/* 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();
}
});