forked from xiongyuxing/tiku-backend.net
1753 lines
290 KiB
JavaScript
1753 lines
290 KiB
JavaScript
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 => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' })[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="第一章 函数与极限 第二章 导数与微分"></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 });
|
||
});
|