Some checks failed
Deploy / deploy (push) Failing after 1s
- 经营管理/重点工作台账卡片改用 .metric-card 类(与首页一致) - 卡片增加 lucide 图标(签约/金额/任务/状态等) - 布局:左对齐、text-2xl、图标+标签
520 lines
25 KiB
JavaScript
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;
|
|
}
|