chore: initialize repository with plugin source and muscle-analysis tooling

This commit is contained in:
wanguliu 2026-07-18 00:16:37 +08:00
commit 10775a2888
69 changed files with 17936 additions and 0 deletions

16
.gitignore vendored Normal file
View 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
View 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
View 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-atlasCC 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
View 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
View 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 devwatch 监听改动热重载
*/
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'], // externalobsidian 由 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
View 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
View 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)

View 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)

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 130 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 182 KiB

View 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

File diff suppressed because it is too large Load diff

32
package.json Normal file
View 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"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 130 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 182 KiB

92
src/codeBlockDefs.ts Normal file
View 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
View 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
View 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 日期段格式)
// - livetrue 表示"随真实日期滚动"(无 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) 辅助肌群:取自训练项配置的 musclesprimary / 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('、') : '—';
}

View 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
View 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));
}
}
}

View 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
View 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,
});
// 收集软删除墓碑标记过的 iddeleted 列 === '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 列 = trueO(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
View 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
View 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.jsonPlugin.loadData / saveData
* vault vault
* - logsvault workout_logs.csv Dataview
* - configvault 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.5svault.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;
}
// 新增一条记录:补全 idtimestamp 可由调用方覆盖缺省取当前时间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.appendO(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 合并成「一次」整文件写盘。
// 注意删除已改为「软删除」appendTombstoneO(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 的墓碑到 CSVvault.appendO(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);
}
// 不带 rowmain.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);
}
// 不带 rowmain.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
View 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
View 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
View 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 派生肌肉 sidefront / 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
View 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
*
* cachedReadObsidian
*/
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
View 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
View 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';
// 构造一条最小 LogRowcomputeStat 只用到 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
View 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
* (130)
*/
// ===== 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);
}

View file

@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest';
import { formatSvgMuscleLabel, SvgMuscleEntry } from './svgMuscleCatalog';
/*
* svgMuscleCatalog
*
* - formatSvgMuscleLabel: 把目录中的 SVG UI
* SVG pathexternal_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)');
});
});

View 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
View 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; // 是否计入覆盖统计
// 超过该天数未练即提醒/标红;默认 7v2.3 由通用设置下放到每块肌肉)
restThresholdDays?: number;
// 该肌肉在身体示意图 SVG 中对应的全部路径 id1→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
View 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
View 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
View 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
View 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);
// 官方方式:告诉 Obsidianworkout-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
View 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
View 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
View 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
View 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();
});
}

View 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
View 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);
}
}

View 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();
}
}

View 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
View 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
* IDSVG 1N */
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();
}
}

View 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
View 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
View 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
View 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 种 inputTypenumber(数字)、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
View 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
View 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
View 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 + repsFieldproductSum 需要 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 + fieldBoneRepMax 需要 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();
}
}

View 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
View 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
View 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);
}
}

View 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);
}
}

View 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();
}
}

View 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
View 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);
}

View file

@ -0,0 +1,353 @@
// @vitest-environment jsdom
/*
* management-ui.test.tsVitest
* vitest / Running
* "回归测试"
*
* - vitest Jest`it(...)` `expect(...)` `describe`
* - jsdom Node DOMdocument/
* - installDomHelpers Obsidian 便empty/addClass/setText/createEljsdom
*
*/
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 的字段只有 labelKeyfield.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取代
// 交互模型完全不同,相关回归用例不再适用。
// 用例 F1resolveLogExerciseName 优先用 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
View 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
View file

@ -0,0 +1,21 @@
// 训练记录的主键生成器。
//
// 用 12 位 base360-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
View 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

File diff suppressed because it is too large Load diff

25
tsconfig.json Normal file
View file

@ -0,0 +1,25 @@
// tsconfig.jsonTypeScript 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
View file

@ -0,0 +1,22 @@
/*
* vitest.config.ts
* vitest
*
* - environment: 'jsdom' jsdom DOMdocument/
* - 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
View 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.mdObsidian 账号)→ 绑定 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. **推送代码**到 GitHubPublic
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