Implement automatic reminder engine with admin and company UI.

Add schema v6, read-only scan rules for unsubmitted flows, gaps and pending reviews, deduplicated delivery with append-only event history, daily scan thread, and full API/frontend integration with 18 new tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
总工
2026-08-28 13:33:06 +00:00
co-authored by Cursor multica-agent
parent 951b353765
commit f17636183d
8 changed files with 1934 additions and 270 deletions
+37
View File
@@ -713,6 +713,22 @@
<h1>提醒管理</h1>
<p class="page-sub">向成员公司发送处理提醒,并跟踪系统提醒与人工提醒的触达与处理状态。</p>
</div>
<div class="page-actions">
<button type="button" class="btn btn-sm" id="reminder-scan-btn">立即扫描</button>
</div>
</div>
<div class="card" id="pending-reminders-card" style="margin-bottom: 16px;">
<div class="card-head">
<span class="card-title">待提醒清单<span class="sub">系统按流水提交、断档、待确认自动发现,点发送即送达对应公司</span></span>
<div class="row" style="gap: 10px; align-items: center;">
<span class="meta" id="pending-summary"></span>
<button type="button" class="btn btn-primary btn-sm" id="pending-send-all" disabled>全部一键发送</button>
</div>
</div>
<div id="pending-list">
<div class="empty" id="pending-empty">暂无需要提醒的事项 · 各公司流水与确认进度正常</div>
</div>
</div>
<div class="grid grid-1-2">
@@ -764,6 +780,7 @@
<thead>
<tr>
<th>公司</th>
<th>来源</th>
<th>类型</th>
<th class="wrap">内容摘要</th>
<th>发送时间</th>
@@ -1035,6 +1052,26 @@
</div>
</div>
<!-- 提醒详情抽屉 -->
<aside class="drawer" id="reminder-detail-drawer" aria-label="提醒详情">
<div class="drawer-head">
<div>
<span class="pill pill-info" id="rd-source-pill">系统</span>
<h2 class="d-title" id="rd-title">提醒详情</h2>
<p class="d-desc" id="rd-sub"></p>
</div>
<button type="button" class="icon-button" data-close-rd aria-label="关闭详情" title="关闭详情">×</button>
</div>
<div class="drawer-body">
<dl class="kv" id="rd-fields"></dl>
<h3 style="font-size: 13px; margin: 16px 0 8px; color: var(--muted);">事件留痕</h3>
<div id="rd-events"></div>
</div>
<div class="drawer-foot">
<button type="button" class="btn" data-close-rd>关闭</button>
</div>
</aside>
<!-- 提醒历史弹窗 -->
<div class="modal-backdrop" id="history-modal">
<div class="modal">
+403 -155
View File
@@ -963,7 +963,7 @@ function setSelectOptions(select, names, { keepFirst = false } = {}) {
select.replaceChildren(...kept, ...names.map((name) => new Option(name, name)));
}
function fillCompanySelects(names) {
function fillCompanySelects(names, companies) {
// Every company picker is driven by master data: a newly created company
// appears in pair queries, audit filters, flow filters and reminders
// without any code change.
@@ -976,7 +976,20 @@ function fillCompanySelects(names) {
setSelectOptions($("#auditCompany"), names, { keepFirst: true });
setSelectOptions($("#flowCompany"), names, { keepFirst: true });
const reminderList = $("#reminderCompanyList");
if (reminderList) {
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;";
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;
}));
} else if (reminderList) {
reminderList.replaceChildren(...names.map((name) => {
const label = document.createElement("label");
label.className = "row";
@@ -994,6 +1007,293 @@ function fillCompanySelects(names) {
setSelectOptions($('#openingDialog [name="to"]'), names);
}
function formatReminderTime(value) {
if (!value) return "—";
const text = String(value).replace("T", " ").slice(0, 16);
return text;
}
function reminderTypePill(ruleKey) {
if (ruleKey === "pending_review") return "pill-warn";
if (ruleKey === "manual") return "pill-warn";
return "pill-danger";
}
function reminderStatusPill(statusUi) {
return { unread: "pill-danger", doing: "pill-warn", done: "pill-success" }[statusUi] || "pill-muted";
}
function reminderStatusLabel(statusUi) {
return { unread: "未读", doing: "处理中", done: "已完成" }[statusUi] || statusUi;
}
async function loadAdminRemindersPending() {
const list = $("#pending-list");
const empty = $("#pending-empty");
const summary = $("#pending-summary");
const sendAll = $("#pending-send-all");
if (!list) return;
const response = await fetch("/api/admin/reminders/pending").catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
const result = await response?.json().catch(() => ({}));
if (!response?.ok) return;
const findings = result.findings || [];
list.querySelectorAll(".list-row").forEach((row) => row.remove());
if (!findings.length) {
if (empty) empty.style.display = "";
if (summary) summary.textContent = "0 家公司 · 0 项";
if (sendAll) { sendAll.disabled = true; sendAll.textContent = "全部一键发送"; }
return;
}
if (empty) empty.style.display = "none";
const companies = new Set(findings.map((item) => item.company_id)).size;
if (summary) summary.textContent = `${companies} 家公司 · ${findings.length}`;
if (sendAll) {
sendAll.disabled = false;
sendAll.textContent = "全部一键发送";
sendAll.dataset.keys = JSON.stringify(findings.map((item) => item.dedupe_key));
}
findings.forEach((item) => {
const row = document.createElement("div");
row.className = "list-row";
row.dataset.dedupeKey = item.dedupe_key;
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>' +
`<span class="pill ${reminderTypePill(item.rule_key)}">${item.rule_label}</span>` +
`<div class="lr-main"><div class="lr-title">${item.title}</div>` +
`<div class="lr-sub"><span class="meta">${item.reason}${sentHint}</span></div></div>` +
`<span class="pill ${item.duration_pill}" style="flex:none;font-family:var(--font-mono);">${item.days_open} 天</span>` +
`<div class="lr-side"><button type="button" class="btn btn-sm btn-primary pending-send-one">发送提醒</button></div>`;
list.append(row);
});
}
async function loadAdminRemindersHistory(sourceFilter) {
const tbody = $("#reminder-tbody");
if (!tbody) return;
const query = sourceFilter && sourceFilter !== "all" ? `?source=${sourceFilter === "system" ? "auto" : "manual"}` : "";
const response = await fetch(`/api/admin/reminders${query}`).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
const result = await response?.json().catch(() => ({}));
if (!response?.ok) return;
const items = result.reminders || [];
tbody.replaceChildren(...items.map((item) => {
const tr = document.createElement("tr");
tr.dataset.source = item.source === "auto" ? "system" : "manual";
tr.dataset.reminderId = String(item.id);
const summary = item.content.length > 46 ? item.content.slice(0, 46) + "…" : item.content;
const sourceCell = item.source === "auto"
? '<span class="pill pill-info">系统</span>'
: '<span class="tag">人工</span>';
tr.innerHTML =
`<td class="cell-main">${item.company_name}</td>` +
`<td>${sourceCell}</td>` +
`<td><span class="pill ${reminderTypePill(item.rule_key)}">${item.display_type}</span></td>` +
`<td class="wrap">${summary.replace(/</g, "&lt;")}</td>` +
`<td class="num-col">${formatReminderTime(item.last_sent_at)}</td>` +
`<td class="num-col">${item.deadline || "—"}</td>` +
`<td><span class="pill ${reminderStatusPill(item.status_ui)}">${reminderStatusLabel(item.status_ui)}</span></td>` +
'<td><div class="row" style="gap:6px;"><button type="button" class="btn btn-sm act-remind">再提醒</button><button type="button" class="btn btn-sm btn-ghost act-detail">详情</button></div></td>';
return tr;
}));
refreshReminderCounts(result.stats || {});
}
function refreshReminderCounts(stats) {
const rows = $$("#reminder-tbody tr");
let all = rows.length, sys = 0, man = 0;
rows.forEach((r) => { if (r.dataset.source === "system") sys++; else man++; });
$("#count-all").textContent = all;
$("#count-system").textContent = sys;
$("#count-manual").textContent = man;
$("#table-foot-count").textContent = `${all} 条提醒记录`;
if (stats) {
$("#table-foot-state").textContent = `未读 ${stats.unread || 0} · 处理中 ${stats.doing || 0} · 已完成 ${stats.done || 0}`;
}
}
async function sendPendingReminders(keys) {
const response = await fetch("/api/admin/reminders/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dedupe_keys: keys }),
}).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return null; }
const result = await response?.json().catch(() => ({}));
if (!response?.ok) {
showToast("发送失败", result?.message || "请稍后重试", "danger");
return null;
}
return result;
}
function openReminderDetailDrawer(reminderId) {
fetch(`/api/admin/reminders/${reminderId}`)
.then((response) => response.json())
.then((result) => {
const item = result.reminder;
if (!item) return;
$("#rd-title").textContent = item.title;
$("#rd-sub").textContent = item.content;
const sourcePill = $("#rd-source-pill");
if (sourcePill) {
sourcePill.textContent = item.source === "auto" ? "系统" : "人工";
sourcePill.className = item.source === "auto" ? "pill pill-info" : "tag";
}
$("#rd-fields").innerHTML =
`<dt>公司</dt><dd>${item.company_name}</dd>` +
`<dt>类型</dt><dd>${item.display_type}</dd>` +
`<dt>触发原因</dt><dd>${item.rule_params?.pending_count ? `待处理 ${item.rule_params.pending_count}` : (item.rule_params?.period || "—")}</dd>` +
`<dt>发送时间</dt><dd>${formatReminderTime(item.last_sent_at)}</dd>` +
`<dt>催办次数</dt><dd>${item.send_count}</dd>` +
`<dt>处理状态</dt><dd>${reminderStatusLabel(item.status_ui)}</dd>`;
$("#rd-events").innerHTML = (item.events || []).map((ev) =>
`<div class="list-row"><div class="lr-main"><div class="lr-title">${ev.event_type}</div><div class="lr-sub meta">${formatReminderTime(ev.created_at)} · ${ev.actor}${ev.detail ? " · " + ev.detail : ""}</div></div></div>`,
).join("") || '<div class="empty"><div class="e-title">暂无事件</div></div>';
$("#reminder-detail-drawer")?.classList.add("is-open");
})
.catch(() => showToast("加载详情失败", "", "danger"));
}
function initAdminReminders() {
loadAdminRemindersPending();
loadAdminRemindersHistory("all");
$("#reminder-scan-btn")?.addEventListener("click", async () => {
const response = await fetch("/api/admin/reminders/scan", { method: "POST" }).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
const result = await response?.json().catch(() => ({}));
if (!response?.ok) {
showToast("扫描失败", result?.message || "请稍后重试", "danger");
return;
}
await loadAdminRemindersPending();
showToast("扫描完成", `发现 ${result.summary?.items || 0} 项待提醒`, "success");
});
$("#pending-send-all")?.addEventListener("click", async () => {
const checked = $$(".pending-check:checked");
let keys;
if (checked.length) {
keys = checked.map((input) => input.closest(".list-row")?.dataset.dedupeKey).filter(Boolean);
} else {
keys = JSON.parse($("#pending-send-all").dataset.keys || "[]");
}
if (!keys.length) return;
const result = await sendPendingReminders(keys);
if (!result) return;
showToast(checked.length ? "已发送选中提醒" : "已全部发送", `${result.count}`, "success");
await loadAdminRemindersPending();
await loadAdminRemindersHistory($("#reminder-tabs .active")?.dataset.filter || "all");
});
$("#pending-list")?.addEventListener("click", async (event) => {
const btn = event.target.closest(".pending-send-one");
if (!btn) return;
const row = btn.closest(".list-row");
const key = row?.dataset.dedupeKey;
if (!key) return;
const result = await sendPendingReminders([key]);
if (!result) return;
if (!motionQuery.matches) {
row.style.transition = "opacity 0.2s ease";
row.style.opacity = "0";
setTimeout(() => row.remove(), 200);
} else {
row.remove();
}
showToast("已发送提醒", "", "success");
await loadAdminRemindersPending();
await loadAdminRemindersHistory($("#reminder-tabs .active")?.dataset.filter || "all");
});
$("#pending-list")?.addEventListener("change", (event) => {
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}` : "全部一键发送";
});
$("#reminderForm")?.addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
const checked = Array.prototype.slice.call(form.querySelectorAll('input[name="company"]:checked'));
const type = $("#reminder-type").value;
const content = $("#reminder-content").value.trim();
const deadline = $("#reminder-deadline").value;
$("#company-error").style.display = checked.length ? "none" : "";
$("#content-error").style.display = content ? "none" : "";
if (!checked.length || !content) return;
for (const input of checked) {
const response = await fetch("/api/admin/reminders/manual", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
company_id: Number(input.value),
display_type: type,
content,
deadline: deadline || null,
}),
}).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
const result = await response?.json().catch(() => ({}));
if (!response?.ok) {
showToast("发送失败", result?.message || "请稍后重试", "danger");
return;
}
}
const companies = checked.map((c) => {
const company = (state.companies || []).find((item) => String(item.id) === c.value);
return company?.name || c.value;
}).join("、");
form.querySelectorAll('input[name="company"]').forEach((c) => { c.checked = false; });
const sendHint = $("#send-hint");
sendHint.style.display = "";
sendHint.textContent = `已发送给 ${companies},共 ${checked.length} 家公司。`;
clearTimeout(sendHint._t);
sendHint._t = setTimeout(() => { sendHint.style.display = "none"; }, 4000);
showToast("人工提醒已发送", companies, "success");
await loadAdminRemindersHistory($("#reminder-tabs .active")?.dataset.filter || "all");
});
$("#reminder-tabs")?.addEventListener("click", (event) => {
const btn = event.target.closest("button[data-filter]");
if (!btn) return;
$("#reminder-tabs").querySelectorAll("button").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
loadAdminRemindersHistory(btn.dataset.filter);
});
$("#reminder-tbody")?.addEventListener("click", async (event) => {
const detailBtn = event.target.closest(".act-detail");
if (detailBtn) {
const tr = detailBtn.closest("tr");
openReminderDetailDrawer(tr.dataset.reminderId);
return;
}
const remindBtn = event.target.closest(".act-remind");
if (!remindBtn) return;
const tr = remindBtn.closest("tr");
const response = await fetch(`/api/admin/reminders/${tr.dataset.reminderId}/resend`, { method: "POST" }).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
const result = await response?.json().catch(() => ({}));
if (!response?.ok) {
showToast("再提醒失败", result?.message || "请稍后重试", "danger");
return;
}
remindBtn.textContent = "已再提醒";
remindBtn.disabled = true;
await loadAdminRemindersHistory($("#reminder-tabs .active")?.dataset.filter || "all");
showToast("已再次发送提醒", "", "success");
});
$$("[data-close-rd]").forEach((btn) => btn.addEventListener("click", () => {
$("#reminder-detail-drawer")?.classList.remove("is-open");
}));
}
async function loadAdminCompanies() {
const tbody = $("#companyTable tbody");
showTableLoading(tbody, 6);
@@ -1005,7 +1305,7 @@ async function loadAdminCompanies() {
const result = await response.json().catch(() => null);
const companies = result?.companies || [];
renderAdminCompanyTable(companies);
fillCompanySelects(companies.map((company) => company.name));
fillCompanySelects(companies.map((company) => company.name), companies);
if (companies.length >= 2) setPair(companies[0].name, companies[1].name);
}
@@ -1227,7 +1527,7 @@ function initAdmin() {
remind?.addEventListener("change", renderSwitch);
renderSwitch();
$("#systemSettings")?.addEventListener("submit", (event) => {
$("#systemSettings")?.addEventListener("submit", async (event) => {
event.preventDefault();
const day = parseInt($("#cs-day")?.value, 10);
const tip = $("#cs-save-tip");
@@ -1235,8 +1535,19 @@ function initAdmin() {
if (tip) { tip.style.display = ""; tip.style.color = "var(--danger)"; tip.textContent = "结账日须为 1-28 之间的整数"; }
return;
}
const gapDays = parseInt($("#cs-remind-days")?.value, 10) || 5;
const response = await fetch("/api/admin/reminder-settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ settings: { monthly_start_day: String(day || 5), gap_days: String(gapDays) } }),
}).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
if (!response?.ok) {
showToast("设置保存失败", "", "danger");
return;
}
if (tip) { tip.style.display = ""; tip.style.color = "var(--success)"; tip.textContent = "已保存 · 立即生效"; }
showToast("系统计算口径已保存", "正式系统将记录修改前后值与操作人", "success");
showToast("系统计算口径已保存", "提醒扫描参数已同步更新", "success");
});
$("#runClosingCheck")?.addEventListener("click", () => {
@@ -1309,119 +1620,7 @@ function initAdmin() {
showToast("期初余额已提交复核", "正式系统将保留录入依据与操作人", "success");
});
$("#reminderForm")?.addEventListener("submit", (event) => {
event.preventDefault();
const form = event.currentTarget;
const checked = Array.prototype.slice.call(form.querySelectorAll('input[name="company"]:checked'));
const type = $("#reminder-type").value;
const content = $("#reminder-content").value.trim();
const deadline = $("#reminder-deadline").value;
$("#company-error").style.display = checked.length ? "none" : "";
$("#content-error").style.display = content ? "none" : "";
if (!checked.length || !content) return;
const companies = checked.map((c) => c.value).join("、");
const summary = content.length > 46 ? content.slice(0, 46) + "…" : content;
const row = document.createElement("tr");
row.dataset.source = "manual";
row.innerHTML =
'<td class="cell-main">' + companies + '</td>' +
'<td><span class="tag">' + type + '</span></td>' +
'<td class="wrap">' + summary.replace(/</g, "&lt;") + '</td>' +
'<td class="meta">刚刚<span class="cell-sub">人工 · 系统管理员</span></td>' +
'<td class="meta">' + (deadline || "—") + '</td>' +
'<td><span class="pill pill-danger">未读</span></td>' +
'<td><div class="row" style="gap:6px;"><button type="button" class="btn btn-sm act-remind">再提醒</button><button type="button" class="btn btn-sm btn-ghost act-history" data-company="' + checked[0].value + '">查看历史</button></div></td>';
$("#reminder-tbody").insertBefore(row, $("#reminder-tbody").firstChild);
refreshReminderCounts();
form.querySelectorAll('input[name="company"]').forEach((c) => { c.checked = false; });
$("#reminder-content").value = "请于截止日期前完成 2026 年 7 月银行流水上传与待确认事项处理。";
$("#reminder-deadline").value = "2026-08-29";
const sendHint = $("#send-hint");
sendHint.style.display = "";
sendHint.textContent = "已发送给 " + companies + ",共 " + checked.length + " 家公司。";
clearTimeout(sendHint._t);
sendHint._t = setTimeout(() => { sendHint.style.display = "none"; }, 4000);
});
// 提醒历史 tabs / 计数
function refreshReminderCounts() {
const rows = $$("#reminder-tbody tr");
let all = rows.length, sys = 0, man = 0;
rows.forEach((r) => { if (r.dataset.source === "system") sys++; else man++; });
$("#count-all").textContent = all;
$("#count-system").textContent = sys;
$("#count-manual").textContent = man;
$("#table-foot-count").textContent = "共 " + all + " 条提醒记录";
}
$("#reminder-tabs")?.addEventListener("click", (event) => {
const btn = event.target.closest("button[data-filter]");
if (!btn) return;
$("#reminder-tabs").querySelectorAll("button").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
const filter = btn.dataset.filter;
$$("#reminder-tbody tr").forEach((r) => { r.style.display = (filter === "all" || r.dataset.source === filter) ? "" : "none"; });
});
$("#reminder-tbody")?.addEventListener("click", (event) => {
const remindBtn = event.target.closest(".act-remind");
if (remindBtn) {
const tr = remindBtn.closest("tr");
const timeCell = tr.children[3];
const sub = timeCell.querySelector(".cell-sub");
timeCell.firstChild.textContent = "刚刚";
if (sub) sub.textContent = sub.textContent.replace(/\s*·?\s*第 \d+ 次/, "") + " · 再提醒";
else timeCell.insertAdjacentHTML("beforeend", '<span class="cell-sub">再提醒</span>');
remindBtn.textContent = "已再提醒";
remindBtn.disabled = true;
return;
}
const historyBtn = event.target.closest(".act-history");
if (historyBtn) openReminderHistory(historyBtn.dataset.company);
});
const reminderHISTORIES = {
"金牛农业": [
{ time: "2026-08-18 09:02", src: "系统 · 第 2 次", type: "流水未提交", status: "danger", statusText: "未读", body: "贵公司 7 月全部银行账户流水尚未提交,距 7 月结账顺延截止日仅剩 11 天,请尽快上传。" },
{ time: "2026-08-11 09:00", src: "系统 · 第 1 次", type: "流水未提交", status: "danger", statusText: "未读", body: "2026 年 7 月银行流水上传提醒:请于截止日前完成全部账户流水提交。" },
],
"金牛新能源": [
{ time: "2026-08-15 09:00", src: "系统", type: "单边待确认", status: "warn", statusText: "处理中", body: "与金牛贸易 3 笔单边流水合计 ¥4,860,000.00 待确认,请选择对方银行流水佐证。" },
],
"金牛置业": [
{ time: "2026-08-14 16:40", src: "人工 · 张维", type: "流水未提交", status: "warn", statusText: "处理中", body: "中行尾号 8821 账户 7 月 6—16 日流水断档,请补传该期间银行回单或对账单。" },
],
"金牛贸易": [
{ time: "2026-08-15 09:00", src: "系统", type: "单边待确认", status: "success", statusText: "已完成", body: "与金牛新能源往来 3 笔单边流水待确认,请核对 7 月销售回款记录。" },
],
"金牛煤业": [
{ time: "2026-08-12 09:00", src: "系统", type: "科目待确认", status: "warn", statusText: "处理中", body: "2 笔手工记录往来科目待确认(应收 / 其他应收),涉及煤炭运费结算。" },
],
"金牛物流": [
{ time: "2026-08-10 11:25", src: "人工 · 张维", type: "科目待确认", status: "success", statusText: "已完成", body: "1 笔运费结算手工记录科目待确认(其他应付待判定),请于结账前完成。" },
],
};
function openReminderHistory(company) {
$("#history-title").textContent = company + " · 提醒历史";
$("#history-sub").textContent = "2026 年 7 月账期以来的全部触达记录,按时间倒序。";
const list = $("#history-list");
const items = reminderHISTORIES[company] || [];
if (!items.length) {
list.innerHTML = '<div class="empty"><div class="e-title">暂无历史记录</div>该公司在 7 月账期内尚未收到过提醒。</div>';
} else {
list.innerHTML = items.map((it) => {
return '<div class="list-row">' +
'<span class="tag">' + it.type + '</span>' +
'<div class="lr-main"><div class="lr-title">' + it.body + '</div><div class="lr-sub"><span class="meta">' + it.time + ' · ' + it.src + '</span></div></div>' +
'<div class="lr-side"><span class="pill pill-' + it.status + '">' + it.statusText + '</span></div></div>';
}).join("");
}
openModal("history-modal");
}
$("#history-close")?.addEventListener("click", () => closeModal("history-modal"));
$("#history-ok")?.addEventListener("click", () => closeModal("history-modal"));
refreshReminderCounts();
initAdminReminders();
}
const FLOW_DEMO = [
@@ -2068,34 +2267,60 @@ function initReconcile() {
refreshCounts();
}
function renderCompanyNoticeRow(item) {
const row = document.createElement("div");
row.className = "list-row";
row.dataset.status = item.status_ui;
row.dataset.reminderId = String(item.id);
const titleWeight = item.status_ui === "unread" ? "650" : "400";
const titleColor = item.status_ui === "unread" ? "var(--fg)" : "var(--muted)";
const sourceTag = item.source === "auto" ? '<span class="pill pill-info">系统</span>' : '<span class="tag">总账管理员</span>';
const actionLink = item.action_link;
let side = "";
if (item.status_ui === "unread") {
side = '<button class="btn btn-sm btn-mark-read">标记已读</button>';
} else if (item.status_ui === "doing" && actionLink) {
side = `<button class="btn btn-sm btn-primary" data-view-link="${actionLink}">去处理</button>`;
} else if (item.status_ui === "doing") {
side = '<button class="btn btn-sm btn-ghost btn-mark-done">标记完成</button>';
}
row.innerHTML =
`<span class="pill ${reminderStatusPill(item.status_ui)}" style="flex:none;">${reminderStatusLabel(item.status_ui)}</span>` +
sourceTag +
`<div class="lr-main"><div class="lr-title" style="font-weight:${titleWeight};color:${titleColor};">${item.title}</div>` +
`<div class="lr-sub"><span class="meta">${formatReminderTime(item.last_sent_at)}</span> · ${item.content}</div></div>` +
`<div class="lr-side">${side}</div>`;
return row;
}
function initNotifications() {
const tabs = $("#notice-tabs");
const list = $("#notice-list");
if (!tabs || !list) return;
const rows = $$(".list-row", list);
const emptyBox = $("#notice-empty");
let currentFilter = "all";
let rows = [];
const STATUS_PILL = { unread: "pill-danger", doing: "pill-warn", done: "pill-success" };
const STATUS_LABEL = { unread: "未读", doing: "处理中", done: "已完成" };
function counts() {
const c = { all: rows.length, unread: 0, doing: 0, done: 0 };
rows.forEach((row) => { c[row.getAttribute("data-status")] += 1; });
return c;
}
function refreshCounts() {
const c = counts();
function refreshCounts(stats, unreadCount) {
const c = stats || { all: rows.length, unread: 0, doing: 0, done: 0 };
if (!stats) {
c.all = rows.length;
rows.forEach((row) => { c[row.getAttribute("data-status")] += 1; });
} else {
c.all = rows.length;
}
$("#count-all").textContent = c.all;
$("#count-unread").textContent = c.unread;
$("#count-doing").textContent = c.doing;
$("#count-done").textContent = c.done;
const navBadge = $('.side-nav a[data-view="notifications"] .nav-badge');
const unread = unreadCount ?? c.unread;
if (navBadge) {
navBadge.textContent = c.unread;
navBadge.style.display = c.unread ? "" : "none";
navBadge.textContent = unread;
navBadge.style.display = unread ? "" : "none";
}
const wsLink = $("#workspace-notice-link");
if (wsLink) wsLink.textContent = unread ? `全部通知 (${c.all}) →` : "全部通知 →";
}
function applyFilter() {
@@ -2105,15 +2330,35 @@ function initNotifications() {
row.style.display = show ? "" : "none";
if (show) visible += 1;
});
emptyBox.style.display = visible === 0 ? "" : "none";
if (emptyBox) emptyBox.style.display = visible === 0 ? "" : "none";
}
function setStatus(row, status) {
row.setAttribute("data-status", status);
const pill = $(".pill", row);
if (pill) {
pill.className = `pill ${STATUS_PILL[status]}`;
pill.textContent = STATUS_LABEL[status];
async function loadReminders() {
const response = await fetch("/api/company/reminders").catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
const result = await response?.json().catch(() => ({}));
if (!response?.ok) return;
const items = result.reminders || [];
list.replaceChildren(...items.map((item) => renderCompanyNoticeRow(item)));
rows = $$(".list-row", list);
refreshCounts(result.stats, result.unread_count);
applyFilter();
const wsList = $("#workspace-notice-list");
if (wsList) {
const preview = items.slice(0, 3);
if (!preview.length) {
wsList.innerHTML = '<div class="empty"><div class="e-title">暂无通知</div></div>';
} else {
wsList.replaceChildren(...preview.map((item) => {
const row = document.createElement("div");
row.className = "list-row";
row.innerHTML =
`<span class="pill ${reminderTypePill(item.rule_key)}">${item.display_type}</span>` +
`<div class="lr-main"><div class="lr-title">${item.title}</div><div class="lr-sub">${item.content.slice(0, 48)}</div></div>` +
`<span class="lr-side meta">${formatReminderTime(item.last_sent_at).slice(5, 10)}</span>`;
return row;
}));
}
}
}
@@ -2127,29 +2372,32 @@ function initNotifications() {
applyFilter();
});
list.addEventListener("click", (event) => {
const btn = event.target.closest(".btn-mark-read");
list.addEventListener("click", async (event) => {
const readBtn = event.target.closest(".btn-mark-read");
const doneBtn = event.target.closest(".btn-mark-done");
const btn = readBtn || doneBtn;
if (!btn) return;
const row = btn.closest(".list-row");
setStatus(row, "done");
btn.remove();
refreshCounts();
applyFilter();
const id = row?.dataset.reminderId;
if (!id) return;
const action = readBtn ? "acknowledge" : "resolve";
const response = await fetch(`/api/company/reminders/${id}/${action}`, { method: "POST" }).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
if (!response?.ok) return;
await loadReminders();
});
$("#mark-all-read")?.addEventListener("click", () => {
rows.forEach((row) => {
if (row.getAttribute("data-status") !== "unread") return;
setStatus(row, "done");
$(".btn-mark-read", row)?.remove();
});
refreshCounts();
applyFilter();
$("#mark-all-read")?.addEventListener("click", async () => {
const unreadRows = rows.filter((row) => row.getAttribute("data-status") === "unread");
for (const row of unreadRows) {
const id = row.dataset.reminderId;
await fetch(`/api/company/reminders/${id}/acknowledge`, { method: "POST" }).catch(() => null);
}
await loadReminders();
showToast("通知已全部标为已读", "", "success");
});
refreshCounts();
applyFilter();
loadReminders();
}
function openAccountDetail(account) {
+10 -109
View File
@@ -25,7 +25,7 @@
<a data-view="reconcile" href="#reconcile"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M9 11.5l2 2 4-4.5"/><rect x="4" y="3" width="16" height="18" rx="2"/></svg><span class="nav-label">往来确认</span><span class="nav-badge">5</span></a>
<div class="nav-group">账户与消息</div>
<a data-view="accounts" href="#accounts"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 10h18"/></svg><span class="nav-label">银行账户</span></a>
<a data-view="notifications" href="#notifications"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M6 9a6 6 0 1 1 12 0c0 5 2 6 2 6H4s2-1 2-6"/><path d="M10 19a2 2 0 0 0 4 0"/></svg><span class="nav-label">通知</span><span class="nav-badge">3</span></a>
<a data-view="notifications" href="#notifications"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M6 9a6 6 0 1 1 12 0c0 5 2 6 2 6H4s2-1 2-6"/><path d="M10 19a2 2 0 0 0 4 0"/></svg><span class="nav-label">通知</span><span class="nav-badge" style="display: none;">0</span></a>
</nav>
<div class="side-foot">
<div class="user-row">
@@ -200,31 +200,10 @@
<div class="card">
<div class="card-head">
<span class="card-title">最新通知<span class="sub">来自集团总账</span></span>
<button class="btn btn-sm btn-ghost" data-view-link="notifications">全部通知 (3)</button>
<button class="btn btn-sm btn-ghost" data-view-link="notifications" id="workspace-notice-link">全部通知 →</button>
</div>
<div class="list-row">
<span class="pill pill-warn">提醒</span>
<div class="lr-main">
<div class="lr-title">7 月账期结账日顺延至 08-29</div>
<div class="lr-sub">请在此之前完成往来确认事项</div>
</div>
<span class="lr-side meta">08-18</span>
</div>
<div class="list-row">
<span class="pill pill-warn">提醒</span>
<div class="lr-main">
<div class="lr-title">3 笔单边流水待确认</div>
<div class="lr-sub">涉及金牛置业、金牛贸易往来</div>
</div>
<span class="lr-side meta">08-16</span>
</div>
<div class="list-row">
<span class="pill pill-success">通过</span>
<div class="lr-main">
<div class="lr-title">中行尾号 9916 流水导入成功</div>
<div class="lr-sub">流水覆盖至 08-12</div>
</div>
<span class="lr-side meta">08-12</span>
<div id="workspace-notice-list">
<div class="empty"><div class="e-title">暂无通知</div></div>
</div>
</div>
</div>
@@ -707,93 +686,15 @@
<div class="card">
<div class="tabs" id="notice-tabs">
<button type="button" class="active" data-filter="all" aria-pressed="true">全部<span class="tab-count" id="count-all">9</span></button>
<button type="button" data-filter="unread" aria-pressed="false">未读<span class="tab-count" id="count-unread">3</span></button>
<button type="button" data-filter="doing" aria-pressed="false">处理中<span class="tab-count" id="count-doing">2</span></button>
<button type="button" data-filter="done" aria-pressed="false">已完成<span class="tab-count" id="count-done">4</span></button>
<button type="button" class="active" data-filter="all" aria-pressed="true">全部<span class="tab-count" id="count-all">0</span></button>
<button type="button" data-filter="unread" aria-pressed="false">未读<span class="tab-count" id="count-unread">0</span></button>
<button type="button" data-filter="doing" aria-pressed="false">处理中<span class="tab-count" id="count-doing">0</span></button>
<button type="button" data-filter="done" aria-pressed="false">已完成<span class="tab-count" id="count-done">0</span></button>
</div>
<div id="notice-list">
<div class="list-row" data-status="unread">
<span class="pill pill-danger" style="flex: none;">未读</span>
<span class="tag">系统</span>
<div class="lr-main">
<div class="lr-title">7 月流水存在断档风险:交行 尾号 7710 未上传</div>
<div class="lr-sub"><span class="meta">2026-08-20 14:32</span> · 交行 7710 账户(待审核)7 月流水尚未上传,距 7 月结账日(顺延至 08-29)仅剩 9 天,请尽快补传。</div>
</div>
<div class="lr-side"><button class="btn btn-sm btn-mark-read">标记已读</button></div>
</div>
<div class="list-row" data-status="unread">
<span class="pill pill-danger" style="flex: none;">未读</span>
<span class="tag">系统</span>
<div class="lr-main">
<div class="lr-title">您有 2 笔单边流水待选择对方证据</div>
<div class="lr-sub"><span class="meta">2026-08-19 09:15</span> · 与金牛置业的煤炭采购款 ¥3,200,000.00 等 2 笔流水仅有本方记录,需选择对方银行流水佐证。</div>
</div>
<div class="lr-side"><button class="btn btn-sm btn-mark-read">标记已读</button></div>
</div>
<div class="list-row" data-status="unread">
<span class="pill pill-danger" style="flex: none;">未读</span>
<span class="tag">总账管理员</span>
<div class="lr-main">
<div class="lr-title">总账管理员提醒:请于 08-29 前完成待确认事项</div>
<div class="lr-sub"><span class="meta">2026-08-18 16:40</span> · 7 月账期结账顺延至 08-29,贵公司当前仍有 5 项往来确认未完成,请合理安排时间。</div>
</div>
<div class="lr-side"><button class="btn btn-sm btn-mark-read">标记已读</button></div>
</div>
<div class="list-row" data-status="doing">
<span class="pill pill-warn" style="flex: none;">处理中</span>
<span class="tag">系统</span>
<div class="lr-main">
<div class="lr-title">手工记录科目待确认:其他应收 ¥86,500.00</div>
<div class="lr-sub"><span class="meta">2026-08-15 10:02</span> · 07-28 录入的矿区备用金垫付记录,往来科目「其他应收」待总账管理员复核。</div>
</div>
<div class="lr-side"><button class="btn btn-sm btn-primary" data-view-link="manual">去处理</button></div>
</div>
<div class="list-row" data-status="doing">
<span class="pill pill-warn" style="flex: none;">处理中</span>
<span class="tag">总账管理员</span>
<div class="lr-main">
<div class="lr-title">往来确认回复:置业已确认 320 万煤炭采购款</div>
<div class="lr-sub"><span class="meta">2026-08-12 11:26</span> · 金牛置业已确认 07-14 煤炭采购款 ¥3,200,000.00,请补充本方工行 3305 账户流水证据完成闭环。</div>
</div>
<div class="lr-side"><button class="btn btn-sm btn-primary" data-view-link="reconcile">去处理</button></div>
</div>
<div class="list-row" data-status="done">
<span class="pill pill-success" style="flex: none;">已完成</span>
<span class="tag">系统</span>
<div class="lr-main">
<div class="lr-title">流水导入成功:中行 尾号 9916 · 42 笔</div>
<div class="lr-sub"><span class="meta">2026-08-06 15:52</span> · 7 月中行一般户流水已导入,42 笔全部校验通过,无重复记录。</div>
</div>
</div>
<div class="list-row" data-status="done">
<span class="pill pill-success" style="flex: none;">已完成</span>
<span class="tag">系统</span>
<div class="lr-main">
<div class="lr-title">流水导入成功:工行 尾号 3305 · 86 笔</div>
<div class="lr-sub"><span class="meta">2026-08-06 15:48</span> · 7 月工行基本户流水已导入,86 笔全部校验通过,无重复记录。</div>
</div>
</div>
<div class="list-row" data-status="done">
<span class="pill pill-success" style="flex: none;">已完成</span>
<span class="tag">总账管理员</span>
<div class="lr-main">
<div class="lr-title">交行 尾号 7710 账户开户资料已受理</div>
<div class="lr-sub"><span class="meta">2026-07-22 13:10</span> · 新账户开户资料已提交总账管理员,审核通过后方可启用并上传流水。</div>
</div>
</div>
<div class="list-row" data-status="done">
<span class="pill pill-success" style="flex: none;">已完成</span>
<span class="tag">系统</span>
<div class="lr-main">
<div class="lr-title">2026 年 6 月账期已结账</div>
<div class="lr-sub"><span class="meta">2026-07-05 09:00</span> · 6 月账期已按每月 5 日结账规则完成结账,期末数据已锁定,不可再修改。</div>
</div>
</div>
</div>
<div id="notice-list"></div>
<div class="empty" id="notice-empty" style="display: none;">
<div class="empty" id="notice-empty">
<div class="e-title">暂无该状态的通知</div>
<div>切换其他状态查看,或等待新的系统与管理员消息。</div>
</div>