mirror of
https://github.com/wanguliux/workout-block.git
synced 2026-07-22 08:33:12 +00:00
chore: initialize repository with plugin source and muscle-analysis tooling
This commit is contained in:
commit
10775a2888
69 changed files with 17936 additions and 0 deletions
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# 依赖(由 npm install 生成,不入库)
|
||||
node_modules/
|
||||
|
||||
# 构建产物(由 esbuild 生成,不入库;发布时再构建 / 用 GitHub Release)
|
||||
main.js
|
||||
*.js.map
|
||||
dist/
|
||||
|
||||
# 本地发布归档(与根目录 main.js/manifest.json 完全重复;用 git tag / GitHub Release 管理历史版本)
|
||||
版本/
|
||||
|
||||
# 系统 / 编辑器临时文件
|
||||
.DS_Store
|
||||
*.log
|
||||
Thumbs.db
|
||||
.obsidian/
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 wanguliu
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
271
README.md
Normal file
271
README.md
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
# 训练块 Workout Block
|
||||
|
||||
一款以**「极致的自由度」**为核心的 Obsidian 训练记录插件。它不预设任何训练体系——你**自己定义训练类型**、**记录任何你想记录的数据**、**对记录字段做任意衍生计算**;训练计划、肌肉管理、肌肉热力图同样全部由你按自己的方式配置。所有内容以**代码块**形式呈现,渲染与数据层高度解耦。数据以纯文本(CSV / JSON)存放在你的 vault 中,可被 Dataview 等工具直接查询。
|
||||
|
||||
> **核心主张**:没有「固定字段」、没有「标准动作库」、没有「官方模板」。插件只提供一套可无限自定义的骨架,怎么用完全由你决定。
|
||||
|
||||
> 插件 ID:`workout-block` | 最低 Obsidian 版本:`1.5.0` | 许可证:MIT | 语言:中文 / English
|
||||
|
||||
---
|
||||
|
||||
## 🧭 设计哲学:一切皆可自定义
|
||||
|
||||
多数训练 App 把字段、动作、计划写死,你只能被动适配。本插件反其道而行——它把「定义权」还给你:
|
||||
|
||||
| 你决定什么 | 怎么决定 | 文档/代码 |
|
||||
|------------|----------|-----------|
|
||||
| 有哪些**训练类型** | 完全自建,如「力量 / 有氧 / 自重 / 敏捷 / 攀岩」任意增删 | 记录录入 §5 |
|
||||
| 每种类型**记录哪些字段** | 任意数量、任意组合(数字 / 时长 / 文本 / 下拉),无系统约束 | 同上 |
|
||||
| 字段**单位** | 重量自动 kg/lb 换算,或自由文本单位(次 / 公里 / 层 / 圈…) | 同上 |
|
||||
| 记录字段的**衍生数据** | 像配训练类型一样配「数据统计」,引导式或写表达式 `sum(reps*weight)` | 数据统计设计 |
|
||||
| **训练计划**怎么排 | 每个训练项、每一组的目标量自定义;按具体日期或每周周几执行 | 训练计划设计 |
|
||||
| **肌肉怎么管** | 任意颗粒度:一块肌肉可映射到 1 条或 N 条解剖路径 | 肌肉管理设计 |
|
||||
| **热力图**画什么 | 逐肌选指标 / 时间窗 / 颜色分档,医学解剖级人体图 | 同上 |
|
||||
|
||||
下面分模块展开。
|
||||
|
||||
---
|
||||
|
||||
## ✨ 功能特性
|
||||
|
||||
### 1. 完全自定义的训练类型 —— 你练什么,由你定义
|
||||
|
||||
训练类型(如「力量」「有氧」「自重」)只是种子默认值,**你可以新建任意类型**,并为每个类型配置它包含的字段。系统**不预设**「某类型必须有哪些字段」,字段的「数量、控件、单位」完全由你决定。
|
||||
|
||||
- 支持 **4 种输入控件**自由组合:`number`(数字)/ `duration`(时·分·秒,存储为秒)/ `text`(文本)/ `select`(下拉,可自定义选项)。
|
||||
- 每个类型至少 1 个字段,可任意增删。
|
||||
- 改一个类型,所有「新建训练项 / 记录」的录入界面随之改变(字段由类型的 `fields` 动态渲染)。
|
||||
|
||||
**自定义示例**:新建「敏捷训练」类型,字段设为 `组数`(数字) + `距离`(数字,单位「公里」) + `组间休息`(下拉:30s / 60s / 90s)。—— 换作力量训练,也可以只有 `重量` + `次数`。没有限制。
|
||||
|
||||
### 2. 记录任何你想训练的数据 —— 自由度落到「每一组」
|
||||
|
||||
记录的最小颗粒度是**「一组」**(如「卧推 60kg × 8 次」= CSV 一行)。录入时填写的字段**完全由所选训练类型决定**,所以你定义了多少种类型、每个类型有多少字段,就能记录多少种数据。
|
||||
|
||||
- **上次值记忆**:录入下一组时自动带出该训练项上一次的数值,省去重复输入(可在设置关闭)。
|
||||
- **模糊匹配**:训练项搜索支持子串匹配,随手输入即可命中。
|
||||
- 所有数据落盘为纯文本,字段以 JSON 存于 `fields` 列,表结构与类型定义解耦——新增类型 / 字段不会破坏历史记录。
|
||||
|
||||
### 3. 对记录字段做任意衍生计算 —— 你想要的指标,自己算
|
||||
|
||||
内置的「总组数」只是系统预置的**一条普通统计**,你完全可以删掉它,定义自己关心的派生指标。
|
||||
|
||||
- **双模式公式**:
|
||||
- 引导式构建器:求和 `sum` / 乘积求和 `Σ(a×b)` / 平均·最大·最小 / 计数 `count`,点选即可。
|
||||
- 自由表达式(高级):直接写 `sum(reps * weight)`、`avg(weight)`、`max(weight)` 等。
|
||||
- **关联训练类型**:一条统计可关联一个或多个类型,只在对应类型的代码块里出现(如「总训练量」同时关联力量与自重)。
|
||||
- **安全**:内置受限求值器,禁用 `eval` / `Function`,函数白名单 + 字段引用 + 四则运算,保存前做语法与合法性校验,非法公式拦截保存。
|
||||
- 统计结果**渲染时实时计算**,不写回 CSV、不污染原始数据。
|
||||
|
||||
### 4. 极高自由度的训练计划 —— 排你自己的练法
|
||||
|
||||
训练计划不是被模板框死的,而是完全可配的训练方案实例:
|
||||
|
||||
- **逐组目标量**:每个训练项、每一组都可以预设不同的目标字段值(因为字段是你自定义的,每组可能不同)。
|
||||
- **灵活的计划时间**:具体到某一天,或「每周一、三、五」这类 ISO 周几循环。
|
||||
- **基于方案快速建计划**:扫描含 `workout-plan` 代码块的笔记作为来源,一键合并训练项;也支持手动添加方案外的项目、单独增删任意训练组。
|
||||
- **笔记即方案**:含 `workout-plan` 代码块的笔记天然就是训练方案,无需额外实体。
|
||||
- **完成态独立持久化**:在代码块里逐组点「完成」即写入记录;完成状态存于配置、独立于训练记录,**完成即完成,不做按日 / 周重置**,删除记录不影响完成态。
|
||||
|
||||
### 5. 任意颗粒度的肌肉管理 —— 从「一大块肩膀」到「前/中/后束」
|
||||
|
||||
肌肉与人体示意图 SVG 的关系是 **1 块肌肉 → N 条 SVG 路径**的可配置映射,精细度由你定:
|
||||
|
||||
- 新手只想看「肩膀」一大块;教练要区分「三角肌前 / 中 / 后束」分别着色——同一个插件都支持。
|
||||
- **首次引导三档导入**(只是初份配置,不是锁死的「模式」):
|
||||
- **默认**:13 块基础肌肉,按健身群映射到全部解剖路径(最完整,推荐)。
|
||||
- **精简**:每块只映射代表性主路径,图表更干净。
|
||||
- **手动**:映射留空,由你逐个勾选。
|
||||
- 导入后随时在编辑弹窗增删映射,「想改就改,不被预设绑架」。
|
||||
- 双语肌肉目录(中文 / 英文解剖名)共 **143 条**可映射路径,配搜索框应对规模。
|
||||
|
||||
### 6. 医学解剖级的肌肉热力图 —— 看见身体的强弱
|
||||
|
||||
基于**完整正 / 背面人体解剖 SVG**(医学解剖命名,源自 flutter-body-atlas,CC BY 4.0)渲染全身肌肉负荷:
|
||||
|
||||
- 正 / 背面一键切换,两份 SVG 均内联,切换只改显示不重算。
|
||||
- 按训练量(默认「次数」,可逐肌改)给各肌肉着色,颜色分级逐肌可配(级数 ≤ 99,每级颜色与阈值自定义十六进制)。
|
||||
- **三级回退**:肌肉级 → 代码块级(`metric` / `range` 参数)→ 全局默认,每块肌肉都能单独定「用什么指标、看多久」。
|
||||
- 加权着色:主练权重 1.0、辅练 0.5 累加;绝对阈值着色(非全图归一化),多肌命中同路径取贡献最大者分档。
|
||||
- 进入视口才计算上色(懒渲染),主线程负担低。
|
||||
|
||||
### 7. 代码块呈现、高度解耦 —— 内容长在笔记里
|
||||
|
||||
插件接管四类围栏代码块的渲染,直接在笔记里写即可,渲染与数据层完全分离:
|
||||
|
||||
| 代码块 | 视角 |
|
||||
|--------|------|
|
||||
| `workout-log` | 单动作历史表 + 分组聚合统计 |
|
||||
| `workout-day` | 当日训练总览(项目 / 统计值 / 主辅肌群 / 方案) |
|
||||
| `workout-plan` | 训练计划完成面板(逐组点完成) |
|
||||
| `workout-heatmap` | 全身肌肉负荷热力图(正/背切换) |
|
||||
|
||||
- **可扩展注册表**:内部维护代码块类型注册表,新增一种代码块 = 写一个 handler 并 `registerCodeBlock`,**无需改动既有逻辑**。
|
||||
- **精准重渲染**:数据变更时只重画含该训练项的代码块(`rerenderBlocksForExercise`),避免全量卡顿;语言切换 / 外部改文件才全局刷新。
|
||||
- 预览模式与阅读模式均生效,样式作用域隔离,不污染 vault 全局。
|
||||
|
||||
### 8. 中英双语 & 纯文本数据
|
||||
|
||||
- 界面随 Obsidian 语言切换,切换即重渲染,代码块表格 / 热力图文字 / 时长 token 均干净无残留。
|
||||
- 所有数据存于 vault 内的文本文件(CSV + JSON),不使用任何私有二进制格式,可进 Git、随手备份、被 Dataview 等工具直接查询。
|
||||
|
||||
---
|
||||
|
||||
## 📦 安装
|
||||
|
||||
### 方式一:BRAT(推荐,支持自动更新)
|
||||
|
||||
1. 在 Obsidian 社区插件市场安装 **BRAT**。
|
||||
2. 打开 BRAT 设置 → `Add a beta plugin`,填入本仓库地址。
|
||||
3. 在「社区插件」中启用 **训练块 Workout Block**。
|
||||
|
||||
### 方式二:手动安装
|
||||
|
||||
1. 从 Releases 或仓库根目录下载 `main.js` 与 `manifest.json`。
|
||||
2. 把它们放进你的 vault:`<vault>/.obsidian/plugins/workout-block/`。
|
||||
3. 在「社区插件」中启用本插件。
|
||||
|
||||
> 首次启动会自动写入默认训练类型、肌肉与统计配置,热力图开箱即用,无需手动初始化。
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速上手
|
||||
|
||||
启用插件后:
|
||||
|
||||
- 点击左侧栏的 **哑铃图标**,或命令面板(`Ctrl/Cmd + P`)搜索「记录一组」,即可录入一条训练。
|
||||
- 想长期跟踪某训练项,在笔记里写一个 `workout-log` 代码块(见下)。
|
||||
- 想自定义体系:打开设置 → 训练块 → 对应的「管理」弹窗,新建训练类型 / 训练项 / 训练计划 / 统计 / 肌肉映射。
|
||||
|
||||
---
|
||||
|
||||
## 📝 代码块(Code Blocks)
|
||||
|
||||
插件接管了以下四种围栏代码块的渲染,直接在笔记里写即可。
|
||||
|
||||
### `workout-log` —— 训练记录表(按动作看历史)
|
||||
|
||||
展示训练记录,可点「记录 / 编辑 / 删除」交互操作;分组上方显示该组聚合统计。
|
||||
|
||||
| 参数 | 说明 | 默认 |
|
||||
|------|------|------|
|
||||
| `exercise` | 仅显示指定训练项(按名称) | 显示全部 |
|
||||
| `limit` / `number` | 最多显示条数 | 50 |
|
||||
| `day` | 仅显示最近 N 天的记录 | — |
|
||||
| `group_by` | `date`(按天)/ `week`(年-周) | `date` |
|
||||
| `sort` | `desc` / `asc` | `desc` |
|
||||
| `show_add` | 是否显示顶部「添加记录」按钮 | `true` |
|
||||
|
||||
````markdown
|
||||
```workout-log
|
||||
exercise: 深蹲
|
||||
limit: 20
|
||||
```
|
||||
````
|
||||
|
||||
### `workout-day` —— 当日训练总览
|
||||
|
||||
按天汇总「练了哪些项目」,表格列为:项目 / 数据统计值 / 主肌群 / 辅助肌群 / 训练方案。
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `day: 2026-07-12` | 查询指定日期 |
|
||||
| `day: today` | 显示当日(活数据,随日期滚动) |
|
||||
| 不写 `day` | 同样显示当日,并提供「固定为当日」按钮写回日期 |
|
||||
|
||||
````markdown
|
||||
```workout-day
|
||||
day: 2026-07-12
|
||||
```
|
||||
````
|
||||
|
||||
### `workout-heatmap` —— 肌肉热力图
|
||||
|
||||
渲染完整人体肌肉图,按训练量着色。代码块上方有 **正面 / 背面** 切换按钮;颜色分级(默认 4 级:蓝 / 绿 / 橙 / 红)的级数、每级颜色与阈值,均可在「肌肉管理」里逐肌肉自定义。
|
||||
|
||||
| 参数 | 说明 | 默认 |
|
||||
|------|------|------|
|
||||
| `metric` | 引用一条数据统计配置(如「次数」) | 全局默认(次数) |
|
||||
| `range` | `7d` / `30d` / `90d` / `all` / 日期区间 | 全局默认(7d) |
|
||||
|
||||
````markdown
|
||||
```workout-heatmap
|
||||
metric: 次数
|
||||
range: 7d
|
||||
```
|
||||
````
|
||||
|
||||
### `workout-plan` —— 训练计划完成面板
|
||||
|
||||
跟踪某个训练方案的完成进度,逐组标记完成。
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `plan: 计划名` | 指定要展示的训练方案;不写则显示「选择计划」下拉,选中后自动把计划名写回代码块 |
|
||||
|
||||
````markdown
|
||||
```workout-plan
|
||||
plan: 推日 A
|
||||
```
|
||||
````
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 设置与数据管理
|
||||
|
||||
在设置页(`Ctrl/Cmd + ,` → 训练块)可配置:
|
||||
|
||||
- **训练类型 / 训练项 / 肌肉 / 统计值 / 训练计划**:均在对应的「管理」弹窗中增删改——这是插件「自由度」的总开关。
|
||||
- **语言**:中文 / English。
|
||||
- **重量单位**:kg / lb(影响重量类字段的展示与换算)。
|
||||
- **上次值记忆**:开关。
|
||||
- **数据目录**:训练记录 CSV 的存放位置(默认 vault 根目录)。
|
||||
- **压缩清理 CSV**:删除训练记录采用「软删除」(界面即时移除,磁盘空间延迟回收);点此按钮真正压缩文件、回收空间。
|
||||
|
||||
### 数据存储位置
|
||||
|
||||
| 文件 | 内容 |
|
||||
|------|------|
|
||||
| `workout_logs.csv` | 所有训练记录(纯 CSV,便于 Dataview 查询) |
|
||||
| `workout-config.json` | 配置:训练类型、训练项、肌肉及其 SVG 映射、统计值、训练计划 |
|
||||
|
||||
两套数据都位于 vault 内(数据目录可在设置中更改),均为纯文本,可放进 Git 或随手备份。
|
||||
|
||||
---
|
||||
|
||||
## 🔧 开发
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 开发模式(监听改动,不压缩)
|
||||
npm run dev
|
||||
|
||||
# 生产构建(输出根目录 main.js)
|
||||
npm run build
|
||||
|
||||
# 运行测试
|
||||
npm test
|
||||
|
||||
# 测试覆盖率
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
构建产物为根目录的 `main.js`,配合 `manifest.json` 即可作为插件运行。
|
||||
|
||||
### 技术栈
|
||||
|
||||
- TypeScript + [esbuild](https://esbuild.github.io/)(打包)
|
||||
- [Vitest](https://vitest.dev/) + jsdom(单元测试)
|
||||
- [papaparse](https://www.papaparse.com/)(CSV 读写)
|
||||
- Obsidian API
|
||||
|
||||
---
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
MIT —— 可自由使用、修改与分发。仓库根目录已附带 `LICENSE` 文件(MIT 全文),以便 GitHub 自动识别许可证。
|
||||
|
||||
插件打包的肌肉 SVG 插图来自第三方,遵循 **CC BY 4.0**(作者 Ryan Graves)与 **BSD-3-Clause**(flutter-body-atlas, Kit G.)。署名与许可详情见 [THIRD-PARTY-NOTICES](./THIRD-PARTY-NOTICES)。
|
||||
|
||||
55
THIRD-PARTY-NOTICES
Normal file
55
THIRD-PARTY-NOTICES
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
Third-Party Notices
|
||||
====================
|
||||
|
||||
This project bundles third-party assets. Their licenses are reproduced below.
|
||||
|
||||
|
||||
Muscle illustrations
|
||||
--------------------
|
||||
Files:
|
||||
src/assets/muscle_layer_front.svg
|
||||
src/assets/muscle_layer_back.svg
|
||||
src/assets/muscle_layer_front_nohead.svg
|
||||
|
||||
Original artwork : Figma Muscle Atlas by Ryan Graves
|
||||
Bundled via : flutter-body-atlas (https://github.com/kit-g/flutter-body-atlas)
|
||||
License : Creative Commons Attribution 4.0 International (CC BY 4.0)
|
||||
https://creativecommons.org/licenses/by/4.0/
|
||||
|
||||
CC BY 4.0 attribution requirements satisfied here:
|
||||
- Author credited : Ryan Graves
|
||||
- License linked : CC BY 4.0 (see URL above)
|
||||
- Modifications : The head region was removed via SVG path id filtering.
|
||||
- No endorsement by the original author is implied.
|
||||
|
||||
|
||||
flutter-body-atlas code wrapper
|
||||
-------------------------------
|
||||
The repository above is distributed under the BSD 3-Clause License.
|
||||
|
||||
Copyright (c) 2026 Kit G.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice,
|
||||
this list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its contributors
|
||||
may be used to endorse or promote products derived from this software
|
||||
without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
47
esbuild.config.mjs
Normal file
47
esbuild.config.mjs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* esbuild.config.mjs(打包脚本)
|
||||
* 文件作用:用 esbuild 把 TypeScript 源码打包成 Obsidian 能加载的单个 main.js。
|
||||
* 概念解释:Obsidian 插件最终运行的是 main.js,本脚本负责把 src/main.ts 及其依赖"打包/压缩"成它。
|
||||
* 用法:npm run build(带 --production 压缩)或 npm run dev(watch 监听改动热重载)。
|
||||
*/
|
||||
|
||||
import esbuild from 'esbuild';
|
||||
import process from 'process';
|
||||
import fs from 'fs';
|
||||
|
||||
// banner:生成文件顶部的注释块,声明这是由 esbuild 自动生成的文件(提醒不要直接手改 main.js)
|
||||
const banner = `/*
|
||||
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
|
||||
if you want to view the source, please visit the github repository of this plugin
|
||||
*/
|
||||
`;
|
||||
|
||||
// 是否生产模式:命令行带 --production 时走压缩,否则进入监听模式
|
||||
const prod = process.argv.includes('--production');
|
||||
|
||||
const context = await esbuild.context({
|
||||
banner: {
|
||||
js: banner,
|
||||
},
|
||||
entryPoints: ['src/main.ts'], // 打包入口:从 main.ts 开始把依赖都收进来
|
||||
bundle: true, // bundle=true:把所有 import 合并成一个文件
|
||||
external: ['obsidian'], // external:obsidian 由 Obsidian 运行时提供,不要打进包里(减小体积、避免重复)
|
||||
format: 'cjs', // 输出 CommonJS 格式(Obsidian 加载插件用的模块格式)
|
||||
target: 'ES2018', // 目标语法级别,兼容主流环境
|
||||
logLevel: 'info',
|
||||
sourcemap: prod ? false : 'inline', // 非生产模式生成内联 sourcemap,便于调试时定位源码
|
||||
treeShaking: true, // 摇树优化:删除未被引用的无用代码
|
||||
outfile: 'main.js', // 输出文件:Obsidian 加载的就是它
|
||||
loader: {
|
||||
'.svg': 'text', // SVG 作为字符串内联,便于热力图直接插入 DOM
|
||||
},
|
||||
});
|
||||
|
||||
if (prod) {
|
||||
// 生产模式:一次性重新构建后退出
|
||||
await context.rebuild();
|
||||
process.exit(0);
|
||||
} else {
|
||||
// 开发模式:持续监听文件改动并自动重新打包(热重载)
|
||||
await context.watch();
|
||||
}
|
||||
10
manifest.json
Normal file
10
manifest.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"id": "workout-block",
|
||||
"name": "训练块 Workout Block",
|
||||
"version": "1.0.0",
|
||||
"minAppVersion": "1.5.0",
|
||||
"description": "Obsidian 训练记录插件:记录力量/有氧/自重训练,支持完全自定义训练类型、肌肉映射、热力图与训练计划;内置移动端适配与代码块快捷插入。",
|
||||
"author": "wanguliux",
|
||||
"authorUrl": "https://github.com/wanguliux",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
215
muscle_analysis/analyze.py
Normal file
215
muscle_analysis/analyze.py
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import re, os
|
||||
|
||||
ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# Non-muscle base layers / body-part ids to exclude
|
||||
EXCLUDE = {
|
||||
'underlayer', 'non_muscle',
|
||||
'foot_l', 'foot_r', 'ankle_l', 'ankle_r',
|
||||
'hand_l', 'hand_r', 'hands', 'wrist_l', 'wrist_r', 'palm_l', 'palm_r',
|
||||
'head', 'face',
|
||||
}
|
||||
# Top-level <g> containers (grouping wrappers, not individual muscles)
|
||||
CONTAINERS = {
|
||||
'legs', 'adductors', 'glutes', 'arms', 'core', 'back',
|
||||
'shoulders', 'chest', 'neck', 'hamstrings',
|
||||
}
|
||||
|
||||
# Chinese anatomical names for the muscle base (after stripping side/segment suffixes)
|
||||
BASE_CN = {
|
||||
'tibialis_anterior': '胫骨前肌',
|
||||
'extensor_hallucis_longus': '拇长伸肌',
|
||||
'fibularis_longus': '腓骨长肌',
|
||||
'extensor_digitorum_longus': '趾长伸肌',
|
||||
'gastrocnemius': '腓肠肌',
|
||||
'semitendinosus': '半腱肌',
|
||||
'vastus_lateralis': '股外侧肌',
|
||||
'vastus_medialis': '股内侧肌',
|
||||
'sartoris': '缝匠肌', # SVG typo for sartorius
|
||||
'sartorius': '缝匠肌',
|
||||
'gracilis': '股薄肌',
|
||||
'rectus_femoris': '股直肌',
|
||||
'iliotibial_tract': '髂胫束(筋膜)',
|
||||
'adductor_longus': '长收肌',
|
||||
'pectineus': '耻骨肌',
|
||||
'adductor_magnus': '大收肌',
|
||||
'gluteus_medius': '臀中肌',
|
||||
'gluteus_maximus': '臀大肌',
|
||||
'flexor_digitorum_superficialis': '指浅屈肌',
|
||||
'pronator_quadratus': '旋前方肌',
|
||||
'extensor_carpi_radialis_longus': '桡侧腕长伸肌',
|
||||
'palmaris_longus': '掌长肌',
|
||||
'flexor_carpi_radialis': '桡侧腕屈肌',
|
||||
'pronator_teres': '旋前圆肌',
|
||||
'triceps_brachii': '肱三头肌',
|
||||
'brachioradialis': '肱桡肌',
|
||||
'biceps_brachii': '肱二头肌',
|
||||
'external_oblique': '腹外斜肌',
|
||||
'rectus_abdominis': '腹直肌',
|
||||
'latissimus_dorsi': '背阔肌',
|
||||
'lateral_deltoid': '三角肌(中束)',
|
||||
'trapezius_upper': '斜方肌(上束)',
|
||||
'trapezius_middle': '斜方肌(中束)',
|
||||
'trapezius_lower': '斜方肌(下束)',
|
||||
'anterior_deltoid': '三角肌(前束)',
|
||||
'posterior_deltoid': '三角肌(后束)',
|
||||
'pectoralis_major': '胸大肌',
|
||||
'platysma': '颈阔肌',
|
||||
'sternohyoid': '胸骨舌骨肌',
|
||||
'sternocleidomastoid': '胸锁乳突肌',
|
||||
'infraspinatus': '冈下肌',
|
||||
'semimembranosus': '半膜肌',
|
||||
'biceps_femoris': '股二头肌',
|
||||
'extensor_digitorum': '指总伸肌',
|
||||
'extensor_carpi_ulnaris': '尺侧腕伸肌',
|
||||
'anconeus': '肘肌',
|
||||
'flexor_carpi_ulnaris': '尺侧腕屈肌',
|
||||
}
|
||||
|
||||
def base_name(mid):
|
||||
"""Strip side (_l/_r) and keep head/segment info minimal -> base muscle key."""
|
||||
s = mid.lower()
|
||||
# triceps/biceps heads: keep just the muscle name (merge heads for grouping)
|
||||
if s.startswith('triceps_brachii'):
|
||||
return 'triceps_brachii'
|
||||
if s.startswith('biceps_brachii'):
|
||||
return 'biceps_brachii'
|
||||
# strip trailing _l / _r
|
||||
s = re.sub(r'_[lr]$', '', s)
|
||||
# strip numeric segment suffixes like _1 _2 and _r/_l already removed
|
||||
s = re.sub(r'_\d+$', '', s)
|
||||
return s
|
||||
|
||||
def fitness_group(mid):
|
||||
s = mid.lower()
|
||||
if 'pectoralis_major' in s: return '胸部'
|
||||
if s.startswith('trapezius'): return '斜方肌'
|
||||
if 'latissimus_dorsi' in s or 'infraspinatus' in s: return '背部'
|
||||
if 'deltoid' in s: return '肩部(三角肌)'
|
||||
if 'biceps_brachii' in s: return '肱二头肌'
|
||||
if 'triceps_brachii' in s: return '肱三头肌'
|
||||
if any(k in s for k in ['gastrocnemius','tibialis','fibularis','extensor_hallucis','extensor_digitorum_longus']):
|
||||
return '小腿'
|
||||
if any(k in s for k in ['brachioradialis','flexor_digitorum','pronator','extensor_carpi',
|
||||
'palmaris','flexor_carpi','extensor_digitorum','anconeus']):
|
||||
return '前臂'
|
||||
if 'rectus_abdominis' in s or 'external_oblique' in s: return '腹肌/核心'
|
||||
if 'gluteus' in s: return '臀部'
|
||||
if any(k in s for k in ['rectus_femoris','vastus','sartoris','sartorius','iliotibial']):
|
||||
return '股四头肌'
|
||||
if any(k in s for k in ['semitendinosus','semimembranosus','biceps_femoris']): return '腘绳肌'
|
||||
if any(k in s for k in ['adductor','pectineus','gracilis']): return '内收肌'
|
||||
if any(k in s for k in ['sternocleidomastoid','platysma','sternohyoid']): return '颈部'
|
||||
return '其他'
|
||||
|
||||
def parse_ids(path):
|
||||
with open(path, encoding='utf-8') as f:
|
||||
txt = f.read()
|
||||
return re.findall(r'\bid="([^"]+)"', txt)
|
||||
|
||||
files = {
|
||||
'正面': os.path.join(ROOT, 'muscle_layer_front.svg'),
|
||||
'背面': os.path.join(ROOT, 'muscle_layer_back.svg'),
|
||||
}
|
||||
|
||||
muscles = [] # (id, view, base, group)
|
||||
for view, fp in files.items():
|
||||
for mid in parse_ids(fp):
|
||||
if mid in EXCLUDE or mid in CONTAINERS:
|
||||
continue
|
||||
b = base_name(mid)
|
||||
g = fitness_group(mid)
|
||||
muscles.append((mid, view, b, g))
|
||||
|
||||
# ---- Counts ----
|
||||
from collections import defaultdict, OrderedDict
|
||||
per_view = defaultdict(int)
|
||||
for mid, view, b, g in muscles:
|
||||
per_view[view] += 1
|
||||
total = len(muscles)
|
||||
|
||||
# distinct anatomical muscle types (merge L/R + segments + across views)
|
||||
distinct_types = set(b for mid, view, b, g in muscles)
|
||||
|
||||
# per fitness group aggregation
|
||||
group_info = OrderedDict()
|
||||
for mid, view, b, g in muscles:
|
||||
if g not in group_info:
|
||||
group_info[g] = {'paths': 0, 'bases': set(), 'views': set(), 'ids_by_view': defaultdict(list)}
|
||||
group_info[g]['paths'] += 1
|
||||
group_info[g]['bases'].add(b)
|
||||
group_info[g]['views'].add(view)
|
||||
if mid not in group_info[g]['ids_by_view'][view]: # dedupe within a view
|
||||
group_info[g]['ids_by_view'][view].append(mid)
|
||||
|
||||
# ---- Write report ----
|
||||
lines = []
|
||||
lines.append('# 肌肉热力图 SVG 肌肉统计与健身肌肉群分组')
|
||||
lines.append('')
|
||||
lines.append('> 数据源:`kit-g/flutter-body-atlas` 的 `muscle_layer_front.svg`(正面)与 `muscle_layer_back.svg`(背面)')
|
||||
lines.append('> 已下载至 `muscle_analysis/` 目录。')
|
||||
lines.append('')
|
||||
lines.append('## 一、肌肉数量统计(不含面部)')
|
||||
lines.append('')
|
||||
lines.append('| 视图 | 肌肉路径块数 |')
|
||||
lines.append('| --- | ---: |')
|
||||
lines.append(f'| 正面 (front) | {per_view["正面"]} |')
|
||||
lines.append(f'| 背面 (back) | {per_view["背面"]} |')
|
||||
lines.append(f'| **合计** | **{total}** |')
|
||||
lines.append('')
|
||||
lines.append(f'- 上述为**单独绘制的肌肉路径块数**(左右侧、分节段各自计为 1 块;已剔除 `face` 面部、`foot`/`ankle`/`hand`/`wrist`/`palm` 等非肌肉部位,以及 `underlayer`/`non_muscle` 底层)。')
|
||||
lines.append(f'- 若将左右侧合并、分节段合并、正反视图去重,**不同的解剖肌肉类型共 {len(distinct_types)} 种**。')
|
||||
lines.append('')
|
||||
lines.append('## 二、健身常用肌肉群分组表')
|
||||
lines.append('')
|
||||
lines.append('下表把 143 块细碎肌肉路径汇总为健身人群常用的 14 个肌肉群。')
|
||||
lines.append('')
|
||||
lines.append('| 健身肌肉群 | 路径块数 | 出现视图 | 包含的具体肌肉(SVG 基础名 → 中文) |')
|
||||
lines.append('| --- | ---: | --- | --- |')
|
||||
for g in ['胸部','背部','斜方肌','肩部(三角肌)','肱二头肌','肱三头肌','前臂','腹肌/核心',
|
||||
'臀部','股四头肌','腘绳肌','内收肌','小腿','颈部']:
|
||||
if g not in group_info:
|
||||
continue
|
||||
info = group_info[g]
|
||||
views = '/'.join(sorted(info['views']))
|
||||
parts = []
|
||||
for b in sorted(info['bases']):
|
||||
parts.append(f'{b} → {BASE_CN.get(b, b)}')
|
||||
detail = ';'.join(parts)
|
||||
lines.append(f'| {g} | {info["paths"]} | {views} | {detail} |')
|
||||
lines.append('')
|
||||
lines.append(f'**合计路径块数:** {sum(v["paths"] for v in group_info.values())}(= 143,校验一致)')
|
||||
lines.append('')
|
||||
lines.append('## 三、附录:每个健身肌肉群对应的完整 SVG 元素 id(可直接用于热力图填色)')
|
||||
lines.append('')
|
||||
for g in ['胸部','背部','斜方肌','肩部(三角肌)','肱二头肌','肱三头肌','前臂','腹肌/核心',
|
||||
'臀部','股四头肌','腘绳肌','内收肌','小腿','颈部']:
|
||||
if g not in group_info:
|
||||
continue
|
||||
info = group_info[g]
|
||||
lines.append(f'### {g}(共 {info["paths"]} 块)')
|
||||
lines.append('')
|
||||
for view in ['正面','背面']:
|
||||
ids = sorted(info['ids_by_view'].get(view, []))
|
||||
if not ids:
|
||||
continue
|
||||
lines.append(f'**{view}视图({len(ids)} 块):**')
|
||||
lines.append('')
|
||||
lines.append('```')
|
||||
lines.append(', '.join(ids))
|
||||
lines.append('```')
|
||||
lines.append('')
|
||||
lines.append('')
|
||||
|
||||
out = os.path.join(ROOT, '肌肉统计与分组报告.md')
|
||||
with open(out, 'w', encoding='utf-8') as f:
|
||||
f.write('\n'.join(lines))
|
||||
|
||||
print('Total muscle paths (excl. face):', total)
|
||||
print('Front:', per_view['正面'], 'Back:', per_view['背面'])
|
||||
print('Distinct anatomical types:', len(distinct_types))
|
||||
print('Fitness groups:', len(group_info))
|
||||
for g, info in group_info.items():
|
||||
print(f' {g}: {info["paths"]} paths, {len(info["bases"])} types, views={sorted(info["views"])}')
|
||||
print('Report written to', out)
|
||||
265
muscle_analysis/generate_catalog.py
Normal file
265
muscle_analysis/generate_catalog.py
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import os
|
||||
|
||||
ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
EXCLUDE = {
|
||||
'underlayer', 'non_muscle',
|
||||
'foot_l', 'foot_r', 'ankle_l', 'ankle_r',
|
||||
'hand_l', 'hand_r', 'hands', 'wrist_l', 'wrist_r', 'palm_l', 'palm_r',
|
||||
'head', 'face',
|
||||
}
|
||||
CONTAINERS = {
|
||||
'legs', 'adductors', 'glutes', 'arms', 'core', 'back',
|
||||
'shoulders', 'chest', 'neck', 'hamstrings',
|
||||
}
|
||||
|
||||
BASE_CN = {
|
||||
'tibialis_anterior': '胫骨前肌',
|
||||
'extensor_hallucis_longus': '拇长伸肌',
|
||||
'fibularis_longus': '腓骨长肌',
|
||||
'extensor_digitorum_longus': '趾长伸肌',
|
||||
'gastrocnemius': '腓肠肌',
|
||||
'semitendinosus': '半腱肌',
|
||||
'vastus_lateralis': '股外侧肌',
|
||||
'vastus_medialis': '股内侧肌',
|
||||
'sartoris': '缝匠肌',
|
||||
'sartorius': '缝匠肌',
|
||||
'gracilis': '股薄肌',
|
||||
'rectus_femoris': '股直肌',
|
||||
'iliotibial_tract': '髂胫束(筋膜)',
|
||||
'adductor_longus': '长收肌',
|
||||
'pectineus': '耻骨肌',
|
||||
'adductor_magnus': '大收肌',
|
||||
'gluteus_medius': '臀中肌',
|
||||
'gluteus_maximus': '臀大肌',
|
||||
'flexor_digitorum_superficialis': '指浅屈肌',
|
||||
'pronator_quadratus': '旋前方肌',
|
||||
'extensor_carpi_radialis_longus': '桡侧腕长伸肌',
|
||||
'palmaris_longus': '掌长肌',
|
||||
'flexor_carpi_radialis': '桡侧腕屈肌',
|
||||
'pronator_teres': '旋前圆肌',
|
||||
'triceps_brachii': '肱三头肌',
|
||||
'brachioradialis': '肱桡肌',
|
||||
'biceps_brachii': '肱二头肌',
|
||||
'external_oblique': '腹外斜肌',
|
||||
'rectus_abdominis': '腹直肌',
|
||||
'latissimus_dorsi': '背阔肌',
|
||||
'lateral_deltoid': '三角肌(中束)',
|
||||
'trapezius_upper': '斜方肌(上束)',
|
||||
'trapezius_middle': '斜方肌(中束)',
|
||||
'trapezius_lower': '斜方肌(下束)',
|
||||
'anterior_deltoid': '三角肌(前束)',
|
||||
'posterior_deltoid': '三角肌(后束)',
|
||||
'pectoralis_major': '胸大肌',
|
||||
'platysma': '颈阔肌',
|
||||
'sternohyoid': '胸骨舌骨肌',
|
||||
'sternocleidomastoid': '胸锁乳突肌',
|
||||
'infraspinatus': '冈下肌',
|
||||
'semimembranosus': '半膜肌',
|
||||
'biceps_femoris': '股二头肌',
|
||||
'extensor_digitorum': '指总伸肌',
|
||||
'extensor_carpi_ulnaris': '尺侧腕伸肌',
|
||||
'anconeus': '肘肌',
|
||||
'flexor_carpi_ulnaris': '尺侧腕屈肌',
|
||||
}
|
||||
|
||||
BASE_EN = {
|
||||
'tibialis_anterior': 'Tibialis Anterior',
|
||||
'extensor_hallucis_longus': 'Extensor Hallucis Longus',
|
||||
'fibularis_longus': 'Fibularis Longus',
|
||||
'extensor_digitorum_longus': 'Extensor Digitorum Longus',
|
||||
'gastrocnemius': 'Gastrocnemius',
|
||||
'semitendinosus': 'Semitendinosus',
|
||||
'vastus_lateralis': 'Vastus Lateralis',
|
||||
'vastus_medialis': 'Vastus Medialis',
|
||||
'sartoris': 'Sartorius',
|
||||
'sartorius': 'Sartorius',
|
||||
'gracilis': 'Gracilis',
|
||||
'rectus_femoris': 'Rectus Femoris',
|
||||
'iliotibial_tract': 'Iliotibial Tract',
|
||||
'adductor_longus': 'Adductor Longus',
|
||||
'pectineus': 'Pectineus',
|
||||
'adductor_magnus': 'Adductor Magnus',
|
||||
'gluteus_medius': 'Gluteus Medius',
|
||||
'gluteus_maximus': 'Gluteus Maximus',
|
||||
'flexor_digitorum_superficialis': 'Flexor Digitorum Superficialis',
|
||||
'pronator_quadratus': 'Pronator Quadratus',
|
||||
'extensor_carpi_radialis_longus': 'Extensor Carpi Radialis Longus',
|
||||
'palmaris_longus': 'Palmaris Longus',
|
||||
'flexor_carpi_radialis': 'Flexor Carpi Radialis',
|
||||
'pronator_teres': 'Pronator Teres',
|
||||
'triceps_brachii': 'Triceps Brachii',
|
||||
'brachioradialis': 'Brachioradialis',
|
||||
'biceps_brachii': 'Biceps Brachii',
|
||||
'external_oblique': 'External Oblique',
|
||||
'rectus_abdominis': 'Rectus Abdominis',
|
||||
'latissimus_dorsi': 'Latissimus Dorsi',
|
||||
'lateral_deltoid': 'Deltoid (Lateral)',
|
||||
'trapezius_upper': 'Trapezius (Upper)',
|
||||
'trapezius_middle': 'Trapezius (Middle)',
|
||||
'trapezius_lower': 'Trapezius (Lower)',
|
||||
'anterior_deltoid': 'Deltoid (Anterior)',
|
||||
'posterior_deltoid': 'Deltoid (Posterior)',
|
||||
'pectoralis_major': 'Pectoralis Major',
|
||||
'platysma': 'Platysma',
|
||||
'sternohyoid': 'Sternohyoid',
|
||||
'sternocleidomastoid': 'Sternocleidomastoid',
|
||||
'infraspinatus': 'Infraspinatus',
|
||||
'semimembranosus': 'Semimembranosus',
|
||||
'biceps_femoris': 'Biceps Femoris',
|
||||
'extensor_digitorum': 'Extensor Digitorum',
|
||||
'extensor_carpi_ulnaris': 'Extensor Carpi Ulnaris',
|
||||
'anconeus': 'Anconeus',
|
||||
'flexor_carpi_ulnaris': 'Flexor Carpi Ulnaris',
|
||||
}
|
||||
|
||||
GROUP_ZH_TO_KEY = {
|
||||
'胸部': 'chest',
|
||||
'背部': 'back',
|
||||
'斜方肌': 'traps',
|
||||
'肩部(三角肌)': 'shoulders',
|
||||
'肱二头肌': 'biceps',
|
||||
'肱三头肌': 'triceps',
|
||||
'前臂': 'forearms',
|
||||
'腹肌/核心': 'abs',
|
||||
'臀部': 'glutes',
|
||||
'股四头肌': 'quads',
|
||||
'腘绳肌': 'hamstrings',
|
||||
'内收肌': 'adductors',
|
||||
'小腿': 'calves',
|
||||
'颈部': 'neck',
|
||||
}
|
||||
|
||||
GROUP_KEY_TO_EN = {
|
||||
'chest': 'Chest',
|
||||
'back': 'Back',
|
||||
'traps': 'Traps',
|
||||
'shoulders': 'Shoulders (Delts)',
|
||||
'biceps': 'Biceps',
|
||||
'triceps': 'Triceps',
|
||||
'forearms': 'Forearms',
|
||||
'abs': 'Abs / Core',
|
||||
'glutes': 'Glutes',
|
||||
'quads': 'Quads',
|
||||
'hamstrings': 'Hamstrings',
|
||||
'adductors': 'Adductors',
|
||||
'calves': 'Calves',
|
||||
'neck': 'Neck',
|
||||
}
|
||||
|
||||
|
||||
def base_name(mid):
|
||||
s = mid.lower()
|
||||
if s.startswith('triceps_brachii'):
|
||||
return 'triceps_brachii'
|
||||
if s.startswith('biceps_brachii'):
|
||||
return 'biceps_brachii'
|
||||
s = re.sub(r'_[lr]$', '', s)
|
||||
s = re.sub(r'_\d+$', '', s)
|
||||
return s
|
||||
|
||||
|
||||
def fitness_group(mid):
|
||||
s = mid.lower()
|
||||
if 'pectoralis_major' in s:
|
||||
return '胸部'
|
||||
if s.startswith('trapezius'):
|
||||
return '斜方肌'
|
||||
if 'latissimus_dorsi' in s or 'infraspinatus' in s:
|
||||
return '背部'
|
||||
if 'deltoid' in s:
|
||||
return '肩部(三角肌)'
|
||||
if 'biceps_brachii' in s:
|
||||
return '肱二头肌'
|
||||
if 'triceps_brachii' in s:
|
||||
return '肱三头肌'
|
||||
if any(k in s for k in ['gastrocnemius', 'tibialis', 'fibularis', 'extensor_hallucis', 'extensor_digitorum_longus']):
|
||||
return '小腿'
|
||||
if any(k in s for k in ['brachioradialis', 'flexor_digitorum', 'pronator', 'extensor_carpi',
|
||||
'palmaris', 'flexor_carpi', 'extensor_digitorum', 'anconeus']):
|
||||
return '前臂'
|
||||
if 'rectus_abdominis' in s or 'external_oblique' in s:
|
||||
return '腹肌/核心'
|
||||
if 'gluteus' in s:
|
||||
return '臀部'
|
||||
if any(k in s for k in ['rectus_femoris', 'vastus', 'sartoris', 'sartorius', 'iliotibial']):
|
||||
return '股四头肌'
|
||||
if any(k in s for k in ['semitendinosus', 'semimembranosus', 'biceps_femoris']):
|
||||
return '腘绳肌'
|
||||
if any(k in s for k in ['adductor', 'pectineus', 'gracilis']):
|
||||
return '内收肌'
|
||||
if any(k in s for k in ['sternocleidomastoid', 'platysma', 'sternohyoid']):
|
||||
return '颈部'
|
||||
return '其他'
|
||||
|
||||
|
||||
def parse_ids(path):
|
||||
with open(path, encoding='utf-8') as f:
|
||||
txt = f.read()
|
||||
return re.findall(r'\bid="([^"]+)"', txt)
|
||||
|
||||
|
||||
files = {
|
||||
'front': os.path.join(ROOT, 'muscle_layer_front.svg'),
|
||||
'back': os.path.join(ROOT, 'muscle_layer_back.svg'),
|
||||
}
|
||||
|
||||
entries = []
|
||||
for side, fp in files.items():
|
||||
for mid in parse_ids(fp):
|
||||
if mid in EXCLUDE or mid in CONTAINERS:
|
||||
continue
|
||||
b = base_name(mid)
|
||||
g_zh = fitness_group(mid)
|
||||
g_key = GROUP_ZH_TO_KEY.get(g_zh, 'other')
|
||||
entries.append({
|
||||
'id': mid,
|
||||
'side': side,
|
||||
'fitnessGroup': g_key,
|
||||
'zh': BASE_CN.get(b, b),
|
||||
'en': BASE_EN.get(b, b),
|
||||
})
|
||||
|
||||
entries.sort(key=lambda x: (x['side'], x['fitnessGroup'], x['id']))
|
||||
|
||||
lines = []
|
||||
lines.append('/*')
|
||||
lines.append(' * svgMuscleCatalog.ts —— SVG 肌肉路径目录(自动生成)')
|
||||
lines.append(' * 来源:muscle_analysis/muscle_layer_front.svg + muscle_layer_back.svg')
|
||||
lines.append(' * 共 {} 条路径,14 个健身群,中英双语。'.format(len(entries)))
|
||||
lines.append(' */')
|
||||
lines.append('')
|
||||
lines.append("export interface SvgMuscleEntry {")
|
||||
lines.append(" id: string;")
|
||||
lines.append(" side: 'front' | 'back';")
|
||||
lines.append(" fitnessGroup: string;")
|
||||
lines.append(" zh: string;")
|
||||
lines.append(" en: string;")
|
||||
lines.append("}")
|
||||
lines.append('')
|
||||
lines.append("export const SVG_CATALOG_VERSION = 'flutter-body-atlas@main-2026-07-12';")
|
||||
lines.append('')
|
||||
lines.append('export const FITNESS_GROUPS: { key: string; zh: string; en: string }[] = [')
|
||||
for g_key in ['chest', 'back', 'traps', 'shoulders', 'biceps', 'triceps', 'forearms', 'abs', 'glutes', 'quads', 'hamstrings', 'adductors', 'calves', 'neck']:
|
||||
en = GROUP_KEY_TO_EN[g_key]
|
||||
zh = [k for k, v in GROUP_ZH_TO_KEY.items() if v == g_key][0]
|
||||
lines.append(" {{ key: '{}', zh: '{}', en: '{}' }},".format(g_key, zh, en))
|
||||
lines.append('];')
|
||||
lines.append('')
|
||||
lines.append('export const SVG_MUSCLE_CATALOG: SvgMuscleEntry[] = [')
|
||||
for e in entries:
|
||||
lines.append(" {{ id: '{}', side: '{}', fitnessGroup: '{}', zh: '{}', en: '{}' }},".format(
|
||||
e['id'], e['side'], e['fitnessGroup'], e['zh'], e['en']))
|
||||
lines.append('];')
|
||||
lines.append('')
|
||||
lines.append("// 渲染时隐藏的 SVG 组/区域 id(头部与面部轮廓,非肌肉)")
|
||||
lines.append("export const HIDDEN_SVG_GROUP_IDS = ['head', 'face'];")
|
||||
lines.append('')
|
||||
|
||||
out = os.path.join(ROOT, '..', 'src', 'data', 'svgMuscleCatalog.ts')
|
||||
with open(out, 'w', encoding='utf-8') as f:
|
||||
f.write('\n'.join(lines))
|
||||
|
||||
print('Wrote', len(entries), 'entries to', out)
|
||||
200
muscle_analysis/muscle_layer_back.svg
Normal file
200
muscle_analysis/muscle_layer_back.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 130 KiB |
292
muscle_analysis/muscle_layer_front.svg
Normal file
292
muscle_analysis/muscle_layer_front.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 182 KiB |
238
muscle_analysis/肌肉统计与分组报告.md
Normal file
238
muscle_analysis/肌肉统计与分组报告.md
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
# 肌肉热力图 SVG 肌肉统计与健身肌肉群分组
|
||||
|
||||
> 数据源:`kit-g/flutter-body-atlas` 的 `muscle_layer_front.svg`(正面)与 `muscle_layer_back.svg`(背面)
|
||||
> 已下载至 `muscle_analysis/` 目录。
|
||||
|
||||
## 一、肌肉数量统计(不含面部)
|
||||
|
||||
| 视图 | 肌肉路径块数 |
|
||||
| --- | ---: |
|
||||
| 正面 (front) | 89 |
|
||||
| 背面 (back) | 54 |
|
||||
| **合计** | **143** |
|
||||
|
||||
- 上述为**单独绘制的肌肉路径块数**(左右侧、分节段各自计为 1 块;已剔除 `face` 面部、`foot`/`ankle`/`hand`/`wrist`/`palm` 等非肌肉部位,以及 `underlayer`/`non_muscle` 底层)。
|
||||
- 若将左右侧合并、分节段合并、正反视图去重,**不同的解剖肌肉类型共 46 种**。
|
||||
|
||||
## 二、健身常用肌肉群分组表
|
||||
|
||||
下表把 143 块细碎肌肉路径汇总为健身人群常用的 14 个肌肉群。
|
||||
|
||||
| 健身肌肉群 | 路径块数 | 出现视图 | 包含的具体肌肉(SVG 基础名 → 中文) |
|
||||
| --- | ---: | --- | --- |
|
||||
| 胸部 | 2 | 正面 | pectoralis_major → 胸大肌 |
|
||||
| 背部 | 6 | 正面/背面 | infraspinatus → 冈下肌;latissimus_dorsi → 背阔肌 |
|
||||
| 斜方肌 | 8 | 正面/背面 | trapezius_lower → 斜方肌(下束);trapezius_middle → 斜方肌(中束);trapezius_upper → 斜方肌(上束) |
|
||||
| 肩部(三角肌) | 8 | 正面/背面 | anterior_deltoid → 三角肌(前束);lateral_deltoid → 三角肌(中束);posterior_deltoid → 三角肌(后束) |
|
||||
| 肱二头肌 | 4 | 正面 | biceps_brachii → 肱二头肌 |
|
||||
| 肱三头肌 | 10 | 正面/背面 | triceps_brachii → 肱三头肌 |
|
||||
| 前臂 | 24 | 正面/背面 | anconeus → 肘肌;brachioradialis → 肱桡肌;extensor_carpi_radialis_longus → 桡侧腕长伸肌;extensor_carpi_ulnaris → 尺侧腕伸肌;extensor_digitorum → 指总伸肌;flexor_carpi_radialis → 桡侧腕屈肌;flexor_carpi_ulnaris → 尺侧腕屈肌;flexor_digitorum_superficialis → 指浅屈肌;palmaris_longus → 掌长肌;pronator_quadratus → 旋前方肌;pronator_teres → 旋前圆肌 |
|
||||
| 腹肌/核心 | 25 | 正面/背面 | external_oblique → 腹外斜肌;rectus_abdominis → 腹直肌 |
|
||||
| 臀部 | 8 | 正面/背面 | gluteus_maximus → 臀大肌;gluteus_medius → 臀中肌 |
|
||||
| 股四头肌 | 12 | 正面/背面 | iliotibial_tract → 髂胫束(筋膜);rectus_femoris → 股直肌;sartoris → 缝匠肌;vastus_lateralis → 股外侧肌;vastus_medialis → 股内侧肌 |
|
||||
| 腘绳肌 | 10 | 正面/背面 | biceps_femoris → 股二头肌;semimembranosus → 半膜肌;semitendinosus → 半腱肌 |
|
||||
| 内收肌 | 8 | 正面/背面 | adductor_longus → 长收肌;adductor_magnus → 大收肌;gracilis → 股薄肌;pectineus → 耻骨肌 |
|
||||
| 小腿 | 12 | 正面/背面 | extensor_digitorum_longus → 趾长伸肌;extensor_hallucis_longus → 拇长伸肌;fibularis_longus → 腓骨长肌;gastrocnemius → 腓肠肌;tibialis_anterior → 胫骨前肌 |
|
||||
| 颈部 | 6 | 正面/背面 | platysma → 颈阔肌;sternocleidomastoid → 胸锁乳突肌;sternohyoid → 胸骨舌骨肌 |
|
||||
|
||||
**合计路径块数:** 143(= 143,校验一致)
|
||||
|
||||
## 三、附录:每个健身肌肉群对应的完整 SVG 元素 id(可直接用于热力图填色)
|
||||
|
||||
### 胸部(共 2 块)
|
||||
|
||||
**正面视图(2 块):**
|
||||
|
||||
```
|
||||
pectoralis_major_l, pectoralis_major_r
|
||||
```
|
||||
|
||||
|
||||
### 背部(共 6 块)
|
||||
|
||||
**正面视图(2 块):**
|
||||
|
||||
```
|
||||
latissimus_dorsi_l, latissimus_dorsi_r
|
||||
```
|
||||
|
||||
**背面视图(4 块):**
|
||||
|
||||
```
|
||||
infraspinatus_l, infraspinatus_r, latissimus_dorsi_l, latissimus_dorsi_r
|
||||
```
|
||||
|
||||
|
||||
### 斜方肌(共 8 块)
|
||||
|
||||
**正面视图(2 块):**
|
||||
|
||||
```
|
||||
trapezius_upper_l, trapezius_upper_r
|
||||
```
|
||||
|
||||
**背面视图(6 块):**
|
||||
|
||||
```
|
||||
trapezius_lower_l, trapezius_lower_r, trapezius_middle_l, trapezius_middle_r, trapezius_upper_l, trapezius_upper_r
|
||||
```
|
||||
|
||||
|
||||
### 肩部(三角肌)(共 8 块)
|
||||
|
||||
**正面视图(4 块):**
|
||||
|
||||
```
|
||||
anterior_deltoid_l, anterior_deltoid_r, lateral_deltoid_l, lateral_deltoid_r
|
||||
```
|
||||
|
||||
**背面视图(4 块):**
|
||||
|
||||
```
|
||||
lateral_deltoid_l, lateral_deltoid_r, posterior_deltoid_l, posterior_deltoid_r
|
||||
```
|
||||
|
||||
|
||||
### 肱二头肌(共 4 块)
|
||||
|
||||
**正面视图(4 块):**
|
||||
|
||||
```
|
||||
biceps_brachii_caput_breve_l, biceps_brachii_caput_breve_r, biceps_brachii_caput_longum_l, biceps_brachii_caput_longum_r
|
||||
```
|
||||
|
||||
|
||||
### 肱三头肌(共 10 块)
|
||||
|
||||
**正面视图(4 块):**
|
||||
|
||||
```
|
||||
triceps_brachii_caput_laterale_l, triceps_brachii_caput_laterale_r, triceps_brachii_caput_longum_l, triceps_brachii_caput_longum_r
|
||||
```
|
||||
|
||||
**背面视图(6 块):**
|
||||
|
||||
```
|
||||
triceps_brachii_caput_laterale_l, triceps_brachii_caput_laterale_r, triceps_brachii_caput_longum_l, triceps_brachii_caput_longum_r, triceps_brachii_caput_mediale_l, triceps_brachii_caput_mediale_r
|
||||
```
|
||||
|
||||
|
||||
### 前臂(共 24 块)
|
||||
|
||||
**正面视图(14 块):**
|
||||
|
||||
```
|
||||
brachioradialis_l, brachioradialis_r, extensor_carpi_radialis_longus_l, extensor_carpi_radialis_longus_r, flexor_carpi_radialis_l, flexor_carpi_radialis_r, flexor_digitorum_superficialis_l, flexor_digitorum_superficialis_r, palmaris_longus_l, palmaris_longus_r, pronator_quadratus_l, pronator_quadratus_r, pronator_teres_l, pronator_teres_r
|
||||
```
|
||||
|
||||
**背面视图(10 块):**
|
||||
|
||||
```
|
||||
anconeus_l, anconeus_r, brachioradialis_l, brachioradialis_r, extensor_carpi_ulnaris_l, extensor_carpi_ulnaris_r, extensor_digitorum_l, extensor_digitorum_r, flexor_carpi_ulnaris_l, flexor_carpi_ulnaris_r
|
||||
```
|
||||
|
||||
|
||||
### 腹肌/核心(共 25 块)
|
||||
|
||||
**正面视图(23 块):**
|
||||
|
||||
```
|
||||
external_oblique_1_l, external_oblique_1_r, external_oblique_2_l, external_oblique_2_r, external_oblique_3_l, external_oblique_3_r, external_oblique_4_l, external_oblique_4_r, external_oblique_5_l, external_oblique_5_r, external_oblique_6_l, external_oblique_6_r, external_oblique_7_l, external_oblique_7_r, external_oblique_8_l, external_oblique_8_r, rectus_abdominis_1, rectus_abdominis_2_l, rectus_abdominis_2_r, rectus_abdominis_3_l, rectus_abdominis_3_r, rectus_abdominis_4_l, rectus_abdominis_4_r
|
||||
```
|
||||
|
||||
**背面视图(2 块):**
|
||||
|
||||
```
|
||||
external_oblique_1_l, external_oblique_1_r
|
||||
```
|
||||
|
||||
|
||||
### 臀部(共 8 块)
|
||||
|
||||
**正面视图(2 块):**
|
||||
|
||||
```
|
||||
gluteus_medius_2_l, gluteus_medius_2_r
|
||||
```
|
||||
|
||||
**背面视图(6 块):**
|
||||
|
||||
```
|
||||
gluteus_maximus_l, gluteus_maximus_r, gluteus_medius_1_l, gluteus_medius_1_r, gluteus_medius_2_l, gluteus_medius_2_r
|
||||
```
|
||||
|
||||
|
||||
### 股四头肌(共 12 块)
|
||||
|
||||
**正面视图(10 块):**
|
||||
|
||||
```
|
||||
iliotibial_tract_l, iliotibial_tract_r, rectus_femoris_l, rectus_femoris_r, sartoris_l, sartoris_r, vastus_lateralis_l, vastus_lateralis_r, vastus_medialis_l, vastus_medialis_r
|
||||
```
|
||||
|
||||
**背面视图(2 块):**
|
||||
|
||||
```
|
||||
iliotibial_tract_l, iliotibial_tract_r
|
||||
```
|
||||
|
||||
|
||||
### 腘绳肌(共 10 块)
|
||||
|
||||
**正面视图(2 块):**
|
||||
|
||||
```
|
||||
semitendinosus_l, semitendinosus_r
|
||||
```
|
||||
|
||||
**背面视图(8 块):**
|
||||
|
||||
```
|
||||
biceps_femoris_l, biceps_femoris_r, semimembranosus_1_l, semimembranosus_1_r, semimembranosus_2_l, semimembranosus_2_r, semitendinosus_l, semitendinosus_r
|
||||
```
|
||||
|
||||
|
||||
### 内收肌(共 8 块)
|
||||
|
||||
**正面视图(6 块):**
|
||||
|
||||
```
|
||||
adductor_longus_l, adductor_longus_r, gracilis_l, gracilis_r, pectineus_l, pectineus_r
|
||||
```
|
||||
|
||||
**背面视图(2 块):**
|
||||
|
||||
```
|
||||
adductor_magnus_l, adductor_magnus_r
|
||||
```
|
||||
|
||||
|
||||
### 小腿(共 12 块)
|
||||
|
||||
**正面视图(10 块):**
|
||||
|
||||
```
|
||||
extensor_digitorum_longus_l, extensor_digitorum_longus_r, extensor_hallucis_longus_l, extensor_hallucis_longus_r, fibularis_longus_l, fibularis_longus_r, gastrocnemius_l, gastrocnemius_r, tibialis_anterior_l, tibialis_anterior_r
|
||||
```
|
||||
|
||||
**背面视图(2 块):**
|
||||
|
||||
```
|
||||
gastrocnemius_l, gastrocnemius_r
|
||||
```
|
||||
|
||||
|
||||
### 颈部(共 6 块)
|
||||
|
||||
**正面视图(4 块):**
|
||||
|
||||
```
|
||||
platysma, sternocleidomastoid_l, sternocleidomastoid_r, sternohyoid
|
||||
```
|
||||
|
||||
**背面视图(2 块):**
|
||||
|
||||
```
|
||||
sternocleidomastoid_l, sternocleidomastoid_r
|
||||
```
|
||||
|
||||
3062
package-lock.json
generated
Normal file
3062
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
32
package.json
Normal file
32
package.json
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"name": "workout-block",
|
||||
"version": "1.0.0",
|
||||
"description": "训练块 Workout Block —— 高度可自定义的 Obsidian 训练记录插件",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "node esbuild.config.mjs --production",
|
||||
"test": "vitest",
|
||||
"test:coverage": "vitest --coverage"
|
||||
},
|
||||
"keywords": [
|
||||
"obsidian",
|
||||
"workout",
|
||||
"fitness"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"obsidian": "^1.5.0",
|
||||
"papaparse": "^5.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/papaparse": "^5.3.14",
|
||||
"@vitest/coverage-v8": "^1.0.0",
|
||||
"esbuild": "^0.20.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"typescript": "^5.3.0",
|
||||
"vitest": "^1.0.0"
|
||||
}
|
||||
}
|
||||
200
src/assets/muscle_layer_back.svg
Normal file
200
src/assets/muscle_layer_back.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 130 KiB |
292
src/assets/muscle_layer_front.svg
Normal file
292
src/assets/muscle_layer_front.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 182 KiB |
92
src/codeBlockDefs.ts
Normal file
92
src/codeBlockDefs.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
/*
|
||||
* codeBlockDefs.ts —— 快捷插入代码块的元数据与文本生成
|
||||
* 这是「插入代码块」功能的单一数据源:主弹窗(卡片列表)和参数弹窗(表单)
|
||||
* 都从这里读取定义,后续新增代码块只需在此追加一条,无需改弹窗逻辑。
|
||||
*
|
||||
* 重要:params[].key 必须使用各代码块解析器实际接受的「下划线风格」命名
|
||||
* (见 src/codeblock/workoutLog.ts 等 parseParams 的 switch case),
|
||||
* 例如 workout-log 接受的是 group_by / show_add 而非 groupBy / showAdd,
|
||||
* 否则生成的参数不会被解析器识别而失效。
|
||||
*/
|
||||
|
||||
export type ParamType = 'text' | 'number' | 'select';
|
||||
|
||||
export interface CodeBlockParamDef {
|
||||
key: string; // 写入代码块正文、解析器可识别的参数名(下划线风格)
|
||||
label: string; // 参数中文标签
|
||||
desc?: string; // 参数说明(显示在标签下方)
|
||||
type: ParamType; // 控件类型
|
||||
required?: boolean; // 是否必填(本功能统一非必填,保留字段以备扩展)
|
||||
placeholder?: string; // 输入框占位提示
|
||||
options?: string[]; // type=select 时的静态选项(值)
|
||||
optionLabels?: Record<string, string>;// select 选项值 → 中文展示
|
||||
dynamic?: 'exercise' | 'plan' | 'metric'; // select 选项来自 DataManager 配置(运行时填充)
|
||||
}
|
||||
|
||||
export interface CodeBlockDef {
|
||||
id: string; // 代码块类型名(即 ``` 后的语言标识)
|
||||
icon: string; // Obsidian 图标名(用 setIcon 渲染)
|
||||
title: string; // 中文名称
|
||||
desc: string; // 功能说明
|
||||
params: CodeBlockParamDef[];
|
||||
}
|
||||
|
||||
export const CODE_BLOCK_DEFS: CodeBlockDef[] = [
|
||||
{
|
||||
id: 'workout-log',
|
||||
icon: 'table',
|
||||
title: '训练记录表',
|
||||
desc: '按训练项/时间筛选,展示历史训练记录明细表格',
|
||||
params: [
|
||||
{ key: 'exercise', label: '训练项', type: 'text', placeholder: '如:卧推(留空显示全部)' },
|
||||
{ key: 'day', label: '最近天数', type: 'number', placeholder: '如 30,只显示最近 N 天' },
|
||||
{ key: 'group_by', label: '分组方式', type: 'select', options: ['date', 'week'], optionLabels: { date: '按日期', week: '按周' } },
|
||||
{ key: 'sort', label: '排序', type: 'select', options: ['desc', 'asc'], optionLabels: { desc: '最新在前', asc: '最早在前' } },
|
||||
{ key: 'number', label: '最近条数', type: 'number', placeholder: '只显示最新 N 条' },
|
||||
{ key: 'limit', label: '最多渲染', type: 'number', placeholder: '最多渲染 N 条(默认 50)' },
|
||||
{ key: 'show_add', label: '显示添加按钮', type: 'select', options: ['true', 'false'], optionLabels: { true: '显示', false: '隐藏' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'workout-day',
|
||||
icon: 'calendar',
|
||||
title: '当日训练',
|
||||
desc: '展示指定某一天的训练总览(不写则显示今天,随真实日期滚动)',
|
||||
params: [
|
||||
{ key: 'day', label: '日期', type: 'text', placeholder: 'today 或 2026-07-17(留空=今天)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'workout-heatmap',
|
||||
icon: 'flame',
|
||||
title: '肌肉热力图',
|
||||
desc: '用颜色深浅展示各肌肉的训练强度分布',
|
||||
params: [
|
||||
{ key: 'metric', label: '统计指标', type: 'select', dynamic: 'metric', placeholder: '留空用默认指标' },
|
||||
{ key: 'range', label: '时间范围', type: 'text', placeholder: '30 / all / 2026-01-01..2026-12-31' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'workout-plan',
|
||||
icon: 'clipboard-list',
|
||||
title: '训练计划面板',
|
||||
desc: '展示某个训练计划的完成进度与每组打卡',
|
||||
params: [
|
||||
{ key: 'plan', label: '计划名称', type: 'select', dynamic: 'plan' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// 根据定义与用户填写的值,生成最终插入的代码块纯文本。
|
||||
// 值为空(或纯空白)的参数会被跳过,从而落到代码块的默认值逻辑上。
|
||||
export function buildCodeBlock(def: CodeBlockDef, values: Record<string, string>): string {
|
||||
const lines = [def.id];
|
||||
for (const p of def.params) {
|
||||
const v = values[p.key];
|
||||
if (v === undefined || v === null) continue;
|
||||
const trimmed = v.trim();
|
||||
if (trimmed === '') continue;
|
||||
lines.push(`${p.key}: ${trimmed}`);
|
||||
}
|
||||
return '```' + lines.join('\n') + '\n```\n';
|
||||
}
|
||||
185
src/codeblock/registry.ts
Normal file
185
src/codeblock/registry.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import { App, MarkdownPostProcessorContext } from 'obsidian';
|
||||
import { WorkoutConfig } from '../data/types';
|
||||
import { resolveExerciseIdByName } from '../data/display';
|
||||
|
||||
/*
|
||||
* registry.ts —— 代码块(Code Block)渲染注册表
|
||||
* 背景:在 Obsidian 笔记里用 ```workout-log 这样的「围栏代码块」,
|
||||
* 插件会把它渲染成表格。Obsidian 管这种渲染叫 MarkdownPostProcessor。
|
||||
* 本文件用两个全局容器管理:
|
||||
* 1) registry:类型名(如 'workout-log') → 渲染函数 的映射,相当于「登记每种代码块怎么画」。
|
||||
* 2) renderedBlocks:记录笔记里「所有已渲染出来的代码块」,方便数据变化时统一重画。
|
||||
*/
|
||||
|
||||
// 渲染函数类型:接收代码块原文 source、要往里塞内容的 DOM 容器 el、以及上下文 ctx
|
||||
export type CodeBlockHandler = (source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) => void;
|
||||
|
||||
// 类型 → 渲染函数 的注册表(Map 像字典,key 是代码块类型名)
|
||||
const registry = new Map<string, CodeBlockHandler>();
|
||||
// 持有 Obsidian App 引用,用于重渲染后把焦点还给编辑器(修复「代码块按钮被重渲染移除导致光标丢失」)。
|
||||
// 由 main.ts 在 onload 时注入。
|
||||
let appRef: App | null = null;
|
||||
export function setRegistryApp(app: App): void {
|
||||
appRef = app;
|
||||
}
|
||||
// 已渲染代码块清单:每个元素记下了它的 DOM 容器、类型、原文、上下文,便于之后整体重渲染
|
||||
const renderedBlocks: Array<{ el: HTMLElement; type: string; source: string; ctx: MarkdownPostProcessorContext }> = [];
|
||||
|
||||
// 注册一种代码块类型及其渲染函数(插件启动时调用,例如把 'workout-log' 关联到对应渲染器)
|
||||
export function registerCodeBlock(type: string, handler: CodeBlockHandler): void {
|
||||
registry.set(type, handler);
|
||||
}
|
||||
|
||||
// 根据类型名取出对应的渲染函数;没有就返回 undefined
|
||||
export function getCodeBlockHandler(type: string): CodeBlockHandler | undefined {
|
||||
return registry.get(type);
|
||||
}
|
||||
|
||||
// 返回所有已注册的类型名列表(例如 ['workout-log']),用于初始化时告诉 Obsidian 要接管哪些代码块
|
||||
export function getAllRegisteredTypes(): string[] {
|
||||
return Array.from(registry.keys());
|
||||
}
|
||||
|
||||
// 登记一个「已经渲染完」的代码块,把它记进 renderedBlocks,供后续重渲染使用
|
||||
// 注意:同一个 el 只允许存在一条记录,避免数据变化时重复渲染同一个 DOM。
|
||||
export function registerRenderedBlock(el: HTMLElement, type: string, source: string, ctx: MarkdownPostProcessorContext): void {
|
||||
// 如果该 el 之前已经登记过,先移除旧条目,再 push 新条目(更新 source/ctx)。
|
||||
unregisterRenderedBlock(el);
|
||||
renderedBlocks.push({ el, type, source, ctx });
|
||||
}
|
||||
|
||||
// 从清单里移除某个已渲染块(DOM 被销毁时调用,避免残留导致重复渲染)
|
||||
export function unregisterRenderedBlock(el: HTMLElement): void {
|
||||
const index = renderedBlocks.findIndex((block) => block.el === el);
|
||||
if (index !== -1) {
|
||||
renderedBlocks.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// 重渲染防抖:把短时间内的多次重渲染请求合并成一次,避免数据变化/语言切换时反复刷新。
|
||||
// 关键点:用「待执行过滤器列表」累积多个请求,而非单一 shouldRender。
|
||||
// 否则连续调用 rerenderBlocksByType('workout-day') 和 rerenderBlocksByType('workout-heatmap')
|
||||
// 时,第二次会 clearTimeout 掉第一次的定时器,导致只有后者真正执行,前者被静默吞掉。
|
||||
let rerenderTimer: number | null = null;
|
||||
const RERENDER_DEBOUNCE_MS = 50;
|
||||
const pendingRerenderFilters: Array<(source: string, type: string) => boolean> = [];
|
||||
|
||||
// 调度一次重渲染:shouldRender(source, type) 决定哪些代码块需要被重画。
|
||||
// 多次调用会被合并:防抖窗口内任一过滤器命中即重渲染该代码块。
|
||||
// 先复制 renderedBlocks 快照再遍历,防止 handler 内部修改数组导致遍历异常或漏渲染。
|
||||
function scheduleRerender(shouldRender: (source: string, type: string) => boolean): void {
|
||||
pendingRerenderFilters.push(shouldRender);
|
||||
if (rerenderTimer !== null) {
|
||||
// 已有待执行定时器:仅累积过滤器,不重置定时器,保证先前请求不会被吞。
|
||||
return;
|
||||
}
|
||||
rerenderTimer = window.setTimeout(() => {
|
||||
const filters = pendingRerenderFilters.slice();
|
||||
pendingRerenderFilters.length = 0;
|
||||
rerenderTimer = null;
|
||||
const blocks = [...renderedBlocks];
|
||||
// 焦点修复(机制 A):重渲染会 block.el.empty() 物理移除代码块内所有子节点,
|
||||
// 若用户刚点的删除/完成按钮正持有焦点,焦点会被带回 document.body,
|
||||
// 导致 Live Preview 下的 Obsidian 编辑器丢失光标、无法输入。
|
||||
// 故先标记「是否有待重渲染的块当前持有焦点」,重渲染后统一把焦点还给编辑器。
|
||||
// —— 光标修复(机制 A,稳健版)——
|
||||
// 现象复盘:上一版只在循环末尾调一次 ed.focus(),但 Obsidian 在代码块 DOM 变动后
|
||||
// 会「异步」再重渲染一次 markdown,把刚还回的焦点再次夺走,光标又丢
|
||||
// (用户只能靠切窗再切回、由 OS 窗口焦点事件在稳定后恢复)。本版三重保险:
|
||||
// ① 预判抢先:若焦点在「即将被 empty() 移除的按钮」上,先把焦点交给编辑器本体,
|
||||
// 按钮被移除时焦点已在编辑器上,不再掉回 document.body(从源头消除「被夺」)。
|
||||
// ② 容错:每个 handler 用 try/catch 包住,单块渲染异常不得中断焦点恢复。
|
||||
// ③ 兜底:下一绘制帧(rAF)与短暂延时后再 focus 一次——与「切窗再切回」机制同源,
|
||||
// 且只在焦点仍「空」(body/null)时才抢回,不打扰用户已主动转移的焦点。
|
||||
// 注意:只在该块「确实会被重渲染」时才判定/抢先,避免误夺走别的代码块里
|
||||
// 用户仍在用的按钮焦点。
|
||||
let focusOwnerWillRerender = false;
|
||||
for (const block of blocks) {
|
||||
if (block.el.contains(document.activeElement)) {
|
||||
if (filters.some((f) => f(block.source, block.type))) {
|
||||
focusOwnerWillRerender = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ① 抢先把焦点从按钮移到编辑器本体(仅当该块确会被重渲染)
|
||||
if (focusOwnerWillRerender && appRef) {
|
||||
const ed = appRef.workspace.activeEditor?.editor;
|
||||
if (ed) ed.focus();
|
||||
}
|
||||
|
||||
for (const block of blocks) {
|
||||
// 安全检查:代码块所在视图可能已在 50ms 防抖窗口内被卸载(笔记切换/重载)。
|
||||
// 此时 el 已脱离文档,若仍去 empty()/重渲染会操作已销毁的 DOM,破坏 Obsidian
|
||||
// 内部(CodeMirror)状态,引发 RangeError 与卡死。故跳过并清理该登记。
|
||||
if (!block.el.isConnected) {
|
||||
unregisterRenderedBlock(block.el);
|
||||
continue;
|
||||
}
|
||||
// 任一过滤器命中即重渲染(多请求合并语义)
|
||||
const should = filters.some((f) => f(block.source, block.type));
|
||||
if (should) {
|
||||
const handler = registry.get(block.type);
|
||||
if (handler) {
|
||||
try {
|
||||
block.el.empty();
|
||||
handler(block.source, block.el, block.ctx);
|
||||
} catch (e) {
|
||||
console.error('[workout] 代码块重渲染失败,已跳过该块以避免中断焦点恢复:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 兜底:对抗 Obsidian 异步二次夺焦(与 OS 窗口焦点恢复同源)
|
||||
if (focusOwnerWillRerender && appRef) {
|
||||
const restore = () => {
|
||||
const ae = document.activeElement;
|
||||
// 只在焦点仍「空」(body/null) 时才抢回,不打扰用户已主动转移的焦点
|
||||
if (ae === document.body || ae === null) {
|
||||
const e2 = appRef?.workspace.activeEditor?.editor;
|
||||
if (e2) e2.focus();
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(restore);
|
||||
window.setTimeout(restore, 80);
|
||||
}
|
||||
}, RERENDER_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
// 重渲染「所有」已渲染的代码块:用于语言切换、外部改文件等需要全局刷新的场景。
|
||||
export function rerenderAllBlocks(): void {
|
||||
scheduleRerender(() => true);
|
||||
}
|
||||
|
||||
// 只重渲染「显示指定训练项」的代码块:添加/编辑/删除一条记录时调用,
|
||||
// 避免无关表格也被重画(记录越多、代码块越多,全量重渲染越卡)。
|
||||
// 通过把代码块里的 `exercise:` 参数解析成稳定的 exerciseId 做匹配;
|
||||
// 无 config 或无法解析时,退化为按名字模糊匹配。
|
||||
export function rerenderBlocksForExercise(config: WorkoutConfig | null, exerciseId: string | undefined, exerciseName: string): void {
|
||||
const name = (exerciseName || '').toLowerCase();
|
||||
scheduleRerender((source) => {
|
||||
const m = source.match(/^\s*exercise:\s*(.+)$/m);
|
||||
if (!m) return true; // 没有 exercise 参数,无法判断,保守重画
|
||||
const q = m[1].trim();
|
||||
if (!q) return true;
|
||||
|
||||
// 优先用稳定的 exerciseId 匹配(跨语言可靠)。
|
||||
if (config && exerciseId) {
|
||||
const sourceExerciseId = resolveExerciseIdByName(config, q);
|
||||
if (sourceExerciseId) {
|
||||
return sourceExerciseId === exerciseId;
|
||||
}
|
||||
}
|
||||
|
||||
// 兜底:按当前语言显示名做模糊匹配(兼容旧数据/自定义训练项)。
|
||||
const lowerQ = q.toLowerCase();
|
||||
return lowerQ === name || name.includes(lowerQ) || lowerQ.includes(name);
|
||||
});
|
||||
}
|
||||
|
||||
// 只重渲染指定类型的代码块(保留以兼容旧调用,已并入 scheduleRerender)。
|
||||
export function rerenderBlocksByType(type: string): void {
|
||||
scheduleRerender((_source, blockType) => blockType === type);
|
||||
}
|
||||
244
src/codeblock/workoutDay.ts
Normal file
244
src/codeblock/workoutDay.ts
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
import { App, Component, MarkdownPostProcessorContext, MarkdownRenderChild, Notice, TFile } from 'obsidian';
|
||||
import { LogRow, WorkoutConfig } from '../data/types';
|
||||
import { t } from '../i18n';
|
||||
import { computeStat, formatStatValue } from '../data/statExpr';
|
||||
import { getExerciseName, getMuscleName, resolveLogExerciseName } from '../data/display';
|
||||
import { registerRenderedBlock, unregisterRenderedBlock } from './registry';
|
||||
|
||||
/*
|
||||
* workoutDay.ts —— 把 ```workout-day 代码块渲染成「当日训练总览表」。
|
||||
*
|
||||
* 用户在笔记里写:
|
||||
* ```workout-day
|
||||
* day: 2026-07-12
|
||||
* ```
|
||||
* 或 day: today(显示当日,活数据)/ 不写 day(同样显示当日,但多出一个「固定为当日」按钮)。
|
||||
*
|
||||
* 表格列(5 列):
|
||||
* 项目 —— 该日训练到的每个训练项(按训练先后排序)
|
||||
* 数据统计值 —— 该训练项所属类型下、已启用的数据统计条目(可多条,合并一格)
|
||||
* 主肌群 —— 该训练项配置的 primary 肌肉
|
||||
* 辅助肌群 —— 该训练项配置的 secondary 肌肉
|
||||
* 训练方案 —— 该训练项当日记录的 plan 字段(去重后合并展示)
|
||||
*
|
||||
* 「固定为当日」按钮:仅在「没有写 day 参数」时出现。点击后把当天的日期
|
||||
* 写进代码块的 day 参数(直接改笔记源码),按钮随即消失,表格固定为该日。
|
||||
* 注意:当 day=today 时,虽然显示的是当日数据,但因为 day 参数已存在,按钮同样不显示。
|
||||
*/
|
||||
|
||||
// 代码块支持的参数
|
||||
interface WorkoutDayParams {
|
||||
// day 的实际取值:'today' 或 'YYYY-MM-DD' 字面日期;缺省时 undefined
|
||||
dayValue?: string;
|
||||
// 是否显式写了 day 参数(写了 today 也算 true)
|
||||
hasDayParam: boolean;
|
||||
}
|
||||
|
||||
// 解析代码块正文:找出 day 参数及其取值,并记录"是否显式写了 day"。
|
||||
function parseParams(source: string): WorkoutDayParams {
|
||||
const params: WorkoutDayParams = { hasDayParam: false };
|
||||
for (const line of source.split('\n')) {
|
||||
const [key, value] = line.split(':').map((s) => s.trim());
|
||||
if (!key || value === undefined) continue;
|
||||
if (key === 'day') {
|
||||
params.hasDayParam = true;
|
||||
params.dayValue = value || undefined;
|
||||
}
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
// 把 Date 格式化为本地 'YYYY-MM-DD'(与 CSV 里 timestamp 的日期段同格式,便于直接比对)。
|
||||
function formatLocalDate(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
// 校验 'YYYY-MM-DD' 字面日期格式是否合法(仅做格式校验,不校验真实日历)。
|
||||
function isValidDateStr(s: string): boolean {
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(s);
|
||||
}
|
||||
|
||||
// 解析目标日期:返回 { target, live }
|
||||
// - target:用于过滤日志的日期字符串(同 CSV 日期段格式)
|
||||
// - live:true 表示"随真实日期滚动"(无 day 参数 / day=today),重渲染时会重新取今天
|
||||
function resolveTargetDate(params: WorkoutDayParams): { target: string; live: boolean } {
|
||||
if (!params.hasDayParam) {
|
||||
return { target: formatLocalDate(new Date()), live: true };
|
||||
}
|
||||
if (params.dayValue === 'today') {
|
||||
return { target: formatLocalDate(new Date()), live: true };
|
||||
}
|
||||
// 字面日期:无论格式是否合法,都作为 target 用于比对(非法格式下不会命中任何记录)
|
||||
return { target: params.dayValue ?? '', live: false };
|
||||
}
|
||||
|
||||
// 用于追踪已经为哪些 el 注册过 Obsidian Component,防止重渲染时重复 ctx.addChild。
|
||||
const registeredComponents = new WeakMap<HTMLElement, boolean>();
|
||||
|
||||
// 主渲染函数:把代码块渲染成「当日训练总览表」。
|
||||
export async function renderWorkoutDay(
|
||||
source: string,
|
||||
el: HTMLElement,
|
||||
ctx: MarkdownPostProcessorContext,
|
||||
app: App,
|
||||
logs: LogRow[],
|
||||
config: WorkoutConfig
|
||||
): Promise<void> {
|
||||
// 首次渲染时:注册 Obsidian 子组件,在代码块所在视图卸载时自动从 registry 移除。
|
||||
if (!registeredComponents.has(el)) {
|
||||
const child = new MarkdownRenderChild(el);
|
||||
child.onunload = () => {
|
||||
unregisterRenderedBlock(el);
|
||||
registeredComponents.delete(el);
|
||||
};
|
||||
ctx.addChild(child);
|
||||
registeredComponents.set(el, true);
|
||||
registerRenderedBlock(el, 'workout-day', source, ctx);
|
||||
}
|
||||
|
||||
const params = parseParams(source);
|
||||
const { target } = resolveTargetDate(params);
|
||||
|
||||
// 点击「固定为当日」:把今天的日期写进代码块源码的 day 参数,使表格固定为该日。
|
||||
async function pinDayToToday(): Promise<void> {
|
||||
const file = ctx.sourcePath ? app.vault.getAbstractFileByPath(ctx.sourcePath) : null;
|
||||
if (!file || !(file instanceof TFile)) {
|
||||
new Notice(t('codeblock.day.pinFailed'));
|
||||
return;
|
||||
}
|
||||
const info = ctx.getSectionInfo(el);
|
||||
if (!info) {
|
||||
new Notice(t('codeblock.day.pinFailed'));
|
||||
return;
|
||||
}
|
||||
const today = formatLocalDate(new Date());
|
||||
const content = await app.vault.read(file);
|
||||
const lines = content.split('\n');
|
||||
let found = false;
|
||||
// 在代码块体内(开围栏之后、闭围栏之前)查找是否已有 day: 行
|
||||
for (let i = info.lineStart + 1; i < info.lineEnd; i++) {
|
||||
if (/^\s*day\s*:/.test(lines[i])) {
|
||||
lines[i] = `day: ${today}`;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 没有就插在闭围栏之前
|
||||
if (!found) {
|
||||
lines.splice(info.lineEnd, 0, `day: ${today}`);
|
||||
}
|
||||
await app.vault.modify(file, lines.join('\n'));
|
||||
}
|
||||
|
||||
// 过滤出目标日期当天的全部记录(timestamp 形如 "2026-07-12 18:30",取空格前的日期段比对)。
|
||||
// 防御:timestamp 缺失的记录直接忽略,避免脏数据导致 split(undefined) 崩溃。
|
||||
const dayLogs = logs.filter((log) => log.timestamp && log.timestamp.split(' ')[0] === target);
|
||||
|
||||
// 容器与头部
|
||||
const container = el.createDiv();
|
||||
container.addClass('workout-day-container');
|
||||
|
||||
const header = container.createDiv();
|
||||
header.addClass('workout-day-header');
|
||||
header.createSpan({ text: t('codeblock.day.viewing', { date: target }), cls: 'workout-day-date' });
|
||||
// 「固定为当日」按钮:仅当「没写 day 参数」时显示(day=today 视为已固定,不显示)。
|
||||
if (!params.hasDayParam) {
|
||||
const pinBtn = header.createEl('button', { text: t('codeblock.day.pinToday') });
|
||||
pinBtn.addClass('mod-cta');
|
||||
pinBtn.addEventListener('click', () => {
|
||||
void pinDayToToday();
|
||||
});
|
||||
}
|
||||
|
||||
// 空状态:该日没有任何训练记录
|
||||
if (dayLogs.length === 0) {
|
||||
container.createDiv({ text: t('codeblock.day.empty', { date: target }), cls: 'workout-empty' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 按训练项(exercise)聚合:以 exerciseId 为稳定 key,无 id 的旧数据回退到显示名。
|
||||
interface DayGroup {
|
||||
key: string;
|
||||
exerciseId?: string;
|
||||
category: string;
|
||||
logs: LogRow[];
|
||||
firstTs: string;
|
||||
}
|
||||
const groups = new Map<string, DayGroup>();
|
||||
for (const log of dayLogs) {
|
||||
const nameFallback = resolveLogExerciseName(config, log);
|
||||
const key = log.exerciseId || nameFallback || '(unknown)';
|
||||
let group = groups.get(key);
|
||||
if (!group) {
|
||||
group = { key, exerciseId: log.exerciseId, category: log.category, logs: [], firstTs: log.timestamp || '' };
|
||||
groups.set(key, group);
|
||||
}
|
||||
group.logs.push(log);
|
||||
if (log.timestamp && log.timestamp < group.firstTs) group.firstTs = log.timestamp;
|
||||
}
|
||||
|
||||
// 按当日首次训练时间排序(训练先后),更符合"今天练了啥"的阅读顺序。
|
||||
const sortedGroups = Array.from(groups.values()).sort((a, b) => a.firstTs.localeCompare(b.firstTs));
|
||||
|
||||
// 画表格
|
||||
const table = container.createEl('table');
|
||||
table.addClass('workout-day-table');
|
||||
|
||||
const thead = table.createEl('thead');
|
||||
const headerRow = thead.createEl('tr');
|
||||
headerRow.createEl('th', { text: t('codeblock.day.project') });
|
||||
headerRow.createEl('th', { text: t('codeblock.day.statValue') });
|
||||
headerRow.createEl('th', { text: t('codeblock.day.primaryMuscles') });
|
||||
headerRow.createEl('th', { text: t('codeblock.day.secondaryMuscles') });
|
||||
headerRow.createEl('th', { text: t('codeblock.day.plan') });
|
||||
|
||||
const tbody = table.createEl('tbody');
|
||||
for (const group of sortedGroups) {
|
||||
const row = tbody.createEl('tr');
|
||||
|
||||
// 1) 项目名:优先用配置里的训练项对象拿显示名,否则回退到日志解析名
|
||||
const exercise = group.exerciseId ? config.exercises.find((e) => e.id === group.exerciseId) : undefined;
|
||||
const name = exercise
|
||||
? getExerciseName(exercise)
|
||||
: (resolveLogExerciseName(config, group.logs[0]) || t('codeblock.day.unknown'));
|
||||
row.createEl('td', { text: name });
|
||||
|
||||
// 2) 数据统计值:该训练项所属类型下、已启用的统计条目(多条合并一格)。
|
||||
const matchedStats = (config.statistics ?? []).filter(
|
||||
(s) => s.enabled && s.associatedTypes.includes(group.category)
|
||||
);
|
||||
const statText = matchedStats.length
|
||||
? matchedStats.map((s) => `${s.name}: ${formatStatValue(computeStat(s, group.logs))}`).join(' | ')
|
||||
: '—';
|
||||
row.createEl('td', { text: statText });
|
||||
|
||||
// 3) 主肌群 / 4) 辅助肌群:取自训练项配置的 muscles(primary / secondary)
|
||||
row.createEl('td', { text: muscleNames(config, exercise, 'primary') });
|
||||
row.createEl('td', { text: muscleNames(config, exercise, 'secondary') });
|
||||
|
||||
// 5) 训练方案:该训练项当日记录的 plan 字段(去重合并;无则显示占位)
|
||||
const plans = Array.from(
|
||||
new Set(group.logs.map((l) => l.plan).filter((p): p is string => typeof p === 'string' && p.length > 0))
|
||||
);
|
||||
row.createEl('td', { text: plans.length ? plans.join('、') : '—' });
|
||||
}
|
||||
}
|
||||
|
||||
// 取出某训练项在指定角色(primary/secondary)下配置的肌肉显示名,用"、"连接;无则 "—"。
|
||||
function muscleNames(
|
||||
config: WorkoutConfig,
|
||||
exercise: { muscles?: { muscleId: string; role: 'primary' | 'secondary' }[] } | undefined,
|
||||
role: 'primary' | 'secondary'
|
||||
): string {
|
||||
if (!exercise?.muscles || exercise.muscles.length === 0) return '—';
|
||||
const names = exercise.muscles
|
||||
.filter((m) => m.role === role)
|
||||
.map((m) => {
|
||||
const muscle = config.muscles.find((mm) => mm.id === m.muscleId);
|
||||
return muscle ? getMuscleName(muscle) : m.muscleId;
|
||||
});
|
||||
return names.length ? names.join('、') : '—';
|
||||
}
|
||||
437
src/codeblock/workoutHeatmap.ts
Normal file
437
src/codeblock/workoutHeatmap.ts
Normal file
|
|
@ -0,0 +1,437 @@
|
|||
import { Component, MarkdownPostProcessorContext, MarkdownRenderChild } from 'obsidian';
|
||||
import { HeatmapLevel, LogRow, Muscle, StatDef, WorkoutConfig } from '../data/types';
|
||||
import { computeStat } from '../data/statExpr';
|
||||
import {
|
||||
formatSvgMuscleLabel,
|
||||
HIDDEN_SVG_GROUP_IDS,
|
||||
SvgMuscleEntry,
|
||||
SVG_MUSCLE_CATALOG,
|
||||
} from '../data/svgMuscleCatalog';
|
||||
import { registerRenderedBlock, unregisterRenderedBlock } from './registry';
|
||||
import { getLocale, t } from '../i18n';
|
||||
import frontSvg from '../assets/muscle_layer_front.svg';
|
||||
import backSvg from '../assets/muscle_layer_back.svg';
|
||||
|
||||
const DEFAULT_HEATMAP_SCALE: HeatmapLevel[] = [
|
||||
{ color: '#3b82f6', max: 5 },
|
||||
{ color: '#22c55e', max: 10 },
|
||||
{ color: '#f97316', max: 20 },
|
||||
{ color: '#ef4444', max: 40 },
|
||||
];
|
||||
|
||||
/* workoutHeatmap.ts —— 把 ```workout-heatmap 代码块渲染成全身肌肉热力图。
|
||||
* 精细度自动跟随肌肉管理里的 svgRegionIds 配置;支持正/背切换、指标与时间窗参数。 */
|
||||
|
||||
interface HeatmapParams {
|
||||
metric?: string;
|
||||
range?: string;
|
||||
}
|
||||
|
||||
function parseParams(source: string): HeatmapParams {
|
||||
const params: HeatmapParams = {};
|
||||
for (const line of source.split('\n')) {
|
||||
const [key, value] = line.split(':').map((s) => s.trim());
|
||||
if (!key || value === undefined) continue;
|
||||
if (key === 'metric') params.metric = value;
|
||||
if (key === 'range') params.range = value;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function todayStr(): string {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function dateWithinRange(dateStr: string, range?: string): boolean {
|
||||
if (!range) return true;
|
||||
if (range === 'all') return true;
|
||||
if (RANGE_OPTIONS.includes(range)) {
|
||||
const days = parseInt(range);
|
||||
const today = new Date(`${todayStr()}T00:00:00`);
|
||||
const cutoff = new Date(today);
|
||||
cutoff.setDate(cutoff.getDate() - (days - 1));
|
||||
const d = new Date(`${dateStr}T00:00:00`);
|
||||
return d >= cutoff && d <= today;
|
||||
}
|
||||
if (range.includes('..')) {
|
||||
const [start, end] = range.split('..').map((s) => s.trim());
|
||||
return dateStr >= start && dateStr <= end;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const RANGE_OPTIONS = ['7d', '30d', '90d'];
|
||||
|
||||
// 静态 SVG 解析缓存:frontSvg / backSvg 是固定字符串(约 320KB),每次重渲染都 innerHTML 解析
|
||||
// 会在主线程做昂贵的 HTML 词法分析。改为「模块内只解析一次、之后 cloneNode 复用」,重渲染代价
|
||||
// 从「解析 640KB」降为「克隆已解析的 DOM」,对删除记录等高频数据变化意义极大(否则删除即卡顿)。
|
||||
// 缓存的是 <svg> 元素本身,cloneNode 后直接挂到容器下,渲染出的 DOM 结构与原来一致。
|
||||
let parsedFrontSvg: SVGElement | null = null;
|
||||
let parsedBackSvg: SVGElement | null = null;
|
||||
function getMuscleSvg(side: 'front' | 'back'): SVGElement {
|
||||
if (side === 'front') {
|
||||
if (!parsedFrontSvg) {
|
||||
const holder = document.createElement('div');
|
||||
holder.innerHTML = frontSvg;
|
||||
parsedFrontSvg = holder.firstElementChild as SVGElement;
|
||||
}
|
||||
return parsedFrontSvg;
|
||||
}
|
||||
if (!parsedBackSvg) {
|
||||
const holder = document.createElement('div');
|
||||
holder.innerHTML = backSvg;
|
||||
parsedBackSvg = holder.firstElementChild as SVGElement;
|
||||
}
|
||||
return parsedBackSvg;
|
||||
}
|
||||
|
||||
// —— 肌肉悬停提示(自定义黑框气泡,即时显示)——
|
||||
// 页面级单例气泡 + 事件委托到容器:进入肌肉即显示、跟随鼠标、移出即隐藏,零延迟。
|
||||
// 背景硬编码深色(不依赖主题变量),避免某些主题下渲染成白框;同时不注入 SVG 原生
|
||||
// <title>(Windows 下原生提示是白底系统框,会与黑框重复),由本气泡统一承担。
|
||||
let heatmapTooltipEl: HTMLElement | null = null;
|
||||
function ensureHeatmapTooltip(): HTMLElement {
|
||||
if (heatmapTooltipEl && document.body.contains(heatmapTooltipEl)) return heatmapTooltipEl;
|
||||
const tip = document.createElement('div');
|
||||
tip.id = 'workout-heatmap-tooltip';
|
||||
tip.addClass('workout-heatmap-tooltip');
|
||||
tip.style.display = 'none';
|
||||
document.body.appendChild(tip);
|
||||
heatmapTooltipEl = tip;
|
||||
return tip;
|
||||
}
|
||||
|
||||
function bindMuscleTooltips(wrap: HTMLElement, side: 'front' | 'back'): void {
|
||||
const tip = ensureHeatmapTooltip();
|
||||
|
||||
const entryFor = (target: EventTarget | null): SvgMuscleEntry | null => {
|
||||
let el: Element | null = target as Element | null;
|
||||
while (el && el !== wrap) {
|
||||
const id = el.getAttribute('id');
|
||||
if (id) {
|
||||
const entry = SVG_MUSCLE_CATALOG.find((e) => e.id === id && e.side === side);
|
||||
if (entry) return entry;
|
||||
}
|
||||
el = el.parentElement;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const locate = (me: MouseEvent) => {
|
||||
tip.style.left = `${me.clientX + 12}px`;
|
||||
tip.style.top = `${me.clientY + 12}px`;
|
||||
};
|
||||
|
||||
// 进入肌肉即时显示(无延迟);在肌肉间移动直接换名;移出隐藏。
|
||||
wrap.addEventListener('mouseover', (ev: Event) => {
|
||||
const entry = entryFor(ev.target);
|
||||
if (!entry) return;
|
||||
const me = ev as MouseEvent;
|
||||
tip.textContent = formatSvgMuscleLabel(entry, getLocale());
|
||||
tip.style.display = 'block';
|
||||
locate(me);
|
||||
});
|
||||
wrap.addEventListener('mousemove', (ev: Event) => {
|
||||
if (tip.style.display === 'block') locate(ev as MouseEvent);
|
||||
});
|
||||
wrap.addEventListener('mouseleave', () => {
|
||||
tip.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function resolveDefaultMetric(config: WorkoutConfig): StatDef | undefined {
|
||||
return config.statistics.find((s) => s.heatmapDefault) ?? config.statistics.find((s) => s.id === 'count');
|
||||
}
|
||||
|
||||
function resolveMetric(config: WorkoutConfig, nameOrId?: string): StatDef | undefined {
|
||||
if (!nameOrId) return resolveDefaultMetric(config);
|
||||
const lower = nameOrId.toLowerCase();
|
||||
return config.statistics.find((s) => s.id.toLowerCase() === lower || s.name.toLowerCase() === lower);
|
||||
}
|
||||
|
||||
function resolveRange(muscle: Muscle, codeRange?: string): string {
|
||||
if (muscle.heatmapRange) return muscle.heatmapRange;
|
||||
if (codeRange) return codeRange;
|
||||
return '7d';
|
||||
}
|
||||
|
||||
function resolveMetricId(config: WorkoutConfig, muscle: Muscle, codeMetric?: string): StatDef | undefined {
|
||||
if (muscle.heatmapMetric) return resolveMetric(config, muscle.heatmapMetric);
|
||||
if (codeMetric) return resolveMetric(config, codeMetric);
|
||||
return resolveDefaultMetric(config);
|
||||
}
|
||||
|
||||
function roleWeight(role: 'primary' | 'secondary'): number {
|
||||
return role === 'primary' ? 1.0 : 0.5;
|
||||
}
|
||||
|
||||
function computeMuscleValue(
|
||||
muscle: Muscle,
|
||||
stat: StatDef,
|
||||
range: string,
|
||||
logs: LogRow[],
|
||||
exerciseMuscleMap: Map<string, { muscleId: string; role: 'primary' | 'secondary' }[]>
|
||||
): number {
|
||||
let total = 0;
|
||||
for (const log of logs) {
|
||||
if (!log.timestamp || !log.exerciseId || !dateWithinRange(log.timestamp.split(' ')[0], range)) continue;
|
||||
// 用预构建的 map 做 O(1) 查找,代替原先 config.exercises.find(每次 O(exercises)),
|
||||
// 把整体复杂度从 O(muscles × logs × exercises) 降到 O(muscles × logs)。
|
||||
const em = exerciseMuscleMap.get(log.exerciseId);
|
||||
if (!em) continue;
|
||||
const hit = em.find((m) => m.muscleId === muscle.id);
|
||||
if (!hit) continue;
|
||||
const instanceValue = computeStat(stat, [log]);
|
||||
if (!Number.isFinite(instanceValue)) continue;
|
||||
total += instanceValue * roleWeight(hit.role);
|
||||
}
|
||||
return Math.round(total * 100) / 100;
|
||||
}
|
||||
|
||||
function colorForValue(value: number, scale?: HeatmapLevel[]): string {
|
||||
const levels = scale && scale.length > 0 ? scale : DEFAULT_HEATMAP_SCALE;
|
||||
// 按阈值升序:值 ≤ 某档阈值即取该色;超过所有阈值则取最高档(最重)。
|
||||
const sorted = [...levels].sort((a, b) => (a.max ?? Infinity) - (b.max ?? Infinity));
|
||||
for (const level of sorted) {
|
||||
if (value <= (level.max ?? Infinity)) return level.color;
|
||||
}
|
||||
return sorted[sorted.length - 1]?.color ?? '#ef4444';
|
||||
}
|
||||
|
||||
function colorToCss(color: string): string {
|
||||
const map: Record<string, string> = {
|
||||
blue: '#3b82f6',
|
||||
green: '#22c55e',
|
||||
orange: '#f97316',
|
||||
red: '#ef4444',
|
||||
};
|
||||
if (color.startsWith('#')) return color; // 十六进制直接返回(自定义颜色)
|
||||
return map[color] ?? color;
|
||||
}
|
||||
|
||||
const registeredComponents = new WeakMap<HTMLElement, boolean>();
|
||||
// 记录每个热力图代码块对应的 IntersectionObserver,便于重渲染/卸载时断开,避免泄漏与重复计算。
|
||||
const heatmapObservers = new WeakMap<HTMLElement, IntersectionObserver>();
|
||||
|
||||
export async function renderWorkoutHeatmap(
|
||||
source: string,
|
||||
el: HTMLElement,
|
||||
ctx: MarkdownPostProcessorContext,
|
||||
logs: LogRow[],
|
||||
config: WorkoutConfig
|
||||
): Promise<void> {
|
||||
if (!registeredComponents.has(el)) {
|
||||
const child = new MarkdownRenderChild(el);
|
||||
child.onunload = () => {
|
||||
unregisterRenderedBlock(el);
|
||||
const ob = heatmapObservers.get(el);
|
||||
if (ob) ob.disconnect();
|
||||
heatmapObservers.delete(el);
|
||||
registeredComponents.delete(el);
|
||||
};
|
||||
ctx.addChild(child);
|
||||
registeredComponents.set(el, true);
|
||||
registerRenderedBlock(el, 'workout-heatmap', source, ctx);
|
||||
}
|
||||
|
||||
const params = parseParams(source);
|
||||
const defaultMetric = resolveDefaultMetric(config);
|
||||
const metric = params.metric ? resolveMetric(config, params.metric) : defaultMetric;
|
||||
const codeRange = params.range;
|
||||
|
||||
// 无映射兜底:所有肌肉都没有配置 svgRegionIds 时,渲染再多样也上不了色,
|
||||
// 直接给用户一条可操作的提示,避免「有数据但热力图一片空白」的困惑。
|
||||
const hasAnyMapping = config.muscles.some((m) => (m.svgRegionIds?.length ?? 0) > 0);
|
||||
if (!hasAnyMapping) {
|
||||
const container = el.createDiv({ cls: 'workout-heatmap-container' });
|
||||
container.createDiv({ text: t('codeblock.heatmap.noMapping'), cls: 'workout-heatmap-empty' });
|
||||
return;
|
||||
}
|
||||
|
||||
const container = el.createDiv();
|
||||
container.addClass('workout-heatmap-container');
|
||||
|
||||
// 头部:视图切换 + 图例
|
||||
const header = container.createDiv();
|
||||
header.addClass('workout-heatmap-header');
|
||||
|
||||
const viewSwitch = header.createDiv();
|
||||
viewSwitch.addClass('workout-heatmap-switch');
|
||||
const frontBtn = viewSwitch.createEl('button', { text: t('modal.muscleManager.front') });
|
||||
const backBtn = viewSwitch.createEl('button', { text: t('modal.muscleManager.back') });
|
||||
frontBtn.addClass('mod-cta');
|
||||
|
||||
const info = header.createDiv();
|
||||
info.addClass('workout-heatmap-info');
|
||||
const metricName = metric?.name ?? t('modal.muscleManager.followDefaultMetric');
|
||||
const rangeText = codeRange ?? '7d';
|
||||
info.createEl('span', { text: `${t('modal.muscleManager.heatmapMetric')}: ${metricName}` });
|
||||
info.createEl('span', { text: `${t('modal.muscleManager.heatmapRange')}: ${rangeText}` });
|
||||
|
||||
const legend = header.createDiv();
|
||||
legend.addClass('workout-heatmap-legend');
|
||||
// 阈值逐肌可配,单一图例无法呈现各肌不同刻度,故图例仅展示 4 色由轻→重,
|
||||
// 具体阈值在各肌肉编辑页查看。
|
||||
legend.createEl('span', { text: t('codeblock.heatmap.legendLow'), cls: 'workout-heatmap-legend-end' });
|
||||
const legendScale = [...DEFAULT_HEATMAP_SCALE].sort((a, b) => (a.max ?? Infinity) - (b.max ?? Infinity));
|
||||
for (let i = 0; i < legendScale.length; i++) {
|
||||
const level = legendScale[i];
|
||||
const item = legend.createDiv();
|
||||
item.addClass('workout-heatmap-legend-item');
|
||||
const dot = item.createEl('span');
|
||||
dot.style.width = '14px';
|
||||
dot.style.height = '14px';
|
||||
dot.style.borderRadius = '50%';
|
||||
dot.style.background = colorToCss(level.color);
|
||||
}
|
||||
legend.createEl('span', { text: t('codeblock.heatmap.legendHigh'), cls: 'workout-heatmap-legend-end' });
|
||||
|
||||
const viewsWrap = container.createDiv();
|
||||
viewsWrap.addClass('workout-heatmap-views');
|
||||
|
||||
const frontWrap = viewsWrap.createDiv();
|
||||
frontWrap.addClass('workout-heatmap-view');
|
||||
frontWrap.appendChild(getMuscleSvg('front').cloneNode(true));
|
||||
|
||||
const backWrap = viewsWrap.createDiv();
|
||||
backWrap.addClass('workout-heatmap-view');
|
||||
backWrap.style.display = 'none';
|
||||
backWrap.appendChild(getMuscleSvg('back').cloneNode(true));
|
||||
|
||||
// 隐藏头部/面部轮廓(cloneNode 复用已解析 SVG,代价极低,骨架阶段即可完成)
|
||||
for (const wrap of [frontWrap, backWrap]) {
|
||||
for (const hid of HIDDEN_SVG_GROUP_IDS) {
|
||||
const el2 = wrap.querySelector(`[id="${hid}"]`) as HTMLElement | null;
|
||||
if (el2) el2.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// 给每块肌肉路径绑定悬停提示(骨架阶段绑定一次即可,正/背切换只是显隐,路径元素不变)。
|
||||
// try/catch 兜底:提示气泡属增强体验,绝不应拖垮核心渲染(正/背切换、上色)。
|
||||
try {
|
||||
bindMuscleTooltips(frontWrap, 'front');
|
||||
bindMuscleTooltips(backWrap, 'back');
|
||||
} catch (err) {
|
||||
console.warn('[workout-heatmap] 绑定悬停提示失败,已跳过(不影响上色与视图切换)', err);
|
||||
}
|
||||
|
||||
// 懒渲染占位:未进入视口前只显示骨架 + 一句提示,真正的「逐肌肉计算 + 上色」
|
||||
// 延迟到代码块滚动进入视口时才执行(按需渲染)。这样即使在有热力图的笔记里删记录,
|
||||
// 只要热力图不在视野内,就不会跟着每次数据变化做 O(muscles × logs) 的重计算,主线程更轻。
|
||||
const lazyTip = container.createDiv({ cls: 'workout-heatmap-lazy' });
|
||||
lazyTip.setText(t('codeblock.heatmap.lazy'));
|
||||
|
||||
// —— 以下为「重计算 + 上色」,仅当代码块可见时执行 ——
|
||||
function applyHeatmap(): void {
|
||||
if (lazyTip.isConnected) lazyTip.remove();
|
||||
|
||||
// 预构建 exerciseId → 训练项肌肉映射,供 computeMuscleValue 做 O(1) 查找(避免逐日志 O(exercises) 线性扫描)。
|
||||
const exerciseMuscleMap = new Map<string, { muscleId: string; role: 'primary' | 'secondary' }[]>();
|
||||
for (const ex of config.exercises) {
|
||||
if (ex.muscles && ex.muscles.length) {
|
||||
exerciseMuscleMap.set(ex.id, ex.muscles.map((m) => ({ muscleId: m.muscleId, role: m.role })));
|
||||
}
|
||||
}
|
||||
|
||||
// 计算每块肌肉的标量值
|
||||
const muscleEntries: { muscle: Muscle; stat: StatDef; range: string; value: number; scale: HeatmapLevel[] }[] = [];
|
||||
for (const muscle of config.muscles) {
|
||||
const stat = resolveMetricId(config, muscle, params.metric);
|
||||
if (!stat) continue;
|
||||
const range = resolveRange(muscle, codeRange);
|
||||
const value = computeMuscleValue(muscle, stat, range, logs, exerciseMuscleMap);
|
||||
// 逐肌分档:优先用该肌自己的 heatmapLevels,否则用所选 StatDef 的默认分档
|
||||
const scale = muscle.heatmapLevels ?? stat.heatmapScale ?? DEFAULT_HEATMAP_SCALE;
|
||||
muscleEntries.push({ muscle, stat, range, value, scale });
|
||||
}
|
||||
|
||||
const activeLogs = logs.filter((l) => l.timestamp && dateWithinRange(l.timestamp.split(' ')[0], codeRange ?? '7d'));
|
||||
const hasData = activeLogs.length > 0 && muscleEntries.length > 0 && muscleEntries.some((e) => e.value > 0);
|
||||
if (!hasData) {
|
||||
const emptyMsg = codeRange === 'all'
|
||||
? t('codeblock.heatmap.emptyAll')
|
||||
: t('codeblock.heatmap.emptyRange', { range: rangeText });
|
||||
container.createDiv({ text: emptyMsg, cls: 'workout-heatmap-empty' });
|
||||
}
|
||||
|
||||
function applyColorToView(wrap: HTMLElement, side: 'front' | 'back'): void {
|
||||
if (!hasData) return;
|
||||
// 先聚合每个 SVG 路径的累计值,并记下「贡献最大的肌肉」的分档作为该路径的着色刻度。
|
||||
// 注意:同一 id 可能在目录里同时存在 front 和 back 两条记录(同一肌群在正/背
|
||||
// 两个 SVG 文件里都画了同名路径)。这里用 some() 判断「该 id 是否在目标 side
|
||||
// 上存在」,避免 find() 命中第一条记录后误把另一侧的同名路径过滤掉。
|
||||
const pathInfo = new Map<string, { value: number; scale: HeatmapLevel[]; winner: number }>();
|
||||
for (const { muscle, value, scale } of muscleEntries) {
|
||||
if (value <= 0) continue;
|
||||
const ids = (muscle.svgRegionIds ?? []).filter((id) =>
|
||||
SVG_MUSCLE_CATALOG.some((e) => e.id === id && e.side === side)
|
||||
);
|
||||
if (ids.length === 0) continue;
|
||||
for (const id of ids) {
|
||||
const ex = pathInfo.get(id);
|
||||
if (!ex) {
|
||||
pathInfo.set(id, { value, scale, winner: value });
|
||||
} else {
|
||||
ex.value += value;
|
||||
// 多肌命中同路径时,用「单次贡献最大」的肌肉的分档来着色
|
||||
if (value > ex.winner) {
|
||||
ex.winner = value;
|
||||
ex.scale = scale;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, info] of pathInfo) {
|
||||
const pathEl = wrap.querySelector(`[id="${id}"]`) as SVGPathElement | SVGGElement | null;
|
||||
if (!pathEl) {
|
||||
console.warn(`[workout-heatmap] SVG path not found: ${id}`);
|
||||
continue;
|
||||
}
|
||||
const colorName = colorForValue(info.value, info.scale);
|
||||
pathEl.style.fill = colorToCss(colorName);
|
||||
pathEl.style.opacity = '0.85';
|
||||
}
|
||||
}
|
||||
|
||||
applyColorToView(frontWrap, 'front');
|
||||
applyColorToView(backWrap, 'back');
|
||||
}
|
||||
|
||||
// 视图切换按钮(骨架阶段就可点,不依赖计算完成)
|
||||
function showSide(side: 'front' | 'back'): void {
|
||||
frontWrap.style.display = side === 'front' ? 'block' : 'none';
|
||||
backWrap.style.display = side === 'back' ? 'block' : 'none';
|
||||
frontBtn.toggleClass('mod-cta', side === 'front');
|
||||
backBtn.toggleClass('mod-cta', side === 'back');
|
||||
frontBtn.toggleClass('mod-muted', side === 'back');
|
||||
backBtn.toggleClass('mod-muted', side === 'front');
|
||||
}
|
||||
frontBtn.addEventListener('click', () => showSide('front'));
|
||||
backBtn.addEventListener('click', () => showSide('back'));
|
||||
showSide('front');
|
||||
|
||||
// 旧 observer(重渲染场景下可能仍存在)先断开,再建新的,避免泄漏/重复计算。
|
||||
const prevOb = heatmapObservers.get(el);
|
||||
if (prevOb) prevOb.disconnect();
|
||||
|
||||
if (typeof IntersectionObserver !== 'undefined') {
|
||||
// 仅当热力图代码块进入视口(含 200px 预加载边距)时才执行 applyHeatmap 重计算 + 上色。
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
for (const e of entries) {
|
||||
if (e.isIntersecting) {
|
||||
applyHeatmap();
|
||||
io.disconnect();
|
||||
heatmapObservers.delete(el);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, { rootMargin: '200px' });
|
||||
io.observe(el);
|
||||
heatmapObservers.set(el, io);
|
||||
} else {
|
||||
// 环境不支持 IntersectionObserver(极罕见):直接同步计算,行为回退为旧逻辑。
|
||||
applyHeatmap();
|
||||
}
|
||||
}
|
||||
346
src/codeblock/workoutLog.ts
Normal file
346
src/codeblock/workoutLog.ts
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
import { Component, MarkdownPostProcessorContext, MarkdownRenderChild } from 'obsidian';
|
||||
import { LogRow, FieldDef, WorkoutConfig } from '../data/types';
|
||||
import { t } from '../i18n';
|
||||
import { getFieldLabel, getFieldUnit, renderFieldValue, resolveLogExerciseName, resolveExerciseIdByName } from '../data/display';
|
||||
import { computeStat, formatStatValue } from '../data/statExpr';
|
||||
import { registerRenderedBlock, unregisterRenderedBlock } from './registry';
|
||||
|
||||
/*
|
||||
* workoutLog.ts —— 把 ```workout-log 代码块渲染成 HTML 表格
|
||||
* 这是真正「画表格」的代码。用户在笔记里写如下代码块:
|
||||
* ```workout-log
|
||||
* exercise: 深蹲
|
||||
* limit: 20
|
||||
* group_by: date
|
||||
* ```
|
||||
* 插件解析这些参数 → 过滤出对应训练项的日志记录 → 画表头和数据行,
|
||||
* 并给每行加「编辑/删除」按钮。数据变化时由 registry 触发的重渲染会再次调用本文件。
|
||||
*/
|
||||
|
||||
// 代码块支持的参数(都写在代码块正文里,用 key: value 形式)
|
||||
interface WorkoutLogParams {
|
||||
exercise: string;
|
||||
limit?: number;
|
||||
number?: number;
|
||||
day?: number;
|
||||
groupBy?: 'date' | 'week';
|
||||
sort?: 'desc' | 'asc';
|
||||
showAdd?: boolean;
|
||||
}
|
||||
|
||||
// 解析代码块正文:把 "key: value" 每行拆开,填进上面的参数对象
|
||||
function parseParams(source: string): WorkoutLogParams {
|
||||
// 先给一份「默认值」,下面遇到对应行再覆盖
|
||||
const params: WorkoutLogParams = {
|
||||
exercise: '',
|
||||
limit: 50,
|
||||
groupBy: 'date',
|
||||
sort: 'desc',
|
||||
showAdd: true,
|
||||
};
|
||||
|
||||
// 按换行把代码块正文切成多行,逐行读取参数
|
||||
const lines = source.split('\n');
|
||||
for (const line of lines) {
|
||||
// 每行按冒号切成 [key, value],并去掉首尾空格
|
||||
const [key, value] = line.split(':').map((s) => s.trim());
|
||||
// key 为空或没有 value 的行跳过(比如空行)
|
||||
if (!key || value === undefined) continue;
|
||||
|
||||
// 根据 key 名字分别赋值;switch 让每个参数各走各的处理分支
|
||||
switch (key) {
|
||||
case 'exercise':
|
||||
params.exercise = value;
|
||||
break;
|
||||
case 'limit':
|
||||
// parseInt 转数字,转不出来或 0 时用默认 50
|
||||
params.limit = parseInt(value) || 50;
|
||||
break;
|
||||
case 'number':
|
||||
// 限制渲染的记录条数(取最新 N 条),0 或非法值时忽略
|
||||
params.number = parseInt(value) || undefined;
|
||||
break;
|
||||
case 'day':
|
||||
// 仅显示数据中最近 N 天的记录,0 或非法值时忽略
|
||||
params.day = parseInt(value) || undefined;
|
||||
break;
|
||||
case 'group_by':
|
||||
params.groupBy = value as 'date' | 'week';
|
||||
break;
|
||||
case 'sort':
|
||||
params.sort = value as 'desc' | 'asc';
|
||||
break;
|
||||
case 'show_add':
|
||||
// 只要不是写 "false"(不分大小写)就当作显示
|
||||
params.showAdd = value.toLowerCase() !== 'false';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
// 把日志记录按指定维度分组,返回 分组key → 该组日志数组 的 Map
|
||||
// 例如 group_by=date 时,同一天的记录归到一组,key 就是日期字符串
|
||||
function groupLogs(logs: LogRow[], groupBy: 'date' | 'week'): Map<string, LogRow[]> {
|
||||
const groups = new Map<string, LogRow[]>();
|
||||
|
||||
for (const log of logs) {
|
||||
// 防御:timestamp 缺失的记录无法分组,直接跳过,避免 split(undefined) 崩溃。
|
||||
if (!log.timestamp) continue;
|
||||
|
||||
let key: string;
|
||||
switch (groupBy) {
|
||||
case 'date':
|
||||
// timestamp 形如 "2026-07-10 18:30",按空格切,取第 0 段就是日期
|
||||
key = log.timestamp.split(' ')[0];
|
||||
break;
|
||||
case 'week':
|
||||
// 按「第几周」分组:用年份-周号作为 key
|
||||
const date = new Date(log.timestamp);
|
||||
const weekNum = getWeekNumber(date);
|
||||
key = `${date.getFullYear()}-W${weekNum}`;
|
||||
break;
|
||||
default:
|
||||
key = log.timestamp.split(' ')[0];
|
||||
}
|
||||
|
||||
// Map 里还没有这个组就先建一个空数组
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, []);
|
||||
}
|
||||
// 把当前记录塞进对应组
|
||||
groups.get(key)!.push(log);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
// 计算某个日期是「当年的第几周」:用日期距元旦的天数 ÷ 7 向上取整
|
||||
function getWeekNumber(date: Date): number {
|
||||
const startOfYear = new Date(date.getFullYear(), 0, 1);
|
||||
const diff = date.getTime() - startOfYear.getTime();
|
||||
const oneWeek = 1000 * 60 * 60 * 24 * 7; // 一周的毫秒数
|
||||
return Math.ceil(diff / oneWeek);
|
||||
}
|
||||
|
||||
// 用于追踪已经为哪些 el 注册过 Obsidian Component,防止重渲染时重复 ctx.addChild。
|
||||
const registeredComponents = new WeakMap<HTMLElement, boolean>();
|
||||
|
||||
// 主渲染函数:把代码块渲染成表格。
|
||||
// 参数说明:
|
||||
// source/logs —— 代码块原文与全部日志记录
|
||||
// getTrainingTypeFields —— 根据训练类型取「有哪些字段」的函数
|
||||
// unit —— 用户偏好单位 kg/lb
|
||||
// config —— 聚合配置
|
||||
// onAddRecord/onEditRecord/onDeleteRecord —— 点击按钮时回调给上层去弹窗/删数据
|
||||
export async function renderWorkoutLog(
|
||||
source: string,
|
||||
el: HTMLElement,
|
||||
ctx: MarkdownPostProcessorContext,
|
||||
logs: LogRow[],
|
||||
getTrainingTypeFields: (category: string) => FieldDef[] | Promise<FieldDef[]>,
|
||||
unit: 'kg' | 'lb',
|
||||
config: WorkoutConfig,
|
||||
onAddRecord: (exercise: string, plan?: string) => void,
|
||||
onEditRecord: (log: LogRow) => void,
|
||||
onDeleteRecord: (log: LogRow) => void
|
||||
): Promise<void> {
|
||||
// 首次渲染时:注册 Obsidian 子组件,在代码块所在视图卸载时自动从 registry 移除。
|
||||
// 使用 WeakMap 保证一个 el 只注册一次,避免 data-changed / 语言切换等重绘场景下子组件累积。
|
||||
if (!registeredComponents.has(el)) {
|
||||
const child = new MarkdownRenderChild(el);
|
||||
child.onunload = () => {
|
||||
unregisterRenderedBlock(el);
|
||||
registeredComponents.delete(el);
|
||||
};
|
||||
ctx.addChild(child);
|
||||
registeredComponents.set(el, true);
|
||||
registerRenderedBlock(el, 'workout-log', source, ctx);
|
||||
}
|
||||
|
||||
// 先解析代码块参数
|
||||
const params = parseParams(source);
|
||||
// 没写 exercise 就没法展示,给个提示并登记后返回
|
||||
if (!params.exercise) {
|
||||
el.createDiv({ text: t('codeblock.noRecords', { exercise: t('codeblock.notSpecified') }) });
|
||||
return;
|
||||
}
|
||||
|
||||
// 把代码块里写的训练项名(如 "深蹲" / "Squat")解析成稳定的 exerciseId。
|
||||
// 记录只存稳定的 exerciseId,不存显示名,因此必须按 id 匹配,
|
||||
// 才能在中英文切换、改名后都查到对应记录。
|
||||
const targetExerciseId = resolveExerciseIdByName(config, params.exercise);
|
||||
const query = params.exercise.toLowerCase();
|
||||
|
||||
// 过滤目标训练项记录:优先按 exerciseId 精确匹配;无 id(旧数据/自定义)时按名字回退。
|
||||
let filteredLogs = logs.filter((log) => {
|
||||
if (targetExerciseId && log.exerciseId === targetExerciseId) return true;
|
||||
const resolved = resolveLogExerciseName(config, log);
|
||||
const lowerResolved = resolved.toLowerCase();
|
||||
return lowerResolved === query || lowerResolved.includes(query);
|
||||
});
|
||||
|
||||
// day 参数:仅保留「最近 N 个有记录的日」(按该动作的去重日期降序取前 N 天,跳过无训练的空白日)。
|
||||
// 这样无论数据间隔多大、或测试数据集中在同一天,day:N 都稳定显示最近 N 个训练日,
|
||||
// 不会出现「填任何数都只有一天」的体感问题。
|
||||
if (params.day && params.day > 0) {
|
||||
const distinctDays = Array.from(
|
||||
new Set(
|
||||
filteredLogs
|
||||
.filter((l) => !!l.timestamp)
|
||||
.map((l) => l.timestamp.split(' ')[0])
|
||||
)
|
||||
).sort(); // 升序(旧→新)
|
||||
// 取最近 N 天(升序数组的末尾 N 个 = 最新的 N 个训练日)
|
||||
const keepDays = new Set(distinctDays.slice(-params.day));
|
||||
filteredLogs = filteredLogs.filter((l) => !!l.timestamp && keepDays.has(l.timestamp.split(' ')[0]));
|
||||
}
|
||||
|
||||
// 排序(desc 默认从新到旧);timestamp 缺失的记录排最后,避免 localeCompare(undefined) 异常。
|
||||
filteredLogs = filteredLogs.sort((a, b) => {
|
||||
const ta = a.timestamp || '';
|
||||
const tb = b.timestamp || '';
|
||||
return params.sort === 'desc' ? tb.localeCompare(ta) : ta.localeCompare(tb);
|
||||
});
|
||||
|
||||
// 统计必须基于「完整的、未被 number/limit 裁剪的真实数据」:
|
||||
// 若统计也跟着用渲染出来的子集,次数/总量等会被渲染条数带偏
|
||||
// (例如当天 6 条只显示 3 条,统计却显示 3 而不是 6)。故先按完整数据分组供统计使用。
|
||||
const fullGroups = groupLogs(filteredLogs, params.groupBy ?? 'date');
|
||||
|
||||
// number 参数优先于 limit,仅控制「渲染条数」(取排序后的前 N 条),不影响上面的统计。
|
||||
const recordLimit = params.number ?? params.limit ?? 50;
|
||||
const displayLogs = filteredLogs.slice(0, recordLimit);
|
||||
|
||||
// 用裁剪后的数据分组,决定「画哪几行」
|
||||
const groups = groupLogs(displayLogs, params.groupBy ?? 'date');
|
||||
|
||||
// 从当前笔记路径取出文件名(去掉 .md)作为「训练方案名」,点击添加时传给上层
|
||||
const plan = ctx.sourcePath ? ctx.sourcePath.split('/').pop()?.replace('.md', '') : undefined;
|
||||
|
||||
// 一条记录都没有:显示空提示,并按需显示「添加记录」按钮
|
||||
if (filteredLogs.length === 0) {
|
||||
el.createDiv({ text: t('codeblock.noRecords', { exercise: params.exercise }) });
|
||||
if (params.showAdd) {
|
||||
// 空状态下同样提供「添加记录」入口
|
||||
const addBtn = el.createEl('button', { text: t('codeblock.addRecord', { exercise: params.exercise }) });
|
||||
addBtn.addClass('mod-cta');
|
||||
addBtn.addEventListener('click', () => onAddRecord(params.exercise, plan));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 有数据:开始画界面
|
||||
const container = el.createDiv();
|
||||
container.addClass('workout-log-container');
|
||||
|
||||
// 顶部「添加记录」按钮(若开启)
|
||||
if (params.showAdd) {
|
||||
const header = container.createDiv();
|
||||
header.addClass('workout-log-header');
|
||||
const addBtn = header.createEl('button', { text: t('codeblock.addRecord', { exercise: params.exercise }) });
|
||||
addBtn.addClass('mod-cta');
|
||||
addBtn.addEventListener('click', () => onAddRecord(params.exercise, plan));
|
||||
}
|
||||
|
||||
// 创建表格元素
|
||||
const table = container.createEl('table');
|
||||
table.addClass('workout-log-table');
|
||||
|
||||
// 取首条记录所属训练类型的字段定义(决定表头画哪些列)
|
||||
const category = filteredLogs[0].category;
|
||||
const fields = await getTrainingTypeFields(category);
|
||||
|
||||
// 本代码块训练项所属训练类型下、已启用且关联该类型的数据统计条目。
|
||||
const matchedStats = (config.statistics ?? []).filter(
|
||||
(s) => s.enabled && s.associatedTypes.includes(category)
|
||||
);
|
||||
|
||||
// 画表头 <thead>
|
||||
const thead = table.createEl('thead');
|
||||
const headerRow = thead.createEl('tr');
|
||||
|
||||
// 第一列固定为日期
|
||||
headerRow.createEl('th', { text: t('codeblock.date') });
|
||||
// 每个字段画一列,列标题 = 字段标签,若有单位在后面加 (单位)
|
||||
for (const field of fields) {
|
||||
const unitText = getFieldUnit(field, unit);
|
||||
headerRow.createEl('th', { text: `${getFieldLabel(field)}${unitText ? `(${unitText})` : ''}` });
|
||||
}
|
||||
// 备注列、操作列(编辑/删除按钮所在)
|
||||
headerRow.createEl('th', { text: t('codeblock.note') });
|
||||
headerRow.createEl('th', { text: t('codeblock.actions') });
|
||||
|
||||
// 画表体 <tbody>
|
||||
const tbody = table.createEl('tbody');
|
||||
|
||||
// 按渲染分组(displayLogs 已裁剪)的「分组名」排序
|
||||
const groupKeys = Array.from(groups.keys()).sort((a, b) => (params.sort === 'desc' ? b.localeCompare(a) : a.localeCompare(b)));
|
||||
|
||||
// 遍历每个分组,每个分组内逐条记录画一行
|
||||
for (const key of groupKeys) {
|
||||
const groupLogsList = groups.get(key)!;
|
||||
// 统计基于「完整数据」分组(fullGroups),不受 number/limit 裁剪影响,保证次数/总量等准确
|
||||
const statSourceLogs = fullGroups.get(key) ?? groupLogsList;
|
||||
|
||||
// 分组上方:日期 / 统计行。
|
||||
// 布局:[日期(分组key) | 统计值(合并字段列) | 备注(空) | 操作(空)]
|
||||
// 无统计时仅显示日期行作为分组分隔标识。
|
||||
const groupHeaderRow = tbody.createEl('tr');
|
||||
groupHeaderRow.addClass(matchedStats.length > 0 ? 'workout-log-stat' : 'workout-log-group-header');
|
||||
|
||||
// 第一格固定为日期(分组 key),不随统计 colspan 吞掉
|
||||
groupHeaderRow.createEl('td', { text: key });
|
||||
|
||||
if (matchedStats.length > 0) {
|
||||
// 有统计时:统计值合并所有字段列,纯数字、不附加单位。
|
||||
// 多条统计用换行排列(\n),同一单元格内每条一行,CSS 用 white-space: pre-line 还原换行。
|
||||
const statCells = matchedStats
|
||||
.map((s) => `${s.name}: ${formatStatValue(computeStat(s, statSourceLogs))}`)
|
||||
.join('\n');
|
||||
const statCell = groupHeaderRow.createEl('td', { text: statCells });
|
||||
statCell.colSpan = fields.length; // 显式设置,确保跨列合并所有配置字段
|
||||
statCell.addClass('workout-log-stat-cell');
|
||||
} else {
|
||||
// 无统计时:字段列区域空白(合并占位)
|
||||
const emptyCell = groupHeaderRow.createEl('td');
|
||||
emptyCell.colSpan = fields.length;
|
||||
}
|
||||
// 备注列与操作列始终为空
|
||||
groupHeaderRow.createEl('td', { text: '' });
|
||||
groupHeaderRow.createEl('td', { text: '' });
|
||||
|
||||
for (const log of groupLogsList) {
|
||||
const row = tbody.createEl('tr');
|
||||
|
||||
// 第一列:只显示时间部分(HH:mm),日期已在上方分组头显示。
|
||||
// 防御:timestamp 缺失时整行不渲染(分组阶段已过滤,此处再兜底)。
|
||||
if (!log.timestamp) continue;
|
||||
const timePart = log.timestamp.split(' ')[1] ?? log.timestamp;
|
||||
row.createEl('td', { text: timePart });
|
||||
|
||||
// 中间各字段列:取该记录的字段值,按字段类型渲染(含单位换算)。
|
||||
// 按需求:单元格只显示纯数字,单位仅在表头体现;时长字段仍由 renderFieldValue 保持可读文本。
|
||||
for (const field of fields) {
|
||||
const value = log.fields[field.key];
|
||||
const cell = row.createEl('td', { text: renderFieldValue(value, field, unit) });
|
||||
}
|
||||
|
||||
// 备注列
|
||||
row.createEl('td', { text: log.note || '' });
|
||||
|
||||
// 操作列:编辑按钮 + 删除按钮,样式与训练项管理保持一致(workout-action-btn / workout-danger-btn)
|
||||
const actionsCell = row.createEl('td');
|
||||
const actionsWrap = actionsCell.createEl('div');
|
||||
actionsWrap.addClass('workout-card-actions');
|
||||
const editBtn = actionsWrap.createEl('button', { text: t('codeblock.edit') });
|
||||
editBtn.addClass('workout-action-btn');
|
||||
editBtn.addEventListener('click', () => onEditRecord(log));
|
||||
const deleteBtn = actionsWrap.createEl('button', { text: t('codeblock.delete') });
|
||||
deleteBtn.addClass('workout-danger-btn');
|
||||
deleteBtn.addEventListener('click', () => onDeleteRecord(log));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
231
src/codeblock/workoutPlan.ts
Normal file
231
src/codeblock/workoutPlan.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
import { Component, MarkdownPostProcessorContext, MarkdownRenderChild, MarkdownView, TFile } from 'obsidian';
|
||||
import { DataManager } from '../data/DataManager';
|
||||
import { FieldDef, PlanItem, PlanSet, TrainingPlanInstance, WorkoutConfig } from '../data/types';
|
||||
import { t } from '../i18n';
|
||||
import { getExerciseNameById, getFieldLabel, getFieldUnit, getTrainingTypeName, renderFieldValue, formatTimeRule } from '../data/display';
|
||||
import { registerRenderedBlock, unregisterRenderedBlock, rerenderBlocksByType } from './registry';
|
||||
import { PlanSetEditModal } from '../ui/PlanSetEditModal';
|
||||
|
||||
/*
|
||||
* workoutPlan.ts —— 把 ```workout-plan 代码块渲染成「训练计划完成面板」。
|
||||
* 代码块只需要一个 plan 参数(= 计划名,全局唯一),即决定显示哪个具体计划实例。
|
||||
* 无 plan 时渲染「选择计划」下拉框,选中后把计划名写回代码块(变为 plan: 选中的计划名)。
|
||||
* 有 plan 时渲染完成面板:每个训练项的每组展示预设值 + [编辑][完成];点「完成」写一条 CSV 记录
|
||||
* (时间=点击时刻),点「编辑」弹小窗改该组值(复用编辑记录界面的字段渲染)。
|
||||
*/
|
||||
|
||||
interface PlanParams {
|
||||
plan?: string;
|
||||
}
|
||||
|
||||
function parseParams(source: string): PlanParams {
|
||||
const params: PlanParams = {};
|
||||
for (const line of source.split('\n')) {
|
||||
// 只在第一个冒号处切分,保证值里带冒号(如计划名「推胸: A」)也能完整保留,
|
||||
// 否则 `line.split(':')` 只取第二段会导致写回代码块后无法正确解析。
|
||||
const idx = line.indexOf(':');
|
||||
if (idx === -1) continue;
|
||||
const key = line.slice(0, idx).trim();
|
||||
const value = line.slice(idx + 1).trim();
|
||||
if (!key) continue;
|
||||
if (key === 'plan') params.plan = value;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
// 把一组的字段渲染成「值 单位」空格分隔的展示文字(如 `60 kg 8 次`)。
|
||||
function formatSetFields(fields: Record<string, unknown>, typeFields: FieldDef[], unit: 'kg' | 'lb'): string {
|
||||
return typeFields
|
||||
.map((f) => {
|
||||
const v = fields[f.key];
|
||||
if (v === undefined || v === null) return '';
|
||||
const rv = renderFieldValue(v, f, unit);
|
||||
if (f.mass) return `${rv} ${unit}`; // 质量字段附上单位(kg/lb,跟随设置)
|
||||
const u = getFieldUnit(f, unit);
|
||||
return u ? `${rv} ${u}` : rv;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
const registeredComponents = new WeakMap<HTMLElement, boolean>();
|
||||
|
||||
export async function renderWorkoutPlan(
|
||||
source: string,
|
||||
el: HTMLElement,
|
||||
ctx: MarkdownPostProcessorContext,
|
||||
dataManager: DataManager
|
||||
): Promise<void> {
|
||||
if (!registeredComponents.has(el)) {
|
||||
const child = new MarkdownRenderChild(el);
|
||||
child.onunload = () => {
|
||||
unregisterRenderedBlock(el);
|
||||
registeredComponents.delete(el);
|
||||
};
|
||||
ctx.addChild(child);
|
||||
registeredComponents.set(el, true);
|
||||
registerRenderedBlock(el, 'workout-plan', source, ctx);
|
||||
}
|
||||
|
||||
const params = parseParams(source);
|
||||
const config = await dataManager.getConfig();
|
||||
const plans = config.plans ?? [];
|
||||
const plan = params.plan ? plans.find((p) => p.name === params.plan) : undefined;
|
||||
|
||||
if (!plan) {
|
||||
renderSelectPlan(el, dataManager, ctx, plans);
|
||||
return;
|
||||
}
|
||||
|
||||
renderPanel(el, dataManager, plan, config);
|
||||
}
|
||||
|
||||
// 无 plan(或 plan 找不到):渲染「选择计划」下拉框;选中后把计划名写回代码块。
|
||||
function renderSelectPlan(el: HTMLElement, dataManager: DataManager, ctx: MarkdownPostProcessorContext, plans: TrainingPlanInstance[]): void {
|
||||
const wrap = el.createDiv();
|
||||
wrap.addClass('workout-plan-select');
|
||||
|
||||
const label = wrap.createEl('label', { text: t('codeblock.plan.select') });
|
||||
label.addClass('workout-plan-select-label');
|
||||
|
||||
if (plans.length === 0) {
|
||||
wrap.createEl('div', { text: t('codeblock.plan.noPlan') }).addClass('workout-hint');
|
||||
return;
|
||||
}
|
||||
|
||||
const select = wrap.createEl('select');
|
||||
select.addClass('workout-select');
|
||||
select.createEl('option', { value: '', text: t('codeblock.plan.selectPlaceholder') });
|
||||
for (const p of plans) {
|
||||
select.createEl('option', { value: p.name, text: p.name });
|
||||
}
|
||||
select.addEventListener('change', async () => {
|
||||
const name = select.value;
|
||||
if (!name) return;
|
||||
await writePlanToCodeBlock(dataManager, ctx, el, name);
|
||||
});
|
||||
}
|
||||
|
||||
// 把选中的计划名写入代码块源(改写笔记里的 ```workout-plan 块),随后 Obsidian 自动重渲染。
|
||||
// 修复机制 B:原先用 vault.modify(file, ...) 整文件改写当前正在编辑的笔记,
|
||||
// Obsidian 会当成「外部改动」重新加载该笔记,导致编辑器光标/选区/撤销栈全部重置(光标丢失、无法输入)。
|
||||
// 改为:若当前文件正好在 Markdown 编辑器(Live Preview/源码)中打开,则仅用 editor.replaceRange
|
||||
// 做「局部替换」——编辑器内部事务,不触发整文件 reload,焦点与光标得以保留;只有在无法拿到
|
||||
// 对应编辑器(如阅读模式、或在别的文件里)时,才退回 vault.modify 兜底(该场景没有可丢失的光标)。
|
||||
async function writePlanToCodeBlock(dataManager: DataManager, ctx: MarkdownPostProcessorContext, el: HTMLElement, name: string): Promise<void> {
|
||||
const section = ctx.getSectionInfo(el);
|
||||
if (!section) return;
|
||||
const file = dataManager.app.vault.getAbstractFileByPath(ctx.sourcePath);
|
||||
if (!(file instanceof TFile)) return;
|
||||
const newBlock = '```workout-plan\nplan: ' + name.trim() + '\n```';
|
||||
|
||||
// 找到正在编辑该文件的 Markdown 视图,优先用编辑器事务局部替换。
|
||||
const mdView = dataManager.app.workspace
|
||||
.getLeavesOfType('markdown')
|
||||
.map((leaf) => leaf.view)
|
||||
.find((v): v is MarkdownView => v instanceof MarkdownView && v.file?.path === ctx.sourcePath);
|
||||
if (mdView && mdView.editor) {
|
||||
const editor = mdView.editor;
|
||||
const from = { line: section.lineStart, ch: 0 };
|
||||
const to = { line: section.lineEnd, ch: editor.getLine(section.lineEnd).length };
|
||||
editor.replaceRange(newBlock, from, to);
|
||||
return;
|
||||
}
|
||||
|
||||
// 兜底:当前笔记未在可编辑视图打开(如阅读模式),只能用整文件改写;
|
||||
// 此场景不存在「正在编辑的光标」,故不会造成光标丢失。
|
||||
const content = await dataManager.app.vault.read(file);
|
||||
const lines = content.split('\n');
|
||||
lines.splice(section.lineStart, section.lineEnd - section.lineStart + 1, newBlock);
|
||||
await dataManager.app.vault.modify(file, lines.join('\n'));
|
||||
}
|
||||
|
||||
function renderPanel(el: HTMLElement, dataManager: DataManager, plan: TrainingPlanInstance, config: WorkoutConfig): void {
|
||||
const unit = dataManager.getSettings().unit;
|
||||
const container = el.createDiv();
|
||||
container.addClass('workout-plan-panel');
|
||||
|
||||
// 计算进度:完成状态来自计划配置里持久化的 completedSets(独立于训练记录),
|
||||
// 这样即使删除了该组产生的训练记录,完成状态依然保留。
|
||||
const enabledItems = plan.items.filter((i) => i.enabled);
|
||||
const completedSets = plan.completedSets ?? {};
|
||||
const completedKeys = new Set(Object.keys(completedSets));
|
||||
let totalSets = 0;
|
||||
let doneSets = 0;
|
||||
for (const item of enabledItems) {
|
||||
for (const set of item.sets) {
|
||||
totalSets++;
|
||||
if (completedKeys.has(`${item.exerciseId}#${set.id}`)) doneSets++;
|
||||
}
|
||||
}
|
||||
|
||||
// 计划头
|
||||
const header = container.createDiv();
|
||||
header.addClass('workout-plan-header');
|
||||
header.createSpan({ text: plan.name }).addClass('workout-plan-name');
|
||||
const timeText = formatTimeRule(plan.timeRule);
|
||||
if (timeText) {
|
||||
header.createSpan({ text: timeText }).addClass('workout-plan-time');
|
||||
}
|
||||
header.createSpan({ text: t('codeblock.plan.progress', { done: String(doneSets), total: String(totalSets) }) }).addClass('workout-plan-progress');
|
||||
|
||||
// 训练项 + 组
|
||||
for (const item of enabledItems) {
|
||||
const type = config.trainingTypes.find((tt) => tt.id === item.category);
|
||||
const exerciseName = getExerciseNameById(config.exercises, item.exerciseId);
|
||||
const typeName = getTrainingTypeName(type);
|
||||
|
||||
const itemEl = container.createDiv();
|
||||
itemEl.addClass('workout-plan-item');
|
||||
itemEl.createEl('div', { text: `${exerciseName}${typeName ? ` [${typeName}]` : ''}` }).addClass('workout-plan-item-title');
|
||||
|
||||
const setsEl = itemEl.createDiv();
|
||||
setsEl.addClass('workout-plan-sets');
|
||||
|
||||
item.sets.forEach((set, sidx) => {
|
||||
const isCompleted = completedKeys.has(`${item.exerciseId}#${set.id}`);
|
||||
const row = setsEl.createDiv();
|
||||
row.addClass('workout-row', 'workout-plan-set');
|
||||
|
||||
row.createSpan({ text: t('modal.newPlan.setName', { n: String(sidx + 1) }) }).addClass('workout-plan-setno');
|
||||
const fieldsText = formatSetFields(set.fields, type?.fields ?? [], unit);
|
||||
row.createSpan({ text: fieldsText || t('codeblock.plan.emptyFields') }).addClass('workout-plan-setfields');
|
||||
|
||||
if (isCompleted) {
|
||||
row.createSpan({ text: t('codeblock.plan.completed') }).addClass('workout-plan-completed');
|
||||
} else {
|
||||
// 编辑按钮(仅未完成时显示:改该组计划预设值)
|
||||
const editBtn = row.createEl('button', { text: t('codeblock.plan.edit') });
|
||||
editBtn.addClass('mod-cta', 'workout-plan-set-edit');
|
||||
editBtn.addEventListener('click', () => {
|
||||
new PlanSetEditModal(dataManager, {
|
||||
exerciseId: item.exerciseId,
|
||||
category: item.category,
|
||||
title: t('codeblock.plan.editTitle', { exercise: exerciseName, n: String(sidx + 1) }),
|
||||
initialFields: set.fields,
|
||||
onSave: async (fields) => {
|
||||
await dataManager.updatePlanSetFields(plan.name.trim(), item.exerciseId, set.id, fields);
|
||||
rerenderBlocksByType('workout-plan');
|
||||
},
|
||||
}).open();
|
||||
});
|
||||
|
||||
// 完成按钮(仅未完成显示)
|
||||
const completeBtn = row.createEl('button', { text: t('codeblock.plan.complete') });
|
||||
completeBtn.addClass('mod-cta', 'workout-plan-set-complete');
|
||||
completeBtn.addEventListener('click', async () => {
|
||||
// 1) 持久化「已完成」状态到计划配置(独立于训练记录,删除记录不影响)
|
||||
await dataManager.markPlanSetCompleted(plan, item.exerciseId, set.id);
|
||||
// 2) 同时往训练记录库写一条数据;plan 存稳定方案标识(sourceNote 或 id),而非可改的计划名
|
||||
await dataManager.addLog({
|
||||
exerciseId: item.exerciseId,
|
||||
category: item.category,
|
||||
fields: { ...set.fields, _planSet: set.id },
|
||||
plan: plan.sourceNote || plan.id,
|
||||
});
|
||||
rerenderBlocksByType('workout-plan');
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
305
src/data/CSVStore.ts
Normal file
305
src/data/CSVStore.ts
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
import Papa from 'papaparse';
|
||||
import { App, TFile } from 'obsidian';
|
||||
import { LogRow, CSV_FILENAME } from './types';
|
||||
import { DataManager } from './DataManager';
|
||||
import { generateId } from '../util/id';
|
||||
|
||||
/*
|
||||
* CSVStore.ts —— 训练记录的数据层(vault 内 workout_logs.csv)。
|
||||
*
|
||||
* 设计要点:
|
||||
* - 数据以标准 CSV 存在 vault 中,支持 Dataview 等外部工具查询(满足"两个文件可查询")。
|
||||
* - 关键性能优化(根治"添加记录卡顿"):
|
||||
* 新增记录用 Vault.append(file, line) 做 O(1) 追加,不再每次把整个 CSV 重写一遍;
|
||||
* Vault.append 走 Obsidian 缓存层(区别于 adapter.append 绕过缓存导致崩溃)。
|
||||
* - 删除采用「软删除」:删除时不再整文件重写,而是 O(1) 追加一行墓碑
|
||||
* (deleted 列标记为 true),读取时过滤掉被删 id 的行。彻底消灭删除卡顿。
|
||||
* 「整文件压缩清理」(真正移除被删行)由设置页按钮在用户主动触发时执行。
|
||||
* - 编辑因需改中间行,仍整体重写(writeAll,使用 vault.modify),但属低频操作,
|
||||
* 且记录少时极快;外部手动改文件也不会丢数据。
|
||||
*/
|
||||
// 第 8 列 deleted:空 = 正常行;'true' = 软删除墓碑行(仅标记 id 被删除,不作为记录)。
|
||||
const CSV_HEADER = 'id,timestamp,exerciseId,category,fields,note,plan,deleted';
|
||||
|
||||
export class CSVStore {
|
||||
private dm: DataManager; // 数据中枢,用于取设置(CSV 所在目录)与 App 实例
|
||||
private app: App;
|
||||
|
||||
constructor(dm: DataManager) {
|
||||
this.dm = dm;
|
||||
this.app = dm.app;
|
||||
}
|
||||
|
||||
// CSV 文件路径(基于设置里的 csvDirectory,空 = vault 根目录)
|
||||
private get path(): string {
|
||||
const dir = this.dm.getSettings().csvDirectory || '';
|
||||
return dir ? `${dir}/${CSV_FILENAME}` : CSV_FILENAME;
|
||||
}
|
||||
|
||||
// 解析 CSV 文本为 LogRow[],失败返回空结果(不抛异常,避免影响启动)。
|
||||
// 返回值带 dropped:本被丢弃的脏行计数(如超长 fields 的幽灵行),供 init 判断是否需要落盘自愈。
|
||||
// 返回值带 deletedIds:被软删除(墓碑行)标记过的 id 集合,供缓存过滤与 id 分配去重。
|
||||
parseContent(content: string): { rows: LogRow[]; dropped: number; deletedIds: string[] } {
|
||||
try {
|
||||
const result = Papa.parse<Record<string, string>>(content, {
|
||||
header: true,
|
||||
skipEmptyLines: true,
|
||||
});
|
||||
// 收集软删除墓碑标记过的 id(deleted 列 === 'true' 的行)。
|
||||
const deletedIds = new Set<string>();
|
||||
let dropped = 0;
|
||||
const rows = result.data
|
||||
.map((row): LogRow | null => {
|
||||
// 软删除墓碑行:deleted 列为 true,仅记录「该 id 已被删除」,
|
||||
// 不计入脏行(dropped),也不作为正常记录返回(避免与同名数据行重复)。
|
||||
if (row.deleted === 'true') {
|
||||
if (row.id) deletedIds.add(row.id);
|
||||
return null;
|
||||
}
|
||||
// 关键兜底:timestamp / category 是后续过滤/分组必须的字段;缺失则无法渲染,
|
||||
// 且往往是脏 CSV(如未闭合引号把整行吞乱)产生的幽灵行,直接丢弃。
|
||||
if (!row.timestamp || !row.category) {
|
||||
dropped++;
|
||||
return null;
|
||||
}
|
||||
let fields: Record<string, unknown> = {};
|
||||
const raw = row.fields;
|
||||
if (raw) {
|
||||
// 单条记录的 fields 异常大(脏 CSV 未闭合引号把多行吞进一个单元格),
|
||||
// 整行直接丢弃——而非置空后保留成幽灵行。否则会:① 与正常行形成重复 id;
|
||||
// ② 被 writeAll 再次写回、撑大文件。丢弃才能从根上消除脏数据。
|
||||
if (raw.length > 10000) {
|
||||
console.warn('[workout] 跳过超长 fields(疑似脏数据):', raw.slice(0, 80));
|
||||
dropped++;
|
||||
return null;
|
||||
}
|
||||
// 逐行兜底:某行 fields 解析失败只影响该行,绝不让整文件解析失败导致全部记录丢失。
|
||||
try {
|
||||
fields = JSON.parse(raw);
|
||||
} catch {
|
||||
fields = {};
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: row.id || generateId(),
|
||||
timestamp: row.timestamp,
|
||||
exerciseId: row.exerciseId || undefined,
|
||||
category: row.category,
|
||||
fields,
|
||||
note: row.note || undefined,
|
||||
plan: row.plan || undefined,
|
||||
};
|
||||
})
|
||||
.filter((r): r is LogRow => r !== null)
|
||||
// 过滤掉「数据行 id 已被墓碑标记删除」的残留行(软删除前的旧数据行),
|
||||
// 保证内存缓存与渲染看到的都是存活记录。
|
||||
.filter((r) => !deletedIds.has(r.id));
|
||||
return { rows, dropped, deletedIds: Array.from(deletedIds) };
|
||||
} catch {
|
||||
return { rows: [], dropped: 0, deletedIds: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// 读取 CSV 文本:优先走 vault 缓存层(getAbstractFileByPath + vault.read,写盘安全);
|
||||
// 兜底走 adapter 直接读磁盘。
|
||||
// 关键修复点:Obsidian 在「首次打开仓库」时会在 vault 文件缓存(fileMap)尚未就绪前
|
||||
// 就调用插件 onload(),此时 getAbstractFileByPath 漏返回已存在的 CSV → init() 误判
|
||||
// 为「无文件」→ logsCache 永远为空 → 首次打开看不到历史记录、添加也只显示当次。
|
||||
// 手动重载插件时 vault 已就绪,故能正常显示。改用 adapter.read(不依赖 fileMap)兜底后,
|
||||
// 即使 fileMap 未就绪也能从磁盘读到历史记录。文件确实不存在时返回 null。
|
||||
private async readFileContent(): Promise<string | null> {
|
||||
const file = this.app.vault.getAbstractFileByPath(this.path);
|
||||
if (file instanceof TFile) {
|
||||
return await this.app.vault.read(file);
|
||||
}
|
||||
try {
|
||||
return await this.app.vault.adapter.read(this.path);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 从 vault 读取全部记录,并返回丢弃的脏行数(供 init 落盘自愈使用)。
|
||||
async readAllWithStats(): Promise<{ rows: LogRow[]; dropped: number; deletedIds: string[] }> {
|
||||
const content = await this.readFileContent();
|
||||
if (content === null) return { rows: [], dropped: 0, deletedIds: [] };
|
||||
return this.parseContent(content);
|
||||
}
|
||||
|
||||
// 追加一行:优先 Vault.append(缓存安全的 O(1) 追加)。
|
||||
// 旧版 Obsidian 无 append 时回退为 read + modify 整文件重写(兜底兼容)。
|
||||
// 关键:用 Papa.unparse 生成数据行(header: false),避免手动 escapeCell 对 JSON 内部引号错误双重转义。
|
||||
// 例如 JSON {"note":"say \"hello\""} 经 escapeCell 处理会变成 ""say ""hello"""",破坏结构;
|
||||
// 而 Papa.unparse 能正确处理嵌套引号,输出 "{""note"":""say \""hello\""""}"。
|
||||
async appendRow(row: LogRow): Promise<void> {
|
||||
const line = this.toCsvLine(row);
|
||||
const file = this.app.vault.getAbstractFileByPath(this.path);
|
||||
if (file instanceof TFile) {
|
||||
if (typeof this.app.vault.append === 'function') {
|
||||
await this.app.vault.append(file, line + '\n');
|
||||
} else {
|
||||
const content = await this.app.vault.read(file);
|
||||
const newContent = content.replace(/\s+$/, '') + '\n' + line + '\n';
|
||||
await this.app.vault.modify(file, newContent);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await this.app.vault.create(this.path, CSV_HEADER + '\n' + line + '\n');
|
||||
} catch (e: unknown) {
|
||||
if (String((e as Error)?.message ?? '').includes('File already exists')) {
|
||||
const f = this.app.vault.getAbstractFileByPath(this.path);
|
||||
if (f instanceof TFile) {
|
||||
if (typeof this.app.vault.append === 'function') {
|
||||
await this.app.vault.append(f, line + '\n');
|
||||
} else {
|
||||
const c = await this.app.vault.read(f);
|
||||
await this.app.vault.modify(f, c.replace(/\s+$/, '') + '\n' + line + '\n');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 把一行记录转成 CSV 文本。使用 Papa.unparse({ header: false }) 确保转义与 writeAll 完全一致。
|
||||
// deleted 列为空(正常行);墓碑行由 appendTombstone 单独构造。
|
||||
private toCsvLine(row: LogRow): string {
|
||||
const columns = CSV_HEADER.split(',');
|
||||
return Papa.unparse(
|
||||
[{
|
||||
id: row.id,
|
||||
timestamp: row.timestamp,
|
||||
exerciseId: row.exerciseId || '',
|
||||
category: row.category,
|
||||
fields: JSON.stringify(normalizeFields(row.fields)),
|
||||
note: row.note || '',
|
||||
plan: row.plan || '',
|
||||
deleted: '',
|
||||
}],
|
||||
{ columns, header: false }
|
||||
);
|
||||
}
|
||||
|
||||
// 追加一行「软删除墓碑」:仅标记 id 为已删除(deleted 列 = true),O(1) 追加,不重写整文件。
|
||||
// 读取时 parseContent 据此过滤掉该 id 的残留数据行。用于 deleteLog,根治删除卡顿。
|
||||
async appendTombstone(id: string): Promise<void> {
|
||||
const line = Papa.unparse(
|
||||
[{ id, timestamp: '', exerciseId: '', category: '', fields: '', note: '', plan: '', deleted: 'true' }],
|
||||
{ columns: CSV_HEADER.split(','), header: false }
|
||||
);
|
||||
const file = this.app.vault.getAbstractFileByPath(this.path);
|
||||
if (file instanceof TFile) {
|
||||
if (typeof this.app.vault.append === 'function') {
|
||||
await this.app.vault.append(file, line + '\n');
|
||||
} else {
|
||||
const content = await this.app.vault.read(file);
|
||||
await this.app.vault.modify(file, content.replace(/\s+$/, '') + '\n' + line + '\n');
|
||||
}
|
||||
}
|
||||
// 文件不存在(极端:记录所在的 CSV 被外部删除)时静默忽略:
|
||||
// 删除已反映到内存缓存,后续任何写盘都会以存活记录为准干净重建。
|
||||
}
|
||||
|
||||
// 批量追加「软删除墓碑」:把多个 id 的墓碑合并成「一次」O(1) 追加,
|
||||
// 避免逐条删除时多次 appendTombstone 触发多次写入。用于「删除训练项时级联删除其全部
|
||||
// 训练记录」场景,保证与大 CSV 同样无卡顿。文件不存在时静默忽略(删除已反映到内存缓存)。
|
||||
async appendTombstones(ids: string[]): Promise<void> {
|
||||
if (ids.length === 0) return;
|
||||
const columns = CSV_HEADER.split(',');
|
||||
const content = ids
|
||||
.map((id) =>
|
||||
Papa.unparse(
|
||||
[{ id, timestamp: '', exerciseId: '', category: '', fields: '', note: '', plan: '', deleted: 'true' }],
|
||||
{ columns, header: false }
|
||||
)
|
||||
)
|
||||
.join('\n');
|
||||
const file = this.app.vault.getAbstractFileByPath(this.path);
|
||||
if (file instanceof TFile) {
|
||||
if (typeof this.app.vault.append === 'function') {
|
||||
await this.app.vault.append(file, content + '\n');
|
||||
} else {
|
||||
const existing = await this.app.vault.read(file);
|
||||
await this.app.vault.modify(file, existing.replace(/\s+$/, '') + '\n' + content + '\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 整体写入(编辑/删除/批量导入)。存在则改、否则建。
|
||||
// 强制使用规范列序(CSV_HEADER),避免「内存对象键序」泄漏到磁盘导致列顺序漂移:
|
||||
// 例如 addLog 用 {...row, id, timestamp} 构造,会把 id/timestamp 排到末尾,若直接按首行键序
|
||||
// 序列化,磁盘列序会变成 exerciseId,category,...,timestamp,id —— 数据不丢但顺序混乱。
|
||||
async writeAll(rows: LogRow[]): Promise<void> {
|
||||
const columns = CSV_HEADER.split(',');
|
||||
// 关键:持久化前过滤掉无效行。旧版 bug 可能让 logsCache 中混入 timestamp/category 为空的行;
|
||||
// 若直接写回磁盘,下次读取会再次产生幽灵行并触发 split(undefined) 报错。只保留有效行。
|
||||
const validRows = rows.filter((row) => !!row.timestamp && !!row.category);
|
||||
let csv: string;
|
||||
if (validRows.length === 0) {
|
||||
// 空数据时只写表头,避免 Papa.unparse([]) 返回空字符串导致表头丢失。
|
||||
// 删除最后一条记录后文件至少保留表头,下次 appendRow 可正确追加。
|
||||
csv = CSV_HEADER;
|
||||
} else {
|
||||
csv = Papa.unparse(
|
||||
validRows.map((row) => ({
|
||||
id: row.id,
|
||||
timestamp: row.timestamp,
|
||||
exerciseId: row.exerciseId || '', // 缺失则填空,保证 CSV 列对齐
|
||||
category: row.category,
|
||||
fields: JSON.stringify(normalizeFields(row.fields)), // 规范化为干净对象再序列化,避免脏字段被放大
|
||||
note: row.note || '',
|
||||
plan: row.plan || '',
|
||||
deleted: '', // 正常行:空(writeAll 用于压缩/编辑,不含墓碑)
|
||||
})),
|
||||
{ columns }
|
||||
);
|
||||
}
|
||||
await this.createOrModify(csv + '\n');
|
||||
}
|
||||
|
||||
// 存在则改、不存在则建;若并发初始化导致「文件已存在」(File already exists),
|
||||
// 回退为 modify,避免插件加载时抛错中断 onload。
|
||||
private async createOrModify(content: string): Promise<void> {
|
||||
const file = this.app.vault.getAbstractFileByPath(this.path);
|
||||
if (file instanceof TFile) {
|
||||
await this.app.vault.modify(file, content);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.app.vault.create(this.path, content);
|
||||
} catch (e: unknown) {
|
||||
if (String((e as Error)?.message ?? '').includes('File already exists')) {
|
||||
const existing = this.app.vault.getAbstractFileByPath(this.path);
|
||||
if (existing instanceof TFile) await this.app.vault.modify(existing, content);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 判断磁盘上的 CSV 表头是否为「旧版结构」(与当前规范表头不一致)。
|
||||
// 仅用于迁移自愈:旧文件是 9 列、无 id 列,直接追加新行会导致列错位、数据损坏。
|
||||
async isHeaderStale(): Promise<boolean> {
|
||||
const content = await this.readFileContent();
|
||||
if (!content) return false;
|
||||
const firstLine = content.split('\n', 1)[0];
|
||||
return firstLine !== CSV_HEADER;
|
||||
}
|
||||
}
|
||||
|
||||
// 把 fields 规范化为干净的普通对象:已是对象直接返回;是 JSON 字符串则尝试解析;
|
||||
// 其它情况(undefined / 脏字符串 / 非对象)一律回退为 {},避免序列化出巨型或非法单元格。
|
||||
function normalizeFields(f: unknown): Record<string, unknown> {
|
||||
if (f && typeof f === 'object') return f as Record<string, unknown>;
|
||||
if (typeof f === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(f);
|
||||
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
160
src/data/ConfigStore.ts
Normal file
160
src/data/ConfigStore.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import { App, TFile } from 'obsidian';
|
||||
import { WorkoutConfig, CONFIG_FILENAME } from './types';
|
||||
import { DataManager } from './DataManager';
|
||||
import { applyDefaultNameKeys } from './display';
|
||||
import { getDefaultConfig } from './seed';
|
||||
|
||||
/*
|
||||
* ConfigStore.ts —— 聚合配置(训练类型/训练项/肌肉)的数据层(vault 内 workout-config.json)。
|
||||
*
|
||||
* 配置文件也留在 vault 内,便于用户用 Dataview / 外部工具查询或备份。
|
||||
* 配置改动属低频操作,使用 vault.modify 整体写入即可(与记录 CSV 同套缓存安全 API)。
|
||||
*/
|
||||
export class ConfigStore {
|
||||
private dm: DataManager; // 数据中枢,用于取设置与 App 实例
|
||||
private app: App;
|
||||
private cache: WorkoutConfig | null = null; // 内存缓存;null 表示尚未加载
|
||||
|
||||
constructor(dm: DataManager) {
|
||||
this.dm = dm;
|
||||
this.app = dm.app;
|
||||
}
|
||||
|
||||
// 配置文件路径(基于设置里的 configDirectory,空 = vault 根目录)
|
||||
private get path(): string {
|
||||
const dir = this.dm.getSettings().configDirectory || '';
|
||||
return dir ? `${dir}/${CONFIG_FILENAME}` : CONFIG_FILENAME;
|
||||
}
|
||||
|
||||
// 读取配置文件文本:优先走 vault 缓存层,兜底走 adapter 直接读磁盘。
|
||||
// 与 CSVStore 同理:首次打开仓库时 fileMap 可能未就绪,getAbstractFileByPath 漏返回
|
||||
// 已存在的配置文件 → 误判为「无配置」→ 表格列定义/训练项缺失。adapter.read 兜底修复。
|
||||
// 文件确实不存在时返回 null。
|
||||
private async readFileContent(): Promise<string | null> {
|
||||
const file = this.app.vault.getAbstractFileByPath(this.path);
|
||||
if (file instanceof TFile) {
|
||||
return await this.app.vault.read(file);
|
||||
}
|
||||
try {
|
||||
return await this.app.vault.adapter.read(this.path);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 确保配置已加载(带缓存)。首次无配置文件时用默认配置并落盘。
|
||||
// legacyConfig:来自旧版插件私有 data 的配置,仅在「vault 内尚无配置文件」时作为兜底导入。
|
||||
async ensureLoaded(legacyConfig?: WorkoutConfig): Promise<void> {
|
||||
if (this.cache) return;
|
||||
const content = await this.readFileContent();
|
||||
if (content !== null) {
|
||||
try {
|
||||
this.cache = this.migrate(JSON.parse(content));
|
||||
} catch {
|
||||
// 配置文件损坏:回退到默认(或 legacy)并覆盖写入,避免一直报错
|
||||
const fallback = legacyConfig ? this.migrate(legacyConfig) : this.migrate(getDefaultConfig());
|
||||
this.cache = fallback;
|
||||
await this.writeConfig(fallback);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// vault 内无配置文件:优先用 legacy(旧版迁移),否则默认配置
|
||||
const initial = legacyConfig ? this.migrate(legacyConfig) : this.migrate(getDefaultConfig());
|
||||
this.cache = initial;
|
||||
await this.writeConfig(initial);
|
||||
}
|
||||
|
||||
// 读取配置:有缓存直接返回,否则加载。
|
||||
async load(): Promise<WorkoutConfig> {
|
||||
await this.ensureLoaded();
|
||||
return this.cache!;
|
||||
}
|
||||
|
||||
// 写入配置:更新缓存 + vault 文件。由 DataManager.saveConfig 在 selfWriting 期间调用。
|
||||
async save(config: WorkoutConfig): Promise<void> {
|
||||
this.cache = config;
|
||||
await this.writeConfig(config);
|
||||
}
|
||||
|
||||
// 真正写盘:存在则改、否则建。
|
||||
private async writeConfig(config: WorkoutConfig): Promise<void> {
|
||||
const content = JSON.stringify(config, null, 2);
|
||||
const file = this.app.vault.getAbstractFileByPath(this.path);
|
||||
if (file instanceof TFile) {
|
||||
await this.app.vault.modify(file, content);
|
||||
} else {
|
||||
try {
|
||||
await this.app.vault.create(this.path, content);
|
||||
} catch (e: unknown) {
|
||||
if (String((e as Error)?.message ?? '').includes('File already exists')) {
|
||||
// 并发初始化竞态:文件已在,回退为 modify
|
||||
const existing = this.app.vault.getAbstractFileByPath(this.path);
|
||||
if (existing instanceof TFile) await this.app.vault.modify(existing, content);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 清空缓存(配置被外部改动或路径变化时调用,迫使下次重新读取)。
|
||||
clearCache(): void {
|
||||
this.cache = null;
|
||||
}
|
||||
|
||||
// 获取当前缓存(可能为 null)。
|
||||
getCache(): WorkoutConfig | null {
|
||||
return this.cache;
|
||||
}
|
||||
|
||||
// 数据迁移(migrate):补全缺失结构,保证向后兼容。
|
||||
private migrate(config: WorkoutConfig): WorkoutConfig {
|
||||
if (!config.version) config.version = 1;
|
||||
if (!config.trainingTypes) config.trainingTypes = getDefaultConfig().trainingTypes;
|
||||
if (!config.exercises) config.exercises = getDefaultConfig().exercises;
|
||||
if (!config.muscles) config.muscles = getDefaultConfig().muscles;
|
||||
// 数据统计:旧配置缺 statistics 时,按每个训练类型注入一条种子"总组数"。
|
||||
// 用 count()(不引用字段),单类型关联即可复现"每个块都有总组数";
|
||||
// 用户想跨类型可手动把 associatedTypes 改为多个类型。
|
||||
if (!config.statistics) {
|
||||
config.statistics = config.trainingTypes.map((type) => ({
|
||||
id: `seed-total-sets-${type.id}`,
|
||||
name: '总组数',
|
||||
associatedTypes: [type.id],
|
||||
formula: { mode: 'builder', builder: { kind: 'count' } },
|
||||
granularity: 'daily',
|
||||
enabled: true,
|
||||
}));
|
||||
}
|
||||
// 训练计划:旧配置缺 plans 时置空数组(聚合进既有配置,不单独建文件)
|
||||
if (!config.plans) config.plans = [];
|
||||
// 2026-07-14 大改:把种子新增的训练项 / 计划 / 训练总量指标,幂等地并入既有配置。
|
||||
// 按 id(训练项/统计)或 name(计划)去重,绝不覆盖用户已改/已建的数据;重复执行安全。
|
||||
{
|
||||
const seed = getDefaultConfig();
|
||||
const exIds = new Set(config.exercises.map((e) => e.id));
|
||||
for (const ex of seed.exercises) if (!exIds.has(ex.id)) config.exercises.push(ex);
|
||||
const planNames = new Set(config.plans.map((p) => p.name));
|
||||
for (const pl of seed.plans ?? []) if (!planNames.has(pl.name)) config.plans.push(pl);
|
||||
const statIds = new Set(config.statistics.map((s) => s.id));
|
||||
for (const st of seed.statistics) if (!statIds.has(st.id)) config.statistics.push(st);
|
||||
}
|
||||
// 字段单位模型迁移:旧 unit 枚举(none/mass/length/count/time/custom) -> mass + unitLabel。
|
||||
// 幂等:已迁移的数据没有 unit/customUnit 字段,重复执行无副作用。
|
||||
const migrateUnit = (fields: any[] | undefined): void => {
|
||||
if (!fields) return;
|
||||
for (const f of fields) {
|
||||
if (!f) continue;
|
||||
if (f.unit === 'mass') f.mass = true;
|
||||
else if (f.unit === 'custom') f.unitLabel = f.customUnit ?? '';
|
||||
else if (f.unit === 'count') f.unitLabel = '次';
|
||||
// length/time/none/未定义 -> 全部清空(不再保留死值)
|
||||
delete f.unit;
|
||||
delete f.customUnit;
|
||||
}
|
||||
};
|
||||
for (const type of config.trainingTypes ?? []) migrateUnit(type.fields);
|
||||
|
||||
return applyDefaultNameKeys(config);
|
||||
}
|
||||
}
|
||||
703
src/data/DataManager.ts
Normal file
703
src/data/DataManager.ts
Normal file
|
|
@ -0,0 +1,703 @@
|
|||
import { App, Events, Plugin } from 'obsidian';
|
||||
import { LogRow, WorkoutConfig, TrainingType, Exercise, Muscle, PluginSettings, DEFAULT_SETTINGS, CSV_FILENAME, CONFIG_FILENAME, TrainingPlanInstance } from './types';
|
||||
import { generateId } from '../util/id';
|
||||
import { CSVStore } from './CSVStore';
|
||||
import { ConfigStore } from './ConfigStore';
|
||||
|
||||
/*
|
||||
* DataManager.ts —— 插件的「数据中枢」。
|
||||
*
|
||||
* 存储分工(回到「两个文件 + 插件设置」的经典结构):
|
||||
* - settings:插件私有 data.json(Plugin.loadData / saveData),仅存放用户偏好,
|
||||
* 不进 vault、不影响查询、也不触发 vault 文件写入导致的卡顿。
|
||||
* - logs:vault 内 workout_logs.csv(支持 Dataview 等查询)。
|
||||
* - config:vault 内 workout-config.json(同样支持查询)。
|
||||
*
|
||||
* 性能要点(根治"添加记录卡顿"):
|
||||
* - 添加记录走 CSVStore.appendRow,内部用 Vault.append 做 O(1) 追加,不再整文件重写。
|
||||
* - selfWriting 标志:插件自身写盘期间,main.ts 的 vault.on('modify') 监听完全跳过
|
||||
* 重载 + 重渲染(data-changed 已做精准重渲染),避免重复开销与卡顿。
|
||||
*
|
||||
* 数据迁移:若 CSV 为空但旧版插件私有 data 里有 logs / config,则一次性导入 vault 文件,
|
||||
* 不丢用户在上一版测试期间产生的数据。
|
||||
*/
|
||||
// 取今天日期(YYYY-MM-DD),用于完成状态打标。
|
||||
function todayStr(): string {
|
||||
const n = new Date();
|
||||
const pad = (x: number) => String(x).padStart(2, '0');
|
||||
return `${n.getFullYear()}-${pad(n.getMonth() + 1)}-${pad(n.getDate())}`;
|
||||
}
|
||||
|
||||
export class DataManager {
|
||||
app: App; // Obsidian 应用实例
|
||||
private plugin: Plugin; // 插件实例,用于 loadData/saveData(私有 settings)
|
||||
private events: Events; // 事件总线
|
||||
private csvStore: CSVStore; // 记录数据层(vault CSV)
|
||||
private configStore: ConfigStore; // 配置数据层(vault JSON)
|
||||
private settings: PluginSettings; // 设置内存副本(来自插件私有 data)
|
||||
private logsCache: LogRow[] = []; // 记录内存缓存(存活记录,已排除软删除)
|
||||
private deletedIds = new Set<string>(); // 软删除标记过的 id 集合(墓碑行),用于缓存过滤与 id 去重
|
||||
private selfWriting = false; // 自身写盘标志:写盘期间为 true
|
||||
private lastSelfWriteAt = 0; // 最近一次自身写盘时间戳(ms):用于 vault.on('modify') 漏判兜底
|
||||
private logsFlushTimer: number | null = null; // 记录写盘防抖定时器(合并多次删除/编辑为一次整文件写)
|
||||
|
||||
constructor(plugin: Plugin) {
|
||||
this.plugin = plugin;
|
||||
this.app = plugin.app;
|
||||
this.events = new Events();
|
||||
this.settings = { ...DEFAULT_SETTINGS };
|
||||
this.csvStore = new CSVStore(this);
|
||||
this.configStore = new ConfigStore(this);
|
||||
}
|
||||
|
||||
// 初始化:加载设置(私有 data)、迁移、加载记录(CSV)与配置(vault JSON)。
|
||||
async init(): Promise<void> {
|
||||
const saved = (await this.plugin.loadData()) as (Partial<PluginSettings> & { logs?: LogRow[]; config?: WorkoutConfig }) | null;
|
||||
this.settings = { ...DEFAULT_SETTINGS, ...(saved || {}) };
|
||||
this.applySettingsMigration();
|
||||
|
||||
// 记录:优先从 vault CSV 读取;若 CSV 为空但旧版私有 data 有 logs,则一次性导入 CSV。
|
||||
// readAllWithStats 额外返回丢弃的脏行数(超长 fields 的幽灵行),用于判断是否需要落盘自愈。
|
||||
const initial = await this.csvStore.readAllWithStats();
|
||||
this.logsCache = initial.rows;
|
||||
this.deletedIds = new Set(initial.deletedIds);
|
||||
const dropped = initial.dropped;
|
||||
const hadRows = this.logsCache.length > 0;
|
||||
// 自净:剔除损坏行(字段超大——通常因脏 CSV 未闭合引号把多行吞进一个单元格,
|
||||
// 使某一行 id/fields 变成数百 KB 巨串)与重复 id 的幽灵行。若不清除,每次 writeAll
|
||||
// 都会把巨串放大写回,导致 Obsidian 主线程长时间阻塞、删除记录时整块卡死。
|
||||
const wasDirty = this.sanitizeLogsCache();
|
||||
|
||||
if (!hadRows && saved?.logs && saved.logs.length > 0) {
|
||||
this.logsCache = saved.logs;
|
||||
this.setSelfWriting(true);
|
||||
try {
|
||||
await this.csvStore.writeAll(this.logsCache);
|
||||
} finally {
|
||||
this.setSelfWriting(false);
|
||||
}
|
||||
} else if (wasDirty || dropped > 0) {
|
||||
// 自净后落盘,确保磁盘 CSV 也被修正(丢弃的脏行不再写回),避免下次读取再次解析出脏行
|
||||
this.setSelfWriting(true);
|
||||
try {
|
||||
await this.csvStore.writeAll(this.logsCache);
|
||||
} finally {
|
||||
this.setSelfWriting(false);
|
||||
}
|
||||
}
|
||||
|
||||
// CSV 表头自愈:若磁盘是旧版 9 列表头,整体重写归一为 CSV_HEADER,
|
||||
// 并把兜底生成的 id 持久化,避免后续 appendRow 追加 7 列行造成列错位/数据损坏。
|
||||
await this.normalizeCsvIfNeeded();
|
||||
|
||||
// 配置:vault JSON 优先;若没有则用旧版私有 data 的 config 兜底导入。
|
||||
await this.configStore.ensureLoaded(saved?.config);
|
||||
}
|
||||
|
||||
// CSV 迁移自愈:若磁盘表头不是当前规范表头(旧版结构),用缓存整体重写,
|
||||
// 归一表头并持久化兜底 id,防止「旧文件 + 新追加行」列错位导致数据损坏。
|
||||
private async normalizeCsvIfNeeded(): Promise<void> {
|
||||
if (!(await this.csvStore.isHeaderStale())) return;
|
||||
this.setSelfWriting(true);
|
||||
try {
|
||||
await this.csvStore.writeAll(this.logsCache);
|
||||
} finally {
|
||||
this.setSelfWriting(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容旧版设置字段名(升级保障):把旧的 dataDirectory / csvPath / configPath 映射到新目录字段。
|
||||
private applySettingsMigration(): void {
|
||||
const s = this.settings as unknown as Record<string, unknown>;
|
||||
if (typeof s['dataDirectory'] === 'string' && s['dataDirectory']) {
|
||||
const oldDir = s['dataDirectory'] as string;
|
||||
if (!this.settings.csvDirectory) this.settings.csvDirectory = oldDir;
|
||||
if (!this.settings.configDirectory) this.settings.configDirectory = oldDir;
|
||||
}
|
||||
if (typeof s['csvPath'] === 'string' && s['csvPath']) {
|
||||
const parts = (s['csvPath'] as string).split('/');
|
||||
parts.pop();
|
||||
const dir = parts.join('/');
|
||||
if (!this.settings.csvDirectory) this.settings.csvDirectory = dir;
|
||||
}
|
||||
if (typeof s['configPath'] === 'string' && s['configPath']) {
|
||||
const parts = (s['configPath'] as string).split('/');
|
||||
parts.pop();
|
||||
const dir = parts.join('/');
|
||||
if (!this.settings.configDirectory) this.settings.configDirectory = dir;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取设置(仅内存)。
|
||||
getSettings(): PluginSettings {
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
// 保存设置到私有 data,并广播 settings-changed。
|
||||
async saveSettings(): Promise<void> {
|
||||
await this.plugin.saveData(this.settings);
|
||||
this.emit('settings-changed', this.settings);
|
||||
}
|
||||
|
||||
// 重新从 CSV 加载记录到缓存(外部改文件时调用)。
|
||||
// 同步刷新 deletedIds,确保软删除墓碑在外部文件变动后仍被正确过滤。
|
||||
async reloadLogs(): Promise<void> {
|
||||
const r = await this.csvStore.readAllWithStats();
|
||||
this.logsCache = r.rows;
|
||||
this.deletedIds = new Set(r.deletedIds);
|
||||
}
|
||||
|
||||
// 重新从 vault JSON 加载配置到缓存(外部改文件时调用,先清缓存再读)。
|
||||
async reloadConfig(): Promise<void> {
|
||||
this.configStore.clearCache();
|
||||
await this.configStore.load();
|
||||
}
|
||||
|
||||
// 返回记录副本,避免外部改动影响内部缓存。
|
||||
// 防御性过滤软删除 id:即便缓存意外混入已删行,渲染/统计也看不到。
|
||||
getLogs(): LogRow[] {
|
||||
return this.logsCache.filter((r) => !this.deletedIds.has(r.id));
|
||||
}
|
||||
|
||||
// —— selfWriting 控制 ——
|
||||
// 关键修复点:写盘的「开始」与「结束」都刷新 lastSelfWriteAt 时间戳。
|
||||
// 旧实现只在 setSelfWriting(true)(写盘开始)时刷新;一旦 CSV 较大、整文件重写本身
|
||||
// 耗时超过 1.5s,vault.on('modify') 在写盘【结束后】派发时,时间戳已超出窗口 → 被误判为
|
||||
// 「外部手动改文件」→ 触发 reloadLogs()(整文件重读)+ rerenderAllBlocks()(重算 320KB 热力图),
|
||||
// 造成删除记录后 Obsidian 主线程卡死、丢光标、无法输入。
|
||||
// 改为「结束也刷新」后,无论写盘耗时多久,modify 事件派发时时间戳都落在窗口内,保护必然生效。
|
||||
private setSelfWriting(v: boolean): void {
|
||||
this.selfWriting = v;
|
||||
this.lastSelfWriteAt = Date.now();
|
||||
}
|
||||
// 自身写盘是否「刚刚发生」(默认 2s 内,留足事件派发延迟余量)。
|
||||
// 关键:Obsidian 的 vault.on('modify') 事件是在写盘完成后才异步派发的,此时 selfWriting
|
||||
// 早已被复位为 false,仅靠 isSelfWriting() 会漏判,导致自身写盘被当成「外部改文件」,
|
||||
// 进而触发整文件重读 + 全量重渲染(对大 CSV / 320KB 肌肉 SVG 热力图是致命卡顿)。
|
||||
// 用时间戳兜底(且结束也刷新,见 setSelfWriting),确保自身写盘一定被跳过,
|
||||
// data-changed 已做的精准重渲染足够。
|
||||
wasSelfWrittenRecently(windowMs = 2000): boolean {
|
||||
return Date.now() - this.lastSelfWriteAt < windowMs;
|
||||
}
|
||||
// 供 main.ts 的 vault.on('modify') 监听判断是否「插件自身正在写盘」。
|
||||
isSelfWriting(): boolean {
|
||||
return this.selfWriting;
|
||||
}
|
||||
|
||||
// 记录 CSV 文件路径(供 main.ts 监听匹配)。
|
||||
getCsvPath(): string {
|
||||
const dir = this.settings.csvDirectory || '';
|
||||
return dir ? `${dir}/${CSV_FILENAME}` : CSV_FILENAME;
|
||||
}
|
||||
// 配置 JSON 文件路径(供 main.ts 监听匹配)。
|
||||
getConfigPath(): string {
|
||||
const dir = this.settings.configDirectory || '';
|
||||
return dir ? `${dir}/${CONFIG_FILENAME}` : CONFIG_FILENAME;
|
||||
}
|
||||
|
||||
// 生成一个与现有记录不重复的短 id(默认基于内存缓存 + 软删除集合,避免与已删 id 撞车;
|
||||
// 也可传入已占用集合做批量去重,此时同样并入 deletedIds)。
|
||||
private uniqueLogId(taken: Set<string> = new Set([...this.logsCache.map((r) => r.id), ...this.deletedIds])): string {
|
||||
let id = generateId();
|
||||
while (taken.has(id)) id = generateId();
|
||||
return id;
|
||||
}
|
||||
|
||||
// 新增一条记录:补全 id(timestamp 可由调用方覆盖,缺省取当前时间),O(1) 追加到 CSV,同步缓存,广播 data-changed。
|
||||
async addLog(row: Omit<LogRow, 'id' | 'timestamp'> & { timestamp?: string }): Promise<void> {
|
||||
const now = new Date();
|
||||
const timestamp = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
||||
const id = this.uniqueLogId();
|
||||
const logRow: LogRow = {
|
||||
id,
|
||||
timestamp,
|
||||
...row,
|
||||
};
|
||||
this.setSelfWriting(true);
|
||||
try {
|
||||
await this.csvStore.appendRow(logRow); // Vault.append,O(1),不重写整文件
|
||||
} finally {
|
||||
this.setSelfWriting(false);
|
||||
}
|
||||
this.logsCache.push(logRow);
|
||||
this.emit('data-changed', { type: 'add', row: logRow });
|
||||
}
|
||||
|
||||
// 批量新增「计划」记录:一次合并后整体持久化(只写盘一次)。每条独立生成稳定 id。
|
||||
async addPlanLogs(rows: Omit<LogRow, 'id' | 'timestamp'>[]): Promise<void> {
|
||||
const now = new Date();
|
||||
const taken = new Set([...this.logsCache.map((r) => r.id), ...this.deletedIds]);
|
||||
const logRows: LogRow[] = rows.map((row, i) => {
|
||||
const time = new Date(now.getTime() + i * 60000);
|
||||
const timestamp = `${time.getFullYear()}-${String(time.getMonth() + 1).padStart(2, '0')}-${String(time.getDate()).padStart(2, '0')} ${String(time.getHours()).padStart(2, '0')}:${String(time.getMinutes()).padStart(2, '0')}`;
|
||||
const id = this.uniqueLogId(taken);
|
||||
taken.add(id);
|
||||
return { ...row, id, timestamp };
|
||||
});
|
||||
const merged = [...this.logsCache, ...logRows];
|
||||
this.setSelfWriting(true);
|
||||
try {
|
||||
await this.csvStore.writeAll(merged);
|
||||
} finally {
|
||||
this.setSelfWriting(false);
|
||||
}
|
||||
this.logsCache = merged;
|
||||
this.emit('data-changed', { type: 'add-plan', rows: logRows });
|
||||
}
|
||||
|
||||
// 训练记录写盘防抖:把短时间内的多次 updateLog 合并成「一次」整文件写盘。
|
||||
// 注意:删除已改为「软删除」(appendTombstone,O(1) 追加墓碑),不再触发本防抖重写,
|
||||
// 因此本机制如今只为编辑(updateLog)合并写盘用——编辑本就需改中间行、属低频操作。
|
||||
// 界面刷新走 data-changed(即时),持久化走此处(合并),互不阻塞。
|
||||
// 关键:立即标记 selfWriting 开始时间戳,使 wasSelfWrittenRecently() 能覆盖从「调度写盘」到
|
||||
// 「写盘完成」的整个窗口,避免 vault.on('modify') 在防抖期间误判为外部修改触发重复重渲染。
|
||||
private scheduleLogsFlush(): void {
|
||||
if (this.logsFlushTimer !== null) return;
|
||||
this.lastSelfWriteAt = Date.now();
|
||||
this.logsFlushTimer = window.setTimeout(() => {
|
||||
this.logsFlushTimer = null;
|
||||
void this.flushLogsToDisk();
|
||||
}, 200);
|
||||
}
|
||||
|
||||
// 实际整文件写盘(防抖到点后执行)。写盘期间 selfWriting=true,避免 vault modify 监听重复刷新。
|
||||
// 仅由 updateLog 触发(edit 需改中间行)。软删除走 appendTombstone,不经过此处。
|
||||
private async flushLogsToDisk(): Promise<void> {
|
||||
this.setSelfWriting(true);
|
||||
try {
|
||||
await this.csvStore.writeAll(this.logsCache);
|
||||
} catch (e) {
|
||||
console.error('[workout] 训练记录写盘失败:', e);
|
||||
} finally {
|
||||
this.setSelfWriting(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新一条记录:按稳定主键 id 定位旧行,合并更新后广播变化,写盘走防抖合并(编辑需改中间行,属低频)。
|
||||
async updateLog(id: string, updates: Partial<LogRow>): Promise<void> {
|
||||
const index = this.logsCache.findIndex((r) => r.id === id);
|
||||
if (index !== -1) {
|
||||
this.logsCache[index] = { ...this.logsCache[index], ...updates };
|
||||
}
|
||||
const updated = index !== -1 ? this.logsCache[index] : ({ ...updates, id } as LogRow);
|
||||
this.emit('data-changed', { type: 'update', row: updated });
|
||||
this.scheduleLogsFlush();
|
||||
}
|
||||
|
||||
// 软删除一条记录:不再整文件重写,而是「内存移除 + O(1) 追加墓碑」。
|
||||
// 1) 从内存缓存移除该行(所有渲染/统计都走 getLogs,自动过滤,界面即时消失);
|
||||
// 2) 把 id 记入 deletedIds(用于缓存过滤与后续 id 分配去重);
|
||||
// 3) 仅追加一行 deleted=true 的墓碑到 CSV(vault.append,O(1)),不重写整文件
|
||||
// —— 这彻底消灭了「删除必须整文件重写 → 大 CSV 卡死主线程、丢光标、打不了字」的问题;
|
||||
// 4) 即时广播 data-changed(不依赖写盘),表格里那一行立刻消失。
|
||||
// 真正的整文件压缩(移除墓碑、释放体积)由设置页「压缩清理 CSV」按钮按需触发(compactLogs)。
|
||||
async deleteLog(id: string): Promise<void> {
|
||||
const removed = this.logsCache.find((r) => r.id === id);
|
||||
if (!removed) return; // 已删除或不存在,避免重复追加墓碑
|
||||
this.logsCache = this.logsCache.filter((r) => r.id !== id);
|
||||
this.deletedIds.add(id);
|
||||
this.setSelfWriting(true);
|
||||
try {
|
||||
await this.csvStore.appendTombstone(id);
|
||||
} catch (e) {
|
||||
console.error('[workout] 追加删除墓碑失败:', e);
|
||||
} finally {
|
||||
this.setSelfWriting(false);
|
||||
}
|
||||
this.emit('data-changed', { type: 'delete', row: removed });
|
||||
}
|
||||
|
||||
// 整文件压缩清理:以内存中的「存活记录」为准重写 CSV,
|
||||
// 丢弃所有软删除墓碑行与被删记录的残留行,真正释放体积并规整文件。
|
||||
// 由设置页「压缩清理 CSV」按钮在用户主动触发时执行(O(n) 整写一次,可接受)。
|
||||
// 返回被彻底移除的删除记录条数(供 UI 提示)。
|
||||
async compactLogs(): Promise<number> {
|
||||
const removed = this.deletedIds.size;
|
||||
this.setSelfWriting(true);
|
||||
try {
|
||||
await this.csvStore.writeAll(this.logsCache);
|
||||
} catch (e) {
|
||||
console.error('[workout] 压缩清理 CSV 失败:', e);
|
||||
return 0;
|
||||
} finally {
|
||||
this.setSelfWriting(false);
|
||||
}
|
||||
this.deletedIds.clear();
|
||||
// 通知所有代码块刷新(记录集变化,覆盖 log / day / heatmap)
|
||||
this.emit('data-changed', { type: 'bulk-update' });
|
||||
return removed;
|
||||
}
|
||||
|
||||
// 剔除损坏的日志记录:核心字段缺失、超长或重复均视为脏数据丢弃。
|
||||
// 相同 id 只保留第一条,后续重复行(脏 CSV 未闭合引号制造的幽灵行)一并丢弃。
|
||||
// 返回是否发生过剔除,便于 init() 决定是否落盘修正。
|
||||
private sanitizeLogsCache(): boolean {
|
||||
const MAX_ID = 2000;
|
||||
const MAX_FIELDS = 10000;
|
||||
const seen = new Set<string>();
|
||||
const before = this.logsCache.length;
|
||||
this.logsCache = this.logsCache.filter((r) => {
|
||||
// 关键字段缺失:id / timestamp / category 任意为空即无法正确渲染或持久化,直接丢弃。
|
||||
if (!r.id || !r.timestamp || !r.category) return false;
|
||||
const idLen = String(r.id).length;
|
||||
const fieldsLen = r.fields ? JSON.stringify(r.fields).length : 0;
|
||||
if (idLen > MAX_ID || fieldsLen > MAX_FIELDS) return false;
|
||||
// 去重:重复 id 只保留第一条(幽灵/损坏行丢弃),避免脏数据撑大文件或干扰查询。
|
||||
if (seen.has(r.id)) return false;
|
||||
seen.add(r.id);
|
||||
return true;
|
||||
});
|
||||
return this.logsCache.length !== before;
|
||||
}
|
||||
|
||||
// 标记某计划某训练项的某组为「已完成」(持久化到计划配置,独立于训练记录)。
|
||||
// 即使之后删除了该组产生的训练记录,完成状态仍保留——满足「完成即完成」的预期。
|
||||
async markPlanSetCompleted(plan: TrainingPlanInstance, exerciseId: string, setId: string): Promise<void> {
|
||||
const config = await this.getConfig();
|
||||
const target = config.plans?.find((p) => p.id === plan.id);
|
||||
if (!target) return;
|
||||
if (!target.completedSets) target.completedSets = {};
|
||||
target.completedSets[`${exerciseId}#${setId}`] = todayStr();
|
||||
await this.saveConfig(config);
|
||||
}
|
||||
|
||||
// 读取配置(来自 vault JSON 缓存或加载)。
|
||||
async getConfig(): Promise<WorkoutConfig> {
|
||||
return await this.configStore.load();
|
||||
}
|
||||
|
||||
// 保存配置并广播 config-changed(写盘期间 selfWriting=true,避免监听重复刷新)。
|
||||
async saveConfig(config: WorkoutConfig): Promise<void> {
|
||||
this.setSelfWriting(true);
|
||||
try {
|
||||
await this.configStore.save(config);
|
||||
} finally {
|
||||
this.setSelfWriting(false);
|
||||
}
|
||||
this.emit('config-changed', config);
|
||||
}
|
||||
|
||||
// —— 以下是一组对配置里「训练类型 / 训练项 / 肌肉」的增删改封装 ——
|
||||
async addTrainingType(type: TrainingType): Promise<void> {
|
||||
const config = await this.getConfig();
|
||||
config.trainingTypes.push(type);
|
||||
await this.saveConfig(config);
|
||||
}
|
||||
|
||||
async updateTrainingType(id: string, updates: Partial<TrainingType>): Promise<void> {
|
||||
const config = await this.getConfig();
|
||||
const index = config.trainingTypes.findIndex((t) => t.id === id);
|
||||
if (index !== -1) {
|
||||
config.trainingTypes[index] = { ...config.trainingTypes[index], ...updates };
|
||||
await this.saveConfig(config);
|
||||
}
|
||||
}
|
||||
|
||||
// 重命名训练类型 ID,并级联更新所有关联引用:
|
||||
// 1) 配置里该类型的 id 改为 newId(其余字段按 updates 合并);
|
||||
// 2) 所有训练记录的 category === oldId → newId;
|
||||
// 3) 所有训练项的 category === oldId → newId(训练项归属该类型);
|
||||
// 4) 所有统计的 associatedTypes 里出现的 oldId → newId(统计按类型关联)。
|
||||
// 用于「编辑训练类型时改了 ID」的场景,保证记录/训练项/统计都不被断联。
|
||||
async renameTrainingType(oldId: string, newId: string, updates: Partial<TrainingType>): Promise<void> {
|
||||
const config = await this.getConfig();
|
||||
const index = config.trainingTypes.findIndex((t) => t.id === oldId);
|
||||
if (index !== -1) {
|
||||
config.trainingTypes[index] = { ...config.trainingTypes[index], ...updates, id: newId };
|
||||
await this.saveConfig(config); // saveConfig 内部会广播 config-changed
|
||||
}
|
||||
|
||||
// 级联更新记录里的 category
|
||||
let changed = false;
|
||||
this.logsCache = this.logsCache.map((r) => {
|
||||
if (r.category === oldId) {
|
||||
changed = true;
|
||||
return { ...r, category: newId };
|
||||
}
|
||||
return r;
|
||||
});
|
||||
|
||||
// 级联更新训练项的 category(训练项归属该类型)
|
||||
let exercisesChanged = false;
|
||||
config.exercises = config.exercises.map((e) => {
|
||||
if (e.category === oldId) {
|
||||
exercisesChanged = true;
|
||||
return { ...e, category: newId };
|
||||
}
|
||||
return e;
|
||||
});
|
||||
|
||||
// 级联更新统计的 associatedTypes(统计按训练类型关联,可能含多个 id)
|
||||
let statsChanged = false;
|
||||
config.statistics = (config.statistics || []).map((s) => {
|
||||
if (s.associatedTypes && s.associatedTypes.includes(oldId)) {
|
||||
statsChanged = true;
|
||||
return {
|
||||
...s,
|
||||
associatedTypes: s.associatedTypes.map((t) => (t === oldId ? newId : t)),
|
||||
};
|
||||
}
|
||||
return s;
|
||||
});
|
||||
|
||||
if (exercisesChanged || statsChanged) {
|
||||
await this.saveConfig(config); // 二次持久化(配置侧);已含 config-changed 广播
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
this.setSelfWriting(true);
|
||||
try {
|
||||
await this.csvStore.writeAll(this.logsCache);
|
||||
} finally {
|
||||
this.setSelfWriting(false);
|
||||
}
|
||||
// 不带 row,main.ts 会退化为全量重渲染(覆盖所有引用该 ID 的表格)
|
||||
this.emit('data-changed', { type: 'bulk-update' });
|
||||
}
|
||||
}
|
||||
|
||||
async deleteTrainingType(id: string): Promise<void> {
|
||||
const config = await this.getConfig();
|
||||
config.trainingTypes = config.trainingTypes.filter((t) => t.id !== id);
|
||||
await this.saveConfig(config);
|
||||
}
|
||||
|
||||
async addExercise(exercise: Exercise): Promise<void> {
|
||||
const config = await this.getConfig();
|
||||
config.exercises.push(exercise);
|
||||
await this.saveConfig(config);
|
||||
}
|
||||
|
||||
async updateExercise(id: string, updates: Partial<Exercise>): Promise<void> {
|
||||
const config = await this.getConfig();
|
||||
const index = config.exercises.findIndex((e) => e.id === id);
|
||||
if (index !== -1) {
|
||||
config.exercises[index] = { ...config.exercises[index], ...updates };
|
||||
await this.saveConfig(config);
|
||||
}
|
||||
}
|
||||
|
||||
// 重命名训练项 ID,并级联更新所有关联记录:
|
||||
// 1) 配置里该训练项的 id 改为 newId(其余字段按 updates 合并);
|
||||
// 2) CSV 中所有 exerciseId === oldId 的记录批量改为 newId,整体写盘。
|
||||
// 用于「编辑训练项时改了 ID」的场景,保证历史记录不被断联。
|
||||
async renameExercise(oldId: string, newId: string, updates: Partial<Exercise>): Promise<void> {
|
||||
const config = await this.getConfig();
|
||||
const index = config.exercises.findIndex((e) => e.id === oldId);
|
||||
if (index !== -1) {
|
||||
config.exercises[index] = { ...config.exercises[index], ...updates, id: newId };
|
||||
await this.saveConfig(config); // saveConfig 内部会广播 config-changed
|
||||
}
|
||||
|
||||
// 级联更新记录里的 exerciseId
|
||||
let changed = false;
|
||||
this.logsCache = this.logsCache.map((r) => {
|
||||
if (r.exerciseId === oldId) {
|
||||
changed = true;
|
||||
return { ...r, exerciseId: newId };
|
||||
}
|
||||
return r;
|
||||
});
|
||||
|
||||
if (changed) {
|
||||
this.setSelfWriting(true);
|
||||
try {
|
||||
await this.csvStore.writeAll(this.logsCache);
|
||||
} finally {
|
||||
this.setSelfWriting(false);
|
||||
}
|
||||
// 不带 row,main.ts 会退化为全量重渲染(覆盖所有引用该 ID 的表格)
|
||||
this.emit('data-changed', { type: 'bulk-update' });
|
||||
}
|
||||
}
|
||||
|
||||
// 删除训练项:先级联软删除其关联的全部训练记录(exerciseId === id),再移除训练项本身。
|
||||
// 级联删除沿用 deleteLog 的软删除策略(移除缓存行 + 记墓碑 id + O(1) 批量追加墓碑),
|
||||
// 避免整文件重写卡顿;真正释放体积沿用「压缩清理 CSV」按需执行。
|
||||
// 这样「训练项还有训练记录时删除该训练项」不会再留下孤儿记录。
|
||||
async deleteExercise(id: string): Promise<void> {
|
||||
// 1) 找出该训练项关联的全部训练记录并软删除
|
||||
const related = this.logsCache.filter((r) => r.exerciseId === id);
|
||||
if (related.length > 0) {
|
||||
this.logsCache = this.logsCache.filter((r) => r.exerciseId !== id);
|
||||
for (const r of related) this.deletedIds.add(r.id);
|
||||
this.setSelfWriting(true);
|
||||
try {
|
||||
await this.csvStore.appendTombstones(related.map((r) => r.id));
|
||||
} catch (e) {
|
||||
console.error('[workout] 级联删除训练记录墓碑失败:', e);
|
||||
} finally {
|
||||
this.setSelfWriting(false);
|
||||
}
|
||||
// 训练记录变化:无 row → main.ts 退化为全量重渲染所有 log/day/heatmap 代码块
|
||||
this.emit('data-changed', { type: 'bulk-update' });
|
||||
}
|
||||
|
||||
// 2) 移除训练项本身并落盘(saveConfig 会广播 config-changed,触发项目列表与代码块刷新)
|
||||
const config = await this.getConfig();
|
||||
config.exercises = config.exercises.filter((e) => e.id !== id);
|
||||
await this.saveConfig(config);
|
||||
}
|
||||
|
||||
async addMuscle(muscle: Muscle): Promise<void> {
|
||||
const config = await this.getConfig();
|
||||
config.muscles.push(muscle);
|
||||
await this.saveConfig(config);
|
||||
}
|
||||
|
||||
async updateMuscle(id: string, updates: Partial<Muscle>): Promise<void> {
|
||||
const config = await this.getConfig();
|
||||
const index = config.muscles.findIndex((m) => m.id === id);
|
||||
if (index !== -1) {
|
||||
config.muscles[index] = { ...config.muscles[index], ...updates };
|
||||
await this.saveConfig(config);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMuscle(id: string): Promise<void> {
|
||||
const config = await this.getConfig();
|
||||
config.muscles = config.muscles.filter((m) => m.id !== id);
|
||||
await this.saveConfig(config);
|
||||
}
|
||||
|
||||
// —— 训练计划(聚合进 workout-config.json 的 plans 字段)——
|
||||
// 返回全部训练计划实例(无则空数组)。
|
||||
async getPlans(): Promise<TrainingPlanInstance[]> {
|
||||
const config = await this.getConfig();
|
||||
return config.plans ?? [];
|
||||
}
|
||||
|
||||
// 按计划名(全局唯一)取单个计划实例。
|
||||
async getPlanByName(name: string): Promise<TrainingPlanInstance | undefined> {
|
||||
const config = await this.getConfig();
|
||||
return config.plans?.find((p) => p.name === name);
|
||||
}
|
||||
|
||||
// 校验计划名是否已被占用(编辑时传入 exceptId 排除自身)。
|
||||
async isPlanNameTaken(name: string, exceptId?: string): Promise<boolean> {
|
||||
const config = await this.getConfig();
|
||||
return (config.plans ?? []).some((p) => p.name === name && p.id !== exceptId);
|
||||
}
|
||||
|
||||
// 新增或更新计划:以 name 为主键(全局唯一)。已存在则覆盖,否则追加。
|
||||
async upsertPlan(plan: TrainingPlanInstance): Promise<void> {
|
||||
const config = await this.getConfig();
|
||||
if (!config.plans) config.plans = [];
|
||||
// 以稳定的 id 为主键(而非 name)。编辑计划时若改名,按 name 匹配会找不到旧条目、
|
||||
// 导致新增一条同 id 的重复计划、旧条目残留(配置损坏);按 id 匹配则原地更新。
|
||||
const idx = config.plans.findIndex((p) => p.id === plan.id);
|
||||
const oldName = idx >= 0 ? config.plans[idx].name : undefined;
|
||||
if (idx >= 0) config.plans[idx] = plan;
|
||||
else config.plans.push(plan);
|
||||
await this.saveConfig(config);
|
||||
// 级联改名:编辑计划时若改了名称,把所有引用旧名的 workout-plan 代码块改写到新名(不动 CSV 历史)
|
||||
if (oldName && oldName !== plan.name) {
|
||||
try {
|
||||
await this.renamePlanInCodeBlocks(oldName, plan.name);
|
||||
} catch (e) {
|
||||
console.error('[workout] 级联更新计划代码块失败:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 级联改名:扫描 vault 内所有 markdown 文件,把引用了 oldName 的 workout-plan 代码块的
|
||||
// plan: 参数改写为 newName。只改代码块、不触碰 CSV 历史记录。
|
||||
private async renamePlanInCodeBlocks(oldName: string, newName: string): Promise<number> {
|
||||
const files = this.app.vault.getMarkdownFiles();
|
||||
let updated = 0;
|
||||
for (const file of files) {
|
||||
const content = await this.app.vault.read(file);
|
||||
const lines = content.split('\n');
|
||||
let changed = false;
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
if (lines[i].trim().startsWith('```workout-plan')) {
|
||||
// 在代码块内查找 plan: 参数行(只处理第一个)
|
||||
let j = i + 1;
|
||||
let closing = -1;
|
||||
while (j < lines.length) {
|
||||
if (lines[j].trim().startsWith('```')) { closing = j; break; }
|
||||
const trimmed = lines[j].trim();
|
||||
const colonIdx = trimmed.indexOf(':');
|
||||
if (colonIdx > 0) {
|
||||
const key = trimmed.slice(0, colonIdx).trim();
|
||||
if (key === 'plan') {
|
||||
const val = trimmed.slice(colonIdx + 1).trim();
|
||||
if (val === oldName) {
|
||||
lines[j] = 'plan: ' + newName;
|
||||
changed = true;
|
||||
updated++;
|
||||
}
|
||||
break; // 只处理第一个 plan: 参数
|
||||
}
|
||||
}
|
||||
j++;
|
||||
}
|
||||
i = closing >= 0 ? closing + 1 : lines.length;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
await this.app.vault.modify(file, lines.join('\n'));
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
// 删除计划(不影响已写 CSV 记录)。
|
||||
async deletePlan(name: string): Promise<void> {
|
||||
const config = await this.getConfig();
|
||||
if (!config.plans) return;
|
||||
config.plans = config.plans.filter((p) => p.name !== name);
|
||||
await this.saveConfig(config);
|
||||
}
|
||||
|
||||
// 更新某计划内某训练项某组的预设字段(组内编辑「未完成」组保存时调用,只改预设不写库)。
|
||||
async updatePlanSetFields(planName: string, exerciseId: string, setId: string, fields: Record<string, unknown>): Promise<void> {
|
||||
const config = await this.getConfig();
|
||||
const plan = config.plans?.find((p) => p.name === planName);
|
||||
if (!plan) return;
|
||||
const item = plan.items.find((i) => i.exerciseId === exerciseId && i.enabled);
|
||||
const set = item?.sets.find((s) => s.id === setId);
|
||||
if (set) set.fields = fields;
|
||||
await this.saveConfig(config);
|
||||
}
|
||||
|
||||
// 记忆上次输入值:返回最近一次该训练项的字段值,供下次自动填充。
|
||||
getLastValues(exerciseId?: string): Record<string, unknown> | null {
|
||||
if (!this.settings.lastValueMemory) {
|
||||
return null;
|
||||
}
|
||||
if (!exerciseId) {
|
||||
return null;
|
||||
}
|
||||
const logs = this.logsCache.filter((l) => l.exerciseId === exerciseId && l.timestamp);
|
||||
if (logs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
logs.sort((a, b) => (b.timestamp || '').localeCompare(a.timestamp || ''));
|
||||
return logs[0].fields;
|
||||
}
|
||||
|
||||
// 订阅事件(重载签名:分别约束三种事件的回调参数类型)。
|
||||
on(event: 'data-changed', callback: (data: { type: string; row?: LogRow; rows?: LogRow[]; id?: string }) => void): void;
|
||||
on(event: 'config-changed', callback: (config: WorkoutConfig) => void): void;
|
||||
on(event: 'settings-changed', callback: (settings: PluginSettings) => void): void;
|
||||
on(event: string, callback: (...args: any[]) => void): void {
|
||||
this.events.on(event, callback);
|
||||
}
|
||||
|
||||
// 取消订阅事件
|
||||
off(event: string, callback: (...args: unknown[]) => void): void {
|
||||
this.events.off(event, callback);
|
||||
}
|
||||
|
||||
// 内部触发事件
|
||||
private emit(event: 'data-changed', data: { type: string; row?: LogRow; rows?: LogRow[]; id?: string }): void;
|
||||
private emit(event: 'config-changed', config: WorkoutConfig): void;
|
||||
private emit(event: 'settings-changed', settings: PluginSettings): void;
|
||||
private emit(event: string, ...args: unknown[]): void {
|
||||
this.events.trigger(event, ...args);
|
||||
}
|
||||
}
|
||||
60
src/data/display.test.ts
Normal file
60
src/data/display.test.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
/*
|
||||
* display.test.ts —— display.ts 单元测试
|
||||
* 重点覆盖:训练项显示名解析、多语言反向查找、记录字段格式化等纯函数逻辑。
|
||||
*/
|
||||
|
||||
describe('display helpers', async () => {
|
||||
const { getDefaultConfig } = await import('./seed');
|
||||
const { setLocale } = await import('../i18n');
|
||||
const { resolveExerciseByName, resolveExerciseIdByName } = await import('./display');
|
||||
|
||||
beforeEach(() => {
|
||||
// 每个用例前重置回中文,避免用例间语言状态互相影响
|
||||
setLocale('zh');
|
||||
});
|
||||
|
||||
// 用例 L1:中文界面下,用中文名能解析到默认训练项
|
||||
it('L1: resolves default exercise by Chinese name in zh locale', () => {
|
||||
const config = getDefaultConfig();
|
||||
const exercise = resolveExerciseByName(config, '深蹲');
|
||||
expect(exercise?.id).toBe('squat');
|
||||
expect(resolveExerciseIdByName(config, '深蹲')).toBe('squat');
|
||||
});
|
||||
|
||||
// 用例 L2:英文界面下,代码块里仍用中文名(未随语言切换)时,也能解析到默认训练项
|
||||
// 这是修复中英文切换后默认训练项查不到记录的关键回归点
|
||||
it('L2: resolves default exercise by Chinese name even in en locale', () => {
|
||||
setLocale('en');
|
||||
const config = getDefaultConfig();
|
||||
const exercise = resolveExerciseByName(config, '深蹲');
|
||||
expect(exercise?.id).toBe('squat');
|
||||
expect(resolveExerciseIdByName(config, '深蹲')).toBe('squat');
|
||||
});
|
||||
|
||||
// 用例 L3:中文界面下,用英文名也应能解析到默认训练项
|
||||
it('L3: resolves default exercise by English name in zh locale', () => {
|
||||
const config = getDefaultConfig();
|
||||
expect(resolveExerciseByName(config, 'Squat')?.id).toBe('squat');
|
||||
expect(resolveExerciseIdByName(config, 'Bench Press')).toBe('bench_press');
|
||||
});
|
||||
|
||||
// 用例 L4:自定义训练项(只有固定 name,没有 nameKey)按 name 匹配
|
||||
it('L4: resolves custom exercise by its plain name', () => {
|
||||
const config = getDefaultConfig();
|
||||
config.exercises.push({
|
||||
id: 'custom_test',
|
||||
name: '测试训练',
|
||||
category: 'strength',
|
||||
});
|
||||
setLocale('en');
|
||||
expect(resolveExerciseByName(config, '测试训练')?.id).toBe('custom_test');
|
||||
});
|
||||
|
||||
// 用例 L5:按训练项 id 也能匹配
|
||||
it('L5: resolves exercise by its id', () => {
|
||||
const config = getDefaultConfig();
|
||||
expect(resolveExerciseByName(config, 'deadlift')?.id).toBe('deadlift');
|
||||
});
|
||||
});
|
||||
173
src/data/display.ts
Normal file
173
src/data/display.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import { t, getAllTranslations } from '../i18n';
|
||||
import { Exercise, FieldDef, LogRow, Muscle, TimeRule, TrainingType, WorkoutConfig } from './types';
|
||||
import { formatDuration } from '../util/duration';
|
||||
import { formatMass } from '../util/units';
|
||||
|
||||
/*
|
||||
* display.ts —— "显示名解析"工具集。
|
||||
* 配置里的名称优先用 nameKey(国际化 key,由 i18n 函数 t 翻译成当前语言),
|
||||
* 没有 nameKey 才用 name 字段。本文件还提供字段标签、单位、字段值的格式化函数,
|
||||
* 负责把"程序存起来的数据"变成"用户看得懂的文字"。
|
||||
*/
|
||||
|
||||
// 默认肌肉 -> 国际化 key 的映射表(当配置里没写 nameKey 时作为兜底)
|
||||
export const DEFAULT_MUSCLE_NAME_KEYS: Record<string, string> = {
|
||||
chest: 'muscle.chest',
|
||||
front_delt: 'muscle.front_delt',
|
||||
biceps: 'muscle.biceps',
|
||||
quads: 'muscle.quads',
|
||||
front_calf: 'muscle.front_calf',
|
||||
abs: 'muscle.abs',
|
||||
lats: 'muscle.lats',
|
||||
traps: 'muscle.traps',
|
||||
rear_delt: 'muscle.rear_delt',
|
||||
triceps: 'muscle.triceps',
|
||||
hamstrings: 'muscle.hamstrings',
|
||||
glutes: 'muscle.glutes',
|
||||
back_calf: 'muscle.back_calf',
|
||||
};
|
||||
|
||||
// 默认训练项 -> 国际化 key 映射
|
||||
export const DEFAULT_EXERCISE_NAME_KEYS: Record<string, string> = {
|
||||
squat: 'exercise.squat',
|
||||
deadlift: 'exercise.deadlift',
|
||||
bench_press: 'exercise.bench_press',
|
||||
pushup: 'exercise.pushup',
|
||||
pullup: 'exercise.pullup',
|
||||
plank: 'exercise.plank',
|
||||
running: 'exercise.running',
|
||||
jumping_rope: 'exercise.jumping_rope',
|
||||
cycling: 'exercise.cycling',
|
||||
};
|
||||
|
||||
// 默认训练类型 -> 国际化 key 映射
|
||||
export const DEFAULT_TRAINING_TYPE_NAME_KEYS: Record<string, string> = {
|
||||
strength: 'type.strength',
|
||||
aerobic: 'type.aerobic',
|
||||
bodyweight: 'type.bodyweight',
|
||||
};
|
||||
|
||||
// 取训练类型的显示名:nameKey 优先(翻译成当前语言),否则用 name/id 兜底。
|
||||
export function getTrainingTypeName(type?: TrainingType): string {
|
||||
if (!type) return '';
|
||||
return type.nameKey ? t(type.nameKey) : type.name || type.id;
|
||||
}
|
||||
|
||||
// 取训练项显示名
|
||||
export function getExerciseName(exercise?: Exercise): string {
|
||||
if (!exercise) return '';
|
||||
return exercise.nameKey ? t(exercise.nameKey) : exercise.name || exercise.id;
|
||||
}
|
||||
|
||||
// 取肌肉显示名
|
||||
export function getMuscleName(muscle?: Muscle): string {
|
||||
if (!muscle) return '';
|
||||
return muscle.nameKey ? t(muscle.nameKey) : muscle.name || muscle.id;
|
||||
}
|
||||
|
||||
// 取字段标签(展示给用户看的字段名)
|
||||
export function getFieldLabel(field: FieldDef): string {
|
||||
return field.labelKey ? t(field.labelKey) : field.label || field.key;
|
||||
}
|
||||
|
||||
// 取字段单位文字:质量类返回用户设置的 kg/lb;其余返回自由单位文字(unitLabel)。
|
||||
export function getFieldUnit(field: FieldDef, unit: 'kg' | 'lb'): string {
|
||||
if (field.mass) return unit; // 质量跟随插件单位设置(kg 或 lb)
|
||||
if (field.unitLabel) return field.unitLabel; // 自由单位文字(如"次""层""圈")
|
||||
return '';
|
||||
}
|
||||
|
||||
// 把字段值格式化成展示文字:时长用 formatDuration,质量用 formatMass(含单位换算),其余直接转字符串。
|
||||
export function renderFieldValue(value: unknown, field: FieldDef, unit: 'kg' | 'lb'): string {
|
||||
if (value === undefined || value === null) return '';
|
||||
|
||||
switch (field.inputType) {
|
||||
case 'duration':
|
||||
return formatDuration(Number(value)); // 秒数 -> "1分30秒"这类可读文本
|
||||
case 'number':
|
||||
if (field.mass) {
|
||||
return formatMass(Number(value), unit); // 质量按 kg/lb 换算并显示单位
|
||||
}
|
||||
return String(value);
|
||||
case 'text':
|
||||
return String(value);
|
||||
default:
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
// 按 id 在训练项列表里查找并返回显示名(找不到返回空字符串)
|
||||
export function getExerciseNameById(exercises: Exercise[], id?: string): string {
|
||||
if (!id) return '';
|
||||
const exercise = exercises.find((e) => e.id === id);
|
||||
return exercise ? getExerciseName(exercise) : '';
|
||||
}
|
||||
|
||||
// 把计划时间规则渲染成可读文字:指定日期直接返回日期;每周模式按 ISO 周几
|
||||
// (周一=1 … 周日=7)拼成「每周 一、三、五」这样的提示。供「新增训练计划」弹窗
|
||||
// 与 workout-plan 完成面板复用,避免各模块各自拼装造成表述不一致。
|
||||
export function formatTimeRule(rule: TimeRule): string {
|
||||
if (rule.type === 'date') {
|
||||
if (!rule.date) return '';
|
||||
return rule.date;
|
||||
}
|
||||
const labels = ['一', '二', '三', '四', '五', '六', '日']; // ISO 周一=1 … 周日=7
|
||||
const days = (rule.weekdays ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => a - b)
|
||||
.map((d) => (d >= 1 && d <= 7 ? labels[d - 1] : String(d)));
|
||||
if (days.length === 0) return t('modal.newPlan.weekdayNone');
|
||||
return t('modal.newPlan.weekdayHint', { days: days.join('、') });
|
||||
}
|
||||
|
||||
// 按「名字/显示名/id」在配置里反查训练项对象。
|
||||
// 用于把代码块里的 `exercise: 深蹲` 或 `exercise: Squat` 解析成稳定的训练项 id,
|
||||
// 解决多语言切换后按「当前语言显示名」匹配不到历史记录的问题。
|
||||
export function resolveExerciseByName(config: WorkoutConfig, name: string): Exercise | undefined {
|
||||
if (!name) return undefined;
|
||||
const lower = name.toLowerCase();
|
||||
return config.exercises.find((e) => {
|
||||
if (e.id.toLowerCase() === lower) return true;
|
||||
if ((e.name || '').toLowerCase() === lower) return true;
|
||||
// 默认训练项用 nameKey 做国际化;按全部支持语言的显示名匹配,
|
||||
// 这样中英文切换后,代码块里用任意一种语言写的名字都能查到记录。
|
||||
if (e.nameKey && getAllTranslations(e.nameKey).some((n) => n.toLowerCase() === lower)) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
// 同上,但直接返回训练项 id(找不到返回 undefined)。
|
||||
export function resolveExerciseIdByName(config: WorkoutConfig, name: string): string | undefined {
|
||||
return resolveExerciseByName(config, name)?.id;
|
||||
}
|
||||
|
||||
// 解析日志记录的运动显示名:
|
||||
// 仅用 exerciseId(稳定,改名/切换语言后自动解析为最新显示名)。
|
||||
// 无 exerciseId 的旧数据无法关联配置,返回空串,由调用方决定兜底展示。
|
||||
export function resolveLogExerciseName(config: WorkoutConfig, log: LogRow): string {
|
||||
if (log.exerciseId) {
|
||||
const name = getExerciseNameById(config.exercises, log.exerciseId);
|
||||
if (name) return name; // 找到了就返回最新显示名(始终随配置更新)
|
||||
}
|
||||
return ''; // 无 exerciseId(旧数据)时返回空
|
||||
}
|
||||
|
||||
// 给配置里的训练类型/训练项/肌肉补齐默认 nameKey(仅在还没有 nameKey 时补,不覆盖已有值)。
|
||||
export function applyDefaultNameKeys(config: WorkoutConfig): WorkoutConfig {
|
||||
config.trainingTypes = config.trainingTypes.map((type) => ({
|
||||
...type,
|
||||
nameKey: type.nameKey || DEFAULT_TRAINING_TYPE_NAME_KEYS[type.id],
|
||||
}));
|
||||
|
||||
config.exercises = config.exercises.map((exercise) => ({
|
||||
...exercise,
|
||||
nameKey: exercise.nameKey || DEFAULT_EXERCISE_NAME_KEYS[exercise.id],
|
||||
}));
|
||||
|
||||
config.muscles = config.muscles.map((muscle) => ({
|
||||
...muscle,
|
||||
nameKey: muscle.nameKey || DEFAULT_MUSCLE_NAME_KEYS[muscle.id],
|
||||
}));
|
||||
|
||||
return config;
|
||||
}
|
||||
98
src/data/muscleMapping.ts
Normal file
98
src/data/muscleMapping.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { Muscle, WorkoutConfig } from './types';
|
||||
import { SVG_MUSCLE_CATALOG } from './svgMuscleCatalog';
|
||||
|
||||
// 默认 13 块肌肉各自归属的健身群 key(用于「默认」档自动映射)
|
||||
export const MUSCLE_FITNESS_GROUP: Record<string, string> = {
|
||||
chest: 'chest',
|
||||
front_delt: 'shoulders',
|
||||
biceps: 'biceps',
|
||||
quads: 'quads',
|
||||
front_calf: 'calves',
|
||||
abs: 'abs',
|
||||
lats: 'back',
|
||||
traps: 'traps',
|
||||
rear_delt: 'shoulders',
|
||||
triceps: 'triceps',
|
||||
hamstrings: 'hamstrings',
|
||||
glutes: 'glutes',
|
||||
back_calf: 'calves',
|
||||
};
|
||||
|
||||
// 「精简」档:每块肌肉只保留 1~2 条代表性主路径
|
||||
export const MINIMAL_MAP: Record<string, string[]> = {
|
||||
chest: ['pectoralis_major_l', 'pectoralis_major_r'],
|
||||
front_delt: ['anterior_deltoid_l', 'anterior_deltoid_r'],
|
||||
biceps: ['biceps_brachii_caput_longum_l', 'biceps_brachii_caput_longum_r'],
|
||||
quads: ['vastus_lateralis_l', 'vastus_lateralis_r', 'rectus_femoris_l', 'rectus_femoris_r'],
|
||||
front_calf: ['tibialis_anterior_l', 'tibialis_anterior_r'],
|
||||
abs: ['rectus_abdominis_1', 'rectus_abdominis_2_l', 'rectus_abdominis_2_r'],
|
||||
lats: ['latissimus_dorsi_l', 'latissimus_dorsi_r'],
|
||||
traps: ['trapezius_upper_l', 'trapezius_upper_r'],
|
||||
rear_delt: ['posterior_deltoid_l', 'posterior_deltoid_r'],
|
||||
triceps: ['triceps_brachii_caput_longum_l', 'triceps_brachii_caput_longum_r'],
|
||||
hamstrings: ['biceps_femoris_l', 'biceps_femoris_r'],
|
||||
glutes: ['gluteus_maximus_l', 'gluteus_maximus_r'],
|
||||
back_calf: ['gastrocnemius_l', 'gastrocnemius_r'],
|
||||
};
|
||||
|
||||
export type MappingTier = 'default' | 'minimal' | 'manual';
|
||||
|
||||
// 根据档位生成每块默认肌肉的 svgRegionIds
|
||||
export function buildMappings(tier: MappingTier): Record<string, string[]> {
|
||||
const result: Record<string, string[]> = {};
|
||||
for (const muscleId of Object.keys(MUSCLE_FITNESS_GROUP)) {
|
||||
if (tier === 'manual') {
|
||||
result[muscleId] = [];
|
||||
continue;
|
||||
}
|
||||
if (tier === 'minimal') {
|
||||
result[muscleId] = [...(MINIMAL_MAP[muscleId] ?? [])];
|
||||
continue;
|
||||
}
|
||||
// default:取该肌所属健身群的全部路径。
|
||||
// 同一 id 可能同时出现在 front 和 back 两个 SVG 文件里(目录里就有两条记录),
|
||||
// 这里去重,避免热力图聚合时对同一路径重复累加 value 造成双倍计数。
|
||||
const group = MUSCLE_FITNESS_GROUP[muscleId];
|
||||
const seen = new Set<string>();
|
||||
const ids: string[] = [];
|
||||
for (const entry of SVG_MUSCLE_CATALOG) {
|
||||
if (entry.fitnessGroup !== group) continue;
|
||||
if (seen.has(entry.id)) continue;
|
||||
seen.add(entry.id);
|
||||
ids.push(entry.id);
|
||||
}
|
||||
result[muscleId] = ids;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// 把某档映射直接写进配置(首次引导 / 重新套用预设用)
|
||||
export function applyMappingTier(config: WorkoutConfig, tier: MappingTier): void {
|
||||
const mappings = buildMappings(tier);
|
||||
for (const muscle of config.muscles) {
|
||||
if (mappings[muscle.id]) {
|
||||
muscle.svgRegionIds = mappings[muscle.id];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 读取某肌肉的 SVG 路径 id 集合(渲染层统一入口)
|
||||
export function getSvgRegionIds(muscle: Muscle): string[] {
|
||||
return muscle.svgRegionIds ?? [];
|
||||
}
|
||||
|
||||
// 由已映射路径的 side 派生肌肉 side(front / back / both)。
|
||||
// 同一 id 在目录里可能同时存在 front 和 back 两条记录,故需用 some() 收集两侧:
|
||||
// 只要该 id 在某一侧出现过,就认为该肌肉在该侧有覆盖。
|
||||
export function deriveMuscleSide(muscle: Muscle): 'front' | 'back' | 'both' {
|
||||
const ids = getSvgRegionIds(muscle);
|
||||
const sides = new Set<'front' | 'back'>();
|
||||
for (const id of ids) {
|
||||
if (SVG_MUSCLE_CATALOG.some((e) => e.id === id && e.side === 'front')) sides.add('front');
|
||||
if (SVG_MUSCLE_CATALOG.some((e) => e.id === id && e.side === 'back')) sides.add('back');
|
||||
}
|
||||
if (sides.size === 1) {
|
||||
return sides.has('front') ? 'front' : 'back';
|
||||
}
|
||||
return 'both';
|
||||
}
|
||||
74
src/data/planScanner.ts
Normal file
74
src/data/planScanner.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { App, TFile } from 'obsidian';
|
||||
import { WorkoutConfig } from './types';
|
||||
import { resolveExerciseByName } from './display';
|
||||
|
||||
/*
|
||||
* planScanner.ts —— 训练方案(笔记)扫描工具
|
||||
* 「训练方案」= 含有 ```workout-plan 代码块的笔记。本文件提供两个能力:
|
||||
* 1) findSchemeNotes:扫描 vault,找出所有训练方案笔记(供「新增训练计划」下拉选择)。
|
||||
* 2) extractSchemeExercises:从某方案笔记里提取所有 workout-log 代码块的 exercise,
|
||||
* 解析为训练项列表(去重),作为计划的初始训练项。
|
||||
* 两者都用 cachedRead(Obsidian 缓存层),避免反复读盘;结果做进程内缓存。
|
||||
*/
|
||||
|
||||
export interface SchemeNote {
|
||||
path: string;
|
||||
name: string; // basename(去掉 .md),即方案名
|
||||
}
|
||||
|
||||
// 进程内缓存:扫描结果在本次会话内复用,避免每次打开弹窗都全量扫描。
|
||||
let schemeCache: SchemeNote[] | null = null;
|
||||
|
||||
// 重置缓存(配置/笔记变化后可调用,强制下次重新扫描)。
|
||||
export function invalidateSchemeCache(): void {
|
||||
schemeCache = null;
|
||||
}
|
||||
|
||||
// 找出所有含 ```workout-plan 代码块的笔记。
|
||||
export async function findSchemeNotes(app: App): Promise<SchemeNote[]> {
|
||||
if (schemeCache) return schemeCache;
|
||||
const notes: SchemeNote[] = [];
|
||||
if (typeof app.vault.getMarkdownFiles !== 'function') return notes;
|
||||
const files = app.vault.getMarkdownFiles();
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = await app.vault.cachedRead(file);
|
||||
if (/```\s*workout-plan\b/.test(content)) {
|
||||
notes.push({ path: file.path, name: file.basename });
|
||||
}
|
||||
} catch {
|
||||
// 单文件读取失败不影响整体
|
||||
}
|
||||
}
|
||||
notes.sort((a, b) => a.name.localeCompare(b.name));
|
||||
schemeCache = notes;
|
||||
return notes;
|
||||
}
|
||||
|
||||
// 从方案笔记提取训练项:遍历所有 ```workout-log 代码块,解析 exercise 参数 → 训练项 id。
|
||||
export async function extractSchemeExercises(
|
||||
app: App,
|
||||
notePath: string,
|
||||
config: WorkoutConfig
|
||||
): Promise<{ exerciseId: string; category: string }[]> {
|
||||
if (typeof app.vault.getAbstractFileByPath !== 'function') return [];
|
||||
const file = app.vault.getAbstractFileByPath(notePath);
|
||||
if (!(file instanceof TFile)) return [];
|
||||
const content = await app.vault.cachedRead(file);
|
||||
|
||||
const result: { exerciseId: string; category: string }[] = [];
|
||||
const seen = new Set<string>();
|
||||
const re = /```\s*workout-log[\s\S]*?```/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(content))) {
|
||||
const block = m[0];
|
||||
const exLine = block.match(/^\s*exercise:\s*(.+)$/m);
|
||||
if (!exLine) continue;
|
||||
const ex = resolveExerciseByName(config, exLine[1].trim());
|
||||
if (ex && !seen.has(ex.id)) {
|
||||
seen.add(ex.id);
|
||||
result.push({ exerciseId: ex.id, category: ex.category });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
468
src/data/seed.ts
Normal file
468
src/data/seed.ts
Normal file
|
|
@ -0,0 +1,468 @@
|
|||
import { Exercise, Muscle, StatDef, TrainingType, WorkoutConfig, PlanItem, TrainingPlanInstance } from './types';
|
||||
import { buildMappings } from './muscleMapping';
|
||||
|
||||
/*
|
||||
* seed.ts —— 默认配置数据("种子数据")。
|
||||
* 插件首次运行、配置文件不存在时,就用这里的默认训练类型、肌肉、训练项生成初始配置。
|
||||
* 想增加默认动作或肌肉,改这里即可。每个对象用稳定 id 标识,改名不影响历史记录。
|
||||
*/
|
||||
|
||||
// 默认采用「default」档映射,使热力图开箱即用,无需用户手动打开肌肉管理触发引导。
|
||||
const DEFAULT_MUSCLE_MAPPINGS = buildMappings('default');
|
||||
|
||||
// 默认训练类型:力量、有氧、自重三类,各自带要记录的字段。
|
||||
export const DEFAULT_TRAINING_TYPES: TrainingType[] = [
|
||||
{
|
||||
id: 'strength',
|
||||
nameKey: 'type.strength',
|
||||
icon: 'dumbbell',
|
||||
fields: [
|
||||
{ key: 'weight', labelKey: 'field.weight', inputType: 'number', mass: true, required: true }, // 重量(参与 kg/lb 换算)
|
||||
{ key: 'reps', labelKey: 'field.reps', inputType: 'number', unitLabel: '次', required: true }, // 次数
|
||||
],
|
||||
contributesToCoverage: true,
|
||||
},
|
||||
{
|
||||
id: 'aerobic',
|
||||
nameKey: 'type.aerobic',
|
||||
icon: 'wind',
|
||||
fields: [
|
||||
{ key: 'duration_sec', labelKey: 'field.duration_sec', inputType: 'duration', required: true }, // 时长(秒)——跑步/骑行/跳绳等都先记时长
|
||||
{ key: 'distance_km', labelKey: 'field.distance', inputType: 'number', unitLabel: '公里', required: false }, // 距离(跑步/骑行等,跳绳可留空)
|
||||
],
|
||||
contributesToCoverage: false,
|
||||
},
|
||||
{
|
||||
id: 'bodyweight',
|
||||
nameKey: 'type.bodyweight',
|
||||
icon: 'person-standing',
|
||||
fields: [
|
||||
{ key: 'reps', labelKey: 'field.reps', inputType: 'number', unitLabel: '次', required: false }, // 次数(俯卧撑/引体向上等)
|
||||
{ key: 'duration_sec', labelKey: 'field.duration_sec', inputType: 'duration', required: false }, // 时长(平板支撑等支撑/静力类)
|
||||
],
|
||||
contributesToCoverage: true,
|
||||
},
|
||||
];
|
||||
|
||||
// 默认肌肉列表。svgRegionIds 已预填默认映射,使热力图开箱即用;
|
||||
// 用户仍可在「肌肉管理」里重新套用「精简/手动」档覆盖。
|
||||
export const DEFAULT_MUSCLES: Muscle[] = [
|
||||
{ id: 'chest', nameKey: 'muscle.chest', contributesToCoverage: true, restThresholdDays: 7, svgRegionIds: DEFAULT_MUSCLE_MAPPINGS.chest ?? [] },
|
||||
{ id: 'front_delt', nameKey: 'muscle.front_delt', contributesToCoverage: true, restThresholdDays: 7, svgRegionIds: DEFAULT_MUSCLE_MAPPINGS.front_delt ?? [] },
|
||||
{ id: 'biceps', nameKey: 'muscle.biceps', contributesToCoverage: true, restThresholdDays: 7, svgRegionIds: DEFAULT_MUSCLE_MAPPINGS.biceps ?? [] },
|
||||
{ id: 'quads', nameKey: 'muscle.quads', contributesToCoverage: true, restThresholdDays: 7, svgRegionIds: DEFAULT_MUSCLE_MAPPINGS.quads ?? [] },
|
||||
{ id: 'front_calf', nameKey: 'muscle.front_calf', contributesToCoverage: true, restThresholdDays: 7, svgRegionIds: DEFAULT_MUSCLE_MAPPINGS.front_calf ?? [] },
|
||||
{ id: 'abs', nameKey: 'muscle.abs', contributesToCoverage: true, restThresholdDays: 7, svgRegionIds: DEFAULT_MUSCLE_MAPPINGS.abs ?? [] },
|
||||
{ id: 'lats', nameKey: 'muscle.lats', contributesToCoverage: true, restThresholdDays: 7, svgRegionIds: DEFAULT_MUSCLE_MAPPINGS.lats ?? [] },
|
||||
{ id: 'traps', nameKey: 'muscle.traps', contributesToCoverage: true, restThresholdDays: 7, svgRegionIds: DEFAULT_MUSCLE_MAPPINGS.traps ?? [] },
|
||||
{ id: 'rear_delt', nameKey: 'muscle.rear_delt', contributesToCoverage: true, restThresholdDays: 7, svgRegionIds: DEFAULT_MUSCLE_MAPPINGS.rear_delt ?? [] },
|
||||
{ id: 'triceps', nameKey: 'muscle.triceps', contributesToCoverage: true, restThresholdDays: 7, svgRegionIds: DEFAULT_MUSCLE_MAPPINGS.triceps ?? [] },
|
||||
{ id: 'hamstrings', nameKey: 'muscle.hamstrings', contributesToCoverage: true, restThresholdDays: 7, svgRegionIds: DEFAULT_MUSCLE_MAPPINGS.hamstrings ?? [] },
|
||||
{ id: 'glutes', nameKey: 'muscle.glutes', contributesToCoverage: true, restThresholdDays: 7, svgRegionIds: DEFAULT_MUSCLE_MAPPINGS.glutes ?? [] },
|
||||
{ id: 'back_calf', nameKey: 'muscle.back_calf', contributesToCoverage: true, restThresholdDays: 7, svgRegionIds: DEFAULT_MUSCLE_MAPPINGS.back_calf ?? [] },
|
||||
];
|
||||
|
||||
// 默认数据统计:「组数」= 每条记录即一组,count() 即组数,作为热力图默认指标。
|
||||
export const DEFAULT_COUNT_STAT: StatDef = {
|
||||
id: 'count',
|
||||
name: '组数',
|
||||
associatedTypes: ['strength', 'aerobic', 'bodyweight'],
|
||||
formula: { mode: 'builder', builder: { kind: 'count' } },
|
||||
granularity: 'daily',
|
||||
enabled: true,
|
||||
heatmapDefault: true,
|
||||
heatmapScale: [
|
||||
{ color: '#3b82f6', max: 5 },
|
||||
{ color: '#22c55e', max: 10 },
|
||||
{ color: '#f97316', max: 20 },
|
||||
{ color: '#ef4444', max: 40 },
|
||||
],
|
||||
};
|
||||
|
||||
// 力量训练「训练总量」:每日 Σ(次数 × 重量),衡量训练负荷。
|
||||
export const DEFAULT_VOLUME_STAT: StatDef = {
|
||||
id: 'volume',
|
||||
name: '训练总量',
|
||||
associatedTypes: ['strength'],
|
||||
formula: { mode: 'builder', builder: { kind: 'productSum', fieldA: 'reps', fieldB: 'weight' } },
|
||||
granularity: 'daily',
|
||||
enabled: true,
|
||||
heatmapDefault: false,
|
||||
};
|
||||
|
||||
// 力量训练「1RM 估算」(Epley 公式):取最佳组的估算一次最大重量,量化进步。
|
||||
// 公式:weight × (1 + reps/30),对每条记录计算后取 max。
|
||||
// 适用范围: reps ≤ 10 时较准确;高次数组(>12)会高估,属已知局限。
|
||||
export const DEFAULT_1RM_STAT: StatDef = {
|
||||
id: 'oneRepMax',
|
||||
name: '1RM 估算',
|
||||
associatedTypes: ['strength'],
|
||||
formula: { mode: 'builder', builder: { kind: 'oneRepMax', weightField: 'weight', repsField: 'reps' } },
|
||||
granularity: 'daily',
|
||||
enabled: true,
|
||||
heatmapDefault: false,
|
||||
};
|
||||
|
||||
// 默认训练项:每个动作归属某训练类型,并标注练到哪些肌肉、主练还是辅练。
|
||||
export const DEFAULT_EXERCISES: Exercise[] = [
|
||||
{
|
||||
id: 'squat',
|
||||
nameKey: 'exercise.squat',
|
||||
category: 'strength',
|
||||
muscles: [
|
||||
{ muscleId: 'quads', role: 'primary' },
|
||||
{ muscleId: 'glutes', role: 'secondary' },
|
||||
{ muscleId: 'hamstrings', role: 'secondary' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'deadlift',
|
||||
nameKey: 'exercise.deadlift',
|
||||
category: 'strength',
|
||||
muscles: [
|
||||
{ muscleId: 'hamstrings', role: 'primary' },
|
||||
{ muscleId: 'glutes', role: 'primary' },
|
||||
{ muscleId: 'traps', role: 'secondary' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'bench_press',
|
||||
nameKey: 'exercise.bench_press',
|
||||
category: 'strength',
|
||||
muscles: [
|
||||
{ muscleId: 'chest', role: 'primary' },
|
||||
{ muscleId: 'front_delt', role: 'secondary' },
|
||||
{ muscleId: 'triceps', role: 'secondary' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'pushup',
|
||||
nameKey: 'exercise.pushup',
|
||||
category: 'bodyweight',
|
||||
muscles: [
|
||||
{ muscleId: 'chest', role: 'primary' },
|
||||
{ muscleId: 'triceps', role: 'secondary' },
|
||||
{ muscleId: 'front_delt', role: 'secondary' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'pullup',
|
||||
nameKey: 'exercise.pullup',
|
||||
category: 'bodyweight',
|
||||
muscles: [
|
||||
{ muscleId: 'lats', role: 'primary' },
|
||||
{ muscleId: 'biceps', role: 'secondary' },
|
||||
{ muscleId: 'traps', role: 'secondary' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'plank',
|
||||
nameKey: 'exercise.plank',
|
||||
category: 'bodyweight',
|
||||
muscles: [
|
||||
{ muscleId: 'abs', role: 'primary' },
|
||||
{ muscleId: 'quads', role: 'secondary' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'running',
|
||||
nameKey: 'exercise.running',
|
||||
category: 'aerobic',
|
||||
muscles: [
|
||||
{ muscleId: 'quads', role: 'primary' },
|
||||
{ muscleId: 'hamstrings', role: 'secondary' },
|
||||
{ muscleId: 'front_calf', role: 'secondary' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'jumping_rope',
|
||||
nameKey: 'exercise.jumping_rope',
|
||||
category: 'aerobic',
|
||||
muscles: [
|
||||
{ muscleId: 'front_calf', role: 'primary' },
|
||||
{ muscleId: 'abs', role: 'secondary' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'cycling',
|
||||
nameKey: 'exercise.cycling',
|
||||
category: 'aerobic',
|
||||
muscles: [
|
||||
{ muscleId: 'quads', role: 'primary' },
|
||||
{ muscleId: 'hamstrings', role: 'secondary' },
|
||||
],
|
||||
},
|
||||
|
||||
// ===== 新增训练项(2026-07-14 大改:扩充力量训练动作库)=====
|
||||
// 说明:以下动作均属「力量训练」(strength)。已与种子原有动作(深蹲/硬拉/卧推/引体向上/平板支撑/跑步/跳绳/骑行)
|
||||
// 去重;「罗马尼亚硬拉」与表格里的「直腿硬拉」(romanian_deadlift) 视为同一动作,仅保留直腿硬拉。
|
||||
|
||||
// —— 推 / 胸 & 肩 & 肱三头 ——
|
||||
{
|
||||
id: 'dumbbell_bench_press',
|
||||
nameKey: 'exercise.dumbbell_bench_press',
|
||||
category: 'strength',
|
||||
muscles: [
|
||||
{ muscleId: 'chest', role: 'primary' },
|
||||
{ muscleId: 'front_delt', role: 'secondary' },
|
||||
{ muscleId: 'triceps', role: 'secondary' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'incline_dumbbell_press',
|
||||
nameKey: 'exercise.incline_dumbbell_press',
|
||||
category: 'strength',
|
||||
muscles: [
|
||||
{ muscleId: 'chest', role: 'primary' },
|
||||
{ muscleId: 'front_delt', role: 'secondary' },
|
||||
{ muscleId: 'triceps', role: 'secondary' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'close_grip_bench_press',
|
||||
nameKey: 'exercise.close_grip_bench_press',
|
||||
category: 'strength',
|
||||
muscles: [
|
||||
{ muscleId: 'chest', role: 'primary' },
|
||||
{ muscleId: 'triceps', role: 'secondary' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'dip',
|
||||
nameKey: 'exercise.dip',
|
||||
category: 'strength',
|
||||
muscles: [
|
||||
{ muscleId: 'chest', role: 'primary' },
|
||||
{ muscleId: 'triceps', role: 'secondary' },
|
||||
{ muscleId: 'front_delt', role: 'secondary' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'dumbbell_press',
|
||||
nameKey: 'exercise.dumbbell_press',
|
||||
category: 'strength',
|
||||
muscles: [
|
||||
{ muscleId: 'front_delt', role: 'primary' },
|
||||
{ muscleId: 'triceps', role: 'secondary' },
|
||||
{ muscleId: 'traps', role: 'secondary' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'dumbbell_lateral_raise',
|
||||
nameKey: 'exercise.dumbbell_lateral_raise',
|
||||
category: 'strength',
|
||||
muscles: [
|
||||
{ muscleId: 'front_delt', role: 'primary' },
|
||||
{ muscleId: 'traps', role: 'secondary' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'barbell_front_raise',
|
||||
nameKey: 'exercise.barbell_front_raise',
|
||||
category: 'strength',
|
||||
muscles: [
|
||||
{ muscleId: 'front_delt', role: 'primary' },
|
||||
{ muscleId: 'traps', role: 'secondary' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'barbell_push_press',
|
||||
nameKey: 'exercise.barbell_push_press',
|
||||
category: 'strength',
|
||||
muscles: [
|
||||
{ muscleId: 'front_delt', role: 'primary' },
|
||||
{ muscleId: 'triceps', role: 'secondary' },
|
||||
{ muscleId: 'traps', role: 'secondary' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'barbell_lying_triceps',
|
||||
nameKey: 'exercise.barbell_lying_triceps',
|
||||
category: 'strength',
|
||||
muscles: [
|
||||
{ muscleId: 'triceps', role: 'primary' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'dumbbell_overhead_triceps',
|
||||
nameKey: 'exercise.dumbbell_overhead_triceps',
|
||||
category: 'strength',
|
||||
muscles: [
|
||||
{ muscleId: 'triceps', role: 'primary' },
|
||||
],
|
||||
},
|
||||
|
||||
// —— 拉 / 背 & 肱二头 ——
|
||||
{
|
||||
id: 'barbell_row',
|
||||
nameKey: 'exercise.barbell_row',
|
||||
category: 'strength',
|
||||
muscles: [
|
||||
{ muscleId: 'lats', role: 'primary' },
|
||||
{ muscleId: 'traps', role: 'secondary' },
|
||||
{ muscleId: 'biceps', role: 'secondary' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'dumbbell_row',
|
||||
nameKey: 'exercise.dumbbell_row',
|
||||
category: 'strength',
|
||||
muscles: [
|
||||
{ muscleId: 'lats', role: 'primary' },
|
||||
{ muscleId: 'traps', role: 'secondary' },
|
||||
{ muscleId: 'biceps', role: 'secondary' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'dumbbell_reverse_fly',
|
||||
nameKey: 'exercise.dumbbell_reverse_fly',
|
||||
category: 'strength',
|
||||
muscles: [
|
||||
{ muscleId: 'rear_delt', role: 'primary' },
|
||||
{ muscleId: 'traps', role: 'secondary' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'cable_face_pull',
|
||||
nameKey: 'exercise.cable_face_pull',
|
||||
category: 'strength',
|
||||
muscles: [
|
||||
{ muscleId: 'rear_delt', role: 'primary' },
|
||||
{ muscleId: 'traps', role: 'secondary' },
|
||||
{ muscleId: 'front_delt', role: 'secondary' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'barbell_upright_row',
|
||||
nameKey: 'exercise.barbell_upright_row',
|
||||
category: 'strength',
|
||||
muscles: [
|
||||
{ muscleId: 'traps', role: 'primary' },
|
||||
{ muscleId: 'front_delt', role: 'secondary' },
|
||||
{ muscleId: 'biceps', role: 'secondary' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'barbell_curl',
|
||||
nameKey: 'exercise.barbell_curl',
|
||||
category: 'strength',
|
||||
muscles: [
|
||||
{ muscleId: 'biceps', role: 'primary' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'dumbbell_curl',
|
||||
nameKey: 'exercise.dumbbell_curl',
|
||||
category: 'strength',
|
||||
muscles: [
|
||||
{ muscleId: 'biceps', role: 'primary' },
|
||||
],
|
||||
},
|
||||
|
||||
// —— 腿 & 核心 ——
|
||||
{
|
||||
id: 'lunge',
|
||||
nameKey: 'exercise.lunge',
|
||||
category: 'strength',
|
||||
muscles: [
|
||||
{ muscleId: 'quads', role: 'primary' },
|
||||
{ muscleId: 'glutes', role: 'secondary' },
|
||||
{ muscleId: 'hamstrings', role: 'secondary' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'romanian_deadlift',
|
||||
nameKey: 'exercise.romanian_deadlift',
|
||||
category: 'strength',
|
||||
muscles: [
|
||||
{ muscleId: 'hamstrings', role: 'primary' },
|
||||
{ muscleId: 'glutes', role: 'primary' },
|
||||
{ muscleId: 'traps', role: 'secondary' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'hanging_leg_raise',
|
||||
nameKey: 'exercise.hanging_leg_raise',
|
||||
category: 'strength',
|
||||
muscles: [
|
||||
{ muscleId: 'abs', role: 'primary' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ab_crunch',
|
||||
nameKey: 'exercise.ab_crunch',
|
||||
category: 'strength',
|
||||
muscles: [
|
||||
{ muscleId: 'abs', role: 'primary' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// 计划项构造辅助:每个动作默认带 1 个空预设组(用户训练时再填重量/次数)。
|
||||
function pItem(exerciseId: string, category: string, enabled = true): PlanItem {
|
||||
return {
|
||||
exerciseId,
|
||||
category,
|
||||
enabled,
|
||||
sets: [{ id: 's1', fields: {} }],
|
||||
};
|
||||
}
|
||||
|
||||
// 默认训练计划:三分化(背 / 胸 / 腿)。时间规则为经典的一周三练排期(可在设置里改)。
|
||||
export const DEFAULT_PLANS: TrainingPlanInstance[] = [
|
||||
{
|
||||
id: '374890bb-4fd8-449b-8b1e-60dd652eed32',
|
||||
name: '三分化-背',
|
||||
timeRule: { type: 'weekday', weekdays: [1, 4] }, // 周一、周四
|
||||
createdAt: '2026-07-14',
|
||||
items: [
|
||||
pItem('barbell_row', 'strength'),
|
||||
pItem('dumbbell_row', 'strength'),
|
||||
pItem('pullup', 'bodyweight'),
|
||||
pItem('dumbbell_reverse_fly', 'strength'),
|
||||
pItem('cable_face_pull', 'strength'),
|
||||
pItem('barbell_curl', 'strength'),
|
||||
pItem('dumbbell_curl', 'strength'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '1200e157-ae12-4394-b523-bc62218c15df',
|
||||
name: '三分化-胸',
|
||||
timeRule: { type: 'weekday', weekdays: [2, 5] }, // 周二、周五
|
||||
createdAt: '2026-07-14',
|
||||
items: [
|
||||
pItem('dumbbell_bench_press', 'strength'),
|
||||
pItem('incline_dumbbell_press', 'strength'),
|
||||
pItem('dumbbell_press', 'strength'),
|
||||
pItem('dumbbell_lateral_raise', 'strength'),
|
||||
pItem('dip', 'strength', false), // 可选
|
||||
pItem('close_grip_bench_press', 'strength', false), // 可选
|
||||
pItem('dumbbell_overhead_triceps', 'strength', false), // 可选
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '44db2031-cba7-414b-83f5-72dcc62d90fd',
|
||||
name: '三分化-腿',
|
||||
timeRule: { type: 'weekday', weekdays: [3, 6] }, // 周三、周六
|
||||
createdAt: '2026-07-14',
|
||||
items: [
|
||||
pItem('squat', 'strength'),
|
||||
pItem('lunge', 'strength'),
|
||||
pItem('romanian_deadlift', 'strength'),
|
||||
pItem('ab_crunch', 'strength'),
|
||||
pItem('hanging_leg_raise', 'strength'),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// 汇总成完整默认配置(version = 1,表示第一版结构)
|
||||
export function getDefaultConfig(): WorkoutConfig {
|
||||
return {
|
||||
version: 1,
|
||||
trainingTypes: DEFAULT_TRAINING_TYPES,
|
||||
exercises: DEFAULT_EXERCISES,
|
||||
muscles: DEFAULT_MUSCLES,
|
||||
statistics: [DEFAULT_COUNT_STAT, DEFAULT_VOLUME_STAT, DEFAULT_1RM_STAT],
|
||||
plans: DEFAULT_PLANS,
|
||||
};
|
||||
}
|
||||
156
src/data/statExpr.test.ts
Normal file
156
src/data/statExpr.test.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
computeStat,
|
||||
validateExpression,
|
||||
builderToExpr,
|
||||
exprToBuilder,
|
||||
allowedStatFields,
|
||||
} from './statExpr';
|
||||
import { StatDef, WorkoutConfig, LogRow } from './types';
|
||||
|
||||
// 构造一条最小 LogRow(computeStat 只用到 fields)
|
||||
function log(fields: Record<string, unknown>): LogRow {
|
||||
return {
|
||||
id: 'log-id',
|
||||
timestamp: '2026-07-10 18:00',
|
||||
exerciseId: 'squat',
|
||||
category: 'strength',
|
||||
fields,
|
||||
};
|
||||
}
|
||||
|
||||
const stat = (over: Partial<StatDef>): StatDef => ({
|
||||
id: 'st1',
|
||||
name: '测试统计',
|
||||
associatedTypes: ['strength'],
|
||||
formula: { mode: 'expression', expression: 'sum(reps * weight)' },
|
||||
granularity: 'daily',
|
||||
enabled: true,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('computeStat 计算', () => {
|
||||
const records = [log({ reps: 1, weight: 10 }), log({ reps: 3, weight: 20 }), log({ reps: 5, weight: 30 })];
|
||||
|
||||
it('sum(reps * weight) = 训练量', () => {
|
||||
const s = stat({ formula: { mode: 'expression', expression: 'sum(reps * weight)' } });
|
||||
expect(computeStat(s, records)).toBe(1 * 10 + 3 * 20 + 5 * 30);
|
||||
});
|
||||
|
||||
it('count() = 记录条数', () => {
|
||||
const s = stat({ formula: { mode: 'expression', expression: 'count()' } });
|
||||
expect(computeStat(s, records)).toBe(3);
|
||||
});
|
||||
|
||||
it('avg(weight) = 平均值', () => {
|
||||
const s = stat({ formula: { mode: 'expression', expression: 'avg(weight)' } });
|
||||
expect(computeStat(s, records)).toBe((10 + 20 + 30) / 3);
|
||||
});
|
||||
|
||||
it('max/min(weight) 取极值', () => {
|
||||
expect(computeStat(stat({ formula: { mode: 'expression', expression: 'max(weight)' } }), records)).toBe(30);
|
||||
expect(computeStat(stat({ formula: { mode: 'expression', expression: 'min(weight)' } }), records)).toBe(10);
|
||||
});
|
||||
|
||||
it('builder 模式的乘积求和等价 sum(a*b)', () => {
|
||||
const s = stat({
|
||||
formula: { mode: 'builder', builder: { kind: 'productSum', fieldA: 'reps', fieldB: 'weight' } },
|
||||
});
|
||||
expect(computeStat(s, records)).toBe(1 * 10 + 3 * 20 + 5 * 30);
|
||||
});
|
||||
|
||||
it('时长字段按秒数计算(保持可读与计算不冲突)', () => {
|
||||
const durRecords = [log({ duration_sec: 90 }), log({ duration_sec: 30 })];
|
||||
const s = stat({ formula: { mode: 'expression', expression: 'sum(duration_sec)' } });
|
||||
expect(computeStat(s, durRecords)).toBe(120);
|
||||
});
|
||||
|
||||
it('缺失字段视为 0(不崩)', () => {
|
||||
const partial = [log({ reps: 2, weight: 10 }), log({ reps: 4 })];
|
||||
const s = stat({ formula: { mode: 'expression', expression: 'sum(reps * weight)' } });
|
||||
expect(computeStat(s, partial)).toBe(2 * 10 + 4 * 0);
|
||||
});
|
||||
|
||||
it('非法表达式返回 NaN(渲染时兜底)', () => {
|
||||
const s = stat({ formula: { mode: 'expression', expression: 'sum((' } });
|
||||
expect(Number.isNaN(computeStat(s, records))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateExpression 校验', () => {
|
||||
it('合法表达式通过', () => {
|
||||
expect(() => validateExpression('sum(reps * weight)', ['reps', 'weight'])).not.toThrow();
|
||||
expect(() => validateExpression('count()', [])).not.toThrow();
|
||||
// 四则运算写在聚合函数内部(针对单条记录)即合法
|
||||
expect(() => validateExpression('sum(reps / 2)', ['reps'])).not.toThrow();
|
||||
});
|
||||
|
||||
it('未授权字段报错', () => {
|
||||
expect(() => validateExpression('sum(reps)', ['weight'])).toThrow(/不在可用字段范围/);
|
||||
});
|
||||
|
||||
it('未知函数报错', () => {
|
||||
expect(() => validateExpression('median(reps)', ['reps'])).toThrow(/未知函数/);
|
||||
});
|
||||
|
||||
it('聚合函数嵌套报错', () => {
|
||||
expect(() => validateExpression('sum(sum(reps))', ['reps'])).toThrow(/不能嵌套/);
|
||||
});
|
||||
|
||||
it('非聚合根节点报错', () => {
|
||||
expect(() => validateExpression('reps * weight', ['reps', 'weight'])).toThrow(/聚合函数/);
|
||||
});
|
||||
|
||||
it('count 带参数报错', () => {
|
||||
expect(() => validateExpression('count(reps)', ['reps'])).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('builderToExpr / exprToBuilder 双向转换', () => {
|
||||
it('builder → expr', () => {
|
||||
expect(builderToExpr({ kind: 'sum', field: 'reps' })).toBe('sum(reps)');
|
||||
expect(builderToExpr({ kind: 'productSum', fieldA: 'reps', fieldB: 'weight' })).toBe('sum(reps * weight)');
|
||||
expect(builderToExpr({ kind: 'count' })).toBe('count()');
|
||||
expect(builderToExpr({ kind: 'avg', field: 'weight' })).toBe('avg(weight)');
|
||||
});
|
||||
|
||||
it('expr → builder 匹配四种形状', () => {
|
||||
expect(exprToBuilder('sum(reps)')).toEqual({ kind: 'sum', field: 'reps' });
|
||||
expect(exprToBuilder('sum(reps * weight)')).toEqual({ kind: 'productSum', fieldA: 'reps', fieldB: 'weight' });
|
||||
expect(exprToBuilder('avg(weight)')).toEqual({ kind: 'avg', field: 'weight' });
|
||||
expect(exprToBuilder('count()')).toEqual({ kind: 'count' });
|
||||
});
|
||||
|
||||
it('复杂嵌套表达式无法降维时返回 null', () => {
|
||||
expect(exprToBuilder('sum(reps) / count()')).toBeNull();
|
||||
expect(exprToBuilder('reps * weight')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('allowedStatFields 字段交集', () => {
|
||||
const config: WorkoutConfig = {
|
||||
version: 1,
|
||||
trainingTypes: [
|
||||
{ id: 'strength', fields: [{ key: 'reps' }, { key: 'weight' }, { key: 'duration_sec' }] } as any,
|
||||
{ id: 'cardio', fields: [{ key: 'distance' }, { key: 'duration_sec' }, { key: 'pace' }] } as any,
|
||||
],
|
||||
exercises: [],
|
||||
muscles: [],
|
||||
statistics: [],
|
||||
};
|
||||
|
||||
it('单类型返回其全部字段', () => {
|
||||
const s = stat({ associatedTypes: ['strength'] });
|
||||
expect(allowedStatFields(s, config).sort()).toEqual(['duration_sec', 'reps', 'weight']);
|
||||
});
|
||||
|
||||
it('多类型取字段交集(如「时长」)', () => {
|
||||
const s = stat({ associatedTypes: ['strength', 'cardio'] });
|
||||
expect(allowedStatFields(s, config)).toEqual(['duration_sec']);
|
||||
});
|
||||
|
||||
it('无交集类型返回空', () => {
|
||||
const s = stat({ associatedTypes: [] });
|
||||
expect(allowedStatFields(s, config)).toEqual([]);
|
||||
});
|
||||
});
|
||||
344
src/data/statExpr.ts
Normal file
344
src/data/statExpr.ts
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
import { LogRow, WorkoutConfig, StatDef, StatAggregation } from './types';
|
||||
|
||||
/*
|
||||
* statExpr.ts —— 数据统计功能的「受限表达式引擎」。
|
||||
*
|
||||
* 安全底线:彻底禁用 eval / new Function,手写「词法 + 递归下降语法分析」,
|
||||
* 把表达式解析成 AST 后解释执行。函数白名单仅 sum/avg/max/min/count;
|
||||
* 操作数只能是已授权字段(key),支持 + - * / 四则运算与括号嵌套。
|
||||
*
|
||||
* 语义约定:一条统计(stat)在最外层必须是一个「聚合函数调用」,
|
||||
* 其参数为「针对单条记录」的表达式(字段引用的四则组合)。
|
||||
* 计算时:对每条记录求参数值,再由聚合函数跨记录汇总。
|
||||
* 例:sum(reps * weight) = 对每条记录算 次数×重量 后求和 = 训练量。
|
||||
*
|
||||
* 时长字段(duration):底层存的是秒数(number),表达式按字段 key 引用,
|
||||
* 引擎直接对原始数值(秒)运算;可读性(如「1分30秒」)由展示层负责,不参与计算。
|
||||
*/
|
||||
|
||||
// ===== AST 节点 =====
|
||||
type Node =
|
||||
| { type: 'num'; value: number }
|
||||
| { type: 'field'; name: string }
|
||||
| { type: 'binop'; op: '+' | '-' | '*' | '/'; left: Node; right: Node }
|
||||
| { type: 'func'; name: string; args: Node[] };
|
||||
|
||||
const FUNCS = new Set(['sum', 'avg', 'max', 'min', 'count']);
|
||||
|
||||
// ===== 词法分析(Tokenizer)=====
|
||||
type Tok =
|
||||
| { t: 'num'; v: number }
|
||||
| { t: 'ident'; v: string }
|
||||
| { t: 'op'; v: '+' | '-' | '*' | '/' }
|
||||
| { t: 'lparen' }
|
||||
| { t: 'rparen' }
|
||||
| { t: 'comma' }
|
||||
| { t: 'eof' };
|
||||
|
||||
function tokenize(input: string): Tok[] {
|
||||
const toks: Tok[] = [];
|
||||
const s = input;
|
||||
let i = 0;
|
||||
while (i < s.length) {
|
||||
const c = s[i];
|
||||
// 跳过空白
|
||||
if (c === ' ' || c === '\t' || c === '\n' || c === '\r') { i++; continue; }
|
||||
// 数字(十进制,允许一个小数点)
|
||||
if (c >= '0' && c <= '9') {
|
||||
let j = i;
|
||||
while (j < s.length && /[0-9.]/.test(s[j])) j++;
|
||||
const num = Number(s.slice(i, j));
|
||||
if (Number.isNaN(num)) throw new Error('非法数字');
|
||||
toks.push({ t: 'num', v: num });
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
// 标识符(字段 key / 函数名):字母或下划线开头,后续可含字母数字下划线
|
||||
if (/[a-zA-Z_]/.test(c)) {
|
||||
let j = i;
|
||||
while (j < s.length && /[a-zA-Z0-9_]/.test(s[j])) j++;
|
||||
toks.push({ t: 'ident', v: s.slice(i, j) });
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
// 运算符 / 括号 / 逗号
|
||||
if (c === '+' || c === '-' || c === '*' || c === '/') { toks.push({ t: 'op', v: c }); i++; continue; }
|
||||
if (c === '(') { toks.push({ t: 'lparen' }); i++; continue; }
|
||||
if (c === ')') { toks.push({ t: 'rparen' }); i++; continue; }
|
||||
if (c === ',') { toks.push({ t: 'comma' }); i++; continue; }
|
||||
throw new Error(`非法字符:${c}`);
|
||||
}
|
||||
toks.push({ t: 'eof' });
|
||||
return toks;
|
||||
}
|
||||
|
||||
// ===== 递归下降语法分析(Parser)=====
|
||||
class Parser {
|
||||
private toks: Tok[];
|
||||
private pos = 0;
|
||||
constructor(toks: Tok[]) { this.toks = toks; }
|
||||
private peek(): Tok { return this.toks[this.pos]; }
|
||||
private next(): Tok { return this.toks[this.pos++]; }
|
||||
private expectRparen(): void {
|
||||
if (this.next().t !== 'rparen') throw new Error('缺少右括号 ")"');
|
||||
}
|
||||
|
||||
parse(): Node {
|
||||
const node = this.parseExpr();
|
||||
if (this.peek().t !== 'eof') throw new Error('表达式存在多余内容');
|
||||
return node;
|
||||
}
|
||||
|
||||
private parseExpr(): Node {
|
||||
let left = this.parseTerm();
|
||||
let tk = this.peek();
|
||||
while (tk.t === 'op' && (tk.v === '+' || tk.v === '-')) {
|
||||
const op = (this.next() as Extract<Tok, { t: 'op' }>).v;
|
||||
left = { type: 'binop', op, left, right: this.parseTerm() };
|
||||
tk = this.peek();
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
private parseTerm(): Node {
|
||||
let left = this.parseFactor();
|
||||
let tk = this.peek();
|
||||
while (tk.t === 'op' && (tk.v === '*' || tk.v === '/')) {
|
||||
const op = (this.next() as Extract<Tok, { t: 'op' }>).v;
|
||||
left = { type: 'binop', op, left, right: this.parseFactor() };
|
||||
tk = this.peek();
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
private parseFactor(): Node {
|
||||
const t = this.peek();
|
||||
if (t.t === 'num') { this.next(); return { type: 'num', value: t.v }; }
|
||||
if (t.t === 'lparen') {
|
||||
this.next();
|
||||
const e = this.parseExpr();
|
||||
this.expectRparen();
|
||||
return e;
|
||||
}
|
||||
if (t.t === 'ident') {
|
||||
// 标识符后紧跟 '(' 视为函数调用,否则视为字段引用
|
||||
const ahead = this.toks[this.pos + 1];
|
||||
if (ahead && ahead.t === 'lparen') return this.parseFunc();
|
||||
this.next();
|
||||
return { type: 'field', name: t.v };
|
||||
}
|
||||
throw new Error('表达式不完整或存在非法符号');
|
||||
}
|
||||
|
||||
private parseFunc(): Node {
|
||||
const nameTok = this.next();
|
||||
if (nameTok.t !== 'ident') throw new Error('函数名非法');
|
||||
const name = nameTok.v;
|
||||
if (!FUNCS.has(name)) throw new Error(`未知函数:${name}(仅支持 sum/avg/max/min/count)`);
|
||||
if (this.next().t !== 'lparen') throw new Error(`函数 ${name} 缺少左括号`);
|
||||
const args: Node[] = [];
|
||||
if (this.peek().t !== 'rparen') {
|
||||
args.push(this.parseExpr());
|
||||
while (this.peek().t === 'comma') {
|
||||
this.next();
|
||||
args.push(this.parseExpr());
|
||||
}
|
||||
}
|
||||
this.expectRparen();
|
||||
return { type: 'func', name, args };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 结构校验(最外层必须是单聚合函数,且不能嵌套)=====
|
||||
function validateStructure(ast: Node): void {
|
||||
if (ast.type !== 'func') {
|
||||
throw new Error('表达式必须是一个聚合函数调用(如 sum(...))');
|
||||
}
|
||||
const f = ast as Extract<Node, { type: 'func' }>;
|
||||
if (f.name === 'count') {
|
||||
if (f.args.length !== 0) throw new Error('count() 不接受参数');
|
||||
} else {
|
||||
if (f.args.length !== 1) throw new Error(`${f.name}() 需要 1 个参数`);
|
||||
if (f.args[0].type === 'func') throw new Error('聚合函数不能嵌套');
|
||||
}
|
||||
}
|
||||
|
||||
// 收集表达式中引用的字段名(递归遍历,顺带拒绝嵌套函数)
|
||||
function collectFields(node: Node, out: Set<string>): void {
|
||||
switch (node.type) {
|
||||
case 'field':
|
||||
out.add(node.name);
|
||||
break;
|
||||
case 'num':
|
||||
break;
|
||||
case 'binop':
|
||||
collectFields(node.left, out);
|
||||
collectFields(node.right, out);
|
||||
break;
|
||||
case 'func':
|
||||
for (const a of node.args) {
|
||||
if (a.type === 'func') throw new Error('聚合函数不能嵌套');
|
||||
collectFields(a, out);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 对外:校验表达式 =====
|
||||
export function validateExpression(expr: string, allowedFields: string[]): void {
|
||||
const ast = new Parser(tokenize(expr)).parse();
|
||||
validateStructure(ast);
|
||||
const fields = new Set<string>();
|
||||
collectFields(ast, fields);
|
||||
const allowed = new Set(allowedFields);
|
||||
for (const f of fields) {
|
||||
if (!allowed.has(f)) {
|
||||
throw new Error(`字段 "${f}" 不在可用字段范围内(请检查关联的训练类型)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 对外:计算某分组的统计值 =====
|
||||
// 对每条记录求「聚合函数参数」的值,再由聚合函数跨记录汇总。
|
||||
function evalNode(node: Node, record: LogRow): number {
|
||||
switch (node.type) {
|
||||
case 'num':
|
||||
return node.value;
|
||||
case 'field': {
|
||||
const raw = record.fields[node.name];
|
||||
const n = typeof raw === 'number' ? raw : Number(raw);
|
||||
return Number.isNaN(n) ? 0 : n; // 缺失/非法字段视为 0
|
||||
}
|
||||
case 'binop': {
|
||||
const l = evalNode(node.left, record);
|
||||
const r = evalNode(node.right, record);
|
||||
switch (node.op) {
|
||||
case '+': return l + r;
|
||||
case '-': return l - r;
|
||||
case '*': return l * r;
|
||||
case '/': return r === 0 ? 0 : l / r; // 除零保护
|
||||
}
|
||||
}
|
||||
case 'func':
|
||||
throw new Error('聚合函数不能嵌套'); // 理论上不会到达
|
||||
}
|
||||
throw new Error('表达式无效');
|
||||
}
|
||||
|
||||
export function computeStat(stat: StatDef, records: LogRow[]): number {
|
||||
// 解析表达式:builder 模式先转成表达式字符串
|
||||
const expr = stat.formula.mode === 'builder'
|
||||
? builderToExpr(stat.formula.builder)
|
||||
: (stat.formula.expression ?? '');
|
||||
|
||||
let ast: Node;
|
||||
try {
|
||||
ast = new Parser(tokenize(expr)).parse();
|
||||
} catch {
|
||||
return NaN; // 表达式非法:渲染时兜底,不崩
|
||||
}
|
||||
if (ast.type !== 'func') return NaN;
|
||||
const f = ast as Extract<Node, { type: 'func' }>;
|
||||
|
||||
// count:记录条数
|
||||
if (f.name === 'count') {
|
||||
return records.length;
|
||||
}
|
||||
|
||||
// 其余:先对每条记录求值,再聚合
|
||||
const values: number[] = [];
|
||||
for (const rec of records) {
|
||||
try {
|
||||
values.push(evalNode(f.args[0], rec));
|
||||
} catch {
|
||||
values.push(0);
|
||||
}
|
||||
}
|
||||
|
||||
let result: number;
|
||||
switch (f.name) {
|
||||
case 'sum':
|
||||
result = values.reduce((a, b) => a + b, 0);
|
||||
break;
|
||||
case 'avg':
|
||||
result = values.length ? values.reduce((a, b) => a + b, 0) / values.length : 0;
|
||||
break;
|
||||
case 'max':
|
||||
result = values.length ? Math.max(...values) : 0;
|
||||
break;
|
||||
case 'min':
|
||||
result = values.length ? Math.min(...values) : 0;
|
||||
break;
|
||||
default:
|
||||
result = NaN;
|
||||
}
|
||||
if (!Number.isFinite(result)) return 0;
|
||||
// 防浮点噪声
|
||||
return Math.round(result * 100) / 100;
|
||||
}
|
||||
|
||||
// 把统计值规整成展示字符串(纯数字,不附加任何单位);非法值显示 "-"。
|
||||
export function formatStatValue(n: number): string {
|
||||
if (Number.isNaN(n)) return '-';
|
||||
return String(Math.round(n * 100) / 100);
|
||||
}
|
||||
|
||||
// ===== 引导式 ↔ 表达式 双向转换 =====
|
||||
export function builderToExpr(b?: StatAggregation): string {
|
||||
if (!b) return '';
|
||||
switch (b.kind) {
|
||||
case 'sum': return `sum(${b.field})`;
|
||||
case 'productSum': return `sum(${b.fieldA} * ${b.fieldB})`;
|
||||
case 'oneRepMax': return `max(${b.weightField} * (1 + ${b.repsField} / 30))`;
|
||||
case 'count': return 'count()';
|
||||
case 'avg': return `avg(${b.field})`;
|
||||
case 'max': return `max(${b.field})`;
|
||||
case 'min': return `min(${b.field})`;
|
||||
}
|
||||
}
|
||||
|
||||
// 表达式 → 引导式(尽力而为):匹配四种形状则回填 builder;否则返回 null(保留 expression 模式)。
|
||||
export function exprToBuilder(expr: string): StatAggregation | null {
|
||||
let ast: Node;
|
||||
try {
|
||||
ast = new Parser(tokenize(expr)).parse();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (ast.type !== 'func') return null;
|
||||
const f = ast as Extract<Node, { type: 'func' }>;
|
||||
|
||||
if (f.name === 'count' && f.args.length === 0) return { kind: 'count' };
|
||||
if (f.args.length !== 1) return null;
|
||||
const arg = f.args[0];
|
||||
|
||||
// sum(field) / avg|max|min(field)
|
||||
if (arg.type === 'field') {
|
||||
if (f.name === 'sum') return { kind: 'sum', field: arg.name };
|
||||
if (f.name === 'avg' || f.name === 'max' || f.name === 'min') {
|
||||
return { kind: f.name, field: arg.name };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// sum(fieldA * fieldB) → productSum
|
||||
if (f.name === 'sum' && arg.type === 'binop' && arg.op === '*' &&
|
||||
arg.left.type === 'field' && arg.right.type === 'field') {
|
||||
return { kind: 'productSum', fieldA: arg.left.name, fieldB: arg.right.name };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ===== 关联类型的字段「交集」=====
|
||||
// 编辑公式时,可选字段 = 所有 associatedTypes 对应训练类型的 fields 之交集。
|
||||
// 保证公式引用的字段在每个关联类型里都有效(多类型共享字段,如「时长」)。
|
||||
export function allowedStatFields(stat: StatDef, config: WorkoutConfig): string[] {
|
||||
const typeFields = stat.associatedTypes
|
||||
.map((id) => config.trainingTypes.find((t) => t.id === id)?.fields ?? [])
|
||||
.filter((f) => f.length > 0);
|
||||
if (typeFields.length === 0) return [];
|
||||
// 以第一个类型的字段为基准,保留「在每个其他类型里都存在」的 key
|
||||
return typeFields[0]
|
||||
.filter((f) => typeFields.slice(1).every((fields) => fields.some((x) => x.key === f.key)))
|
||||
.map((f) => f.key);
|
||||
}
|
||||
73
src/data/svgMuscleCatalog.test.ts
Normal file
73
src/data/svgMuscleCatalog.test.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { formatSvgMuscleLabel, SvgMuscleEntry } from './svgMuscleCatalog';
|
||||
|
||||
/*
|
||||
* svgMuscleCatalog 单元测试
|
||||
* 关键概念:
|
||||
* - formatSvgMuscleLabel: 把目录中的 SVG 肌肉条目格式化为 UI 显示标签。
|
||||
* 同一块肌肉可能被 SVG 拆成多条 path(external_oblique_1_l..8_l、
|
||||
* rectus_abdominis_1..4_r),函数需从 id 提取段号并追加左右标识,
|
||||
* 避免编辑肌肉弹窗里出现大量重名复选框。
|
||||
*/
|
||||
|
||||
describe('formatSvgMuscleLabel', () => {
|
||||
it('distinguishes segmented abs paths in Chinese', () => {
|
||||
const oblique: SvgMuscleEntry = {
|
||||
id: 'external_oblique_1_l',
|
||||
side: 'front',
|
||||
fitnessGroup: 'abs',
|
||||
zh: '腹外斜肌',
|
||||
en: 'External Oblique',
|
||||
};
|
||||
expect(formatSvgMuscleLabel(oblique, 'zh')).toBe('腹外斜肌 1(左)');
|
||||
|
||||
const obliqueRight: SvgMuscleEntry = {
|
||||
id: 'external_oblique_8_r',
|
||||
side: 'front',
|
||||
fitnessGroup: 'abs',
|
||||
zh: '腹外斜肌',
|
||||
en: 'External Oblique',
|
||||
};
|
||||
expect(formatSvgMuscleLabel(obliqueRight, 'zh')).toBe('腹外斜肌 8(右)');
|
||||
|
||||
const rectusCenter: SvgMuscleEntry = {
|
||||
id: 'rectus_abdominis_1',
|
||||
side: 'front',
|
||||
fitnessGroup: 'abs',
|
||||
zh: '腹直肌',
|
||||
en: 'Rectus Abdominis',
|
||||
};
|
||||
expect(formatSvgMuscleLabel(rectusCenter, 'zh')).toBe('腹直肌 1');
|
||||
|
||||
const rectusLeft: SvgMuscleEntry = {
|
||||
id: 'rectus_abdominis_2_l',
|
||||
side: 'front',
|
||||
fitnessGroup: 'abs',
|
||||
zh: '腹直肌',
|
||||
en: 'Rectus Abdominis',
|
||||
};
|
||||
expect(formatSvgMuscleLabel(rectusLeft, 'zh')).toBe('腹直肌 2(左)');
|
||||
});
|
||||
|
||||
it('keeps non-segmented muscle labels concise in Chinese', () => {
|
||||
const chest: SvgMuscleEntry = {
|
||||
id: 'pectoralis_major_l',
|
||||
side: 'front',
|
||||
fitnessGroup: 'chest',
|
||||
zh: '胸大肌',
|
||||
en: 'Pectoralis Major',
|
||||
};
|
||||
expect(formatSvgMuscleLabel(chest, 'zh')).toBe('胸大肌(左)');
|
||||
});
|
||||
|
||||
it('distinguishes segmented paths in English', () => {
|
||||
const oblique: SvgMuscleEntry = {
|
||||
id: 'external_oblique_1_l',
|
||||
side: 'front',
|
||||
fitnessGroup: 'abs',
|
||||
zh: '腹外斜肌',
|
||||
en: 'External Oblique',
|
||||
};
|
||||
expect(formatSvgMuscleLabel(oblique, 'en')).toBe('External Oblique 1 (L)');
|
||||
});
|
||||
});
|
||||
202
src/data/svgMuscleCatalog.ts
Normal file
202
src/data/svgMuscleCatalog.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
/*
|
||||
* svgMuscleCatalog.ts —— SVG 肌肉路径目录(自动生成)
|
||||
* 来源:muscle_analysis/muscle_layer_front.svg + muscle_layer_back.svg
|
||||
* 共 143 条路径,14 个健身群,中英双语。
|
||||
*/
|
||||
|
||||
export interface SvgMuscleEntry {
|
||||
id: string;
|
||||
side: 'front' | 'back';
|
||||
fitnessGroup: string;
|
||||
zh: string;
|
||||
en: string;
|
||||
}
|
||||
|
||||
export const SVG_CATALOG_VERSION = 'flutter-body-atlas@main-2026-07-12';
|
||||
|
||||
export const FITNESS_GROUPS: { key: string; zh: string; en: string }[] = [
|
||||
{ key: 'chest', zh: '胸部', en: 'Chest' },
|
||||
{ key: 'back', zh: '背部', en: 'Back' },
|
||||
{ key: 'traps', zh: '斜方肌', en: 'Traps' },
|
||||
{ key: 'shoulders', zh: '肩部(三角肌)', en: 'Shoulders (Delts)' },
|
||||
{ key: 'biceps', zh: '肱二头肌', en: 'Biceps' },
|
||||
{ key: 'triceps', zh: '肱三头肌', en: 'Triceps' },
|
||||
{ key: 'forearms', zh: '前臂', en: 'Forearms' },
|
||||
{ key: 'abs', zh: '腹肌/核心', en: 'Abs / Core' },
|
||||
{ key: 'glutes', zh: '臀部', en: 'Glutes' },
|
||||
{ key: 'quads', zh: '股四头肌', en: 'Quads' },
|
||||
{ key: 'hamstrings', zh: '腘绳肌', en: 'Hamstrings' },
|
||||
{ key: 'adductors', zh: '内收肌', en: 'Adductors' },
|
||||
{ key: 'calves', zh: '小腿', en: 'Calves' },
|
||||
{ key: 'neck', zh: '颈部', en: 'Neck' },
|
||||
];
|
||||
|
||||
export const SVG_MUSCLE_CATALOG: SvgMuscleEntry[] = [
|
||||
{ id: 'external_oblique_1_l', side: 'back', fitnessGroup: 'abs', zh: '腹外斜肌', en: 'External Oblique' },
|
||||
{ id: 'external_oblique_1_r', side: 'back', fitnessGroup: 'abs', zh: '腹外斜肌', en: 'External Oblique' },
|
||||
{ id: 'adductor_magnus_l', side: 'back', fitnessGroup: 'adductors', zh: '大收肌', en: 'Adductor Magnus' },
|
||||
{ id: 'adductor_magnus_r', side: 'back', fitnessGroup: 'adductors', zh: '大收肌', en: 'Adductor Magnus' },
|
||||
{ id: 'infraspinatus_l', side: 'back', fitnessGroup: 'back', zh: '冈下肌', en: 'Infraspinatus' },
|
||||
{ id: 'infraspinatus_r', side: 'back', fitnessGroup: 'back', zh: '冈下肌', en: 'Infraspinatus' },
|
||||
{ id: 'latissimus_dorsi_l', side: 'back', fitnessGroup: 'back', zh: '背阔肌', en: 'Latissimus Dorsi' },
|
||||
{ id: 'latissimus_dorsi_r', side: 'back', fitnessGroup: 'back', zh: '背阔肌', en: 'Latissimus Dorsi' },
|
||||
{ id: 'gastrocnemius_l', side: 'back', fitnessGroup: 'calves', zh: '腓肠肌', en: 'Gastrocnemius' },
|
||||
{ id: 'gastrocnemius_r', side: 'back', fitnessGroup: 'calves', zh: '腓肠肌', en: 'Gastrocnemius' },
|
||||
{ id: 'anconeus_l', side: 'back', fitnessGroup: 'forearms', zh: '肘肌', en: 'Anconeus' },
|
||||
{ id: 'anconeus_r', side: 'back', fitnessGroup: 'forearms', zh: '肘肌', en: 'Anconeus' },
|
||||
{ id: 'brachioradialis_l', side: 'back', fitnessGroup: 'forearms', zh: '肱桡肌', en: 'Brachioradialis' },
|
||||
{ id: 'brachioradialis_r', side: 'back', fitnessGroup: 'forearms', zh: '肱桡肌', en: 'Brachioradialis' },
|
||||
{ id: 'extensor_carpi_ulnaris_l', side: 'back', fitnessGroup: 'forearms', zh: '尺侧腕伸肌', en: 'Extensor Carpi Ulnaris' },
|
||||
{ id: 'extensor_carpi_ulnaris_r', side: 'back', fitnessGroup: 'forearms', zh: '尺侧腕伸肌', en: 'Extensor Carpi Ulnaris' },
|
||||
{ id: 'extensor_digitorum_l', side: 'back', fitnessGroup: 'forearms', zh: '指总伸肌', en: 'Extensor Digitorum' },
|
||||
{ id: 'extensor_digitorum_r', side: 'back', fitnessGroup: 'forearms', zh: '指总伸肌', en: 'Extensor Digitorum' },
|
||||
{ id: 'flexor_carpi_ulnaris_l', side: 'back', fitnessGroup: 'forearms', zh: '尺侧腕屈肌', en: 'Flexor Carpi Ulnaris' },
|
||||
{ id: 'flexor_carpi_ulnaris_r', side: 'back', fitnessGroup: 'forearms', zh: '尺侧腕屈肌', en: 'Flexor Carpi Ulnaris' },
|
||||
{ id: 'gluteus_maximus_l', side: 'back', fitnessGroup: 'glutes', zh: '臀大肌', en: 'Gluteus Maximus' },
|
||||
{ id: 'gluteus_maximus_r', side: 'back', fitnessGroup: 'glutes', zh: '臀大肌', en: 'Gluteus Maximus' },
|
||||
{ id: 'gluteus_medius_1_l', side: 'back', fitnessGroup: 'glutes', zh: '臀中肌', en: 'Gluteus Medius' },
|
||||
{ id: 'gluteus_medius_1_r', side: 'back', fitnessGroup: 'glutes', zh: '臀中肌', en: 'Gluteus Medius' },
|
||||
{ id: 'gluteus_medius_2_l', side: 'back', fitnessGroup: 'glutes', zh: '臀中肌', en: 'Gluteus Medius' },
|
||||
{ id: 'gluteus_medius_2_r', side: 'back', fitnessGroup: 'glutes', zh: '臀中肌', en: 'Gluteus Medius' },
|
||||
{ id: 'biceps_femoris_l', side: 'back', fitnessGroup: 'hamstrings', zh: '股二头肌', en: 'Biceps Femoris' },
|
||||
{ id: 'biceps_femoris_r', side: 'back', fitnessGroup: 'hamstrings', zh: '股二头肌', en: 'Biceps Femoris' },
|
||||
{ id: 'semimembranosus_1_l', side: 'back', fitnessGroup: 'hamstrings', zh: '半膜肌', en: 'Semimembranosus' },
|
||||
{ id: 'semimembranosus_1_r', side: 'back', fitnessGroup: 'hamstrings', zh: '半膜肌', en: 'Semimembranosus' },
|
||||
{ id: 'semimembranosus_2_l', side: 'back', fitnessGroup: 'hamstrings', zh: '半膜肌', en: 'Semimembranosus' },
|
||||
{ id: 'semimembranosus_2_r', side: 'back', fitnessGroup: 'hamstrings', zh: '半膜肌', en: 'Semimembranosus' },
|
||||
{ id: 'semitendinosus_l', side: 'back', fitnessGroup: 'hamstrings', zh: '半腱肌', en: 'Semitendinosus' },
|
||||
{ id: 'semitendinosus_r', side: 'back', fitnessGroup: 'hamstrings', zh: '半腱肌', en: 'Semitendinosus' },
|
||||
{ id: 'sternocleidomastoid_l', side: 'back', fitnessGroup: 'neck', zh: '胸锁乳突肌', en: 'Sternocleidomastoid' },
|
||||
{ id: 'sternocleidomastoid_r', side: 'back', fitnessGroup: 'neck', zh: '胸锁乳突肌', en: 'Sternocleidomastoid' },
|
||||
{ id: 'iliotibial_tract_l', side: 'back', fitnessGroup: 'quads', zh: '髂胫束(筋膜)', en: 'Iliotibial Tract' },
|
||||
{ id: 'iliotibial_tract_r', side: 'back', fitnessGroup: 'quads', zh: '髂胫束(筋膜)', en: 'Iliotibial Tract' },
|
||||
{ id: 'lateral_deltoid_l', side: 'back', fitnessGroup: 'shoulders', zh: '三角肌(中束)', en: 'Deltoid (Lateral)' },
|
||||
{ id: 'lateral_deltoid_r', side: 'back', fitnessGroup: 'shoulders', zh: '三角肌(中束)', en: 'Deltoid (Lateral)' },
|
||||
{ id: 'posterior_deltoid_l', side: 'back', fitnessGroup: 'shoulders', zh: '三角肌(后束)', en: 'Deltoid (Posterior)' },
|
||||
{ id: 'posterior_deltoid_r', side: 'back', fitnessGroup: 'shoulders', zh: '三角肌(后束)', en: 'Deltoid (Posterior)' },
|
||||
{ id: 'trapezius_lower_l', side: 'back', fitnessGroup: 'traps', zh: '斜方肌(下束)', en: 'Trapezius (Lower)' },
|
||||
{ id: 'trapezius_lower_r', side: 'back', fitnessGroup: 'traps', zh: '斜方肌(下束)', en: 'Trapezius (Lower)' },
|
||||
{ id: 'trapezius_middle_l', side: 'back', fitnessGroup: 'traps', zh: '斜方肌(中束)', en: 'Trapezius (Middle)' },
|
||||
{ id: 'trapezius_middle_r', side: 'back', fitnessGroup: 'traps', zh: '斜方肌(中束)', en: 'Trapezius (Middle)' },
|
||||
{ id: 'trapezius_upper_l', side: 'back', fitnessGroup: 'traps', zh: '斜方肌(上束)', en: 'Trapezius (Upper)' },
|
||||
{ id: 'trapezius_upper_r', side: 'back', fitnessGroup: 'traps', zh: '斜方肌(上束)', en: 'Trapezius (Upper)' },
|
||||
{ id: 'triceps_brachii_caput_laterale_l', side: 'back', fitnessGroup: 'triceps', zh: '肱三头肌', en: 'Triceps Brachii' },
|
||||
{ id: 'triceps_brachii_caput_laterale_r', side: 'back', fitnessGroup: 'triceps', zh: '肱三头肌', en: 'Triceps Brachii' },
|
||||
{ id: 'triceps_brachii_caput_longum_l', side: 'back', fitnessGroup: 'triceps', zh: '肱三头肌', en: 'Triceps Brachii' },
|
||||
{ id: 'triceps_brachii_caput_longum_r', side: 'back', fitnessGroup: 'triceps', zh: '肱三头肌', en: 'Triceps Brachii' },
|
||||
{ id: 'triceps_brachii_caput_mediale_l', side: 'back', fitnessGroup: 'triceps', zh: '肱三头肌', en: 'Triceps Brachii' },
|
||||
{ id: 'triceps_brachii_caput_mediale_r', side: 'back', fitnessGroup: 'triceps', zh: '肱三头肌', en: 'Triceps Brachii' },
|
||||
{ id: 'external_oblique_1_l', side: 'front', fitnessGroup: 'abs', zh: '腹外斜肌', en: 'External Oblique' },
|
||||
{ id: 'external_oblique_1_r', side: 'front', fitnessGroup: 'abs', zh: '腹外斜肌', en: 'External Oblique' },
|
||||
{ id: 'external_oblique_2_l', side: 'front', fitnessGroup: 'abs', zh: '腹外斜肌', en: 'External Oblique' },
|
||||
{ id: 'external_oblique_2_r', side: 'front', fitnessGroup: 'abs', zh: '腹外斜肌', en: 'External Oblique' },
|
||||
{ id: 'external_oblique_3_l', side: 'front', fitnessGroup: 'abs', zh: '腹外斜肌', en: 'External Oblique' },
|
||||
{ id: 'external_oblique_3_r', side: 'front', fitnessGroup: 'abs', zh: '腹外斜肌', en: 'External Oblique' },
|
||||
{ id: 'external_oblique_4_l', side: 'front', fitnessGroup: 'abs', zh: '腹外斜肌', en: 'External Oblique' },
|
||||
{ id: 'external_oblique_4_r', side: 'front', fitnessGroup: 'abs', zh: '腹外斜肌', en: 'External Oblique' },
|
||||
{ id: 'external_oblique_5_l', side: 'front', fitnessGroup: 'abs', zh: '腹外斜肌', en: 'External Oblique' },
|
||||
{ id: 'external_oblique_5_r', side: 'front', fitnessGroup: 'abs', zh: '腹外斜肌', en: 'External Oblique' },
|
||||
{ id: 'external_oblique_6_l', side: 'front', fitnessGroup: 'abs', zh: '腹外斜肌', en: 'External Oblique' },
|
||||
{ id: 'external_oblique_6_r', side: 'front', fitnessGroup: 'abs', zh: '腹外斜肌', en: 'External Oblique' },
|
||||
{ id: 'external_oblique_7_l', side: 'front', fitnessGroup: 'abs', zh: '腹外斜肌', en: 'External Oblique' },
|
||||
{ id: 'external_oblique_7_r', side: 'front', fitnessGroup: 'abs', zh: '腹外斜肌', en: 'External Oblique' },
|
||||
{ id: 'external_oblique_8_l', side: 'front', fitnessGroup: 'abs', zh: '腹外斜肌', en: 'External Oblique' },
|
||||
{ id: 'external_oblique_8_r', side: 'front', fitnessGroup: 'abs', zh: '腹外斜肌', en: 'External Oblique' },
|
||||
{ id: 'rectus_abdominis_1', side: 'front', fitnessGroup: 'abs', zh: '腹直肌', en: 'Rectus Abdominis' },
|
||||
{ id: 'rectus_abdominis_2_l', side: 'front', fitnessGroup: 'abs', zh: '腹直肌', en: 'Rectus Abdominis' },
|
||||
{ id: 'rectus_abdominis_2_r', side: 'front', fitnessGroup: 'abs', zh: '腹直肌', en: 'Rectus Abdominis' },
|
||||
{ id: 'rectus_abdominis_3_l', side: 'front', fitnessGroup: 'abs', zh: '腹直肌', en: 'Rectus Abdominis' },
|
||||
{ id: 'rectus_abdominis_3_r', side: 'front', fitnessGroup: 'abs', zh: '腹直肌', en: 'Rectus Abdominis' },
|
||||
{ id: 'rectus_abdominis_4_l', side: 'front', fitnessGroup: 'abs', zh: '腹直肌', en: 'Rectus Abdominis' },
|
||||
{ id: 'rectus_abdominis_4_r', side: 'front', fitnessGroup: 'abs', zh: '腹直肌', en: 'Rectus Abdominis' },
|
||||
{ id: 'adductor_longus_l', side: 'front', fitnessGroup: 'adductors', zh: '长收肌', en: 'Adductor Longus' },
|
||||
{ id: 'adductor_longus_r', side: 'front', fitnessGroup: 'adductors', zh: '长收肌', en: 'Adductor Longus' },
|
||||
{ id: 'gracilis_l', side: 'front', fitnessGroup: 'adductors', zh: '股薄肌', en: 'Gracilis' },
|
||||
{ id: 'gracilis_r', side: 'front', fitnessGroup: 'adductors', zh: '股薄肌', en: 'Gracilis' },
|
||||
{ id: 'pectineus_l', side: 'front', fitnessGroup: 'adductors', zh: '耻骨肌', en: 'Pectineus' },
|
||||
{ id: 'pectineus_r', side: 'front', fitnessGroup: 'adductors', zh: '耻骨肌', en: 'Pectineus' },
|
||||
{ id: 'latissimus_dorsi_l', side: 'front', fitnessGroup: 'back', zh: '背阔肌', en: 'Latissimus Dorsi' },
|
||||
{ id: 'latissimus_dorsi_r', side: 'front', fitnessGroup: 'back', zh: '背阔肌', en: 'Latissimus Dorsi' },
|
||||
{ id: 'biceps_brachii_caput_breve_l', side: 'front', fitnessGroup: 'biceps', zh: '肱二头肌', en: 'Biceps Brachii' },
|
||||
{ id: 'biceps_brachii_caput_breve_r', side: 'front', fitnessGroup: 'biceps', zh: '肱二头肌', en: 'Biceps Brachii' },
|
||||
{ id: 'biceps_brachii_caput_longum_l', side: 'front', fitnessGroup: 'biceps', zh: '肱二头肌', en: 'Biceps Brachii' },
|
||||
{ id: 'biceps_brachii_caput_longum_r', side: 'front', fitnessGroup: 'biceps', zh: '肱二头肌', en: 'Biceps Brachii' },
|
||||
{ id: 'extensor_digitorum_longus_l', side: 'front', fitnessGroup: 'calves', zh: '趾长伸肌', en: 'Extensor Digitorum Longus' },
|
||||
{ id: 'extensor_digitorum_longus_r', side: 'front', fitnessGroup: 'calves', zh: '趾长伸肌', en: 'Extensor Digitorum Longus' },
|
||||
{ id: 'extensor_hallucis_longus_l', side: 'front', fitnessGroup: 'calves', zh: '拇长伸肌', en: 'Extensor Hallucis Longus' },
|
||||
{ id: 'extensor_hallucis_longus_r', side: 'front', fitnessGroup: 'calves', zh: '拇长伸肌', en: 'Extensor Hallucis Longus' },
|
||||
{ id: 'fibularis_longus_l', side: 'front', fitnessGroup: 'calves', zh: '腓骨长肌', en: 'Fibularis Longus' },
|
||||
{ id: 'fibularis_longus_r', side: 'front', fitnessGroup: 'calves', zh: '腓骨长肌', en: 'Fibularis Longus' },
|
||||
{ id: 'gastrocnemius_l', side: 'front', fitnessGroup: 'calves', zh: '腓肠肌', en: 'Gastrocnemius' },
|
||||
{ id: 'gastrocnemius_r', side: 'front', fitnessGroup: 'calves', zh: '腓肠肌', en: 'Gastrocnemius' },
|
||||
{ id: 'tibialis_anterior_l', side: 'front', fitnessGroup: 'calves', zh: '胫骨前肌', en: 'Tibialis Anterior' },
|
||||
{ id: 'tibialis_anterior_r', side: 'front', fitnessGroup: 'calves', zh: '胫骨前肌', en: 'Tibialis Anterior' },
|
||||
{ id: 'pectoralis_major_l', side: 'front', fitnessGroup: 'chest', zh: '胸大肌', en: 'Pectoralis Major' },
|
||||
{ id: 'pectoralis_major_r', side: 'front', fitnessGroup: 'chest', zh: '胸大肌', en: 'Pectoralis Major' },
|
||||
{ id: 'brachioradialis_l', side: 'front', fitnessGroup: 'forearms', zh: '肱桡肌', en: 'Brachioradialis' },
|
||||
{ id: 'brachioradialis_r', side: 'front', fitnessGroup: 'forearms', zh: '肱桡肌', en: 'Brachioradialis' },
|
||||
{ id: 'extensor_carpi_radialis_longus_l', side: 'front', fitnessGroup: 'forearms', zh: '桡侧腕长伸肌', en: 'Extensor Carpi Radialis Longus' },
|
||||
{ id: 'extensor_carpi_radialis_longus_r', side: 'front', fitnessGroup: 'forearms', zh: '桡侧腕长伸肌', en: 'Extensor Carpi Radialis Longus' },
|
||||
{ id: 'flexor_carpi_radialis_l', side: 'front', fitnessGroup: 'forearms', zh: '桡侧腕屈肌', en: 'Flexor Carpi Radialis' },
|
||||
{ id: 'flexor_carpi_radialis_r', side: 'front', fitnessGroup: 'forearms', zh: '桡侧腕屈肌', en: 'Flexor Carpi Radialis' },
|
||||
{ id: 'flexor_digitorum_superficialis_l', side: 'front', fitnessGroup: 'forearms', zh: '指浅屈肌', en: 'Flexor Digitorum Superficialis' },
|
||||
{ id: 'flexor_digitorum_superficialis_r', side: 'front', fitnessGroup: 'forearms', zh: '指浅屈肌', en: 'Flexor Digitorum Superficialis' },
|
||||
{ id: 'palmaris_longus_l', side: 'front', fitnessGroup: 'forearms', zh: '掌长肌', en: 'Palmaris Longus' },
|
||||
{ id: 'palmaris_longus_r', side: 'front', fitnessGroup: 'forearms', zh: '掌长肌', en: 'Palmaris Longus' },
|
||||
{ id: 'pronator_quadratus_l', side: 'front', fitnessGroup: 'forearms', zh: '旋前方肌', en: 'Pronator Quadratus' },
|
||||
{ id: 'pronator_quadratus_r', side: 'front', fitnessGroup: 'forearms', zh: '旋前方肌', en: 'Pronator Quadratus' },
|
||||
{ id: 'pronator_teres_l', side: 'front', fitnessGroup: 'forearms', zh: '旋前圆肌', en: 'Pronator Teres' },
|
||||
{ id: 'pronator_teres_r', side: 'front', fitnessGroup: 'forearms', zh: '旋前圆肌', en: 'Pronator Teres' },
|
||||
{ id: 'gluteus_medius_2_l', side: 'front', fitnessGroup: 'glutes', zh: '臀中肌', en: 'Gluteus Medius' },
|
||||
{ id: 'gluteus_medius_2_r', side: 'front', fitnessGroup: 'glutes', zh: '臀中肌', en: 'Gluteus Medius' },
|
||||
{ id: 'semitendinosus_l', side: 'front', fitnessGroup: 'hamstrings', zh: '半腱肌', en: 'Semitendinosus' },
|
||||
{ id: 'semitendinosus_r', side: 'front', fitnessGroup: 'hamstrings', zh: '半腱肌', en: 'Semitendinosus' },
|
||||
{ id: 'platysma', side: 'front', fitnessGroup: 'neck', zh: '颈阔肌', en: 'Platysma' },
|
||||
{ id: 'sternocleidomastoid_l', side: 'front', fitnessGroup: 'neck', zh: '胸锁乳突肌', en: 'Sternocleidomastoid' },
|
||||
{ id: 'sternocleidomastoid_r', side: 'front', fitnessGroup: 'neck', zh: '胸锁乳突肌', en: 'Sternocleidomastoid' },
|
||||
{ id: 'sternohyoid', side: 'front', fitnessGroup: 'neck', zh: '胸骨舌骨肌', en: 'Sternohyoid' },
|
||||
{ id: 'iliotibial_tract_l', side: 'front', fitnessGroup: 'quads', zh: '髂胫束(筋膜)', en: 'Iliotibial Tract' },
|
||||
{ id: 'iliotibial_tract_r', side: 'front', fitnessGroup: 'quads', zh: '髂胫束(筋膜)', en: 'Iliotibial Tract' },
|
||||
{ id: 'rectus_femoris_l', side: 'front', fitnessGroup: 'quads', zh: '股直肌', en: 'Rectus Femoris' },
|
||||
{ id: 'rectus_femoris_r', side: 'front', fitnessGroup: 'quads', zh: '股直肌', en: 'Rectus Femoris' },
|
||||
{ id: 'sartoris_l', side: 'front', fitnessGroup: 'quads', zh: '缝匠肌', en: 'Sartorius' },
|
||||
{ id: 'sartoris_r', side: 'front', fitnessGroup: 'quads', zh: '缝匠肌', en: 'Sartorius' },
|
||||
{ id: 'vastus_lateralis_l', side: 'front', fitnessGroup: 'quads', zh: '股外侧肌', en: 'Vastus Lateralis' },
|
||||
{ id: 'vastus_lateralis_r', side: 'front', fitnessGroup: 'quads', zh: '股外侧肌', en: 'Vastus Lateralis' },
|
||||
{ id: 'vastus_medialis_l', side: 'front', fitnessGroup: 'quads', zh: '股内侧肌', en: 'Vastus Medialis' },
|
||||
{ id: 'vastus_medialis_r', side: 'front', fitnessGroup: 'quads', zh: '股内侧肌', en: 'Vastus Medialis' },
|
||||
{ id: 'anterior_deltoid_l', side: 'front', fitnessGroup: 'shoulders', zh: '三角肌(前束)', en: 'Deltoid (Anterior)' },
|
||||
{ id: 'anterior_deltoid_r', side: 'front', fitnessGroup: 'shoulders', zh: '三角肌(前束)', en: 'Deltoid (Anterior)' },
|
||||
{ id: 'lateral_deltoid_l', side: 'front', fitnessGroup: 'shoulders', zh: '三角肌(中束)', en: 'Deltoid (Lateral)' },
|
||||
{ id: 'lateral_deltoid_r', side: 'front', fitnessGroup: 'shoulders', zh: '三角肌(中束)', en: 'Deltoid (Lateral)' },
|
||||
{ id: 'trapezius_upper_l', side: 'front', fitnessGroup: 'traps', zh: '斜方肌(上束)', en: 'Trapezius (Upper)' },
|
||||
{ id: 'trapezius_upper_r', side: 'front', fitnessGroup: 'traps', zh: '斜方肌(上束)', en: 'Trapezius (Upper)' },
|
||||
{ id: 'triceps_brachii_caput_laterale_l', side: 'front', fitnessGroup: 'triceps', zh: '肱三头肌', en: 'Triceps Brachii' },
|
||||
{ id: 'triceps_brachii_caput_laterale_r', side: 'front', fitnessGroup: 'triceps', zh: '肱三头肌', en: 'Triceps Brachii' },
|
||||
{ id: 'triceps_brachii_caput_longum_l', side: 'front', fitnessGroup: 'triceps', zh: '肱三头肌', en: 'Triceps Brachii' },
|
||||
{ id: 'triceps_brachii_caput_longum_r', side: 'front', fitnessGroup: 'triceps', zh: '肱三头肌', en: 'Triceps Brachii' },
|
||||
];
|
||||
|
||||
// 渲染时隐藏的 SVG 组/区域 id(头部与面部轮廓,非肌肉)
|
||||
export const HIDDEN_SVG_GROUP_IDS = ['head', 'face'];
|
||||
|
||||
// 生成 SVG 肌肉路径在 UI 中的显示标签。
|
||||
// 同一块肌肉在 SVG 里可能被拆成多条 path(如 external_oblique_1_l..8_l),
|
||||
// 目录里只保存统一基础名;显示时从 id 提取段号,避免列表中出现大量重名。
|
||||
// 例如:external_oblique_1_l -> "腹外斜肌 1(左)"
|
||||
// rectus_abdominis_2_r -> "腹直肌 2(右)"
|
||||
// pectoralis_major_l -> "胸大肌(左)"
|
||||
export function formatSvgMuscleLabel(entry: SvgMuscleEntry, locale: 'zh' | 'en' | string): string {
|
||||
let base = locale === 'zh' ? entry.zh : entry.en;
|
||||
const segmentMatch = entry.id.match(/_(\d+)(?:_l|_r)?$/);
|
||||
if (segmentMatch) {
|
||||
const segment = segmentMatch[1];
|
||||
base += locale === 'zh' ? ` ${segment}` : ` ${segment}`;
|
||||
}
|
||||
if (entry.id.endsWith('_l')) {
|
||||
base += locale === 'zh' ? '(左)' : ' (L)';
|
||||
} else if (entry.id.endsWith('_r')) {
|
||||
base += locale === 'zh' ? '(右)' : ' (R)';
|
||||
}
|
||||
return base;
|
||||
}
|
||||
186
src/data/types.ts
Normal file
186
src/data/types.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
/*
|
||||
* types.ts —— 数据层的"数据结构(图纸)"定义文件。
|
||||
* 这里集中声明了插件用到的所有数据类型:字段定义、训练类型、肌肉、训练项、
|
||||
* 单条训练记录、完整配置、插件设置等。其他所有文件都从这里 import 这些类型,
|
||||
* 保证整个插件的数据格式统一、不会混乱。
|
||||
* 概念:TypeScript 里的 interface 就像给数据"画图纸",规定一个对象里有哪些字段、
|
||||
* 每个字段是什么类型;export const 则是导出一份固定不变的常量值。
|
||||
*/
|
||||
|
||||
// 字段定义:描述一个训练类型里"要记录哪些数据项",比如力量训练要记"重量"和"次数"。
|
||||
export interface FieldDef {
|
||||
key: string; // 字段的唯一内部标识(如 "weight")。切勿随意改名,否则旧数据就无法对应
|
||||
labelKey?: string; // 国际化 key(多语言用),如 "field.weight",由 i18n 翻译成"重量"
|
||||
label?: string; // 没有 labelKey 时的兜底显示名
|
||||
inputType: 'number' | 'duration' | 'text' | 'select'; // 输入控件类型:数字 / 时长 / 文本 / 下拉选择
|
||||
mass?: boolean; // 是否参与 kg/lb 换算(仅数字字段有意义;选中"质量"即自动 true)
|
||||
unitLabel?: string; // 单位显示文字(自由文本,如"次""层""圈""公里"),留空表示无单位
|
||||
required?: boolean; // 是否为必填项
|
||||
options?: string[]; // 当 inputType 为 'select'(下拉)时的可选项列表
|
||||
}
|
||||
|
||||
// 训练类型:如"力量训练""有氧训练"。每个类型定义一组要记录的字段(fields)。
|
||||
export interface TrainingType {
|
||||
id: string; // 唯一标识(如 "strength")。建议稳定不变,改名会断掉历史记录关联
|
||||
nameKey?: string; // 国际化显示名 key(优先级高于 name)
|
||||
name?: string; // 没有 nameKey 时的兜底名称
|
||||
icon?: string; // 显示的图标名(Obsidian/Lucide 图标)
|
||||
fields: FieldDef[]; // 该训练类型要记录的字段列表
|
||||
contributesToCoverage?: boolean; // 是否计入"肌肉覆盖/训练频率"统计
|
||||
}
|
||||
|
||||
// 肌肉:人体部位,用于训练覆盖统计和身体示意图高亮。
|
||||
export interface Muscle {
|
||||
id: string; // 唯一标识(如 "chest")
|
||||
nameKey?: string; // 国际化显示名 key
|
||||
name?: string; // 兜底名称
|
||||
contributesToCoverage: boolean; // 是否计入覆盖统计
|
||||
// 超过该天数未练即提醒/标红;默认 7(v2.3 由通用设置下放到每块肌肉)
|
||||
restThresholdDays?: number;
|
||||
// 该肌肉在身体示意图 SVG 中对应的全部路径 id(1→N 可配置映射)
|
||||
svgRegionIds: string[];
|
||||
// 热力图每块肌肉可覆盖的指标、时间窗、颜色分档;留空则跟随代码块/全局默认(三级回退)
|
||||
heatmapMetric?: string; // 引用 StatDef.id;空=跟随默认
|
||||
heatmapRange?: string; // 7d / 30d / 90d / all / 日期区间;空=跟随默认
|
||||
heatmapLevels?: HeatmapLevel[]; // 该肌 4 色分档(蓝/绿/橙/红)阈值;空=跟随所选指标的默认分档(逐肌可配)
|
||||
}
|
||||
|
||||
// 训练项与肌肉的关联:一个动作练到哪些肌肉,以及是主练还是辅练。
|
||||
export interface ExerciseMuscle {
|
||||
muscleId: string; // 关联到的 Muscle 的 id
|
||||
role: 'primary' | 'secondary'; // 主练(primary)还是辅练(secondary)
|
||||
contributesToCoverage?: boolean; // 该关联是否计入覆盖统计
|
||||
}
|
||||
|
||||
// 训练项(动作):如"深蹲""卧推"。属于某个训练类型,并关联若干肌肉。
|
||||
export interface Exercise {
|
||||
id: string; // 唯一标识(如 "squat")。记录用此 id 关联,改名不影响历史
|
||||
nameKey?: string; // 国际化显示名 key
|
||||
name?: string; // 兜底名称
|
||||
category: string; // 所属训练类型的 id(对应 TrainingType.id)
|
||||
muscles?: ExerciseMuscle[]; // 该动作涉及的肌肉及其角色
|
||||
}
|
||||
|
||||
// 一条训练记录(CSV 里的一行)。这是用户每次锻炼实际产生的数据。
|
||||
export interface LogRow {
|
||||
id: string; // 稳定唯一标识(12 位 base36 短随机 id),作为记录主键,替代 timestamp 的定位职责
|
||||
timestamp: string; // 记录时间,格式 "YYYY-MM-DD HH:mm",仅用于展示/排序/分组
|
||||
exerciseId?: string; // 训练项 id,用于稳定关联配置(改名/换语言后仍能解析到最新名)
|
||||
category: string; // 训练类型 id
|
||||
fields: Record<string, unknown>; // 各字段值,统一以 JSON 对象存储(含义由训练类型决定)
|
||||
note?: string; // 备注
|
||||
plan?: string; // 计划/方案名(预留,当前未消费)
|
||||
}
|
||||
|
||||
// 训练计划:从训练方案(笔记)或手动创建的持久化配置实例。
|
||||
// 一组训练项 + 每项的多个组(每组字段独立预设)。时间规则支持具体日期或每周某几天。
|
||||
export interface PlanSet {
|
||||
id: string; // 组内稳定 id(完成状态匹配用)
|
||||
fields: Record<string, unknown>; // 该组字段预设值(用户自定义,如 {weight:60, reps:8})
|
||||
}
|
||||
|
||||
export interface PlanItem {
|
||||
exerciseId: string; // 训练项 id(稳定关联配置)
|
||||
category: string; // 所属训练类型 id(决定字段)
|
||||
enabled: boolean; // 是否纳入本次计划(全选默认 true)
|
||||
sets: PlanSet[]; // 多组,每组字段独立
|
||||
}
|
||||
|
||||
export interface TimeRule {
|
||||
type: 'date' | 'weekday';
|
||||
date?: string; // type='date':YYYY-MM-DD
|
||||
weekdays?: number[]; // type='weekday':ISO 周几,周一=1 … 周日=7,如 [1,3,5]
|
||||
}
|
||||
|
||||
export interface TrainingPlanInstance {
|
||||
id: string; // uuid
|
||||
name: string; // 全局唯一,即 workout-plan 代码块的 plan 参数值
|
||||
timeRule: TimeRule; // 计划时间
|
||||
sourceNote?: string; // 来源方案笔记 basename(可选;手动创建时为空)
|
||||
createdAt: string; // YYYY-MM-DD
|
||||
items: PlanItem[];
|
||||
// 已完成组持久化:key = `${exerciseId}#${setId}`,value = 完成日期(YYYY-MM-DD)。
|
||||
// 独立于训练记录存储——即使删除了该组产生的训练记录,完成状态仍保留(完成即完成)。
|
||||
completedSets?: Record<string, string>;
|
||||
}
|
||||
|
||||
// 完整配置:聚合了训练类型、训练项、肌肉三块,整体存进 workout-config.json。
|
||||
// 数据统计(数据分析)条目:让用户对训练记录的字段做数学聚合。
|
||||
// 作用域 = 关联一个或多个训练类型(其字段交集即公式可选字段);
|
||||
// 显示仅在「训练项 category ∈ associatedTypes」的代码块里出现;
|
||||
// 计算始终落在当前代码块训练项、当前分组的记录上。
|
||||
export type StatGranularity = 'daily' | 'weekly' | 'monthly';
|
||||
|
||||
export type StatAggregation =
|
||||
| { kind: 'sum'; field: string } // 字段求和:Σ 每条记录的字段值
|
||||
| { kind: 'productSum'; fieldA: string; fieldB: string } // 乘积求和:Σ(字段A×字段B)
|
||||
| { kind: 'oneRepMax'; weightField: string; repsField: string } // 1RM 估算:取最佳组的 Epley 公式估算值
|
||||
| { kind: 'avg' | 'max' | 'min'; field: string } // 均值 / 最大 / 最小
|
||||
| { kind: 'count' }; // 记录条数(不引用字段)
|
||||
|
||||
// 热力图颜色档:颜色标识 + 该档上限(含);末档省略 max 表示 +∞
|
||||
export interface HeatmapLevel {
|
||||
color: string;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
export interface StatDef {
|
||||
id: string; // 唯一标识
|
||||
name: string; // 显示名(如「总训练量」「总时长」)
|
||||
associatedTypes: string[]; // 关联的训练类型 id 列表(支持多个)
|
||||
formula: {
|
||||
mode: 'builder' | 'expression'; // 引导式构建器 / 自由表达式
|
||||
builder?: StatAggregation; // mode='builder' 时有效
|
||||
expression?: string; // mode='expression' 时有效,如 "sum(reps * weight)"
|
||||
};
|
||||
granularity: StatGranularity;
|
||||
enabled: boolean;
|
||||
// 作为热力图指标时的【默认】颜色分档模板;每块肌肉可在编辑页基于此单独覆盖(见 §5.4.1)
|
||||
heatmapScale?: HeatmapLevel[];
|
||||
// 标记是否为热力图默认指标(种子置「次数」为 true)
|
||||
heatmapDefault?: boolean;
|
||||
}
|
||||
|
||||
export interface WorkoutConfig {
|
||||
version: number; // 配置版本号,用于数据迁移(migrate)
|
||||
trainingTypes: TrainingType[];
|
||||
exercises: Exercise[];
|
||||
muscles: Muscle[];
|
||||
statistics: StatDef[];
|
||||
plans?: TrainingPlanInstance[]; // 训练计划实例列表(聚合进既有配置文件,不单独建文件)
|
||||
}
|
||||
|
||||
// 插件设置:存放路径、单位、语言、各种集成开关等用户偏好。
|
||||
export interface PluginSettings {
|
||||
csvDirectory: string; // 训练记录 CSV 所在目录(vault 内相对路径,空 = 根目录)
|
||||
configDirectory: string; // 配置文件所在目录(空 = 根目录)
|
||||
unit: 'kg' | 'lb'; // 重量单位:公斤 / 磅
|
||||
language: 'zh' | 'en'; // 界面语言:中文 / 英文
|
||||
dataviewIntegration: boolean; // 是否启用 Dataview 集成
|
||||
dailyNotesIntegration: boolean; // 是否启用日记集成
|
||||
templaterIntegration: boolean; // 是否启用 Templater 集成
|
||||
lastValueMemory: boolean; // 是否记忆上次输入值(下次自动填充)
|
||||
// 肌肉管理首次引导是否已完成(v2.3)
|
||||
muscleMappingInitialized: boolean;
|
||||
// 设置页「训练设置」区块下五个管理条目的显示顺序(仅影响设置页排序,不影响功能逻辑)
|
||||
managerOrder: string[]; // 取值见 renderManagersSection:['types','exercises','muscles','statistics','plans']
|
||||
}
|
||||
|
||||
// 默认设置:首次安装时使用。之后用户改过的设置会覆盖其中对应项。
|
||||
export const DEFAULT_SETTINGS: PluginSettings = {
|
||||
csvDirectory: '',
|
||||
configDirectory: '',
|
||||
unit: 'kg',
|
||||
language: 'zh',
|
||||
dataviewIntegration: false,
|
||||
dailyNotesIntegration: false,
|
||||
templaterIntegration: false,
|
||||
lastValueMemory: true,
|
||||
muscleMappingInitialized: false,
|
||||
managerOrder: ['types', 'exercises', 'muscles', 'statistics', 'plans'],
|
||||
};
|
||||
|
||||
// 训练记录 CSV 的文件名(存于 vault 中)
|
||||
export const CSV_FILENAME = 'workout_logs.csv';
|
||||
// 聚合配置 JSON 的文件名
|
||||
export const CONFIG_FILENAME = 'workout-config.json';
|
||||
483
src/i18n/en.ts
Normal file
483
src/i18n/en.ts
Normal file
|
|
@ -0,0 +1,483 @@
|
|||
/*
|
||||
* en.ts —— 英文文案字典(i18n 英文包)
|
||||
* 与 zh.ts 结构完全对称,存放插件所有英文界面文字。
|
||||
* key(如 common.save)的层级和命名必须和 zh.ts 保持一致,
|
||||
* 这样 index.ts 的 t('common.save') 无论切到中文还是英文都能取到对应文案。
|
||||
* 想新增/修改某条英文文案,直接在本文件对应位置改即可。
|
||||
*/
|
||||
|
||||
export const en = {
|
||||
pluginName: 'Workout Block',
|
||||
pluginDescription: 'Lightweight, data-owning, customizable workout tracking plugin',
|
||||
|
||||
command: {
|
||||
recordSet: 'Training Plan: Record Set',
|
||||
newPlan: 'Training Plan: New Plan',
|
||||
newExercise: 'Training Plan: New Exercise',
|
||||
newType: 'Training Plan: New Training Type',
|
||||
settings: 'Training Plan: Settings',
|
||||
insertCodeblock: 'Insert Code Block',
|
||||
},
|
||||
|
||||
type: {
|
||||
strength: 'Strength',
|
||||
aerobic: 'Aerobic',
|
||||
bodyweight: 'Bodyweight',
|
||||
},
|
||||
|
||||
exercise: {
|
||||
squat: 'Squat',
|
||||
deadlift: 'Deadlift',
|
||||
bench_press: 'Bench Press',
|
||||
pushup: 'Push-Up',
|
||||
pullup: 'Pull-Up',
|
||||
plank: 'Plank',
|
||||
running: 'Running',
|
||||
jumping_rope: 'Jump Rope',
|
||||
cycling: 'Cycling',
|
||||
// —— added (2026-07-14) ——
|
||||
dumbbell_bench_press: 'Dumbbell Bench Press',
|
||||
incline_dumbbell_press: 'Incline Dumbbell Press',
|
||||
close_grip_bench_press: 'Close-Grip Bench Press',
|
||||
dip: 'Dip',
|
||||
dumbbell_press: 'Dumbbell Press',
|
||||
dumbbell_lateral_raise: 'Dumbbell Lateral Raise',
|
||||
barbell_front_raise: 'Barbell Front Raise',
|
||||
barbell_push_press: 'Barbell Push Press',
|
||||
barbell_lying_triceps: 'Barbell Lying Triceps Extension',
|
||||
dumbbell_overhead_triceps: 'Dumbbell Overhead Triceps Extension',
|
||||
barbell_row: 'Barbell Row',
|
||||
dumbbell_row: 'Dumbbell Row',
|
||||
dumbbell_reverse_fly: 'Dumbbell Reverse Fly',
|
||||
cable_face_pull: 'Cable Face Pull',
|
||||
barbell_upright_row: 'Barbell Upright Row',
|
||||
barbell_curl: 'Barbell Curl',
|
||||
dumbbell_curl: 'Dumbbell Curl',
|
||||
lunge: 'Lunge',
|
||||
romanian_deadlift: 'Romanian Deadlift',
|
||||
hanging_leg_raise: 'Hanging Leg Raise',
|
||||
ab_crunch: 'Ab Crunch',
|
||||
},
|
||||
|
||||
muscle: {
|
||||
chest: 'Chest',
|
||||
front_delt: 'Front Delts',
|
||||
biceps: 'Biceps',
|
||||
quads: 'Quads',
|
||||
front_calf: 'Front Calves',
|
||||
abs: 'Abs',
|
||||
lats: 'Lats',
|
||||
traps: 'Traps',
|
||||
rear_delt: 'Rear Delts',
|
||||
triceps: 'Triceps',
|
||||
hamstrings: 'Hamstrings',
|
||||
glutes: 'Glutes',
|
||||
back_calf: 'Back Calves',
|
||||
},
|
||||
|
||||
field: {
|
||||
weight: 'Weight',
|
||||
reps: 'Reps',
|
||||
bodyweight: 'Body Weight',
|
||||
duration_sec: 'Duration',
|
||||
distance: 'Distance',
|
||||
},
|
||||
|
||||
unit: {
|
||||
kg: 'kg',
|
||||
lb: 'lb',
|
||||
},
|
||||
|
||||
duration: {
|
||||
hour: 'h',
|
||||
minute: 'm',
|
||||
second: 's',
|
||||
},
|
||||
|
||||
fieldInputType: {
|
||||
number: 'Number',
|
||||
duration: 'Duration',
|
||||
text: 'Text',
|
||||
select: 'Select',
|
||||
},
|
||||
|
||||
fieldUnit: {
|
||||
none: 'None',
|
||||
mass: 'Mass',
|
||||
custom: 'Custom',
|
||||
},
|
||||
|
||||
codeblock: {
|
||||
addRecord: 'Add record for "{exercise}"',
|
||||
edit: 'Edit',
|
||||
delete: 'Delete',
|
||||
confirmDelete: 'Are you sure you want to delete this record?',
|
||||
date: 'Date',
|
||||
note: 'Note',
|
||||
actions: 'Actions',
|
||||
totalSets: 'Total Sets',
|
||||
maxWeight: 'Max Weight',
|
||||
noRecords: 'No records for "{exercise}" yet. Click the button above to start adding.',
|
||||
notSpecified: 'Not specified',
|
||||
plan: {
|
||||
select: 'Select plan',
|
||||
noPlan: 'No training plan yet. Create one via "New Training Plan".',
|
||||
selectPlaceholder: '— Select a plan —',
|
||||
progress: 'Progress {done}/{total}',
|
||||
completed: 'Completed',
|
||||
complete: 'Complete',
|
||||
edit: 'Edit',
|
||||
editTitle: 'Edit · {exercise} · Set {n}',
|
||||
emptyFields: '(no presets set)',
|
||||
},
|
||||
day: {
|
||||
viewing: 'Viewing date: {date}',
|
||||
project: 'Exercise',
|
||||
statValue: 'Stats',
|
||||
primaryMuscles: 'Primary',
|
||||
secondaryMuscles: 'Secondary',
|
||||
plan: 'Plan',
|
||||
pinToday: 'Pin to today',
|
||||
empty: 'No training records on "{date}"',
|
||||
unknown: 'Unknown exercise',
|
||||
pinFailed: 'Pin failed: could not locate code block',
|
||||
},
|
||||
heatmap: {
|
||||
emptyAll: 'No training records',
|
||||
emptyRange: 'No training records in the last {range}',
|
||||
noMapping: 'Muscles have no SVG mapping yet. Open "Muscle Manager" and apply a preset (default/minimal) to see the heatmap.',
|
||||
lazy: 'Heatmap will render automatically when scrolled into view…',
|
||||
legendLow: 'Light',
|
||||
legendHigh: 'Heavy',
|
||||
},
|
||||
},
|
||||
|
||||
modal: {
|
||||
recordSet: {
|
||||
title: 'Record Set',
|
||||
exercise: 'Exercise',
|
||||
searchExercisePlaceholder: 'Search exercises...',
|
||||
type: 'Training Type',
|
||||
newExercise: '+ New',
|
||||
note: 'Note (optional)',
|
||||
save: 'Save',
|
||||
inputPlaceholder: 'Enter',
|
||||
selectType: 'Please select a training type',
|
||||
requiredField: 'is required',
|
||||
saved: 'Record saved',
|
||||
updated: 'Record updated',
|
||||
saveFailed: 'Save failed',
|
||||
time: 'Time',
|
||||
plan: 'Training Scheme',
|
||||
scheme: 'Training Scheme',
|
||||
planPlaceholder: 'Leave empty if not tied to a scheme',
|
||||
noSchemeOption: '— No Scheme —',
|
||||
schemeNotFound: 'deleted/renamed',
|
||||
exerciseLocked: '(cannot be changed when editing)',
|
||||
noMatchingExercise: 'No matching exercises',
|
||||
selectExercise: 'Please select a valid exercise',
|
||||
},
|
||||
insertCodeblock: {
|
||||
title: 'Insert Workout Code Block',
|
||||
searchPlaceholder: 'Search code blocks...',
|
||||
noMatch: 'No matching code block',
|
||||
paramTitle: 'Set parameters (all optional; blank = default)',
|
||||
optional: 'optional',
|
||||
skip: 'Skip parameters',
|
||||
insert: 'Insert at cursor',
|
||||
inserted: 'Code block inserted at cursor',
|
||||
noEditor: 'Open a Markdown note first',
|
||||
paramsCount: '{n} optional params',
|
||||
},
|
||||
newPlan: {
|
||||
title: 'New Training Plan',
|
||||
editTitle: 'Edit Training Plan',
|
||||
name: 'Plan name',
|
||||
namePlaceholder: 'Enter plan name',
|
||||
defaultPrefix: 'Training Plan',
|
||||
nameRequired: 'Please enter a plan name',
|
||||
nameDuplicate: 'Plan name already exists',
|
||||
time: 'Plan schedule',
|
||||
timeDate: 'On a date',
|
||||
timeWeek: 'Weekly',
|
||||
date: 'Date',
|
||||
weekdayHint: 'Weekly on {days}',
|
||||
weekdayNone: 'No weekday selected',
|
||||
selectPlan: 'Training scheme',
|
||||
noScheme: '(Add manually / no scheme)',
|
||||
selected: 'Selected {x} / {y}',
|
||||
addItem: '+ Add exercise',
|
||||
removeItem: 'Remove',
|
||||
addSet: '+ Add set',
|
||||
setName: 'Set {n}',
|
||||
setSaved: 'Saved',
|
||||
saved: 'Training plan saved',
|
||||
updated: 'Training plan updated',
|
||||
saveFailed: 'Save failed',
|
||||
atLeastOne: 'Please select at least one exercise',
|
||||
noExercise: 'No exercises yet. Add one under "Exercises" first.',
|
||||
},
|
||||
newExercise: {
|
||||
title: 'New Exercise',
|
||||
editTitle: 'Edit Exercise',
|
||||
name: 'Name',
|
||||
id: 'Exercise ID',
|
||||
idPlaceholder: 'Auto-generated from name if blank',
|
||||
idDuplicate: 'Exercise ID already exists, please choose another',
|
||||
idInvalid: 'ID cannot contain comma, quote, or newline',
|
||||
category: 'Training Type',
|
||||
primaryMuscles: 'Primary',
|
||||
secondaryMuscles: 'Secondary',
|
||||
save: 'Save',
|
||||
nameRequired: 'Please enter exercise name',
|
||||
categoryRequired: 'Please select a training type',
|
||||
saved: 'Exercise saved',
|
||||
updated: 'Exercise updated',
|
||||
saveFailed: 'Save failed',
|
||||
},
|
||||
newType: {
|
||||
title: 'New Training Type',
|
||||
editTitle: 'Edit Training Type',
|
||||
name: 'Type Name',
|
||||
id: 'Category ID',
|
||||
idPlaceholder: 'Auto-generated from name if blank',
|
||||
idDuplicate: 'Category ID already exists, please choose another',
|
||||
idInvalid: 'ID cannot contain comma, quote, or newline',
|
||||
fields: 'Field List',
|
||||
addField: '+ Add Field',
|
||||
removeField: '- Remove',
|
||||
fieldKey: 'Field Key',
|
||||
fieldLabel: 'Field Label',
|
||||
inputType: 'Input Type',
|
||||
unit: 'Unit',
|
||||
customUnit: 'Unit text',
|
||||
options: 'Options (dropdown)',
|
||||
optionsPlaceholder: 'Comma-separated, e.g. Light, Medium, Heavy',
|
||||
save: 'Save',
|
||||
nameRequired: 'Please enter type name',
|
||||
saved: 'Training type saved',
|
||||
saveFailed: 'Save failed',
|
||||
},
|
||||
editRecord: {
|
||||
title: 'Edit Record',
|
||||
save: 'Save',
|
||||
},
|
||||
muscleManager: {
|
||||
title: 'Muscle Manager',
|
||||
name: 'Name',
|
||||
side: 'Part',
|
||||
sideLabel: 'Part',
|
||||
coverage: 'Count toward coverage',
|
||||
coverageLabel: 'Coverage',
|
||||
idLabel: 'ID',
|
||||
add: '+ Add Muscle',
|
||||
search: 'Search muscle name / ID',
|
||||
edit: 'Edit',
|
||||
editTitle: 'Edit Muscle',
|
||||
delete: 'Delete',
|
||||
front: 'Front',
|
||||
back: 'Back',
|
||||
both: 'Both',
|
||||
idRequired: 'Please enter an ID',
|
||||
nameRequired: 'Please enter a name',
|
||||
saved: 'Muscle saved',
|
||||
saveFailed: 'Save failed',
|
||||
deleted: 'Deleted',
|
||||
onboardingTitle: 'Set up muscle mapping',
|
||||
onboardingDesc: 'Choose an initial detail level. You can change it anytime in the edit dialog.',
|
||||
tierDefault: 'Default (by fitness group)',
|
||||
tierDefaultDesc: 'Map each muscle to all paths in its fitness group.',
|
||||
tierMinimal: 'Minimal (main paths)',
|
||||
tierMinimalDesc: 'Keep only representative main paths for a cleaner chart.',
|
||||
tierManual: 'Manual',
|
||||
tierManualDesc: 'Import empty mappings and pick paths yourself.',
|
||||
mappingApplied: 'Preset mapping applied',
|
||||
reapplyPreset: 'Re-apply preset',
|
||||
restThreshold: 'Rest threshold (days)',
|
||||
mappedPaths: 'Mapped paths',
|
||||
svgMapping: 'SVG Muscle Mapping',
|
||||
searchPaths: 'Search id / zh / en',
|
||||
clearMapping: 'Clear',
|
||||
applyGroupPreset: 'Apply group preset',
|
||||
selectedCount: 'Selected {count}',
|
||||
noPaths: 'No matching paths',
|
||||
heatmapSettings: 'Heatmap Settings',
|
||||
heatmapMetric: 'Metric',
|
||||
followDefaultMetric: 'Follow default (sets)',
|
||||
heatmapRange: 'Range',
|
||||
followDefaultRange: 'Follow default (7d)',
|
||||
customRange: 'Custom date range',
|
||||
colorLevels: 'Color levels',
|
||||
colorHex: 'Hex color, e.g. #3b82f6',
|
||||
scaleHint: 'Color light→heavy, each level has a threshold: left is the color (click the swatch to pick, or type a hex), right is the threshold (≤). Level count sets how many color tiers.',
|
||||
initScale: 'Use default 4 levels',
|
||||
scaleMaxHint: '≤',
|
||||
scaleUnbounded: '(unbounded)',
|
||||
},
|
||||
typeManager: {
|
||||
title: 'Training Type Manager',
|
||||
name: 'Type Name',
|
||||
fields: 'Fields',
|
||||
coverage: 'Contributes to Coverage',
|
||||
coverageLabel: 'Coverage',
|
||||
add: '+ Add Training Type',
|
||||
search: 'Search training type name / ID',
|
||||
edit: 'Edit',
|
||||
delete: 'Delete',
|
||||
},
|
||||
exerciseManager: {
|
||||
title: 'Exercise Manager',
|
||||
name: 'Exercise Name',
|
||||
category: 'Type',
|
||||
primaryMuscles: 'Primary',
|
||||
secondaryMuscles: 'Secondary',
|
||||
add: '+ Add Exercise',
|
||||
search: 'Search exercise name / ID',
|
||||
edit: 'Edit',
|
||||
delete: 'Delete',
|
||||
},
|
||||
statManager: {
|
||||
title: 'Stat Manager',
|
||||
add: '+ Add Stat',
|
||||
search: 'Search stat name',
|
||||
edit: 'Edit',
|
||||
delete: 'Delete',
|
||||
name: 'Stat Name',
|
||||
types: 'Associated Types',
|
||||
none: 'None',
|
||||
formula: 'Formula',
|
||||
mode: 'Mode',
|
||||
builder: 'Builder',
|
||||
expression: 'Expression',
|
||||
op: 'Operation',
|
||||
opSum: 'Sum',
|
||||
opProductSum: 'Product Sum',
|
||||
opOneRepMax: '1RM Est. (Epley)',
|
||||
opAvg: 'Average',
|
||||
opMax: 'Max',
|
||||
opMin: 'Min',
|
||||
opCount: 'Count',
|
||||
field: 'Field',
|
||||
fieldA: 'Field A',
|
||||
fieldB: 'Field B',
|
||||
weightField: 'Weight Field',
|
||||
repsField: 'Reps Field',
|
||||
preview: 'Expression Preview',
|
||||
allowedFields: 'Allowed Fields',
|
||||
granularity: 'Granularity',
|
||||
daily: 'Daily',
|
||||
weekly: 'Weekly',
|
||||
monthly: 'Monthly',
|
||||
enabled: 'Enabled',
|
||||
showInStats: 'Show in stats',
|
||||
countHint: 'count() needs no field',
|
||||
noTypes: 'No training types yet. Add one under "Training Types" first.',
|
||||
selectField: 'Please select a field',
|
||||
exprPlaceholder: 'e.g. sum(reps * weight)',
|
||||
exprError: 'Invalid expression: ',
|
||||
},
|
||||
stat: {
|
||||
title: 'Add Stat',
|
||||
editTitle: 'Edit Stat',
|
||||
nameRequired: 'Please enter a stat name',
|
||||
typesRequired: 'Please associate at least one training type',
|
||||
selectField: 'Please select a field',
|
||||
saved: 'Stat saved',
|
||||
},
|
||||
},
|
||||
|
||||
settings: {
|
||||
title: 'Training Plan Settings',
|
||||
chinese: '中文',
|
||||
english: 'English',
|
||||
dataPath: 'Data File Locations',
|
||||
csvDirectory: 'Training Log CSV Directory',
|
||||
csvDirectoryDesc: 'Directory for workout_logs.csv',
|
||||
configDirectory: 'Aggregate Config JSON Directory',
|
||||
configDirectoryDesc: 'Directory for workout-config.json',
|
||||
dataDirectoryPlaceholder: 'Leave empty for vault root',
|
||||
browse: 'Browse',
|
||||
trainingSettings: 'Training Settings',
|
||||
dragToReorder: 'Drag to reorder',
|
||||
moveUp: 'Move up',
|
||||
moveDown: 'Move down',
|
||||
trainingTypes: 'Training Types',
|
||||
exercises: 'Exercises',
|
||||
muscles: 'Muscles',
|
||||
typeManager: 'Training Type Manager',
|
||||
exerciseManager: 'Exercise Manager',
|
||||
muscleManager: 'Muscle Manager',
|
||||
openManager: 'Open Manager',
|
||||
totalTypes: 'Total',
|
||||
totalExercises: 'Total',
|
||||
totalMuscles: 'Total',
|
||||
totalPlans: 'Total',
|
||||
noMuscles: 'No muscles yet. Click the button below to add.',
|
||||
noTypes: 'No training types yet. Click the button below to add.',
|
||||
noExercises: 'No exercises yet. Click the button below to add.',
|
||||
noFields: 'No fields yet. Click the button below to add.',
|
||||
statistics: 'Statistics',
|
||||
statisticsManager: 'Stat Manager',
|
||||
totalStatistics: 'Total',
|
||||
noStatistics: 'No statistics yet. Click the button below to add.',
|
||||
atLeastOneField: 'Training type requires at least 1 field',
|
||||
confirmDeleteType: 'Confirm delete training type',
|
||||
confirmDeleteExercise: 'Confirm delete exercise',
|
||||
confirmDeleteExerciseRecords: 'Delete exercise "{name}"? Its {count} associated training record(s) will also be permanently removed.',
|
||||
confirmDelete: 'Are you sure',
|
||||
muscleReferencedBy: 'This muscle is referenced by',
|
||||
typeReferencedBy: 'This training type is referenced by',
|
||||
days: 'days',
|
||||
none: 'None',
|
||||
reminderThreshold: 'Reminder Threshold',
|
||||
reminderThresholdDays: 'Days since last training threshold',
|
||||
reminderThresholdDesc: 'Muscles not trained for more than this number of days will be marked as reminder',
|
||||
unit: 'Unit',
|
||||
language: 'Language',
|
||||
integrations: 'Integrations',
|
||||
dataview: 'Dataview',
|
||||
dailyNotes: 'Daily Notes',
|
||||
templater: 'Templater',
|
||||
lastValueMemory: 'Last Value Memory',
|
||||
trainingPlans: {
|
||||
title: 'Training Plans',
|
||||
add: 'New Training Plan',
|
||||
manage: 'Manage Training Plans',
|
||||
search: 'Search training plan name',
|
||||
total: 'Total',
|
||||
schedule: 'Schedule',
|
||||
itemCount: 'Exercises',
|
||||
source: 'Source',
|
||||
manual: 'Manual',
|
||||
edit: 'Edit',
|
||||
delete: 'Delete',
|
||||
noPlans: 'No training plans yet',
|
||||
confirmDelete: 'Delete training plan "{name}"? Existing training records will not be removed.',
|
||||
},
|
||||
maintenance: 'Data Maintenance',
|
||||
compactCsv: 'Compact & Clean CSV',
|
||||
compactCsvDesc: 'Deletion uses soft-delete (marked, not rewritten — instant). This compacts the whole file, permanently removing deleted records and reclaiming space.',
|
||||
confirmCompact: 'Compact & clean the CSV? This permanently removes all deleted records.',
|
||||
compactDone: 'Compacted: removed {n} deleted record(s)',
|
||||
softDeleteHint: 'Record deleted (soft-delete). To reclaim file space, run "Compact & Clean CSV" in settings.',
|
||||
general: 'General Settings',
|
||||
on: 'On',
|
||||
off: 'Off',
|
||||
add: '+ Add',
|
||||
edit: 'Edit',
|
||||
delete: 'Delete',
|
||||
},
|
||||
|
||||
common: {
|
||||
cancel: 'Cancel',
|
||||
close: 'Close',
|
||||
ok: 'OK',
|
||||
save: 'Save',
|
||||
edit: 'Edit',
|
||||
delete: 'Delete',
|
||||
add: 'Add',
|
||||
noMatch: 'No matching results',
|
||||
name: 'Name',
|
||||
type: 'Type',
|
||||
},
|
||||
};
|
||||
85
src/i18n/index.ts
Normal file
85
src/i18n/index.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { zh } from './zh';
|
||||
import { en } from './en';
|
||||
|
||||
/*
|
||||
* index.ts —— i18n(国际化)核心模块
|
||||
* 职责:统一管理「当前语言」并提供 t() 取文案的函数。
|
||||
* 工作流程:setLocale() 切换 zh/en 并记录当前字典 → 任意界面调用 t('key.path') 取文字。
|
||||
* 字典本体在 zh.ts(中文)和 en.ts(英文)。本文件不直接存文字,只负责「按 key 找文字」。
|
||||
*/
|
||||
|
||||
// 由 zh 字典的顶层 key 推导出语言种类类型('zh' | 'en' 的等价别名),仅用于类型约束
|
||||
export type LocaleKey = keyof typeof zh;
|
||||
|
||||
// 当前语言,默认中文
|
||||
let currentLocale: 'zh' | 'en' = 'zh';
|
||||
// 当前使用的字典对象(zh 或 en),默认指向中文字典
|
||||
let currentDict = zh;
|
||||
|
||||
// 切换语言:把全局变量改成指定的字典,后续 t() 就会从新字典取文字
|
||||
export function setLocale(locale: 'zh' | 'en') {
|
||||
currentLocale = locale;
|
||||
currentDict = locale === 'zh' ? zh : en;
|
||||
}
|
||||
|
||||
// 获取当前语言(其他地方需要按语言做格式化的判断时会用到,例如 duration.ts)
|
||||
export function getLocale(): 'zh' | 'en' {
|
||||
return currentLocale;
|
||||
}
|
||||
|
||||
// 获取某个 i18n key 在全部支持语言(zh / en)中的翻译结果,用于多语言反向查找。
|
||||
// 例如 getAllTranslations('exercise.squat') 会返回 ['深蹲', 'Squat']。
|
||||
export function getAllTranslations(key: string): string[] {
|
||||
const values = new Set<string>();
|
||||
for (const dict of [zh, en]) {
|
||||
const keys = key.split('.');
|
||||
let value: unknown = dict;
|
||||
for (const k of keys) {
|
||||
if (typeof value === 'object' && value !== null && k in value) {
|
||||
value = (value as Record<string, unknown>)[k];
|
||||
} else {
|
||||
value = undefined;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (typeof value === 'string' && value) {
|
||||
values.add(value);
|
||||
}
|
||||
}
|
||||
return Array.from(values);
|
||||
}
|
||||
|
||||
// 核心取文案函数:t('codeblock.edit') 返回当前语言里对应的文字
|
||||
// 第二个参数 params 用于替换文案里的占位符,例如 t('codeblock.addRecord', { exercise: '深蹲' })
|
||||
// 会把文案里的 {exercise} 替换成 '深蹲'
|
||||
export function t(key: string, params?: Record<string, string>): string {
|
||||
// 把 'a.b.c' 这样的路径按点号拆成数组 ['a','b','c'],便于一层层往下找
|
||||
const keys = key.split('.');
|
||||
let value: unknown = currentDict;
|
||||
|
||||
// 逐层进入嵌套对象:先 currentDict['a'],再 ['b'],再 ['c']
|
||||
for (const k of keys) {
|
||||
// 当前层是对象且存在该 key 时,继续往里走;否则说明 key 写错了
|
||||
if (typeof value === 'object' && value !== null && k in value) {
|
||||
value = (value as Record<string, unknown>)[k];
|
||||
} else {
|
||||
// 找不到就原样返回 key 本身,方便在界面上立刻发现「文案缺失」
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
// 最终取到的值必须是字符串才算成功,不是字符串也原样返回 key
|
||||
if (typeof value !== 'string') {
|
||||
return key;
|
||||
}
|
||||
|
||||
// 有占位符参数时,做正则替换
|
||||
// 正则 /{(\w+)}/g 匹配所有 {...} 占位符,(\w+) 捕获花括号里的名字
|
||||
// 例如 {exercise} 被替换成 params.exercise 的值;若 params 里没有该参数则保留 {exercise} 原样
|
||||
if (params) {
|
||||
return value.replace(/{(\w+)}/g, (_, paramName) => params[paramName] || `{${paramName}}`);
|
||||
}
|
||||
|
||||
// 没有占位符,直接返回文案
|
||||
return value;
|
||||
}
|
||||
484
src/i18n/zh.ts
Normal file
484
src/i18n/zh.ts
Normal file
|
|
@ -0,0 +1,484 @@
|
|||
/*
|
||||
* zh.ts —— 中文文案字典(i18n 中文包)
|
||||
* 这个文件存放插件所有中文界面文字。它是个嵌套的对象(object),
|
||||
* 用「点号路径」作为 key,例如 common.save 对应下面的 common → save。
|
||||
* 这些 key 与 index.ts 里的 t('key.path') 一一对应:t() 按当前语言来这里取文字。
|
||||
* 想新增/修改某条中文文案,直接在本文件对应位置改即可,无需碰其他文件。
|
||||
* 注意:英文版在 en.ts,两边 key 结构必须保持一致。
|
||||
*/
|
||||
|
||||
export const zh = {
|
||||
pluginName: '训练块',
|
||||
pluginDescription: '轻量、数据自有、可自定义的训练记录插件',
|
||||
|
||||
command: {
|
||||
recordSet: '记录训练',
|
||||
newPlan: '新增训练计划',
|
||||
newExercise: '新建训练项',
|
||||
newType: '新建训练类型',
|
||||
settings: '设置',
|
||||
insertCodeblock: '插入代码块',
|
||||
},
|
||||
|
||||
type: {
|
||||
strength: '力量训练',
|
||||
aerobic: '有氧训练',
|
||||
bodyweight: '自重训练',
|
||||
},
|
||||
|
||||
exercise: {
|
||||
squat: '深蹲',
|
||||
deadlift: '硬拉',
|
||||
bench_press: '卧推',
|
||||
pushup: '俯卧撑',
|
||||
pullup: '引体向上',
|
||||
plank: '平板支撑',
|
||||
running: '跑步',
|
||||
jumping_rope: '跳绳',
|
||||
cycling: '骑行',
|
||||
// —— 新增(2026-07-14 大改)——
|
||||
dumbbell_bench_press: '哑铃卧推',
|
||||
incline_dumbbell_press: '上斜哑铃卧推',
|
||||
close_grip_bench_press: '窄距卧推',
|
||||
dip: '臂屈伸',
|
||||
dumbbell_press: '哑铃推举',
|
||||
dumbbell_lateral_raise: '哑铃侧平举',
|
||||
barbell_front_raise: '杠铃前平举',
|
||||
barbell_push_press: '杠铃推举',
|
||||
barbell_lying_triceps: '杠铃仰卧臂屈伸',
|
||||
dumbbell_overhead_triceps: '哑铃颈后臂屈伸',
|
||||
barbell_row: '杠铃划船',
|
||||
dumbbell_row: '哑铃划船',
|
||||
dumbbell_reverse_fly: '哑铃俯身飞鸟',
|
||||
cable_face_pull: '绳索面拉',
|
||||
barbell_upright_row: '杠铃提拉',
|
||||
barbell_curl: '杠铃弯举',
|
||||
dumbbell_curl: '哑铃弯举',
|
||||
lunge: '箭步蹲',
|
||||
romanian_deadlift: '直腿硬拉',
|
||||
hanging_leg_raise: '悬垂举腿',
|
||||
ab_crunch: '平板卷腹',
|
||||
},
|
||||
|
||||
muscle: {
|
||||
chest: '胸大肌',
|
||||
front_delt: '三角肌前束',
|
||||
biceps: '肱二头',
|
||||
quads: '股四头',
|
||||
front_calf: '小腿前侧',
|
||||
abs: '腹肌',
|
||||
lats: '背阔肌',
|
||||
traps: '斜方肌',
|
||||
rear_delt: '三角肌后束',
|
||||
triceps: '肱三头',
|
||||
hamstrings: '腘绳肌',
|
||||
glutes: '臀肌',
|
||||
back_calf: '小腿后侧',
|
||||
},
|
||||
|
||||
field: {
|
||||
weight: '重量',
|
||||
reps: '次数',
|
||||
bodyweight: '体重',
|
||||
duration_sec: '时长',
|
||||
distance: '距离',
|
||||
},
|
||||
|
||||
unit: {
|
||||
kg: 'kg',
|
||||
lb: 'lb',
|
||||
},
|
||||
|
||||
duration: {
|
||||
hour: '时',
|
||||
minute: '分',
|
||||
second: '秒',
|
||||
},
|
||||
|
||||
fieldInputType: {
|
||||
number: '数字',
|
||||
duration: '时长',
|
||||
text: '文本',
|
||||
select: '下拉选择',
|
||||
},
|
||||
|
||||
fieldUnit: {
|
||||
none: '无',
|
||||
mass: '质量',
|
||||
custom: '自定义',
|
||||
},
|
||||
|
||||
codeblock: {
|
||||
addRecord: '为“{exercise}”添加记录',
|
||||
edit: '编辑',
|
||||
delete: '删除',
|
||||
confirmDelete: '确定删除这条记录吗?',
|
||||
date: '日期',
|
||||
note: '备注',
|
||||
actions: '操作',
|
||||
totalSets: '总组数',
|
||||
maxWeight: '最高重量',
|
||||
noRecords: '“{exercise}”暂无记录,点击上方按钮开始添加。',
|
||||
notSpecified: '未指定',
|
||||
plan: {
|
||||
select: '选择计划',
|
||||
noPlan: '暂无训练计划,请先通过「新增训练计划」创建',
|
||||
selectPlaceholder: '— 请选择计划 —',
|
||||
progress: '进度 {done}/{total}',
|
||||
completed: '已完成',
|
||||
complete: '完成',
|
||||
edit: '编辑',
|
||||
editTitle: '编辑 · {exercise} · 第 {n} 组',
|
||||
emptyFields: '(未设置预设值)',
|
||||
},
|
||||
day: {
|
||||
viewing: '查看日期:{date}',
|
||||
project: '项目',
|
||||
statValue: '数据统计值',
|
||||
primaryMuscles: '主肌群',
|
||||
secondaryMuscles: '辅助肌群',
|
||||
plan: '训练方案',
|
||||
pinToday: '固定为当日',
|
||||
empty: '“{date}” 暂无训练记录',
|
||||
unknown: '未知动作',
|
||||
pinFailed: '固定失败:无法定位代码块位置',
|
||||
},
|
||||
heatmap: {
|
||||
emptyAll: '暂无训练记录',
|
||||
emptyRange: '最近 {range} 暂无训练记录',
|
||||
noMapping: '肌肉尚未配置 SVG 映射,请在「肌肉管理」里套用默认/精简档预设后再查看热力图',
|
||||
lazy: '滚动到此处时自动渲染热力图…',
|
||||
legendLow: '轻',
|
||||
legendHigh: '重',
|
||||
},
|
||||
},
|
||||
|
||||
modal: {
|
||||
recordSet: {
|
||||
title: '记录训练',
|
||||
exercise: '训练项',
|
||||
searchExercisePlaceholder: '搜索训练项...',
|
||||
type: '训练类型',
|
||||
newExercise: '+ 新建',
|
||||
note: '备注(可选)',
|
||||
save: '保存',
|
||||
inputPlaceholder: '请输入',
|
||||
selectType: '请选择训练类型',
|
||||
requiredField: '为必填项',
|
||||
saved: '记录已保存',
|
||||
updated: '记录已更新',
|
||||
saveFailed: '保存失败',
|
||||
time: '时间',
|
||||
plan: '训练方案',
|
||||
scheme: '训练方案',
|
||||
planPlaceholder: '留空则不关联方案',
|
||||
noSchemeOption: '— 不关联方案 —',
|
||||
schemeNotFound: '已删除或重命名',
|
||||
exerciseLocked: '(编辑时不可修改)',
|
||||
noMatchingExercise: '无匹配的训练项',
|
||||
selectExercise: '请选择有效的训练项',
|
||||
},
|
||||
insertCodeblock: {
|
||||
title: '插入训练代码块',
|
||||
searchPlaceholder: '搜索代码块...',
|
||||
noMatch: '无匹配的代码块',
|
||||
paramTitle: '设置参数(全部可选,留空即用默认)',
|
||||
optional: '可选',
|
||||
skip: '跳过参数',
|
||||
insert: '插入到光标处',
|
||||
inserted: '已插入代码块到光标处',
|
||||
noEditor: '请先打开一个 Markdown 笔记',
|
||||
paramsCount: '{n} 个可选参数',
|
||||
},
|
||||
newPlan: {
|
||||
title: '新增训练计划',
|
||||
editTitle: '编辑训练计划',
|
||||
name: '计划名称',
|
||||
namePlaceholder: '请输入计划名称',
|
||||
defaultPrefix: '训练计划',
|
||||
nameRequired: '请输入计划名称',
|
||||
nameDuplicate: '计划名称已存在,请换一个',
|
||||
time: '计划时间',
|
||||
timeDate: '指定日期',
|
||||
timeWeek: '每周',
|
||||
date: '日期',
|
||||
weekdayHint: '每周 {days}',
|
||||
weekdayNone: '未选择星期',
|
||||
selectPlan: '选择训练方案',
|
||||
noScheme: '(手动添加 / 不关联方案)',
|
||||
selected: '已选 {x} / 共 {y}',
|
||||
addItem: '+ 添加训练项',
|
||||
removeItem: '移除',
|
||||
addSet: '+ 添加组',
|
||||
setName: '第 {n} 组',
|
||||
setSaved: '已保存',
|
||||
saved: '训练计划已保存',
|
||||
updated: '训练计划已更新',
|
||||
saveFailed: '保存失败',
|
||||
atLeastOne: '请至少选择一个训练项',
|
||||
noExercise: '暂无训练项,请先到「训练项」添加',
|
||||
},
|
||||
newExercise: {
|
||||
title: '新建训练项',
|
||||
editTitle: '编辑训练项',
|
||||
name: '名称',
|
||||
id: '训练项ID',
|
||||
idPlaceholder: '留空则自动取名称',
|
||||
idDuplicate: '训练项ID已存在,请换一个',
|
||||
idInvalid: 'ID 不能包含逗号、引号或换行',
|
||||
category: '所属训练类型',
|
||||
primaryMuscles: '主练',
|
||||
secondaryMuscles: '辅助',
|
||||
save: '保存',
|
||||
nameRequired: '请输入训练项名称',
|
||||
categoryRequired: '请选择训练类型',
|
||||
saved: '训练项已保存',
|
||||
updated: '训练项已更新',
|
||||
saveFailed: '保存失败',
|
||||
},
|
||||
newType: {
|
||||
title: '新建训练类型',
|
||||
editTitle: '编辑训练类型',
|
||||
name: '类型名称',
|
||||
id: '类别ID',
|
||||
idPlaceholder: '留空则自动取类型名称',
|
||||
idDuplicate: '类别ID已存在,请换一个',
|
||||
idInvalid: 'ID 不能包含逗号、引号或换行',
|
||||
fields: '字段列表',
|
||||
addField: '+ 添加字段',
|
||||
removeField: '- 移除',
|
||||
fieldKey: '字段键',
|
||||
fieldLabel: '字段标签',
|
||||
inputType: '输入类型',
|
||||
unit: '单位',
|
||||
customUnit: '单位文字',
|
||||
options: '选项(下拉)',
|
||||
optionsPlaceholder: '用英文逗号分隔,如:轻,中,重',
|
||||
save: '保存',
|
||||
nameRequired: '请输入类型名称',
|
||||
saved: '训练类型已保存',
|
||||
saveFailed: '保存失败',
|
||||
},
|
||||
editRecord: {
|
||||
title: '编辑记录',
|
||||
save: '保存',
|
||||
},
|
||||
muscleManager: {
|
||||
title: '肌肉管理',
|
||||
name: '名称',
|
||||
side: '部位',
|
||||
sideLabel: '部位',
|
||||
coverage: '计入覆盖',
|
||||
coverageLabel: '覆盖',
|
||||
idLabel: 'ID',
|
||||
add: '+ 新增肌肉',
|
||||
search: '搜索肌肉名称 / ID',
|
||||
edit: '编辑',
|
||||
editTitle: '编辑肌肉',
|
||||
delete: '删除',
|
||||
front: '正面',
|
||||
back: '背面',
|
||||
both: '双侧',
|
||||
idRequired: '请输入 ID',
|
||||
nameRequired: '请输入名称',
|
||||
saved: '肌肉已保存',
|
||||
saveFailed: '保存失败',
|
||||
deleted: '已删除',
|
||||
onboardingTitle: '首次使用肌肉映射',
|
||||
onboardingDesc: '请选择初始详细程度。之后仍可在编辑弹窗随时调整。',
|
||||
tierDefault: '默认(按健身群)',
|
||||
tierDefaultDesc: '每块肌肉映射到所属健身群的全部路径,最完整。',
|
||||
tierMinimal: '精简(主路径)',
|
||||
tierMinimalDesc: '每块肌肉只保留代表性主路径,图表更干净。',
|
||||
tierManual: '手动',
|
||||
tierManualDesc: '先导入空映射,由你逐个在编辑弹窗勾选。',
|
||||
mappingApplied: '已套用预设映射',
|
||||
reapplyPreset: '重新套用预设',
|
||||
restThreshold: '未练天数阈值',
|
||||
mappedPaths: '已映射路径',
|
||||
svgMapping: 'SVG 肌肉映射',
|
||||
searchPaths: '搜索 id / 中文 / 英文',
|
||||
clearMapping: '清空',
|
||||
applyGroupPreset: '套用健身群预设',
|
||||
selectedCount: '已选 {count} 块',
|
||||
noPaths: '没有匹配的路径',
|
||||
heatmapSettings: '热力图设置',
|
||||
heatmapMetric: '指标',
|
||||
followDefaultMetric: '跟随默认(组数)',
|
||||
heatmapRange: '时间窗',
|
||||
followDefaultRange: '跟随默认(7d)',
|
||||
customRange: '自定义日期区间',
|
||||
colorLevels: '颜色分级',
|
||||
colorHex: '十六进制颜色,如 #3b82f6',
|
||||
scaleHint: '颜色由轻到重,每档对应一个阈值:左侧为颜色(点色块可拾取,或直接填十六进制),右侧为阈值(≤)。分级数决定颜色档数量。',
|
||||
initScale: '用默认 4 档',
|
||||
scaleMaxHint: '≤',
|
||||
scaleUnbounded: '(无穷大)',
|
||||
},
|
||||
typeManager: {
|
||||
title: '训练类型管理',
|
||||
name: '类型名称',
|
||||
fields: '字段',
|
||||
coverage: '计入覆盖',
|
||||
coverageLabel: '覆盖',
|
||||
add: '+ 新增训练类型',
|
||||
search: '搜索训练类型名称 / ID',
|
||||
edit: '编辑',
|
||||
delete: '删除',
|
||||
},
|
||||
exerciseManager: {
|
||||
title: '训练项管理',
|
||||
name: '训练项名称',
|
||||
category: '所属类型',
|
||||
primaryMuscles: '主练',
|
||||
secondaryMuscles: '辅助',
|
||||
add: '+ 新增训练项',
|
||||
search: '搜索训练项名称 / ID',
|
||||
edit: '编辑',
|
||||
delete: '删除',
|
||||
},
|
||||
statManager: {
|
||||
title: '数据统计管理',
|
||||
add: '+ 新增统计',
|
||||
search: '搜索统计名称',
|
||||
edit: '编辑',
|
||||
delete: '删除',
|
||||
name: '统计名称',
|
||||
types: '关联训练类型',
|
||||
none: '无',
|
||||
formula: '公式',
|
||||
mode: '模式',
|
||||
builder: '引导式',
|
||||
expression: '表达式',
|
||||
op: '运算',
|
||||
opSum: '求和',
|
||||
opProductSum: '乘积求和',
|
||||
opOneRepMax: '1RM 估算(Epley)',
|
||||
opAvg: '平均',
|
||||
opMax: '最大',
|
||||
opMin: '最小',
|
||||
opCount: '计数',
|
||||
field: '字段',
|
||||
fieldA: '字段 A',
|
||||
fieldB: '字段 B',
|
||||
weightField: '重量字段',
|
||||
repsField: '次数字段',
|
||||
preview: '表达式预览',
|
||||
allowedFields: '可用字段',
|
||||
granularity: '时间粒度',
|
||||
daily: '每日',
|
||||
weekly: '每周',
|
||||
monthly: '每月',
|
||||
enabled: '启用',
|
||||
showInStats: '在统计中显示',
|
||||
countHint: 'count() 无需选择字段',
|
||||
noTypes: '暂无训练类型,请先到「训练类型」添加',
|
||||
selectField: '请选择字段',
|
||||
exprPlaceholder: '如 sum(reps * weight)',
|
||||
exprError: '表达式无效:',
|
||||
},
|
||||
stat: {
|
||||
title: '新增统计',
|
||||
editTitle: '编辑统计',
|
||||
nameRequired: '请输入统计名称',
|
||||
typesRequired: '请至少关联一个训练类型',
|
||||
selectField: '请选择字段',
|
||||
saved: '统计已保存',
|
||||
},
|
||||
},
|
||||
|
||||
settings: {
|
||||
title: '训练计划设置',
|
||||
chinese: '中文',
|
||||
english: 'English',
|
||||
dataPath: '数据文件位置',
|
||||
csvDirectory: '训练记录 CSV 目录',
|
||||
csvDirectoryDesc: 'workout_logs.csv 所在目录',
|
||||
configDirectory: '聚合配置 JSON 目录',
|
||||
configDirectoryDesc: 'workout-config.json 所在目录',
|
||||
dataDirectoryPlaceholder: '留空表示根目录',
|
||||
browse: '浏览',
|
||||
trainingSettings: '训练设置',
|
||||
dragToReorder: '拖拽排序',
|
||||
moveUp: '上移',
|
||||
moveDown: '下移',
|
||||
trainingTypes: '训练类型',
|
||||
exercises: '训练项',
|
||||
muscles: '肌肉',
|
||||
typeManager: '训练类型管理',
|
||||
exerciseManager: '训练项管理',
|
||||
muscleManager: '肌肉管理',
|
||||
openManager: '打开管理',
|
||||
totalTypes: '总数',
|
||||
totalExercises: '总数',
|
||||
totalMuscles: '总数',
|
||||
totalPlans: '总数',
|
||||
noMuscles: '暂无肌肉配置,点击下方按钮新增。',
|
||||
noTypes: '暂无训练类型,点击下方按钮新增。',
|
||||
noExercises: '暂无训练项,点击下方按钮新增。',
|
||||
noFields: '暂无字段,点击下方按钮新增。',
|
||||
statistics: '数据统计',
|
||||
statisticsManager: '数据统计管理',
|
||||
totalStatistics: '总数',
|
||||
noStatistics: '暂无统计,点击下方按钮新增。',
|
||||
atLeastOneField: '训练类型至少需要 1 个字段',
|
||||
confirmDeleteType: '确定删除训练类型',
|
||||
confirmDeleteExercise: '确定删除训练项',
|
||||
confirmDeleteExerciseRecords: '确定删除训练项「{name}」吗?它关联的 {count} 条训练记录也会被一并删除,且无法恢复。',
|
||||
confirmDelete: '确定删除吗',
|
||||
muscleReferencedBy: '该肌肉已被以下训练项引用',
|
||||
typeReferencedBy: '该训练类型已被以下训练项引用',
|
||||
days: '天',
|
||||
none: '无',
|
||||
reminderThreshold: '提醒阈值',
|
||||
reminderThresholdDays: '未练天数阈值',
|
||||
reminderThresholdDesc: '超过此天数未练的肌肉将被标记为提醒',
|
||||
unit: '单位',
|
||||
language: '语言',
|
||||
integrations: '联动开关',
|
||||
dataview: 'Dataview',
|
||||
dailyNotes: 'Daily Notes',
|
||||
templater: 'Templater',
|
||||
lastValueMemory: '上次值记忆',
|
||||
trainingPlans: {
|
||||
title: '训练计划',
|
||||
add: '新增训练计划',
|
||||
manage: '训练计划管理',
|
||||
search: '搜索训练计划名称',
|
||||
total: '总数',
|
||||
schedule: '计划时间',
|
||||
itemCount: '训练项数',
|
||||
source: '来源',
|
||||
manual: '手动创建',
|
||||
edit: '编辑',
|
||||
delete: '删除',
|
||||
noPlans: '暂无训练计划',
|
||||
confirmDelete: '确定删除训练计划「{name}」吗?这不会删除已产生的训练记录。',
|
||||
},
|
||||
maintenance: '数据维护',
|
||||
compactCsv: '压缩清理 CSV',
|
||||
compactCsvDesc: '删除记录采用软删除(仅标记、不重写文件,删除瞬时完成)。此操作将整文件压缩,永久移除被删记录并释放体积。',
|
||||
confirmCompact: '确定要压缩清理 CSV 吗?这将永久移除所有已删除的记录。',
|
||||
compactDone: '已压缩清理,移除 {n} 条被删记录',
|
||||
softDeleteHint: '记录已删除(软删除);如需彻底清理文件体积,可到设置页「压缩清理 CSV」。',
|
||||
general: '通用设置',
|
||||
on: '开',
|
||||
off: '关',
|
||||
add: '+ 添加',
|
||||
edit: '编辑',
|
||||
delete: '删除',
|
||||
},
|
||||
|
||||
common: {
|
||||
cancel: '取消',
|
||||
close: '关闭',
|
||||
ok: '确定',
|
||||
save: '保存',
|
||||
edit: '编辑',
|
||||
delete: '删除',
|
||||
add: '添加',
|
||||
noMatch: '没有匹配的结果',
|
||||
name: '名称',
|
||||
type: '类型',
|
||||
},
|
||||
};
|
||||
333
src/main.ts
Normal file
333
src/main.ts
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
import { Plugin, Notice } from 'obsidian';
|
||||
import { DataManager } from './data/DataManager';
|
||||
import { getExerciseNameById } from './data/display';
|
||||
import { setLocale, t } from './i18n';
|
||||
import { registerCodeBlock, rerenderAllBlocks, rerenderBlocksForExercise, rerenderBlocksByType, setRegistryApp } from './codeblock/registry';
|
||||
import { renderWorkoutLog } from './codeblock/workoutLog';
|
||||
import { renderWorkoutDay } from './codeblock/workoutDay';
|
||||
import { renderWorkoutHeatmap } from './codeblock/workoutHeatmap';
|
||||
import { renderWorkoutPlan } from './codeblock/workoutPlan';
|
||||
import { LogRow, FieldDef } from './data/types';
|
||||
import { applyMappingTier } from './data/muscleMapping';
|
||||
import { RecordModal } from './ui/RecordModal';
|
||||
import { confirmWithModal } from './ui/Confirm';
|
||||
import { NewPlanModal } from './ui/NewPlanModal';
|
||||
import { ExerciseModal } from './ui/ExerciseModal';
|
||||
import { TypeModal } from './ui/TypeModal';
|
||||
import { SettingsTab } from './ui/SettingsTab';
|
||||
import { InsertCodeBlockModal } from './ui/InsertCodeBlockModal';
|
||||
|
||||
/*
|
||||
* main.ts —— 插件入口文件(核心枢纽)
|
||||
* 本文件定义了 WorkoutPlugin 类,它继承自 Obsidian 的 Plugin 基类。
|
||||
* 插件的"生命周期"(加载、卸载)和各种功能入口(命令、侧边栏图标、
|
||||
* 代码块渲染、设置页、视图、事件监听)都在这里集中注册。
|
||||
* 可以把它理解成一座"调度中心":它自己不画界面,而是负责把各个
|
||||
* 子模块(数据管理、弹窗、代码块、视图)连接起来并对外暴露功能。
|
||||
*/
|
||||
export default class WorkoutPlugin extends Plugin {
|
||||
// 数据管理器:负责读写训练记录 CSV、配置文件、语言设置等。几乎所有功能都依赖它。
|
||||
private dataManager!: DataManager;
|
||||
// 设置页对象(Obsidian 的 SettingTab),可能为空(尚未创建)。
|
||||
private settingsTab: SettingsTab | null = null;
|
||||
|
||||
// 插件加载时由 Obsidian 自动调用。这是初始化所有功能的入口。
|
||||
async onload(): Promise<void> {
|
||||
// 1) 创建并初始化数据管理器(读取/准备 CSV 与配置文件)
|
||||
this.dataManager = new DataManager(this);
|
||||
await this.dataManager.init();
|
||||
|
||||
// 1.1) 把 App 引用注入代码块注册表,供重渲染后恢复编辑器焦点(修复光标丢失)
|
||||
setRegistryApp(this.app);
|
||||
|
||||
// 2) 根据配置文件里的语言设置,切换 i18n 当前语言(t() 之后会返回对应语言文案)
|
||||
const settings = this.dataManager.getSettings();
|
||||
setLocale(settings.language);
|
||||
|
||||
// 2.1) 旧版本迁移:若用户从未打开过肌肉管理引导,且存在 svgRegionIds 为空的默认肌肉,
|
||||
// 自动套用「default」档映射,使热力图开箱即用,避免用户困惑「热力图无颜色」。
|
||||
await this.ensureMuscleMappingInitialized();
|
||||
|
||||
// 3) 依次注册插件的各个组成部分
|
||||
this.registerCommands(); // 命令面板里的命令(如"记录一组""记录训练方案")
|
||||
this.registerRibbon(); // 左侧栏的图标按钮
|
||||
this.registerCodeBlocks(); // ```workout-log 代码块的接管渲染
|
||||
this.registerSettingsTab(); // 设置页
|
||||
this.registerEventListeners(); // 数据变化 / 文件修改时的自动刷新
|
||||
|
||||
// 移动端适配:在 body 注入 .is-mobile 根类,供 styles.css 中所有 `.is-mobile` 作用域规则生效。
|
||||
// 弹窗 / 代码块 / 设置页 DOM 均在 body 之下,一处注入即可全局覆盖;桌面端不注入,零回归。
|
||||
if (this.app.isMobile) document.body.classList.add('is-mobile');
|
||||
|
||||
console.log('Workout plugin loaded');
|
||||
}
|
||||
|
||||
// 旧版本兼容:为尚未初始化肌肉映射的存量配置自动套用默认映射。
|
||||
// 仅在 muscleMappingInitialized === false 且存在空 svgRegionIds 时执行一次,
|
||||
// 完成后立即标记 true,避免重复迁移或覆盖用户已手动配置的映射。
|
||||
private async ensureMuscleMappingInitialized(): Promise<void> {
|
||||
const settings = this.dataManager.getSettings();
|
||||
if (settings.muscleMappingInitialized) return;
|
||||
const config = await this.dataManager.getConfig();
|
||||
const hasEmpty = config.muscles.some((m) => (m.svgRegionIds?.length ?? 0) === 0);
|
||||
if (!hasEmpty) {
|
||||
// 所有肌肉已有映射(可能是新装用户用预填默认值),仅补标记位
|
||||
settings.muscleMappingInitialized = true;
|
||||
await this.dataManager.saveSettings();
|
||||
return;
|
||||
}
|
||||
applyMappingTier(config, 'default');
|
||||
await this.dataManager.saveConfig(config);
|
||||
settings.muscleMappingInitialized = true;
|
||||
await this.dataManager.saveSettings();
|
||||
console.log('[workout-block] 已自动套用默认肌肉映射(热力图开箱即用)');
|
||||
}
|
||||
|
||||
// 插件卸载(禁用/重载)时调用。这里只需做简单的清理日志;子模块会在 Obsidian 内部被自动回收。
|
||||
onunload(): void {
|
||||
document.body.classList.remove('is-mobile');
|
||||
console.log('Workout plugin unloaded');
|
||||
}
|
||||
|
||||
// 注册命令面板中的命令。每个 addCommand 会在 Obsidian 命令面板(Ctrl/Cmd+P)里出现一条。
|
||||
// callback 指定点击该命令时执行的函数(这里都转发给对应的 openXxx 方法)。
|
||||
private registerCommands(): void {
|
||||
// "记录一组"命令:打开单条记录录入弹窗
|
||||
this.addCommand({
|
||||
id: 'workout-record-set',
|
||||
name: t('command.recordSet'),
|
||||
icon: 'dumbbell',
|
||||
callback: () => this.openRecordModal(),
|
||||
});
|
||||
|
||||
// "新增训练计划"命令:打开配置弹窗,从已有方案或手动添加训练项并预设每组字段
|
||||
this.addCommand({
|
||||
id: 'workout-record-plan',
|
||||
name: t('command.newPlan'),
|
||||
icon: 'list-checks',
|
||||
callback: () => this.openNewPlanModal(),
|
||||
});
|
||||
|
||||
// "新建训练项"命令:打开训练项(动作)新建/编辑弹窗
|
||||
this.addCommand({
|
||||
id: 'workout-new-exercise',
|
||||
name: t('command.newExercise'),
|
||||
icon: 'plus-circle',
|
||||
callback: () => this.openNewExerciseModal(),
|
||||
});
|
||||
|
||||
// "新建训练类型"命令:打开训练类型(带字段定义)新建/编辑弹窗
|
||||
this.addCommand({
|
||||
id: 'workout-new-type',
|
||||
name: t('command.newType'),
|
||||
icon: 'layers',
|
||||
callback: () => this.openNewTypeModal(),
|
||||
});
|
||||
|
||||
// "设置"命令:打开插件设置页
|
||||
this.addCommand({
|
||||
id: 'workout-settings',
|
||||
name: t('command.settings'),
|
||||
icon: 'settings',
|
||||
callback: () => this.openSettings(),
|
||||
});
|
||||
}
|
||||
|
||||
// 注册左侧边栏(ribbon)的图标按钮。点击后同样打开单条记录录入弹窗。
|
||||
private registerRibbon(): void {
|
||||
this.addRibbonIcon('dumbbell', t('command.recordSet'), () => {
|
||||
this.openRecordModal();
|
||||
});
|
||||
this.addRibbonIcon('code', t('command.insertCodeblock'), () => {
|
||||
this.openInsertCodeBlockModal();
|
||||
});
|
||||
}
|
||||
|
||||
// 注册 ```workout-log 代码块的接管渲染逻辑。
|
||||
// 当用户笔记里出现 ```workout-log ... ``` 代码块时,Obsidian 会把代码块内容
|
||||
// 交给这里的处理函数,由插件渲染成带交互(点击记录/编辑/删除)的表格。
|
||||
private registerCodeBlocks(): void {
|
||||
// codeBlockHandler 是真正的渲染逻辑:读取所有记录、单位、配置,然后交给 renderWorkoutLog 渲染。
|
||||
// source: 代码块的原始文本(用户写在 ``` 之间的内容)
|
||||
// el: 插件用来往页面里插入渲染结果的 DOM 容器
|
||||
// ctx: Obsidian 的渲染上下文(一般无需关心)
|
||||
const codeBlockHandler = (source: string, el: HTMLElement, ctx: any) => {
|
||||
const logs = this.dataManager.getLogs();
|
||||
const unit = this.dataManager.getSettings().unit;
|
||||
this.dataManager.getConfig().then((config) => {
|
||||
// 根据训练类型(category)找到该类型下定义的所有字段(FieldDef[]),用于表格列展示
|
||||
const getTrainingTypeFields = (category: string): FieldDef[] => {
|
||||
const type = config.trainingTypes.find((tt) => tt.id === category);
|
||||
return type?.fields || [];
|
||||
};
|
||||
|
||||
// 调用真正渲染代码块的函数,并传入若干回调(点击行时打开哪个弹窗)
|
||||
renderWorkoutLog(
|
||||
source,
|
||||
el,
|
||||
ctx,
|
||||
logs,
|
||||
getTrainingTypeFields,
|
||||
unit,
|
||||
config,
|
||||
(exercise, plan) => this.openRecordModal(exercise, plan), // 点击"记录"按钮
|
||||
(log) => this.openEditRecordModal(log), // 点击"编辑"
|
||||
(log) => this.deleteRecord(log) // 点击"删除"
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// 把上面这个处理函数登记到代码块注册表(供后续 rerenderAllBlocks 统一重渲染使用)
|
||||
registerCodeBlock('workout-log', codeBlockHandler);
|
||||
|
||||
// 官方方式:告诉 Obsidian,workout-log 这个代码块类型由我们接管渲染
|
||||
this.registerMarkdownCodeBlockProcessor('workout-log', async (source, el, ctx) => {
|
||||
codeBlockHandler(source, el, ctx);
|
||||
});
|
||||
|
||||
// 同样的模式注册 workout-day 代码块(当日训练总览表)。
|
||||
const dayHandler = (source: string, el: HTMLElement, ctx: any) => {
|
||||
const logs = this.dataManager.getLogs();
|
||||
this.dataManager.getConfig().then((config) => {
|
||||
renderWorkoutDay(source, el, ctx, this.app, logs, config);
|
||||
});
|
||||
};
|
||||
registerCodeBlock('workout-day', dayHandler);
|
||||
this.registerMarkdownCodeBlockProcessor('workout-day', async (source, el, ctx) => {
|
||||
dayHandler(source, el, ctx);
|
||||
});
|
||||
|
||||
// 注册 workout-heatmap 代码块(全身肌肉热力图)
|
||||
const heatmapHandler = (source: string, el: HTMLElement, ctx: any) => {
|
||||
const logs = this.dataManager.getLogs();
|
||||
this.dataManager.getConfig().then((config) => {
|
||||
renderWorkoutHeatmap(source, el, ctx, logs, config);
|
||||
});
|
||||
};
|
||||
registerCodeBlock('workout-heatmap', heatmapHandler);
|
||||
this.registerMarkdownCodeBlockProcessor('workout-heatmap', async (source, el, ctx) => {
|
||||
heatmapHandler(source, el, ctx);
|
||||
});
|
||||
|
||||
// 注册 workout-plan 代码块(训练计划完成面板)。无 plan 参数时渲染「选择计划」下拉,
|
||||
// 选中后写回代码块;有 plan 时渲染完成面板(每组 [编辑][完成] + 进度)。
|
||||
const planHandler = (source: string, el: HTMLElement, ctx: any) => {
|
||||
renderWorkoutPlan(source, el, ctx, this.dataManager);
|
||||
};
|
||||
registerCodeBlock('workout-plan', planHandler);
|
||||
this.registerMarkdownCodeBlockProcessor('workout-plan', async (source, el, ctx) => {
|
||||
planHandler(source, el, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
// 注册设置页(Settings → 找到本插件)。SettingsTab 负责插件的可视化配置界面。
|
||||
private registerSettingsTab(): void {
|
||||
this.settingsTab = new SettingsTab(this.app, this, this.dataManager);
|
||||
this.addSettingTab(this.settingsTab);
|
||||
}
|
||||
|
||||
// 注册事件监听:当数据或配置变化时,自动重渲染相关 workout-log 代码块,保证页面显示最新数据。
|
||||
private registerEventListeners(): void {
|
||||
// 数据(记录)变化后:若知道是哪条记录变了,只重渲染显示该训练项的表格;
|
||||
// 否则(如批量导入)退化为全量重渲染。这样添加一条记录就不会重建所有表格。
|
||||
// 传入 config 是为了让 rerenderBlocksForExercise 按稳定的 exerciseId 匹配代码块,
|
||||
// 避免多语言下源码写中文名、记录存英文显示名时重渲染不到对应表格。
|
||||
this.dataManager.on('data-changed', async (data) => {
|
||||
const row = data?.row;
|
||||
if (row) {
|
||||
const config = await this.dataManager.getConfig();
|
||||
const name = row.exerciseId ? getExerciseNameById(config.exercises, row.exerciseId) : '';
|
||||
rerenderBlocksForExercise(config, row.exerciseId, name);
|
||||
} else {
|
||||
rerenderAllBlocks();
|
||||
}
|
||||
// workout-day 按日聚合所有训练项,任何一条记录变化都可能改变某日的展示,统一重渲染
|
||||
rerenderBlocksByType('workout-day');
|
||||
// 训练记录变化影响热力图数据,统一重渲染
|
||||
rerenderBlocksByType('workout-heatmap');
|
||||
});
|
||||
|
||||
// 配置(训练项/类型/语言)变化后,标签与字段都可能变,需要全量重渲染
|
||||
this.dataManager.on('config-changed', () => {
|
||||
rerenderAllBlocks();
|
||||
// 训练项/肌肉/统计配置变化同样影响 workout-day 的展示,统一重渲染
|
||||
rerenderBlocksByType('workout-day');
|
||||
// 肌肉映射与统计配置变化影响热力图,统一重渲染
|
||||
rerenderBlocksByType('workout-heatmap');
|
||||
});
|
||||
|
||||
// 单位/语言等设置变化后,单位相关的代码块(完成面板、记录表)需刷新以反映新单位
|
||||
this.dataManager.on('settings-changed', () => {
|
||||
rerenderBlocksByType('workout-plan');
|
||||
rerenderBlocksByType('workout-log');
|
||||
});
|
||||
|
||||
// 监听仓库文件被修改的事件:仅当用户「在插件之外」手动改了 CSV / 配置文件时才需要
|
||||
// 整体重读 + 重渲染。插件自身写盘期间(或刚写完后很短窗口内)selfWriting / wasSelfWrittenRecently
|
||||
// 为真,此时 data-changed 已经做了精准重渲染,这里直接跳过,避免重复开销与卡顿。
|
||||
// 注意:vault.on('modify') 在写盘完成后才异步派发,届时 selfWriting 已被复位为 false,
|
||||
// 故必须叠加 wasSelfWrittenRecently 时间戳兜底,否则自身删除/编辑写盘会触发整文件重读 +
|
||||
// 全量重渲染(含 320KB 肌肉 SVG 热力图),导致删除记录后 Obsidian 主线程卡死、无法立即编辑。
|
||||
this.app.vault.on('modify', async (file) => {
|
||||
const selfWritten = this.dataManager.isSelfWriting() || this.dataManager.wasSelfWrittenRecently();
|
||||
if (file.path === this.dataManager.getCsvPath()) {
|
||||
if (!selfWritten) {
|
||||
await this.dataManager.reloadLogs();
|
||||
rerenderAllBlocks();
|
||||
}
|
||||
}
|
||||
if (file.path === this.dataManager.getConfigPath()) {
|
||||
if (!selfWritten) {
|
||||
await this.dataManager.reloadConfig();
|
||||
rerenderAllBlocks();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 打开"插入代码块"弹窗:列出全部训练代码块,选择后带参插入到光标处。
|
||||
private openInsertCodeBlockModal(): void {
|
||||
new InsertCodeBlockModal(this.dataManager).open();
|
||||
}
|
||||
|
||||
// 打开"记录一组"弹窗。可选传入 exercise(预选训练项)和 plan(所属训练方案名)。
|
||||
private openRecordModal(exercise?: string, plan?: string): void {
|
||||
new RecordModal(this.dataManager, { exercise, plan }).open();
|
||||
}
|
||||
|
||||
// 打开"新增训练计划"弹窗(配置形态:选择方案 / 手动添加、预设每组字段、设定计划时间)
|
||||
private openNewPlanModal(): void {
|
||||
new NewPlanModal(this.dataManager).open();
|
||||
}
|
||||
|
||||
// 打开"新建训练项"弹窗
|
||||
private openNewExerciseModal(): void {
|
||||
new ExerciseModal(this.dataManager).open();
|
||||
}
|
||||
|
||||
// 打开"新建训练类型"弹窗
|
||||
private openNewTypeModal(): void {
|
||||
new TypeModal(this.dataManager).open();
|
||||
}
|
||||
|
||||
// 打开设置页:先打开 Obsidian 设置,再定位到本插件对应的标签页
|
||||
private openSettings(): void {
|
||||
this.app.setting.open();
|
||||
this.app.setting.openTabById(this.manifest.id);
|
||||
}
|
||||
|
||||
// 打开"编辑已有记录"弹窗:把要编辑的记录 editLog 传给 RecordModal
|
||||
private openEditRecordModal(log: LogRow): void {
|
||||
new RecordModal(this.dataManager, { editLog: log }).open();
|
||||
}
|
||||
|
||||
// 删除一条记录:先用浏览器原生 confirm 确认,再调用 dataManager 删除(dataManager 会触发 data-changed 重渲染)
|
||||
private async deleteRecord(log: LogRow): Promise<void> {
|
||||
if (!(await confirmWithModal(this.app, t('codeblock.confirmDelete')))) {
|
||||
return;
|
||||
}
|
||||
await this.dataManager.deleteLog(log.id);
|
||||
// 软删除:记录已即时从界面移除,但磁盘文件需到设置页「压缩清理 CSV」才真正回收体积。
|
||||
new Notice(t('settings.softDeleteHint'));
|
||||
}
|
||||
}
|
||||
270
src/test/obsidian-shim.ts
Normal file
270
src/test/obsidian-shim.ts
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
/*
|
||||
* obsidian-shim.ts(测试桩 / mock 文件)
|
||||
* 文件作用:用假的(fake)类模拟 Obsidian 的 API,让单元测试能在 Node/浏览器环境里运行,而不依赖真正的 Obsidian 软件。
|
||||
* 为什么需要它:单元测试跑在 vitest + jsdom 里,没有真实的 Obsidian 运行环境。vitest.config.ts 会把代码里 `import ... from 'obsidian'`
|
||||
* 这个导入"重定向"到本文件,于是测试使用的就是我们这里写的简化版类,而不是真实的 Obsidian。
|
||||
* 概念解释:"测试桩/打桩(mock/stub)"——用假对象替代外部依赖,使代码可独立、可重复地测试。
|
||||
*
|
||||
* 注意:这里只是"够测试用"的简化实现,不是 Obsidian 完整 API。
|
||||
*/
|
||||
|
||||
// 收集所有弹出的 Notice(通知)文字,供测试断言;这是真实 Obsidian 没有的测试辅助功能
|
||||
const notices: string[] = [];
|
||||
|
||||
// 测试可读取当前累计的通知列表
|
||||
export function __getNotices(): string[] {
|
||||
return notices;
|
||||
}
|
||||
|
||||
// 测试在每个用例前调用,清空通知记录,保证用例之间互不干扰
|
||||
export function __resetNotices(): void {
|
||||
notices.length = 0;
|
||||
}
|
||||
|
||||
// 模拟 Obsidian 的事件中心:可按事件名注册(on)、注销(off)、触发(trigger)回调
|
||||
export class Events {
|
||||
private handlers = new Map<string, Set<(...args: unknown[]) => void>>();
|
||||
|
||||
on(event: string, callback: (...args: unknown[]) => void): void {
|
||||
if (!this.handlers.has(event)) {
|
||||
this.handlers.set(event, new Set());
|
||||
}
|
||||
this.handlers.get(event)?.add(callback);
|
||||
}
|
||||
|
||||
off(event: string, callback: (...args: unknown[]) => void): void {
|
||||
this.handlers.get(event)?.delete(callback);
|
||||
}
|
||||
|
||||
trigger(event: string, ...args: unknown[]): void {
|
||||
for (const callback of this.handlers.get(event) ?? []) {
|
||||
callback(...args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// App:极简占位,真实插件里它代表整个 Obsidian 应用上下文;测试里通常不需要内容
|
||||
export class App {}
|
||||
|
||||
// Modal:模拟弹窗基类。contentEl 是一个真实 <div>,用于承载弹窗内容。
|
||||
export class Modal {
|
||||
app: any;
|
||||
contentEl: HTMLDivElement;
|
||||
|
||||
constructor(app: any) {
|
||||
this.app = app;
|
||||
this.contentEl = document.createElement('div');
|
||||
}
|
||||
|
||||
// 打开弹窗:若子类实现了 onOpen 就调用它(真实 Obsidian 还会把弹窗挂到界面上)
|
||||
open(): unknown {
|
||||
return (this as any).onOpen?.();
|
||||
}
|
||||
|
||||
// 关闭弹窗:若子类实现了 onClose 就调用它
|
||||
close(): unknown {
|
||||
return (this as any).onClose?.();
|
||||
}
|
||||
}
|
||||
|
||||
// 设置页基类:每个插件在"设置"里看到的页面都继承它
|
||||
export class PluginSettingTab {
|
||||
app: any;
|
||||
plugin: any;
|
||||
containerEl: HTMLDivElement;
|
||||
|
||||
constructor(app: any, plugin: any) {
|
||||
this.app = app;
|
||||
this.plugin = plugin;
|
||||
this.containerEl = document.createElement('div');
|
||||
}
|
||||
}
|
||||
|
||||
// 模拟"按钮"UI 组件:自动创建一个 <button> 并挂到容器里
|
||||
export class ButtonComponent {
|
||||
buttonEl: HTMLButtonElement;
|
||||
|
||||
constructor(containerEl: HTMLElement) {
|
||||
this.buttonEl = document.createElement('button');
|
||||
containerEl.appendChild(this.buttonEl);
|
||||
}
|
||||
|
||||
setButtonText(text: string): this {
|
||||
this.buttonEl.textContent = text;
|
||||
return this;
|
||||
}
|
||||
|
||||
// 真实 Obsidian 的 ButtonComponent 提供 setWarning():把按钮设为警告样式(红色)。
|
||||
// 这里只做占位,返回 this 以支持链式调用。
|
||||
setWarning(): this {
|
||||
this.buttonEl.classList.add('mod-warning');
|
||||
return this;
|
||||
}
|
||||
|
||||
onClick(callback: () => void): this {
|
||||
this.buttonEl.addEventListener('click', callback);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
// 模拟"单行文本输入框"组件:创建一个 <input>
|
||||
export class TextComponent {
|
||||
inputEl: HTMLInputElement;
|
||||
|
||||
constructor(containerEl: HTMLElement) {
|
||||
this.inputEl = document.createElement('input');
|
||||
containerEl.appendChild(this.inputEl);
|
||||
}
|
||||
|
||||
setPlaceholder(text: string): this {
|
||||
this.inputEl.placeholder = text;
|
||||
return this;
|
||||
}
|
||||
|
||||
setValue(value: string): this {
|
||||
this.inputEl.value = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
onChange(callback: (value: string) => void): this {
|
||||
this.inputEl.addEventListener('change', () => callback(this.inputEl.value));
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
// 模拟"下拉选择框"组件:创建一个 <select>
|
||||
export class DropdownComponent {
|
||||
selectEl: HTMLSelectElement;
|
||||
|
||||
constructor(containerEl: HTMLElement) {
|
||||
this.selectEl = document.createElement('select');
|
||||
containerEl.appendChild(this.selectEl);
|
||||
}
|
||||
|
||||
addOption(value: string, label: string): this {
|
||||
const option = document.createElement('option');
|
||||
option.value = value;
|
||||
option.textContent = label;
|
||||
this.selectEl.appendChild(option);
|
||||
return this;
|
||||
}
|
||||
|
||||
setValue(value: string): this {
|
||||
this.selectEl.value = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
onChange(callback: (value: string) => void): this {
|
||||
this.selectEl.addEventListener('change', () => callback(this.selectEl.value));
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
// 模拟"开关(复选框)"组件:创建一个 type=checkbox 的 <input>
|
||||
export class ToggleComponent {
|
||||
toggleEl: HTMLInputElement;
|
||||
|
||||
constructor(containerEl: HTMLElement) {
|
||||
this.toggleEl = document.createElement('input');
|
||||
this.toggleEl.type = 'checkbox';
|
||||
containerEl.appendChild(this.toggleEl);
|
||||
}
|
||||
|
||||
setValue(value: boolean): this {
|
||||
this.toggleEl.checked = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
onChange(callback: (value: boolean) => void): this {
|
||||
this.toggleEl.addEventListener('change', () => callback(this.toggleEl.checked));
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
// 模拟"设置项"组件:一行设置,左边是名称+描述(info),右边是控件(control)
|
||||
export class Setting {
|
||||
settingEl: HTMLDivElement;
|
||||
private infoEl: HTMLDivElement;
|
||||
private controlEl: HTMLDivElement;
|
||||
|
||||
constructor(containerEl: HTMLElement) {
|
||||
this.settingEl = document.createElement('div');
|
||||
this.infoEl = document.createElement('div');
|
||||
this.controlEl = document.createElement('div');
|
||||
this.settingEl.append(this.infoEl, this.controlEl);
|
||||
containerEl.appendChild(this.settingEl);
|
||||
}
|
||||
|
||||
setName(name: string): this {
|
||||
const nameEl = document.createElement('div');
|
||||
nameEl.textContent = name;
|
||||
this.infoEl.appendChild(nameEl);
|
||||
return this;
|
||||
}
|
||||
|
||||
setDesc(desc: string): this {
|
||||
const descEl = document.createElement('div');
|
||||
descEl.textContent = desc;
|
||||
this.infoEl.appendChild(descEl);
|
||||
return this;
|
||||
}
|
||||
|
||||
// 往右侧控件区添加一个按钮组件,并把组件交给回调去配置
|
||||
addButton(callback: (component: ButtonComponent) => void): this {
|
||||
callback(new ButtonComponent(this.controlEl));
|
||||
return this;
|
||||
}
|
||||
|
||||
addText(callback: (component: TextComponent) => void): this {
|
||||
callback(new TextComponent(this.controlEl));
|
||||
return this;
|
||||
}
|
||||
|
||||
addDropdown(callback: (component: DropdownComponent) => void): this {
|
||||
callback(new DropdownComponent(this.controlEl));
|
||||
return this;
|
||||
}
|
||||
|
||||
addToggle(callback: (component: ToggleComponent) => void): this {
|
||||
callback(new ToggleComponent(this.controlEl));
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
// 模拟"通知(Notice)":弹窗提示。构造时把消息文字记到 notices 数组,供测试检查
|
||||
export class Notice {
|
||||
constructor(message: string) {
|
||||
notices.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
// 模拟输入框自动补全的基类(对应 VaultPathSuggest 继承的那个)
|
||||
export class AbstractInputSuggest<T> {
|
||||
app: any;
|
||||
inputEl: HTMLInputElement;
|
||||
|
||||
constructor(app: any, inputEl: HTMLInputElement) {
|
||||
this.app = app;
|
||||
this.inputEl = inputEl;
|
||||
}
|
||||
|
||||
close(): void {}
|
||||
}
|
||||
|
||||
// 模糊/普通选择弹窗的基类链(仅为让导入它们的代码能正常实例化)
|
||||
export class SuggestModal<T> extends Modal {}
|
||||
|
||||
export class FuzzySuggestModal<T> extends SuggestModal<T> {}
|
||||
|
||||
// 文件对象占位(测试里基本只用到其 path)
|
||||
export class TFile {}
|
||||
|
||||
// 文件夹对象:带一个 path 字段,对应 VaultFolderSuggestModal 使用的内容
|
||||
export class TFolder {
|
||||
path = '';
|
||||
}
|
||||
|
||||
// 规整路径:把 Windows 的反斜杠 \ 统一替换为正斜杠 /(Obsidian 路径约定用 /)
|
||||
export function normalizePath(value: string): string {
|
||||
return value.replace(/\\/g, '/');
|
||||
}
|
||||
20
src/types/obsidian-augment.d.ts
vendored
Normal file
20
src/types/obsidian-augment.d.ts
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
// 类型增强:补齐 obsidian 包未声明、但 Obsidian 运行时真实存在的成员,
|
||||
// 以及 SVG 资源的模块声明,使 tsc 类型检查通过。仅影响类型世界,不改变任何运行时行为。
|
||||
import 'obsidian';
|
||||
|
||||
declare module 'obsidian' {
|
||||
interface App {
|
||||
/** 当前是否处于移动端(运行时提供,obsidian 类型桩未声明) */
|
||||
isMobile: boolean;
|
||||
/** 设置页管理器,用于打开本插件对应的设置标签 */
|
||||
setting: {
|
||||
open(): void;
|
||||
openTabById(id: string): void;
|
||||
};
|
||||
}
|
||||
|
||||
// AbstractInputSuggest 的 inputEl 在运行时存在,但 obsidian 类型桩未声明。
|
||||
interface AbstractInputSuggest<T> {
|
||||
inputEl: HTMLInputElement;
|
||||
}
|
||||
}
|
||||
7
src/types/svg.d.ts
vendored
Normal file
7
src/types/svg.d.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// SVG 资源的环境模块声明:esbuild 通过 loader 把 .svg 导入处理成字符串 URL,
|
||||
// tsc 需要对应的模块声明才能识别 `import x from '*.svg'` 默认导出。
|
||||
// 本文件不含任何顶层 import/export,确保被当作全局环境声明(而非模块增强)。
|
||||
declare module '*.svg' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
66
src/ui/Confirm.ts
Normal file
66
src/ui/Confirm.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { App, Modal, ButtonComponent } from 'obsidian';
|
||||
import { t } from '../i18n';
|
||||
|
||||
export interface ConfirmOptions {
|
||||
title?: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
// 标记这是危险操作(默认 true,给确认按钮加警示样式)
|
||||
warning?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用内确认框(替代原生 confirm())。
|
||||
*
|
||||
* 为什么必须用它、不能用原生 confirm():
|
||||
* Obsidian 跑在 Electron 上,原生 alert/confirm/prompt 是 **OS 级对话框**,会临时把焦点
|
||||
* 从 Electron 的 webview 整个拽出去;弹窗关闭后 Electron **不会可靠地把焦点还给 webview**
|
||||
* (长期已知 bug)。后果就是:一旦在「编辑器内」触发原生 confirm(例如 Live Preview 下代码块里的
|
||||
* 删除按钮),弹窗关闭后编辑器光标/输入能力永久丢失,只能靠「切到别的窗口再切回」这种 OS 焦点
|
||||
* 事件强行夺回——和本项目遇到的「删记录后丢光标、切窗才恢复」现象完全吻合。
|
||||
*
|
||||
* 本函数用 Obsidian 自己的 Modal 渲染确认框,焦点**始终留在 webview 内**,根本不会触发上述 bug。
|
||||
* 返回 Promise<boolean>,调用方 `await` 即可,与同步 `confirm()` 的语义一致但更安全。
|
||||
*/
|
||||
export function confirmWithModal(
|
||||
app: App,
|
||||
message: string,
|
||||
opts: ConfirmOptions = {}
|
||||
): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const modal = new Modal(app);
|
||||
if (opts.title) modal.titleEl.setText(opts.title);
|
||||
|
||||
modal.contentEl.empty();
|
||||
modal.contentEl.createDiv({ text: message, cls: 'workout-confirm-msg' });
|
||||
|
||||
const btnRow = modal.contentEl.createDiv({ cls: 'workout-confirm-row' });
|
||||
|
||||
let settled = false;
|
||||
const finish = (val: boolean): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
modal.close();
|
||||
resolve(val);
|
||||
};
|
||||
|
||||
new ButtonComponent(btnRow)
|
||||
.setButtonText(opts.cancelText ?? t('common.cancel'))
|
||||
.onClick(() => finish(false));
|
||||
|
||||
const confirmBtn = new ButtonComponent(btnRow)
|
||||
.setButtonText(opts.confirmText ?? t('common.ok'))
|
||||
.setCta();
|
||||
if (opts.warning !== false) confirmBtn.setWarning();
|
||||
confirmBtn.onClick(() => finish(true));
|
||||
|
||||
// 打开后把焦点放到「取消」按钮(应用内 DOM,不会触发原生焦点 bug)。
|
||||
// 默认聚焦取消可避免误删;且确认弹窗本身不会把焦点拽出 webview。
|
||||
modal.onOpen = () => {
|
||||
const first = btnRow.querySelector('button');
|
||||
if (first) (first as HTMLElement).focus();
|
||||
};
|
||||
|
||||
modal.open();
|
||||
});
|
||||
}
|
||||
188
src/ui/ExerciseManagerModal.ts
Normal file
188
src/ui/ExerciseManagerModal.ts
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
import { Modal, Notice } from 'obsidian';
|
||||
import { DataManager } from '../data/DataManager';
|
||||
import { getExerciseName, getMuscleName, getTrainingTypeName } from '../data/display';
|
||||
import { Exercise, Muscle, TrainingType } from '../data/types';
|
||||
import { t } from '../i18n';
|
||||
import { ExerciseModal } from './ExerciseModal';
|
||||
import { confirmWithModal } from './Confirm';
|
||||
|
||||
/* ExerciseManagerModal —— "训练项"列表管理弹窗。
|
||||
* 继承 Obsidian 的 Modal。打开后列出所有训练项,每条显示所属类型、主练/辅助肌肉,
|
||||
* 以及"编辑/删除"按钮。点编辑打开 ExerciseModal,删除前直接确认即可(训练项被其它
|
||||
* 训练类型/肌肉引用的情况较少,这里不做引用检查,仅做简单确认)。 */
|
||||
|
||||
export class ExerciseManagerModal extends Modal {
|
||||
private dataManager: DataManager;
|
||||
private exercisesContainer!: HTMLDivElement;
|
||||
private exercises: Exercise[] = [];
|
||||
// 列表里要把"类型 id / 肌肉 id"翻译成名字,所以需要缓存这两个列表。
|
||||
private trainingTypes: TrainingType[] = [];
|
||||
private muscles: Muscle[] = [];
|
||||
// 搜索关键字(已转小写),用于实时过滤列表。
|
||||
private searchQuery = '';
|
||||
|
||||
constructor(dataManager: DataManager) {
|
||||
super(dataManager.app);
|
||||
this.dataManager = dataManager;
|
||||
}
|
||||
|
||||
// 弹窗打开:读配置,缓存类型与肌肉列表,搭标题与容器,渲染列表,加"新增/关闭"按钮。
|
||||
async onOpen(): Promise<void> {
|
||||
const { contentEl } = this;
|
||||
contentEl.addClass('workout-manager-modal');
|
||||
|
||||
const config = await this.dataManager.getConfig();
|
||||
this.exercises = config.exercises;
|
||||
this.trainingTypes = config.trainingTypes;
|
||||
this.muscles = config.muscles;
|
||||
|
||||
contentEl.createEl('h2', { text: t('modal.exerciseManager.title') });
|
||||
|
||||
// 顶部搜索框(吸顶):按名称 / ID 实时过滤。
|
||||
const searchWrap = contentEl.createDiv();
|
||||
searchWrap.addClass('workout-manager-search');
|
||||
const search = searchWrap.createEl('input', { type: 'text', cls: 'workout-input' });
|
||||
search.placeholder = t('modal.exerciseManager.search');
|
||||
search.addEventListener('input', () => {
|
||||
this.searchQuery = search.value.trim().toLowerCase();
|
||||
this.renderExercises();
|
||||
});
|
||||
|
||||
// 顶部工具栏:新增训练项。
|
||||
const topToolbar = contentEl.createDiv();
|
||||
topToolbar.addClass('workout-btn-row');
|
||||
topToolbar.style.justifyContent = 'flex-start';
|
||||
const addTop = topToolbar.createEl('button', { text: t('modal.exerciseManager.add') });
|
||||
addTop.addClass('mod-cta');
|
||||
addTop.addEventListener('click', () => this.openAddExercise());
|
||||
|
||||
this.exercisesContainer = contentEl.createDiv();
|
||||
this.exercisesContainer.addClass('workout-muscles-list');
|
||||
|
||||
this.renderExercises();
|
||||
|
||||
const btnRow = contentEl.createDiv();
|
||||
btnRow.addClass('workout-btn-row');
|
||||
|
||||
const closeBtn = btnRow.createEl('button', { text: t('common.close') });
|
||||
closeBtn.addClass('mod-muted');
|
||||
closeBtn.addEventListener('click', () => this.close());
|
||||
}
|
||||
|
||||
// 渲染训练项列表:无数据显示空提示,否则逐条画出名称、类型、主/辅肌肉与编辑/删除按钮。
|
||||
private renderExercises(): void {
|
||||
this.exercisesContainer.empty();
|
||||
|
||||
if (this.exercises.length === 0) {
|
||||
this.exercisesContainer.createEl('p', { text: t('settings.noExercises') });
|
||||
return;
|
||||
}
|
||||
|
||||
// 按搜索词过滤(名称或 ID);空列表与无匹配分别给出提示。
|
||||
const q = this.searchQuery;
|
||||
const visible = this.exercises.filter((exercise) => {
|
||||
if (!q) return true;
|
||||
const name = ((getExerciseName(exercise) || exercise.name) ?? '').toLowerCase();
|
||||
return name.includes(q) || exercise.id.toLowerCase().includes(q);
|
||||
});
|
||||
|
||||
if (visible.length === 0) {
|
||||
this.exercisesContainer.createEl('p', { text: t('common.noMatch') });
|
||||
return;
|
||||
}
|
||||
|
||||
for (const exercise of visible) {
|
||||
// 用 category 找到类型对象,再翻译成显示名;找不到就回退到原始 category 字符串。
|
||||
const type = this.trainingTypes.find((item) => item.id === exercise.category);
|
||||
const typeName = getTrainingTypeName(type) || exercise.category;
|
||||
|
||||
// 把训练项关联的肌肉按角色分成"主练/辅助"两组。
|
||||
const primaryMuscles = exercise.muscles?.filter((muscle) => muscle.role === 'primary') || [];
|
||||
const secondaryMuscles = exercise.muscles?.filter((muscle) => muscle.role === 'secondary') || [];
|
||||
|
||||
// 把肌肉 id 翻译成名字,多个用逗号分隔;没有则用"无"。
|
||||
const primaryNames =
|
||||
primaryMuscles
|
||||
.map((entry) => getMuscleName(this.muscles.find((muscle) => muscle.id === entry.muscleId)) || entry.muscleId)
|
||||
.join(', ') || t('settings.none');
|
||||
const secondaryNames =
|
||||
secondaryMuscles
|
||||
.map((entry) => getMuscleName(this.muscles.find((muscle) => muscle.id === entry.muscleId)) || entry.muscleId)
|
||||
.join(', ') || t('settings.none');
|
||||
|
||||
const row = this.exercisesContainer.createDiv();
|
||||
row.addClass('workout-card');
|
||||
|
||||
const infoCol = row.createDiv();
|
||||
infoCol.addClass('workout-card-info');
|
||||
|
||||
const exerciseName = getExerciseName(exercise) || exercise.name;
|
||||
infoCol.createEl('div', { text: exerciseName, cls: 'workout-card-title' });
|
||||
|
||||
// 明细行:所属类型、主练肌肉、辅助肌肉。
|
||||
const detailLines = [
|
||||
`${t('modal.exerciseManager.category')}: ${typeName}`,
|
||||
`${t('modal.exerciseManager.primaryMuscles')}: ${primaryNames}`,
|
||||
`${t('modal.exerciseManager.secondaryMuscles')}: ${secondaryNames}`,
|
||||
];
|
||||
|
||||
for (const detail of detailLines) {
|
||||
infoCol.createEl('div', { text: detail, cls: 'workout-card-meta' });
|
||||
}
|
||||
|
||||
// 右侧按钮列:编辑 / 删除。
|
||||
const btnCol = row.createDiv();
|
||||
btnCol.addClass('workout-card-actions');
|
||||
|
||||
const editBtn = btnCol.createEl('button', { text: t('modal.exerciseManager.edit') });
|
||||
editBtn.addClass('workout-action-btn');
|
||||
editBtn.addEventListener('click', () => {
|
||||
const editModal = new ExerciseModal(this.dataManager, { editExercise: exercise });
|
||||
editModal.onClose = () => this.refresh();
|
||||
editModal.open();
|
||||
});
|
||||
|
||||
const deleteBtn = btnCol.createEl('button', { text: t('modal.exerciseManager.delete') });
|
||||
deleteBtn.addClass('workout-danger-btn');
|
||||
deleteBtn.addEventListener('click', () => this.deleteExercise(exercise));
|
||||
}
|
||||
}
|
||||
|
||||
// 打开"新增训练项"弹窗,关闭后刷新列表。
|
||||
private openAddExercise(): void {
|
||||
const editModal = new ExerciseModal(this.dataManager);
|
||||
editModal.onClose = () => this.refresh();
|
||||
editModal.open();
|
||||
}
|
||||
|
||||
// 删除训练项:先统计其关联的训练记录数,若有则提示「记录也会被一并删除」,确认后执行级联删除,并刷新列表。
|
||||
private async deleteExercise(exercise: Exercise): Promise<void> {
|
||||
const exerciseName = getExerciseName(exercise) || exercise.name || '';
|
||||
// 统计该训练项关联的训练记录数(getLogs 已过滤软删除,计数即真实可见记录)。
|
||||
const recordCount = this.dataManager.getLogs().filter((l) => l.exerciseId === exercise.id).length;
|
||||
const message =
|
||||
recordCount > 0
|
||||
? t('settings.confirmDeleteExerciseRecords', { name: exerciseName, count: String(recordCount) })
|
||||
: `${t('settings.confirmDeleteExercise')}: ${exerciseName}?`;
|
||||
if (!(await confirmWithModal(this.app, message))) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.dataManager.deleteExercise(exercise.id);
|
||||
new Notice(`${exerciseName} ${t('common.delete')}`);
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
// 重新读取配置并刷新列表(同时刷新缓存的类型/肌肉,保证显示名最新)。
|
||||
private async refresh(): Promise<void> {
|
||||
const config = await this.dataManager.getConfig();
|
||||
this.exercises = config.exercises;
|
||||
this.trainingTypes = config.trainingTypes;
|
||||
this.muscles = config.muscles;
|
||||
this.renderExercises();
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.contentEl.empty();
|
||||
}
|
||||
}
|
||||
276
src/ui/ExerciseModal.ts
Normal file
276
src/ui/ExerciseModal.ts
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
import { Modal, Notice } from 'obsidian';
|
||||
import { DataManager } from '../data/DataManager';
|
||||
import { getExerciseName, getMuscleName, getTrainingTypeName } from '../data/display';
|
||||
import { Exercise, TrainingType, Muscle, ExerciseMuscle } from '../data/types';
|
||||
import { t } from '../i18n';
|
||||
import { INVALID_ID_RE, isInvalidId } from './idValidation';
|
||||
|
||||
/*
|
||||
* ExerciseModal.ts —— "新建/编辑训练项"弹窗
|
||||
* 训练项(动作)是训练记录的基础:每个训练项有"名称""所属训练类型"
|
||||
* 以及"主练/辅助肌肉"。弹窗支持新建和编辑两种模式(传 editExercise 即编辑)。
|
||||
* 界面对肌肉采用"分组复选框":主练一组、辅助一组,保存时按勾选结果
|
||||
* 组装成 {muscleId, role} 列表写回配置。
|
||||
*/
|
||||
interface ExerciseModalOptions {
|
||||
editExercise?: Exercise; // 要编辑的训练项;省略则为新建模式
|
||||
}
|
||||
|
||||
export class ExerciseModal extends Modal {
|
||||
private dataManager: DataManager;
|
||||
private options: ExerciseModalOptions;
|
||||
private nameInput!: HTMLInputElement;
|
||||
private idInput!: HTMLInputElement; // 可编辑的训练项 ID(留空则保存时按名称推导)
|
||||
private idHint!: HTMLDivElement; // ID 非法字符提示(默认隐藏,触发时闪现)
|
||||
private idHintTimer: number | null = null; // 提示自动隐藏的计时器
|
||||
private categorySelect!: HTMLSelectElement;
|
||||
private primaryMusclesContainer!: HTMLDivElement;
|
||||
private secondaryMusclesContainer!: HTMLDivElement;
|
||||
private trainingTypes: TrainingType[] = [];
|
||||
private muscles: Muscle[] = [];
|
||||
|
||||
constructor(dataManager: DataManager, options: ExerciseModalOptions = {}) {
|
||||
super(dataManager.app);
|
||||
this.dataManager = dataManager;
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
// onOpen:构建界面。先读配置拿到训练类型与肌肉列表,再渲染各输入控件。
|
||||
async onOpen(): Promise<void> {
|
||||
const { contentEl } = this;
|
||||
contentEl.addClass('workout-edit-modal');
|
||||
const config = await this.dataManager.getConfig();
|
||||
this.trainingTypes = config.trainingTypes;
|
||||
this.muscles = config.muscles;
|
||||
|
||||
// 标题:编辑模式用"编辑训练项"文案,否则用"新建训练项"
|
||||
contentEl.createEl('h2', { text: this.options.editExercise ? t('modal.newExercise.editTitle') : t('modal.newExercise.title') });
|
||||
|
||||
// 名称行:文本输入框,编辑时预填已有名称
|
||||
const nameRow = contentEl.createDiv();
|
||||
nameRow.addClass('workout-field');
|
||||
nameRow.createEl('label', { text: t('modal.newExercise.name') });
|
||||
this.nameInput = nameRow.createEl('input', { type: 'text' });
|
||||
this.nameInput.addClass('workout-input');
|
||||
if (this.options.editExercise) {
|
||||
this.nameInput.value = getExerciseName(this.options.editExercise);
|
||||
}
|
||||
|
||||
// 训练项 ID 行:可编辑文本框。留空时保存会自动按名称推导;
|
||||
// 编辑模式下预填当前 id,用户改了它则会级联更新所有关联记录。
|
||||
const idRow = contentEl.createDiv();
|
||||
idRow.addClass('workout-field');
|
||||
idRow.createEl('label', { text: t('modal.newExercise.id') });
|
||||
this.idInput = idRow.createEl('input', { type: 'text', placeholder: t('modal.newExercise.idPlaceholder') });
|
||||
this.idInput.addClass('workout-input');
|
||||
if (this.options.editExercise) {
|
||||
this.idInput.value = this.options.editExercise.id;
|
||||
}
|
||||
|
||||
// 轻量实时校验:ID 不能含逗号/引号/换行(会破坏 CSV 单元格与键引用)。
|
||||
// beforeinput 阶段直接拦截非法字符(光标不跳),input 阶段再兜底 strip(覆盖粘贴)。
|
||||
this.idHint = contentEl.createEl('div', { cls: 'workout-id-hint' });
|
||||
this.idInput.addEventListener('beforeinput', (e: Event) => {
|
||||
const data = (e as InputEvent).data ?? '';
|
||||
if (data && INVALID_ID_RE.test(data)) {
|
||||
e.preventDefault();
|
||||
this.flashIdHint('modal.newExercise.idInvalid');
|
||||
}
|
||||
});
|
||||
this.idInput.addEventListener('input', () => {
|
||||
const v = this.idInput.value;
|
||||
if (INVALID_ID_RE.test(v)) {
|
||||
this.idInput.value = v.replace(INVALID_ID_RE, '');
|
||||
this.flashIdHint('modal.newExercise.idInvalid');
|
||||
}
|
||||
});
|
||||
|
||||
// 训练类型行:下拉框,编辑时预选中已有类型
|
||||
const categoryRow = contentEl.createDiv();
|
||||
categoryRow.addClass('workout-field');
|
||||
categoryRow.createEl('label', { text: t('modal.newExercise.category') });
|
||||
this.categorySelect = categoryRow.createEl('select');
|
||||
this.categorySelect.addClass('workout-select');
|
||||
|
||||
for (const type of this.trainingTypes) {
|
||||
const typeName = getTrainingTypeName(type) || type.id;
|
||||
const option = this.categorySelect.createEl('option', {
|
||||
value: type.id,
|
||||
text: typeName,
|
||||
});
|
||||
if (this.options.editExercise?.category === type.id) {
|
||||
option.selected = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 主练肌肉区块:h3 标题 + 复选框容器
|
||||
const primaryRow = contentEl.createDiv();
|
||||
primaryRow.createEl('h3', { text: t('modal.newExercise.primaryMuscles'), cls: 'workout-section-title' });
|
||||
this.primaryMusclesContainer = primaryRow.createDiv();
|
||||
this.primaryMusclesContainer.addClass('workout-muscles-container', 'workout-check-grid');
|
||||
|
||||
// 辅助肌肉区块
|
||||
const secondaryRow = contentEl.createDiv();
|
||||
secondaryRow.createEl('h3', { text: t('modal.newExercise.secondaryMuscles'), cls: 'workout-section-title' });
|
||||
this.secondaryMusclesContainer = secondaryRow.createDiv();
|
||||
this.secondaryMusclesContainer.addClass('workout-muscles-container', 'workout-check-grid');
|
||||
|
||||
// 渲染两组肌肉复选框(会用编辑模式已勾选项做预选)
|
||||
this.renderMuscles();
|
||||
|
||||
// 底部按钮行:取消 + 保存
|
||||
const btnRow = contentEl.createDiv();
|
||||
btnRow.addClass('workout-btn-row');
|
||||
|
||||
const cancelBtn = btnRow.createEl('button', { text: t('common.cancel') });
|
||||
cancelBtn.addClass('mod-muted');
|
||||
cancelBtn.addEventListener('click', () => this.close());
|
||||
|
||||
const saveBtn = btnRow.createEl('button', { text: t('common.save') });
|
||||
saveBtn.addClass('mod-cta');
|
||||
saveBtn.addEventListener('click', () => this.save());
|
||||
}
|
||||
|
||||
// 渲染主练/辅助两组肌肉复选框。编辑模式下根据 editExercise.muscles 的 role 预勾选相应项。
|
||||
private renderMuscles(): void {
|
||||
this.primaryMusclesContainer.empty();
|
||||
this.secondaryMusclesContainer.empty();
|
||||
|
||||
// 从已有训练项里分别取出"主练""辅助"肌肉 id 集合(用于预勾选)
|
||||
const selectedPrimary = this.options.editExercise?.muscles
|
||||
?.filter((m) => m.role === 'primary')
|
||||
.map((m) => m.muscleId) || [];
|
||||
|
||||
const selectedSecondary = this.options.editExercise?.muscles
|
||||
?.filter((m) => m.role === 'secondary')
|
||||
.map((m) => m.muscleId) || [];
|
||||
|
||||
for (const muscle of this.muscles) {
|
||||
const muscleName = getMuscleName(muscle) || muscle.id;
|
||||
|
||||
// 主练复选框:每个肌肉一个 label 包裹 checkbox + 文字
|
||||
const primaryLabel = this.primaryMusclesContainer.createEl('label');
|
||||
primaryLabel.addClass('workout-check-item');
|
||||
const primaryCheckbox = primaryLabel.createEl('input', { type: 'checkbox' });
|
||||
primaryCheckbox.value = muscle.id; // 用 muscle.id 作为值,保存时取这个
|
||||
if (selectedPrimary.includes(muscle.id)) {
|
||||
primaryCheckbox.checked = true;
|
||||
}
|
||||
primaryLabel.createEl('span', { text: muscleName });
|
||||
|
||||
// 辅助复选框:结构同上
|
||||
const secondaryLabel = this.secondaryMusclesContainer.createEl('label');
|
||||
secondaryLabel.addClass('workout-check-item');
|
||||
const secondaryCheckbox = secondaryLabel.createEl('input', { type: 'checkbox' });
|
||||
secondaryCheckbox.value = muscle.id;
|
||||
if (selectedSecondary.includes(muscle.id)) {
|
||||
secondaryCheckbox.checked = true;
|
||||
}
|
||||
secondaryLabel.createEl('span', { text: muscleName });
|
||||
}
|
||||
}
|
||||
|
||||
// 保存:先做必填校验(名称、类型),再用 querySelectorAll 收集两组中被勾选的肌肉,
|
||||
// 组装成 {muscleId, role} 列表,最后新增或更新训练项,并给出提示。
|
||||
// 训练项 ID:输入框留空时按「名称」自动推导;非空时以用户输入为准(空格转下划线)。
|
||||
// 编辑模式下若改了 ID,则级联更新数据库里所有关联该 ID 的记录。
|
||||
private async save(): Promise<void> {
|
||||
const name = this.nameInput.value.trim();
|
||||
if (!name) {
|
||||
new Notice(t('modal.newExercise.nameRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
const category = this.categorySelect.value;
|
||||
if (!category) {
|
||||
new Notice(t('modal.newExercise.categoryRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算最终 ID:用户输入优先,留空则按名称推导(小写、空格转下划线)。
|
||||
// 两路都额外 strip 掉非法字符(逗号/引号/换行),即使从名称推导也保证 CSV 安全。
|
||||
const idValue = this.idInput.value.trim();
|
||||
const finalId = (idValue || name.toLowerCase())
|
||||
.replace(/\s+/g, '_')
|
||||
.replace(INVALID_ID_RE, '');
|
||||
|
||||
// 兜底:万一仍含非法字符(理论上已被实时校验拦截),直接拦下保存。
|
||||
if (isInvalidId(finalId)) {
|
||||
new Notice(t('modal.newExercise.idInvalid'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 防重复:新建时与全部已有 id 比对;编辑时排除自身旧 id,仅当改成了别人的 id 才拦截。
|
||||
const config = await this.dataManager.getConfig();
|
||||
const exists = config.exercises.some(
|
||||
(e) => e.id === finalId && e.id !== this.options.editExercise?.id
|
||||
);
|
||||
if (exists) {
|
||||
new Notice(t('modal.newExercise.idDuplicate'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 收集主练、辅助两组中处于勾选状态(checked)的复选框
|
||||
const primaryCheckboxes = this.primaryMusclesContainer.querySelectorAll('input[type="checkbox"]:checked');
|
||||
const secondaryCheckboxes = this.secondaryMusclesContainer.querySelectorAll('input[type="checkbox"]:checked');
|
||||
|
||||
// 把勾选结果组装成肌肉关系数组
|
||||
const muscles: ExerciseMuscle[] = [];
|
||||
primaryCheckboxes.forEach((cb) => {
|
||||
muscles.push({ muscleId: (cb as HTMLInputElement).value, role: 'primary' });
|
||||
});
|
||||
secondaryCheckboxes.forEach((cb) => {
|
||||
muscles.push({ muscleId: (cb as HTMLInputElement).value, role: 'secondary' });
|
||||
});
|
||||
|
||||
try {
|
||||
const updates: Partial<Exercise> = {
|
||||
name,
|
||||
category,
|
||||
muscles: muscles.length > 0 ? muscles : undefined,
|
||||
};
|
||||
if (this.options.editExercise) {
|
||||
const oldId = this.options.editExercise.id;
|
||||
if (finalId !== oldId) {
|
||||
// ID 变了:级联改写配置里的 id + 所有关联记录里的 exerciseId
|
||||
await this.dataManager.renameExercise(oldId, finalId, updates);
|
||||
} else {
|
||||
// ID 没变:普通更新
|
||||
await this.dataManager.updateExercise(oldId, updates);
|
||||
}
|
||||
new Notice(t('modal.newExercise.updated'));
|
||||
} else {
|
||||
// 新建模式:写入自定义 ID(用户输入或按名称推导)
|
||||
await this.dataManager.addExercise({
|
||||
id: finalId,
|
||||
nameKey: undefined,
|
||||
name,
|
||||
category,
|
||||
muscles: muscles.length > 0 ? muscles : undefined,
|
||||
});
|
||||
new Notice(t('modal.newExercise.saved'));
|
||||
}
|
||||
this.close();
|
||||
} catch (error) {
|
||||
new Notice(t('modal.newExercise.saveFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
}
|
||||
|
||||
// 闪现一条 ID 非法字符提示:显示文案,2.5s 后自动淡出(轻量、不打断输入)。
|
||||
private flashIdHint(msgKey: string): void {
|
||||
this.idHint.textContent = t(msgKey);
|
||||
this.idHint.addClass('is-visible');
|
||||
if (this.idHintTimer !== null) {
|
||||
window.clearTimeout(this.idHintTimer);
|
||||
}
|
||||
this.idHintTimer = window.setTimeout(() => {
|
||||
this.idHint.removeClass('is-visible');
|
||||
this.idHintTimer = null;
|
||||
}, 2500);
|
||||
}
|
||||
}
|
||||
110
src/ui/InsertCodeBlockModal.ts
Normal file
110
src/ui/InsertCodeBlockModal.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { Modal, setIcon } from 'obsidian';
|
||||
import { DataManager } from '../data/DataManager';
|
||||
import { t } from '../i18n';
|
||||
import { CODE_BLOCK_DEFS, CodeBlockDef } from '../codeBlockDefs';
|
||||
import { InsertCodeBlockParamModal } from './InsertCodeBlockParamModal';
|
||||
|
||||
/*
|
||||
* InsertCodeBlockModal —— 快捷插入代码块(主弹窗)
|
||||
* 侧边栏点「插入代码块」后弹出:顶部搜索框过滤,下方列出全部训练代码块,
|
||||
* 每张卡片含图标/名称/功能说明/「N 个可选参数」徽标;点卡片即进入参数弹窗。
|
||||
*/
|
||||
export class InsertCodeBlockModal extends Modal {
|
||||
private dataManager: DataManager;
|
||||
private searchInput!: HTMLInputElement;
|
||||
private listEl!: HTMLDivElement;
|
||||
private filtered: CodeBlockDef[] = CODE_BLOCK_DEFS;
|
||||
|
||||
constructor(dataManager: DataManager) {
|
||||
super(dataManager.app);
|
||||
this.dataManager = dataManager;
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
const { contentEl } = this;
|
||||
contentEl.addClass('workout-insert-modal');
|
||||
|
||||
contentEl.createEl('h2', { text: t('modal.insertCodeblock.title') });
|
||||
|
||||
// 搜索框
|
||||
const searchField = contentEl.createDiv();
|
||||
searchField.addClass('workout-field');
|
||||
this.searchInput = searchField.createEl('input', { type: 'text' });
|
||||
this.searchInput.addClass('workout-input');
|
||||
this.searchInput.placeholder = t('modal.insertCodeblock.searchPlaceholder');
|
||||
this.searchInput.addEventListener('input', () => this.filter(this.searchInput.value));
|
||||
|
||||
// 卡片列表容器
|
||||
this.listEl = contentEl.createDiv();
|
||||
this.listEl.addClass('workout-insert-list');
|
||||
|
||||
this.renderList();
|
||||
}
|
||||
|
||||
// 按搜索词过滤(匹配标题或说明,大小写不敏感)
|
||||
private filter(query: string): void {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) {
|
||||
this.filtered = CODE_BLOCK_DEFS;
|
||||
} else {
|
||||
this.filtered = CODE_BLOCK_DEFS.filter(
|
||||
(d) => d.title.toLowerCase().includes(q) || d.desc.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
this.renderList();
|
||||
}
|
||||
|
||||
// 渲染卡片列表
|
||||
private renderList(): void {
|
||||
this.listEl.empty();
|
||||
if (this.filtered.length === 0) {
|
||||
this.listEl
|
||||
.createDiv({ text: t('modal.insertCodeblock.noMatch') })
|
||||
.addClass('workout-insert-empty');
|
||||
return;
|
||||
}
|
||||
for (const def of this.filtered) {
|
||||
const card = this.listEl.createDiv();
|
||||
card.addClass('workout-insert-card');
|
||||
card.setAttribute('role', 'button');
|
||||
card.tabIndex = 0;
|
||||
|
||||
// 左侧图标
|
||||
const iconEl = card.createSpan();
|
||||
iconEl.addClass('workout-insert-card-icon');
|
||||
setIcon(iconEl, def.icon);
|
||||
|
||||
// 右侧文字区
|
||||
const textWrap = card.createDiv();
|
||||
textWrap.addClass('workout-insert-card-text');
|
||||
|
||||
const titleRow = textWrap.createDiv();
|
||||
titleRow.addClass('workout-insert-card-title');
|
||||
titleRow.createSpan({ text: def.title });
|
||||
|
||||
const badge = titleRow.createSpan({
|
||||
text: t('modal.insertCodeblock.paramsCount', { n: String(def.params.length) }),
|
||||
});
|
||||
badge.addClass('workout-insert-card-badge');
|
||||
|
||||
textWrap.createDiv({ text: def.desc, cls: 'workout-insert-card-desc' });
|
||||
|
||||
// 点击:打开参数弹窗并关闭自身
|
||||
const open = () => {
|
||||
this.close();
|
||||
new InsertCodeBlockParamModal(this.dataManager, def).open();
|
||||
};
|
||||
card.addEventListener('click', open);
|
||||
card.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
open();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.contentEl.empty();
|
||||
}
|
||||
}
|
||||
148
src/ui/InsertCodeBlockParamModal.ts
Normal file
148
src/ui/InsertCodeBlockParamModal.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { Modal, setIcon, Notice, MarkdownView } from 'obsidian';
|
||||
import { DataManager } from '../data/DataManager';
|
||||
import { WorkoutConfig } from '../data/types';
|
||||
import { t } from '../i18n';
|
||||
import { CodeBlockDef, CodeBlockParamDef, buildCodeBlock } from '../codeBlockDefs';
|
||||
|
||||
/*
|
||||
* InsertCodeBlockParamModal —— 参数弹窗
|
||||
* 选定某个代码块后弹出:展示该代码块说明 + 动态生成的参数表单(全部非必填)。
|
||||
* 底部「跳过参数」直接插入纯代码块;「插入到光标处」带上已填参数。
|
||||
* 通过当前 Markdown 编辑器的 editor.replaceSelection 在光标处插入文本。
|
||||
*/
|
||||
export class InsertCodeBlockParamModal extends Modal {
|
||||
private dataManager: DataManager;
|
||||
private def: CodeBlockDef;
|
||||
private values: Record<string, string> = {};
|
||||
private paramContainer!: HTMLDivElement;
|
||||
private inputs: Record<string, HTMLInputElement | HTMLSelectElement> = {};
|
||||
|
||||
constructor(dataManager: DataManager, def: CodeBlockDef) {
|
||||
super(dataManager.app);
|
||||
this.dataManager = dataManager;
|
||||
this.def = def;
|
||||
}
|
||||
|
||||
async onOpen(): Promise<void> {
|
||||
const { contentEl } = this;
|
||||
contentEl.addClass('workout-insert-param-modal');
|
||||
|
||||
// 标题:图标 + 名称
|
||||
const header = contentEl.createDiv();
|
||||
header.addClass('workout-insert-param-header');
|
||||
const iconEl = header.createSpan();
|
||||
iconEl.addClass('workout-insert-card-icon');
|
||||
setIcon(iconEl, this.def.icon);
|
||||
header.createEl('h2', { text: this.def.title });
|
||||
|
||||
contentEl.createDiv({ text: this.def.desc, cls: 'workout-insert-card-desc' });
|
||||
|
||||
// 参数区标题
|
||||
const paramTitle = contentEl.createDiv();
|
||||
paramTitle.addClass('workout-insert-param-subtitle');
|
||||
paramTitle.setText(t('modal.insertCodeblock.paramTitle'));
|
||||
|
||||
this.paramContainer = contentEl.createDiv();
|
||||
this.paramContainer.addClass('workout-insert-params');
|
||||
|
||||
// 动态选项(plan/metric)需要先读 config
|
||||
const config = await this.dataManager.getConfig();
|
||||
this.renderParams(config);
|
||||
|
||||
// 底部按钮行
|
||||
const btnRow = contentEl.createDiv();
|
||||
btnRow.addClass('workout-btn-row');
|
||||
|
||||
const skipBtn = btnRow.createEl('button', { text: t('modal.insertCodeblock.skip') });
|
||||
skipBtn.addClass('mod-muted');
|
||||
skipBtn.addEventListener('click', () => {
|
||||
this.insert(buildCodeBlock(this.def, {}));
|
||||
});
|
||||
|
||||
const insertBtn = btnRow.createEl('button', { text: t('modal.insertCodeblock.insert') });
|
||||
insertBtn.addClass('mod-cta');
|
||||
insertBtn.addEventListener('click', () => {
|
||||
this.collectValues();
|
||||
this.insert(buildCodeBlock(this.def, this.values));
|
||||
});
|
||||
}
|
||||
|
||||
// 渲染参数表单;config 用于填充 dynamic 的 select 选项(计划名 / 统计指标)
|
||||
private renderParams(config: WorkoutConfig): void {
|
||||
this.paramContainer.empty();
|
||||
for (const p of this.def.params) {
|
||||
const row = this.paramContainer.createDiv();
|
||||
row.addClass('workout-field');
|
||||
|
||||
const labelRow = row.createDiv();
|
||||
labelRow.addClass('workout-insert-param-label');
|
||||
labelRow.createSpan({ text: p.label });
|
||||
const optTag = labelRow.createSpan({ text: t('modal.insertCodeblock.optional') });
|
||||
optTag.addClass('workout-insert-param-optional');
|
||||
|
||||
if (p.desc) {
|
||||
row.createDiv({ text: p.desc, cls: 'workout-insert-param-hint' });
|
||||
}
|
||||
|
||||
if (p.type === 'select') {
|
||||
const select = row.createEl('select');
|
||||
select.addClass('workout-select');
|
||||
select.createEl('option', { value: '', text: '— 不设置 —' });
|
||||
|
||||
let options: { value: string; label: string }[] = [];
|
||||
if (p.dynamic === 'plan') {
|
||||
options = (config.plans ?? []).map((pl) => ({ value: pl.name, label: pl.name }));
|
||||
} else if (p.dynamic === 'metric') {
|
||||
options = (config.statistics ?? []).map((m) => ({ value: m.id, label: m.name ?? m.id }));
|
||||
} else {
|
||||
options = (p.options ?? []).map((o) => ({ value: o, label: p.optionLabels?.[o] ?? o }));
|
||||
}
|
||||
for (const o of options) {
|
||||
select.createEl('option', { value: o.value, text: o.label });
|
||||
}
|
||||
select.addEventListener('change', () => {
|
||||
this.values[p.key] = select.value;
|
||||
});
|
||||
this.inputs[p.key] = select;
|
||||
} else {
|
||||
const input = row.createEl('input', { type: p.type === 'number' ? 'number' : 'text' });
|
||||
input.addClass('workout-input');
|
||||
if (p.type === 'number') {
|
||||
input.setAttribute('step', 'any');
|
||||
input.setAttribute('inputmode', 'numeric');
|
||||
}
|
||||
if (p.placeholder) input.placeholder = p.placeholder;
|
||||
input.addEventListener('change', () => {
|
||||
this.values[p.key] = input.value;
|
||||
});
|
||||
this.inputs[p.key] = input;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 收集各控件当前值(兜底:覆盖用户未触发 change 的情况)
|
||||
private collectValues(): void {
|
||||
for (const p of this.def.params) {
|
||||
const el = this.inputs[p.key];
|
||||
if (!el) continue;
|
||||
this.values[p.key] = el.value;
|
||||
}
|
||||
}
|
||||
|
||||
// 在光标处插入代码块文本;无活动编辑器时提示并关闭
|
||||
private insert(text: string): void {
|
||||
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (!view) {
|
||||
new Notice(t('modal.insertCodeblock.noEditor'));
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
view.editor.replaceSelection(text);
|
||||
new Notice(t('modal.insertCodeblock.inserted'));
|
||||
this.close();
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.contentEl.empty();
|
||||
}
|
||||
}
|
||||
526
src/ui/MuscleEditModal.ts
Normal file
526
src/ui/MuscleEditModal.ts
Normal file
|
|
@ -0,0 +1,526 @@
|
|||
import { Modal, Notice } from 'obsidian';
|
||||
import { DataManager } from '../data/DataManager';
|
||||
import { getMuscleName } from '../data/display';
|
||||
import { MUSCLE_FITNESS_GROUP } from '../data/muscleMapping';
|
||||
import { FITNESS_GROUPS, formatSvgMuscleLabel, SVG_MUSCLE_CATALOG, SvgMuscleEntry } from '../data/svgMuscleCatalog';
|
||||
import { HeatmapLevel, Muscle, StatDef, WorkoutConfig } from '../data/types';
|
||||
import { getLocale, t } from '../i18n';
|
||||
|
||||
/* MuscleEditModal —— 编辑单个肌肉的弹窗(新增或编辑共用)。
|
||||
* 字段:ID、名称、是否计入覆盖、未练天数阈值、SVG 肌肉映射(1→N 多选)、热力图设置。 */
|
||||
|
||||
interface MuscleEditModalOptions {
|
||||
muscle?: Muscle;
|
||||
}
|
||||
|
||||
const DEFAULT_HEATMAP_SCALE: HeatmapLevel[] = [
|
||||
{ color: '#3b82f6', max: 5 },
|
||||
{ color: '#22c55e', max: 10 },
|
||||
{ color: '#f97316', max: 20 },
|
||||
{ color: '#ef4444', max: 40 },
|
||||
];
|
||||
|
||||
// 生成"轻→重"渐变色:蓝(220)→红(0) 的 HSL 插值,作为分级数的默认配色。
|
||||
function hslToHex(h: number, s: number, l: number): string {
|
||||
s /= 100; l /= 100;
|
||||
const k = (n: number) => (n + h / 30) % 12;
|
||||
const a = s * Math.min(l, 1 - l);
|
||||
const f = (n: number) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
|
||||
const toHex = (x: number) => Math.round(255 * x).toString(16).padStart(2, '0');
|
||||
return `#${toHex(f(0))}${toHex(f(8))}${toHex(f(4))}`;
|
||||
}
|
||||
|
||||
function hexForIndex(i: number, n: number): string {
|
||||
const start = 220, end = 0;
|
||||
const hue = n <= 1 ? start : start + (end - start) * (i / (n - 1));
|
||||
return hslToHex(hue, 72, 50);
|
||||
}
|
||||
|
||||
const RANGE_OPTIONS = ['7d', '30d', '90d', 'all'];
|
||||
|
||||
export class MuscleEditModal extends Modal {
|
||||
private dataManager: DataManager;
|
||||
private options: MuscleEditModalOptions;
|
||||
private config!: WorkoutConfig;
|
||||
|
||||
private idInput!: HTMLInputElement;
|
||||
private nameInput!: HTMLInputElement;
|
||||
private coverageToggle!: HTMLInputElement;
|
||||
private restThresholdInput!: HTMLInputElement;
|
||||
|
||||
private selectedIds = new Set<string>();
|
||||
private searchQuery = '';
|
||||
private metricId = '';
|
||||
private rangeValue = '';
|
||||
private customRange = '';
|
||||
private muscleLevels: HeatmapLevel[] | null = null; // 该肌的 4 色分档(逐肌可配)
|
||||
|
||||
private mappingContainer!: HTMLDivElement;
|
||||
private countEl!: HTMLElement;
|
||||
private heatmapSection!: HTMLDivElement;
|
||||
|
||||
constructor(dataManager: DataManager, options: MuscleEditModalOptions = {}) {
|
||||
super(dataManager.app);
|
||||
this.dataManager = dataManager;
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
async onOpen(): Promise<void> {
|
||||
const { contentEl } = this;
|
||||
contentEl.addClass('workout-edit-modal');
|
||||
|
||||
this.config = await this.dataManager.getConfig();
|
||||
|
||||
const muscle = this.options.muscle;
|
||||
if (muscle) {
|
||||
this.selectedIds = new Set(muscle.svgRegionIds ?? []);
|
||||
this.metricId = muscle.heatmapMetric ?? '';
|
||||
this.rangeValue = muscle.heatmapRange ?? '';
|
||||
this.muscleLevels = muscle.heatmapLevels ? this.normalizeLevels(muscle.heatmapLevels) : null;
|
||||
if (!RANGE_OPTIONS.includes(this.rangeValue) && this.rangeValue) {
|
||||
this.customRange = this.rangeValue;
|
||||
this.rangeValue = 'custom';
|
||||
}
|
||||
}
|
||||
|
||||
contentEl.createEl('h2', { text: muscle ? t('modal.muscleManager.editTitle') : t('modal.muscleManager.add') });
|
||||
|
||||
// 两列网格:ID + 名称
|
||||
const idNameCol = contentEl.createDiv();
|
||||
idNameCol.addClass('workout-two-col');
|
||||
|
||||
// ID
|
||||
const idRow = idNameCol.createDiv();
|
||||
idRow.addClass('workout-field');
|
||||
idRow.createEl('label', { text: 'ID' });
|
||||
this.idInput = idRow.createEl('input', { type: 'text' });
|
||||
this.idInput.addClass('workout-input');
|
||||
this.idInput.placeholder = 'id';
|
||||
if (muscle?.id) {
|
||||
this.idInput.value = muscle.id;
|
||||
this.idInput.disabled = true;
|
||||
}
|
||||
|
||||
// 名称
|
||||
const nameRow = idNameCol.createDiv();
|
||||
nameRow.addClass('workout-field');
|
||||
nameRow.createEl('label', { text: t('modal.muscleManager.name') });
|
||||
this.nameInput = nameRow.createEl('input', { type: 'text' });
|
||||
this.nameInput.addClass('workout-input');
|
||||
if (muscle) {
|
||||
this.nameInput.value = getMuscleName(muscle);
|
||||
}
|
||||
|
||||
// 两列网格:计入覆盖(开关)+ 未练天数阈值
|
||||
const coverRestCol = contentEl.createDiv();
|
||||
coverRestCol.addClass('workout-two-col');
|
||||
|
||||
// 覆盖(布尔开关)
|
||||
const coverageToggleWrap = coverRestCol.createDiv();
|
||||
coverageToggleWrap.addClass('workout-toggle');
|
||||
this.coverageToggle = coverageToggleWrap.createEl('input', { type: 'checkbox', cls: 'workout-switch' });
|
||||
coverageToggleWrap.createEl('span', { text: t('modal.muscleManager.coverage') });
|
||||
if (muscle?.contributesToCoverage) {
|
||||
this.coverageToggle.checked = true;
|
||||
}
|
||||
|
||||
// 未练天数阈值
|
||||
const restRow = coverRestCol.createDiv();
|
||||
restRow.addClass('workout-field');
|
||||
restRow.createEl('label', { text: t('modal.muscleManager.restThreshold') });
|
||||
this.restThresholdInput = restRow.createEl('input', { type: 'number' });
|
||||
this.restThresholdInput.addClass('workout-input');
|
||||
this.restThresholdInput.setAttribute('step', 'any');
|
||||
this.restThresholdInput.setAttribute('inputmode', 'numeric');
|
||||
this.restThresholdInput.min = '1';
|
||||
this.restThresholdInput.value = String(muscle?.restThresholdDays ?? 7);
|
||||
|
||||
// SVG 肌肉映射
|
||||
contentEl.createEl('h3', { text: t('modal.muscleManager.svgMapping'), cls: 'workout-section-title' });
|
||||
this.renderMappingHeader(contentEl);
|
||||
this.mappingContainer = contentEl.createDiv();
|
||||
this.mappingContainer.addClass('workout-fields-list');
|
||||
this.mappingContainer.style.maxHeight = '320px';
|
||||
this.mappingContainer.style.overflow = 'auto';
|
||||
this.renderMappingList();
|
||||
|
||||
// 热力图设置
|
||||
contentEl.createEl('h3', { text: t('modal.muscleManager.heatmapSettings'), cls: 'workout-section-title' });
|
||||
this.heatmapSection = contentEl.createDiv();
|
||||
this.renderHeatmapSettings();
|
||||
|
||||
// 底部按钮
|
||||
const btnRow = contentEl.createDiv();
|
||||
btnRow.addClass('workout-btn-row');
|
||||
|
||||
const cancelBtn = btnRow.createEl('button', { text: t('common.cancel') });
|
||||
cancelBtn.addClass('mod-muted');
|
||||
cancelBtn.addEventListener('click', () => this.close());
|
||||
|
||||
const saveBtn = btnRow.createEl('button', { text: t('common.save') });
|
||||
saveBtn.addClass('mod-cta');
|
||||
saveBtn.addEventListener('click', () => this.save());
|
||||
}
|
||||
|
||||
private renderMappingHeader(parent: HTMLElement): void {
|
||||
const header = parent.createDiv();
|
||||
header.addClass('workout-mapping-toolbar');
|
||||
|
||||
const search = header.createEl('input', { type: 'text' });
|
||||
search.addClass('workout-input');
|
||||
search.placeholder = t('modal.muscleManager.searchPaths');
|
||||
search.addEventListener('input', () => {
|
||||
this.searchQuery = search.value.trim().toLowerCase();
|
||||
this.renderMappingList();
|
||||
});
|
||||
|
||||
this.countEl = header.createEl('span', { text: this.countText() });
|
||||
this.countEl.addClass('workout-mapping-count');
|
||||
|
||||
const clearBtn = header.createEl('button', { text: t('modal.muscleManager.clearMapping') });
|
||||
clearBtn.addClass('mod-muted');
|
||||
clearBtn.addEventListener('click', () => {
|
||||
this.selectedIds.clear();
|
||||
this.renderMappingList();
|
||||
});
|
||||
|
||||
const presetBtn = header.createEl('button', { text: t('modal.muscleManager.applyGroupPreset') });
|
||||
presetBtn.addClass('mod-cta');
|
||||
presetBtn.addEventListener('click', () => this.applyGroupPreset());
|
||||
}
|
||||
|
||||
private countText(): string {
|
||||
return t('modal.muscleManager.selectedCount', { count: String(this.selectedIds.size) });
|
||||
}
|
||||
|
||||
private applyGroupPreset(): void {
|
||||
const muscleId = this.options.muscle?.id ?? this.idInput.value.trim();
|
||||
const group = MUSCLE_FITNESS_GROUP[muscleId];
|
||||
if (!group) return;
|
||||
for (const entry of SVG_MUSCLE_CATALOG) {
|
||||
if (entry.fitnessGroup === group) {
|
||||
this.selectedIds.add(entry.id);
|
||||
}
|
||||
}
|
||||
this.renderMappingList();
|
||||
}
|
||||
|
||||
// SVG 路径显示标签:复用目录层格式化,自动区分段号与左右。
|
||||
private formatEntryLabel(entry: SvgMuscleEntry, locale: string): string {
|
||||
return formatSvgMuscleLabel(entry, locale);
|
||||
}
|
||||
|
||||
private renderMappingList(): void {
|
||||
this.mappingContainer.empty();
|
||||
this.countEl.textContent = this.countText();
|
||||
|
||||
const locale = getLocale();
|
||||
const query = this.searchQuery;
|
||||
const filtered = query
|
||||
? SVG_MUSCLE_CATALOG.filter((e) =>
|
||||
e.id.toLowerCase().includes(query) ||
|
||||
e.zh.toLowerCase().includes(query) ||
|
||||
e.en.toLowerCase().includes(query)
|
||||
)
|
||||
: SVG_MUSCLE_CATALOG;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
this.mappingContainer.createEl('p', { text: t('modal.muscleManager.noPaths') });
|
||||
return;
|
||||
}
|
||||
|
||||
// 按 side + fitnessGroup 分组
|
||||
const grouped = new Map<string, Map<string, typeof SVG_MUSCLE_CATALOG>>();
|
||||
for (const entry of filtered) {
|
||||
if (!grouped.has(entry.side)) grouped.set(entry.side, new Map());
|
||||
const sideMap = grouped.get(entry.side)!;
|
||||
if (!sideMap.has(entry.fitnessGroup)) sideMap.set(entry.fitnessGroup, []);
|
||||
sideMap.get(entry.fitnessGroup)!.push(entry);
|
||||
}
|
||||
|
||||
const sideOrder: Array<'front' | 'back'> = ['front', 'back'];
|
||||
for (const side of sideOrder) {
|
||||
const sideMap = grouped.get(side);
|
||||
if (!sideMap) continue;
|
||||
|
||||
const sideHeader = this.mappingContainer.createEl('h4', { text: t(side === 'front' ? 'modal.muscleManager.front' : 'modal.muscleManager.back') });
|
||||
sideHeader.style.margin = '8px 0 4px';
|
||||
|
||||
for (const group of FITNESS_GROUPS) {
|
||||
const entries = sideMap.get(group.key);
|
||||
if (!entries || entries.length === 0) continue;
|
||||
|
||||
const groupWrap = this.mappingContainer.createDiv();
|
||||
groupWrap.addClass('workout-mapping-group');
|
||||
|
||||
const groupHeader = groupWrap.createDiv();
|
||||
groupHeader.addClass('workout-mapping-group-title');
|
||||
groupHeader.textContent = locale === 'zh' ? group.zh : group.en;
|
||||
|
||||
const grid = groupWrap.createDiv();
|
||||
grid.addClass('workout-check-grid');
|
||||
|
||||
for (const entry of entries) {
|
||||
const item = grid.createEl('label');
|
||||
item.addClass('workout-check-item');
|
||||
const checkbox = item.createEl('input', { type: 'checkbox' }) as HTMLInputElement;
|
||||
checkbox.checked = this.selectedIds.has(entry.id);
|
||||
checkbox.addEventListener('change', () => {
|
||||
if (checkbox.checked) this.selectedIds.add(entry.id);
|
||||
else this.selectedIds.delete(entry.id);
|
||||
this.countEl.textContent = this.countText();
|
||||
});
|
||||
item.createSpan({ text: this.formatEntryLabel(entry, locale), title: entry.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private renderHeatmapSettings(): void {
|
||||
this.heatmapSection.empty();
|
||||
|
||||
// —— 两列网格:时间窗 + 指标
|
||||
const rangeMetricCol = this.heatmapSection.createDiv();
|
||||
rangeMetricCol.addClass('workout-two-col');
|
||||
|
||||
// —— 时间窗(已调到指标上方)
|
||||
const rangeRow = rangeMetricCol.createDiv();
|
||||
rangeRow.addClass('workout-field');
|
||||
rangeRow.createEl('label', { text: t('modal.muscleManager.heatmapRange') });
|
||||
const rangeSelect = rangeRow.createEl('select') as HTMLSelectElement;
|
||||
rangeSelect.addClass('workout-select');
|
||||
rangeSelect.createEl('option', { value: '', text: t('modal.muscleManager.followDefaultRange') });
|
||||
for (const r of RANGE_OPTIONS) {
|
||||
const opt = rangeSelect.createEl('option', { value: r, text: r });
|
||||
if (this.rangeValue === r) opt.selected = true;
|
||||
}
|
||||
const customOpt = rangeSelect.createEl('option', { value: 'custom', text: t('modal.muscleManager.customRange') });
|
||||
if (this.rangeValue === 'custom') customOpt.selected = true;
|
||||
|
||||
const customInput = rangeRow.createEl('input', { type: 'text' }) as HTMLInputElement;
|
||||
customInput.addClass('workout-input');
|
||||
customInput.placeholder = 'YYYY-MM-DD..YYYY-MM-DD';
|
||||
customInput.style.display = this.rangeValue === 'custom' ? 'block' : 'none';
|
||||
customInput.value = this.customRange;
|
||||
|
||||
rangeSelect.addEventListener('change', () => {
|
||||
this.rangeValue = rangeSelect.value;
|
||||
customInput.style.display = this.rangeValue === 'custom' ? 'block' : 'none';
|
||||
});
|
||||
customInput.addEventListener('input', () => {
|
||||
this.customRange = customInput.value.trim();
|
||||
});
|
||||
|
||||
// —— 指标
|
||||
const metricRow = rangeMetricCol.createDiv();
|
||||
metricRow.addClass('workout-field');
|
||||
metricRow.createEl('label', { text: t('modal.muscleManager.heatmapMetric') });
|
||||
const metricSelect = metricRow.createEl('select') as HTMLSelectElement;
|
||||
metricSelect.addClass('workout-select');
|
||||
metricSelect.createEl('option', { value: '', text: t('modal.muscleManager.followDefaultMetric') });
|
||||
for (const stat of this.config.statistics) {
|
||||
const opt = metricSelect.createEl('option', { value: stat.id, text: stat.name });
|
||||
if (stat.id === this.metricId) opt.selected = true;
|
||||
}
|
||||
metricSelect.addEventListener('change', () => {
|
||||
this.metricId = metricSelect.value;
|
||||
// 阈值逐肌独立,不随指标切换清空;若从未设置,会自动按新指标/默认指标模板初始化。
|
||||
this.renderHeatmapSettings();
|
||||
});
|
||||
|
||||
// —— 颜色分档(逐肌,始终可配)
|
||||
// 已有则用已有分档;否则用所选/默认指标(StatDef)的默认分档初始化。
|
||||
if (!this.muscleLevels) {
|
||||
const stat = this.resolveMetricStat();
|
||||
this.muscleLevels = this.normalizeLevels(stat?.heatmapScale ?? DEFAULT_HEATMAP_SCALE);
|
||||
}
|
||||
|
||||
const hint = this.heatmapSection.createEl('p', {
|
||||
text: t('modal.muscleManager.scaleHint'),
|
||||
cls: 'workout-manager-detail',
|
||||
});
|
||||
hint.style.marginTop = '4px';
|
||||
|
||||
// —— 颜色分级数(控制在指标下方):决定下方颜色设置栏的数量 ——
|
||||
const countRow = this.heatmapSection.createDiv();
|
||||
countRow.addClass('workout-field');
|
||||
countRow.createEl('label', { text: t('modal.muscleManager.colorLevels') });
|
||||
const countInput = countRow.createEl('input', { type: 'number' }) as HTMLInputElement;
|
||||
countInput.addClass('workout-input');
|
||||
countInput.setAttribute('step', 'any');
|
||||
countInput.setAttribute('inputmode', 'numeric');
|
||||
countInput.min = '1';
|
||||
countInput.max = '99';
|
||||
countInput.value = String(this.muscleLevels.length);
|
||||
countInput.style.width = '70px';
|
||||
countInput.addEventListener('input', () => {
|
||||
let n = parseInt(countInput.value, 10);
|
||||
if (isNaN(n)) n = 1;
|
||||
n = Math.max(1, Math.min(99, n));
|
||||
this.setLevelCount(n);
|
||||
this.renderHeatmapSettings();
|
||||
});
|
||||
|
||||
// —— 颜色设置栏:左侧色块(可拾取/预览)+十六进制代码,中间 ≤,右侧阈值 ——
|
||||
const scaleContainer = this.heatmapSection.createDiv();
|
||||
for (let i = 0; i < this.muscleLevels.length; i++) {
|
||||
const level = this.muscleLevels[i];
|
||||
const isLast = i === this.muscleLevels.length - 1;
|
||||
const row = scaleContainer.createDiv();
|
||||
row.addClass('workout-scale-row');
|
||||
|
||||
// 色块:原生拾色器兼作预览小圆点
|
||||
const swatch = row.createEl('input', { type: 'color' }) as HTMLInputElement;
|
||||
swatch.value = this.normalizeHex(level.color);
|
||||
swatch.addClass('workout-scale-swatch');
|
||||
|
||||
// 十六进制颜色代码输入框
|
||||
const hexInput = row.createEl('input', { type: 'text' }) as HTMLInputElement;
|
||||
hexInput.addClass('workout-input', 'workout-scale-hex');
|
||||
hexInput.value = level.color;
|
||||
hexInput.placeholder = '#3b82f6';
|
||||
hexInput.title = t('modal.muscleManager.colorHex');
|
||||
|
||||
// 色块 <-> hex 双向同步
|
||||
swatch.addEventListener('input', () => {
|
||||
level.color = swatch.value;
|
||||
hexInput.value = swatch.value;
|
||||
});
|
||||
hexInput.addEventListener('input', () => {
|
||||
const v = hexInput.value.trim();
|
||||
level.color = v;
|
||||
if (/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(v)) {
|
||||
swatch.value = this.normalizeHex(v);
|
||||
}
|
||||
});
|
||||
|
||||
// ≤ 分隔符(末档为无穷大,不显示阈值输入框)
|
||||
const le = row.createEl('span', { text: '≤' });
|
||||
le.addClass('workout-scale-sep');
|
||||
|
||||
if (isLast) {
|
||||
const unbounded = row.createEl('span', { text: t('modal.muscleManager.scaleUnbounded') });
|
||||
unbounded.addClass('workout-scale-unbounded');
|
||||
} else {
|
||||
const maxInput = row.createEl('input', { type: 'number' }) as HTMLInputElement;
|
||||
maxInput.addClass('workout-input', 'workout-scale-max');
|
||||
maxInput.setAttribute('step', 'any');
|
||||
maxInput.setAttribute('inputmode', 'decimal');
|
||||
maxInput.value = level.max === undefined ? '' : String(level.max);
|
||||
maxInput.addEventListener('input', () => {
|
||||
const v = parseFloat(maxInput.value);
|
||||
level.max = isNaN(v) ? undefined : v;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 解析当前应作为「默认分档模板」的 StatDef:
|
||||
// 显式选了指标 → 用该指标;跟随默认 → 找全局默认指标(heatmapDefault: true,种子默认「次数」)。
|
||||
private resolveMetricStat(): StatDef | undefined {
|
||||
if (this.metricId) {
|
||||
return this.config.statistics.find((s) => s.id === this.metricId);
|
||||
}
|
||||
return this.config.statistics.find((s) => s.heatmapDefault) ?? this.config.statistics[0];
|
||||
}
|
||||
|
||||
private colorCss(color: string): string {
|
||||
const map: Record<string, string> = {
|
||||
blue: '#3b82f6',
|
||||
green: '#22c55e',
|
||||
orange: '#f97316',
|
||||
red: '#ef4444',
|
||||
};
|
||||
if (color.startsWith('#')) return color; // 十六进制直接返回
|
||||
return map[color] ?? color;
|
||||
}
|
||||
|
||||
// 把任意来源的分档规整为「可显示/可保存」形态:命名色→hex;缺失阈值补默认。
|
||||
private normalizeLevels(input?: HeatmapLevel[]): HeatmapLevel[] {
|
||||
const base = input && input.length ? input : DEFAULT_HEATMAP_SCALE;
|
||||
const levels = base.map((l) => ({
|
||||
color: this.colorCss(l.color),
|
||||
max: l.max,
|
||||
}));
|
||||
for (let i = 0; i < levels.length; i++) {
|
||||
if (levels[i].max === undefined) {
|
||||
const prev = i > 0 ? levels[i - 1].max ?? 0 : 0;
|
||||
levels[i].max = prev <= 0 ? 5 : prev + 10;
|
||||
}
|
||||
}
|
||||
return levels;
|
||||
}
|
||||
|
||||
// 把颜色规整为 6 位十六进制,供原生 <input type="color"> 使用(短写 #abc → #aabbcc)。
|
||||
private normalizeHex(c: string): string {
|
||||
if (c.startsWith('#')) {
|
||||
if (/^#[0-9a-fA-F]{3}$/.test(c)) {
|
||||
return '#' + c[1] + c[1] + c[2] + c[2] + c[3] + c[3];
|
||||
}
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(c)) return c;
|
||||
}
|
||||
const mapped = this.colorCss(c);
|
||||
return mapped.startsWith('#') && /^#[0-9a-fA-F]{6}$/.test(mapped) ? mapped : '#3b82f6';
|
||||
}
|
||||
|
||||
// 按分级数调整分档数组:减少则截断,增加则追加(新档用渐变默认色 + 递增阈值)。
|
||||
private setLevelCount(n: number): void {
|
||||
const levels = this.muscleLevels ? this.muscleLevels.slice() : [];
|
||||
if (n <= levels.length) {
|
||||
this.muscleLevels = levels.slice(0, n);
|
||||
return;
|
||||
}
|
||||
let prevMax = levels.length ? (levels[levels.length - 1].max ?? 0) : 0;
|
||||
while (levels.length < n) {
|
||||
const idx = levels.length;
|
||||
prevMax = prevMax <= 0 ? 5 : Math.round(prevMax * 1.8);
|
||||
levels.push({ color: hexForIndex(idx, n), max: prevMax });
|
||||
}
|
||||
this.muscleLevels = levels;
|
||||
}
|
||||
|
||||
private async save(): Promise<void> {
|
||||
const id = this.idInput.value.trim();
|
||||
const name = this.nameInput.value.trim();
|
||||
|
||||
if (!id) {
|
||||
new Notice(t('modal.muscleManager.idRequired'));
|
||||
return;
|
||||
}
|
||||
if (!name) {
|
||||
new Notice(t('modal.muscleManager.nameRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
const range = this.rangeValue === 'custom' ? (this.customRange || '') : this.rangeValue;
|
||||
|
||||
const muscleData: Muscle = {
|
||||
id,
|
||||
nameKey: this.options.muscle?.nameKey,
|
||||
name,
|
||||
contributesToCoverage: this.coverageToggle.checked,
|
||||
restThresholdDays: parseInt(this.restThresholdInput.value) || 7,
|
||||
svgRegionIds: Array.from(this.selectedIds),
|
||||
heatmapMetric: this.metricId || undefined,
|
||||
heatmapRange: range || undefined,
|
||||
heatmapLevels: this.muscleLevels ? JSON.parse(JSON.stringify(this.muscleLevels)) : undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
if (this.options.muscle) {
|
||||
await this.dataManager.updateMuscle(id, muscleData);
|
||||
} else {
|
||||
await this.dataManager.addMuscle(muscleData);
|
||||
}
|
||||
new Notice(t('modal.muscleManager.saved'));
|
||||
this.close();
|
||||
} catch (error) {
|
||||
new Notice(t('modal.muscleManager.saveFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
}
|
||||
}
|
||||
218
src/ui/MuscleManagerModal.ts
Normal file
218
src/ui/MuscleManagerModal.ts
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
import { Modal, Notice } from 'obsidian';
|
||||
import { DataManager } from '../data/DataManager';
|
||||
import { applyMappingTier, MappingTier } from '../data/muscleMapping';
|
||||
import { getExerciseName, getMuscleName } from '../data/display';
|
||||
import { Muscle } from '../data/types';
|
||||
import { t } from '../i18n';
|
||||
import { MuscleEditModal } from './MuscleEditModal';
|
||||
import { confirmWithModal } from './Confirm';
|
||||
|
||||
/* MuscleManagerModal —— "肌肉"列表管理弹窗。
|
||||
* 首次打开时显示引导卡,让用户选择默认/精简/手动三档导入映射。
|
||||
* 之后列出所有肌肉,支持编辑、删除、重新套用预设。 */
|
||||
|
||||
export class MuscleManagerModal extends Modal {
|
||||
private dataManager: DataManager;
|
||||
private musclesContainer!: HTMLDivElement;
|
||||
private muscles: Muscle[] = [];
|
||||
private searchQuery = '';
|
||||
private searchWrap!: HTMLDivElement;
|
||||
|
||||
constructor(dataManager: DataManager) {
|
||||
super(dataManager.app);
|
||||
this.dataManager = dataManager;
|
||||
}
|
||||
|
||||
async onOpen(): Promise<void> {
|
||||
const { contentEl } = this;
|
||||
contentEl.addClass('workout-manager-modal');
|
||||
|
||||
const config = await this.dataManager.getConfig();
|
||||
this.muscles = config.muscles;
|
||||
|
||||
contentEl.createEl('h2', { text: t('modal.muscleManager.title') });
|
||||
|
||||
// 顶部搜索框(吸顶)。首次引导阶段先隐藏,进入列表后再显示。
|
||||
this.searchWrap = contentEl.createDiv();
|
||||
this.searchWrap.addClass('workout-manager-search');
|
||||
this.searchWrap.style.display = 'none';
|
||||
const search = this.searchWrap.createEl('input', { type: 'text', cls: 'workout-input' });
|
||||
search.placeholder = t('modal.muscleManager.search');
|
||||
search.addEventListener('input', () => {
|
||||
this.searchQuery = search.value.trim().toLowerCase();
|
||||
this.renderMuscles();
|
||||
});
|
||||
|
||||
this.musclesContainer = contentEl.createDiv();
|
||||
this.musclesContainer.addClass('workout-muscles-list');
|
||||
|
||||
if (!this.dataManager.getSettings().muscleMappingInitialized) {
|
||||
this.renderOnboarding();
|
||||
} else {
|
||||
this.renderMuscles();
|
||||
}
|
||||
}
|
||||
|
||||
// 首次引导:三档选择
|
||||
private renderOnboarding(): void {
|
||||
this.searchWrap.style.display = 'none';
|
||||
this.musclesContainer.empty();
|
||||
|
||||
const card = this.musclesContainer.createDiv();
|
||||
card.addClass('workout-heatmap-empty');
|
||||
card.createEl('p', { text: t('modal.muscleManager.onboardingTitle') });
|
||||
card.createEl('p', {
|
||||
text: t('modal.muscleManager.onboardingDesc'),
|
||||
cls: 'workout-manager-detail',
|
||||
});
|
||||
|
||||
const tiers: { tier: MappingTier; labelKey: string; descKey: string }[] = [
|
||||
{ tier: 'default', labelKey: 'modal.muscleManager.tierDefault', descKey: 'modal.muscleManager.tierDefaultDesc' },
|
||||
{ tier: 'minimal', labelKey: 'modal.muscleManager.tierMinimal', descKey: 'modal.muscleManager.tierMinimalDesc' },
|
||||
{ tier: 'manual', labelKey: 'modal.muscleManager.tierManual', descKey: 'modal.muscleManager.tierManualDesc' },
|
||||
];
|
||||
|
||||
for (const { tier, labelKey, descKey } of tiers) {
|
||||
const row = card.createDiv();
|
||||
row.addClass('workout-row');
|
||||
const btn = row.createEl('button', { text: t(labelKey) });
|
||||
btn.addClass('mod-cta');
|
||||
btn.style.marginRight = '8px';
|
||||
btn.addEventListener('click', () => this.applyTier(tier));
|
||||
row.createEl('span', { text: t(descKey), cls: 'workout-manager-detail' });
|
||||
}
|
||||
}
|
||||
|
||||
private async applyTier(tier: MappingTier): Promise<void> {
|
||||
const config = await this.dataManager.getConfig();
|
||||
applyMappingTier(config, tier);
|
||||
await this.dataManager.saveConfig(config);
|
||||
this.dataManager.getSettings().muscleMappingInitialized = true;
|
||||
await this.dataManager.saveSettings();
|
||||
this.muscles = config.muscles;
|
||||
new Notice(t('modal.muscleManager.mappingApplied'));
|
||||
this.renderMuscles();
|
||||
}
|
||||
|
||||
private renderMuscles(): void {
|
||||
this.searchWrap.style.display = '';
|
||||
this.musclesContainer.empty();
|
||||
|
||||
const toolbar = this.musclesContainer.createDiv();
|
||||
toolbar.addClass('workout-btn-row');
|
||||
toolbar.style.justifyContent = 'flex-start';
|
||||
|
||||
const addBtn = toolbar.createEl('button', { text: t('modal.muscleManager.add') });
|
||||
addBtn.addClass('mod-cta');
|
||||
addBtn.addEventListener('click', () => this.openAddMuscle());
|
||||
|
||||
const reinitBtn = toolbar.createEl('button', { text: t('modal.muscleManager.reapplyPreset') });
|
||||
reinitBtn.addClass('mod-muted');
|
||||
reinitBtn.addEventListener('click', () => this.renderOnboarding());
|
||||
|
||||
const list = this.musclesContainer.createDiv();
|
||||
list.addClass('workout-muscles-list');
|
||||
|
||||
// 按搜索词过滤(名称或 ID);空列表与无匹配分别给出提示。
|
||||
const q = this.searchQuery;
|
||||
const visible = this.muscles.filter((muscle) => {
|
||||
if (!q) return true;
|
||||
const name = (getMuscleName(muscle) || muscle.id).toLowerCase();
|
||||
return name.includes(q) || muscle.id.toLowerCase().includes(q);
|
||||
});
|
||||
|
||||
if (this.muscles.length === 0) {
|
||||
list.createEl('p', { text: t('settings.noMuscles') });
|
||||
return;
|
||||
}
|
||||
if (visible.length === 0) {
|
||||
list.createEl('p', { text: t('common.noMatch') });
|
||||
return;
|
||||
}
|
||||
|
||||
for (const muscle of visible) {
|
||||
const muscleName = getMuscleName(muscle) || muscle.id;
|
||||
const mappedCount = muscle.svgRegionIds?.length ?? 0;
|
||||
|
||||
const row = list.createDiv();
|
||||
row.addClass('workout-card');
|
||||
|
||||
const infoCol = row.createDiv();
|
||||
infoCol.addClass('workout-card-info');
|
||||
infoCol.createEl('div', { text: muscleName, cls: 'workout-card-title' });
|
||||
|
||||
// 注:side(部位)由 svgRegionIds 经目录派生,不再单列(§13)
|
||||
const detailLines = [
|
||||
`${t('common.name')}: ${muscle.id}`,
|
||||
`${t('modal.muscleManager.coverage')}: ${muscle.contributesToCoverage ? t('settings.on') : t('settings.off')}`,
|
||||
`${t('modal.muscleManager.restThreshold')}: ${muscle.restThresholdDays ?? 7} ${t('settings.days')}`,
|
||||
`${t('modal.muscleManager.mappedPaths')}: ${mappedCount}`,
|
||||
];
|
||||
|
||||
for (const detail of detailLines) {
|
||||
const detailEl = infoCol.createEl('div', { text: detail, cls: 'workout-card-meta' });
|
||||
detailEl.title = detail;
|
||||
}
|
||||
|
||||
const btnCol = row.createDiv();
|
||||
btnCol.addClass('workout-card-actions');
|
||||
|
||||
const editBtn = btnCol.createEl('button', { text: t('modal.muscleManager.edit') });
|
||||
editBtn.addClass('workout-action-btn');
|
||||
editBtn.addEventListener('click', () => {
|
||||
const editModal = new MuscleEditModal(this.dataManager, { muscle });
|
||||
editModal.onClose = () => this.refresh();
|
||||
editModal.open();
|
||||
});
|
||||
|
||||
const deleteBtn = btnCol.createEl('button', { text: t('modal.muscleManager.delete') });
|
||||
deleteBtn.addClass('workout-danger-btn');
|
||||
deleteBtn.addEventListener('click', () => this.deleteMuscle(muscle));
|
||||
}
|
||||
|
||||
// 底部工具栏:关闭(长列表时无需滚到顶部即可关闭;顶部已有「新增肌肉」按钮)。
|
||||
const bottom = this.musclesContainer.createDiv();
|
||||
bottom.addClass('workout-btn-row');
|
||||
const closeBtn = bottom.createEl('button', { text: t('common.close') });
|
||||
closeBtn.addClass('mod-muted');
|
||||
closeBtn.addEventListener('click', () => this.close());
|
||||
}
|
||||
|
||||
private openAddMuscle(): void {
|
||||
const editModal = new MuscleEditModal(this.dataManager);
|
||||
editModal.onClose = () => this.refresh();
|
||||
editModal.open();
|
||||
}
|
||||
|
||||
private async deleteMuscle(muscle: Muscle): Promise<void> {
|
||||
const muscleName = getMuscleName(muscle) || muscle.id;
|
||||
|
||||
const config = await this.dataManager.getConfig();
|
||||
const referencedExercises = config.exercises.filter((exercise) =>
|
||||
exercise.muscles?.some((entry) => entry.muscleId === muscle.id)
|
||||
);
|
||||
|
||||
if (referencedExercises.length > 0) {
|
||||
const names = referencedExercises.map((exercise) => getExerciseName(exercise) || exercise.id).join(', ');
|
||||
if (!(await confirmWithModal(this.app, `${t('settings.muscleReferencedBy')}: ${names}\n${t('settings.confirmDelete')}?`))) {
|
||||
return;
|
||||
}
|
||||
} else if (!(await confirmWithModal(this.app, `${t('settings.confirmDelete')}: ${muscleName}?`))) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.dataManager.deleteMuscle(muscle.id);
|
||||
new Notice(`${muscleName} ${t('modal.muscleManager.deleted')}`);
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
private async refresh(): Promise<void> {
|
||||
const config = await this.dataManager.getConfig();
|
||||
this.muscles = config.muscles;
|
||||
this.renderMuscles();
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.contentEl.empty();
|
||||
}
|
||||
}
|
||||
486
src/ui/NewPlanModal.ts
Normal file
486
src/ui/NewPlanModal.ts
Normal file
|
|
@ -0,0 +1,486 @@
|
|||
import { Modal, Notice } from 'obsidian';
|
||||
import { DataManager } from '../data/DataManager';
|
||||
import { Exercise, FieldDef, TrainingPlanInstance, PlanItem, PlanSet, TimeRule, TrainingType } from '../data/types';
|
||||
import { getExerciseName, getFieldLabel, getFieldUnit, formatTimeRule, getTrainingTypeName } from '../data/display';
|
||||
import { t } from '../i18n';
|
||||
import { parseDuration, secondsToParts } from '../util/duration';
|
||||
import { formatMass, parseMass } from '../util/units';
|
||||
import { generateId } from '../util/id';
|
||||
import { findSchemeNotes, extractSchemeExercises, invalidateSchemeCache, SchemeNote } from '../data/planScanner';
|
||||
|
||||
/*
|
||||
* NewPlanModal.ts —— 「新增训练计划」弹窗(配置形态)
|
||||
* 从已有训练方案(含 workout-plan 代码块的笔记)加载训练项,或手动添加,
|
||||
* 为每个训练项的每一组预设字段,配置计划时间(具体日期 / 每周 ISO 周几),
|
||||
* 保存为一条 TrainingPlanInstance 聚合进 workout-config.json 的 plans 字段。
|
||||
* 与「训练计划管理」是独立功能:本弹窗只负责「创建 / 编辑」单个计划。
|
||||
*/
|
||||
interface ModalSet {
|
||||
id: string;
|
||||
fields: Record<string, unknown>;
|
||||
}
|
||||
interface ModalItem {
|
||||
exerciseId: string;
|
||||
category: string;
|
||||
enabled: boolean;
|
||||
sets: ModalSet[];
|
||||
}
|
||||
|
||||
export class NewPlanModal extends Modal {
|
||||
private dataManager: DataManager;
|
||||
private editPlan?: TrainingPlanInstance;
|
||||
|
||||
private exercises: Exercise[] = [];
|
||||
private trainingTypes: TrainingType[] = [];
|
||||
|
||||
private name = '';
|
||||
private nameManuallyEdited = false;
|
||||
private timeRule: TimeRule = { type: 'date', date: this.todayStr() };
|
||||
private sourceNote?: string;
|
||||
private items: ModalItem[] = [];
|
||||
|
||||
private nameInput!: HTMLInputElement;
|
||||
private timeTypeSelect!: HTMLSelectElement;
|
||||
private timeControlsEl!: HTMLDivElement;
|
||||
private schemeSelect!: HTMLSelectElement;
|
||||
private selectAllCheckbox!: HTMLInputElement;
|
||||
private selectLabel!: HTMLSpanElement;
|
||||
private itemsContainer!: HTMLDivElement;
|
||||
|
||||
constructor(dataManager: DataManager, options?: { editPlan?: TrainingPlanInstance }) {
|
||||
super(dataManager.app);
|
||||
this.dataManager = dataManager;
|
||||
this.editPlan = options?.editPlan;
|
||||
}
|
||||
|
||||
private todayStr(): string {
|
||||
const n = new Date();
|
||||
const pad = (x: number) => String(x).padStart(2, '0');
|
||||
return `${n.getFullYear()}-${pad(n.getMonth() + 1)}-${pad(n.getDate())}`;
|
||||
}
|
||||
|
||||
async onOpen(): Promise<void> {
|
||||
const { contentEl } = this;
|
||||
contentEl.addClass('workout-edit-modal', 'workout-plan-modal');
|
||||
|
||||
const config = await this.dataManager.getConfig();
|
||||
this.exercises = config.exercises;
|
||||
this.trainingTypes = config.trainingTypes;
|
||||
|
||||
// 编辑模式:用已有计划预填
|
||||
if (this.editPlan) {
|
||||
this.name = this.editPlan.name;
|
||||
this.nameManuallyEdited = true;
|
||||
this.timeRule = JSON.parse(JSON.stringify(this.editPlan.timeRule));
|
||||
this.sourceNote = this.editPlan.sourceNote;
|
||||
this.items = this.editPlan.items.map((it) => ({
|
||||
exerciseId: it.exerciseId,
|
||||
category: it.category,
|
||||
enabled: it.enabled,
|
||||
sets: it.sets.map((s) => ({ id: s.id, fields: { ...s.fields } })),
|
||||
}));
|
||||
}
|
||||
|
||||
contentEl.createEl('h2', { text: this.editPlan ? t('modal.newPlan.editTitle') : t('modal.newPlan.title') });
|
||||
|
||||
// 计划名称(标签上方,对齐重设计稿)
|
||||
const nameField = contentEl.createDiv();
|
||||
nameField.addClass('workout-field');
|
||||
nameField.createEl('label', { text: t('modal.newPlan.name') });
|
||||
this.nameInput = nameField.createEl('input', { type: 'text' });
|
||||
this.nameInput.addClass('workout-input');
|
||||
this.nameInput.placeholder = t('modal.newPlan.namePlaceholder');
|
||||
this.nameInput.value = this.name || this.defaultName();
|
||||
this.nameInput.addEventListener('input', () => {
|
||||
this.name = this.nameInput.value;
|
||||
this.nameManuallyEdited = true;
|
||||
});
|
||||
|
||||
// 计划时间类型 + 控件(两列网格:左类型下拉,右日期 / 周几)
|
||||
const timeTwoCol = contentEl.createDiv();
|
||||
timeTwoCol.addClass('workout-two-col');
|
||||
const timeField = timeTwoCol.createDiv();
|
||||
timeField.addClass('workout-field');
|
||||
timeField.createEl('label', { text: t('modal.newPlan.time') });
|
||||
this.timeTypeSelect = timeField.createEl('select');
|
||||
this.timeTypeSelect.addClass('workout-select');
|
||||
this.timeTypeSelect.createEl('option', { value: 'date', text: t('modal.newPlan.timeDate') });
|
||||
this.timeTypeSelect.createEl('option', { value: 'weekday', text: t('modal.newPlan.timeWeek') });
|
||||
this.timeTypeSelect.value = this.timeRule.type;
|
||||
this.timeTypeSelect.addEventListener('change', () => {
|
||||
this.timeRule.type = this.timeTypeSelect.value as 'date' | 'weekday';
|
||||
this.renderTimeControls();
|
||||
});
|
||||
this.timeControlsEl = timeTwoCol.createDiv();
|
||||
this.timeControlsEl.addClass('workout-plan-time-controls');
|
||||
this.renderTimeControls();
|
||||
|
||||
// 选择训练方案(下拉,标签上方)
|
||||
const schemeField = contentEl.createDiv();
|
||||
schemeField.addClass('workout-field');
|
||||
schemeField.createEl('label', { text: t('modal.newPlan.selectPlan') });
|
||||
this.schemeSelect = schemeField.createEl('select');
|
||||
this.schemeSelect.addClass('workout-select');
|
||||
await this.populateSchemeSelect();
|
||||
|
||||
// 全选行
|
||||
const selectAllRow = contentEl.createDiv();
|
||||
selectAllRow.addClass('workout-row', 'workout-plan-selectall');
|
||||
this.selectAllCheckbox = selectAllRow.createEl('input', { type: 'checkbox' });
|
||||
this.selectAllCheckbox.addEventListener('change', () => {
|
||||
const checked = this.selectAllCheckbox.checked;
|
||||
this.items.forEach((it) => (it.enabled = checked));
|
||||
this.renderItems();
|
||||
});
|
||||
this.selectLabel = selectAllRow.createSpan({ text: '' });
|
||||
this.selectLabel.addClass('workout-hint');
|
||||
|
||||
// 训练项容器
|
||||
this.itemsContainer = contentEl.createDiv();
|
||||
this.itemsContainer.addClass('workout-plan-items');
|
||||
|
||||
// 添加项目按钮
|
||||
const addRow = contentEl.createDiv();
|
||||
addRow.addClass('workout-btn-row', 'workout-plan-toolbar');
|
||||
const addBtn = addRow.createEl('button', { text: t('modal.newPlan.addItem') });
|
||||
addBtn.addClass('mod-cta');
|
||||
addBtn.addEventListener('click', () => this.addItem());
|
||||
|
||||
// 底部按钮
|
||||
const btnRow = contentEl.createDiv();
|
||||
btnRow.addClass('workout-btn-row');
|
||||
const cancelBtn = btnRow.createEl('button', { text: t('common.cancel') });
|
||||
cancelBtn.addClass('mod-muted');
|
||||
cancelBtn.addEventListener('click', () => this.close());
|
||||
const saveBtn = btnRow.createEl('button', { text: t('common.save') });
|
||||
saveBtn.addClass('mod-cta');
|
||||
saveBtn.addEventListener('click', () => this.save());
|
||||
|
||||
this.renderItems();
|
||||
}
|
||||
|
||||
private defaultName(): string {
|
||||
if (this.sourceNote) return `${this.sourceNote} ${this.todayStr()}`;
|
||||
return `${t('modal.newPlan.defaultPrefix')} ${this.todayStr()}`;
|
||||
}
|
||||
|
||||
private async populateSchemeSelect(): Promise<void> {
|
||||
this.schemeSelect.empty();
|
||||
this.schemeSelect.createEl('option', { value: '', text: t('modal.newPlan.noScheme') });
|
||||
let notes: SchemeNote[] = [];
|
||||
try {
|
||||
notes = await findSchemeNotes(this.app);
|
||||
} catch {
|
||||
notes = [];
|
||||
}
|
||||
for (const note of notes) {
|
||||
this.schemeSelect.createEl('option', { value: note.path, text: note.name });
|
||||
}
|
||||
if (this.sourceNote) {
|
||||
const match = notes.find((n) => n.name === this.sourceNote);
|
||||
if (match) this.schemeSelect.value = match.path;
|
||||
}
|
||||
this.schemeSelect.addEventListener('change', () => this.onSchemeChange());
|
||||
}
|
||||
|
||||
private async onSchemeChange(): Promise<void> {
|
||||
const path = this.schemeSelect.value;
|
||||
if (!path) {
|
||||
this.sourceNote = undefined;
|
||||
return;
|
||||
}
|
||||
const config = await this.dataManager.getConfig();
|
||||
const extracted = await extractSchemeExercises(this.app, path, config);
|
||||
this.sourceNote = this.schemeSelect.options[this.schemeSelect.selectedIndex]?.text;
|
||||
// 合并:保留已有项(含其字段/组),仅追加方案里尚未存在的动作,避免误选方案清空用户配置
|
||||
const existingIds = new Set(this.items.map((it) => it.exerciseId));
|
||||
for (const e of extracted) {
|
||||
if (existingIds.has(e.exerciseId)) continue; // 已存在则保留原配置,不覆盖
|
||||
this.items.push({
|
||||
exerciseId: e.exerciseId,
|
||||
category: e.category,
|
||||
enabled: true,
|
||||
sets: [{ id: generateId(), fields: {} }],
|
||||
});
|
||||
existingIds.add(e.exerciseId);
|
||||
}
|
||||
if (!this.nameManuallyEdited) {
|
||||
this.name = this.defaultName();
|
||||
this.nameInput.value = this.name;
|
||||
}
|
||||
this.renderItems();
|
||||
}
|
||||
|
||||
private renderTimeControls(): void {
|
||||
this.timeControlsEl.empty();
|
||||
if (this.timeRule.type === 'date') {
|
||||
const dateField = this.timeControlsEl.createDiv();
|
||||
dateField.addClass('workout-field');
|
||||
dateField.createEl('label', { text: t('modal.newPlan.date') });
|
||||
const input = dateField.createEl('input', { type: 'date' });
|
||||
input.addClass('workout-input');
|
||||
input.value = this.timeRule.date || this.todayStr();
|
||||
input.addEventListener('change', () => {
|
||||
this.timeRule.date = input.value;
|
||||
});
|
||||
} else {
|
||||
if (!this.timeRule.weekdays) this.timeRule.weekdays = [];
|
||||
const labels = ['一', '二', '三', '四', '五', '六', '日']; // ISO 周一=1 … 周日=7
|
||||
const chips = this.timeControlsEl.createDiv();
|
||||
chips.addClass('workout-plan-weekdays');
|
||||
labels.forEach((lab, i) => {
|
||||
const iso = i + 1;
|
||||
const chip = chips.createEl('button', { text: lab });
|
||||
chip.addClass('workout-plan-weekday');
|
||||
if (this.timeRule.weekdays!.includes(iso)) chip.addClass('is-active');
|
||||
chip.addEventListener('click', () => {
|
||||
const arr = this.timeRule.weekdays!;
|
||||
const idx = arr.indexOf(iso);
|
||||
if (idx >= 0) arr.splice(idx, 1);
|
||||
else arr.push(iso);
|
||||
this.renderTimeControls();
|
||||
});
|
||||
});
|
||||
const hint = this.timeControlsEl.createEl('div', { text: formatTimeRule(this.timeRule) });
|
||||
hint.addClass('workout-hint');
|
||||
}
|
||||
}
|
||||
|
||||
private addItem(exerciseId?: string): void {
|
||||
const ex = exerciseId ? this.exercises.find((e) => e.id === exerciseId) : this.exercises[0];
|
||||
if (!ex) {
|
||||
new Notice(t('modal.newPlan.noExercise'));
|
||||
return;
|
||||
}
|
||||
this.items.push({
|
||||
exerciseId: ex.id,
|
||||
category: ex.category,
|
||||
enabled: true,
|
||||
sets: [{ id: generateId(), fields: {} }],
|
||||
});
|
||||
this.renderItems();
|
||||
}
|
||||
|
||||
private renderItems(): void {
|
||||
this.itemsContainer.empty();
|
||||
this.items.forEach((item, idx) => this.renderItemCard(item, idx));
|
||||
this.updateSelectionLabel();
|
||||
}
|
||||
|
||||
private renderItemCard(item: ModalItem, index: number): void {
|
||||
const card = this.itemsContainer.createDiv();
|
||||
card.addClass('workout-plan-card');
|
||||
|
||||
const header = card.createDiv();
|
||||
header.addClass('workout-plan-card-header');
|
||||
|
||||
const enabled = header.createEl('input', { type: 'checkbox' });
|
||||
enabled.checked = item.enabled;
|
||||
enabled.addEventListener('change', () => {
|
||||
item.enabled = enabled.checked;
|
||||
this.updateSelectionLabel();
|
||||
});
|
||||
|
||||
const exerciseSelect = header.createEl('select');
|
||||
exerciseSelect.addClass('workout-select');
|
||||
for (const ex of this.exercises) {
|
||||
const type = this.trainingTypes.find((tt) => tt.id === ex.category);
|
||||
exerciseSelect.createEl('option', {
|
||||
value: ex.id,
|
||||
text: `${getExerciseName(ex)} (${getTrainingTypeName(type) || ex.category})`,
|
||||
});
|
||||
}
|
||||
exerciseSelect.value = item.exerciseId;
|
||||
exerciseSelect.addEventListener('change', () => {
|
||||
const ex = this.exercises.find((e) => e.id === exerciseSelect.value);
|
||||
if (ex) {
|
||||
item.exerciseId = ex.id;
|
||||
item.category = ex.category;
|
||||
}
|
||||
});
|
||||
|
||||
const removeBtn = header.createEl('button', { text: t('modal.newPlan.removeItem') });
|
||||
removeBtn.addClass('mod-warning', 'workout-plan-remove-btn');
|
||||
removeBtn.addEventListener('click', () => {
|
||||
this.items.splice(index, 1);
|
||||
this.renderItems();
|
||||
});
|
||||
|
||||
const setsContainer = card.createDiv();
|
||||
setsContainer.addClass('workout-plan-set-list');
|
||||
item.sets.forEach((set, sidx) => this.renderSetRow(item, set, sidx, setsContainer));
|
||||
|
||||
const addSetBtn = card.createEl('button', { text: t('modal.newPlan.addSet') });
|
||||
addSetBtn.addClass('workout-btn-outline', 'workout-plan-addset');
|
||||
addSetBtn.addEventListener('click', () => {
|
||||
item.sets.push({ id: generateId(), fields: {} });
|
||||
this.renderItems();
|
||||
});
|
||||
}
|
||||
|
||||
private renderSetRow(item: ModalItem, set: ModalSet, sidx: number, container: HTMLDivElement): void {
|
||||
const row = container.createDiv();
|
||||
row.addClass('workout-plan-set-grid');
|
||||
|
||||
const no = row.createDiv();
|
||||
no.addClass('workout-plan-set-no');
|
||||
no.setText(t('modal.newPlan.setName', { n: String(sidx + 1) }));
|
||||
|
||||
const fieldsBox = row.createDiv();
|
||||
fieldsBox.addClass('workout-plan-set-fields');
|
||||
this.appendSetFields(item.category, set, fieldsBox);
|
||||
|
||||
const delBtn = row.createEl('button', { text: '✕' });
|
||||
delBtn.addClass('mod-warning', 'workout-plan-set-del');
|
||||
delBtn.addEventListener('click', () => {
|
||||
item.sets.splice(sidx, 1);
|
||||
if (item.sets.length === 0) item.sets.push({ id: generateId(), fields: {} });
|
||||
this.renderItems();
|
||||
});
|
||||
}
|
||||
|
||||
private appendSetFields(category: string, set: ModalSet, container: HTMLDivElement): void {
|
||||
const type = this.trainingTypes.find((tt) => tt.id === category);
|
||||
if (!type) return;
|
||||
const unit = this.dataManager.getSettings().unit;
|
||||
for (const field of type.fields) {
|
||||
const fieldRow = container.createDiv();
|
||||
fieldRow.addClass('workout-plan-set-field');
|
||||
const unitText = getFieldUnit(field, unit);
|
||||
fieldRow.createEl('label', { text: `${getFieldLabel(field)}${unitText ? ` (${unitText})` : ''}` });
|
||||
const value = set.fields[field.key];
|
||||
switch (field.inputType) {
|
||||
case 'number': {
|
||||
const num = fieldRow.createEl('input', { type: 'number' });
|
||||
num.addClass('workout-input');
|
||||
num.setAttribute('step', 'any');
|
||||
num.setAttribute('inputmode', 'decimal');
|
||||
if (field.mass) {
|
||||
num.value = value != null ? formatMass(Number(value), unit) : '';
|
||||
num.addEventListener('change', () => {
|
||||
set.fields[field.key] = parseMass(num.value, unit);
|
||||
});
|
||||
} else {
|
||||
num.value = value != null ? String(value) : '';
|
||||
num.addEventListener('change', () => {
|
||||
set.fields[field.key] = parseFloat(num.value);
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'duration': {
|
||||
const dur = fieldRow.createDiv();
|
||||
dur.addClass('workout-duration-row');
|
||||
const parts = value != null ? secondsToParts(Number(value)) : { hours: 0, minutes: 0, seconds: 0 };
|
||||
const h = dur.createEl('input', { type: 'number', placeholder: t('duration.hour') });
|
||||
h.addClass('workout-input');
|
||||
h.setAttribute('step', 'any');
|
||||
h.setAttribute('inputmode', 'numeric');
|
||||
h.value = String(parts.hours);
|
||||
const mi = dur.createEl('input', { type: 'number', placeholder: t('duration.minute') });
|
||||
mi.addClass('workout-input');
|
||||
mi.setAttribute('step', 'any');
|
||||
mi.setAttribute('inputmode', 'numeric');
|
||||
mi.value = String(parts.minutes);
|
||||
const s = dur.createEl('input', { type: 'number', placeholder: t('duration.second') });
|
||||
s.addClass('workout-input');
|
||||
s.setAttribute('step', 'any');
|
||||
s.setAttribute('inputmode', 'numeric');
|
||||
s.value = String(parts.seconds);
|
||||
const upd = () => {
|
||||
set.fields[field.key] = parseDuration(
|
||||
parseInt(h.value, 10) || 0,
|
||||
parseInt(mi.value, 10) || 0,
|
||||
parseInt(s.value, 10) || 0
|
||||
);
|
||||
};
|
||||
h.addEventListener('change', upd);
|
||||
mi.addEventListener('change', upd);
|
||||
s.addEventListener('change', upd);
|
||||
break;
|
||||
}
|
||||
case 'text': {
|
||||
const txt = fieldRow.createEl('input', { type: 'text' });
|
||||
txt.addClass('workout-input');
|
||||
txt.value = value != null ? String(value) : '';
|
||||
txt.addEventListener('change', () => {
|
||||
set.fields[field.key] = txt.value;
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'select': {
|
||||
const sel = fieldRow.createEl('select');
|
||||
sel.addClass('workout-select');
|
||||
const opts = field.options ?? [];
|
||||
if (opts.length === 0) sel.createEl('option', { value: '', text: t('settings.none') });
|
||||
else
|
||||
for (const o of opts) {
|
||||
const opt = sel.createEl('option', { value: o, text: o });
|
||||
if (value != null && String(value) === o) opt.selected = true;
|
||||
}
|
||||
if (value == null && opts.length > 0) set.fields[field.key] = sel.value;
|
||||
sel.addEventListener('change', () => {
|
||||
set.fields[field.key] = sel.value;
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private updateSelectionLabel(): void {
|
||||
const total = this.items.length;
|
||||
const selected = this.items.filter((i) => i.enabled).length;
|
||||
this.selectAllCheckbox.checked = total > 0 && selected === total;
|
||||
this.selectAllCheckbox.indeterminate = selected > 0 && selected < total;
|
||||
this.selectLabel.setText(t('modal.newPlan.selected', { x: String(selected), y: String(total) }));
|
||||
}
|
||||
|
||||
private async save(): Promise<void> {
|
||||
const name = this.name.trim();
|
||||
if (!name) {
|
||||
new Notice(t('modal.newPlan.nameRequired'));
|
||||
return;
|
||||
}
|
||||
const taken = await this.dataManager.isPlanNameTaken(name, this.editPlan?.id);
|
||||
if (taken) {
|
||||
new Notice(t('modal.newPlan.nameDuplicate'));
|
||||
return;
|
||||
}
|
||||
const enabledItems = this.items.filter((i) => i.enabled);
|
||||
if (enabledItems.length === 0) {
|
||||
new Notice(t('modal.newPlan.atLeastOne'));
|
||||
return;
|
||||
}
|
||||
if (this.timeRule.type === 'weekday' && (!this.timeRule.weekdays || this.timeRule.weekdays.length === 0)) {
|
||||
this.timeRule.weekdays = [1];
|
||||
}
|
||||
|
||||
const plan: TrainingPlanInstance = {
|
||||
id: this.editPlan?.id ?? generateId(),
|
||||
name,
|
||||
timeRule: { ...this.timeRule },
|
||||
sourceNote: this.sourceNote,
|
||||
createdAt: this.editPlan?.createdAt ?? this.todayStr(),
|
||||
items: enabledItems.map((i) => ({
|
||||
exerciseId: i.exerciseId,
|
||||
category: i.category,
|
||||
enabled: true,
|
||||
sets: i.sets.map((s) => ({ id: s.id, fields: { ...s.fields } })),
|
||||
})),
|
||||
};
|
||||
|
||||
try {
|
||||
await this.dataManager.upsertPlan(plan);
|
||||
invalidateSchemeCache();
|
||||
new Notice(this.editPlan ? t('modal.newPlan.updated') : t('modal.newPlan.saved'));
|
||||
this.close();
|
||||
} catch {
|
||||
new Notice(t('modal.newPlan.saveFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.contentEl.empty();
|
||||
}
|
||||
}
|
||||
169
src/ui/PlanSetEditModal.ts
Normal file
169
src/ui/PlanSetEditModal.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import { Modal, Notice } from 'obsidian';
|
||||
import { DataManager } from '../data/DataManager';
|
||||
import { FieldDef, TrainingType } from '../data/types';
|
||||
import { getFieldLabel, getFieldUnit } from '../data/display';
|
||||
import { t } from '../i18n';
|
||||
import { secondsToParts, parseDuration } from '../util/duration';
|
||||
import { formatMass, parseMass } from '../util/units';
|
||||
|
||||
/*
|
||||
* PlanSetEditModal.ts —— 训练计划代码块里「编辑」单组时弹出的小窗。
|
||||
* 复用「编辑训练记录」界面的同一套字段渲染(数字/时长/文本/下拉),
|
||||
* 但这里是「计划记录」而非实际记录:保存只把改动写到计划预设(workout-config.json),
|
||||
* 或由调用方决定是否回写 CSV(已完成组)。训练记录时间字段不出现(由「完成」写入)。
|
||||
*/
|
||||
export interface PlanSetEditOptions {
|
||||
exerciseId: string;
|
||||
category: string;
|
||||
title: string; // 如「编辑 · 卧推 · 第 1 组」
|
||||
initialFields: Record<string, unknown>;
|
||||
onSave: (fields: Record<string, unknown>) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export class PlanSetEditModal extends Modal {
|
||||
private dataManager: DataManager;
|
||||
private options: PlanSetEditOptions;
|
||||
private trainingTypes: TrainingType[] = [];
|
||||
private fieldValues: Record<string, unknown> = {};
|
||||
private fieldsContainer!: HTMLDivElement;
|
||||
|
||||
constructor(dataManager: DataManager, options: PlanSetEditOptions) {
|
||||
super(dataManager.app);
|
||||
this.dataManager = dataManager;
|
||||
this.options = options;
|
||||
this.fieldValues = { ...options.initialFields };
|
||||
}
|
||||
|
||||
async onOpen(): Promise<void> {
|
||||
const { contentEl } = this;
|
||||
contentEl.addClass('workout-edit-modal');
|
||||
|
||||
const config = await this.dataManager.getConfig();
|
||||
this.trainingTypes = config.trainingTypes;
|
||||
|
||||
contentEl.createEl('h2', { text: this.options.title });
|
||||
|
||||
this.fieldsContainer = contentEl.createDiv();
|
||||
this.fieldsContainer.addClass('workout-fields');
|
||||
this.renderFields();
|
||||
|
||||
const btnRow = contentEl.createDiv();
|
||||
btnRow.addClass('workout-btn-row');
|
||||
const cancelBtn = btnRow.createEl('button', { text: t('common.cancel') });
|
||||
cancelBtn.addClass('mod-muted');
|
||||
cancelBtn.addEventListener('click', () => this.close());
|
||||
const saveBtn = btnRow.createEl('button', { text: t('common.save') });
|
||||
saveBtn.addClass('mod-cta');
|
||||
saveBtn.addEventListener('click', () => this.save());
|
||||
}
|
||||
|
||||
// 与 RecordModal.renderFields 一致的字段渲染(不渲染时间/计划字段)
|
||||
private renderFields(): void {
|
||||
this.fieldsContainer.empty();
|
||||
const type = this.trainingTypes.find((tt) => tt.id === this.options.category);
|
||||
if (!type) return;
|
||||
const unit = this.dataManager.getSettings().unit;
|
||||
|
||||
for (const field of type.fields) {
|
||||
const fieldRow = this.fieldsContainer.createDiv();
|
||||
fieldRow.addClass('workout-field');
|
||||
const unitText = getFieldUnit(field, unit);
|
||||
fieldRow.createEl('label', { text: `${getFieldLabel(field)}${unitText ? ` (${unitText})` : ''}` });
|
||||
const value = this.fieldValues[field.key];
|
||||
const placeholderText = `${t('modal.recordSet.inputPlaceholder')}${getFieldLabel(field)}`;
|
||||
|
||||
switch (field.inputType) {
|
||||
case 'number': {
|
||||
const num = fieldRow.createEl('input', { type: 'number' });
|
||||
num.addClass('workout-input');
|
||||
num.setAttribute('step', 'any');
|
||||
num.setAttribute('inputmode', 'decimal');
|
||||
num.placeholder = placeholderText;
|
||||
if (field.mass) {
|
||||
num.value = value != null ? formatMass(Number(value), unit) : '';
|
||||
num.addEventListener('change', () => {
|
||||
this.fieldValues[field.key] = parseMass(num.value, unit);
|
||||
});
|
||||
} else {
|
||||
num.value = value != null ? String(value) : '';
|
||||
num.addEventListener('change', () => {
|
||||
this.fieldValues[field.key] = parseFloat(num.value);
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'duration': {
|
||||
const dur = fieldRow.createDiv();
|
||||
dur.addClass('workout-duration-row');
|
||||
const parts = value != null ? secondsToParts(Number(value)) : { hours: 0, minutes: 0, seconds: 0 };
|
||||
const h = dur.createEl('input', { type: 'number', placeholder: t('duration.hour') });
|
||||
h.addClass('workout-input');
|
||||
h.setAttribute('step', 'any');
|
||||
h.setAttribute('inputmode', 'numeric');
|
||||
h.value = String(parts.hours);
|
||||
const mi = dur.createEl('input', { type: 'number', placeholder: t('duration.minute') });
|
||||
mi.addClass('workout-input');
|
||||
mi.setAttribute('step', 'any');
|
||||
mi.setAttribute('inputmode', 'numeric');
|
||||
mi.value = String(parts.minutes);
|
||||
const s = dur.createEl('input', { type: 'number', placeholder: t('duration.second') });
|
||||
s.addClass('workout-input');
|
||||
s.setAttribute('step', 'any');
|
||||
s.setAttribute('inputmode', 'numeric');
|
||||
s.value = String(parts.seconds);
|
||||
const upd = () => {
|
||||
this.fieldValues[field.key] = parseDuration(
|
||||
parseInt(h.value, 10) || 0,
|
||||
parseInt(mi.value, 10) || 0,
|
||||
parseInt(s.value, 10) || 0
|
||||
);
|
||||
};
|
||||
h.addEventListener('change', upd);
|
||||
mi.addEventListener('change', upd);
|
||||
s.addEventListener('change', upd);
|
||||
break;
|
||||
}
|
||||
case 'text': {
|
||||
const txt = fieldRow.createEl('input', { type: 'text' });
|
||||
txt.addClass('workout-input');
|
||||
txt.placeholder = placeholderText;
|
||||
txt.value = value != null ? String(value) : '';
|
||||
txt.addEventListener('change', () => {
|
||||
this.fieldValues[field.key] = txt.value;
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'select': {
|
||||
const sel = fieldRow.createEl('select');
|
||||
sel.addClass('workout-select');
|
||||
const opts = field.options ?? [];
|
||||
if (opts.length === 0) sel.createEl('option', { value: '', text: t('settings.none') });
|
||||
else
|
||||
for (const o of opts) {
|
||||
const opt = sel.createEl('option', { value: o, text: o });
|
||||
if (value != null && String(value) === o) opt.selected = true;
|
||||
}
|
||||
if (value == null && opts.length > 0) this.fieldValues[field.key] = sel.value;
|
||||
sel.addEventListener('change', () => {
|
||||
this.fieldValues[field.key] = sel.value;
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async save(): Promise<void> {
|
||||
try {
|
||||
await this.options.onSave(this.fieldValues);
|
||||
new Notice(t('modal.newPlan.setSaved'));
|
||||
this.close();
|
||||
} catch {
|
||||
new Notice(t('modal.newPlan.saveFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.contentEl.empty();
|
||||
}
|
||||
}
|
||||
595
src/ui/RecordModal.ts
Normal file
595
src/ui/RecordModal.ts
Normal file
|
|
@ -0,0 +1,595 @@
|
|||
import { Modal, Notice } from 'obsidian';
|
||||
import { DataManager } from '../data/DataManager';
|
||||
import { getExerciseName, getFieldLabel, getTrainingTypeName, resolveExerciseByName } from '../data/display';
|
||||
import { Exercise, FieldDef, LogRow, TrainingType } from '../data/types';
|
||||
import { t } from '../i18n';
|
||||
import { secondsToParts, parseDuration } from '../util/duration';
|
||||
import { formatMass, parseMass } from '../util/units';
|
||||
|
||||
/*
|
||||
* RecordModal.ts —— "记录一组"录入弹窗
|
||||
* 这是用户最常用、也最复杂的弹窗:选择训练项 → 自动带出训练类型 →
|
||||
* 根据该类型的字段定义动态渲染输入控件(数字/时长/文本/下拉)→ 保存写入 CSV。
|
||||
* 它支持两种模式:
|
||||
* 1) 新增:options 仅传 exercise/plan(或都不传)
|
||||
* 2) 编辑:options 传 editLog(一条已有记录),界面会预填值
|
||||
* 还支持"记忆上次值":选了训练项后自动填入该训练项上一次的记录值。
|
||||
* 弹窗类必须继承 Obsidian 的 Modal,并实现 onOpen(构建界面)和 onClose(清理)。
|
||||
*/
|
||||
interface RecordModalOptions {
|
||||
exercise?: string; // 可选:打开时预选的训练项(按 id/名称匹配)
|
||||
plan?: string; // 可选:该记录所属的训练方案名
|
||||
editLog?: LogRow; // 可选:要编辑的已有记录;存在时进入"编辑模式"
|
||||
}
|
||||
|
||||
// 记录里的时间戳格式为 "YYYY-MM-DD HH:mm",而 datetime-local 输入框值是 "YYYY-MM-DDTHH:mm",
|
||||
// 两者仅分隔符不同,下面两个函数做互转。
|
||||
function toDateTimeLocal(ts: string): string {
|
||||
const [date, time] = ts.split(' ');
|
||||
return date ? `${date}T${time ?? '00:00'}` : '';
|
||||
}
|
||||
|
||||
// 取当前时间并格式化为 datetime-local 所需的 "YYYY-MM-DDTHH:mm"
|
||||
function nowDateTimeLocal(): string {
|
||||
const n = new Date();
|
||||
const pad = (x: number) => String(x).padStart(2, '0');
|
||||
return `${n.getFullYear()}-${pad(n.getMonth() + 1)}-${pad(n.getDate())}T${pad(n.getHours())}:${pad(n.getMinutes())}`;
|
||||
}
|
||||
|
||||
export class RecordModal extends Modal {
|
||||
private dataManager: DataManager;
|
||||
private options: RecordModalOptions;
|
||||
private exerciseInput!: HTMLInputElement; // 训练项搜索输入框(combobox)
|
||||
private exerciseDropdown!: HTMLDivElement; // 搜索下拉候选列表
|
||||
private exerciseId = ''; // 当前选中训练项的 id(必须匹配已有训练项,否则保存拦截)
|
||||
private typeDisplay!: HTMLDivElement; // 显示"当前训练类型"的文本区域
|
||||
private fieldsContainer!: HTMLDivElement; // 动态字段控件的容器
|
||||
private noteInput!: HTMLTextAreaElement; // 备注输入框
|
||||
private timeInput!: HTMLInputElement; // 时间输入框(datetime-local);创建默认当前、编辑可改
|
||||
private planInput!: HTMLSelectElement; // 训练方案下拉框;创建默认空、编辑可改
|
||||
private category = ''; // 当前训练项的类型 id(决定显示哪些字段)
|
||||
private fieldValues: Record<string, unknown> = {}; // 各字段当前值,key 为字段 key
|
||||
private exercises: Exercise[] = [];
|
||||
private trainingTypes: TrainingType[] = [];
|
||||
private filteredExercises: Exercise[] = []; // 搜索过滤后的训练项列表
|
||||
private dropdownHighlighted = -1; // 下拉列表高亮索引(键盘导航用)
|
||||
|
||||
// 构造函数:在 new RecordModal(...).open() 之前被调用,仅做字段初始化。
|
||||
constructor(dataManager: DataManager, options: RecordModalOptions = {}) {
|
||||
super(dataManager.app); // 必须先把 app 传给父类 Modal
|
||||
this.dataManager = dataManager;
|
||||
this.options = options;
|
||||
this.category = '';
|
||||
}
|
||||
|
||||
// onOpen:弹窗真正打开、contentEl(弹窗内容容器)可用时调用。所有界面都建在这里。
|
||||
async onOpen(): Promise<void> {
|
||||
const { contentEl } = this;
|
||||
// 给内容容器加 CSS 类,便于 styles.css 针对本弹窗做样式
|
||||
contentEl.addClass('workout-edit-modal');
|
||||
|
||||
// 异步读取配置(训练项列表、训练类型列表)
|
||||
const config = await this.dataManager.getConfig();
|
||||
this.exercises = config.exercises;
|
||||
this.trainingTypes = config.trainingTypes;
|
||||
|
||||
// 创建标题:编辑模式用"编辑记录"文案,否则用"记录一组"文案
|
||||
contentEl.createEl('h2', { text: this.options.editLog ? t('modal.editRecord.title') : t('modal.recordSet.title') });
|
||||
|
||||
// 训练项 + 训练类型 同行(两列网格):左为训练项下拉,右为随训练项自动带出的训练类型(只读胶囊)
|
||||
const twoCol = contentEl.createDiv();
|
||||
twoCol.addClass('workout-two-col');
|
||||
|
||||
// 左列:训练项搜索框(combobox)。可输入文字模糊搜索,空白时显示所有候选项。
|
||||
const exerciseField = twoCol.createDiv();
|
||||
exerciseField.addClass('workout-field');
|
||||
exerciseField.createEl('label', { text: t('modal.recordSet.exercise') });
|
||||
|
||||
// 搜索输入框 + 下拉候选列表的容器(相对定位,让下拉绝对定位在输入框下方)
|
||||
const comboWrapper = exerciseField.createDiv();
|
||||
comboWrapper.addClass('workout-combo-wrapper');
|
||||
|
||||
this.exerciseInput = comboWrapper.createEl('input', { type: 'text' });
|
||||
this.exerciseInput.addClass('workout-input');
|
||||
this.exerciseInput.addClass('workout-combo-input');
|
||||
this.exerciseInput.placeholder = t('modal.recordSet.searchExercisePlaceholder') || '搜索训练项...';
|
||||
|
||||
// 下拉候选列表(默认隐藏)
|
||||
this.exerciseDropdown = comboWrapper.createDiv();
|
||||
this.exerciseDropdown.addClass('workout-combo-dropdown');
|
||||
this.exerciseDropdown.style.display = 'none';
|
||||
|
||||
// 初始化过滤后的列表为全部训练项
|
||||
this.filteredExercises = [...this.exercises];
|
||||
this.renderExerciseDropdown();
|
||||
|
||||
// 右列:训练类型展示(只读胶囊,随所选训练项自动变化)
|
||||
const typeField = twoCol.createDiv();
|
||||
typeField.addClass('workout-field');
|
||||
typeField.createEl('label', { text: t('modal.recordSet.type') });
|
||||
this.typeDisplay = typeField.createDiv();
|
||||
this.typeDisplay.addClass('workout-type-pill');
|
||||
|
||||
// —— 编辑模式:用已有记录预填各字段的值与上下文 ——
|
||||
if (this.options.editLog) {
|
||||
this.exerciseId = this.options.editLog.exerciseId || '';
|
||||
this.category = this.options.editLog.category;
|
||||
this.fieldValues = { ...this.options.editLog.fields }; // 浅拷贝一份,避免直接改动原始记录
|
||||
const editEx = this.exercises.find(e => e.id === this.exerciseId);
|
||||
if (editEx) {
|
||||
this.exerciseInput.value = getExerciseName(editEx);
|
||||
this.exerciseInput.disabled = true;
|
||||
exerciseField.createEl('span', { text: t('modal.recordSet.exerciseLocked'), cls: 'workout-hint' });
|
||||
}
|
||||
}
|
||||
|
||||
// —— 预选训练项模式:根据 options.exercise 在搜索框里预填名称并选中对应项 ——
|
||||
if (this.options.exercise && !this.options.editLog) {
|
||||
const config = await this.dataManager.getConfig();
|
||||
const matched = resolveExerciseByName(config, this.options.exercise);
|
||||
if (matched) {
|
||||
this.exerciseInput.value = getExerciseName(matched);
|
||||
this.exerciseId = matched.id;
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索框交互事件:输入时过滤下拉列表、聚焦/失焦控制显示
|
||||
this.setupExerciseComboEvents();
|
||||
|
||||
// 设置初始选中值:优先用编辑模式/预选模式的值,否则默认选第一个训练项
|
||||
if (!this.options.editLog && !this.options.exercise && this.exercises.length > 0) {
|
||||
this.exerciseId = this.exercises[0].id;
|
||||
this.exerciseInput.value = getExerciseName(this.exercises[0]);
|
||||
}
|
||||
|
||||
// 字段容器:各动态字段的控件会被插入到这里
|
||||
this.fieldsContainer = contentEl.createDiv();
|
||||
this.fieldsContainer.addClass('workout-fields');
|
||||
|
||||
// 时间 + 训练计划 同行(两列网格,对齐重设计稿)
|
||||
const timePlanCol = contentEl.createDiv();
|
||||
timePlanCol.addClass('workout-two-col');
|
||||
|
||||
// 时间输入框:创建模式预填当前时间,编辑模式预填记录原时间(datetime-local 用 T 分隔)。
|
||||
const timeField = timePlanCol.createDiv();
|
||||
timeField.addClass('workout-field');
|
||||
timeField.createEl('label', { text: t('modal.recordSet.time') });
|
||||
this.timeInput = timeField.createEl('input', { type: 'datetime-local' });
|
||||
this.timeInput.addClass('workout-input');
|
||||
this.timeInput.value = this.options.editLog
|
||||
? toDateTimeLocal(this.options.editLog.timestamp)
|
||||
: nowDateTimeLocal();
|
||||
|
||||
// 训练方案下拉框:可选关联一个训练方案(允许为空)。
|
||||
// 从侧边栏打开时可从已有方案中选择;从代码块打开时预填传入的 plan。
|
||||
const planField = timePlanCol.createDiv();
|
||||
planField.addClass('workout-field');
|
||||
planField.createEl('label', { text: t('modal.recordSet.scheme') });
|
||||
this.planInput = planField.createEl('select');
|
||||
this.planInput.addClass('workout-select');
|
||||
|
||||
// 空选项:不关联任何方案
|
||||
const emptyOpt = this.planInput.createEl('option', {
|
||||
value: '',
|
||||
text: t('modal.recordSet.noSchemeOption') || '— 不关联方案 —',
|
||||
});
|
||||
|
||||
// 填入已有训练计划作为候选项
|
||||
const configForPlans = await this.dataManager.getConfig();
|
||||
const plans = configForPlans.plans ?? [];
|
||||
for (const p of plans) {
|
||||
const opt = this.planInput.createEl('option', { value: p.name, text: p.name });
|
||||
}
|
||||
|
||||
// 预选值:编辑模式取记录原 plan;新增模式优先用传入的 options.plan(如来自代码块)
|
||||
const prefillPlan = this.options.editLog?.plan ?? this.options.plan ?? '';
|
||||
if (prefillPlan && plans.some(p => p.name === prefillPlan)) {
|
||||
this.planInput.value = prefillPlan;
|
||||
} else if (prefillPlan) {
|
||||
// 传入的 plan 名不在现有计划列表中(可能已被删除或重命名),追加为临时选项
|
||||
const fallbackOpt = this.planInput.createEl('option', { value: prefillPlan, text: `${prefillPlan} (${t('modal.recordSet.schemeNotFound') || '未找到'})` });
|
||||
this.planInput.value = prefillPlan;
|
||||
}
|
||||
|
||||
// 备注输入框:放到所有录入项(训练项/类型/字段/时间/计划)的底部
|
||||
const noteField = contentEl.createDiv();
|
||||
noteField.addClass('workout-field');
|
||||
noteField.createEl('label', { text: t('modal.recordSet.note') });
|
||||
this.noteInput = noteField.createEl('textarea', { placeholder: t('modal.recordSet.note') });
|
||||
this.noteInput.addClass('workout-textarea');
|
||||
if (this.options.editLog?.note) {
|
||||
this.noteInput.value = this.options.editLog.note;
|
||||
}
|
||||
|
||||
// 底部按钮行:取消 + 保存
|
||||
const btnRow = contentEl.createDiv();
|
||||
btnRow.addClass('workout-btn-row');
|
||||
|
||||
// 取消按钮:mod-muted 是 Obsidian 的次要按钮样式;点击直接关闭弹窗
|
||||
const cancelBtn = btnRow.createEl('button', { text: t('common.cancel') });
|
||||
cancelBtn.addClass('mod-muted');
|
||||
cancelBtn.addEventListener('click', () => this.close());
|
||||
|
||||
// 保存按钮:mod-cta 是 Obsidian 的主按钮(高亮)样式;点击执行 save()
|
||||
const saveBtn = btnRow.createEl('button', { text: t('common.save') });
|
||||
saveBtn.addClass('mod-cta');
|
||||
saveBtn.addEventListener('click', () => this.save());
|
||||
|
||||
// 打开时根据初始状态立刻渲染字段:
|
||||
// - 编辑模式:用已有记录预填(前面已设),且不加载「上次值」,避免覆盖正在编辑的值;
|
||||
// - 新增模式:无论是否预选训练项,都统一走 onExerciseChange —— 它会设置 exerciseId/category、
|
||||
// 加载该训练项的「上次值」、再渲染字段。这是「记忆上次值」生效的关键路径。
|
||||
if (this.options.editLog) {
|
||||
this.updateTypeDisplay();
|
||||
this.renderFields();
|
||||
} else if (this.exercises.length > 0) {
|
||||
this.onExerciseChange();
|
||||
}
|
||||
}
|
||||
|
||||
// 训练项切换时的统一处理:更新上下文、类型显示、上次值、字段控件。
|
||||
private onExerciseChange(): void {
|
||||
const exercise = this.exercises.find((item) => item.id === this.exerciseId);
|
||||
if (exercise) {
|
||||
this.exerciseId = exercise.id;
|
||||
this.category = exercise.category;
|
||||
this.updateTypeDisplay(); // 显示对应训练类型
|
||||
this.loadLastValues(); // 记忆上次值:填入该训练项上一次记录
|
||||
this.renderFields(); // 按新类型的字段重新生成输入控件
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 训练项搜索 Combobox 实现 =====
|
||||
|
||||
// 配置搜索框事件:输入过滤、键盘导航、失焦隐藏、点击候选选中
|
||||
private setupExerciseComboEvents(): void {
|
||||
const input = this.exerciseInput;
|
||||
const dropdown = this.exerciseDropdown;
|
||||
|
||||
// 输入时实时过滤下拉列表
|
||||
input.addEventListener('input', () => {
|
||||
this.filterExercises(input.value);
|
||||
this.dropdownHighlighted = -1;
|
||||
this.renderExerciseDropdown();
|
||||
dropdown.style.display = 'block';
|
||||
});
|
||||
|
||||
// 聚焦时显示全部候选项
|
||||
input.addEventListener('focus', () => {
|
||||
if (!this.options.editLog) { // 编辑模式不弹出
|
||||
this.filterExercises(input.value);
|
||||
this.dropdownHighlighted = -1;
|
||||
this.renderExerciseDropdown();
|
||||
dropdown.style.display = 'block';
|
||||
}
|
||||
});
|
||||
|
||||
// 键盘导航:↑↓移动高亮、Enter选中、Escape关闭
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (dropdown.style.display === 'none') return;
|
||||
const items = dropdown.querySelectorAll('.workout-combo-item');
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
this.dropdownHighlighted = Math.min(this.dropdownHighlighted + 1, items.length - 1);
|
||||
this.updateDropdownHighlight(items);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
this.dropdownHighlighted = Math.max(this.dropdownHighlighted - 1, 0);
|
||||
this.updateDropdownHighlight(items);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (this.dropdownHighlighted >= 0 && this.dropdownHighlighted < items.length) {
|
||||
const exId = (items[this.dropdownHighlighted] as HTMLElement).dataset.id!;
|
||||
this.selectExerciseById(exId);
|
||||
}
|
||||
dropdown.style.display = 'none';
|
||||
} else if (e.key === 'Escape') {
|
||||
dropdown.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// 点击页面其他区域时关闭下拉(用延迟避免点击候选项时先关闭)
|
||||
document.addEventListener('mousedown', (e) => {
|
||||
if (!comboWrapper!.contains(e.target as Node)) {
|
||||
dropdown.style.display = 'none';
|
||||
}
|
||||
});
|
||||
// comboWrapper 是闭包变量,引用上面创建的 comboWrapper DOM 元素
|
||||
let comboWrapper: HTMLElement | null = input.parentElement;
|
||||
}
|
||||
|
||||
// 按输入文字过滤训练项列表(模糊匹配:名称/ID 包含即命中)
|
||||
private filterExercises(query: string): void {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) {
|
||||
this.filteredExercises = [...this.exercises];
|
||||
} else {
|
||||
this.filteredExercises = this.exercises.filter(ex => {
|
||||
const name = getExerciseName(ex).toLowerCase();
|
||||
return name.includes(q) || ex.id.toLowerCase().includes(q);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染下拉候选列表
|
||||
private renderExerciseDropdown(): void {
|
||||
const dropdown = this.exerciseDropdown;
|
||||
dropdown.empty();
|
||||
|
||||
if (this.filteredExercises.length === 0) {
|
||||
const emptyItem = dropdown.createDiv({ text: t('modal.recordSet.noMatchingExercise') || '无匹配项' });
|
||||
emptyItem.addClass('workout-combo-item');
|
||||
emptyItem.addClass('workout-combo-empty');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const ex of this.filteredExercises) {
|
||||
const item = dropdown.createDiv();
|
||||
item.addClass('workout-combo-item');
|
||||
item.dataset.id = ex.id;
|
||||
// 高亮当前选中项
|
||||
if (ex.id === this.exerciseId) item.addClass('workout-combo-selected');
|
||||
|
||||
const nameSpan = item.createSpan({ text: getExerciseName(ex) });
|
||||
// 显示类型标签辅助区分
|
||||
const typeTag = item.createSpan({
|
||||
text: getTrainingTypeName(this.trainingTypes.find(t => t.id === ex.category)) || ex.category,
|
||||
});
|
||||
typeTag.addClass('workout-combo-type-tag');
|
||||
|
||||
// 点击选中
|
||||
item.addEventListener('click', () => {
|
||||
this.selectExerciseById(ex.id);
|
||||
this.exerciseDropdown.style.display = 'none';
|
||||
});
|
||||
|
||||
// 鼠标悬停同步高亮索引
|
||||
item.addEventListener('mouseenter', () => {
|
||||
this.dropdownHighlighted = Array.from(dropdown.querySelectorAll('.workout-combo-item')).indexOf(item);
|
||||
this.updateDropdownHighlight(dropdown.querySelectorAll('.workout-combo-item'));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 更新键盘导航高亮样式
|
||||
private updateDropdownHighlight(items: NodeListOf<Element>): void {
|
||||
items.forEach((item, i) => {
|
||||
(item as HTMLElement).toggleClass('workout-combo-highlighted', i === this.dropdownHighlighted);
|
||||
});
|
||||
}
|
||||
|
||||
// 按 ID 选中一个训练项并触发 onExerciseChange
|
||||
private selectExerciseById(id: string): void {
|
||||
const exercise = this.exercises.find(e => e.id === id);
|
||||
if (!exercise) return;
|
||||
this.exerciseId = id;
|
||||
this.exerciseInput.value = getExerciseName(exercise);
|
||||
this.onExerciseChange(); // 触发类型切换+上次值加载+字段渲染
|
||||
}
|
||||
|
||||
// 根据当前 category(训练类型 id)更新"训练类型"显示文本
|
||||
private updateTypeDisplay(): void {
|
||||
const type = this.trainingTypes.find((item) => item.id === this.category);
|
||||
this.typeDisplay.setText(getTrainingTypeName(type) || this.category);
|
||||
}
|
||||
|
||||
// "记忆上次值":非编辑模式下,读取该训练项上一次保存的记录值并填入 fieldValues,
|
||||
// 这样用户连续记录同一动作时不用每次重填。编辑模式不触发(避免覆盖正在编辑的值)。
|
||||
private loadLastValues(): void {
|
||||
if (!this.options.editLog) {
|
||||
const lastValues = this.dataManager.getLastValues(this.exerciseId);
|
||||
if (lastValues) {
|
||||
this.fieldValues = { ...lastValues };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 核心:按当前训练类型的字段定义,动态创建对应的输入控件并塞进 fieldsContainer。
|
||||
// 支持 4 种 inputType:number(数字)、duration(时长)、text(文本)、select(下拉)。
|
||||
private renderFields(): void {
|
||||
this.fieldsContainer.empty(); // 先清空,避免重复渲染时控件叠加
|
||||
const type = this.trainingTypes.find((item) => item.id === this.category);
|
||||
if (!type) return;
|
||||
|
||||
for (const field of type.fields) {
|
||||
const fieldRow = this.fieldsContainer.createDiv();
|
||||
fieldRow.addClass('workout-field');
|
||||
|
||||
const unit = this.dataManager.getSettings().unit; // 当前重量单位(kg/lb)
|
||||
const unitText = this.getFieldUnit(field, unit); // 计算该字段的单位显示文字
|
||||
const fieldLabel = getFieldLabel(field);
|
||||
|
||||
fieldRow.createEl('label', { text: `${fieldLabel}${unitText ? ` (${unitText})` : ''}` });
|
||||
|
||||
const fieldValue = this.fieldValues[field.key]; // 该字段的当前值(编辑/上次值可能已存在)
|
||||
const placeholderText = `${t('modal.recordSet.inputPlaceholder')}${fieldLabel}`;
|
||||
|
||||
// 根据字段类型分支创建不同控件
|
||||
switch (field.inputType) {
|
||||
case 'number': {
|
||||
const numInput = fieldRow.createEl('input', { type: 'number' });
|
||||
numInput.addClass('workout-input');
|
||||
numInput.setAttribute('step', 'any');
|
||||
numInput.setAttribute('inputmode', 'decimal');
|
||||
numInput.placeholder = placeholderText;
|
||||
if (field.mass) {
|
||||
// 重量字段:显示时按单位格式化;用户输入后解析回统一数值再存入 fieldValues
|
||||
numInput.value = fieldValue != null ? formatMass(Number(fieldValue), unit) : '';
|
||||
numInput.addEventListener('change', () => {
|
||||
this.fieldValues[field.key] = parseMass(numInput.value, unit);
|
||||
});
|
||||
} else {
|
||||
// 普通数字:直接存 parseFloat
|
||||
numInput.value = fieldValue != null ? String(fieldValue) : '';
|
||||
numInput.addEventListener('change', () => {
|
||||
this.fieldValues[field.key] = parseFloat(numInput.value);
|
||||
});
|
||||
}
|
||||
// 必填字段:标记 required,提交时会被校验
|
||||
if (field.required) {
|
||||
numInput.required = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'duration': {
|
||||
// 时长字段:把"秒"拆成 时/分/秒 三个等宽输入框,分别显示、再拼回秒。
|
||||
const durationRow = fieldRow.createDiv();
|
||||
durationRow.addClass('workout-duration-grid');
|
||||
|
||||
// 把已有秒数拆成 {hours, minutes, seconds},没有值则全 0
|
||||
const parts = fieldValue != null ? secondsToParts(Number(fieldValue)) : { hours: 0, minutes: 0, seconds: 0 };
|
||||
|
||||
const hourInput = durationRow.createEl('input', { type: 'number', placeholder: t('duration.hour') });
|
||||
hourInput.addClass('workout-input');
|
||||
hourInput.setAttribute('step', 'any');
|
||||
hourInput.setAttribute('inputmode', 'numeric');
|
||||
hourInput.value = String(parts.hours);
|
||||
hourInput.title = t('duration.hour');
|
||||
|
||||
const minInput = durationRow.createEl('input', { type: 'number', placeholder: t('duration.minute') });
|
||||
minInput.addClass('workout-input');
|
||||
minInput.setAttribute('step', 'any');
|
||||
minInput.setAttribute('inputmode', 'numeric');
|
||||
minInput.value = String(parts.minutes);
|
||||
minInput.title = t('duration.minute');
|
||||
|
||||
const secInput = durationRow.createEl('input', { type: 'number', placeholder: t('duration.second') });
|
||||
secInput.addClass('workout-input');
|
||||
secInput.setAttribute('step', 'any');
|
||||
secInput.setAttribute('inputmode', 'numeric');
|
||||
secInput.value = String(parts.seconds);
|
||||
secInput.title = t('duration.second');
|
||||
|
||||
// 任一时/分/秒变化,都重新拼成总秒数存入 fieldValues
|
||||
const updateDuration = () => {
|
||||
this.fieldValues[field.key] = parseDuration(
|
||||
parseInt(hourInput.value, 10) || 0,
|
||||
parseInt(minInput.value, 10) || 0,
|
||||
parseInt(secInput.value, 10) || 0
|
||||
);
|
||||
};
|
||||
hourInput.addEventListener('change', updateDuration);
|
||||
minInput.addEventListener('change', updateDuration);
|
||||
secInput.addEventListener('change', updateDuration);
|
||||
break;
|
||||
}
|
||||
case 'text': {
|
||||
const textInput = fieldRow.createEl('input', { type: 'text' });
|
||||
textInput.addClass('workout-input');
|
||||
textInput.placeholder = placeholderText;
|
||||
textInput.value = fieldValue != null ? String(fieldValue) : '';
|
||||
textInput.addEventListener('change', () => {
|
||||
this.fieldValues[field.key] = textInput.value;
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'select': {
|
||||
const selectInput = fieldRow.createEl('select');
|
||||
selectInput.addClass('workout-select');
|
||||
const opts = field.options ?? [];
|
||||
if (opts.length === 0) {
|
||||
// 无选项时给一个"无"占位项
|
||||
selectInput.createEl('option', { value: '', text: t('settings.none') });
|
||||
} else {
|
||||
for (const opt of opts) {
|
||||
const option = selectInput.createEl('option', { value: opt, text: opt });
|
||||
// 若已有值且与之匹配,则预选中
|
||||
if (fieldValue != null && String(fieldValue) === opt) {
|
||||
option.selected = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 下拉框原生默认选中第一项(即使未手动选择也如此),但 change 不触发,
|
||||
// fieldValues 不会被写入,直接保存会导致该字段丢数据。
|
||||
// 因此未编辑过时,手动把当前选中值(第一项)写回 fieldValues。
|
||||
if (fieldValue == null && opts.length > 0) {
|
||||
this.fieldValues[field.key] = selectInput.value;
|
||||
}
|
||||
selectInput.addEventListener('change', () => {
|
||||
this.fieldValues[field.key] = selectInput.value;
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 根据字段定义计算该字段的单位显示文字(用于界面提示)。mass 用当前单位 kg/lb,否则用自由单位文字。
|
||||
private getFieldUnit(field: FieldDef, unit: 'kg' | 'lb'): string {
|
||||
if (field.mass) return unit;
|
||||
if (field.unitLabel) return field.unitLabel;
|
||||
return '';
|
||||
}
|
||||
|
||||
// 从 datetime-local 输入框读出 "YYYY-MM-DD HH:mm";为空则回退到当前时间。
|
||||
private readTimestamp(): string {
|
||||
const v = this.timeInput.value;
|
||||
if (v) return v.replace('T', ' ');
|
||||
const n = new Date();
|
||||
const pad = (x: number) => String(x).padStart(2, '0');
|
||||
return `${n.getFullYear()}-${pad(n.getMonth() + 1)}-${pad(n.getDate())} ${pad(n.getHours())}:${pad(n.getMinutes())}`;
|
||||
}
|
||||
|
||||
// 保存:校验必填项后写入数据管理器(新增或更新),并给出右下角提示 Notice,最后关闭弹窗。
|
||||
private async save(): Promise<void> {
|
||||
// 必须先确定训练项(exerciseId),否则不知道该保存哪些字段
|
||||
if (!this.exerciseId || !this.exercises.find(e => e.id === this.exerciseId)) {
|
||||
new Notice(t('modal.recordSet.selectExercise') || '请选择有效的训练项');
|
||||
return;
|
||||
}
|
||||
|
||||
const type = this.trainingTypes.find((item) => item.id === this.category);
|
||||
if (!type) {
|
||||
new Notice(t('modal.recordSet.selectType'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 必填校验:遍历该类型的字段,required 且值为空(undefined/null)则拦截并提示
|
||||
for (const field of type.fields) {
|
||||
if (field.required && (this.fieldValues[field.key] === undefined || this.fieldValues[field.key] === null)) {
|
||||
new Notice(`${getFieldLabel(field)} ${t('modal.recordSet.requiredField')}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const timestamp = this.readTimestamp();
|
||||
const plan = this.planInput.value || undefined;
|
||||
if (this.options.editLog) {
|
||||
// 编辑模式:用原始记录的定位信息去更新(训练项已锁定,不能改)
|
||||
await this.dataManager.updateLog(
|
||||
this.options.editLog.id,
|
||||
{
|
||||
exerciseId: this.exerciseId,
|
||||
category: this.category,
|
||||
fields: this.fieldValues,
|
||||
note: this.noteInput.value || undefined,
|
||||
timestamp,
|
||||
plan,
|
||||
}
|
||||
);
|
||||
new Notice(t('modal.recordSet.updated'));
|
||||
} else {
|
||||
// 新增模式:直接追加一条记录(plan 默认文件名/空,时间默认当前、均可改)
|
||||
await this.dataManager.addLog({
|
||||
exerciseId: this.exerciseId,
|
||||
category: this.category,
|
||||
fields: this.fieldValues,
|
||||
note: this.noteInput.value || undefined,
|
||||
plan,
|
||||
timestamp,
|
||||
});
|
||||
new Notice(t('modal.recordSet.saved'));
|
||||
}
|
||||
this.close();
|
||||
} catch {
|
||||
// 写入失败(如 CSV 异常)时给出错误提示,但不关闭弹窗,方便用户重试
|
||||
new Notice(t('modal.recordSet.saveFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
// onClose:弹窗关闭时清空内容容器,释放 DOM。Obsidian 会在关闭后自动移除弹窗本身。
|
||||
onClose(): void {
|
||||
this.contentEl.empty();
|
||||
}
|
||||
}
|
||||
401
src/ui/SettingsTab.ts
Normal file
401
src/ui/SettingsTab.ts
Normal file
|
|
@ -0,0 +1,401 @@
|
|||
import { App, PluginSettingTab, Setting, Notice } from 'obsidian';
|
||||
import { rerenderAllBlocks } from '../codeblock/registry';
|
||||
import { DataManager } from '../data/DataManager';
|
||||
import { WorkoutConfig } from '../data/types';
|
||||
import { setLocale, t } from '../i18n';
|
||||
import { ExerciseManagerModal } from './ExerciseManagerModal';
|
||||
import { MuscleManagerModal } from './MuscleManagerModal';
|
||||
import { StatManagerModal } from './StatManagerModal';
|
||||
import { TypeManagerModal } from './TypeManagerModal';
|
||||
import { TrainingPlanManagerModal } from './TrainingPlanManagerModal';
|
||||
import { VaultFolderSuggestModal } from './VaultFolderSuggestModal';
|
||||
import { VaultPathSuggest } from './VaultPathSuggest';
|
||||
import { confirmWithModal } from './Confirm';
|
||||
|
||||
/* SettingsTab —— 插件的设置页面。
|
||||
* 它继承 Obsidian 的 PluginSettingTab,是 Obsidian「设置」里本插件的专属页面。
|
||||
* 页面用 Obsidian 的 Setting 类逐项构建(每项 = 一行:标题 + 说明 + 控件)。
|
||||
* 这里负责:数据文件路径、训练类型/训练项/肌肉的管理入口、单位/语言/阈值等通用项、
|
||||
* 以及 Dataview / Daily Notes / Templater 的联动开关。任何修改都会保存到 DataManager 并刷新。 */
|
||||
|
||||
export class SettingsTab extends PluginSettingTab {
|
||||
// dataManager:本插件的核心数据管家,负责读取/保存配置与记录。
|
||||
private dataManager: DataManager;
|
||||
|
||||
constructor(app: App, plugin: any, dataManager: DataManager) {
|
||||
super(app, plugin);
|
||||
this.dataManager = dataManager;
|
||||
// 设置页左侧导航里本插件的显示名(随语言切换)。
|
||||
this.name = t('pluginName');
|
||||
}
|
||||
|
||||
// display() 是设置页的"绘制"方法,Obsidian 每次打开/重绘设置页都会调用它。
|
||||
// 注意:必须先 empty() 清掉旧内容,再重新渲染,否则重复打开会叠加。
|
||||
display(): void {
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
// 页面大标题(文案来自多语言 i18n 的 t())。
|
||||
containerEl.createEl('h2', { text: t('settings.title') });
|
||||
|
||||
// 配置是异步读取的(来自磁盘上的 JSON),拿到后再分段渲染各区块。
|
||||
this.dataManager.getConfig().then((config) => {
|
||||
this.renderDataPathSection(containerEl);
|
||||
this.renderManagersSection(containerEl, config);
|
||||
this.renderMaintenanceSection(containerEl);
|
||||
this.renderGeneralSection(containerEl);
|
||||
// 联动开关区块(Dataview / Daily Notes / Templater)暂时隐藏,
|
||||
// 待第三方联动功能开发完成后再放开。保留 renderIntegrationSection 以便后续恢复。
|
||||
// this.renderIntegrationSection(containerEl);
|
||||
});
|
||||
}
|
||||
|
||||
// 区块一:数据文件路径。包含"CSV 目录"和"配置 JSON 目录"两项,
|
||||
// 既支持手动输入,也支持点"浏览"按钮用 VaultFolderSuggestModal 选文件夹。
|
||||
private renderDataPathSection(containerEl: HTMLElement): void {
|
||||
const section = containerEl.createEl('div');
|
||||
section.createEl('h3', { text: t('settings.dataPath') });
|
||||
|
||||
// CSV 目录设置项。Setting 构造 = 一行设置;setName 是标题,setDesc 是灰色说明。
|
||||
const csvSetting = new Setting(section)
|
||||
.setName(t('settings.csvDirectory'))
|
||||
.setDesc(t('settings.csvDirectoryDesc'));
|
||||
|
||||
// 保存输入框引用,便于"浏览"选完文件夹后回填文字。
|
||||
let csvInput: { inputEl: HTMLInputElement; setValue(value: string): unknown } | null = null;
|
||||
// addText() 在设置项里加一个单行文本输入框,用于填写目录路径。
|
||||
csvSetting.addText((text) => {
|
||||
csvInput = text;
|
||||
text
|
||||
.setPlaceholder(t('settings.dataDirectoryPlaceholder'))
|
||||
.setValue(this.dataManager.getSettings().csvDirectory);
|
||||
|
||||
// VaultPathSuggest:输入时弹出 Vault 内文件夹的自动补全建议。
|
||||
new VaultPathSuggest(this.app, text.inputEl, async (value: string) => {
|
||||
this.dataManager.getSettings().csvDirectory = value;
|
||||
await this.dataManager.saveSettings();
|
||||
});
|
||||
|
||||
// 用户每次改文字都即时保存(onChange)。
|
||||
text.onChange(async (value) => {
|
||||
this.dataManager.getSettings().csvDirectory = value;
|
||||
await this.dataManager.saveSettings();
|
||||
});
|
||||
});
|
||||
// addButton() 在该设置项右侧加一个按钮;这里点"浏览"打开文件夹选择弹窗。
|
||||
csvSetting.addButton((btn) =>
|
||||
btn.setButtonText(t('settings.browse')).onClick(() => {
|
||||
new VaultFolderSuggestModal(this.app, async (value) => {
|
||||
csvInput?.setValue(value);
|
||||
this.dataManager.getSettings().csvDirectory = value;
|
||||
await this.dataManager.saveSettings();
|
||||
}).open();
|
||||
})
|
||||
);
|
||||
|
||||
// 配置 JSON 目录设置项,结构和上面的 CSV 目录完全对称。
|
||||
const configSetting = new Setting(section)
|
||||
.setName(t('settings.configDirectory'))
|
||||
.setDesc(t('settings.configDirectoryDesc'));
|
||||
|
||||
let configInput: { inputEl: HTMLInputElement; setValue(value: string): unknown } | null = null;
|
||||
configSetting.addText((text) => {
|
||||
configInput = text;
|
||||
text
|
||||
.setPlaceholder(t('settings.dataDirectoryPlaceholder'))
|
||||
.setValue(this.dataManager.getSettings().configDirectory);
|
||||
|
||||
new VaultPathSuggest(this.app, text.inputEl, async (value: string) => {
|
||||
this.dataManager.getSettings().configDirectory = value;
|
||||
await this.dataManager.saveSettings();
|
||||
});
|
||||
|
||||
text.onChange(async (value) => {
|
||||
this.dataManager.getSettings().configDirectory = value;
|
||||
await this.dataManager.saveSettings();
|
||||
});
|
||||
});
|
||||
configSetting.addButton((btn) =>
|
||||
btn.setButtonText(t('settings.browse')).onClick(() => {
|
||||
new VaultFolderSuggestModal(this.app, async (value) => {
|
||||
configInput?.setValue(value);
|
||||
this.dataManager.getSettings().configDirectory = value;
|
||||
await this.dataManager.saveSettings();
|
||||
}).open();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 区块二:训练设置。把五个「打开管理」入口平铺成一个列表(不再按分类各自加标题),
|
||||
// 每行左侧有拖拽手柄,可用鼠标拖动改变显示顺序;新顺序存入 settings.managerOrder。
|
||||
// 说明:排序仅影响设置页里这些条目的显示顺序,不改变任何功能逻辑。
|
||||
private renderManagersSection(containerEl: HTMLElement, config: WorkoutConfig): void {
|
||||
const section = containerEl.createEl('div');
|
||||
section.createEl('h3', { text: t('settings.trainingSettings') });
|
||||
|
||||
// 五个管理条目:key 仅用于排序存储;name/desc 为文案;open 为按钮点击后弹出的管理弹窗。
|
||||
const managers: Record<string, { name: string; desc: string; open: () => void }> = {
|
||||
types: {
|
||||
name: t('settings.typeManager'),
|
||||
desc: `${t('settings.totalTypes')}: ${config.trainingTypes.length}`,
|
||||
open: () => new TypeManagerModal(this.dataManager).open(),
|
||||
},
|
||||
exercises: {
|
||||
name: t('settings.exerciseManager'),
|
||||
desc: `${t('settings.totalExercises')}: ${config.exercises.length}`,
|
||||
open: () => new ExerciseManagerModal(this.dataManager).open(),
|
||||
},
|
||||
muscles: {
|
||||
name: t('settings.muscleManager'),
|
||||
desc: `${t('settings.totalMuscles')}: ${config.muscles.length}`,
|
||||
open: () => new MuscleManagerModal(this.dataManager).open(),
|
||||
},
|
||||
statistics: {
|
||||
name: t('settings.statisticsManager'),
|
||||
desc: `${t('settings.totalStatistics')}: ${config.statistics.length}`,
|
||||
open: () => new StatManagerModal(this.dataManager).open(),
|
||||
},
|
||||
plans: {
|
||||
name: t('settings.trainingPlans.manage'),
|
||||
desc: `${t('settings.totalPlans')}: ${config.plans?.length ?? 0}`,
|
||||
open: () => new TrainingPlanManagerModal(this.dataManager).open(),
|
||||
},
|
||||
};
|
||||
|
||||
// 读取并校验已保存顺序,剔除未知 key、补齐缺失 key,保证顺序数组始终完整合法。
|
||||
const order = this.normalizeManagerOrder(this.dataManager.getSettings().managerOrder);
|
||||
|
||||
// 平铺容器:所有条目渲染于此,便于拖拽时直接重排 DOM(条目间纯间距分隔,无分隔线)。
|
||||
const listEl = section.createEl('div', { cls: 'workout-manager-list' });
|
||||
|
||||
// 拖拽中的源行引用。
|
||||
let dragEl: HTMLElement | null = null;
|
||||
|
||||
const isMobile = this.app.isMobile;
|
||||
|
||||
const renderRow = (key: string): void => {
|
||||
const def = managers[key];
|
||||
const setting = new Setting(listEl)
|
||||
.setName(def.name)
|
||||
.setDesc(def.desc)
|
||||
.addButton((btn) =>
|
||||
btn.setButtonText(t('settings.openManager')).onClick(() => def.open())
|
||||
);
|
||||
|
||||
const row = setting.settingEl;
|
||||
row.classList.add('workout-manager-row');
|
||||
row.setAttribute('data-key', key);
|
||||
|
||||
// 排序:上移 / 下移按钮,移动端与桌面端都常驻可用(移动端以按钮替代拖拽)。
|
||||
const move = async (dir: -1 | 1): Promise<void> => {
|
||||
const rows = Array.from(listEl.querySelectorAll<HTMLElement>('.workout-manager-row'));
|
||||
const i = rows.indexOf(row);
|
||||
const j = i + dir;
|
||||
if (j < 0 || j >= rows.length) return;
|
||||
if (dir === -1) listEl.insertBefore(row, rows[j]);
|
||||
else rows[j].after(row);
|
||||
const newOrder = Array.from(listEl.querySelectorAll<HTMLElement>('.workout-manager-row'))
|
||||
.map((el) => el.getAttribute('data-key'))
|
||||
.filter((k): k is string => !!k);
|
||||
this.dataManager.getSettings().managerOrder = newOrder;
|
||||
await this.dataManager.saveSettings();
|
||||
};
|
||||
setting
|
||||
.addButton((btn) => {
|
||||
btn.setIcon('arrow-up').setTooltip(t('settings.moveUp')).onClick(() => move(-1));
|
||||
btn.buttonEl.addClass('workout-manager-move');
|
||||
})
|
||||
.addButton((btn) => {
|
||||
btn.setIcon('arrow-down').setTooltip(t('settings.moveDown')).onClick(() => move(1));
|
||||
btn.buttonEl.addClass('workout-manager-move');
|
||||
});
|
||||
|
||||
// 桌面端:拖拽手柄 + 拖拽事件;移动端不使用拖拽。
|
||||
if (!isMobile) {
|
||||
const handle = row.createEl('div', {
|
||||
cls: 'workout-manager-handle',
|
||||
attr: { 'aria-label': t('settings.dragToReorder'), title: t('settings.dragToReorder') },
|
||||
});
|
||||
// 六个点的拖拽图标(内联 SVG,随主题 currentColor 着色)。
|
||||
handle.innerHTML =
|
||||
'<svg viewBox="0 0 12 16" width="12" height="16" fill="currentColor">' +
|
||||
'<circle cx="3" cy="3" r="1.5"/><circle cx="9" cy="3" r="1.5"/>' +
|
||||
'<circle cx="3" cy="8" r="1.5"/><circle cx="9" cy="8" r="1.5"/>' +
|
||||
'<circle cx="3" cy="13" r="1.5"/><circle cx="9" cy="13" r="1.5"/></svg>';
|
||||
// createEl 默认追加到行尾,把手柄移到行首。
|
||||
row.insertBefore(handle, row.firstChild);
|
||||
handle.draggable = true;
|
||||
|
||||
handle.addEventListener('dragstart', (e: DragEvent) => {
|
||||
dragEl = row;
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', key);
|
||||
// 以整行为拖拽影像,手柄拖起时整行跟随光标。
|
||||
e.dataTransfer.setDragImage(row, 0, 0);
|
||||
}
|
||||
row.classList.add('workout-manager-row--dragging');
|
||||
});
|
||||
handle.addEventListener('dragend', () => {
|
||||
dragEl = null;
|
||||
row.classList.remove('workout-manager-row--dragging');
|
||||
listEl
|
||||
.querySelectorAll('.workout-manager-row--over')
|
||||
.forEach((el) => el.classList.remove('workout-manager-row--over'));
|
||||
});
|
||||
|
||||
// 拖拽相关事件挂在 row 上:手柄拖动时 row 是拖拽影像,命中检测与插入位置都基于 row。
|
||||
row.addEventListener('dragover', (e: DragEvent) => {
|
||||
if (!dragEl || dragEl === row) return;
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
|
||||
row.classList.add('workout-manager-row--over');
|
||||
});
|
||||
row.addEventListener('dragleave', () => {
|
||||
row.classList.remove('workout-manager-row--over');
|
||||
});
|
||||
row.addEventListener('drop', async (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (!dragEl || dragEl === row) return;
|
||||
const rect = row.getBoundingClientRect();
|
||||
const after = e.clientY > rect.top + rect.height / 2;
|
||||
if (after) {
|
||||
row.after(dragEl);
|
||||
} else {
|
||||
row.before(dragEl);
|
||||
}
|
||||
// 重排后读取新顺序并保存,下次打开设置页沿用。
|
||||
const newOrder = Array.from(
|
||||
listEl.querySelectorAll<HTMLElement>('.workout-manager-row')
|
||||
)
|
||||
.map((el) => el.getAttribute('data-key'))
|
||||
.filter((k): k is string => !!k);
|
||||
this.dataManager.getSettings().managerOrder = newOrder;
|
||||
await this.dataManager.saveSettings();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
order.forEach((key) => renderRow(key));
|
||||
}
|
||||
|
||||
// 校验管理条目顺序:仅保留已知 key 并补齐缺失 key,保证顺序数组始终完整且合法。
|
||||
private normalizeManagerOrder(saved: string[] | undefined): string[] {
|
||||
const all = ['types', 'exercises', 'muscles', 'statistics', 'plans'];
|
||||
const valid = (saved ?? []).filter((k) => all.includes(k));
|
||||
const missing = all.filter((k) => !valid.includes(k));
|
||||
return [...valid, ...missing];
|
||||
}
|
||||
|
||||
// 区块(数据维护):压缩清理 CSV。删除记录采用软删除(仅标记、不重写),
|
||||
// 文件体积不会立即回收;本区块提供按钮,在用户主动触发时整文件压缩、彻底移除被删记录。
|
||||
private renderMaintenanceSection(containerEl: HTMLElement): void {
|
||||
const section = containerEl.createEl('div');
|
||||
section.createEl('h3', { text: t('settings.maintenance') });
|
||||
|
||||
new Setting(section)
|
||||
.setName(t('settings.compactCsv'))
|
||||
.setDesc(t('settings.compactCsvDesc'))
|
||||
.addButton((btn) =>
|
||||
btn
|
||||
.setButtonText(t('settings.compactCsv'))
|
||||
.setWarning()
|
||||
.onClick(async () => {
|
||||
if (!(await confirmWithModal(this.app, t('settings.confirmCompact')))) return;
|
||||
const removed = await this.dataManager.compactLogs();
|
||||
new Notice(t('settings.compactDone', { n: String(removed) }));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 区块六:通用设置。提醒阈值、重量单位、语言、上次值记忆。
|
||||
private renderGeneralSection(containerEl: HTMLElement): void {
|
||||
const section = containerEl.createEl('div');
|
||||
section.createEl('h3', { text: t('settings.general') });
|
||||
|
||||
// 重量单位:下拉框(addDropdown)。可选 kg / lb,改后保存并刷新所有代码块。
|
||||
new Setting(section)
|
||||
.setName(t('settings.unit'))
|
||||
.addDropdown((dropdown) =>
|
||||
dropdown
|
||||
.addOption('kg', 'kg')
|
||||
.addOption('lb', 'lb')
|
||||
.setValue(this.dataManager.getSettings().unit)
|
||||
.onChange(async (value) => {
|
||||
this.dataManager.getSettings().unit = value as 'kg' | 'lb';
|
||||
await this.dataManager.saveSettings();
|
||||
rerenderAllBlocks();
|
||||
})
|
||||
);
|
||||
|
||||
// 语言:下拉框,可选中文 / 英文。改后保存 + 切换语言 + 重绘本页以即时生效。
|
||||
new Setting(section)
|
||||
.setName(t('settings.language'))
|
||||
.addDropdown((dropdown) =>
|
||||
dropdown
|
||||
.addOption('zh', t('settings.chinese'))
|
||||
.addOption('en', t('settings.english'))
|
||||
.setValue(this.dataManager.getSettings().language)
|
||||
.onChange(async (value) => {
|
||||
this.dataManager.getSettings().language = value as 'zh' | 'en';
|
||||
await this.dataManager.saveSettings();
|
||||
setLocale(value as 'zh' | 'en');
|
||||
rerenderAllBlocks();
|
||||
this.display();
|
||||
})
|
||||
);
|
||||
|
||||
// 上次值记忆:开关(addToggle)。打开后下次录训练会自动带出上次填的值。
|
||||
new Setting(section)
|
||||
.setName(t('settings.lastValueMemory'))
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(this.dataManager.getSettings().lastValueMemory)
|
||||
.onChange(async (value) => {
|
||||
this.dataManager.getSettings().lastValueMemory = value;
|
||||
await this.dataManager.saveSettings();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 区块六:第三方联动开关。Dataview / Daily Notes / Templater 各自一个开关。
|
||||
private renderIntegrationSection(containerEl: HTMLElement): void {
|
||||
const section = containerEl.createEl('div');
|
||||
section.createEl('h3', { text: t('settings.integrations') });
|
||||
|
||||
// 联动 Dataview 插件的开关。
|
||||
new Setting(section)
|
||||
.setName(t('settings.dataview'))
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(this.dataManager.getSettings().dataviewIntegration)
|
||||
.onChange(async (value) => {
|
||||
this.dataManager.getSettings().dataviewIntegration = value;
|
||||
await this.dataManager.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
// 联动 Daily Notes(日记)的开关。
|
||||
new Setting(section)
|
||||
.setName(t('settings.dailyNotes'))
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(this.dataManager.getSettings().dailyNotesIntegration)
|
||||
.onChange(async (value) => {
|
||||
this.dataManager.getSettings().dailyNotesIntegration = value;
|
||||
await this.dataManager.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
// 联动 Templater 插件的开关。
|
||||
new Setting(section)
|
||||
.setName(t('settings.templater'))
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(this.dataManager.getSettings().templaterIntegration)
|
||||
.onChange(async (value) => {
|
||||
this.dataManager.getSettings().templaterIntegration = value;
|
||||
await this.dataManager.saveSettings();
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
180
src/ui/StatManagerModal.ts
Normal file
180
src/ui/StatManagerModal.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import { Modal, Notice } from 'obsidian';
|
||||
import { DataManager } from '../data/DataManager';
|
||||
import { getTrainingTypeName } from '../data/display';
|
||||
import { StatDef, StatGranularity, WorkoutConfig } from '../data/types';
|
||||
import { t } from '../i18n';
|
||||
import { StatModal } from './StatModal';
|
||||
import { confirmWithModal } from './Confirm';
|
||||
|
||||
/* StatManagerModal —— "数据统计"列表管理弹窗(镜像 TypeManagerModal)。
|
||||
* 列出所有统计条目,每条显示:名称 / 关联训练类型 / 时间粒度 / 启用开关,
|
||||
* 并提供"编辑 / 删除"按钮。点编辑打开 StatModal 编辑单个统计;删除前二次确认。 */
|
||||
|
||||
export class StatManagerModal extends Modal {
|
||||
private dataManager: DataManager;
|
||||
private config!: WorkoutConfig;
|
||||
private statsContainer!: HTMLDivElement;
|
||||
private stats: StatDef[] = [];
|
||||
// 搜索关键字(已转小写),用于实时过滤列表。
|
||||
private searchQuery = '';
|
||||
|
||||
constructor(dataManager: DataManager) {
|
||||
super(dataManager.app);
|
||||
this.dataManager = dataManager;
|
||||
}
|
||||
|
||||
async onOpen(): Promise<void> {
|
||||
const { contentEl } = this;
|
||||
contentEl.addClass('workout-manager-modal');
|
||||
|
||||
this.config = await this.dataManager.getConfig();
|
||||
this.stats = this.config.statistics;
|
||||
|
||||
contentEl.createEl('h2', { text: t('modal.statManager.title') });
|
||||
|
||||
// 顶部搜索框(吸顶):按名称 / 关联类型 / ID 实时过滤。
|
||||
const searchWrap = contentEl.createDiv();
|
||||
searchWrap.addClass('workout-manager-search');
|
||||
const search = searchWrap.createEl('input', { type: 'text', cls: 'workout-input' });
|
||||
search.placeholder = t('modal.statManager.search');
|
||||
search.addEventListener('input', () => {
|
||||
this.searchQuery = search.value.trim().toLowerCase();
|
||||
this.renderStats();
|
||||
});
|
||||
|
||||
// 顶部工具栏:新增统计。
|
||||
const topToolbar = contentEl.createDiv();
|
||||
topToolbar.addClass('workout-btn-row');
|
||||
topToolbar.style.justifyContent = 'flex-start';
|
||||
const addTop = topToolbar.createEl('button', { text: t('modal.statManager.add') });
|
||||
addTop.addClass('mod-cta');
|
||||
addTop.addEventListener('click', () => this.openAddStat());
|
||||
|
||||
this.statsContainer = contentEl.createDiv();
|
||||
this.statsContainer.addClass('workout-muscles-list');
|
||||
|
||||
this.renderStats();
|
||||
|
||||
const btnRow = contentEl.createDiv();
|
||||
btnRow.addClass('workout-btn-row');
|
||||
|
||||
const closeBtn = btnRow.createEl('button', { text: t('common.close') });
|
||||
closeBtn.addClass('mod-muted');
|
||||
closeBtn.addEventListener('click', () => this.close());
|
||||
}
|
||||
|
||||
private granularityLabel(g: StatGranularity): string {
|
||||
return g === 'daily' ? t('modal.statManager.daily')
|
||||
: g === 'weekly' ? t('modal.statManager.weekly')
|
||||
: t('modal.statManager.monthly');
|
||||
}
|
||||
|
||||
private renderStats(): void {
|
||||
this.statsContainer.empty();
|
||||
|
||||
if (this.stats.length === 0) {
|
||||
this.statsContainer.createEl('p', { text: t('settings.noStatistics') });
|
||||
return;
|
||||
}
|
||||
|
||||
// 按搜索词过滤(名称 / 关联类型 / ID);空列表与无匹配分别给出提示。
|
||||
const q = this.searchQuery;
|
||||
const visible = this.stats.filter((stat) => {
|
||||
if (!q) return true;
|
||||
if (stat.name.toLowerCase().includes(q) || stat.id.toLowerCase().includes(q)) return true;
|
||||
const typeNames = stat.associatedTypes
|
||||
.map((id) => {
|
||||
const type = this.config.trainingTypes.find((ty) => ty.id === id);
|
||||
return type ? (getTrainingTypeName(type) || type.id) : id;
|
||||
})
|
||||
.join(' ');
|
||||
return typeNames.toLowerCase().includes(q);
|
||||
});
|
||||
|
||||
if (visible.length === 0) {
|
||||
this.statsContainer.createEl('p', { text: t('common.noMatch') });
|
||||
return;
|
||||
}
|
||||
|
||||
for (const stat of visible) {
|
||||
const row = this.statsContainer.createDiv();
|
||||
row.addClass('workout-card');
|
||||
|
||||
const infoCol = row.createDiv();
|
||||
infoCol.addClass('workout-card-info');
|
||||
infoCol.createEl('div', { text: stat.name, cls: 'workout-card-title' });
|
||||
|
||||
const btnCol = row.createDiv();
|
||||
btnCol.addClass('workout-card-actions');
|
||||
|
||||
const typeNames = stat.associatedTypes
|
||||
.map((id) => {
|
||||
const type = this.config.trainingTypes.find((ty) => ty.id === id);
|
||||
return type ? (getTrainingTypeName(type) || type.id) : id;
|
||||
})
|
||||
.join(', ');
|
||||
const detailLines = [
|
||||
`${t('modal.statManager.types')}: ${typeNames || t('modal.statManager.none')}`,
|
||||
`${t('modal.statManager.granularity')}: ${this.granularityLabel(stat.granularity)}`,
|
||||
];
|
||||
for (const detail of detailLines) {
|
||||
infoCol.createEl('div', { text: detail, cls: 'workout-card-meta' });
|
||||
}
|
||||
|
||||
// 启用开关(列表内直接切换)—— 设计稿为普通勾选框,不使用 workout-switch 布尔滑块
|
||||
const toggleWrap = btnCol.createEl('label');
|
||||
toggleWrap.addClass('workout-inline-check');
|
||||
const toggle = toggleWrap.createEl('input', { type: 'checkbox' }) as HTMLInputElement;
|
||||
toggle.checked = stat.enabled;
|
||||
toggle.addEventListener('change', async () => {
|
||||
stat.enabled = toggle.checked;
|
||||
await this.saveStats();
|
||||
});
|
||||
toggleWrap.appendText(t('modal.statManager.enabled'));
|
||||
|
||||
const editBtn = btnCol.createEl('button', { text: t('modal.statManager.edit') });
|
||||
editBtn.addClass('workout-action-btn');
|
||||
editBtn.addEventListener('click', () => {
|
||||
const editModal = new StatModal(this.dataManager, { editStat: stat });
|
||||
editModal.onClose = () => this.refresh();
|
||||
editModal.open();
|
||||
});
|
||||
|
||||
const deleteBtn = btnCol.createEl('button', { text: t('modal.statManager.delete') });
|
||||
deleteBtn.addClass('workout-danger-btn');
|
||||
deleteBtn.addEventListener('click', () => this.deleteStat(stat));
|
||||
}
|
||||
}
|
||||
|
||||
// 打开"新增统计"弹窗,关闭后刷新列表。
|
||||
private openAddStat(): void {
|
||||
const editModal = new StatModal(this.dataManager);
|
||||
editModal.onClose = () => this.refresh();
|
||||
editModal.open();
|
||||
}
|
||||
|
||||
private async saveStats(): Promise<void> {
|
||||
const config = await this.dataManager.getConfig();
|
||||
config.statistics = this.stats;
|
||||
await this.dataManager.saveConfig(config);
|
||||
}
|
||||
|
||||
private async deleteStat(stat: StatDef): Promise<void> {
|
||||
if (!(await confirmWithModal(this.app, `${t('settings.confirmDelete')}:${stat.name}?`))) return;
|
||||
this.stats = this.stats.filter((s) => s.id !== stat.id);
|
||||
await this.saveStats();
|
||||
new Notice(`${stat.name} ${t('common.delete')}`);
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
private async refresh(): Promise<void> {
|
||||
this.config = await this.dataManager.getConfig();
|
||||
this.stats = this.config.statistics;
|
||||
this.renderStats();
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
}
|
||||
}
|
||||
412
src/ui/StatModal.ts
Normal file
412
src/ui/StatModal.ts
Normal file
|
|
@ -0,0 +1,412 @@
|
|||
import { Modal, Notice } from 'obsidian';
|
||||
import { DataManager } from '../data/DataManager';
|
||||
import { getTrainingTypeName } from '../data/display';
|
||||
import { StatDef, StatAggregation, StatGranularity, WorkoutConfig } from '../data/types';
|
||||
import { allowedStatFields, builderToExpr, exprToBuilder, validateExpression } from '../data/statExpr';
|
||||
import { t } from '../i18n';
|
||||
|
||||
/* StatModal —— "新增/编辑数据统计"弹窗。
|
||||
* 表单包含:名称 / 关联训练类型(多选) / 公式(引导式+表达式双模式) / 时间粒度 / 启用。
|
||||
* 关联类型变化时会重算「字段交集」并重载字段下拉;表达式模式实时校验、保存前再校验。 */
|
||||
|
||||
interface StatModalOptions {
|
||||
editStat?: StatDef;
|
||||
}
|
||||
|
||||
// 引导式运算下拉的展示标签(内部 kind 不友好,需映射成用户看得懂的文字)。
|
||||
// 用函数(而非模块常量)是为了在「每次渲染」时取当前语言,语言切换后能即时更新。
|
||||
function opLabel(kind: StatAggregation['kind']): string {
|
||||
switch (kind) {
|
||||
case 'sum': return t('modal.statManager.opSum');
|
||||
case 'productSum': return t('modal.statManager.opProductSum');
|
||||
case 'oneRepMax': return t('modal.statManager.opOneRepMax');
|
||||
case 'avg': return t('modal.statManager.opAvg');
|
||||
case 'max': return t('modal.statManager.opMax');
|
||||
case 'min': return t('modal.statManager.opMin');
|
||||
case 'count': return t('modal.statManager.opCount');
|
||||
}
|
||||
}
|
||||
|
||||
export class StatModal extends Modal {
|
||||
private dataManager: DataManager;
|
||||
private options: StatModalOptions;
|
||||
private config!: WorkoutConfig;
|
||||
|
||||
private name = '';
|
||||
private associatedTypes: string[] = [];
|
||||
private mode: 'builder' | 'expression' = 'builder';
|
||||
private builder: StatAggregation = { kind: 'sum', field: '' };
|
||||
private expression = '';
|
||||
private granularity: StatGranularity = 'daily';
|
||||
private enabled = true;
|
||||
|
||||
private typesContainer!: HTMLDivElement;
|
||||
private formulaContainer!: HTMLDivElement;
|
||||
|
||||
constructor(dataManager: DataManager, options: StatModalOptions = {}) {
|
||||
super(dataManager.app);
|
||||
this.dataManager = dataManager;
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
async onOpen(): Promise<void> {
|
||||
const { contentEl } = this;
|
||||
contentEl.addClass('workout-edit-modal');
|
||||
|
||||
this.config = await this.dataManager.getConfig();
|
||||
|
||||
// 编辑模式:从已有统计拷贝状态(深拷贝 builder,避免直接改动原配置)
|
||||
const edit = this.options.editStat;
|
||||
if (edit) {
|
||||
this.name = edit.name;
|
||||
this.associatedTypes = [...edit.associatedTypes];
|
||||
this.mode = edit.formula.mode;
|
||||
this.builder = edit.formula.builder ? { ...edit.formula.builder } : { kind: 'sum', field: '' };
|
||||
this.expression = edit.formula.expression ?? '';
|
||||
this.granularity = edit.granularity;
|
||||
this.enabled = edit.enabled;
|
||||
}
|
||||
|
||||
contentEl.createEl('h2', { text: edit ? t('modal.stat.editTitle') : t('modal.stat.title') });
|
||||
|
||||
// 名称
|
||||
const nameRow = contentEl.createDiv();
|
||||
nameRow.addClass('workout-field');
|
||||
nameRow.createEl('label', { text: t('modal.statManager.name') });
|
||||
const nameInput = nameRow.createEl('input', { type: 'text' }) as HTMLInputElement;
|
||||
nameInput.addClass('workout-input');
|
||||
nameInput.value = this.name;
|
||||
nameInput.addEventListener('input', () => { this.name = nameInput.value; });
|
||||
|
||||
// 关联训练类型(多选)
|
||||
contentEl.createEl('h3', { text: t('modal.statManager.types') });
|
||||
this.typesContainer = contentEl.createDiv();
|
||||
this.typesContainer.addClass('workout-check-grid');
|
||||
this.renderTypeChecks();
|
||||
|
||||
// 公式
|
||||
contentEl.createEl('h3', { text: t('modal.statManager.formula') });
|
||||
const modeRow = contentEl.createDiv();
|
||||
modeRow.addClass('workout-field');
|
||||
modeRow.createEl('label', { text: t('modal.statManager.mode') });
|
||||
const modeSelect = modeRow.createEl('select') as HTMLSelectElement;
|
||||
modeSelect.addClass('workout-select');
|
||||
for (const m of ['builder', 'expression'] as const) {
|
||||
const opt = modeSelect.createEl('option', {
|
||||
value: m,
|
||||
text: m === 'builder' ? t('modal.statManager.builder') : t('modal.statManager.expression'),
|
||||
});
|
||||
if (m === this.mode) opt.selected = true;
|
||||
}
|
||||
modeSelect.addEventListener('change', () => {
|
||||
this.mode = modeSelect.value as 'builder' | 'expression';
|
||||
// 切换模式时把当前公式同步成另一形态的表示,避免切换后公式丢失。
|
||||
// 引导式 → 表达式:用 builderToExpr 生成表达式字符串。
|
||||
// 表达式 → 引导式:用 exprToBuilder 尽力回填;复杂表达式降级为默认 sum。
|
||||
if (this.mode === 'expression') {
|
||||
this.expression = builderToExpr(this.builder) || this.expression;
|
||||
} else {
|
||||
const b = exprToBuilder(this.expression);
|
||||
if (b) this.builder = b;
|
||||
}
|
||||
this.renderFormula();
|
||||
});
|
||||
|
||||
this.formulaContainer = contentEl.createDiv();
|
||||
this.renderFormula();
|
||||
|
||||
// 两列网格:时间粒度 + 启用(对齐重设计稿)
|
||||
const granEnableCol = contentEl.createDiv();
|
||||
granEnableCol.addClass('workout-two-col');
|
||||
|
||||
// 时间粒度
|
||||
const granRow = granEnableCol.createDiv();
|
||||
granRow.addClass('workout-field');
|
||||
granRow.createEl('label', { text: t('modal.statManager.granularity') });
|
||||
const granSelect = granRow.createEl('select') as HTMLSelectElement;
|
||||
granSelect.addClass('workout-select');
|
||||
for (const g of ['daily', 'weekly', 'monthly'] as const) {
|
||||
const opt = granSelect.createEl('option', {
|
||||
value: g,
|
||||
text: g === 'daily' ? t('modal.statManager.daily')
|
||||
: g === 'weekly' ? t('modal.statManager.weekly')
|
||||
: t('modal.statManager.monthly'),
|
||||
});
|
||||
if (g === this.granularity) opt.selected = true;
|
||||
}
|
||||
granSelect.addEventListener('change', () => { this.granularity = granSelect.value as StatGranularity; });
|
||||
|
||||
// 启用(布尔开关)
|
||||
const enabledToggleWrap = granEnableCol.createDiv();
|
||||
enabledToggleWrap.addClass('workout-toggle');
|
||||
const enabledToggle = enabledToggleWrap.createEl('input', { type: 'checkbox', cls: 'workout-switch' }) as HTMLInputElement;
|
||||
enabledToggle.checked = this.enabled;
|
||||
enabledToggle.addEventListener('change', () => { this.enabled = enabledToggle.checked; });
|
||||
enabledToggleWrap.createEl('span', { text: t('modal.statManager.showInStats') });
|
||||
|
||||
// 底部按钮
|
||||
const btnRow = contentEl.createDiv();
|
||||
btnRow.addClass('workout-btn-row');
|
||||
const cancelBtn = btnRow.createEl('button', { text: t('common.cancel') });
|
||||
cancelBtn.addClass('mod-muted');
|
||||
cancelBtn.addEventListener('click', () => this.close());
|
||||
const saveBtn = btnRow.createEl('button', { text: t('common.save') });
|
||||
saveBtn.addClass('mod-cta');
|
||||
saveBtn.addEventListener('click', () => this.save());
|
||||
}
|
||||
|
||||
// 当前关联类型下的可用字段交集(供字段下拉与校验使用)
|
||||
private allowed(): string[] {
|
||||
const tmp: StatDef = {
|
||||
id: 'tmp', name: this.name, associatedTypes: this.associatedTypes,
|
||||
formula: { mode: 'builder', builder: this.builder },
|
||||
granularity: this.granularity, enabled: this.enabled,
|
||||
};
|
||||
return allowedStatFields(tmp, this.config);
|
||||
}
|
||||
|
||||
private renderTypeChecks(): void {
|
||||
this.typesContainer.empty();
|
||||
if (this.config.trainingTypes.length === 0) {
|
||||
this.typesContainer.createEl('p', { text: t('modal.statManager.noTypes') });
|
||||
return;
|
||||
}
|
||||
for (const type of this.config.trainingTypes) {
|
||||
const row = this.typesContainer.createDiv();
|
||||
row.addClass('workout-check-item');
|
||||
const checkbox = row.createEl('input', { type: 'checkbox' }) as HTMLInputElement;
|
||||
checkbox.checked = this.associatedTypes.includes(type.id);
|
||||
checkbox.addEventListener('change', () => {
|
||||
if (checkbox.checked) {
|
||||
if (!this.associatedTypes.includes(type.id)) this.associatedTypes.push(type.id);
|
||||
} else {
|
||||
this.associatedTypes = this.associatedTypes.filter((id) => id !== type.id);
|
||||
}
|
||||
this.onTypesChanged();
|
||||
});
|
||||
row.createEl('label', { text: getTrainingTypeName(type) || type.id });
|
||||
}
|
||||
}
|
||||
|
||||
// 关联类型变化:重算字段交集,重置越界字段,重渲染公式区
|
||||
private onTypesChanged(): void {
|
||||
const allowed = this.allowed();
|
||||
const b = this.builder as any;
|
||||
if (!b.field || !allowed.includes(b.field)) b.field = allowed[0] ?? '';
|
||||
if (!b.fieldA || !allowed.includes(b.fieldA)) b.fieldA = allowed[0] ?? '';
|
||||
if (!b.fieldB || !allowed.includes(b.fieldB)) b.fieldB = allowed[0] ?? '';
|
||||
if (!b.weightField || !allowed.includes(b.weightField)) b.weightField = allowed[0] ?? '';
|
||||
if (!b.repsField || !allowed.includes(b.repsField)) b.repsField = allowed[0] ?? '';
|
||||
this.renderFormula();
|
||||
}
|
||||
|
||||
private renderFormula(): void {
|
||||
this.formulaContainer.empty();
|
||||
const allowed = this.allowed();
|
||||
|
||||
if (this.mode === 'builder') {
|
||||
// 运算下拉
|
||||
const opRow = this.formulaContainer.createDiv();
|
||||
opRow.addClass('workout-field');
|
||||
opRow.createEl('label', { text: t('modal.statManager.op') });
|
||||
const opSelect = opRow.createEl('select') as HTMLSelectElement;
|
||||
opSelect.addClass('workout-select');
|
||||
const ops: StatAggregation['kind'][] = ['sum', 'productSum', 'oneRepMax', 'avg', 'max', 'min', 'count'];
|
||||
for (const op of ops) {
|
||||
const opt = opSelect.createEl('option', { value: op, text: opLabel(op) });
|
||||
if (op === this.builder.kind) opt.selected = true;
|
||||
}
|
||||
opSelect.addEventListener('change', () => {
|
||||
const kind = opSelect.value as StatAggregation['kind'];
|
||||
// 切换运算时尽量保留用户已选的「主字段」,避免来回切运算把选择清空。
|
||||
const prevField = (this.builder as any).field ?? (this.builder as any).fieldA ?? (this.builder as any).weightField ?? '';
|
||||
const fieldOk = allowed.includes(prevField);
|
||||
const field = fieldOk ? prevField : (allowed[0] ?? '');
|
||||
// oneRepMax 需要 weightField + repsField;productSum 需要 fieldA + fieldB;其余只需 field。
|
||||
if (kind === 'count') {
|
||||
this.builder = { kind: 'count' };
|
||||
} else if (kind === 'oneRepMax') {
|
||||
this.builder = { kind: 'oneRepMax', weightField: field, repsField: allowed.find(f => f !== field) ?? allowed[0] ?? '' };
|
||||
} else if (kind === 'productSum') {
|
||||
this.builder = { kind: 'productSum', fieldA: field, fieldB: allowed[0] ?? '' };
|
||||
} else {
|
||||
this.builder = { kind, field } as StatAggregation;
|
||||
}
|
||||
this.renderFormula();
|
||||
});
|
||||
|
||||
if (this.builder.kind === 'count') {
|
||||
this.formulaContainer.createEl('p', { text: t('modal.statManager.countHint') });
|
||||
} else if (this.builder.kind === 'oneRepMax') {
|
||||
// 1RM 估算需要重量字段和次数字段(不渲染通用"字段"下拉)
|
||||
// 重量字段
|
||||
const wfRow = this.formulaContainer.createDiv();
|
||||
wfRow.addClass('workout-field');
|
||||
wfRow.createEl('label', { text: t('modal.statManager.weightField') });
|
||||
const wfSelect = wfRow.createEl('select') as HTMLSelectElement;
|
||||
wfSelect.addClass('workout-select');
|
||||
if (allowed.length === 0) {
|
||||
wfSelect.createEl('option', { value: '', text: t('modal.statManager.selectField') });
|
||||
} else {
|
||||
for (const f of allowed) {
|
||||
const opt = wfSelect.createEl('option', { value: f, text: f });
|
||||
if (f === (this.builder as any).weightField) opt.selected = true;
|
||||
}
|
||||
}
|
||||
wfSelect.addEventListener('change', () => { (this.builder as any).weightField = wfSelect.value; });
|
||||
|
||||
// 次数字段
|
||||
const rfRow = this.formulaContainer.createDiv();
|
||||
rfRow.addClass('workout-field');
|
||||
rfRow.createEl('label', { text: t('modal.statManager.repsField') });
|
||||
const rfSelect = rfRow.createEl('select') as HTMLSelectElement;
|
||||
rfSelect.addClass('workout-select');
|
||||
if (allowed.length === 0) {
|
||||
rfSelect.createEl('option', { value: '', text: t('modal.statManager.selectField') });
|
||||
} else {
|
||||
for (const f of allowed) {
|
||||
const opt = rfSelect.createEl('option', { value: f, text: f });
|
||||
if (f === (this.builder as any).repsField) opt.selected = true;
|
||||
}
|
||||
}
|
||||
rfSelect.addEventListener('change', () => { (this.builder as any).repsField = rfSelect.value; });
|
||||
} else {
|
||||
// 通用字段下拉(sum / avg / max / min 的字段;productSum 的字段 A)
|
||||
const fieldRow = this.formulaContainer.createDiv();
|
||||
fieldRow.addClass('workout-field');
|
||||
fieldRow.createEl('label', { text: t('modal.statManager.field') });
|
||||
const fieldSelect = fieldRow.createEl('select') as HTMLSelectElement;
|
||||
fieldSelect.addClass('workout-select');
|
||||
if (allowed.length === 0) {
|
||||
fieldSelect.createEl('option', { value: '', text: t('modal.statManager.selectField') });
|
||||
} else {
|
||||
// 选中值:productSum 取 fieldA,其余取 field
|
||||
const selectedValue = this.builder.kind === 'productSum'
|
||||
? (this.builder as any).fieldA
|
||||
: (this.builder as any).field;
|
||||
for (const f of allowed) {
|
||||
const opt = fieldSelect.createEl('option', { value: f, text: f });
|
||||
if (f === selectedValue) opt.selected = true;
|
||||
}
|
||||
}
|
||||
fieldSelect.addEventListener('change', () => {
|
||||
if (this.builder.kind === 'productSum') (this.builder as any).fieldA = fieldSelect.value;
|
||||
else (this.builder as any).field = fieldSelect.value;
|
||||
});
|
||||
|
||||
// 乘积求和需要第二个字段 B
|
||||
if (this.builder.kind === 'productSum') {
|
||||
const fieldBRow = this.formulaContainer.createDiv();
|
||||
fieldBRow.addClass('workout-field');
|
||||
fieldBRow.createEl('label', { text: t('modal.statManager.fieldB') });
|
||||
const fieldBSelect = fieldBRow.createEl('select') as HTMLSelectElement;
|
||||
fieldBSelect.addClass('workout-select');
|
||||
if (allowed.length === 0) {
|
||||
fieldBSelect.createEl('option', { value: '', text: t('modal.statManager.selectField') });
|
||||
} else {
|
||||
for (const f of allowed) {
|
||||
const opt = fieldBSelect.createEl('option', { value: f, text: f });
|
||||
if (f === (this.builder as any).fieldB) opt.selected = true;
|
||||
}
|
||||
}
|
||||
fieldBSelect.addEventListener('change', () => { (this.builder as any).fieldB = fieldBSelect.value; });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 表达式模式
|
||||
const ta = this.formulaContainer.createEl('textarea') as HTMLTextAreaElement;
|
||||
ta.addClass('workout-input');
|
||||
ta.value = this.expression;
|
||||
ta.placeholder = t('modal.statManager.exprPlaceholder');
|
||||
ta.addEventListener('input', () => { this.expression = ta.value; this.updateExprError(allowed); });
|
||||
ta.addEventListener('blur', () => { this.updateExprError(allowed); });
|
||||
|
||||
const hint = this.formulaContainer.createEl('p', { cls: 'workout-manager-detail' });
|
||||
hint.textContent = `${t('modal.statManager.allowedFields')}: ${allowed.length ? allowed.join(', ') : t('modal.statManager.none')}`;
|
||||
|
||||
const errEl = this.formulaContainer.createEl('p', { cls: 'workout-manager-detail' });
|
||||
errEl.addClass('workout-stat-error');
|
||||
errEl.style.color = 'var(--text-error)';
|
||||
(this.formulaContainer as any)._errEl = errEl;
|
||||
this.updateExprError(allowed);
|
||||
}
|
||||
|
||||
// 预览
|
||||
const preview = this.formulaContainer.createEl('p', { cls: 'workout-manager-detail' });
|
||||
const exprStr = this.mode === 'builder' ? builderToExpr(this.builder) : this.expression;
|
||||
preview.textContent = `${t('modal.statManager.preview')}: ${exprStr || '-'}`;
|
||||
}
|
||||
|
||||
private updateExprError(allowed: string[]): void {
|
||||
const errEl = (this.formulaContainer as any)?._errEl as HTMLElement | undefined;
|
||||
if (!errEl) return;
|
||||
if (this.mode !== 'expression' || !this.expression.trim()) {
|
||||
errEl.textContent = '';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
validateExpression(this.expression, allowed);
|
||||
errEl.textContent = '';
|
||||
} catch (e) {
|
||||
errEl.textContent = `${t('modal.statManager.exprError')} ${(e as Error).message}`;
|
||||
}
|
||||
}
|
||||
|
||||
private async save(): Promise<void> {
|
||||
const name = this.name.trim();
|
||||
if (!name) { new Notice(t('modal.stat.nameRequired')); return; }
|
||||
if (this.associatedTypes.length === 0) { new Notice(t('modal.stat.typesRequired')); return; }
|
||||
|
||||
const allowed = this.allowed();
|
||||
let formula: StatDef['formula'];
|
||||
if (this.mode === 'builder') {
|
||||
if (this.builder.kind !== 'count') {
|
||||
// productSum 需要 fieldA + fieldB;oneRepMax 需要 weightField + repsField;其余(sum/avg/max/min)需要 field。
|
||||
const b = this.builder as any;
|
||||
const missing = this.builder.kind === 'productSum'
|
||||
? !(b.fieldA && b.fieldB)
|
||||
: this.builder.kind === 'oneRepMax'
|
||||
? !(b.weightField && b.repsField)
|
||||
: !b.field;
|
||||
if (missing) {
|
||||
new Notice(t('modal.stat.selectField'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
formula = { mode: 'builder', builder: this.builder };
|
||||
} else {
|
||||
try {
|
||||
validateExpression(this.expression, allowed);
|
||||
} catch (e) {
|
||||
new Notice(`${t('modal.statManager.exprError')} ${(e as Error).message}`);
|
||||
return;
|
||||
}
|
||||
formula = { mode: 'expression', expression: this.expression };
|
||||
}
|
||||
|
||||
const stat: StatDef = {
|
||||
id: this.options.editStat?.id ?? crypto.randomUUID(),
|
||||
name,
|
||||
associatedTypes: [...this.associatedTypes],
|
||||
formula,
|
||||
granularity: this.granularity,
|
||||
enabled: this.enabled,
|
||||
};
|
||||
|
||||
const config = await this.dataManager.getConfig();
|
||||
if (this.options.editStat) {
|
||||
const idx = config.statistics.findIndex((s) => s.id === stat.id);
|
||||
if (idx !== -1) config.statistics[idx] = stat;
|
||||
} else {
|
||||
config.statistics.push(stat);
|
||||
}
|
||||
await this.dataManager.saveConfig(config);
|
||||
new Notice(t('modal.stat.saved'));
|
||||
this.close();
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
}
|
||||
}
|
||||
152
src/ui/TrainingPlanManagerModal.ts
Normal file
152
src/ui/TrainingPlanManagerModal.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { Modal, Notice } from 'obsidian';
|
||||
import { DataManager } from '../data/DataManager';
|
||||
import { formatTimeRule } from '../data/display';
|
||||
import { TrainingPlanInstance } from '../data/types';
|
||||
import { t } from '../i18n';
|
||||
import { NewPlanModal } from './NewPlanModal';
|
||||
import { confirmWithModal } from './Confirm';
|
||||
|
||||
/* TrainingPlanManagerModal —— "训练计划"列表管理弹窗。
|
||||
* 与训练项/类型/肌肉/统计的管理弹窗保持一致:设置页点「打开管理」后弹出,
|
||||
* 只有在这个弹窗里才能进行计划的 新增 / 编辑 / 删除。删除仅移除计划定义,
|
||||
* 不影响已产生的训练记录(CSV 历史保留)。 */
|
||||
|
||||
export class TrainingPlanManagerModal extends Modal {
|
||||
private dataManager: DataManager;
|
||||
private listContainer!: HTMLDivElement;
|
||||
private plans: TrainingPlanInstance[] = [];
|
||||
// 搜索关键字(已转小写),用于实时过滤列表。
|
||||
private searchQuery = '';
|
||||
|
||||
constructor(dataManager: DataManager) {
|
||||
super(dataManager.app);
|
||||
this.dataManager = dataManager;
|
||||
}
|
||||
|
||||
async onOpen(): Promise<void> {
|
||||
const { contentEl } = this;
|
||||
contentEl.addClass('workout-manager-modal');
|
||||
|
||||
const config = await this.dataManager.getConfig();
|
||||
this.plans = config.plans ?? [];
|
||||
|
||||
contentEl.createEl('h2', { text: t('settings.trainingPlans.manage') });
|
||||
|
||||
// 顶部搜索框(吸顶):按名称 / 来源实时过滤。
|
||||
const searchWrap = contentEl.createDiv();
|
||||
searchWrap.addClass('workout-manager-search');
|
||||
const search = searchWrap.createEl('input', { type: 'text', cls: 'workout-input' });
|
||||
search.placeholder = t('settings.trainingPlans.search');
|
||||
search.addEventListener('input', () => {
|
||||
this.searchQuery = search.value.trim().toLowerCase();
|
||||
this.renderList();
|
||||
});
|
||||
|
||||
// 顶部工具栏:新增训练计划。
|
||||
const topToolbar = contentEl.createDiv();
|
||||
topToolbar.addClass('workout-btn-row');
|
||||
topToolbar.style.justifyContent = 'flex-start';
|
||||
const addTop = topToolbar.createEl('button', { text: t('settings.trainingPlans.add') });
|
||||
addTop.addClass('mod-cta');
|
||||
addTop.addEventListener('click', () => this.openAddPlan());
|
||||
|
||||
this.listContainer = contentEl.createDiv();
|
||||
this.listContainer.addClass('workout-muscles-list');
|
||||
|
||||
this.renderList();
|
||||
|
||||
const btnRow = contentEl.createDiv();
|
||||
btnRow.addClass('workout-btn-row');
|
||||
|
||||
const closeBtn = btnRow.createEl('button', { text: t('common.close') });
|
||||
closeBtn.addClass('mod-muted');
|
||||
closeBtn.addEventListener('click', () => this.close());
|
||||
}
|
||||
|
||||
// 渲染计划列表:无数据显示空提示,否则逐条画出名称、时间规则、训练项数、来源与编辑/删除按钮。
|
||||
private renderList(): void {
|
||||
this.listContainer.empty();
|
||||
|
||||
if (this.plans.length === 0) {
|
||||
this.listContainer.createEl('p', { text: t('settings.trainingPlans.noPlans') });
|
||||
return;
|
||||
}
|
||||
|
||||
// 按搜索词过滤(名称 / 来源);空列表与无匹配分别给出提示。
|
||||
const q = this.searchQuery;
|
||||
const visible = this.plans.filter((plan) => {
|
||||
if (!q) return true;
|
||||
const name = plan.name.toLowerCase();
|
||||
const source = (plan.sourceNote || '').toLowerCase();
|
||||
return name.includes(q) || source.includes(q);
|
||||
});
|
||||
|
||||
if (visible.length === 0) {
|
||||
this.listContainer.createEl('p', { text: t('common.noMatch') });
|
||||
return;
|
||||
}
|
||||
|
||||
for (const plan of visible) {
|
||||
const enabledCount = plan.items.filter((i) => i.enabled).length;
|
||||
const row = this.listContainer.createDiv();
|
||||
row.addClass('workout-card');
|
||||
|
||||
const infoCol = row.createDiv();
|
||||
infoCol.addClass('workout-card-info');
|
||||
|
||||
infoCol.createEl('div', { text: plan.name, cls: 'workout-card-title' });
|
||||
|
||||
const detailLines = [
|
||||
`${t('settings.trainingPlans.schedule')}: ${formatTimeRule(plan.timeRule)}`,
|
||||
`${t('settings.trainingPlans.itemCount')}: ${enabledCount}`,
|
||||
`${t('settings.trainingPlans.source')}: ${plan.sourceNote || t('settings.trainingPlans.manual')}`,
|
||||
];
|
||||
for (const detail of detailLines) {
|
||||
infoCol.createEl('div', { text: detail, cls: 'workout-card-meta' });
|
||||
}
|
||||
|
||||
const btnCol = row.createDiv();
|
||||
btnCol.addClass('workout-card-actions');
|
||||
|
||||
const editBtn = btnCol.createEl('button', { text: t('settings.trainingPlans.edit') });
|
||||
editBtn.addClass('workout-action-btn');
|
||||
editBtn.addEventListener('click', () => {
|
||||
const modal = new NewPlanModal(this.dataManager, { editPlan: plan });
|
||||
modal.onClose = () => this.refresh();
|
||||
modal.open();
|
||||
});
|
||||
|
||||
const deleteBtn = btnCol.createEl('button', { text: t('settings.trainingPlans.delete') });
|
||||
deleteBtn.addClass('workout-danger-btn');
|
||||
deleteBtn.addEventListener('click', () => this.deletePlan(plan));
|
||||
}
|
||||
}
|
||||
|
||||
// 打开"新增训练计划"弹窗,关闭后刷新列表。
|
||||
private openAddPlan(): void {
|
||||
const modal = new NewPlanModal(this.dataManager);
|
||||
modal.onClose = () => this.refresh();
|
||||
modal.open();
|
||||
}
|
||||
|
||||
// 删除计划:确认后执行删除并刷新列表(不删 CSV 历史记录)。
|
||||
private async deletePlan(plan: TrainingPlanInstance): Promise<void> {
|
||||
if (!(await confirmWithModal(this.app, t('settings.trainingPlans.confirmDelete', { name: plan.name })))) {
|
||||
return;
|
||||
}
|
||||
await this.dataManager.deletePlan(plan.name);
|
||||
new Notice(`${plan.name} ${t('common.delete')}`);
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
// 重新读取配置并刷新列表。
|
||||
private async refresh(): Promise<void> {
|
||||
const config = await this.dataManager.getConfig();
|
||||
this.plans = config.plans ?? [];
|
||||
this.renderList();
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.contentEl.empty();
|
||||
}
|
||||
}
|
||||
176
src/ui/TypeManagerModal.ts
Normal file
176
src/ui/TypeManagerModal.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import { Modal, Notice } from 'obsidian';
|
||||
import { DataManager } from '../data/DataManager';
|
||||
import { getExerciseName, getFieldLabel, getTrainingTypeName } from '../data/display';
|
||||
import { TrainingType } from '../data/types';
|
||||
import { t } from '../i18n';
|
||||
import { TypeModal } from './TypeModal';
|
||||
import { confirmWithModal } from './Confirm';
|
||||
|
||||
/* TypeManagerModal —— "训练类型"列表管理弹窗。
|
||||
* 继承 Obsidian 的 Modal(居中浮层弹窗)。打开后列出所有训练类型,每条带字段说明、
|
||||
* 是否计入覆盖,以及"编辑/删除"按钮。点编辑会打开 TypeModal 编辑单个类型,
|
||||
* 删除前会检查该类型是否被训练项引用,避免误删导致数据错乱。 */
|
||||
|
||||
export class TypeManagerModal extends Modal {
|
||||
private dataManager: DataManager;
|
||||
// 装类型列表的容器,重绘时只清空它而不动标题/按钮。
|
||||
private typesContainer!: HTMLDivElement;
|
||||
// 当前内存中的类型列表(来自配置)。
|
||||
private types: TrainingType[] = [];
|
||||
// 搜索关键字(已转小写),用于实时过滤列表。
|
||||
private searchQuery = '';
|
||||
|
||||
constructor(dataManager: DataManager) {
|
||||
super(dataManager.app);
|
||||
this.dataManager = dataManager;
|
||||
}
|
||||
|
||||
// 弹窗打开时:读配置、搭标题与列表容器、渲染列表、底部加"新增/关闭"按钮。
|
||||
async onOpen(): Promise<void> {
|
||||
const { contentEl } = this;
|
||||
contentEl.addClass('workout-manager-modal');
|
||||
|
||||
const config = await this.dataManager.getConfig();
|
||||
this.types = config.trainingTypes;
|
||||
|
||||
contentEl.createEl('h2', { text: t('modal.typeManager.title') });
|
||||
|
||||
// 顶部搜索框(吸顶):按名称 / ID 实时过滤。
|
||||
const searchWrap = contentEl.createDiv();
|
||||
searchWrap.addClass('workout-manager-search');
|
||||
const search = searchWrap.createEl('input', { type: 'text', cls: 'workout-input' });
|
||||
search.placeholder = t('modal.typeManager.search');
|
||||
search.addEventListener('input', () => {
|
||||
this.searchQuery = search.value.trim().toLowerCase();
|
||||
this.renderTypes();
|
||||
});
|
||||
|
||||
// 顶部工具栏:新增训练类型。
|
||||
const topToolbar = contentEl.createDiv();
|
||||
topToolbar.addClass('workout-btn-row');
|
||||
topToolbar.style.justifyContent = 'flex-start';
|
||||
const addTop = topToolbar.createEl('button', { text: t('modal.typeManager.add') });
|
||||
addTop.addClass('mod-cta');
|
||||
addTop.addEventListener('click', () => this.openAddType());
|
||||
|
||||
this.typesContainer = contentEl.createDiv();
|
||||
this.typesContainer.addClass('workout-muscles-list');
|
||||
|
||||
this.renderTypes();
|
||||
|
||||
// 底部按钮行:新增类型 / 关闭弹窗。
|
||||
const btnRow = contentEl.createDiv();
|
||||
btnRow.addClass('workout-btn-row');
|
||||
|
||||
const closeBtn = btnRow.createEl('button', { text: t('common.close') });
|
||||
closeBtn.addClass('mod-muted');
|
||||
closeBtn.addEventListener('click', () => this.close());
|
||||
}
|
||||
|
||||
// 渲染类型列表:无数据显示空提示,否则逐条画出名称、字段、覆盖开关与编辑/删除按钮。
|
||||
private renderTypes(): void {
|
||||
this.typesContainer.empty();
|
||||
|
||||
if (this.types.length === 0) {
|
||||
this.typesContainer.createEl('p', { text: t('settings.noTypes') });
|
||||
return;
|
||||
}
|
||||
|
||||
// 按搜索词过滤(名称或 ID);空列表与无匹配分别给出提示。
|
||||
const q = this.searchQuery;
|
||||
const visible = this.types.filter((type) => {
|
||||
if (!q) return true;
|
||||
const name = (getTrainingTypeName(type) || type.id).toLowerCase();
|
||||
return name.includes(q) || type.id.toLowerCase().includes(q);
|
||||
});
|
||||
|
||||
if (visible.length === 0) {
|
||||
this.typesContainer.createEl('p', { text: t('common.noMatch') });
|
||||
return;
|
||||
}
|
||||
|
||||
for (const type of visible) {
|
||||
// 显示名优先用多语言名称,回退到 id。
|
||||
const typeName = getTrainingTypeName(type) || type.id;
|
||||
// 把字段列表拼成逗号分隔的文字;没有字段时显示"无字段"。
|
||||
const fieldNames =
|
||||
type.fields.length > 0 ? type.fields.map((field) => getFieldLabel(field)).join(', ') : t('settings.noFields');
|
||||
|
||||
const row = this.typesContainer.createDiv();
|
||||
row.addClass('workout-card');
|
||||
|
||||
const infoCol = row.createDiv();
|
||||
infoCol.addClass('workout-card-info');
|
||||
infoCol.createEl('div', { text: typeName, cls: 'workout-card-title' });
|
||||
|
||||
// 每条类型的明细行:字段列表 + 是否计入覆盖(开/关)。
|
||||
const detailLines = [
|
||||
`${t('modal.typeManager.fields')}: ${fieldNames}`,
|
||||
`${t('modal.typeManager.coverage')}: ${type.contributesToCoverage ? t('settings.on') : t('settings.off')}`,
|
||||
];
|
||||
|
||||
for (const detail of detailLines) {
|
||||
infoCol.createEl('div', { text: detail, cls: 'workout-card-meta' });
|
||||
}
|
||||
|
||||
// 右侧按钮列:编辑 / 删除。
|
||||
const btnCol = row.createDiv();
|
||||
btnCol.addClass('workout-card-actions');
|
||||
|
||||
const editBtn = btnCol.createEl('button', { text: t('modal.typeManager.edit') });
|
||||
editBtn.addClass('workout-action-btn');
|
||||
editBtn.addEventListener('click', () => {
|
||||
// 传入 editType 表示编辑这个已有类型,关闭后刷新列表。
|
||||
const editModal = new TypeModal(this.dataManager, { editType: type });
|
||||
editModal.onClose = () => this.refresh();
|
||||
editModal.open();
|
||||
});
|
||||
|
||||
const deleteBtn = btnCol.createEl('button', { text: t('modal.typeManager.delete') });
|
||||
deleteBtn.addClass('workout-danger-btn');
|
||||
deleteBtn.addEventListener('click', () => this.deleteType(type));
|
||||
}
|
||||
}
|
||||
|
||||
// 打开"新增训练类型"弹窗,关闭后刷新列表。
|
||||
private openAddType(): void {
|
||||
const editModal = new TypeModal(this.dataManager);
|
||||
editModal.onClose = () => this.refresh();
|
||||
editModal.open();
|
||||
}
|
||||
|
||||
// 删除某个训练类型:先检查是否被训练项引用,被引用则二次确认,再执行删除。
|
||||
private async deleteType(type: TrainingType): Promise<void> {
|
||||
const typeName = getTrainingTypeName(type) || type.id;
|
||||
|
||||
const config = await this.dataManager.getConfig();
|
||||
// 找出 category 指向该类型的训练项(即"被引用"的检查)。
|
||||
const referencedExercises = config.exercises.filter((exercise) => exercise.category === type.id);
|
||||
|
||||
if (referencedExercises.length > 0) {
|
||||
// 被引用:列出引用它的训练项,确认后才允许删(避免训练项失联)。
|
||||
const names = referencedExercises.map((exercise) => getExerciseName(exercise) || exercise.id).join(', ');
|
||||
if (!(await confirmWithModal(this.app, `${t('settings.typeReferencedBy')}: ${names}\n${t('settings.confirmDelete')}?`))) {
|
||||
return;
|
||||
}
|
||||
} else if (!(await confirmWithModal(this.app, `${t('settings.confirmDeleteType')}: ${typeName}?`))) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.dataManager.deleteTrainingType(type.id);
|
||||
new Notice(`${typeName} ${t('common.delete')}`);
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
// 重新读取配置并刷新列表(编辑/删除后调用)。注意 onClose 钩子也会触发它。
|
||||
private async refresh(): Promise<void> {
|
||||
const config = await this.dataManager.getConfig();
|
||||
this.types = config.trainingTypes;
|
||||
this.renderTypes();
|
||||
}
|
||||
|
||||
// 弹窗关闭时清空内容(Obsidian 规范要求释放 DOM)。
|
||||
onClose(): void {
|
||||
this.contentEl.empty();
|
||||
}
|
||||
}
|
||||
389
src/ui/TypeModal.ts
Normal file
389
src/ui/TypeModal.ts
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
import { Modal, Notice } from 'obsidian';
|
||||
import { DataManager } from '../data/DataManager';
|
||||
import { getTrainingTypeName } from '../data/display';
|
||||
import { TrainingType, FieldDef } from '../data/types';
|
||||
import { t } from '../i18n';
|
||||
import { INVALID_ID_RE, isInvalidId } from './idValidation';
|
||||
|
||||
/*
|
||||
* TypeModal.ts —— "新建/编辑训练类型"弹窗
|
||||
* 训练类型定义了"记录一组时会出现哪些字段"。本弹窗让用户填类型名,
|
||||
* 并提供"动态增删字段"的能力:每个字段有 字段键(key)/标签(label)/
|
||||
* 输入类型(inputType)/单位(unit)/选项(options) 五项,可任意增删。
|
||||
* 编辑模式下会从已有类型拷贝一份字段,避免直接改动原始配置;
|
||||
* 删除字段按钮会同时从内部数组移除并整体重渲染,保证数组与界面一致。
|
||||
*/
|
||||
interface TypeModalOptions {
|
||||
editType?: TrainingType; // 要编辑的训练类型;省略则为新建模式
|
||||
}
|
||||
|
||||
export class TypeModal extends Modal {
|
||||
private dataManager: DataManager;
|
||||
private options: TypeModalOptions;
|
||||
private nameInput!: HTMLInputElement;
|
||||
private idInput!: HTMLInputElement; // 可编辑的类别 ID(留空则保存时按类型名称推导)
|
||||
private idHint!: HTMLDivElement; // ID 非法字符提示(默认隐藏,触发时闪现)
|
||||
private idHintTimer: number | null = null; // 提示自动隐藏的计时器
|
||||
private fieldsContainer!: HTMLDivElement;
|
||||
private fields: FieldDef[] = []; // 当前正在编辑的字段数组
|
||||
|
||||
constructor(dataManager: DataManager, options: TypeModalOptions = {}) {
|
||||
super(dataManager.app);
|
||||
this.dataManager = dataManager;
|
||||
this.options = options;
|
||||
// 编辑模式:拷贝一份已有字段的浅副本,后续改动只作用于副本,保存时才写回
|
||||
if (options.editType?.fields && Array.isArray(options.editType.fields)) {
|
||||
this.fields = options.editType.fields.map(f => ({ ...f }));
|
||||
}
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
const { contentEl } = this;
|
||||
contentEl.addClass('workout-edit-modal');
|
||||
|
||||
// 标题:编辑模式用"编辑训练类型"文案
|
||||
contentEl.createEl('h2', { text: this.options.editType ? t('modal.newType.editTitle') : t('modal.newType.title') });
|
||||
|
||||
// 两列网格:类型名称 + 类别 ID(对齐重设计稿的两列布局)
|
||||
const nameIdCol = contentEl.createDiv();
|
||||
nameIdCol.addClass('workout-two-col');
|
||||
|
||||
// 类型名行:编辑时预填已有名称
|
||||
const nameRow = nameIdCol.createDiv();
|
||||
nameRow.addClass('workout-field');
|
||||
nameRow.createEl('label', { text: t('modal.newType.name') });
|
||||
this.nameInput = nameRow.createEl('input', { type: 'text' });
|
||||
this.nameInput.addClass('workout-input');
|
||||
if (this.options.editType) {
|
||||
this.nameInput.value = getTrainingTypeName(this.options.editType);
|
||||
}
|
||||
|
||||
// 类别 ID 行:可编辑文本框。留空时保存会自动按类型名称推导;
|
||||
// 编辑模式下预填当前 id,用户改了它则会级联更新所有关联(记录/训练项/统计)。
|
||||
const idRow = nameIdCol.createDiv();
|
||||
idRow.addClass('workout-field');
|
||||
idRow.createEl('label', { text: t('modal.newType.id') });
|
||||
this.idInput = idRow.createEl('input', { type: 'text', placeholder: t('modal.newType.idPlaceholder') });
|
||||
this.idInput.addClass('workout-input');
|
||||
if (this.options.editType) {
|
||||
this.idInput.value = this.options.editType.id;
|
||||
}
|
||||
|
||||
// 轻量实时校验:ID 不能含逗号/引号/换行(会破坏 CSV 单元格与键引用)。
|
||||
// beforeinput 阶段直接拦截非法字符(光标不跳),input 阶段再兜底 strip(覆盖粘贴)。
|
||||
this.idHint = contentEl.createEl('div', { cls: 'workout-id-hint' });
|
||||
this.idInput.addEventListener('beforeinput', (e: Event) => {
|
||||
const data = (e as InputEvent).data ?? '';
|
||||
if (data && INVALID_ID_RE.test(data)) {
|
||||
e.preventDefault();
|
||||
this.flashIdHint('modal.newType.idInvalid');
|
||||
}
|
||||
});
|
||||
this.idInput.addEventListener('input', () => {
|
||||
const v = this.idInput.value;
|
||||
if (INVALID_ID_RE.test(v)) {
|
||||
this.idInput.value = v.replace(INVALID_ID_RE, '');
|
||||
this.flashIdHint('modal.newType.idInvalid');
|
||||
}
|
||||
});
|
||||
|
||||
// 字段列表标题
|
||||
contentEl.createEl('h3', { text: t('modal.newType.fields'), cls: 'workout-section-title' });
|
||||
|
||||
// 字段容器:所有字段行渲染在这里
|
||||
this.fieldsContainer = contentEl.createDiv();
|
||||
this.fieldsContainer.addClass('workout-fields-list');
|
||||
|
||||
// 初始渲染已有的字段(新建模式可能为空)
|
||||
this.renderAllFields();
|
||||
|
||||
// "添加字段"按钮:往数组 push 一个默认字段,再整体重渲染
|
||||
const addFieldBtn = contentEl.createEl('button', { text: t('modal.newType.addField') });
|
||||
addFieldBtn.addClass('mod-cta');
|
||||
addFieldBtn.addEventListener('click', () => {
|
||||
this.fields.push({
|
||||
key: '',
|
||||
label: '',
|
||||
inputType: 'number',
|
||||
});
|
||||
this.renderAllFields();
|
||||
});
|
||||
|
||||
// 底部按钮行:取消 + 保存
|
||||
const btnRow = contentEl.createDiv();
|
||||
btnRow.addClass('workout-btn-row');
|
||||
|
||||
const cancelBtn = btnRow.createEl('button', { text: t('common.cancel') });
|
||||
cancelBtn.addClass('mod-muted');
|
||||
cancelBtn.addEventListener('click', () => this.close());
|
||||
|
||||
const saveBtn = btnRow.createEl('button', { text: t('common.save') });
|
||||
saveBtn.addClass('mod-cta');
|
||||
saveBtn.addEventListener('click', () => this.save());
|
||||
}
|
||||
|
||||
// 渲染所有字段行:没有字段时显示提示文案;否则逐个用 buildFieldRow 生成并挂载。
|
||||
private renderAllFields(): void {
|
||||
this.fieldsContainer.empty();
|
||||
|
||||
if (this.fields.length === 0) {
|
||||
this.fieldsContainer.createEl('p', { text: t('settings.noFields') });
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.fields.length; i++) {
|
||||
this.fieldsContainer.appendChild(this.buildFieldRow(i));
|
||||
}
|
||||
}
|
||||
|
||||
// 构建单条字段行(index 对应 fields 数组下标)。返回挂载好的 DOM 元素,由 renderAllFields 统一挂载,
|
||||
// 切换输入类型时也能用新元素 replaceWith 整行重渲染,保证单位/选项列随类型正确显隐。
|
||||
private buildFieldRow(index: number): HTMLDivElement {
|
||||
const field = this.fields[index];
|
||||
const card = document.createElement('div') as HTMLDivElement;
|
||||
card.addClass('workout-field-card');
|
||||
|
||||
// 头部:序号徽标 + 删除按钮(上方保持简洁,字段详情在下方输入框中)
|
||||
const header = card.createDiv();
|
||||
header.addClass('workout-field-card-header');
|
||||
const titleWrap = header.createDiv();
|
||||
titleWrap.addClass('workout-field-card-title');
|
||||
const badge = titleWrap.createEl('span');
|
||||
badge.addClass('workout-field-card-badge');
|
||||
badge.setText(String(index + 1));
|
||||
const removeBtn = header.createEl('button', { text: t('modal.newType.removeField') });
|
||||
removeBtn.addClass('workout-danger-btn');
|
||||
removeBtn.addEventListener('click', () => {
|
||||
this.fields.splice(index, 1); // 从内部数组移除该字段
|
||||
this.renderAllFields(); // 重新渲染整列,保证下标与界面同步
|
||||
});
|
||||
|
||||
// 主体:两列网格(字段键 / 标签 / 输入类型 / 单位)
|
||||
const body = card.createDiv();
|
||||
body.addClass('workout-field-card-body');
|
||||
|
||||
// 第 1 列:字段键(key)——代码中引用的标识符
|
||||
const col1 = body.createDiv();
|
||||
col1.addClass('workout-field');
|
||||
col1.createEl('label', { text: t('modal.newType.fieldKey') });
|
||||
const keyInput = col1.createEl('input', { type: 'text', placeholder: 'field_key' });
|
||||
keyInput.addClass('workout-input');
|
||||
keyInput.value = field.key;
|
||||
keyInput.addEventListener('change', () => {
|
||||
this.fields[index].key = keyInput.value.trim();
|
||||
});
|
||||
|
||||
// 第 2 列:标签(label)——界面上显示给用户看的名称
|
||||
const col2 = body.createDiv();
|
||||
col2.addClass('workout-field');
|
||||
col2.createEl('label', { text: t('modal.newType.fieldLabel') });
|
||||
const labelInput = col2.createEl('input', { type: 'text', placeholder: t('modal.newType.fieldLabel') });
|
||||
labelInput.addClass('workout-input');
|
||||
labelInput.value = field.label || '';
|
||||
labelInput.addEventListener('change', () => {
|
||||
this.fields[index].label = labelInput.value;
|
||||
});
|
||||
|
||||
// 第 3 列:输入类型(inputType)——数字/时长/文本/下拉选择(随语言切换显示)
|
||||
const col3 = body.createDiv();
|
||||
col3.addClass('workout-field');
|
||||
col3.createEl('label', { text: t('modal.newType.inputType') });
|
||||
const inputTypeSelect = col3.createEl('select');
|
||||
inputTypeSelect.addClass('workout-select');
|
||||
const inputTypes: FieldDef['inputType'][] = ['number', 'duration', 'text', 'select'];
|
||||
for (const type of inputTypes) {
|
||||
const option = inputTypeSelect.createEl('option', {
|
||||
value: type,
|
||||
text: t(`fieldInputType.${type}`),
|
||||
});
|
||||
if (field.inputType === type) {
|
||||
option.selected = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 第 4 列:单位——仅"数字"类型出现。三选一下拉(随语言切换):
|
||||
// 无(默认,不显示单位)/ 质量(自动参与 kg/lb 换算)/ 自定义(自由填写单位文字)。
|
||||
if (field.inputType === 'number') {
|
||||
const col4 = body.createDiv();
|
||||
col4.addClass('workout-field');
|
||||
col4.createEl('label', { text: t('modal.newType.unit') });
|
||||
const unitSelect = col4.createEl('select');
|
||||
unitSelect.addClass('workout-select');
|
||||
const unitOptions: { value: string; key: string }[] = [
|
||||
{ value: '', key: 'fieldUnit.none' },
|
||||
{ value: 'mass', key: 'fieldUnit.mass' },
|
||||
{ value: 'custom', key: 'fieldUnit.custom' },
|
||||
];
|
||||
// 当前选中的档位:mass 优先、其次自定义(有 unitLabel)、否则无
|
||||
let currentUnit: 'none' | 'mass' | 'custom' = 'none';
|
||||
if (field.mass) currentUnit = 'mass';
|
||||
else if (field.unitLabel) currentUnit = 'custom';
|
||||
for (const opt of unitOptions) {
|
||||
const option = unitSelect.createEl('option', {
|
||||
value: opt.value,
|
||||
text: t(opt.key),
|
||||
});
|
||||
if (currentUnit === (opt.value === '' ? 'none' : opt.value)) {
|
||||
option.selected = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 选「自定义」时才出现的单位文字输入框(如"层""圈""公里"),放在卡片底部虚线区
|
||||
const cond = card.createDiv();
|
||||
cond.addClass('workout-field-card-cond');
|
||||
const customWrap = cond.createDiv();
|
||||
customWrap.addClass('workout-field', 'workout-custom-unit');
|
||||
customWrap.createEl('label', { text: t('modal.newType.customUnit') });
|
||||
const customUnitInput = customWrap.createEl('input', {
|
||||
type: 'text',
|
||||
placeholder: t('modal.newType.customUnit'),
|
||||
});
|
||||
customUnitInput.addClass('workout-input');
|
||||
customUnitInput.value = field.unitLabel || '';
|
||||
if (currentUnit === 'custom') {
|
||||
customWrap.addClass('is-custom');
|
||||
}
|
||||
customUnitInput.addEventListener('change', () => {
|
||||
this.fields[index].unitLabel = customUnitInput.value.trim() || undefined;
|
||||
});
|
||||
|
||||
unitSelect.addEventListener('change', () => {
|
||||
const v = unitSelect.value;
|
||||
if (v === 'mass') {
|
||||
// 质量:自动换算,清空自定义文字并隐藏单位输入框
|
||||
this.fields[index].mass = true;
|
||||
this.fields[index].unitLabel = undefined;
|
||||
customWrap.removeClass('is-custom');
|
||||
customUnitInput.value = '';
|
||||
} else if (v === 'custom') {
|
||||
// 自定义:关闭质量换算,显示单位文字输入框
|
||||
this.fields[index].mass = false;
|
||||
customWrap.addClass('is-custom');
|
||||
} else {
|
||||
// 无:都不设
|
||||
this.fields[index].mass = false;
|
||||
this.fields[index].unitLabel = undefined;
|
||||
customWrap.removeClass('is-custom');
|
||||
customUnitInput.value = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 选项(options)——仅"下拉选择"类型出现,逗号分隔;解析回字符串数组
|
||||
if (field.inputType === 'select') {
|
||||
const cond = card.createDiv();
|
||||
cond.addClass('workout-field-card-cond');
|
||||
const optWrap = cond.createDiv();
|
||||
optWrap.addClass('workout-field');
|
||||
optWrap.createEl('label', { text: t('modal.newType.options') });
|
||||
const optionsInput = optWrap.createEl('input', { type: 'text', placeholder: t('modal.newType.optionsPlaceholder') });
|
||||
optionsInput.addClass('workout-input');
|
||||
optionsInput.value = field.options ? field.options.join(', ') : '';
|
||||
optionsInput.addEventListener('change', () => {
|
||||
// 把逗号分隔文本拆成去空白、去空的字符串数组;为空则置 undefined
|
||||
const vals = optionsInput.value
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
this.fields[index].options = vals.length > 0 ? vals : undefined;
|
||||
});
|
||||
}
|
||||
|
||||
// 输入类型切换:先写回,再整卡重渲染,让单位/选项区按新类型正确显隐
|
||||
inputTypeSelect.addEventListener('change', () => {
|
||||
this.fields[index].inputType = inputTypeSelect.value as FieldDef['inputType'];
|
||||
card.replaceWith(this.buildFieldRow(index));
|
||||
});
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
// 保存:校验类型名、至少 1 个有效字段(有 key 且 label 或 labelKey),再新增或更新训练类型。
|
||||
// 类别 ID:输入框留空时按「类型名称」自动推导;非空时以用户输入为准(空格转下划线)。
|
||||
// 编辑模式下若改了 ID,则级联更新数据库里所有关联该 ID 的记录 / 训练项 / 统计。
|
||||
private async save(): Promise<void> {
|
||||
const name = this.nameInput.value.trim();
|
||||
if (!name) {
|
||||
new Notice(t('modal.newType.nameRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算最终 ID:用户输入优先(空格→下划线,保留大小写);留空则按类型名称推导。
|
||||
// 两路都额外 strip 掉非法字符(逗号/引号/换行),即使从名称推导也保证 CSV 安全。
|
||||
const idValue = this.idInput.value.trim();
|
||||
const finalId = (idValue || name.toLowerCase())
|
||||
.replace(/\s+/g, '_')
|
||||
.replace(INVALID_ID_RE, '');
|
||||
|
||||
// 兜底:万一仍含非法字符(理论上已被实时校验拦截),直接拦下保存。
|
||||
if (isInvalidId(finalId)) {
|
||||
new Notice(t('modal.newType.idInvalid'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 防重复:新建时与全部已有 id 比对;编辑时排除自身旧 id,仅当改成了别人的 id 才拦截。
|
||||
const config = await this.dataManager.getConfig();
|
||||
const exists = config.trainingTypes.some(
|
||||
(t) => t.id === finalId && t.id !== this.options.editType?.id
|
||||
);
|
||||
if (exists) {
|
||||
new Notice(t('modal.newType.idDuplicate'));
|
||||
return;
|
||||
}
|
||||
|
||||
const validFields = this.fields.filter((f) => f.key && (f.label || f.labelKey));
|
||||
if (validFields.length === 0) {
|
||||
new Notice(t('settings.atLeastOneField'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const updates: Partial<TrainingType> = {
|
||||
name,
|
||||
fields: validFields,
|
||||
};
|
||||
if (this.options.editType) {
|
||||
const oldId = this.options.editType.id;
|
||||
if (finalId !== oldId) {
|
||||
// ID 变了:级联改写配置里的 id + 所有关联的记录/训练项/统计
|
||||
await this.dataManager.renameTrainingType(oldId, finalId, updates);
|
||||
} else {
|
||||
// ID 没变:普通更新
|
||||
await this.dataManager.updateTrainingType(oldId, updates);
|
||||
}
|
||||
new Notice(t('modal.newType.saved'));
|
||||
} else {
|
||||
// 新建模式:写入自定义 ID(用户输入或按类型名称推导),参与覆盖统计
|
||||
await this.dataManager.addTrainingType({
|
||||
id: finalId,
|
||||
nameKey: undefined,
|
||||
name,
|
||||
fields: validFields,
|
||||
contributesToCoverage: true,
|
||||
});
|
||||
new Notice(t('modal.newType.saved'));
|
||||
}
|
||||
this.close();
|
||||
} catch (error) {
|
||||
new Notice(t('modal.newType.saveFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
}
|
||||
|
||||
// 闪现一条 ID 非法字符提示:显示文案,2.5s 后自动淡出(轻量、不打断输入)。
|
||||
private flashIdHint(msgKey: string): void {
|
||||
this.idHint.textContent = t(msgKey);
|
||||
this.idHint.addClass('is-visible');
|
||||
if (this.idHintTimer !== null) {
|
||||
window.clearTimeout(this.idHintTimer);
|
||||
}
|
||||
this.idHintTimer = window.setTimeout(() => {
|
||||
this.idHint.removeClass('is-visible');
|
||||
this.idHintTimer = null;
|
||||
}, 2500);
|
||||
}
|
||||
}
|
||||
37
src/ui/VaultFolderSuggestModal.ts
Normal file
37
src/ui/VaultFolderSuggestModal.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* VaultFolderSuggestModal.ts
|
||||
* 文件作用:实现一个"模糊搜索选择文件夹"的弹窗,让用户从 Obsidian 仓库(vault)里挑选一个目录。
|
||||
* 架构角色:继承 Obsidian 内置的 FuzzySuggestModal(模糊搜索弹窗),用于设置数据文件存放位置。
|
||||
* 当用户在设置页点"Browse"时打开这个弹窗,选中文件夹后把路径回传给调用方。
|
||||
*/
|
||||
|
||||
import { App, FuzzySuggestModal, TFolder } from 'obsidian';
|
||||
|
||||
// 继承 FuzzySuggestModal<TFolder>:泛型 TFolder 表示弹窗里每条可选项都是一个文件夹对象
|
||||
export class VaultFolderSuggestModal extends FuzzySuggestModal<TFolder> {
|
||||
// 用户选中某个文件夹后,通过这个回调把路径(字符串)交还给外部
|
||||
private onSelectCallback: (value: string) => void;
|
||||
|
||||
constructor(app: App, onSelect: (value: string) => void) {
|
||||
super(app);
|
||||
this.onSelectCallback = onSelect;
|
||||
// 输入框里的占位提示文字
|
||||
this.setPlaceholder('Select a folder');
|
||||
}
|
||||
|
||||
// 返回弹窗里要展示的全部可选项:取出 vault 里所有"已加载的文件",过滤出其中的文件夹(TFolder)
|
||||
getItems(): TFolder[] {
|
||||
return this.app.vault.getAllLoadedFiles().filter((file) => file instanceof TFolder) as TFolder[];
|
||||
}
|
||||
|
||||
// 决定每个文件夹在列表里显示的文本(这里用它的路径,空路径显示根目录 '/')
|
||||
getItemText(item: TFolder): string {
|
||||
return item.path || '/';
|
||||
}
|
||||
|
||||
// 用户用键盘/鼠标选中某一项时触发:拿到它的路径并回调给外部
|
||||
onChooseItem(item: TFolder): void {
|
||||
const value = item.path || '';
|
||||
this.onSelectCallback(value);
|
||||
}
|
||||
}
|
||||
72
src/ui/VaultPathSuggest.ts
Normal file
72
src/ui/VaultPathSuggest.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
/*
|
||||
* VaultPathSuggest.ts
|
||||
* 文件作用:在设置页的"数据目录"文本框里输入路径时,实时下拉提示匹配的文件夹(输入建议)。
|
||||
* 架构角色:继承 Obsidian 的 AbstractInputSuggest,给一个 HTML 输入框绑定自动补全能力。
|
||||
* 与 VaultFolderSuggestModal 的区别:这里是在文本框里"边打字边提示",而不是弹出整屏选择窗。
|
||||
*/
|
||||
|
||||
import { App, AbstractInputSuggest, TFolder } from 'obsidian';
|
||||
|
||||
// 继承 AbstractInputSuggest<string>:泛型 string 表示建议项是路径字符串
|
||||
export class VaultPathSuggest extends AbstractInputSuggest<string> {
|
||||
// 用户从下拉里选中某条建议后,通过这个回调把路径交还外部
|
||||
private onSelectCallback: (value: string) => void;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
inputEl: HTMLInputElement,
|
||||
onSelect: (value: string) => void
|
||||
) {
|
||||
super(app, inputEl);
|
||||
this.onSelectCallback = onSelect;
|
||||
}
|
||||
|
||||
// 核心逻辑:根据当前输入框内容 query,计算要展示的文件夹路径建议列表
|
||||
getSuggestions(query: string): string[] {
|
||||
const suggestions: string[] = [];
|
||||
// 取出 vault 里所有文件夹(真实 Obsidian API 用 getAllLoadedFiles + TFolder 过滤)
|
||||
const folders = this.app.vault.getAllLoadedFiles().filter((file) => file instanceof TFolder);
|
||||
|
||||
// Obsidian 路径用 '/' 分段(如 logs/config/2024)。把输入按 '/' 切分:
|
||||
// currentPrefix 是最后一个 '/' 之前的部分(已写好、不可变的父路径)
|
||||
const parts = query.split('/');
|
||||
const currentPrefix = parts.slice(0, -1).join('/');
|
||||
// searchTerm 是当前正在输入的"最后一段",转小写以便忽略大小写匹配
|
||||
const searchTerm = parts[parts.length - 1].toLowerCase();
|
||||
|
||||
for (const folder of folders) {
|
||||
const folderPath = folder.path;
|
||||
|
||||
if (currentPrefix) {
|
||||
// 已有父路径前缀:只在"该前缀直接下属"的文件夹里找当前段匹配的
|
||||
if (folderPath.startsWith(currentPrefix + '/')) {
|
||||
// 去掉父前缀,得到相对路径再判断开头是否匹配 searchTerm
|
||||
const relativePath = folderPath.substring(currentPrefix.length + 1);
|
||||
if (relativePath.toLowerCase().startsWith(searchTerm)) {
|
||||
suggestions.push(currentPrefix + '/' + relativePath);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 没有父路径前缀(用户还在输入第一段):直接按整个路径前缀匹配
|
||||
if (folderPath.toLowerCase().startsWith(searchTerm)) {
|
||||
suggestions.push(folderPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 最多只展示前 10 条建议,避免列表过长
|
||||
return suggestions.slice(0, 10);
|
||||
}
|
||||
|
||||
// 在一条下拉建议里显示的文字(空路径显示根目录 '/')
|
||||
renderSuggestion(value: string, el: HTMLElement): void {
|
||||
el.setText(value || '/');
|
||||
}
|
||||
|
||||
// 用户从下拉里选中某条建议:把文本框内容设为该路径、回调外部、并关闭下拉
|
||||
selectSuggestion(value: string): void {
|
||||
this.inputEl.value = value;
|
||||
this.onSelectCallback(value);
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
22
src/ui/idValidation.test.ts
Normal file
22
src/ui/idValidation.test.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { INVALID_ID_RE, isInvalidId } from './idValidation';
|
||||
|
||||
describe('idValidation', () => {
|
||||
it('合法 ID 不报错(含中文、下划线、大小写)', () => {
|
||||
expect(isInvalidId('squat')).toBe(false);
|
||||
expect(isInvalidId('罗马尼亚_硬拉')).toBe(false);
|
||||
expect(isInvalidId('Leg_Press')).toBe(false);
|
||||
expect(INVALID_ID_RE.test('ok_id')).toBe(false);
|
||||
});
|
||||
|
||||
it('含逗号 / 双引号 / 换行 判定为非法', () => {
|
||||
expect(isInvalidId('a,b')).toBe(true);
|
||||
expect(isInvalidId('a"b')).toBe(true);
|
||||
expect(isInvalidId('a\nb')).toBe(true);
|
||||
expect(isInvalidId('a\rb')).toBe(true);
|
||||
});
|
||||
|
||||
it('空格不算非法(保存时会归一为下划线)', () => {
|
||||
expect(isInvalidId('leg press')).toBe(false);
|
||||
});
|
||||
});
|
||||
16
src/ui/idValidation.ts
Normal file
16
src/ui/idValidation.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// 训练项 ID / 类别 ID 的轻量实时校验工具。
|
||||
//
|
||||
// 一个合法的 ID 会被当成 CSV 单元格写入记录表,同时作为 exerciseId / category /
|
||||
// associatedTypes 的稳定键被多处引用。因此它不能包含 CSV 分隔敏感字符:
|
||||
// - 逗号 "," → 会和 CSV 列分隔符冲突,解析时整行错位
|
||||
// - 双引号 "\"" → CSV 转义字符,会破坏单元格边界
|
||||
// - 换行 "\n\r" → 会让一条记录断成多行
|
||||
// 空格允许(保存时会归一为下划线,见 ExerciseModal/TypeModal 的 save())。
|
||||
|
||||
// 匹配所有「非法 ID 字符」的正则(逗号 / 双引号 / 换行 / 回车)。
|
||||
export const INVALID_ID_RE = /[,"\n\r]/;
|
||||
|
||||
// 判断一个字符串是否含有非法 ID 字符。返回 true 表示不合法。
|
||||
export function isInvalidId(value: string): boolean {
|
||||
return INVALID_ID_RE.test(value);
|
||||
}
|
||||
353
src/ui/management-ui.test.ts
Normal file
353
src/ui/management-ui.test.ts
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
/*
|
||||
* management-ui.test.ts(Vitest 回归测试)
|
||||
* 文件作用:用 vitest 验证各个弹窗/设置页渲染是否正确(如英文环境下训练项显示 Running、删除行不串数据等)。
|
||||
* 架构角色:这是一组"回归测试"——一旦有人改坏界面渲染,测试就会失败、及时报警。
|
||||
* 关键概念:
|
||||
* - vitest:一个测试框架(类似 Jest),`it(...)` 定义一个用例,`expect(...)` 做断言,`describe` 把用例分组。
|
||||
* - jsdom:在 Node 里模拟浏览器 DOM(document/元素等),让我们能在没有真浏览器时操作页面元素。
|
||||
* - installDomHelpers:真实 Obsidian 给元素加了一些便捷方法(empty/addClass/setText/createEl…),jsdom 没有,
|
||||
* 这里手动把这些方法补到元素的原型上,测试代码才能正常调用。
|
||||
*/
|
||||
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { __resetNotices } from '../test/obsidian-shim';
|
||||
|
||||
// 给 jsdom 的 HTML 元素补充 Obsidian 风格的便捷方法(empty/addClass/setText/createEl 等)
|
||||
function installDomHelpers() {
|
||||
const elementProto = HTMLElement.prototype as HTMLElement & {
|
||||
empty?: () => void;
|
||||
addClass?: (...classes: string[]) => void;
|
||||
setText?: (text: string) => void;
|
||||
createEl?: (tag: string, options?: Record<string, unknown>) => HTMLElement;
|
||||
createDiv?: (options?: Record<string, unknown>) => HTMLDivElement;
|
||||
createButton?: (options?: Record<string, unknown>) => HTMLButtonElement;
|
||||
};
|
||||
|
||||
if (!elementProto.empty) {
|
||||
// empty:清空元素内部所有子内容(等价 innerHTML = '')
|
||||
elementProto.empty = function empty() {
|
||||
this.innerHTML = '';
|
||||
};
|
||||
}
|
||||
|
||||
if (!elementProto.addClass) {
|
||||
// addClass:批量添加 CSS 类
|
||||
elementProto.addClass = function addClass(...classes: string[]) {
|
||||
this.classList.add(...classes);
|
||||
};
|
||||
}
|
||||
|
||||
if (!elementProto.setText) {
|
||||
// setText:设置元素显示的纯文本
|
||||
elementProto.setText = function setText(text: string) {
|
||||
this.textContent = text;
|
||||
};
|
||||
}
|
||||
|
||||
// applyOptions:把 Obsidian 的"创建元素配置项"(text/cls/type/placeholder/value) 应用到新元素上
|
||||
const applyOptions = (el: HTMLElement, options?: Record<string, unknown>) => {
|
||||
if (!options) return el;
|
||||
|
||||
if (typeof options.text === 'string') {
|
||||
el.textContent = options.text;
|
||||
}
|
||||
if (typeof options.cls === 'string') {
|
||||
el.className = options.cls;
|
||||
}
|
||||
if (typeof options.type === 'string' && 'type' in el) {
|
||||
(el as HTMLInputElement).type = options.type;
|
||||
}
|
||||
if (typeof options.placeholder === 'string' && 'placeholder' in el) {
|
||||
(el as HTMLInputElement | HTMLTextAreaElement).placeholder = options.placeholder;
|
||||
}
|
||||
if (typeof options.value === 'string' && 'value' in el) {
|
||||
(el as HTMLInputElement | HTMLSelectElement).value = options.value;
|
||||
}
|
||||
|
||||
return el;
|
||||
};
|
||||
|
||||
if (!elementProto.createEl) {
|
||||
// createEl:创建一个指定标签的元素,套用配置,并挂到当前元素下
|
||||
elementProto.createEl = function createEl(this: HTMLElement, tag: string, options?: Record<string, unknown>) {
|
||||
const el = document.createElement(tag);
|
||||
applyOptions(el, options);
|
||||
this.appendChild(el);
|
||||
return el;
|
||||
} as any;
|
||||
}
|
||||
|
||||
if (!elementProto.createDiv) {
|
||||
// createDiv:快捷创建 <div>
|
||||
elementProto.createDiv = function createDiv(options?: Record<string, unknown>) {
|
||||
return this.createEl?.('div', options) as HTMLDivElement;
|
||||
};
|
||||
}
|
||||
|
||||
if (!elementProto.createButton) {
|
||||
// createButton:快捷创建 <button>
|
||||
elementProto.createButton = function createButton(options?: Record<string, unknown>) {
|
||||
return this.createEl?.('button', options) as HTMLButtonElement;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 等待所有 microtask / 宏任务刷新(用于 display() 内部 getConfig().then() 的异步渲染链)。
|
||||
function flush(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
// beforeAll:本组所有用例开始前只执行一次(这里用于安装 DOM 辅助方法)
|
||||
beforeAll(() => {
|
||||
installDomHelpers();
|
||||
});
|
||||
|
||||
// beforeEach:每个用例前都执行:清空页面、清空通知、清空 mock,保证用例互不干扰
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
__resetNotices();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('management UI regressions', async () => {
|
||||
// 动态导入被测模块(用 await import 以便在 describe 内按需加载)
|
||||
const { DEFAULT_SETTINGS } = await import('../data/types');
|
||||
const { getDefaultConfig } = await import('../data/seed');
|
||||
const { setLocale } = await import('../i18n');
|
||||
const { RecordModal } = await import('./RecordModal');
|
||||
const { ExerciseManagerModal } = await import('./ExerciseManagerModal');
|
||||
const { MuscleManagerModal } = await import('./MuscleManagerModal');
|
||||
const { SettingsTab } = await import('./SettingsTab');
|
||||
const { TypeModal } = await import('./TypeModal');
|
||||
const { TypeManagerModal } = await import('./TypeManagerModal');
|
||||
const { resolveLogExerciseName } = await import('../data/display');
|
||||
|
||||
// 构造一个假的 App 对象:只提供测试需要的 vault.getFolders / loadData / saveData
|
||||
function createApp() {
|
||||
return {
|
||||
vault: {
|
||||
getFolders: () => [{ path: 'logs' }, { path: 'config' }],
|
||||
},
|
||||
loadData: vi.fn(async () => null),
|
||||
saveData: vi.fn(async () => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
// 构造一个假的 DataManager(数据管理接口):用真实默认配置,所有方法都用 vi.fn 模拟(记录是否被调用、传了什么参数)
|
||||
function createDataManager() {
|
||||
const settings = { ...DEFAULT_SETTINGS };
|
||||
const config = structuredClone(getDefaultConfig());
|
||||
return {
|
||||
app: createApp(),
|
||||
getConfig: vi.fn(async () => config),
|
||||
getSettings: vi.fn(() => settings),
|
||||
saveSettings: vi.fn(async () => undefined),
|
||||
addExercise: vi.fn(async () => undefined),
|
||||
updateExercise: vi.fn(async () => undefined),
|
||||
deleteExercise: vi.fn(async () => undefined),
|
||||
addPlanLogs: vi.fn(async () => undefined),
|
||||
addMuscle: vi.fn(async () => undefined),
|
||||
updateMuscle: vi.fn(async () => undefined),
|
||||
deleteMuscle: vi.fn(async () => undefined),
|
||||
addTrainingType: vi.fn(async () => undefined),
|
||||
updateTrainingType: vi.fn(async () => undefined),
|
||||
deleteTrainingType: vi.fn(async () => undefined),
|
||||
getLastValues: vi.fn(() => null),
|
||||
};
|
||||
}
|
||||
|
||||
// 用例:切换到英文后,记录弹窗里的默认训练项名应显示为英文 Running
|
||||
it('localizes default exercise names in the record modal when locale switches to English', async () => {
|
||||
setLocale('en');
|
||||
const dataManager = createDataManager();
|
||||
const modal = new RecordModal(dataManager as any);
|
||||
|
||||
await modal.onOpen();
|
||||
|
||||
const optionTexts = Array.from(modal.contentEl.querySelectorAll('select option')).map((option) =>
|
||||
option.textContent?.trim()
|
||||
);
|
||||
|
||||
expect(optionTexts).toContain('Running');
|
||||
});
|
||||
|
||||
// 用例:训练项管理列表应直接渲染出完整明细(类型/主练肌群/次练肌群)以及 Edit/Delete 按钮
|
||||
it('renders full exercise details directly in the exercise manager list', async () => {
|
||||
setLocale('en');
|
||||
const dataManager = createDataManager();
|
||||
const modal = new ExerciseManagerModal(dataManager as any);
|
||||
|
||||
await modal.onOpen();
|
||||
|
||||
const text = modal.contentEl.textContent ?? '';
|
||||
const runningRow = Array.from(modal.contentEl.querySelectorAll('.workout-card')).find((row) =>
|
||||
row.textContent?.includes('Running')
|
||||
) as HTMLElement;
|
||||
const detailLines = Array.from(runningRow.querySelectorAll('.workout-card-meta')).map((el) =>
|
||||
el.textContent?.trim()
|
||||
);
|
||||
const actionButtons = Array.from(runningRow.querySelectorAll('.workout-action-btn, .workout-danger-btn')).map((button) =>
|
||||
button.textContent?.trim()
|
||||
);
|
||||
|
||||
expect(text).toContain('Running');
|
||||
expect(text).toContain('Primary: Quads');
|
||||
expect(text).toContain('Secondary: Hamstrings, Front Calves');
|
||||
expect(detailLines).toEqual([
|
||||
'Type: Aerobic',
|
||||
'Primary: Quads',
|
||||
'Secondary: Hamstrings, Front Calves',
|
||||
]);
|
||||
expect(actionButtons).toEqual(['Edit', 'Delete']);
|
||||
});
|
||||
|
||||
// 用例:肌肉管理列表应渲染出带标签的明细(名称/部位/是否计入覆盖/建议间隔/已映射路径)以及按钮
|
||||
it('renders labeled side, coverage, and frequency details in the muscle manager list', async () => {
|
||||
setLocale('en');
|
||||
const dataManager = createDataManager();
|
||||
// 列表渲染分支依赖 muscleMappingInitialized:真实首次打开时它由引导流程置 true。
|
||||
// 测试直接置 true,使 onOpen 走 renderMuscles(而非引导卡)。
|
||||
dataManager.getSettings().muscleMappingInitialized = true;
|
||||
const modal = new MuscleManagerModal(dataManager as any);
|
||||
|
||||
await modal.onOpen();
|
||||
|
||||
const text = modal.contentEl.textContent ?? '';
|
||||
|
||||
const config = await dataManager.getConfig();
|
||||
const chest = config.muscles.find((m) => m.id === 'chest')!;
|
||||
const mappedCount = chest.svgRegionIds?.length ?? 0;
|
||||
|
||||
expect(text).toContain('Name: chest');
|
||||
expect(text).toContain('Count toward coverage: On');
|
||||
expect(text).toContain('Rest threshold (days): 7 days');
|
||||
const firstRow = modal.contentEl.querySelector('.workout-card') as HTMLElement;
|
||||
const detailLines = Array.from(firstRow.querySelectorAll('.workout-card-meta')).map((el) =>
|
||||
el.textContent?.trim()
|
||||
);
|
||||
const actionButtons = Array.from(firstRow.querySelectorAll('.workout-action-btn, .workout-danger-btn')).map((button) =>
|
||||
button.textContent?.trim()
|
||||
);
|
||||
expect(detailLines).toEqual([
|
||||
'Name: chest',
|
||||
'Count toward coverage: On',
|
||||
'Rest threshold (days): 7 days',
|
||||
`Mapped paths: ${mappedCount}`,
|
||||
]);
|
||||
expect(actionButtons).toEqual(['Edit', 'Delete']);
|
||||
});
|
||||
|
||||
// 用例:设置页应同时提供两个"数据目录"的 Browse 浏览按钮
|
||||
it('provides browse buttons for both data directory settings', async () => {
|
||||
setLocale('en');
|
||||
const dataManager = createDataManager();
|
||||
const tab = new SettingsTab(dataManager.app as any, {} as any, dataManager as any);
|
||||
|
||||
tab.display();
|
||||
// display() 在 getConfig().then() 中异步渲染各 section,需冲刷宏任务确保渲染完成
|
||||
await flush();
|
||||
|
||||
const buttonTexts = Array.from(tab.containerEl.querySelectorAll('button')).map((button) =>
|
||||
button.textContent?.trim()
|
||||
);
|
||||
|
||||
expect(buttonTexts.filter((text) => text === 'Browse')).toHaveLength(2);
|
||||
});
|
||||
|
||||
// 用例:训练类型管理列表应把明细拆成单独行,并带 Edit/Delete 按钮
|
||||
it('renders training type rows with separate detail lines and action buttons', async () => {
|
||||
setLocale('en');
|
||||
const dataManager = createDataManager();
|
||||
const modal = new TypeManagerModal(dataManager as any);
|
||||
|
||||
await modal.onOpen();
|
||||
|
||||
const strengthRow = Array.from(modal.contentEl.querySelectorAll('.workout-card')).find((row) =>
|
||||
row.textContent?.includes('Strength')
|
||||
) as HTMLElement;
|
||||
const detailLines = Array.from(strengthRow.querySelectorAll('.workout-card-meta')).map((el) =>
|
||||
el.textContent?.trim()
|
||||
);
|
||||
const actionButtons = Array.from(strengthRow.querySelectorAll('.workout-action-btn, .workout-danger-btn')).map((button) =>
|
||||
button.textContent?.trim()
|
||||
);
|
||||
|
||||
expect(detailLines).toEqual(['Fields: Weight, Reps', 'Contributes to Coverage: On']);
|
||||
expect(actionButtons).toEqual(['Edit', 'Delete']);
|
||||
});
|
||||
|
||||
// 用例:在下拉里切换语言后,设置页与新打开的管理弹窗都应同步切换为对应语言
|
||||
it('switches the settings UI and newly opened manager modals when language changes from the dropdown', async () => {
|
||||
setLocale('zh');
|
||||
const dataManager = createDataManager();
|
||||
const tab = new SettingsTab(dataManager.app as any, {} as any, dataManager as any);
|
||||
|
||||
tab.display();
|
||||
await flush();
|
||||
|
||||
// 找到含 zh/en 两个选项的语言下拉框
|
||||
const languageSelect = Array.from(tab.containerEl.querySelectorAll('select')).find(
|
||||
(select) =>
|
||||
Array.from(select.options).some((option) => option.value === 'zh') &&
|
||||
Array.from(select.options).some((option) => option.value === 'en')
|
||||
) as HTMLSelectElement;
|
||||
|
||||
// 把它改成英文并触发 change 事件,再冲刷异步渲染
|
||||
languageSelect.value = 'en';
|
||||
languageSelect.dispatchEvent(new Event('change'));
|
||||
await flush();
|
||||
|
||||
expect(tab.containerEl.textContent).toContain('Training Plan Settings');
|
||||
|
||||
const modal = new ExerciseManagerModal(dataManager as any);
|
||||
await modal.onOpen();
|
||||
|
||||
expect(modal.contentEl.textContent).toContain('Exercise Manager');
|
||||
expect(modal.contentEl.textContent).toContain('Running');
|
||||
});
|
||||
|
||||
// 用例 M1:保存一个"字段只用 labelKey、没有纯文本 label"的内置训练类型(旧逻辑会误拦保存,这里验证能通过)
|
||||
it('M1: saves a built-in training type whose fields use labelKey only (no plain label)', async () => {
|
||||
setLocale('en');
|
||||
const dataManager = createDataManager();
|
||||
const config = await dataManager.getConfig();
|
||||
const strength = config.trainingTypes.find((t) => t.id === 'strength')!;
|
||||
// strength 的字段只有 labelKey(field.weight),没有 label —— 旧逻辑会因 validFields 为空而拦截保存
|
||||
const modal = new TypeModal(dataManager as any, { editType: strength });
|
||||
|
||||
modal.onOpen();
|
||||
|
||||
const nameInput = modal.contentEl.querySelector('input') as HTMLInputElement;
|
||||
nameInput.value = 'Strength';
|
||||
|
||||
const saveBtn = Array.from(modal.contentEl.querySelectorAll('button')).find(
|
||||
(b) => b.textContent?.trim() === 'Save'
|
||||
)!;
|
||||
saveBtn.click();
|
||||
|
||||
await flush();
|
||||
|
||||
expect(dataManager.updateTrainingType).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// 用例 H2(原 PlanRecordModal 多行动态增删测试)已随「新增训练计划」Modal 重构移除:
|
||||
// 旧「记录训练方案」弹窗(一次性写多条记录)已被「新增训练计划」(写 workout-config.json 的 plans)取代,
|
||||
// 交互模型完全不同,相关回归用例不再适用。
|
||||
|
||||
// 用例 F1:resolveLogExerciseName 优先用 exerciseId 解析名称,旧数据无 id 时回退到存储名
|
||||
it('F1: resolveLogExerciseName prefers exerciseId and falls back to stored name', async () => {
|
||||
setLocale('en');
|
||||
const config = getDefaultConfig();
|
||||
|
||||
// 新数据:用 exerciseId 解析,即使存储名过期也显示配置中的最新名
|
||||
expect(resolveLogExerciseName(config, { exerciseId: 'squat' } as any)).toBe('Squat');
|
||||
// 无 exerciseId(旧数据):已无法关联配置,返回空串
|
||||
expect(resolveLogExerciseName(config, { } as any)).toBe('');
|
||||
// 未知 id:返回空串
|
||||
expect(resolveLogExerciseName(config, { exerciseId: 'nope' } as any)).toBe('');
|
||||
});
|
||||
|
||||
// 用例 F1(原 PlanRecordModal 保存写多条记录)已随 Modal 重构移除,理由同上。
|
||||
});
|
||||
59
src/util/duration.ts
Normal file
59
src/util/duration.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { getLocale, t } from '../i18n';
|
||||
|
||||
/*
|
||||
* duration.ts —— 时长工具
|
||||
* 训练时长在「存储」时统一用「秒」这个最小单位(一个整数),
|
||||
* 好处是不会出现 1.5 分、30.5 分这种带小数的分,计算/排序都简单。
|
||||
* 本文件负责两件事:
|
||||
* 1) formatDuration:把「秒」拆成 时/分/秒 文字(如 75 → "1分15秒"),用于界面显示。
|
||||
* 2) parseDuration / secondsToParts:把 时/分/秒 反向合回「秒」,或拆成对象。
|
||||
* 单位文字(时/分/秒)取自 i18n,所以会随语言变化(英文显示 h/m/s)。
|
||||
*/
|
||||
|
||||
// 把秒数格式化成「时/分/秒」连写的文字,用于界面展示
|
||||
export function formatDuration(seconds: number): string {
|
||||
// 非正数直接返回 "0秒"(或对应语言的秒)
|
||||
if (seconds <= 0) {
|
||||
return '0' + t('duration.second');
|
||||
}
|
||||
|
||||
// 把总秒数拆成 时、分、秒 三个整数:1 小时=3600 秒,1 分=60 秒
|
||||
// 用取整除和取余得到各部分,例如 75 → 0时 1分 15秒
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
|
||||
// parts 收集要显示的各段文字,最后拼到一起
|
||||
const parts: string[] = [];
|
||||
const locale = getLocale();
|
||||
|
||||
// 只有该部分大于 0 才显示,避免出现 "0时0分15秒"
|
||||
if (hours > 0) {
|
||||
parts.push(`${hours}${t('duration.hour')}`);
|
||||
}
|
||||
if (minutes > 0) {
|
||||
parts.push(`${minutes}${t('duration.minute')}`);
|
||||
}
|
||||
// 秒为 0 且前面已经有内容时(如正好 1 分钟整)就不显示 "0秒";
|
||||
// 若前面什么都没有(parts 为空,说明不足 1 分),则至少显示秒,避免返回空串
|
||||
if (secs > 0 || parts.length === 0) {
|
||||
parts.push(`${secs}${t('duration.second')}`);
|
||||
}
|
||||
|
||||
// 把各段拼成最终文字(中文直接连写,因为文案里已带单位字)
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
// 反向操作:把 时/分/秒 三个数字合成「总秒数」,用于从表单输入转回存储格式
|
||||
export function parseDuration(hours: number, minutes: number, seconds: number): number {
|
||||
return hours * 3600 + minutes * 60 + seconds;
|
||||
}
|
||||
|
||||
// 把秒数拆成 {hours, minutes, seconds} 对象,方便表单回填(例如编辑时把存储的秒拆开填进输入框)
|
||||
export function secondsToParts(seconds: number): { hours: number; minutes: number; seconds: number } {
|
||||
return {
|
||||
hours: Math.floor(seconds / 3600),
|
||||
minutes: Math.floor((seconds % 3600) / 60),
|
||||
seconds: seconds % 60,
|
||||
};
|
||||
}
|
||||
21
src/util/id.ts
Normal file
21
src/util/id.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// 训练记录的主键生成器。
|
||||
//
|
||||
// 用 12 位 base36(0-9a-z)短随机 id 取代 crypto.randomUUID() 的 36 位 UUID。
|
||||
// 理由:记录 id 仅是单机本地 CSV 的内部主键,不被其他记录引用、不对外暴露,
|
||||
// 不需要 UUID「跨系统全局唯一」的强度;12 位 base36 有 36^12 ≈ 4.7e18 种组合,
|
||||
// 对单用户本地数据碰撞概率可忽略。
|
||||
//
|
||||
// 字符集仅含 0-9a-z,天然规避 CSV 敏感字符(逗号 / 双引号 / 换行),可直接安全写入单元格。
|
||||
// 仍使用 crypto.getRandomValues 保证密码学强度的随机性(与 randomUUID 同款 Crypto 全局对象)。
|
||||
|
||||
const BASE36 = '0123456789abcdefghijklmnopqrstuvwxyz';
|
||||
|
||||
export function generateId(length = 12): string {
|
||||
const bytes = new Uint8Array(length);
|
||||
crypto.getRandomValues(bytes);
|
||||
let id = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
id += BASE36[bytes[i] % 36];
|
||||
}
|
||||
return id;
|
||||
}
|
||||
36
src/util/units.ts
Normal file
36
src/util/units.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* units.ts —— 重量单位换算工具
|
||||
* 用户的体重/负重可能用 kg(公斤)或 lb(磅)显示。
|
||||
* 换算关系:1 kg ≈ 2.2 lb(这里用近似系数,足够日常训练记录使用)。
|
||||
* 设计原则:内部存储统一用 kg,只在「显示」和「解析用户输入」时按用户偏好换算。
|
||||
* 这样无论用户选 kg 还是 lb,底层数据始终是统一的 kg,互转不会丢失基准。
|
||||
*/
|
||||
|
||||
// kg → lb:乘 2.2 后保留 1 位小数(先 *10 取整再 /10,相当于四舍五入保留 1 位)
|
||||
export function kgToLb(kg: number): number {
|
||||
return Math.round(kg * 2.2 * 10) / 10;
|
||||
}
|
||||
|
||||
// lb → kg:除以 2.2 后四舍五入到整数
|
||||
export function lbToKg(lb: number): number {
|
||||
return Math.round(lb / 2.2);
|
||||
}
|
||||
|
||||
// 把内部 kg 数值格式化成界面显示文字
|
||||
// 若用户偏好 lb,则先换算成 lb 并保留 1 位小数;否则直接显示 kg 数值
|
||||
export function formatMass(value: number, unit: 'kg' | 'lb'): string {
|
||||
if (unit === 'lb') {
|
||||
return kgToLb(value).toFixed(1);
|
||||
}
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
// 解析用户输入的字符串(如输入框里填的 "150")成内部存储的 kg 数值
|
||||
// 若用户偏好 lb,则先把输入当 lb 转成 kg 存储;否则直接当 kg
|
||||
export function parseMass(value: string, unit: 'kg' | 'lb'): number {
|
||||
const numValue = parseFloat(value);
|
||||
if (unit === 'lb') {
|
||||
return lbToKg(numValue);
|
||||
}
|
||||
return numValue;
|
||||
}
|
||||
1267
styles.css
Normal file
1267
styles.css
Normal file
File diff suppressed because it is too large
Load diff
25
tsconfig.json
Normal file
25
tsconfig.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// tsconfig.json:TypeScript 编译配置(JSONC,允许本行这样的 // 注释)。
|
||||
// 作用:规定编译目标、模块格式、严格程度等。注意这里的 "noEmit": true——
|
||||
// 实际打包由 esbuild 完成,tsc 只用于类型检查,不产出文件。
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2018",
|
||||
"module": "commonjs",
|
||||
"lib": ["DOM", "ES2018"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"types": ["node", "vitest"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "main.js"]
|
||||
}
|
||||
22
vitest.config.ts
Normal file
22
vitest.config.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* vitest.config.ts(测试配置)
|
||||
* 文件作用:配置 vitest 的运行环境,让单元测试能跑起来。
|
||||
* 要点:
|
||||
* - environment: 'jsdom':用 jsdom 模拟浏览器 DOM(document/元素等),因为插件界面依赖网页环境。
|
||||
* - resolve.alias:把代码中 `import ... from 'obsidian'` 这个导入"重定向"到 src/test/obsidian-shim.ts,
|
||||
* 也就是前面讲的"测试桩",使测试不依赖真实 Obsidian 就能运行。
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
obsidian: path.resolve(__dirname, 'src/test/obsidian-shim.ts'),
|
||||
},
|
||||
},
|
||||
});
|
||||
79
上架注意事项.md
Normal file
79
上架注意事项.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# 上架注意事项(Obsidian 社区插件)
|
||||
|
||||
> ⚠️ 这是内部上架清单,汇集了发布前**待确认 / 待办**的点。正式推到 GitHub 公开仓库前,可把它移出仓库或加入 `.gitignore`,避免随插件一起公开。
|
||||
|
||||
---
|
||||
|
||||
## 一、账号要求(重要,先搞清楚)
|
||||
|
||||
**结论:需要两个账号,都要有。**
|
||||
|
||||
1. **GitHub 账号** —— 你已有,用于托管源码 + 发 Release。
|
||||
2. **Obsidian 账号** —— **需要新注册(免费)**。2026 年上架流程已改版:旧的「给 obsidianmd/obsidian-releases 提 PR」流程已废弃,现在统一在 **community.obsidian.md** 操作。注册 Obsidian 账号后,用来**绑定你的 GitHub 账号**(验证你拥有该仓库)并提交插件。
|
||||
|
||||
> 所以直接回答你的问题:**是的,得注册一个 Obsidian 账号**(和 GitHub 账号是两个独立的,都得有)。
|
||||
|
||||
---
|
||||
|
||||
## 二、本次会话累积的待确认点 ✅/❌
|
||||
|
||||
- [ ] **LICENSE 版权持有者**:当前写的是 `wanguliu`(取自 git `user.name`)。若你 GitHub 显示名 / 期望署名不同,告诉我改。
|
||||
- [ ] **`manifest.json` 的 `author`(必填)**:当前为空,必须填写(上架前置条件)。
|
||||
- [ ] **`manifest.json` 的 `authorUrl`(建议填)**:当前为空,填你的 GitHub 主页或个人站点更规范。
|
||||
- [x] **`description` 已补齐**:manifest 描述现覆盖核心卖点(记录 + 热力图 + 计划 + 移动端适配 + 代码块插入),不再提及已移除的周汇总视图。
|
||||
- [ ] **第三方素材原链接(可选)**:`THIRD-PARTY-NOTICES` 目前以 flutter-body-atlas 仓库作为 SVG 来源引用。若你能找到 Ryan Graves 的 Figma Muscle Atlas 原始链接,补进 NOTICE 更严谨(非强制)。
|
||||
|
||||
---
|
||||
|
||||
## 三、上架前置检查清单(官方硬性要求)
|
||||
|
||||
- [ ] 仓库为 **Public**。
|
||||
- [ ] 仓库根目录含 **README.md**(✓ 已建)、**LICENSE**(✓ 已建)、**manifest.json**(✓ 已建,待补 author)。
|
||||
- [ ] **manifest.json 字段齐全**:
|
||||
- `id`: `workout-block` —— 唯一、不含 "obsidian" ✓
|
||||
- `name`: `训练块 Workout Block` ✓
|
||||
- `version`: `1.0.0` —— 须 `x.y.z` 格式 ✓
|
||||
- `minAppVersion`: `1.5.0` ✓
|
||||
- `description`: 见上方待确认点
|
||||
- `author`: **待填(必填)**
|
||||
- `authorUrl`: 待填(建议)
|
||||
- [ ] **创建 GitHub Release**:
|
||||
- Tag 版本号 **必须与 manifest 的 `version` 完全一致**(如 `1.0.0`)。
|
||||
- 上传附件:**`main.js`** + **`manifest.json`** + **`styles.css`**(样式独立成文件,需一并上传,否则界面无样式)。
|
||||
- [ ] **提交到社区目录**:登录 community.obsidian.md(Obsidian 账号)→ 绑定 GitHub → `New plugin` → 填仓库 URL → 同意 Developer policies → `Submit`。
|
||||
|
||||
---
|
||||
|
||||
## 四、自动审核(Scorecard)关注点 —— 提前自查
|
||||
|
||||
新流程对**每个版本**自动扫描,结果体现在插件页 Scorecard。本插件已自查,相关项如下:
|
||||
|
||||
| 审核关注项 | 本插件情况 | 备注 |
|
||||
|-----------|-----------|------|
|
||||
| 读取 vault 文件 | ✅ 正常 | 仅读写 `workout_logs.csv` / `workout-config.json` 两个已知文件(按配置目录),**未枚举整个 vault** |
|
||||
| 网络请求 | ✅ 无 | 已 grep 确认无 `fetch` / `XMLHttpRequest` / `requestUrl`,纯本地插件 |
|
||||
| 读取剪贴板 | ✅ 无 | 未调用 `navigator.clipboard` |
|
||||
| 动态执行 / 混淆 | ✅ 无 | 未使用 `eval()` / `new Function()`,无代码混淆 |
|
||||
| 依赖漏洞 | ⚠️ 待查 | 提交前跑一次 `npm audit`,确认无高危依赖 |
|
||||
| main.js 体积 | ⚠️ 关注 | 约 643KB,偏大但应可接受;若被标记可关注 |
|
||||
| README 完整性 | ✅ 满足 | 已具备功能/安装/代码块说明 |
|
||||
| Release artifact attestation | ⚠️ 关注 | 新流程可能要求;以提交后反馈为准 |
|
||||
|
||||
> 规则来源:官方自动审核会扫「已知漏洞依赖、构建产物与源码是否对应、网络请求、读剪贴板、枚举 vault、动态执行、混淆、main.js 体积、README 是否缺失、release 是否缺 attestation」等。本插件除体积/依赖两项需临门一脚确认外,其余均干净。
|
||||
|
||||
---
|
||||
|
||||
## 五、标准提交流程(简述)
|
||||
|
||||
1. **推送代码**到 GitHub(Public)。
|
||||
2. **发 GitHub Release**:Tag = `1.0.0`(与 manifest 一致),附件放 `main.js` + `manifest.json`。
|
||||
3. **登录 community.obsidian.md**(Obsidian 账号)→ 绑定 GitHub → `New plugin` → 填仓库 URL → 同意政策 → `Submit`。
|
||||
4. 等**自动扫描(几分钟)**;必要时按 Scorecard 提示修。人工复核通过后,约 **24 小时内**上架,用户可在 Obsidian 内直接搜到并安装。
|
||||
|
||||
---
|
||||
|
||||
## 六、参考链接
|
||||
|
||||
- 官方提交流程:https://docs.obsidian.md/Plugins/Releasing/Submit+your+plugin
|
||||
- Developer policies / Submission requirements:见 docs.obsidian.md 同站「Plugins / Releasing」栏目
|
||||
- 社区目录:https://community.obsidian.md
|
||||
Loading…
Reference in a new issue