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 @@
-
-
-
- 发送处理提醒选择公司后系统自动列出待提醒事项,核对后一键发送
-
-
+
+
+ 提醒历史含系统自动触发与人工发送的全部提醒记录
+
+
+
+
+
+
+
+
+
+ | 公司 |
+ 来源 |
+ 类型 |
+ 内容摘要 |
+ 发送时间 |
+ 截止日期 |
+ 处理状态 |
+ 操作 |
+
+
+
+
+
+
+
-
-
- 提醒历史含系统自动触发与人工发送的全部提醒记录
-
-
-
-
-
-
-
-
-
-
- | 公司 |
- 来源 |
- 类型 |
- 内容摘要 |
- 发送时间 |
- 截止日期 |
- 处理状态 |
- 操作 |
-
-
-
-
-
-
+
+
+ 发送处理提醒将以人工提醒形式送达所选公司出纳与财务负责人
+
+
@@ -1011,6 +1033,6 @@
-
+