stouchinglhh
Claude
sync processor from GitLab c697b6e: ็‰ˆๆœฌ 1.19.4 / ๅ‰็ซฏ 1.12.4 โ€”โ€” ๆœๅŠก็ซฏ i18n(้ฆ–ๅฑไธๅ†้—ช่‹ฑๆ–‡)ใ€ๆŽจ็†ๆจกๅ—ๅนถๅ…ฅๆจกๅž‹้กตใ€ๅ‰็ซฏๆŠฝๅ‡บ shared/ใ€็ผ“ๅญ˜้”ฎๆ”น็”จๅ‰็ซฏๅท
bb59211
Raw History Blame Contribute Delete
73.7 kB
/* Episode list, info panel, review/delete/export */
function escHtml(s) {
if (!s) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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 = `<div class="p-4 text-center text-gray-500 text-sm">${t('loading')}</div>`;
}
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 = `<div class="p-4 text-center text-red-400 text-sm">${t('load_failed')}: ${err.message}</div>`;
}
} 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 = `<div class="p-4 text-center text-gray-500 text-sm">${t('no_data')}</div>`;
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 += `
<div class="project-group border-b border-gray-800" data-project-id="${escHtml(projectId)}">
<div class="project-header px-3 py-2 flex items-center gap-2 cursor-pointer hover:bg-gray-800/50 select-none"
onclick="toggleProjectCollapse('${escHtml(projectId)}')">
${projectSelectBoxHtml(projectId, sorted)}
<iconify-icon icon="ant-design:folder-outlined" class="text-blue-500"></iconify-icon>
<span class="text-sm font-medium text-gray-200 truncate">${escHtml(group.project?.name || '')}</span>
<span class="text-xs text-gray-500 flex-shrink-0">${sorted.length}</span>
<span class="project-select-count text-xs text-blue-400 flex-shrink-0"></span>
<iconify-icon icon="ant-design:${pCollapsed ? 'right' : 'down'}-outlined"
class="project-caret text-gray-600 ml-auto"></iconify-icon>
</div>
<div class="project-body ${pCollapsed ? 'hidden' : ''}">`;
if (sorted.length === 0) {
html += `<div class="text-center text-gray-600 text-xs py-2">${t('no_tasks_in_project')}</div>`;
}
sorted.forEach(ep => { html += episodeCardHtml(ep, group.project?.name); });
html += `</div></div>`;
});
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 `<input type="checkbox" class="project-checkbox w-3.5 h-3.5 rounded accent-blue-600 flex-shrink-0"
data-project-id="${escHtml(projectId)}"${disabled}
title="${escHtml(t('select_project_all'))}"
onclick="event.stopPropagation();toggleProjectSelect('${escHtml(projectId)}', this.checked)">`;
}
// ่ฏฅ้กน็›ฎๅœจๅฝ“ๅ‰่ง†ๅ›พไธ‹ๅฏ่ง็š„ใ€ๅฏๅ‹พ้€‰็š„ๆ‰นๆฌก 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)
? '<span class="inline-block w-2 h-2 rounded-full bg-blue-400 mr-1"></span>'
: (isFailed
? '<span class="inline-block w-2 h-2 rounded-full bg-red-400 mr-1"></span>'
: (isReviewed
? '<span class="inline-block w-2 h-2 rounded-full bg-green-400 mr-1"></span>'
: '<span class="inline-block w-2 h-2 rounded-full bg-yellow-400 mr-1"></span>'));
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)
? `<input type="checkbox" class="batch-checkbox w-3.5 h-3.5 rounded accent-blue-600 flex-shrink-0"
data-episode-id="${ep.id}" ${checked}
onclick="event.stopPropagation();toggleBatchSelect('${ep.id}', this.checked)">`
: '';
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
? `<button disabled
class="mt-1.5 w-full bg-blue-950/60 text-blue-300/80 text-xs px-3 py-1 rounded cursor-wait">
<iconify-icon icon="ant-design:loading-outlined" class="icon-sm"></iconify-icon> ${t('processing')}</button>`
: (canReprocess && (ep.status === 'completed' || ep.status === 'to_review')
? `<button onclick="event.stopPropagation();reprocessEpisode('${ep.id}')"
title="Re-run the bound workflow for this episode"
class="mt-1.5 w-full bg-purple-900/70 hover:bg-purple-800 text-purple-200 text-xs px-3 py-1 rounded">
<iconify-icon icon="ant-design:tool-outlined" class="icon-sm"></iconify-icon> ${t('reprocess')}</button>`
: ''))
: '';
const buttons = isAnnotationPage ? '' : (isFailed
? `<div class="flex gap-2">
<button onclick="event.stopPropagation();retryEpisode('${ep.id}')"
class="bg-blue-800 hover:bg-blue-700 text-blue-200 text-xs px-3 py-1 rounded flex-1"><iconify-icon icon="ant-design:reload-outlined" class="icon-sm"></iconify-icon> ${t('retry_review')}</button>
<button onclick="event.stopPropagation();deleteEpisode('${ep.id}')"
class="bg-gray-800 hover:bg-red-900 text-gray-400 hover:text-red-300 text-xs px-3 py-1 rounded flex-1"><iconify-icon icon="ant-design:delete-outlined" class="icon-sm"></iconify-icon> ${t('delete')}</button>
</div>`
: (isReviewed
? `<div class="flex gap-2">
<button onclick="event.stopPropagation();downloadEpisode('${ep.id}')"
title="${t('export_hint')}"
class="bg-blue-800 hover:bg-blue-700 text-blue-200 text-xs px-3 py-1 rounded flex-1"><iconify-icon icon="ant-design:export-outlined" class="icon-sm"></iconify-icon> ${t('export')}</button>
<button onclick="event.stopPropagation();unreviewEpisode('${ep.id}')"
class="bg-yellow-800 hover:bg-yellow-700 text-yellow-200 text-xs px-3 py-1 rounded flex-1"><iconify-icon icon="ant-design:rollback-outlined" class="icon-sm"></iconify-icon> ${t('unreview')}</button>
</div>`
: `<div class="flex gap-2">
<button onclick="event.stopPropagation();markReviewed('${ep.id}')"
class="bg-green-800 hover:bg-green-700 text-green-200 text-xs px-3 py-1 rounded flex-1"><iconify-icon icon="ant-design:check-outlined" class="icon-sm"></iconify-icon> ${t('approve')}</button>
<button onclick="event.stopPropagation();deleteEpisode('${ep.id}')"
class="bg-gray-800 hover:bg-red-900 text-gray-400 hover:text-red-300 text-xs px-3 py-1 rounded"><iconify-icon icon="ant-design:delete-outlined" class="icon-sm"></iconify-icon></button>
</div>${workflowButton}`));
return `
<div class="episode-card ${activeClass} p-3 border-b border-gray-800 hover:bg-gray-800/50 ${cardClass} flex gap-2"
data-episode-id="${ep.id}"
${cardClick}
${isWorkflowQueued ? `title="${t('processing')}"` : ''}>
${checkbox}
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5 mb-2">
${statusDot}
<span class="text-xs ${statusColor}">${statusText}</span>
</div>
<div class="flex items-center gap-1.5 mb-1">
${typeof ep.episode_index === 'number'
? `<div class="text-sm font-semibold text-blue-300 truncate" title="${ep.name || ''}">#${ep.episode_index}</div>`
: `<div class="text-sm truncate text-gray-200">${ep.name || taskName || ep.id.slice(0, 8)}</div>`}
</div>
<div class="text-xs text-gray-500 space-y-0.5 mb-2">
<div><iconify-icon icon="ant-design:field-time-outlined" class="icon-sm"></iconify-icon> ${ep.fps || 30}FPS ยท ${ep.frame_count || 0}${t('frame')}</div>
${cameraCount > 0 ? `<div><iconify-icon icon="ant-design:video-camera-outlined" class="icon-sm"></iconify-icon> ${cameraCount} ${t('cameras')}</div>` : ''}
<div><iconify-icon icon="ant-design:calendar-outlined" class="icon-sm"></iconify-icon> ${time}</div>
</div>
${buttons}
</div>
</div>`;
}
// โ”€โ”€ 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 =
'<div class="flex items-start gap-2">' +
'<iconify-icon icon="ant-design:warning-filled" class="text-amber-300 text-base flex-shrink-0 mt-0.5"></iconify-icon>' +
'<div class="min-w-0">' +
'<div class="font-medium text-amber-100">' + escHtml(t('input_missing_title')) + '</div>' +
'<div class="mt-0.5 text-amber-300/90">' + escHtml(t('input_missing_detail')) + '</div>' +
'<div class="mt-1 font-mono break-words">' + escHtml(missing.join(', ')) + '</div>' +
(matched.length ? '<div class="mt-1 text-emerald-300/90">โœ“ ' + escHtml(matched.join(', ')) + '</div>' : '') +
'</div>' +
'</div>';
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 = `
<!-- ๅ›พๆ ‡้กปๅ–่‡ช iconify-preload.js ็™ฝๅๅ•(loading-3-quarters-outlined
ไธๅœจ่กจๅ†…,ไผš่ตฐ api.iconify.design โ†’ ้ฎ็ฝฉๅ‡บ็Žฐๆ—ถๅกไธ€ไธ‹)ใ€‚
็”จ่กจๅ†…็š„ loading-outlined,้…ๅˆ animate-spin ่ง†่ง‰ไธ€่‡ดใ€‚ -->
<iconify-icon icon="ant-design:loading-outlined" class="text-blue-400 text-4xl animate-spin"></iconify-icon>
<div class="text-gray-200 text-sm">${t('ai_annotating')}</div>
<div class="text-gray-500 text-xs">${t('ai_annotating_hint')}</div>`;
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 = '<div class="flex items-center gap-1 text-green-400 text-xs"><iconify-icon icon="ant-design:check-circle-filled" class="icon-sm"></iconify-icon> Cleaning passed</div>';
} else {
const failList = (cr.checks || []).filter(c => !c.passed).map(c => c.name).join(', ');
cleaningHTML = '<div class="flex items-center gap-1 text-red-400 text-xs" title="' + failList + '"><iconify-icon icon="ant-design:warning-filled" class="icon-sm"></iconify-icon> Cleaning failed</div>';
}
}
// ไธŠไผ ไธๅŒน้…/่ฟ่กŒๅคฑ่ดฅๅผ‚ๅธธ:็‚นๅ‡ปๆ–‡ไปถๅŽๅœจๆญค่ฏฆๆƒ…ๆ˜พ็คบ(ไธๅœจ้กน็›ฎๅˆ—่กจๆ˜พ็คบ)
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 '<div class="flex items-center gap-1 ' + color + ' text-xs" title="' + safeMsg + '">' +
'<iconify-icon icon="ant-design:warning-filled" class="icon-sm"></iconify-icon> ' + kindLabel + msg + '</div>';
}).join('');
}
const isAnnotationPage = window.EGODATA_PAGE_MODE === 'annotation';
const buttons = isAnnotationPage ? '' : (isFailed
? '<button onclick="retryEpisode(\'' + ep.id + '\')" class="flex-1 bg-blue-800 hover:bg-blue-700 text-blue-200 py-1.5 rounded text-xs"><iconify-icon icon="ant-design:reload-outlined" class="icon-sm"></iconify-icon> ' + t('retry_review') + '</button>' +
'<button onclick="deleteEpisode(\'' + ep.id + '\')" class="bg-gray-800 hover:bg-red-900 text-gray-400 hover:text-red-300 py-1.5 px-3 rounded text-xs"><iconify-icon icon="ant-design:delete-outlined" class="icon-sm"></iconify-icon></button>'
: (ep.status === 'processing'
? '<button disabled class="flex-1 bg-blue-950/60 text-blue-300/80 py-1.5 rounded text-xs cursor-wait"><iconify-icon icon="ant-design:loading-outlined" class="icon-sm"></iconify-icon> ' + t('processing') + '</button>' +
'<button onclick="deleteEpisode(\'' + ep.id + '\')" class="bg-gray-800 hover:bg-red-900 text-gray-400 hover:text-red-300 py-1.5 px-3 rounded text-xs"><iconify-icon icon="ant-design:delete-outlined" class="icon-sm"></iconify-icon></button>'
: (isReviewed
? '<button onclick="downloadEpisode(\'' + ep.id + '\')" title="' + t('export_hint') + '" class="flex-1 bg-blue-800 hover:bg-blue-700 text-blue-200 py-1.5 rounded text-xs"><iconify-icon icon="ant-design:export-outlined" class="icon-sm"></iconify-icon> ' + t('export') + '</button>' +
'<button onclick="unreviewEpisode(\'' + ep.id + '\')" class="flex-1 bg-yellow-800 hover:bg-yellow-700 text-yellow-200 py-1.5 rounded text-xs"><iconify-icon icon="ant-design:rollback-outlined" class="icon-sm"></iconify-icon> ' + t('unreview') + '</button>'
: '<button onclick="markReviewed(\'' + ep.id + '\')" class="flex-1 bg-green-700 hover:bg-green-600 text-white py-1.5 rounded text-xs font-medium"><iconify-icon icon="ant-design:check-outlined" class="icon-sm"></iconify-icon> ' + t('approve') + '</button>' +
'<button onclick="deleteEpisode(\'' + ep.id + '\')" class="bg-gray-800 hover:bg-red-900 text-gray-400 hover:text-red-300 py-1.5 px-3 rounded text-xs"><iconify-icon icon="ant-design:delete-outlined" class="icon-sm"></iconify-icon></button>')));
if (infoDiv) {
infoDiv.innerHTML =
'<div class="space-y-1.5 text-xs">' +
(cleaningHTML || '') +
(exceptionHTML || '') +
'<div class="text-gray-500 space-y-0.5">' +
'<div><iconify-icon icon="ant-design:field-time-outlined" class="icon-sm"></iconify-icon> ' + ep.frame_count + ' frames ยท ' + ep.fps + ' FPS ยท ' + duration + 's</div>' +
(cameraList ? '<div><iconify-icon icon="ant-design:video-camera-outlined" class="icon-sm"></iconify-icon> ' + cameraList + '</div>' : '') +
'<div><iconify-icon icon="ant-design:calendar-outlined" class="icon-sm"></iconify-icon> ' + time + '</div>' +
'<div id="episode-detail-sync-status" class="hidden"></div>' +
'<div id="episode-detail-ai-status" class="hidden"></div>' +
'</div>' +
'<div class="flex items-center gap-2 bg-gray-800 rounded px-3 py-1.5">' +
'<span class="text-gray-500">Frame</span>' +
'<span id="heatmap-frame" class="text-blue-400 font-mono font-bold text-lg">-</span>' +
'<span class="text-gray-600">/ ' + ep.frame_count + '</span>' +
'</div>' +
'<div class="flex gap-2">' + buttons + '</div>' +
'<div id="episode-detail-format"></div>' +
'</div>';
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
? '<span class="text-blue-300 font-medium">' + label + '</span>'
: '<span class="text-gray-500">' + label + '</span>';
el.innerHTML = '<div class="flex items-center gap-1 text-gray-500"><iconify-icon icon="ant-design:export-outlined" class="icon-sm"></iconify-icon> ' +
t('export') + ': ' + badge +
(d.available ? ' ยท <button id="btn-re-export" onclick="reExportEpisode()" class="text-blue-400 hover:underline text-xs">' + t('re_export') + '</button>' : '') +
'</div>';
})
.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
? '<span class="text-blue-300 font-medium">' + label + '</span>'
: '<span class="text-gray-500">' + label + '</span>';
el.innerHTML = '<div class="flex items-center gap-1 text-gray-500"><iconify-icon icon="ant-design:export-outlined" class="icon-sm"></iconify-icon> ' +
t('export') + ': ' + badge +
(d.available ? ' ยท <button id="btn-re-export" onclick="reExportEpisode()" class="text-blue-400 hover:underline text-xs">' + t('re_export') + '</button>' : '') +
'</div>';
} 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 =
'<div class="flex items-center justify-center h-64 text-gray-600">' + t('deleted_msg') + '</div>';
// 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 = `<div class="p-4 text-center text-gray-500 text-sm">${t('loading')}</div>`;
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 = `<div class="p-8 text-center text-gray-500"><iconify-icon icon="ant-design:delete-outlined" class="icon-md"></iconify-icon> ${t('trash_empty')}</div>`;
} 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 `
<div class="bg-gray-800 rounded p-4 flex items-center justify-between">
<div class="flex-1">
<div class="text-sm font-medium text-gray-200">${ep.task_description || ep.name || 'unknown'}</div>
<div class="text-xs text-gray-500 mt-1">
<iconify-icon icon="ant-design:field-time-outlined" class="icon-sm"></iconify-icon> ${ep.fps}FPS ยท ${ep.frame_count}${t('frame')} ยท <iconify-icon icon="ant-design:video-camera-outlined" class="icon-sm"></iconify-icon> ${(ep.camera_names||[]).join(', ') || t('not_found')}
</div>
<div class="text-xs text-gray-500"><iconify-icon icon="ant-design:calendar-outlined" class="icon-sm"></iconify-icon> ${time}</div>
</div>
<div class="text-xs text-gray-400 mr-4"><iconify-icon icon="ant-design:hourglass-outlined" class="icon-sm"></iconify-icon> ${t('days_left')} ${daysLeft} ${t('day_unit')}</div>
<div class="flex gap-2">
<button onclick="restoreEpisode('${ep.id}')"
class="bg-green-700 hover:bg-green-600 text-white text-xs px-3 py-1.5 rounded"><iconify-icon icon="ant-design:rollback-outlined" class="icon-sm"></iconify-icon> ${t('restore')}</button>
<button onclick="permanentDeleteEpisode('${ep.id}')"
class="bg-red-800 hover:bg-red-700 text-red-200 text-xs px-3 py-1.5 rounded"><iconify-icon icon="ant-design:delete-outlined" class="icon-sm"></iconify-icon> ${t('permanent_delete')}</button>
</div>
</div>`;
}).join('');
}
updateTrashBadge();
} catch (err) {
listEl.innerHTML = `<div class="p-4 text-center text-red-400 text-sm">${t('load_failed')}</div>`;
}
}
// updateTrashBadge ็”ฑ base.html ๅ…จๅฑ€ๆไพ›(ๆ‰€ๆœ‰้กต้ข็”Ÿๆ•ˆ),ๅˆ ้™ค/ๆขๅค/ๆธ…็ฉบๅŽ
// ๅ„ๆ“ไฝœๅ‡ฝๆ•ฐ่ฐƒ็”จๅฎƒ็ซ‹ๅณๅˆทๆ–ฐๅพฝๆ ‡,ๆ— ้œ€ๅˆทๆ–ฐ้กต้ขใ€‚