// projects.js — 重点工作与台账(项目管理 + 任务管理) function applyUserTenants() { fetch("/api/auth/me").then(r => r.json()).then(data => { if (!data.logged_in) { window.location.href = "/login"; return; } const user = data.user; const avatar = document.querySelector("#userAvatar"); avatar.textContent = user.display_name.charAt(0); avatar.title = user.display_name; const nameEl = document.querySelector("#userDisplayName"); if (nameEl) { nameEl.textContent = user.display_name; nameEl.title = user.display_name; } avatar.addEventListener("click", (e) => { e.stopPropagation(); toggleUserMenu(user); }); const allowedTenants = data.tenants || []; document.querySelectorAll(".workspace-nav-item").forEach(el => { el.style.display = allowedTenants.includes(el.dataset.tenant) ? "" : "none"; }); }); } window.toggleUserMenu = (user) => { let menu = document.getElementById("userMenu"); if (menu) { menu.remove(); return; } const avatar = document.querySelector("#userAvatar"); const rect = avatar.getBoundingClientRect(); menu = document.createElement("div"); menu.id = "userMenu"; menu.className = "fixed bg-white rounded-lg shadow-xl border border-slate-200 py-1 min-w-[160px] z-[9999]"; menu.style.left = Math.min(rect.left - 8, window.innerWidth - 180) + "px"; menu.style.top = rect.bottom + 6 + "px"; menu.innerHTML = `

${esc(user.display_name)}

${esc(user.username || "")}

