feat: 删除部门模块 + 任务系统重构 + 布局修复
- 删除部门(Home)侧边栏入口、面板及相关代码 - 删除 home.js,chartOptions 等图表工具迁移到 utils.js - 清理 render()、togglePhase、filterPhaseTasks 等死代码 - 任务列表改为平铺(取消阶段分组),支持拖拽排序 - 任务表单加开始时间字段,重组字段布局为三列 - 任务展开面板改为竖排:时间、任务进展、卡点与备注 - 视图按钮和新增任务按钮移入任务卡片顶部 - 修复 index.html div 嵌套不平衡导致布局塌陷 - 修复 savedTab 校验和 switchTenant 侧边栏 active 同步 - 总览工作台只显示预算/发生总览入口 - 性能面板 id 去重
This commit is contained in:
@@ -525,6 +525,39 @@ def finance_yearly():
|
||||
conn.close()
|
||||
|
||||
|
||||
@bp.route("/api/finance/copy-to-plan/<int:source_id>", 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/<resource>/list", methods=["GET"])
|
||||
|
||||
@@ -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 = `<section class="card p-6 text-red-700">加载失败:${esc(error.message)}</section>`;
|
||||
|
||||
@@ -1370,6 +1370,8 @@ window.finShowCtx = function(e, pfId) {
|
||||
FM_COLORS.map(function(c) {
|
||||
return '<div data-act="' + c.key + '" style="padding:6px 12px;font-size:13px;cursor:pointer;color:#334155;display:flex;align-items:center"><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:' + c.hex + ';margin-right:8px;flex-shrink:0"></span>' + c.label + '</div>';
|
||||
}).join('') +
|
||||
'<div style="border-top:1px solid #e2e8f0;margin:4px 0"></div>' +
|
||||
'<div data-act="copy_plan" style="padding:6px 12px;font-size:13px;cursor:pointer;color:#334155;display:flex;align-items:center"><span style="margin-right:6px;flex-shrink:0">📋</span>复制项目到预算</div>' +
|
||||
'<div style="border-top:1px solid #e2e8f0;margin:2px 0"></div>' +
|
||||
'<div data-act="clear" style="padding:6px 12px;font-size:13px;cursor:pointer;color:#94a3b8">取消标记</div>';
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
? '你还有 <strong class=\"text-red-600\">' + yrUnpaidWan + '万</strong> 未回款'
|
||||
: '超额回款 <strong class=\"text-green-600\">' + yrUnpaidWan + '万</strong>';
|
||||
const payReminder = yrRev > 0 ? '<div class=\"flex items-center gap-3 px-4 py-3 rounded-lg border\" style=\"background:linear-gradient(135deg,#fef3c7,#fde68a);border-color:#f59e0b\">' +
|
||||
'<i data-lucide=\"bell-ring\" class=\"text-amber-600 flex-shrink-0\" style=\"width:18px;height:18px\"></i>' +
|
||||
'<span class=\"text-sm text-amber-900\"><strong>回款提醒:</strong>本年度确收 <strong>' + moneyInt(yrRev) + '</strong>,回款 <strong>' + moneyInt(yrPay) + '</strong>,' + unpaidLabel + ',当前回款率:<strong class=\"' + payRateCls + '\">' + yrPayRate + '%</strong></span>' +
|
||||
'</div>' : '';
|
||||
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 '<span class="text-slate-300">—</span>';
|
||||
const pct = Math.round((cur - prev) / prev * 100);
|
||||
const cls = pct >= 0 ? 'text-green-600' : 'text-red-600';
|
||||
const sign = pct >= 0 ? '+' : '';
|
||||
return '<span class="' + cls + '">' + sign + pct + '%</span>';
|
||||
};
|
||||
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 '<td class="py-2 text-right font-semibold ' + (PF_RAW[col] >= 0 ? 'text-green-600' : 'text-red-600') + '">' + val + '</td>';
|
||||
if (ri === 7) return '<td class="py-2 text-right font-semibold ' + (CF_RAW[col] >= 0 ? 'text-green-600' : 'text-red-600') + '">' + val + '</td>';
|
||||
return '<td class="py-2 text-right font-semibold text-slate-800">' + val + '</td>';
|
||||
};
|
||||
var thead = '<tr class="border-b border-slate-200"><th class="py-2 text-left text-slate-500">指标</th><th class="py-2 text-right font-semibold text-slate-700">年度累计</th><th class="py-2 text-right font-semibold text-slate-700">上季度累计</th><th class="py-2 text-right font-semibold text-slate-700">上月累计</th><th class="py-2 text-right font-semibold text-slate-700">本季度累计</th><th class="py-2 text-right font-semibold text-slate-400">季环比</th><th class="py-2 text-right font-semibold text-slate-700">本月累计</th><th class="py-2 text-right font-semibold text-slate-400">月环比</th></tr>';
|
||||
var tbody = '';
|
||||
for (var ri = 0; ri < 8; ri++) {
|
||||
var vals = ROWS[ri];
|
||||
tbody += '<tr class="border-b border-slate-100 last:border-0"><td class="py-2 text-left font-semibold text-slate-700">' + LABELS[ri] + '</td>' +
|
||||
tdVal(ri, 0, vals[0][1]) +
|
||||
tdVal(ri, 3, vals[3][1]) +
|
||||
tdVal(ri, 4, vals[4][1]) +
|
||||
tdVal(ri, 1, vals[1][1]) +
|
||||
'<td class="py-2 text-right">' + qoq(Q_FIELDS[ri], Q_PREV[ri]) + '</td>' +
|
||||
tdVal(ri, 2, vals[2][1]) +
|
||||
'<td class="py-2 text-right">' + qoq(M_FIELDS[ri], M_PREV[ri]) + '</td></tr>';
|
||||
}
|
||||
document.querySelector("#home").innerHTML = `
|
||||
<div class="grid gap-5">
|
||||
${`<div class="grid grid-cols-4 gap-3">
|
||||
${[
|
||||
["项目管理", m.total_projects, "finance"],
|
||||
["重点工作与台账", m.total_proposals, "projects"],
|
||||
["业务方案", m.total_products, "proposals"],
|
||||
["产品迭代", m.upcoming_products, "products"],
|
||||
].map(([label, value, tab]) => `<button class="metric-card" onclick="switchTab('${tab}')"><span class="flex items-center gap-2 text-xs text-slate-500"><i data-lucide="gauge"></i>${label}</span><strong class="mt-2 block text-2xl">${value}</strong></button>`).join("")}
|
||||
</div>`}
|
||||
${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 += '<tr class="border-b border-slate-100 last:border-0"><td class="py-2 text-left font-semibold text-slate-700">' + r[0] + '</td>' +
|
||||
'<td class="py-2 text-right font-semibold ' + cls + '">' + moneyInt(r[1][0]) + '</td>' +
|
||||
'<td class="py-2 text-right font-semibold ' + cls + '">' + moneyInt(r[1][3]) + '</td>' +
|
||||
'<td class="py-2 text-right font-semibold ' + cls + '">' + moneyInt(r[1][4]) + '</td>' +
|
||||
'<td class="py-2 text-right font-semibold ' + cls + '">' + moneyInt(r[1][1]) + '</td>' +
|
||||
'<td class="py-2 text-right">' + qoq(r[2][0], r[2][1]) + '</td>' +
|
||||
'<td class="py-2 text-right font-semibold ' + cls + '">' + moneyInt(r[1][2]) + '</td>' +
|
||||
'<td class="py-2 text-right">' + qoq(r[3][0], r[3][1]) + '</td></tr>';
|
||||
}
|
||||
return card('<h3 class="text-sm font-bold text-slate-700 mb-3">' + title + '</h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead><tr class="border-b border-slate-200"><th class="py-2 text-left text-slate-500">指标</th><th class="py-2 text-right font-semibold text-slate-700">年度累计</th><th class="py-2 text-right font-semibold text-slate-700">上季度累计</th><th class="py-2 text-right font-semibold text-slate-700">上月累计</th><th class="py-2 text-right font-semibold text-slate-700">本季度累计</th><th class="py-2 text-right font-semibold text-slate-400">季环比</th><th class="py-2 text-right font-semibold text-slate-700">本月累计</th><th class="py-2 text-right font-semibold text-slate-400">月环比</th></tr></thead><tbody>' + h + '</tbody></table></div>', 'p-4');
|
||||
};
|
||||
return buildDeptTable('部门经营情况', d.slice(0, 3)) + buildDeptTable('部门现金流', d.slice(3, 6));
|
||||
})()}
|
||||
${card(`<h2 class="text-lg font-bold">近期动态</h2><div class="mt-4 grid gap-2">${summary.recent.map((r) => `<div class="flex items-start justify-between rounded-md bg-slate-50 px-3 py-2 text-sm group"><span class="break-words">${r.content}</span><div class="flex items-center gap-2 flex-shrink-0 ml-2"><span class="text-xs text-slate-400">${r.followed_at}</span><button class="btn btn-ghost btn-sm text-red-400 opacity-0 group-hover:opacity-100 p-0 w-5 h-5" onclick="event.preventDefault();deleteActivity(${r.id})" title="删除动态"><i data-lucide="trash-2" style="width:14px;height:14px"></i></button></div></div>`).join("")}</div>`, "p-5")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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*/`
|
||||
<div class="grid grid-cols-5 gap-3 mb-4">
|
||||
<div id="projectHeadCards" class="grid grid-cols-5 gap-3 mb-4">
|
||||
${[
|
||||
["项目总数", items.length, "folder"],
|
||||
["任务总数", taskStats.total, "list-checks"],
|
||||
@@ -317,15 +309,6 @@ function renderProjects() {
|
||||
<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">
|
||||
@@ -349,6 +332,15 @@ function renderProjects() {
|
||||
</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">
|
||||
@@ -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) => `<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 `
|
||||
${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>' : ''}
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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 = `<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>
|
||||
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 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>
|
||||
<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="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 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>
|
||||
<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 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>
|
||||
</form>`;
|
||||
drawer.classList.add("open");
|
||||
<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 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]) => `
|
||||
<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 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");
|
||||
}
|
||||
|
||||
@@ -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 } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -63,10 +63,6 @@
|
||||
<div class="w-8 h-px bg-slate-700 my-2"></div>
|
||||
<!-- 导航 Tab 图标 -->
|
||||
<div class="flex flex-col items-center gap-1 w-14" id="sidebarTabs">
|
||||
<div class="sidebar-tab active" data-tab="home" onclick="switchTab('home')" title="部门">
|
||||
<i data-lucide="trending-up" style="width:20px;height:20px"></i>
|
||||
<span class="text-[10px] mt-1">部门</span>
|
||||
</div>
|
||||
<div class="sidebar-tab" data-tab="plan" onclick="switchTab('plan')" title="预算">
|
||||
<i data-lucide="calendar-range" style="width:20px;height:20px"></i>
|
||||
<span class="text-[10px] mt-1">预算</span>
|
||||
@@ -122,21 +118,19 @@
|
||||
</header>
|
||||
|
||||
<main class="px-8 py-6">
|
||||
<section id="home" class="panel active"></section>
|
||||
<section id="overview_plan" class="panel"></section>
|
||||
<section id="overview_finance" class="panel"></section>
|
||||
<section id="projects" class="panel"></section>
|
||||
<section id="proposals" class="panel"></section>
|
||||
<section id="products" class="panel"></section>
|
||||
<section id="performance" class="panel"></section>
|
||||
<section id="performance" class="panel"></section>
|
||||
<section id="finance" class="panel"></section>
|
||||
<section id="plan" class="panel"></section>
|
||||
<section id="plan" class="panel active"></section>
|
||||
</main>
|
||||
</div><!-- 关闭主内容区 -->
|
||||
</div><!-- 关闭 flex 容器 -->
|
||||
<aside id="drawer" class="drawer" aria-hidden="true"></aside>
|
||||
<div id="taskModal" class="task-modal"></div>
|
||||
<div id="taskModal"></div>
|
||||
<!-- 新增项目模态框 -->
|
||||
<div id="newProjectModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closeNewProjectModal()">
|
||||
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-md mx-4" onclick="event.stopPropagation()">
|
||||
@@ -155,7 +149,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<script src="{{ url_for('static', filename='modules/utils.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='modules/home.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='modules/projects.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='modules/proposals.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='modules/products.js') }}"></script>
|
||||
|
||||
Reference in New Issue
Block a user