diff --git a/tests/test_reminders_page.py b/tests/test_reminders_page.py new file mode 100644 index 0000000..5e52335 --- /dev/null +++ b/tests/test_reminders_page.py @@ -0,0 +1,171 @@ +"""HEL-230: 提醒管理页通栏三块与三步发送流的结构/响应式契约。""" + +from __future__ import annotations + +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 + + +class RemindersPageSourceContractTests(unittest.TestCase): + def test_admin_html_order_and_send_flow(self) -> None: + html = (WEB / "admin.html").read_text(encoding="utf-8") + self.assertIn('data-page="reminders"', html) + self.assertIn('id="pending-reminders-card"', html) + self.assertIn('id="reminder-history-card"', html) + self.assertIn('id="send-reminder-card"', html) + self.assertIn('class="send-flow"', html) + self.assertIn('class="sf-step"', html) + self.assertIn('id="pick-count"', html) + self.assertIn('id="reminderForm"', html) + self.assertIn('id="reminderCompanyList"', html) + self.assertIn('id="reminder-type"', html) + self.assertIn('id="reminder-content"', html) + self.assertIn('id="reminder-deadline"', html) + self.assertIn('id="send-btn"', html) + self.assertIn('id="send-hint"', html) + 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=9", html) + self.assertIn("app.js?v=14", html) + pending = html.index('id="pending-reminders-card"') + history = html.index('id="reminder-history-card"') + send = html.index('id="send-reminder-card"') + self.assertLess(pending, history) + self.assertLess(history, send) + reminders = html[html.index('data-page="reminders"') : html.index("新增公司弹窗")] + self.assertNotIn("grid-1-2", reminders) + self.assertEqual(reminders.count('class="sf-step"'), 3) + self.assertIn("选择接收公司", reminders) + self.assertIn("填写提醒内容", reminders) + self.assertIn("设定截止并发送", reminders) + + def test_app_js_keeps_real_reminder_apis(self) -> None: + js = (WEB / "app.js").read_text(encoding="utf-8") + self.assertIn('label.className = "pick"', js) + self.assertIn("function updatePickCount", js) + self.assertIn("function syncPendingPicks", js) + self.assertIn("/api/admin/reminders/pending", js) + self.assertIn("/api/admin/reminders/manual", js) + self.assertIn("/api/admin/reminders/send", js) + self.assertIn("/api/admin/reminders/scan", js) + self.assertIn("function loadAdminRemindersPending", js) + self.assertIn("function loadAdminRemindersHistory", js) + self.assertIn("function openReminderDetailDrawer", js) + self.assertIn("initAdminReminders()", js) + self.assertIn("dataset.companyId", js) + + def test_design_system_send_flow_uses_tokens(self) -> None: + css = (WEB / "design-system.css").read_text(encoding="utf-8") + self.assertIn(".send-flow", css) + self.assertIn(".sf-step", css) + self.assertIn(".pick", css) + self.assertIn("minmax(0, 1.5fr)", css) + block_start = css.index("提醒管理:三步发送流") + block = css[block_start:] + self.assertIn("var(--border)", block) + self.assertIn("var(--accent-soft)", block) + self.assertIn("var(--accent)", block) + self.assertIn("@media (max-width: 1100px)", block) + self.assertNotRegex(block, r"#[0-9a-fA-F]{3,8}") + self.assertNotRegex(block, r"rgb\(") + self.assertNotIn("box-shadow: 0 0", block) + + +def _chromium_available() -> bool: + try: + import ctypes.util + + return bool(ctypes.util.find_library("atk-1.0")) + except Exception: + return False + + +@unittest.skipUnless(sync_playwright, "playwright 未安装,跳过布局冒烟") +@unittest.skipUnless(_chromium_available(), "系统缺少 chromium 依赖库(如 libatk),跳过布局冒烟") +class RemindersPageLayoutSmokeTests(unittest.TestCase): + """桌面三列 / 窄屏竖排,360 / 820 / 1440 无页面级横向溢出。""" + + @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_reminders(self, page, width: int) -> None: + html = (WEB / "admin.html").read_text(encoding="utf-8") + page.set_viewport_size({"width": width, "height": 900}) + page.set_content( + html.replace('src="app.js?v=14"', 'src=""'), + base_url=self.base, + ) + page.evaluate( + """() => { + document.querySelectorAll('.app-view').forEach((el) => { + el.classList.toggle('is-active', el.dataset.page === 'reminders'); + }); + const list = document.getElementById('reminderCompanyList'); + if (list && !list.children.length) { + ['A公司', 'B公司', '郑州金牛建业煤炭有限责任公司'].forEach((name, i) => { + const label = document.createElement('label'); + label.className = 'pick'; + label.innerHTML = '' + name; + if (i < 2) label.querySelector('input').checked = true; + list.append(label); + }); + } + }""" + ) + + 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=14", html) + with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_page() + self._open_reminders(page, 1440) + cols_wide = page.evaluate( + "() => getComputedStyle(document.querySelector('.send-flow')).gridTemplateColumns" + ) + self.assertEqual(len(cols_wide.split()), 3, f"桌面端应为三列,实际: {cols_wide}") + overflow_wide = page.evaluate( + "() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1" + ) + self.assertFalse(overflow_wide, "1440px 出现页面级横向溢出") + + self._open_reminders(page, 900) + cols_narrow = page.evaluate( + "() => getComputedStyle(document.querySelector('.send-flow')).gridTemplateColumns" + ) + self.assertEqual(len(cols_narrow.split()), 1, f"窄屏应为单列,实际: {cols_narrow}") + + for width in (360, 820, 1440): + self._open_reminders(page, width) + overflow = page.evaluate( + "() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1" + ) + self.assertFalse(overflow, f"{width}px 出现页面级横向溢出") + browser.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/web/admin.html b/web/admin.html index 92e0191..21eecf1 100644 --- a/web/admin.html +++ b/web/admin.html @@ -5,7 +5,7 @@ 管理端 · 金牛集团 - + @@ -638,7 +638,8 @@ -
+
+
待提醒清单系统按流水提交、断档、待确认自动发现,点发送即送达对应公司
@@ -651,72 +652,93 @@
-
-
-
- 发送处理提醒选择公司后系统自动列出待提醒事项,核对后一键发送 -
-
-
- -
- -
-
- - -
-
- - - -
-
- - -
- - -
+
+
+ 提醒历史含系统自动触发与人工发送的全部提醒记录
+
+ + + +
+
+ + + + + + + + + + + + + + +
公司来源类型内容摘要发送时间截止日期处理状态操作
+
+
+ 共 0 条提醒记录 + 未读 0 · 处理中 0 · 已完成 0 +
+
-
-
- 提醒历史含系统自动触发与人工发送的全部提醒记录 -
-
- - - -
-
- - - - - - - - - - - - - - -
公司来源类型内容摘要发送时间截止日期处理状态操作
-
-
- 共 0 条提醒记录 - 未读 0 · 处理中 0 · 已完成 0 -
+
+
+ 发送处理提醒将以人工提醒形式送达所选公司出纳与财务负责人
+
+
+
+
+ 01 + 选择接收公司 + 已选 0 家 +
+
+ +

