Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
815b68c1fc | ||
|
|
609ebffdc9 | ||
|
|
77fc625ded | ||
|
|
ece3e53472 | ||
|
|
5816e8aa71 |
@@ -19,3 +19,6 @@ nul
|
||||
.agent_context/
|
||||
.kimi/
|
||||
|
||||
vendor_pkgs/
|
||||
node_modules/
|
||||
package-lock.json
|
||||
|
||||
@@ -11,8 +11,8 @@ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from bank_importer import (
|
||||
auth, importing, ledger_events, manual_records, master_data, matching,
|
||||
multipart, personal_transit, positions, subjects,
|
||||
auth, dashboard, importing, ledger_events, manual_records, master_data, matching,
|
||||
multipart, personal_transit, positions, settings, subjects,
|
||||
)
|
||||
from bank_importer.db import connect, migrate, utc_now
|
||||
|
||||
@@ -80,12 +80,28 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
if path == "/api/admin/audit-log":
|
||||
self._handle_admin_audit_log(query)
|
||||
return
|
||||
if path == "/api/admin/settings":
|
||||
self._handle_admin_settings()
|
||||
return
|
||||
if path == "/api/admin/reminders":
|
||||
self._handle_admin_reminders(query)
|
||||
return
|
||||
if path == "/api/admin/reminders/pending":
|
||||
self._handle_admin_reminder_pending(query)
|
||||
return
|
||||
if path == "/api/admin/transfer-events":
|
||||
self._handle_admin_transfer_events(query)
|
||||
return
|
||||
if path == "/api/admin/match-exceptions":
|
||||
self._handle_admin_match_exceptions(query)
|
||||
return
|
||||
if path == "/api/admin/dashboard":
|
||||
self._handle_admin_dashboard(query)
|
||||
return
|
||||
company_dash = re.fullmatch(r"/api/admin/dashboard/companies/(\d+)", path)
|
||||
if company_dash:
|
||||
self._handle_admin_dashboard_company(int(company_dash.group(1)), query)
|
||||
return
|
||||
if path == "/api/admin/personal-transit-mappings":
|
||||
self._handle_admin_personal_mappings(query)
|
||||
return
|
||||
@@ -232,6 +248,12 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
if mapping_review:
|
||||
self._handle_admin_review_personal_mapping(int(mapping_review.group(1)))
|
||||
return
|
||||
if path == "/api/admin/settings":
|
||||
self._handle_admin_update_settings()
|
||||
return
|
||||
if path == "/api/admin/reminders/send":
|
||||
self._handle_admin_send_reminders()
|
||||
return
|
||||
|
||||
# B-44 intercompany positions (admin writes)
|
||||
subject_decision = re.fullmatch(
|
||||
@@ -297,7 +319,7 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
if user is None:
|
||||
return None
|
||||
if user["role"] != "admin":
|
||||
self._send_json(403, {"status": "error", "message": "该操作仅限总账管理员。"})
|
||||
self._send_json(403, {"status": "error", "message": "该操作仅限管理员。"})
|
||||
return None
|
||||
return user
|
||||
|
||||
@@ -354,7 +376,7 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
return
|
||||
if reason == "disabled":
|
||||
self._send_json(
|
||||
403, {"status": "error", "message": "账号已停用,请联系总账管理员。"}
|
||||
403, {"status": "error", "message": "账号已停用,请联系管理员。"}
|
||||
)
|
||||
return
|
||||
if user is None:
|
||||
@@ -1404,6 +1426,204 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# System settings (admin read/write, persisted + audited)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _handle_admin_settings(self) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user = self._require_admin(connection)
|
||||
if user is None:
|
||||
return
|
||||
values = settings.get_settings(connection)
|
||||
self._send_json(200, {"status": "ok", "settings": values})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_admin_update_settings(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
|
||||
before = settings.get_settings(connection)
|
||||
try:
|
||||
updated = settings.update_settings(connection, data, user)
|
||||
except ValueError as exc:
|
||||
self._send_json(400, {"status": "error", "message": str(exc)})
|
||||
return
|
||||
changed = {
|
||||
key: updated[key]
|
||||
for key in updated
|
||||
if before.get(key) != updated[key]
|
||||
}
|
||||
auth.audit(
|
||||
connection,
|
||||
"settings_update",
|
||||
actor=user,
|
||||
target="system_settings",
|
||||
detail=";".join(
|
||||
f"{key}:{before.get(key)}->{updated[key]}" for key in changed
|
||||
),
|
||||
ip=self._client_ip,
|
||||
)
|
||||
self._send_json(200, {"status": "ok", "settings": updated})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Reminder management (admin)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _reminder_payload(row) -> dict[str, object]:
|
||||
payload = {
|
||||
"id": row["id"],
|
||||
"company_id": row["company_id"],
|
||||
"company_name": row["company_name"],
|
||||
"kind": row["kind"],
|
||||
"content": row["content"],
|
||||
"deadline": row["deadline"],
|
||||
"source": row["source"],
|
||||
"status": row["status"],
|
||||
"actor_username": row["actor_username"],
|
||||
"created_at": row["created_at"],
|
||||
}
|
||||
return payload
|
||||
|
||||
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
|
||||
conditions: list[str] = []
|
||||
params: list[object] = []
|
||||
raw_company = (query.get("company_id") or [None])[0]
|
||||
if raw_company:
|
||||
try:
|
||||
params.append(int(raw_company))
|
||||
except ValueError:
|
||||
self._send_json(400, {"status": "error", "message": "company_id 参数无效。"})
|
||||
return
|
||||
conditions.append("r.company_id = ?")
|
||||
raw_limit = (query.get("limit") or ["200"])[0]
|
||||
try:
|
||||
limit = max(1, min(int(raw_limit), 500))
|
||||
except ValueError:
|
||||
limit = 200
|
||||
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
rows = connection.execute(
|
||||
f"""
|
||||
SELECT r.id, r.company_id, c.name AS company_name, r.kind,
|
||||
r.content, r.deadline, r.source, r.status,
|
||||
r.actor_username, r.created_at
|
||||
FROM reminders r
|
||||
JOIN companies c ON c.id = r.company_id
|
||||
{where}
|
||||
ORDER BY r.id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(*params, limit),
|
||||
).fetchall()
|
||||
self._send_json(
|
||||
200,
|
||||
{"status": "ok",
|
||||
"reminders": [self._reminder_payload(row) for row in rows]},
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_admin_reminder_pending(self, query: dict[str, list[str]]) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user = self._require_admin(connection)
|
||||
if user is None:
|
||||
return
|
||||
raw_company = (query.get("company_id") or [None])[0]
|
||||
try:
|
||||
company_id = int(raw_company)
|
||||
except (TypeError, ValueError):
|
||||
self._send_json(400, {"status": "error", "message": "company_id 参数无效。"})
|
||||
return
|
||||
company = connection.execute(
|
||||
"SELECT id, name FROM companies WHERE id = ?", (company_id,)
|
||||
).fetchone()
|
||||
if company is None:
|
||||
self._send_json(404, {"status": "error", "message": "公司不存在。"})
|
||||
return
|
||||
items = settings.pending_items(connection, company_id)
|
||||
self._send_json(
|
||||
200,
|
||||
{
|
||||
"status": "ok",
|
||||
"company_id": company_id,
|
||||
"company_name": company["name"],
|
||||
"items": items,
|
||||
},
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_admin_send_reminders(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(str(data.get("company_id")))
|
||||
except (TypeError, ValueError):
|
||||
self._send_json(400, {"status": "error", "message": "company_id 参数无效。"})
|
||||
return
|
||||
company = connection.execute(
|
||||
"SELECT id, name FROM companies WHERE id = ?", (company_id,)
|
||||
).fetchone()
|
||||
if company is None:
|
||||
self._send_json(404, {"status": "error", "message": "公司不存在。"})
|
||||
return
|
||||
try:
|
||||
created, deadline = settings.send_reminders(
|
||||
connection, company_id, user
|
||||
)
|
||||
except ValueError as exc:
|
||||
self._send_json(400, {"status": "error", "message": str(exc)})
|
||||
return
|
||||
if not created:
|
||||
self._send_json(
|
||||
400,
|
||||
{"status": "error",
|
||||
"message": "该公司当前没有待提醒事项,无需发送。"},
|
||||
)
|
||||
return
|
||||
auth.audit(
|
||||
connection,
|
||||
"reminder_send",
|
||||
actor=user,
|
||||
target=f"company:{company_id}",
|
||||
detail=f"items:{len(created)}",
|
||||
ip=self._client_ip,
|
||||
)
|
||||
self._send_json(
|
||||
200,
|
||||
{
|
||||
"status": "ok",
|
||||
"company_id": company_id,
|
||||
"company_name": company["name"],
|
||||
"deadline": deadline,
|
||||
"reminders": created,
|
||||
},
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Canonical transfer events (admin)
|
||||
# ------------------------------------------------------------------
|
||||
@@ -1653,6 +1873,49 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_admin_dashboard(self, query: dict[str, list[str]]) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user = self._require_admin(connection)
|
||||
if user is None:
|
||||
return
|
||||
from_date = (query.get("from") or ["2026-01-01"])[0] or "2026-01-01"
|
||||
cutoff = (query.get("cutoff") or [None])[0] or None
|
||||
try:
|
||||
payload = dashboard.build_dashboard(
|
||||
connection, from_date=from_date, cutoff=cutoff
|
||||
)
|
||||
except ValueError as exc:
|
||||
self._send_json(400, {"status": "error", "message": str(exc)})
|
||||
return
|
||||
self._send_json(200, {"status": "ok", **payload})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_admin_dashboard_company(
|
||||
self, company_id: int, query: dict[str, list[str]]
|
||||
) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user = self._require_admin(connection)
|
||||
if user is None:
|
||||
return
|
||||
from_date = (query.get("from") or ["2026-01-01"])[0] or "2026-01-01"
|
||||
cutoff = (query.get("cutoff") or [dashboard.today_shanghai()])[0]
|
||||
try:
|
||||
payload = dashboard.company_peer_groups(
|
||||
connection, company_id, from_date=from_date, cutoff=cutoff
|
||||
)
|
||||
except KeyError:
|
||||
self._send_json(404, {"status": "error", "message": "公司不存在。"})
|
||||
return
|
||||
except ValueError as exc:
|
||||
self._send_json(400, {"status": "error", "message": str(exc)})
|
||||
return
|
||||
self._send_json(200, {"status": "ok", **payload})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_admin_reconcile(self) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
"""Admin dashboard aggregates over existing B-43 eligible events and review queues.
|
||||
|
||||
No fabricated demo amounts. Opening balances are unavailable until B-45; peer
|
||||
group ``opening`` is always null and ``ending`` equals period net change.
|
||||
Period closing status is unknown without the monthly-close module — returned
|
||||
as null so the UI shows an em dash rather than a guessed label.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
import sqlite3
|
||||
|
||||
|
||||
WAN = Decimal("10000")
|
||||
ZERO = Decimal("0")
|
||||
TWOPLACES = Decimal("0.01")
|
||||
|
||||
|
||||
def today_shanghai() -> str:
|
||||
return datetime.now(timezone(timedelta(hours=8))).date().isoformat()
|
||||
|
||||
|
||||
def _q2(value: Decimal) -> str:
|
||||
return str(value.quantize(TWOPLACES, rounding=ROUND_HALF_UP))
|
||||
|
||||
|
||||
def _to_wan(amount: Decimal) -> str:
|
||||
return _q2(amount / WAN)
|
||||
|
||||
|
||||
def _parse_day(iso_ts: str | None) -> str | None:
|
||||
if not iso_ts:
|
||||
return None
|
||||
text = str(iso_ts)
|
||||
return text[:10] if len(text) >= 10 else None
|
||||
|
||||
|
||||
def audit_counts(connection: sqlite3.Connection) -> dict[str, int]:
|
||||
"""Derive pending review counts from real queues only.
|
||||
|
||||
Priority mapping (aligned with current admin audit UI semantics):
|
||||
- high: unresolved / needs_review match exceptions
|
||||
- medium: pending bank-account registrations
|
||||
- low: reserved for future low-risk queues (currently always 0)
|
||||
"""
|
||||
high = connection.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS n
|
||||
FROM current_transfer_decisions c
|
||||
JOIN transfer_match_decisions d ON d.id = c.decision_id
|
||||
JOIN canonical_transfer_events e ON e.id = c.event_id
|
||||
WHERE e.lifecycle = 'active'
|
||||
AND d.classification IN ('unresolved', 'needs_review')
|
||||
"""
|
||||
).fetchone()["n"]
|
||||
medium = connection.execute(
|
||||
"SELECT COUNT(*) AS n FROM bank_accounts WHERE status = 'pending'"
|
||||
).fetchone()["n"]
|
||||
low = 0
|
||||
return {
|
||||
"total": int(high) + int(medium) + int(low),
|
||||
"high": int(high),
|
||||
"medium": int(medium),
|
||||
"low": int(low),
|
||||
}
|
||||
|
||||
|
||||
def _load_eligible(
|
||||
connection: sqlite3.Connection, *, from_date: str, cutoff: str
|
||||
) -> list[sqlite3.Row]:
|
||||
return connection.execute(
|
||||
"""
|
||||
SELECT e.event_id, e.decision_id, e.effective_at, e.amount, e.currency,
|
||||
e.payer_company_id, e.payee_company_id, e.pairing,
|
||||
cpayer.name AS payer_name, cpayee.name AS payee_name
|
||||
FROM eligible_intercompany_events e
|
||||
JOIN companies cpayer ON cpayer.id = e.payer_company_id
|
||||
JOIN companies cpayee ON cpayee.id = e.payee_company_id
|
||||
WHERE date(e.effective_at) >= date(?)
|
||||
AND date(e.effective_at) <= date(?)
|
||||
ORDER BY e.effective_at, e.event_id
|
||||
""",
|
||||
(from_date, cutoff),
|
||||
).fetchall()
|
||||
|
||||
|
||||
def _company_rows(connection: sqlite3.Connection) -> list[sqlite3.Row]:
|
||||
return connection.execute(
|
||||
"""
|
||||
SELECT id, name FROM companies
|
||||
WHERE status != 'disabled'
|
||||
ORDER BY id
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
|
||||
def company_summaries(
|
||||
connection: sqlite3.Connection, *, from_date: str, cutoff: str
|
||||
) -> tuple[list[dict[str, object]], dict[str, object]]:
|
||||
companies = _company_rows(connection)
|
||||
events = _load_eligible(connection, from_date=from_date, cutoff=cutoff)
|
||||
|
||||
debit_total = ZERO
|
||||
credit_total = ZERO
|
||||
by_id: dict[int, dict[str, object]] = {}
|
||||
for company in companies:
|
||||
by_id[int(company["id"])] = {
|
||||
"id": int(company["id"]),
|
||||
"name": company["name"],
|
||||
"detail_count": 0,
|
||||
"debit": ZERO,
|
||||
"credit": ZERO,
|
||||
"period_status": None,
|
||||
"period_status_label": "—",
|
||||
}
|
||||
|
||||
for event in events:
|
||||
amount = Decimal(str(event["amount"]))
|
||||
debit_total += amount
|
||||
credit_total += amount
|
||||
payer_id = int(event["payer_company_id"])
|
||||
payee_id = int(event["payee_company_id"])
|
||||
if payer_id in by_id:
|
||||
row = by_id[payer_id]
|
||||
row["detail_count"] = int(row["detail_count"]) + 1
|
||||
row["debit"] = Decimal(row["debit"]) + amount
|
||||
if payee_id in by_id:
|
||||
row = by_id[payee_id]
|
||||
row["detail_count"] = int(row["detail_count"]) + 1
|
||||
row["credit"] = Decimal(row["credit"]) + amount
|
||||
|
||||
items: list[dict[str, object]] = []
|
||||
for company in companies:
|
||||
row = by_id[int(company["id"])]
|
||||
debit = Decimal(row["debit"])
|
||||
credit = Decimal(row["credit"])
|
||||
# Payee inflow − payer outflow = signed net from this company's view.
|
||||
net = credit - debit
|
||||
items.append(
|
||||
{
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
"detail_count": row["detail_count"],
|
||||
"debit_wan": _to_wan(debit),
|
||||
"credit_wan": _to_wan(credit),
|
||||
"net_wan": _to_wan(net),
|
||||
"period_status": row["period_status"],
|
||||
"period_status_label": row["period_status_label"],
|
||||
}
|
||||
)
|
||||
|
||||
totals = {
|
||||
"company_count": len(items),
|
||||
"detail_count": len(events),
|
||||
"debit_wan": _to_wan(debit_total),
|
||||
"credit_wan": _to_wan(credit_total),
|
||||
"net_wan": _to_wan(debit_total - credit_total),
|
||||
}
|
||||
return items, totals
|
||||
|
||||
|
||||
def company_peer_groups(
|
||||
connection: sqlite3.Connection,
|
||||
company_id: int,
|
||||
*,
|
||||
from_date: str,
|
||||
cutoff: str,
|
||||
) -> dict[str, object]:
|
||||
company = connection.execute(
|
||||
"SELECT id, name FROM companies WHERE id = ?", (company_id,)
|
||||
).fetchone()
|
||||
if company is None:
|
||||
raise KeyError(company_id)
|
||||
|
||||
events = connection.execute(
|
||||
"""
|
||||
SELECT e.event_id, e.effective_at, e.amount, e.currency,
|
||||
e.payer_company_id, e.payee_company_id,
|
||||
cpayer.name AS payer_name, cpayee.name AS payee_name
|
||||
FROM eligible_intercompany_events e
|
||||
JOIN companies cpayer ON cpayer.id = e.payer_company_id
|
||||
JOIN companies cpayee ON cpayee.id = e.payee_company_id
|
||||
WHERE (e.payer_company_id = ? OR e.payee_company_id = ?)
|
||||
AND date(e.effective_at) >= date(?)
|
||||
AND date(e.effective_at) <= date(?)
|
||||
ORDER BY e.effective_at, e.event_id
|
||||
""",
|
||||
(company_id, company_id, from_date, cutoff),
|
||||
).fetchall()
|
||||
|
||||
groups: dict[int, dict[str, object]] = {}
|
||||
for event in events:
|
||||
amount = Decimal(str(event["amount"]))
|
||||
payer_id = int(event["payer_company_id"])
|
||||
payee_id = int(event["payee_company_id"])
|
||||
if payer_id == company_id:
|
||||
peer_id = payee_id
|
||||
peer_name = event["payee_name"]
|
||||
direction = "debit"
|
||||
summary = f"付往 {peer_name}"
|
||||
else:
|
||||
peer_id = payer_id
|
||||
peer_name = event["payer_name"]
|
||||
direction = "credit"
|
||||
summary = f"收自 {peer_name}"
|
||||
|
||||
bucket = groups.get(peer_id)
|
||||
if bucket is None:
|
||||
bucket = {
|
||||
"peer_id": peer_id,
|
||||
"peer_name": peer_name,
|
||||
"count": 0,
|
||||
"opening": None,
|
||||
"opening_status": "unavailable",
|
||||
"debit": ZERO,
|
||||
"credit": ZERO,
|
||||
"lines": [],
|
||||
}
|
||||
groups[peer_id] = bucket
|
||||
|
||||
bucket["count"] = int(bucket["count"]) + 1
|
||||
if direction == "debit":
|
||||
bucket["debit"] = Decimal(bucket["debit"]) + amount
|
||||
else:
|
||||
bucket["credit"] = Decimal(bucket["credit"]) + amount
|
||||
|
||||
day = _parse_day(event["effective_at"]) or ""
|
||||
bucket["lines"].append(
|
||||
{
|
||||
"event_id": int(event["event_id"]),
|
||||
"date": day,
|
||||
"direction": direction,
|
||||
"summary": summary,
|
||||
"amount_wan": _to_wan(amount),
|
||||
"currency": event["currency"] or "CNY",
|
||||
}
|
||||
)
|
||||
|
||||
result_groups: list[dict[str, object]] = []
|
||||
for peer_id in sorted(groups.keys(), key=lambda i: groups[i]["peer_name"]):
|
||||
bucket = groups[peer_id]
|
||||
debit = Decimal(bucket["debit"])
|
||||
credit = Decimal(bucket["credit"])
|
||||
ending = credit - debit
|
||||
result_groups.append(
|
||||
{
|
||||
"peer_id": bucket["peer_id"],
|
||||
"peer_name": bucket["peer_name"],
|
||||
"count": bucket["count"],
|
||||
"opening": None,
|
||||
"opening_status": "unavailable",
|
||||
"debit_wan": _to_wan(debit),
|
||||
"credit_wan": _to_wan(credit),
|
||||
"ending_wan": _to_wan(ending),
|
||||
"result_kind": "period_net_change",
|
||||
"lines": bucket["lines"],
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"company_id": int(company["id"]),
|
||||
"company_name": company["name"],
|
||||
"from_date": from_date,
|
||||
"cutoff": cutoff,
|
||||
"groups": result_groups,
|
||||
}
|
||||
|
||||
|
||||
def weekly_flow(
|
||||
connection: sqlite3.Connection, *, cutoff: str, days: int = 7
|
||||
) -> dict[str, object]:
|
||||
end = datetime.strptime(cutoff, "%Y-%m-%d").date()
|
||||
start = end - timedelta(days=days - 1)
|
||||
labels: list[str] = []
|
||||
inflow = [ZERO] * days
|
||||
outflow = [ZERO] * days
|
||||
index: dict[str, int] = {}
|
||||
for offset in range(days):
|
||||
day = start + timedelta(days=offset)
|
||||
key = day.isoformat()
|
||||
index[key] = offset
|
||||
labels.append(f"{day.month:02d}-{day.day:02d}")
|
||||
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT date(e.effective_at) AS day, e.amount
|
||||
FROM eligible_intercompany_events e
|
||||
WHERE date(e.effective_at) >= date(?)
|
||||
AND date(e.effective_at) <= date(?)
|
||||
""",
|
||||
(start.isoformat(), cutoff),
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
day = row["day"]
|
||||
if day not in index:
|
||||
continue
|
||||
amount = Decimal(str(row["amount"]))
|
||||
# Group-level flow: every eligible transfer is both an outflow (payer)
|
||||
# and an inflow (payee); plot both series with the same absolute amount.
|
||||
inflow[index[day]] += amount
|
||||
outflow[index[day]] += amount
|
||||
|
||||
return {
|
||||
"labels": labels,
|
||||
"inflow_wan": [_to_wan(v) for v in inflow],
|
||||
"outflow_wan": [_to_wan(v) for v in outflow],
|
||||
}
|
||||
|
||||
|
||||
def build_dashboard(
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
from_date: str = "2026-01-01",
|
||||
cutoff: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
cutoff_date = cutoff or today_shanghai()
|
||||
try:
|
||||
datetime.strptime(from_date, "%Y-%m-%d")
|
||||
datetime.strptime(cutoff_date, "%Y-%m-%d")
|
||||
except ValueError as exc:
|
||||
raise ValueError("日期必须是 YYYY-MM-DD") from exc
|
||||
if from_date > cutoff_date:
|
||||
raise ValueError("from 不能晚于 cutoff")
|
||||
|
||||
period = datetime.strptime(cutoff_date, "%Y-%m-%d")
|
||||
# Display month for the status column header: use the calendar month of cutoff.
|
||||
period_month = period.month
|
||||
companies, totals = company_summaries(
|
||||
connection, from_date=from_date, cutoff=cutoff_date
|
||||
)
|
||||
return {
|
||||
"from_date": from_date,
|
||||
"cutoff": cutoff_date,
|
||||
"period_month": period_month,
|
||||
"period_label": f"{period.year}-{period.month:02d}",
|
||||
"audit": audit_counts(connection),
|
||||
"totals": totals,
|
||||
"companies": companies,
|
||||
"weekly_flow": weekly_flow(connection, cutoff=cutoff_date),
|
||||
"opening_status": "unavailable",
|
||||
}
|
||||
@@ -798,6 +798,54 @@ MIGRATIONS: tuple[Migration, ...] = (
|
||||
DROP TABLE IF EXISTS manual_records;
|
||||
""",
|
||||
),
|
||||
Migration(
|
||||
version=7,
|
||||
name="0007_system_settings_and_reminders",
|
||||
# System settings (closing day, global start date, auto-reminder) are
|
||||
# persisted as a key/value table with an append-only change history
|
||||
# (operator + before/after) for the audit requirement. Reminders are a
|
||||
# separate append-only table so reminder history survives re-sends.
|
||||
up="""
|
||||
CREATE TABLE system_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_by INTEGER REFERENCES users (id),
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE system_setting_changes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT NOT NULL,
|
||||
before_value TEXT,
|
||||
after_value TEXT NOT NULL,
|
||||
actor_user_id INTEGER REFERENCES users (id),
|
||||
actor_username TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE reminders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
company_id INTEGER NOT NULL REFERENCES companies (id),
|
||||
kind TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
deadline TEXT,
|
||||
source TEXT NOT NULL CHECK (source IN ('system', 'manual')),
|
||||
status TEXT NOT NULL DEFAULT 'unread'
|
||||
CHECK (status IN ('unread', 'done')),
|
||||
actor_user_id INTEGER REFERENCES users (id),
|
||||
actor_username TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_reminders_company ON reminders (company_id);
|
||||
""",
|
||||
down="""
|
||||
DROP INDEX IF EXISTS idx_reminders_company;
|
||||
DROP TABLE IF EXISTS reminders;
|
||||
DROP TABLE IF EXISTS system_setting_changes;
|
||||
DROP TABLE IF EXISTS system_settings;
|
||||
""",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -262,7 +262,7 @@ def submit_bank_account(
|
||||
|
||||
if existing["company_id"] == company_id:
|
||||
raise ConflictError("该银行账号已登记,请等待现有申请处理。")
|
||||
raise ConflictError("该银行账号已被其他公司登记,请联系总账管理员核对。")
|
||||
raise ConflictError("该银行账号已被其他公司登记,请联系管理员核对。")
|
||||
|
||||
|
||||
def review_bank_account(
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
"""System settings and reminder item generation.
|
||||
|
||||
System-wide parameters (closing day, global start date, auto-reminder toggle
|
||||
and lead days) are persisted in ``system_settings`` with an append-only
|
||||
``system_setting_changes`` trail. Reminder pending items are derived from real
|
||||
backend data (per-sheet reviews, bank accounts, canonical transfer decisions)
|
||||
rather than hard-coded rosters.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .db import utc_now
|
||||
|
||||
# Defaults are applied when a key is absent; the value type is always string.
|
||||
DEFAULT_SETTINGS: dict[str, str] = {
|
||||
"closing_day": "5",
|
||||
"start_date": "2026-01-01",
|
||||
"auto_remind": "1",
|
||||
"remind_days": "3",
|
||||
}
|
||||
|
||||
_SETTING_KEYS = frozenset({"closing_day", "start_date", "auto_remind", "remind_days"})
|
||||
|
||||
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||||
|
||||
|
||||
def _valid_date(value: str) -> bool:
|
||||
if not _DATE_RE.match(value):
|
||||
return False
|
||||
try:
|
||||
datetime.strptime(value, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def get_settings(connection: sqlite3.Connection) -> dict[str, str]:
|
||||
settings = dict(DEFAULT_SETTINGS)
|
||||
rows = connection.execute(
|
||||
"SELECT key, value FROM system_settings"
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
settings[row["key"]] = row["value"]
|
||||
return settings
|
||||
|
||||
|
||||
def validate_settings(values: dict[str, object]) -> tuple[dict[str, str], str | None]:
|
||||
"""Return ``(cleaned, error)``; ``cleaned`` holds only recognized keys."""
|
||||
cleaned: dict[str, str] = {}
|
||||
if "closing_day" in values:
|
||||
raw = str(values["closing_day"]).strip()
|
||||
try:
|
||||
day = int(raw)
|
||||
except ValueError:
|
||||
return cleaned, "每月结账日须为 1-28 之间的整数。"
|
||||
if day < 1 or day > 28:
|
||||
return cleaned, "每月结账日须为 1-28 之间的整数。"
|
||||
cleaned["closing_day"] = str(day)
|
||||
if "start_date" in values:
|
||||
raw = str(values["start_date"]).strip()
|
||||
if not _valid_date(raw):
|
||||
return cleaned, "全局起算日须为有效日期(YYYY-MM-DD)。"
|
||||
cleaned["start_date"] = raw
|
||||
if "auto_remind" in values:
|
||||
raw = str(values["auto_remind"]).strip()
|
||||
if raw not in {"0", "1"}:
|
||||
return cleaned, "自动提醒开关须为 0 或 1。"
|
||||
cleaned["auto_remind"] = raw
|
||||
if "remind_days" in values:
|
||||
raw = str(values["remind_days"]).strip()
|
||||
try:
|
||||
days = int(raw)
|
||||
except ValueError:
|
||||
return cleaned, "提前提醒天数须为不小于 1 的整数。"
|
||||
if days < 1 or days > 30:
|
||||
return cleaned, "提前提醒天数须为 1-30 之间的整数。"
|
||||
cleaned["remind_days"] = str(days)
|
||||
return cleaned, None
|
||||
|
||||
|
||||
def update_settings(
|
||||
connection: sqlite3.Connection,
|
||||
values: dict[str, object],
|
||||
actor: sqlite3.Row,
|
||||
) -> dict[str, str]:
|
||||
cleaned, error = validate_settings(values)
|
||||
if error is not None:
|
||||
raise ValueError(error)
|
||||
if not cleaned:
|
||||
raise ValueError("没有需要保存的设置项。")
|
||||
current = get_settings(connection)
|
||||
with connection:
|
||||
for key, new_value in cleaned.items():
|
||||
old_value = current.get(key)
|
||||
if old_value == new_value:
|
||||
continue
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO system_settings (key, value, updated_by, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
updated_by = excluded.updated_by,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(key, new_value, actor["id"], utc_now()),
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO system_setting_changes (
|
||||
key, before_value, after_value,
|
||||
actor_user_id, actor_username, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
key,
|
||||
old_value,
|
||||
new_value,
|
||||
actor["id"],
|
||||
actor["username"],
|
||||
utc_now(),
|
||||
),
|
||||
)
|
||||
return get_settings(connection)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Reminder pending items
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
def _current_period() -> tuple[str, str]:
|
||||
"""Return the current calendar month as ``(start, end_exclusive)`` dates."""
|
||||
today = datetime.now(timezone.utc)
|
||||
start = today.strftime("%Y-%m-01")
|
||||
year, month = today.year, today.month
|
||||
if month == 12:
|
||||
end = f"{year + 1}-01-01"
|
||||
else:
|
||||
end = f"{year}-{month + 1:02d}-01"
|
||||
return start, end
|
||||
|
||||
|
||||
def pending_items(connection: sqlite3.Connection, company_id: int) -> list[dict[str, str]]:
|
||||
"""Derive the list of pending reminder items for one company."""
|
||||
items: list[dict[str, str]] = []
|
||||
|
||||
company = connection.execute(
|
||||
"SELECT id, name FROM companies WHERE id = ?", (company_id,)
|
||||
).fetchone()
|
||||
if company is None:
|
||||
return items
|
||||
|
||||
period_start, _ = _current_period()
|
||||
|
||||
# 1) 本月流水未提交:有已启用账户,但本月没有任何已确认的工作表。
|
||||
active_accounts = connection.execute(
|
||||
"""
|
||||
SELECT id, account_number FROM bank_accounts
|
||||
WHERE company_id = ? AND status = 'active'
|
||||
""",
|
||||
(company_id,),
|
||||
).fetchall()
|
||||
confirmed_this_period = connection.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS n
|
||||
FROM sheet_reviews rv
|
||||
JOIN import_batches b ON b.id = rv.import_batch_id
|
||||
WHERE b.company_id = ? AND rv.review_status = 'confirmed'
|
||||
AND rv.sheet_batch_id IN (
|
||||
SELECT id FROM sheet_batches s
|
||||
WHERE s.period_end >= ?
|
||||
)
|
||||
""",
|
||||
(company_id, period_start),
|
||||
).fetchone()["n"]
|
||||
if active_accounts and confirmed_this_period == 0:
|
||||
items.append(
|
||||
{
|
||||
"kind": "流水未提交",
|
||||
"content": "本月各银行账户流水尚未提交,请尽快上传本月银行流水。",
|
||||
}
|
||||
)
|
||||
|
||||
# 2) 待确认工作表:仍有未确认的解析结果。
|
||||
pending_sheets = connection.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS n
|
||||
FROM sheet_reviews rv
|
||||
JOIN import_batches b ON b.id = rv.import_batch_id
|
||||
WHERE b.company_id = ? AND rv.review_status = 'pending'
|
||||
""",
|
||||
(company_id,),
|
||||
).fetchone()["n"]
|
||||
if pending_sheets:
|
||||
items.append(
|
||||
{
|
||||
"kind": "待确认工作表",
|
||||
"content": f"有 {pending_sheets} 个导入工作表尚未确认,请核对后确认。",
|
||||
}
|
||||
)
|
||||
|
||||
# 3) 待审核账户登记:处于待复核状态的银行账户。
|
||||
pending_accounts = connection.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS n FROM bank_accounts
|
||||
WHERE company_id = ? AND status = 'pending'
|
||||
""",
|
||||
(company_id,),
|
||||
).fetchone()["n"]
|
||||
if pending_accounts:
|
||||
items.append(
|
||||
{
|
||||
"kind": "账户登记",
|
||||
"content": f"有 {pending_accounts} 个银行账户登记待审核。",
|
||||
}
|
||||
)
|
||||
|
||||
# 4) 待确认往来事项:尚未解决的往来匹配。
|
||||
pending_transfers = connection.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS n
|
||||
FROM current_transfer_decisions c
|
||||
JOIN transfer_match_decisions d ON d.id = c.decision_id
|
||||
JOIN transfer_decision_participants p
|
||||
ON p.decision_id = d.id AND p.company_id = ?
|
||||
WHERE d.classification IN ('unresolved', 'needs_review')
|
||||
""",
|
||||
(company_id,),
|
||||
).fetchone()["n"]
|
||||
if pending_transfers:
|
||||
items.append(
|
||||
{
|
||||
"kind": "往来待确认",
|
||||
"content": f"有 {pending_transfers} 项往来流水待确认,请核对对方银行流水佐证。",
|
||||
}
|
||||
)
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def send_reminders(
|
||||
connection: sqlite3.Connection,
|
||||
company_id: int,
|
||||
actor: sqlite3.Row,
|
||||
) -> tuple[list[dict[str, str]], str | None]:
|
||||
"""Create reminder rows for a company's pending items.
|
||||
|
||||
Returns ``(created, deadline)``. ``created`` is the list of persisted
|
||||
reminder payloads; ``deadline`` is derived from the closing-day setting.
|
||||
"""
|
||||
items = pending_items(connection, company_id)
|
||||
if not items:
|
||||
return [], None
|
||||
|
||||
settings = get_settings(connection)
|
||||
try:
|
||||
closing_day = int(settings["closing_day"])
|
||||
except ValueError:
|
||||
closing_day = 5
|
||||
today = datetime.now(timezone.utc)
|
||||
# Deadline: next month's closing day (current month if today is before it).
|
||||
if today.day < closing_day:
|
||||
deadline = today.strftime(f"%Y-%m-{closing_day:02d}")
|
||||
else:
|
||||
year, month = today.year, today.month
|
||||
if month == 12:
|
||||
year, month = year + 1, 1
|
||||
else:
|
||||
month += 1
|
||||
deadline = f"{year}-{month:02d}-{closing_day:02d}"
|
||||
|
||||
created: list[dict[str, str]] = []
|
||||
with connection:
|
||||
for item in items:
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO reminders (
|
||||
company_id, kind, content, deadline, source,
|
||||
actor_user_id, actor_username, created_at
|
||||
) VALUES (?, ?, ?, ?, 'manual', ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
company_id,
|
||||
item["kind"],
|
||||
item["content"],
|
||||
deadline,
|
||||
actor["id"],
|
||||
actor["username"],
|
||||
utc_now(),
|
||||
),
|
||||
)
|
||||
created.append(
|
||||
{
|
||||
"id": cursor.lastrowid,
|
||||
"company_id": company_id,
|
||||
"kind": item["kind"],
|
||||
"content": item["content"],
|
||||
"deadline": deadline,
|
||||
"source": "manual",
|
||||
"status": "unread",
|
||||
"actor_username": actor["username"],
|
||||
"created_at": utc_now(),
|
||||
}
|
||||
)
|
||||
return created, deadline
|
||||
@@ -1,86 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const path = require("path");
|
||||
const assert = require("assert");
|
||||
const ui = require(path.join(__dirname, "..", "web", "app.js"));
|
||||
|
||||
assert.strictEqual(ui.fmtAbsMoney(-1280), "1,280.00");
|
||||
assert.strictEqual(ui.fmtAbsMoney(1280), "1,280.00");
|
||||
assert.strictEqual(ui.fmtAbsMoney("60.5"), "60.50");
|
||||
|
||||
assert.strictEqual(ui.eventIsNegative({ posting_kind: "reversal" }), true);
|
||||
assert.strictEqual(ui.eventIsNegative({ posting_kind: "normal", is_repayment: true }), true);
|
||||
assert.strictEqual(ui.eventIsNegative({ posting_kind: "normal", is_repayment: false }), false);
|
||||
|
||||
assert.strictEqual(ui.cycleTab(0, 4, false), 1);
|
||||
assert.strictEqual(ui.cycleTab(3, 4, false), 0);
|
||||
assert.strictEqual(ui.cycleTab(0, 4, true), 3);
|
||||
assert.strictEqual(ui.cycleTab(2, 5, true), 1);
|
||||
assert.strictEqual(ui.cycleTab(0, 0, false), 0);
|
||||
|
||||
assert.strictEqual(ui.drawerEscAction(1), "close");
|
||||
assert.strictEqual(ui.drawerEscAction(0), "close");
|
||||
assert.strictEqual(ui.drawerEscAction(2), "back");
|
||||
assert.strictEqual(ui.drawerEscAction(3), "back");
|
||||
|
||||
const abs = ui.amountWithCurrency(1280, "CNY");
|
||||
assert.ok(abs.includes("CNY"));
|
||||
assert.ok(abs.includes("1,280.00"));
|
||||
assert.ok(!abs.includes("+"));
|
||||
assert.ok(!abs.includes("−"));
|
||||
|
||||
const repay = ui.amountWithCurrency(3200000, "CNY", { signed: true, negative: true });
|
||||
assert.ok(repay.includes("−"));
|
||||
assert.ok(!repay.includes("+"));
|
||||
assert.ok(repay.includes("3,200,000.00"));
|
||||
|
||||
assert.strictEqual(ui.resultDirection(10).label, "应收");
|
||||
assert.strictEqual(ui.resultDirection(-10).label, "应付");
|
||||
assert.strictEqual(ui.resultDirection(0).label, "持平");
|
||||
|
||||
assert.strictEqual(ui.isCompactAmount(1280), false);
|
||||
assert.strictEqual(ui.isCompactAmount(999999999), false);
|
||||
assert.strictEqual(ui.isCompactAmount(1000000000), true);
|
||||
assert.strictEqual(ui.isCompactAmount("123456789012345"), true);
|
||||
assert.ok(ui.amountWithCurrency("123456789012345", "CNY").includes("is-compact"));
|
||||
assert.ok(!ui.amountWithCurrency(1280, "CNY").includes("is-compact"));
|
||||
|
||||
assert.strictEqual(ui.cashDirectionLabel("outgoing"), "转出");
|
||||
assert.strictEqual(ui.cashDirectionLabel("incoming"), "转入");
|
||||
assert.strictEqual(ui.relatedFlowLabel(null), "无关联流水");
|
||||
assert.strictEqual(ui.relatedFlowLabel(""), "无关联流水");
|
||||
assert.strictEqual(ui.relatedFlowLabel("42"), "42");
|
||||
|
||||
const subjectFields = ui.auditEvidenceFields("subject-review", {
|
||||
direction: "outgoing",
|
||||
effectiveAt: "2026-07-18T09:00:00",
|
||||
amount: "600",
|
||||
currency: "CNY",
|
||||
summary: "资金调拨",
|
||||
});
|
||||
assert.deepStrictEqual(subjectFields.map((item) => item[0]), ["方向", "日期", "金额", "摘要"]);
|
||||
assert.strictEqual(subjectFields[0][1], "转出");
|
||||
|
||||
const manualFields = ui.auditEvidenceFields("manual-review", {
|
||||
counterpartyName: "乙公司",
|
||||
direction: "incoming",
|
||||
relatedSourceRowId: "",
|
||||
submittedBy: "出纳甲",
|
||||
attachmentName: "",
|
||||
amount: "80",
|
||||
currency: "CNY",
|
||||
summary: "补记",
|
||||
});
|
||||
assert.deepStrictEqual(
|
||||
manualFields.map((item) => item[0]),
|
||||
["对方公司名", "方向", "关联流水号", "提交人", "附件", "摘要", "金额"],
|
||||
);
|
||||
assert.strictEqual(manualFields[0][1], "乙公司");
|
||||
assert.strictEqual(manualFields[1][1], "转入");
|
||||
assert.strictEqual(manualFields[2][1], "无关联流水");
|
||||
|
||||
assert.ok(ui.accountCell({ visibility: "visible", label: "中信 5316" }).includes("中信 5316"));
|
||||
assert.ok(ui.accountCell({ visibility: "masked" }).includes("按对方授权不可见"));
|
||||
assert.ok(ui.accountCell({ visibility: "missing" }).includes("源行缺失"));
|
||||
|
||||
console.log("b44_ui_check ok");
|
||||
-138
@@ -1,138 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>B-44 余额目录布局回归</title>
|
||||
<link rel="stylesheet" href="../../web/styles.css" />
|
||||
</head>
|
||||
<body data-portal="admin">
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar" id="sidebar" aria-label="总账管理导航">
|
||||
<div class="brand"><span class="brand-mark">金</span><span class="brand-copy"><strong>金牛集团</strong><small>总账管理端</small></span></div>
|
||||
<nav class="nav-list">
|
||||
<button class="nav-item is-active" type="button"><span>往来查询</span></button>
|
||||
</nav>
|
||||
</aside>
|
||||
<div class="workspace">
|
||||
<main id="main-content">
|
||||
<section class="app-view is-active">
|
||||
<header class="page-heading"><div><h1>往来查询</h1><p>公司间往来余额目录</p></div></header>
|
||||
<section class="panel company-ledger-panel" aria-label="公司余额目录">
|
||||
<div class="panel-heading"><div><h2>公司余额目录</h2><p>每行余额都附带截止日、期初状态、本期借贷、结果与未决金额</p></div></div>
|
||||
<div class="ledger-head is-balances"><span>公司</span><span class="ledger-hide-md">借方合计</span><span class="ledger-hide-md">贷方合计</span><span>期末结果</span><span>未决</span><span>截止日</span><span></span></div>
|
||||
<div class="company-ledgers">
|
||||
<details class="company-ledger is-balances">
|
||||
<summary>
|
||||
<span class="company-name" title="甲公司"><i>甲</i><b>甲公司</b><small class="currency-tag">CNY</small></span>
|
||||
<strong class="amount debit ledger-hide-md"><span class="amount-with-currency"><span class="currency-code">CNY</span>1,280.00</span></strong>
|
||||
<strong class="amount credit ledger-hide-md"><span class="amount-with-currency"><span class="currency-code">CNY</span>320.00</span></strong>
|
||||
<span class="ledger-result"><em class="status success">应收</em><b><span class="amount-with-currency"><span class="currency-code">CNY</span>960.00</span></b></span>
|
||||
<span class="ledger-unresolved is-empty"><span class="status neutral">未决 0.00</span></span>
|
||||
<span class="ledger-cutoff">截止 2026.07.31</span>
|
||||
<svg></svg>
|
||||
</summary>
|
||||
</details>
|
||||
<details class="company-ledger is-balances" id="stressRow">
|
||||
<summary>
|
||||
<span class="company-name" title="东南沿海综合贸易与供应链管理股份有限公司"><i>东</i><b>东南沿海综合贸易与供应链管理股份有限公司</b><small class="currency-tag">CNY</small></span>
|
||||
<strong class="amount debit ledger-hide-md"><span class="amount-with-currency is-compact"><span class="currency-code">CNY</span>123,456,789,012,345.00</span></strong>
|
||||
<strong class="amount credit ledger-hide-md"><span class="amount-with-currency is-compact"><span class="currency-code">CNY</span>100,000,000,000,000.00</span></strong>
|
||||
<span class="ledger-result"><em class="status success">应收</em><b><span class="amount-with-currency is-compact"><span class="currency-code">CNY</span>23,456,789,012,345.00</span></b></span>
|
||||
<span class="ledger-unresolved is-active"><span class="status warning">未决 2,150.00 · 3 笔</span><small style="display:block;color:var(--color-ink-muted);font-size:10px">手工记录待审 800.00 · 1 笔;待确认科目 1,350.00 · 2 笔</small></span>
|
||||
<span class="ledger-cutoff">截止 2026.07.31</span>
|
||||
<svg></svg>
|
||||
</summary>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="drawer is-open" id="evidenceDrawer">
|
||||
<header class="drawer-header">
|
||||
<div>
|
||||
<div class="drawer-breadcrumb"><button type="button">往来查询</button> / 甲公司 ↔ 乙公司</div>
|
||||
<h2>甲公司 ↔ 乙公司</h2>
|
||||
</div>
|
||||
<button type="button" class="icon-button" aria-label="关闭">×</button>
|
||||
</header>
|
||||
<div class="drawer-body">
|
||||
<div class="pair-balance-line is-six" id="drawerSix">
|
||||
<div><span>期初余额</span><strong class="amount-neutral">期初不可用</strong></div>
|
||||
<div><span>本期借方</span><strong><span class="amount-with-currency is-compact"><span class="currency-code">CNY</span>123,456,789,012,345.00</span></strong></div>
|
||||
<div><span>本期贷方</span><strong><span class="amount-with-currency"><span class="currency-code">CNY</span>320.00</span></strong></div>
|
||||
<div class="pair-final"><span>期末结果</span><strong><em class="status success">应收</em> <span class="amount-with-currency is-compact"><span class="currency-code">CNY</span>23,456,789,012,345.00</span></strong></div>
|
||||
<div class="pair-unresolved is-empty"><span>未决金额</span><strong><span class="amount-with-currency"><span class="currency-code">CNY</span>0.00</span></strong></div>
|
||||
<div><span>截止日</span><strong class="amount-neutral">2026.07.31</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<pre id="b175-metrics" hidden></pre>
|
||||
<script>
|
||||
function box(el) {
|
||||
const r = el.getBoundingClientRect();
|
||||
return { left: r.left, right: r.right, top: r.top, bottom: r.bottom, width: r.width, height: r.height };
|
||||
}
|
||||
function overlap(a, b) {
|
||||
const dx = Math.min(a.right, b.right) - Math.max(a.left, b.left);
|
||||
const dy = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
|
||||
if (dx <= 0 || dy <= 0) return 0;
|
||||
return Math.round(Math.min(dx, dy) === dy ? dx : dx);
|
||||
}
|
||||
function clipped(el) {
|
||||
return el.scrollWidth > el.clientWidth + 1;
|
||||
}
|
||||
function measure() {
|
||||
const row = document.querySelector("#stressRow");
|
||||
const summary = row.querySelector("summary");
|
||||
const amount = row.querySelector(".ledger-result .amount-with-currency");
|
||||
const direction = row.querySelector(".ledger-result .status");
|
||||
const unresolved = row.querySelector(".ledger-unresolved .status");
|
||||
const cutoff = row.querySelector(".ledger-cutoff");
|
||||
const chevron = row.querySelector("summary > svg");
|
||||
const head = document.querySelector(".ledger-head.is-balances");
|
||||
const drawerAmount = document.querySelector("#drawerSix .pair-final .amount-with-currency");
|
||||
const amountBox = box(amount);
|
||||
const unresolvedBox = box(unresolved);
|
||||
const cutoffBox = box(cutoff);
|
||||
const chevronBox = box(chevron);
|
||||
const directionBox = box(direction);
|
||||
const metrics = {
|
||||
viewport: window.innerWidth,
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
fiveColVisible: getComputedStyle(head).display !== "none",
|
||||
amountText: amount.textContent.replace(/\s+/g, " ").trim(),
|
||||
unresolvedText: unresolved.textContent.replace(/\s+/g, " ").trim(),
|
||||
cutoffText: cutoff.textContent.replace(/\s+/g, " ").trim(),
|
||||
amountUnresolvedOverlap: overlap(amountBox, unresolvedBox),
|
||||
amountCutoffOverlap: overlap(amountBox, cutoffBox),
|
||||
unresolvedCutoffOverlap: overlap(unresolvedBox, cutoffBox),
|
||||
unresolvedChevronOverlap: overlap(unresolvedBox, chevronBox),
|
||||
cutoffChevronOverlap: overlap(cutoffBox, chevronBox),
|
||||
directionUnresolvedOverlap: overlap(directionBox, unresolvedBox),
|
||||
amountClipped: clipped(amount),
|
||||
unresolvedClipped: clipped(unresolved),
|
||||
cutoffClipped: clipped(cutoff),
|
||||
amountVisible: amountBox.width > 4 && amountBox.height > 4,
|
||||
unresolvedVisible: unresolvedBox.width > 4 && unresolvedBox.height > 4,
|
||||
cutoffVisible: cutoffBox.width > 4 && cutoffBox.height > 4,
|
||||
stackedResult: getComputedStyle(row.querySelector(".ledger-result")).flexDirection === "column",
|
||||
drawerAmountClipped: clipped(drawerAmount),
|
||||
summaryWidth: Math.round(box(summary).width),
|
||||
};
|
||||
const node = document.getElementById("b175-metrics");
|
||||
node.hidden = false;
|
||||
node.textContent = JSON.stringify(metrics);
|
||||
document.title = "B175 " + node.textContent;
|
||||
}
|
||||
window.measureB175 = measure;
|
||||
if (document.readyState === "complete") {
|
||||
measure();
|
||||
} else {
|
||||
window.addEventListener("load", measure);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,109 +0,0 @@
|
||||
"""Static + Node checks for the B-44 visual rework.
|
||||
|
||||
Covers unique ids, nav copy, drawer/directory breakpoints, eight-column
|
||||
event table, and the exported keyboard/amount helpers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
WEB = ROOT / "web"
|
||||
|
||||
|
||||
class B44FrontendContractTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.admin = (WEB / "admin.html").read_text(encoding="utf-8")
|
||||
cls.company = (WEB / "company.html").read_text(encoding="utf-8")
|
||||
cls.css = (WEB / "styles.css").read_text(encoding="utf-8")
|
||||
cls.js = (WEB / "app.js").read_text(encoding="utf-8")
|
||||
|
||||
def test_admin_ids_are_unique(self) -> None:
|
||||
self.assertEqual(1, self.admin.count('id="companyLedgers"'))
|
||||
self.assertEqual(1, self.admin.count('id="balanceLedgers"'))
|
||||
self.assertIn('data-view="pair"', self.admin)
|
||||
self.assertRegex(self.admin, r'data-view="pair"[^>]*>[\s\S]*?<span>往来查询</span>')
|
||||
self.assertIn("<h1>往来查询</h1>", self.admin)
|
||||
self.assertIn("转为异常后,该记录暂不纳入余额计算", self.admin)
|
||||
self.assertIn('id="auditExceptionNote"', self.admin)
|
||||
|
||||
def test_company_balance_groups_and_nav(self) -> None:
|
||||
self.assertIn('id="companyBalanceGroups"', self.company)
|
||||
self.assertNotIn('id="companyBalanceLine"', self.company)
|
||||
self.assertRegex(self.company, r'data-view="balances"[^>]*>[\s\S]*?<span>往来余额</span>')
|
||||
self.assertIn("<h1>往来余额</h1>", self.company)
|
||||
|
||||
def test_css_directory_and_drawer_breakpoints(self) -> None:
|
||||
self.assertIn("@media (max-width: 375px)", self.css)
|
||||
self.assertIn("@media (max-width: 1179px)", self.css)
|
||||
self.assertIn("@media (min-width: 376px) and (max-width: 900px)", self.css)
|
||||
self.assertIn(
|
||||
"minmax(80px, 0.85fr) minmax(152px, 1.2fr) minmax(96px, 0.9fr) minmax(84px, 0.5fr) 24px",
|
||||
self.css,
|
||||
)
|
||||
self.assertIn("@media (max-width: 767px)", self.css)
|
||||
self.assertIn(".drawer {", self.css)
|
||||
self.assertIn("width: 640px", self.css)
|
||||
self.assertIn(".drawer { width: 480px; }", self.css)
|
||||
self.assertIn(".drawer { width: 100%; }", self.css)
|
||||
self.assertIn(".drawer .pair-balance-line.is-six", self.css)
|
||||
self.assertIn("repeat(3, 1fr)", self.css)
|
||||
self.assertIn(".drawer .event-table { min-width: 860px; }", self.css)
|
||||
self.assertIn(".company-row.is-balance-counterparty", self.css)
|
||||
self.assertIn(".ledger-hide-md", self.css)
|
||||
self.assertIn(".company-name b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }", self.css)
|
||||
self.assertIn(".amount-with-currency.is-compact { font-size: 11px; }", self.css)
|
||||
self.assertIn("minmax(88px, 1fr) minmax(0, max-content) 24px", self.css)
|
||||
self.assertIn("grid-row: 1 / span 2", self.css)
|
||||
self.assertIn("flex-direction: column; align-items: flex-end; gap: 2px;", self.css)
|
||||
|
||||
def test_js_selectors_and_event_table(self) -> None:
|
||||
self.assertIn('loadAdminBalances($("#balanceLedgers"))', self.js)
|
||||
self.assertNotIn('loadAdminBalances($("#companyLedgers"))', self.js)
|
||||
self.assertIn("交易日期", self.js)
|
||||
self.assertIn("本方账户", self.js)
|
||||
self.assertIn("对方账户", self.js)
|
||||
self.assertIn("摘要", self.js)
|
||||
self.assertIn("匹配状态", self.js)
|
||||
self.assertIn("function eventTableHead()", self.js)
|
||||
self.assertIn("colspan=\"8\"", self.js)
|
||||
self.assertIn("function cycleTab(", self.js)
|
||||
self.assertIn("function drawerEscAction(", self.js)
|
||||
self.assertIn("function eventIsNegative(", self.js)
|
||||
self.assertIn("fmtAbsMoney", self.js)
|
||||
self.assertIn("is-balance-counterparty", self.js)
|
||||
self.assertIn("function isCompactAmount(", self.js)
|
||||
self.assertIn("function auditEvidenceFields(", self.js)
|
||||
self.assertIn("无关联流水", self.js)
|
||||
self.assertIn('row.dataset.direction = "outgoing"', self.js)
|
||||
self.assertIn("row.dataset.counterpartyName", self.js)
|
||||
self.assertIn("row.dataset.relatedSourceRowId", self.js)
|
||||
heads = re.search(
|
||||
r"function eventTableHead\(\) \{\s*return `([^`]+)`",
|
||||
self.js,
|
||||
)
|
||||
self.assertIsNotNone(heads)
|
||||
markup = heads.group(1)
|
||||
self.assertNotIn("来源", markup)
|
||||
for label in ("交易日期", "方向", "科目", "本方账户", "对方账户", "摘要", "匹配状态", "金额"):
|
||||
self.assertIn(label, markup)
|
||||
self.assertEqual(8, len(re.findall(r"<th\b", markup)))
|
||||
|
||||
def test_node_keyboard_and_amount_helpers(self) -> None:
|
||||
result = subprocess.run(
|
||||
["node", str(ROOT / "tests" / "b44_ui_check.js")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(ROOT),
|
||||
)
|
||||
self.assertEqual(0, result.returncode, result.stdout + result.stderr)
|
||||
self.assertIn("b44_ui_check ok", result.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,530 +0,0 @@
|
||||
"""Chromium layout regression for the B-44 admin balance directory.
|
||||
|
||||
Measures 15-digit period-end amounts against the unresolved chip using
|
||||
getBoundingClientRect — not static text presence.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import urlopen
|
||||
import unittest
|
||||
import sys
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(Path(__file__).resolve().parent) not in sys.path:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
FIXTURE = "/tests/fixtures/b44-balance-directory.html"
|
||||
CHROME_CANDIDATES = [
|
||||
Path(os.environ.get("PROGRAMFILES", r"C:\Program Files"))
|
||||
/ "Google/Chrome/Application/chrome.exe",
|
||||
Path(os.environ.get("PROGRAMFILES(X86)", r"C:\Program Files (x86)"))
|
||||
/ "Microsoft/Edge/Application/msedge.exe",
|
||||
]
|
||||
VIEWPORTS = (720, 768, 800, 900, 375, 1024, 1440)
|
||||
|
||||
|
||||
def chrome_bin() -> Path:
|
||||
for path in CHROME_CANDIDATES:
|
||||
if path.exists():
|
||||
return path
|
||||
raise FileNotFoundError("Chrome/Edge not found for layout regression")
|
||||
|
||||
|
||||
class QuietHandler(SimpleHTTPRequestHandler):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, directory=str(ROOT), **kwargs)
|
||||
|
||||
def log_message(self, *_args) -> None:
|
||||
pass
|
||||
|
||||
def handle(self) -> None:
|
||||
try:
|
||||
super().handle()
|
||||
except (ConnectionResetError, BrokenPipeError, TimeoutError):
|
||||
pass
|
||||
|
||||
|
||||
class _Cdp:
|
||||
def __init__(self, ws_url: str) -> None:
|
||||
parsed = urlparse(ws_url)
|
||||
self._sock = socket.create_connection((parsed.hostname, parsed.port or 80), timeout=10)
|
||||
self._sock.settimeout(10)
|
||||
key = base64.b64encode(os.urandom(16)).decode()
|
||||
path = parsed.path + (f"?{parsed.query}" if parsed.query else "")
|
||||
self._sock.sendall(
|
||||
(
|
||||
f"GET {path} HTTP/1.1\r\n"
|
||||
f"Host: {parsed.netloc}\r\n"
|
||||
"Upgrade: websocket\r\n"
|
||||
"Connection: Upgrade\r\n"
|
||||
f"Sec-WebSocket-Key: {key}\r\n"
|
||||
"Sec-WebSocket-Version: 13\r\n\r\n"
|
||||
).encode()
|
||||
)
|
||||
header = b""
|
||||
while b"\r\n\r\n" not in header:
|
||||
chunk = self._sock.recv(4096)
|
||||
if not chunk:
|
||||
raise ConnectionError("CDP websocket handshake failed")
|
||||
header += chunk
|
||||
expected = base64.b64encode(
|
||||
hashlib.sha1((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest()
|
||||
).decode()
|
||||
if expected not in header.decode("latin1"):
|
||||
raise ConnectionError("CDP websocket accept mismatch")
|
||||
leftover = header.split(b"\r\n\r\n", 1)[1]
|
||||
self._buf = leftover
|
||||
self._next_id = 1
|
||||
|
||||
def call(self, method: str, params: dict | None = None, timeout: float = 15) -> dict:
|
||||
msg_id = self._next_id
|
||||
self._next_id += 1
|
||||
self._send({"id": msg_id, "method": method, "params": params or {}})
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
payload = self._recv()
|
||||
if payload.get("id") == msg_id:
|
||||
if "error" in payload:
|
||||
raise RuntimeError(f"{method}: {payload['error']}")
|
||||
return payload.get("result") or {}
|
||||
raise TimeoutError(method)
|
||||
|
||||
def wait_event(self, name: str, timeout: float = 15) -> dict:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
payload = self._recv()
|
||||
if payload.get("method") == name:
|
||||
return payload.get("params") or {}
|
||||
raise TimeoutError(name)
|
||||
|
||||
def close(self) -> None:
|
||||
try:
|
||||
self._sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _send(self, obj: dict) -> None:
|
||||
data = json.dumps(obj, separators=(",", ":")).encode()
|
||||
mask = os.urandom(4)
|
||||
header = bytearray([0x81])
|
||||
length = len(data)
|
||||
if length < 126:
|
||||
header.append(0x80 | length)
|
||||
elif length < 65536:
|
||||
header.append(0x80 | 126)
|
||||
header.extend(length.to_bytes(2, "big"))
|
||||
else:
|
||||
header.append(0x80 | 127)
|
||||
header.extend(length.to_bytes(8, "big"))
|
||||
header.extend(mask)
|
||||
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(data))
|
||||
self._sock.sendall(header + masked)
|
||||
|
||||
def _recv(self) -> dict:
|
||||
while True:
|
||||
opcode, payload = self._read_frame()
|
||||
if opcode == 0x9:
|
||||
self._send_pong(payload)
|
||||
continue
|
||||
if opcode == 0xA:
|
||||
continue
|
||||
if opcode == 0x8:
|
||||
raise ConnectionError("CDP websocket closed")
|
||||
return json.loads(payload.decode())
|
||||
|
||||
def _send_pong(self, payload: bytes) -> None:
|
||||
mask = os.urandom(4)
|
||||
header = bytearray([0x8A, 0x80 | len(payload)])
|
||||
header.extend(mask)
|
||||
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
|
||||
self._sock.sendall(header + masked)
|
||||
|
||||
def _read_frame(self) -> tuple[int, bytes]:
|
||||
header = self._read_exact(2)
|
||||
opcode = header[0] & 0x0F
|
||||
length = header[1] & 0x7F
|
||||
masked = bool(header[1] & 0x80)
|
||||
if length == 126:
|
||||
length = int.from_bytes(self._read_exact(2), "big")
|
||||
elif length == 127:
|
||||
length = int.from_bytes(self._read_exact(8), "big")
|
||||
mask = self._read_exact(4) if masked else b""
|
||||
payload = self._read_exact(length)
|
||||
if masked:
|
||||
payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
|
||||
return opcode, payload
|
||||
|
||||
def _read_exact(self, size: int) -> bytes:
|
||||
while len(self._buf) < size:
|
||||
chunk = self._sock.recv(4096)
|
||||
if not chunk:
|
||||
raise ConnectionError("CDP websocket closed")
|
||||
self._buf += chunk
|
||||
data, self._buf = self._buf[:size], self._buf[size:]
|
||||
return data
|
||||
|
||||
|
||||
class B44LayoutRegressionTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.chrome = chrome_bin()
|
||||
cls.httpd = ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
|
||||
cls.http_thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
|
||||
cls.http_thread.start()
|
||||
cls.port = cls.httpd.server_address[1]
|
||||
cls.url = f"http://127.0.0.1:{cls.port}{FIXTURE}"
|
||||
cls.tmp = tempfile.TemporaryDirectory(prefix="b44-layout-", ignore_cleanup_errors=True)
|
||||
user_dir = cls.tmp.name
|
||||
cls.proc = subprocess.Popen(
|
||||
[
|
||||
str(cls.chrome),
|
||||
"--headless=new",
|
||||
"--disable-gpu",
|
||||
"--no-first-run",
|
||||
"--disable-extensions",
|
||||
"--remote-debugging-port=0",
|
||||
f"--user-data-dir={user_dir}",
|
||||
"about:blank",
|
||||
],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
port_file = Path(user_dir) / "DevToolsActivePort"
|
||||
deadline = time.time() + 15
|
||||
listing = None
|
||||
while time.time() < deadline:
|
||||
if port_file.exists() and port_file.stat().st_size:
|
||||
text = port_file.read_text(encoding="utf-8").strip().splitlines()
|
||||
if text:
|
||||
cls.debug_port = int(text[0])
|
||||
try:
|
||||
listing = json.loads(
|
||||
urlopen(
|
||||
f"http://127.0.0.1:{cls.debug_port}/json/list",
|
||||
timeout=5,
|
||||
).read()
|
||||
)
|
||||
except Exception:
|
||||
listing = None
|
||||
if listing and any(item.get("type") == "page" for item in listing):
|
||||
break
|
||||
time.sleep(0.05)
|
||||
else:
|
||||
raise RuntimeError("Chrome DevTools port not ready")
|
||||
page = next(item for item in listing if item.get("type") == "page")
|
||||
cls.cdp = _Cdp(page["webSocketDebuggerUrl"])
|
||||
cls.cdp.call("Runtime.enable")
|
||||
cls.cdp.call("Page.enable")
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls) -> None:
|
||||
if getattr(cls, "cdp", None):
|
||||
cls.cdp.close()
|
||||
if getattr(cls, "proc", None):
|
||||
cls.proc.terminate()
|
||||
try:
|
||||
cls.proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
cls.proc.kill()
|
||||
cls.proc.wait(timeout=5)
|
||||
time.sleep(0.2)
|
||||
if getattr(cls, "httpd", None):
|
||||
cls.httpd.shutdown()
|
||||
cls.httpd.server_close()
|
||||
if getattr(cls, "tmp", None):
|
||||
cls.tmp.cleanup()
|
||||
|
||||
def _metrics(self, width: int) -> dict:
|
||||
self.cdp.call("Emulation.setDeviceMetricsOverride", {
|
||||
"width": width,
|
||||
"height": 1100,
|
||||
"deviceScaleFactor": 1,
|
||||
"mobile": False,
|
||||
})
|
||||
self.cdp.call("Page.navigate", {"url": f"{self.url}?w={width}"})
|
||||
deadline = time.time() + 10
|
||||
last = None
|
||||
while time.time() < deadline:
|
||||
result = self.cdp.call(
|
||||
"Runtime.evaluate",
|
||||
{
|
||||
"expression": (
|
||||
"typeof window.measureB175 === 'function' "
|
||||
"? (window.measureB175(), document.getElementById('b175-metrics').textContent) "
|
||||
": null"
|
||||
),
|
||||
"returnByValue": True,
|
||||
},
|
||||
)
|
||||
last = result.get("result", {}).get("value")
|
||||
if last:
|
||||
return json.loads(last)
|
||||
time.sleep(0.1)
|
||||
raise TimeoutError(f"layout metrics not ready: {last}")
|
||||
|
||||
def test_fifteen_digit_amount_clear_of_unresolved_chip(self) -> None:
|
||||
for width in VIEWPORTS:
|
||||
with self.subTest(width=width):
|
||||
metrics = self._metrics(width)
|
||||
self.assertEqual(width, metrics["viewport"], metrics)
|
||||
self.assertIn("23,456,789,012,345.00", metrics["amountText"])
|
||||
self.assertIn("未决", metrics["unresolvedText"])
|
||||
self.assertIn("2026.07.31", metrics["cutoffText"])
|
||||
self.assertTrue(metrics["amountVisible"])
|
||||
self.assertTrue(metrics["unresolvedVisible"])
|
||||
self.assertTrue(metrics["cutoffVisible"])
|
||||
self.assertFalse(metrics["amountClipped"], metrics)
|
||||
self.assertFalse(metrics["unresolvedClipped"], metrics)
|
||||
self.assertFalse(metrics["cutoffClipped"], metrics)
|
||||
self.assertEqual(0, metrics["amountUnresolvedOverlap"], metrics)
|
||||
self.assertEqual(0, metrics["amountCutoffOverlap"], metrics)
|
||||
self.assertEqual(0, metrics["unresolvedCutoffOverlap"], metrics)
|
||||
self.assertEqual(0, metrics["directionUnresolvedOverlap"], metrics)
|
||||
self.assertEqual(0, metrics["cutoffChevronOverlap"], metrics)
|
||||
self.assertFalse(metrics["drawerAmountClipped"], metrics)
|
||||
if 376 <= width <= 1179:
|
||||
self.assertTrue(metrics["fiveColVisible"], metrics)
|
||||
if 376 <= width <= 900:
|
||||
self.assertTrue(metrics["stackedResult"], metrics)
|
||||
if width <= 375:
|
||||
self.assertFalse(metrics["fiveColVisible"], metrics)
|
||||
|
||||
|
||||
LIVE_MEASURE = r"""
|
||||
(() => {
|
||||
const pair = document.querySelector('[data-view="pair"]');
|
||||
if (pair) pair.click();
|
||||
const rows = [...document.querySelectorAll("#balanceLedgers .company-ledger.is-balances")];
|
||||
if (!rows.length) return null;
|
||||
const row = rows.find((item) => (item.textContent || "").includes("123,456,789,012,345")) || rows[0];
|
||||
const amount = row.querySelector(".ledger-result .amount-with-currency");
|
||||
const direction = row.querySelector(".ledger-result .status");
|
||||
const unresolved = row.querySelector(".ledger-unresolved .status") || row.querySelector(".ledger-unresolved");
|
||||
const cutoff = row.querySelector(".ledger-cutoff");
|
||||
const chevron = row.querySelector("summary > svg");
|
||||
const box = (el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return { left: r.left, right: r.right, top: r.top, bottom: r.bottom };
|
||||
};
|
||||
const overlap = (a, b) => {
|
||||
const dx = Math.min(a.right, b.right) - Math.max(a.left, b.left);
|
||||
const dy = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
|
||||
if (dx <= 0 || dy <= 0) return 0;
|
||||
return Math.round(dx);
|
||||
};
|
||||
const clipped = (el) => el.scrollWidth > el.clientWidth + 1;
|
||||
const amountBox = box(amount);
|
||||
const unresolvedBox = box(unresolved);
|
||||
return {
|
||||
viewport: window.innerWidth,
|
||||
amountText: (amount.textContent || "").replace(/\s+/g, " ").trim(),
|
||||
unresolvedText: (unresolved.textContent || "").replace(/\s+/g, " ").trim(),
|
||||
cutoffText: (cutoff.textContent || "").replace(/\s+/g, " ").trim(),
|
||||
amountUnresolvedOverlap: overlap(amountBox, unresolvedBox),
|
||||
directionUnresolvedOverlap: overlap(box(direction), unresolvedBox),
|
||||
amountCutoffOverlap: overlap(amountBox, box(cutoff)),
|
||||
unresolvedCutoffOverlap: overlap(unresolvedBox, box(cutoff)),
|
||||
cutoffChevronOverlap: overlap(box(cutoff), box(chevron)),
|
||||
amountClipped: clipped(amount),
|
||||
unresolvedClipped: clipped(unresolved),
|
||||
cutoffClipped: clipped(cutoff),
|
||||
fiveColVisible: getComputedStyle(document.querySelector(".ledger-head.is-balances")).display !== "none",
|
||||
stackedResult: getComputedStyle(row.querySelector(".ledger-result")).flexDirection === "column",
|
||||
rowCount: rows.length,
|
||||
};
|
||||
})()
|
||||
"""
|
||||
|
||||
COMPANY_MEASURE = r"""
|
||||
(() => {
|
||||
const balances = document.querySelector('[data-view="balances"]');
|
||||
if (balances) balances.click();
|
||||
const row = document.querySelector(".company-row.is-balance-counterparty");
|
||||
if (!row) return null;
|
||||
const amount = row.querySelector(".amount-with-currency") || row.querySelector(".company-row-figure strong");
|
||||
const direction = row.querySelector(".status");
|
||||
const cutoff = row.querySelector(".company-row-figure small");
|
||||
const box = (el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return { left: r.left, right: r.right, top: r.top, bottom: r.bottom, width: r.width, height: r.height };
|
||||
};
|
||||
const overlap = (a, b) => {
|
||||
const dx = Math.min(a.right, b.right) - Math.max(a.left, b.left);
|
||||
const dy = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
|
||||
if (dx <= 0 || dy <= 0) return 0;
|
||||
return Math.round(dx);
|
||||
};
|
||||
return {
|
||||
viewport: window.innerWidth,
|
||||
amountText: (amount?.textContent || "").replace(/\s+/g, " ").trim(),
|
||||
directionText: (direction?.textContent || "").replace(/\s+/g, " ").trim(),
|
||||
cutoffText: (cutoff?.textContent || "").replace(/\s+/g, " ").trim(),
|
||||
amountDirectionOverlap: amount && direction ? overlap(box(amount), box(direction)) : 0,
|
||||
visible: Boolean(amount && amount.getBoundingClientRect().width > 4),
|
||||
};
|
||||
})()
|
||||
"""
|
||||
|
||||
|
||||
class LiveAdminDirectoryLayoutTests(unittest.TestCase):
|
||||
"""Chromium against a real login/API page with B-168-scale 15-digit amounts."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
from test_positions_api import IntercompanyApiTests
|
||||
|
||||
cls.api = IntercompanyApiTests("test_admin_balances_directory")
|
||||
cls.api.setUp()
|
||||
confirmed = cls.api._fresh_pair("123456789012345.00")
|
||||
cls.api._confirm(confirmed["ledger_event_id"])
|
||||
cls.api._fresh_pair("2150.00")
|
||||
cls.tmp = tempfile.TemporaryDirectory(prefix="b44-live-", ignore_cleanup_errors=True)
|
||||
cls.proc = subprocess.Popen(
|
||||
[
|
||||
str(chrome_bin()),
|
||||
"--headless=new",
|
||||
"--disable-gpu",
|
||||
"--no-first-run",
|
||||
"--disable-extensions",
|
||||
"--remote-debugging-port=0",
|
||||
f"--user-data-dir={cls.tmp.name}",
|
||||
"about:blank",
|
||||
],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
port_file = Path(cls.tmp.name) / "DevToolsActivePort"
|
||||
deadline = time.time() + 15
|
||||
listing = None
|
||||
while time.time() < deadline:
|
||||
if port_file.exists() and port_file.stat().st_size:
|
||||
text = port_file.read_text(encoding="utf-8").strip().splitlines()
|
||||
if text:
|
||||
cls.debug_port = int(text[0])
|
||||
try:
|
||||
listing = json.loads(
|
||||
urlopen(
|
||||
f"http://127.0.0.1:{cls.debug_port}/json/list",
|
||||
timeout=5,
|
||||
).read()
|
||||
)
|
||||
except Exception:
|
||||
listing = None
|
||||
if listing and any(item.get("type") == "page" for item in listing):
|
||||
break
|
||||
time.sleep(0.05)
|
||||
else:
|
||||
raise RuntimeError("Chrome DevTools port not ready")
|
||||
page = next(item for item in listing if item.get("type") == "page")
|
||||
cls.cdp = _Cdp(page["webSocketDebuggerUrl"])
|
||||
cls.cdp.call("Runtime.enable")
|
||||
cls.cdp.call("Page.enable")
|
||||
cls.cdp.call("Network.enable")
|
||||
token = cls.api.admin.cookies["cw_session"]
|
||||
cls.cdp.call(
|
||||
"Network.setCookie",
|
||||
{
|
||||
"name": "cw_session",
|
||||
"value": token,
|
||||
"url": f"http://127.0.0.1:{cls.api.port}/",
|
||||
},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls) -> None:
|
||||
if getattr(cls, "cdp", None):
|
||||
cls.cdp.close()
|
||||
if getattr(cls, "proc", None):
|
||||
cls.proc.terminate()
|
||||
try:
|
||||
cls.proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
cls.proc.kill()
|
||||
cls.proc.wait(timeout=5)
|
||||
time.sleep(0.2)
|
||||
if getattr(cls, "api", None):
|
||||
cls.api.tearDown()
|
||||
cls.api.doCleanups()
|
||||
if getattr(cls, "tmp", None):
|
||||
cls.tmp.cleanup()
|
||||
|
||||
def _eval(self, expression: str):
|
||||
deadline = time.time() + 12
|
||||
last = None
|
||||
while time.time() < deadline:
|
||||
result = self.cdp.call(
|
||||
"Runtime.evaluate",
|
||||
{"expression": expression, "returnByValue": True},
|
||||
)
|
||||
last = result.get("result", {}).get("value")
|
||||
if last:
|
||||
return last
|
||||
time.sleep(0.15)
|
||||
raise TimeoutError(f"live page metrics not ready: {last}")
|
||||
|
||||
def _open(self, path: str, width: int) -> None:
|
||||
self.cdp.call("Emulation.setDeviceMetricsOverride", {
|
||||
"width": width,
|
||||
"height": 1100,
|
||||
"deviceScaleFactor": 1,
|
||||
"mobile": False,
|
||||
})
|
||||
self.cdp.call("Page.navigate", {"url": f"http://127.0.0.1:{self.api.port}/{path}"})
|
||||
time.sleep(0.4)
|
||||
|
||||
def test_live_admin_directory_viewports(self) -> None:
|
||||
for width in (720, 768, 800, 900, 375, 1024, 1440):
|
||||
with self.subTest(width=width):
|
||||
self._open("admin.html", width)
|
||||
metrics = self._eval(LIVE_MEASURE)
|
||||
self.assertEqual(width, metrics["viewport"], metrics)
|
||||
self.assertGreaterEqual(metrics["rowCount"], 1, metrics)
|
||||
self.assertIn("123,456,789,012,345", metrics["amountText"], metrics)
|
||||
self.assertIn("未决", metrics["unresolvedText"], metrics)
|
||||
self.assertIn("2026.07.31", metrics["cutoffText"], metrics)
|
||||
self.assertFalse(metrics["amountClipped"], metrics)
|
||||
self.assertFalse(metrics["unresolvedClipped"], metrics)
|
||||
self.assertEqual(0, metrics["amountUnresolvedOverlap"], metrics)
|
||||
self.assertEqual(0, metrics["directionUnresolvedOverlap"], metrics)
|
||||
self.assertEqual(0, metrics["amountCutoffOverlap"], metrics)
|
||||
if 376 <= width <= 900:
|
||||
self.assertTrue(metrics["stackedResult"], metrics)
|
||||
if width <= 375:
|
||||
self.assertFalse(metrics["fiveColVisible"], metrics)
|
||||
|
||||
def test_live_company_balance_rows(self) -> None:
|
||||
token = self.api.cashier_a.cookies["cw_session"]
|
||||
self.cdp.call(
|
||||
"Network.setCookie",
|
||||
{
|
||||
"name": "cw_session",
|
||||
"value": token,
|
||||
"url": f"http://127.0.0.1:{self.api.port}/",
|
||||
},
|
||||
)
|
||||
for width in (375, 1440):
|
||||
with self.subTest(width=width):
|
||||
self._open("company.html", width)
|
||||
metrics = self._eval(COMPANY_MEASURE)
|
||||
self.assertTrue(metrics["visible"], metrics)
|
||||
self.assertTrue(metrics["amountText"], metrics)
|
||||
self.assertTrue(metrics["directionText"], metrics)
|
||||
self.assertIn("截止", metrics["cutoffText"], metrics)
|
||||
self.assertEqual(0, metrics["amountDirectionOverlap"], metrics)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Dashboard aggregate helpers and admin HTTP endpoints (no openpyxl dependency)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
from bank_importer import dashboard, master_data
|
||||
from bank_importer.db import connect, migrate
|
||||
|
||||
import server
|
||||
from test_server_auth import Client, as_json
|
||||
|
||||
BOOTSTRAP_PASSWORD = "BootAdmin123"
|
||||
ADMIN_PASSWORD = "AdminPass123"
|
||||
|
||||
|
||||
class DashboardUnitTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.connection = connect(":memory:")
|
||||
migrate(self.connection)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.connection.close()
|
||||
|
||||
def test_empty_dashboard(self) -> None:
|
||||
payload = dashboard.build_dashboard(
|
||||
self.connection, from_date="2026-01-01", cutoff="2026-08-20"
|
||||
)
|
||||
self.assertEqual(0, payload["audit"]["total"])
|
||||
self.assertEqual([], payload["companies"])
|
||||
self.assertEqual(7, len(payload["weekly_flow"]["labels"]))
|
||||
|
||||
def test_pending_account_counts_as_medium(self) -> None:
|
||||
now = master_data.utc_now()
|
||||
cursor = self.connection.execute(
|
||||
"INSERT INTO companies (name, credit_code, cashier_name, status, created_at, updated_at) "
|
||||
"VALUES ('甲公司', NULL, NULL, 'active', ?, ?)",
|
||||
(now, now),
|
||||
)
|
||||
company_id = int(cursor.lastrowid)
|
||||
self.connection.commit()
|
||||
master_data.submit_bank_account(
|
||||
self.connection,
|
||||
company_id=company_id,
|
||||
bank_name="工行",
|
||||
account_type="一般户",
|
||||
account_number="6222020000000001",
|
||||
start_date="2026-01-01",
|
||||
actor=None,
|
||||
)
|
||||
counts = dashboard.audit_counts(self.connection)
|
||||
self.assertEqual(1, counts["medium"])
|
||||
self.assertEqual(0, counts["high"])
|
||||
self.assertEqual(1, counts["total"])
|
||||
|
||||
def test_company_summaries_from_eligible_events(self) -> None:
|
||||
now = master_data.utc_now()
|
||||
a = self.connection.execute(
|
||||
"INSERT INTO companies (name, credit_code, cashier_name, status, created_at, updated_at) "
|
||||
"VALUES ('甲公司', NULL, NULL, 'active', ?, ?)",
|
||||
(now, now),
|
||||
).lastrowid
|
||||
b = self.connection.execute(
|
||||
"INSERT INTO companies (name, credit_code, cashier_name, status, created_at, updated_at) "
|
||||
"VALUES ('乙公司', NULL, NULL, 'active', ?, ?)",
|
||||
(now, now),
|
||||
).lastrowid
|
||||
event_id = self.connection.execute(
|
||||
"INSERT INTO canonical_transfer_events (lifecycle, created_at) VALUES ('active', ?)",
|
||||
(now,),
|
||||
).lastrowid
|
||||
decision_id = self.connection.execute(
|
||||
"""
|
||||
INSERT INTO transfer_match_decisions (
|
||||
event_id, revision, effective_at, amount, currency, classification,
|
||||
pairing, locked, mode, rule_version, created_at
|
||||
) VALUES (?, 1, '2026-07-05T10:00:00', '100000.00', 'CNY', 'intercompany',
|
||||
'paired', 0, 'manual', 'test', ?)
|
||||
""",
|
||||
(event_id, now),
|
||||
).lastrowid
|
||||
self.connection.execute(
|
||||
"INSERT INTO current_transfer_decisions (event_id, decision_id) VALUES (?, ?)",
|
||||
(event_id, decision_id),
|
||||
)
|
||||
self.connection.execute(
|
||||
"""
|
||||
INSERT INTO transfer_decision_participants (
|
||||
decision_id, role, company_id, bank_account_id, resolve_method, created_at
|
||||
) VALUES (?, 'payer', ?, NULL, 'manual', ?), (?, 'payee', ?, NULL, 'manual', ?)
|
||||
""",
|
||||
(decision_id, a, now, decision_id, b, now),
|
||||
)
|
||||
self.connection.commit()
|
||||
|
||||
items, totals = dashboard.company_summaries(
|
||||
self.connection, from_date="2026-01-01", cutoff="2026-08-20"
|
||||
)
|
||||
self.assertEqual(1, totals["detail_count"])
|
||||
by_name = {row["name"]: row for row in items}
|
||||
self.assertEqual(1, by_name["甲公司"]["detail_count"])
|
||||
self.assertEqual("10.00", by_name["甲公司"]["debit_wan"])
|
||||
self.assertEqual("-10.00", by_name["甲公司"]["net_wan"])
|
||||
self.assertEqual("10.00", by_name["乙公司"]["credit_wan"])
|
||||
self.assertEqual("10.00", by_name["乙公司"]["net_wan"])
|
||||
|
||||
detail = dashboard.company_peer_groups(
|
||||
self.connection, int(a), from_date="2026-01-01", cutoff="2026-08-20"
|
||||
)
|
||||
self.assertEqual(1, len(detail["groups"]))
|
||||
self.assertEqual("乙公司", detail["groups"][0]["peer_name"])
|
||||
self.assertIsNone(detail["groups"][0]["opening"])
|
||||
|
||||
|
||||
class DashboardApiTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.temp_dir = tempfile.TemporaryDirectory()
|
||||
root = Path(cls.temp_dir.name)
|
||||
cls.db_path = root / "app.db"
|
||||
cls.storage = root / "files"
|
||||
|
||||
cls._old_db_path = server.DB_PATH
|
||||
cls._old_storage = server.STORAGE_DIR
|
||||
server.DB_PATH = cls.db_path
|
||||
server.STORAGE_DIR = cls.storage
|
||||
|
||||
os.environ["APP_BOOTSTRAP_ADMIN_PASSWORD"] = BOOTSTRAP_PASSWORD
|
||||
connection = connect(cls.db_path)
|
||||
migrate(connection)
|
||||
assert server.ensure_bootstrap_admin(connection) is None
|
||||
connection.close()
|
||||
|
||||
class QuietHandler(server.AppHandler):
|
||||
def log_message(self, *args) -> None:
|
||||
pass
|
||||
|
||||
cls.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
|
||||
cls.port = cls.httpd.server_address[1]
|
||||
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
|
||||
cls.thread.start()
|
||||
|
||||
cls.admin = Client("127.0.0.1", cls.port)
|
||||
status, _, data = cls.admin.post_json(
|
||||
"/api/login",
|
||||
{"username": "group-admin", "password": BOOTSTRAP_PASSWORD, "portal": "admin"},
|
||||
)
|
||||
assert status == 200, data
|
||||
status, _, data = cls.admin.post_json(
|
||||
"/api/password/change",
|
||||
{"old_password": BOOTSTRAP_PASSWORD, "new_password": ADMIN_PASSWORD},
|
||||
)
|
||||
assert status == 200, data
|
||||
|
||||
status, _, data = cls.admin.post_json(
|
||||
"/api/admin/companies", {"name": "甲公司", "username": "cashier-a"}
|
||||
)
|
||||
assert status == 200, data
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls) -> None:
|
||||
cls.httpd.shutdown()
|
||||
cls.httpd.server_close()
|
||||
server.DB_PATH = cls._old_db_path
|
||||
server.STORAGE_DIR = cls._old_storage
|
||||
cls.temp_dir.cleanup()
|
||||
|
||||
def test_dashboard_ok_for_admin(self) -> None:
|
||||
status, _, raw = self.admin.get(
|
||||
"/api/admin/dashboard?from=2026-01-01&cutoff=2026-08-20"
|
||||
)
|
||||
data = as_json(raw)
|
||||
self.assertEqual(200, status)
|
||||
self.assertEqual("ok", data["status"])
|
||||
self.assertIn("audit", data)
|
||||
self.assertEqual(1, len(data["companies"]))
|
||||
self.assertEqual("甲公司", data["companies"][0]["name"])
|
||||
|
||||
def test_company_detail_missing(self) -> None:
|
||||
status, _, raw = self.admin.get(
|
||||
"/api/admin/dashboard/companies/999999?from=2026-01-01&cutoff=2026-08-20"
|
||||
)
|
||||
data = as_json(raw)
|
||||
self.assertEqual(404, status)
|
||||
self.assertEqual("error", data["status"])
|
||||
|
||||
def test_company_detail_ok(self) -> None:
|
||||
status, _, raw = self.admin.get("/api/admin/dashboard")
|
||||
data = as_json(raw)
|
||||
company_id = data["companies"][0]["id"]
|
||||
status, _, raw = self.admin.get(
|
||||
f"/api/admin/dashboard/companies/{company_id}?from=2026-01-01&cutoff=2026-08-20"
|
||||
)
|
||||
detail = as_json(raw)
|
||||
self.assertEqual(200, status)
|
||||
self.assertEqual([], detail["groups"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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, 6], first)
|
||||
self.assertEqual([1, 2, 3, 4, 5, 6, 7], first)
|
||||
self.assertEqual([], migrate(self.connection))
|
||||
self.assertEqual(first, applied_versions(self.connection))
|
||||
tables = {
|
||||
@@ -82,19 +82,22 @@ class MigrationTests(PersistenceTestCase):
|
||||
"ledger_event_bank_sources",
|
||||
"ledger_event_manual_sources",
|
||||
"ledger_subject_suggestions",
|
||||
"system_settings",
|
||||
"system_setting_changes",
|
||||
"reminders",
|
||||
"schema_migrations",
|
||||
):
|
||||
self.assertIn(table, tables)
|
||||
|
||||
def test_rollback_removes_schema_and_forward_rebuilds_it(self) -> None:
|
||||
self.assertEqual([6, 5, 4, 3, 2, 1], rollback(self.connection, 0))
|
||||
self.assertEqual([7, 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, 6], migrate(self.connection))
|
||||
self.assertEqual([1, 2, 3, 4, 5, 6], applied_versions(self.connection))
|
||||
self.assertEqual([1, 2, 3, 4, 5, 6, 7], migrate(self.connection))
|
||||
self.assertEqual([1, 2, 3, 4, 5, 6, 7], applied_versions(self.connection))
|
||||
|
||||
def test_rollback_to_4_keeps_bank_evidence_and_drops_event_layer(self) -> None:
|
||||
self.import_sample()
|
||||
@@ -102,7 +105,7 @@ class MigrationTests(PersistenceTestCase):
|
||||
"SELECT COUNT(*) AS n FROM source_rows"
|
||||
).fetchone()["n"]
|
||||
self.assertGreater(row_count, 0)
|
||||
self.assertEqual([6, 5], rollback(self.connection, 4))
|
||||
self.assertEqual([7, 6, 5], rollback(self.connection, 4))
|
||||
# The pre-migration evidence and schema are untouched.
|
||||
self.assertEqual(
|
||||
row_count,
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
"""Integration tests for system settings persistence and reminder flow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from http.client import HTTPConnection
|
||||
from http.cookies import SimpleCookie
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
from bank_importer.db import connect, migrate
|
||||
from bank_importer import auth, settings
|
||||
|
||||
import server
|
||||
|
||||
|
||||
BOOTSTRAP_PASSWORD = "BootAdmin123"
|
||||
ADMIN_PASSWORD = "AdminPass123"
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self, host: str, port: int) -> None:
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.cookies: dict[str, str] = {}
|
||||
|
||||
def request(self, method, path, body=None, headers=None):
|
||||
connection = HTTPConnection(self.host, self.port)
|
||||
request_headers = dict(headers or {})
|
||||
if self.cookies:
|
||||
request_headers["Cookie"] = "; ".join(
|
||||
f"{k}={v}" for k, v in self.cookies.items()
|
||||
)
|
||||
connection.request(method, path, body=body, headers=request_headers)
|
||||
response = connection.getresponse()
|
||||
data = response.read()
|
||||
set_cookie = dict(response.getheaders()).get("Set-Cookie")
|
||||
if set_cookie:
|
||||
cookie = SimpleCookie()
|
||||
cookie.load(set_cookie)
|
||||
for key, morsel in cookie.items():
|
||||
if morsel.value:
|
||||
self.cookies[key] = morsel.value
|
||||
else:
|
||||
self.cookies.pop(key, None)
|
||||
status = response.status
|
||||
connection.close()
|
||||
return status, data
|
||||
|
||||
def get(self, path):
|
||||
return self.request("GET", path)
|
||||
|
||||
def post_json(self, path, payload):
|
||||
return self.request(
|
||||
"POST", path, body=json.dumps(payload).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
|
||||
def as_json(data: bytes):
|
||||
return json.loads(data.decode("utf-8"))
|
||||
|
||||
|
||||
class SettingsAndRemindersTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.temp_dir = tempfile.TemporaryDirectory()
|
||||
root = Path(cls.temp_dir.name)
|
||||
cls.db_path = root / "app.db"
|
||||
cls.storage = root / "files"
|
||||
|
||||
cls._old_db_path = server.DB_PATH
|
||||
cls._old_storage = server.STORAGE_DIR
|
||||
server.DB_PATH = cls.db_path
|
||||
server.STORAGE_DIR = cls.storage
|
||||
|
||||
os.environ["APP_BOOTSTRAP_ADMIN_PASSWORD"] = BOOTSTRAP_PASSWORD
|
||||
connection = connect(cls.db_path)
|
||||
migrate(connection)
|
||||
server.ensure_bootstrap_admin(connection)
|
||||
# One active account for company 1 so pending items are generated.
|
||||
with connection:
|
||||
connection.execute(
|
||||
"INSERT INTO companies (name, credit_code, status, created_at, updated_at) "
|
||||
"VALUES ('甲公司', NULL, 'active', 't', 't')"
|
||||
)
|
||||
company_id = connection.execute(
|
||||
"SELECT id FROM companies WHERE name = '甲公司'"
|
||||
).fetchone()["id"]
|
||||
connection.execute(
|
||||
"INSERT INTO bank_accounts (company_id, account_number, bank_name, status, created_at, updated_at) "
|
||||
"VALUES (?, '11112222', '工行', 'active', 't', 't')",
|
||||
(company_id,),
|
||||
)
|
||||
cls.company_id = company_id
|
||||
connection.close()
|
||||
|
||||
class QuietHandler(server.AppHandler):
|
||||
def log_message(self, *args) -> None:
|
||||
pass
|
||||
|
||||
cls.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
|
||||
cls.port = cls.httpd.server_address[1]
|
||||
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
|
||||
cls.thread.start()
|
||||
|
||||
cls.admin = Client("127.0.0.1", cls.port)
|
||||
status, data = cls.admin.post_json(
|
||||
"/api/login",
|
||||
{"username": "group-admin", "password": BOOTSTRAP_PASSWORD, "portal": "admin"},
|
||||
)
|
||||
assert status == 200, data
|
||||
status, data = cls.admin.post_json(
|
||||
"/api/password/change",
|
||||
{"old_password": BOOTSTRAP_PASSWORD, "new_password": ADMIN_PASSWORD},
|
||||
)
|
||||
assert status == 200, data
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls) -> None:
|
||||
cls.httpd.shutdown()
|
||||
cls.httpd.server_close()
|
||||
server.DB_PATH = cls._old_db_path
|
||||
server.STORAGE_DIR = cls._old_storage
|
||||
os.environ.pop("APP_BOOTSTRAP_ADMIN_PASSWORD", None)
|
||||
cls.temp_dir.cleanup()
|
||||
|
||||
def test_settings_roundtrip_persists(self) -> None:
|
||||
status, data = self.admin.post_json(
|
||||
"/api/admin/settings",
|
||||
{"closing_day": "1", "start_date": "2026-01-05", "auto_remind": "0", "remind_days": "5"},
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
updated = as_json(data)["settings"]
|
||||
self.assertEqual("1", updated["closing_day"])
|
||||
self.assertEqual("2026-01-05", updated["start_date"])
|
||||
self.assertEqual("0", updated["auto_remind"])
|
||||
self.assertEqual("5", updated["remind_days"])
|
||||
|
||||
status, data = self.admin.get("/api/admin/settings")
|
||||
self.assertEqual(200, status)
|
||||
self.assertEqual("1", as_json(data)["settings"]["closing_day"])
|
||||
|
||||
def test_invalid_closing_day_rejected(self) -> None:
|
||||
for bad in ("0", "29", "abc"):
|
||||
status, data = self.admin.post_json("/api/admin/settings", {"closing_day": bad})
|
||||
self.assertEqual(400, status, (bad, data))
|
||||
|
||||
def test_setting_change_is_audited(self) -> None:
|
||||
status, data = self.admin.post_json("/api/admin/settings", {"closing_day": "7"})
|
||||
self.assertEqual(200, status)
|
||||
connection = connect(self.db_path)
|
||||
try:
|
||||
rows = connection.execute(
|
||||
"SELECT key, before_value, after_value, actor_username "
|
||||
"FROM system_setting_changes WHERE key = 'closing_day' "
|
||||
"ORDER BY id DESC LIMIT 1"
|
||||
).fetchall()
|
||||
finally:
|
||||
connection.close()
|
||||
self.assertTrue(rows)
|
||||
self.assertEqual("7", rows[0]["after_value"])
|
||||
self.assertNotEqual(rows[0]["before_value"], rows[0]["after_value"])
|
||||
self.assertEqual("group-admin", rows[0]["actor_username"])
|
||||
|
||||
def test_reminder_pending_and_send(self) -> None:
|
||||
status, data = self.admin.get(
|
||||
f"/api/admin/reminders/pending?company_id={self.company_id}"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
items = as_json(data)["items"]
|
||||
self.assertTrue(items)
|
||||
|
||||
status, data = self.admin.post_json(
|
||||
"/api/admin/reminders/send", {"company_id": self.company_id}
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
payload = as_json(data)
|
||||
self.assertGreaterEqual(len(payload["reminders"]), 1)
|
||||
self.assertTrue(payload["deadline"])
|
||||
|
||||
status, data = self.admin.get("/api/admin/reminders")
|
||||
self.assertEqual(200, status)
|
||||
history = as_json(data)["reminders"]
|
||||
self.assertGreaterEqual(len(history), 1)
|
||||
self.assertEqual(self.company_id, history[0]["company_id"])
|
||||
|
||||
def test_company_user_forbidden_on_settings_and_reminders(self) -> None:
|
||||
# A company user must not be able to read or write admin settings.
|
||||
status, data = self.admin.post_json(
|
||||
"/api/admin/users", {"username": "cashier-x", "company_id": self.company_id}
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
initial = as_json(data)["initial_password"]
|
||||
cashier = Client("127.0.0.1", self.port)
|
||||
status, data = cashier.post_json(
|
||||
"/api/login",
|
||||
{"username": "cashier-x", "password": initial, "portal": "company"},
|
||||
)
|
||||
self.assertEqual(200, status)
|
||||
status, data = cashier.post_json(
|
||||
"/api/password/change",
|
||||
{"old_password": initial, "new_password": "Changed123"},
|
||||
)
|
||||
self.assertEqual(200, status)
|
||||
|
||||
for path in ("/api/admin/settings", "/api/admin/reminders"):
|
||||
status, _ = cashier.get(path)
|
||||
self.assertEqual(403, status, path)
|
||||
|
||||
|
||||
class SettingsModuleTests(unittest.TestCase):
|
||||
"""Unit tests for the settings module on a fresh in-memory database."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.connection = connect(":memory:")
|
||||
self.addCleanup(self.connection.close)
|
||||
migrate(self.connection)
|
||||
|
||||
def test_defaults_applied_when_no_row_exists(self) -> None:
|
||||
values = settings.get_settings(self.connection)
|
||||
self.assertEqual("5", values["closing_day"])
|
||||
self.assertEqual("2026-01-01", values["start_date"])
|
||||
self.assertEqual("1", values["auto_remind"])
|
||||
self.assertEqual("3", values["remind_days"])
|
||||
|
||||
def test_validate_rejects_bad_values(self) -> None:
|
||||
for bad in ({"closing_day": "0"}, {"closing_day": "29"}, {"closing_day": "abc"}):
|
||||
_, error = settings.validate_settings(bad)
|
||||
self.assertIsNotNone(error)
|
||||
_, error = settings.validate_settings({"start_date": "2026-13-40"})
|
||||
self.assertIsNotNone(error)
|
||||
_, error = settings.validate_settings({"auto_remind": "2"})
|
||||
self.assertIsNotNone(error)
|
||||
|
||||
def test_update_writes_value_and_trail(self) -> None:
|
||||
user_id = auth.create_user(self.connection, "group-admin", "AdminPass123", "admin")
|
||||
user = self.connection.execute(
|
||||
"SELECT id, username FROM users WHERE id = ?", (user_id,)
|
||||
).fetchone()
|
||||
updated = settings.update_settings(
|
||||
self.connection, {"closing_day": "1", "auto_remind": "0"}, user
|
||||
)
|
||||
self.assertEqual("1", updated["closing_day"])
|
||||
self.assertEqual("0", updated["auto_remind"])
|
||||
changes = self.connection.execute(
|
||||
"SELECT key, before_value, after_value FROM system_setting_changes ORDER BY id"
|
||||
).fetchall()
|
||||
self.assertEqual(2, len(changes))
|
||||
self.assertEqual("5", changes[0]["before_value"])
|
||||
self.assertEqual("1", changes[0]["after_value"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+933
-198
File diff suppressed because it is too large
Load Diff
+1688
-1624
File diff suppressed because it is too large
Load Diff
+909
-179
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,720 @@
|
||||
/* ─── 金牛实业资金往来管理系统 · 共享样式 ─────────────────────────────
|
||||
方向: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; }
|
||||
|
||||
/* 端标识:登录页顶部大字区分管理端 / 公司端 */
|
||||
.login-role-title {
|
||||
font-size: 34px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1.15;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.login-role-title.admin { color: var(--accent); }
|
||||
.login-role-title.company { color: var(--info); }
|
||||
.login-role-caption { color: var(--muted); font-size: 13px; margin-bottom: 22px; }
|
||||
/* 管理端青绿 / 公司端品蓝侧栏色块 */
|
||||
.login-aside.company { background: var(--info); }
|
||||
.login-aside.company .aside-item .tick { color: var(--surface); opacity: 0.85; }
|
||||
.login-back {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
margin-bottom: 20px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
color: var(--fg);
|
||||
font-size: 12.5px; font-weight: 600;
|
||||
}
|
||||
.login-back:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.login-back.company:hover { border-color: var(--info); color: var(--info); }
|
||||
@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); }
|
||||
+30
-45
@@ -1,48 +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" />
|
||||
</head>
|
||||
<body class="entry-page">
|
||||
<!--
|
||||
THESIS: 登录页只完成身份确认,并明确区分总账管理端与公司业务端。
|
||||
OWN-WORLD: 深黑身份场景、石墨玻璃表单和荧光绿当前状态,延续双端工作台的材料语言。
|
||||
STORY: 居中集团登录卡:先确认集团身份与系统名称,再选择工作端口登录。
|
||||
FIRST VIEWPORT: 一张悬浮玻璃登录卡居于氛围光中央,集团名称置顶,端口选择、账号表单与系统事实依次排列。
|
||||
FORM: 用户参考图锁定的深色玻璃财务工作台,Operate 模式;seed key fe8a50aa。
|
||||
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md
|
||||
-->
|
||||
<main class="entry-shell">
|
||||
<section class="entry-card" aria-labelledby="login-title">
|
||||
<div class="entry-brand"><span class="brand-mark">金</span><span><strong id="product-name">河南金牛实业集团</strong><small>集团资金往来管理系统</small></span></div>
|
||||
<form class="entry-form" id="loginForm">
|
||||
<header><h2 id="login-title">登录</h2><p>请选择与账号一致的工作端口</p></header>
|
||||
<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>
|
||||
<p class="entry-note" id="loginError" role="alert" hidden></p>
|
||||
<label class="check-field"><input type="checkbox" checked />记住本次登录</label>
|
||||
<button class="button primary wide" type="submit"><span id="loginAction">进入总账管理端</span><svg><use href="icons.svg#chevron-right"/></svg></button>
|
||||
</form>
|
||||
<dl class="entry-facts">
|
||||
<div><dt>全局起算日</dt><dd>2026.01.01</dd></div>
|
||||
<div><dt>当前账期</dt><dd>2026 年 7 月</dd></div>
|
||||
<div><dt>覆盖银行</dt><dd>已对接 6 家</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
</main>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
<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=5" />
|
||||
</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>
|
||||
|
||||
<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,80 @@
|
||||
<!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=5" />
|
||||
</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">
|
||||
<a class="login-back" href="index.html">← 返回入口页</a>
|
||||
<div class="login-role-title admin">管理端</div>
|
||||
<p class="login-role-caption">管理员统一监控集团各公司资金往来,执行月度结账。</p>
|
||||
<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=5"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,80 @@
|
||||
<!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=5" />
|
||||
</head>
|
||||
<body data-role="company">
|
||||
<div class="login-wrap">
|
||||
<aside class="login-aside company">
|
||||
<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">
|
||||
<a class="login-back company" href="index.html">← 返回入口页</a>
|
||||
<div class="login-role-title company">公司端</div>
|
||||
<p class="login-role-caption">成员公司出纳上传流水、登记手工往来、确认单边匹配。</p>
|
||||
<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=5"></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;
|
||||
});
|
||||
-936
@@ -1,936 +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);
|
||||
--row-h-evidence: 55px;
|
||||
--z-drawer: 150;
|
||||
--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);
|
||||
}
|
||||
|
||||
* { 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 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; 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; min-width: 0; }
|
||||
.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 b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||
.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 */
|
||||
.entry-page { overflow-x: hidden; }
|
||||
.entry-shell { min-height: 100vh; display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(430px, 0.85fr); }
|
||||
.entry-context { position: relative; min-height: 100vh; display: flex; flex-direction: column; justify-content: space-between; padding: clamp(32px, 5vw, 76px); border-right: 1px solid var(--color-line); background: #080808; }
|
||||
.entry-context::after { content: ""; position: absolute; inset: 12% 8% 12% auto; width: 1px; background: rgba(55, 235, 137, 0.32); box-shadow: 0 0 38px rgba(55, 235, 137, 0.45); }
|
||||
.entry-brand { display: flex; align-items: center; gap: 12px; }
|
||||
.entry-brand > span:last-child { display: flex; flex-direction: column; }
|
||||
.entry-brand small { color: var(--color-ink-muted); }
|
||||
.entry-statement { max-width: 680px; margin: 80px 0; }
|
||||
.entry-statement h1 { max-width: 640px; font-size: clamp(38px, 5vw, 70px); line-height: 1.12; letter-spacing: 0; }
|
||||
.entry-statement p { margin-top: 20px; color: var(--color-ink-muted); }
|
||||
.entry-facts { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; max-width: 670px; }
|
||||
.entry-facts div { padding: 15px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: var(--color-surface-muted); }
|
||||
.entry-facts dt { color: var(--color-ink-muted); }
|
||||
.entry-facts dd { margin-top: 4px; color: var(--color-primary); }
|
||||
.entry-form-wrap { min-height: 100vh; display: grid; place-items: center; padding: 40px; background: var(--color-bg); }
|
||||
.entry-form { width: min(420px, 100%); display: grid; gap: 18px; padding: 28px; border: 1px solid var(--color-line); border-radius: var(--radius-xl); background: var(--color-surface); box-shadow: var(--shadow-panel); backdrop-filter: blur(24px); }
|
||||
.entry-form header h2 { font-size: 25px; }
|
||||
.entry-form header p, .entry-note { color: var(--color-ink-muted); }
|
||||
.role-switch { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; }
|
||||
.role-switch label { cursor: pointer; }
|
||||
.role-switch input { position: absolute; opacity: 0; }
|
||||
.role-switch span { min-height: 83px; display: grid; grid-template-columns: 30px 1fr; align-content: center; gap: 1px 9px; padding: 12px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: rgba(0, 0, 0, 0.14); transition: border-color var(--duration-fast), background var(--duration-fast), transform var(--duration-fast); }
|
||||
.role-switch svg { grid-row: 1 / 3; align-self: center; color: var(--color-ink-muted); }
|
||||
.role-switch small { color: var(--color-ink-muted); }
|
||||
.role-switch input:checked + span { border-color: rgba(55, 235, 137, 0.4); background: var(--color-primary-wash); transform: translateY(-1px); }
|
||||
.role-switch input:checked + span svg { color: var(--color-primary); }
|
||||
.entry-note { font-size: 10px; }
|
||||
|
||||
@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:not(.is-balances) summary { grid-template-columns: minmax(180px, 1.4fr) repeat(2, minmax(100px, 0.7fr)) 28px; }
|
||||
.company-ledger:not(.is-balances) summary > span:nth-child(4), .company-ledger:not(.is-balances) 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; }
|
||||
.entry-shell { grid-template-columns: 1fr; }
|
||||
.entry-context { min-height: 58vh; border-right: 0; border-bottom: 1px solid var(--color-line); }
|
||||
.entry-form-wrap { min-height: auto; }
|
||||
}
|
||||
|
||||
@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:not(.is-balances) summary { grid-template-columns: minmax(145px, 1fr) 100px 24px; padding-inline: 12px; }
|
||||
.company-ledger:not(.is-balances) summary > strong:nth-of-type(2), .company-ledger:not(.is-balances) summary > span:nth-child(4), .company-ledger:not(.is-balances) 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; }
|
||||
.entry-context { min-height: auto; padding: 30px 22px; }
|
||||
.entry-statement { margin: 54px 0 42px; }
|
||||
.entry-statement h1 { font-size: 39px; }
|
||||
.entry-facts { grid-template-columns: 1fr; }
|
||||
.entry-form-wrap { padding: 32px 16px 48px; }
|
||||
.entry-form { padding: 22px; }
|
||||
}
|
||||
|
||||
@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; }
|
||||
.role-switch { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.timeline-strip { grid-template-columns: 1fr; }
|
||||
.timeline-legend { padding-bottom: 4px; }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.company-row:not(.is-balance-counterparty) { grid-template-columns: 34px minmax(0, 1fr) auto; }
|
||||
.company-row:not(.is-balance-counterparty) .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; }
|
||||
|
||||
/* ===== 登录页重做:居中集团登录卡 ===== */
|
||||
.entry-shell { display: grid; grid-template-columns: 1fr; place-items: center; min-height: 100vh; padding: 48px 20px; }
|
||||
.entry-card { position: relative; width: min(470px, 100%); overflow: hidden; border: 1px solid rgba(255, 255, 255, 0.13); border-radius: var(--radius-xl); background: linear-gradient(165deg, rgba(36, 36, 36, 0.9) 0%, rgba(16, 16, 16, 0.92) 60%, rgba(10, 10, 10, 0.94) 100%); box-shadow: var(--shadow-panel), 0 0 60px rgba(55, 235, 137, 0.06); backdrop-filter: blur(28px) saturate(125%); }
|
||||
.entry-card::before { content: ""; position: absolute; inset: 0; background: radial-gradient(150% 110% at 50% 118%, rgba(255, 255, 255, 0.07), transparent 55%), linear-gradient(165deg, rgba(255, 255, 255, 0.12) 0%, rgba(255, 255, 255, 0.03) 42%, transparent 70%); pointer-events: none; }
|
||||
.entry-card::after { content: ""; position: absolute; top: 0; left: 18%; right: 18%; height: 2px; background: linear-gradient(90deg, transparent, rgba(55, 235, 137, 0.65), transparent); }
|
||||
.entry-brand { flex-direction: column; justify-content: center; gap: 15px; min-height: 0; padding: 36px 28px 24px; border-bottom: 1px solid var(--color-line); text-align: center; }
|
||||
.entry-brand .brand-mark { width: 54px; height: 54px; border-radius: 17px; font-size: 26px; }
|
||||
.entry-brand > span:last-child { align-items: center; }
|
||||
.entry-brand strong { font-size: 21px; letter-spacing: 0.03em; }
|
||||
.entry-brand small { margin-top: 4px; font-size: 12px; letter-spacing: 0.08em; }
|
||||
.entry-form { width: 100%; gap: 16px; padding: 24px 28px 10px; border: 0; background: transparent; box-shadow: none; backdrop-filter: none; }
|
||||
.entry-form header { text-align: center; }
|
||||
.entry-form header h2 { font-size: 22px; }
|
||||
.entry-form header p { margin-top: 4px; color: var(--color-ink-muted); font-size: 12px; }
|
||||
.entry-facts { max-width: none; gap: 10px; padding: 16px 28px 28px; }
|
||||
.entry-facts div { padding: 12px; text-align: center; }
|
||||
.entry-facts dd { margin-top: 2px; font-size: 12px; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; }
|
||||
}
|
||||
|
||||
/* ---- B-44 intercompany balances ---- */
|
||||
.status.info { border-color: rgba(102, 168, 255, 0.18); background: var(--color-info-wash); color: var(--color-info); }
|
||||
.amount-neutral { color: var(--color-ink-muted); }
|
||||
.currency-code { font-size: 10px; color: var(--color-ink-muted); margin-right: 4px; }
|
||||
.amount-with-currency { white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.amount-with-currency.is-compact { font-size: 11px; }
|
||||
.account-cell { white-space: nowrap; }
|
||||
.summary-cell { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.candidate-list { margin: 8px 0 0; padding: 0; list-style: none; color: var(--color-ink-soft); font-size: 12px; }
|
||||
.candidate-list li { padding: 4px 0; border-bottom: 1px dashed var(--color-line); }
|
||||
.candidate-list li:last-child { border-bottom: 0; }
|
||||
.reason-full { max-height: calc(1.6em * 6); overflow: auto; white-space: pre-wrap; }
|
||||
.decision-danger { margin-top: 8px; color: var(--color-danger); font-size: 12px; }
|
||||
.balance-currency-groups { display: grid; gap: 12px; }
|
||||
.balance-currency-groups .pair-balance-line { border: 1px solid var(--color-line); border-radius: var(--radius-md); overflow: hidden; }
|
||||
|
||||
/* Balance directory: 公司 | 借方 | 贷方 | 期末结果 | 未决 | 截止日 | ▸ */
|
||||
.ledger-head.is-balances, .company-ledger.is-balances summary {
|
||||
grid-template-columns: minmax(180px, 1.3fr) minmax(0, 0.7fr) minmax(0, 0.7fr) minmax(0, 0.9fr) minmax(0, 0.7fr) minmax(96px, 0.55fr) 24px;
|
||||
}
|
||||
.company-ledger.is-balances summary { min-height: 72px; }
|
||||
.company-ledger.is-balances summary .company-name { min-width: 96px; }
|
||||
.company-ledger.is-balances summary .ledger-cutoff { color: var(--color-ink-muted); font-size: 11px; white-space: nowrap; }
|
||||
.company-ledger.is-balances .ledger-result { display: flex; align-items: center; gap: 7px; min-width: 0; }
|
||||
.company-ledger.is-balances .ledger-result b { font: 600 14px var(--font-data); font-variant-numeric: tabular-nums; white-space: nowrap; min-width: 0; }
|
||||
.company-ledger.is-balances .ledger-unresolved { font-size: 11px; white-space: nowrap; }
|
||||
.company-ledger.is-balances .ledger-unresolved.is-empty { color: var(--color-ink-muted); }
|
||||
.company-ledger.is-balances .ledger-unresolved.is-active { color: var(--color-warning); }
|
||||
.company-ledger.is-balances .currency-tag { color: var(--color-ink-muted); font-size: 10px; }
|
||||
.company-ledger.is-balances .subject-row small { white-space: nowrap; }
|
||||
@media (max-width: 1179px) {
|
||||
.ledger-head.is-balances, .company-ledger.is-balances summary {
|
||||
grid-template-columns: minmax(120px, 1.2fr) minmax(136px, 0.9fr) minmax(0, 0.7fr) minmax(96px, 0.55fr) 24px;
|
||||
}
|
||||
.ledger-head.is-balances .ledger-hide-md,
|
||||
.company-ledger.is-balances summary .ledger-hide-md { display: none; }
|
||||
.company-ledger.is-balances summary .amount-with-currency .currency-code { display: none; }
|
||||
}
|
||||
@media (min-width: 376px) and (max-width: 900px) {
|
||||
.ledger-head.is-balances { display: grid; }
|
||||
.ledger-head.is-balances, .company-ledger.is-balances summary {
|
||||
grid-template-columns: minmax(80px, 0.85fr) minmax(152px, 1.2fr) minmax(96px, 0.9fr) minmax(84px, 0.5fr) 24px;
|
||||
}
|
||||
.company-ledger.is-balances summary .ledger-result {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.company-ledger.is-balances .ledger-result b { font-size: 11px; }
|
||||
.company-ledger.is-balances .ledger-unresolved {
|
||||
min-width: 0;
|
||||
white-space: normal;
|
||||
}
|
||||
.company-ledger.is-balances .ledger-unresolved .status {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
white-space: normal;
|
||||
justify-content: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
@media (max-width: 375px) {
|
||||
.ledger-head.is-balances { display: none; }
|
||||
.company-ledger.is-balances summary {
|
||||
grid-template-columns: minmax(88px, 1fr) minmax(0, max-content) 24px;
|
||||
grid-template-rows: auto auto;
|
||||
align-items: center;
|
||||
column-gap: 10px;
|
||||
row-gap: 4px;
|
||||
}
|
||||
.company-ledger.is-balances summary .company-name { grid-column: 1; grid-row: 1; min-width: 88px; }
|
||||
.company-ledger.is-balances summary .ledger-result {
|
||||
grid-column: 2; grid-row: 1; justify-self: end;
|
||||
flex-direction: column; align-items: flex-end; gap: 2px;
|
||||
}
|
||||
.company-ledger.is-balances summary .ledger-result .currency-code { display: none; }
|
||||
.company-ledger.is-balances summary .ledger-unresolved {
|
||||
grid-column: 1; grid-row: 2; min-width: 0; white-space: normal;
|
||||
}
|
||||
.company-ledger.is-balances summary .ledger-cutoff { grid-column: 2; grid-row: 2; justify-self: end; }
|
||||
.company-ledger.is-balances summary > svg { grid-column: 3; grid-row: 1 / span 2; align-self: center; }
|
||||
.company-ledger.is-balances summary .ledger-hide-md { display: none; }
|
||||
}
|
||||
|
||||
/* Pair report six-cell: page may be six-across; drawer is always two rows of three */
|
||||
.pair-balance-line.is-six { grid-template-columns: repeat(6, 1fr); }
|
||||
.pair-balance-line.is-six .pair-final { grid-column: auto; border-left: 1px solid var(--color-line); }
|
||||
.pair-balance-line .pair-open-note { color: var(--color-ink-muted); font-size: 10px; margin-top: 3px; }
|
||||
.pair-balance-line .pair-unresolved b { color: var(--color-warning); }
|
||||
.pair-balance-line .pair-unresolved.is-empty b { color: var(--color-ink); }
|
||||
.pair-balance-line .pair-cutoff { color: var(--color-ink-muted); font-size: 10px; margin-top: 3px; }
|
||||
@media (max-width: 1180px) {
|
||||
.pair-balance-line.is-six { grid-template-columns: repeat(3, 1fr); }
|
||||
.pair-balance-line.is-six div:nth-child(4) { border-top: 1px solid var(--color-line); border-left: 0; }
|
||||
.pair-balance-line.is-six div:nth-child(5), .pair-balance-line.is-six div:nth-child(6) { border-top: 1px solid var(--color-line); }
|
||||
}
|
||||
.amount-with-currency { font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.amount-with-currency.is-compact { font-size: 11px; }
|
||||
.amount-with-currency .currency-code { margin-right: 4px; color: var(--color-ink-muted); font-size: 10px; font-weight: 500; }
|
||||
|
||||
/* Evidence drawer: <768 full, 768–1179 480px, >=1180 640px */
|
||||
.drawer-scrim { position: fixed; inset: 0; z-index: calc(var(--z-drawer) - 1); background: rgba(5, 5, 5, 0.6); opacity: 0; pointer-events: none; transition: opacity var(--duration-standard) var(--ease-out); }
|
||||
.drawer-scrim.is-open { opacity: 1; pointer-events: auto; }
|
||||
.drawer { position: fixed; top: 0; right: 0; bottom: 0; z-index: var(--z-drawer); width: 640px; max-width: 100%; display: flex; flex-direction: column; background: rgba(17, 17, 17, 0.96); border-left: 1px solid var(--color-line-strong); box-shadow: var(--shadow-panel); transform: translateX(24px); opacity: 0; visibility: hidden; transition: transform var(--duration-standard) var(--ease-out), opacity var(--duration-standard) var(--ease-out), visibility 0s linear var(--duration-standard); }
|
||||
.drawer.is-open { transform: translateX(0); opacity: 1; visibility: visible; transition-delay: 0s; }
|
||||
@media (max-width: 1179px) { .drawer { width: 480px; } }
|
||||
@media (max-width: 767px) { .drawer { width: 100%; } }
|
||||
.drawer-header { min-height: 74px; display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 16px 18px; border-bottom: 1px solid var(--color-line); }
|
||||
.drawer-header .drawer-breadcrumb { display: flex; flex-wrap: wrap; align-items: center; gap: 5px; color: var(--color-ink-muted); font-size: 11px; }
|
||||
.drawer-header .drawer-breadcrumb button { border: 0; padding: 0; background: none; color: var(--color-info); cursor: pointer; font-size: 11px; }
|
||||
.drawer-header .drawer-breadcrumb button:hover { text-decoration: underline; }
|
||||
.drawer-header h2 { margin-top: 6px; font-size: 17px; }
|
||||
.drawer-body { flex: 1; overflow: auto; padding: 16px 18px 24px; contain: layout paint; }
|
||||
.drawer .pair-balance-line { border: 1px solid var(--color-line); border-radius: var(--radius-md); overflow: hidden; margin-bottom: 16px; }
|
||||
.drawer .pair-balance-line div { min-height: 64px; min-width: 0; overflow: hidden; }
|
||||
.drawer .pair-balance-line strong { overflow-wrap: anywhere; }
|
||||
.drawer .pair-balance-line .amount-with-currency.is-compact { white-space: normal; overflow-wrap: anywhere; }
|
||||
.drawer .pair-balance-line .pair-cutoff,
|
||||
.drawer .pair-balance-line strong.amount-neutral { white-space: nowrap; }
|
||||
.drawer .pair-balance-line.is-six {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
.drawer .pair-balance-line.is-six .pair-final { grid-column: auto; }
|
||||
.drawer .pair-balance-line.is-six div:nth-child(4) { border-top: 1px solid var(--color-line); border-left: 0; }
|
||||
.drawer .pair-balance-line.is-six div:nth-child(5),
|
||||
.drawer .pair-balance-line.is-six div:nth-child(6) { border-top: 1px solid var(--color-line); }
|
||||
.drawer .event-table { min-width: 860px; }
|
||||
.drawer .event-table td { height: var(--row-h-evidence); }
|
||||
.drawer .event-row { cursor: pointer; }
|
||||
.drawer .event-row:focus-visible { outline: 2px solid var(--color-primary-strong); outline-offset: -2px; }
|
||||
.drawer .evidence-stack { display: flex; flex-direction: column; gap: 10px; }
|
||||
.drawer .evidence-midline { display: flex; align-items: center; gap: 10px; color: var(--color-ink-muted); font-size: 11px; }
|
||||
.drawer .evidence-midline::before, .drawer .evidence-midline::after { content: ""; flex: 1; height: 1px; background: var(--color-line); }
|
||||
.evidence-block.is-missing { border-color: rgba(102, 168, 255, 0.28); background: var(--color-info-wash); }
|
||||
.evidence-block .evidence-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px 14px; margin-top: 10px; }
|
||||
.evidence-block .evidence-grid div { display: flex; flex-direction: column; min-width: 0; }
|
||||
.evidence-block .evidence-grid dt { color: var(--color-ink-muted); font-size: 10px; }
|
||||
.evidence-block .evidence-grid dd { color: var(--color-ink-soft); font-size: 12px; word-break: break-all; }
|
||||
.evidence-block .evidence-masked { color: var(--color-ink-muted); font-style: normal; }
|
||||
@media (max-width: 720px) { .evidence-block .evidence-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
.company-row.is-balance-counterparty { grid-template-columns: 34px minmax(0, 1fr) minmax(0, auto); }
|
||||
.company-row.is-balance-counterparty .company-row-figure {
|
||||
display: flex; flex-direction: column; align-items: flex-end; gap: 4px; min-width: 0;
|
||||
}
|
||||
.company-row.is-balance-counterparty .company-row-figure strong { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 6px; }
|
||||
@media (max-width: 900px) {
|
||||
.company-row.is-balance-counterparty {
|
||||
grid-template-columns: 34px minmax(0, 1fr);
|
||||
row-gap: 8px;
|
||||
}
|
||||
.company-row.is-balance-counterparty .company-row-figure {
|
||||
grid-column: 2;
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: row;
|
||||
column-gap: 10px;
|
||||
row-gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading skeletons */
|
||||
.skeleton-row { height: var(--row-h-evidence); display: flex; align-items: center; gap: 12px; padding: 0 14px; border-bottom: 1px solid var(--color-line); }
|
||||
.skeleton-row span { height: 12px; border-radius: 6px; background: rgba(255, 255, 255, 0.06); }
|
||||
.skeleton-row span:nth-child(1) { width: 22%; } .skeleton-row span:nth-child(2) { width: 14%; }
|
||||
.skeleton-row span:nth-child(3) { width: 14%; } .skeleton-row span:nth-child(4) { width: 18%; }
|
||||
.skeleton-row span:nth-child(5) { width: 12%; }
|
||||
.skeleton-block { animation: skeleton-breathe 1200ms ease-in-out infinite; }
|
||||
|
||||
/* Empty / error / no-permission states */
|
||||
.state-panel { display: flex; flex-direction: column; align-items: center; gap: 10px; justify-content: center; min-height: 240px; padding: 28px 18px; text-align: center; color: var(--color-ink-muted); }
|
||||
.state-panel .state-icon { width: 40px; height: 40px; color: var(--color-ink-muted); opacity: 0.7; }
|
||||
.state-panel strong { color: var(--color-ink-soft); font-size: 14px; }
|
||||
.state-panel p { max-width: 420px; font-size: 12px; }
|
||||
.state-panel .button { margin-top: 6px; }
|
||||
.state-panel.is-error { border: 1px solid rgba(255, 98, 109, 0.3); border-radius: var(--radius-lg); background: var(--color-danger-wash); }
|
||||
.state-panel.is-error strong { color: var(--color-danger); }
|
||||
.state-panel.is-denied { border: 1px solid var(--color-line); border-radius: var(--radius-lg); background: var(--color-surface); }
|
||||
|
||||
/* Company portal alert variant + direction chips */
|
||||
.balance-alert { border-color: rgba(255, 188, 82, 0.24); background: var(--color-warning-wash); }
|
||||
.balance-alert > svg { color: var(--color-warning); }
|
||||
.balance-alert .button { color: var(--color-warning); }
|
||||
.company-row .result-direction { font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
.subject-strip button { white-space: nowrap; }
|
||||
|
||||
/* Direction chips: text-first, arrows as affordance, never color-only */
|
||||
.direction-chip { display: inline-flex; align-items: center; gap: 4px; min-height: 23px; padding: 2px 8px; border-radius: 8px; border: 1px solid var(--color-line); background: rgba(255, 255, 255, 0.045); color: var(--color-ink-soft); font-size: 10px; white-space: nowrap; }
|
||||
|
||||
@keyframes skeleton-breathe { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }
|
||||
@media (prefers-reduced-motion: reduce) { .skeleton-block { animation: none; } }
|
||||
Reference in New Issue
Block a user