Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
212b1f9b9b | ||
|
|
f17636183d | ||
|
|
951b353765 | ||
|
|
20d967a4a4 | ||
|
|
31fdc2e456 | ||
|
|
1d07812fa8 | ||
|
|
2fe06b74ae | ||
|
|
307f9ba76f | ||
|
|
020b556801 | ||
|
|
7938a274f2 | ||
|
|
dc0fb76f8f | ||
|
|
5a6a629597 | ||
|
|
5f215271af |
@@ -1,3 +1,5 @@
|
||||
⚠️ 本文档已过时,仅留档备查,请勿删除。当前代码状态请看 `README.md`、`项目需求.md`、`最新进度.md` 和 `任务清单.md`。
|
||||
|
||||
# 项目交接说明
|
||||
|
||||
更新时间:2026-08-06
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# 总账项目手册
|
||||
|
||||
最后核对:2026-08-23。核对依据是当前 `main` 最新代码(`5f21527`)、数据库迁移、接口、测试和近期提交记录。
|
||||
|
||||
这是接手本项目的第一站。阅读顺序如下:
|
||||
|
||||
1. [项目需求](项目需求.md):先知道系统要解决什么问题,以及哪些规则绝不能碰。
|
||||
2. [最新进度](最新进度.md):再确认哪些能力已经真实完成,哪些仍未完成。
|
||||
3. [任务清单](任务清单.md):最后挑选下一项工作,避免重复做已经完成的事。
|
||||
4. 需要技术细节时,再查看 `decisions/` 中的技术决定和 `issues/` 中保留的历史任务规格。
|
||||
|
||||
## 维护规矩
|
||||
|
||||
- 每新增、开始、完成、取消一项任务,负责的智能体都要在同一次提交里更新 `最新进度.md` 和 `任务清单.md`。
|
||||
- 新功能改变产品规则时,同时更新 `项目需求.md`;技术方案有变化时,补充 `decisions/` 的决定记录。
|
||||
- 写进手册的“已完成”必须能在代码、测试或已合并提交中找到依据;只有计划、页面演示或口头讨论的内容,一律写为“未完成”。
|
||||
- 不删除历史文档。旧内容与当前代码不一致时,在旧文件开头加“已过时,仅留档备查”提示,并在本目录写新版说明。
|
||||
|
||||
## 旧交接说明
|
||||
|
||||
`HANDOFF.md` 是 2026-08-06 的历史交接稿,已经不反映当前实现,保留仅作对照。当前事实以本手册三份正文为准。
|
||||
@@ -1,3 +1,5 @@
|
||||
⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`。
|
||||
|
||||
# [P0] 仓库银行样本数据分级与脱敏治理
|
||||
|
||||
## 背景
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`。
|
||||
|
||||
# [P0] 持久化层与不可变银行导入基础
|
||||
|
||||
## 背景
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`。
|
||||
|
||||
# [P0] 正式认证、RBAC 与公司级数据隔离
|
||||
|
||||
## 背景
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`。
|
||||
|
||||
# [P1] 动态公司、用户、账户和别名主数据
|
||||
|
||||
## 背景
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`。
|
||||
|
||||
# [P1] 银行导入 API 加固与回归测试
|
||||
|
||||
## 背景
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`。
|
||||
|
||||
# [P1] 规范转账事件、双边匹配与调拨排除
|
||||
|
||||
## 背景
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# 任务清单
|
||||
|
||||
最后核对:2026-08-23。状态以当前代码和已合并提交为准。
|
||||
|
||||
## 正在做
|
||||
|
||||
- 当前没有已确认正在进行的业务功能开发。下一项工作开始前,先在此处写明负责人、范围和验收标准。
|
||||
|
||||
## 已做完
|
||||
|
||||
- 六类银行流水模板识别与解析。
|
||||
- 数据库迁移、不可变原始证据、导入批次和重复导入处理。
|
||||
- 管理员/公司用户登录、密码策略、会话、限流、服务端公司隔离和审计。
|
||||
- 动态公司、用户、银行账户、别名与账户审核流程。
|
||||
- 导入接口加固、逐工作表确认、失败诊断和导出。
|
||||
- 规范转账事件、双边匹配、同公司调拨排除与个人过账映射。
|
||||
- 登录页视觉融合改版。
|
||||
|
||||
## 还没安排
|
||||
|
||||
- 公司间余额和四类往来科目的正式计算,已批准手工记录入账,以及从余额逐层查回原始流水。
|
||||
- 起算日、期初余额、流水覆盖断档检测和无业务校准。
|
||||
- 月结、重开、调整/冲销审批与审计报告。
|
||||
- 服务端流水查询、筛选、分页和可追溯导出。
|
||||
- 站内提醒与状态流转;外部通知只预留扩展位置,不默认启用。
|
||||
- 前端全面接入真实接口,移除模拟金额、静态业务记录和 `localStorage` 业务状态。
|
||||
- 测试环境之外的运行保障,包括 HTTPS、备份、监控和正式部署方案。
|
||||
|
||||
每完成或新增一项任务,必须在同一次提交里把它从本清单的相应栏目移走或补上,并同步更新 `最新进度.md`。
|
||||
@@ -0,0 +1,24 @@
|
||||
# 最新进度
|
||||
|
||||
最后核对:2026-08-23。以下“已完成”均以当前代码、数据库迁移、接口和自动化测试为依据,不把页面演示当作真实功能。
|
||||
|
||||
## 已经真实完成
|
||||
|
||||
- 支持中信、农行、工行、建行、河南农商行、郑州银行六类样本的 `.xls` / `.xlsx` 流水解析;能识别变动的表头位置和列顺序,并校验余额连续性。
|
||||
- 已有 SQLite 数据库和 5 次版本迁移。原始文件按内容哈希保存,导入批次、工作表、源行和异常都有记录;原始文件、工作表和源行被数据库规则保护,不能直接改或删。
|
||||
- 已实现管理员与公司用户登录、首次改密、会话失效、登录失败限流、服务端权限隔离和审计记录。
|
||||
- 已实现公司、用户、银行账户、别名等主数据管理;公司提交的银行账户需管理员审核后才能参与上传和识别。
|
||||
- 已实现导入、逐工作表确认或忽略、失败诊断、重复上传处理和 CSV 导出;未确认的工作表不进入后续处理。
|
||||
- 已实现规范转账事件和双边流水匹配。匹配决定保留历史,无法自动判断的记录进入人工审核;同公司调拨、外部流水和未锁定的单边记录不进入已确认的公司间往来。
|
||||
- 已有登录页、总账端和公司端页面,最近一次合并完成了登录页视觉改版。
|
||||
|
||||
## 仍未完成或不能当成已完成
|
||||
|
||||
- 公司间余额与会计科目的正式计算、已批准手工记录入账和逐层余额追溯尚未完成。
|
||||
- 全局起算日、期初余额、流水覆盖断档、无业务校准、月结、重开和调整审批尚未完成。
|
||||
- 提醒、完整的服务端查询导出,以及前端彻底移除演示数据和浏览器本地业务状态尚未完成。
|
||||
- 生产部署所需的 HTTPS、反向代理、备份、监控和正式运行保障尚未完成;本项目当前只允许测试环境部署。
|
||||
|
||||
## 最近验证
|
||||
|
||||
2026-08-23 已运行完整 Python 自动化测试:解析、持久化、认证与权限、导入接口、主数据和双边匹配相关测试均通过。后续修改功能时,必须再次运行完整测试并在本文件记录结果。
|
||||
@@ -0,0 +1,26 @@
|
||||
# 项目需求
|
||||
|
||||
## 这个系统做什么
|
||||
|
||||
这是河南金牛实业集团有限公司内部各公司之间的资金往来管理系统。它把各公司的银行流水集中起来,识别同一笔公司间转账的两边记录,形成可以一路查回原始银行流水的往来结果。
|
||||
|
||||
系统有两个独立入口:
|
||||
|
||||
- 总账管理端:管理公司、公司账号、银行账户、导入记录、异常和公司间转账匹配。
|
||||
- 公司出纳端:只处理本公司的账户登记、流水上传、工作表确认和本公司相关异常。
|
||||
|
||||
## 必须做到的业务规则
|
||||
|
||||
- 银行原始文件和原始流水是证据,导入后不能直接修改或删除;修正必须通过可追溯的审核决定、冲销或调整完成。
|
||||
- 一笔公司间转账的收、付两边只能算作一个经济事件,不能因为有两条银行流水就重复计算。
|
||||
- 同一家公司不同银行账户之间调拨,不计入公司间往来。
|
||||
- 账号、金额、方向、日期和余额等可核对信息优先于户名;信息不完整或有歧义时进入异常审核,不能猜。
|
||||
- 已确认金额和待确认金额必须分开;所有余额都要说明统计截止日期和未决金额。
|
||||
- 公司用户只能查看、上传和导出本公司的数据。这个限制由服务端执行,页面上“看不见”不等于有权限限制。
|
||||
- 起算日前的流水必须保留,但不参与期内计算;期初余额、流水覆盖断档、无业务校准、月结和调整都要留痕。
|
||||
|
||||
## 当前技术边界
|
||||
|
||||
项目使用 Python 标准库服务、SQLite 数据库和浏览器页面。银行模板支持 `.xls` 与 `.xlsx`,按表头识别而非依赖固定行列。金额使用精确十进制,真实运行数据放在 `data/`,不进入代码仓库。
|
||||
|
||||
系统目前仍是测试环境项目,不能当作已完成生产部署:正式上线前仍需补齐核算、期初与断档、月结、备份、监控和 HTTPS 等能力。
|
||||
@@ -6,11 +6,14 @@ import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from http.cookies import SimpleCookie
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from bank_importer import auth, importing, master_data, matching, multipart, personal_transit
|
||||
from bank_importer import auth, importing, master_data, matching, multipart, personal_transit, reminders
|
||||
from bank_importer.db import connect, migrate, utc_now
|
||||
|
||||
|
||||
@@ -100,6 +103,26 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
if company_event_match:
|
||||
self._handle_company_transfer_event_detail(int(company_event_match.group(1)))
|
||||
return
|
||||
if path == "/api/admin/reminders/pending":
|
||||
self._handle_admin_reminders_pending()
|
||||
return
|
||||
if path == "/api/admin/reminders":
|
||||
self._handle_admin_reminders(query)
|
||||
return
|
||||
if path == "/api/admin/reminder-settings":
|
||||
self._handle_admin_reminder_settings_get()
|
||||
return
|
||||
admin_reminder_match = re.fullmatch(r"/api/admin/reminders/(\d+)", path)
|
||||
if admin_reminder_match:
|
||||
self._handle_admin_reminder_detail(int(admin_reminder_match.group(1)))
|
||||
return
|
||||
if path == "/api/company/reminders":
|
||||
self._handle_company_reminders()
|
||||
return
|
||||
company_reminder_match = re.fullmatch(r"/api/company/reminders/(\d+)", path)
|
||||
if company_reminder_match:
|
||||
self._handle_company_reminder_detail(int(company_reminder_match.group(1)))
|
||||
return
|
||||
|
||||
if path == "/admin.html" and not self._guard_page("admin"):
|
||||
return
|
||||
@@ -167,6 +190,30 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
if mapping_review:
|
||||
self._handle_admin_review_personal_mapping(int(mapping_review.group(1)))
|
||||
return
|
||||
if path == "/api/admin/reminders/scan":
|
||||
self._handle_admin_reminders_scan()
|
||||
return
|
||||
if path == "/api/admin/reminders/send":
|
||||
self._handle_admin_reminders_send()
|
||||
return
|
||||
if path == "/api/admin/reminders/manual":
|
||||
self._handle_admin_reminders_manual()
|
||||
return
|
||||
admin_resend_match = re.fullmatch(r"/api/admin/reminders/(\d+)/resend", path)
|
||||
if admin_resend_match:
|
||||
self._handle_admin_reminder_resend(int(admin_resend_match.group(1)))
|
||||
return
|
||||
if path == "/api/admin/reminder-settings":
|
||||
self._handle_admin_reminder_settings_put()
|
||||
return
|
||||
company_status_match = re.fullmatch(
|
||||
r"/api/company/reminders/(\d+)/(acknowledge|resolve)", path
|
||||
)
|
||||
if company_status_match:
|
||||
self._handle_company_reminder_status(
|
||||
int(company_status_match.group(1)), company_status_match.group(2)
|
||||
)
|
||||
return
|
||||
self._send_json(404, {"status": "error", "message": "接口不存在。"})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -1996,6 +2043,260 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
return None
|
||||
return data
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Reminders
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _handle_admin_reminders_pending(self) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user = self._require_admin(connection)
|
||||
if user is None:
|
||||
return
|
||||
findings = reminders.scan_findings(connection)
|
||||
items = [reminders.finding_to_dict(item) for item in findings]
|
||||
companies = len({item["company_id"] for item in items})
|
||||
self._send_json(
|
||||
200,
|
||||
{
|
||||
"status": "ok",
|
||||
"findings": items,
|
||||
"summary": {"companies": companies, "items": len(items)},
|
||||
},
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_admin_reminders_scan(self) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user = self._require_admin(connection)
|
||||
if user is None:
|
||||
return
|
||||
result = reminders.run_scan(connection, actor=user, ip=self._client_ip)
|
||||
items = [reminders.finding_to_dict(item) for item in result["findings"]]
|
||||
companies = len({item["company_id"] for item in items})
|
||||
self._send_json(
|
||||
200,
|
||||
{
|
||||
"status": "ok",
|
||||
"findings": items,
|
||||
"counts": result["counts"],
|
||||
"summary": {"companies": companies, "items": len(items)},
|
||||
},
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_admin_reminders_send(self) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user = self._require_admin(connection)
|
||||
if user is None:
|
||||
return
|
||||
data = self._read_json_body()
|
||||
if data is None:
|
||||
return
|
||||
raw_keys = data.get("dedupe_keys")
|
||||
if not isinstance(raw_keys, list) or not raw_keys:
|
||||
self._send_json(400, {"status": "error", "message": "请指定 dedupe_keys。"})
|
||||
return
|
||||
dedupe_keys = [str(key) for key in raw_keys]
|
||||
sent = reminders.deliver_many(
|
||||
connection, dedupe_keys, actor=user, ip=self._client_ip
|
||||
)
|
||||
self._send_json(200, {"status": "ok", "sent": sent, "count": len(sent)})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_admin_reminders_manual(self) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user = self._require_admin(connection)
|
||||
if user is None:
|
||||
return
|
||||
data = self._read_json_body()
|
||||
if data is None:
|
||||
return
|
||||
try:
|
||||
company_id = int(data.get("company_id"))
|
||||
except (TypeError, ValueError):
|
||||
self._send_json(400, {"status": "error", "message": "company_id 无效。"})
|
||||
return
|
||||
display_type = str(data.get("display_type") or data.get("type") or "").strip()
|
||||
content = str(data.get("content") or "").strip()
|
||||
deadline = str(data.get("deadline") or "").strip() or None
|
||||
if not display_type or not content:
|
||||
self._send_json(400, {"status": "error", "message": "类型与内容不能为空。"})
|
||||
return
|
||||
try:
|
||||
reminder_id = reminders.send_manual(
|
||||
connection,
|
||||
company_id=company_id,
|
||||
display_type=display_type,
|
||||
content=content,
|
||||
deadline=deadline,
|
||||
actor=user,
|
||||
ip=self._client_ip,
|
||||
)
|
||||
except ValueError as exc:
|
||||
self._send_json(400, {"status": "error", "message": str(exc)})
|
||||
return
|
||||
self._send_json(200, {"status": "ok", "reminder_id": reminder_id})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_admin_reminder_resend(self, reminder_id: int) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user = self._require_admin(connection)
|
||||
if user is None:
|
||||
return
|
||||
try:
|
||||
reminders.resend_reminder(
|
||||
connection, reminder_id, actor=user, ip=self._client_ip
|
||||
)
|
||||
except ValueError as exc:
|
||||
self._send_json(400, {"status": "error", "message": str(exc)})
|
||||
return
|
||||
self._send_json(200, {"status": "ok"})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_admin_reminders(self, query: dict[str, list[str]]) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user = self._require_admin(connection)
|
||||
if user is None:
|
||||
return
|
||||
source = (query.get("source") or [None])[0]
|
||||
if source not in {None, "auto", "manual", "system"}:
|
||||
self._send_json(400, {"status": "error", "message": "source 参数无效。"})
|
||||
return
|
||||
filter_source = "auto" if source == "system" else source
|
||||
items = reminders.list_admin_reminders(connection, source=filter_source)
|
||||
stats = {"unread": 0, "doing": 0, "done": 0}
|
||||
for item in items:
|
||||
stats[item["status_ui"]] += 1
|
||||
self._send_json(200, {"status": "ok", "reminders": items, "stats": stats})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_admin_reminder_detail(self, reminder_id: int) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user = self._require_admin(connection)
|
||||
if user is None:
|
||||
return
|
||||
detail = reminders.get_reminder_detail(connection, reminder_id)
|
||||
if detail is None:
|
||||
self._send_json(404, {"status": "error", "message": "提醒不存在。"})
|
||||
return
|
||||
self._send_json(200, {"status": "ok", "reminder": detail})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_admin_reminder_settings_get(self) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user = self._require_admin(connection)
|
||||
if user is None:
|
||||
return
|
||||
self._send_json(200, {"status": "ok", "settings": reminders.get_settings(connection)})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_admin_reminder_settings_put(self) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user = self._require_admin(connection)
|
||||
if user is None:
|
||||
return
|
||||
data = self._read_json_body()
|
||||
if data is None:
|
||||
return
|
||||
updates = data.get("settings") if isinstance(data.get("settings"), dict) else data
|
||||
if not isinstance(updates, dict):
|
||||
self._send_json(400, {"status": "error", "message": "settings 格式无效。"})
|
||||
return
|
||||
try:
|
||||
settings = reminders.update_settings(connection, updates)
|
||||
except ValueError as exc:
|
||||
self._send_json(400, {"status": "error", "message": str(exc)})
|
||||
return
|
||||
auth.audit(
|
||||
connection,
|
||||
"reminder_settings_update",
|
||||
actor=user,
|
||||
detail=json.dumps(settings, ensure_ascii=False),
|
||||
ip=self._client_ip,
|
||||
)
|
||||
self._send_json(200, {"status": "ok", "settings": settings})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_company_reminders(self) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user = self._require_user(connection)
|
||||
if user is None:
|
||||
return
|
||||
if user["role"] != "company":
|
||||
self._send_json(403, {"status": "error", "message": "该操作仅限公司端。"})
|
||||
return
|
||||
items = reminders.list_company_reminders(connection, int(user["company_id"]))
|
||||
unread = reminders.company_unread_count(connection, int(user["company_id"]))
|
||||
stats = {"unread": 0, "doing": 0, "done": 0}
|
||||
for item in items:
|
||||
stats[item["status_ui"]] += 1
|
||||
self._send_json(
|
||||
200,
|
||||
{"status": "ok", "reminders": items, "unread_count": unread, "stats": stats},
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_company_reminder_detail(self, reminder_id: int) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user = self._require_user(connection)
|
||||
if user is None:
|
||||
return
|
||||
if user["role"] != "company":
|
||||
self._send_json(403, {"status": "error", "message": "该操作仅限公司端。"})
|
||||
return
|
||||
detail = reminders.get_reminder_detail(connection, reminder_id)
|
||||
if detail is None or int(detail["company_id"]) != int(user["company_id"]):
|
||||
self._send_json(404, {"status": "error", "message": "提醒不存在。"})
|
||||
return
|
||||
self._send_json(200, {"status": "ok", "reminder": detail})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_company_reminder_status(self, reminder_id: int, action: str) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user = self._require_user(connection)
|
||||
if user is None:
|
||||
return
|
||||
if user["role"] != "company":
|
||||
self._send_json(403, {"status": "error", "message": "该操作仅限公司端。"})
|
||||
return
|
||||
new_status = "acknowledged" if action == "acknowledge" else "resolved"
|
||||
ok = reminders.update_reminder_status(
|
||||
connection,
|
||||
reminder_id,
|
||||
new_status,
|
||||
company_id=int(user["company_id"]),
|
||||
actor=user,
|
||||
)
|
||||
if not ok:
|
||||
self._send_json(404, {"status": "error", "message": "提醒不存在。"})
|
||||
return
|
||||
self._send_json(200, {"status": "ok"})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
@staticmethod
|
||||
def _sheet_payload(row) -> dict[str, object]:
|
||||
"""Serialize one persisted per-sheet review record."""
|
||||
@@ -2068,6 +2369,44 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
self.wfile.write(content)
|
||||
|
||||
|
||||
def _parse_scan_time(value: str) -> tuple[int, int]:
|
||||
parts = value.strip().split(":")
|
||||
if len(parts) != 2:
|
||||
return 8, 0
|
||||
try:
|
||||
return int(parts[0]), int(parts[1])
|
||||
except ValueError:
|
||||
return 8, 0
|
||||
|
||||
|
||||
def _seconds_until_scan(scan_time: str) -> float:
|
||||
hour, minute = _parse_scan_time(scan_time)
|
||||
now = datetime.now(timezone.utc)
|
||||
target = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
||||
if target <= now:
|
||||
target += timedelta(days=1)
|
||||
return (target - now).total_seconds()
|
||||
|
||||
|
||||
def _scheduled_reminder_scan_loop() -> None:
|
||||
"""Daily scan thread; audit-only, does not auto-deliver to companies."""
|
||||
while True:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
settings = reminders.get_settings(connection)
|
||||
scan_time = settings.get("scan_time", "08:00")
|
||||
finally:
|
||||
connection.close()
|
||||
time.sleep(max(1.0, _seconds_until_scan(scan_time)))
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
reminders.run_scan(connection, actor=None, ip=None)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def ensure_bootstrap_admin(connection) -> str | None:
|
||||
"""Create the first admin when none exists; returns the generated password."""
|
||||
existing = connection.execute(
|
||||
@@ -2096,6 +2435,8 @@ def main() -> None:
|
||||
# Printed once to stdout; never written to any log file.
|
||||
print(f"Bootstrap admin initial password (shown once): {initial_password}")
|
||||
server = ThreadingHTTPServer((host, port), AppHandler)
|
||||
scan_thread = threading.Thread(target=_scheduled_reminder_scan_loop, daemon=True)
|
||||
scan_thread.start()
|
||||
print(f"Serving on http://{host}:{port}")
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
@@ -569,6 +569,78 @@ MIGRATIONS: tuple[Migration, ...] = (
|
||||
ALTER TABLE import_batches DROP COLUMN upload_bank_account_id;
|
||||
""",
|
||||
),
|
||||
Migration(
|
||||
version=6,
|
||||
name="0006_reminders",
|
||||
up="""
|
||||
CREATE TABLE reminder_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO reminder_settings (key, value, updated_at) VALUES
|
||||
('monthly_start_day', '5', datetime('now')),
|
||||
('gap_days', '5', datetime('now')),
|
||||
('scan_time', '08:00', datetime('now'));
|
||||
|
||||
CREATE TABLE reminders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
company_id INTEGER NOT NULL REFERENCES companies (id),
|
||||
rule_key TEXT NOT NULL CHECK (rule_key IN (
|
||||
'unsubmitted', 'gap', 'pending_review', 'manual'
|
||||
)),
|
||||
dedupe_key TEXT NOT NULL UNIQUE,
|
||||
rule_params TEXT NOT NULL DEFAULT '{}',
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
deadline TEXT,
|
||||
source TEXT NOT NULL CHECK (source IN ('auto', 'manual')),
|
||||
status TEXT NOT NULL DEFAULT 'open'
|
||||
CHECK (status IN ('open', 'acknowledged', 'resolved')),
|
||||
send_count INTEGER NOT NULL DEFAULT 0,
|
||||
first_sent_at TEXT,
|
||||
last_sent_at TEXT,
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
action_link TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX idx_reminders_company ON reminders (company_id);
|
||||
CREATE INDEX idx_reminders_status ON reminders (status);
|
||||
|
||||
CREATE TABLE reminder_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
reminder_id INTEGER NOT NULL REFERENCES reminders (id),
|
||||
event_type TEXT NOT NULL CHECK (event_type IN (
|
||||
'sent', 'acknowledged', 'resolved', 'escalated'
|
||||
)),
|
||||
actor TEXT NOT NULL,
|
||||
detail TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_reminder_events_reminder ON reminder_events (reminder_id);
|
||||
|
||||
CREATE TRIGGER reminder_events_no_update BEFORE UPDATE ON reminder_events
|
||||
BEGIN SELECT RAISE (ABORT, 'reminder_events rows are append-only'); END;
|
||||
CREATE TRIGGER reminder_events_no_delete BEFORE DELETE ON reminder_events
|
||||
BEGIN SELECT RAISE (ABORT, 'reminder_events rows are append-only'); END;
|
||||
CREATE TRIGGER reminders_no_delete BEFORE DELETE ON reminders
|
||||
BEGIN SELECT RAISE (ABORT, 'reminders rows are immutable history'); END;
|
||||
""",
|
||||
down="""
|
||||
DROP TRIGGER IF EXISTS reminders_no_delete;
|
||||
DROP TRIGGER IF EXISTS reminder_events_no_delete;
|
||||
DROP TRIGGER IF EXISTS reminder_events_no_update;
|
||||
DROP INDEX IF EXISTS idx_reminder_events_reminder;
|
||||
DROP TABLE IF EXISTS reminder_events;
|
||||
DROP INDEX IF EXISTS idx_reminders_status;
|
||||
DROP INDEX IF EXISTS idx_reminders_company;
|
||||
DROP TABLE IF EXISTS reminders;
|
||||
DROP TABLE IF EXISTS reminder_settings;
|
||||
""",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,757 @@
|
||||
"""Read-only reminder rule engine and delivery helpers.
|
||||
|
||||
Scans existing tables to surface pending items; delivery writes append-only
|
||||
``reminders`` / ``reminder_events`` rows without touching bank or business data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
import json
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
import uuid
|
||||
|
||||
from bank_importer import auth
|
||||
from bank_importer.db import utc_now
|
||||
|
||||
RULE_UNSUBMITTED = "unsubmitted"
|
||||
RULE_GAP = "gap"
|
||||
RULE_PENDING = "pending_review"
|
||||
RULE_MANUAL = "manual"
|
||||
|
||||
SETTING_MONTHLY_START_DAY = "monthly_start_day"
|
||||
SETTING_GAP_DAYS = "gap_days"
|
||||
SETTING_SCAN_TIME = "scan_time"
|
||||
|
||||
DEFAULT_SETTINGS: dict[str, str] = {
|
||||
SETTING_MONTHLY_START_DAY: "5",
|
||||
SETTING_GAP_DAYS: "5",
|
||||
SETTING_SCAN_TIME: "08:00",
|
||||
}
|
||||
|
||||
RULE_LABELS = {
|
||||
RULE_UNSUBMITTED: "流水未提交",
|
||||
RULE_GAP: "流水断档",
|
||||
RULE_PENDING: "待确认/待审核",
|
||||
}
|
||||
|
||||
ACTION_LINKS = {
|
||||
RULE_UNSUBMITTED: "upload",
|
||||
RULE_GAP: "flows",
|
||||
RULE_PENDING: "reconcile",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScanFinding:
|
||||
company_id: int
|
||||
company_name: str
|
||||
rule_key: str
|
||||
dedupe_key: str
|
||||
rule_params: dict[str, Any]
|
||||
title: str
|
||||
content: str
|
||||
reason: str
|
||||
days_open: int
|
||||
deadline: str | None
|
||||
action_link: str
|
||||
existing_reminder_id: int | None
|
||||
send_count: int
|
||||
|
||||
|
||||
def _today() -> date:
|
||||
return datetime.now(timezone.utc).date()
|
||||
|
||||
|
||||
def get_settings(connection: sqlite3.Connection) -> dict[str, str]:
|
||||
rows = connection.execute("SELECT key, value FROM reminder_settings").fetchall()
|
||||
settings = dict(DEFAULT_SETTINGS)
|
||||
for row in rows:
|
||||
settings[row["key"]] = row["value"]
|
||||
return settings
|
||||
|
||||
|
||||
def _valid_scan_time(value: str) -> bool:
|
||||
parts = value.strip().split(":")
|
||||
if len(parts) != 2 or not parts[0].isdigit() or not parts[1].isdigit():
|
||||
return False
|
||||
if len(parts[1]) != 2:
|
||||
return False
|
||||
hour, minute = int(parts[0]), int(parts[1])
|
||||
return 0 <= hour <= 23 and 0 <= minute <= 59
|
||||
|
||||
|
||||
def validate_setting(key: str, value: str) -> str:
|
||||
raw = str(value).strip()
|
||||
if key == SETTING_MONTHLY_START_DAY:
|
||||
try:
|
||||
day = int(raw)
|
||||
except ValueError as exc:
|
||||
raise ValueError("每月起始日须为 1 到 28 的整数。") from exc
|
||||
if day < 1 or day > 28:
|
||||
raise ValueError("每月起始日须为 1 到 28 的整数。")
|
||||
return str(day)
|
||||
if key == SETTING_GAP_DAYS:
|
||||
try:
|
||||
days = int(raw)
|
||||
except ValueError as exc:
|
||||
raise ValueError("断档天数须为大于等于 1 的整数。") from exc
|
||||
if days < 1:
|
||||
raise ValueError("断档天数须为大于等于 1 的整数。")
|
||||
return str(days)
|
||||
if key == SETTING_SCAN_TIME:
|
||||
if not _valid_scan_time(raw):
|
||||
raise ValueError("扫描时间须为合法的 HH:MM。")
|
||||
hour, minute = raw.split(":")
|
||||
return f"{int(hour):02d}:{minute}"
|
||||
raise ValueError(f"unknown setting: {key}")
|
||||
|
||||
|
||||
def update_settings(connection: sqlite3.Connection, updates: dict[str, str]) -> dict[str, str]:
|
||||
allowed = set(DEFAULT_SETTINGS)
|
||||
cleaned: dict[str, str] = {}
|
||||
for key, value in updates.items():
|
||||
if key not in allowed:
|
||||
raise ValueError(f"unknown setting: {key}")
|
||||
cleaned[key] = validate_setting(key, value)
|
||||
now = utc_now()
|
||||
with connection:
|
||||
for key, value in cleaned.items():
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO reminder_settings (key, value, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
||||
""",
|
||||
(key, value, now),
|
||||
)
|
||||
return get_settings(connection)
|
||||
|
||||
|
||||
def _setting_int(settings: dict[str, str], key: str) -> int:
|
||||
try:
|
||||
return int(validate_setting(key, settings.get(key, DEFAULT_SETTINGS[key])))
|
||||
except (TypeError, ValueError):
|
||||
return int(DEFAULT_SETTINGS[key])
|
||||
|
||||
|
||||
def _month_period(day: date | None = None) -> str:
|
||||
ref = day or _today()
|
||||
return f"{ref.year:04d}-{ref.month:02d}"
|
||||
|
||||
|
||||
def _parse_date(value: str | None) -> date | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return date.fromisoformat(value[:10])
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _days_between(start: date, end: date) -> int:
|
||||
return max(0, (end - start).days)
|
||||
|
||||
|
||||
def _duration_pill_class(days: int) -> str:
|
||||
if days >= 7:
|
||||
return "pill-danger"
|
||||
if days >= 3:
|
||||
return "pill-warn"
|
||||
return "pill-muted"
|
||||
|
||||
|
||||
def _existing_reminder(connection: sqlite3.Connection, dedupe_key: str) -> sqlite3.Row | None:
|
||||
return connection.execute(
|
||||
"""
|
||||
SELECT id, send_count, status, last_sent_at
|
||||
FROM reminders
|
||||
WHERE dedupe_key = ? AND status != 'resolved'
|
||||
""",
|
||||
(dedupe_key,),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def _company_has_month_batch(connection: sqlite3.Connection, company_id: int, period: str) -> bool:
|
||||
year, month = period.split("-")
|
||||
prefix = f"{year}-{month}"
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM import_batches b
|
||||
JOIN sheet_batches s ON s.import_batch_id = b.id
|
||||
WHERE b.company_id = ?
|
||||
AND (
|
||||
substr(s.period_start, 1, 7) = ?
|
||||
OR substr(s.period_end, 1, 7) = ?
|
||||
OR (s.period_start <= ? || '-31' AND s.period_end >= ? || '-01')
|
||||
)
|
||||
LIMIT 1
|
||||
""",
|
||||
(company_id, prefix, prefix, prefix, prefix),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def _latest_batch_end(connection: sqlite3.Connection, company_id: int) -> date | None:
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT MAX(s.period_end) AS latest_end
|
||||
FROM import_batches b
|
||||
JOIN sheet_batches s ON s.import_batch_id = b.id
|
||||
WHERE b.company_id = ?
|
||||
""",
|
||||
(company_id,),
|
||||
).fetchone()
|
||||
return _parse_date(row["latest_end"] if row else None)
|
||||
|
||||
|
||||
def _pending_review_count(connection: sqlite3.Connection, company_id: int) -> int:
|
||||
match_count = connection.execute(
|
||||
"""
|
||||
SELECT COUNT(DISTINCT e.id)
|
||||
FROM canonical_transfer_events e
|
||||
JOIN current_transfer_decisions c ON c.event_id = e.id
|
||||
JOIN transfer_match_decisions d ON d.id = c.decision_id
|
||||
JOIN transfer_decision_participants payer ON payer.decision_id = d.id AND payer.role = 'payer'
|
||||
JOIN transfer_decision_participants payee ON payee.decision_id = d.id AND payee.role = 'payee'
|
||||
WHERE e.lifecycle = 'active'
|
||||
AND d.classification IN ('unresolved', 'needs_review')
|
||||
AND (payer.company_id = ? OR payee.company_id = ?)
|
||||
""",
|
||||
(company_id, company_id),
|
||||
).fetchone()[0]
|
||||
sheet_count = connection.execute(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM sheet_reviews r
|
||||
JOIN import_batches b ON b.id = r.import_batch_id
|
||||
WHERE b.company_id = ?
|
||||
AND r.outcome = 'parsed'
|
||||
AND r.review_status = 'pending'
|
||||
""",
|
||||
(company_id,),
|
||||
).fetchone()[0]
|
||||
account_count = connection.execute(
|
||||
"""
|
||||
SELECT COUNT(*) FROM bank_accounts
|
||||
WHERE company_id = ? AND status = 'pending'
|
||||
""",
|
||||
(company_id,),
|
||||
).fetchone()[0]
|
||||
return int(match_count) + int(sheet_count) + int(account_count)
|
||||
|
||||
|
||||
def _active_companies(connection: sqlite3.Connection) -> list[sqlite3.Row]:
|
||||
return connection.execute(
|
||||
"""
|
||||
SELECT id, name FROM companies
|
||||
WHERE COALESCE(status, 'active') != 'disabled'
|
||||
ORDER BY name
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
|
||||
def scan_findings(connection: sqlite3.Connection) -> list[ScanFinding]:
|
||||
settings = get_settings(connection)
|
||||
today = _today()
|
||||
period = _month_period(today)
|
||||
monthly_start = _setting_int(settings, SETTING_MONTHLY_START_DAY)
|
||||
gap_days = _setting_int(settings, SETTING_GAP_DAYS)
|
||||
findings: list[ScanFinding] = []
|
||||
|
||||
for company in _active_companies(connection):
|
||||
company_id = int(company["id"])
|
||||
company_name = company["name"]
|
||||
|
||||
if today.day >= monthly_start and not _company_has_month_batch(connection, company_id, period):
|
||||
dedupe_key = f"{company_id}:{RULE_UNSUBMITTED}:{period}"
|
||||
days_open = _days_between(date(today.year, today.month, monthly_start), today)
|
||||
existing = _existing_reminder(connection, dedupe_key)
|
||||
findings.append(
|
||||
ScanFinding(
|
||||
company_id=company_id,
|
||||
company_name=company_name,
|
||||
rule_key=RULE_UNSUBMITTED,
|
||||
dedupe_key=dedupe_key,
|
||||
rule_params={"period": period},
|
||||
title=f"{company_name} · {today.month} 月流水未提交",
|
||||
content=(
|
||||
f"贵公司 {period} 银行流水尚未提交。"
|
||||
f"请于截止日前完成全部账户流水上传。"
|
||||
),
|
||||
reason=f"应交 {today.month:02d}-{monthly_start:02d} · 已逾期 {days_open} 天",
|
||||
days_open=days_open,
|
||||
deadline=None,
|
||||
action_link=ACTION_LINKS[RULE_UNSUBMITTED],
|
||||
existing_reminder_id=int(existing["id"]) if existing else None,
|
||||
send_count=int(existing["send_count"]) if existing else 0,
|
||||
)
|
||||
)
|
||||
|
||||
latest_end = _latest_batch_end(connection, company_id)
|
||||
if latest_end is not None:
|
||||
gap = _days_between(latest_end, today)
|
||||
if gap > gap_days:
|
||||
dedupe_key = f"{company_id}:{RULE_GAP}:{latest_end.isoformat()}"
|
||||
existing = _existing_reminder(connection, dedupe_key)
|
||||
findings.append(
|
||||
ScanFinding(
|
||||
company_id=company_id,
|
||||
company_name=company_name,
|
||||
rule_key=RULE_GAP,
|
||||
dedupe_key=dedupe_key,
|
||||
rule_params={"latest_end": latest_end.isoformat(), "gap_days": gap},
|
||||
title=f"{company_name} · 流水断档 {gap} 天",
|
||||
content=(
|
||||
f"最近流水截止日为 {latest_end.isoformat()},"
|
||||
f"已连续 {gap} 天无新数据,请补传断档期间银行流水。"
|
||||
),
|
||||
reason=f"最近截止 {latest_end.strftime('%m-%d')} · 断档 {gap} 天",
|
||||
days_open=gap,
|
||||
deadline=None,
|
||||
action_link=ACTION_LINKS[RULE_GAP],
|
||||
existing_reminder_id=int(existing["id"]) if existing else None,
|
||||
send_count=int(existing["send_count"]) if existing else 0,
|
||||
)
|
||||
)
|
||||
|
||||
pending_count = _pending_review_count(connection, company_id)
|
||||
if pending_count > 0:
|
||||
dedupe_key = f"{company_id}:{RULE_PENDING}:active"
|
||||
existing = _existing_reminder(connection, dedupe_key)
|
||||
first_seen = _parse_date(existing["last_sent_at"][:10] if existing and existing["last_sent_at"] else None)
|
||||
days_open = _days_between(first_seen, today) if first_seen else 0
|
||||
findings.append(
|
||||
ScanFinding(
|
||||
company_id=company_id,
|
||||
company_name=company_name,
|
||||
rule_key=RULE_PENDING,
|
||||
dedupe_key=dedupe_key,
|
||||
rule_params={"pending_count": pending_count},
|
||||
title=f"{company_name} · {pending_count} 项待确认/待审核",
|
||||
content=(
|
||||
f"贵公司当前有 {pending_count} 项往来确认、流水审核或账户登记待处理,"
|
||||
f"请尽快完成确认以免影响结账。"
|
||||
),
|
||||
reason=f"待处理 {pending_count} 项",
|
||||
days_open=days_open,
|
||||
deadline=None,
|
||||
action_link=ACTION_LINKS[RULE_PENDING],
|
||||
existing_reminder_id=int(existing["id"]) if existing else None,
|
||||
send_count=int(existing["send_count"]) if existing else 0,
|
||||
)
|
||||
)
|
||||
|
||||
findings.sort(key=lambda item: item.days_open, reverse=True)
|
||||
return findings
|
||||
|
||||
|
||||
def run_scan(
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
actor: sqlite3.Row | None = None,
|
||||
ip: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
findings = scan_findings(connection)
|
||||
counts = {
|
||||
RULE_UNSUBMITTED: sum(1 for f in findings if f.rule_key == RULE_UNSUBMITTED),
|
||||
RULE_GAP: sum(1 for f in findings if f.rule_key == RULE_GAP),
|
||||
RULE_PENDING: sum(1 for f in findings if f.rule_key == RULE_PENDING),
|
||||
}
|
||||
auth.audit(
|
||||
connection,
|
||||
"reminder_scan",
|
||||
actor=actor,
|
||||
detail=json.dumps({"total": len(findings), "by_rule": counts}, ensure_ascii=False),
|
||||
ip=ip,
|
||||
)
|
||||
return {"findings": findings, "counts": counts}
|
||||
|
||||
|
||||
def finding_to_dict(finding: ScanFinding) -> dict[str, Any]:
|
||||
return {
|
||||
"company_id": finding.company_id,
|
||||
"company_name": finding.company_name,
|
||||
"rule_key": finding.rule_key,
|
||||
"rule_label": RULE_LABELS.get(finding.rule_key, finding.rule_key),
|
||||
"dedupe_key": finding.dedupe_key,
|
||||
"rule_params": finding.rule_params,
|
||||
"title": finding.title,
|
||||
"content": finding.content,
|
||||
"reason": finding.reason,
|
||||
"days_open": finding.days_open,
|
||||
"duration_pill": _duration_pill_class(finding.days_open),
|
||||
"deadline": finding.deadline,
|
||||
"action_link": finding.action_link,
|
||||
"existing_reminder_id": finding.existing_reminder_id,
|
||||
"send_count": finding.send_count,
|
||||
}
|
||||
|
||||
|
||||
def _append_event(
|
||||
connection: sqlite3.Connection,
|
||||
reminder_id: int,
|
||||
event_type: str,
|
||||
actor: str,
|
||||
detail: str | None = None,
|
||||
) -> None:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO reminder_events (reminder_id, event_type, actor, detail, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(reminder_id, event_type, actor, detail, utc_now()),
|
||||
)
|
||||
|
||||
|
||||
def _actor_label(actor: sqlite3.Row | None) -> str:
|
||||
if actor is None:
|
||||
return "system"
|
||||
return str(actor["id"])
|
||||
|
||||
|
||||
def deliver_finding(
|
||||
connection: sqlite3.Connection,
|
||||
dedupe_key: str,
|
||||
*,
|
||||
actor: sqlite3.Row | None = None,
|
||||
ip: str | None = None,
|
||||
) -> int | None:
|
||||
findings = {item.dedupe_key: item for item in scan_findings(connection)}
|
||||
finding = findings.get(dedupe_key)
|
||||
if finding is None:
|
||||
return None
|
||||
return _deliver(connection, finding, actor=actor, ip=ip)
|
||||
|
||||
|
||||
def _deliver(
|
||||
connection: sqlite3.Connection,
|
||||
finding: ScanFinding,
|
||||
*,
|
||||
actor: sqlite3.Row | None = None,
|
||||
ip: str | None = None,
|
||||
) -> int:
|
||||
now = utc_now()
|
||||
actor_ref = _actor_label(actor)
|
||||
existing = connection.execute(
|
||||
"SELECT id, send_count, status FROM reminders WHERE dedupe_key = ?",
|
||||
(finding.dedupe_key,),
|
||||
).fetchone()
|
||||
|
||||
with connection:
|
||||
if existing is not None:
|
||||
reminder_id = int(existing["id"])
|
||||
send_count = int(existing["send_count"]) + 1
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE reminders
|
||||
SET send_count = ?, last_sent_at = ?, status = 'open',
|
||||
title = ?, content = ?, rule_params = ?, action_link = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
send_count,
|
||||
now,
|
||||
finding.title,
|
||||
finding.content,
|
||||
json.dumps(finding.rule_params, ensure_ascii=False),
|
||||
finding.action_link,
|
||||
reminder_id,
|
||||
),
|
||||
)
|
||||
event_type = "sent" if existing["status"] == "resolved" else (
|
||||
"escalated" if send_count > 1 else "sent"
|
||||
)
|
||||
_append_event(
|
||||
connection,
|
||||
reminder_id,
|
||||
event_type,
|
||||
actor_ref,
|
||||
f"第 {send_count} 次催办",
|
||||
)
|
||||
else:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO reminders (
|
||||
company_id, rule_key, dedupe_key, rule_params, title, content,
|
||||
deadline, source, status, send_count, first_sent_at, last_sent_at,
|
||||
created_by, created_at, action_link
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, 'auto', 'open', 1, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
finding.company_id,
|
||||
finding.rule_key,
|
||||
finding.dedupe_key,
|
||||
json.dumps(finding.rule_params, ensure_ascii=False),
|
||||
finding.title,
|
||||
finding.content,
|
||||
finding.deadline,
|
||||
now,
|
||||
now,
|
||||
actor_ref,
|
||||
now,
|
||||
finding.action_link,
|
||||
),
|
||||
)
|
||||
reminder_id = int(connection.execute("SELECT last_insert_rowid()").fetchone()[0])
|
||||
_append_event(connection, reminder_id, "sent", actor_ref, finding.reason)
|
||||
|
||||
auth.audit(
|
||||
connection,
|
||||
"reminder_send",
|
||||
actor=actor,
|
||||
target=f"reminder:{reminder_id}",
|
||||
detail=finding.dedupe_key,
|
||||
ip=ip,
|
||||
)
|
||||
return reminder_id
|
||||
|
||||
|
||||
def deliver_many(
|
||||
connection: sqlite3.Connection,
|
||||
dedupe_keys: list[str],
|
||||
*,
|
||||
actor: sqlite3.Row | None = None,
|
||||
ip: str | None = None,
|
||||
) -> list[int]:
|
||||
sent: list[int] = []
|
||||
for key in dedupe_keys:
|
||||
try:
|
||||
reminder_id = deliver_finding(connection, key, actor=actor, ip=ip)
|
||||
except Exception:
|
||||
continue
|
||||
if reminder_id is not None:
|
||||
sent.append(reminder_id)
|
||||
return sent
|
||||
|
||||
|
||||
def send_manual(
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
company_id: int,
|
||||
display_type: str,
|
||||
content: str,
|
||||
deadline: str | None,
|
||||
actor: sqlite3.Row,
|
||||
ip: str | None = None,
|
||||
) -> int:
|
||||
company = connection.execute(
|
||||
"SELECT name FROM companies WHERE id = ?", (company_id,)
|
||||
).fetchone()
|
||||
if company is None:
|
||||
raise ValueError("company not found")
|
||||
now = utc_now()
|
||||
dedupe_key = f"{company_id}:{RULE_MANUAL}:{uuid.uuid4().hex}"
|
||||
title = f"{company['name']} · {display_type}"
|
||||
with connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO reminders (
|
||||
company_id, rule_key, dedupe_key, rule_params, title, content,
|
||||
deadline, source, status, send_count, first_sent_at, last_sent_at,
|
||||
created_by, created_at, action_link
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, 'manual', 'open', 1, ?, ?, ?, ?, NULL)
|
||||
""",
|
||||
(
|
||||
company_id,
|
||||
RULE_MANUAL,
|
||||
dedupe_key,
|
||||
json.dumps({"display_type": display_type}, ensure_ascii=False),
|
||||
title,
|
||||
content,
|
||||
deadline,
|
||||
now,
|
||||
now,
|
||||
str(actor["id"]),
|
||||
now,
|
||||
),
|
||||
)
|
||||
reminder_id = int(connection.execute("SELECT last_insert_rowid()").fetchone()[0])
|
||||
_append_event(connection, reminder_id, "sent", str(actor["id"]), display_type)
|
||||
auth.audit(
|
||||
connection,
|
||||
"reminder_send_manual",
|
||||
actor=actor,
|
||||
target=f"reminder:{reminder_id}",
|
||||
detail=display_type,
|
||||
ip=ip,
|
||||
)
|
||||
return reminder_id
|
||||
|
||||
|
||||
def resend_reminder(
|
||||
connection: sqlite3.Connection,
|
||||
reminder_id: int,
|
||||
*,
|
||||
actor: sqlite3.Row,
|
||||
ip: str | None = None,
|
||||
) -> None:
|
||||
row = connection.execute(
|
||||
"SELECT id, send_count, status FROM reminders WHERE id = ?", (reminder_id,)
|
||||
).fetchone()
|
||||
if row is None or row["status"] == "resolved":
|
||||
raise ValueError("reminder not available")
|
||||
now = utc_now()
|
||||
send_count = int(row["send_count"]) + 1
|
||||
with connection:
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE reminders
|
||||
SET send_count = ?, last_sent_at = ?, status = 'open'
|
||||
WHERE id = ?
|
||||
""",
|
||||
(send_count, now, reminder_id),
|
||||
)
|
||||
_append_event(
|
||||
connection,
|
||||
reminder_id,
|
||||
"escalated",
|
||||
str(actor["id"]),
|
||||
f"第 {send_count} 次催办",
|
||||
)
|
||||
auth.audit(
|
||||
connection,
|
||||
"reminder_resend",
|
||||
actor=actor,
|
||||
target=f"reminder:{reminder_id}",
|
||||
ip=ip,
|
||||
)
|
||||
|
||||
|
||||
def _reminder_row_to_dict(row: sqlite3.Row, *, company_name: str | None = None) -> dict[str, Any]:
|
||||
params = json.loads(row["rule_params"]) if row["rule_params"] else {}
|
||||
rule_key = row["rule_key"]
|
||||
display_type = params.get("display_type") if rule_key == RULE_MANUAL else RULE_LABELS.get(rule_key, rule_key)
|
||||
status = row["status"]
|
||||
status_ui = {"open": "unread", "acknowledged": "doing", "resolved": "done"}[status]
|
||||
return {
|
||||
"id": row["id"],
|
||||
"company_id": row["company_id"],
|
||||
"company_name": company_name or row["company_name"],
|
||||
"rule_key": rule_key,
|
||||
"display_type": display_type,
|
||||
"title": row["title"],
|
||||
"content": row["content"],
|
||||
"deadline": row["deadline"],
|
||||
"source": row["source"],
|
||||
"status": status,
|
||||
"status_ui": status_ui,
|
||||
"send_count": row["send_count"],
|
||||
"first_sent_at": row["first_sent_at"],
|
||||
"last_sent_at": row["last_sent_at"],
|
||||
"created_by": row["created_by"],
|
||||
"created_at": row["created_at"],
|
||||
"action_link": row["action_link"],
|
||||
"rule_params": params,
|
||||
}
|
||||
|
||||
|
||||
def list_admin_reminders(
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
source: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
conditions = ["r.send_count > 0"]
|
||||
params: list[Any] = []
|
||||
if source in {"auto", "manual"}:
|
||||
conditions.append("r.source = ?")
|
||||
params.append(source)
|
||||
where = " AND ".join(conditions)
|
||||
rows = connection.execute(
|
||||
f"""
|
||||
SELECT r.*, c.name AS company_name
|
||||
FROM reminders r
|
||||
JOIN companies c ON c.id = r.company_id
|
||||
WHERE {where}
|
||||
ORDER BY r.last_sent_at DESC, r.id DESC
|
||||
""",
|
||||
params,
|
||||
).fetchall()
|
||||
return [_reminder_row_to_dict(row) for row in rows]
|
||||
|
||||
|
||||
def list_company_reminders(connection: sqlite3.Connection, company_id: int) -> list[dict[str, Any]]:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT r.*, c.name AS company_name
|
||||
FROM reminders r
|
||||
JOIN companies c ON c.id = r.company_id
|
||||
WHERE r.company_id = ? AND r.send_count > 0
|
||||
ORDER BY
|
||||
CASE r.status WHEN 'open' THEN 0 WHEN 'acknowledged' THEN 1 ELSE 2 END,
|
||||
r.last_sent_at DESC
|
||||
""",
|
||||
(company_id,),
|
||||
).fetchall()
|
||||
return [_reminder_row_to_dict(row) for row in rows]
|
||||
|
||||
|
||||
def get_reminder_detail(connection: sqlite3.Connection, reminder_id: int) -> dict[str, Any] | None:
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT r.*, c.name AS company_name
|
||||
FROM reminders r
|
||||
JOIN companies c ON c.id = r.company_id
|
||||
WHERE r.id = ?
|
||||
""",
|
||||
(reminder_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
events = connection.execute(
|
||||
"""
|
||||
SELECT event_type, actor, detail, created_at
|
||||
FROM reminder_events
|
||||
WHERE reminder_id = ?
|
||||
ORDER BY id
|
||||
""",
|
||||
(reminder_id,),
|
||||
).fetchall()
|
||||
payload = _reminder_row_to_dict(row)
|
||||
payload["events"] = [dict(item) for item in events]
|
||||
return payload
|
||||
|
||||
|
||||
def update_reminder_status(
|
||||
connection: sqlite3.Connection,
|
||||
reminder_id: int,
|
||||
new_status: str,
|
||||
*,
|
||||
company_id: int | None = None,
|
||||
actor: sqlite3.Row | None = None,
|
||||
) -> bool:
|
||||
if new_status not in {"acknowledged", "resolved"}:
|
||||
raise ValueError("invalid status")
|
||||
row = connection.execute(
|
||||
"SELECT id, company_id, status FROM reminders WHERE id = ?",
|
||||
(reminder_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return False
|
||||
if company_id is not None and int(row["company_id"]) != company_id:
|
||||
return False
|
||||
if row["status"] == "resolved":
|
||||
return False
|
||||
event_type = "acknowledged" if new_status == "acknowledged" else "resolved"
|
||||
actor_ref = _actor_label(actor)
|
||||
with connection:
|
||||
connection.execute(
|
||||
"UPDATE reminders SET status = ? WHERE id = ?",
|
||||
(new_status, reminder_id),
|
||||
)
|
||||
_append_event(connection, reminder_id, event_type, actor_ref, None)
|
||||
return True
|
||||
|
||||
|
||||
def company_unread_count(connection: sqlite3.Connection, company_id: int) -> int:
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS n FROM reminders
|
||||
WHERE company_id = ? AND status = 'open' AND send_count > 0
|
||||
""",
|
||||
(company_id,),
|
||||
).fetchone()
|
||||
return int(row["n"])
|
||||
@@ -41,7 +41,7 @@ class PersistenceTestCase(unittest.TestCase):
|
||||
class MigrationTests(PersistenceTestCase):
|
||||
def test_migrate_creates_schema_and_is_idempotent(self) -> None:
|
||||
first = applied_versions(self.connection)
|
||||
self.assertEqual([1, 2, 3, 4, 5], first)
|
||||
self.assertEqual([1, 2, 3, 4, 5, 6], first)
|
||||
self.assertEqual([], migrate(self.connection))
|
||||
self.assertEqual(first, applied_versions(self.connection))
|
||||
tables = {
|
||||
@@ -73,19 +73,22 @@ class MigrationTests(PersistenceTestCase):
|
||||
"transfer_match_candidates",
|
||||
"current_transfer_decisions",
|
||||
"transfer_observation_claims",
|
||||
"reminder_settings",
|
||||
"reminders",
|
||||
"reminder_events",
|
||||
"schema_migrations",
|
||||
):
|
||||
self.assertIn(table, tables)
|
||||
|
||||
def test_rollback_removes_schema_and_forward_rebuilds_it(self) -> None:
|
||||
self.assertEqual([5, 4, 3, 2, 1], rollback(self.connection, 0))
|
||||
self.assertEqual([6, 5, 4, 3, 2, 1], rollback(self.connection, 0))
|
||||
self.assertEqual([], applied_versions(self.connection))
|
||||
remaining = self.connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'source_rows'"
|
||||
).fetchone()
|
||||
self.assertIsNone(remaining)
|
||||
self.assertEqual([1, 2, 3, 4, 5], migrate(self.connection))
|
||||
self.assertEqual([1, 2, 3, 4, 5], applied_versions(self.connection))
|
||||
self.assertEqual([1, 2, 3, 4, 5, 6], migrate(self.connection))
|
||||
self.assertEqual([1, 2, 3, 4, 5, 6], applied_versions(self.connection))
|
||||
|
||||
def test_rollback_to_4_keeps_bank_evidence_and_drops_event_layer(self) -> None:
|
||||
self.import_sample()
|
||||
@@ -93,7 +96,7 @@ class MigrationTests(PersistenceTestCase):
|
||||
"SELECT COUNT(*) AS n FROM source_rows"
|
||||
).fetchone()["n"]
|
||||
self.assertGreater(row_count, 0)
|
||||
self.assertEqual([5], rollback(self.connection, 4))
|
||||
self.assertEqual([6, 5], rollback(self.connection, 4))
|
||||
# The pre-migration evidence and schema are untouched.
|
||||
self.assertEqual(
|
||||
row_count,
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
"""Tests for automatic reminder detection, delivery, isolation and audit trail."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
import json
|
||||
import sqlite3
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from bank_importer import auth, reminders
|
||||
from bank_importer.db import connect, migrate, utc_now
|
||||
|
||||
|
||||
class ReminderTestCase(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.connection = connect(":memory:")
|
||||
self.addCleanup(self.connection.close)
|
||||
migrate(self.connection)
|
||||
now = utc_now()
|
||||
with self.connection:
|
||||
self.connection.execute(
|
||||
"INSERT INTO companies (name, created_at, updated_at) VALUES ('甲公司', ?, ?)",
|
||||
(now, now),
|
||||
)
|
||||
self.connection.execute(
|
||||
"INSERT INTO companies (name, created_at, updated_at) VALUES ('乙公司', ?, ?)",
|
||||
(now, now),
|
||||
)
|
||||
self.company_a = int(
|
||||
self.connection.execute("SELECT id FROM companies WHERE name = '甲公司'").fetchone()["id"]
|
||||
)
|
||||
self.company_b = int(
|
||||
self.connection.execute("SELECT id FROM companies WHERE name = '乙公司'").fetchone()["id"]
|
||||
)
|
||||
self.admin_id = auth.create_user(self.connection, "admin1", "AdminPass123", "admin")
|
||||
self.user_a = auth.create_user(
|
||||
self.connection, "cashier-a", "CashierA123", "company", company_id=self.company_a
|
||||
)
|
||||
self.user_b = auth.create_user(
|
||||
self.connection, "cashier-b", "CashierB123", "company", company_id=self.company_b
|
||||
)
|
||||
self.admin = self.connection.execute(
|
||||
"SELECT * FROM users WHERE id = ?", (self.admin_id,)
|
||||
).fetchone()
|
||||
self.actor_a = self.connection.execute(
|
||||
"SELECT * FROM users WHERE id = ?", (self.user_a,)
|
||||
).fetchone()
|
||||
|
||||
def _insert_batch(
|
||||
self,
|
||||
company_id: int,
|
||||
*,
|
||||
period_start: str,
|
||||
period_end: str,
|
||||
) -> None:
|
||||
now = utc_now()
|
||||
with self.connection:
|
||||
self.connection.execute(
|
||||
"""
|
||||
INSERT INTO source_files (sha256, original_filename, size_bytes, storage_path, created_at)
|
||||
VALUES (?, 'f.xls', 1, '/tmp/f.xls', ?)
|
||||
""",
|
||||
(f"sha-{company_id}-{period_end}", now),
|
||||
)
|
||||
file_id = int(self.connection.execute("SELECT last_insert_rowid()").fetchone()[0])
|
||||
self.connection.execute(
|
||||
"""
|
||||
INSERT INTO import_batches (source_file_id, status, company_id, created_at, updated_at)
|
||||
VALUES (?, 'parsed', ?, ?, ?)
|
||||
""",
|
||||
(file_id, company_id, now, now),
|
||||
)
|
||||
batch_id = int(self.connection.execute("SELECT last_insert_rowid()").fetchone()[0])
|
||||
self.connection.execute(
|
||||
"""
|
||||
INSERT INTO sheet_batches (
|
||||
import_batch_id, sheet_name, bank_name, template_id, template_version,
|
||||
header_row, period_start, period_end, transaction_count, created_at
|
||||
) VALUES (?, 's1', '工行', 'tpl', 1, 1, ?, ?, 1, ?)
|
||||
""",
|
||||
(batch_id, period_start, period_end, now),
|
||||
)
|
||||
|
||||
def _insert_pending_account(self, company_id: int) -> None:
|
||||
now = utc_now()
|
||||
with self.connection:
|
||||
self.connection.execute(
|
||||
"""
|
||||
INSERT INTO bank_accounts (
|
||||
company_id, account_number, bank_name, status, created_at, updated_at
|
||||
) VALUES (?, ?, '工行', 'pending', ?, ?)
|
||||
""",
|
||||
(company_id, f"622{company_id:012d}", now, now),
|
||||
)
|
||||
|
||||
|
||||
class RuleDetectionTests(ReminderTestCase):
|
||||
def test_unsubmitted_detected_when_no_current_month_batch(self) -> None:
|
||||
today = date.today()
|
||||
if today.day < 5:
|
||||
self.skipTest("monthly start day gate not reached today")
|
||||
findings = reminders.scan_findings(self.connection)
|
||||
keys = {item.company_id: item for item in findings if item.rule_key == reminders.RULE_UNSUBMITTED}
|
||||
self.assertIn(self.company_a, keys)
|
||||
self.assertIn(self.company_b, keys)
|
||||
|
||||
def test_unsubmitted_not_reported_when_batch_exists(self) -> None:
|
||||
today = date.today()
|
||||
if today.day < 5:
|
||||
self.skipTest("monthly start day gate not reached today")
|
||||
period = f"{today.year:04d}-{today.month:02d}"
|
||||
self._insert_batch(self.company_a, period_start=f"{period}-01", period_end=f"{period}-15")
|
||||
findings = reminders.scan_findings(self.connection)
|
||||
for item in findings:
|
||||
if item.rule_key == reminders.RULE_UNSUBMITTED:
|
||||
self.assertNotEqual(self.company_a, item.company_id)
|
||||
|
||||
def test_gap_detected_after_threshold(self) -> None:
|
||||
old_end = (date.today() - timedelta(days=10)).isoformat()
|
||||
self._insert_batch(self.company_a, period_start="2026-01-01", period_end=old_end)
|
||||
findings = reminders.scan_findings(self.connection)
|
||||
gap = [item for item in findings if item.rule_key == reminders.RULE_GAP and item.company_id == self.company_a]
|
||||
self.assertEqual(1, len(gap))
|
||||
|
||||
def test_gap_not_reported_for_recent_batch(self) -> None:
|
||||
recent = (date.today() - timedelta(days=1)).isoformat()
|
||||
self._insert_batch(self.company_a, period_start="2026-07-01", period_end=recent)
|
||||
findings = reminders.scan_findings(self.connection)
|
||||
gap = [item for item in findings if item.rule_key == reminders.RULE_GAP and item.company_id == self.company_a]
|
||||
self.assertEqual(0, len(gap))
|
||||
|
||||
def test_pending_review_detected_with_pending_account(self) -> None:
|
||||
self._insert_pending_account(self.company_a)
|
||||
findings = reminders.scan_findings(self.connection)
|
||||
pending = [
|
||||
item for item in findings
|
||||
if item.rule_key == reminders.RULE_PENDING and item.company_id == self.company_a
|
||||
]
|
||||
self.assertEqual(1, len(pending))
|
||||
self.assertGreater(pending[0].rule_params["pending_count"], 0)
|
||||
|
||||
def test_pending_review_not_reported_when_clean(self) -> None:
|
||||
findings = reminders.scan_findings(self.connection)
|
||||
pending = [
|
||||
item for item in findings
|
||||
if item.rule_key == reminders.RULE_PENDING and item.company_id == self.company_a
|
||||
]
|
||||
self.assertEqual(0, len(pending))
|
||||
|
||||
|
||||
class DeliveryAndDedupTests(ReminderTestCase):
|
||||
def test_send_creates_reminder_and_event(self) -> None:
|
||||
today = date.today()
|
||||
if today.day < 5:
|
||||
self.skipTest("monthly start day gate not reached today")
|
||||
findings = reminders.scan_findings(self.connection)
|
||||
self.assertTrue(findings)
|
||||
key = findings[0].dedupe_key
|
||||
reminder_id = reminders.deliver_finding(self.connection, key, actor=self.admin)
|
||||
self.assertIsNotNone(reminder_id)
|
||||
row = self.connection.execute(
|
||||
"SELECT send_count, status FROM reminders WHERE id = ?", (reminder_id,)
|
||||
).fetchone()
|
||||
self.assertEqual(1, row["send_count"])
|
||||
self.assertEqual("open", row["status"])
|
||||
events = self.connection.execute(
|
||||
"SELECT event_type FROM reminder_events WHERE reminder_id = ?", (reminder_id,)
|
||||
).fetchall()
|
||||
self.assertEqual(["sent"], [item["event_type"] for item in events])
|
||||
|
||||
def test_repeat_send_increments_count_without_new_row(self) -> None:
|
||||
today = date.today()
|
||||
if today.day < 5:
|
||||
self.skipTest("monthly start day gate not reached today")
|
||||
findings = reminders.scan_findings(self.connection)
|
||||
key = findings[0].dedupe_key
|
||||
first = reminders.deliver_finding(self.connection, key, actor=self.admin)
|
||||
second = reminders.deliver_finding(self.connection, key, actor=self.admin)
|
||||
self.assertEqual(first, second)
|
||||
row = self.connection.execute(
|
||||
"SELECT send_count FROM reminders WHERE dedupe_key = ?", (key,)
|
||||
).fetchone()
|
||||
self.assertEqual(2, row["send_count"])
|
||||
count = self.connection.execute("SELECT COUNT(*) FROM reminders WHERE dedupe_key = ?", (key,)).fetchone()[0]
|
||||
self.assertEqual(1, count)
|
||||
events = self.connection.execute(
|
||||
"SELECT COUNT(*) FROM reminder_events WHERE reminder_id = ?", (first,)
|
||||
).fetchone()[0]
|
||||
self.assertEqual(2, events)
|
||||
|
||||
def test_resolved_reminder_reopens_same_row(self) -> None:
|
||||
self._insert_pending_account(self.company_a)
|
||||
finding = next(
|
||||
item
|
||||
for item in reminders.scan_findings(self.connection)
|
||||
if item.rule_key == reminders.RULE_PENDING and item.company_id == self.company_a
|
||||
)
|
||||
first = reminders.deliver_finding(self.connection, finding.dedupe_key, actor=self.admin)
|
||||
self.assertIsNotNone(first)
|
||||
reminders.update_reminder_status(
|
||||
self.connection, first, "resolved", company_id=self.company_a, actor=self.actor_a
|
||||
)
|
||||
second = reminders.deliver_finding(self.connection, finding.dedupe_key, actor=self.admin)
|
||||
self.assertEqual(first, second)
|
||||
row = self.connection.execute(
|
||||
"SELECT send_count, status, dedupe_key FROM reminders WHERE id = ?",
|
||||
(first,),
|
||||
).fetchone()
|
||||
self.assertEqual(2, row["send_count"])
|
||||
self.assertEqual("open", row["status"])
|
||||
count = self.connection.execute(
|
||||
"SELECT COUNT(*) FROM reminders WHERE dedupe_key = ?", (finding.dedupe_key,)
|
||||
).fetchone()[0]
|
||||
self.assertEqual(1, count)
|
||||
events = [
|
||||
item["event_type"]
|
||||
for item in self.connection.execute(
|
||||
"SELECT event_type FROM reminder_events WHERE reminder_id = ? ORDER BY id",
|
||||
(first,),
|
||||
).fetchall()
|
||||
]
|
||||
self.assertEqual(["sent", "resolved", "sent"], events)
|
||||
|
||||
def test_deliver_many_continues_after_one_failure(self) -> None:
|
||||
self._insert_pending_account(self.company_a)
|
||||
finding = next(
|
||||
item
|
||||
for item in reminders.scan_findings(self.connection)
|
||||
if item.rule_key == reminders.RULE_PENDING and item.company_id == self.company_a
|
||||
)
|
||||
original = reminders.deliver_finding
|
||||
|
||||
def flaky(connection, key, **kwargs):
|
||||
if key == "boom":
|
||||
raise sqlite3.IntegrityError("UNIQUE constraint failed: reminders.dedupe_key")
|
||||
return original(connection, key, **kwargs)
|
||||
|
||||
with patch.object(reminders, "deliver_finding", side_effect=flaky):
|
||||
sent = reminders.deliver_many(
|
||||
self.connection, ["boom", finding.dedupe_key], actor=self.admin
|
||||
)
|
||||
self.assertEqual(1, len(sent))
|
||||
self.assertEqual(
|
||||
1,
|
||||
self.connection.execute("SELECT COUNT(*) FROM reminders").fetchone()[0],
|
||||
)
|
||||
|
||||
def test_manual_reminder_each_send_is_separate(self) -> None:
|
||||
first = reminders.send_manual(
|
||||
self.connection,
|
||||
company_id=self.company_a,
|
||||
display_type="其他",
|
||||
content="请尽快处理",
|
||||
deadline="2026-09-01",
|
||||
actor=self.admin,
|
||||
)
|
||||
second = reminders.send_manual(
|
||||
self.connection,
|
||||
company_id=self.company_a,
|
||||
display_type="其他",
|
||||
content="再次提醒",
|
||||
deadline=None,
|
||||
actor=self.admin,
|
||||
)
|
||||
self.assertNotEqual(first, second)
|
||||
|
||||
|
||||
class IsolationTests(ReminderTestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
reminders.send_manual(
|
||||
self.connection,
|
||||
company_id=self.company_a,
|
||||
display_type="流水未提交",
|
||||
content="甲公司专属",
|
||||
deadline=None,
|
||||
actor=self.admin,
|
||||
)
|
||||
reminders.send_manual(
|
||||
self.connection,
|
||||
company_id=self.company_b,
|
||||
display_type="流水未提交",
|
||||
content="乙公司专属",
|
||||
deadline=None,
|
||||
actor=self.admin,
|
||||
)
|
||||
|
||||
def test_company_sees_only_own_reminders(self) -> None:
|
||||
items_a = reminders.list_company_reminders(self.connection, self.company_a)
|
||||
items_b = reminders.list_company_reminders(self.connection, self.company_b)
|
||||
self.assertEqual(1, len(items_a))
|
||||
self.assertEqual(1, len(items_b))
|
||||
self.assertIn("甲公司", items_a[0]["title"])
|
||||
self.assertIn("乙公司", items_b[0]["title"])
|
||||
|
||||
def test_company_cannot_update_other_company_reminder(self) -> None:
|
||||
other_id = reminders.list_company_reminders(self.connection, self.company_b)[0]["id"]
|
||||
ok = reminders.update_reminder_status(
|
||||
self.connection, other_id, "acknowledged", company_id=self.company_a, actor=self.actor_a
|
||||
)
|
||||
self.assertFalse(ok)
|
||||
|
||||
|
||||
class AuditTrailTests(ReminderTestCase):
|
||||
def test_scan_writes_audit_log(self) -> None:
|
||||
reminders.run_scan(self.connection, actor=self.admin, ip="127.0.0.1")
|
||||
row = self.connection.execute(
|
||||
"SELECT action, detail FROM audit_log WHERE action = 'reminder_scan'"
|
||||
).fetchone()
|
||||
self.assertIsNotNone(row)
|
||||
detail = json.loads(row["detail"])
|
||||
self.assertIn("total", detail)
|
||||
|
||||
def test_events_are_append_only(self) -> None:
|
||||
reminder_id = reminders.send_manual(
|
||||
self.connection,
|
||||
company_id=self.company_a,
|
||||
display_type="测试",
|
||||
content="内容",
|
||||
deadline=None,
|
||||
actor=self.admin,
|
||||
)
|
||||
with self.assertRaises(Exception):
|
||||
with self.connection:
|
||||
self.connection.execute(
|
||||
"UPDATE reminder_events SET detail = 'tampered' WHERE reminder_id = ?",
|
||||
(reminder_id,),
|
||||
)
|
||||
with self.assertRaises(Exception):
|
||||
with self.connection:
|
||||
self.connection.execute(
|
||||
"DELETE FROM reminder_events WHERE reminder_id = ?", (reminder_id,)
|
||||
)
|
||||
|
||||
def test_reminders_cannot_be_deleted(self) -> None:
|
||||
reminder_id = reminders.send_manual(
|
||||
self.connection,
|
||||
company_id=self.company_a,
|
||||
display_type="测试",
|
||||
content="内容",
|
||||
deadline=None,
|
||||
actor=self.admin,
|
||||
)
|
||||
with self.assertRaises(Exception):
|
||||
with self.connection:
|
||||
self.connection.execute("DELETE FROM reminders WHERE id = ?", (reminder_id,))
|
||||
|
||||
|
||||
class StatusFlowTests(ReminderTestCase):
|
||||
def test_acknowledge_and_resolve(self) -> None:
|
||||
reminder_id = reminders.send_manual(
|
||||
self.connection,
|
||||
company_id=self.company_a,
|
||||
display_type="待确认",
|
||||
content="请处理",
|
||||
deadline=None,
|
||||
actor=self.admin,
|
||||
)
|
||||
ok = reminders.update_reminder_status(
|
||||
self.connection, reminder_id, "acknowledged", company_id=self.company_a, actor=self.actor_a
|
||||
)
|
||||
self.assertTrue(ok)
|
||||
row = self.connection.execute(
|
||||
"SELECT status FROM reminders WHERE id = ?", (reminder_id,)
|
||||
).fetchone()
|
||||
self.assertEqual("acknowledged", row["status"])
|
||||
ok = reminders.update_reminder_status(
|
||||
self.connection, reminder_id, "resolved", company_id=self.company_a, actor=self.actor_a
|
||||
)
|
||||
self.assertTrue(ok)
|
||||
events = self.connection.execute(
|
||||
"SELECT event_type FROM reminder_events WHERE reminder_id = ? ORDER BY id",
|
||||
(reminder_id,),
|
||||
).fetchall()
|
||||
self.assertEqual(
|
||||
["sent", "acknowledged", "resolved"],
|
||||
[item["event_type"] for item in events],
|
||||
)
|
||||
|
||||
|
||||
class SettingsTests(ReminderTestCase):
|
||||
def test_default_settings_and_update(self) -> None:
|
||||
settings = reminders.get_settings(self.connection)
|
||||
self.assertEqual("5", settings["monthly_start_day"])
|
||||
self.assertEqual("5", settings["gap_days"])
|
||||
updated = reminders.update_settings(
|
||||
self.connection, {"gap_days": "7", "monthly_start_day": "6"}
|
||||
)
|
||||
self.assertEqual("7", updated["gap_days"])
|
||||
self.assertEqual("6", updated["monthly_start_day"])
|
||||
|
||||
def test_unknown_setting_rejected(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
reminders.update_settings(self.connection, {"unknown_key": "1"})
|
||||
|
||||
def test_invalid_monthly_start_day_rejected(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
reminders.update_settings(self.connection, {"monthly_start_day": "0"})
|
||||
with self.assertRaises(ValueError):
|
||||
reminders.update_settings(self.connection, {"monthly_start_day": "29"})
|
||||
self.assertEqual("5", reminders.get_settings(self.connection)["monthly_start_day"])
|
||||
|
||||
def test_invalid_gap_days_rejected(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
reminders.update_settings(self.connection, {"gap_days": "0"})
|
||||
with self.assertRaises(ValueError):
|
||||
reminders.update_settings(self.connection, {"gap_days": "abc"})
|
||||
self.assertEqual("5", reminders.get_settings(self.connection)["gap_days"])
|
||||
|
||||
def test_invalid_scan_time_rejected(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
reminders.update_settings(self.connection, {"scan_time": "25:00"})
|
||||
with self.assertRaises(ValueError):
|
||||
reminders.update_settings(self.connection, {"scan_time": "8:0"})
|
||||
self.assertEqual("08:00", reminders.get_settings(self.connection)["scan_time"])
|
||||
|
||||
def test_corrupt_settings_do_not_break_scan(self) -> None:
|
||||
with self.connection:
|
||||
self.connection.execute(
|
||||
"UPDATE reminder_settings SET value = 'not-a-number' WHERE key = 'monthly_start_day'"
|
||||
)
|
||||
self.connection.execute(
|
||||
"UPDATE reminder_settings SET value = '0' WHERE key = 'gap_days'"
|
||||
)
|
||||
findings = reminders.scan_findings(self.connection)
|
||||
self.assertIsInstance(findings, list)
|
||||
|
||||
|
||||
class NoticeListDelegationTests(unittest.TestCase):
|
||||
def test_async_notice_go_handle_uses_event_delegation(self) -> None:
|
||||
source = (Path(__file__).resolve().parents[1] / "web" / "app.js").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
init_body = source.split("function initNotifications()", 1)[1].split("\nfunction ", 1)[0]
|
||||
self.assertIn('list.addEventListener("click"', init_body)
|
||||
self.assertIn('closest("[data-view-link]")', init_body)
|
||||
self.assertIn("showView(viewLink.dataset.viewLink)", init_body)
|
||||
self.assertIn('document.addEventListener("click"', source)
|
||||
self.assertNotIn(
|
||||
'$$("[data-view-link]").forEach((button) => button.addEventListener("click"',
|
||||
source,
|
||||
)
|
||||
|
||||
def test_delegated_lookup_finds_button_inserted_after_init(self) -> None:
|
||||
"""Simulate #notice-list after async replaceChildren: click target is the new button."""
|
||||
list_root = {"id": "notice-list", "parent": None, "attrs": {}}
|
||||
side = {"id": "lr-side", "parent": list_root, "attrs": {}}
|
||||
button = {
|
||||
"id": "go",
|
||||
"parent": side,
|
||||
"attrs": {"data-view-link": "reconcile"},
|
||||
}
|
||||
list_root["children"] = [side]
|
||||
side["children"] = [button]
|
||||
|
||||
def closest(node, attr):
|
||||
current = node
|
||||
while current is not None:
|
||||
if attr in current.get("attrs", {}):
|
||||
return current
|
||||
current = current.get("parent")
|
||||
return None
|
||||
|
||||
clicked = closest(button, "data-view-link")
|
||||
self.assertIsNotNone(clicked)
|
||||
self.assertEqual("reconcile", clicked["attrs"]["data-view-link"])
|
||||
self.assertIs(list_root, clicked["parent"]["parent"])
|
||||
|
||||
|
||||
class MigrationTests(unittest.TestCase):
|
||||
def test_v6_migration_applies_and_rolls_back(self) -> None:
|
||||
connection = connect(":memory:")
|
||||
self.addCleanup(connection.close)
|
||||
migrate(connection)
|
||||
versions = connection.execute(
|
||||
"SELECT version FROM schema_migrations ORDER BY version"
|
||||
).fetchall()
|
||||
self.assertEqual(6, versions[-1]["version"])
|
||||
connection.execute("DELETE FROM schema_migrations WHERE version = 6")
|
||||
connection.executescript(
|
||||
"""
|
||||
DROP TRIGGER IF EXISTS reminders_no_delete;
|
||||
DROP TRIGGER IF EXISTS reminder_events_no_delete;
|
||||
DROP TRIGGER IF EXISTS reminder_events_no_update;
|
||||
DROP TABLE IF EXISTS reminder_events;
|
||||
DROP TABLE IF EXISTS reminders;
|
||||
DROP TABLE IF EXISTS reminder_settings;
|
||||
"""
|
||||
)
|
||||
row = connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE name = 'reminders'"
|
||||
).fetchone()
|
||||
self.assertIsNone(row)
|
||||
@@ -287,6 +287,8 @@ class ServerAuthMatrixTests(unittest.TestCase):
|
||||
lambda: anon.get("/api/admin/users"),
|
||||
lambda: anon.get("/api/admin/companies"),
|
||||
lambda: anon.get("/api/admin/audit-log"),
|
||||
lambda: anon.get("/api/admin/reminders"),
|
||||
lambda: anon.get("/api/company/reminders"),
|
||||
lambda: anon.get("/api/me"),
|
||||
):
|
||||
status, _, data = method_check()
|
||||
@@ -387,6 +389,10 @@ class ServerAuthMatrixTests(unittest.TestCase):
|
||||
lambda: self.cashier_a.request("POST", "/api/admin/users/1/enable"),
|
||||
lambda: self.cashier_a.request("POST", "/api/admin/users/1/reset-password"),
|
||||
lambda: self.cashier_a.get("/api/admin/audit-log"),
|
||||
lambda: self.cashier_a.get("/api/admin/reminders"),
|
||||
lambda: self.cashier_a.get("/api/admin/reminders/pending"),
|
||||
lambda: self.cashier_a.post_json("/api/admin/reminder-settings", {"gap_days": "3"}),
|
||||
lambda: self.cashier_a.post_json("/api/admin/reminders/send", {"dedupe_keys": ["x"]}),
|
||||
)
|
||||
for call in calls:
|
||||
status, _, data = call()
|
||||
@@ -557,6 +563,28 @@ class ServerAuthMatrixTests(unittest.TestCase):
|
||||
self.assertNotIn(password, row["detail"] or "")
|
||||
self.assertNotIn(password, row["target"] or "")
|
||||
|
||||
def test_invalid_reminder_settings_return_400_and_do_not_persist(self) -> None:
|
||||
cases = (
|
||||
{"monthly_start_day": "0"},
|
||||
{"gap_days": "0"},
|
||||
{"scan_time": "25:99"},
|
||||
)
|
||||
for payload in cases:
|
||||
with self.subTest(payload=payload):
|
||||
status, _, data = self.admin.post_json(
|
||||
"/api/admin/reminder-settings", {"settings": payload}
|
||||
)
|
||||
self.assertEqual(400, status, data)
|
||||
self.assertEqual("error", as_json(data)["status"])
|
||||
status, _, data = self.admin.get("/api/admin/reminder-settings")
|
||||
self.assertEqual(200, status)
|
||||
settings = as_json(data)["settings"]
|
||||
self.assertEqual("5", settings["monthly_start_day"])
|
||||
self.assertEqual("5", settings["gap_days"])
|
||||
self.assertEqual("08:00", settings["scan_time"])
|
||||
status, _, data = self.admin.get("/api/admin/reminders/pending")
|
||||
self.assertEqual(200, status, data)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+1054
-185
File diff suppressed because it is too large
Load Diff
+1400
-426
File diff suppressed because it is too large
Load Diff
+813
-151
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,693 @@
|
||||
/* ─── 金牛实业资金往来管理系统 · 共享样式 ─────────────────────────────
|
||||
方向:tech-utility(数据密集型工具)。六枚基础 token 绑定设计方向,
|
||||
状态色仅在此 :root 块内以 oklch 派生,组件一律引用变量。 */
|
||||
|
||||
:root {
|
||||
--bg: oklch(98% 0.005 250);
|
||||
--surface: oklch(100% 0 0);
|
||||
--fg: oklch(22% 0.02 240);
|
||||
--muted: oklch(50% 0.018 240);
|
||||
--border: oklch(90% 0.008 240);
|
||||
--accent: oklch(58% 0.16 145);
|
||||
|
||||
/* 状态色 — 仅此处定义 */
|
||||
--success: oklch(55% 0.14 150);
|
||||
--warn: oklch(62% 0.14 70);
|
||||
--danger: oklch(55% 0.18 25);
|
||||
--info: oklch(52% 0.12 240);
|
||||
|
||||
--accent-soft: color-mix(in oklch, var(--accent) 12%, transparent);
|
||||
--success-soft: color-mix(in oklch, var(--success) 12%, transparent);
|
||||
--warn-soft: color-mix(in oklch, var(--warn) 14%, transparent);
|
||||
--danger-soft: color-mix(in oklch, var(--danger) 11%, transparent);
|
||||
--info-soft: color-mix(in oklch, var(--info) 10%, transparent);
|
||||
--fg-soft: color-mix(in oklch, var(--fg) 5%, transparent);
|
||||
|
||||
--font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono", "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace;
|
||||
|
||||
--radius: 8px;
|
||||
--radius-lg: 12px;
|
||||
--side-w: 232px;
|
||||
}
|
||||
|
||||
/* ─── reset ─────────────────────────────────────────────────────── */
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
img, svg { display: block; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
button { font: inherit; cursor: pointer; }
|
||||
h1, h2, h3, h4 { margin: 0; line-height: 1.3; }
|
||||
p { margin: 0; }
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ─── 应用外壳 ──────────────────────────────────────────────────── */
|
||||
.shell { display: grid; grid-template-columns: var(--side-w) 1fr; min-height: 100vh; }
|
||||
|
||||
.sidebar {
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
.side-brand {
|
||||
padding: 18px 20px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.side-brand .brand-name { font-size: 15px; font-weight: 650; letter-spacing: -0.01em; }
|
||||
.side-brand .brand-sub { font-family: var(--font-mono); font-size: 11px; color: var(--muted); margin-top: 3px; letter-spacing: 0.02em; }
|
||||
.side-role {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
margin-top: 10px;
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-size: 11px; font-weight: 600;
|
||||
}
|
||||
.side-role.company { background: var(--info-soft); color: var(--info); }
|
||||
|
||||
.side-nav { flex: 1; overflow-y: auto; padding: 12px 10px; }
|
||||
.side-nav .nav-group { font-family: var(--font-mono); font-size: 10.5px; letter-spacing: 0.08em; color: var(--muted); padding: 14px 10px 6px; }
|
||||
.side-nav a {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius);
|
||||
color: var(--muted);
|
||||
font-size: 13.5px;
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
.side-nav a svg { width: 16px; height: 16px; flex: none; }
|
||||
.side-nav a:hover { color: var(--fg); background: var(--fg-soft); }
|
||||
.side-nav a.active {
|
||||
color: var(--fg);
|
||||
background: var(--accent-soft);
|
||||
font-weight: 600;
|
||||
}
|
||||
.side-nav a.active svg { color: var(--accent); }
|
||||
.side-nav a .nav-badge {
|
||||
margin-left: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
font-weight: 600;
|
||||
}
|
||||
.side-foot { border-top: 1px solid var(--border); padding: 12px 16px; }
|
||||
.side-foot .user-row { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||
.side-foot .user-row > div { min-width: 0; }
|
||||
.side-foot .avatar {
|
||||
width: 30px; height: 30px; border-radius: 50%;
|
||||
background: var(--fg); color: var(--surface);
|
||||
display: grid; place-items: center;
|
||||
font-size: 12px; font-weight: 600;
|
||||
flex: none;
|
||||
}
|
||||
.side-foot .user-name { font-size: 13px; font-weight: 600; white-space: nowrap; }
|
||||
.side-foot .user-meta { font-family: var(--font-mono); font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.side-foot .logout { margin-left: auto; color: var(--muted); font-size: 12px; padding: 4px 6px; border-radius: 6px; background: none; border: 0; flex: none; white-space: nowrap; }
|
||||
.side-foot .logout:hover { color: var(--danger); background: var(--danger-soft); }
|
||||
|
||||
.main { min-width: 0; display: flex; flex-direction: column; }
|
||||
|
||||
.topbar {
|
||||
position: sticky; top: 0; z-index: 20;
|
||||
background: color-mix(in oklch, var(--bg) 88%, transparent);
|
||||
backdrop-filter: blur(10px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 12px 28px;
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.topbar .crumb { font-family: var(--font-mono); font-size: 12px; color: var(--muted); }
|
||||
.topbar .crumb b { color: var(--fg); font-weight: 600; }
|
||||
.topbar .topbar-right { margin-left: auto; display: flex; align-items: center; gap: 10px; }
|
||||
|
||||
.content { padding: 24px 28px 64px; max-width: 1440px; width: 100%; margin-inline: auto; }
|
||||
|
||||
/* ─── 页头 ──────────────────────────────────────────────────────── */
|
||||
.page-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||
.page-head h1 { font-size: 22px; font-weight: 700; letter-spacing: -0.015em; }
|
||||
.page-head .page-sub { color: var(--muted); font-size: 13px; margin-top: 5px; max-width: 72ch; }
|
||||
.page-head .page-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
/* ─── 按钮 ──────────────────────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 7px;
|
||||
padding: 8px 14px;
|
||||
min-height: 34px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--fg);
|
||||
font-size: 13px; font-weight: 550;
|
||||
transition: background 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
.btn:hover { border-color: color-mix(in oklch, var(--fg) 40%, var(--border)); background: var(--fg-soft); }
|
||||
.btn:active { transform: translateY(1px); }
|
||||
.btn-primary { background: var(--accent); border-color: var(--accent); color: var(--surface); }
|
||||
.btn-primary:hover { background: color-mix(in oklch, var(--accent) 88%, black); border-color: color-mix(in oklch, var(--accent) 88%, black); }
|
||||
.btn-ghost { background: transparent; border-color: transparent; color: var(--muted); }
|
||||
.btn-ghost:hover { color: var(--fg); background: var(--fg-soft); }
|
||||
.btn-danger { color: var(--danger); border-color: color-mix(in oklch, var(--danger) 35%, var(--border)); }
|
||||
.btn-danger:hover { background: var(--danger-soft); border-color: var(--danger); }
|
||||
.btn-sm { padding: 4px 10px; min-height: 26px; font-size: 12px; border-radius: 6px; }
|
||||
.btn[disabled] { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-primary[disabled] { opacity: 1; background: var(--fg-soft); border-color: var(--border); color: var(--muted); }
|
||||
|
||||
/* ─── 卡片 ──────────────────────────────────────────────────────── */
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 18px 20px;
|
||||
}
|
||||
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
|
||||
.card-title { font-size: 14px; font-weight: 650; letter-spacing: -0.005em; }
|
||||
.card-title .sub { display: block; font-size: 12px; font-weight: 400; color: var(--muted); margin-top: 2px; }
|
||||
|
||||
/* ─── 指标卡 ────────────────────────────────────────────────────── */
|
||||
.stat-card { padding: 16px 18px; }
|
||||
.stat-card .stat-label { font-size: 12.5px; color: var(--muted); display: flex; align-items: center; gap: 6px; }
|
||||
.stat-card .stat-value {
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 26px; font-weight: 650;
|
||||
letter-spacing: -0.02em;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.stat-card .stat-value .unit { font-size: 13px; color: var(--muted); font-weight: 400; margin-left: 2px; }
|
||||
.stat-card .stat-foot { font-size: 12px; color: var(--muted); margin-top: 6px; }
|
||||
.stat-card.alert .stat-value { color: var(--danger); }
|
||||
.stat-card.warn .stat-value { color: var(--warn); }
|
||||
.stat-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
|
||||
.stat-dot.danger { background: var(--danger); }
|
||||
.stat-dot.warn { background: var(--warn); }
|
||||
.stat-dot.success { background: var(--success); }
|
||||
.stat-dot.info { background: var(--info); }
|
||||
|
||||
/* ─── 栅格与布局工具 ────────────────────────────────────────────── */
|
||||
.grid { display: grid; gap: 14px; }
|
||||
.grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.grid-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.grid-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||
.grid-2-1 { grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); align-items: start; }
|
||||
.grid-1-2 { grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); align-items: start; }
|
||||
.grid-3-2 { grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); align-items: start; }
|
||||
.row { display: flex; align-items: center; gap: 10px; }
|
||||
.row-between { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.stack { display: flex; flex-direction: column; gap: 14px; }
|
||||
.muted { color: var(--muted); }
|
||||
.num { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||
.meta { font-family: var(--font-mono); font-size: 12px; color: var(--muted); }
|
||||
.mt-0 { margin-top: 0; }
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.grid-4, .grid-5 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.grid-3, .grid-2-1, .grid-1-2, .grid-3-2 { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.shell { grid-template-columns: 1fr; }
|
||||
.sidebar { position: static; height: auto; }
|
||||
.grid-2 { grid-template-columns: 1fr; }
|
||||
.content { padding: 16px 16px 48px; }
|
||||
}
|
||||
|
||||
/* ─── 数据表 ────────────────────────────────────────────────────── */
|
||||
.table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--surface); }
|
||||
.ds-table { width: 100%; border-collapse: collapse; font-size: 13px; min-width: 640px; }
|
||||
.ds-table th, .ds-table td { padding: 9px 14px; text-align: left; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
||||
.ds-table th {
|
||||
color: var(--muted); font-weight: 550;
|
||||
font-family: var(--font-mono); font-size: 11px;
|
||||
letter-spacing: 0.05em;
|
||||
background: var(--bg);
|
||||
position: sticky; top: 0;
|
||||
}
|
||||
.ds-table tbody tr:hover { background: var(--fg-soft); }
|
||||
.ds-table tbody tr:last-child td { border-bottom: 0; }
|
||||
.ds-table .num-col { font-family: var(--font-mono); font-variant-numeric: tabular-nums; text-align: right; }
|
||||
.ds-table td.wrap, .ds-table th.wrap { white-space: normal; min-width: 180px; }
|
||||
.ds-table .cell-main { font-weight: 550; }
|
||||
.ds-table .cell-sub { display: block; font-family: var(--font-mono); font-size: 11px; color: var(--muted); margin-top: 1px; }
|
||||
.ds-table tr.clickable { cursor: pointer; }
|
||||
.ds-table .amt-in { color: var(--success); }
|
||||
.ds-table .amt-out { color: var(--danger); }
|
||||
.table-foot { display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; border-top: 1px solid var(--border); font-size: 12px; color: var(--muted); }
|
||||
|
||||
/* ─── 徽章 / 状态 ───────────────────────────────────────────────── */
|
||||
.pill {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 11.5px; font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pill::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
|
||||
.pill-success { background: var(--success-soft); color: var(--success); }
|
||||
.pill-warn { background: var(--warn-soft); color: color-mix(in oklch, var(--warn) 80%, black); }
|
||||
.pill-danger { background: var(--danger-soft); color: var(--danger); }
|
||||
.pill-info { background: var(--info-soft); color: var(--info); }
|
||||
.pill-muted { background: var(--fg-soft); color: var(--muted); }
|
||||
.pill-accent { background: var(--accent-soft); color: var(--accent); }
|
||||
|
||||
.tag {
|
||||
display: inline-flex; align-items: center;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ─── 筛选栏与表单 ──────────────────────────────────────────────── */
|
||||
.filters {
|
||||
display: flex; align-items: flex-end; gap: 12px; flex-wrap: wrap;
|
||||
padding: 14px 16px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.field { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
|
||||
.field > label { font-size: 12px; color: var(--muted); font-weight: 550; }
|
||||
.input, .select, .textarea, .field .input, .field .select, .field .textarea {
|
||||
padding: 7px 11px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
min-height: 34px;
|
||||
}
|
||||
.input:focus, .select:focus, .textarea:focus, .field .input:focus, .field .select:focus, .field .textarea:focus {
|
||||
outline: 2px solid var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.input.num-input { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||
.textarea { min-height: 84px; resize: vertical; line-height: 1.55; }
|
||||
.field .hint { font-size: 11.5px; color: var(--muted); }
|
||||
.field .hint.error { color: var(--danger); }
|
||||
|
||||
/* ─── Tabs ──────────────────────────────────────────────────────── */
|
||||
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); margin-bottom: 16px; overflow-x: auto; }
|
||||
.tabs button {
|
||||
background: none; border: 0;
|
||||
padding: 9px 14px;
|
||||
font-size: 13px; font-weight: 550;
|
||||
color: var(--muted);
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tabs button:hover { color: var(--fg); }
|
||||
.tabs button.active { color: var(--fg); border-bottom-color: var(--accent); }
|
||||
.tabs button .tab-count {
|
||||
font-family: var(--font-mono); font-size: 10.5px;
|
||||
padding: 1px 6px; border-radius: 999px;
|
||||
background: var(--fg-soft); color: var(--muted);
|
||||
margin-left: 5px;
|
||||
}
|
||||
.tabs button.active .tab-count { background: var(--accent-soft); color: var(--accent); }
|
||||
|
||||
/* ─── 时间轴(账期) ─────────────────────────────────────────────── */
|
||||
.timeline { display: flex; gap: 0; border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; background: var(--surface); padding: 0; }
|
||||
.tl-cell { flex: 1; padding: 12px 10px; border-right: 1px solid var(--border); text-align: center; min-width: 0; }
|
||||
.tl-cell:last-child { border-right: 0; }
|
||||
.tl-cell .tl-month { font-family: var(--font-mono); font-size: 11px; color: var(--muted); letter-spacing: 0.04em; }
|
||||
.tl-cell .tl-state { font-size: 12px; font-weight: 600; margin-top: 4px; display: flex; align-items: center; justify-content: center; gap: 5px; }
|
||||
.tl-cell.closed { background: var(--fg-soft); }
|
||||
.tl-cell.closed .tl-state { color: var(--muted); }
|
||||
.tl-cell.current { background: var(--accent-soft); }
|
||||
.tl-cell.current .tl-state { color: var(--accent); }
|
||||
.tl-cell.open .tl-state { color: var(--fg); }
|
||||
|
||||
/* ─── 进度条 ────────────────────────────────────────────────────── */
|
||||
.progress { height: 6px; border-radius: 999px; background: var(--fg-soft); overflow: hidden; }
|
||||
.progress > span { display: block; height: 100%; border-radius: 999px; background: var(--accent); }
|
||||
.progress.warn > span { background: var(--warn); }
|
||||
.progress.danger > span { background: var(--danger); }
|
||||
|
||||
/* ─── 列表行(快查 / 待办 / 通知) ────────────────────────────────── */
|
||||
.list-row {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 10px 4px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
}
|
||||
.list-row:last-child { border-bottom: 0; }
|
||||
.list-row .lr-main { min-width: 0; flex: 1; }
|
||||
.list-row .lr-title { font-weight: 550; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.list-row .lr-sub { font-size: 12px; color: var(--muted); margin-top: 1px; }
|
||||
.list-row .lr-side { text-align: right; flex: none; }
|
||||
[data-detail] { cursor: pointer; }
|
||||
.list-row[data-detail]:hover { background: var(--fg-soft); }
|
||||
.list-row[data-detail] { margin-inline: -4px; padding-inline: 4px; border-radius: var(--radius); }
|
||||
|
||||
/* ─── 弹窗 ──────────────────────────────────────────────────────── */
|
||||
.modal-backdrop {
|
||||
position: fixed; inset: 0; z-index: 50;
|
||||
background: color-mix(in oklch, var(--fg) 45%, transparent);
|
||||
display: none;
|
||||
align-items: center; justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.modal-backdrop.open { display: flex; }
|
||||
.modal {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border);
|
||||
width: 100%; max-width: 560px;
|
||||
max-height: 86vh; overflow-y: auto;
|
||||
padding: 22px 24px;
|
||||
box-shadow: 0 18px 50px color-mix(in oklch, var(--fg) 25%, transparent);
|
||||
}
|
||||
.modal.wide { max-width: 760px; }
|
||||
.modal-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; }
|
||||
.modal-title { font-size: 16px; font-weight: 700; }
|
||||
.modal-close { background: none; border: 0; color: var(--muted); font-size: 18px; padding: 4px 8px; border-radius: 6px; }
|
||||
.modal-close:hover { background: var(--fg-soft); color: var(--fg); }
|
||||
.modal-sub { font-size: 12.5px; color: var(--muted); margin-bottom: 16px; }
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }
|
||||
|
||||
/* ─── 明细展开行 ────────────────────────────────────────────────── */
|
||||
.detail-box {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 14px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.kv { display: grid; grid-template-columns: auto 1fr; gap: 4px 18px; font-size: 12.5px; }
|
||||
.kv dt { color: var(--muted); }
|
||||
.kv dd { margin: 0; font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* ─── 提示条 ────────────────────────────────────────────────────── */
|
||||
.notice {
|
||||
display: flex; gap: 10px; align-items: flex-start;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.notice.warn { background: var(--warn-soft); border-color: color-mix(in oklch, var(--warn) 30%, transparent); }
|
||||
.notice.danger { background: var(--danger-soft); border-color: color-mix(in oklch, var(--danger) 30%, transparent); }
|
||||
.notice.info { background: var(--info-soft); border-color: color-mix(in oklch, var(--info) 25%, transparent); }
|
||||
.notice .n-title { font-weight: 650; }
|
||||
.notice .n-body { color: color-mix(in oklch, var(--fg) 80%, var(--muted)); margin-top: 2px; font-size: 12.5px; }
|
||||
|
||||
/* ─── 登录页 ────────────────────────────────────────────────────── */
|
||||
.login-wrap {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.login-aside {
|
||||
background: var(--fg);
|
||||
color: var(--surface);
|
||||
padding: 48px 56px;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.login-aside .brand-mark { font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.1em; opacity: 0.65; }
|
||||
.login-aside h1 { font-size: 30px; font-weight: 700; letter-spacing: -0.02em; margin-top: 18px; line-height: 1.25; }
|
||||
.login-aside .aside-sub { opacity: 0.72; font-size: 14px; margin-top: 14px; max-width: 40ch; }
|
||||
.login-aside .aside-list { margin-top: auto; display: flex; flex-direction: column; gap: 12px; }
|
||||
.login-aside .aside-item { display: flex; gap: 10px; font-size: 13px; opacity: 0.85; align-items: baseline; }
|
||||
.login-aside .aside-item .tick { font-family: var(--font-mono); color: var(--accent); }
|
||||
.login-panel { display: grid; place-items: center; padding: 48px 32px; }
|
||||
.login-card { width: 100%; max-width: 400px; }
|
||||
.login-card h2 { font-size: 20px; font-weight: 700; letter-spacing: -0.01em; }
|
||||
.login-card .login-sub { color: var(--muted); font-size: 13px; margin: 6px 0 24px; }
|
||||
.role-switch { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 18px; }
|
||||
.role-switch button {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 10px;
|
||||
text-align: left;
|
||||
transition: border-color 0.12s ease, background 0.12s ease;
|
||||
}
|
||||
.role-switch button .r-name { font-size: 13.5px; font-weight: 650; }
|
||||
.role-switch button .r-desc { font-size: 11.5px; color: var(--muted); margin-top: 2px; }
|
||||
.role-switch button.active { border-color: var(--accent); background: var(--accent-soft); }
|
||||
.role-switch button.active .r-name { color: var(--accent); }
|
||||
.login-card .field { margin-bottom: 14px; }
|
||||
.login-foot { margin-top: 16px; font-size: 12px; color: var(--muted); text-align: center; }
|
||||
.login-foot a { color: var(--accent); font-weight: 550; }
|
||||
.login-foot a:hover { text-decoration: underline; }
|
||||
@media (max-width: 900px) {
|
||||
.login-wrap { grid-template-columns: 1fr; }
|
||||
.login-aside { display: none; }
|
||||
}
|
||||
|
||||
/* ─── 空状态 ────────────────────────────────────────────────────── */
|
||||
.empty {
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.empty .e-title { font-weight: 600; color: var(--fg); margin-bottom: 4px; }
|
||||
|
||||
/* ─── 账期流程条(公司端工作台) ─────────────────────────────────── */
|
||||
.flow { display: grid; grid-template-columns: repeat(5, 1fr); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; background: var(--surface); }
|
||||
.flow-step { padding: 14px 16px 13px; border-right: 1px solid var(--border); min-width: 0; }
|
||||
.flow-step:last-child { border-right: 0; }
|
||||
a.flow-step { cursor: pointer; transition: background 0.12s ease; }
|
||||
a.flow-step:hover { background: var(--fg-soft); }
|
||||
.flow-step .fs-top { display: flex; align-items: center; gap: 8px; }
|
||||
.flow-step .fs-idx { font-family: var(--font-mono); font-size: 11px; color: var(--muted); }
|
||||
.flow-step .fs-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--border); flex: none; }
|
||||
.flow-step .fs-name { font-size: 13.5px; font-weight: 650; }
|
||||
.flow-step .fs-state { font-size: 12px; font-weight: 600; margin-top: 7px; color: var(--muted); }
|
||||
.flow-step .fs-meta { font-size: 12px; color: var(--muted); margin-top: 2px; }
|
||||
.flow-step.done .fs-dot { background: var(--success); }
|
||||
.flow-step.done .fs-state { color: var(--success); }
|
||||
.flow-step.part .fs-dot { background: var(--warn); }
|
||||
.flow-step.part .fs-state { color: color-mix(in oklch, var(--warn) 78%, black); }
|
||||
.flow-step.doing { background: var(--warn-soft); }
|
||||
.flow-step.doing .fs-dot { background: var(--warn); box-shadow: 0 0 0 3px color-mix(in oklch, var(--warn) 22%, transparent); }
|
||||
.flow-step.doing .fs-state { color: color-mix(in oklch, var(--warn) 78%, black); }
|
||||
a.flow-step.doing:hover { background: color-mix(in oklch, var(--warn) 16%, transparent); }
|
||||
@media (max-width: 860px) {
|
||||
.flow { grid-template-columns: 1fr; }
|
||||
.flow-step { border-right: 0; border-bottom: 1px solid var(--border); }
|
||||
.flow-step:last-child { border-bottom: 0; }
|
||||
}
|
||||
|
||||
/* ─── 迷你指标(2×2 数字块) ────────────────────────────────────── */
|
||||
.mini-stats { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.mini-stat { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); padding: 11px 13px; min-width: 0; }
|
||||
.mini-stat .ms-label { font-size: 11.5px; color: var(--muted); }
|
||||
.mini-stat .ms-value { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: 17px; font-weight: 650; letter-spacing: -0.01em; margin-top: 3px; }
|
||||
.mini-stat .ms-value .unit { font-size: 11px; font-weight: 400; color: var(--muted); margin-left: 2px; }
|
||||
.mini-stat .ms-value.pos { color: var(--success); }
|
||||
.mini-stat .ms-value.neg { color: var(--danger); }
|
||||
|
||||
/* ─── 上传组件(拖放区 / 文件条 / 附件选择) ────────────────────── */
|
||||
.dropzone {
|
||||
border: 1.5px dashed color-mix(in oklch, var(--fg) 28%, var(--border));
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg);
|
||||
padding: 30px 20px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s ease, background 0.12s ease;
|
||||
}
|
||||
.dropzone:hover, .dropzone:focus-visible { border-color: var(--accent); background: var(--accent-soft); }
|
||||
.dropzone.dragover { border-color: var(--accent); border-style: solid; background: var(--accent-soft); }
|
||||
.dropzone svg { width: 28px; height: 28px; margin: 0 auto 10px; color: var(--muted); }
|
||||
.dropzone .dz-title { font-size: 14px; font-weight: 650; }
|
||||
.dropzone .dz-sub { font-size: 12.5px; color: var(--muted); margin-top: 5px; }
|
||||
.dropzone .dz-sub .dz-browse { color: var(--accent); font-weight: 600; text-decoration: underline; text-underline-offset: 3px; }
|
||||
.dropzone .dz-formats { font-family: var(--font-mono); font-size: 11px; color: var(--muted); margin-top: 12px; letter-spacing: 0.02em; }
|
||||
|
||||
.file-chip {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
}
|
||||
.file-chip svg { width: 18px; height: 18px; color: var(--muted); flex: none; }
|
||||
.file-chip .fc-info { min-width: 0; flex: 1; }
|
||||
.file-chip .fc-name { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.file-chip .fc-meta { font-family: var(--font-mono); font-size: 11px; color: var(--muted); margin-top: 1px; }
|
||||
.file-chip .fc-remove { background: none; border: 0; color: var(--muted); font-size: 16px; line-height: 1; padding: 4px 8px; border-radius: 6px; flex: none; }
|
||||
.file-chip .fc-remove:hover { color: var(--danger); background: var(--danger-soft); }
|
||||
|
||||
.attach-pick {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
padding: 7px 12px;
|
||||
min-height: 34px;
|
||||
border: 1.5px dashed color-mix(in oklch, var(--fg) 28%, var(--border));
|
||||
border-radius: var(--radius);
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s ease, color 0.12s ease, background 0.12s ease;
|
||||
}
|
||||
.attach-pick svg { width: 15px; height: 15px; }
|
||||
.attach-pick:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
|
||||
|
||||
/* ─── 数据表合计行 ─────────────────────────────────────────────── */
|
||||
.ds-table tfoot td { background: var(--bg); font-weight: 650; border-top: 1.5px solid color-mix(in oklch, var(--fg) 30%, var(--border)); border-bottom: 0; }
|
||||
|
||||
/* ─── 入口页(index) ────────────────────────────────────────────── */
|
||||
.portal-wrap { min-height: 100vh; display: grid; place-items: center; padding: 40px 24px; }
|
||||
.portal-inner { width: 100%; max-width: 880px; }
|
||||
.portal-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 28px; }
|
||||
.portal-card {
|
||||
display: block;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 24px;
|
||||
transition: border-color 0.12s ease, transform 0.12s ease;
|
||||
}
|
||||
.portal-card:hover { border-color: var(--accent); transform: translateY(-2px); }
|
||||
.portal-card .pc-role { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.08em; color: var(--accent); }
|
||||
.portal-card h3 { font-size: 17px; font-weight: 700; margin-top: 8px; }
|
||||
.portal-card p { color: var(--muted); font-size: 13px; margin-top: 6px; }
|
||||
.portal-card .pc-go { font-size: 12.5px; font-weight: 600; color: var(--accent); margin-top: 14px; }
|
||||
@media (max-width: 720px) { .portal-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
/* ─── SPA 视图切换 / 无障碍结构类 ───────────────────────────────── */
|
||||
.app-view { display: none; }
|
||||
.app-view.is-active { display: block; }
|
||||
.sr-only { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
.skip-link {
|
||||
position: fixed; top: 8px; left: 50%; z-index: 500;
|
||||
padding: 8px 14px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--accent); color: var(--surface);
|
||||
font-size: 13px; font-weight: 600;
|
||||
transform: translate(-50%, -150%);
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
.skip-link:focus { transform: translate(-50%, 0); }
|
||||
|
||||
/* ─── 图标按钮 / 窄屏侧栏折叠 ──────────────────────────────────── */
|
||||
.icon-button {
|
||||
display: inline-grid; place-items: center;
|
||||
width: 34px; height: 34px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface); color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s ease, background 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
.icon-button:hover { color: var(--fg); border-color: color-mix(in oklch, var(--fg) 40%, var(--border)); background: var(--fg-soft); }
|
||||
.icon-button svg { width: 18px; height: 18px; }
|
||||
.menu-button { display: none; }
|
||||
@media (max-width: 860px) {
|
||||
.menu-button { display: inline-grid; }
|
||||
.shell { grid-template-columns: 1fr; }
|
||||
.sidebar {
|
||||
position: fixed; top: 0; left: 0; bottom: 0; z-index: 40;
|
||||
width: var(--side-w); height: 100dvh;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.sidebar.is-open { transform: none; box-shadow: 0 0 48px color-mix(in oklch, var(--fg) 22%, transparent); }
|
||||
}
|
||||
|
||||
/* ─── Toast 轻量提示 ───────────────────────────────────────────── */
|
||||
.toast-region { position: fixed; right: 16px; bottom: 16px; z-index: 300; display: grid; gap: 8px; }
|
||||
.toast {
|
||||
display: flex; align-items: flex-start; gap: 9px;
|
||||
min-width: 260px; max-width: 380px;
|
||||
padding: 11px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface);
|
||||
box-shadow: 0 12px 34px color-mix(in oklch, var(--fg) 18%, transparent);
|
||||
font-size: 13px;
|
||||
}
|
||||
.toast .t-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; margin-top: 5px; background: var(--muted); }
|
||||
.toast .t-body { min-width: 0; }
|
||||
.toast .t-title { font-weight: 650; }
|
||||
.toast .t-detail { color: var(--muted); font-size: 12px; margin-top: 1px; }
|
||||
.toast.success { border-color: color-mix(in oklch, var(--success) 35%, var(--border)); }
|
||||
.toast.success .t-dot { background: var(--success); }
|
||||
.toast.warn { border-color: color-mix(in oklch, var(--warn) 40%, var(--border)); }
|
||||
.toast.warn .t-dot { background: var(--warn); }
|
||||
.toast.danger { border-color: color-mix(in oklch, var(--danger) 35%, var(--border)); }
|
||||
.toast.danger .t-dot { background: var(--danger); }
|
||||
.toast.info { border-color: color-mix(in oklch, var(--info) 35%, var(--border)); }
|
||||
.toast.info .t-dot { background: var(--info); }
|
||||
@media (max-width: 720px) {
|
||||
.toast-region { right: 12px; bottom: 12px; left: 12px; }
|
||||
.toast { min-width: 0; max-width: none; }
|
||||
}
|
||||
|
||||
/* ─── 侧滑详情抽屉(用 modal/card token 重画) ─────────────────── */
|
||||
.drawer {
|
||||
position: fixed; top: 0; right: 0; bottom: 0; z-index: 60;
|
||||
width: min(400px, calc(100vw - 24px));
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--surface);
|
||||
border-left: 1px solid var(--border);
|
||||
box-shadow: -18px 0 50px color-mix(in oklch, var(--fg) 22%, transparent);
|
||||
transform: translateX(100%);
|
||||
visibility: hidden;
|
||||
transition: transform 0.22s ease, visibility 0.22s;
|
||||
}
|
||||
.drawer.is-open { transform: none; visibility: visible; }
|
||||
.drawer-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 18px 20px 12px; border-bottom: 1px solid var(--border); }
|
||||
.drawer-head .d-title { font-size: 16px; font-weight: 700; margin-top: 6px; }
|
||||
.drawer-head .d-desc { color: var(--muted); font-size: 12.5px; margin-top: 4px; }
|
||||
.drawer-body { flex: 1; overflow-y: auto; padding: 16px 20px; }
|
||||
.drawer-body .kv { grid-template-columns: auto 1fr; gap: 8px 18px; }
|
||||
.drawer-body .kv dt { color: var(--muted); white-space: nowrap; }
|
||||
.drawer-body .kv dd { text-align: right; word-break: break-all; }
|
||||
.drawer-tip { margin: 0 20px 12px; padding: 12px 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); color: var(--muted); font-size: 12.5px; }
|
||||
.drawer-tip strong { display: block; color: var(--fg); margin-bottom: 4px; }
|
||||
.drawer-foot { display: flex; justify-content: flex-end; gap: 8px; padding: 14px 20px; border-top: 1px solid var(--border); }
|
||||
|
||||
/* ─── 加载中骨架 / 表内加载行 ──────────────────────────────────── */
|
||||
.skeleton { display: block; border-radius: var(--radius); background: var(--fg-soft); position: relative; overflow: hidden; }
|
||||
.skeleton::after {
|
||||
content: ""; position: absolute; inset: 0;
|
||||
background: linear-gradient(90deg, transparent, color-mix(in oklch, var(--surface) 60%, transparent), transparent);
|
||||
animation: skeleton-shimmer 1.3s ease-in-out infinite;
|
||||
}
|
||||
@keyframes skeleton-shimmer { from { transform: translateX(-100%); } to { transform: translateX(100%); } }
|
||||
.skeleton-line { height: 12px; margin-bottom: 10px; }
|
||||
.loading-row { padding: 40px 20px; text-align: center; color: var(--muted); font-family: var(--font-mono); font-size: 12.5px; letter-spacing: 0.04em; }
|
||||
.loading-inline { display: inline-flex; align-items: center; gap: 8px; color: var(--muted); font-family: var(--font-mono); font-size: 12.5px; }
|
||||
.loading-inline::before {
|
||||
content: ""; width: 12px; height: 12px; border-radius: 50%;
|
||||
border: 2px solid var(--border); border-top-color: var(--accent);
|
||||
animation: loading-spin 0.7s linear infinite;
|
||||
}
|
||||
@keyframes loading-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ─── 提示条补充:成功态 ───────────────────────────────────────── */
|
||||
.notice.success { background: var(--success-soft); border-color: color-mix(in oklch, var(--success) 30%, transparent); }
|
||||
.notice.success .n-title { color: color-mix(in oklch, var(--success) 82%, black); }
|
||||
+29
-71
@@ -1,75 +1,33 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="河南金牛实业集团有限公司资金往来管理系统" />
|
||||
<title>登录 · 河南金牛实业集团有限公司资金往来管理系统</title>
|
||||
<link rel="stylesheet" href="styles.css?v=2" />
|
||||
</head>
|
||||
<body class="entry-page">
|
||||
<main class="entry-shell">
|
||||
<header class="entry-topbar">
|
||||
<div class="entry-brand">
|
||||
<span class="entry-seal" aria-hidden="true">金</span>
|
||||
<span class="entry-system-name">河南金牛实业集团有限公司资金往来管理系统</span>
|
||||
</div>
|
||||
</header>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>金牛实业资金往来管理系统 · 入口</title>
|
||||
<link rel="stylesheet" href="design-system.css?v=4" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="portal-wrap">
|
||||
<div class="portal-inner">
|
||||
<p class="meta" style="letter-spacing: 0.1em;">JINNIU GROUP · INTERCOMPANY TREASURY</p>
|
||||
<h1 style="font-size: 26px; font-weight: 700; letter-spacing: -0.015em; margin-top: 10px;">河南金牛实业集团有限公司 · 资金往来管理系统</h1>
|
||||
<p class="muted" style="margin-top: 10px; max-width: 64ch;">集团内部公司间资金往来记账平台。银行流水导入后自动轧算往来余额,支持从集团汇总逐级穿透至银行原始流水。本系统包含总账管理端与公司业务端两套界面。</p>
|
||||
|
||||
<section class="entry-context" aria-labelledby="entry-heading">
|
||||
<div class="entry-statement">
|
||||
<h1 id="entry-heading">集团资金往来管理</h1>
|
||||
<p>集团内部资金往来登记、核对及台账查询入口</p>
|
||||
</div>
|
||||
<svg class="entry-figure" viewBox="0 0 520 300" aria-hidden="true" focusable="false" preserveAspectRatio="xMidYMax meet">
|
||||
<path class="fig-arc" d="M 12 292 C 150 150, 370 150, 508 292" />
|
||||
<rect class="fig-bar" x="96" y="150" width="34" height="140" rx="6" />
|
||||
<rect class="fig-bar" x="172" y="120" width="34" height="170" rx="6" />
|
||||
<rect class="fig-bar" x="248" y="178" width="34" height="112" rx="6" />
|
||||
<rect class="fig-bar" x="324" y="100" width="34" height="190" rx="6" />
|
||||
<rect class="fig-bar" x="400" y="60" width="34" height="230" rx="6" />
|
||||
<polyline class="fig-line" points="113,138 189,108 265,166 341,88 417,48" />
|
||||
</svg>
|
||||
</section>
|
||||
|
||||
<section class="entry-form-wrap" aria-labelledby="login-title">
|
||||
<form class="entry-form" id="loginForm">
|
||||
<header>
|
||||
<h2 id="login-title">欢迎登录</h2>
|
||||
<p>请选择登录入口,使用账号和密码进入系统</p>
|
||||
</header>
|
||||
|
||||
<p class="entry-note" id="loginError" role="alert" hidden></p>
|
||||
|
||||
<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" 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>
|
||||
|
||||
<div class="entry-options">
|
||||
<label class="check-field"><input type="checkbox" checked />记住登录入口</label>
|
||||
<a class="entry-hint" href="#changePassword">首次登录需修改密码</a>
|
||||
</div>
|
||||
|
||||
<button class="button primary wide" type="submit">
|
||||
<span class="spinner" aria-hidden="true"></span>
|
||||
<span id="loginAction">进入总账管理端</span>
|
||||
<svg class="submit-arrow"><use href="icons.svg#chevron-right"/></svg>
|
||||
</button>
|
||||
|
||||
<p class="entry-compliance">登录即表示你已阅读并遵守集团资金往来管理规范</p>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
<script src="app.js?v=2"></script>
|
||||
</body>
|
||||
<div class="portal-grid">
|
||||
<a class="portal-card" href="login-admin.html">
|
||||
<span class="pc-role">总账管理端 · 7 个页面</span>
|
||||
<h3>管理总览 / 往来查询 / 审核中心 / 流水管理 / 公司与账号 / 结账与期初 / 提醒管理</h3>
|
||||
<p>面向集团总账会计:全局监控各公司流水提交与往来余额,集中处理审核事项,执行月度结账。</p>
|
||||
<span class="pc-go">去管理端登录 →</span>
|
||||
</a>
|
||||
<a class="portal-card" href="login-company.html">
|
||||
<span class="pc-role">公司业务端 · 7 个页面</span>
|
||||
<h3>工作台 / 流水导入 / 手工记录 / 流水管理 / 往来确认 / 银行账户 / 通知</h3>
|
||||
<p>面向成员公司出纳:上传银行流水、登记手工往来、确认单边匹配与科目,跟踪本月完成进度。</p>
|
||||
<span class="pc-go">去公司端登录 →</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>登录 · 金牛实业资金往来管理系统</title>
|
||||
<link rel="stylesheet" href="design-system.css?v=4" />
|
||||
</head>
|
||||
<body data-role="admin">
|
||||
<div class="login-wrap">
|
||||
<aside class="login-aside">
|
||||
<span class="brand-mark">JINNIU GROUP · TREASURY</span>
|
||||
<h1>河南金牛实业集团有限公司<br />资金往来管理系统</h1>
|
||||
<p class="aside-sub">集团内部各公司之间资金往来的统一记账平台。导入银行流水后自动轧算公司间往来余额,从集团汇总可层层下钻至银行原始流水。</p>
|
||||
<div class="aside-list">
|
||||
<div class="aside-item"><span class="tick">01</span><span>银行流水自动归集,公司间往来余额实时轧算</span></div>
|
||||
<div class="aside-item"><span class="tick">02</span><span>单边匹配、流水断档、科目确认集中审核</span></div>
|
||||
<div class="aside-item"><span class="tick">03</span><span>月度结账检查:流水提交、账户连续、审核完成</span></div>
|
||||
<div class="aside-item"><span class="tick">04</span><span>汇总 → 公司 → 账户 → 原始流水,四级穿透追溯</span></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="login-panel">
|
||||
<div class="login-card">
|
||||
<h2>登录系统</h2>
|
||||
<p class="login-sub">请输入账号密码登录总账管理端。首次登录需修改初始密码。</p>
|
||||
|
||||
<form id="login-form" novalidate>
|
||||
<div class="field">
|
||||
<label for="account">账号</label>
|
||||
<input class="input" id="account" placeholder="请输入账号" autocomplete="username" />
|
||||
<span class="hint error" id="account-err" hidden>请输入账号</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">密码</label>
|
||||
<input class="input" id="password" type="password" placeholder="请输入密码" autocomplete="current-password" />
|
||||
<span class="hint error" id="password-err" hidden>请输入密码</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="hint error" id="login-err" role="alert" hidden></span>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit" style="width: 100%; margin-top: 6px;">登 录</button>
|
||||
</form>
|
||||
|
||||
<p class="login-foot">忘记密码请联系集团总账管理员重置 · <a href="index.html">返回入口页</a></p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div class="modal-backdrop" id="pwd-modal">
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="pwd-title">
|
||||
<div class="modal-head">
|
||||
<span class="modal-title" id="pwd-title">首次登录 · 修改初始密码</span>
|
||||
</div>
|
||||
<p class="modal-sub">为保障资金安全,首次登录必须修改初始密码。新密码需 8 位以上,且包含字母与数字。</p>
|
||||
<div class="field" style="margin-bottom: 12px;">
|
||||
<label for="new-pwd">新密码</label>
|
||||
<input class="input" id="new-pwd" type="password" placeholder="8 位以上,含字母与数字" autocomplete="new-password" />
|
||||
<span class="hint error" id="new-pwd-err" hidden>密码需 8 位以上,且包含字母与数字</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="new-pwd2">确认新密码</label>
|
||||
<input class="input" id="new-pwd2" type="password" placeholder="再次输入新密码" autocomplete="new-password" />
|
||||
<span class="hint error" id="new-pwd2-err" hidden>两次输入的密码不一致</span>
|
||||
</div>
|
||||
<div class="field" style="margin-top: 12px;">
|
||||
<span class="hint error" id="pwd-err" role="alert" hidden></span>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-primary" id="pwd-confirm">确认修改并进入系统</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="login.js?v=4"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,77 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>登录 · 金牛实业资金往来管理系统</title>
|
||||
<link rel="stylesheet" href="design-system.css?v=4" />
|
||||
</head>
|
||||
<body data-role="company">
|
||||
<div class="login-wrap">
|
||||
<aside class="login-aside">
|
||||
<span class="brand-mark">JINNIU GROUP · TREASURY</span>
|
||||
<h1>河南金牛实业集团有限公司<br />资金往来管理系统</h1>
|
||||
<p class="aside-sub">集团内部各公司之间资金往来的统一记账平台。导入银行流水后自动轧算公司间往来余额,从集团汇总可层层下钻至银行原始流水。</p>
|
||||
<div class="aside-list">
|
||||
<div class="aside-item"><span class="tick">01</span><span>银行流水自动归集,公司间往来余额实时轧算</span></div>
|
||||
<div class="aside-item"><span class="tick">02</span><span>单边匹配、流水断档、科目确认集中审核</span></div>
|
||||
<div class="aside-item"><span class="tick">03</span><span>月度结账检查:流水提交、账户连续、审核完成</span></div>
|
||||
<div class="aside-item"><span class="tick">04</span><span>汇总 → 公司 → 账户 → 原始流水,四级穿透追溯</span></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="login-panel">
|
||||
<div class="login-card">
|
||||
<h2>登录系统</h2>
|
||||
<p class="login-sub">请输入账号密码登录公司业务端。首次登录需修改初始密码。</p>
|
||||
|
||||
<form id="login-form" novalidate>
|
||||
<div class="field">
|
||||
<label for="account">账号</label>
|
||||
<input class="input" id="account" placeholder="请输入账号" autocomplete="username" />
|
||||
<span class="hint error" id="account-err" hidden>请输入账号</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">密码</label>
|
||||
<input class="input" id="password" type="password" placeholder="请输入密码" autocomplete="current-password" />
|
||||
<span class="hint error" id="password-err" hidden>请输入密码</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="hint error" id="login-err" role="alert" hidden></span>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit" style="width: 100%; margin-top: 6px;">登 录</button>
|
||||
</form>
|
||||
|
||||
<p class="login-foot">忘记密码请联系集团总账管理员重置 · <a href="index.html">返回入口页</a></p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div class="modal-backdrop" id="pwd-modal">
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="pwd-title">
|
||||
<div class="modal-head">
|
||||
<span class="modal-title" id="pwd-title">首次登录 · 修改初始密码</span>
|
||||
</div>
|
||||
<p class="modal-sub">为保障资金安全,首次登录必须修改初始密码。新密码需 8 位以上,且包含字母与数字。</p>
|
||||
<div class="field" style="margin-bottom: 12px;">
|
||||
<label for="new-pwd">新密码</label>
|
||||
<input class="input" id="new-pwd" type="password" placeholder="8 位以上,含字母与数字" autocomplete="new-password" />
|
||||
<span class="hint error" id="new-pwd-err" hidden>密码需 8 位以上,且包含字母与数字</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="new-pwd2">确认新密码</label>
|
||||
<input class="input" id="new-pwd2" type="password" placeholder="再次输入新密码" autocomplete="new-password" />
|
||||
<span class="hint error" id="new-pwd2-err" hidden>两次输入的密码不一致</span>
|
||||
</div>
|
||||
<div class="field" style="margin-top: 12px;">
|
||||
<span class="hint error" id="pwd-err" role="alert" hidden></span>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-primary" id="pwd-confirm">确认修改并进入系统</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="login.js?v=4"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,96 @@
|
||||
const ROLE = document.body.dataset.role || "admin";
|
||||
const REDIRECT = ROLE === "admin" ? "admin.html" : "company.html";
|
||||
|
||||
const form = document.getElementById("login-form");
|
||||
const accountInput = document.getElementById("account");
|
||||
const passwordInput = document.getElementById("password");
|
||||
const accountErr = document.getElementById("account-err");
|
||||
const passwordErr = document.getElementById("password-err");
|
||||
const loginErr = document.getElementById("login-err");
|
||||
const submitButton = form.querySelector('button[type="submit"]');
|
||||
|
||||
const modal = document.getElementById("pwd-modal");
|
||||
const newPwd = document.getElementById("new-pwd");
|
||||
const newPwd2 = document.getElementById("new-pwd2");
|
||||
const newPwdErr = document.getElementById("new-pwd-err");
|
||||
const newPwd2Err = document.getElementById("new-pwd2-err");
|
||||
const pwdErr = document.getElementById("pwd-err");
|
||||
const pwdConfirm = document.getElementById("pwd-confirm");
|
||||
|
||||
const submitLabel = "登 录";
|
||||
|
||||
function setLoading(loading) {
|
||||
submitButton.disabled = loading;
|
||||
submitButton.textContent = loading ? "登录中…" : submitLabel;
|
||||
accountInput.disabled = loading;
|
||||
passwordInput.disabled = loading;
|
||||
}
|
||||
|
||||
function showLoginError(message) {
|
||||
loginErr.textContent = message || "";
|
||||
loginErr.hidden = !message;
|
||||
}
|
||||
|
||||
function openPasswordModal() {
|
||||
newPwd.value = "";
|
||||
newPwd2.value = "";
|
||||
newPwdErr.hidden = true;
|
||||
newPwd2Err.hidden = true;
|
||||
pwdErr.hidden = true;
|
||||
modal.classList.add("open");
|
||||
newPwd.focus();
|
||||
}
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
showLoginError("");
|
||||
const account = accountInput.value.trim();
|
||||
const password = passwordInput.value;
|
||||
accountErr.hidden = Boolean(account);
|
||||
passwordErr.hidden = Boolean(password);
|
||||
if (!account || !password) return;
|
||||
|
||||
setLoading(true);
|
||||
const response = await fetch("/api/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: account, password, portal: ROLE }),
|
||||
}).catch(() => null);
|
||||
const result = await response?.json().catch(() => ({}));
|
||||
if (!response || !response.ok) {
|
||||
showLoginError(result?.message || "登录服务暂时不可用,请稍后重试。");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (result.must_change_password) {
|
||||
setLoading(false);
|
||||
openPasswordModal();
|
||||
return;
|
||||
}
|
||||
window.location.href = REDIRECT;
|
||||
});
|
||||
|
||||
pwdConfirm.addEventListener("click", async () => {
|
||||
const p1 = newPwd.value;
|
||||
const p2 = newPwd2.value;
|
||||
const valid = p1.length >= 8 && /[a-zA-Z]/.test(p1) && /\d/.test(p1);
|
||||
newPwdErr.hidden = valid;
|
||||
newPwd2Err.hidden = p1 === p2;
|
||||
pwdErr.hidden = true;
|
||||
if (!valid || p1 !== p2) return;
|
||||
|
||||
pwdConfirm.disabled = true;
|
||||
const response = await fetch("/api/password/change", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ old_password: passwordInput.value, new_password: p1 }),
|
||||
}).catch(() => null);
|
||||
const result = await response?.json().catch(() => ({}));
|
||||
if (!response || !response.ok) {
|
||||
pwdErr.textContent = result?.message || "修改密码失败,请稍后重试。";
|
||||
pwdErr.hidden = false;
|
||||
pwdConfirm.disabled = false;
|
||||
return;
|
||||
}
|
||||
window.location.href = REDIRECT;
|
||||
});
|
||||
-896
@@ -1,896 +0,0 @@
|
||||
:root {
|
||||
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: #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);
|
||||
--color-ink: #f2f7f4;
|
||||
--color-ink-soft: #a6b0aa;
|
||||
--color-ink-muted: #89938d;
|
||||
--color-primary: #37eb89;
|
||||
--color-primary-strong: #70ffae;
|
||||
--color-primary-dark: #08160e;
|
||||
--color-primary-wash: rgba(55, 235, 137, 0.11);
|
||||
--color-positive: #37eb89;
|
||||
--color-positive-wash: rgba(55, 235, 137, 0.1);
|
||||
--color-warning: #ffbc52;
|
||||
--color-warning-wash: rgba(255, 188, 82, 0.11);
|
||||
--color-danger: #ff626d;
|
||||
--color-danger-wash: rgba(255, 98, 109, 0.11);
|
||||
--color-info: #66a8ff;
|
||||
--color-info-wash: rgba(102, 168, 255, 0.11);
|
||||
--radius-sm: 10px;
|
||||
--radius-md: 16px;
|
||||
--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.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;
|
||||
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
/* Login page — 深海军蓝 × 账房金 (HEL-32 已定稿) */
|
||||
--jn-navy-900: #0B2740;
|
||||
--jn-navy-800: #123A5C;
|
||||
--jn-navy-700: #1B4F7A;
|
||||
--jn-blue-600: #1D4ED8;
|
||||
--jn-blue-700: #1E40AF;
|
||||
--jn-blue-800: #1E3A8A;
|
||||
--jn-blue-50: #EFF6FF;
|
||||
--jn-gold-500: #C9A227;
|
||||
--jn-gold-400: #D9B84A;
|
||||
--jn-bg-page: #F5F7FA;
|
||||
--jn-bg-panel: #FFFFFF;
|
||||
--jn-text-1: #1F2937;
|
||||
--jn-text-2: #6B7280;
|
||||
--jn-text-3: #9CA3AF;
|
||||
--jn-border: #E5E7EB;
|
||||
--jn-border-strong: #D1D5DB;
|
||||
--jn-error: #DC2626;
|
||||
--jn-error-bg: #FEF2F2;
|
||||
--jn-disabled-bg: #F3F4F6;
|
||||
}
|
||||
|
||||
* { 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: 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; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
h1, h2, h3, p, dl, dd { margin: 0; }
|
||||
svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
||||
strong, .amount, .number, dd { font-variant-numeric: tabular-nums; }
|
||||
::selection { background: rgba(55, 235, 137, 0.26); color: #fff; }
|
||||
::-webkit-scrollbar { width: 9px; height: 9px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { border: 2px solid transparent; border-radius: 10px; background: rgba(255, 255, 255, 0.15); background-clip: padding-box; }
|
||||
|
||||
.sr-only { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
.skip-link { position: fixed; top: 8px; left: 50%; z-index: 500; padding: 8px 12px; border-radius: var(--radius-sm); background: var(--color-primary); color: var(--color-primary-dark); transform: translate(-50%, -150%); }
|
||||
.skip-link:focus { transform: translate(-50%, 0); }
|
||||
:focus-visible { outline: 2px solid var(--color-primary-strong); outline-offset: 3px; }
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
/* Application frame */
|
||||
.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; }
|
||||
.brand-copy small, .user-block small, .company-context small { color: var(--color-ink-muted); font-size: 10px; }
|
||||
.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: 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)); }
|
||||
.nav-item svg { width: 17px; flex: 0 0 auto; }
|
||||
.nav-item span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.nav-item b { min-width: 19px; height: 19px; display: grid; place-items: center; margin-left: auto; border-radius: 7px; background: var(--color-primary-wash); color: var(--color-primary); font-size: 10px; }
|
||||
.sidebar-footer { display: grid; gap: 8px; margin-top: auto; padding-top: 12px; border-top: 1px solid var(--color-line); }
|
||||
.user-block { min-height: 51px; display: flex; align-items: center; gap: 10px; padding: 7px 9px; }
|
||||
.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: 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(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; }
|
||||
.period-button, .icon-button, .inside-icon, .swap-button { display: inline-grid; place-items: center; border: 1px solid var(--color-line); background: var(--color-surface-muted); color: var(--color-ink-soft); cursor: pointer; transition: border-color var(--duration-fast), color var(--duration-fast), background var(--duration-fast), transform var(--duration-fast); }
|
||||
.period-button { min-height: 40px; grid-auto-flow: column; gap: 8px; padding: 0 13px; border-radius: 14px; }
|
||||
.icon-button { position: relative; width: 40px; height: 40px; border-radius: 13px; }
|
||||
.inside-icon { width: 36px; height: 36px; border: 0; background: transparent; }
|
||||
.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: 12px 32px 58px; }
|
||||
.app-view { display: none; }
|
||||
.app-view.is-active { display: block; }
|
||||
.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); }
|
||||
.button:hover { transform: translateY(-1px); }
|
||||
.button:active { transform: translateY(0) scale(0.985); }
|
||||
.button.primary { background: var(--color-primary); color: var(--color-primary-dark); box-shadow: 0 8px 24px rgba(55, 235, 137, 0.18); }
|
||||
.button.primary:hover { background: var(--color-primary-strong); box-shadow: 0 10px 30px rgba(55, 235, 137, 0.25); }
|
||||
.button.secondary { border-color: var(--color-line); background: rgba(255, 255, 255, 0.045); color: var(--color-ink); }
|
||||
.button.secondary:hover { border-color: var(--color-line-strong); background: rgba(255, 255, 255, 0.08); }
|
||||
.button.small { min-height: 34px; padding-inline: 11px; border-radius: 11px; font-size: 11px; }
|
||||
.button.wide { width: 100%; }
|
||||
.button:disabled { opacity: 0.42; cursor: not-allowed; transform: none; }
|
||||
.text-button { padding: 3px 0; border: 0; background: none; color: var(--color-primary); cursor: pointer; }
|
||||
.text-button:hover { color: var(--color-primary-strong); }
|
||||
.full-text-button { width: 100%; padding: 13px; border-top: 1px solid var(--color-line); }
|
||||
.field { min-width: 0; display: flex; flex-direction: column; gap: 6px; color: var(--color-ink-soft); }
|
||||
.field > span { color: var(--color-ink-muted); font-size: 11px; }
|
||||
.field input, .field select, .field textarea { width: 100%; min-height: 40px; padding: 9px 11px; border: 1px solid var(--color-line); border-radius: 11px; outline: 0; background: rgba(2, 5, 3, 0.48); color: var(--color-ink); transition: border-color var(--duration-fast), box-shadow var(--duration-fast), background var(--duration-fast); }
|
||||
.field textarea { resize: vertical; }
|
||||
.field input:focus, .field select:focus, .field textarea:focus { border-color: rgba(55, 235, 137, 0.5); background: rgba(2, 5, 3, 0.7); box-shadow: 0 0 0 3px rgba(55, 235, 137, 0.08); }
|
||||
.field select { color-scheme: dark; }
|
||||
.field.compact { min-width: 150px; }
|
||||
.password-field { display: grid; grid-template-columns: 1fr 38px; border: 1px solid var(--color-line); border-radius: 12px; background: rgba(2, 5, 3, 0.48); }
|
||||
.password-field input { border: 0; background: transparent; }
|
||||
.check-field { display: flex; align-items: center; gap: 8px; color: var(--color-ink-soft); }
|
||||
.check-field input { accent-color: var(--color-primary); }
|
||||
.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: 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); }
|
||||
.panel-heading h2 { font-size: 15px; }
|
||||
.panel-heading p { margin-top: 3px; color: var(--color-ink-muted); font-size: 11px; }
|
||||
.status { display: inline-flex; align-items: center; justify-content: center; gap: 5px; width: max-content; min-height: 23px; padding: 2px 8px; border: 1px solid transparent; border-radius: 8px; font-size: 10px; font-style: normal; white-space: nowrap; }
|
||||
.status.success { border-color: rgba(55, 235, 137, 0.18); background: var(--color-positive-wash); color: var(--color-positive); }
|
||||
.status.warning { border-color: rgba(255, 188, 82, 0.18); background: var(--color-warning-wash); color: var(--color-warning); }
|
||||
.status.danger { border-color: rgba(255, 98, 109, 0.18); background: var(--color-danger-wash); color: var(--color-danger); }
|
||||
.status.neutral { border-color: var(--color-line); background: rgba(255, 255, 255, 0.045); color: var(--color-ink-soft); }
|
||||
.task-level { width: 30px; height: 30px; display: grid; place-items: center; border-radius: 10px; font-size: 10px; font-style: normal; font-weight: 800; }
|
||||
.task-level.danger { background: var(--color-danger-wash); color: var(--color-danger); }
|
||||
.task-level.warning { background: var(--color-warning-wash); color: var(--color-warning); }
|
||||
.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: 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: 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; }
|
||||
.metric-foot { grid-column: 1 / -1; color: var(--color-ink-muted); font-size: 11px; }
|
||||
.metric-foot strong { color: var(--color-positive); }
|
||||
.metric-card.warning { color: var(--color-warning); }
|
||||
.metric-card.danger { color: var(--color-danger); }
|
||||
.metric-card.success { color: var(--color-positive); }
|
||||
.metric-card .metric-copy { color: var(--color-ink); }
|
||||
.metric-card .metric-icon { color: var(--color-ink-soft); }
|
||||
.metric-card .metric-foot { color: var(--color-ink-muted); }
|
||||
.metric-card[data-metric-link], .metric-card[data-metric-action] { cursor: pointer; }
|
||||
.metric-card.warning .metric-icon, .metric-card.warning .metric-foot strong { color: var(--color-warning); }
|
||||
.metric-card.warning .metric-icon::after { opacity: 0.28; }
|
||||
.metric-card.danger .metric-icon, .metric-card.danger .metric-foot strong { color: var(--color-danger); }
|
||||
.metric-card.danger .metric-icon::after { opacity: 0.32; }
|
||||
.metric-card.success .metric-icon { color: var(--color-positive); }
|
||||
.metric-card.success .metric-icon::after { opacity: 0.22; border-color: var(--color-positive); }
|
||||
.period-ribbon { display: grid; grid-template-columns: 1fr 1fr 1fr minmax(310px, 1.4fr); margin-bottom: 18px; overflow: hidden; }
|
||||
.period-ribbon > div { min-height: 72px; display: flex; flex-direction: column; justify-content: center; padding: 13px 17px; border-left: 1px solid var(--color-line); }
|
||||
.period-ribbon > div:first-child { border-left: 0; }
|
||||
.period-ribbon span, .period-ribbon small { color: var(--color-ink-muted); }
|
||||
.period-ribbon strong { margin-top: 3px; }
|
||||
.period-ribbon > .period-warning { display: grid; grid-template-columns: 24px minmax(0, 1fr) auto; align-items: center; gap: 10px; }
|
||||
.period-warning > svg { color: var(--color-warning); }
|
||||
.period-warning > span { display: flex; flex-direction: column; }
|
||||
.period-warning > span strong { color: var(--color-ink); }
|
||||
.admin-dashboard-grid { display: grid; grid-template-columns: minmax(0, 1.42fr) minmax(310px, 0.58fr); gap: 18px; margin-bottom: 18px; }
|
||||
.task-list button { width: 100%; min-height: 73px; display: grid; grid-template-columns: 32px minmax(0, 1fr) auto 18px; align-items: center; gap: 11px; padding: 12px 16px; border: 0; border-top: 1px solid var(--color-line); background: transparent; text-align: left; cursor: pointer; transition: background var(--duration-fast); }
|
||||
.task-list button:first-child { border-top: 0; }
|
||||
.task-list button:hover { background: rgba(255, 255, 255, 0.035); }
|
||||
.task-list button > span:nth-child(2) { display: flex; flex-direction: column; }
|
||||
.task-list small, .task-list button > b { color: var(--color-ink-muted); font-size: 11px; }
|
||||
.task-list button > svg { color: var(--color-ink-muted); }
|
||||
.quick-pair-form { display: grid; grid-template-columns: 1fr 38px 1fr; gap: 10px; padding: 16px; }
|
||||
.quick-pair-form .button { grid-column: 1 / -1; }
|
||||
.quick-result { margin: 0 16px 16px; padding: 14px; border: 1px solid rgba(55, 235, 137, 0.16); border-radius: var(--radius-md); background: var(--color-primary-wash); }
|
||||
.quick-result span, .quick-result p { color: var(--color-ink-muted); }
|
||||
.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); }
|
||||
|
||||
/* 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(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; }
|
||||
.timeline-legend span { color: var(--color-ink-muted); font-size: 11px; }
|
||||
.timeline-body { position: relative; min-height: 88px; padding-top: 6px; }
|
||||
.timeline-axis { position: relative; height: 14px; display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); border-bottom: 1px solid var(--color-line); }
|
||||
.timeline-axis span { font: 600 10px var(--font-data); color: var(--color-ink-muted); align-self: end; padding-bottom: 4px; text-align: left; }
|
||||
.timeline-axis span + span { border-left: 1px solid var(--color-line); padding-left: 4px; }
|
||||
.timeline-rows { display: grid; gap: 8px; margin-top: 12px; }
|
||||
.timeline-row { position: relative; display: grid; grid-template-columns: 96px minmax(0, 1fr); align-items: center; gap: 10px; font-size: 11px; }
|
||||
.timeline-row > span { color: var(--color-ink-soft); }
|
||||
.timeline-track { position: relative; height: 14px; border-radius: 7px; background: rgba(255, 255, 255, 0.04); overflow: hidden; }
|
||||
.timeline-track i { position: absolute; top: 0; bottom: 0; border-radius: inherit; }
|
||||
.timeline-track i.coverage { background: linear-gradient(90deg, rgba(55, 235, 137, 0.35), rgba(55, 235, 137, 0.55)); box-shadow: inset 0 0 0 1px rgba(112, 255, 174, 0.32); }
|
||||
.timeline-track i.warning { background: linear-gradient(90deg, rgba(255, 188, 82, 0.32), rgba(255, 188, 82, 0.52)); box-shadow: inset 0 0 0 1px rgba(255, 188, 82, 0.32); }
|
||||
.timeline-track i.danger { background: linear-gradient(90deg, rgba(255, 98, 109, 0.32), rgba(255, 98, 109, 0.52)); box-shadow: inset 0 0 0 1px rgba(255, 98, 109, 0.32); }
|
||||
.timeline-today { position: absolute; top: -2px; bottom: -2px; width: 2px; background: var(--color-primary-strong); box-shadow: 0 0 12px rgba(112, 255, 174, 0.6); border-radius: 1px; }
|
||||
.timeline-today::after { content: ""; position: absolute; top: -6px; left: 50%; transform: translateX(-50%); width: 10px; height: 10px; border-radius: 50%; background: var(--color-primary-strong); box-shadow: 0 0 0 4px rgba(112, 255, 174, 0.18); }
|
||||
.timeline-strip-panel { position: relative; }
|
||||
|
||||
/* Ledger and queries */
|
||||
.inline-search { width: min(240px, 38vw); height: 36px; display: flex; align-items: center; gap: 8px; padding: 0 11px; border: 1px solid var(--color-line); border-radius: 11px; background: rgba(0, 0, 0, 0.18); color: var(--color-ink-muted); }
|
||||
.inline-search input { width: 100%; border: 0; outline: 0; background: transparent; color: var(--color-ink); }
|
||||
.ledger-head, .company-ledger summary { display: grid; grid-template-columns: minmax(210px, 1.4fr) repeat(2, minmax(100px, 0.75fr)) minmax(150px, 0.9fr) 86px 24px; align-items: center; gap: 10px; }
|
||||
.ledger-head { min-height: 42px; padding: 0 16px; border-bottom: 1px solid var(--color-line); color: var(--color-ink-muted); font-size: 10px; }
|
||||
.company-ledger { border-top: 1px solid var(--color-line); }
|
||||
.company-ledger:first-child { border-top: 0; }
|
||||
.company-ledger summary { min-height: 72px; padding: 10px 16px; list-style: none; cursor: pointer; transition: background var(--duration-fast); }
|
||||
.company-ledger summary::-webkit-details-marker { display: none; }
|
||||
.company-ledger summary:hover { background: rgba(255, 255, 255, 0.035); }
|
||||
.company-ledger summary > svg { transition: transform var(--duration-standard) var(--ease-out); }
|
||||
.company-ledger[open] summary > svg { transform: rotate(180deg); }
|
||||
.company-name { display: grid; grid-template-columns: 38px minmax(0, 1fr); align-items: center; column-gap: 10px; }
|
||||
.company-name i { width: 36px; height: 36px; grid-row: 1 / 3; display: grid; place-items: center; border-radius: 12px; background: var(--color-primary-wash); color: var(--color-primary); font-style: normal; font-weight: 800; }
|
||||
.company-name small { color: var(--color-ink-muted); }
|
||||
.amount { font: 600 13px var(--font-data); }
|
||||
.amount.debit { color: var(--color-positive); }
|
||||
.amount.credit { color: var(--color-warning); }
|
||||
.ledger-breakdown { display: grid; grid-template-columns: 1fr 1fr; border-top: 1px solid var(--color-line); background: rgba(0, 0, 0, 0.13); }
|
||||
.ledger-breakdown section { padding: 14px 16px; }
|
||||
.ledger-breakdown section + section { border-left: 1px solid var(--color-line); }
|
||||
.ledger-breakdown header { display: flex; justify-content: space-between; margin-bottom: 8px; color: var(--color-ink-soft); }
|
||||
.subject-row { width: 100%; min-height: 48px; display: grid; grid-template-columns: minmax(0, 1fr) auto 18px; align-items: center; gap: 10px; padding: 8px 10px; border: 0; border-top: 1px solid var(--color-line); background: transparent; text-align: left; cursor: pointer; }
|
||||
.subject-row:hover { background: rgba(255, 255, 255, 0.035); }
|
||||
.subject-row span { display: flex; flex-direction: column; }
|
||||
.subject-row small { color: var(--color-ink-muted); }
|
||||
.query-band, .filter-grid { padding: 17px; margin-bottom: 18px; }
|
||||
.pair-query-form { display: grid; grid-template-columns: minmax(150px, 1fr) 38px minmax(150px, 1fr) minmax(150px, 0.8fr) auto; align-items: end; gap: 11px; }
|
||||
.pair-report { overflow: hidden; }
|
||||
.pair-report-heading { min-height: 84px; display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 16px 18px; border-bottom: 1px solid var(--color-line); }
|
||||
.pair-report-heading p { color: var(--color-ink-muted); }
|
||||
.pair-result { text-align: right; }
|
||||
.pair-result strong { display: block; color: var(--color-primary); font: 700 22px var(--font-data); }
|
||||
.pair-balance-line { display: grid; grid-template-columns: repeat(5, 1fr); border-bottom: 1px solid var(--color-line); }
|
||||
.pair-balance-line div { min-height: 76px; display: flex; flex-direction: column; justify-content: center; padding: 12px 16px; border-left: 1px solid var(--color-line); }
|
||||
.pair-balance-line div:first-child { border-left: 0; }
|
||||
.pair-balance-line span, .pair-balance-line small { color: var(--color-ink-muted); }
|
||||
.pair-balance-line strong { margin-top: 3px; font: 600 15px var(--font-data); }
|
||||
.pair-final strong { color: var(--color-primary); font-size: 19px; }
|
||||
.subject-strip { display: grid; grid-template-columns: repeat(5, 1fr); border-bottom: 1px solid var(--color-line); }
|
||||
.subject-strip button { min-height: 62px; display: flex; flex-direction: column; justify-content: center; padding: 9px 14px; border: 0; border-left: 1px solid var(--color-line); background: rgba(255, 255, 255, 0.018); text-align: left; cursor: pointer; }
|
||||
.subject-strip button:first-child { border-left: 0; }
|
||||
.subject-strip button.is-active { background: var(--color-primary-wash); color: var(--color-primary); box-shadow: inset 0 -2px var(--color-primary); }
|
||||
.subject-strip span { color: var(--color-ink-muted); font-size: 10px; }
|
||||
|
||||
/* Filters and tables */
|
||||
.filter-bar { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 12px; margin-bottom: 18px; }
|
||||
.segmented { display: flex; gap: 4px; padding: 4px; overflow-x: auto; border: 1px solid var(--color-line); border-radius: 13px; background: rgba(0, 0, 0, 0.18); }
|
||||
.segmented button { min-height: 32px; padding: 0 11px; border: 0; border-radius: 9px; background: transparent; color: var(--color-ink-muted); white-space: nowrap; cursor: pointer; }
|
||||
.segmented button.is-active { background: rgba(255, 255, 255, 0.09); color: var(--color-ink); }
|
||||
.flow-filters { display: grid; grid-template-columns: repeat(5, minmax(130px, 1fr)) minmax(200px, 1.4fr) auto; align-items: end; gap: 11px; }
|
||||
.flow-filters .grow { min-width: 0; }
|
||||
.table-summary { min-height: 51px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 0 16px; border-bottom: 1px solid var(--color-line); color: var(--color-ink-muted); font-size: 11px; }
|
||||
.table-scroll { max-width: 100%; overflow: auto; }
|
||||
.data-table { width: 100%; min-width: 860px; border-collapse: collapse; }
|
||||
.data-table th { height: 42px; padding: 8px 13px; border-bottom: 1px solid var(--color-line); background: rgba(255, 255, 255, 0.025); color: var(--color-ink-muted); font-size: 10px; font-weight: 500; text-align: left; white-space: nowrap; }
|
||||
.data-table td { height: 55px; padding: 9px 13px; border-bottom: 1px solid rgba(255, 255, 255, 0.055); color: var(--color-ink-soft); vertical-align: middle; }
|
||||
.data-table tbody tr { transition: background var(--duration-fast); }
|
||||
.data-table tbody tr:hover { background: rgba(55, 235, 137, 0.035); }
|
||||
.data-table tbody tr:last-child td { border-bottom: 0; }
|
||||
.data-table td strong, .data-table td small { display: block; }
|
||||
.data-table td strong { color: var(--color-ink); }
|
||||
.data-table td small { color: var(--color-ink-muted); }
|
||||
.data-table .number { color: var(--color-ink); text-align: right; font-family: var(--font-data); font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* Settings, reminders and company work */
|
||||
.settings-layout { display: grid; grid-template-columns: 340px minmax(0, 1fr); gap: 18px; }
|
||||
.form-body { display: grid; gap: 15px; padding: 17px; }
|
||||
.panel form footer, form.panel footer { display: flex; justify-content: flex-end; gap: 9px; padding: 13px 17px; border-top: 1px solid var(--color-line); }
|
||||
.closing-panel { grid-column: 1 / -1; }
|
||||
.closing-checks { display: grid; grid-template-columns: repeat(4, 1fr); }
|
||||
.closing-checks article { min-height: 75px; display: flex; align-items: center; gap: 10px; padding: 13px 16px; border-left: 1px solid var(--color-line); }
|
||||
.closing-checks article:first-child { border-left: 0; }
|
||||
.closing-checks svg { color: var(--color-positive); }
|
||||
.closing-checks .is-blocked svg { color: var(--color-danger); }
|
||||
.closing-checks span { display: flex; flex-direction: column; }
|
||||
.closing-checks small { color: var(--color-ink-muted); }
|
||||
.closing-footer { min-height: 62px; display: flex; align-items: center; justify-content: flex-end; gap: 10px; padding: 10px 16px; border-top: 1px solid var(--color-line); }
|
||||
.closing-footer > span { margin-right: auto; color: var(--color-ink-muted); }
|
||||
.reminder-layout { display: grid; grid-template-columns: 360px minmax(0, 1fr); gap: 18px; }
|
||||
.notification-list article { min-height: 76px; display: grid; grid-template-columns: 38px minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 12px 16px; border-top: 1px solid var(--color-line); }
|
||||
.notification-list article:first-child { border-top: 0; }
|
||||
.notification-list article.is-unread { background: rgba(102, 168, 255, 0.045); }
|
||||
.notification-list article > span:nth-child(2) { display: flex; flex-direction: column; }
|
||||
.notification-list small, .notification-list p { color: var(--color-ink-muted); }
|
||||
.notification-list p { margin-top: 4px; }
|
||||
.notification-icon { width: 35px; height: 35px; display: grid; place-items: center; border-radius: 12px; background: rgba(255, 255, 255, 0.055); color: var(--color-ink-soft); }
|
||||
.notification-icon.danger { background: var(--color-danger-wash); color: var(--color-danger); }
|
||||
.notification-icon.warning { background: var(--color-warning-wash); color: var(--color-warning); }
|
||||
.notification-icon.success { background: var(--color-positive-wash); color: var(--color-positive); }
|
||||
.company-alert { min-height: 78px; display: grid; grid-template-columns: 32px minmax(0, 1fr) auto; align-items: center; gap: 14px; margin-bottom: 18px; padding: 14px 16px; border: 1px solid rgba(255, 98, 109, 0.22); border-radius: var(--radius-lg); background: var(--color-danger-wash); box-shadow: var(--shadow-low); }
|
||||
.company-alert > svg { color: var(--color-danger); }
|
||||
.company-alert p { color: #c18e92; }
|
||||
.company-dashboard-grid { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(300px, 0.65fr); gap: 18px; }
|
||||
.cashier-tasks article { min-height: 92px; display: grid; grid-template-columns: 32px minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 13px 16px; border-top: 1px solid var(--color-line); }
|
||||
.cashier-tasks article:first-child { border-top: 0; }
|
||||
.cashier-tasks p, .cashier-tasks small { color: var(--color-ink-muted); }
|
||||
.coverage-list article { min-height: 62px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 16px; border-top: 1px solid var(--color-line); }
|
||||
.coverage-list article:first-child { border-top: 0; }
|
||||
.coverage-list article > span { display: flex; flex-direction: column; }
|
||||
.coverage-list small { color: var(--color-ink-muted); }
|
||||
.work-progress { display: grid; grid-template-columns: minmax(130px, 1fr) 40px minmax(130px, 1fr) 40px minmax(130px, 1fr) 40px minmax(130px, 1fr); align-items: center; margin-top: 18px; padding: 18px; }
|
||||
.work-progress div { display: flex; align-items: center; gap: 10px; }
|
||||
.work-progress div > span { width: 31px; height: 31px; display: grid; place-items: center; border-radius: 11px; background: var(--color-positive); color: var(--color-primary-dark); font-weight: 800; }
|
||||
.work-progress p { display: flex; flex-direction: column; }
|
||||
.work-progress small { color: var(--color-ink-muted); }
|
||||
.work-progress i { height: 1px; background: var(--color-line-strong); }
|
||||
.work-progress .pending > span { background: rgba(255, 255, 255, 0.065); color: var(--color-ink-muted); }
|
||||
.reconcile-summary { display: grid; grid-template-columns: repeat(3, 1fr); margin-bottom: 18px; overflow: hidden; }
|
||||
.reconcile-summary div { min-height: 86px; display: flex; flex-direction: column; justify-content: center; padding: 14px 18px; border-left: 1px solid var(--color-line); }
|
||||
.reconcile-summary div:first-child { border-left: 0; }
|
||||
.reconcile-summary span, .reconcile-summary small { color: var(--color-ink-muted); }
|
||||
.reconcile-summary strong { font: 600 19px var(--font-data); }
|
||||
.review-list > article { padding: 16px; border-top: 1px solid var(--color-line); }
|
||||
.review-list > article:first-child { border-top: 0; }
|
||||
.review-main { display: grid; grid-template-columns: 38px 1fr; gap: 10px; }
|
||||
.review-main p { color: var(--color-ink-muted); }
|
||||
.candidate { display: flex; align-items: end; justify-content: space-between; gap: 16px; margin: 13px 0 0 48px; padding: 12px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: rgba(0, 0, 0, 0.15); }
|
||||
.candidate-options { display: grid; gap: 8px; margin: 13px 0 0 48px; padding: 12px; border: 1px solid var(--color-line); border-radius: var(--radius-md); }
|
||||
.candidate-options legend { padding: 0 5px; color: var(--color-ink-muted); }
|
||||
.candidate-options label { display: flex; align-items: center; gap: 9px; padding: 9px; border-radius: 11px; background: rgba(255, 255, 255, 0.035); }
|
||||
.candidate-options input { accent-color: var(--color-primary); }
|
||||
.candidate-options label span { display: flex; flex-direction: column; }
|
||||
.candidate-options small { color: var(--color-ink-muted); }
|
||||
.review-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 10px; }
|
||||
.review-history { padding: 14px 16px; border-top: 1px solid var(--color-line); background: var(--color-positive-wash); }
|
||||
.evidence-block { padding: 12px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: rgba(0, 0, 0, 0.16); }
|
||||
.evidence-block strong, .evidence-block small { display: block; }
|
||||
.evidence-block small { color: var(--color-ink-muted); }
|
||||
.account-directory { display: grid; grid-template-columns: repeat(3, minmax(240px, 1fr)); gap: 16px; }
|
||||
.account-directory article { overflow: hidden; border: 1px solid var(--color-line); border-radius: var(--radius-lg); background: var(--color-surface); box-shadow: var(--shadow-low); }
|
||||
.account-directory header { display: grid; grid-template-columns: 38px minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 15px; border-bottom: 1px solid var(--color-line); }
|
||||
.account-directory header div { display: flex; flex-direction: column; }
|
||||
.account-directory header small { color: var(--color-ink-muted); }
|
||||
.bank-mark { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 12px; background: var(--color-primary-wash); color: var(--color-primary); font-weight: 800; }
|
||||
.account-directory dl { display: grid; grid-template-columns: repeat(3, 1fr); }
|
||||
.account-directory dl div { padding: 12px; border-left: 1px solid var(--color-line); }
|
||||
.account-directory dl div:first-child { border-left: 0; }
|
||||
.account-directory dt { color: var(--color-ink-muted); font-size: 10px; }
|
||||
.account-directory dd { margin-top: 3px; font-size: 11px; }
|
||||
.manual-layout { display: grid; grid-template-columns: minmax(340px, 390px) minmax(0, 1fr); align-items: start; gap: 18px; }
|
||||
.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, 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); }
|
||||
.dialog-body { display: grid; gap: 15px; padding: 18px; }
|
||||
.dialog > form > footer { display: flex; justify-content: flex-end; gap: 9px; padding: 13px 18px; border-top: 1px solid var(--color-line); }
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.form-callout { display: flex; align-items: center; gap: 9px; padding: 10px; border: 1px solid rgba(102, 168, 255, 0.22); border-radius: 12px; background: var(--color-info-wash); color: #9ac6ff; }
|
||||
.dropzone { min-height: 168px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; border: 1px dashed rgba(255, 255, 255, 0.24); border-radius: var(--radius-md); background: rgba(0, 0, 0, 0.15); color: var(--color-ink-soft); text-align: center; transition: border-color var(--duration-fast), background var(--duration-fast); }
|
||||
.dropzone:hover, .dropzone.is-dragging { border-color: var(--color-primary); background: var(--color-primary-wash); }
|
||||
.dropzone input { position: absolute; width: 1px; height: 1px; opacity: 0; }
|
||||
.dropzone > svg { width: 30px; height: 30px; color: var(--color-primary); }
|
||||
.file-preview { display: grid; grid-template-columns: 40px minmax(0, 1fr) 40px; align-items: center; gap: 10px; padding: 10px; border: 1px solid var(--color-line); border-radius: var(--radius-md); }
|
||||
.file-preview > span:nth-child(2) { display: flex; flex-direction: column; min-width: 0; }
|
||||
.file-preview strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.file-preview small { color: var(--color-ink-muted); }
|
||||
.file-type { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 12px; background: var(--color-primary-wash); color: var(--color-primary); }
|
||||
.parse-result { display: grid; grid-template-columns: 38px 1fr; align-items: center; gap: 10px; padding: 11px; border: 1px solid rgba(55, 235, 137, 0.22); border-radius: var(--radius-md); background: var(--color-positive-wash); }
|
||||
.parse-result p { color: #9bd7b6; }
|
||||
.parse-result.is-exception { border-color: rgba(255, 188, 82, 0.25); background: var(--color-warning-wash); }
|
||||
.parse-result.is-exception p { color: #e7bd78; }
|
||||
.sheet-review { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--color-line); }
|
||||
.sheet-review h3 { font-size: 14px; }
|
||||
.sheet-review-hint { margin-top: 4px; color: var(--color-ink-muted); font-size: 12px; line-height: 1.5; }
|
||||
.sheet-list { display: grid; gap: 8px; margin-top: 10px; max-height: 220px; overflow-y: auto; }
|
||||
.sheet-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; padding: 10px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: var(--color-surface-muted); }
|
||||
.sheet-item.is-pending { border-color: rgba(255, 188, 82, 0.45); }
|
||||
.sheet-item-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.sheet-item-head strong { font-size: 13px; }
|
||||
.sheet-item-head small { color: var(--color-ink-muted); }
|
||||
.sheet-item-meta { margin-top: 5px; color: var(--color-ink-muted); font-size: 12px; line-height: 1.5; }
|
||||
.sheet-item-actions { display: flex; align-items: center; gap: 6px; }
|
||||
.sheet-item-actions .text-button { font-size: 12px; padding: 4px 8px; }
|
||||
.sheet-item-reason { margin-top: 6px; padding: 6px 8px; border: 1px solid var(--color-line); border-radius: var(--radius-sm); color: var(--color-ink-muted); font-size: 12px; }
|
||||
.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, 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 — 深海军蓝 × 账房金 (HEL-32) */
|
||||
.entry-page {
|
||||
color-scheme: light;
|
||||
background: var(--jn-bg-page);
|
||||
color: var(--jn-text-1);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.entry-page::before { display: none; }
|
||||
.entry-page :focus-visible { outline: 2px solid var(--jn-blue-600); outline-offset: 3px; }
|
||||
|
||||
.entry-shell {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(420px, 42%) minmax(0, 1fr);
|
||||
grid-template-rows: 56px minmax(0, 1fr);
|
||||
grid-template-areas: "topbar topbar" "context form";
|
||||
}
|
||||
|
||||
.entry-topbar {
|
||||
grid-area: topbar;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 24px;
|
||||
background: var(--jn-bg-panel);
|
||||
border-bottom: 1px solid var(--jn-border);
|
||||
}
|
||||
.entry-brand { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||
.entry-seal {
|
||||
width: 28px; height: 28px; flex: 0 0 auto;
|
||||
display: grid; place-items: center;
|
||||
border: 1.5px solid var(--jn-gold-500);
|
||||
border-radius: 6px;
|
||||
background: var(--jn-bg-panel);
|
||||
color: var(--jn-gold-500);
|
||||
font-size: 17px; line-height: 1; font-weight: 700;
|
||||
}
|
||||
.entry-system-name {
|
||||
font-size: 17px; line-height: 26px; font-weight: 600;
|
||||
color: var(--jn-text-1);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.entry-context {
|
||||
grid-area: context;
|
||||
position: relative;
|
||||
display: flex; flex-direction: column; justify-content: center;
|
||||
padding: 64px;
|
||||
background: linear-gradient(160deg, var(--jn-navy-800), var(--jn-navy-900));
|
||||
overflow: hidden;
|
||||
}
|
||||
.entry-statement { max-width: 520px; }
|
||||
.entry-statement h1 { font-size: 32px; line-height: 40px; font-weight: 700; color: #ffffff; letter-spacing: 0; }
|
||||
.entry-statement p { margin-top: 12px; max-width: 440px; font-size: 14px; line-height: 22px; color: #B9CBDD; }
|
||||
.entry-figure { width: 100%; height: auto; max-width: 520px; margin-top: 48px; display: block; }
|
||||
.entry-figure .fig-bar { fill: none; stroke: var(--jn-navy-700); stroke-width: 1.5; }
|
||||
.entry-figure .fig-arc { fill: none; stroke: var(--jn-navy-700); stroke-width: 80; opacity: 0.2; }
|
||||
.entry-figure .fig-line { fill: none; stroke: var(--jn-gold-500); stroke-width: 1.5; }
|
||||
|
||||
.entry-form-wrap {
|
||||
grid-area: form;
|
||||
display: grid; place-items: center;
|
||||
padding: 48px 40px;
|
||||
background: var(--jn-bg-panel);
|
||||
}
|
||||
.entry-form { width: min(400px, 100%); display: grid; gap: 20px; }
|
||||
.entry-form header h2 { font-size: 24px; line-height: 32px; font-weight: 600; color: var(--jn-text-1); }
|
||||
.entry-form header p { margin-top: 6px; font-size: 14px; line-height: 22px; color: var(--jn-text-2); }
|
||||
|
||||
#loginError {
|
||||
padding: 10px 14px;
|
||||
border-left: 3px solid var(--jn-error);
|
||||
border-radius: 8px;
|
||||
background: var(--jn-error-bg);
|
||||
color: var(--jn-error);
|
||||
font-size: 13px; line-height: 18px;
|
||||
}
|
||||
|
||||
.role-switch { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.role-switch label { cursor: pointer; }
|
||||
.role-switch input { position: absolute; opacity: 0; }
|
||||
.role-switch span {
|
||||
min-height: 76px;
|
||||
display: grid; grid-template-columns: 24px 1fr;
|
||||
align-content: center; gap: 2px 10px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--jn-border);
|
||||
border-radius: 8px;
|
||||
background: var(--jn-bg-panel);
|
||||
transition: border-color 150ms ease-out, background 150ms ease-out;
|
||||
}
|
||||
.role-switch span:hover { border-color: var(--jn-border-strong); }
|
||||
.role-switch svg { grid-row: 1 / 3; align-self: center; color: var(--jn-text-3); }
|
||||
.role-switch b { font-size: 15px; line-height: 22px; font-weight: 600; color: var(--jn-text-2); }
|
||||
.role-switch small { font-size: 12px; line-height: 18px; color: var(--jn-text-3); }
|
||||
.role-switch input:focus-visible + span { border-color: var(--jn-blue-600); box-shadow: 0 0 0 3px rgba(29, 78, 216, 0.15); }
|
||||
.role-switch input:checked + span { border: 1.5px solid var(--jn-blue-600); background: var(--jn-blue-50); }
|
||||
.role-switch input:checked + span svg { color: var(--jn-blue-600); }
|
||||
.role-switch input:checked + span b { color: var(--jn-blue-600); }
|
||||
|
||||
.entry-form .field { display: flex; flex-direction: column; gap: 8px; }
|
||||
.entry-form .field > span { font-size: 14px; line-height: 20px; font-weight: 500; color: var(--jn-text-1); }
|
||||
.entry-form .field input {
|
||||
width: 100%; height: 44px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--jn-border);
|
||||
border-radius: 8px;
|
||||
outline: 0;
|
||||
background: var(--jn-bg-panel);
|
||||
color: var(--jn-text-1);
|
||||
font-size: 15px; line-height: 22px;
|
||||
transition: border-color 150ms ease-out, box-shadow 150ms ease-out;
|
||||
}
|
||||
.entry-form .field input::placeholder { color: var(--jn-text-3); }
|
||||
.entry-form .field input:hover { border-color: var(--jn-border-strong); }
|
||||
.entry-form .field input:focus { border-color: var(--jn-blue-600); box-shadow: 0 0 0 3px rgba(29, 78, 216, 0.15); }
|
||||
.entry-form .password-field {
|
||||
display: grid; grid-template-columns: 1fr 44px;
|
||||
border: 1px solid var(--jn-border);
|
||||
border-radius: 8px;
|
||||
background: var(--jn-bg-panel);
|
||||
transition: border-color 150ms ease-out, box-shadow 150ms ease-out;
|
||||
}
|
||||
.entry-form .password-field:hover { border-color: var(--jn-border-strong); }
|
||||
.entry-form .password-field:focus-within { border-color: var(--jn-blue-600); box-shadow: 0 0 0 3px rgba(29, 78, 216, 0.15); }
|
||||
.entry-form .password-field input { border: 0; box-shadow: none; background: transparent; }
|
||||
.entry-form .password-field input:focus { border: 0; box-shadow: none; }
|
||||
.entry-form .inside-icon { width: 44px; height: 44px; color: var(--jn-text-3); border: 0; background: transparent; }
|
||||
.entry-form .inside-icon:hover { color: var(--jn-text-2); background: transparent; transform: none; }
|
||||
.entry-form .inside-icon svg { width: 20px; height: 20px; }
|
||||
|
||||
.entry-options { display: flex; align-items: center; justify-content: space-between; }
|
||||
.entry-form .check-field { display: flex; align-items: center; gap: 8px; font-size: 13px; line-height: 18px; color: var(--jn-text-2); cursor: pointer; }
|
||||
.entry-form .check-field input { width: 16px; height: 16px; accent-color: var(--jn-blue-600); }
|
||||
.entry-hint { font-size: 13px; line-height: 18px; color: var(--jn-blue-600); }
|
||||
.entry-hint:hover { color: var(--jn-blue-700); }
|
||||
|
||||
.entry-form .entry-note { font-size: 12px; line-height: 18px; color: var(--jn-text-2); }
|
||||
|
||||
.entry-form .button.primary {
|
||||
width: 100%; height: 44px;
|
||||
border-radius: 8px;
|
||||
background: var(--jn-blue-600);
|
||||
color: #ffffff;
|
||||
box-shadow: none;
|
||||
font-size: 15px; line-height: 22px; font-weight: 600;
|
||||
}
|
||||
.entry-form .button.primary:hover { background: var(--jn-blue-700); box-shadow: none; transform: none; }
|
||||
.entry-form .button.primary:active { background: var(--jn-blue-800); transform: none; }
|
||||
.entry-form .button.primary:disabled { background: var(--jn-disabled-bg); color: var(--jn-text-3); opacity: 1; cursor: not-allowed; transform: none; }
|
||||
.entry-form .button.primary.is-loading:disabled { background: var(--jn-blue-600); color: #ffffff; opacity: 0.7; }
|
||||
.spinner {
|
||||
width: 16px; height: 16px; flex: 0 0 auto;
|
||||
display: none;
|
||||
border: 2px solid rgba(255, 255, 255, 0.35);
|
||||
border-top-color: #ffffff;
|
||||
border-radius: 50%;
|
||||
animation: jn-spin 1s linear infinite;
|
||||
}
|
||||
.button.is-loading .spinner { display: inline-block; }
|
||||
.button.is-loading .submit-arrow { display: none; }
|
||||
@keyframes jn-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.entry-compliance { font-size: 12px; line-height: 18px; color: var(--jn-text-3); text-align: center; }
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.entry-shell { grid-template-columns: 1fr; grid-template-rows: 56px 180px minmax(0, 1fr); grid-template-areas: "topbar" "context" "form"; }
|
||||
.entry-context { padding: 32px 40px; justify-content: center; }
|
||||
.entry-statement h1 { font-size: 24px; line-height: 32px; }
|
||||
.entry-figure { display: none; }
|
||||
.entry-form-wrap { padding: 48px 40px; }
|
||||
.entry-form { width: min(440px, 100%); }
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.entry-shell { grid-template-rows: 56px 96px minmax(0, 1fr); }
|
||||
.entry-topbar { padding: 0 16px; }
|
||||
.entry-seal { width: 24px; height: 24px; font-size: 14px; border-radius: 6px; }
|
||||
.entry-system-name { font-size: 15px; line-height: 22px; }
|
||||
.entry-context { padding: 24px; }
|
||||
.entry-statement h1 { font-size: 18px; line-height: 26px; }
|
||||
.entry-statement p { display: none; }
|
||||
.entry-form-wrap { padding: 24px 16px; }
|
||||
.entry-form { gap: 16px; }
|
||||
.entry-form .field input { height: 48px; }
|
||||
.entry-form .password-field { grid-template-columns: 1fr 48px; }
|
||||
.entry-form .inside-icon { width: 48px; height: 48px; }
|
||||
.entry-form .button.primary { height: 48px; }
|
||||
.role-switch { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.app-shell { grid-template-columns: 88px minmax(0, 1fr); }
|
||||
.sidebar { width: 70px; padding-inline: 9px; }
|
||||
.workspace { grid-column: 2; }
|
||||
.brand { justify-content: center; padding-inline: 0; }
|
||||
.brand-copy, .nav-item span, .nav-item b, .user-block > span:last-child, .company-context div { display: none; }
|
||||
.nav-item { justify-content: center; padding: 0; }
|
||||
.user-block, .company-context { justify-content: center; padding-inline: 0; }
|
||||
.metric-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.period-ribbon { grid-template-columns: repeat(3, 1fr); }
|
||||
.period-ribbon > .period-warning { grid-column: 1 / -1; border-top: 1px solid var(--color-line); border-left: 0; }
|
||||
.admin-dashboard-grid, .company-dashboard-grid { grid-template-columns: 1fr; }
|
||||
.flow-filters { grid-template-columns: repeat(3, 1fr); }
|
||||
.flow-filters .button { min-height: 40px; }
|
||||
.manual-layout { grid-template-columns: 1fr; }
|
||||
.account-directory { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
main { padding-inline: 20px; }
|
||||
.search-box { display: none; }
|
||||
.settings-layout, .reminder-layout { grid-template-columns: 1fr; }
|
||||
.pair-query-form { grid-template-columns: 1fr 40px 1fr; }
|
||||
.pair-query-form .field:nth-of-type(3) { grid-column: 1 / 3; }
|
||||
.pair-query-form .button { grid-column: 3; }
|
||||
.ledger-head { display: none; }
|
||||
.company-ledger summary { grid-template-columns: minmax(180px, 1.4fr) repeat(2, minmax(100px, 0.7fr)) 28px; }
|
||||
.company-ledger summary > span:nth-child(4), .company-ledger summary > span:nth-child(5) { display: none; }
|
||||
.ledger-breakdown { grid-template-columns: 1fr; }
|
||||
.ledger-breakdown section + section { border-top: 1px solid var(--color-line); border-left: 0; }
|
||||
.pair-balance-line { grid-template-columns: repeat(3, 1fr); }
|
||||
.pair-balance-line .pair-final { grid-column: 1 / -1; border-top: 1px solid var(--color-line); border-left: 0; }
|
||||
.subject-strip { grid-template-columns: repeat(3, 1fr); }
|
||||
.work-progress { grid-template-columns: 1fr 24px 1fr; row-gap: 16px; }
|
||||
.work-progress i:nth-of-type(2) { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.app-shell { display: block; }
|
||||
.sidebar { inset: 10px auto 10px 10px; width: 242px; transform: translateX(calc(-100% - 20px)); transition: transform var(--duration-standard) var(--ease-out); }
|
||||
.sidebar.is-open { transform: none; }
|
||||
.sidebar.is-open .brand-copy, .sidebar.is-open .nav-item span, .sidebar.is-open .nav-item b, .sidebar.is-open .user-block > span:last-child, .sidebar.is-open .company-context div { display: flex; }
|
||||
.sidebar.is-open .nav-item { justify-content: flex-start; padding: 0 12px; }
|
||||
.sidebar.is-open .company-context { justify-content: flex-start; padding-inline: 10px; }
|
||||
.workspace { display: block; }
|
||||
.topbar { height: 64px; padding: 0 13px; gap: 9px; }
|
||||
.menu-button { display: inline-grid; }
|
||||
.workspace-name { min-width: 0; }
|
||||
.workspace-name span { display: none; }
|
||||
.demo-badge { padding: 2px 6px; }
|
||||
.period-button { display: none; }
|
||||
.topbar-actions .button { width: 40px; padding: 0; font-size: 0; }
|
||||
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; }
|
||||
.metric-grid { grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.metric-card { min-height: 128px; grid-template-columns: minmax(0, 1fr) 38px; padding: 15px; }
|
||||
.metric-icon { width: 38px; height: 38px; border-radius: 12px; }
|
||||
.metric-value { font-size: 25px; }
|
||||
.period-ribbon { grid-template-columns: 1fr 1fr; }
|
||||
.period-ribbon > div { min-height: 64px; }
|
||||
.period-ribbon > div:nth-child(3) { border-top: 1px solid var(--color-line); border-left: 0; }
|
||||
.period-warning { grid-column: 1 / -1; }
|
||||
.panel-heading { min-height: 60px; padding: 11px 13px; }
|
||||
.inline-search { display: none; }
|
||||
.task-list button { grid-template-columns: 30px minmax(0, 1fr) 18px; padding-inline: 12px; }
|
||||
.task-list button > b { display: none; }
|
||||
.quick-pair-form { grid-template-columns: 1fr 38px 1fr; padding-inline: 12px; }
|
||||
.quick-result { margin-inline: 12px; }
|
||||
.company-ledger summary { grid-template-columns: minmax(145px, 1fr) 100px 24px; padding-inline: 12px; }
|
||||
.company-ledger summary > strong:nth-of-type(2), .company-ledger summary > span:nth-child(4), .company-ledger summary > span:nth-child(5) { display: none; }
|
||||
.company-name { grid-template-columns: 30px minmax(0, 1fr); }
|
||||
.company-name i { width: 28px; height: 28px; }
|
||||
.filter-bar { align-items: stretch; flex-direction: column; }
|
||||
.segmented { width: 100%; }
|
||||
.flow-filters { grid-template-columns: 1fr 1fr; }
|
||||
.flow-filters .grow, .flow-filters .button { grid-column: 1 / -1; }
|
||||
.pair-query-form { grid-template-columns: 1fr 38px 1fr; }
|
||||
.pair-query-form .field:nth-of-type(3), .pair-query-form .button { grid-column: 1 / -1; }
|
||||
.pair-report-heading { align-items: flex-start; flex-direction: column; }
|
||||
.pair-balance-line { grid-template-columns: 1fr 1fr; }
|
||||
.pair-balance-line .pair-final { grid-column: auto; border-left: 1px solid var(--color-line); }
|
||||
.subject-strip { grid-template-columns: 1fr 1fr; }
|
||||
.company-alert { grid-template-columns: 28px 1fr; }
|
||||
.company-alert .button { grid-column: 1 / -1; }
|
||||
.cashier-tasks article { grid-template-columns: 30px minmax(0, 1fr); }
|
||||
.cashier-tasks .button { grid-column: 2; justify-self: start; }
|
||||
.work-progress { grid-template-columns: 1fr; }
|
||||
.work-progress i { display: none; }
|
||||
.reconcile-summary { grid-template-columns: 1fr; }
|
||||
.reconcile-summary div { min-height: 70px; border-top: 1px solid var(--color-line); border-left: 0; }
|
||||
.reconcile-summary div:first-child { border-top: 0; }
|
||||
.candidate, .candidate-options { align-items: stretch; flex-direction: column; margin-left: 0; }
|
||||
.account-directory { grid-template-columns: 1fr; }
|
||||
.account-directory dl { grid-template-columns: 1fr 1fr; }
|
||||
.closing-checks { grid-template-columns: 1fr; }
|
||||
.closing-checks article { border-top: 1px solid var(--color-line); border-left: 0; }
|
||||
.closing-footer { align-items: stretch; flex-direction: column; }
|
||||
.notification-list article { grid-template-columns: 34px minmax(0, 1fr); }
|
||||
.notification-list article > em { grid-column: 2; justify-self: start; }
|
||||
.table-summary { align-items: flex-start; flex-direction: column; justify-content: center; padding-block: 9px; }
|
||||
.dialog { width: 100%; max-width: none; height: 100%; max-height: none; border-radius: 0; }
|
||||
.dialog > form { min-height: 100%; display: flex; flex-direction: column; }
|
||||
.dialog-body { flex: 1; }
|
||||
.form-grid { grid-template-columns: 1fr; }
|
||||
.toast-region { right: 12px; bottom: 12px; left: 12px; }
|
||||
.toast { min-width: 0; max-width: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 460px) {
|
||||
.metric-card { min-height: 150px; padding: 13px; }
|
||||
.metric-copy { min-height: 52px; }
|
||||
.metric-label { font-size: 11px; }
|
||||
.metric-value { font-size: 23px; }
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.timeline-strip { grid-template-columns: 1fr; }
|
||||
.timeline-legend { padding-bottom: 4px; }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.company-row { grid-template-columns: 34px minmax(0, 1fr) auto; }
|
||||
.company-row-figure { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.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; }
|
||||
|
||||
@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; }
|
||||
.button.is-loading .spinner { animation-duration: 1s !important; animation-iteration-count: infinite !important; }
|
||||
}
|
||||
Reference in New Issue
Block a user