B-39~B-41: 服务端 API 与双端页面接入

- server.py 新增认证、批次/行/导出、公司/账号/别名主数据、审核
  与审计接口,全部在服务端实施租户隔离与权限校验。
- 前端接入真实登录、首次改密、公司账号、账户登记/审核、批次查询
  与上传持久化;移除演示账号密码预填。
- README 更新运行、登录与样本数据政策说明;HANDOFF 同步默认监听
  行为。DESIGN-TOKENS 与样式对齐当前中性石墨玻璃视觉。
This commit is contained in:
腾讯WorkBuddy
2026-08-16 01:49:54 +08:00
parent 51c301d9df
commit 545837446c
9 changed files with 2046 additions and 380 deletions
+514 -139
View File
@@ -7,10 +7,16 @@ const viewNames = portal === "admin"
: { workspace: "工作台", upload: "流水导入", manual: "手工记录", flows: "流水管理", reconcile: "往来确认", accounts: "银行账户", notifications: "通知" };
const storageKeys = {
accounts: "ledger-demo-account-submissions",
manual: "ledger-demo-manual-records",
};
const accountStatusLabels = {
pending: "待复核",
active: "已启用",
returned: "已退回",
disabled: "已停用",
};
const state = {
currentView: portal === "admin" ? "dashboard" : "workspace",
selectedFile: null,
@@ -51,18 +57,20 @@ function animateView(view, { initial = false } = {}) {
elements.forEach((element, index) => {
element.getAnimations().forEach((animation) => animation.cancel());
element.animate(
[
{ opacity: 0, transform: `translateY(${initial ? 16 : 10}px)` },
{ opacity: 1, transform: "translateY(0)" },
],
{
duration: 440,
delay: Math.min(index * 38, 260),
easing: "cubic-bezier(.22,1,.36,1)",
fill: "both",
},
);
// metric-card 的 3D 倾斜与悬停倾斜由 CSS 控制,入场动画只做淡入,
// 否则 fill:both 的 translateY(0) 会覆盖 CSS 的 transform,导致倾斜失效。
const keyframes = element.classList.contains("metric-card")
? [{ opacity: 0 }, { opacity: 1 }]
: [
{ opacity: 0, transform: `translateY(${initial ? 16 : 10}px)` },
{ opacity: 1, transform: "translateY(0)" },
];
element.animate(keyframes, {
duration: 440,
delay: Math.min(index * 38, 260),
easing: "cubic-bezier(.22,1,.36,1)",
fill: "both",
});
});
}
@@ -110,10 +118,18 @@ function writeStoredRecords(key, records) {
function recordStatus(status) {
if (["已启用", "已确认"].includes(status)) return { className: "success", label: status };
if (status === "已退回") return { className: "danger", label: status };
if (status === "异常待处理") return { className: "neutral", label: status };
if (["异常待处理", "已停用"].includes(status)) return { className: "neutral", label: status };
return { className: "warning", label: status || "待复核" };
}
function accountStatusLabel(status) {
return accountStatusLabels[status] || "待复核";
}
function accountTail(masked) {
return String(masked || "").replace(/^\*+/, "");
}
function formatCurrency(value) {
return Number(value).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
@@ -137,13 +153,14 @@ function showToast(title, detail = "") {
function closeNavigation({ restoreFocus = false } = {}) {
const sidebar = $("#sidebar");
const button = $("#menuButton");
if (!sidebar || !button) return;
if (!sidebar) return;
const wasOpen = sidebar.classList.contains("is-open");
sidebar.classList.remove("is-open");
button.setAttribute("aria-expanded", "false");
button.setAttribute("aria-label", "打开导航");
if (restoreFocus && wasOpen) button.focus();
$$(".menu-button").forEach((button) => {
button.setAttribute("aria-expanded", "false");
button.setAttribute("aria-label", "打开导航");
});
if (restoreFocus && wasOpen) $(".menu-button")?.focus();
}
function showView(view) {
@@ -165,6 +182,93 @@ function showView(view) {
window.scrollTo({ top: 0, behavior: motionQuery.matches ? "auto" : "smooth" });
}
const detailContent = {
"gap-a": { tag: ["danger", "高风险"], title: "A公司 · 工行账户断档", desc: "工商银行 9481 缺少 07.01—07.21 流水,已影响 7 月结账。", fields: [["公司", "A公司"], ["账户", "工商银行 · 9481"], ["缺口期间", "2026.07.01—07.21 · 21 天"], ["影响", "7 月结账 · 账户覆盖 · 双边匹配"], ["当前状态", "已逾期 2 天"]], tip: "建议先向 A公司出纳发送补传提醒,补齐后在审核中心复核覆盖区间。", action: ["去审核中心处理", "audit"] },
"match-bd": { tag: ["warning", "中风险"], title: "B公司 ↔ D公司 · 单边待匹配", desc: "D公司侧流水已到,B公司侧尚未确认,合计 312.00 万元。", fields: [["本方", "B公司"], ["对方", "D公司"], ["笔数 / 金额", "6 笔 · 312.00 万元"], ["候选情况", "金额与日期存在 2 个候选"], ["当前状态", "今日新增"]], tip: "建议按账号优先核对候选流水,金额与日期相同者先确认。", action: ["去审核中心匹配", "audit"] },
"calib-f": { tag: ["warning", "中风险"], title: "F公司 · 起算区间待校准", desc: "01.01—01.16 无银行流水覆盖,公司已提交无业务说明。", fields: [["公司", "F公司"], ["账户", "农业银行 · 3650"], ["无覆盖期间", "2026.01.01—01.16"], ["现有依据", "公司已提交无业务说明"], ["当前状态", "待公司确认"]], tip: "无业务说明属于审计证据,复核通过后该区间标记为已校准,不生成银行流水。", action: ["去审核中心复核", "audit"] },
"task-upload": { tag: ["danger", "最紧急"], title: "补传工商银行流水", desc: "账户尾号 9481 缺少 07.01—07.21 流水,已逾期 2 天。", fields: [["账户", "工商银行 · 9481"], ["缺少期间", "2026.07.01—07.21 · 21 天"], ["影响", "7 月账户覆盖与双边匹配"], ["截止", "08.05 集团结账日前"]], tip: "从工商银行网银导出 7 月流水后直接上传,系统会自动识别表头并重新计算匹配。", action: ["去上传流水", "upload"] },
"task-match": { tag: ["warning", "待确认"], title: "确认 1 笔单边流水", desc: "07.18 转出 280.00 万元,系统找到 2 个对方候选。", fields: [["对方", "B公司"], ["日期 / 金额", "07.18 · 280.00 万元"], ["候选", "工商银行 9481(推荐)· 建设银行 2046"], ["核对点", "摘要与账号是否一致"]], tip: "系统推荐账号一致的候选,请核对回单后再确认。", action: ["去往来确认", "reconcile"] },
"task-subject": { tag: ["warning", "待确认"], title: "确认往来科目", desc: "06.27 转出 600.00 万元,规则无法区分应收与其他应收。", fields: [["对方", "C公司"], ["日期 / 金额", "06.27 · 600.00 万元"], ["待确认", "应收 或 其他应收"], ["摘要", "资金调拨"]], tip: "科目只按确定性规则建议,拿不准时选“其他应收”并在说明里注明依据。", action: ["去确认科目", "reconcile"] },
"task-notice": { tag: ["neutral", "提醒"], title: "阅读总账提醒", desc: "管理员要求 08.08 前完成 7 月银行流水确认。", fields: [["来自", "系统管理员 · 今天 09:30"], ["处理期限", "2026.08.08"], ["关联事项", "断档补传 · 2 项待确认往来"]], tip: "完成补传和两项确认后,再提交公司确认即可。", action: ["查看通知", "notifications"] },
"acct-citic": { tag: ["success", "连续"], title: "中信银行 · 5316", desc: "基本户 · 起算日以来流水全部连续。", fields: [["账户类型", "基本户"], ["启用日期", "2026.01.01"], ["流水覆盖", "01.01—07.31 · 7 个月连续"], ["最近导入", "今天 09:42"]], tip: "该账户无需处理,8 月流水到期后正常上传即可。", action: ["查看银行账户", "accounts"] },
"acct-abc": { tag: ["success", "连续"], title: "农业银行 · 3650", desc: "一般户 · 起算日以来流水全部连续。", fields: [["账户类型", "一般户"], ["启用日期", "2026.01.01"], ["流水覆盖", "01.01—07.31 · 7 个月连续"], ["最近导入", "08.01 08:01"]], tip: "该账户无需处理,8 月流水到期后正常上传即可。", action: ["查看银行账户", "accounts"] },
"acct-icbc": { tag: ["danger", "断档"], title: "工商银行 · 9481", desc: "一般户 · 缺少 07.01—07.21 流水,已逾期 2 天。", fields: [["账户类型", "一般户"], ["缺口期间", "2026.07.01—07.21 · 21 天"], ["影响", "7 月结账与双边匹配"], ["最近导入", "07.31 16:186 月批次)"]], tip: "这是最紧急的一项:补齐后系统会自动重算覆盖与匹配。", action: ["去补传流水", "upload"] },
"acct-ccb": { tag: ["success", "连续"], title: "建设银行 · 0845", desc: "一般户 · 起算日以来流水全部连续。", fields: [["账户类型", "一般户"], ["启用日期", "2026.01.01"], ["流水覆盖", "01.01—07.31 · 7 个月连续"], ["最近导入", "08.01 10:03"]], tip: "该账户无需处理,8 月流水到期后正常上传即可。", action: ["查看银行账户", "accounts"] },
};
function initDetailDrawer() {
const triggers = $$("[data-detail]");
if (!triggers.length) return;
const drawer = document.createElement("aside");
drawer.className = "detail-drawer";
drawer.id = "detailDrawer";
drawer.setAttribute("aria-label", "事项详情");
drawer.innerHTML = `<header><div><span class="status" id="detailTag"></span><h2 id="detailTitle"></h2><p class="detail-desc" id="detailDesc"></p></div><button type="button" class="icon-button" data-close-detail aria-label="关闭详情" title="关闭详情"><svg><use href="icons.svg#x"/></svg></button></header><dl class="detail-fields" id="detailFields"></dl><div class="detail-tip" id="detailTip"></div><footer><button type="button" class="button secondary" data-close-detail>关闭</button><button type="button" class="button primary" id="detailAction"></button></footer>`;
document.body.append(drawer);
let lastTrigger = null;
function closeDrawer({ restoreFocus = true } = {}) {
drawer.classList.remove("is-open");
if (restoreFocus && lastTrigger) lastTrigger.focus();
}
function openDetail(id, trigger) {
const item = detailContent[id];
if (!item) return;
lastTrigger = trigger;
const tag = $("#detailTag", drawer);
tag.className = `status ${item.tag[0]}`;
tag.textContent = item.tag[1];
$("#detailTitle", drawer).textContent = item.title;
$("#detailDesc", drawer).textContent = item.desc;
const fields = $("#detailFields", drawer);
fields.replaceChildren(...item.fields.map(([label, value]) => {
const row = document.createElement("div");
const dt = document.createElement("dt"); dt.textContent = label;
const dd = document.createElement("dd"); dd.textContent = value;
row.append(dt, dd);
return row;
}));
const tip = $("#detailTip", drawer);
tip.replaceChildren();
const tipHeading = document.createElement("strong"); tipHeading.textContent = "处理建议";
tip.append(tipHeading, document.createTextNode(item.tip));
const action = $("#detailAction", drawer);
action.textContent = item.action[0];
action.onclick = () => {
closeDrawer({ restoreFocus: false });
if (item.action[1] === "upload") $("[data-open-upload]")?.click();
else showView(item.action[1]);
};
drawer.classList.add("is-open");
$("[data-close-detail]", drawer).focus();
}
$$("[data-close-detail]", drawer).forEach((button) => button.addEventListener("click", () => closeDrawer()));
drawer.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
event.stopPropagation();
closeDrawer();
}
});
triggers.forEach((element) => {
element.addEventListener("click", (event) => {
const innerButton = event.target.closest("button");
if (innerButton && innerButton !== element) return;
openDetail(element.dataset.detail, element);
});
if (element.tagName !== "BUTTON") {
element.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openDetail(element.dataset.detail, element);
}
});
}
});
}
function initEntry() {
const form = $("#loginForm");
if (!form) return;
@@ -178,38 +282,131 @@ function initEntry() {
}
const roleInputs = $$('input[name="role"]', form);
const username = $('input[name="username"]', form);
const password = $('input[name="password"]', form);
const action = $("#loginAction");
const errorBox = $("#loginError");
const changeSection = $("#changePassword");
let pendingRole = null;
function showError(message) {
errorBox.textContent = message;
errorBox.hidden = false;
}
function updateRole() {
const role = $('input[name="role"]:checked', form).value;
action.textContent = role === "admin" ? "进入总账管理端" : "进入公司业务端";
username.value = role === "admin" ? "group-admin" : "a-cashier";
}
roleInputs.forEach((input) => input.addEventListener("change", updateRole));
$("#togglePassword").addEventListener("click", (event) => {
const password = $('input[name="password"]', form);
const visible = password.type === "text";
password.type = visible ? "password" : "text";
event.currentTarget.setAttribute("aria-label", visible ? "显示密码" : "隐藏密码");
event.currentTarget.title = visible ? "显示密码" : "隐藏密码";
});
form.addEventListener("submit", (event) => {
form.addEventListener("submit", async (event) => {
event.preventDefault();
const role = $('input[name="role"]:checked', form).value;
errorBox.hidden = true;
const role = pendingRole || $('input[name="role"]:checked', form).value;
if (pendingRole) {
const newPassword = $('input[name="new_password"]', form).value;
const confirmPassword = $('input[name="confirm_password"]', form).value;
if (newPassword !== confirmPassword) {
showError("两次输入的新密码不一致。");
return;
}
const changeResponse = await fetch("/api/password/change", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ old_password: password.value, new_password: newPassword }),
}).catch(() => null);
const changeResult = await changeResponse?.json().catch(() => ({}));
if (!changeResponse || !changeResponse.ok) {
showError(changeResult?.message || "修改密码失败,请稍后重试。");
return;
}
window.location.href = role === "admin" ? "admin.html" : "company.html";
return;
}
const response = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: username.value.trim(), password: password.value, portal: role }),
}).catch(() => null);
const result = await response?.json().catch(() => ({}));
if (!response || !response.ok) {
showError(result?.message || "登录服务暂时不可用,请稍后重试。");
return;
}
if (result.must_change_password) {
pendingRole = role;
changeSection.hidden = false;
action.textContent = "设置新密码并进入";
$('input[name="new_password"]', form).focus();
return;
}
window.location.href = role === "admin" ? "admin.html" : "company.html";
});
}
async function initAuthGuard() {
if (portal === "entry") return true;
try {
const response = await fetch("/api/me");
if (response.status === 401) {
window.location.href = "index.html";
return false;
}
const me = await response.json();
if (!response.ok || me.role !== portal) {
window.location.href = "index.html";
return false;
}
state.me = me;
applyCompanyIdentity(me);
return true;
} catch {
return true;
}
}
function applyCompanyIdentity(me) {
// The company portal always shows the session-bound company, never a
// hard-coded one.
if (portal !== "company" || !me?.company_name) return;
const context = $(".company-context");
if (context) {
const mark = $("span", context);
if (mark) mark.textContent = me.company_name.slice(0, 1);
const name = $("strong", context);
if (name) name.textContent = me.company_name;
}
}
function initShell() {
const menuButton = $("#menuButton");
if (menuButton) {
$$(".page-heading").forEach((heading) => {
if (heading.querySelector(".menu-button")) return;
const button = document.createElement("button");
button.type = "button";
button.className = "icon-button menu-button";
button.setAttribute("aria-label", "打开导航");
button.setAttribute("aria-expanded", "false");
button.setAttribute("aria-controls", "sidebar");
button.title = "打开导航";
button.innerHTML = '<svg><use href="icons.svg#menu"></use></svg>';
heading.prepend(button);
});
$$(".menu-button").forEach((menuButton) => {
menuButton.addEventListener("click", () => {
const open = $("#sidebar").classList.toggle("is-open");
menuButton.setAttribute("aria-expanded", String(open));
menuButton.setAttribute("aria-label", open ? "关闭导航" : "打开导航");
});
}
});
$$(".nav-item").forEach((item) => {
const label = $("span", item)?.textContent.trim();
@@ -220,6 +417,13 @@ function initShell() {
});
$$("[data-view]").forEach((button) => button.addEventListener("click", () => showView(button.dataset.view)));
$$("[data-view-link]").forEach((button) => button.addEventListener("click", () => showView(button.dataset.viewLink)));
$$('a.nav-item[href="index.html"]').forEach((link) => link.addEventListener("click", async (event) => {
event.preventDefault();
try {
await fetch("/api/logout", { method: "POST" });
} catch { /* 网络异常时仍然回到登录页 */ }
window.location.href = "index.html";
}));
$$("[data-metric-link]").forEach((card) => {
const activate = () => showView(card.dataset.metricLink);
card.addEventListener("click", activate);
@@ -240,7 +444,11 @@ function initShell() {
}
});
});
$$("[data-toast]").forEach((button) => button.addEventListener("click", () => showToast(button.dataset.toast)));
// Delegated: company table rows are rendered from the API after init.
document.addEventListener("click", (event) => {
const toastButton = event.target.closest("[data-toast]");
if (toastButton) showToast(toastButton.dataset.toast);
});
$(".nav-item[data-view].is-active")?.setAttribute("aria-current", "page");
$("#globalSearch")?.addEventListener("input", (event) => {
@@ -252,12 +460,13 @@ function initShell() {
});
document.addEventListener("click", (event) => {
if ($("#sidebar")?.classList.contains("is-open") && !event.target.closest("#sidebar") && !event.target.closest("#menuButton")) closeNavigation({ restoreFocus: true });
if ($("#sidebar")?.classList.contains("is-open") && !event.target.closest("#sidebar") && !event.target.closest(".menu-button")) closeNavigation({ restoreFocus: true });
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") closeNavigation({ restoreFocus: true });
});
initDetailDrawer();
initMotion();
}
@@ -407,59 +616,85 @@ function renderCompanyManualRecords() {
if ($("#manualPendingStatus")) $("#manualPendingStatus").textContent = `${pending} 笔待总账复核`;
}
function addApprovedAccountOption(record) {
if (record.status !== "已启用") return;
const value = `${record.bank} · ${record.accountNumber.slice(-4)}`;
function fillAccountSelects(accounts) {
const usable = accounts.filter((account) => account.usable);
[$("#accountSelect"), $('#manualEntryForm [name="sourceAccount"]')].forEach((select) => {
if (!select || [...select.options].some((option) => option.value === value)) return;
const option = new Option(value, value);
option.dataset.submittedAccount = record.id;
select.add(option);
if (!select) return;
const kept = [...select.options].filter((option) => option.value === "" || option.textContent === "个人过账");
select.replaceChildren(...kept);
usable.forEach((account) => {
const value = `${account.bank_name} · ${accountTail(account.account_number_masked)}`;
const option = new Option(value, value);
option.dataset.accountId = account.id;
select.add(option);
});
});
}
function renderCompanyAccountSubmissions() {
function renderCompanyAccounts(accounts) {
const directory = $("#accountDirectory");
if (!directory) return;
$$('[data-submitted-account]', directory).forEach((item) => item.remove());
const records = readStoredRecords(storageKeys.accounts).filter((record) => record.company === "A公司");
records.forEach((record) => {
const article = document.createElement("article");
article.dataset.submittedAccount = record.id;
const header = document.createElement("header");
const mark = document.createElement("span"); mark.className = "bank-mark"; mark.textContent = record.bank.slice(0, 1);
const identity = document.createElement("div");
const name = document.createElement("strong"); name.textContent = record.bank;
const meta = document.createElement("small"); meta.textContent = `${record.type} · 尾号 ${record.accountNumber.slice(-4)}`;
identity.append(name, meta);
const status = recordStatus(record.status);
const badge = document.createElement("em"); badge.className = `status ${status.className}`; badge.textContent = status.label;
header.append(mark, identity, badge);
const details = document.createElement("dl");
[["申请启用", record.startDate], ["流水覆盖", record.status === "已启用" ? "尚未上传" : "不参与计算"], ["提交时间", record.createdAt]].forEach(([term, value]) => {
const wrapper = document.createElement("div");
const dt = document.createElement("dt"); dt.textContent = term;
const dd = document.createElement("dd"); dd.textContent = value;
wrapper.append(dt, dd); details.append(wrapper);
});
article.append(header, details); directory.append(article);
addApprovedAccountOption(record);
});
if (directory) {
directory.replaceChildren(...accounts.map((account) => {
const article = document.createElement("article");
const header = document.createElement("header");
const mark = document.createElement("span"); mark.className = "bank-mark"; mark.textContent = account.bank_name.slice(0, 1);
const identity = document.createElement("div");
const name = document.createElement("strong"); name.textContent = account.bank_name;
const meta = document.createElement("small"); meta.textContent = `${account.account_type} · 尾号 ${accountTail(account.account_number_masked)}`;
identity.append(name, meta);
const status = recordStatus(accountStatusLabel(account.status));
const badge = document.createElement("em"); badge.className = `status ${status.className}`; badge.textContent = status.label;
header.append(mark, identity, badge);
const details = document.createElement("dl");
const rows = [
["申请启用", account.effective_from || "待审核确定"],
["流水覆盖", account.usable ? "尚未上传" : "不参与计算"],
["提交时间", String(account.created_at || "").slice(0, 10) || "—"],
];
if (account.status === "returned" && account.review_reason) rows.push(["退回原因", account.review_reason]);
if (account.status === "disabled" && account.effective_to) rows.push(["停用日期", account.effective_to]);
rows.forEach(([term, value]) => {
const wrapper = document.createElement("div");
const dt = document.createElement("dt"); dt.textContent = term;
const dd = document.createElement("dd"); dd.textContent = value;
wrapper.append(dt, dd); details.append(wrapper);
});
article.append(header, details);
return article;
}));
}
fillAccountSelects(accounts);
}
async function loadCompanyAccounts() {
const response = await fetch("/api/company/accounts").catch(() => null);
if (response?.status === 401 || response?.status === 403) {
window.location.href = "index.html";
return;
}
if (!response?.ok) return;
const result = await response.json().catch(() => null);
if (result?.accounts) renderCompanyAccounts(result.accounts);
}
function appendAdminReviewRow(record, kind) {
const tbody = $("#auditRows");
if (!tbody) return;
const isAccount = kind === "account";
// Account rows come from the server (full number visible only in this
// authorized admin view); manual records are still browser-local demo data.
const statusLabel = isAccount ? accountStatusLabel(record.status) : record.status;
const row = document.createElement("tr");
row.dataset.storedReview = record.id;
row.dataset.recordId = record.id;
row.dataset.recordKind = kind;
row.dataset.auditType = kind === "account" ? "账户" : "手工";
row.dataset.company = record.company;
row.dataset.evidence = kind === "account"
if (isAccount) row.dataset.accountId = record.id;
row.dataset.auditType = isAccount ? "账户" : "手工";
row.dataset.company = isAccount ? record.company_name : record.company;
row.dataset.evidence = isAccount
? "公司提交资料、开户行、账号、账户类型与启用日期"
: "公司手工记录、关联银行流水号、证明附件与提交说明";
if (record.status !== "待复核" && record.status !== "待总账复核") row.dataset.resolved = "true";
if (statusLabel !== "待复核" && statusLabel !== "待总账复核") row.dataset.resolved = "true";
const riskCell = document.createElement("td");
const risk = document.createElement("span"); risk.className = "task-level warning"; risk.textContent = "中"; riskCell.append(risk);
@@ -469,11 +704,11 @@ function appendAdminReviewRow(record, kind) {
const typeCell = document.createElement("td");
const periodCell = document.createElement("td");
const impactCell = document.createElement("td");
if (kind === "account") {
identity.textContent = `${record.company} · ${record.bank} ${record.accountNumber.slice(-4)}`;
detail.textContent = `${record.type} · 完整账号 ${record.accountNumber}`;
if (isAccount) {
identity.textContent = `${record.company_name} · ${record.bank_name} ${String(record.account_number).slice(-4)}`;
detail.textContent = `${record.account_type} · 完整账号 ${record.account_number}`;
typeCell.textContent = "账户登记";
periodCell.textContent = record.startDate;
periodCell.textContent = record.effective_from || "待审核确定";
impactCell.textContent = "账户识别与流水上传";
} else {
identity.textContent = `${record.company} · ${record.id}`;
@@ -484,37 +719,53 @@ function appendAdminReviewRow(record, kind) {
}
identityCell.append(identity, detail);
const statusCell = document.createElement("td");
const status = recordStatus(record.status);
const status = recordStatus(statusLabel);
const badge = document.createElement("span"); badge.className = `status ${status.className}`; badge.textContent = status.label; statusCell.append(badge);
const actionCell = document.createElement("td");
const action = document.createElement("button"); action.className = "text-button"; action.dataset.auditAction = "";
action.textContent = row.dataset.resolved ? "查看记录" : "复核";
if (isAccount) row.dataset.accountStatus = record.status;
action.textContent = row.dataset.resolved
? (isAccount && record.status === "active" ? "管理" : "查看记录")
: "复核";
if (row.dataset.resolved) {
action.dataset.record = `${record.decision || record.status} · ${record.reviewReason || "已留痕"}`;
action.dataset.decision = record.decision || record.status;
action.dataset.reason = record.reviewReason || "已留痕";
action.dataset.processedAt = record.reviewedAt || "时间未记录";
const decisionLabels = { active: "复核通过并启用账户", returned: "退回公司修改", disabled: "停用并驳回" };
const decisionLabel = isAccount ? decisionLabels[record.status] || statusLabel : record.decision || record.status;
const reasonText = (isAccount ? record.review_reason : record.reviewReason) || "已留痕";
action.dataset.record = `${decisionLabel} · ${reasonText}`;
action.dataset.decision = decisionLabel;
action.dataset.reason = reasonText;
action.dataset.processedAt = (isAccount ? record.reviewed_at : record.reviewedAt) || "时间未记录";
}
actionCell.append(action);
row.append(riskCell, identityCell, typeCell, periodCell, impactCell, statusCell, actionCell);
tbody.append(row);
}
async function renderAdminAccountReviews() {
const tbody = $("#auditRows");
if (!tbody) return;
$$('[data-stored-review][data-record-kind="account"]', tbody).forEach((row) => row.remove());
const response = await fetch("/api/admin/accounts").catch(() => null);
if (!response?.ok) return;
const result = await response.json().catch(() => null);
(result?.accounts || []).forEach((account) => appendAdminReviewRow(account, "account"));
updateAuditCounts();
}
function renderStoredAdminReviews() {
if (!$("#auditRows")) return;
$$('[data-stored-review]', $("#auditRows")).forEach((row) => row.remove());
readStoredRecords(storageKeys.accounts).forEach((record) => appendAdminReviewRow(record, "account"));
readStoredRecords(storageKeys.manual).forEach((record) => appendAdminReviewRow(record, "manual"));
renderAdminAccountReviews();
}
function updateStoredReview(kind, id, status, decision, reviewReason, reviewedAt) {
if (!kind || !id) return;
const key = kind === "account" ? storageKeys.accounts : storageKeys.manual;
const records = readStoredRecords(key);
if (kind !== "manual" || !id) return;
const records = readStoredRecords(storageKeys.manual);
const record = records.find((item) => item.id === id);
if (!record) return;
Object.assign(record, { status, decision, reviewReason, reviewedAt });
writeStoredRecords(key, records);
writeStoredRecords(storageKeys.manual, records);
}
function updateAuditCounts() {
@@ -529,9 +780,71 @@ function updateAuditCounts() {
if (badge) badge.textContent = unresolved.length;
}
function companyStatusBadge(status) {
if (status === "preparing") return { className: "neutral", label: "筹备中" };
if (status === "disabled") return { className: "danger", label: "已停用" };
return { className: "success", label: "正常" };
}
function renderAdminCompanyTable(companies) {
const tbody = $("#companyTable tbody");
if (!tbody) return;
tbody.replaceChildren(...companies.map((company) => {
const row = document.createElement("tr");
const nameCell = document.createElement("td");
const name = document.createElement("strong"); name.textContent = company.name;
const code = document.createElement("small"); code.textContent = `COMP-${String(company.id).padStart(3, "0")}`;
nameCell.append(name, code);
const credit = document.createElement("td"); credit.textContent = company.credit_code || "待补充";
const accounts = document.createElement("td"); accounts.textContent = `${company.account_count ?? 0}`;
const usernames = document.createElement("td"); usernames.textContent = company.usernames || "未创建";
const cashier = document.createElement("td"); cashier.textContent = company.cashier_name || "未指定";
const statusCell = document.createElement("td");
const badge = companyStatusBadge(company.status);
statusCell.innerHTML = `<span class="status ${badge.className}">${badge.label}</span>`;
const actionCell = document.createElement("td");
actionCell.innerHTML = `<button class="text-button" data-toast="已打开 ${company.name} 主档">管理</button>`;
row.append(nameCell, credit, accounts, usernames, cashier, statusCell, actionCell);
return row;
}));
}
function setSelectOptions(select, names, { keepFirst = false } = {}) {
if (!select || !names.length) return;
const kept = keepFirst && select.options.length ? [select.options[0].cloneNode(true)] : [];
select.replaceChildren(...kept, ...names.map((name) => new Option(name, name)));
}
function fillCompanySelects(names) {
// 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.
$$("[data-pair-form]").forEach((form) => {
setSelectOptions($('[name="from"]', form), names);
setSelectOptions($('[name="to"]', form), names);
const toSelect = $('[name="to"]', form);
if (toSelect && names.length > 1) toSelect.value = names[1];
});
setSelectOptions($("#auditCompany"), names, { keepFirst: true });
setSelectOptions($("#flowCompany"), names, { keepFirst: true });
setSelectOptions($('#reminderForm [name="company"]'), names, { keepFirst: true });
setSelectOptions($('#openingDialog [name="from"]'), names);
setSelectOptions($('#openingDialog [name="to"]'), names);
}
async function loadAdminCompanies() {
const response = await fetch("/api/admin/companies").catch(() => null);
if (!response?.ok) return;
const result = await response.json().catch(() => null);
const companies = result?.companies || [];
renderAdminCompanyTable(companies);
fillCompanySelects(companies.map((company) => company.name));
}
function initAdmin() {
renderStoredAdminReviews();
updateAuditCounts();
loadAdminCompanies();
const companySearch = $('[data-filter-target="companyLedgers"]');
companySearch?.addEventListener("input", () => {
const query = companySearch.value.trim().toLowerCase();
@@ -560,7 +873,10 @@ function initAdmin() {
const auditDialog = $("#auditDialog");
$$('[data-close-audit]').forEach((button) => button.addEventListener("click", () => auditDialog.close()));
$$("[data-audit-action]").forEach((button) => button.addEventListener("click", () => {
// Delegated: account review rows arrive asynchronously from the API.
$("#auditRows")?.addEventListener("click", (event) => {
const button = event.target.closest("[data-audit-action]");
if (!button) return;
const row = button.closest("tr");
state.auditRow = row;
const cells = $$('td', row);
@@ -569,8 +885,9 @@ function initAdmin() {
const reason = $('[name="reason"]', form);
const submit = $('button[type="submit"]', form);
form.reset();
const isActiveAccount = row.dataset.recordKind === "account" && row.dataset.accountStatus === "active";
const decisions = row.dataset.recordKind === "account"
? ["复核通过并启用账户", "退回公司修改", "停用并驳回"]
? (isActiveAccount ? ["停用并驳回"] : ["复核通过并启用账户", "退回公司修改", "停用并驳回"])
: ["确认并纳入计算", "退回公司补充材料", "转为异常待后续处理"];
decision.replaceChildren(new Option("请选择", ""), ...decisions.map((item) => new Option(item, item)));
decision.disabled = false;
@@ -584,7 +901,7 @@ function initAdmin() {
const detail = document.createElement("small"); detail.textContent = cells[1].querySelector("small").textContent;
const source = document.createElement("small"); source.textContent = `证据:${row.dataset.evidence || "银行原始行、导入批次、账户覆盖区间与公司说明"}`;
evidence.append(heading, detail, source);
if (button.dataset.record) {
if (button.dataset.record && !isActiveAccount) {
decision.value = button.dataset.decision;
reason.value = button.dataset.reason;
decision.disabled = true;
@@ -596,28 +913,57 @@ function initAdmin() {
evidence.append(record);
}
auditDialog.showModal();
}));
$("#auditForm")?.addEventListener("submit", (event) => {
});
$("#auditForm")?.addEventListener("submit", async (event) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
const row = state.auditRow;
const decision = String(data.get("decision"));
const reason = String(data.get("reason"));
const approved = decision.includes("通过") || decision.includes("确认并纳入");
const returned = decision.includes("退回");
const storedStatus = approved
? (row.dataset.recordKind === "account" ? "已启用" : "已确认")
: (returned ? "已退回" : "异常待处理");
let storedStatus;
let reviewedAccount = null;
if (row.dataset.recordKind === "account" && row.dataset.accountId) {
// Server-side review: the account only becomes usable after this succeeds.
const apiDecision = approved ? "approve" : returned ? "return" : "disable";
const response = await fetch(`/api/admin/accounts/${row.dataset.accountId}/review`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ decision: apiDecision, reason }),
}).catch(() => null);
if (response?.status === 401) {
window.location.href = "index.html";
return;
}
const result = await response?.json().catch(() => ({}));
if (!response || !response.ok) {
showToast("审核结果提交失败", result?.message || "请稍后重试");
return;
}
reviewedAccount = result.account;
storedStatus = accountStatusLabel(result.account?.status);
} else {
storedStatus = approved
? (row.dataset.recordKind === "account" ? "已启用" : "已确认")
: (returned ? "已退回" : "异常待处理");
updateStoredReview(row.dataset.recordKind, row.dataset.recordId, storedStatus, decision, reason, new Date().toLocaleString("zh-CN", { hour12: false }));
}
const status = recordStatus(storedStatus);
const statusCell = row.children[5];
statusCell.innerHTML = `<span class="status ${status.className}">${status.label}</span>`;
row.dataset.resolved = "true";
const action = $("[data-audit-action]", row);
action.textContent = "查看记录";
if (reviewedAccount) {
row.dataset.accountStatus = reviewedAccount.status;
action.textContent = reviewedAccount.status === "active" ? "管理" : "查看记录";
} else {
action.textContent = "查看记录";
}
action.dataset.record = `${decision} · ${data.get("reason")}`;
action.dataset.decision = decision;
action.dataset.reason = data.get("reason");
action.dataset.processedAt = new Date().toLocaleString("zh-CN", { hour12: false });
updateStoredReview(row.dataset.recordKind, row.dataset.recordId, storedStatus, decision, String(data.get("reason")), action.dataset.processedAt);
updateAuditCounts();
auditDialog.close();
event.currentTarget.reset();
@@ -626,29 +972,42 @@ function initAdmin() {
const dialog = $("#companyDialog");
$("#openCompanyDialog")?.addEventListener("click", () => dialog.showModal());
$("#companyForm")?.addEventListener("submit", (event) => {
$("#companyForm")?.addEventListener("submit", async (event) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
const row = document.createElement("tr");
const values = [data.get("companyName"), data.get("creditCode") || "待补充", "0 个", data.get("loginName"), data.get("cashier")];
values.forEach((value, index) => {
const cell = document.createElement("td");
if (index === 0) {
const strong = document.createElement("strong");
strong.textContent = value;
cell.append(strong);
} else cell.textContent = value;
row.append(cell);
});
const statusCell = document.createElement("td");
statusCell.innerHTML = '<span class="status success">正常</span>';
const actionCell = document.createElement("td");
actionCell.innerHTML = '<button class="text-button">管理</button>';
row.append(statusCell, actionCell);
$("#companyTable tbody").append(row);
const companyName = String(data.get("companyName") || "").trim();
const loginName = String(data.get("loginName") || "").trim();
const createUser = data.get("createUser") !== null;
const payload = {
name: companyName,
credit_code: String(data.get("creditCode") || "").trim(),
cashier_name: String(data.get("cashier") || "").trim(),
};
if (createUser && loginName) payload.username = loginName;
const response = await fetch("/api/admin/companies", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}).catch(() => null);
if (response?.status === 401) {
window.location.href = "index.html";
return;
}
const result = await response?.json().catch(() => ({}));
if (!response || !response.ok) {
showToast("公司创建失败", result?.message || "请稍后重试");
return;
}
const accountCreated = Boolean(result.username);
dialog.close();
event.currentTarget.reset();
showToast("公司与账号已创建", "初始密码:ChangeMe2026(演示)");
await loadAdminCompanies();
showToast(
accountCreated ? "公司与账号已创建" : "公司已创建",
accountCreated ? `账号 ${result.username} 的初始密码与登录账号相同,首次登录必须修改` : "可稍后在账号管理中创建公司账号",
);
});
$("#systemSettings")?.addEventListener("submit", (event) => {
@@ -826,22 +1185,31 @@ function acceptFile(file) {
async function parseFile() {
const formData = new FormData();
formData.append("file", state.selectedFile);
const selectedAccount = $("#accountSelect")?.selectedOptions?.[0];
if (selectedAccount?.dataset.accountId) {
formData.append("bank_account_id", selectedAccount.dataset.accountId);
}
let result;
let parsed = false;
try {
const response = await fetch("/api/parse", { method: "POST", body: formData });
if (response.status === 401 || response.status === 403) {
window.location.href = "index.html";
return;
}
result = await response.json();
parsed = response.ok && result.status === "parsed";
parsed = response.ok && ["parsed", "duplicate"].includes(result.status);
} catch {
result = { status: "error", message: "解析服务暂时不可用,请稍后重试。" };
}
state.parseResult = result;
const duplicated = result.status === "duplicate";
const panel = $("#parseResult");
panel.classList.toggle("is-exception", !parsed);
$("use", panel).setAttribute("href", parsed ? "icons.svg#circle-check" : "icons.svg#circle-alert");
$("strong", panel).textContent = parsed ? "文件解析完成" : "未识别到银行模板";
$("strong", panel).textContent = duplicated ? "文件已导入过" : parsed ? "文件解析完成" : "未识别到银行模板";
$("#parseSummary").textContent = parsed
? `${result.bank} · 表头第 ${result.header_row} 行 · ${result.transactions} 条明细 · ${result.warnings.length ? `${result.warnings.length} 项提示` : "校验通过"}`
? `${result.bank} · 表头第 ${result.header_row} 行 · ${result.transactions} 条明细 · ${result.warnings.length ? `${result.warnings.length} 项提示` : "校验通过"}${duplicated ? " · 重复上传,复用已有批次" : ""}`
: `${result.message} 系统不会猜测模板或自动入账。`;
panel.hidden = false;
$("#parseButton span").textContent = parsed ? "确认导入" : "提交异常";
@@ -866,7 +1234,7 @@ function submitImportException() {
function initCompany() {
renderCompanyManualRecords();
renderCompanyAccountSubmissions();
loadCompanyAccounts();
const uploadDialog = $("#uploadDialog");
$$('[data-open-upload]').forEach((button) => button.addEventListener("click", () => uploadDialog.showModal()));
$$('[data-close-upload]').forEach((button) => button.addEventListener("click", () => uploadDialog.close()));
@@ -890,7 +1258,7 @@ function initCompany() {
$("#uploadForm")?.addEventListener("submit", async (event) => {
event.preventDefault();
if ($("#parseButton").dataset.stage === "confirm") {
if (state.parseResult?.status === "parsed") confirmImport();
if (["parsed", "duplicate"].includes(state.parseResult?.status)) confirmImport();
else submitImportException();
return;
}
@@ -973,29 +1341,33 @@ function initCompany() {
const accountDialog = $("#accountDialog");
$("#openAccountDialog")?.addEventListener("click", () => accountDialog.showModal());
$$('[data-close-account]').forEach((button) => button.addEventListener("click", () => accountDialog.close()));
$("#accountForm")?.addEventListener("submit", (event) => {
$("#accountForm")?.addEventListener("submit", async (event) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
const accountNumber = String(data.get("accountNumber")).replace(/[\s-]/g, "");
const records = readStoredRecords(storageKeys.accounts);
if (records.some((record) => record.accountNumber === accountNumber && record.status !== "已退回")) {
showToast("该银行账号已登记", "请等待现有申请处理,或联系总账管理员核对");
// The server binds the account to the session company and normalizes the
// number; duplicates come back as 409.
const response = await fetch("/api/company/accounts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
bank_name: String(data.get("bank") || "").trim(),
account_type: String(data.get("type") || ""),
account_number: String(data.get("accountNumber") || ""),
start_date: String(data.get("startDate") || ""),
}),
}).catch(() => null);
if (response?.status === 401 || response?.status === 403) {
window.location.href = "index.html";
return;
}
const result = await response?.json().catch(() => ({}));
if (!response || !response.ok) {
showToast("账户登记失败", result?.message || "请稍后重试");
return;
}
records.push({
id: `ACC-${Date.now().toString().slice(-10)}`,
company: "A公司",
bank: String(data.get("bank")).trim(),
type: String(data.get("type")),
accountNumber,
startDate: String(data.get("startDate")),
status: "待复核",
createdAt: new Date().toLocaleString("zh-CN", { hour12: false }),
});
if (!writeStoredRecords(storageKeys.accounts, records)) return;
renderCompanyAccountSubmissions();
accountDialog.close();
event.currentTarget.reset();
await loadCompanyAccounts();
showToast("银行账户已提交登记", "复核通过前不能上传流水,也不参与账户识别和覆盖计算");
});
}
@@ -1003,12 +1375,15 @@ function initCompany() {
if (portal === "entry") {
initEntry();
} else {
initShell();
initFlowTools();
if (portal === "admin") {
initPairQueries();
initAdmin();
} else {
initCompany();
}
initAuthGuard().then((allowed) => {
if (!allowed) return;
initShell();
initFlowTools();
if (portal === "admin") {
initPairQueries();
initAdmin();
} else {
initCompany();
}
});
}