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