${user.role === 'admin' ? `` : ''} `; document.body.appendChild(menu); if (window.lucide) lucide.createIcons(); setTimeout(() => { document.addEventListener("click", function closeMenu() { menu.remove(); document.removeEventListener("click", closeMenu); }, { once: true }); }, 10); }; window.closeUserMenu = () => { const m = document.getElementById("userMenu"); if (m) m.remove(); }; window.selectProject = (id) => { state.selectedProject = id; document.querySelectorAll(".project-tree-node").forEach((el) => el.classList.toggle("active", parseInt(el.dataset.id) === id)); renderProjectTasks(id); }; window.togglePhase = (phaseId) => { const wrap = document.querySelector(`#${phaseId}`); if (!wrap) return; wrap.classList.toggle("collapsed"); const toggle = document.querySelector(`#${phaseId}-toggle`); if (toggle) toggle.style.transform = wrap.classList.contains("collapsed") ? "rotate(-90deg)" : ""; }; window.showProjectContext = (event, id) => { event.preventDefault(); event.stopPropagation(); const menu = document.querySelector("#projectContextMenu"); if (!menu) return; menu.dataset.projectId = id; menu.style.left = event.clientX + "px"; menu.style.top = event.clientY + "px"; menu.classList.remove("hidden"); }; window.openProjectDrawer = () => { const menu = document.querySelector("#projectContextMenu"); if (menu) { const id = parseInt(menu.dataset.projectId); if (id) openDrawer("operations", id); } }; window.renameProject = async () => { const menu = document.querySelector("#projectContextMenu"); if (!menu) return; const id = parseInt(menu.dataset.projectId); if (!id) return; const project = (state.data.operations || []).find(x => x.id === id); if (!project) return; const newName = prompt("请输入新的项目名称:", project.project_name); if (!newName || newName.trim() === project.project_name) return; try { await api(`/api/operations/${id}`, { method: "PUT", body: JSON.stringify({ data: { project_name: newName.trim() } }) }); project.project_name = newName.trim(); renderProjects(); toast("已重命名", "success"); } catch (error) { toast("重命名失败:" + error.message, "error"); } }; window.duplicateProject = async () => { const menu = document.querySelector("#projectContextMenu"); if (!menu) return; const id = parseInt(menu.dataset.projectId); if (!id) return; const project = (state.data.operations || []).find(x => x.id === id); if (!project) return; const newName = prompt("请输入副本项目名称:", project.project_name + " - 副本"); if (!newName) return; try { const result = await api("/api/operations", { method: "POST", body: JSON.stringify({ data: { project_name: newName.trim(), project_version: project.project_version || "v1.0", project_type: project.project_type || "opportunity", project_status: project.project_status || "", current_stage: project.current_stage || "", owner: project.owner || "慰心", target_customer: project.target_customer || "", customer_need: project.customer_need || "", expected_contract_amount: project.expected_contract_amount || 0, expected_sign_date: project.expected_sign_date || "", sign_probability: project.sign_probability || 0, next_action: project.next_action || "", sop_stage: project.sop_stage || "", execution_progress: project.execution_progress || 0, current_deliverable: project.current_deliverable || "", risks: project.risks || "", notes: project.notes || "", tenant: state.tenant, }}), }); // 复制任务 const tasks = (state.data.tasks || []).filter(t => t.project_id === id); for (const t of tasks) { await api("/api/tasks", { method: "POST", body: JSON.stringify({ data: { project_id: result.id, phase: t.phase || "", milestone: t.milestone || "", task: t.task || "", owner: t.owner || "", due_date: t.due_date || "", blockers: t.blockers || "", notes: t.notes || "", status: "未开始", priority: t.priority || "P2", sort_order: t.sort_order || 0, tenant: state.tenant, }}), }); } toast("已创建副本", "success"); await load(); } catch (error) { toast("创建副本失败:" + error.message, "error"); } }; window.hideProjectContext = () => { const menu = document.querySelector("#projectContextMenu"); if (menu) menu.classList.add("hidden"); }; window.openNewProjectModal = () => { document.querySelector("#newProjectModal").classList.remove("hidden"); }; window.closeNewProjectModal = () => { document.querySelector("#newProjectModal").classList.add("hidden"); }; function renderProjects() { const items = state.data.operations; if (!state.selectedProject && items.length > 0) { state.selectedProject = items[0].id; } const tasks = state.data.tasks || []; const taskStats = { total: tasks.length, ongoing: tasks.filter(t => t.status === '进行中').length, done: tasks.filter(t => t.status === '已结束').length, pending: tasks.filter(t => t.status === '未开始').length, }; document.querySelector("#projects").innerHTML = /*html*/`
${[ ["项目总数", items.length, "folder"], ["任务总数", taskStats.total, "list-checks"], ["进行中", taskStats.ongoing, "play-circle"], ["已结束", taskStats.done, "check-circle"], ["未开始", taskStats.pending, "circle"], ].map(([label, value, icon]) => `
${label}${value}
`).join("")}
项目
${items.map((x) => `
${esc(x.project_name)}
`).join("")} ${items.length === 0 ? '
暂无项目
' : ''}
${state.selectedProject ? '
' + renderTaskListHTML(state.selectedProject) + '
' : `

请从左侧选择项目查看台账

`}
`; document.querySelector("#projectContextMenu")?.remove(); const menu = document.createElement("div"); menu.id = "projectContextMenu"; menu.className = "project-context-menu hidden"; menu.innerHTML = `
查看项目详情
重命名项目
创建副本
`; document.body.appendChild(menu); document.removeEventListener("click", hideProjectContext); document.addEventListener("click", hideProjectContext); if (state.selectedProject) renderProjectTasks(state.selectedProject); if (window.lucide) window.lucide.createIcons(); } function filterPhaseTasks(tasks, phase) { return tasks.filter((t) => t.phase === phase); } function renderTaskListHTML(projectId) { const project = state.data.operations.find((x) => x.id === projectId); if (!project) return ""; const tasks = (state.data.tasks || []).filter((t) => t.project_id === projectId); const filtered = tasks; const defaultPhases = ["商务洽谈", "系统上线", "团队分工", "项目交付", "上线推广", "结项验收"]; const customPhases = [...new Set(filtered.map(t => t.phase).filter(Boolean))]; const phaseOrder = [...defaultPhases]; customPhases.forEach(p => { if (!phaseOrder.includes(p)) phaseOrder.push(p); }); const phases = phaseOrder.filter(p => filterPhaseTasks(filtered, p).length > 0); const phaseTasks = phases.map(p => ({ phase: p, tasks: filterPhaseTasks(filtered, p) })); return ` ${phaseTasks.map(({ phase, tasks: pt }) => { if (!pt.length) return ""; const phaseId = "phase-" + projectId + "-" + phase.replace(/\s/g, ""); return `
${pt.length}
${pt.map((t) => `
${esc(t.status) || '未开始'} ${esc(t.priority) || 'P2'}
${esc(t.task)} ${state.taskView === 'detail' && t.notes ? '' + esc(t.notes) + '' : ""} ${state.taskView === 'detail' && t.blockers ? '\u26a0 ' + esc(t.blockers) + '' : ""}
${esc(t.owner) || ''} ${esc(t.due_date) || ''}
`).join("")}
`; }).join("")} ${filtered.length === 0 ? '
暂无任务,点击上方按钮创建
' : ''} `; } function renderProjectTasks(projectId) { const project = state.data.operations.find((x) => x.id === projectId); if (!project) { state.selectedProject = null; renderProjects(); return; } const tasks = (state.data.tasks || []).filter((t) => t.project_id === projectId); const defaultPhases = ["商务洽谈", "系统上线", "团队分工", "项目交付", "上线推广", "结项验收"]; const customPhases = [...new Set(tasks.map(t => t.phase).filter(Boolean))]; const phases = [...new Set([...defaultPhases, ...customPhases])]; const body = document.querySelector(".task-feed-body"); if (body) body.innerHTML = renderTaskListHTML(projectId); if (window.lucide) window.lucide.createIcons(); } window.openTaskFormForSelected = () => { openTaskForm(state.selectedProject, null); }; window.openTaskForm = (projectId, taskId) => { if (!projectId) return; // 确保 drawer 存在 let drawer = document.querySelector(`#task-drawer-${projectId}`); if (!drawer) { drawer = document.createElement("div"); drawer.id = `task-drawer-${projectId}`; drawer.className = "task-drawer"; document.body.appendChild(drawer); } const tasks = (state.data.tasks || []).filter((t) => t.project_id === projectId); const defaultPhases = ["商务洽谈", "系统上线", "团队分工", "项目交付", "上线推广", "结项验收"]; const customPhases = [...new Set(tasks.map(t => t.phase).filter(Boolean))]; const phases = [...new Set([...defaultPhases, ...customPhases])]; const task = taskId ? (state.data.tasks || []).find((t) => t.id === taskId) : null; drawer.innerHTML = `
${task ? "编辑任务" : "新增任务"}
${task ? `` : ""}
`; drawer.classList.add("open"); if (window.lucide) window.lucide.createIcons(); }; window.closeTaskDrawer = (projectId) => { const drawer = document.querySelector(`#task-drawer-${projectId}`); if (drawer) drawer.classList.remove("open"); refreshTaskList(projectId); }; window.refreshTaskList = (projectId) => { const body = document.querySelector(".task-feed-body"); if (body && state.selectedProject === projectId) { body.innerHTML = renderTaskListHTML(projectId); if (window.lucide) window.lucide.createIcons(); } }; window.submitTaskForm = async (event, projectId) => { event.preventDefault(); const data = Object.fromEntries(new FormData(event.currentTarget).entries()); data.project_id = Number(projectId); data.tenant = state.tenant; const taskId = data.task_id; delete data.task_id; try { if (taskId) { await api(`/api/tasks/${taskId}`, { method: "PUT", body: JSON.stringify({ data }) }); const task = (state.data.tasks || []).find(t => t.id === parseInt(taskId)); if (task) Object.assign(task, data); if (data.task) logActivity("task", taskId, "更新了任务「" + data.task + "」"); closeTaskDrawer(projectId); } else { const result = await api("/api/tasks", { method: "POST", body: JSON.stringify({ data }) }); if (result.id && data.task) logActivity("task", result.id, "创建了任务「" + data.task + "」"); closeTaskDrawer(projectId); await load(); } } catch (error) { toast("保存失败:" + error.message, "error"); } }; window.cycleTaskStatus = async (taskId, projectId) => { const tasks = state.data.tasks || []; const task = tasks.find((t) => t.id === taskId); if (!task) return; const statuses = ["未开始", "进行中", "已结束"]; const current = statuses.indexOf(task.status) >= 0 ? task.status : "未开始"; const newStatus = statuses[(statuses.indexOf(current) + 1) % statuses.length]; try { await api(`/api/tasks/${taskId}`, { method: "PUT", body: JSON.stringify({ data: { status: newStatus } }) }); task.status = newStatus; const row = document.querySelector(`.task-item[data-id="${taskId}"]`); if (row) { row.classList.toggle("task-done", newStatus === "已结束"); const badge = row.querySelector(".task-status-badge"); if (badge) { badge.textContent = newStatus; badge.className = "task-status-badge status-" + newStatus; } } } catch (error) { toast("更新失败:" + error.message, "error"); } }; window.cycleTaskPriority = async (taskId, projectId) => { const tasks = state.data.tasks || []; const task = tasks.find((t) => t.id === taskId); if (!task) return; const priorities = ["P0", "P1", "P2", "P3"]; const current = priorities.indexOf(task.priority) >= 0 ? task.priority : "P2"; const newPriority = priorities[(priorities.indexOf(current) + 1) % priorities.length]; try { await api(`/api/tasks/${taskId}`, { method: "PUT", body: JSON.stringify({ data: { priority: newPriority } }) }); task.priority = newPriority; const row = document.querySelector(`.task-item[data-id="${taskId}"]`); if (row) { row.classList.remove("task-p0", "task-p1"); if (newPriority === "P0") row.classList.add("task-p0"); else if (newPriority === "P1") row.classList.add("task-p1"); const badge = row.querySelector(".task-priority-badge"); if (badge) { badge.textContent = newPriority; badge.className = "task-priority-badge priority-" + newPriority.toLowerCase(); } } } catch (error) { toast("更新失败:" + error.message, "error"); } }; window.deleteTask = async (projectId) => { const taskId = document.querySelector(`#task-id-${projectId}`).value; if (!taskId) return; if (!confirm("确认删除该任务?此操作不可撤销。")) return; try { const task = (state.data.tasks || []).find(t => t.id === parseInt(taskId)); const taskName = task ? task.task : ""; await api(`/api/tasks/${taskId}`, { method: "DELETE" }); if (taskName) logActivity("task", taskId, "删除了任务「" + taskName + "」"); closeTaskDrawer(projectId); state.data.tasks = (state.data.tasks || []).filter(t => t.id !== parseInt(taskId)); const row = document.querySelector(`.task-item[data-id="${taskId}"]`); if (row) row.remove(); } catch (error) { toast("删除失败:" + error.message, "error"); } }; // 拖拽排序 let dragTaskId = null; window.handleTaskDragStart = (event, taskId) => { dragTaskId = taskId; event.currentTarget.classList.add("dragging"); event.dataTransfer.effectAllowed = "move"; }; window.handleTaskDrop = async (event, projectId, phase) => { event.preventDefault(); event.currentTarget.classList.remove("drag-over"); const target = event.currentTarget; if (!dragTaskId) return; const dragged = document.querySelector(`.task-item[data-id="${dragTaskId}"]`); if (!dragged) return; const afterElement = getDragAfterElement(target, event.clientY); if (afterElement) { target.insertBefore(dragged, afterElement); } else { target.appendChild(dragged); } dragged.classList.remove("dragging"); const rows = [...target.querySelectorAll(".task-item")]; const updates = rows.map((row, i) => ({ id: parseInt(row.dataset.id), sort_order: i })); try { await api(`/api/tasks/batch-sort`, { method: "POST", body: JSON.stringify({ items: updates }) }); } catch (e) { /* non-critical */ } dragTaskId = null; }; function getDragAfterElement(container, y) { const elements = [...container.querySelectorAll(".task-item:not(.dragging)")]; return elements.reduce((closest, child) => { const box = child.getBoundingClientRect(); const offset = y - box.top - box.height / 2; if (offset < 0 && offset > closest.offset) { return { offset: offset, element: child }; } else { return closest; } }, { offset: Number.NEGATIVE_INFINITY }).element; }