HEL-155: 按第三轮效果图改造管理端首页

替换借贷轧差为待审核统计卡;公司往来合计/明细合成主从模块(折叠明细);
新增柱状图与折线图位;接入 /api/admin/dashboard 真实归集与审核队列数据。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
施工员
2026-08-26 03:32:47 +00:00
co-authored by Cursor multica-agent
parent ece3e53472
commit 77fc625ded
6 changed files with 992 additions and 173 deletions
+295
View File
@@ -908,6 +908,15 @@ function updateAuditCounts() {
if (pending) pending.textContent = unresolved.length;
const foot = $("#auditFoot");
if (foot) foot.textContent = `${rows.length} 项 · 待审核 ${unresolved.length}`;
// Keep dashboard audit card in sync with the live audit table when possible.
// Prefer the authoritative /api/admin/dashboard payload; this is a best-effort
// DOM fallback only when the card already exists and dashboard reload has not run.
if ($("#dashAuditTotal") && !state.dashAuditFromApi) {
const high = unresolved.filter((row) => row.querySelector(".pill-danger")).length;
const medium = unresolved.filter((row) => row.querySelector(".pill-warn")).length;
const low = Math.max(0, unresolved.length - high - medium);
updateDashAuditCard({ total: unresolved.length, high, medium, low });
}
}
function companyStatusBadge(status) {
@@ -983,6 +992,291 @@ function fillCompanySelects(names) {
}
setSelectOptions($('#openingDialog [name="from"]'), names);
setSelectOptions($('#openingDialog [name="to"]'), names);
setSelectOptions($("#ql-self"), names);
setSelectOptions($("#ql-peer"), names);
if ($("#ql-peer") && names.length > 1) $("#ql-peer").value = names[0];
if ($("#ql-self") && names.length > 1) $("#ql-self").value = names[names.length - 1];
}
function formatWan(value) {
const num = Number(value);
if (!Number.isFinite(num)) return "0.00";
const abs = Math.abs(num).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
if (num > 0) return `+${abs}`;
if (num < 0) return `-${abs}`;
return abs;
}
function netClass(value) {
const num = Number(value);
if (num > 0) return "amt-in";
if (num < 0) return "amt-out";
return "";
}
function statusPill(label) {
if (!label || label === "—") return '<span class="meta">—</span>';
const map = {
"已完成": "pill-success",
"流水待补": "pill-warn",
"确认中": "pill-warn",
"未提交": "pill-danger",
};
return `<span class="pill ${map[label] || "pill-muted"}">${label}</span>`;
}
function updateDashAuditCard(audit) {
const totalEl = $("#dashAuditTotal");
const footEl = $("#dashAuditFoot");
if (!totalEl || !footEl || !audit) return;
totalEl.innerHTML = `${audit.total ?? 0}<span class="unit">项</span>`;
footEl.textContent = `${audit.high ?? 0} 项 · 中 ${audit.medium ?? 0} 项 · 其余 ${audit.low ?? 0} 项低风险`;
}
function renderBarChart(companies) {
const host = $("#dashBarChart");
if (!host) return;
if (!companies.length) {
host.innerHTML = '<div class="empty" style="padding: 36px 12px;"><div class="e-title">暂无往来分布</div><div>归集后将显示各公司期末净值</div></div>';
return;
}
const values = companies.map((c) => Number(c.net_wan) || 0);
const maxAbs = Math.max(1, ...values.map((v) => Math.abs(v)));
const w = 560;
const h = 220;
const padL = 44;
const padR = 12;
const padT = 24;
const padB = 36;
const plotW = w - padL - padR;
const plotH = h - padT - padB;
const zeroY = padT + plotH / 2;
const gap = 10;
const barW = Math.max(12, (plotW - gap * companies.length) / companies.length);
const ticks = [maxAbs, maxAbs / 2, 0, -maxAbs / 2, -maxAbs];
const tickLines = ticks.map((t) => {
const y = zeroY - (t / maxAbs) * (plotH / 2);
const label = t === 0 ? "0" : (t > 0 ? `+${Math.round(t)}` : `${Math.round(t)}`);
return `<line x1="${padL}" y1="${y}" x2="${w - padR}" y2="${y}" stroke="var(--border)" stroke-width="1"/>
<text x="${padL - 6}" y="${y + 3}" text-anchor="end" fill="var(--muted)" font-size="10" font-family="var(--font-mono)">${label}</text>`;
}).join("");
const bars = companies.map((c, i) => {
const v = Number(c.net_wan) || 0;
const x = padL + gap / 2 + i * (barW + gap);
const bh = (Math.abs(v) / maxAbs) * (plotH / 2);
const y = v >= 0 ? zeroY - bh : zeroY;
const color = v >= 0 ? "var(--success)" : "var(--danger)";
const label = (c.name || "").replace(/河南|有限公司|科技发展/g, "").slice(0, 4) || c.name;
const valY = v >= 0 ? y - 4 : y + bh + 11;
return `<rect x="${x}" y="${y}" width="${barW}" height="${Math.max(bh, v === 0 ? 0 : 2)}" fill="${color}" rx="2"/>
<text x="${x + barW / 2}" y="${valY}" text-anchor="middle" fill="${color}" font-size="10" font-family="var(--font-mono)">${formatWan(v).replace(".00", "")}</text>
<text x="${x + barW / 2}" y="${h - 10}" text-anchor="middle" fill="var(--muted)" font-size="10">${label}</text>`;
}).join("");
host.innerHTML = `<svg viewBox="0 0 ${w} ${h}" width="100%" height="100%" role="img">${tickLines}<line x1="${padL}" y1="${zeroY}" x2="${w - padR}" y2="${zeroY}" stroke="var(--fg)" stroke-width="1" opacity="0.35"/>${bars}</svg>`;
}
function renderLineChart(flow) {
const host = $("#dashLineChart");
if (!host) return;
const labels = flow?.labels || [];
const inflow = (flow?.inflow_wan || []).map(Number);
const outflow = (flow?.outflow_wan || []).map(Number);
if (!labels.length) {
host.innerHTML = '<div class="empty" style="padding: 36px 12px;"><div class="e-title">暂无周度流水</div><div>有归集数据后显示近 7 日流入流出</div></div>';
return;
}
const w = 560;
const h = 220;
const padL = 40;
const padR = 12;
const padT = 20;
const padB = 32;
const plotW = w - padL - padR;
const plotH = h - padT - padB;
const maxV = Math.max(1, ...inflow, ...outflow);
const xAt = (i) => padL + (labels.length === 1 ? plotW / 2 : (i / (labels.length - 1)) * plotW);
const yAt = (v) => padT + plotH - (v / maxV) * plotH;
const pathOf = (series) => series.map((v, i) => `${i ? "L" : "M"}${xAt(i)},${yAt(v)}`).join(" ");
const grid = [0, 0.5, 1].map((t) => {
const y = yAt(maxV * t);
return `<line x1="${padL}" y1="${y}" x2="${w - padR}" y2="${y}" stroke="var(--border)"/><text x="${padL - 6}" y="${y + 3}" text-anchor="end" fill="var(--muted)" font-size="10" font-family="var(--font-mono)">${Math.round(maxV * t)}</text>`;
}).join("");
const xLabels = labels.map((lab, i) => `<text x="${xAt(i)}" y="${h - 8}" text-anchor="middle" fill="var(--muted)" font-size="10" font-family="var(--font-mono)">${lab}</text>`).join("");
host.innerHTML = `<svg viewBox="0 0 ${w} ${h}" width="100%" height="100%" role="img">${grid}
<path d="${pathOf(inflow)}" fill="none" stroke="var(--success)" stroke-width="2"/>
<path d="${pathOf(outflow)}" fill="none" stroke="var(--danger)" stroke-width="2"/>
${inflow.map((v, i) => `<circle cx="${xAt(i)}" cy="${yAt(v)}" r="2.5" fill="var(--success)"/>`).join("")}
${outflow.map((v, i) => `<circle cx="${xAt(i)}" cy="${yAt(v)}" r="2.5" fill="var(--danger)"/>`).join("")}
${xLabels}
<text x="${w - padR}" y="14" text-anchor="end" fill="var(--success)" font-size="11">流入</text>
<text x="${w - padR - 40}" y="14" text-anchor="end" fill="var(--danger)" font-size="11">流出</text>
</svg>`;
}
function renderDashCompanyRows(companies, selectedId) {
const tbody = $("#dashCompanyRows");
if (!tbody) return;
if (!companies.length) {
tbody.innerHTML = '<tr><td colspan="4" class="muted">暂无公司或尚无归集往来</td></tr>';
return;
}
tbody.replaceChildren(...companies.map((company) => {
const tr = document.createElement("tr");
tr.className = "clickable";
tr.dataset.companyId = company.id;
if (String(company.id) === String(selectedId)) {
tr.style.background = "var(--accent-soft)";
}
const nameTd = document.createElement("td");
nameTd.style.cssText = "white-space: nowrap; max-width: 220px; overflow: hidden; text-overflow: ellipsis;";
nameTd.title = company.name;
nameTd.textContent = company.name;
const countTd = document.createElement("td");
countTd.className = "num-col";
countTd.textContent = String(company.detail_count ?? 0);
const netTd = document.createElement("td");
netTd.className = `num-col ${netClass(company.net_wan)}`;
netTd.textContent = formatWan(company.net_wan);
const statusTd = document.createElement("td");
statusTd.innerHTML = statusPill(company.period_status_label);
tr.append(nameTd, countTd, netTd, statusTd);
return tr;
}));
}
function chevronSvg(expanded) {
// Same inline chevron style used elsewhere in the deployed admin shell.
if (expanded) {
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" style="width:12px;height:12px;display:inline-block;vertical-align:-1px;margin-right:6px;"><path d="m6 9 6 6 6-6"/></svg>';
}
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" style="width:12px;height:12px;display:inline-block;vertical-align:-1px;margin-right:6px;"><path d="m9 18 6-6-6-6"/></svg>';
}
function renderDashDetail(payload, { expandFirst = true } = {}) {
const tbody = $("#dashDetailRows");
const title = $("#dashDetailTitle");
if (!tbody) return;
if (!payload) {
tbody.innerHTML = '<tr><td colspan="6" class="muted">请选择左侧公司</td></tr>';
return;
}
if (title) {
title.innerHTML = `公司往来明细 · ${payload.company_name}<span class="sub" id="dashDetailSub">按对方公司分组 · 二级默认收起 · 期初待 B-45</span>`;
}
const groups = payload.groups || [];
if (!groups.length) {
tbody.innerHTML = '<tr><td colspan="6" class="muted">该公司区间内暂无已归集往来</td></tr>';
return;
}
const rows = [];
groups.forEach((group, index) => {
const expanded = expandFirst && index === 0;
const groupTr = document.createElement("tr");
groupTr.className = "clickable";
groupTr.dataset.peerGroup = String(group.peer_id);
groupTr.dataset.peerName = group.peer_name;
groupTr.dataset.expanded = expanded ? "true" : "false";
groupTr.innerHTML = `
<td style="font-weight:550;">${chevronSvg(expanded)}<span data-peer-label>${group.peer_name}</span></td>
<td class="num-col">${group.count}</td>
<td class="num-col meta">—</td>
<td class="num-col">${Number(group.debit_wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
<td class="num-col">${Number(group.credit_wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
<td class="num-col ${netClass(group.ending_wan)}">${formatWan(group.ending_wan)}</td>`;
rows.push(groupTr);
(group.lines || []).forEach((line) => {
const lineTr = document.createElement("tr");
lineTr.dataset.peerChild = String(group.peer_id);
lineTr.style.display = expanded ? "" : "none";
const dirTag = line.direction === "debit"
? '<span class="tag">借</span>'
: '<span class="tag">贷</span>';
lineTr.innerHTML = `
<td style="padding-left: 28px;">${dirTag} <span class="meta">${line.date}</span> ${line.summary}</td>
<td class="num-col">1</td>
<td class="num-col meta">—</td>
<td class="num-col">${line.direction === "debit" ? Number(line.amount_wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : "—"}</td>
<td class="num-col">${line.direction === "credit" ? Number(line.amount_wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : "—"}</td>
<td class="num-col meta">—</td>`;
rows.push(lineTr);
});
});
tbody.replaceChildren(...rows);
}
async function loadDashCompanyDetail(companyId) {
if (!companyId) return;
state.dashSelectedId = companyId;
renderDashCompanyRows(state.dashCompanies || [], companyId);
const tbody = $("#dashDetailRows");
if (tbody) tbody.innerHTML = '<tr class="loading-row"><td colspan="6">加载中…</td></tr>';
const from = state.dashFrom || "2026-01-01";
const cutoff = state.dashCutoff || "2026-08-20";
const response = await fetch(`/api/admin/dashboard/companies/${companyId}?from=${encodeURIComponent(from)}&cutoff=${encodeURIComponent(cutoff)}`).catch(() => null);
if (!response?.ok) {
if (tbody) tbody.innerHTML = '<tr><td colspan="6" class="muted">明细加载失败</td></tr>';
return;
}
const result = await response.json().catch(() => null);
renderDashDetail(result);
}
async function loadAdminDashboard() {
if (!$("#dashCompanyRows")) return;
const response = await fetch("/api/admin/dashboard?from=2026-01-01&cutoff=2026-08-20").catch(() => null);
if (!response?.ok) {
$("#dashCompanyRows").innerHTML = '<tr><td colspan="4" class="muted">总览加载失败</td></tr>';
return;
}
const data = await response.json().catch(() => null);
if (!data || data.status !== "ok") {
$("#dashCompanyRows").innerHTML = '<tr><td colspan="4" class="muted">总览加载失败</td></tr>';
return;
}
state.dashCompanies = data.companies || [];
state.dashFrom = data.from_date;
state.dashCutoff = data.cutoff;
updateDashAuditCard(data.audit);
state.dashAuditFromApi = true;
const statusHead = $("#dashStatusHead");
if (statusHead && data.period_month) statusHead.textContent = `${data.period_month}月状态`;
const listSub = $("#dashListSub");
if (listSub) listSub.textContent = `${data.from_date}${data.cutoff} · 单位:万元`;
renderBarChart(state.dashCompanies);
renderLineChart(data.weekly_flow);
const selected = state.dashSelectedId || state.dashCompanies[0]?.id;
renderDashCompanyRows(state.dashCompanies, selected);
if (selected) await loadDashCompanyDetail(selected);
}
function initDashboard() {
if (!$("#companyMasterDetail")) return;
$("#dashCompanyRows")?.addEventListener("click", (event) => {
const tr = event.target.closest("tr[data-company-id]");
if (!tr) return;
loadDashCompanyDetail(tr.dataset.companyId);
});
$("#dashDetailRows")?.addEventListener("click", (event) => {
const tr = event.target.closest("tr[data-peer-group]");
if (!tr) return;
const peerId = tr.dataset.peerGroup;
const open = tr.dataset.expanded !== "true";
tr.dataset.expanded = open ? "true" : "false";
const nameCell = tr.children[0];
if (nameCell) {
nameCell.innerHTML = `${chevronSvg(open)}<span data-peer-label>${tr.dataset.peerName || ""}</span>`;
}
$$('#dashDetailRows tr[data-peer-child]').forEach((child) => {
if (child.dataset.peerChild === peerId) child.style.display = open ? "" : "none";
});
});
$("#company-search")?.addEventListener("input", function () {
const q = this.value.trim();
$$("#dashCompanyRows tr[data-company-id]").forEach((tr) => {
tr.style.display = (!q || tr.textContent.includes(q)) ? "" : "none";
});
});
loadAdminDashboard();
}
async function loadAdminCompanies() {
@@ -1004,6 +1298,7 @@ function initAdmin() {
renderStoredAdminReviews();
updateAuditCounts();
loadAdminCompanies();
initDashboard();
let activeAuditType = "all";
function filterAuditRows() {