diff --git a/.scratch/project-icons/spec.md b/.scratch/project-icons/spec.md new file mode 100644 index 0000000..b4e6326 --- /dev/null +++ b/.scratch/project-icons/spec.md @@ -0,0 +1,30 @@ +# 项目图标接入 + +Type: task +Status: resolved + +## Scope + +将已确认的 18 个项目图标接入小程序。首页和项目管理保留紧凑单列卡片;添加、编辑项目时从专业动作、基础练习、物件三类选择,也可不使用图标。图标随项目保存到现有 PostgreSQL,沿用身份隔离、归档和刷新规则。 + +## Implementation + +- 保留 `docs/design/project-icons/` 下的 SVG 原稿,小程序使用同图案的透明 PNG,随包提供,不依赖外部图片服务。 +- 新增可选请求字段 `iconId`;空字符串取消,旧客户端省略时保留已有选择。服务端仅接受已提供的 18 个标识。 +- 新增版本迁移;为已有同名预置项目一次性配置对应图标,其他项目保持无图标。重新登录不重置用户选择。 +- 项目管理的“改名”改为“编辑”,名称与图标一次保存。选择面板关闭不提交;保存失败保留表单。 +- 不更改记录统计、身份方式或发布配置。 + +## Validation + +验证新增、改名保留图标、更换、取消、未知标识拒绝、跨账号隔离、重新读取及旧数据库迁移;执行小程序 typecheck/client/build 与后端 vet/race/build、隔离 PostgreSQL 集成测试。平台运行与静态构建分别记录。 + +## Result + +2026-09-29 完成本地接入,未发布或提交。 + +- `pnpm typecheck`、`pnpm test:client`(4 项)、`pnpm build` 通过。18 张 384 × 384 透明 PNG 共约 167 KiB,均进入小程序构建产物,保留 SVG 原稿。 +- `go vet ./...`、`./scripts/test-integration.sh`(真实隔离 PostgreSQL 下执行 `go test -race ./...`)、`go build -o bin/api ./cmd/server` 通过。新增测试覆盖全部 18 个标识、旧客户端省略字段、取消、归档、未知值拒绝、用户隔离、服务重建读取、迁移重复执行和旧结构升级后历史保留。 +- 微信开发者工具连接本机真实后端,7 项交互验证通过:默认图标与紧凑卡片、6/8/4 分类切换和试选、新增并持久化、重进首页恢复、更换、取消编辑不写入、取消图标后无占位。未捕获未处理异常;只新增和清理本次唯一标记的临时项目,未变更既有项目选择或练习记录。 +- 本机后端已更新并执行迁移;默认项目图标正常读取。只读代理 `/root/project_icon_review` 已完成,未发现具体可行动缺陷。`git diff --check` 通过。 +- 已目视检查首页、项目管理与选择面板的模拟器截图。真机、其他屏幕宽度和原生键盘交互未在本轮验证。 diff --git a/.scratch/record-overview-and-project-order/spec.md b/.scratch/record-overview-and-project-order/spec.md new file mode 100644 index 0000000..64f2b4a --- /dev/null +++ b/.scratch/record-overview-and-project-order/spec.md @@ -0,0 +1,19 @@ +# 记录总览与项目排序 + +Type: task +Status: resolved + +## Scope + +记录首页展示总记录时长、坚持天数和最早记录日期;项目右侧展示累计小时数。项目操作区提供纯 SVG 图标的管理列表与添加项目入口,管理列表可拖动排序并保存。 + +## Implementation + +- 总览的两个主数字按同一基线区域排版,日期仅在有记录时显示为“从 YYYY 年 MM 月 DD 日”。 +- 项目累计分钟数换算为小时,项目时长数字加大。操作区只显示 28px 图标,保留无障碍名称。 +- 新增独立排序页;拖动右侧手柄调整本地顺序,点击保存后调用 `PUT /v1/projects/order`。 +- 服务端只接受当前用户完整的活跃项目 ID 列表,沿用既有位置槽位,保留归档项目和新增项目的排列语义。 + +## Validation + +2026-09-29,本地代码已完成,未提交或发布。小程序 `pnpm typecheck`、`pnpm test:client`、`pnpm build` 通过;后端 `go vet ./...`、`go test -race ./...`、`go build -o bin/api ./cmd/server` 和隔离 PostgreSQL 集成测试通过。服务端测试覆盖顺序持久化、后续新增、无效或跨用户 ID、归档后重排。微信开发者工具中的实际拖动与图标显示未在本轮完成交互核验。 diff --git a/DESIGN.md b/DESIGN.md index b6fed83..5b5a36e 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -31,7 +31,7 @@ | 主文字 | `#3e3435` | 正文、名称和标题,承担主要阅读信息 | | 次要文字 | `#827572` | 日期、解释与辅助说明,仍需清晰可读 | | 主强调色(Primary) | `#9c5268` | 主要操作、选中状态、记录时长和图表聚焦项 | -| 品牌小字 | `#946d76` | 低调的品牌标记,不替代页面标题 | +| 汇总标签 | `#946d76` | 标识今天或累计练习,不替代汇总数字 | | 汇总表面 / 描边 / 数字 | `#f1e7e3` / `#eddfda` / `#864b5e` | 承载今天或累计练习的核心汇总,与普通记录形成层级 | | 输入表面 / 描边 / 文字 | `#f8f4ef` / `#e8dfd8` / `#44383b` | 将可填写区域与外层面板区分 | | 普通按钮文字 / 描边 | `#655656` / `#e5dcd6` | 辅助操作,背景沿用内容表面色 | @@ -61,7 +61,7 @@ | 角色 | 字号 / 字重 | 使用规则 | | --- | --- | --- | -| 页面标题 | `26px / 600` | 表达当前页面目的;行高 `1.35`,字距 `-0.5px` | +| 页面标题 | 沿用原生导航栏 | 内容区域不重复放置页面大标题 | | 核心汇总数字 | 时长 `29px / 600`,统计 `30px / 600` | 保留已有两种汇总组件;时长行高 `1.5` | | 区块标题与项目入口名称 | `17px / 600` | 组织内容或标识可进入的项目 | | 正文与记录名称 | `15px`,记录名称字重 `500` | 支持较长名称和实际内容 | @@ -69,15 +69,15 @@ | 表单标签 | `14px / 500` | 清楚表达字段含义 | | 说明、记录时长与笔记 | `13px` | 作为局部辅助层级,不与汇总数字竞争 | | 紧凑辅助信息与单位 | `12px` | 日期、单位、帮助文字等 | -| 空状态标题 | `16px / 500` | 配合 `13px` 说明文字,说明行高 `1.8` | +| 空状态标题 | `16px / 500` | 简短说明当前无数据;可执行的下一步使用明确按钮 | 长项目名称、笔记和汇总时长允许换行,保留笔记的原始换行,不用省略号隐藏主要信息。数字必须带上可理解的单位,例如「1 小时 30 分钟」「2 次」。 -现状中品牌标记、项目编号及部分辅助文字使用 `10–11px`,趋势日期标签使用周视图 `10px`、月视图 `8px`。这些局部值不作为新正文或操作标签的默认字号;小字号和图表标签的可读性仍需在目标设备验证。 +现状中汇总标签及部分辅助文字使用 `10–11px`,趋势日期标签使用周视图 `10px`、月视图 `8px`。这些局部值不作为新正文或操作标签的默认字号;小字号和图表标签的可读性仍需在目标设备验证。 ## Layout -页面以纵向单栏组织一个主要任务;两列用于并列的项目入口或简短指标,七列仅用于日历。新增页面按「页面目的 → 主要内容或输入 → 相关操作与反馈」建立阅读顺序,避免为了填满画面增加区块。 +页面以纵向单栏组织一个主要任务;项目入口和管理列表均为一行一张卡片,两列仅用于简短指标,七列仅用于日历。新增页面按「主要内容或输入 → 相关操作与反馈」建立阅读顺序,避免为了填满画面增加区块。 尺寸均为 `375px` 设计宽度下的源码设计尺寸,由现有构建配置转换为小程序单位,不等同于设备截图的物理像素。页面基准内边距为上 `20px`、左右 `18px`、下 `32px` 加底部安全区。 @@ -94,11 +94,11 @@ ### 导航与内容组织 -页面标题区使用品牌小字、主标题和简短副标题。管理、补记等局部入口靠近所属区块,主要提交操作放在完整输入之后。 +页面用途由原生导航栏说明,内容直接从数据或字段开始,不放页面大标题、品牌小字、抒情标题或副标题。管理、补记等局部入口靠近所属区块,表单主操作统一固定在底部。 当前一级导航为「记录、日历、成长」,使用文字 TabBar;练习记录和项目管理为二级页面。延续这一层级,新页面不自行增加一套全局导航。 -快捷按钮允许换行,长内容允许撑高,底部内容保留滚动与安全区。表单操作跟随内容滚动;键盘出现时的可达性属于验收事项,不能仅凭静态布局认定已适配。 +快捷按钮允许换行,长内容允许撑高。编辑页面底部固定一行两个等宽按钮,左侧「取消」、右侧「保存」,间距 `12px`。操作栏下方保留安全区,页面底部预留 `100px` 加安全区,确保末尾内容可以滚动到操作栏上方。键盘出现时的可达性属于验收事项,不能仅凭静态布局认定已适配。 ## Elevation & Depth @@ -114,10 +114,11 @@ | --- | --- | --- | --- | | 通用面板 | `18px` | `18px` | 表单或成组内容的默认容器 | | 核心汇总 | `21px` | 上下 `23px`、左右 `22px` | 承载少量重点数字,留白比普通内容更充裕 | -| 项目入口 | `17px` | 上下 `15px`、左右 `16px` | 两列排列,最小高度 `126px`,支持名称换行 | +| 项目入口 | `17px` | 上下 `10px`、左右 `16px` | 一行一张,名称前可放 `32px` 项目图标;高度由内容决定,支持名称换行 | +| 项目管理条目 | `17px` | 上下 `8px`、左右 `16px` | 名称与操作按钮同排;高度由内容决定 | | 练习记录 | `16px` | 上下 `16px`、左右 `18px` | 可连续浏览的独立记录 | | 日历面板 | `18px` | 上下 `16px`、左右 `12px` | 沿用面板外形,为七列日期留出横向空间 | -| 输入区域 | `11px` | 上下 `12px`、左右 `14px` | 位于面板内,明确字段边界 | +| 输入区域 | `11px` | 左右 `14px`;单行输入上下 `0`,选择区域与笔记上下 `12px` | 单行原生输入依靠高度定位文字,避免垂直内边距使文字下移 | | 状态提示条 | `12px` | 上下 `13px`、左右 `15px` | 紧贴相关操作提供短反馈 | 按钮圆角为 `12px`,日期选中区域为 `13px`。这些差异是现有组件的基准,不构成一套可按大小任意套用的全局圆角等级。需要新容器时,按用途复用上表,避免再增加近似数值。 @@ -126,9 +127,9 @@ ### 操作按钮 -主要提交使用灰玫瑰实心按钮;快捷选项与重试使用描边按钮;管理、取消、更正等辅助入口使用文字按钮。危险操作使用危险文字样式,并在确认时说明后果。 +主要提交使用灰玫瑰实心按钮;底部「取消」与快捷选项使用描边按钮;管理、更正等辅助入口使用文字按钮。危险操作使用危险文字样式,并在确认时说明后果。 -表单提交沿用中号按钮,高度 `44px`、字号 `16px`;行内操作和切换沿用小号按钮,高度 `32px`、字号 `13px`。需要独占一行的主要提交按钮填满容器宽度。提交中显示加载状态并阻止重复操作,不能只靠按钮文字变化表达等待。 +表单底部两个按钮沿用中号,高度 `44px`、字号 `16px`,各填满所在的半行;行内操作和切换沿用小号按钮,高度 `32px`、字号 `13px`。提交中显示加载状态并阻止重复操作,不能只靠按钮文字变化表达等待。 复用现有按钮组件及主题;按下、禁用和加载的具体表现沿用组件行为,不在页面上另造一套状态样式。 @@ -136,13 +137,13 @@ 字段采用「上方标签 + 下方完整输入区域」,占位文字仅补充示例或提示。帮助文字靠近字段,字数计数置于笔记区下方右侧。 -普通输入高度 `48px`;选择区域最小高度 `48px`,允许长内容撑高;笔记区沿用 `140px` 高度。标签与字段保持清楚关联,字段组之间留出更大间距。主要提交与取消放在表单内容之后,删除入口与保存操作分开。 +普通输入高度 `48px`;选择区域最小高度 `48px`,允许长内容撑高;笔记区沿用 `140px` 高度。标签与字段保持清楚关联,字段组之间留出更大间距。主要提交与取消使用固定底部操作栏,删除入口留在内容区,与保存操作分开。 ### 卡片与列表 -项目卡片以名称为主体,编号和操作提示保持次要。记录卡片先展示项目名与时长,再展示日期、归档标记与更正入口;笔记置于下方,以细线和留白分隔。长名称优先获得可换行空间。 +项目入口卡片展示名称与用户选择的图标,不放编号或重复的操作提示,不设用于装饰留白的最小高度。图标采用灰玫瑰线描和浅色填充,以 `32px` 方形置于名称前;未选择或无法识别图标时不占位。记录卡片先展示项目名与时长,再展示日期、归档标记与更正入口;笔记置于下方,以细线和留白分隔。长名称优先获得可换行空间。 -连续的管理条目放在同一个面板内,用细分隔线区分行;每项的改名、移除入口靠近项目名称与状态。归档状态用文字标注,不仅通过弱化颜色表达。 +管理条目每项独立成卡,以间距区分;图标、名称、编辑和移除按钮放在同一行,不附加重复的历史记录说明。编辑表单中可选择或取消图标,底部选择面板按「基础练习、专业动作、物件」分类;名称与图标一并保存。移除的归档或删除后果在确认弹窗中说明。历史记录中的归档状态仍用文字标注。 ### 日历与图表 @@ -158,13 +159,13 @@ | --- | --- | | 加载中 | 在内容区域说明正在读取,避免暂时展示成空记录 | | 读取失败 | 说明失败并提供重试入口,与无数据状态区分 | -| 没有数据 | 简短标题与解释;有可执行的下一步时提供明确入口 | +| 没有数据 | 简短说明无数据;有可执行的下一步时提供明确入口 | | 表单错误 | 保留输入,以错误提示解释需要处理的问题 | | 操作成功 | 结果确认后再显示成功提示;反馈靠近操作或在返回后呈现 | | 保存结果未确认 | 保留提交内容,明确说明尚未确认,并提供重试确认入口 | | 删除或移除 | 确认前说明后果,明确区分删除记录与归档项目 | -文案使用具体动作,例如「保存练习」「补记练习」「取消并返回」。空状态可以写「休息也是日常。漏记了一堂课,也可以补在这天。」时长说明保留「时长记录的是投入,不是技术水平」的含义。 +按钮文案使用简短动作,例如「保存」「取消」「删除」。保存结果未确认时,主按钮使用「重试」,提示区说明需要确认的结果,并禁用取消以保留提交保护。空状态使用「暂无记录」等直接描述;不添加鼓励性口号、抒情说明或重复引导。保留字段限制、图表含义、错误反馈及危险操作后果等必要说明。 ## Do's and Don'ts @@ -186,6 +187,6 @@ - [全局样式](miniprogram/src/app.scss):实际颜色、字号、间距与按钮主题映射。 - [构建配置](miniprogram/config/index.ts):设计宽度与尺寸转换配置。 - [导航配置](miniprogram/src/app.config.ts):原生导航与底部导航。 -- [公共组件](miniprogram/src/components/common.tsx):页面标题、加载与空状态、记录卡片及按钮复用入口。 +- [公共组件](miniprogram/src/components/common.tsx):底部表单操作栏、加载与空状态、记录卡片及按钮复用入口。 新增页面优先遵循本文件的使用规则并复用现有组件。发现文档与实现不一致时,明确差异及其原因,不直接将偶然实现提升为通用规则;有意调整视觉语言时,同步更新相关规则和对应样式。 diff --git a/README.md b/README.md index 02f49de..110f20d 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ pnpm install --frozen-lockfile pnpm dev ``` -微信开发者工具导入 `miniprogram/`,AppID 为 `wx6dfbf1021db8aee0`,编译目录为 `dist/`。底部入口为“记录、日历、成长”。首次请求通过 `Taro.login` 取得 code,在后端核验后建立业务会话;登录失败或网络失败会显示重试入口,不会当作空记录。 +微信开发者工具导入 `miniprogram/`,AppID 为 `wx6dfbf1021db8aee0`,编译目录为 `dist/`。底部入口为“记录、成长、我的”,日历从记录页进入。“我的”可由用户选择微信头像和昵称,仅保存在本机。首次请求通过 `Taro.login` 取得 code,在后端核验后建立业务会话;登录失败或网络失败会显示重试入口,不会当作空记录。 默认 API 为 `http://127.0.0.1:8080`。使用本地 HTTP 地址时,在开发者工具的本地设置中开启“不校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书”,仅用于本地调试。真机需使用能够访问的接口地址,并按微信要求配置域名。 @@ -56,6 +56,8 @@ TARO_APP_API_BASE_URL=https://api.example.com pnpm build 项目名最多 40 个 Unicode 字符,笔记最多 2000 个字符;前后端使用相同校验。分钟为正整数,按 PostgreSQL integer 存储范围上限为 2147483647。笔记只作为普通文本显示。每次主动新增生成独立提交标识;结果未确认时保留该标识与完整输入,重试不重复累计。首期不提供离线保存队列。 +项目可选 18 个内置图标(基础练习 6、专业动作 8、物件 4),也可选择无图标。添加或编辑时与名称一并保存,首页和管理页展示。SVG 原稿位于 `docs/design/project-icons/`,小程序包内使用对应的透明 PNG。后端迁移 002 为已有同名预置项目一次性配图;之后改名、重新登录和服务重启均不重置用户选择。更新小程序前先更新后端,使接口支持 `iconId`。 + ## 后端开发 需要 Go 1.26 或更高版本,以及 Docker Compose。仅使用 Docker 运行后端时无需本机 Go。 @@ -134,7 +136,8 @@ go build -o bin/api ./cmd/server | `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}` | 最新项目 | +| `PUT /v1/projects/order` | 按完整活跃项目 ID 列表保存显示顺序,JSON `{projectIds: string[]}` | `{action: "reordered"}` | +| `POST /v1/projects` / `PUT /v1/projects/{id}` | 新增 / 编辑,JSON `{name, iconId?}`;空字符串取消图标,省略保留现有图标 | 最新项目(含 `iconId`) | | `DELETE /v1/projects/{id}` | 删除未使用项目或归档 | `{action: "deleted" / "archived"}` | | `GET /v1/records` | `from` / `to` 日期范围、`limit`(1–100,默认20)、`cursor` | `{records, nextCursor}` | | `GET /v1/records/{id}` | 读取本人单条记录 | 项目当前名称、日期、分钟、笔记 | diff --git a/backend/internal/database/002_project_icons.sql b/backend/internal/database/002_project_icons.sql new file mode 100644 index 0000000..432d8b5 --- /dev/null +++ b/backend/internal/database/002_project_icons.sql @@ -0,0 +1,15 @@ +ALTER TABLE projects ADD COLUMN icon_id text NOT NULL DEFAULT '' CHECK (char_length(icon_id) <= 64); + +-- Existing installations have never offered icon selection. Seed familiar names +-- once; subsequent migrations/logins must preserve explicit changes or removal. +UPDATE projects SET icon_id = CASE name + WHEN '零基础' THEN 'plie' + WHEN '基础提升' THEN 'tendu' + WHEN '软开素质' THEN 'flexibility' + WHEN '足髋训练' THEN 'foot-hip' + WHEN '核心臀腿' THEN 'core-glutes' + WHEN '小球核心' THEN 'ball-core' + WHEN '天鹅臂颈' THEN 'swan-arms' + WHEN '呼吸训练' THEN 'breathing' + ELSE '' +END; diff --git a/backend/internal/database/003_project_order.sql b/backend/internal/database/003_project_order.sql new file mode 100644 index 0000000..8dfa844 --- /dev/null +++ b/backend/internal/database/003_project_order.sql @@ -0,0 +1,2 @@ +-- Keep identity allocation for new projects while allowing owned projects to swap positions. +ALTER TABLE projects ALTER COLUMN position SET GENERATED BY DEFAULT; diff --git a/backend/internal/database/migrate.go b/backend/internal/database/migrate.go index da651ee..3e2f13b 100644 --- a/backend/internal/database/migrate.go +++ b/backend/internal/database/migrate.go @@ -11,6 +11,12 @@ import ( //go:embed 001_practice.sql var practiceSQL string +//go:embed 002_project_icons.sql +var projectIconsSQL string + +//go:embed 003_project_order.sql +var projectOrderSQL 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 { @@ -25,17 +31,20 @@ func Migrate(ctx context.Context, pool *pgxpool.Pool) error { 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 { + for i, sql := range []string{practiceSQL, projectIconsSQL, projectOrderSQL} { + version := i + 1 + var applied bool + if err = tx.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version=$1)", version).Scan(&applied); err != nil { return err } + if !applied { + if _, err = tx.Exec(ctx, sql); err != nil { + return fmt.Errorf("migration %d: %w", version, err) + } + if _, err = tx.Exec(ctx, "INSERT INTO schema_migrations(version) VALUES ($1)", version); err != nil { + return err + } + } } return tx.Commit(ctx) } diff --git a/backend/internal/httpapi/practice.go b/backend/internal/httpapi/practice.go index 267306c..9649848 100644 --- a/backend/internal/httpapi/practice.go +++ b/backend/internal/httpapi/practice.go @@ -36,6 +36,7 @@ func NewAppHandler(pool *pgxpool.Pool, verifier IdentityVerifier, now func() tim 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/order", a.auth(a.reorderProjects)) 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)) @@ -139,14 +140,28 @@ func (a *app) projects(w http.ResponseWriter, r *http.Request, user string) erro } return err } -func (a *app) saveProject(w http.ResponseWriter, r *http.Request, user string) error { +func (a *app) reorderProjects(w http.ResponseWriter, r *http.Request, user string) error { var input struct { - Name string `json:"name"` + ProjectIDs []string `json:"projectIds"` } 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 := a.store.ReorderProjects(r.Context(), user, input.ProjectIDs); err != nil { + return err + } + respond(w, 200, map[string]string{"action": "reordered"}) + return nil +} +func (a *app) saveProject(w http.ResponseWriter, r *http.Request, user string) error { + var input struct { + Name string `json:"name"` + IconID *string `json:"iconId"` + } + if err := decode(w, r, &input); err != nil { + return err + } + p, err := a.store.SaveProject(r.Context(), user, r.PathValue("id"), input.Name, input.IconID) if err == nil { respond(w, 200, p) } diff --git a/backend/internal/httpapi/practice_test.go b/backend/internal/httpapi/practice_test.go index f14f326..d4fe9cd 100644 --- a/backend/internal/httpapi/practice_test.go +++ b/backend/internal/httpapi/practice_test.go @@ -160,6 +160,158 @@ func (h *harness) project(token, name string) string { return h.request("POST", "/v1/projects", token, string(body), 200)["id"].(string) } +func TestProjectOrderPersistsAndRejectsStaleOrForeignLists(t *testing.T) { + h := setup(t) + alice, bob := h.login("alice"), h.login("bob") + projectIDs := func(token string) []string { + h.t.Helper() + projects := h.request("GET", "/v1/projects", token, "", 200)["projects"].([]any) + ids := make([]string, len(projects)) + for i, raw := range projects { + ids[i] = raw.(map[string]any)["id"].(string) + } + return ids + } + setOrder := func(ids []string, status int) { + h.t.Helper() + body, _ := json.Marshal(map[string][]string{"projectIds": ids}) + h.request("PUT", "/v1/projects/order", alice, string(body), status) + } + initial := projectIDs(alice) + want := append([]string(nil), initial...) + for i, j := 0, len(want)-1; i < j; i, j = i+1, j-1 { + want[i], want[j] = want[j], want[i] + } + setOrder(want, 200) + h.h = httpapi.NewAppHandler(h.pool, testIdentity{}, func() time.Time { return h.now }) + if got := projectIDs(h.login("alice")); strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("order did not persist: %v", got) + } + newID := h.project(alice, "新项目") + want = append(want, newID) + if got := projectIDs(alice); strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("new project did not append: %v", got) + } + duplicate := append([]string(nil), want...) + duplicate[0] = duplicate[1] + setOrder(duplicate, 400) + setOrder(want[:len(want)-1], 400) + foreign := append([]string(nil), want...) + foreign[0] = projectIDs(bob)[0] + setOrder(foreign, 400) + if got := projectIDs(alice); strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("invalid order changed projects: %v", got) + } + h.request("POST", "/v1/records", alice, recordBody(want[0], "2026-09-28", 15, "", "sort-archive-0001"), 200) + h.request("DELETE", "/v1/projects/"+want[0], alice, "", 200) + setOrder(want, 400) + active := projectIDs(alice) + if strings.Join(active, ",") != strings.Join(want[1:], ",") { + t.Fatalf("archived project stayed active: %v", active) + } + for i, j := 0, len(active)-1; i < j; i, j = i+1, j-1 { + active[i], active[j] = active[j], active[i] + } + setOrder(active, 200) + if got := projectIDs(alice); strings.Join(got, ",") != strings.Join(active, ",") { + t.Fatalf("reorder after archive failed: %v", got) + } +} + +func TestProjectIconsPersistClearAndRespectOwnership(t *testing.T) { + h := setup(t) + alice, bob := h.login("alice"), h.login("bob") + presets := h.request("GET", "/v1/projects", alice, "", 200)["projects"].([]any) + want := []string{"plie", "tendu", "flexibility", "foot-hip", "core-glutes", "ball-core", "swan-arms", "breathing"} + for i, raw := range presets { + if raw.(map[string]any)["iconId"] != want[i] { + t.Fatalf("preset %d: %v", i, raw) + } + } + p := h.request("POST", "/v1/projects", alice, `{"name":"自选练习","iconId":"ball-core"}`, 200) + id := p["id"].(string) + if p["iconId"] != "ball-core" { + t.Fatal(p) + } + // Older clients rename without sending iconId. This must not clear artwork. + p = h.request("PUT", "/v1/projects/"+id, alice, `{"name":"晚间练习"}`, 200) + if p["iconId"] != "ball-core" { + t.Fatal("rename cleared the icon") + } + for _, icon := range []string{"plie", "tendu", "releve", "port-de-bras", "arabesque", "attitude", "pirouette", "grand-jete", "foot-hip", "flexibility", "ball-core", "swan-arms", "breathing", "core-glutes", "soft-shoes", "pointe-shoes", "barre", "piano"} { + p = h.request("PUT", "/v1/projects/"+id, alice, `{"name":"晚间练习","iconId":"`+icon+`"}`, 200) + if p["iconId"] != icon { + t.Fatalf("icon did not round trip: %s", icon) + } + } + h.request("PUT", "/v1/projects/"+id, bob, `{"name":"偷改","iconId":"breathing"}`, 404) + h.request("PUT", "/v1/projects/"+id, alice, `{"name":"不应保存","iconId":"unknown"}`, 400) + h.request("POST", "/v1/projects", alice, `{"name":"拒绝外部图片","iconId":"https://example.com/a.svg"}`, 400) + h.request("POST", "/v1/projects", alice, `{"name":"拒绝对象","iconId":{}}`, 400) + // A fresh handler and login must read the last confirmed value from the database. + h.h = httpapi.NewAppHandler(h.pool, testIdentity{}, func() time.Time { return h.now }) + find := func(token string) map[string]any { + for _, raw := range h.request("GET", "/v1/projects?includeArchived=true", token, "", 200)["projects"].([]any) { + project := raw.(map[string]any) + if project["id"] == id { + return project + } + } + t.Fatal("saved project missing") + return nil + } + p = find(h.login("alice")) + if p["name"] != "晚间练习" || p["iconId"] != "piano" { + t.Fatal("rejected write changed persisted project", p) + } + h.request("POST", "/v1/records", alice, recordBody(id, "2026-09-28", 15, "", "icon-archive-0001"), 200) + h.request("DELETE", "/v1/projects/"+id, alice, "", 200) + if p = find(alice); p["archived"] != true || p["iconId"] != "piano" { + t.Fatal("archiving lost artwork", p) + } + // Clearing a preset stays cleared after login and a service migration restart. + presetID := presets[0].(map[string]any)["id"].(string) + p = h.request("PUT", "/v1/projects/"+presetID, alice, `{"name":"零基础","iconId":""}`, 200) + if p["iconId"] != "" { + t.Fatal("explicit clear failed", p) + } + if err := database.Migrate(context.Background(), h.pool); err != nil { + t.Fatal(err) + } + projects := h.request("GET", "/v1/projects", h.login("alice"), "", 200)["projects"].([]any) + if projects[0].(map[string]any)["iconId"] != "" { + t.Fatal("restart restored a cleared icon") + } +} + +func TestProjectIconMigrationPreservesExistingProjectsAndRecords(t *testing.T) { + h := setup(t) + token := h.login("alice") + id := h.project(token, "自定义旧项目") + h.request("POST", "/v1/records", token, recordBody(id, "2026-09-28", 30, "已有笔记", "icon-migrate-001"), 200) + // Recreate the version-1 shape only inside this test's isolated schema. + ctx := context.Background() + if _, err := h.pool.Exec(ctx, "ALTER TABLE projects DROP COLUMN icon_id; DELETE FROM schema_migrations WHERE version=2"); err != nil { + t.Fatal(err) + } + if err := database.Migrate(ctx, h.pool); err != nil { + t.Fatal(err) + } + projects := h.request("GET", "/v1/projects", token, "", 200)["projects"].([]any) + if len(projects) != 9 || projects[0].(map[string]any)["iconId"] != "plie" { + t.Fatal("existing presets were not migrated", projects) + } + custom := projects[8].(map[string]any) + if custom["id"] != id || custom["iconId"] != "" || custom["name"] != "自定义旧项目" { + t.Fatal("migration changed custom project", custom) + } + records := h.request("GET", "/v1/records", token, "", 200)["records"].([]any) + if len(records) != 1 || records[0].(map[string]any)["note"] != "已有笔记" { + t.Fatal("migration changed existing history", records) + } + h.summary(token, 30, 1) +} + 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) @@ -187,7 +339,9 @@ func TestRecordsRoundTripCorrectionsAndStatistics(t *testing.T) { token := h.login("alice") class := h.project(token, "晚课") extra := h.project(token, "课后练习") - h.summary(token, 0, 0) + if got := h.summary(token, 0, 0)["allTime"].(map[string]any)["firstDate"]; got != "" { + t.Fatalf("empty history has first date %v", got) + } 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) @@ -198,6 +352,9 @@ func TestRecordsRoundTripCorrectionsAndStatistics(t *testing.T) { 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 got := review["allTime"].(map[string]any)["firstDate"]; got != "2026-09-27" { + t.Fatalf("earliest practice date after correction: %v", got) + } if review["period"].(map[string]any)["totalMinutes"] != float64(15) { t.Fatal("Sunday must belong to the previous week") } @@ -207,7 +364,9 @@ func TestRecordsRoundTripCorrectionsAndStatistics(t *testing.T) { 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) + if got := h.summary(token, 0, 0)["allTime"].(map[string]any)["firstDate"]; got != "" { + t.Fatalf("deleted history retained first date %v", got) + } } func TestArchiveRenameAndOwnershipAcrossAccounts(t *testing.T) { diff --git a/backend/internal/practice/icons.go b/backend/internal/practice/icons.go new file mode 100644 index 0000000..eac72da --- /dev/null +++ b/backend/internal/practice/icons.go @@ -0,0 +1,12 @@ +package practice + +// Icon IDs address bundled artwork, never client-provided URLs or file paths. +func validProjectIcon(id string) bool { + switch id { + case "", "plie", "tendu", "releve", "port-de-bras", "arabesque", "attitude", + "pirouette", "grand-jete", "foot-hip", "flexibility", "ball-core", + "swan-arms", "breathing", "core-glutes", "soft-shoes", "pointe-shoes", "barre", "piano": + return true + } + return false +} diff --git a/backend/internal/practice/review.go b/backend/internal/practice/review.go index 5a381c0..58f9709 100644 --- a/backend/internal/practice/review.go +++ b/backend/internal/practice/review.go @@ -13,10 +13,12 @@ type ProjectMinutes struct { Minutes int64 `json:"minutes"` } type Summary struct { - TotalMinutes int64 `json:"totalMinutes"` - PracticeDays int `json:"practiceDays"` - RecordCount int64 `json:"recordCount"` - Projects []ProjectMinutes `json:"projects"` + TotalMinutes int64 `json:"totalMinutes"` + PracticeDays int `json:"practiceDays"` + // FirstDate is the earliest actual practice date, or empty without records. + FirstDate string `json:"firstDate"` + RecordCount int64 `json:"recordCount"` + Projects []ProjectMinutes `json:"projects"` } type DayMinutes struct { Date string `json:"date"` @@ -42,6 +44,9 @@ func newSummary() *summaryBuilder { func (b *summaryBuilder) add(date string, project ProjectMinutes, count int64) { b.summary.TotalMinutes += project.Minutes b.summary.RecordCount += count + if b.summary.FirstDate == "" || date < b.summary.FirstDate { + b.summary.FirstDate = date + } b.dates[date] = true previous := b.projects[project.ProjectID] project.Minutes += previous.Minutes diff --git a/backend/internal/practice/store.go b/backend/internal/practice/store.go index 5595b40..94dcee2 100644 --- a/backend/internal/practice/store.go +++ b/backend/internal/practice/store.go @@ -27,7 +27,10 @@ var ( ErrDeleted = errors.New("这次提交的记录已被删除,不会重复创建") ) -var presets = []string{"零基础", "基础提升", "软开素质", "足髋训练", "核心臀腿", "小球核心", "天鹅臂颈", "呼吸训练"} +var presets = []struct{ name, iconID string }{ + {"零基础", "plie"}, {"基础提升", "tendu"}, {"软开素质", "flexibility"}, {"足髋训练", "foot-hip"}, + {"核心臀腿", "core-glutes"}, {"小球核心", "ball-core"}, {"天鹅臂颈", "swan-arms"}, {"呼吸训练", "breathing"}, +} type Store struct { db *pgxpool.Pool @@ -73,8 +76,8 @@ func (s *Store) Login(ctx context.Context, identity string) (Session, error) { 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 { + for _, preset := range presets { + if _, err = tx.Exec(ctx, "INSERT INTO projects(id,user_id,name,icon_id) VALUES($1,$2,$3,$4)", newID(), id, preset.name, preset.iconID); err != nil { return Session{}, err } } @@ -133,12 +136,13 @@ func (s *Store) write(ctx context.Context, user string, fn func(pgx.Tx) error) e type Project struct { ID string `json:"id"` Name string `json:"name"` + IconID string `json:"iconId"` 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) + rows, err := s.db.Query(ctx, `SELECT p.id,p.name,p.icon_id,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 @@ -147,7 +151,7 @@ func (s *Store) Projects(ctx context.Context, user string, archived bool) ([]Pro projects := []Project{} for rows.Next() { var p Project - if err := rows.Scan(&p.ID, &p.Name, &p.Archived, &p.HasRecords); err != nil { + if err := rows.Scan(&p.ID, &p.Name, &p.IconID, &p.Archived, &p.HasRecords); err != nil { return nil, err } projects = append(projects, p) @@ -155,20 +159,75 @@ func (s *Store) Projects(ctx context.Context, user string, archived bool) ([]Pro return projects, rows.Err() } -func (s *Store) SaveProject(ctx context.Context, user, id, name string) (Project, error) { +// ReorderProjects accepts exactly the owner's active project IDs. Reusing their +// current position slots leaves archived history and automatic append positions intact. +func (s *Store) ReorderProjects(ctx context.Context, user string, ids []string) error { + if ids == nil { + return ErrInvalid + } + return s.write(ctx, user, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, `SELECT id,position FROM projects WHERE user_id=$1 AND NOT archived ORDER BY position`, user) + if err != nil { + return err + } + positions := make([]int64, 0, len(ids)) + available := make(map[string]bool, len(ids)) + for rows.Next() { + var id string + var position int64 + if err := rows.Scan(&id, &position); err != nil { + rows.Close() + return err + } + positions = append(positions, position) + available[id] = true + } + err = rows.Err() + rows.Close() + if err != nil { + return err + } + if len(ids) != len(positions) { + return ErrInvalid + } + for _, id := range ids { + if !available[id] { + return ErrInvalid + } + delete(available, id) + } + for index, id := range ids { + if _, err := tx.Exec(ctx, `UPDATE projects SET position=$3 WHERE user_id=$1 AND id=$2 AND NOT archived`, user, id, positions[index]); err != nil { + return err + } + } + return nil + }) +} + +// SaveProject persists a name and optional icon choice within the owner's account. +// A nil iconID preserves existing artwork for older clients; an empty string clears it. +// Unknown icon IDs return ErrInvalid, and another owner's project returns ErrUnavailable. +func (s *Store) SaveProject(ctx context.Context, user, id, name string, iconID *string) (Project, error) { name = strings.TrimSpace(name) if name == "" || utf8.RuneCountInString(name) > NameLimit || strings.ContainsRune(name, 0) { return Project{}, ErrInvalid } + if iconID != nil && !validProjectIcon(*iconID) { + return Project{}, ErrInvalid + } p := Project{ID: id, Name: name} + if iconID != nil { + p.IconID = *iconID + } 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) + _, err := tx.Exec(ctx, "INSERT INTO projects(id,user_id,name,icon_id) VALUES($1,$2,$3,$4)", p.ID, user, name, p.IconID) 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) + err := tx.QueryRow(ctx, `UPDATE projects SET name=$3,icon_id=COALESCE($4,icon_id) WHERE user_id=$1 AND id=$2 RETURNING icon_id,archived, + EXISTS(SELECT 1 FROM records WHERE user_id=$1 AND project_id=$2)`, user, id, name, iconID).Scan(&p.IconID, &p.Archived, &p.HasRecords) if errors.Is(err, pgx.ErrNoRows) { return ErrUnavailable } diff --git a/docs/design/project-icons/arabesque.svg b/docs/design/project-icons/arabesque.svg new file mode 100644 index 0000000..b25e85e --- /dev/null +++ b/docs/design/project-icons/arabesque.svg @@ -0,0 +1,14 @@ + + Arabesque · 阿拉贝斯克 + 修长的灰玫瑰线描舞者,侧身单腿站立,后腿伸直,手臂与脚尖以自然收细的曲线舒展。 + + + + + + + + + + + diff --git a/docs/design/project-icons/attitude.svg b/docs/design/project-icons/attitude.svg new file mode 100644 index 0000000..e9df407 --- /dev/null +++ b/docs/design/project-icons/attitude.svg @@ -0,0 +1,14 @@ + + Attitude · 阿蒂蒂德 + 侧身舞者单腿支撑,工作腿向后抬起并屈膝,脚尖绷直;一臂举高,另一臂向前舒展。 + + + + + + + + + + + diff --git a/docs/design/project-icons/ball-core.svg b/docs/design/project-icons/ball-core.svg new file mode 100644 index 0000000..e69604f --- /dev/null +++ b/docs/design/project-icons/ball-core.svg @@ -0,0 +1,18 @@ + + 小球核心 + 舞者坐姿收腹,屈膝抬腿,双手在身前环抱一颗小球。 + + + + + + + + + + + + + + + diff --git a/docs/design/project-icons/barre.svg b/docs/design/project-icons/barre.svg new file mode 100644 index 0000000..734ac1d --- /dev/null +++ b/docs/design/project-icons/barre.svg @@ -0,0 +1,11 @@ + + 把杆 · Barre + 灰玫瑰线描双层练习把杆,柔和木色横杆与稳定的立柱底座。 + + + + + + + + diff --git a/docs/design/project-icons/breathing.svg b/docs/design/project-icons/breathing.svg new file mode 100644 index 0000000..aaca666 --- /dev/null +++ b/docs/design/project-icons/breathing.svg @@ -0,0 +1,17 @@ + + 呼吸训练 + 舞者自然盘坐,双手轻放身体两侧,下肋旁的柔和弧线表现呼吸节奏。 + + + + + + + + + + + + + + diff --git a/docs/design/project-icons/core-glutes.svg b/docs/design/project-icons/core-glutes.svg new file mode 100644 index 0000000..7a91366 --- /dev/null +++ b/docs/design/project-icons/core-glutes.svg @@ -0,0 +1,15 @@ + + 核心臀腿 + 侧面臀桥轮廓:肩背贴近垫面,髋部抬起,双膝屈曲,双脚平放,手臂沿身体两侧伸展。 + + + + + + + + + + + + diff --git a/docs/design/project-icons/flexibility.svg b/docs/design/project-icons/flexibility.svg new file mode 100644 index 0000000..8b08a13 --- /dev/null +++ b/docs/design/project-icons/flexibility.svg @@ -0,0 +1,14 @@ + + 软开素质 + 坐姿舞者一腿舒展、一腿收拢,手臂弧线越过头顶,身体向伸展腿一侧延长。 + + + + + + + + + + + diff --git a/docs/design/project-icons/foot-hip.svg b/docs/design/project-icons/foot-hip.svg new file mode 100644 index 0000000..de1f4ab --- /dev/null +++ b/docs/design/project-icons/foot-hip.svg @@ -0,0 +1,16 @@ + + 足髋训练 + 坐姿舞者一腿屈曲外展、一腿向前伸展并勾起脚尖,以足部与髋部的轮廓表达基础训练。 + + + + + + + + + + + + + diff --git a/docs/design/project-icons/grand-jete.svg b/docs/design/project-icons/grand-jete.svg new file mode 100644 index 0000000..9d988f6 --- /dev/null +++ b/docs/design/project-icons/grand-jete.svg @@ -0,0 +1,15 @@ + + Grand jeté · 大跳 + 腾空舞者,前后双腿伸展成大幅分腿线,双脚绷直,双臂舒展。 + + + + + + + + + + + + diff --git a/docs/design/project-icons/piano.svg b/docs/design/project-icons/piano.svg new file mode 100644 index 0000000..6e7c9d0 --- /dev/null +++ b/docs/design/project-icons/piano.svg @@ -0,0 +1,15 @@ + + 钢琴 · Piano + 灰玫瑰线描三角钢琴,弧形琴身、浅色琴盖与简洁黑白琴键。 + + + + + + + + + + + + diff --git a/docs/design/project-icons/pirouette.svg b/docs/design/project-icons/pirouette.svg new file mode 100644 index 0000000..d57a796 --- /dev/null +++ b/docs/design/project-icons/pirouette.svg @@ -0,0 +1,16 @@ + + Pirouette · 旋转 + 旋转中的代表姿态:单脚半脚尖支撑,另一脚收至支撑腿膝旁,双臂围圆,短弧线提示旋转。 + + + + + + + + + + + + + diff --git a/docs/design/project-icons/plie.svg b/docs/design/project-icons/plie.svg new file mode 100644 index 0000000..3de0691 --- /dev/null +++ b/docs/design/project-icons/plie.svg @@ -0,0 +1,13 @@ + + Plié · 一位半蹲 + 灰玫瑰线描舞者,以一位脚位半蹲,脚跟相接并着地,双膝向外打开,小腿收向脚跟,躯干保持直立。 + + + + + + + + + + diff --git a/docs/design/project-icons/pointe-shoes.svg b/docs/design/project-icons/pointe-shoes.svg new file mode 100644 index 0000000..56df4c3 --- /dev/null +++ b/docs/design/project-icons/pointe-shoes.svg @@ -0,0 +1,18 @@ + + 足尖鞋 · Pointe shoes + 灰玫瑰线描的成对足尖鞋,浅玫瑰鞋身与自然垂落的缎带。 + + + + + + + + + + + + + + + diff --git a/docs/design/project-icons/port-de-bras.svg b/docs/design/project-icons/port-de-bras.svg new file mode 100644 index 0000000..655f81d --- /dev/null +++ b/docs/design/project-icons/port-de-bras.svg @@ -0,0 +1,13 @@ + + Port de bras · 手臂练习 + 以一个手位瞬间表达手臂练习:一臂柔和地举过头顶,另一臂向侧方舒展。 + + + + + + + + + + diff --git a/docs/design/project-icons/preview.html b/docs/design/project-icons/preview.html new file mode 100644 index 0000000..cf8b70a --- /dev/null +++ b/docs/design/project-icons/preview.html @@ -0,0 +1,209 @@ + + + + + + 芭蕾岛 · 项目图标库 + + + + +
+
芭蕾岛18 枚项目图标
+
+

