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 @@
+
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 @@
+
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 @@
+
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 @@
+
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 @@
+
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 @@
+
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 @@
+
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 @@
+
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 @@
+
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 @@
+
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 @@
+
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 @@
+
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)} 日}
+
+
+
+ } ariaLabel='日历' onClick={() => { void Taro.navigateTo({ url: '/pages/calendar/index' }) }} />
+ } ariaLabel='管理列表' onClick={() => { void Taro.navigateTo({ url: '/pages/projects-sort/index' }) }} />
+ } ariaLabel='添加项目' onClick={() => { void Taro.navigateTo({ url: '/pages/projects/index' }) }} />
+
+ {data.projects.length ? {data.projects.map((project, index) => { void Taro.navigateTo({ url: `/pages/record/index?projectId=${project.id}&date=${data.today}` }) }}>
+ {project.name}{hours(projectMinutes.get(project.id) || 0)} h
+ )} : }
>}
}
diff --git a/miniprogram/src/pages/mine/index.config.ts b/miniprogram/src/pages/mine/index.config.ts
new file mode 100644
index 0000000..200c57e
--- /dev/null
+++ b/miniprogram/src/pages/mine/index.config.ts
@@ -0,0 +1 @@
+export default definePageConfig({ navigationBarTitleText: '芭蕾岛 · 我的' })
diff --git a/miniprogram/src/pages/mine/index.tsx b/miniprogram/src/pages/mine/index.tsx
new file mode 100644
index 0000000..4728121
--- /dev/null
+++ b/miniprogram/src/pages/mine/index.tsx
@@ -0,0 +1,62 @@
+import { useRef, useState } from 'react'
+import Taro from '@tarojs/taro'
+import { Button, Image, Input, Text, View } from '@tarojs/components'
+
+interface LocalProfile { nickname: string; avatarPath: string }
+
+const profileKey = 'ballet-profile:v1'
+
+function readProfile(): LocalProfile {
+ try {
+ const stored = Taro.getStorageSync>(profileKey)
+ return {
+ nickname: typeof stored?.nickname === 'string' ? stored.nickname : '',
+ avatarPath: typeof stored?.avatarPath === 'string' ? stored.avatarPath : '',
+ }
+ } catch { return { nickname: '', avatarPath: '' } }
+}
+
+export default function Mine() {
+ const [profile, setProfile] = useState(readProfile)
+ const profileRef = useRef(profile)
+ const [error, setError] = useState('')
+ const savingAvatar = useRef(false)
+
+ function updateProfile(next: LocalProfile): boolean {
+ profileRef.current = next
+ setProfile(next)
+ try { Taro.setStorageSync(profileKey, next); setError(''); return true }
+ catch { setError('资料暂时无法保存到本机。'); return false }
+ }
+
+ async function chooseAvatar(event: { detail: { avatarUrl?: string } }) {
+ const tempFilePath = event.detail.avatarUrl
+ if (!tempFilePath || savingAvatar.current) return
+ savingAvatar.current = true
+ setError('')
+ try {
+ const result = await Taro.saveFile({ tempFilePath })
+ if (!('savedFilePath' in result)) throw new Error('头像暂时无法保存,请重试。')
+ const previous = profileRef.current.avatarPath
+ const persisted = updateProfile({ ...profileRef.current, avatarPath: result.savedFilePath })
+ if (persisted && previous && previous !== result.savedFilePath) {
+ void Taro.removeSavedFile({ filePath: previous }).catch(() => {})
+ }
+ } catch { setError('头像暂时无法保存,请重试。') }
+ finally { savingAvatar.current = false }
+ }
+
+ return
+
+
+
+ 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 => )}
练习笔记 选填
-
{error && {error}}
-
- {id && }
+ {id && }
+ returnToSource()} onSave={save} />
>}
{done && {done}}
diff --git a/miniprogram/src/services/practice.ts b/miniprogram/src/services/practice.ts
index 48bd24b..f4a0087 100644
--- a/miniprogram/src/services/practice.ts
+++ b/miniprogram/src/services/practice.ts
@@ -4,7 +4,7 @@ export const NAME_LIMIT = 40
export const NOTE_LIMIT = 2000
export const MINUTE_LIMIT = 2147483647
-export interface Project { id: string; name: string; archived: boolean; hasRecords: boolean }
+export interface Project { id: string; name: string; iconId?: string; archived: boolean; hasRecords: boolean }
export interface PracticeRecord {
id: string; projectId: string; projectName: string; archived: boolean
date: string; minutes: number; note: string
@@ -12,7 +12,7 @@ export interface PracticeRecord {
export interface RecordInput { projectId: string; date: string; minutes: number; note: string; requestId?: string }
export interface RecordList { records: PracticeRecord[]; nextCursor: string }
export interface ProjectMinutes { projectId: string; name: string; archived: boolean; minutes: number }
-export interface Summary { totalMinutes: number; practiceDays: number; recordCount: number; projects: ProjectMinutes[] }
+export interface Summary { totalMinutes: number; practiceDays: number; firstDate: string; recordCount: number; projects: ProjectMinutes[] }
export interface Review {
today: string; from: string; to: string; allTime: Summary; period: Summary
days: { date: string; minutes: number }[]
@@ -104,7 +104,8 @@ function query(values: Record) {
export const api = {
projects: (includeArchived = false) => request<{ projects: Project[]; today: string }>(`/projects?includeArchived=${includeArchived}`),
- saveProject: (name: string, id?: string) => request(id ? `/projects/${id}` : '/projects', id ? 'PUT' : 'POST', { name }),
+ reorderProjects: (projectIds: string[]) => request<{ action: 'reordered' }>('/projects/order', 'PUT', { projectIds }),
+ saveProject: (name: string, id?: string, iconId?: string) => request(id ? `/projects/${id}` : '/projects', id ? 'PUT' : 'POST', { name, ...(iconId !== undefined ? { iconId } : {}) }),
removeProject: (id: string) => request<{ action: 'deleted' | 'archived' }>(`/projects/${id}`, 'DELETE'),
records: (options: { from?: string; to?: string; cursor?: string; limit?: number } = {}) => request(`/records?${query(options)}`),
record: (id: string) => request(`/records/${id}`),
diff --git a/miniprogram/tests/practice.test.cjs b/miniprogram/tests/practice.test.cjs
index 4cfb399..fd83359 100644
--- a/miniprogram/tests/practice.test.cjs
+++ b/miniprogram/tests/practice.test.cjs
@@ -82,3 +82,22 @@ test('failed loads stay errors and only confirmed writes notify visible pages',
assert.equal(changes, 1)
stop()
})
+
+test('project saves distinguish keeping, replacing and clearing an icon', async () => {
+ const payloads = []
+ const service = client(async request => {
+ if (request.url.endsWith('/session')) return session('token')
+ payloads.push({ method: request.method, data: JSON.parse(JSON.stringify(request.data)) })
+ return ok({ id: 'project-1', ...request.data })
+ })
+ await service.api.saveProject('小球核心', undefined, 'ball-core')
+ await service.api.saveProject('晚间练习', 'project-1')
+ await service.api.saveProject('晚间练习', 'project-1', 'breathing')
+ await service.api.saveProject('晚间练习', 'project-1', '')
+ assert.deepEqual(payloads, [
+ { method: 'POST', data: { name: '小球核心', iconId: 'ball-core' } },
+ { method: 'PUT', data: { name: '晚间练习' } },
+ { method: 'PUT', data: { name: '晚间练习', iconId: 'breathing' } },
+ { method: 'PUT', data: { name: '晚间练习', iconId: '' } },
+ ])
+})
diff --git a/miniprogram/types/global.d.ts b/miniprogram/types/global.d.ts
index b3874fe..265af6d 100644
--- a/miniprogram/types/global.d.ts
+++ b/miniprogram/types/global.d.ts
@@ -2,3 +2,12 @@ declare const __API_BASE_URL__: string
declare module '*.scss'
+declare module '*.png' {
+ const src: string
+ export default src
+}
+
+declare module '*.svg' {
+ const src: string
+ export default src
+}