/* Project management — hierarchy: Project → Task (upload batch) → Episode (video). Replaces the old task-definition page. */ let editingProjectId = null; const _expanded = new Set(); // project ids with open task list let _projectsSnapshot = []; let _projectsLoaded = false; let _projectsLoadController = null; const _projectTreeSnapshot = new Map(); const PROJECTS_SNAPSHOT_CACHE_KEY = 'egodata.projects.snapshot.v1'; const PROJECTS_SNAPSHOT_CACHE_TTL = 60 * 1000; function readProjectsSnapshotCache() { try { const cached = JSON.parse(sessionStorage.getItem(PROJECTS_SNAPSHOT_CACHE_KEY) || 'null'); if (!cached || !Array.isArray(cached.projects) || Date.now() - Number(cached.savedAt || 0) > PROJECTS_SNAPSHOT_CACHE_TTL) return null; return cached; } catch (_) { return null; } } function saveProjectsSnapshotCache(projects) { try { sessionStorage.setItem(PROJECTS_SNAPSHOT_CACHE_KEY, JSON.stringify({ savedAt: Date.now(), projects, })); } catch (_) { /* storage quota/private mode — network refresh still works */ } } function escHtml(s) { if (!s) return ''; return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function escAttr(s) { if (!s) return ''; return escHtml(s).replace(/'/g, '''); } async function apiFetch(url, options = {}) { const res = await fetch(url, { headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }, ...options, }); if (!res.ok) { const detail = await res.json().catch(() => ({})); throw new Error(detail.detail || `HTTP ${res.status}`); } return res.status === 204 ? null : res.json(); } // 一个项目绑定一个当前工作流。旧项目文件仍兼容 workflow_ids 数组, // 但前端选择和保存都只保留一个,与后端派发规则保持一致。 let _selectedWorkflowIds = []; let _workflowNameById = {}; // id → name(下拉框内显示已选名称) function updateWorkflowDropdownLabel() { const label = document.getElementById('wf-dropdown-label'); if (!label) return; if (_selectedWorkflowIds.length > 0) { // 框内直接显示已选工作流名(最多 2 个,超出 +N) const names = _selectedWorkflowIds.slice(0, 2).map(id => _workflowNameById[id]).filter(Boolean); label.textContent = names.length > 0 ? (_selectedWorkflowIds.length > 2 ? `${names.join(', ')} +${_selectedWorkflowIds.length - 2}` : names.join(', ')) : t('workflow_selected').replace('%s', _selectedWorkflowIds.length); label.classList.remove('text-gray-400'); label.classList.add('text-gray-200'); } else { label.textContent = t('workflow_placeholder'); label.classList.remove('text-gray-200'); label.classList.add('text-gray-400'); } } // 工作流下拉:点击按钮展开,点选项行切换当前唯一工作流,点外部关闭 // 顶部固定 "+ New Workflow" 行:在项目中直接新建工作流(命名即建、自动选中) // 命名用内联输入(替代浏览器 prompt):点 + 行 → 就地变输入框,回车创建、Esc/失焦取消 let _wfNameEditing = false; function startWorkflowNameInput() { _wfNameEditing = true; loadWorkflowsForSelect().then(() => { const input = document.getElementById('wf-new-name'); if (input) { input.focus(); input.select(); } }); } function cancelWorkflowNameInput() { if (!_wfNameEditing) return; _wfNameEditing = false; loadWorkflowsForSelect(); } async function commitWorkflowName(input) { if (!_wfNameEditing) return; const name = (input.value || '').trim(); _wfNameEditing = false; // 先退出编辑态,再创建 if (!name) { loadWorkflowsForSelect(); return; } try { const w = await apiFetch('/api/v1/workflows', { method: 'POST', body: JSON.stringify({ name, graph: { nodes: [], edges: [] }, status: 'draft' }), }); _selectedWorkflowIds = [w.id]; await loadWorkflowsForSelect(); updateWorkflowDropdownLabel(); } catch (e) { alert(t('workflow_create_failed') + ': ' + e.message); loadWorkflowsForSelect(); // 失败也退出编辑态 } } async function loadWorkflowsForSelect() { try { const d = await apiFetch('/api/v1/workflows?limit=100'); const panel = document.getElementById('wf-dropdown-panel'); if (!panel) return; _workflowNameById = {}; d.workflows.forEach(w => { _workflowNameById[w.id] = w.name; }); const newRow = _wfNameEditing ? `` : `
${t('new_workflow_panel')}
`; const listHtml = d.workflows.length === 0 ? `
${t('workflow_placeholder')}
` : d.workflows.map(w => { const checked = _selectedWorkflowIds.includes(w.id); return `
✓ ${escHtml(w.name)}${w.is_preset ? `${t('template_badge')}` : ''}${w.node_count === 0 ? `⚠ ${t('workflow_empty')}` : ''}
`; }).join(''); panel.innerHTML = newRow + listHtml; updateWorkflowDropdownLabel(); } catch (e) { console.error('load workflows failed', e); } } function toggleWorkflowRow(row) { const id = row.dataset.id; const idx = _selectedWorkflowIds.indexOf(id); if (idx >= 0) { _selectedWorkflowIds = []; } else { _selectedWorkflowIds = [id]; } // Re-render all rows so the previous selection is visibly cleared. loadWorkflowsForSelect(); updateWorkflowDropdownLabel(); } // 面板开合:按钮切换(箭头旋转),点外部关闭 function closeWorkflowDropdown() { const panel = document.getElementById('wf-dropdown-panel'); const arrow = document.getElementById('wf-dropdown-arrow'); if (panel) panel.classList.add('hidden'); if (arrow) arrow.classList.remove('rotate-180'); } (function initWorkflowDropdown() { document.addEventListener('click', (e) => { const panel = document.getElementById('wf-dropdown-panel'); const toggle = document.getElementById('wf-dropdown-toggle'); const arrow = document.getElementById('wf-dropdown-arrow'); if (!panel || !toggle) return; if (toggle.contains(e.target)) { const open = panel.classList.toggle('hidden'); if (arrow) arrow.classList.toggle('rotate-180', !open); return; } if (!panel.contains(e.target)) { closeWorkflowDropdown(); } }); })(); function getSelectedWorkflowIds() { return _selectedWorkflowIds.slice(); } function renderProjectsFromMemory() { const listEl = document.getElementById('projects-list'); if (!listEl) return; const search = (document.getElementById('projects-search')?.value || '').toLowerCase(); const projects = _projectsSnapshot.filter(p => !search || p.name.toLowerCase().includes(search)); if (projects.length === 0) { listEl.innerHTML = `
${t('no_projects')}
`; return; } listEl.innerHTML = projects.map(p => projectCard(p)).join(''); projects.filter(p => _expanded.has(p.id)).forEach(p => renderTree(p.id)); } async function loadProjects(options = {}) { const force = Boolean(options && options.force); const listEl = document.getElementById('projects-list'); if (!listEl) return; if (_projectsLoaded && !force) { renderProjectsFromMemory(); return; } if (!force && !_projectsLoaded) { const cached = readProjectsSnapshotCache(); if (cached) { _projectsSnapshot = cached.projects; _projectsLoaded = true; renderProjectsFromMemory(); } } if (_projectsLoadController) _projectsLoadController.abort(); const controller = new AbortController(); _projectsLoadController = controller; try { const d = await apiFetch('/api/v1/projects?limit=200', { signal: controller.signal }); if (_projectsLoadController !== controller) return; _projectsSnapshot = d.projects || []; saveProjectsSnapshotCache(_projectsSnapshot); _projectsLoaded = true; if (force) _projectTreeSnapshot.clear(); renderProjectsFromMemory(); } catch (err) { if (err?.name === 'AbortError') return; listEl.innerHTML = `
${t('project_load_failed')}: ${err.message}
`; } finally { if (_projectsLoadController === controller) _projectsLoadController = null; } } function projectCard(p) { const badge = p.status === 'active' ? 'active' : 'paused'; // 工作流名 → 可点击链接,跳转到 Studio 对应工作流(项目为主导,工作流跟随项目) // 后端已把 workflow_ids 与 workflow_names 对齐(悬空引用过滤); // 这里 zip 后按名字过滤兜底,绝不显示假名(如 'Workflow') const wfIds = (p.workflow_ids && p.workflow_ids.length ? p.workflow_ids : []); const wfNames = (p.workflow_names && p.workflow_names.length ? p.workflow_names : (p.workflow_name ? [p.workflow_name] : [])); const wfLinks = wfIds .map((id, i) => ({ id, name: wfNames[i] })) .filter(w => w.name); const wf = wfLinks.length ? ` ${wfLinks.map(w => ` ${w.name}`) .join('·')} ` : `${t('unbound_workflow')}`; // 项目条数: 已上传(累计,含删除)/ 目标 M 条 —— 统计上传过的所有数据 const target = parseInt(p.target_episodes, 10) || 0; const uploaded = parseInt(p.uploaded_total, 10) || 0; const countLabel = target > 0 ? `${uploaded} / ${target} ${t('ep_count')}` : `${uploaded} ${t('ep_count')}`; const newBatches = parseInt(p.new_batch_count, 10) || 0; const reuploads = parseInt(p.reupload_count, 10) || 0; const unclassified = parseInt(p.unclassified_upload_count, 10) || 0; const uploadDetail = ` ${t('upload_new')}: ${newBatches} · ${t('upload_reupload')}: ${reuploads}${unclassified ? ` · ${t('upload_unclassified')}: ${unclassified}` : ''} `; const countBtn = ` `; return `
${escHtml(p.name)}${badge}
${wf} ${p.device_type ? ` ${escHtml(p.device_type)}` : ''} ${(p.observed_inputs && p.observed_inputs.length) ? ` ${t('observed_inputs')}: ${escHtml(p.observed_inputs.join(', '))}` : ''} ${countBtn} ${uploadDetail}
${p.description ? `
${p.description}
` : ''}
`; } function toggleTree(projectId) { if (_expanded.has(projectId)) { _expanded.delete(projectId); loadProjects(); return; } _expanded.add(projectId); loadProjects(); } async function renderTree(projectId) { const el = document.getElementById(`tree-${projectId}`); if (!el) return; el.classList.remove('hidden'); el.innerHTML = `
${t('loading')}
`; try { // 批次列表:报错不在此显示(点击文件后在 Review 详情里看) let d = _projectTreeSnapshot.get(projectId); if (!d) { d = await apiFetch(`/api/v1/projects/${projectId}/tree`); _projectTreeSnapshot.set(projectId, d); } if (!d.tasks || d.tasks.length === 0) { el.innerHTML = `
${t('no_tasks_in_project')}
`; return; } el.innerHTML = d.tasks.map(taskRow).join(''); } catch (err) { el.innerHTML = `
${t('project_load_failed')}: ${err.message}
`; } } function taskRow(task) { // 注意:参数名不能用 t —— 会遮蔽全局 i18n 翻译函数 t() // 一个上传压缩包 = 一个批次 = 一行显示(不再多级嵌套) // 报错不在此显示(点击文件后在 Review 详情里看) const e = (task.episodes || [])[0] || {}; const statusColor = { completed: 'text-green-400', to_review: 'text-yellow-400', reviewed: 'text-blue-400', failed: 'text-red-400' }[e.status] || 'text-gray-400'; const name = task.name || e.name || e.id; const camera = e.camera || ''; const frames = e.frame_count || 0; // 已彻底删除的历史条目(目录已删,记录保留,统计不丢) const isPurged = task.purged || e.status === 'purged'; const statusHtml = isPurged ? `${t('status_purged')}` : `${e.status}`; const nameHtml = isPurged ? `${name}` : `${name}`; return `
${nameHtml} ${camera && !isPurged ? `${camera}` : ''} ${!isPurged ? `${frames} ${t('frame')}` : ''} ${statusHtml}
${task.created_at ? new Date(task.created_at).toLocaleString() : ''}
`; } // ── Project form ───────────────────────────────────────── function showProjectForm() { editingProjectId = null; document.getElementById('project-form-title').textContent = t('new_project'); document.getElementById('pf-name').value = ''; document.getElementById('pf-description').value = ''; document.getElementById('pf-status').value = 'active'; _selectedWorkflowIds = []; document.getElementById('pf-target').value = ''; loadWorkflowsForSelect(); closeWorkflowDropdown(); // 打开表单时下拉收起 document.getElementById('project-form').classList.remove('hidden'); } function hideProjectForm() { document.getElementById('project-form').classList.add('hidden'); } async function editProject(id) { try { const p = await apiFetch(`/api/v1/projects/${id}`); editingProjectId = id; document.getElementById('project-form-title').textContent = t('edit_project') + ': ' + p.name; document.getElementById('pf-name').value = p.name; document.getElementById('pf-description').value = p.description || ''; document.getElementById('pf-status').value = p.status; document.getElementById('pf-target').value = p.target_episodes || ''; _selectedWorkflowIds = (p.workflow_ids || []).slice(); await loadWorkflowsForSelect(); closeWorkflowDropdown(); document.getElementById('project-form').classList.remove('hidden'); } catch (e) { alert(t('project_load_failed') + ': ' + e.message); } } // 表单校验 + 组装请求体;不合法 → alert 并返回 null function buildProjectBody() { const name = document.getElementById('pf-name').value.trim(); if (!name) { alert(t('project_name')); return null; } const targetVal = parseInt(document.getElementById('pf-target').value, 10); const target = Number.isFinite(targetVal) && targetVal > 0 ? targetVal : 0; const status = document.getElementById('pf-status').value; const wfIds = getSelectedWorkflowIds(); // 项目可以先 active 接收数据;没有绑定工作流时只入库不处理, // 绑定并保存工作流后由后端自动回填历史批次。 // device_type 已移除(手工标记被 observed_inputs 自动记录替代); // 编辑时后端只更新 body 里的键,老项目的 device_type 值保留 return { body: { name, workflow_id: wfIds[0] || null, workflow_ids: wfIds, description: document.getElementById('pf-description').value.trim() || null, status: status, params: target > 0 ? { target_episodes: target } : {}, }, wfIds, }; } async function saveProject() { const built = buildProjectBody(); if (!built) return; const isNew = !editingProjectId; try { const saved = isNew ? await apiFetch('/api/v1/projects', { method: 'POST', body: JSON.stringify(built.body) }) : await apiFetch(`/api/v1/projects/${editingProjectId}`, { method: 'PUT', body: JSON.stringify(built.body) }); hideProjectForm(); loadProjects({ force: true }); // 只有新建项目才弹窗提醒并跳转工作流画布(可取消,项目已保存不会丢); // 编辑已有项目只保存,不打扰。画布上可直接用模板或新建工作流。 if (isNew && confirm(t('project_saved_go_build'))) { const q = new URLSearchParams(); if (built.wfIds.length) q.set('workflow', built.wfIds[0]); q.set('project', saved.id); window.location = `/workflow-studio?${q.toString()}`; } } catch (e) { alert(t('project_save_failed') + ': ' + e.message); } } async function deleteProject(id, name) { if (!confirm(t('confirm_delete_project') + name + t('confirm_delete_project_suffix'))) return; try { await apiFetch(`/api/v1/projects/${id}`, { method: 'DELETE' }); _expanded.delete(id); loadProjects({ force: true }); } catch (e) { alert(t('project_delete_failed') + ': ' + e.message); } } // ── Init ───────────────────────────────────────────────── document.addEventListener('DOMContentLoaded', () => { if (document.getElementById('projects-list')) { loadProjects(); } });