每家公司将分别生成一条提醒,可在上方「待提醒清单」勾选后自动带入。

+
+
+
+ 02 + 填写提醒内容 +
+
+ + +
+
+ + + +
+
+
+
+ 03 + 设定截止并发送 +
+
+ + +
+
+ +

发送后可在上方「提醒历史」中跟踪触达与处理状态。

+ +
+
+
+
+
@@ -1011,6 +1033,6 @@
- + diff --git a/web/app.js b/web/app.js index 86eec5e..4d829f5 100644 --- a/web/app.js +++ b/web/app.js @@ -1143,16 +1143,15 @@ function fillCompanySelects(names, companies) { if (reminderList && companies?.length) { reminderList.replaceChildren(...companies.map((company) => { const label = document.createElement("label"); - label.className = "row"; - label.style.cssText = "gap:6px;font-size:13px;"; + label.className = "pick"; const input = document.createElement("input"); input.type = "checkbox"; input.name = "company"; input.value = String(company.id); - input.style.width = "auto"; label.append(input, document.createTextNode(company.name)); return label; })); + syncPendingPicks(); } setSelectOptions($('#openingDialog [name="from"]'), names); setSelectOptions($('#openingDialog [name="to"]'), names); @@ -1809,6 +1808,23 @@ function reminderStatusLabel(statusUi) { return { unread: "未读", doing: "处理中", done: "已完成" }[statusUi] || statusUi; } +function updatePickCount() { + const countEl = $("#pick-count"); + if (!countEl) return; + const n = $$('#reminderCompanyList input[name="company"]:checked').length; + countEl.textContent = "已选 " + n + " 家"; +} + +function syncPendingPicks() { + $$(".pending-check:checked").forEach((input) => { + const companyId = input.closest(".list-row")?.dataset.companyId; + if (!companyId) return; + const chip = document.querySelector('#reminderCompanyList input[name="company"][value="' + companyId + '"]'); + if (chip) chip.checked = true; + }); + updatePickCount(); +} + async function loadAdminRemindersPending() { const list = $("#pending-list"); const empty = $("#pending-empty"); @@ -1839,6 +1855,7 @@ async function loadAdminRemindersPending() { const row = document.createElement("div"); row.className = "list-row"; row.dataset.dedupeKey = item.dedupe_key; + row.dataset.companyId = String(item.company_id); const sentHint = item.send_count > 0 ? ` · 已提醒 ${item.send_count} 次` : ""; row.innerHTML = '' + @@ -1994,8 +2011,17 @@ function initAdminReminders() { if (!event.target.classList.contains("pending-check")) return; const checked = $$(".pending-check:checked").length; const sendAll = $("#pending-send-all"); - if (!sendAll) return; - sendAll.textContent = checked ? `发送选中(${checked})` : "全部一键发送"; + if (sendAll) sendAll.textContent = checked ? `发送选中(${checked})` : "全部一键发送"; + if (event.target.checked) { + const companyId = event.target.closest(".list-row")?.dataset.companyId; + const chip = companyId && document.querySelector('#reminderCompanyList input[name="company"][value="' + companyId + '"]'); + if (chip) chip.checked = true; + } + updatePickCount(); + }); + + $("#reminderCompanyList")?.addEventListener("change", (event) => { + if (event.target.name === "company") updatePickCount(); }); $("#reminderForm")?.addEventListener("submit", async (event) => { @@ -2031,6 +2057,7 @@ function initAdminReminders() { return company?.name || c.value; }).join("、"); form.querySelectorAll('input[name="company"]').forEach((c) => { c.checked = false; }); + updatePickCount(); const sendHint = $("#send-hint"); sendHint.style.display = ""; sendHint.textContent = `已发送给 ${companies},共 ${checked.length} 家公司。`; diff --git a/web/design-system.css b/web/design-system.css index c91d177..8da73d4 100644 --- a/web/design-system.css +++ b/web/design-system.css @@ -1083,3 +1083,97 @@ body[data-portal="company"] .side-nav a[data-view="transfers"].active svg { .xfer-split { grid-template-columns: 1fr; } .xfer-split-pane.confirmed { border-right: 0; border-bottom: 1px solid var(--border); } } + +/* ─── 提醒管理:三步发送流(HEL-230) ─────────────────────────── */ +.send-flow { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.5fr) minmax(0, 1fr); + align-items: stretch; + gap: 0; +} +.sf-step { + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; + padding: 0 18px; +} +.sf-step:first-child { padding-left: 0; } +.sf-step:last-child { padding-right: 0; } +.sf-step + .sf-step { border-left: 1px solid var(--border); } +.sf-step-head { + display: flex; + align-items: baseline; + gap: 8px; +} +.sf-idx { + font-family: var(--font-mono); + font-size: 12px; + font-weight: 650; + color: var(--accent); + flex: none; +} +.sf-step-head > span:nth-child(2) { font-size: 13px; font-weight: 650; } +.sf-step-head .meta { margin-left: auto; } +.sf-step .hint { font-size: 11.5px; color: var(--muted); } +.sf-step .hint.error { color: var(--danger); } +.pick-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} +.pick { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 10px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--surface); + color: var(--fg); + font-size: 13px; + cursor: pointer; + transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease; +} +.pick:hover { background: var(--fg-soft); } +.pick:has(input:checked) { + background: var(--accent-soft); + border-color: var(--accent); + color: var(--accent); +} +.pick input { width: auto; margin: 0; } +.sf-send { + margin-top: auto; + display: flex; + flex-direction: column; + gap: 8px; +} +.sf-send .btn-primary { width: 100%; } + +[data-page="reminders"] .history-table { + border: 0; +} +[data-page="reminders"] .history-table .ds-table { + min-width: 0; +} +[data-page="reminders"] .history-table .ds-table th:nth-child(1), +[data-page="reminders"] .history-table .ds-table td:nth-child(1) { + width: 14%; +} +[data-page="reminders"] .history-table .ds-table th:nth-child(4), +[data-page="reminders"] .history-table .ds-table td:nth-child(4) { + width: 28%; + white-space: normal; + min-width: 160px; +} + +@media (max-width: 1100px) { + .send-flow { grid-template-columns: minmax(0, 1fr); } + .sf-step { padding: 14px 0 0; } + .sf-step:first-child { padding-top: 0; } + .sf-step + .sf-step { + border-left: 0; + border-top: 1px solid var(--border); + } +}