From d996aaab0f5062d7dadfeefaada0e4533e09b79f Mon Sep 17 00:00:00 2001 From: wanguliu <756933962@qq.com> Date: Sun, 19 Jul 2026 13:18:53 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E7=89=88=E6=9C=AC=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- manifest.json | 4 +- package.json | 2 +- src/codeBlockDefs.ts | 4 +- src/ui/InsertCodeBlockParamModal.ts | 144 +++++++++++++++++++++++++++- src/ui/SettingsTab.ts | 84 ++++++++++------ src/ui/management-ui.test.ts | 10 +- 6 files changed, 211 insertions(+), 37 deletions(-) diff --git a/manifest.json b/manifest.json index d47204f..43a9382 100644 --- a/manifest.json +++ b/manifest.json @@ -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", diff --git a/package.json b/package.json index a7d2701..c2e46b2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "workout-block", - "version": "1.0.0", + "version": "1.0.2", "description": "训练块 Workout Block —— 高度可自定义的 Obsidian 训练记录插件", "main": "main.js", "scripts": { diff --git a/src/codeBlockDefs.ts b/src/codeBlockDefs.ts index f8d6679..57a5589 100644 --- a/src/codeBlockDefs.ts +++ b/src/codeBlockDefs.ts @@ -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: '最早在前' } }, diff --git a/src/ui/InsertCodeBlockParamModal.ts b/src/ui/InsertCodeBlockParamModal.ts index ba26ef6..a2b7fe7 100644 --- a/src/ui/InsertCodeBlockParamModal.ts +++ b/src/ui/InsertCodeBlockParamModal.ts @@ -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 = {}; private paramContainer!: HTMLDivElement; private inputs: Record = {}; + private exerciseCombos: Record = {}; 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, 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(); } diff --git a/src/ui/SettingsTab.ts b/src/ui/SettingsTab.ts index d9890a7..948de56 100644 --- a/src/ui/SettingsTab.ts +++ b/src/ui/SettingsTab.ts @@ -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; + 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): 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, + 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): void { diff --git a/src/ui/management-ui.test.ts b/src/ui/management-ui.test.ts index 5153fbe..96a37f4 100644 --- a/src/ui/management-ui.test.ts +++ b/src/ui/management-ui.test.ts @@ -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),不是