Spaces:
Running
Running
stouchinglhh
Claude
sync processor from GitLab c697b6e: ็ๆฌ 1.19.4 / ๅ็ซฏ 1.12.4 โโ ๆๅก็ซฏ i18n(้ฆๅฑไธๅ้ช่ฑๆ)ใๆจ็ๆจกๅๅนถๅ
ฅๆจกๅ้กตใๅ็ซฏๆฝๅบ shared/ใ็ผๅญ้ฎๆน็จๅ็ซฏๅท
bb59211 Download processor/web/static/js/app.js from Stouching-Robotics/DataFlowing: direct link, hf CLI and curl.
- Browser
- Download file 73.7 kB
-
https://huggingface.co/spaces/Stouching-Robotics/DataFlowing/resolve/main/processor/web/static/js/app.js
- Command line
-
hf download hf://spaces/Stouching-Robotics/DataFlowing/processor/web/static/js/app.js
-
curl -L -o app.js https://huggingface.co/spaces/Stouching-Robotics/DataFlowing/resolve/main/processor/web/static/js/app.js
73.7 kB
| /* Episode list, info panel, review/delete/export */ | |
| function escHtml(s) { | |
| if (!s) return ''; | |
| return String(s).replace(/&/g, '&').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 = `<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 ๅ จๅฑๆไพ(ๆๆ้กต้ข็ๆ),ๅ ้ค/ๆขๅค/ๆธ ็ฉบๅ | |
| // ๅๆไฝๅฝๆฐ่ฐ็จๅฎ็ซๅณๅทๆฐๅพฝๆ ,ๆ ้ๅทๆฐ้กต้ขใ | |