feat: 增强剧集提取失败项定位与引导

这个提交包含在:
Zain 2026-03-25 19:54:12 +08:00
父节点 4df5375b92
当前提交 2559ef127f
共有 9 个文件被更改,包括 110 次插入 和 7 次删除

查看文件

@ -12,3 +12,5 @@
- 2026-03-25:`config-panel` 将 `activeRuleConfig` 的同步逻辑从 `updated()` 调整到 `willUpdate()`,避免 Lit 在测试与运行时发出“update completed 后又触发 update”的低效更新警告。 - 2026-03-25:`config-panel` 将 `activeRuleConfig` 的同步逻辑从 `updated()` 调整到 `willUpdate()`,避免 Lit 在测试与运行时发出“update completed 后又触发 update”的低效更新警告。
- 2026-03-25:多语言文案已补齐到 `zh_CN` / `zh_TW` / `en`,并确认占位符必须使用 Chrome i18n 兼容的 `$1` 形式,不能写成 `{0}`。 - 2026-03-25:多语言文案已补齐到 `zh_CN` / `zh_TW` / `en`,并确认占位符必须使用 Chrome i18n 兼容的 `$1` 形式,不能写成 `{0}`。
- 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:为失败项增加内联引导文案 `episode_assist_focus_hint`,明确告诉用户可以直接点整段/片段继续修正,再次预览。

查看文件

@ -11,3 +11,5 @@
- 2026-03-25:完成 `preview-panel` / `virtual-preview-list` / `file-selector-panel` 的辅助回填接线,实现从预览项整段回填、片段点选、失败项快捷修正、执行成功后自动记录 recent preset。 - 2026-03-25:完成 `preview-panel` / `virtual-preview-list` / `file-selector-panel` 的辅助回填接线,实现从预览项整段回填、片段点选、失败项快捷修正、执行成功后自动记录 recent preset。
- 2026-03-25:补齐 `zh_CN` / `zh_TW` / `en` 新文案,并新增 4 个单测文件覆盖 config-panel、preview-panel、file-selector-panel 的新交互。 - 2026-03-25:补齐 `zh_CN` / `zh_TW` / `en` 新文案,并新增 4 个单测文件覆盖 config-panel、preview-panel、file-selector-panel 的新交互。
- 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:补充失败态引导文案与测试,验证通过:`typecheck` ✅,`vitest` 全量 266 tests ✅,`lint` ✅(仅历史 warning)。

查看文件

@ -570,12 +570,16 @@ export class FileSelectorPanel extends LitElement {
private handleAssistApplyFullName(e: CustomEvent<{ fileId: string; fileName: string }>): void { private handleAssistApplyFullName(e: CustomEvent<{ fileId: string; fileName: string }>): void {
this.applyAssistValue(this.episodeExtractAssistState.fillTarget, e.detail.fileName); this.applyAssistValue(this.episodeExtractAssistState.fillTarget, e.detail.fileName);
this.setAssistState({ sampleFileId: e.detail.fileId, sampleFileName: e.detail.fileName }); this.setAssistState({
sampleFileId: e.detail.fileId,
sampleFileName: e.detail.fileName,
suggestedFailureFileId: null,
});
} }
private handleAssistApplySegment(e: CustomEvent<{ fileId: string; segment: string }>): void { private handleAssistApplySegment(e: CustomEvent<{ fileId: string; segment: string }>): void {
this.applyAssistValue(this.episodeExtractAssistState.fillTarget, e.detail.segment); this.applyAssistValue(this.episodeExtractAssistState.fillTarget, e.detail.segment);
this.setAssistState({ sampleFileId: e.detail.fileId }); this.setAssistState({ sampleFileId: e.detail.fileId, suggestedFailureFileId: null });
} }
private handleAssistUseItemAsSample(e: CustomEvent<{ fileId: string; fileName: string }>): void { private handleAssistUseItemAsSample(e: CustomEvent<{ fileId: string; fileName: string }>): void {
@ -605,6 +609,7 @@ export class FileSelectorPanel extends LitElement {
sampleFileId: e.detail.fileId, sampleFileId: e.detail.fileId,
sampleFileName: raw, sampleFileName: raw,
fillTarget: 'prefix', fillTarget: 'prefix',
suggestedFailureFileId: null,
}); });
} }

查看文件

