feat: 增加剧集提取失败项导航

这个提交包含在:
Zain 2026-03-25 21:54:39 +08:00
父节点 2559ef127f
当前提交 a3c6ed48ed
共有 11 个文件被更改,包括 287 次插入 和 6 次删除

查看文件

@ -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:完整验证结果:`npm run typecheck` 通过,`npx vitest run` 通过(36 files / 263 tests);`npm run lint` 通过但仓库内仍存在大量历史 warning,本次未扩散为新的 lint error。
- 2026-03-25:继续完成“剧集提取失败态增强”:`suggestedFailureFileId` 现在真正用于右侧预览的失败项高亮与自动滚动定位,避免“点了首个失败项但用户还要自己找”的断层体验。 - 2026-03-25:继续完成“剧集提取失败态增强”:`suggestedFailureFileId` 现在真正用于右侧预览的失败项高亮与自动滚动定位,避免“点了首个失败项但用户还要自己找”的断层体验。
- 2026-03-25:为失败项增加内联引导文案 `episode_assist_focus_hint`,明确告诉用户可以直接点整段/片段继续修正,再次预览。 - 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。

查看文件

@ -13,3 +13,8 @@
- 2026-03-25:完成验证:`npm run typecheck` ✅,`npx vitest run` ✅,`npm run lint` ✅(仅剩历史 warning)。 - 2026-03-25:完成验证:`npm run typecheck` ✅,`npx vitest run` ✅,`npm run lint` ✅(仅剩历史 warning)。
- 2026-03-25:完成下一批失败态增强:右侧预览支持根据 `suggestedFailureFileId` 自动高亮并滚动定位失败项,且在整段/片段回填成功后清除聚焦态,避免提示残留。 - 2026-03-25:完成下一批失败态增强:右侧预览支持根据 `suggestedFailureFileId` 自动高亮并滚动定位失败项,且在整段/片段回填成功后清除聚焦态,避免提示残留。
- 2026-03-25:补充失败态引导文案与测试,验证通过:`typecheck` ✅,`vitest` 全量 266 tests ✅,`lint` ✅(仅历史 warning)。 - 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)。

查看文件

