HEL-157: 删除首页图表、校正主从模块并统一待审核口径
移除柱状图/折线图死代码;主从改为 480px+明细宽栏;待审核首页卡、侧栏角标与后端口径同源。 Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
co-authored by
Cursor
multica-agent
parent
815b68c1fc
commit
7fb97119ba
@@ -22,3 +22,7 @@ nul
|
||||
vendor_pkgs/
|
||||
node_modules/
|
||||
package-lock.json
|
||||
|
||||
# local vendor for agent test env (not shipped)
|
||||
.vendor/
|
||||
vendor_wheels/
|
||||
|
||||
@@ -42,8 +42,11 @@ def audit_counts(connection: sqlite3.Connection) -> dict[str, int]:
|
||||
|
||||
Priority mapping (aligned with current admin audit UI semantics):
|
||||
- high: unresolved / needs_review match exceptions
|
||||
- medium: pending bank-account registrations
|
||||
- medium: pending bank-account registrations + pending manual records
|
||||
- low: reserved for future low-risk queues (currently always 0)
|
||||
|
||||
Homepage card, sidebar badge and audit-center heading must all read this
|
||||
same payload (via ``/api/admin/dashboard`` → ``audit``).
|
||||
"""
|
||||
high = connection.execute(
|
||||
"""
|
||||
@@ -55,9 +58,19 @@ def audit_counts(connection: sqlite3.Connection) -> dict[str, int]:
|
||||
AND d.classification IN ('unresolved', 'needs_review')
|
||||
"""
|
||||
).fetchone()["n"]
|
||||
medium = connection.execute(
|
||||
medium_accounts = connection.execute(
|
||||
"SELECT COUNT(*) AS n FROM bank_accounts WHERE status = 'pending'"
|
||||
).fetchone()["n"]
|
||||
medium_manuals = connection.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS n
|
||||
FROM manual_records m
|
||||
JOIN current_manual_record_decisions c ON c.record_id = m.id
|
||||
JOIN manual_record_decisions d ON d.id = c.decision_id
|
||||
WHERE d.state = 'pending'
|
||||
"""
|
||||
).fetchone()["n"]
|
||||
medium = int(medium_accounts) + int(medium_manuals)
|
||||
low = 0
|
||||
return {
|
||||
"total": int(high) + int(medium) + int(low),
|
||||
|
||||
@@ -57,6 +57,96 @@ class DashboardUnitTests(unittest.TestCase):
|
||||
self.assertEqual(0, counts["high"])
|
||||
self.assertEqual(1, counts["total"])
|
||||
|
||||
def test_pending_manual_counts_as_medium(self) -> None:
|
||||
from bank_importer import auth, manual_records
|
||||
|
||||
now = master_data.utc_now()
|
||||
a = self.connection.execute(
|
||||
"INSERT INTO companies (name, credit_code, cashier_name, status, created_at, updated_at) "
|
||||
"VALUES ('甲公司', NULL, NULL, 'active', ?, ?)",
|
||||
(now, now),
|
||||
).lastrowid
|
||||
b = self.connection.execute(
|
||||
"INSERT INTO companies (name, credit_code, cashier_name, status, created_at, updated_at) "
|
||||
"VALUES ('乙公司', NULL, NULL, 'active', ?, ?)",
|
||||
(now, now),
|
||||
).lastrowid
|
||||
self.connection.commit()
|
||||
auth.create_user(
|
||||
self.connection, "cashier-a", "CashierPass123", "company", company_id=int(a)
|
||||
)
|
||||
actor = self.connection.execute(
|
||||
"SELECT * FROM users WHERE username = 'cashier-a'"
|
||||
).fetchone()
|
||||
manual_records.submit(
|
||||
self.connection,
|
||||
company_id=int(a),
|
||||
counterparty_company_id=int(b),
|
||||
occurred_at="2026-02-01T09:00:00",
|
||||
direction="incoming",
|
||||
amount="100.00",
|
||||
currency="CNY",
|
||||
funding_source="other",
|
||||
requested_subject="receivable",
|
||||
request_key="hel157-manual-1",
|
||||
actor=actor,
|
||||
)
|
||||
counts = dashboard.audit_counts(self.connection)
|
||||
self.assertEqual(1, counts["medium"])
|
||||
self.assertEqual(0, counts["high"])
|
||||
self.assertEqual(1, counts["total"])
|
||||
|
||||
def test_audit_total_equals_queue_sum(self) -> None:
|
||||
"""Homepage / badge / audit heading must share one backend total."""
|
||||
now = master_data.utc_now()
|
||||
a = self.connection.execute(
|
||||
"INSERT INTO companies (name, credit_code, cashier_name, status, created_at, updated_at) "
|
||||
"VALUES ('甲公司', NULL, NULL, 'active', ?, ?)",
|
||||
(now, now),
|
||||
).lastrowid
|
||||
self.connection.commit()
|
||||
master_data.submit_bank_account(
|
||||
self.connection,
|
||||
company_id=int(a),
|
||||
bank_name="工行",
|
||||
account_type="一般户",
|
||||
account_number="6222020000000002",
|
||||
start_date="2026-01-01",
|
||||
actor=None,
|
||||
)
|
||||
event_id = self.connection.execute(
|
||||
"INSERT INTO canonical_transfer_events (lifecycle, created_at) VALUES ('active', ?)",
|
||||
(now,),
|
||||
).lastrowid
|
||||
decision_id = self.connection.execute(
|
||||
"""
|
||||
INSERT INTO transfer_match_decisions (
|
||||
event_id, revision, effective_at, amount, currency, classification,
|
||||
pairing, locked, mode, rule_version, created_at
|
||||
) VALUES (?, 1, '2026-07-05T10:00:00', '100000.00', 'CNY', 'unresolved',
|
||||
'single', 0, 'manual', 'test', ?)
|
||||
""",
|
||||
(event_id, now),
|
||||
).lastrowid
|
||||
self.connection.execute(
|
||||
"INSERT INTO current_transfer_decisions (event_id, decision_id) VALUES (?, ?)",
|
||||
(event_id, decision_id),
|
||||
)
|
||||
self.connection.commit()
|
||||
|
||||
counts = dashboard.audit_counts(self.connection)
|
||||
payload = dashboard.build_dashboard(
|
||||
self.connection, from_date="2026-01-01", cutoff="2026-08-20"
|
||||
)
|
||||
self.assertEqual(counts, payload["audit"])
|
||||
self.assertEqual(
|
||||
counts["total"],
|
||||
counts["high"] + counts["medium"] + counts["low"],
|
||||
)
|
||||
self.assertEqual(1, counts["high"])
|
||||
self.assertEqual(1, counts["medium"])
|
||||
self.assertEqual(2, counts["total"])
|
||||
|
||||
def test_company_summaries_from_eligible_events(self) -> None:
|
||||
now = master_data.utc_now()
|
||||
a = self.connection.execute(
|
||||
@@ -180,6 +270,49 @@ class DashboardApiTests(unittest.TestCase):
|
||||
self.assertEqual(1, len(data["companies"]))
|
||||
self.assertEqual("甲公司", data["companies"][0]["name"])
|
||||
|
||||
def test_audit_counts_sync_after_account_review(self) -> None:
|
||||
"""Pending account raises dashboard.audit; approve brings total back down."""
|
||||
from bank_importer.db import connect as db_connect
|
||||
from bank_importer import master_data as md
|
||||
|
||||
status, _, raw = self.admin.get("/api/admin/companies")
|
||||
company_id = as_json(raw)["companies"][0]["id"]
|
||||
|
||||
connection = db_connect(self.db_path)
|
||||
try:
|
||||
account = md.submit_bank_account(
|
||||
connection,
|
||||
company_id=company_id,
|
||||
bank_name="工行",
|
||||
account_type="一般户",
|
||||
account_number="6222020000000099",
|
||||
start_date="2026-01-01",
|
||||
actor=None,
|
||||
)
|
||||
account_id = account["id"]
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
status, _, raw = self.admin.get("/api/admin/dashboard")
|
||||
before = as_json(raw)["audit"]
|
||||
self.assertEqual(200, status)
|
||||
self.assertGreaterEqual(before["medium"], 1)
|
||||
self.assertGreaterEqual(before["total"], 1)
|
||||
|
||||
status, _, raw = self.admin.post_json(
|
||||
f"/api/admin/accounts/{account_id}/review",
|
||||
{"decision": "approve", "reason": "HEL-157 sync test"},
|
||||
)
|
||||
self.assertEqual(200, status, raw)
|
||||
|
||||
status, _, raw = self.admin.get("/api/admin/dashboard")
|
||||
after = as_json(raw)["audit"]
|
||||
self.assertEqual(after["medium"], before["medium"] - 1)
|
||||
self.assertEqual(after["total"], before["total"] - 1)
|
||||
self.assertEqual(
|
||||
after["total"], after["high"] + after["medium"] + after["low"]
|
||||
)
|
||||
|
||||
def test_company_detail_missing(self) -> None:
|
||||
status, _, raw = self.admin.get(
|
||||
"/api/admin/dashboard/companies/999999?from=2026-01-01&cutoff=2026-08-20"
|
||||
|
||||
+21
-81
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="金牛集团管理端" />
|
||||
<title>管理端 · 金牛集团</title>
|
||||
<link rel="stylesheet" href="design-system.css?v=5" />
|
||||
<link rel="stylesheet" href="design-system.css?v=6" />
|
||||
</head>
|
||||
<body data-portal="admin">
|
||||
<a class="skip-link" href="#main-content">跳到主要内容</a>
|
||||
@@ -20,7 +20,7 @@
|
||||
<div class="nav-group">日常</div>
|
||||
<a class="active" data-view="dashboard" href="#dashboard"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><rect x="3" y="3" width="8" height="8" rx="1.5"/><rect x="13" y="3" width="8" height="5" rx="1.5"/><rect x="13" y="10" width="8" height="11" rx="1.5"/><rect x="3" y="13" width="8" height="8" rx="1.5"/></svg><span class="nav-label">管理总览</span></a>
|
||||
<a data-view="pair" href="#pair"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M7 8h13l-3.5-3.5M17 16H4l3.5 3.5"/></svg><span class="nav-label">往来查询</span></a>
|
||||
<a data-view="audit" href="#audit"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M9 11.5l2 2 4-4.5"/><rect x="4" y="3" width="16" height="18" rx="2"/></svg><span class="nav-label">审核中心</span><span class="nav-badge">6</span></a>
|
||||
<a data-view="audit" href="#audit"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M9 11.5l2 2 4-4.5"/><rect x="4" y="3" width="16" height="18" rx="2"/></svg><span class="nav-label">审核中心</span><span class="nav-badge" id="auditNavBadge" style="display: none;">0</span></a>
|
||||
<a data-view="flows" href="#flows"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M4 6h16M4 12h16M4 18h10"/></svg><span class="nav-label">流水管理</span></a>
|
||||
<div class="nav-group">基础与结账</div>
|
||||
<a data-view="companies" href="#companies"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M4 21V8l8-5 8 5v13"/><path d="M9 21v-6h6v6"/></svg><span class="nav-label">公司与账号</span></a>
|
||||
@@ -82,36 +82,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-2" style="margin-top: 14px;">
|
||||
<div class="card">
|
||||
<div class="card-head" style="margin-bottom: 10px;">
|
||||
<span class="card-title">各公司往来分布<span class="sub">期末净值 · 单位:万元</span></span>
|
||||
</div>
|
||||
<div id="dashBarChart" style="height: 220px; min-width: 0;" aria-label="各公司往来分布柱状图"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head" style="margin-bottom: 10px;">
|
||||
<span class="card-title">本周资金流入流出<span class="sub">集团内往来 · 单位:万元</span></span>
|
||||
</div>
|
||||
<div id="dashLineChart" style="height: 220px; min-width: 0;" aria-label="本周资金流入流出折线图"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="companyMasterDetail" style="margin-top: 14px; padding: 0; overflow: hidden;">
|
||||
<div class="grid grid-1-2" style="gap: 0; align-items: stretch; height: 492px;">
|
||||
<div style="border-right: 1px solid var(--border); display: flex; flex-direction: column; min-width: 0; min-height: 0;">
|
||||
<div class="card-head" style="padding: 10px 14px 8px; margin-bottom: 0; gap: 8px;">
|
||||
<div class="grid grid-480-1 dash-master-split">
|
||||
<div class="dash-master-pane dash-master-pane--list">
|
||||
<div class="card-head dash-master-head">
|
||||
<span class="card-title" style="min-width: 0;">公司往来合计<span class="sub" id="dashListSub" style="display: inline; margin-left: 6px;">单位:万元</span></span>
|
||||
<input class="input" id="company-search" aria-label="搜索公司" placeholder="搜索…" style="width: 120px; min-height: 28px; padding: 4px 8px; flex: none;" />
|
||||
</div>
|
||||
<div class="table-wrap" style="border: 0; border-radius: 0; flex: 1; min-height: 0; overflow: auto;">
|
||||
<table class="ds-table" id="dashCompanyTable" style="min-width: 0;">
|
||||
<div class="table-wrap dash-master-scroll">
|
||||
<table class="ds-table ds-table--compact" id="dashCompanyTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="padding: 6px 12px;">公司</th>
|
||||
<th class="num-col" style="padding: 6px 12px;">明细笔数</th>
|
||||
<th class="num-col" style="padding: 6px 12px;">期末净值</th>
|
||||
<th id="dashStatusHead" style="padding: 6px 12px;">状态</th>
|
||||
<th>公司</th>
|
||||
<th class="num-col">明细笔数</th>
|
||||
<th class="num-col">期末净值</th>
|
||||
<th id="dashStatusHead">状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="dashCompanyRows">
|
||||
@@ -120,20 +105,20 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; min-width: 0; min-height: 0;">
|
||||
<div class="card-head" style="padding: 10px 14px 8px; margin-bottom: 0;">
|
||||
<div class="dash-master-pane">
|
||||
<div class="card-head dash-master-head">
|
||||
<span class="card-title" id="dashDetailTitle">公司往来明细<span class="sub" id="dashDetailSub">选择左侧公司查看会计式分级明细</span></span>
|
||||
</div>
|
||||
<div class="table-wrap" id="dashDetailWrap" style="border: 0; border-radius: 0; flex: 1; min-height: 0; overflow: auto;">
|
||||
<table class="ds-table" id="dashDetailTable" style="min-width: 0;">
|
||||
<div class="table-wrap dash-master-scroll" id="dashDetailWrap">
|
||||
<table class="ds-table ds-table--compact" id="dashDetailTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="padding: 6px 12px;">对方 / 摘要</th>
|
||||
<th class="num-col" style="padding: 6px 12px;">笔数</th>
|
||||
<th class="num-col" style="padding: 6px 12px;">期初</th>
|
||||
<th class="num-col" style="padding: 6px 12px;">本期借方</th>
|
||||
<th class="num-col" style="padding: 6px 12px;">本期贷方</th>
|
||||
<th class="num-col" style="padding: 6px 12px;">期末</th>
|
||||
<th>对方 / 摘要</th>
|
||||
<th class="num-col">笔数</th>
|
||||
<th class="num-col">期初</th>
|
||||
<th class="num-col">本期借方</th>
|
||||
<th class="num-col">本期贷方</th>
|
||||
<th class="num-col">期末</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="dashDetailRows">
|
||||
@@ -347,51 +332,6 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="auditRows">
|
||||
<tr data-audit-type="断档" data-company="金牛置业">
|
||||
<td><span class="pill pill-danger">高</span></td>
|
||||
<td><span class="cell-main">金牛置业 · 中行尾号 8821</span><span class="cell-sub">07-06 至 07-16 无流水</span></td>
|
||||
<td>流水断档</td>
|
||||
<td>2026-07</td>
|
||||
<td class="wrap">11 天无流水,与金牛煤业 320 万煤炭采购款往来无法归集,阻断 7 月结账</td>
|
||||
<td><span class="pill pill-info">待审核</span></td>
|
||||
<td><div class="row" style="gap:6px;"><button type="button" class="btn btn-sm btn-primary" data-audit-action="approve">通过</button><button type="button" class="btn btn-sm btn-danger" data-audit-action="reject">驳回</button></div></td>
|
||||
</tr>
|
||||
<tr data-audit-type="单边" data-company="金牛新能源">
|
||||
<td><span class="pill pill-warn">中</span></td>
|
||||
<td><span class="cell-main">金牛新能源 ↔ 金牛贸易</span><span class="cell-sub">3 笔单边合计 486 万</span></td>
|
||||
<td>单边匹配</td>
|
||||
<td>2026-07</td>
|
||||
<td class="wrap">单边支付无对方对应流水,往来差异挂账,待对方选择流水佐证</td>
|
||||
<td><span class="pill pill-info">待审核</span></td>
|
||||
<td><div class="row" style="gap:6px;"><button type="button" class="btn btn-sm btn-primary" data-audit-action="approve">通过</button><button type="button" class="btn btn-sm btn-danger" data-audit-action="reject">驳回</button></div></td>
|
||||
</tr>
|
||||
<tr data-audit-type="科目" data-company="金牛煤业">
|
||||
<td><span class="pill pill-warn">中</span></td>
|
||||
<td><span class="cell-main">金牛煤业 · 手工记录</span><span class="cell-sub">场地押金 42 万</span></td>
|
||||
<td>科目确认</td>
|
||||
<td>2026-07</td>
|
||||
<td class="wrap">「应收 / 其他应收」科目待判定,影响科目汇总口径</td>
|
||||
<td><span class="pill pill-info">待审核</span></td>
|
||||
<td><div class="row" style="gap:6px;"><button type="button" class="btn btn-sm btn-primary" data-audit-action="approve">通过</button><button type="button" class="btn btn-sm btn-danger" data-audit-action="reject">驳回</button></div></td>
|
||||
</tr>
|
||||
<tr data-audit-type="起算" data-company="金牛物流">
|
||||
<td><span class="pill pill-danger">高</span></td>
|
||||
<td><span class="cell-main">金牛物流 · 工行基本户</span><span class="cell-sub">期初与上年结转差 28.6 万</span></td>
|
||||
<td>起算区间校准</td>
|
||||
<td>2026-01 起算</td>
|
||||
<td class="wrap">期初余额与上年结转不衔接,全年累计数失真,需校准起算区间</td>
|
||||
<td><span class="pill pill-info">待审核</span></td>
|
||||
<td><div class="row" style="gap:6px;"><button type="button" class="btn btn-sm btn-primary" data-audit-action="approve">通过</button><button type="button" class="btn btn-sm btn-danger" data-audit-action="reject">驳回</button></div></td>
|
||||
</tr>
|
||||
<tr data-audit-type="手工" data-company="金牛贸易">
|
||||
<td><span class="pill pill-warn">中</span></td>
|
||||
<td><span class="cell-main">金牛贸易 · 手工补录</span><span class="cell-sub">现金缴存 64 万</span></td>
|
||||
<td>公司手工记录</td>
|
||||
<td>2026-07</td>
|
||||
<td class="wrap">无银行回单附件,需人工核实后方可入账</td>
|
||||
<td><span class="pill pill-info">待审核</span></td>
|
||||
<td><div class="row" style="gap:6px;"><button type="button" class="btn btn-sm btn-primary" data-audit-action="approve">通过</button><button type="button" class="btn btn-sm btn-danger" data-audit-action="reject">驳回</button></div></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="table-foot">
|
||||
|
||||
+44
-103
@@ -872,12 +872,14 @@ async function renderAdminAccountReviews() {
|
||||
(result?.accounts || []).forEach((account) => appendAdminReviewRow(account, "account"));
|
||||
updateAuditCounts();
|
||||
updatePendingAccountNotice();
|
||||
await refreshAuditCountsFromApi();
|
||||
}
|
||||
|
||||
function renderStoredAdminReviews() {
|
||||
if (!$("#auditRows")) return;
|
||||
$$('[data-stored-review]', $("#auditRows")).forEach((row) => row.remove());
|
||||
readStoredRecords(storageKeys.manual).forEach((record) => appendAdminReviewRow(record, "manual"));
|
||||
// 管理端待审列表只展示后端权威队列(当前为待复核银行账户等),
|
||||
// 不再混入 localStorage 演示手工单,避免角标/首页/列表三处口径分裂。
|
||||
renderAdminAccountReviews();
|
||||
}
|
||||
|
||||
@@ -899,26 +901,51 @@ function updateAuditCounts() {
|
||||
const badge = $(".tab-count", button);
|
||||
if (badge) badge.textContent = count;
|
||||
});
|
||||
const badge = $('.side-nav a[data-view="audit"] .nav-badge');
|
||||
if (badge) {
|
||||
badge.textContent = unresolved.length;
|
||||
badge.style.display = unresolved.length ? "" : "none";
|
||||
}
|
||||
const pending = $("#pending-count");
|
||||
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) {
|
||||
// 角标 / 首页待审核卡 / 审核中心标题数统一走后端口径(见 applyAuditCounts)。
|
||||
// 此处仅在尚无 API 结果时,用真实列表行数作短暂回退,避免演示写死数字。
|
||||
if (!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 });
|
||||
applyAuditCounts({ total: unresolved.length, high, medium, low });
|
||||
}
|
||||
}
|
||||
|
||||
function applyAuditCounts(audit, { fromApi = false } = {}) {
|
||||
if (!audit) return;
|
||||
const total = Number(audit.total) || 0;
|
||||
const high = Number(audit.high) || 0;
|
||||
const medium = Number(audit.medium) || 0;
|
||||
const low = Number(audit.low) || 0;
|
||||
state.dashAudit = { total, high, medium, low };
|
||||
if (fromApi) state.dashAuditFromApi = true;
|
||||
|
||||
const totalEl = $("#dashAuditTotal");
|
||||
const footEl = $("#dashAuditFoot");
|
||||
if (totalEl) totalEl.innerHTML = `${total}<span class="unit">项</span>`;
|
||||
if (footEl) footEl.textContent = `高 ${high} 项 · 中 ${medium} 项 · 其余 ${low} 项低风险`;
|
||||
|
||||
const badge = $("#auditNavBadge") || $('.side-nav a[data-view="audit"] .nav-badge');
|
||||
if (badge) {
|
||||
badge.textContent = String(total);
|
||||
badge.style.display = total > 0 ? "" : "none";
|
||||
}
|
||||
const pending = $("#pending-count");
|
||||
if (pending) pending.textContent = String(total);
|
||||
}
|
||||
|
||||
async function refreshAuditCountsFromApi() {
|
||||
const from = await resolveDashStartDate();
|
||||
const response = await fetch(`/api/admin/dashboard?from=${encodeURIComponent(from)}`).catch(() => null);
|
||||
if (!response?.ok) return null;
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!data || data.status !== "ok" || !data.audit) return null;
|
||||
applyAuditCounts(data.audit, { fromApi: true });
|
||||
return data.audit;
|
||||
}
|
||||
|
||||
function companyStatusBadge(status) {
|
||||
if (status === "preparing") return { className: "neutral", label: "筹备中" };
|
||||
if (status === "disabled") return { className: "danger", label: "已停用" };
|
||||
@@ -1026,91 +1053,7 @@ function statusPill(label) {
|
||||
}
|
||||
|
||||
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>`;
|
||||
applyAuditCounts(audit, { fromApi: true });
|
||||
}
|
||||
|
||||
function renderDashCompanyRows(companies, selectedId) {
|
||||
@@ -1125,10 +1068,10 @@ function renderDashCompanyRows(companies, selectedId) {
|
||||
tr.className = "clickable";
|
||||
tr.dataset.companyId = company.id;
|
||||
if (String(company.id) === String(selectedId)) {
|
||||
tr.style.background = "var(--accent-soft)";
|
||||
tr.classList.add("is-selected");
|
||||
}
|
||||
const nameTd = document.createElement("td");
|
||||
nameTd.style.cssText = "white-space: nowrap; max-width: 220px; overflow: hidden; text-overflow: ellipsis;";
|
||||
nameTd.className = "dash-company-name";
|
||||
nameTd.title = company.name;
|
||||
nameTd.textContent = company.name;
|
||||
const countTd = document.createElement("td");
|
||||
@@ -1251,13 +1194,10 @@ async function loadAdminDashboard() {
|
||||
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);
|
||||
@@ -1384,6 +1324,7 @@ function initAdmin() {
|
||||
actionCell.innerHTML = `<span class="meta">${approved ? "已通过" : "已驳回"} · 系统管理员</span>`;
|
||||
}
|
||||
updateAuditCounts();
|
||||
await refreshAuditCountsFromApi();
|
||||
showToast("审核结果已记录", approved ? "已通过的数据取得相应使用或核算资格" : "当前记录不参与自动核算", "success");
|
||||
}
|
||||
|
||||
|
||||
+51
-1
@@ -213,6 +213,53 @@ p { margin: 0; }
|
||||
.grid-2-1 { grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); align-items: start; }
|
||||
.grid-1-2 { grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); align-items: start; }
|
||||
.grid-3-2 { grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); align-items: start; }
|
||||
/* 管理端首页主从:左栏约 480px(定稿 HEL-153),右侧明细更宽 */
|
||||
.grid-480-1 { grid-template-columns: minmax(0, 480px) minmax(0, 1fr); align-items: stretch; }
|
||||
|
||||
.dash-master-split {
|
||||
gap: 0;
|
||||
height: 492px;
|
||||
}
|
||||
.dash-master-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.dash-master-pane--list {
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
.dash-master-head {
|
||||
padding: 10px 14px 8px;
|
||||
margin-bottom: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
.dash-master-scroll {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
.ds-table.ds-table--compact {
|
||||
min-width: 0;
|
||||
}
|
||||
.ds-table.ds-table--compact th,
|
||||
.ds-table.ds-table--compact td {
|
||||
padding: 6px 12px;
|
||||
}
|
||||
.ds-table tbody tr.is-selected {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.ds-table tbody tr.is-selected:hover {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.dash-company-name {
|
||||
white-space: nowrap;
|
||||
max-width: 280px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.row { display: flex; align-items: center; gap: 10px; }
|
||||
.row-between { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.stack { display: flex; flex-direction: column; gap: 14px; }
|
||||
@@ -223,7 +270,10 @@ p { margin: 0; }
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.grid-4, .grid-5 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.grid-3, .grid-2-1, .grid-1-2, .grid-3-2 { grid-template-columns: minmax(0, 1fr); }
|
||||
.grid-3, .grid-2-1, .grid-1-2, .grid-3-2, .grid-480-1 { grid-template-columns: minmax(0, 1fr); }
|
||||
.dash-master-split { height: auto; max-height: none; }
|
||||
.dash-master-pane--list { border-right: 0; border-bottom: 1px solid var(--border); }
|
||||
.dash-master-pane { max-height: 420px; }
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.shell { grid-template-columns: 1fr; }
|
||||
|
||||
Reference in New Issue
Block a user