Files
opc-manager/static/modules/projects.js
mac ec457e9d40 feat: 删除部门模块 + 任务系统重构 + 布局修复
- 删除部门(Home)侧边栏入口、面板及相关代码
- 删除 home.js,chartOptions 等图表工具迁移到 utils.js
- 清理 render()、togglePhase、filterPhaseTasks 等死代码
- 任务列表改为平铺(取消阶段分组),支持拖拽排序
- 任务表单加开始时间字段,重组字段布局为三列
- 任务展开面板改为竖排:时间、任务进展、卡点与备注
- 视图按钮和新增任务按钮移入任务卡片顶部
- 修复 index.html div 嵌套不平衡导致布局塌陷
- 修复 savedTab 校验和 switchTenant 侧边栏 active 同步
- 总览工作台只显示预算/发生总览入口
- 性能面板 id 去重
2026-07-15 14:49:12 +08:00

666 lines
32 KiB
JavaScript

// projects.js — 重点工作与台账(项目管理 + 任务管理)
function applyUserTenants() {
return 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);
});
// 缓存可用工作台列表,供下拉菜单使用
state.allowedTenants = data.tenants || [];
// 如果当前工作台不在权限范围内,选择第一个
if (state.allowedTenants.length > 0 && state.allowedTenants.indexOf(state.tenant) < 0) {
state.tenant = state.allowedTenants[0];
localStorage.setItem("opc-active-tenant", state.tenant);
var label0 = state.tenant.replace("·无界", "");
document.querySelector("#workspaceTitle").textContent = label0 + " OPC 工作台";
}
updateTenantLabel();
});
}
window.toggleTenantMenu = (event) => {
event.stopPropagation();
let menu = document.getElementById("tenantMenu");
if (menu) { menu.remove(); return; }
const btn = event.currentTarget;
const rect = btn.getBoundingClientRect();
const tenants = (state.allowedTenants || []).slice().sort(function(a, b) { var order = ['总览','学术·无界','MCN·无界','科普·无界','医患·无界','科研·无界','测试·无界']; var ai = order.indexOf(a); var bi = order.indexOf(b); if (ai < 0) ai = 99; if (bi < 0) bi = 99; return ai - bi; });
menu = document.createElement("div");
menu.id = "tenantMenu";
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 = `
<div class="px-4 py-2 border-b border-slate-100">
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">切换工作台</p>
</div>
${tenants.map(t => `
<button class="w-full text-left px-4 py-2 text-sm hover:bg-slate-50 transition-colors flex items-center justify-between gap-2 ${t === state.tenant ? 'text-brand-600 font-medium' : 'text-slate-700'}" onclick="switchTenantFromMenu('${t.replace(/'/g, "\\'")}')">
<span>${esc(t)}</span>
${t === state.tenant ? '<i data-lucide="check" style="width:14px;height:14px"></i>' : ''}
</button>
`).join('')}`;
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.switchTenantFromMenu = (tenant) => {
switchTenant(tenant);
};
function updateTenantLabel() {
const tenants = (state.allowedTenants || []).slice().sort(function(a, b) { var order = ['总览','学术·无界','MCN·无界','科普·无界','医患·无界','科研·无界','测试·无界']; var ai = order.indexOf(a); var bi = order.indexOf(b); if (ai < 0) ai = 99; if (bi < 0) bi = 99; return ai - bi; });
var tenantIcons = {
'学术·无界': 'graduation-cap',
'MCN·无界': 'video',
'科普·无界': 'flask-conical',
'医患·无界': 'stethoscope',
'科研·无界': 'microscope',
'测试·无界': 'flask-round',
'总览': 'layout-dashboard'
};
const container = document.querySelector("#tenantList");
if (container) {
var currentShort = (state.tenant || '').replace("·无界", "");
var currentIcon = tenantIcons[state.tenant] || 'circle';
container.innerHTML = '<div class="flex flex-col items-center cursor-pointer hover:bg-slate-800 rounded-lg py-2 px-1 w-14 transition-colors" onclick="toggleTenantMenu(event)" title="切换工作台">' +
'<i data-lucide="' + currentIcon + '" style="width:20px;height:20px;color:var(--brand-400)"></i>' +
'<span class="text-[10px] mt-1 max-w-[56px] truncate text-center text-slate-300">' + esc(currentShort) + '</span>' +
'<i data-lucide="chevron-down" style="width:12px;height:12px;color:#64748b;margin-top:2px"></i>' +
'</div>';
if (window.lucide) window.lucide.createIcons();
}
}
window.toggleTenantMenu = (event) => {
event.stopPropagation();
let menu = document.getElementById("tenantMenu");
if (menu) { menu.remove(); return; }
const btn = event.currentTarget;
const rect = btn.getBoundingClientRect();
const tenants = (state.allowedTenants || []).slice().sort(function(a, b) { var order = ['总览','学术·无界','MCN·无界','科普·无界','医患·无界','科研·无界','测试·无界']; var ai = order.indexOf(a); var bi = order.indexOf(b); if (ai < 0) ai = 99; if (bi < 0) bi = 99; return ai - bi; });
var tenantIcons = {
'学术·无界': 'graduation-cap',
'MCN·无界': 'video',
'科普·无界': 'flask-conical',
'医患·无界': 'stethoscope',
'科研·无界': 'microscope',
'测试·无界': 'flask-round',
'总览': 'layout-dashboard'
};
menu = document.createElement("div");
menu.id = "tenantMenu";
menu.className = "fixed bg-white rounded-lg shadow-xl border border-slate-200 py-1 min-w-[180px] z-[9999]";
menu.style.left = Math.min(rect.left - 8, window.innerWidth - 200) + "px";
menu.style.top = rect.bottom + 6 + "px";
menu.innerHTML = '<div class="px-4 py-2 border-b border-slate-100"><p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">切换工作台</p></div>' +
tenants.map(t => {
const shortName = t.replace("·无界", "");
const isActive = t === state.tenant;
var icon = tenantIcons[t] || 'circle';
return '<button class="w-full text-left px-4 py-2.5 text-sm hover:bg-slate-50 transition-colors flex items-center gap-3 ' + (isActive ? 'text-brand-600 font-medium bg-brand-50' : 'text-slate-700') + '" onclick="switchTenantFromMenu(\'' + t.replace(/'/g, "\\'") + '\')">' +
'<i data-lucide="' + icon + '" style="width:16px;height:16px;color:' + (isActive ? 'var(--brand-600)' : '#94a3b8') + '"></i>' +
'<span class="flex-1">' + esc(shortName) + '</span>' +
(isActive ? '<i data-lucide="check" style="width:14px;height:14px;color:var(--brand-600)"></i>' : '') +
'</button>';
}).join('');
document.body.appendChild(menu);
if (window.lucide) window.lucide.createIcons();
setTimeout(() => {
document.addEventListener("click", function closeMenu() {
menu.remove();
document.removeEventListener("click", closeMenu);
}, { once: true });
}, 10);
};
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 = `
<div class="px-4 py-3 border-b border-slate-100">
<p class="text-sm font-semibold text-slate-800">${esc(user.display_name)}</p>
<p class="text-xs text-slate-500 mt-0.5">${esc(user.username || "")}</p>
</div>
${user.role === 'admin' ? `<button class="w-full text-left px-4 py-2 text-sm text-slate-700 hover:bg-slate-50 transition-colors flex items-center gap-2" onclick="closeUserMenu();openAdminUsers()">
<i data-lucide="users" style="width:14px;height:14px"></i>账号管理
</button>` : ''}
<button class="w-full text-left px-4 py-2 text-sm text-slate-700 hover:bg-slate-50 transition-colors flex items-center gap-2" onclick="closeUserMenu();openChangePwdModal()">
<i data-lucide="lock" style="width:14px;height:14px"></i>修改密码
</button>
<button class="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors flex items-center gap-2" onclick="doLogout()">
<i data-lucide="log-out" style="width:14px;height:14px"></i>退出登录
</button>`;
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.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");
try { state.data.operations = await api("/api/operations/list?tenant=" + encodeURIComponent(state.tenant)); renderProjects(); } catch(e) {}
} 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*/`
<div id="projectHeadCards" class="grid grid-cols-5 gap-3 mb-4">
${[
["项目总数", items.length, "folder"],
["任务总数", taskStats.total, "list-checks"],
["进行中", taskStats.ongoing, "play-circle"],
["已结束", taskStats.done, "check-circle"],
["未开始", taskStats.pending, "circle"],
].map(([label, value, icon]) => `
<div class="metric-card"><span class="flex items-center gap-2 text-xs text-slate-500"><i data-lucide="${icon}" style="width:14px;height:14px"></i>${label}</span><strong class="mt-2 block text-2xl">${value}</strong></div>
`).join("")}
</div>
<div class="project-board">
<div class="project-board-body">
<div class="project-tree">
<div class="project-tree-hd">
<span>项目</span>
<button class="btn btn-ghost btn-sm rounded-full w-7 h-7 p-0" onclick="openNewProjectModal()" title="新增项目">
<i data-lucide="plus" style="width:16px;height:16px"></i>
</button>
</div>
<div class="project-tree-list" id="projectTreeList">
${items.map((x) => `
<div class="project-tree-node ${state.selectedProject === x.id ? 'active' : ''}"
data-id="${x.id}"
onclick="selectProject(${x.id})"
oncontextmenu="showProjectContext(event, ${x.id})">
<span class="project-tree-icon"><i data-lucide="folder"></i></span>
<span class="project-tree-name">${esc(x.project_name)}</span>
</div>
`).join("")}
${items.length === 0 ? '<div class="project-tree-empty">暂无项目</div>' : ''}
</div>
</div>
<div class="task-feed">
<div class="flex justify-between items-center px-4 py-2 border-b border-slate-100 bg-white" style="flex-shrink:0">
<div class="flex items-center gap-1" id="taskViewToggle">
<button class="btn btn-sm ${state.taskView === 'compact' ? 'btn-primary' : 'btn-ghost'} p-1.5" onclick="setTaskView('compact')" title="标题视图"><i data-lucide="list" style="width:16px;height:16px"></i></button>
<button class="btn btn-sm ${state.taskView !== 'compact' ? 'btn-primary' : 'btn-ghost'} p-1.5" onclick="setTaskView('detail')" title="详细视图"><i data-lucide="align-left" style="width:16px;height:16px"></i></button>
</div>
<button class="btn btn-primary btn-sm" onclick="openTaskFormForSelected()">
<i data-lucide="plus"></i>新增任务
</button>
</div>
${state.selectedProject ? '<div class="task-feed-body">' + renderTaskListHTML(state.selectedProject) + '</div>' : `
<div class="project-empty">
<div class="text-center">
<i data-lucide="arrow-left" style="width:32px;height:32px;margin:0 auto 12px;color:#cbd5e1"></i>
<p>请从左侧选择项目查看台账</p>
</div>
</div>`}
</div>
</div>
</div>`;
document.querySelector("#projectContextMenu")?.remove();
const menu = document.createElement("div");
menu.id = "projectContextMenu";
menu.className = "project-context-menu hidden";
menu.innerHTML = `<div class="project-context-item" onclick="openProjectDrawer()"><i data-lucide="info"></i>查看项目详情</div><div class="project-context-item" onclick="renameProject()"><i data-lucide="edit-3"></i>重命名项目</div><div class="project-context-item" onclick="duplicateProject()"><i data-lucide="copy"></i>创建副本</div>`;
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 taskItem = (t) => `<div class="task-item ${t.status === '已结束' ? 'task-done' : ''} ${t.priority === 'P0' ? 'task-p0' : t.priority === 'P1' ? 'task-p1' : ''} ${state.taskView === 'detail' ? 'task-detail' : ''}" data-id="${t.id}" draggable="true" ondragstart="handleTaskDragStart(event, ${t.id})" ondragend="event.currentTarget.classList.remove('dragging')" onclick="openTaskForm(${projectId}, ${t.id})">
<span class="task-grip"><i data-lucide="grip-vertical"></i></span>
<span class="task-status-badge status-${esc(t.status) || '未开始'}" onclick="event.stopPropagation(); cycleTaskStatus(${t.id}, ${projectId})" title="点击切换状态">${esc(t.status) || '未开始'}</span>
<span class="task-priority-badge priority-${(t.priority || 'P2').toLowerCase()}" onclick="event.stopPropagation(); cycleTaskPriority(${t.id}, ${projectId})" title="点击切换优先级">${esc(t.priority) || 'P2'}</span>
<div class="task-content">
<span class="task-title">${esc(t.task)}</span>
${state.taskView === 'detail' && t.notes ? '<span class="task-desc">' + esc(t.notes) + '</span>' : ""}
${state.taskView === 'detail' && t.blockers ? '<span class="task-blocker">\u26a0 ' + esc(t.blockers) + '</span>' : ""}
</div>
<span class="task-meta">${esc(t.owner) || ''}</span>
<span class="task-expand-toggle" onclick="event.stopPropagation();toggleTaskExpand(${t.id})"><i data-lucide="chevron-down" id="tke_icon_${t.id}"></i></span>
</div>
<div class="task-expand-body hidden" id="tke_body_${t.id}">
<div class="flex flex-col gap-3 px-6 py-3 text-sm">
<div><span class="text-slate-400 text-xs">时间</span><p class="text-slate-700 mt-1">${esc(t.start_date) || '—'}${esc(t.due_date) || '—'}</p></div>
<div><span class="text-slate-400 text-xs">任务进展</span><p class="text-slate-700 mt-1">${esc(t.notes) || '—'}</p></div>
<div><span class="text-slate-400 text-xs">卡点与备注</span><p class="${t.blockers ? 'text-red-600' : 'text-slate-700'} mt-1">${t.blockers ? '\u26a0 ' + esc(t.blockers) : '—'}</p></div>
</div>
</div>`;
return `
<div class="task-section-list-wrap">
<div class="task-section-list" ondrop="handleTaskDrop(event, ${projectId}, '')" ondragover="event.preventDefault(); event.currentTarget.classList.add('drag-over')" ondragleave="event.currentTarget.classList.remove('drag-over')">
${tasks.length ? tasks.map(taskItem).join("") : '<div class="task-empty">暂无任务,点击上方按钮创建</div>'}
</div>
</div>
`;
}
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;
const modal = document.getElementById("taskModal");
if (!modal) return;
const task = taskId ? (state.data.tasks || []).find((t) => t.id === taskId) : null;
modal.className = "hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40";
modal.innerHTML = `<div class="bg-white rounded-2xl shadow-2xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto" onclick="event.stopPropagation()">
<div class="sticky top-0 z-10 bg-white/95 backdrop-blur border-b border-slate-100 px-6 py-4 flex items-center justify-between">
<h3 class="text-lg font-bold text-slate-800">${task ? "编辑任务" : "新增任务"}</h3>
<div class="flex items-center gap-2">
${task ? '<button type="button" class="btn btn-ghost btn-sm text-red-600 hover:bg-red-50" id="taskDelBtn"><i data-lucide="trash-2"></i>删除</button>' : ""}
<button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0" id="taskClsBtn"><i data-lucide="x"></i></button>
</div>
</div>
<form class="px-6 py-4 flex flex-col gap-4" onsubmit="event.preventDefault()">
<input type="hidden" name="task_id" value="${task ? task.id : ''}">
<div class="grid grid-cols-2 gap-3">
<label class="task-field"><span>任务名称</span><input name="task" required class="form-ctrl" value="${task ? esc(task.task) : ''}"></label>
<label class="task-field"><span>优先级</span><select name="priority" class="form-ctrl"><option ${task && task.priority === 'P0' ? 'selected' : ''}>P0</option><option ${task && task.priority === 'P1' ? 'selected' : ''}>P1</option><option ${(!task || task.priority === 'P2') ? 'selected' : ''}>P2</option><option ${task && task.priority === 'P3' ? 'selected' : ''}>P3</option></select></label>
</div>
<div class="grid grid-cols-2 gap-3">
<label class="task-field"><span>状态</span><select name="status" class="form-ctrl"><option ${(!task || task.status === '未开始') ? 'selected' : ''}>未开始</option><option ${task && task.status === '进行中' ? 'selected' : ''}>进行中</option><option ${task && task.status === '已结束' ? 'selected' : ''}>已结束</option></select></label>
</div>
<div class="grid grid-cols-3 gap-3">
<label class="task-field"><span>负责人</span><input name="owner" class="form-ctrl" value="${task ? esc(task.owner) : ''}"></label>
<label class="task-field"><span>开始时间</span><input name="start_date" type="date" class="form-ctrl" value="${task ? esc(task.start_date) : ''}"></label>
<label class="task-field"><span>截止时间</span><input name="due_date" type="date" class="form-ctrl" value="${task ? esc(task.due_date) : ''}"></label>
</div>
<label class="task-field"><span>任务进展</span><textarea name="notes" rows="3" class="form-ctrl">${task ? esc(task.notes) : ''}</textarea></label>
<label class="task-field"><span>卡点&备注</span><textarea name="blockers" rows="2" class="form-ctrl" placeholder="风险卡点、依赖项等">${task ? esc(task.blockers) : ''}</textarea></label>
<div class="flex justify-end gap-2 pt-3 border-t border-slate-100">
<button type="button" class="btn btn-ghost btn-sm" id="taskCancelBtn">取消</button>
<button type="submit" class="btn btn-primary btn-sm" id="taskSubmitBtn">${task ? "保存" : "确认新增"}</button>
</div>
</form>
</div>`;
// 绑定事件
var delBtn = document.getElementById("taskDelBtn");
if (delBtn) delBtn.onclick = function() { deleteTask(projectId); };
var clsBtn = document.getElementById("taskClsBtn");
if (clsBtn) clsBtn.onclick = function() { closeTaskDrawer(projectId); };
var cancelBtn = document.getElementById("taskCancelBtn");
if (cancelBtn) cancelBtn.onclick = function() { closeTaskDrawer(projectId); };
var submitBtn = document.getElementById("taskSubmitBtn");
if (submitBtn) submitBtn.onclick = function(e) { e.preventDefault(); submitTaskForm(e, projectId); };
modal.classList.remove("hidden");
if (window.lucide) window.lucide.createIcons();
};
window.closeTaskDrawer = (projectId) => {
const modal = document.getElementById("taskModal");
if (modal) modal.classList.add("hidden");
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.refreshProjectHeadCards = () => {
const container = document.querySelector("#projectHeadCards");
if (!container) return;
const items = state.data.operations || [];
const tasks = state.data.tasks || [];
const stats = {
total: tasks.length,
ongoing: tasks.filter(t => t.status === '进行中').length,
done: tasks.filter(t => t.status === '已结束').length,
pending: tasks.filter(t => t.status === '未开始').length,
};
const cards = [
["项目总数", items.length, "folder"],
["任务总数", stats.total, "list-checks"],
["进行中", stats.ongoing, "play-circle"],
["已结束", stats.done, "check-circle"],
["未开始", stats.pending, "circle"],
];
container.innerHTML = cards.map(([label, value, icon]) => `
<div class="metric-card"><span class="flex items-center gap-2 text-xs text-slate-500"><i data-lucide="${icon}" style="width:14px;height:14px"></i>${label}</span><strong class="mt-2 block text-2xl">${value}</strong></div>
`).join("");
if (window.lucide) window.lucide.createIcons();
};
window.submitTaskForm = async (event, projectId) => {
event.preventDefault();
const form = document.getElementById("taskModal").querySelector("form");
const data = Object.fromEntries(new FormData(form).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);
refreshProjectHeadCards();
} else {
const result = await api("/api/tasks", { method: "POST", body: JSON.stringify({ data }) });
if (result.id && data.task) logActivity("task", result.id, "创建了任务「" + data.task + "」");
if (!state.data.tasks) state.data.tasks = [];
state.data.tasks.push({ id: result.id, ...data });
closeTaskDrawer(projectId);
refreshProjectHeadCards();
}
} 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;
}
}
refreshProjectHeadCards();
} 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.toggleTaskExpand = (taskId) => {
const body = document.getElementById("tke_body_" + taskId);
const icon = document.getElementById("tke_icon_" + taskId);
if (!body || !icon) return;
const isHidden = body.classList.contains("hidden");
if (isHidden) {
body.classList.remove("hidden");
icon.style.transform = "rotate(180deg)";
} else {
body.classList.add("hidden");
icon.style.transform = "";
}
if (window.lucide) window.lucide.createIcons();
};
window.deleteTask = async (projectId) => {
const modal = document.getElementById("taskModal");
if (!modal) return;
const taskId = modal.querySelector('input[name="task_id"]').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();
refreshProjectHeadCards();
} 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;
}