From a3c6ed48ed165ec53f3b2157a04d1f3488007ce6 Mon Sep 17 00:00:00 2001 From: Zain Date: Wed, 25 Mar 2026 21:54:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E5=89=A7=E9=9B=86?= =?UTF-8?q?=E6=8F=90=E5=8F=96=E5=A4=B1=E8=B4=A5=E9=A1=B9=E5=AF=BC=E8=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- findings.md | 5 + progress.md | 5 + src/content/components/file-selector-panel.ts | 22 ++- src/content/components/preview-panel.ts | 127 ++++++++++++++++++ .../components/virtual-preview-list.ts | 4 +- src/locales/en.json | 3 + src/locales/zh_CN.json | 3 + src/locales/zh_TW.json | 3 + task_plan.md | 1 + .../file-selector-panel-rule-presets.test.ts | 40 ++++++ .../unit/preview-panel-episode-assist.test.ts | 80 +++++++++++ 11 files changed, 287 insertions(+), 6 deletions(-) diff --git a/findings.md b/findings.md index bbaf5df..c157cc5 100644 --- a/findings.md +++ b/findings.md @@ -14,3 +14,8 @@ - 2026-03-25:完整验证结果:`npm run typecheck` 通过,`npx vitest run` 通过(36 files / 263 tests);`npm run lint` 通过但仓库内仍存在大量历史 warning,本次未扩散为新的 lint error。 - 2026-03-25:继续完成“剧集提取失败态增强”:`suggestedFailureFileId` 现在真正用于右侧预览的失败项高亮与自动滚动定位,避免“点了首个失败项但用户还要自己找”的断层体验。 - 2026-03-25:为失败项增加内联引导文案 `episode_assist_focus_hint`,明确告诉用户可以直接点整段/片段继续修正,再次预览。 +- 2026-03-25:已提交第三批改动 `2559ef1 feat: 增强剧集提取失败项定位与引导`,当前工作区已恢复干净,可继续下一批优化。 +- 2026-03-25:已确认下一批范围为右侧预览区顶部失败导航(B1),仅做失败总数、当前序号、上一个/下一个失败项,不扩展到失败筛选或自动跳转。 +- 2026-03-25:本批已完成右侧预览区失败导航条:显示失败总数、当前序号、上一个/下一个失败项,并通过 `episode-assist-focus-failure` 事件复用既有失败聚焦逻辑。 +- 2026-03-25:额外补了一层 `scrollIntoView` 守卫,避免测试环境或部分宿主环境缺少该方法时抛出异常。 +- 2026-03-25:本批验证结果:`npm run typecheck` 通过,`npx vitest run` 通过(36 files / 268 tests),`npm run lint` 通过但仍仅存在仓库历史 warning。 diff --git a/progress.md b/progress.md index 4025efc..dd75e0e 100644 --- a/progress.md +++ b/progress.md @@ -13,3 +13,8 @@ - 2026-03-25:完成验证:`npm run typecheck` ✅,`npx vitest run` ✅,`npm run lint` ✅(仅剩历史 warning)。 - 2026-03-25:完成下一批失败态增强:右侧预览支持根据 `suggestedFailureFileId` 自动高亮并滚动定位失败项,且在整段/片段回填成功后清除聚焦态,避免提示残留。 - 2026-03-25:补充失败态引导文案与测试,验证通过:`typecheck` ✅,`vitest` 全量 266 tests ✅,`lint` ✅(仅历史 warning)。 +- 2026-03-25:已提交第三批改动 `2559ef1 feat: 增强剧集提取失败项定位与引导`,准备进入下一批优化设计。 +- 2026-03-25:与用户确认下一批方案:右侧预览区顶部增加失败导航条,采用非循环边界禁用策略,并继续复用 `suggestedFailureFileId` 做高亮与滚动。 +- 2026-03-25:实现右侧预览区顶部失败导航条,支持显示失败总数、当前序号,并通过上一个/下一个按钮切换失败聚焦项。 +- 2026-03-25:完成接线:`preview-panel` 负责计算 prev/next 目标并派发 `episode-assist-focus-failure`,`file-selector-panel` 统一更新 sample 与 `suggestedFailureFileId`。 +- 2026-03-25:完成验证:`npm run typecheck` ✅,`npx vitest run` ✅(36 files / 268 tests),`npm run lint` ✅(仅历史 warning)。 diff --git a/src/content/components/file-selector-panel.ts b/src/content/components/file-selector-panel.ts index 7582403..5eb378f 100644 --- a/src/content/components/file-selector-panel.ts +++ b/src/content/components/file-selector-panel.ts @@ -543,6 +543,17 @@ export class FileSelectorPanel extends LitElement { this.setAssistState({ fillMode: e.detail.mode }); } + private focusAssistFailure(fileId: string): void { + const file = this.selectedFiles.find((item) => item.id === fileId) || this.filteredFiles.find((item) => item.id === fileId); + if (!file) return; + + this.setAssistState({ + sampleFileId: file.id, + sampleFileName: file.name, + suggestedFailureFileId: file.id, + }); + } + private handleAssistUseRandomSample(): void { const candidates = this.selectedFiles.length > 0 ? this.selectedFiles : this.filteredFiles; const sample = candidates[Math.floor(Math.random() * Math.max(1, candidates.length))]; @@ -553,11 +564,7 @@ export class FileSelectorPanel extends LitElement { private handleAssistUseFirstFailure(): void { const firstFailure = this.previewList.find((item) => item.error); if (!firstFailure) return; - this.setAssistState({ - sampleFileId: firstFailure.file.id, - sampleFileName: firstFailure.file.name, - suggestedFailureFileId: firstFailure.file.id, - }); + this.focusAssistFailure(firstFailure.file.id); } private handleAssistClearSample(): void { @@ -613,6 +620,10 @@ export class FileSelectorPanel extends LitElement { }); } + private handleAssistFocusFailure(e: CustomEvent<{ fileId: string }>): void { + this.focusAssistFailure(e.detail.fileId); + } + private handleAssistFocusSegmentMode(e: CustomEvent<{ fileId: string }>): void { const file = this.selectedFiles.find((item) => item.id === e.detail.fileId) || this.filteredFiles.find((item) => item.id === e.detail.fileId); this.setAssistState({ @@ -1662,6 +1673,7 @@ export class FileSelectorPanel extends LitElement { @episode-assist-use-item-as-sample=${this.handleAssistUseItemAsSample} @episode-assist-use-prefix-from-item=${this.handleAssistUsePrefixFromItem} @episode-assist-focus-segment-mode=${this.handleAssistFocusSegmentMode} + @episode-assist-focus-failure=${this.handleAssistFocusFailure} > diff --git a/src/content/components/preview-panel.ts b/src/content/components/preview-panel.ts index 422b9cc..70887b4 100644 --- a/src/content/components/preview-panel.ts +++ b/src/content/components/preview-panel.ts @@ -19,6 +19,31 @@ import './virtual-preview-list'; */ @customElement('preview-panel') export class PreviewPanel extends LitElement { + private getFailureNavigationState() { + const failureItems = this.items.filter((item) => Boolean(item.error)); + const currentId = this.episodeExtractAssistState.suggestedFailureFileId; + const currentIndex = failureItems.findIndex((item) => item.file.id === currentId); + const normalizedIndex = failureItems.length === 0 ? -1 : currentIndex >= 0 ? currentIndex : 0; + + return { + failureItems, + normalizedIndex, + currentItem: normalizedIndex >= 0 ? failureItems[normalizedIndex] : null, + previousItem: normalizedIndex > 0 ? failureItems[normalizedIndex - 1] : null, + nextItem: normalizedIndex >= 0 && normalizedIndex < failureItems.length - 1 ? failureItems[normalizedIndex + 1] : null, + }; + } + + private emitAssistFocusFailure(fileId: string): void { + this.dispatchEvent( + new CustomEvent('episode-assist-focus-failure', { + detail: { fileId }, + bubbles: true, + composed: true, + }) + ); + } + /** * Array of preview items */ @@ -49,6 +74,47 @@ export class PreviewPanel extends LitElement { @property({ attribute: false }) episodeExtractAssistState: EpisodeExtractAssistState = DEFAULT_EPISODE_EXTRACT_ASSIST_STATE; + private renderFailureNavigation() { + if (!this.episodeExtractAssistEnabled) { + return null; + } + + const { failureItems, normalizedIndex, previousItem, nextItem } = this.getFailureNavigationState(); + if (failureItems.length === 0) { + return null; + } + + const currentPosition = normalizedIndex >= 0 ? normalizedIndex + 1 : 0; + + return html` +
+
+ ${I18nService.t('episode_assist_failure_nav_label')} + ${failureItems.length} + ${currentPosition} / ${failureItems.length} +
+
+ + +
+
+ `; + } + render() { const hasConflicts = this.conflictCount > 0; const successCount = this.items.filter((i) => i.done).length; @@ -104,6 +170,8 @@ export class PreviewPanel extends LitElement { : ''} + ${this.renderFailureNavigation()} +
${this.loading ? html` @@ -208,6 +276,65 @@ export class PreviewPanel extends LitElement { color: #ff4d4f; } + .failure-nav { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 16px; + background: var(--cdr-info-bg, #e6f4ff); + border-bottom: 1px solid var(--cdr-info-border, #91caff); + flex-shrink: 0; + } + + .failure-nav-summary { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + color: var(--cdr-info-text, #0958d9); + font-size: 13px; + font-weight: 500; + } + + .failure-nav-label { + color: var(--cdr-text, #262626); + } + + .failure-nav-count, + .failure-nav-position { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px 8px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.7); + border: 1px solid rgba(9, 88, 217, 0.18); + white-space: nowrap; + } + + .failure-nav-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + } + + .failure-nav-button { + border: 1px solid var(--cdr-border, #d9d9d9); + background: var(--cdr-surface, #fff); + color: var(--cdr-text, #262626); + border-radius: 8px; + padding: 6px 10px; + font-size: 12px; + cursor: pointer; + } + + .failure-nav-button:disabled { + opacity: 0.45; + cursor: not-allowed; + } + .list-container { flex: 1; overflow: hidden; diff --git a/src/content/components/virtual-preview-list.ts b/src/content/components/virtual-preview-list.ts index fb765c6..0fa1513 100644 --- a/src/content/components/virtual-preview-list.ts +++ b/src/content/components/virtual-preview-list.ts @@ -270,7 +270,9 @@ export class VirtualPreviewList extends LitElement { this._lastFocusedFailureId = suggestedFailureFileId; requestAnimationFrame(() => { const target = this.renderRoot.querySelector(`[data-file-id="${suggestedFailureFileId}"]`); - target?.scrollIntoView({ block: 'center', behavior: 'smooth' }); + if (target && typeof target.scrollIntoView === 'function') { + target.scrollIntoView({ block: 'center', behavior: 'smooth' }); + } }); } diff --git a/src/locales/en.json b/src/locales/en.json index a9ba247..d501de2 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -241,5 +241,8 @@ "rule_preset_invalid": "This template is incompatible with the current version. Please delete and save it again.", "rule_preset_invalid_badge": "Invalid", "rule_preset_invalid_hint": "This template config is no longer valid and cannot be applied.", + "episode_assist_failure_nav_label": "Failed items", + "episode_assist_prev_failure": "Previous", + "episode_assist_next_failure": "Next", "episode_assist_focus_hint": "The failed item is highlighted. Fill from the full text or a segment, then preview again." } diff --git a/src/locales/zh_CN.json b/src/locales/zh_CN.json index 9735670..9af4c4e 100644 --- a/src/locales/zh_CN.json +++ b/src/locales/zh_CN.json @@ -241,5 +241,8 @@ "rule_preset_invalid": "该模板与当前版本不兼容,请删除后重新保存。", "rule_preset_invalid_badge": "已失效", "rule_preset_invalid_hint": "该模板配置已失效,暂时无法应用。", + "episode_assist_failure_nav_label": "失败项", + "episode_assist_prev_failure": "上一个", + "episode_assist_next_failure": "下一个", "episode_assist_focus_hint": "已为你定位失败项,可直接点整段或片段回填后继续预览。" } diff --git a/src/locales/zh_TW.json b/src/locales/zh_TW.json index c0abdd2..4efc9d8 100644 --- a/src/locales/zh_TW.json +++ b/src/locales/zh_TW.json @@ -241,5 +241,8 @@ "rule_preset_invalid": "此模板與目前版本不相容,請刪除後重新儲存。", "rule_preset_invalid_badge": "已失效", "rule_preset_invalid_hint": "此模板設定已失效,暫時無法套用。", + "episode_assist_failure_nav_label": "失敗項", + "episode_assist_prev_failure": "上一個", + "episode_assist_next_failure": "下一個", "episode_assist_focus_hint": "已為你定位失敗項,可直接點整段或片段回填後繼續預覽。" } diff --git a/task_plan.md b/task_plan.md index 06aff24..6ffb7d7 100644 --- a/task_plan.md +++ b/task_plan.md @@ -23,3 +23,4 @@ | P0-2:剧集提取交互增强(B) | complete | 已实现样本回填、片段点选、失败项快捷修正、预览项回填接线。 | | P0-3:规则模板 / 最近使用(B) | complete | 已实现 recent 自动记录、模板保存/删除、同名覆盖确认。 | | 验证与回归 | complete | `typecheck` / `vitest` / `lint` 已跑通;lint 仅剩仓库历史 warning。 | +| 后续批次:继续优化剧集提取失败修正体验 | complete | 已完成右侧预览区顶部失败导航(B1):失败总数 + 当前序号 + 上一个/下一个失败项,并完成验证。 | diff --git a/tests/unit/file-selector-panel-rule-presets.test.ts b/tests/unit/file-selector-panel-rule-presets.test.ts index b364c15..864e4ad 100644 --- a/tests/unit/file-selector-panel-rule-presets.test.ts +++ b/tests/unit/file-selector-panel-rule-presets.test.ts @@ -15,6 +15,7 @@ type FileSelectorPanelPresetHarness = FileSelectorPanel & { recentRulePresets: any[]; handleApplyRulePreset(event: CustomEvent<{ presetId: string }>): Promise; handleExecute(): Promise; + handleAssistFocusFailure(event: CustomEvent<{ fileId: string }>): void; }; class PresetTestAdapter implements PlatformAdapter { @@ -208,4 +209,43 @@ describe('FileSelectorPanel rule preset integration', () => { expect(panel.recentRulePresets).toHaveLength(1); expect(panel.recentRulePresets[0].id).toBe('recent-1'); }); + + it('focuses the target failure item from preview navigation', async () => { + const panel = new FileSelectorPanel() as FileSelectorPanelPresetHarness; + panel.allFiles = [ + { + id: 'file-1', + name: 'Show.S01E01.mkv', + ext: '.mkv', + parentId: 'root', + size: 1, + mtime: 1, + }, + { + id: 'file-2', + name: 'Show.S01E02.mkv', + ext: '.mkv', + parentId: 'root', + size: 1, + mtime: 2, + }, + ]; + panel.uncheckList = new Set(); + panel.episodeExtractAssistState = { + sampleFileId: null, + sampleFileName: null, + fillTarget: 'prefix', + fillMode: 'segment', + suggestedFailureFileId: null, + }; + + panel.handleAssistFocusFailure( + new CustomEvent('episode-assist-focus-failure', { detail: { fileId: 'file-2' } }) + ); + + expect(panel.episodeExtractAssistState.sampleFileId).toBe('file-2'); + expect(panel.episodeExtractAssistState.sampleFileName).toBe('Show.S01E02.mkv'); + expect(panel.episodeExtractAssistState.suggestedFailureFileId).toBe('file-2'); + }); + }); diff --git a/tests/unit/preview-panel-episode-assist.test.ts b/tests/unit/preview-panel-episode-assist.test.ts index ed729d0..def1f1a 100644 --- a/tests/unit/preview-panel-episode-assist.test.ts +++ b/tests/unit/preview-panel-episode-assist.test.ts @@ -78,4 +78,84 @@ describe('PreviewPanel episode assist bridge', () => { panel.remove(); }); + + it('renders failure navigation in preview header and dispatches focus events', async () => { + const panel = new PreviewPanel(); + panel.episodeExtractAssistEnabled = true; + panel.episodeExtractAssistState = { + sampleFileId: 'file-2', + sampleFileName: 'My.Show.S01E02', + fillTarget: 'prefix', + fillMode: 'segment', + suggestedFailureFileId: 'file-2', + }; + panel.items = [ + { + file: { + id: 'file-1', + name: 'My.Show.S01E01.mkv', + ext: '.mkv', + parentId: 'root', + size: 1, + mtime: Date.now(), + }, + newName: 'My.Show.S01E01.mkv', + conflict: false, + error: 'extract_episode_not_found', + }, + { + file: { + id: 'file-2', + name: 'My.Show.S01E02.mkv', + ext: '.mkv', + parentId: 'root', + size: 1, + mtime: Date.now(), + }, + newName: 'My.Show.S01E02.mkv', + conflict: false, + error: 'extract_episode_not_found', + }, + { + file: { + id: 'file-3', + name: 'My.Show.S01E03.mkv', + ext: '.mkv', + parentId: 'root', + size: 1, + mtime: Date.now(), + }, + newName: 'My.Show.S01E03.mkv', + conflict: false, + error: 'extract_episode_not_found', + }, + ]; + + const focusEvents: Array> = []; + panel.addEventListener('episode-assist-focus-failure', (event) => { + focusEvents.push(event as CustomEvent<{ fileId: string }>); + }); + + document.body.appendChild(panel); + await panel.updateComplete; + + const summary = panel.shadowRoot?.querySelector('[data-role="episode-assist-failure-nav-summary"]'); + const prevButton = panel.shadowRoot?.querySelector('[data-role="episode-assist-prev-failure"]'); + const nextButton = panel.shadowRoot?.querySelector('[data-role="episode-assist-next-failure"]'); + + expect(summary?.textContent).toContain('3'); + expect(summary?.textContent).toContain('2 / 3'); + expect(prevButton?.disabled).toBe(false); + expect(nextButton?.disabled).toBe(false); + + prevButton?.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true })); + nextButton?.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true })); + + expect(focusEvents).toHaveLength(2); + expect(focusEvents[0].detail.fileId).toBe('file-1'); + expect(focusEvents[1].detail.fileId).toBe('file-3'); + + panel.remove(); + }); + });