镜像自地址
https://github.com/lhly/cloud-drive-renamer.git
已同步 2026-10-20 13:47:47 +08:00
feat: 增加剧集提取失败项导航
这个提交包含在:
父节点
2559ef127f
当前提交
a3c6ed48ed
@ -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。
|
||||
|
||||
@ -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)。
|
||||
|
||||
@ -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}
|
||||
></preview-panel>
|
||||
</div>
|
||||
|
||||
|
||||
@ -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`
|
||||
<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() {
|
||||
const hasConflicts = this.conflictCount > 0;
|
||||
const successCount = this.items.filter((i) => i.done).length;
|
||||
@ -104,6 +170,8 @@ export class PreviewPanel extends LitElement {
|
||||
: ''}
|
||||
</div>
|
||||
|
||||
${this.renderFailureNavigation()}
|
||||
|
||||
<div class="list-container">
|
||||
${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;
|
||||
|
||||
@ -270,7 +270,9 @@ export class VirtualPreviewList extends LitElement {
|
||||
this._lastFocusedFailureId = suggestedFailureFileId;
|
||||
requestAnimationFrame(() => {
|
||||
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_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."
|
||||
}
|
||||
|
||||
@ -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": "已为你定位失败项,可直接点整段或片段回填后继续预览。"
|
||||
}
|
||||
|
||||
@ -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": "已為你定位失敗項,可直接點整段或片段回填後繼續預覽。"
|
||||
}
|
||||
|
||||
@ -23,3 +23,4 @@
|
||||
| P0-2:剧集提取交互增强(B) | complete | 已实现样本回填、片段点选、失败项快捷修正、预览项回填接线。 |
|
||||
| P0-3:规则模板 / 最近使用(B) | complete | 已实现 recent 自动记录、模板保存/删除、同名覆盖确认。 |
|
||||
| 验证与回归 | complete | `typecheck` / `vitest` / `lint` 已跑通;lint 仅剩仓库历史 warning。 |
|
||||
| 后续批次:继续优化剧集提取失败修正体验 | complete | 已完成右侧预览区顶部失败导航(B1):失败总数 + 当前序号 + 上一个/下一个失败项,并完成验证。 |
|
||||
|
||||
@ -15,6 +15,7 @@ type FileSelectorPanelPresetHarness = FileSelectorPanel & {
|
||||
recentRulePresets: any[];
|
||||
handleApplyRulePreset(event: CustomEvent<{ presetId: string }>): Promise<void>;
|
||||
handleExecute(): Promise<void>;
|
||||
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');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@ -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<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();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户