项目图标库

柔和线描 · 浅色填充

+
+
+
+
+

项目列表预览52 px 行高

+
阿拉贝斯克周末基础课
+
+
+
图标尺寸
+
阿拉贝斯克
+
+
+
+
+
预览底色
+
+
+ +
+ + + diff --git a/docs/design/project-icons/releve.svg b/docs/design/project-icons/releve.svg new file mode 100644 index 0000000..de3cad2 --- /dev/null +++ b/docs/design/project-icons/releve.svg @@ -0,0 +1,14 @@ + + Relevé · 立半脚尖 + 修长舞者直立,双脚脚跟抬起,前脚掌着地,双臂在身前自然围圆。 + + + + + + + + + + + diff --git a/docs/design/project-icons/soft-shoes.svg b/docs/design/project-icons/soft-shoes.svg new file mode 100644 index 0000000..0a50f56 --- /dev/null +++ b/docs/design/project-icons/soft-shoes.svg @@ -0,0 +1,16 @@ + + 软底舞鞋 · Ballet slippers + 灰玫瑰线描软底舞鞋,圆润鞋头、浅色鞋身与交叉松紧带。 + + + + + + + + + + + + + diff --git a/docs/design/project-icons/swan-arms.svg b/docs/design/project-icons/swan-arms.svg new file mode 100644 index 0000000..3361209 --- /dev/null +++ b/docs/design/project-icons/swan-arms.svg @@ -0,0 +1,13 @@ + + 天鹅臂颈 + 修长肩颈与轻柔展开的双臂,手腕自然垂落,形成舒展的翼形轮廓。 + + + + + + + + + + diff --git a/docs/design/project-icons/tendu.svg b/docs/design/project-icons/tendu.svg new file mode 100644 index 0000000..a361e37 --- /dev/null +++ b/docs/design/project-icons/tendu.svg @@ -0,0 +1,12 @@ + + Tendu · 擦地 + 修长舞者单腿直立,工作腿向侧方伸长,绷直的脚尖仍触地。 + + + + + + + + + diff --git a/miniprogram/src/app.config.ts b/miniprogram/src/app.config.ts index 37591ec..533c420 100644 --- a/miniprogram/src/app.config.ts +++ b/miniprogram/src/app.config.ts @@ -1,11 +1,11 @@ export default defineAppConfig({ - pages: ['pages/index/index', 'pages/calendar/index', 'pages/growth/index', 'pages/record/index', 'pages/projects/index'], + pages: ['pages/index/index', 'pages/calendar/index', 'pages/growth/index', 'pages/mine/index', 'pages/record/index', 'pages/projects/index', 'pages/projects-sort/index'], tabBar: { color: '#817573', selectedColor: '#9c5268', backgroundColor: '#fffdfa', borderStyle: 'white', list: [ { pagePath: 'pages/index/index', text: '记录' }, - { pagePath: 'pages/calendar/index', text: '日历' }, { pagePath: 'pages/growth/index', text: '成长' }, + { pagePath: 'pages/mine/index', text: '我的' }, ], }, window: { diff --git a/miniprogram/src/app.scss b/miniprogram/src/app.scss index b4d3e75..68b2cc2 100644 --- a/miniprogram/src/app.scss +++ b/miniprogram/src/app.scss @@ -24,33 +24,57 @@ page { } view, text, input, textarea { box-sizing: border-box; } .page { padding: 20px 18px 32px; padding-bottom: calc(32px + env(safe-area-inset-bottom)); } -.page-heading { margin: 4px 2px 24px; } .eyebrow { display: block; color: #946d76; font-size: 10px; letter-spacing: 2px; margin-bottom: 8px; } -.page-title { display: block; margin-bottom: 8px; font-size: 26px; font-weight: 600; line-height: 1.35; letter-spacing: -0.5px; } .muted { color: #827572; font-size: 13px; } .small { font-size: 12px; } .block { display: block; } .panel { padding: 18px; background: #fffdfa; border: 1px solid #ece3dd; border-radius: 18px; } .hero-card { padding: 23px 22px; border: 1px solid #eddfda; background: #f1e7e3; border-radius: 21px; } .hero-number { display: block; color: #864b5e; font-size: 29px; font-weight: 600; line-height: 1.5; margin: 4px 0 8px; overflow-wrap: anywhere; } +.record-overview { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; align-items: start; } +.overview-column { min-width: 0; } +.overview-column--right { border-left: 1px solid #e2cfd0; padding-left: 18px; } +.overview-label { display: block; color: #946d76; font-size: 12px; line-height: 18px; } +.overview-value { display: block; color: #864b5e; font-size: 28px; font-weight: 600; line-height: 1.3; margin-top: 8px; overflow-wrap: anywhere; } +.overview-unit { font-size: 15px; font-weight: 400; } +.overview-start { display: block; color: #827572; font-size: 11px; margin-top: 6px; } .section-heading, .row { display: flex; align-items: center; justify-content: space-between; gap: 10px; } .section-heading { margin: 24px 2px 12px; } +.section-heading--actions { justify-content: flex-end; margin-top: 12px; } .section-title { font-size: 17px; font-weight: 600; } -.project-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } -.project-tile { border: 1px solid #e7ded8; border-radius: 17px; min-height: 126px; padding: 15px 16px; background: #f4ece8; } +.project-list { display: flex; flex-direction: column; gap: 10px; } +.project-tile { display: flex; align-items: center; gap: 12px; border: 1px solid #e7ded8; border-radius: 17px; padding: 10px 16px; background: #f4ece8; } .project-tile--1 { background: #eef0e7; border-color: #e0e5d9; } .project-tile--2 { background: #eeeaf2; border-color: #e3dfea; } .project-tile--3 { background: #f4efdf; border-color: #eae4d5; } -.project-mark { display: block; color: #9a8583; font-size: 10px; letter-spacing: 1px; margin-bottom: 10px; } -.project-title { display: block; font-size: 17px; font-weight: 600; overflow-wrap: anywhere; } -.project-hint { display: block; font-size: 11px; color: #8b7476; margin-top: 8px; } +.project-title { display: block; flex: 1; min-width: 0; font-size: 17px; font-weight: 600; overflow-wrap: anywhere; } +.project-hours { flex-shrink: 0; color: #9c5268; font-size: 18px; font-weight: 600; white-space: nowrap; } +.project-hours-unit { font-size: 12px; font-weight: 400; } +.project-icon { display: block; flex-shrink: 0; width: 32px; height: 32px; } +.action-icon { display: block; width: 28px; height: 28px; } +.profile-card { display: flex; align-items: center; gap: 18px; padding: 24px 20px; border: 1px solid #eddfda; border-radius: 21px; background: #f1e7e3; } +.profile-avatar-button { display: flex; flex-shrink: 0; align-items: center; justify-content: center; width: 72px; height: 72px; margin: 0; padding: 0; border-radius: 50%; background: #e2cfd0; overflow: hidden; } +.profile-avatar-button::after { border: 0; } +.profile-avatar { display: block; width: 72px; height: 72px; } +.profile-avatar-placeholder { color: #864b5e; font-size: 28px; font-weight: 600; line-height: 1; } +.profile-details { flex: 1; min-width: 0; } +.profile-nickname { display: block; width: 100%; height: 36px; color: #3e3435; font-size: 21px; font-weight: 600; } +.profile-hint { display: block; margin-top: 3px; color: #827572; font-size: 12px; } +.profile-storage-note { display: block; margin: 12px 2px; color: #827572; font-size: 11px; } +.sort-instruction { display: block; color: #827572; font-size: 13px; margin-bottom: 16px; } +.sort-project-list { display: flex; flex-direction: column; gap: 10px; } +.sortable-project { position: relative; z-index: 0; display: flex; align-items: center; gap: 12px; min-height: 64px; padding: 8px 10px 8px 16px; border: 1px solid #ece3dd; border-radius: 17px; background: #fffdfa; } +.sortable-project--dragging { z-index: 2; border-color: #9c5268; background: #f4ece8; } +.sortable-project--target { border-color: #bc8292; } +.sort-project-name { flex: 1; min-width: 0; font-size: 16px; font-weight: 500; overflow-wrap: anywhere; } +.sort-drag-handle { display: flex; flex-shrink: 0; align-items: center; justify-content: center; width: 44px; height: 44px; } +.sort-drag-icon { display: block; width: 22px; height: 22px; } .record-card { padding: 16px 18px; background: #fffdfa; border: 1px solid #ece3dd; border-radius: 16px; margin-bottom: 10px; } .record-name { font-size: 15px; font-weight: 500; overflow-wrap: anywhere; flex: 1; min-width: 0; } .record-duration { color: #9c5268; font-size: 13px; text-align: right; max-width: 45%; } .record-note { display: block; white-space: pre-wrap; overflow-wrap: anywhere; padding-top: 12px; border-top: 1px solid #f0e9e3; font-size: 13px; color: #756364; } .empty { text-align: center; padding: 20px 4px; } -.empty-title { display: block; font-size: 16px; font-weight: 500; margin-bottom: 10px; } -.empty .muted { display: block; line-height: 1.8; } +.empty-title { display: block; font-size: 16px; font-weight: 500; } .status-panel { display: flex; flex-direction: column; gap: 16px; align-items: center; margin-bottom: 16px; } .error-text { color: #a43b4b; } .error-banner, .success-banner { display: block; border-radius: 12px; padding: 13px 15px; margin: 14px 0; font-size: 13px; line-height: 1.7; overflow-wrap: anywhere; } @@ -58,21 +82,32 @@ view, text, input, textarea { box-sizing: border-box; } .success-banner { color: #4f6b4c; background: #eaf0e7; } .form-panel .field-label:first-child { margin-top: 0; } .field-label { display: block; font-size: 14px; font-weight: 500; margin: 22px 0 10px; } -.field { display: block; width: 100%; background: #f8f4ef; border: 1px solid #e8dfd8; border-radius: 11px; padding: 12px 14px; height: 48px; font-size: 16px; color: #44383b; } -.picker-field { display: flex; justify-content: space-between; height: auto; min-height: 48px; overflow-wrap: anywhere; gap: 8px; } +// Native single-line inputs position their text from height; vertical padding shifts filled values down. +.field { display: block; width: 100%; background: #f8f4ef; border: 1px solid #e8dfd8; border-radius: 11px; padding: 0 14px; height: 48px; font-size: 16px; color: #44383b; } +.picker-field { display: flex; justify-content: space-between; padding: 12px 14px; height: auto; min-height: 48px; overflow-wrap: anywhere; gap: 8px; } .quick-minutes { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; } -.note-input { min-height: 140px; height: 140px; line-height: 1.6; } +.note-input { padding: 12px 14px; min-height: 140px; height: 140px; line-height: 1.6; } .field-counter { display: block; text-align: right; color: #968983; font-size: 11px; margin-top: 6px; } .field-help { display: block; color: #877872; font-size: 12px; margin: 10px 0 14px; line-height: 1.7; } -.form-actions { margin-top: 22px; } -.project-name-input { margin-top: 16px; } -.page-footnote { display: block; text-align: center; color: #998a82; font-size: 11px; line-height: 1.8; margin: 24px 12px 8px; } -.managed-project { padding: 12px 0; border-bottom: 1px solid #f0e8e1; } -.managed-project:first-child { padding-top: 0; } -.managed-project:last-child { padding-bottom: 0; border-bottom: 0; } -.managed-project > .record-name { display: block; } -.managed-project > .row { align-items: flex-end; } +.form-page { padding-bottom: calc(100px + env(safe-area-inset-bottom)); } +.form-actions { position: fixed; left: 0; right: 0; bottom: 0; z-index: 20; display: flex; gap: 12px; padding: 12px 18px; padding-bottom: calc(12px + env(safe-area-inset-bottom)); border-top: 1px solid #ece3dd; background: #fffdfa; } +.form-action { flex: 1; min-width: 0; } +.managed-project { display: flex; align-items: center; gap: 12px; padding: 8px 16px; background: #fffdfa; border: 1px solid #ece3dd; border-radius: 17px; } .inline-actions { display: flex; flex-shrink: 0; } +.project-icon-field { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.project-icon-current { display: flex; align-items: center; gap: 10px; min-width: 0; } +.icon-picker { --popup-background-color: #fffdfa; --popup-rounded-border-radius: 20px; } +.icon-picker-heading { display: flex; align-items: center; justify-content: space-between; padding: 16px 18px 8px; } +.icon-picker-tabs { display: flex; gap: 8px; padding: 0 18px 14px; } +.icon-picker-scroll { height: 48vh; max-height: 400px; } +.icon-picker-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; padding: 2px 18px 16px; } +.icon-choice { position: relative; display: flex; width: 100%; min-width: 0; flex-direction: column; align-items: center; gap: 4px; margin: 0; padding: 10px 4px; border: 1px solid #ece3dd; border-radius: 12px; background: #faf7f3; color: #655656; font-size: 12px; line-height: 1.5; } +.icon-choice::after { border: 0; } +.icon-choice--selected { background: #f4ece8; border-color: #9c5268; color: #9c5268; } +.icon-choice-image { display: block; width: 60px; height: 60px; } +.icon-choice-name { display: block; overflow-wrap: anywhere; } +.icon-choice-check { position: absolute; top: 4px; right: 7px; font-size: 12px; } +.icon-picker-footer { padding: 6px 18px calc(12px + env(safe-area-inset-bottom)); border-top: 1px solid #ece3dd; } .month-nav { display: flex; align-items: center; justify-content: space-between; gap: 4px; margin: 0 -8px 12px; } .calendar-panel { padding: 16px 12px; } .calendar-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); text-align: center; } diff --git a/miniprogram/src/assets/action-icons/add-project.svg b/miniprogram/src/assets/action-icons/add-project.svg new file mode 100644 index 0000000..79243aa --- /dev/null +++ b/miniprogram/src/assets/action-icons/add-project.svg @@ -0,0 +1,4 @@ + + + + diff --git a/miniprogram/src/assets/action-icons/calendar.svg b/miniprogram/src/assets/action-icons/calendar.svg new file mode 100644 index 0000000..ffcef36 --- /dev/null +++ b/miniprogram/src/assets/action-icons/calendar.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/miniprogram/src/assets/action-icons/drag-handle.svg b/miniprogram/src/assets/action-icons/drag-handle.svg new file mode 100644 index 0000000..6e2132b --- /dev/null +++ b/miniprogram/src/assets/action-icons/drag-handle.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/miniprogram/src/assets/action-icons/manage-list.svg b/miniprogram/src/assets/action-icons/manage-list.svg new file mode 100644 index 0000000..86208be --- /dev/null +++ b/miniprogram/src/assets/action-icons/manage-list.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/miniprogram/src/assets/project-icons/arabesque.png b/miniprogram/src/assets/project-icons/arabesque.png new file mode 100644 index 0000000..b6cde10 Binary files /dev/null and b/miniprogram/src/assets/project-icons/arabesque.png differ diff --git a/miniprogram/src/assets/project-icons/attitude.png b/miniprogram/src/assets/project-icons/attitude.png new file mode 100644 index 0000000..b62206c Binary files /dev/null and b/miniprogram/src/assets/project-icons/attitude.png differ diff --git a/miniprogram/src/assets/project-icons/ball-core.png b/miniprogram/src/assets/project-icons/ball-core.png new file mode 100644 index 0000000..d9ff6d9 Binary files /dev/null and b/miniprogram/src/assets/project-icons/ball-core.png differ diff --git a/miniprogram/src/assets/project-icons/barre.png b/miniprogram/src/assets/project-icons/barre.png new file mode 100644 index 0000000..b17d2d2 Binary files /dev/null and b/miniprogram/src/assets/project-icons/barre.png differ diff --git a/miniprogram/src/assets/project-icons/breathing.png b/miniprogram/src/assets/project-icons/breathing.png new file mode 100644 index 0000000..601b63b Binary files /dev/null and b/miniprogram/src/assets/project-icons/breathing.png differ diff --git a/miniprogram/src/assets/project-icons/core-glutes.png b/miniprogram/src/assets/project-icons/core-glutes.png new file mode 100644 index 0000000..644d848 Binary files /dev/null and b/miniprogram/src/assets/project-icons/core-glutes.png differ diff --git a/miniprogram/src/assets/project-icons/flexibility.png b/miniprogram/src/assets/project-icons/flexibility.png new file mode 100644 index 0000000..905d07a Binary files /dev/null and b/miniprogram/src/assets/project-icons/flexibility.png differ diff --git a/miniprogram/src/assets/project-icons/foot-hip.png b/miniprogram/src/assets/project-icons/foot-hip.png new file mode 100644 index 0000000..d9938ae Binary files /dev/null and b/miniprogram/src/assets/project-icons/foot-hip.png differ diff --git a/miniprogram/src/assets/project-icons/grand-jete.png b/miniprogram/src/assets/project-icons/grand-jete.png new file mode 100644 index 0000000..c1cb7a1 Binary files /dev/null and b/miniprogram/src/assets/project-icons/grand-jete.png differ diff --git a/miniprogram/src/assets/project-icons/index.ts b/miniprogram/src/assets/project-icons/index.ts new file mode 100644 index 0000000..8764d9b --- /dev/null +++ b/miniprogram/src/assets/project-icons/index.ts @@ -0,0 +1,45 @@ +import plie from './plie.png' +import tendu from './tendu.png' +import releve from './releve.png' +import portDeBras from './port-de-bras.png' +import arabesque from './arabesque.png' +import attitude from './attitude.png' +import pirouette from './pirouette.png' +import grandJete from './grand-jete.png' +import footHip from './foot-hip.png' +import flexibility from './flexibility.png' +import ballCore from './ball-core.png' +import swanArms from './swan-arms.png' +import breathing from './breathing.png' +import coreGlutes from './core-glutes.png' +import softShoes from './soft-shoes.png' +import pointeShoes from './pointe-shoes.png' +import barre from './barre.png' +import piano from './piano.png' + +// Transparent 3× exports of docs/design/project-icons/*.svg. Only bundled assets +// are resolved here; an unknown server ID safely displays the project name alone. +export const projectIcons = [ + { id: 'foot-hip', name: '足髋训练', group: 'training', src: footHip }, + { id: 'flexibility', name: '软开素质', group: 'training', src: flexibility }, + { id: 'ball-core', name: '小球核心', group: 'training', src: ballCore }, + { id: 'swan-arms', name: '天鹅臂颈', group: 'training', src: swanArms }, + { id: 'breathing', name: '呼吸训练', group: 'training', src: breathing }, + { id: 'core-glutes', name: '核心臀腿', group: 'training', src: coreGlutes }, + { id: 'plie', name: '蹲', group: 'motion', src: plie }, + { id: 'tendu', name: '擦地', group: 'motion', src: tendu }, + { id: 'releve', name: '立半脚尖', group: 'motion', src: releve }, + { id: 'port-de-bras', name: '手臂练习', group: 'motion', src: portDeBras }, + { id: 'arabesque', name: '阿拉贝斯克', group: 'motion', src: arabesque }, + { id: 'attitude', name: '阿蒂蒂德', group: 'motion', src: attitude }, + { id: 'pirouette', name: '旋转', group: 'motion', src: pirouette }, + { id: 'grand-jete', name: '大跳', group: 'motion', src: grandJete }, + { id: 'soft-shoes', name: '软底舞鞋', group: 'object', src: softShoes }, + { id: 'pointe-shoes', name: '足尖鞋', group: 'object', src: pointeShoes }, + { id: 'barre', name: '把杆', group: 'object', src: barre }, + { id: 'piano', name: '钢琴', group: 'object', src: piano }, +] as const + +export function projectIcon(id?: string) { + return projectIcons.find(icon => icon.id === id) +} diff --git a/miniprogram/src/assets/project-icons/piano.png b/miniprogram/src/assets/project-icons/piano.png new file mode 100644 index 0000000..f7ae314 Binary files /dev/null and b/miniprogram/src/assets/project-icons/piano.png differ diff --git a/miniprogram/src/assets/project-icons/pirouette.png b/miniprogram/src/assets/project-icons/pirouette.png new file mode 100644 index 0000000..b71fd8d Binary files /dev/null and b/miniprogram/src/assets/project-icons/pirouette.png differ diff --git a/miniprogram/src/assets/project-icons/plie.png b/miniprogram/src/assets/project-icons/plie.png new file mode 100644 index 0000000..447cfa2 Binary files /dev/null and b/miniprogram/src/assets/project-icons/plie.png differ diff --git a/miniprogram/src/assets/project-icons/pointe-shoes.png b/miniprogram/src/assets/project-icons/pointe-shoes.png new file mode 100644 index 0000000..0611dfd Binary files /dev/null and b/miniprogram/src/assets/project-icons/pointe-shoes.png differ diff --git a/miniprogram/src/assets/project-icons/port-de-bras.png b/miniprogram/src/assets/project-icons/port-de-bras.png new file mode 100644 index 0000000..8fa7de9 Binary files /dev/null and b/miniprogram/src/assets/project-icons/port-de-bras.png differ diff --git a/miniprogram/src/assets/project-icons/releve.png b/miniprogram/src/assets/project-icons/releve.png new file mode 100644 index 0000000..9eddd48 Binary files /dev/null and b/miniprogram/src/assets/project-icons/releve.png differ diff --git a/miniprogram/src/assets/project-icons/soft-shoes.png b/miniprogram/src/assets/project-icons/soft-shoes.png new file mode 100644 index 0000000..b7f4c62 Binary files /dev/null and b/miniprogram/src/assets/project-icons/soft-shoes.png differ diff --git a/miniprogram/src/assets/project-icons/swan-arms.png b/miniprogram/src/assets/project-icons/swan-arms.png new file mode 100644 index 0000000..fe125d6 Binary files /dev/null and b/miniprogram/src/assets/project-icons/swan-arms.png differ diff --git a/miniprogram/src/assets/project-icons/tendu.png b/miniprogram/src/assets/project-icons/tendu.png new file mode 100644 index 0000000..a98119a Binary files /dev/null and b/miniprogram/src/assets/project-icons/tendu.png differ diff --git a/miniprogram/src/components/common.tsx b/miniprogram/src/components/common.tsx index 16b5d4d..06c46f4 100644 --- a/miniprogram/src/components/common.tsx +++ b/miniprogram/src/components/common.tsx @@ -7,15 +7,25 @@ import { duration } from '../utils/dates' export { Button } -export function PageHeading({ title, subtitle }: { title: string; subtitle: string }) { - return BALLET ISLAND{title}{subtitle} +export function FormActions({ busy, cancelDisabled, saveDisabled, saveLabel = '保存', onCancel, onSave }: { + busy: boolean + cancelDisabled?: boolean + saveDisabled?: boolean + saveLabel?: string + onCancel: () => unknown + onSave: () => unknown +}) { + return + + + } export function LoadState({ loading, error, retry }: { loading: boolean; error: string; retry: () => unknown }) { if (!loading && !error) return null return {loading ? '正在读取你的练习…' : error}{error && } } -export function Empty({ title, text }: { title: string; text: string }) { - return {title}{text} +export function Empty({ title }: { title: string }) { + return {title} } export function RecordCards({ records }: { records: PracticeRecord[] }) { return {records.map(record => diff --git a/miniprogram/src/components/project-icon-picker.tsx b/miniprogram/src/components/project-icon-picker.tsx new file mode 100644 index 0000000..2802e1e --- /dev/null +++ b/miniprogram/src/components/project-icon-picker.tsx @@ -0,0 +1,51 @@ +import { useState } from 'react' +import { Button as NativeButton, Image, ScrollView, Text, View } from '@tarojs/components' +import Popup from '@taroify/core/popup' +import '@taroify/core/popup/style' +import { Button } from './common' +import { ProjectIcon } from './project-icon' +import { projectIcon, projectIcons } from '../assets/project-icons' + +const groups = [ + { id: 'training', name: '基础练习' }, + { id: 'motion', name: '专业动作' }, + { id: 'object', name: '物件' }, +] as const +type Group = typeof groups[number]['id'] + +/** Selection only updates the enclosing form; its save/cancel controls own persistence. */ +export function ProjectIconPicker({ value, disabled, onChange }: { + value: string; disabled: boolean; onChange: (id: string) => void +}) { + const [open, setOpen] = useState(false) + const [group, setGroup] = useState('training') + const selected = projectIcon(value) + + function choose(id: string) { + if (disabled) return + onChange(id) + setOpen(false) + } + + return <> + + {selected?.name || '无图标'} + + + setOpen(false)}> + 项目图标 + {groups.map(item => )} + + {projectIcons.filter(icon => icon.group === group).map(icon => choose(icon.id)} + > + + {icon.name} + {value === icon.id && ✓} + )} + + + + +} diff --git a/miniprogram/src/components/project-icon.tsx b/miniprogram/src/components/project-icon.tsx new file mode 100644 index 0000000..20851f0 --- /dev/null +++ b/miniprogram/src/components/project-icon.tsx @@ -0,0 +1,8 @@ +import { Image } from '@tarojs/components' +import { projectIcon } from '../assets/project-icons' + +/** Unknown or empty IDs leave the readable project title intact without a placeholder. */ +export function ProjectIcon({ id, className = '' }: { id?: string; className?: string }) { + const icon = projectIcon(id) + return icon ? : null +} diff --git a/miniprogram/src/pages/calendar/index.tsx b/miniprogram/src/pages/calendar/index.tsx index f056229..8b55b00 100644 --- a/miniprogram/src/pages/calendar/index.tsx +++ b/miniprogram/src/pages/calendar/index.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from 'react' import Taro, { useDidHide } from '@tarojs/taro' import { Picker, Text, View } from '@tarojs/components' -import { Button, Empty, LoadState, PageHeading, RecordCards } from '../../components/common' +import { Button, Empty, LoadState, RecordCards } from '../../components/common' import { useRemote } from '../../hooks/useRemote' import { api, errorMessage, type RecordList } from '../../services/practice' import { addMonths, displayDate, duration, monthLabel, monthStart, parseDate, shanghaiToday } from '../../utils/dates' @@ -41,7 +41,6 @@ export default function Calendar() { } return - @@ -59,7 +58,7 @@ export default function Calendar() { {review && list && <> {displayDate(selected)}共 {duration(total)} - {list.records.length ? : } + {list.records.length ? : } {moreError && {moreError}} {list.nextCursor && } } diff --git a/miniprogram/src/pages/growth/index.tsx b/miniprogram/src/pages/growth/index.tsx index 15a01db..302d6b3 100644 --- a/miniprogram/src/pages/growth/index.tsx +++ b/miniprogram/src/pages/growth/index.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { Text, View } from '@tarojs/components' -import { Button, Empty, LoadState, PageHeading } from '../../components/common' +import { Button, Empty, LoadState } from '../../components/common' import { useRemote } from '../../hooks/useRemote' import { api } from '../../services/practice' import { addDays, addMonths, duration, monthStart, shanghaiToday } from '../../utils/dates' @@ -19,10 +19,9 @@ export default function Growth() { } const currentPeriod = review && (period === 'week' ? review.to >= review.today : monthStart(review.from) === monthStart(review.today)) return - {review && <> - 累计练习{duration(review.allTime.totalMinutes)}每一分钟,都来自你留下的真实记录。 + 累计练习{duration(review.allTime.totalMinutes)} {review.allTime.practiceDays} 天练习天数{review.allTime.recordCount} 次练习记录 时长趋势{(['week', 'month'] as const).map(value => )} @@ -34,12 +33,11 @@ export default function Growth() { {focus ? `${focus.date} · ${duration(focus.minutes)}` : '点按柱形查看当天时长;空白表示没有记录。'} {!currentPeriod && } - 时间花在哪里全部练习 + 项目时长全部练习 {review.allTime.projects.length ? review.allTime.projects.map(project => {project.name}{project.archived ? ' · 已归档' : ''}{(project.minutes / review.allTime.totalMinutes * 100).toFixed(1)}% {duration(project.minutes)} - ) : } - 时长记录的是投入,不是技术水平。慢慢来,按自己的节奏练习。 + ) : } } } diff --git a/miniprogram/src/pages/index/index.tsx b/miniprogram/src/pages/index/index.tsx index 3cef6ba..8f97000 100644 --- a/miniprogram/src/pages/index/index.tsx +++ b/miniprogram/src/pages/index/index.tsx @@ -1,29 +1,41 @@ import Taro from '@tarojs/taro' -import { Text, View } from '@tarojs/components' -import { Button, Empty, LoadState, PageHeading, RecordCards } from '../../components/common' +import { Image, Text, View } from '@tarojs/components' +import addProjectIcon from '../../assets/action-icons/add-project.svg' +import calendarIcon from '../../assets/action-icons/calendar.svg' +import manageListIcon from '../../assets/action-icons/manage-list.svg' +import { Button, Empty, LoadState } from '../../components/common' +import { ProjectIcon } from '../../components/project-icon' import { useRemote } from '../../hooks/useRemote' import { api } from '../../services/practice' -import { duration } from '../../utils/dates' + +function hours(minutes: number) { return Number((minutes / 60).toFixed(2)) } export default function Index() { const state = useRemote(async () => { - const [projects, recent, review] = await Promise.all([api.projects(), api.records(), api.review('week')]) - return { ...projects, recent, review } + const [projects, review] = await Promise.all([api.projects(), api.review('week')]) + return { ...projects, review } }) const data = state.data - const todayMinutes = data?.review.days.find(day => day.date === data.today)?.minutes || 0 + const projectMinutes = new Map(data?.review.allTime.projects.map(project => [project.projectId, project.minutes]) || []) + const firstDate = data?.review.allTime.firstDate return - {data && <> - 今天的练习{duration(todayMinutes)}{data.today} · 按实际练习日期记录 - 这次练了什么 - {data.projects.length ? {data.projects.map((project, index) => { void Taro.navigateTo({ url: `/pages/record/index?projectId=${project.id}&date=${data.today}` }) }}> - {String(index + 1).padStart(2, '0')}{project.name}记录练习 + - )} : } - 近期记录最近 20 条 - {data.recent.records.length ? : } - + + 总记录时间{hours(data.review.allTime.totalMinutes)} h + + 坚持天数{data.review.allTime.practiceDays} 天 + {firstDate && 从 {firstDate.slice(0, 4)} 年 {firstDate.slice(5, 7)} 月 {firstDate.slice(8, 10)} 日} + + + + + + updateProfile({ ...profileRef.current, nickname: event.detail.value })} /> + 点击头像或昵称,选择微信资料 + + + {error && {error}} + 头像和昵称仅保存在当前设备 + +} diff --git a/miniprogram/src/pages/projects-sort/index.config.ts b/miniprogram/src/pages/projects-sort/index.config.ts new file mode 100644 index 0000000..4213441 --- /dev/null +++ b/miniprogram/src/pages/projects-sort/index.config.ts @@ -0,0 +1 @@ +export default definePageConfig({ navigationBarTitleText: '管理列表' }) diff --git a/miniprogram/src/pages/projects-sort/index.tsx b/miniprogram/src/pages/projects-sort/index.tsx new file mode 100644 index 0000000..126628b --- /dev/null +++ b/miniprogram/src/pages/projects-sort/index.tsx @@ -0,0 +1,107 @@ +import { useEffect, useRef, useState } from 'react' +import Taro from '@tarojs/taro' +import { Image, Text, View, type ITouchEvent } from '@tarojs/components' +import dragHandleIcon from '../../assets/action-icons/drag-handle.svg' +import { Empty, FormActions, LoadState, returnToSource } from '../../components/common' +import { ProjectIcon } from '../../components/project-icon' +import { useRemote } from '../../hooks/useRemote' +import { api, ApiError, errorMessage, type Project } from '../../services/practice' + +interface DragState { + id: string + originIndex: number + targetIndex: number + startY: number + centers: number[] +} + +function moveProject(projects: Project[], from: number, to: number) { + const next = [...projects] + next.splice(to, 0, next.splice(from, 1)[0]) + return next +} + +export default function ProjectsSort() { + const state = useRemote(() => api.projects()) + const [projects, setProjects] = useState([]) + const [dragging, setDragging] = useState<{ id: string; offset: number; targetIndex: number }>() + const dragRef = useRef() + const busyRef = useRef(false) + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + + useEffect(() => { if (state.data) setProjects(state.data.projects) }, [state.data]) + + const dirty = !!state.data && projects.length === state.data.projects.length && + projects.some((project, index) => project.id !== state.data!.projects[index].id) + + function beginDrag(id: string, event: ITouchEvent) { + if (busyRef.current || dragRef.current) return + const startY = event.touches[0]?.clientY + const originIndex = projects.findIndex(project => project.id === id) + if (startY === undefined || originIndex < 0) return + dragRef.current = { id, originIndex, targetIndex: originIndex, startY, centers: [] } + setDragging({ id, offset: 0, targetIndex: originIndex }) + const query = Taro.createSelectorQuery() + query.selectAll('.sortable-project').boundingClientRect(rects => { + if (dragRef.current?.id !== id || !Array.isArray(rects)) return + dragRef.current.centers = rects.map(rect => rect.top + rect.height / 2) + }) + query.exec() + } + + function updateDrag(event: ITouchEvent) { + const current = dragRef.current + const y = event.touches[0]?.clientY ?? event.changedTouches[0]?.clientY + if (!current || y === undefined) return + const offset = y - current.startY + if (current.centers.length === projects.length) { + const center = current.centers[current.originIndex] + offset + let closest = current.originIndex + for (let index = 0; index < current.centers.length; index++) { + if (Math.abs(current.centers[index] - center) < Math.abs(current.centers[closest] - center)) closest = index + } + current.targetIndex = closest + } + setDragging({ id: current.id, offset, targetIndex: current.targetIndex }) + } + + function endDrag(event: ITouchEvent) { + const current = dragRef.current + if (!current) return + updateDrag(event) + dragRef.current = undefined + setDragging(undefined) + if (current.targetIndex !== current.originIndex) { + setProjects(previous => moveProject(previous, current.originIndex, current.targetIndex)) + setError('') + } + } + + async function save() { + if (busyRef.current || dragRef.current || !dirty) return + busyRef.current = true; setBusy(true); setError('') + try { + await api.reorderProjects(projects.map(project => project.id)) + await returnToSource('顺序已保存') + } catch (cause) { setError(cause instanceof ApiError && cause.status === 400 ? '项目列表已变化,请返回后重新打开排序页。' : errorMessage(cause)) } + finally { busyRef.current = false; setBusy(false) } + } + + return + + {state.data && <> + 按住右侧图标拖动项目,完成后保存。 + {error && {error}} + {projects.length ? + {projects.map((project, index) => + {project.name} + beginDrag(project.id, event as ITouchEvent)} onTouchMove={event => updateDrag(event as ITouchEvent)} onTouchEnd={event => endDrag(event as ITouchEvent)} onTouchCancel={() => { dragRef.current = undefined; setDragging(undefined) }}> + + + )} + : } + returnToSource()} onSave={save} /> + } + +} diff --git a/miniprogram/src/pages/projects/index.tsx b/miniprogram/src/pages/projects/index.tsx index 6731cb2..d518413 100644 --- a/miniprogram/src/pages/projects/index.tsx +++ b/miniprogram/src/pages/projects/index.tsx @@ -1,7 +1,9 @@ import { useRef, useState } from 'react' import Taro from '@tarojs/taro' import { Input, Text, View } from '@tarojs/components' -import { Button, Empty, LoadState, PageHeading } from '../../components/common' +import { Button, Empty, FormActions, LoadState } from '../../components/common' +import { ProjectIcon } from '../../components/project-icon' +import { ProjectIconPicker } from '../../components/project-icon-picker' import { useRemote } from '../../hooks/useRemote' import { api, errorMessage, NAME_LIMIT, type Project } from '../../services/practice' @@ -9,6 +11,7 @@ export default function Projects() { const state = useRemote(() => api.projects()) const [editing, setEditing] = useState() const [name, setName] = useState('') + const [iconId, setIconId] = useState('') const [busy, setBusy] = useState(false) const busyRef = useRef(false) const [error, setError] = useState('') @@ -20,9 +23,9 @@ export default function Projects() { if (!clean || Array.from(clean).length > NAME_LIMIT || clean.includes('\0')) { setError(`项目名称须为 1–${NAME_LIMIT} 字,不能只含空白或包含空字符`); return } busyRef.current = true; setBusy(true); setError(''); setNotice('') try { - await api.saveProject(clean, editing?.id) - setNotice(editing ? '已改名,历史记录也会使用这个名称。' : '项目已添加。') - setName(''); setEditing(undefined) + await api.saveProject(clean, editing?.id, iconId) + setNotice(editing ? '项目已更新。' : '项目已添加。') + setName(''); setIconId(''); setEditing(undefined) await state.reload() } catch (cause) { setError(errorMessage(cause)) } finally { busyRef.current = false; setBusy(false) } @@ -35,35 +38,33 @@ export default function Projects() { const confirmed = await Taro.showModal({ title: project.hasRecords ? '移出常用项目?' : '移除这个项目?', content: project.hasRecords ? `“${project.name}”将归档,历史记录、笔记和累计时长都会保留。` : `“${project.name}”没有记录时会被删除。如果已有新记录,将改为归档并保留历史。`, - confirmText: '确认移除', cancelText: '保留项目', + confirmText: '移除', cancelText: '取消', }) if (!confirmed.confirm) return const result = await api.removeProject(project.id) - if (editing?.id === project.id) { setEditing(undefined); setName('') } + if (editing?.id === project.id) { setEditing(undefined); setName(''); setIconId('') } setNotice(result.action === 'archived' ? '已归档,历史记录和时长均已保留。' : '未使用的项目已删除。') await state.reload() } catch (cause) { setError(errorMessage(cause)) } finally { busyRef.current = false; setBusy(false) } } - return - + return - {editing ? '修改项目名称' : '添加练习项目'} - setName(event.detail.value)} /> + 项目名称 + setName(event.detail.value)} /> 最多 {NAME_LIMIT} 字。改名后,历史记录会统一显示新名称。 - - {editing && } + {error && {error}} {notice && {notice}} 常用项目{state.data ? `${state.data.projects.length} 个` : ''} - {state.data && - {state.data.projects.length ? state.data.projects.map(project => - {project.name}{project.hasRecords ? '已有练习 · 移除会保留历史' : '尚无练习记录'} - ) : } - } - 有练习记录的项目会归档,不会删除历史。归档项目仍计入成长,也能从原记录进入更正。 + {state.data && (state.data.projects.length ? + {state.data.projects.map(project => + {project.name} + )} + : )} + { setEditing(undefined); setName(''); setIconId(''); setError(''); setNotice('') }} onSave={save} /> } diff --git a/miniprogram/src/pages/record/index.tsx b/miniprogram/src/pages/record/index.tsx index f9203dc..3bc92b2 100644 --- a/miniprogram/src/pages/record/index.tsx +++ b/miniprogram/src/pages/record/index.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from 'react' import Taro, { useDidHide, useDidShow, useRouter, useUnload } from '@tarojs/taro' import { Input, Picker, Text, Textarea, View } from '@tarojs/components' -import { Button, LoadState, PageHeading, returnToSource } from '../../components/common' +import { Button, FormActions, LoadState, returnToSource } from '../../components/common' import { useRemote } from '../../hooks/useRemote' import { api, ApiError, clearPendingRecord, errorMessage, getPendingRecord, MINUTE_LIMIT, NOTE_LIMIT, retainPendingRecord, submissionID, type PendingRecord } from '../../services/practice' import { validDate } from '../../utils/dates' @@ -93,7 +93,7 @@ export default function RecordForm() { if (!id || busyRef.current || uncertain) return busyRef.current = true; setBusy(true) try { - const result = await Taro.showModal({ title: '删除这条误记?', content: '这条记录的时长和笔记将被删除,日历与成长统计会一起更新。', confirmText: '删除记录', confirmColor: '#a23d4c', cancelText: '保留记录' }) + const result = await Taro.showModal({ title: '删除这条误记?', content: '这条记录的时长和笔记将被删除,日历与成长统计会一起更新。', confirmText: '删除', confirmColor: '#a23d4c', cancelText: '取消' }) if (!result.confirm) return setError('') await api.deleteRecord(id) @@ -103,7 +103,6 @@ export default function RecordForm() { } return - {!context && } {context && !done && <> {resumed.current && uncertain && 上次保存尚未确认,已恢复当时的项目、日期和填写内容。请先重试确认这次保存。} @@ -112,20 +111,20 @@ export default function RecordForm() { `${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 || '')}> {selected ? `${selected.name}${selected.archived ? '(已归档)' : ''}` : '请选择项目'}⌄ - {!choices.length && 先添加一个项目,就能开始记录。} + {!choices.length && } {selected?.archived && 这条历史记录可以保留原项目,也可改选常用项目。} 实际练习日期 setDate(event.detail.value)}>{date}⌄ 整堂时长 · 分钟 - setMinutes(event.detail.value)} /> + setMinutes(event.detail.value)} /> {[30, 45, 60, 90].map(value => )} 练习笔记 选填 -