@@ -17,9 +17,12 @@ const HISTORY_APP_KEY = 'gongxuePlatform';
const API _CONNECTED _PAGES = new Set ( [
'overview' ,
'tenants' ,
'domains' ,
'tenantDetail' ,
'billingProfile' ,
'subscriptions' ,
'catalog' ,
'saasFinance' ,
'billing' ,
'usage' ,
'dunning' ,
@@ -28,6 +31,7 @@ const API_CONNECTED_PAGES = new Set([
'sms' ,
'paymentSettings' ,
'staff' ,
'roles' ,
'audit' ,
'alerts' ,
] ) ;
@@ -35,37 +39,41 @@ const API_CONNECTED_PAGES = new Set([
const pageMeta = {
overview : { id : 'P-01' , title : '平台经营工作台' , permission : 'platform:overview:read' } ,
tenants : { id : 'P-02' , title : '租户列表与创建' , permission : 'platform:tenant:read' } ,
domains : { id : 'P-02A' , title : '租户域名与证书' , permission : 'platform:tenant:read' } ,
tenantDetail : { id : 'P-03' , title : '租户详情与生命周期' , permission : 'platform:tenant:read' , level : 2 } ,
billingProfile : { id : 'P-04' , title : '租户账务资料' , permission : 'platform:tenant:billing_profile' , level : 3 } ,
subscriptions : { id : 'P-05' , title : 'SaaS 套餐与订阅' , permission : 'platform:plan:read' } ,
catalog : { id : 'P-05A' , title : 'SaaS 商品与版本' , permission : 'platform:plan:read' } ,
saasFinance : { id : 'P-05B' , title : 'SaaS 订单与交易' , permission : 'platform:billing:read' } ,
billing : { id : 'P-06' , title : '服务费账单' , permission : 'platform:billing:read' } ,
usage : { id : 'P-07' , title : '用量与超额计费' , permission : 'platform:usage:read' } ,
dunning : { id : 'P-08' , title : '收款、逾期与催缴' , permission : 'platform:billing:read' } ,
bank : { id : 'P-09' , title : '公共题库目录与授权 ' , permission : 'platform:question_ bank:read ' } ,
bank : { id : 'P-09' , title : '公共题库' , permission : 'platform:question- bank:manage ' } ,
crm : { id : 'P-10' , title : 'CRM 接入与线索流转' , permission : 'platform:crm:read' } ,
sms : { id : 'P-11' , title : 'SMS 短信服务' , permission : 'platform:sms:read' } ,
paymentSettings : { id : 'P-12' , title : '支付设置' , permission : 'platform:payment:read' } ,
staff : { id : 'P-13' , title : '平台员工与权限' , permission : 'platform:staff:read' } ,
roles : { id : 'P-13A' , title : '平台角色与功能' , permission : 'platform:staff:read' } ,
audit : { id : 'P-14' , title : '平台审计日志' , permission : 'platform:audit:read' } ,
alerts : { id : 'P-15' , title : '审计告警与通知' , permission : 'platform:audit:alert' } ,
} ;
const navGroups = [
[ '平台概览' , [ [ 'overview' , 'gauge' , '经营工作台' ] ] ] ,
[ '租户管理' , [ [ 'tenants' , 'building-2' , '租户列表' ] ] ] ,
[ 'SaaS 账务' , [ [ 'subscriptions' , 'badge-check' , '套餐与订阅 ' ] , [ 'billing' , 'receipt-text' , '服务费账单' ] , [ 'usage' , 'chart-column-big' , '用量与超额' ] , [ 'dunning' , 'bell-ring' , '收款与催缴' ] ] ] ,
[ '租户管理' , [ [ 'tenants' , 'building-2' , '租户列表' ] , [ 'domains' , 'globe-2' , '域名与证书' ] ]] ,
[ 'SaaS 商品与 账务' , [ [ 'catalog' , 'packages' , '商品与版本' ] , [ 'subscriptions' , 'badge-check' , '租户订阅' ] , [ 'saasFinance' , 'landmark' , '订单与交易 ' ] , [ 'billing' , 'receipt-text' , '服务费账单' ] , [ 'usage' , 'chart-column-big' , '用量与超额' ] , [ 'dunning' , 'bell-ring' , '收款与催缴' ] ] ] ,
[ '平台资产' , [ [ 'bank' , 'database-zap' , '公共题库' ] ] ] ,
[ '租户能力' , [ [ 'crm' , 'handshake' , 'CRM 接入' ] , [ 'sms' , 'message-square-text' , '短信服务' ] , [ 'paymentSettings' , 'credit-card' , '支付设置' ] ] ] ,
[ '安全治理' , [ [ 'staff' , 'user-cog' , '员工与权限 ' ] , [ 'audit' , 'scroll-text' , '审计日志' ] , [ 'alerts' , 'shield-alert' , '告警与通知' ] ] ] ,
[ '安全治理' , [ [ 'staff' , 'user-cog' , '平台员工' ] , [ 'roles' , 'shield-check' , '角色与功能 ' ] , [ 'audit' , 'scroll-text' , '审计日志' ] , [ 'alerts' , 'shield-alert' , '告警与通知' ] ] ] ,
] ;
const roleDefinitions = {
super : { label : '超级管理员' , permissions : [ '*' ] } ,
tenant _ops : { label : '租户运营' , permissions : [ 'platform:overview:read' , 'platform:tenant:read' , 'platform:tenant:write' , 'platform:tenant:status' , 'platform:tenant:billing_profile' , 'platform:plan:read' , 'platform:billing:read' , 'platform:question_bank:read' , 'platform:crm:read' , 'platform:crm:write' , 'platform:sms:read' , 'platform:sms:write' ] } ,
tenant _ops : { label : '租户运营' , permissions : [ 'platform:overview:read' , 'platform:tenant:read' , 'platform:tenant:write' , 'platform:tenant:status' , 'platform:tenant:billing_profile' , 'platform:plan:read' , 'platform:billing:read' , 'platform:crm:read' , 'platform:crm:write' , 'platform:sms:read' , 'platform:sms:write' ] } ,
finance : { label : '平台财务' , permissions : [ 'platform:overview:read' , 'platform:tenant:read' , 'platform:tenant:billing_profile' , 'platform:plan:read' , 'platform:billing:read' , 'platform:billing:write' , 'platform:billing:payment' , 'platform:billing:dunning' , 'platform:billing:notification' , 'platform:usage:read' , 'platform:usage:write' , 'platform:payment:read' , 'platform:payment:write' , 'platform:audit:read' ] } ,
audit : { label : '安全审计' , permissions : [ 'platform:overview:read' , 'platform:audit:read' , 'platform:audit:export' , 'platform:audit:alert' , 'platform:audit:notification' ] } ,
bank _ops : { label : '题库运营' , permissions : [ 'platform: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:crm:read' , 'platform:sms:read' , 'platform:payment:read' , 'platform:audit:read' ] } ,
bank _ops : { label : '题库运营' , permissions : [ 'platform:dashboard:view ' , 'platform:question- bank:manage ' ] } ,
readonly : { label : '只读观察员' , permissions : [ 'platform:overview:read' , 'platform:tenant:read' , 'platform:plan:read' , 'platform:billing:read' , 'platform:crm:read' , 'platform:sms:read' , 'platform:payment:read' , 'platform:audit:read' ] } ,
} ;
const seed = {
@@ -78,6 +86,13 @@ const seed = {
{ id : 'tenant_xuezhi_heb' , slug : 'xuezhi-heb' , name : '学知专接本 · 河北' , legal : '石家庄学知教育有限公司' , brand : '学知专接本' , shortName : '学知河北' , host : 'app.xuezhizjb.cn' , plan : '专业版' , planCode : 'pro' , status : '正常' , billing : '正常' , mode : '正式' , students : 3160 , questions : 39860 , storageGB : 164 , trafficTB : 2.1 , expires : '2027-01-08' , balance : 0 , owner : '赵老师' , ownerPhone : '137****6116' , createdAt : '2025-01-08 08:40' } ,
{ id : 'tenant_youcai_js' , slug : 'youcai-js' , name : '优才专转本 · 江苏' , legal : '南京优才教育科技有限公司' , brand : '优才专转本' , shortName : '优才江苏' , host : 'study.youcai.cn' , plan : '专业版' , planCode : 'pro' , status : '待配置' , billing : '正常' , mode : '试用' , students : 0 , questions : 0 , storageGB : 0 , trafficTB : 0 , expires : '' , balance : 0 , owner : '顾老师' , ownerPhone : '138****1120' , createdAt : '2026-07-22 16:08' } ,
] ,
domains : [ ] ,
saasCatalog : { features : [ ] , limitDefinitions : [ ] , offerings : [ ] , versions : [ ] } ,
saasOrders : [ ] ,
saasRefunds : [ ] ,
paymentChannels : [ ] ,
rebateSummary : { } ,
crmLogs : [ ] ,
billingProfiles : {
tenant _gongxue _tj : { billingName : '天津恭学教育科技有限公司' , taxId : '91120101MA07GX2026' , contactName : '李会计' , phone : '13800138000' , email : 'finance@gongxue.cn' , billingAddress : '天津市南开区科研西路 12 号' , invoiceTitle : '天津恭学教育科技有限公司' , type : '增值税专用发票' , bankName : '招商银行天津南开支行' , bankAccountMasked : '**** **** **** 8826' } ,
tenant _qihang _gd : { billingName : '广州启航教育咨询有限公司' , taxId : '91440101MA5QH2026X' , contactName : '黄会计' , phone : '13900139000' , email : 'billing@qihangzsb.cn' , billingAddress : '广州市天河区科韵路 18 号' , invoiceTitle : '广州启航教育咨询有限公司' , type : '增值税普通发票' , bankName : '中国银行广州天河支行' , bankAccountMasked : '**** **** **** 6672' } ,
@@ -100,6 +115,7 @@ const seed = {
{ id : 'INV-202607-0016' , tenantId : 'tenant_gongxue_tj' , type : '订阅服务费' , period : '2026年7月' , total : 16800 , paid : 16800 , balance : 0 , due : '2026-07-15' , status : '已支付' , note : '2026年7月服务费' } ,
{ id : 'INV-202607-0015' , tenantId : 'tenant_xuezhi_heb' , type : '订阅服务费' , period : '2026年7月' , total : 9800 , paid : 9800 , balance : 0 , due : '2026-07-15' , status : '已支付' , note : '2026年7月服务费' } ,
] ,
billingPayments : [ ] ,
usage : [
{ tenantId : 'tenant_gongxue_tj' , period : '2026-07' , source : 'Worker 自动采集' , students : 2846 , questions : 42680 , storageGB : 186 , trafficTB : 1.8 , overage : [ ] , estimated : 0 } ,
{ tenantId : 'tenant_qihang_gd' , period : '2026-07' , source : 'Worker 自动采集' , students : 5280 , questions : 68420 , storageGB : 248 , trafficTB : 3.2 , overage : [ '流量' ] , estimated : 7820 } ,
@@ -120,15 +136,12 @@ const seed = {
{ id : 'QB-EN' , name : '公共英语能力库' , catalog : '全国 / 公共英语' , source : '平台自建' , questions : 86640 , version : 'v2026.6' , status : '稳定版' } ,
{ id : 'QB-SD' , name : '山东专升本全科' , catalog : '山东专升本' , source : '合作内容方' , questions : 52360 , version : 'v2026.7' , status : '稳定版' } ,
] ,
grant s: [
{ id : 'GRANT-001' , b ankId : '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 : '' , localEdi t : 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 : '同步失败' } ,
bankNode s: [
{ id : 'NODE-MATH' , questionB ankId : 'QB-TJ-MATH' , parentId : null , name : '高等数学' , nodeType : 'subject' , depth : 0 , sortOrder : 0 , isActive : true , questionCount : 12860 } ,
{ id : 'NODE-MATH-01' , questionBankId : 'QB-TJ-MATH' , parentId : 'NODE-MATH' , name : '函数、极限与连续' , nodeType : 'chapter' , depth : 1 , sortOrder : 0 , isActive : true , questionCoun t : 1260 } ,
] ,
bankQuestions : [ ] ,
bankImportResult : null ,
crmConfigs : [
{ id : 'CRM-GX-TJ' , tenantId : 'tenant_gongxue_tj' , provider : '标准 CRM' , endpoint : 'https://crm.gongxue.cn/open/leads' , assignmentMode : '按地区轮转' , pool : '天津升本线索池' , enabled : true , status : '正常' , timeout : 8 , retry : 3 , updatedAt : '2026-07-23 09:12' } ,
{ id : 'CRM-QH-GD' , tenantId : 'tenant_qihang_gd' , provider : '自建 CRM' , endpoint : 'https://crm.qihangzsb.cn/api/leads' , assignmentMode : '按校区容量' , pool : '广东转化队列' , enabled : true , status : '异常' , timeout : 10 , retry : 5 , updatedAt : '2026-07-22 18:40' } ,
@@ -159,9 +172,33 @@ const seed = {
{ id : 'PEVT-260723-029' , appId : 'PAY-QH-ALI' , orderNo : 'ORD-260723-2021' , amount : 1280 , status : '回调失败' , channel : '支付宝 WAP' , notified : '重试中' , updatedAt : '2026-07-23 09:49' } ,
{ id : 'PEVT-260722-188' , appId : 'PAY-BS-MANUAL' , orderNo : 'ORD-260722-1098' , amount : 99 , status : '待确认' , channel : '线下转账' , notified : '无' , updatedAt : '2026-07-22 20:18' } ,
] ,
platformPermissions : {
'platform:dashboard:view' : '平台总览' ,
'platform:tenant:manage' : '平台租户管理' ,
'platform:staff:manage' : '平台员工管理' ,
'platform:role:manage' : '平台角色权限管理' ,
'platform:question-bank:manage' : '平台公共题库运营' ,
'platform:audit:view' : '平台审计查询' ,
'platform:billing:notification' : '平台账务通知' ,
'platform:saas-catalog:manage' : 'SaaS 商品目录管理' ,
'platform:saas-billing:manage' : 'SaaS 订单与账务管理' ,
'platform:crm:read' : 'CRM 配置查询' ,
'platform:crm:write' : 'CRM 配置管理' ,
'platform:sms:read' : '短信服务查询' ,
'platform:sms:write' : '短信服务管理' ,
'platform:payment:read' : '支付设置查询' ,
'platform:payment:write' : '支付设置管理' ,
} ,
platformRoles : [
{ id : 'role-super' , code : 'platform_super_admin' , name : '超级管理员' , description : '拥有全部平台管理权限' , permissionCodes : [ '*' ] } ,
{ id : 'role-tenant' , code : 'platform_tenant_operator' , name : '租户运营' , description : '负责租户开通与生命周期管理' , permissionCodes : [ 'platform:dashboard:view' , 'platform:tenant:manage' , 'platform:crm:read' , 'platform:crm:write' ] } ,
{ id : 'role-finance' , code : 'platform_finance' , name : '平台财务' , description : '负责 SaaS 商品、订单、账务与支付' , permissionCodes : [ 'platform:dashboard:view' , 'platform:saas-catalog:manage' , 'platform:saas-billing:manage' , 'platform:billing:notification' , 'platform:payment:read' , 'platform:payment:write' ] } ,
{ id : 'role-audit' , code : 'platform_auditor' , name : '安全审计' , description : '查询审计日志与处理安全告警' , permissionCodes : [ 'platform:dashboard:view' , 'platform:audit:view' ] } ,
{ id : 'role-bank' , code : 'platform_question_bank_operator' , name : '题库运营' , description : '负责公共题库结构、题目与导入' , permissionCodes : [ 'platform:dashboard:view' , 'platform:question-bank:manage' ] } ,
] ,
staff : [
{ id : 'STAFF-01' , authU serId: '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-01' , u serId: 'STAFF-01' , roleIds : [ 'role-super' ] , roleCodes : [ 'platform_super_admin' ] , name : '陈浩' , email : 'chenhao@gongxue.cn' , phone : '138****6600' , role : '超级管理员' , permissions : [ '*' ] , status : '正常' , lastActive : '刚刚' } ,
{ id : 'STAFF-02' , authUserId : 'auth-zhoumin' , username : 'zhoumin' , name : '周敏' , email : 'zhoumin@gongxue.cn' , phone : '139****2188' , role : '题库运营' , permissions : [ 'platform:question- bank:manage ' ] , status : '正常' , lastActive : '8分钟前' } ,
{ id : 'STAFF-03' , authUserId : 'auth-liujia' , username : 'liujia' , name : '刘佳' , email : 'liujia@gongxue.cn' , phone : '136****9028' , role : '平台财务' , permissions : [ 'platform:billing:read' , 'platform:billing:write' , 'platform:billing:payment' , 'platform:usage:read' , 'platform:usage:write' ] , status : '正常' , lastActive : '21分钟前' } ,
{ id : 'STAFF-04' , authUserId : 'auth-wanglei' , username : 'wanglei' , name : '王磊' , email : 'wanglei@gongxue.cn' , phone : '135****2086' , role : '租户运营' , permissions : [ 'platform:tenant:read' , 'platform:tenant:write' , 'platform:tenant:status' ] , status : '正常' , lastActive : '1小时前' } ,
{ id : 'STAFF-05' , authUserId : 'auth-sunchen' , username : 'sunchen' , name : '孙晨' , email : 'sunchen@gongxue.cn' , phone : '137****1118' , role : '安全审计' , permissions : [ 'platform:audit:read' , 'platform:audit:alert' ] , status : '已停用' , lastActive : '3天前' } ,
@@ -169,7 +206,7 @@ const seed = {
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.upda te' , targetType : 'gr ant ' , targetId : 'GRANT-001 ' , target : '高数公共题库 v2026.7' , severity : '中' , result : '成功' , ip : '172.16.3.16' , details : '授权范围更新为 2 家指定租户 。' } ,
{ id : 'AUD-20260723-085844' , time : '2026-07-23 08:58:44 CST' , actor : '周敏' , role : '题库运营' , action : 'platform.question_bank.import_execu ted ' , targetType : 'question_b ank ' , targetId : 'QB-TJ-MATH ' , target : '高数公共题库 v2026.7' , severity : '中' , result : '成功' , ip : '172.16.3.16' , details : '结构化 JSON 导入完成,新增 8 个内容节点和 126 道题 。' } ,
{ id : 'AUD-20260723-084127' , time : '2026-07-23 08:41:27 CST' , actor : '刘佳' , role : '平台财务' , action : 'invoice.payment.confirm' , targetType : 'invoice' , targetId : 'INV-202607-0016' , target : 'INV-202607-0016' , severity : '高' , result : '成功' , ip : '172.16.8.12' , details : '人工确认收款 ¥16,800, 流水号已脱敏。' } ,
{ id : 'AUD-20260723-081209' , time : '2026-07-23 08:12:09 CST' , actor : 'unknown' , role : '-' , action : 'auth.login.failed' , targetType : 'application' , targetId : 'platform-console' , target : 'platform-console' , severity : '严重' , result : '失败' , ip : '45.62.18.9' , details : '5 分钟内连续登录失败 12 次。' } ,
{ id : 'AUD-20260720-163510' , time : '2026-07-20 16:35:10 CST' , actor : '周敏' , role : '题库运营' , action : 'bank.catalog.read' , targetType : 'question_bank' , targetId : 'QB-SD' , target : '山东专升本全科' , severity : '中' , result : '成功' , ip : '172.16.3.16' , details : '查看公共题库版本与租户采用状态。' } ,
@@ -177,14 +214,14 @@ const seed = {
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-20260723-004' , title : '公共题库导入 失败' , summary : '结构化 JSON 文件存在 12 个稳定编码 冲突' , severity : '中' , status : 'open' , owner : '题库运营' , targetType : 'question_bank' , targetId : 'QB-EN' , evidence : [ '文件 版本 2.0 ' , '目标题库 公共英语能力库 ' , '冲突 12 条' ] , updatedAt : '42分钟前' , note : '' } ,
{ id : 'ALT-20260722-019' , title : '存储用量告警' , summary : '启航专升本存储用量达到套餐 92%' , severity : '中' , status : 'open' , owner : '租户运营' , targetType : 'tenant' , targetId : 'tenant_qihang_gd' , evidence : [ '当前 460 GB' , '套餐上限 500 GB' ] , updatedAt : '昨天' , note : '' } ,
{ id : 'ALT-20260722-011' , title : 'Provider 密钥即将过期' , summary : '优才专转本支付 Provider 密钥 7 天后到期' , severity : '高' , status : 'resolved' , owner : '平台运维' , targetType : 'tenant' , targetId : 'tenant_youcai_js' , evidence : [ '密钥只写不可回显' , '新密钥已于昨天更新' ] , updatedAt : '昨天' , note : '完成轮换。' } ,
] ,
alertRules : [
{ id : 'RULE-LOGIN-BURST' , code : 'LOGIN_FAILURE_BURST' , name : '平台登录失败突增' , severity : '严重' , enabled : true , window : '5 分钟' , threshold : '≥ 10 次' , target : '平台账号与来源 IP' } ,
{ id : 'RULE-INVOICE-OVERDUE' , code : 'INVOICE_OVERDUE' , name : '服务费账单逾期' , severity : '高' , enabled : true , window : '每日 09:00' , threshold : '逾期 ≥ 1 天' , target : '未结清平台账单' } ,
{ id : 'RULE-BANK-SYNC ' , code : 'QUESTION_BANK_SYNC _FAILED' , name : '公共题库同步 失败' , severity : '中' , enabled : true , window : '任务 结束时' , threshold : '失败 或冲突 ≥ 10' , target : '租户题库同步 任务' } ,
{ id : 'RULE-BANK-IMPORT ' , code : 'QUESTION_BANK_IMPORT _FAILED' , name : '公共题库导入 失败' , severity : '中' , enabled : true , window : '导入 结束时' , threshold : '无效记录 或冲突 ≥ 10' , target : '平台公共题库导入 任务' } ,
] ,
auditChannels : [
{ id : 'CH-SEC-MAIL' , code : 'security_email' , name : '平台安全邮箱' , provider : '邮件' , endpoint : 'security@gongxue.cn' , minSeverity : '中' , statusFilter : 'open,acknowledged' , timeout : 8 , maxAttempts : 3 , enabled : true , lastStatus : '正常' } ,
@@ -216,14 +253,32 @@ function normalizeData(saved) {
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 . crmConfigs = normalized . crmConfigs . map ( item => ( { enabled : false , status : '未启用' , retry : 0 , ... item } ) ) ;
normalized . crmLeads = normalized . crmLeads . map ( item => ( { attempts : 0 , lastError : '' , ... item } ) ) ;
normalized . smsChannels = normalized . smsChannels . map ( item => ( { enabled : false , quota : 0 , used : 0 , ... item } ) ) ;
normalized . smsTemplates = normalized . smsTemplates . map ( item => ( { lastSend : '-' , successRate : '-' , ... item } ) ) ;
normalized . paymentApps = normalized . paymentApps . map ( item => ( { mode : 'sandbox' , status : '未启用' , ... item } ) ) ;
normalized . paymentEvents = normalized . paymentEvents . map ( item => ( { notified : '无' , ... item } ) ) ;
normalized . staff = normalized . staff . map ( item => ( { p ermissions: [ ] , status : '正常' , lastActive : '尚未登录' , ... item } ) ) ;
normalized . platformPermissions = Object . fromEntries ( Object . entries ( normalized . platformP ermissions || { } ) . map ( ( [ code , name ] ) => [
code ,
seed . platformPermissions [ code ] || name ,
] ) ) ;
normalized . platformRoles = normalized . platformRoles . map ( item => item . code === 'platform_super_admin'
? { ... item , name : '超级管理员' , description : '拥有全部平台管理权限' }
: item ) ;
normalized . staff = normalized . staff . map ( item => {
const roleIds = item . roleIds ? . length ? item . roleIds : normalized . platformRoles . filter ( role => role . name === item . role ) . map ( role => role . id ) ;
const assignedRoles = normalized . platformRoles . filter ( role => roleIds . includes ( role . id ) ) ;
return {
permissions : assignedRoles . flatMap ( role => role . permissionCodes ) ,
status : '正常' ,
lastActive : '尚未登录' ,
... item ,
userId : item . userId || item . id ,
roleIds ,
roleCodes : item . roleCodes || assignedRoles . map ( role => role . code ) ,
} ;
} ) ;
normalized . alerts = normalized . alerts . map ( item => ( { evidence : [ ] , note : '' , status : 'open' , ... item } ) ) ;
normalized . tasks = normalized . tasks . map ( item => ( { status : 'failed' , error : '' , resultTarget : '任务中心' , updatedAt : item . createdAt || todayTime ( ) , ... item } ) ) ;
normalized . version = seed . version ;
@@ -252,15 +307,15 @@ let data = loadData();
if ( platformApi ? . isEnabled ( ) ) {
[
'tenants' , 'plans' , 'subscriptions' , 'invoices' , 'usage' , 'reminders' ,
'dunningChannels' , 'dunningEvents' , 'banks' , 'grant s' , 'bankSync ' , 'staff' ,
'dunningChannels' , 'dunningEvents' , 'banks' , 'bankNode s' , 'bankQuestions ' , 'staff' ,
'auditLogs' , 'alerts' , 'alertRules' , 'auditChannels' , 'notificationEvents' , 'tasks' ,
] . forEach ( key => { data [ key ] = [ ] ; } ) ;
data . billingProfiles = { } ;
}
const state = {
view : 'overview' , previousView : '' , selectedTenant : data . tenants [ 0 ] ? . id || '' , tenantFilter : 'all' , tenantPlan : 'all' , tenantSearch : '' , globalSearch : '' ,
billingTab : 'invoices' , bankTab : 'catalog' , bankIncludeTenant : false , selectedBank : data . banks [ 0 ] ? . id || '' ,
crmTab : 'configs' , smsTab : 'channels' , paymentTab : 'apps' ,
billingTab : 'invoices' , bankTab : 'structure' , selectedBank : data . banks [ 0 ] ? . id || '' , selectedBankNode : '' , bankQuestionPage : 1 , bankQuestionTotal : 0 ,
crmTab : 'configs' , smsTab : 'channels' , paymentTab : 'apps' , saasFinanceTab : 'orders' ,
invoiceSearch : '' , invoiceStatus : 'all' , bankSearch : '' , staffSearch : '' , usagePeriod : '2026-07' , auditRange : '24h' , auditSeverity : 'all' , auditSearch : '' , alertStatus : 'all' , selectedAlert : '' , selectedInvoice : '' ,
role : 'super' , scenario : 'normal' , response : 'success' , modal : '' , modalData : { } , submitting : '' , reading : '' , contractResults : { } , history : [ ] ,
apiConnection : platformApi ? . isEnabled ( ) ? 'connecting' : 'mock' , apiLoadedAt : '' , apiLoadedOperations : 0 , apiFailedOperations : 0 ,
@@ -357,10 +412,7 @@ async function hydrateFromBackend({ silent = false } = {}) {
state . apiConnection = 'connecting' ;
updateBackendStatus ( ) ;
try {
const result = await platformApi . bootstrap ( {
includeTenantBanks : state . bankIncludeTenant ,
period : state . usagePeriod ,
} ) ;
const result = await platformApi . bootstrap ( { period : state . usagePeriod } ) ;
const successful = Object . keys ( result . payloads ) . length ;
if ( ! successful ) throw Object . values ( result . errors ) [ 0 ] || new Error ( '没有平台接口成功返回' ) ;
applyBackendPatch ( platformApi . adaptBootstrap ( result . payloads , data ) ) ;
@@ -412,7 +464,32 @@ function entityId(prefix) { return `${prefix}-${Date.now().toString(36).toUpperC
function tenantById ( id ) { return data . tenants . find ( item => item . id === id ) ; }
function planByCode ( code ) { return data . plans . find ( item => item . code === code ) ; }
function invoiceById ( id ) { return data . invoices . find ( item => item . id === id ) ; }
function paymentForInvoice ( invoice ) { return data . billingPayments . find ( item => item . orderId && item . orderId === invoice ? . orderId && item . status === 'pending' ) ; }
function canConfirmInvoicePayment ( invoice ) { return ! platformApi ? . isEnabled ( ) || Boolean ( paymentForInvoice ( invoice ) ) ; }
function bankById ( id ) { return data . banks . find ( item => item . id === id ) ; }
function bankNodeById ( id ) { return data . bankNodes . find ( item => item . id === id ) ; }
function questionTypeLabel ( value ) { return ( { choice : '单选题' , multiple _choice : '多选题' , true _false : '判断题' , fill _blank : '填空题' , short _answer : '简答题' , reading : '阅读材料题' , programming : '编程题' } ) [ String ( value || '' ) . toLowerCase ( ) ] || value || '-' ; }
function bankNodeTypeLabel ( value ) { return ( { category : '分类' , subject : '科目' , chapter : '章节' , paper : '试卷' , custom : '自定义' } ) [ String ( value || '' ) . toLowerCase ( ) ] || value || '-' ; }
function adaptBankNode ( item ) { return { ... item , questionBankId : item . questionBankId , nodeType : String ( item . nodeType || '' ) . replace ( /([a-z0-9])([A-Z])/g , '$1_$2' ) . toLowerCase ( ) , questionCount : Number ( item . questionCount || 0 ) } ; }
function adaptBankQuestion ( item ) { return { ... item , statusLabel : businessStatusLabel ( item . status ) , versionNo : Number ( item . versionNo || 0 ) } ; }
function parseJsonText ( value , fallback ) { try { return String ( value || '' ) . trim ( ) ? JSON . parse ( value ) : fallback ; } catch { return null ; } }
async function loadBankWorkspace ( { questions = true } = { } ) {
const bank = bankById ( state . selectedBank ) ;
if ( ! bank || ! platformApi ? . isEnabled ( ) ) return ;
try {
const nodes = await platformApi . request ( 'PlatformQuestionBanksController_getNodes' , { pathParams : { bankId : bank . id } } ) ;
data . bankNodes = apiItems ( nodes ) . map ( adaptBankNode ) ;
if ( ! state . selectedBankNode || ! data . bankNodes . some ( item => item . id === state . selectedBankNode ) ) state . selectedBankNode = data . bankNodes . find ( item => item . isActive && [ 'chapter' , 'paper' ] . includes ( item . nodeType ) ) ? . id || data . bankNodes . find ( item => item . isActive ) ? . id || '' ;
if ( questions ) {
const result = await platformApi . request ( 'PlatformQuestionBanksController_getQuestions' , { query : { questionBankId : bank . id , contentNodeId : state . selectedBankNode || undefined , page : state . bankQuestionPage , pageSize : 20 } } ) ;
data . bankQuestions = apiItems ( result ) . map ( adaptBankQuestion ) ;
state . bankQuestionTotal = Number ( result . total || data . bankQuestions . length ) ;
}
saveData ( ) ; render ( ) ;
} catch ( error ) {
showToast ( backendErrorText ( error , '公共题库加载失败' ) , 'error' ) ;
}
}
function notificationChannelName ( id ) {
return [ ... data . dunningChannels , ... data . auditChannels ] . find ( channel => channel . id === id ) ? . name || id ;
}
@@ -449,14 +526,6 @@ function rowActionAccessibleName(control) {
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 ) } ` ;
@@ -495,10 +564,6 @@ function switchAccessibleName(control) {
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 ? '并撤销会话' : '' } ` ;
@@ -729,6 +794,39 @@ function renderScenario(content) {
return content ;
}
function businessStatusLabel ( value ) {
const key = String ( value || '' ) . replace ( /([a-z0-9])([A-Z])/g , '$1_$2' ) . toLowerCase ( ) ;
return ( { draft : '草稿' , active : '正常' , published : '已发布' , retired : '已下架' , archived : '已归档' , pending : '待处理' , pending _payment : '待付款' , paid : '已支付' , succeeded : '已成功' , failed : '失败' , requested : '已申请' , completed : '已完成' , verified : '已验证' , dns _pending : 'DNS 待验证' , tls _pending : 'TLS 待就绪' , disabled : '已停用' } ) [ key ] || value || '-' ;
}
function renderDomains ( ) {
return ` <div class="page-shell"> ${ pageHead ( '租户域名与证书' , '统一查看租户域名的 DNS 验证、证书和主域名状态,可对未就绪域名重新检查。' ) }
<section class="table-panel"><table class="platform-table"><thead><tr><th>租户</th><th>域名</th><th>用途</th><th>主域名</th><th>DNS 验证</th><th>TLS 就绪</th><th>最近检查</th><th>状态</th><th></th></tr></thead><tbody> ${ data . domains . map ( item => ` <tr><td> ${ escapeHtml ( tenantById ( item . tenantId ) ? . name || item . tenantId ) } </td><td><code> ${ escapeHtml ( item . host ) } </code></td><td> ${ escapeHtml ( item . domainType || '-' ) } </td><td> ${ item . isPrimary ? '是' : '否' } </td><td> ${ item . dnsVerifiedAt ? '已验证' : '待验证' } </td><td> ${ item . tlsReadyAt ? '已就绪' : '待就绪' } </td><td> ${ escapeHtml ( item . lastCheckedAt || '-' ) } </td><td> ${ statusBadge ( businessStatusLabel ( item . status ) ) } <small> ${ escapeHtml ( item . lastFailureReason || '' ) } </small></td><td><button class="button" data-action="recheck-domain" data-domain-id=" ${ item . id } ">重新检查</button></td></tr> ` ) . join ( '' ) } </tbody></table> ${ data . domains . length ? '' : emptyBlock ( '暂无租户域名' , '租户配置域名后将在这里显示。' ) } </section></div> ` ;
}
function renderCatalog ( ) {
const catalog = data . saasCatalog || { } ;
const features = catalog . features || [ ] ;
const offerings = catalog . offerings || [ ] ;
const versions = catalog . versions || [ ] ;
const offeringName = id => offerings . find ( item => item . id === id ) ? . name || id ;
return ` <div class="page-shell"> ${ pageHead ( 'SaaS 商品与版本' , '管理可售套餐、增值服务、功能权益、使用限额和定价版本。' , ` <button class="button" data-action="new-saas-feature"> ${ icon ( 'blocks' ) } 新建功能</button><button class="button" data-action="new-saas-limit"> ${ icon ( 'gauge' ) } 新建限额</button><button class="button" data-action="new-saas-offering"> ${ icon ( 'package-plus' ) } 新建商品</button><button class="button" data-action="tenant-feature-override"> ${ icon ( 'building-cog' ) } 租户功能覆盖</button><button class="button primary" data-action="new-saas-version"> ${ icon ( 'git-branch-plus' ) } 新建版本</button> ` ) }
<section class="metric-grid"><article class="metric"><span> ${ icon ( 'blocks' ) } </span><small>功能</small><strong> ${ features . length } </strong><p>其中 ${ features . filter ( item => String ( item . status ) . toLowerCase ( ) === 'active' ) . length } 项启用</p></article><article class="metric"><span> ${ icon ( 'packages' ) } </span><small>商品</small><strong> ${ offerings . length } </strong><p>基础套餐与增值包</p></article><article class="metric"><span> ${ icon ( 'git-branch' ) } </span><small>版本</small><strong> ${ versions . length } </strong><p>草稿、发布与下架生命周期</p></article></section>
<section class="table-panel"><div class="table-head"><h2>套餐版本</h2><span>发布后版本不可直接覆写</span></div><table class="platform-table"><thead><tr><th>商品</th><th>版本</th><th>周期</th><th>原价</th><th>售价</th><th>生效时间</th><th>状态</th><th></th></tr></thead><tbody> ${ versions . map ( item => ` <tr><td><strong> ${ escapeHtml ( offeringName ( item . offeringId ) ) } </strong><small> ${ escapeHtml ( item . offeringId ) } </small></td><td>V ${ item . version } </td><td> ${ escapeHtml ( item . billingCycle || '-' ) } </td><td> ${ money ( Number ( item . originalAmountCents || 0 ) / 100 ) } </td><td><strong> ${ money ( Number ( item . amountCents || 0 ) / 100 ) } </strong></td><td> ${ escapeHtml ( item . effectiveAt || '-' ) } </td><td> ${ statusBadge ( businessStatusLabel ( item . status ) ) } </td><td><div class="row-actions"> ${ String ( item . status ) . toLowerCase ( ) === 'draft' ? ` <button class="button" data-action="publish-saas-version" data-version-id=" ${ item . id } ">发布</button> ` : ` <button class="button" data-action="clone-saas-version" data-version-id=" ${ item . id } ">克隆</button> ` } ${ String ( item . status ) . toLowerCase ( ) === 'published' ? ` <button class="button" data-action="retire-saas-version" data-version-id=" ${ item . id } ">下架</button> ` : '' } </div></td></tr> ` ) . join ( '' ) } </tbody></table></section>
<section class="table-panel"><div class="table-head"><h2>功能目录</h2><span>配置套餐可包含的业务功能</span></div><table class="platform-table"><thead><tr><th>功能</th><th>编码</th><th>分类</th><th>参考价</th><th>核心功能</th><th>状态</th></tr></thead><tbody> ${ features . map ( item => ` <tr><td><strong> ${ escapeHtml ( item . name ) } </strong><small> ${ escapeHtml ( item . description || '' ) } </small></td><td><code> ${ escapeHtml ( item . code ) } </code></td><td> ${ escapeHtml ( item . category || '-' ) } </td><td> ${ money ( Number ( item . referencePriceCents || 0 ) / 100 ) } </td><td> ${ item . isCore ? '是' : '否' } </td><td> ${ statusBadge ( businessStatusLabel ( item . status ) ) } </td></tr> ` ) . join ( '' ) } </tbody></table></section><section class="table-panel"><div class="table-head"><h2>功能限额</h2><span>设置席位、题量、存储等使用上限</span></div><table class="platform-table"><thead><tr><th>限额</th><th>指标编码</th><th>所属功能</th><th>单位</th><th>计量方式</th><th>告警阈值</th><th>硬限额</th></tr></thead><tbody> ${ ( catalog . limitDefinitions || [ ] ) . map ( item => ` <tr><td><strong> ${ escapeHtml ( item . name ) } </strong></td><td><code> ${ escapeHtml ( item . metricCode ) } </code></td><td> ${ escapeHtml ( features . find ( feature => feature . code === item . featureCode ) ? . name || item . featureCode ) } </td><td> ${ escapeHtml ( item . unit ) } </td><td> ${ String ( item . kind ) . toLowerCase ( ) === 'period' ? '周期累计' : '当前值' } </td><td> ${ item . warningPercent } %</td><td> ${ item . isHardLimit ? '是' : '否' } </td></tr> ` ) . join ( '' ) } </tbody></table></section></div> ` ;
}
function renderSaasFinance ( ) {
const tabs = [ [ 'orders' , '订单' ] , [ 'payments' , '支付' ] , [ 'refunds' , '退款' ] , [ 'invoices' , '发票' ] , [ 'subscriptions' , '订阅' ] ] ;
const rows = state . saasFinanceTab === 'orders' ? data . saasOrders : state . saasFinanceTab === 'payments' ? data . billingPayments : state . saasFinanceTab === 'refunds' ? data . saasRefunds : state . saasFinanceTab === 'invoices' ? data . invoices : data . subscriptions ;
const cells = item => state . saasFinanceTab === 'orders' ? [ item . orderNo , tenantById ( item . tenantId ) ? . name || item . tenantId , item . purpose , money ( Number ( item . totalAmountCents || 0 ) / 100 ) , businessStatusLabel ( item . status ) , item . expiresAt ] : state . saasFinanceTab === 'payments' ? [ item . paymentNo || item . id , tenantById ( item . tenantId ) ? . name || item . tenantId , item . provider || '-' , money ( item . amount ) , businessStatusLabel ( item . status ) , item . paidAt || '-' ] : state . saasFinanceTab === 'refunds' ? [ item . refundNo || item . id , tenantById ( item . tenantId ) ? . name || item . tenantId , item . reason || '-' , money ( Number ( item . amountCents || 0 ) / 100 ) , businessStatusLabel ( item . status ) , item . completedAt || '-' ] : state . saasFinanceTab === 'invoices' ? [ item . id , tenantById ( item . tenantId ) ? . name || item . tenantId , item . type , money ( item . total ) , item . status , item . due ] : [ item . id , tenantById ( item . tenantId ) ? . name || item . tenantId , item . planCode || '-' , money ( item . amount ) , item . status , item . expires ] ;
return ` <div class="page-shell"> ${ pageHead ( 'SaaS 订单与交易' , '集中查看租户订购、收款、退款、开票和订阅情况。' ) } <nav class="billing-tabs"> ${ tabs . map ( ( [ value , label ] ) => ` <button class=" ${ state . saasFinanceTab === value ? 'is-active' : '' } " data-saas-finance-tab=" ${ value } "> ${ label } </button> ` ) . join ( '' ) } </nav><section class="table-panel"><table class="platform-table"><thead><tr><th>编号</th><th>租户</th><th>类型 / 说明</th><th>金额</th><th>状态</th><th>时间</th></tr></thead><tbody> ${ rows . map ( item => ` <tr> ${ cells ( item ) . map ( ( value , index ) => ` <td> ${ index === 0 ? ` <code> ${ escapeHtml ( value ) } </code> ` : escapeHtml ( value ) } </td> ` ) . join ( '' ) } </tr> ` ) . join ( '' ) } </tbody></table> ${ rows . length ? '' : emptyBlock ( '当前暂无数据' , '有新的业务记录后会在这里显示。' ) } </section></div> ` ;
}
function renderRoles ( ) {
return ` <div class="page-shell"> ${ pageHead ( '平台角色与功能' , '按岗位配置可访问的菜单和可执行的业务操作。' , ` <button class="button primary" data-action="new-platform-role"> ${ icon ( 'shield-plus' ) } 新建角色</button> ` ) } <div class="staff-layout"><section class="panel"><div class="panel-head"><div><h2>平台角色</h2><span> ${ data . platformRoles . length } 个启用角色</span></div></div> ${ data . platformRoles . map ( role => ` <article class="role-row"><span> ${ icon ( 'shield-check' ) } </span><div><strong> ${ escapeHtml ( role . name ) } </strong><small> ${ escapeHtml ( role . description || '暂无角色说明' ) } </small></div><b> ${ role . permissionCodes . length } 项功能</b><button class="button" data-action="edit-platform-role" data-role-id=" ${ role . id } ">编辑</button></article> ` ) . join ( '' ) } </section><section class="panel"><div class="panel-head"><div><h2>可分配功能</h2><span>选择角色可以使用的业务功能</span></div></div> ${ Object . entries ( data . platformPermissions ) . map ( ( [ code , name ] ) => ` <article class="role-row"><span> ${ icon ( 'key-round' ) } </span><div><strong> ${ escapeHtml ( name ) } </strong><small><code> ${ escapeHtml ( code ) } </code></small></div><b> ${ data . platformRoles . filter ( role => role . permissionCodes . includes ( code ) || role . permissionCodes . includes ( '*' ) ) . length } 个角色</b></article> ` ) . join ( '' ) } </section></div></div> ` ;
}
function renderOverview ( ) {
if ( platformApi ? . isEnabled ( ) ) return renderApiOverview ( ) ;
const active = data . tenants . filter ( tenant => tenant . status === '正常' ) . length ;
@@ -792,7 +890,7 @@ function renderBillingProfile() {
function renderSubscriptions ( ) {
const selectedTenant = state . selectedTenant ? tenantById ( state . selectedTenant ) : null ;
const rows = state . scenario === 'empty' ? [ ] : data . subscriptions ;
return ` <div class="page-shell"> ${ pageHead ( 'SaaS 套餐与订阅' , '维护套餐能力边界 ,并为租户创建或续订订阅。当前 API 只提供套餐查询,因此不提供套餐编辑 。' , ` <button class="button" data-action="subscriptions-to-usage"> ${ icon ( 'chart-column-big' ) } 配置超额计费</button> ${ canWrite ( 'platform:billing:write' ) ? ` <button class="button primary" data-action="create-subscription"> ${ icon ( 'plus' ) } 创建订阅</button> ` : '' } ` ) }
return ` <div class="page-shell"> ${ pageHead ( 'SaaS 套餐与订阅' , '查看套餐权益 ,并为租户创建或续订订阅。套餐内容请在“商品与版本”中维护 。' , ` <button class="button" data-action="subscriptions-to-usage"> ${ icon ( 'chart-column-big' ) } 配置超额计费</button> ${ canWrite ( 'platform:billing:write' ) ? ` <button class="button primary" data-action="create-subscription"> ${ icon ( 'plus' ) } 创建订阅</button> ` : '' } ` ) }
<div class="plan-grid"> ${ data . plans . map ( ( plan , index ) => ` <article class="plan-card ${ index === 1 ? 'is-featured' : '' } "><header><div><span> ${ plan . name } </span><p> ${ plan . description } </p></div><b> ${ plan . tenantCount } 家租户</b></header><strong> ${ money ( plan . amount ) } <small>/年</small></strong><ul> ${ plan . quotas . map ( item => ` <li> ${ icon ( 'check' ) } ${ item } </li> ` ) . join ( '' ) } </ul><button class="button" data-action="select-plan" data-plan-code=" ${ plan . code } ">选择此套餐</button></article> ` ) . join ( '' ) } </div>
${ rows . length ? ` <section class="table-panel subscription-table"><div class="table-head"><div><h2>活跃与试用订阅</h2><span> ${ rows . length } 条 ${ selectedTenant ? ` · 已定位 ${ selectedTenant . name } ` : '' } </span></div><button class="button" data-action="subscription-to-billing"> ${ icon ( 'receipt-text' ) } 进入账单候选</button></div><table class="platform-table"><thead><tr><th>租户</th><th>订阅号</th><th>套餐</th><th>周期</th><th>开始时间</th><th>到期时间</th><th>自动续费</th><th>状态</th><th></th></tr></thead><tbody> ${ rows . map ( subscription => { const tenant = tenantById ( subscription . tenantId ) ; return ` <tr class=" ${ selectedTenant ? . id === tenant ? . id ? 'is-highlighted' : '' } "><td><strong> ${ escapeHtml ( tenant ? . name || '-' ) } </strong><small> ${ escapeHtml ( tenant ? . slug || '-' ) } </small></td><td><code> ${ subscription . id } </code></td><td> ${ planByCode ( subscription . planCode ) ? . name || subscription . planCode } </td><td> ${ subscription . cycle } </td><td> ${ subscription . start } </td><td> ${ subscription . expires } </td><td><button class="switch ${ subscription . autoRenew ? 'is-on' : '' } " data-action="toggle-auto-renew" data-subscription-id=" ${ subscription . id } " ${ canWrite ( 'platform:billing:write' ) ? '' : 'disabled' } ><i></i></button></td><td> ${ statusBadge ( subscription . status ) } </td><td><button class="row-action" data-tenant-detail=" ${ subscription . tenantId } "> ${ icon ( 'chevron-right' ) } </button></td></tr> ` ; } ).join('')}</tbody></table></section> ` : emptyBlock ( '还没有订阅' , '创建租户订阅后将在此显示。' , canWrite ( 'platform:billing:write' ) ? '<button class="button primary" data-action="create-subscription">创建订阅</button>' : '' ) } </div> ` ;
}
@@ -827,28 +925,49 @@ function renderDunning() {
function renderBank ( ) {
const selected = bankById ( state . selectedBank ) || data . banks [ 0 ] ;
if ( selected && state . selectedBank !== selected . id ) state . selectedBank = selected . id ;
const query = state . bankSearch . trim ( ) . toLowerCase ( ) ;
const bankRows = data . banks . filter ( bank => ! query || [ bank . name , bank . catalog , bank . source , bank . version ] . some ( value => String ( value ) . toLowerCase ( ) . includes ( query ) ) ) ;
return ` <div class="page-shell"> ${ pageHead ( '公共题库目录与授权' , '维护公共题库目录、授权规则与租户采用同步状态。授权禁用不会静默删除租户快照。' , ` <label class="toggle-label"><input type="checkbox" id="includeTenantBanks" ${ sta te. bankIncludeTenant ? 'checked' : '' } />含租户库</label> ${ canWrite ( 'platform: question_b ank:grant' ) ? ` <button class="button primary" data-action="new-grant"> ${ icon ( 'plus' ) } 新建授权</button> ` : '' } ` ) }
<section class="filter-bar"><label> ${ icon ( 'search' ) } <input id="bankSearch" value=" ${ escapeHtml ( state . bankSearch ) } " placeholder="搜索题库名称、目录、来源或版本" /></label><button class="button" data-action="apply-bank-search" ${ state . reading === 'bank-query' ? 'disabl ed' : '' } > ${ state . reading === 'bank-query' ? ` ${ icon ( 'loader-circle' ) } 查询中 ` : ` ${ icon ( 'search' ) } 查询 ` } </button></section> ${ contractResult ( 'bank-query' ) } ${ contractResult ( 'bank-sync ' ) }
<nav class="billing-tabs"><button class=" ${ state . bankT ab === 'catalog' ? 'is-active' : '' } " data-bank-tab="catalog">题库目录</button><button class=" ${ state . bankTab === 'grants' ? 'is-active' : '' } " data-bank-tab="grants">授权规则</button><button class=" ${ state . bankTab === 'sync' ? 'is-active' : '' } " data-bank-tab="sync">采用与同步</button></nav>
${ state . bankTab === 'catalog' ? ` <div class="bank-layout"><aside class="bank-tree"><header><strong>公共题库目录</strong><span> ${ bankRows . length } 个</span></header> ${ bankRows . map ( bank => ` <button class=" ${ bank . id === selected . id ? 'is-active' : '' } " data-bank-select=" ${ bank . id } "> ${ icon ( 'library-big' ) } <span><strong> ${ escapeHtml ( bank . name ) } </strong><small> ${ formatNumber ( bank . questions ) } 题 · ${ bank . source } </small></span> ${ icon ( 'chevron-right' ) } </button> ` ) . join ( '' ) || '<p class="tree-empty">没有匹配题库</p>' } ${ state . bankIncludeTenant ? ` <button> ${ icon ( 'building-2' ) } <span><strong>启航租户本地库</strong><small>68,420 题 · 只读来源查看</small></span> ${ icon ( 'chevron-right' ) } </button> ` : '' } </aside><section class="bank-content"><header><div><span> ${ escapeHtml ( selected . catalog ) } </span><h2> ${ escapeHtml ( selected . name ) } </h2><p>当前版本 ${ selected . version } · 来源 ${ selected . source } </p></div><button class="button" data-action="preview-bank-sync">采用与同步</button></header><div class="bank-stats"><div><span>题目</span><strong> ${ formatNumber ( selected . questions ) } </strong></div><div><span>授权规则</span><strong> ${ data . grants . filter ( grant => grant . bankId === selected . id ) . length } </strong></div><div><span>采用租户</span><strong> ${ data . bankSync . filter ( sync => sync . bankId === selected . id ) . length } </strong></div><div><span>当前状态</span><strong> ${ selected . status } </strong></div></div><section class="version-card"><div><span class="status success"> ${ selected . status } </span><h3> ${ selected . version } </h3><p>公共题库内容由平台内容生产链路维护,本页只负责授权和采用状态。</p><small>更新时间 2026-07-23 09:00 CST</small></div><div><button class="button" data-action="preview-bank-sync">查看同步</button><button class="button primary" data-action="new-grant">授权租户</button></div></section></section></div> ` : state . bankTab === 'grants' ? ` <section class="table-panel"><table class="platform-table"><thead><tr><th>规则</th><th>题库</th><th>授权范围</th><th>套餐 / 租户</th><th>地区 / 科目</th><th>有效期</th><th>本地改编</th><th>状态</th><th></th></tr></thead><tbody> ${ data . grants . filter ( grant => ! query || [ grant . id , bankById ( grant . bankId ) ? . name , grant . scope , ... grant . allowedRegionIds , ... grant . allowedSubjectIds ] . some ( value => String ( value || '' ) . toLowerCase ( ) . includes ( query ) ) ) . map ( gr ant => ` <tr><td><code> ${ grant . id } </code></td><td><strong> ${ escapeHtml ( bankById ( grant . bankId ) ? . name || '-' ) } </strong></td><td> ${ grant . scope } </td><td> ${ grant . allowedPlanCodes . length ? grant . allowedPlanCodes . map ( code => planByCode ( code ) ? . name || code ) . join ( '、' ) : grant . allowedTenantIds . map ( id => tenantById ( id ) ? . name || id ) . join ( '、' ) || '-' } </td><td> ${ [ ... grant . allowedRegionIds , ... grant . allowedSubjectIds ] . join ( ' / ' ) || '不限' } </td><td> ${ grant . startsAt } 至 ${ grant . expiresAt || '长期' } </td><td> ${ grant . localEdit ? '允许' : '只读采用' } </td><td> ${ statusBadge ( grant . status ) } </td><td><div class="row-actions"><button class="row-action" data-action="edit-grant" data-grant-id =" ${ gr ant . id } "> ${ icon ( 'pencil' ) } </button><button class="switch ${ grant . status === '启用' ? 'is-on' : '' } " data-action="toggle-grant" data-grant-id=" ${ grant . id } "><i></i></button></div></td></tr> ` ) . join ( '' ) } </tbody></table></section> ` : ` <section class="table-panel"><div class="table-head"><div><h2>租户采用与同步状态</h2><span>源版本、任务状态与冲突均来自专属查询</span></div><button class="button" data-action="refresh-bank-sync" ${ state . reading === 'bank-sync' ? 'disabled' : '' } > ${ state . reading === 'bank-sync' ? ` ${ icon ( 'loader-circle' ) } 刷新中 ` : ` ${ icon ( 'refresh-cw' ) } 刷新同步状态 ` } </button></div><table class="platform-table"><thead><tr><th>租户</th><th>题库</th><th>当前版本</th><th>目标版本</th><th>同步进度</th><th>冲突</th><th>状态</th><th></th></tr></thead><tbody> ${ data . bankSync . filter ( sync => ! query || [ tenantById ( sync . tenantId ) ? . name , bankById ( sync . bankId ) ? . name , sync . current , sync . target , sync . status ] . some ( value => String ( value || '' ) . toLowerCase ( ) . includes ( query ) ) ) . map ( sync => ` <tr><td> ${ escapeHtml ( tenantById ( sync . tenantId ) ? . name || '- ' ) } </td><td >${ escapeHtml ( bankById ( sync . bankId ) ? . name || '-' ) } </td><td> ${ sync . current } </td><td> ${ sync . target } </td><td><div class="usage-cell"><span> ${ sync . progress } %</span><i><b style="width: ${ sync . progress } %"></b></i></div></td><td> ${ sync . conflicts } </td><td> ${ statusBadge ( sync . status ) } </td><td><button class="row-action" data-action="sync-detail" data-sync-key=" ${ sync . tenantId } : ${ sync . bankId } "> ${ icon ( 'chevron-right' ) } </button></td></tr> ` ) . join ( '' ) } </tbody></table></section> ` } </div > ` ;
const bankRows = data . banks . filter ( bank => ! query || [ bank . name , bank . status ] . some ( value => String ( value || '' ) . toLowerCase ( ) . includes ( query ) ) ) ;
const nodes = data . bankNodes . fil ter ( node => node . questionB ankId === selected ? . id ) ;
const nodeOptions = nodes . filter ( node => node . isActive ) . map ( node => ` <option value=" ${ node . id } " ${ state . selectedBankNode === node . id ? 'select ed' : '' } > ${ ' ' . repeat ( node . depth || 0 ) } ${ escapeHtml ( node . name ) } ( ${ bankNodeTypeLabel ( node . nodeType ) } ) </option> ` ) . join ( ' ') ;
const t abs = [ [ 'structure' , '内容结构' ] , [ 'single' , '单题录入' ] , [ 'batch' , '批量导入' ] , [ 'manage' , '题目管理' ] , [ 'structured' , 'JSON 文件导入' ] ] ;
const bankSelect = ` <select id="bankSelector" aria-label="当前公共题库"> ${ data . banks . map ( b ank => ` <option value =" ${ b ank . id } " ${ bank . id === selected ? . id ? 'selected' : '' } > ${ escapeHtml ( bank. name ) } </option> ` ) . join ( '' ) } </select > ` ;
let content = emptyBlock ( '尚未创建公共题库' , '点击“新建题库”后即可配置分类、科目、章节和试卷。' ) ;
if ( selected && state . bankTab === 'structure' ) content = ` <div class="bank-layout"><aside class="bank-tree"><header><strong>内容层级</strong><span> ${ nodes . length } 个节点</span></header> ${ nodes . map ( node => ` <button class=" ${ node . id === state . selectedBankNode ? 'is-active' : '' } " data-bank-node-select=" ${ node . id } " style="padding-left: ${ 18 + Number ( node . depth || 0 ) * 18 } px"> ${ icon ( node . nodeType === 'paper' ? 'file-text' : node . nodeType === 'chapter' ? 'book-open' : 'folder-tree' ) } <span><strong> ${ escapeHtml ( node . name ) } </strong><small> ${ bankNodeTypeLabel ( node . nodeType ) } · ${ node . questionCount || 0 } 题</small></span></button> ` ) . join ( '' ) || '<p class="tree-empty">暂无内容层级</p>' } </aside><section class="bank-content"><header><div><span>平台公共题库</span><h2> ${ escapeHtml ( selected . name ) } </h2><p>通过分类、科目、章节和试卷组织题目。</p></div><div class="row-actions"><button class="button" data-action="edit-bank">编辑题库</button><button class="button" data-action="new-bank-node">新建节点</button><button class="button primary" data-action="batch-bank-nodes">批量创建章节/试卷</button></div></header><div class="bank-stats"><div><span>内容节点</span><strong> ${ selected . nodes || nodes . length } </strong></div><div><span>题目总数</span><strong> ${ formatNumber ( selected . questions || 0 ) } </strong></div><div><span>启用节点</span><strong> ${ nodes . filter ( item => item . isActive ) . length } </strong></div><div><span>题库状态</span><strong> ${ selected . status } </strong></div></div> ${ state . selectedBankNode ? ` <section class="version-card"><div><span class="status success"> ${ bankNodeTypeLabel ( bankNodeById ( state . selectedBankNode ) ? . nodeType ) } </span><h3> ${ escapeHtml ( bankNodeById ( state . selectedBankNode ) ? . name || '' ) } </h3><p>当前节点共 ${ bankNodeById ( state . selectedBankNode ) ? . questionCount || 0 } 道未归档题目。</p></div><div><button class="button" data-action="edit-bank-node">编辑节点</button><button class="button danger" data-action="archive-bank-node">归档节点</button></div></section> ` : '' } </section></div> ` ;
if ( selected && state . bankTab === 'structure' && state . selectedBankNode ) content += ` <section class="panel"><div class="panel-head"><div><h2>节点排序</h2><span>调整当前节点在同级内容中的显示顺序</span></div><div class="row-actions"><button class="button" data-action="shift-bank-node" data-sort-delta="-10">上移</button><button class="button" data-action="shift-bank-node" data-sort-delta="10">下移</button></div></div></section> ` ;
if ( selected && state . bankTab === 'single' ) content = ` <section class="panel"><div class="panel-head"><div><h2>单题录入</h2><span>保存后自动形成新题目版本</span></div></div><form id="bankQuestionForm" class="modal-form"><input type="hidden" name="id" /><div class="form-error-summary" hidden tabindex="-1"></div><div class="form-grid"><label>目标章节/试卷<select name="contentNodeId" required> ${ nodeOptions } </select></label><label>题型<select name="type"><option value="choice">单选题</option><option value="multiple_choice">多选题</option><option value="true_false">判断题</option><option value="fill_blank">填空题</option><option value="short_answer">简答题</option><option value="reading">阅读材料题</option><option value="programming">编程题</option></select></label><label>难度<select name="difficulty"><option value="1">1 · 容易</option><option value="2">2 · 较易</option><option value="3" selected>3 · 中等</option><option value="4">4 · 较难</option><option value="5">5 · 困难</option></select></label><label>稳定编号<input name="legacyId" placeholder="可选,用于重复导入识别" /></label><label class="full">题干<textarea name="content" rows="6" required placeholder="支持 Markdown 和图片链接"></textarea></label><label class="full">选项 JSON<textarea name="options" rows="4" placeholder='["选项 A","选项 B"]'></textarea></label><label class="full">答案<textarea name="answerText" rows="2"></textarea></label><label class="full">解析<textarea name="explanation" rows="4"></textarea></label><label>图片或媒体地址<input name="mediaUrl" placeholder="https://..." /></label><label>上传题目图片<input id="bankQuestionImage" type="file" accept="image/*" /><button class="button" type="button" data-action="upload-question-image">上传并填入地址</button></label><label>标签<input name="tags" placeholder="高频、真题" /></label></div><div class="modal-actions"><button class="button" type="button" data-action="preview-question">预览</button><button class="button primary" type="submit">保存题目</button></div></form></section> ` ;
if ( selected && state . bankTab === 'batch' ) content = ` <section class="panel"><div class="panel-head"><div><h2>普通题目批量导入</h2><span>先选择章节或试卷,再粘贴题目数组</span></div></div><form id="bankBatchImportForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><div class="form-grid"><label>目标章节/试卷<select name="contentNodeId" required> ${ nodeOptions } </select></label><label>来源名称<input name="sourceName" placeholder="例如: 2026 年高数整理" /></label><label class="full">题目 JSON<textarea name="payload" rows="16" required placeholder='[{"type":"choice","content":"题干","options":["A","B"],"answerText":"A"}]'></textarea></label></div><div class="modal-actions"><button class="button" type="submit" data-import-mode="preview">预检数据</button><button class="button primary" type="submit" data-import-mode="execute">执行导入</button></div></form> ${ renderBankImportResult ( ) } </section> ` ;
if ( selected && state . bankTab === 'manage' ) content = ` <section class="filter-bar"><label> ${ icon ( 'search' ) } <input id="bankQuestionSearch" placeholder="搜索题干或题型" /></label><select id="bankQuestionType"><option value="">全部题型</option> ${ [ 'choice' , 'multiple_choice' , 'true_false' , 'fill_blank' , 'short_answer' , 'reading' , 'programming' ] . map ( value => ` <option value=" ${ value } "> ${ questionTypeLabel ( value ) } </option> ` ) . join ( '' ) } </select><select id="bankQuestionStatus"><option value="">全部状态</option><option value="published">已发布</option><option value="draft">草稿</option><option value="archived">已归档</option></select><button class="button" data-action="load-bank-questions">查询题目</button></section><section class="table-panel"><table class="platform-table"><thead><tr><th>题目</th><th>题型</th><th>难度</th><th>节点</th><th>版本</th><th>状态</th><th></th></tr></thead><tbody> ${ data . bankQuestions . map ( item => ` <tr><td><strong> ${ escapeHtml ( String ( item . content || '' ) . slice ( 0 , 80 ) || '未填写题干' ) } </strong><small> ${ escapeHtml ( item . legacyId || item . id ) } </small></td><td> ${ questionTypeLabel ( item . type ) } </td><td> ${ item . difficulty || '-' } </td><td> ${ escapeHtml ( bankNodeById ( item . contentNodeId ) ? . name || '-' ) } </td><td>V ${ item . versionNo } </td><td> ${ statusBadge ( item . statusLabel ) } </td><td><button class="button" data-action="edit-bank-question" data-question-id=" ${ item . id } ">编辑</button> ${ item . statusLabel !== '已归档' ? ` <button class="button danger" data-action="archive-bank-question" data-question-id=" ${ item . id } ">归档</button> ` : '' } </td></tr> ` ) . join ( '' ) } </tbody></table> ${ data . bankQuestions . length ? ` <div class="table-head"><span>共 ${ state . bankQuestionTotal } 道题</span><div><button class="button" data-action="bank-question-page" data-page=" ${ Math . max ( 1 , state . bankQuestionPage - 1 ) } " ${ state . bankQuestionPage <= 1 ? 'disabled' : '' } >上一页</button><button class="button" data-action="bank-question-page" data-page=" ${ state . bankQuestionPage + 1 } " ${ state . bankQuestionPage * 20 >= state . bankQuestionTotal ? 'disabled' : '' } >下一页</button></div></div> ` : emptyBlock ( '暂无题目' , '选择节点后查询,或先录入、导入题目。' ) } </section> ` ;
if ( selected && state . bankTab === 'structured' ) content = ` <section class="panel"><div class="panel-head"><div><h2>JSON 文件导入</h2><span>支持带分类、科目、章节/试卷和题目的 2.0 结构化文件</span></div></div><form id="bankStructuredImportForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><div class="form-grid"><label>挂载到已有节点<select name="contentNodeId"><option value="">从题库根层级导入</option> ${ nodeOptions } </select></label><label>选择 .json 文件<input id="bankStructuredFile" type="file" accept="application/json,.json" /></label><label class="full">结构化 JSON<textarea id="bankStructuredPayload" name="payload" rows="18" required placeholder='{"_tikuExport":"2.0","subjects":[...]}'></textarea></label></div><div class="modal-actions"><button class="button" type="submit" data-import-mode="preview">预检层级和题目</button><button class="button primary" type="submit" data-import-mode="execute">执行结构化导入</button></div></form> ${ renderBankImportResult ( ) } </section> ` ;
return ` <div class="page-shell"> ${ pageHead ( '公共题库' , '配置公共题库内容结构,完成单题录入、题目管理和两种 JSON 导入。' , ` ${ selected ? ` <button class="button danger" data-action="archive-bank"> ${ icon ( 'archive' ) } 归档题库</button> ` : '' } <button class="button primary" data-action="new-bank"> ${ icon ( 'plus' ) } 新建题库</button> ` ) } <section class="filter-bar"><label> ${ icon ( 'search' ) } <input id="bankSearch" value=" ${ escapeHtml ( state . bankSearch ) } " placeholder="搜索题库名称" /></label> ${ bankSelect } <button class="button" data-action="apply-bank-search"> ${ icon ( 'search' ) } 查询</button></section> ${ contractResult ( 'bank-query' ) } <nav class="billing-tabs"> ${ tabs . map ( ( [ value , label ] ) => ` <button class=" ${ state . bankTab === value ? 'is-active' : '' } " data-bank-tab=" ${ value } "> ${ label } </button> ` ) . join ( '' ) } </nav> ${ content } </div> ` ;
}
function renderBankImportResult ( ) {
const result = data . bankImportResult ;
if ( ! result ) return '' ;
const job = result . detail ? . job || result . job || { } ;
const issues = result . detail ? . issues || result . issues || [ ] ;
return ` <section class="version-card"><div><span class="status ${ Number ( job . errorCount || 0 ) ? 'warning' : 'success' } "> ${ job . dryRun ? '预检完成' : '导入完成' } </span><h3>共 ${ job . totalCount || 0 } 条, ${ job . validCount || 0 } 条有效</h3><p>新增题目 ${ result . insertedQuestionCount || 0 } ,更新 ${ result . updatedQuestionCount || 0 } ,跳过 ${ result . skippedQuestionCount || 0 } ,新建节点 ${ result . createdNodeCount || 0 } 。</p> ${ issues . length ? ` <small> ${ issues . slice ( 0 , 8 ) . map ( item => ` 第 ${ item . rowNo || '-' } 行: ${ escapeHtml ( item . message ) } ` ) . join ( '<br>' ) } </small> ` : '<small>没有发现数据问题。</small>' } </div></section> ` ;
}
function renderCrm ( ) {
const activeConfigs = data . crmConfigs . filter ( item => item . enabled ) . length ;
const failedLeads = data . crmLeads . filter ( item => item . status === '推送失败' ) . length ;
const notice = platformApi ? . isEnabled ( ) ? '已接入平台 CRM API, 可集中维护租户线索推送配置、失败队列和同步日志。' : '集中管理租户 CRM 接入、线索队列与分配策略 。';
const notice = '集中维护租户线索推送配置、失败队列、分配策略和同步日志 。';
if ( state . crmTab === 'logs' ) return ` <div class="page-shell"> ${ pageHead ( 'CRM 推送日志' , notice ) } <nav class="billing-tabs"><button data-crm-tab="configs">接入配置</button><button data-crm-tab="leads">线索队列</button><button class="is-active" data-crm-tab="logs">推送日志</button><button data-crm-tab="rules">分配策略</button></nav><section class="table-panel"><table class="platform-table"><thead><tr><th>日志 ID</th><th>租户</th><th>队列任务</th><th>HTTP 状态</th><th>耗时</th><th>尝试次数</th><th>结果 / 错误</th><th>时间</th></tr></thead><tbody> ${ data . crmLogs . map ( item => ` <tr><td><code> ${ escapeHtml ( item . id ) } </code></td><td> ${ escapeHtml ( tenantById ( item . tenantId ) ? . name || item . tenantId ) } </td><td><code> ${ escapeHtml ( item . queueId || '-' ) } </code></td><td> ${ escapeHtml ( item . statusCode || '-' ) } </td><td> ${ escapeHtml ( item . durationMs || 0 ) } ms</td><td> ${ escapeHtml ( item . attempt || item . attempts || 1 ) } </td><td> ${ statusBadge ( item . success ? '成功' : '失败' ) } <small> ${ escapeHtml ( item . error || item . responseSummary || '' ) } </small></td><td> ${ escapeHtml ( item . createdAt || '-' ) } </td></tr> ` ) . join ( '' ) } </tbody></table> ${ data . crmLogs . length ? '' : emptyBlock ( '暂无 CRM 推送日志' , '线索推送后会在这里保留请求结果与错误摘要。' ) } </section></div> ` ;
return ` <div class="page-shell"> ${ pageHead ( 'CRM 接入与线索流转' , notice , ` ${ canWrite ( 'platform:crm:write' ) ? ` <button class="button primary" data-action="new-crm-config"> ${ icon ( 'plus' ) } 接入配置</button> ` : '' } ` ) }
<section class="usage-kpis"><div><span>已启用接入</span><strong> ${ activeConfigs } </strong><small>共 ${ data . crmConfigs . length } 家租户配置</small><i><b style="width: ${ Math . min ( 100 , activeConfigs / Math . max ( 1 , data . crmConfigs . length ) * 100 ) } %"></b></i></div><div><span>待推送线索</span><strong> ${ data . crmLeads . filter ( item => item . status === '待推送' ) . length } </strong><small>来自学生端留资与活动报名</small><i><b style="width:36%"></b></i></div><div><span>推送失败</span><strong> ${ failedLeads } </strong><small>进入重试或死信处理</small><i class="orange"><b style="width: ${ failedLeads ? 45 : 0 } %"></b></i></div><div><span>权限</span><strong> ${ hasPermission ( 'platform:crm:write' ) ? '可维护' : '只读' } </strong><small><code>platform:crm:read/write</code></small><i><b style="width: ${ hasPermission ( 'platform:crm:write' ) ? 100 : 50 } %"></b></i></div></section>
<nav class="billing-tabs"><button class=" ${ state . crmTab === 'configs' ? 'is-active' : '' } " data-crm-tab="configs">接入配置</button><button class=" ${ state . crmTab === 'leads' ? 'is-active' : '' } " data-crm-tab="leads">线索队列</button><button class=" ${ state . crmTab === 'rules' ? 'is-active' : '' } " data-crm-tab="rules">分配策略</button></nav>
<nav class="billing-tabs"><button class=" ${ state . crmTab === 'configs' ? 'is-active' : '' } " data-crm-tab="configs">接入配置</button><button class=" ${ state . crmTab === 'leads' ? 'is-active' : '' } " data-crm-tab="leads">线索队列</button><button class=" ${ state . crmTab === 'logs' ? 'is-active' : '' } " data-crm-tab="logs">推送日志</button><button class=" ${ state . crmTab === 'rules' ? 'is-active' : '' } " data-crm-tab="rules">分配策略</button></nav>
${ state . crmTab === 'configs' ? ` <section class="table-panel"><table class="platform-table"><thead><tr><th>租户</th><th>Provider</th><th>Endpoint</th><th>分配方式</th><th>线索池</th><th>超时 / 重试</th><th>状态</th><th></th></tr></thead><tbody> ${ data . crmConfigs . map ( config => ` <tr><td><strong> ${ escapeHtml ( tenantById ( config . tenantId ) ? . name || '-' ) } </strong><small> ${ escapeHtml ( config . id ) } </small></td><td> ${ escapeHtml ( config . provider ) } </td><td><code> ${ escapeHtml ( config . endpoint ) } </code></td><td> ${ escapeHtml ( config . assignmentMode ) } </td><td> ${ escapeHtml ( config . pool ) } </td><td> ${ config . timeout } s / ${ config . retry } 次</td><td> ${ statusBadge ( config . status ) } </td><td><div class="row-actions"> ${ canWrite ( 'platform:crm:write' ) ? ` <button class="row-action" data-action="edit-crm-config" data-crm-id=" ${ config . id } "> ${ icon ( 'pencil' ) } </button><button class="switch ${ config . enabled ? 'is-on' : '' } " data-action="toggle-crm-config" data-crm-id=" ${ config . id } "><i></i></button> ` : '' } </div></td></tr> ` ) . join ( '' ) } </tbody></table></section> ` : state . crmTab === 'leads' ? ` <section class="table-panel"><table class="platform-table"><thead><tr><th>线索</th><th>租户</th><th>来源 / 科目</th><th>负责人</th><th>尝试次数</th><th>状态</th><th>错误摘要</th><th></th></tr></thead><tbody> ${ data . crmLeads . map ( lead => ` <tr><td><strong> ${ escapeHtml ( lead . student ) } </strong><small> ${ lead . id } · ${ lead . phone } </small></td><td> ${ escapeHtml ( tenantById ( lead . tenantId ) ? . name || '-' ) } </td><td> ${ escapeHtml ( lead . source ) } <small> ${ escapeHtml ( lead . subject ) } </small></td><td> ${ escapeHtml ( lead . owner ) } </td><td> ${ lead . attempts } </td><td> ${ statusBadge ( lead . status ) } </td><td> ${ escapeHtml ( lead . lastError || '-' ) } </td><td> ${ lead . status === '推送失败' && canWrite ( 'platform:crm:write' ) ? ` <button class="row-action" data-action="retry-crm-lead" data-lead-id=" ${ lead . id } "> ${ icon ( 'rotate-ccw' ) } </button> ` : '' } </td></tr> ` ) . join ( '' ) } </tbody></table></section> ` : ` <div class="staff-layout"><section class="panel"><div class="panel-head"><div><h2>平台默认策略</h2><span>租户可在套餐授权后覆盖</span></div></div> ${ [ [ '按地区轮转' , '地区、校区、课程标签优先,适合多校区机构' ] , [ '按校区容量' , '按顾问容量与当日跟进上限分配' ] , [ '手工分配' , '只进入队列,由租户管理员手工认领' ] ] . map ( ( [ name , desc ] ) => ` <article class="role-row"><span> ${ icon ( 'route' ) } </span><div><strong> ${ name } </strong><small> ${ desc } </small></div><b>启用</b><button class="button" data-action="crm-rule-detail">查看</button></article> ` ) . join ( '' ) } </section><section class="panel"><div class="panel-head"><div><h2>线索同步边界</h2><span>平台统一监控推送质量和异常重试</span></div></div><div class="policy-timeline"><p><b>接入配置</b><span>维护 endpoint、secret 引用、超时和重试策略</span></p><p><b>线索队列</b><span>集中查看待推送、失败和重试中的线索</span></p><p><b>同步日志</b><span>保留请求结果、错误摘要和审计线索</span></p></div></section></div> ` } </div> ` ;
}
function renderSms ( ) {
const sent = data . smsChannels . reduce ( ( sum , item ) => sum + item . used , 0 ) ;
const quota = data . smsChannels . reduce ( ( sum , item ) => sum + item . quota , 0 ) ;
const notice = platformApi ? . isEnabled ( ) ? '已接入平台 SMS API, 渠道、模板和发送日志来自后端真实数据。' : '集中管理租户短信渠道、模板审核状态和发送记录。' ;
const notice = '集中管理租户短信渠道、模板审核状态和发送记录。' ;
const smsNav = ` <nav class="billing-tabs"><button class=" ${ state . smsTab === 'channels' ? 'is-active' : '' } " data-sms-tab="channels">短信渠道</button><button class=" ${ state . smsTab === 'templates' ? 'is-active' : '' } " data-sms-tab="templates">短信模板</button><button class=" ${ state . smsTab === 'logs' ? 'is-active' : '' } " data-sms-tab="logs">发送日志</button></nav> ` ;
if ( state . smsTab === 'templates' ) return ` <div class="page-shell"> ${ pageHead ( '短信模板与审核' , notice , ` <button class="button primary" data-action="new-sms-template"> ${ icon ( 'plus' ) } 新建模板</button> ` ) } ${ smsNav } <section class="table-panel"><table class="platform-table"><thead><tr><th>租户</th><th>模板</th><th>渠道</th><th>编码</th><th>类型</th><th>发送 / 失败</th><th>审核状态</th><th>启用状态</th><th></th></tr></thead><tbody> ${ data . smsTemplates . map ( template => ` <tr><td> ${ escapeHtml ( tenantById ( template . tenantId ) ? . name || template . tenantId ) } </td><td><strong> ${ escapeHtml ( template . name ) } </strong><small> ${ escapeHtml ( template . id ) } </small></td><td> ${ escapeHtml ( template . channelName || data . smsChannels . find ( item => item . id === template . channelId ) ? . signature || '-' ) } </td><td><code> ${ escapeHtml ( template . code ) } </code></td><td> ${ escapeHtml ( template . type ) } </td><td> ${ template . sentCount || 0 } / ${ template . failedCount || 0 } </td><td> ${ statusBadge ( template . status ) } </td><td> ${ statusBadge ( template . templateStatus || '-' ) } </td><td><button class="button" data-action="edit-sms-template" data-template-id=" ${ template . id } ">编辑</button> ${ template . status === '草稿' ? ` <button class="button" data-action="submit-sms-template" data-template-id=" ${ template . id } ">提交审核</button> ` : '' } <button class="button" data-action="disable-sms-template" data-template-id=" ${ template . id } ">停用</button></td></tr> ` ) . join ( '' ) } </tbody></table></section></div> ` ;
if ( state . smsTab === 'logs' ) return ` <div class="page-shell"> ${ pageHead ( '短信发送日志' , notice ) } ${ smsNav } <section class="table-panel"><table class="platform-table"><thead><tr><th>日志 ID</th><th>租户</th><th>模板</th><th>手机号</th><th>Provider</th><th>时间</th><th>状态</th></tr></thead><tbody> ${ ( data . smsLogs || [ ] ) . map ( item => ` <tr><td><code> ${ escapeHtml ( item . id ) } </code></td><td> ${ escapeHtml ( tenantById ( item . tenantId ) ? . name || item . tenantId ) } </td><td> ${ escapeHtml ( data . smsTemplates . find ( template => template . id === item . templateId ) ? . name || item . templateId || '-' ) } </td><td> ${ escapeHtml ( item . phone ) } </td><td> ${ escapeHtml ( item . provider ) } </td><td> ${ escapeHtml ( item . updatedAt ) } </td><td> ${ statusBadge ( item . status ) } </td></tr> ` ) . join ( '' ) } </tbody></table> ${ data . smsLogs ? . length ? '' : emptyBlock ( '暂无短信发送日志' , '发送短信后会在这里显示脱敏记录。' ) } </section></div> ` ;
return ` <div class="page-shell"> ${ pageHead ( 'SMS 短信服务' , notice , ` ${ canWrite ( 'platform:sms:write' ) ? ` <button class="button primary" data-action="new-sms-channel"> ${ icon ( 'plus' ) } 短信渠道</button> ` : '' } ` ) }
<section class="usage-kpis"><div><span>启用渠道</span><strong> ${ data . smsChannels . filter ( item => item . enabled ) . length } </strong><small>租户自有短信服务</small><i><b style="width:66%"></b></i></div><div><span>本月发送</span><strong> ${ formatNumber ( sent ) } </strong><small>总额度 ${ formatNumber ( quota ) } </small><i><b style="width: ${ quota ? Math . min ( 100 , sent / quota * 100 ) : 0 } %"></b></i></div><div><span>模板通过</span><strong> ${ data . smsTemplates . filter ( item => item . status === '已通过' ) . length } </strong><small> ${ data . smsTemplates . length } 个模板</small><i><b style="width:67%"></b></i></div><div><span>权限</span><strong> ${ hasPermission ( 'platform:sms:write' ) ? '可维护' : '只读' } </strong><small><code>platform:sms:read/write</code></small><i><b style="width: ${ hasPermission ( 'platform:sms:write' ) ? 100 : 50 } %"></b></i></div></section>
<nav class="billing-tabs"><button class=" ${ state . smsTab === 'channels' ? 'is-active' : '' } " data-sms-tab="channels">短信渠道</button><button class=" ${ state . smsTab === 'templates' ? 'is-active' : '' } " data-sms-tab="templates">短信模板</button><button class=" ${ state . smsTab === 'logs' ? 'is-active' : '' } " data-sms-tab="logs">发送日志</button></nav>
@@ -857,10 +976,13 @@ function renderSms() {
function renderPaymentSettings ( ) {
const activeApps = data . paymentApps . filter ( item => item . status === '正常' ) . length ;
const notice = platformApi ? . isEnabled ( ) ? '已接入平台支付设置 API, 可维护平台收款应用、租户代管应用和支付事件。' : '集中管理平台收款、租户支付代管和回调健康。' ;
const notice = '集中管理平台收款、租户支付代管、支付渠道 和回调健康。' ;
const paymentNav = ` <nav class="billing-tabs"><button class=" ${ state . paymentTab === 'apps' ? 'is-active' : '' } " data-payment-tab="apps">支付应用</button><button class=" ${ state . paymentTab === 'channels' ? 'is-active' : '' } " data-payment-tab="channels">支付渠道</button><button class=" ${ state . paymentTab === 'events' ? 'is-active' : '' } " data-payment-tab="events">支付事件</button><button class=" ${ state . paymentTab === 'rebates' ? 'is-active' : '' } " data-payment-tab="rebates">返佣汇总</button><button class=" ${ state . paymentTab === 'settlement' ? 'is-active' : '' } " data-payment-tab="settlement">结算与风控</button></nav> ` ;
if ( state . paymentTab === 'channels' ) return ` <div class="page-shell"> ${ pageHead ( '平台支付渠道' , '管理平台收款应用的支付服务商、回调地址和密钥引用。' , ` <button class="button primary" data-action="new-payment-channel"> ${ icon ( 'plus' ) } 新建渠道</button> ` ) } ${ paymentNav } <section class="table-panel"><table class="platform-table"><thead><tr><th>应用</th><th>渠道</th><th>支付服务商</th><th>模式</th><th>回调路径</th><th>密钥引用</th><th>状态</th><th></th></tr></thead><tbody> ${ data . paymentChannels . map ( item => ` <tr><td> ${ escapeHtml ( data . paymentApps . find ( appItem => appItem . id === item . appId ) ? . appName || item . appId ) } </td><td><strong> ${ escapeHtml ( item . displayName ) } </strong><small> ${ escapeHtml ( item . id ) } </small></td><td> ${ escapeHtml ( item . provider ) } </td><td> ${ escapeHtml ( item . mode ) } </td><td><code> ${ escapeHtml ( item . callbackPath || '-' ) } </code></td><td><code> ${ escapeHtml ( item . secretRef || '-' ) } </code></td><td> ${ statusBadge ( businessStatusLabel ( item . status ) ) } </td><td><button class="button" data-action="edit-payment-channel" data-channel-id=" ${ item . id } ">编辑</button><button class="button" data-action="disable-payment-channel" data-channel-id=" ${ item . id } ">停用</button></td></tr> ` ) . join ( '' ) } </tbody></table> ${ data . paymentChannels . length ? '' : emptyBlock ( '暂无平台支付渠道' , '请先创建平台收款应用。' ) } </section></div> ` ;
if ( state . paymentTab === 'rebates' ) { const summary = data . rebateSummary || { } ; return ` <div class="page-shell"> ${ pageHead ( '平台返佣汇总' , '查看平台交易的返佣、待结算、已结算与异常数据。' ) } ${ paymentNav } <section class="metric-grid"><article class="metric"><small>交易总额</small><strong> ${ money ( Number ( summary . grossAmountCents || 0 ) / 100 ) } </strong></article><article class="metric"><small>返佣总额</small><strong> ${ money ( Number ( summary . commissionAmountCents || 0 ) / 100 ) } </strong></article><article class="metric"><small>待结算</small><strong> ${ money ( Number ( summary . pendingSettlementCents || 0 ) / 100 ) } </strong></article><article class="metric"><small>已结算</small><strong> ${ money ( Number ( summary . paidSettlementCents || 0 ) / 100 ) } </strong></article><article class="metric"><small>异常数</small><strong> ${ summary . exceptionCount || 0 } </strong></article></section></div> ` ; }
return ` <div class="page-shell"> ${ pageHead ( '支付设置' , notice , ` ${ canWrite ( 'platform:payment:write' ) ? ` <button class="button primary" data-action="new-payment-app"> ${ icon ( 'plus' ) } 支付应用</button> ` : '' } ` ) }
<section class="usage-kpis"><div><span>正常应用</span><strong> ${ activeApps } </strong><small>共 ${ data . paymentApps . length } 个收款应用</small><i><b style="width: ${ Math . min ( 100 , activeApps / Math . max ( 1 , data . paymentApps . length ) * 100 ) } %"></b></i></div><div><span>本日支付事件</span><strong> ${ data . paymentEvents . length } </strong><small>含成功、回调失败与人工确认</small><i><b style="width:52%"></b></i></div><div><span>回调异常</span><strong> ${ data . paymentEvents . filter ( item => item . status === '回调失败' ) . length } </strong><small>需要财务或运维介入</small><i class="orange"><b style="width:34%"></b></i></div><div><span>权限</span><strong> ${ hasPermission ( 'platform:payment:write' ) ? '可维护' : '只读' } </strong><small><code>platform:payment:read/write</code></small><i><b style="width: ${ hasPermission ( 'platform:payment:write' ) ? 100 : 50 } %"></b></i></div></section>
<nav class="billing-tabs"><button class=" ${ state . paymentTab === 'apps' ? 'is-active' : '' } " data-payment-tab="apps">支付应用</button><button class=" ${ state . paymentTab === 'events' ? 'is-active' : '' } " data-payment-tab="events">支付事件</button><button class=" ${ state . paymentTab === 'settlement' ? 'is-active' : '' } " data-payment-tab="settlement">结算与风控</button></nav>
${ paymentNav }
${ state . paymentTab === 'apps' ? ` <section class="table-panel"><table class="platform-table"><thead><tr><th>租户</th><th>应用</th><th>Provider</th><th>模式</th><th>结算</th><th>Secret Ref</th><th>状态</th><th></th></tr></thead><tbody> ${ data . paymentApps . map ( appItem => ` <tr><td><strong> ${ escapeHtml ( tenantById ( appItem . tenantId ) ? . name || '-' ) } </strong><small> ${ appItem . id } </small></td><td> ${ escapeHtml ( appItem . appName ) } </td><td><code> ${ escapeHtml ( appItem . provider ) } </code></td><td> ${ escapeHtml ( appItem . mode ) } </td><td> ${ escapeHtml ( appItem . settlement ) } </td><td><code> ${ escapeHtml ( appItem . secretRef ) } </code></td><td> ${ statusBadge ( appItem . status ) } </td><td><div class="row-actions"> ${ canWrite ( 'platform:payment:write' ) ? ` <button class="row-action" data-action="edit-payment-app" data-payment-id=" ${ appItem . id } "> ${ icon ( 'pencil' ) } </button><button class="switch ${ appItem . status !== '未启用' ? 'is-on' : '' } " data-action="toggle-payment-app" data-payment-id=" ${ appItem . id } "><i></i></button> ` : '' } </div></td></tr> ` ) . join ( '' ) } </tbody></table></section> ` : state . paymentTab === 'events' ? ` <section class="table-panel"><table class="platform-table"><thead><tr><th>事件</th><th>应用</th><th>订单号</th><th>金额</th><th>渠道</th><th>回调</th><th>时间</th><th>状态</th></tr></thead><tbody> ${ data . paymentEvents . map ( event => { const appItem = data . paymentApps . find ( item => item . id === event . appId ) ; return ` <tr><td><code> ${ event . id } </code></td><td> ${ escapeHtml ( appItem ? . appName || '-' ) } <small> ${ escapeHtml ( tenantById ( appItem ? . tenantId ) ? . name || '-' ) } </small></td><td><code> ${ escapeHtml ( event . orderNo ) } </code></td><td><strong> ${ money ( event . amount ) } </strong></td><td> ${ escapeHtml ( event . channel ) } </td><td> ${ escapeHtml ( event . notified ) } </td><td> ${ escapeHtml ( event . updatedAt ) } </td><td> ${ statusBadge ( event . status ) } </td></tr> ` ; } ).join('')}</tbody></table></section> ` : ` <div class="staff-layout"><section class="panel"><div class="panel-head"><div><h2>平台风控规则</h2><span>配置证书、回调、人工确认等支付安全策略</span></div></div> ${ [ [ '证书过期提醒' , '支付证书 14 天内过期生成高危告警' ] , [ '回调失败重试' , '按 1m/5m/30m/2h 退避重试并记录审计' ] , [ '人工确认保护' , '线下收款必须二次确认并保留流水号脱敏值' ] ] . map ( ( [ name , desc ] ) => ` <article class="role-row"><span> ${ icon ( 'shield-check' ) } </span><div><strong> ${ name } </strong><small> ${ desc } </small></div><b>启用</b><button class="button" data-action="payment-rule-detail">查看</button></article> ` ) . join ( '' ) } </section><section class="panel"><div class="panel-head"><div><h2>支付能力边界</h2><span>区分平台收款和租户收款代管</span></div></div><div class="policy-timeline"><p><b>平台收款应用</b><span>用于套餐、模块和增值服务收款</span></p><p><b>租户代管应用</b><span>维护学生端收款渠道和回调地址</span></p><p><b>支付事件</b><span>只读跟踪交易、退款、回调和人工确认状态</span></p></div></section></div> ` } </div> ` ;
}
@@ -869,7 +991,7 @@ function renderStaff() {
const staffRows = data . staff . filter ( person => ! query || [ person . name , person . username , person . email , person . phone , person . role , person . status ] . some ( value => String ( value || '' ) . toLowerCase ( ) . includes ( query ) ) ) ;
return ` <div class="page-shell"> ${ pageHead ( '平台员工与权限' , '平台权限独立于租户权限;禁用员工可同步撤销会话,且不能禁用最后一名超级管理员。' , ` ${ canWrite ( 'platform:staff:write' ) ? ` <button class="button primary" data-action="new-staff"> ${ icon ( 'user-plus' ) } 新建员工</button> ` : '' } ` ) }
<section class="filter-bar"><label> ${ icon ( 'search' ) } <input id="staffSearch" value=" ${ escapeHtml ( state . staffSearch ) } " placeholder="搜索姓名、账号、邮箱、角色或状态" /></label><button class="button" data-action="apply-staff-search"> ${ icon ( 'search' ) } 查询员工</button></section>
<div class="staff-layout"><section class="panel"><div class="panel-head"><div><h2>平台员工</h2><span> ${ staffRows . length } 名匹配 · ${ data . staff . filter ( item => item . status === '正常' ) . length } 名活跃</span></div></div> ${ staffRows . map ( person => ` <article class="staff-row"><span> ${ escapeHtml ( person . name . slice ( 0 , 1 ) ) } </span><div><strong> ${ escapeHtml ( person . name ) } </strong><small> ${ escapeHtml ( person . email ) } · ${ escapeHtml ( person . phone ) } </small></div><b> ${ escapeHtml ( person . role ) } </b><em> ${ person . permissions . includes ( '*' ) ? '全部权限 ' : ` ${ person . permissions . length } 项权限 ` } </em><i class="status ${ tone ( person . status ) } "> ${ person . status } </i><div class="row-actions"><button class="row-action" data-action="edit-staff" data-staff-id=" ${ person . id } "> ${ icon ( 'pencil' ) } </button><button class="switch ${ person . status === '正常' ? 'is-on' : '' } " data-action="toggle-staff" data-staff-id=" ${ person . id } "><i></i></button></div></article> ` ) . join ( '' ) || emptyBlock ( '没有匹配员工' , '调整姓名、账号、 邮箱、角色或状态关键词。' ) } </section><section class="panel"><div class="panel-head"><div><h2>权限目录 </h2><span>遵循最小权限;当前演示角色可在顶部切换 </span></div></div> ${ Object . entries ( roleDefinitions ) . filter ( ( [ key ] ) => key != = 'readonly' ) . map ( ( [ key , role ] ) => ` <article class="role-row"><span> ${ icon ( 'shield-check' ) } </span><div><strong> ${ role . label } </strong><small> ${ role . permission s. includes ( '*' ) ? '全部平台权限 ' : role . permission s. join ( ' · ' ) } </small></div><b> ${ data . staff . filter ( person => person . role === role . label ) . length } 人</b><button class="button" data-action="new-staff" data-role-key =" ${ key } ">按此角色创建</button></article> ` ) . join ( '' ) } </section></div></div>` ;
<div class="staff-layout"><section class="panel"><div class="panel-head"><div><h2>平台员工</h2><span> ${ staffRows . length } 名匹配 · ${ data . staff . filter ( item => item . status === '正常' ) . length } 名活跃</span></div></div> ${ staffRows . map ( person => ` <article class="staff-row"><span> ${ escapeHtml ( person . name . slice ( 0 , 1 ) ) } </span><div><strong> ${ escapeHtml ( person . name ) } </strong><small> ${ escapeHtml ( person . email ) } · ${ escapeHtml ( person . phone ) } </small></div><b> ${ escapeHtml ( person . role ) } </b><em> ${ person . permissions . includes ( '*' ) ? '全部平台功能 ' : ` ${ person . permissions . length } 项功能 ` } </em><i class="status ${ tone ( person . status ) } "> ${ person . status } </i><div class="row-actions"><button class="row-action" data-action="edit-staff" data-staff-id=" ${ person . id } "> ${ icon ( 'pencil' ) } </button><button class="switch ${ person . status === '正常' ? 'is-on' : '' } " data-action="toggle-staff" data-staff-id=" ${ person . id } "><i></i></button></div></article> ` ) . join ( '' ) || emptyBlock ( '没有匹配员工' , '调整姓名、邮箱、手机号 、角色或状态关键词。' ) } </section><section class="panel"><div class="panel-head"><div><h2>平台角色与功能 </h2><span>按岗位分配可使用的业务功能 </span></div></div> ${ data . platformRoles . map ( role => { const features = role . permissionCodes . map ( code => data . platformPermissions [ code ] || code ) ; return ` <article class="role-row"><span> ${ icon ( 'shield-check' ) } </span><div><strong> ${ escapeHtml ( role. name ) } </strong><small> ${ escapeHtml ( role . description || ( feature s. includes ( '*' ) ? '全部平台功能 ' : feature s. join ( '、' ) ) ) } </small></div><b> ${ data . staff . filter ( person => person . roleIds ? . includes ( role . id ) ) .length } 人</b><button class="button" data-action="new-staff" data-role-id =" ${ role . id } ">按此角色创建</button></article> ` ; } ).join('') || emptyBlock('暂无可用平台角色', '请先创建平台角色并选择可用功能。')} </section></div></div>` ;
}
function renderAudit ( ) {
@@ -888,12 +1010,12 @@ function renderAlerts() {
let alerts = state . alertStatus === 'all' ? data . alerts : data . alerts . filter ( alert => alert . status === state . alertStatus ) ;
if ( state . scenario === 'empty' ) alerts = [ ] ;
return ` <div class="page-shell"> ${ pageHead ( '审计告警与通知' , '按 open → acknowledged → resolved/ignored 流转;状态接口禁止重新打开。规则当前只读。' , ` ${ canWrite ( 'platform:audit:notification' ) ? ` <button class="button primary" data-action="new-audit-channel"> ${ icon ( 'plus' ) } 新建通知渠道</button> ` : '' } ` ) }
<section class="panel alert-rules"><div class="panel-head"><div><h2>告警规则</h2><span>后端只读 · ${ data . alertRules . length } 条启用规则</span></div><code>GET /api/platform-admin/audit-alert-rules</code>< /div><div class="rule-grid"> ${ data . alertRules . map ( rule => ` <article><span class="severity ${ tone ( rule . severity ) } "> ${ rule . severity } </span><div><strong> ${ escapeHtml ( rule . name ) } </strong><small><code> ${ escapeHtml ( rule . code ) } </code> · ${ escapeHtml ( rule . window ) } · ${ escapeHtml ( rule . threshold ) } </small><p> ${ escapeHtml ( rule . target ) } </p></div> ${ statusBadge ( rule . enabled ? '启用' : '已停用' ) } </article> ` ) . join ( '' ) } </div></section>
<section class="panel alert-rules"><div class="panel-head"><div><h2>告警规则</h2><span> ${ data . alertRules . length } 条启用规则</span></div></div><div class="rule-grid"> ${ data . alertRules . map ( rule => ` <article><span class="severity ${ tone ( rule . severity ) } "> ${ rule . severity } </span><div><strong> ${ escapeHtml ( rule . name ) } </strong><small><code> ${ escapeHtml ( rule . code ) } </code> · ${ escapeHtml ( rule . window ) } · ${ escapeHtml ( rule . threshold ) } </small><p> ${ escapeHtml ( rule . target ) } </p></div> ${ statusBadge ( rule . enabled ? '启用' : '已停用' ) } </article> ` ) . join ( '' ) } </div></section>
<div class="alert-layout"><section class="panel"><div class="panel-head"><div><h2>告警队列</h2><span>开放 ${ data . alerts . filter ( a => a . status === 'open' ) . length } · 已确认 ${ data . alerts . filter ( a => a . status === 'acknowledged' ) . length } · 已结束 ${ data . alerts . filter ( a => [ 'resolved' , 'ignored' ] . includes ( a . status ) ) . length } </span></div><div class="filter-segment"> ${ [ [ 'all' , '全部' ] , [ 'open' , '开放' ] , [ 'acknowledged' , '已确认' ] , [ 'resolved' , '已解决' ] , [ 'ignored' , '已忽略' ] ] . map ( ( [ value , label ] ) => ` <button class=" ${ state . alertStatus === value ? 'is-active' : '' } " data-alert-status=" ${ value } "> ${ label } </button> ` ) . join ( '' ) } </div></div> ${ alerts . length ? alerts . map ( alert => ` <article class="security-alert"><span class="alert-level ${ tone ( alert . severity ) } "> ${ alert . severity } </span><div><small> ${ alert . id } · ${ alert . owner } </small><strong> ${ escapeHtml ( alert . title ) } </strong><p> ${ escapeHtml ( alert . summary ) } </p><em> ${ alert . updatedAt } </em></div><b class="status ${ tone ( alert . status ) } "> ${ alertStatusLabel ( alert . status ) } </b><div class="inline-actions"><button class="button" data-alert-detail=" ${ alert . id } ">查看证据</button> ${ alert . status === 'open' && canWrite ( 'platform:audit:alert' ) ? ` <button class="button primary" data-action="transition-alert" data-alert-id=" ${ alert . id } " data-next-status="acknowledged">确认</button> ` : '' } ${ [ 'open' , 'acknowledged' ] . includes ( alert . status ) && canWrite ( 'platform:audit:alert' ) ? ` <button class="button" data-action="transition-alert" data-alert-id=" ${ alert . id } " data-next-status="resolved">解决</button> ` : '' } </div></article> ` ) . join ( '' ) : emptyBlock ( '当前筛选没有告警' , '告警规则仍保持只读,可切换筛选查看历史状态。' ) } </section>
<aside class="channel-panel"><section class="panel"><div class="panel-head"><div><h2>通知渠道</h2><span> ${ data . auditChannels . filter ( channel => channel . enabled ) . length } 个启用</span></div></div> ${ data . auditChannels . map ( channel => ` <article class="channel-row"><span> ${ icon ( channel . provider === '邮件' ? 'mail' : channel . provider === '短信' ? 'message-square' : 'webhook' ) } </span><div><strong> ${ escapeHtml ( channel . name ) } </strong><small> ${ escapeHtml ( channel . endpoint ) } · ≥ ${ channel . minSeverity } · ${ channel . maxAttempts } 次</small></div><b class="status ${ tone ( channel . lastStatus ) } "> ${ channel . lastStatus } </b><div class="row-actions"><button class="row-action" data-action="edit-audit-channel" data-channel-id=" ${ channel . id } "> ${ icon ( 'pencil' ) } </button><button class="switch ${ channel . enabled ? 'is-on' : '' } " data-action="toggle-audit-channel" data-channel-id=" ${ channel . id } "><i></i></button></div></article> ` ) . join ( '' ) } </section><section class="panel failure-panel"><div class="panel-head"><div><h2>通知失败事件</h2><span>最近24小时</span></div></div><div> ${ data . notificationEvents . filter ( event => event . status === '失败' ) . map ( event => ` <article><strong> ${ escapeHtml ( event . subject ) } </strong><p> ${ escapeHtml ( event . error ) } · 已尝试 ${ event . attempts } 次</p><div class="inline-actions"><button class="button" data-notification-detail=" ${ event . id } ">查看详情</button><button class="button" data-action="retry-notification" data-event-id=" ${ event . id } ">立即重试</button></div></article> ` ) . join ( '' ) || '<p>没有通知失败事件。</p>' } </div></section></aside></div></div> ` ;
}
const templates = { overview : renderOverview , tenants : renderTenants , tenantDetail : renderTenantDetail , billingProfile : renderBillingProfile , subscriptions : renderSubscriptions , billing : renderBilling , usage : renderUsage , dunning : renderDunning , bank : renderBank , crm : renderCrm , sms : renderSms , paymentSettings : renderPaymentSettings , staff : renderStaff , audit : renderAudit , alerts : renderAlerts } ;
const templates = { overview : renderOverview , tenants : renderTenants , domains : renderDomains , tenantDetail : renderTenantDetail , billingProfile : renderBillingProfile , catalog : renderCatalog , subscriptions : renderSubscriptions , saasFinance : renderSaasFinance , billing : renderBilling , usage : renderUsage , dunning : renderDunning , bank : renderBank , crm : renderCrm , sms : renderSms , paymentSettings : renderPaymentSettings , staff : renderStaff , roles : renderRoles , audit : renderAudit , alerts : renderAlerts } ;
function render ( ) {
renderNav ( ) ;
@@ -965,7 +1087,7 @@ function closeModal(options = {}) {
return true ;
}
function navigationSnapshot ( ) {
return { view : state . view , selectedTenant : state . selectedTenant , selectedInvoice : state . selectedInvoice , selectedAlert : state . selectedAlert , tenantFilter : state . tenantFilter , tenantPlan : state . tenantPlan , tenantSearch : state . tenantSearch , invoiceSearch : state . invoiceSearch , invoiceStatus : state . invoiceStatus , billingTab : state . billingTab , bankTab : state . bankTab , crmTab : state . crmTab , smsTab : state . smsTab , paymentTab : state . paymentTab , 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 } ;
return { view : state . view , selectedTenant : state . selectedTenant , selectedInvoice : state . selectedInvoice , selectedAlert : state . selectedAlert , tenantFilter : state . tenantFilter , tenantPlan : state . tenantPlan , tenantSearch : state . tenantSearch , invoiceSearch : state . invoiceSearch , invoiceStatus : state . invoiceStatus , billingTab : state . billingTab , bankTab : state . bankTab , crmTab : state . crmTab , smsTab : state . smsTab , paymentTab : state . paymentTab , saasFinanceTab : state . saasFinanceTab , bankSearch : state . bankSearch , selectedBank : state . selectedBank , selectedBankNode : state . selectedBankNode , staffSearch : state . staffSearch , usagePeriod : state . usagePeriod , auditRange : state . auditRange , auditSeverity : state . auditSeverity , auditSearch : state . auditSearch , alertStatus : state . alertStatus , scrollY : window . scrollY } ;
}
function syncNavigationState ( ) { if ( history . state ? . [ HISTORY _APP _KEY ] ) history . replaceState ( { ... history . state , snapshot : navigationSnapshot ( ) } , '' , location . href ) ; }
function navigate ( view , options = { } ) {
@@ -986,6 +1108,7 @@ function navigate(view, options = {}) {
state . previousView = state . view ; state . view = view ; state . scenario = 'normal' ; closeModal ( { force : true } ) ; render ( ) ; scrollTo ( 0 , 0 ) ;
if ( options . browser !== false ) { historySequence += 1 ; history . pushState ( { [ HISTORY _APP _KEY ] : true , sequence : historySequence , snapshot : navigationSnapshot ( ) } , '' , location . href ) ; }
if ( [ 'tenantDetail' , 'billingProfile' ] . includes ( view ) ) hydrateTenantDetail ( state . selectedTenant ) ;
if ( view === 'bank' ) loadBankWorkspace ( ) ;
} ;
if ( ! options . force && hasUnsavedChanges ( ) ) return requestLeave ( perform ) ;
perform ( ) ; return true ;
@@ -1077,7 +1200,7 @@ async function simulateWrite({ key, permission, validate, apply, success, close
} ;
finish ( ) ;
}
async function executeBackendMutation ( operationId , input , apply , { refresh = true } = { } ) {
async function executeBackendMutation ( operationId , input , apply , { refresh = true , pathParams = { } } = { } ) {
if ( ! platformApi ? . isEnabled ( ) ) {
apply ? . ( null ) ;
return true ;
@@ -1085,6 +1208,7 @@ async function executeBackendMutation(operationId, input, apply, { refresh = tru
try {
const payload = await platformApi . request ( operationId , {
body : platformApi . buildBody ( operationId , typeof input === 'function' ? input ( ) : input ) ,
pathParams ,
} ) ;
apply ? . ( payload ) ;
if ( refresh ) hydrateFromBackend ( { silent : true } ) ;
@@ -1127,11 +1251,44 @@ function channelEndpointFormValue(channel) {
}
const modalTemplates = {
saasFeature ( ) {
return modalFrame ( '新建 SaaS 功能' , '功能将用于套餐版本与租户权益校验。' , ` <form id="saasFeatureForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><div class="form-grid"> ${ formField ( '功能编码 *' , 'code' , '' , 'required placeholder="例如 learning.practice"' ) } ${ formField ( '功能名称 *' , 'name' , '' , 'required' ) } ${ formField ( '业务分类 *' , 'category' , '' , 'required' ) } ${ formField ( '功能说明' , 'description' ) } ${ formField ( '参考价(元)' , 'referencePrice' , '0' , 'type="number" min="0" step="0.01"' ) } <label><span>状态</span><select name="status"><option value="active">正常</option><option value="draft">草稿</option></select></label></div></form> ` , ` <button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="saasFeatureForm">保存功能</button> ` , { id : 'SaaS 商品目录' , wide : true } ) ;
} ,
saasOffering ( ) {
return modalFrame ( '新建 SaaS 商品' , '商品可为基础套餐或增值包。' , ` <form id="saasOfferingForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><div class="form-grid"> ${ formField ( '商品编码 *' , 'code' , '' , 'required' ) } ${ formField ( '商品名称 *' , 'name' , '' , 'required' ) } <label><span>商品类型</span><select name="type"><option value="base_plan">基础套餐</option><option value="add_on">增值包</option></select></label> ${ formField ( '商品说明' , 'description' ) } <label><span>状态</span><select name="status"><option value="active">正常</option><option value="draft">草稿</option></select></label></div></form> ` , ` <button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="saasOfferingForm">保存商品</button> ` , { id : 'SaaS 商品目录' , wide : true } ) ;
} ,
saasLimit ( ) {
const features = data . saasCatalog ? . features || [ ] ;
return modalFrame ( '新建 SaaS 功能限额' , '限额定义供套餐版本配置和运行时用量校验使用。' , ` <form id="saasLimitForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><div class="form-grid"> ${ formField ( '指标编码 *' , 'metricCode' , '' , 'required placeholder="例如 students.current"' ) } <label><span>所属功能 *</span><select name="featureCode"> ${ features . map ( item => ` <option value=" ${ item . code } "> ${ escapeHtml ( item . name ) } </option> ` ) . join ( '' ) } </select></label> ${ formField ( '限额名称 *' , 'name' , '' , 'required' ) } ${ formField ( '单位 *' , 'unit' , 'count' , 'required' ) } <label><span>计量方式</span><select name="kind"><option value="current">当前值</option><option value="period">周期累计</option></select></label> ${ formField ( '告警阈值(%) ' , 'warningPercent' , '80' , 'type="number" min="1" max="100"' ) } <label class="check-row"><input name="isHardLimit" type="checkbox" checked /> 超额时拒绝继续写入</label></div></form> ` , ` <button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="saasLimitForm">保存限额</button> ` , { id : 'SaaS 功能限额' , wide : true } ) ;
} ,
tenantFeatureOverride ( ) {
const features = data . saasCatalog ? . features || [ ] ;
return modalFrame ( '租户功能覆盖' , '对单个租户强制启用或停用某项 SaaS 功能,必须记录可审计原因。' , ` <form id="tenantFeatureOverrideForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><div class="form-grid"><label><span>租户 *</span><select name="tenantId"> ${ tenantOptions ( state . selectedTenant ) } </select></label><label><span>功能 *</span><select name="featureCode"> ${ features . map ( item => ` <option value=" ${ item . code } "> ${ escapeHtml ( item . name ) } </option> ` ) . join ( '' ) } </select></label><label><span>覆盖方式</span><select name="mode"><option value="force_enabled">强制启用</option><option value="force_disabled">强制停用</option><option value="inherit">恢复套餐继承</option></select></label><label><span>到期时间</span><input type="datetime-local" name="expiresAt" /></label> ${ formField ( '操作原因 *' , 'reason' , '' , 'required' ) } </div></form> ` , ` <button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="tenantFeatureOverrideForm">保存覆盖规则</button> ` , { id : 'SaaS 租户权益' , wide : true } ) ;
} ,
saasVersion ( ) {
const offerings = data . saasCatalog ? . offerings || [ ] ;
return modalFrame ( '新建套餐版本' , '新版本先保存为草稿,确认后再单独发布。' , ` <form id="saasVersionForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><div class="form-grid"><label><span>所属商品 *</span><select name="offeringId"> ${ offerings . map ( item => ` <option value=" ${ item . id } "> ${ escapeHtml ( item . name ) } </option> ` ) . join ( '' ) } </select></label><label><span>计费周期</span><select name="billingCycle"><option value="yearly">年付</option><option value="monthly">月付</option><option value="quarterly">季付</option></select></label> ${ formField ( '原价(元)' , 'originalAmount' , '0' , 'type="number" min="0" step="0.01"' ) } ${ formField ( '售价(元)' , 'amount' , '0' , 'type="number" min="0" step="0.01"' ) } ${ formField ( '包含功能编码' , 'featureCodes' , '' , 'placeholder="多个编码用逗号分隔"' ) } <label><span>生效时间</span><input type="datetime-local" name="effectiveAt" /></label></div></form> ` , ` <button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="saasVersionForm">保存草稿</button> ` , { id : 'SaaS 版本管理' , wide : true } ) ;
} ,
platformRole ( ) {
const role = data . platformRoles . find ( item => item . id === state . modalData . roleId ) || { } ;
return modalFrame ( role . id ? '编辑平台角色' : '新建平台角色' , '选择该角色可以使用的业务功能,保存后对所属员工生效。' , ` <form id="platformRoleForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><input type="hidden" name="id" value=" ${ role . id || '' } " /><div class="form-grid"> ${ formField ( '角色编码 *' , 'code' , role . code || '' , 'required' ) } ${ formField ( '角色名称 *' , 'name' , role . name || '' , 'required' ) } ${ formField ( '角色说明' , 'description' , role . description || '' ) } </div><fieldset><legend>功能选择</legend><div class="permission-grid"> ${ Object . entries ( data . platformPermissions ) . map ( ( [ code , name ] ) => ` <label><input type="checkbox" name="permissionCodes" value=" ${ code } " ${ role . permissionCodes ? . includes ( code ) || role . permissionCodes ? . includes ( '*' ) ? 'checked' : '' } /><span><strong> ${ escapeHtml ( name ) } </strong><small> ${ escapeHtml ( code ) } </small></span></label> ` ) . join ( '' ) } </div></fieldset></form> ` , ` <button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="platformRoleForm">保存角色</button> ` , { id : '平台角色管理' , wide : true } ) ;
} ,
paymentChannel ( ) {
const channel = data . paymentChannels . find ( item => item . id === state . modalData . channelId ) || { } ;
const apps = data . paymentApps . filter ( item => item . appScope === 'platform' ) ;
return modalFrame ( channel . id ? '编辑平台支付渠道' : '新建平台支付渠道' , '渠道密钥只保存 Secret Ref, 不在页面保存明文凭据。' , ` <form id="paymentChannelForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><input type="hidden" name="id" value=" ${ channel . id || '' } " /><div class="form-grid"><label><span>平台支付应用 *</span><select name="appId"> ${ apps . map ( item => ` <option value=" ${ item . id } " ${ channel . appId === item . id ? 'selected' : '' } > ${ escapeHtml ( item . appName ) } </option> ` ) . join ( '' ) } </select></label> ${ formField ( '渠道名称 *' , 'displayName' , channel . displayName || '' , 'required' ) } ${ formField ( 'Provider *' , 'provider' , channel . provider || 'manual' , 'required' ) } ${ formField ( '模式' , 'mode' , channel . mode || 'PlatformCollect' ) } ${ formField ( '回调路径' , 'callbackPath' , channel . callbackPath || '' ) } ${ formField ( 'Secret Ref' , 'secretRef' , channel . secretRef || '' ) } <label><span>状态</span><select name="status"><option value="active">正常</option><option value="testing">测试中</option><option value="disabled">已停用</option></select></label></div></form> ` , ` <button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="paymentChannelForm">保存渠道</button> ` , { id : '平台支付设置' , wide : true } ) ;
} ,
smsTemplate ( ) {
const template = data . smsTemplates . find ( item => item . id === state . modalData . templateId ) || { } ;
const tenantId = template . tenantId || state . selectedTenant || data . smsChannels [ 0 ] ? . tenantId || '' ;
const channels = data . smsChannels . filter ( item => ! tenantId || item . tenantId === tenantId ) ;
return modalFrame ( template . id ? '编辑短信模板' : '新建短信模板' , '模板先保存为草稿,再通过独立动作提交审核。' , ` <form id="smsTemplateForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><input type="hidden" name="id" value=" ${ template . id || '' } " /><div class="form-grid"><label><span>租户 *</span><select name="tenantId"> ${ tenantOptions ( tenantId ) } </select></label><label><span>短信渠道 *</span><select name="channelId"> ${ channels . map ( item => ` <option value=" ${ item . id } " ${ template . channelId === item . id ? 'selected' : '' } > ${ escapeHtml ( item . name || item . signature ) } </option> ` ) . join ( '' ) } </select></label> ${ formField ( '模板编码 *' , 'code' , template . code || '' , 'required' ) } ${ formField ( '模板名称 *' , 'name' , template . name || '' , 'required' ) } <label><span>模板类型</span><select name="type"><option value="notification">通知</option><option value="verification">验证码</option><option value="marketing">营销</option></select></label> ${ formField ( 'Provider 模板编码' , 'providerTemplateCode' , template . providerTemplateCode || '' ) } </div><label><span>模板内容 *</span><textarea name="content" rows="5" required> ${ escapeHtml ( template . content || '' ) } </textarea></label> ${ formField ( '备注' , 'remark' , template . remark || '' ) } </form> ` , ` <button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="smsTemplateForm">保存草稿</button> ` , { id : '短信模板管理' , wide : true } ) ;
} ,
mobileBoundary ( ) {
return modalFrame ( '请在桌面端继续' , ` ${ state . modalData . actionLabel || '此操作' } 需要完整工作区。 ` , ` <div class="mobile-boundary"><span> ${ icon ( 'monitor-up' ) } </span><div><strong>移动端只支持查看与轻量处理</strong><p>租户、账务、用量、授权、员工和通知渠道的创建或复杂编辑,请在宽度不少于 768px 的桌面端完成,避免误操作。</p></div></div> ` , ` <button class="button primary" data-action="close-modal">知道了</button> ` , { id : '全局 · 移动端边界' } ) ;
} ,
createTenant ( ) {
return modalFrame ( '创建 租户' , '创建主体、独立域名和首期订阅基础。slug 与域名必须唯一 。' , ` <form id="createTenantForm" class="modal-form" novalidate><div class="form-error-summary" hidden tabindex="-1"></div><div class="form-grid"> ${ formField ( 'slug *' , 'slug' , '' , 'required placeholder="例如 xuehai-zj"' ) } ${ formField ( '租户名称 *' , 'name' , '' , 'required' ) } ${ formField ( '主体名称' , 'legalName' ) } ${ formField ( '品牌名称 ' , 'brand Name' ) } ${ formField ( '简称 ' , 'shortName ' ) } ${ formField ( '主域名 * ' , 'primaryHost ' , '' , 'required placeholder="study.example.cn"' ) } <label><span>初始套餐 *</span><select name="planCode"> ${ planOptions ( 'pro' ) } </select></label><label><span>业务模式</span><select name="mode"><option>试用</option><option>正式</option></select></label> <label><span>业务状态</span><select name="status"><option>待配置</option><option>试用</option><option>正常</option></select></label><label><span>账务状态</span><select name="billingStatus"><option>正常 </option><option>待付款 </option></select></label><label><span>首期金额(元)</span><input name="amount" type="number" min="0" step="0.01" value="0" / ></label></div></form> ` , ` <button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="createTenantForm" ${ state . submitting === 'create-tenant' ? 'disabled' : '' } > ${ state . submitting === 'create-tenant' ? ` ${ icon ( 'loader-circle' ) } 创建 中` : '创建 租户' } </button> ` , { id : 'P-02 · 创建向导 ' , wide : true } ) ;
return modalFrame ( '开通 租户' , '创建租户和首位负责人账号;套餐、域名和账务信息可在开通后继续配置 。' , ` <form id="createTenantForm" class="modal-form" novalidate><div class="form-error-summary" hidden tabindex="-1"></div><div class="form-grid"> ${ formField ( '租户短编码 *' , 'slug' , '' , 'required placeholder="例如 xuehai-zj"' ) } ${ formField ( '租户名称 *' , 'name' , '' , 'required' ) } ${ formField ( '法定 主体名称' , 'legalName' ) } ${ formField ( '负责人姓名 * ' , 'owner Name' , '' , 'required' )} ${ formField ( '负责人邮箱 ' , 'ownerEmail' , '' , 'type="email" ' ) } ${ formField ( '负责人手机号 ' , 'ownerPhone ' , '' , 'placeholder="11 位手机号"' ) } ${ formField ( '临时密码 *' , 'temporaryPassword' , '' , 'required type="password" minlength="12" autocomplete="new-password" placeholder="至少 12 位,包含字母和数字"' ) } <label><span>业务状态</span><select name="status"><option>待配置</option><option>试用</option><option>正常</option></select></label><label><span>账务状态</span><select name="billingStatus"><option>试用 </option><option>正常 </option><option>暂停</option></select ></label></div></form> ` , ` <button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="createTenantForm" ${ state . submitting === 'create-tenant' ? 'disabled' : '' } > ${ state . submitting === 'create-tenant' ? ` ${ icon ( 'loader-circle' ) } 开通 中` : '开通 租户' } </button> ` , { id : 'P-02 · 租户开通 ' , wide : true } ) ;
} ,
tenantStatus ( ) {
const tenant = tenantById ( state . modalData . tenantId ) ;
@@ -1151,7 +1308,8 @@ const modalTemplates = {
} ,
payment ( ) {
const invoice = invoiceById ( state . modalData . invoiceId ) ;
return modalFrame ( '人工确认收款' , ` ${ invoice . id } · 当前余额 ${ money ( invoice . balance ) } 。金额不可超过余额,原始支付载荷不会回显。 ` , ` <form id="paymentForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><div class="form-grid"><label><span>收款金额(元)*</span><input name="amount" type="number" min="0.01" max=" ${ invoice . balance } " value=" ${ invoice . balance } " /></label> ${ formField ( '平台收款号 *' , 'paymentNo' , ` PAY- ${ Date . now ( ) . toString ( ) . slice ( - 8 ) } ` , 'required' ) } ${ formField ( '支付渠道' , 'provider' , '线下银行转账' ) } ${ formField ( '交易流水号 *' , 'tradeNo' , '' , 'required placeholder="仅用于 Mock 入账"' ) } <label><span>支付时间 *</span><input name="paidAt" type="datetime-local" value="2026-07-23T10:10" /></label></div><label class="check-row"><input type="checkbox" name="confirm" value="yes" /> 我已核对银行流水与收款主体</label></form> ` , ` <button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="paymentForm">确认入账</button> ` , { id : 'P-08 · 资金动作' , wide : true } ) ;
const payment = paymentForInvoice ( invoice ) ;
return modalFrame ( '人工确认收款' , ` ${ invoice . id } · 待确认金额 ${ money ( payment ? . amount || invoice . balance ) } 。本操作确认后端已存在的待支付记录。 ` , ` <form id="paymentForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><div class="form-grid"> ${ formField ( '交易流水号 *' , 'tradeNo' , '' , 'required placeholder="银行或支付渠道流水号"' ) } <label><span>支付时间 *</span><input name="paidAt" type="datetime-local" value=" ${ new Date ( ) . toISOString ( ) . slice ( 0 , 16 ) } " /></label> ${ formField ( '确认原因 *' , 'reason' , '平台财务核对到账' , 'required' ) } </div><label class="check-row"><input type="checkbox" name="confirm" value="yes" /> 我已核对银行流水与收款主体</label></form> ` , ` <button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="paymentForm">确认入账</button> ` , { id : 'P-08 · 资金动作' , wide : true } ) ;
} ,
overduePreview ( ) {
const candidates = data . invoices . filter ( invoice => invoice . status === '已逾期' ) ;
@@ -1169,9 +1327,17 @@ const modalTemplates = {
const redactedHint = platformApi ? . isEnabled ( ) && channel . endpointRedacted ? ' 后端不会回显完整 URL; 编辑时必须重新输入完整 HTTPS Webhook。' : '' ;
return modalFrame ( channel . id ? '编辑催缴渠道' : '新建催缴渠道' , ` Webhook secret 为只写字段,保存后不会回显。 ${ redactedHint } ` , ` <form id="dunningChannelForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><input type="hidden" name="id" value=" ${ channel . id || '' } " /><div class="form-grid"> ${ formField ( '渠道代码 *' , 'code' , channel . code || '' , 'required' ) } ${ formField ( '渠道名称 *' , 'name' , channel . name || '' , 'required' ) } <label><span>Provider</span><select name="provider"> ${ notificationProviderOptions ( channel . provider ) } </select></label> ${ formField ( 'HTTPS Webhook *' , 'endpoint' , channelEndpointFormValue ( channel ) , 'required placeholder="https://..."' ) } ${ formField ( 'Secret( 只写) ' , 'secret' , '' , 'type="password" autocomplete="new-password"' ) } ${ formField ( '最大重试次数' , 'retry' , channel . retry || 3 , 'type="number" min="1" max="10"' ) } <label class="check-row"><input name="enabled" type="checkbox" ${ channel . enabled !== false ? 'checked' : '' } /> 启用渠道</label></div></form> ` , ` <button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="dunningChannelForm">保存渠道</button> ` , { id : 'P-08 · 通知渠道' , wide : true } ) ;
} ,
gr ant ( ) {
const gr ant = data . grants . find ( item => item . id === state . modalData . gr ant Id) || { } ;
return modalFrame ( gr ant . id ? '编辑公共题库授权 ' : '新建公共题库授权 ' , '授权范围与允许列表会按后端 DTO 保存;地区和科目字段使用 UUID 。' , ` <form id="gr ant Form" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><input type="hidden" name="id" value=" ${ gr ant . id || '' } " /><div class="form-grid"><label><span>来源题库 *</span><select name="bankId"> ${ data . banks . map ( bank => ` <option value=" ${ bank . id } " ${ bank . id === ( grant . bankId || state . selectedBank ) ? 'selected' : '' } > ${ bank . name } </option> ` ) . join ( '' ) } </select></label><label><span>授权范围 *</span><select name="scope"><option ${ gr ant . scope === '全部活跃租户' ? 'selected' : '' } >全部活跃租户</option><option ${ grant . scope === '指定套餐' ? 'selected' : '' } >指定套餐</option><option ${ grant . scope === '指定租户' ? 'selected' : '' } >指定租户</option><option ${ grant . scope === '套餐 + 租户' ? 'selected' : '' } >套餐 + 租户</option></select></label><label><span>允许套餐</span><select name="planCode"><option value="">不限制套餐</option> ${ planOptions ( grant . allowedPlanCodes ? . [ 0 ] || '' ) } </select></label><label><span>允许租户</span><select name="tenantId"><option value="">不指定租户</option> ${ tenantOptions ( grant . allowedTenantIds ? . [ 0 ] || '' ) } </select></label> ${ formField ( '地区 ID( UUID) ' , 'regionIds ', gr ant . allowedRegionIds ? . join ( ',' ) || '' , 'placeholder="多个 UUID 用逗号分隔"' ) } ${ formField ( '科目 ID( UUID) ' , 'subjectIds' , gr ant . allowedSubjectIds ? . join ( ',' ) || '' , 'placeholder="多个 UUID 用逗号分隔"' ) } <label><span>开始日期 *</span><input name="startsAt" type="date" value=" ${ grant . startsAt || '2026-07-23' } " /></label><label><span>到期日期</span><input name="expiresAt" type="date" value=" ${ grant . expiresAt || '' } " /></label><label class="check-row"><input name="localEdit" type="checkbox" ${ grant . localEdit ? 'checked' : '' } /> 允许租户本地改编</label> </div></form> ` , ` <button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="gr ant Form">保存授权 </button> ` , { id : 'P-09 · 授权规则' , wide : true } ) ;
b ank ( ) {
const b ank = bankById ( state . modalData . b ank Id) || { } ;
return modalFrame ( b ank . id ? '编辑公共题库' : '新建公共题库' , '题库保存后可继续配置分类、科目、章节和试卷 。' , ` <form id="b ank Form" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><input type="hidden" name="id" value=" ${ b ank . id || '' } " /><div class="form-grid">${ formField ( '题库名称 *' , 'name' , b ank . name || '' , 'required maxlength="300"' ) } ${ formField ( '内容版本' , 'version ', b ank . version === '-' ? '' : ( b ank . version || '' ) , 'placeholder="例如: 2026.1"' ) } </div></form> ` , ` <button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="b ank Form">保存题库 </button> ` , { id : 'P-09 · 公共题库' } ) ;
} ,
bankNode ( ) {
const node = bankNodeById ( state . modalData . nodeId ) || { } ;
const parents = data . bankNodes . filter ( item => item . questionBankId === state . selectedBank && item . isActive && item . id !== node . id ) ;
return modalFrame ( node . id ? '编辑内容节点' : '新建内容节点' , '使用中文业务层级组织题目,章节和试卷可直接录入题目。' , ` <form id="bankNodeForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><input type="hidden" name="id" value=" ${ node . id || '' } " /><div class="form-grid"> ${ formField ( '节点名称 *' , 'name' , node . name || '' , 'required maxlength="300"' ) } <label><span>节点类型 *</span><select name="nodeType"><option value="category" ${ node . nodeType === 'category' ? 'selected' : '' } >分类</option><option value="subject" ${ node . nodeType === 'subject' ? 'selected' : '' } >科目</option><option value="chapter" ${ node . nodeType === 'chapter' ? 'selected' : '' } >章节</option><option value="paper" ${ node . nodeType === 'paper' ? 'selected' : '' } >试卷</option></select></label><label><span>上级节点</span><select name="parentId"><option value="">题库根层级</option> ${ parents . map ( item => ` <option value=" ${ item . id } " ${ item . id === ( node . parentId || state . selectedBankNode ) ? 'selected' : '' } > ${ ' ' . repeat ( item . depth || 0 ) } ${ escapeHtml ( item . name ) } </option> ` ) . join ( '' ) } </select></label> ${ formField ( '排序' , 'sortOrder' , node . sortOrder || 0 , 'type="number" min="0"' ) } </div></form> ` , ` <button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="bankNodeForm">保存节点</button> ` , { id : 'P-09 · 内容结构' } ) ;
} ,
batchBankNodes ( ) {
return modalFrame ( '批量创建章节或试卷' , '每行一个名称,一次最多创建 100 个。' , ` <form id="bankNodeBatchForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><div class="form-grid"><label><span>类型</span><select name="nodeType"><option value="chapter">章节</option><option value="paper">试卷</option></select></label><label><span>上级节点</span><select name="parentId"><option value="">题库根层级</option> ${ data . bankNodes . filter ( item => item . questionBankId === state . selectedBank && item . isActive ) . map ( item => ` <option value=" ${ item . id } " ${ item . id === state . selectedBankNode ? 'selected' : '' } > ${ ' ' . repeat ( item . depth || 0 ) } ${ escapeHtml ( item . name ) } </option> ` ) . join ( '' ) } </select></label><label class="full"><span>名称列表 *</span><textarea name="names" rows="10" required placeholder="第一章 函数与极限 第二章 导数与微分"></textarea></label></div></form> ` , ` <button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="bankNodeBatchForm">批量创建</button> ` , { id : 'P-09 · 批量创建' , wide : true } ) ;
} ,
crmConfig ( ) {
const config = data . crmConfigs . find ( item => item . id === state . modalData . crmId ) || { } ;
@@ -1188,12 +1354,13 @@ const modalTemplates = {
} ,
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:wri te' , 'platform:question_bank:read' , 'platform:question_bank:grant' , 'platform:question_bank:ops' , 'platform:crm:read' , 'platform:crm:write' , 'platform:sms:read' , 'platform:sms:write' , 'platform:payment:read' , 'platform:payment:write' , 'platform:staff:read' , 'platform:staff:write' , 'platform:staff:status' , 'platform:audit:read' , 'platform:audit:export' , 'platform:audit:alert' , 'platform:audit:notification' ] ;
const presetPermissions = roleDefinitions [ state . modalData . roleKey ] ? . permissions || [ ] ;
return modalFrame ( person . id ? '编辑平台员工' : '新建平台员工' , 'Auth 用户必须存在;按最小权限分组授权。' , ` <form id="staffForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><input type="hidden" name="id" value=" ${ person . id || '' } " /><div class="form-grid"> ${ formField ( 'Auth User ID *' , 'authUserId' , person . authUserId || '' , 'required' ) } ${ formField ( '用户名 *' , 'username' , person . username || '' , 'required' ) } ${ formField ( '姓名 *' , 'name' , person . name || '' , 'required' ) } ${ formField ( '邮箱 *' , 'email' , person . email || '' , 'required type="email"' ) } ${ formField ( '手机号' , 'phone' , editablePhone ) } <label><span>员工角色</span><select name="role"> ${ [ '租户运营' , '平台财务' , '安全审计' , '题库运营' , '超级管理员' ] . map ( rol e => ` <option ${ selectedRole === role ? 'selected' : '' } > ${ role } </option> ` ) . join ( '' ) } </select></label></div><fieldset><legend>平台权限</legend><div class="permission-grid"> ${ permissionList . map ( permission => { const selected = person . permissions ? . inclu des ( '*' ) || person . permissions ? . includes ( permission ) || presetPermissions . includes ( '* ' ) || presetPermissions . includes ( permission ) ; return ` <label><input type="checkbox" name="permissions" value=" ${ permission } " ${ selected ? 'checked' : '' } /> ${ permission } </label> ` ; } ).join('')}</div><button type="button" class="link" data-action="select-permission-group">选择当前角色推荐权限</button><button type="button" class="link" data-action="clear-staff-form">清空整个表单</button></fieldset></form> ` , ` <button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="staffForm">保存员工</button> ` , { id : 'P-13 · 员工权限' , wide : true } ) ;
const selectedRoleIds = person . roleIds ? . length ? person . roleIds : ( sta te. modalData . roleId ? [ state . modalData . roleId ] : [ ] ) ;
const roleOptions = data . platformRoles . map ( role => {
const featureNames = role . permissionCodes . map ( cod e => data . platformPermissions [ co de] || code ) . join ( '、 ' ) || '暂无功能' ;
return ` <label><input type="checkbox" name="roleIds" value=" ${ role . id } " ${ selectedRoleIds . includes ( role . id ) ? 'checked' : '' } /><span><strong> ${ escapeHtml ( role . name ) } </strong><small> ${ escapeHtml ( role . description || featureNames ) } </small><em>包含功能: ${ escapeHtml ( featureNames ) } </em></span></label> ` ;
} ) . join ( '' ) ;
return modalFrame ( person . id ? '编辑平台员工' : '新建平台员工' , '员工账号与平台角色由后端统一管理,功能说明来自当前权限目录。' , ` <form id="staffForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><input type="hidden" name="userId" value=" ${ person . userId || person . id || '' } " /><div class="form-grid"> ${ formField ( '姓名 *' , 'name' , person . name || '' , 'required' ) } ${ formField ( '邮箱 *' , 'email' , person . email || '' , 'required type="email"' ) } ${ formField ( '手机号' , 'phone' , editablePhone , 'placeholder="已脱敏号码留空表示不修改"' ) } </div><fieldset><legend>角色与功能选择</legend><div class="permission-grid"> ${ roleOptions || '<p>当前账号无权读取平台角色目录。</p>' } </div><button type="button" class="link" data-action="clear-staff-form">清空整个表单</button></fieldset></form> ` , ` <button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="staffForm">保存员工</button> ` , { id : 'P-13 · 员工角色' , wide : true } ) ;
} ,
auditDetail ( ) {
const log = data . auditLogs . find ( item => item . id === state . modalData . auditId ) ;
@@ -1206,12 +1373,7 @@ const modalTemplates = {
notificationDetail ( ) {
const event = [ ... ( data . notificationEvents || [ ] ) , ... ( data . dunningEvents || [ ] ) ] . find ( item => item . id === state . modalData . eventId ) ;
const channel = event ? notificationChannelName ( event . channelId ) : '-' ;
return modalFrame ( '通知事件详情' , ` ${ event ? . id || '-' } · 请求与错误摘要已脱敏。 ` , ` <dl class="modal-detail"><div><dt>主题</dt><dd> ${ escapeHtml ( event ? . subject || '-' ) } </dd></div><div><dt>通知渠道</dt><dd> ${ escapeHtml ( channel ) } </dd></div><div><dt>状态</dt><dd> ${ statusBadge ( event ? . status || '-' ) } </dd></div><div><dt>尝试次数</dt><dd> ${ event ? . attempts || 0 } </dd></div><div><dt>更新时间</dt><dd> ${ escapeHtml ( event ? . updatedAt || '-' ) } </dd></div><div class="wide"><dt>错误摘要</dt><dd> ${ escapeHtml ( event ? . error || '无' ) } </dd></div><div class="wide"><dt>API 契约</dt><dd><code>PlatformAdminAuditController_events</code><br />GET /api/platform-admin/audit-notification-events</dd></div>< /dl> ` , ` <button class="button" data-copy=" ${ event ? . id || '' } "> ${ icon ( 'copy' ) } 复制事件 ID</button> ${ event ? . status === '失败' ? ` <button class="button primary" data-action="retry-notification" data-event-id=" ${ event . id } ">重试通知</button> ` : '<button class="button primary" data-action="close-modal">关闭</button>' } ` , { id : 'P-15 · 通知事件' , wide : true } ) ;
} ,
syncDetail ( ) {
const [ tenantId , bankId ] = String ( state . modalData . syncKey || '' ) . split ( ':' ) ;
const sync = data . bankSync . find ( item => item . tenantId === tenantId && item . bankId === bankId ) ;
return modalFrame ( '租户采用与同步详情' , ` ${ tenantById ( tenantId ) ? . name || '-' } · ${ bankById ( bankId ) ? . name || '-' } ` , ` <dl class="modal-detail"><div><dt>源版本</dt><dd> ${ escapeHtml ( sync ? . current || '-' ) } </dd></div><div><dt>目标版本</dt><dd> ${ escapeHtml ( sync ? . target || '-' ) } </dd></div><div><dt>任务状态</dt><dd> ${ statusBadge ( sync ? . status || '-' ) } </dd></div><div><dt>同步进度</dt><dd> ${ sync ? . progress || 0 } %</dd></div><div><dt>冲突数量</dt><dd> ${ sync ? . conflicts || 0 } </dd></div><div><dt>采用范围</dt><dd> ${ escapeHtml ( data . grants . filter ( grant => grant . bankId === bankId && ( grant . allowedTenantIds . includes ( tenantId ) || ! grant . allowedTenantIds . length ) ) . map ( grant => grant . scope ) . join ( '、' ) || '未匹配显式授权' ) } </dd></div><div class="wide"><dt>API 契约</dt><dd><code>PlatformAdminQuestionBanksController_syncStatus</code><br />GET /api/platform-admin/question-bank-sync-status</dd></div></dl> ` , ` <button class="button" data-action="close-modal">关闭</button><button class="button primary" data-action="refresh-bank-sync">刷新同步状态</button> ` , { id : 'P-09 · 采用同步' , wide : true } ) ;
return modalFrame ( '通知事件详情' , ` ${ event ? . id || '-' } · 请求与错误摘要已脱敏。 ` , ` <dl class="modal-detail"><div><dt>主题</dt><dd> ${ escapeHtml ( event ? . subject || '-' ) } </dd></div><div><dt>通知渠道</dt><dd> ${ escapeHtml ( channel ) } </dd></div><div><dt>状态</dt><dd> ${ statusBadge ( event ? . status || '-' ) } </dd></div><div><dt>尝试次数</dt><dd> ${ event ? . attempts || 0 } </dd></div><div><dt>更新时间</dt><dd> ${ escapeHtml ( event ? . updatedAt || '-' ) } </dd></div><div class="wide"><dt>错误摘要</dt><dd> ${ escapeHtml ( event ? . error || '无' ) } </dd></div></dl> ` , ` <button class="button" data-copy=" ${ event ? . id || '' } "> ${ icon ( 'copy' ) } 复制事件 ID</button> ${ event ? . status === '失败' ? ` <button class="button primary" data-action="retry-notification" data-event-id=" ${ event . id } ">重试通知</button> ` : '<button class="button primary" data-action="close-modal">关闭</button>' } ` , { id : 'P-15 · 通知事件' , wide : true } ) ;
} ,
auditChannel ( ) {
const channel = data . auditChannels . find ( item => item . id === state . modalData . channelId ) || { } ;
@@ -1261,15 +1423,52 @@ function validateNotificationChannel(values) {
document . addEventListener ( 'submit' , event => {
event . preventDefault ( ) ;
const form = event . target ;
const desktopOnlyForms = new Set ( [ 'createTenantForm' , 'tenantStatusForm' , 'billingProfileForm' , 'subscriptionForm' , 'invoiceForm' , 'paymentForm' , 'usageForm' , 'dunningChannelForm' , 'grant Form' , 'crmConfigForm' , 'smsChannelForm' , 'paymentAppForm' , 'staffForm' , 'auditChannelForm' ] ) ;
const desktopOnlyForms = new Set ( [ 'createTenantForm' , 'tenantStatusForm' , 'billingProfileForm' , 'subscriptionForm' , 'invoiceForm' , 'paymentForm' , 'usageForm' , 'dunningChannelForm' , 'bankForm' , 'bankNodeForm' , 'bankNodeBatch Form' , 'crmConfigForm' , 'smsChannelForm' , 'paymentAppForm' , 'staffForm' , 'auditChannelForm' ] ) ;
if ( desktopOnlyForms . has ( form . id ) && requireDesktop ( '提交复杂配置或资金操作' ) ) return ;
if ( form . id === 'saasFeatureForm' ) {
const values = Object . fromEntries ( new FormData ( form ) ) ;
simulateWrite ( { key : 'saas-feature' , permission : 'platform:plan:write' , operationId : 'PlatformSaasController_upsertFeature' , body : values , validate : ( ) => ! values . code || ! values . name || ! values . category ? { code : '功能编码、名称和分类不能为空' } : null , apply : payload => { const item = payload || { ... values , id : entityId ( 'FEATURE' ) , referencePriceCents : Math . round ( Number ( values . referencePrice || 0 ) * 100 ) } ; data . saasCatalog . features . unshift ( item ) ; return item ; } , success : 'SaaS 功能已保存' } ) ;
}
if ( form . id === 'saasOfferingForm' ) {
const values = Object . fromEntries ( new FormData ( form ) ) ;
simulateWrite ( { key : 'saas-offering' , permission : 'platform:plan:write' , operationId : 'PlatformSaasController_upsertOffering' , body : values , validate : ( ) => ! values . code || ! values . name ? { code : '商品编码和名称不能为空' } : null , apply : payload => { const item = payload || { ... values , id : entityId ( 'OFFERING' ) } ; data . saasCatalog . offerings . unshift ( item ) ; return item ; } , success : 'SaaS 商品已保存' } ) ;
}
if ( form . id === 'saasLimitForm' ) {
const values = Object . fromEntries ( new FormData ( form ) ) ; values . isHardLimit = Boolean ( new FormData ( form ) . get ( 'isHardLimit' ) ) ;
simulateWrite ( { key : 'saas-limit' , permission : 'platform:plan:write' , operationId : 'PlatformSaasController_upsertFeatureLimit' , body : values , validate : ( ) => ! values . metricCode || ! values . featureCode || ! values . name ? { metricCode : '指标编码、所属功能和限额名称不能为空' } : null , apply : payload => { const item = payload || { ... values , id : entityId ( 'LIMIT' ) } ; data . saasCatalog . limitDefinitions . unshift ( item ) ; return item ; } , success : 'SaaS 功能限额已保存' } ) ;
}
if ( form . id === 'tenantFeatureOverrideForm' ) {
const values = Object . fromEntries ( new FormData ( form ) ) ;
simulateWrite ( { key : 'tenant-feature-override' , permission : 'platform:plan:write' , operationId : 'PlatformSaasController_upsertTenantFeatureOverride' , body : values , validate : ( ) => ! values . tenantId || ! values . featureCode || ! values . reason ? { reason : '租户、功能和操作原因不能为空' } : null , apply : payload => payload || values , success : '租户功能覆盖规则已保存' } ) ;
}
if ( form . id === 'saasVersionForm' ) {
const values = Object . fromEntries ( new FormData ( form ) ) ;
simulateWrite ( { key : 'saas-version' , permission : 'platform:plan:write' , operationId : 'PlatformSaasController_upsertVersion' , body : values , validate : ( ) => ! values . offeringId ? { offeringId : '必须选择所属商品' } : Number ( values . amount ) < 0 ? { amount : '售价不能小于 0' } : null , apply : payload => { const item = payload || { ... values , id : entityId ( 'VERSION' ) , version : 1 , status : 'draft' , amountCents : Math . round ( Number ( values . amount || 0 ) * 100 ) , originalAmountCents : Math . round ( Number ( values . originalAmount || 0 ) * 100 ) } ; data . saasCatalog . versions . unshift ( item ) ; return item ; } , success : '套餐版本草稿已保存' } ) ;
}
if ( form . id === 'platformRoleForm' ) {
const values = Object . fromEntries ( new FormData ( form ) ) ; const permissionCodes = new FormData ( form ) . getAll ( 'permissionCodes' ) ;
let savedRole = null ;
simulateWrite ( { key : 'platform-role' , permission : 'platform:staff:write' , request : async ( ) => { savedRole = await platformApi . request ( 'PlatformBackofficeController_upsertRole' , { body : platformApi . buildBody ( 'PlatformBackofficeController_upsertRole' , values ) } ) ; await platformApi . request ( 'PlatformBackofficeController_replaceRoleBindings' , { pathParams : { roleId : savedRole . id } , body : platformApi . buildBody ( 'PlatformBackofficeController_replaceRoleBindings' , { permissionCodes , menuCodes : savedRole . menuCodes || [ ] } ) } ) ; return savedRole ; } , validate : ( ) => { const errors = { } ; if ( ! values . code ) errors . code = '角色编码不能为空' ; if ( ! values . name ) errors . name = '角色名称不能为空' ; if ( ! permissionCodes . length ) errors . permissionCodes = '至少选择一项平台功能' ; return Object . keys ( errors ) . length ? errors : null ; } , apply : payload => { const existing = data . platformRoles . find ( item => item . id === values . id ) ; const next = { id : payload ? . id || values . id || entityId ( 'ROLE' ) , code : values . code , name : values . name , description : values . description || '' , permissionCodes , menuCodes : payload ? . menuCodes || existing ? . menuCodes || [ ] } ; if ( existing ) Object . assign ( existing , next ) ; else data . platformRoles . unshift ( next ) ; return next ; } , success : '平台角色与功能绑定已保存' } ) ;
}
if ( form . id === 'paymentChannelForm' ) {
const values = Object . fromEntries ( new FormData ( form ) ) ;
simulateWrite ( { key : 'payment-channel' , permission : 'platform:payment:write' , operationId : 'PlatformAdminPaymentSettingsController_upsertChannel' , body : values , validate : ( ) => { const errors = { } ; if ( ! values . appId ) errors . appId = '必须选择平台支付应用' ; if ( ! values . displayName ) errors . displayName = '渠道名称不能为空' ; if ( ! values . provider ) errors . provider = 'Provider 不能为空' ; return Object . keys ( errors ) . length ? errors : null ; } , apply : payload => { const existing = data . paymentChannels . find ( item => item . id === values . id ) ; const next = payload || { ... values , id : values . id || entityId ( 'PAYCH' ) } ; if ( existing ) Object . assign ( existing , next ) ; else data . paymentChannels . unshift ( next ) ; return next ; } , success : '平台支付渠道已保存' } ) ;
}
if ( form . id === 'smsTemplateForm' ) {
const values = Object . fromEntries ( new FormData ( form ) ) ;
simulateWrite ( { key : 'sms-template' , permission : 'platform:sms:write' , operationId : 'PlatformAdminSmsController_upsertTemplate' , body : values , validate : ( ) => { const errors = { } ; [ 'tenantId' , 'channelId' , 'code' , 'name' , 'content' ] . forEach ( name => { if ( ! values [ name ] ) errors [ name ] = ` ${ name } 不能为空 ` ; } ) ; return Object . keys ( errors ) . length ? errors : null ; } , apply : payload => { const existing = data . smsTemplates . find ( item => item . id === values . id ) ; const next = payload ? platformApi . adapters . smsTemplate ( payload ) : { ... values , id : values . id || entityId ( 'SMSTPL' ) , status : '草稿' , templateStatus : '正常' , sentCount : 0 , failedCount : 0 } ; if ( existing ) Object . assign ( existing , next ) ; else data . smsTemplates . unshift ( next ) ; return next ; } , success : '短信模板草稿已保存' } ) ;
}
if ( form . id === 'createTenantForm' ) {
const slug = fieldValue ( form , 'slug' ) ; const host = fieldValue ( form , 'primaryHost' ) ; const name = fieldV alue( form , 'name' ) ; const amount = parseMoney ( fieldV alue( form , 'amount' ) ) ;
simulateWrite ( { key : 'create-tenant' , permission : 'platform:tenant:write' , operationId : 'PlatformAdminTenantsController_create' , body : ( ) => ( { ... Object . fromEntries ( new FormData ( form ) ) , amount } ) , validate : ( ) => {
const values = Object . fromEntries ( new FormData ( form ) ) ; const slug = v alues . slug ; const n ame = v alues . name ;
simulateWrite ( { key : 'create-tenant' , permission : 'platform:tenant:write' , operationId : 'PlatformAdminTenantsController_create' , body : ( ) => values , validate : ( ) => {
const errors = { } ; if ( ! /^[a-z0-9]+(?:-[a-z0-9]+)*$/ . test ( slug ) ) errors . slug = 'slug 仅支持小写字母、数字和连字符' ;
if ( ! name ) errors . name = '租户名称不能为空' ; if ( ! /^([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 ( ! name ) errors . name = '租户名称不能为空' ;
if ( ! values . ownerName ) errors . ownerName = '负责人姓名不能为空' ;
if ( ! values . ownerEmail && ! values . ownerPhone ) errors . ownerEmail = '负责人邮箱和手机号至少填写一项' ;
if ( values . ownerPhone && ! /^1\d{10}$/ . test ( values . ownerPhone ) ) errors . ownerPhone = '请输入 11 位中国大陆手机号' ;
if ( ! /^(?=.*[A-Za-z])(?=.*\d).{12,}$/ . test ( values . temporaryPassword || '' ) ) errors . temporaryPassword = '临时密码至少 12 位且必须同时包含字母和数字' ;
if ( data . tenants . some ( item => item . slug === slug ) ) errors . slug = 'slug 已存在' ; return Object . keys ( errors ) . length ? errors : null ;
} , apply : payload => { const fallback = { id : ` tenant_ ${ slug . replaceAll ( '-' , '_' ) } ` , slug , name , legal : values . legalName || name , brand : name , shortName : name , host : '-' , plan : '未订阅' , planCode : '' , status : values . status , billing : values . billingStatus , mode : '-' , students : 0 , questions : 0 , storageGB : 0 , trafficTB : 0 , expires : '' , balance : 0 , owner : values . ownerName , ownerPhone : values . ownerPhone || '-' , createdAt : todayTime ( ) } ; const tenantPayload = payload ? . tenant || payload ? . item ; const tenant = tenantPayload ? platformApi . adapters . tenant ( tenantPayload , fallback , { } ) : fallback ; tenant . owner = values . ownerName ; tenant . ownerPhone = values . ownerPhone || '-' ; data . tenants . unshift ( tenant ) ; state . selectedTenant = tenant . id ; addAudit ( 'tenant.create' , 'tenant' , tenant . id , tenant . name , '高' , ` 开通租户 ${ tenant . name } 并创建首位负责人账号。 ` ) ; state . modal = '' ; navigate ( 'tenantDetail' ) ; return tenant ; } , success : '租户与负责人账号已开通' , close : false , rerender : false } ) ;
}
if ( form . id === 'tenantStatusForm' ) {
const tenant = tenantById ( state . modalData . tenantId ) ; const reason = fieldValue ( form , 'reason' ) ;
@@ -1287,8 +1486,8 @@ document.addEventListener('submit', event => {
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 amou nt = 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 : '收款已确认,账单与租户余额已更新' } ) ;
const invoice = invoiceById ( state . modalData . invoiceId ) ; const values = Object . fromEntries ( new FormData ( form ) ) ; const payme nt = paymentForInvoice ( invoice ) ; const amount = payment ? . amount || invoice . balance ;
simulateWrite ( { key : 'payment' , permission : 'platform:billing:payment' , operationId : 'PlatformAdminBillingController_confirmPayment' , body : ( ) => ( { ... values , paymentId : payment ? . id , amount } ) , validate : ( ) => { const errors = { } ; if ( platformApi ? . isEnabled ( ) && ! payment ) errors . paymentId = '该账单没有可确认的待支付记录' ; if ( ! amount || amount <= 0 ) errors . amount = '收款金额必须大于 0' ; if ( amount > invoice . balance ) errors . amount = ` 收款金额不可超过余额 ${ money ( invoice . balance ) } ` ; if ( ! values . tradeNo ) errors . tradeNo = '交易流水号不能为空' ; if ( ! new FormData ( form ) . get ( 'confirm' ) ) errors . confirm = '请先确认已核对银行流水' ; return Object . keys ( errors ) . length ? errors : null ; } , apply : ( ) => { invoice . paid += amount ; invoice . balance -= amount ; invoice . status = invoice . balance === 0 ? '已支付' : '部分支付' ; if ( payment ) payment . status = 'succeeded' ; const tenant = tenantById ( invoice . tenantId ) ; tenant . balance = Math . max ( 0 , tenant . balance - amount ) ; tenant . billing = tenant . balance === 0 ? '正常' : '待付款' ; if ( tenant . status === '暂停' && tenant . balance === 0 ) tenant . status = '正常' ; addAudit ( 'invoice.payment.confirm' , 'invoice' , invoice . id , invoice . id , '高' , ` 人工确认收款 ${ money ( amount ) } ,交易流水仅保存脱敏摘要。 ` ) ; } , success : '收款已确认,账单与租户余额已更新' } ) ;
}
if ( form . id === 'usageForm' ) {
const values = Object . fromEntries ( new FormData ( form ) ) ;
@@ -1299,9 +1498,25 @@ document.addEventListener('submit', event => {
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 === 'gr ant Form' ) {
if ( form . id === 'b ank Form' ) {
const values = Object . fromEntries ( new FormData ( form ) ) ;
simulateWrite ( { key : 'gr ant ' , permission : 'platform:question_ bank:grant ' , operationId : 'PlatformAdmin QuestionBanksController_upsertGr ant ' , 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 . gr ant s. unshift ( next ) ; addAudit ( 'bank.grant.upd ate' , 'grant' , next . id , bankById ( next . bankId ) . name , '高' , ` 保存授权范围 ${ next . scope } ;地区 ${ next . allowedRegionIds . join ( '、' ) || '不限' } ;科目 ${ next . allowedSubjectIds . join ( '、' ) || '不限' } 。 ` ) ; } , success : '公共题库授权范围 已保存' } ) ;
simulateWrite ( { key : 'question-b ank ' , permission : 'platform:question- bank:manage ' , operationId : 'PlatformQuestionBanksController_upsertB ank ' , body : ( ) => ( { id : values . id || null , name : values . name , metadata : values . version ? { version : values . version } : { } } ) , validate : ( ) => ! values . name ? . trim ( ) ? { name : '题库名称不能为空' } : null , apply : payload => { const item = payload ? platformApi . adapters . bank ( payload , bankById ( payload . id ) ) : { id : values . id || entityId ( 'QB ' ) , name : values . name , version : values . version || '-' , status : '正常' , source : '平台自建' , catalog : '平台公共题库' , nodes : 0 , questions : 0 } ; const existing = bankById ( item . id ) ; if ( existing ) Object . assign ( existing , item ) ; else data . b ank s. unshift ( item ) ; st ate. selectedBank = item . id ; state . selectedBankNode = '' ; return item ; } , success : '公共题库已保存' } ) ;
}
if ( form . id === 'bankNodeForm' ) {
const values = Object . fromEntries ( new FormData ( form ) ) ;
simulateWrite ( { key : 'question-bank-node' , permission : 'platform:question-bank:manage' , operationId : 'PlatformQuestionBanksController_upsertNode' , body : ( ) => ( { id : values . id || null , questionBankId : state . selectedBank , parentId : values . parentId || null , nodeKey : null , name : values . name , nodeType : values . nodeType , sortOrder : Number ( values . sortOrder || 0 ) , isSelectable : true , metadata : { } } ) , validate : ( ) => ! values . name ? . trim ( ) ? { name : '节点名称不能为空' } : null , apply : payload => { const item = payload ? adaptBankNode ( payload ) : { id : values . id || entityId ( 'NODE' ) , questionBankId : state . selectedBank , parentId : values . parentId || null , name : values . name , nodeType : values . nodeType , sortOrder : Number ( values . sortOrder || 0 ) , depth : values . parentId ? Number ( bankNodeById ( values . parentId ) ? . depth || 0 ) + 1 : 0 , isActive : true , questionCount : 0 } ; const existing = bankNodeById ( item . id ) ; if ( existing ) Object . assign ( existing , item ) ; else data . bankNodes . push ( item ) ; state . selectedBankNode = item . id ; return item ; } , success : '内容节点已保存' } ) ;
}
if ( form . id === 'bankNodeBatchForm' ) {
const values = Object . fromEntries ( new FormData ( form ) ) ; const names = String ( values . names || '' ) . split ( /\r?\n/ ) . map ( item => item . trim ( ) ) . filter ( Boolean ) ;
simulateWrite ( { key : 'question-bank-node-batch' , permission : 'platform:question-bank:manage' , operationId : 'PlatformQuestionBanksController_batchCreateNodes' , body : ( ) => ( { questionBankId : state . selectedBank , parentId : values . parentId || null , nodeType : values . nodeType , names } ) , validate : ( ) => ! names . length ? { names : '至少填写一个章节或试卷名称' } : names . length > 100 ? { names : '一次最多创建 100 个节点' } : null , apply : payload => { const items = apiItems ( payload ) ; if ( items . length ) data . bankNodes . push ( ... items . map ( adaptBankNode ) ) ; return items . length || names . length ; } , success : count => ` 已创建 ${ count } 个内容节点 ` } ) ;
}
if ( form . id === 'bankQuestionForm' ) {
const values = Object . fromEntries ( new FormData ( form ) ) ; const options = parseJsonText ( values . options , [ ] ) ; const tags = String ( values . tags || '' ) . split ( /[、,, ]/ ) . map ( item => item . trim ( ) ) . filter ( Boolean ) ;
simulateWrite ( { key : 'question-bank-question' , permission : 'platform:question-bank:manage' , operationId : 'PlatformQuestionBanksController_upsertQuestion' , body : ( ) => ( { id : values . id || null , questionBankId : state . selectedBank , contentNodeId : values . contentNodeId , legacyId : values . legacyId || null , type : values . type , typeLabel : questionTypeLabel ( values . type ) , difficulty : Number ( values . difficulty || 3 ) , tags , content : values . content , options , correctOptionIndex : null , correctOptionIndices : [ ] , answerText : values . answerText || null , explanation : values . explanation || null , subQuestions : [ ] , codeLang : null , codeTemplate : null , mediaUrl : values . mediaUrl || null , status : 'published' , examMarkers : { } , sourceHash : null } ) , validate : ( ) => { const errors = { } ; if ( ! values . contentNodeId ) errors . contentNodeId = '请选择章节或试卷' ; if ( ! values . content ? . trim ( ) ) errors . content = '题干不能为空' ; if ( options === null || ! Array . isArray ( options ) ) errors . options = '选项必须是 JSON 数组' ; return Object . keys ( errors ) . length ? errors : null ; } , apply : payload => { const item = payload ? adaptBankQuestion ( payload ) : { ... values , id : values . id || entityId ( 'Q' ) , questionBankId : state . selectedBank , contentNodeId : values . contentNodeId , options , tags , versionNo : 1 , status : 'published' , statusLabel : '已发布' } ; const existing = data . bankQuestions . find ( row => row . id === item . id ) ; if ( existing ) Object . assign ( existing , item ) ; else data . bankQuestions . unshift ( item ) ; return item ; } , success : '题目已保存并生成新版本' , close : false } ) ;
}
if ( form . id === 'bankBatchImportForm' || form . id === 'bankStructuredImportForm' ) {
const values = Object . fromEntries ( new FormData ( form ) ) ; const payload = parseJsonText ( values . payload , null ) ; const execute = event . submitter ? . dataset . importMode === 'execute' ; const format = form . id === 'bankStructuredImportForm' ? 'structured-v2' : 'simple' ;
simulateWrite ( { key : ` question-bank-import- ${ format } ` , permission : 'platform:question-bank:manage' , operationId : execute ? 'PlatformQuestionBanksController_executeImport' : 'PlatformQuestionBanksController_previewImport' , body : ( ) => ( { questionBankId : state . selectedBank , contentNodeId : values . contentNodeId || null , format , sourceName : values . sourceName || ( format === 'structured-v2' ? '结构化题库文件' : '普通题目批量导入' ) , payload } ) , validate : ( ) => { const errors = { } ; if ( format === 'simple' && ! values . contentNodeId ) errors . contentNodeId = '普通批量导入必须选择章节或试卷' ; if ( payload === null ) errors . payload = '请输入有效的 JSON' ; return Object . keys ( errors ) . length ? errors : null ; } , apply : result => { data . bankImportResult = result || { detail : { job : { dryRun : ! execute , totalCount : Array . isArray ( payload ) ? payload . length : 0 , validCount : Array . isArray ( payload ) ? payload . length : 0 , errorCount : 0 } , issues : [ ] } , insertedQuestionCount : execute && Array . isArray ( payload ) ? payload . length : 0 , updatedQuestionCount : 0 , skippedQuestionCount : 0 , createdNodeCount : 0 } ; return data . bankImportResult ; } , success : execute ? '题库导入已执行' : '导入预检已完成' , close : false } ) ;
}
if ( form . id === 'crmConfigForm' ) {
const values = Object . fromEntries ( new FormData ( form ) ) ;
@@ -1318,8 +1533,8 @@ document.addEventListener('submit', event => {
simulateWrite ( { key : 'payment-app' , permission : 'platform:payment:write' , operationId , body : ( ) => values , validate : ( ) => { const errors = { } ; if ( values . appScope === 'tenant' && ! values . tenantId ) errors . tenantId = '租户代管必须选择租户' ; if ( ! values . appName ) errors . appName = '应用名称不能为空' ; return Object . keys ( errors ) . length ? errors : null ; } , apply : payload => { const item = payload ? ( values . appScope === 'tenant' ? platformApi . adapters . tenantPaymentApp ( payload ) : platformApi . adapters . platformPaymentApp ( payload ) ) : { ... values , id : values . id || entityId ( 'PAYAPP' ) , status : values . status } ; const index = data . paymentApps . findIndex ( row => row . id === item . id ) ; if ( index >= 0 ) data . paymentApps [ index ] = item ; else data . paymentApps . unshift ( item ) ; addAudit ( 'payment.app.upsert' , 'payment_app' , item . id , item . appName , '高' , '保存支付应用配置。' ) ; } , success : '支付应用已保存' } ) ;
}
if ( form . id === 'staffForm' ) {
const values = Object . fromEntries ( new FormData ( form ) ) ; const permission s = new FormData ( form ) . getAll ( 'permission s' ) ;
simulateWrite ( { key : 'staff' , permission : 'platform:staff:write' , operationId : 'PlatformAdminOverviewController_upsertStaff' , body : ( ) => ( { ... values , permission s } ) , 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 ( ! permission s. length ) errors . permission s = '至少授予一项平台权限 ' ; return Object . keys ( errors ) . length ? errors : null ; } , apply : ( ) => { const existing = data . staff . find ( item => item . id === values . i d) ; const next = { id : values . i d || entityId ( 'STAFF' ) , authU serId: values . authU serId, user name: values . user name, name : values . name , email: values . e mail , phon e: 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 , '高' , ` 保存平台员工及 ${ permission s. length } 项有效权限 。 ` ) ; } , success : '平台员工与有效权限 已保存' } ) ;
const values = Object . fromEntries ( new FormData ( form ) ) ; const roleId s = new FormData ( form ) . getAll ( 'roleId s' ) ;
simulateWrite ( { key : 'staff' , permission : 'platform:staff:write' , operationId : 'PlatformAdminOverviewController_upsertStaff' , body : ( ) => ( { ... values , roleId s } ) , validate : ( ) => { const errors = { } ; if ( ! values . name ) errors . name = '姓名不能为空' ; if ( ! values . email ) errors . email = '邮箱不能为空' ; if ( values . phone && ! /^1\d{10}$/ . test ( values . phone ) ) errors . phone = '请输入 11 位手机号 '; if ( ! roleId s. length ) errors . roleId s = '至少选择一个平台角色 ' ; return Object . keys ( errors ) . length ? errors : null ; } , apply : payload => { const existing = data . staff . find ( item => item . id === values . userI d) ; const assignedRoles = data . platformRoles . filter ( role => roleIds . includes ( role . id ) ) ; const fallback = { id : values . userI d || entityId ( 'STAFF' ) , u serId: values . u serId || '' , name : values . name , email : values . email , phone : values . phone || existing ? . phone || '-' , roleIds , roleCodes : assignedRol es . map ( role => role . code ) , rol e: assignedRoles . map ( role => role . name ) . join ( '、' ) , permissions : [ ... new Set ( assignedRoles . flatMap ( role => role . permissionCodes ) ) ] , status : existing ? . status || '正常' , lastActive : existing ? . lastActive || '尚未登录' } ; const next = payload ? { ... fallback , ... platformApi . adapters . staff ( payload ) , roleIds , roleCodes : fallback . roleCodes , role : fallback . role , permissions : fallback . permissions } : fallback ; if ( existing ) Object . assign ( existing , next ) ; else data . staff . unshift ( next ) ; addAudit ( 'staff.upsert' , 'staff' , next . id , next . name , '高' , ` 保存平台员工及 ${ assignedRole s. length } 个角色 。 ` ) ; } , success : '平台员工与角色 已保存' } ) ;
}
if ( form . id === 'auditChannelForm' ) {
const values = Object . fromEntries ( new FormData ( form ) ) ;
@@ -1342,11 +1557,13 @@ document.addEventListener('click', event => {
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 bankTab = event . target . closest ( '[data-bank-tab]' ) ; if ( bankTab ) { state . bankTab = bankTab . dataset . bankTab ; syncNavigationState ( ) ; render ( ) ; if ( [ 'structure' , 'manage' ] . includes ( state . bankTab ) ) loadBankWorkspace ( ) ; return ; }
const crmTab = event . target . closest ( '[data-crm-tab]' ) ; if ( crmTab ) { state . crmTab = crmTab . dataset . crmTab ; syncNavigationState ( ) ; render ( ) ; return ; }
const smsTab = event . target . closest ( '[data-sms-tab]' ) ; if ( smsTab ) { state . smsTab = smsTab . dataset . smsTab ; syncNavigationState ( ) ; render ( ) ; return ; }
const paymentTab = event . target . closest ( '[data-payment-tab]' ) ; if ( paymentTab ) { state . paymentTab = paymentTab . dataset . paymentTab ; syncNavigationState ( ) ; render ( ) ; return ; }
const bankSelect = event . target . closest ( '[data-bank-select ]' ) ; if ( bankSelect ) { state . selectedBank = bankSelect . dataset . bankSelect ; syncNavigationState ( ) ; render ( ) ; return ; }
const saasFinanceTab = event . target . closest ( '[data-saas-finance-tab ]' ) ; if ( saasFinanceTab ) { state . saasFinanceTab = saasFinanceTab . dataset . saasFinanceTab ; syncNavigationState ( ) ; render ( ) ; return ; }
const bankSelect = event . target . closest ( '[data-bank-select]' ) ; if ( bankSelect ) { state . selectedBank = bankSelect . dataset . bankSelect ; state . selectedBankNode = '' ; syncNavigationState ( ) ; render ( ) ; loadBankWorkspace ( ) ; return ; }
const bankNodeSelect = event . target . closest ( '[data-bank-node-select]' ) ; if ( bankNodeSelect ) { state . selectedBankNode = bankNodeSelect . dataset . bankNodeSelect ; syncNavigationState ( ) ; render ( ) ; return ; }
const auditSeverity = event . target . closest ( '[data-audit-severity]' ) ; if ( auditSeverity ) { state . auditSeverity = auditSeverity . dataset . auditSeverity ; syncNavigationState ( ) ; render ( ) ; return ; }
const alertStatus = event . target . closest ( '[data-alert-status]' ) ; if ( alertStatus ) { state . alertStatus = alertStatus . dataset . alertStatus ; syncNavigationState ( ) ; render ( ) ; return ; }
const actionElement = event . target . closest ( '[data-action]' ) ; const action = actionElement ? . dataset . action ; if ( ! action ) return ;
@@ -1367,6 +1584,19 @@ document.addEventListener('click', event => {
if ( action === 'reset-mock' ) { if ( requireDesktop ( '重置平台 Mock 数据' ) ) return ; confirmAction ( { title : '重置平台 Mock 数据' , description : '恢复到 v1.1 初始演示数据。' , impact : '所有本地创建的租户、账单、任务、授权、员工修改和告警状态将被清除。' , confirmLabel : '确认重置' } , ( ) => { localStorage . removeItem ( STORAGE _KEY ) ; data = clone ( seed ) ; state . selectedTenant = data . tenants [ 0 ] . id ; saveData ( ) ; render ( ) ; showToast ( '平台 Mock 数据已重置' ) ; } ) ; return ; }
if ( action === 'open-task-center' ) { openModal ( 'tasks' ) ; return ; }
if ( action === 'create-tenant' ) { if ( requireDesktop ( '创建租户' ) ) return ; openModal ( 'createTenant' ) ; return ; }
if ( action === 'new-saas-feature' ) { openModal ( 'saasFeature' ) ; return ; }
if ( action === 'new-saas-offering' ) { openModal ( 'saasOffering' ) ; return ; }
if ( action === 'new-saas-version' ) { if ( ! ( data . saasCatalog ? . offerings || [ ] ) . length ) { showToast ( '请先创建 SaaS 商品' , 'error' ) ; return ; } openModal ( 'saasVersion' ) ; return ; }
if ( action === 'new-platform-role' ) { openModal ( 'platformRole' ) ; return ; }
if ( action === 'edit-platform-role' ) { openModal ( 'platformRole' , { roleId : actionElement . dataset . roleId } ) ; return ; }
if ( action === 'new-payment-channel' ) { if ( ! data . paymentApps . some ( item => item . appScope === 'platform' ) ) { showToast ( '请先创建平台支付应用' , 'error' ) ; return ; } openModal ( 'paymentChannel' ) ; return ; }
if ( action === 'edit-payment-channel' ) { openModal ( 'paymentChannel' , { channelId : actionElement . dataset . channelId } ) ; return ; }
if ( action === 'disable-payment-channel' ) { const channelId = actionElement . dataset . channelId ; if ( ! platformApi ? . isEnabled ( ) ) { const channel = data . paymentChannels . find ( item => item . id === channelId ) ; if ( channel ) channel . status = 'disabled' ; render ( ) ; showToast ( '支付渠道已停用' ) ; return ; } platformApi . request ( 'PlatformAdminPaymentSettingsController_disableChannel' , { pathParams : { channelId } , body : { } } ) . then ( payload => { const channel = data . paymentChannels . find ( item => item . id === channelId ) ; if ( channel ) Object . assign ( channel , payload ) ; render ( ) ; showToast ( '支付渠道已停用' ) ; } ) . catch ( error => showToast ( backendErrorText ( error , '停用支付渠道失败' ) , 'error' ) ) ; return ; }
if ( action === 'new-sms-template' ) { if ( ! data . smsChannels . length ) { showToast ( '请先创建租户短信渠道' , 'error' ) ; return ; } openModal ( 'smsTemplate' ) ; return ; }
if ( action === 'edit-sms-template' ) { openModal ( 'smsTemplate' , { templateId : actionElement . dataset . templateId } ) ; return ; }
if ( [ 'submit-sms-template' , 'disable-sms-template' ] . includes ( action ) ) { const templateId = actionElement . dataset . templateId ; const operationId = action === 'submit-sms-template' ? 'PlatformAdminSmsController_submitTemplateReview' : 'PlatformAdminSmsController_disableTemplate' ; if ( ! platformApi ? . isEnabled ( ) ) { const template = data . smsTemplates . find ( item => item . id === templateId ) ; if ( template ) { if ( action === 'submit-sms-template' ) template . status = '审核中' ; else template . templateStatus = '未启用' ; } render ( ) ; showToast ( action === 'submit-sms-template' ? '短信模板已提交审核' : '短信模板已停用' ) ; return ; } platformApi . request ( operationId , { pathParams : { templateId } , body : { } } ) . then ( payload => { const template = data . smsTemplates . find ( item => item . id === templateId ) ; if ( template ) Object . assign ( template , platformApi . adapters . smsTemplate ( payload ) ) ; render ( ) ; showToast ( action === 'submit-sms-template' ? '短信模板已提交审核' : '短信模板已停用' ) ; } ) . catch ( error => showToast ( backendErrorText ( error , '短信模板操作失败' ) , 'error' ) ) ; return ; }
if ( [ 'publish-saas-version' , 'clone-saas-version' , 'retire-saas-version' ] . includes ( action ) ) { const operationId = action === 'publish-saas-version' ? 'PlatformSaasController_publishVersion' : action === 'clone-saas-version' ? 'PlatformSaasController_cloneVersion' : 'PlatformSaasController_retireVersion' ; const versionId = actionElement . dataset . versionId ; if ( ! platformApi ? . isEnabled ( ) ) { const version = data . saasCatalog . versions . find ( item => item . id === versionId ) ; if ( version ) version . status = action === 'retire-saas-version' ? 'retired' : action === 'publish-saas-version' ? 'published' : version . status ; render ( ) ; showToast ( '套餐版本状态已更新' ) ; return ; } platformApi . request ( operationId , { pathParams : { versionId } , body : { } } ) . then ( ( ) => hydrateFromBackend ( { silent : true } ) ) . then ( ( ) => showToast ( '套餐版本状态已更新' ) ) . catch ( error => showToast ( backendErrorText ( error , '套餐版本操作失败' ) , 'error' ) ) ; return ; }
if ( action === 'recheck-domain' ) { const domain = data . domains . find ( item => item . id === actionElement . dataset . domainId ) ; if ( ! domain ) return ; if ( ! platformApi ? . isEnabled ( ) ) { showToast ( '真实 API 模式才能重新检查域名' , 'error' ) ; return ; } platformApi . request ( 'PlatformAdminTenantsController_recheckDomain' , { pathParams : { domainId : domain . id } , body : { } } ) . then ( payload => { Object . assign ( domain , payload ) ; render ( ) ; showToast ( '域名 DNS/TLS 状态已重新检查' ) ; } ) . catch ( error => showToast ( backendErrorText ( error , '域名检查失败' ) , 'error' ) ) ; return ; }
if ( action === 'prepare-tenant-status' ) { if ( requireDesktop ( '变更租户生命周期' ) ) return ; openModal ( 'tenantStatus' , { tenantId : actionElement . dataset . tenantId || state . selectedTenant } ) ; return ; }
if ( action === 'open-tenant-portal' ) { const tenant = tenantById ( state . selectedTenant ) ; let portalWindow = null ; try { portalWindow = window . open ( TENANT _PORTAL _ORIGIN , '_blank' ) ; if ( portalWindow ) portalWindow . opener = null ; } catch { } showToast ( portalWindow ? ` 已在新标签打开独立租户端 · ${ tenant ? . name || '当前租户' } ` : '已请求打开独立租户端;如未出现,请允许本站弹出窗口' , portalWindow ? 'success' : 'error' ) ; return ; }
if ( action === 'edit-billing-profile' ) { if ( requireDesktop ( '编辑租户账务资料' ) ) return ; navigate ( 'billingProfile' ) ; return ; }
@@ -1403,37 +1633,44 @@ document.addEventListener('click', event => {
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_upsert Channel' , { ... channel , enabled : nextEnabled } , apply ) ; else apply ( ); return ; }
if ( action === 'new-gr ant ' ) { if ( requireDesktop ( '新建公共题库授权 ' ) ) return ; openModal ( 'gr ant ' ) ; 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 => { const items = apiItems ( payload ) ; if ( items . length ) data . banks = 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-gr ant ' ) { if ( requireDesktop ( '编辑 公共题库授权 ') ) return ; openModal ( 'grant' , { grantId : actionElement . dataset . gr ant Id } ) ; 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 => { const items = apiItems ( payload ) ; if ( items . length ) data . bankSync = 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 , ex ecu te: taskItem => { taskItem . rows = data . bankSync . length ; taskItem . failedRows = data . bankSync . filter ( item => item . status === '同步失败' ) . length ; } } ) ; render ( ) ; showToast ( ` 同步状态检查任务 ${ task . id } 已创建 ` ) ; return ; }
if ( action === 'refres h-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 => { const items = apiItems ( payload ) ; if ( items . length ) data . bankSync = items . map ( platformApi . adapters . sync ) ; return ` ${ data . bankSync . length } 条采用记录, ${ data . bankSync . filter ( item => item . status === '同步失败' ) . length } 条失败 ` ; } } ) ; return ; }
if ( action === 'sync-detail' ) { openModal ( 'sync Detail' , { syncKey : actionElement . dataset . syncKey } ) ; return ; }
if ( action === 'toggle-dunning-channel' ) { if ( requireDesktop ( '变更催缴渠道状态' ) ) return ; if ( ! canWrite ( 'platform:billing:notification' ) ) return ; const channel = data . dunningChannels . find ( item => item . id === actionElement . dataset . channelId ) ; if ( ! channel ) return ; const nextEnabled = ! channel . enabled ; const apply = ( ) => { channel . enabled = nextEnabled ; addAudit ( 'dunning.channel.status' , 'notification_channel' , channel . id , channel . name , '高' , ` 催缴渠道 ${ channel . enabled ? '启用' : '停用' } 。 ` ) ; saveData ( ) ; render ( ) ; showToast ( '渠道状态已更新' ) ; } ; if ( ! platformApi ? . isEnabled ( ) ) { apply ( ) ; return ; } if ( ! nextEnabled ) { executeBackendMutation ( 'PlatformAdminDunningChannelsController_disable Channel' , { channelId : channel . id , reason : '平台管理员停用催缴渠道' } , apply ) ; return ; } if ( channel . endpointRedacted ) { openModal ( 'dunningChannel' , { channelId : channel . id } ) ; showToast ( '重新启用前请填写完整的 HTTPS 通知地址' , 'error' ) ; return ; } executeBackendMutation ( 'PlatformAdminDunningChannelsController_upsertChannel' , { ... channel , enabled : true } , apply ) ; return ; }
if ( action === 'new-b ank ' ) { if ( requireDesktop ( '新建公共题库' ) ) return ; openModal ( 'b ank ' ) ; return ; }
if ( action === 'edit-bank' ) { if ( requireDesktop ( '编辑公共题库' ) ) return ; openModal ( ' bank' , { bankId : state . selectedBank } ) ; return ; }
if ( action === 'archive-b ank ' ) { const bank = bankById ( state . selectedBank ) ; if ( ! bank ) return ; confirmAction ( { title : '归档 公共题库', description : bank . name , impact : '仅当题库中的题目和内容节点均已归档时才能归档,历史版本不会删除。' , confirmLabel : '确认归档' } , ( ) => executeBackendMutation ( 'PlatformQuestionBanksController_archiveBank' , { } , payload => { Object . assign ( bank , payload ? platformApi . adapters . bank ( payload , bank ) : { status : '已归档' } ) ; render ( ) ; showToast ( '公共题库已归档' ) ; } , { refresh : false , pathParams : { b ank Id: bank . id } } ) ) ; return ; }
if ( action === 'new-bank-node ' ) { if ( requireDesktop ( '新建内容节点 ' ) ) return ; openModal ( 'bankNode' ) ; return ; }
if ( action === 'edit -bank-node ' ) { if ( requireDesktop ( '编辑内容节点' ) ) return ; openModal ( 'bankNode' , { nodeId : state . sel ectedBankNode } ) ; return ; }
if ( action === 'batc h-bank-nodes ' ) { if ( requireDesktop ( '批量创建章节或试卷' ) ) return ; openModal ( 'batchBankNodes' ) ; return ; }
if ( action === 'shift-bank-node' ) { const node = bankNodeById ( state . selectedBankNode ) ; if ( ! node ) return ; const sortOrder = Number ( node . sortOrder || 0 ) + Number ( actionElement . dataset . sort Del ta || 0 ) ; executeBackendMutation ( 'PlatformQuestionBanksController_upsertNode' , { id : node . id , questionBankId : node . questionBankId , parentId : node . parentId || null , nodeKey : node . nodeKey || null , name : node . name , nodeType : node . nodeType , sortOrder , isSelectable : node . isSelectable !== false , metadata : node . metadata || { } } , payload => { Object . assign ( node , payload ? adaptBankNode ( payload ) : { sortOrder } ) ; data . bankNodes . sort ( ( left , right ) => Number ( left . sortOrder || 0 ) - Number ( right . sortOrder || 0 ) ) ; render ( ) ; showToast ( '内容节点顺序已保存' ) ; } , { refresh : false } ) ; return ; }
if ( action === 'apply-bank-search' ) { runContractRead ( { key : 'bank-query' , actionId : 'act.p-09.01' , operationId : 'PlatformQuestionBanksController_getBanks' , title : '公共题库查询' , suppliedRequestId : actionElement . dataset . contractRequestId , liveRequest : ( ) => platformApi . request ( 'PlatformQuestionBanksController_getBanks' , { query : { keyword : state . bankSearch || undefined , status : 'all' } } ) , apply : payload => { const items = apiItems ( payload ) ; data . banks = items . map ( item => platformApi . adapters . bank ( item , bankById ( item . id ) ) ) ; if ( ! bankById ( state . selectedBank ) ) state . selectedBank = data . banks [ 0 ] ? . id || '' ; return ` ${ data . banks . length } 个匹配题库 ` ; } } ) ; return ; }
if ( action === 'load-bank-questions' || action === 'bank-question-page' ) { if ( action === 'bank-question-page' ) state . bankQuestionPage = Number ( actionElement . dataset . page || 1 ) ; const query = { questionBankId : state . selectedBank , contentNodeId : state . selectedBankNode || undefined , keyword : document . querySelector ( '#bankQuestionSearch' ) ? . value || undefined , type : document . querySelector ( '#bankQuestionType' ) ? . value || undefined , status : document . querySelector ( '#bankQuestionStatus' ) ? . value || undefined , page : state . bankQuestionPage , pageSize : 20 } ; if ( ! platformApi ? . isEnabled ( ) ) { render ( ) ; return ; } platformApi . request ( 'PlatformQuestionBanksController_getQuestions' , { query } ) . then ( payload => { data . bankQuestions = apiItems ( payload ) . map ( adaptBankQuestion ) ; state . bankQuestionTotal = Number ( payload . total || data . bankQuestions . length ) ; render ( ) ; } ) . catch ( error => showToast ( backendErrorText ( error , '题目查询失败' ) , 'error' ) ) ; return ; }
if ( action === 'edit-bank-question' ) { const question = data . bankQuestions . find ( item => item . id === actionElement . dataset . questionId ) ; if ( ! question ) return ; state . bankTab = 'single' ; state . selectedBankNode = question . contentNodeId ; render ( ) ; requestAnimationFrame ( ( ) => { const form = document . querySelector ( '#bankQuestionForm' ) ; if ( ! form ) return ; form . elements . id . value = question . id ; form . elements . contentNodeId . value = question . contentNodeId ; form . elements . type . value = question . type ; form . elements . difficulty . value = question . difficulty || 3 ; form . elements . legacyId . value = question . legacyId || '' ; form . elements . content . value = question . content || '' ; form . elements . options . value = JSON . stringify ( question . options || [ ] , null , 2 ) ; form . elements . answerText . value = question . answerText || '' ; form . elements . explanation . value = question . explanation || '' ; form . elements . mediaUrl . value = question . mediaUrl || '' ; form . elements . tags . value = Array . isArray ( question . tags ) ? question . tags . join ( '、' ) : '' ; } ) ; return ; }
if ( action === 'archive-bank-question' ) { const questionId = actionElement . dataset . questionId ; const apply = ( ) => { const question = data . bankQuestions . find ( item => item . id === questionId ) ; if ( question ) { question . status = 'archived' ; question . statusLabel = '已归档' ; } render ( ) ; showToast ( '题目已归档' ) ; } ; confirmAction ( { title : '归档题目' , description : '归档后题目历史版本仍会保留。' , impact : '该题目不会再进入公共题库正常查询结果。' , confirmLabel : '确认归档' } , ( ) => executeBackendMutation ( 'PlatformQuestionBanksController_archiveQuestions' , { questionIds : [ questionId ] } , apply , { refresh : false } ) ) ; return ; }
if ( action === 'archive-bank-node' ) { const node = bankNodeById ( state . selectedBankNode ) ; if ( ! node ) return ; confirmAction ( { title : '归档内容节点' , description : node . name , impact : '仅当节点没有启用的下级节点和未归档题目时才能归档。' , confirmLabel : '确认归档' } , ( ) => executeBackendMutation ( 'PlatformQuestionBanksController_archiveNode' , { } , ( ) => { node . isActive = false ; render ( ) ; showToast ( '内容节点已归档' ) ; } , { refresh : false , pathParams : { nodeId : node . id } } ) ) ; return ; }
if ( action === 'upload-question-image' ) { const input = document . querySelector ( '#bankQuestionImage' ) ; const file = input ? . files ? . [ 0 ] ; if ( ! file ) { showToast ( '请先选择题目图片' , 'error' ) ; return ; } if ( ! platformApi ? . isEnabled ( ) ) { document . querySelector ( '#bankQuestionForm' ) . elements . mediaUrl . value = ` https://static.example.test/questions/ ${ encodeURIComponent ( file . name ) } ` ; showToast ( '图片地址已填入' ) ; return ; } actionElement . disabled = true ; platformApi . request ( 'PlatformQuestionBanksController_signAssetUpload' , { body : { contentNodeId : state . selectedBankNode || null , fileName : file . name , mimeType : file . type || 'application/octet-stream' , fileSizeBytes : file . size , title : file . name , metadata : { usage : 'question' } } } ) . then ( async signed => { await fetch ( signed . upload . url , { method : signed . upload . method || 'PUT' , headers : signed . upload . headers || { } , body : file } ) ; const confirmed = await platformApi . request ( 'PlatformQuestionBanksController_confirmAssetUpload' , { body : { assetId : signed . item . id , mimeType : file . type , fileSizeBytes : file . size } } ) ; document . querySelector ( '#bankQuestionForm' ) . elements . mediaUrl . value = confirmed . item . cdnUrl || String ( signed . upload . url ) . split ( '?' ) [ 0 ] ; markDirty ( 'page' ) ; showToast ( '题目图片已上传并填入地址' ) ; } ) . catch ( error => showToast ( backendErrorText ( error , '题目图片上传失败' ) , 'error' ) ) . finally ( ( ) => { actionElement . disabled = false ; } ) ; return ; }
if ( action === 'preview-question' ) { const form = document . querySelector ( '#bankQuestionForm' ) ; showToast ( form ? . elements . content . value ? . trim ( ) ? ` 题目预览: ${ form . elements . content . value . trim ( ) . slice ( 0 , 80 ) } ` : '请先填写题干' , form ? . elements . content . value ? . trim ( ) ? 'success' : 'error' ) ; return ; }
if ( action === 'new-crm-config' || action === 'edit-crm-config' ) { if ( ! canWrite ( 'platform:crm:write' ) ) return ; openModal ( 'crmConfig' , { crmId : actionElement . dataset . crmId || '' } ) ; return ; }
if ( action === 'crm-rule-detail' ) { showToast ( 'CRM 分配策略保存在接入配置的 assignmentConfig 中' ) ; return ; }
if ( action === 'toggle-crm-config' ) { if ( ! canWrite ( 'platform:crm:write' ) ) return ; const config = data . crmConfigs . find ( item => item . id === actionElement . dataset . crmId ) ; if ( ! config ) return ; const nextEnabled = ! config . enabled ; const apply = payload => { const next = payload ? platformApi . adapters . crmConfig ( payload ) : { ... config , enabled : nextEnabled , status : nextEnabled ? '正常' : '未启用' } ; Object . assign ( config , next ) ; addAudit ( 'crm.config.status' , 'crm_config' , config . id , tenantById ( config . tenantId ) ? . name || config . id , '中' , ` CRM 接入 ${ config . enabled ? '启用' : '停用' } 。 ` ) ; saveData ( ) ; render ( ) ; showToast ( 'CRM 接入状态已更新' ) ; } ; if ( platformApi ? . isEnabled ( ) ) executeBackendMutation ( 'PlatformAdminCrmController_upsertConfig' , { ... config , enabled : nextEnabled } , apply ) ; else apply ( ) ; return ; }
if ( action === 'retry-crm-lead' ) { if ( ! canWrite ( 'platform:crm:write' ) ) return ; const lead = data . crmLeads . find ( item => item . id === actionElement . dataset . leadId ) ; if ( ! lead ) return ; const apply = payload => { const next = payload ? platformApi . adapters . crmLead ( payload ) : { ... lead , status : '重试中' , attempts : lead . attempts + 1 , lastError : '' } ; Object . assign ( lead , next ) ; addAudit ( 'crm.lead.retry' , 'crm_lead' , lead . id , lead . student , '中' , ` CRM 线索 ${ lead . id } 已重试推送。 ` ) ; saveData ( ) ; render ( ) ; showToast ( '线索已进入重试队列' ) ; } ; if ( platformApi ? . isEnabled ( ) ) executeBackendMutation ( 'PlatformAdminCrmController_retryLead' , lead , apply ) ; else apply ( ) ; return ; }
if ( action === 'new-sms-channel' || action === 'edit-sms-channel' ) { if ( ! canWrite ( 'platform:sms:write' ) ) return ; openModal ( 'smsChannel' , { smsId : actionElement . dataset . smsId || '' } ) ; return ; }
if ( action === 'toggle-sms-channel' ) { if ( ! canWrite ( 'platform:sms:write' ) ) return ; const channel = data . smsChannels . find ( item => item . id === actionElement . dataset . smsId ) ; if ( ! channel ) return ; const nextStatus = channel . enabled ? '未启用' : '正常' ; const apply = payload => { const next = payload ? platformApi . adapters . smsChannel ( payload ) : { ... channel , enabled : nextStatus === '正常' , status : nextStatus } ; Object . assign ( channel , next ) ; addAudit ( 'sms.channel.status' , 'sms_channel' , channel . id , tenantById ( channel . tenantId ) ? . name || channel . id , '中' , ` 短信渠道 ${ channel . enabled ? '启用' : '停用' } 。 ` ) ; saveData ( ) ; render ( ) ; showToast ( '短信渠道状态已更新' ) ; } ; if ( platformApi ? . isEnabled ( ) ) executeBackendMutation ( 'PlatformAdminSmsController_upsertChannel' , { ... channel , status : nextStatus } , apply ) ; else apply ( ) ; return ; }
if ( action === 'toggle-sms-channel' ) { if ( ! canWrite ( 'platform:sms:write' ) ) return ; const channel = data . smsChannels . find ( item => item . id === actionElement . dataset . smsId ) ; if ( ! channel ) return ; const nextStatus = channel . enabled ? '未启用' : '正常' ; const apply = payload => { const next = payload ? platformApi . adapters . smsChannel ( payload ) : { ... channel , enabled : nextStatus === '正常' , status : nextStatus } ; Object . assign ( channel , next ) ; addAudit ( 'sms.channel.status' , 'sms_channel' , channel . id , tenantById ( channel . tenantId ) ? . name || channel . id , '中' , ` 短信渠道 ${ channel . enabled ? '启用' : '停用' } 。 ` ) ; saveData ( ) ; render ( ) ; showToast ( '短信渠道状态已更新' ) ; } ; if ( ! platformApi ? . isEnabled ( ) ) { apply ( ) ; return ; } if ( channel . enabled ) { executeBackendMutation ( 'PlatformAdminSmsController_disableChannel' , { } , apply , { refresh : false , pathParams : { channelId : channel . id } } ) ; return ; } executeBackendMutation ( 'PlatformAdminSmsController_upsertChannel' , { ... channel , status : nextStatus } , apply ) ; return ; }
if ( action === 'new-payment-app' || action === 'edit-payment-app' ) { if ( ! canWrite ( 'platform:payment:write' ) ) return ; openModal ( 'paymentApp' , { paymentId : actionElement . dataset . paymentId || '' } ) ; return ; }
if ( action === 'payment-rule-detail' ) { showToast ( '支付风控规则用于证书提醒、回调重试和人工确认保护' ) ; return ; }
if ( action === 'toggle-payment-app' ) { if ( ! canWrite ( 'platform:payment:write' ) ) return ; const paymentApp = data . paymentApps . find ( item => item . id === actionElement . dataset . paymentId ) ; if ( ! paymentApp ) return ; const disabling = paymentApp . status !== '未启用' ; const nextStatus = disabling ? '未启用' : '正常' ; const operationId = paymentApp . appScope === 'tenant' ? 'PlatformAdminTenantPaymentSettingsController_upsertApp' : 'PlatformAdminPaymentSettingsController_upsertApp' ; const apply = payload => { const next = payload ? ( paymentApp . appScope === 'tenant' ? platformApi . adapters . tenantPaymentApp ( payload ) : platformApi . adapters . platformPaymentApp ( payload ) ) : { ... paymentApp , status : nextStatus } ; Object . assign ( paymentApp , next ) ; addAudit ( 'payment.app.status' , 'payment_app' , paymentApp . id , paymentApp . appName , '高' , ` 支付应用 ${ disabling ? '停用' : '启用' } 。 ` ) ; saveData ( ) ; render ( ) ; showToast ( '支付应用状态已更新' ) ; } ; if ( platformApi ? . isEnabled ( ) ) executeBackendMutation ( operationId , { ... paymentApp , status : nextStatus } , apply ) ; else apply ( ) ; return ; }
if ( action === 'new-staff' ) { if ( requireDesktop ( '新建平台员工与授权' ) ) return ; openModal ( 'staff' , { roleKey : actionElement . dataset . roleKey || '' } ) ; return ; }
if ( action === 'new-staff' ) { if ( requireDesktop ( '新建平台员工与授权' ) ) return ; openModal ( 'staff' , { roleId : actionElement . dataset . roleId || '' } ) ; return ; }
if ( action === 'edit-staff' ) { if ( requireDesktop ( '编辑平台员工与授权' ) ) return ; openModal ( 'staff' , { staffId : actionElement . dataset . staffId } ) ; return ; }
if ( action === 'toggle-staff' ) { if ( requireDesktop ( '变更平台员工状态' ) ) return ; if ( ! canWrite ( 'platform:staff:status' ) ) return ; const person = data . staff . find ( item => item . id === actionElement . dataset . staffId ) ; if ( ! person ) return ; const disabling = person . status === '正常' ; if ( disabl ing && 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' , 'platform:payment:read' , 'platform:payment: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' , 'platform:crm:read' , 'platform:crm:write' , 'platform:sms:read' , 'platform:sms:write' ] : Array . from ( form . querySelectorAll ( '[name="permissions"]' ) ) . map ( input => input . value ) ; form . querySelectorAll ( '[name="permissions"]' ) . forEach ( input => { input . checked = recommended . includes ( input . value ) ; } ) ; return ; }
if ( action === 'clear-staff-form' ) { const form = overlayRoot . querySelector ( '#staffForm' ) ; if ( ! form ) return ; form . querySelectorAll ( 'input:not([type="hidden"]):not([type="checkbox"])' ) . forEach ( input => { input . value = '' ; } ) ; form . querySelectorAll ( 'input[type="checkbox"]' ) . forEach ( input => { input . checked = false ; } ) ; form . elements . role . value = '租户运营' ; state . modalData . dirty = true ; form . querySelector ( '[name="authUserId "]' ) ? . focus ( ) ; return ; }
if ( action === 'toggle-staff' ) { if ( requireDesktop ( '变更平台员工状态' ) ) return ; if ( ! canWrite ( 'platform:staff:status' ) ) return ; const person = data . staff . find ( item => item . id === actionElement . dataset . staffId ) ; if ( ! person ) return ; const disabling = person . status === '正常' ; const isSuperAdm in = person . roleCodes ? . includes ( 'platform_super_admin' ) ; if ( disabling && isSuperAdmin && data . staff . filter ( item => item . roleCodes ? . includes ( 'platform_super_admin' ) && item . status === '正常' ) . length === 1 ) { showToast ( '不能禁用最后一名超级管理员' , 'error' ) ; return ; } const apply = ( ) => { person . status = disabling ? '已停用' : '正常' ; addAudit ( 'staff.status.update' , 'staff' , person . id , person . name , '高' , ` ${ disabling ? '停用并撤销会话' : '启用' } 平台员工。 ` ) ; saveData ( ) ; render ( ) ; showToast ( ` 员工已 ${ disabling ? '停用并撤销会话' : '启用' } ` ) ; } ; const execute = ( ) => platformApi ? . isEnabled ( ) ? executeBackendMutation ( 'PlatformAdminOverviewController_updateStaffStatus' , { userId : person . userId || person . id , status : disabling ? 'disabled' : 'active' , reason : disabling ? '平台控制台停用员工' : '平台控制台启用员工' } , apply ) : apply ( ) ; if ( disabling ) confirmAction ( { title : '停用平台员工' , description : ` ${ person . name } · ${ person . role } ` , impact : '该员工的全部平台会话将立即撤销,后续无法登录平台端;租户端权限不在本域名内处理。' , confirmLabel : '停用并撤销会话' , confirmActionName : 'execute-staff-disable' } , execute ) ; else execute ( ) ; return ; }
if ( action === 'select-permission-group' ) { const form = overlayRoot . querySelector ( '#staffForm' ) ; const role = fieldValue ( form , 'role' ) ; const recommended = role === '平台财务' ? [ 'platform:billing:read' , 'platform:billing:write' , 'platform:billing:payment' , 'platform:billing:dunning' , 'platform:billing:notification' , 'platform:usage:read' , 'platform:usage:write' , 'platform:payment:read' , 'platform:payment:write' ] : role === '安全审计' ? [ 'platform:audit:read' , 'platform:audit:export' , 'platform:audit:alert' , 'platform:audit:notification' ] : role === '题库运营' ? [ 'platform:dashboard:view ' , 'platform:question- bank:manage ' ] : role === '租户运营' ? [ 'platform:tenant:read' , 'platform:tenant:write' , 'platform:tenant:status' , 'platform:tenant:billing_profile' , 'platform:crm:read' , 'platform:crm:write' , 'platform:sms:read' , 'platform:sms:write' ] : Array . from ( form . querySelectorAll ( '[name="permissions"]' ) ) . map ( input => input . value ) ; form . querySelectorAll ( '[name="permissions"]' ) . forEach ( input => { input . checked = recommended . includes ( input . value ) ; } ) ; return ; }
if ( action === 'clear-staff-form' ) { const form = overlayRoot . querySelector ( '#staffForm' ) ; if ( ! form ) return ; form . querySelectorAll ( 'input:not([type="hidden"]):not([type="checkbox"])' ) . forEach ( input => { input . value = '' ; } ) ; form . querySelectorAll ( 'input[type="checkbox"]' ) . forEach ( input => { input . checked = false ; } ) ; state . modalData . dirty = true ; form . querySelector ( '[name="name "]' ) ? . focus ( ) ; return ; }
if ( action === 'apply-staff-search' ) { render ( ) ; return ; }
if ( action === 'export-audit' ) { if ( ! hasPermission ( 'platform:audit:export' ) ) { showToast ( '缺少导出权限: platform:audit:export' , 'error' ) ; return ; } const format = actionElement . dataset . format || 'csv' ; if ( platformApi ? . isEnabled ( ) ) { platformApi . request ( 'PlatformAdminAuditController_exportLogs' , { query : { format , q : state . auditSearch || undefined , limit : 500 } } ) . then ( payload => { const item = platformApi . downloadExport ( payload ) ; showToast ( ` 审计文件已下载 · ${ item . rowCount ? ? item . rows ? ? 0 } 行 · ${ item . sha256 || item . hash || 'hash 见响应' } ` ) ; } ) . catch ( error => showToast ( backendErrorText ( error , '审计导出失败' ) , 'error' ) ) ; return ; } const task = addTask ( ` 导出平台审计日志 ${ format . toUpperCase ( ) } ` , '任务中心下载文件' , taskItem => { taskItem . file = ` platform-audit- ${ Date . now ( ) } . ${ format } ` ; taskItem . hash = ` sha256: ${ Math . random ( ) . toString ( 36 ) . slice ( 2 ) . padEnd ( 32 , '0' ) } ` ; taskItem . rows = data . auditLogs . length ; } ) ; showToast ( ` 导出任务 ${ task . id } 已创建 ` ) ; return ; }
if ( action === 'jump-related-alert' ) { state . alertStatus = 'all' ; state . selectedAlert = data . alerts . find ( alert => data . auditLogs . some ( log => log . targetId === alert . targetId || log . targetId === alert . id ) ) ? . id || '' ; navigate ( 'alerts' ) ; if ( state . selectedAlert ) openModal ( 'alertDetail' , { alertId : state . selectedAlert } ) ; return ; }
if ( action === 'jump-audit-target' ) { const log = data . auditLogs . find ( item => item . id === actionElement . dataset . auditId ) ; closeModal ( ) ; if ( log . targetType === 'tenant' ) { state . selectedTenant = log . targetId ; navigate ( 'tenantDetail' ) ; } else if ( log . targetType === 'invoice' ) { state . selectedInvoice = log . targetId ; navigate ( 'billing' ) ; } else if ( log . targetType === 'question_bank' || log . targetType === 'grant' ) navigate ( 'bank' ) ; else navigate ( 'alerts' ) ; return ; }
if ( action === 'jump-audit-target' ) { const log = data . auditLogs . find ( item => item . id === actionElement . dataset . auditId ) ; closeModal ( ) ; if ( log . targetType === 'tenant' ) { state . selectedTenant = log . targetId ; navigate ( 'tenantDetail' ) ; } else if ( log . targetType === 'invoice' ) { state . selectedInvoice = log . targetId ; navigate ( 'billing' ) ; } else if ( log . targetType === 'question_bank' ) navigate ( 'bank' ) ; else navigate ( 'alerts' ) ; return ; }
if ( action === 'transition-alert' ) { if ( ! canWrite ( 'platform:audit:alert' ) ) return ; const alert = data . alerts . find ( item => item . id === actionElement . dataset . alertId ) ; const next = actionElement . dataset . nextStatus ; if ( ! alert ) return ; if ( ! [ 'open' , 'acknowledged' ] . includes ( alert . status ) ) { showToast ( '已结束告警禁止重新打开或再次处理' , 'error' ) ; return ; } if ( alert . status === 'open' && ! [ 'acknowledged' , 'resolved' , 'ignored' ] . includes ( next ) ) return ; if ( alert . status === 'acknowledged' && ! [ 'resolved' , 'ignored' ] . includes ( next ) ) return ; const note = document . querySelector ( '#alertNote' ) ? . value . trim ( ) || '' ; const apply = ( ) => { alert . status = next ; alert . note = note ; alert . updatedAt = '刚刚' ; addAudit ( 'audit.alert.status.update' , 'audit_alert' , alert . id , alert . title , '高' , ` 告警状态更新为 ${ alertStatusLabel ( next ) } ${ note ? ` ,备注: ${ note } ` : '' } 。 ` ) ; clearDirty ( 'modal' ) ; saveData ( ) ; closeModal ( { force : true } ) ; render ( ) ; showToast ( ` 告警已 ${ alertStatusLabel ( next ) } ` ) ; } ; if ( platformApi ? . isEnabled ( ) ) executeBackendMutation ( 'PlatformAdminAuditController_updateAlert' , { alertId : alert . id , status : next , note } , apply ) ; else apply ( ) ; return ; }
if ( action === 'new-audit-channel' ) { if ( requireDesktop ( '新建审计通知渠道' ) ) return ; openModal ( 'auditChannel' ) ; return ; }
if ( action === 'edit-audit-channel' ) { if ( requireDesktop ( '编辑审计通知渠道' ) ) return ; openModal ( 'auditChannel' , { channelId : actionElement . dataset . channelId } ) ; return ; }
if ( action === 'toggle-audit-channel' ) { if ( requireDesktop ( '变更审计通知渠道状态' ) ) return ; if ( ! canWrite ( 'platform:audit:notification' ) ) return ; const channel = data . auditChannels . find ( item => item . id === actionElement . dataset . channelId ) ; if ( ! channel ) return ; if ( platformApi ? . isEnabled ( ) && channel . endpointRedacted ) { openModal ( 'auditChannel' , { channelId : channel . id } ) ; showToast ( '后端不回显完整 Webhook; 请重新输入完整 HTTPS URL 后保存状态' , 'error' ) ; return ; } const nextEnabled = ! channel . enabled ; const apply = ( ) => { channel . enabled = nextEnabled ; addAudit ( 'audit.channel.status' , 'notification_channel' , channel . id , channel . name , '高' , ` 审计告警渠道 ${ channel . enabled ? '启用' : '停用' } 。 ` ) ; saveData ( ) ; render ( ) ; showToast ( '审计通知渠道状态已更新' ) ; } ; if ( platformApi ? . isEnabled ( ) ) executeBackendMutation ( 'PlatformAdminAuditController_upsertChannel' , { ... channel , enabled : nextEnabled } , apply ) ; else apply ( ) ; return ; }
if ( action === 'retry-notification' ) { 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-notification' ) { const eventId = actionElement . dataset . eventId ; const dunningEvent = ( data . dunningEvents || [ ] ) . find ( item => item . id === eventId ) ; const notification = dunningEvent || ( data . notificationEvents || [ ] ) . find ( item => item. id === eventId ) ; if ( ! notification ) return ; if ( platformApi ? . isEnabled ( ) ) { if ( ! dunningEvent ) { showToast ( '审计通知暂无后端重试接口' , 'error' ) ; return ; } platformApi . request ( 'PlatformAdminDunningEventsController_retryEvent' , { body : { eventId , reason : '平台管理员手工重试' } } ) . then ( payload => { Object . assign ( dunningEvent , platformApi . a dapters . event( payload , 'dunning' ) ) ; closeModal ( { force : true } ) ; render ( ) ; showToast ( '催缴通知事件已重新标记待发送' ) ; } ) . catch ( error => showToast ( backendErrorText ( error , '催缴通知重试失败' ) , 'error' ) ) ; return ; } notification . status = '重试中' ; notification . attempts += 1 ; notification . updatedAt = ` ${ todayTime ( ) } CST ` ; saveData ( ) ; render ( ) ; setTimeout ( ( ) => { notification . status = '成功' ; notification . error = '' ; saveData ( ) ; render ( ) ; showToast ( '通知重试成功' ) ; } , 800 ) ; return ; }
if ( action === 'retry-task' ) { const task = data . tasks . find ( item => item . id === actionElement . dataset . taskId ) ; task . status = 'processing' ; task . error = '' ; task . updatedAt = todayTime ( ) ; saveData ( ) ; renderOverlay ( ) ; setTimeout ( ( ) => { task . status = 'completed' ; task . updatedAt = todayTime ( ) ; saveData ( ) ; renderOverlay ( ) ; updateCounters ( ) ; showToast ( '任务重试完成' ) ; } , 900 ) ; return ; }
} ) ;
@@ -1458,12 +1695,8 @@ document.addEventListener('change', event => {
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 } ) ;
}
if ( event . target . id === 'bankSelector' ) { state . selectedBank = event . target . value ; state . selectedBankNode = '' ; state . bankQuestionPage = 1 ; syncNavigationState ( ) ; render ( ) ; loadBankWorkspace ( ) ; }
if ( event . target . id === 'bankStructuredFile' ) { const file = event . target . files ? . [ 0 ] ; if ( ! file ) return ; file . text ( ) . then ( text => { const target = document . querySelector ( '#bankStructuredPayload' ) ; if ( target ) { target . value = text ; markDirty ( 'page' ) ; } } ) . catch ( ( ) => showToast ( '无法读取所选 JSON 文件' , 'error' ) ) ; }
} ) ;
menuButton . addEventListener ( 'click' , toggleMobileMenu ) ;
document . querySelector ( '#sidebarBackdrop' ) . addEventListener ( 'click' , closeMobileMenu ) ;