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:
yuxuanhui
2026-09-29 13:47:26 +08:00
parent 4b42b55928
commit 6224ef5980
49 changed files with 2824 additions and 71 deletions
@@ -0,0 +1,73 @@
# iHour 的哪些能力支撑练习记录与回顾,截图证明了哪些实际用法?
Type: research
Labels: wayfinder:research
Status: resolved
Assignee: yuxuanhui
Research agent: /root/ihour_research
Research agent status: complete
Resolved: 2026-09-28
Parent: [芭蕾岛第一期:练习记录与成长回顾](../map.md)
Blocked by: none
## Question
通过 iHour 官方资料和用户截图,核实项目组织、日常记录、计时、提醒、统计、成就、分享和数据延续能力。区分官方证实、截图直接观察、推断和未知,提供后续第一期取舍所需依据;不代替用户决定功能范围。
## Answer
研究子代理 `/root/ihour_research` 已完成只读检索;主代理抽查官方产品页、Google Play 与中国区 App Store,并查看用户提供的原始统计截图。未安装或实际操作 iHour,以下不是运行验证。
### 官方可核实的能力
| 能力 | 已核实事实 | 来源 |
| --- | --- | --- |
| 项目与记录 | 按不同项目记录每日投入,展示项目累计时间 | [官方产品页](https://app.ipad.ly/ihour?lang=zh_hans) |
| 计时、提醒与规划 | 日常提醒、正倒计时,以及长期时间规划 | [Google Play 开发者介绍](https://play.google.com/store/apps/details?hl=zh&id=com.clover.ihour) |
| 图表与进度 | 时间投入统计、各项目累计进度 | [官方产品页](https://app.ipad.ly/ihour?lang=zh_hans) |
| 激励与个性化 | 成就、隐藏成就、社交分享、背景与项目图标 | [官方产品页](https://app.ipad.ly/ihour?lang=zh_hans) |
| 专注与同步 | iOS 介绍列出专注主题、环境音、禁用手机模式、云端同步及怪兽收集;版本说明列出每日专注目标与快捷启动 | [中国区 App Store](https://apps.apple.com/cn/app/ihour-%E6%97%B6%E9%97%B4%E6%8A%95%E8%B5%84%E8%AE%A1%E5%88%92-%E4%B8%93%E6%B3%A8%E8%AE%A1%E6%97%B6%E5%A4%A7%E5%B8%88/id687625208) |
可归纳出的产品循环为:组织项目 → 记录投入或计时 → 回顾时间分布与累计 → 获得激励。该循环是研究归纳;“线下上课”和“自主练习”不是官方已证实的独立记录类型。
### 截图直接观察
用户提供三个页面:时间投资计划、我的项目、我的统计。证据来自本次对话附件,文件名分别为 `codex-clipboard-4f251b27-ac28-41ea-af31-3a571f87a65d.jpg`、`codex-clipboard-57729cc0-5e2f-44ac-bfbe-a64c46ea2986.jpg`、`codex-clipboard-bcfe8704-b26f-4de9-9b74-942814031d3f.jpg`;没有把临时图片路径视为长期有效的资产链接。
- “芭蕾”之下列出八个项目;每个有图标、名称和添加入口。截图没有展示添加后的表单,不能据此还原具体输入步骤。
- 统计页显示 163 条记录、167 小时、平均每周 4 小时、最近 7 天 4.5 小时;另有“在 iHour 记录时间的 103 天”。“103 天”的算法未核实,不能解读为连续打卡。
- 同一统计页的专注时长为 0,怪兽数量为 0、成就数量为 31。只能说明该页面的记录总时长与专注时长不同,不能断言用户从未使用计时或不喜欢游戏化。
- 页面包含日期热图、单日项目分布、最近 7 天/每周/每月/每年柱状统计,以及全部/年/月/周的累计分布。
统计页的精确展示如下:
| 项目 | 小时 |
| --- | ---: |
| 足髋训练 | 3 |
| 软开素质 | 19 |
| 基础提升 | 45.5 |
| 小球核心 | 19 |
| 天鹅臂颈 | 31 |
| 呼吸训练 | 2 |
| 核心臀腿 | 11 |
| 零基础 | 36.5 |
| 合计 | 167 |
八项相加恰为 167,支持此样本中“芭蕾”是汇总层的判断;不证明 iHour 所有父子项目都采用相同规则。项目页把基础提升和零基础显示为 46、37 小时,统计页显示为 45.5、36.5 小时,提示页面精度不同,不能用取整后的展示值重新计算总量。
### 对芭蕾场景的启发,尚待用户决策
- 课后快速记录应作为优先候选:这个样本积累了大量记录,却没有显示相应的专注时长。需要真实使用习惯进一步确认,不能据此直接取消计时。
- 现有名称同时包含学习阶段、身体部位和器械等信息,也可能直接来自课程名称。应允许讨论保留熟悉的名称,不宜立即强制统一为专业动作分类。
- 回顾可以先回答“这周练了什么、投入多少、过去积累了多少”;是否加入教师提示、主观收获、目标和里程碑属于后续决策。时长不能直接换算为技术等级。
### 资料边界
- 官方公开资料没有核实过去日期补记、编辑和删除的操作规则、父子项目汇总细节、各统计指标算法、文件导出与导入能力。未证实不表示应用没有这些功能。
- 官方网站同时提供 iOS 和 Android 入口。两端商店均有付费项目,但免费/会员权限边界及跨端一致性未核实;不据此制定本项目付费策略。
- 调研没有使用旧用户评论来定义当前功能,也没有把 iOS 专属能力推定到 Android 或微信小程序。
- 三张截图只有汇总与局部日期信息,无法重建 163 条原始记录;不能承诺仅凭截图完整迁移历史明细。
## Comments
- 2026-09-28:按用户“先调研”要求完成研究,由主代理保存结论;本票解决事实问题,不确认第一期功能清单。
@@ -0,0 +1,36 @@
# 第一期如何让上课和自主练习被轻松记下来?
Type: grilling
Labels: wayfinder:grilling
Status: resolved
Assignee: yuxuanhui
Parent: [芭蕾岛第一期:练习记录与成长回顾](../map.md)
Blocked by: 01
## Question
在用户已有线下课或跟练内容的前提下,首期主要采用练后填写时长、练前启动计时,还是两者都提供?通过一节 90 分钟线下课和一次 15 分钟自主练习,明确录入的最少必填内容、常用项目复用方式、是否需要课后感受或教师提示,以及补记、修改、删除的边界。
先确认真实记录习惯,再决定功能优先级;不能仅凭截图中的“+”或专注计时为零断言用户一定怎样操作。
## Answer
经逐项讨论及用户最终回复“可以”,第一期记录流程确定如下:
1. 练完后填写时长;以整堂课为一条记录,不要求拆分课内内容。额外自主练习另记。
2. 最小表单为项目、日期、时长,以及选填文字笔记。笔记可用于教师提示或个人收获,不填也能保存。
3. 预置少量常用项目,允许用户改名、删除和新增。已有记录的项目采用归档,从常用列表移除,历史记录和累计统计保留。
4. 允许补记过去日期、修改已保存记录和删除误记。统计随有效记录变化更新;项目归档与单条记录更正、删除是不同操作。
由上述规则推导的验收例子:一节 90 分钟课程及额外 15 分钟自主练习分别登记,合计 105 分钟;将课程更正为 60 分钟后合计 75 分钟;删除误记的 15 分钟后合计 60 分钟。归档课程项目不再减少这 60 分钟。以上是后续实现的验收依据,尚未执行产品测试。
具体预置清单、项目分组、改名后的历史呈现及汇总层级由[练习项目如何组织,才能保留熟悉的课名又不重复计算时间?](03-practice-organization.md)继续确认;图表口径、数据延续和页面反馈由各自后续事项处理。本票不代表全部第一期需求已定稿。
## Comments
- 用户确认:“练完后填写时长”。第一期的主记录方式确定为练后登记;不将练前启动计时作为主流程。该回答没有决定具体表单字段、补记与修改规则,也不等于永久排除计时能力。
- 用户确认:“整堂记录”。一堂课记一条练习记录并填写整堂总时长,不要求按把杆、中间、核心等内容拆分。承接上一轮选项,额外自主练习另记。
- 用户确认:“预置几个常用项目,允许改名、删除和新增”。首次使用提供少量可直接选择的项目,用户可按自己的课程与练习习惯管理;具体预置清单尚未确定。该回答未决定删除项目是否影响既有记录,也未决定改名对历史名称的呈现方式。
- 用户确认:“保留历史(推荐)”。已有练习记录的项目从常用列表移除时采用“归档”,历史记录及其累计统计继续保留。本决定针对项目管理,不代表禁止用户更正或删除单条误记;单条记录的维护边界尚待确认。
- 用户确认:“增加选填笔记”。每条练习记录可附一段文字,用于教师提示或个人练习收获;不填写笔记也能保存。没有据此增加图片、视频或其他附件需求。
- 用户对“项目、日期、时长与选填笔记”的表单,以及“允许补记、修改、删除误记,统计同步更新”的规则回复“可以”。本票据此解决,完整结论见 Answer。
@@ -0,0 +1,36 @@
# 练习项目如何组织,才能保留熟悉的课名又不重复计算时间?
Type: grilling
Labels: wayfinder:grilling
Status: resolved
Assignee: yuxuanhui
Parent: [芭蕾岛第一期:练习记录与成长回顾](../map.md)
Blocked by: 01, 02
## Question
沿用[第一期如何让上课和自主练习被轻松记下来?](02-recording-flow.md)已经确认的记录粒度及项目管理规则,不重新讨论。剩余需要决定:项目采用平铺列表还是需要分组/父子层级,具体预置哪些名称,项目改名后历史记录如何呈现?
截图中的“零基础、基础提升、足髋训练、小球核心”等名称可以作为候选,不视为统一训练标准。若使用分组,需明确分组是否仅用于组织和汇总、是否允许直接登记,避免重复累计。时长输入与图表显示精度转入成长回顾事项统一明确;这里不决定技术结构。
## Answer
用户已确认第一期采用以下项目组织规则:
1. 平铺展示具体练习项目,不设分组或父子项目层级。
2. 首批预置八个项目:零基础、基础提升、软开素质、足髋训练、核心臀腿、小球核心、天鹅臂颈、呼吸训练。这些是可编辑的起始名称,不是统一训练分类标准。
3. 项目改名后,历史记录和统计统一显示新名称,仍属于原来的同一项目;日期、时长和笔记保持不变。
沿用[已确认的记录流程](02-recording-flow.md),一条练习记录选择一个项目。由此推导,总量按有效记录各累计一次,项目汇总不是另一份额外投入;改名不生成新项目或新的记录,不增加或减少累计时长。项目新增及归档规则由记录流程事项保存,本票不重复定义。
例如,将“基础提升”改名为“芭蕾基训”后,旧记录与项目统计均显示“芭蕾基训”,原来登记的 90 分钟仍是同一条 90 分钟记录。这是后续实现验收场景,尚未执行产品测试。
时长输入、显示精度及各图表的具体口径统一交由[成长回顾首先应回答哪些问题,并提供怎样的鼓励?](04-growth-review.md)处理,尚未视为已决定。
## Comments
- 记录流程已确认后领取本票;先讨论平铺与分组,再确定预置清单及其余组织规则。
- 用户确认:“平铺列表”。第一期直接展示并选择具体练习项目,不设置分组或父子项目层级。
- 用户对所提八项回复“可以”,首批预置项目确定为:零基础、基础提升、软开素质、足髋训练、核心臀腿、小球核心、天鹅臂颈、呼吸训练。这些仅作为可编辑的起始项目名称,不是统一训练标准。
- 用户确认:“统一显示新名称(推荐)”。历史记录与统计统一显示改名后的名称,原有日期、时长和笔记不变。项目组织问题据此解决。
- 时长输入与显示精度和统计呈现关联更紧,已明确转入成长回顾事项继续讨论,没有在本票中默认为已确认。
@@ -0,0 +1,34 @@
# 成长回顾首先应回答哪些问题,并提供怎样的鼓励?
Type: grilling
Labels: wayfinder:grilling
Status: resolved
Assignee: yuxuanhui
Parent: [芭蕾岛第一期:练习记录与成长回顾](../map.md)
Blocked by: 02, 03
## Question
用户最需要回顾的是练了多少、多久练一次、各项目时间分布,还是每次练习的收获?据此决定总时长、记录次数、练习天数、周月趋势、日历和项目分布的首期优先级,以及是否需要目标、里程碑或分享。
明确练习天数与连续天数、记录条数与课次的区别,以及补记对图表和里程碑的影响。投入时间不等于技术水平提升;需要讨论如何呈现休息日,而不是默认以每日连续打卡作为唯一激励。
承接项目组织事项中的时长精度问题,明确用户如何输入时长、统计如何展示小时和分钟,以及总量与项目展示值之间的关系,避免先取整再汇总造成误差。
## Answer
用户确认首期包含累计时长与练习天数、练习日历、周月时长趋势,以及各项目时长和占比。日历可点开日期回看记录;同一天多条记录,练习天数计一天。
用户明确决定练习目标、成就徽章和分享卡片均放到后续版本。第一期的成长回顾由练习数据及已有笔记支撑,不额外建立技能等级、打卡奖励或分享流程。
采用以下常规默认方案,后续在原型和规格中集中验证:时长按分钟输入,使用完整时长汇总后再格式化;周按周一至周日、月按自然月;补记按实际练习日期归属;项目归档不影响其历史统计。以上为代理提供的默认细节,并非用户逐项确认。
验收推导:同日登记 90 分钟课程与 15 分钟自主练习,累计 105 分钟、练习天数为一天;将其中一条改到另一个日期后,累计时长不变,练习天数及相应日期的图表随有效记录变化。尚未执行产品测试。
## Comments
- 记录流程与项目组织均已解决,现领取本票。
- 用户对所提统计范围回复“可以”:第一期包含累计时长与练习天数、可点开日期回看记录的练习日历、周月时长趋势、各项目时长及占比。同一天多条练习记录,练习天数计一天。
- 常规细节拟采用以下默认方案,后续统一在原型或规格中呈现,不逐项追加问答:时长以分钟为输入精度,汇总使用完整时长后再格式化展示;日期默认今天且可补记;周按周一至周日、月按自然月;补记按实际练习日期归属;归档项目的历史继续计入统计。这些是代理提出的默认方案,不标记为用户逐项确认。
- 用户明确确认:“练习目标、成就徽章和分享卡片,都放到后续版本”。本票据此解决。
- 用户询问剩余决策:除本票的激励与分享外,现有地图还需确认数据保存及旧记录衔接,并通过核心流程原型确认页面。后续问题聚焦影响范围或数据含义的取舍,常规可逆细节提供默认方案。
@@ -0,0 +1,26 @@
# 练习记录需要怎样保存,已有 iHour 积累如何衔接?
Type: grilling
Labels: wayfinder:grilling
Status: resolved
Assignee: yuxuanhui
Parent: [芭蕾岛第一期:练习记录与成长回顾](../map.md)
Blocked by: 01
## Question
用户首次使用、换设备或重装后应如何保留自己的记录,首次录入前是否接受登录?对于已有的 iHour 积累,第一期从新记录开始、允许逐条补录,还是需要期初汇总?
如果带入历史总时长,需要决定其是否进入趋势、练习天数和次数统计,不能从总时长虚构历史明细。自动导入必须先取得真实可导出样例再评估,公开资料未证实其可用;不因参考 iHour 就默认承诺同步或迁移能力。
## Answer
用户确认数据保存按建议执行:关联微信身份,云端保存练习记录,同一微信身份换设备后可以恢复自己的记录。该项是产品要求,尚未接入或验证实际登录、云端保存与恢复;实现继续沿用当前小程序和后端项目,不因“云端保存”一词改变技术供应商。
用户明确“先不考虑旧数据衔接”。第一期从本产品新建立的记录开始,不做 iHour 导入、迁移或期初累计录入,也不带入截图中的历史总量。这不取消记录流程已确认的日常漏记补录能力。
首次使用的登录提示与保存反馈作为原型中的体验细节验证;不增加独立的手机号注册流程。原型只用内存模拟数据,不实际调用身份服务或上传用户内容。
## Comments
- 用户在同一条回复中明确确认数据保存、旧数据范围和激励范围;据此记录已经作出的产品取舍,不重复请求确认。
@@ -0,0 +1,47 @@
# 哪套最小页面与流程能完成记录和回顾?
Type: prototype
Labels: wayfinder:prototype
Status: resolved
Assignee: yuxuanhui
Parent: [芭蕾岛第一期:练习记录与成长回顾](../map.md)
Blocked by: 02, 03, 04, 05
## Question
在记录方式、项目组织、回顾指标和数据延续边界得到确认后,以低保真流程或可交互草图讨论“首次建立常用项目 → 记录一次练习 → 回看本周与累计”的最小路径。哪些页面、入口、空状态和反馈必须存在,什么结果能证明第一期已经满足需要?
原型是讨论材料,需要用户实际反馈后才能解决本票;不交付生产界面,也不把当前候选的“记录、日历、成长”当作已确认导航。
## Answer
用户体验原型后明确回复:“顺手,可以按此设计”。据此确认该原型作为第一期交互设计依据:
- 主导航采用“记录、日历、成长”三个入口。
- 记录页直接选择平铺项目,进入包含项目、日期、整堂时长与选填笔记的表单;保存后回到来源页面并显示结果。
- 日历页选择日期后查看当天记录,可新增该日期的记录或编辑已有记录;修改后日历与统计同步更新。
- 成长页展示已确认的累计投入、练习天数、周月趋势及项目分布。
- 项目管理从记录页进入,支持新增、改名及移除;有历史记录的项目按已确认规则归档,历史继续保留。
- 沿用草图中展示的空状态、表单提示、保存/取消和误记删除路径,作为正式实现时的设计基线。演示场景切换和“当前演示数据”面板仅供讨论,不属于正式产品功能。
本票确认的是用户认可的交互方案。正式组件适配、真实微信登录、云端保存与恢复、网络失败和重试仍需在后续实现中完成并验证;不将内存原型当成已上线或已接通后端的产品。现有原型保留为设计参考,不直接替换业务代码。
## Comments
- 前置产品决策已明确,开始制作“记录、日历、成长”的可交互流程草图。采用 prototype 的状态/操作流程分支,检验新增、更正、项目改名/归档后各页面数据是否易于理解;不在此轮比较正式视觉风格。
- 将原来的模糊项“中断、漏记、重复录入反馈”纳入本票的具体检查:保存与取消入口、补记日期、空状态、无效时长提示、误记删除、提交后返回位置。首次使用与保存反馈也在此讨论。
- 使用内存中的演示数据;不调用微信登录或云服务,不修改业务源码,不提交或切换分支。用户反馈前保持 claimed。
- 用户已反馈“顺手,可以按此设计”,本票据此收束,不再重复询问已确认的入口和操作路径。
## Assets
- [练习记录与回顾的交互草图](/Users/yuxuanhui/.codex/visualizations/2026/09/28/01a0e6a5-d2a3-7f51-bff3-545ef8c42df0/ballet-practice-flow.html):会话内展示,纯本地交互;提供“首次使用”和“已有练习记录”两个演示场景。示例数据不来自用户历史记录,刷新后重置。
## Verification
- 已在浏览器实际操作:新增 90 分钟记录并保存笔记;更正为 60 分钟后成长页显示 1 小时、1 天;项目改名并归档后统计仍保留 1 小时且使用新名称。
- 已操作日历回看与日期更正;删除该条模拟误记后,累计回到 0 分钟、0 天。
- 已切换示例场景和周/月统计;示例三条记录合计 195 分钟、三个练习日。
- 已检查约 320px 内容宽度下的成长页和日历;检查后的浏览器预览尺寸已恢复。原型脚本通过语法检查。
- 尚未验证真实微信身份、云端保存、换设备恢复及生产端能力。首次登录提示、网络失败与重试也未在草图中模拟。
- 用户验收反馈已收到:认可三个入口以及原型中的操作路径。交互检查和用户反馈均已有记录;真实产品接入仍未验证。
@@ -0,0 +1,13 @@
# 哪些公开芭蕾知识来源适合建立动作目录?
Type: research
Labels: wayfinder:research
Status: claimed
Assignee: yuxuanhui
Research agent: /root/ballet_knowledge
Parent: [芭蕾岛产品规划:练习记录、语音录入与动作成长](../map.md)
Blocked by: none
## Question
查找舞团、舞校、考试机构等公开的芭蕾动作术语来源,核实哪些适合作为动作名称、别名、分类和简短释义的参考。给出少量种子动作,区分术语目录与分级教学/掌握标准,说明法语及中文名称差异、访问与复用边界;不复制完整教材、不创建正式知识库,不替用户定义动作解锁规则。
@@ -0,0 +1,13 @@
# 语音解析、LLM 与工具调用如何可靠地产生练习记录?
Type: research
Labels: wayfinder:research
Status: claimed
Assignee: yuxuanhui
Research agent: /root/voice_feasibility
Parent: [芭蕾岛产品规划:练习记录、语音录入与动作成长](../map.md)
Blocked by: none
## Question
结合 Taro 微信小程序与现有 Go 后端,核实录音、语音识别、中文与芭蕾术语处理、结构化抽取及工具调用所需的可用能力。比较短录音后处理与实时交互对首期的影响,提出可验证的记录草稿流程;列明相对日期、多项练习、信息缺失、语音更正、重复提交和模型失败的处理原则。只做文档研究,不调用付费模型、不选择最终供应商、不修改业务代码。
@@ -0,0 +1,12 @@
# 语音录入与动作成就分别在哪一期交付?
Type: grilling
Labels: wayfinder:grilling
Status: claimed
Assignee: yuxuanhui
Parent: [芭蕾岛产品规划:练习记录、语音录入与动作成长](../map.md)
Blocked by: none
## Question
整体产品包含练习记录、语音录入和动作成就;首期是否包含语音录入及动作成就,哪些作为后续扩展?
@@ -0,0 +1,49 @@
# 第一期本地实现与验证
Type: task
Status: resolved
Scope: [已确认规格](../spec.md)
## Work
完成微信身份与业务会话、PostgreSQL 迁移、项目管理、练习记录、日历和成长页;保持 07–09 规划票不变。不部署、发布、提交或推送。
## Validation
按规格默认测试安排,通过 HTTP 请求/响应验证真实 PostgreSQL 持久化、身份隔离、幂等、归档与统计;仅替换外部微信核验。另执行后端 vet/race/build、小程序 typecheck/build。微信开发者工具、真实身份和跨设备验收分别记录,不由构建结果替代。
## Comments
- 2026-09-28:已读取交接、规格及仓库约定。工作区原有 `AGENTS.md` 修改和 `.scratch/`、`docs/` 材料保留。
- 2026-09-29:用户完成微信开发者工具登录并授权开启本机服务端口。CLI 通过 34789 确认 `login: true` 并打开项目,通过 34790 接入官方自动化 SDK;验收边界与结果补充如下。
- 2026-09-29:用户配置后端微信凭据后,核对 AppID 与项目一致;已启动本机 Compose PostgreSQL 和 Go 开发服务。`healthz`、`readyz` 均为 200;从模拟器取得真实 `wx.login` code,由本机 HTTP 客户端提交后端,微信身份交换成功返回业务会话,携带会话读取到 8 个初始项目。没有输出或另存凭据。模拟器直接请求本机接口仍被域名校验拦截,已请求用户确认仅对被 Git 忽略的本机私有配置关闭该校验;确认前不修改安全设置。
- 2026-09-29:用户手动勾选本机“不校验合法域名”。确认私有配置 `urlCheck: false` 后,完成真实后端的模拟器保存、更正、重读和统计联调;临时记录已清理,后端与数据库保持运行。
## Answer
2026-09-28 完成第一期本地实现,2026-09-29 补充真实微信身份及本机模拟器联调;本票不代表生产发布或完整真机验收完成。
- 后端:版本化事务迁移;微信 code2Session 核验适配器;30 天随机业务会话;八个项目原子预置;项目新增/改名/删除或归档;练习增删改查、提交幂等及删除回执;按实际练习日期计算累计、去重练习日、周/月趋势和项目分布。写入按用户串行化并校验归属。
- 小程序:记录、日历、成长三个入口,独立记录表单和项目管理页;保存后回来源页并刷新;失败与空结果分开;会话失效恢复;待确认提交保留输入和原标识。近期列表按登记/更正时间排序,补记可立即看到。未加入演示数据、计时器、激励、分享或 07–09 的其他规划。
- 执行默认值:项目名 40 个 Unicode 字符、笔记 2000 字符;分钟为正整数并受 PostgreSQL integer 范围约束。暂存表单只跨当前进程内页面导航,不提供离线队列或重启后的草稿持久化。
### 实际验证
- `go vet ./...`、连接隔离 PostgreSQL 18 的 `REQUIRE_TEST_DATABASE=1 go test -race ./...`、`go build -o bin/api ./cmd/server` 全部通过。Go 共 12 个顶层测试(其中 9 个业务/数据库集成测试、3 个健康检查测试);业务测试替换微信外部网络响应,其他会话、授权和数据库操作真实执行。
- HTTP 验证包括首次/再次登录、过期会话、另一账户猜测标识、改名/归档、归档记录编辑限制、补记与更正/删除后的统计、周日/周一及月底边界、上海业务日期、正整数/空值/长度约束、分页、重复提交/主动重复登记、并发重试/项目移除、删除回执、服务失败。重新创建 handler 后仍能恢复同一账户的数据库记录。
- `./scripts/test-integration.sh` 已实际启动独立临时 PostgreSQL 并执行 race 测试成功,退出后自动清理其容器。开发过程中另建的隔离测试容器也在收尾清理;这些隔离集成测试没有使用业务数据库或 Compose 数据卷。
- `pnpm typecheck`、`pnpm test:client`(3 项通过)、`pnpm build` 全部通过。客户端测试在 Taro 网络/存储边界验证续期、待确认提交、失败状态及成功写入通知,不等于小程序端到端运行验证。
- `docker compose --env-file .env.example config --quiet` 和本地镜像 `ballet-island-v1-local-check` 构建通过。构建时有上游 Node `punycode` 弃用提示,不影响编译成功。
- 两次只读核验:后端核验未发现确定缺陷;前端核验发现“未确认保存可取消而丢失提交标识”,已由主代理修正并补充客户端回归检查。主代理另修正近期记录排序,并以失败到通过的 HTTP 回归验证。
- 2026-09-29 微信模拟器:开发者工具 2.02.2608070、基础库 3.17.4、iPhone 12/13 (Pro) 390px 视口。记录、日历、成长三个页面均能打开,在真实后端不可达时明确显示网络失败及重试入口,没有捕获到未处理异常。
- 临时模拟微信/HTTP 响应后,9 项 UI 检查通过:八项目与近期记录、项目卡片进入表单并预选、空时长阻止提交、填写与保存后返回首页、日历标记及当天记录、补记首项选择与取消返回、周趋势和分布、月趋势切换与点按、项目管理添加。模拟结果不代表真实微信核验或数据库持久化;未写入真实后端或本地业务会话存储,检查后恢复原始 wx 方法并清除模拟数据。
- 截图发现 Taroify 默认按钮尺寸与项目 375px 设计宽度不匹配;仅通过公开主题变量修正按钮尺寸、边距和字号。再次执行 `pnpm typecheck`、`pnpm build`、`git diff --check` 通过,并重新通过上述 9 项 UI 检查。模拟器测得小按钮 33px、主按钮 45px,四个快捷时长按钮同排;已目视检查五页截图。
- 本机真实联调 7 项通过,全程未模拟微信身份或 HTTP 响应:登录后读取项目;模拟器保存 1 分钟临时记录并由已认证接口读回;日历显示记录及练习日标记;成长页与接口均增加 1 分钟、1 条记录;重新进入首页仍读到记录;表单更正为 2 分钟且记录数不增加;通过已认证 DELETE 接口清理本次临时记录后,累计时长和记录数恢复联调前值。没有捕获到未处理异常。此轮没有验证原生删除确认弹窗;清理仅按本次唯一临时标记定位,不修改用户其他记录。
### 未验证与下一步
- 本机 `backend/.env` 已配置 `WECHAT_APP_ID` / `WECHAT_APP_SECRET`,真实微信身份交换与已认证项目读取已验证。凭据只供服务端使用,没有输出或加入小程序包。
- 微信开发者工具登录、项目打开和五页模拟器渲染已验证;原生键盘、其他窄屏尺寸、弱网/断网保存及系统返回提示仍未完成平台验收。本次模拟器验证使用自动化输入事件,并仅对部分页面执行滚动检查。
- 本机 127.0.0.1:8080 的后端与 PostgreSQL 已启动且就绪。用户关闭本机开发者工具域名校验后,小程序到后端、数据库的真实保存、更正与读取链路已通过。
- 正式 request 合法域名/HTTPS、另一设备读取、客户端进程重启恢复和真实多账户隔离仍待验收;重新进入首页不等于完整进程重启。HTTP 中的受控微信响应不能替代这些证据。
- 本轮没有部署、发布、提交或推送;07–09 规划票保持原状。启动步骤和接口约定见根目录 `README.md`。
+45
View File
@@ -0,0 +1,45 @@
# 芭蕾岛第一期:练习记录与成长回顾
Labels: wayfinder:map
Status: open
Created: 2026-09-28
## Destination
明确面向已有线下课或跟练内容的芭蕾爱好者,第一期应保留哪些 iHour 能力、如何适配练习场景,以及核心流程、统计口径和验收边界。地图完成后,后续工作可据此形成实现规格并开发。
## Notes
- 用户已确认第一期主要解决“已有线下课或跟练内容,只需记录练习、回顾成长”。这一定位来自建图对话,不代表具体功能已获确认。
- 本地图只做调研与决策。遵循 wayfinder;人与产品有关的决策结合 grilling、domain-modeling,流程形态通过 prototype 与用户讨论。
- 每次只问一个关键问题;事实由代理查询,取舍由用户确认。候选建议不得写成已决定事项;建图阶段不关闭人工决策票,后续讨论按用户实际确认逐项解决。
- 事实依据分为官方产品资料、用户提供的三张截图、推断。单个用户的分类和使用数据不能代表全部芭蕾爱好者。
- 用户明确要求先调研 iHour,因此先行只读检索,再收录研究票。research 子技能未安装,采用网页检索与官方来源抽查;研究代理只读,主代理保存本地结论,不创建研究提交或切换分支。
- 本仓库按 `docs/agents/issue-tracker.md` 在本地 Markdown 跟踪;依赖使用 `Blocked by`,领取使用 `Status: claimed`。事项详情只保存在各自票中,本文件仅作索引。
- 本任务中的术语与规则以各决策票为准,未决建议继续标注为草案。已确认术语先留在本任务,不修改共享 CONTEXT.md 或 ADR。
- 本轮已收束基础记录与回顾的产品及交互设计。同目录中未参与本轮讨论的其他规划事项保持原状,不据此扩充已确认的第一期范围;整张地图暂不标记为全部完成。
- 已依据已解决事项整理[第一期实现规格](spec.md),状态为 `ready-for-agent`;实现范围、数据规则与验收依据以该规格为入口。
## Decisions so far
<!-- 仅索引已解决事项;未决事项通过 issues/ 中的状态与依赖查找。 -->
- [iHour 的哪些能力支撑练习记录与回顾,截图证明了哪些实际用法?](issues/01-ihour-evidence.md):已核实项目记时、统计和激励能力;截图表明用户按芭蕾项目积累记录,记录时长与专注时长需要区分,具体操作与导入规则仍未证实。
- [第一期如何让上课和自主练习被轻松记下来?](issues/02-recording-flow.md):练后整堂登记,笔记选填;项目可管理且归档保留历史,练习记录允许补记与更正,统计随之更新。
- [练习项目如何组织,才能保留熟悉的课名又不重复计算时间?](issues/03-practice-organization.md):平铺八个可编辑预置项目,改名后历史与统计统一使用新名称,同一条练习记录只累计一次。
- [成长回顾首先应回答哪些问题,并提供怎样的鼓励?](issues/04-growth-review.md):累计、日历、周月趋势和项目分布进入首期;目标、徽章和分享后置。
- [练习记录需要怎样保存,已有 iHour 积累如何衔接?](issues/05-record-continuity.md):微信身份关联云端记录,首期不做旧数据衔接,保留日常补记能力。
- [哪套最小页面与流程能完成记录和回顾?](issues/06-core-flow-prototype.md):用户认可“记录、日历、成长”三个入口与草图操作路径,作为第一期交互设计依据。
- [第一期本地实现与验证](issues/10-v1-implementation.md):后续执行阶段已按规格完成本地代码、真实 PostgreSQL HTTP 集成验证和两端构建;真实微信与设备验收仍待环境配置,未部署、发布或提交。07–09 规划票保持原状。
## Not yet specified
- 产品范围稳定后,识别需要核实的小程序能力限制及实现前置条件。
## Out of scope
- 本轮不实现业务代码、不部署、不发布;这些属于地图完成后的执行阶段。
- 第一期开设或推荐训练内容、自动编排训练计划,不属于用户已确认的“记录已有练习”定位。
- 全量复刻 iHour、多平台原生客户端、商业化体系不属于本地图的目的。
- 第一期的练习目标、成就徽章和分享卡片后置,依据见[成长回顾决策](issues/04-growth-review.md)。
- 第一期不做旧数据衔接,依据见[记录保存与历史衔接决策](issues/05-record-continuity.md)。
+213
View File
@@ -0,0 +1,213 @@
# 芭蕾岛第一期:练习记录与成长回顾
Status: ready-for-agent
Created: 2026-09-28
Parent: [第一期决策地图](map.md)
## Problem Statement
芭蕾爱好者已经在线下上课或跟随现有内容练习,需要一个适合课后快速登记、长期回顾投入的工具。用户提供的 iHour 截图展示了按熟悉的课程或练习名称记录时长、回看累计与项目分布的实际使用方式,但通用时间管理中的项目层级、计时和激励体系并非第一期都需要。
用户希望练完后一笔记下整堂课,不必拆分把杆、中间或每个动作;偶尔另外练习,也能单独记录。课名和练习习惯会变化,因此项目需要可管理,改名或停用不能损失历史。回顾应能回答“练了多久、在哪些天练习、时间花在哪里、当时有什么收获”,而不是把时长当作技术水平。
## Solution
提供微信小程序中的“记录、日历、成长”三个入口,沿用用户已认可的交互草图。
| 入口 | 第一期开通的能力 |
| --- | --- |
| 记录 | 从平铺项目进入课后登记;填写实际练习日期、整堂时长和选填笔记;查看近期记录;进入项目管理 |
| 日历 | 识别有练习的日期;点选日期查看当天记录;为所选日期补记,或更正已有记录 |
| 成长 | 累计时长、练习天数、周/月时长趋势、各项目时长与占比 |
首次使用预置八个可编辑项目:零基础、基础提升、软开素质、足髋训练、核心臀腿、小球核心、天鹅臂颈、呼吸训练。这些是来自用户使用习惯的便捷名称,不是芭蕾训练的标准分类。
数据关联微信身份并存于现有后端使用的数据库,同一微信身份换设备后可恢复记录。新账户从空记录开始;不导入 iHour 历史,不带入截图中的累计数字。练习目标、成就徽章和分享卡片留待后续版本。
## User Stories
1. 作为芭蕾爱好者,我希望打开小程序即可理解“记录、日历、成长”三个入口,以便快速找到登记和回顾的位置。
2. 作为首次使用者,我希望获得八个熟悉的预置项目,以便无需先建立分类体系就能记录练习。
3. 作为首次使用者,我希望历史与统计真实地从零开始,以便区分自己的投入和产品演示数据。
4. 作为上课的学员,我希望练完后手动填写整堂课的时长,以便不在上课时操作计时器。
5. 作为上课的学员,我希望一条记录只选择一个项目,以便无需拆分课堂环节,也不会重复计算同一段时间。
6. 作为自主练习者,我希望能另外登记一次练习,以便保留课程之外的投入。
7. 作为记录者,我希望常用项目平铺展示,以便直接选中课名或练习名称。
8. 作为记录者,我希望日期默认今天,以便完成常见的课后登记。
9. 作为漏记过练习的人,我希望能选择实际练习日期补记,以便日历和趋势反映练习发生的时间。
10. 作为记录者,我希望按分钟填写时长,以便保留不足一小时的练习。
11. 作为记录者,我希望选填老师反馈或个人收获,以便回顾那一次练习的具体内容。
12. 作为记录者,我希望不写笔记也能保存,以便只想记时长时快速完成。
13. 作为记录者,我希望无效日期、时长或缺失项目得到明确提示,以便在保存前改正输入。
14. 作为记录者,我希望可以取消登记,以便未完成的输入不会变成正式记录。
15. 作为记录者,我希望保存成功后回到发起登记的页面并看到结果,以便确认本次操作已经完成。
16. 作为记录者,我希望保存失败时保留输入并允许重试,以便无需重新填写整堂课的信息。
17. 作为记录者,我希望重复点击或重试同一次保存不会生成重复记录,以便统计不会被网络问题放大。
18. 作为回顾练习的人,我希望在记录页看到近期记录,以便检查刚刚登记的信息。
19. 作为误填信息的人,我希望更正记录的项目、日期、时长和笔记,以便保留准确的练习历史。
20. 作为误记练习的人,我希望删除单条错误记录,以便将其从日历和统计中移除。
21. 作为回顾练习的人,我希望日历标记有记录的日期,以便看清练习在时间上的分布。
22. 作为回顾练习的人,我希望点选日期后看到当天的全部记录及笔记,以便回想那天练了什么。
23. 作为补记练习的人,我希望从日历发起登记时沿用所选日期,以便不必重复选择日期。
24. 作为更正记录的人,我希望修改后相关日期、累计与趋势一起更新,以便不同页面展示一致的事实。
25. 作为有个人练习习惯的人,我希望新增项目,以便使用自己的课程或练习名称。
26. 作为调整课名的人,我希望给项目改名,以便名称符合现在的用法。
27. 作为回顾历史的人,我希望改名后的项目在旧记录和统计里也统一使用新名称,以便仍能识别为同一个项目。
28. 作为整理项目的人,我希望删除没有使用过的项目,以便缩短日常选择列表。
29. 作为停止某类练习的人,我希望将有记录的项目移出常用列表并保留历史,以便不用担心整理项目导致数据丢失。
30. 作为回顾历史的人,我希望归档项目的记录、笔记和时长仍可查看和更正,以便过去的练习不会消失。
31. 作为关注投入的人,我希望看到准确的累计时长,以便了解自己在芭蕾练习上投入了多少时间。
32. 作为关注练习习惯的人,我希望同一天无论登记几次都只算一个练习日,以便练习天数不被记录条数放大。
33. 作为回顾近期练习的人,我希望切换周和月查看时长趋势,以便了解一段时间内的练习安排。
34. 作为回顾练习结构的人,我希望看到各项目时长及占比,以便了解投入分布。
35. 作为需要休息的练习者,我希望没有练习的日期如实留空,以便回顾不依赖连续打卡或惩罚休息日。
36. 作为使用多个设备的人,我希望同一微信身份能读取已保存的项目和记录,以便更换设备后继续使用。
37. 作为记录个人笔记的人,我希望自己的数据只能由自己的身份读取和修改,以便笔记和练习历史保持私密。
38. 作为遇到网络或登录问题的人,我希望界面说明当前未能加载或保存,而不把失败显示为零记录或保存成功,以便判断是否需要重试。
## Implementation Decisions
### 决策来源与实现默认值
- 用户已确认:练后填写、整堂记录、选填笔记、八个可编辑平铺项目、删除项目保留历史、改名统一显示新名称、三页流程、云端身份关联保存、不衔接旧数据、激励与分享后置。
- 已认可的原型决定页面入口、操作顺序、保存后返回位置、空状态与更正路径。正式界面适配小程序;演示场景切换、固定演示日期和演示数据面板不进入产品。
- 为使规格可执行,采用已讨论过的常规默认值:正整数分钟、日期默认今天、允许过去日期而不允许未来日期、周一至周日为一周、自然月为一个月。这些是实现默认值,不表述为用户逐项确认的产品取舍。
- 日历日期统一按一个业务时区解释,首期默认 Asia/Shanghai;练习日期存为日期值,与创建时间分开。不依据设备时区或创建时间重新划分已保存记录的日期,首期不增加时区设置页面。
- 下文身份校验、失败反馈、幂等写入和数据约束属于完成云端保存及准确统计所需的实现要求,不扩展为新的独立产品功能。
### 架构与模块职责
- 延续 Taro React 小程序和 Go/PostgreSQL 后端。云端保存指现有后端持久化,不因此改用另一套云服务或建立第二套业务数据源。
- 身份与会话模块负责核验微信身份、映射内部用户标识、建立与校验业务会话。外部身份核验集中在一个适配边界;不得将客户端自报的用户标识直接作为授权依据。
- 练习业务模块统一负责项目管理、记录增删改查和回顾统计。对外提供业务操作,内部封装归属校验、分钟与日期规则、归档规则及数据库操作;不按每个页面重复实现统计逻辑。
- 小程序负责页面状态、表单反馈与展示,通过统一请求边界使用业务接口。服务端对所有写入再次校验,不依赖客户端校验保证正确性。
- 复用现有服务启动、数据库连接和 HTTP 请求处理结构。现有存活及就绪检查继续保持职责独立;新增业务接口必须校验身份。
- 首期统计直接从有效记录计算,避免维护另一套容易失真的累计余额。需要优化时以相同可观察结果为约束,不提前建立复杂缓存或异步统计系统。
### 数据模型与不变量
| 对象 | 必要信息 | 约束 |
| --- | --- | --- |
| 用户 | 内部身份、经服务端验证的微信身份关联 | 同一身份恢复同一份数据;身份密钥与平台凭据仅存于服务端 |
| 练习项目 | 稳定标识、所属用户、当前名称、是否归档 | 用户之间隔离;改名保留标识;有历史记录时不可级联删除 |
| 练习记录 | 稳定标识、所属用户、项目标识、实际练习日期、整数分钟、可空文本笔记 | 每条只属于一个本人的项目;日期不晚于业务当天;分钟大于零;笔记可留空 |
| 写入识别信息 | 用户范围内的请求标识及处理结果 | 同一次新增的重试可识别;不能吞掉用户有意新建的第二条相同内容记录 |
- 服务端首次建立用户时一次性创建八个预置项目。初始化应原子且可重入;再次登录不得重复创建,也不得恢复用户已删除或归档的预置项目。
- 项目改名后,历史记录通过同一项目标识读取当前名称。记录日期、分钟与笔记不变,不另存一份用于展示的旧项目名称。
- 没有记录的项目可以删除;存在记录的项目执行归档。判定与移除应具有一致性,避免与同时新增记录竞争时删掉历史。
- 归档项目不出现在新增记录的项目选择中,仍参与历史查询和全部统计。编辑其原有记录时可保留该项目或改选一个活跃项目;不能把另一条记录新改入已归档项目。
- 删除练习记录后,该记录不再参与日历、练习天数和时长统计。项目归档与记录删除是不同操作:前者保留历史,后者去掉误记。
- 新增、改名时拒绝全空白项目名;笔记按普通文本保存和展示。客户端与服务端保持一致的输入长度限制和错误提示,不把原型中的临时输入上限当作产品决策。
- 所有关联校验都包含用户归属;不能通过猜测另一个项目或记录标识跨账户读取或修改数据。
### 接口契约
以下约定业务输入输出,不预先固定路由命名或内部函数形状。所有业务操作从有效会话识别用户,错误返回应能区分需要重新登录、输入无效、对象不可用和暂时性服务失败。
| 操作 | 输入与条件 | 可观察结果 |
| --- | --- | --- |
| 建立/恢复会话 | 微信端取得的有效身份交换凭据 | 返回业务会话;恢复同一用户,必要时完成一次性预置初始化 |
| 查询项目 | 有效会话 | 返回当前活跃项目;查询历史时仍能解析归档项目的名称和状态 |
| 新增/改名项目 | 名称;改名另带项目标识 | 返回稳定标识与最新名称;改名在后续历史和统计查询中统一生效 |
| 移除项目 | 本人项目标识 | 未使用项目删除,已使用项目归档;结果明确告知采取的操作 |
| 查询练习记录 | 日期或日期范围;近期列表使用有界查询 | 返回本人记录、当前项目名称、日期、分钟及笔记;归档项目记录仍可返回 |
| 新增记录 | 活跃项目、练习日期、分钟、可空笔记、此次提交标识 | 完整持久化后返回记录;相同提交标识的重试不重复新增 |
| 更正记录 | 本人记录标识和完整有效表单 | 原记录被更新;不新增记录;遵守归档项目的编辑规则 |
| 删除记录 | 本人记录标识 | 记录不再可见且不参与汇总;重试不会作用于其他记录 |
| 查询回顾 | 累计范围、选定周或月等所需范围 | 返回总分钟、去重练习天数、按日期的分钟及项目分布;结果使用一致的范围口径 |
- 对同一次新增,客户端在等待或重试期间复用提交标识,首次成功后结束该次提交;再次主动登记生成新的标识。重复提交不同内容时应给出可识别错误,不静默覆盖第一次结果。
- 加载失败与有效空结果是不同状态。失败不得被映射为零分钟、零天或空历史;保存提示以服务端确认持久化为准。
- 写入成功后更新或重新获取受影响的记录、日历和成长数据。迟到的旧请求响应不得覆盖新结果;返回来源页面时应能看到本次修改。
- 会话失效时允许重新建立会话并继续操作;在结果未确认前保留用户填写内容。客户端仅作临时表单保留,不承担跨设备数据持久化或离线冲突合并。
### 页面与交互
- 记录页以活跃项目平铺列表作为主要录入入口,展示近期记录并提供项目管理入口;新用户看到真实空状态和预置项目。
- 记录表单包含项目、实际练习日期、分钟、选填笔记。整堂课只产生一条记录,额外练习另建记录。保存期间阻止重复操作;失败保留表单;取消不写入。
- 从记录页新增时日期默认今天;从日历新增时沿用选中日期;编辑时读取原记录。保存完成回到原页面,并展示成功反馈及更新后的数据。
- 记录删除沿用原型中的误记删除路径并清楚提示影响,避免将“移除项目”和“删除记录”混淆。无效提交不能关闭表单或显示成功。
- 项目管理支持新增、改名和移除。有历史时明确告知将从常用项目中移除、历史仍保留;首期不增加项目分组、层级或独立归档恢复中心。
- 日历支持月份切换、练习日期标记和当天明细。没有记录的日期展示可补记的空状态;有多条时完整列出,不把当天汇总伪装成一堂课。
- 成长页展示累计、练习天数、周/月趋势及项目时长分布。采用原型中的趋势切换路径;首期不再增加年趋势或任意范围分析面板。
- 所有项目均移出活跃列表时,仍可查看历史和成长,并提供新增项目入口。零记录时占比展示为空状态,不出现无效百分比或虚构数据。
### 统计口径
- 总时长等于所选范围内有效记录的整数分钟之和;先汇总,再格式化为小时和分钟。展示取整不得反向参与统计。
- 练习天数等于所选范围内至少有一条有效记录的不同练习日期数,不等于记录条数、注册天数或连续打卡天数。
- 每条记录计入一个项目一次;所有项目分钟之和等于同范围总分钟。有历史的归档项目照常计入,名称使用最新名称。
- 项目占比以该项目分钟除以同范围总分钟计算;零总量时不做除法。显示百分比可按统一精度取整,因此显示值总和可能有舍入差异,不能靠改动分钟数强凑。
- 周趋势按所选周的日期汇总,月趋势按所选自然月的日期汇总;无记录日为零。日历与图表按实际练习日期归属,不按提交日期归属。
- 补记、更改日期、更改项目、修改分钟或删除记录后,所有受影响日期、范围和项目的统计应重新反映有效记录。改名和归档本身不改变累计时长或练习天数。
## Testing Decisions
### 主要自动化边界
优先使用现有 HTTP handler 的公开请求/响应边界,覆盖身份后的项目、记录、回顾完整业务路径。现有健康检查测试已经使用 Go 标准测试工具和 HTTP 测试请求验证状态码、响应及上下文行为,可沿用该风格。
业务集成测试连接独立 PostgreSQL 测试数据库或隔离命名空间,运行相同的数据结构初始化,验证真实持久化、关联和查询。只在外部微信身份核验边界替换不可控的平台网络响应,业务会话、归属校验和数据库行为继续实际执行。不为每个内部函数、SQL 语句或页面组件另设一套测试边界。
仓库当前只有健康检查测试,没有项目、练习记录或前端端到端测试。上述业务测试环境需要随功能建立;默认不另引入小程序端到端自动化框架。该安排是依据现有测试入口提出的执行默认方案,尚未经用户单独确认;不影响已经确认的产品范围。
### 验收场景
| 场景 | 对外结果 |
| --- | --- |
| 同一身份首次进入与再次登录 | 首次得到八个活跃项目和零记录;再次进入不重复预置、不恢复已移除项目;有效历史保持不变 |
| 不同用户访问同一对象标识 | 第二个用户不能读取、更改或删除第一个用户的项目及记录;接口不泄露其笔记 |
| 同日登记 90 分钟课程及 15 分钟额外练习 | 两条记录、105 分钟、1 个练习日;项目分布分钟合计为 105 |
| 将 90 分钟改为 60,再删除 15 分钟误记 | 先得到 75 分钟,再得到 60 分钟;日历、近期记录与成长一致 |
| 将同日两条中的一条移到另一日期 | 总分钟不变,练习日从 1 变为 2;原日期和新日期的日历与趋势同时变化 |
| 改名并归档有记录的项目 | 历史和统计统一显示新名称,时长及练习天数不变;新增选择器中不再出现该项目 |
| 编辑归档项目原有记录 | 可更正日期、时长、笔记并保留原项目,或改选活跃项目;不能新增记录到归档项目 |
| 删除未使用项目并重新登录 | 项目仍保持移除状态,不被再次初始化;仍可添加新的项目 |
| 删除某天最后一条记录 | 该日期不再计入练习天数;删除全部记录后累计为 0、练习天数为 0,分布展示空状态 |
| 分钟精度与边界日期 | 45 分钟和 30 分钟合计显示 1 小时 15 分钟;月底、周日/周一的记录按约定边界归属,补记不落在提交日 |
| 可空笔记和无效表单 | 空笔记成功保存;零、负数、非整数分钟、未来日期、空项目名以及不可用项目写入被拒绝,不产生部分数据 |
| 重复提交与主动重复登记 | 同一提交标识重试只得到一条记录;用户两次主动登记相同内容可得到两条记录 |
| 提交已成功但响应丢失 | 使用原提交标识重试后得到已保存记录,不重复累计;输入不会因未经确认的结果而丢失 |
| 服务异常或会话失效 | 明确区分失败与空历史;重试或重新登录后可继续;不显示虚假保存成功 |
好的测试从请求开始,断言返回结果及后续读取能看到的事实;时长、练习天数和归档语义均通过业务接口验证。不以内部调用次数、SQL 字符串、组件结构或样式快照替代业务结果。日期测试使用受控的业务当天,避免执行日期不同造成不稳定。
### 小程序与真实平台验收
- 用微信开发者工具走完三页导航、首次空状态、整堂记录、补记、更正、误记删除、项目改名与归档,核对表单键盘、滚动和窄屏布局。
- 用真实微信身份完成首次使用、会话恢复和另一设备读取同一份记录;重启客户端后仍能读取,另一身份看不到这些记录。
- 检查弱网/断网保存失败、重复点击、会话失效、重新进入页面后的反馈。确认保存成功来源于真实后端响应。
- 小程序源码变更后,在其项目目录执行 `pnpm typecheck`、`pnpm build`;后端源码变更后,在其项目目录执行 `go vet ./...`、`go test -race ./...`、`go build -o bin/api ./cmd/server`。若调整部署配置,另验证 Compose 和镜像构建。
- 静态检查、构建、HTTP 集成测试和真实微信设备验收分别记录结果;任何一项通过均不替代其他项。本文是规格,本轮未执行这些生产实现检查。
## Out of Scope
- 实时计时器、后台计时、番茄钟,以及把一堂课拆为多个动作或训练环节。
- 自建课程、教学视频、动作知识库、训练计划、自动推荐、技能等级评估或医疗/康复建议。
- 语音录入、LLM 解析练习、动作识别。其他尚未确认的规划事项不自动扩充本规格。
- 练习目标、成就徽章、分享卡片、连续打卡奖励和社交排行。
- iHour 自动同步、数据导入、历史迁移、期初累计;普通的按实际日期补记仍在范围内。
- 项目父子层级、同时给多项目累计同一条记录、完整归档管理或回收站体系。
- 笔记附件、图片或视频上传,以及笔记内容的自动分析。
- 独立手机号注册、多平台原生客户端、订阅或付费体系。
- 离线保存队列、多设备同时编辑冲突合并、实时推送同步。已确认的云端记录跨设备读取与恢复仍在范围内。
- 本次规格交付不包含业务代码实现、生产部署或发布。
## Further Notes
### 依据与交付边界
- 需求依据为当前对话及已解决的[记录流程](issues/02-recording-flow.md)、[项目组织](issues/03-practice-organization.md)、[成长回顾](issues/04-growth-review.md)、[数据保存](issues/05-record-continuity.md)、[核心交互](issues/06-core-flow-prototype.md)决策。
- [iHour 调研](issues/01-ihour-evidence.md)用于理解参考产品;用户截图中的数字与布局是使用证据,不是导入要求,也不构成训练分类或能力评估标准。
- [用户认可的交互草图](/Users/yuxuanhui/.codex/visualizations/2026/09/28/01a0e6a5-d2a3-7f51-bff3-545ef8c42df0/ballet-practice-flow.html)是本机设计参考,不直接复制为生产实现。原型使用内存和虚构样例,未接入微信或后端;正式实现不得依赖该本机路径运行。
- 原型已实际验证新增、编辑、删除、改名、归档、日历回看和周/月切换,并检查过窄屏显示;这些证据只说明流程草图可操作,不证明生产持久化、登录或跨设备恢复可用。
- 同目录的其他规划票保持原状,未纳入这轮已确认范围。本规格不宣称整张规划地图全部完成。
### 实现前置条件
- 当前仓库是小程序欢迎页与 Go 服务健康检查骨架,尚无业务数据结构、身份认证、项目/记录接口或成长页面。应把本规格作为完整业务闭环的新实现,不能假设相应接口已经存在。
- 当前项目声明 Taro 4.2.1、React 18.3.1、Taroify 1.0.6,后端使用 Go、pgx 和 PostgreSQL;实现时依照各项目约定核对实际依赖和所需官方文档。此处版本描述来自配置读取,不表示本轮运行过这些工具。
- 实现阶段需要为新业务结构建立可重复执行的数据迁移,并为测试提供隔离数据库。不得使用用户实际练习数据作破坏性验收样本。
- 微信身份交换、会话生命周期、平台所需网络配置和可用的 HTTPS 后端地址需按真实应用配置接通并核实。平台凭据不能放入小程序包,部署及凭据配置不能由“规格已完成”推定为已授权发布。
- `ready-for-agent` 表示范围和验收依据足以交给开发代理,不表示业务代码已完成、构建已通过或产品已经上线。
+13
View File
@@ -9,3 +9,16 @@
修改小程序后,在 `miniprogram/` 执行 `pnpm typecheck` 和 `pnpm build`。修改后端后,在 `backend/` 执行 `go vet ./...`、`go test -race ./...` 和 `go build -o bin/api ./cmd/server`;部署配置变更另需检查 Compose 和镜像构建。仅报告实际执行的验证。
## Agent skills
### Issue tracker
任务使用本地 Markdown 跟踪;读写事项或规格前,先读 `docs/agents/issue-tracker.md`。
### Triage labels
采用默认的五个分流标签;分流事项前,先读 `docs/agents/triage-labels.md`。
### Domain docs
采用 single-context 布局;探索代码库前,先读 `docs/agents/domain.md`。
+34 -3
View File
@@ -1,6 +1,6 @@
# Ballet Island
同一 Git 仓库中的两个独立项目,分别安装依赖、运行与构建。当前提供项目骨架、欢迎页和服务健康检查,业务需求后续补充。
同一 Git 仓库中的两个独立项目,分别安装依赖、运行与构建。第一期提供课后练习登记、日历回看、成长统计和练习项目管理,通过微信身份关联 PostgreSQL 中的个人数据。
## 目录
@@ -12,10 +12,12 @@ miniprogram/ Taro 4.2.1 + React 18 + TypeScript + Taroify 1.0.6
pnpm-lock.yaml 小程序依赖锁文件
node_modules/ 小程序本地依赖(不提交)
.nvmrc Node 版本
.agents/skills/taroify/ Taroify Agent Skill
backend/ Go 1.26 + PostgreSQL 18
cmd/server/ 服务入口、数据库连接与优雅退出
internal/httpapi/ HTTP 路由与测试
internal/identity/ 微信身份核验适配器
internal/practice/ 会话、项目、记录与回顾业务
internal/database/ 内嵌版本迁移
go.mod / go.sum Go 模块与依赖
Dockerfile 后端镜像构建
compose.yaml API 与数据库编排
@@ -37,7 +39,7 @@ pnpm install --frozen-lockfile
pnpm dev
```
微信开发者工具导入 `miniprogram/`,AppID 为 `wx6dfbf1021db8aee0`,编译目录为 `dist/`。首页“检查连接”请求后端 `/readyz`,同时检查数据库是否可用。
微信开发者工具导入 `miniprogram/`,AppID 为 `wx6dfbf1021db8aee0`,编译目录为 `dist/`。底部入口为“记录、日历、成长”。首次请求通过 `Taro.login` 取得 code,在后端核验后建立业务会话;登录失败或网络失败会显示重试入口,不会当作空记录。
默认 API 为 `http://127.0.0.1:8080`。使用本地 HTTP 地址时,在开发者工具的本地设置中开启“不校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书”,仅用于本地调试。真机需使用能够访问的接口地址,并按微信要求配置域名。
@@ -50,6 +52,10 @@ TARO_APP_API_BASE_URL=https://api.example.com pnpm build
`TARO_APP_API_BASE_URL` 是公开接口地址,不自动读取后端 `.env`;未设置时使用本机默认地址。
首次登录原子创建八个可编辑项目,练习数据从零开始。表单支持补记、更正和误记删除;项目改名同步显示于历史,有记录的项目移除时归档。近期列表按最后登记或更正时间排序,使刚保存的补记也能立即看到;日历和统计使用实际练习日期,统一按 Asia/Shanghai 解释,周一开始一周。
项目名最多 40 个 Unicode 字符,笔记最多 2000 个字符;前后端使用相同校验。分钟为正整数,按 PostgreSQL integer 存储范围上限为 2147483647。笔记只作为普通文本显示。每次主动新增生成独立提交标识;结果未确认时保留该标识与完整输入,重试不重复累计。首期不提供离线保存队列。
## 后端开发
需要 Go 1.26 或更高版本,以及 Docker Compose。仅使用 Docker 运行后端时无需本机 Go。
@@ -67,6 +73,10 @@ docker compose up -d --wait postgres
后端全程无需 Node.js 或 pnpm。
在可信的 `backend/.env` 中设置 `WECHAT_APP_ID` 和 `WECHAT_APP_SECRET`,AppID 必须与小程序一致。AppSecret 只存在于服务端配置中,不能放入小程序源码或构建变量。通过[微信官方 code2Session 接口](https://developers.weixin.qq.com/miniprogram/dev/server/API/user-login/api_code2session.html)核验身份,不接受客户端自报的用户标识;业务会话为随机 bearer token,有效期 30 天,数据库只存 token 摘要,过期后小程序自动重新登录一次。
服务启动会在一个带锁的事务中运行尚未应用的版本迁移,初始化用户、会话、项目、练习和幂等回执表。后续启动不会重置数据或补回已移除项目。迁移文件位于 `backend/internal/database/`,已经应用的迁移应保留不改,通过新增版本演进结构。没有微信凭据时健康检查仍可运行,登录返回 503;配置缺失不提供模拟身份或开发后门。
## Docker 运行与部署
以下命令在 `backend/` 中执行:
@@ -95,6 +105,7 @@ PostgreSQL 18 数据卷按[官方镜像说明](https://hub.docker.com/_/postgres
```sh
pnpm typecheck
pnpm test:client
pnpm build
```
@@ -104,13 +115,33 @@ pnpm build
go vet ./...
go test -race ./...
go build -o bin/api ./cmd/server
# 真实 PostgreSQL 18 HTTP 集成验证,自动创建和清理临时测试容器。
./scripts/test-integration.sh
```
集成脚本只使用独立、内存存储的 PostgreSQL 容器,不读取业务 `.env`,不挂载 Compose 数据卷。每个用例创建自己的 schema 并在结束后清理。已有独立测试数据库时可通过 `TEST_DATABASE_URL` 指定(用户须有创建 schema 的权限);`REQUIRE_TEST_DATABASE=1` 可让缺少配置直接失败。单独执行 `go test` 而未提供测试数据库时,会明确跳过数据库集成用例,不能据此宣称业务集成通过。
集成测试经过真实 HTTP handler、业务会话和 PostgreSQL,仅在微信网络核验处使用受控响应,覆盖首次初始化、重复登录、身份隔离、归档与更正、分钟汇总、周/月边界、分页、提交重试和并发。`pnpm test:client` 使用 Node 内置测试工具验证请求层重新登录、待确认输入与提交标识保留、失败反馈和成功写入通知;只替换 Taro 网络与本地存储调用,不引入小程序端到端框架。
保存未确认时不能通过表单取消;系统返回会提示,待确认输入只在当前小程序进程内暂存,再次登记会恢复原提交。完全退出小程序会失去该临时表单,重新进入后应先查看云端历史;这不提供离线队列或进程重启后的草稿持久化。微信开发者工具中的键盘/滚动/窄屏表现、真实微信首次登录、跨设备恢复及断网流程仍需独立验收,构建和模拟微信响应均不能替代这些验证。
产物分别在 `miniprogram/dist/` 和 `backend/bin/api`,均不进入版本控制。
| 接口 | 用途 | 响应 |
| --- | --- | --- |
| `GET /healthz` | HTTP 服务存活检查 | `200 {"status":"ok"}` |
| `GET /readyz` | 实际检查 PostgreSQL 连接 | 可用时 200,不可用时 `503 {"status":"unavailable"}` |
| `POST /v1/session` | 微信 code 换业务会话 | `{token, expiresAt}` |
| `GET /v1/projects` | 活跃项目;`includeArchived=true` 包含归档 | `{projects, today}` |
| `POST /v1/projects` / `PUT /v1/projects/{id}` | 新增 / 改名,JSON `{name}` | 最新项目 |
| `DELETE /v1/projects/{id}` | 删除未使用项目或归档 | `{action: "deleted" / "archived"}` |
| `GET /v1/records` | `from` / `to` 日期范围、`limit`(1–100,默认20)、`cursor` | `{records, nextCursor}` |
| `GET /v1/records/{id}` | 读取本人单条记录 | 项目当前名称、日期、分钟、笔记 |
| `POST /v1/records` / `PUT /v1/records/{id}` | 新增 / 更正 | `{projectId, date, minutes, note}`;新增另需 `requestId`(16–128位字母、数字、下划线或短横线) |
| `DELETE /v1/records/{id}` | 删除误记,可安全重试 | `{action: "deleted"}` |
| `GET /v1/review?period=week&date=YYYY-MM-DD` | `week` / `month`,日期默认业务今天 | 累计、选定周期、每日分钟与项目分布 |
除会话交换和健康检查外,接口均需 `Authorization: Bearer <token>`。失败以 `{error:{code,message}}` 返回:401 重新登录、400 输入无效、404 对象不可用、409 归档项目或提交内容冲突、410 已删除记录的旧提交、503 暂时性失败。错误响应不返回数据库细节、微信密钥或其他用户的数据。移除/记录写入在用户范围内串行化,防止并发删除项目损坏历史。
Go 服务启动时检查数据库连接,收到 `SIGINT` / `SIGTERM` 时停止接收请求并关闭连接池。
+4
View File
@@ -11,3 +11,7 @@ HTTP_ADDR=127.0.0.1:8080
API_PORT=8080
API_BIND=127.0.0.1
# Server-only WeChat credentials. Never put AppSecret in the mini program bundle.
# Without these, health checks work but identity exchange returns 503.
WECHAT_APP_ID=wx6dfbf1021db8aee0
WECHAT_APP_SECRET=
+7 -2
View File
@@ -11,7 +11,9 @@ import (
"syscall"
"time"
"ballet-island/backend/internal/database"
"ballet-island/backend/internal/httpapi"
"ballet-island/backend/internal/identity"
"github.com/jackc/pgx/v5/pgxpool"
)
@@ -40,8 +42,11 @@ func run() error {
}
defer pool.Close()
startupCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
startupCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
err = pool.Ping(startupCtx)
if err == nil {
err = database.Migrate(startupCtx, pool)
}
cancel()
if err != nil {
return fmt.Errorf("connect database: %w", err)
@@ -53,7 +58,7 @@ func run() error {
}
server := &http.Server{
Addr: addr,
Handler: httpapi.NewHandler(pool.Ping),
Handler: httpapi.NewAppHandler(pool, &identity.WeChat{AppID: os.Getenv("WECHAT_APP_ID"), AppSecret: os.Getenv("WECHAT_APP_SECRET")}, nil),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
+2
View File
@@ -32,6 +32,8 @@ services:
PGUSER: ${PGUSER:?Set PGUSER in .env}
PGPASSWORD: ${PGPASSWORD:?Set PGPASSWORD in .env}
PGSSLMODE: disable
WECHAT_APP_ID: ${WECHAT_APP_ID:-}
WECHAT_APP_SECRET: ${WECHAT_APP_SECRET:-}
ports:
- "${API_BIND:-127.0.0.1}:${API_PORT:-8080}:8080"
depends_on:
@@ -0,0 +1,47 @@
CREATE TABLE users (
id text PRIMARY KEY,
wechat_identity text NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE sessions (
token_hash text PRIMARY KEY,
user_id text NOT NULL REFERENCES users(id),
expires_at timestamptz NOT NULL
);
CREATE INDEX sessions_expiry ON sessions(expires_at);
CREATE TABLE projects (
id text PRIMARY KEY,
user_id text NOT NULL REFERENCES users(id),
name text NOT NULL CHECK (char_length(btrim(name)) BETWEEN 1 AND 40),
archived boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
position bigint GENERATED ALWAYS AS IDENTITY,
UNIQUE (user_id, id)
);
CREATE INDEX projects_owner ON projects(user_id, position);
CREATE TABLE records (
id text PRIMARY KEY,
user_id text NOT NULL REFERENCES users(id),
project_id text NOT NULL,
practice_date date NOT NULL CHECK (practice_date >= DATE '0001-01-01'),
minutes integer NOT NULL CHECK (minutes > 0),
note text NOT NULL DEFAULT '' CHECK (char_length(note) <= 2000),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
FOREIGN KEY (user_id, project_id) REFERENCES projects(user_id, id) ON DELETE RESTRICT
);
CREATE INDEX records_owner_date ON records(user_id, practice_date DESC, id DESC);
CREATE INDEX records_owner_updated ON records(user_id, updated_at DESC, id DESC);
CREATE INDEX records_project ON records(user_id, project_id);
-- Keep submission receipts after a record is deleted so a delayed retry cannot resurrect it.
CREATE TABLE submissions (
user_id text NOT NULL REFERENCES users(id),
request_id text NOT NULL,
payload_hash text NOT NULL,
record_id text NOT NULL,
PRIMARY KEY (user_id, request_id)
);
+41
View File
@@ -0,0 +1,41 @@
package database
import (
"context"
_ "embed"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
//go:embed 001_practice.sql
var practiceSQL string
// Migrate applies versioned migrations atomically. A transaction-scoped lock keeps
// concurrent service starts from applying the same version twice.
func Migrate(ctx context.Context, pool *pgxpool.Pool) error {
tx, err := pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
if _, err = tx.Exec(ctx, "SELECT pg_advisory_xact_lock(684127930)"); err != nil {
return err
}
if _, err = tx.Exec(ctx, "CREATE TABLE IF NOT EXISTS schema_migrations (version integer PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())"); err != nil {
return err
}
var applied bool
if err = tx.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version=1)").Scan(&applied); err != nil {
return err
}
if !applied {
if _, err = tx.Exec(ctx, practiceSQL); err != nil {
return fmt.Errorf("migration 1: %w", err)
}
if _, err = tx.Exec(ctx, "INSERT INTO schema_migrations(version) VALUES (1)"); err != nil {
return err
}
}
return tx.Commit(ctx)
}
+210
View File
@@ -0,0 +1,210 @@
package httpapi
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"ballet-island/backend/internal/identity"
"ballet-island/backend/internal/practice"
"github.com/jackc/pgx/v5/pgxpool"
)
// IdentityVerifier is the only external identity dependency. Implementations
// return an app-scoped verified identity and must never trust client user IDs.
type IdentityVerifier interface {
Exchange(context.Context, string) (string, error)
}
type app struct {
store *practice.Store
verifier IdentityVerifier
}
type operation func(http.ResponseWriter, *http.Request, string) error
// NewAppHandler adds authenticated business routes while retaining independent health checks.
func NewAppHandler(pool *pgxpool.Pool, verifier IdentityVerifier, now func() time.Time) http.Handler {
a := &app{store: practice.New(pool, now), verifier: verifier}
mux := http.NewServeMux()
mux.Handle("/", NewHandler(pool.Ping))
mux.HandleFunc("POST /v1/session", a.login)
mux.HandleFunc("GET /v1/projects", a.auth(a.projects))
mux.HandleFunc("POST /v1/projects", a.auth(a.saveProject))
mux.HandleFunc("PUT /v1/projects/{id}", a.auth(a.saveProject))
mux.HandleFunc("DELETE /v1/projects/{id}", a.auth(a.removeProject))
mux.HandleFunc("GET /v1/records", a.auth(a.records))
mux.HandleFunc("GET /v1/records/{id}", a.auth(a.record))
mux.HandleFunc("POST /v1/records", a.auth(a.saveRecord))
mux.HandleFunc("PUT /v1/records/{id}", a.auth(a.saveRecord))
mux.HandleFunc("DELETE /v1/records/{id}", a.auth(a.deleteRecord))
mux.HandleFunc("GET /v1/review", a.auth(a.review))
return mux
}
func respond(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func decode(w http.ResponseWriter, r *http.Request, value any) error {
r.Body = http.MaxBytesReader(w, r.Body, 32*1024)
d := json.NewDecoder(r.Body)
d.DisallowUnknownFields()
if err := d.Decode(value); err != nil {
return practice.ErrInvalid
}
if err := d.Decode(new(any)); err != io.EOF {
return practice.ErrInvalid
}
return nil
}
func failure(w http.ResponseWriter, err error) {
status, code, message := 503, "service_unavailable", "服务暂时不可用,请稍后重试"
switch {
case errors.Is(err, practice.ErrUnauthorized), errors.Is(err, identity.ErrInvalidCode):
status, code, message = 401, "unauthorized", practice.ErrUnauthorized.Error()
case errors.Is(err, practice.ErrInvalid):
status, code, message = 400, "invalid_input", practice.ErrInvalid.Error()
case errors.Is(err, practice.ErrUnavailable):
status, code, message = 404, "unavailable", practice.ErrUnavailable.Error()
case errors.Is(err, practice.ErrArchived):
status, code, message = 409, "archived_project", practice.ErrArchived.Error()
case errors.Is(err, practice.ErrConflict):
status, code, message = 409, "submission_conflict", practice.ErrConflict.Error()
case errors.Is(err, practice.ErrDeleted):
status, code, message = 410, "record_deleted", practice.ErrDeleted.Error()
default:
// Do not log raw database/network errors: they may contain notes, credentials or URLs.
slog.Error("business request failed", "category", code)
}
respond(w, status, map[string]any{"error": map[string]string{"code": code, "message": message}})
}
func (a *app) auth(fn operation) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
defer cancel()
r = r.WithContext(ctx)
header := r.Header.Get("Authorization")
if !strings.HasPrefix(header, "Bearer ") {
failure(w, practice.ErrUnauthorized)
return
}
user, err := a.store.Authenticate(ctx, strings.TrimPrefix(header, "Bearer "))
if err == nil {
err = fn(w, r, user)
}
if err != nil {
failure(w, err)
}
}
}
func (a *app) login(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
defer cancel()
var input struct {
Code string `json:"code"`
}
if err := decode(w, r, &input); err != nil || strings.TrimSpace(input.Code) == "" || len(input.Code) > 512 {
failure(w, practice.ErrInvalid)
return
}
identity, err := a.verifier.Exchange(ctx, input.Code)
if err != nil {
failure(w, err)
return
}
session, err := a.store.Login(ctx, identity)
if err != nil {
failure(w, err)
return
}
respond(w, 200, session)
}
func (a *app) projects(w http.ResponseWriter, r *http.Request, user string) error {
projects, err := a.store.Projects(r.Context(), user, r.URL.Query().Get("includeArchived") == "true")
if err == nil {
respond(w, 200, map[string]any{"projects": projects, "today": a.store.Today()})
}
return err
}
func (a *app) saveProject(w http.ResponseWriter, r *http.Request, user string) error {
var input struct {
Name string `json:"name"`
}
if err := decode(w, r, &input); err != nil {
return err
}
p, err := a.store.SaveProject(r.Context(), user, r.PathValue("id"), input.Name)
if err == nil {
respond(w, 200, p)
}
return err
}
func (a *app) removeProject(w http.ResponseWriter, r *http.Request, user string) error {
mode, err := a.store.RemoveProject(r.Context(), user, r.PathValue("id"))
if err == nil {
respond(w, 200, map[string]string{"action": mode})
}
return err
}
func (a *app) records(w http.ResponseWriter, r *http.Request, user string) error {
q := r.URL.Query()
limit := 20
if q.Has("limit") {
var err error
limit, err = strconv.Atoi(q.Get("limit"))
if err != nil {
return practice.ErrInvalid
}
}
list, err := a.store.Records(r.Context(), user, q.Get("from"), q.Get("to"), q.Get("cursor"), limit)
if err == nil {
respond(w, 200, list)
}
return err
}
func (a *app) record(w http.ResponseWriter, r *http.Request, user string) error {
record, err := a.store.Record(r.Context(), user, r.PathValue("id"))
if err == nil {
respond(w, 200, record)
}
return err
}
func (a *app) saveRecord(w http.ResponseWriter, r *http.Request, user string) error {
var input practice.RecordInput
if err := decode(w, r, &input); err != nil {
return err
}
record, err := a.store.SaveRecord(r.Context(), user, r.PathValue("id"), input)
if err == nil {
respond(w, 200, record)
}
return err
}
func (a *app) deleteRecord(w http.ResponseWriter, r *http.Request, user string) error {
err := a.store.DeleteRecord(r.Context(), user, r.PathValue("id"))
if err == nil {
respond(w, 200, map[string]string{"action": "deleted"})
}
return err
}
func (a *app) review(w http.ResponseWriter, r *http.Request, user string) error {
review, err := a.store.Review(r.Context(), user, r.URL.Query().Get("period"), r.URL.Query().Get("date"))
if err == nil {
respond(w, 200, review)
}
return err
}
+397
View File
@@ -0,0 +1,397 @@
package httpapi_test
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync"
"testing"
"time"
"ballet-island/backend/internal/database"
"ballet-island/backend/internal/httpapi"
"ballet-island/backend/internal/identity"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type testIdentity struct{}
func (testIdentity) Exchange(_ context.Context, code string) (string, error) {
return "test-app:" + code, nil
}
type harness struct {
t *testing.T
h http.Handler
now time.Time
pool *pgxpool.Pool
}
func setup(t *testing.T) *harness {
t.Helper()
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
if os.Getenv("REQUIRE_TEST_DATABASE") == "1" {
t.Fatal("TEST_DATABASE_URL is required")
}
t.Skip("PostgreSQL integration: set TEST_DATABASE_URL or run scripts/test-integration.sh")
}
ctx := context.Background()
admin, err := pgxpool.New(ctx, url)
if err != nil {
t.Fatal(err)
}
t.Cleanup(admin.Close)
schema := fmt.Sprintf("practice_test_%d", time.Now().UnixNano())
quoted := pgx.Identifier{schema}.Sanitize()
if _, err = admin.Exec(ctx, "CREATE SCHEMA "+quoted); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _, _ = admin.Exec(ctx, "DROP SCHEMA "+quoted+" CASCADE") })
config, err := pgxpool.ParseConfig(url)
if err != nil {
t.Fatal(err)
}
config.ConnConfig.RuntimeParams["search_path"] = schema
pool, err := pgxpool.NewWithConfig(ctx, config)
if err != nil {
t.Fatal(err)
}
t.Cleanup(pool.Close)
if err := database.Migrate(ctx, pool); err != nil {
t.Fatal(err)
}
// The same migration path is safe on every service restart.
if err := database.Migrate(ctx, pool); err != nil {
t.Fatal(err)
}
h := &harness{t: t, pool: pool, now: time.Date(2026, 9, 28, 2, 0, 0, 0, time.UTC)}
h.h = httpapi.NewAppHandler(pool, testIdentity{}, func() time.Time { return h.now })
return h
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func TestWeChatExchangeAndSessionExpiry(t *testing.T) {
h := setup(t)
verifier := &identity.WeChat{AppID: "test-app", AppSecret: "server-only", Client: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Scheme != "https" || r.URL.Host != "api.weixin.qq.com" || r.URL.Query().Get("secret") != "server-only" || r.URL.Query().Get("grant_type") != "authorization_code" {
t.Fatal("invalid identity exchange")
}
body := `{"openid":"verified-alice","session_key":"never-return-this"}`
if r.URL.Query().Get("js_code") == "invalid" {
body = `{"errcode":40029,"errmsg":"invalid code"}`
}
if r.URL.Query().Get("js_code") == "upstream-down" {
body = `{"errcode":-1,"errmsg":"system error"}`
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body))}, nil
})}}
h.h = httpapi.NewAppHandler(h.pool, verifier, func() time.Time { return h.now })
h.request("POST", "/v1/session", "", `{"code":"invalid"}`, 401)
h.request("POST", "/v1/session", "", `{"code":"upstream-down"}`, 503)
session := h.request("POST", "/v1/session", "", `{"code":"valid"}`, 200)
serialized, _ := json.Marshal(session)
if strings.Contains(string(serialized), "verified-alice") || strings.Contains(string(serialized), "never-return-this") {
t.Fatal("private identity details leaked")
}
token := session["token"].(string)
h.request("GET", "/v1/projects", token, "", 200)
h.now = h.now.Add(31 * 24 * time.Hour)
h.request("GET", "/v1/projects", token, "", 401)
h.request("GET", "/v1/projects", h.login("valid"), "", 200)
}
func (h *harness) request(method, path, token, body string, want int) map[string]any {
h.t.Helper()
r := httptest.NewRequest(method, path, strings.NewReader(body))
r.Header.Set("Content-Type", "application/json")
if token != "" {
r.Header.Set("Authorization", "Bearer "+token)
}
w := httptest.NewRecorder()
h.h.ServeHTTP(w, r)
if w.Code != want {
h.t.Fatalf("%s %s: got %d %s, want %d", method, path, w.Code, w.Body.String(), want)
}
result := map[string]any{}
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
h.t.Fatal(err)
}
return result
}
func (h *harness) login(code string) string {
h.t.Helper()
return h.request("POST", "/v1/session", "", `{"code":"`+code+`"}`, 200)["token"].(string)
}
func TestFirstLoginSeedsOnceAndSessionsRestoreIdentity(t *testing.T) {
h := setup(t)
token := h.login("alice")
projects := h.request("GET", "/v1/projects", token, "", 200)["projects"].([]any)
if len(projects) != 8 {
t.Fatalf("got %d projects", len(projects))
}
first := projects[0].(map[string]any)
if first["name"] != "零基础" {
t.Fatal(first)
}
h.request("DELETE", "/v1/projects/"+first["id"].(string), token, "", 200)
projects = h.request("GET", "/v1/projects", h.login("alice"), "", 200)["projects"].([]any)
if len(projects) != 7 {
t.Fatal("login must not restore removed presets")
}
h.request("GET", "/v1/projects", "", "", 401)
h.request("GET", "/v1/projects", "invented-session", "", 401)
}
func (h *harness) project(token, name string) string {
h.t.Helper()
body, _ := json.Marshal(map[string]string{"name": name})
return h.request("POST", "/v1/projects", token, string(body), 200)["id"].(string)
}
func recordBody(project, date string, minutes int, note, key string) string {
body, _ := json.Marshal(map[string]any{"projectId": project, "date": date, "minutes": minutes, "note": note, "requestId": key})
return string(body)
}
func (h *harness) summary(token string, minutes, days float64) map[string]any {
h.t.Helper()
review := h.request("GET", "/v1/review?period=week&date=2026-09-28", token, "", 200)
total := review["allTime"].(map[string]any)
if total["totalMinutes"] != minutes || total["practiceDays"] != days {
h.t.Fatalf("unexpected total: %v", total)
}
var projectMinutes float64
for _, raw := range total["projects"].([]any) {
projectMinutes += raw.(map[string]any)["minutes"].(float64)
}
if projectMinutes != minutes {
h.t.Fatalf("project distribution: %v", total)
}
return review
}
func TestRecordsRoundTripCorrectionsAndStatistics(t *testing.T) {
h := setup(t)
token := h.login("alice")
class := h.project(token, "晚课")
extra := h.project(token, "课后练习")
h.summary(token, 0, 0)
one := h.request("POST", "/v1/records", token, recordBody(class, "2026-09-28", 90, "老师反馈:放松肩颈", "lesson-000000001"), 200)
two := h.request("POST", "/v1/records", token, recordBody(extra, "2026-09-28", 15, "", "lesson-000000002"), 200)
h.summary(token, 105, 1)
list := h.request("GET", "/v1/records?from=2026-09-28&to=2026-09-28", token, "", 200)["records"].([]any)
if len(list) != 2 {
t.Fatal(list)
}
id1, id2 := one["id"].(string), two["id"].(string)
h.request("PUT", "/v1/records/"+id1, token, recordBody(class, "2026-09-27", 90, "改到周日", ""), 200)
review := h.summary(token, 105, 2)
if review["period"].(map[string]any)["totalMinutes"] != float64(15) {
t.Fatal("Sunday must belong to the previous week")
}
h.request("PUT", "/v1/records/"+id1, token, recordBody(class, "2026-09-28", 60, "", ""), 200)
h.summary(token, 75, 1)
h.request("DELETE", "/v1/records/"+id2, token, "", 200)
h.summary(token, 60, 1)
h.request("DELETE", "/v1/records/"+id1, token, "", 200)
h.request("DELETE", "/v1/records/"+id1, token, "", 200)
h.summary(token, 0, 0)
}
func TestArchiveRenameAndOwnershipAcrossAccounts(t *testing.T) {
h := setup(t)
alice, bob := h.login("alice"), h.login("bob")
p := h.project(alice, "旧课名")
active := h.project(alice, "新练习")
r := h.request("POST", "/v1/records", alice, recordBody(p, "2026-09-20", 45, "私人笔记", "archive-000000001"), 200)
id := r["id"].(string)
h.request("GET", "/v1/records/"+id, bob, "", 404)
h.request("PUT", "/v1/records/"+id, bob, recordBody(p, "2026-09-20", 60, "偷改", ""), 404)
h.request("DELETE", "/v1/records/"+id, bob, "", 200)
h.request("PUT", "/v1/projects/"+p, bob, `{"name":"偷改"}`, 404)
h.request("DELETE", "/v1/projects/"+p, bob, "", 404)
h.request("POST", "/v1/records", bob, recordBody(p, "2026-09-20", 60, "", "cross-user-000001"), 404)
h.summary(bob, 0, 0)
h.request("PUT", "/v1/projects/"+p, alice, `{"name":"基础晚课"}`, 200)
removed := h.request("DELETE", "/v1/projects/"+p, alice, "", 200)
if removed["action"] != "archived" {
t.Fatal(removed)
}
record := h.request("GET", "/v1/records/"+id, alice, "", 200)
if record["projectName"] != "基础晚课" || record["note"] != "私人笔记" || record["archived"] != true {
t.Fatal(record)
}
for _, p0 := range h.request("GET", "/v1/projects", alice, "", 200)["projects"].([]any) {
if p0.(map[string]any)["id"] == p {
t.Fatal("archived project in active choices")
}
}
summary := h.summary(alice, 45, 1)["allTime"].(map[string]any)
if summary["projects"].([]any)[0].(map[string]any)["name"] != "基础晚课" {
t.Fatal(summary)
}
h.request("POST", "/v1/records", alice, recordBody(p, "2026-09-20", 30, "", "archive-000000002"), 409)
h.request("PUT", "/v1/records/"+id, alice, recordBody(p, "2026-09-21", 30, "仍保留归档项目", ""), 200)
h.request("PUT", "/v1/records/"+id, alice, recordBody(active, "2026-09-21", 30, "改入活跃项目", ""), 200)
h.request("PUT", "/v1/records/"+id, alice, recordBody(p, "2026-09-21", 30, "不能移回归档项目", ""), 409)
// A fresh handler models a second process/device reading the same persisted account.
h.h = httpapi.NewAppHandler(h.pool, testIdentity{}, func() time.Time { return h.now })
h.summary(h.login("alice"), 30, 1)
}
func TestSubmissionRetriesAreIdempotentAndCannotResurrectDeletedRecords(t *testing.T) {
h := setup(t)
token := h.login("alice")
p := h.project(token, "课程")
body := recordBody(p, "2026-09-28", 45, "", "retry-0000000001")
first := h.request("POST", "/v1/records", token, body, 200)
retry := h.request("POST", "/v1/records", h.login("alice"), body, 200)
if first["id"] != retry["id"] {
t.Fatal("response-loss retry duplicated the record")
}
h.request("POST", "/v1/records", token, recordBody(p, "2026-09-28", 46, "", "retry-0000000001"), 409)
h.summary(token, 45, 1)
h.request("POST", "/v1/records", token, recordBody(p, "2026-09-28", 45, "", "retry-0000000002"), 200)
h.summary(token, 90, 1)
h.request("DELETE", "/v1/records/"+first["id"].(string), token, "", 200)
h.request("POST", "/v1/records", token, body, 410)
h.summary(token, 45, 1)
}
func TestInvalidFormsNeverPersistAndDateBoundariesUseShanghai(t *testing.T) {
h := setup(t)
h.now = time.Date(2026, 9, 27, 16, 1, 0, 0, time.UTC) // already Monday in Shanghai
token := h.login("alice")
p := h.project(token, "课程")
for i, entry := range []struct {
date string
minutes int
note string
}{
{"2026-09-28", 0, ""}, {"2026-09-28", -1, ""}, {"2026-09-29", 30, ""},
{"2026-02-30", 30, ""}, {"2026-9-1", 30, ""}, {"0000-01-01", 30, ""},
{"2026-09-28", 30, strings.Repeat("字", 2001)}, {"2026-09-28", 30, "bad\x00note"},
} {
h.request("POST", "/v1/records", token, recordBody(p, entry.date, entry.minutes, entry.note, fmt.Sprintf("invalid-%016d", i)), 400)
}
h.request("POST", "/v1/records", token, `{"projectId":"`+p+`","date":"2026-09-28","minutes":1.5,"requestId":"invalid-decimal1"}`, 400)
h.request("POST", "/v1/records", token, recordBody("", "2026-09-28", 30, "", "missing-00000001"), 400)
h.request("POST", "/v1/projects", token, `{"name":"  \n "}`, 400)
longName, _ := json.Marshal(map[string]string{"name": strings.Repeat("字", 41)})
h.request("POST", "/v1/projects", token, string(longName), 400)
h.summary(token, 0, 0)
h.request("POST", "/v1/records", token, recordBody(p, "2026-08-31", 45, "", "boundary-0000001"), 200)
h.request("POST", "/v1/records", token, recordBody(p, "2026-09-01", 30, "", "boundary-0000002"), 200)
h.summary(token, 75, 2)
aug := h.request("GET", "/v1/review?period=month&date=2026-08-31", token, "", 200)
sep := h.request("GET", "/v1/review?period=month&date=2026-09-28", token, "", 200)
if aug["period"].(map[string]any)["totalMinutes"] != float64(45) || sep["period"].(map[string]any)["totalMinutes"] != float64(30) || len(sep["days"].([]any)) != 30 {
t.Fatal(aug, sep)
}
h.request("POST", "/v1/records", token, recordBody(p, "2026-09-28", 1, "", "boundary-0000003"), 200)
}
func TestRecordPaginationReturnsEveryEntryOnce(t *testing.T) {
h := setup(t)
token := h.login("alice")
p := h.project(token, "课程")
for i := 0; i < 5; i++ {
h.request("POST", "/v1/records", token, recordBody(p, "2026-09-28", i+1, "", fmt.Sprintf("page-%016d", i)), 200)
}
latest := h.request("POST", "/v1/records", token, recordBody(p, "2026-08-01", 45, "补记也应出现在近期记录顶部", "page-backdated-1"), 200)
recent := h.request("GET", "/v1/records?limit=1", token, "", 200)["records"].([]any)
if recent[0].(map[string]any)["id"] != latest["id"] {
t.Fatal("a saved backdated entry must appear in recent records")
}
seen := map[string]bool{}
cursor := ""
for {
page := h.request("GET", "/v1/records?from=2026-09-28&to=2026-09-28&limit=2&cursor="+cursor, token, "", 200)
for _, raw := range page["records"].([]any) {
id := raw.(map[string]any)["id"].(string)
if seen[id] {
t.Fatal("duplicate entry")
}
seen[id] = true
}
cursor = page["nextCursor"].(string)
if cursor == "" {
break
}
}
if len(seen) != 5 {
t.Fatal(seen)
}
}
func TestConcurrentRetriesAndProjectRemovalKeepHistory(t *testing.T) {
h := setup(t)
token := h.login("alice")
p := h.project(token, "课程")
body := recordBody(p, "2026-09-28", 90, "", "concurrent-00001")
responses := make(chan *httptest.ResponseRecorder, 8)
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Go(func() {
r := httptest.NewRequest("POST", "/v1/records", strings.NewReader(body))
r.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
h.h.ServeHTTP(w, r)
responses <- w
})
}
wg.Wait()
close(responses)
for w := range responses {
if w.Code != 200 {
t.Fatalf("concurrent save: %d %s", w.Code, w.Body.String())
}
}
h.summary(token, 90, 1)
// Removing a project and creating a record race for the same user. Whichever
// arrives first, the original history survives and totals remain exact.
var create, remove *httptest.ResponseRecorder
wg.Go(func() {
r := httptest.NewRequest("POST", "/v1/records", strings.NewReader(recordBody(p, "2026-09-28", 15, "", "concurrent-00002")))
r.Header.Set("Authorization", "Bearer "+token)
create = httptest.NewRecorder()
h.h.ServeHTTP(create, r)
})
wg.Go(func() {
r := httptest.NewRequest("DELETE", "/v1/projects/"+p, nil)
r.Header.Set("Authorization", "Bearer "+token)
remove = httptest.NewRecorder()
h.h.ServeHTTP(remove, r)
})
wg.Wait()
if remove.Code != 200 || (create.Code != 200 && create.Code != 409) {
t.Fatal(remove.Code, create.Code)
}
want := float64(90)
if create.Code == 200 {
want = 105
}
h.summary(token, want, 1)
}
func TestDatabaseFailureIsNotAnEmptyHistory(t *testing.T) {
h := setup(t)
token := h.login("alice")
h.pool.Close()
h.request("GET", "/v1/records", token, "", 503)
h.request("GET", "/v1/review?period=week", token, "", 503)
h.request("GET", "/healthz", "", "", 200)
h.request("GET", "/readyz", "", "", 503)
}
+61
View File
@@ -0,0 +1,61 @@
package identity
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"time"
)
var ErrInvalidCode = errors.New("WeChat login code is invalid or expired")
var errUnavailable = errors.New("WeChat identity verification is unavailable")
// WeChat exchanges wx.login codes at the server only. Client is an optional
// network dependency for tests; production uses a bounded HTTPS client.
type WeChat struct {
AppID string
AppSecret string
Client *http.Client
}
// Exchange returns an app-scoped openid after WeChat verifies the code. It never
// returns session_key or wraps URL errors, since exchange URLs contain secrets.
func (w *WeChat) Exchange(ctx context.Context, code string) (string, error) {
if w.AppID == "" || w.AppSecret == "" {
return "", errUnavailable
}
query := url.Values{"appid": {w.AppID}, "secret": {w.AppSecret}, "js_code": {code}, "grant_type": {"authorization_code"}}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.weixin.qq.com/sns/jscode2session?"+query.Encode(), nil)
if err != nil {
return "", errUnavailable
}
client := w.Client
if client == nil {
client = &http.Client{Timeout: 5 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
}
response, err := client.Do(request)
if err != nil {
return "", errUnavailable
}
defer response.Body.Close()
if response.StatusCode != 200 {
return "", errUnavailable
}
var result struct {
OpenID string `json:"openid"`
ErrCode int `json:"errcode"`
}
if err = json.NewDecoder(io.LimitReader(response.Body, 16*1024)).Decode(&result); err != nil {
return "", errUnavailable
}
if result.ErrCode == 40029 || result.ErrCode == 40163 {
return "", ErrInvalidCode
}
if result.ErrCode != 0 || result.OpenID == "" {
return "", errUnavailable
}
return w.AppID + ":" + result.OpenID, nil
}
+199
View File
@@ -0,0 +1,199 @@
package practice
import (
"context"
"encoding/json"
"errors"
"regexp"
"strings"
"time"
"unicode/utf8"
"github.com/jackc/pgx/v5"
)
type RecordInput struct {
ProjectID string `json:"projectId"`
Date string `json:"date"`
Minutes int `json:"minutes"`
Note string `json:"note"`
RequestID string `json:"requestId"`
}
type Record struct {
updatedAt time.Time
ID string `json:"id"`
ProjectID string `json:"projectId"`
ProjectName string `json:"projectName"`
Archived bool `json:"archived"`
Date string `json:"date"`
Minutes int `json:"minutes"`
Note string `json:"note"`
}
type RecordList struct {
Records []Record `json:"records"`
NextCursor string `json:"nextCursor"`
}
var requestIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]{16,128}$`)
var objectIDPattern = regexp.MustCompile(`^[A-Z2-7]{26}$`)
func validDate(date string) bool {
t, err := time.Parse(time.DateOnly, date)
return err == nil && t.Year() >= 1 && t.Format(time.DateOnly) == date
}
const recordSelect = `SELECT r.id,r.project_id,p.name,p.archived,r.practice_date::text,r.minutes,r.note,r.updated_at
FROM records r JOIN projects p ON p.user_id=r.user_id AND p.id=r.project_id`
func scanRecord(row pgx.Row) (Record, error) {
var r Record
err := row.Scan(&r.ID, &r.ProjectID, &r.ProjectName, &r.Archived, &r.Date, &r.Minutes, &r.Note, &r.updatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return r, ErrUnavailable
}
return r, err
}
func (s *Store) Record(ctx context.Context, user, id string) (Record, error) {
return scanRecord(s.db.QueryRow(ctx, recordSelect+" WHERE r.user_id=$1 AND r.id=$2", user, id))
}
// Records bounds every response. The updated-at/id cursor permits complete day lists
// without silently truncating them or loading an account's entire history.
// Recent entries include just-saved corrections and backdated practices first.
func (s *Store) Records(ctx context.Context, user, from, to, cursor string, limit int) (RecordList, error) {
if limit < 1 || limit > 100 {
return RecordList{}, ErrInvalid
}
if from == "" {
from = "0001-01-01"
}
if to == "" {
to = s.Today()
}
if !validDate(from) || !validDate(to) || from > to {
return RecordList{}, ErrInvalid
}
cursorTime, cursorID := time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC), "~"
if cursor != "" {
parts := strings.Split(cursor, "~")
if len(parts) != 2 || !objectIDPattern.MatchString(parts[1]) {
return RecordList{}, ErrInvalid
}
var err error
cursorTime, err = time.Parse(time.RFC3339Nano, parts[0])
if err != nil {
return RecordList{}, ErrInvalid
}
cursorID = parts[1]
}
rows, err := s.db.Query(ctx, recordSelect+` WHERE r.user_id=$1 AND r.practice_date BETWEEN $2::date AND $3::date
AND (r.updated_at,r.id)<($4,$5) ORDER BY r.updated_at DESC,r.id DESC LIMIT $6`, user, from, to, cursorTime, cursorID, limit+1)
if err != nil {
return RecordList{}, err
}
defer rows.Close()
result := RecordList{Records: []Record{}}
for rows.Next() {
r, err := scanRecord(rows)
if err != nil {
return RecordList{}, err
}
result.Records = append(result.Records, r)
}
if err := rows.Err(); err != nil {
return RecordList{}, err
}
if len(result.Records) > limit {
result.Records = result.Records[:limit]
last := result.Records[limit-1]
result.NextCursor = last.updatedAt.UTC().Format(time.RFC3339Nano) + "~" + last.ID
}
return result, nil
}
// SaveRecord commits the record and submission receipt together. Retrying an
// uncertain create keeps its ID; a different payload using the same key is rejected.
func (s *Store) SaveRecord(ctx context.Context, user, id string, input RecordInput) (Record, error) {
if !validDate(input.Date) || input.Date > s.Today() || input.Minutes < 1 || input.Minutes > MinuteLimit ||
input.ProjectID == "" || utf8.RuneCountInString(input.Note) > NoteLimit || strings.ContainsRune(input.Note, 0) {
return Record{}, ErrInvalid
}
if id == "" && !requestIDPattern.MatchString(input.RequestID) {
return Record{}, ErrInvalid
}
var result Record
err := s.write(ctx, user, func(tx pgx.Tx) error {
payload := input
payload.RequestID = ""
encoded, _ := json.Marshal(payload)
hash := digest(string(encoded))
if id == "" {
var savedHash, savedID string
err := tx.QueryRow(ctx, "SELECT payload_hash,record_id FROM submissions WHERE user_id=$1 AND request_id=$2", user, input.RequestID).Scan(&savedHash, &savedID)
if err == nil {
if savedHash != hash {
return ErrConflict
}
result, err = scanRecord(tx.QueryRow(ctx, recordSelect+" WHERE r.user_id=$1 AND r.id=$2", user, savedID))
if errors.Is(err, ErrUnavailable) {
return ErrDeleted
}
return err
}
if !errors.Is(err, pgx.ErrNoRows) {
return err
}
}
originalProject := ""
if id != "" {
err := tx.QueryRow(ctx, "SELECT project_id FROM records WHERE user_id=$1 AND id=$2", user, id).Scan(&originalProject)
if errors.Is(err, pgx.ErrNoRows) {
return ErrUnavailable
}
if err != nil {
return err
}
}
var archived bool
err := tx.QueryRow(ctx, "SELECT archived FROM projects WHERE user_id=$1 AND id=$2", user, input.ProjectID).Scan(&archived)
if errors.Is(err, pgx.ErrNoRows) {
return ErrUnavailable
}
if err != nil {
return err
}
if archived && originalProject != input.ProjectID {
return ErrArchived
}
if id == "" {
id = newID()
if _, err = tx.Exec(ctx, `INSERT INTO records(id,user_id,project_id,practice_date,minutes,note)
VALUES($1,$2,$3,$4::date,$5,$6)`, id, user, input.ProjectID, input.Date, input.Minutes, input.Note); err != nil {
return err
}
if _, err = tx.Exec(ctx, "INSERT INTO submissions(user_id,request_id,payload_hash,record_id) VALUES($1,$2,$3,$4)", user, input.RequestID, hash, id); err != nil {
return err
}
} else {
if _, err = tx.Exec(ctx, `UPDATE records SET project_id=$3,practice_date=$4::date,minutes=$5,note=$6,updated_at=now()
WHERE user_id=$1 AND id=$2`, user, id, input.ProjectID, input.Date, input.Minutes, input.Note); err != nil {
return err
}
}
result, err = scanRecord(tx.QueryRow(ctx, recordSelect+" WHERE r.user_id=$1 AND r.id=$2", user, id))
return err
})
return result, err
}
// DeleteRecord is scoped to the session owner and safe to retry, including after
// the last record of a date has gone. Submission receipts deliberately remain.
func (s *Store) DeleteRecord(ctx context.Context, user, id string) error {
return s.write(ctx, user, func(tx pgx.Tx) error {
_, err := tx.Exec(ctx, "DELETE FROM records WHERE user_id=$1 AND id=$2", user, id)
return err
})
}
+122
View File
@@ -0,0 +1,122 @@
package practice
import (
"context"
"sort"
"time"
)
type ProjectMinutes struct {
ProjectID string `json:"projectId"`
Name string `json:"name"`
Archived bool `json:"archived"`
Minutes int64 `json:"minutes"`
}
type Summary struct {
TotalMinutes int64 `json:"totalMinutes"`
PracticeDays int `json:"practiceDays"`
RecordCount int64 `json:"recordCount"`
Projects []ProjectMinutes `json:"projects"`
}
type DayMinutes struct {
Date string `json:"date"`
Minutes int64 `json:"minutes"`
}
type Review struct {
Today string `json:"today"`
From string `json:"from"`
To string `json:"to"`
AllTime Summary `json:"allTime"`
Period Summary `json:"period"`
Days []DayMinutes `json:"days"`
}
type summaryBuilder struct {
summary Summary
dates map[string]bool
projects map[string]ProjectMinutes
}
func newSummary() *summaryBuilder {
return &summaryBuilder{dates: map[string]bool{}, projects: map[string]ProjectMinutes{}}
}
func (b *summaryBuilder) add(date string, project ProjectMinutes, count int64) {
b.summary.TotalMinutes += project.Minutes
b.summary.RecordCount += count
b.dates[date] = true
previous := b.projects[project.ProjectID]
project.Minutes += previous.Minutes
b.projects[project.ProjectID] = project
}
func (b *summaryBuilder) result() Summary {
b.summary.PracticeDays = len(b.dates)
b.summary.Projects = []ProjectMinutes{}
for _, p := range b.projects {
b.summary.Projects = append(b.summary.Projects, p)
}
sort.Slice(b.summary.Projects, func(i, j int) bool {
a, c := b.summary.Projects[i], b.summary.Projects[j]
if a.Minutes == c.Minutes {
return a.ProjectID < c.ProjectID
}
return a.Minutes > c.Minutes
})
return b.summary
}
// Review derives totals, daily minutes and project shares from one SQL snapshot.
// Archived projects participate and always use their current name. Monday starts a week.
func (s *Store) Review(ctx context.Context, user, period, date string) (Review, error) {
if date == "" {
date = s.Today()
}
if !validDate(date) || date > s.Today() {
return Review{}, ErrInvalid
}
anchor, _ := time.Parse(time.DateOnly, date)
var start, end time.Time
switch period {
case "week":
start = anchor.AddDate(0, 0, -(int(anchor.Weekday())+6)%7)
end = start.AddDate(0, 0, 6)
case "month":
start = time.Date(anchor.Year(), anchor.Month(), 1, 0, 0, 0, 0, time.UTC)
end = start.AddDate(0, 1, -1)
default:
return Review{}, ErrInvalid
}
result := Review{Today: s.Today(), From: start.Format(time.DateOnly), To: end.Format(time.DateOnly), Days: []DayMinutes{}}
// A single grouped query keeps all-time and selected-period summaries consistent
// during concurrent edits, without storing a second set of cumulative balances.
rows, err := s.db.Query(ctx, `SELECT r.practice_date::text,p.id,p.name,p.archived,sum(r.minutes)::bigint,count(*)
FROM records r JOIN projects p ON p.user_id=r.user_id AND p.id=r.project_id
WHERE r.user_id=$1 GROUP BY r.practice_date,p.id,p.name,p.archived`, user)
if err != nil {
return Review{}, err
}
defer rows.Close()
all, selected := newSummary(), newSummary()
daily := map[string]int64{}
for rows.Next() {
var date string
var p ProjectMinutes
var count int64
if err := rows.Scan(&date, &p.ProjectID, &p.Name, &p.Archived, &p.Minutes, &count); err != nil {
return Review{}, err
}
all.add(date, p, count)
if date >= result.From && date <= result.To {
selected.add(date, p, count)
daily[date] += p.Minutes
}
}
if err := rows.Err(); err != nil {
return Review{}, err
}
result.AllTime = all.result()
result.Period = selected.result()
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
date := d.Format(time.DateOnly)
result.Days = append(result.Days, DayMinutes{Date: date, Minutes: daily[date]})
}
return result, nil
}
+201
View File
@@ -0,0 +1,201 @@
package practice
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"strings"
"time"
"unicode/utf8"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
const NameLimit = 40
const NoteLimit = 2000
const MinuteLimit = 2147483647 // PostgreSQL integer storage limit; minutes remain exact.
var (
ErrInvalid = errors.New("输入无效,请检查后重试")
ErrUnavailable = errors.New("项目或记录不可用,请刷新后重试")
ErrUnauthorized = errors.New("登录已失效,请重新登录")
ErrArchived = errors.New("该项目已移出常用列表,请选择其他项目")
ErrConflict = errors.New("这次提交已保存了不同内容,请刷新记录后更正")
ErrDeleted = errors.New("这次提交的记录已被删除,不会重复创建")
)
var presets = []string{"零基础", "基础提升", "软开素质", "足髋训练", "核心臀腿", "小球核心", "天鹅臂颈", "呼吸训练"}
type Store struct {
db *pgxpool.Pool
now func() time.Time
}
// New keeps all ownership, dates, archival and persistence rules behind business operations.
// now is injectable so business-day boundaries can be verified without changing the host clock.
func New(db *pgxpool.Pool, now func() time.Time) *Store {
if now == nil {
now = time.Now
}
return &Store{db: db, now: now}
}
func newID() string { return rand.Text() }
func digest(s string) string { sum := sha256.Sum256([]byte(s)); return hex.EncodeToString(sum[:]) }
// Today is a calendar date in the fixed first-release business timezone, not the device timezone.
func (s *Store) Today() string {
return s.now().In(time.FixedZone("Asia/Shanghai", 8*60*60)).Format(time.DateOnly)
}
type Session struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expiresAt"`
}
// Login accepts only a server-verified, app-scoped WeChat identity. User creation,
// presets and the session commit together; deleted presets never reappear on login.
func (s *Store) Login(ctx context.Context, identity string) (Session, error) {
if identity == "" {
return Session{}, ErrUnauthorized
}
tx, err := s.db.Begin(ctx)
if err != nil {
return Session{}, err
}
defer tx.Rollback(ctx)
id := newID()
result, err := tx.Exec(ctx, "INSERT INTO users(id,wechat_identity) VALUES($1,$2) ON CONFLICT(wechat_identity) DO NOTHING", id, identity)
if err != nil {
return Session{}, err
}
if result.RowsAffected() == 1 {
for _, name := range presets {
if _, err = tx.Exec(ctx, "INSERT INTO projects(id,user_id,name) VALUES($1,$2,$3)", newID(), id, name); err != nil {
return Session{}, err
}
}
} else if err = tx.QueryRow(ctx, "SELECT id FROM users WHERE wechat_identity=$1", identity).Scan(&id); err != nil {
return Session{}, err
}
// Serialize session maintenance for this user as well as business writes.
if _, err = tx.Exec(ctx, "SELECT id FROM users WHERE id=$1 FOR UPDATE", id); err != nil {
return Session{}, err
}
if _, err = tx.Exec(ctx, "DELETE FROM sessions WHERE user_id=$1 AND expires_at <= $2", id, s.now()); err != nil {
return Session{}, err
}
session := Session{Token: newID() + newID(), ExpiresAt: s.now().Add(30 * 24 * time.Hour)}
if _, err = tx.Exec(ctx, "INSERT INTO sessions(token_hash,user_id,expires_at) VALUES($1,$2,$3)", digest(session.Token), id, session.ExpiresAt); err != nil {
return Session{}, err
}
if err = tx.Commit(ctx); err != nil {
return Session{}, err
}
return session, nil
}
// Authenticate never accepts a client-provided user ID. Only opaque, unexpired
// bearer tokens are usable; their hashes, not tokens, are kept in PostgreSQL.
func (s *Store) Authenticate(ctx context.Context, token string) (string, error) {
if len(token) != 52 {
return "", ErrUnauthorized
}
var user string
err := s.db.QueryRow(ctx, "SELECT user_id FROM sessions WHERE token_hash=$1 AND expires_at>$2", digest(token), s.now()).Scan(&user)
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrUnauthorized
}
return user, err
}
// write serializes a user's mutations. Project removal, record writes and
// idempotency receipts therefore see a consistent state even across devices.
func (s *Store) write(ctx context.Context, user string, fn func(pgx.Tx) error) error {
tx, err := s.db.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
var id string
if err = tx.QueryRow(ctx, "SELECT id FROM users WHERE id=$1 FOR UPDATE", user).Scan(&id); err != nil {
return err
}
if err = fn(tx); err != nil {
return err
}
return tx.Commit(ctx)
}
type Project struct {
ID string `json:"id"`
Name string `json:"name"`
Archived bool `json:"archived"`
HasRecords bool `json:"hasRecords"`
}
func (s *Store) Projects(ctx context.Context, user string, archived bool) ([]Project, error) {
rows, err := s.db.Query(ctx, `SELECT p.id,p.name,p.archived,EXISTS(SELECT 1 FROM records r WHERE r.user_id=p.user_id AND r.project_id=p.id)
FROM projects p WHERE p.user_id=$1 AND ($2 OR NOT p.archived) ORDER BY p.position`, user, archived)
if err != nil {
return nil, err
}
defer rows.Close()
projects := []Project{}
for rows.Next() {
var p Project
if err := rows.Scan(&p.ID, &p.Name, &p.Archived, &p.HasRecords); err != nil {
return nil, err
}
projects = append(projects, p)
}
return projects, rows.Err()
}
func (s *Store) SaveProject(ctx context.Context, user, id, name string) (Project, error) {
name = strings.TrimSpace(name)
if name == "" || utf8.RuneCountInString(name) > NameLimit || strings.ContainsRune(name, 0) {
return Project{}, ErrInvalid
}
p := Project{ID: id, Name: name}
err := s.write(ctx, user, func(tx pgx.Tx) error {
if id == "" {
p.ID = newID()
_, err := tx.Exec(ctx, "INSERT INTO projects(id,user_id,name) VALUES($1,$2,$3)", p.ID, user, name)
return err
}
err := tx.QueryRow(ctx, `UPDATE projects SET name=$3 WHERE user_id=$1 AND id=$2 RETURNING archived,
EXISTS(SELECT 1 FROM records WHERE user_id=$1 AND project_id=$2)`, user, id, name).Scan(&p.Archived, &p.HasRecords)
if errors.Is(err, pgx.ErrNoRows) {
return ErrUnavailable
}
return err
})
return p, err
}
func (s *Store) RemoveProject(ctx context.Context, user, id string) (string, error) {
mode := "deleted"
err := s.write(ctx, user, func(tx pgx.Tx) error {
var used bool
err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM records WHERE user_id=$1 AND project_id=$2)
FROM projects WHERE user_id=$1 AND id=$2`, user, id).Scan(&used)
if errors.Is(err, pgx.ErrNoRows) {
return ErrUnavailable
}
if err != nil {
return err
}
if used {
mode = "archived"
_, err = tx.Exec(ctx, "UPDATE projects SET archived=true WHERE user_id=$1 AND id=$2", user, id)
} else {
_, err = tx.Exec(ctx, "DELETE FROM projects WHERE user_id=$1 AND id=$2", user, id)
}
return err
})
return mode, err
}
+27
View File
@@ -0,0 +1,27 @@
#!/bin/sh
set -eu
backend_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$backend_dir"
# A supplied test database must permit isolated CREATE SCHEMA. Tests never use
# PG* application settings or touch existing tables; every test owns its schema.
if [ -n "${TEST_DATABASE_URL:-}" ]; then
REQUIRE_TEST_DATABASE=1 go test -race ./...
exit
fi
# Disposable memory-backed database, loopback only, no existing Compose volumes.
test_container=$(docker run --detach --rm --publish 127.0.0.1::5432 \
--env POSTGRES_USER=ballet_test --env POSTGRES_PASSWORD=local-isolated-test \
--env POSTGRES_DB=ballet_test --tmpfs /var/lib/postgresql postgres:18-alpine)
trap 'docker stop "$test_container" >/dev/null' EXIT HUP INT TERM
attempt=0
until docker exec "$test_container" pg_isready -U ballet_test -d ballet_test >/dev/null 2>&1; do
attempt=$((attempt + 1))
if [ "$attempt" -ge 30 ]; then printf '%s\n' 'Test PostgreSQL did not become ready.' >&2; exit 1; fi
sleep 1
done
test_port=$(docker port "$test_container" 5432 | sed 's/.*://')
TEST_DATABASE_URL="postgres://ballet_test:local-isolated-test@127.0.0.1:$test_port/ballet_test?sslmode=disable" \
REQUIRE_TEST_DATABASE=1 go test -race ./...
+31
View File
@@ -0,0 +1,31 @@
# Domain Docs
本仓库采用 single-context 布局,前端和后端共享根目录的领域文档。
## 探索前读取
- 根目录的 `CONTEXT.md`:领域术语、概念和关系。
- 根目录的 `docs/adr/`:仅阅读与当前工作相关的架构决策。
文件不存在时直接继续,不把缺失视为阻塞,也不主动建议预先创建。
后续由 `domain-modeling` 在术语或决策得到验证、且用户明确批准后
按需创建。
## 文件布局
- `CONTEXT.md`:仓库共享的领域上下文。
- `docs/adr/NNNN-<slug>.md`:按编号保存架构决策。
- `miniprogram/` 和 `backend/` 继续按各自的项目约定开发。
## 术语一致性
事项标题、重构建议、假设和测试名称中的领域概念,
使用 `CONTEXT.md` 已定义的术语,遵循其明确规定的用词边界。
所需概念尚未定义时,先判断是否误用了项目之外的术语;
确有缺口时,将其列为后续领域建模的候选。
## 决策冲突
输出与现有 ADR 冲突时,明确指出对应 ADR、冲突内容及重新讨论的理由,
不静默覆盖已有决策。
+40
View File
@@ -0,0 +1,40 @@
# Issue tracker: Local Markdown
本仓库的事项和规格以 Markdown 文件保存在 `.scratch/`。
## 文件约定
- 每个功能一个目录:`.scratch/<feature-slug>/`。
- 规格文件:`.scratch/<feature-slug>/spec.md`。
- 实现事项:`.scratch/<feature-slug>/issues/<NN>-<slug>.md`,
从 `01` 开始编号,每个事项一个文件。
- 分流状态写在事项文件顶部附近的 `Status:` 行;
使用 `triage-labels.md` 中的标签字符串。
- 评论和讨论追加到文件底部的 `## Comments` 下。
## 发布和读取
技能要求“发布到 issue tracker”时,在对应功能目录中创建文件,
按需创建目录。
技能要求“获取相关 ticket”时,读取指定路径的事项文件。
仅提供编号时,在对应功能目录中定位。
## Wayfinding
供 `wayfinder` 使用,一个地图文件对应多个子事项文件。
- 地图:`.scratch/<effort>/map.md`,包含 Notes、
Decisions-so-far 和 Fog。
- 子事项:`.scratch/<effort>/issues/<NN>-<slug>.md`,
从 `01` 编号,正文记录待解决的问题。
- 类型:顶部的 `Type:` 行使用 research、prototype、grilling 或 task。
- 此流程的工作状态:`Status: open`、`Status: claimed`、
`Status: resolved`。
- 依赖:顶部用 `Blocked by: NN, NN` 记录前置事项;
所有前置事项均为 resolved 时解除阻塞。
- 选取:扫描当前 effort 的 issues 目录,按编号选择第一个
open 且未被阻塞的事项。
- 领取:开始工作前写入 `Status: claimed` 并保存。
- 解决:在 `## Answer` 下追加结论,写入 `Status: resolved`,
再向地图的 Decisions-so-far 追加结论摘要及事项链接。
+15
View File
@@ -0,0 +1,15 @@
# Triage Labels
技能中的五种标准分流角色映射如下。
本地 Markdown 事项的 `Status:` 行使用“本仓库标签”列。
| 标准角色 | 本仓库标签 | 含义 |
| --- | --- | --- |
| needs-triage | needs-triage | 等待维护者评估 |
| needs-info | needs-info | 等待补充信息 |
| ready-for-agent | ready-for-agent | 需求明确,可交由代理实现 |
| ready-for-human | ready-for-human | 需要人工实现 |
| wontfix | wontfix | 不予处理 |
技能提及某个分流角色时,使用对应的本仓库标签。
后续调整标签名称时,修改本仓库标签列。
+2 -1
View File
@@ -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",
+25 -2
View File
@@ -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": {}
}
+12 -5
View File
@@ -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
View File
@@ -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; }
+33
View File
@@ -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' })
}
+29
View File
@@ -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: '芭蕾岛 · 日历' })
+67
View File
@@ -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: '芭蕾岛 · 成长' })
+45
View File
@@ -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 -2
View File
@@ -1,4 +1,3 @@
export default definePageConfig({
navigationBarTitleText: 'Ballet Island',
navigationBarTitleText: '芭蕾岛 · 记录',
})
-21
View File
@@ -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;
}
}
+25 -30
View File
@@ -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: '管理练习项目' })
+69
View File
@@ -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: '练习记录' })
+132
View File
@@ -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>
}
+124
View File
@@ -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 : '暂时无法完成,请稍后重试'
}
+21
View File
@@ -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
}
+84
View File
@@ -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()
})