Files
tiku-backend.net/Tiku.Api/wwwroot/platform-admin/app.js

1352 lines
200 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const mainView = document.querySelector('#mainView');
const sideNav = document.querySelector('#sideNav');
const pageTitle = document.querySelector('#pageTitle');
const toast = document.querySelector('.toast');
const app = document.querySelector('.platform-app');
const overlayRoot = document.querySelector('#overlayRoot');
const roleSelect = document.querySelector('#roleSelect');
const scenarioSelect = document.querySelector('#scenarioSelect');
const responseSelect = document.querySelector('#responseSelect');
const menuButton = document.querySelector('#menuButton');
const taskCount = document.querySelector('#taskCount');
const alertCount = document.querySelector('#alertCount');
const platformApi = window.GongxuePlatformApi;
const STORAGE_KEY = 'gongxue-platform-spec-v1.1';
const TENANT_PORTAL_ORIGIN = 'http://127.0.0.1:4174/';
const HISTORY_APP_KEY = 'gongxuePlatform';
const API_CONNECTED_PAGES = new Set(['overview', 'tenants', 'tenantDetail', 'billingProfile', 'staff', 'audit', 'alerts']);
const pageMeta = {
overview: { id: 'P-01', title: '平台经营工作台', permission: 'platform:overview:read' },
tenants: { id: 'P-02', title: '租户列表与创建', permission: 'platform:tenant:read' },
tenantDetail: { id: 'P-03', title: '租户详情与生命周期', permission: 'platform:tenant:read', level: 2 },
billingProfile: { id: 'P-04', title: '租户账务资料', permission: 'platform:tenant:billing_profile', level: 3 },
subscriptions: { id: 'P-05', title: 'SaaS 套餐与订阅', permission: 'platform:plan:read' },
billing: { id: 'P-06', title: '服务费账单', permission: 'platform:billing:read' },
usage: { id: 'P-07', title: '用量与超额计费', permission: 'platform:usage:read' },
dunning: { id: 'P-08', title: '收款、逾期与催缴', permission: 'platform:billing:read' },
bank: { id: 'P-09', title: '公共题库目录与授权', permission: 'platform:question_bank:read' },
staff: { id: 'P-10', title: '平台员工与权限', permission: 'platform:staff:read' },
audit: { id: 'P-11', title: '平台审计日志', permission: 'platform:audit:read' },
alerts: { id: 'P-12', title: '审计告警与通知', permission: 'platform:audit:alert' },
};
const navGroups = [
['平台概览', [['overview', 'gauge', '经营工作台']]],
['租户管理', [['tenants', 'building-2', '租户列表']]],
['SaaS 账务', [['subscriptions', 'badge-check', '套餐与订阅'], ['billing', 'receipt-text', '服务费账单'], ['usage', 'chart-column-big', '用量与超额'], ['dunning', 'bell-ring', '收款与催缴']]],
['平台资产', [['bank', 'database-zap', '公共题库']]],
['安全治理', [['staff', 'user-cog', '员工与权限'], ['audit', 'scroll-text', '审计日志'], ['alerts', 'shield-alert', '告警与通知']]],
];
const roleDefinitions = {
super: { label: '超级管理员', permissions: ['*'] },
tenant_ops: { label: '租户运营', permissions: ['platform:overview:read', 'platform:tenant:read', 'platform:tenant:write', 'platform:tenant:status', 'platform:tenant:billing_profile', 'platform:plan:read', 'platform:billing:read', 'platform:question_bank:read'] },
finance: { label: '平台财务', permissions: ['platform:overview:read', 'platform:tenant:read', 'platform:tenant:billing_profile', 'platform:plan:read', 'platform:billing:read', 'platform:billing:write', 'platform:billing:payment', 'platform:billing:dunning', 'platform:billing:notification', 'platform:usage:read', 'platform:usage:write', 'platform:audit:read'] },
audit: { label: '安全审计', permissions: ['platform:overview:read', 'platform:audit:read', 'platform:audit:export', 'platform:audit:alert', 'platform:audit:notification'] },
bank_ops: { label: '题库运营', permissions: ['platform:overview:read', 'platform:question_bank:read', 'platform:question_bank:grant', 'platform:question_bank:ops'] },
readonly: { label: '只读观察员', permissions: ['platform:overview:read', 'platform:tenant:read', 'platform:plan:read', 'platform:billing:read', 'platform:question_bank:read', 'platform:audit:read'] },
};
const seed = {
version: 11,
tenants: [
{ id: 'tenant_gongxue_tj', slug: 'gongxue-tj', name: '恭学教育 · 天津', legal: '天津恭学教育科技有限公司', brand: '恭学教育', shortName: '恭学天津', host: 'study.gongxue.cn', plan: '旗舰版', planCode: 'enterprise', status: '正常', billing: '正常', mode: '正式', students: 2846, questions: 42680, storageGB: 186, trafficTB: 1.8, expires: '2027-07-22', balance: 0, owner: '张校长', ownerPhone: '138****6008', createdAt: '2025-07-22 10:18' },
{ id: 'tenant_qihang_gd', slug: 'qihang-gd', name: '启航专升本 · 广东', legal: '广州启航教育咨询有限公司', brand: '启航专升本', shortName: '启航广东', host: 'study.qihangzsb.cn', plan: '专业版', planCode: 'pro', status: '正常', billing: '待付款', mode: '正式', students: 5280, questions: 68420, storageGB: 248, trafficTB: 3.2, expires: '2026-08-05', balance: 12800, owner: '林老师', ownerPhone: '139****2288', createdAt: '2025-08-05 09:12' },
{ id: 'tenant_bosi_sd', slug: 'bosi-sd', name: '博思升本 · 山东', legal: '山东博思教育集团有限公司', brand: '博思升本', shortName: '博思山东', host: 'tiku.bosiedu.cn', plan: '基础版', planCode: 'basic', status: '试用', billing: '正常', mode: '试用', students: 642, questions: 12860, storageGB: 42, trafficTB: .6, expires: '2026-08-12', balance: 0, owner: '孙老师', ownerPhone: '136****1209', createdAt: '2026-07-12 14:20' },
{ id: 'tenant_mingtu_hn', slug: 'mingtu-hn', name: '明途教育 · 河南', legal: '郑州明途教育科技有限公司', brand: '明途教育', shortName: '明途河南', host: 'learn.mingtu.cn', plan: '基础版', planCode: 'basic', status: '暂停', billing: '逾期', mode: '正式', students: 1280, questions: 18620, storageGB: 86, trafficTB: .9, expires: '2026-07-18', balance: 8600, owner: '魏校长', ownerPhone: '135****8806', createdAt: '2025-01-18 11:03' },
{ id: 'tenant_xuezhi_heb', slug: 'xuezhi-heb', name: '学知专接本 · 河北', legal: '石家庄学知教育有限公司', brand: '学知专接本', shortName: '学知河北', host: 'app.xuezhizjb.cn', plan: '专业版', planCode: 'pro', status: '正常', billing: '正常', mode: '正式', students: 3160, questions: 39860, storageGB: 164, trafficTB: 2.1, expires: '2027-01-08', balance: 0, owner: '赵老师', ownerPhone: '137****6116', createdAt: '2025-01-08 08:40' },
{ id: 'tenant_youcai_js', slug: 'youcai-js', name: '优才专转本 · 江苏', legal: '南京优才教育科技有限公司', brand: '优才专转本', shortName: '优才江苏', host: 'study.youcai.cn', plan: '专业版', planCode: 'pro', status: '待配置', billing: '正常', mode: '试用', students: 0, questions: 0, storageGB: 0, trafficTB: 0, expires: '', balance: 0, owner: '顾老师', ownerPhone: '138****1120', createdAt: '2026-07-22 16:08' },
],
billingProfiles: {
tenant_gongxue_tj: { billingName: '天津恭学教育科技有限公司', taxId: '91120101MA07GX2026', contactName: '李会计', phone: '13800138000', email: 'finance@gongxue.cn', billingAddress: '天津市南开区科研西路 12 号', invoiceTitle: '天津恭学教育科技有限公司', type: '增值税专用发票', bankName: '招商银行天津南开支行', bankAccountMasked: '**** **** **** 8826' },
tenant_qihang_gd: { billingName: '广州启航教育咨询有限公司', taxId: '91440101MA5QH2026X', contactName: '黄会计', phone: '13900139000', email: 'billing@qihangzsb.cn', billingAddress: '广州市天河区科韵路 18 号', invoiceTitle: '广州启航教育咨询有限公司', type: '增值税普通发票', bankName: '中国银行广州天河支行', bankAccountMasked: '**** **** **** 6672' },
},
plans: [
{ code: 'basic', name: '基础版', description: '适合单地区小型机构', cycle: '年度', amount: 4800, quotas: ['2,000 学生席位', '30,000 题目', '100 GB 存储', '基础内容运营'], tenantCount: 12 },
{ code: 'pro', name: '专业版', description: '适合成熟区域型机构', cycle: '年度', amount: 9800, quotas: ['8,000 学生席位', '100,000 题目', '500 GB 存储', 'CRM 与营销能力'], tenantCount: 24 },
{ code: 'enterprise', name: '旗舰版', description: '适合多校区与高内容规模', cycle: '年度', amount: 16800, quotas: ['20,000 学生席位', '300,000 题目', '2 TB 存储', '财务对账与高级审计'], tenantCount: 12 },
],
subscriptions: [
{ id: 'SUB-202607-001', tenantId: 'tenant_gongxue_tj', planCode: 'enterprise', status: '活跃', cycle: '年度', amount: 16800, start: '2026-07-22', expires: '2027-07-22', autoRenew: true },
{ id: 'SUB-202608-002', tenantId: 'tenant_qihang_gd', planCode: 'pro', status: '活跃', cycle: '年度', amount: 9800, start: '2025-08-05', expires: '2026-08-05', autoRenew: true },
{ id: 'SUB-TRIAL-003', tenantId: 'tenant_bosi_sd', planCode: 'basic', status: '试用中', cycle: '试用', amount: 0, start: '2026-07-12', expires: '2026-08-12', autoRenew: false },
{ id: 'SUB-202501-004', tenantId: 'tenant_mingtu_hn', planCode: 'basic', status: '暂停', cycle: '年度', amount: 4800, start: '2025-01-18', expires: '2026-07-18', autoRenew: false },
{ id: 'SUB-202601-005', tenantId: 'tenant_xuezhi_heb', planCode: 'pro', status: '活跃', cycle: '年度', amount: 9800, start: '2026-01-08', expires: '2027-01-08', autoRenew: true },
],
invoices: [
{ id: 'INV-202607-0018', tenantId: 'tenant_qihang_gd', type: '订阅服务费', period: '2026年7月', total: 12800, paid: 0, balance: 12800, due: '2026-08-05', status: '待付款', note: '2026年7月服务费' },
{ id: 'INV-202607-0017', tenantId: 'tenant_mingtu_hn', type: '订阅服务费', period: '2026年7月', total: 8600, paid: 0, balance: 8600, due: '2026-07-18', status: '已逾期', note: '2026年7月服务费' },
{ id: 'INV-202607-0016', tenantId: 'tenant_gongxue_tj', type: '订阅服务费', period: '2026年7月', total: 16800, paid: 16800, balance: 0, due: '2026-07-15', status: '已支付', note: '2026年7月服务费' },
{ id: 'INV-202607-0015', tenantId: 'tenant_xuezhi_heb', type: '订阅服务费', period: '2026年7月', total: 9800, paid: 9800, balance: 0, due: '2026-07-15', status: '已支付', note: '2026年7月服务费' },
],
usage: [
{ tenantId: 'tenant_gongxue_tj', period: '2026-07', source: 'Worker 自动采集', students: 2846, questions: 42680, storageGB: 186, trafficTB: 1.8, overage: [], estimated: 0 },
{ tenantId: 'tenant_qihang_gd', period: '2026-07', source: 'Worker 自动采集', students: 5280, questions: 68420, storageGB: 248, trafficTB: 3.2, overage: ['流量'], estimated: 7820 },
{ tenantId: 'tenant_bosi_sd', period: '2026-07', source: 'Worker 自动采集', students: 642, questions: 12860, storageGB: 42, trafficTB: .6, overage: [], estimated: 0 },
{ tenantId: 'tenant_mingtu_hn', period: '2026-07', source: '人工补录', students: 1280, questions: 18620, storageGB: 86, trafficTB: .9, overage: ['题目', '存储'], estimated: 4260 },
{ tenantId: 'tenant_xuezhi_heb', period: '2026-07', source: 'Worker 自动采集', students: 3160, questions: 39860, storageGB: 164, trafficTB: 2.1, overage: [], estimated: 0 },
],
reminders: [
{ id: 'REM-260723-01', invoiceId: 'INV-202607-0017', tenantId: 'tenant_mingtu_hn', level: 3, channel: '邮件 + 短信', status: '已送达', date: '2026-07-23 09:20', message: '服务费账单已逾期 5 天,请尽快处理。' },
{ id: 'REM-260722-02', invoiceId: 'INV-202607-0018', tenantId: 'tenant_qihang_gd', level: 1, channel: '邮件', status: '已送达', date: '2026-07-22 15:40', message: '账单将在 13 天后到期。' },
],
dunningChannels: [
{ id: 'DUN-EMAIL', code: 'finance_email', name: '财务邮件', provider: 'SMTP', endpoint: 'finance@gongxue.cn', enabled: true, retry: 3 },
{ id: 'DUN-WX', code: 'finance_wecom', name: '财务企微群', provider: 'Webhook', endpoint: 'https://qyapi.weixin.qq.com/***', enabled: true, retry: 5 },
],
banks: [
{ id: 'QB-TJ-MATH', name: '高等数学公共题库', catalog: '天津专升本 / 高等数学', source: '平台自建', questions: 12860, version: 'v2026.7', status: '稳定版' },
{ id: 'QB-EN', name: '公共英语能力库', catalog: '全国 / 公共英语', source: '平台自建', questions: 86640, version: 'v2026.6', status: '稳定版' },
{ id: 'QB-SD', name: '山东专升本全科', catalog: '山东专升本', source: '合作内容方', questions: 52360, version: 'v2026.7', status: '稳定版' },
],
grants: [
{ id: 'GRANT-001', bankId: 'QB-TJ-MATH', scope: '指定租户', allowedPlanCodes: [], allowedTenantIds: ['tenant_gongxue_tj', 'tenant_qihang_gd'], allowedRegionIds: ['天津'], allowedSubjectIds: ['高等数学'], status: '启用', startsAt: '2026-07-18', expiresAt: '2027-07-18', localEdit: false },
{ id: 'GRANT-002', bankId: 'QB-EN', scope: '指定套餐', allowedPlanCodes: ['pro', 'enterprise'], allowedTenantIds: [], allowedRegionIds: [], allowedSubjectIds: ['公共英语'], status: '启用', startsAt: '2026-06-01', expiresAt: '', localEdit: true },
],
bankSync: [
{ tenantId: 'tenant_gongxue_tj', bankId: 'QB-TJ-MATH', current: 'v2026.6', target: 'v2026.7', progress: 100, conflicts: 0, status: '已完成' },
{ tenantId: 'tenant_qihang_gd', bankId: 'QB-EN', current: 'v2026.5', target: 'v2026.6', progress: 72, conflicts: 3, status: '同步中' },
{ tenantId: 'tenant_mingtu_hn', bankId: 'QB-EN', current: 'v2026.3', target: 'v2026.6', progress: 38, conflicts: 12, status: '同步失败' },
],
staff: [
{ id: 'STAFF-01', authUserId: 'auth-chenhao', username: 'chenhao', name: '陈浩', email: 'chenhao@gongxue.cn', phone: '138****6600', role: '超级管理员', permissions: ['*'], status: '正常', lastActive: '刚刚' },
{ id: 'STAFF-02', authUserId: 'auth-zhoumin', username: 'zhoumin', name: '周敏', email: 'zhoumin@gongxue.cn', phone: '139****2188', role: '题库运营', permissions: ['platform:question_bank:read'], status: '正常', lastActive: '8分钟前' },
{ id: 'STAFF-03', authUserId: 'auth-liujia', username: 'liujia', name: '刘佳', email: 'liujia@gongxue.cn', phone: '136****9028', role: '平台财务', permissions: ['platform:billing:read', 'platform:billing:write', 'platform:billing:payment', 'platform:usage:read', 'platform:usage:write'], status: '正常', lastActive: '21分钟前' },
{ id: 'STAFF-04', authUserId: 'auth-wanglei', username: 'wanglei', name: '王磊', email: 'wanglei@gongxue.cn', phone: '135****2086', role: '租户运营', permissions: ['platform:tenant:read', 'platform:tenant:write', 'platform:tenant:status'], status: '正常', lastActive: '1小时前' },
{ id: 'STAFF-05', authUserId: 'auth-sunchen', username: 'sunchen', name: '孙晨', email: 'sunchen@gongxue.cn', phone: '137****1118', role: '安全审计', permissions: ['platform:audit:read', 'platform:audit:alert'], status: '已停用', lastActive: '3天前' },
],
auditLogs: [
{ id: 'AUD-20260723-094218', time: '2026-07-23 09:42:18 CST', actor: '陈浩', role: '平台管理员', action: 'tenant.status.update', targetType: 'tenant', targetId: 'tenant_mingtu_hn', target: '明途教育 · 河南', severity: '高', result: '成功', ip: '172.16.4.28', details: '将业务状态由正常更新为暂停,原因:账单逾期。' },
{ id: 'AUD-20260723-093602', time: '2026-07-23 09:36:02 CST', actor: 'system-billing', role: '系统任务', action: 'invoice.batch.create', targetType: 'invoice_batch', targetId: 'TASK-260723-001', target: '2026年7月账单', severity: '中', result: '成功', ip: 'internal', details: '生成 48 张账单,跳过 2 条重复候选。' },
{ id: 'AUD-20260723-085844', time: '2026-07-23 08:58:44 CST', actor: '周敏', role: '题库运营', action: 'bank.grant.update', targetType: 'grant', targetId: 'GRANT-001', target: '高数公共题库 v2026.7', severity: '中', result: '成功', ip: '172.16.3.16', details: '授权范围更新为 2 家指定租户。' },
{ id: 'AUD-20260723-084127', time: '2026-07-23 08:41:27 CST', actor: '刘佳', role: '平台财务', action: 'invoice.payment.confirm', targetType: 'invoice', targetId: 'INV-202607-0016', target: 'INV-202607-0016', severity: '高', result: '成功', ip: '172.16.8.12', details: '人工确认收款 ¥16,800流水号已脱敏。' },
{ id: 'AUD-20260723-081209', time: '2026-07-23 08:12:09 CST', actor: 'unknown', role: '-', action: 'auth.login.failed', targetType: 'application', targetId: 'platform-console', target: 'platform-console', severity: '严重', result: '失败', ip: '45.62.18.9', details: '5 分钟内连续登录失败 12 次。' },
{ id: 'AUD-20260720-163510', time: '2026-07-20 16:35:10 CST', actor: '周敏', role: '题库运营', action: 'bank.catalog.read', targetType: 'question_bank', targetId: 'QB-SD', target: '山东专升本全科', severity: '中', result: '成功', ip: '172.16.3.16', details: '查看公共题库版本与租户采用状态。' },
],
alerts: [
{ id: 'ALT-20260723-008', title: '异常登录尝试', summary: '5 分钟内 12 次平台登录失败', severity: '严重', status: 'open', owner: '安全审计', targetType: 'application', targetId: 'platform-console', evidence: ['来源 IP 45.62.18.9', '账号枚举行为 12 次', '命中规则 LOGIN_FAILURE_BURST'], updatedAt: '2分钟前', note: '' },
{ id: 'ALT-20260723-006', title: '租户账单逾期', summary: '明途教育服务费账单逾期 5 天', severity: '高', status: 'acknowledged', owner: '平台财务', targetType: 'invoice', targetId: 'INV-202607-0017', evidence: ['余额 ¥8,600', '到期日 2026-07-18', '已发送 3 次催缴'], updatedAt: '18分钟前', note: '财务已联系租户。' },
{ id: 'ALT-20260723-004', title: '公共题库同步失败', summary: '河南租户同步发生 12 个内容冲突', severity: '中', status: 'open', owner: '题库运营', targetType: 'question_bank', targetId: 'QB-EN', evidence: ['当前版本 v2026.3', '目标版本 v2026.6', '冲突 12 条'], updatedAt: '42分钟前', note: '' },
{ id: 'ALT-20260722-019', title: '存储用量告警', summary: '启航专升本存储用量达到套餐 92%', severity: '中', status: 'open', owner: '租户运营', targetType: 'tenant', targetId: 'tenant_qihang_gd', evidence: ['当前 460 GB', '套餐上限 500 GB'], updatedAt: '昨天', note: '' },
{ id: 'ALT-20260722-011', title: 'Provider 密钥即将过期', summary: '优才专转本支付 Provider 密钥 7 天后到期', severity: '高', status: 'resolved', owner: '平台运维', targetType: 'tenant', targetId: 'tenant_youcai_js', evidence: ['密钥只写不可回显', '新密钥已于昨天更新'], updatedAt: '昨天', note: '完成轮换。' },
],
alertRules: [
{ id: 'RULE-LOGIN-BURST', code: 'LOGIN_FAILURE_BURST', name: '平台登录失败突增', severity: '严重', enabled: true, window: '5 分钟', threshold: '≥ 10 次', target: '平台账号与来源 IP' },
{ id: 'RULE-INVOICE-OVERDUE', code: 'INVOICE_OVERDUE', name: '服务费账单逾期', severity: '高', enabled: true, window: '每日 09:00', threshold: '逾期 ≥ 1 天', target: '未结清平台账单' },
{ id: 'RULE-BANK-SYNC', code: 'QUESTION_BANK_SYNC_FAILED', name: '公共题库同步失败', severity: '中', enabled: true, window: '任务结束时', threshold: '失败或冲突 ≥ 10', target: '租户题库同步任务' },
],
auditChannels: [
{ id: 'CH-SEC-MAIL', code: 'security_email', name: '平台安全邮箱', provider: '邮件', endpoint: 'security@gongxue.cn', minSeverity: '中', statusFilter: 'open,acknowledged', timeout: 8, maxAttempts: 3, enabled: true, lastStatus: '正常' },
{ id: 'CH-SEC-WX', code: 'security_wecom', name: '财务告警群', provider: 'Webhook', endpoint: 'Webhook · 企业微信', minSeverity: '高', statusFilter: 'open', timeout: 5, maxAttempts: 5, enabled: true, lastStatus: '正常' },
{ id: 'CH-DUTY-SMS', code: 'duty_sms', name: '值班手机', provider: '短信', endpoint: '138****6008', minSeverity: '严重', statusFilter: 'open', timeout: 10, maxAttempts: 3, enabled: true, lastStatus: '失败1次' },
],
notificationEvents: [
{ id: 'EVT-260723-001', channelId: 'CH-DUTY-SMS', subject: '异常登录尝试', status: '失败', attempts: 2, updatedAt: '2026-07-23 09:44 CST', error: '运营商返回频率限制' },
{ id: 'EVT-260723-002', channelId: 'CH-SEC-MAIL', subject: '异常登录尝试', status: '成功', attempts: 1, updatedAt: '2026-07-23 09:43 CST', error: '' },
],
dunningEvents: [],
tasks: [],
};
function clone(value) { return JSON.parse(JSON.stringify(value)); }
function normalizeData(saved) {
const normalized = clone(seed);
if (!saved || typeof saved !== 'object') return normalized;
Object.keys(normalized).forEach(key => {
if (key === 'version') return;
if (Array.isArray(normalized[key])) {
if (Array.isArray(saved[key])) normalized[key] = saved[key];
return;
}
if (normalized[key] && typeof normalized[key] === 'object') {
if (saved[key] && typeof saved[key] === 'object' && !Array.isArray(saved[key])) normalized[key] = { ...normalized[key], ...saved[key] };
return;
}
if (saved[key] !== undefined) normalized[key] = saved[key];
});
normalized.usage = normalized.usage.map(item => ({ overage: [], estimated: 0, source: 'Worker 自动采集', ...item }));
normalized.grants = normalized.grants.map(item => ({ allowedPlanCodes: [], allowedTenantIds: [], allowedRegionIds: [], allowedSubjectIds: [], localEdit: false, ...item }));
normalized.staff = normalized.staff.map(item => ({ permissions: [], status: '正常', lastActive: '尚未登录', ...item }));
normalized.alerts = normalized.alerts.map(item => ({ evidence: [], note: '', status: 'open', ...item }));
normalized.tasks = normalized.tasks.map(item => ({ status: 'failed', error: '', resultTarget: '任务中心', updatedAt: item.createdAt || todayTime(), ...item }));
normalized.version = seed.version;
return normalized;
}
function loadData() {
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY));
if (saved?.version === seed.version) {
const normalized = normalizeData(saved);
normalized.tasks.forEach(task => {
if (['pending', 'processing'].includes(task.status)) {
task.status = 'failed';
task.error = '页面重载中断了本次 Mock 执行,可从任务中心重试。';
task.updatedAt = todayTime();
}
});
return normalized;
}
} catch (error) {
console.warn('Mock state restore failed', error);
}
return clone(seed);
}
let data = loadData();
if (platformApi?.isEnabled()) {
[
'tenants', 'plans', 'subscriptions', 'invoices', 'usage', 'reminders',
'dunningChannels', 'dunningEvents', 'banks', 'grants', 'bankSync', 'staff',
'auditLogs', 'alerts', 'alertRules', 'auditChannels', 'notificationEvents', 'tasks',
].forEach(key => { data[key] = []; });
data.billingProfiles = {};
}
const state = {
view: 'overview', previousView: '', selectedTenant: data.tenants[0]?.id || '', tenantFilter: 'all', tenantPlan: 'all', tenantSearch: '', globalSearch: '',
billingTab: 'invoices', bankTab: 'catalog', bankIncludeTenant: false, selectedBank: data.banks[0]?.id || '',
invoiceSearch: '', invoiceStatus: 'all', bankSearch: '', staffSearch: '', usagePeriod: '2026-07', auditRange: '24h', auditSeverity: 'all', auditSearch: '', alertStatus: 'all', selectedAlert: '', selectedInvoice: '',
role: 'super', scenario: 'normal', response: 'success', modal: '', modalData: {}, submitting: '', reading: '', contractResults: {}, history: [],
apiConnection: platformApi?.isEnabled() ? 'connecting' : 'mock', apiLoadedAt: '', apiLoadedOperations: 0, apiFailedOperations: 0,
};
let pendingLeave = null;
let historySequence = Number(history.state?.sequence) || 0;
let restoringHistory = false;
let bypassHistoryGuard = false;
const dirtyScopes = new Set();
const loadedTenantDetails = new Set();
let storageWarningShown = false;
function saveData() {
if (platformApi?.isEnabled()) return true;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
return true;
} catch (error) {
console.warn('Mock state persistence failed', error);
if (!storageWarningShown) {
storageWarningShown = true;
showToast('浏览器存储不可用,本次修改仅在当前页面保留', 'error');
}
return false;
}
}
function backendStatusLabel() {
if (!platformApi?.isEnabled()) return '静态演示数据';
if (state.apiConnection === 'connecting') return '正在连接后端';
if (state.apiConnection === 'connected') {
const partial = state.apiFailedOperations ? ` · ${state.apiFailedOperations} 项无权限/失败` : '';
return `后端已连接${partial}`;
}
return '后端不可用 · 已保留静态演示';
}
function updateBackendStatus() {
const status = document.querySelector('.top-status > span');
if (!status) return;
status.innerHTML = `<i></i>${escapeHtml(backendStatusLabel())}`;
status.title = platformApi?.isEnabled()
? `${platformApi.publicConfig().apiBaseUrl || '未配置 API 地址'}${state.apiLoadedAt ? ` · ${state.apiLoadedAt}` : ''}`
: 'runtime-config.js 当前为 mock 模式';
status.classList.toggle('is-degraded', state.apiConnection === 'degraded');
}
function applyBackendPatch(patch) {
Object.entries(patch).forEach(([key, value]) => { data[key] = value; });
if (patch.tenants) {
const tenantIds = new Set(data.tenants.map(tenant => tenant.id));
if (!tenantIds.has(state.selectedTenant)) state.selectedTenant = data.tenants[0]?.id || '';
}
if (patch.banks && !data.banks.some(bank => bank.id === state.selectedBank)) state.selectedBank = data.banks[0]?.id || '';
if (patch.usage) {
const latestUsage = new Map();
data.usage.forEach(item => {
const current = latestUsage.get(item.tenantId);
if (!current || item.period > current.period) latestUsage.set(item.tenantId, item);
});
data.tenants.forEach(tenant => {
const usage = latestUsage.get(tenant.id);
if (!usage) return;
tenant.students = usage.students;
tenant.questions = usage.questions;
tenant.storageGB = usage.storageGB;
tenant.trafficTB = usage.trafficTB;
});
}
if (patch.plans || patch.tenants) {
data.plans.forEach(plan => {
plan.tenantCount = data.tenants.filter(tenant => tenant.planCode === plan.code).length;
});
}
}
async function hydrateFromBackend({ silent = false } = {}) {
if (!platformApi?.isEnabled()) {
state.apiConnection = 'mock';
updateBackendStatus();
return false;
}
state.apiConnection = 'connecting';
updateBackendStatus();
try {
const result = await platformApi.bootstrap({
includeTenantBanks: state.bankIncludeTenant,
period: state.usagePeriod,
});
const successful = Object.keys(result.payloads).length;
if (!successful) throw Object.values(result.errors)[0] || new Error('没有平台接口成功返回');
applyBackendPatch(platformApi.adaptBootstrap(result.payloads, data));
state.apiConnection = 'connected';
state.apiLoadedAt = todayTime();
state.apiLoadedOperations = successful;
state.apiFailedOperations = Object.keys(result.errors).length;
render();
if (!silent) showToast(`后端数据已刷新 · ${successful}/${result.requested} 项成功`);
return true;
} catch (error) {
state.apiConnection = 'degraded';
state.apiFailedOperations = 1;
render();
if (!silent) showToast(`${error.message || '后端连接失败'} · 已保留静态演示`, 'error');
return false;
}
}
async function hydrateTenantDetail(tenantId, { force = false } = {}) {
if (!platformApi?.isEnabled() || !tenantId || (!force && loadedTenantDetails.has(tenantId))) return;
try {
const detail = await platformApi.tenantDetail(tenantId);
const tenant = tenantById(tenantId);
if (tenant && detail.tenant) {
tenant.legal = detail.tenant.legalName || tenant.legal;
tenant.brand = detail.tenant.brandName || tenant.brand;
tenant.shortName = detail.tenant.shortName || tenant.shortName;
tenant.createdAt = detail.tenant.createdAt ? String(detail.tenant.createdAt).slice(0, 10) : tenant.createdAt;
tenant.host = detail.domains.find(domain => domain.isPrimary)?.host || detail.domains[0]?.host || tenant.host;
}
data.subscriptions = [
...data.subscriptions.filter(item => item.tenantId !== tenantId),
...detail.subscriptions,
];
if (detail.billingProfile) data.billingProfiles[tenantId] = detail.billingProfile;
loadedTenantDetails.add(tenantId);
render();
} catch (error) {
showToast(`${error.message || '租户详情加载失败'}${error.requestId ? ` · ${error.requestId}` : ''}`, 'error');
}
}
function icon(name) { return `<i data-lucide="${name}"></i>`; }
function refreshIcons() { if (window.lucide) window.lucide.createIcons({ attrs: { 'stroke-width': 1.8 } }); }
function formatNumber(value) { return Number(value || 0).toLocaleString('zh-CN'); }
function money(value) { return `¥${Number(value || 0).toLocaleString('zh-CN')}`; }
function todayTime() { return new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', dateStyle: 'short', timeStyle: 'medium' }).format(new Date()).replaceAll('/', '-'); }
function requestId() { return `req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`; }
function entityId(prefix) { return `${prefix}-${Date.now().toString(36).toUpperCase()}`; }
function tenantById(id) { return data.tenants.find(item => item.id === id); }
function planByCode(code) { return data.plans.find(item => item.code === code); }
function invoiceById(id) { return data.invoices.find(item => item.id === id); }
function bankById(id) { return data.banks.find(item => item.id === id); }
function notificationChannelName(id) {
return [...data.dunningChannels, ...data.auditChannels].find(channel => channel.id === id)?.name || id;
}
function setAccessibleControlName(element, label) {
if (!element || !label) return;
element.setAttribute('aria-label', label);
element.setAttribute('title', label);
}
function recordLabel(name, id) { return [name, id].filter(Boolean).join(' · '); }
function rowActionAccessibleName(control) {
const tenantId = control.dataset.tenantDetail || control.dataset.tenantId;
const tenant = tenantById(tenantId);
if (control.dataset.tenantDetail) return `查看租户 ${recordLabel(tenant?.name, tenantId)} 详情`;
if (control.dataset.invoiceDetail) {
const invoice = invoiceById(control.dataset.invoiceDetail);
return `查看账单 ${recordLabel(invoice?.id, tenantById(invoice?.tenantId)?.name)} 详情`;
}
if (control.dataset.notificationDetail) {
const event = [...(data.notificationEvents || []), ...(data.dunningEvents || [])].find(item => item.id === control.dataset.notificationDetail);
return `查看通知事件 ${recordLabel(event?.subject, event?.id || control.dataset.notificationDetail)} 详情`;
}
if (control.dataset.auditDetail) {
const log = data.auditLogs.find(item => item.id === control.dataset.auditDetail);
return `查看审计日志 ${recordLabel(log?.id || control.dataset.auditDetail, log?.target)} 详情`;
}
const action = control.dataset.action;
if (action === 'prepare-tenant-status') return `${tenant?.status === '暂停' ? '恢复' : '暂停'}租户 ${recordLabel(tenant?.name, tenantId)}`;
if (action === 'tenant-to-billing') return `查看租户 ${recordLabel(tenant?.name, tenantId)} 账务`;
if (action === 'confirm-payment') {
const invoice = invoiceById(control.dataset.invoiceId);
return `确认账单 ${recordLabel(invoice?.id || control.dataset.invoiceId, tenantById(invoice?.tenantId)?.name)} 收款`;
}
if (action === 'edit-dunning-channel') {
const channel = data.dunningChannels.find(item => item.id === control.dataset.channelId);
return `编辑催缴渠道 ${recordLabel(channel?.name, channel?.id || control.dataset.channelId)}`;
}
if (action === 'edit-grant') {
const grant = data.grants.find(item => item.id === control.dataset.grantId);
return `编辑公共题库授权 ${recordLabel(bankById(grant?.bankId)?.name, grant?.id || control.dataset.grantId)}`;
}
if (action === 'sync-detail') {
const [syncTenantId, bankId] = String(control.dataset.syncKey || '').split(':');
return `查看 ${recordLabel(tenantById(syncTenantId)?.name, syncTenantId)}${recordLabel(bankById(bankId)?.name, bankId)} 的同步详情`;
}
if (action === 'edit-staff') {
const person = data.staff.find(item => item.id === control.dataset.staffId);
return `编辑员工 ${recordLabel(person?.name, person?.id || control.dataset.staffId)}`;
}
if (action === 'edit-audit-channel') {
const channel = data.auditChannels.find(item => item.id === control.dataset.channelId);
return `编辑审计通知渠道 ${recordLabel(channel?.name, channel?.id || control.dataset.channelId)}`;
}
return control.title || control.textContent.trim();
}
function switchAccessibleName(control) {
const enabled = control.classList.contains('is-on');
const action = control.dataset.action;
if (action === 'toggle-auto-renew') {
const subscription = data.subscriptions.find(item => item.id === control.dataset.subscriptionId);
const tenant = tenantById(subscription?.tenantId);
return `${enabled ? '关闭' : '开启'}${tenant?.name || '该租户'}订阅 ${subscription?.id || control.dataset.subscriptionId} 自动续费`;
}
if (action === 'toggle-dunning-channel') {
const channel = data.dunningChannels.find(item => item.id === control.dataset.channelId);
return `${enabled ? '停用' : '启用'}催缴渠道 ${recordLabel(channel?.name, channel?.id || control.dataset.channelId)}`;
}
if (action === 'toggle-grant') {
const grant = data.grants.find(item => item.id === control.dataset.grantId);
return `${enabled ? '禁用' : '启用'}公共题库授权 ${recordLabel(bankById(grant?.bankId)?.name, grant?.id || control.dataset.grantId)}`;
}
if (action === 'toggle-staff') {
const person = data.staff.find(item => item.id === control.dataset.staffId);
return `${enabled ? '停用员工' : '启用员工'} ${recordLabel(person?.name, person?.id || control.dataset.staffId)}${enabled ? '并撤销会话' : ''}`;
}
if (action === 'toggle-audit-channel') {
const channel = data.auditChannels.find(item => item.id === control.dataset.channelId);
return `${enabled ? '停用' : '启用'}审计通知渠道 ${recordLabel(channel?.name, channel?.id || control.dataset.channelId)}`;
}
return enabled ? '关闭此设置' : '开启此设置';
}
function updateMenuButtonAccessibility() {
const expanded = app.classList.contains('menu-open');
setAccessibleControlName(menuButton, expanded ? '关闭平台导航菜单' : '打开平台导航菜单');
menuButton.setAttribute('aria-expanded', String(expanded));
menuButton.setAttribute('aria-controls', 'sideNav');
}
function decorateAccessibleControls(root) {
const selectLabels = {
tenantPlanFilter: '租户套餐筛选',
invoiceStatusFilter: '账单状态筛选',
roleSelect: '演示角色',
scenarioSelect: '页面状态演练',
responseSelect: '下一次 Mock 写请求结果',
};
Object.entries(selectLabels).forEach(([id, label]) => setAccessibleControlName(document.querySelector(`#${id}`), label));
root.querySelectorAll('.row-action').forEach(control => setAccessibleControlName(control, rowActionAccessibleName(control)));
root.querySelectorAll('.switch').forEach(control => {
setAccessibleControlName(control, switchAccessibleName(control));
control.setAttribute('role', 'switch');
control.setAttribute('aria-checked', String(control.classList.contains('is-on')));
});
document.querySelectorAll('.icon-button').forEach(control => {
const fallback = control.title || control.getAttribute('aria-label');
if (fallback) setAccessibleControlName(control, fallback);
});
updateMenuButtonAccessibility();
}
function hasPermission(permission) {
if (platformApi?.isEnabled() && data.apiPermissions?.effective && Object.prototype.hasOwnProperty.call(data.apiPermissions.effective, permission)) {
return data.apiPermissions.effective[permission] === true;
}
const grants = roleDefinitions[state.role]?.permissions || [];
if (grants.includes('*') || grants.includes(permission)) return true;
return grants.some(grant => grant.endsWith('*') && permission.startsWith(grant.slice(0, -1)));
}
function tone(text) {
const value = String(text || '');
if (/正常|活跃|已支付|resolved|已解决|已授权|成功|已完成|启用|已送达/.test(value)) return 'success';
if (/逾期|暂停|严重|失败|阻断|已停用|同步失败/.test(value)) return 'danger';
if (/待|试用|警告|即将|acknowledged|已确认|同步中|执行中|开放|open/.test(value)) return 'warning';
return 'neutral';
}
function alertStatusLabel(status) { return ({ open: '开放', acknowledged: '已确认', resolved: '已解决', ignored: '已忽略' })[status] || status; }
function taskStatusLabel(status) { return ({ pending: '待处理', processing: '执行中', completed: '已完成', failed: '失败' })[status] || status; }
function escapeHtml(value) { return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' })[char]); }
function fieldValue(form, name) { return String(new FormData(form).get(name) || '').trim(); }
function parseMoney(value) { return Number(String(value || '').replaceAll(',', '')); }
function addAudit(action, targetType, targetId, target, severity, details, result = '成功') {
data.auditLogs.unshift({ id: entityId('AUD'), time: `${todayTime()} CST`, actor: roleDefinitions[state.role].label, role: roleDefinitions[state.role].label, action, targetType, targetId, target, severity, result, ip: '127.0.0.1', details });
saveData();
}
function addTask(label, resultTarget, execute) {
const task = { id: entityId('TASK'), label, resultTarget, status: 'pending', createdAt: todayTime(), updatedAt: todayTime(), error: '' };
data.tasks.unshift(task); saveData(); updateCounters();
setTimeout(() => { task.status = 'processing'; task.updatedAt = todayTime(); saveData(); updateCounters(); renderOverlay(); }, 350);
setTimeout(() => {
try { execute?.(task); task.status = 'completed'; }
catch (error) { task.status = 'failed'; task.error = error.message || '任务执行失败'; }
task.updatedAt = todayTime(); saveData(); updateCounters(); render(); renderOverlay();
}, 1200);
return task;
}
function contractResult(key) {
const result = state.contractResults[key];
if (!result) return '';
const toneClass = result.status === 'error' ? 'is-error' : result.status === 'loading' ? 'is-loading' : 'is-success';
const glyph = result.status === 'error' ? 'triangle-alert' : result.status === 'loading' ? 'loader-circle' : 'circle-check';
return `<div class="contract-result ${toneClass}" role="status">${icon(glyph)}<span><strong>${escapeHtml(result.title)}</strong><small>${escapeHtml(result.detail)} · Request ID<code>${escapeHtml(result.requestId)}</code></small></span></div>`;
}
function runContractRead({ key, actionId, operationId, title, apply, suppliedRequestId = '', liveRequest }) {
if (state.reading) return;
const id = suppliedRequestId || requestId();
state.reading = key;
state.contractResults[key] = { status: 'loading', title: `${title}处理中`, detail: operationId, requestId: id };
render();
if (platformApi?.isEnabled()) {
Promise.resolve(typeof liveRequest === 'function' ? liveRequest() : platformApi.request(operationId))
.then(payload => {
state.reading = '';
const requestIdValue = payload?.meta?.requestId || id;
const detail = apply?.(payload) || '查询结果已刷新';
state.contractResults[key] = { status: 'success', title: `${title}完成`, detail, requestId: requestIdValue };
if (actionId) window.recordPlatformSpecResult?.(actionId, { result: 'success', operationId, requestId: requestIdValue });
render();
})
.catch(error => {
state.reading = '';
const requestIdValue = error.requestId || id;
state.contractResults[key] = { status: 'error', title: `${title}失败`, detail: error.message || '平台接口请求失败', requestId: requestIdValue };
if (actionId) window.recordPlatformSpecResult?.(actionId, { result: 'failed', operationId, requestId: requestIdValue });
render();
});
return;
}
const response = nextResponse();
setTimeout(() => {
state.reading = '';
if (response !== 'success') {
const detail = response === 'validation_error' ? '服务端拒绝查询参数,已保留当前筛选' : 'Mock 查询失败,已保留当前筛选';
state.contractResults[key] = { status: 'error', title: `${title}失败`, detail, requestId: id };
addAudit(`${key}.failed`, 'mock_request', id, operationId, response === 'validation_error' ? '中' : '高', `${detail}。Request ID${id}`, '失败');
if (actionId) window.recordPlatformSpecResult?.(actionId, { result: 'failed', operationId, requestId: id });
render();
return;
}
const detail = apply?.() || '查询结果已刷新';
state.contractResults[key] = { status: 'success', title: `${title}完成`, detail, requestId: id };
if (actionId) window.recordPlatformSpecResult?.(actionId, { result: 'success', operationId, requestId: id });
render();
}, 520);
}
function addContractTask({ actionId, operationId, label, resultTarget, execute, suppliedRequestId = '' }) {
const response = nextResponse();
const id = suppliedRequestId || requestId();
const task = addTask(label, resultTarget, taskItem => {
if (response !== 'success') {
const message = `${response === 'validation_error' ? '服务端字段校验失败' : 'Mock 异步请求失败'} · Request ID${id}`;
addAudit(`${actionId}.failed`, 'mock_request', id, operationId, '高', message, '失败');
window.recordPlatformSpecResult?.(actionId, { result: 'failed', operationId, requestId: id, taskId: taskItem.id });
throw new Error(message);
}
execute?.(taskItem, id);
window.recordPlatformSpecResult?.(actionId, { result: 'success', operationId, requestId: id, taskId: taskItem.id });
});
task.requestId = id;
task.operationId = operationId;
saveData();
return task;
}
function canWrite(permission) { return hasPermission(permission) && state.role !== 'readonly'; }
function isCompactAdmin() { return window.matchMedia('(max-width: 767px)').matches; }
function requireDesktop(actionLabel) {
if (!isCompactAdmin()) return false;
openModal('mobileBoundary', { actionLabel });
return true;
}
function closeMobileMenu() {
app.classList.remove('menu-open');
document.body.classList.remove('menu-locked');
updateMenuButtonAccessibility();
}
function toggleMobileMenu() {
const opening = !app.classList.contains('menu-open');
app.classList.toggle('menu-open', opening);
document.body.classList.toggle('menu-locked', opening);
updateMenuButtonAccessibility();
}
function setupPrototypeControls() {
roleSelect.innerHTML = Object.entries(roleDefinitions).map(([value, role]) => `<option value="${value}">${role.label}</option>`).join('');
scenarioSelect.innerHTML = [['normal', '正常'], ['loading', '加载中'], ['empty', '空状态'], ['partial_error', '局部失败'], ['error', '加载失败'], ['forbidden', '403 无权限'], ['session_invalid', '401 会话失效'], ['submitting', '提交中'], ['success', '成功结果']].map(([value, label]) => `<option value="${value}">${label}</option>`).join('');
responseSelect.innerHTML = [['success', '下一次成功'], ['server_error', '下一次服务端失败'], ['validation_error', '下一次字段失败']].map(([value, label]) => `<option value="${value}">${label}</option>`).join('');
roleSelect.value = state.role; scenarioSelect.value = state.scenario; responseSelect.value = state.response;
}
function renderNav() {
const activeNav = ['tenantDetail', 'billingProfile'].includes(state.view) ? 'tenants' : state.view;
sideNav.innerHTML = navGroups.map(([group, items]) => {
const visible = items.filter(([id]) =>
hasPermission(pageMeta[id].permission) && (!platformApi?.isEnabled() || API_CONNECTED_PAGES.has(id)));
if (!visible.length) return '';
return `<section><h2>${group}</h2>${visible.map(([id, glyph, label]) => `<button class="${activeNav === id ? 'is-active' : ''}" data-nav="${id}">${icon(glyph)}<span>${label}</span>${id === 'alerts' ? `<b>${data.alerts.filter(alert => alert.status === 'open').length}</b>` : ''}</button>`).join('')}</section>`;
}).join('');
const staffShortcut = document.querySelector('.platform-foot>button');
if (staffShortcut) staffShortcut.hidden = !hasPermission('platform:staff:read');
const accountRole = document.querySelector('.platform-foot small');
if (accountRole) accountRole.textContent = roleDefinitions[state.role]?.label || '平台管理员';
const authenticatedUser = window.GongxuePlatformAuth?.getUser?.();
const accountName = document.querySelector('.platform-foot strong');
const accountAvatar = document.querySelector('.platform-foot>div>span');
const displayName = authenticatedUser?.name || authenticatedUser?.email;
if (accountName && displayName) accountName.textContent = displayName;
if (accountAvatar && displayName) accountAvatar.textContent = displayName.slice(0, 1).toUpperCase();
}
function updateCounters() {
const activeTasks = data.tasks.filter(task => ['pending', 'processing'].includes(task.status)).length;
const openAlerts = data.alerts.filter(alert => alert.status === 'open').length;
taskCount.textContent = activeTasks;
taskCount.hidden = activeTasks === 0;
alertCount.textContent = openAlerts;
alertCount.hidden = openAlerts === 0;
}
function pageHead(title, description, actions = '', options = {}) {
const level = options.level || pageMeta[state.view]?.level || 1;
const nav = level > 1 ? `<div class="subpage-nav"><button class="button" data-action="subpage-back">${icon('arrow-left')}返回</button><button class="icon-button" data-action="subpage-close" title="关闭并回到安全首页">${icon('x')}</button></div>` : '';
return `<header class="page-head ${level > 1 ? 'is-subpage' : ''}"><div>${nav}<small class="page-id">${pageMeta[state.view]?.id || ''}${level > 1 ? ` · L${level}` : ''}</small><h1>${title}</h1><p>${description}</p></div><div>${actions}</div></header>`;
}
function metric(label, value, note, glyph, style = 'blue') { return `<article class="metric"><span class="metric-icon ${style}">${icon(glyph)}</span><div><small>${label}</small><strong>${value}</strong><p>${note}</p></div></article>`; }
function statusBadge(value) { return `<span class="status ${tone(value)}">${escapeHtml(value)}</span>`; }
function emptyBlock(title, description, action = '') { return `<section class="state-panel"><span>${icon('inbox')}</span><h2>${title}</h2><p>${description}</p>${action}</section>`; }
function requestErrorBlock(partial = false) {
const id = requestId();
return `<section class="state-panel ${partial ? 'is-partial' : 'is-error'}"><span>${icon(partial ? 'cloud-off' : 'triangle-alert')}</span><h2>${partial ? '部分数据暂时不可用' : '页面加载失败'}</h2><p>${partial ? '主列表保留成功数据,右侧聚合区加载失败。' : 'Mock 服务返回错误,未泄露内部堆栈。'} Request ID<code>${id}</code></p><div><button class="button" data-copy="${id}">${icon('copy')}复制 ID</button><button class="button primary" data-action="retry-page">${icon('refresh-cw')}重试</button></div></section>`;
}
function renderScenario(content) {
if (state.scenario === 'session_invalid') return `<div class="page-shell">${pageHead('会话已失效', '平台会话已过期或被管理员撤销。')}<section class="state-panel is-forbidden"><span>${icon('log-in')}</span><h2>401 · 请重新登录</h2><p>当前 Mock 已清理会话上下文,不展示任何平台业务数据。重新登录后返回安全首页。</p><button class="button primary" data-action="restore-session">重新登录</button></section></div>`;
if (!hasPermission(pageMeta[state.view]?.permission) || state.scenario === 'forbidden') return `<div class="page-shell">${pageHead('没有访问权限', '当前演示角色无权访问此平台功能。')}<section class="state-panel is-forbidden"><span>${icon('shield-x')}</span><h2>403 · 权限不足</h2><p>需要权限 <code>${pageMeta[state.view]?.permission}</code>。菜单会按权限隐藏,深链进入则显示此页面。</p><button class="button primary" data-nav="overview">返回安全首页</button></section></div>`;
if (state.scenario === 'loading') return `<div class="page-shell">${pageHead(pageMeta[state.view].title, '正在从 apps/api Mock 加载数据。')}<section class="skeleton-stack">${Array.from({ length: 6 }, (_, index) => `<i style="--w:${88 - index * 7}%"></i>`).join('')}</section><p class="state-hint"> 8 使 <button class="link" data-action="retry-page"></button></p></div>`;
if (state.scenario === 'submitting') return `<div class="page-shell">${pageHead(pageMeta[state.view].title, '正在提交 Mock 写请求。')}<section class="state-panel"><span>${icon('loader-circle')}</span><h2>正在提交</h2><p>按钮已防重复提交,输入内容会保留;完成后将显示结果摘要与唯一结果编号。</p><button class="button" data-action="retry-page">取消演练并恢复</button></section></div>`;
if (state.scenario === 'success') return `<div class="page-shell">${pageHead(pageMeta[state.view].title, '本次 Mock 操作已完成。')}<section class="state-panel is-success"><span>${icon('circle-check')}</span><h2>操作成功</h2><p>结果编号:<code>RESULT-DEMO-20260723</code> · 已写入审计,可返回当前工作页继续处理。</p><div><button class="button" data-copy="RESULT-DEMO-20260723">${icon('copy')}复制结果编号</button><button class="button primary" data-action="retry-page">返回工作页</button></div></section></div>`;
if (state.scenario === 'error') return `<div class="page-shell">${pageHead(pageMeta[state.view].title, '数据加载未完成。')}${requestErrorBlock()}</div>`;
if (state.scenario === 'empty') return `<div class="page-shell">${pageHead(pageMeta[state.view].title, '当前演示数据集为空。')}${emptyBlock('当前页面没有数据', '这是只存在于 Mock 原型的空状态演练。恢复“正常”即可继续测试真实交互。', '<button class="button primary" data-action="retry-page">恢复正常数据</button>')}</div>`;
if (state.scenario === 'partial_error') {
const closingIndex = content.lastIndexOf('</div>');
return closingIndex >= 0 ? `${content.slice(0, closingIndex)}${requestErrorBlock(true)}${content.slice(closingIndex)}` : `${content}${requestErrorBlock(true)}`;
}
return content;
}
function renderOverview() {
if (platformApi?.isEnabled()) return renderApiOverview();
const active = data.tenants.filter(tenant => tenant.status === '正常').length;
const overdue = data.invoices.filter(invoice => invoice.status === '已逾期').reduce((sum, invoice) => sum + invoice.balance, 0);
const openAlerts = data.alerts.filter(alert => alert.status === 'open').slice(0, 4);
const totalStudents = data.tenants.reduce((sum, tenant) => sum + tenant.students, 0);
const totalQuestions = data.banks.reduce((sum, bank) => sum + bank.questions, 0);
return `<div class="page-shell">${pageHead('平台经营工作台', '查看租户规模、订阅账务、平台用量与安全态势。数据更新时间2026-07-23 10:10 CST。', `<button class="button" data-action="export-audit">${icon('download')}导出审计</button><button class="button primary" data-action="refresh">${icon('refresh-cw')}刷新</button>`)}
<section class="metric-grid">${metric('有效租户', active, `${data.tenants.length}`, 'building-2')}${metric('活跃订阅', data.subscriptions.filter(item => item.status === '活跃').length, '未来30天到期 2 条', 'badge-dollar-sign', 'green')}${metric('逾期应收', money(overdue), `涉及 ${data.invoices.filter(item => item.status === '已逾期').length} 家租户`, 'receipt-text', 'orange')}${metric('平台学生规模', formatNumber(totalStudents), 'Mock 最近30日活跃 82%', 'users', 'purple')}${metric('公共题库题目', formatNumber(totalQuestions), `${data.banks.length} 个公共来源库`, 'database-zap', 'cyan')}</section>
<div class="overview-grid"><section class="panel revenue-panel"><div class="panel-head"><div><h2>平台收入与租户增长</h2><span>最近 12 个月 · 不含租户学生端交易流水</span></div><div class="legend"><span><i class="blue"></i>MRR</span><span><i class="green"></i>有效租户</span></div></div><div class="revenue-chart"><div class="chart-axis"><span>¥500k</span><span>¥350k</span><span>¥200k</span><span>0</span></div><div class="chart-plot"><div class="grid"></div><div class="bars">${[42, 48, 52, 58, 64, 71, 78, 82, 88, 94, 101, 108].map((value, index) => `<span><i style="height:${value * 1.65}px"></i><b style="height:${38 + index * 3}px"></b><em>${index + 1}月</em></span>`).join('')}</div></div></div></section>
<section class="panel alert-panel"><div class="panel-head"><div><h2>需要关注</h2><span>按业务影响排序</span></div><button class="link" data-nav="alerts">全部告警</button></div>${openAlerts.map(alert => `<button class="alert-row" data-alert-detail="${alert.id}"><span>${icon(alert.targetType === 'invoice' ? 'circle-dollar-sign' : alert.targetType === 'question_bank' ? 'database-backup' : 'triangle-alert')}</span><div><strong>${escapeHtml(alert.title)}</strong><small>${escapeHtml(alert.summary)}</small></div>${icon('chevron-right')}</button>`).join('') || emptyBlock('没有开放告警', '平台当前没有需要处理的开放告警。')}</section></div>
<div class="overview-lower"><section class="panel"><div class="panel-head"><div><h2>租户状态</h2><span>可进入租户工作列表处理</span></div><button class="link" data-nav="tenants">查看租户</button></div><div class="tenant-health"><div class="health-donut"><strong>${data.tenants.length}</strong><span>全部租户</span></div><div>${[['正常', data.tenants.filter(t => t.status === '正常').length, 68], ['试用', data.tenants.filter(t => t.status === '试用').length, 16], ['待配置', data.tenants.filter(t => t.status === '待配置').length, 16], ['暂停/逾期', data.tenants.filter(t => t.status === '暂停' || t.billing === '逾期').length, 16]].map(([label, count, percent]) => `<p><span>${label}</span><i><b style="width:${percent}%"></b></i><strong>${count}</strong></p>`).join('')}</div></div></section>
<section class="panel"><div class="panel-head"><div><h2>账务结论</h2><span>先行动,再看明细</span></div><button class="link" data-nav="billing">账单工作台</button></div><div class="usage-summary"><div><span>待付款</span><strong>${money(data.invoices.filter(i => i.status === '待付款').reduce((s, i) => s + i.balance, 0))}</strong><small>可发送到期提醒</small></div><div><span>已逾期</span><strong>${money(overdue)}</strong><small>进入催缴处理</small></div><div><span>预估超额</span><strong>${money(data.usage.reduce((s, u) => s + u.estimated, 0))}</strong><small>需确认生成</small></div></div></section>
<section class="panel activity-panel"><div class="panel-head"><div><h2>最近平台事件</h2><span>跨租户关键变更</span></div><button class="link" data-nav="audit">审计日志</button></div><div class="platform-activity">${data.auditLogs.slice(0, 3).map(log => `<div><span>${escapeHtml(log.actor.slice(0, 1))}</span><p><strong>${escapeHtml(log.actor)}</strong> ${escapeHtml(log.details)}<small>${escapeHtml(log.time)} · ${escapeHtml(log.ip)}</small></p></div>`).join('')}</div></section></div></div>`;
}
function renderApiOverview() {
const overview = data.apiOverview || {};
const openAlerts = data.alerts.filter(alert => alert.status === 'open').slice(0, 4);
return `<div class="page-shell">${pageHead('平台经营工作台', `真实 PostgreSQL 数据 · ${state.apiLoadedAt || '正在加载'}`, `<button class="button primary" data-action="refresh">${icon('refresh-cw')}刷新</button>`)}
<section class="metric-grid">${metric('全部租户', overview.tenantCount ?? data.tenants.length, `活跃 ${overview.activeTenantCount ?? 0}`, 'building-2')}${metric('暂停租户', overview.suspendedTenantCount ?? 0, '来自真实租户状态', 'pause-circle', 'orange')}${metric('已支付订单', overview.paidOrderCount ?? 0, `全部订单 ${overview.orderCount ?? 0}`, 'receipt-text', 'green')}${metric('平台收入', money(Number(overview.revenueCents || 0) / 100), '按已支付订单统计', 'circle-dollar-sign', 'cyan')}${metric('题库 / 题目', `${overview.questionBankCount ?? 0} / ${formatNumber(overview.questionCount ?? 0)}`, `学习活跃用户 ${overview.learningActiveUserCount ?? 0}`, 'database-zap', 'purple')}</section>
<div class="overview-grid"><section class="panel"><div class="panel-head"><div><h2>租户状态</h2><span>来自 /api/platform-admin/tenants</span></div><button class="link" data-nav="tenants">查看租户</button></div><div class="tenant-health"><div class="health-donut"><strong>${data.tenants.length}</strong><span>全部租户</span></div><div>${[['正常', data.tenants.filter(item => item.status === '正常').length, 100], ['待配置', data.tenants.filter(item => item.status === '待配置').length, 100], ['暂停', data.tenants.filter(item => item.status === '暂停').length, 100]].map(([label, count, percent]) => `<p><span>${label}</span><i><b style="width:${data.tenants.length ? Math.round(count / data.tenants.length * percent) : 0}%"></b></i><strong>${count}</strong></p>`).join('')}</div></div></section>
<section class="panel alert-panel"><div class="panel-head"><div><h2>开放告警</h2><span>真实审计告警</span></div><button class="link" data-nav="alerts">全部告警</button></div>${openAlerts.map(alert => `<button class="alert-row" data-alert-detail="${alert.id}"><span>${icon('triangle-alert')}</span><div><strong>${escapeHtml(alert.title)}</strong><small>${escapeHtml(alert.summary)}</small></div>${icon('chevron-right')}</button>`).join('') || emptyBlock('没有开放告警', '当前数据库没有需要处理的开放告警。')}</section></div>
<section class="panel activity-panel"><div class="panel-head"><div><h2>最近平台事件</h2><span>来自真实审计日志</span></div><button class="link" data-nav="audit">审计日志</button></div><div class="platform-activity">${data.auditLogs.slice(0, 6).map(log => `<div><span>${escapeHtml(log.actor.slice(0, 1))}</span><p><strong>${escapeHtml(log.actor)}</strong> ${escapeHtml(log.details)}<small>${escapeHtml(log.time)} · ${escapeHtml(log.ip)}</small></p></div>`).join('') || '<p class="auth-help">暂无审计事件</p>'}</div></section></div>`;
}
function renderTenants() {
let rows = data.tenants.filter(tenant => state.tenantFilter === 'all' || tenant.status === state.tenantFilter || tenant.billing === state.tenantFilter);
if (state.tenantPlan !== 'all') rows = rows.filter(tenant => tenant.planCode === state.tenantPlan);
const query = state.tenantSearch.trim().toLowerCase();
if (query) rows = rows.filter(tenant => [tenant.name, tenant.slug, tenant.legal, tenant.host].some(value => value.toLowerCase().includes(query)));
if (state.scenario === 'empty') rows = [];
return `<div class="page-shell">${pageHead('租户列表与创建', '搜索租户并管理主体、域名、套餐、业务状态与账务状态。', `${platformApi?.isEnabled() ? '' : `<button class="button" data-action="reset-mock">${icon('rotate-ccw')}重置 Mock</button>`}${canWrite('platform:tenant:write') ? `<button class="button primary" data-action="create-tenant">${icon('plus')}创建租户</button>` : ''}`)}
<section class="filter-bar"><label>${icon('search')}<input id="tenantSearch" value="${escapeHtml(state.tenantSearch)}" placeholder="搜索租户名称、slug、主体或域名" /></label><select id="tenantPlanFilter"><option value="all">全部套餐</option>${data.plans.map(plan => `<option value="${plan.code}" ${state.tenantPlan === plan.code ? 'selected' : ''}>${plan.name}</option>`).join('')}</select><div class="filter-segment">${['all', '正常', '试用', '暂停', '待配置', '逾期'].map(value => `<button class="${state.tenantFilter === value ? 'is-active' : ''}" data-tenant-filter="${value}">${value === 'all' ? '全部' : value}</button>`).join('')}</div></section>
${rows.length ? `<section class="table-panel"><table class="platform-table"><thead><tr><th>租户</th><th>套餐</th><th>模式</th><th>业务状态</th><th>账务状态</th><th>学生 / 题目</th><th>订阅到期</th><th>待付余额</th><th></th></tr></thead><tbody>${rows.map(tenant => `<tr><td><button class="cell-link tenant-cell" data-tenant-detail="${tenant.id}"><span>${escapeHtml(tenant.name.slice(0, 1))}</span><div><strong>${escapeHtml(tenant.name)}</strong><small>${escapeHtml(tenant.slug)} · ${escapeHtml(tenant.host)}</small></div></button></td><td><strong>${escapeHtml(tenant.plan)}</strong></td><td>${escapeHtml(tenant.mode)}</td><td>${statusBadge(tenant.status)}</td><td>${statusBadge(tenant.billing)}</td><td><strong>${formatNumber(tenant.students)}</strong><small>${formatNumber(tenant.questions)} 题</small></td><td>${tenant.expires || '-'}</td><td><b class="${tenant.balance ? 'money-due' : ''}">${money(tenant.balance)}</b></td><td><div class="row-actions"><button class="row-action" data-tenant-detail="${tenant.id}" title="打开详情">${icon('chevron-right')}</button>${canWrite('platform:tenant:status') ? `<button class="row-action" data-action="prepare-tenant-status" data-tenant-id="${tenant.id}" title="${tenant.status === '暂停' ? '恢复' : '暂停'}">${icon(tenant.status === '暂停' ? 'play' : 'pause')}</button>` : ''}<button class="row-action" data-action="tenant-to-billing" data-tenant-id="${tenant.id}" title="查看账务">${icon('receipt-text')}</button></div></td></tr>`).join('')}</tbody></table><footer><span>共 ${rows.length} 家匹配租户</span><span>筛选、搜索和返回上下文会保留</span></footer></section>` : emptyBlock(query || state.tenantFilter !== 'all' || state.tenantPlan !== 'all' ? '没有匹配的租户' : '还没有租户', query || state.tenantFilter !== 'all' || state.tenantPlan !== 'all' ? '调整搜索词或筛选条件后重试。' : '创建第一个租户及首期订阅基础。', canWrite('platform:tenant:write') ? `<button class="button primary" data-action="create-tenant">${icon('plus')}创建租户</button>` : '')}</div>`;
}
function renderTenantDetail() {
const tenant = tenantById(state.selectedTenant) || data.tenants[0];
if (!tenant) return `<div class="page-shell">${pageHead('租户详情与生命周期', '租户不存在或已被移除。', '', { level: 2 })}${emptyBlock('租户不存在', '返回租户列表选择其他租户。', '<button class="button" data-action="subpage-back">返回列表</button>')}</div>`;
const subscription = data.subscriptions.find(item => item.tenantId === tenant.id);
const invoices = data.invoices.filter(item => item.tenantId === tenant.id);
const usage = data.usage.find(item => item.tenantId === tenant.id);
return `<div class="page-shell">${pageHead(tenant.name, `${tenant.slug} · ${tenant.legal}`, `<button class="button" data-action="open-tenant-portal">${icon('external-link')}打开租户门户</button>${canWrite('platform:tenant:status') ? `<button class="button danger" data-action="prepare-tenant-status" data-tenant-id="${tenant.id}">${icon(tenant.status === '暂停' ? 'play' : 'pause')}${tenant.status === '暂停' ? '恢复租户' : '暂停租户'}</button>` : ''}`, { level: 2 })}
<section class="detail-summary"><div><span>业务状态</span>${statusBadge(tenant.status)}</div><div><span>账务状态</span>${statusBadge(tenant.billing)}</div><div><span>当前套餐</span><strong>${escapeHtml(tenant.plan)}</strong></div><div><span>订阅到期</span><strong>${tenant.expires || '-'}</strong></div><div><span>待付余额</span><strong class="${tenant.balance ? 'money-due' : ''}">${money(tenant.balance)}</strong></div></section>
<div class="detail-grid"><section class="panel"><div class="panel-head"><div><h2>主体、负责人和域名</h2><span>平台只展示链接,不冒充租户登录</span></div></div><dl class="detail-list"><div><dt>主体名称</dt><dd>${escapeHtml(tenant.legal)}</dd></div><div><dt>租户 ID</dt><dd><code>${tenant.id}</code></dd></div><div><dt>负责人</dt><dd>${escapeHtml(tenant.owner)} · ${escapeHtml(tenant.ownerPhone)}</dd></div><div><dt>开通时间</dt><dd>${escapeHtml(tenant.createdAt)} CST</dd></div><div><dt>学生端域名</dt><dd>${escapeHtml(tenant.host)} ${statusBadge('已验证')}</dd></div><div><dt>租户后台域名</dt><dd>admin.${escapeHtml(tenant.host.replace(/^study\.|^learn\.|^app\.|^tiku\./, ''))} ${statusBadge('已验证')}</dd></div></dl></section>
<section class="panel action-panel"><div class="panel-head"><div><h2>关联工作区</h2><span>详情承接上下文,编辑成功回详情</span></div></div><button data-action="edit-billing-profile">${icon('landmark')}<span><strong>租户账务资料</strong><small>开票抬头、税号、联系人和脱敏账户</small></span>${icon('chevron-right')}</button><button data-action="create-subscription-for-tenant">${icon('badge-check')}<span><strong>管理订阅</strong><small>${subscription ? `${subscription.id} · ${subscription.status}` : '尚无订阅'}</small></span>${icon('chevron-right')}</button><button data-action="tenant-to-billing" data-tenant-id="${tenant.id}">${icon('receipt-text')}<span><strong>服务费账单</strong><small>${invoices.length} 张 · 待付 ${money(tenant.balance)}</small></span>${icon('chevron-right')}</button><button data-action="tenant-to-usage" data-tenant-id="${tenant.id}">${icon('chart-column-big')}<span><strong>用量与超额</strong><small>${usage ? `${usage.source} · 预估 ${money(usage.estimated)}` : '暂无用量记录'}</small></span>${icon('chevron-right')}</button></section></div>
<section class="panel timeline-panel"><div class="panel-head"><div><h2>生命周期轨迹</h2><span>状态变更写入平台审计</span></div></div><div class="timeline"><p class="done"><b>租户创建</b><span>${tenant.createdAt} CST · 主体与初始模式已保存</span></p><p class="done"><b>域名验证</b><span>${tenant.host} · HTTPS 正常</span></p><p class="${subscription ? 'done' : ''}"><b>订阅生效</b><span>${subscription ? `${subscription.start}${subscription.expires}` : '待创建订阅'}</span></p><p class="${tenant.billing === '逾期' ? 'risk' : 'done'}"><b>账务状态</b><span>${tenant.billing}${tenant.balance ? ` · 待付 ${money(tenant.balance)}` : ''}</span></p></div></section></div>`;
}
function renderBillingProfile() {
const tenant = tenantById(state.selectedTenant) || data.tenants[0];
const profile = data.billingProfiles[tenant.id] || {};
const readOnly = !canWrite('platform:tenant:billing_profile');
return `<div class="page-shell">${pageHead('租户账务资料', `${tenant.name} · 银行账号仅展示脱敏值,密钥和完整账号不得回显。`, `<button class="button" data-action="billing-profile-to-invoices">${icon('receipt-text')}关联账单</button>`, { level: 3 })}
<form class="form-workspace" id="billingProfileForm" novalidate><div class="form-error-summary" hidden tabindex="-1"></div><section class="panel form-section"><div class="panel-head"><div><h2>开票与联系人</h2><span>${readOnly ? '当前角色仅可查看' : '必填字段离开时校验,提交时再次校验'}</span></div></div><div class="form-grid"><label><span>开票名称 *</span><input name="billingName" value="${escapeHtml(profile.billingName || tenant.legal)}" ${readOnly ? 'disabled' : ''} required /></label><label><span>统一社会信用代码</span><input name="taxId" value="${escapeHtml(profile.taxId || '')}" ${readOnly ? 'disabled' : ''} placeholder="建议填写 18 位税号" /></label><label><span>财务联系人 *</span><input name="contactName" value="${escapeHtml(profile.contactName || '')}" ${readOnly ? 'disabled' : ''} required /></label><label><span>手机号 *</span><input name="phone" value="${escapeHtml(profile.phone || '')}" ${readOnly ? 'disabled' : ''} inputmode="tel" required /></label><label><span>邮箱 *</span><input name="email" type="email" value="${escapeHtml(profile.email || '')}" ${readOnly ? 'disabled' : ''} required /></label><label class="wide"><span>开票地址</span><input name="billingAddress" value="${escapeHtml(profile.billingAddress || '')}" ${readOnly ? 'disabled' : ''} /></label></div></section>
<section class="panel form-section"><div class="panel-head"><div><h2>发票与银行信息</h2><span>平台只接受脱敏账户尾号</span></div></div><div class="form-grid"><label><span>发票抬头 *</span><input name="invoiceTitle" value="${escapeHtml(profile.invoiceTitle || profile.billingName || tenant.legal)}" ${readOnly ? 'disabled' : ''} required /></label><label><span>发票类型</span><select name="type" ${readOnly ? 'disabled' : ''}><option ${profile.type === '增值税专用发票' ? 'selected' : ''}>增值税专用发票</option><option ${profile.type === '增值税普通发票' ? 'selected' : ''}>增值税普通发票</option></select></label><label><span>开户行</span><input name="bankName" value="${escapeHtml(profile.bankName || '')}" ${readOnly ? 'disabled' : ''} /></label><label><span>银行账号(脱敏)</span><input name="bankAccountMasked" value="${escapeHtml(profile.bankAccountMasked || '')}" ${readOnly ? 'disabled' : ''} placeholder="**** **** **** 1234" /></label></div></section>
<footer class="form-actions"><span>所有时间以 Asia/Shanghai 展示 · 保存将写入审计日志</span>${!readOnly ? `<button class="button" type="button" data-action="subpage-back">取消</button><button class="button primary" type="submit" form="billingProfileForm" ${state.submitting === 'billing-profile' ? 'disabled' : ''}>${state.submitting === 'billing-profile' ? `${icon('loader-circle')}保存中` : `${icon('save')}保存账务资料`}</button>` : ''}</footer></form></div>`;
}
function renderSubscriptions() {
const selectedTenant = state.selectedTenant ? tenantById(state.selectedTenant) : null;
const rows = state.scenario === 'empty' ? [] : data.subscriptions;
return `<div class="page-shell">${pageHead('SaaS 套餐与订阅', '维护套餐能力边界,并为租户创建或续订订阅。当前 API 只提供套餐查询,因此不提供套餐编辑。', `<button class="button" data-action="subscriptions-to-usage">${icon('chart-column-big')}配置超额计费</button>${canWrite('platform:billing:write') ? `<button class="button primary" data-action="create-subscription">${icon('plus')}创建订阅</button>` : ''}`)}
<div class="plan-grid">${data.plans.map((plan, index) => `<article class="plan-card ${index === 1 ? 'is-featured' : ''}"><header><div><span>${plan.name}</span><p>${plan.description}</p></div><b>${plan.tenantCount} 家租户</b></header><strong>${money(plan.amount)}<small>/年</small></strong><ul>${plan.quotas.map(item => `<li>${icon('check')}${item}</li>`).join('')}</ul><button class="button" data-action="select-plan" data-plan-code="${plan.code}">选择此套餐</button></article>`).join('')}</div>
${rows.length ? `<section class="table-panel subscription-table"><div class="table-head"><div><h2>活跃与试用订阅</h2><span>${rows.length}${selectedTenant ? ` · 已定位 ${selectedTenant.name}` : ''}</span></div><button class="button" data-action="subscription-to-billing">${icon('receipt-text')}进入账单候选</button></div><table class="platform-table"><thead><tr><th>租户</th><th>订阅号</th><th>套餐</th><th>周期</th><th>开始时间</th><th>到期时间</th><th>自动续费</th><th>状态</th><th></th></tr></thead><tbody>${rows.map(subscription => { const tenant = tenantById(subscription.tenantId); return `<tr class="${selectedTenant?.id === tenant?.id ? 'is-highlighted' : ''}"><td><strong>${escapeHtml(tenant?.name || '-')}</strong><small>${escapeHtml(tenant?.slug || '-')}</small></td><td><code>${subscription.id}</code></td><td>${planByCode(subscription.planCode)?.name || subscription.planCode}</td><td>${subscription.cycle}</td><td>${subscription.start}</td><td>${subscription.expires}</td><td><button class="switch ${subscription.autoRenew ? 'is-on' : ''}" data-action="toggle-auto-renew" data-subscription-id="${subscription.id}" ${canWrite('platform:billing:write') ? '' : 'disabled'}><i></i></button></td><td>${statusBadge(subscription.status)}</td><td><button class="row-action" data-tenant-detail="${subscription.tenantId}">${icon('chevron-right')}</button></td></tr>`; }).join('')}</tbody></table></section>` : emptyBlock('还没有订阅', '创建租户订阅后将在此显示。', canWrite('platform:billing:write') ? '<button class="button primary" data-action="create-subscription">创建订阅</button>' : '')}</div>`;
}
function renderBilling() {
const query = state.invoiceSearch.trim().toLowerCase();
const rows = state.scenario === 'empty' ? [] : data.invoices.filter(invoice => (!state.selectedTenant || invoice.tenantId === state.selectedTenant) && (state.invoiceStatus === 'all' || invoice.status === state.invoiceStatus) && (!query || [invoice.id, invoice.type, invoice.period, invoice.note, tenantById(invoice.tenantId)?.name].some(value => String(value || '').toLowerCase().includes(query))));
const unpaid = data.invoices.filter(invoice => invoice.status === '待付款').reduce((sum, invoice) => sum + invoice.balance, 0);
const overdue = data.invoices.filter(invoice => invoice.status === '已逾期').reduce((sum, invoice) => sum + invoice.balance, 0);
return `<div class="page-shell">${pageHead('服务费账单', '查询、手工创建、预览订阅账单候选并通过 dry-run 确认批量生成。', `${canWrite('platform:billing:write') ? `<button class="button" data-action="preview-invoice-candidates">${icon('scan-search')}预览候选</button><button class="button primary" data-action="create-invoice">${icon('plus')}手工创建</button>` : ''}`)}
<section class="metric-grid billing-metrics">${metric('本月应收', money(data.invoices.reduce((s, i) => s + i.total, 0)), `${data.invoices.length} 张账单`, 'receipt-text')}${metric('已收款', money(data.invoices.reduce((s, i) => s + i.paid, 0)), '人工收款需二次确认', 'circle-dollar-sign', 'green')}${metric('待付款', money(unpaid), '可发送到期提醒', 'clock-3', 'orange')}${metric('已逾期', money(overdue), `${data.invoices.filter(i => i.status === '已逾期').length} 家租户`, 'triangle-alert', 'red')}</section>
<section class="filter-bar"><label>${icon('search')}<input id="invoiceSearch" value="${escapeHtml(state.invoiceSearch)}" placeholder="搜索账单号、租户、类型或账期" /></label><select id="invoiceStatusFilter"><option value="all">全部状态</option>${['待付款', '已逾期', '部分支付', '已支付'].map(value => `<option value="${value}" ${state.invoiceStatus === value ? 'selected' : ''}>${value}</option>`).join('')}</select><button class="button" data-action="apply-invoice-query" ${state.reading === 'invoice-query' ? 'disabled' : ''}>${state.reading === 'invoice-query' ? `${icon('loader-circle')}查询中` : `${icon('search')}查询`}</button></section>${contractResult('invoice-query')}
<nav class="billing-tabs"><button class="is-active">服务费账单</button><button data-nav="dunning">收款与催缴</button></nav>
${rows.length ? `<section class="table-panel"><table class="platform-table"><thead><tr><th>账单号</th><th>租户</th><th>类型 / 账期</th><th>总额</th><th>已付 / 余额</th><th>到期日</th><th>状态</th><th></th></tr></thead><tbody>${rows.map(invoice => { const tenant = tenantById(invoice.tenantId); return `<tr><td><button class="cell-link" data-invoice-detail="${invoice.id}"><strong>${invoice.id}</strong></button></td><td><button class="cell-link" data-tenant-detail="${invoice.tenantId}">${escapeHtml(tenant?.name || '-')}</button></td><td>${invoice.type}<small>${invoice.period}</small></td><td><strong>${money(invoice.total)}</strong></td><td>${money(invoice.paid)}<small>余额 ${money(invoice.balance)}</small></td><td>${invoice.due}</td><td>${statusBadge(invoice.status)}</td><td><div class="row-actions"><button class="row-action" data-invoice-detail="${invoice.id}">${icon('chevron-right')}</button>${invoice.balance && canWrite('platform:billing:payment') ? `<button class="row-action" data-action="confirm-payment" data-invoice-id="${invoice.id}" title="确认收款">${icon('badge-dollar-sign')}</button>` : ''}</div></td></tr>`; }).join('')}</tbody></table></section>` : emptyBlock('没有匹配账单', query || state.invoiceStatus !== 'all' ? '调整账单查询条件后重试。' : state.selectedTenant ? '当前租户没有匹配账单,可清除租户上下文查看全部。' : '账单候选生成后会在这里显示。', state.selectedTenant ? '<button class="button" data-action="clear-tenant-context">查看全部账单</button>' : '')}</div>`;
}
function renderUsage() {
const periodRows = data.usage.filter(item => item.period === state.usagePeriod);
const rows = state.scenario === 'empty' ? [] : periodRows.filter(item => !state.selectedTenant || item.tenantId === state.selectedTenant);
const periodLabel = state.usagePeriod.replace('-', '年') + '月';
return `<div class="page-shell">${pageHead('用量与超额计费', '按账期查看学生席位、题目、存储与流量用量;区分 Worker 自动采集和人工补录。', `<select class="control" id="usagePeriodFilter" aria-label="用量账期"><option value="2026-07" ${state.usagePeriod === '2026-07' ? 'selected' : ''}>2026年7月</option><option value="2026-06" ${state.usagePeriod === '2026-06' ? 'selected' : ''}>2026年6月</option></select>${canWrite('platform:usage:write') ? `<button class="button" data-action="record-usage">${icon('plus')}记录用量</button><button class="button primary" data-action="preview-overage">${icon('scan-search')}预览超额候选</button>` : ''}`)}
<section class="usage-kpis"><div><span>${periodLabel}学生席位</span><strong>${formatNumber(periodRows.reduce((s, i) => s + i.students, 0))}</strong><small>合同总量 176,000</small><i><b style="width:72%"></b></i></div><div><span>题目数量</span><strong>${formatNumber(periodRows.reduce((s, i) => s + i.questions, 0))}</strong><small>合同总量 495,000</small><i><b style="width:58%"></b></i></div><div><span>对象存储</span><strong>${formatNumber(periodRows.reduce((s, i) => s + i.storageGB, 0))} GB</strong><small>合同总量 13.5 TB</small><i><b style="width:64%"></b></i></div><div><span>预估超额费</span><strong>${money(periodRows.reduce((s, i) => s + i.estimated, 0))}</strong><small>涉及 ${periodRows.filter(i => i.estimated).length} 家租户</small><i class="orange"><b style="width:38%"></b></i></div></section>
${rows.length ? `<section class="table-panel"><table class="platform-table usage-table"><thead><tr><th>租户</th><th>来源 / 周期</th><th>学生席位</th><th>题目数量</th><th>存储 / 流量</th><th>超额项</th><th>预估费用</th><th></th></tr></thead><tbody>${rows.map(item => { const tenant = tenantById(item.tenantId); const plan = planByCode(tenant?.planCode); const seatQuota = plan?.code === 'enterprise' ? 20000 : plan?.code === 'pro' ? 8000 : 2000; return `<tr><td><strong>${escapeHtml(tenant?.name || '-')}</strong><small>${escapeHtml(tenant?.plan || '-')}</small></td><td>${item.source}<small>${item.period}</small></td><td><div class="usage-cell"><span>${formatNumber(item.students)} / ${formatNumber(seatQuota)}</span><i><b style="width:${Math.min(100, item.students / seatQuota * 100)}%"></b></i></div></td><td><div class="usage-cell"><span>${formatNumber(item.questions)}</span><i><b style="width:${Math.min(100, item.questions / 1000)}%"></b></i></div></td><td>${item.storageGB} GB<small>${item.trafficTB} TB 流量</small></td><td>${statusBadge(item.overage.length ? item.overage.join(' + ') : '无')}</td><td><strong>${money(item.estimated)}</strong></td><td><button class="row-action" data-tenant-detail="${item.tenantId}">${icon('chevron-right')}</button></td></tr>`; }).join('')}</tbody></table></section>` : emptyBlock('本账期没有用量记录', '可以手工补录,或等待 Worker 自动采集。', canWrite('platform:usage:write') ? '<button class="button primary" data-action="record-usage">记录用量</button>' : '')}</div>`;
}
function renderDunning() {
const overdueInvoices = data.invoices.filter(invoice => invoice.status === '已逾期' || invoice.status === '待付款');
return `<div class="page-shell">${pageHead('收款、逾期与催缴', '人工确认收款、dry-run 预览逾期处理、生成催缴并跟踪通知事件。', `${canWrite('platform:billing:dunning') ? `<button class="button" data-action="preview-overdue">${icon('scan-search')}预览逾期</button>` : ''}${canWrite('platform:billing:notification') ? `<button class="button primary" data-action="new-dunning-channel">${icon('plus')}通知渠道</button>` : ''}`)}
<nav class="billing-tabs"><button class="${state.billingTab === 'collections' ? 'is-active' : ''}" data-billing-tab="collections">收款与催缴</button><button class="${state.billingTab === 'reminders' ? 'is-active' : ''}" data-billing-tab="reminders">提醒记录</button><button class="${state.billingTab === 'channels' ? 'is-active' : ''}" data-billing-tab="channels">通知渠道</button><button class="${state.billingTab === 'events' ? 'is-active' : ''}" data-billing-tab="events">通知事件</button></nav>
${state.billingTab === 'collections' ? `<div class="collection-console"><section class="panel"><div class="panel-head"><div><h2>待收与逾期队列</h2><span>${overdueInvoices.length} 张账单</span></div></div>${overdueInvoices.map(invoice => { const tenant = tenantById(invoice.tenantId); return `<article><span>${icon('building-2')}</span><div><strong>${escapeHtml(tenant?.name || '-')}</strong><small>${invoice.id} · 到期 ${invoice.due} · ${invoice.status}</small></div><b>${money(invoice.balance)}</b><div class="inline-actions"><button class="button" data-action="generate-reminder" data-invoice-id="${invoice.id}">生成催缴</button><button class="button primary" data-action="confirm-payment" data-invoice-id="${invoice.id}">确认收款</button></div></article>`; }).join('') || emptyBlock('没有待处理账单', '已无待收或逾期服务费账单。')}</section><section class="panel"><div class="panel-head"><div><h2>全局催缴策略</h2><span>可由平台任务生成提醒</span></div></div><div class="policy-timeline"><p><b>到期前 7 天</b><span>邮件 + 租户后台通知</span></p><p><b>到期当天</b><span>短信 + 邮件 + 租户后台通知</span></p><p><b>逾期 3 天</b><span>升级财务负责人并限制部分写操作</span></p><p><b>逾期 7 天</b><span>暂停租户业务,保留只读访问</span></p></div></section></div>` : state.billingTab === 'reminders' ? `<section class="table-panel"><table class="platform-table"><thead><tr><th>提醒号</th><th>租户 / 账单</th><th>级别</th><th>渠道</th><th>消息</th><th>时间</th><th>状态</th></tr></thead><tbody>${data.reminders.map(item => `<tr><td><code>${item.id}</code></td><td><strong>${escapeHtml(tenantById(item.tenantId)?.name || '-')}</strong><small>${item.invoiceId}</small></td><td>L${item.level}</td><td>${item.channel}</td><td>${escapeHtml(item.message)}</td><td>${item.date} CST</td><td>${statusBadge(item.status)}</td></tr>`).join('')}</tbody></table></section>` : state.billingTab === 'channels' ? `<section class="table-panel"><table class="platform-table"><thead><tr><th>渠道</th><th>代码</th><th>Provider</th><th>端点</th><th>重试</th><th>状态</th><th></th></tr></thead><tbody>${data.dunningChannels.map(channel => `<tr><td><strong>${escapeHtml(channel.name)}</strong></td><td><code>${channel.code}</code></td><td>${channel.provider}</td><td>${escapeHtml(channel.endpoint)}</td><td>${channel.retry} 次</td><td>${statusBadge(channel.enabled ? '启用' : '已停用')}</td><td><div class="row-actions"><button class="row-action" data-action="edit-dunning-channel" data-channel-id="${channel.id}">${icon('pencil')}</button><button class="switch ${channel.enabled ? 'is-on' : ''}" data-action="toggle-dunning-channel" data-channel-id="${channel.id}"><i></i></button></div></td></tr>`).join('')}</tbody></table></section>` : `<section class="table-panel"><table class="platform-table"><thead><tr><th>事件 ID</th><th>主题</th><th>渠道</th><th>尝试</th><th>更新时间</th><th>结果</th><th>错误</th><th></th></tr></thead><tbody>${(data.dunningEvents || []).map(event => `<tr><td><code>${event.id}</code></td><td>${escapeHtml(event.subject)}</td><td>${escapeHtml(notificationChannelName(event.channelId))}</td><td>${event.attempts}</td><td>${event.updatedAt}</td><td>${statusBadge(event.status)}</td><td>${escapeHtml(event.error || '-')}</td><td><button class="row-action" data-notification-detail="${event.id}">${icon('chevron-right')}</button></td></tr>`).join('')}</tbody></table></section>`}</div>`;
}
function renderBank() {
const selected = bankById(state.selectedBank) || data.banks[0];
const query = state.bankSearch.trim().toLowerCase();
const bankRows = data.banks.filter(bank => !query || [bank.name, bank.catalog, bank.source, bank.version].some(value => String(value).toLowerCase().includes(query)));
return `<div class="page-shell">${pageHead('公共题库目录与授权', '维护公共题库目录、授权规则与租户采用同步状态。授权禁用不会静默删除租户快照。', `<label class="toggle-label"><input type="checkbox" id="includeTenantBanks" ${state.bankIncludeTenant ? 'checked' : ''} />含租户库</label>${canWrite('platform:question_bank:grant') ? `<button class="button primary" data-action="new-grant">${icon('plus')}新建授权</button>` : ''}`)}
<section class="filter-bar"><label>${icon('search')}<input id="bankSearch" value="${escapeHtml(state.bankSearch)}" placeholder="搜索题库名称、目录、来源或版本" /></label><button class="button" data-action="apply-bank-search" ${state.reading === 'bank-query' ? 'disabled' : ''}>${state.reading === 'bank-query' ? `${icon('loader-circle')}查询中` : `${icon('search')}查询`}</button></section>${contractResult('bank-query')}${contractResult('bank-sync')}
<nav class="billing-tabs"><button class="${state.bankTab === 'catalog' ? 'is-active' : ''}" data-bank-tab="catalog">题库目录</button><button class="${state.bankTab === 'grants' ? 'is-active' : ''}" data-bank-tab="grants">授权规则</button><button class="${state.bankTab === 'sync' ? 'is-active' : ''}" data-bank-tab="sync">采用与同步</button></nav>
${state.bankTab === 'catalog' ? `<div class="bank-layout"><aside class="bank-tree"><header><strong>公共题库目录</strong><span>${bankRows.length} 个</span></header>${bankRows.map(bank => `<button class="${bank.id === selected.id ? 'is-active' : ''}" data-bank-select="${bank.id}">${icon('library-big')}<span><strong>${escapeHtml(bank.name)}</strong><small>${formatNumber(bank.questions)} 题 · ${bank.source}</small></span>${icon('chevron-right')}</button>`).join('') || '<p class="tree-empty">没有匹配题库</p>'}${state.bankIncludeTenant ? `<button>${icon('building-2')}<span><strong>启航租户本地库</strong><small>68,420 题 · 只读来源查看</small></span>${icon('chevron-right')}</button>` : ''}</aside><section class="bank-content"><header><div><span>${escapeHtml(selected.catalog)}</span><h2>${escapeHtml(selected.name)}</h2><p>当前版本 ${selected.version} · 来源 ${selected.source}</p></div><button class="button" data-action="preview-bank-sync">采用与同步</button></header><div class="bank-stats"><div><span>题目</span><strong>${formatNumber(selected.questions)}</strong></div><div><span>授权规则</span><strong>${data.grants.filter(grant => grant.bankId === selected.id).length}</strong></div><div><span>采用租户</span><strong>${data.bankSync.filter(sync => sync.bankId === selected.id).length}</strong></div><div><span>当前状态</span><strong>${selected.status}</strong></div></div><section class="version-card"><div><span class="status success">${selected.status}</span><h3>${selected.version}</h3><p>公共题库内容由平台内容生产链路维护,本页只负责授权和采用状态。</p><small>更新时间 2026-07-23 09:00 CST</small></div><div><button class="button" data-action="preview-bank-sync">查看同步</button><button class="button primary" data-action="new-grant">授权租户</button></div></section></section></div>` : state.bankTab === 'grants' ? `<section class="table-panel"><table class="platform-table"><thead><tr><th>规则</th><th>题库</th><th>授权范围</th><th>套餐 / 租户</th><th>地区 / 科目</th><th>有效期</th><th>本地改编</th><th>状态</th><th></th></tr></thead><tbody>${data.grants.filter(grant => !query || [grant.id, bankById(grant.bankId)?.name, grant.scope, ...grant.allowedRegionIds, ...grant.allowedSubjectIds].some(value => String(value || '').toLowerCase().includes(query))).map(grant => `<tr><td><code>${grant.id}</code></td><td><strong>${escapeHtml(bankById(grant.bankId)?.name || '-')}</strong></td><td>${grant.scope}</td><td>${grant.allowedPlanCodes.length ? grant.allowedPlanCodes.map(code => planByCode(code)?.name || code).join('、') : grant.allowedTenantIds.map(id => tenantById(id)?.name || id).join('、') || '-'}</td><td>${[...grant.allowedRegionIds, ...grant.allowedSubjectIds].join(' / ') || '不限'}</td><td>${grant.startsAt}${grant.expiresAt || '长期'}</td><td>${grant.localEdit ? '允许' : '只读采用'}</td><td>${statusBadge(grant.status)}</td><td><div class="row-actions"><button class="row-action" data-action="edit-grant" data-grant-id="${grant.id}">${icon('pencil')}</button><button class="switch ${grant.status === '启用' ? 'is-on' : ''}" data-action="toggle-grant" data-grant-id="${grant.id}"><i></i></button></div></td></tr>`).join('')}</tbody></table></section>` : `<section class="table-panel"><div class="table-head"><div><h2>租户采用与同步状态</h2><span>源版本、任务状态与冲突均来自专属查询</span></div><button class="button" data-action="refresh-bank-sync" ${state.reading === 'bank-sync' ? 'disabled' : ''}>${state.reading === 'bank-sync' ? `${icon('loader-circle')}刷新中` : `${icon('refresh-cw')}刷新同步状态`}</button></div><table class="platform-table"><thead><tr><th>租户</th><th>题库</th><th>当前版本</th><th>目标版本</th><th>同步进度</th><th>冲突</th><th>状态</th><th></th></tr></thead><tbody>${data.bankSync.filter(sync => !query || [tenantById(sync.tenantId)?.name, bankById(sync.bankId)?.name, sync.current, sync.target, sync.status].some(value => String(value || '').toLowerCase().includes(query))).map(sync => `<tr><td>${escapeHtml(tenantById(sync.tenantId)?.name || '-')}</td><td>${escapeHtml(bankById(sync.bankId)?.name || '-')}</td><td>${sync.current}</td><td>${sync.target}</td><td><div class="usage-cell"><span>${sync.progress}%</span><i><b style="width:${sync.progress}%"></b></i></div></td><td>${sync.conflicts}</td><td>${statusBadge(sync.status)}</td><td><button class="row-action" data-action="sync-detail" data-sync-key="${sync.tenantId}:${sync.bankId}">${icon('chevron-right')}</button></td></tr>`).join('')}</tbody></table></section>`}</div>`;
}
function renderStaff() {
const query = state.staffSearch.trim().toLowerCase();
const staffRows = data.staff.filter(person => !query || [person.name, person.username, person.email, person.phone, person.role, person.status].some(value => String(value || '').toLowerCase().includes(query)));
return `<div class="page-shell">${pageHead('平台员工与权限', '平台权限独立于租户权限;禁用员工可同步撤销会话,且不能禁用最后一名超级管理员。', `${canWrite('platform:staff:write') ? `<button class="button primary" data-action="new-staff">${icon('user-plus')}新建员工</button>` : ''}`)}
<section class="filter-bar"><label>${icon('search')}<input id="staffSearch" value="${escapeHtml(state.staffSearch)}" placeholder="搜索姓名、账号、邮箱、角色或状态" /></label><button class="button" data-action="apply-staff-search">${icon('search')}查询员工</button></section>
<div class="staff-layout"><section class="panel"><div class="panel-head"><div><h2>平台员工</h2><span>${staffRows.length} 名匹配 · ${data.staff.filter(item => item.status === '正常').length} 名活跃</span></div></div>${staffRows.map(person => `<article class="staff-row"><span>${escapeHtml(person.name.slice(0, 1))}</span><div><strong>${escapeHtml(person.name)}</strong><small>${escapeHtml(person.email)} · ${escapeHtml(person.phone)}</small></div><b>${escapeHtml(person.role)}</b><em>${person.permissions.includes('*') ? '全部权限' : `${person.permissions.length} 项权限`}</em><i class="status ${tone(person.status)}">${person.status}</i><div class="row-actions"><button class="row-action" data-action="edit-staff" data-staff-id="${person.id}">${icon('pencil')}</button><button class="switch ${person.status === '正常' ? 'is-on' : ''}" data-action="toggle-staff" data-staff-id="${person.id}"><i></i></button></div></article>`).join('') || emptyBlock('没有匹配员工', '调整姓名、账号、邮箱、角色或状态关键词。')}</section><section class="panel"><div class="panel-head"><div><h2>权限目录</h2><span>遵循最小权限;当前演示角色可在顶部切换</span></div></div>${Object.entries(roleDefinitions).filter(([key]) => key !== 'readonly').map(([key, role]) => `<article class="role-row"><span>${icon('shield-check')}</span><div><strong>${role.label}</strong><small>${role.permissions.includes('*') ? '全部平台权限' : role.permissions.join(' · ')}</small></div><b>${data.staff.filter(person => person.role === role.label).length}人</b><button class="button" data-action="new-staff" data-role-key="${key}">按此角色创建</button></article>`).join('')}</section></div></div>`;
}
function renderAudit() {
let rows = data.auditLogs.filter(log => state.auditSeverity === 'all' || log.severity === state.auditSeverity);
if (state.auditRange === '24h') rows = rows.filter(log => log.time.startsWith('2026-07-23'));
const query = state.auditSearch.toLowerCase();
if (query) rows = rows.filter(log => [log.id, log.actor, log.action, log.target, log.ip].some(value => value.toLowerCase().includes(query)));
if (state.scenario === 'empty') rows = [];
const relatedAlertCount = data.alerts.filter(alert => rows.some(log => log.targetId === alert.targetId || log.targetId === alert.id)).length;
return `<div class="page-shell">${pageHead('平台审计日志', '检索跨租户敏感操作、账务变更与权限事件导出任务提供文件、hash 和行数。', `<button class="button" data-action="jump-related-alert">${icon('shield-alert')}关联告警 ${relatedAlertCount}</button><button class="button" data-action="export-audit" data-format="csv">${icon('download')}导出 CSV</button><button class="button" data-action="export-audit" data-format="json">${icon('braces')}导出 JSON</button>`)}
<section class="filter-bar audit-filter"><label>${icon('search')}<input id="auditSearch" value="${escapeHtml(state.auditSearch)}" placeholder="搜索操作者、动作、目标或 IP" /></label><select id="auditRangeFilter" aria-label="审计时间范围"><option value="24h" ${state.auditRange === '24h' ? 'selected' : ''}>最近24小时</option><option value="7d" ${state.auditRange === '7d' ? 'selected' : ''}>最近7天</option></select><div class="filter-segment">${['all', '严重', '高', '中'].map(value => `<button class="${state.auditSeverity === value ? 'is-active' : ''}" data-audit-severity="${value}">${value === 'all' ? '全部级别' : value}</button>`).join('')}</div></section>
${rows.length ? `<section class="table-panel"><table class="platform-table audit-table"><thead><tr><th>时间</th><th>操作者</th><th>动作</th><th>目标</th><th>级别</th><th>结果</th><th>来源 IP</th><th></th></tr></thead><tbody>${rows.map(log => `<tr><td>${escapeHtml(log.time)}</td><td><strong>${escapeHtml(log.actor)}</strong><small>${escapeHtml(log.role)}</small></td><td><code>${escapeHtml(log.action)}</code></td><td>${escapeHtml(log.target)}</td><td><span class="severity ${tone(log.severity)}">${log.severity}</span></td><td>${statusBadge(log.result)}</td><td><code>${escapeHtml(log.ip)}</code></td><td><button class="row-action" data-audit-detail="${log.id}">${icon('chevron-right')}</button></td></tr>`).join('')}</tbody></table></section>` : emptyBlock('没有审计日志', '调整时间范围、严重级别或搜索条件。')}</div>`;
}
function renderAlerts() {
let alerts = state.alertStatus === 'all' ? data.alerts : data.alerts.filter(alert => alert.status === state.alertStatus);
if (state.scenario === 'empty') alerts = [];
return `<div class="page-shell">${pageHead('审计告警与通知', '按 open → acknowledged → resolved/ignored 流转;状态接口禁止重新打开。规则当前只读。', `${canWrite('platform:audit:notification') ? `<button class="button primary" data-action="new-audit-channel">${icon('plus')}新建通知渠道</button>` : ''}`)}
<section class="panel alert-rules"><div class="panel-head"><div><h2>告警规则</h2><span>后端只读 · ${data.alertRules.length} 条启用规则</span></div><code>GET /api/platform-admin/audit-alert-rules</code></div><div class="rule-grid">${data.alertRules.map(rule => `<article><span class="severity ${tone(rule.severity)}">${rule.severity}</span><div><strong>${escapeHtml(rule.name)}</strong><small><code>${escapeHtml(rule.code)}</code> · ${escapeHtml(rule.window)} · ${escapeHtml(rule.threshold)}</small><p>${escapeHtml(rule.target)}</p></div>${statusBadge(rule.enabled ? '启用' : '已停用')}</article>`).join('')}</div></section>
<div class="alert-layout"><section class="panel"><div class="panel-head"><div><h2>告警队列</h2><span>开放 ${data.alerts.filter(a => a.status === 'open').length} · 已确认 ${data.alerts.filter(a => a.status === 'acknowledged').length} · 已结束 ${data.alerts.filter(a => ['resolved', 'ignored'].includes(a.status)).length}</span></div><div class="filter-segment">${[['all', '全部'], ['open', '开放'], ['acknowledged', '已确认'], ['resolved', '已解决'], ['ignored', '已忽略']].map(([value, label]) => `<button class="${state.alertStatus === value ? 'is-active' : ''}" data-alert-status="${value}">${label}</button>`).join('')}</div></div>${alerts.length ? alerts.map(alert => `<article class="security-alert"><span class="alert-level ${tone(alert.severity)}">${alert.severity}</span><div><small>${alert.id} · ${alert.owner}</small><strong>${escapeHtml(alert.title)}</strong><p>${escapeHtml(alert.summary)}</p><em>${alert.updatedAt}</em></div><b class="status ${tone(alert.status)}">${alertStatusLabel(alert.status)}</b><div class="inline-actions"><button class="button" data-alert-detail="${alert.id}">查看证据</button>${alert.status === 'open' && canWrite('platform:audit:alert') ? `<button class="button primary" data-action="transition-alert" data-alert-id="${alert.id}" data-next-status="acknowledged">确认</button>` : ''}${['open', 'acknowledged'].includes(alert.status) && canWrite('platform:audit:alert') ? `<button class="button" data-action="transition-alert" data-alert-id="${alert.id}" data-next-status="resolved">解决</button>` : ''}</div></article>`).join('') : emptyBlock('当前筛选没有告警', '告警规则仍保持只读,可切换筛选查看历史状态。')}</section>
<aside class="channel-panel"><section class="panel"><div class="panel-head"><div><h2>通知渠道</h2><span>${data.auditChannels.filter(channel => channel.enabled).length} 个启用</span></div></div>${data.auditChannels.map(channel => `<article class="channel-row"><span>${icon(channel.provider === '邮件' ? 'mail' : channel.provider === '短信' ? 'message-square' : 'webhook')}</span><div><strong>${escapeHtml(channel.name)}</strong><small>${escapeHtml(channel.endpoint)} · ≥${channel.minSeverity} · ${channel.maxAttempts} 次</small></div><b class="status ${tone(channel.lastStatus)}">${channel.lastStatus}</b><div class="row-actions"><button class="row-action" data-action="edit-audit-channel" data-channel-id="${channel.id}">${icon('pencil')}</button><button class="switch ${channel.enabled ? 'is-on' : ''}" data-action="toggle-audit-channel" data-channel-id="${channel.id}"><i></i></button></div></article>`).join('')}</section><section class="panel failure-panel"><div class="panel-head"><div><h2>通知失败事件</h2><span>最近24小时</span></div></div><div>${data.notificationEvents.filter(event => event.status === '失败').map(event => `<article><strong>${escapeHtml(event.subject)}</strong><p>${escapeHtml(event.error)} · 已尝试 ${event.attempts} 次</p><div class="inline-actions"><button class="button" data-notification-detail="${event.id}">查看详情</button><button class="button" data-action="retry-notification" data-event-id="${event.id}">立即重试</button></div></article>`).join('') || '<p>没有通知失败事件。</p>'}</div></section></aside></div></div>`;
}
const templates = { overview: renderOverview, tenants: renderTenants, tenantDetail: renderTenantDetail, billingProfile: renderBillingProfile, subscriptions: renderSubscriptions, billing: renderBilling, usage: renderUsage, dunning: renderDunning, bank: renderBank, staff: renderStaff, audit: renderAudit, alerts: renderAlerts };
function render() {
renderNav();
const meta = pageMeta[state.view] || pageMeta.overview;
pageTitle.textContent = `${meta.id} · ${meta.title}`;
mainView.innerHTML = renderScenario((templates[state.view] || renderOverview)());
closeMobileMenu();
roleSelect.value = state.role; scenarioSelect.value = state.scenario; responseSelect.value = state.response;
updateCounters(); refreshIcons();
window.decoratePlatformSpec?.(mainView, meta.id);
decorateAccessibleControls(mainView);
updateBackendStatus();
}
function renderOverlay() {
if (!state.modal) { overlayRoot.innerHTML = ''; return; }
const renderer = modalTemplates[state.modal];
overlayRoot.innerHTML = renderer ? renderer() : '';
refreshIcons();
window.decoratePlatformSpec?.(overlayRoot, pageMeta[state.view]?.id || '');
decorateAccessibleControls(overlayRoot);
requestAnimationFrame(() => {
const modal = overlayRoot.querySelector('.modal');
modal?.querySelector('input:not([type="hidden"]),select,textarea,button')?.focus();
});
}
let toastTimer;
let lastOverlayTrigger = null;
function showToast(message, kind = 'success') {
toast.querySelector('span').textContent = message;
toast.classList.toggle('is-error', kind === 'error');
toast.classList.add('is-visible');
clearTimeout(toastTimer); toastTimer = setTimeout(() => toast.classList.remove('is-visible'), 2800);
}
function openModal(name, modalData = {}) {
const activeElement = document.activeElement;
if (!state.modal && activeElement instanceof HTMLElement && activeElement !== document.body && !overlayRoot.contains(activeElement)) lastOverlayTrigger = activeElement;
state.modal = name;
state.modalData = { ...modalData, dirty: false };
renderOverlay();
}
function markDirty(scope) { dirtyScopes.add(scope); if (scope === 'modal') state.modalData.dirty = true; }
function clearDirty(scope = '') { if (scope) dirtyScopes.delete(scope); else dirtyScopes.clear(); if (!scope || scope === 'modal') state.modalData.dirty = false; }
function hasUnsavedChanges(scope = '') { return scope ? dirtyScopes.has(scope) || (scope === 'modal' && state.modalData.dirty) : dirtyScopes.size > 0 || Boolean(state.modalData.dirty); }
function renderLeaveGuard() {
if (!pendingLeave) return;
let root = document.querySelector('#unsavedLeaveGuard');
if (!root) { root = document.createElement('div'); root.id = 'unsavedLeaveGuard'; document.body.append(root); }
root.className = 'unsaved-leave-guard';
root.innerHTML = `<section class="modal leave-guard-card" role="alertdialog" aria-modal="true" aria-labelledby="leave-guard-title" aria-describedby="leave-guard-description"><header><div><small>全局 · 离开保护</small><h2 id="leave-guard-title">有未保存的修改</h2><p>离开前请确认如何处理当前编辑内容。</p></div><button class="icon-button" data-leave-choice="stay" aria-label="继续编辑">${icon('x')}</button></header><div class="confirm-impact"><span>${icon('triangle-alert')}</span><p id="leave-guard-description">放弃离开后,本次尚未提交的表单修改不会写入 Mock 数据;已保存内容不受影响。</p></div><footer><button class="button" data-leave-choice="stay">继续编辑</button><button class="button danger" data-leave-choice="discard">放弃并离开</button></footer></section>`;
refreshIcons(); requestAnimationFrame(() => root.querySelector('[data-leave-choice="stay"]')?.focus());
}
function closeLeaveGuard() { document.querySelector('#unsavedLeaveGuard')?.remove(); }
function requestLeave(execute, { scope = '' } = {}) {
if (!hasUnsavedChanges(scope)) { execute(); return true; }
pendingLeave = { execute, scope }; renderLeaveGuard(); return false;
}
function closeModal(options = {}) {
const close = () => {
clearDirty('modal');
const trigger = lastOverlayTrigger;
state.modal = '';
state.modalData = {};
lastOverlayTrigger = null;
renderOverlay();
if (trigger?.isConnected) requestAnimationFrame(() => trigger.focus());
};
if (state.modal && !options.force && hasUnsavedChanges('modal')) return requestLeave(close, { scope: 'modal' });
close();
return true;
}
function navigationSnapshot() {
return { view: state.view, selectedTenant: state.selectedTenant, selectedInvoice: state.selectedInvoice, selectedAlert: state.selectedAlert, tenantFilter: state.tenantFilter, tenantPlan: state.tenantPlan, tenantSearch: state.tenantSearch, invoiceSearch: state.invoiceSearch, invoiceStatus: state.invoiceStatus, billingTab: state.billingTab, bankTab: state.bankTab, bankIncludeTenant: state.bankIncludeTenant, bankSearch: state.bankSearch, selectedBank: state.selectedBank, staffSearch: state.staffSearch, usagePeriod: state.usagePeriod, auditRange: state.auditRange, auditSeverity: state.auditSeverity, auditSearch: state.auditSearch, alertStatus: state.alertStatus, scrollY: window.scrollY };
}
function syncNavigationState() { if (history.state?.[HISTORY_APP_KEY]) history.replaceState({ ...history.state, snapshot: navigationSnapshot() }, '', location.href); }
function navigate(view, options = {}) {
if (!pageMeta[view]) return;
if (platformApi?.isEnabled() && !API_CONNECTED_PAGES.has(view)) {
showToast('该模块尚未接入真实后端,本阶段不会回退到 Mock 数据', 'error');
return false;
}
const perform = () => {
clearDirty();
if (!hasPermission(pageMeta[view].permission)) { state.previousView = state.view; state.view = view; state.scenario = 'forbidden'; render(); return; }
if (options.push !== false) {
const nextEntry = navigationSnapshot();
const last = state.history[state.history.length - 1];
if (!last || last.view !== nextEntry.view || last.selectedTenant !== nextEntry.selectedTenant) state.history = [...state.history.slice(-2), nextEntry];
if (options.browser !== false && history.state?.[HISTORY_APP_KEY]) history.replaceState({ ...history.state, snapshot: nextEntry }, '', location.href);
}
state.previousView = state.view; state.view = view; state.scenario = 'normal'; closeModal({ force: true }); render(); scrollTo(0, 0);
if (options.browser !== false) { historySequence += 1; history.pushState({ [HISTORY_APP_KEY]: true, sequence: historySequence, snapshot: navigationSnapshot() }, '', location.href); }
if (['tenantDetail', 'billingProfile'].includes(view)) hydrateTenantDetail(state.selectedTenant);
};
if (!options.force && hasUnsavedChanges()) return requestLeave(perform);
perform(); return true;
}
function navigateBack() {
requestLeave(() => {
clearDirty();
if (history.state?.[HISTORY_APP_KEY] && history.length > 1) { history.back(); return; }
const prior = state.history.pop();
if (!prior) { state.view = 'overview'; state.previousView = ''; render(); scrollTo(0, 0); return; }
Object.assign(state, prior); render(); requestAnimationFrame(() => scrollTo(0, prior.scrollY || 0));
});
}
function closeSubpage() {
requestLeave(() => { clearDirty(); state.history = []; state.view = 'overview'; state.previousView = ''; state.scenario = 'normal'; render(); scrollTo(0, 0); history.replaceState({ [HISTORY_APP_KEY]: true, sequence: historySequence, snapshot: navigationSnapshot() }, '', location.href); });
}
window.addEventListener('popstate', event => {
const entry = event.state;
if (!entry?.[HISTORY_APP_KEY] || !entry.snapshot) return;
const targetSequence = Number(entry.sequence) || 0;
if (restoringHistory) { restoringHistory = false; return; }
if (hasUnsavedChanges() && !bypassHistoryGuard) {
const delta = targetSequence - historySequence;
restoringHistory = true; history.go(-delta);
requestLeave(() => { bypassHistoryGuard = true; history.go(delta); });
return;
}
bypassHistoryGuard = false; historySequence = targetSequence; clearDirty();
const currentView = state.view;
Object.assign(state, entry.snapshot, { previousView: currentView, scenario: 'normal', modal: '', modalData: {} });
lastOverlayTrigger = null;
if (state.history.length && state.history[state.history.length - 1].view === state.view) state.history.pop();
render();
requestAnimationFrame(() => scrollTo(0, entry.snapshot.scrollY || 0));
if (entry.rootGuard) { historySequence += 1; history.pushState({ [HISTORY_APP_KEY]: true, sequence: historySequence, snapshot: navigationSnapshot() }, '', location.href); }
});
function nextResponse() {
const response = state.response;
if (response !== 'success') { state.response = 'success'; responseSelect.value = 'success'; }
return response;
}
async function simulateWrite({ key, permission, validate, apply, success, close = true, rerender = true, operationId = '', body, request: executeRequest }) {
if (!canWrite(permission)) { showToast(`缺少写权限:${permission}`, 'error'); return; }
const validation = validate?.();
if (validation) { showFormErrors(validation); return; }
const mockResponse = platformApi?.isEnabled() ? 'success' : nextResponse();
state.submitting = key;
const activeForm = overlayRoot.querySelector('form') || mainView.querySelector('form');
const submitButton = activeForm && (activeForm.querySelector('[type="submit"]') || document.querySelector(`[type="submit"][form="${activeForm.id}"]`));
const submitMarkup = submitButton?.innerHTML;
if (submitButton) { submitButton.disabled = true; submitButton.innerHTML = `${icon('loader-circle')}提交中`; refreshIcons(); }
const finish = async () => {
if (mockResponse !== 'success') {
state.submitting = '';
const id = requestId();
addAudit(`${key}.failed`, 'mock_request', id, key, mockResponse === 'validation_error' ? '中' : '高', `${mockResponse === 'validation_error' ? '服务端字段校验失败' : 'Mock 写请求失败'}Request ID${id}`, '失败');
showToast(`${mockResponse === 'validation_error' ? '服务端字段校验失败' : '提交失败'} · ${id}`, 'error');
if (mockResponse === 'validation_error') showFormErrors({ server: `服务端拒绝本次输入(${id}` });
if (submitButton) { submitButton.disabled = false; submitButton.innerHTML = submitMarkup; refreshIcons(); }
return;
}
try {
let payload = null;
if (platformApi?.isEnabled()) {
if (typeof executeRequest === 'function') payload = await executeRequest();
else if (operationId) {
const input = typeof body === 'function' ? body() : (body || {});
payload = await platformApi.request(operationId, { body: platformApi.buildBody(operationId, input) });
} else {
throw new Error(`操作 ${key} 尚未配置后端 operationId`);
}
} else {
await new Promise(resolve => setTimeout(resolve, 650));
}
state.submitting = '';
clearDirty(activeForm?.closest('#overlayRoot') ? 'modal' : 'page');
const result = apply(payload); saveData();
if (close) closeModal({ force: true });
if (rerender) render();
showToast(typeof success === 'function' ? success(result) : success);
if (platformApi?.isEnabled()) hydrateFromBackend({ silent: true });
} catch (error) {
const id = error.requestId || requestId();
state.submitting = '';
showToast(`${error.message || '提交失败'} · ${id}`, 'error');
if (error.status === 400) showFormErrors({ server: `${error.message || '服务端字段校验失败'}${id}` });
if (submitButton) { submitButton.disabled = false; submitButton.innerHTML = submitMarkup; refreshIcons(); }
}
};
finish();
}
async function executeBackendMutation(operationId, input, apply, { refresh = true } = {}) {
if (!platformApi?.isEnabled()) {
apply?.(null);
return true;
}
try {
const payload = await platformApi.request(operationId, {
body: platformApi.buildBody(operationId, typeof input === 'function' ? input() : input),
});
apply?.(payload);
if (refresh) hydrateFromBackend({ silent: true });
return true;
} catch (error) {
showToast(`${error.message || '提交失败'}${error.requestId ? ` · ${error.requestId}` : ''}`, 'error');
return false;
}
}
function showFormErrors(errors) {
const form = overlayRoot.querySelector('form') || mainView.querySelector('form');
if (!form) { showToast(Object.values(errors)[0], 'error'); return; }
form.querySelectorAll('.field-error').forEach(node => node.remove());
form.querySelectorAll('.is-invalid').forEach(node => node.classList.remove('is-invalid'));
const summary = form.querySelector('.form-error-summary');
if (summary) { summary.hidden = false; summary.innerHTML = `<strong>请修正以下问题</strong><ul>${Object.values(errors).map(message => `<li>${escapeHtml(message)}</li>`).join('')}</ul>`; summary.focus(); }
Object.entries(errors).forEach(([name, message]) => {
const input = form.elements[name];
if (!input) return;
input.classList.add('is-invalid');
input.insertAdjacentHTML('afterend', `<small class="field-error">${escapeHtml(message)}</small>`);
});
const first = form.querySelector('.is-invalid'); first?.focus();
}
function modalFrame(title, description, content, actions = '', options = {}) {
return `<div class="modal-backdrop"><section class="modal ${options.wide ? 'is-wide' : ''}" role="dialog" aria-modal="true" aria-labelledby="modalTitle"><header><div><small>${options.id || '平台操作'}</small><h2 id="modalTitle">${title}</h2><p>${description}</p></div><button class="icon-button" data-action="close-modal" aria-label="关闭">${icon('x')}</button></header>${content}${actions ? `<footer>${actions}</footer>` : ''}</section></div>`;
}
function formField(label, name, value = '', attrs = '') { return `<label><span>${label}</span><input name="${name}" value="${escapeHtml(value)}" ${attrs} /></label>`; }
function tenantOptions(selected = '') { return data.tenants.map(tenant => `<option value="${tenant.id}" ${tenant.id === selected ? 'selected' : ''}>${escapeHtml(tenant.name)}</option>`).join(''); }
function planOptions(selected = '') { return data.plans.map(plan => `<option value="${plan.code}" ${plan.code === selected ? 'selected' : ''}>${plan.name} · ${money(plan.amount)}/年</option>`).join(''); }
function notificationProviderOptions(selected = '') {
const normalized = ({ Webhook: 'generic', SMTP: 'generic', 邮件: 'generic', 短信: 'generic', 钉钉: 'dingtalk', 飞书: 'feishu', 企微: 'wecom' })[selected] || selected || 'generic';
return [['generic', '通用 Webhook'], ['dingtalk', '钉钉'], ['feishu', '飞书'], ['wecom', '企微']]
.map(([value, label]) => `<option value="${value}" ${normalized === value ? 'selected' : ''}>${label}</option>`)
.join('');
}
function channelEndpointFormValue(channel) {
return platformApi?.isEnabled() && channel.endpointRedacted ? '' : channel.endpoint || '';
}
const modalTemplates = {
mobileBoundary() {
return modalFrame('请在桌面端继续', `${state.modalData.actionLabel || '此操作'}需要完整工作区。`, `<div class="mobile-boundary"><span>${icon('monitor-up')}</span><div><strong>移动端只支持查看与轻量处理</strong><p>租户、账务、用量、授权、员工和通知渠道的创建或复杂编辑,请在宽度不少于 768px 的桌面端完成,避免误操作。</p></div></div>`, `<button class="button primary" data-action="close-modal">知道了</button>`, { id: '全局 · 移动端边界' });
},
createTenant() {
return modalFrame('创建租户', '创建主体、独立域名和首期订阅基础。slug 与域名必须唯一。', `<form id="createTenantForm" class="modal-form" novalidate><div class="form-error-summary" hidden tabindex="-1"></div><div class="form-grid">${formField('slug *', 'slug', '', 'required placeholder="例如 xuehai-zj"')}${formField('租户名称 *', 'name', '', 'required')}${formField('主体名称', 'legalName')}${formField('品牌名称', 'brandName')}${formField('简称', 'shortName')}${formField('主域名 *', 'primaryHost', '', 'required placeholder="study.example.cn"')}<label><span>初始套餐 *</span><select name="planCode">${planOptions('pro')}</select></label><label><span>业务模式</span><select name="mode"><option>试用</option><option>正式</option></select></label><label><span>业务状态</span><select name="status"><option>待配置</option><option>试用</option><option>正常</option></select></label><label><span>账务状态</span><select name="billingStatus"><option>正常</option><option>待付款</option></select></label><label><span>首期金额(元)</span><input name="amount" type="number" min="0" step="0.01" value="0" /></label></div></form>`, `<button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="createTenantForm" ${state.submitting === 'create-tenant' ? 'disabled' : ''}>${state.submitting === 'create-tenant' ? `${icon('loader-circle')}创建中` : '创建租户'}</button>`, { id: 'P-02 · 创建向导', wide: true });
},
tenantStatus() {
const tenant = tenantById(state.modalData.tenantId);
const restoring = tenant.status === '暂停';
return modalFrame(restoring ? '恢复租户' : '暂停租户', `${tenant.name} 的业务状态变更为“${restoring ? '正常' : '暂停'}”。${restoring ? '恢复业务写操作。' : '学生端保留只读访问,新增权益与部分写操作将受限。'}`, `<form id="tenantStatusForm" class="modal-form"><div class="danger-notice">${icon('triangle-alert')}此操作将写入审计日志,并同步刷新租户列表、详情和经营概览。</div><label><span>同步账务状态</span><select name="billingStatus"><option ${tenant.billing === '正常' ? 'selected' : ''}>正常</option><option ${tenant.billing === '待付款' ? 'selected' : ''}>待付款</option><option ${tenant.billing === '逾期' ? 'selected' : ''}>逾期</option></select></label><label><span>变更原因 *</span><textarea name="reason" rows="4" placeholder="请填写可审计的业务原因"></textarea></label><label class="check-row"><input type="checkbox" name="confirm" value="yes" /> 我已确认影响对象和业务后果</label></form>`, `<button class="button" data-action="close-modal">取消</button><button class="button danger" type="submit" form="tenantStatusForm">确认${restoring ? '恢复' : '暂停'}</button>`, { id: 'P-03 · 生命周期' });
},
createSubscription() {
const selectedPlan = state.modalData.planCode || tenantById(state.selectedTenant)?.planCode || 'pro';
return modalFrame('创建租户订阅', '日期与金额必须有效;同一租户的重复活跃订阅会被阻止。', `<form id="subscriptionForm" class="modal-form" novalidate><div class="form-error-summary" hidden tabindex="-1"></div><div class="form-grid"><label><span>租户 *</span><select name="tenantId">${tenantOptions(state.selectedTenant)}</select></label><label><span>套餐 *</span><select name="planCode">${planOptions(selectedPlan)}</select></label><label><span>周期</span><select name="cycle"><option>年度</option><option>月度</option><option>试用</option></select></label><label><span>金额(元)*</span><input name="amount" type="number" min="0" value="${planByCode(selectedPlan)?.amount || 0}" /></label><label><span>开始日期 *</span><input name="start" type="date" value="2026-07-23" /></label><label><span>到期日期 *</span><input name="expires" type="date" value="2027-07-23" /></label><label><span>状态</span><select name="status"><option>活跃</option><option>试用中</option></select></label><label class="check-row"><input name="autoRenew" type="checkbox" checked /> 自动续费</label></div></form>`, `<button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="subscriptionForm">创建订阅</button>`, { id: 'P-05 · 订阅表单', wide: true });
},
createInvoice() {
return modalFrame('手工创建服务费账单', '金额以元展示Mock 写入时显式转换;到期日必须晚于今天。', `<form id="invoiceForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><div class="form-grid"><label><span>租户 *</span><select name="tenantId">${tenantOptions(state.selectedTenant)}</select></label><label><span>账单类型 *</span><select name="type"><option>订阅服务费</option><option>用量超额费</option><option>人工调整</option></select></label><label><span>账期 *</span><input name="period" value="2026年7月" /></label><label><span>金额(元)*</span><input name="amount" type="number" min="0.01" step="0.01" /></label><label><span>到期日 *</span><input name="due" type="date" value="2026-08-15" /></label><label class="wide"><span>备注</span><textarea name="note" rows="3"></textarea></label></div></form>`, `<button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="invoiceForm">创建账单</button>`, { id: 'P-06 · 手工账单', wide: true });
},
invoiceCandidates() {
const candidates = data.subscriptions.filter(subscription => !data.invoices.some(invoice => invoice.note.includes(subscription.id))).map(subscription => ({ subscription, tenant: tenantById(subscription.tenantId) }));
return modalFrame('订阅账单候选 · Dry-run', '预览不会写入账单;已有账单候选将被幂等跳过。', `<div class="candidate-list">${candidates.map(({ subscription, tenant }) => `<label><input type="checkbox" name="candidate" value="${subscription.id}" checked /><span><strong>${escapeHtml(tenant?.name || '-')}</strong><small>${subscription.id} · ${planByCode(subscription.planCode)?.name} · ${money(subscription.amount)}</small></span><button type="button" class="button" data-action="generate-subscription-invoice" data-subscription-id="${subscription.id}"></button></label>`).join('') || '<p></p>'}</div>`, `<button class="button" data-action="close-modal"></button><button class="button" data-action="run-invoice-dry-run">${icon('scan-search')} dry-run</button>${candidates.length ? `<button class="button primary" data-action="confirm-generate-candidates"> ${candidates.length} </button>` : ''}`, { id: 'P-06 · Dry-run', wide: true });
},
payment() {
const invoice = invoiceById(state.modalData.invoiceId);
return modalFrame('人工确认收款', `${invoice.id} · 当前余额 ${money(invoice.balance)}。金额不可超过余额,原始支付载荷不会回显。`, `<form id="paymentForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><div class="form-grid"><label><span>收款金额(元)*</span><input name="amount" type="number" min="0.01" max="${invoice.balance}" value="${invoice.balance}" /></label>${formField('平台收款号 *', 'paymentNo', `PAY-${Date.now().toString().slice(-8)}`, 'required')}${formField('支付渠道', 'provider', '线下银行转账')}${formField('交易流水号 *', 'tradeNo', '', 'required placeholder="仅用于 Mock 入账"')}<label><span>支付时间 *</span><input name="paidAt" type="datetime-local" value="2026-07-23T10:10" /></label></div><label class="check-row"><input type="checkbox" name="confirm" value="yes" /> 我已核对银行流水与收款主体</label></form>`, `<button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="paymentForm">确认入账</button>`, { id: 'P-08 · 资金动作', wide: true });
},
overduePreview() {
const candidates = data.invoices.filter(invoice => invoice.status === '已逾期');
return modalFrame('逾期处理预览 · Dry-run', '本次预览不改变账单或租户状态;确认后异步创建催缴记录。', `<div class="candidate-list">${candidates.map(invoice => `<label><input type="checkbox" name="candidate" value="${invoice.id}" checked /><span><strong>${escapeHtml(tenantById(invoice.tenantId)?.name || '-')}</strong><small>${invoice.id} · ${money(invoice.balance)} · 到期 ${invoice.due}</small></span></label>`).join('') || '<p>当前没有逾期候选。</p>'}</div>`, `<button class="button" data-action="close-modal">关闭</button>${candidates.length ? '<button class="button primary" data-action="confirm-overdue-process">生成催缴任务</button>' : ''}`, { id: 'P-08 · Dry-run' });
},
recordUsage() {
return modalFrame('记录平台用量', '人工记录会标记来源;同租户同账期重复记录会被阻止。', `<form id="usageForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><div class="form-grid"><label><span>租户 *</span><select name="tenantId">${tenantOptions(state.selectedTenant)}</select></label><label><span>账期 *</span><input name="period" type="month" value="2026-08" /></label><label><span>学生席位</span><input name="students" type="number" min="0" value="0" /></label><label><span>题目数量</span><input name="questions" type="number" min="0" value="0" /></label><label><span>存储 GB</span><input name="storageGB" type="number" min="0" value="0" /></label><label><span>流量 TB</span><input name="trafficTB" type="number" min="0" step="0.1" value="0" /></label></div></form>`, `<button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="usageForm">保存记录</button>`, { id: 'P-07 · 人工用量', wide: true });
},
overagePreview() {
const candidates = data.usage.filter(item => item.period === state.usagePeriod && item.estimated > 0 && !data.invoices.some(invoice => invoice.note === `超额 ${item.period}` && invoice.tenantId === item.tenantId));
return modalFrame('超额账单候选 · Dry-run', `${state.usagePeriod} 账期预览;确认后通过异步任务生成账单。`, `<div class="candidate-list">${candidates.map(item => `<label><input type="checkbox" name="candidate" value="${item.tenantId}:${item.period}" checked /><span><strong>${escapeHtml(tenantById(item.tenantId)?.name || '-')}</strong><small>${item.period} · ${item.overage.join(' + ')} · ${money(item.estimated)}</small></span></label>`).join('') || '<p>当前账期无超额候选。</p>'}</div>`, `<button class="button" data-action="close-modal">关闭</button><button class="button" data-action="run-overage-dry-run">${icon('scan-search')}执行 dry-run</button>${candidates.length ? '<button class="button primary" data-action="confirm-overage-invoices">生成超额账单</button>' : ''}`, { id: 'P-07 · Dry-run' });
},
dunningChannel() {
const channel = data.dunningChannels.find(item => item.id === state.modalData.channelId) || {};
const redactedHint = platformApi?.isEnabled() && channel.endpointRedacted ? ' 后端不会回显完整 URL编辑时必须重新输入完整 HTTPS Webhook。' : '';
return modalFrame(channel.id ? '编辑催缴渠道' : '新建催缴渠道', `Webhook secret 为只写字段,保存后不会回显。${redactedHint}`, `<form id="dunningChannelForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><input type="hidden" name="id" value="${channel.id || ''}" /><div class="form-grid">${formField('渠道代码 *', 'code', channel.code || '', 'required')}${formField('渠道名称 *', 'name', channel.name || '', 'required')}<label><span>Provider</span><select name="provider">${notificationProviderOptions(channel.provider)}</select></label>${formField('HTTPS Webhook *', 'endpoint', channelEndpointFormValue(channel), 'required placeholder="https://..."')}${formField('Secret只写', 'secret', '', 'type="password" autocomplete="new-password"')}${formField('最大重试次数', 'retry', channel.retry || 3, 'type="number" min="1" max="10"')}<label class="check-row"><input name="enabled" type="checkbox" ${channel.enabled !== false ? 'checked' : ''} /> 启用渠道</label></div></form>`, `<button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="dunningChannelForm">保存渠道</button>`, { id: 'P-08 · 通知渠道', wide: true });
},
grant() {
const grant = data.grants.find(item => item.id === state.modalData.grantId) || {};
return modalFrame(grant.id ? '编辑公共题库授权' : '新建公共题库授权', '授权范围与允许列表会按后端 DTO 保存;地区和科目字段使用 UUID。', `<form id="grantForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><input type="hidden" name="id" value="${grant.id || ''}" /><div class="form-grid"><label><span>来源题库 *</span><select name="bankId">${data.banks.map(bank => `<option value="${bank.id}" ${bank.id === (grant.bankId || state.selectedBank) ? 'selected' : ''}>${bank.name}</option>`).join('')}</select></label><label><span>授权范围 *</span><select name="scope"><option ${grant.scope === '全部活跃租户' ? 'selected' : ''}>全部活跃租户</option><option ${grant.scope === '指定套餐' ? 'selected' : ''}>指定套餐</option><option ${grant.scope === '指定租户' ? 'selected' : ''}>指定租户</option><option ${grant.scope === '套餐 + 租户' ? 'selected' : ''}>套餐 + 租户</option></select></label><label><span>允许套餐</span><select name="planCode"><option value="">不限制套餐</option>${planOptions(grant.allowedPlanCodes?.[0] || '')}</select></label><label><span>允许租户</span><select name="tenantId"><option value="">不指定租户</option>${tenantOptions(grant.allowedTenantIds?.[0] || '')}</select></label>${formField('地区 IDUUID', 'regionIds', grant.allowedRegionIds?.join(',') || '', 'placeholder="多个 UUID 用逗号分隔"')}${formField('科目 IDUUID', 'subjectIds', grant.allowedSubjectIds?.join(',') || '', 'placeholder="多个 UUID 用逗号分隔"')}<label><span>开始日期 *</span><input name="startsAt" type="date" value="${grant.startsAt || '2026-07-23'}" /></label><label><span>到期日期</span><input name="expiresAt" type="date" value="${grant.expiresAt || ''}" /></label><label class="check-row"><input name="localEdit" type="checkbox" ${grant.localEdit ? 'checked' : ''} /> 允许租户本地改编</label></div></form>`, `<button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="grantForm">保存授权</button>`, { id: 'P-09 · 授权规则', wide: true });
},
staff() {
const person = data.staff.find(item => item.id === state.modalData.staffId) || {};
const presetRole = roleDefinitions[state.modalData.roleKey]?.label || '';
const selectedRole = person.role || presetRole || '租户运营';
const editablePhone = /^1\d{10}$/.test(person.phone || '') ? person.phone : '';
const permissionList = ['platform:overview:read', 'platform:tenant:read', 'platform:tenant:write', 'platform:tenant:status', 'platform:tenant:billing_profile', 'platform:plan:read', 'platform:billing:read', 'platform:billing:write', 'platform:billing:payment', 'platform:billing:dunning', 'platform:billing:notification', 'platform:usage:read', 'platform:usage:write', 'platform:question_bank:read', 'platform:question_bank:grant', 'platform:question_bank:ops', 'platform:staff:read', 'platform:staff:write', 'platform:staff:status', 'platform:audit:read', 'platform:audit:export', 'platform:audit:alert', 'platform:audit:notification'];
const presetPermissions = roleDefinitions[state.modalData.roleKey]?.permissions || [];
return modalFrame(person.id ? '编辑平台员工' : '新建平台员工', 'Auth 用户必须存在;按最小权限分组授权。', `<form id="staffForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><input type="hidden" name="id" value="${person.id || ''}" /><div class="form-grid">${formField('Auth User ID *', 'authUserId', person.authUserId || '', 'required')}${formField('用户名 *', 'username', person.username || '', 'required')}${formField('姓名 *', 'name', person.name || '', 'required')}${formField('邮箱 *', 'email', person.email || '', 'required type="email"')}${formField('手机号', 'phone', editablePhone)}<label><span>员工角色</span><select name="role">${['租户运营', '平台财务', '安全审计', '题库运营', '超级管理员'].map(role => `<option ${selectedRole === role ? 'selected' : ''}>${role}</option>`).join('')}</select></label></div><fieldset><legend>平台权限</legend><div class="permission-grid">${permissionList.map(permission => { const selected = person.permissions?.includes('*') || person.permissions?.includes(permission) || presetPermissions.includes('*') || presetPermissions.includes(permission); return `<label><input type="checkbox" name="permissions" value="${permission}" ${selected ? 'checked' : ''} />${permission}</label>`; }).join('')}</div><button type="button" class="link" data-action="select-permission-group">选择当前角色推荐权限</button><button type="button" class="link" data-action="clear-staff-form">清空整个表单</button></fieldset></form>`, `<button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="staffForm">保存员工</button>`, { id: 'P-10 · 员工权限', wide: true });
},
auditDetail() {
const log = data.auditLogs.find(item => item.id === state.modalData.auditId);
return modalFrame('审计事件详情', `${log.id} · 敏感字段已脱敏。`, `<dl class="modal-detail"><div><dt>时间</dt><dd>${escapeHtml(log.time)}</dd></div><div><dt>操作者</dt><dd>${escapeHtml(log.actor)} · ${escapeHtml(log.role)}</dd></div><div><dt>动作</dt><dd><code>${escapeHtml(log.action)}</code></dd></div><div><dt>目标</dt><dd>${escapeHtml(log.targetType)} · <code>${escapeHtml(log.targetId)}</code></dd></div><div><dt>来源 IP</dt><dd><code>${escapeHtml(log.ip)}</code></dd></div><div class="wide"><dt>详情</dt><dd>${escapeHtml(log.details)}</dd></div></dl>`, `<button class="button" data-copy="${log.id}">${icon('copy')}复制 ID</button><button class="button" data-action="jump-audit-target" data-audit-id="${log.id}">跳转目标对象</button><button class="button primary" data-action="close-modal">完成</button>`, { id: 'P-11 · 只读详情', wide: true });
},
alertDetail() {
const alert = data.alerts.find(item => item.id === (state.modalData.alertId || state.selectedAlert));
return modalFrame('告警证据与处理', `${alert.id} · ${alert.title}`, `<div class="alert-evidence"><div class="evidence-head"><span class="alert-level ${tone(alert.severity)}">${alert.severity}</span><div><strong>${escapeHtml(alert.summary)}</strong><small>${alert.owner} · ${alert.updatedAt}</small></div>${statusBadge(alertStatusLabel(alert.status))}</div><ul>${alert.evidence.map(item => `<li>${icon('file-search')}${escapeHtml(item)}</li>`).join('')}</ul><label><span>处理备注</span><textarea id="alertNote" rows="3" ${['resolved', 'ignored'].includes(alert.status) ? 'disabled' : ''}>${escapeHtml(alert.note)}</textarea></label></div>`, `<button class="button" data-copy="${alert.id}">${icon('copy')}复制 ID</button>${['open', 'acknowledged'].includes(alert.status) ? `<button class="button" data-action="transition-alert" data-alert-id="${alert.id}" data-next-status="ignored">忽略</button><button class="button primary" data-action="transition-alert" data-alert-id="${alert.id}" data-next-status="${alert.status === 'open' ? 'acknowledged' : 'resolved'}">${alert.status === 'open' ? '确认告警' : '解决告警'}</button>` : '<button class="button primary" data-action="close-modal">关闭</button>'}`, { id: 'P-12 · 状态机', wide: true });
},
notificationDetail() {
const event = [...(data.notificationEvents || []), ...(data.dunningEvents || [])].find(item => item.id === state.modalData.eventId);
const channel = event ? notificationChannelName(event.channelId) : '-';
return modalFrame('通知事件详情', `${event?.id || '-'} · 请求与错误摘要已脱敏。`, `<dl class="modal-detail"><div><dt>主题</dt><dd>${escapeHtml(event?.subject || '-')}</dd></div><div><dt>通知渠道</dt><dd>${escapeHtml(channel)}</dd></div><div><dt>状态</dt><dd>${statusBadge(event?.status || '-')}</dd></div><div><dt>尝试次数</dt><dd>${event?.attempts || 0}</dd></div><div><dt>更新时间</dt><dd>${escapeHtml(event?.updatedAt || '-')}</dd></div><div class="wide"><dt>错误摘要</dt><dd>${escapeHtml(event?.error || '无')}</dd></div><div class="wide"><dt>API 契约</dt><dd><code>PlatformAdminAuditController_events</code><br />GET /api/platform-admin/audit-notification-events</dd></div></dl>`, `<button class="button" data-copy="${event?.id || ''}">${icon('copy')}复制事件 ID</button>${event?.status === '失败' ? `<button class="button primary" data-action="retry-notification" data-event-id="${event.id}">重试通知</button>` : '<button class="button primary" data-action="close-modal">关闭</button>'}`, { id: 'P-12 · 通知事件', wide: true });
},
syncDetail() {
const [tenantId, bankId] = String(state.modalData.syncKey || '').split(':');
const sync = data.bankSync.find(item => item.tenantId === tenantId && item.bankId === bankId);
return modalFrame('租户采用与同步详情', `${tenantById(tenantId)?.name || '-'} · ${bankById(bankId)?.name || '-'}`, `<dl class="modal-detail"><div><dt>源版本</dt><dd>${escapeHtml(sync?.current || '-')}</dd></div><div><dt>目标版本</dt><dd>${escapeHtml(sync?.target || '-')}</dd></div><div><dt>任务状态</dt><dd>${statusBadge(sync?.status || '-')}</dd></div><div><dt>同步进度</dt><dd>${sync?.progress || 0}%</dd></div><div><dt>冲突数量</dt><dd>${sync?.conflicts || 0}</dd></div><div><dt>采用范围</dt><dd>${escapeHtml(data.grants.filter(grant => grant.bankId === bankId && (grant.allowedTenantIds.includes(tenantId) || !grant.allowedTenantIds.length)).map(grant => grant.scope).join('、') || '未匹配显式授权')}</dd></div><div class="wide"><dt>API 契约</dt><dd><code>PlatformAdminQuestionBanksController_syncStatus</code><br />GET /api/platform-admin/question-bank-sync-status</dd></div></dl>`, `<button class="button" data-action="close-modal">关闭</button><button class="button primary" data-action="refresh-bank-sync">刷新同步状态</button>`, { id: 'P-09 · 采用同步', wide: true });
},
auditChannel() {
const channel = data.auditChannels.find(item => item.id === state.modalData.channelId) || {};
const redactedHint = platformApi?.isEnabled() && channel.endpointRedacted ? ' 后端不会回显完整 URL编辑时必须重新输入完整 HTTPS Webhook。' : '';
return modalFrame(channel.id ? '编辑审计通知渠道' : '新建审计通知渠道', `secret 只写;状态和严重级别过滤由平台后端执行。${redactedHint}`, `<form id="auditChannelForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><input type="hidden" name="id" value="${channel.id || ''}" /><div class="form-grid">${formField('渠道代码 *', 'code', channel.code || '', 'required')}${formField('渠道名称 *', 'name', channel.name || '', 'required')}<label><span>Provider</span><select name="provider">${notificationProviderOptions(channel.provider)}</select></label>${formField('HTTPS Webhook *', 'endpoint', channelEndpointFormValue(channel), 'required placeholder="https://..."')}${formField('Secret只写', 'secret', '', 'type="password" autocomplete="new-password"')}<label><span>最低级别</span><select name="minSeverity">${['中', '高', '严重'].map(value => `<option ${channel.minSeverity === value ? 'selected' : ''}>${value}</option>`).join('')}</select></label>${formField('状态过滤', 'statusFilter', channel.statusFilter || 'open,acknowledged')}${formField('超时(秒)', 'timeout', channel.timeout || 8, 'type="number" min="1" max="60"')}${formField('最大尝试', 'maxAttempts', channel.maxAttempts || 3, 'type="number" min="1" max="10"')}<label class="check-row"><input name="enabled" type="checkbox" ${channel.enabled !== false ? 'checked' : ''} /> 启用渠道</label></div></form>`, `<button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="auditChannelForm">保存渠道</button>`, { id: 'P-12 · 通知渠道', wide: true });
},
confirmAction() {
const { title, description, impact, confirmLabel = '确认操作', confirmActionName = 'execute-confirmed', tone: confirmTone = 'danger' } = state.modalData;
return modalFrame(title, description, `<div class="confirm-impact"><span>${icon(confirmTone === 'danger' ? 'triangle-alert' : 'circle-help')}</span><p>${escapeHtml(impact)}</p></div>`, `<button class="button" data-action="close-modal">取消</button><button class="button ${confirmTone}" data-action="${confirmActionName}">${escapeHtml(confirmLabel)}</button>`, { id: '全局 · 二次确认' });
},
tasks() {
return modalFrame('异步任务中心', '批量账单、超额账单和导出均返回 task_id可跟踪、重试并跳转结果。', `<div class="task-list">${data.tasks.length ? data.tasks.map(task => `<article data-task-status="${escapeHtml(task.status)}"><span class="task-state ${tone(taskStatusLabel(task.status))}">${icon(task.status === 'completed' ? 'circle-check' : task.status === 'failed' ? 'circle-x' : 'loader-circle')}</span><div><strong>${escapeHtml(task.label)}</strong><small><code>${task.id}</code> · ${task.updatedAt}</small><p>${task.error ? escapeHtml(task.error) : `结果去向:${escapeHtml(task.resultTarget)}`}</p></div>${task.status === 'failed' ? `<button class="button" data-action="retry-task" data-task-id="${task.id}">重试</button>` : statusBadge(taskStatusLabel(task.status))}</article>`).join('') : emptyBlock('没有异步任务', '批量生成、导出或重试后会出现在这里。')}</div>`, `<button class="button" data-action="reset-mock">重置全部 Mock</button><button class="button primary" data-action="close-modal">关闭</button>`, { id: '全局 · 任务中心', wide: true });
},
};
function confirmAction(config, execute) {
openModal('confirmAction', config);
state.modalData.execute = execute;
}
function validateBillingProfile(form) {
const errors = {};
['billingName', 'contactName', 'phone', 'email', 'invoiceTitle'].forEach(name => { if (!fieldValue(form, name)) errors[name] = `${form.elements[name].previousElementSibling?.textContent.replace('*', '').trim() || name}不能为空`; });
const phone = fieldValue(form, 'phone'); if (phone && !/^1\d{10}$/.test(phone)) errors.phone = '请输入 11 位中国大陆手机号';
const email = fieldValue(form, 'email'); if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) errors.email = '请输入有效邮箱地址';
const taxId = fieldValue(form, 'taxId'); if (taxId && !/^[0-9A-Z]{15,20}$/.test(taxId)) errors.taxId = '税号应为 15-20 位大写字母或数字';
const account = fieldValue(form, 'bankAccountMasked'); if (account && !account.includes('*')) errors.bankAccountMasked = '不得提交完整银行账号,请使用脱敏值';
return Object.keys(errors).length ? errors : null;
}
function validateNotificationChannel(values) {
const errors = {};
if (!values.code) errors.code = '渠道代码不能为空';
if (!values.name) errors.name = '渠道名称不能为空';
if (!values.endpoint) errors.endpoint = 'Webhook URL 不能为空';
if (platformApi?.isEnabled() && values.endpoint) {
try {
const url = new URL(values.endpoint);
const local = ['localhost', '127.0.0.1', '::1'].includes(url.hostname);
if (url.protocol !== 'https:' && !local) errors.endpoint = '联调/生产 Webhook 必须使用 HTTPS';
} catch {
errors.endpoint = '请输入完整有效的 Webhook URL';
}
}
return Object.keys(errors).length ? errors : null;
}
document.addEventListener('submit', event => {
event.preventDefault();
const form = event.target;
const desktopOnlyForms = new Set(['createTenantForm', 'tenantStatusForm', 'billingProfileForm', 'subscriptionForm', 'invoiceForm', 'paymentForm', 'usageForm', 'dunningChannelForm', 'grantForm', 'staffForm', 'auditChannelForm']);
if (desktopOnlyForms.has(form.id) && requireDesktop('提交复杂配置或资金操作')) return;
if (form.id === 'createTenantForm') {
const slug = fieldValue(form, 'slug'); const host = fieldValue(form, 'primaryHost'); const name = fieldValue(form, 'name'); const amount = parseMoney(fieldValue(form, 'amount'));
simulateWrite({ key: 'create-tenant', permission: 'platform:tenant:write', operationId: 'PlatformAdminTenantsController_create', body: () => ({ ...Object.fromEntries(new FormData(form)), amount }), validate: () => {
const errors = {}; if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) errors.slug = 'slug 仅支持小写字母、数字和连字符';
if (!name) errors.name = '租户名称不能为空'; if (!/^([a-z0-9-]+\.)+[a-z]{2,}$/i.test(host)) errors.primaryHost = '请输入有效域名';
if (data.tenants.some(item => item.slug === slug)) errors.slug = 'slug 已存在'; if (data.tenants.some(item => item.host === host)) errors.primaryHost = '域名已被其他租户使用'; return Object.keys(errors).length ? errors : null;
}, apply: payload => { const planCode = fieldValue(form, 'planCode'); const plan = planByCode(planCode); const fallback = { id: `tenant_${slug.replaceAll('-', '_')}`, slug, name, legal: fieldValue(form, 'legalName') || name, brand: fieldValue(form, 'brandName') || name, shortName: fieldValue(form, 'shortName') || name, host, plan: plan.name, planCode, status: fieldValue(form, 'status'), billing: fieldValue(form, 'billingStatus') || (amount > 0 ? '待付款' : '正常'), mode: fieldValue(form, 'mode'), students: 0, questions: 0, storageGB: 0, trafficTB: 0, expires: '', balance: amount, owner: '待配置', ownerPhone: '-', createdAt: todayTime() }; const tenant = payload?.item ? platformApi.adapters.tenant({ ...payload.item, planCode, brandName: fallback.brand }, fallback, Object.fromEntries(data.plans.map(item => [item.code, item]))) : fallback; tenant.host = host; tenant.shortName = fallback.shortName; data.tenants.unshift(tenant); if (!platformApi?.isEnabled() && amount > 0) data.invoices.unshift({ id: entityId('INV'), tenantId: tenant.id, type: '首期服务费', period: '首期', total: amount, paid: 0, balance: amount, due: '2026-08-15', status: '待付款', note: '创建租户首期金额' }); state.selectedTenant = tenant.id; addAudit('tenant.create', 'tenant', tenant.id, tenant.name, '高', `创建租户 ${tenant.name},初始套餐 ${tenant.plan},账务状态 ${tenant.billing},首期金额 ${money(amount)}`); state.modal = ''; navigate('tenantDetail'); return tenant; }, success: '租户与首期账务已创建,已进入租户详情', close: false, rerender: false });
}
if (form.id === 'tenantStatusForm') {
const tenant = tenantById(state.modalData.tenantId); const reason = fieldValue(form, 'reason');
simulateWrite({ key: 'tenant-status', permission: 'platform:tenant:status', operationId: 'PlatformAdminTenantsController_status', body: () => ({ tenantId: tenant.id, status: tenant.status === '暂停' ? '正常' : '暂停', billingStatus: fieldValue(form, 'billingStatus'), reason }), validate: () => !reason ? { reason: '必须填写可审计的变更原因' } : !new FormData(form).get('confirm') ? { confirm: '请确认业务影响' } : null, apply: () => { const from = tenant.status; const billingFrom = tenant.billing; tenant.status = from === '暂停' ? '正常' : '暂停'; tenant.billing = fieldValue(form, 'billingStatus') || tenant.billing; addAudit('tenant.status.update', 'tenant', tenant.id, tenant.name, '高', `业务状态 ${from}${tenant.status};账务状态 ${billingFrom}${tenant.billing}。原因:${reason}`); return tenant.status; }, success: nextStatus => `租户已${nextStatus === '暂停' ? '暂停' : '恢复'},账务状态已同步` });
}
if (form.id === 'billingProfileForm') {
simulateWrite({ key: 'billing-profile', permission: 'platform:tenant:billing_profile', operationId: 'PlatformAdminTenantsController_billingProfile', body: () => ({ tenantId: state.selectedTenant, ...Object.fromEntries(new FormData(form)) }), validate: () => validateBillingProfile(form), apply: () => { data.billingProfiles[state.selectedTenant] = Object.fromEntries(new FormData(form)); addAudit('tenant.billing_profile.update', 'tenant', state.selectedTenant, tenantById(state.selectedTenant).name, '高', '更新租户账务与开票资料,银行账号保持脱敏。'); navigateBack(); }, success: '账务资料已保存并回到租户详情', close: false, rerender: false });
}
if (form.id === 'subscriptionForm') {
const values = Object.fromEntries(new FormData(form));
simulateWrite({ key: 'subscription', permission: 'platform:billing:write', operationId: 'PlatformAdminBillingController_createSubscription', body: () => ({ ...values, autoRenew: Boolean(new FormData(form).get('autoRenew')) }), validate: () => { const errors = {}; if (data.subscriptions.some(item => item.tenantId === values.tenantId && item.status === '活跃')) errors.tenantId = '该租户已有活跃订阅,请先处理原订阅'; if (!values.amount || Number(values.amount) < 0) errors.amount = '金额必须大于等于 0'; if (!values.start) errors.start = '开始日期不能为空'; if (!values.expires || values.expires <= values.start) errors.expires = '到期日期必须晚于开始日期'; return Object.keys(errors).length ? errors : null; }, apply: () => { const subscription = { id: entityId('SUB'), tenantId: values.tenantId, planCode: values.planCode, status: values.status, cycle: values.cycle, amount: Number(values.amount), start: values.start, expires: values.expires, autoRenew: Boolean(new FormData(form).get('autoRenew')) }; data.subscriptions.unshift(subscription); const tenant = tenantById(values.tenantId); tenant.planCode = values.planCode; tenant.plan = planByCode(values.planCode).name; tenant.expires = values.expires; addAudit('subscription.create', 'subscription', subscription.id, tenant.name, '高', `创建 ${subscription.cycle} ${tenant.plan} 订阅,金额 ${money(subscription.amount)}`); }, success: '订阅已创建并更新租户套餐' });
}
if (form.id === 'invoiceForm') {
const values = Object.fromEntries(new FormData(form));
simulateWrite({ key: 'invoice', permission: 'platform:billing:write', operationId: 'PlatformAdminBillingController_createInvoice', body: values, validate: () => { const errors = {}; if (!values.amount || Number(values.amount) <= 0) errors.amount = '金额必须大于 0'; if (!values.due) errors.due = '到期日不能为空'; if (!values.period) errors.period = '账期不能为空'; return Object.keys(errors).length ? errors : null; }, apply: () => { const invoice = { id: entityId('INV'), tenantId: values.tenantId, type: values.type, period: values.period, total: Number(values.amount), paid: 0, balance: Number(values.amount), due: values.due, status: '待付款', note: values.note || values.type }; data.invoices.unshift(invoice); const tenant = tenantById(values.tenantId); tenant.balance += invoice.balance; tenant.billing = '待付款'; addAudit('invoice.create', 'invoice', invoice.id, invoice.id, '高', `手工创建 ${money(invoice.total)} 服务费账单。`); }, success: '账单草稿已创建' });
}
if (form.id === 'paymentForm') {
const invoice = invoiceById(state.modalData.invoiceId); const values = Object.fromEntries(new FormData(form)); const amount = parseMoney(values.amount);
simulateWrite({ key: 'payment', permission: 'platform:billing:payment', operationId: 'PlatformAdminBillingController_confirmPayment', body: () => ({ ...values, tenantId: invoice.tenantId, invoiceId: invoice.backendId || invoice.id, amount }), validate: () => { const errors = {}; if (!amount || amount <= 0) errors.amount = '收款金额必须大于 0'; if (amount > invoice.balance) errors.amount = `收款金额不可超过余额 ${money(invoice.balance)}`; if (!values.paymentNo) errors.paymentNo = '平台收款号不能为空'; if (!values.tradeNo) errors.tradeNo = '交易流水号不能为空'; if (!new FormData(form).get('confirm')) errors.confirm = '请先确认已核对银行流水'; return Object.keys(errors).length ? errors : null; }, apply: () => { invoice.paid += amount; invoice.balance -= amount; invoice.status = invoice.balance === 0 ? '已支付' : '部分支付'; const tenant = tenantById(invoice.tenantId); tenant.balance = Math.max(0, tenant.balance - amount); tenant.billing = tenant.balance === 0 ? '正常' : '待付款'; if (tenant.status === '暂停' && tenant.balance === 0) tenant.status = '正常'; addAudit('invoice.payment.confirm', 'invoice', invoice.id, invoice.id, '高', `人工确认收款 ${money(amount)},交易流水仅保存脱敏摘要。`); }, success: '收款已确认,账单与租户余额已更新' });
}
if (form.id === 'usageForm') {
const values = Object.fromEntries(new FormData(form));
simulateWrite({ key: 'usage', permission: 'platform:usage:write', operationId: 'PlatformAdminBillingController_recordUsage', request: () => Promise.all([['students', values.students], ['questions', values.questions], ['storage_gb', values.storageGB], ['traffic_tb', values.trafficTB]].map(([metricKey, metricValue]) => platformApi.request('PlatformAdminBillingController_recordUsage', { body: platformApi.buildBody('PlatformAdminBillingController_recordUsage', { tenantId: values.tenantId, period: values.period, metricKey, metricValue }) }))), validate: () => data.usage.some(item => item.tenantId === values.tenantId && item.period === values.period) ? { period: '该租户账期已有记录,避免周期重叠或重复' } : null, apply: () => { data.usage.unshift({ tenantId: values.tenantId, period: values.period, source: '人工补录', students: Number(values.students), questions: Number(values.questions), storageGB: Number(values.storageGB), trafficTB: Number(values.trafficTB), overage: [], estimated: 0 }); addAudit('usage.create', 'tenant', values.tenantId, tenantById(values.tenantId).name, '中', `人工补录 ${values.period} 平台用量。`); }, success: '用量记录已保存并标记人工来源' });
}
if (form.id === 'dunningChannelForm') {
const values = Object.fromEntries(new FormData(form));
const existing = data.dunningChannels.find(item => item.id === values.id);
simulateWrite({ key: 'dunning-channel', permission: 'platform:billing:notification', operationId: 'PlatformAdminDunningChannelsController_upsertChannel', body: () => ({ ...values, secretRef: existing?.secretRef, reminderTypes: existing?.reminderTypes, reminderChannels: existing?.reminderChannels, minReminderLevel: existing?.minReminderLevel, enabled: Boolean(new FormData(form).get('enabled')) }), validate: () => validateNotificationChannel(values), apply: () => { const next = { id: values.id || entityId('DUN'), code: values.code, name: values.name, provider: values.provider, endpoint: values.endpoint, endpointRedacted: false, secretRef: existing?.secretRef || '', retry: Number(values.retry), enabled: Boolean(new FormData(form).get('enabled')) }; if (existing) Object.assign(existing, next); else data.dunningChannels.unshift(next); addAudit('dunning.channel.upsert', 'notification_channel', next.id, next.name, '高', `保存催缴渠道 ${next.code}secret 保持只写。`); }, success: '催缴通知渠道已保存' });
}
if (form.id === 'grantForm') {
const values = Object.fromEntries(new FormData(form));
simulateWrite({ key: 'grant', permission: 'platform:question_bank:grant', operationId: 'PlatformAdminQuestionBanksController_upsertGrant', body: () => ({ ...values, localEdit: Boolean(new FormData(form).get('localEdit')) }), validate: () => { const errors = {}; const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const splitScope = value => String(value || '').split(/[、,]/).map(item => item.trim()).filter(Boolean); if (values.scope === '指定套餐' && !values.planCode) errors.planCode = '指定套餐范围必须选择套餐'; if (values.scope === '指定租户' && !values.tenantId) errors.tenantId = '指定租户范围必须选择租户'; if (values.scope === '套餐 + 租户' && (!values.planCode || !values.tenantId)) errors.planCode = '混合范围必须同时选择套餐与租户'; if (platformApi?.isEnabled() && splitScope(values.regionIds).some(value => !uuid.test(value))) errors.regionIds = '地区范围必须为 UUID多个值用逗号分隔'; if (platformApi?.isEnabled() && splitScope(values.subjectIds).some(value => !uuid.test(value))) errors.subjectIds = '科目范围必须为 UUID多个值用逗号分隔'; if (values.expiresAt && values.expiresAt <= values.startsAt) errors.expiresAt = '到期日期必须晚于开始日期'; return Object.keys(errors).length ? errors : null; }, apply: () => { const existing = data.grants.find(item => item.id === values.id); const splitScope = value => String(value || '').split(/[、,]/).map(item => item.trim()).filter(Boolean); const next = { id: values.id || entityId('GRANT'), bankId: values.bankId, scope: values.scope, allowedPlanCodes: values.planCode ? [values.planCode] : [], allowedTenantIds: values.tenantId ? [values.tenantId] : [], allowedRegionIds: splitScope(values.regionIds), allowedSubjectIds: splitScope(values.subjectIds), status: existing?.status || '启用', startsAt: values.startsAt, expiresAt: values.expiresAt, localEdit: Boolean(new FormData(form).get('localEdit')) }; if (existing) Object.assign(existing, next); else data.grants.unshift(next); addAudit('bank.grant.update', 'grant', next.id, bankById(next.bankId).name, '高', `保存授权范围 ${next.scope};地区 ${next.allowedRegionIds.join('、') || '不限'};科目 ${next.allowedSubjectIds.join('、') || '不限'}`); }, success: '公共题库授权范围已保存' });
}
if (form.id === 'staffForm') {
const values = Object.fromEntries(new FormData(form)); const permissions = new FormData(form).getAll('permissions');
simulateWrite({ key: 'staff', permission: 'platform:staff:write', operationId: 'PlatformAdminOverviewController_upsertStaff', body: () => ({ ...values, permissions }), validate: () => { const errors = {}; ['authUserId', 'username', 'name', 'email'].forEach(name => { if (!values[name]) errors[name] = `${name} 不能为空`; }); if (platformApi?.isEnabled() && values.authUserId && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(values.authUserId)) errors.authUserId = '真实模式必须填写已存在的 Supabase Auth 用户 UUID'; if (!platformApi?.isEnabled() && values.authUserId && !values.authUserId.startsWith('auth-')) errors.authUserId = 'Mock Auth 用户不存在,请使用 auth- 前缀'; if (!permissions.length) errors.permissions = '至少授予一项平台权限'; return Object.keys(errors).length ? errors : null; }, apply: () => { const existing = data.staff.find(item => item.id === values.id); const next = { id: values.id || entityId('STAFF'), authUserId: values.authUserId, username: values.username, name: values.name, email: values.email, phone: values.phone || '-', role: values.role, permissions, status: existing?.status || '正常', lastActive: existing?.lastActive || '尚未登录' }; if (existing) Object.assign(existing, next); else data.staff.unshift(next); addAudit('staff.upsert', 'staff', next.id, next.name, '高', `保存平台员工及 ${permissions.length} 项有效权限。`); }, success: '平台员工与有效权限已保存' });
}
if (form.id === 'auditChannelForm') {
const values = Object.fromEntries(new FormData(form));
const existing = data.auditChannels.find(item => item.id === values.id);
simulateWrite({ key: 'audit-channel', permission: 'platform:audit:notification', operationId: 'PlatformAdminAuditController_upsertChannel', body: () => ({ ...values, secretRef: existing?.secretRef, enabled: Boolean(new FormData(form).get('enabled')) }), validate: () => validateNotificationChannel(values), apply: () => { const next = { id: values.id || entityId('CH'), code: values.code, name: values.name, provider: values.provider, endpoint: values.endpoint, endpointRedacted: false, secretRef: existing?.secretRef || '', minSeverity: values.minSeverity, statusFilter: values.statusFilter, timeout: Number(values.timeout), maxAttempts: Number(values.maxAttempts), enabled: Boolean(new FormData(form).get('enabled')), lastStatus: existing?.lastStatus || '正常' }; if (existing) Object.assign(existing, next); else data.auditChannels.unshift(next); addAudit('audit.channel.upsert', 'notification_channel', next.id, next.name, '高', `保存审计告警渠道 ${next.code}secret 保持只写。`); }, success: '审计通知渠道已保存' });
}
});
document.addEventListener('click', event => {
if (event.target.classList.contains('modal-backdrop')) {
closeModal();
return;
}
const copy = event.target.closest('[data-copy]'); if (copy) { navigator.clipboard?.writeText(copy.dataset.copy); showToast('已复制到剪贴板'); return; }
const nav = event.target.closest('[data-nav]'); if (nav) { requestLeave(() => { state.selectedTenant = ''; navigate(nav.dataset.nav, { force: true }); }); return; }
const tenant = event.target.closest('[data-tenant-detail]'); if (tenant) { requestLeave(() => { state.selectedTenant = tenant.dataset.tenantDetail; navigate('tenantDetail', { force: true }); }); return; }
const invoice = event.target.closest('[data-invoice-detail]'); if (invoice) { if (requireDesktop('确认账单收款')) return; state.selectedInvoice = invoice.dataset.invoiceDetail; openModal('payment', { invoiceId: invoice.dataset.invoiceDetail }); return; }
const audit = event.target.closest('[data-audit-detail]'); if (audit) { openModal('auditDetail', { auditId: audit.dataset.auditDetail }); return; }
const alertDetail = event.target.closest('[data-alert-detail]'); if (alertDetail) { openModal('alertDetail', { alertId: alertDetail.dataset.alertDetail }); return; }
const notificationDetail = event.target.closest('[data-notification-detail]'); if (notificationDetail) { openModal('notificationDetail', { eventId: notificationDetail.dataset.notificationDetail }); return; }
const tf = event.target.closest('[data-tenant-filter]'); if (tf) { state.tenantFilter = tf.dataset.tenantFilter; syncNavigationState(); render(); return; }
const billingTab = event.target.closest('[data-billing-tab]'); if (billingTab) { state.billingTab = billingTab.dataset.billingTab; syncNavigationState(); render(); return; }
const bankTab = event.target.closest('[data-bank-tab]'); if (bankTab) { state.bankTab = bankTab.dataset.bankTab; syncNavigationState(); render(); return; }
const bankSelect = event.target.closest('[data-bank-select]'); if (bankSelect) { state.selectedBank = bankSelect.dataset.bankSelect; syncNavigationState(); render(); return; }
const auditSeverity = event.target.closest('[data-audit-severity]'); if (auditSeverity) { state.auditSeverity = auditSeverity.dataset.auditSeverity; syncNavigationState(); render(); return; }
const alertStatus = event.target.closest('[data-alert-status]'); if (alertStatus) { state.alertStatus = alertStatus.dataset.alertStatus; syncNavigationState(); render(); return; }
const actionElement = event.target.closest('[data-action]'); const action = actionElement?.dataset.action; if (!action) return;
if (action === 'close-modal') { closeModal(); return; }
if (action === 'execute-confirmed' || action === 'execute-staff-disable') { if (requireDesktop('执行高风险确认操作')) return; const execute = state.modalData.execute; closeModal({ force: true }); execute?.(); return; }
if (action === 'subpage-back') { navigateBack(); return; }
if (action === 'subpage-close') { closeSubpage(); return; }
if (action === 'retry-page') { state.scenario = 'normal'; scenarioSelect.value = 'normal'; render(); return; }
if (action === 'restore-session') { state.scenario = 'normal'; state.view = 'overview'; state.previousView = ''; state.history = []; scenarioSelect.value = 'normal'; render(); showToast('Mock 会话已恢复'); return; }
if (action === 'logout' && platformApi?.isEnabled()) { window.GongxuePlatformAuth?.logout(); return; }
if (action === 'logout') { closeModal(); state.scenario = 'session_invalid'; state.view = 'overview'; state.previousView = ''; state.history = []; scenarioSelect.value = 'session_invalid'; addAudit('auth.logout', 'application', 'platform-console', '平台管理控制台', '中', '超级管理员主动退出当前平台端会话。'); saveData(); render(); return; }
if (action === 'refresh') {
if (platformApi?.isEnabled()) { hydrateFromBackend(); return; }
state.scenario = 'loading'; scenarioSelect.value = 'loading'; render();
setTimeout(() => { state.scenario = 'normal'; scenarioSelect.value = 'normal'; render(); showToast('平台静态演示数据已刷新'); }, 700);
return;
}
if (action === 'reset-mock') { if (requireDesktop('重置平台 Mock 数据')) return; confirmAction({ title: '重置平台 Mock 数据', description: '恢复到 v1.1 初始演示数据。', impact: '所有本地创建的租户、账单、任务、授权、员工修改和告警状态将被清除。', confirmLabel: '确认重置' }, () => { localStorage.removeItem(STORAGE_KEY); data = clone(seed); state.selectedTenant = data.tenants[0].id; saveData(); render(); showToast('平台 Mock 数据已重置'); }); return; }
if (action === 'open-task-center') { openModal('tasks'); return; }
if (action === 'create-tenant') { if (requireDesktop('创建租户')) return; openModal('createTenant'); return; }
if (action === 'prepare-tenant-status') { if (requireDesktop('变更租户生命周期')) return; openModal('tenantStatus', { tenantId: actionElement.dataset.tenantId || state.selectedTenant }); return; }
if (action === 'open-tenant-portal') { const tenant = tenantById(state.selectedTenant); let portalWindow = null; try { portalWindow = window.open(TENANT_PORTAL_ORIGIN, '_blank'); if (portalWindow) portalWindow.opener = null; } catch {} showToast(portalWindow ? `已在新标签打开独立租户端 · ${tenant?.name || '当前租户'}` : '已请求打开独立租户端;如未出现,请允许本站弹出窗口', portalWindow ? 'success' : 'error'); return; }
if (action === 'edit-billing-profile') { if (requireDesktop('编辑租户账务资料')) return; navigate('billingProfile'); return; }
if (action === 'billing-profile-to-invoices' || action === 'tenant-to-billing') { if (actionElement.dataset.tenantId) state.selectedTenant = actionElement.dataset.tenantId; navigate('billing'); return; }
if (action === 'tenant-to-usage') { state.selectedTenant = actionElement.dataset.tenantId; navigate('usage'); return; }
if (action === 'clear-tenant-context') { state.selectedTenant = ''; render(); return; }
if (action === 'create-subscription-for-tenant') { if (requireDesktop('创建租户订阅')) return; navigate('subscriptions'); openModal('createSubscription'); return; }
if (action === 'create-subscription') { if (requireDesktop('创建租户订阅')) return; openModal('createSubscription'); return; }
if (action === 'select-plan') { if (requireDesktop('选择套餐并创建订阅')) return; openModal('createSubscription', { planCode: actionElement.dataset.planCode }); return; }
if (action === 'subscription-to-billing') { navigate('billing'); return; }
if (action === 'subscriptions-to-usage') { navigate('usage'); return; }
if (action === 'toggle-auto-renew') { if (requireDesktop('变更订阅自动续费')) return; if (platformApi?.isEnabled()) { showToast('后端暂未提供订阅更新接口,真实模式禁止仅改本地状态', 'error'); return; } const item = data.subscriptions.find(subscription => subscription.id === actionElement.dataset.subscriptionId); if (!canWrite('platform:billing:write')) return; item.autoRenew = !item.autoRenew; addAudit('subscription.auto_renew.update', 'subscription', item.id, item.id, '中', `自动续费更新为 ${item.autoRenew ? '开启' : '关闭'}`); saveData(); render(); showToast('自动续费设置已更新'); return; }
if (action === 'create-invoice') { if (requireDesktop('手工创建账单')) return; openModal('createInvoice'); return; }
if (action === 'apply-invoice-query') { runContractRead({ key: 'invoice-query', actionId: 'act.p-06.01', operationId: 'PlatformAdminBillingController_invoices', title: '账单查询', suppliedRequestId: actionElement.dataset.contractRequestId, liveRequest: () => platformApi.request('PlatformAdminBillingController_invoices', { query: { tenantId: state.selectedTenant || undefined, status: ({ 草稿: 'draft', 待付款: 'issued', 已支付: 'paid', 已作废: 'void', 已逾期: 'overdue' })[state.invoiceStatus], limit: 200 } }), apply: payload => { if (Array.isArray(payload?.items)) data.invoices = payload.items.map(platformApi.adapters.invoice); return `${data.invoices.filter(invoice => (!state.selectedTenant || invoice.tenantId === state.selectedTenant) && (state.invoiceStatus === 'all' || invoice.status === state.invoiceStatus) && (!state.invoiceSearch.trim() || [invoice.id, invoice.type, invoice.period, invoice.note, tenantById(invoice.tenantId)?.name].some(value => String(value || '').toLowerCase().includes(state.invoiceSearch.trim().toLowerCase())))).length} 张匹配账单`; } }); return; }
if (action === 'preview-invoice-candidates') { if (requireDesktop('批量生成账单')) return; openModal('invoiceCandidates'); return; }
if (action === 'run-invoice-dry-run' && platformApi?.isEnabled()) { const subscriptionIds = Array.from(overlayRoot.querySelectorAll('[name="candidate"]:checked')).map(input => input.value); executeBackendMutation('PlatformAdminBillingController_fromSubscriptionsBatch', { subscriptionIds, dryRun: true }, payload => { const result = payload?.item || {}; showToast(`Dry-run 完成 · 可生成 ${result.items?.length || 0} 条,跳过 ${result.skippedCount || 0}`); }, { refresh: false }); return; }
if (action === 'run-invoice-dry-run') { const count = overlayRoot.querySelectorAll('[name="candidate"]:checked').length; showToast(`Dry-run 已校验 ${count} 条订阅候选,未写入账单`); return; }
if (action === 'generate-subscription-invoice' && platformApi?.isEnabled()) { if (requireDesktop('为单个订阅生成账单')) return; const subscription = data.subscriptions.find(item => item.id === actionElement.dataset.subscriptionId); if (!subscription) { showToast('订阅不存在或不可用', 'error'); return; } executeBackendMutation('PlatformAdminBillingController_fromSubscription', { tenantId: subscription.tenantId, subscriptionId: subscription.id, status: 'issued' }, payload => { closeModal({ force: true }); window.recordPlatformSpecResult?.('act.p-06.04', { result: 'success', operationId: 'PlatformAdminBillingController_fromSubscription', requestId: payload?.meta?.requestId || '' }); showToast('订阅账单已由后端生成'); }); return; }
if (action === 'generate-subscription-invoice') { if (requireDesktop('为单个订阅生成账单')) return; const subscription = data.subscriptions.find(item => item.id === actionElement.dataset.subscriptionId); const suppliedRequestId = actionElement.dataset.contractRequestId || requestId(); if (!subscription || data.invoices.some(invoice => invoice.note.includes(subscription.id))) { state.contractResults['invoice-query'] = { status: 'error', title: '单订阅生成失败', detail: '该订阅已存在账单或订阅不可用', requestId: suppliedRequestId }; addAudit('invoice.from_subscription.failed', 'subscription', subscription?.id || '-', subscription?.id || '-', '中', `幂等冲突。Request ID${suppliedRequestId}`, '失败'); window.recordPlatformSpecResult?.('act.p-06.04', { result: 'failed', operationId: 'PlatformAdminBillingController_fromSubscription', requestId: suppliedRequestId }); render(); return; } const task = addContractTask({ actionId: 'act.p-06.04', operationId: 'PlatformAdminBillingController_fromSubscription', label: `为订阅 ${subscription.id} 生成账单`, resultTarget: '服务费账单', suppliedRequestId, execute: taskItem => { const invoice = { id: entityId('INV'), tenantId: subscription.tenantId, type: '订阅服务费', period: '下一订阅账期', total: subscription.amount, paid: 0, balance: subscription.amount, due: subscription.expires, status: '待付款', note: `订阅 ${subscription.id}` }; data.invoices.unshift(invoice); tenantById(subscription.tenantId).balance += invoice.balance; taskItem.invoiceId = invoice.id; addAudit('invoice.from_subscription.create', 'invoice', invoice.id, invoice.id, '高', `通过任务 ${taskItem.id} 为订阅 ${subscription.id} 生成单张账单。`); } }); showToast(`单订阅账单任务 ${task.id} 已创建`); return; }
if (action === 'confirm-generate-candidates' && platformApi?.isEnabled()) { if (requireDesktop('批量生成账单')) return; const subscriptionIds = Array.from(overlayRoot.querySelectorAll('[name="candidate"]:checked')).map(input => input.value); if (!subscriptionIds.length) { showToast('请至少选择一条订阅账单候选', 'error'); return; } executeBackendMutation('PlatformAdminBillingController_fromSubscriptionsBatch', { subscriptionIds, dryRun: false, status: 'issued' }, payload => { closeModal({ force: true }); const result = payload?.item || {}; showToast(`后端已生成 ${result.createdCount || 0} 张账单,跳过 ${result.skippedCount || 0}`); }); return; }
if (action === 'confirm-generate-candidates') { if (requireDesktop('批量生成账单')) return; const selectedIds = Array.from(overlayRoot.querySelectorAll('[name="candidate"]:checked')).map(input => input.value); if (!selectedIds.length) { showToast('请至少选择一条订阅账单候选', 'error'); return; } const candidates = data.subscriptions.filter(subscription => selectedIds.includes(subscription.id) && !data.invoices.some(invoice => invoice.note.includes(subscription.id))); closeModal(); const task = addTask(`批量生成 ${candidates.length} 张订阅账单`, '服务费账单', () => { candidates.forEach(subscription => { const invoice = { id: entityId('INV'), tenantId: subscription.tenantId, type: '订阅服务费', period: '下一订阅账期', total: subscription.amount, paid: 0, balance: subscription.amount, due: subscription.expires, status: '待付款', note: `订阅 ${subscription.id}` }; data.invoices.unshift(invoice); const tenant = tenantById(subscription.tenantId); tenant.balance += invoice.balance; tenant.billing = '待付款'; }); addAudit('invoice.batch.create', 'invoice_batch', task.id, task.id, '高', `批量生成 ${candidates.length} 张订阅账单。`); }); showToast(`任务 ${task.id} 已创建`); return; }
if (action === 'confirm-payment') { if (requireDesktop('确认账单收款')) return; openModal('payment', { invoiceId: actionElement.dataset.invoiceId }); return; }
if (action === 'preview-overdue' && platformApi?.isEnabled()) { if (requireDesktop('批量处理逾期账单')) return; executeBackendMutation('PlatformAdminBillingController_processOverdue', { dryRun: true, limit: 100, channel: 'internal' }, payload => { openModal('overduePreview'); showToast(`后端 Dry-run${payload?.item?.processed || payload?.item?.items?.length || 0} 条候选`); }, { refresh: false }); return; }
if (action === 'preview-overdue') { if (requireDesktop('批量处理逾期账单')) return; openModal('overduePreview'); return; }
if (action === 'confirm-overdue-process' && platformApi?.isEnabled()) { showToast('后端逾期处理接口不支持指定 invoiceIds真实模式禁止按当前勾选直接执行', 'error'); return; }
if (action === 'confirm-overdue-process') { if (requireDesktop('批量处理逾期账单')) return; const selectedIds = Array.from(overlayRoot.querySelectorAll('[name="candidate"]:checked')).map(input => input.value); if (!selectedIds.length) { showToast('请至少选择一条逾期账单候选', 'error'); return; } const overdue = data.invoices.filter(invoice => invoice.status === '已逾期' && selectedIds.includes(invoice.id)); closeModal(); const task = addTask(`处理 ${overdue.length} 张逾期账单`, '收款与催缴', () => { overdue.forEach(invoice => data.reminders.unshift({ id: entityId('REM'), invoiceId: invoice.id, tenantId: invoice.tenantId, level: 3, channel: '邮件 + 短信', status: '发送中', date: todayTime(), message: `服务费账单 ${invoice.id} 已逾期,请尽快处理。` })); addAudit('invoice.overdue.process', 'invoice_batch', task.id, task.id, '高', `生成 ${overdue.length} 条催缴记录。`); }); showToast(`催缴任务 ${task.id} 已创建`); return; }
if (action === 'generate-reminder' && platformApi?.isEnabled()) { showToast('后端仅提供批量逾期处理,没有单账单催缴创建接口', 'error'); return; }
if (action === 'generate-reminder') { if (requireDesktop('生成账单催缴')) return; const invoice = invoiceById(actionElement.dataset.invoiceId); const reminder = { id: entityId('REM'), invoiceId: invoice.id, tenantId: invoice.tenantId, level: invoice.status === '已逾期' ? 3 : 1, channel: '邮件 + 短信', status: '已加入队列', date: todayTime(), message: invoice.status === '已逾期' ? `账单已逾期,请尽快支付 ${money(invoice.balance)}` : `账单即将到期,应付 ${money(invoice.balance)}` }; data.reminders.unshift(reminder); addAudit('invoice.reminder.create', 'reminder', reminder.id, reminder.id, '中', `${invoice.id} 创建 L${reminder.level} 催缴。`); saveData(); render(); showToast('催缴已加入发送队列'); return; }
if (action === 'record-usage') { if (requireDesktop('人工记录平台用量')) return; openModal('recordUsage'); return; }
if (action === 'preview-overage') { if (requireDesktop('批量生成超额账单')) return; openModal('overagePreview'); return; }
if (action === 'run-overage-dry-run' && platformApi?.isEnabled()) { const tenantIds = Array.from(overlayRoot.querySelectorAll('[name="candidate"]:checked')).map(input => input.value.split(':')[0]); executeBackendMutation('PlatformAdminBillingController_fromUsage', { tenantIds, period: state.usagePeriod, dryRun: true }, payload => { const result = payload?.item || {}; showToast(`超额 Dry-run 完成 · 可生成 ${result.items?.length || 0}`); }, { refresh: false }); return; }
if (action === 'run-overage-dry-run') { const count = overlayRoot.querySelectorAll('[name="candidate"]:checked').length; showToast(`Dry-run 已校验 ${count} 条超额候选,未写入账单`); return; }
if (action === 'confirm-overage-invoices' && platformApi?.isEnabled()) { if (requireDesktop('批量生成超额账单')) return; const tenantIds = Array.from(overlayRoot.querySelectorAll('[name="candidate"]:checked')).map(input => input.value.split(':')[0]); if (!tenantIds.length) { showToast('请至少选择一条超额账单候选', 'error'); return; } executeBackendMutation('PlatformAdminBillingController_fromUsage', { tenantIds, period: state.usagePeriod, dryRun: false, status: 'issued' }, payload => { closeModal({ force: true }); showToast(`后端已生成 ${payload?.item?.createdCount || 0} 张超额账单`); }); return; }
if (action === 'confirm-overage-invoices') { if (requireDesktop('批量生成超额账单')) return; const selectedKeys = Array.from(overlayRoot.querySelectorAll('[name="candidate"]:checked')).map(input => input.value); if (!selectedKeys.length) { showToast('请至少选择一条超额账单候选', 'error'); return; } const candidates = data.usage.filter(item => item.period === state.usagePeriod && selectedKeys.includes(`${item.tenantId}:${item.period}`) && item.estimated > 0 && !data.invoices.some(invoice => invoice.note === `超额 ${item.period}` && invoice.tenantId === item.tenantId)); closeModal(); const task = addTask(`生成 ${candidates.length} 张超额账单`, '用量与超额计费', () => { candidates.forEach(item => { const invoice = { id: entityId('INV'), tenantId: item.tenantId, type: '用量超额费', period: item.period, total: item.estimated, paid: 0, balance: item.estimated, due: '2026-08-15', status: '待付款', note: `超额 ${item.period}` }; data.invoices.unshift(invoice); const tenant = tenantById(item.tenantId); tenant.balance += invoice.balance; tenant.billing = '待付款'; }); addAudit('usage.invoice.batch.create', 'invoice_batch', task.id, task.id, '高', `生成 ${candidates.length} 张用量超额账单。`); }); showToast(`超额账单任务 ${task.id} 已创建`); return; }
if (action === 'new-dunning-channel') { if (requireDesktop('新建催缴渠道')) return; openModal('dunningChannel'); return; }
if (action === 'edit-dunning-channel') { if (requireDesktop('编辑催缴渠道')) return; openModal('dunningChannel', { channelId: actionElement.dataset.channelId }); return; }
if (action === 'toggle-dunning-channel') { if (requireDesktop('变更催缴渠道状态')) return; if (!canWrite('platform:billing:notification')) return; const channel = data.dunningChannels.find(item => item.id === actionElement.dataset.channelId); if (!channel) return; if (platformApi?.isEnabled() && channel.endpointRedacted) { openModal('dunningChannel', { channelId: channel.id }); showToast('后端不回显完整 Webhook请重新输入完整 HTTPS URL 后保存状态', 'error'); return; } const nextEnabled = !channel.enabled; const apply = () => { channel.enabled = nextEnabled; addAudit('dunning.channel.status', 'notification_channel', channel.id, channel.name, '高', `催缴渠道${channel.enabled ? '启用' : '停用'}`); saveData(); render(); showToast('渠道状态已更新'); }; if (platformApi?.isEnabled()) executeBackendMutation('PlatformAdminDunningChannelsController_upsertChannel', { ...channel, enabled: nextEnabled }, apply); else apply(); return; }
if (action === 'new-grant') { if (requireDesktop('新建公共题库授权')) return; openModal('grant'); return; }
if (action === 'apply-bank-search') { runContractRead({ key: 'bank-query', actionId: 'act.p-09.01', operationId: 'PlatformAdminQuestionBanksController_banks', title: '题库搜索', suppliedRequestId: actionElement.dataset.contractRequestId, liveRequest: () => platformApi.request('PlatformAdminQuestionBanksController_banks', { query: { q: state.bankSearch || undefined, status: 'active', includeTenantBanks: state.bankIncludeTenant, limit: 200 } }), apply: payload => { if (Array.isArray(payload?.items)) data.banks = payload.items.map(item => platformApi.adapters.bank(item, bankById(item.id))); return `${data.banks.filter(bank => !state.bankSearch.trim() || [bank.name, bank.catalog, bank.source, bank.version].some(value => String(value).toLowerCase().includes(state.bankSearch.trim().toLowerCase()))).length} 个匹配题库`; } }); return; }
if (action === 'edit-grant') { if (requireDesktop('编辑公共题库授权')) return; openModal('grant', { grantId: actionElement.dataset.grantId }); return; }
if (action === 'toggle-grant') { if (requireDesktop('变更公共题库授权状态')) return; if (!canWrite('platform:question_bank:grant')) return; const grant = data.grants.find(item => item.id === actionElement.dataset.grantId); if (!grant) return; const next = grant.status === '启用' ? '已禁用' : '启用'; const apply = () => { grant.status = next; addAudit('bank.grant.status', 'grant', grant.id, bankById(grant.bankId)?.name || grant.bankId, '高', `授权规则状态更新为 ${next},租户快照保留。`); saveData(); render(); showToast('授权状态已更新'); }; const execute = () => platformApi?.isEnabled() ? executeBackendMutation('PlatformAdminQuestionBanksController_upsertGrant', { ...grant, status: next }, apply) : apply(); if (next === '已禁用') confirmAction({ title: '禁用公共题库授权', description: `${bankById(grant.bankId)?.name || grant.bankId} · ${grant.id}`, impact: '后续同步将停止,但租户已经采用的内容快照不会被删除。', confirmLabel: '确认禁用' }, execute); else execute(); return; }
if (action === 'preview-bank-sync') { state.bankTab = 'sync'; if (platformApi?.isEnabled()) { runContractRead({ key: 'bank-sync', actionId: 'act.p-09.05', operationId: 'PlatformAdminQuestionBanksController_syncStatus', title: '采用同步状态', suppliedRequestId: actionElement.dataset.contractRequestId, liveRequest: () => platformApi.request('PlatformAdminQuestionBanksController_syncStatus', { query: { limit: 200 } }), apply: payload => { if (Array.isArray(payload?.items)) data.bankSync = payload.items.map(platformApi.adapters.sync); return `${data.bankSync.length} 条采用记录,${data.bankSync.filter(item => item.status === '同步失败').length} 条失败`; } }); return; } const task = addContractTask({ actionId: 'act.p-09.05', operationId: 'PlatformAdminQuestionBanksController_syncStatus', label: '检查租户题库采用与同步状态', resultTarget: '公共题库 · 采用与同步', suppliedRequestId: actionElement.dataset.contractRequestId, execute: taskItem => { taskItem.rows = data.bankSync.length; taskItem.failedRows = data.bankSync.filter(item => item.status === '同步失败').length; } }); render(); showToast(`同步状态检查任务 ${task.id} 已创建`); return; }
if (action === 'refresh-bank-sync') { if (state.modal === 'syncDetail') closeModal({ force: true }); runContractRead({ key: 'bank-sync', operationId: 'PlatformAdminQuestionBanksController_syncStatus', title: '采用同步状态', liveRequest: () => platformApi.request('PlatformAdminQuestionBanksController_syncStatus', { query: { limit: 200 } }), apply: payload => { if (Array.isArray(payload?.items)) data.bankSync = payload.items.map(platformApi.adapters.sync); return `${data.bankSync.length} 条采用记录,${data.bankSync.filter(item => item.status === '同步失败').length} 条失败`; } }); return; }
if (action === 'sync-detail') { openModal('syncDetail', { syncKey: actionElement.dataset.syncKey }); return; }
if (action === 'new-staff') { if (requireDesktop('新建平台员工与授权')) return; openModal('staff', { roleKey: actionElement.dataset.roleKey || '' }); return; }
if (action === 'edit-staff') { if (requireDesktop('编辑平台员工与授权')) return; openModal('staff', { staffId: actionElement.dataset.staffId }); return; }
if (action === 'toggle-staff') { if (requireDesktop('变更平台员工状态')) return; if (!canWrite('platform:staff:status')) return; const person = data.staff.find(item => item.id === actionElement.dataset.staffId); if (!person) return; const disabling = person.status === '正常'; if (disabling && person.role === '超级管理员' && data.staff.filter(item => item.role === '超级管理员' && item.status === '正常').length === 1) { showToast('不能禁用最后一名超级管理员', 'error'); return; } const apply = () => { person.status = disabling ? '已停用' : '正常'; addAudit('staff.status.update', 'staff', person.id, person.name, '高', `${disabling ? '停用并撤销会话' : '启用'}平台员工。`); saveData(); render(); showToast(`员工已${disabling ? '停用并撤销会话' : '启用'}`); }; const execute = () => platformApi?.isEnabled() ? executeBackendMutation('PlatformAdminOverviewController_updateStaffStatus', { staffId: person.id, status: disabling ? 'disabled' : 'active', reason: disabling ? '平台控制台停用员工' : '平台控制台启用员工', revokeSessions: disabling }, apply) : apply(); if (disabling) confirmAction({ title: '停用平台员工', description: `${person.name} · ${person.role}`, impact: '该员工的全部平台会话将立即撤销,后续无法登录平台端;租户端权限不在本域名内处理。', confirmLabel: '停用并撤销会话', confirmActionName: 'execute-staff-disable' }, execute); else execute(); return; }
if (action === 'select-permission-group') { const form = overlayRoot.querySelector('#staffForm'); const role = fieldValue(form, 'role'); const recommended = role === '平台财务' ? ['platform:billing:read', 'platform:billing:write', 'platform:billing:payment', 'platform:billing:dunning', 'platform:billing:notification', 'platform:usage:read', 'platform:usage:write'] : role === '安全审计' ? ['platform:audit:read', 'platform:audit:export', 'platform:audit:alert', 'platform:audit:notification'] : role === '题库运营' ? ['platform:question_bank:read', 'platform:question_bank:grant', 'platform:question_bank:ops'] : role === '租户运营' ? ['platform:tenant:read', 'platform:tenant:write', 'platform:tenant:status', 'platform:tenant:billing_profile'] : Array.from(form.querySelectorAll('[name="permissions"]')).map(input => input.value); form.querySelectorAll('[name="permissions"]').forEach(input => { input.checked = recommended.includes(input.value); }); return; }
if (action === 'clear-staff-form') { const form = overlayRoot.querySelector('#staffForm'); if (!form) return; form.querySelectorAll('input:not([type="hidden"]):not([type="checkbox"])').forEach(input => { input.value = ''; }); form.querySelectorAll('input[type="checkbox"]').forEach(input => { input.checked = false; }); form.elements.role.value = '租户运营'; state.modalData.dirty = true; form.querySelector('[name="authUserId"]')?.focus(); return; }
if (action === 'apply-staff-search') { render(); return; }
if (action === 'export-audit') { if (!hasPermission('platform:audit:export')) { showToast('缺少导出权限platform:audit:export', 'error'); return; } const format = actionElement.dataset.format || 'csv'; if (platformApi?.isEnabled()) { platformApi.request('PlatformAdminAuditController_exportLogs', { query: { format, q: state.auditSearch || undefined, limit: 500 } }).then(payload => { const item = platformApi.downloadExport(payload); showToast(`审计文件已下载 · ${item.rowCount ?? item.rows ?? 0} 行 · ${item.sha256 || item.hash || 'hash 见响应'}`); }).catch(error => showToast(`${error.message || '审计导出失败'}${error.requestId ? ` · ${error.requestId}` : ''}`, 'error')); return; } const task = addTask(`导出平台审计日志 ${format.toUpperCase()}`, '任务中心下载文件', taskItem => { taskItem.file = `platform-audit-${Date.now()}.${format}`; taskItem.hash = `sha256:${Math.random().toString(36).slice(2).padEnd(32, '0')}`; taskItem.rows = data.auditLogs.length; }); showToast(`导出任务 ${task.id} 已创建`); return; }
if (action === 'jump-related-alert') { state.alertStatus = 'all'; state.selectedAlert = data.alerts.find(alert => data.auditLogs.some(log => log.targetId === alert.targetId || log.targetId === alert.id))?.id || ''; navigate('alerts'); if (state.selectedAlert) openModal('alertDetail', { alertId: state.selectedAlert }); return; }
if (action === 'jump-audit-target') { const log = data.auditLogs.find(item => item.id === actionElement.dataset.auditId); closeModal(); if (log.targetType === 'tenant') { state.selectedTenant = log.targetId; navigate('tenantDetail'); } else if (log.targetType === 'invoice') { state.selectedInvoice = log.targetId; navigate('billing'); } else if (log.targetType === 'question_bank' || log.targetType === 'grant') navigate('bank'); else navigate('alerts'); return; }
if (action === 'transition-alert') { if (!canWrite('platform:audit:alert')) return; const alert = data.alerts.find(item => item.id === actionElement.dataset.alertId); const next = actionElement.dataset.nextStatus; if (!alert) return; if (!['open', 'acknowledged'].includes(alert.status)) { showToast('已结束告警禁止重新打开或再次处理', 'error'); return; } if (alert.status === 'open' && !['acknowledged', 'resolved', 'ignored'].includes(next)) return; if (alert.status === 'acknowledged' && !['resolved', 'ignored'].includes(next)) return; const note = document.querySelector('#alertNote')?.value.trim() || ''; const apply = () => { alert.status = next; alert.note = note; alert.updatedAt = '刚刚'; addAudit('audit.alert.status.update', 'audit_alert', alert.id, alert.title, '高', `告警状态更新为 ${alertStatusLabel(next)}${note ? `,备注:${note}` : ''}`); clearDirty('modal'); saveData(); closeModal({ force: true }); render(); showToast(`告警已${alertStatusLabel(next)}`); }; if (platformApi?.isEnabled()) executeBackendMutation('PlatformAdminAuditController_updateAlert', { alertId: alert.id, status: next, note }, apply); else apply(); return; }
if (action === 'new-audit-channel') { if (requireDesktop('新建审计通知渠道')) return; openModal('auditChannel'); return; }
if (action === 'edit-audit-channel') { if (requireDesktop('编辑审计通知渠道')) return; openModal('auditChannel', { channelId: actionElement.dataset.channelId }); return; }
if (action === 'toggle-audit-channel') { if (requireDesktop('变更审计通知渠道状态')) return; if (!canWrite('platform:audit:notification')) return; const channel = data.auditChannels.find(item => item.id === actionElement.dataset.channelId); if (!channel) return; if (platformApi?.isEnabled() && channel.endpointRedacted) { openModal('auditChannel', { channelId: channel.id }); showToast('后端不回显完整 Webhook请重新输入完整 HTTPS URL 后保存状态', 'error'); return; } const nextEnabled = !channel.enabled; const apply = () => { channel.enabled = nextEnabled; addAudit('audit.channel.status', 'notification_channel', channel.id, channel.name, '高', `审计告警渠道${channel.enabled ? '启用' : '停用'}`); saveData(); render(); showToast('审计通知渠道状态已更新'); }; if (platformApi?.isEnabled()) executeBackendMutation('PlatformAdminAuditController_upsertChannel', { ...channel, enabled: nextEnabled }, apply); else apply(); return; }
if (action === 'retry-notification') { if (platformApi?.isEnabled()) { showToast('后端未提供通知事件重试接口,真实模式禁止伪造成功状态', 'error'); return; } const notification = [...(data.notificationEvents || []), ...(data.dunningEvents || [])].find(item => item.id === actionElement.dataset.eventId); if (!notification) return; notification.status = '重试中'; notification.attempts += 1; notification.updatedAt = `${todayTime()} CST`; saveData(); render(); setTimeout(() => { notification.status = '成功'; notification.error = ''; saveData(); render(); showToast('通知重试成功'); }, 800); return; }
if (action === 'retry-task') { const task = data.tasks.find(item => item.id === actionElement.dataset.taskId); task.status = 'processing'; task.error = ''; task.updatedAt = todayTime(); saveData(); renderOverlay(); setTimeout(() => { task.status = 'completed'; task.updatedAt = todayTime(); saveData(); renderOverlay(); updateCounters(); showToast('任务重试完成'); }, 900); return; }
});
document.addEventListener('input', event => {
if (state.modal && event.target.closest('#overlayRoot') && event.target.matches('input,select,textarea')) markDirty('modal');
if (event.target.closest('#mainView form')) markDirty('page');
if (event.target.id === 'tenantSearch') { state.tenantSearch = event.target.value; clearTimeout(state.searchTimer); state.searchTimer = setTimeout(render, 180); }
if (event.target.id === 'invoiceSearch') state.invoiceSearch = event.target.value;
if (event.target.id === 'bankSearch') state.bankSearch = event.target.value;
if (event.target.id === 'staffSearch') { state.staffSearch = event.target.value; clearTimeout(state.staffTimer); state.staffTimer = setTimeout(render, 180); }
if (event.target.id === 'auditSearch') { state.auditSearch = event.target.value; clearTimeout(state.auditTimer); state.auditTimer = setTimeout(render, 180); }
if (event.target.id === 'globalSearch') state.globalSearch = event.target.value;
if (['tenantSearch', 'invoiceSearch', 'bankSearch', 'staffSearch', 'auditSearch'].includes(event.target.id)) syncNavigationState();
});
document.addEventListener('change', event => {
if (state.modal && event.target.closest('#overlayRoot') && event.target.matches('input,select,textarea')) markDirty('modal');
if (event.target.closest('#mainView form')) markDirty('page');
if (event.target === roleSelect) { state.role = event.target.value; state.scenario = 'normal'; if (!hasPermission(pageMeta[state.view].permission)) state.view = 'overview'; render(); showToast(`已切换为${roleDefinitions[state.role].label}`); }
if (event.target === scenarioSelect) { state.scenario = event.target.value; render(); }
if (event.target === responseSelect) { state.response = event.target.value; showToast('下一次 Mock 写请求结果已设置'); }
if (event.target.id === 'tenantPlanFilter') { state.tenantPlan = event.target.value; syncNavigationState(); render(); }
if (event.target.id === 'invoiceStatusFilter') { state.invoiceStatus = event.target.value; syncNavigationState(); }
if (event.target.id === 'usagePeriodFilter') { state.usagePeriod = event.target.value; syncNavigationState(); render(); }
if (event.target.id === 'auditRangeFilter') { state.auditRange = event.target.value; syncNavigationState(); render(); }
if (event.target.id === 'includeTenantBanks') {
state.bankIncludeTenant = event.target.checked;
syncNavigationState();
render();
if (platformApi?.isEnabled()) hydrateFromBackend({ silent: true });
}
});
menuButton.addEventListener('click', toggleMobileMenu);
document.querySelector('#sidebarBackdrop').addEventListener('click', closeMobileMenu);
document.addEventListener('keydown', event => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { event.preventDefault(); document.querySelector('#globalSearch').focus(); }
if (event.key === 'Enter' && event.target.id === 'globalSearch') {
event.preventDefault();
const query = event.target.value.trim().toLowerCase();
if (!query) { showToast('请输入租户、账单、告警或审计关键词', 'error'); return; }
const tenant = data.tenants.find(item => [item.name, item.slug, item.legal, item.host].some(value => value.toLowerCase().includes(query)));
if (tenant) { state.selectedTenant = tenant.id; navigate('tenantDetail'); showToast(`已定位租户:${tenant.name}`); return; }
const invoice = data.invoices.find(item => [item.id, item.type, item.period, item.note].some(value => String(value).toLowerCase().includes(query)));
if (invoice) { state.selectedTenant = invoice.tenantId; state.selectedInvoice = invoice.id; navigate('billing'); showToast(`已定位账单:${invoice.id}`); return; }
const alert = data.alerts.find(item => [item.id, item.title, item.summary].some(value => value.toLowerCase().includes(query)));
if (alert) { state.selectedAlert = alert.id; navigate('alerts'); openModal('alertDetail', { alertId: alert.id }); return; }
const audit = data.auditLogs.find(item => [item.id, item.actor, item.action, item.target, item.details].some(value => value.toLowerCase().includes(query)));
if (audit) { navigate('audit'); openModal('auditDetail', { auditId: audit.id }); return; }
showToast('没有找到匹配的租户、账单、告警或审计事件', 'error');
}
if (event.key === 'Tab' && state.modal) {
const modal = overlayRoot.querySelector('.modal');
const focusable = modal ? Array.from(modal.querySelectorAll('button:not([disabled]),input:not([disabled]):not([type="hidden"]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])')).filter(element => element.offsetParent !== null) : [];
if (focusable.length) {
const first = focusable[0]; const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
}
}
if (event.key === 'Escape') { if (state.modal) closeModal(); else if (app.classList.contains('menu-open')) closeMobileMenu(); else if (pageMeta[state.view]?.level > 1) navigateBack(); }
});
document.addEventListener('click', event => {
const choice = event.target.closest('[data-leave-choice]')?.dataset.leaveChoice;
if (!choice) return;
event.preventDefault(); event.stopImmediatePropagation();
if (choice === 'stay') { pendingLeave = null; closeLeaveGuard(); return; }
const leave = pendingLeave; pendingLeave = null; clearDirty(leave?.scope || ''); closeLeaveGuard(); leave?.execute?.();
}, true);
window.addEventListener('beforeunload', event => { if (!hasUnsavedChanges()) return; event.preventDefault(); event.returnValue = ''; });
window.addEventListener('DOMContentLoaded', async () => {
if (platformApi?.isEnabled()) {
const authenticated = await window.GongxuePlatformAuth?.requireSession();
if (!authenticated) return;
document.documentElement.dataset.platformMode = 'api';
}
setupPrototypeControls();
if (platformApi?.isEnabled()) document.querySelector('.prototype-controls')?.setAttribute('hidden', '');
render();
const rootSnapshot = navigationSnapshot();
history.replaceState({ [HISTORY_APP_KEY]: true, rootGuard: true, sequence: historySequence, snapshot: rootSnapshot }, '', location.href);
historySequence += 1;
history.pushState({ [HISTORY_APP_KEY]: true, sequence: historySequence, snapshot: rootSnapshot }, '', location.href);
if (platformApi?.isEnabled()) hydrateFromBackend({ silent: true });
});