From 6eccbe97653697f4a09fce7c0072ca17b66a1afa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=80=BB=E5=B7=A5?= Date: Wed, 2 Sep 2026 11:07:16 +0800 Subject: [PATCH] =?UTF-8?q?HEL-373:=20=E4=B8=BB=E9=A2=98=E5=8C=96=20combob?= =?UTF-8?q?ox=20=E8=A6=86=E7=9B=96=E5=A4=9C=E9=97=B4=E4=B8=8B=E6=8B=89?= =?UTF-8?q?=E5=85=A8=E7=8A=B6=E6=80=81=EF=BC=8C=E6=97=A5=E6=9C=9F=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E6=94=AF=E6=8C=81=E5=B9=B4=E6=9C=88=E7=9B=B4=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原生 select 弹层无法可靠套黑金 token,改为可访问 listbox;日期标题增加年份/月份下拉,越月不提交非法日。 Co-authored-by: Cursor Co-authored-by: multica-agent --- tests/test_company_transfers_page.py | 2 +- tests/test_hel351_controls.py | 51 ++- tests/test_hel373_select_datepicker.py | 425 +++++++++++++++++++++++++ tests/test_reminders_page.py | 5 +- web/admin.html | 5 +- web/company.html | 5 +- web/datepicker.js | 105 +++++- web/design-system.css | 175 +++++++++- web/index.html | 2 +- web/login-admin.html | 2 +- web/login-company.html | 2 +- web/select.js | 399 +++++++++++++++++++++++ 12 files changed, 1150 insertions(+), 28 deletions(-) create mode 100644 tests/test_hel373_select_datepicker.py create mode 100644 web/select.js diff --git a/tests/test_company_transfers_page.py b/tests/test_company_transfers_page.py index dec2b1c..57bac25 100644 --- a/tests/test_company_transfers_page.py +++ b/tests/test_company_transfers_page.py @@ -31,7 +31,7 @@ class TransfersPageSourceContractTests(unittest.TestCase): self.assertIn('id="transferEvidenceDrawer"', html) self.assertIn("期间净变动", html) self.assertNotIn("本公司往来合计", html) - self.assertIn("design-system.css?v=13", html) + self.assertIn("design-system.css?v=14", html) self.assertIn("app.js?v=18", html) # 侧栏顺序:流水管理 → 转账往来 → 往来确认 flows = html.index('data-view="flows"') diff --git a/tests/test_hel351_controls.py b/tests/test_hel351_controls.py index a4368ac..f840228 100644 --- a/tests/test_hel351_controls.py +++ b/tests/test_hel351_controls.py @@ -75,7 +75,8 @@ class Hel351SourceContractTests(unittest.TestCase): 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.assertIn("select.js?v=1", html) + self.assertIn("datepicker.js?v=2", html) self.assertNotIn("初始密码由管理员统一发放", html) js = (WEB / "app.js").read_text(encoding="utf-8") self.assertIn("function showOnceCredentials(", js) @@ -135,15 +136,18 @@ class Hel351BrowserProbeTests(unittest.TestCase): + """, wait_until="domcontentloaded", ) - page.wait_for_function("() => window.JinniuDatePicker") + page.wait_for_function("() => window.JinniuDatePicker && window.JinniuSelect") if zoom != 1: page.evaluate(f"() => {{ document.body.style.zoom = '{zoom}'; }}") @@ -152,25 +156,44 @@ class Hel351BrowserProbeTests(unittest.TestCase): browser = p.chromium.launch(headless=True, args=["--no-sandbox"]) page = browser.new_page(viewport={"width": 1440, "height": 900}) self._open(page, night=True) + page.locator("#probe-select").locator("xpath=ancestor::div[contains(@class,'ds-combo')]").locator(".ds-combo-trigger").click() + page.wait_for_selector("#ds-listbox:not([hidden])") colors = page.evaluate( """() => { - const sel = document.getElementById('probe-select'); - const cs = getComputedStyle(sel); - const opt = getComputedStyle(sel.options[0]); + const trigger = document.querySelector('#probe-select').closest('.ds-combo').querySelector('.ds-combo-trigger'); + const box = document.getElementById('ds-listbox'); + const placeholder = box.querySelector('.is-placeholder'); + const plain = [...box.querySelectorAll('.ds-listbox-option')].find((el) => !el.classList.contains('is-placeholder') && !el.classList.contains('is-disabled') && !el.classList.contains('is-selected')); + const disabled = box.querySelector('.is-disabled'); + const tcs = getComputedStyle(trigger); + const bcs = getComputedStyle(box); + const pcs = placeholder && getComputedStyle(placeholder); + const ncs = plain && getComputedStyle(plain); + const dcs = disabled && getComputedStyle(disabled); return { - scheme: getComputedStyle(document.documentElement).colorScheme, - bg: cs.backgroundColor, - color: cs.color, - optionBg: opt.backgroundColor, - optionColor: opt.color, + triggerBg: tcs.backgroundColor, + triggerColor: tcs.color, + boxBg: bcs.backgroundColor, + boxColor: bcs.color, + placeholderColor: pcs && pcs.color, + placeholderBg: pcs && pcs.backgroundColor, + plainColor: ncs && ncs.color, + plainBg: ncs && ncs.backgroundColor, + disabledColor: dcs && dcs.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"]) + white = "rgb(255, 255, 255)" + self.assertNotEqual(white, colors["triggerBg"]) + self.assertNotEqual(white, colors["triggerColor"]) + self.assertNotEqual(white, colors["boxBg"]) + self.assertNotEqual(white, colors["boxColor"]) + self.assertNotEqual(white, colors["placeholderBg"]) + self.assertNotEqual(white, colors["placeholderColor"]) + self.assertNotEqual(white, colors["plainBg"]) + self.assertNotEqual(white, colors["plainColor"]) + self.assertNotEqual(white, colors["disabledColor"]) def test_tabs_have_no_vertical_scrollbar_at_zoom(self) -> None: with sync_playwright() as p: diff --git a/tests/test_hel373_select_datepicker.py b/tests/test_hel373_select_datepicker.py new file mode 100644 index 0000000..d7c2a99 --- /dev/null +++ b/tests/test_hel373_select_datepicker.py @@ -0,0 +1,425 @@ +"""HEL-373: 黑金下拉全状态 + 日期年月直选。""" + +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" +SHOT_DIR = ROOT.parent / "hel373-shots" + +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 + + +PROBE_HTML = """ + + + + + + + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+ + + +""" + + +class Hel373SourceContractTests(unittest.TestCase): + def test_custom_listbox_and_year_month_exist(self) -> None: + css = (WEB / "design-system.css").read_text(encoding="utf-8") + js_select = (WEB / "select.js").read_text(encoding="utf-8") + js_date = (WEB / "datepicker.js").read_text(encoding="utf-8") + admin = (WEB / "admin.html").read_text(encoding="utf-8") + company = (WEB / "company.html").read_text(encoding="utf-8") + self.assertIn("select.select", js_select) + self.assertIn('setAttribute("role", "combobox")', js_select) + self.assertIn('setAttribute("role", "listbox")', js_select) + self.assertIn("JinniuSelect", js_select) + self.assertIn("data-dp-year", js_date) + self.assertIn("data-dp-month", js_date) + self.assertIn("YEAR_PAD", js_date) + self.assertIn(".ds-listbox", css) + self.assertIn(".ds-combo-trigger", css) + self.assertIn(".ds-listbox-option.is-placeholder", css) + self.assertIn("html[data-theme=\"night\"] .ds-listbox", css) + self.assertIn("var(--gold-line)", css[css.index("html[data-theme=\"night\"] .ds-listbox") :]) + self.assertIn("html[data-theme=\"night\"] .ds-listbox-option.is-selected", css) + self.assertIn("select.js?v=1", admin) + self.assertIn("select.js?v=1", company) + self.assertIn("datepicker.js?v=2", admin) + self.assertIn("datepicker.js?v=2", company) + self.assertIn("design-system.css?v=14", admin) + self.assertIn("design-system.css?v=14", company) + + def test_listbox_uses_tokens_not_hardcoded_night_hex(self) -> None: + css = (WEB / "design-system.css").read_text(encoding="utf-8") + start = css.index("/* ─── 主题化 combobox") + end = css.index("@media (prefers-reduced-motion: reduce)", start) + block = css[start:end] + self.assertNotIn("#fff", block) + self.assertNotIn("#ffffff", block.lower()) + self.assertNotIn("#000", block) + self.assertIn("var(--surface)", block) + self.assertIn("var(--text)", block) + self.assertIn("var(--gold)", block) + self.assertIn("var(--fill)", block) + + +@unittest.skipUnless(sync_playwright, "playwright 未安装,跳过浏览器探针") +@unittest.skipUnless(_chromium_available(), "chromium 无法启动,跳过浏览器探针") +class Hel373BrowserProbeTests(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, height: int = 900) -> None: + page.set_viewport_size({"width": width, "height": height}) + theme_attr = 'data-theme="night"' if night else 'data-theme="day"' + page.set_content( + PROBE_HTML.format(theme_attr=theme_attr, base=self.base), + wait_until="domcontentloaded", + ) + page.wait_for_function("() => window.JinniuDatePicker && window.JinniuSelect") + page.wait_for_selector("#probe-empty + .ds-combo-trigger, .ds-combo #probe-empty") + page.wait_for_function( + "() => document.querySelectorAll('.ds-combo-trigger').length >= 2" + ) + + def _trigger(self, page, select_id: str): + return page.locator(f"#{select_id}").locator( + "xpath=ancestor::div[contains(@class,'ds-combo')]" + ).locator(".ds-combo-trigger") + + def test_night_placeholder_unselected_selected_disabled(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) + empty_btn = self._trigger(page, "probe-empty") + self.assertTrue(empty_btn.evaluate("el => el.classList.contains('is-placeholder')")) + empty_btn.click() + page.wait_for_selector("#ds-listbox:not([hidden])") + snapshot = page.evaluate( + """() => { + const box = document.getElementById('ds-listbox'); + const opts = [...box.querySelectorAll('.ds-listbox-option')].map((el) => { + const cs = getComputedStyle(el); + return { + text: el.textContent, + color: cs.color, + bg: cs.backgroundColor, + selected: el.classList.contains('is-selected'), + placeholder: el.classList.contains('is-placeholder'), + disabled: el.classList.contains('is-disabled'), + }; + }); + const bcs = getComputedStyle(box); + const tcs = getComputedStyle(document.querySelector('#probe-empty').closest('.ds-combo').querySelector('.ds-combo-trigger')); + return { + boxBg: bcs.backgroundColor, + boxColor: bcs.color, + boxBorder: bcs.borderTopColor, + triggerBg: tcs.backgroundColor, + triggerColor: tcs.color, + opts, + }; + }""" + ) + page.keyboard.press("Escape") + filled = self._trigger(page, "probe-filled") + filled.click() + page.wait_for_selector("#ds-listbox:not([hidden])") + selected = page.evaluate( + """() => { + const el = document.querySelector('#ds-listbox .is-selected'); + const cs = getComputedStyle(el); + return { color: cs.color, bg: cs.backgroundColor, text: el.textContent }; + }""" + ) + native = page.evaluate("() => document.getElementById('probe-filled').value") + browser.close() + white = "rgb(255, 255, 255)" + self.assertNotEqual(white, snapshot["boxBg"]) + self.assertNotEqual(white, snapshot["boxColor"]) + self.assertNotEqual(white, snapshot["triggerBg"]) + self.assertNotEqual(white, snapshot["triggerColor"]) + placeholder = next(o for o in snapshot["opts"] if o["placeholder"]) + plain = next(o for o in snapshot["opts"] if not o["placeholder"] and not o["disabled"] and not o["selected"]) + disabled = next(o for o in snapshot["opts"] if o["disabled"]) + self.assertNotEqual(white, placeholder["color"]) + self.assertNotEqual(white, placeholder["bg"]) + self.assertNotEqual(white, plain["color"]) + self.assertNotEqual(white, plain["bg"]) + self.assertNotEqual(white, disabled["color"]) + self.assertIn("217", selected["color"]) + self.assertEqual("河南金牛煤业有限公司", selected["text"]) + self.assertEqual("b", native) + + def test_keyboard_and_form_value(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) + btn = self._trigger(page, "probe-empty") + btn.focus() + page.keyboard.press("ArrowDown") + page.wait_for_selector("#ds-listbox:not([hidden])") + page.keyboard.press("ArrowDown") + page.keyboard.press("Enter") + value = page.evaluate("() => document.getElementById('probe-empty').value") + label = btn.inner_text() + btn.focus() + page.keyboard.press("Enter") + page.wait_for_selector("#ds-listbox:not([hidden])") + page.keyboard.press("Escape") + hidden = page.evaluate("() => document.getElementById('ds-listbox').hidden") + browser.close() + self.assertEqual("a", value) + self.assertIn("农业", label) + self.assertTrue(hidden) + + def test_year_month_jump_keeps_selected_and_skips_invalid_day(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])") + before = page.evaluate("() => document.getElementById('probe-date').value") + year_btn = page.locator("#ds-datepicker [data-dp-year]").locator( + "xpath=ancestor::div[contains(@class,'ds-combo')]" + ).locator(".ds-combo-trigger") + year_btn.click() + page.wait_for_selector("#ds-listbox:not([hidden])") + page.locator("#ds-listbox .ds-listbox-option", has_text="2024年").click() + page.wait_for_function( + "() => document.querySelector('[data-dp-year]').value === '2024'" + ) + month_btn = page.locator("#ds-datepicker [data-dp-month]").locator( + "xpath=ancestor::div[contains(@class,'ds-combo')]" + ).locator(".ds-combo-trigger") + month_btn.click() + page.wait_for_selector("#ds-listbox:not([hidden])") + page.locator("#ds-listbox .ds-listbox-option", has_text="02月").click() + after = page.evaluate( + """() => { + const input = document.getElementById('probe-date'); + const panel = document.getElementById('ds-datepicker'); + const selected = panel.querySelector('.ds-dp-day.is-selected'); + const days = [...panel.querySelectorAll('.ds-dp-day:not(.is-other)')].map((el) => el.textContent); + const year = document.querySelector('[data-dp-year]').value; + const month = document.querySelector('[data-dp-month]').value; + return { + value: input.value, + selected: selected && selected.getAttribute('data-iso'), + has31: days.includes('31'), + year, + month, + titleYear: document.querySelector('#ds-datepicker .ds-combo-trigger').textContent, + }; + }""" + ) + browser.close() + self.assertEqual("2026-01-31", before) + self.assertEqual("2026-01-31", after["value"]) + self.assertIsNone(after["selected"]) + self.assertFalse(after["has31"]) + self.assertEqual("2024", after["year"]) + self.assertEqual("1", after["month"]) + + def test_leap_year_feb29_and_minmax_year_range(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=False) + page.click("#probe-date") + page.wait_for_selector("#ds-datepicker:not([hidden])") + page.locator("#ds-datepicker [data-dp-year]").locator( + "xpath=ancestor::div[contains(@class,'ds-combo')]" + ).locator(".ds-combo-trigger").click() + years = page.evaluate( + """() => [...document.querySelectorAll('#ds-listbox .ds-listbox-option')].map((el) => el.textContent)""" + ) + page.locator("#ds-listbox .ds-listbox-option", has_text="2024年").click() + page.locator("#ds-datepicker [data-dp-month]").locator( + "xpath=ancestor::div[contains(@class,'ds-combo')]" + ).locator(".ds-combo-trigger").click() + page.locator("#ds-listbox .ds-listbox-option", has_text="02月").click() + days = page.evaluate( + """() => [...document.querySelectorAll('#ds-datepicker .ds-dp-day:not(.is-other)')].map((el) => el.textContent)""" + ) + page.click('#ds-datepicker [data-iso="2024-02-29"]') + value = page.evaluate("() => document.getElementById('probe-date').value") + page.click("#probe-free-date") + page.wait_for_selector("#ds-datepicker:not([hidden])") + page.locator("#ds-datepicker [data-dp-year]").locator( + "xpath=ancestor::div[contains(@class,'ds-combo')]" + ).locator(".ds-combo-trigger").click() + free_years = page.evaluate( + """() => [...document.querySelectorAll('#ds-listbox .ds-listbox-option')].map((el) => el.textContent)""" + ) + browser.close() + from datetime import date + year = date.today().year + self.assertIn("2020年", years) + self.assertIn("2028年", years) + self.assertNotIn("2019年", years) + self.assertIn("29", days) + self.assertEqual("2024-02-29", value) + self.assertIn(f"{year - 80}年", free_years) + self.assertIn(f"{year + 20}年", free_years) + + def test_prev_next_month_still_works(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])") + page.click('#ds-datepicker [data-dp-nav="1"]') + state = page.evaluate( + """() => ({ + month: document.querySelector('[data-dp-month]').value, + year: document.querySelector('[data-dp-year]').value, + value: document.getElementById('probe-date').value, + })""" + ) + browser.close() + self.assertEqual("1", state["month"]) + self.assertEqual("2026", state["year"]) + self.assertEqual("2026-01-31", state["value"]) + + def test_day_theme_trigger_stays_light(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=False) + colors = page.evaluate( + """() => { + const btn = document.querySelector('#probe-filled').closest('.ds-combo').querySelector('.ds-combo-trigger'); + const cs = getComputedStyle(btn); + return { bg: cs.backgroundColor, color: cs.color }; + }""" + ) + browser.close() + self.assertNotEqual("rgb(16, 15, 13)", colors["bg"]) + + def test_viewports_and_shots(self) -> None: + SHOT_DIR.mkdir(parents=True, exist_ok=True) + viewports = [360, 390, 820, 1440] + with sync_playwright() as p: + browser = p.chromium.launch(headless=True, args=["--no-sandbox"]) + for width in viewports: + for night in (False, True): + page = browser.new_page(viewport={"width": width, "height": 800}) + self._open(page, night=night, width=width, height=800) + theme = "night" if night else "day" + self._trigger(page, "probe-empty").click() + page.wait_for_selector("#ds-listbox:not([hidden])") + page.screenshot( + path=str(SHOT_DIR / f"select-empty-{theme}-{width}.png"), + full_page=True, + ) + page.keyboard.press("Escape") + self._trigger(page, "probe-filled").click() + page.wait_for_selector("#ds-listbox:not([hidden])") + page.screenshot( + path=str(SHOT_DIR / f"select-filled-{theme}-{width}.png"), + full_page=True, + ) + page.keyboard.press("Escape") + page.click("#probe-date") + page.wait_for_selector("#ds-datepicker:not([hidden])") + page.screenshot( + path=str(SHOT_DIR / f"datepicker-{theme}-{width}.png"), + full_page=True, + ) + page.locator("#ds-datepicker [data-dp-year]").locator( + "xpath=ancestor::div[contains(@class,'ds-combo')]" + ).locator(".ds-combo-trigger").click() + page.wait_for_selector("#ds-listbox:not([hidden])") + page.screenshot( + path=str(SHOT_DIR / f"datepicker-year-{theme}-{width}.png"), + full_page=True, + ) + page.close() + browser.close() + self.assertTrue((SHOT_DIR / "select-empty-night-1440.png").exists()) + self.assertTrue((SHOT_DIR / "datepicker-year-night-360.png").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_reminders_page.py b/tests/test_reminders_page.py index a3d1cee..dd16820 100644 --- a/tests/test_reminders_page.py +++ b/tests/test_reminders_page.py @@ -37,7 +37,7 @@ 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=13", html) + self.assertIn("design-system.css?v=14", html) self.assertIn("app.js?v=18", html) pending = html.index('id="pending-reminders-card"') history = html.index('id="reminder-history-card"') @@ -73,7 +73,8 @@ class RemindersPageSourceContractTests(unittest.TestCase): self.assertIn(".pick", css) self.assertIn("minmax(0, 1.5fr)", css) block_start = css.index("提醒管理:三步发送流") - block = css[block_start:] + block_end = css.index("月结 / 重开 / 审计") + block = css[block_start:block_end] self.assertIn("var(--border)", block) self.assertIn("var(--accent-soft)", block) self.assertIn("var(--accent)", block) diff --git a/web/admin.html b/web/admin.html index d8068af..761cc80 100644 --- a/web/admin.html +++ b/web/admin.html @@ -11,7 +11,7 @@ document.documentElement.classList.add("v-fusion"); })(); - + @@ -1255,7 +1255,8 @@
- + + diff --git a/web/company.html b/web/company.html index c59fcb8..abe0d51 100644 --- a/web/company.html +++ b/web/company.html @@ -11,7 +11,7 @@ document.documentElement.classList.add("v-fusion"); })(); - + @@ -1044,7 +1044,8 @@
- + + diff --git a/web/datepicker.js b/web/datepicker.js index d79c050..b7fbb17 100644 --- a/web/datepicker.js +++ b/web/datepicker.js @@ -4,10 +4,14 @@ var panel = null; var grid = null; var titleEl = null; + var yearSelect = null; + var monthSelect = null; var activeInput = null; var viewYear = 0; var viewMonth = 0; var bound = false; + var YEAR_PAD = 80; + var YEAR_AHEAD = 20; function pad(n) { return n < 10 ? "0" + n : String(n); @@ -46,7 +50,10 @@ panel.innerHTML = '
' + '' + - '
' + + '
' + + '' + + '' + + "
" + '' + "
" + '
' + WEEK.map(function (d) { return "" + d + ""; }).join("") + "
" + @@ -57,11 +64,27 @@ ""; document.body.appendChild(panel); titleEl = panel.querySelector(".ds-dp-title"); + yearSelect = panel.querySelector("[data-dp-year]"); + monthSelect = panel.querySelector("[data-dp-month]"); grid = panel.querySelector(".ds-dp-grid"); + yearSelect.addEventListener("change", function () { + var next = Number(yearSelect.value); + if (!next) return; + viewYear = next; + render(); + }); + monthSelect.addEventListener("change", function () { + var next = Number(monthSelect.value); + if (Number.isNaN(next)) return; + viewMonth = next; + render(); + }); panel.addEventListener("mousedown", function (event) { + if (event.target.closest(".ds-combo, select, input, textarea")) return; event.preventDefault(); }); panel.addEventListener("click", function (event) { + if (event.target.closest(".ds-combo, .ds-listbox, select")) return; var nav = event.target.closest("[data-dp-nav]"); if (nav) { shiftMonth(Number(nav.getAttribute("data-dp-nav"))); @@ -105,9 +128,75 @@ document.querySelectorAll('input[type="date"]').forEach(enhance); } + function boundYears() { + var now = new Date().getFullYear(); + var minY = now - YEAR_PAD; + var maxY = now + YEAR_AHEAD; + var hasMin = false; + var hasMax = false; + if (activeInput && activeInput.min) { + var minDate = parseISO(activeInput.min); + if (minDate) { + minY = minDate.getFullYear(); + hasMin = true; + } + } + if (activeInput && activeInput.max) { + var maxDate = parseISO(activeInput.max); + if (maxDate) { + maxY = maxDate.getFullYear(); + hasMax = true; + } + } + if (!hasMin && viewYear < minY) minY = viewYear; + if (!hasMax && viewYear > maxY) maxY = viewYear; + if (hasMin && viewYear < minY) minY = viewYear; + if (hasMax && viewYear > maxY) maxY = viewYear; + return { minY: minY, maxY: maxY }; + } + + function lastDayOfMonth(year, month) { + return new Date(year, month + 1, 0).getDate(); + } + + function monthHasValidDay(year, month) { + var min = activeInput && activeInput.min ? activeInput.min : ""; + var max = activeInput && activeInput.max ? activeInput.max : ""; + var start = toISO(year, month, 1); + var end = toISO(year, month, lastDayOfMonth(year, month)); + if (min && end < min) return false; + if (max && start > max) return false; + return true; + } + + function syncYearMonthSelects() { + if (!yearSelect || !monthSelect) return; + var years = boundYears(); + var yearHtml = ""; + for (var year = years.minY; year <= years.maxY; year += 1) { + yearHtml += '"; + } + yearSelect.innerHTML = yearHtml; + yearSelect.value = String(viewYear); + var monthHtml = ""; + for (var month = 0; month < 12; month += 1) { + var disabled = !monthHasValidDay(viewYear, month); + monthHtml += + '"; + } + monthSelect.innerHTML = monthHtml; + monthSelect.value = String(viewMonth); + if (global.JinniuSelect && typeof global.JinniuSelect.scan === "function") { + global.JinniuSelect.scan(panel); + } + } + function render() { if (!grid || !titleEl) return; - titleEl.textContent = viewYear + "年" + pad(viewMonth + 1) + "月"; + syncYearMonthSelects(); var first = new Date(viewYear, viewMonth, 1); var start = first.getDay(); var selected = activeInput ? parseISO(activeInput.value) : null; @@ -186,6 +275,9 @@ function closePicker() { if (!panel || panel.hidden) return; + if (global.JinniuSelect && typeof global.JinniuSelect.close === "function") { + global.JinniuSelect.close(); + } panel.hidden = true; if (activeInput) activeInput.setAttribute("aria-expanded", "false"); activeInput = null; @@ -221,7 +313,14 @@ openPicker(input); return; } - if (panel && !panel.hidden && !panel.contains(event.target)) closePicker(); + if ( + panel && + !panel.hidden && + !panel.contains(event.target) && + !(event.target.closest && event.target.closest(".ds-listbox")) + ) { + closePicker(); + } } function onKey(event) { diff --git a/web/design-system.css b/web/design-system.css index 3ea3295..77f752d 100644 --- a/web/design-system.css +++ b/web/design-system.css @@ -1793,11 +1793,23 @@ html[data-theme="night"] .ds-datepicker { margin-bottom: 10px; } .ds-dp-title { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + flex: 1; + min-width: 0; font-size: 13.5px; font-weight: 650; font-variant-numeric: tabular-nums; } -html[data-theme="night"] .ds-dp-title { color: var(--gold); } +.ds-dp-ym { + min-height: 26px; + padding: 2px 8px; + font-size: 12.5px; + font-weight: 650; + font-variant-numeric: tabular-nums; +} .ds-dp-nav { width: 28px; height: 28px; @@ -1875,6 +1887,167 @@ html[data-theme="night"] .ds-dp-day.is-selected { } .ds-dp-foot button:hover { background: var(--hover); color: var(--text); } +/* ─── 主题化 combobox(替代原生 select 弹层) ───────────────── */ +.ds-combo { + position: relative; + display: inline-grid; + vertical-align: top; + max-width: 100%; + min-width: 0; +} +.field > .ds-combo { + display: grid; + width: 100%; +} +.ds-combo-native { + grid-area: 1 / 1; + opacity: 0; + pointer-events: none; + min-width: 0; +} +.ds-combo-trigger { + grid-area: 1 / 1; + position: relative; + appearance: none; + -webkit-appearance: none; + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + width: 100%; + min-width: 0; + min-height: 34px; + margin: 0; + padding: 7px 28px 7px 11px; + border: 1px solid var(--hairline); + border-radius: 9px; + background: var(--fill); + color: var(--fg); + font: inherit; + font-size: 13.5px; + line-height: 1.3; + text-align: left; + cursor: pointer; + color-scheme: inherit; +} +.ds-combo-trigger::after { + content: ""; + position: absolute; + right: 11px; + top: 50%; + width: 0; + height: 0; + margin-top: -2px; + border: 4px solid transparent; + border-top-color: var(--text-3); + pointer-events: none; +} +.ds-combo-trigger.is-placeholder { + color: var(--text-3); +} +.ds-combo.is-open .ds-combo-trigger { + border-color: var(--focus-ring); + box-shadow: 0 0 0 3px var(--focus-glow); +} +.ds-combo-trigger:focus { + outline: none; + border-color: var(--focus-ring); + box-shadow: 0 0 0 3px var(--focus-glow); +} +.ds-combo-trigger:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 2px; +} +.ds-combo.is-disabled .ds-combo-trigger, +.ds-combo-trigger:disabled { + color: var(--text-3); + background: var(--fill); + opacity: 0.72; + cursor: not-allowed; +} +.ds-combo--compact .ds-combo-trigger { + min-height: 26px; + padding: 2px 22px 2px 8px; + font-size: 12.5px; + font-weight: 650; + font-variant-numeric: tabular-nums; +} +html[data-theme="night"] .ds-combo.is-open .ds-combo-trigger, +html[data-theme="night"] .ds-combo-trigger:focus { + border-color: var(--gold-line); +} +html[data-theme="night"] .ds-combo.is-open .ds-combo-trigger::after, +html[data-theme="night"] .ds-combo-trigger:focus::after { + border-top-color: var(--gold); +} + +.ds-listbox { + position: fixed; + z-index: var(--z-tooltip); + max-height: min(280px, 50vh); + overflow-x: hidden; + overflow-y: auto; + padding: 4px; + background: var(--surface); + color: var(--text); + border: 1px solid var(--hairline); + border-radius: 9px; + box-shadow: var(--shadow-modal); +} +html[data-theme="night"] .ds-listbox { + background: var(--surface); + border-color: var(--gold-line); +} +.ds-listbox[hidden] { display: none !important; } +.ds-listbox-option { + display: block; + width: 100%; + padding: 7px 10px; + border: 0; + border-radius: 7px; + background: transparent; + color: var(--text); + font: inherit; + font-size: 13.5px; + text-align: left; + cursor: pointer; +} +.ds-listbox-option.is-placeholder { + color: var(--text-3); +} +.ds-listbox-option:hover, +.ds-listbox-option.is-active { + background: var(--hover); +} +.ds-listbox-option.is-selected, +.ds-listbox-option[aria-selected="true"] { + background: var(--accent-soft); + color: var(--accent); + font-weight: 650; +} +html[data-theme="night"] .ds-listbox-option.is-selected, +html[data-theme="night"] .ds-listbox-option[aria-selected="true"] { + background: var(--gold-soft); + color: var(--gold); +} +html[data-theme="night"] .ds-listbox-option.is-active { + box-shadow: inset 0 0 0 1px var(--gold-line); +} +.ds-listbox-option.is-disabled, +.ds-listbox-option[aria-disabled="true"] { + color: var(--text-3); + opacity: 0.45; + cursor: not-allowed; + pointer-events: none; +} + +@media (prefers-reduced-motion: reduce) { + .ds-combo-trigger, + .ds-listbox-option { + transition: none; + } +} + /* ─── 一次性初始密码领取 ─────────────────────────────────────── */ .cred-box { display: grid; diff --git a/web/index.html b/web/index.html index c6a2104..830bd0f 100644 --- a/web/index.html +++ b/web/index.html @@ -10,7 +10,7 @@ document.documentElement.classList.add("v-fusion"); })(); - +
diff --git a/web/login-admin.html b/web/login-admin.html index d188902..e4a8207 100644 --- a/web/login-admin.html +++ b/web/login-admin.html @@ -10,7 +10,7 @@ document.documentElement.classList.add("v-fusion"); })(); - +