/* Episode list, info panel, review/delete/export */ function escHtml(s) { if (!s) return ''; return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } let selectedEpisodes = new Set(); // batch selection let allEpisodes = []; let hierarchyData = []; // Project → Task → Episode tree for the list view const _expandedProjects = new Set(); // expanded project ids (default: collapsed) const _collapsedTasks = new Set(); // collapsed task ids let _loadEpoch = 0; // stale-response guard — discard out-of-order poll results let _formatEpoch = 0; // 导出格式徽标防竞态:快速切换批次时丢弃过期响应 let _silentReloadTimer = null; // coalesce background list refreshes after actions let _listLoadController = null; // abort obsolete hierarchy requests on fast navigation let _episodesLoadedAt = 0; // snapshot freshness for background reconciliation let selectMode = false; // selection mode toggle — hides checkboxes by default // 视图缓存。筛选结果只依赖 [筛选条件 + 排序模式 + 层级快照],与勾选状态无关; // 但勾选一行原先要重算三遍(visibleEpisodes → hierarchy → updateBatchUI), // 每次都 spread 复制全部 episode。缓存后勾选只改 Set 和那一行 DOM。 // _hierarchyRev 在层级数据变化时自增 —— 快照替换、局部增删、局部改字段都要加。 let _hierarchyRev = 0; let _viewCache = { key: null, groups: [], visible: [], visibleIds: new Set(), reviewedIds: [] }; // 上一次渲染所依据的服务端快照指纹;一致就跳过一次整表重绘。 let _hierarchySignatureAt = null; // A short-lived cross-navigation snapshot makes the Review page paint // immediately after leaving Projects/Workflow. The server remains // authoritative: loadEpisodes still refreshes in the background. const REVIEW_SNAPSHOT_CACHE_KEY = 'egodata.review.hierarchy.v1'; const REVIEW_SNAPSHOT_CACHE_TTL = 60 * 1000; function readReviewSnapshotCache() { try { const cached = JSON.parse(sessionStorage.getItem(REVIEW_SNAPSHOT_CACHE_KEY) || 'null'); if (!cached || !Array.isArray(cached.projects) || Date.now() - Number(cached.savedAt || 0) > REVIEW_SNAPSHOT_CACHE_TTL) return null; return cached; } catch (_) { return null; } } function saveReviewSnapshotCache(projects) { try { sessionStorage.setItem(REVIEW_SNAPSHOT_CACHE_KEY, JSON.stringify({ savedAt: Date.now(), projects, })); } catch (_) { /* storage quota/private mode — network refresh still works */ } } // 渲染只依赖这些字段;其余字段(如 duration、camera_streams)变了不影响卡片。 function _episodeRenderSignature(ep) { return [ep.id, ep.status, ep.ai_quality_status, ep._uiWorkflowState, ep.frame_count, ep.fps, ep.name, ep.episode_index, (ep.camera_names || []).length, ep.timestamp, ep.created_at].join(':'); } function _hierarchySignature(projects) { let parts = []; (projects || []).forEach(node => { parts.push(`P${node.project?.id}:${node.project?.name}`); (node.episodes || []).forEach(ep => parts.push(_episodeRenderSignature(ep))); }); return parts.join(';'); } function applyHierarchySnapshot(projects, savedAt = Date.now()) { const incoming = projects || []; // 15 秒轮询每次都会走到这里。卡片渲染只依赖上面那些字段,内容没变时 // 直接返回 —— 否则用户正勾选着,列表被整表重建,会闪、丢焦点、丢点击。 const signature = _hierarchySignature(incoming); if (signature === _hierarchySignatureAt) { _episodesLoadedAt = savedAt; return; } _hierarchySignatureAt = signature; hierarchyData = incoming; _hierarchyRev++; _episodesLoadedAt = savedAt; allEpisodes = hierarchyData.flatMap(node => (node.episodes || []).map(e => ({ ...e, task_description: node.project.name }))); // updateTaskFilter 可能清掉已失效的 filter-task —— 必须在算视图之前调用, // 否则 currentView() 会按一个已经不存在的筛选条件构建缓存。 updateTaskFilter(visibleEpisodesForCurrentView()); updateEpisodeListCount(allEpisodes, '', document.getElementById('filter-task')?.value || ''); renderEpisodeCards(); } // ── Nav tree → filter bridge (called from base.html) ── function applyNavFilter(statusParam, taskName) { // Set hidden filter controls const statusSel = document.getElementById('filter-status'); const taskSel = document.getElementById('filter-task'); const searchInput = document.getElementById('search-input'); if (statusSel) statusSel.value = statusParam; const requestedTask = taskName || ''; if (taskSel) taskSel.value = requestedTask; if (searchInput) searchInput.value = requestedTask; // Update URL const url = new URL(document.location); url.searchParams.set('status', statusParam); url.searchParams.set('search', requestedTask); history.replaceState({}, '', url); // Close detail panel (if open), then filter the in-memory snapshot. backToList(); if (hierarchyData.length) { renderEpisodeListFromMemory(); scheduleSilentEpisodeRefresh(250); } else { loadEpisodes(); } // Highlight nav tree if (typeof window.highlightNavActive === 'function') { window.highlightNavActive(statusParam, taskName); } } window.applyNavFilter = applyNavFilter; function syncReviewShortcutActive(status) { const key = status === 'reviewed' ? 'approved' : (status === 'failed' ? 'failed' : 'reviewing'); document.querySelectorAll('.sidebar-sub[data-review-status]').forEach(link => { link.classList.toggle('active', link.dataset.reviewStatus === key); }); } function switchReviewStatus(status, options = {}) { const allowed = new Set(['completed', 'to_review', 'reviewed', 'failed']); const nextStatus = allowed.has(status) ? status : 'completed'; const statusSel = document.getElementById('filter-status'); const taskSel = document.getElementById('filter-task'); const searchInput = document.getElementById('search-input'); if (statusSel) statusSel.value = nextStatus; if (taskSel) taskSel.value = ''; if (searchInput) searchInput.value = ''; const url = new URL(window.location.href); url.searchParams.set('status', nextStatus); url.searchParams.delete('search'); if (options.replace) history.replaceState({}, '', url); else history.pushState({}, '', url); syncReviewShortcutActive(nextStatus); backToList(); if (hierarchyData.length) { renderEpisodeListFromMemory(); // Keep the switch instant; only reconcile in the background when the // current snapshot is older than a moment. if (Date.now() - _episodesLoadedAt > 1000) scheduleSilentEpisodeRefresh(200); } else { loadEpisodes(); } } window.switchReviewStatus = switchReviewStatus; function installReviewStatusNavigation() { if (window.location.pathname !== '/review') return; const statusByShortcut = { reviewing: 'completed', approved: 'reviewed', failed: 'failed' }; document.querySelectorAll('.sidebar-sub[data-review-status]').forEach(link => { link.addEventListener('click', event => { event.preventDefault(); switchReviewStatus(statusByShortcut[link.dataset.reviewStatus] || 'completed'); }); }); window.addEventListener('popstate', () => { const params = new URLSearchParams(window.location.search); const status = params.get('status') || 'completed'; const statusSel = document.getElementById('filter-status'); const searchInput = document.getElementById('search-input'); if (statusSel) statusSel.value = status; if (searchInput) searchInput.value = params.get('search') || ''; syncReviewShortcutActive(status); if (hierarchyData.length) renderEpisodeListFromMemory(); else loadEpisodes(); }); } // ── Init from URL param ───────────────────────────── function initStatusFromURL() { const params = new URLSearchParams(document.location.search); const statusParam = params.get('status') || 'completed'; const sel = document.getElementById('filter-status'); if (sel) sel.value = statusParam; const searchParam = params.get('search') || ''; const searchInput = document.getElementById('search-input'); if (searchInput && searchParam) searchInput.value = searchParam; } // ── Sort order ────────────────────────────────────── // label 存 **i18n key** 而不是文案 —— 这个标签会被 toggleSortOrder() 直接写进 // DOM,写死英文的话切了中文后点一下排序又变回英文。 const SORT_MODES = [ { dir: 'asc', labelKey: 'name_1_to_n', icon: 'ant-design:ordered-list-outlined' }, { dir: 'desc', labelKey: 'name_n_to_1', icon: 'ant-design:ordered-list-outlined' }, ]; let sortModeIdx = 0; // cycles 0→1→2→3→0 function currentSortMode() { return SORT_MODES[sortModeIdx]; } function toggleSortOrder() { sortModeIdx = (sortModeIdx + 1) % SORT_MODES.length; const mode = currentSortMode(); const icon = document.getElementById('sort-order-icon'); const label = document.getElementById('sort-order-label'); const btn = document.getElementById('btn-sort-order'); if (icon) icon.setAttribute('icon', mode.icon); if (label) label.textContent = t(mode.labelKey); if (btn) btn.title = t('sort_label') + t(mode.labelKey); // Re-sort in memory (no network round trip) — only refetch if not yet loaded if (hierarchyData.length === 0) { loadEpisodes(); } else { renderEpisodeListFromMemory(); } } // Extract trailing numeric suffix: "Chew_gum_0005" → 5, "ep_00020" → 20, "no_number" → 0 function extractEpisodeNumber(name) { const m = String(name || '').match(/(\d+)\s*$/); return m ? parseInt(m[1], 10) : 0; } // ── Selection mode ────────────────────────────────── function toggleSelectMode() { selectMode = !selectMode; if (!selectMode) { // 退出选择模式:清空选择,但**不动项目的展开/折叠状态** —— // 用户展开过哪些项目是他自己的布局,不该被模式切换重置。 selectedEpisodes.clear(); } // 进入选择模式**不再自动展开所有项目**。项目行本身就有三态全选框, // 折叠状态下点一下即可选中整批(卡片仍在 DOM 里),所以没有展开的必要; // 自动展开会让几十个项目同时铺开,列表瞬间变长、滚动位置也丢了。 // 退出时的按钮态与批量栏显隐统一交给 updateBatchUI。 renderEpisodeListFromMemory(); } // ── Load ──────────────────────────────────────────── function currentReviewStatus() { return document.getElementById('filter-status')?.value || 'completed'; } function isEpisodeTemporarilyHidden(ep) { // Reprocessing invalidates the previous review artifact immediately. Keep // the episode out of every list until the server publishes the new state; // otherwise the old card can remain visible as "Processing…". return Boolean(ep && (ep.status === 'processing' || ep._uiWorkflowState === 'queued')); } function matchesReviewStatus(status, filter = currentReviewStatus()) { if (!filter) return true; if (filter === 'completed' || filter === 'to_review') { if (status === 'completed' || status === 'to_review') return true; // processing 由 hierarchyForCurrentView 统一隐藏,避免用户打开 // 旧产物;处理完成后由下一次静默刷新重新出现。 return false; } if (filter === 'reviewed') { return status === 'reviewed' || status === 'approved'; } return status === filter; } function matchesEpisodeSearch(ep, projectName, rawSearch) { const search = String(rawSearch || '').trim().toLowerCase(); if (!search) return true; return (ep.name || '').toLowerCase().includes(search) || String(ep.id || '').toLowerCase().includes(search) || String(projectName || '').toLowerCase().includes(search); } // The server snapshot contains all live episode metadata. Status/search // filtering happens here, so Reviewing ↔ Approved never starts another scan. function hierarchyForCurrentView() { const status = currentReviewStatus(); const search = document.getElementById('search-input')?.value || ''; return hierarchyData.map(node => ({ ...node, episodes: (node.episodes || []).filter(ep => !isEpisodeTemporarilyHidden(ep) && matchesReviewStatus(ep.status, status) && matchesEpisodeSearch(ep, node.project?.name, search)), })); } // 缓存后的视图模型:分组(已按 task 筛选并按当前排序模式排好)+ 扁平的 // 可见 / 已审核 id 列表。渲染、勾选、计数全部读这一份,不再各自重算。 function currentView() { const status = currentReviewStatus(); const search = document.getElementById('search-input')?.value || ''; const taskFilter = document.getElementById('filter-task')?.value || ''; const dir = currentSortMode().dir === 'asc' ? 1 : -1; const key = [_hierarchyRev, status, search, taskFilter, dir].join('|'); if (_viewCache.key === key) return _viewCache; const groups = hierarchyForCurrentView() .filter(node => !taskFilter || (node.project?.name || '') === taskFilter) .map(node => ({ project: node.project, // 组内按尾部序号排序(Test1_000012 → 12),只排副本不动原始数据 episodes: [...(node.episodes || [])].sort((a, b) => (extractEpisodeNumber(a.name) - extractEpisodeNumber(b.name)) * dir), })); const visible = []; const visibleIds = new Set(); const reviewedIds = []; const framesById = new Map(); groups.forEach(group => { group.episodes.forEach(ep => { visible.push({ ...ep, task_description: group.project?.name }); visibleIds.add(String(ep.id)); framesById.set(String(ep.id), Number(ep.frame_count) || 0); if (ep.status === 'reviewed' || ep.status === 'approved') { reviewedIds.push(String(ep.id)); } }); }); _viewCache = { key, groups, visible, visibleIds, reviewedIds, framesById }; return _viewCache; } function visibleEpisodesForCurrentView() { return currentView().visible; } async function loadEpisodes(options = {}) { const silent = Boolean(options.silent); if (!silent && _silentReloadTimer) { clearTimeout(_silentReloadTimer); _silentReloadTimer = null; } const taskFilter = document.getElementById('filter-task')?.value || ''; const listEl = document.getElementById('episode-list-inline'); if (!listEl) return; // Paint the last successful hierarchy immediately on a new page load. // A normal request below still reconciles status/uploads in the background. if (!silent && hierarchyData.length === 0) { const cached = readReviewSnapshotCache(); if (cached) applyHierarchySnapshot(cached.projects, Number(cached.savedAt) || Date.now()); } // Save scroll position before innerHTML rebuild (prevent 15s poll reset) const scrollContainer = document.getElementById('episode-list-section'); const savedScrollTop = scrollContainer ? scrollContainer.scrollTop : 0; if (!silent && hierarchyData.length === 0) { listEl.innerHTML = `
${t('loading')}
`; } if (_listLoadController) _listLoadController.abort(); const controller = new AbortController(); _listLoadController = controller; const fetchEpoch = ++_loadEpoch; try { // Hierarchy view: Project → Task (upload batch) → Episodes. // Status and search are applied after the full snapshot arrives. // ``fetchEpoch`` was captured before dispatch so stale responses are ignored. const res = await fetch('/api/v1/projects/hierarchy', { signal: controller.signal }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); if (fetchEpoch !== _loadEpoch) return; // newer request in flight — discard saveReviewSnapshotCache(data.projects || []); applyHierarchySnapshot(data.projects || []); // Restore scroll position after render if (scrollContainer) { requestAnimationFrame(() => { scrollContainer.scrollTop = savedScrollTop; }); } } catch (err) { if (err?.name === 'AbortError') return; if (!silent && hierarchyData.length === 0) { listEl.innerHTML = `
${t('load_failed')}: ${err.message}
`; } } finally { if (_listLoadController === controller) _listLoadController = null; } } function updateEpisodeListCount(episodes, search, taskFilter) { const countEl = document.getElementById('episode-list-count'); if (!countEl) return; let filtered = episodes.filter(ep => !isEpisodeTemporarilyHidden(ep) && matchesReviewStatus(ep.status)); if (taskFilter) filtered = filtered.filter(ep => (ep.task_description || '') === taskFilter); if (search) { const s = search.toLowerCase(); filtered = filtered.filter(ep => (ep.name || '').toLowerCase().includes(s) || (ep.task_description || '').toLowerCase().includes(s) || String(ep.id).toLowerCase().includes(s)); } countEl.textContent = filtered.length + ' episodes'; } function updateTaskFilter(episodes) { // filter-task is now a hidden input (not a select). Values are set by applyNavFilter(). // This function remains as a hook: clear the value if the task no longer exists in results. const sel = document.getElementById('filter-task'); if (!sel) return; const current = sel.value; if (current) { const taskNames = new Set(episodes.map(e => e.task_description || 'unknown')); if (!taskNames.has(current)) { sel.value = ''; } } } function renderEpisodeCards() { const listEl = document.getElementById('episode-list-inline'); if (!listEl) return; // Auto-exit selection mode when switching away from Reviewed const statusFilter = document.getElementById('filter-status')?.value || ''; if (selectMode && statusFilter !== 'reviewed') { selectMode = false; selectedEpisodes.clear(); } // 分组、筛选、排序都来自缓存的视图模型 —— 渲染只负责拼 HTML。 const view = currentView(); // 项目文件夹**始终渲染**(含空项目/刚创建的项目):几个项目就显示几个 // 文件夹,哪怕里面没有批次;只有没有任何项目时才显示 No data。 if (view.groups.length === 0) { listEl.innerHTML = `
${t('no_data')}
`; updateBatchUI(); return; } // 任务概念已移除:项目 → Episodes 两层(组内排序已在 currentView 完成) let html = ''; view.groups.forEach((group) => { const sorted = group.episodes; const projectId = String(group.project?.id ?? ''); // 默认折叠:只在用户点击展开过的项目才展开视频卡片 const pCollapsed = !_expandedProjects.has(group.project?.id); html += `
${projectSelectBoxHtml(projectId, sorted)} ${escHtml(group.project?.name || '')} ${sorted.length}
`; if (sorted.length === 0) { html += `
${t('no_tasks_in_project')}
`; } sorted.forEach(ep => { html += episodeCardHtml(ep, group.project?.name); }); html += `
`; }); listEl.innerHTML = html; syncProjectSelectDom(); updateBatchUI(); } // 项目行左侧的三态全选框。只作用于当前筛选下该项目可见的已审核批次; // 折叠状态照常可点 —— 卡片仍在 DOM 里,只是 .project-body 被 hidden, // 所以点一下就能选中折叠中的整批,不必先展开。 function projectSelectBoxHtml(projectId, episodes) { const statusFilter = document.getElementById('filter-status')?.value || ''; if (!selectMode || statusFilter !== 'reviewed') return ''; const reviewed = episodes.filter(ep => ep.status === 'reviewed' || ep.status === 'approved'); const disabled = reviewed.length === 0 ? ' disabled' : ''; return ``; } // 该项目在当前视图下可见的、可勾选的批次 id。 function projectReviewedIds(projectId) { const group = currentView().groups.find( item => String(item.project?.id ?? '') === String(projectId)); if (!group) return []; return group.episodes .filter(ep => ep.status === 'reviewed' || ep.status === 'approved') .map(ep => String(ep.id)); } function toggleProjectSelect(projectId, checked) { projectReviewedIds(projectId).forEach(id => { if (checked) selectedEpisodes.add(id); else selectedEpisodes.delete(id); }); syncProjectSelectionDom(projectId); updateBatchUI(); } // 把选中状态同步回 DOM:项目行三态框 + 该项目下每张卡片。只碰受影响的项目, // 不重建整个列表 —— 这是勾选不再"闪一下"的关键。 function syncProjectSelectionDom(onlyProjectId) { document.querySelectorAll('#episode-list-inline .project-group').forEach(group => { if (onlyProjectId !== undefined && String(group.dataset.projectId) !== String(onlyProjectId)) return; group.querySelectorAll('input.batch-checkbox').forEach(box => { box.checked = selectedEpisodes.has(String(box.dataset.episodeId)); box.closest('.episode-card')?.classList.toggle('selected', box.checked); }); syncProjectSelectBox(group); }); } function syncProjectSelectBox(group) { const box = group.querySelector('.project-checkbox'); if (!box) return; const ids = projectReviewedIds(box.dataset.projectId); const selected = ids.filter(id => selectedEpisodes.has(id)).length; box.checked = ids.length > 0 && selected === ids.length; box.indeterminate = selected > 0 && selected < ids.length; const badge = group.querySelector('.project-select-count'); if (badge) badge.textContent = selected > 0 ? `${selected}/${ids.length}` : ''; } function syncProjectSelectDom() { syncProjectSelectionDom(); } function toggleProjectCollapse(projectId) { if (_expandedProjects.has(projectId)) _expandedProjects.delete(projectId); else _expandedProjects.add(projectId); // 只切这一个项目的 body 与箭头:整表重绘会丢掉滚动位置和焦点, // 大列表上肉眼可见地闪。 document.querySelectorAll('#episode-list-inline .project-group').forEach(group => { if (String(group.dataset.projectId) !== String(projectId)) return; const collapsed = !_expandedProjects.has(projectId); group.querySelector('.project-body')?.classList.toggle('hidden', collapsed); group.querySelector('.project-caret') ?.setAttribute('icon', collapsed ? 'ant-design:right-outlined' : 'ant-design:down-outlined'); }); } // Apply small action results locally so the list responds immediately. The // server remains authoritative; a coalesced silent refresh reconciles state // after the filesystem-backed hierarchy has caught up. function renderEpisodeListFromMemory() { // updateTaskFilter 可能清掉已失效的 filter-task,所以要在取视图之前调用。 updateTaskFilter(visibleEpisodesForCurrentView()); updateEpisodeListCount(allEpisodes, '', document.getElementById('filter-task')?.value || ''); renderEpisodeCards(); } function removeEpisodeFromLocalList(episodeId) { const id = String(episodeId); selectedEpisodes.delete(id); _hierarchyRev++; hierarchyData.forEach(node => { node.episodes = (node.episodes || []).filter(ep => String(ep.id) !== id); }); allEpisodes = allEpisodes.filter(ep => String(ep.id) !== id); renderEpisodeListFromMemory(); } function updateEpisodeInLocalList(episodeId, patch) { const id = String(episodeId); _hierarchyRev++; hierarchyData.forEach(node => { const ep = (node.episodes || []).find(item => String(item.id) === id); if (ep) Object.assign(ep, patch); }); const flat = allEpisodes.find(ep => String(ep.id) === id); if (flat) Object.assign(flat, patch); renderEpisodeListFromMemory(); } function scheduleSilentEpisodeRefresh(delay = 1200) { clearTimeout(_silentReloadTimer); _silentReloadTimer = setTimeout(() => { _silentReloadTimer = null; loadEpisodes({ silent: true }); }, delay); } function toggleTaskCollapse(taskId) { if (_collapsedTasks.has(taskId)) _collapsedTasks.delete(taskId); else _collapsedTasks.add(taskId); renderEpisodeListFromMemory(); } function episodeCardHtml(ep, taskName) { const activeClass = currentEpisodeId === ep.id ? 'active' : ''; // 前端入队标记(点击后立即显示)或后端真实状态(静默刷新后保持显示) const isWorkflowQueued = ep._uiWorkflowState === 'queued' || ep.status === 'processing'; // AI 标注运行中:整批数据等标注完成后才允许查看 const isAiAnnotating = !isWorkflowQueued && ep.ai_quality_status === 'running'; const cardClass = (isWorkflowQueued || isAiAnnotating) ? 'cursor-default opacity-80' : 'cursor-pointer'; const cardClick = (isWorkflowQueued || isAiAnnotating) ? '' : `onclick="selectEpisode('${ep.id}')"`; const isReviewed = ep.status === 'reviewed' || ep.status === 'approved'; const isFailed = ep.status === 'failed'; const statusDot = (isWorkflowQueued || isAiAnnotating) ? '' : (isFailed ? '' : (isReviewed ? '' : '')); const statusText = isWorkflowQueued ? t('processing') : (isAiAnnotating ? t('ai_annotating') : (isFailed ? t('stat_failed') : (isReviewed ? t('reviewed') : t('reviewing')))); const statusColor = (isWorkflowQueued || isAiAnnotating) ? 'text-blue-400' : (isFailed ? 'text-red-400' : (isReviewed ? 'text-green-400' : 'text-yellow-400')); const cameraCount = (ep.camera_names || []).length; const timestamp = ep.timestamp || ''; const time = timestamp ? timestamp : new Date(ep.created_at).toLocaleDateString('zh-CN'); const checked = selectedEpisodes.has(ep.id) ? 'checked' : ''; const checkbox = (isReviewed && selectMode) ? `` : ''; const isAnnotationPage = window.EGODATA_PAGE_MODE === 'annotation'; const statusFilter = document.getElementById('filter-status')?.value || new URLSearchParams(document.location.search).get('status') || 'completed'; const isReviewingMode = statusFilter === 'completed' || statusFilter === 'to_review'; const userRole = String(window.__EGO_USER__?.role || '').toLowerCase(); const canReprocess = userRole === 'admin' || userRole === 'engineer'; // processing(含前端入队标记)显示禁用的"处理中"状态按钮,不要求角色; // 主动重跑按钮仍只对 admin/engineer 开放且要求 completed/to_review。 const workflowButton = !isAnnotationPage && isReviewingMode ? (isWorkflowQueued ? `` : (canReprocess && (ep.status === 'completed' || ep.status === 'to_review') ? `` : '')) : ''; const buttons = isAnnotationPage ? '' : (isFailed ? `
` : (isReviewed ? `
` : `
${workflowButton}`)); return `
${checkbox}
${statusDot} ${statusText}
${typeof ep.episode_index === 'number' ? `
#${ep.episode_index}
` : `
${ep.name || taskName || ep.id.slice(0, 8)}
`}
${ep.fps || 30}FPS · ${ep.frame_count || 0}${t('frame')}
${cameraCount > 0 ? `
${cameraCount} ${t('cameras')}
` : ''}
${time}
${buttons}
`; } // ── Back to list ───────────────────────────────────── function backToList() { // Closing detail must release decoders, 3D canvases, timers, sockets and // in-flight media requests. Hiding the panel alone leaves the old video // workload alive behind Reviewing/Approved and makes the next click lag. if (typeof clearEpisodeWorkspace === 'function') clearEpisodeWorkspace(); const detail = document.getElementById('episode-detail'); const listSection = document.getElementById('episode-list-section'); if (detail) detail.classList.add('hidden'); if (listSection) listSection.classList.remove('hidden'); // 返回批次列表 → 隐藏漂浮预览控件(点开视频后才有) if (typeof setPreviewPanelsVisible === 'function') setPreviewPanelsVisible(false); const warning = document.getElementById('input-warning-banner'); if (warning) { warning.classList.add('hidden'); warning.innerHTML = ''; } } function renderInputWarning(ep) { const warning = document.getElementById('input-warning-banner'); if (!warning) return; const missing = []; const matched = []; (ep.exceptions || []).filter(ex => ex.kind === 'input_missing').forEach(ex => { (ex.missing || []).forEach(value => { if (!missing.includes(String(value))) missing.push(String(value)); }); (ex.matched || []).forEach(value => { if (!matched.includes(String(value))) matched.push(String(value)); }); }); if (!missing.length) { warning.classList.add('hidden'); warning.innerHTML = ''; return; } warning.innerHTML = '
' + '' + '
' + '
' + escHtml(t('input_missing_title')) + '
' + '
' + escHtml(t('input_missing_detail')) + '
' + '
' + escHtml(missing.join(', ')) + '
' + (matched.length ? '
✓ ' + escHtml(matched.join(', ')) + '
' : '') + '
' + '
'; warning.classList.remove('hidden'); } // ── Batch selection ────────────────────────────────── function toggleBatchSelect(episodeId, checked) { const id = String(episodeId); if (checked) selectedEpisodes.add(id); else selectedEpisodes.delete(id); // 只同步这一行与它所属项目的三态框 —— 不重算筛选、不重建列表。 const card = document.querySelector( `#episode-list-inline .episode-card[data-episode-id="${CSS.escape(id)}"]`); card?.classList.toggle('selected', checked); const group = card?.closest('.project-group'); if (group) syncProjectSelectBox(group); updateBatchUI(); } function toggleSelectAll(checked) { if (!selectMode) return; if ((document.getElementById('filter-status')?.value || '') !== 'reviewed') return; currentView().reviewedIds.forEach(id => { if (checked) selectedEpisodes.add(id); else selectedEpisodes.delete(id); }); // 直接写已渲染的 checkbox,不重建整个列表。 document.querySelectorAll('#episode-list-inline input.batch-checkbox').forEach(box => { box.checked = selectedEpisodes.has(String(box.dataset.episodeId)); box.closest('.episode-card')?.classList.toggle('selected', box.checked); }); syncProjectSelectDom(); updateBatchUI(); } function updateBatchUI() { const selectAllBar = document.getElementById('select-all-bar'); const batchBar = document.getElementById('batch-bar'); const selectBtn = document.getElementById('btn-toggle-select'); const statusFilter = document.getElementById('filter-status')?.value || ''; const isReviewedMode = statusFilter === 'reviewed'; const showSelection = selectMode && isReviewedMode; // 「Select」只在非选择模式出现 —— 进入选择模式后由批量栏里的 Cancel 接管。 if (selectBtn) { selectBtn.classList.toggle('hidden', !isReviewedMode || selectMode); } // Force-exit selection mode when switching away from Reviewed if (!isReviewedMode && selectMode) { selectMode = false; selectedEpisodes.clear(); } if (selectAllBar) { selectAllBar.classList.toggle('hidden', !showSelection); } // 批量栏只要在选择模式就显示(哪怕一集都没选)—— 否则 Cancel 没地方放。 if (batchBar) { batchBar.classList.toggle('hidden', !showSelection); } if (!showSelection) { if (!selectMode) selectedEpisodes.clear(); return; } // Update counts const view = currentView(); const reviewedCount = view.reviewedIds.length; const selCount = view.reviewedIds.filter(id => selectedEpisodes.has(id)).length; const selectCount = document.getElementById('select-count'); if (selectCount) selectCount.textContent = `(${selCount}/${reviewedCount})`; const selectAllCb = document.getElementById('select-all-checkbox'); if (selectAllCb) { selectAllCb.checked = reviewedCount > 0 && selCount === reviewedCount; selectAllCb.indeterminate = selCount > 0 && selCount < reviewedCount; } // 选中规模:让用户在点之前知道这一下要跑多久 —— 打包下载是秒级原样 zip, // 导出数据集是整库重建(实测约 30 秒/集)。 let frames = 0; view.reviewedIds.forEach(id => { if (selectedEpisodes.has(id)) frames += view.framesById.get(id) || 0; }); const batchCount = document.getElementById('batch-count'); if (batchCount) { batchCount.textContent = t('batch_selected_summary') .replace('%s', String(selCount)) .replace('%f', frames.toLocaleString()); } const exportBtnLabel = document.getElementById('batch-export-label'); if (exportBtnLabel) { const minutes = Math.max(1, Math.round(selCount * 30 / 60)); exportBtnLabel.textContent = selCount >= 5 ? `${t('batch_export_dataset')} ~${minutes}${t('minutes_short')}` : t('batch_export_dataset'); } // 一集都没选时两个动作按钮禁用(Cancel 仍可点,否则退不出去)。 const exportBtn = document.getElementById('batch-export-btn'); if (exportBtn) exportBtn.disabled = selCount === 0; const zipBtn = document.getElementById('batch-zip-btn'); if (zipBtn) zipBtn.disabled = selCount === 0; } // 导出数据集:合并成一个可训练数据集。整库重建 —— 每路视频重编码、 // 逐帧重采样、全量重算 stats,实测约 30 秒/集。 async function batchDownload(button = null) { if (selectedEpisodes.size === 0) return; const ids = Array.from(selectedEpisodes); const done = await startReviewExport(ids, null, null, button); if (done && selectMode) toggleSelectMode(); } // 打包下载:每集一个目录原样 zip,不重建,秒级返回。 // 用原生表单提交而非 fetch + blob —— 浏览器自己流式写盘,不会把整个 // zip 缓冲进内存(几十集的包很容易上 GB)。 function batchZipDownload(button = null) { if (selectedEpisodes.size === 0) return; const form = document.createElement('form'); form.method = 'POST'; form.action = '/api/v1/export/batch-download'; form.style.display = 'none'; const field = document.createElement('input'); field.type = 'hidden'; field.name = 'episode_ids'; field.value = Array.from(selectedEpisodes).join(','); form.appendChild(field); document.body.appendChild(form); form.submit(); form.remove(); if (button) button.blur(); } function waitForExportJob(jobId, button) { const originalHtml = button ? button.innerHTML : null; return new Promise(resolve => { const poll = async () => { try { const res = await fetch(`/api/v1/export/${jobId}`); const job = await res.json(); if (job.status === 'completed') { resolve(true); return; } if (job.status === 'failed') { alert('Export failed: ' + (job.error || 'unknown')); resolve(false); return; } if (button) { // 导出期间按钮直接显示百分比进度,不再静默等待 const pct = Math.round((Number(job.progress) || 0) * 100); button.textContent = 'Exporting… ' + pct + '%'; } } catch (err) { alert('Export status failed: ' + err.message); resolve(false); return; } setTimeout(poll, 800); }; poll(); }).finally(() => { if (button) { if (originalHtml !== null) button.innerHTML = originalHtml; button.disabled = false; button.dataset.exporting = ''; button.classList.remove('opacity-60', 'cursor-wait'); } }); } async function startReviewExport(ids, format, datasetName, button = null) { if (!ids || ids.length === 0) return false; if (button) { button.disabled = true; button.dataset.exporting = '1'; button.classList.add('opacity-60', 'cursor-wait'); } try { const payload = { episode_ids: ids, split_ratio: 0.9, }; if (datasetName) payload.dataset_name = datasetName; // Normal review-page exports omit the format deliberately: the // backend resolves it from the selected project's workflow node. if (format) payload.export_format = format; const res = await fetch('/api/v1/export/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!res.ok) { const err = await res.json().catch(() => ({})); alert('Export failed: ' + (err.detail || 'unknown')); if (button) { button.disabled = false; button.dataset.exporting = ''; button.classList.remove('opacity-60', 'cursor-wait'); } return false; } const job = await res.json(); const done = await waitForExportJob(job.id, button); if (done) { window.location.href = `/api/v1/export/download/${job.id}`; return true; } return false; } catch (err) { alert('Export failed: ' + err.message); if (button) { button.disabled = false; button.dataset.exporting = ''; button.classList.remove('opacity-60', 'cursor-wait'); } return false; } } // ── Select / Info Panel ────────────────────────────── function showAiPendingGate(episodeId) { // 全屏等待遮罩:AI 标注未完成时批次数据不可查看。每 4 秒轮询层级 // 接口,标注结束(非 running)后自动移除遮罩并重新进入批次。 const listSection = document.getElementById('episode-list-section'); const detail = document.getElementById('episode-detail'); if (listSection) listSection.classList.add('hidden'); if (detail) detail.classList.remove('hidden'); let overlay = document.getElementById('ai-pending-overlay'); if (!overlay) { overlay = document.createElement('div'); overlay.id = 'ai-pending-overlay'; overlay.className = 'fixed inset-0 z-[999] flex flex-col items-center justify-center gap-3 bg-black/85'; document.body.appendChild(overlay); } overlay.innerHTML = `
${t('ai_annotating')}
${t('ai_annotating_hint')}
`; if (overlay.dataset.polling === '1') return; overlay.dataset.polling = '1'; (async function poll() { for (let i = 0; i < 300; i++) { // 最多约 20 分钟 await new Promise(resolve => setTimeout(resolve, 4000)); try { const res = await fetch('/api/v1/projects/hierarchy'); if (!res.ok) continue; const data = await res.json(); const projects = Array.isArray(data) ? data : (data && data.projects) || []; let done = false; for (const projectNode of projects) { const hit = (projectNode.episodes || []) .find(e => e.id === episodeId); if (!hit) continue; if (hit.ai_quality_status !== 'running') { done = true; // passed / failed / null → 可查看 } else if (hit.status === 'processing') { done = true; // 回到 processing,由现有门禁接管 } break; } if (done) { overlay.dataset.polling = ''; overlay.remove(); if (typeof selectEpisode === 'function') { selectEpisode(episodeId); } return; } } catch (_) { /* 网络抖动,继续轮询 */ } } overlay.dataset.polling = ''; overlay.remove(); if (typeof backToList === 'function') backToList(); })(); } function selectEpisode(episodeId) { const ep = allEpisodes.find(e => e.id === episodeId); if (!ep) return; // Reprocessing keeps the list card visible, but its previous artifacts are // no longer a valid review target. Do not reopen stale media while the // worker is replacing the processed output. if (ep.status === 'processing' || ep._uiWorkflowState === 'queued') { backToList(); return; } // AI 标注运行中:整批数据等标注完成后才显示(全屏等待遮罩 + 轮询, // 完成后自动载入)。 if (ep.ai_quality_status === 'running') { showAiPendingGate(episodeId); return; } // Annotation is a separate workspace: start every selected file with a // clean editor while leaving the review page's approval flow untouched. if (window.EGODATA_PAGE_MODE === 'annotation' && typeof hideAnnotationForm === 'function') { hideAnnotationForm(); } // Highlight card document.querySelectorAll('.episode-card').forEach(el => el.classList.remove('active')); const card = document.querySelector('.episode-card[data-episode-id="' + episodeId + '"]'); if (card) card.classList.add('active'); // Load video const cameras = ep.camera_names || []; const playbackMeta = { frameCount: ep.frame_count, fps: ep.fps }; if (typeof loadGroupedEpisodeVideo === 'function') { loadGroupedEpisodeVideo(episodeId, cameras, { hasSkeleton: Boolean(ep.has_skeleton), ...playbackMeta, }); } else { loadEpisodeVideo(episodeId, cameras, playbackMeta); } // Switch to detail view: hide list, show detail panel const listSection = document.getElementById('episode-list-section'); const detail = document.getElementById('episode-detail'); if (listSection) listSection.classList.add('hidden'); if (detail) detail.classList.remove('hidden'); // 进入批次详情(审核/通过/标注页)→ 显示漂浮预览控件 if (typeof setPreviewPanelsVisible === 'function') setPreviewPanelsVisible(true); renderInputWarning(ep); // Fill detail header const nameEl = document.getElementById('detail-episode-name'); if (nameEl) { const episodeNumber = typeof ep.episode_index === 'number' ? `#${ep.episode_index}` : ''; const title = ep.task_description || ep.name || ep.id.slice(0, 8); nameEl.textContent = episodeNumber ? `${episodeNumber} · ${title}` : title; nameEl.title = ep.id || title; } const statusBadge = document.getElementById('detail-status-badge'); if (statusBadge) { const isReviewed = ep.status === 'reviewed' || ep.status === 'approved'; const isFailed = ep.status === 'failed'; const isProcessing = ep.status === 'processing'; statusBadge.className = 'text-xs flex-shrink-0 ' + (isFailed ? 'text-red-400' : (isReviewed ? 'text-green-400' : (isProcessing ? 'text-blue-400' : 'text-yellow-400'))); statusBadge.textContent = isFailed ? t('stat_failed') : (isReviewed ? t('reviewed') : (isProcessing ? t('processing') : t('reviewing'))); } // Fill detail info const infoDiv = document.getElementById('episode-detail-info'); const meta = ep.meta || {}; const timestamp = meta.timestamp || ''; const time = timestamp || new Date(ep.created_at).toLocaleString('zh-CN'); const isReviewed = ep.status === 'reviewed' || ep.status === 'approved'; const isFailed = ep.status === 'failed'; const duration = ep.fps > 0 ? (ep.frame_count / ep.fps).toFixed(1) : '0'; const cameraList = (ep.camera_names || []).join(', '); let cleaningHTML = ''; if (ep.cleaning_report) { const cr = ep.cleaning_report; if (cr.passed) { cleaningHTML = '
Cleaning passed
'; } else { const failList = (cr.checks || []).filter(c => !c.passed).map(c => c.name).join(', '); cleaningHTML = '
Cleaning failed
'; } } // 上传不匹配/运行失败异常:点击文件后在此详情显示(不在项目列表显示) let exceptionHTML = ''; if (ep.exceptions && ep.exceptions.length) { exceptionHTML = ep.exceptions.map(function (ex) { const kindLabel = ex.kind === 'run_failed' ? t('exception_kind_failed') : (ex.kind === 'input_missing' ? t('exception_kind_missing') : t('exception_kind_mismatch')); const safeMsg = escHtml(ex.message || ''); const msg = safeMsg ? ': ' + safeMsg : ''; const color = ex.kind === 'input_missing' ? 'text-amber-300' : 'text-red-400'; return '
' + ' ' + kindLabel + msg + '
'; }).join(''); } const isAnnotationPage = window.EGODATA_PAGE_MODE === 'annotation'; const buttons = isAnnotationPage ? '' : (isFailed ? '' + '' : (ep.status === 'processing' ? '' + '' : (isReviewed ? '' + '' : '' + ''))); if (infoDiv) { infoDiv.innerHTML = '
' + (cleaningHTML || '') + (exceptionHTML || '') + '
' + '
' + ep.frame_count + ' frames · ' + ep.fps + ' FPS · ' + duration + 's
' + (cameraList ? '
' + cameraList + '
' : '') + '
' + time + '
' + '' + '' + '
' + '
' + 'Frame' + '-' + '/ ' + ep.frame_count + '' + '
' + '
' + buttons + '
' + '
' + '
'; if (typeof updateEpisodeDetailSyncStatus === 'function') updateEpisodeDetailSyncStatus(); // 导出格式徽标(工作流连接驱动:HDF5 / LeRobot v2.1 / v3.0 / Raw 兜底) const fmtEl = document.getElementById('episode-detail-format'); if (fmtEl) { fmtEl.innerHTML = ''; if (!isAnnotationPage && isReviewed) { const fmtEpoch = ++_formatEpoch; fetch(`/api/v1/export/episode-format/${episodeId}`) .then(r => r.json()) .then(d => { if (fmtEpoch !== _formatEpoch) return; // 已切换批次,丢弃过期响应 const el = document.getElementById('episode-detail-format'); if (!el) return; const label = escHtml(d.label || 'Raw'); const badge = d.available ? '' + label + '' : '' + label + ''; el.innerHTML = '
' + t('export') + ': ' + badge + (d.available ? ' · ' : '') + '
'; }) .catch(() => {}); } } } // Re-export:只重建导出,不重跑检测(复用最新 run 的检测产物 + // 当前工作流的导出配置/连线)。Review 详情 Export 徽标旁按钮。 // (挂 window:inline onclick 需要全局可见) window.reExportEpisode = async function () { if (!currentEpisodeId) return; const btn = document.getElementById('btn-re-export'); if (!btn || btn.dataset.busy === '1') return; btn.dataset.busy = '1'; btn.textContent = t('re_exporting') + '…'; let finalStatus = 'done'; try { const res = await fetch(`/api/v1/export/re-export/${currentEpisodeId}`, { method: 'POST' }); if (!res.ok) throw new Error((await res.json().catch(() => ({}))).detail || res.status); for (let i = 0; i < 300; i++) { await new Promise(r => setTimeout(r, 1000)); let st = { status: 'running' }; try { st = await (await fetch(`/api/v1/export/re-export/${currentEpisodeId}/status`)).json(); } catch (_) { /* keep polling */ } if (st.status === 'done' || st.status === 'failed' || st.status === 'interrupted') { finalStatus = st.status; if (st.status !== 'done') { alert(t('re_export_failed') + ': ' + (st.detail || st.status)); } else { alert(t('re_export_done') + ': ' + (st.detail || '')); } break; } } } catch (err) { finalStatus = 'failed'; alert(t('re_export_failed') + ': ' + (err.message || err)); } btn.dataset.busy = ''; btn.textContent = t('re_export'); if (finalStatus === 'done') { // 刷新徽标(格式可能已变) const fmtEpoch = ++_formatEpoch; try { const d = await (await fetch(`/api/v1/export/episode-format/${currentEpisodeId}`)).json(); if (fmtEpoch !== _formatEpoch || !currentEpisodeId) return; const el = document.getElementById('episode-detail-format'); if (!el) return; const label = escHtml(d.label || 'Raw'); const badge = d.available ? '' + label + '' : '' + label + ''; el.innerHTML = '
' + t('export') + ': ' + badge + (d.available ? ' · ' : '') + '
'; } catch (_) { /* 刷新失败下次进入详情自动重取 */ } } } // Update frame info header const frameHeader = document.getElementById('frame-info-header'); const totalFramesEl = document.getElementById('detail-total-frames'); if (frameHeader) frameHeader.classList.remove('hidden'); if (totalFramesEl) totalFramesEl.textContent = ep.frame_count || 0; // Show frame controls if (typeof showAnnotationUI === 'function') showAnnotationUI(); // Annotations are loaded by loadEpisodeVideo after frame data is ready // (avoid race: loadAnnotations needs episodeTotalFrames from loadFrameData) } // ── Review ────────────────────────────────────────── async function markReviewed(episodeId) { try { const res = await fetch(`/api/v1/episode/${episodeId}/review`, { method: 'POST' }); if (res.ok) { backToList(); removeEpisodeFromLocalList(episodeId); scheduleSilentEpisodeRefresh(); } } catch (err) { alert('Operation failed: ' + err.message); } } async function unreviewEpisode(episodeId) { try { const res = await fetch(`/api/v1/episode/${episodeId}/unreview`, { method: 'POST' }); if (res.ok) { backToList(); removeEpisodeFromLocalList(episodeId); scheduleSilentEpisodeRefresh(); } } catch (err) { alert('Operation failed: ' + err.message); } } async function retryEpisode(episodeId) { try { const res = await fetch(`/api/v1/episode/${episodeId}/retry`, { method: 'POST' }); if (res.ok) { backToList(); removeEpisodeFromLocalList(episodeId); scheduleSilentEpisodeRefresh(); } } catch (err) { alert('Operation failed: ' + err.message); } } // ── Reprocess(主动重新处理)────────────────────────── async function reprocessEpisode(episodeId) { if (!confirm(t('confirm_reprocess'))) return; // The worker will replace the canonical parquet. Do not reuse the old // full depth/3D browser buffers when this episode is opened again. if (typeof window.invalidateEpisodePlaybackCache === 'function') { window.invalidateEpisodePlaybackCache(episodeId); } // Clear the old detail workspace before waiting for the queue response. // Reprocessing is asynchronous; the old media must not remain active // while the user navigates to another review section. backToList(); // Mark the local snapshot before the POST resolves. This closes the small // window in which a fast click could reopen the old completed artifact. updateEpisodeInLocalList(episodeId, { _uiWorkflowState: 'queued' }); try { const res = await fetch(`/api/v1/episode/${episodeId}/reprocess`, { method: 'POST' }); if (res.ok) { scheduleSilentEpisodeRefresh(); } else { updateEpisodeInLocalList(episodeId, { _uiWorkflowState: null }); const err = await res.json(); alert(t('reprocess_failed') + (err.detail || res.status)); } } catch (err) { updateEpisodeInLocalList(episodeId, { _uiWorkflowState: null }); alert(t('reprocess_failed') + err.message); } } // ── Delete ────────────────────────────────────────── async function deleteEpisode(episodeId) { if (!confirm(t('confirm_trash'))) return; try { const res = await fetch(`/api/v1/episode/${episodeId}/delete`, { method: 'POST' }); if (res.ok) { updateTrashBadge(); // 垃圾桶徽标立即 +1,无需刷新页面 backToList(); // instant feedback — close detail, show list if (currentEpisodeId === episodeId) { document.getElementById('video-grid').innerHTML = '
' + t('deleted_msg') + '
'; // Clear annotations annotations = []; if (typeof clearAnnotationOverlay === 'function') clearAnnotationOverlay(); } removeEpisodeFromLocalList(episodeId); scheduleSilentEpisodeRefresh(); } } catch (err) { alert(t('op_failed') + ': ' + err.message); } } /** 从失败的响应里取出可读的失败原因。 * * FastAPI 的 ``HTTPException`` 把原因放在 ``detail`` 里(如"Episode is not in * trash"、"Episode delete verification failed: [...]")。拿不到就退回状态码, * **绝不能返回空** —— 静默的失败比报错更难查。 */ async function apiErrorDetail(res) { try { const body = await res.json(); if (body && body.detail) return String(body.detail); } catch (e) { /* 非 JSON 响应 */ } return `HTTP ${res.status}`; } async function restoreEpisode(episodeId) { try { const res = await fetch(`/api/v1/episode/${episodeId}/restore`, { method: 'POST' }); if (res.ok) { updateTrashBadge(); // 恢复后徽标立即 -1 loadTrashList(); if (typeof refreshReviewTree === 'function') refreshReviewTree(); } else { // ★ 非 2xx 时必须发声。此前只有 if(res.ok) 一个分支 —— 后端返回 // 409/500 时前端什么都不做,用户看到的是"点了没反应",也无从查起。 alert(t('restore_failed') + ': ' + await apiErrorDetail(res)); } } catch (err) { alert(t('restore_failed') + ': ' + err.message); } } async function permanentDeleteEpisode(episodeId) { if (!confirm(t('confirm_permanent'))) return; try { const res = await fetch(`/api/v1/episode/${episodeId}/permanent`, { method: 'DELETE' }); if (res.ok) { updateTrashBadge(); loadTrashList(); if (typeof refreshReviewTree === 'function') refreshReviewTree(); } else { alert(t('delete_failed') + ': ' + await apiErrorDetail(res)); } } catch (err) { alert(t('delete_failed') + ': ' + err.message); } } async function purgeTrash() { if (!confirm(t('confirm_purge'))) return; try { const res = await fetch('/api/v1/episodes/purge-trash', { method: 'POST' }); if (res.ok) { updateTrashBadge(); loadTrashList(); if (typeof refreshReviewTree === 'function') refreshReviewTree(); } else { alert(t('purge_failed') + ': ' + await apiErrorDetail(res)); } } catch (err) { alert(t('purge_failed') + ': ' + err.message); } } // ── Export ──────────────────────────────────────────── async function downloadEpisode(episodeId) { // A single episode already has its workflow-published export product. // That endpoint returns the version selected in the workflow export node. window.location.href = `/api/v1/export/download-episode/${episodeId}`; } // ── Init ──────────────────────────────────────────── document.addEventListener('DOMContentLoaded', () => { // Apply data-i18n translations document.querySelectorAll('[data-i18n]').forEach(el => { el.textContent = t(el.dataset.i18n); }); document.querySelectorAll('[data-i18n-placeholder]').forEach(el => { el.placeholder = t(el.dataset.i18nPlaceholder); }); // Init nav labels const navReview = document.getElementById('nav-review'); const navTrash = document.getElementById('nav-trash'); if (navReview) navReview.textContent = t('video_review'); if (navTrash) navTrash.textContent = t('trash'); if (document.getElementById('episode-list-inline')) { initStatusFromURL(); installReviewStatusNavigation(); loadEpisodes(); setInterval(() => { loadEpisodes({ silent: true }); updateTrashBadge(); }, 15000); updateTrashBadge(); // 两个批量按钮的提示:说清一个是重建、一个是原样打包。 const exportBtn = document.getElementById('batch-export-btn'); if (exportBtn) exportBtn.title = t('batch_export_hint'); const zipBtn = document.getElementById('batch-zip-btn'); if (zipBtn) zipBtn.title = t('batch_zip_hint'); } if (document.getElementById('trash-list')) { loadTrashList(); } }); async function loadTrashList() { const listEl = document.getElementById('trash-list'); if (!listEl) return; listEl.innerHTML = `
${t('loading')}
`; try { const res = await fetch('/api/v1/episodes?status=deleted&limit=200'); const data = await res.json(); const episodes = data.episodes || []; if (episodes.length === 0) { listEl.innerHTML = `
${t('trash_empty')}
`; } else { const now = new Date(); listEl.innerHTML = episodes.map(ep => { const delTime = new Date(ep.deleted_at); const daysLeft = Math.max(0, Math.ceil(7 - (now - delTime) / (1000 * 60 * 60 * 24))); const time = new Date(ep.created_at).toLocaleString('zh-CN'); return `
${ep.task_description || ep.name || 'unknown'}
${ep.fps}FPS · ${ep.frame_count}${t('frame')} · ${(ep.camera_names||[]).join(', ') || t('not_found')}
${time}
${t('days_left')} ${daysLeft} ${t('day_unit')}
`; }).join(''); } updateTrashBadge(); } catch (err) { listEl.innerHTML = `
${t('load_failed')}
`; } } // updateTrashBadge 由 base.html 全局提供(所有页面生效),删除/恢复/清空后 // 各操作函数调用它立即刷新徽标,无需刷新页面。