From ec457e9d40c4e80fbb51595276d932c6589a2392 Mon Sep 17 00:00:00 2001 From: mac Date: Wed, 15 Jul 2026 14:49:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=A0=E9=99=A4=E9=83=A8=E9=97=A8?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=20+=20=E4=BB=BB=E5=8A=A1=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E9=87=8D=E6=9E=84=20+=20=E5=B8=83=E5=B1=80=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除部门(Home)侧边栏入口、面板及相关代码 - 删除 home.js,chartOptions 等图表工具迁移到 utils.js - 清理 render()、togglePhase、filterPhaseTasks 等死代码 - 任务列表改为平铺(取消阶段分组),支持拖拽排序 - 任务表单加开始时间字段,重组字段布局为三列 - 任务展开面板改为竖排:时间、任务进展、卡点与备注 - 视图按钮和新增任务按钮移入任务卡片顶部 - 修复 index.html div 嵌套不平衡导致布局塌陷 - 修复 savedTab 校验和 switchTenant 侧边栏 active 同步 - 总览工作台只显示预算/发生总览入口 - 性能面板 id 去重 --- backend/routes.py | 33 +++++ static/app.js | 5 +- static/modules/finance.js | 10 ++ static/modules/home.js | 248 ------------------------------------- static/modules/products.js | 8 +- static/modules/projects.js | 215 ++++++++++++++++++-------------- static/modules/utils.js | 46 ++++--- static/styles.css | 22 +++- templates/index.html | 11 +- 9 files changed, 227 insertions(+), 371 deletions(-) delete mode 100644 static/modules/home.js diff --git a/backend/routes.py b/backend/routes.py index 895990c..edd2959 100644 --- a/backend/routes.py +++ b/backend/routes.py @@ -525,6 +525,39 @@ def finance_yearly(): conn.close() +@bp.route("/api/finance/copy-to-plan/", methods=["POST"]) +@login_required +def copy_finance_to_plan(source_id): + """将已发生模块的项目复制到预算模块""" + table, cols = TABLES["planFinances"] + conn = db() + try: + src = one(conn, "SELECT * FROM project_finances WHERE id=?", [source_id]) + if not src: + return jsonify({"error": "源项目不存在"}), 404 + # 构建目标数据:customer_name 前加"副本-" + data = {} + for col in cols: + if col == "customer_name": + data[col] = "副本-" + (src.get("customer_name") or "") + elif col == "project_type": + data[col] = "待签约" + elif col == "weight": + data[col] = "20%" + elif col == "created_at" or col == "updated_at": + data[col] = "" + else: + data[col] = src.get(col, "") + values = [data.get(col, "") for col in cols] + cur = _exec(conn, + f"INSERT INTO {table} ({','.join(cols)}) VALUES ({','.join(['?']*len(cols))})", + values) + conn.commit() + return jsonify({"ok": True, "id": cur.lastrowid}) + finally: + conn.close() + + # ---------- 通用 CRUD ---------- @bp.route("/api//list", methods=["GET"]) diff --git a/static/app.js b/static/app.js index fc15257..f2c89bb 100644 --- a/static/app.js +++ b/static/app.js @@ -6,8 +6,11 @@ applyUserTenants().then(() => { return load(); }).then(() => { const savedTab = localStorage.getItem("opc-active-tab"); - if (savedTab && savedTab !== "home") { + const validTabs = ["plan", "finance", "projects", "proposals", "products", "performance", "overview_plan", "overview_finance"]; + if (savedTab && validTabs.includes(savedTab) && document.getElementById(savedTab)) { switchTab(savedTab); + } else { + switchTab(state.active); } }).catch((error) => { document.querySelector("main").innerHTML = `
加载失败:${esc(error.message)}
`; diff --git a/static/modules/finance.js b/static/modules/finance.js index 2dad419..7ecf0bd 100644 --- a/static/modules/finance.js +++ b/static/modules/finance.js @@ -1370,6 +1370,8 @@ window.finShowCtx = function(e, pfId) { FM_COLORS.map(function(c) { return '
' + c.label + '
'; }).join('') + + '
' + + '
📋复制项目到预算
' + '
' + '
取消标记
'; menu.addEventListener('click', function(ev) { @@ -1379,6 +1381,14 @@ window.finShowCtx = function(e, pfId) { var act = target.getAttribute('data-act'); var id = menu.getAttribute('data-pfid'); menu.style.display = 'none'; + if (act === 'copy_plan') { + if (!confirm('确认将此项目复制到预算模块?\n\n项目名称前将自动添加「副本-」前缀。')) return; + fetch('/api/finance/copy-to-plan/' + id, { method: 'POST' }).then(r => r.json()).then(d => { + if (d.ok) { alert('复制成功!请切换到预算模块查看。'); renderFinance(); } + else { alert('复制失败:' + (d.error || '未知错误')); } + }).catch(function() { alert('复制失败,请检查网络连接。'); }); + return; + } if (act === 'clear') { delete state._fmMarks.color[id]; } else { diff --git a/static/modules/home.js b/static/modules/home.js deleted file mode 100644 index 256e340..0000000 --- a/static/modules/home.js +++ /dev/null @@ -1,248 +0,0 @@ -// home.js — 首页渲染 + 财务趋势图 - -function renderHome() { - const { summary, financeMonthly } = state.data; - const m = summary.metrics; - const moneyInt = (v) => `${Math.round(Number(v || 0)).toLocaleString("zh-CN")}`; - // 回款提醒 - const yrRev = m.revenue_annual || 0, yrPay = m.payment_annual || 0, yrUnpaid = yrRev - yrPay; - const yrPayRate = yrRev > 0 ? Math.round(yrPay / yrRev * 100) : 0; - const yrUnpaidWan = Math.round(Math.abs(yrUnpaid) / 10000); - const payRateCls = yrPayRate < 30 ? 'text-red-600' : yrPayRate < 80 ? 'text-amber-600' : 'text-green-600'; - const unpaidLabel = yrUnpaid >= 0 - ? '你还有 ' + yrUnpaidWan + '万 未回款' - : '超额回款 ' + yrUnpaidWan + '万'; - const payReminder = yrRev > 0 ? '
' + - '' + - '回款提醒:本年度确收 ' + moneyInt(yrRev) + ',回款 ' + moneyInt(yrPay) + ',' + unpaidLabel + ',当前回款率:' + yrPayRate + '%' + - '
' : ''; - const rows1 = [ - ["年度累计", moneyInt(m.signed_annual || m.signed_amount)], - ["本季度累计", moneyInt(m.signed_q2 || 0)], - ["本月累计", moneyInt(m.signed_month || 0)], - ["上本季度累计", moneyInt(m.signed_prev_q || 0)], - ["上月累计", moneyInt(m.signed_prev_month || 0)], - ]; - const rows2 = [ - ["年度累计", moneyInt(m.revenue_annual)], - ["本季度累计", moneyInt(m.revenue_q2)], - ["本月累计", moneyInt(m.monthly_revenue)], - ["上本季度累计", moneyInt(m.revenue_prev_q || 0)], - ["上月累计", moneyInt(m.revenue_prev_month || 0)], - ]; - const rows3 = [ - ["年度累计", moneyInt(m.gross_annual)], - ["本季度累计", moneyInt(m.gross_q2)], - ["本月累计", moneyInt(m.monthly_net_profit)], - ["上本季度累计", moneyInt(m.gross_prev_q || 0)], - ["上月累计", moneyInt(m.gross_prev_month || 0)], - ]; - const rows4 = [ - ["年度累计", moneyInt(m.payment_annual || 0)], - ["本季度累计", moneyInt(m.payment_q2 || 0)], - ["本月累计", moneyInt(m.payment_month || 0)], - ["上本季度累计", moneyInt(m.payment_prev_q || 0)], - ["上月累计", moneyInt(m.payment_prev_month || 0)], - ]; - const rows5 = [ - ["年度累计", moneyInt(m.cost_annual || 0)], - ["本季度累计", moneyInt(m.cost_q2 || 0)], - ["本月累计", moneyInt(m.cost_month || 0)], - ["上本季度累计", moneyInt(m.cost_prev_q || 0)], - ["上月累计", moneyInt(m.cost_prev_month || 0)], - ]; - const rows7 = [ - ["年度累计", moneyInt(m.paid_annual || 0)], - ["本季度累计", moneyInt(m.paid_q2 || 0)], - ["本月累计", moneyInt(m.paid_month || 0)], - ["上本季度累计", moneyInt(m.paid_prev_q || 0)], - ["上月累计", moneyInt(m.paid_prev_month || 0)], - ]; - const rowsProjExpense = [ - ["年度累计", moneyInt(m.proj_expense_annual || 0)], - ["本季度累计", moneyInt(m.proj_expense_q2 || 0)], - ["本月累计", moneyInt(m.proj_expense_month || 0)], - ["上本季度累计", moneyInt(m.proj_expense_prev_q || 0)], - ["上月累计", moneyInt(m.proj_expense_prev_month || 0)], - ]; - const rows8 = [ - ["年度累计", moneyInt(m.cashflow_annual || 0)], - ["本季度累计", moneyInt(m.cashflow_q2 || 0)], - ["本月累计", moneyInt(m.cashflow_month || 0)], - ["上本季度累计", moneyInt(m.cashflow_prev_q || 0)], - ["上月累计", moneyInt(m.cashflow_prev_month || 0)], - ]; - const rows9 = [ - ["年度累计", moneyInt(m.profit_annual || 0)], - ["本季度累计", moneyInt(m.profit_q2 || 0)], - ["本月累计", moneyInt(m.profit_month || 0)], - ["上本季度累计", moneyInt(m.profit_prev_q || 0)], - ["上月累计", moneyInt(m.profit_prev_month || 0)], - ]; - const qoq = (cur, prev) => { - if (!prev) return ''; - const pct = Math.round((cur - prev) / prev * 100); - const cls = pct >= 0 ? 'text-green-600' : 'text-red-600'; - const sign = pct >= 0 ? '+' : ''; - return '' + sign + pct + '%'; - }; - const COLORS = ['text-slate-700','text-brand-600','text-green-600','text-rose-600','text-indigo-600','text-amber-600','text-purple-600','text-cyan-600']; - const LABELS = ['合同金额','确收金额','确收毛利','成本','项目利润','回款金额','已付','现金流']; - const Q_FIELDS = [m.signed_q2||0, m.revenue_q2, m.gross_q2, m.cost_q2||0, m.profit_q2||0, m.payment_q2||0, m.paid_q2||0, m.cashflow_q2||0]; - const Q_PREV = [m.signed_prev_q||0, m.revenue_prev_q||0, m.gross_prev_q||0, m.cost_prev_q||0, m.profit_prev_q||0, m.payment_prev_q||0, m.paid_prev_q||0, m.cashflow_prev_q||0]; - const M_FIELDS = [m.signed_month||0, m.monthly_revenue, m.monthly_net_profit, m.cost_month||0, m.profit_month||0, m.payment_month||0, m.paid_month||0, m.cashflow_month||0]; - const M_PREV = [m.signed_prev_month||0, m.revenue_prev_month||0, m.gross_prev_month||0, m.cost_prev_month||0, m.profit_prev_month||0, m.payment_prev_month||0, m.paid_prev_month||0, m.cashflow_prev_month||0]; - const ROWS = [rows1, rows2, rows3, rows5, rows9, rows4, rows7, rows8]; - // 现金流和项目利润的原始值数组,用于正负着色 - const CF_RAW = [m.cashflow_annual||0, m.cashflow_q2||0, m.cashflow_month||0, m.cashflow_prev_q||0, m.cashflow_prev_month||0]; - const PF_RAW = [m.profit_annual||0, m.profit_q2||0, m.profit_month||0, m.profit_prev_q||0, m.profit_prev_month||0]; - var valCls = function(ri) { return (ri === 4 || ri === 7) ? '' : 'text-slate-800'; }; - var tdVal = function(ri, col, val) { - if (ri === 4) return '' + val + ''; - if (ri === 7) return '' + val + ''; - return '' + val + ''; - }; - var thead = '指标年度累计上季度累计上月累计本季度累计季环比本月累计月环比'; - var tbody = ''; - for (var ri = 0; ri < 8; ri++) { - var vals = ROWS[ri]; - tbody += '' + LABELS[ri] + '' + - tdVal(ri, 0, vals[0][1]) + - tdVal(ri, 3, vals[3][1]) + - tdVal(ri, 4, vals[4][1]) + - tdVal(ri, 1, vals[1][1]) + - '' + qoq(Q_FIELDS[ri], Q_PREV[ri]) + '' + - tdVal(ri, 2, vals[2][1]) + - '' + qoq(M_FIELDS[ri], M_PREV[ri]) + ''; - } - document.querySelector("#home").innerHTML = ` -
- ${`
- ${[ - ["项目管理", m.total_projects, "finance"], - ["重点工作与台账", m.total_proposals, "projects"], - ["业务方案", m.total_products, "proposals"], - ["产品迭代", m.upcoming_products, "products"], - ].map(([label, value, tab]) => ``).join("")} -
`} - ${payReminder} - ${(() => { - var d = [ - ['项目毛利', [m.profit_annual||0, m.profit_q2||0, m.profit_month||0, m.profit_prev_q||0, m.profit_prev_month||0], - [m.profit_q2||0,m.profit_prev_q||0], [m.profit_month||0,m.profit_prev_month||0]], - ['部门费用', [m.expense_annual||0, m.expense_q2||0, (m.expense_month||0), m.expense_prev_q||0, m.expense_prev_month||0], - [m.expense_q2||0,m.expense_prev_q||0], [(m.expense_month||0),m.expense_prev_month||0]], - ['部门净利', [ - (m.profit_annual||0)-(m.expense_annual||0), (m.profit_q2||0)-(m.expense_q2||0), (m.profit_month||0)-(m.expense_month||0), - (m.profit_prev_q||0)-(m.expense_prev_q||0), (m.profit_prev_month||0)-(m.expense_prev_month||0) - ], [ - (m.profit_q2||0)-(m.expense_q2||0), (m.profit_prev_q||0)-(m.expense_prev_q||0) - ], [ - (m.profit_month||0)-(m.expense_month||0), (m.profit_prev_month||0)-(m.expense_prev_month||0) - ]], - ['项目现金流', [m.cashflow_annual||0, m.cashflow_q2||0, m.cashflow_month||0, m.cashflow_prev_q||0, m.cashflow_prev_month||0], - [m.cashflow_q2||0,m.cashflow_prev_q||0], [m.cashflow_month||0,m.cashflow_prev_month||0]], - ['部门费用流出', [m.expense_paid_annual||0, m.expense_paid_q2||0, m.expense_paid_month||0, m.expense_paid_prev_q||0, m.expense_paid_prev_month||0], - [m.expense_paid_q2||0,m.expense_paid_prev_q||0], [m.expense_paid_month||0,m.expense_paid_prev_month||0]], - ['部门现金流', [m.dept_cf_annual||0, m.dept_cf_q2||0, m.dept_cf_month||0, m.dept_cf_prev_q||0, m.dept_cf_prev_month||0], - [m.dept_cf_q2||0,m.dept_cf_prev_q||0], [m.dept_cf_month||0,m.dept_cf_prev_month||0]] - ]; - var buildDeptTable = function(title, dataRows) { - var h = ''; - for (var di = 0; di < dataRows.length; di++) { - var r = dataRows[di]; - var rawNet = r[1][0]; - var isNet = r[0] === '部门净利' || r[0] === '部门现金流'; - var isCF = r[0] === '项目现金流'; - var cls = isNet ? (rawNet >= 0 ? 'text-green-600' : 'text-red-600') : isCF ? (rawNet >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-800'; - h += '' + r[0] + '' + - '' + moneyInt(r[1][0]) + '' + - '' + moneyInt(r[1][3]) + '' + - '' + moneyInt(r[1][4]) + '' + - '' + moneyInt(r[1][1]) + '' + - '' + qoq(r[2][0], r[2][1]) + '' + - '' + moneyInt(r[1][2]) + '' + - '' + qoq(r[3][0], r[3][1]) + ''; - } - return card('

' + title + '

' + h + '
指标年度累计上季度累计上月累计本季度累计季环比本月累计月环比
', 'p-4'); - }; - return buildDeptTable('部门经营情况', d.slice(0, 3)) + buildDeptTable('部门现金流', d.slice(3, 6)); - })()} - ${card(`

近期动态

${summary.recent.map((r) => `
${r.content}
${r.followed_at}
`).join("")}
`, "p-5")} -
- `; -} - -function chartOptions(yCallback) { - return { - responsive: true, - maintainAspectRatio: false, - plugins: { legend: { position: "bottom", labels: { boxWidth: 12, font: { size: 11 } } } }, - scales: { - x: { ticks: { font: { size: 10 } }, grid: { display: false } }, - y: { ticks: { font: { size: 11 }, callback: yCallback } }, - }, - }; -} - -const moneyTick = (v) => v >= 10000 ? (v / 10000).toFixed(0) + "万" : v; -const monthLabels = (data) => data.map((x) => parseInt(x.month.split("-")[1]) + "月"); - -function renderCharts(data) { - const labels = monthLabels(data); - const baseOpts = chartOptions(moneyTick); - - // 图1:月度签约 - const c1 = document.querySelector("#chartSign"); - if (c1 && window.Chart) { - if (state.chart) state.chart.destroy(); - state.chart = new Chart(c1, { - type: "line", - data: { labels, datasets: [ - { label: "签约金额", data: data.map((x) => x.sign || 0), borderColor: "#6366f1", backgroundColor: "rgba(99,102,241,0.06)", fill: true, tension: 0.3 }, - ]}, - options: baseOpts, - }); - } - - // 图2:月度确收与毛利 - const c2 = document.querySelector("#chartRev"); - if (c2 && window.Chart) { - if (state.chart2) state.chart2.destroy(); - state.chart2 = new Chart(c2, { - type: "line", - data: { labels, datasets: [ - { label: "确收", data: data.map((x) => x.revenue || 0), borderColor: "var(--brand-600)", backgroundColor: "rgba(37,99,235,0.06)", fill: true, tension: 0.3 }, - { label: "毛利", data: data.map((x) => x.gross || 0), borderColor: "#059669", backgroundColor: "rgba(5,150,105,0.06)", fill: true, tension: 0.3 }, - ]}, - options: baseOpts, - }); - } - - // 图3:月度回款与已付 - const c3 = document.querySelector("#chartCash"); - if (c3 && window.Chart) { - if (state.chart3) state.chart3.destroy(); - state.chart3 = new Chart(c3, { - type: "line", - data: { labels, datasets: [ - { label: "回款", data: data.map((x) => x.payment || 0), borderColor: "#d97706", backgroundColor: "rgba(217,119,6,0.06)", fill: true, tension: 0.3 }, - { label: "已付", data: data.map((x) => x.cost || 0), borderColor: "#7c3aed", backgroundColor: "rgba(124,58,237,0.06)", fill: true, tension: 0.3 }, - ]}, - options: baseOpts, - }); - } - - // 图4:月度项目利润 - const c4 = document.querySelector("#chartProfit"); - if (c4 && window.Chart) { - if (state.chart4) state.chart4.destroy(); - state.chart4 = new Chart(c4, { - type: "line", - data: { labels, datasets: [ - { label: "利润", data: data.map((x) => (x.gross || 0)), borderColor: "#6366f1", backgroundColor: "rgba(99,102,241,0.06)", fill: true, tension: 0.3 }, - ]}, - options: baseOpts, - }); - } -} diff --git a/static/modules/products.js b/static/modules/products.js index 76bb110..ebee912 100644 --- a/static/modules/products.js +++ b/static/modules/products.js @@ -19,7 +19,13 @@ async function createResource(event, resource) { const name = data.project_name || data.target_customer || data.customer_or_project_name || data.product_name || ""; if (result.id && name) logActivity(resType, result.id, "创建了" + name); form.reset(); - try { state.data.products = await api("/api/products/list?tenant=" + encodeURIComponent(state.tenant)); renderProducts(); } catch(e) {} + if (resource === "operations") { + try { state.data.operations = await api("/api/operations/list?tenant=" + encodeURIComponent(state.tenant)); renderProjects(); } catch(e) {} + } else if (resource === "proposals") { + try { state.data.proposals = await api("/api/proposals/list?tenant=" + encodeURIComponent(state.tenant)); renderProposals(); } catch(e) {} + } else { + try { state.data.products = await api("/api/products/list?tenant=" + encodeURIComponent(state.tenant)); renderProducts(); } catch(e) {} + } } catch (error) { toast("创建失败:" + error.message, "error"); } diff --git a/static/modules/projects.js b/static/modules/projects.js index 8467e9d..b37872a 100644 --- a/static/modules/projects.js +++ b/static/modules/projects.js @@ -173,14 +173,6 @@ window.selectProject = (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(); @@ -306,7 +298,7 @@ function renderProjects() { pending: tasks.filter(t => t.status === '未开始').length, }; document.querySelector("#projects").innerHTML = /*html*/` -
+
${[ ["项目总数", items.length, "folder"], ["任务总数", taskStats.total, "list-checks"], @@ -317,15 +309,6 @@ function renderProjects() {
${label}${value}
`).join("")}
-
-
- - -
- -
@@ -349,6 +332,15 @@ function renderProjects() {
+
+
+ + +
+ +
${state.selectedProject ? '
' + renderTaskListHTML(state.selectedProject) + '
' : `
@@ -381,44 +373,33 @@ 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) })); + + const taskItem = (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) || ''} + +
+ `; 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 ? '
暂无任务,点击上方按钮创建
' : ''} +
+
+ ${tasks.length ? tasks.map(taskItem).join("") : '
暂无任务,点击上方按钮创建
'} +
+
`; } @@ -440,48 +421,56 @@ window.openTaskFormForSelected = () => { 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 modal = document.getElementById("taskModal"); + if (!modal) return; const task = taskId ? (state.data.tasks || []).find((t) => t.id === taskId) : null; - drawer.innerHTML = `
${task ? "编辑任务" : "新增任务"}
${task ? `` : ""}
-
- -
- - + modal.className = "hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40"; + modal.innerHTML = `
+
+

${task ? "编辑任务" : "新增任务"}

+
+ ${task ? '' : ""} +
-
- - +
+ + +
+ +
-
- - +
+
- - -
- - +
+ + +
- `; - drawer.classList.add("open"); + + +
+ + +
+ +
`; + // 绑定事件 + 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 drawer = document.querySelector(`#task-drawer-${projectId}`); - if (drawer) drawer.classList.remove("open"); + const modal = document.getElementById("taskModal"); + if (modal) modal.classList.add("hidden"); refreshTaskList(projectId); }; @@ -492,10 +481,34 @@ window.refreshTaskList = (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]) => ` +
${label}${value}
+ `).join(""); + if (window.lucide) window.lucide.createIcons(); +}; window.submitTaskForm = async (event, projectId) => { event.preventDefault(); - const data = Object.fromEntries(new FormData(event.currentTarget).entries()); + 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; @@ -507,11 +520,14 @@ window.submitTaskForm = async (event, projectId) => { 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); - try { state.data.tasks = await api("/api/tasks/list?tenant=" + encodeURIComponent(state.tenant)); } catch(e) {} + refreshProjectHeadCards(); } } catch (error) { toast("保存失败:" + error.message, "error"); @@ -537,6 +553,7 @@ window.cycleTaskStatus = async (taskId, projectId) => { badge.className = "task-status-badge status-" + newStatus; } } + refreshProjectHeadCards(); } catch (error) { toast("更新失败:" + error.message, "error"); } @@ -568,8 +585,25 @@ window.cycleTaskPriority = async (taskId, projectId) => { } }; +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 taskId = document.querySelector(`#task-id-${projectId}`).value; + const modal = document.getElementById("taskModal"); + if (!modal) return; + const taskId = modal.querySelector('input[name="task_id"]').value; if (!taskId) return; if (!confirm("确认删除该任务?此操作不可撤销。")) return; try { @@ -581,6 +615,7 @@ window.deleteTask = async (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"); } diff --git a/static/modules/utils.js b/static/modules/utils.js index afab100..bb9e987 100644 --- a/static/modules/utils.js +++ b/static/modules/utils.js @@ -1,7 +1,7 @@ // utils.js — 全局工具函数与共享状态 const state = { - active: "home", + active: "plan", data: null, tenant: localStorage.getItem("opc-active-tenant") || "科普·无界", opFilter: "all", @@ -120,7 +120,6 @@ function renderTable(headers, rows, rowClicks) { } var _loadingSteps = [ - { fn: 'renderHome', label: '正在加载首页仪表盘(指标卡片+图表)', skip: function() { return true; } }, { fn: 'renderOverviewPlan', label: '正在加载预算总览', skip: function() { return state.tenant !== '总览'; } }, { fn: 'renderOverviewFinance', label: '正在加载发生总览', skip: function() { return state.tenant !== '总览'; } }, { fn: 'renderProjects', label: '正在加载业务机会列表' }, @@ -235,7 +234,7 @@ var _tabLoaded = {}; var _proposalsInitialized = false; async function ensureTabData(tab) { - if (tab === 'home' || tab === 'performance' || tab === 'overview_plan' || tab === 'overview_finance') return; + if (tab === 'performance' || tab === 'overview_plan' || tab === 'overview_finance') return; var resource = _tabResourceMap[tab]; if (!resource) return; var dataKey = resource; @@ -249,6 +248,8 @@ async function ensureTabData(tab) { try { state.data.expense = await api("/api/expense/list?tenant=" + encodeURIComponent(state.tenant)); } catch(e) {} try { state.data.finance = await api("/api/finance/list?tenant=" + encodeURIComponent(state.tenant)); } catch(e) {} try { state.data.tasks = await api("/api/tasks/list?tenant=" + encodeURIComponent(state.tenant)); } catch(e) {} + } else if (tab === 'projects') { + try { state.data.tasks = await api("/api/tasks/list?tenant=" + encodeURIComponent(state.tenant)); } catch(e) {} } else if (tab === 'plan') { try { state.data.planExpense = await api("/api/planExpense/list?tenant=" + encodeURIComponent(state.tenant)); } catch(e) {} } @@ -265,7 +266,7 @@ function switchTab(tab) { document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === tab)); // 更新顶部标题 - const titles = { home: 'OPC 工作台', finance: '执行管理', plan: '预算管理', projects: '重点工作台账', proposals: '业务方案', products: '版本与运营', performance: '季度绩效考核', overview_plan: '预算总览', overview_finance: '发生总览' }; + const titles = { finance: '执行管理', plan: '预算管理', projects: '重点工作台账', proposals: '业务方案', products: '版本与运营', performance: '季度绩效考核', overview_plan: '预算总览', overview_finance: '发生总览' }; const titleEl = document.querySelector('#workspaceTitle'); if (titleEl) titleEl.textContent = titles[tab] || state.tenant + ' OPC 工作台'; @@ -283,28 +284,15 @@ function updateSidebarTabs() { if (isOverview) { btn.style.display = (tab === "overview_plan" || tab === "overview_finance") ? "" : "none"; } else { - btn.style.display = (tab === "overview_plan" || tab === "overview_finance" || tab === "home") ? "none" : ""; + btn.style.display = (tab === "overview_plan" || tab === "overview_finance") ? "none" : ""; } }); } -function render() { - if (!state.data) return; - if (state.tenant === '总览') renderHome(); - renderProjects(); - renderProposals(); - renderProducts(); - renderPerformance(); - renderFinance(); - renderPlan(); - if (window.lucide) window.lucide.createIcons(); -} - function renderActive() { if (!state.data) return; const tab = state.active; - if (tab === "home") renderHome(); - else if (tab === "overview_plan") renderOverviewPlan(); + if (tab === "overview_plan") renderOverviewPlan(); else if (tab === "overview_finance") renderOverviewFinance(); else if (tab === "projects") renderProjects(); else if (tab === "proposals") renderProposals(); @@ -403,11 +391,14 @@ window.switchTenant = async (tenant) => { localStorage.setItem("opc-active-tab", "overview_plan"); document.querySelectorAll(".sidebar-tab").forEach((btn) => btn.classList.toggle("active", btn.dataset.tab === "overview_plan")); document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === "overview_plan")); - } else if (state.active === 'home' || state.active === 'overview_plan' || state.active === 'overview_finance') { + } else if (state.active === 'overview_plan' || state.active === 'overview_finance') { state.active = 'plan'; localStorage.setItem("opc-active-tab", "plan"); document.querySelectorAll(".sidebar-tab").forEach((btn) => btn.classList.toggle("active", btn.dataset.tab === "plan")); document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === "plan")); + } else { + document.querySelectorAll(".sidebar-tab").forEach((btn) => btn.classList.toggle("active", btn.dataset.tab === state.active)); + document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === state.active)); } await load(); }; @@ -424,3 +415,18 @@ window.doLogout = async () => { await api("/api/auth/logout", { method: "POST" }); location.href = "/login"; }; + +// 图表工具函数(曾被 home.js 中 plan/finance 模块复用) +const moneyTick = (v) => v >= 10000 ? (v / 10000).toFixed(0) + "万" : v; +const monthLabels = (data) => data.map((x) => parseInt(x.month.split("-")[1]) + "月"); +function chartOptions(yCallback) { + return { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { position: "bottom", labels: { boxWidth: 12, font: { size: 11 } } } }, + scales: { + x: { ticks: { font: { size: 10 } }, grid: { display: false } }, + y: { ticks: { font: { size: 11 }, callback: yCallback } }, + }, + }; +} diff --git a/static/styles.css b/static/styles.css index 284b7e7..f7b9d21 100644 --- a/static/styles.css +++ b/static/styles.css @@ -537,6 +537,26 @@ body { font-size: 13px; } +.task-expand-toggle { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + cursor: pointer; + color: #94a3b8; + border-radius: 4px; + flex-shrink: 0; + transition: color 0.15s, background 0.15s; +} +.task-expand-toggle:hover { color: #475569; background: #f1f5f9; } +.task-expand-toggle i { transition: transform 0.2s ease; } + +.task-expand-body { + border-top: 1px solid #f1f5f9; + background: #fafbfc; +} + /* 业务方案列表项 */ .proposal-item { display: flex; @@ -1109,8 +1129,6 @@ td { } /* Task Modal — Plane style */ -.task-modal { display: none; } -.task-modal.active { display: block; } .task-overlay { position: fixed; inset: 0; background: rgba(15,23,42,0.35); z-index: 200; display: flex; align-items: flex-start; justify-content: center; diff --git a/templates/index.html b/templates/index.html index cc19cce..0dbe614 100644 --- a/templates/index.html +++ b/templates/index.html @@ -63,10 +63,6 @@
-
-
+
-