feat: add project icon picker component and integrate with project management
- Implemented ProjectIconPicker component for selecting project icons. - Created ProjectIcon component to display selected icons. - Updated calendar page to remove unused PageHeading component. - Modified growth page to enhance UI and improve text clarity. - Refactored index page to include project icons and improve layout. - Added mine page for user profile management with avatar selection. - Introduced projects-sort page for managing project order with drag-and-drop functionality. - Enhanced projects page to allow icon selection when adding or editing projects. - Updated record page to streamline user experience and improve form actions. - Adjusted practice service to support project icon management. - Added tests for project saving functionality to ensure correct handling of icons. - Updated global type definitions to support SVG and PNG imports.
@@ -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` 通过。
|
||||
- 已目视检查首页、项目管理与选择面板的模拟器截图。真机、其他屏幕宽度和原生键盘交互未在本轮验证。
|
||||
@@ -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、归档后重排。微信开发者工具中的实际拖动与图标显示未在本轮完成交互核验。
|
||||
@@ -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):底部表单操作栏、加载与空状态、记录卡片及按钮复用入口。
|
||||
|
||||
新增页面优先遵循本文件的使用规则并复用现有组件。发现文档与实现不一致时,明确差异及其原因,不直接将偶然实现提升为通用规则;有意调整视觉语言时,同步更新相关规则和对应样式。
|
||||
|
||||
@@ -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}` | 读取本人单条记录 | 项目当前名称、日期、分钟、笔记 |
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128" fill="none" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Arabesque · 阿拉贝斯克</title>
|
||||
<desc id="desc">修长的灰玫瑰线描舞者,侧身单腿站立,后腿伸直,手臂与脚尖以自然收细的曲线舒展。</desc>
|
||||
<g stroke="#9c5268" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M51 39C64 39 78 34 91 29L97 26C99 26 98 28 96 29C84 36 68 44 53 46Z" fill="#fff4ed"/>
|
||||
<path d="M43 31C44 35 43 38 40 39C31 39 23 36 15 33L9 30C7 29 7 31 9 32C17 39 28 43 39 44C43 44 46 45 48 48C51 52 51 58 54 63C55 66 55 68 54 72C52 81 55 91 56 98C57 104 57 110 56 115L49 118C47 119 47 120 50 120H59C61 120 61 119 60 117C60 111 60 104 61 98C62 88 65 80 62 73C72 72 83 66 94 62C103 59 112 54 119 51C121 50 121 49 119 49L108 53C97 56 87 59 78 61C71 63 66 66 62 63C58 59 57 53 55 48C53 43 48 39 48 35L48 30Z" fill="#fff4ed"/>
|
||||
<path d="M42 39C45 43 49 43 51 40C53 43 54 47 55 50C56 56 58 60 62 64L65 67C64 70 63 73 60 75C57 71 54 70 53 66C52 61 52 56 50 52C48 48 46 46 42 45Z" fill="#ead1d7"/>
|
||||
<path d="M52 62C55 62 58 63 60 65" stroke="#bd8292" stroke-width="1.2"/>
|
||||
<path d="M47 18C43 16 39 18 38 22L38 25L36 27C35 28 37 28 38 28C38 32 40 34 43 34C47 33 50 29 50 25C50 22 49 19 47 18Z" fill="#fff4ed"/>
|
||||
<path d="M38 23C42 23 45 21 45 18C49 18 51 21 50 25" fill="#ead1d7"/>
|
||||
<path d="M49 19C52 20 55 18 54 15C53 12 49 13 48 16" fill="#ead1d7"/>
|
||||
<path d="M56 114L60 115M106 54L108 57" stroke-width="1.2"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128" fill="none" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Attitude · 阿蒂蒂德</title>
|
||||
<desc id="desc">侧身舞者单腿支撑,工作腿向后抬起并屈膝,脚尖绷直;一臂举高,另一臂向前舒展。</desc>
|
||||
<g stroke="#9c5268" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M48 37C57 29 62 24 60 17C59 14 56 11 53 10C51 9 52 7 54 8C62 11 68 18 67 25C66 33 61 39 53 44Z" fill="#fff4ed"/>
|
||||
<path d="M62 66C71 58 78 54 86 51C90 50 93 51 96 55L106 67L114 71C117 72 116 74 114 73L103 72C97 69 91 65 87 60C81 65 74 74 64 77Z" fill="#fff4ed"/>
|
||||
<path d="M43 31C44 35 43 38 40 39C31 41 22 42 13 41C10 40 9 42 12 43C22 47 32 48 41 45C45 44 47 47 48 50C50 54 51 59 54 63C55 66 55 68 54 72C52 81 55 91 56 98C57 104 57 110 56 115L49 118C47 119 47 120 50 120H59C61 120 61 119 60 117C60 111 60 104 61 98C62 88 65 80 62 73L63 66C58 59 57 53 55 48C53 43 48 39 48 35L48 30Z" fill="#fff4ed"/>
|
||||
<path d="M42 39C45 43 49 43 51 40C53 43 54 47 55 50C56 56 58 60 62 64L65 67C64 70 63 73 60 75C57 71 54 70 53 66C52 61 52 56 50 52C48 48 46 46 42 45Z" fill="#ead1d7"/>
|
||||
<path d="M52 62C55 62 58 63 60 65M56 114L60 115M105 66L102 70" stroke="#bd8292" stroke-width="1.2"/>
|
||||
<path d="M47 18C43 16 39 18 38 22L38 25L36 27C35 28 37 28 38 28C38 32 40 34 43 34C47 33 50 29 50 25C50 22 49 19 47 18Z" fill="#fff4ed"/>
|
||||
<path d="M38 23C42 23 45 21 45 18C49 18 51 21 50 25" fill="#ead1d7"/>
|
||||
<path d="M49 19C52 20 55 18 54 15C53 12 49 13 48 16" fill="#ead1d7"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128" fill="none" role="img" aria-labelledby="title desc">
|
||||
<title id="title">小球核心</title>
|
||||
<desc id="desc">舞者坐姿收腹,屈膝抬腿,双手在身前环抱一颗小球。</desc>
|
||||
<g stroke="#9c5268" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M16 111H113" stroke="#d8b6bf" stroke-width="1.2"/>
|
||||
<path d="M57 83C64 77 72 66 79 64C84 63 87 65 90 69L105 82L112 83C115 83 115 85 112 86L103 86C95 82 89 78 82 73L65 91Z" fill="#f3e4e5"/>
|
||||
<path d="M37 48C39 50 40 53 40 57C37 66 39 78 44 87C47 94 51 99 59 98C67 97 76 88 82 80L85 78C89 85 96 91 104 96C106 97 110 96 114 96C117 96 118 98 114 99L103 101C94 97 84 91 78 84C73 89 65 96 60 97L50 91C46 79 49 71 48 64C48 57 46 53 43 48Z" fill="#fff4ed"/>
|
||||
<path d="M40 55C43 58 46 58 47 56C50 65 47 70 48 77C48 82 50 87 54 90L65 92C63 95 61 97 59 98C51 99 47 94 44 87C39 78 37 66 40 57Z" fill="#ead1d7"/>
|
||||
<path d="M44 82C47 82 50 84 52 87M103 94L105 99" stroke="#bd8292" stroke-width="1.2"/>
|
||||
<circle cx="69" cy="55" r="10" fill="#ead1d7"/>
|
||||
<path d="M68 49C71 49 74 51 74 54" stroke="#fff4ed" stroke-width="1.65"/>
|
||||
<path d="M44 58C49 61 51 67 55 69C58 69 61 65 63 62L64 58C64 56 66 56 67 58L67 62C65 67 62 72 57 74C50 74 46 69 43 64" fill="#fff4ed"/>
|
||||
<path d="M48 57L58 60L60 55C61 53 63 54 62 56L62 61L58 64" fill="#fff4ed"/>
|
||||
<path d="M32 35C28 34 28 30 31 29C35 28 37 31 36 34" fill="#ead1d7"/>
|
||||
<path d="M34 33C38 32 42 35 44 39C46 43 44 48 41 50C37 52 33 49 31 44C29 39 30 35 34 33Z" fill="#fff4ed"/>
|
||||
<path d="M31 41C34 39 36 36 35 33C39 34 42 36 44 40" fill="#ead1d7"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128" fill="none" role="img" aria-labelledby="title desc">
|
||||
<title id="title">把杆 · Barre</title>
|
||||
<desc id="desc">灰玫瑰线描双层练习把杆,柔和木色横杆与稳定的立柱底座。</desc>
|
||||
<g stroke="#9c5268" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M25 31H31V107H25ZM97 31H103V107H97Z" fill="#fff4ed"/>
|
||||
<rect x="12" y="30" width="104" height="8" rx="4" fill="#ead1d7"/>
|
||||
<rect x="12" y="58" width="104" height="8" rx="4" fill="#f3e4e5"/>
|
||||
<path d="M17 110L24 104H32L39 110V113H17ZM89 110L96 104H104L111 110V113H89Z" fill="#ead1d7"/>
|
||||
<path d="M19 34H28M19 62H28M44 113H84" stroke="#bd8292" stroke-width="1.4"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 782 B |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128" fill="none" role="img" aria-labelledby="title desc">
|
||||
<title id="title">呼吸训练</title>
|
||||
<desc id="desc">舞者自然盘坐,双手轻放身体两侧,下肋旁的柔和弧线表现呼吸节奏。</desc>
|
||||
<g stroke="#9c5268" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 113H107" stroke="#d8b6bf" stroke-width="1.2"/>
|
||||
<path d="M53 81C44 82 35 88 29 94C25 98 27 103 32 105C44 109 62 106 75 104L87 103C90 102 91 99 88 97L77 94L72 83Z" fill="#fff4ed"/>
|
||||
<path d="M74 81C84 83 94 90 99 96C102 100 99 104 94 105C83 107 67 104 54 101L41 102C37 102 36 100 39 98L48 93L53 82Z" fill="#fff4ed"/>
|
||||
<path d="M61 37C61 41 59 43 55 45C48 46 45 52 43 59L39 74C38 78 40 81 44 80L57 75C60 74 59 72 57 72L46 75L51 61C54 68 53 75 51 83C55 86 59 88 64 91C69 88 73 86 77 83C75 75 74 68 77 61L82 75L71 72C69 72 68 74 71 75L84 80C88 81 90 78 89 74L85 59C83 52 80 46 73 45C69 43 67 41 67 37Z" fill="#fff4ed"/>
|
||||
<path d="M55 46C60 51 68 51 73 46L78 54L77 61C74 68 75 75 77 83C73 86 69 88 64 91C59 88 55 86 51 83C53 75 54 68 51 61L50 54Z" fill="#ead1d7"/>
|
||||
<path d="M46 75L57 72C59 72 60 74 57 75L50 78M82 75L71 72C69 72 68 74 71 75L78 78" fill="#fff4ed"/>
|
||||
<path d="M56 80C61 82 67 82 72 80M48 94L57 97" stroke="#bd8292" stroke-width="1.2"/>
|
||||
<path d="M60 24C57 22 58 18 62 18C67 17 70 20 67 23" fill="#ead1d7"/>
|
||||
<path d="M57.5 30C57.5 25.5 60 23 64 23C68 23 70.5 25.5 70.5 30C70.5 34.5 68 39 64 39C60 39 57.5 34.5 57.5 30Z" fill="#fff4ed"/>
|
||||
<path d="M57.5 30C60 30 63 27 64 24C66 27 68 29 70.5 30" fill="#ead1d7"/>
|
||||
<path d="M32 60C28 65 28 72 32 77M25 56C19 64 19 73 25 81M96 60C100 65 100 72 96 77M103 56C109 64 109 73 103 81" stroke="#bd8292" stroke-width="1.2"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128" fill="none" role="img" aria-labelledby="title desc">
|
||||
<title id="title">核心臀腿</title>
|
||||
<desc id="desc">侧面臀桥轮廓:肩背贴近垫面,髋部抬起,双膝屈曲,双脚平放,手臂沿身体两侧伸展。</desc>
|
||||
<g stroke="#9c5268" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 103H116" stroke="#d8b6bf" stroke-width="1.2"/>
|
||||
<path d="M66 62L82 57C87 55 90 58 92 63L102 88L111 93C114 95 113 97 110 96L99 95C97 91 95 85 93 81L83 68L71 75Z" fill="#f3e4e5"/>
|
||||
<path d="M27 90C31 87 34 85 37 82C45 74 53 66 62 61C67 58 71 62 74 65L85 75C89 79 91 85 94 92L106 94C110 94 112 97 108 98H93C90 98 89 96 88 93L78 80L67 74C60 81 52 85 44 88C39 91 35 95 30 95L25 96Z" fill="#fff4ed"/>
|
||||
<path d="M37 82C45 74 53 66 62 61C67 58 71 62 74 65C70 68 67 72 67 75C60 81 52 85 44 88L37 92L32 87Z" fill="#ead1d7"/>
|
||||
<path d="M58 64C61 66 64 70 66 74M91 92L96 92" stroke="#bd8292" stroke-width="1.2"/>
|
||||
<path d="M36 86C42 87 47 91 54 93L70 94C73 95 73 97 70 97L54 97C46 96 40 94 33 93" fill="#fff4ed"/>
|
||||
<path d="M16 88C13 87 11 90 13 93C15 95 18 94 19 92" fill="#ead1d7"/>
|
||||
<path d="M17 84C21 82 26 83 28 87L31 89C32 90 30 91 29 91C28 96 25 99 21 99C17 99 14 95 14 91C14 88 15 86 17 84Z" fill="#fff4ed"/>
|
||||
<path d="M15 87C18 89 20 94 19 98C15 97 13 92 15 87Z" fill="#ead1d7"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128" fill="none" role="img" aria-labelledby="title desc">
|
||||
<title id="title">软开素质</title>
|
||||
<desc id="desc">坐姿舞者一腿舒展、一腿收拢,手臂弧线越过头顶,身体向伸展腿一侧延长。</desc>
|
||||
<g stroke="#9c5268" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M15 114H115" stroke="#d8b6bf" stroke-width="1.2"/>
|
||||
<path d="M70 55C66 48 62 38 67 31C73 24 84 27 94 31L103 37C105 38 104 40 102 39L91 34C84 32 76 30 73 34C71 38 74 45 77 51Z" fill="#fff4ed"/>
|
||||
<path d="M77 51C76 54 78 56 81 58C86 63 89 70 93 79L103 94L111 98C114 100 113 102 110 101L101 98C96 92 91 87 87 81L77 68C74 75 71 80 68 85C80 88 90 96 101 102L113 104C116 104 117 106 113 107L99 108C85 104 73 98 59 96C50 100 43 103 35 103C41 105 48 105 54 106C57 107 56 109 53 109L31 108C27 108 24 106 26 103C33 96 42 88 52 85C56 79 61 71 65 63C68 57 72 55 73 50Z" fill="#fff4ed"/>
|
||||
<path d="M69 58C73 61 78 62 81 58L85 64L77 68C74 75 71 80 68 85C64 87 61 92 59 96C55 94 51 91 48 90L52 85C56 79 61 71 65 63Z" fill="#ead1d7"/>
|
||||
<path d="M56 79C59 82 63 83 68 83M102 102L101 107" stroke="#bd8292" stroke-width="1.2"/>
|
||||
<path d="M72 37C68 36 68 32 71 31C75 30 77 33 76 36" fill="#ead1d7"/>
|
||||
<path d="M73 36C77 34 81 36 84 40C86 44 85 50 81 52C77 54 73 50 71 46C69 42 70 38 73 36Z" fill="#fff4ed"/>
|
||||
<path d="M71 44C74 42 76 39 75 36C79 36 82 38 84 41" fill="#ead1d7"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128" fill="none" role="img" aria-labelledby="title desc">
|
||||
<title id="title">足髋训练</title>
|
||||
<desc id="desc">坐姿舞者一腿屈曲外展、一腿向前伸展并勾起脚尖,以足部与髋部的轮廓表达基础训练。</desc>
|
||||
<g stroke="#9c5268" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M16 113H114" stroke="#d8b6bf" stroke-width="1.2"/>
|
||||
<path d="M48 74C58 74 73 77 80 85C85 91 85 97 79 101L62 108C58 109 56 108 59 106L73 98C76 96 76 93 73 91L51 86Z" fill="#fff4ed"/>
|
||||
<path d="M41 37C43 41 41 44 38 46C35 55 32 67 30 78L26 94L19 97C17 98 18 100 21 99L30 98C31 98 32 97 32 95L36 79L42 60C44 66 44 70 42 77C40 83 43 88 49 89C62 91 76 99 91 103C97 105 102 106 108 105C111 104 112 101 111 97L112 87C112 85 110 85 109 88L106 96C105 99 103 100 100 99L78 88C68 83 60 80 53 79C56 69 52 60 53 51C52 47 48 44 47 41L47 36Z" fill="#fff4ed"/>
|
||||
<path d="M39 47C42 50 47 50 50 46L53 51C52 61 56 70 53 79C58 80 61 81 64 82C62 86 59 89 56 91L49 89C43 88 40 83 42 77C44 70 44 66 42 60L37 56Z" fill="#ead1d7"/>
|
||||
<path d="M51 48C56 53 59 62 62 70L70 82L76 85C78 86 77 88 75 87L68 85C65 81 61 77 58 73C54 67 50 61 48 55" fill="#fff4ed"/>
|
||||
<path d="M43 75C46 77 50 77 53 77M103 98L108 101" stroke="#bd8292" stroke-width="1.2"/>
|
||||
<path d="M43 24C39 22 40 18 44 18C48 18 50 21 47 24" fill="#ead1d7"/>
|
||||
<path d="M39 25C42 22 47 23 49 27C51 32 48 38 44 39C40 39 37 34 37 30C37 28 38 26 39 25Z" fill="#fff4ed"/>
|
||||
<path d="M37 30C41 30 44 27 45 24C48 26 49 28 49 30" fill="#ead1d7"/>
|
||||
<path d="M115 95C118 91 118 87 116 83" stroke="#bd8292" stroke-width="1.2"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128" fill="none" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Grand jeté · 大跳</title>
|
||||
<desc id="desc">腾空舞者,前后双腿伸展成大幅分腿线,双脚绷直,双臂舒展。</desc>
|
||||
<g transform="translate(0 10)" stroke="#9c5268" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M68 39C80 35 90 28 100 19L105 15C107 14 108 15 106 17C99 28 85 39 71 45Z" fill="#fff4ed"/>
|
||||
<path d="M67 67C80 67 92 72 104 76L119 77C122 78 121 80 118 81L105 81C92 82 80 78 66 76Z" fill="#fff4ed"/>
|
||||
<path d="M59 67C50 68 43 74 31 77L11 82C7 82 6 85 9 85L29 84C42 83 53 79 64 76Z" fill="#fff4ed"/>
|
||||
<path d="M62 31C62 35 60 37 57 38C49 35 42 29 35 24L24 17C21 15 20 16 22 18C28 26 41 39 55 44C59 47 58 54 60 59L67 72L73 68C68 59 67 53 66 48C65 43 66 38 67 33Z" fill="#fff4ed"/>
|
||||
<path d="M57 38C60 41 64 41 66 38C68 44 66 48 68 53C69 59 71 64 74 68C71 71 67 73 63 75C61 72 60 68 60 64C61 58 60 53 58 49L55 44Z" fill="#ead1d7"/>
|
||||
<path d="M61 62C65 62 68 64 71 66M23 79L24 84M108 76V81" stroke="#bd8292" stroke-width="1.2"/>
|
||||
<path d="M66 16C62 14 58 16 57 20L57 23L55 25C54 26 56 26 57 26C57 30 59 32 62 32C66 31 69 27 69 23C69 20 68 17 66 16Z" fill="#fff4ed"/>
|
||||
<path d="M57 21C61 21 64 19 64 16C68 16 70 19 69 23" fill="#ead1d7"/>
|
||||
<path d="M68 17C71 18 74 16 73 13C72 10 68 11 67 14" fill="#ead1d7"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128" fill="none" role="img" aria-labelledby="title desc">
|
||||
<title id="title">钢琴 · Piano</title>
|
||||
<desc id="desc">灰玫瑰线描三角钢琴,弧形琴身、浅色琴盖与简洁黑白琴键。</desc>
|
||||
<g stroke="#9c5268" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M26 86V111H31L34 86M86 85L91 109H96V80" fill="#ead1d7"/>
|
||||
<path d="M25 37H63C85 37 108 48 108 63C108 77 95 83 85 83H25Z" fill="#f3e4e5"/>
|
||||
<path d="M25 37L63 17C81 17 105 29 112 44L108 63L25 56Z" fill="#ead1d7"/>
|
||||
<path d="M93 61L96 43M25 37V56"/>
|
||||
<path d="M22 75H84V90H22Z" fill="#fff4ed"/>
|
||||
<path d="M22 70H84V76H22Z" fill="#ead1d7"/>
|
||||
<path d="M31 76V90M40 76V90M49 76V90M58 76V90M67 76V90M76 76V90" stroke-width="1.2"/>
|
||||
<path d="M31 76V83M40 76V83M58 76V83M67 76V83M76 76V83" stroke-width="3.5"/>
|
||||
<path d="M54 90V98H64M60 94V99"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 969 B |
@@ -0,0 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128" fill="none" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Pirouette · 旋转</title>
|
||||
<desc id="desc">旋转中的代表姿态:单脚半脚尖支撑,另一脚收至支撑腿膝旁,双臂围圆,短弧线提示旋转。</desc>
|
||||
<g stroke="#9c5268" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M37 101C21 108 39 118 55 117M88 102C104 108 93 115 80 116L84 112M80 116L85 118" stroke="#bd8292" stroke-width="1.35"/>
|
||||
<path d="M63 69C61 79 63 90 64 99L65 111L69 116L72 120H80C82 120 81 119 79 117L72 112L71 99C75 87 76 79 73 70Z" fill="#fff4ed"/>
|
||||
<path d="M54 69C49 75 44 80 40 84C37 87 38 90 42 93L58 98C61 99 64 99 65 96C64 94 61 94 59 94L47 87L60 75Z" fill="#fff4ed"/>
|
||||
<path d="M61 30C61 34 60 36 56 37L53 43C55 49 58 53 58 59C58 64 55 67 54 71C58 71 62 74 64 77C66 74 70 71 74 71C73 67 70 64 70 59C70 53 73 49 75 43L72 37C68 36 67 34 67 30Z" fill="#fff4ed"/>
|
||||
<path d="M56 38C60 45 68 45 72 38L74 43C72 49 69 54 69 59C69 64 72 68 74 70C69 71 66 73 64 76C62 73 59 71 54 70C56 68 59 64 59 59C59 54 56 49 54 43Z" fill="#ead1d7"/>
|
||||
<path d="M54 40C44 42 39 46 40 52C41 58 51 61 61 59C63 59 63 57 61 56C53 56 47 54 47 51C47 49 50 47 55 46M74 40C84 42 89 46 88 52C87 58 77 61 67 59C65 59 65 57 67 56C75 56 81 54 81 51C81 49 78 47 73 46" fill="#fff4ed"/>
|
||||
<path d="M61 17C58 14 60 11 64 11C68 11 70 14 67 17" fill="#ead1d7"/>
|
||||
<path d="M57.5 23C57.5 18.5 60 16 64 16C68 16 70.5 18.5 70.5 23C70.5 27.5 68 32 64 32C60 32 57.5 27.5 57.5 23Z" fill="#fff4ed"/>
|
||||
<path d="M57.5 23C60 23 63 20 64 17C66 20 68 22 70.5 23" fill="#ead1d7"/>
|
||||
<path d="M65 109L71 109M58 94V97" stroke-width="1.2"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128" fill="none" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Plié · 一位半蹲</title>
|
||||
<desc id="desc">灰玫瑰线描舞者,以一位脚位半蹲,脚跟相接并着地,双膝向外打开,小腿收向脚跟,躯干保持直立。</desc>
|
||||
<g stroke="#9c5268" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M61 30C61 34 60 36 56 37C47 36 39 39 31 44C24 48 18 52 12 54C10 55 11 56 13 56C22 53 29 49 37 46C43 43 49 43 54 44C55 49 58 53 58 59C58 63 56 66 53 69C49 74 43 78 40 84C37 88 36 90 39 94C42 97 46 103 51 109C54 112 57 115 60 117L49 120C47 120 47 121 50 121H62C64 121 64 120 63 118C62 114 58 111 56 107C53 102 51 96 46 92C45 91 45 90 46 89C50 85 55 80 60 75C62 74 63 75 64 77C65 75 66 74 68 75C73 80 78 85 82 89C83 90 83 91 82 92C77 96 75 102 72 107C70 111 66 114 65 118C64 120 64 121 66 121H78C81 121 81 120 79 120L68 117C71 115 74 112 77 109C82 103 86 97 89 94C92 90 91 88 88 84C85 78 79 74 75 69C72 66 70 63 70 59C70 53 73 49 74 44C79 43 85 43 91 46C99 49 106 53 115 56C117 56 118 55 116 54C110 52 104 48 97 44C89 39 81 36 72 37C68 36 67 34 67 30Z" fill="#fff4ed"/>
|
||||
<path d="M56 38C58 41 60 43 64 43C68 43 70 41 72 38L74 43C72 49 69 54 69 59C69 64 73 68 75 70C70 70 66 73 64 76C62 73 58 70 53 70C55 68 59 64 59 59C59 54 56 49 54 43Z" fill="#ead1d7"/>
|
||||
<path d="M59 60C62 62 66 62 69 60" stroke="#bd8292" stroke-width="1.2"/>
|
||||
<path d="M61 17C58 14 60 11 64 11C68 11 70 14 67 17" fill="#ead1d7"/>
|
||||
<path d="M57.5 23C57.5 18.5 60 16 64 16C68 16 70.5 18.5 70.5 23C70.5 27.5 68 32 64 32C60 32 57.5 27.5 57.5 23Z" fill="#fff4ed"/>
|
||||
<path d="M57.5 23C60 23 63 20 64 17C66 20 68 22 70.5 23" fill="#ead1d7"/>
|
||||
<path d="M58 114L61 113M67 113L70 114" stroke-width="1.2"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128" fill="none" role="img" aria-labelledby="title desc">
|
||||
<title id="title">足尖鞋 · Pointe shoes</title>
|
||||
<desc id="desc">灰玫瑰线描的成对足尖鞋,浅玫瑰鞋身与自然垂落的缎带。</desc>
|
||||
<g stroke="#9c5268" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M55 58C46 43 46 34 58 26C68 19 71 15 66 10C62 6 55 9 54 13C52 20 60 28 70 36C81 45 84 52 84 62"/>
|
||||
<path d="M48 58C42 44 43 34 54 26M77 39C84 35 85 27 80 24C75 20 68 23 67 29M74 44C71 48 68 53 68 60"/>
|
||||
<g transform="rotate(14 81 85)">
|
||||
<path d="M79 52C70 52 66 63 65 75L64 101C64 108 67 113 72 114H85C90 113 92 109 92 102L93 76C94 62 91 52 84 52Z" fill="#ead1d7"/>
|
||||
<path d="M80 57C74 57 71 67 70 78C70 84 72 88 78 89C84 89 87 85 88 78C89 68 86 57 83 57Z" fill="#fff4ed"/>
|
||||
<path d="M66 102C72 100 85 100 91 103M71 65L87 76M87 65L70 77"/>
|
||||
</g>
|
||||
<g transform="rotate(18 44 84)">
|
||||
<path d="M44 49C34 49 29 61 28 75L29 102C29 110 32 115 37 116H50C55 115 58 110 58 103L60 73C60 59 55 49 49 49Z" fill="#f3e4e5"/>
|
||||
<path d="M45 55C38 55 34 65 34 78C34 85 37 90 43 90C50 90 53 85 54 78C55 65 51 55 48 55Z" fill="#fff4ed"/>
|
||||
<path d="M30 104C38 101 49 102 57 105M36 64L54 77M53 64L34 77"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128" fill="none" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Port de bras · 手臂练习</title>
|
||||
<desc id="desc">以一个手位瞬间表达手臂练习:一臂柔和地举过头顶,另一臂向侧方舒展。</desc>
|
||||
<g stroke="#9c5268" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M70 37C77 31 80 26 77 20C75 16 71 13 67 12C65 11 64 9 67 9C77 10 85 17 86 25C87 33 82 40 74 44Z" fill="#fff4ed"/>
|
||||
<path d="M61 30C61 34 60 36 56 37C47 36 39 39 31 44C24 48 18 52 12 54C10 55 11 56 13 56C22 53 29 49 37 46C43 43 49 43 54 44C55 49 58 53 58 59C58 64 55 67 54 71C53 81 57 92 58 99L59 116L51 119C49 120 50 121 52 121H63L64 98L65 121H76C78 121 79 120 77 119L69 116L70 99C71 92 75 81 74 71C73 67 70 64 70 59C70 53 73 49 74 43L72 37C68 36 67 34 67 30Z" fill="#fff4ed"/>
|
||||
<path d="M56 38C60 45 68 45 72 38L74 43C72 49 69 54 69 59C69 64 72 68 74 70C69 71 66 73 64 76C62 73 59 71 54 70C56 68 59 64 59 59C59 54 56 49 54 43Z" fill="#ead1d7"/>
|
||||
<path d="M59 60C62 62 66 62 69 60M59 114H63M65 114H69" stroke="#bd8292" stroke-width="1.2"/>
|
||||
<path d="M61 17C58 14 60 11 64 11C68 11 70 14 67 17" fill="#ead1d7"/>
|
||||
<path d="M57.5 23C57.5 18.5 60 16 64 16C68 16 70.5 18.5 70.5 23C70.5 27.5 68 32 64 32C60 32 57.5 27.5 57.5 23Z" fill="#fff4ed"/>
|
||||
<path d="M57.5 23C60 23 63 20 64 17C66 20 68 22 70.5 23" fill="#ead1d7"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,209 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>芭蕾岛 · 项目图标库</title>
|
||||
<link rel="icon" type="image/svg+xml" href="pointe-shoes.svg">
|
||||
<style>
|
||||
:root { color-scheme: light; --ink: #3e3435; --muted: #827572; --accent: #9c5268; --surface: #fffdfa; --line: #e9dfd9; --sample: #f4ece8; }
|
||||
* { box-sizing: border-box; }
|
||||
[hidden] { display: none !important; }
|
||||
body { margin: 0; background: #faf7f3; color: var(--ink); font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif; line-height: 1.55; }
|
||||
button { font: inherit; cursor: pointer; }
|
||||
button:focus-visible, a:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
|
||||
main { width: min(1080px, calc(100% - 64px)); margin: 0 auto; padding: 32px 0 24px; }
|
||||
.topline, .header, .preview-strip, .controls, .control, .project-row, .sample-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.topline { padding-bottom: 17px; border-bottom: 1px solid var(--line); font-size: 12px; color: var(--muted); }
|
||||
.brand { font-size: 14px; font-weight: 600; letter-spacing: 2px; color: var(--ink); }
|
||||
.header { margin: 22px 0; align-items: flex-end; }
|
||||
h1 { margin: 0 0 5px; font-size: 29px; font-weight: 600; letter-spacing: -.7px; }
|
||||
p { margin: 0; }
|
||||
.muted { font-size: 13px; color: var(--muted); }
|
||||
.palette { display: flex; gap: 6px; padding-bottom: 6px; }
|
||||
.palette span { width: 20px; height: 20px; border-radius: 50%; border: 1px solid #00000006; }
|
||||
.preview-strip { padding: 17px; border: 1px solid var(--line); border-radius: 17px; background: var(--surface); }
|
||||
.project-preview { width: 390px; max-width: 100%; }
|
||||
.preview-label { display: flex; justify-content: space-between; margin-bottom: 8px; font-size: 11px; color: var(--muted); }
|
||||
.project-row { justify-content: flex-start; gap: 12px; min-height: 52px; padding: 5px 16px; border: 1px solid #e7ded8; border-radius: 14px; background: var(--sample); }
|
||||
.project-row img { width: var(--list-icon-size, 32px); height: var(--list-icon-size, 32px); flex-shrink: 0; }
|
||||
.project-name { min-width: 0; overflow-wrap: anywhere; font-size: 15px; font-weight: 500; }
|
||||
.preview-options { display: flex; flex-direction: column; align-items: flex-end; gap: 12px; }
|
||||
.control { justify-content: flex-start; gap: 12px; font-size: 12px; color: var(--muted); }
|
||||
.swatches, .sizes, .filters { display: flex; gap: 6px; }
|
||||
.filters { flex-wrap: wrap; }
|
||||
.swatch { width: 23px; height: 23px; border-radius: 50%; border: 1px solid #dacdc6; padding: 0; }
|
||||
.swatch[aria-pressed="true"] { outline: 1.5px solid var(--accent); outline-offset: 3px; }
|
||||
.size, .filter, .clear { border: 1px solid var(--line); border-radius: 7px; color: var(--muted); background: transparent; padding: 3px 10px; font-size: 12px; white-space: nowrap; }
|
||||
.size[aria-pressed="true"], .filter[aria-pressed="true"] { background: var(--surface); border-color: var(--accent); color: var(--accent); }
|
||||
.clear { padding: 0; border: 0; color: var(--accent); }
|
||||
.controls { padding: 23px 1px 16px; flex-wrap: wrap; }
|
||||
.filter { padding: 5px 12px; }
|
||||
.samples { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; }
|
||||
.sample { overflow: hidden; border: 1px solid var(--line); border-radius: 17px; background: var(--surface); }
|
||||
.sample:has(.sample-stage[aria-pressed="true"]) { border-color: var(--accent); }
|
||||
.sample-stage { position: relative; display: grid; place-items: center; width: 100%; height: 144px; padding: 0; border: 0; background: var(--sample); color: var(--accent); }
|
||||
.sample-stage[aria-pressed="true"]:after { content: '✓'; position: absolute; top: 12px; right: 16px; font-size: 13px; }
|
||||
.sample-stage img { width: 112px; height: 112px; }
|
||||
.number { position: absolute; top: 13px; left: 15px; color: #9b8985; font-size: 10px; font-variant-numeric: tabular-nums; }
|
||||
.sample-body { padding: 12px 15px; }
|
||||
h2 { margin: 0; font-size: 14px; font-weight: 600; }
|
||||
.english { color: var(--muted); font-size: 11px; margin-top: 2px; min-height: 18px; }
|
||||
.sample-footer { margin-top: 10px; padding-top: 9px; border-top: 1px solid #f0e9e3; gap: 8px; }
|
||||
.scale { display: flex; align-items: flex-end; gap: 10px; }
|
||||
.scale span { display: flex; flex-direction: column; align-items: center; gap: 3px; }
|
||||
.scale small { color: #998b83; font-size: 9px; }
|
||||
.download { color: var(--accent); font-size: 11px; text-decoration: none; padding: 8px 0; }
|
||||
.download:hover { text-decoration: underline; }
|
||||
footer { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 10px; margin-top: 23px; padding-top: 16px; border-top: 1px solid var(--line); color: #998b83; font-size: 11px; }
|
||||
footer a { color: var(--muted); text-decoration: none; }
|
||||
@media (max-width: 850px) { .samples { grid-template-columns: repeat(3, minmax(0, 1fr)); } .scale { gap: 6px; } }
|
||||
@media (max-width: 600px) {
|
||||
main { width: calc(100% - 32px); padding-top: 24px; }
|
||||
h1 { font-size: 25px; }
|
||||
.samples { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||||
.sample-stage { height: 132px; }
|
||||
.sample-stage img { width: 100px; height: 100px; }
|
||||
.sample-body { padding: 10px 12px; }
|
||||
.sample-footer { gap: 4px; }
|
||||
.scale span:last-child { display: none; }
|
||||
.scale { gap: 8px; }
|
||||
.download { font-size: 10px; }
|
||||
.preview-strip { flex-direction: column; align-items: stretch; padding: 13px; }
|
||||
.preview-options { flex-direction: column; align-items: stretch; gap: 10px; }
|
||||
.preview-options .control { justify-content: space-between; }
|
||||
.control { gap: 7px; }
|
||||
.size { padding: 3px 7px; }
|
||||
.controls { gap: 18px; padding-top: 20px; }
|
||||
.filter { padding: 4px 8px; }
|
||||
.palette span { width: 15px; height: 15px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="topline"><span class="brand">芭蕾岛</span><span>18 枚项目图标</span></div>
|
||||
<header class="header">
|
||||
<div><h1>项目图标库</h1><p class="muted">柔和线描 · 浅色填充</p></div>
|
||||
<div class="palette" aria-label="灰玫瑰、浅玫瑰、暖白配色"><span style="background:#9c5268"></span><span style="background:#ead1d7"></span><span style="background:#fff4ed"></span></div>
|
||||
</header>
|
||||
<section class="preview-strip" aria-label="项目图标预览">
|
||||
<div class="project-preview">
|
||||
<p class="preview-label"><span>项目列表预览</span><span>52 px 行高</span></p>
|
||||
<div class="project-row"><img id="chosen-project-icon" src="arabesque.svg" alt="阿拉贝斯克"><span class="project-name">周末基础课</span></div>
|
||||
</div>
|
||||
<div class="preview-options">
|
||||
<div class="control"><span>图标尺寸</span><div class="sizes" role="group" aria-label="列表图标大小"><button type="button" class="size" data-size="24" aria-pressed="false">24 px</button><button type="button" class="size" data-size="32" aria-pressed="true">32 px</button><button type="button" class="size" data-size="40" aria-pressed="false">40 px</button></div></div>
|
||||
<div class="control"><span id="chosen-label" aria-live="polite">阿拉贝斯克</span><button type="button" class="clear" id="clear-icon">无图标</button></div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="controls">
|
||||
<div class="filters" role="group" aria-label="图标分类"><button type="button" class="filter" data-filter="all" aria-pressed="true">全部 18</button><button type="button" class="filter" data-filter="motion" aria-pressed="false">专业动作 8</button><button type="button" class="filter" data-filter="training" aria-pressed="false">基础练习 6</button><button type="button" class="filter" data-filter="object" aria-pressed="false">物件 4</button></div>
|
||||
<div class="control"><span>预览底色</span><div class="swatches" role="group" aria-label="预览底色"><button type="button" class="swatch" data-background="#f4ece8" style="background:#f4ece8" aria-label="浅玫瑰" aria-pressed="true"></button><button type="button" class="swatch" data-background="#fffdfa" style="background:#fffdfa" aria-label="暖白" aria-pressed="false"></button><button type="button" class="swatch" data-background="#eef0e7" style="background:#eef0e7" aria-label="灰绿" aria-pressed="false"></button></div></div>
|
||||
</div>
|
||||
<section class="samples" aria-label="项目图标库,点击图案试选"></section>
|
||||
<footer><span>透明背景 · 可编辑 SVG · 点击图案试选</span><span>预览稿,尚未接入小程序</span><span>动作参考:<a href="https://australianballet.com.au/ballet-101/ballet-vocabulary" target="_blank" rel="noreferrer">澳大利亚芭蕾舞团</a> · <a href="https://www.atlantaballet.com/resources/ballet-terms-and-positions" target="_blank" rel="noreferrer">亚特兰大芭蕾舞团</a></span></footer>
|
||||
</main>
|
||||
<script>
|
||||
const icons = [
|
||||
{ id: 'plie', name: '蹲', term: 'Plié · 一位半蹲', group: 'motion' },
|
||||
{ id: 'tendu', name: '擦地', term: 'Tendu', group: 'motion' },
|
||||
{ id: 'releve', name: '立半脚尖', term: 'Relevé', group: 'motion' },
|
||||
{ id: 'port-de-bras', name: '手臂练习', term: 'Port de bras', group: 'motion' },
|
||||
{ id: 'arabesque', name: '阿拉贝斯克', term: 'Arabesque', group: 'motion' },
|
||||
{ id: 'attitude', name: '阿蒂蒂德', term: 'Attitude derrière', group: 'motion' },
|
||||
{ id: 'pirouette', name: '旋转', term: 'Pirouette', group: 'motion' },
|
||||
{ id: 'grand-jete', name: '大跳', term: 'Grand jeté', group: 'motion' },
|
||||
{ id: 'foot-hip', name: '足髋训练', term: 'Foot & hip', group: 'training' },
|
||||
{ id: 'flexibility', name: '软开素质', term: 'Flexibility', group: 'training' },
|
||||
{ id: 'ball-core', name: '小球核心', term: 'Small ball core', group: 'training' },
|
||||
{ id: 'swan-arms', name: '天鹅臂颈', term: 'Swan arms & neck', group: 'training' },
|
||||
{ id: 'breathing', name: '呼吸训练', term: 'Breathing', group: 'training' },
|
||||
{ id: 'core-glutes', name: '核心臀腿', term: 'Core, glutes & legs', group: 'training' },
|
||||
{ id: 'soft-shoes', name: '软底舞鞋', term: 'Ballet slippers', group: 'object' },
|
||||
{ id: 'pointe-shoes', name: '足尖鞋', term: 'Pointe shoes', group: 'object' },
|
||||
{ id: 'barre', name: '把杆', term: 'Barre', group: 'object' },
|
||||
{ id: 'piano', name: '钢琴', term: 'Piano', group: 'object' },
|
||||
];
|
||||
const gallery = document.querySelector('.samples');
|
||||
icons.forEach((icon, index) => {
|
||||
const card = document.createElement('article');
|
||||
card.className = 'sample';
|
||||
card.dataset.group = icon.group;
|
||||
const stage = document.createElement('button');
|
||||
stage.type = 'button';
|
||||
stage.className = 'sample-stage';
|
||||
stage.dataset.icon = icon.id;
|
||||
stage.setAttribute('aria-label', '选择' + icon.name);
|
||||
stage.setAttribute('aria-pressed', String(icon.id === 'arabesque'));
|
||||
const number = document.createElement('span');
|
||||
number.className = 'number';
|
||||
number.textContent = String(index + 1).padStart(2, '0');
|
||||
const image = document.createElement('img');
|
||||
image.src = icon.id + '.svg';
|
||||
image.alt = icon.name;
|
||||
stage.append(number, image);
|
||||
const body = document.createElement('div');
|
||||
body.className = 'sample-body';
|
||||
const title = document.createElement('h2');
|
||||
title.textContent = icon.name;
|
||||
const term = document.createElement('p');
|
||||
term.className = 'english';
|
||||
term.textContent = icon.term;
|
||||
const footer = document.createElement('div');
|
||||
footer.className = 'sample-footer';
|
||||
const scale = document.createElement('div');
|
||||
scale.className = 'scale';
|
||||
[24, 32, 48].forEach(size => {
|
||||
const item = document.createElement('span');
|
||||
const preview = document.createElement('img');
|
||||
preview.src = icon.id + '.svg';
|
||||
preview.width = size;
|
||||
preview.height = size;
|
||||
preview.alt = icon.name + ' ' + size + ' 像素';
|
||||
const label = document.createElement('small');
|
||||
label.textContent = size;
|
||||
item.append(preview, label);
|
||||
scale.append(item);
|
||||
});
|
||||
const download = document.createElement('a');
|
||||
download.className = 'download';
|
||||
download.href = icon.id + '.svg';
|
||||
download.download = icon.id + '.svg';
|
||||
download.setAttribute('aria-label', '下载' + icon.name + ' SVG');
|
||||
download.textContent = 'SVG ↓';
|
||||
footer.append(scale, download);
|
||||
body.append(title, term, footer);
|
||||
card.append(stage, body);
|
||||
gallery.append(card);
|
||||
stage.addEventListener('click', () => selectIcon(icon));
|
||||
});
|
||||
function selectIcon(icon) {
|
||||
const preview = document.getElementById('chosen-project-icon');
|
||||
preview.hidden = !icon;
|
||||
if (icon) { preview.src = icon.id + '.svg'; preview.alt = icon.name; }
|
||||
document.getElementById('chosen-label').textContent = icon ? icon.name : '无图标';
|
||||
document.querySelectorAll('[data-icon]').forEach(button => button.setAttribute('aria-pressed', String(Boolean(icon) && button.dataset.icon === icon.id)));
|
||||
}
|
||||
document.getElementById('clear-icon').addEventListener('click', () => selectIcon(null));
|
||||
document.querySelectorAll('[data-background]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
document.documentElement.style.setProperty('--sample', button.dataset.background);
|
||||
document.querySelectorAll('[data-background]').forEach(item => item.setAttribute('aria-pressed', String(item === button)));
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('[data-size]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
document.documentElement.style.setProperty('--list-icon-size', button.dataset.size + 'px');
|
||||
document.querySelectorAll('[data-size]').forEach(item => item.setAttribute('aria-pressed', String(item === button)));
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('[data-filter]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
document.querySelectorAll('.sample').forEach(card => { card.hidden = button.dataset.filter !== 'all' && card.dataset.group !== button.dataset.filter; });
|
||||
document.querySelectorAll('[data-filter]').forEach(item => item.setAttribute('aria-pressed', String(item === button)));
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128" fill="none" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Relevé · 立半脚尖</title>
|
||||
<desc id="desc">修长舞者直立,双脚脚跟抬起,前脚掌着地,双臂在身前自然围圆。</desc>
|
||||
<g stroke="#9c5268" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M56 68C54 78 56 88 58 96L58 109C58 112 53 116 49 118C46 119 46 121 49 121H56C58 118 60 115 62 114L63 96L64 75L65 96L66 114C68 115 70 118 72 121H79C82 121 82 119 79 118C75 116 70 112 70 109L70 96C72 88 74 78 72 68Z" fill="#fff4ed"/>
|
||||
<path d="M61 30C61 34 60 36 56 37L53 43C55 49 58 53 58 59C58 64 55 67 54 71C58 71 62 74 64 77C66 74 70 71 74 71C73 67 70 64 70 59C70 53 73 49 75 43L72 37C68 36 67 34 67 30Z" fill="#fff4ed"/>
|
||||
<path d="M56 38C60 45 68 45 72 38L74 43C72 49 69 54 69 59C69 64 72 68 74 70C69 71 66 73 64 76C62 73 59 71 54 70C56 68 59 64 59 59C59 54 56 49 54 43Z" fill="#ead1d7"/>
|
||||
<path d="M54 40C43 43 37 50 39 58C41 67 50 73 61 74C63 74 63 72 61 71C52 67 47 61 46 56C45 52 49 48 55 46M74 40C85 43 91 50 89 58C87 67 78 73 67 74C65 74 65 72 67 71C76 67 81 61 82 56C83 52 79 48 73 46" fill="#fff4ed"/>
|
||||
<path d="M61 17C58 14 60 11 64 11C68 11 70 14 67 17" fill="#ead1d7"/>
|
||||
<path d="M57.5 23C57.5 18.5 60 16 64 16C68 16 70.5 18.5 70.5 23C70.5 27.5 68 32 64 32C60 32 57.5 27.5 57.5 23Z" fill="#fff4ed"/>
|
||||
<path d="M57.5 23C60 23 63 20 64 17C66 20 68 22 70.5 23" fill="#ead1d7"/>
|
||||
<path d="M58 108L62 109M66 109L70 108" stroke-width="1.2"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128" fill="none" role="img" aria-labelledby="title desc">
|
||||
<title id="title">软底舞鞋 · Ballet slippers</title>
|
||||
<desc id="desc">灰玫瑰线描软底舞鞋,圆润鞋头、浅色鞋身与交叉松紧带。</desc>
|
||||
<g stroke="#9c5268" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<g transform="rotate(19 83 68)">
|
||||
<path d="M83 24C73 24 67 36 66 51L66 89C66 101 71 109 80 110C89 111 96 103 97 91L99 54C100 35 94 24 86 24Z" fill="#ead1d7"/>
|
||||
<path d="M83 31C76 31 72 44 72 57C72 63 75 67 81 68C88 68 92 63 92 57C93 44 89 31 85 31Z" fill="#fff4ed"/>
|
||||
<path d="M75 41L92 57M90 41L72 57M72 93C75 99 87 100 91 94"/>
|
||||
</g>
|
||||
<g transform="rotate(24 44 70)">
|
||||
<path d="M44 27C34 27 27 40 27 55L28 91C28 105 35 113 44 112C54 111 60 102 60 89L60 55C60 38 54 27 47 27Z" fill="#f3e4e5"/>
|
||||
<path d="M44 34C37 34 33 46 33 59C33 67 37 71 44 71C51 71 55 66 54 59C54 45 50 34 46 34Z" fill="#fff4ed"/>
|
||||
<path d="M35 45L54 60M53 45L33 60M34 96C38 102 49 102 54 95"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128" fill="none" role="img" aria-labelledby="title desc">
|
||||
<title id="title">天鹅臂颈</title>
|
||||
<desc id="desc">修长肩颈与轻柔展开的双臂,手腕自然垂落,形成舒展的翼形轮廓。</desc>
|
||||
<g stroke="#9c5268" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M60 39C61 45 59 49 55 51C47 51 41 46 35 41C32 39 30 39 28 42C23 49 18 55 12 59L8 64C7 67 9 67 11 65L16 61C24 57 29 51 32 47C38 54 46 59 52 59C54 68 57 72 57 80C57 89 51 98 48 109C56 113 72 113 80 109C77 98 71 89 71 80C71 72 74 68 76 59C82 59 90 54 96 47C99 51 104 57 112 61L117 65C119 67 121 67 120 64L116 59C110 55 105 49 100 42C98 39 96 39 93 41C87 46 81 51 73 51C69 49 67 45 68 39Z" fill="#fff4ed"/>
|
||||
<path d="M55 52C60 58 68 58 73 52L76 58C74 68 71 73 71 80C71 89 77 98 80 109C72 113 56 113 48 109C51 98 57 89 57 80C57 73 54 68 52 58Z" fill="#ead1d7"/>
|
||||
<path d="M57 82C61 84 67 84 71 82M52 59C56 61 59 61 61 61M67 61C70 61 72 60 76 59" stroke="#bd8292" stroke-width="1.2"/>
|
||||
<path d="M59 22C55 19 57 15 62 15C67 15 69 18 66 21" fill="#ead1d7"/>
|
||||
<path d="M61 21C56 22 54 26 55 31C55 36 59 42 64 42C68 41 72 36 72 31C72 26 69 21 65 21Z" fill="#fff4ed"/>
|
||||
<path d="M55 30C60 29 63 25 64 21C67 24 69 27 72 29" fill="#ead1d7"/>
|
||||
<path d="M20 73C26 73 30 69 33 65M95 65C98 69 102 73 108 73" stroke="#d8b6bf" stroke-width="1.2"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128" fill="none" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Tendu · 擦地</title>
|
||||
<desc id="desc">修长舞者单腿直立,工作腿向侧方伸长,绷直的脚尖仍触地。</desc>
|
||||
<g stroke="#9c5268" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M61 30C61 34 60 36 56 37C47 36 39 39 31 44C24 48 18 52 12 54C10 55 11 56 13 56C22 53 29 49 37 46C43 43 49 43 54 44C55 49 58 53 58 59C58 64 55 67 54 71C51 81 53 90 54 99V114L46 118C44 119 44 120 47 120H59C61 120 61 118 60 116L61 97C63 88 65 80 64 74C72 88 80 96 91 107L106 119C108 121 110 120 109 118L98 104C89 91 83 80 75 69C72 65 70 63 70 59C70 53 73 49 74 44C79 43 85 43 91 46C99 49 106 53 115 56C117 56 118 55 116 54C110 52 104 48 97 44C89 39 81 36 72 37C68 36 67 34 67 30Z" fill="#fff4ed"/>
|
||||
<path d="M56 38C58 41 60 43 64 43C68 43 70 41 72 38L74 43C72 49 69 54 69 59C69 64 73 68 75 70C70 70 66 73 64 76C62 73 58 70 53 70C55 68 59 64 59 59C59 54 56 49 54 43Z" fill="#ead1d7"/>
|
||||
<path d="M59 60C62 62 66 62 69 60M54 113L60 114M99 111L102 108" stroke="#bd8292" stroke-width="1.2"/>
|
||||
<path d="M61 17C58 14 60 11 64 11C68 11 70 14 67 17" fill="#ead1d7"/>
|
||||
<path d="M57.5 23C57.5 18.5 60 16 64 16C68 16 70.5 18.5 70.5 23C70.5 27.5 68 32 64 32C60 32 57.5 27.5 57.5 23Z" fill="#fff4ed"/>
|
||||
<path d="M57.5 23C60 23 63 20 64 17C66 20 68 22 70.5 23" fill="#ead1d7"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -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: {
|
||||
|
||||
@@ -24,33 +24,57 @@ page {
|
||||
}
|
||||
view, text, input, textarea { box-sizing: border-box; }
|
||||
.page { padding: 20px 18px 32px; padding-bottom: calc(32px + env(safe-area-inset-bottom)); }
|
||||
.page-heading { margin: 4px 2px 24px; }
|
||||
.eyebrow { display: block; color: #946d76; font-size: 10px; letter-spacing: 2px; margin-bottom: 8px; }
|
||||
.page-title { display: block; margin-bottom: 8px; font-size: 26px; font-weight: 600; line-height: 1.35; letter-spacing: -0.5px; }
|
||||
.muted { color: #827572; font-size: 13px; }
|
||||
.small { font-size: 12px; }
|
||||
.block { display: block; }
|
||||
.panel { padding: 18px; background: #fffdfa; border: 1px solid #ece3dd; border-radius: 18px; }
|
||||
.hero-card { padding: 23px 22px; border: 1px solid #eddfda; background: #f1e7e3; border-radius: 21px; }
|
||||
.hero-number { display: block; color: #864b5e; font-size: 29px; font-weight: 600; line-height: 1.5; margin: 4px 0 8px; overflow-wrap: anywhere; }
|
||||
.record-overview { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; align-items: start; }
|
||||
.overview-column { min-width: 0; }
|
||||
.overview-column--right { border-left: 1px solid #e2cfd0; padding-left: 18px; }
|
||||
.overview-label { display: block; color: #946d76; font-size: 12px; line-height: 18px; }
|
||||
.overview-value { display: block; color: #864b5e; font-size: 28px; font-weight: 600; line-height: 1.3; margin-top: 8px; overflow-wrap: anywhere; }
|
||||
.overview-unit { font-size: 15px; font-weight: 400; }
|
||||
.overview-start { display: block; color: #827572; font-size: 11px; margin-top: 6px; }
|
||||
.section-heading, .row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.section-heading { margin: 24px 2px 12px; }
|
||||
.section-heading--actions { justify-content: flex-end; margin-top: 12px; }
|
||||
.section-title { font-size: 17px; font-weight: 600; }
|
||||
.project-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||
.project-tile { border: 1px solid #e7ded8; border-radius: 17px; min-height: 126px; padding: 15px 16px; background: #f4ece8; }
|
||||
.project-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.project-tile { display: flex; align-items: center; gap: 12px; border: 1px solid #e7ded8; border-radius: 17px; padding: 10px 16px; background: #f4ece8; }
|
||||
.project-tile--1 { background: #eef0e7; border-color: #e0e5d9; }
|
||||
.project-tile--2 { background: #eeeaf2; border-color: #e3dfea; }
|
||||
.project-tile--3 { background: #f4efdf; border-color: #eae4d5; }
|
||||
.project-mark { display: block; color: #9a8583; font-size: 10px; letter-spacing: 1px; margin-bottom: 10px; }
|
||||
.project-title { display: block; font-size: 17px; font-weight: 600; overflow-wrap: anywhere; }
|
||||
.project-hint { display: block; font-size: 11px; color: #8b7476; margin-top: 8px; }
|
||||
.project-title { display: block; flex: 1; min-width: 0; font-size: 17px; font-weight: 600; overflow-wrap: anywhere; }
|
||||
.project-hours { flex-shrink: 0; color: #9c5268; font-size: 18px; font-weight: 600; white-space: nowrap; }
|
||||
.project-hours-unit { font-size: 12px; font-weight: 400; }
|
||||
.project-icon { display: block; flex-shrink: 0; width: 32px; height: 32px; }
|
||||
.action-icon { display: block; width: 28px; height: 28px; }
|
||||
.profile-card { display: flex; align-items: center; gap: 18px; padding: 24px 20px; border: 1px solid #eddfda; border-radius: 21px; background: #f1e7e3; }
|
||||
.profile-avatar-button { display: flex; flex-shrink: 0; align-items: center; justify-content: center; width: 72px; height: 72px; margin: 0; padding: 0; border-radius: 50%; background: #e2cfd0; overflow: hidden; }
|
||||
.profile-avatar-button::after { border: 0; }
|
||||
.profile-avatar { display: block; width: 72px; height: 72px; }
|
||||
.profile-avatar-placeholder { color: #864b5e; font-size: 28px; font-weight: 600; line-height: 1; }
|
||||
.profile-details { flex: 1; min-width: 0; }
|
||||
.profile-nickname { display: block; width: 100%; height: 36px; color: #3e3435; font-size: 21px; font-weight: 600; }
|
||||
.profile-hint { display: block; margin-top: 3px; color: #827572; font-size: 12px; }
|
||||
.profile-storage-note { display: block; margin: 12px 2px; color: #827572; font-size: 11px; }
|
||||
.sort-instruction { display: block; color: #827572; font-size: 13px; margin-bottom: 16px; }
|
||||
.sort-project-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.sortable-project { position: relative; z-index: 0; display: flex; align-items: center; gap: 12px; min-height: 64px; padding: 8px 10px 8px 16px; border: 1px solid #ece3dd; border-radius: 17px; background: #fffdfa; }
|
||||
.sortable-project--dragging { z-index: 2; border-color: #9c5268; background: #f4ece8; }
|
||||
.sortable-project--target { border-color: #bc8292; }
|
||||
.sort-project-name { flex: 1; min-width: 0; font-size: 16px; font-weight: 500; overflow-wrap: anywhere; }
|
||||
.sort-drag-handle { display: flex; flex-shrink: 0; align-items: center; justify-content: center; width: 44px; height: 44px; }
|
||||
.sort-drag-icon { display: block; width: 22px; height: 22px; }
|
||||
.record-card { padding: 16px 18px; background: #fffdfa; border: 1px solid #ece3dd; border-radius: 16px; margin-bottom: 10px; }
|
||||
.record-name { font-size: 15px; font-weight: 500; overflow-wrap: anywhere; flex: 1; min-width: 0; }
|
||||
.record-duration { color: #9c5268; font-size: 13px; text-align: right; max-width: 45%; }
|
||||
.record-note { display: block; white-space: pre-wrap; overflow-wrap: anywhere; padding-top: 12px; border-top: 1px solid #f0e9e3; font-size: 13px; color: #756364; }
|
||||
.empty { text-align: center; padding: 20px 4px; }
|
||||
.empty-title { display: block; font-size: 16px; font-weight: 500; margin-bottom: 10px; }
|
||||
.empty .muted { display: block; line-height: 1.8; }
|
||||
.empty-title { display: block; font-size: 16px; font-weight: 500; }
|
||||
.status-panel { display: flex; flex-direction: column; gap: 16px; align-items: center; margin-bottom: 16px; }
|
||||
.error-text { color: #a43b4b; }
|
||||
.error-banner, .success-banner { display: block; border-radius: 12px; padding: 13px 15px; margin: 14px 0; font-size: 13px; line-height: 1.7; overflow-wrap: anywhere; }
|
||||
@@ -58,21 +82,32 @@ view, text, input, textarea { box-sizing: border-box; }
|
||||
.success-banner { color: #4f6b4c; background: #eaf0e7; }
|
||||
.form-panel .field-label:first-child { margin-top: 0; }
|
||||
.field-label { display: block; font-size: 14px; font-weight: 500; margin: 22px 0 10px; }
|
||||
.field { display: block; width: 100%; background: #f8f4ef; border: 1px solid #e8dfd8; border-radius: 11px; padding: 12px 14px; height: 48px; font-size: 16px; color: #44383b; }
|
||||
.picker-field { display: flex; justify-content: space-between; height: auto; min-height: 48px; overflow-wrap: anywhere; gap: 8px; }
|
||||
// Native single-line inputs position their text from height; vertical padding shifts filled values down.
|
||||
.field { display: block; width: 100%; background: #f8f4ef; border: 1px solid #e8dfd8; border-radius: 11px; padding: 0 14px; height: 48px; font-size: 16px; color: #44383b; }
|
||||
.picker-field { display: flex; justify-content: space-between; padding: 12px 14px; height: auto; min-height: 48px; overflow-wrap: anywhere; gap: 8px; }
|
||||
.quick-minutes { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
|
||||
.note-input { min-height: 140px; height: 140px; line-height: 1.6; }
|
||||
.note-input { padding: 12px 14px; min-height: 140px; height: 140px; line-height: 1.6; }
|
||||
.field-counter { display: block; text-align: right; color: #968983; font-size: 11px; margin-top: 6px; }
|
||||
.field-help { display: block; color: #877872; font-size: 12px; margin: 10px 0 14px; line-height: 1.7; }
|
||||
.form-actions { margin-top: 22px; }
|
||||
.project-name-input { margin-top: 16px; }
|
||||
.page-footnote { display: block; text-align: center; color: #998a82; font-size: 11px; line-height: 1.8; margin: 24px 12px 8px; }
|
||||
.managed-project { padding: 12px 0; border-bottom: 1px solid #f0e8e1; }
|
||||
.managed-project:first-child { padding-top: 0; }
|
||||
.managed-project:last-child { padding-bottom: 0; border-bottom: 0; }
|
||||
.managed-project > .record-name { display: block; }
|
||||
.managed-project > .row { align-items: flex-end; }
|
||||
.form-page { padding-bottom: calc(100px + env(safe-area-inset-bottom)); }
|
||||
.form-actions { position: fixed; left: 0; right: 0; bottom: 0; z-index: 20; display: flex; gap: 12px; padding: 12px 18px; padding-bottom: calc(12px + env(safe-area-inset-bottom)); border-top: 1px solid #ece3dd; background: #fffdfa; }
|
||||
.form-action { flex: 1; min-width: 0; }
|
||||
.managed-project { display: flex; align-items: center; gap: 12px; padding: 8px 16px; background: #fffdfa; border: 1px solid #ece3dd; border-radius: 17px; }
|
||||
.inline-actions { display: flex; flex-shrink: 0; }
|
||||
.project-icon-field { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.project-icon-current { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||
.icon-picker { --popup-background-color: #fffdfa; --popup-rounded-border-radius: 20px; }
|
||||
.icon-picker-heading { display: flex; align-items: center; justify-content: space-between; padding: 16px 18px 8px; }
|
||||
.icon-picker-tabs { display: flex; gap: 8px; padding: 0 18px 14px; }
|
||||
.icon-picker-scroll { height: 48vh; max-height: 400px; }
|
||||
.icon-picker-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; padding: 2px 18px 16px; }
|
||||
.icon-choice { position: relative; display: flex; width: 100%; min-width: 0; flex-direction: column; align-items: center; gap: 4px; margin: 0; padding: 10px 4px; border: 1px solid #ece3dd; border-radius: 12px; background: #faf7f3; color: #655656; font-size: 12px; line-height: 1.5; }
|
||||
.icon-choice::after { border: 0; }
|
||||
.icon-choice--selected { background: #f4ece8; border-color: #9c5268; color: #9c5268; }
|
||||
.icon-choice-image { display: block; width: 60px; height: 60px; }
|
||||
.icon-choice-name { display: block; overflow-wrap: anywhere; }
|
||||
.icon-choice-check { position: absolute; top: 4px; right: 7px; font-size: 12px; }
|
||||
.icon-picker-footer { padding: 6px 18px calc(12px + env(safe-area-inset-bottom)); border-top: 1px solid #ece3dd; }
|
||||
.month-nav { display: flex; align-items: center; justify-content: space-between; gap: 4px; margin: 0 -8px 12px; }
|
||||
.calendar-panel { padding: 16px 12px; }
|
||||
.calendar-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); text-align: center; }
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#9c5268" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="4"/>
|
||||
<path d="M12 7.5v9M7.5 12h9"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 271 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#9c5268" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="5" width="18" height="16" rx="3"/>
|
||||
<path d="M7 3v4M17 3v4M3 10h18"/>
|
||||
<path d="M8 14h2M14 14h2M8 17h2M14 17h2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 319 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="#9c5268">
|
||||
<circle cx="9" cy="5" r="1.4"/><circle cx="15" cy="5" r="1.4"/>
|
||||
<circle cx="9" cy="12" r="1.4"/><circle cx="15" cy="12" r="1.4"/>
|
||||
<circle cx="9" cy="19" r="1.4"/><circle cx="15" cy="19" r="1.4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 308 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#9c5268" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="4.5" cy="6" r="1" fill="#9c5268" stroke="none"/>
|
||||
<circle cx="4.5" cy="12" r="1" fill="#9c5268" stroke="none"/>
|
||||
<circle cx="4.5" cy="18" r="1" fill="#9c5268" stroke="none"/>
|
||||
<path d="M8 6h12M8 12h12M8 18h12"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 415 B |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
@@ -0,0 +1,45 @@
|
||||
import plie from './plie.png'
|
||||
import tendu from './tendu.png'
|
||||
import releve from './releve.png'
|
||||
import portDeBras from './port-de-bras.png'
|
||||
import arabesque from './arabesque.png'
|
||||
import attitude from './attitude.png'
|
||||
import pirouette from './pirouette.png'
|
||||
import grandJete from './grand-jete.png'
|
||||
import footHip from './foot-hip.png'
|
||||
import flexibility from './flexibility.png'
|
||||
import ballCore from './ball-core.png'
|
||||
import swanArms from './swan-arms.png'
|
||||
import breathing from './breathing.png'
|
||||
import coreGlutes from './core-glutes.png'
|
||||
import softShoes from './soft-shoes.png'
|
||||
import pointeShoes from './pointe-shoes.png'
|
||||
import barre from './barre.png'
|
||||
import piano from './piano.png'
|
||||
|
||||
// Transparent 3× exports of docs/design/project-icons/*.svg. Only bundled assets
|
||||
// are resolved here; an unknown server ID safely displays the project name alone.
|
||||
export const projectIcons = [
|
||||
{ id: 'foot-hip', name: '足髋训练', group: 'training', src: footHip },
|
||||
{ id: 'flexibility', name: '软开素质', group: 'training', src: flexibility },
|
||||
{ id: 'ball-core', name: '小球核心', group: 'training', src: ballCore },
|
||||
{ id: 'swan-arms', name: '天鹅臂颈', group: 'training', src: swanArms },
|
||||
{ id: 'breathing', name: '呼吸训练', group: 'training', src: breathing },
|
||||
{ id: 'core-glutes', name: '核心臀腿', group: 'training', src: coreGlutes },
|
||||
{ id: 'plie', name: '蹲', group: 'motion', src: plie },
|
||||
{ id: 'tendu', name: '擦地', group: 'motion', src: tendu },
|
||||
{ id: 'releve', name: '立半脚尖', group: 'motion', src: releve },
|
||||
{ id: 'port-de-bras', name: '手臂练习', group: 'motion', src: portDeBras },
|
||||
{ id: 'arabesque', name: '阿拉贝斯克', group: 'motion', src: arabesque },
|
||||
{ id: 'attitude', name: '阿蒂蒂德', group: 'motion', src: attitude },
|
||||
{ id: 'pirouette', name: '旋转', group: 'motion', src: pirouette },
|
||||
{ id: 'grand-jete', name: '大跳', group: 'motion', src: grandJete },
|
||||
{ id: 'soft-shoes', name: '软底舞鞋', group: 'object', src: softShoes },
|
||||
{ id: 'pointe-shoes', name: '足尖鞋', group: 'object', src: pointeShoes },
|
||||
{ id: 'barre', name: '把杆', group: 'object', src: barre },
|
||||
{ id: 'piano', name: '钢琴', group: 'object', src: piano },
|
||||
] as const
|
||||
|
||||
export function projectIcon(id?: string) {
|
||||
return projectIcons.find(icon => icon.id === id)
|
||||
}
|
||||
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
@@ -7,15 +7,25 @@ import { duration } from '../utils/dates'
|
||||
|
||||
export { Button }
|
||||
|
||||
export function PageHeading({ title, subtitle }: { title: string; subtitle: string }) {
|
||||
return <View className='page-heading'><Text className='eyebrow'>BALLET ISLAND</Text><Text className='page-title'>{title}</Text><Text className='muted'>{subtitle}</Text></View>
|
||||
export function FormActions({ busy, cancelDisabled, saveDisabled, saveLabel = '保存', onCancel, onSave }: {
|
||||
busy: boolean
|
||||
cancelDisabled?: boolean
|
||||
saveDisabled?: boolean
|
||||
saveLabel?: string
|
||||
onCancel: () => unknown
|
||||
onSave: () => unknown
|
||||
}) {
|
||||
return <View className='form-actions'>
|
||||
<View className='form-action'><Button block variant='outlined' disabled={busy || cancelDisabled} onClick={() => { void onCancel() }}>取消</Button></View>
|
||||
<View className='form-action'><Button block color='primary' loading={busy} disabled={busy || saveDisabled} onClick={() => { void onSave() }}>{saveLabel}</Button></View>
|
||||
</View>
|
||||
}
|
||||
export function LoadState({ loading, error, retry }: { loading: boolean; error: string; retry: () => unknown }) {
|
||||
if (!loading && !error) return null
|
||||
return <View className='panel status-panel'><Text className={error ? 'error-text' : 'muted'}>{loading ? '正在读取你的练习…' : error}</Text>{error && <Button size='small' variant='outlined' onClick={() => { void retry() }}>重新加载</Button>}</View>
|
||||
}
|
||||
export function Empty({ title, text }: { title: string; text: string }) {
|
||||
return <View className='empty'><Text className='empty-title'>{title}</Text><Text className='muted'>{text}</Text></View>
|
||||
export function Empty({ title }: { title: string }) {
|
||||
return <View className='empty'><Text className='empty-title'>{title}</Text></View>
|
||||
}
|
||||
export function RecordCards({ records }: { records: PracticeRecord[] }) {
|
||||
return <View>{records.map(record => <View className='record-card' key={record.id}>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useState } from 'react'
|
||||
import { Button as NativeButton, Image, ScrollView, Text, View } from '@tarojs/components'
|
||||
import Popup from '@taroify/core/popup'
|
||||
import '@taroify/core/popup/style'
|
||||
import { Button } from './common'
|
||||
import { ProjectIcon } from './project-icon'
|
||||
import { projectIcon, projectIcons } from '../assets/project-icons'
|
||||
|
||||
const groups = [
|
||||
{ id: 'training', name: '基础练习' },
|
||||
{ id: 'motion', name: '专业动作' },
|
||||
{ id: 'object', name: '物件' },
|
||||
] as const
|
||||
type Group = typeof groups[number]['id']
|
||||
|
||||
/** Selection only updates the enclosing form; its save/cancel controls own persistence. */
|
||||
export function ProjectIconPicker({ value, disabled, onChange }: {
|
||||
value: string; disabled: boolean; onChange: (id: string) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [group, setGroup] = useState<Group>('training')
|
||||
const selected = projectIcon(value)
|
||||
|
||||
function choose(id: string) {
|
||||
if (disabled) return
|
||||
onChange(id)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return <>
|
||||
<View className='project-icon-field'>
|
||||
<View className='project-icon-current'><ProjectIcon id={value} /><Text className='muted'>{selected?.name || '无图标'}</Text></View>
|
||||
<Button size='small' variant='text' disabled={disabled} onClick={() => { setGroup(selected?.group || 'training'); setOpen(true) }}>{selected ? '更换图标' : '选择图标'}</Button>
|
||||
</View>
|
||||
<Popup className='icon-picker' open={open} placement='bottom' rounded onClose={() => setOpen(false)}>
|
||||
<View className='icon-picker-heading'><Text className='section-title'>项目图标</Text><Button size='small' variant='text' onClick={() => setOpen(false)}>关闭</Button></View>
|
||||
<View className='icon-picker-tabs'>{groups.map(item => <Button key={item.id} size='small' variant={group === item.id ? 'outlined' : 'text'} color={group === item.id ? 'primary' : 'default'} onClick={() => setGroup(item.id)}>{item.name}</Button>)}</View>
|
||||
<ScrollView className='icon-picker-scroll' scrollY>
|
||||
<View className='icon-picker-grid'>{projectIcons.filter(icon => icon.group === group).map(icon => <NativeButton
|
||||
key={icon.id} className={`icon-choice${value === icon.id ? ' icon-choice--selected' : ''}`} disabled={disabled}
|
||||
ariaLabel={`${icon.name}${value === icon.id ? ',已选中' : ''}`} onClick={() => choose(icon.id)}
|
||||
>
|
||||
<Image className='icon-choice-image' src={icon.src} mode='aspectFit' />
|
||||
<Text className='icon-choice-name'>{icon.name}</Text>
|
||||
{value === icon.id && <Text className='icon-choice-check'>✓</Text>}
|
||||
</NativeButton>)}</View>
|
||||
</ScrollView>
|
||||
<View className='icon-picker-footer'><Button block variant='text' disabled={disabled} onClick={() => choose('')}>无图标{!value ? ' · 已选中' : ''}</Button></View>
|
||||
</Popup>
|
||||
</>
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Image } from '@tarojs/components'
|
||||
import { projectIcon } from '../assets/project-icons'
|
||||
|
||||
/** Unknown or empty IDs leave the readable project title intact without a placeholder. */
|
||||
export function ProjectIcon({ id, className = '' }: { id?: string; className?: string }) {
|
||||
const icon = projectIcon(id)
|
||||
return icon ? <Image className={`project-icon ${className}`} src={icon.src} mode='aspectFit' ariaLabel={icon.name} /> : null
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import Taro, { useDidHide } from '@tarojs/taro'
|
||||
import { Picker, Text, View } from '@tarojs/components'
|
||||
import { Button, Empty, LoadState, PageHeading, RecordCards } from '../../components/common'
|
||||
import { Button, Empty, LoadState, RecordCards } from '../../components/common'
|
||||
import { useRemote } from '../../hooks/useRemote'
|
||||
import { api, errorMessage, type RecordList } from '../../services/practice'
|
||||
import { addMonths, displayDate, duration, monthLabel, monthStart, parseDate, shanghaiToday } from '../../utils/dates'
|
||||
@@ -41,7 +41,6 @@ export default function Calendar() {
|
||||
}
|
||||
|
||||
return <View className='page'>
|
||||
<PageHeading title='练习留在日子里' subtitle='有练习的日子做个记号,休息日就留白。' />
|
||||
<View className='panel calendar-panel'>
|
||||
<View className='month-nav'>
|
||||
<Button variant='text' size='small' disabled={month <= '0001-01-01'} onClick={() => setSelected(addMonths(month, -1))}>上月</Button>
|
||||
@@ -59,7 +58,7 @@ export default function Calendar() {
|
||||
</View>
|
||||
{review && list && <>
|
||||
<View className='section-heading'><View><Text className='section-title'>{displayDate(selected)}</Text><Text className='muted small block'>共 {duration(total)}</Text></View><Button color='primary' size='small' onClick={() => { void Taro.navigateTo({ url: `/pages/record/index?date=${selected}` }) }}>补记练习</Button></View>
|
||||
{list.records.length ? <RecordCards records={list.records} /> : <View className='panel'><Empty title='这一天还没有记录' text='休息也是日常。漏记了一堂课,也可以补在这天。' /></View>}
|
||||
{list.records.length ? <RecordCards records={list.records} /> : <View className='panel'><Empty title='当天暂无记录' /></View>}
|
||||
{moreError && <Text className='error-banner'>{moreError}</Text>}
|
||||
{list.nextCursor && <Button block variant='outlined' loading={loadingMore} disabled={loadingMore} onClick={more}>查看当天更多记录</Button>}
|
||||
</>}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { Text, View } from '@tarojs/components'
|
||||
import { Button, Empty, LoadState, PageHeading } from '../../components/common'
|
||||
import { Button, Empty, LoadState } from '../../components/common'
|
||||
import { useRemote } from '../../hooks/useRemote'
|
||||
import { api } from '../../services/practice'
|
||||
import { addDays, addMonths, duration, monthStart, shanghaiToday } from '../../utils/dates'
|
||||
@@ -19,10 +19,9 @@ export default function Growth() {
|
||||
}
|
||||
const currentPeriod = review && (period === 'week' ? review.to >= review.today : monthStart(review.from) === monthStart(review.today))
|
||||
return <View className='page'>
|
||||
<PageHeading title='把投入慢慢积累' subtitle='回看练习的时间,也回看自己的节奏。' />
|
||||
<LoadState {...state} retry={state.reload} />
|
||||
{review && <>
|
||||
<View className='hero-card'><Text className='eyebrow'>累计练习</Text><Text className='hero-number'>{duration(review.allTime.totalMinutes)}</Text><Text className='muted'>每一分钟,都来自你留下的真实记录。</Text></View>
|
||||
<View className='hero-card'><Text className='eyebrow'>累计练习</Text><Text className='hero-number'>{duration(review.allTime.totalMinutes)}</Text></View>
|
||||
<View className='stats-grid'><View className='panel'><Text className='stat-number'>{review.allTime.practiceDays}<Text className='stat-unit'> 天</Text></Text><Text className='muted'>练习天数</Text></View><View className='panel'><Text className='stat-number'>{review.allTime.recordCount}<Text className='stat-unit'> 次</Text></Text><Text className='muted'>练习记录</Text></View></View>
|
||||
<View className='panel trend-panel'>
|
||||
<View className='section-heading'><Text className='section-title'>时长趋势</Text><View className='segmented'>{(['week', 'month'] as const).map(value => <Button key={value} size='small' variant={period === value ? 'contained' : 'text'} color={period === value ? 'primary' : 'default'} onClick={() => { setPeriod(value); setFocused('') }}>{value === 'week' ? '按周' : '按月'}</Button>)}</View></View>
|
||||
@@ -34,12 +33,11 @@ export default function Growth() {
|
||||
<Text className='field-help'>{focus ? `${focus.date} · ${duration(focus.minutes)}` : '点按柱形查看当天时长;空白表示没有记录。'}</Text>
|
||||
{!currentPeriod && <Button block variant='text' size='small' onClick={() => setAnchor(review.today)}>回到本{period === 'week' ? '周' : '月'}</Button>}
|
||||
</View>
|
||||
<View className='section-heading'><Text className='section-title'>时间花在哪里</Text><Text className='muted small'>全部练习</Text></View>
|
||||
<View className='section-heading'><Text className='section-title'>项目时长</Text><Text className='muted small'>全部练习</Text></View>
|
||||
<View className='panel'>{review.allTime.projects.length ? review.allTime.projects.map(project => <View className='distribution' key={project.projectId}>
|
||||
<View className='row'><Text className='record-name'>{project.name}{project.archived ? ' · 已归档' : ''}</Text><Text className='small'>{(project.minutes / review.allTime.totalMinutes * 100).toFixed(1)}%</Text></View>
|
||||
<View className='distribution-track'><View className='distribution-bar' style={{ width: `${project.minutes / review.allTime.totalMinutes * 100}%` }} /></View><Text className='muted small'>{duration(project.minutes)}</Text>
|
||||
</View>) : <Empty title='第一笔投入,还在前面' text='保存练习后,这里会如实显示时长和项目分布。' />}</View>
|
||||
<Text className='page-footnote'>时长记录的是投入,不是技术水平。慢慢来,按自己的节奏练习。</Text>
|
||||
</View>) : <Empty title='暂无练习数据' />}</View>
|
||||
</>}
|
||||
</View>
|
||||
}
|
||||
|
||||
@@ -1,29 +1,41 @@
|
||||
import Taro from '@tarojs/taro'
|
||||
import { Text, View } from '@tarojs/components'
|
||||
import { Button, Empty, LoadState, PageHeading, RecordCards } from '../../components/common'
|
||||
import { Image, Text, View } from '@tarojs/components'
|
||||
import addProjectIcon from '../../assets/action-icons/add-project.svg'
|
||||
import calendarIcon from '../../assets/action-icons/calendar.svg'
|
||||
import manageListIcon from '../../assets/action-icons/manage-list.svg'
|
||||
import { Button, Empty, LoadState } from '../../components/common'
|
||||
import { ProjectIcon } from '../../components/project-icon'
|
||||
import { useRemote } from '../../hooks/useRemote'
|
||||
import { api } from '../../services/practice'
|
||||
import { duration } from '../../utils/dates'
|
||||
|
||||
function hours(minutes: number) { return Number((minutes / 60).toFixed(2)) }
|
||||
|
||||
export default function Index() {
|
||||
const state = useRemote(async () => {
|
||||
const [projects, recent, review] = await Promise.all([api.projects(), api.records(), api.review('week')])
|
||||
return { ...projects, recent, review }
|
||||
const [projects, review] = await Promise.all([api.projects(), api.review('week')])
|
||||
return { ...projects, review }
|
||||
})
|
||||
const data = state.data
|
||||
const todayMinutes = data?.review.days.find(day => day.date === data.today)?.minutes || 0
|
||||
const projectMinutes = new Map(data?.review.allTime.projects.map(project => [project.projectId, project.minutes]) || [])
|
||||
const firstDate = data?.review.allTime.firstDate
|
||||
return <View className='page'>
|
||||
<PageHeading title='每一次练习,都算数' subtitle='课后记下一笔,慢慢看见自己的投入。' />
|
||||
<LoadState {...state} retry={state.reload} />
|
||||
{data && <>
|
||||
<View className='hero-card'><Text className='eyebrow'>今天的练习</Text><Text className='hero-number'>{duration(todayMinutes)}</Text><Text className='muted'>{data.today} · 按实际练习日期记录</Text></View>
|
||||
<View className='section-heading'><Text className='section-title'>这次练了什么</Text><Button size='small' variant='text' onClick={() => { void Taro.navigateTo({ url: '/pages/projects/index' }) }}>管理项目</Button></View>
|
||||
{data.projects.length ? <View className='project-grid'>{data.projects.map((project, index) => <View key={project.id} className={`project-tile project-tile--${index % 4}`} onClick={() => { void Taro.navigateTo({ url: `/pages/record/index?projectId=${project.id}&date=${data.today}` }) }}>
|
||||
<Text className='project-mark'>{String(index + 1).padStart(2, '0')}</Text><Text className='project-title'>{project.name}</Text><Text className='project-hint'>记录练习 +</Text>
|
||||
</View>)}</View> : <View className='panel'><Empty title='常用项目暂时为空' text='历史和成长仍会保留。添加一个项目,继续记录。' /><Button block color='primary' onClick={() => { void Taro.navigateTo({ url: '/pages/projects/index' }) }}>添加项目</Button></View>}
|
||||
<View className='section-heading'><Text className='section-title'>近期记录</Text><Text className='muted small'>最近 20 条</Text></View>
|
||||
{data.recent.records.length ? <RecordCards records={data.recent.records} /> : <View className='panel'><Empty title='从第一堂课开始' text='选择上面的项目,记录时长;想写的时候,再留下收获。' /></View>}
|
||||
<Button block variant='text' onClick={() => { void Taro.switchTab({ url: '/pages/calendar/index' }) }}>去日历回看或补记</Button>
|
||||
<View className='hero-card record-overview'>
|
||||
<View className='overview-column'><Text className='overview-label'>总记录时间</Text><Text className='overview-value'>{hours(data.review.allTime.totalMinutes)}<Text className='overview-unit'> h</Text></Text></View>
|
||||
<View className='overview-column overview-column--right'>
|
||||
<Text className='overview-label'>坚持天数</Text><Text className='overview-value'>{data.review.allTime.practiceDays}<Text className='overview-unit'> 天</Text></Text>
|
||||
{firstDate && <Text className='overview-start'>从 {firstDate.slice(0, 4)} 年 {firstDate.slice(5, 7)} 月 {firstDate.slice(8, 10)} 日</Text>}
|
||||
</View>
|
||||
</View>
|
||||
<View className='section-heading section-heading--actions'>
|
||||
<Button variant='text' icon={<Image className='action-icon' src={calendarIcon} mode='aspectFit' />} ariaLabel='日历' onClick={() => { void Taro.navigateTo({ url: '/pages/calendar/index' }) }} />
|
||||
<Button variant='text' icon={<Image className='action-icon' src={manageListIcon} mode='aspectFit' />} ariaLabel='管理列表' onClick={() => { void Taro.navigateTo({ url: '/pages/projects-sort/index' }) }} />
|
||||
<Button variant='text' icon={<Image className='action-icon' src={addProjectIcon} mode='aspectFit' />} ariaLabel='添加项目' onClick={() => { void Taro.navigateTo({ url: '/pages/projects/index' }) }} />
|
||||
</View>
|
||||
{data.projects.length ? <View className='project-list'>{data.projects.map((project, index) => <View key={project.id} className={`project-tile project-tile--${index % 4}`} onClick={() => { void Taro.navigateTo({ url: `/pages/record/index?projectId=${project.id}&date=${data.today}` }) }}>
|
||||
<ProjectIcon id={project.iconId} /><Text className='project-title'>{project.name}</Text><Text className='project-hours'>{hours(projectMinutes.get(project.id) || 0)}<Text className='project-hours-unit'> h</Text></Text>
|
||||
</View>)}</View> : <View className='panel'><Empty title='暂无项目' /></View>}
|
||||
</>}
|
||||
</View>
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export default definePageConfig({ navigationBarTitleText: '芭蕾岛 · 我的' })
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { Button, Image, Input, Text, View } from '@tarojs/components'
|
||||
|
||||
interface LocalProfile { nickname: string; avatarPath: string }
|
||||
|
||||
const profileKey = 'ballet-profile:v1'
|
||||
|
||||
function readProfile(): LocalProfile {
|
||||
try {
|
||||
const stored = Taro.getStorageSync<Partial<LocalProfile>>(profileKey)
|
||||
return {
|
||||
nickname: typeof stored?.nickname === 'string' ? stored.nickname : '',
|
||||
avatarPath: typeof stored?.avatarPath === 'string' ? stored.avatarPath : '',
|
||||
}
|
||||
} catch { return { nickname: '', avatarPath: '' } }
|
||||
}
|
||||
|
||||
export default function Mine() {
|
||||
const [profile, setProfile] = useState(readProfile)
|
||||
const profileRef = useRef(profile)
|
||||
const [error, setError] = useState('')
|
||||
const savingAvatar = useRef(false)
|
||||
|
||||
function updateProfile(next: LocalProfile): boolean {
|
||||
profileRef.current = next
|
||||
setProfile(next)
|
||||
try { Taro.setStorageSync(profileKey, next); setError(''); return true }
|
||||
catch { setError('资料暂时无法保存到本机。'); return false }
|
||||
}
|
||||
|
||||
async function chooseAvatar(event: { detail: { avatarUrl?: string } }) {
|
||||
const tempFilePath = event.detail.avatarUrl
|
||||
if (!tempFilePath || savingAvatar.current) return
|
||||
savingAvatar.current = true
|
||||
setError('')
|
||||
try {
|
||||
const result = await Taro.saveFile({ tempFilePath })
|
||||
if (!('savedFilePath' in result)) throw new Error('头像暂时无法保存,请重试。')
|
||||
const previous = profileRef.current.avatarPath
|
||||
const persisted = updateProfile({ ...profileRef.current, avatarPath: result.savedFilePath })
|
||||
if (persisted && previous && previous !== result.savedFilePath) {
|
||||
void Taro.removeSavedFile({ filePath: previous }).catch(() => {})
|
||||
}
|
||||
} catch { setError('头像暂时无法保存,请重试。') }
|
||||
finally { savingAvatar.current = false }
|
||||
}
|
||||
|
||||
return <View className='page'>
|
||||
<View className='profile-card'>
|
||||
<Button className='profile-avatar-button' openType='chooseAvatar' ariaLabel='选择微信头像' onChooseAvatar={event => { void chooseAvatar(event as { detail: { avatarUrl?: string } }) }}>
|
||||
{profile.avatarPath ? <Image className='profile-avatar' src={profile.avatarPath} mode='aspectFill' /> : <Text className='profile-avatar-placeholder'>微</Text>}
|
||||
</Button>
|
||||
<View className='profile-details'>
|
||||
<Input className='profile-nickname' type='nickname' value={profile.nickname} maxlength={32} placeholder='微信用户' onInput={event => updateProfile({ ...profileRef.current, nickname: event.detail.value })} />
|
||||
<Text className='profile-hint'>点击头像或昵称,选择微信资料</Text>
|
||||
</View>
|
||||
</View>
|
||||
{error && <Text className='error-banner'>{error}</Text>}
|
||||
<Text className='profile-storage-note'>头像和昵称仅保存在当前设备</Text>
|
||||
</View>
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export default definePageConfig({ navigationBarTitleText: '管理列表' })
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { Image, Text, View, type ITouchEvent } from '@tarojs/components'
|
||||
import dragHandleIcon from '../../assets/action-icons/drag-handle.svg'
|
||||
import { Empty, FormActions, LoadState, returnToSource } from '../../components/common'
|
||||
import { ProjectIcon } from '../../components/project-icon'
|
||||
import { useRemote } from '../../hooks/useRemote'
|
||||
import { api, ApiError, errorMessage, type Project } from '../../services/practice'
|
||||
|
||||
interface DragState {
|
||||
id: string
|
||||
originIndex: number
|
||||
targetIndex: number
|
||||
startY: number
|
||||
centers: number[]
|
||||
}
|
||||
|
||||
function moveProject(projects: Project[], from: number, to: number) {
|
||||
const next = [...projects]
|
||||
next.splice(to, 0, next.splice(from, 1)[0])
|
||||
return next
|
||||
}
|
||||
|
||||
export default function ProjectsSort() {
|
||||
const state = useRemote(() => api.projects())
|
||||
const [projects, setProjects] = useState<Project[]>([])
|
||||
const [dragging, setDragging] = useState<{ id: string; offset: number; targetIndex: number }>()
|
||||
const dragRef = useRef<DragState>()
|
||||
const busyRef = useRef(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => { if (state.data) setProjects(state.data.projects) }, [state.data])
|
||||
|
||||
const dirty = !!state.data && projects.length === state.data.projects.length &&
|
||||
projects.some((project, index) => project.id !== state.data!.projects[index].id)
|
||||
|
||||
function beginDrag(id: string, event: ITouchEvent) {
|
||||
if (busyRef.current || dragRef.current) return
|
||||
const startY = event.touches[0]?.clientY
|
||||
const originIndex = projects.findIndex(project => project.id === id)
|
||||
if (startY === undefined || originIndex < 0) return
|
||||
dragRef.current = { id, originIndex, targetIndex: originIndex, startY, centers: [] }
|
||||
setDragging({ id, offset: 0, targetIndex: originIndex })
|
||||
const query = Taro.createSelectorQuery()
|
||||
query.selectAll('.sortable-project').boundingClientRect(rects => {
|
||||
if (dragRef.current?.id !== id || !Array.isArray(rects)) return
|
||||
dragRef.current.centers = rects.map(rect => rect.top + rect.height / 2)
|
||||
})
|
||||
query.exec()
|
||||
}
|
||||
|
||||
function updateDrag(event: ITouchEvent) {
|
||||
const current = dragRef.current
|
||||
const y = event.touches[0]?.clientY ?? event.changedTouches[0]?.clientY
|
||||
if (!current || y === undefined) return
|
||||
const offset = y - current.startY
|
||||
if (current.centers.length === projects.length) {
|
||||
const center = current.centers[current.originIndex] + offset
|
||||
let closest = current.originIndex
|
||||
for (let index = 0; index < current.centers.length; index++) {
|
||||
if (Math.abs(current.centers[index] - center) < Math.abs(current.centers[closest] - center)) closest = index
|
||||
}
|
||||
current.targetIndex = closest
|
||||
}
|
||||
setDragging({ id: current.id, offset, targetIndex: current.targetIndex })
|
||||
}
|
||||
|
||||
function endDrag(event: ITouchEvent) {
|
||||
const current = dragRef.current
|
||||
if (!current) return
|
||||
updateDrag(event)
|
||||
dragRef.current = undefined
|
||||
setDragging(undefined)
|
||||
if (current.targetIndex !== current.originIndex) {
|
||||
setProjects(previous => moveProject(previous, current.originIndex, current.targetIndex))
|
||||
setError('')
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (busyRef.current || dragRef.current || !dirty) return
|
||||
busyRef.current = true; setBusy(true); setError('')
|
||||
try {
|
||||
await api.reorderProjects(projects.map(project => project.id))
|
||||
await returnToSource('顺序已保存')
|
||||
} catch (cause) { setError(cause instanceof ApiError && cause.status === 400 ? '项目列表已变化,请返回后重新打开排序页。' : errorMessage(cause)) }
|
||||
finally { busyRef.current = false; setBusy(false) }
|
||||
}
|
||||
|
||||
return <View className='page form-page'>
|
||||
<LoadState {...state} retry={state.reload} />
|
||||
{state.data && <>
|
||||
<Text className='sort-instruction'>按住右侧图标拖动项目,完成后保存。</Text>
|
||||
{error && <Text className='error-banner'>{error}</Text>}
|
||||
{projects.length ? <View className='sort-project-list'>
|
||||
{projects.map((project, index) => <View key={project.id} className={`sortable-project ${dragging?.id === project.id ? 'sortable-project--dragging' : ''} ${dragging && dragging.targetIndex === index && dragging.id !== project.id ? 'sortable-project--target' : ''}`} style={dragging?.id === project.id ? { transform: `translateY(${dragging.offset}px)` } : undefined}>
|
||||
<ProjectIcon id={project.iconId} /><Text className='sort-project-name'>{project.name}</Text>
|
||||
<View className='sort-drag-handle' catchMove onTouchStart={event => beginDrag(project.id, event as ITouchEvent)} onTouchMove={event => updateDrag(event as ITouchEvent)} onTouchEnd={event => endDrag(event as ITouchEvent)} onTouchCancel={() => { dragRef.current = undefined; setDragging(undefined) }}>
|
||||
<Image className='sort-drag-icon' src={dragHandleIcon} mode='aspectFit' ariaLabel='拖动排序' />
|
||||
</View>
|
||||
</View>)}
|
||||
</View> : <View className='panel'><Empty title='暂无项目' /></View>}
|
||||
<FormActions busy={busy} saveDisabled={!dirty || !!dragging} onCancel={() => returnToSource()} onSave={save} />
|
||||
</>}
|
||||
</View>
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { Input, Text, View } from '@tarojs/components'
|
||||
import { Button, Empty, LoadState, PageHeading } from '../../components/common'
|
||||
import { Button, Empty, FormActions, LoadState } from '../../components/common'
|
||||
import { ProjectIcon } from '../../components/project-icon'
|
||||
import { ProjectIconPicker } from '../../components/project-icon-picker'
|
||||
import { useRemote } from '../../hooks/useRemote'
|
||||
import { api, errorMessage, NAME_LIMIT, type Project } from '../../services/practice'
|
||||
|
||||
@@ -9,6 +11,7 @@ export default function Projects() {
|
||||
const state = useRemote(() => api.projects())
|
||||
const [editing, setEditing] = useState<Project>()
|
||||
const [name, setName] = useState('')
|
||||
const [iconId, setIconId] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const busyRef = useRef(false)
|
||||
const [error, setError] = useState('')
|
||||
@@ -20,9 +23,9 @@ export default function Projects() {
|
||||
if (!clean || Array.from(clean).length > NAME_LIMIT || clean.includes('\0')) { setError(`项目名称须为 1–${NAME_LIMIT} 字,不能只含空白或包含空字符`); return }
|
||||
busyRef.current = true; setBusy(true); setError(''); setNotice('')
|
||||
try {
|
||||
await api.saveProject(clean, editing?.id)
|
||||
setNotice(editing ? '已改名,历史记录也会使用这个名称。' : '项目已添加。')
|
||||
setName(''); setEditing(undefined)
|
||||
await api.saveProject(clean, editing?.id, iconId)
|
||||
setNotice(editing ? '项目已更新。' : '项目已添加。')
|
||||
setName(''); setIconId(''); setEditing(undefined)
|
||||
await state.reload()
|
||||
} catch (cause) { setError(errorMessage(cause)) }
|
||||
finally { busyRef.current = false; setBusy(false) }
|
||||
@@ -35,35 +38,33 @@ export default function Projects() {
|
||||
const confirmed = await Taro.showModal({
|
||||
title: project.hasRecords ? '移出常用项目?' : '移除这个项目?',
|
||||
content: project.hasRecords ? `“${project.name}”将归档,历史记录、笔记和累计时长都会保留。` : `“${project.name}”没有记录时会被删除。如果已有新记录,将改为归档并保留历史。`,
|
||||
confirmText: '确认移除', cancelText: '保留项目',
|
||||
confirmText: '移除', cancelText: '取消',
|
||||
})
|
||||
if (!confirmed.confirm) return
|
||||
const result = await api.removeProject(project.id)
|
||||
if (editing?.id === project.id) { setEditing(undefined); setName('') }
|
||||
if (editing?.id === project.id) { setEditing(undefined); setName(''); setIconId('') }
|
||||
setNotice(result.action === 'archived' ? '已归档,历史记录和时长均已保留。' : '未使用的项目已删除。')
|
||||
await state.reload()
|
||||
} catch (cause) { setError(errorMessage(cause)) }
|
||||
finally { busyRef.current = false; setBusy(false) }
|
||||
}
|
||||
|
||||
return <View className='page'>
|
||||
<PageHeading title='用你熟悉的名字' subtitle='课程会变化,留下的练习一直都在。' />
|
||||
return <View className='page form-page'>
|
||||
<View className='panel form-panel'>
|
||||
<Text className='section-title'>{editing ? '修改项目名称' : '添加练习项目'}</Text>
|
||||
<Input className='field project-name-input' value={name} maxlength={-1} disabled={busy} placeholder='例如:周末基础课' onInput={event => setName(event.detail.value)} />
|
||||
<Text className='field-label'>项目名称</Text>
|
||||
<Input className='field' value={name} maxlength={-1} disabled={busy} cursorSpacing={120} placeholder='例如:周末基础课' onInput={event => setName(event.detail.value)} />
|
||||
<Text className='field-help'>最多 {NAME_LIMIT} 字。改名后,历史记录会统一显示新名称。</Text>
|
||||
<Button block color='primary' loading={busy} disabled={busy} onClick={save}>{editing ? '保存名称' : '添加项目'}</Button>
|
||||
{editing && <Button block variant='text' disabled={busy} onClick={() => { setEditing(undefined); setName(''); setError('') }}>取消改名</Button>}
|
||||
<ProjectIconPicker value={iconId} disabled={busy} onChange={setIconId} />
|
||||
</View>
|
||||
{error && <Text className='error-banner'>{error}</Text>}
|
||||
{notice && <Text className='success-banner'>{notice}</Text>}
|
||||
<View className='section-heading'><Text className='section-title'>常用项目</Text><Text className='muted small'>{state.data ? `${state.data.projects.length} 个` : ''}</Text></View>
|
||||
<LoadState {...state} retry={state.reload} />
|
||||
{state.data && <View className='panel'>
|
||||
{state.data.projects.length ? state.data.projects.map(project => <View className='managed-project' key={project.id}>
|
||||
<Text className='record-name'>{project.name}</Text><View className='row'><Text className='muted small'>{project.hasRecords ? '已有练习 · 移除会保留历史' : '尚无练习记录'}</Text><View className='inline-actions'><Button size='small' variant='text' disabled={busy} onClick={() => { setEditing(project); setName(project.name); setError(''); void Taro.pageScrollTo({ scrollTop: 0, duration: 200 }) }}>改名</Button><Button size='small' color='danger' variant='text' disabled={busy} onClick={() => remove(project)}>移除</Button></View></View>
|
||||
</View>) : <Empty title='还没有常用项目' text='可以在上方添加项目,之前的历史仍可在日历里查看。' />}
|
||||
</View>}
|
||||
<Text className='page-footnote'>有练习记录的项目会归档,不会删除历史。归档项目仍计入成长,也能从原记录进入更正。</Text>
|
||||
{state.data && (state.data.projects.length ? <View className='project-list'>
|
||||
{state.data.projects.map(project => <View className='managed-project' key={project.id}>
|
||||
<ProjectIcon id={project.iconId} /><Text className='record-name'>{project.name}</Text><View className='inline-actions'><Button size='small' variant='text' disabled={busy} onClick={() => { setEditing(project); setName(project.name); setIconId(project.iconId || ''); setError(''); setNotice(''); void Taro.pageScrollTo({ scrollTop: 0, duration: 200 }) }}>编辑</Button><Button size='small' color='danger' variant='text' disabled={busy} onClick={() => remove(project)}>移除</Button></View>
|
||||
</View>)}
|
||||
</View> : <View className='panel'><Empty title='暂无项目' /></View>)}
|
||||
<FormActions busy={busy} onCancel={() => { setEditing(undefined); setName(''); setIconId(''); setError(''); setNotice('') }} onSave={save} />
|
||||
</View>
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import Taro, { useDidHide, useDidShow, useRouter, useUnload } from '@tarojs/taro'
|
||||
import { Input, Picker, Text, Textarea, View } from '@tarojs/components'
|
||||
import { Button, LoadState, PageHeading, returnToSource } from '../../components/common'
|
||||
import { Button, FormActions, LoadState, returnToSource } from '../../components/common'
|
||||
import { useRemote } from '../../hooks/useRemote'
|
||||
import { api, ApiError, clearPendingRecord, errorMessage, getPendingRecord, MINUTE_LIMIT, NOTE_LIMIT, retainPendingRecord, submissionID, type PendingRecord } from '../../services/practice'
|
||||
import { validDate } from '../../utils/dates'
|
||||
@@ -93,7 +93,7 @@ export default function RecordForm() {
|
||||
if (!id || busyRef.current || uncertain) return
|
||||
busyRef.current = true; setBusy(true)
|
||||
try {
|
||||
const result = await Taro.showModal({ title: '删除这条误记?', content: '这条记录的时长和笔记将被删除,日历与成长统计会一起更新。', confirmText: '删除记录', confirmColor: '#a23d4c', cancelText: '保留记录' })
|
||||
const result = await Taro.showModal({ title: '删除这条误记?', content: '这条记录的时长和笔记将被删除,日历与成长统计会一起更新。', confirmText: '删除', confirmColor: '#a23d4c', cancelText: '取消' })
|
||||
if (!result.confirm) return
|
||||
setError('')
|
||||
await api.deleteRecord(id)
|
||||
@@ -103,7 +103,6 @@ export default function RecordForm() {
|
||||
}
|
||||
|
||||
return <View className='page form-page'>
|
||||
<PageHeading title={id ? '更正这次练习' : '记下一次练习'} subtitle='一堂课记一笔,额外练习也可以单独记。' />
|
||||
{!context && <LoadState {...state} retry={state.reload} />}
|
||||
{context && !done && <>
|
||||
{resumed.current && uncertain && <Text className='error-banner'>上次保存尚未确认,已恢复当时的项目、日期和填写内容。请先重试确认这次保存。</Text>}
|
||||
@@ -112,20 +111,20 @@ export default function RecordForm() {
|
||||
<Picker mode='selector' range={['请选择项目', ...choices.map(project => `${project.name}${project.archived ? '(已归档)' : ''}`)]} value={choices.findIndex(project => project.id === projectId) + 1} disabled={locked || !choices.length} onChange={event => setProjectId(choices[Number(event.detail.value) - 1]?.id || '')}>
|
||||
<View className='field picker-field'>{selected ? `${selected.name}${selected.archived ? '(已归档)' : ''}` : '请选择项目'}<Text>⌄</Text></View>
|
||||
</Picker>
|
||||
{!choices.length && <View className='field-help'><Text>先添加一个项目,就能开始记录。</Text><Button size='small' variant='text' onClick={() => { void Taro.navigateTo({ url: '/pages/projects/index' }) }}>添加项目</Button></View>}
|
||||
{!choices.length && <View className='field-help'><Button size='small' variant='text' onClick={() => { void Taro.navigateTo({ url: '/pages/projects/index' }) }}>添加项目</Button></View>}
|
||||
{selected?.archived && <Text className='field-help'>这条历史记录可以保留原项目,也可改选常用项目。</Text>}
|
||||
<Text className='field-label'>实际练习日期</Text>
|
||||
<Picker mode='date' value={date || context.today} start='0001-01-01' end={context.today} disabled={locked} onChange={event => setDate(event.detail.value)}><View className='field picker-field'>{date}<Text>⌄</Text></View></Picker>
|
||||
<Text className='field-label'>整堂时长 · 分钟</Text>
|
||||
<Input className='field' type='number' value={minutes} maxlength={10} disabled={locked} placeholder='例如 90' onInput={event => setMinutes(event.detail.value)} />
|
||||
<Input className='field' type='number' value={minutes} maxlength={10} disabled={locked} cursorSpacing={120} placeholder='例如 90' onInput={event => setMinutes(event.detail.value)} />
|
||||
<View className='quick-minutes'>{[30, 45, 60, 90].map(value => <Button key={value} size='small' variant='outlined' disabled={locked} onClick={() => setMinutes(String(value))}>{value} 分钟</Button>)}</View>
|
||||
<Text className='field-label'>练习笔记 <Text className='muted small'>选填</Text></Text>
|
||||
<Textarea className='field note-input' value={note} maxlength={-1} disabled={locked} cursorSpacing={24} placeholder='老师的提醒、今天的感受,想写什么都可以。' onInput={event => setNote(event.detail.value)} />
|
||||
<Textarea className='field note-input' value={note} maxlength={-1} disabled={locked} cursorSpacing={120} placeholder='记录练习笔记' onInput={event => setNote(event.detail.value)} />
|
||||
<Text className='field-counter'>{Array.from(note).length} / {NOTE_LIMIT}</Text>
|
||||
</View>
|
||||
{error && <Text className='error-banner'>{error}</Text>}
|
||||
<View className='form-actions'><Button color='primary' block loading={busy} disabled={busy || (!pending.current && !choices.length)} onClick={save}>{uncertain ? '重试并确认保存结果' : '保存练习'}</Button><Button block variant='text' disabled={locked} onClick={() => { void returnToSource() }}>取消并返回</Button></View>
|
||||
{id && <Button color='danger' variant='text' block disabled={busy || uncertain} onClick={remove}>删除这条误记</Button>}
|
||||
{id && <Button color='danger' variant='text' block disabled={locked} onClick={remove}>删除</Button>}
|
||||
<FormActions busy={busy} cancelDisabled={uncertain} saveDisabled={!pending.current && !choices.length} saveLabel={uncertain ? '重试' : '保存'} onCancel={() => returnToSource()} onSave={save} />
|
||||
</>}
|
||||
{done && <View className='panel status-panel'><Text>{done}</Text><Button color='primary' block onClick={() => { void returnToSource() }}>返回</Button></View>}
|
||||
</View>
|
||||
|
||||
@@ -4,7 +4,7 @@ export const NAME_LIMIT = 40
|
||||
export const NOTE_LIMIT = 2000
|
||||
export const MINUTE_LIMIT = 2147483647
|
||||
|
||||
export interface Project { id: string; name: string; archived: boolean; hasRecords: boolean }
|
||||
export interface Project { id: string; name: string; iconId?: string; archived: boolean; hasRecords: boolean }
|
||||
export interface PracticeRecord {
|
||||
id: string; projectId: string; projectName: string; archived: boolean
|
||||
date: string; minutes: number; note: string
|
||||
@@ -12,7 +12,7 @@ export interface PracticeRecord {
|
||||
export interface RecordInput { projectId: string; date: string; minutes: number; note: string; requestId?: string }
|
||||
export interface RecordList { records: PracticeRecord[]; nextCursor: string }
|
||||
export interface ProjectMinutes { projectId: string; name: string; archived: boolean; minutes: number }
|
||||
export interface Summary { totalMinutes: number; practiceDays: number; recordCount: number; projects: ProjectMinutes[] }
|
||||
export interface Summary { totalMinutes: number; practiceDays: number; firstDate: string; recordCount: number; projects: ProjectMinutes[] }
|
||||
export interface Review {
|
||||
today: string; from: string; to: string; allTime: Summary; period: Summary
|
||||
days: { date: string; minutes: number }[]
|
||||
@@ -104,7 +104,8 @@ function query(values: Record<string, string | number | undefined>) {
|
||||
|
||||
export const api = {
|
||||
projects: (includeArchived = false) => request<{ projects: Project[]; today: string }>(`/projects?includeArchived=${includeArchived}`),
|
||||
saveProject: (name: string, id?: string) => request<Project>(id ? `/projects/${id}` : '/projects', id ? 'PUT' : 'POST', { name }),
|
||||
reorderProjects: (projectIds: string[]) => request<{ action: 'reordered' }>('/projects/order', 'PUT', { projectIds }),
|
||||
saveProject: (name: string, id?: string, iconId?: string) => request<Project>(id ? `/projects/${id}` : '/projects', id ? 'PUT' : 'POST', { name, ...(iconId !== undefined ? { iconId } : {}) }),
|
||||
removeProject: (id: string) => request<{ action: 'deleted' | 'archived' }>(`/projects/${id}`, 'DELETE'),
|
||||
records: (options: { from?: string; to?: string; cursor?: string; limit?: number } = {}) => request<RecordList>(`/records?${query(options)}`),
|
||||
record: (id: string) => request<PracticeRecord>(`/records/${id}`),
|
||||
|
||||
@@ -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: '' } },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||