Compare commits

..
Author SHA1 Message Date
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
13 changed files with 917 additions and 36 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()
+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);
+246 -9
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,7 +1276,7 @@ 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 轻量提示 ───────────────────────────────────────────── */
@@ -1265,7 +1334,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 +1498,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 +1744,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">