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

1753 lines
290 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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',
'domains',
'tenantDetail',
'billingProfile',
'subscriptions',
'catalog',
'saasFinance',
'billing',
'usage',
'dunning',
'bank',
'crm',
'sms',
'paymentSettings',
'staff',
'roles',
'audit',
'alerts',
]);
const pageMeta = {
overview: { id: 'P-01', title: '平台经营工作台', permission: 'platform:overview:read' },
tenants: { id: 'P-02', title: '租户列表与创建', permission: 'platform:tenant:read' },
domains: { id: 'P-02A', 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' },
catalog: { id: 'P-05A', title: 'SaaS 商品与版本', permission: 'platform:plan:read' },
saasFinance: { id: 'P-05B', title: 'SaaS 订单与交易', permission: 'platform:billing: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:manage' },
crm: { id: 'P-10', title: 'CRM 接入与线索流转', permission: 'platform:crm:read' },
sms: { id: 'P-11', title: 'SMS 短信服务', permission: 'platform:sms:read' },
paymentSettings: { id: 'P-12', title: '支付设置', permission: 'platform:payment:read' },
staff: { id: 'P-13', title: '平台员工与权限', permission: 'platform:staff:read' },
roles: { id: 'P-13A', title: '平台角色与功能', permission: 'platform:staff:read' },
audit: { id: 'P-14', title: '平台审计日志', permission: 'platform:audit:read' },
alerts: { id: 'P-15', title: '审计告警与通知', permission: 'platform:audit:alert' },
};
const navGroups = [
['平台概览', [['overview', 'gauge', '经营工作台']]],
['租户管理', [['tenants', 'building-2', '租户列表'], ['domains', 'globe-2', '域名与证书']]],
['SaaS 商品与账务', [['catalog', 'packages', '商品与版本'], ['subscriptions', 'badge-check', '租户订阅'], ['saasFinance', 'landmark', '订单与交易'], ['billing', 'receipt-text', '服务费账单'], ['usage', 'chart-column-big', '用量与超额'], ['dunning', 'bell-ring', '收款与催缴']]],
['平台资产', [['bank', 'database-zap', '公共题库']]],
['租户能力', [['crm', 'handshake', 'CRM 接入'], ['sms', 'message-square-text', '短信服务'], ['paymentSettings', 'credit-card', '支付设置']]],
['安全治理', [['staff', 'user-cog', '平台员工'], ['roles', 'shield-check', '角色与功能'], ['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:crm:read', 'platform:crm:write', 'platform:sms:read', 'platform:sms:write'] },
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:payment:read', 'platform:payment: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:dashboard:view', 'platform:question-bank:manage'] },
readonly: { label: '只读观察员', permissions: ['platform:overview:read', 'platform:tenant:read', 'platform:plan:read', 'platform:billing:read', 'platform:crm:read', 'platform:sms:read', 'platform:payment: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' },
],
domains: [],
saasCatalog: { features: [], limitDefinitions: [], offerings: [], versions: [] },
saasOrders: [],
saasRefunds: [],
paymentChannels: [],
rebateSummary: {},
crmLogs: [],
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月服务费' },
],
billingPayments: [],
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: '稳定版' },
],
bankNodes: [
{ id: 'NODE-MATH', questionBankId: 'QB-TJ-MATH', parentId: null, name: '高等数学', nodeType: 'subject', depth: 0, sortOrder: 0, isActive: true, questionCount: 12860 },
{ id: 'NODE-MATH-01', questionBankId: 'QB-TJ-MATH', parentId: 'NODE-MATH', name: '函数、极限与连续', nodeType: 'chapter', depth: 1, sortOrder: 0, isActive: true, questionCount: 1260 },
],
bankQuestions: [],
bankImportResult: null,
crmConfigs: [
{ id: 'CRM-GX-TJ', tenantId: 'tenant_gongxue_tj', provider: '标准 CRM', endpoint: 'https://crm.gongxue.cn/open/leads', assignmentMode: '按地区轮转', pool: '天津升本线索池', enabled: true, status: '正常', timeout: 8, retry: 3, updatedAt: '2026-07-23 09:12' },
{ id: 'CRM-QH-GD', tenantId: 'tenant_qihang_gd', provider: '自建 CRM', endpoint: 'https://crm.qihangzsb.cn/api/leads', assignmentMode: '按校区容量', pool: '广东转化队列', enabled: true, status: '异常', timeout: 10, retry: 5, updatedAt: '2026-07-22 18:40' },
{ id: 'CRM-BS-SD', tenantId: 'tenant_bosi_sd', provider: '手工队列', endpoint: '-', assignmentMode: '手工分配', pool: '试用线索池', enabled: false, status: '未启用', timeout: 8, retry: 0, updatedAt: '2026-07-18 11:30' },
],
crmLeads: [
{ id: 'LEAD-260723-018', tenantId: 'tenant_qihang_gd', source: '公开课报名', student: '陈同学', phone: '139****8201', subject: '广东专升本英语', owner: '广州一校区', status: '推送失败', attempts: 4, lastError: 'CRM 422: campus required', createdAt: '2026-07-23 09:48' },
{ id: 'LEAD-260723-016', tenantId: 'tenant_gongxue_tj', source: '题库体验页', student: '王同学', phone: '138****2098', subject: '高等数学', owner: '天津转化组', status: '已推送', attempts: 1, lastError: '', createdAt: '2026-07-23 09:26' },
{ id: 'LEAD-260722-044', tenantId: 'tenant_mingtu_hn', source: '学生端留资', student: '李同学', phone: '135****6172', subject: '河南专升本语文', owner: '默认队列', status: '待推送', attempts: 0, lastError: '', createdAt: '2026-07-22 21:05' },
],
smsChannels: [
{ id: 'SMS-ALI-GX', tenantId: 'tenant_gongxue_tj', provider: '阿里云短信', signature: '恭学教育', scene: 'login,notice,dunning', status: '正常', enabled: true, secretRef: 'tenant/gongxue/sms/aliyun', quota: 50000, used: 21860, updatedAt: '2026-07-22 16:10' },
{ id: 'SMS-TENCENT-QH', tenantId: 'tenant_qihang_gd', provider: '腾讯云短信', signature: '启航专升本', scene: 'login,marketing', status: '模板待审', enabled: true, secretRef: 'tenant/qihang/sms/tencent', quota: 80000, used: 64220, updatedAt: '2026-07-21 19:30' },
{ id: 'SMS-GENERIC-BS', tenantId: 'tenant_bosi_sd', provider: '通用短信网关', signature: '博思升本', scene: 'login', status: '未启用', enabled: false, secretRef: 'redacted', quota: 10000, used: 0, updatedAt: '2026-07-18 10:00' },
],
smsTemplates: [
{ id: 'TPL-LOGIN-001', channelId: 'SMS-ALI-GX', code: 'student_login_otp', name: '学生登录验证码', type: '登录验证', status: '已通过', lastSend: '2026-07-23 09:58', successRate: '99.2%' },
{ id: 'TPL-DUN-001', channelId: 'SMS-ALI-GX', code: 'invoice_due_notice', name: '租户账单到期提醒', type: '平台催缴', status: '已通过', lastSend: '2026-07-23 09:20', successRate: '98.7%' },
{ id: 'TPL-MKT-009', channelId: 'SMS-TENCENT-QH', code: 'open_class_reminder', name: '公开课提醒', type: '营销触达', status: '审核中', lastSend: '-', successRate: '-' },
],
paymentApps: [
{ id: 'PAY-GX-STUDENT', tenantId: 'tenant_gongxue_tj', appName: '恭学学生端收款', provider: 'wechat_pay', mode: 'production', status: '正常', settlement: 'T+1 至租户商户号', secretRef: 'tenant/gongxue/pay/wechat', updatedAt: '2026-07-22 15:08' },
{ id: 'PAY-QH-ALI', tenantId: 'tenant_qihang_gd', appName: '启航课程收款', provider: 'alipay', mode: 'production', status: '证书即将过期', settlement: 'T+1 至租户支付宝商户', secretRef: 'tenant/qihang/pay/alipay', updatedAt: '2026-07-20 13:40' },
{ id: 'PAY-BS-MANUAL', tenantId: 'tenant_bosi_sd', appName: '试用线下收款', provider: 'manual', mode: 'sandbox', status: '未启用', settlement: '人工确认', secretRef: 'redacted', updatedAt: '2026-07-18 10:20' },
],
paymentEvents: [
{ id: 'PEVT-260723-031', appId: 'PAY-GX-STUDENT', orderNo: 'ORD-260723-2048', amount: 398, status: '支付成功', channel: '微信 JSAPI', notified: '已回调', updatedAt: '2026-07-23 09:56' },
{ id: 'PEVT-260723-029', appId: 'PAY-QH-ALI', orderNo: 'ORD-260723-2021', amount: 1280, status: '回调失败', channel: '支付宝 WAP', notified: '重试中', updatedAt: '2026-07-23 09:49' },
{ id: 'PEVT-260722-188', appId: 'PAY-BS-MANUAL', orderNo: 'ORD-260722-1098', amount: 99, status: '待确认', channel: '线下转账', notified: '无', updatedAt: '2026-07-22 20:18' },
],
platformPermissions: {
'platform:dashboard:view': '平台总览',
'platform:tenant:manage': '平台租户管理',
'platform:staff:manage': '平台员工管理',
'platform:role:manage': '平台角色权限管理',
'platform:question-bank:manage': '平台公共题库运营',
'platform:audit:view': '平台审计查询',
'platform:billing:notification': '平台账务通知',
'platform:saas-catalog:manage': 'SaaS 商品目录管理',
'platform:saas-billing:manage': 'SaaS 订单与账务管理',
'platform:crm:read': 'CRM 配置查询',
'platform:crm:write': 'CRM 配置管理',
'platform:sms:read': '短信服务查询',
'platform:sms:write': '短信服务管理',
'platform:payment:read': '支付设置查询',
'platform:payment:write': '支付设置管理',
},
platformRoles: [
{ id: 'role-super', code: 'platform_super_admin', name: '超级管理员', description: '拥有全部平台管理权限', permissionCodes: ['*'] },
{ id: 'role-tenant', code: 'platform_tenant_operator', name: '租户运营', description: '负责租户开通与生命周期管理', permissionCodes: ['platform:dashboard:view', 'platform:tenant:manage', 'platform:crm:read', 'platform:crm:write'] },
{ id: 'role-finance', code: 'platform_finance', name: '平台财务', description: '负责 SaaS 商品、订单、账务与支付', permissionCodes: ['platform:dashboard:view', 'platform:saas-catalog:manage', 'platform:saas-billing:manage', 'platform:billing:notification', 'platform:payment:read', 'platform:payment:write'] },
{ id: 'role-audit', code: 'platform_auditor', name: '安全审计', description: '查询审计日志与处理安全告警', permissionCodes: ['platform:dashboard:view', 'platform:audit:view'] },
{ id: 'role-bank', code: 'platform_question_bank_operator', name: '题库运营', description: '负责公共题库结构、题目与导入', permissionCodes: ['platform:dashboard:view', 'platform:question-bank:manage'] },
],
staff: [
{ id: 'STAFF-01', userId: 'STAFF-01', roleIds: ['role-super'], roleCodes: ['platform_super_admin'], 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:manage'], 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: 'platform.question_bank.import_executed', targetType: 'question_bank', targetId: 'QB-TJ-MATH', target: '高数公共题库 v2026.7', severity: '中', result: '成功', ip: '172.16.3.16', details: '结构化 JSON 导入完成,新增 8 个内容节点和 126 道题。' },
{ 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: '结构化 JSON 文件存在 12 个稳定编码冲突', severity: '中', status: 'open', owner: '题库运营', targetType: 'question_bank', targetId: 'QB-EN', evidence: ['文件版本 2.0', '目标题库 公共英语能力库', '冲突 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-IMPORT', code: 'QUESTION_BANK_IMPORT_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.crmConfigs = normalized.crmConfigs.map(item => ({ enabled: false, status: '未启用', retry: 0, ...item }));
normalized.crmLeads = normalized.crmLeads.map(item => ({ attempts: 0, lastError: '', ...item }));
normalized.smsChannels = normalized.smsChannels.map(item => ({ enabled: false, quota: 0, used: 0, ...item }));
normalized.smsTemplates = normalized.smsTemplates.map(item => ({ lastSend: '-', successRate: '-', ...item }));
normalized.paymentApps = normalized.paymentApps.map(item => ({ mode: 'sandbox', status: '未启用', ...item }));
normalized.paymentEvents = normalized.paymentEvents.map(item => ({ notified: '无', ...item }));
normalized.platformPermissions = Object.fromEntries(Object.entries(normalized.platformPermissions || {}).map(([code, name]) => [
code,
seed.platformPermissions[code] || name,
]));
normalized.platformRoles = normalized.platformRoles.map(item => item.code === 'platform_super_admin'
? { ...item, name: '超级管理员', description: '拥有全部平台管理权限' }
: item);
normalized.staff = normalized.staff.map(item => {
const roleIds = item.roleIds?.length ? item.roleIds : normalized.platformRoles.filter(role => role.name === item.role).map(role => role.id);
const assignedRoles = normalized.platformRoles.filter(role => roleIds.includes(role.id));
return {
permissions: assignedRoles.flatMap(role => role.permissionCodes),
status: '正常',
lastActive: '尚未登录',
...item,
userId: item.userId || item.id,
roleIds,
roleCodes: item.roleCodes || assignedRoles.map(role => role.code),
};
});
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', 'bankNodes', 'bankQuestions', '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: 'structure', selectedBank: data.banks[0]?.id || '', selectedBankNode: '', bankQuestionPage: 1, bankQuestionTotal: 0,
crmTab: 'configs', smsTab: 'channels', paymentTab: 'apps', saasFinanceTab: 'orders',
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 backendErrorText(error, fallback = '平台接口请求失败') {
if (!error) return fallback;
const pieces = [error.message || fallback];
if (error.code) pieces.push(`code=${error.code}`);
if (error.requestId) pieces.push(`requestId=${error.requestId}`);
return pieces.join(' · ');
}
function apiItems(payload) {
if (Array.isArray(payload)) return payload;
if (Array.isArray(payload?.items)) return payload.items;
return [];
}
function apiItem(payload) {
return payload?.item || payload || {};
}
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({ 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(`${backendErrorText(error, '后端连接失败')} · 已切换至演示模式`, '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(backendErrorText(error, '租户详情加载失败'), '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 paymentForInvoice(invoice) { return data.billingPayments.find(item => item.orderId && item.orderId === invoice?.orderId && item.status === 'pending'); }
function canConfirmInvoicePayment(invoice) { return !platformApi?.isEnabled() || Boolean(paymentForInvoice(invoice)); }
function bankById(id) { return data.banks.find(item => item.id === id); }
function bankNodeById(id) { return data.bankNodes.find(item => item.id === id); }
function questionTypeLabel(value) { return ({ choice: '单选题', multiple_choice: '多选题', true_false: '判断题', fill_blank: '填空题', short_answer: '简答题', reading: '阅读材料题', programming: '编程题' })[String(value || '').toLowerCase()] || value || '-'; }
function bankNodeTypeLabel(value) { return ({ category: '分类', subject: '科目', chapter: '章节', paper: '试卷', custom: '自定义' })[String(value || '').toLowerCase()] || value || '-'; }
function adaptBankNode(item) { return { ...item, questionBankId: item.questionBankId, nodeType: String(item.nodeType || '').replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase(), questionCount: Number(item.questionCount || 0) }; }
function adaptBankQuestion(item) { return { ...item, statusLabel: businessStatusLabel(item.status), versionNo: Number(item.versionNo || 0) }; }
function parseJsonText(value, fallback) { try { return String(value || '').trim() ? JSON.parse(value) : fallback; } catch { return null; } }
async function loadBankWorkspace({ questions = true } = {}) {
const bank = bankById(state.selectedBank);
if (!bank || !platformApi?.isEnabled()) return;
try {
const nodes = await platformApi.request('PlatformQuestionBanksController_getNodes', { pathParams: { bankId: bank.id } });
data.bankNodes = apiItems(nodes).map(adaptBankNode);
if (!state.selectedBankNode || !data.bankNodes.some(item => item.id === state.selectedBankNode)) state.selectedBankNode = data.bankNodes.find(item => item.isActive && ['chapter', 'paper'].includes(item.nodeType))?.id || data.bankNodes.find(item => item.isActive)?.id || '';
if (questions) {
const result = await platformApi.request('PlatformQuestionBanksController_getQuestions', { query: { questionBankId: bank.id, contentNodeId: state.selectedBankNode || undefined, page: state.bankQuestionPage, pageSize: 20 } });
data.bankQuestions = apiItems(result).map(adaptBankQuestion);
state.bankQuestionTotal = Number(result.total || data.bankQuestions.length);
}
saveData(); render();
} catch (error) {
showToast(backendErrorText(error, '公共题库加载失败'), 'error');
}
}
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-staff') {
const person = data.staff.find(item => item.id === control.dataset.staffId);
return `编辑员工 ${recordLabel(person?.name, person?.id || control.dataset.staffId)}`;
}
if (action === 'edit-crm-config') {
const config = data.crmConfigs.find(item => item.id === control.dataset.crmId);
return `编辑 CRM 接入 ${recordLabel(tenantById(config?.tenantId)?.name, config?.id || control.dataset.crmId)}`;
}
if (action === 'retry-crm-lead') {
const lead = data.crmLeads.find(item => item.id === control.dataset.leadId);
return `重试 CRM 线索 ${recordLabel(lead?.student, lead?.id || control.dataset.leadId)}`;
}
if (action === 'edit-sms-channel') {
const channel = data.smsChannels.find(item => item.id === control.dataset.smsId);
return `编辑短信渠道 ${recordLabel(channel?.signature, channel?.id || control.dataset.smsId)}`;
}
if (action === 'edit-payment-app') {
const paymentApp = data.paymentApps.find(item => item.id === control.dataset.paymentId);
return `编辑支付应用 ${recordLabel(paymentApp?.appName, paymentApp?.id || control.dataset.paymentId)}`;
}
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-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-crm-config') {
const config = data.crmConfigs.find(item => item.id === control.dataset.crmId);
return `${enabled ? '停用' : '启用'} CRM 接入 ${recordLabel(tenantById(config?.tenantId)?.name, config?.id || control.dataset.crmId)}`;
}
if (action === 'toggle-sms-channel') {
const channel = data.smsChannels.find(item => item.id === control.dataset.smsId);
return `${enabled ? '停用' : '启用'}短信渠道 ${recordLabel(channel?.signature, channel?.id || control.dataset.smsId)}`;
}
if (action === 'toggle-payment-app') {
const paymentApp = data.paymentApps.find(item => item.id === control.dataset.paymentId);
return `${enabled ? '停用' : '启用'}支付应用 ${recordLabel(paymentApp?.appName, paymentApp?.id || control.dataset.paymentId)}`;
}
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 || payload?.requestId || payload?.traceId || 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: backendErrorText(error), 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 businessStatusLabel(value) {
const key = String(value || '').replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
return ({ draft: '草稿', active: '正常', published: '已发布', retired: '已下架', archived: '已归档', pending: '待处理', pending_payment: '待付款', paid: '已支付', succeeded: '已成功', failed: '失败', requested: '已申请', completed: '已完成', verified: '已验证', dns_pending: 'DNS 待验证', tls_pending: 'TLS 待就绪', disabled: '已停用' })[key] || value || '-';
}
function renderDomains() {
return `<div class="page-shell">${pageHead('租户域名与证书', '统一查看租户域名的 DNS 验证、证书和主域名状态,可对未就绪域名重新检查。')}
<section class="table-panel"><table class="platform-table"><thead><tr><th>租户</th><th>域名</th><th>用途</th><th>主域名</th><th>DNS 验证</th><th>TLS 就绪</th><th>最近检查</th><th>状态</th><th></th></tr></thead><tbody>${data.domains.map(item => `<tr><td>${escapeHtml(tenantById(item.tenantId)?.name || item.tenantId)}</td><td><code>${escapeHtml(item.host)}</code></td><td>${escapeHtml(item.domainType || '-')}</td><td>${item.isPrimary ? '是' : '否'}</td><td>${item.dnsVerifiedAt ? '已验证' : '待验证'}</td><td>${item.tlsReadyAt ? '已就绪' : '待就绪'}</td><td>${escapeHtml(item.lastCheckedAt || '-')}</td><td>${statusBadge(businessStatusLabel(item.status))}<small>${escapeHtml(item.lastFailureReason || '')}</small></td><td><button class="button" data-action="recheck-domain" data-domain-id="${item.id}">重新检查</button></td></tr>`).join('')}</tbody></table>${data.domains.length ? '' : emptyBlock('暂无租户域名', '租户配置域名后将在这里显示。')}</section></div>`;
}
function renderCatalog() {
const catalog = data.saasCatalog || {};
const features = catalog.features || [];
const offerings = catalog.offerings || [];
const versions = catalog.versions || [];
const offeringName = id => offerings.find(item => item.id === id)?.name || id;
return `<div class="page-shell">${pageHead('SaaS 商品与版本', '管理可售套餐、增值服务、功能权益、使用限额和定价版本。', `<button class="button" data-action="new-saas-feature">${icon('blocks')}新建功能</button><button class="button" data-action="new-saas-limit">${icon('gauge')}新建限额</button><button class="button" data-action="new-saas-offering">${icon('package-plus')}新建商品</button><button class="button" data-action="tenant-feature-override">${icon('building-cog')}租户功能覆盖</button><button class="button primary" data-action="new-saas-version">${icon('git-branch-plus')}新建版本</button>`)}
<section class="metric-grid"><article class="metric"><span>${icon('blocks')}</span><small>功能</small><strong>${features.length}</strong><p>其中 ${features.filter(item => String(item.status).toLowerCase() === 'active').length} 项启用</p></article><article class="metric"><span>${icon('packages')}</span><small>商品</small><strong>${offerings.length}</strong><p>基础套餐与增值包</p></article><article class="metric"><span>${icon('git-branch')}</span><small>版本</small><strong>${versions.length}</strong><p>草稿、发布与下架生命周期</p></article></section>
<section class="table-panel"><div class="table-head"><h2>套餐版本</h2><span>发布后版本不可直接覆写</span></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>${versions.map(item => `<tr><td><strong>${escapeHtml(offeringName(item.offeringId))}</strong><small>${escapeHtml(item.offeringId)}</small></td><td>V${item.version}</td><td>${escapeHtml(item.billingCycle || '-')}</td><td>${money(Number(item.originalAmountCents || 0) / 100)}</td><td><strong>${money(Number(item.amountCents || 0) / 100)}</strong></td><td>${escapeHtml(item.effectiveAt || '-')}</td><td>${statusBadge(businessStatusLabel(item.status))}</td><td><div class="row-actions">${String(item.status).toLowerCase() === 'draft' ? `<button class="button" data-action="publish-saas-version" data-version-id="${item.id}">发布</button>` : `<button class="button" data-action="clone-saas-version" data-version-id="${item.id}">克隆</button>`}${String(item.status).toLowerCase() === 'published' ? `<button class="button" data-action="retire-saas-version" data-version-id="${item.id}">下架</button>` : ''}</div></td></tr>`).join('')}</tbody></table></section>
<section class="table-panel"><div class="table-head"><h2>功能目录</h2><span>配置套餐可包含的业务功能</span></div><table class="platform-table"><thead><tr><th>功能</th><th>编码</th><th>分类</th><th>参考价</th><th>核心功能</th><th>状态</th></tr></thead><tbody>${features.map(item => `<tr><td><strong>${escapeHtml(item.name)}</strong><small>${escapeHtml(item.description || '')}</small></td><td><code>${escapeHtml(item.code)}</code></td><td>${escapeHtml(item.category || '-')}</td><td>${money(Number(item.referencePriceCents || 0) / 100)}</td><td>${item.isCore ? '是' : '否'}</td><td>${statusBadge(businessStatusLabel(item.status))}</td></tr>`).join('')}</tbody></table></section><section class="table-panel"><div class="table-head"><h2>功能限额</h2><span>设置席位、题量、存储等使用上限</span></div><table class="platform-table"><thead><tr><th>限额</th><th>指标编码</th><th>所属功能</th><th>单位</th><th>计量方式</th><th>告警阈值</th><th>硬限额</th></tr></thead><tbody>${(catalog.limitDefinitions || []).map(item => `<tr><td><strong>${escapeHtml(item.name)}</strong></td><td><code>${escapeHtml(item.metricCode)}</code></td><td>${escapeHtml(features.find(feature => feature.code === item.featureCode)?.name || item.featureCode)}</td><td>${escapeHtml(item.unit)}</td><td>${String(item.kind).toLowerCase() === 'period' ? '周期累计' : '当前值'}</td><td>${item.warningPercent}%</td><td>${item.isHardLimit ? '是' : '否'}</td></tr>`).join('')}</tbody></table></section></div>`;
}
function renderSaasFinance() {
const tabs = [['orders', '订单'], ['payments', '支付'], ['refunds', '退款'], ['invoices', '发票'], ['subscriptions', '订阅']];
const rows = state.saasFinanceTab === 'orders' ? data.saasOrders : state.saasFinanceTab === 'payments' ? data.billingPayments : state.saasFinanceTab === 'refunds' ? data.saasRefunds : state.saasFinanceTab === 'invoices' ? data.invoices : data.subscriptions;
const cells = item => state.saasFinanceTab === 'orders' ? [item.orderNo, tenantById(item.tenantId)?.name || item.tenantId, item.purpose, money(Number(item.totalAmountCents || 0) / 100), businessStatusLabel(item.status), item.expiresAt] : state.saasFinanceTab === 'payments' ? [item.paymentNo || item.id, tenantById(item.tenantId)?.name || item.tenantId, item.provider || '-', money(item.amount), businessStatusLabel(item.status), item.paidAt || '-'] : state.saasFinanceTab === 'refunds' ? [item.refundNo || item.id, tenantById(item.tenantId)?.name || item.tenantId, item.reason || '-', money(Number(item.amountCents || 0) / 100), businessStatusLabel(item.status), item.completedAt || '-'] : state.saasFinanceTab === 'invoices' ? [item.id, tenantById(item.tenantId)?.name || item.tenantId, item.type, money(item.total), item.status, item.due] : [item.id, tenantById(item.tenantId)?.name || item.tenantId, item.planCode || '-', money(item.amount), item.status, item.expires];
return `<div class="page-shell">${pageHead('SaaS 订单与交易', '集中查看租户订购、收款、退款、开票和订阅情况。')}<nav class="billing-tabs">${tabs.map(([value, label]) => `<button class="${state.saasFinanceTab === value ? 'is-active' : ''}" data-saas-finance-tab="${value}">${label}</button>`).join('')}</nav><section class="table-panel"><table class="platform-table"><thead><tr><th>编号</th><th>租户</th><th>类型 / 说明</th><th>金额</th><th>状态</th><th>时间</th></tr></thead><tbody>${rows.map(item => `<tr>${cells(item).map((value, index) => `<td>${index === 0 ? `<code>${escapeHtml(value)}</code>` : escapeHtml(value)}</td>`).join('')}</tr>`).join('')}</tbody></table>${rows.length ? '' : emptyBlock('当前暂无数据', '有新的业务记录后会在这里显示。')}</section></div>`;
}
function renderRoles() {
return `<div class="page-shell">${pageHead('平台角色与功能', '按岗位配置可访问的菜单和可执行的业务操作。', `<button class="button primary" data-action="new-platform-role">${icon('shield-plus')}新建角色</button>`)}<div class="staff-layout"><section class="panel"><div class="panel-head"><div><h2>平台角色</h2><span>${data.platformRoles.length} 个启用角色</span></div></div>${data.platformRoles.map(role => `<article class="role-row"><span>${icon('shield-check')}</span><div><strong>${escapeHtml(role.name)}</strong><small>${escapeHtml(role.description || '暂无角色说明')}</small></div><b>${role.permissionCodes.length} 项功能</b><button class="button" data-action="edit-platform-role" data-role-id="${role.id}">编辑</button></article>`).join('')}</section><section class="panel"><div class="panel-head"><div><h2>可分配功能</h2><span>选择角色可以使用的业务功能</span></div></div>${Object.entries(data.platformPermissions).map(([code, name]) => `<article class="role-row"><span>${icon('key-round')}</span><div><strong>${escapeHtml(name)}</strong><small><code>${escapeHtml(code)}</code></small></div><b>${data.platformRoles.filter(role => role.permissionCodes.includes(code) || role.permissionCodes.includes('*')).length} 个角色</b></article>`).join('')}</section></div></div>`;
}
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 套餐与订阅', '查看套餐权益,并为租户创建或续订订阅。套餐内容请在“商品与版本”中维护。', `<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];
if (selected && state.selectedBank !== selected.id) state.selectedBank = selected.id;
const query = state.bankSearch.trim().toLowerCase();
const bankRows = data.banks.filter(bank => !query || [bank.name, bank.status].some(value => String(value || '').toLowerCase().includes(query)));
const nodes = data.bankNodes.filter(node => node.questionBankId === selected?.id);
const nodeOptions = nodes.filter(node => node.isActive).map(node => `<option value="${node.id}" ${state.selectedBankNode === node.id ? 'selected' : ''}>${' '.repeat(node.depth || 0)}${escapeHtml(node.name)}${bankNodeTypeLabel(node.nodeType)}</option>`).join('');
const tabs = [['structure', '内容结构'], ['single', '单题录入'], ['batch', '批量导入'], ['manage', '题目管理'], ['structured', 'JSON 文件导入']];
const bankSelect = `<select id="bankSelector" aria-label="当前公共题库">${data.banks.map(bank => `<option value="${bank.id}" ${bank.id === selected?.id ? 'selected' : ''}>${escapeHtml(bank.name)}</option>`).join('')}</select>`;
let content = emptyBlock('尚未创建公共题库', '点击“新建题库”后即可配置分类、科目、章节和试卷。');
if (selected && state.bankTab === 'structure') content = `<div class="bank-layout"><aside class="bank-tree"><header><strong>内容层级</strong><span>${nodes.length} 个节点</span></header>${nodes.map(node => `<button class="${node.id === state.selectedBankNode ? 'is-active' : ''}" data-bank-node-select="${node.id}" style="padding-left:${18 + Number(node.depth || 0) * 18}px">${icon(node.nodeType === 'paper' ? 'file-text' : node.nodeType === 'chapter' ? 'book-open' : 'folder-tree')}<span><strong>${escapeHtml(node.name)}</strong><small>${bankNodeTypeLabel(node.nodeType)} · ${node.questionCount || 0} 题</small></span></button>`).join('') || '<p class="tree-empty">暂无内容层级</p>'}</aside><section class="bank-content"><header><div><span>平台公共题库</span><h2>${escapeHtml(selected.name)}</h2><p>通过分类、科目、章节和试卷组织题目。</p></div><div class="row-actions"><button class="button" data-action="edit-bank">编辑题库</button><button class="button" data-action="new-bank-node">新建节点</button><button class="button primary" data-action="batch-bank-nodes">批量创建章节/试卷</button></div></header><div class="bank-stats"><div><span>内容节点</span><strong>${selected.nodes || nodes.length}</strong></div><div><span>题目总数</span><strong>${formatNumber(selected.questions || 0)}</strong></div><div><span>启用节点</span><strong>${nodes.filter(item => item.isActive).length}</strong></div><div><span>题库状态</span><strong>${selected.status}</strong></div></div>${state.selectedBankNode ? `<section class="version-card"><div><span class="status success">${bankNodeTypeLabel(bankNodeById(state.selectedBankNode)?.nodeType)}</span><h3>${escapeHtml(bankNodeById(state.selectedBankNode)?.name || '')}</h3><p>当前节点共 ${bankNodeById(state.selectedBankNode)?.questionCount || 0} 道未归档题目。</p></div><div><button class="button" data-action="edit-bank-node">编辑节点</button><button class="button danger" data-action="archive-bank-node">归档节点</button></div></section>` : ''}</section></div>`;
if (selected && state.bankTab === 'structure' && state.selectedBankNode) content += `<section class="panel"><div class="panel-head"><div><h2>节点排序</h2><span>调整当前节点在同级内容中的显示顺序</span></div><div class="row-actions"><button class="button" data-action="shift-bank-node" data-sort-delta="-10">上移</button><button class="button" data-action="shift-bank-node" data-sort-delta="10">下移</button></div></div></section>`;
if (selected && state.bankTab === 'single') content = `<section class="panel"><div class="panel-head"><div><h2>单题录入</h2><span>保存后自动形成新题目版本</span></div></div><form id="bankQuestionForm" class="modal-form"><input type="hidden" name="id" /><div class="form-error-summary" hidden tabindex="-1"></div><div class="form-grid"><label>目标章节/试卷<select name="contentNodeId" required>${nodeOptions}</select></label><label>题型<select name="type"><option value="choice">单选题</option><option value="multiple_choice">多选题</option><option value="true_false">判断题</option><option value="fill_blank">填空题</option><option value="short_answer">简答题</option><option value="reading">阅读材料题</option><option value="programming">编程题</option></select></label><label>难度<select name="difficulty"><option value="1">1 · 容易</option><option value="2">2 · 较易</option><option value="3" selected>3 · 中等</option><option value="4">4 · 较难</option><option value="5">5 · 困难</option></select></label><label>稳定编号<input name="legacyId" placeholder="可选,用于重复导入识别" /></label><label class="full">题干<textarea name="content" rows="6" required placeholder="支持 Markdown 和图片链接"></textarea></label><label class="full">选项 JSON<textarea name="options" rows="4" placeholder='["选项 A","选项 B"]'></textarea></label><label class="full">答案<textarea name="answerText" rows="2"></textarea></label><label class="full">解析<textarea name="explanation" rows="4"></textarea></label><label>图片或媒体地址<input name="mediaUrl" placeholder="https://..." /></label><label>上传题目图片<input id="bankQuestionImage" type="file" accept="image/*" /><button class="button" type="button" data-action="upload-question-image">上传并填入地址</button></label><label>标签<input name="tags" placeholder="高频、真题" /></label></div><div class="modal-actions"><button class="button" type="button" data-action="preview-question">预览</button><button class="button primary" type="submit">保存题目</button></div></form></section>`;
if (selected && state.bankTab === 'batch') content = `<section class="panel"><div class="panel-head"><div><h2>普通题目批量导入</h2><span>先选择章节或试卷,再粘贴题目数组</span></div></div><form id="bankBatchImportForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><div class="form-grid"><label>目标章节/试卷<select name="contentNodeId" required>${nodeOptions}</select></label><label>来源名称<input name="sourceName" placeholder="例如2026 年高数整理" /></label><label class="full">题目 JSON<textarea name="payload" rows="16" required placeholder='[{"type":"choice","content":"题干","options":["A","B"],"answerText":"A"}]'></textarea></label></div><div class="modal-actions"><button class="button" type="submit" data-import-mode="preview">预检数据</button><button class="button primary" type="submit" data-import-mode="execute">执行导入</button></div></form>${renderBankImportResult()}</section>`;
if (selected && state.bankTab === 'manage') content = `<section class="filter-bar"><label>${icon('search')}<input id="bankQuestionSearch" placeholder="搜索题干或题型" /></label><select id="bankQuestionType"><option value="">全部题型</option>${['choice','multiple_choice','true_false','fill_blank','short_answer','reading','programming'].map(value => `<option value="${value}">${questionTypeLabel(value)}</option>`).join('')}</select><select id="bankQuestionStatus"><option value="">全部状态</option><option value="published">已发布</option><option value="draft">草稿</option><option value="archived">已归档</option></select><button class="button" data-action="load-bank-questions">查询题目</button></section><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.bankQuestions.map(item => `<tr><td><strong>${escapeHtml(String(item.content || '').slice(0, 80) || '未填写题干')}</strong><small>${escapeHtml(item.legacyId || item.id)}</small></td><td>${questionTypeLabel(item.type)}</td><td>${item.difficulty || '-'}</td><td>${escapeHtml(bankNodeById(item.contentNodeId)?.name || '-')}</td><td>V${item.versionNo}</td><td>${statusBadge(item.statusLabel)}</td><td><button class="button" data-action="edit-bank-question" data-question-id="${item.id}">编辑</button>${item.statusLabel !== '已归档' ? `<button class="button danger" data-action="archive-bank-question" data-question-id="${item.id}">归档</button>` : ''}</td></tr>`).join('')}</tbody></table>${data.bankQuestions.length ? `<div class="table-head"><span>共 ${state.bankQuestionTotal} 道题</span><div><button class="button" data-action="bank-question-page" data-page="${Math.max(1, state.bankQuestionPage - 1)}" ${state.bankQuestionPage <= 1 ? 'disabled' : ''}>上一页</button><button class="button" data-action="bank-question-page" data-page="${state.bankQuestionPage + 1}" ${state.bankQuestionPage * 20 >= state.bankQuestionTotal ? 'disabled' : ''}>下一页</button></div></div>` : emptyBlock('暂无题目', '选择节点后查询,或先录入、导入题目。')}</section>`;
if (selected && state.bankTab === 'structured') content = `<section class="panel"><div class="panel-head"><div><h2>JSON 文件导入</h2><span>支持带分类、科目、章节/试卷和题目的 2.0 结构化文件</span></div></div><form id="bankStructuredImportForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><div class="form-grid"><label>挂载到已有节点<select name="contentNodeId"><option value="">从题库根层级导入</option>${nodeOptions}</select></label><label>选择 .json 文件<input id="bankStructuredFile" type="file" accept="application/json,.json" /></label><label class="full">结构化 JSON<textarea id="bankStructuredPayload" name="payload" rows="18" required placeholder='{"_tikuExport":"2.0","subjects":[...]}'></textarea></label></div><div class="modal-actions"><button class="button" type="submit" data-import-mode="preview">预检层级和题目</button><button class="button primary" type="submit" data-import-mode="execute">执行结构化导入</button></div></form>${renderBankImportResult()}</section>`;
return `<div class="page-shell">${pageHead('公共题库', '配置公共题库内容结构,完成单题录入、题目管理和两种 JSON 导入。', `${selected ? `<button class="button danger" data-action="archive-bank">${icon('archive')}归档题库</button>` : ''}<button class="button primary" data-action="new-bank">${icon('plus')}新建题库</button>`)}<section class="filter-bar"><label>${icon('search')}<input id="bankSearch" value="${escapeHtml(state.bankSearch)}" placeholder="搜索题库名称" /></label>${bankSelect}<button class="button" data-action="apply-bank-search">${icon('search')}查询</button></section>${contractResult('bank-query')}<nav class="billing-tabs">${tabs.map(([value, label]) => `<button class="${state.bankTab === value ? 'is-active' : ''}" data-bank-tab="${value}">${label}</button>`).join('')}</nav>${content}</div>`;
}
function renderBankImportResult() {
const result = data.bankImportResult;
if (!result) return '';
const job = result.detail?.job || result.job || {};
const issues = result.detail?.issues || result.issues || [];
return `<section class="version-card"><div><span class="status ${Number(job.errorCount || 0) ? 'warning' : 'success'}">${job.dryRun ? '预检完成' : '导入完成'}</span><h3>共 ${job.totalCount || 0} 条,${job.validCount || 0} 条有效</h3><p>新增题目 ${result.insertedQuestionCount || 0},更新 ${result.updatedQuestionCount || 0},跳过 ${result.skippedQuestionCount || 0},新建节点 ${result.createdNodeCount || 0}。</p>${issues.length ? `<small>${issues.slice(0, 8).map(item => `${item.rowNo || '-'} 行:${escapeHtml(item.message)}`).join('<br>')}</small>` : '<small>没有发现数据问题。</small>'}</div></section>`;
}
function renderCrm() {
const activeConfigs = data.crmConfigs.filter(item => item.enabled).length;
const failedLeads = data.crmLeads.filter(item => item.status === '推送失败').length;
const notice = '集中维护租户线索推送配置、失败队列、分配策略和同步日志。';
if (state.crmTab === 'logs') return `<div class="page-shell">${pageHead('CRM 推送日志', notice)}<nav class="billing-tabs"><button data-crm-tab="configs">接入配置</button><button data-crm-tab="leads">线索队列</button><button class="is-active" data-crm-tab="logs">推送日志</button><button data-crm-tab="rules">分配策略</button></nav><section class="table-panel"><table class="platform-table"><thead><tr><th>日志 ID</th><th>租户</th><th>队列任务</th><th>HTTP 状态</th><th>耗时</th><th>尝试次数</th><th>结果 / 错误</th><th>时间</th></tr></thead><tbody>${data.crmLogs.map(item => `<tr><td><code>${escapeHtml(item.id)}</code></td><td>${escapeHtml(tenantById(item.tenantId)?.name || item.tenantId)}</td><td><code>${escapeHtml(item.queueId || '-')}</code></td><td>${escapeHtml(item.statusCode || '-')}</td><td>${escapeHtml(item.durationMs || 0)} ms</td><td>${escapeHtml(item.attempt || item.attempts || 1)}</td><td>${statusBadge(item.success ? '成功' : '失败')}<small>${escapeHtml(item.error || item.responseSummary || '')}</small></td><td>${escapeHtml(item.createdAt || '-')}</td></tr>`).join('')}</tbody></table>${data.crmLogs.length ? '' : emptyBlock('暂无 CRM 推送日志', '线索推送后会在这里保留请求结果与错误摘要。')}</section></div>`;
return `<div class="page-shell">${pageHead('CRM 接入与线索流转', notice, `${canWrite('platform:crm:write') ? `<button class="button primary" data-action="new-crm-config">${icon('plus')}接入配置</button>` : ''}`)}
<section class="usage-kpis"><div><span>已启用接入</span><strong>${activeConfigs}</strong><small>共 ${data.crmConfigs.length} 家租户配置</small><i><b style="width:${Math.min(100, activeConfigs / Math.max(1, data.crmConfigs.length) * 100)}%"></b></i></div><div><span>待推送线索</span><strong>${data.crmLeads.filter(item => item.status === '待推送').length}</strong><small>来自学生端留资与活动报名</small><i><b style="width:36%"></b></i></div><div><span>推送失败</span><strong>${failedLeads}</strong><small>进入重试或死信处理</small><i class="orange"><b style="width:${failedLeads ? 45 : 0}%"></b></i></div><div><span>权限</span><strong>${hasPermission('platform:crm:write') ? '可维护' : '只读'}</strong><small><code>platform:crm:read/write</code></small><i><b style="width:${hasPermission('platform:crm:write') ? 100 : 50}%"></b></i></div></section>
<nav class="billing-tabs"><button class="${state.crmTab === 'configs' ? 'is-active' : ''}" data-crm-tab="configs">接入配置</button><button class="${state.crmTab === 'leads' ? 'is-active' : ''}" data-crm-tab="leads">线索队列</button><button class="${state.crmTab === 'logs' ? 'is-active' : ''}" data-crm-tab="logs">推送日志</button><button class="${state.crmTab === 'rules' ? 'is-active' : ''}" data-crm-tab="rules">分配策略</button></nav>
${state.crmTab === 'configs' ? `<section class="table-panel"><table class="platform-table"><thead><tr><th>租户</th><th>Provider</th><th>Endpoint</th><th>分配方式</th><th>线索池</th><th>超时 / 重试</th><th>状态</th><th></th></tr></thead><tbody>${data.crmConfigs.map(config => `<tr><td><strong>${escapeHtml(tenantById(config.tenantId)?.name || '-')}</strong><small>${escapeHtml(config.id)}</small></td><td>${escapeHtml(config.provider)}</td><td><code>${escapeHtml(config.endpoint)}</code></td><td>${escapeHtml(config.assignmentMode)}</td><td>${escapeHtml(config.pool)}</td><td>${config.timeout}s / ${config.retry} 次</td><td>${statusBadge(config.status)}</td><td><div class="row-actions">${canWrite('platform:crm:write') ? `<button class="row-action" data-action="edit-crm-config" data-crm-id="${config.id}">${icon('pencil')}</button><button class="switch ${config.enabled ? 'is-on' : ''}" data-action="toggle-crm-config" data-crm-id="${config.id}"><i></i></button>` : ''}</div></td></tr>`).join('')}</tbody></table></section>` : state.crmTab === 'leads' ? `<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>${data.crmLeads.map(lead => `<tr><td><strong>${escapeHtml(lead.student)}</strong><small>${lead.id} · ${lead.phone}</small></td><td>${escapeHtml(tenantById(lead.tenantId)?.name || '-')}</td><td>${escapeHtml(lead.source)}<small>${escapeHtml(lead.subject)}</small></td><td>${escapeHtml(lead.owner)}</td><td>${lead.attempts}</td><td>${statusBadge(lead.status)}</td><td>${escapeHtml(lead.lastError || '-')}</td><td>${lead.status === '推送失败' && canWrite('platform:crm:write') ? `<button class="row-action" data-action="retry-crm-lead" data-lead-id="${lead.id}">${icon('rotate-ccw')}</button>` : ''}</td></tr>`).join('')}</tbody></table></section>` : `<div class="staff-layout"><section class="panel"><div class="panel-head"><div><h2>平台默认策略</h2><span>租户可在套餐授权后覆盖</span></div></div>${[['按地区轮转', '地区、校区、课程标签优先,适合多校区机构'], ['按校区容量', '按顾问容量与当日跟进上限分配'], ['手工分配', '只进入队列,由租户管理员手工认领']].map(([name, desc]) => `<article class="role-row"><span>${icon('route')}</span><div><strong>${name}</strong><small>${desc}</small></div><b>启用</b><button class="button" data-action="crm-rule-detail">查看</button></article>`).join('')}</section><section class="panel"><div class="panel-head"><div><h2>线索同步边界</h2><span>平台统一监控推送质量和异常重试</span></div></div><div class="policy-timeline"><p><b>接入配置</b><span>维护 endpoint、secret 引用、超时和重试策略</span></p><p><b>线索队列</b><span>集中查看待推送、失败和重试中的线索</span></p><p><b>同步日志</b><span>保留请求结果、错误摘要和审计线索</span></p></div></section></div>`}</div>`;
}
function renderSms() {
const sent = data.smsChannels.reduce((sum, item) => sum + item.used, 0);
const quota = data.smsChannels.reduce((sum, item) => sum + item.quota, 0);
const notice = '集中管理租户短信渠道、模板审核状态和发送记录。';
const smsNav = `<nav class="billing-tabs"><button class="${state.smsTab === 'channels' ? 'is-active' : ''}" data-sms-tab="channels">短信渠道</button><button class="${state.smsTab === 'templates' ? 'is-active' : ''}" data-sms-tab="templates">短信模板</button><button class="${state.smsTab === 'logs' ? 'is-active' : ''}" data-sms-tab="logs">发送日志</button></nav>`;
if (state.smsTab === 'templates') return `<div class="page-shell">${pageHead('短信模板与审核', notice, `<button class="button primary" data-action="new-sms-template">${icon('plus')}新建模板</button>`)}${smsNav}<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.smsTemplates.map(template => `<tr><td>${escapeHtml(tenantById(template.tenantId)?.name || template.tenantId)}</td><td><strong>${escapeHtml(template.name)}</strong><small>${escapeHtml(template.id)}</small></td><td>${escapeHtml(template.channelName || data.smsChannels.find(item => item.id === template.channelId)?.signature || '-')}</td><td><code>${escapeHtml(template.code)}</code></td><td>${escapeHtml(template.type)}</td><td>${template.sentCount || 0} / ${template.failedCount || 0}</td><td>${statusBadge(template.status)}</td><td>${statusBadge(template.templateStatus || '-')}</td><td><button class="button" data-action="edit-sms-template" data-template-id="${template.id}">编辑</button>${template.status === '草稿' ? `<button class="button" data-action="submit-sms-template" data-template-id="${template.id}">提交审核</button>` : ''}<button class="button" data-action="disable-sms-template" data-template-id="${template.id}">停用</button></td></tr>`).join('')}</tbody></table></section></div>`;
if (state.smsTab === 'logs') return `<div class="page-shell">${pageHead('短信发送日志', notice)}${smsNav}<section class="table-panel"><table class="platform-table"><thead><tr><th>日志 ID</th><th>租户</th><th>模板</th><th>手机号</th><th>Provider</th><th>时间</th><th>状态</th></tr></thead><tbody>${(data.smsLogs || []).map(item => `<tr><td><code>${escapeHtml(item.id)}</code></td><td>${escapeHtml(tenantById(item.tenantId)?.name || item.tenantId)}</td><td>${escapeHtml(data.smsTemplates.find(template => template.id === item.templateId)?.name || item.templateId || '-')}</td><td>${escapeHtml(item.phone)}</td><td>${escapeHtml(item.provider)}</td><td>${escapeHtml(item.updatedAt)}</td><td>${statusBadge(item.status)}</td></tr>`).join('')}</tbody></table>${data.smsLogs?.length ? '' : emptyBlock('暂无短信发送日志', '发送短信后会在这里显示脱敏记录。')}</section></div>`;
return `<div class="page-shell">${pageHead('SMS 短信服务', notice, `${canWrite('platform:sms:write') ? `<button class="button primary" data-action="new-sms-channel">${icon('plus')}短信渠道</button>` : ''}`)}
<section class="usage-kpis"><div><span>启用渠道</span><strong>${data.smsChannels.filter(item => item.enabled).length}</strong><small>租户自有短信服务</small><i><b style="width:66%"></b></i></div><div><span>本月发送</span><strong>${formatNumber(sent)}</strong><small>总额度 ${formatNumber(quota)}</small><i><b style="width:${quota ? Math.min(100, sent / quota * 100) : 0}%"></b></i></div><div><span>模板通过</span><strong>${data.smsTemplates.filter(item => item.status === '已通过').length}</strong><small>${data.smsTemplates.length} 个模板</small><i><b style="width:67%"></b></i></div><div><span>权限</span><strong>${hasPermission('platform:sms:write') ? '可维护' : '只读'}</strong><small><code>platform:sms:read/write</code></small><i><b style="width:${hasPermission('platform:sms:write') ? 100 : 50}%"></b></i></div></section>
<nav class="billing-tabs"><button class="${state.smsTab === 'channels' ? 'is-active' : ''}" data-sms-tab="channels">短信渠道</button><button class="${state.smsTab === 'templates' ? 'is-active' : ''}" data-sms-tab="templates">短信模板</button><button class="${state.smsTab === 'logs' ? 'is-active' : ''}" data-sms-tab="logs">发送日志</button></nav>
${state.smsTab === 'channels' ? `<section class="table-panel"><table class="platform-table"><thead><tr><th>租户</th><th>Provider</th><th>签名</th><th>场景</th><th>Secret Ref</th><th>额度使用</th><th>状态</th><th></th></tr></thead><tbody>${data.smsChannels.map(channel => `<tr><td><strong>${escapeHtml(tenantById(channel.tenantId)?.name || '-')}</strong><small>${channel.id}</small></td><td>${escapeHtml(channel.provider)}</td><td>${escapeHtml(channel.signature)}</td><td>${escapeHtml(channel.scene)}</td><td><code>${escapeHtml(channel.secretRef)}</code></td><td><div class="usage-cell"><span>${formatNumber(channel.used)} / ${formatNumber(channel.quota)}</span><i><b style="width:${channel.quota ? Math.min(100, channel.used / channel.quota * 100) : 0}%"></b></i></div></td><td>${statusBadge(channel.status)}</td><td><div class="row-actions">${canWrite('platform:sms:write') ? `<button class="row-action" data-action="edit-sms-channel" data-sms-id="${channel.id}">${icon('pencil')}</button><button class="switch ${channel.enabled ? 'is-on' : ''}" data-action="toggle-sms-channel" data-sms-id="${channel.id}"><i></i></button>` : ''}</div></td></tr>`).join('')}</tbody></table></section>` : state.smsTab === 'templates' ? `<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.smsTemplates.map(template => { const channel = data.smsChannels.find(item => item.id === template.channelId); return `<tr><td><strong>${escapeHtml(template.name)}</strong><small>${template.id}</small></td><td>${escapeHtml(channel?.signature || '-')}</td><td><code>${escapeHtml(template.code)}</code></td><td>${escapeHtml(template.type)}</td><td>${escapeHtml(template.lastSend)}</td><td>${escapeHtml(template.successRate)}</td><td>${statusBadge(template.status)}</td></tr>`; }).join('')}</tbody></table></section>` : `<section class="table-panel"><table class="platform-table"><thead><tr><th>事件</th><th>租户</th><th>模板</th><th>手机号</th><th>Provider</th><th>时间</th><th>状态</th></tr></thead><tbody>${data.smsTemplates.slice(0, 3).map((template, index) => { const channel = data.smsChannels.find(item => item.id === template.channelId); return `<tr><td><code>SMSLOG-260723-00${index + 1}</code></td><td>${escapeHtml(tenantById(channel?.tenantId)?.name || '-')}</td><td>${escapeHtml(template.name)}</td><td>13${index + 6}****${8200 + index}</td><td>${escapeHtml(channel?.provider || '-')}</td><td>${escapeHtml(template.lastSend || '-')}</td><td>${statusBadge(template.status === '已通过' ? '发送成功' : '待发送')}</td></tr>`; }).join('')}</tbody></table></section>`}</div>`;
}
function renderPaymentSettings() {
const activeApps = data.paymentApps.filter(item => item.status === '正常').length;
const notice = '集中管理平台收款、租户支付代管、支付渠道和回调健康。';
const paymentNav = `<nav class="billing-tabs"><button class="${state.paymentTab === 'apps' ? 'is-active' : ''}" data-payment-tab="apps">支付应用</button><button class="${state.paymentTab === 'channels' ? 'is-active' : ''}" data-payment-tab="channels">支付渠道</button><button class="${state.paymentTab === 'events' ? 'is-active' : ''}" data-payment-tab="events">支付事件</button><button class="${state.paymentTab === 'rebates' ? 'is-active' : ''}" data-payment-tab="rebates">返佣汇总</button><button class="${state.paymentTab === 'settlement' ? 'is-active' : ''}" data-payment-tab="settlement">结算与风控</button></nav>`;
if (state.paymentTab === 'channels') return `<div class="page-shell">${pageHead('平台支付渠道', '管理平台收款应用的支付服务商、回调地址和密钥引用。', `<button class="button primary" data-action="new-payment-channel">${icon('plus')}新建渠道</button>`)}${paymentNav}<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>${data.paymentChannels.map(item => `<tr><td>${escapeHtml(data.paymentApps.find(appItem => appItem.id === item.appId)?.appName || item.appId)}</td><td><strong>${escapeHtml(item.displayName)}</strong><small>${escapeHtml(item.id)}</small></td><td>${escapeHtml(item.provider)}</td><td>${escapeHtml(item.mode)}</td><td><code>${escapeHtml(item.callbackPath || '-')}</code></td><td><code>${escapeHtml(item.secretRef || '-')}</code></td><td>${statusBadge(businessStatusLabel(item.status))}</td><td><button class="button" data-action="edit-payment-channel" data-channel-id="${item.id}">编辑</button><button class="button" data-action="disable-payment-channel" data-channel-id="${item.id}">停用</button></td></tr>`).join('')}</tbody></table>${data.paymentChannels.length ? '' : emptyBlock('暂无平台支付渠道', '请先创建平台收款应用。')}</section></div>`;
if (state.paymentTab === 'rebates') { const summary = data.rebateSummary || {}; return `<div class="page-shell">${pageHead('平台返佣汇总', '查看平台交易的返佣、待结算、已结算与异常数据。')}${paymentNav}<section class="metric-grid"><article class="metric"><small>交易总额</small><strong>${money(Number(summary.grossAmountCents || 0) / 100)}</strong></article><article class="metric"><small>返佣总额</small><strong>${money(Number(summary.commissionAmountCents || 0) / 100)}</strong></article><article class="metric"><small>待结算</small><strong>${money(Number(summary.pendingSettlementCents || 0) / 100)}</strong></article><article class="metric"><small>已结算</small><strong>${money(Number(summary.paidSettlementCents || 0) / 100)}</strong></article><article class="metric"><small>异常数</small><strong>${summary.exceptionCount || 0}</strong></article></section></div>`; }
return `<div class="page-shell">${pageHead('支付设置', notice, `${canWrite('platform:payment:write') ? `<button class="button primary" data-action="new-payment-app">${icon('plus')}支付应用</button>` : ''}`)}
<section class="usage-kpis"><div><span>正常应用</span><strong>${activeApps}</strong><small>共 ${data.paymentApps.length} 个收款应用</small><i><b style="width:${Math.min(100, activeApps / Math.max(1, data.paymentApps.length) * 100)}%"></b></i></div><div><span>本日支付事件</span><strong>${data.paymentEvents.length}</strong><small>含成功、回调失败与人工确认</small><i><b style="width:52%"></b></i></div><div><span>回调异常</span><strong>${data.paymentEvents.filter(item => item.status === '回调失败').length}</strong><small>需要财务或运维介入</small><i class="orange"><b style="width:34%"></b></i></div><div><span>权限</span><strong>${hasPermission('platform:payment:write') ? '可维护' : '只读'}</strong><small><code>platform:payment:read/write</code></small><i><b style="width:${hasPermission('platform:payment:write') ? 100 : 50}%"></b></i></div></section>
${paymentNav}
${state.paymentTab === 'apps' ? `<section class="table-panel"><table class="platform-table"><thead><tr><th>租户</th><th>应用</th><th>Provider</th><th>模式</th><th>结算</th><th>Secret Ref</th><th>状态</th><th></th></tr></thead><tbody>${data.paymentApps.map(appItem => `<tr><td><strong>${escapeHtml(tenantById(appItem.tenantId)?.name || '-')}</strong><small>${appItem.id}</small></td><td>${escapeHtml(appItem.appName)}</td><td><code>${escapeHtml(appItem.provider)}</code></td><td>${escapeHtml(appItem.mode)}</td><td>${escapeHtml(appItem.settlement)}</td><td><code>${escapeHtml(appItem.secretRef)}</code></td><td>${statusBadge(appItem.status)}</td><td><div class="row-actions">${canWrite('platform:payment:write') ? `<button class="row-action" data-action="edit-payment-app" data-payment-id="${appItem.id}">${icon('pencil')}</button><button class="switch ${appItem.status !== '未启用' ? 'is-on' : ''}" data-action="toggle-payment-app" data-payment-id="${appItem.id}"><i></i></button>` : ''}</div></td></tr>`).join('')}</tbody></table></section>` : state.paymentTab === 'events' ? `<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>${data.paymentEvents.map(event => { const appItem = data.paymentApps.find(item => item.id === event.appId); return `<tr><td><code>${event.id}</code></td><td>${escapeHtml(appItem?.appName || '-')}<small>${escapeHtml(tenantById(appItem?.tenantId)?.name || '-')}</small></td><td><code>${escapeHtml(event.orderNo)}</code></td><td><strong>${money(event.amount)}</strong></td><td>${escapeHtml(event.channel)}</td><td>${escapeHtml(event.notified)}</td><td>${escapeHtml(event.updatedAt)}</td><td>${statusBadge(event.status)}</td></tr>`; }).join('')}</tbody></table></section>` : `<div class="staff-layout"><section class="panel"><div class="panel-head"><div><h2>平台风控规则</h2><span>配置证书、回调、人工确认等支付安全策略</span></div></div>${[['证书过期提醒', '支付证书 14 天内过期生成高危告警'], ['回调失败重试', '按 1m/5m/30m/2h 退避重试并记录审计'], ['人工确认保护', '线下收款必须二次确认并保留流水号脱敏值']].map(([name, desc]) => `<article class="role-row"><span>${icon('shield-check')}</span><div><strong>${name}</strong><small>${desc}</small></div><b>启用</b><button class="button" data-action="payment-rule-detail">查看</button></article>`).join('')}</section><section class="panel"><div class="panel-head"><div><h2>支付能力边界</h2><span>区分平台收款和租户收款代管</span></div></div><div class="policy-timeline"><p><b>平台收款应用</b><span>用于套餐、模块和增值服务收款</span></p><p><b>租户代管应用</b><span>维护学生端收款渠道和回调地址</span></p><p><b>支付事件</b><span>只读跟踪交易、退款、回调和人工确认状态</span></p></div></section></div>`}</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>${data.platformRoles.map(role => { const features = role.permissionCodes.map(code => data.platformPermissions[code] || code); return `<article class="role-row"><span>${icon('shield-check')}</span><div><strong>${escapeHtml(role.name)}</strong><small>${escapeHtml(role.description || (features.includes('*') ? '全部平台功能' : features.join('、')))}</small></div><b>${data.staff.filter(person => person.roleIds?.includes(role.id)).length}人</b><button class="button" data-action="new-staff" data-role-id="${role.id}">按此角色创建</button></article>`; }).join('') || emptyBlock('暂无可用平台角色', '请先创建平台角色并选择可用功能。')}</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></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, domains: renderDomains, tenantDetail: renderTenantDetail, billingProfile: renderBillingProfile, catalog: renderCatalog, subscriptions: renderSubscriptions, saasFinance: renderSaasFinance, billing: renderBilling, usage: renderUsage, dunning: renderDunning, bank: renderBank, crm: renderCrm, sms: renderSms, paymentSettings: renderPaymentSettings, staff: renderStaff, roles: renderRoles, 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, crmTab: state.crmTab, smsTab: state.smsTab, paymentTab: state.paymentTab, saasFinanceTab: state.saasFinanceTab, bankSearch: state.bankSearch, selectedBank: state.selectedBank, selectedBankNode: state.selectedBankNode, 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('该模块当前不可用,请先完成后端配置后再访问', '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 (view === 'bank') loadBankWorkspace();
};
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(backendErrorText(error, '提交失败'), 'error');
if (error.status === 400) showFormErrors({ server: backendErrorText(error, '服务端字段校验失败') });
if (submitButton) { submitButton.disabled = false; submitButton.innerHTML = submitMarkup; refreshIcons(); }
}
};
finish();
}
async function executeBackendMutation(operationId, input, apply, { refresh = true, pathParams = {} } = {}) {
if (!platformApi?.isEnabled()) {
apply?.(null);
return true;
}
try {
const payload = await platformApi.request(operationId, {
body: platformApi.buildBody(operationId, typeof input === 'function' ? input() : input),
pathParams,
});
apply?.(payload);
if (refresh) hydrateFromBackend({ silent: true });
return true;
} catch (error) {
showToast(backendErrorText(error, '提交失败'), '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 = {
saasFeature() {
return modalFrame('新建 SaaS 功能', '功能将用于套餐版本与租户权益校验。', `<form id="saasFeatureForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><div class="form-grid">${formField('功能编码 *', 'code', '', 'required placeholder="例如 learning.practice"')}${formField('功能名称 *', 'name', '', 'required')}${formField('业务分类 *', 'category', '', 'required')}${formField('功能说明', 'description')}${formField('参考价(元)', 'referencePrice', '0', 'type="number" min="0" step="0.01"')}<label><span>状态</span><select name="status"><option value="active">正常</option><option value="draft">草稿</option></select></label></div></form>`, `<button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="saasFeatureForm">保存功能</button>`, { id: 'SaaS 商品目录', wide: true });
},
saasOffering() {
return modalFrame('新建 SaaS 商品', '商品可为基础套餐或增值包。', `<form id="saasOfferingForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><div class="form-grid">${formField('商品编码 *', 'code', '', 'required')}${formField('商品名称 *', 'name', '', 'required')}<label><span>商品类型</span><select name="type"><option value="base_plan">基础套餐</option><option value="add_on">增值包</option></select></label>${formField('商品说明', 'description')}<label><span>状态</span><select name="status"><option value="active">正常</option><option value="draft">草稿</option></select></label></div></form>`, `<button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="saasOfferingForm">保存商品</button>`, { id: 'SaaS 商品目录', wide: true });
},
saasLimit() {
const features = data.saasCatalog?.features || [];
return modalFrame('新建 SaaS 功能限额', '限额定义供套餐版本配置和运行时用量校验使用。', `<form id="saasLimitForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><div class="form-grid">${formField('指标编码 *', 'metricCode', '', 'required placeholder="例如 students.current"')}<label><span>所属功能 *</span><select name="featureCode">${features.map(item => `<option value="${item.code}">${escapeHtml(item.name)}</option>`).join('')}</select></label>${formField('限额名称 *', 'name', '', 'required')}${formField('单位 *', 'unit', 'count', 'required')}<label><span>计量方式</span><select name="kind"><option value="current">当前值</option><option value="period">周期累计</option></select></label>${formField('告警阈值(%', 'warningPercent', '80', 'type="number" min="1" max="100"')}<label class="check-row"><input name="isHardLimit" type="checkbox" checked /> 超额时拒绝继续写入</label></div></form>`, `<button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="saasLimitForm">保存限额</button>`, { id: 'SaaS 功能限额', wide: true });
},
tenantFeatureOverride() {
const features = data.saasCatalog?.features || [];
return modalFrame('租户功能覆盖', '对单个租户强制启用或停用某项 SaaS 功能,必须记录可审计原因。', `<form id="tenantFeatureOverrideForm" 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="featureCode">${features.map(item => `<option value="${item.code}">${escapeHtml(item.name)}</option>`).join('')}</select></label><label><span>覆盖方式</span><select name="mode"><option value="force_enabled">强制启用</option><option value="force_disabled">强制停用</option><option value="inherit">恢复套餐继承</option></select></label><label><span>到期时间</span><input type="datetime-local" name="expiresAt" /></label>${formField('操作原因 *', 'reason', '', 'required')}</div></form>`, `<button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="tenantFeatureOverrideForm">保存覆盖规则</button>`, { id: 'SaaS 租户权益', wide: true });
},
saasVersion() {
const offerings = data.saasCatalog?.offerings || [];
return modalFrame('新建套餐版本', '新版本先保存为草稿,确认后再单独发布。', `<form id="saasVersionForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><div class="form-grid"><label><span>所属商品 *</span><select name="offeringId">${offerings.map(item => `<option value="${item.id}">${escapeHtml(item.name)}</option>`).join('')}</select></label><label><span>计费周期</span><select name="billingCycle"><option value="yearly">年付</option><option value="monthly">月付</option><option value="quarterly">季付</option></select></label>${formField('原价(元)', 'originalAmount', '0', 'type="number" min="0" step="0.01"')}${formField('售价(元)', 'amount', '0', 'type="number" min="0" step="0.01"')}${formField('包含功能编码', 'featureCodes', '', 'placeholder="多个编码用逗号分隔"')}<label><span>生效时间</span><input type="datetime-local" name="effectiveAt" /></label></div></form>`, `<button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="saasVersionForm">保存草稿</button>`, { id: 'SaaS 版本管理', wide: true });
},
platformRole() {
const role = data.platformRoles.find(item => item.id === state.modalData.roleId) || {};
return modalFrame(role.id ? '编辑平台角色' : '新建平台角色', '选择该角色可以使用的业务功能,保存后对所属员工生效。', `<form id="platformRoleForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><input type="hidden" name="id" value="${role.id || ''}" /><div class="form-grid">${formField('角色编码 *', 'code', role.code || '', 'required')}${formField('角色名称 *', 'name', role.name || '', 'required')}${formField('角色说明', 'description', role.description || '')}</div><fieldset><legend>功能选择</legend><div class="permission-grid">${Object.entries(data.platformPermissions).map(([code, name]) => `<label><input type="checkbox" name="permissionCodes" value="${code}" ${role.permissionCodes?.includes(code) || role.permissionCodes?.includes('*') ? 'checked' : ''} /><span><strong>${escapeHtml(name)}</strong><small>${escapeHtml(code)}</small></span></label>`).join('')}</div></fieldset></form>`, `<button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="platformRoleForm">保存角色</button>`, { id: '平台角色管理', wide: true });
},
paymentChannel() {
const channel = data.paymentChannels.find(item => item.id === state.modalData.channelId) || {};
const apps = data.paymentApps.filter(item => item.appScope === 'platform');
return modalFrame(channel.id ? '编辑平台支付渠道' : '新建平台支付渠道', '渠道密钥只保存 Secret Ref不在页面保存明文凭据。', `<form id="paymentChannelForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><input type="hidden" name="id" value="${channel.id || ''}" /><div class="form-grid"><label><span>平台支付应用 *</span><select name="appId">${apps.map(item => `<option value="${item.id}" ${channel.appId === item.id ? 'selected' : ''}>${escapeHtml(item.appName)}</option>`).join('')}</select></label>${formField('渠道名称 *', 'displayName', channel.displayName || '', 'required')}${formField('Provider *', 'provider', channel.provider || 'manual', 'required')}${formField('模式', 'mode', channel.mode || 'PlatformCollect')}${formField('回调路径', 'callbackPath', channel.callbackPath || '')}${formField('Secret Ref', 'secretRef', channel.secretRef || '')}<label><span>状态</span><select name="status"><option value="active">正常</option><option value="testing">测试中</option><option value="disabled">已停用</option></select></label></div></form>`, `<button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="paymentChannelForm">保存渠道</button>`, { id: '平台支付设置', wide: true });
},
smsTemplate() {
const template = data.smsTemplates.find(item => item.id === state.modalData.templateId) || {};
const tenantId = template.tenantId || state.selectedTenant || data.smsChannels[0]?.tenantId || '';
const channels = data.smsChannels.filter(item => !tenantId || item.tenantId === tenantId);
return modalFrame(template.id ? '编辑短信模板' : '新建短信模板', '模板先保存为草稿,再通过独立动作提交审核。', `<form id="smsTemplateForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><input type="hidden" name="id" value="${template.id || ''}" /><div class="form-grid"><label><span>租户 *</span><select name="tenantId">${tenantOptions(tenantId)}</select></label><label><span>短信渠道 *</span><select name="channelId">${channels.map(item => `<option value="${item.id}" ${template.channelId === item.id ? 'selected' : ''}>${escapeHtml(item.name || item.signature)}</option>`).join('')}</select></label>${formField('模板编码 *', 'code', template.code || '', 'required')}${formField('模板名称 *', 'name', template.name || '', 'required')}<label><span>模板类型</span><select name="type"><option value="notification">通知</option><option value="verification">验证码</option><option value="marketing">营销</option></select></label>${formField('Provider 模板编码', 'providerTemplateCode', template.providerTemplateCode || '')}</div><label><span>模板内容 *</span><textarea name="content" rows="5" required>${escapeHtml(template.content || '')}</textarea></label>${formField('备注', 'remark', template.remark || '')}</form>`, `<button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="smsTemplateForm">保存草稿</button>`, { id: '短信模板管理', wide: true });
},
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('开通租户', '创建租户和首位负责人账号;套餐、域名和账务信息可在开通后继续配置。', `<form id="createTenantForm" class="modal-form" novalidate><div class="form-error-summary" hidden tabindex="-1"></div><div class="form-grid">${formField('租户短编码 *', 'slug', '', 'required placeholder="例如 xuehai-zj"')}${formField('租户名称 *', 'name', '', 'required')}${formField('法定主体名称', 'legalName')}${formField('负责人姓名 *', 'ownerName', '', 'required')}${formField('负责人邮箱', 'ownerEmail', '', 'type="email"')}${formField('负责人手机号', 'ownerPhone', '', 'placeholder="11 位手机号"')}${formField('临时密码 *', 'temporaryPassword', '', 'required type="password" minlength="12" autocomplete="new-password" placeholder="至少 12 位,包含字母和数字"')}<label><span>业务状态</span><select name="status"><option>待配置</option><option>试用</option><option>正常</option></select></label><label><span>账务状态</span><select name="billingStatus"><option>试用</option><option>正常</option><option>暂停</option></select></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);
const payment = paymentForInvoice(invoice);
return modalFrame('人工确认收款', `${invoice.id} · 待确认金额 ${money(payment?.amount || invoice.balance)}。本操作确认后端已存在的待支付记录。`, `<form id="paymentForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><div class="form-grid">${formField('交易流水号 *', 'tradeNo', '', 'required placeholder="银行或支付渠道流水号"')}<label><span>支付时间 *</span><input name="paidAt" type="datetime-local" value="${new Date().toISOString().slice(0, 16)}" /></label>${formField('确认原因 *', 'reason', '平台财务核对到账', 'required')}</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 });
},
bank() {
const bank = bankById(state.modalData.bankId) || {};
return modalFrame(bank.id ? '编辑公共题库' : '新建公共题库', '题库保存后可继续配置分类、科目、章节和试卷。', `<form id="bankForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><input type="hidden" name="id" value="${bank.id || ''}" /><div class="form-grid">${formField('题库名称 *', 'name', bank.name || '', 'required maxlength="300"')}${formField('内容版本', 'version', bank.version === '-' ? '' : (bank.version || ''), 'placeholder="例如2026.1"')}</div></form>`, `<button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="bankForm">保存题库</button>`, { id: 'P-09 · 公共题库' });
},
bankNode() {
const node = bankNodeById(state.modalData.nodeId) || {};
const parents = data.bankNodes.filter(item => item.questionBankId === state.selectedBank && item.isActive && item.id !== node.id);
return modalFrame(node.id ? '编辑内容节点' : '新建内容节点', '使用中文业务层级组织题目,章节和试卷可直接录入题目。', `<form id="bankNodeForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><input type="hidden" name="id" value="${node.id || ''}" /><div class="form-grid">${formField('节点名称 *', 'name', node.name || '', 'required maxlength="300"')}<label><span>节点类型 *</span><select name="nodeType"><option value="category" ${node.nodeType === 'category' ? 'selected' : ''}>分类</option><option value="subject" ${node.nodeType === 'subject' ? 'selected' : ''}>科目</option><option value="chapter" ${node.nodeType === 'chapter' ? 'selected' : ''}>章节</option><option value="paper" ${node.nodeType === 'paper' ? 'selected' : ''}>试卷</option></select></label><label><span>上级节点</span><select name="parentId"><option value="">题库根层级</option>${parents.map(item => `<option value="${item.id}" ${item.id === (node.parentId || state.selectedBankNode) ? 'selected' : ''}>${' '.repeat(item.depth || 0)}${escapeHtml(item.name)}</option>`).join('')}</select></label>${formField('排序', 'sortOrder', node.sortOrder || 0, 'type="number" min="0"')}</div></form>`, `<button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="bankNodeForm">保存节点</button>`, { id: 'P-09 · 内容结构' });
},
batchBankNodes() {
return modalFrame('批量创建章节或试卷', '每行一个名称,一次最多创建 100 个。', `<form id="bankNodeBatchForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><div class="form-grid"><label><span>类型</span><select name="nodeType"><option value="chapter">章节</option><option value="paper">试卷</option></select></label><label><span>上级节点</span><select name="parentId"><option value="">题库根层级</option>${data.bankNodes.filter(item => item.questionBankId === state.selectedBank && item.isActive).map(item => `<option value="${item.id}" ${item.id === state.selectedBankNode ? 'selected' : ''}>${' '.repeat(item.depth || 0)}${escapeHtml(item.name)}</option>`).join('')}</select></label><label class="full"><span>名称列表 *</span><textarea name="names" rows="10" required placeholder="第一章 函数与极限&#10;第二章 导数与微分"></textarea></label></div></form>`, `<button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="bankNodeBatchForm">批量创建</button>`, { id: 'P-09 · 批量创建', wide: true });
},
crmConfig() {
const config = data.crmConfigs.find(item => item.id === state.modalData.crmId) || {};
return modalFrame(config.id ? '编辑 CRM 接入' : '新建 CRM 接入', '平台代管租户 CRM webhook 接入Secret 只保存引用,不提交明文密钥。', `<form id="crmConfigForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><input type="hidden" name="id" value="${config.id || ''}" /><input type="hidden" name="backendId" value="${config.backendId || config.id || ''}" /><div class="form-grid"><label><span>租户 *</span><select name="tenantId">${tenantOptions(config.tenantId || state.selectedTenant)}</select></label>${formField('Webhook Endpoint *', 'endpoint', config.endpoint || '', 'required placeholder="https://..."')}${formField('Secret Ref', 'secretRef', config.secretRef || '', 'placeholder="tenant_secret:crm:..."')}${formField('表单/线索池', 'pool', config.pool || '')}${formField('考试类型', 'examType', config.examType || '')}${formField('超时(秒)', 'timeout', config.timeout || 10, 'type="number" min="1" max="120"')}${formField('重试次数', 'retry', config.retry || 3, 'type="number" min="0" max="10"')}<label><span>分配方式</span><select name="assignmentMode"><option value="round_robin">按地区轮转</option><option value="direct">手工分配</option><option value="referrer">来源归属</option><option value="none">不分配</option></select></label><label class="check-row"><input name="enabled" type="checkbox" ${config.enabled !== false ? 'checked' : ''} /> 启用接入</label></div></form>`, `<button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="crmConfigForm">保存接入</button>`, { id: 'P-10 · CRM 接入', wide: true });
},
smsChannel() {
const channel = data.smsChannels.find(item => item.id === state.modalData.smsId) || {};
return modalFrame(channel.id ? '编辑短信渠道' : '新建短信渠道', '渠道密钥只保存 Secret Ref模板和发送日志会保留历史。', `<form id="smsChannelForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><input type="hidden" name="id" value="${channel.id || ''}" /><input type="hidden" name="backendId" value="${channel.backendId || channel.id || ''}" /><div class="form-grid"><label><span>租户 *</span><select name="tenantId">${tenantOptions(channel.tenantId || state.selectedTenant)}</select></label>${formField('Provider *', 'provider', channel.provider || 'aliyun', 'required')}${formField('渠道名称 *', 'name', channel.name || '', 'required')}${formField('短信签名 *', 'signature', channel.signature || '', 'required')}${formField('场景 *', 'scene', channel.scene || 'login', 'required')}${formField('Secret Ref', 'secretRef', channel.secretRef || '')}${formField('月额度', 'quota', channel.quota || 0, 'type="number" min="0"')}<label><span>状态</span><select name="status"><option ${channel.status === '正常' ? 'selected' : ''}>正常</option><option ${channel.status === '测试中' ? 'selected' : ''}>测试中</option><option ${channel.status === '未启用' ? 'selected' : ''}>未启用</option></select></label></div></form>`, `<button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="smsChannelForm">保存渠道</button>`, { id: 'P-11 · 短信渠道', wide: true });
},
paymentApp() {
const appItem = data.paymentApps.find(item => item.id === state.modalData.paymentId) || {};
const isTenant = appItem.appScope === 'tenant' || appItem.tenantId;
return modalFrame(appItem.id ? '编辑支付应用' : '新建平台支付应用', '平台自有收款用于租户购买套餐/模块;租户代管应用可选择具体租户。', `<form id="paymentAppForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><input type="hidden" name="id" value="${appItem.id || ''}" /><input type="hidden" name="backendId" value="${appItem.backendId || appItem.id || ''}" /><div class="form-grid"><label><span>类型</span><select name="appScope"><option value="platform" ${!isTenant ? 'selected' : ''}>平台自有收款</option><option value="tenant" ${isTenant ? 'selected' : ''}>租户支付代管</option></select></label><label><span>租户</span><select name="tenantId"><option value="">平台</option>${tenantOptions(appItem.tenantId || state.selectedTenant)}</select></label>${formField('应用代码', 'appCode', appItem.appCode || '')}${formField('应用名称 *', 'appName', appItem.appName || '', 'required')}${formField('Provider', 'provider', appItem.provider === 'platform' ? 'manual' : (appItem.provider || 'manual'))}${formField('Secret Ref', 'secretRef', appItem.secretRef || '')}${formField('结算模式', 'settlement', appItem.settlement || 'PlatformCollect')}<label><span>状态</span><select name="status"><option ${appItem.status === '正常' ? 'selected' : ''}>正常</option><option ${appItem.status === '测试中' ? 'selected' : ''}>测试中</option><option ${appItem.status === '未启用' ? 'selected' : ''}>未启用</option></select></label></div></form>`, `<button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="paymentAppForm">保存应用</button>`, { id: 'P-12 · 支付设置', wide: true });
},
staff() {
const person = data.staff.find(item => item.id === state.modalData.staffId) || {};
const editablePhone = /^1\d{10}$/.test(person.phone || '') ? person.phone : '';
const selectedRoleIds = person.roleIds?.length ? person.roleIds : (state.modalData.roleId ? [state.modalData.roleId] : []);
const roleOptions = data.platformRoles.map(role => {
const featureNames = role.permissionCodes.map(code => data.platformPermissions[code] || code).join('、') || '暂无功能';
return `<label><input type="checkbox" name="roleIds" value="${role.id}" ${selectedRoleIds.includes(role.id) ? 'checked' : ''} /><span><strong>${escapeHtml(role.name)}</strong><small>${escapeHtml(role.description || featureNames)}</small><em>包含功能:${escapeHtml(featureNames)}</em></span></label>`;
}).join('');
return modalFrame(person.id ? '编辑平台员工' : '新建平台员工', '员工账号与平台角色由后端统一管理,功能说明来自当前权限目录。', `<form id="staffForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><input type="hidden" name="userId" value="${person.userId || person.id || ''}" /><div class="form-grid">${formField('姓名 *', 'name', person.name || '', 'required')}${formField('邮箱 *', 'email', person.email || '', 'required type="email"')}${formField('手机号', 'phone', editablePhone, 'placeholder="已脱敏号码留空表示不修改"')}</div><fieldset><legend>角色与功能选择</legend><div class="permission-grid">${roleOptions || '<p>当前账号无权读取平台角色目录。</p>'}</div><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-13 · 员工角色', 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-14 · 只读详情', 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-15 · 状态机', 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></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-15 · 通知事件', 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-15 · 通知渠道', 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', 'bankForm', 'bankNodeForm', 'bankNodeBatchForm', 'crmConfigForm', 'smsChannelForm', 'paymentAppForm', 'staffForm', 'auditChannelForm']);
if (desktopOnlyForms.has(form.id) && requireDesktop('提交复杂配置或资金操作')) return;
if (form.id === 'saasFeatureForm') {
const values = Object.fromEntries(new FormData(form));
simulateWrite({ key: 'saas-feature', permission: 'platform:plan:write', operationId: 'PlatformSaasController_upsertFeature', body: values, validate: () => !values.code || !values.name || !values.category ? { code: '功能编码、名称和分类不能为空' } : null, apply: payload => { const item = payload || { ...values, id: entityId('FEATURE'), referencePriceCents: Math.round(Number(values.referencePrice || 0) * 100) }; data.saasCatalog.features.unshift(item); return item; }, success: 'SaaS 功能已保存' });
}
if (form.id === 'saasOfferingForm') {
const values = Object.fromEntries(new FormData(form));
simulateWrite({ key: 'saas-offering', permission: 'platform:plan:write', operationId: 'PlatformSaasController_upsertOffering', body: values, validate: () => !values.code || !values.name ? { code: '商品编码和名称不能为空' } : null, apply: payload => { const item = payload || { ...values, id: entityId('OFFERING') }; data.saasCatalog.offerings.unshift(item); return item; }, success: 'SaaS 商品已保存' });
}
if (form.id === 'saasLimitForm') {
const values = Object.fromEntries(new FormData(form)); values.isHardLimit = Boolean(new FormData(form).get('isHardLimit'));
simulateWrite({ key: 'saas-limit', permission: 'platform:plan:write', operationId: 'PlatformSaasController_upsertFeatureLimit', body: values, validate: () => !values.metricCode || !values.featureCode || !values.name ? { metricCode: '指标编码、所属功能和限额名称不能为空' } : null, apply: payload => { const item = payload || { ...values, id: entityId('LIMIT') }; data.saasCatalog.limitDefinitions.unshift(item); return item; }, success: 'SaaS 功能限额已保存' });
}
if (form.id === 'tenantFeatureOverrideForm') {
const values = Object.fromEntries(new FormData(form));
simulateWrite({ key: 'tenant-feature-override', permission: 'platform:plan:write', operationId: 'PlatformSaasController_upsertTenantFeatureOverride', body: values, validate: () => !values.tenantId || !values.featureCode || !values.reason ? { reason: '租户、功能和操作原因不能为空' } : null, apply: payload => payload || values, success: '租户功能覆盖规则已保存' });
}
if (form.id === 'saasVersionForm') {
const values = Object.fromEntries(new FormData(form));
simulateWrite({ key: 'saas-version', permission: 'platform:plan:write', operationId: 'PlatformSaasController_upsertVersion', body: values, validate: () => !values.offeringId ? { offeringId: '必须选择所属商品' } : Number(values.amount) < 0 ? { amount: '售价不能小于 0' } : null, apply: payload => { const item = payload || { ...values, id: entityId('VERSION'), version: 1, status: 'draft', amountCents: Math.round(Number(values.amount || 0) * 100), originalAmountCents: Math.round(Number(values.originalAmount || 0) * 100) }; data.saasCatalog.versions.unshift(item); return item; }, success: '套餐版本草稿已保存' });
}
if (form.id === 'platformRoleForm') {
const values = Object.fromEntries(new FormData(form)); const permissionCodes = new FormData(form).getAll('permissionCodes');
let savedRole = null;
simulateWrite({ key: 'platform-role', permission: 'platform:staff:write', request: async () => { savedRole = await platformApi.request('PlatformBackofficeController_upsertRole', { body: platformApi.buildBody('PlatformBackofficeController_upsertRole', values) }); await platformApi.request('PlatformBackofficeController_replaceRoleBindings', { pathParams: { roleId: savedRole.id }, body: platformApi.buildBody('PlatformBackofficeController_replaceRoleBindings', { permissionCodes, menuCodes: savedRole.menuCodes || [] }) }); return savedRole; }, validate: () => { const errors = {}; if (!values.code) errors.code = '角色编码不能为空'; if (!values.name) errors.name = '角色名称不能为空'; if (!permissionCodes.length) errors.permissionCodes = '至少选择一项平台功能'; return Object.keys(errors).length ? errors : null; }, apply: payload => { const existing = data.platformRoles.find(item => item.id === values.id); const next = { id: payload?.id || values.id || entityId('ROLE'), code: values.code, name: values.name, description: values.description || '', permissionCodes, menuCodes: payload?.menuCodes || existing?.menuCodes || [] }; if (existing) Object.assign(existing, next); else data.platformRoles.unshift(next); return next; }, success: '平台角色与功能绑定已保存' });
}
if (form.id === 'paymentChannelForm') {
const values = Object.fromEntries(new FormData(form));
simulateWrite({ key: 'payment-channel', permission: 'platform:payment:write', operationId: 'PlatformAdminPaymentSettingsController_upsertChannel', body: values, validate: () => { const errors = {}; if (!values.appId) errors.appId = '必须选择平台支付应用'; if (!values.displayName) errors.displayName = '渠道名称不能为空'; if (!values.provider) errors.provider = 'Provider 不能为空'; return Object.keys(errors).length ? errors : null; }, apply: payload => { const existing = data.paymentChannels.find(item => item.id === values.id); const next = payload || { ...values, id: values.id || entityId('PAYCH') }; if (existing) Object.assign(existing, next); else data.paymentChannels.unshift(next); return next; }, success: '平台支付渠道已保存' });
}
if (form.id === 'smsTemplateForm') {
const values = Object.fromEntries(new FormData(form));
simulateWrite({ key: 'sms-template', permission: 'platform:sms:write', operationId: 'PlatformAdminSmsController_upsertTemplate', body: values, validate: () => { const errors = {}; ['tenantId', 'channelId', 'code', 'name', 'content'].forEach(name => { if (!values[name]) errors[name] = `${name} 不能为空`; }); return Object.keys(errors).length ? errors : null; }, apply: payload => { const existing = data.smsTemplates.find(item => item.id === values.id); const next = payload ? platformApi.adapters.smsTemplate(payload) : { ...values, id: values.id || entityId('SMSTPL'), status: '草稿', templateStatus: '正常', sentCount: 0, failedCount: 0 }; if (existing) Object.assign(existing, next); else data.smsTemplates.unshift(next); return next; }, success: '短信模板草稿已保存' });
}
if (form.id === 'createTenantForm') {
const values = Object.fromEntries(new FormData(form)); const slug = values.slug; const name = values.name;
simulateWrite({ key: 'create-tenant', permission: 'platform:tenant:write', operationId: 'PlatformAdminTenantsController_create', body: () => values, validate: () => {
const errors = {}; if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) errors.slug = 'slug 仅支持小写字母、数字和连字符';
if (!name) errors.name = '租户名称不能为空';
if (!values.ownerName) errors.ownerName = '负责人姓名不能为空';
if (!values.ownerEmail && !values.ownerPhone) errors.ownerEmail = '负责人邮箱和手机号至少填写一项';
if (values.ownerPhone && !/^1\d{10}$/.test(values.ownerPhone)) errors.ownerPhone = '请输入 11 位中国大陆手机号';
if (!/^(?=.*[A-Za-z])(?=.*\d).{12,}$/.test(values.temporaryPassword || '')) errors.temporaryPassword = '临时密码至少 12 位且必须同时包含字母和数字';
if (data.tenants.some(item => item.slug === slug)) errors.slug = 'slug 已存在'; return Object.keys(errors).length ? errors : null;
}, apply: payload => { const fallback = { id: `tenant_${slug.replaceAll('-', '_')}`, slug, name, legal: values.legalName || name, brand: name, shortName: name, host: '-', plan: '未订阅', planCode: '', status: values.status, billing: values.billingStatus, mode: '-', students: 0, questions: 0, storageGB: 0, trafficTB: 0, expires: '', balance: 0, owner: values.ownerName, ownerPhone: values.ownerPhone || '-', createdAt: todayTime() }; const tenantPayload = payload?.tenant || payload?.item; const tenant = tenantPayload ? platformApi.adapters.tenant(tenantPayload, fallback, {}) : fallback; tenant.owner = values.ownerName; tenant.ownerPhone = values.ownerPhone || '-'; data.tenants.unshift(tenant); state.selectedTenant = tenant.id; addAudit('tenant.create', 'tenant', tenant.id, tenant.name, '高', `开通租户 ${tenant.name} 并创建首位负责人账号。`); 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 payment = paymentForInvoice(invoice); const amount = payment?.amount || invoice.balance;
simulateWrite({ key: 'payment', permission: 'platform:billing:payment', operationId: 'PlatformAdminBillingController_confirmPayment', body: () => ({ ...values, paymentId: payment?.id, amount }), validate: () => { const errors = {}; if (platformApi?.isEnabled() && !payment) errors.paymentId = '该账单没有可确认的待支付记录'; if (!amount || amount <= 0) errors.amount = '收款金额必须大于 0'; if (amount > invoice.balance) errors.amount = `收款金额不可超过余额 ${money(invoice.balance)}`; 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 ? '已支付' : '部分支付'; if (payment) payment.status = 'succeeded'; 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 === 'bankForm') {
const values = Object.fromEntries(new FormData(form));
simulateWrite({ key: 'question-bank', permission: 'platform:question-bank:manage', operationId: 'PlatformQuestionBanksController_upsertBank', body: () => ({ id: values.id || null, name: values.name, metadata: values.version ? { version: values.version } : {} }), validate: () => !values.name?.trim() ? { name: '题库名称不能为空' } : null, apply: payload => { const item = payload ? platformApi.adapters.bank(payload, bankById(payload.id)) : { id: values.id || entityId('QB'), name: values.name, version: values.version || '-', status: '正常', source: '平台自建', catalog: '平台公共题库', nodes: 0, questions: 0 }; const existing = bankById(item.id); if (existing) Object.assign(existing, item); else data.banks.unshift(item); state.selectedBank = item.id; state.selectedBankNode = ''; return item; }, success: '公共题库已保存' });
}
if (form.id === 'bankNodeForm') {
const values = Object.fromEntries(new FormData(form));
simulateWrite({ key: 'question-bank-node', permission: 'platform:question-bank:manage', operationId: 'PlatformQuestionBanksController_upsertNode', body: () => ({ id: values.id || null, questionBankId: state.selectedBank, parentId: values.parentId || null, nodeKey: null, name: values.name, nodeType: values.nodeType, sortOrder: Number(values.sortOrder || 0), isSelectable: true, metadata: {} }), validate: () => !values.name?.trim() ? { name: '节点名称不能为空' } : null, apply: payload => { const item = payload ? adaptBankNode(payload) : { id: values.id || entityId('NODE'), questionBankId: state.selectedBank, parentId: values.parentId || null, name: values.name, nodeType: values.nodeType, sortOrder: Number(values.sortOrder || 0), depth: values.parentId ? Number(bankNodeById(values.parentId)?.depth || 0) + 1 : 0, isActive: true, questionCount: 0 }; const existing = bankNodeById(item.id); if (existing) Object.assign(existing, item); else data.bankNodes.push(item); state.selectedBankNode = item.id; return item; }, success: '内容节点已保存' });
}
if (form.id === 'bankNodeBatchForm') {
const values = Object.fromEntries(new FormData(form)); const names = String(values.names || '').split(/\r?\n/).map(item => item.trim()).filter(Boolean);
simulateWrite({ key: 'question-bank-node-batch', permission: 'platform:question-bank:manage', operationId: 'PlatformQuestionBanksController_batchCreateNodes', body: () => ({ questionBankId: state.selectedBank, parentId: values.parentId || null, nodeType: values.nodeType, names }), validate: () => !names.length ? { names: '至少填写一个章节或试卷名称' } : names.length > 100 ? { names: '一次最多创建 100 个节点' } : null, apply: payload => { const items = apiItems(payload); if (items.length) data.bankNodes.push(...items.map(adaptBankNode)); return items.length || names.length; }, success: count => `已创建 ${count} 个内容节点` });
}
if (form.id === 'bankQuestionForm') {
const values = Object.fromEntries(new FormData(form)); const options = parseJsonText(values.options, []); const tags = String(values.tags || '').split(/[、,]/).map(item => item.trim()).filter(Boolean);
simulateWrite({ key: 'question-bank-question', permission: 'platform:question-bank:manage', operationId: 'PlatformQuestionBanksController_upsertQuestion', body: () => ({ id: values.id || null, questionBankId: state.selectedBank, contentNodeId: values.contentNodeId, legacyId: values.legacyId || null, type: values.type, typeLabel: questionTypeLabel(values.type), difficulty: Number(values.difficulty || 3), tags, content: values.content, options, correctOptionIndex: null, correctOptionIndices: [], answerText: values.answerText || null, explanation: values.explanation || null, subQuestions: [], codeLang: null, codeTemplate: null, mediaUrl: values.mediaUrl || null, status: 'published', examMarkers: {}, sourceHash: null }), validate: () => { const errors = {}; if (!values.contentNodeId) errors.contentNodeId = '请选择章节或试卷'; if (!values.content?.trim()) errors.content = '题干不能为空'; if (options === null || !Array.isArray(options)) errors.options = '选项必须是 JSON 数组'; return Object.keys(errors).length ? errors : null; }, apply: payload => { const item = payload ? adaptBankQuestion(payload) : { ...values, id: values.id || entityId('Q'), questionBankId: state.selectedBank, contentNodeId: values.contentNodeId, options, tags, versionNo: 1, status: 'published', statusLabel: '已发布' }; const existing = data.bankQuestions.find(row => row.id === item.id); if (existing) Object.assign(existing, item); else data.bankQuestions.unshift(item); return item; }, success: '题目已保存并生成新版本', close: false });
}
if (form.id === 'bankBatchImportForm' || form.id === 'bankStructuredImportForm') {
const values = Object.fromEntries(new FormData(form)); const payload = parseJsonText(values.payload, null); const execute = event.submitter?.dataset.importMode === 'execute'; const format = form.id === 'bankStructuredImportForm' ? 'structured-v2' : 'simple';
simulateWrite({ key: `question-bank-import-${format}`, permission: 'platform:question-bank:manage', operationId: execute ? 'PlatformQuestionBanksController_executeImport' : 'PlatformQuestionBanksController_previewImport', body: () => ({ questionBankId: state.selectedBank, contentNodeId: values.contentNodeId || null, format, sourceName: values.sourceName || (format === 'structured-v2' ? '结构化题库文件' : '普通题目批量导入'), payload }), validate: () => { const errors = {}; if (format === 'simple' && !values.contentNodeId) errors.contentNodeId = '普通批量导入必须选择章节或试卷'; if (payload === null) errors.payload = '请输入有效的 JSON'; return Object.keys(errors).length ? errors : null; }, apply: result => { data.bankImportResult = result || { detail: { job: { dryRun: !execute, totalCount: Array.isArray(payload) ? payload.length : 0, validCount: Array.isArray(payload) ? payload.length : 0, errorCount: 0 }, issues: [] }, insertedQuestionCount: execute && Array.isArray(payload) ? payload.length : 0, updatedQuestionCount: 0, skippedQuestionCount: 0, createdNodeCount: 0 }; return data.bankImportResult; }, success: execute ? '题库导入已执行' : '导入预检已完成', close: false });
}
if (form.id === 'crmConfigForm') {
const values = Object.fromEntries(new FormData(form));
values.enabled = Boolean(new FormData(form).get('enabled'));
simulateWrite({ key: 'crm-config', permission: 'platform:crm:write', operationId: 'PlatformAdminCrmController_upsertConfig', body: () => values, validate: () => { const errors = {}; if (!values.tenantId) errors.tenantId = '租户不能为空'; if (!values.endpoint) errors.endpoint = 'Webhook Endpoint 不能为空'; return Object.keys(errors).length ? errors : null; }, apply: payload => { const item = payload ? platformApi.adapters.crmConfig(payload) : { ...values, id: values.id || entityId('CRM'), enabled: values.enabled, status: values.enabled ? '正常' : '未启用' }; const index = data.crmConfigs.findIndex(row => row.id === item.id); if (index >= 0) data.crmConfigs[index] = item; else data.crmConfigs.unshift(item); addAudit('crm.config.upsert', 'crm_config', item.id, tenantById(item.tenantId)?.name || item.id, '中', '保存 CRM 接入配置。'); }, success: 'CRM 接入配置已保存' });
}
if (form.id === 'smsChannelForm') {
const values = Object.fromEntries(new FormData(form));
simulateWrite({ key: 'sms-channel', permission: 'platform:sms:write', operationId: 'PlatformAdminSmsController_upsertChannel', body: () => values, validate: () => { const errors = {}; if (!values.tenantId) errors.tenantId = '租户不能为空'; if (!values.provider) errors.provider = 'Provider 不能为空'; if (!values.signature) errors.signature = '短信签名不能为空'; return Object.keys(errors).length ? errors : null; }, apply: payload => { const item = payload ? platformApi.adapters.smsChannel(payload) : { ...values, id: values.id || entityId('SMS'), enabled: values.status === '正常', used: 0 }; const index = data.smsChannels.findIndex(row => row.id === item.id); if (index >= 0) data.smsChannels[index] = item; else data.smsChannels.unshift(item); addAudit('sms.channel.upsert', 'sms_channel', item.id, tenantById(item.tenantId)?.name || item.id, '中', '保存短信渠道配置。'); }, success: '短信渠道已保存' });
}
if (form.id === 'paymentAppForm') {
const values = Object.fromEntries(new FormData(form));
const operationId = values.appScope === 'tenant' ? 'PlatformAdminTenantPaymentSettingsController_upsertApp' : 'PlatformAdminPaymentSettingsController_upsertApp';
simulateWrite({ key: 'payment-app', permission: 'platform:payment:write', operationId, body: () => values, validate: () => { const errors = {}; if (values.appScope === 'tenant' && !values.tenantId) errors.tenantId = '租户代管必须选择租户'; if (!values.appName) errors.appName = '应用名称不能为空'; return Object.keys(errors).length ? errors : null; }, apply: payload => { const item = payload ? (values.appScope === 'tenant' ? platformApi.adapters.tenantPaymentApp(payload) : platformApi.adapters.platformPaymentApp(payload)) : { ...values, id: values.id || entityId('PAYAPP'), status: values.status }; const index = data.paymentApps.findIndex(row => row.id === item.id); if (index >= 0) data.paymentApps[index] = item; else data.paymentApps.unshift(item); addAudit('payment.app.upsert', 'payment_app', item.id, item.appName, '高', '保存支付应用配置。'); }, success: '支付应用已保存' });
}
if (form.id === 'staffForm') {
const values = Object.fromEntries(new FormData(form)); const roleIds = new FormData(form).getAll('roleIds');
simulateWrite({ key: 'staff', permission: 'platform:staff:write', operationId: 'PlatformAdminOverviewController_upsertStaff', body: () => ({ ...values, roleIds }), validate: () => { const errors = {}; if (!values.name) errors.name = '姓名不能为空'; if (!values.email) errors.email = '邮箱不能为空'; if (values.phone && !/^1\d{10}$/.test(values.phone)) errors.phone = '请输入 11 位手机号'; if (!roleIds.length) errors.roleIds = '至少选择一个平台角色'; return Object.keys(errors).length ? errors : null; }, apply: payload => { const existing = data.staff.find(item => item.id === values.userId); const assignedRoles = data.platformRoles.filter(role => roleIds.includes(role.id)); const fallback = { id: values.userId || entityId('STAFF'), userId: values.userId || '', name: values.name, email: values.email, phone: values.phone || existing?.phone || '-', roleIds, roleCodes: assignedRoles.map(role => role.code), role: assignedRoles.map(role => role.name).join('、'), permissions: [...new Set(assignedRoles.flatMap(role => role.permissionCodes))], status: existing?.status || '正常', lastActive: existing?.lastActive || '尚未登录' }; const next = payload ? { ...fallback, ...platformApi.adapters.staff(payload), roleIds, roleCodes: fallback.roleCodes, role: fallback.role, permissions: fallback.permissions } : fallback; if (existing) Object.assign(existing, next); else data.staff.unshift(next); addAudit('staff.upsert', 'staff', next.id, next.name, '高', `保存平台员工及 ${assignedRoles.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(); if (['structure', 'manage'].includes(state.bankTab)) loadBankWorkspace(); return; }
const crmTab = event.target.closest('[data-crm-tab]'); if (crmTab) { state.crmTab = crmTab.dataset.crmTab; syncNavigationState(); render(); return; }
const smsTab = event.target.closest('[data-sms-tab]'); if (smsTab) { state.smsTab = smsTab.dataset.smsTab; syncNavigationState(); render(); return; }
const paymentTab = event.target.closest('[data-payment-tab]'); if (paymentTab) { state.paymentTab = paymentTab.dataset.paymentTab; syncNavigationState(); render(); return; }
const saasFinanceTab = event.target.closest('[data-saas-finance-tab]'); if (saasFinanceTab) { state.saasFinanceTab = saasFinanceTab.dataset.saasFinanceTab; syncNavigationState(); render(); return; }
const bankSelect = event.target.closest('[data-bank-select]'); if (bankSelect) { state.selectedBank = bankSelect.dataset.bankSelect; state.selectedBankNode = ''; syncNavigationState(); render(); loadBankWorkspace(); return; }
const bankNodeSelect = event.target.closest('[data-bank-node-select]'); if (bankNodeSelect) { state.selectedBankNode = bankNodeSelect.dataset.bankNodeSelect; 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 === 'new-saas-feature') { openModal('saasFeature'); return; }
if (action === 'new-saas-offering') { openModal('saasOffering'); return; }
if (action === 'new-saas-version') { if (!(data.saasCatalog?.offerings || []).length) { showToast('请先创建 SaaS 商品', 'error'); return; } openModal('saasVersion'); return; }
if (action === 'new-platform-role') { openModal('platformRole'); return; }
if (action === 'edit-platform-role') { openModal('platformRole', { roleId: actionElement.dataset.roleId }); return; }
if (action === 'new-payment-channel') { if (!data.paymentApps.some(item => item.appScope === 'platform')) { showToast('请先创建平台支付应用', 'error'); return; } openModal('paymentChannel'); return; }
if (action === 'edit-payment-channel') { openModal('paymentChannel', { channelId: actionElement.dataset.channelId }); return; }
if (action === 'disable-payment-channel') { const channelId = actionElement.dataset.channelId; if (!platformApi?.isEnabled()) { const channel = data.paymentChannels.find(item => item.id === channelId); if (channel) channel.status = 'disabled'; render(); showToast('支付渠道已停用'); return; } platformApi.request('PlatformAdminPaymentSettingsController_disableChannel', { pathParams: { channelId }, body: {} }).then(payload => { const channel = data.paymentChannels.find(item => item.id === channelId); if (channel) Object.assign(channel, payload); render(); showToast('支付渠道已停用'); }).catch(error => showToast(backendErrorText(error, '停用支付渠道失败'), 'error')); return; }
if (action === 'new-sms-template') { if (!data.smsChannels.length) { showToast('请先创建租户短信渠道', 'error'); return; } openModal('smsTemplate'); return; }
if (action === 'edit-sms-template') { openModal('smsTemplate', { templateId: actionElement.dataset.templateId }); return; }
if (['submit-sms-template', 'disable-sms-template'].includes(action)) { const templateId = actionElement.dataset.templateId; const operationId = action === 'submit-sms-template' ? 'PlatformAdminSmsController_submitTemplateReview' : 'PlatformAdminSmsController_disableTemplate'; if (!platformApi?.isEnabled()) { const template = data.smsTemplates.find(item => item.id === templateId); if (template) { if (action === 'submit-sms-template') template.status = '审核中'; else template.templateStatus = '未启用'; } render(); showToast(action === 'submit-sms-template' ? '短信模板已提交审核' : '短信模板已停用'); return; } platformApi.request(operationId, { pathParams: { templateId }, body: {} }).then(payload => { const template = data.smsTemplates.find(item => item.id === templateId); if (template) Object.assign(template, platformApi.adapters.smsTemplate(payload)); render(); showToast(action === 'submit-sms-template' ? '短信模板已提交审核' : '短信模板已停用'); }).catch(error => showToast(backendErrorText(error, '短信模板操作失败'), 'error')); return; }
if (['publish-saas-version', 'clone-saas-version', 'retire-saas-version'].includes(action)) { const operationId = action === 'publish-saas-version' ? 'PlatformSaasController_publishVersion' : action === 'clone-saas-version' ? 'PlatformSaasController_cloneVersion' : 'PlatformSaasController_retireVersion'; const versionId = actionElement.dataset.versionId; if (!platformApi?.isEnabled()) { const version = data.saasCatalog.versions.find(item => item.id === versionId); if (version) version.status = action === 'retire-saas-version' ? 'retired' : action === 'publish-saas-version' ? 'published' : version.status; render(); showToast('套餐版本状态已更新'); return; } platformApi.request(operationId, { pathParams: { versionId }, body: {} }).then(() => hydrateFromBackend({ silent: true })).then(() => showToast('套餐版本状态已更新')).catch(error => showToast(backendErrorText(error, '套餐版本操作失败'), 'error')); return; }
if (action === 'recheck-domain') { const domain = data.domains.find(item => item.id === actionElement.dataset.domainId); if (!domain) return; if (!platformApi?.isEnabled()) { showToast('真实 API 模式才能重新检查域名', 'error'); return; } platformApi.request('PlatformAdminTenantsController_recheckDomain', { pathParams: { domainId: domain.id }, body: {} }).then(payload => { Object.assign(domain, payload); render(); showToast('域名 DNS/TLS 状态已重新检查'); }).catch(error => showToast(backendErrorText(error, '域名检查失败'), 'error')); 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 => { const items = apiItems(payload); if (items.length) data.invoices = 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 = apiItem(payload); 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 || payload?.traceId || '' }); 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 = apiItem(payload); 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 => { const result = apiItem(payload); openModal('overduePreview'); showToast(`后端 Dry-run${result.processed || result.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 = apiItem(payload); 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(`后端已生成 ${apiItem(payload).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; 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()) { apply(); return; } if (!nextEnabled) { executeBackendMutation('PlatformAdminDunningChannelsController_disableChannel', { channelId: channel.id, reason: '平台管理员停用催缴渠道' }, apply); return; } if (channel.endpointRedacted) { openModal('dunningChannel', { channelId: channel.id }); showToast('重新启用前请填写完整的 HTTPS 通知地址', 'error'); return; } executeBackendMutation('PlatformAdminDunningChannelsController_upsertChannel', { ...channel, enabled: true }, apply); return; }
if (action === 'new-bank') { if (requireDesktop('新建公共题库')) return; openModal('bank'); return; }
if (action === 'edit-bank') { if (requireDesktop('编辑公共题库')) return; openModal('bank', { bankId: state.selectedBank }); return; }
if (action === 'archive-bank') { const bank = bankById(state.selectedBank); if (!bank) return; confirmAction({ title: '归档公共题库', description: bank.name, impact: '仅当题库中的题目和内容节点均已归档时才能归档,历史版本不会删除。', confirmLabel: '确认归档' }, () => executeBackendMutation('PlatformQuestionBanksController_archiveBank', {}, payload => { Object.assign(bank, payload ? platformApi.adapters.bank(payload, bank) : { status: '已归档' }); render(); showToast('公共题库已归档'); }, { refresh: false, pathParams: { bankId: bank.id } })); return; }
if (action === 'new-bank-node') { if (requireDesktop('新建内容节点')) return; openModal('bankNode'); return; }
if (action === 'edit-bank-node') { if (requireDesktop('编辑内容节点')) return; openModal('bankNode', { nodeId: state.selectedBankNode }); return; }
if (action === 'batch-bank-nodes') { if (requireDesktop('批量创建章节或试卷')) return; openModal('batchBankNodes'); return; }
if (action === 'shift-bank-node') { const node = bankNodeById(state.selectedBankNode); if (!node) return; const sortOrder = Number(node.sortOrder || 0) + Number(actionElement.dataset.sortDelta || 0); executeBackendMutation('PlatformQuestionBanksController_upsertNode', { id: node.id, questionBankId: node.questionBankId, parentId: node.parentId || null, nodeKey: node.nodeKey || null, name: node.name, nodeType: node.nodeType, sortOrder, isSelectable: node.isSelectable !== false, metadata: node.metadata || {} }, payload => { Object.assign(node, payload ? adaptBankNode(payload) : { sortOrder }); data.bankNodes.sort((left, right) => Number(left.sortOrder || 0) - Number(right.sortOrder || 0)); render(); showToast('内容节点顺序已保存'); }, { refresh: false }); return; }
if (action === 'apply-bank-search') { runContractRead({ key: 'bank-query', actionId: 'act.p-09.01', operationId: 'PlatformQuestionBanksController_getBanks', title: '公共题库查询', suppliedRequestId: actionElement.dataset.contractRequestId, liveRequest: () => platformApi.request('PlatformQuestionBanksController_getBanks', { query: { keyword: state.bankSearch || undefined, status: 'all' } }), apply: payload => { const items = apiItems(payload); data.banks = items.map(item => platformApi.adapters.bank(item, bankById(item.id))); if (!bankById(state.selectedBank)) state.selectedBank = data.banks[0]?.id || ''; return `${data.banks.length} 个匹配题库`; } }); return; }
if (action === 'load-bank-questions' || action === 'bank-question-page') { if (action === 'bank-question-page') state.bankQuestionPage = Number(actionElement.dataset.page || 1); const query = { questionBankId: state.selectedBank, contentNodeId: state.selectedBankNode || undefined, keyword: document.querySelector('#bankQuestionSearch')?.value || undefined, type: document.querySelector('#bankQuestionType')?.value || undefined, status: document.querySelector('#bankQuestionStatus')?.value || undefined, page: state.bankQuestionPage, pageSize: 20 }; if (!platformApi?.isEnabled()) { render(); return; } platformApi.request('PlatformQuestionBanksController_getQuestions', { query }).then(payload => { data.bankQuestions = apiItems(payload).map(adaptBankQuestion); state.bankQuestionTotal = Number(payload.total || data.bankQuestions.length); render(); }).catch(error => showToast(backendErrorText(error, '题目查询失败'), 'error')); return; }
if (action === 'edit-bank-question') { const question = data.bankQuestions.find(item => item.id === actionElement.dataset.questionId); if (!question) return; state.bankTab = 'single'; state.selectedBankNode = question.contentNodeId; render(); requestAnimationFrame(() => { const form = document.querySelector('#bankQuestionForm'); if (!form) return; form.elements.id.value = question.id; form.elements.contentNodeId.value = question.contentNodeId; form.elements.type.value = question.type; form.elements.difficulty.value = question.difficulty || 3; form.elements.legacyId.value = question.legacyId || ''; form.elements.content.value = question.content || ''; form.elements.options.value = JSON.stringify(question.options || [], null, 2); form.elements.answerText.value = question.answerText || ''; form.elements.explanation.value = question.explanation || ''; form.elements.mediaUrl.value = question.mediaUrl || ''; form.elements.tags.value = Array.isArray(question.tags) ? question.tags.join('、') : ''; }); return; }
if (action === 'archive-bank-question') { const questionId = actionElement.dataset.questionId; const apply = () => { const question = data.bankQuestions.find(item => item.id === questionId); if (question) { question.status = 'archived'; question.statusLabel = '已归档'; } render(); showToast('题目已归档'); }; confirmAction({ title: '归档题目', description: '归档后题目历史版本仍会保留。', impact: '该题目不会再进入公共题库正常查询结果。', confirmLabel: '确认归档' }, () => executeBackendMutation('PlatformQuestionBanksController_archiveQuestions', { questionIds: [questionId] }, apply, { refresh: false })); return; }
if (action === 'archive-bank-node') { const node = bankNodeById(state.selectedBankNode); if (!node) return; confirmAction({ title: '归档内容节点', description: node.name, impact: '仅当节点没有启用的下级节点和未归档题目时才能归档。', confirmLabel: '确认归档' }, () => executeBackendMutation('PlatformQuestionBanksController_archiveNode', {}, () => { node.isActive = false; render(); showToast('内容节点已归档'); }, { refresh: false, pathParams: { nodeId: node.id } })); return; }
if (action === 'upload-question-image') { const input = document.querySelector('#bankQuestionImage'); const file = input?.files?.[0]; if (!file) { showToast('请先选择题目图片', 'error'); return; } if (!platformApi?.isEnabled()) { document.querySelector('#bankQuestionForm').elements.mediaUrl.value = `https://static.example.test/questions/${encodeURIComponent(file.name)}`; showToast('图片地址已填入'); return; } actionElement.disabled = true; platformApi.request('PlatformQuestionBanksController_signAssetUpload', { body: { contentNodeId: state.selectedBankNode || null, fileName: file.name, mimeType: file.type || 'application/octet-stream', fileSizeBytes: file.size, title: file.name, metadata: { usage: 'question' } } }).then(async signed => { await fetch(signed.upload.url, { method: signed.upload.method || 'PUT', headers: signed.upload.headers || {}, body: file }); const confirmed = await platformApi.request('PlatformQuestionBanksController_confirmAssetUpload', { body: { assetId: signed.item.id, mimeType: file.type, fileSizeBytes: file.size } }); document.querySelector('#bankQuestionForm').elements.mediaUrl.value = confirmed.item.cdnUrl || String(signed.upload.url).split('?')[0]; markDirty('page'); showToast('题目图片已上传并填入地址'); }).catch(error => showToast(backendErrorText(error, '题目图片上传失败'), 'error')).finally(() => { actionElement.disabled = false; }); return; }
if (action === 'preview-question') { const form = document.querySelector('#bankQuestionForm'); showToast(form?.elements.content.value?.trim() ? `题目预览:${form.elements.content.value.trim().slice(0, 80)}` : '请先填写题干', form?.elements.content.value?.trim() ? 'success' : 'error'); return; }
if (action === 'new-crm-config' || action === 'edit-crm-config') { if (!canWrite('platform:crm:write')) return; openModal('crmConfig', { crmId: actionElement.dataset.crmId || '' }); return; }
if (action === 'crm-rule-detail') { showToast('CRM 分配策略保存在接入配置的 assignmentConfig 中'); return; }
if (action === 'toggle-crm-config') { if (!canWrite('platform:crm:write')) return; const config = data.crmConfigs.find(item => item.id === actionElement.dataset.crmId); if (!config) return; const nextEnabled = !config.enabled; const apply = payload => { const next = payload ? platformApi.adapters.crmConfig(payload) : { ...config, enabled: nextEnabled, status: nextEnabled ? '正常' : '未启用' }; Object.assign(config, next); addAudit('crm.config.status', 'crm_config', config.id, tenantById(config.tenantId)?.name || config.id, '中', `CRM 接入${config.enabled ? '启用' : '停用'}`); saveData(); render(); showToast('CRM 接入状态已更新'); }; if (platformApi?.isEnabled()) executeBackendMutation('PlatformAdminCrmController_upsertConfig', { ...config, enabled: nextEnabled }, apply); else apply(); return; }
if (action === 'retry-crm-lead') { if (!canWrite('platform:crm:write')) return; const lead = data.crmLeads.find(item => item.id === actionElement.dataset.leadId); if (!lead) return; const apply = payload => { const next = payload ? platformApi.adapters.crmLead(payload) : { ...lead, status: '重试中', attempts: lead.attempts + 1, lastError: '' }; Object.assign(lead, next); addAudit('crm.lead.retry', 'crm_lead', lead.id, lead.student, '中', `CRM 线索 ${lead.id} 已重试推送。`); saveData(); render(); showToast('线索已进入重试队列'); }; if (platformApi?.isEnabled()) executeBackendMutation('PlatformAdminCrmController_retryLead', lead, apply); else apply(); return; }
if (action === 'new-sms-channel' || action === 'edit-sms-channel') { if (!canWrite('platform:sms:write')) return; openModal('smsChannel', { smsId: actionElement.dataset.smsId || '' }); return; }
if (action === 'toggle-sms-channel') { if (!canWrite('platform:sms:write')) return; const channel = data.smsChannels.find(item => item.id === actionElement.dataset.smsId); if (!channel) return; const nextStatus = channel.enabled ? '未启用' : '正常'; const apply = payload => { const next = payload ? platformApi.adapters.smsChannel(payload) : { ...channel, enabled: nextStatus === '正常', status: nextStatus }; Object.assign(channel, next); addAudit('sms.channel.status', 'sms_channel', channel.id, tenantById(channel.tenantId)?.name || channel.id, '中', `短信渠道${channel.enabled ? '启用' : '停用'}`); saveData(); render(); showToast('短信渠道状态已更新'); }; if (!platformApi?.isEnabled()) { apply(); return; } if (channel.enabled) { executeBackendMutation('PlatformAdminSmsController_disableChannel', {}, apply, { refresh: false, pathParams: { channelId: channel.id } }); return; } executeBackendMutation('PlatformAdminSmsController_upsertChannel', { ...channel, status: nextStatus }, apply); return; }
if (action === 'new-payment-app' || action === 'edit-payment-app') { if (!canWrite('platform:payment:write')) return; openModal('paymentApp', { paymentId: actionElement.dataset.paymentId || '' }); return; }
if (action === 'payment-rule-detail') { showToast('支付风控规则用于证书提醒、回调重试和人工确认保护'); return; }
if (action === 'toggle-payment-app') { if (!canWrite('platform:payment:write')) return; const paymentApp = data.paymentApps.find(item => item.id === actionElement.dataset.paymentId); if (!paymentApp) return; const disabling = paymentApp.status !== '未启用'; const nextStatus = disabling ? '未启用' : '正常'; const operationId = paymentApp.appScope === 'tenant' ? 'PlatformAdminTenantPaymentSettingsController_upsertApp' : 'PlatformAdminPaymentSettingsController_upsertApp'; const apply = payload => { const next = payload ? (paymentApp.appScope === 'tenant' ? platformApi.adapters.tenantPaymentApp(payload) : platformApi.adapters.platformPaymentApp(payload)) : { ...paymentApp, status: nextStatus }; Object.assign(paymentApp, next); addAudit('payment.app.status', 'payment_app', paymentApp.id, paymentApp.appName, '高', `支付应用${disabling ? '停用' : '启用'}`); saveData(); render(); showToast('支付应用状态已更新'); }; if (platformApi?.isEnabled()) executeBackendMutation(operationId, { ...paymentApp, status: nextStatus }, apply); else apply(); return; }
if (action === 'new-staff') { if (requireDesktop('新建平台员工与授权')) return; openModal('staff', { roleId: actionElement.dataset.roleId || '' }); 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 === '正常'; const isSuperAdmin = person.roleCodes?.includes('platform_super_admin'); if (disabling && isSuperAdmin && data.staff.filter(item => item.roleCodes?.includes('platform_super_admin') && 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', { userId: person.userId || person.id, status: disabling ? 'disabled' : 'active', reason: 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', 'platform:payment:read', 'platform:payment:write'] : role === '安全审计' ? ['platform:audit:read', 'platform:audit:export', 'platform:audit:alert', 'platform:audit:notification'] : role === '题库运营' ? ['platform:dashboard:view', 'platform:question-bank:manage'] : role === '租户运营' ? ['platform:tenant:read', 'platform:tenant:write', 'platform:tenant:status', 'platform:tenant:billing_profile', 'platform:crm:read', 'platform:crm:write', 'platform:sms:read', 'platform:sms:write'] : 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; }); state.modalData.dirty = true; form.querySelector('[name="name"]')?.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(backendErrorText(error, '审计导出失败'), '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') 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') { const eventId = actionElement.dataset.eventId; const dunningEvent = (data.dunningEvents || []).find(item => item.id === eventId); const notification = dunningEvent || (data.notificationEvents || []).find(item => item.id === eventId); if (!notification) return; if (platformApi?.isEnabled()) { if (!dunningEvent) { showToast('审计通知暂无后端重试接口', 'error'); return; } platformApi.request('PlatformAdminDunningEventsController_retryEvent', { body: { eventId, reason: '平台管理员手工重试' } }).then(payload => { Object.assign(dunningEvent, platformApi.adapters.event(payload, 'dunning')); closeModal({ force: true }); render(); showToast('催缴通知事件已重新标记待发送'); }).catch(error => showToast(backendErrorText(error, '催缴通知重试失败'), 'error')); 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 === 'bankSelector') { state.selectedBank = event.target.value; state.selectedBankNode = ''; state.bankQuestionPage = 1; syncNavigationState(); render(); loadBankWorkspace(); }
if (event.target.id === 'bankStructuredFile') { const file = event.target.files?.[0]; if (!file) return; file.text().then(text => { const target = document.querySelector('#bankStructuredPayload'); if (target) { target.value = text; markDirty('page'); } }).catch(() => showToast('无法读取所选 JSON 文件', 'error')); }
});
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 });
});