feat: add growth tracking page and related functionality
- Implemented a new Growth page to track practice time and trends. - Added API integration for fetching review data. - Created components for displaying practice statistics and trends. - Updated navigation titles for the main index and growth pages. - Removed unused styles from the index page. - Introduced a Projects management page for adding and editing practice projects. - Developed a Record form for logging practice sessions with validation. - Added utility functions for date manipulation and duration formatting. - Implemented error handling and session management in the practice service. - Created unit tests for the practice service to ensure reliability.
This commit is contained in:
@@ -10,7 +10,8 @@
|
||||
"scripts": {
|
||||
"dev": "taro build --type weapp --watch",
|
||||
"build": "taro build --type weapp",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:client": "node --test tests/practice.test.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4",
|
||||
|
||||
@@ -8,6 +8,29 @@
|
||||
"es6": false,
|
||||
"minified": true,
|
||||
"postcss": false,
|
||||
"urlCheck": true
|
||||
}
|
||||
}
|
||||
"urlCheck": true,
|
||||
"compileWorklet": false,
|
||||
"uglifyFileName": false,
|
||||
"uploadWithSourceMap": true,
|
||||
"enhance": false,
|
||||
"packNpmManually": false,
|
||||
"packNpmRelationList": [],
|
||||
"minifyWXSS": true,
|
||||
"minifyWXML": true,
|
||||
"localPlugins": false,
|
||||
"disableUseStrict": false,
|
||||
"useCompilerPlugins": false,
|
||||
"condition": false,
|
||||
"babelSetting": {
|
||||
"ignore": [],
|
||||
"disablePlugins": [],
|
||||
"outputPath": ""
|
||||
}
|
||||
},
|
||||
"simulatorPluginLibVersion": {},
|
||||
"packOptions": {
|
||||
"ignore": [],
|
||||
"include": []
|
||||
},
|
||||
"editorSetting": {}
|
||||
}
|
||||
@@ -1,10 +1,17 @@
|
||||
export default defineAppConfig({
|
||||
pages: ['pages/index/index'],
|
||||
pages: ['pages/index/index', 'pages/calendar/index', 'pages/growth/index', 'pages/record/index', 'pages/projects/index'],
|
||||
tabBar: {
|
||||
color: '#817573', selectedColor: '#9c5268', backgroundColor: '#fffdfa', borderStyle: 'white',
|
||||
list: [
|
||||
{ pagePath: 'pages/index/index', text: '记录' },
|
||||
{ pagePath: 'pages/calendar/index', text: '日历' },
|
||||
{ pagePath: 'pages/growth/index', text: '成长' },
|
||||
],
|
||||
},
|
||||
window: {
|
||||
navigationBarTitleText: 'Ballet Island',
|
||||
navigationBarBackgroundColor: '#ffffff',
|
||||
navigationBarTitleText: '芭蕾岛',
|
||||
navigationBarBackgroundColor: '#faf7f3',
|
||||
navigationBarTextStyle: 'black',
|
||||
backgroundColor: '#f7f8fa',
|
||||
backgroundColor: '#faf7f3',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
+102
-4
@@ -1,7 +1,105 @@
|
||||
page {
|
||||
background: #f7f8fa;
|
||||
color: #323233;
|
||||
background: #faf7f3;
|
||||
color: #3e3435;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif;
|
||||
font-size: 16px;
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
--primary-color: #9c5268;
|
||||
--button-primary-background-color: #9c5268;
|
||||
--button-primary-border-color: #9c5268;
|
||||
--button-border-radius: 12px;
|
||||
--button-default-color: #655656;
|
||||
--button-default-border-color: #e5dcd6;
|
||||
--button-default-background-color: #fffdfa;
|
||||
// Match this project's 375px design width; Taroify defaults use 750px dimensions.
|
||||
--button-border-width: 1px;
|
||||
--button-height-small: 32px;
|
||||
--button-padding-small: 0 8px;
|
||||
--button-font-size-small: 13px;
|
||||
--button-height-medium: 44px;
|
||||
--button-padding-medium: 0 16px;
|
||||
--button-font-size-medium: 16px;
|
||||
--button-loading-icon-size: 20px;
|
||||
--button-content-gap: 4px;
|
||||
}
|
||||
|
||||
view, text, input, textarea { box-sizing: border-box; }
|
||||
.page { padding: 20px 18px 32px; padding-bottom: calc(32px + env(safe-area-inset-bottom)); }
|
||||
.page-heading { margin: 4px 2px 24px; }
|
||||
.eyebrow { display: block; color: #946d76; font-size: 10px; letter-spacing: 2px; margin-bottom: 8px; }
|
||||
.page-title { display: block; margin-bottom: 8px; font-size: 26px; font-weight: 600; line-height: 1.35; letter-spacing: -0.5px; }
|
||||
.muted { color: #827572; font-size: 13px; }
|
||||
.small { font-size: 12px; }
|
||||
.block { display: block; }
|
||||
.panel { padding: 18px; background: #fffdfa; border: 1px solid #ece3dd; border-radius: 18px; }
|
||||
.hero-card { padding: 23px 22px; border: 1px solid #eddfda; background: #f1e7e3; border-radius: 21px; }
|
||||
.hero-number { display: block; color: #864b5e; font-size: 29px; font-weight: 600; line-height: 1.5; margin: 4px 0 8px; overflow-wrap: anywhere; }
|
||||
.section-heading, .row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.section-heading { margin: 24px 2px 12px; }
|
||||
.section-title { font-size: 17px; font-weight: 600; }
|
||||
.project-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||
.project-tile { border: 1px solid #e7ded8; border-radius: 17px; min-height: 126px; padding: 15px 16px; background: #f4ece8; }
|
||||
.project-tile--1 { background: #eef0e7; border-color: #e0e5d9; }
|
||||
.project-tile--2 { background: #eeeaf2; border-color: #e3dfea; }
|
||||
.project-tile--3 { background: #f4efdf; border-color: #eae4d5; }
|
||||
.project-mark { display: block; color: #9a8583; font-size: 10px; letter-spacing: 1px; margin-bottom: 10px; }
|
||||
.project-title { display: block; font-size: 17px; font-weight: 600; overflow-wrap: anywhere; }
|
||||
.project-hint { display: block; font-size: 11px; color: #8b7476; margin-top: 8px; }
|
||||
.record-card { padding: 16px 18px; background: #fffdfa; border: 1px solid #ece3dd; border-radius: 16px; margin-bottom: 10px; }
|
||||
.record-name { font-size: 15px; font-weight: 500; overflow-wrap: anywhere; flex: 1; min-width: 0; }
|
||||
.record-duration { color: #9c5268; font-size: 13px; text-align: right; max-width: 45%; }
|
||||
.record-note { display: block; white-space: pre-wrap; overflow-wrap: anywhere; padding-top: 12px; border-top: 1px solid #f0e9e3; font-size: 13px; color: #756364; }
|
||||
.empty { text-align: center; padding: 20px 4px; }
|
||||
.empty-title { display: block; font-size: 16px; font-weight: 500; margin-bottom: 10px; }
|
||||
.empty .muted { display: block; line-height: 1.8; }
|
||||
.status-panel { display: flex; flex-direction: column; gap: 16px; align-items: center; margin-bottom: 16px; }
|
||||
.error-text { color: #a43b4b; }
|
||||
.error-banner, .success-banner { display: block; border-radius: 12px; padding: 13px 15px; margin: 14px 0; font-size: 13px; line-height: 1.7; overflow-wrap: anywhere; }
|
||||
.error-banner { color: #9d3748; background: #fae9e9; }
|
||||
.success-banner { color: #4f6b4c; background: #eaf0e7; }
|
||||
.form-panel .field-label:first-child { margin-top: 0; }
|
||||
.field-label { display: block; font-size: 14px; font-weight: 500; margin: 22px 0 10px; }
|
||||
.field { display: block; width: 100%; background: #f8f4ef; border: 1px solid #e8dfd8; border-radius: 11px; padding: 12px 14px; height: 48px; font-size: 16px; color: #44383b; }
|
||||
.picker-field { display: flex; justify-content: space-between; height: auto; min-height: 48px; overflow-wrap: anywhere; gap: 8px; }
|
||||
.quick-minutes { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
|
||||
.note-input { min-height: 140px; height: 140px; line-height: 1.6; }
|
||||
.field-counter { display: block; text-align: right; color: #968983; font-size: 11px; margin-top: 6px; }
|
||||
.field-help { display: block; color: #877872; font-size: 12px; margin: 10px 0 14px; line-height: 1.7; }
|
||||
.form-actions { margin-top: 22px; }
|
||||
.project-name-input { margin-top: 16px; }
|
||||
.page-footnote { display: block; text-align: center; color: #998a82; font-size: 11px; line-height: 1.8; margin: 24px 12px 8px; }
|
||||
.managed-project { padding: 12px 0; border-bottom: 1px solid #f0e8e1; }
|
||||
.managed-project:first-child { padding-top: 0; }
|
||||
.managed-project:last-child { padding-bottom: 0; border-bottom: 0; }
|
||||
.managed-project > .record-name { display: block; }
|
||||
.managed-project > .row { align-items: flex-end; }
|
||||
.inline-actions { display: flex; flex-shrink: 0; }
|
||||
.month-nav { display: flex; align-items: center; justify-content: space-between; gap: 4px; margin: 0 -8px 12px; }
|
||||
.calendar-panel { padding: 16px 12px; }
|
||||
.calendar-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); text-align: center; }
|
||||
.weekday-row { color: #a39790; font-size: 12px; margin: 12px 0 8px; }
|
||||
.calendar-day { height: 47px; border-radius: 13px; display: flex; flex-direction: column; align-items: center; justify-content: center; margin: 2px 1px; font-size: 14px; }
|
||||
.calendar-day--selected { color: #fffdfa; background: #9c5268; }
|
||||
.calendar-day--future { color: #c7bdb7; }
|
||||
.practice-dot { width: 4px; height: 4px; border-radius: 50%; margin-top: 4px; background: transparent; }
|
||||
.practice-dot--visible { background: #bd7d8e; }
|
||||
.calendar-day--selected .practice-dot--visible { background: #fffdfa; }
|
||||
.calendar-footer { display: flex; justify-content: space-between; align-items: center; border-top: 1px solid #eee5df; margin-top: 14px; padding: 8px 4px 0; }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 12px 0 22px; }
|
||||
.stat-number { display: block; color: #675759; font-size: 30px; font-weight: 600; }
|
||||
.stat-unit { font-size: 12px; font-weight: 400; }
|
||||
.trend-panel .section-heading { margin: 0 0 14px; }
|
||||
.segmented { display: flex; padding: 3px; background: #f1ebe5; border-radius: 12px; }
|
||||
.trend-total { display: block; font-size: 18px; color: #955367; margin: 14px 0; }
|
||||
.chart { display: flex; gap: 9px; align-items: flex-end; height: 147px; margin-top: 22px; }
|
||||
.chart-column { display: flex; flex: 1; min-width: 0; flex-direction: column; align-items: center; height: 100%; }
|
||||
.chart-track { height: 120px; width: 100%; display: flex; align-items: flex-end; border-bottom: 1px solid #e9ded7; }
|
||||
.chart-bar { width: 100%; border-radius: 5px 5px 0 0; background: #d8a8b2; }
|
||||
.chart-bar--focused { background: #9c5268; }
|
||||
.chart-label { font-size: 10px; color: #a18f87; margin-top: 6px; }
|
||||
.chart--month { gap: 3px; }
|
||||
.chart--month .chart-label { font-size: 8px; }
|
||||
.chart--month .chart-column:nth-child(2n) .chart-label { visibility: hidden; }
|
||||
.distribution { margin-bottom: 20px; }
|
||||
.distribution:last-child { margin-bottom: 0; }
|
||||
.distribution-track { height: 7px; border-radius: 7px; background: #f0e8e2; margin: 10px 0 6px; overflow: hidden; }
|
||||
.distribution-bar { height: 100%; border-radius: 7px; background: #bc8292; }
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import Taro from '@tarojs/taro'
|
||||
import { Text, View } from '@tarojs/components'
|
||||
import Button from '@taroify/core/button'
|
||||
import '@taroify/core/button/style'
|
||||
import type { PracticeRecord } from '../services/practice'
|
||||
import { duration } from '../utils/dates'
|
||||
|
||||
export { Button }
|
||||
|
||||
export function PageHeading({ title, subtitle }: { title: string; subtitle: string }) {
|
||||
return <View className='page-heading'><Text className='eyebrow'>BALLET ISLAND</Text><Text className='page-title'>{title}</Text><Text className='muted'>{subtitle}</Text></View>
|
||||
}
|
||||
export function LoadState({ loading, error, retry }: { loading: boolean; error: string; retry: () => unknown }) {
|
||||
if (!loading && !error) return null
|
||||
return <View className='panel status-panel'><Text className={error ? 'error-text' : 'muted'}>{loading ? '正在读取你的练习…' : error}</Text>{error && <Button size='small' variant='outlined' onClick={() => { void retry() }}>重新加载</Button>}</View>
|
||||
}
|
||||
export function Empty({ title, text }: { title: string; text: string }) {
|
||||
return <View className='empty'><Text className='empty-title'>{title}</Text><Text className='muted'>{text}</Text></View>
|
||||
}
|
||||
export function RecordCards({ records }: { records: PracticeRecord[] }) {
|
||||
return <View>{records.map(record => <View className='record-card' key={record.id}>
|
||||
<View className='row'><Text className='record-name'>{record.projectName}</Text><Text className='record-duration'>{duration(record.minutes)}</Text></View>
|
||||
<View className='row'><Text className='muted small'>{record.date}{record.archived ? ' · 已归档项目' : ''}</Text><Button size='small' variant='text' onClick={() => { void Taro.navigateTo({ url: `/pages/record/index?id=${record.id}` }) }}>更正</Button></View>
|
||||
{record.note && <Text className='record-note'>{record.note}</Text>}
|
||||
</View>)}</View>
|
||||
}
|
||||
|
||||
/** Return to the actual originating page; deep-entry forms fall back to records. */
|
||||
export async function returnToSource(message?: string) {
|
||||
if (Taro.getCurrentPages().length > 1) await Taro.navigateBack()
|
||||
else await Taro.switchTab({ url: '/pages/index/index' })
|
||||
if (message) void Taro.showToast({ title: message, icon: 'none' })
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useCallback, useEffect, useRef, useState, type DependencyList } from 'react'
|
||||
import { useDidHide, useDidShow, useUnload } from '@tarojs/taro'
|
||||
import { errorMessage, onPracticeChange } from '../services/practice'
|
||||
|
||||
/** Each load owns a sequence number. Late responses from a previous date,
|
||||
* hidden page, or pre-save request cannot overwrite newer visible results. */
|
||||
export function useRemote<T>(loader: () => Promise<T>, dependencies: DependencyList = []) {
|
||||
const load = useRef(loader)
|
||||
load.current = loader
|
||||
const sequence = useRef(0)
|
||||
const visible = useRef(true)
|
||||
const [state, setState] = useState<{ data?: T; loading: boolean; error: string }>({ loading: true, error: '' })
|
||||
const reload = useCallback(async () => {
|
||||
const version = ++sequence.current
|
||||
setState({ loading: true, error: '' })
|
||||
try {
|
||||
const data = await load.current()
|
||||
if (version === sequence.current) setState({ data, loading: false, error: '' })
|
||||
} catch (error) {
|
||||
if (version === sequence.current) setState({ loading: false, error: errorMessage(error) })
|
||||
}
|
||||
}, [])
|
||||
useEffect(() => { void reload(); return () => { sequence.current++ } }, [reload, ...dependencies])
|
||||
useEffect(() => onPracticeChange(() => { if (visible.current) void reload() }), [reload])
|
||||
useDidShow(() => { visible.current = true; void reload() })
|
||||
useDidHide(() => { visible.current = false; sequence.current++ })
|
||||
useUnload(() => { visible.current = false; sequence.current++ })
|
||||
return { ...state, reload }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export default definePageConfig({ navigationBarTitleText: '芭蕾岛 · 日历' })
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import Taro, { useDidHide } from '@tarojs/taro'
|
||||
import { Picker, Text, View } from '@tarojs/components'
|
||||
import { Button, Empty, LoadState, PageHeading, RecordCards } from '../../components/common'
|
||||
import { useRemote } from '../../hooks/useRemote'
|
||||
import { api, errorMessage, type RecordList } from '../../services/practice'
|
||||
import { addMonths, displayDate, duration, monthLabel, monthStart, parseDate, shanghaiToday } from '../../utils/dates'
|
||||
|
||||
export default function Calendar() {
|
||||
const [selected, setSelected] = useState(shanghaiToday())
|
||||
const [today, setToday] = useState(shanghaiToday())
|
||||
const month = monthStart(selected)
|
||||
const state = useRemote(async () => {
|
||||
const [review, records] = await Promise.all([api.review('month', selected), api.records({ from: selected, to: selected, limit: 100 })])
|
||||
return { review, records }
|
||||
}, [selected])
|
||||
const [list, setList] = useState<RecordList>()
|
||||
const [moreError, setMoreError] = useState('')
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const pageSequence = useRef(0)
|
||||
const moreBusy = useRef(false)
|
||||
useEffect(() => {
|
||||
pageSequence.current++; moreBusy.current = false; setLoadingMore(false); setMoreError('')
|
||||
setList(state.data?.records)
|
||||
if (state.data) setToday(state.data.review.today)
|
||||
}, [state.data])
|
||||
useDidHide(() => { pageSequence.current++ })
|
||||
const review = state.data?.review
|
||||
const offset = (parseDate(month).getUTCDay() + 6) % 7
|
||||
const total = review?.days.find(day => day.date === selected)?.minutes || 0
|
||||
|
||||
async function more() {
|
||||
if (!list?.nextCursor || moreBusy.current) return
|
||||
const version = pageSequence.current
|
||||
moreBusy.current = true; setLoadingMore(true); setMoreError('')
|
||||
try {
|
||||
const next = await api.records({ from: selected, to: selected, limit: 100, cursor: list.nextCursor })
|
||||
if (version === pageSequence.current) setList({ records: [...list.records, ...next.records], nextCursor: next.nextCursor })
|
||||
} catch (error) { if (version === pageSequence.current) setMoreError(errorMessage(error)) }
|
||||
finally { if (version === pageSequence.current) { moreBusy.current = false; setLoadingMore(false) } }
|
||||
}
|
||||
|
||||
return <View className='page'>
|
||||
<PageHeading title='练习留在日子里' subtitle='有练习的日子做个记号,休息日就留白。' />
|
||||
<View className='panel calendar-panel'>
|
||||
<View className='month-nav'>
|
||||
<Button variant='text' size='small' disabled={month <= '0001-01-01'} onClick={() => setSelected(addMonths(month, -1))}>上月</Button>
|
||||
<Picker mode='date' fields='month' value={selected} start='0001-01-01' end={today} onChange={event => setSelected(`${event.detail.value.slice(0, 7)}-01`)}><Text className='section-title'>{monthLabel(month)} ⌄</Text></Picker>
|
||||
<Button variant='text' size='small' disabled={month >= monthStart(today)} onClick={() => setSelected(addMonths(month, 1))}>下月</Button>
|
||||
</View>
|
||||
<View className='calendar-grid weekday-row'>{['一', '二', '三', '四', '五', '六', '日'].map(day => <Text key={day}>{day}</Text>)}</View>
|
||||
<LoadState {...state} retry={state.reload} />
|
||||
{review && <>
|
||||
<View className='calendar-grid'>{Array.from({ length: offset }, (_, i) => <View key={`blank-${i}`} />)}{review.days.map(day => <View key={day.date} className={`calendar-day ${selected === day.date ? 'calendar-day--selected' : ''} ${day.date > today ? 'calendar-day--future' : ''}`} onClick={() => { if (day.date <= today) setSelected(day.date) }}>
|
||||
<Text>{Number(day.date.slice(8))}</Text><View className={`practice-dot ${day.minutes ? 'practice-dot--visible' : ''}`} />
|
||||
</View>)}</View>
|
||||
<View className='calendar-footer'><Text className='muted small'>圆点表示这天有练习</Text><Button variant='text' size='small' onClick={() => setSelected(today)}>回到今天</Button></View>
|
||||
</>}
|
||||
</View>
|
||||
{review && list && <>
|
||||
<View className='section-heading'><View><Text className='section-title'>{displayDate(selected)}</Text><Text className='muted small block'>共 {duration(total)}</Text></View><Button color='primary' size='small' onClick={() => { void Taro.navigateTo({ url: `/pages/record/index?date=${selected}` }) }}>补记练习</Button></View>
|
||||
{list.records.length ? <RecordCards records={list.records} /> : <View className='panel'><Empty title='这一天还没有记录' text='休息也是日常。漏记了一堂课,也可以补在这天。' /></View>}
|
||||
{moreError && <Text className='error-banner'>{moreError}</Text>}
|
||||
{list.nextCursor && <Button block variant='outlined' loading={loadingMore} disabled={loadingMore} onClick={more}>查看当天更多记录</Button>}
|
||||
</>}
|
||||
</View>
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export default definePageConfig({ navigationBarTitleText: '芭蕾岛 · 成长' })
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useState } from 'react'
|
||||
import { Text, View } from '@tarojs/components'
|
||||
import { Button, Empty, LoadState, PageHeading } from '../../components/common'
|
||||
import { useRemote } from '../../hooks/useRemote'
|
||||
import { api } from '../../services/practice'
|
||||
import { addDays, addMonths, duration, monthStart, shanghaiToday } from '../../utils/dates'
|
||||
|
||||
export default function Growth() {
|
||||
const [period, setPeriod] = useState<'week' | 'month'>('week')
|
||||
const [anchor, setAnchor] = useState(shanghaiToday())
|
||||
const state = useRemote(() => api.review(period, anchor), [period, anchor])
|
||||
const review = state.data
|
||||
const max = Math.max(1, ...(review?.days.map(day => day.minutes) || []))
|
||||
const [focused, setFocused] = useState('')
|
||||
const focus = review?.days.find(day => day.date === focused)
|
||||
const move = (direction: number) => {
|
||||
const next = period === 'week' ? addDays(review!.from, direction * 7) : addMonths(review!.from, direction)
|
||||
if (next >= '0001-01-01' && next <= review!.today) { setFocused(''); setAnchor(next) }
|
||||
}
|
||||
const currentPeriod = review && (period === 'week' ? review.to >= review.today : monthStart(review.from) === monthStart(review.today))
|
||||
return <View className='page'>
|
||||
<PageHeading title='把投入慢慢积累' subtitle='回看练习的时间,也回看自己的节奏。' />
|
||||
<LoadState {...state} retry={state.reload} />
|
||||
{review && <>
|
||||
<View className='hero-card'><Text className='eyebrow'>累计练习</Text><Text className='hero-number'>{duration(review.allTime.totalMinutes)}</Text><Text className='muted'>每一分钟,都来自你留下的真实记录。</Text></View>
|
||||
<View className='stats-grid'><View className='panel'><Text className='stat-number'>{review.allTime.practiceDays}<Text className='stat-unit'> 天</Text></Text><Text className='muted'>练习天数</Text></View><View className='panel'><Text className='stat-number'>{review.allTime.recordCount}<Text className='stat-unit'> 次</Text></Text><Text className='muted'>练习记录</Text></View></View>
|
||||
<View className='panel trend-panel'>
|
||||
<View className='section-heading'><Text className='section-title'>时长趋势</Text><View className='segmented'>{(['week', 'month'] as const).map(value => <Button key={value} size='small' variant={period === value ? 'contained' : 'text'} color={period === value ? 'primary' : 'default'} onClick={() => { setPeriod(value); setFocused('') }}>{value === 'week' ? '按周' : '按月'}</Button>)}</View></View>
|
||||
<View className='month-nav'><Button size='small' variant='text' disabled={review.from <= '0001-01-01'} onClick={() => move(-1)}>上一{period === 'week' ? '周' : '月'}</Button><Text className='small'>{review.from} — {review.to.slice(5)}</Text><Button size='small' variant='text' disabled={!!currentPeriod} onClick={() => move(1)}>下一{period === 'week' ? '周' : '月'}</Button></View>
|
||||
<Text className='trend-total'>{duration(review.period.totalMinutes)}<Text className='muted small'> · {review.period.practiceDays} 个练习日</Text></Text>
|
||||
<View className={`chart chart--${period}`}>{review.days.map(day => <View key={day.date} className='chart-column' onClick={() => setFocused(day.date)}>
|
||||
<View className='chart-track'><View className={`chart-bar ${day.date === focused ? 'chart-bar--focused' : ''}`} style={{ height: `${day.minutes / max * 100}%` }} /></View><Text className='chart-label'>{Number(day.date.slice(8))}</Text>
|
||||
</View>)}</View>
|
||||
<Text className='field-help'>{focus ? `${focus.date} · ${duration(focus.minutes)}` : '点按柱形查看当天时长;空白表示没有记录。'}</Text>
|
||||
{!currentPeriod && <Button block variant='text' size='small' onClick={() => setAnchor(review.today)}>回到本{period === 'week' ? '周' : '月'}</Button>}
|
||||
</View>
|
||||
<View className='section-heading'><Text className='section-title'>时间花在哪里</Text><Text className='muted small'>全部练习</Text></View>
|
||||
<View className='panel'>{review.allTime.projects.length ? review.allTime.projects.map(project => <View className='distribution' key={project.projectId}>
|
||||
<View className='row'><Text className='record-name'>{project.name}{project.archived ? ' · 已归档' : ''}</Text><Text className='small'>{(project.minutes / review.allTime.totalMinutes * 100).toFixed(1)}%</Text></View>
|
||||
<View className='distribution-track'><View className='distribution-bar' style={{ width: `${project.minutes / review.allTime.totalMinutes * 100}%` }} /></View><Text className='muted small'>{duration(project.minutes)}</Text>
|
||||
</View>) : <Empty title='第一笔投入,还在前面' text='保存练习后,这里会如实显示时长和项目分布。' />}</View>
|
||||
<Text className='page-footnote'>时长记录的是投入,不是技术水平。慢慢来,按自己的节奏练习。</Text>
|
||||
</>}
|
||||
</View>
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: 'Ballet Island',
|
||||
navigationBarTitleText: '芭蕾岛 · 记录',
|
||||
})
|
||||
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
.welcome {
|
||||
margin: 32px 20px;
|
||||
padding: 28px 24px;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
|
||||
&__title {
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__message {
|
||||
display: block;
|
||||
margin-bottom: 28px;
|
||||
color: #646566;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +1,29 @@
|
||||
import { useState } from 'react'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { Text, View } from '@tarojs/components'
|
||||
import Button from '@taroify/core/button'
|
||||
import '@taroify/core/button/style'
|
||||
import { checkServiceReady } from '../../services/health'
|
||||
import './index.scss'
|
||||
import { Button, Empty, LoadState, PageHeading, RecordCards } from '../../components/common'
|
||||
import { useRemote } from '../../hooks/useRemote'
|
||||
import { api } from '../../services/practice'
|
||||
import { duration } from '../../utils/dates'
|
||||
|
||||
export default function Index() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [message, setMessage] = useState('欢迎来到 Ballet Island')
|
||||
|
||||
async function handleCheck() {
|
||||
setLoading(true)
|
||||
try {
|
||||
await checkServiceReady()
|
||||
setMessage('连接成功,服务已就绪')
|
||||
} catch {
|
||||
setMessage('暂时无法连接,请稍后重试')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="welcome">
|
||||
<Text className="welcome__title">Ballet Island</Text>
|
||||
<Text className="welcome__message">{message}</Text>
|
||||
<Button color="primary" block loading={loading} disabled={loading} onClick={handleCheck}>
|
||||
检查连接
|
||||
</Button>
|
||||
</View>
|
||||
)
|
||||
const state = useRemote(async () => {
|
||||
const [projects, recent, review] = await Promise.all([api.projects(), api.records(), api.review('week')])
|
||||
return { ...projects, recent, review }
|
||||
})
|
||||
const data = state.data
|
||||
const todayMinutes = data?.review.days.find(day => day.date === data.today)?.minutes || 0
|
||||
return <View className='page'>
|
||||
<PageHeading title='每一次练习,都算数' subtitle='课后记下一笔,慢慢看见自己的投入。' />
|
||||
<LoadState {...state} retry={state.reload} />
|
||||
{data && <>
|
||||
<View className='hero-card'><Text className='eyebrow'>今天的练习</Text><Text className='hero-number'>{duration(todayMinutes)}</Text><Text className='muted'>{data.today} · 按实际练习日期记录</Text></View>
|
||||
<View className='section-heading'><Text className='section-title'>这次练了什么</Text><Button size='small' variant='text' onClick={() => { void Taro.navigateTo({ url: '/pages/projects/index' }) }}>管理项目</Button></View>
|
||||
{data.projects.length ? <View className='project-grid'>{data.projects.map((project, index) => <View key={project.id} className={`project-tile project-tile--${index % 4}`} onClick={() => { void Taro.navigateTo({ url: `/pages/record/index?projectId=${project.id}&date=${data.today}` }) }}>
|
||||
<Text className='project-mark'>{String(index + 1).padStart(2, '0')}</Text><Text className='project-title'>{project.name}</Text><Text className='project-hint'>记录练习 +</Text>
|
||||
</View>)}</View> : <View className='panel'><Empty title='常用项目暂时为空' text='历史和成长仍会保留。添加一个项目,继续记录。' /><Button block color='primary' onClick={() => { void Taro.navigateTo({ url: '/pages/projects/index' }) }}>添加项目</Button></View>}
|
||||
<View className='section-heading'><Text className='section-title'>近期记录</Text><Text className='muted small'>最近 20 条</Text></View>
|
||||
{data.recent.records.length ? <RecordCards records={data.recent.records} /> : <View className='panel'><Empty title='从第一堂课开始' text='选择上面的项目,记录时长;想写的时候,再留下收获。' /></View>}
|
||||
<Button block variant='text' onClick={() => { void Taro.switchTab({ url: '/pages/calendar/index' }) }}>去日历回看或补记</Button>
|
||||
</>}
|
||||
</View>
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export default definePageConfig({ navigationBarTitleText: '管理练习项目' })
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { Input, Text, View } from '@tarojs/components'
|
||||
import { Button, Empty, LoadState, PageHeading } from '../../components/common'
|
||||
import { useRemote } from '../../hooks/useRemote'
|
||||
import { api, errorMessage, NAME_LIMIT, type Project } from '../../services/practice'
|
||||
|
||||
export default function Projects() {
|
||||
const state = useRemote(() => api.projects())
|
||||
const [editing, setEditing] = useState<Project>()
|
||||
const [name, setName] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const busyRef = useRef(false)
|
||||
const [error, setError] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
|
||||
async function save() {
|
||||
if (busyRef.current) return
|
||||
const clean = name.trim()
|
||||
if (!clean || Array.from(clean).length > NAME_LIMIT || clean.includes('\0')) { setError(`项目名称须为 1–${NAME_LIMIT} 字,不能只含空白或包含空字符`); return }
|
||||
busyRef.current = true; setBusy(true); setError(''); setNotice('')
|
||||
try {
|
||||
await api.saveProject(clean, editing?.id)
|
||||
setNotice(editing ? '已改名,历史记录也会使用这个名称。' : '项目已添加。')
|
||||
setName(''); setEditing(undefined)
|
||||
await state.reload()
|
||||
} catch (cause) { setError(errorMessage(cause)) }
|
||||
finally { busyRef.current = false; setBusy(false) }
|
||||
}
|
||||
|
||||
async function remove(project: Project) {
|
||||
if (busyRef.current) return
|
||||
busyRef.current = true; setBusy(true); setError(''); setNotice('')
|
||||
try {
|
||||
const confirmed = await Taro.showModal({
|
||||
title: project.hasRecords ? '移出常用项目?' : '移除这个项目?',
|
||||
content: project.hasRecords ? `“${project.name}”将归档,历史记录、笔记和累计时长都会保留。` : `“${project.name}”没有记录时会被删除。如果已有新记录,将改为归档并保留历史。`,
|
||||
confirmText: '确认移除', cancelText: '保留项目',
|
||||
})
|
||||
if (!confirmed.confirm) return
|
||||
const result = await api.removeProject(project.id)
|
||||
if (editing?.id === project.id) { setEditing(undefined); setName('') }
|
||||
setNotice(result.action === 'archived' ? '已归档,历史记录和时长均已保留。' : '未使用的项目已删除。')
|
||||
await state.reload()
|
||||
} catch (cause) { setError(errorMessage(cause)) }
|
||||
finally { busyRef.current = false; setBusy(false) }
|
||||
}
|
||||
|
||||
return <View className='page'>
|
||||
<PageHeading title='用你熟悉的名字' subtitle='课程会变化,留下的练习一直都在。' />
|
||||
<View className='panel form-panel'>
|
||||
<Text className='section-title'>{editing ? '修改项目名称' : '添加练习项目'}</Text>
|
||||
<Input className='field project-name-input' value={name} maxlength={-1} disabled={busy} placeholder='例如:周末基础课' onInput={event => setName(event.detail.value)} />
|
||||
<Text className='field-help'>最多 {NAME_LIMIT} 字。改名后,历史记录会统一显示新名称。</Text>
|
||||
<Button block color='primary' loading={busy} disabled={busy} onClick={save}>{editing ? '保存名称' : '添加项目'}</Button>
|
||||
{editing && <Button block variant='text' disabled={busy} onClick={() => { setEditing(undefined); setName(''); setError('') }}>取消改名</Button>}
|
||||
</View>
|
||||
{error && <Text className='error-banner'>{error}</Text>}
|
||||
{notice && <Text className='success-banner'>{notice}</Text>}
|
||||
<View className='section-heading'><Text className='section-title'>常用项目</Text><Text className='muted small'>{state.data ? `${state.data.projects.length} 个` : ''}</Text></View>
|
||||
<LoadState {...state} retry={state.reload} />
|
||||
{state.data && <View className='panel'>
|
||||
{state.data.projects.length ? state.data.projects.map(project => <View className='managed-project' key={project.id}>
|
||||
<Text className='record-name'>{project.name}</Text><View className='row'><Text className='muted small'>{project.hasRecords ? '已有练习 · 移除会保留历史' : '尚无练习记录'}</Text><View className='inline-actions'><Button size='small' variant='text' disabled={busy} onClick={() => { setEditing(project); setName(project.name); setError(''); void Taro.pageScrollTo({ scrollTop: 0, duration: 200 }) }}>改名</Button><Button size='small' color='danger' variant='text' disabled={busy} onClick={() => remove(project)}>移除</Button></View></View>
|
||||
</View>) : <Empty title='还没有常用项目' text='可以在上方添加项目,之前的历史仍可在日历里查看。' />}
|
||||
</View>}
|
||||
<Text className='page-footnote'>有练习记录的项目会归档,不会删除历史。归档项目仍计入成长,也能从原记录进入更正。</Text>
|
||||
</View>
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export default definePageConfig({ navigationBarTitleText: '练习记录' })
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import Taro, { useDidHide, useDidShow, useRouter, useUnload } from '@tarojs/taro'
|
||||
import { Input, Picker, Text, Textarea, View } from '@tarojs/components'
|
||||
import { Button, LoadState, PageHeading, returnToSource } from '../../components/common'
|
||||
import { useRemote } from '../../hooks/useRemote'
|
||||
import { api, ApiError, clearPendingRecord, errorMessage, getPendingRecord, MINUTE_LIMIT, NOTE_LIMIT, retainPendingRecord, submissionID, type PendingRecord } from '../../services/practice'
|
||||
import { validDate } from '../../utils/dates'
|
||||
|
||||
export default function RecordForm() {
|
||||
const { params } = useRouter()
|
||||
const resumed = useRef(getPendingRecord())
|
||||
const id = resumed.current ? resumed.current.id : params.id
|
||||
const initialised = useRef(false)
|
||||
const busyRef = useRef(false)
|
||||
const pending = useRef<PendingRecord | undefined>(resumed.current)
|
||||
const visible = useRef(true)
|
||||
useDidShow(() => { visible.current = true })
|
||||
useDidHide(() => { visible.current = false })
|
||||
useUnload(() => { visible.current = false })
|
||||
const key = useRef(submissionID())
|
||||
const state = useRemote(async () => {
|
||||
const [projects, record] = await Promise.all([api.projects(true), id && !resumed.current ? api.record(id) : Promise.resolve(undefined)])
|
||||
return { ...projects, record }
|
||||
}, [id])
|
||||
const [context, setContext] = useState<typeof state.data>()
|
||||
const [projectId, setProjectId] = useState('')
|
||||
const [date, setDate] = useState('')
|
||||
const [minutes, setMinutes] = useState('')
|
||||
const [note, setNote] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [uncertain, setUncertain] = useState(!!resumed.current)
|
||||
const [error, setError] = useState('')
|
||||
const [done, setDone] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.data) return
|
||||
const data = state.data
|
||||
setContext(data)
|
||||
if (initialised.current) return
|
||||
initialised.current = true
|
||||
const draft = resumed.current?.input
|
||||
setProjectId(draft?.projectId || data.record?.projectId || params.projectId || '')
|
||||
setDate(draft?.date || data.record?.date || (params.date && validDate(params.date) && params.date <= data.today ? params.date : data.today))
|
||||
setMinutes(draft ? String(draft.minutes) : data.record ? String(data.record.minutes) : '')
|
||||
setNote(draft?.note ?? data.record?.note ?? '')
|
||||
}, [state.data, params.projectId, params.date])
|
||||
|
||||
const choices = context?.projects.filter(project => !project.archived || project.id === context.record?.projectId || project.id === resumed.current?.input.projectId) || []
|
||||
const selected = choices.find(project => project.id === projectId)
|
||||
const locked = busy || uncertain
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible.current) return
|
||||
if (uncertain || (busy && pending.current)) Taro.enableAlertBeforeUnload({ message: '保存尚未确认,请先重试。返回后再次登记会恢复原输入。', fail: () => {} })
|
||||
else Taro.disableAlertBeforeUnload({ fail: () => {} })
|
||||
}, [busy, uncertain])
|
||||
|
||||
async function finish(message: string) {
|
||||
if (!visible.current) return
|
||||
Taro.disableAlertBeforeUnload({ fail: () => {} })
|
||||
setDone(message)
|
||||
try { await returnToSource(message) } catch { setError('已保存结果,可点击下方按钮返回') }
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (busyRef.current || done || !context) return
|
||||
if (!pending.current) {
|
||||
if (!selected) { setError('请选择一个可用项目'); return }
|
||||
if (!validDate(date) || date > context.today) { setError('请选择有效的实际练习日期,不能晚于今天'); return }
|
||||
if (!/^\d+$/.test(minutes) || Number(minutes) < 1 || Number(minutes) > MINUTE_LIMIT) { setError(`时长须为 1–${MINUTE_LIMIT} 的整数分钟`); return }
|
||||
if (Array.from(note).length > NOTE_LIMIT || note.includes('\0')) { setError(`笔记最多 ${NOTE_LIMIT} 字,不能包含空字符`); return }
|
||||
pending.current = { id, input: { projectId, date, minutes: Number(minutes), note, ...(id ? {} : { requestId: key.current }) } }
|
||||
}
|
||||
const attempt = pending.current
|
||||
retainPendingRecord(attempt)
|
||||
busyRef.current = true; setBusy(true); setError('')
|
||||
try {
|
||||
await api.saveRecord(attempt.input, id)
|
||||
clearPendingRecord(attempt)
|
||||
setUncertain(false)
|
||||
await finish(id ? '记录已更正' : '练习已保存')
|
||||
} catch (cause) {
|
||||
const unknownResult = !(cause instanceof ApiError) || cause.status === 0 || cause.status >= 500
|
||||
setUncertain(unknownResult)
|
||||
// Freeze the submitted values after an uncertain result, keeping the exact
|
||||
// payload and key until retry confirms persistence. Never silently create another entry.
|
||||
if (!unknownResult) { clearPendingRecord(attempt); pending.current = undefined }
|
||||
setError(unknownResult ? `${errorMessage(cause)}。本次结果尚未确认,请重试确认;填写内容已保留。` : errorMessage(cause))
|
||||
} finally { busyRef.current = false; setBusy(false) }
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!id || busyRef.current || uncertain) return
|
||||
busyRef.current = true; setBusy(true)
|
||||
try {
|
||||
const result = await Taro.showModal({ title: '删除这条误记?', content: '这条记录的时长和笔记将被删除,日历与成长统计会一起更新。', confirmText: '删除记录', confirmColor: '#a23d4c', cancelText: '保留记录' })
|
||||
if (!result.confirm) return
|
||||
setError('')
|
||||
await api.deleteRecord(id)
|
||||
await finish('记录已删除')
|
||||
} catch (cause) { setError(`${errorMessage(cause)}。删除结果未确认,可重试删除。`) }
|
||||
finally { busyRef.current = false; setBusy(false) }
|
||||
}
|
||||
|
||||
return <View className='page form-page'>
|
||||
<PageHeading title={id ? '更正这次练习' : '记下一次练习'} subtitle='一堂课记一笔,额外练习也可以单独记。' />
|
||||
{!context && <LoadState {...state} retry={state.reload} />}
|
||||
{context && !done && <>
|
||||
{resumed.current && uncertain && <Text className='error-banner'>上次保存尚未确认,已恢复当时的项目、日期和填写内容。请先重试确认这次保存。</Text>}
|
||||
<View className='panel form-panel'>
|
||||
<Text className='field-label'>练习项目</Text>
|
||||
<Picker mode='selector' range={['请选择项目', ...choices.map(project => `${project.name}${project.archived ? '(已归档)' : ''}`)]} value={choices.findIndex(project => project.id === projectId) + 1} disabled={locked || !choices.length} onChange={event => setProjectId(choices[Number(event.detail.value) - 1]?.id || '')}>
|
||||
<View className='field picker-field'>{selected ? `${selected.name}${selected.archived ? '(已归档)' : ''}` : '请选择项目'}<Text>⌄</Text></View>
|
||||
</Picker>
|
||||
{!choices.length && <View className='field-help'><Text>先添加一个项目,就能开始记录。</Text><Button size='small' variant='text' onClick={() => { void Taro.navigateTo({ url: '/pages/projects/index' }) }}>添加项目</Button></View>}
|
||||
{selected?.archived && <Text className='field-help'>这条历史记录可以保留原项目,也可改选常用项目。</Text>}
|
||||
<Text className='field-label'>实际练习日期</Text>
|
||||
<Picker mode='date' value={date || context.today} start='0001-01-01' end={context.today} disabled={locked} onChange={event => setDate(event.detail.value)}><View className='field picker-field'>{date}<Text>⌄</Text></View></Picker>
|
||||
<Text className='field-label'>整堂时长 · 分钟</Text>
|
||||
<Input className='field' type='number' value={minutes} maxlength={10} disabled={locked} placeholder='例如 90' onInput={event => setMinutes(event.detail.value)} />
|
||||
<View className='quick-minutes'>{[30, 45, 60, 90].map(value => <Button key={value} size='small' variant='outlined' disabled={locked} onClick={() => setMinutes(String(value))}>{value} 分钟</Button>)}</View>
|
||||
<Text className='field-label'>练习笔记 <Text className='muted small'>选填</Text></Text>
|
||||
<Textarea className='field note-input' value={note} maxlength={-1} disabled={locked} cursorSpacing={24} placeholder='老师的提醒、今天的感受,想写什么都可以。' onInput={event => setNote(event.detail.value)} />
|
||||
<Text className='field-counter'>{Array.from(note).length} / {NOTE_LIMIT}</Text>
|
||||
</View>
|
||||
{error && <Text className='error-banner'>{error}</Text>}
|
||||
<View className='form-actions'><Button color='primary' block loading={busy} disabled={busy || (!pending.current && !choices.length)} onClick={save}>{uncertain ? '重试并确认保存结果' : '保存练习'}</Button><Button block variant='text' disabled={locked} onClick={() => { void returnToSource() }}>取消并返回</Button></View>
|
||||
{id && <Button color='danger' variant='text' block disabled={busy || uncertain} onClick={remove}>删除这条误记</Button>}
|
||||
</>}
|
||||
{done && <View className='panel status-panel'><Text>{done}</Text><Button color='primary' block onClick={() => { void returnToSource() }}>返回</Button></View>}
|
||||
</View>
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import Taro from '@tarojs/taro'
|
||||
|
||||
export const NAME_LIMIT = 40
|
||||
export const NOTE_LIMIT = 2000
|
||||
export const MINUTE_LIMIT = 2147483647
|
||||
|
||||
export interface Project { id: string; name: string; archived: boolean; hasRecords: boolean }
|
||||
export interface PracticeRecord {
|
||||
id: string; projectId: string; projectName: string; archived: boolean
|
||||
date: string; minutes: number; note: string
|
||||
}
|
||||
export interface RecordInput { projectId: string; date: string; minutes: number; note: string; requestId?: string }
|
||||
export interface RecordList { records: PracticeRecord[]; nextCursor: string }
|
||||
export interface ProjectMinutes { projectId: string; name: string; archived: boolean; minutes: number }
|
||||
export interface Summary { totalMinutes: number; practiceDays: number; recordCount: number; projects: ProjectMinutes[] }
|
||||
export interface Review {
|
||||
today: string; from: string; to: string; allTime: Summary; period: Summary
|
||||
days: { date: string; minutes: number }[]
|
||||
}
|
||||
interface Session { token: string; expiresAt: string }
|
||||
type Method = 'GET' | 'POST' | 'PUT' | 'DELETE'
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(message: string, public status = 0, public code = 'network_error') { super(message) }
|
||||
}
|
||||
|
||||
const storageKey = `ballet-session:${__API_BASE_URL__}`
|
||||
let current: Session | undefined
|
||||
let storageRead = false
|
||||
let loginPromise: Promise<Session> | undefined
|
||||
const changeListeners = new Set<() => void>()
|
||||
|
||||
export function onPracticeChange(listener: () => void) {
|
||||
changeListeners.add(listener)
|
||||
return () => { changeListeners.delete(listener) }
|
||||
}
|
||||
|
||||
// A temporary form receipt, not an offline queue. It survives page navigation
|
||||
// within this app session so native Back cannot silently discard an uncertain save.
|
||||
export interface PendingRecord { id?: string; input: RecordInput }
|
||||
let pendingRecord: PendingRecord | undefined
|
||||
export function getPendingRecord() { return pendingRecord }
|
||||
export function retainPendingRecord(value: PendingRecord) { pendingRecord = value }
|
||||
export function clearPendingRecord(value: PendingRecord) { if (pendingRecord === value) pendingRecord = undefined }
|
||||
|
||||
async function send<T>(path: string, method: Method, data?: object, token?: string): Promise<T> {
|
||||
let response: Taro.request.SuccessCallbackResult<T & { error?: { code: string; message: string } }>
|
||||
try {
|
||||
response = await Taro.request({
|
||||
url: `${__API_BASE_URL__}/v1${path}`, method, data, timeout: 12000,
|
||||
header: { 'content-type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||||
})
|
||||
} catch {
|
||||
throw new ApiError('网络连接未完成,请检查网络后重试')
|
||||
}
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw new ApiError(response.data?.error?.message || '服务暂时不可用,请稍后重试', response.statusCode, response.data?.error?.code)
|
||||
}
|
||||
return response.data
|
||||
}
|
||||
|
||||
async function session(): Promise<Session> {
|
||||
if (!storageRead) {
|
||||
storageRead = true
|
||||
try { current = Taro.getStorageSync<Session>(storageKey) || undefined } catch { /* Memory sessions still work if storage is unavailable. */ }
|
||||
}
|
||||
if (current?.token && Date.parse(current.expiresAt) > Date.now() + 60000) return current
|
||||
if (!loginPromise) {
|
||||
loginPromise = (async () => {
|
||||
let code: string
|
||||
try { code = (await Taro.login({ timeout: 10000 })).code } catch { throw new ApiError('微信登录未完成,请重试') }
|
||||
if (!code) throw new ApiError('未取得微信登录凭据,请重试')
|
||||
const result = await send<Session>('/session', 'POST', { code })
|
||||
current = result
|
||||
try { Taro.setStorageSync(storageKey, result) } catch { /* Do not turn a confirmed login into a failure. */ }
|
||||
return result
|
||||
})().finally(() => { loginPromise = undefined })
|
||||
}
|
||||
return loginPromise
|
||||
}
|
||||
|
||||
/** Refresh a rejected session once. A 401 is returned before business writes;
|
||||
* transport failures are left to the form, which retains its submission key. */
|
||||
async function request<T>(path: string, method: Method = 'GET', data?: object): Promise<T> {
|
||||
const original = await session()
|
||||
let result: T
|
||||
try { result = await send<T>(path, method, data, original.token) } catch (error) {
|
||||
if (!(error instanceof ApiError) || error.status !== 401) throw error
|
||||
if (current?.token === original.token) {
|
||||
current = undefined
|
||||
try { Taro.removeStorageSync(storageKey) } catch { /* The next exchange replaces storage. */ }
|
||||
}
|
||||
const renewed = await session()
|
||||
result = await send<T>(path, method, data, renewed.token)
|
||||
}
|
||||
if (method !== 'GET') changeListeners.forEach(listener => listener())
|
||||
return result
|
||||
}
|
||||
|
||||
function query(values: Record<string, string | number | undefined>) {
|
||||
return Object.entries(values).filter(([, value]) => value !== undefined && value !== '')
|
||||
.map(([key, value]) => `${key}=${encodeURIComponent(String(value))}`).join('&')
|
||||
}
|
||||
|
||||
export const api = {
|
||||
projects: (includeArchived = false) => request<{ projects: Project[]; today: string }>(`/projects?includeArchived=${includeArchived}`),
|
||||
saveProject: (name: string, id?: string) => request<Project>(id ? `/projects/${id}` : '/projects', id ? 'PUT' : 'POST', { name }),
|
||||
removeProject: (id: string) => request<{ action: 'deleted' | 'archived' }>(`/projects/${id}`, 'DELETE'),
|
||||
records: (options: { from?: string; to?: string; cursor?: string; limit?: number } = {}) => request<RecordList>(`/records?${query(options)}`),
|
||||
record: (id: string) => request<PracticeRecord>(`/records/${id}`),
|
||||
saveRecord: (input: RecordInput, id?: string) => request<PracticeRecord>(id ? `/records/${id}` : '/records', id ? 'PUT' : 'POST', input),
|
||||
deleteRecord: (id: string) => request<{ action: 'deleted' }>(`/records/${id}`, 'DELETE'),
|
||||
review: (period: 'week' | 'month', date?: string) => request<Review>(`/review?${query({ period, date })}`),
|
||||
}
|
||||
|
||||
/** An operation identifier, not an authentication secret. A new form gets a new
|
||||
* key even when its content is identical to an earlier intentional entry. */
|
||||
export function submissionID() {
|
||||
return `${Date.now().toString(36)}-${Array.from({ length: 32 }, () => Math.floor(Math.random() * 36).toString(36)).join('')}`
|
||||
}
|
||||
|
||||
export function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : '暂时无法完成,请稍后重试'
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/** Date-only calculations use UTC fields, independent of the device timezone. */
|
||||
export function shanghaiToday() { return new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10) }
|
||||
export function parseDate(date: string) { return new Date(`${date}T00:00:00Z`) }
|
||||
export function dateString(date: Date) { return date.toISOString().slice(0, 10) }
|
||||
export function addDays(date: string, days: number) {
|
||||
const value = parseDate(date); value.setUTCDate(value.getUTCDate() + days); return dateString(value)
|
||||
}
|
||||
export function monthStart(date: string) { return `${date.slice(0, 7)}-01` }
|
||||
export function addMonths(date: string, months: number) {
|
||||
const value = parseDate(monthStart(date)); value.setUTCMonth(value.getUTCMonth() + months); return dateString(value)
|
||||
}
|
||||
export function monthLabel(date: string) { return `${Number(date.slice(0, 4))} 年 ${Number(date.slice(5, 7))} 月` }
|
||||
export function displayDate(date: string) { return `${Number(date.slice(5, 7))} 月 ${Number(date.slice(8, 10))} 日` }
|
||||
export function duration(minutes: number) {
|
||||
const hours = Math.floor(minutes / 60), rest = minutes % 60
|
||||
return hours ? `${hours} 小时${rest ? ` ${rest} 分钟` : ''}` : `${rest} 分钟`
|
||||
}
|
||||
export function validDate(date: string) {
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(date) && date >= '0001-01-01' &&
|
||||
!Number.isNaN(parseDate(date).getTime()) && dateString(parseDate(date)) === date
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const vm = require('node:vm')
|
||||
const ts = require('typescript')
|
||||
|
||||
// Exercise the exported client against the platform network/storage seam. No
|
||||
// frontend E2E framework or production authentication bypass is introduced.
|
||||
function client(network, options = {}) {
|
||||
const storage = new Map()
|
||||
let loginNumber = 0
|
||||
const taro = {
|
||||
login: async () => ({ code: `login-${++loginNumber}` }),
|
||||
getStorageSync: key => storage.get(key),
|
||||
setStorageSync: (key, value) => storage.set(key, value),
|
||||
removeStorageSync: key => { if (options.storageRemovalFails) throw new Error('storage unavailable'); storage.delete(key) },
|
||||
request: network,
|
||||
}
|
||||
const source = fs.readFileSync(path.join(__dirname, '../src/services/practice.ts'), 'utf8')
|
||||
const js = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, esModuleInterop: true } }).outputText
|
||||
const exports = {}
|
||||
vm.runInNewContext(js, { exports, require: name => { assert.equal(name, '@tarojs/taro'); return taro }, __API_BASE_URL__: 'https://local-test.invalid' })
|
||||
return exports
|
||||
}
|
||||
const ok = data => ({ statusCode: 200, data })
|
||||
const session = token => ok({ token, expiresAt: '2099-01-01T00:00:00Z' })
|
||||
const failure = (status, code) => ({ statusCode: status, data: { error: { code, message: code } } })
|
||||
|
||||
// Same public API calls as the pages: a renewed token cannot change a submitted
|
||||
// form or create a fresh operation, including when platform storage removal fails.
|
||||
test('expired sessions renew and retain the exact submission', async () => {
|
||||
let exchange = 0
|
||||
const payloads = []
|
||||
const service = client(async request => {
|
||||
if (request.url.endsWith('/session')) return session(`token-${++exchange}`)
|
||||
payloads.push(request.data)
|
||||
if (request.header.Authorization === 'Bearer token-1') return failure(401, 'unauthorized')
|
||||
return ok({ id: 'record-1' })
|
||||
}, { storageRemovalFails: true })
|
||||
const input = { projectId: 'p', date: '2026-09-28', minutes: 90, note: '', requestId: 'same-submission-1' }
|
||||
assert.equal((await service.api.saveRecord(input)).id, 'record-1')
|
||||
assert.equal(exchange, 2)
|
||||
assert.deepEqual(payloads, [input, input])
|
||||
})
|
||||
|
||||
test('uncertain save stays recoverable and an explicit retry keeps its key', async () => {
|
||||
let lost = true
|
||||
const payloads = []
|
||||
const service = client(async request => {
|
||||
if (request.url.endsWith('/session')) return session('token')
|
||||
payloads.push(request.data)
|
||||
if (lost) { lost = false; throw new Error('response lost') }
|
||||
return ok({ id: 'record-1' })
|
||||
})
|
||||
const pending = { input: { projectId: 'p', date: '2026-09-28', minutes: 90, note: 'keep this', requestId: service.submissionID() } }
|
||||
service.retainPendingRecord(pending)
|
||||
await assert.rejects(service.api.saveRecord(pending.input), error => error.status === 0)
|
||||
const resumed = service.getPendingRecord()
|
||||
assert.equal(resumed, pending)
|
||||
assert.equal((await service.api.saveRecord(resumed.input)).id, 'record-1')
|
||||
assert.equal(payloads[0].requestId, payloads[1].requestId)
|
||||
service.clearPendingRecord(resumed)
|
||||
assert.equal(service.getPendingRecord(), undefined)
|
||||
assert.notEqual(service.submissionID(), pending.input.requestId)
|
||||
})
|
||||
|
||||
test('failed loads stay errors and only confirmed writes notify visible pages', async () => {
|
||||
let failWrite = true
|
||||
const service = client(async request => {
|
||||
if (request.url.endsWith('/session')) return session('token')
|
||||
if (request.method === 'GET') return failure(503, 'service_unavailable')
|
||||
if (failWrite) { failWrite = false; return failure(503, 'service_unavailable') }
|
||||
return ok({ id: 'record-1' })
|
||||
})
|
||||
let changes = 0
|
||||
const stop = service.onPracticeChange(() => { changes++ })
|
||||
await assert.rejects(service.api.records(), error => error.status === 503)
|
||||
await assert.rejects(service.api.saveRecord({}), error => error.status === 503)
|
||||
assert.equal(changes, 0)
|
||||
await service.api.saveRecord({})
|
||||
assert.equal(changes, 1)
|
||||
stop()
|
||||
})
|
||||
Reference in New Issue
Block a user