Files
opc-manager/static/modules/projects.js
mac 361359ee32
Some checks failed
Deploy / deploy (push) Failing after 1s
统计卡片统一为 metric-card 样式 + 增加图标
- 经营管理/重点工作台账卡片改用 .metric-card 类(与首页一致)
- 卡片增加 lucide 图标(签约/金额/任务/状态等)
- 布局:左对齐、text-2xl、图标+标签
2026-06-23 22:49:55 +08:00

520 lines
25 KiB
JavaScript

// 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 = `
<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-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.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*/`
<div 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="flex justify-between items-center mb-3">
<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>
<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">
${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 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 `<div class="task-section">
<div class="task-section-hd" onclick="togglePhase('${phaseId}')">
<span class="task-section-toggle" id="${phaseId}-toggle"><i data-lucide="chevron-down"></i></span>
<span class="task-section-icon"><i data-lucide="layers"></i></span>
<span class="task-section-label">${phase}</span>
<span class="task-section-n">${pt.length}</span>
</div>
<div class="task-section-list-wrap" id="${phaseId}">
<div class="task-section-list" data-phase="${phase}" ondrop="handleTaskDrop(event, ${projectId}, '${phase}')" ondragover="event.preventDefault(); event.currentTarget.classList.add('drag-over')" ondragleave="event.currentTarget.classList.remove('drag-over')">
${pt.map((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')">
<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" onclick="openTaskForm(${projectId}, ${t.id})">
<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-meta text-slate-400">${esc(t.due_date) || ''}</span>
</div>`).join("")}
</div>
</div>
</div>`;
}).join("")}
${filtered.length === 0 ? '<div class="task-empty">暂无任务,点击上方按钮创建</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;
// 确保 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 = `<div class="task-drawer-hd"><span class="task-drawer-title">${task ? "编辑任务" : "新增任务"}</span><div class="flex items-center gap-2">${task ? `<button type="button" class="btn btn-ghost btn-sm text-red-600 hover:bg-red-50" onclick="deleteTask(${projectId})"><i data-lucide="trash-2"></i>删除</button>` : ""}<button class="task-close" onclick="closeTaskDrawer(${projectId})"><i data-lucide="x"></i></button></div></div>
<form class="task-drawer-form" onsubmit="submitTaskForm(event, ${projectId})">
<input type="hidden" name="task_id" id="task-id-${projectId}" value="${task ? task.id : ''}">
<div class="task-field-row">
<label class="task-field"><span>任务名称</span><input name="task" required id="task-name-${projectId}" class="form-ctrl" value="${task ? esc(task.task) : ''}"></label>
<label class="task-field"><span>任务分组</span><select name="phase" id="task-phase-${projectId}" class="form-ctrl">${phases.map((p) => `<option ${task && task.phase === p ? "selected" : ""}>${p}</option>`).join("")}</select></label>
</div>
<div class="task-field-row">
<label class="task-field"><span>优先级</span><select name="priority" id="task-priority-${projectId}" 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>
<label class="task-field"><span>状态</span><select name="status" id="task-status-${projectId}" 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="task-field-row">
<label class="task-field"><span>负责人</span><input name="owner" id="task-owner-${projectId}" class="form-ctrl" value="${task ? esc(task.owner) : ''}"></label>
<label class="task-field"><span>截止时间</span><input name="due_date" type="date" id="task-due-${projectId}" class="form-ctrl" value="${task ? esc(task.due_date) : ''}"></label>
</div>
<label class="task-field"><span>任务说明</span><textarea name="notes" rows="3" id="task-notes-${projectId}" class="form-ctrl">${task ? esc(task.notes) : ''}</textarea></label>
<label class="task-field"><span>卡点&备注</span><textarea name="blockers" rows="2" id="task-blockers-${projectId}" class="form-ctrl" placeholder="">${task ? esc(task.blockers) : ''}</textarea></label>
<div class="flex justify-end gap-2 mt-4 pt-3 border-t border-slate-100">
<button type="button" class="btn btn-ghost btn-sm" onclick="closeTaskDrawer(${projectId})">取消</button>
<button type="submit" class="btn btn-primary btn-sm" id="task-submit-btn-${projectId}">${task ? "保存" : "确认新增"}</button>
</div>
</form>`;
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;
}