Spaces:
Running
Running
Download processor/web/static/js/tasks.js from Stouching-Robotics/DataFlowing: direct link, hf CLI and curl.
- Browser
- Download file 24 kB
-
https://huggingface.co/spaces/Stouching-Robotics/DataFlowing/resolve/main/processor/web/static/js/tasks.js
- Command line
-
hf download hf://spaces/Stouching-Robotics/DataFlowing/processor/web/static/js/tasks.js
-
curl -L -o tasks.js https://huggingface.co/spaces/Stouching-Robotics/DataFlowing/resolve/main/processor/web/static/js/tasks.js
24 kB
| /* 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, '>').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 | |
| ? `<input id="wf-new-name" type="text" maxlength="128" placeholder="${t('new_workflow_prompt')}" | |
| class="w-full bg-gray-800 border border-blue-600 rounded px-2 py-1.5 text-xs text-gray-200 placeholder-gray-500 focus:outline-none" | |
| onkeydown="if(event.key==='Enter'){commitWorkflowName(this)}else if(event.key==='Escape'){cancelWorkflowNameInput()}" | |
| onblur="if(document.getElementById('wf-new-name')){setTimeout(()=>{cancelWorkflowNameInput()},120)}">` | |
| : `<div class="flex items-center gap-2 px-2 py-1.5 cursor-pointer hover:bg-gray-800 rounded text-xs text-blue-400 font-medium" | |
| onclick="startWorkflowNameInput()"> | |
| <iconify-icon icon="ant-design:plus-outlined" class="icon-sm shrink-0"></iconify-icon> | |
| <span>${t('new_workflow_panel')}</span> | |
| </div>`; | |
| const listHtml = d.workflows.length === 0 | |
| ? `<div class="text-xs text-gray-600 px-2 py-1.5">${t('workflow_placeholder')}</div>` | |
| : d.workflows.map(w => { | |
| const checked = _selectedWorkflowIds.includes(w.id); | |
| return ` | |
| <div class="wf-row flex items-center gap-2 px-2 py-1.5 cursor-pointer hover:bg-gray-800 rounded text-xs text-gray-300" | |
| data-id="${w.id}" onclick="toggleWorkflowRow(this)"> | |
| <span class="wf-check w-3.5 h-3.5 rounded border flex items-center justify-center text-[9px] leading-none shrink-0 | |
| ${checked ? 'bg-blue-600 border-blue-500 text-white' : 'border-gray-600 text-transparent'}">✓</span> | |
| <span class="truncate flex-1">${escHtml(w.name)}${w.is_preset ? `<span class="text-[9px] text-yellow-500 border border-yellow-800 rounded px-1 ml-1 align-middle">${t('template_badge')}</span>` : ''}${w.node_count === 0 ? `<span class="text-[9px] text-yellow-500 border border-yellow-800 rounded px-1 ml-1 align-middle">⚠ ${t('workflow_empty')}</span>` : ''}</span> | |
| <a href="/workflow-studio?workflow=${w.id}${w.project_id ? `&project=${encodeURIComponent(w.project_id)}` : ''}" onclick="event.stopPropagation()" title="Edit workflow" | |
| class="text-blue-400 hover:text-blue-300 shrink-0 text-[12px]"> | |
| <iconify-icon icon="ant-design:edit-outlined" class="icon-sm"></iconify-icon> | |
| </a> | |
| </div>`; | |
| }).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 = `<div class="text-center text-gray-600 text-sm py-8">${t('no_projects')}</div>`; | |
| 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 = `<div class="text-center text-red-400 text-sm py-8">${t('project_load_failed')}: ${err.message}</div>`; | |
| } finally { | |
| if (_projectsLoadController === controller) _projectsLoadController = null; | |
| } | |
| } | |
| function projectCard(p) { | |
| const badge = p.status === 'active' | |
| ? '<span class="text-green-400 text-xs border border-green-800 rounded px-1.5 py-0.5">active</span>' | |
| : '<span class="text-gray-500 text-xs border border-gray-700 rounded px-1.5 py-0.5">paused</span>'; | |
| // 工作流名 → 可点击链接,跳转到 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 | |
| ? `<span class="text-xs flex items-center gap-1 flex-wrap"> | |
| <iconify-icon icon="ant-design:deployment-unit-outlined" class="icon-sm text-blue-400"></iconify-icon> | |
| ${wfLinks.map(w => ` | |
| <a href="/workflow-studio?workflow=${w.id}&project=${p.id}" | |
| onclick="event.stopPropagation()" title="Edit workflow" | |
| class="text-blue-400 hover:underline">${w.name}</a>`) | |
| .join('<span class="text-gray-600">·</span>')} | |
| </span>` | |
| : `<span class="text-xs text-gray-600">${t('unbound_workflow')}</span>`; | |
| // 项目条数: 已上传(累计,含删除)/ 目标 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 = ` | |
| <span class="text-gray-600 text-[11px]" title="${t('upload_total')}: ${uploaded}"> | |
| ${t('upload_new')}: ${newBatches} · ${t('upload_reupload')}: ${reuploads}${unclassified ? ` · ${t('upload_unclassified')}: ${unclassified}` : ''} | |
| </span>`; | |
| const countBtn = ` | |
| <button onclick="event.stopPropagation(); editProject('${p.id}')" | |
| title="${t('edit_project')} (${t('target_episodes')}: ${target > 0 ? target : t('target_unlimited')})" | |
| class="hover:text-blue-400"> | |
| <iconify-icon icon="ant-design:inbox-outlined" class="icon-sm"></iconify-icon> ${countLabel} | |
| </button>`; | |
| return ` | |
| <div class="bg-gray-900 rounded-lg border border-gray-800 overflow-hidden"> | |
| <div class="p-4 cursor-pointer hover:bg-gray-800 transition-colors" onclick="toggleTree('${p.id}')"> | |
| <div class="flex items-center justify-between"> | |
| <div class="flex items-center gap-3"> | |
| <iconify-icon icon="ant-design:folder-outlined" class="text-xl text-blue-500"></iconify-icon> | |
| <div> | |
| <div class="flex items-center gap-2"> | |
| <span class="font-medium text-gray-200">${escHtml(p.name)}</span>${badge} | |
| </div> | |
| <div class="flex items-center gap-3 mt-1 text-gray-500 text-xs"> | |
| ${wf} | |
| ${p.device_type ? `<span><iconify-icon icon="ant-design:video-camera-outlined" class="icon-sm"></iconify-icon> ${escHtml(p.device_type)}</span>` : ''} | |
| ${(p.observed_inputs && p.observed_inputs.length) | |
| ? `<span class="text-gray-600 text-[11px]"><iconify-icon icon="ant-design:api-outlined" class="icon-sm"></iconify-icon> ${t('observed_inputs')}: ${escHtml(p.observed_inputs.join(', '))}</span>` : ''} | |
| <span class="text-gray-400">${countBtn}</span> | |
| ${uploadDetail} | |
| </div> | |
| ${p.description ? `<div class="text-gray-600 text-xs mt-1">${p.description}</div>` : ''} | |
| </div> | |
| </div> | |
| <div class="flex items-center gap-2"> | |
| <button onclick="event.stopPropagation(); editProject('${p.id}')" | |
| class="text-gray-500 hover:text-blue-400 text-xs px-2 py-1 rounded hover:bg-gray-800"><iconify-icon icon="ant-design:edit-outlined" class="icon-sm"></iconify-icon> ${t('edit_project')}</button> | |
| <button onclick="event.stopPropagation(); deleteProject('${p.id}', '${p.name}')" | |
| class="text-gray-500 hover:text-red-400 text-xs px-2 py-1 rounded hover:bg-gray-800"><iconify-icon icon="ant-design:delete-outlined" class="icon-sm"></iconify-icon> ${t('delete_project')}</button> | |
| <iconify-icon icon="ant-design:down-outlined" class="text-gray-500 ${_expanded.has(p.id) ? 'rotate-180' : ''}" style="transition: transform .15s"></iconify-icon> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="tree-${p.id}" class="hidden border-t border-gray-800"></div> | |
| </div>`; | |
| } | |
| 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 = `<div class="text-center text-gray-600 text-sm py-4">${t('loading')}</div>`; | |
| 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 = `<div class="text-center text-gray-600 text-sm py-4">${t('no_tasks_in_project')}</div>`; | |
| return; | |
| } | |
| el.innerHTML = d.tasks.map(taskRow).join(''); | |
| } catch (err) { | |
| el.innerHTML = `<div class="text-center text-red-400 text-sm py-4">${t('project_load_failed')}: ${err.message}</div>`; | |
| } | |
| } | |
| 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 | |
| ? `<span class="text-xs text-gray-600 shrink-0" title="${t('status_purged')}">${t('status_purged')}</span>` | |
| : `<span class="text-xs ${statusColor} shrink-0">${e.status}</span>`; | |
| const nameHtml = isPurged | |
| ? `<span class="text-sm text-gray-600 truncate" title="${t('status_purged')}">${name}</span>` | |
| : `<a href="/review?search=${encodeURIComponent(name)}" class="text-sm text-gray-300 hover:text-blue-400 truncate">${name}</a>`; | |
| return ` | |
| <div class="px-4 py-2.5 border-b border-gray-800 last:border-0 flex items-center justify-between gap-3 ${isPurged ? 'opacity-70' : ''}"> | |
| <div class="flex items-center gap-2 min-w-0"> | |
| <iconify-icon icon="ant-design:delete-outlined" class="text-gray-700 shrink-0"></iconify-icon> | |
| ${nameHtml} | |
| ${camera && !isPurged ? `<span class="text-xs text-gray-600 shrink-0">${camera}</span>` : ''} | |
| ${!isPurged ? `<span class="text-xs text-gray-600 shrink-0">${frames} ${t('frame')}</span>` : ''} | |
| ${statusHtml} | |
| </div> | |
| <span class="text-xs text-gray-600 shrink-0">${task.created_at ? new Date(task.created_at).toLocaleString() : ''}</span> | |
| </div>`; | |
| } | |
| // ── 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(); | |
| } | |
| }); | |