B-39~B-41: 服务端 API 与双端页面接入
- server.py 新增认证、批次/行/导出、公司/账号/别名主数据、审核 与审计接口,全部在服务端实施租户隔离与权限校验。 - 前端接入真实登录、首次改密、公司账号、账户登记/审核、批次查询 与上传持久化;移除演示账号密码预填。 - README 更新运行、登录与样本数据政策说明;HANDOFF 同步默认监听 行为。DESIGN-TOKENS 与样式对齐当前中性石墨玻璃视觉。
This commit is contained in:
+6
-6
@@ -26,12 +26,12 @@ Body type is `13px/1.55`. Page headlines use `clamp(26px, 3vw, 35px)` with `1.15
|
||||
|
||||
| Token | Value | Use |
|
||||
| --- | --- | --- |
|
||||
| `--color-bg` | `#050706` | Continuous application canvas |
|
||||
| `--color-bg-soft` | `#090d0b` | Soft near-black tonal alternative |
|
||||
| `--color-nav` | `#090c0b` | Navigation frame fallback |
|
||||
| `--color-surface` | `rgba(20, 26, 23, 0.82)` | Primary graphite glass |
|
||||
| `--color-surface-solid` | `#131916` | Opaque graphite fallback |
|
||||
| `--color-surface-raised` | `#18201c` | Raised or denser surface |
|
||||
| `--color-bg` | `#050505` | Continuous application canvas |
|
||||
| `--color-bg-soft` | `#0a0a0a` | Soft near-black tonal alternative |
|
||||
| `--color-nav` | `#0a0a0a` | Navigation frame fallback |
|
||||
| `--color-surface` | `rgba(22, 22, 22, 0.82)` | Primary neutral graphite glass |
|
||||
| `--color-surface-solid` | `#161616` | Opaque graphite fallback |
|
||||
| `--color-surface-raised` | `#1c1c1c` | Raised or denser surface |
|
||||
| `--color-surface-muted` | `rgba(255, 255, 255, 0.035)` | Quiet inset field and row treatment |
|
||||
|
||||
### Structure and Ink
|
||||
|
||||
@@ -19,18 +19,42 @@ The original Excel file is never modified.
|
||||
```powershell
|
||||
python -m pip install -r requirements.txt
|
||||
$env:PYTHONPATH = "src"
|
||||
python -m bank_importer.db # 创建/迁移 SQLite 数据库(默认 data/app.db)
|
||||
python -m bank_importer.cli "流水模板"
|
||||
python -m unittest discover -s tests -v
|
||||
python server.py
|
||||
```
|
||||
|
||||
访问地址:
|
||||
数据库与原始文件默认保存在 `data/`(已加入 `.gitignore`)。上传文件按
|
||||
SHA-256 内容哈希不可变保存,重复上传返回 `duplicate` 状态并复用已有批次。
|
||||
技术决策见 `docs/decisions/002-persistence.md`。
|
||||
|
||||
## 登录与账号
|
||||
|
||||
系统已启用真实身份认证(决策见 `docs/decisions/003-auth.md`):
|
||||
|
||||
- 首次启动若无管理员,自动创建引导管理员:用户名取环境变量
|
||||
`APP_ADMIN_USERNAME`(默认 `group-admin`),密码取
|
||||
`APP_BOOTSTRAP_ADMIN_PASSWORD`;未设置该变量则生成随机初始密码并
|
||||
只在控制台打印一次。
|
||||
- 引导管理员及管理员创建的公司账号首次登录都必须修改密码。
|
||||
- 公司、公司账号由管理员在总账端「公司与账号 → 新增公司」或
|
||||
`/api/admin/companies`、`/api/admin/users` 动态创建;
|
||||
公司账号的初始密码与登录账号相同,首次登录强制改密
|
||||
(「重置密码」仍生成随机一次性密码)。
|
||||
- 会话有效期 8 小时;同一账号同一 IP 10 分钟内登录失败 5 次将被限流。
|
||||
|
||||
访问地址(服务默认监听 `0.0.0.0:4173`,同局域网设备把 `127.0.0.1` 换成本机局域网 IP 即可访问;可用环境变量 `APP_HOST` / `APP_PORT` 覆盖):
|
||||
|
||||
- 登录入口:`http://127.0.0.1:4173/`
|
||||
- 总账管理端:`http://127.0.0.1:4173/admin.html`
|
||||
- 公司业务端:`http://127.0.0.1:4173/company.html`
|
||||
|
||||
公司端上传会把所选工作簿提交到本地 `/api/parse`,与 CLI 使用同一个确定性表头解析器。当前前端是交互原型,公司、账号、期初、提醒和导入确认仅保存在当前浏览器页面中,尚未接入数据库和正式身份认证。
|
||||
公司端上传会把所选工作簿提交到本地 `/api/parse`,与 CLI 使用同一个确定性表头解析器;解析结果、原始文件、批次和源行会持久化到 SQLite,并按登录账号绑定的公司隔离。当前前端仍是交互原型,期初、提醒、往来匹配等业务状态仅保存在当前浏览器页面中,尚未接入数据库。
|
||||
|
||||
## 样本数据政策
|
||||
|
||||
`流水模板/` 中的六份银行样本(中信、农行、工行、建行、河南农商、郑州银行)已经项目所有者(白栖知,2026-08-08)书面确认:**基于真实银行流水脱敏后的测试数据**,可以保留在仓库中作为解析器 fixture。新增银行样本的审核要求:必须先脱敏(户名、账号、对方信息等),由项目所有者确认后方可提交;真实生产流水、真实上传文件和导出文件一律不进仓库(见 `.gitignore` 中的 `data/`、`uploads/`、`exports/` 规则)。
|
||||
|
||||
The CLI prints batch summaries only. It does not print sensitive transaction
|
||||
details.
|
||||
|
||||
+1
-1
@@ -160,7 +160,7 @@ bank_importer 解析内核
|
||||
### 测试与运行
|
||||
|
||||
1. 现有 6 项测试只覆盖解析内核,没有 `/api/parse`、认证、导入持久化、匹配、核算、权限或端到端测试。
|
||||
2. `server.py` 基于标准库开发服务器,只绑定 `127.0.0.1`,没有生产部署、TLS、日志、备份或监控方案。
|
||||
2. `server.py` 基于标准库开发服务器,默认绑定 `0.0.0.0`(可用 `APP_HOST` 覆盖),没有生产部署、TLS、日志、备份或监控方案。
|
||||
3. `流水模板/` 已进入 Git 历史;必须确认均为合成或已脱敏数据,真实生产流水不得继续提交到仓库。
|
||||
|
||||
## 6. 已作出的重要技术决策及原因
|
||||
|
||||
+47
-50
@@ -3,8 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="往来台账总账管理端" />
|
||||
<title>总账管理端 · 往来台账</title>
|
||||
<meta name="description" content="金牛集团总账管理端" />
|
||||
<title>总账管理端 · 金牛集团</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body data-portal="admin">
|
||||
@@ -19,7 +19,7 @@
|
||||
<a class="skip-link" href="#main-content">跳到主要内容</a>
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar" id="sidebar" aria-label="总账管理导航">
|
||||
<div class="brand"><span class="brand-mark">往</span><span class="brand-copy"><strong>往来台账</strong><small>总账管理端</small></span></div>
|
||||
<div class="brand"><span class="brand-mark">金</span><span class="brand-copy"><strong>金牛集团</strong><small>总账管理端</small></span></div>
|
||||
<nav class="nav-list">
|
||||
<button class="nav-item is-active" data-view="dashboard"><svg><use href="icons.svg#layout-dashboard"/></svg><span>管理总览</span></button>
|
||||
<button class="nav-item" data-view="pair"><svg><use href="icons.svg#arrow-left-right"/></svg><span>往来查询</span></button>
|
||||
@@ -36,20 +36,18 @@
|
||||
</aside>
|
||||
|
||||
<div class="workspace">
|
||||
<header class="topbar">
|
||||
<button class="icon-button menu-button" id="menuButton" aria-label="打开导航" aria-expanded="false" aria-controls="sidebar" title="打开导航"><svg><use href="icons.svg#menu"/></svg></button>
|
||||
<div class="workspace-name"><span>集团财务中心</span><strong id="currentViewName">管理总览</strong></div>
|
||||
<span class="demo-badge">演示数据</span>
|
||||
<div class="topbar-actions">
|
||||
<label class="search-box"><svg><use href="icons.svg#search"/></svg><span class="sr-only">全局检索</span><input id="globalSearch" type="search" placeholder="检索公司、账号或流水" /></label>
|
||||
<button class="period-button" type="button"><svg><use href="icons.svg#calendar"/></svg><span>截至 2026.07.31</span></button>
|
||||
<button class="icon-button" data-view-link="reminders" aria-label="提醒管理,4 项未处理" title="提醒管理"><svg><use href="icons.svg#bell"/></svg><i class="notification-dot">4</i></button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main-content">
|
||||
<section class="app-view is-active" data-page="dashboard">
|
||||
<header class="page-heading"><div><h1>管理总览</h1><p>集团账务口径与高优先级事项</p></div><button class="button secondary" data-view-link="settings"><svg><use href="icons.svg#settings"/></svg>账期设置</button></header>
|
||||
<header class="page-heading dashboard-head"><div><h1>管理总览</h1><p>集团账务口径与高优先级事项</p></div>
|
||||
<div class="content-toolbar">
|
||||
<label class="search-box"><svg><use href="icons.svg#search"/></svg><span class="sr-only">全局检索</span><input id="globalSearch" type="search" placeholder="检索公司、账号或流水" /></label>
|
||||
<div class="toolbar-group">
|
||||
<button class="icon-button" data-view-link="reminders" aria-label="提醒管理,4 项未处理" title="提醒管理"><svg><use href="icons.svg#bell"/></svg><i class="notification-dot">4</i></button>
|
||||
<button class="period-button" type="button"><svg><use href="icons.svg#calendar"/></svg><span>截至 2026.07.31</span></button>
|
||||
<button class="button secondary" data-view-link="settings"><svg><use href="icons.svg#settings"/></svg>账期设置</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="metric-grid" aria-label="总账关键指标">
|
||||
<article class="metric-card danger" data-metric-link="audit" tabindex="0" role="button" aria-label="查看 7 项待审核事项">
|
||||
@@ -81,27 +79,13 @@
|
||||
<div class="period-warning"><svg><use href="icons.svg#circle-alert"/></svg><span><strong>2 家公司存在起算缺口</strong><small>最早缺口始于 2026.01.01</small></span><button class="text-button" data-view-link="audit">去审核</button></div>
|
||||
</section>
|
||||
|
||||
<section class="panel deck-panel" aria-label="重点公司状态卡栈">
|
||||
<div class="panel-heading"><div><h2>重点公司</h2><p>集团账户状态总览 · 按风险等级排列,荧光绿为公司端第一视角</p></div><button class="text-button" data-view-link="companies">查看全部 6 家</button></div>
|
||||
<div class="stack-deck" role="list">
|
||||
<div class="deck-stage">
|
||||
<article class="deck-card" role="listitem" data-view-link="companies" tabindex="0" aria-label="C公司 全部账户连续"><span class="deck-pos">05</span><span class="deck-mark">C</span><div class="deck-name"><strong>C公司</strong><small>全部账户连续</small></div><div class="deck-meta"><strong>2 个账户</strong><small>01.01—07.31</small></div><div class="deck-foot"><span>连续 7 个月</span><em class="status success">正常</em></div></article>
|
||||
<article class="deck-card" role="listitem" data-view-link="audit" tabindex="0" aria-label="E公司 科目待确认"><span class="deck-pos">04</span><span class="deck-mark">E</span><div class="deck-name"><strong>E公司</strong><small>科目待确认</small></div><div class="deck-meta"><strong>88.00 万元</strong><small>07.09 · 应收与其他应收</small></div><div class="deck-foot"><span>等待公司确认</span><em class="status warning">中</em></div></article>
|
||||
<article class="deck-card is-active is-danger" role="listitem" data-view-link="audit" tabindex="0" aria-label="A公司 工商银行账户断档 高风险"><span class="deck-pos">01</span><span class="deck-mark">A</span><div class="deck-name"><strong>A公司</strong><small>工商银行账户断档</small></div><div class="deck-meta"><strong>缺口 21 天</strong><small>07.01—07.21</small></div><div class="deck-foot"><span>影响 7 月结账</span><em class="status danger">高</em></div></article>
|
||||
<article class="deck-card is-warning" role="listitem" data-view-link="audit" tabindex="0" aria-label="B公司 单边流水待匹配"><span class="deck-pos">02</span><span class="deck-mark">B</span><div class="deck-name"><strong>B公司</strong><small>单边流水待匹配</small></div><div class="deck-meta"><strong>312.00 万元</strong><small>6 笔 · B↔D</small></div><div class="deck-foot"><span>今日新增</span><em class="status warning">中</em></div></article>
|
||||
<article class="deck-card is-warning" role="listitem" data-view-link="audit" tabindex="0" aria-label="F公司 起算区间待校准"><span class="deck-pos">03</span><span class="deck-mark">F</span><div class="deck-name"><strong>F公司</strong><small>起算区间待校准</small></div><div class="deck-meta"><strong>缺口 16 天</strong><small>01.01—01.16</small></div><div class="deck-foot"><span>待公司确认</span><em class="status warning">中</em></div></article>
|
||||
<article class="deck-card" role="listitem" data-view-link="companies" tabindex="0" aria-label="D公司 全部账户连续"><span class="deck-pos">06</span><span class="deck-mark">D</span><div class="deck-name"><strong>D公司</strong><small>全部账户连续</small></div><div class="deck-meta"><strong>1 个账户</strong><small>01.01—07.31</small></div><div class="deck-foot"><span>连续 7 个月</span><em class="status success">正常</em></div></article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="admin-dashboard-grid">
|
||||
<section class="panel priority-panel">
|
||||
<div class="panel-heading"><div><h2>优先处理</h2><p>影响当前账期可靠性的事项</p></div><button class="text-button" data-view-link="audit">查看全部 7 项</button></div>
|
||||
<div class="task-list compact-task-list">
|
||||
<button data-view-link="audit"><span class="task-level danger">高</span><span><strong>A公司 · 工行账户断档</strong><small>缺少 07.01—07.21,影响 7 月结账</small></span><b>已逾期 2 天</b><svg><use href="icons.svg#chevron-right"/></svg></button>
|
||||
<button data-view-link="audit"><span class="task-level warning">中</span><span><strong>B公司 ↔ D公司 · 单边待匹配</strong><small>6 笔,合计 312.00 万元</small></span><b>今日新增</b><svg><use href="icons.svg#chevron-right"/></svg></button>
|
||||
<button data-view-link="audit"><span class="task-level warning">中</span><span><strong>F公司 · 起算区间待校准</strong><small>01.01—01.16 无银行流水覆盖</small></span><b>待公司确认</b><svg><use href="icons.svg#chevron-right"/></svg></button>
|
||||
<button data-detail="gap-a"><span class="task-level danger">高</span><span><strong>A公司 · 工行账户断档</strong><small>缺少 07.01—07.21,影响 7 月结账</small></span><b>已逾期 2 天</b><svg><use href="icons.svg#chevron-right"/></svg></button>
|
||||
<button data-detail="match-bd"><span class="task-level warning">中</span><span><strong>B公司 ↔ D公司 · 单边待匹配</strong><small>6 笔,合计 312.00 万元</small></span><b>今日新增</b><svg><use href="icons.svg#chevron-right"/></svg></button>
|
||||
<button data-detail="calib-f"><span class="task-level warning">中</span><span><strong>F公司 · 起算区间待校准</strong><small>01.01—01.16 无银行流水覆盖</small></span><b>待公司确认</b><svg><use href="icons.svg#chevron-right"/></svg></button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -117,21 +101,6 @@
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<section class="timeline-strip panel" aria-label="覆盖连续性时间轴">
|
||||
<div class="timeline-legend"><strong>覆盖连续性</strong><span>2026.01.01 — 2026.07.31</span><small>11 条账户 · 1 个断档 · 今日 07.24</small></div>
|
||||
<div class="timeline-body">
|
||||
<div class="timeline-axis"><span>1月</span><span>2月</span><span>3月</span><span>4月</span><span>5月</span><span>6月</span><span>7月</span><span>8月</span><span>9月</span><span>10月</span><span>11月</span><span>12月</span></div>
|
||||
<div class="timeline-rows">
|
||||
<div class="timeline-row"><span>A公司 · 工行 9481</span><div class="timeline-track"><i class="coverage" style="left:0%;right:33%"></i><i class="danger" style="left:33%;right:67%"></i><span class="timeline-today" style="left:67%"></span></div></div>
|
||||
<div class="timeline-row"><span>A公司 · 中信 5316</span><div class="timeline-track"><i class="coverage" style="left:0%;right:33%"></i><span class="timeline-today" style="left:67%"></span></div></div>
|
||||
<div class="timeline-row"><span>A公司 · 建行 0845</span><div class="timeline-track"><i class="coverage" style="left:0%;right:33%"></i><span class="timeline-today" style="left:67%"></span></div></div>
|
||||
<div class="timeline-row"><span>A公司 · 农行 3650</span><div class="timeline-track"><i class="coverage" style="left:0%;right:33%"></i><span class="timeline-today" style="left:67%"></span></div></div>
|
||||
<div class="timeline-row"><span>B公司 · 工行 1234</span><div class="timeline-track"><i class="coverage" style="left:0%;right:33%"></i><span class="timeline-today" style="left:67%"></span></div></div>
|
||||
<div class="timeline-row"><span>F公司 · 农行 3650</span><div class="timeline-track"><i class="warning" style="left:0%;right:33%"></i><i class="coverage" style="left:0%;right:33%"></i><span class="timeline-today" style="left:67%"></span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel company-ledger-panel">
|
||||
<div class="panel-heading"><div><h2>公司借贷目录</h2><p>统计区间 2026.01.01—2026.07.31 · 单位:万元</p></div><label class="inline-search"><svg><use href="icons.svg#search"/></svg><input data-filter-target="companyLedgers" placeholder="快速检索公司" /></label></div>
|
||||
<div class="ledger-head"><span>公司</span><span>借方合计</span><span>贷方合计</span><span>期末方向</span><span>待审核</span><span></span></div>
|
||||
@@ -147,6 +116,34 @@
|
||||
<details class="company-ledger"><summary><span class="company-name"><i>C</i><b>C公司</b><small>2 个银行账户</small></span><strong class="amount debit">1,894.00</strong><strong class="amount credit">1,052.00</strong><span><em class="status success">应收</em><b class="amount">842.00</b></span><span class="status neutral">0 笔</span><svg><use href="icons.svg#chevron-down"/></svg></summary><div class="ledger-breakdown"><section><header><h3>借方明细</h3><strong>1,894.00</strong></header><button class="subject-row" data-pair-link="C公司|E公司"><span><b>E公司</b><small>应收 · 5 笔</small></span><strong>1,052.00</strong><svg><use href="icons.svg#chevron-right"/></svg></button></section><section><header><h3>贷方明细</h3><strong>1,052.00</strong></header><button class="subject-row" data-pair-link="C公司|A公司"><span><b>A公司</b><small>其他应付 · 3 笔</small></span><strong>1,052.00</strong><svg><use href="icons.svg#chevron-right"/></svg></button></section></div></details>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel timeline-panel" aria-label="账期时间轴">
|
||||
<div class="panel-heading"><div><h2>账期时间轴</h2><p>起算日 2026.01.01 · 每月 5 日结上月账</p></div><span class="status danger">1 处断档 · 7 月结账日 08.05</span></div>
|
||||
<div class="timeline" role="img" aria-label="2026 年账期时间轴:1 至 6 月已结账,7 月进行中,工商银行 9481 在 07.01 至 07.21 断档,今天 08.08">
|
||||
<div class="timeline-bar">
|
||||
<span class="timeline-seg is-closed" style="left:0;width:81.9%"></span>
|
||||
<span class="timeline-seg is-current" style="left:81.9%;width:15.8%"></span>
|
||||
<span class="timeline-gap" style="left:81.9%;width:9.1%"></span>
|
||||
<i class="timeline-today" style="left:99.1%"></i>
|
||||
</div>
|
||||
<div class="timeline-ticks" aria-hidden="true">
|
||||
<span style="left:0">01.01<small>起算日</small></span>
|
||||
<span style="left:14%">2月</span>
|
||||
<span style="left:26.7%">3月</span>
|
||||
<span style="left:40.7%">4月</span>
|
||||
<span style="left:54.3%">5月</span>
|
||||
<span style="left:68.3%">6月</span>
|
||||
<span style="left:81.9%">7月</span>
|
||||
<span style="left:95.9%">8月</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="timeline-legend">
|
||||
<li><i class="dot success"></i>1—6 月已结账</li>
|
||||
<li><i class="dot warning"></i>7 月进行中 · 08.05 结账</li>
|
||||
<li><i class="dot danger"></i>工行 9481 断档 07.01—07.21</li>
|
||||
<li><i class="dot today"></i>今天 08.08</li>
|
||||
</ul>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="app-view" data-page="pair">
|
||||
@@ -171,7 +168,7 @@
|
||||
<section class="app-view" data-page="audit">
|
||||
<header class="page-heading"><div><h1>审核中心</h1><p>统一处理核算异常、账户登记与公司手工记录</p></div></header>
|
||||
<section class="filter-bar"><div class="segmented" role="group" aria-label="审核类型"><button class="is-active" data-audit-filter="all" data-label="全部" aria-pressed="true">全部 6</button><button data-audit-filter="断档" data-label="流水断档" aria-pressed="false">流水断档 1</button><button data-audit-filter="单边" data-label="单边匹配" aria-pressed="false">单边匹配 1</button><button data-audit-filter="科目" data-label="科目待确认" aria-pressed="false">科目待确认 1</button><button data-audit-filter="起算" data-label="起算校准" aria-pressed="false">起算校准 1</button><button data-audit-filter="账户" data-label="账户登记" aria-pressed="false">账户登记 1</button><button data-audit-filter="手工" data-label="手工记录" aria-pressed="false">手工记录 1</button></div><label class="field compact"><span>公司</span><select id="auditCompany"><option>全部公司</option><option>A公司</option><option>B公司</option><option>E公司</option><option>F公司</option></select></label></section>
|
||||
<section class="panel"><div class="table-scroll"><table class="data-table audit-table"><thead><tr><th>风险</th><th>公司 / 事项</th><th>类型</th><th>涉及期间</th><th>影响</th><th>状态</th><th>操作</th></tr></thead><tbody id="auditRows"><tr data-audit-type="断档" data-company="A公司"><td><span class="task-level danger">高</span></td><td><strong>A公司 · 工商银行 9481</strong><small>上期截止 06.30,下期始于 07.22</small></td><td>流水断档</td><td>07.01—07.21</td><td>7 月结账</td><td><span class="status danger">已逾期</span></td><td><button class="text-button" data-audit-action>审核</button></td></tr><tr data-audit-type="单边" data-company="B公司"><td><span class="task-level warning">中</span></td><td><strong>B公司 ↔ D公司</strong><small>金额与日期存在 2 个候选</small></td><td>单边匹配</td><td>07.18</td><td>312.00 万元</td><td><span class="status warning">待审核</span></td><td><button class="text-button" data-audit-action>匹配</button></td></tr><tr data-audit-type="科目" data-company="E公司"><td><span class="task-level warning">中</span></td><td><strong>E公司 ↔ F公司</strong><small>规则无法区分应收与其他应收</small></td><td>科目待确认</td><td>07.09</td><td>88.00 万元</td><td><span class="status warning">待确认</span></td><td><button class="text-button" data-audit-action>确认</button></td></tr><tr data-audit-type="起算" data-company="F公司"><td><span class="task-level warning">中</span></td><td><strong>F公司 · 农业银行 3650</strong><small>公司已提交无业务说明</small></td><td>起算校准</td><td>01.01—01.16</td><td>期初口径</td><td><span class="status neutral">待复核</span></td><td><button class="text-button" data-audit-action>复核</button></td></tr><tr data-audit-type="账户" data-company="A公司" data-record-kind="account" data-evidence="公司提交资料、开户行、账号、账户类型与启用日期"><td><span class="task-level warning">中</span></td><td><strong>A公司 · 郑州银行 3028</strong><small>一般户 · 申请启用日 2026.08.01</small></td><td>账户登记</td><td>2026.08.01</td><td>账户识别与流水上传</td><td><span class="status warning">待复核</span></td><td><button class="text-button" data-audit-action>复核</button></td></tr><tr data-audit-type="手工" data-company="A公司" data-record-kind="manual" data-evidence="公司手工记录、关联银行流水号、证明附件与提交说明"><td><span class="task-level warning">中</span></td><td><strong>A公司 · MR-DEMO-001</strong><small>付款 80,000.00 元 · B公司 · 其他应收</small></td><td>手工记录</td><td>2026.07.29</td><td>待确认往来 8.00 万元</td><td><span class="status warning">待复核</span></td><td><button class="text-button" data-audit-action>复核</button></td></tr></tbody></table></div></section>
|
||||
<section class="panel"><div class="table-scroll"><table class="data-table audit-table"><thead><tr><th>风险</th><th>公司 / 事项</th><th>类型</th><th>涉及期间</th><th>影响</th><th>状态</th><th>操作</th></tr></thead><tbody id="auditRows"><tr data-audit-type="断档" data-company="A公司"><td><span class="task-level danger">高</span></td><td><strong>A公司 · 工商银行 9481</strong><small>上期截止 06.30,下期始于 07.22</small></td><td>流水断档</td><td>07.01—07.21</td><td>7 月结账</td><td><span class="status danger">已逾期</span></td><td><button class="text-button" data-audit-action>审核</button></td></tr><tr data-audit-type="单边" data-company="B公司"><td><span class="task-level warning">中</span></td><td><strong>B公司 ↔ D公司</strong><small>金额与日期存在 2 个候选</small></td><td>单边匹配</td><td>07.18</td><td>312.00 万元</td><td><span class="status warning">待审核</span></td><td><button class="text-button" data-audit-action>匹配</button></td></tr><tr data-audit-type="科目" data-company="E公司"><td><span class="task-level warning">中</span></td><td><strong>E公司 ↔ F公司</strong><small>规则无法区分应收与其他应收</small></td><td>科目待确认</td><td>07.09</td><td>88.00 万元</td><td><span class="status warning">待确认</span></td><td><button class="text-button" data-audit-action>确认</button></td></tr><tr data-audit-type="起算" data-company="F公司"><td><span class="task-level warning">中</span></td><td><strong>F公司 · 农业银行 3650</strong><small>公司已提交无业务说明</small></td><td>起算校准</td><td>01.01—01.16</td><td>期初口径</td><td><span class="status neutral">待复核</span></td><td><button class="text-button" data-audit-action>复核</button></td></tr><tr data-audit-type="手工" data-company="A公司" data-record-kind="manual" data-evidence="公司手工记录、关联银行流水号、证明附件与提交说明"><td><span class="task-level warning">中</span></td><td><strong>A公司 · MR-DEMO-001</strong><small>付款 80,000.00 元 · B公司 · 其他应收</small></td><td>手工记录</td><td>2026.07.29</td><td>待确认往来 8.00 万元</td><td><span class="status warning">待复核</span></td><td><button class="text-button" data-audit-action>复核</button></td></tr></tbody></table></div></section>
|
||||
</section>
|
||||
|
||||
<section class="app-view" data-page="flows">
|
||||
@@ -182,7 +179,7 @@
|
||||
|
||||
<section class="app-view" data-page="companies">
|
||||
<header class="page-heading"><div><h1>公司与账号</h1><p>维护公司主档、银行账户和一对一公司账号</p></div><button class="button primary" id="openCompanyDialog"><svg><use href="icons.svg#plus"/></svg>新增公司</button></header>
|
||||
<section class="panel"><div class="table-scroll"><table class="data-table" id="companyTable"><thead><tr><th>公司全称</th><th>统一社会信用代码</th><th>银行账户</th><th>公司账号</th><th>出纳人员</th><th>状态</th><th>操作</th></tr></thead><tbody><tr><td><strong>A公司</strong><small>COMP-001</small></td><td>91410100MA******</td><td>4 个</td><td>a-cashier</td><td>牛女士</td><td><span class="status success">正常</span></td><td><button class="text-button" data-toast="已打开 A 公司主档">管理</button></td></tr><tr><td><strong>B公司</strong><small>COMP-002</small></td><td>91410100MB******</td><td>3 个</td><td>b-cashier</td><td>王女士</td><td><span class="status success">正常</span></td><td><button class="text-button" data-toast="已打开 B 公司主档">管理</button></td></tr><tr><td><strong>筹备公司</strong><small>COMP-006</small></td><td>注册中</td><td>0 个</td><td>未创建</td><td>未指定</td><td><span class="status neutral">筹备中</span></td><td><button class="text-button" data-toast="已打开筹备公司主档">管理</button></td></tr></tbody></table></div></section>
|
||||
<section class="panel"><div class="table-scroll"><table class="data-table" id="companyTable"><thead><tr><th>公司全称</th><th>统一社会信用代码</th><th>银行账户</th><th>公司账号</th><th>出纳人员</th><th>状态</th><th>操作</th></tr></thead><tbody></tbody></table></div></section>
|
||||
</section>
|
||||
|
||||
<section class="app-view" data-page="settings">
|
||||
@@ -208,7 +205,7 @@
|
||||
<dialog id="companyDialog" class="dialog">
|
||||
<form method="dialog" id="companyForm">
|
||||
<header><div><h2>新增公司</h2><p>公司主档与公司账号可一次创建</p></div><button class="icon-button" value="cancel" aria-label="关闭" title="关闭"><svg><use href="icons.svg#x"/></svg></button></header>
|
||||
<div class="dialog-body"><label class="field"><span>公司全称</span><input name="companyName" required /></label><label class="field"><span>统一社会信用代码</span><input name="creditCode" /></label><label class="check-field"><input type="checkbox" name="createUser" checked />同时创建公司账号</label><div class="form-grid"><label class="field"><span>登录账号</span><input name="loginName" required /></label><label class="field"><span>出纳人员</span><input name="cashier" required /></label></div><p class="form-callout"><svg><use href="icons.svg#key-round"/></svg>初始密码将在创建后显示一次,首次登录必须修改。</p></div>
|
||||
<div class="dialog-body"><label class="field"><span>公司全称</span><input name="companyName" required /></label><label class="field"><span>统一社会信用代码</span><input name="creditCode" /></label><label class="check-field"><input type="checkbox" name="createUser" checked />同时创建公司账号</label><div class="form-grid"><label class="field"><span>登录账号</span><input name="loginName" required /></label><label class="field"><span>出纳人员</span><input name="cashier" required /></label></div><p class="form-callout"><svg><use href="icons.svg#key-round"/></svg>初始密码与登录账号相同,首次登录必须修改。</p></div>
|
||||
<footer><button class="button secondary" value="cancel">取消</button><button class="button primary" value="default">创建公司与账号</button></footer>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
+514
-139
@@ -7,10 +7,16 @@ const viewNames = portal === "admin"
|
||||
: { workspace: "工作台", upload: "流水导入", manual: "手工记录", flows: "流水管理", reconcile: "往来确认", accounts: "银行账户", notifications: "通知" };
|
||||
|
||||
const storageKeys = {
|
||||
accounts: "ledger-demo-account-submissions",
|
||||
manual: "ledger-demo-manual-records",
|
||||
};
|
||||
|
||||
const accountStatusLabels = {
|
||||
pending: "待复核",
|
||||
active: "已启用",
|
||||
returned: "已退回",
|
||||
disabled: "已停用",
|
||||
};
|
||||
|
||||
const state = {
|
||||
currentView: portal === "admin" ? "dashboard" : "workspace",
|
||||
selectedFile: null,
|
||||
@@ -51,18 +57,20 @@ function animateView(view, { initial = false } = {}) {
|
||||
|
||||
elements.forEach((element, index) => {
|
||||
element.getAnimations().forEach((animation) => animation.cancel());
|
||||
element.animate(
|
||||
[
|
||||
{ opacity: 0, transform: `translateY(${initial ? 16 : 10}px)` },
|
||||
{ opacity: 1, transform: "translateY(0)" },
|
||||
],
|
||||
{
|
||||
duration: 440,
|
||||
delay: Math.min(index * 38, 260),
|
||||
easing: "cubic-bezier(.22,1,.36,1)",
|
||||
fill: "both",
|
||||
},
|
||||
);
|
||||
// metric-card 的 3D 倾斜与悬停倾斜由 CSS 控制,入场动画只做淡入,
|
||||
// 否则 fill:both 的 translateY(0) 会覆盖 CSS 的 transform,导致倾斜失效。
|
||||
const keyframes = element.classList.contains("metric-card")
|
||||
? [{ opacity: 0 }, { opacity: 1 }]
|
||||
: [
|
||||
{ opacity: 0, transform: `translateY(${initial ? 16 : 10}px)` },
|
||||
{ opacity: 1, transform: "translateY(0)" },
|
||||
];
|
||||
element.animate(keyframes, {
|
||||
duration: 440,
|
||||
delay: Math.min(index * 38, 260),
|
||||
easing: "cubic-bezier(.22,1,.36,1)",
|
||||
fill: "both",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -110,10 +118,18 @@ function writeStoredRecords(key, records) {
|
||||
function recordStatus(status) {
|
||||
if (["已启用", "已确认"].includes(status)) return { className: "success", label: status };
|
||||
if (status === "已退回") return { className: "danger", label: status };
|
||||
if (status === "异常待处理") return { className: "neutral", label: status };
|
||||
if (["异常待处理", "已停用"].includes(status)) return { className: "neutral", label: status };
|
||||
return { className: "warning", label: status || "待复核" };
|
||||
}
|
||||
|
||||
function accountStatusLabel(status) {
|
||||
return accountStatusLabels[status] || "待复核";
|
||||
}
|
||||
|
||||
function accountTail(masked) {
|
||||
return String(masked || "").replace(/^\*+/, "");
|
||||
}
|
||||
|
||||
function formatCurrency(value) {
|
||||
return Number(value).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
@@ -137,13 +153,14 @@ function showToast(title, detail = "") {
|
||||
|
||||
function closeNavigation({ restoreFocus = false } = {}) {
|
||||
const sidebar = $("#sidebar");
|
||||
const button = $("#menuButton");
|
||||
if (!sidebar || !button) return;
|
||||
if (!sidebar) return;
|
||||
const wasOpen = sidebar.classList.contains("is-open");
|
||||
sidebar.classList.remove("is-open");
|
||||
button.setAttribute("aria-expanded", "false");
|
||||
button.setAttribute("aria-label", "打开导航");
|
||||
if (restoreFocus && wasOpen) button.focus();
|
||||
$$(".menu-button").forEach((button) => {
|
||||
button.setAttribute("aria-expanded", "false");
|
||||
button.setAttribute("aria-label", "打开导航");
|
||||
});
|
||||
if (restoreFocus && wasOpen) $(".menu-button")?.focus();
|
||||
}
|
||||
|
||||
function showView(view) {
|
||||
@@ -165,6 +182,93 @@ function showView(view) {
|
||||
window.scrollTo({ top: 0, behavior: motionQuery.matches ? "auto" : "smooth" });
|
||||
}
|
||||
|
||||
const detailContent = {
|
||||
"gap-a": { tag: ["danger", "高风险"], title: "A公司 · 工行账户断档", desc: "工商银行 9481 缺少 07.01—07.21 流水,已影响 7 月结账。", fields: [["公司", "A公司"], ["账户", "工商银行 · 9481"], ["缺口期间", "2026.07.01—07.21 · 21 天"], ["影响", "7 月结账 · 账户覆盖 · 双边匹配"], ["当前状态", "已逾期 2 天"]], tip: "建议先向 A公司出纳发送补传提醒,补齐后在审核中心复核覆盖区间。", action: ["去审核中心处理", "audit"] },
|
||||
"match-bd": { tag: ["warning", "中风险"], title: "B公司 ↔ D公司 · 单边待匹配", desc: "D公司侧流水已到,B公司侧尚未确认,合计 312.00 万元。", fields: [["本方", "B公司"], ["对方", "D公司"], ["笔数 / 金额", "6 笔 · 312.00 万元"], ["候选情况", "金额与日期存在 2 个候选"], ["当前状态", "今日新增"]], tip: "建议按账号优先核对候选流水,金额与日期相同者先确认。", action: ["去审核中心匹配", "audit"] },
|
||||
"calib-f": { tag: ["warning", "中风险"], title: "F公司 · 起算区间待校准", desc: "01.01—01.16 无银行流水覆盖,公司已提交无业务说明。", fields: [["公司", "F公司"], ["账户", "农业银行 · 3650"], ["无覆盖期间", "2026.01.01—01.16"], ["现有依据", "公司已提交无业务说明"], ["当前状态", "待公司确认"]], tip: "无业务说明属于审计证据,复核通过后该区间标记为已校准,不生成银行流水。", action: ["去审核中心复核", "audit"] },
|
||||
"task-upload": { tag: ["danger", "最紧急"], title: "补传工商银行流水", desc: "账户尾号 9481 缺少 07.01—07.21 流水,已逾期 2 天。", fields: [["账户", "工商银行 · 9481"], ["缺少期间", "2026.07.01—07.21 · 21 天"], ["影响", "7 月账户覆盖与双边匹配"], ["截止", "08.05 集团结账日前"]], tip: "从工商银行网银导出 7 月流水后直接上传,系统会自动识别表头并重新计算匹配。", action: ["去上传流水", "upload"] },
|
||||
"task-match": { tag: ["warning", "待确认"], title: "确认 1 笔单边流水", desc: "07.18 转出 280.00 万元,系统找到 2 个对方候选。", fields: [["对方", "B公司"], ["日期 / 金额", "07.18 · 280.00 万元"], ["候选", "工商银行 9481(推荐)· 建设银行 2046"], ["核对点", "摘要与账号是否一致"]], tip: "系统推荐账号一致的候选,请核对回单后再确认。", action: ["去往来确认", "reconcile"] },
|
||||
"task-subject": { tag: ["warning", "待确认"], title: "确认往来科目", desc: "06.27 转出 600.00 万元,规则无法区分应收与其他应收。", fields: [["对方", "C公司"], ["日期 / 金额", "06.27 · 600.00 万元"], ["待确认", "应收 或 其他应收"], ["摘要", "资金调拨"]], tip: "科目只按确定性规则建议,拿不准时选“其他应收”并在说明里注明依据。", action: ["去确认科目", "reconcile"] },
|
||||
"task-notice": { tag: ["neutral", "提醒"], title: "阅读总账提醒", desc: "管理员要求 08.08 前完成 7 月银行流水确认。", fields: [["来自", "系统管理员 · 今天 09:30"], ["处理期限", "2026.08.08"], ["关联事项", "断档补传 · 2 项待确认往来"]], tip: "完成补传和两项确认后,再提交公司确认即可。", action: ["查看通知", "notifications"] },
|
||||
"acct-citic": { tag: ["success", "连续"], title: "中信银行 · 5316", desc: "基本户 · 起算日以来流水全部连续。", fields: [["账户类型", "基本户"], ["启用日期", "2026.01.01"], ["流水覆盖", "01.01—07.31 · 7 个月连续"], ["最近导入", "今天 09:42"]], tip: "该账户无需处理,8 月流水到期后正常上传即可。", action: ["查看银行账户", "accounts"] },
|
||||
"acct-abc": { tag: ["success", "连续"], title: "农业银行 · 3650", desc: "一般户 · 起算日以来流水全部连续。", fields: [["账户类型", "一般户"], ["启用日期", "2026.01.01"], ["流水覆盖", "01.01—07.31 · 7 个月连续"], ["最近导入", "08.01 08:01"]], tip: "该账户无需处理,8 月流水到期后正常上传即可。", action: ["查看银行账户", "accounts"] },
|
||||
"acct-icbc": { tag: ["danger", "断档"], title: "工商银行 · 9481", desc: "一般户 · 缺少 07.01—07.21 流水,已逾期 2 天。", fields: [["账户类型", "一般户"], ["缺口期间", "2026.07.01—07.21 · 21 天"], ["影响", "7 月结账与双边匹配"], ["最近导入", "07.31 16:18(6 月批次)"]], tip: "这是最紧急的一项:补齐后系统会自动重算覆盖与匹配。", action: ["去补传流水", "upload"] },
|
||||
"acct-ccb": { tag: ["success", "连续"], title: "建设银行 · 0845", desc: "一般户 · 起算日以来流水全部连续。", fields: [["账户类型", "一般户"], ["启用日期", "2026.01.01"], ["流水覆盖", "01.01—07.31 · 7 个月连续"], ["最近导入", "08.01 10:03"]], tip: "该账户无需处理,8 月流水到期后正常上传即可。", action: ["查看银行账户", "accounts"] },
|
||||
};
|
||||
|
||||
function initDetailDrawer() {
|
||||
const triggers = $$("[data-detail]");
|
||||
if (!triggers.length) return;
|
||||
const drawer = document.createElement("aside");
|
||||
drawer.className = "detail-drawer";
|
||||
drawer.id = "detailDrawer";
|
||||
drawer.setAttribute("aria-label", "事项详情");
|
||||
drawer.innerHTML = `<header><div><span class="status" id="detailTag"></span><h2 id="detailTitle"></h2><p class="detail-desc" id="detailDesc"></p></div><button type="button" class="icon-button" data-close-detail aria-label="关闭详情" title="关闭详情"><svg><use href="icons.svg#x"/></svg></button></header><dl class="detail-fields" id="detailFields"></dl><div class="detail-tip" id="detailTip"></div><footer><button type="button" class="button secondary" data-close-detail>关闭</button><button type="button" class="button primary" id="detailAction"></button></footer>`;
|
||||
document.body.append(drawer);
|
||||
let lastTrigger = null;
|
||||
|
||||
function closeDrawer({ restoreFocus = true } = {}) {
|
||||
drawer.classList.remove("is-open");
|
||||
if (restoreFocus && lastTrigger) lastTrigger.focus();
|
||||
}
|
||||
|
||||
function openDetail(id, trigger) {
|
||||
const item = detailContent[id];
|
||||
if (!item) return;
|
||||
lastTrigger = trigger;
|
||||
const tag = $("#detailTag", drawer);
|
||||
tag.className = `status ${item.tag[0]}`;
|
||||
tag.textContent = item.tag[1];
|
||||
$("#detailTitle", drawer).textContent = item.title;
|
||||
$("#detailDesc", drawer).textContent = item.desc;
|
||||
const fields = $("#detailFields", drawer);
|
||||
fields.replaceChildren(...item.fields.map(([label, value]) => {
|
||||
const row = document.createElement("div");
|
||||
const dt = document.createElement("dt"); dt.textContent = label;
|
||||
const dd = document.createElement("dd"); dd.textContent = value;
|
||||
row.append(dt, dd);
|
||||
return row;
|
||||
}));
|
||||
const tip = $("#detailTip", drawer);
|
||||
tip.replaceChildren();
|
||||
const tipHeading = document.createElement("strong"); tipHeading.textContent = "处理建议";
|
||||
tip.append(tipHeading, document.createTextNode(item.tip));
|
||||
const action = $("#detailAction", drawer);
|
||||
action.textContent = item.action[0];
|
||||
action.onclick = () => {
|
||||
closeDrawer({ restoreFocus: false });
|
||||
if (item.action[1] === "upload") $("[data-open-upload]")?.click();
|
||||
else showView(item.action[1]);
|
||||
};
|
||||
drawer.classList.add("is-open");
|
||||
$("[data-close-detail]", drawer).focus();
|
||||
}
|
||||
|
||||
$$("[data-close-detail]", drawer).forEach((button) => button.addEventListener("click", () => closeDrawer()));
|
||||
drawer.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.stopPropagation();
|
||||
closeDrawer();
|
||||
}
|
||||
});
|
||||
|
||||
triggers.forEach((element) => {
|
||||
element.addEventListener("click", (event) => {
|
||||
const innerButton = event.target.closest("button");
|
||||
if (innerButton && innerButton !== element) return;
|
||||
openDetail(element.dataset.detail, element);
|
||||
});
|
||||
if (element.tagName !== "BUTTON") {
|
||||
element.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
openDetail(element.dataset.detail, element);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initEntry() {
|
||||
const form = $("#loginForm");
|
||||
if (!form) return;
|
||||
@@ -178,38 +282,131 @@ function initEntry() {
|
||||
}
|
||||
const roleInputs = $$('input[name="role"]', form);
|
||||
const username = $('input[name="username"]', form);
|
||||
const password = $('input[name="password"]', form);
|
||||
const action = $("#loginAction");
|
||||
const errorBox = $("#loginError");
|
||||
const changeSection = $("#changePassword");
|
||||
let pendingRole = null;
|
||||
|
||||
function showError(message) {
|
||||
errorBox.textContent = message;
|
||||
errorBox.hidden = false;
|
||||
}
|
||||
|
||||
function updateRole() {
|
||||
const role = $('input[name="role"]:checked', form).value;
|
||||
action.textContent = role === "admin" ? "进入总账管理端" : "进入公司业务端";
|
||||
username.value = role === "admin" ? "group-admin" : "a-cashier";
|
||||
}
|
||||
|
||||
roleInputs.forEach((input) => input.addEventListener("change", updateRole));
|
||||
$("#togglePassword").addEventListener("click", (event) => {
|
||||
const password = $('input[name="password"]', form);
|
||||
const visible = password.type === "text";
|
||||
password.type = visible ? "password" : "text";
|
||||
event.currentTarget.setAttribute("aria-label", visible ? "显示密码" : "隐藏密码");
|
||||
event.currentTarget.title = visible ? "显示密码" : "隐藏密码";
|
||||
});
|
||||
form.addEventListener("submit", (event) => {
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const role = $('input[name="role"]:checked', form).value;
|
||||
errorBox.hidden = true;
|
||||
const role = pendingRole || $('input[name="role"]:checked', form).value;
|
||||
|
||||
if (pendingRole) {
|
||||
const newPassword = $('input[name="new_password"]', form).value;
|
||||
const confirmPassword = $('input[name="confirm_password"]', form).value;
|
||||
if (newPassword !== confirmPassword) {
|
||||
showError("两次输入的新密码不一致。");
|
||||
return;
|
||||
}
|
||||
const changeResponse = await fetch("/api/password/change", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ old_password: password.value, new_password: newPassword }),
|
||||
}).catch(() => null);
|
||||
const changeResult = await changeResponse?.json().catch(() => ({}));
|
||||
if (!changeResponse || !changeResponse.ok) {
|
||||
showError(changeResult?.message || "修改密码失败,请稍后重试。");
|
||||
return;
|
||||
}
|
||||
window.location.href = role === "admin" ? "admin.html" : "company.html";
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch("/api/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: username.value.trim(), password: password.value, portal: role }),
|
||||
}).catch(() => null);
|
||||
const result = await response?.json().catch(() => ({}));
|
||||
if (!response || !response.ok) {
|
||||
showError(result?.message || "登录服务暂时不可用,请稍后重试。");
|
||||
return;
|
||||
}
|
||||
if (result.must_change_password) {
|
||||
pendingRole = role;
|
||||
changeSection.hidden = false;
|
||||
action.textContent = "设置新密码并进入";
|
||||
$('input[name="new_password"]', form).focus();
|
||||
return;
|
||||
}
|
||||
window.location.href = role === "admin" ? "admin.html" : "company.html";
|
||||
});
|
||||
}
|
||||
|
||||
async function initAuthGuard() {
|
||||
if (portal === "entry") return true;
|
||||
try {
|
||||
const response = await fetch("/api/me");
|
||||
if (response.status === 401) {
|
||||
window.location.href = "index.html";
|
||||
return false;
|
||||
}
|
||||
const me = await response.json();
|
||||
if (!response.ok || me.role !== portal) {
|
||||
window.location.href = "index.html";
|
||||
return false;
|
||||
}
|
||||
state.me = me;
|
||||
applyCompanyIdentity(me);
|
||||
return true;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function applyCompanyIdentity(me) {
|
||||
// The company portal always shows the session-bound company, never a
|
||||
// hard-coded one.
|
||||
if (portal !== "company" || !me?.company_name) return;
|
||||
const context = $(".company-context");
|
||||
if (context) {
|
||||
const mark = $("span", context);
|
||||
if (mark) mark.textContent = me.company_name.slice(0, 1);
|
||||
const name = $("strong", context);
|
||||
if (name) name.textContent = me.company_name;
|
||||
}
|
||||
}
|
||||
|
||||
function initShell() {
|
||||
const menuButton = $("#menuButton");
|
||||
if (menuButton) {
|
||||
$$(".page-heading").forEach((heading) => {
|
||||
if (heading.querySelector(".menu-button")) return;
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "icon-button menu-button";
|
||||
button.setAttribute("aria-label", "打开导航");
|
||||
button.setAttribute("aria-expanded", "false");
|
||||
button.setAttribute("aria-controls", "sidebar");
|
||||
button.title = "打开导航";
|
||||
button.innerHTML = '<svg><use href="icons.svg#menu"></use></svg>';
|
||||
heading.prepend(button);
|
||||
});
|
||||
|
||||
$$(".menu-button").forEach((menuButton) => {
|
||||
menuButton.addEventListener("click", () => {
|
||||
const open = $("#sidebar").classList.toggle("is-open");
|
||||
menuButton.setAttribute("aria-expanded", String(open));
|
||||
menuButton.setAttribute("aria-label", open ? "关闭导航" : "打开导航");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$$(".nav-item").forEach((item) => {
|
||||
const label = $("span", item)?.textContent.trim();
|
||||
@@ -220,6 +417,13 @@ function initShell() {
|
||||
});
|
||||
$$("[data-view]").forEach((button) => button.addEventListener("click", () => showView(button.dataset.view)));
|
||||
$$("[data-view-link]").forEach((button) => button.addEventListener("click", () => showView(button.dataset.viewLink)));
|
||||
$$('a.nav-item[href="index.html"]').forEach((link) => link.addEventListener("click", async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
await fetch("/api/logout", { method: "POST" });
|
||||
} catch { /* 网络异常时仍然回到登录页 */ }
|
||||
window.location.href = "index.html";
|
||||
}));
|
||||
$$("[data-metric-link]").forEach((card) => {
|
||||
const activate = () => showView(card.dataset.metricLink);
|
||||
card.addEventListener("click", activate);
|
||||
@@ -240,7 +444,11 @@ function initShell() {
|
||||
}
|
||||
});
|
||||
});
|
||||
$$("[data-toast]").forEach((button) => button.addEventListener("click", () => showToast(button.dataset.toast)));
|
||||
// Delegated: company table rows are rendered from the API after init.
|
||||
document.addEventListener("click", (event) => {
|
||||
const toastButton = event.target.closest("[data-toast]");
|
||||
if (toastButton) showToast(toastButton.dataset.toast);
|
||||
});
|
||||
$(".nav-item[data-view].is-active")?.setAttribute("aria-current", "page");
|
||||
|
||||
$("#globalSearch")?.addEventListener("input", (event) => {
|
||||
@@ -252,12 +460,13 @@ function initShell() {
|
||||
});
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
if ($("#sidebar")?.classList.contains("is-open") && !event.target.closest("#sidebar") && !event.target.closest("#menuButton")) closeNavigation({ restoreFocus: true });
|
||||
if ($("#sidebar")?.classList.contains("is-open") && !event.target.closest("#sidebar") && !event.target.closest(".menu-button")) closeNavigation({ restoreFocus: true });
|
||||
});
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") closeNavigation({ restoreFocus: true });
|
||||
});
|
||||
|
||||
initDetailDrawer();
|
||||
initMotion();
|
||||
}
|
||||
|
||||
@@ -407,59 +616,85 @@ function renderCompanyManualRecords() {
|
||||
if ($("#manualPendingStatus")) $("#manualPendingStatus").textContent = `${pending} 笔待总账复核`;
|
||||
}
|
||||
|
||||
function addApprovedAccountOption(record) {
|
||||
if (record.status !== "已启用") return;
|
||||
const value = `${record.bank} · ${record.accountNumber.slice(-4)}`;
|
||||
function fillAccountSelects(accounts) {
|
||||
const usable = accounts.filter((account) => account.usable);
|
||||
[$("#accountSelect"), $('#manualEntryForm [name="sourceAccount"]')].forEach((select) => {
|
||||
if (!select || [...select.options].some((option) => option.value === value)) return;
|
||||
const option = new Option(value, value);
|
||||
option.dataset.submittedAccount = record.id;
|
||||
select.add(option);
|
||||
if (!select) return;
|
||||
const kept = [...select.options].filter((option) => option.value === "" || option.textContent === "个人过账");
|
||||
select.replaceChildren(...kept);
|
||||
usable.forEach((account) => {
|
||||
const value = `${account.bank_name} · ${accountTail(account.account_number_masked)}`;
|
||||
const option = new Option(value, value);
|
||||
option.dataset.accountId = account.id;
|
||||
select.add(option);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderCompanyAccountSubmissions() {
|
||||
function renderCompanyAccounts(accounts) {
|
||||
const directory = $("#accountDirectory");
|
||||
if (!directory) return;
|
||||
$$('[data-submitted-account]', directory).forEach((item) => item.remove());
|
||||
const records = readStoredRecords(storageKeys.accounts).filter((record) => record.company === "A公司");
|
||||
records.forEach((record) => {
|
||||
const article = document.createElement("article");
|
||||
article.dataset.submittedAccount = record.id;
|
||||
const header = document.createElement("header");
|
||||
const mark = document.createElement("span"); mark.className = "bank-mark"; mark.textContent = record.bank.slice(0, 1);
|
||||
const identity = document.createElement("div");
|
||||
const name = document.createElement("strong"); name.textContent = record.bank;
|
||||
const meta = document.createElement("small"); meta.textContent = `${record.type} · 尾号 ${record.accountNumber.slice(-4)}`;
|
||||
identity.append(name, meta);
|
||||
const status = recordStatus(record.status);
|
||||
const badge = document.createElement("em"); badge.className = `status ${status.className}`; badge.textContent = status.label;
|
||||
header.append(mark, identity, badge);
|
||||
const details = document.createElement("dl");
|
||||
[["申请启用", record.startDate], ["流水覆盖", record.status === "已启用" ? "尚未上传" : "不参与计算"], ["提交时间", record.createdAt]].forEach(([term, value]) => {
|
||||
const wrapper = document.createElement("div");
|
||||
const dt = document.createElement("dt"); dt.textContent = term;
|
||||
const dd = document.createElement("dd"); dd.textContent = value;
|
||||
wrapper.append(dt, dd); details.append(wrapper);
|
||||
});
|
||||
article.append(header, details); directory.append(article);
|
||||
addApprovedAccountOption(record);
|
||||
});
|
||||
if (directory) {
|
||||
directory.replaceChildren(...accounts.map((account) => {
|
||||
const article = document.createElement("article");
|
||||
const header = document.createElement("header");
|
||||
const mark = document.createElement("span"); mark.className = "bank-mark"; mark.textContent = account.bank_name.slice(0, 1);
|
||||
const identity = document.createElement("div");
|
||||
const name = document.createElement("strong"); name.textContent = account.bank_name;
|
||||
const meta = document.createElement("small"); meta.textContent = `${account.account_type} · 尾号 ${accountTail(account.account_number_masked)}`;
|
||||
identity.append(name, meta);
|
||||
const status = recordStatus(accountStatusLabel(account.status));
|
||||
const badge = document.createElement("em"); badge.className = `status ${status.className}`; badge.textContent = status.label;
|
||||
header.append(mark, identity, badge);
|
||||
const details = document.createElement("dl");
|
||||
const rows = [
|
||||
["申请启用", account.effective_from || "待审核确定"],
|
||||
["流水覆盖", account.usable ? "尚未上传" : "不参与计算"],
|
||||
["提交时间", String(account.created_at || "").slice(0, 10) || "—"],
|
||||
];
|
||||
if (account.status === "returned" && account.review_reason) rows.push(["退回原因", account.review_reason]);
|
||||
if (account.status === "disabled" && account.effective_to) rows.push(["停用日期", account.effective_to]);
|
||||
rows.forEach(([term, value]) => {
|
||||
const wrapper = document.createElement("div");
|
||||
const dt = document.createElement("dt"); dt.textContent = term;
|
||||
const dd = document.createElement("dd"); dd.textContent = value;
|
||||
wrapper.append(dt, dd); details.append(wrapper);
|
||||
});
|
||||
article.append(header, details);
|
||||
return article;
|
||||
}));
|
||||
}
|
||||
fillAccountSelects(accounts);
|
||||
}
|
||||
|
||||
async function loadCompanyAccounts() {
|
||||
const response = await fetch("/api/company/accounts").catch(() => null);
|
||||
if (response?.status === 401 || response?.status === 403) {
|
||||
window.location.href = "index.html";
|
||||
return;
|
||||
}
|
||||
if (!response?.ok) return;
|
||||
const result = await response.json().catch(() => null);
|
||||
if (result?.accounts) renderCompanyAccounts(result.accounts);
|
||||
}
|
||||
|
||||
function appendAdminReviewRow(record, kind) {
|
||||
const tbody = $("#auditRows");
|
||||
if (!tbody) return;
|
||||
const isAccount = kind === "account";
|
||||
// Account rows come from the server (full number visible only in this
|
||||
// authorized admin view); manual records are still browser-local demo data.
|
||||
const statusLabel = isAccount ? accountStatusLabel(record.status) : record.status;
|
||||
const row = document.createElement("tr");
|
||||
row.dataset.storedReview = record.id;
|
||||
row.dataset.recordId = record.id;
|
||||
row.dataset.recordKind = kind;
|
||||
row.dataset.auditType = kind === "account" ? "账户" : "手工";
|
||||
row.dataset.company = record.company;
|
||||
row.dataset.evidence = kind === "account"
|
||||
if (isAccount) row.dataset.accountId = record.id;
|
||||
row.dataset.auditType = isAccount ? "账户" : "手工";
|
||||
row.dataset.company = isAccount ? record.company_name : record.company;
|
||||
row.dataset.evidence = isAccount
|
||||
? "公司提交资料、开户行、账号、账户类型与启用日期"
|
||||
: "公司手工记录、关联银行流水号、证明附件与提交说明";
|
||||
if (record.status !== "待复核" && record.status !== "待总账复核") row.dataset.resolved = "true";
|
||||
if (statusLabel !== "待复核" && statusLabel !== "待总账复核") row.dataset.resolved = "true";
|
||||
|
||||
const riskCell = document.createElement("td");
|
||||
const risk = document.createElement("span"); risk.className = "task-level warning"; risk.textContent = "中"; riskCell.append(risk);
|
||||
@@ -469,11 +704,11 @@ function appendAdminReviewRow(record, kind) {
|
||||
const typeCell = document.createElement("td");
|
||||
const periodCell = document.createElement("td");
|
||||
const impactCell = document.createElement("td");
|
||||
if (kind === "account") {
|
||||
identity.textContent = `${record.company} · ${record.bank} ${record.accountNumber.slice(-4)}`;
|
||||
detail.textContent = `${record.type} · 完整账号 ${record.accountNumber}`;
|
||||
if (isAccount) {
|
||||
identity.textContent = `${record.company_name} · ${record.bank_name} ${String(record.account_number).slice(-4)}`;
|
||||
detail.textContent = `${record.account_type} · 完整账号 ${record.account_number}`;
|
||||
typeCell.textContent = "账户登记";
|
||||
periodCell.textContent = record.startDate;
|
||||
periodCell.textContent = record.effective_from || "待审核确定";
|
||||
impactCell.textContent = "账户识别与流水上传";
|
||||
} else {
|
||||
identity.textContent = `${record.company} · ${record.id}`;
|
||||
@@ -484,37 +719,53 @@ function appendAdminReviewRow(record, kind) {
|
||||
}
|
||||
identityCell.append(identity, detail);
|
||||
const statusCell = document.createElement("td");
|
||||
const status = recordStatus(record.status);
|
||||
const status = recordStatus(statusLabel);
|
||||
const badge = document.createElement("span"); badge.className = `status ${status.className}`; badge.textContent = status.label; statusCell.append(badge);
|
||||
const actionCell = document.createElement("td");
|
||||
const action = document.createElement("button"); action.className = "text-button"; action.dataset.auditAction = "";
|
||||
action.textContent = row.dataset.resolved ? "查看记录" : "复核";
|
||||
if (isAccount) row.dataset.accountStatus = record.status;
|
||||
action.textContent = row.dataset.resolved
|
||||
? (isAccount && record.status === "active" ? "管理" : "查看记录")
|
||||
: "复核";
|
||||
if (row.dataset.resolved) {
|
||||
action.dataset.record = `${record.decision || record.status} · ${record.reviewReason || "已留痕"}`;
|
||||
action.dataset.decision = record.decision || record.status;
|
||||
action.dataset.reason = record.reviewReason || "已留痕";
|
||||
action.dataset.processedAt = record.reviewedAt || "时间未记录";
|
||||
const decisionLabels = { active: "复核通过并启用账户", returned: "退回公司修改", disabled: "停用并驳回" };
|
||||
const decisionLabel = isAccount ? decisionLabels[record.status] || statusLabel : record.decision || record.status;
|
||||
const reasonText = (isAccount ? record.review_reason : record.reviewReason) || "已留痕";
|
||||
action.dataset.record = `${decisionLabel} · ${reasonText}`;
|
||||
action.dataset.decision = decisionLabel;
|
||||
action.dataset.reason = reasonText;
|
||||
action.dataset.processedAt = (isAccount ? record.reviewed_at : record.reviewedAt) || "时间未记录";
|
||||
}
|
||||
actionCell.append(action);
|
||||
row.append(riskCell, identityCell, typeCell, periodCell, impactCell, statusCell, actionCell);
|
||||
tbody.append(row);
|
||||
}
|
||||
|
||||
async function renderAdminAccountReviews() {
|
||||
const tbody = $("#auditRows");
|
||||
if (!tbody) return;
|
||||
$$('[data-stored-review][data-record-kind="account"]', tbody).forEach((row) => row.remove());
|
||||
const response = await fetch("/api/admin/accounts").catch(() => null);
|
||||
if (!response?.ok) return;
|
||||
const result = await response.json().catch(() => null);
|
||||
(result?.accounts || []).forEach((account) => appendAdminReviewRow(account, "account"));
|
||||
updateAuditCounts();
|
||||
}
|
||||
|
||||
function renderStoredAdminReviews() {
|
||||
if (!$("#auditRows")) return;
|
||||
$$('[data-stored-review]', $("#auditRows")).forEach((row) => row.remove());
|
||||
readStoredRecords(storageKeys.accounts).forEach((record) => appendAdminReviewRow(record, "account"));
|
||||
readStoredRecords(storageKeys.manual).forEach((record) => appendAdminReviewRow(record, "manual"));
|
||||
renderAdminAccountReviews();
|
||||
}
|
||||
|
||||
function updateStoredReview(kind, id, status, decision, reviewReason, reviewedAt) {
|
||||
if (!kind || !id) return;
|
||||
const key = kind === "account" ? storageKeys.accounts : storageKeys.manual;
|
||||
const records = readStoredRecords(key);
|
||||
if (kind !== "manual" || !id) return;
|
||||
const records = readStoredRecords(storageKeys.manual);
|
||||
const record = records.find((item) => item.id === id);
|
||||
if (!record) return;
|
||||
Object.assign(record, { status, decision, reviewReason, reviewedAt });
|
||||
writeStoredRecords(key, records);
|
||||
writeStoredRecords(storageKeys.manual, records);
|
||||
}
|
||||
|
||||
function updateAuditCounts() {
|
||||
@@ -529,9 +780,71 @@ function updateAuditCounts() {
|
||||
if (badge) badge.textContent = unresolved.length;
|
||||
}
|
||||
|
||||
function companyStatusBadge(status) {
|
||||
if (status === "preparing") return { className: "neutral", label: "筹备中" };
|
||||
if (status === "disabled") return { className: "danger", label: "已停用" };
|
||||
return { className: "success", label: "正常" };
|
||||
}
|
||||
|
||||
function renderAdminCompanyTable(companies) {
|
||||
const tbody = $("#companyTable tbody");
|
||||
if (!tbody) return;
|
||||
tbody.replaceChildren(...companies.map((company) => {
|
||||
const row = document.createElement("tr");
|
||||
const nameCell = document.createElement("td");
|
||||
const name = document.createElement("strong"); name.textContent = company.name;
|
||||
const code = document.createElement("small"); code.textContent = `COMP-${String(company.id).padStart(3, "0")}`;
|
||||
nameCell.append(name, code);
|
||||
const credit = document.createElement("td"); credit.textContent = company.credit_code || "待补充";
|
||||
const accounts = document.createElement("td"); accounts.textContent = `${company.account_count ?? 0} 个`;
|
||||
const usernames = document.createElement("td"); usernames.textContent = company.usernames || "未创建";
|
||||
const cashier = document.createElement("td"); cashier.textContent = company.cashier_name || "未指定";
|
||||
const statusCell = document.createElement("td");
|
||||
const badge = companyStatusBadge(company.status);
|
||||
statusCell.innerHTML = `<span class="status ${badge.className}">${badge.label}</span>`;
|
||||
const actionCell = document.createElement("td");
|
||||
actionCell.innerHTML = `<button class="text-button" data-toast="已打开 ${company.name} 主档">管理</button>`;
|
||||
row.append(nameCell, credit, accounts, usernames, cashier, statusCell, actionCell);
|
||||
return row;
|
||||
}));
|
||||
}
|
||||
|
||||
function setSelectOptions(select, names, { keepFirst = false } = {}) {
|
||||
if (!select || !names.length) return;
|
||||
const kept = keepFirst && select.options.length ? [select.options[0].cloneNode(true)] : [];
|
||||
select.replaceChildren(...kept, ...names.map((name) => new Option(name, name)));
|
||||
}
|
||||
|
||||
function fillCompanySelects(names) {
|
||||
// Every company picker is driven by master data: a newly created company
|
||||
// appears in pair queries, audit filters, flow filters and reminders
|
||||
// without any code change.
|
||||
$$("[data-pair-form]").forEach((form) => {
|
||||
setSelectOptions($('[name="from"]', form), names);
|
||||
setSelectOptions($('[name="to"]', form), names);
|
||||
const toSelect = $('[name="to"]', form);
|
||||
if (toSelect && names.length > 1) toSelect.value = names[1];
|
||||
});
|
||||
setSelectOptions($("#auditCompany"), names, { keepFirst: true });
|
||||
setSelectOptions($("#flowCompany"), names, { keepFirst: true });
|
||||
setSelectOptions($('#reminderForm [name="company"]'), names, { keepFirst: true });
|
||||
setSelectOptions($('#openingDialog [name="from"]'), names);
|
||||
setSelectOptions($('#openingDialog [name="to"]'), names);
|
||||
}
|
||||
|
||||
async function loadAdminCompanies() {
|
||||
const response = await fetch("/api/admin/companies").catch(() => null);
|
||||
if (!response?.ok) return;
|
||||
const result = await response.json().catch(() => null);
|
||||
const companies = result?.companies || [];
|
||||
renderAdminCompanyTable(companies);
|
||||
fillCompanySelects(companies.map((company) => company.name));
|
||||
}
|
||||
|
||||
function initAdmin() {
|
||||
renderStoredAdminReviews();
|
||||
updateAuditCounts();
|
||||
loadAdminCompanies();
|
||||
const companySearch = $('[data-filter-target="companyLedgers"]');
|
||||
companySearch?.addEventListener("input", () => {
|
||||
const query = companySearch.value.trim().toLowerCase();
|
||||
@@ -560,7 +873,10 @@ function initAdmin() {
|
||||
|
||||
const auditDialog = $("#auditDialog");
|
||||
$$('[data-close-audit]').forEach((button) => button.addEventListener("click", () => auditDialog.close()));
|
||||
$$("[data-audit-action]").forEach((button) => button.addEventListener("click", () => {
|
||||
// Delegated: account review rows arrive asynchronously from the API.
|
||||
$("#auditRows")?.addEventListener("click", (event) => {
|
||||
const button = event.target.closest("[data-audit-action]");
|
||||
if (!button) return;
|
||||
const row = button.closest("tr");
|
||||
state.auditRow = row;
|
||||
const cells = $$('td', row);
|
||||
@@ -569,8 +885,9 @@ function initAdmin() {
|
||||
const reason = $('[name="reason"]', form);
|
||||
const submit = $('button[type="submit"]', form);
|
||||
form.reset();
|
||||
const isActiveAccount = row.dataset.recordKind === "account" && row.dataset.accountStatus === "active";
|
||||
const decisions = row.dataset.recordKind === "account"
|
||||
? ["复核通过并启用账户", "退回公司修改", "停用并驳回"]
|
||||
? (isActiveAccount ? ["停用并驳回"] : ["复核通过并启用账户", "退回公司修改", "停用并驳回"])
|
||||
: ["确认并纳入计算", "退回公司补充材料", "转为异常待后续处理"];
|
||||
decision.replaceChildren(new Option("请选择", ""), ...decisions.map((item) => new Option(item, item)));
|
||||
decision.disabled = false;
|
||||
@@ -584,7 +901,7 @@ function initAdmin() {
|
||||
const detail = document.createElement("small"); detail.textContent = cells[1].querySelector("small").textContent;
|
||||
const source = document.createElement("small"); source.textContent = `证据:${row.dataset.evidence || "银行原始行、导入批次、账户覆盖区间与公司说明"}`;
|
||||
evidence.append(heading, detail, source);
|
||||
if (button.dataset.record) {
|
||||
if (button.dataset.record && !isActiveAccount) {
|
||||
decision.value = button.dataset.decision;
|
||||
reason.value = button.dataset.reason;
|
||||
decision.disabled = true;
|
||||
@@ -596,28 +913,57 @@ function initAdmin() {
|
||||
evidence.append(record);
|
||||
}
|
||||
auditDialog.showModal();
|
||||
}));
|
||||
$("#auditForm")?.addEventListener("submit", (event) => {
|
||||
});
|
||||
$("#auditForm")?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const data = new FormData(event.currentTarget);
|
||||
const row = state.auditRow;
|
||||
const decision = String(data.get("decision"));
|
||||
const reason = String(data.get("reason"));
|
||||
const approved = decision.includes("通过") || decision.includes("确认并纳入");
|
||||
const returned = decision.includes("退回");
|
||||
const storedStatus = approved
|
||||
? (row.dataset.recordKind === "account" ? "已启用" : "已确认")
|
||||
: (returned ? "已退回" : "异常待处理");
|
||||
let storedStatus;
|
||||
let reviewedAccount = null;
|
||||
if (row.dataset.recordKind === "account" && row.dataset.accountId) {
|
||||
// Server-side review: the account only becomes usable after this succeeds.
|
||||
const apiDecision = approved ? "approve" : returned ? "return" : "disable";
|
||||
const response = await fetch(`/api/admin/accounts/${row.dataset.accountId}/review`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ decision: apiDecision, reason }),
|
||||
}).catch(() => null);
|
||||
if (response?.status === 401) {
|
||||
window.location.href = "index.html";
|
||||
return;
|
||||
}
|
||||
const result = await response?.json().catch(() => ({}));
|
||||
if (!response || !response.ok) {
|
||||
showToast("审核结果提交失败", result?.message || "请稍后重试");
|
||||
return;
|
||||
}
|
||||
reviewedAccount = result.account;
|
||||
storedStatus = accountStatusLabel(result.account?.status);
|
||||
} else {
|
||||
storedStatus = approved
|
||||
? (row.dataset.recordKind === "account" ? "已启用" : "已确认")
|
||||
: (returned ? "已退回" : "异常待处理");
|
||||
updateStoredReview(row.dataset.recordKind, row.dataset.recordId, storedStatus, decision, reason, new Date().toLocaleString("zh-CN", { hour12: false }));
|
||||
}
|
||||
const status = recordStatus(storedStatus);
|
||||
const statusCell = row.children[5];
|
||||
statusCell.innerHTML = `<span class="status ${status.className}">${status.label}</span>`;
|
||||
row.dataset.resolved = "true";
|
||||
const action = $("[data-audit-action]", row);
|
||||
action.textContent = "查看记录";
|
||||
if (reviewedAccount) {
|
||||
row.dataset.accountStatus = reviewedAccount.status;
|
||||
action.textContent = reviewedAccount.status === "active" ? "管理" : "查看记录";
|
||||
} else {
|
||||
action.textContent = "查看记录";
|
||||
}
|
||||
action.dataset.record = `${decision} · ${data.get("reason")}`;
|
||||
action.dataset.decision = decision;
|
||||
action.dataset.reason = data.get("reason");
|
||||
action.dataset.processedAt = new Date().toLocaleString("zh-CN", { hour12: false });
|
||||
updateStoredReview(row.dataset.recordKind, row.dataset.recordId, storedStatus, decision, String(data.get("reason")), action.dataset.processedAt);
|
||||
updateAuditCounts();
|
||||
auditDialog.close();
|
||||
event.currentTarget.reset();
|
||||
@@ -626,29 +972,42 @@ function initAdmin() {
|
||||
|
||||
const dialog = $("#companyDialog");
|
||||
$("#openCompanyDialog")?.addEventListener("click", () => dialog.showModal());
|
||||
$("#companyForm")?.addEventListener("submit", (event) => {
|
||||
$("#companyForm")?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const data = new FormData(event.currentTarget);
|
||||
const row = document.createElement("tr");
|
||||
const values = [data.get("companyName"), data.get("creditCode") || "待补充", "0 个", data.get("loginName"), data.get("cashier")];
|
||||
values.forEach((value, index) => {
|
||||
const cell = document.createElement("td");
|
||||
if (index === 0) {
|
||||
const strong = document.createElement("strong");
|
||||
strong.textContent = value;
|
||||
cell.append(strong);
|
||||
} else cell.textContent = value;
|
||||
row.append(cell);
|
||||
});
|
||||
const statusCell = document.createElement("td");
|
||||
statusCell.innerHTML = '<span class="status success">正常</span>';
|
||||
const actionCell = document.createElement("td");
|
||||
actionCell.innerHTML = '<button class="text-button">管理</button>';
|
||||
row.append(statusCell, actionCell);
|
||||
$("#companyTable tbody").append(row);
|
||||
const companyName = String(data.get("companyName") || "").trim();
|
||||
const loginName = String(data.get("loginName") || "").trim();
|
||||
const createUser = data.get("createUser") !== null;
|
||||
|
||||
const payload = {
|
||||
name: companyName,
|
||||
credit_code: String(data.get("creditCode") || "").trim(),
|
||||
cashier_name: String(data.get("cashier") || "").trim(),
|
||||
};
|
||||
if (createUser && loginName) payload.username = loginName;
|
||||
|
||||
const response = await fetch("/api/admin/companies", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}).catch(() => null);
|
||||
if (response?.status === 401) {
|
||||
window.location.href = "index.html";
|
||||
return;
|
||||
}
|
||||
const result = await response?.json().catch(() => ({}));
|
||||
if (!response || !response.ok) {
|
||||
showToast("公司创建失败", result?.message || "请稍后重试");
|
||||
return;
|
||||
}
|
||||
const accountCreated = Boolean(result.username);
|
||||
dialog.close();
|
||||
event.currentTarget.reset();
|
||||
showToast("公司与账号已创建", "初始密码:ChangeMe2026(演示)");
|
||||
await loadAdminCompanies();
|
||||
showToast(
|
||||
accountCreated ? "公司与账号已创建" : "公司已创建",
|
||||
accountCreated ? `账号 ${result.username} 的初始密码与登录账号相同,首次登录必须修改` : "可稍后在账号管理中创建公司账号",
|
||||
);
|
||||
});
|
||||
|
||||
$("#systemSettings")?.addEventListener("submit", (event) => {
|
||||
@@ -826,22 +1185,31 @@ function acceptFile(file) {
|
||||
async function parseFile() {
|
||||
const formData = new FormData();
|
||||
formData.append("file", state.selectedFile);
|
||||
const selectedAccount = $("#accountSelect")?.selectedOptions?.[0];
|
||||
if (selectedAccount?.dataset.accountId) {
|
||||
formData.append("bank_account_id", selectedAccount.dataset.accountId);
|
||||
}
|
||||
let result;
|
||||
let parsed = false;
|
||||
try {
|
||||
const response = await fetch("/api/parse", { method: "POST", body: formData });
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
window.location.href = "index.html";
|
||||
return;
|
||||
}
|
||||
result = await response.json();
|
||||
parsed = response.ok && result.status === "parsed";
|
||||
parsed = response.ok && ["parsed", "duplicate"].includes(result.status);
|
||||
} catch {
|
||||
result = { status: "error", message: "解析服务暂时不可用,请稍后重试。" };
|
||||
}
|
||||
state.parseResult = result;
|
||||
const duplicated = result.status === "duplicate";
|
||||
const panel = $("#parseResult");
|
||||
panel.classList.toggle("is-exception", !parsed);
|
||||
$("use", panel).setAttribute("href", parsed ? "icons.svg#circle-check" : "icons.svg#circle-alert");
|
||||
$("strong", panel).textContent = parsed ? "文件解析完成" : "未识别到银行模板";
|
||||
$("strong", panel).textContent = duplicated ? "文件已导入过" : parsed ? "文件解析完成" : "未识别到银行模板";
|
||||
$("#parseSummary").textContent = parsed
|
||||
? `${result.bank} · 表头第 ${result.header_row} 行 · ${result.transactions} 条明细 · ${result.warnings.length ? `${result.warnings.length} 项提示` : "校验通过"}`
|
||||
? `${result.bank} · 表头第 ${result.header_row} 行 · ${result.transactions} 条明细 · ${result.warnings.length ? `${result.warnings.length} 项提示` : "校验通过"}${duplicated ? " · 重复上传,复用已有批次" : ""}`
|
||||
: `${result.message} 系统不会猜测模板或自动入账。`;
|
||||
panel.hidden = false;
|
||||
$("#parseButton span").textContent = parsed ? "确认导入" : "提交异常";
|
||||
@@ -866,7 +1234,7 @@ function submitImportException() {
|
||||
|
||||
function initCompany() {
|
||||
renderCompanyManualRecords();
|
||||
renderCompanyAccountSubmissions();
|
||||
loadCompanyAccounts();
|
||||
const uploadDialog = $("#uploadDialog");
|
||||
$$('[data-open-upload]').forEach((button) => button.addEventListener("click", () => uploadDialog.showModal()));
|
||||
$$('[data-close-upload]').forEach((button) => button.addEventListener("click", () => uploadDialog.close()));
|
||||
@@ -890,7 +1258,7 @@ function initCompany() {
|
||||
$("#uploadForm")?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
if ($("#parseButton").dataset.stage === "confirm") {
|
||||
if (state.parseResult?.status === "parsed") confirmImport();
|
||||
if (["parsed", "duplicate"].includes(state.parseResult?.status)) confirmImport();
|
||||
else submitImportException();
|
||||
return;
|
||||
}
|
||||
@@ -973,29 +1341,33 @@ function initCompany() {
|
||||
const accountDialog = $("#accountDialog");
|
||||
$("#openAccountDialog")?.addEventListener("click", () => accountDialog.showModal());
|
||||
$$('[data-close-account]').forEach((button) => button.addEventListener("click", () => accountDialog.close()));
|
||||
$("#accountForm")?.addEventListener("submit", (event) => {
|
||||
$("#accountForm")?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const data = new FormData(event.currentTarget);
|
||||
const accountNumber = String(data.get("accountNumber")).replace(/[\s-]/g, "");
|
||||
const records = readStoredRecords(storageKeys.accounts);
|
||||
if (records.some((record) => record.accountNumber === accountNumber && record.status !== "已退回")) {
|
||||
showToast("该银行账号已登记", "请等待现有申请处理,或联系总账管理员核对");
|
||||
// The server binds the account to the session company and normalizes the
|
||||
// number; duplicates come back as 409.
|
||||
const response = await fetch("/api/company/accounts", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
bank_name: String(data.get("bank") || "").trim(),
|
||||
account_type: String(data.get("type") || ""),
|
||||
account_number: String(data.get("accountNumber") || ""),
|
||||
start_date: String(data.get("startDate") || ""),
|
||||
}),
|
||||
}).catch(() => null);
|
||||
if (response?.status === 401 || response?.status === 403) {
|
||||
window.location.href = "index.html";
|
||||
return;
|
||||
}
|
||||
const result = await response?.json().catch(() => ({}));
|
||||
if (!response || !response.ok) {
|
||||
showToast("账户登记失败", result?.message || "请稍后重试");
|
||||
return;
|
||||
}
|
||||
records.push({
|
||||
id: `ACC-${Date.now().toString().slice(-10)}`,
|
||||
company: "A公司",
|
||||
bank: String(data.get("bank")).trim(),
|
||||
type: String(data.get("type")),
|
||||
accountNumber,
|
||||
startDate: String(data.get("startDate")),
|
||||
status: "待复核",
|
||||
createdAt: new Date().toLocaleString("zh-CN", { hour12: false }),
|
||||
});
|
||||
if (!writeStoredRecords(storageKeys.accounts, records)) return;
|
||||
renderCompanyAccountSubmissions();
|
||||
accountDialog.close();
|
||||
event.currentTarget.reset();
|
||||
await loadCompanyAccounts();
|
||||
showToast("银行账户已提交登记", "复核通过前不能上传流水,也不参与账户识别和覆盖计算");
|
||||
});
|
||||
}
|
||||
@@ -1003,12 +1375,15 @@ function initCompany() {
|
||||
if (portal === "entry") {
|
||||
initEntry();
|
||||
} else {
|
||||
initShell();
|
||||
initFlowTools();
|
||||
if (portal === "admin") {
|
||||
initPairQueries();
|
||||
initAdmin();
|
||||
} else {
|
||||
initCompany();
|
||||
}
|
||||
initAuthGuard().then((allowed) => {
|
||||
if (!allowed) return;
|
||||
initShell();
|
||||
initFlowTools();
|
||||
if (portal === "admin") {
|
||||
initPairQueries();
|
||||
initAdmin();
|
||||
} else {
|
||||
initCompany();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+54
-31
@@ -3,8 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="往来台账公司业务端" />
|
||||
<title>A公司业务端 · 往来台账</title>
|
||||
<meta name="description" content="金牛集团公司业务端" />
|
||||
<title>公司业务端 · 金牛集团</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body data-portal="company">
|
||||
@@ -19,7 +19,7 @@
|
||||
<a class="skip-link" href="#main-content">跳到主要内容</a>
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar" id="sidebar" aria-label="公司业务导航">
|
||||
<div class="brand"><span class="brand-mark">往</span><span class="brand-copy"><strong>往来台账</strong><small>公司业务端</small></span></div>
|
||||
<div class="brand"><span class="brand-mark">金</span><span class="brand-copy"><strong>金牛集团</strong><small>公司业务端</small></span></div>
|
||||
<div class="company-context"><span>A</span><div><strong>A公司</strong><small>当前登录公司</small></div></div>
|
||||
<nav class="nav-list">
|
||||
<button class="nav-item is-active" data-view="workspace"><svg><use href="icons.svg#layout-dashboard"/></svg><span>工作台</span><b>4</b></button>
|
||||
@@ -37,20 +37,18 @@
|
||||
</aside>
|
||||
|
||||
<div class="workspace">
|
||||
<header class="topbar">
|
||||
<button class="icon-button menu-button" id="menuButton" aria-label="打开导航" aria-expanded="false" aria-controls="sidebar" title="打开导航"><svg><use href="icons.svg#menu"/></svg></button>
|
||||
<div class="workspace-name"><span>A公司</span><strong id="currentViewName">工作台</strong></div>
|
||||
<span class="demo-badge">演示数据</span>
|
||||
<div class="topbar-actions">
|
||||
<label class="search-box"><svg><use href="icons.svg#search"/></svg><span class="sr-only">搜索本公司数据</span><input id="globalSearch" type="search" placeholder="搜索本公司流水或对方" /></label>
|
||||
<button class="period-button" type="button"><svg><use href="icons.svg#calendar"/></svg><span>2026 年 7 月</span></button>
|
||||
<button class="button primary" data-open-upload><svg><use href="icons.svg#upload"/></svg>上传流水</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main-content">
|
||||
<section class="app-view is-active" data-page="workspace">
|
||||
<header class="page-heading"><div><h1>本月待办</h1><p>A公司 · 距离集团结账日还有 3 天</p></div><span class="status warning" id="workspacePendingStatus">4 项待处理</span></header>
|
||||
<header class="page-heading dashboard-head"><div><h1>本月待办</h1><p>A公司 · 距离集团结账日还有 3 天</p><span class="status warning" id="workspacePendingStatus">4 项待处理</span></div>
|
||||
<div class="content-toolbar">
|
||||
<label class="search-box"><svg><use href="icons.svg#search"/></svg><span class="sr-only">搜索本公司数据</span><input id="globalSearch" type="search" placeholder="搜索本公司流水或对方" /></label>
|
||||
<div class="toolbar-group">
|
||||
<button class="icon-button" data-view-link="notifications" aria-label="通知,2 项未读" title="通知"><svg><use href="icons.svg#bell"/></svg><i class="notification-dot">2</i></button>
|
||||
<button class="period-button" type="button"><svg><use href="icons.svg#calendar"/></svg><span>2026 年 7 月</span></button>
|
||||
<button class="button primary" data-open-upload><svg><use href="icons.svg#upload"/></svg>上传流水</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<section class="metric-grid" aria-label="本公司关键指标">
|
||||
<article class="metric-card danger" data-metric-action="upload" tabindex="0" role="button" aria-label="上传 1 个待补流水账户">
|
||||
<div class="metric-copy"><span class="metric-label">待上传账户</span><strong class="metric-value">1<small>/ 4</small></strong></div>
|
||||
@@ -74,25 +72,23 @@
|
||||
</article>
|
||||
</section>
|
||||
<section class="company-alert"><svg><use href="icons.svg#circle-alert"/></svg><div><strong>7 月数据尚不能结账</strong><p>工商银行账户缺少 07.01—07.21 流水,补齐后系统将重新计算匹配结果。</p></div><button class="button primary" data-open-upload>补传流水</button></section>
|
||||
<section class="panel deck-panel" aria-label="账户覆盖卡栈">
|
||||
<div class="panel-heading"><div><h2>账户覆盖</h2><p>A公司 · 4 个银行账户 · 荧光绿为当前连续账户</p></div><span class="status warning">3 / 4 连续</span></div>
|
||||
<div class="stack-deck" role="list">
|
||||
<div class="deck-stage">
|
||||
<article class="deck-card" role="listitem" data-view-link="accounts" tabindex="0" aria-label="中信银行 5316 全部连续" style="--deck-offset:-200px;--deck-rotate:-14deg;--deck-scale:0.94;opacity:0.92;z-index:2"><span class="deck-pos">04</span><span class="deck-mark">中</span><div class="deck-name"><strong>中信银行 · 5316</strong><small>基本户</small></div><div class="deck-meta"><strong>01.01—07.31</strong><small>7 个月连续</small></div><div class="deck-foot"><span>最近导入 09:42</span><em class="status success">连续</em></div></article>
|
||||
<article class="deck-card" role="listitem" data-view-link="accounts" tabindex="0" aria-label="农业银行 3650 全部连续" style="--deck-offset:-90px;--deck-rotate:-6deg;--deck-scale:0.98;opacity:0.96;z-index:3"><span class="deck-pos">03</span><span class="deck-mark">农</span><div class="deck-name"><strong>农业银行 · 3650</strong><small>一般户</small></div><div class="deck-meta"><strong>01.01—07.31</strong><small>7 个月连续</small></div><div class="deck-foot"><span>最近导入 08.01</span><em class="status success">连续</em></div></article>
|
||||
<article class="deck-card is-active is-danger" role="listitem" data-open-upload tabindex="0" aria-label="工商银行 9481 缺少 21 天" style="transform:translate(0,0) rotate(0deg) scale(1);z-index:5"><span class="deck-pos">01</span><span class="deck-mark">工</span><div class="deck-name"><strong>工商银行 · 9481</strong><small>一般户</small></div><div class="deck-meta"><strong>缺口 21 天</strong><small>07.01—07.21</small></div><div class="deck-foot"><span>已逾期 2 天</span><em class="status danger">断档</em></div></article>
|
||||
<article class="deck-card" role="listitem" data-view-link="accounts" tabindex="0" aria-label="建设银行 0845 全部连续" style="--deck-offset:90px;--deck-rotate:6deg;--deck-scale:0.98;opacity:0.96;z-index:3"><span class="deck-pos">02</span><span class="deck-mark">建</span><div class="deck-name"><strong>建设银行 · 0845</strong><small>一般户</small></div><div class="deck-meta"><strong>01.01—07.31</strong><small>7 个月连续</small></div><div class="deck-foot"><span>最近导入 08.01</span><em class="status success">连续</em></div></article>
|
||||
</div>
|
||||
<section class="panel company-stack-panel" aria-label="账户覆盖">
|
||||
<div class="panel-heading"><div><h2>账户覆盖</h2><p>A公司 · 4 个银行账户</p></div><span class="status warning">3 / 4 连续</span></div>
|
||||
<div class="company-list" role="list">
|
||||
<article class="company-row" role="listitem" data-detail="acct-citic" tabindex="0" aria-label="中信银行 5316 全部连续,查看详情"><span class="company-row-mark">中</span><div class="company-row-body"><strong>中信银行 · 5316</strong><small>基本户 · 最近导入 09:42</small></div><div class="company-row-figure"><strong>01.01—07.31</strong><small>7 个月连续</small></div><em class="status success">连续</em></article>
|
||||
<article class="company-row" role="listitem" data-detail="acct-abc" tabindex="0" aria-label="农业银行 3650 全部连续,查看详情"><span class="company-row-mark">农</span><div class="company-row-body"><strong>农业银行 · 3650</strong><small>一般户 · 最近导入 08:01</small></div><div class="company-row-figure"><strong>01.01—07.31</strong><small>7 个月连续</small></div><em class="status success">连续</em></article>
|
||||
<article class="company-row is-danger" role="listitem" data-detail="acct-icbc" tabindex="0" aria-label="工商银行 9481 缺少 21 天,查看详情"><span class="company-row-mark">工</span><div class="company-row-body"><strong>工商银行 · 9481</strong><small>一般户 · 已逾期 2 天</small></div><div class="company-row-figure"><strong>缺口 21 天</strong><small>07.01—07.21</small></div><em class="status danger">断档</em></article>
|
||||
<article class="company-row" role="listitem" data-detail="acct-ccb" tabindex="0" aria-label="建设银行 0845 全部连续,查看详情"><span class="company-row-mark">建</span><div class="company-row-body"><strong>建设银行 · 0845</strong><small>一般户 · 最近导入 08:01</small></div><div class="company-row-figure"><strong>01.01—07.31</strong><small>7 个月连续</small></div><em class="status success">连续</em></article>
|
||||
</div>
|
||||
</section>
|
||||
<div class="company-dashboard-grid">
|
||||
<section class="panel task-panel">
|
||||
<div class="panel-heading"><div><h2>需要处理</h2><p>按结账影响和期限排序</p></div></div>
|
||||
<div class="cashier-tasks">
|
||||
<article data-task-type="upload"><span class="task-level danger">1</span><div><h3>补传工商银行流水</h3><p>账户尾号 9481 · 缺少 07.01—07.21</p><small>影响:7 月账户覆盖与双边匹配</small></div><button class="button secondary small" data-open-upload>上传</button></article>
|
||||
<article data-task-type="match"><span class="task-level warning">2</span><div><h3>确认 1 笔单边流水</h3><p>对方:B公司 · 07.18 · 280.00 万元</p><small>系统找到 2 个候选,请核对摘要与账号</small></div><button class="button secondary small" data-view-link="reconcile">去匹配</button></article>
|
||||
<article data-task-type="subject"><span class="task-level warning">3</span><div><h3>确认往来科目</h3><p>对方:C公司 · 06.27 · 600.00 万元</p><small>待确认:应收或其他应收</small></div><button class="button secondary small" data-view-link="reconcile">去确认</button></article>
|
||||
<article><span class="task-level neutral">4</span><div><h3>阅读总账提醒</h3><p>请于 08.08 前完成 7 月银行流水确认</p><small>管理员 · 今天 09:30</small></div><button class="button secondary small" data-view-link="notifications">查看</button></article>
|
||||
<article data-task-type="upload" data-detail="task-upload"><span class="task-level danger">1</span><div><h3>补传工商银行流水</h3><p>账户尾号 9481 · 缺少 07.01—07.21</p><small>影响:7 月账户覆盖与双边匹配</small></div><button class="button secondary small" data-open-upload>上传</button></article>
|
||||
<article data-task-type="match" data-detail="task-match"><span class="task-level warning">2</span><div><h3>确认 1 笔单边流水</h3><p>对方:B公司 · 07.18 · 280.00 万元</p><small>系统找到 2 个候选,请核对摘要与账号</small></div><button class="button secondary small" data-view-link="reconcile">去匹配</button></article>
|
||||
<article data-task-type="subject" data-detail="task-subject"><span class="task-level warning">3</span><div><h3>确认往来科目</h3><p>对方:C公司 · 06.27 · 600.00 万元</p><small>待确认:应收或其他应收</small></div><button class="button secondary small" data-view-link="reconcile">去确认</button></article>
|
||||
<article data-detail="task-notice"><span class="task-level neutral">4</span><div><h3>阅读总账提醒</h3><p>请于 08.08 前完成 7 月银行流水确认</p><small>管理员 · 今天 09:30</small></div><button class="button secondary small" data-view-link="notifications">查看</button></article>
|
||||
</div>
|
||||
</section>
|
||||
<aside class="panel coverage-panel">
|
||||
@@ -102,6 +98,33 @@
|
||||
</aside>
|
||||
</div>
|
||||
<section class="work-progress" aria-label="本月处理进度"><div><span>1</span><p><strong>流水上传</strong><small>3 / 4 个账户完成</small></p></div><i></i><div><span>2</span><p><strong>异常处理</strong><small>2 项待确认</small></p></div><i></i><div class="pending"><span>3</span><p><strong>公司确认</strong><small>尚未提交</small></p></div><i></i><div class="pending"><span>4</span><p><strong>集团结账</strong><small>次月 5 日</small></p></div></section>
|
||||
<section class="panel timeline-panel" aria-label="账期时间轴">
|
||||
<div class="panel-heading"><div><h2>账期时间轴</h2><p>起算日 2026.01.01 · 每月 5 日结上月账</p></div><span class="status warning">距 7 月结账日 08.05 还有 3 天</span></div>
|
||||
<div class="timeline" role="img" aria-label="2026 年账期时间轴:1 至 6 月已结账,7 月进行中,工商银行 9481 在 07.01 至 07.21 断档,今天 08.08">
|
||||
<div class="timeline-bar">
|
||||
<span class="timeline-seg is-closed" style="left:0;width:81.9%"></span>
|
||||
<span class="timeline-seg is-current" style="left:81.9%;width:15.8%"></span>
|
||||
<span class="timeline-gap" style="left:81.9%;width:9.1%"></span>
|
||||
<i class="timeline-today" style="left:99.1%"></i>
|
||||
</div>
|
||||
<div class="timeline-ticks" aria-hidden="true">
|
||||
<span style="left:0">01.01<small>起算日</small></span>
|
||||
<span style="left:14%">2月</span>
|
||||
<span style="left:26.7%">3月</span>
|
||||
<span style="left:40.7%">4月</span>
|
||||
<span style="left:54.3%">5月</span>
|
||||
<span style="left:68.3%">6月</span>
|
||||
<span style="left:81.9%">7月</span>
|
||||
<span style="left:95.9%">8月</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="timeline-legend">
|
||||
<li><i class="dot success"></i>1—6 月已结账</li>
|
||||
<li><i class="dot warning"></i>7 月进行中 · 08.05 结账</li>
|
||||
<li><i class="dot danger"></i>工行 9481 断档 07.01—07.21</li>
|
||||
<li><i class="dot today"></i>今天 08.08</li>
|
||||
</ul>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="app-view" data-page="upload">
|
||||
@@ -116,7 +139,7 @@
|
||||
<div class="panel-heading"><div><h2>新增记录</h2><p>A公司 · 提交人:牛女士</p></div></div>
|
||||
<div class="form-body">
|
||||
<div class="form-grid"><label class="field"><span>交易日期</span><input name="transactionDate" type="date" value="2026-08-06" required /></label><label class="field"><span>收付方向</span><select name="direction" required><option>付款</option><option>收款</option></select></label></div>
|
||||
<div class="form-grid"><label class="field"><span>金额(元)</span><input name="amount" type="number" min="0.01" step="0.01" required /></label><label class="field"><span>资金来源</span><select name="sourceAccount" required><option value="">请选择</option><option>中信银行 · 5316</option><option>工商银行 · 9481</option><option>建设银行 · 0845</option><option>农业银行 · 3650</option><option>个人过账</option></select></label></div>
|
||||
<div class="form-grid"><label class="field"><span>金额(元)</span><input name="amount" type="number" min="0.01" step="0.01" required /></label><label class="field"><span>资金来源</span><select name="sourceAccount" required><option value="">请选择</option><option>个人过账</option></select></label></div>
|
||||
<div class="form-grid"><label class="field"><span>对方类型</span><select name="counterpartyType" required><option>集团内部公司</option><option>个人过账方</option><option>外部单位</option></select></label><label class="field"><span>对方名称</span><input name="counterparty" maxlength="100" placeholder="公司全称或个人姓名" required /></label></div>
|
||||
<div class="form-grid"><label class="field"><span>对方账号</span><input name="counterpartyAccount" maxlength="64" placeholder="可选" /></label><label class="field"><span>往来科目</span><select name="subject" required><option>应收</option><option>应付</option><option>其他应收</option><option>其他应付</option></select></label></div>
|
||||
<label class="field"><span>业务摘要</span><input name="summary" maxlength="120" placeholder="例如:个人代付后转回" required /></label>
|
||||
@@ -147,7 +170,7 @@
|
||||
|
||||
<section class="app-view" data-page="accounts">
|
||||
<header class="page-heading"><div><h1>银行账户</h1><p>A公司登记账户与流水覆盖区间</p></div><button class="button secondary" id="openAccountDialog"><svg><use href="icons.svg#plus"/></svg>登记账户</button></header>
|
||||
<section class="account-directory" id="accountDirectory"><article><header><span class="bank-mark">中</span><div><strong>中信银行</strong><small>基本户 · 尾号 5316</small></div><em class="status success">连续</em></header><dl><div><dt>启用日期</dt><dd>2026.01.01</dd></div><div><dt>流水覆盖</dt><dd>01.01—07.31</dd></div><div><dt>最近导入</dt><dd>今天 09:42</dd></div></dl></article><article><header><span class="bank-mark">工</span><div><strong>工商银行</strong><small>一般户 · 尾号 9481</small></div><em class="status danger">断档</em></header><dl><div><dt>启用日期</dt><dd>2026.01.01</dd></div><div><dt>流水覆盖</dt><dd>缺 07.01—07.21</dd></div><div><dt>最近导入</dt><dd>07.31 16:18</dd></div></dl></article><article><header><span class="bank-mark">建</span><div><strong>建设银行</strong><small>一般户 · 尾号 0845</small></div><em class="status success">连续</em></header><dl><div><dt>启用日期</dt><dd>2026.01.01</dd></div><div><dt>流水覆盖</dt><dd>01.01—07.31</dd></div><div><dt>最近导入</dt><dd>08.01 10:03</dd></div></dl></article></section>
|
||||
<section class="account-directory" id="accountDirectory"></section>
|
||||
</section>
|
||||
|
||||
<section class="app-view" data-page="notifications">
|
||||
@@ -161,7 +184,7 @@
|
||||
<dialog id="uploadDialog" class="dialog upload-dialog">
|
||||
<form id="uploadForm">
|
||||
<header><div><h2>上传银行流水</h2><p>A公司 · 系统将识别表头与银行模板</p></div><button type="button" class="icon-button" data-close-upload aria-label="关闭" title="关闭"><svg><use href="icons.svg#x"/></svg></button></header>
|
||||
<div class="dialog-body"><label class="field"><span>银行账户</span><select id="accountSelect" required><option value="">请选择账户</option><option>中信银行 · 5316</option><option>工商银行 · 9481</option><option>建设银行 · 0845</option><option>农业银行 · 3650</option></select></label><label class="dropzone" id="dropzone"><input id="fileInput" type="file" accept=".xls,.xlsx" /><svg><use href="icons.svg#upload"/></svg><strong>选择或拖入银行流水文件</strong><span>支持 .xls 与 .xlsx,最大 20 MB</span></label><div class="file-preview" id="filePreview" hidden><span class="file-type"><svg><use href="icons.svg#file-spreadsheet"/></svg></span><span><strong id="fileName"></strong><small id="fileMeta"></small></span><button type="button" class="icon-button" id="removeFile" aria-label="移除文件" title="移除文件"><svg><use href="icons.svg#x"/></svg></button></div><div class="parse-result" id="parseResult" hidden><span class="notification-icon"><svg><use href="icons.svg#circle-check"/></svg></span><span><strong>文件解析完成</strong><p id="parseSummary"></p></span></div></div>
|
||||
<div class="dialog-body"><label class="field"><span>银行账户</span><select id="accountSelect" required><option value="">请选择账户</option></select></label><label class="dropzone" id="dropzone"><input id="fileInput" type="file" accept=".xls,.xlsx" /><svg><use href="icons.svg#upload"/></svg><strong>选择或拖入银行流水文件</strong><span>支持 .xls 与 .xlsx,最大 20 MB</span></label><div class="file-preview" id="filePreview" hidden><span class="file-type"><svg><use href="icons.svg#file-spreadsheet"/></svg></span><span><strong id="fileName"></strong><small id="fileMeta"></small></span><button type="button" class="icon-button" id="removeFile" aria-label="移除文件" title="移除文件"><svg><use href="icons.svg#x"/></svg></button></div><div class="parse-result" id="parseResult" hidden><span class="notification-icon"><svg><use href="icons.svg#circle-check"/></svg></span><span><strong>文件解析完成</strong><p id="parseSummary"></p></span></div></div>
|
||||
<footer><button type="button" class="button secondary" data-close-upload>取消</button><button class="button primary" id="parseButton" type="submit" disabled><span>开始解析</span></button></footer>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
+20
-21
@@ -3,45 +3,44 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="集团内部银行流水与往来账务管理" />
|
||||
<title>登录 · 往来台账</title>
|
||||
<meta name="description" content="河南金牛实业集团内部资金往来与银行流水管理" />
|
||||
<title>登录 · 河南金牛实业集团</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body class="entry-page">
|
||||
<!--
|
||||
THESIS: 登录页只完成身份确认,并明确区分总账管理端与公司业务端。
|
||||
OWN-WORLD: 深黑身份场景、石墨玻璃表单和荧光绿当前状态,延续双端工作台的材料语言。
|
||||
STORY: 用户确认工作身份后进入拥有独立导航、功能侧重和数据边界的对应端口。
|
||||
FIRST VIEWPORT: 左侧用大标题和三个系统事实建立产品身份,右侧玻璃表单首先选择角色再登录。
|
||||
STORY: 居中集团登录卡:先确认集团身份与系统名称,再选择工作端口登录。
|
||||
FIRST VIEWPORT: 一张悬浮玻璃登录卡居于氛围光中央,集团名称置顶,端口选择、账号表单与系统事实依次排列。
|
||||
FORM: 用户参考图锁定的深色玻璃财务工作台,Operate 模式;seed key fe8a50aa。
|
||||
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md
|
||||
-->
|
||||
<main class="entry-shell">
|
||||
<section class="entry-context" aria-labelledby="product-name">
|
||||
<div class="entry-brand"><span class="brand-mark">往</span><span><strong id="product-name">往来台账</strong><small>集团资金往来管理系统</small></span></div>
|
||||
<div class="entry-statement">
|
||||
<h1>一笔往来,追溯到双方银行凭证。</h1>
|
||||
<p>当前演示环境 · 数据均为模拟</p>
|
||||
</div>
|
||||
<dl class="entry-facts">
|
||||
<div><dt>全局起算日</dt><dd>2026.01.01</dd></div>
|
||||
<div><dt>当前账期</dt><dd>2026 年 7 月</dd></div>
|
||||
<div><dt>银行模板</dt><dd>已支持 6 家</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
<section class="entry-form-wrap" aria-labelledby="login-title">
|
||||
<section class="entry-card" aria-labelledby="login-title">
|
||||
<div class="entry-brand"><span class="brand-mark">金</span><span><strong id="product-name">河南金牛实业集团</strong><small>集团资金往来管理系统</small></span></div>
|
||||
<form class="entry-form" id="loginForm">
|
||||
<header><h2 id="login-title">登录往来台账</h2><p>请选择与账号一致的工作端口</p></header>
|
||||
<header><h2 id="login-title">登录</h2><p>请选择与账号一致的工作端口</p></header>
|
||||
<div class="role-switch" role="radiogroup" aria-label="工作端口">
|
||||
<label><input type="radio" name="role" value="admin" checked /><span><svg><use href="icons.svg#shield-check"/></svg><b>总账管理端</b><small>集团管理员</small></span></label>
|
||||
<label><input type="radio" name="role" value="company" /><span><svg><use href="icons.svg#building"/></svg><b>公司业务端</b><small>公司出纳</small></span></label>
|
||||
</div>
|
||||
<label class="field"><span>账号</span><input name="username" autocomplete="username" value="group-admin" required /></label>
|
||||
<label class="field"><span>密码</span><span class="password-field"><input name="password" type="password" autocomplete="current-password" value="demo123456" required /><button type="button" class="inside-icon" id="togglePassword" aria-label="显示密码" title="显示密码"><svg><use href="icons.svg#eye"/></svg></button></span></label>
|
||||
<label class="field"><span>账号</span><input name="username" autocomplete="username" required /></label>
|
||||
<label class="field"><span>密码</span><span class="password-field"><input name="password" type="password" autocomplete="current-password" required /><button type="button" class="inside-icon" id="togglePassword" aria-label="显示密码" title="显示密码"><svg><use href="icons.svg#eye"/></svg></button></span></label>
|
||||
<div id="changePassword" hidden>
|
||||
<p class="entry-note">首次登录须修改密码,请设置新密码后再进入工作台。</p>
|
||||
<label class="field"><span>新密码</span><input name="new_password" type="password" autocomplete="new-password" /></label>
|
||||
<label class="field"><span>确认新密码</span><input name="confirm_password" type="password" autocomplete="new-password" /></label>
|
||||
</div>
|
||||
<p class="entry-note" id="loginError" role="alert" hidden></p>
|
||||
<label class="check-field"><input type="checkbox" checked />记住本次登录</label>
|
||||
<button class="button primary wide" type="submit"><span id="loginAction">进入总账管理端</span><svg><use href="icons.svg#chevron-right"/></svg></button>
|
||||
<p class="entry-note">演示账号不会验证密码,正式系统将由服务端校验账号与公司绑定关系。</p>
|
||||
</form>
|
||||
<dl class="entry-facts">
|
||||
<div><dt>全局起算日</dt><dd>2026.01.01</dd></div>
|
||||
<div><dt>当前账期</dt><dd>2026 年 7 月</dd></div>
|
||||
<div><dt>覆盖银行</dt><dd>已对接 6 家</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
</main>
|
||||
<script src="app.js"></script>
|
||||
|
||||
+187
-87
@@ -2,12 +2,12 @@
|
||||
color-scheme: dark;
|
||||
--font-ui: "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", system-ui, sans-serif;
|
||||
--font-data: "Segoe UI", "Microsoft YaHei UI", system-ui, sans-serif;
|
||||
--color-bg: #050706;
|
||||
--color-bg-soft: #090d0b;
|
||||
--color-nav: #090c0b;
|
||||
--color-surface: rgba(20, 26, 23, 0.82);
|
||||
--color-surface-solid: #131916;
|
||||
--color-surface-raised: #18201c;
|
||||
--color-bg: #050505;
|
||||
--color-bg-soft: #0a0a0a;
|
||||
--color-nav: #0a0a0a;
|
||||
--color-surface: rgba(22, 22, 22, 0.82);
|
||||
--color-surface-solid: #161616;
|
||||
--color-surface-raised: #1c1c1c;
|
||||
--color-surface-muted: rgba(255, 255, 255, 0.035);
|
||||
--color-line: rgba(255, 255, 255, 0.09);
|
||||
--color-line-strong: rgba(255, 255, 255, 0.16);
|
||||
@@ -31,7 +31,7 @@
|
||||
--radius-lg: 22px;
|
||||
--radius-xl: 28px;
|
||||
--shadow-panel: 0 24px 70px rgba(0, 0, 0, 0.34), inset 0 1px rgba(255, 255, 255, 0.035);
|
||||
--shadow-low: 0 12px 32px rgba(0, 0, 0, 0.22), inset 0 1px rgba(255, 255, 255, 0.025);
|
||||
--shadow-low: 0 12px 32px rgba(0, 0, 0, 0.22), inset 0 1px rgba(255, 255, 255, 0.06), inset 0 -1px 0 rgba(0, 0, 0, 0.22);
|
||||
--shadow-glow: 0 0 28px rgba(55, 235, 137, 0.12);
|
||||
--duration-fast: 150ms;
|
||||
--duration-standard: 260ms;
|
||||
@@ -40,7 +40,7 @@
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { min-width: 320px; background: var(--color-bg); scroll-behavior: smooth; }
|
||||
body { margin: 0; min-width: 320px; min-height: 100vh; background: var(--color-bg); color: var(--color-ink); font: 13px/1.55 var(--font-ui); -webkit-font-smoothing: antialiased; }
|
||||
body { margin: 0; min-width: 320px; min-height: 100vh; background: var(--color-bg); color: var(--color-ink); font: 14px/1.6 var(--font-ui); -webkit-font-smoothing: antialiased; }
|
||||
button, input, select, textarea { font: inherit; }
|
||||
button, a, summary { -webkit-tap-highlight-color: transparent; }
|
||||
button { color: inherit; }
|
||||
@@ -60,9 +60,9 @@ strong, .amount, .number, dd { font-variant-numeric: tabular-nums; }
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
/* Application frame */
|
||||
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 252px minmax(0, 1fr); }
|
||||
.sidebar { position: fixed; inset: 18px auto 18px 18px; z-index: 120; width: 234px; display: flex; flex-direction: column; padding: 16px 12px; border: 1px solid var(--color-line); border-radius: var(--radius-xl); background: rgba(12, 16, 14, 0.9); box-shadow: var(--shadow-panel); backdrop-filter: blur(26px) saturate(125%); }
|
||||
.brand { min-height: 60px; display: flex; align-items: center; gap: 11px; padding: 7px 10px 15px; border-bottom: 1px solid var(--color-line); }
|
||||
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 266px minmax(0, 1fr); }
|
||||
.sidebar { position: fixed; inset: 18px auto 18px 18px; z-index: 120; width: 248px; display: flex; flex-direction: column; padding: 16px 12px; border: 1px solid var(--color-line); border-radius: var(--radius-xl); background: rgba(12, 12, 12, 0.9); box-shadow: var(--shadow-panel); backdrop-filter: blur(26px) saturate(125%); }
|
||||
.brand { min-height: 60px; display: flex; align-items: center; gap: 11px; padding: 7px 10px 20px; border-bottom: 1px solid var(--color-line); }
|
||||
.brand-mark { width: 36px; height: 36px; display: grid; place-items: center; flex: 0 0 auto; border: 1px solid rgba(112, 255, 174, 0.32); border-radius: 12px; background: var(--color-primary-wash); color: var(--color-primary-strong); box-shadow: var(--shadow-glow); font-size: 18px; font-weight: 800; }
|
||||
.brand-copy { display: flex; flex-direction: column; min-width: 0; }
|
||||
.brand-copy strong { font-size: 15px; }
|
||||
@@ -70,8 +70,8 @@ strong, .amount, .number, dd { font-variant-numeric: tabular-nums; }
|
||||
.company-context { min-height: 64px; display: flex; align-items: center; gap: 10px; margin: 12px 4px 2px; padding: 10px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: var(--color-surface-muted); }
|
||||
.company-context > span { width: 34px; height: 34px; display: grid; place-items: center; border-radius: 11px; background: var(--color-primary); color: var(--color-primary-dark); font-weight: 800; }
|
||||
.company-context div { display: flex; flex-direction: column; }
|
||||
.nav-list { display: grid; gap: 5px; padding: 14px 2px; overflow-y: auto; }
|
||||
.nav-item { position: relative; width: 100%; min-height: 43px; display: flex; align-items: center; gap: 11px; padding: 0 12px; border: 1px solid transparent; border-radius: 14px; background: transparent; color: var(--color-ink-soft); text-align: left; cursor: pointer; transition: color var(--duration-fast), background var(--duration-fast), border-color var(--duration-fast), transform var(--duration-fast); }
|
||||
.nav-list { display: grid; gap: 8px; padding: 14px 2px; overflow-y: auto; }
|
||||
.nav-item { position: relative; width: 100%; min-height: 46px; display: flex; align-items: center; gap: 11px; padding: 0 12px; border: 1px solid transparent; border-radius: 14px; background: transparent; color: var(--color-ink-soft); text-align: left; cursor: pointer; transition: color var(--duration-fast), background var(--duration-fast), border-color var(--duration-fast), transform var(--duration-fast); }
|
||||
.nav-item:hover { color: var(--color-ink); background: rgba(255, 255, 255, 0.045); transform: translateX(2px); }
|
||||
.nav-item.is-active { border-color: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.075); color: var(--color-ink); box-shadow: inset 0 1px rgba(255, 255, 255, 0.045), 0 8px 22px rgba(0, 0, 0, 0.2); }
|
||||
.nav-item.is-active svg { color: var(--color-primary); filter: drop-shadow(0 0 8px rgba(55, 235, 137, 0.42)); }
|
||||
@@ -83,13 +83,14 @@ strong, .amount, .number, dd { font-variant-numeric: tabular-nums; }
|
||||
.user-block > span:last-child { display: flex; flex-direction: column; }
|
||||
.avatar { width: 34px; height: 34px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 50%; background: #242c28; color: var(--color-primary-strong); font-weight: 700; }
|
||||
.workspace { min-width: 0; grid-column: 2; }
|
||||
.topbar { position: sticky; top: 0; z-index: 90; height: 78px; display: flex; align-items: center; gap: 13px; padding: 0 30px; border-bottom: 1px solid rgba(255, 255, 255, 0.055); background: rgba(5, 7, 6, 0.82); backdrop-filter: blur(22px); }
|
||||
.topbar { position: sticky; top: 0; z-index: 90; height: 56px; display: flex; align-items: center; gap: 13px; padding: 0 30px; border-bottom: 1px solid rgba(255, 255, 255, 0.055); background: rgba(5, 5, 5, 0.82); backdrop-filter: blur(22px); }
|
||||
.topbar-spacer { flex: 1; }
|
||||
.workspace-name { display: flex; flex-direction: column; min-width: 150px; }
|
||||
.workspace-name span { color: var(--color-ink-muted); font-size: 10px; }
|
||||
.workspace-name strong { font-size: 14px; }
|
||||
.demo-badge { padding: 3px 8px; border: 1px solid rgba(255, 188, 82, 0.22); border-radius: 8px; background: var(--color-warning-wash); color: var(--color-warning); font-size: 10px; }
|
||||
.topbar-actions { display: flex; align-items: center; gap: 9px; margin-left: auto; }
|
||||
.search-box { width: clamp(230px, 26vw, 390px); height: 40px; display: flex; align-items: center; gap: 9px; padding: 0 13px; border: 1px solid var(--color-line); border-radius: 14px; background: rgba(255, 255, 255, 0.035); color: var(--color-ink-muted); transition: border-color var(--duration-fast), background var(--duration-fast), box-shadow var(--duration-fast); }
|
||||
.search-box { width: clamp(200px, 22vw, 340px); height: 40px; display: flex; align-items: center; gap: 9px; padding: 0 13px; border: 1px solid var(--color-line); border-radius: 14px; background: rgba(255, 255, 255, 0.035); color: var(--color-ink-muted); transition: border-color var(--duration-fast), background var(--duration-fast), box-shadow var(--duration-fast); }
|
||||
.search-box:focus-within { border-color: rgba(55, 235, 137, 0.4); background: rgba(255, 255, 255, 0.055); box-shadow: var(--shadow-glow); }
|
||||
.search-box input { width: 100%; border: 0; outline: 0; background: transparent; color: var(--color-ink); }
|
||||
.search-box input::placeholder, input::placeholder, textarea::placeholder { color: #626b66; }
|
||||
@@ -100,12 +101,16 @@ strong, .amount, .number, dd { font-variant-numeric: tabular-nums; }
|
||||
.period-button:hover, .icon-button:hover, .inside-icon:hover, .swap-button:hover { border-color: var(--color-line-strong); background: rgba(255, 255, 255, 0.07); color: var(--color-ink); transform: translateY(-1px); }
|
||||
.notification-dot { position: absolute; top: -5px; right: -5px; min-width: 17px; height: 17px; display: grid; place-items: center; border: 2px solid var(--color-bg); border-radius: 9px; background: var(--color-danger); color: #fff; font: 9px var(--font-data); }
|
||||
.menu-button { display: none; }
|
||||
main { width: min(1560px, 100%); margin: 0 auto; padding: 32px 32px 58px; }
|
||||
main { width: min(1560px, 100%); margin: 0 auto; padding: 12px 32px 58px; }
|
||||
.app-view { display: none; }
|
||||
.app-view.is-active { display: block; }
|
||||
.page-heading { min-height: 70px; display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 22px; }
|
||||
.page-heading h1 { font-size: clamp(26px, 3vw, 35px); line-height: 1.15; letter-spacing: 0; }
|
||||
.page-heading p { margin-top: 7px; color: var(--color-ink-muted); }
|
||||
.page-heading { min-height: 56px; display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 18px; }
|
||||
.page-heading h1 { font-size: clamp(22px, 2.4vw, 30px); line-height: 1.2; letter-spacing: 0; }
|
||||
.page-heading p { margin-top: 5px; color: var(--color-ink-muted); }
|
||||
.content-toolbar { display: contents; }
|
||||
.content-toolbar > .search-box { margin-inline: auto; width: clamp(260px, 42vw, 520px); }
|
||||
.content-toolbar .toolbar-group { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
|
||||
.dashboard-head { align-items: center; gap: 20px; margin-bottom: 20px; }
|
||||
|
||||
/* Commands and fields */
|
||||
.button { min-height: 40px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 0 15px; border: 1px solid transparent; border-radius: 13px; font-weight: 700; cursor: pointer; transition: transform var(--duration-fast), box-shadow var(--duration-fast), background var(--duration-fast), border-color var(--duration-fast); }
|
||||
@@ -135,8 +140,9 @@ main { width: min(1560px, 100%); margin: 0 auto; padding: 32px 32px 58px; }
|
||||
.swap-button { width: 38px; height: 38px; border-radius: 12px; align-self: end; }
|
||||
|
||||
/* Glass surfaces */
|
||||
.panel, .period-ribbon, .query-band, .filter-bar, .filter-grid, .pair-report, .work-progress, .reconcile-summary { position: relative; border: 1px solid var(--color-line); border-radius: var(--radius-lg); background: var(--color-surface); box-shadow: var(--shadow-low); backdrop-filter: blur(22px) saturate(120%); }
|
||||
.panel::before, .period-ribbon::before, .reconcile-summary::before, .work-progress::before { content: ""; position: absolute; inset: 0; border-radius: inherit; background: linear-gradient(168deg, rgba(255, 255, 255, 0.04) 0%, rgba(255, 255, 255, 0) 32%); pointer-events: none; }
|
||||
.panel, .period-ribbon, .query-band, .filter-bar, .filter-grid, .pair-report, .work-progress, .reconcile-summary { position: relative; border: 1px solid var(--color-line); border-radius: var(--radius-lg); background: linear-gradient(160deg, rgba(28, 28, 28, 0.82) 0%, rgba(16, 16, 16, 0.86) 50%, rgba(10, 10, 10, 0.88) 100%); box-shadow: var(--shadow-low); backdrop-filter: blur(22px) saturate(120%); transition: transform var(--duration-standard) var(--ease-out), box-shadow var(--duration-standard), border-color var(--duration-standard); }
|
||||
.panel::before, .period-ribbon::before, .reconcile-summary::before, .work-progress::before { content: ""; position: absolute; inset: 0; border-radius: inherit; background: linear-gradient(165deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.03) 40%, rgba(255, 255, 255, 0) 70%); pointer-events: none; }
|
||||
.panel:hover { box-shadow: 0 20px 48px rgba(0, 0, 0, 0.32), inset 0 1px rgba(255, 255, 255, 0.07); border-color: rgba(255, 255, 255, 0.14); }
|
||||
.panel { min-width: 0; overflow: hidden; }
|
||||
.panel > * { position: relative; }
|
||||
.panel-heading { min-height: 67px; display: flex; align-items: center; justify-content: space-between; gap: 15px; padding: 14px 17px; border-bottom: 1px solid var(--color-line); }
|
||||
@@ -153,14 +159,15 @@ main { width: min(1560px, 100%); margin: 0 auto; padding: 32px 32px 58px; }
|
||||
.task-level.neutral { background: rgba(255, 255, 255, 0.055); color: var(--color-ink-soft); }
|
||||
|
||||
/* Dashboard */
|
||||
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin: 0 0 18px; }
|
||||
.metric-card { position: relative; min-height: 142px; display: grid; grid-template-columns: minmax(0, 1fr) 48px; align-content: space-between; gap: 14px; padding: 20px; overflow: hidden; border: 1px solid var(--color-line); border-radius: var(--radius-lg); background: var(--color-surface); box-shadow: var(--shadow-low); backdrop-filter: blur(24px) saturate(125%); transition: transform var(--duration-standard) var(--ease-out), border-color var(--duration-standard), box-shadow var(--duration-standard); }
|
||||
.metric-card::before { content: ""; position: absolute; inset: 0; border-radius: inherit; background: linear-gradient(168deg, rgba(255, 255, 255, 0.055) 0%, rgba(255, 255, 255, 0) 38%); pointer-events: none; }
|
||||
.metric-card::after { content: ""; position: absolute; inset: auto 15% -1px 15%; height: 1px; background: currentColor; opacity: 0.35; box-shadow: 0 -8px 24px currentColor; }
|
||||
.metric-card:hover { transform: translateY(-4px); border-color: rgba(255, 255, 255, 0.17); box-shadow: 0 24px 54px rgba(0, 0, 0, 0.3); }
|
||||
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 18px; margin: 0 0 18px; }
|
||||
.metric-card { position: relative; min-height: 164px; display: grid; grid-template-columns: minmax(0, 1fr) 48px; align-content: space-between; gap: 14px; padding: 24px 22px; overflow: hidden; border: 1px solid var(--color-line); border-radius: var(--radius-lg); background: linear-gradient(160deg, rgba(28, 28, 28, 0.86) 0%, rgba(14, 14, 14, 0.88) 50%, rgba(8, 8, 8, 0.9) 100%); box-shadow: var(--shadow-low); backdrop-filter: blur(24px) saturate(125%); transform: perspective(900px) rotateX(0.6deg) rotateY(-0.8deg); transform-origin: center bottom; transition: transform var(--duration-standard) var(--ease-out), border-color var(--duration-standard), box-shadow var(--duration-standard); }
|
||||
.metric-card::before { content: ""; position: absolute; inset: 0; border-radius: inherit; background: linear-gradient(165deg, rgba(255, 255, 255, 0.12) 0%, rgba(255, 255, 255, 0.04) 42%, rgba(255, 255, 255, 0) 72%); pointer-events: none; }
|
||||
.metric-card::after { content: ""; position: absolute; inset: auto 15% -1px 15%; height: 1px; background: currentColor; opacity: 0.35; box-shadow: 0 -8px 24px currentColor; transition: opacity var(--duration-standard), box-shadow var(--duration-standard); }
|
||||
.metric-card:hover::after { opacity: 0.7; box-shadow: 0 -10px 34px currentColor; }
|
||||
.metric-card:hover { transform: perspective(900px) rotateX(3.5deg) rotateY(-4.5deg) translateY(-10px); border-color: rgba(255, 255, 255, 0.32); box-shadow: 0 36px 80px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.06), inset 0 1px rgba(255, 255, 255, 0.14); }
|
||||
.metric-copy { align-self: start; }
|
||||
.metric-label { display: block; color: var(--color-ink-soft); font-size: 12px; }
|
||||
.metric-value { display: block; margin-top: 7px; font: 700 31px/1 var(--font-data); letter-spacing: 0; }
|
||||
.metric-label { display: block; color: var(--color-ink-soft); font-size: 13px; }
|
||||
.metric-value { display: block; margin-top: 7px; font: 700 34px/1.05 var(--font-data); letter-spacing: 0; }
|
||||
.metric-value small { margin-left: 5px; color: var(--color-ink-muted); font: 11px var(--font-ui); }
|
||||
.metric-icon { position: relative; width: 48px; height: 48px; display: grid; place-items: center; border: 1px solid var(--color-line); border-radius: 50%; background: radial-gradient(circle at 50% 35%, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.02) 70%); color: var(--color-ink-soft); box-shadow: inset 0 1px rgba(255, 255, 255, 0.05); }
|
||||
.metric-icon::after { content: ""; position: absolute; inset: -3px; border-radius: 50%; border: 1px solid currentColor; opacity: 0.12; pointer-events: none; }
|
||||
@@ -202,49 +209,24 @@ main { width: min(1560px, 100%); margin: 0 auto; padding: 32px 32px 58px; }
|
||||
.quick-result strong { display: block; margin: 4px 0; color: var(--color-primary-strong); font: 700 22px var(--font-data); }
|
||||
.quick-result small { margin-left: 4px; font: 10px var(--font-ui); }
|
||||
|
||||
/* Stack-deck: signature 3D card-stack visual */
|
||||
.stack-deck { position: relative; height: 188px; perspective: 1400px; perspective-origin: 50% 60%; }
|
||||
.deck-stage { position: absolute; inset: 0; display: grid; place-items: center; }
|
||||
.deck-card { position: absolute; width: 184px; min-height: 158px; display: flex; flex-direction: column; justify-content: space-between; gap: 14px; padding: 16px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: linear-gradient(180deg, rgba(28, 36, 32, 0.92), rgba(14, 19, 16, 0.92)); box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35), inset 0 1px rgba(255, 255, 255, 0.05); transform-origin: 50% 100%; transition: transform var(--duration-standard) var(--ease-out), box-shadow var(--duration-standard), border-color var(--duration-standard), opacity var(--duration-standard); cursor: pointer; }
|
||||
.deck-card::before { content: ""; position: absolute; inset: 0; border-radius: inherit; background: linear-gradient(168deg, rgba(255, 255, 255, 0.07) 0%, rgba(255, 255, 255, 0) 36%); pointer-events: none; }
|
||||
.deck-card > * { position: relative; }
|
||||
.deck-card .deck-mark { width: 32px; height: 32px; display: grid; place-items: center; border-radius: 11px; background: var(--color-primary-wash); color: var(--color-primary); font-weight: 800; font-size: 13px; }
|
||||
.deck-card .deck-name { display: flex; flex-direction: column; }
|
||||
.deck-card .deck-name strong { font-size: 14px; }
|
||||
.deck-card .deck-name small { color: var(--color-ink-muted); font-size: 11px; }
|
||||
.deck-card .deck-meta { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
|
||||
.deck-card .deck-meta strong { font: 600 16px var(--font-data); color: var(--color-ink); }
|
||||
.deck-card .deck-meta small { color: var(--color-ink-muted); font-size: 11px; }
|
||||
.deck-card .deck-foot { display: flex; align-items: center; justify-content: space-between; gap: 6px; font-size: 10px; color: var(--color-ink-muted); }
|
||||
.deck-card.is-active { border-color: rgba(55, 235, 137, 0.55); background: linear-gradient(180deg, rgba(20, 56, 36, 0.95), rgba(8, 22, 14, 0.95)); box-shadow: 0 28px 60px rgba(0, 0, 0, 0.45), 0 0 36px rgba(55, 235, 137, 0.22), inset 0 1px rgba(112, 255, 174, 0.18); }
|
||||
.deck-card.is-active .deck-mark { background: rgba(255, 255, 255, 0.08); color: var(--color-primary-strong); box-shadow: inset 0 0 0 1px rgba(112, 255, 174, 0.4); }
|
||||
.deck-card.is-active .deck-name strong { color: var(--color-primary-strong); }
|
||||
.deck-card.is-warning { border-color: rgba(255, 188, 82, 0.35); }
|
||||
.deck-card.is-danger { border-color: rgba(255, 98, 109, 0.35); }
|
||||
.deck-card .deck-pos { position: absolute; top: 12px; right: 12px; font: 700 10px var(--font-data); color: var(--color-ink-muted); letter-spacing: 0.06em; }
|
||||
.deck-card.is-active .deck-pos { color: var(--color-primary-strong); }
|
||||
|
||||
/* Stack-deck fan layout */
|
||||
.deck-stage .deck-card:hover { transform: translateY(-12px) scale(1.02); box-shadow: 0 28px 60px rgba(0, 0, 0, 0.5); opacity: 1; z-index: 6; }
|
||||
.deck-stage .deck-card.is-active:hover { transform: translateY(-10px) scale(1.04); box-shadow: 0 32px 70px rgba(0, 0, 0, 0.55), 0 0 40px rgba(55, 235, 137, 0.3), inset 0 1px rgba(112, 255, 174, 0.18); }
|
||||
.deck-panel { padding-bottom: 6px; overflow: visible; }
|
||||
.deck-panel > .panel-heading { background: var(--color-surface); position: relative; z-index: 7; }
|
||||
.deck-panel .stack-deck { overflow: visible; padding: 12px 16px 18px; }
|
||||
|
||||
/* Deck card fan layout driven by --deck-offset / --deck-rotate */
|
||||
.deck-card { --deck-offset: 0px; --deck-rotate: 0deg; --deck-scale: 1; transform: translate(var(--deck-offset), 6px) rotate(var(--deck-rotate)) scale(var(--deck-scale)); }
|
||||
.deck-stage .deck-card:nth-child(1) { --deck-offset: -280px; --deck-rotate: -22deg; --deck-scale: 0.92; z-index: 1; opacity: 0.85; }
|
||||
.deck-stage .deck-card:nth-child(2) { --deck-offset: -165px; --deck-rotate: -12deg; --deck-scale: 0.96; z-index: 2; opacity: 0.92; }
|
||||
.deck-stage .deck-card:nth-child(3) { --deck-offset: -55px; --deck-rotate: -4deg; z-index: 3; }
|
||||
.deck-stage .deck-card:nth-child(4) { --deck-offset: 55px; --deck-rotate: 4deg; z-index: 4; }
|
||||
.deck-stage .deck-card:nth-child(5) { --deck-offset: 165px; --deck-rotate: 12deg; --deck-scale: 0.96; z-index: 2; opacity: 0.92; }
|
||||
.deck-stage .deck-card:nth-child(6) { --deck-offset: 280px; --deck-rotate: 22deg; --deck-scale: 0.92; z-index: 1; opacity: 0.85; }
|
||||
.deck-stage .deck-card:nth-child(7) { --deck-offset: 395px; --deck-rotate: 30deg; --deck-scale: 0.88; z-index: 0; opacity: 0.7; }
|
||||
.deck-stage .deck-card:nth-child(8) { --deck-offset: -395px; --deck-rotate: -30deg; --deck-scale: 0.88; z-index: 0; opacity: 0.7; }
|
||||
.deck-card.is-active { z-index: 5; }
|
||||
/* Key-company / account-coverage flat list (replaces the floating 3D card stack) */
|
||||
.company-list { display: grid; gap: 8px; padding: 10px 12px 14px; }
|
||||
.company-row { position: relative; display: grid; grid-template-columns: 34px minmax(0, 1fr) auto auto; align-items: center; gap: 12px; min-height: 62px; padding: 11px 13px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: linear-gradient(160deg, rgba(22, 22, 22, 0.7) 0%, rgba(12, 12, 12, 0.75) 100%); cursor: pointer; transition: background var(--duration-fast), border-color var(--duration-fast), transform var(--duration-standard) var(--ease-out), box-shadow var(--duration-standard); }
|
||||
.company-row:hover { background: linear-gradient(160deg, rgba(32, 32, 32, 0.8) 0%, rgba(18, 18, 18, 0.85) 100%); border-color: var(--color-line-strong); box-shadow: 0 12px 28px rgba(0, 0, 0, 0.25); }
|
||||
.company-row:focus-visible { outline: 2px solid var(--color-primary-strong); outline-offset: 2px; }
|
||||
.company-row-mark { width: 34px; height: 34px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 10px; background: var(--color-primary-wash); color: var(--color-primary); font-weight: 800; font-size: 13px; }
|
||||
.company-row.is-warning .company-row-mark { background: var(--color-warning-wash); color: var(--color-warning); }
|
||||
.company-row.is-danger .company-row-mark { background: var(--color-danger-wash); color: var(--color-danger); }
|
||||
.company-row-body { display: flex; flex-direction: column; min-width: 0; }
|
||||
.company-row-body strong { font-size: 14px; }
|
||||
.company-row-body small { color: var(--color-ink-muted); font-size: 11px; }
|
||||
.company-row-figure { display: flex; flex-direction: column; align-items: flex-end; text-align: right; min-width: 92px; }
|
||||
.company-row-figure strong { font: 600 14px var(--font-data); color: var(--color-ink); }
|
||||
.company-row-figure small { color: var(--color-ink-muted); font-size: 11px; }
|
||||
.company-row .status { flex: 0 0 auto; }
|
||||
|
||||
/* Timeline-strip: horizontal calculation window ribbon */
|
||||
.timeline-strip { display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: 14px; align-items: stretch; padding: 16px 18px; border: 1px solid var(--color-line); border-radius: var(--radius-lg); background: linear-gradient(180deg, rgba(20, 26, 23, 0.86), rgba(10, 14, 12, 0.86)); box-shadow: var(--shadow-low); backdrop-filter: blur(22px) saturate(125%); }
|
||||
.timeline-strip { display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: 14px; align-items: stretch; padding: 16px 18px; border: 1px solid var(--color-line); border-radius: var(--radius-lg); background: linear-gradient(180deg, rgba(22, 22, 22, 0.86), rgba(10, 10, 10, 0.86)); box-shadow: var(--shadow-low); backdrop-filter: blur(22px) saturate(125%); }
|
||||
.timeline-strip::before { content: ""; position: absolute; }
|
||||
.timeline-legend { display: flex; flex-direction: column; justify-content: center; gap: 6px; }
|
||||
.timeline-legend strong { font-size: 13px; }
|
||||
@@ -409,7 +391,7 @@ main { width: min(1560px, 100%); margin: 0 auto; padding: 32px 32px 58px; }
|
||||
.manual-records-table { min-width: 900px; }
|
||||
|
||||
/* Dialogs and feedback */
|
||||
.dialog { width: min(580px, calc(100% - 28px)); padding: 0; overflow: hidden; border: 1px solid var(--color-line-strong); border-radius: var(--radius-lg); background: rgba(17, 23, 20, 0.96); color: var(--color-ink); box-shadow: var(--shadow-panel); backdrop-filter: blur(28px); }
|
||||
.dialog { width: min(580px, calc(100% - 28px)); padding: 0; overflow: hidden; border: 1px solid var(--color-line-strong); border-radius: var(--radius-lg); background: rgba(17, 17, 17, 0.96); color: var(--color-ink); box-shadow: var(--shadow-panel); backdrop-filter: blur(28px); }
|
||||
.dialog::backdrop { background: rgba(0, 0, 0, 0.74); backdrop-filter: blur(6px); }
|
||||
.dialog > form > header { min-height: 72px; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 18px; border-bottom: 1px solid var(--color-line); }
|
||||
.dialog header p { color: var(--color-ink-muted); }
|
||||
@@ -431,14 +413,14 @@ main { width: min(1560px, 100%); margin: 0 auto; padding: 32px 32px 58px; }
|
||||
.parse-result.is-exception { border-color: rgba(255, 188, 82, 0.25); background: var(--color-warning-wash); }
|
||||
.parse-result.is-exception p { color: #e7bd78; }
|
||||
.toast-region { position: fixed; right: 20px; bottom: 20px; z-index: 200; display: grid; gap: 8px; }
|
||||
.toast { min-width: 280px; max-width: 390px; padding: 13px 15px; border: 1px solid var(--color-line-strong); border-radius: var(--radius-md); background: rgba(20, 26, 23, 0.96); color: var(--color-ink); box-shadow: var(--shadow-panel); backdrop-filter: blur(20px); }
|
||||
.toast { min-width: 280px; max-width: 390px; padding: 13px 15px; border: 1px solid var(--color-line-strong); border-radius: var(--radius-md); background: rgba(20, 20, 20, 0.96); color: var(--color-ink); box-shadow: var(--shadow-panel); backdrop-filter: blur(20px); }
|
||||
.toast strong, .toast small { display: block; }
|
||||
.toast small { color: var(--color-ink-muted); }
|
||||
|
||||
/* Login */
|
||||
.entry-page { overflow-x: hidden; }
|
||||
.entry-shell { min-height: 100vh; display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(430px, 0.85fr); }
|
||||
.entry-context { position: relative; min-height: 100vh; display: flex; flex-direction: column; justify-content: space-between; padding: clamp(32px, 5vw, 76px); border-right: 1px solid var(--color-line); background: #070a08; }
|
||||
.entry-context { position: relative; min-height: 100vh; display: flex; flex-direction: column; justify-content: space-between; padding: clamp(32px, 5vw, 76px); border-right: 1px solid var(--color-line); background: #080808; }
|
||||
.entry-context::after { content: ""; position: absolute; inset: 12% 8% 12% auto; width: 1px; background: rgba(55, 235, 137, 0.32); box-shadow: 0 0 38px rgba(55, 235, 137, 0.45); }
|
||||
.entry-brand { display: flex; align-items: center; gap: 12px; }
|
||||
.entry-brand > span:last-child { display: flex; flex-direction: column; }
|
||||
@@ -519,7 +501,7 @@ main { width: min(1560px, 100%); margin: 0 auto; padding: 32px 32px 58px; }
|
||||
.demo-badge { padding: 2px 6px; }
|
||||
.period-button { display: none; }
|
||||
.topbar-actions .button { width: 40px; padding: 0; font-size: 0; }
|
||||
main { padding: 22px 13px 42px; }
|
||||
main { padding: 12px 13px 42px; }
|
||||
.page-heading { min-height: auto; flex-direction: column; margin-bottom: 18px; }
|
||||
.page-heading h1 { font-size: 27px; }
|
||||
.page-heading > .button { align-self: stretch; }
|
||||
@@ -592,34 +574,152 @@ main { width: min(1560px, 100%); margin: 0 auto; padding: 32px 32px 58px; }
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.stack-deck { height: 168px; }
|
||||
.deck-card { width: 168px; min-height: 144px; padding: 14px; }
|
||||
.deck-stage .deck-card:nth-child(1), .deck-stage .deck-card:nth-child(6) { opacity: 0; pointer-events: none; }
|
||||
.deck-stage .deck-card:nth-child(1) { transform: translate(-260px, 14px) rotate(-22deg) scale(0.85); }
|
||||
.deck-stage .deck-card:nth-child(6) { transform: translate(260px, 14px) rotate(22deg) scale(0.85); }
|
||||
.deck-stage .deck-card:nth-child(2) { transform: translate(-160px, 6px) rotate(-12deg) scale(0.94); }
|
||||
.deck-stage .deck-card:nth-child(5) { transform: translate(160px, 6px) rotate(12deg) scale(0.94); }
|
||||
.timeline-strip { grid-template-columns: 1fr; }
|
||||
.timeline-legend { padding-bottom: 4px; }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.stack-deck { height: auto; padding: 0 4px 8px; }
|
||||
.deck-stage { position: static; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||||
.deck-stage .deck-card { position: relative; transform: none !important; opacity: 1 !important; width: auto; }
|
||||
.deck-card .deck-pos { right: 10px; }
|
||||
.company-row { grid-template-columns: 34px minmax(0, 1fr) auto; }
|
||||
.company-row-figure { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.stack-deck { height: auto; padding-bottom: 4px; }
|
||||
.deck-stage { grid-template-columns: 1fr; gap: 8px; }
|
||||
.deck-card { width: auto; min-height: 0; padding: 12px; }
|
||||
.timeline-strip { padding: 14px; }
|
||||
.timeline-rows { gap: 6px; }
|
||||
.timeline-row { grid-template-columns: 80px minmax(0, 1fr); }
|
||||
.timeline-axis span { font-size: 9px; }
|
||||
}
|
||||
|
||||
/* ===== 参考图对齐:胶囊搜索条 / 账期时间轴 / 右侧详情面板 ===== */
|
||||
.content-toolbar > .search-box { border-radius: 999px; background: rgba(255, 255, 255, 0.045); backdrop-filter: blur(18px) saturate(120%); }
|
||||
|
||||
.timeline-panel { margin-top: 18px; padding: 18px; }
|
||||
.timeline { padding: 10px 4px 0; }
|
||||
.timeline-bar { position: relative; height: 14px; border: 1px solid var(--color-line); border-radius: 999px; background: rgba(255, 255, 255, 0.05); }
|
||||
.timeline-seg, .timeline-gap { position: absolute; top: 2px; bottom: 2px; border-radius: 999px; }
|
||||
.timeline-seg.is-closed { background: linear-gradient(90deg, rgba(55, 235, 137, 0.5), rgba(55, 235, 137, 0.28)); }
|
||||
.timeline-seg.is-current { background: linear-gradient(90deg, rgba(255, 188, 82, 0.4), rgba(255, 188, 82, 0.18)); }
|
||||
.timeline-gap { background: repeating-linear-gradient(45deg, rgba(255, 98, 109, 0.75) 0 6px, rgba(255, 98, 109, 0.35) 6px 12px); box-shadow: 0 0 12px rgba(255, 98, 109, 0.25); }
|
||||
.timeline-today { position: absolute; top: -4px; bottom: -4px; width: 2px; background: var(--color-primary-strong); box-shadow: 0 0 10px rgba(55, 235, 137, 0.6); }
|
||||
.timeline-today::after { content: "今天"; position: absolute; top: -19px; right: 5px; font-size: 10px; font-style: normal; color: var(--color-primary-strong); white-space: nowrap; }
|
||||
.timeline-ticks { position: relative; height: 34px; margin-top: 8px; }
|
||||
.timeline-ticks span { position: absolute; display: flex; flex-direction: column; transform: translateX(-50%); font-size: 10px; color: var(--color-ink-muted); text-align: center; }
|
||||
.timeline-ticks span:first-child { transform: none; text-align: left; }
|
||||
.timeline-ticks span:last-child { transform: translateX(-100%); text-align: right; }
|
||||
.timeline-ticks small { color: var(--color-ink-soft); }
|
||||
.timeline-panel .timeline-legend { display: flex; flex-direction: row; flex-wrap: wrap; gap: 6px 18px; margin: 10px 0 0; padding: 0; list-style: none; font-size: 11px; color: var(--color-ink-soft); }
|
||||
.timeline-panel .timeline-legend li { display: flex; align-items: center; gap: 6px; }
|
||||
.timeline-panel .dot { width: 8px; height: 8px; border-radius: 50%; }
|
||||
.dot.success { background: var(--color-positive); }
|
||||
.dot.warning { background: var(--color-warning); }
|
||||
.dot.danger { background: var(--color-danger); }
|
||||
.dot.today { background: var(--color-primary-strong); box-shadow: 0 0 6px rgba(55, 235, 137, 0.7); }
|
||||
|
||||
.detail-drawer { position: fixed; top: 16px; right: 16px; bottom: 16px; z-index: 80; width: min(380px, calc(100vw - 32px)); display: flex; flex-direction: column; border: 1px solid var(--color-line-strong); border-radius: var(--radius-lg); background: linear-gradient(160deg, rgba(28, 28, 28, 0.92), rgba(12, 12, 12, 0.94)); box-shadow: var(--shadow-panel); backdrop-filter: blur(26px) saturate(125%); transform: translateX(calc(100% + 32px)); visibility: hidden; transition: transform var(--duration-standard) var(--ease-out), visibility var(--duration-standard); }
|
||||
.detail-drawer.is-open { transform: none; visibility: visible; }
|
||||
.detail-drawer > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 18px 18px 12px; border-bottom: 1px solid var(--color-line); }
|
||||
.detail-drawer h2 { margin-top: 8px; font-size: 17px; }
|
||||
.detail-drawer .detail-desc { margin-top: 4px; color: var(--color-ink-muted); font-size: 12px; }
|
||||
.detail-fields { display: grid; gap: 10px; margin: 0; padding: 14px 18px; overflow-y: auto; }
|
||||
.detail-fields > div { display: flex; justify-content: space-between; gap: 14px; padding-bottom: 10px; border-bottom: 1px dashed var(--color-line); }
|
||||
.detail-fields dt { flex-shrink: 0; color: var(--color-ink-muted); font-size: 12px; }
|
||||
.detail-fields dd { margin: 0; text-align: right; font-size: 12px; }
|
||||
.detail-tip { margin: 0 18px 12px; padding: 12px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: var(--color-surface-muted); color: var(--color-ink-soft); font-size: 12px; }
|
||||
.detail-tip strong { display: block; margin-bottom: 4px; color: var(--color-ink); }
|
||||
.detail-drawer > footer { display: flex; gap: 10px; margin-top: auto; padding: 14px 18px; border-top: 1px solid var(--color-line); }
|
||||
.detail-drawer > footer .button { flex: 1; justify-content: center; }
|
||||
.detail-drawer .status { align-self: flex-start; }
|
||||
[data-detail] { cursor: pointer; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.detail-drawer { top: 8px; right: 8px; bottom: 8px; width: calc(100vw - 16px); }
|
||||
.timeline-panel { padding: 14px; }
|
||||
}
|
||||
|
||||
/* ===== 质感打磨:玻璃光泽 / 氛围光 / 对比与呼吸感 ===== */
|
||||
:root {
|
||||
--radius-lg: 24px;
|
||||
--radius-xl: 30px;
|
||||
}
|
||||
|
||||
body::before { content: ""; position: fixed; inset: 0; z-index: 0; pointer-events: none; background: radial-gradient(52vw 42vh at 62% 32%, rgba(55, 235, 137, 0.055), transparent 62%), radial-gradient(40vw 34vh at 90% 4%, rgba(102, 168, 255, 0.032), transparent 60%), radial-gradient(46vw 40vh at 28% 96%, rgba(55, 235, 137, 0.028), transparent 60%); }
|
||||
.app-shell { position: relative; z-index: 0; }
|
||||
|
||||
.metric-card { border-color: rgba(255, 255, 255, 0.12); background: radial-gradient(120% 90% at 50% 108%, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.02) 42%, transparent 60%), linear-gradient(160deg, rgba(41, 41, 41, 0.86) 0%, rgba(23, 23, 23, 0.88) 50%, rgba(12, 12, 12, 0.9) 100%); }
|
||||
.metric-card::before { background: radial-gradient(130% 100% at 50% 112%, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.03) 45%, transparent 62%), linear-gradient(165deg, rgba(255, 255, 255, 0.14) 0%, rgba(255, 255, 255, 0.05) 42%, transparent 72%); }
|
||||
.panel::before, .period-ribbon::before, .reconcile-summary::before, .work-progress::before { background: radial-gradient(150% 110% at 50% 118%, rgba(255, 255, 255, 0.05), transparent 55%), linear-gradient(165deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.03) 40%, transparent 70%); }
|
||||
|
||||
.nav-item.is-active { border-color: rgba(255, 255, 255, 0.16); background: linear-gradient(160deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.06)); box-shadow: inset 0 1px rgba(255, 255, 255, 0.14), 0 10px 26px rgba(0, 0, 0, 0.32), 0 0 18px rgba(55, 235, 137, 0.08); }
|
||||
.metric-value { font-size: clamp(28px, 2.6vw, 38px); font-weight: 800; color: #ffffff; white-space: nowrap; }
|
||||
|
||||
.metric-grid { gap: 20px; margin-bottom: 20px; }
|
||||
.admin-dashboard-grid { gap: 20px; margin-bottom: 20px; }
|
||||
.company-dashboard-grid { gap: 20px; }
|
||||
.page-heading { margin-bottom: 22px; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.metric-grid { gap: 10px; margin-bottom: 14px; }
|
||||
.page-heading { margin-bottom: 18px; }
|
||||
}
|
||||
|
||||
/* ===== 反馈修正:侧栏质感 + 模块间距统一 ===== */
|
||||
.sidebar { border-color: rgba(255, 255, 255, 0.13); background: linear-gradient(170deg, rgba(26, 26, 26, 0.92), rgba(10, 10, 10, 0.94)); box-shadow: var(--shadow-panel), 0 0 44px rgba(55, 235, 137, 0.05); }
|
||||
.sidebar::before { content: ""; position: absolute; inset: 0; border-radius: inherit; background: radial-gradient(150% 110% at 50% 118%, rgba(255, 255, 255, 0.06), transparent 55%), linear-gradient(170deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.02) 45%, transparent 70%); pointer-events: none; }
|
||||
.nav-list { gap: 10px; }
|
||||
.nav-item { min-height: 48px; border-radius: 16px; }
|
||||
.user-block { border: 1px solid var(--color-line); border-radius: 16px; background: var(--color-surface-muted); }
|
||||
|
||||
.company-alert { margin-bottom: 20px; }
|
||||
.period-ribbon { margin-bottom: 20px; }
|
||||
.company-stack-panel { margin-bottom: 20px; }
|
||||
.work-progress { margin-top: 20px; }
|
||||
.timeline-panel { margin-top: 20px; }
|
||||
|
||||
/* ===== 反馈修正:整体提亮 + 列表改为玻璃行(去掉线条隔断) ===== */
|
||||
:root {
|
||||
--color-ink-soft: #b6c0ba;
|
||||
--color-ink-muted: #99a39d;
|
||||
}
|
||||
|
||||
.panel, .period-ribbon, .query-band, .filter-bar, .filter-grid, .pair-report, .work-progress, .reconcile-summary { border-color: rgba(255, 255, 255, 0.12); background: linear-gradient(160deg, rgba(37, 37, 37, 0.84) 0%, rgba(21, 21, 21, 0.87) 50%, rgba(12, 12, 12, 0.89) 100%); }
|
||||
.panel::before, .period-ribbon::before, .reconcile-summary::before, .work-progress::before { background: radial-gradient(150% 110% at 50% 118%, rgba(255, 255, 255, 0.06), transparent 55%), linear-gradient(165deg, rgba(255, 255, 255, 0.13) 0%, rgba(255, 255, 255, 0.04) 40%, transparent 70%); }
|
||||
|
||||
.task-list, .cashier-tasks, .coverage-list, .notification-list, .review-list { display: grid; gap: 8px; padding: 12px 14px 14px; }
|
||||
.task-list button, .cashier-tasks article, .coverage-list article, .notification-list article, .review-list > article, .subject-row { border: 1px solid var(--color-line); border-radius: var(--radius-md); background: linear-gradient(160deg, rgba(32, 32, 32, 0.72) 0%, rgba(15, 15, 15, 0.78) 100%); transition: background var(--duration-fast), border-color var(--duration-fast), box-shadow var(--duration-fast), transform var(--duration-fast); }
|
||||
.task-list button:hover, .cashier-tasks article:hover, .coverage-list article:hover, .notification-list article:hover, .review-list > article:hover, .subject-row:hover { border-color: rgba(255, 255, 255, 0.2); background: linear-gradient(160deg, rgba(44, 44, 44, 0.82) 0%, rgba(22, 22, 22, 0.86) 100%); box-shadow: 0 10px 26px rgba(0, 0, 0, 0.28); }
|
||||
.notification-list article.is-unread { border-color: rgba(102, 168, 255, 0.3); background: linear-gradient(160deg, rgba(102, 168, 255, 0.1), rgba(102, 168, 255, 0.04)); }
|
||||
.company-ledger { margin: 8px 14px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: linear-gradient(160deg, rgba(32, 32, 32, 0.72) 0%, rgba(15, 15, 15, 0.78) 100%); overflow: hidden; }
|
||||
.company-ledger[open] { border-color: rgba(255, 255, 255, 0.18); }
|
||||
.company-ledger:last-child { margin-bottom: 14px; }
|
||||
|
||||
.data-table { border-collapse: separate; border-spacing: 0 8px; }
|
||||
.data-table th { height: 28px; padding: 0 13px; border-bottom: 0; background: transparent; }
|
||||
.data-table td { border-bottom: 0; border-top: 1px solid var(--color-line); border-bottom: 1px solid var(--color-line); background: linear-gradient(160deg, rgba(32, 32, 32, 0.72) 0%, rgba(15, 15, 15, 0.78) 100%); }
|
||||
.data-table td:first-child { border-left: 1px solid var(--color-line); border-radius: 12px 0 0 12px; }
|
||||
.data-table td:last-child { border-right: 1px solid var(--color-line); border-radius: 0 12px 12px 0; }
|
||||
.data-table tbody tr:hover td { background: linear-gradient(160deg, rgba(44, 44, 44, 0.85) 0%, rgba(22, 22, 22, 0.88) 100%); }
|
||||
.data-table tbody tr:last-child td { border-bottom: 1px solid var(--color-line); }
|
||||
.table-scroll { padding: 2px 14px 8px; }
|
||||
.table-summary { margin: 0 14px; }
|
||||
|
||||
/* ===== 登录页重做:居中集团登录卡 ===== */
|
||||
.entry-shell { display: grid; grid-template-columns: 1fr; place-items: center; min-height: 100vh; padding: 48px 20px; }
|
||||
.entry-card { position: relative; width: min(470px, 100%); overflow: hidden; border: 1px solid rgba(255, 255, 255, 0.13); border-radius: var(--radius-xl); background: linear-gradient(165deg, rgba(36, 36, 36, 0.9) 0%, rgba(16, 16, 16, 0.92) 60%, rgba(10, 10, 10, 0.94) 100%); box-shadow: var(--shadow-panel), 0 0 60px rgba(55, 235, 137, 0.06); backdrop-filter: blur(28px) saturate(125%); }
|
||||
.entry-card::before { content: ""; position: absolute; inset: 0; background: radial-gradient(150% 110% at 50% 118%, rgba(255, 255, 255, 0.07), transparent 55%), linear-gradient(165deg, rgba(255, 255, 255, 0.12) 0%, rgba(255, 255, 255, 0.03) 42%, transparent 70%); pointer-events: none; }
|
||||
.entry-card::after { content: ""; position: absolute; top: 0; left: 18%; right: 18%; height: 2px; background: linear-gradient(90deg, transparent, rgba(55, 235, 137, 0.65), transparent); }
|
||||
.entry-brand { flex-direction: column; justify-content: center; gap: 15px; min-height: 0; padding: 36px 28px 24px; border-bottom: 1px solid var(--color-line); text-align: center; }
|
||||
.entry-brand .brand-mark { width: 54px; height: 54px; border-radius: 17px; font-size: 26px; }
|
||||
.entry-brand > span:last-child { align-items: center; }
|
||||
.entry-brand strong { font-size: 21px; letter-spacing: 0.03em; }
|
||||
.entry-brand small { margin-top: 4px; font-size: 12px; letter-spacing: 0.08em; }
|
||||
.entry-form { width: 100%; gap: 16px; padding: 24px 28px 10px; border: 0; background: transparent; box-shadow: none; backdrop-filter: none; }
|
||||
.entry-form header { text-align: center; }
|
||||
.entry-form header h2 { font-size: 22px; }
|
||||
.entry-form header p { margin-top: 4px; color: var(--color-ink-muted); font-size: 12px; }
|
||||
.entry-facts { max-width: none; gap: 10px; padding: 16px 28px 28px; }
|
||||
.entry-facts div { padding: 12px; text-align: center; }
|
||||
.entry-facts dd { margin-top: 2px; font-size: 12px; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user