const mainView = document.querySelector('#mainView'); const sideNav = document.querySelector('#sideNav'); const pageTitle = document.querySelector('#pageTitle'); const toast = document.querySelector('.toast'); const app = document.querySelector('.platform-app'); const overlayRoot = document.querySelector('#overlayRoot'); const roleSelect = document.querySelector('#roleSelect'); const scenarioSelect = document.querySelector('#scenarioSelect'); const responseSelect = document.querySelector('#responseSelect'); const menuButton = document.querySelector('#menuButton'); const taskCount = document.querySelector('#taskCount'); const alertCount = document.querySelector('#alertCount'); const platformApi = window.GongxuePlatformApi; const STORAGE_KEY = 'gongxue-platform-spec-v1.1'; const TENANT_PORTAL_ORIGIN = 'http://127.0.0.1:4174/'; const HISTORY_APP_KEY = 'gongxuePlatform'; const API_CONNECTED_PAGES = new Set(['overview', 'tenants', 'tenantDetail', 'billingProfile', 'staff', 'audit', 'alerts']); const pageMeta = { overview: { id: 'P-01', title: '平台经营工作台', permission: 'platform:overview:read' }, tenants: { id: 'P-02', title: '租户列表与创建', permission: 'platform:tenant:read' }, tenantDetail: { id: 'P-03', title: '租户详情与生命周期', permission: 'platform:tenant:read', level: 2 }, billingProfile: { id: 'P-04', title: '租户账务资料', permission: 'platform:tenant:billing_profile', level: 3 }, subscriptions: { id: 'P-05', title: 'SaaS 套餐与订阅', permission: 'platform:plan:read' }, billing: { id: 'P-06', title: '服务费账单', permission: 'platform:billing:read' }, usage: { id: 'P-07', title: '用量与超额计费', permission: 'platform:usage:read' }, dunning: { id: 'P-08', title: '收款、逾期与催缴', permission: 'platform:billing:read' }, bank: { id: 'P-09', title: '公共题库目录与授权', permission: 'platform:question_bank:read' }, staff: { id: 'P-10', title: '平台员工与权限', permission: 'platform:staff:read' }, audit: { id: 'P-11', title: '平台审计日志', permission: 'platform:audit:read' }, alerts: { id: 'P-12', title: '审计告警与通知', permission: 'platform:audit:alert' }, }; const navGroups = [ ['平台概览', [['overview', 'gauge', '经营工作台']]], ['租户管理', [['tenants', 'building-2', '租户列表']]], ['SaaS 账务', [['subscriptions', 'badge-check', '套餐与订阅'], ['billing', 'receipt-text', '服务费账单'], ['usage', 'chart-column-big', '用量与超额'], ['dunning', 'bell-ring', '收款与催缴']]], ['平台资产', [['bank', 'database-zap', '公共题库']]], ['安全治理', [['staff', 'user-cog', '员工与权限'], ['audit', 'scroll-text', '审计日志'], ['alerts', 'shield-alert', '告警与通知']]], ]; const roleDefinitions = { super: { label: '超级管理员', permissions: ['*'] }, tenant_ops: { label: '租户运营', permissions: ['platform:overview:read', 'platform:tenant:read', 'platform:tenant:write', 'platform:tenant:status', 'platform:tenant:billing_profile', 'platform:plan:read', 'platform:billing:read', 'platform:question_bank:read'] }, finance: { label: '平台财务', permissions: ['platform:overview:read', 'platform:tenant:read', 'platform:tenant:billing_profile', 'platform:plan:read', 'platform:billing:read', 'platform:billing:write', 'platform:billing:payment', 'platform:billing:dunning', 'platform:billing:notification', 'platform:usage:read', 'platform:usage:write', 'platform:audit:read'] }, audit: { label: '安全审计', permissions: ['platform:overview:read', 'platform:audit:read', 'platform:audit:export', 'platform:audit:alert', 'platform:audit:notification'] }, bank_ops: { label: '题库运营', permissions: ['platform:overview:read', 'platform:question_bank:read', 'platform:question_bank:grant', 'platform:question_bank:ops'] }, readonly: { label: '只读观察员', permissions: ['platform:overview:read', 'platform:tenant:read', 'platform:plan:read', 'platform:billing:read', 'platform:question_bank:read', 'platform:audit:read'] }, }; const seed = { version: 11, tenants: [ { id: 'tenant_gongxue_tj', slug: 'gongxue-tj', name: '恭学教育 · 天津', legal: '天津恭学教育科技有限公司', brand: '恭学教育', shortName: '恭学天津', host: 'study.gongxue.cn', plan: '旗舰版', planCode: 'enterprise', status: '正常', billing: '正常', mode: '正式', students: 2846, questions: 42680, storageGB: 186, trafficTB: 1.8, expires: '2027-07-22', balance: 0, owner: '张校长', ownerPhone: '138****6008', createdAt: '2025-07-22 10:18' }, { id: 'tenant_qihang_gd', slug: 'qihang-gd', name: '启航专升本 · 广东', legal: '广州启航教育咨询有限公司', brand: '启航专升本', shortName: '启航广东', host: 'study.qihangzsb.cn', plan: '专业版', planCode: 'pro', status: '正常', billing: '待付款', mode: '正式', students: 5280, questions: 68420, storageGB: 248, trafficTB: 3.2, expires: '2026-08-05', balance: 12800, owner: '林老师', ownerPhone: '139****2288', createdAt: '2025-08-05 09:12' }, { id: 'tenant_bosi_sd', slug: 'bosi-sd', name: '博思升本 · 山东', legal: '山东博思教育集团有限公司', brand: '博思升本', shortName: '博思山东', host: 'tiku.bosiedu.cn', plan: '基础版', planCode: 'basic', status: '试用', billing: '正常', mode: '试用', students: 642, questions: 12860, storageGB: 42, trafficTB: .6, expires: '2026-08-12', balance: 0, owner: '孙老师', ownerPhone: '136****1209', createdAt: '2026-07-12 14:20' }, { id: 'tenant_mingtu_hn', slug: 'mingtu-hn', name: '明途教育 · 河南', legal: '郑州明途教育科技有限公司', brand: '明途教育', shortName: '明途河南', host: 'learn.mingtu.cn', plan: '基础版', planCode: 'basic', status: '暂停', billing: '逾期', mode: '正式', students: 1280, questions: 18620, storageGB: 86, trafficTB: .9, expires: '2026-07-18', balance: 8600, owner: '魏校长', ownerPhone: '135****8806', createdAt: '2025-01-18 11:03' }, { id: 'tenant_xuezhi_heb', slug: 'xuezhi-heb', name: '学知专接本 · 河北', legal: '石家庄学知教育有限公司', brand: '学知专接本', shortName: '学知河北', host: 'app.xuezhizjb.cn', plan: '专业版', planCode: 'pro', status: '正常', billing: '正常', mode: '正式', students: 3160, questions: 39860, storageGB: 164, trafficTB: 2.1, expires: '2027-01-08', balance: 0, owner: '赵老师', ownerPhone: '137****6116', createdAt: '2025-01-08 08:40' }, { id: 'tenant_youcai_js', slug: 'youcai-js', name: '优才专转本 · 江苏', legal: '南京优才教育科技有限公司', brand: '优才专转本', shortName: '优才江苏', host: 'study.youcai.cn', plan: '专业版', planCode: 'pro', status: '待配置', billing: '正常', mode: '试用', students: 0, questions: 0, storageGB: 0, trafficTB: 0, expires: '', balance: 0, owner: '顾老师', ownerPhone: '138****1120', createdAt: '2026-07-22 16:08' }, ], billingProfiles: { tenant_gongxue_tj: { billingName: '天津恭学教育科技有限公司', taxId: '91120101MA07GX2026', contactName: '李会计', phone: '13800138000', email: 'finance@gongxue.cn', billingAddress: '天津市南开区科研西路 12 号', invoiceTitle: '天津恭学教育科技有限公司', type: '增值税专用发票', bankName: '招商银行天津南开支行', bankAccountMasked: '**** **** **** 8826' }, tenant_qihang_gd: { billingName: '广州启航教育咨询有限公司', taxId: '91440101MA5QH2026X', contactName: '黄会计', phone: '13900139000', email: 'billing@qihangzsb.cn', billingAddress: '广州市天河区科韵路 18 号', invoiceTitle: '广州启航教育咨询有限公司', type: '增值税普通发票', bankName: '中国银行广州天河支行', bankAccountMasked: '**** **** **** 6672' }, }, plans: [ { code: 'basic', name: '基础版', description: '适合单地区小型机构', cycle: '年度', amount: 4800, quotas: ['2,000 学生席位', '30,000 题目', '100 GB 存储', '基础内容运营'], tenantCount: 12 }, { code: 'pro', name: '专业版', description: '适合成熟区域型机构', cycle: '年度', amount: 9800, quotas: ['8,000 学生席位', '100,000 题目', '500 GB 存储', 'CRM 与营销能力'], tenantCount: 24 }, { code: 'enterprise', name: '旗舰版', description: '适合多校区与高内容规模', cycle: '年度', amount: 16800, quotas: ['20,000 学生席位', '300,000 题目', '2 TB 存储', '财务对账与高级审计'], tenantCount: 12 }, ], subscriptions: [ { id: 'SUB-202607-001', tenantId: 'tenant_gongxue_tj', planCode: 'enterprise', status: '活跃', cycle: '年度', amount: 16800, start: '2026-07-22', expires: '2027-07-22', autoRenew: true }, { id: 'SUB-202608-002', tenantId: 'tenant_qihang_gd', planCode: 'pro', status: '活跃', cycle: '年度', amount: 9800, start: '2025-08-05', expires: '2026-08-05', autoRenew: true }, { id: 'SUB-TRIAL-003', tenantId: 'tenant_bosi_sd', planCode: 'basic', status: '试用中', cycle: '试用', amount: 0, start: '2026-07-12', expires: '2026-08-12', autoRenew: false }, { id: 'SUB-202501-004', tenantId: 'tenant_mingtu_hn', planCode: 'basic', status: '暂停', cycle: '年度', amount: 4800, start: '2025-01-18', expires: '2026-07-18', autoRenew: false }, { id: 'SUB-202601-005', tenantId: 'tenant_xuezhi_heb', planCode: 'pro', status: '活跃', cycle: '年度', amount: 9800, start: '2026-01-08', expires: '2027-01-08', autoRenew: true }, ], invoices: [ { id: 'INV-202607-0018', tenantId: 'tenant_qihang_gd', type: '订阅服务费', period: '2026年7月', total: 12800, paid: 0, balance: 12800, due: '2026-08-05', status: '待付款', note: '2026年7月服务费' }, { id: 'INV-202607-0017', tenantId: 'tenant_mingtu_hn', type: '订阅服务费', period: '2026年7月', total: 8600, paid: 0, balance: 8600, due: '2026-07-18', status: '已逾期', note: '2026年7月服务费' }, { id: 'INV-202607-0016', tenantId: 'tenant_gongxue_tj', type: '订阅服务费', period: '2026年7月', total: 16800, paid: 16800, balance: 0, due: '2026-07-15', status: '已支付', note: '2026年7月服务费' }, { id: 'INV-202607-0015', tenantId: 'tenant_xuezhi_heb', type: '订阅服务费', period: '2026年7月', total: 9800, paid: 9800, balance: 0, due: '2026-07-15', status: '已支付', note: '2026年7月服务费' }, ], usage: [ { tenantId: 'tenant_gongxue_tj', period: '2026-07', source: 'Worker 自动采集', students: 2846, questions: 42680, storageGB: 186, trafficTB: 1.8, overage: [], estimated: 0 }, { tenantId: 'tenant_qihang_gd', period: '2026-07', source: 'Worker 自动采集', students: 5280, questions: 68420, storageGB: 248, trafficTB: 3.2, overage: ['流量'], estimated: 7820 }, { tenantId: 'tenant_bosi_sd', period: '2026-07', source: 'Worker 自动采集', students: 642, questions: 12860, storageGB: 42, trafficTB: .6, overage: [], estimated: 0 }, { tenantId: 'tenant_mingtu_hn', period: '2026-07', source: '人工补录', students: 1280, questions: 18620, storageGB: 86, trafficTB: .9, overage: ['题目', '存储'], estimated: 4260 }, { tenantId: 'tenant_xuezhi_heb', period: '2026-07', source: 'Worker 自动采集', students: 3160, questions: 39860, storageGB: 164, trafficTB: 2.1, overage: [], estimated: 0 }, ], reminders: [ { id: 'REM-260723-01', invoiceId: 'INV-202607-0017', tenantId: 'tenant_mingtu_hn', level: 3, channel: '邮件 + 短信', status: '已送达', date: '2026-07-23 09:20', message: '服务费账单已逾期 5 天,请尽快处理。' }, { id: 'REM-260722-02', invoiceId: 'INV-202607-0018', tenantId: 'tenant_qihang_gd', level: 1, channel: '邮件', status: '已送达', date: '2026-07-22 15:40', message: '账单将在 13 天后到期。' }, ], dunningChannels: [ { id: 'DUN-EMAIL', code: 'finance_email', name: '财务邮件', provider: 'SMTP', endpoint: 'finance@gongxue.cn', enabled: true, retry: 3 }, { id: 'DUN-WX', code: 'finance_wecom', name: '财务企微群', provider: 'Webhook', endpoint: 'https://qyapi.weixin.qq.com/***', enabled: true, retry: 5 }, ], banks: [ { id: 'QB-TJ-MATH', name: '高等数学公共题库', catalog: '天津专升本 / 高等数学', source: '平台自建', questions: 12860, version: 'v2026.7', status: '稳定版' }, { id: 'QB-EN', name: '公共英语能力库', catalog: '全国 / 公共英语', source: '平台自建', questions: 86640, version: 'v2026.6', status: '稳定版' }, { id: 'QB-SD', name: '山东专升本全科', catalog: '山东专升本', source: '合作内容方', questions: 52360, version: 'v2026.7', status: '稳定版' }, ], grants: [ { id: 'GRANT-001', bankId: 'QB-TJ-MATH', scope: '指定租户', allowedPlanCodes: [], allowedTenantIds: ['tenant_gongxue_tj', 'tenant_qihang_gd'], allowedRegionIds: ['天津'], allowedSubjectIds: ['高等数学'], status: '启用', startsAt: '2026-07-18', expiresAt: '2027-07-18', localEdit: false }, { id: 'GRANT-002', bankId: 'QB-EN', scope: '指定套餐', allowedPlanCodes: ['pro', 'enterprise'], allowedTenantIds: [], allowedRegionIds: [], allowedSubjectIds: ['公共英语'], status: '启用', startsAt: '2026-06-01', expiresAt: '', localEdit: true }, ], bankSync: [ { tenantId: 'tenant_gongxue_tj', bankId: 'QB-TJ-MATH', current: 'v2026.6', target: 'v2026.7', progress: 100, conflicts: 0, status: '已完成' }, { tenantId: 'tenant_qihang_gd', bankId: 'QB-EN', current: 'v2026.5', target: 'v2026.6', progress: 72, conflicts: 3, status: '同步中' }, { tenantId: 'tenant_mingtu_hn', bankId: 'QB-EN', current: 'v2026.3', target: 'v2026.6', progress: 38, conflicts: 12, status: '同步失败' }, ], staff: [ { id: 'STAFF-01', authUserId: 'auth-chenhao', username: 'chenhao', name: '陈浩', email: 'chenhao@gongxue.cn', phone: '138****6600', role: '超级管理员', permissions: ['*'], status: '正常', lastActive: '刚刚' }, { id: 'STAFF-02', authUserId: 'auth-zhoumin', username: 'zhoumin', name: '周敏', email: 'zhoumin@gongxue.cn', phone: '139****2188', role: '题库运营', permissions: ['platform:question_bank:read'], status: '正常', lastActive: '8分钟前' }, { id: 'STAFF-03', authUserId: 'auth-liujia', username: 'liujia', name: '刘佳', email: 'liujia@gongxue.cn', phone: '136****9028', role: '平台财务', permissions: ['platform:billing:read', 'platform:billing:write', 'platform:billing:payment', 'platform:usage:read', 'platform:usage:write'], status: '正常', lastActive: '21分钟前' }, { id: 'STAFF-04', authUserId: 'auth-wanglei', username: 'wanglei', name: '王磊', email: 'wanglei@gongxue.cn', phone: '135****2086', role: '租户运营', permissions: ['platform:tenant:read', 'platform:tenant:write', 'platform:tenant:status'], status: '正常', lastActive: '1小时前' }, { id: 'STAFF-05', authUserId: 'auth-sunchen', username: 'sunchen', name: '孙晨', email: 'sunchen@gongxue.cn', phone: '137****1118', role: '安全审计', permissions: ['platform:audit:read', 'platform:audit:alert'], status: '已停用', lastActive: '3天前' }, ], auditLogs: [ { id: 'AUD-20260723-094218', time: '2026-07-23 09:42:18 CST', actor: '陈浩', role: '平台管理员', action: 'tenant.status.update', targetType: 'tenant', targetId: 'tenant_mingtu_hn', target: '明途教育 · 河南', severity: '高', result: '成功', ip: '172.16.4.28', details: '将业务状态由正常更新为暂停,原因:账单逾期。' }, { id: 'AUD-20260723-093602', time: '2026-07-23 09:36:02 CST', actor: 'system-billing', role: '系统任务', action: 'invoice.batch.create', targetType: 'invoice_batch', targetId: 'TASK-260723-001', target: '2026年7月账单', severity: '中', result: '成功', ip: 'internal', details: '生成 48 张账单,跳过 2 条重复候选。' }, { id: 'AUD-20260723-085844', time: '2026-07-23 08:58:44 CST', actor: '周敏', role: '题库运营', action: 'bank.grant.update', targetType: 'grant', targetId: 'GRANT-001', target: '高数公共题库 v2026.7', severity: '中', result: '成功', ip: '172.16.3.16', details: '授权范围更新为 2 家指定租户。' }, { id: 'AUD-20260723-084127', time: '2026-07-23 08:41:27 CST', actor: '刘佳', role: '平台财务', action: 'invoice.payment.confirm', targetType: 'invoice', targetId: 'INV-202607-0016', target: 'INV-202607-0016', severity: '高', result: '成功', ip: '172.16.8.12', details: '人工确认收款 ¥16,800,流水号已脱敏。' }, { id: 'AUD-20260723-081209', time: '2026-07-23 08:12:09 CST', actor: 'unknown', role: '-', action: 'auth.login.failed', targetType: 'application', targetId: 'platform-console', target: 'platform-console', severity: '严重', result: '失败', ip: '45.62.18.9', details: '5 分钟内连续登录失败 12 次。' }, { id: 'AUD-20260720-163510', time: '2026-07-20 16:35:10 CST', actor: '周敏', role: '题库运营', action: 'bank.catalog.read', targetType: 'question_bank', targetId: 'QB-SD', target: '山东专升本全科', severity: '中', result: '成功', ip: '172.16.3.16', details: '查看公共题库版本与租户采用状态。' }, ], alerts: [ { id: 'ALT-20260723-008', title: '异常登录尝试', summary: '5 分钟内 12 次平台登录失败', severity: '严重', status: 'open', owner: '安全审计', targetType: 'application', targetId: 'platform-console', evidence: ['来源 IP 45.62.18.9', '账号枚举行为 12 次', '命中规则 LOGIN_FAILURE_BURST'], updatedAt: '2分钟前', note: '' }, { id: 'ALT-20260723-006', title: '租户账单逾期', summary: '明途教育服务费账单逾期 5 天', severity: '高', status: 'acknowledged', owner: '平台财务', targetType: 'invoice', targetId: 'INV-202607-0017', evidence: ['余额 ¥8,600', '到期日 2026-07-18', '已发送 3 次催缴'], updatedAt: '18分钟前', note: '财务已联系租户。' }, { id: 'ALT-20260723-004', title: '公共题库同步失败', summary: '河南租户同步发生 12 个内容冲突', severity: '中', status: 'open', owner: '题库运营', targetType: 'question_bank', targetId: 'QB-EN', evidence: ['当前版本 v2026.3', '目标版本 v2026.6', '冲突 12 条'], updatedAt: '42分钟前', note: '' }, { id: 'ALT-20260722-019', title: '存储用量告警', summary: '启航专升本存储用量达到套餐 92%', severity: '中', status: 'open', owner: '租户运营', targetType: 'tenant', targetId: 'tenant_qihang_gd', evidence: ['当前 460 GB', '套餐上限 500 GB'], updatedAt: '昨天', note: '' }, { id: 'ALT-20260722-011', title: 'Provider 密钥即将过期', summary: '优才专转本支付 Provider 密钥 7 天后到期', severity: '高', status: 'resolved', owner: '平台运维', targetType: 'tenant', targetId: 'tenant_youcai_js', evidence: ['密钥只写不可回显', '新密钥已于昨天更新'], updatedAt: '昨天', note: '完成轮换。' }, ], alertRules: [ { id: 'RULE-LOGIN-BURST', code: 'LOGIN_FAILURE_BURST', name: '平台登录失败突增', severity: '严重', enabled: true, window: '5 分钟', threshold: '≥ 10 次', target: '平台账号与来源 IP' }, { id: 'RULE-INVOICE-OVERDUE', code: 'INVOICE_OVERDUE', name: '服务费账单逾期', severity: '高', enabled: true, window: '每日 09:00', threshold: '逾期 ≥ 1 天', target: '未结清平台账单' }, { id: 'RULE-BANK-SYNC', code: 'QUESTION_BANK_SYNC_FAILED', name: '公共题库同步失败', severity: '中', enabled: true, window: '任务结束时', threshold: '失败或冲突 ≥ 10', target: '租户题库同步任务' }, ], auditChannels: [ { id: 'CH-SEC-MAIL', code: 'security_email', name: '平台安全邮箱', provider: '邮件', endpoint: 'security@gongxue.cn', minSeverity: '中', statusFilter: 'open,acknowledged', timeout: 8, maxAttempts: 3, enabled: true, lastStatus: '正常' }, { id: 'CH-SEC-WX', code: 'security_wecom', name: '财务告警群', provider: 'Webhook', endpoint: 'Webhook · 企业微信', minSeverity: '高', statusFilter: 'open', timeout: 5, maxAttempts: 5, enabled: true, lastStatus: '正常' }, { id: 'CH-DUTY-SMS', code: 'duty_sms', name: '值班手机', provider: '短信', endpoint: '138****6008', minSeverity: '严重', statusFilter: 'open', timeout: 10, maxAttempts: 3, enabled: true, lastStatus: '失败1次' }, ], notificationEvents: [ { id: 'EVT-260723-001', channelId: 'CH-DUTY-SMS', subject: '异常登录尝试', status: '失败', attempts: 2, updatedAt: '2026-07-23 09:44 CST', error: '运营商返回频率限制' }, { id: 'EVT-260723-002', channelId: 'CH-SEC-MAIL', subject: '异常登录尝试', status: '成功', attempts: 1, updatedAt: '2026-07-23 09:43 CST', error: '' }, ], dunningEvents: [], tasks: [], }; function clone(value) { return JSON.parse(JSON.stringify(value)); } function normalizeData(saved) { const normalized = clone(seed); if (!saved || typeof saved !== 'object') return normalized; Object.keys(normalized).forEach(key => { if (key === 'version') return; if (Array.isArray(normalized[key])) { if (Array.isArray(saved[key])) normalized[key] = saved[key]; return; } if (normalized[key] && typeof normalized[key] === 'object') { if (saved[key] && typeof saved[key] === 'object' && !Array.isArray(saved[key])) normalized[key] = { ...normalized[key], ...saved[key] }; return; } if (saved[key] !== undefined) normalized[key] = saved[key]; }); normalized.usage = normalized.usage.map(item => ({ overage: [], estimated: 0, source: 'Worker 自动采集', ...item })); normalized.grants = normalized.grants.map(item => ({ allowedPlanCodes: [], allowedTenantIds: [], allowedRegionIds: [], allowedSubjectIds: [], localEdit: false, ...item })); normalized.staff = normalized.staff.map(item => ({ permissions: [], status: '正常', lastActive: '尚未登录', ...item })); normalized.alerts = normalized.alerts.map(item => ({ evidence: [], note: '', status: 'open', ...item })); normalized.tasks = normalized.tasks.map(item => ({ status: 'failed', error: '', resultTarget: '任务中心', updatedAt: item.createdAt || todayTime(), ...item })); normalized.version = seed.version; return normalized; } function loadData() { try { const saved = JSON.parse(localStorage.getItem(STORAGE_KEY)); if (saved?.version === seed.version) { const normalized = normalizeData(saved); normalized.tasks.forEach(task => { if (['pending', 'processing'].includes(task.status)) { task.status = 'failed'; task.error = '页面重载中断了本次 Mock 执行,可从任务中心重试。'; task.updatedAt = todayTime(); } }); return normalized; } } catch (error) { console.warn('Mock state restore failed', error); } return clone(seed); } let data = loadData(); if (platformApi?.isEnabled()) { [ 'tenants', 'plans', 'subscriptions', 'invoices', 'usage', 'reminders', 'dunningChannels', 'dunningEvents', 'banks', 'grants', 'bankSync', 'staff', 'auditLogs', 'alerts', 'alertRules', 'auditChannels', 'notificationEvents', 'tasks', ].forEach(key => { data[key] = []; }); data.billingProfiles = {}; } const state = { view: 'overview', previousView: '', selectedTenant: data.tenants[0]?.id || '', tenantFilter: 'all', tenantPlan: 'all', tenantSearch: '', globalSearch: '', billingTab: 'invoices', bankTab: 'catalog', bankIncludeTenant: false, selectedBank: data.banks[0]?.id || '', invoiceSearch: '', invoiceStatus: 'all', bankSearch: '', staffSearch: '', usagePeriod: '2026-07', auditRange: '24h', auditSeverity: 'all', auditSearch: '', alertStatus: 'all', selectedAlert: '', selectedInvoice: '', role: 'super', scenario: 'normal', response: 'success', modal: '', modalData: {}, submitting: '', reading: '', contractResults: {}, history: [], apiConnection: platformApi?.isEnabled() ? 'connecting' : 'mock', apiLoadedAt: '', apiLoadedOperations: 0, apiFailedOperations: 0, }; let pendingLeave = null; let historySequence = Number(history.state?.sequence) || 0; let restoringHistory = false; let bypassHistoryGuard = false; const dirtyScopes = new Set(); const loadedTenantDetails = new Set(); let storageWarningShown = false; function saveData() { if (platformApi?.isEnabled()) return true; try { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); return true; } catch (error) { console.warn('Mock state persistence failed', error); if (!storageWarningShown) { storageWarningShown = true; showToast('浏览器存储不可用,本次修改仅在当前页面保留', 'error'); } return false; } } function backendStatusLabel() { if (!platformApi?.isEnabled()) return '静态演示数据'; if (state.apiConnection === 'connecting') return '正在连接后端'; if (state.apiConnection === 'connected') { const partial = state.apiFailedOperations ? ` · ${state.apiFailedOperations} 项无权限/失败` : ''; return `后端已连接${partial}`; } return '后端不可用 · 已保留静态演示'; } function updateBackendStatus() { const status = document.querySelector('.top-status > span'); if (!status) return; status.innerHTML = `${escapeHtml(backendStatusLabel())}`; status.title = platformApi?.isEnabled() ? `${platformApi.publicConfig().apiBaseUrl || '未配置 API 地址'}${state.apiLoadedAt ? ` · ${state.apiLoadedAt}` : ''}` : 'runtime-config.js 当前为 mock 模式'; status.classList.toggle('is-degraded', state.apiConnection === 'degraded'); } function applyBackendPatch(patch) { Object.entries(patch).forEach(([key, value]) => { data[key] = value; }); if (patch.tenants) { const tenantIds = new Set(data.tenants.map(tenant => tenant.id)); if (!tenantIds.has(state.selectedTenant)) state.selectedTenant = data.tenants[0]?.id || ''; } if (patch.banks && !data.banks.some(bank => bank.id === state.selectedBank)) state.selectedBank = data.banks[0]?.id || ''; if (patch.usage) { const latestUsage = new Map(); data.usage.forEach(item => { const current = latestUsage.get(item.tenantId); if (!current || item.period > current.period) latestUsage.set(item.tenantId, item); }); data.tenants.forEach(tenant => { const usage = latestUsage.get(tenant.id); if (!usage) return; tenant.students = usage.students; tenant.questions = usage.questions; tenant.storageGB = usage.storageGB; tenant.trafficTB = usage.trafficTB; }); } if (patch.plans || patch.tenants) { data.plans.forEach(plan => { plan.tenantCount = data.tenants.filter(tenant => tenant.planCode === plan.code).length; }); } } async function hydrateFromBackend({ silent = false } = {}) { if (!platformApi?.isEnabled()) { state.apiConnection = 'mock'; updateBackendStatus(); return false; } state.apiConnection = 'connecting'; updateBackendStatus(); try { const result = await platformApi.bootstrap({ includeTenantBanks: state.bankIncludeTenant, period: state.usagePeriod, }); const successful = Object.keys(result.payloads).length; if (!successful) throw Object.values(result.errors)[0] || new Error('没有平台接口成功返回'); applyBackendPatch(platformApi.adaptBootstrap(result.payloads, data)); state.apiConnection = 'connected'; state.apiLoadedAt = todayTime(); state.apiLoadedOperations = successful; state.apiFailedOperations = Object.keys(result.errors).length; render(); if (!silent) showToast(`后端数据已刷新 · ${successful}/${result.requested} 项成功`); return true; } catch (error) { state.apiConnection = 'degraded'; state.apiFailedOperations = 1; render(); if (!silent) showToast(`${error.message || '后端连接失败'} · 已保留静态演示`, 'error'); return false; } } async function hydrateTenantDetail(tenantId, { force = false } = {}) { if (!platformApi?.isEnabled() || !tenantId || (!force && loadedTenantDetails.has(tenantId))) return; try { const detail = await platformApi.tenantDetail(tenantId); const tenant = tenantById(tenantId); if (tenant && detail.tenant) { tenant.legal = detail.tenant.legalName || tenant.legal; tenant.brand = detail.tenant.brandName || tenant.brand; tenant.shortName = detail.tenant.shortName || tenant.shortName; tenant.createdAt = detail.tenant.createdAt ? String(detail.tenant.createdAt).slice(0, 10) : tenant.createdAt; tenant.host = detail.domains.find(domain => domain.isPrimary)?.host || detail.domains[0]?.host || tenant.host; } data.subscriptions = [ ...data.subscriptions.filter(item => item.tenantId !== tenantId), ...detail.subscriptions, ]; if (detail.billingProfile) data.billingProfiles[tenantId] = detail.billingProfile; loadedTenantDetails.add(tenantId); render(); } catch (error) { showToast(`${error.message || '租户详情加载失败'}${error.requestId ? ` · ${error.requestId}` : ''}`, 'error'); } } function icon(name) { return ``; } function refreshIcons() { if (window.lucide) window.lucide.createIcons({ attrs: { 'stroke-width': 1.8 } }); } function formatNumber(value) { return Number(value || 0).toLocaleString('zh-CN'); } function money(value) { return `¥${Number(value || 0).toLocaleString('zh-CN')}`; } function todayTime() { return new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', dateStyle: 'short', timeStyle: 'medium' }).format(new Date()).replaceAll('/', '-'); } function requestId() { return `req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`; } function entityId(prefix) { return `${prefix}-${Date.now().toString(36).toUpperCase()}`; } function tenantById(id) { return data.tenants.find(item => item.id === id); } function planByCode(code) { return data.plans.find(item => item.code === code); } function invoiceById(id) { return data.invoices.find(item => item.id === id); } function bankById(id) { return data.banks.find(item => item.id === id); } function notificationChannelName(id) { return [...data.dunningChannels, ...data.auditChannels].find(channel => channel.id === id)?.name || id; } function setAccessibleControlName(element, label) { if (!element || !label) return; element.setAttribute('aria-label', label); element.setAttribute('title', label); } function recordLabel(name, id) { return [name, id].filter(Boolean).join(' · '); } function rowActionAccessibleName(control) { const tenantId = control.dataset.tenantDetail || control.dataset.tenantId; const tenant = tenantById(tenantId); if (control.dataset.tenantDetail) return `查看租户 ${recordLabel(tenant?.name, tenantId)} 详情`; if (control.dataset.invoiceDetail) { const invoice = invoiceById(control.dataset.invoiceDetail); return `查看账单 ${recordLabel(invoice?.id, tenantById(invoice?.tenantId)?.name)} 详情`; } if (control.dataset.notificationDetail) { const event = [...(data.notificationEvents || []), ...(data.dunningEvents || [])].find(item => item.id === control.dataset.notificationDetail); return `查看通知事件 ${recordLabel(event?.subject, event?.id || control.dataset.notificationDetail)} 详情`; } if (control.dataset.auditDetail) { const log = data.auditLogs.find(item => item.id === control.dataset.auditDetail); return `查看审计日志 ${recordLabel(log?.id || control.dataset.auditDetail, log?.target)} 详情`; } const action = control.dataset.action; if (action === 'prepare-tenant-status') return `${tenant?.status === '暂停' ? '恢复' : '暂停'}租户 ${recordLabel(tenant?.name, tenantId)}`; if (action === 'tenant-to-billing') return `查看租户 ${recordLabel(tenant?.name, tenantId)} 账务`; if (action === 'confirm-payment') { const invoice = invoiceById(control.dataset.invoiceId); return `确认账单 ${recordLabel(invoice?.id || control.dataset.invoiceId, tenantById(invoice?.tenantId)?.name)} 收款`; } if (action === 'edit-dunning-channel') { const channel = data.dunningChannels.find(item => item.id === control.dataset.channelId); return `编辑催缴渠道 ${recordLabel(channel?.name, channel?.id || control.dataset.channelId)}`; } if (action === 'edit-grant') { const grant = data.grants.find(item => item.id === control.dataset.grantId); return `编辑公共题库授权 ${recordLabel(bankById(grant?.bankId)?.name, grant?.id || control.dataset.grantId)}`; } if (action === 'sync-detail') { const [syncTenantId, bankId] = String(control.dataset.syncKey || '').split(':'); return `查看 ${recordLabel(tenantById(syncTenantId)?.name, syncTenantId)} 与 ${recordLabel(bankById(bankId)?.name, bankId)} 的同步详情`; } if (action === 'edit-staff') { const person = data.staff.find(item => item.id === control.dataset.staffId); return `编辑员工 ${recordLabel(person?.name, person?.id || control.dataset.staffId)}`; } if (action === 'edit-audit-channel') { const channel = data.auditChannels.find(item => item.id === control.dataset.channelId); return `编辑审计通知渠道 ${recordLabel(channel?.name, channel?.id || control.dataset.channelId)}`; } return control.title || control.textContent.trim(); } function switchAccessibleName(control) { const enabled = control.classList.contains('is-on'); const action = control.dataset.action; if (action === 'toggle-auto-renew') { const subscription = data.subscriptions.find(item => item.id === control.dataset.subscriptionId); const tenant = tenantById(subscription?.tenantId); return `${enabled ? '关闭' : '开启'}${tenant?.name || '该租户'}订阅 ${subscription?.id || control.dataset.subscriptionId} 自动续费`; } if (action === 'toggle-dunning-channel') { const channel = data.dunningChannels.find(item => item.id === control.dataset.channelId); return `${enabled ? '停用' : '启用'}催缴渠道 ${recordLabel(channel?.name, channel?.id || control.dataset.channelId)}`; } if (action === 'toggle-grant') { const grant = data.grants.find(item => item.id === control.dataset.grantId); return `${enabled ? '禁用' : '启用'}公共题库授权 ${recordLabel(bankById(grant?.bankId)?.name, grant?.id || control.dataset.grantId)}`; } if (action === 'toggle-staff') { const person = data.staff.find(item => item.id === control.dataset.staffId); return `${enabled ? '停用员工' : '启用员工'} ${recordLabel(person?.name, person?.id || control.dataset.staffId)}${enabled ? '并撤销会话' : ''}`; } if (action === 'toggle-audit-channel') { const channel = data.auditChannels.find(item => item.id === control.dataset.channelId); return `${enabled ? '停用' : '启用'}审计通知渠道 ${recordLabel(channel?.name, channel?.id || control.dataset.channelId)}`; } return enabled ? '关闭此设置' : '开启此设置'; } function updateMenuButtonAccessibility() { const expanded = app.classList.contains('menu-open'); setAccessibleControlName(menuButton, expanded ? '关闭平台导航菜单' : '打开平台导航菜单'); menuButton.setAttribute('aria-expanded', String(expanded)); menuButton.setAttribute('aria-controls', 'sideNav'); } function decorateAccessibleControls(root) { const selectLabels = { tenantPlanFilter: '租户套餐筛选', invoiceStatusFilter: '账单状态筛选', roleSelect: '演示角色', scenarioSelect: '页面状态演练', responseSelect: '下一次 Mock 写请求结果', }; Object.entries(selectLabels).forEach(([id, label]) => setAccessibleControlName(document.querySelector(`#${id}`), label)); root.querySelectorAll('.row-action').forEach(control => setAccessibleControlName(control, rowActionAccessibleName(control))); root.querySelectorAll('.switch').forEach(control => { setAccessibleControlName(control, switchAccessibleName(control)); control.setAttribute('role', 'switch'); control.setAttribute('aria-checked', String(control.classList.contains('is-on'))); }); document.querySelectorAll('.icon-button').forEach(control => { const fallback = control.title || control.getAttribute('aria-label'); if (fallback) setAccessibleControlName(control, fallback); }); updateMenuButtonAccessibility(); } function hasPermission(permission) { if (platformApi?.isEnabled() && data.apiPermissions?.effective && Object.prototype.hasOwnProperty.call(data.apiPermissions.effective, permission)) { return data.apiPermissions.effective[permission] === true; } const grants = roleDefinitions[state.role]?.permissions || []; if (grants.includes('*') || grants.includes(permission)) return true; return grants.some(grant => grant.endsWith('*') && permission.startsWith(grant.slice(0, -1))); } function tone(text) { const value = String(text || ''); if (/正常|活跃|已支付|resolved|已解决|已授权|成功|已完成|启用|已送达/.test(value)) return 'success'; if (/逾期|暂停|严重|失败|阻断|已停用|同步失败/.test(value)) return 'danger'; if (/待|试用|警告|即将|acknowledged|已确认|同步中|执行中|开放|open/.test(value)) return 'warning'; return 'neutral'; } function alertStatusLabel(status) { return ({ open: '开放', acknowledged: '已确认', resolved: '已解决', ignored: '已忽略' })[status] || status; } function taskStatusLabel(status) { return ({ pending: '待处理', processing: '执行中', completed: '已完成', failed: '失败' })[status] || status; } function escapeHtml(value) { return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' })[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 `
${icon(glyph)}${escapeHtml(result.title)}${escapeHtml(result.detail)} · Request ID:${escapeHtml(result.requestId)}
`; } function runContractRead({ key, actionId, operationId, title, apply, suppliedRequestId = '', liveRequest }) { if (state.reading) return; const id = suppliedRequestId || requestId(); state.reading = key; state.contractResults[key] = { status: 'loading', title: `${title}处理中`, detail: operationId, requestId: id }; render(); if (platformApi?.isEnabled()) { Promise.resolve(typeof liveRequest === 'function' ? liveRequest() : platformApi.request(operationId)) .then(payload => { state.reading = ''; const requestIdValue = payload?.meta?.requestId || id; const detail = apply?.(payload) || '查询结果已刷新'; state.contractResults[key] = { status: 'success', title: `${title}完成`, detail, requestId: requestIdValue }; if (actionId) window.recordPlatformSpecResult?.(actionId, { result: 'success', operationId, requestId: requestIdValue }); render(); }) .catch(error => { state.reading = ''; const requestIdValue = error.requestId || id; state.contractResults[key] = { status: 'error', title: `${title}失败`, detail: error.message || '平台接口请求失败', requestId: requestIdValue }; if (actionId) window.recordPlatformSpecResult?.(actionId, { result: 'failed', operationId, requestId: requestIdValue }); render(); }); return; } const response = nextResponse(); setTimeout(() => { state.reading = ''; if (response !== 'success') { const detail = response === 'validation_error' ? '服务端拒绝查询参数,已保留当前筛选' : 'Mock 查询失败,已保留当前筛选'; state.contractResults[key] = { status: 'error', title: `${title}失败`, detail, requestId: id }; addAudit(`${key}.failed`, 'mock_request', id, operationId, response === 'validation_error' ? '中' : '高', `${detail}。Request ID:${id}。`, '失败'); if (actionId) window.recordPlatformSpecResult?.(actionId, { result: 'failed', operationId, requestId: id }); render(); return; } const detail = apply?.() || '查询结果已刷新'; state.contractResults[key] = { status: 'success', title: `${title}完成`, detail, requestId: id }; if (actionId) window.recordPlatformSpecResult?.(actionId, { result: 'success', operationId, requestId: id }); render(); }, 520); } function addContractTask({ actionId, operationId, label, resultTarget, execute, suppliedRequestId = '' }) { const response = nextResponse(); const id = suppliedRequestId || requestId(); const task = addTask(label, resultTarget, taskItem => { if (response !== 'success') { const message = `${response === 'validation_error' ? '服务端字段校验失败' : 'Mock 异步请求失败'} · Request ID:${id}`; addAudit(`${actionId}.failed`, 'mock_request', id, operationId, '高', message, '失败'); window.recordPlatformSpecResult?.(actionId, { result: 'failed', operationId, requestId: id, taskId: taskItem.id }); throw new Error(message); } execute?.(taskItem, id); window.recordPlatformSpecResult?.(actionId, { result: 'success', operationId, requestId: id, taskId: taskItem.id }); }); task.requestId = id; task.operationId = operationId; saveData(); return task; } function canWrite(permission) { return hasPermission(permission) && state.role !== 'readonly'; } function isCompactAdmin() { return window.matchMedia('(max-width: 767px)').matches; } function requireDesktop(actionLabel) { if (!isCompactAdmin()) return false; openModal('mobileBoundary', { actionLabel }); return true; } function closeMobileMenu() { app.classList.remove('menu-open'); document.body.classList.remove('menu-locked'); updateMenuButtonAccessibility(); } function toggleMobileMenu() { const opening = !app.classList.contains('menu-open'); app.classList.toggle('menu-open', opening); document.body.classList.toggle('menu-locked', opening); updateMenuButtonAccessibility(); } function setupPrototypeControls() { roleSelect.innerHTML = Object.entries(roleDefinitions).map(([value, role]) => ``).join(''); scenarioSelect.innerHTML = [['normal', '正常'], ['loading', '加载中'], ['empty', '空状态'], ['partial_error', '局部失败'], ['error', '加载失败'], ['forbidden', '403 无权限'], ['session_invalid', '401 会话失效'], ['submitting', '提交中'], ['success', '成功结果']].map(([value, label]) => ``).join(''); responseSelect.innerHTML = [['success', '下一次成功'], ['server_error', '下一次服务端失败'], ['validation_error', '下一次字段失败']].map(([value, label]) => ``).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 `

${group}

${visible.map(([id, glyph, label]) => ``).join('')}
`; }).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 ? `
` : ''; return `
${nav}${pageMeta[state.view]?.id || ''}${level > 1 ? ` · L${level}` : ''}

${title}

${description}

${actions}
`; } function metric(label, value, note, glyph, style = 'blue') { return `
${icon(glyph)}
${label}${value}

${note}

`; } function statusBadge(value) { return `${escapeHtml(value)}`; } function emptyBlock(title, description, action = '') { return `
${icon('inbox')}

${title}

${description}

${action}
`; } function requestErrorBlock(partial = false) { const id = requestId(); return `
${icon(partial ? 'cloud-off' : 'triangle-alert')}

${partial ? '部分数据暂时不可用' : '页面加载失败'}

${partial ? '主列表保留成功数据,右侧聚合区加载失败。' : 'Mock 服务返回错误,未泄露内部堆栈。'} Request ID:${id}

`; } function renderScenario(content) { if (state.scenario === 'session_invalid') return `
${pageHead('会话已失效', '平台会话已过期或被管理员撤销。')}
${icon('log-in')}

401 · 请重新登录

当前 Mock 已清理会话上下文,不展示任何平台业务数据。重新登录后返回安全首页。

`; if (!hasPermission(pageMeta[state.view]?.permission) || state.scenario === 'forbidden') return `
${pageHead('没有访问权限', '当前演示角色无权访问此平台功能。')}
${icon('shield-x')}

403 · 权限不足

需要权限 ${pageMeta[state.view]?.permission}。菜单会按权限隐藏,深链进入则显示此页面。

`; if (state.scenario === 'loading') return `
${pageHead(pageMeta[state.view].title, '正在从 apps/api Mock 加载数据。')}
${Array.from({ length: 6 }, (_, index) => ``).join('')}

超过 8 秒时可使用重试入口。

`; if (state.scenario === 'submitting') return `
${pageHead(pageMeta[state.view].title, '正在提交 Mock 写请求。')}
${icon('loader-circle')}

正在提交

按钮已防重复提交,输入内容会保留;完成后将显示结果摘要与唯一结果编号。

`; if (state.scenario === 'success') return `
${pageHead(pageMeta[state.view].title, '本次 Mock 操作已完成。')}
${icon('circle-check')}

操作成功

结果编号:RESULT-DEMO-20260723 · 已写入审计,可返回当前工作页继续处理。

`; if (state.scenario === 'error') return `
${pageHead(pageMeta[state.view].title, '数据加载未完成。')}${requestErrorBlock()}
`; if (state.scenario === 'empty') return `
${pageHead(pageMeta[state.view].title, '当前演示数据集为空。')}${emptyBlock('当前页面没有数据', '这是只存在于 Mock 原型的空状态演练。恢复“正常”即可继续测试真实交互。', '')}
`; if (state.scenario === 'partial_error') { const closingIndex = content.lastIndexOf(''); return closingIndex >= 0 ? `${content.slice(0, closingIndex)}${requestErrorBlock(true)}${content.slice(closingIndex)}` : `${content}${requestErrorBlock(true)}`; } return content; } function renderOverview() { if (platformApi?.isEnabled()) return renderApiOverview(); const active = data.tenants.filter(tenant => tenant.status === '正常').length; const overdue = data.invoices.filter(invoice => invoice.status === '已逾期').reduce((sum, invoice) => sum + invoice.balance, 0); const openAlerts = data.alerts.filter(alert => alert.status === 'open').slice(0, 4); const totalStudents = data.tenants.reduce((sum, tenant) => sum + tenant.students, 0); const totalQuestions = data.banks.reduce((sum, bank) => sum + bank.questions, 0); return `
${pageHead('平台经营工作台', '查看租户规模、订阅账务、平台用量与安全态势。数据更新时间:2026-07-23 10:10 CST。', ``)}
${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')}

平台收入与租户增长

最近 12 个月 · 不含租户学生端交易流水
MRR有效租户
¥500k¥350k¥200k0
${[42, 48, 52, 58, 64, 71, 78, 82, 88, 94, 101, 108].map((value, index) => `${index + 1}月`).join('')}

需要关注

按业务影响排序
${openAlerts.map(alert => ``).join('') || emptyBlock('没有开放告警', '平台当前没有需要处理的开放告警。')}

租户状态

可进入租户工作列表处理
${data.tenants.length}全部租户
${[['正常', 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]) => `

${label}${count}

`).join('')}

账务结论

先行动,再看明细
待付款${money(data.invoices.filter(i => i.status === '待付款').reduce((s, i) => s + i.balance, 0))}可发送到期提醒
已逾期${money(overdue)}进入催缴处理
预估超额${money(data.usage.reduce((s, u) => s + u.estimated, 0))}需确认生成

最近平台事件

跨租户关键变更
${data.auditLogs.slice(0, 3).map(log => `
${escapeHtml(log.actor.slice(0, 1))}

${escapeHtml(log.actor)} ${escapeHtml(log.details)}${escapeHtml(log.time)} · ${escapeHtml(log.ip)}

`).join('')}
`; } function renderApiOverview() { const overview = data.apiOverview || {}; const openAlerts = data.alerts.filter(alert => alert.status === 'open').slice(0, 4); return `
${pageHead('平台经营工作台', `真实 PostgreSQL 数据 · ${state.apiLoadedAt || '正在加载'}`, ``)}
${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')}

租户状态

来自 /api/platform-admin/tenants
${data.tenants.length}全部租户
${[['正常', 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]) => `

${label}${count}

`).join('')}

开放告警

真实审计告警
${openAlerts.map(alert => ``).join('') || emptyBlock('没有开放告警', '当前数据库没有需要处理的开放告警。')}

最近平台事件

来自真实审计日志
${data.auditLogs.slice(0, 6).map(log => `
${escapeHtml(log.actor.slice(0, 1))}

${escapeHtml(log.actor)} ${escapeHtml(log.details)}${escapeHtml(log.time)} · ${escapeHtml(log.ip)}

`).join('') || '

暂无审计事件

'}
`; } 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 `
${pageHead('租户列表与创建', '搜索租户并管理主体、域名、套餐、业务状态与账务状态。', `${platformApi?.isEnabled() ? '' : ``}${canWrite('platform:tenant:write') ? `` : ''}`)}
${['all', '正常', '试用', '暂停', '待配置', '逾期'].map(value => ``).join('')}
${rows.length ? `
${rows.map(tenant => ``).join('')}
租户套餐模式业务状态账务状态学生 / 题目订阅到期待付余额
${escapeHtml(tenant.plan)}${escapeHtml(tenant.mode)}${statusBadge(tenant.status)}${statusBadge(tenant.billing)}${formatNumber(tenant.students)}${formatNumber(tenant.questions)} 题${tenant.expires || '-'}${money(tenant.balance)}
${canWrite('platform:tenant:status') ? `` : ''}
共 ${rows.length} 家匹配租户筛选、搜索和返回上下文会保留
` : emptyBlock(query || state.tenantFilter !== 'all' || state.tenantPlan !== 'all' ? '没有匹配的租户' : '还没有租户', query || state.tenantFilter !== 'all' || state.tenantPlan !== 'all' ? '调整搜索词或筛选条件后重试。' : '创建第一个租户及首期订阅基础。', canWrite('platform:tenant:write') ? `` : '')}
`; } function renderTenantDetail() { const tenant = tenantById(state.selectedTenant) || data.tenants[0]; if (!tenant) return `
${pageHead('租户详情与生命周期', '租户不存在或已被移除。', '', { level: 2 })}${emptyBlock('租户不存在', '返回租户列表选择其他租户。', '')}
`; 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 `
${pageHead(tenant.name, `${tenant.slug} · ${tenant.legal}`, `${canWrite('platform:tenant:status') ? `` : ''}`, { level: 2 })}
业务状态${statusBadge(tenant.status)}
账务状态${statusBadge(tenant.billing)}
当前套餐${escapeHtml(tenant.plan)}
订阅到期${tenant.expires || '-'}
待付余额${money(tenant.balance)}

主体、负责人和域名

平台只展示链接,不冒充租户登录
主体名称
${escapeHtml(tenant.legal)}
租户 ID
${tenant.id}
负责人
${escapeHtml(tenant.owner)} · ${escapeHtml(tenant.ownerPhone)}
开通时间
${escapeHtml(tenant.createdAt)} CST
学生端域名
${escapeHtml(tenant.host)} ${statusBadge('已验证')}
租户后台域名
admin.${escapeHtml(tenant.host.replace(/^study\.|^learn\.|^app\.|^tiku\./, ''))} ${statusBadge('已验证')}

关联工作区

详情承接上下文,编辑成功回详情

生命周期轨迹

状态变更写入平台审计

租户创建${tenant.createdAt} CST · 主体与初始模式已保存

域名验证${tenant.host} · HTTPS 正常

订阅生效${subscription ? `${subscription.start} 至 ${subscription.expires}` : '待创建订阅'}

账务状态${tenant.billing}${tenant.balance ? ` · 待付 ${money(tenant.balance)}` : ''}

`; } function renderBillingProfile() { const tenant = tenantById(state.selectedTenant) || data.tenants[0]; const profile = data.billingProfiles[tenant.id] || {}; const readOnly = !canWrite('platform:tenant:billing_profile'); return `
${pageHead('租户账务资料', `${tenant.name} · 银行账号仅展示脱敏值,密钥和完整账号不得回显。`, ``, { level: 3 })}

开票与联系人

${readOnly ? '当前角色仅可查看' : '必填字段离开时校验,提交时再次校验'}

发票与银行信息

平台只接受脱敏账户尾号
`; } function renderSubscriptions() { const selectedTenant = state.selectedTenant ? tenantById(state.selectedTenant) : null; const rows = state.scenario === 'empty' ? [] : data.subscriptions; return `
${pageHead('SaaS 套餐与订阅', '维护套餐能力边界,并为租户创建或续订订阅。当前 API 只提供套餐查询,因此不提供套餐编辑。', `${canWrite('platform:billing:write') ? `` : ''}`)}
${data.plans.map((plan, index) => `
${plan.name}

${plan.description}

${plan.tenantCount} 家租户
${money(plan.amount)}/年
    ${plan.quotas.map(item => `
  • ${icon('check')}${item}
  • `).join('')}
`).join('')}
${rows.length ? `

活跃与试用订阅

${rows.length} 条${selectedTenant ? ` · 已定位 ${selectedTenant.name}` : ''}
${rows.map(subscription => { const tenant = tenantById(subscription.tenantId); return ``; }).join('')}
租户订阅号套餐周期开始时间到期时间自动续费状态
${escapeHtml(tenant?.name || '-')}${escapeHtml(tenant?.slug || '-')}${subscription.id}${planByCode(subscription.planCode)?.name || subscription.planCode}${subscription.cycle}${subscription.start}${subscription.expires}${statusBadge(subscription.status)}
` : emptyBlock('还没有订阅', '创建租户订阅后将在此显示。', canWrite('platform:billing:write') ? '' : '')}
`; } 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 `
${pageHead('服务费账单', '查询、手工创建、预览订阅账单候选并通过 dry-run 确认批量生成。', `${canWrite('platform:billing:write') ? `` : ''}`)}
${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')}
${contractResult('invoice-query')} ${rows.length ? `
${rows.map(invoice => { const tenant = tenantById(invoice.tenantId); return ``; }).join('')}
账单号租户类型 / 账期总额已付 / 余额到期日状态
${invoice.type}${invoice.period}${money(invoice.total)}${money(invoice.paid)}余额 ${money(invoice.balance)}${invoice.due}${statusBadge(invoice.status)}
${invoice.balance && canWrite('platform:billing:payment') ? `` : ''}
` : emptyBlock('没有匹配账单', query || state.invoiceStatus !== 'all' ? '调整账单查询条件后重试。' : state.selectedTenant ? '当前租户没有匹配账单,可清除租户上下文查看全部。' : '账单候选生成后会在这里显示。', state.selectedTenant ? '' : '')}
`; } 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 `
${pageHead('用量与超额计费', '按账期查看学生席位、题目、存储与流量用量;区分 Worker 自动采集和人工补录。', `${canWrite('platform:usage:write') ? `` : ''}`)}
${periodLabel}学生席位${formatNumber(periodRows.reduce((s, i) => s + i.students, 0))}合同总量 176,000
题目数量${formatNumber(periodRows.reduce((s, i) => s + i.questions, 0))}合同总量 495,000
对象存储${formatNumber(periodRows.reduce((s, i) => s + i.storageGB, 0))} GB合同总量 13.5 TB
预估超额费${money(periodRows.reduce((s, i) => s + i.estimated, 0))}涉及 ${periodRows.filter(i => i.estimated).length} 家租户
${rows.length ? `
${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 ``; }).join('')}
租户来源 / 周期学生席位题目数量存储 / 流量超额项预估费用
${escapeHtml(tenant?.name || '-')}${escapeHtml(tenant?.plan || '-')}${item.source}${item.period}
${formatNumber(item.students)} / ${formatNumber(seatQuota)}
${formatNumber(item.questions)}
${item.storageGB} GB${item.trafficTB} TB 流量${statusBadge(item.overage.length ? item.overage.join(' + ') : '无')}${money(item.estimated)}
` : emptyBlock('本账期没有用量记录', '可以手工补录,或等待 Worker 自动采集。', canWrite('platform:usage:write') ? '' : '')}
`; } function renderDunning() { const overdueInvoices = data.invoices.filter(invoice => invoice.status === '已逾期' || invoice.status === '待付款'); return `
${pageHead('收款、逾期与催缴', '人工确认收款、dry-run 预览逾期处理、生成催缴并跟踪通知事件。', `${canWrite('platform:billing:dunning') ? `` : ''}${canWrite('platform:billing:notification') ? `` : ''}`)} ${state.billingTab === 'collections' ? `

待收与逾期队列

${overdueInvoices.length} 张账单
${overdueInvoices.map(invoice => { const tenant = tenantById(invoice.tenantId); return `
${icon('building-2')}
${escapeHtml(tenant?.name || '-')}${invoice.id} · 到期 ${invoice.due} · ${invoice.status}
${money(invoice.balance)}
`; }).join('') || emptyBlock('没有待处理账单', '已无待收或逾期服务费账单。')}

全局催缴策略

可由平台任务生成提醒

到期前 7 天邮件 + 租户后台通知

到期当天短信 + 邮件 + 租户后台通知

逾期 3 天升级财务负责人并限制部分写操作

逾期 7 天暂停租户业务,保留只读访问

` : state.billingTab === 'reminders' ? `
${data.reminders.map(item => ``).join('')}
提醒号租户 / 账单级别渠道消息时间状态
${item.id}${escapeHtml(tenantById(item.tenantId)?.name || '-')}${item.invoiceId}L${item.level}${item.channel}${escapeHtml(item.message)}${item.date} CST${statusBadge(item.status)}
` : state.billingTab === 'channels' ? `
${data.dunningChannels.map(channel => ``).join('')}
渠道代码Provider端点重试状态
${escapeHtml(channel.name)}${channel.code}${channel.provider}${escapeHtml(channel.endpoint)}${channel.retry} 次${statusBadge(channel.enabled ? '启用' : '已停用')}
` : `
${(data.dunningEvents || []).map(event => ``).join('')}
事件 ID主题渠道尝试更新时间结果错误
${event.id}${escapeHtml(event.subject)}${escapeHtml(notificationChannelName(event.channelId))}${event.attempts}${event.updatedAt}${statusBadge(event.status)}${escapeHtml(event.error || '-')}
`}
`; } function renderBank() { const selected = bankById(state.selectedBank) || data.banks[0]; const query = state.bankSearch.trim().toLowerCase(); const bankRows = data.banks.filter(bank => !query || [bank.name, bank.catalog, bank.source, bank.version].some(value => String(value).toLowerCase().includes(query))); return `
${pageHead('公共题库目录与授权', '维护公共题库目录、授权规则与租户采用同步状态。授权禁用不会静默删除租户快照。', `${canWrite('platform:question_bank:grant') ? `` : ''}`)}
${contractResult('bank-query')}${contractResult('bank-sync')} ${state.bankTab === 'catalog' ? `
${escapeHtml(selected.catalog)}

${escapeHtml(selected.name)}

当前版本 ${selected.version} · 来源 ${selected.source}

题目${formatNumber(selected.questions)}
授权规则${data.grants.filter(grant => grant.bankId === selected.id).length}
采用租户${data.bankSync.filter(sync => sync.bankId === selected.id).length}
当前状态${selected.status}
${selected.status}

${selected.version}

公共题库内容由平台内容生产链路维护,本页只负责授权和采用状态。

更新时间 2026-07-23 09:00 CST
` : state.bankTab === 'grants' ? `
${data.grants.filter(grant => !query || [grant.id, bankById(grant.bankId)?.name, grant.scope, ...grant.allowedRegionIds, ...grant.allowedSubjectIds].some(value => String(value || '').toLowerCase().includes(query))).map(grant => ``).join('')}
规则题库授权范围套餐 / 租户地区 / 科目有效期本地改编状态
${grant.id}${escapeHtml(bankById(grant.bankId)?.name || '-')}${grant.scope}${grant.allowedPlanCodes.length ? grant.allowedPlanCodes.map(code => planByCode(code)?.name || code).join('、') : grant.allowedTenantIds.map(id => tenantById(id)?.name || id).join('、') || '-'}${[...grant.allowedRegionIds, ...grant.allowedSubjectIds].join(' / ') || '不限'}${grant.startsAt} 至 ${grant.expiresAt || '长期'}${grant.localEdit ? '允许' : '只读采用'}${statusBadge(grant.status)}
` : `

租户采用与同步状态

源版本、任务状态与冲突均来自专属查询
${data.bankSync.filter(sync => !query || [tenantById(sync.tenantId)?.name, bankById(sync.bankId)?.name, sync.current, sync.target, sync.status].some(value => String(value || '').toLowerCase().includes(query))).map(sync => ``).join('')}
租户题库当前版本目标版本同步进度冲突状态
${escapeHtml(tenantById(sync.tenantId)?.name || '-')}${escapeHtml(bankById(sync.bankId)?.name || '-')}${sync.current}${sync.target}
${sync.progress}%
${sync.conflicts}${statusBadge(sync.status)}
`}
`; } 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 `
${pageHead('平台员工与权限', '平台权限独立于租户权限;禁用员工可同步撤销会话,且不能禁用最后一名超级管理员。', `${canWrite('platform:staff:write') ? `` : ''}`)}

平台员工

${staffRows.length} 名匹配 · ${data.staff.filter(item => item.status === '正常').length} 名活跃
${staffRows.map(person => `
${escapeHtml(person.name.slice(0, 1))}
${escapeHtml(person.name)}${escapeHtml(person.email)} · ${escapeHtml(person.phone)}
${escapeHtml(person.role)}${person.permissions.includes('*') ? '全部权限' : `${person.permissions.length} 项权限`}${person.status}
`).join('') || emptyBlock('没有匹配员工', '调整姓名、账号、邮箱、角色或状态关键词。')}

权限目录

遵循最小权限;当前演示角色可在顶部切换
${Object.entries(roleDefinitions).filter(([key]) => key !== 'readonly').map(([key, role]) => `
${icon('shield-check')}
${role.label}${role.permissions.includes('*') ? '全部平台权限' : role.permissions.join(' · ')}
${data.staff.filter(person => person.role === role.label).length}人
`).join('')}
`; } 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 `
${pageHead('平台审计日志', '检索跨租户敏感操作、账务变更与权限事件;导出任务提供文件、hash 和行数。', ``)}
${['all', '严重', '高', '中'].map(value => ``).join('')}
${rows.length ? `
${rows.map(log => ``).join('')}
时间操作者动作目标级别结果来源 IP
${escapeHtml(log.time)}${escapeHtml(log.actor)}${escapeHtml(log.role)}${escapeHtml(log.action)}${escapeHtml(log.target)}${log.severity}${statusBadge(log.result)}${escapeHtml(log.ip)}
` : emptyBlock('没有审计日志', '调整时间范围、严重级别或搜索条件。')}
`; } function renderAlerts() { let alerts = state.alertStatus === 'all' ? data.alerts : data.alerts.filter(alert => alert.status === state.alertStatus); if (state.scenario === 'empty') alerts = []; return `
${pageHead('审计告警与通知', '按 open → acknowledged → resolved/ignored 流转;状态接口禁止重新打开。规则当前只读。', `${canWrite('platform:audit:notification') ? `` : ''}`)}

告警规则

后端只读 · ${data.alertRules.length} 条启用规则
GET /api/platform-admin/audit-alert-rules
${data.alertRules.map(rule => `
${rule.severity}
${escapeHtml(rule.name)}${escapeHtml(rule.code)} · ${escapeHtml(rule.window)} · ${escapeHtml(rule.threshold)}

${escapeHtml(rule.target)}

${statusBadge(rule.enabled ? '启用' : '已停用')}
`).join('')}

告警队列

开放 ${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}
${[['all', '全部'], ['open', '开放'], ['acknowledged', '已确认'], ['resolved', '已解决'], ['ignored', '已忽略']].map(([value, label]) => ``).join('')}
${alerts.length ? alerts.map(alert => `
${alert.severity}
${alert.id} · ${alert.owner}${escapeHtml(alert.title)}

${escapeHtml(alert.summary)}

${alert.updatedAt}
${alertStatusLabel(alert.status)}
${alert.status === 'open' && canWrite('platform:audit:alert') ? `` : ''}${['open', 'acknowledged'].includes(alert.status) && canWrite('platform:audit:alert') ? `` : ''}
`).join('') : emptyBlock('当前筛选没有告警', '告警规则仍保持只读,可切换筛选查看历史状态。')}
`; } const templates = { overview: renderOverview, tenants: renderTenants, tenantDetail: renderTenantDetail, billingProfile: renderBillingProfile, subscriptions: renderSubscriptions, billing: renderBilling, usage: renderUsage, dunning: renderDunning, bank: renderBank, staff: renderStaff, audit: renderAudit, alerts: renderAlerts }; function render() { renderNav(); const meta = pageMeta[state.view] || pageMeta.overview; pageTitle.textContent = `${meta.id} · ${meta.title}`; mainView.innerHTML = renderScenario((templates[state.view] || renderOverview)()); closeMobileMenu(); roleSelect.value = state.role; scenarioSelect.value = state.scenario; responseSelect.value = state.response; updateCounters(); refreshIcons(); window.decoratePlatformSpec?.(mainView, meta.id); decorateAccessibleControls(mainView); updateBackendStatus(); } function renderOverlay() { if (!state.modal) { overlayRoot.innerHTML = ''; return; } const renderer = modalTemplates[state.modal]; overlayRoot.innerHTML = renderer ? renderer() : ''; refreshIcons(); window.decoratePlatformSpec?.(overlayRoot, pageMeta[state.view]?.id || ''); decorateAccessibleControls(overlayRoot); requestAnimationFrame(() => { const modal = overlayRoot.querySelector('.modal'); modal?.querySelector('input:not([type="hidden"]),select,textarea,button')?.focus(); }); } let toastTimer; let lastOverlayTrigger = null; function showToast(message, kind = 'success') { toast.querySelector('span').textContent = message; toast.classList.toggle('is-error', kind === 'error'); toast.classList.add('is-visible'); clearTimeout(toastTimer); toastTimer = setTimeout(() => toast.classList.remove('is-visible'), 2800); } function openModal(name, modalData = {}) { const activeElement = document.activeElement; if (!state.modal && activeElement instanceof HTMLElement && activeElement !== document.body && !overlayRoot.contains(activeElement)) lastOverlayTrigger = activeElement; state.modal = name; state.modalData = { ...modalData, dirty: false }; renderOverlay(); } function markDirty(scope) { dirtyScopes.add(scope); if (scope === 'modal') state.modalData.dirty = true; } function clearDirty(scope = '') { if (scope) dirtyScopes.delete(scope); else dirtyScopes.clear(); if (!scope || scope === 'modal') state.modalData.dirty = false; } function hasUnsavedChanges(scope = '') { return scope ? dirtyScopes.has(scope) || (scope === 'modal' && state.modalData.dirty) : dirtyScopes.size > 0 || Boolean(state.modalData.dirty); } function renderLeaveGuard() { if (!pendingLeave) return; let root = document.querySelector('#unsavedLeaveGuard'); if (!root) { root = document.createElement('div'); root.id = 'unsavedLeaveGuard'; document.body.append(root); } root.className = 'unsaved-leave-guard'; root.innerHTML = ``; refreshIcons(); requestAnimationFrame(() => root.querySelector('[data-leave-choice="stay"]')?.focus()); } function closeLeaveGuard() { document.querySelector('#unsavedLeaveGuard')?.remove(); } function requestLeave(execute, { scope = '' } = {}) { if (!hasUnsavedChanges(scope)) { execute(); return true; } pendingLeave = { execute, scope }; renderLeaveGuard(); return false; } function closeModal(options = {}) { const close = () => { clearDirty('modal'); const trigger = lastOverlayTrigger; state.modal = ''; state.modalData = {}; lastOverlayTrigger = null; renderOverlay(); if (trigger?.isConnected) requestAnimationFrame(() => trigger.focus()); }; if (state.modal && !options.force && hasUnsavedChanges('modal')) return requestLeave(close, { scope: 'modal' }); close(); return true; } function navigationSnapshot() { return { view: state.view, selectedTenant: state.selectedTenant, selectedInvoice: state.selectedInvoice, selectedAlert: state.selectedAlert, tenantFilter: state.tenantFilter, tenantPlan: state.tenantPlan, tenantSearch: state.tenantSearch, invoiceSearch: state.invoiceSearch, invoiceStatus: state.invoiceStatus, billingTab: state.billingTab, bankTab: state.bankTab, bankIncludeTenant: state.bankIncludeTenant, bankSearch: state.bankSearch, selectedBank: state.selectedBank, staffSearch: state.staffSearch, usagePeriod: state.usagePeriod, auditRange: state.auditRange, auditSeverity: state.auditSeverity, auditSearch: state.auditSearch, alertStatus: state.alertStatus, scrollY: window.scrollY }; } function syncNavigationState() { if (history.state?.[HISTORY_APP_KEY]) history.replaceState({ ...history.state, snapshot: navigationSnapshot() }, '', location.href); } function navigate(view, options = {}) { if (!pageMeta[view]) return; if (platformApi?.isEnabled() && !API_CONNECTED_PAGES.has(view)) { showToast('该模块尚未接入真实后端,本阶段不会回退到 Mock 数据', 'error'); return false; } const perform = () => { clearDirty(); if (!hasPermission(pageMeta[view].permission)) { state.previousView = state.view; state.view = view; state.scenario = 'forbidden'; render(); return; } if (options.push !== false) { const nextEntry = navigationSnapshot(); const last = state.history[state.history.length - 1]; if (!last || last.view !== nextEntry.view || last.selectedTenant !== nextEntry.selectedTenant) state.history = [...state.history.slice(-2), nextEntry]; if (options.browser !== false && history.state?.[HISTORY_APP_KEY]) history.replaceState({ ...history.state, snapshot: nextEntry }, '', location.href); } state.previousView = state.view; state.view = view; state.scenario = 'normal'; closeModal({ force: true }); render(); scrollTo(0, 0); if (options.browser !== false) { historySequence += 1; history.pushState({ [HISTORY_APP_KEY]: true, sequence: historySequence, snapshot: navigationSnapshot() }, '', location.href); } if (['tenantDetail', 'billingProfile'].includes(view)) hydrateTenantDetail(state.selectedTenant); }; if (!options.force && hasUnsavedChanges()) return requestLeave(perform); perform(); return true; } function navigateBack() { requestLeave(() => { clearDirty(); if (history.state?.[HISTORY_APP_KEY] && history.length > 1) { history.back(); return; } const prior = state.history.pop(); if (!prior) { state.view = 'overview'; state.previousView = ''; render(); scrollTo(0, 0); return; } Object.assign(state, prior); render(); requestAnimationFrame(() => scrollTo(0, prior.scrollY || 0)); }); } function closeSubpage() { requestLeave(() => { clearDirty(); state.history = []; state.view = 'overview'; state.previousView = ''; state.scenario = 'normal'; render(); scrollTo(0, 0); history.replaceState({ [HISTORY_APP_KEY]: true, sequence: historySequence, snapshot: navigationSnapshot() }, '', location.href); }); } window.addEventListener('popstate', event => { const entry = event.state; if (!entry?.[HISTORY_APP_KEY] || !entry.snapshot) return; const targetSequence = Number(entry.sequence) || 0; if (restoringHistory) { restoringHistory = false; return; } if (hasUnsavedChanges() && !bypassHistoryGuard) { const delta = targetSequence - historySequence; restoringHistory = true; history.go(-delta); requestLeave(() => { bypassHistoryGuard = true; history.go(delta); }); return; } bypassHistoryGuard = false; historySequence = targetSequence; clearDirty(); const currentView = state.view; Object.assign(state, entry.snapshot, { previousView: currentView, scenario: 'normal', modal: '', modalData: {} }); lastOverlayTrigger = null; if (state.history.length && state.history[state.history.length - 1].view === state.view) state.history.pop(); render(); requestAnimationFrame(() => scrollTo(0, entry.snapshot.scrollY || 0)); if (entry.rootGuard) { historySequence += 1; history.pushState({ [HISTORY_APP_KEY]: true, sequence: historySequence, snapshot: navigationSnapshot() }, '', location.href); } }); function nextResponse() { const response = state.response; if (response !== 'success') { state.response = 'success'; responseSelect.value = 'success'; } return response; } async function simulateWrite({ key, permission, validate, apply, success, close = true, rerender = true, operationId = '', body, request: executeRequest }) { if (!canWrite(permission)) { showToast(`缺少写权限:${permission}`, 'error'); return; } const validation = validate?.(); if (validation) { showFormErrors(validation); return; } const mockResponse = platformApi?.isEnabled() ? 'success' : nextResponse(); state.submitting = key; const activeForm = overlayRoot.querySelector('form') || mainView.querySelector('form'); const submitButton = activeForm && (activeForm.querySelector('[type="submit"]') || document.querySelector(`[type="submit"][form="${activeForm.id}"]`)); const submitMarkup = submitButton?.innerHTML; if (submitButton) { submitButton.disabled = true; submitButton.innerHTML = `${icon('loader-circle')}提交中`; refreshIcons(); } const finish = async () => { if (mockResponse !== 'success') { state.submitting = ''; const id = requestId(); addAudit(`${key}.failed`, 'mock_request', id, key, mockResponse === 'validation_error' ? '中' : '高', `${mockResponse === 'validation_error' ? '服务端字段校验失败' : 'Mock 写请求失败'},Request ID:${id}。`, '失败'); showToast(`${mockResponse === 'validation_error' ? '服务端字段校验失败' : '提交失败'} · ${id}`, 'error'); if (mockResponse === 'validation_error') showFormErrors({ server: `服务端拒绝本次输入(${id})` }); if (submitButton) { submitButton.disabled = false; submitButton.innerHTML = submitMarkup; refreshIcons(); } return; } try { let payload = null; if (platformApi?.isEnabled()) { if (typeof executeRequest === 'function') payload = await executeRequest(); else if (operationId) { const input = typeof body === 'function' ? body() : (body || {}); payload = await platformApi.request(operationId, { body: platformApi.buildBody(operationId, input) }); } else { throw new Error(`操作 ${key} 尚未配置后端 operationId`); } } else { await new Promise(resolve => setTimeout(resolve, 650)); } state.submitting = ''; clearDirty(activeForm?.closest('#overlayRoot') ? 'modal' : 'page'); const result = apply(payload); saveData(); if (close) closeModal({ force: true }); if (rerender) render(); showToast(typeof success === 'function' ? success(result) : success); if (platformApi?.isEnabled()) hydrateFromBackend({ silent: true }); } catch (error) { const id = error.requestId || requestId(); state.submitting = ''; showToast(`${error.message || '提交失败'} · ${id}`, 'error'); if (error.status === 400) showFormErrors({ server: `${error.message || '服务端字段校验失败'}(${id})` }); if (submitButton) { submitButton.disabled = false; submitButton.innerHTML = submitMarkup; refreshIcons(); } } }; finish(); } async function executeBackendMutation(operationId, input, apply, { refresh = true } = {}) { if (!platformApi?.isEnabled()) { apply?.(null); return true; } try { const payload = await platformApi.request(operationId, { body: platformApi.buildBody(operationId, typeof input === 'function' ? input() : input), }); apply?.(payload); if (refresh) hydrateFromBackend({ silent: true }); return true; } catch (error) { showToast(`${error.message || '提交失败'}${error.requestId ? ` · ${error.requestId}` : ''}`, 'error'); return false; } } function showFormErrors(errors) { const form = overlayRoot.querySelector('form') || mainView.querySelector('form'); if (!form) { showToast(Object.values(errors)[0], 'error'); return; } form.querySelectorAll('.field-error').forEach(node => node.remove()); form.querySelectorAll('.is-invalid').forEach(node => node.classList.remove('is-invalid')); const summary = form.querySelector('.form-error-summary'); if (summary) { summary.hidden = false; summary.innerHTML = `请修正以下问题`; summary.focus(); } Object.entries(errors).forEach(([name, message]) => { const input = form.elements[name]; if (!input) return; input.classList.add('is-invalid'); input.insertAdjacentHTML('afterend', `${escapeHtml(message)}`); }); const first = form.querySelector('.is-invalid'); first?.focus(); } function modalFrame(title, description, content, actions = '', options = {}) { return ``; } function formField(label, name, value = '', attrs = '') { return ``; } function tenantOptions(selected = '') { return data.tenants.map(tenant => ``).join(''); } function planOptions(selected = '') { return data.plans.map(plan => ``).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]) => ``) .join(''); } function channelEndpointFormValue(channel) { return platformApi?.isEnabled() && channel.endpointRedacted ? '' : channel.endpoint || ''; } const modalTemplates = { mobileBoundary() { return modalFrame('请在桌面端继续', `${state.modalData.actionLabel || '此操作'}需要完整工作区。`, `
${icon('monitor-up')}
移动端只支持查看与轻量处理

租户、账务、用量、授权、员工和通知渠道的创建或复杂编辑,请在宽度不少于 768px 的桌面端完成,避免误操作。

`, ``, { id: '全局 · 移动端边界' }); }, createTenant() { return modalFrame('创建租户', '创建主体、独立域名和首期订阅基础。slug 与域名必须唯一。', ``, ``, { id: 'P-02 · 创建向导', wide: true }); }, tenantStatus() { const tenant = tenantById(state.modalData.tenantId); const restoring = tenant.status === '暂停'; return modalFrame(restoring ? '恢复租户' : '暂停租户', `将 ${tenant.name} 的业务状态变更为“${restoring ? '正常' : '暂停'}”。${restoring ? '恢复业务写操作。' : '学生端保留只读访问,新增权益与部分写操作将受限。'}`, ``, ``, { id: 'P-03 · 生命周期' }); }, createSubscription() { const selectedPlan = state.modalData.planCode || tenantById(state.selectedTenant)?.planCode || 'pro'; return modalFrame('创建租户订阅', '日期与金额必须有效;同一租户的重复活跃订阅会被阻止。', ``, ``, { id: 'P-05 · 订阅表单', wide: true }); }, createInvoice() { return modalFrame('手工创建服务费账单', '金额以元展示,Mock 写入时显式转换;到期日必须晚于今天。', ``, ``, { 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', '预览不会写入账单;已有账单候选将被幂等跳过。', `
${candidates.map(({ subscription, tenant }) => ``).join('') || '

没有可生成候选,全部订阅已存在对应账单。

'}
`, `${candidates.length ? `` : ''}`, { id: 'P-06 · Dry-run', wide: true }); }, payment() { const invoice = invoiceById(state.modalData.invoiceId); return modalFrame('人工确认收款', `${invoice.id} · 当前余额 ${money(invoice.balance)}。金额不可超过余额,原始支付载荷不会回显。`, ``, ``, { id: 'P-08 · 资金动作', wide: true }); }, overduePreview() { const candidates = data.invoices.filter(invoice => invoice.status === '已逾期'); return modalFrame('逾期处理预览 · Dry-run', '本次预览不改变账单或租户状态;确认后异步创建催缴记录。', `
${candidates.map(invoice => ``).join('') || '

当前没有逾期候选。

'}
`, `${candidates.length ? '' : ''}`, { id: 'P-08 · Dry-run' }); }, recordUsage() { return modalFrame('记录平台用量', '人工记录会标记来源;同租户同账期重复记录会被阻止。', ``, ``, { 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} 账期预览;确认后通过异步任务生成账单。`, `
${candidates.map(item => ``).join('') || '

当前账期无超额候选。

'}
`, `${candidates.length ? '' : ''}`, { 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}`, ``, ``, { id: 'P-08 · 通知渠道', wide: true }); }, grant() { const grant = data.grants.find(item => item.id === state.modalData.grantId) || {}; return modalFrame(grant.id ? '编辑公共题库授权' : '新建公共题库授权', '授权范围与允许列表会按后端 DTO 保存;地区和科目字段使用 UUID。', ``, ``, { id: 'P-09 · 授权规则', wide: true }); }, staff() { const person = data.staff.find(item => item.id === state.modalData.staffId) || {}; const presetRole = roleDefinitions[state.modalData.roleKey]?.label || ''; const selectedRole = person.role || presetRole || '租户运营'; const editablePhone = /^1\d{10}$/.test(person.phone || '') ? person.phone : ''; const permissionList = ['platform:overview:read', 'platform:tenant:read', 'platform:tenant:write', 'platform:tenant:status', 'platform:tenant:billing_profile', 'platform:plan:read', 'platform:billing:read', 'platform:billing:write', 'platform:billing:payment', 'platform:billing:dunning', 'platform:billing:notification', 'platform:usage:read', 'platform:usage:write', 'platform:question_bank:read', 'platform:question_bank:grant', 'platform:question_bank:ops', 'platform:staff:read', 'platform:staff:write', 'platform:staff:status', 'platform:audit:read', 'platform:audit:export', 'platform:audit:alert', 'platform:audit:notification']; const presetPermissions = roleDefinitions[state.modalData.roleKey]?.permissions || []; return modalFrame(person.id ? '编辑平台员工' : '新建平台员工', 'Auth 用户必须存在;按最小权限分组授权。', ``, ``, { id: 'P-10 · 员工权限', wide: true }); }, auditDetail() { const log = data.auditLogs.find(item => item.id === state.modalData.auditId); return modalFrame('审计事件详情', `${log.id} · 敏感字段已脱敏。`, ``, ``, { id: 'P-11 · 只读详情', wide: true }); }, alertDetail() { const alert = data.alerts.find(item => item.id === (state.modalData.alertId || state.selectedAlert)); return modalFrame('告警证据与处理', `${alert.id} · ${alert.title}`, `
${alert.severity}
${escapeHtml(alert.summary)}${alert.owner} · ${alert.updatedAt}
${statusBadge(alertStatusLabel(alert.status))}
`, `${['open', 'acknowledged'].includes(alert.status) ? `` : ''}`, { id: 'P-12 · 状态机', wide: true }); }, notificationDetail() { const event = [...(data.notificationEvents || []), ...(data.dunningEvents || [])].find(item => item.id === state.modalData.eventId); const channel = event ? notificationChannelName(event.channelId) : '-'; return modalFrame('通知事件详情', `${event?.id || '-'} · 请求与错误摘要已脱敏。`, ``, `${event?.status === '失败' ? `` : ''}`, { id: 'P-12 · 通知事件', wide: true }); }, syncDetail() { const [tenantId, bankId] = String(state.modalData.syncKey || '').split(':'); const sync = data.bankSync.find(item => item.tenantId === tenantId && item.bankId === bankId); return modalFrame('租户采用与同步详情', `${tenantById(tenantId)?.name || '-'} · ${bankById(bankId)?.name || '-'}`, ``, ``, { id: 'P-09 · 采用同步', wide: true }); }, auditChannel() { const channel = data.auditChannels.find(item => item.id === state.modalData.channelId) || {}; const redactedHint = platformApi?.isEnabled() && channel.endpointRedacted ? ' 后端不会回显完整 URL;编辑时必须重新输入完整 HTTPS Webhook。' : ''; return modalFrame(channel.id ? '编辑审计通知渠道' : '新建审计通知渠道', `secret 只写;状态和严重级别过滤由平台后端执行。${redactedHint}`, ``, ``, { id: 'P-12 · 通知渠道', wide: true }); }, confirmAction() { const { title, description, impact, confirmLabel = '确认操作', confirmActionName = 'execute-confirmed', tone: confirmTone = 'danger' } = state.modalData; return modalFrame(title, description, `
${icon(confirmTone === 'danger' ? 'triangle-alert' : 'circle-help')}

${escapeHtml(impact)}

`, ``, { id: '全局 · 二次确认' }); }, tasks() { return modalFrame('异步任务中心', '批量账单、超额账单和导出均返回 task_id,可跟踪、重试并跳转结果。', `
${data.tasks.length ? data.tasks.map(task => `
${icon(task.status === 'completed' ? 'circle-check' : task.status === 'failed' ? 'circle-x' : 'loader-circle')}
${escapeHtml(task.label)}${task.id} · ${task.updatedAt}

${task.error ? escapeHtml(task.error) : `结果去向:${escapeHtml(task.resultTarget)}`}

${task.status === 'failed' ? `` : statusBadge(taskStatusLabel(task.status))}
`).join('') : emptyBlock('没有异步任务', '批量生成、导出或重试后会出现在这里。')}
`, ``, { id: '全局 · 任务中心', wide: true }); }, }; function confirmAction(config, execute) { openModal('confirmAction', config); state.modalData.execute = execute; } function validateBillingProfile(form) { const errors = {}; ['billingName', 'contactName', 'phone', 'email', 'invoiceTitle'].forEach(name => { if (!fieldValue(form, name)) errors[name] = `${form.elements[name].previousElementSibling?.textContent.replace('*', '').trim() || name}不能为空`; }); const phone = fieldValue(form, 'phone'); if (phone && !/^1\d{10}$/.test(phone)) errors.phone = '请输入 11 位中国大陆手机号'; const email = fieldValue(form, 'email'); if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) errors.email = '请输入有效邮箱地址'; const taxId = fieldValue(form, 'taxId'); if (taxId && !/^[0-9A-Z]{15,20}$/.test(taxId)) errors.taxId = '税号应为 15-20 位大写字母或数字'; const account = fieldValue(form, 'bankAccountMasked'); if (account && !account.includes('*')) errors.bankAccountMasked = '不得提交完整银行账号,请使用脱敏值'; return Object.keys(errors).length ? errors : null; } function validateNotificationChannel(values) { const errors = {}; if (!values.code) errors.code = '渠道代码不能为空'; if (!values.name) errors.name = '渠道名称不能为空'; if (!values.endpoint) errors.endpoint = 'Webhook URL 不能为空'; if (platformApi?.isEnabled() && values.endpoint) { try { const url = new URL(values.endpoint); const local = ['localhost', '127.0.0.1', '::1'].includes(url.hostname); if (url.protocol !== 'https:' && !local) errors.endpoint = '联调/生产 Webhook 必须使用 HTTPS'; } catch { errors.endpoint = '请输入完整有效的 Webhook URL'; } } return Object.keys(errors).length ? errors : null; } document.addEventListener('submit', event => { event.preventDefault(); const form = event.target; const desktopOnlyForms = new Set(['createTenantForm', 'tenantStatusForm', 'billingProfileForm', 'subscriptionForm', 'invoiceForm', 'paymentForm', 'usageForm', 'dunningChannelForm', 'grantForm', 'staffForm', 'auditChannelForm']); if (desktopOnlyForms.has(form.id) && requireDesktop('提交复杂配置或资金操作')) return; if (form.id === 'createTenantForm') { const slug = fieldValue(form, 'slug'); const host = fieldValue(form, 'primaryHost'); const name = fieldValue(form, 'name'); const amount = parseMoney(fieldValue(form, 'amount')); simulateWrite({ key: 'create-tenant', permission: 'platform:tenant:write', operationId: 'PlatformAdminTenantsController_create', body: () => ({ ...Object.fromEntries(new FormData(form)), amount }), validate: () => { const errors = {}; if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) errors.slug = 'slug 仅支持小写字母、数字和连字符'; if (!name) errors.name = '租户名称不能为空'; if (!/^([a-z0-9-]+\.)+[a-z]{2,}$/i.test(host)) errors.primaryHost = '请输入有效域名'; if (data.tenants.some(item => item.slug === slug)) errors.slug = 'slug 已存在'; if (data.tenants.some(item => item.host === host)) errors.primaryHost = '域名已被其他租户使用'; return Object.keys(errors).length ? errors : null; }, apply: payload => { const planCode = fieldValue(form, 'planCode'); const plan = planByCode(planCode); const fallback = { id: `tenant_${slug.replaceAll('-', '_')}`, slug, name, legal: fieldValue(form, 'legalName') || name, brand: fieldValue(form, 'brandName') || name, shortName: fieldValue(form, 'shortName') || name, host, plan: plan.name, planCode, status: fieldValue(form, 'status'), billing: fieldValue(form, 'billingStatus') || (amount > 0 ? '待付款' : '正常'), mode: fieldValue(form, 'mode'), students: 0, questions: 0, storageGB: 0, trafficTB: 0, expires: '', balance: amount, owner: '待配置', ownerPhone: '-', createdAt: todayTime() }; const tenant = payload?.item ? platformApi.adapters.tenant({ ...payload.item, planCode, brandName: fallback.brand }, fallback, Object.fromEntries(data.plans.map(item => [item.code, item]))) : fallback; tenant.host = host; tenant.shortName = fallback.shortName; data.tenants.unshift(tenant); if (!platformApi?.isEnabled() && amount > 0) data.invoices.unshift({ id: entityId('INV'), tenantId: tenant.id, type: '首期服务费', period: '首期', total: amount, paid: 0, balance: amount, due: '2026-08-15', status: '待付款', note: '创建租户首期金额' }); state.selectedTenant = tenant.id; addAudit('tenant.create', 'tenant', tenant.id, tenant.name, '高', `创建租户 ${tenant.name},初始套餐 ${tenant.plan},账务状态 ${tenant.billing},首期金额 ${money(amount)}。`); state.modal = ''; navigate('tenantDetail'); return tenant; }, success: '租户与首期账务已创建,已进入租户详情', close: false, rerender: false }); } if (form.id === 'tenantStatusForm') { const tenant = tenantById(state.modalData.tenantId); const reason = fieldValue(form, 'reason'); simulateWrite({ key: 'tenant-status', permission: 'platform:tenant:status', operationId: 'PlatformAdminTenantsController_status', body: () => ({ tenantId: tenant.id, status: tenant.status === '暂停' ? '正常' : '暂停', billingStatus: fieldValue(form, 'billingStatus'), reason }), validate: () => !reason ? { reason: '必须填写可审计的变更原因' } : !new FormData(form).get('confirm') ? { confirm: '请确认业务影响' } : null, apply: () => { const from = tenant.status; const billingFrom = tenant.billing; tenant.status = from === '暂停' ? '正常' : '暂停'; tenant.billing = fieldValue(form, 'billingStatus') || tenant.billing; addAudit('tenant.status.update', 'tenant', tenant.id, tenant.name, '高', `业务状态 ${from} → ${tenant.status};账务状态 ${billingFrom} → ${tenant.billing}。原因:${reason}`); return tenant.status; }, success: nextStatus => `租户已${nextStatus === '暂停' ? '暂停' : '恢复'},账务状态已同步` }); } if (form.id === 'billingProfileForm') { simulateWrite({ key: 'billing-profile', permission: 'platform:tenant:billing_profile', operationId: 'PlatformAdminTenantsController_billingProfile', body: () => ({ tenantId: state.selectedTenant, ...Object.fromEntries(new FormData(form)) }), validate: () => validateBillingProfile(form), apply: () => { data.billingProfiles[state.selectedTenant] = Object.fromEntries(new FormData(form)); addAudit('tenant.billing_profile.update', 'tenant', state.selectedTenant, tenantById(state.selectedTenant).name, '高', '更新租户账务与开票资料,银行账号保持脱敏。'); navigateBack(); }, success: '账务资料已保存并回到租户详情', close: false, rerender: false }); } if (form.id === 'subscriptionForm') { const values = Object.fromEntries(new FormData(form)); simulateWrite({ key: 'subscription', permission: 'platform:billing:write', operationId: 'PlatformAdminBillingController_createSubscription', body: () => ({ ...values, autoRenew: Boolean(new FormData(form).get('autoRenew')) }), validate: () => { const errors = {}; if (data.subscriptions.some(item => item.tenantId === values.tenantId && item.status === '活跃')) errors.tenantId = '该租户已有活跃订阅,请先处理原订阅'; if (!values.amount || Number(values.amount) < 0) errors.amount = '金额必须大于等于 0'; if (!values.start) errors.start = '开始日期不能为空'; if (!values.expires || values.expires <= values.start) errors.expires = '到期日期必须晚于开始日期'; return Object.keys(errors).length ? errors : null; }, apply: () => { const subscription = { id: entityId('SUB'), tenantId: values.tenantId, planCode: values.planCode, status: values.status, cycle: values.cycle, amount: Number(values.amount), start: values.start, expires: values.expires, autoRenew: Boolean(new FormData(form).get('autoRenew')) }; data.subscriptions.unshift(subscription); const tenant = tenantById(values.tenantId); tenant.planCode = values.planCode; tenant.plan = planByCode(values.planCode).name; tenant.expires = values.expires; addAudit('subscription.create', 'subscription', subscription.id, tenant.name, '高', `创建 ${subscription.cycle} ${tenant.plan} 订阅,金额 ${money(subscription.amount)}。`); }, success: '订阅已创建并更新租户套餐' }); } if (form.id === 'invoiceForm') { const values = Object.fromEntries(new FormData(form)); simulateWrite({ key: 'invoice', permission: 'platform:billing:write', operationId: 'PlatformAdminBillingController_createInvoice', body: values, validate: () => { const errors = {}; if (!values.amount || Number(values.amount) <= 0) errors.amount = '金额必须大于 0'; if (!values.due) errors.due = '到期日不能为空'; if (!values.period) errors.period = '账期不能为空'; return Object.keys(errors).length ? errors : null; }, apply: () => { const invoice = { id: entityId('INV'), tenantId: values.tenantId, type: values.type, period: values.period, total: Number(values.amount), paid: 0, balance: Number(values.amount), due: values.due, status: '待付款', note: values.note || values.type }; data.invoices.unshift(invoice); const tenant = tenantById(values.tenantId); tenant.balance += invoice.balance; tenant.billing = '待付款'; addAudit('invoice.create', 'invoice', invoice.id, invoice.id, '高', `手工创建 ${money(invoice.total)} 服务费账单。`); }, success: '账单草稿已创建' }); } if (form.id === 'paymentForm') { const invoice = invoiceById(state.modalData.invoiceId); const values = Object.fromEntries(new FormData(form)); const amount = parseMoney(values.amount); simulateWrite({ key: 'payment', permission: 'platform:billing:payment', operationId: 'PlatformAdminBillingController_confirmPayment', body: () => ({ ...values, tenantId: invoice.tenantId, invoiceId: invoice.backendId || invoice.id, amount }), validate: () => { const errors = {}; if (!amount || amount <= 0) errors.amount = '收款金额必须大于 0'; if (amount > invoice.balance) errors.amount = `收款金额不可超过余额 ${money(invoice.balance)}`; if (!values.paymentNo) errors.paymentNo = '平台收款号不能为空'; if (!values.tradeNo) errors.tradeNo = '交易流水号不能为空'; if (!new FormData(form).get('confirm')) errors.confirm = '请先确认已核对银行流水'; return Object.keys(errors).length ? errors : null; }, apply: () => { invoice.paid += amount; invoice.balance -= amount; invoice.status = invoice.balance === 0 ? '已支付' : '部分支付'; const tenant = tenantById(invoice.tenantId); tenant.balance = Math.max(0, tenant.balance - amount); tenant.billing = tenant.balance === 0 ? '正常' : '待付款'; if (tenant.status === '暂停' && tenant.balance === 0) tenant.status = '正常'; addAudit('invoice.payment.confirm', 'invoice', invoice.id, invoice.id, '高', `人工确认收款 ${money(amount)},交易流水仅保存脱敏摘要。`); }, success: '收款已确认,账单与租户余额已更新' }); } if (form.id === 'usageForm') { const values = Object.fromEntries(new FormData(form)); simulateWrite({ key: 'usage', permission: 'platform:usage:write', operationId: 'PlatformAdminBillingController_recordUsage', request: () => Promise.all([['students', values.students], ['questions', values.questions], ['storage_gb', values.storageGB], ['traffic_tb', values.trafficTB]].map(([metricKey, metricValue]) => platformApi.request('PlatformAdminBillingController_recordUsage', { body: platformApi.buildBody('PlatformAdminBillingController_recordUsage', { tenantId: values.tenantId, period: values.period, metricKey, metricValue }) }))), validate: () => data.usage.some(item => item.tenantId === values.tenantId && item.period === values.period) ? { period: '该租户账期已有记录,避免周期重叠或重复' } : null, apply: () => { data.usage.unshift({ tenantId: values.tenantId, period: values.period, source: '人工补录', students: Number(values.students), questions: Number(values.questions), storageGB: Number(values.storageGB), trafficTB: Number(values.trafficTB), overage: [], estimated: 0 }); addAudit('usage.create', 'tenant', values.tenantId, tenantById(values.tenantId).name, '中', `人工补录 ${values.period} 平台用量。`); }, success: '用量记录已保存并标记人工来源' }); } if (form.id === 'dunningChannelForm') { const values = Object.fromEntries(new FormData(form)); const existing = data.dunningChannels.find(item => item.id === values.id); simulateWrite({ key: 'dunning-channel', permission: 'platform:billing:notification', operationId: 'PlatformAdminDunningChannelsController_upsertChannel', body: () => ({ ...values, secretRef: existing?.secretRef, reminderTypes: existing?.reminderTypes, reminderChannels: existing?.reminderChannels, minReminderLevel: existing?.minReminderLevel, enabled: Boolean(new FormData(form).get('enabled')) }), validate: () => validateNotificationChannel(values), apply: () => { const next = { id: values.id || entityId('DUN'), code: values.code, name: values.name, provider: values.provider, endpoint: values.endpoint, endpointRedacted: false, secretRef: existing?.secretRef || '', retry: Number(values.retry), enabled: Boolean(new FormData(form).get('enabled')) }; if (existing) Object.assign(existing, next); else data.dunningChannels.unshift(next); addAudit('dunning.channel.upsert', 'notification_channel', next.id, next.name, '高', `保存催缴渠道 ${next.code},secret 保持只写。`); }, success: '催缴通知渠道已保存' }); } if (form.id === 'grantForm') { const values = Object.fromEntries(new FormData(form)); simulateWrite({ key: 'grant', permission: 'platform:question_bank:grant', operationId: 'PlatformAdminQuestionBanksController_upsertGrant', body: () => ({ ...values, localEdit: Boolean(new FormData(form).get('localEdit')) }), validate: () => { const errors = {}; const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const splitScope = value => String(value || '').split(/[、,,]/).map(item => item.trim()).filter(Boolean); if (values.scope === '指定套餐' && !values.planCode) errors.planCode = '指定套餐范围必须选择套餐'; if (values.scope === '指定租户' && !values.tenantId) errors.tenantId = '指定租户范围必须选择租户'; if (values.scope === '套餐 + 租户' && (!values.planCode || !values.tenantId)) errors.planCode = '混合范围必须同时选择套餐与租户'; if (platformApi?.isEnabled() && splitScope(values.regionIds).some(value => !uuid.test(value))) errors.regionIds = '地区范围必须为 UUID,多个值用逗号分隔'; if (platformApi?.isEnabled() && splitScope(values.subjectIds).some(value => !uuid.test(value))) errors.subjectIds = '科目范围必须为 UUID,多个值用逗号分隔'; if (values.expiresAt && values.expiresAt <= values.startsAt) errors.expiresAt = '到期日期必须晚于开始日期'; return Object.keys(errors).length ? errors : null; }, apply: () => { const existing = data.grants.find(item => item.id === values.id); const splitScope = value => String(value || '').split(/[、,,]/).map(item => item.trim()).filter(Boolean); const next = { id: values.id || entityId('GRANT'), bankId: values.bankId, scope: values.scope, allowedPlanCodes: values.planCode ? [values.planCode] : [], allowedTenantIds: values.tenantId ? [values.tenantId] : [], allowedRegionIds: splitScope(values.regionIds), allowedSubjectIds: splitScope(values.subjectIds), status: existing?.status || '启用', startsAt: values.startsAt, expiresAt: values.expiresAt, localEdit: Boolean(new FormData(form).get('localEdit')) }; if (existing) Object.assign(existing, next); else data.grants.unshift(next); addAudit('bank.grant.update', 'grant', next.id, bankById(next.bankId).name, '高', `保存授权范围 ${next.scope};地区 ${next.allowedRegionIds.join('、') || '不限'};科目 ${next.allowedSubjectIds.join('、') || '不限'}。`); }, success: '公共题库授权范围已保存' }); } if (form.id === 'staffForm') { const values = Object.fromEntries(new FormData(form)); const permissions = new FormData(form).getAll('permissions'); simulateWrite({ key: 'staff', permission: 'platform:staff:write', operationId: 'PlatformAdminOverviewController_upsertStaff', body: () => ({ ...values, permissions }), validate: () => { const errors = {}; ['authUserId', 'username', 'name', 'email'].forEach(name => { if (!values[name]) errors[name] = `${name} 不能为空`; }); if (platformApi?.isEnabled() && values.authUserId && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(values.authUserId)) errors.authUserId = '真实模式必须填写已存在的 Supabase Auth 用户 UUID'; if (!platformApi?.isEnabled() && values.authUserId && !values.authUserId.startsWith('auth-')) errors.authUserId = 'Mock Auth 用户不存在,请使用 auth- 前缀'; if (!permissions.length) errors.permissions = '至少授予一项平台权限'; return Object.keys(errors).length ? errors : null; }, apply: () => { const existing = data.staff.find(item => item.id === values.id); const next = { id: values.id || entityId('STAFF'), authUserId: values.authUserId, username: values.username, name: values.name, email: values.email, phone: values.phone || '-', role: values.role, permissions, status: existing?.status || '正常', lastActive: existing?.lastActive || '尚未登录' }; if (existing) Object.assign(existing, next); else data.staff.unshift(next); addAudit('staff.upsert', 'staff', next.id, next.name, '高', `保存平台员工及 ${permissions.length} 项有效权限。`); }, success: '平台员工与有效权限已保存' }); } if (form.id === 'auditChannelForm') { const values = Object.fromEntries(new FormData(form)); const existing = data.auditChannels.find(item => item.id === values.id); simulateWrite({ key: 'audit-channel', permission: 'platform:audit:notification', operationId: 'PlatformAdminAuditController_upsertChannel', body: () => ({ ...values, secretRef: existing?.secretRef, enabled: Boolean(new FormData(form).get('enabled')) }), validate: () => validateNotificationChannel(values), apply: () => { const next = { id: values.id || entityId('CH'), code: values.code, name: values.name, provider: values.provider, endpoint: values.endpoint, endpointRedacted: false, secretRef: existing?.secretRef || '', minSeverity: values.minSeverity, statusFilter: values.statusFilter, timeout: Number(values.timeout), maxAttempts: Number(values.maxAttempts), enabled: Boolean(new FormData(form).get('enabled')), lastStatus: existing?.lastStatus || '正常' }; if (existing) Object.assign(existing, next); else data.auditChannels.unshift(next); addAudit('audit.channel.upsert', 'notification_channel', next.id, next.name, '高', `保存审计告警渠道 ${next.code},secret 保持只写。`); }, success: '审计通知渠道已保存' }); } }); document.addEventListener('click', event => { if (event.target.classList.contains('modal-backdrop')) { closeModal(); return; } const copy = event.target.closest('[data-copy]'); if (copy) { navigator.clipboard?.writeText(copy.dataset.copy); showToast('已复制到剪贴板'); return; } const nav = event.target.closest('[data-nav]'); if (nav) { requestLeave(() => { state.selectedTenant = ''; navigate(nav.dataset.nav, { force: true }); }); return; } const tenant = event.target.closest('[data-tenant-detail]'); if (tenant) { requestLeave(() => { state.selectedTenant = tenant.dataset.tenantDetail; navigate('tenantDetail', { force: true }); }); return; } const invoice = event.target.closest('[data-invoice-detail]'); if (invoice) { if (requireDesktop('确认账单收款')) return; state.selectedInvoice = invoice.dataset.invoiceDetail; openModal('payment', { invoiceId: invoice.dataset.invoiceDetail }); return; } const audit = event.target.closest('[data-audit-detail]'); if (audit) { openModal('auditDetail', { auditId: audit.dataset.auditDetail }); return; } const alertDetail = event.target.closest('[data-alert-detail]'); if (alertDetail) { openModal('alertDetail', { alertId: alertDetail.dataset.alertDetail }); return; } const notificationDetail = event.target.closest('[data-notification-detail]'); if (notificationDetail) { openModal('notificationDetail', { eventId: notificationDetail.dataset.notificationDetail }); return; } const tf = event.target.closest('[data-tenant-filter]'); if (tf) { state.tenantFilter = tf.dataset.tenantFilter; syncNavigationState(); render(); return; } const billingTab = event.target.closest('[data-billing-tab]'); if (billingTab) { state.billingTab = billingTab.dataset.billingTab; syncNavigationState(); render(); return; } const bankTab = event.target.closest('[data-bank-tab]'); if (bankTab) { state.bankTab = bankTab.dataset.bankTab; syncNavigationState(); render(); return; } const bankSelect = event.target.closest('[data-bank-select]'); if (bankSelect) { state.selectedBank = bankSelect.dataset.bankSelect; syncNavigationState(); render(); return; } const auditSeverity = event.target.closest('[data-audit-severity]'); if (auditSeverity) { state.auditSeverity = auditSeverity.dataset.auditSeverity; syncNavigationState(); render(); return; } const alertStatus = event.target.closest('[data-alert-status]'); if (alertStatus) { state.alertStatus = alertStatus.dataset.alertStatus; syncNavigationState(); render(); return; } const actionElement = event.target.closest('[data-action]'); const action = actionElement?.dataset.action; if (!action) return; if (action === 'close-modal') { closeModal(); return; } if (action === 'execute-confirmed' || action === 'execute-staff-disable') { if (requireDesktop('执行高风险确认操作')) return; const execute = state.modalData.execute; closeModal({ force: true }); execute?.(); return; } if (action === 'subpage-back') { navigateBack(); return; } if (action === 'subpage-close') { closeSubpage(); return; } if (action === 'retry-page') { state.scenario = 'normal'; scenarioSelect.value = 'normal'; render(); return; } if (action === 'restore-session') { state.scenario = 'normal'; state.view = 'overview'; state.previousView = ''; state.history = []; scenarioSelect.value = 'normal'; render(); showToast('Mock 会话已恢复'); return; } if (action === 'logout' && platformApi?.isEnabled()) { window.GongxuePlatformAuth?.logout(); return; } if (action === 'logout') { closeModal(); state.scenario = 'session_invalid'; state.view = 'overview'; state.previousView = ''; state.history = []; scenarioSelect.value = 'session_invalid'; addAudit('auth.logout', 'application', 'platform-console', '平台管理控制台', '中', '超级管理员主动退出当前平台端会话。'); saveData(); render(); return; } if (action === 'refresh') { if (platformApi?.isEnabled()) { hydrateFromBackend(); return; } state.scenario = 'loading'; scenarioSelect.value = 'loading'; render(); setTimeout(() => { state.scenario = 'normal'; scenarioSelect.value = 'normal'; render(); showToast('平台静态演示数据已刷新'); }, 700); return; } if (action === 'reset-mock') { if (requireDesktop('重置平台 Mock 数据')) return; confirmAction({ title: '重置平台 Mock 数据', description: '恢复到 v1.1 初始演示数据。', impact: '所有本地创建的租户、账单、任务、授权、员工修改和告警状态将被清除。', confirmLabel: '确认重置' }, () => { localStorage.removeItem(STORAGE_KEY); data = clone(seed); state.selectedTenant = data.tenants[0].id; saveData(); render(); showToast('平台 Mock 数据已重置'); }); return; } if (action === 'open-task-center') { openModal('tasks'); return; } if (action === 'create-tenant') { if (requireDesktop('创建租户')) return; openModal('createTenant'); return; } if (action === 'prepare-tenant-status') { if (requireDesktop('变更租户生命周期')) return; openModal('tenantStatus', { tenantId: actionElement.dataset.tenantId || state.selectedTenant }); return; } if (action === 'open-tenant-portal') { const tenant = tenantById(state.selectedTenant); let portalWindow = null; try { portalWindow = window.open(TENANT_PORTAL_ORIGIN, '_blank'); if (portalWindow) portalWindow.opener = null; } catch {} showToast(portalWindow ? `已在新标签打开独立租户端 · ${tenant?.name || '当前租户'}` : '已请求打开独立租户端;如未出现,请允许本站弹出窗口', portalWindow ? 'success' : 'error'); return; } if (action === 'edit-billing-profile') { if (requireDesktop('编辑租户账务资料')) return; navigate('billingProfile'); return; } if (action === 'billing-profile-to-invoices' || action === 'tenant-to-billing') { if (actionElement.dataset.tenantId) state.selectedTenant = actionElement.dataset.tenantId; navigate('billing'); return; } if (action === 'tenant-to-usage') { state.selectedTenant = actionElement.dataset.tenantId; navigate('usage'); return; } if (action === 'clear-tenant-context') { state.selectedTenant = ''; render(); return; } if (action === 'create-subscription-for-tenant') { if (requireDesktop('创建租户订阅')) return; navigate('subscriptions'); openModal('createSubscription'); return; } if (action === 'create-subscription') { if (requireDesktop('创建租户订阅')) return; openModal('createSubscription'); return; } if (action === 'select-plan') { if (requireDesktop('选择套餐并创建订阅')) return; openModal('createSubscription', { planCode: actionElement.dataset.planCode }); return; } if (action === 'subscription-to-billing') { navigate('billing'); return; } if (action === 'subscriptions-to-usage') { navigate('usage'); return; } if (action === 'toggle-auto-renew') { if (requireDesktop('变更订阅自动续费')) return; if (platformApi?.isEnabled()) { showToast('后端暂未提供订阅更新接口,真实模式禁止仅改本地状态', 'error'); return; } const item = data.subscriptions.find(subscription => subscription.id === actionElement.dataset.subscriptionId); if (!canWrite('platform:billing:write')) return; item.autoRenew = !item.autoRenew; addAudit('subscription.auto_renew.update', 'subscription', item.id, item.id, '中', `自动续费更新为 ${item.autoRenew ? '开启' : '关闭'}。`); saveData(); render(); showToast('自动续费设置已更新'); return; } if (action === 'create-invoice') { if (requireDesktop('手工创建账单')) return; openModal('createInvoice'); return; } if (action === 'apply-invoice-query') { runContractRead({ key: 'invoice-query', actionId: 'act.p-06.01', operationId: 'PlatformAdminBillingController_invoices', title: '账单查询', suppliedRequestId: actionElement.dataset.contractRequestId, liveRequest: () => platformApi.request('PlatformAdminBillingController_invoices', { query: { tenantId: state.selectedTenant || undefined, status: ({ 草稿: 'draft', 待付款: 'issued', 已支付: 'paid', 已作废: 'void', 已逾期: 'overdue' })[state.invoiceStatus], limit: 200 } }), apply: payload => { if (Array.isArray(payload?.items)) data.invoices = payload.items.map(platformApi.adapters.invoice); return `${data.invoices.filter(invoice => (!state.selectedTenant || invoice.tenantId === state.selectedTenant) && (state.invoiceStatus === 'all' || invoice.status === state.invoiceStatus) && (!state.invoiceSearch.trim() || [invoice.id, invoice.type, invoice.period, invoice.note, tenantById(invoice.tenantId)?.name].some(value => String(value || '').toLowerCase().includes(state.invoiceSearch.trim().toLowerCase())))).length} 张匹配账单`; } }); return; } if (action === 'preview-invoice-candidates') { if (requireDesktop('批量生成账单')) return; openModal('invoiceCandidates'); return; } if (action === 'run-invoice-dry-run' && platformApi?.isEnabled()) { const subscriptionIds = Array.from(overlayRoot.querySelectorAll('[name="candidate"]:checked')).map(input => input.value); executeBackendMutation('PlatformAdminBillingController_fromSubscriptionsBatch', { subscriptionIds, dryRun: true }, payload => { const result = payload?.item || {}; showToast(`Dry-run 完成 · 可生成 ${result.items?.length || 0} 条,跳过 ${result.skippedCount || 0} 条`); }, { refresh: false }); return; } if (action === 'run-invoice-dry-run') { const count = overlayRoot.querySelectorAll('[name="candidate"]:checked').length; showToast(`Dry-run 已校验 ${count} 条订阅候选,未写入账单`); return; } if (action === 'generate-subscription-invoice' && platformApi?.isEnabled()) { if (requireDesktop('为单个订阅生成账单')) return; const subscription = data.subscriptions.find(item => item.id === actionElement.dataset.subscriptionId); if (!subscription) { showToast('订阅不存在或不可用', 'error'); return; } executeBackendMutation('PlatformAdminBillingController_fromSubscription', { tenantId: subscription.tenantId, subscriptionId: subscription.id, status: 'issued' }, payload => { closeModal({ force: true }); window.recordPlatformSpecResult?.('act.p-06.04', { result: 'success', operationId: 'PlatformAdminBillingController_fromSubscription', requestId: payload?.meta?.requestId || '' }); showToast('订阅账单已由后端生成'); }); return; } if (action === 'generate-subscription-invoice') { if (requireDesktop('为单个订阅生成账单')) return; const subscription = data.subscriptions.find(item => item.id === actionElement.dataset.subscriptionId); const suppliedRequestId = actionElement.dataset.contractRequestId || requestId(); if (!subscription || data.invoices.some(invoice => invoice.note.includes(subscription.id))) { state.contractResults['invoice-query'] = { status: 'error', title: '单订阅生成失败', detail: '该订阅已存在账单或订阅不可用', requestId: suppliedRequestId }; addAudit('invoice.from_subscription.failed', 'subscription', subscription?.id || '-', subscription?.id || '-', '中', `幂等冲突。Request ID:${suppliedRequestId}。`, '失败'); window.recordPlatformSpecResult?.('act.p-06.04', { result: 'failed', operationId: 'PlatformAdminBillingController_fromSubscription', requestId: suppliedRequestId }); render(); return; } const task = addContractTask({ actionId: 'act.p-06.04', operationId: 'PlatformAdminBillingController_fromSubscription', label: `为订阅 ${subscription.id} 生成账单`, resultTarget: '服务费账单', suppliedRequestId, execute: taskItem => { const invoice = { id: entityId('INV'), tenantId: subscription.tenantId, type: '订阅服务费', period: '下一订阅账期', total: subscription.amount, paid: 0, balance: subscription.amount, due: subscription.expires, status: '待付款', note: `订阅 ${subscription.id}` }; data.invoices.unshift(invoice); tenantById(subscription.tenantId).balance += invoice.balance; taskItem.invoiceId = invoice.id; addAudit('invoice.from_subscription.create', 'invoice', invoice.id, invoice.id, '高', `通过任务 ${taskItem.id} 为订阅 ${subscription.id} 生成单张账单。`); } }); showToast(`单订阅账单任务 ${task.id} 已创建`); return; } if (action === 'confirm-generate-candidates' && platformApi?.isEnabled()) { if (requireDesktop('批量生成账单')) return; const subscriptionIds = Array.from(overlayRoot.querySelectorAll('[name="candidate"]:checked')).map(input => input.value); if (!subscriptionIds.length) { showToast('请至少选择一条订阅账单候选', 'error'); return; } executeBackendMutation('PlatformAdminBillingController_fromSubscriptionsBatch', { subscriptionIds, dryRun: false, status: 'issued' }, payload => { closeModal({ force: true }); const result = payload?.item || {}; showToast(`后端已生成 ${result.createdCount || 0} 张账单,跳过 ${result.skippedCount || 0} 条`); }); return; } if (action === 'confirm-generate-candidates') { if (requireDesktop('批量生成账单')) return; const selectedIds = Array.from(overlayRoot.querySelectorAll('[name="candidate"]:checked')).map(input => input.value); if (!selectedIds.length) { showToast('请至少选择一条订阅账单候选', 'error'); return; } const candidates = data.subscriptions.filter(subscription => selectedIds.includes(subscription.id) && !data.invoices.some(invoice => invoice.note.includes(subscription.id))); closeModal(); const task = addTask(`批量生成 ${candidates.length} 张订阅账单`, '服务费账单', () => { candidates.forEach(subscription => { const invoice = { id: entityId('INV'), tenantId: subscription.tenantId, type: '订阅服务费', period: '下一订阅账期', total: subscription.amount, paid: 0, balance: subscription.amount, due: subscription.expires, status: '待付款', note: `订阅 ${subscription.id}` }; data.invoices.unshift(invoice); const tenant = tenantById(subscription.tenantId); tenant.balance += invoice.balance; tenant.billing = '待付款'; }); addAudit('invoice.batch.create', 'invoice_batch', task.id, task.id, '高', `批量生成 ${candidates.length} 张订阅账单。`); }); showToast(`任务 ${task.id} 已创建`); return; } if (action === 'confirm-payment') { if (requireDesktop('确认账单收款')) return; openModal('payment', { invoiceId: actionElement.dataset.invoiceId }); return; } if (action === 'preview-overdue' && platformApi?.isEnabled()) { if (requireDesktop('批量处理逾期账单')) return; executeBackendMutation('PlatformAdminBillingController_processOverdue', { dryRun: true, limit: 100, channel: 'internal' }, payload => { openModal('overduePreview'); showToast(`后端 Dry-run:${payload?.item?.processed || payload?.item?.items?.length || 0} 条候选`); }, { refresh: false }); return; } if (action === 'preview-overdue') { if (requireDesktop('批量处理逾期账单')) return; openModal('overduePreview'); return; } if (action === 'confirm-overdue-process' && platformApi?.isEnabled()) { showToast('后端逾期处理接口不支持指定 invoiceIds,真实模式禁止按当前勾选直接执行', 'error'); return; } if (action === 'confirm-overdue-process') { if (requireDesktop('批量处理逾期账单')) return; const selectedIds = Array.from(overlayRoot.querySelectorAll('[name="candidate"]:checked')).map(input => input.value); if (!selectedIds.length) { showToast('请至少选择一条逾期账单候选', 'error'); return; } const overdue = data.invoices.filter(invoice => invoice.status === '已逾期' && selectedIds.includes(invoice.id)); closeModal(); const task = addTask(`处理 ${overdue.length} 张逾期账单`, '收款与催缴', () => { overdue.forEach(invoice => data.reminders.unshift({ id: entityId('REM'), invoiceId: invoice.id, tenantId: invoice.tenantId, level: 3, channel: '邮件 + 短信', status: '发送中', date: todayTime(), message: `服务费账单 ${invoice.id} 已逾期,请尽快处理。` })); addAudit('invoice.overdue.process', 'invoice_batch', task.id, task.id, '高', `生成 ${overdue.length} 条催缴记录。`); }); showToast(`催缴任务 ${task.id} 已创建`); return; } if (action === 'generate-reminder' && platformApi?.isEnabled()) { showToast('后端仅提供批量逾期处理,没有单账单催缴创建接口', 'error'); return; } if (action === 'generate-reminder') { if (requireDesktop('生成账单催缴')) return; const invoice = invoiceById(actionElement.dataset.invoiceId); const reminder = { id: entityId('REM'), invoiceId: invoice.id, tenantId: invoice.tenantId, level: invoice.status === '已逾期' ? 3 : 1, channel: '邮件 + 短信', status: '已加入队列', date: todayTime(), message: invoice.status === '已逾期' ? `账单已逾期,请尽快支付 ${money(invoice.balance)}。` : `账单即将到期,应付 ${money(invoice.balance)}。` }; data.reminders.unshift(reminder); addAudit('invoice.reminder.create', 'reminder', reminder.id, reminder.id, '中', `为 ${invoice.id} 创建 L${reminder.level} 催缴。`); saveData(); render(); showToast('催缴已加入发送队列'); return; } if (action === 'record-usage') { if (requireDesktop('人工记录平台用量')) return; openModal('recordUsage'); return; } if (action === 'preview-overage') { if (requireDesktop('批量生成超额账单')) return; openModal('overagePreview'); return; } if (action === 'run-overage-dry-run' && platformApi?.isEnabled()) { const tenantIds = Array.from(overlayRoot.querySelectorAll('[name="candidate"]:checked')).map(input => input.value.split(':')[0]); executeBackendMutation('PlatformAdminBillingController_fromUsage', { tenantIds, period: state.usagePeriod, dryRun: true }, payload => { const result = payload?.item || {}; showToast(`超额 Dry-run 完成 · 可生成 ${result.items?.length || 0} 条`); }, { refresh: false }); return; } if (action === 'run-overage-dry-run') { const count = overlayRoot.querySelectorAll('[name="candidate"]:checked').length; showToast(`Dry-run 已校验 ${count} 条超额候选,未写入账单`); return; } if (action === 'confirm-overage-invoices' && platformApi?.isEnabled()) { if (requireDesktop('批量生成超额账单')) return; const tenantIds = Array.from(overlayRoot.querySelectorAll('[name="candidate"]:checked')).map(input => input.value.split(':')[0]); if (!tenantIds.length) { showToast('请至少选择一条超额账单候选', 'error'); return; } executeBackendMutation('PlatformAdminBillingController_fromUsage', { tenantIds, period: state.usagePeriod, dryRun: false, status: 'issued' }, payload => { closeModal({ force: true }); showToast(`后端已生成 ${payload?.item?.createdCount || 0} 张超额账单`); }); return; } if (action === 'confirm-overage-invoices') { if (requireDesktop('批量生成超额账单')) return; const selectedKeys = Array.from(overlayRoot.querySelectorAll('[name="candidate"]:checked')).map(input => input.value); if (!selectedKeys.length) { showToast('请至少选择一条超额账单候选', 'error'); return; } const candidates = data.usage.filter(item => item.period === state.usagePeriod && selectedKeys.includes(`${item.tenantId}:${item.period}`) && item.estimated > 0 && !data.invoices.some(invoice => invoice.note === `超额 ${item.period}` && invoice.tenantId === item.tenantId)); closeModal(); const task = addTask(`生成 ${candidates.length} 张超额账单`, '用量与超额计费', () => { candidates.forEach(item => { const invoice = { id: entityId('INV'), tenantId: item.tenantId, type: '用量超额费', period: item.period, total: item.estimated, paid: 0, balance: item.estimated, due: '2026-08-15', status: '待付款', note: `超额 ${item.period}` }; data.invoices.unshift(invoice); const tenant = tenantById(item.tenantId); tenant.balance += invoice.balance; tenant.billing = '待付款'; }); addAudit('usage.invoice.batch.create', 'invoice_batch', task.id, task.id, '高', `生成 ${candidates.length} 张用量超额账单。`); }); showToast(`超额账单任务 ${task.id} 已创建`); return; } if (action === 'new-dunning-channel') { if (requireDesktop('新建催缴渠道')) return; openModal('dunningChannel'); return; } if (action === 'edit-dunning-channel') { if (requireDesktop('编辑催缴渠道')) return; openModal('dunningChannel', { channelId: actionElement.dataset.channelId }); return; } if (action === 'toggle-dunning-channel') { if (requireDesktop('变更催缴渠道状态')) return; if (!canWrite('platform:billing:notification')) return; const channel = data.dunningChannels.find(item => item.id === actionElement.dataset.channelId); if (!channel) return; if (platformApi?.isEnabled() && channel.endpointRedacted) { openModal('dunningChannel', { channelId: channel.id }); showToast('后端不回显完整 Webhook;请重新输入完整 HTTPS URL 后保存状态', 'error'); return; } const nextEnabled = !channel.enabled; const apply = () => { channel.enabled = nextEnabled; addAudit('dunning.channel.status', 'notification_channel', channel.id, channel.name, '高', `催缴渠道${channel.enabled ? '启用' : '停用'}。`); saveData(); render(); showToast('渠道状态已更新'); }; if (platformApi?.isEnabled()) executeBackendMutation('PlatformAdminDunningChannelsController_upsertChannel', { ...channel, enabled: nextEnabled }, apply); else apply(); return; } if (action === 'new-grant') { if (requireDesktop('新建公共题库授权')) return; openModal('grant'); return; } if (action === 'apply-bank-search') { runContractRead({ key: 'bank-query', actionId: 'act.p-09.01', operationId: 'PlatformAdminQuestionBanksController_banks', title: '题库搜索', suppliedRequestId: actionElement.dataset.contractRequestId, liveRequest: () => platformApi.request('PlatformAdminQuestionBanksController_banks', { query: { q: state.bankSearch || undefined, status: 'active', includeTenantBanks: state.bankIncludeTenant, limit: 200 } }), apply: payload => { if (Array.isArray(payload?.items)) data.banks = payload.items.map(item => platformApi.adapters.bank(item, bankById(item.id))); return `${data.banks.filter(bank => !state.bankSearch.trim() || [bank.name, bank.catalog, bank.source, bank.version].some(value => String(value).toLowerCase().includes(state.bankSearch.trim().toLowerCase()))).length} 个匹配题库`; } }); return; } if (action === 'edit-grant') { if (requireDesktop('编辑公共题库授权')) return; openModal('grant', { grantId: actionElement.dataset.grantId }); return; } if (action === 'toggle-grant') { if (requireDesktop('变更公共题库授权状态')) return; if (!canWrite('platform:question_bank:grant')) return; const grant = data.grants.find(item => item.id === actionElement.dataset.grantId); if (!grant) return; const next = grant.status === '启用' ? '已禁用' : '启用'; const apply = () => { grant.status = next; addAudit('bank.grant.status', 'grant', grant.id, bankById(grant.bankId)?.name || grant.bankId, '高', `授权规则状态更新为 ${next},租户快照保留。`); saveData(); render(); showToast('授权状态已更新'); }; const execute = () => platformApi?.isEnabled() ? executeBackendMutation('PlatformAdminQuestionBanksController_upsertGrant', { ...grant, status: next }, apply) : apply(); if (next === '已禁用') confirmAction({ title: '禁用公共题库授权', description: `${bankById(grant.bankId)?.name || grant.bankId} · ${grant.id}`, impact: '后续同步将停止,但租户已经采用的内容快照不会被删除。', confirmLabel: '确认禁用' }, execute); else execute(); return; } if (action === 'preview-bank-sync') { state.bankTab = 'sync'; if (platformApi?.isEnabled()) { runContractRead({ key: 'bank-sync', actionId: 'act.p-09.05', operationId: 'PlatformAdminQuestionBanksController_syncStatus', title: '采用同步状态', suppliedRequestId: actionElement.dataset.contractRequestId, liveRequest: () => platformApi.request('PlatformAdminQuestionBanksController_syncStatus', { query: { limit: 200 } }), apply: payload => { if (Array.isArray(payload?.items)) data.bankSync = payload.items.map(platformApi.adapters.sync); return `${data.bankSync.length} 条采用记录,${data.bankSync.filter(item => item.status === '同步失败').length} 条失败`; } }); return; } const task = addContractTask({ actionId: 'act.p-09.05', operationId: 'PlatformAdminQuestionBanksController_syncStatus', label: '检查租户题库采用与同步状态', resultTarget: '公共题库 · 采用与同步', suppliedRequestId: actionElement.dataset.contractRequestId, execute: taskItem => { taskItem.rows = data.bankSync.length; taskItem.failedRows = data.bankSync.filter(item => item.status === '同步失败').length; } }); render(); showToast(`同步状态检查任务 ${task.id} 已创建`); return; } if (action === 'refresh-bank-sync') { if (state.modal === 'syncDetail') closeModal({ force: true }); runContractRead({ key: 'bank-sync', operationId: 'PlatformAdminQuestionBanksController_syncStatus', title: '采用同步状态', liveRequest: () => platformApi.request('PlatformAdminQuestionBanksController_syncStatus', { query: { limit: 200 } }), apply: payload => { if (Array.isArray(payload?.items)) data.bankSync = payload.items.map(platformApi.adapters.sync); return `${data.bankSync.length} 条采用记录,${data.bankSync.filter(item => item.status === '同步失败').length} 条失败`; } }); return; } if (action === 'sync-detail') { openModal('syncDetail', { syncKey: actionElement.dataset.syncKey }); return; } if (action === 'new-staff') { if (requireDesktop('新建平台员工与授权')) return; openModal('staff', { roleKey: actionElement.dataset.roleKey || '' }); return; } if (action === 'edit-staff') { if (requireDesktop('编辑平台员工与授权')) return; openModal('staff', { staffId: actionElement.dataset.staffId }); return; } if (action === 'toggle-staff') { if (requireDesktop('变更平台员工状态')) return; if (!canWrite('platform:staff:status')) return; const person = data.staff.find(item => item.id === actionElement.dataset.staffId); if (!person) return; const disabling = person.status === '正常'; if (disabling && person.role === '超级管理员' && data.staff.filter(item => item.role === '超级管理员' && item.status === '正常').length === 1) { showToast('不能禁用最后一名超级管理员', 'error'); return; } const apply = () => { person.status = disabling ? '已停用' : '正常'; addAudit('staff.status.update', 'staff', person.id, person.name, '高', `${disabling ? '停用并撤销会话' : '启用'}平台员工。`); saveData(); render(); showToast(`员工已${disabling ? '停用并撤销会话' : '启用'}`); }; const execute = () => platformApi?.isEnabled() ? executeBackendMutation('PlatformAdminOverviewController_updateStaffStatus', { staffId: person.id, status: disabling ? 'disabled' : 'active', reason: disabling ? '平台控制台停用员工' : '平台控制台启用员工', revokeSessions: disabling }, apply) : apply(); if (disabling) confirmAction({ title: '停用平台员工', description: `${person.name} · ${person.role}`, impact: '该员工的全部平台会话将立即撤销,后续无法登录平台端;租户端权限不在本域名内处理。', confirmLabel: '停用并撤销会话', confirmActionName: 'execute-staff-disable' }, execute); else execute(); return; } if (action === 'select-permission-group') { const form = overlayRoot.querySelector('#staffForm'); const role = fieldValue(form, 'role'); const recommended = role === '平台财务' ? ['platform:billing:read', 'platform:billing:write', 'platform:billing:payment', 'platform:billing:dunning', 'platform:billing:notification', 'platform:usage:read', 'platform:usage:write'] : role === '安全审计' ? ['platform:audit:read', 'platform:audit:export', 'platform:audit:alert', 'platform:audit:notification'] : role === '题库运营' ? ['platform:question_bank:read', 'platform:question_bank:grant', 'platform:question_bank:ops'] : role === '租户运营' ? ['platform:tenant:read', 'platform:tenant:write', 'platform:tenant:status', 'platform:tenant:billing_profile'] : Array.from(form.querySelectorAll('[name="permissions"]')).map(input => input.value); form.querySelectorAll('[name="permissions"]').forEach(input => { input.checked = recommended.includes(input.value); }); return; } if (action === 'clear-staff-form') { const form = overlayRoot.querySelector('#staffForm'); if (!form) return; form.querySelectorAll('input:not([type="hidden"]):not([type="checkbox"])').forEach(input => { input.value = ''; }); form.querySelectorAll('input[type="checkbox"]').forEach(input => { input.checked = false; }); form.elements.role.value = '租户运营'; state.modalData.dirty = true; form.querySelector('[name="authUserId"]')?.focus(); return; } if (action === 'apply-staff-search') { render(); return; } if (action === 'export-audit') { if (!hasPermission('platform:audit:export')) { showToast('缺少导出权限:platform:audit:export', 'error'); return; } const format = actionElement.dataset.format || 'csv'; if (platformApi?.isEnabled()) { platformApi.request('PlatformAdminAuditController_exportLogs', { query: { format, q: state.auditSearch || undefined, limit: 500 } }).then(payload => { const item = platformApi.downloadExport(payload); showToast(`审计文件已下载 · ${item.rowCount ?? item.rows ?? 0} 行 · ${item.sha256 || item.hash || 'hash 见响应'}`); }).catch(error => showToast(`${error.message || '审计导出失败'}${error.requestId ? ` · ${error.requestId}` : ''}`, 'error')); return; } const task = addTask(`导出平台审计日志 ${format.toUpperCase()}`, '任务中心下载文件', taskItem => { taskItem.file = `platform-audit-${Date.now()}.${format}`; taskItem.hash = `sha256:${Math.random().toString(36).slice(2).padEnd(32, '0')}`; taskItem.rows = data.auditLogs.length; }); showToast(`导出任务 ${task.id} 已创建`); return; } if (action === 'jump-related-alert') { state.alertStatus = 'all'; state.selectedAlert = data.alerts.find(alert => data.auditLogs.some(log => log.targetId === alert.targetId || log.targetId === alert.id))?.id || ''; navigate('alerts'); if (state.selectedAlert) openModal('alertDetail', { alertId: state.selectedAlert }); return; } if (action === 'jump-audit-target') { const log = data.auditLogs.find(item => item.id === actionElement.dataset.auditId); closeModal(); if (log.targetType === 'tenant') { state.selectedTenant = log.targetId; navigate('tenantDetail'); } else if (log.targetType === 'invoice') { state.selectedInvoice = log.targetId; navigate('billing'); } else if (log.targetType === 'question_bank' || log.targetType === 'grant') navigate('bank'); else navigate('alerts'); return; } if (action === 'transition-alert') { if (!canWrite('platform:audit:alert')) return; const alert = data.alerts.find(item => item.id === actionElement.dataset.alertId); const next = actionElement.dataset.nextStatus; if (!alert) return; if (!['open', 'acknowledged'].includes(alert.status)) { showToast('已结束告警禁止重新打开或再次处理', 'error'); return; } if (alert.status === 'open' && !['acknowledged', 'resolved', 'ignored'].includes(next)) return; if (alert.status === 'acknowledged' && !['resolved', 'ignored'].includes(next)) return; const note = document.querySelector('#alertNote')?.value.trim() || ''; const apply = () => { alert.status = next; alert.note = note; alert.updatedAt = '刚刚'; addAudit('audit.alert.status.update', 'audit_alert', alert.id, alert.title, '高', `告警状态更新为 ${alertStatusLabel(next)}${note ? `,备注:${note}` : ''}。`); clearDirty('modal'); saveData(); closeModal({ force: true }); render(); showToast(`告警已${alertStatusLabel(next)}`); }; if (platformApi?.isEnabled()) executeBackendMutation('PlatformAdminAuditController_updateAlert', { alertId: alert.id, status: next, note }, apply); else apply(); return; } if (action === 'new-audit-channel') { if (requireDesktop('新建审计通知渠道')) return; openModal('auditChannel'); return; } if (action === 'edit-audit-channel') { if (requireDesktop('编辑审计通知渠道')) return; openModal('auditChannel', { channelId: actionElement.dataset.channelId }); return; } if (action === 'toggle-audit-channel') { if (requireDesktop('变更审计通知渠道状态')) return; if (!canWrite('platform:audit:notification')) return; const channel = data.auditChannels.find(item => item.id === actionElement.dataset.channelId); if (!channel) return; if (platformApi?.isEnabled() && channel.endpointRedacted) { openModal('auditChannel', { channelId: channel.id }); showToast('后端不回显完整 Webhook;请重新输入完整 HTTPS URL 后保存状态', 'error'); return; } const nextEnabled = !channel.enabled; const apply = () => { channel.enabled = nextEnabled; addAudit('audit.channel.status', 'notification_channel', channel.id, channel.name, '高', `审计告警渠道${channel.enabled ? '启用' : '停用'}。`); saveData(); render(); showToast('审计通知渠道状态已更新'); }; if (platformApi?.isEnabled()) executeBackendMutation('PlatformAdminAuditController_upsertChannel', { ...channel, enabled: nextEnabled }, apply); else apply(); return; } if (action === 'retry-notification') { if (platformApi?.isEnabled()) { showToast('后端未提供通知事件重试接口,真实模式禁止伪造成功状态', 'error'); return; } const notification = [...(data.notificationEvents || []), ...(data.dunningEvents || [])].find(item => item.id === actionElement.dataset.eventId); if (!notification) return; notification.status = '重试中'; notification.attempts += 1; notification.updatedAt = `${todayTime()} CST`; saveData(); render(); setTimeout(() => { notification.status = '成功'; notification.error = ''; saveData(); render(); showToast('通知重试成功'); }, 800); return; } if (action === 'retry-task') { const task = data.tasks.find(item => item.id === actionElement.dataset.taskId); task.status = 'processing'; task.error = ''; task.updatedAt = todayTime(); saveData(); renderOverlay(); setTimeout(() => { task.status = 'completed'; task.updatedAt = todayTime(); saveData(); renderOverlay(); updateCounters(); showToast('任务重试完成'); }, 900); return; } }); document.addEventListener('input', event => { if (state.modal && event.target.closest('#overlayRoot') && event.target.matches('input,select,textarea')) markDirty('modal'); if (event.target.closest('#mainView form')) markDirty('page'); if (event.target.id === 'tenantSearch') { state.tenantSearch = event.target.value; clearTimeout(state.searchTimer); state.searchTimer = setTimeout(render, 180); } if (event.target.id === 'invoiceSearch') state.invoiceSearch = event.target.value; if (event.target.id === 'bankSearch') state.bankSearch = event.target.value; if (event.target.id === 'staffSearch') { state.staffSearch = event.target.value; clearTimeout(state.staffTimer); state.staffTimer = setTimeout(render, 180); } if (event.target.id === 'auditSearch') { state.auditSearch = event.target.value; clearTimeout(state.auditTimer); state.auditTimer = setTimeout(render, 180); } if (event.target.id === 'globalSearch') state.globalSearch = event.target.value; if (['tenantSearch', 'invoiceSearch', 'bankSearch', 'staffSearch', 'auditSearch'].includes(event.target.id)) syncNavigationState(); }); document.addEventListener('change', event => { if (state.modal && event.target.closest('#overlayRoot') && event.target.matches('input,select,textarea')) markDirty('modal'); if (event.target.closest('#mainView form')) markDirty('page'); if (event.target === roleSelect) { state.role = event.target.value; state.scenario = 'normal'; if (!hasPermission(pageMeta[state.view].permission)) state.view = 'overview'; render(); showToast(`已切换为${roleDefinitions[state.role].label}`); } if (event.target === scenarioSelect) { state.scenario = event.target.value; render(); } if (event.target === responseSelect) { state.response = event.target.value; showToast('下一次 Mock 写请求结果已设置'); } if (event.target.id === 'tenantPlanFilter') { state.tenantPlan = event.target.value; syncNavigationState(); render(); } if (event.target.id === 'invoiceStatusFilter') { state.invoiceStatus = event.target.value; syncNavigationState(); } if (event.target.id === 'usagePeriodFilter') { state.usagePeriod = event.target.value; syncNavigationState(); render(); } if (event.target.id === 'auditRangeFilter') { state.auditRange = event.target.value; syncNavigationState(); render(); } if (event.target.id === 'includeTenantBanks') { state.bankIncludeTenant = event.target.checked; syncNavigationState(); render(); if (platformApi?.isEnabled()) hydrateFromBackend({ silent: true }); } }); menuButton.addEventListener('click', toggleMobileMenu); document.querySelector('#sidebarBackdrop').addEventListener('click', closeMobileMenu); document.addEventListener('keydown', event => { if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { event.preventDefault(); document.querySelector('#globalSearch').focus(); } if (event.key === 'Enter' && event.target.id === 'globalSearch') { event.preventDefault(); const query = event.target.value.trim().toLowerCase(); if (!query) { showToast('请输入租户、账单、告警或审计关键词', 'error'); return; } const tenant = data.tenants.find(item => [item.name, item.slug, item.legal, item.host].some(value => value.toLowerCase().includes(query))); if (tenant) { state.selectedTenant = tenant.id; navigate('tenantDetail'); showToast(`已定位租户:${tenant.name}`); return; } const invoice = data.invoices.find(item => [item.id, item.type, item.period, item.note].some(value => String(value).toLowerCase().includes(query))); if (invoice) { state.selectedTenant = invoice.tenantId; state.selectedInvoice = invoice.id; navigate('billing'); showToast(`已定位账单:${invoice.id}`); return; } const alert = data.alerts.find(item => [item.id, item.title, item.summary].some(value => value.toLowerCase().includes(query))); if (alert) { state.selectedAlert = alert.id; navigate('alerts'); openModal('alertDetail', { alertId: alert.id }); return; } const audit = data.auditLogs.find(item => [item.id, item.actor, item.action, item.target, item.details].some(value => value.toLowerCase().includes(query))); if (audit) { navigate('audit'); openModal('auditDetail', { auditId: audit.id }); return; } showToast('没有找到匹配的租户、账单、告警或审计事件', 'error'); } if (event.key === 'Tab' && state.modal) { const modal = overlayRoot.querySelector('.modal'); const focusable = modal ? Array.from(modal.querySelectorAll('button:not([disabled]),input:not([disabled]):not([type="hidden"]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])')).filter(element => element.offsetParent !== null) : []; if (focusable.length) { const first = focusable[0]; const last = focusable[focusable.length - 1]; if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } } } if (event.key === 'Escape') { if (state.modal) closeModal(); else if (app.classList.contains('menu-open')) closeMobileMenu(); else if (pageMeta[state.view]?.level > 1) navigateBack(); } }); document.addEventListener('click', event => { const choice = event.target.closest('[data-leave-choice]')?.dataset.leaveChoice; if (!choice) return; event.preventDefault(); event.stopImmediatePropagation(); if (choice === 'stay') { pendingLeave = null; closeLeaveGuard(); return; } const leave = pendingLeave; pendingLeave = null; clearDirty(leave?.scope || ''); closeLeaveGuard(); leave?.execute?.(); }, true); window.addEventListener('beforeunload', event => { if (!hasUnsavedChanges()) return; event.preventDefault(); event.returnValue = ''; }); window.addEventListener('DOMContentLoaded', async () => { if (platformApi?.isEnabled()) { const authenticated = await window.GongxuePlatformAuth?.requireSession(); if (!authenticated) return; document.documentElement.dataset.platformMode = 'api'; } setupPrototypeControls(); if (platformApi?.isEnabled()) document.querySelector('.prototype-controls')?.setAttribute('hidden', ''); render(); const rootSnapshot = navigationSnapshot(); history.replaceState({ [HISTORY_APP_KEY]: true, rootGuard: true, sequence: historySequence, snapshot: rootSnapshot }, '', location.href); historySequence += 1; history.pushState({ [HISTORY_APP_KEY]: true, sequence: historySequence, snapshot: rootSnapshot }, '', location.href); if (platformApi?.isEnabled()) hydrateFromBackend({ silent: true }); });