Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
551909ad0c | ||
|
|
eefdc92ab6 | ||
|
|
fdc5616115 | ||
|
|
c4f7573653 | ||
|
|
5b3ebad757 |
@@ -45,6 +45,8 @@ SHA-256 内容哈希不可变保存,重复上传返回 `duplicate` 状态并
|
||||
公司账号创建时生成随机一次性初始密码,只在创建响应中显示一次,
|
||||
首次登录强制改密(「重置密码」同样生成随机一次性密码并吊销会话)。
|
||||
- 会话有效期 8 小时;同一账号同一 IP 10 分钟内登录失败 5 次将被限流。
|
||||
内网测试环境可设 `APP_LOGIN_RATE_LIMIT_DISABLED=1` 暂时关闭该锁定
|
||||
(默认未设置 = 保持限流,正式环境不得开启该变量)。
|
||||
|
||||
访问地址(服务默认监听 `0.0.0.0:4173`,同局域网设备把 `127.0.0.1` 换成本机局域网 IP 即可访问;可用环境变量 `APP_HOST` / `APP_PORT` 覆盖):
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# 总账项目手册
|
||||
|
||||
最后核对:2026-08-23。核对依据是当前 `main` 最新代码(`5f21527`)、数据库迁移、接口、测试和近期提交记录。
|
||||
|
||||
这是接手本项目的第一站。阅读顺序如下:
|
||||
|
||||
1. [项目需求](项目需求.md):先知道系统要解决什么问题,以及哪些规则绝不能碰。
|
||||
2. [最新进度](最新进度.md):再确认哪些能力已经真实完成,哪些仍未完成。
|
||||
3. [任务清单](任务清单.md):最后挑选下一项工作,避免重复做已经完成的事。
|
||||
4. 需要技术细节时,再查看 `decisions/` 中的技术决定和 `issues/` 中保留的历史任务规格。
|
||||
|
||||
## 维护规矩
|
||||
|
||||
- 每新增、开始、完成、取消一项任务,负责的智能体都要在同一次提交里更新 `最新进度.md` 和 `任务清单.md`。
|
||||
- 新功能改变产品规则时,同时更新 `项目需求.md`;技术方案有变化时,补充 `decisions/` 的决定记录。
|
||||
- 写进手册的“已完成”必须能在代码、测试或已合并提交中找到依据;只有计划、页面演示或口头讨论的内容,一律写为“未完成”。
|
||||
- 不删除历史文档。旧内容与当前代码不一致时,在旧文件开头加“已过时,仅留档备查”提示,并在本目录写新版说明。
|
||||
|
||||
## 旧交接说明
|
||||
|
||||
`HANDOFF.md` 是 2026-08-06 的历史交接稿,已经不反映当前实现,保留仅作对照。当前事实以本手册三份正文为准。
|
||||
@@ -26,6 +26,9 @@
|
||||
- 计数来自 `login_attempts` 表,窗口为滚动 10 分钟;触发后返回 429,
|
||||
且在窗口内不再记录新尝试,行为确定、可测试。
|
||||
- 失败提示统一为「账号或密码不正确」,不泄露是哪一部分错误。
|
||||
- 运维开关:环境变量 `APP_LOGIN_RATE_LIMIT_DISABLED` 设为 `1/true/yes/on`
|
||||
时完全跳过限流检查,仅限内网测试环境临时使用;默认未设置,保持
|
||||
「5 次失败锁 10 分钟」的生产安全策略不变。
|
||||
|
||||
## 角色与公司绑定
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# 任务清单
|
||||
|
||||
最后核对:2026-08-23。状态以当前代码和已合并提交为准。
|
||||
|
||||
## 正在做
|
||||
|
||||
- 当前没有已确认正在进行的业务功能开发。下一项工作开始前,先在此处写明负责人、范围和验收标准。
|
||||
|
||||
## 已做完
|
||||
|
||||
- 六类银行流水模板识别与解析。
|
||||
- 数据库迁移、不可变原始证据、导入批次和重复导入处理。
|
||||
- 管理员/公司用户登录、密码策略、会话、限流、服务端公司隔离和审计。
|
||||
- 动态公司、用户、银行账户、别名与账户审核流程。
|
||||
- 导入接口加固、逐工作表确认、失败诊断和导出。
|
||||
- 规范转账事件、双边匹配、同公司调拨排除与个人过账映射。
|
||||
- 登录页视觉融合改版。
|
||||
|
||||
## 还没安排
|
||||
|
||||
- 公司间余额和四类往来科目的正式计算,已批准手工记录入账,以及从余额逐层查回原始流水。
|
||||
- 起算日、期初余额、流水覆盖断档检测和无业务校准。
|
||||
- 月结、重开、调整/冲销审批与审计报告。
|
||||
- 服务端流水查询、筛选、分页和可追溯导出。
|
||||
- 站内提醒与状态流转;外部通知只预留扩展位置,不默认启用。
|
||||
- 前端全面接入真实接口,移除模拟金额、静态业务记录和 `localStorage` 业务状态。
|
||||
- 测试环境之外的运行保障,包括 HTTPS、备份、监控和正式部署方案。
|
||||
|
||||
每完成或新增一项任务,必须在同一次提交里把它从本清单的相应栏目移走或补上,并同步更新 `最新进度.md`。
|
||||
@@ -0,0 +1,24 @@
|
||||
# 最新进度
|
||||
|
||||
最后核对:2026-08-23。以下“已完成”均以当前代码、数据库迁移、接口和自动化测试为依据,不把页面演示当作真实功能。
|
||||
|
||||
## 已经真实完成
|
||||
|
||||
- 支持中信、农行、工行、建行、河南农商行、郑州银行六类样本的 `.xls` / `.xlsx` 流水解析;能识别变动的表头位置和列顺序,并校验余额连续性。
|
||||
- 已有 SQLite 数据库和 5 次版本迁移。原始文件按内容哈希保存,导入批次、工作表、源行和异常都有记录;原始文件、工作表和源行被数据库规则保护,不能直接改或删。
|
||||
- 已实现管理员与公司用户登录、首次改密、会话失效、登录失败限流、服务端权限隔离和审计记录。
|
||||
- 已实现公司、用户、银行账户、别名等主数据管理;公司提交的银行账户需管理员审核后才能参与上传和识别。
|
||||
- 已实现导入、逐工作表确认或忽略、失败诊断、重复上传处理和 CSV 导出;未确认的工作表不进入后续处理。
|
||||
- 已实现规范转账事件和双边流水匹配。匹配决定保留历史,无法自动判断的记录进入人工审核;同公司调拨、外部流水和未锁定的单边记录不进入已确认的公司间往来。
|
||||
- 已有登录页、总账端和公司端页面,最近一次合并完成了登录页视觉改版。
|
||||
|
||||
## 仍未完成或不能当成已完成
|
||||
|
||||
- 公司间余额与会计科目的正式计算、已批准手工记录入账和逐层余额追溯尚未完成。
|
||||
- 全局起算日、期初余额、流水覆盖断档、无业务校准、月结、重开和调整审批尚未完成。
|
||||
- 提醒、完整的服务端查询导出,以及前端彻底移除演示数据和浏览器本地业务状态尚未完成。
|
||||
- 生产部署所需的 HTTPS、反向代理、备份、监控和正式运行保障尚未完成;本项目当前只允许测试环境部署。
|
||||
|
||||
## 最近验证
|
||||
|
||||
2026-08-23 已运行完整 Python 自动化测试:解析、持久化、认证与权限、导入接口、主数据和双边匹配相关测试均通过。后续修改功能时,必须再次运行完整测试并在本文件记录结果。
|
||||
@@ -0,0 +1,26 @@
|
||||
# 项目需求
|
||||
|
||||
## 这个系统做什么
|
||||
|
||||
这是河南金牛实业集团有限公司内部各公司之间的资金往来管理系统。它把各公司的银行流水集中起来,识别同一笔公司间转账的两边记录,形成可以一路查回原始银行流水的往来结果。
|
||||
|
||||
系统有两个独立入口:
|
||||
|
||||
- 总账管理端:管理公司、公司账号、银行账户、导入记录、异常和公司间转账匹配。
|
||||
- 公司出纳端:只处理本公司的账户登记、流水上传、工作表确认和本公司相关异常。
|
||||
|
||||
## 必须做到的业务规则
|
||||
|
||||
- 银行原始文件和原始流水是证据,导入后不能直接修改或删除;修正必须通过可追溯的审核决定、冲销或调整完成。
|
||||
- 一笔公司间转账的收、付两边只能算作一个经济事件,不能因为有两条银行流水就重复计算。
|
||||
- 同一家公司不同银行账户之间调拨,不计入公司间往来。
|
||||
- 账号、金额、方向、日期和余额等可核对信息优先于户名;信息不完整或有歧义时进入异常审核,不能猜。
|
||||
- 已确认金额和待确认金额必须分开;所有余额都要说明统计截止日期和未决金额。
|
||||
- 公司用户只能查看、上传和导出本公司的数据。这个限制由服务端执行,页面上“看不见”不等于有权限限制。
|
||||
- 起算日前的流水必须保留,但不参与期内计算;期初余额、流水覆盖断档、无业务校准、月结和调整都要留痕。
|
||||
|
||||
## 当前技术边界
|
||||
|
||||
项目使用 Python 标准库服务、SQLite 数据库和浏览器页面。银行模板支持 `.xls` 与 `.xlsx`,按表头识别而非依赖固定行列。金额使用精确十进制,真实运行数据放在 `data/`,不进入代码仓库。
|
||||
|
||||
系统目前仍是测试环境项目,不能当作已完成生产部署:正式上线前仍需补齐核算、期初与断档、月结、备份、监控和 HTTPS 等能力。
|
||||
@@ -13,6 +13,7 @@ from __future__ import annotations
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import secrets
|
||||
import sqlite3
|
||||
import string
|
||||
@@ -28,6 +29,17 @@ RATE_LIMIT_WINDOW_MINUTES = 10
|
||||
INITIAL_PASSWORD_LENGTH = 12
|
||||
|
||||
|
||||
def _env_flag(value: str | None) -> bool:
|
||||
"""Parse a yes/no style environment flag; blank/absent means False."""
|
||||
return (value or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
# Ops switch for internal test environments: APP_LOGIN_RATE_LIMIT_DISABLED=1
|
||||
# turns off the login-failure lockout entirely. The default (unset) keeps the
|
||||
# production policy — 5 failures within 10 minutes lock the (账号, IP) pair.
|
||||
RATE_LIMIT_DISABLED = _env_flag(os.environ.get("APP_LOGIN_RATE_LIMIT_DISABLED"))
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash ``password`` as ``pbkdf2_sha256$<iterations>$<salt_hex>$<hash_hex>``."""
|
||||
salt = secrets.token_bytes(16)
|
||||
@@ -146,6 +158,7 @@ def authenticate(
|
||||
Every non-rate-limited attempt is recorded in ``login_attempts`` and
|
||||
``audit_log``; the password itself is never stored anywhere.
|
||||
"""
|
||||
if not RATE_LIMIT_DISABLED:
|
||||
window_start = (
|
||||
datetime.now(timezone.utc) - timedelta(minutes=RATE_LIMIT_WINDOW_MINUTES)
|
||||
).isoformat()
|
||||
|
||||
@@ -4,6 +4,7 @@ from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
import sqlite3
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from bank_importer import auth
|
||||
from bank_importer.db import connect, migrate, utc_now
|
||||
@@ -167,6 +168,40 @@ class AuthenticateTests(AuthTestCase):
|
||||
self.assertIsNotNone(user)
|
||||
self.assertIsNone(reason)
|
||||
|
||||
def test_env_flag_parses_truthy_and_falsy_values(self) -> None:
|
||||
cases = {
|
||||
"1": True,
|
||||
"true": True,
|
||||
"YES": True,
|
||||
" on ": True,
|
||||
"": False,
|
||||
"0": False,
|
||||
"false": False,
|
||||
"off": False,
|
||||
None: False,
|
||||
}
|
||||
for value, expected in cases.items():
|
||||
with self.subTest(value=value):
|
||||
self.assertEqual(expected, auth._env_flag(value))
|
||||
|
||||
def test_rate_limit_enabled_by_default(self) -> None:
|
||||
self.assertFalse(auth.RATE_LIMIT_DISABLED)
|
||||
|
||||
def test_env_switch_disables_rate_limit_but_not_credential_checks(self) -> None:
|
||||
self.create_company_user()
|
||||
with mock.patch.object(auth, "RATE_LIMIT_DISABLED", True):
|
||||
for _ in range(auth.RATE_LIMIT_MAX_FAILURES + 2):
|
||||
user, reason = auth.authenticate(
|
||||
self.connection, "cashier-a", "Wrong999", "10.0.0.1"
|
||||
)
|
||||
self.assertIsNone(user)
|
||||
self.assertEqual("bad_credentials", reason)
|
||||
user, reason = auth.authenticate(
|
||||
self.connection, "cashier-a", "Init1234", "10.0.0.1"
|
||||
)
|
||||
self.assertIsNotNone(user)
|
||||
self.assertIsNone(reason)
|
||||
|
||||
|
||||
class SessionTests(AuthTestCase):
|
||||
def test_create_and_resolve_roundtrip(self) -> None:
|
||||
|
||||
@@ -211,6 +211,44 @@ class SettingsAndRemindersTests(unittest.TestCase):
|
||||
status, _ = cashier.get(path)
|
||||
self.assertEqual(403, status, path)
|
||||
|
||||
# Company session must get 403 on admin write endpoints (settings /
|
||||
# reminders / review decisions), not just GETs.
|
||||
write_calls = (
|
||||
("POST /api/admin/settings", lambda: cashier.post_json(
|
||||
"/api/admin/settings",
|
||||
{"closing_day": "10", "start_date": "2026-01-01", "auto_remind": "0", "remind_days": "3"},
|
||||
)),
|
||||
("POST /api/admin/reminders/send", lambda: cashier.post_json(
|
||||
"/api/admin/reminders/send", {"company_id": self.company_id}
|
||||
)),
|
||||
("POST /api/admin/accounts/1/review", lambda: cashier.post_json(
|
||||
"/api/admin/accounts/1/review", {"decision": "approve"}
|
||||
)),
|
||||
("POST /api/admin/transfer-events/1/decisions", lambda: cashier.post_json(
|
||||
"/api/admin/transfer-events/1/decisions",
|
||||
{"action": "confirm_single", "reason": "越权"},
|
||||
)),
|
||||
("POST /api/admin/manual-records/1/decisions", lambda: cashier.post_json(
|
||||
"/api/admin/manual-records/1/decisions",
|
||||
{"action": "approve_new", "reason": "越权"},
|
||||
)),
|
||||
("POST /api/admin/intercompany/events/1/subject-decisions", lambda: cashier.post_json(
|
||||
"/api/admin/intercompany/events/1/subject-decisions",
|
||||
{"subject": "other_receivable", "reason": "越权"},
|
||||
)),
|
||||
("POST /api/admin/intercompany/events/1/adjustments", lambda: cashier.post_json(
|
||||
"/api/admin/intercompany/events/1/adjustments",
|
||||
{"action": "reverse", "reason": "越权"},
|
||||
)),
|
||||
)
|
||||
for label, call in write_calls:
|
||||
status, data = call()
|
||||
self.assertEqual(403, status, f"{label}: {data}")
|
||||
# Settings must be unchanged after the forbidden write attempt.
|
||||
status, data = self.admin.get("/api/admin/settings")
|
||||
self.assertEqual(200, status)
|
||||
self.assertEqual("5", as_json(data)["settings"]["closing_day"])
|
||||
|
||||
|
||||
class SettingsModuleTests(unittest.TestCase):
|
||||
"""Unit tests for the settings module on a fresh in-memory database."""
|
||||
|
||||
+51
-26
@@ -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=6" />
|
||||
<link rel="stylesheet" href="design-system.css?v=8" />
|
||||
</head>
|
||||
<body data-portal="admin">
|
||||
<a class="skip-link" href="#main-content">跳到主要内容</a>
|
||||
@@ -82,33 +82,53 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="companyMasterDetail" style="margin-top: 14px; padding: 0; overflow: hidden;">
|
||||
<div class="card dash-master" id="companyMasterDetail">
|
||||
<div class="card-head dash-master-head">
|
||||
<span class="card-title" style="min-width: 0;">
|
||||
公司往来合计 · 往来明细
|
||||
<span class="sub" id="dashListSub">单位:万元 · 左侧选择公司,右侧查看其往来明细</span>
|
||||
</span>
|
||||
<div class="dash-master-head-actions">
|
||||
<span class="meta" id="dashMasterMeta">—</span>
|
||||
<input class="input" id="company-search" aria-label="搜索公司" placeholder="搜索公司…" />
|
||||
</div>
|
||||
</div>
|
||||
<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 dash-master-scroll">
|
||||
<table class="ds-table ds-table--compact" id="dashCompanyTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>公司</th>
|
||||
<th class="num-col">明细笔数</th>
|
||||
<th class="num-col">期末净值</th>
|
||||
<th id="dashStatusHead">状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="dashCompanyRows">
|
||||
<tr class="loading-row"><td colspan="4">加载中…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="dash-company-list" id="dashCompanyRows" role="listbox" aria-label="公司往来合计">
|
||||
<div class="dash-company-loading muted">加载中…</div>
|
||||
</div>
|
||||
</div>
|
||||
<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 class="dash-master-pane dash-master-pane--detail">
|
||||
<div class="dash-detail-toolbar">
|
||||
<div class="dash-detail-heading">
|
||||
<span class="dash-detail-name" id="dashDetailTitle">请选择左侧公司</span>
|
||||
<span class="meta" id="dashDetailSub">按对方公司分组 · 二级默认收起 · 已确认 / 待确认分列</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dash-detail-summary" id="dashDetailSummary" hidden>
|
||||
<div class="dash-summary-item">
|
||||
<span class="dash-summary-label">笔数</span>
|
||||
<span class="dash-summary-value num" id="dashSumCount">0</span>
|
||||
</div>
|
||||
<div class="dash-summary-item">
|
||||
<span class="dash-summary-label">期初</span>
|
||||
<span class="dash-summary-value num" id="dashSumOpening">—</span>
|
||||
</div>
|
||||
<div class="dash-summary-item">
|
||||
<span class="dash-summary-label">本期借方</span>
|
||||
<span class="dash-summary-value num" id="dashSumDebit">0.00</span>
|
||||
</div>
|
||||
<div class="dash-summary-item">
|
||||
<span class="dash-summary-label">本期贷方</span>
|
||||
<span class="dash-summary-value num" id="dashSumCredit">0.00</span>
|
||||
</div>
|
||||
<div class="dash-summary-item">
|
||||
<span class="dash-summary-label">期末</span>
|
||||
<span class="dash-summary-value num" id="dashSumEnding">0.00</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dash-detail-body" id="dashDetailBody">
|
||||
<div class="table-wrap dash-master-scroll" id="dashDetailWrap">
|
||||
<table class="ds-table ds-table--compact" id="dashDetailTable">
|
||||
<thead>
|
||||
@@ -126,6 +146,11 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="dash-detail-empty" id="dashDetailEmpty" hidden>
|
||||
<div class="e-title">该公司期间内暂无已归集往来</div>
|
||||
<div class="e-desc" id="dashDetailEmptyDesc">期初 — · 导入流水并归集后在此展示</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -403,7 +428,7 @@
|
||||
</table>
|
||||
<div class="table-foot">
|
||||
<span id="flowCount">共 0 笔</span>
|
||||
<span class="meta" id="flowsRangeMeta">数据范围:2026-01-01 起算 · 每月 5 日结账(7 月顺延至 08-29)</span>
|
||||
<span class="meta" id="flowsRangeMeta">数据范围:加载中…</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -493,7 +518,7 @@
|
||||
<div class="field" style="margin-bottom: 14px;">
|
||||
<label for="cs-day">每月结账日</label>
|
||||
<input class="input num-input" type="number" name="closingDay" id="cs-day" min="1" max="28" value="5" />
|
||||
<span class="hint">每月几号执行上月结账 · 2026-07 因故顺延至 08-29</span>
|
||||
<span class="hint">每月几号执行上月结账</span>
|
||||
</div>
|
||||
<div class="field" style="margin-bottom: 16px;">
|
||||
<label>自动提醒</label>
|
||||
@@ -917,6 +942,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="toast-region" id="toastRegion" aria-live="polite"></div>
|
||||
<script src="app.js?v=6"></script>
|
||||
<script src="app.js?v=9"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+145
-50
@@ -1145,49 +1145,48 @@ function netClass(value) {
|
||||
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) {
|
||||
applyAuditCounts(audit, { fromApi: true });
|
||||
}
|
||||
|
||||
function renderDashCompanyRows(companies, selectedId) {
|
||||
const tbody = $("#dashCompanyRows");
|
||||
if (!tbody) return;
|
||||
const list = $("#dashCompanyRows");
|
||||
if (!list) return;
|
||||
if (!companies.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="muted">暂无公司或尚无归集往来</td></tr>';
|
||||
list.innerHTML = '<div class="dash-company-empty muted">暂无公司或尚无归集往来</div>';
|
||||
return;
|
||||
}
|
||||
tbody.replaceChildren(...companies.map((company) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "clickable";
|
||||
tr.dataset.companyId = company.id;
|
||||
const monthLabel = state.dashPeriodMonth ? `${state.dashPeriodMonth}月` : "";
|
||||
list.replaceChildren(...companies.map((company) => {
|
||||
const item = document.createElement("div");
|
||||
item.className = "dash-company-item";
|
||||
item.setAttribute("role", "option");
|
||||
item.dataset.companyId = company.id;
|
||||
item.tabIndex = 0;
|
||||
if (String(company.id) === String(selectedId)) {
|
||||
tr.classList.add("is-selected");
|
||||
item.classList.add("is-selected");
|
||||
item.setAttribute("aria-selected", "true");
|
||||
} else {
|
||||
item.setAttribute("aria-selected", "false");
|
||||
}
|
||||
const nameTd = document.createElement("td");
|
||||
nameTd.className = "dash-company-name";
|
||||
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;
|
||||
const main = document.createElement("div");
|
||||
main.className = "dash-company-main";
|
||||
const name = document.createElement("span");
|
||||
name.className = "dash-company-name";
|
||||
name.title = company.name;
|
||||
name.textContent = company.name;
|
||||
const meta = document.createElement("span");
|
||||
meta.className = "dash-company-meta";
|
||||
const statusLabel = company.period_status_label || "—";
|
||||
meta.textContent = monthLabel
|
||||
? `${company.detail_count ?? 0}笔 · ${monthLabel} ${statusLabel}`
|
||||
: `${company.detail_count ?? 0}笔 · ${statusLabel}`;
|
||||
main.append(name, meta);
|
||||
const net = document.createElement("span");
|
||||
net.className = `dash-company-net ${netClass(company.net_wan)}`;
|
||||
net.textContent = formatWan(company.net_wan);
|
||||
item.append(main, net);
|
||||
return item;
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1199,22 +1198,94 @@ function chevronSvg(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="m9 18 6-6-6-6"/></svg>';
|
||||
}
|
||||
|
||||
function renderDashDetail(payload, { expandFirst = false } = {}) {
|
||||
function formatPlainWan(value) {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return "0.00";
|
||||
return Math.abs(num).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function updateDashDetailSummary(company, payload) {
|
||||
const bar = $("#dashDetailSummary");
|
||||
if (!bar) return;
|
||||
if (!company) {
|
||||
bar.hidden = true;
|
||||
return;
|
||||
}
|
||||
bar.hidden = false;
|
||||
const count = payload?.groups
|
||||
? payload.groups.reduce((sum, g) => sum + Number(g.count || 0), 0)
|
||||
: Number(company.detail_count || 0);
|
||||
const debit = payload?.groups
|
||||
? payload.groups.reduce((sum, g) => sum + Number(g.debit_wan || 0), 0)
|
||||
: Number(company.debit_wan || 0);
|
||||
const credit = payload?.groups
|
||||
? payload.groups.reduce((sum, g) => sum + Number(g.credit_wan || 0), 0)
|
||||
: Number(company.credit_wan || 0);
|
||||
const ending = payload?.groups
|
||||
? payload.groups.reduce((sum, g) => sum + Number(g.ending_wan || 0), 0)
|
||||
: Number(company.net_wan || 0);
|
||||
const setText = (id, text, className = "") => {
|
||||
const el = $(id);
|
||||
if (!el) return;
|
||||
el.textContent = text;
|
||||
el.className = `dash-summary-value num ${className}`.trim();
|
||||
};
|
||||
setText("#dashSumCount", String(count));
|
||||
setText("#dashSumOpening", "—", "meta");
|
||||
setText("#dashSumDebit", formatPlainWan(debit));
|
||||
setText("#dashSumCredit", formatPlainWan(credit));
|
||||
setText("#dashSumEnding", formatWan(ending), netClass(ending));
|
||||
}
|
||||
|
||||
function showDashDetailEmpty(company) {
|
||||
const wrap = $("#dashDetailWrap");
|
||||
const empty = $("#dashDetailEmpty");
|
||||
const desc = $("#dashDetailEmptyDesc");
|
||||
if (wrap) wrap.hidden = true;
|
||||
if (empty) empty.hidden = false;
|
||||
if (desc) {
|
||||
const cutoff = state.dashCutoff || "—";
|
||||
// 方案 A 空态:两行克制说明;期初口径仍为不可用(—),不伪造 0.00
|
||||
desc.textContent = `期初 — · 截至 ${cutoff} 无明细记录 · 导入流水并归集后在此展示`;
|
||||
}
|
||||
updateDashDetailSummary(null);
|
||||
}
|
||||
|
||||
function showDashDetailTable() {
|
||||
const wrap = $("#dashDetailWrap");
|
||||
const empty = $("#dashDetailEmpty");
|
||||
if (wrap) wrap.hidden = false;
|
||||
if (empty) empty.hidden = true;
|
||||
}
|
||||
|
||||
function renderDashDetail(payload, { expandFirst = false, company = null } = {}) {
|
||||
const tbody = $("#dashDetailRows");
|
||||
const title = $("#dashDetailTitle");
|
||||
if (!tbody) return;
|
||||
const selected = company || (state.dashCompanies || []).find(
|
||||
(c) => String(c.id) === String(payload?.company_id || state.dashSelectedId)
|
||||
) || null;
|
||||
|
||||
if (!payload) {
|
||||
if (title) title.textContent = "请选择左侧公司";
|
||||
showDashDetailTable();
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="muted">请选择左侧公司</td></tr>';
|
||||
updateDashDetailSummary(null);
|
||||
return;
|
||||
}
|
||||
if (title) {
|
||||
title.innerHTML = `公司往来明细 · ${payload.company_name}<span class="sub" id="dashDetailSub">按对方公司分组 · 二级默认收起 · 期初待 B-45</span>`;
|
||||
}
|
||||
if (title) title.textContent = payload.company_name || selected?.name || "公司往来明细";
|
||||
const sub = $("#dashDetailSub");
|
||||
if (sub) sub.textContent = "按对方公司分组 · 二级默认收起 · 已确认 / 待确认分列";
|
||||
|
||||
const groups = payload.groups || [];
|
||||
if (!groups.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="muted">该公司区间内暂无已归集往来</td></tr>';
|
||||
showDashDetailEmpty(selected || { detail_count: 0, debit_wan: 0, credit_wan: 0, net_wan: 0 });
|
||||
tbody.replaceChildren();
|
||||
return;
|
||||
}
|
||||
|
||||
showDashDetailTable();
|
||||
updateDashDetailSummary(selected, payload);
|
||||
const rows = [];
|
||||
groups.forEach((group, index) => {
|
||||
const expanded = expandFirst && index === 0;
|
||||
@@ -1268,7 +1339,12 @@ async function resolveDashStartDate() {
|
||||
async function loadDashCompanyDetail(companyId) {
|
||||
if (!companyId) return;
|
||||
state.dashSelectedId = companyId;
|
||||
const company = (state.dashCompanies || []).find((c) => String(c.id) === String(companyId)) || null;
|
||||
renderDashCompanyRows(state.dashCompanies || [], companyId);
|
||||
const title = $("#dashDetailTitle");
|
||||
if (title && company) title.textContent = company.name;
|
||||
updateDashDetailSummary(company, null);
|
||||
showDashDetailTable();
|
||||
const tbody = $("#dashDetailRows");
|
||||
if (tbody) tbody.innerHTML = '<tr class="loading-row"><td colspan="6">加载中…</td></tr>';
|
||||
const from = await resolveDashStartDate();
|
||||
@@ -1278,7 +1354,7 @@ async function loadDashCompanyDetail(companyId) {
|
||||
return;
|
||||
}
|
||||
const result = await response.json().catch(() => null);
|
||||
renderDashDetail(result);
|
||||
renderDashDetail(result, { company });
|
||||
}
|
||||
|
||||
async function loadAdminDashboard() {
|
||||
@@ -1286,22 +1362,30 @@ async function loadAdminDashboard() {
|
||||
const from = await resolveDashStartDate();
|
||||
const response = await fetch(`/api/admin/dashboard?from=${encodeURIComponent(from)}`).catch(() => null);
|
||||
if (!response?.ok) {
|
||||
$("#dashCompanyRows").innerHTML = '<tr><td colspan="4" class="muted">总览加载失败</td></tr>';
|
||||
$("#dashCompanyRows").innerHTML = '<div class="dash-company-empty muted">总览加载失败</div>';
|
||||
return;
|
||||
}
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!data || data.status !== "ok") {
|
||||
$("#dashCompanyRows").innerHTML = '<tr><td colspan="4" class="muted">总览加载失败</td></tr>';
|
||||
$("#dashCompanyRows").innerHTML = '<div class="dash-company-empty muted">总览加载失败</div>';
|
||||
return;
|
||||
}
|
||||
state.dashCompanies = data.companies || [];
|
||||
state.dashFrom = data.from_date;
|
||||
state.dashCutoff = data.cutoff;
|
||||
state.dashPeriodMonth = data.period_month;
|
||||
updateDashAuditCard(data.audit);
|
||||
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} · 单位:万元`;
|
||||
if (listSub) {
|
||||
listSub.textContent = `${data.from_date} 至 ${data.cutoff} · 单位:万元 · 左侧选择公司,右侧查看其往来明细`;
|
||||
}
|
||||
const meta = $("#dashMasterMeta");
|
||||
if (meta) {
|
||||
const totals = data.totals || {};
|
||||
const companyCount = totals.company_count ?? state.dashCompanies.length;
|
||||
const detailCount = totals.detail_count ?? 0;
|
||||
meta.textContent = `${companyCount} 家公司 · 明细 ${detailCount} 笔`;
|
||||
}
|
||||
const selected = state.dashSelectedId || state.dashCompanies[0]?.id;
|
||||
renderDashCompanyRows(state.dashCompanies, selected);
|
||||
if (selected) await loadDashCompanyDetail(selected);
|
||||
@@ -1309,10 +1393,21 @@ async function loadAdminDashboard() {
|
||||
|
||||
function initDashboard() {
|
||||
if (!$("#companyMasterDetail")) return;
|
||||
const selectCompany = (companyId) => {
|
||||
if (!companyId) return;
|
||||
loadDashCompanyDetail(companyId);
|
||||
};
|
||||
$("#dashCompanyRows")?.addEventListener("click", (event) => {
|
||||
const tr = event.target.closest("tr[data-company-id]");
|
||||
if (!tr) return;
|
||||
loadDashCompanyDetail(tr.dataset.companyId);
|
||||
const item = event.target.closest(".dash-company-item[data-company-id]");
|
||||
if (!item) return;
|
||||
selectCompany(item.dataset.companyId);
|
||||
});
|
||||
$("#dashCompanyRows")?.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
const item = event.target.closest(".dash-company-item[data-company-id]");
|
||||
if (!item) return;
|
||||
event.preventDefault();
|
||||
selectCompany(item.dataset.companyId);
|
||||
});
|
||||
$("#dashDetailRows")?.addEventListener("click", (event) => {
|
||||
const tr = event.target.closest("tr[data-peer-group]");
|
||||
@@ -1330,8 +1425,8 @@ function initDashboard() {
|
||||
});
|
||||
$("#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";
|
||||
$$("#dashCompanyRows .dash-company-item[data-company-id]").forEach((item) => {
|
||||
item.style.display = (!q || item.textContent.includes(q)) ? "" : "none";
|
||||
});
|
||||
});
|
||||
loadAdminDashboard();
|
||||
@@ -1649,7 +1744,7 @@ function initAdmin() {
|
||||
const timeline = $("#timelineSub");
|
||||
if (timeline) timeline.textContent = `全局起算日 ${startDate} 起,每月 ${closingDay} 日结账`;
|
||||
const flows = $("#flowsRangeMeta");
|
||||
if (flows) flows.textContent = `数据范围:${startDate} 起算 · 每月 ${closingDay} 日结账(7 月顺延至 08-29)`;
|
||||
if (flows) flows.textContent = `数据范围:${startDate} 起算 · 每月 ${closingDay} 日结账`;
|
||||
const opening = $("#openingSub");
|
||||
if (opening) opening.textContent = `${startDate} 起算的公司间往来期初数`;
|
||||
}
|
||||
|
||||
+234
-14
@@ -216,23 +216,179 @@ p { margin: 0; }
|
||||
/* 管理端首页主从:左栏约 480px(定稿 HEL-153),右侧明细更宽 */
|
||||
.grid-480-1 { grid-template-columns: minmax(0, 480px) minmax(0, 1fr); align-items: stretch; }
|
||||
|
||||
/* 方案 A:一体卡片 · 分栏主从(HEL-162) */
|
||||
.dash-master {
|
||||
margin-top: 14px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dash-master-head {
|
||||
padding: 12px 16px 10px;
|
||||
margin-bottom: 0;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.dash-master-head .card-title .sub {
|
||||
display: inline;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.dash-master-head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: none;
|
||||
margin-left: auto;
|
||||
}
|
||||
.dash-master-head-actions .input {
|
||||
width: 132px;
|
||||
min-height: 28px;
|
||||
padding: 4px 8px;
|
||||
flex: none;
|
||||
}
|
||||
.dash-master-split {
|
||||
gap: 0;
|
||||
height: 492px;
|
||||
/* 少数据自适应;整卡(含共享头)目标约 340px;多数据模块内滚动 */
|
||||
max-height: 300px;
|
||||
/* max-height alone 无法约束隐式行高;补可收缩行后 pane 内 overflow:auto 才会生效 */
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
}
|
||||
.dash-master-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dash-master-pane--list {
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
.dash-master-head {
|
||||
padding: 10px 14px 8px;
|
||||
margin-bottom: 0;
|
||||
gap: 8px;
|
||||
.dash-company-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
.dash-company-loading,
|
||||
.dash-company-empty {
|
||||
padding: 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.dash-company-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 14px 8px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
.dash-company-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.dash-company-item:hover {
|
||||
background: var(--fg-soft);
|
||||
}
|
||||
.dash-company-item.is-selected {
|
||||
background: var(--accent-soft);
|
||||
box-shadow: inset 3px 0 0 var(--accent);
|
||||
padding-left: 13px;
|
||||
}
|
||||
.dash-company-item.is-selected:hover {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.dash-company-main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.dash-company-name {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 13.5px;
|
||||
font-weight: 550;
|
||||
color: var(--fg);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.dash-company-meta {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.dash-company-net {
|
||||
flex: none;
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
min-width: 4.5em;
|
||||
}
|
||||
.dash-detail-toolbar {
|
||||
padding: 10px 14px 0;
|
||||
flex: none;
|
||||
}
|
||||
.dash-detail-heading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.dash-detail-name {
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
color: var(--fg);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.dash-detail-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
margin: 10px 14px 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
flex: none;
|
||||
}
|
||||
.dash-summary-item {
|
||||
padding: 8px 10px;
|
||||
border-right: 1px solid var(--border);
|
||||
min-width: 0;
|
||||
}
|
||||
.dash-summary-item:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
.dash-summary-label {
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.dash-summary-value {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.dash-detail-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.dash-master-scroll {
|
||||
border: 0;
|
||||
@@ -241,6 +397,32 @@ p { margin: 0; }
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
.dash-detail-empty {
|
||||
flex: 1;
|
||||
min-height: 120px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 20px 24px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.dash-detail-empty .e-title {
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.dash-detail-empty .e-desc {
|
||||
max-width: 360px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.dash-master-scroll[hidden],
|
||||
.dash-detail-empty[hidden],
|
||||
.dash-detail-summary[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
.ds-table.ds-table--compact {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -248,18 +430,16 @@ p { margin: 0; }
|
||||
.ds-table.ds-table--compact td {
|
||||
padding: 6px 12px;
|
||||
}
|
||||
.ds-table.ds-table--compact th {
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.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; }
|
||||
@@ -271,15 +451,55 @@ 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-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; }
|
||||
.dash-master-split {
|
||||
max-height: none;
|
||||
grid-template-rows: none;
|
||||
overflow: visible;
|
||||
}
|
||||
.dash-master-pane--list {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
max-height: 280px;
|
||||
}
|
||||
.dash-master-pane--detail {
|
||||
max-height: 420px;
|
||||
}
|
||||
.dash-detail-summary {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.dash-summary-item:nth-child(3) {
|
||||
border-right: 0;
|
||||
}
|
||||
.dash-summary-item:nth-child(n+4) {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.shell { grid-template-columns: 1fr; }
|
||||
.sidebar { position: static; height: auto; }
|
||||
.grid-2 { grid-template-columns: 1fr; }
|
||||
.content { padding: 16px 16px 48px; }
|
||||
.dash-master-head-actions {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.dash-master-head-actions .input {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
}
|
||||
.dash-detail-summary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.dash-summary-item:nth-child(2n) {
|
||||
border-right: 0;
|
||||
}
|
||||
.dash-summary-item:nth-child(3) {
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
.dash-summary-item:nth-child(n+3) {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── 数据表 ────────────────────────────────────────────────────── */
|
||||
|
||||
Reference in New Issue
Block a user