feat: add project icon picker component and integrate with project management

- Implemented ProjectIconPicker component for selecting project icons.
- Created ProjectIcon component to display selected icons.
- Updated calendar page to remove unused PageHeading component.
- Modified growth page to enhance UI and improve text clarity.
- Refactored index page to include project icons and improve layout.
- Added mine page for user profile management with avatar selection.
- Introduced projects-sort page for managing project order with drag-and-drop functionality.
- Enhanced projects page to allow icon selection when adding or editing projects.
- Updated record page to streamline user experience and improve form actions.
- Adjusted practice service to support project icon management.
- Added tests for project saving functionality to ensure correct handling of icons.
- Updated global type definitions to support SVG and PNG imports.
This commit is contained in:
yuxuanhui
2026-09-29 18:40:25 +08:00
parent 9644a68d07
commit b0c62d461e
71 changed files with 1305 additions and 125 deletions
+2 -2
View File
@@ -1,11 +1,11 @@
export default defineAppConfig({
pages: ['pages/index/index', 'pages/calendar/index', 'pages/growth/index', 'pages/record/index', 'pages/projects/index'],
pages: ['pages/index/index', 'pages/calendar/index', 'pages/growth/index', 'pages/mine/index', 'pages/record/index', 'pages/projects/index', 'pages/projects-sort/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: '成长' },
{ pagePath: 'pages/mine/index', text: '我的' },
],
},
window: {
+55 -20
View File
@@ -24,33 +24,57 @@ page {
}
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; }
.record-overview { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; align-items: start; }
.overview-column { min-width: 0; }
.overview-column--right { border-left: 1px solid #e2cfd0; padding-left: 18px; }
.overview-label { display: block; color: #946d76; font-size: 12px; line-height: 18px; }
.overview-value { display: block; color: #864b5e; font-size: 28px; font-weight: 600; line-height: 1.3; margin-top: 8px; overflow-wrap: anywhere; }
.overview-unit { font-size: 15px; font-weight: 400; }
.overview-start { display: block; color: #827572; font-size: 11px; margin-top: 6px; }
.section-heading, .row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.section-heading { margin: 24px 2px 12px; }
.section-heading--actions { justify-content: flex-end; margin-top: 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-list { display: flex; flex-direction: column; gap: 10px; }
.project-tile { display: flex; align-items: center; gap: 12px; border: 1px solid #e7ded8; border-radius: 17px; padding: 10px 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; }
.project-title { display: block; flex: 1; min-width: 0; font-size: 17px; font-weight: 600; overflow-wrap: anywhere; }
.project-hours { flex-shrink: 0; color: #9c5268; font-size: 18px; font-weight: 600; white-space: nowrap; }
.project-hours-unit { font-size: 12px; font-weight: 400; }
.project-icon { display: block; flex-shrink: 0; width: 32px; height: 32px; }
.action-icon { display: block; width: 28px; height: 28px; }
.profile-card { display: flex; align-items: center; gap: 18px; padding: 24px 20px; border: 1px solid #eddfda; border-radius: 21px; background: #f1e7e3; }
.profile-avatar-button { display: flex; flex-shrink: 0; align-items: center; justify-content: center; width: 72px; height: 72px; margin: 0; padding: 0; border-radius: 50%; background: #e2cfd0; overflow: hidden; }
.profile-avatar-button::after { border: 0; }
.profile-avatar { display: block; width: 72px; height: 72px; }
.profile-avatar-placeholder { color: #864b5e; font-size: 28px; font-weight: 600; line-height: 1; }
.profile-details { flex: 1; min-width: 0; }
.profile-nickname { display: block; width: 100%; height: 36px; color: #3e3435; font-size: 21px; font-weight: 600; }
.profile-hint { display: block; margin-top: 3px; color: #827572; font-size: 12px; }
.profile-storage-note { display: block; margin: 12px 2px; color: #827572; font-size: 11px; }
.sort-instruction { display: block; color: #827572; font-size: 13px; margin-bottom: 16px; }
.sort-project-list { display: flex; flex-direction: column; gap: 10px; }
.sortable-project { position: relative; z-index: 0; display: flex; align-items: center; gap: 12px; min-height: 64px; padding: 8px 10px 8px 16px; border: 1px solid #ece3dd; border-radius: 17px; background: #fffdfa; }
.sortable-project--dragging { z-index: 2; border-color: #9c5268; background: #f4ece8; }
.sortable-project--target { border-color: #bc8292; }
.sort-project-name { flex: 1; min-width: 0; font-size: 16px; font-weight: 500; overflow-wrap: anywhere; }
.sort-drag-handle { display: flex; flex-shrink: 0; align-items: center; justify-content: center; width: 44px; height: 44px; }
.sort-drag-icon { display: block; width: 22px; height: 22px; }
.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; }
.empty-title { display: block; font-size: 16px; font-weight: 500; }
.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; }
@@ -58,21 +82,32 @@ view, text, input, textarea { box-sizing: border-box; }
.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; }
// Native single-line inputs position their text from height; vertical padding shifts filled values down.
.field { display: block; width: 100%; background: #f8f4ef; border: 1px solid #e8dfd8; border-radius: 11px; padding: 0 14px; height: 48px; font-size: 16px; color: #44383b; }
.picker-field { display: flex; justify-content: space-between; padding: 12px 14px; 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; }
.note-input { padding: 12px 14px; 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; }
.form-page { padding-bottom: calc(100px + env(safe-area-inset-bottom)); }
.form-actions { position: fixed; left: 0; right: 0; bottom: 0; z-index: 20; display: flex; gap: 12px; padding: 12px 18px; padding-bottom: calc(12px + env(safe-area-inset-bottom)); border-top: 1px solid #ece3dd; background: #fffdfa; }
.form-action { flex: 1; min-width: 0; }
.managed-project { display: flex; align-items: center; gap: 12px; padding: 8px 16px; background: #fffdfa; border: 1px solid #ece3dd; border-radius: 17px; }
.inline-actions { display: flex; flex-shrink: 0; }
.project-icon-field { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.project-icon-current { display: flex; align-items: center; gap: 10px; min-width: 0; }
.icon-picker { --popup-background-color: #fffdfa; --popup-rounded-border-radius: 20px; }
.icon-picker-heading { display: flex; align-items: center; justify-content: space-between; padding: 16px 18px 8px; }
.icon-picker-tabs { display: flex; gap: 8px; padding: 0 18px 14px; }
.icon-picker-scroll { height: 48vh; max-height: 400px; }
.icon-picker-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; padding: 2px 18px 16px; }
.icon-choice { position: relative; display: flex; width: 100%; min-width: 0; flex-direction: column; align-items: center; gap: 4px; margin: 0; padding: 10px 4px; border: 1px solid #ece3dd; border-radius: 12px; background: #faf7f3; color: #655656; font-size: 12px; line-height: 1.5; }
.icon-choice::after { border: 0; }
.icon-choice--selected { background: #f4ece8; border-color: #9c5268; color: #9c5268; }
.icon-choice-image { display: block; width: 60px; height: 60px; }
.icon-choice-name { display: block; overflow-wrap: anywhere; }
.icon-choice-check { position: absolute; top: 4px; right: 7px; font-size: 12px; }
.icon-picker-footer { padding: 6px 18px calc(12px + env(safe-area-inset-bottom)); border-top: 1px solid #ece3dd; }
.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; }
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#9c5268" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="4"/>
<path d="M12 7.5v9M7.5 12h9"/>
</svg>

After

Width:  |  Height:  |  Size: 271 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#9c5268" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="5" width="18" height="16" rx="3"/>
<path d="M7 3v4M17 3v4M3 10h18"/>
<path d="M8 14h2M14 14h2M8 17h2M14 17h2"/>
</svg>

After

Width:  |  Height:  |  Size: 319 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="#9c5268">
<circle cx="9" cy="5" r="1.4"/><circle cx="15" cy="5" r="1.4"/>
<circle cx="9" cy="12" r="1.4"/><circle cx="15" cy="12" r="1.4"/>
<circle cx="9" cy="19" r="1.4"/><circle cx="15" cy="19" r="1.4"/>
</svg>

After

Width:  |  Height:  |  Size: 308 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#9c5268" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="4.5" cy="6" r="1" fill="#9c5268" stroke="none"/>
<circle cx="4.5" cy="12" r="1" fill="#9c5268" stroke="none"/>
<circle cx="4.5" cy="18" r="1" fill="#9c5268" stroke="none"/>
<path d="M8 6h12M8 12h12M8 18h12"/>
</svg>

After

Width:  |  Height:  |  Size: 415 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

@@ -0,0 +1,45 @@
import plie from './plie.png'
import tendu from './tendu.png'
import releve from './releve.png'
import portDeBras from './port-de-bras.png'
import arabesque from './arabesque.png'
import attitude from './attitude.png'
import pirouette from './pirouette.png'
import grandJete from './grand-jete.png'
import footHip from './foot-hip.png'
import flexibility from './flexibility.png'
import ballCore from './ball-core.png'
import swanArms from './swan-arms.png'
import breathing from './breathing.png'
import coreGlutes from './core-glutes.png'
import softShoes from './soft-shoes.png'
import pointeShoes from './pointe-shoes.png'
import barre from './barre.png'
import piano from './piano.png'
// Transparent 3× exports of docs/design/project-icons/*.svg. Only bundled assets
// are resolved here; an unknown server ID safely displays the project name alone.
export const projectIcons = [
{ id: 'foot-hip', name: '足髋训练', group: 'training', src: footHip },
{ id: 'flexibility', name: '软开素质', group: 'training', src: flexibility },
{ id: 'ball-core', name: '小球核心', group: 'training', src: ballCore },
{ id: 'swan-arms', name: '天鹅臂颈', group: 'training', src: swanArms },
{ id: 'breathing', name: '呼吸训练', group: 'training', src: breathing },
{ id: 'core-glutes', name: '核心臀腿', group: 'training', src: coreGlutes },
{ id: 'plie', name: '蹲', group: 'motion', src: plie },
{ id: 'tendu', name: '擦地', group: 'motion', src: tendu },
{ id: 'releve', name: '立半脚尖', group: 'motion', src: releve },
{ id: 'port-de-bras', name: '手臂练习', group: 'motion', src: portDeBras },
{ id: 'arabesque', name: '阿拉贝斯克', group: 'motion', src: arabesque },
{ id: 'attitude', name: '阿蒂蒂德', group: 'motion', src: attitude },
{ id: 'pirouette', name: '旋转', group: 'motion', src: pirouette },
{ id: 'grand-jete', name: '大跳', group: 'motion', src: grandJete },
{ id: 'soft-shoes', name: '软底舞鞋', group: 'object', src: softShoes },
{ id: 'pointe-shoes', name: '足尖鞋', group: 'object', src: pointeShoes },
{ id: 'barre', name: '把杆', group: 'object', src: barre },
{ id: 'piano', name: '钢琴', group: 'object', src: piano },
] as const
export function projectIcon(id?: string) {
return projectIcons.find(icon => icon.id === id)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

+14 -4
View File
@@ -7,15 +7,25 @@ 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 FormActions({ busy, cancelDisabled, saveDisabled, saveLabel = '保存', onCancel, onSave }: {
busy: boolean
cancelDisabled?: boolean
saveDisabled?: boolean
saveLabel?: string
onCancel: () => unknown
onSave: () => unknown
}) {
return <View className='form-actions'>
<View className='form-action'><Button block variant='outlined' disabled={busy || cancelDisabled} onClick={() => { void onCancel() }}>取消</Button></View>
<View className='form-action'><Button block color='primary' loading={busy} disabled={busy || saveDisabled} onClick={() => { void onSave() }}>{saveLabel}</Button></View>
</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 Empty({ title }: { title: string }) {
return <View className='empty'><Text className='empty-title'>{title}</Text></View>
}
export function RecordCards({ records }: { records: PracticeRecord[] }) {
return <View>{records.map(record => <View className='record-card' key={record.id}>
@@ -0,0 +1,51 @@
import { useState } from 'react'
import { Button as NativeButton, Image, ScrollView, Text, View } from '@tarojs/components'
import Popup from '@taroify/core/popup'
import '@taroify/core/popup/style'
import { Button } from './common'
import { ProjectIcon } from './project-icon'
import { projectIcon, projectIcons } from '../assets/project-icons'
const groups = [
{ id: 'training', name: '基础练习' },
{ id: 'motion', name: '专业动作' },
{ id: 'object', name: '物件' },
] as const
type Group = typeof groups[number]['id']
/** Selection only updates the enclosing form; its save/cancel controls own persistence. */
export function ProjectIconPicker({ value, disabled, onChange }: {
value: string; disabled: boolean; onChange: (id: string) => void
}) {
const [open, setOpen] = useState(false)
const [group, setGroup] = useState<Group>('training')
const selected = projectIcon(value)
function choose(id: string) {
if (disabled) return
onChange(id)
setOpen(false)
}
return <>
<View className='project-icon-field'>
<View className='project-icon-current'><ProjectIcon id={value} /><Text className='muted'>{selected?.name || '无图标'}</Text></View>
<Button size='small' variant='text' disabled={disabled} onClick={() => { setGroup(selected?.group || 'training'); setOpen(true) }}>{selected ? '更换图标' : '选择图标'}</Button>
</View>
<Popup className='icon-picker' open={open} placement='bottom' rounded onClose={() => setOpen(false)}>
<View className='icon-picker-heading'><Text className='section-title'>项目图标</Text><Button size='small' variant='text' onClick={() => setOpen(false)}>关闭</Button></View>
<View className='icon-picker-tabs'>{groups.map(item => <Button key={item.id} size='small' variant={group === item.id ? 'outlined' : 'text'} color={group === item.id ? 'primary' : 'default'} onClick={() => setGroup(item.id)}>{item.name}</Button>)}</View>
<ScrollView className='icon-picker-scroll' scrollY>
<View className='icon-picker-grid'>{projectIcons.filter(icon => icon.group === group).map(icon => <NativeButton
key={icon.id} className={`icon-choice${value === icon.id ? ' icon-choice--selected' : ''}`} disabled={disabled}
ariaLabel={`${icon.name}${value === icon.id ? ',已选中' : ''}`} onClick={() => choose(icon.id)}
>
<Image className='icon-choice-image' src={icon.src} mode='aspectFit' />
<Text className='icon-choice-name'>{icon.name}</Text>
{value === icon.id && <Text className='icon-choice-check'>✓</Text>}
</NativeButton>)}</View>
</ScrollView>
<View className='icon-picker-footer'><Button block variant='text' disabled={disabled} onClick={() => choose('')}>无图标{!value ? ' · 已选中' : ''}</Button></View>
</Popup>
</>
}
@@ -0,0 +1,8 @@
import { Image } from '@tarojs/components'
import { projectIcon } from '../assets/project-icons'
/** Unknown or empty IDs leave the readable project title intact without a placeholder. */
export function ProjectIcon({ id, className = '' }: { id?: string; className?: string }) {
const icon = projectIcon(id)
return icon ? <Image className={`project-icon ${className}`} src={icon.src} mode='aspectFit' ariaLabel={icon.name} /> : null
}
+2 -3
View File
@@ -1,7 +1,7 @@
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 { Button, Empty, LoadState, 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'
@@ -41,7 +41,6 @@ export default function Calendar() {
}
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>
@@ -59,7 +58,7 @@ export default function Calendar() {
</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>}
{list.records.length ? <RecordCards records={list.records} /> : <View className='panel'><Empty title='当天暂无记录' /></View>}
{moreError && <Text className='error-banner'>{moreError}</Text>}
{list.nextCursor && <Button block variant='outlined' loading={loadingMore} disabled={loadingMore} onClick={more}>查看当天更多记录</Button>}
</>}
+4 -6
View File
@@ -1,6 +1,6 @@
import { useState } from 'react'
import { Text, View } from '@tarojs/components'
import { Button, Empty, LoadState, PageHeading } from '../../components/common'
import { Button, Empty, LoadState } from '../../components/common'
import { useRemote } from '../../hooks/useRemote'
import { api } from '../../services/practice'
import { addDays, addMonths, duration, monthStart, shanghaiToday } from '../../utils/dates'
@@ -19,10 +19,9 @@ export default function Growth() {
}
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='hero-card'><Text className='eyebrow'>累计练习</Text><Text className='hero-number'>{duration(review.allTime.totalMinutes)}</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>
@@ -34,12 +33,11 @@ export default function Growth() {
<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='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>) : <Empty title='暂无练习数据' />}</View>
</>}
</View>
}
+27 -15
View File
@@ -1,29 +1,41 @@
import Taro from '@tarojs/taro'
import { Text, View } from '@tarojs/components'
import { Button, Empty, LoadState, PageHeading, RecordCards } from '../../components/common'
import { Image, Text, View } from '@tarojs/components'
import addProjectIcon from '../../assets/action-icons/add-project.svg'
import calendarIcon from '../../assets/action-icons/calendar.svg'
import manageListIcon from '../../assets/action-icons/manage-list.svg'
import { Button, Empty, LoadState } from '../../components/common'
import { ProjectIcon } from '../../components/project-icon'
import { useRemote } from '../../hooks/useRemote'
import { api } from '../../services/practice'
import { duration } from '../../utils/dates'
function hours(minutes: number) { return Number((minutes / 60).toFixed(2)) }
export default function Index() {
const state = useRemote(async () => {
const [projects, recent, review] = await Promise.all([api.projects(), api.records(), api.review('week')])
return { ...projects, recent, review }
const [projects, review] = await Promise.all([api.projects(), api.review('week')])
return { ...projects, review }
})
const data = state.data
const todayMinutes = data?.review.days.find(day => day.date === data.today)?.minutes || 0
const projectMinutes = new Map(data?.review.allTime.projects.map(project => [project.projectId, project.minutes]) || [])
const firstDate = data?.review.allTime.firstDate
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 className='hero-card record-overview'>
<View className='overview-column'><Text className='overview-label'>总记录时间</Text><Text className='overview-value'>{hours(data.review.allTime.totalMinutes)}<Text className='overview-unit'> h</Text></Text></View>
<View className='overview-column overview-column--right'>
<Text className='overview-label'>坚持天数</Text><Text className='overview-value'>{data.review.allTime.practiceDays}<Text className='overview-unit'> 天</Text></Text>
{firstDate && <Text className='overview-start'>从 {firstDate.slice(0, 4)} 年 {firstDate.slice(5, 7)} 月 {firstDate.slice(8, 10)} 日</Text>}
</View>
</View>
<View className='section-heading section-heading--actions'>
<Button variant='text' icon={<Image className='action-icon' src={calendarIcon} mode='aspectFit' />} ariaLabel='日历' onClick={() => { void Taro.navigateTo({ url: '/pages/calendar/index' }) }} />
<Button variant='text' icon={<Image className='action-icon' src={manageListIcon} mode='aspectFit' />} ariaLabel='管理列表' onClick={() => { void Taro.navigateTo({ url: '/pages/projects-sort/index' }) }} />
<Button variant='text' icon={<Image className='action-icon' src={addProjectIcon} mode='aspectFit' />} ariaLabel='添加项目' onClick={() => { void Taro.navigateTo({ url: '/pages/projects/index' }) }} />
</View>
{data.projects.length ? <View className='project-list'>{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}` }) }}>
<ProjectIcon id={project.iconId} /><Text className='project-title'>{project.name}</Text><Text className='project-hours'>{hours(projectMinutes.get(project.id) || 0)}<Text className='project-hours-unit'> h</Text></Text>
</View>)}</View> : <View className='panel'><Empty title='暂无项目' /></View>}
</>}
</View>
}
@@ -0,0 +1 @@
export default definePageConfig({ navigationBarTitleText: '芭蕾岛 · 我的' })
+62
View File
@@ -0,0 +1,62 @@
import { useRef, useState } from 'react'
import Taro from '@tarojs/taro'
import { Button, Image, Input, Text, View } from '@tarojs/components'
interface LocalProfile { nickname: string; avatarPath: string }
const profileKey = 'ballet-profile:v1'
function readProfile(): LocalProfile {
try {
const stored = Taro.getStorageSync<Partial<LocalProfile>>(profileKey)
return {
nickname: typeof stored?.nickname === 'string' ? stored.nickname : '',
avatarPath: typeof stored?.avatarPath === 'string' ? stored.avatarPath : '',
}
} catch { return { nickname: '', avatarPath: '' } }
}
export default function Mine() {
const [profile, setProfile] = useState(readProfile)
const profileRef = useRef(profile)
const [error, setError] = useState('')
const savingAvatar = useRef(false)
function updateProfile(next: LocalProfile): boolean {
profileRef.current = next
setProfile(next)
try { Taro.setStorageSync(profileKey, next); setError(''); return true }
catch { setError('资料暂时无法保存到本机。'); return false }
}
async function chooseAvatar(event: { detail: { avatarUrl?: string } }) {
const tempFilePath = event.detail.avatarUrl
if (!tempFilePath || savingAvatar.current) return
savingAvatar.current = true
setError('')
try {
const result = await Taro.saveFile({ tempFilePath })
if (!('savedFilePath' in result)) throw new Error('头像暂时无法保存,请重试。')
const previous = profileRef.current.avatarPath
const persisted = updateProfile({ ...profileRef.current, avatarPath: result.savedFilePath })
if (persisted && previous && previous !== result.savedFilePath) {
void Taro.removeSavedFile({ filePath: previous }).catch(() => {})
}
} catch { setError('头像暂时无法保存,请重试。') }
finally { savingAvatar.current = false }
}
return <View className='page'>
<View className='profile-card'>
<Button className='profile-avatar-button' openType='chooseAvatar' ariaLabel='选择微信头像' onChooseAvatar={event => { void chooseAvatar(event as { detail: { avatarUrl?: string } }) }}>
{profile.avatarPath ? <Image className='profile-avatar' src={profile.avatarPath} mode='aspectFill' /> : <Text className='profile-avatar-placeholder'>微</Text>}
</Button>
<View className='profile-details'>
<Input className='profile-nickname' type='nickname' value={profile.nickname} maxlength={32} placeholder='微信用户' onInput={event => updateProfile({ ...profileRef.current, nickname: event.detail.value })} />
<Text className='profile-hint'>点击头像或昵称,选择微信资料</Text>
</View>
</View>
{error && <Text className='error-banner'>{error}</Text>}
<Text className='profile-storage-note'>头像和昵称仅保存在当前设备</Text>
</View>
}
@@ -0,0 +1 @@
export default definePageConfig({ navigationBarTitleText: '管理列表' })
@@ -0,0 +1,107 @@
import { useEffect, useRef, useState } from 'react'
import Taro from '@tarojs/taro'
import { Image, Text, View, type ITouchEvent } from '@tarojs/components'
import dragHandleIcon from '../../assets/action-icons/drag-handle.svg'
import { Empty, FormActions, LoadState, returnToSource } from '../../components/common'
import { ProjectIcon } from '../../components/project-icon'
import { useRemote } from '../../hooks/useRemote'
import { api, ApiError, errorMessage, type Project } from '../../services/practice'
interface DragState {
id: string
originIndex: number
targetIndex: number
startY: number
centers: number[]
}
function moveProject(projects: Project[], from: number, to: number) {
const next = [...projects]
next.splice(to, 0, next.splice(from, 1)[0])
return next
}
export default function ProjectsSort() {
const state = useRemote(() => api.projects())
const [projects, setProjects] = useState<Project[]>([])
const [dragging, setDragging] = useState<{ id: string; offset: number; targetIndex: number }>()
const dragRef = useRef<DragState>()
const busyRef = useRef(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
useEffect(() => { if (state.data) setProjects(state.data.projects) }, [state.data])
const dirty = !!state.data && projects.length === state.data.projects.length &&
projects.some((project, index) => project.id !== state.data!.projects[index].id)
function beginDrag(id: string, event: ITouchEvent) {
if (busyRef.current || dragRef.current) return
const startY = event.touches[0]?.clientY
const originIndex = projects.findIndex(project => project.id === id)
if (startY === undefined || originIndex < 0) return
dragRef.current = { id, originIndex, targetIndex: originIndex, startY, centers: [] }
setDragging({ id, offset: 0, targetIndex: originIndex })
const query = Taro.createSelectorQuery()
query.selectAll('.sortable-project').boundingClientRect(rects => {
if (dragRef.current?.id !== id || !Array.isArray(rects)) return
dragRef.current.centers = rects.map(rect => rect.top + rect.height / 2)
})
query.exec()
}
function updateDrag(event: ITouchEvent) {
const current = dragRef.current
const y = event.touches[0]?.clientY ?? event.changedTouches[0]?.clientY
if (!current || y === undefined) return
const offset = y - current.startY
if (current.centers.length === projects.length) {
const center = current.centers[current.originIndex] + offset
let closest = current.originIndex
for (let index = 0; index < current.centers.length; index++) {
if (Math.abs(current.centers[index] - center) < Math.abs(current.centers[closest] - center)) closest = index
}
current.targetIndex = closest
}
setDragging({ id: current.id, offset, targetIndex: current.targetIndex })
}
function endDrag(event: ITouchEvent) {
const current = dragRef.current
if (!current) return
updateDrag(event)
dragRef.current = undefined
setDragging(undefined)
if (current.targetIndex !== current.originIndex) {
setProjects(previous => moveProject(previous, current.originIndex, current.targetIndex))
setError('')
}
}
async function save() {
if (busyRef.current || dragRef.current || !dirty) return
busyRef.current = true; setBusy(true); setError('')
try {
await api.reorderProjects(projects.map(project => project.id))
await returnToSource('顺序已保存')
} catch (cause) { setError(cause instanceof ApiError && cause.status === 400 ? '项目列表已变化,请返回后重新打开排序页。' : errorMessage(cause)) }
finally { busyRef.current = false; setBusy(false) }
}
return <View className='page form-page'>
<LoadState {...state} retry={state.reload} />
{state.data && <>
<Text className='sort-instruction'>按住右侧图标拖动项目,完成后保存。</Text>
{error && <Text className='error-banner'>{error}</Text>}
{projects.length ? <View className='sort-project-list'>
{projects.map((project, index) => <View key={project.id} className={`sortable-project ${dragging?.id === project.id ? 'sortable-project--dragging' : ''} ${dragging && dragging.targetIndex === index && dragging.id !== project.id ? 'sortable-project--target' : ''}`} style={dragging?.id === project.id ? { transform: `translateY(${dragging.offset}px)` } : undefined}>
<ProjectIcon id={project.iconId} /><Text className='sort-project-name'>{project.name}</Text>
<View className='sort-drag-handle' catchMove onTouchStart={event => beginDrag(project.id, event as ITouchEvent)} onTouchMove={event => updateDrag(event as ITouchEvent)} onTouchEnd={event => endDrag(event as ITouchEvent)} onTouchCancel={() => { dragRef.current = undefined; setDragging(undefined) }}>
<Image className='sort-drag-icon' src={dragHandleIcon} mode='aspectFit' ariaLabel='拖动排序' />
</View>
</View>)}
</View> : <View className='panel'><Empty title='暂无项目' /></View>}
<FormActions busy={busy} saveDisabled={!dirty || !!dragging} onCancel={() => returnToSource()} onSave={save} />
</>}
</View>
}
+19 -18
View File
@@ -1,7 +1,9 @@
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 { Button, Empty, FormActions, LoadState } from '../../components/common'
import { ProjectIcon } from '../../components/project-icon'
import { ProjectIconPicker } from '../../components/project-icon-picker'
import { useRemote } from '../../hooks/useRemote'
import { api, errorMessage, NAME_LIMIT, type Project } from '../../services/practice'
@@ -9,6 +11,7 @@ export default function Projects() {
const state = useRemote(() => api.projects())
const [editing, setEditing] = useState<Project>()
const [name, setName] = useState('')
const [iconId, setIconId] = useState('')
const [busy, setBusy] = useState(false)
const busyRef = useRef(false)
const [error, setError] = useState('')
@@ -20,9 +23,9 @@ export default function Projects() {
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 api.saveProject(clean, editing?.id, iconId)
setNotice(editing ? '项目已更新。' : '项目已添加。')
setName(''); setIconId(''); setEditing(undefined)
await state.reload()
} catch (cause) { setError(errorMessage(cause)) }
finally { busyRef.current = false; setBusy(false) }
@@ -35,35 +38,33 @@ export default function Projects() {
const confirmed = await Taro.showModal({
title: project.hasRecords ? '移出常用项目?' : '移除这个项目?',
content: project.hasRecords ? `“${project.name}”将归档,历史记录、笔记和累计时长都会保留。` : `“${project.name}”没有记录时会被删除。如果已有新记录,将改为归档并保留历史。`,
confirmText: '确认移除', cancelText: '保留项目',
confirmText: '移除', cancelText: '取消',
})
if (!confirmed.confirm) return
const result = await api.removeProject(project.id)
if (editing?.id === project.id) { setEditing(undefined); setName('') }
if (editing?.id === project.id) { setEditing(undefined); setName(''); setIconId('') }
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='课程会变化,留下的练习一直都在。' />
return <View className='page form-page'>
<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-label'>项目名称</Text>
<Input className='field' value={name} maxlength={-1} disabled={busy} cursorSpacing={120} 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>}
<ProjectIconPicker value={iconId} disabled={busy} onChange={setIconId} />
</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>
{state.data && (state.data.projects.length ? <View className='project-list'>
{state.data.projects.map(project => <View className='managed-project' key={project.id}>
<ProjectIcon id={project.iconId} /><Text className='record-name'>{project.name}</Text><View className='inline-actions'><Button size='small' variant='text' disabled={busy} onClick={() => { setEditing(project); setName(project.name); setIconId(project.iconId || ''); setError(''); setNotice(''); void Taro.pageScrollTo({ scrollTop: 0, duration: 200 }) }}>编辑</Button><Button size='small' color='danger' variant='text' disabled={busy} onClick={() => remove(project)}>移除</Button></View>
</View>)}
</View> : <View className='panel'><Empty title='暂无项目' /></View>)}
<FormActions busy={busy} onCancel={() => { setEditing(undefined); setName(''); setIconId(''); setError(''); setNotice('') }} onSave={save} />
</View>
}
+7 -8
View File
@@ -1,7 +1,7 @@
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 { Button, FormActions, LoadState, 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'
@@ -93,7 +93,7 @@ export default function RecordForm() {
if (!id || busyRef.current || uncertain) return
busyRef.current = true; setBusy(true)
try {
const result = await Taro.showModal({ title: '删除这条误记?', content: '这条记录的时长和笔记将被删除,日历与成长统计会一起更新。', confirmText: '删除记录', confirmColor: '#a23d4c', cancelText: '保留记录' })
const result = await Taro.showModal({ title: '删除这条误记?', content: '这条记录的时长和笔记将被删除,日历与成长统计会一起更新。', confirmText: '删除', confirmColor: '#a23d4c', cancelText: '取消' })
if (!result.confirm) return
setError('')
await api.deleteRecord(id)
@@ -103,7 +103,6 @@ export default function RecordForm() {
}
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>}
@@ -112,20 +111,20 @@ export default function RecordForm() {
<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>}
{!choices.length && <View className='field-help'><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)} />
<Input className='field' type='number' value={minutes} maxlength={10} disabled={locked} cursorSpacing={120} 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)} />
<Textarea className='field note-input' value={note} maxlength={-1} disabled={locked} cursorSpacing={120} 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>}
{id && <Button color='danger' variant='text' block disabled={locked} onClick={remove}>删除</Button>}
<FormActions busy={busy} cancelDisabled={uncertain} saveDisabled={!pending.current && !choices.length} saveLabel={uncertain ? '重试' : '保存'} onCancel={() => returnToSource()} onSave={save} />
</>}
{done && <View className='panel status-panel'><Text>{done}</Text><Button color='primary' block onClick={() => { void returnToSource() }}>返回</Button></View>}
</View>
+4 -3
View File
@@ -4,7 +4,7 @@ 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 Project { id: string; name: string; iconId?: string; archived: boolean; hasRecords: boolean }
export interface PracticeRecord {
id: string; projectId: string; projectName: string; archived: boolean
date: string; minutes: number; note: string
@@ -12,7 +12,7 @@ export interface PracticeRecord {
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 Summary { totalMinutes: number; practiceDays: number; firstDate: string; recordCount: number; projects: ProjectMinutes[] }
export interface Review {
today: string; from: string; to: string; allTime: Summary; period: Summary
days: { date: string; minutes: number }[]
@@ -104,7 +104,8 @@ function query(values: Record<string, string | number | undefined>) {
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 }),
reorderProjects: (projectIds: string[]) => request<{ action: 'reordered' }>('/projects/order', 'PUT', { projectIds }),
saveProject: (name: string, id?: string, iconId?: string) => request<Project>(id ? `/projects/${id}` : '/projects', id ? 'PUT' : 'POST', { name, ...(iconId !== undefined ? { iconId } : {}) }),
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}`),
+19
View File
@@ -82,3 +82,22 @@ test('failed loads stay errors and only confirmed writes notify visible pages',
assert.equal(changes, 1)
stop()
})
test('project saves distinguish keeping, replacing and clearing an icon', async () => {
const payloads = []
const service = client(async request => {
if (request.url.endsWith('/session')) return session('token')
payloads.push({ method: request.method, data: JSON.parse(JSON.stringify(request.data)) })
return ok({ id: 'project-1', ...request.data })
})
await service.api.saveProject('小球核心', undefined, 'ball-core')
await service.api.saveProject('晚间练习', 'project-1')
await service.api.saveProject('晚间练习', 'project-1', 'breathing')
await service.api.saveProject('晚间练习', 'project-1', '')
assert.deepEqual(payloads, [
{ method: 'POST', data: { name: '小球核心', iconId: 'ball-core' } },
{ method: 'PUT', data: { name: '晚间练习' } },
{ method: 'PUT', data: { name: '晚间练习', iconId: 'breathing' } },
{ method: 'PUT', data: { name: '晚间练习', iconId: '' } },
])
})
+9
View File
@@ -2,3 +2,12 @@ declare const __API_BASE_URL__: string
declare module '*.scss'
declare module '*.png' {
const src: string
export default src
}
declare module '*.svg' {
const src: string
export default src
}