Compare commits

..
Author SHA1 Message Date
f5e0915f63 HEL-230: 按确认样图通栏重排提醒管理页
页面改为待提醒清单 → 提醒历史 → 发送处理提醒三块通栏;发送区按样图做成三步横向流程,窄屏竖排,保留原发送与历史留痕能力。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-29 10:14:03 +08:00
4 changed files with 385 additions and 71 deletions
+171
View File
@@ -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 = '<input type="checkbox" name="company" value="' + (i + 1) + '">' + 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()
+88 -66
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="金牛集团管理端" />
<title>管理端 · 金牛集团</title>
<link rel="stylesheet" href="design-system.css?v=8" />
<link rel="stylesheet" href="design-system.css?v=9" />
</head>
<body data-portal="admin">
<a class="skip-link" href="#main-content">跳到主要内容</a>
@@ -638,7 +638,8 @@
</div>
</div>
<div class="card" id="pending-reminders-card" style="margin-bottom: 16px;">
<div class="stack">
<div class="card" id="pending-reminders-card">
<div class="card-head">
<span class="card-title">待提醒清单<span class="sub">系统按流水提交、断档、待确认自动发现,点发送即送达对应公司</span></span>
<div class="row" style="gap: 10px; align-items: center;">
@@ -651,72 +652,93 @@
</div>
</div>
<div class="grid grid-1-2">
<div class="card">
<div class="card-head">
<span class="card-title">发送处理提醒<span class="sub">选择公司后系统自动列出待提醒事项,核对后一键发送</span></span>
</div>
<form id="reminderForm" novalidate>
<div class="field" style="margin-bottom: 14px;">
<label>接收公司(可多选)</label>
<div class="row" id="reminderCompanyList" style="flex-wrap: wrap; gap: 8px 16px;"></div>
<span class="hint error" id="company-error" style="display: none;">请至少选择一家接收公司</span>
</div>
<div class="field" style="margin-bottom: 14px;">
<label for="reminder-type">提醒类型</label>
<select class="select" id="reminder-type">
<option selected>流水未提交</option>
<option>单边待确认</option>
<option>科目待确认</option>
<option>账户登记</option>
<option>其他</option>
</select>
</div>
<div class="field" style="margin-bottom: 14px;">
<label for="reminder-content">提醒内容</label>
<textarea class="textarea" id="reminder-content">请于截止日期前完成 2026 年 7 月银行流水上传与待确认事项处理。</textarea>
<span class="hint error" id="content-error" style="display: none;">提醒内容不能为空</span>
</div>
<div class="field" style="margin-bottom: 18px;">
<label for="reminder-deadline">截止日期</label>
<input class="input" type="date" id="reminder-deadline" value="2026-08-29" min="2026-08-20" />
</div>
<button type="submit" class="btn btn-primary" id="send-btn" style="width: 100%;">发送提醒</button>
<p class="hint" id="send-hint" style="margin-top: 10px; display: none;"></p>
</form>
<div class="card" id="reminder-history-card">
<div class="card-head">
<span class="card-title">提醒历史<span class="sub">含系统自动触发与人工发送的全部提醒记录</span></span>
</div>
<div class="tabs" id="reminder-tabs">
<button type="button" class="active" data-filter="all">全部<span class="tab-count" id="count-all">0</span></button>
<button type="button" data-filter="system">系统提醒<span class="tab-count" id="count-system">0</span></button>
<button type="button" data-filter="manual">人工提醒<span class="tab-count" id="count-manual">0</span></button>
</div>
<div class="table-wrap history-table">
<table class="ds-table">
<thead>
<tr>
<th>公司</th>
<th>来源</th>
<th>类型</th>
<th class="wrap">内容摘要</th>
<th>发送时间</th>
<th>截止日期</th>
<th>处理状态</th>
<th>操作</th>
</tr>
</thead>
<tbody id="reminder-tbody"></tbody>
</table>
</div>
<div class="table-foot">
<span id="table-foot-count">共 0 条提醒记录</span>
<span id="table-foot-state">未读 0 · 处理中 0 · 已完成 0</span>
</div>
</div>
<div class="card">
<div class="card-head">
<span class="card-title">提醒历史<span class="sub">含系统自动触发与人工发送的全部提醒记录</span></span>
</div>
<div class="tabs" id="reminder-tabs">
<button type="button" class="active" data-filter="all">全部<span class="tab-count" id="count-all">0</span></button>
<button type="button" data-filter="system">系统提醒<span class="tab-count" id="count-system">0</span></button>
<button type="button" data-filter="manual">人工提醒<span class="tab-count" id="count-manual">0</span></button>
</div>
<div class="table-wrap" style="border: 0;">
<table class="ds-table">
<thead>
<tr>
<th>公司</th>
<th>来源</th>
<th>类型</th>
<th class="wrap">内容摘要</th>
<th>发送时间</th>
<th>截止日期</th>
<th>处理状态</th>
<th>操作</th>
</tr>
</thead>
<tbody id="reminder-tbody"></tbody>
</table>
</div>
<div class="table-foot">
<span id="table-foot-count">共 0 条提醒记录</span>
<span id="table-foot-state">未读 0 · 处理中 0 · 已完成 0</span>
</div>
<div class="card" id="send-reminder-card">
<div class="card-head">
<span class="card-title">发送处理提醒<span class="sub">将以人工提醒形式送达所选公司出纳与财务负责人</span></span>
</div>
<form id="reminderForm" novalidate>
<div class="send-flow">
<div class="sf-step">
<div class="sf-step-head">
<span class="sf-idx">01</span>
<span>选择接收公司</span>
<span class="meta" id="pick-count">已选 0 家</span>
</div>
<div class="pick-list" id="reminderCompanyList"></div>
<span class="hint error" id="company-error" style="display: none;">请至少选择一家接收公司</span>
<p class="hint">每家公司将分别生成一条提醒,可在上方「待提醒清单」勾选后自动带入。</p>
</div>
<div class="sf-step">
<div class="sf-step-head">
<span class="sf-idx">02</span>
<span>填写提醒内容</span>
</div>
<div class="field">
<label for="reminder-type">提醒类型</label>
<select class="select" id="reminder-type">
<option selected>流水未提交</option>
<option>单边待确认</option>
<option>科目待确认</option>
<option>账户登记</option>
<option>其他</option>
</select>
</div>
<div class="field">
<label for="reminder-content">提醒内容</label>
<textarea class="textarea" id="reminder-content">请于截止日期前完成 2026 年 7 月银行流水上传与待确认事项处理。</textarea>
<span class="hint error" id="content-error" style="display: none;">提醒内容不能为空</span>
</div>
</div>
<div class="sf-step">
<div class="sf-step-head">
<span class="sf-idx">03</span>
<span>设定截止并发送</span>
</div>
<div class="field">
<label for="reminder-deadline">截止日期</label>
<input class="input" type="date" id="reminder-deadline" value="2026-08-29" min="2026-08-20" />
</div>
<div class="sf-send">
<button type="submit" class="btn btn-primary" id="send-btn">发送提醒</button>
<p class="hint" id="send-result-hint">发送后可在上方「提醒历史」中跟踪触达与处理状态。</p>
<p class="hint" id="send-hint" style="display: none;"></p>
</div>
</div>
</div>
</form>
</div>
</div>
</section>
</main>
@@ -1011,6 +1033,6 @@
</div>
</div>
<div class="toast-region" id="toastRegion" aria-live="polite"></div>
<script src="app.js?v=13"></script>
<script src="app.js?v=14"></script>
</body>
</html>
+32 -5
View File
@@ -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 =
'<label class="row" style="gap:6px;flex:none;"><input type="checkbox" class="pending-check" style="width:auto;" /></label>' +
@@ -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} 家公司。`;
+94
View File
@@ -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);
}
}