@ -44,6 +44,7 @@ export class VirtualPreviewList extends LitElement {
*/ */
private _virtualizerInitialized = false; private _virtualizerInitialized = false;
private _itemsKey: string | null = null; private _itemsKey: string | null = null;
private _lastFocusedFailureId: string | null = null;
private shouldVirtualize(): boolean { private shouldVirtualize(): boolean {
return this.items.length > VirtualPreviewList.VIRTUALIZE_MIN_ITEMS; return this.items.length > VirtualPreviewList.VIRTUALIZE_MIN_ITEMS;
@ -124,11 +125,16 @@ export class VirtualPreviewList extends LitElement {
const conflictHint = item.conflict const conflictHint = item.conflict
? html`<span class="conflict-hint">${I18nService.t('error_api_conflict')}</span>` ? html`<span class="conflict-hint">${I18nService.t('error_api_conflict')}</span>`
: ''; : '';
const previewItemClasses = ['preview-item', statusClass].filter(Boolean).join(' '); const isSuggestedFailure =
this.episodeExtractAssistEnabled &&
this.episodeExtractAssistState.suggestedFailureFileId === item.file.id;
const previewItemClasses = ['preview-item', statusClass, isSuggestedFailure ? 'assist-focus' : '']
.filter(Boolean)
.join(' ');
const newNameClasses = ['new-name', 'new-name-primary', statusClass].filter(Boolean).join(' '); const newNameClasses = ['new-name', 'new-name-primary', statusClass].filter(Boolean).join(' ');
return html` return html`
<div class="${previewItemClasses}"> <div class="${previewItemClasses}" data-file-id=${item.file.id}>
<div class="preview-content"> <div class="preview-content">
<div class="old-name-row"> <div class="old-name-row">
<div class="old-name old-name-secondary" title=${item.file.name}> <div class="old-name old-name-secondary" title=${item.file.name}>
@ -145,6 +151,9 @@ export class VirtualPreviewList extends LitElement {
? html`<div class="error-message" title=${errorTitle}>${errorText}</div>` ? html`<div class="error-message" title=${errorTitle}>${errorText}</div>`
: '' : ''
} }
${isSuggestedFailure
? html`<div class="assist-focus-hint" data-role="episode-assist-focus-hint">${I18nService.t('episode_assist_focus_hint')}</div>`
: ''}
${this.renderAssistActions(item)} ${this.renderAssistActions(item)}
</div> </div>
</div> </div>
@ -251,6 +260,24 @@ export class VirtualPreviewList extends LitElement {
protected updated(changedProperties: PropertyValues<this>): void { protected updated(changedProperties: PropertyValues<this>): void {
super.updated(changedProperties); super.updated(changedProperties);
const suggestedFailureFileId = this.episodeExtractAssistState.suggestedFailureFileId;
if (
this.episodeExtractAssistEnabled &&
suggestedFailureFileId &&
suggestedFailureFileId !== this._lastFocusedFailureId &&
(changedProperties.has('episodeExtractAssistState') || changedProperties.has('items'))
) {
this._lastFocusedFailureId = suggestedFailureFileId;
requestAnimationFrame(() => {
const target = this.renderRoot.querySelector<HTMLElement>(`[data-file-id="${suggestedFailureFileId}"]`);
target?.scrollIntoView({ block: 'center', behavior: 'smooth' });
});
}
if (!suggestedFailureFileId) {
this._lastFocusedFailureId = null;
}
if (changedProperties.has('items') && this.items.length > 0) { if (changedProperties.has('items') && this.items.length > 0) {
const nextKey = this.computeItemsKey(this.items); const nextKey = this.computeItemsKey(this.items);
if (nextKey !== this._itemsKey) { if (nextKey !== this._itemsKey) {
@ -356,6 +383,10 @@ export class VirtualPreviewList extends LitElement {
border-left: 3px solid var(--cdr-border-strong, #d9d9d9); border-left: 3px solid var(--cdr-border-strong, #d9d9d9);
} }
.preview-item.assist-focus {
box-shadow: inset 0 0 0 1px var(--cdr-primary, #1890ff), 0 0 0 2px rgba(24, 144, 255, 0.12);
}
.preview-content { .preview-content {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@ -434,6 +465,16 @@ export class VirtualPreviewList extends LitElement {
word-break: break-word; word-break: break-word;
} }
.assist-focus-hint {
margin-top: 6px;
font-size: 12px;
color: var(--cdr-info-text, #0958d9);
background: var(--cdr-info-bg, #e6f4ff);
border: 1px solid var(--cdr-info-border, #91caff);
border-radius: 8px;
padding: 6px 8px;
}
.assist-actions { .assist-actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

查看文件

@ -240,5 +240,6 @@
"episode_assist_focus_segment_mode": "Switch to segment mode", "episode_assist_focus_segment_mode": "Switch to segment mode",
"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_focus_hint": "The failed item is highlighted. Fill from the full text or a segment, then preview again."
} }

查看文件

@ -240,5 +240,6 @@
"episode_assist_focus_segment_mode": "切换到片段点选模式", "episode_assist_focus_segment_mode": "切换到片段点选模式",
"rule_preset_invalid": "该模板与当前版本不兼容,请删除后重新保存。", "rule_preset_invalid": "该模板与当前版本不兼容,请删除后重新保存。",
"rule_preset_invalid_badge": "已失效", "rule_preset_invalid_badge": "已失效",
"rule_preset_invalid_hint": "该模板配置已失效,暂时无法应用。" "rule_preset_invalid_hint": "该模板配置已失效,暂时无法应用。",
"episode_assist_focus_hint": "已为你定位失败项,可直接点整段或片段回填后继续预览。"
} }

查看文件

@ -240,5 +240,6 @@
"episode_assist_focus_segment_mode": "切換到片段點選模式", "episode_assist_focus_segment_mode": "切換到片段點選模式",
"rule_preset_invalid": "此模板與目前版本不相容,請刪除後重新儲存。", "rule_preset_invalid": "此模板與目前版本不相容,請刪除後重新儲存。",
"rule_preset_invalid_badge": "已失效", "rule_preset_invalid_badge": "已失效",
"rule_preset_invalid_hint": "此模板設定已失效,暫時無法套用。" "rule_preset_invalid_hint": "此模板設定已失效,暫時無法套用。",
"episode_assist_focus_hint": "已為你定位失敗項,可直接點整段或片段回填後繼續預覽。"
} }

查看文件

@ -71,6 +71,11 @@ describe('PreviewPanel episode assist bridge', () => {
expect(prefixEvents).toHaveLength(1); expect(prefixEvents).toHaveLength(1);
expect(modeEvents).toHaveLength(1); expect(modeEvents).toHaveLength(1);
const focusedItem = previewList?.shadowRoot?.querySelector('[data-file-id="file-1"]');
const focusHint = previewList?.shadowRoot?.querySelector('[data-role="episode-assist-focus-hint"]');
expect(focusedItem?.className).toContain('assist-focus');
expect(focusHint?.textContent).toContain('定位');
panel.remove(); panel.remove();
}); });
}); });

查看文件

@ -151,6 +151,51 @@ describe('VirtualPreviewList pending preview mapping', () => {
element.remove(); element.remove();
}); });
it('highlights and scrolls to the suggested failure item in assist mode', async () => {
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
const scrollSpy = vi.fn();
HTMLElement.prototype.scrollIntoView = scrollSpy;
const element = new VirtualPreviewList();
element.episodeExtractAssistEnabled = true;
element.episodeExtractAssistState = {
sampleFileId: 'file-error',
sampleFileName: 'Show.S01E01',
fillTarget: 'prefix',
fillMode: 'segment',
suggestedFailureFileId: 'file-error',
};
element.items = [
{
file: {
id: 'file-error',
name: 'Show.S01E01.mkv',
ext: '.mkv',
size: 1,
mtime: Date.now(),
isDir: false,
},
newName: 'Show.S01E01.mkv',
conflict: false,
error: 'extract_episode_not_found',
},
];
document.body.appendChild(element);
await element.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined)));
const focusedItem = element.shadowRoot?.querySelector('[data-file-id="file-error"]');
const hint = element.shadowRoot?.querySelector('[data-role="episode-assist-focus-hint"]');
expect(focusedItem?.className).toContain('assist-focus');
expect(hint?.textContent).toContain(I18nService.t('episode_assist_focus_hint'));
expect(scrollSpy).toHaveBeenCalled();
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
element.remove();
});
it('locks preview-item structure with semantic classes and target-specific truncation rules', async () => { it('locks preview-item structure with semantic classes and target-specific truncation rules', async () => {
const element = new VirtualPreviewList(); const element = new VirtualPreviewList();
element.items = [ element.items = [