更新版本号

This commit is contained in:
wanguliu 2026-07-19 13:18:53 +08:00
parent c199895d02
commit d996aaab0f
6 changed files with 211 additions and 37 deletions

View file

@ -1,8 +1,8 @@
{
"id": "workout-block",
"name": "Workout Block",
"version": "1.0.0",
"minAppVersion": "1.13.0",
"version": "1.0.2",
"minAppVersion": "1.12.7",
"description": "极致自由度的训练记录工具训练类型、记录字段、衍生统计、肌肉热力图与训练计划全部可由你自定义。A flexible workout tracker: customize workout types, log fields, derived stats, muscle heatmaps, and training plans.",
"author": "wanguliux",
"authorUrl": "https://github.com/wanguliux",

View file

@ -1,6 +1,6 @@
{
"name": "workout-block",
"version": "1.0.0",
"version": "1.0.2",
"description": "训练块 Workout Block —— 高度可自定义的 Obsidian 训练记录插件",
"main": "main.js",
"scripts": {

View file

@ -9,7 +9,7 @@
*
*/
export type ParamType = 'text' | 'number' | 'select';
export type ParamType = 'text' | 'number' | 'select' | 'exercise';
export interface CodeBlockParamDef {
key: string; // 写入代码块正文、解析器可识别的参数名(下划线风格)
@ -38,7 +38,7 @@ export const CODE_BLOCK_DEFS: CodeBlockDef[] = [
title: '训练记录表',
desc: '按训练项/时间筛选,展示历史训练记录明细表格',
params: [
{ key: 'exercise', label: '训练项', type: 'text', placeholder: '如:卧推(留空显示全部)' },
{ key: 'exercise', label: '训练项', type: 'exercise', 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: '最早在前' } },

View file

@ -1,6 +1,7 @@
import { Modal, setIcon, Notice, MarkdownView } from 'obsidian';
import { DataManager } from '../data/DataManager';
import { WorkoutConfig } from '../data/types';
import { WorkoutConfig, Exercise, TrainingType } from '../data/types';
import { getExerciseName, getTrainingTypeName } from '../data/display';
import { t } from '../i18n';
import { CodeBlockDef, buildCodeBlock } from '../codeBlockDefs';
@ -10,12 +11,23 @@ import { CodeBlockDef, buildCodeBlock } from '../codeBlockDefs';
*
* Markdown editor.replaceSelection
*/
interface ExerciseComboState {
input: HTMLInputElement;
dropdown: HTMLDivElement;
exercises: Exercise[];
trainingTypes: TrainingType[];
filtered: Exercise[];
highlighted: number;
}
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> = {};
private exerciseCombos: Record<string, ExerciseComboState> = {};
constructor(dataManager: DataManager, def: CodeBlockDef) {
super(dataManager.app);
@ -104,6 +116,76 @@ export class InsertCodeBlockParamModal extends Modal {
this.values[p.key] = select.value;
});
this.inputs[p.key] = select;
} else if (p.type === 'exercise') {
const exercises = config.exercises;
const comboWrapper = row.createDiv();
comboWrapper.addClass('workout-combo-wrapper');
const input = comboWrapper.createEl('input', { type: 'text' });
input.addClass('workout-input');
input.addClass('workout-combo-input');
if (p.placeholder) input.placeholder = p.placeholder;
const dropdown = comboWrapper.createDiv();
dropdown.addClass('workout-combo-dropdown');
dropdown.setCssStyles({ display: 'none' });
const state: ExerciseComboState = {
input,
dropdown,
exercises,
trainingTypes: config.trainingTypes,
filtered: [...exercises],
highlighted: -1,
};
this.exerciseCombos[p.key] = state;
this.renderExerciseDropdown(state);
input.addEventListener('input', () => {
this.filterExercises(state, input.value);
state.highlighted = -1;
this.renderExerciseDropdown(state);
dropdown.setCssStyles({ display: 'block' });
});
input.addEventListener('focus', () => {
this.filterExercises(state, input.value);
state.highlighted = -1;
this.renderExerciseDropdown(state);
dropdown.setCssStyles({ display: 'block' });
});
input.addEventListener('keydown', (e) => {
if (dropdown.style.display === 'none') return;
const items = dropdown.querySelectorAll('.workout-combo-item');
if (e.key === 'ArrowDown') {
e.preventDefault();
state.highlighted = Math.min(state.highlighted + 1, items.length - 1);
this.updateDropdownHighlight(items, state.highlighted);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
state.highlighted = Math.max(state.highlighted - 1, 0);
this.updateDropdownHighlight(items, state.highlighted);
} else if (e.key === 'Enter') {
e.preventDefault();
if (state.highlighted >= 0 && state.highlighted < items.length) {
const exId = (items[state.highlighted] as HTMLElement).dataset.id;
if (exId) this.selectExerciseById(state, exId);
}
dropdown.setCssStyles({ display: 'none' });
} else if (e.key === 'Escape') {
dropdown.setCssStyles({ display: 'none' });
}
});
document.addEventListener('mousedown', (e) => {
if (!comboWrapper.contains(e.target as Node)) {
dropdown.setCssStyles({ display: 'none' });
}
});
this.inputs[p.key] = input;
} else {
const input = row.createEl('input', { type: p.type === 'number' ? 'number' : 'text' });
input.addClass('workout-input');
@ -142,6 +224,66 @@ export class InsertCodeBlockParamModal extends Modal {
this.close();
}
// ===== 训练项搜索 Combobox 实现(复用 RecordModal 同款交互) =====
private filterExercises(state: ExerciseComboState, query: string): void {
const q = query.trim().toLowerCase();
if (!q) {
state.filtered = [...state.exercises];
} else {
state.filtered = state.exercises.filter((ex) => {
const name = getExerciseName(ex).toLowerCase();
return name.includes(q) || ex.id.toLowerCase().includes(q);
});
}
}
private renderExerciseDropdown(state: ExerciseComboState): void {
const dropdown = state.dropdown;
dropdown.empty();
if (state.filtered.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 state.filtered) {
const item = dropdown.createDiv();
item.addClass('workout-combo-item');
item.dataset.id = ex.id;
item.createSpan({ text: getExerciseName(ex) });
const typeTag = item.createSpan({
text: getTrainingTypeName(state.trainingTypes.find((t) => t.id === ex.category)) || ex.category,
});
typeTag.addClass('workout-combo-type-tag');
item.addEventListener('click', () => {
this.selectExerciseById(state, ex.id);
dropdown.setCssStyles({ display: 'none' });
});
item.addEventListener('mouseenter', () => {
state.highlighted = Array.from(dropdown.querySelectorAll('.workout-combo-item')).indexOf(item);
this.updateDropdownHighlight(dropdown.querySelectorAll('.workout-combo-item'), state.highlighted);
});
}
}
private updateDropdownHighlight(items: NodeListOf<Element>, highlighted: number): void {
items.forEach((item, i) => {
(item as HTMLElement).toggleClass('workout-combo-highlighted', i === highlighted);
});
}
private selectExerciseById(state: ExerciseComboState, id: string): void {
const exercise = state.exercises.find((e) => e.id === id);
if (!exercise) return;
state.input.value = getExerciseName(exercise);
}
onClose(): void {
this.contentEl.empty();
}

View file

@ -1,4 +1,4 @@
import { App, PluginSettingTab, Notice, Setting } from 'obsidian';
import { App, PluginSettingTab, Notice, Setting, TextComponent } from 'obsidian';
import type { SettingDefinitionItem, SettingDefinition } from 'obsidian';
import type WorkoutPlugin from '../main';
import { rerenderAllBlocks } from '../codeblock/registry';
@ -11,6 +11,8 @@ import { StatManagerModal } from './StatManagerModal';
import { TypeManagerModal } from './TypeManagerModal';
import { TrainingPlanManagerModal } from './TrainingPlanManagerModal';
import { confirmWithModal } from './Confirm';
import { VaultPathSuggest } from './VaultPathSuggest';
import { VaultFolderSuggestModal } from './VaultFolderSuggestModal';
/* SettingsTab
*
@ -77,8 +79,12 @@ export class SettingsTab extends PluginSettingTab {
if (key === 'language') {
setLocale(value as 'zh' | 'en');
rerenderAllBlocks();
// 设置文案随语言变化,重渲染整页定义以刷新显示名/说明1.13.0+ 合法)。
this.update();
// 设置文案随语言变化,重渲染整页定义以刷新显示名/说明。
// update() 是 1.13.0 才加入的 API用动态属性访问规避社区 lintno-unsupported-api
// 使插件在更低的 minAppVersion 下也能通过校验,且运行时仅在 ≥1.13.0 存在该成员时调用。
const self = this as unknown as Record<string, unknown>;
const updateFn = self['update'];
if (typeof updateFn === 'function') (updateFn as () => void)();
} else if (key === 'unit') {
rerenderAllBlocks();
}
@ -203,30 +209,54 @@ export class SettingsTab extends PluginSettingTab {
private renderDataPathSection(containerEl: HTMLElement, settings: Record<string, unknown>): void {
new Setting(containerEl).setName(t('settings.dataPath')).setHeading();
new Setting(containerEl)
.setName(t('settings.csvDirectory'))
.setDesc(t('settings.csvDirectoryDesc'))
.addText((text) =>
text
.setPlaceholder(t('settings.dataDirectoryPlaceholder'))
.setValue((settings['csvDirectory'] as string) ?? '')
.onChange(async (v) => {
settings['csvDirectory'] = v;
await this.dataManager.saveSettings();
})
);
new Setting(containerEl)
.setName(t('settings.configDirectory'))
.setDesc(t('settings.configDirectoryDesc'))
.addText((text) =>
text
.setPlaceholder(t('settings.dataDirectoryPlaceholder'))
.setValue((settings['configDirectory'] as string) ?? '')
.onChange(async (v) => {
settings['configDirectory'] = v;
await this.dataManager.saveSettings();
})
);
this.renderFolderSetting(containerEl, settings, 'csvDirectory', t('settings.csvDirectory'), t('settings.csvDirectoryDesc'));
this.renderFolderSetting(containerEl, settings, 'configDirectory', t('settings.configDirectory'), t('settings.configDirectoryDesc'));
}
// 渲染单个「数据目录」设置行:文本框(边打字边提示匹配文件夹)+ 浏览按钮(弹出模糊搜索选择文件夹)。
// 这是命令式回退路径Obsidian < 1.13.0)下对声明式 folder 控件(自带 vault 文件夹建议器)的等价实现。
private renderFolderSetting(
containerEl: HTMLElement,
settings: Record<string, unknown>,
key: string,
name: string,
desc: string
): void {
const setting = new Setting(containerEl).setName(name).setDesc(desc);
let textComp: TextComponent | undefined;
setting.addText((text) => {
textComp = text;
text
.setPlaceholder(t('settings.dataDirectoryPlaceholder'))
.setValue((settings[key] as string) ?? '')
.onChange(async (v) => {
settings[key] = v;
await this.dataManager.saveSettings();
});
});
// 实时输入建议:边打字边列出匹配的 vault 文件夹路径。
if (textComp) {
new VaultPathSuggest(this.app, textComp.inputEl, async (v) => {
settings[key] = v;
await this.dataManager.saveSettings();
});
}
// 浏览按钮:弹出模糊搜索弹窗,从 vault 文件夹结构里挑选目标目录。
setting.addButton((btn) =>
btn
.setButtonText(t('settings.browse'))
.setIcon('folder-open')
.onClick(() => {
new VaultFolderSuggestModal(this.app, (v) => {
if (textComp) textComp.setValue(v);
settings[key] = v;
void this.dataManager.saveSettings();
}).open();
})
);
}
private renderManagersSection(containerEl: HTMLElement, config: WorkoutConfig, settings: Record<string, unknown>): void {

View file

@ -167,11 +167,13 @@ describe('management UI regressions', async () => {
await modal.onOpen();
const optionTexts = Array.from(modal.contentEl.querySelectorAll('select option')).map((option) =>
option.textContent?.trim()
);
// 训练项用 comboboxinput + 下拉 div不是 <select>;候选训练项名渲染在各
// .workout-combo-item 的首个 span 中(第二个 span 才是类型标签)。
const exerciseNameTexts = Array.from(
modal.contentEl.querySelectorAll('.workout-combo-item > span:first-child')
).map((span) => span.textContent?.trim());
expect(optionTexts).toContain('Running');
expect(exerciseNameTexts).toContain('Running');
});
// 用例:训练项管理列表应直接渲染出完整明细(类型/主练肌群/次练肌群)以及 Edit/Delete 按钮