Compare commits

..
Author SHA1 Message Date
1061f125a8 HEL-360: 手机端 Toast 覆盖层改为点击穿透
≤720px 时 toast-region 因 top/left/right/bottom 铺满视口,空白处不再拦截导航和表单;Toast 本体仍可点。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-01 22:53:58 +08:00
acc3c29c49 HEL-351: 修复夜间下拉/日期弹层、一次性初始密码领取与缩放滚动条
夜间控件改走现有 token 与 color-scheme,日期弹层锚定触发器并可翻转;
创建公司账号改为一次性可复制口令窗口,列表与后续接口不再回明文。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-01 21:58:56 +08:00
14 changed files with 1330 additions and 37 deletions
+1 -1
View File
@@ -50,7 +50,7 @@ class ConfirmStatusSourceContractTests(unittest.TestCase):
self.assertIn('id="workspacePendingStatus"', html)
self.assertIn('id="workspaceFlowSub"', html)
self.assertIn('data-view-link="reconcile"', html)
self.assertIn("app.js?v=17", html)
self.assertIn("app.js?v=18", html)
# 静态初值仍为进行中(黄),由 JS 在 pending=0 时切 done
self.assertRegex(html, r'class="flow-step doing"[^>]*data-view-link="reconcile"')
+2 -2
View File
@@ -31,8 +31,8 @@ class TransfersPageSourceContractTests(unittest.TestCase):
self.assertIn('id="transferEvidenceDrawer"', html)
self.assertIn("期间净变动", html)
self.assertNotIn("本公司往来合计", html)
self.assertIn("design-system.css?v=12", html)
self.assertIn("app.js?v=17", html)
self.assertIn("design-system.css?v=13", html)
self.assertIn("app.js?v=18", html)
# 侧栏顺序:流水管理 → 转账往来 → 往来确认
flows = html.index('data-view="flows"')
transfers = html.index('data-view="transfers"')
+271
View File
@@ -0,0 +1,271 @@
"""HEL-351: 夜间控件、一次性初始密码交付、缩放滚动条契约。"""
from __future__ import annotations
import os
import threading
import unittest
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
WEB = ROOT / "web"
try:
from playwright.sync_api import sync_playwright
except ImportError: # pragma: no cover
sync_playwright = None
def _prepare_chrome_libs() -> Path | None:
candidates = [ROOT / ".chrome-libs" / "lib"]
for lib_dir in candidates:
if (lib_dir / "libatk-1.0.so.0").exists():
current = os.environ.get("LD_LIBRARY_PATH", "")
prefix = str(lib_dir)
if prefix not in current.split(":"):
os.environ["LD_LIBRARY_PATH"] = (
f"{prefix}:{current}" if current else prefix
)
return lib_dir
return None
def _chromium_available() -> bool:
if not sync_playwright:
return False
_prepare_chrome_libs()
try:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
browser.close()
return True
except Exception:
return False
class Hel351SourceContractTests(unittest.TestCase):
def test_night_select_and_scrollbar_use_tokens(self) -> None:
css = (WEB / "design-system.css").read_text(encoding="utf-8")
self.assertIn("color-scheme: light", css)
self.assertIn("color-scheme: dark", css)
self.assertIn("scrollbar-color: var(--gold) var(--surface-2)", css)
self.assertIn("html[data-theme=\"night\"] ::-webkit-scrollbar-thumb", css)
self.assertIn("background-color: var(--gold)", css)
self.assertIn(".select option", css)
self.assertIn("background-color: var(--surface)", css)
self.assertIn("html[data-theme=\"night\"] .ds-dp-day.is-selected", css)
self.assertIn("color: var(--gold)", css)
self.assertNotIn("overflow: hidden; /* HEL-351", css)
def test_tabs_and_table_split_overflow_axes(self) -> None:
css = (WEB / "design-system.css").read_text(encoding="utf-8")
tabs = css[css.index(".tabs {") : css.index(".tabs button")]
self.assertIn("overflow-x: auto", tabs)
self.assertIn("overflow-y: hidden", tabs)
wrap = css[css.index(".table-wrap {") : css.index(".table-wrap.dash-master-scroll")]
self.assertIn("overflow-x: auto", wrap)
self.assertIn("overflow-y: hidden", wrap)
self.assertIn(".table-wrap.dash-master-scroll", css)
self.assertIn("overflow-y: auto", css[css.index(".table-wrap.dash-master-scroll") :][:180])
def test_admin_delivers_once_password_window(self) -> None:
html = (WEB / "admin.html").read_text(encoding="utf-8")
self.assertIn('id="credentialDialog"', html)
self.assertIn('id="cred-pass"', html)
self.assertIn('id="cred-copy-pass"', html)
self.assertIn("datepicker.js?v=1", html)
self.assertNotIn("初始密码由管理员统一发放", html)
js = (WEB / "app.js").read_text(encoding="utf-8")
self.assertIn("function showOnceCredentials(", js)
self.assertIn("function wipeCredentials(", js)
self.assertNotIn("初始密码已生成(仅此一次显示):${result.initial_password}", js)
self.assertNotIn("临时密码已生成(仅此一次):${result.initial_password}", js)
self.assertIn('openModal("credentialDialog")', js)
def test_datepicker_follows_trigger_and_flips(self) -> None:
js = (WEB / "datepicker.js").read_text(encoding="utf-8")
css = (WEB / "design-system.css").read_text(encoding="utf-8")
self.assertIn("visualViewport", js)
self.assertIn("getBoundingClientRect", js)
self.assertIn("rect.top - gap - height", js)
self.assertIn('addEventListener("scroll", position, true)', js)
block = css[css.index(".ds-datepicker {") : css.index(".ds-datepicker[hidden]")]
self.assertIn("position: fixed", block)
self.assertIn("z-index: var(--z-tooltip)", block)
@unittest.skipUnless(sync_playwright, "playwright 未安装,跳过浏览器探针")
@unittest.skipUnless(_chromium_available(), "chromium 无法启动,跳过浏览器探针")
class Hel351BrowserProbeTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
handler = partial(SimpleHTTPRequestHandler, directory=str(WEB))
cls.httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
cls.port = cls.httpd.server_address[1]
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
cls.thread.start()
cls.base = f"http://127.0.0.1:{cls.port}"
@classmethod
def tearDownClass(cls) -> None:
cls.httpd.shutdown()
cls.httpd.server_close()
def _open(self, page, *, night: bool, width: int = 1440, zoom: float = 1) -> None:
page.set_viewport_size({"width": width, "height": 900})
theme_attr = 'data-theme="night"' if night else 'data-theme="day"'
page.set_content(
f"""<!doctype html>
<html lang="zh-CN" class="v-fusion" {theme_attr}>
<head>
<meta charset="UTF-8"/>
<link rel="stylesheet" href="{self.base}/design-system.css"/>
</head>
<body class="is-app" {theme_attr}>
<div class="tabs" id="probe-tabs">
<button class="active">全部</button>
<button>流水断档</button>
<button>单边匹配</button>
<button>科目确认</button>
<button>起算区间校准</button>
<button>银行账户登记</button>
<button>公司手工记录</button>
<button>重开审批</button>
</div>
<select class="select" id="probe-select">
<option>河南金牛农业科技发展有限公司</option>
<option>河南金牛煤业有限公司</option>
</select>
<input class="input" id="probe-date" type="date" value="2026-09-01" />
<script src="{self.base}/datepicker.js"></script>
</body></html>""",
wait_until="domcontentloaded",
)
page.wait_for_function("() => window.JinniuDatePicker")
if zoom != 1:
page.evaluate(f"() => {{ document.body.style.zoom = '{zoom}'; }}")
def test_night_select_is_not_white_on_white(self) -> None:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
page = browser.new_page(viewport={"width": 1440, "height": 900})
self._open(page, night=True)
colors = page.evaluate(
"""() => {
const sel = document.getElementById('probe-select');
const cs = getComputedStyle(sel);
const opt = getComputedStyle(sel.options[0]);
return {
scheme: getComputedStyle(document.documentElement).colorScheme,
bg: cs.backgroundColor,
color: cs.color,
optionBg: opt.backgroundColor,
optionColor: opt.color,
};
}"""
)
browser.close()
self.assertEqual("dark", colors["scheme"])
self.assertNotEqual("rgb(255, 255, 255)", colors["bg"])
self.assertNotEqual("rgb(255, 255, 255)", colors["color"])
self.assertNotEqual("rgb(255, 255, 255)", colors["optionBg"])
def test_tabs_have_no_vertical_scrollbar_at_zoom(self) -> None:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
page = browser.new_page(viewport={"width": 390, "height": 800})
self._open(page, night=False, width=390, zoom=1.25)
metrics = page.evaluate(
"""() => {
const tabs = document.getElementById('probe-tabs');
const cs = getComputedStyle(tabs);
return {
overflowX: cs.overflowX,
overflowY: cs.overflowY,
clientHeight: tabs.clientHeight,
scrollHeight: tabs.scrollHeight,
clientWidth: tabs.clientWidth,
scrollWidth: tabs.scrollWidth,
};
}"""
)
browser.close()
self.assertEqual("auto", metrics["overflowX"])
self.assertEqual("hidden", metrics["overflowY"])
self.assertLessEqual(metrics["scrollHeight"] - metrics["clientHeight"], 1)
def test_datepicker_anchors_and_night_gold(self) -> None:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
page = browser.new_page(viewport={"width": 1440, "height": 900})
self._open(page, night=True)
page.click("#probe-date")
page.wait_for_selector("#ds-datepicker:not([hidden])")
box = page.evaluate(
"""() => {
const input = document.getElementById('probe-date');
const panel = document.getElementById('ds-datepicker');
const ir = input.getBoundingClientRect();
const pr = panel.getBoundingClientRect();
const selected = panel.querySelector('.ds-dp-day.is-selected');
const cs = selected ? getComputedStyle(selected) : null;
return {
inputBottom: ir.bottom,
inputLeft: ir.left,
panelTop: pr.top,
panelLeft: pr.left,
panelRight: pr.right,
viewportWidth: window.innerWidth,
selectedColor: cs && cs.color,
selectedBg: cs && cs.backgroundColor,
hidden: panel.hidden,
};
}"""
)
browser.close()
self.assertFalse(box["hidden"])
self.assertLess(abs(box["panelTop"] - box["inputBottom"]), 24)
self.assertLess(abs(box["panelLeft"] - box["inputLeft"]), 24)
self.assertLess(box["panelRight"], box["viewportWidth"])
self.assertIsNotNone(box["selectedColor"])
self.assertNotEqual("rgb(255, 255, 255)", box["selectedBg"])
self.assertIn("217", box["selectedColor"])
def test_datepicker_flips_when_near_bottom(self) -> None:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
page = browser.new_page(viewport={"width": 1440, "height": 500})
page.set_content(
f"""<!doctype html>
<html lang="zh-CN" class="v-fusion" data-theme="night">
<head>
<meta charset="UTF-8"/>
<link rel="stylesheet" href="{self.base}/design-system.css"/>
</head>
<body class="is-app" data-theme="night" style="min-height:100vh">
<input class="input" id="probe-date" type="date" value="2026-09-01" style="position:fixed;left:24px;bottom:12px;width:180px" />
<script src="{self.base}/datepicker.js"></script>
</body></html>""",
wait_until="domcontentloaded",
)
page.wait_for_function("() => window.JinniuDatePicker")
page.click("#probe-date")
page.wait_for_selector("#ds-datepicker:not([hidden])")
box = page.evaluate(
"""() => {
const input = document.getElementById('probe-date');
const panel = document.getElementById('ds-datepicker');
const ir = input.getBoundingClientRect();
const pr = panel.getBoundingClientRect();
return { inputTop: ir.top, panelBottom: pr.bottom, panelTop: pr.top };
}"""
)
browser.close()
self.assertLess(box["panelBottom"], box["inputTop"] + 2)
self.assertGreater(box["inputTop"] - box["panelBottom"], 0)
if __name__ == "__main__":
unittest.main()
+411
View File
@@ -0,0 +1,411 @@
"""HEL-360: 手机端 Toast 覆盖层不得拦截页面点击。"""
from __future__ import annotations
import os
import re
import tempfile
import threading
import unittest
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from bank_importer import auth, master_data
from bank_importer.db import connect, migrate
import server
ROOT = Path(__file__).resolve().parents[1]
WEB = ROOT / "web"
ADMIN_PASSWORD = "AdminPass123"
CASHIER_PASSWORD = "CashierA123"
try:
from playwright.sync_api import sync_playwright
except ImportError: # pragma: no cover
sync_playwright = None
def _prepare_chrome_libs() -> Path | None:
candidates = [ROOT / ".chrome-libs" / "lib"]
for lib_dir in candidates:
if (lib_dir / "libatk-1.0.so.0").exists():
current = os.environ.get("LD_LIBRARY_PATH", "")
prefix = str(lib_dir)
if prefix not in current.split(":"):
os.environ["LD_LIBRARY_PATH"] = (
f"{prefix}:{current}" if current else prefix
)
return lib_dir
return None
def _chromium_available() -> bool:
if not sync_playwright:
return False
_prepare_chrome_libs()
try:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
browser.close()
return True
except Exception:
return False
def _css_rule(css: str, selector: str) -> str:
match = re.search(rf"{re.escape(selector)} \{{([^}}]*)\}}", css)
if not match:
raise AssertionError(f"missing rule {selector}")
return match.group(1)
def _mouse_click_center(page, selector: str) -> tuple[float, float]:
loc = page.locator(selector).first
loc.wait_for(state="visible", timeout=8000)
loc.scroll_into_view_if_needed()
page.wait_for_function(
"""(sel) => {
const el = document.querySelector(sel);
if (!el) return false;
const r = el.getBoundingClientRect();
return r.width > 2 && r.height > 2 && r.right > 0 && r.bottom > 0
&& r.left < window.innerWidth && r.top < window.innerHeight;
}""",
arg=selector,
timeout=4000,
)
box = loc.bounding_box()
assert box, f"{selector} has no box"
x = box["x"] + box["width"] / 2
y = box["y"] + box["height"] / 2
page.mouse.click(x, y)
return x, y
def _hit(page, x: float, y: float) -> dict:
return page.evaluate(
"""({x, y}) => {
const el = document.elementFromPoint(x, y);
const region = document.getElementById('toastRegion');
const toast = el && el.closest ? el.closest('.toast') : null;
return {
tag: el ? el.tagName : null,
id: el ? el.id : null,
className: el && el.className ? String(el.className) : '',
inRegion: !!(region && el && region.contains(el)),
inToast: !!toast,
regionPe: region ? getComputedStyle(region).pointerEvents : null,
toastPe: toast ? getComputedStyle(toast).pointerEvents : null,
};
}""",
{"x": x, "y": y},
)
class Hel360SourceContractTests(unittest.TestCase):
def test_toast_region_passes_clicks_to_toast_only(self) -> None:
css = (WEB / "design-system.css").read_text(encoding="utf-8")
region = _css_rule(css, ".toast-region")
toast = _css_rule(css, ".toast")
self.assertIn("pointer-events: none", region)
self.assertIn("pointer-events: auto", toast)
self.assertIn("z-index: var(--z-toast)", region)
mobile = css[css.index("@media (max-width: 720px) {\n .toast-region") :]
mobile_block = mobile[: mobile.index("\n}")]
self.assertIn("left: 12px", mobile_block)
self.assertIn("bottom: 12px", mobile_block)
self.assertIn("right: 12px", mobile_block)
self.assertNotIn("pointer-events: none !important", css)
self.assertNotIn("z-index: -1", region)
self.assertNotIn("z-index: 0", region)
@unittest.skipUnless(sync_playwright, "playwright 未安装,跳过浏览器探针")
@unittest.skipUnless(_chromium_available(), "chromium 无法启动,跳过浏览器探针")
class Hel360CssPointerProbeTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
handler = partial(SimpleHTTPRequestHandler, directory=str(WEB))
cls.httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
cls.port = cls.httpd.server_address[1]
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
cls.thread.start()
cls.base = f"http://127.0.0.1:{cls.port}"
@classmethod
def tearDownClass(cls) -> None:
cls.httpd.shutdown()
cls.httpd.server_close()
def _open(self, page, *, night: bool, width: int, height: int = 640) -> None:
page.set_viewport_size({"width": width, "height": height})
theme = "night" if night else "day"
page.set_content(
f"""<!doctype html>
<html lang="zh-CN" class="v-fusion" data-theme="{theme}">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="stylesheet" href="{self.base}/design-system.css"/>
</head>
<body class="is-app" data-theme="{theme}" style="margin:0;min-height:100vh">
<button type="button" id="page-btn" style="position:fixed;left:50%;top:58%;transform:translate(-50%,-50%);z-index:1">页面按钮</button>
<a id="page-nav" href="#pair" style="position:fixed;left:16px;top:88px;z-index:1">往来查询</a>
<div class="tabs" style="position:fixed;left:16px;top:124px;z-index:1;width:220px">
<button type="button" id="page-tab">页签</button>
</div>
<input class="input" id="page-date" type="date" value="2026-09-01" style="position:fixed;left:16px;top:176px;z-index:1;width:160px" />
<div class="toast-region" id="toastRegion" aria-live="polite">
<div class="toast info" id="the-toast" role="status">
<span class="t-dot"></span>
<div class="t-body">
<div class="t-title">提示</div>
<button type="button" id="toast-copy">复制</button>
<button type="button" id="toast-close">关闭</button>
</div>
</div>
</div>
<script>
window.__clicks = [];
["page-btn","page-nav","page-tab","page-date","toast-copy","toast-close"].forEach((id) => {{
document.getElementById(id).addEventListener("click", () => window.__clicks.push(id));
}});
</script>
</body></html>""",
wait_until="domcontentloaded",
)
page.wait_for_selector("#toastRegion")
def test_empty_overlay_does_not_eat_real_clicks(self) -> None:
cases = [
(360, False, 640),
(390, True, 640),
(820, False, 900),
(1440, True, 900),
]
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
page = browser.new_page()
try:
for width, night, height in cases:
with self.subTest(width=width, night=night):
self._open(page, night=night, width=width, height=height)
metrics = page.evaluate(
"""() => {
const tr = document.getElementById('toastRegion');
const toast = document.getElementById('the-toast');
const r = tr.getBoundingClientRect();
return {
regionPe: getComputedStyle(tr).pointerEvents,
toastPe: getComputedStyle(toast).pointerEvents,
height: r.height,
width: r.width,
top: r.top,
viewportH: window.innerHeight,
};
}"""
)
self.assertEqual("none", metrics["regionPe"], metrics)
self.assertEqual("auto", metrics["toastPe"], metrics)
if width <= 720:
self.assertGreater(metrics["height"], metrics["viewportH"] - 40, metrics)
page.evaluate("() => { window.__clicks = []; }")
_mouse_click_center(page, "#page-btn")
_mouse_click_center(page, "#page-nav")
_mouse_click_center(page, "#page-tab")
_mouse_click_center(page, "#page-date")
clicks = page.evaluate("() => window.__clicks.slice()")
self.assertEqual(
["page-btn", "page-nav", "page-tab", "page-date"],
clicks,
f"width={width} night={night} clicks={clicks}",
)
cx, cy = _mouse_click_center(page, "#toast-copy")
hit = _hit(page, cx, cy)
self.assertTrue(hit["inToast"], hit)
_mouse_click_center(page, "#toast-close")
toast_clicks = page.evaluate("() => window.__clicks.slice(-2)")
self.assertEqual(["toast-copy", "toast-close"], toast_clicks)
mid_x = width / 2
mid_y = height * 0.58
mid = _hit(page, mid_x, mid_y)
self.assertFalse(mid["inRegion"] and not mid["inToast"], mid)
self.assertEqual("none", mid["regionPe"], mid)
finally:
browser.close()
@unittest.skipUnless(sync_playwright, "playwright 未安装,跳过浏览器冒烟")
@unittest.skipUnless(_chromium_available(), "chromium 无法启动,跳过浏览器冒烟")
class Hel360LiveAppClickTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
_prepare_chrome_libs()
cls.temp_dir = tempfile.TemporaryDirectory()
root = Path(cls.temp_dir.name)
cls.db_path = root / "app.db"
cls.storage = root / "files"
cls.storage.mkdir()
cls._old_db = server.DB_PATH
cls._old_storage = server.STORAGE_DIR
server.DB_PATH = cls.db_path
server.STORAGE_DIR = cls.storage
connection = connect(cls.db_path)
migrate(connection)
auth.create_user(
connection, "group-admin", ADMIN_PASSWORD, "admin", must_change_password=False
)
company_id = master_data.create_company(connection, "甲公司", None, None, None)
auth.create_user(
connection,
"cashier-a",
CASHIER_PASSWORD,
"company",
company_id,
must_change_password=False,
)
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.base = f"http://127.0.0.1:{cls.port}"
@classmethod
def tearDownClass(cls) -> None:
cls.httpd.shutdown()
cls.httpd.server_close()
server.DB_PATH = cls._old_db
server.STORAGE_DIR = cls._old_storage
cls.temp_dir.cleanup()
def _login(self, page, *, portal: str) -> None:
login_path = "login-admin.html" if portal == "admin" else "login-company.html"
username = "group-admin" if portal == "admin" else "cashier-a"
password = ADMIN_PASSWORD if portal == "admin" else CASHIER_PASSWORD
page.goto(f"{self.base}/{login_path}", wait_until="domcontentloaded")
page.fill("#account", username)
page.fill("#password", password)
page.locator('button[type="submit"]').click()
expect = "admin.html" if portal == "admin" else "company.html"
page.wait_for_url(f"**/{expect}", timeout=15000)
page.wait_for_selector(".side-nav", timeout=10000)
def _set_theme(self, page, night: bool) -> None:
theme = "night" if night else "day"
page.evaluate(
"""(theme) => {
document.documentElement.setAttribute('data-theme', theme);
document.body.setAttribute('data-theme', theme);
try { localStorage.setItem('jinniu-theme', theme); } catch (e) {}
}""",
theme,
)
def _goto_view(self, page, view: str) -> None:
_mouse_click_center(page, f'.side-nav a[data-view="{view}"]')
page.wait_for_selector(f'.app-view[data-page="{view}"].is-active', timeout=4000)
def _probe_toast_passthrough(self, page, width: int, height: int) -> None:
page.evaluate(
"""() => {
window.__pageHits = 0;
const btn = document.querySelector('.topbar .icon-button, .menu-button, .btn');
if (btn) btn.addEventListener('click', () => { window.__pageHits += 1; }, { once: true });
if (typeof showToast === 'function') showToast('HEL-360', '点击穿透', 'info');
}"""
)
page.wait_for_selector("#toastRegion .toast", timeout=4000)
toast_box = page.locator("#toastRegion .toast").first.bounding_box()
self.assertIsNotNone(toast_box)
page.mouse.click(
toast_box["x"] + toast_box["width"] / 2,
toast_box["y"] + toast_box["height"] / 2,
)
toast_hit = _hit(
page,
toast_box["x"] + toast_box["width"] / 2,
toast_box["y"] + toast_box["height"] / 2,
)
self.assertTrue(toast_hit["inToast"], toast_hit)
self.assertEqual("auto", toast_hit["toastPe"], toast_hit)
empty_x = width / 2
empty_y = height * 0.62
empty = _hit(page, empty_x, empty_y)
self.assertEqual("none", empty["regionPe"], empty)
self.assertFalse(empty["inToast"], empty)
self.assertFalse(empty["inRegion"], empty)
page.mouse.click(empty_x, empty_y)
after = _hit(page, empty_x, empty_y)
self.assertFalse(after["inRegion"], after)
def test_real_clicks_reach_admin_and_company_controls(self) -> None:
cases = [
("admin", 360, 640, False),
("admin", 390, 640, True),
("company", 360, 640, True),
("company", 390, 640, False),
("admin", 820, 900, False),
("admin", 1440, 900, True),
]
with sync_playwright() as p:
browser = p.chromium.launch(
headless=True, args=["--no-sandbox", "--disable-dev-shm-usage"]
)
try:
for portal, width, height, night in cases:
with self.subTest(portal=portal, width=width, night=night):
context = browser.new_context(viewport={"width": 1440, "height": 900})
page = context.new_page()
try:
self._login(page, portal=portal)
self._set_theme(page, night)
page.set_viewport_size({"width": width, "height": height})
page.wait_for_timeout(200)
region = page.evaluate(
"""() => {
const tr = document.getElementById('toastRegion');
const cs = getComputedStyle(tr);
const r = tr.getBoundingClientRect();
return { pe: cs.pointerEvents, height: r.height, vw: window.innerWidth };
}"""
)
self.assertEqual("none", region["pe"], region)
if portal == "admin":
self._goto_view(page, "audit")
tab_sel = '#auditTabs button[data-audit-filter="断档"]'
_mouse_click_center(page, tab_sel)
self.assertEqual("true", page.locator(tab_sel).get_attribute("aria-pressed"))
self._goto_view(page, "pair")
date_sel = "#pairEnd"
else:
self._goto_view(page, "reconcile")
_mouse_click_center(page, "#tab-subject")
self.assertTrue(page.locator("#panel-subject").is_visible())
self._goto_view(page, "flows")
date_sel = "#flowStart"
page.locator(date_sel).scroll_into_view_if_needed()
_mouse_click_center(page, date_sel)
page.wait_for_selector("#ds-datepicker:not([hidden])", timeout=4000)
page.keyboard.press("Escape")
self._probe_toast_passthrough(page, width, height)
finally:
context.close()
finally:
browser.close()
if __name__ == "__main__":
unittest.main()
+3 -3
View File
@@ -37,8 +37,8 @@ class RemindersPageSourceContractTests(unittest.TestCase):
self.assertIn('id="reminder-tbody"', html)
self.assertIn('id="reminder-tabs"', html)
self.assertIn('id="reminder-detail-drawer"', html)
self.assertIn("design-system.css?v=12", html)
self.assertIn("app.js?v=17", html)
self.assertIn("design-system.css?v=13", html)
self.assertIn("app.js?v=18", html)
pending = html.index('id="pending-reminders-card"')
history = html.index('id="reminder-history-card"')
send = html.index('id="send-reminder-card"')
@@ -135,7 +135,7 @@ class RemindersPageLayoutSmokeTests(unittest.TestCase):
def test_send_flow_columns_and_no_page_overflow(self) -> None:
html = (WEB / "admin.html").read_text(encoding="utf-8")
self.assertIn("app.js?v=17", html)
self.assertIn("app.js?v=18", html)
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
+6 -1
View File
@@ -540,6 +540,9 @@ class ServerAuthMatrixTests(unittest.TestCase):
status, _, data = self.admin.get("/api/admin/users")
self.assertEqual(200, status)
bodies.append(data)
status, _, data = self.admin.get("/api/admin/companies")
self.assertEqual(200, status)
bodies.append(data)
status, _, data = self.admin.get("/api/admin/audit-log?limit=100")
self.assertEqual(200, status)
bodies.append(data)
@@ -548,7 +551,9 @@ class ServerAuthMatrixTests(unittest.TestCase):
status, _, data = self.cashier_a.get("/api/batches")
bodies.append(data)
for body in bodies:
self.assertNotIn("password_hash", body.decode("utf-8"))
text = body.decode("utf-8")
self.assertNotIn("password_hash", text)
self.assertNotIn("initial_password", text)
def test_audit_log_contains_no_plaintext_passwords(self) -> None:
connection = connect(self.db_path)
+33 -5
View File
@@ -11,7 +11,7 @@
document.documentElement.classList.add("v-fusion");
})();
</script>
<link rel="stylesheet" href="design-system.css?v=12" />
<link rel="stylesheet" href="design-system.css?v=13" />
</head>
<body class="is-app" data-portal="admin">
<a class="skip-link" href="#main-content">跳到主要内容</a>
@@ -512,7 +512,7 @@
</div>
<div class="table-foot" style="border: 1px solid var(--border); border-radius: 0 0 var(--radius-lg) var(--radius-lg);">
<span id="companyFoot">共 0 家公司</span>
<span class="meta">登录账号初始密码由管理员统一发放</span>
<span class="meta">创建账号时生成一次性随机密码,仅当时显示一次</span>
</div>
</div>
</section>
@@ -876,7 +876,7 @@
<span class="modal-title">新增成员公司</span>
<button type="button" class="modal-close" data-close="companyDialog" aria-label="关闭">×</button>
</div>
<p class="modal-sub">登记后系统将生成公司端登录账号,初始密码由管理员统一发放</p>
<p class="modal-sub">登记后可同时生成公司端登录账号。系统会发一次性随机初始密码,仅创建成功时显示一次,请当场复制后交给出纳;首次登录必须改密。没有全公司通用默认密码</p>
<form id="companyForm" novalidate>
<div class="field" style="margin-bottom: 12px;">
<label for="f-name">公司全称</label>
@@ -899,7 +899,7 @@
<input class="input" id="f-cashier" name="cashier" type="text" required />
</div>
</div>
<p class="hint" style="margin-top: 10px;">创建后生成随机初始密码,仅显示一次,首次登录必须修改</p>
<p class="hint" style="margin-top: 10px;">创建成功后弹出一次性口令窗口,可复制账号和密码。关闭后无法再次查看明文,列表与日志也不会保存密码</p>
<div class="modal-actions">
<button type="button" class="btn" data-close="companyDialog">取消</button>
<button type="submit" class="btn btn-primary">创建公司与账号</button>
@@ -908,6 +908,33 @@
</div>
</div>
<!-- 一次性初始密码领取(关闭后从页面清除明文) -->
<div class="modal-backdrop" id="credentialDialog">
<div class="modal" role="dialog" aria-labelledby="cred-title" aria-modal="true">
<div class="modal-head">
<span class="modal-title" id="cred-title">一次性初始密码</span>
<button type="button" class="modal-close" data-close="credentialDialog" aria-label="关闭">×</button>
</div>
<p class="modal-sub" id="cred-sub">请立即复制并交给出纳。关闭后无法再次查看明文。</p>
<div class="cred-box">
<div class="cred-row">
<span class="cred-label">登录账号</span>
<span class="cred-value" id="cred-user"></span>
<button type="button" class="btn btn-sm" id="cred-copy-user">复制</button>
</div>
<div class="cred-row">
<span class="cred-label">初始密码</span>
<span class="cred-value" id="cred-pass"></span>
<button type="button" class="btn btn-sm btn-primary" id="cred-copy-pass">复制密码</button>
</div>
</div>
<p class="cred-warn">对方首次登录必须修改密码。重置密码同样只展示这一次。</p>
<div class="modal-actions">
<button type="button" class="btn btn-primary" data-close="credentialDialog">我已抄录</button>
</div>
</div>
</div>
<!-- 公司详情弹窗 -->
<div class="modal-backdrop" id="modal-detail">
<div class="modal wide">
@@ -1228,6 +1255,7 @@
</div>
<div class="toast-region" id="toastRegion" aria-live="polite"></div>
<script src="theme.js?v=1"></script>
<script src="app.js?v=17"></script>
<script src="datepicker.js?v=1"></script>
<script src="app.js?v=18"></script>
</body>
</html>
+81 -10
View File
@@ -129,6 +129,50 @@ function showToast(title, detail = "", kind = "info") {
});
}
function copyText(value, button) {
const text = String(value || "");
const done = () => {
if (!button) return;
const previous = button.textContent;
button.textContent = "已复制";
window.setTimeout(() => { button.textContent = previous; }, 1400);
};
const fallback = () => {
const area = document.createElement("textarea");
area.value = text;
area.setAttribute("readonly", "");
area.style.position = "fixed";
area.style.left = "-9999px";
document.body.append(area);
area.select();
try { document.execCommand("copy"); } catch (err) { /* ignore */ }
area.remove();
done();
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done).catch(fallback);
} else {
fallback();
}
}
function wipeCredentials() {
const user = $("#cred-user");
const pass = $("#cred-pass");
if (user) user.textContent = "";
if (pass) pass.textContent = "";
}
function showOnceCredentials({ title, subtitle, username, password }) {
const titleEl = $("#cred-title");
const subEl = $("#cred-sub");
if (titleEl && title) titleEl.textContent = title;
if (subEl && subtitle) subEl.textContent = subtitle;
if ($("#cred-user")) $("#cred-user").textContent = username || "";
if ($("#cred-pass")) $("#cred-pass").textContent = password || "";
openModal("credentialDialog");
}
function toastIfLocked(result) {
const msg = String(result?.message || "");
if (!(result?.year_month || /已结账锁定/.test(msg))) return false;
@@ -2675,9 +2719,24 @@ function initAdmin() {
}));
$("#auditCompany")?.addEventListener("change", filterAuditRows);
document.querySelectorAll("[data-close]").forEach((el) => el.addEventListener("click", () => closeModal(el.dataset.close)));
document.querySelectorAll(".modal-backdrop").forEach((bd) => bd.addEventListener("click", (e) => { if (e.target === bd) bd.classList.remove("open"); }));
document.addEventListener("keydown", (e) => { if (e.key === "Escape") document.querySelectorAll(".modal-backdrop.open").forEach((m) => m.classList.remove("open")); });
document.querySelectorAll("[data-close]").forEach((el) => el.addEventListener("click", () => {
closeModal(el.dataset.close);
if (el.dataset.close === "credentialDialog") wipeCredentials();
}));
$("#cred-copy-user")?.addEventListener("click", () => copyText($("#cred-user")?.textContent, $("#cred-copy-user")));
$("#cred-copy-pass")?.addEventListener("click", () => copyText($("#cred-pass")?.textContent, $("#cred-copy-pass")));
document.querySelectorAll(".modal-backdrop").forEach((bd) => bd.addEventListener("click", (e) => {
if (e.target !== bd) return;
bd.classList.remove("open");
if (bd.id === "credentialDialog") wipeCredentials();
}));
document.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
document.querySelectorAll(".modal-backdrop.open").forEach((m) => {
m.classList.remove("open");
if (m.id === "credentialDialog") wipeCredentials();
});
});
async function submitAuditResult(row) {
const decision = state.auditDecision;
@@ -2889,11 +2948,17 @@ function initAdmin() {
closeModal("companyDialog");
form.reset();
await loadAdminCompanies();
showToast(
accountCreated ? "公司与账号已创建" : "公司已创建",
accountCreated ? `账号 ${result.username} 的初始密码已生成(仅此一次显示):${result.initial_password},首次登录必须修改` : "可稍后在账号管理中创建公司账号",
"success",
);
if (accountCreated) {
showOnceCredentials({
title: "一次性初始密码",
subtitle: "请立即复制并交给出纳。关闭后无法再次查看明文。",
username: result.username,
password: result.initial_password,
});
showToast("公司与账号已创建", "请在一次性口令窗口复制初始密码,关闭后无法再看", "success");
} else {
showToast("公司已创建", "可稍后在账号管理中创建公司账号", "success");
}
});
function openCompanyDetail(companyId) {
@@ -2933,8 +2998,14 @@ function initAdmin() {
if (!response || !response.ok) { showToast("重置失败", result?.message || "请稍后重试", "danger"); return; }
$("#btn-reset-pwd").disabled = true;
$("#btn-reset-pwd").textContent = "已重置";
$("#d-login-meta").textContent = `临时密码已生成(仅此一次):${result.initial_password},首次登录必须修改`;
showToast("密码已重置", "临时密码仅本次显示,首次登录必须修改", "success");
$("#d-login-meta").textContent = "临时密码已在一次性窗口展示,关闭后无法再查看。";
showOnceCredentials({
title: "临时密码已生成",
subtitle: "仅此一次显示。旧会话已失效,首次登录必须修改。",
username: user.username,
password: result.initial_password,
});
showToast("密码已重置", "请在一次性口令窗口复制后交给出纳", "success");
});
const remind = $("#cs-remind");
+3 -2
View File
@@ -11,7 +11,7 @@
document.documentElement.classList.add("v-fusion");
})();
</script>
<link rel="stylesheet" href="design-system.css?v=12" />
<link rel="stylesheet" href="design-system.css?v=13" />
</head>
<body class="is-app" data-portal="company">
<a class="skip-link" href="#main-content">跳到主要内容</a>
@@ -1044,6 +1044,7 @@
<div class="toast-region" id="toastRegion" aria-live="polite"></div>
<script src="theme.js?v=1"></script>
<script src="app.js?v=17"></script>
<script src="datepicker.js?v=1"></script>
<script src="app.js?v=18"></script>
</body>
</html>
+268
View File
@@ -0,0 +1,268 @@
/* 锚定在日期输入旁的页内日历。不引入第三方库;滚动/缩放后跟随触发器。 */
(function (global) {
var WEEK = ["日", "一", "二", "三", "四", "五", "六"];
var panel = null;
var grid = null;
var titleEl = null;
var activeInput = null;
var viewYear = 0;
var viewMonth = 0;
var bound = false;
function pad(n) {
return n < 10 ? "0" + n : String(n);
}
function toISO(year, month, day) {
return year + "-" + pad(month + 1) + "-" + pad(day);
}
function parseISO(value) {
var match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value || "").trim());
if (!match) return null;
var year = Number(match[1]);
var month = Number(match[2]) - 1;
var day = Number(match[3]);
var date = new Date(year, month, day);
if (date.getFullYear() !== year || date.getMonth() !== month || date.getDate() !== day) {
return null;
}
return date;
}
function todayISO() {
var now = new Date();
return toISO(now.getFullYear(), now.getMonth(), now.getDate());
}
function ensurePanel() {
if (panel) return;
panel = document.createElement("div");
panel.className = "ds-datepicker";
panel.id = "ds-datepicker";
panel.hidden = true;
panel.setAttribute("role", "dialog");
panel.setAttribute("aria-label", "选择日期");
panel.innerHTML =
'<div class="ds-dp-head">' +
'<button type="button" class="ds-dp-nav" data-dp-nav="-1" aria-label="上一月"></button>' +
'<div class="ds-dp-title"></div>' +
'<button type="button" class="ds-dp-nav" data-dp-nav="1" aria-label="下一月"></button>' +
"</div>" +
'<div class="ds-dp-week">' + WEEK.map(function (d) { return "<span>" + d + "</span>"; }).join("") + "</div>" +
'<div class="ds-dp-grid" role="grid"></div>' +
'<div class="ds-dp-foot">' +
'<button type="button" data-dp-today>今天</button>' +
'<button type="button" data-dp-clear>清除</button>' +
"</div>";
document.body.appendChild(panel);
titleEl = panel.querySelector(".ds-dp-title");
grid = panel.querySelector(".ds-dp-grid");
panel.addEventListener("mousedown", function (event) {
event.preventDefault();
});
panel.addEventListener("click", function (event) {
var nav = event.target.closest("[data-dp-nav]");
if (nav) {
shiftMonth(Number(nav.getAttribute("data-dp-nav")));
return;
}
if (event.target.closest("[data-dp-today]")) {
commit(todayISO());
return;
}
if (event.target.closest("[data-dp-clear]")) {
commit("");
return;
}
var dayBtn = event.target.closest("[data-iso]");
if (dayBtn && !dayBtn.disabled) commit(dayBtn.getAttribute("data-iso"));
});
}
function enhance(input) {
if (!input || input.dataset.dsDate === "1") return;
input.dataset.dsDate = "1";
if (input.type === "date") {
var value = input.value;
try {
input.type = "text";
} catch (err) {
/* keep native type if the engine refuses */
}
if (value) input.value = value;
}
input.setAttribute("inputmode", "numeric");
input.setAttribute("autocomplete", "off");
input.setAttribute("spellcheck", "false");
input.setAttribute("placeholder", input.getAttribute("placeholder") || "YYYY-MM-DD");
if (!input.getAttribute("pattern")) input.setAttribute("pattern", "\\d{4}-\\d{2}-\\d{2}");
if (!input.classList.contains("num-input")) input.classList.add("num-input");
input.setAttribute("aria-haspopup", "dialog");
}
function scan() {
document.querySelectorAll('input[type="date"]').forEach(enhance);
}
function render() {
if (!grid || !titleEl) return;
titleEl.textContent = viewYear + "年" + pad(viewMonth + 1) + "月";
var first = new Date(viewYear, viewMonth, 1);
var start = first.getDay();
var selected = activeInput ? parseISO(activeInput.value) : null;
var selectedISO = selected ? toISO(selected.getFullYear(), selected.getMonth(), selected.getDate()) : "";
var today = todayISO();
var min = activeInput && activeInput.min ? activeInput.min : "";
var max = activeInput && activeInput.max ? activeInput.max : "";
var html = "";
var cursor = new Date(viewYear, viewMonth, 1 - start);
for (var i = 0; i < 42; i += 1) {
var iso = toISO(cursor.getFullYear(), cursor.getMonth(), cursor.getDate());
var other = cursor.getMonth() !== viewMonth;
var disabled = (min && iso < min) || (max && iso > max);
var cls = "ds-dp-day";
if (other) cls += " is-other";
if (iso === today) cls += " is-today";
if (iso === selectedISO) cls += " is-selected";
html +=
'<button type="button" class="' + cls + '" data-iso="' + iso + '"' +
(disabled ? " disabled" : "") +
' aria-label="' + iso + '"' +
(iso === selectedISO ? ' aria-current="date"' : "") +
">" + cursor.getDate() + "</button>";
cursor.setDate(cursor.getDate() + 1);
}
grid.innerHTML = html;
position();
}
function viewportBox() {
var vv = global.visualViewport;
if (vv) {
return {
left: vv.offsetLeft,
top: vv.offsetTop,
width: vv.width,
height: vv.height,
};
}
return { left: 0, top: 0, width: global.innerWidth, height: global.innerHeight };
}
function position() {
if (!panel || panel.hidden || !activeInput) return;
var rect = activeInput.getBoundingClientRect();
var view = viewportBox();
var gap = 6;
var width = panel.offsetWidth || 280;
var height = panel.offsetHeight || 320;
var left = rect.left;
var top = rect.bottom + gap;
if (top + height > view.top + view.height - 8 && rect.top - gap - height >= view.top + 8) {
top = rect.top - gap - height;
}
if (left + width > view.left + view.width - 8) {
left = Math.max(view.left + 8, rect.right - width);
}
if (left < view.left + 8) left = view.left + 8;
if (top < view.top + 8) top = view.top + 8;
panel.style.left = Math.round(left) + "px";
panel.style.top = Math.round(top) + "px";
}
function openPicker(input) {
if (!input || input.disabled) return;
enhance(input);
ensurePanel();
activeInput = input;
var parsed = parseISO(input.value) || new Date();
viewYear = parsed.getFullYear();
viewMonth = parsed.getMonth();
panel.hidden = false;
render();
input.setAttribute("aria-expanded", "true");
}
function closePicker() {
if (!panel || panel.hidden) return;
panel.hidden = true;
if (activeInput) activeInput.setAttribute("aria-expanded", "false");
activeInput = null;
}
function shiftMonth(delta) {
viewMonth += delta;
while (viewMonth < 0) {
viewMonth += 12;
viewYear -= 1;
}
while (viewMonth > 11) {
viewMonth -= 12;
viewYear += 1;
}
render();
}
function commit(iso) {
if (!activeInput) return;
var input = activeInput;
input.value = iso;
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
closePicker();
input.focus({ preventScroll: true });
}
function onPointer(event) {
var input = event.target.closest && event.target.closest('input[type="date"], input[data-ds-date="1"]');
if (input) {
if (event.type === "mousedown" && event.button === 0) event.preventDefault();
openPicker(input);
return;
}
if (panel && !panel.hidden && !panel.contains(event.target)) closePicker();
}
function onKey(event) {
if (event.key === "Escape") {
closePicker();
return;
}
var input = event.target.closest && event.target.closest('input[data-ds-date="1"]');
if (!input) return;
if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") {
event.preventDefault();
openPicker(input);
}
}
function bind() {
if (bound) return;
bound = true;
document.addEventListener("mousedown", onPointer, true);
document.addEventListener("focusin", function (event) {
var input = event.target.closest && event.target.closest('input[type="date"]');
if (input) openPicker(input);
});
document.addEventListener("keydown", onKey);
document.addEventListener("scroll", position, true);
global.addEventListener("resize", position);
if (global.visualViewport) {
global.visualViewport.addEventListener("resize", position);
global.visualViewport.addEventListener("scroll", position);
}
if (typeof MutationObserver === "function") {
new MutationObserver(scan).observe(document.documentElement, { childList: true, subtree: true });
}
scan();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", bind);
} else {
bind();
}
global.JinniuDatePicker = { open: openPicker, close: closePicker, scan: scan };
})(window);
+248 -10
View File
@@ -61,6 +61,7 @@
--lp-brand: linear-gradient(158deg, #c13423, #a02415 52%, #7e170b);
--lp-brand-glow: radial-gradient(120% 80% at 18% 12%, rgba(255, 255, 255, 0.22), transparent 58%);
--lp-tick: var(--gold-bright);
color-scheme: light;
--fg: var(--text);
--muted: var(--text-2);
@@ -134,6 +135,9 @@ html[data-theme="night"],
--lp-brand: linear-gradient(158deg, #201b14, #161310 52%, #0f0d0a);
--lp-brand-glow: radial-gradient(120% 80% at 18% 12%, rgba(217, 180, 92, 0.18), transparent 58%);
--lp-tick: var(--gold);
color-scheme: dark;
scrollbar-color: var(--gold) var(--surface-2);
scrollbar-width: thin;
}
/* ─── reset ─────────────────────────────────────────────────────── */
@@ -225,7 +229,7 @@ html[data-theme="night"] .brand-logo-night { display: block; }
}
.side-role.company { background: var(--info-soft); color: var(--info); }
.side-nav { flex: 1; overflow-y: auto; overscroll-behavior: contain; padding: 12px 10px; }
.side-nav { flex: 1; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; 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;
@@ -316,6 +320,7 @@ html[data-theme="night"] .brand-logo-night { display: block; }
min-width: 0;
min-height: 0;
height: 100vh;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
display: flex;
@@ -478,7 +483,8 @@ html[data-theme="night"] .brand-logo-night { display: block; }
.dash-company-list {
flex: 1;
min-height: 0;
overflow: auto;
overflow-x: hidden;
overflow-y: auto;
}
.dash-company-loading,
.dash-company-empty {
@@ -606,7 +612,8 @@ html[data-theme="night"] .brand-logo-night { display: block; }
border-radius: 0;
flex: 1;
min-height: 0;
overflow: auto;
overflow-x: auto;
overflow-y: auto;
}
.dash-detail-empty {
flex: 1;
@@ -713,7 +720,16 @@ html[data-theme="night"] .brand-logo-night { display: block; }
}
/* ─── 数据表 ────────────────────────────────────────────────────── */
.table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--surface); }
.table-wrap {
overflow-x: auto;
overflow-y: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--surface);
}
.table-wrap.dash-master-scroll {
overflow-y: auto;
}
.ds-table { width: 100%; border-collapse: collapse; font-size: 13px; min-width: 640px; }
.ds-table th, .ds-table td { padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border); white-space: nowrap; }
.ds-table th {
@@ -784,19 +800,71 @@ html[data-theme="night"] .brand-logo-night { display: block; }
font: inherit;
font-size: 13.5px;
min-height: 34px;
color-scheme: inherit;
}
.input:focus, .select:focus, .textarea:focus, .field .input:focus, .field .select:focus, .field .textarea:focus {
outline: none;
border-color: var(--focus-ring);
box-shadow: 0 0 0 3px var(--focus-glow);
}
.input:disabled, .select:disabled, .textarea:disabled,
.field .input:disabled, .field .select:disabled, .field .textarea:disabled {
color: var(--text-3);
background: var(--fill);
opacity: 0.72;
cursor: not-allowed;
}
.select option,
.select optgroup {
background-color: var(--surface);
color: var(--text);
}
.select option:checked,
.select option:hover {
background-color: var(--accent-soft);
color: var(--text);
}
.select option:disabled {
color: var(--text-3);
}
.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); }
/* 关闭原生日历层,改由 ds-datepicker 锚定在触发器旁 */
input[type="date"]::-webkit-calendar-picker-indicator {
opacity: 0;
pointer-events: none;
display: none;
}
.input[type="date"],
.field .input[type="date"],
.input[data-ds-date="1"],
.field .input[data-ds-date="1"] {
padding-right: 34px;
background-color: var(--fill);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='none' stroke='%236e6e73' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2.5' y='3.5' width='11' height='10' rx='1.5'/%3E%3Cpath d='M2.5 6.5h11M6 2v3M10 2v3'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
background-size: 16px 16px;
}
html[data-theme="night"] .input[type="date"],
html[data-theme="night"] .field .input[type="date"],
html[data-theme="night"] .input[data-ds-date="1"],
html[data-theme="night"] .field .input[data-ds-date="1"] {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='none' stroke='%23d9b45c' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2.5' y='3.5' width='11' height='10' rx='1.5'/%3E%3Cpath d='M2.5 6.5h11M6 2v3M10 2v3'/%3E%3C/svg%3E");
}
/* ─── Tabs ──────────────────────────────────────────────────────── */
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); margin-bottom: 16px; overflow-x: auto; }
.tabs {
display: flex;
gap: 2px;
border-bottom: 1px solid var(--border);
margin-bottom: 16px;
overflow-x: auto;
overflow-y: hidden;
}
.tabs button {
background: none; border: 0;
padding: 9px 14px;
@@ -867,7 +935,7 @@ html[data-theme="night"] .brand-logo-night { display: block; }
border-radius: 16px;
border: 1px solid var(--gold-line);
width: 100%; max-width: min(520px, 94vw);
max-height: 86vh; overflow-y: auto;
max-height: 86vh; overflow-x: hidden; overflow-y: auto;
padding: 22px 24px;
box-shadow: var(--shadow-modal);
}
@@ -1190,6 +1258,7 @@ a.flow-step.doing:hover { background: color-mix(in oklch, var(--warn) 16%, trans
display: flex;
flex-direction: row;
overflow-x: auto;
overflow-y: hidden;
padding: 8px 4px;
flex: 1;
}
@@ -1207,11 +1276,11 @@ a.flow-step.doing:hover { background: color-mix(in oklch, var(--warn) 16%, trans
.theme-seg { margin-bottom: 0; width: 92px; grid-template-columns: 1fr 1fr; }
.account-box { padding: 4px; background: transparent; }
.side-foot .user-name, .side-foot .user-meta, .side-foot .avatar { display: none; }
.main { min-height: 0; height: 100%; overflow-y: auto; }
.main { min-height: 0; height: 100%; overflow-x: hidden; overflow-y: auto; }
}
/* ─── Toast 轻量提示 ───────────────────────────────────────────── */
.toast-region { position: fixed; right: 16px; top: 16px; z-index: var(--z-toast); display: grid; gap: 8px; }
.toast-region { position: fixed; right: 16px; top: 16px; z-index: var(--z-toast); display: grid; gap: 8px; pointer-events: none; }
.toast {
display: flex; align-items: center; gap: 9px;
min-width: 260px; max-width: 380px;
@@ -1223,6 +1292,7 @@ a.flow-step.doing:hover { background: color-mix(in oklch, var(--warn) 16%, trans
color: #fff;
box-shadow: var(--shadow-toast);
font-size: 13px;
pointer-events: auto;
}
.toast .t-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; margin-top: 5px; background: var(--muted); }
.toast .t-body { min-width: 0; }
@@ -1265,7 +1335,7 @@ a.flow-step.doing:hover { background: color-mix(in oklch, var(--warn) 16%, trans
}
.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 { flex: 1; overflow-x: hidden; 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; }
@@ -1429,7 +1499,7 @@ body[data-portal="company"] .side-nav a[data-view="transfers"].active svg {
background-position: center;
}
.batch-bar { display: flex; align-items: center; gap: 10px; padding: 8px 0; }
.login-page { min-height: 100vh; overflow: auto; }
.login-page { min-height: 100vh; overflow-x: hidden; overflow-y: auto; }
.toast .t-detail { color: rgba(255,255,255,.72); }
.toast .t-dot { margin-top: 0; }
@@ -1675,3 +1745,171 @@ body[data-portal="company"] .side-nav a[data-view="transfers"].active svg {
.audit-event-cards { display: grid; gap: 10px; }
}
/* ─── 夜间滚动条:金色滑块 + 深色轨道(HEL-351) ─────────────── */
html[data-theme="night"] ::-webkit-scrollbar {
width: 10px;
height: 10px;
}
html[data-theme="night"] ::-webkit-scrollbar-track {
background: var(--surface-2);
}
html[data-theme="night"] ::-webkit-scrollbar-thumb {
background-color: var(--gold);
border: 2px solid var(--surface-2);
border-radius: 999px;
background-clip: padding-box;
}
html[data-theme="night"] ::-webkit-scrollbar-thumb:hover {
background-color: var(--gold-bright);
}
html[data-theme="night"] ::-webkit-scrollbar-corner {
background: var(--surface);
}
/* ─── 日期选择弹层:锚定触发器,夜间黑金点缀 ───────────────── */
.ds-datepicker {
position: fixed;
z-index: var(--z-tooltip);
width: 280px;
padding: 12px;
background: var(--material-drawer);
backdrop-filter: blur(28px);
-webkit-backdrop-filter: blur(28px);
border: 1px solid var(--hairline);
border-radius: 16px;
box-shadow: var(--shadow-modal);
color: var(--text);
}
html[data-theme="night"] .ds-datepicker {
background: var(--surface);
border-color: var(--gold-line);
}
.ds-datepicker[hidden] { display: none !important; }
.ds-dp-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 10px;
}
.ds-dp-title {
font-size: 13.5px;
font-weight: 650;
font-variant-numeric: tabular-nums;
}
html[data-theme="night"] .ds-dp-title { color: var(--gold); }
.ds-dp-nav {
width: 28px;
height: 28px;
border: 0;
border-radius: 7px;
background: var(--fill);
color: var(--text-2);
}
.ds-dp-nav:hover { background: var(--hover); color: var(--text); }
.ds-dp-week,
.ds-dp-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
}
.ds-dp-week span {
height: 28px;
display: grid;
place-items: center;
font-size: 11px;
font-weight: 600;
color: var(--text-3);
}
.ds-dp-day {
height: 32px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--text);
font-size: 13px;
font-variant-numeric: tabular-nums;
font-family: var(--font-mono);
}
.ds-dp-day:hover { background: var(--hover); }
.ds-dp-day.is-other { color: var(--text-3); }
.ds-dp-day.is-today {
box-shadow: inset 0 0 0 1px var(--accent);
color: var(--accent);
}
.ds-dp-day.is-selected {
background: var(--accent-soft);
color: var(--accent);
font-weight: 700;
}
.ds-dp-day.is-selected.is-today { box-shadow: none; }
html[data-theme="night"] .ds-dp-day.is-today {
box-shadow: inset 0 0 0 1px var(--gold-line);
color: var(--gold);
}
html[data-theme="night"] .ds-dp-day.is-selected {
background: var(--gold-soft);
color: var(--gold);
}
.ds-dp-day:disabled {
color: var(--text-3);
opacity: 0.45;
cursor: not-allowed;
}
.ds-dp-foot {
display: flex;
justify-content: space-between;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--hairline);
}
.ds-dp-foot button {
height: 26px;
padding: 0 10px;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--text-2);
font-size: 12px;
font-weight: 600;
}
.ds-dp-foot button:hover { background: var(--hover); color: var(--text); }
/* ─── 一次性初始密码领取 ─────────────────────────────────────── */
.cred-box {
display: grid;
gap: 10px;
padding: 12px 14px;
border: 1px solid var(--gold-line);
border-radius: 14px;
background: var(--gold-soft);
}
.cred-row {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.cred-row .cred-label {
flex: none;
width: 72px;
font-size: 12px;
font-weight: 600;
color: var(--text-2);
}
.cred-row .cred-value {
flex: 1;
min-width: 0;
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
font-size: 14px;
font-weight: 650;
color: var(--text);
word-break: break-all;
}
.cred-warn {
margin-top: 12px;
font-size: 12.5px;
color: var(--text-2);
}
+1 -1
View File
@@ -10,7 +10,7 @@
document.documentElement.classList.add("v-fusion");
})();
</script>
<link rel="stylesheet" href="design-system.css?v=12" />
<link rel="stylesheet" href="design-system.css?v=13" />
</head>
<body class="login-page">
<div class="portal-wrap">
+1 -1
View File
@@ -10,7 +10,7 @@
document.documentElement.classList.add("v-fusion");
})();
</script>
<link rel="stylesheet" href="design-system.css?v=12" />
<link rel="stylesheet" href="design-system.css?v=13" />
</head>
<body class="login-page" data-role="admin">
<div class="login-wrap">
+1 -1
View File
@@ -10,7 +10,7 @@
document.documentElement.classList.add("v-fusion");
})();
</script>
<link rel="stylesheet" href="design-system.css?v=12" />
<link rel="stylesheet" href="design-system.css?v=13" />
</head>
<body class="login-page" data-role="company">
<div class="login-wrap">