@ -543,6 +543,17 @@ export class FileSelectorPanel extends LitElement {
this.setAssistState({ fillMode: e.detail.mode }); 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 { private handleAssistUseRandomSample(): void {
const candidates = this.selectedFiles.length > 0 ? this.selectedFiles : this.filteredFiles; const candidates = this.selectedFiles.length > 0 ? this.selectedFiles : this.filteredFiles;
const sample = candidates[Math.floor(Math.random() * Math.max(1, candidates.length))]; const sample = candidates[Math.floor(Math.random() * Math.max(1, candidates.length))];
@ -553,11 +564,7 @@ export class FileSelectorPanel extends LitElement {
private handleAssistUseFirstFailure(): void { private handleAssistUseFirstFailure(): void {
const firstFailure = this.previewList.find((item) => item.error); const firstFailure = this.previewList.find((item) => item.error);
if (!firstFailure) return; if (!firstFailure) return;
this.setAssistState({ this.focusAssistFailure(firstFailure.file.id);
sampleFileId: firstFailure.file.id,
sampleFileName: firstFailure.file.name,
suggestedFailureFileId: firstFailure.file.id,
});
} }
private handleAssistClearSample(): void { 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 { 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); const file = this.selectedFiles.find((item) => item.id === e.detail.fileId) || this.filteredFiles.find((item) => item.id === e.detail.fileId);
this.setAssistState({ this.setAssistState({
@ -1662,6 +1673,7 @@ export class FileSelectorPanel extends LitElement {
@episode-assist-use-item-as-sample=${this.handleAssistUseItemAsSample} @episode-assist-use-item-as-sample=${this.handleAssistUseItemAsSample}
@episode-assist-use-prefix-from-item=${this.handleAssistUsePrefixFromItem} @episode-assist-use-prefix-from-item=${this.handleAssistUsePrefixFromItem}
@episode-assist-focus-segment-mode=${this.handleAssistFocusSegmentMode} @episode-assist-focus-segment-mode=${this.handleAssistFocusSegmentMode}
@episode-assist-focus-failure=${this.handleAssistFocusFailure}
></preview-panel> ></preview-panel>
</div> </div>

查看文件

@ -19,6 +19,31 @@ import './virtual-preview-list';
*/ */
@customElement('preview-panel') @customElement('preview-panel')
export class PreviewPanel extends LitElement { 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 * Array of preview items
*/ */
@ -49,6 +74,47 @@ export class PreviewPanel extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
episodeExtractAssistState: EpisodeExtractAssistState = DEFAULT_EPISODE_EXTRACT_ASSIST_STATE; 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`
<div class="failure-nav" data-role="episode-assist-failure-nav">
<div class="failure-nav-summary" data-role="episode-assist-failure-nav-summary">
<span class="failure-nav-label">${I18nService.t('episode_assist_failure_nav_label')}</span>
<span class="failure-nav-count">${failureItems.length}</span>
<span class="failure-nav-position">${currentPosition} / ${failureItems.length}</span>
</div>
<div class="failure-nav-actions">
<button
class="failure-nav-button"
data-role="episode-assist-prev-failure"
?disabled=${!previousItem}
@click=${() => previousItem && this.emitAssistFocusFailure(previousItem.file.id)}
>
${I18nService.t('episode_assist_prev_failure')}
</button>
<button
class="failure-nav-button"
data-role="episode-assist-next-failure"
?disabled=${!nextItem}
@click=${() => nextItem && this.emitAssistFocusFailure(nextItem.file.id)}
>
${I18nService.t('episode_assist_next_failure')}
</button>
</div>
</div>
`;
}
render() { render() {
const hasConflicts = this.conflictCount > 0; const hasConflicts = this.conflictCount > 0;
const successCount = this.items.filter((i) => i.done).length; const successCount = this.items.filter((i) => i.done).length;
@ -104,6 +170,8 @@ export class PreviewPanel extends LitElement {
: ''} : ''}
</div> </div>
${this.renderFailureNavigation()}
<div class="list-container"> <div class="list-container">
${this.loading ${this.loading
? html` ? html`
@ -208,6 +276,65 @@ export class PreviewPanel extends LitElement {
color: #ff4d4f; 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 { .list-container {
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;

查看文件

@ -270,7 +270,9 @@ export class VirtualPreviewList extends LitElement {
this._lastFocusedFailureId = suggestedFailureFileId; this._lastFocusedFailureId = suggestedFailureFileId;
requestAnimationFrame(() => { requestAnimationFrame(() => {
const target = this.renderRoot.querySelector<HTMLElement>(`[data-file-id="${suggestedFailureFileId}"]`); const target = this.renderRoot.querySelector<HTMLElement>(`[data-file-id="${suggestedFailureFileId}"]`);
target?.scrollIntoView({ block: 'center', behavior: 'smooth' }); if (target && typeof target.scrollIntoView === 'function') {
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
}); });
} }

查看文件

@ -241,5 +241,8 @@
"rule_preset_invalid": "This template is incompatible with the current version. Please delete and save it again.", "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_badge": "Invalid",
"rule_preset_invalid_hint": "This template config is no longer valid and cannot be applied.", "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." "episode_assist_focus_hint": "The failed item is highlighted. Fill from the full text or a segment, then preview again."
} }

查看文件

@ -241,5 +241,8 @@
"rule_preset_invalid": "该模板与当前版本不兼容,请删除后重新保存。", "rule_preset_invalid": "该模板与当前版本不兼容,请删除后重新保存。",
"rule_preset_invalid_badge": "已失效", "rule_preset_invalid_badge": "已失效",
"rule_preset_invalid_hint": "该模板配置已失效,暂时无法应用。", "rule_preset_invalid_hint": "该模板配置已失效,暂时无法应用。",
"episode_assist_failure_nav_label": "失败项",
"episode_assist_prev_failure": "上一个",
"episode_assist_next_failure": "下一个",
"episode_assist_focus_hint": "已为你定位失败项,可直接点整段或片段回填后继续预览。" "episode_assist_focus_hint": "已为你定位失败项,可直接点整段或片段回填后继续预览。"
} }

查看文件

@ -241,5 +241,8 @@
"rule_preset_invalid": "此模板與目前版本不相容,請刪除後重新儲存。", "rule_preset_invalid": "此模板與目前版本不相容,請刪除後重新儲存。",
"rule_preset_invalid_badge": "已失效", "rule_preset_invalid_badge": "已失效",
"rule_preset_invalid_hint": "此模板設定已失效,暫時無法套用。", "rule_preset_invalid_hint": "此模板設定已失效,暫時無法套用。",
"episode_assist_failure_nav_label": "失敗項",
"episode_assist_prev_failure": "上一個",
"episode_assist_next_failure": "下一個",
"episode_assist_focus_hint": "已為你定位失敗項,可直接點整段或片段回填後繼續預覽。" "episode_assist_focus_hint": "已為你定位失敗項,可直接點整段或片段回填後繼續預覽。"
} }

查看文件

@ -23,3 +23,4 @@
| P0-2:剧集提取交互增强(B) | complete | 已实现样本回填、片段点选、失败项快捷修正、预览项回填接线。 | | P0-2:剧集提取交互增强(B) | complete | 已实现样本回填、片段点选、失败项快捷修正、预览项回填接线。 |
| P0-3:规则模板 / 最近使用(B) | complete | 已实现 recent 自动记录、模板保存/删除、同名覆盖确认。 | | P0-3:规则模板 / 最近使用(B) | complete | 已实现 recent 自动记录、模板保存/删除、同名覆盖确认。 |
| 验证与回归 | complete | `typecheck` / `vitest` / `lint` 已跑通;lint 仅剩仓库历史 warning。 | | 验证与回归 | complete | `typecheck` / `vitest` / `lint` 已跑通;lint 仅剩仓库历史 warning。 |
| 后续批次:继续优化剧集提取失败修正体验 | complete | 已完成右侧预览区顶部失败导航(B1):失败总数 + 当前序号 + 上一个/下一个失败项,并完成验证。 |

查看文件

@ -15,6 +15,7 @@ type FileSelectorPanelPresetHarness = FileSelectorPanel & {
recentRulePresets: any[]; recentRulePresets: any[];
handleApplyRulePreset(event: CustomEvent<{ presetId: string }>): Promise<void>; handleApplyRulePreset(event: CustomEvent<{ presetId: string }>): Promise<void>;
handleExecute(): Promise<void>; handleExecute(): Promise<void>;
handleAssistFocusFailure(event: CustomEvent<{ fileId: string }>): void;
}; };
class PresetTestAdapter implements PlatformAdapter { class PresetTestAdapter implements PlatformAdapter {
@ -208,4 +209,43 @@ describe('FileSelectorPanel rule preset integration', () => {
expect(panel.recentRulePresets).toHaveLength(1); expect(panel.recentRulePresets).toHaveLength(1);
expect(panel.recentRulePresets[0].id).toBe('recent-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');
});
}); });

查看文件

@ -78,4 +78,84 @@ describe('PreviewPanel episode assist bridge', () => {
panel.remove(); 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<CustomEvent<{ fileId: string }>> = [];
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<HTMLButtonElement>('[data-role="episode-assist-prev-failure"]');
const nextButton = panel.shadowRoot?.querySelector<HTMLButtonElement>('[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();
});
}); });