diff --git a/backend/flask_app.py b/backend/flask_app.py index dc526d9..dec7d02 100644 --- a/backend/flask_app.py +++ b/backend/flask_app.py @@ -395,7 +395,7 @@ def bootstrap(): "recent": rows(conn, "SELECT * FROM follow_up_records ORDER BY id DESC LIMIT 8"), "risks": [{"title": "执行提醒", "content": x["next_action"]} for x in operations if x["next_action"]][:5], } - return jsonify({"summary": summary, "sales": sales, "proposals": proposals, "operations": operations, "products": products, "finance": finance, "financeMonthly": monthly_finance(conn), "tasks": rows(conn, "SELECT * FROM project_tasks ORDER BY phase, id")}) + return jsonify({"summary": summary, "sales": sales, "proposals": proposals, "operations": operations, "products": products, "finance": finance, "financeMonthly": monthly_finance(conn), "tasks": rows(conn, "SELECT * FROM project_tasks ORDER BY phase, sort_order, id")}) finally: conn.close() @@ -406,7 +406,7 @@ TABLES = { "operations": ("operation_projects", ["project_name", "project_version", "project_type", "project_status", "current_stage", "owner", "target_customer", "customer_need", "expected_contract_amount", "expected_sign_date", "sign_probability", "next_action", "sop_stage", "execution_progress", "current_deliverable", "risks", "notes"]), "products": ("product_versions", ["product_name", "version", "version_goal", "feature_list", "launch_date", "status", "platform", "notes"]), "finance": ("finance_records", ["month", "project_name", "record_type", "category", "amount", "occurred_date", "notes"]), - "tasks": ("project_tasks", ["project_id", "phase", "milestone", "task", "owner", "due_date", "blockers", "notes"]), + "tasks": ("project_tasks", ["project_id", "phase", "milestone", "task", "owner", "due_date", "blockers", "notes", "status", "sort_order"]), } @@ -489,6 +489,19 @@ def delete_followup(followup_id): conn.close() +@app.route("/api/tasks/batch-sort", methods=["POST"]) +def batch_sort_tasks(): + conn = db() + try: + items = request.get_json(force=True).get("items", []) + for item in items: + conn.execute("UPDATE project_tasks SET sort_order=? WHERE id=?", (item["sort_order"], item["id"])) + conn.commit() + return jsonify({"ok": True}) + finally: + conn.close() + + @app.route("/api/files/upload", methods=["POST"]) def upload_file(): file = request.files["file"] diff --git a/static/app.js b/static/app.js index 454f8bf..2d009ea 100644 --- a/static/app.js +++ b/static/app.js @@ -283,11 +283,11 @@ function renderProjectTasks(projectId) { ${phases.map((phase) => { const pt = tasks.filter((t) => t.phase === phase); if (!pt.length) return ""; - return `
${phase}${pt.length}
${pt.map((t) => `
${t.task}${t.notes ? `${t.notes}` : ""}${t.blockers ? `⚠ ${t.blockers}` : ""}
${t.owner || ""}${t.due_date || ""}
`).join("")}
`; + return `
${phase}${pt.length}
${pt.map((t) => `
${t.task}${t.notes ? `${t.notes}` : ""}${t.blockers ? `⚠ ${t.blockers}` : ""}
${t.owner || ""}${t.due_date || ""}
`).join("")}
`; }).join("")}
-
编辑任务
+
编辑任务
@@ -586,6 +586,69 @@ window.deleteOperation = async (id) => { alert("删除失败:" + error.message); } }; +window.toggleTaskDone = async (taskId, projectId) => { + const task = (state.data.tasks || []).find((t) => t.id === taskId); + if (!task) return; + const newStatus = task.status === "done" ? "" : "done"; + try { + await api(`/api/tasks/${taskId}`, { method: "PUT", body: JSON.stringify({ data: { status: newStatus } }) }); + await load(); + } catch (error) { + alert("更新失败:" + error.message); + } +}; +window.deleteTask = async (projectId) => { + const taskId = document.querySelector(`#task-id-${projectId}`).value; + if (!taskId) return; + if (!confirm("确认删除该任务?此操作不可撤销。")) return; + try { + await api(`/api/tasks/${taskId}`, { method: "DELETE" }); + closeTaskDrawer(projectId); + await load(); + } catch (error) { + alert("删除失败:" + error.message); + } +}; +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; + // Find the dragged element and insert after the nearest task + const dragged = document.querySelector(`.task-row[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"); + // Update sort_order in DB + const rows = [...target.querySelectorAll(".task-row")]; + 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-row: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, element: child }; + } + return closest; + }, { offset: Number.NEGATIVE_INFINITY }).element; +} window.closeDrawer = () => document.querySelector("#drawer").classList.remove("open"); window.squireInstances = {}; window.squireCmd = (cmd) => { diff --git a/static/styles.css b/static/styles.css index 8c72665..e6f942e 100644 --- a/static/styles.css +++ b/static/styles.css @@ -543,13 +543,17 @@ td { padding: 1px 7px; border-radius: 10px; } .task-group-list { display: flex; flex-direction: column; } +.task-group-list.drag-over { background: #f0f9ff; } .task-row { display: flex; align-items: center; gap: 16px; padding: 10px 16px; border-top: 1px solid #f1f5f9; - cursor: pointer; + cursor: pointer; transition: background 0.15s; } +.task-row.dragging { opacity: 0.4; background: #f1f5f9; } +.task-row.task-done .task-name { text-decoration: line-through; color: #94a3b8; } .task-row:hover { background: #f8fafc; } -.task-dot { display: flex; color: #cbd5e1; flex-shrink: 0; } +.task-dot { display: flex; color: #cbd5e1; flex-shrink: 0; cursor: pointer; } +.task-dot:hover { color: #6366f1; } .task-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; } .task-name { color: #1e293b; font-size: 13px; } .task-desc { color: #94a3b8; font-size: 12px; word-wrap: break-word; overflow-wrap: break-word; }