mirror of
https://github.com/wanguliux/workout-block.git
synced 2026-07-22 08:33:12 +00:00
更新版本号
This commit is contained in:
parent
c199895d02
commit
d996aaab0f
6 changed files with 211 additions and 37 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "workout-block",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.2",
|
||||
"description": "训练块 Workout Block —— 高度可自定义的 Obsidian 训练记录插件",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -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: '最早在前' } },
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;用动态属性访问规避社区 lint(no-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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
// 训练项用 combobox(input + 下拉 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 按钮
|
||||
|
|
|
|||
Loading…
Reference in a new issue