forked from gongxuegit/tiku-backend.net
feat: add platform public question bank workspace
This commit is contained in:
@@ -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: '稳定版' },
|
||||
],
|
||||
grants: [
|
||||
{ id: 'GRANT-001', bankId: 'QB-TJ-MATH', scope: '指定租户', allowedPlanCodes: [], allowedTenantIds: ['tenant_gongxue_tj', 'tenant_qihang_gd'], allowedRegionIds: ['天津'], allowedSubjectIds: ['高等数学'], status: '启用', startsAt: '2026-07-18', expiresAt: '2027-07-18', localEdit: false },
|
||||
{ id: 'GRANT-002', bankId: 'QB-EN', scope: '指定套餐', allowedPlanCodes: ['pro', 'enterprise'], allowedTenantIds: [], allowedRegionIds: [], allowedSubjectIds: ['公共英语'], status: '启用', startsAt: '2026-06-01', expiresAt: '', localEdit: true },
|
||||
],
|
||||
bankSync: [
|
||||
{ tenantId: 'tenant_gongxue_tj', bankId: 'QB-TJ-MATH', current: 'v2026.6', target: 'v2026.7', progress: 100, conflicts: 0, status: '已完成' },
|
||||
{ tenantId: 'tenant_qihang_gd', bankId: 'QB-EN', current: 'v2026.5', target: 'v2026.6', progress: 72, conflicts: 3, status: '同步中' },
|
||||
{ tenantId: 'tenant_mingtu_hn', bankId: 'QB-EN', current: 'v2026.3', target: 'v2026.6', progress: 38, conflicts: 12, status: '同步失败' },
|
||||
bankNodes: [
|
||||
{ id: 'NODE-MATH', questionBankId: 'QB-TJ-MATH', parentId: null, name: '高等数学', nodeType: 'subject', depth: 0, sortOrder: 0, isActive: true, questionCount: 12860 },
|
||||
{ id: 'NODE-MATH-01', questionBankId: 'QB-TJ-MATH', parentId: 'NODE-MATH', name: '函数、极限与连续', nodeType: 'chapter', depth: 1, sortOrder: 0, isActive: true, questionCount: 1260 },
|
||||
],
|
||||
bankQuestions: [],
|
||||
bankImportResult: null,
|
||||
crmConfigs: [
|
||||
{ id: 'CRM-GX-TJ', tenantId: 'tenant_gongxue_tj', provider: '标准 CRM', endpoint: 'https://crm.gongxue.cn/open/leads', assignmentMode: '按地区轮转', pool: '天津升本线索池', enabled: true, status: '正常', timeout: 8, retry: 3, updatedAt: '2026-07-23 09:12' },
|
||||
{ id: 'CRM-QH-GD', tenantId: 'tenant_qihang_gd', provider: '自建 CRM', endpoint: 'https://crm.qihangzsb.cn/api/leads', assignmentMode: '按校区容量', pool: '广东转化队列', enabled: true, status: '异常', timeout: 10, retry: 5, updatedAt: '2026-07-22 18:40' },
|
||||
@@ -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', authUserId: 'auth-chenhao', username: 'chenhao', name: '陈浩', email: 'chenhao@gongxue.cn', phone: '138****6600', role: '超级管理员', permissions: ['*'], status: '正常', lastActive: '刚刚' },
|
||||
{ id: 'STAFF-02', authUserId: 'auth-zhoumin', username: 'zhoumin', name: '周敏', email: 'zhoumin@gongxue.cn', phone: '139****2188', role: '题库运营', permissions: ['platform:question_bank:read'], status: '正常', lastActive: '8分钟前' },
|
||||
{ id: 'STAFF-01', userId: 'STAFF-01', roleIds: ['role-super'], roleCodes: ['platform_super_admin'], name: '陈浩', email: 'chenhao@gongxue.cn', phone: '138****6600', role: '超级管理员', permissions: ['*'], status: '正常', lastActive: '刚刚' },
|
||||
{ id: 'STAFF-02', authUserId: 'auth-zhoumin', username: 'zhoumin', name: '周敏', email: 'zhoumin@gongxue.cn', phone: '139****2188', role: '题库运营', permissions: ['platform:question-bank:manage'], status: '正常', lastActive: '8分钟前' },
|
||||
{ id: 'STAFF-03', authUserId: 'auth-liujia', username: 'liujia', name: '刘佳', email: 'liujia@gongxue.cn', phone: '136****9028', role: '平台财务', permissions: ['platform:billing:read', 'platform:billing:write', 'platform:billing:payment', 'platform:usage:read', 'platform:usage:write'], status: '正常', lastActive: '21分钟前' },
|
||||
{ id: 'STAFF-04', authUserId: 'auth-wanglei', username: 'wanglei', name: '王磊', email: 'wanglei@gongxue.cn', phone: '135****2086', role: '租户运营', permissions: ['platform:tenant:read', 'platform:tenant:write', 'platform:tenant:status'], status: '正常', lastActive: '1小时前' },
|
||||
{ id: 'STAFF-05', authUserId: 'auth-sunchen', username: 'sunchen', name: '孙晨', email: 'sunchen@gongxue.cn', phone: '137****1118', role: '安全审计', permissions: ['platform:audit:read', 'platform:audit:alert'], status: '已停用', lastActive: '3天前' },
|
||||
@@ -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.update', targetType: 'grant', 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_executed', targetType: 'question_bank', targetId: 'QB-TJ-MATH', target: '高数公共题库 v2026.7', severity: '中', result: '成功', ip: '172.16.3.16', details: '结构化 JSON 导入完成,新增 8 个内容节点和 126 道题。' },
|
||||
{ id: 'AUD-20260723-084127', time: '2026-07-23 08:41:27 CST', actor: '刘佳', role: '平台财务', action: 'invoice.payment.confirm', targetType: 'invoice', targetId: 'INV-202607-0016', target: 'INV-202607-0016', severity: '高', result: '成功', ip: '172.16.8.12', details: '人工确认收款 ¥16,800,流水号已脱敏。' },
|
||||
{ id: 'AUD-20260723-081209', time: '2026-07-23 08:12:09 CST', actor: 'unknown', role: '-', action: 'auth.login.failed', targetType: 'application', targetId: 'platform-console', target: 'platform-console', severity: '严重', result: '失败', ip: '45.62.18.9', details: '5 分钟内连续登录失败 12 次。' },
|
||||
{ id: 'AUD-20260720-163510', time: '2026-07-20 16:35:10 CST', actor: '周敏', role: '题库运营', action: 'bank.catalog.read', targetType: 'question_bank', targetId: 'QB-SD', target: '山东专升本全科', severity: '中', result: '成功', ip: '172.16.3.16', details: '查看公共题库版本与租户采用状态。' },
|
||||
@@ -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 => ({ permissions: [], status: '正常', lastActive: '尚未登录', ...item }));
|
||||
normalized.platformPermissions = Object.fromEntries(Object.entries(normalized.platformPermissions || {}).map(([code, name]) => [
|
||||
code,
|
||||
seed.platformPermissions[code] || name,
|
||||
]));
|
||||
normalized.platformRoles = normalized.platformRoles.map(item => item.code === 'platform_super_admin'
|
||||
? { ...item, name: '超级管理员', description: '拥有全部平台管理权限' }
|
||||
: item);
|
||||
normalized.staff = normalized.staff.map(item => {
|
||||
const roleIds = item.roleIds?.length ? item.roleIds : normalized.platformRoles.filter(role => role.name === item.role).map(role => role.id);
|
||||
const assignedRoles = normalized.platformRoles.filter(role => roleIds.includes(role.id));
|
||||
return {
|
||||
permissions: assignedRoles.flatMap(role => role.permissionCodes),
|
||||
status: '正常',
|
||||
lastActive: '尚未登录',
|
||||
...item,
|
||||
userId: item.userId || item.id,
|
||||
roleIds,
|
||||
roleCodes: item.roleCodes || assignedRoles.map(role => role.code),
|
||||
};
|
||||
});
|
||||
normalized.alerts = normalized.alerts.map(item => ({ evidence: [], note: '', status: 'open', ...item }));
|
||||
normalized.tasks = normalized.tasks.map(item => ({ status: 'failed', error: '', resultTarget: '任务中心', updatedAt: item.createdAt || todayTime(), ...item }));
|
||||
normalized.version = seed.version;
|
||||
@@ -252,15 +307,15 @@ let data = loadData();
|
||||
if (platformApi?.isEnabled()) {
|
||||
[
|
||||
'tenants', 'plans', 'subscriptions', 'invoices', 'usage', 'reminders',
|
||||
'dunningChannels', 'dunningEvents', 'banks', 'grants', 'bankSync', 'staff',
|
||||
'dunningChannels', 'dunningEvents', 'banks', 'bankNodes', 'bankQuestions', 'staff',
|
||||
'auditLogs', 'alerts', 'alertRules', 'auditChannels', 'notificationEvents', 'tasks',
|
||||
].forEach(key => { data[key] = []; });
|
||||
data.billingProfiles = {};
|
||||
}
|
||||
const state = {
|
||||
view: 'overview', previousView: '', selectedTenant: data.tenants[0]?.id || '', tenantFilter: 'all', tenantPlan: 'all', tenantSearch: '', globalSearch: '',
|
||||
billingTab: 'invoices', bankTab: '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" ${state.bankIncludeTenant ? 'checked' : ''} />含租户库</label>${canWrite('platform:question_bank:grant') ? `<button class="button primary" data-action="new-grant">${icon('plus')}新建授权</button>` : ''}`)}
|
||||
<section class="filter-bar"><label>${icon('search')}<input id="bankSearch" value="${escapeHtml(state.bankSearch)}" placeholder="搜索题库名称、目录、来源或版本" /></label><button class="button" data-action="apply-bank-search" ${state.reading === 'bank-query' ? 'disabled' : ''}>${state.reading === 'bank-query' ? `${icon('loader-circle')}查询中` : `${icon('search')}查询`}</button></section>${contractResult('bank-query')}${contractResult('bank-sync')}
|
||||
<nav class="billing-tabs"><button class="${state.bankTab === 'catalog' ? 'is-active' : ''}" data-bank-tab="catalog">题库目录</button><button class="${state.bankTab === 'grants' ? 'is-active' : ''}" data-bank-tab="grants">授权规则</button><button class="${state.bankTab === 'sync' ? 'is-active' : ''}" data-bank-tab="sync">采用与同步</button></nav>
|
||||
${state.bankTab === 'catalog' ? `<div class="bank-layout"><aside class="bank-tree"><header><strong>公共题库目录</strong><span>${bankRows.length} 个</span></header>${bankRows.map(bank => `<button class="${bank.id === selected.id ? 'is-active' : ''}" data-bank-select="${bank.id}">${icon('library-big')}<span><strong>${escapeHtml(bank.name)}</strong><small>${formatNumber(bank.questions)} 题 · ${bank.source}</small></span>${icon('chevron-right')}</button>`).join('') || '<p class="tree-empty">没有匹配题库</p>'}${state.bankIncludeTenant ? `<button>${icon('building-2')}<span><strong>启航租户本地库</strong><small>68,420 题 · 只读来源查看</small></span>${icon('chevron-right')}</button>` : ''}</aside><section class="bank-content"><header><div><span>${escapeHtml(selected.catalog)}</span><h2>${escapeHtml(selected.name)}</h2><p>当前版本 ${selected.version} · 来源 ${selected.source}</p></div><button class="button" data-action="preview-bank-sync">采用与同步</button></header><div class="bank-stats"><div><span>题目</span><strong>${formatNumber(selected.questions)}</strong></div><div><span>授权规则</span><strong>${data.grants.filter(grant => grant.bankId === selected.id).length}</strong></div><div><span>采用租户</span><strong>${data.bankSync.filter(sync => sync.bankId === selected.id).length}</strong></div><div><span>当前状态</span><strong>${selected.status}</strong></div></div><section class="version-card"><div><span class="status success">${selected.status}</span><h3>${selected.version}</h3><p>公共题库内容由平台内容生产链路维护,本页只负责授权和采用状态。</p><small>更新时间 2026-07-23 09:00 CST</small></div><div><button class="button" data-action="preview-bank-sync">查看同步</button><button class="button primary" data-action="new-grant">授权租户</button></div></section></section></div>` : state.bankTab === 'grants' ? `<section class="table-panel"><table class="platform-table"><thead><tr><th>规则</th><th>题库</th><th>授权范围</th><th>套餐 / 租户</th><th>地区 / 科目</th><th>有效期</th><th>本地改编</th><th>状态</th><th></th></tr></thead><tbody>${data.grants.filter(grant => !query || [grant.id, bankById(grant.bankId)?.name, grant.scope, ...grant.allowedRegionIds, ...grant.allowedSubjectIds].some(value => String(value || '').toLowerCase().includes(query))).map(grant => `<tr><td><code>${grant.id}</code></td><td><strong>${escapeHtml(bankById(grant.bankId)?.name || '-')}</strong></td><td>${grant.scope}</td><td>${grant.allowedPlanCodes.length ? grant.allowedPlanCodes.map(code => planByCode(code)?.name || code).join('、') : grant.allowedTenantIds.map(id => tenantById(id)?.name || id).join('、') || '-'}</td><td>${[...grant.allowedRegionIds, ...grant.allowedSubjectIds].join(' / ') || '不限'}</td><td>${grant.startsAt} 至 ${grant.expiresAt || '长期'}</td><td>${grant.localEdit ? '允许' : '只读采用'}</td><td>${statusBadge(grant.status)}</td><td><div class="row-actions"><button class="row-action" data-action="edit-grant" data-grant-id="${grant.id}">${icon('pencil')}</button><button class="switch ${grant.status === '启用' ? 'is-on' : ''}" data-action="toggle-grant" data-grant-id="${grant.id}"><i></i></button></div></td></tr>`).join('')}</tbody></table></section>` : `<section class="table-panel"><div class="table-head"><div><h2>租户采用与同步状态</h2><span>源版本、任务状态与冲突均来自专属查询</span></div><button class="button" data-action="refresh-bank-sync" ${state.reading === 'bank-sync' ? 'disabled' : ''}>${state.reading === 'bank-sync' ? `${icon('loader-circle')}刷新中` : `${icon('refresh-cw')}刷新同步状态`}</button></div><table class="platform-table"><thead><tr><th>租户</th><th>题库</th><th>当前版本</th><th>目标版本</th><th>同步进度</th><th>冲突</th><th>状态</th><th></th></tr></thead><tbody>${data.bankSync.filter(sync => !query || [tenantById(sync.tenantId)?.name, bankById(sync.bankId)?.name, sync.current, sync.target, sync.status].some(value => String(value || '').toLowerCase().includes(query))).map(sync => `<tr><td>${escapeHtml(tenantById(sync.tenantId)?.name || '-')}</td><td>${escapeHtml(bankById(sync.bankId)?.name || '-')}</td><td>${sync.current}</td><td>${sync.target}</td><td><div class="usage-cell"><span>${sync.progress}%</span><i><b style="width:${sync.progress}%"></b></i></div></td><td>${sync.conflicts}</td><td>${statusBadge(sync.status)}</td><td><button class="row-action" data-action="sync-detail" data-sync-key="${sync.tenantId}:${sync.bankId}">${icon('chevron-right')}</button></td></tr>`).join('')}</tbody></table></section>`}</div>`;
|
||||
const bankRows = data.banks.filter(bank => !query || [bank.name, bank.status].some(value => String(value || '').toLowerCase().includes(query)));
|
||||
const nodes = data.bankNodes.filter(node => node.questionBankId === selected?.id);
|
||||
const nodeOptions = nodes.filter(node => node.isActive).map(node => `<option value="${node.id}" ${state.selectedBankNode === node.id ? 'selected' : ''}>${' '.repeat(node.depth || 0)}${escapeHtml(node.name)}(${bankNodeTypeLabel(node.nodeType)})</option>`).join('');
|
||||
const tabs = [['structure', '内容结构'], ['single', '单题录入'], ['batch', '批量导入'], ['manage', '题目管理'], ['structured', 'JSON 文件导入']];
|
||||
const bankSelect = `<select id="bankSelector" aria-label="当前公共题库">${data.banks.map(bank => `<option value="${bank.id}" ${bank.id === selected?.id ? 'selected' : ''}>${escapeHtml(bank.name)}</option>`).join('')}</select>`;
|
||||
let content = emptyBlock('尚未创建公共题库', '点击“新建题库”后即可配置分类、科目、章节和试卷。');
|
||||
if (selected && state.bankTab === 'structure') content = `<div class="bank-layout"><aside class="bank-tree"><header><strong>内容层级</strong><span>${nodes.length} 个节点</span></header>${nodes.map(node => `<button class="${node.id === state.selectedBankNode ? 'is-active' : ''}" data-bank-node-select="${node.id}" style="padding-left:${18 + Number(node.depth || 0) * 18}px">${icon(node.nodeType === 'paper' ? 'file-text' : node.nodeType === 'chapter' ? 'book-open' : 'folder-tree')}<span><strong>${escapeHtml(node.name)}</strong><small>${bankNodeTypeLabel(node.nodeType)} · ${node.questionCount || 0} 题</small></span></button>`).join('') || '<p class="tree-empty">暂无内容层级</p>'}</aside><section class="bank-content"><header><div><span>平台公共题库</span><h2>${escapeHtml(selected.name)}</h2><p>通过分类、科目、章节和试卷组织题目。</p></div><div class="row-actions"><button class="button" data-action="edit-bank">编辑题库</button><button class="button" data-action="new-bank-node">新建节点</button><button class="button primary" data-action="batch-bank-nodes">批量创建章节/试卷</button></div></header><div class="bank-stats"><div><span>内容节点</span><strong>${selected.nodes || nodes.length}</strong></div><div><span>题目总数</span><strong>${formatNumber(selected.questions || 0)}</strong></div><div><span>启用节点</span><strong>${nodes.filter(item => item.isActive).length}</strong></div><div><span>题库状态</span><strong>${selected.status}</strong></div></div>${state.selectedBankNode ? `<section class="version-card"><div><span class="status success">${bankNodeTypeLabel(bankNodeById(state.selectedBankNode)?.nodeType)}</span><h3>${escapeHtml(bankNodeById(state.selectedBankNode)?.name || '')}</h3><p>当前节点共 ${bankNodeById(state.selectedBankNode)?.questionCount || 0} 道未归档题目。</p></div><div><button class="button" data-action="edit-bank-node">编辑节点</button><button class="button danger" data-action="archive-bank-node">归档节点</button></div></section>` : ''}</section></div>`;
|
||||
if (selected && state.bankTab === 'structure' && state.selectedBankNode) content += `<section class="panel"><div class="panel-head"><div><h2>节点排序</h2><span>调整当前节点在同级内容中的显示顺序</span></div><div class="row-actions"><button class="button" data-action="shift-bank-node" data-sort-delta="-10">上移</button><button class="button" data-action="shift-bank-node" data-sort-delta="10">下移</button></div></div></section>`;
|
||||
if (selected && state.bankTab === 'single') content = `<section class="panel"><div class="panel-head"><div><h2>单题录入</h2><span>保存后自动形成新题目版本</span></div></div><form id="bankQuestionForm" class="modal-form"><input type="hidden" name="id" /><div class="form-error-summary" hidden tabindex="-1"></div><div class="form-grid"><label>目标章节/试卷<select name="contentNodeId" required>${nodeOptions}</select></label><label>题型<select name="type"><option value="choice">单选题</option><option value="multiple_choice">多选题</option><option value="true_false">判断题</option><option value="fill_blank">填空题</option><option value="short_answer">简答题</option><option value="reading">阅读材料题</option><option value="programming">编程题</option></select></label><label>难度<select name="difficulty"><option value="1">1 · 容易</option><option value="2">2 · 较易</option><option value="3" selected>3 · 中等</option><option value="4">4 · 较难</option><option value="5">5 · 困难</option></select></label><label>稳定编号<input name="legacyId" placeholder="可选,用于重复导入识别" /></label><label class="full">题干<textarea name="content" rows="6" required placeholder="支持 Markdown 和图片链接"></textarea></label><label class="full">选项 JSON<textarea name="options" rows="4" placeholder='["选项 A","选项 B"]'></textarea></label><label class="full">答案<textarea name="answerText" rows="2"></textarea></label><label class="full">解析<textarea name="explanation" rows="4"></textarea></label><label>图片或媒体地址<input name="mediaUrl" placeholder="https://..." /></label><label>上传题目图片<input id="bankQuestionImage" type="file" accept="image/*" /><button class="button" type="button" data-action="upload-question-image">上传并填入地址</button></label><label>标签<input name="tags" placeholder="高频、真题" /></label></div><div class="modal-actions"><button class="button" type="button" data-action="preview-question">预览</button><button class="button primary" type="submit">保存题目</button></div></form></section>`;
|
||||
if (selected && state.bankTab === 'batch') content = `<section class="panel"><div class="panel-head"><div><h2>普通题目批量导入</h2><span>先选择章节或试卷,再粘贴题目数组</span></div></div><form id="bankBatchImportForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><div class="form-grid"><label>目标章节/试卷<select name="contentNodeId" required>${nodeOptions}</select></label><label>来源名称<input name="sourceName" placeholder="例如:2026 年高数整理" /></label><label class="full">题目 JSON<textarea name="payload" rows="16" required placeholder='[{"type":"choice","content":"题干","options":["A","B"],"answerText":"A"}]'></textarea></label></div><div class="modal-actions"><button class="button" type="submit" data-import-mode="preview">预检数据</button><button class="button primary" type="submit" data-import-mode="execute">执行导入</button></div></form>${renderBankImportResult()}</section>`;
|
||||
if (selected && state.bankTab === 'manage') content = `<section class="filter-bar"><label>${icon('search')}<input id="bankQuestionSearch" placeholder="搜索题干或题型" /></label><select id="bankQuestionType"><option value="">全部题型</option>${['choice','multiple_choice','true_false','fill_blank','short_answer','reading','programming'].map(value => `<option value="${value}">${questionTypeLabel(value)}</option>`).join('')}</select><select id="bankQuestionStatus"><option value="">全部状态</option><option value="published">已发布</option><option value="draft">草稿</option><option value="archived">已归档</option></select><button class="button" data-action="load-bank-questions">查询题目</button></section><section class="table-panel"><table class="platform-table"><thead><tr><th>题目</th><th>题型</th><th>难度</th><th>节点</th><th>版本</th><th>状态</th><th></th></tr></thead><tbody>${data.bankQuestions.map(item => `<tr><td><strong>${escapeHtml(String(item.content || '').slice(0, 80) || '未填写题干')}</strong><small>${escapeHtml(item.legacyId || item.id)}</small></td><td>${questionTypeLabel(item.type)}</td><td>${item.difficulty || '-'}</td><td>${escapeHtml(bankNodeById(item.contentNodeId)?.name || '-')}</td><td>V${item.versionNo}</td><td>${statusBadge(item.statusLabel)}</td><td><button class="button" data-action="edit-bank-question" data-question-id="${item.id}">编辑</button>${item.statusLabel !== '已归档' ? `<button class="button danger" data-action="archive-bank-question" data-question-id="${item.id}">归档</button>` : ''}</td></tr>`).join('')}</tbody></table>${data.bankQuestions.length ? `<div class="table-head"><span>共 ${state.bankQuestionTotal} 道题</span><div><button class="button" data-action="bank-question-page" data-page="${Math.max(1, state.bankQuestionPage - 1)}" ${state.bankQuestionPage <= 1 ? 'disabled' : ''}>上一页</button><button class="button" data-action="bank-question-page" data-page="${state.bankQuestionPage + 1}" ${state.bankQuestionPage * 20 >= state.bankQuestionTotal ? 'disabled' : ''}>下一页</button></div></div>` : emptyBlock('暂无题目', '选择节点后查询,或先录入、导入题目。')}</section>`;
|
||||
if (selected && state.bankTab === 'structured') content = `<section class="panel"><div class="panel-head"><div><h2>JSON 文件导入</h2><span>支持带分类、科目、章节/试卷和题目的 2.0 结构化文件</span></div></div><form id="bankStructuredImportForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><div class="form-grid"><label>挂载到已有节点<select name="contentNodeId"><option value="">从题库根层级导入</option>${nodeOptions}</select></label><label>选择 .json 文件<input id="bankStructuredFile" type="file" accept="application/json,.json" /></label><label class="full">结构化 JSON<textarea id="bankStructuredPayload" name="payload" rows="18" required placeholder='{"_tikuExport":"2.0","subjects":[...]}'></textarea></label></div><div class="modal-actions"><button class="button" type="submit" data-import-mode="preview">预检层级和题目</button><button class="button primary" type="submit" data-import-mode="execute">执行结构化导入</button></div></form>${renderBankImportResult()}</section>`;
|
||||
return `<div class="page-shell">${pageHead('公共题库', '配置公共题库内容结构,完成单题录入、题目管理和两种 JSON 导入。', `${selected ? `<button class="button danger" data-action="archive-bank">${icon('archive')}归档题库</button>` : ''}<button class="button primary" data-action="new-bank">${icon('plus')}新建题库</button>`)}<section class="filter-bar"><label>${icon('search')}<input id="bankSearch" value="${escapeHtml(state.bankSearch)}" placeholder="搜索题库名称" /></label>${bankSelect}<button class="button" data-action="apply-bank-search">${icon('search')}查询</button></section>${contractResult('bank-query')}<nav class="billing-tabs">${tabs.map(([value, label]) => `<button class="${state.bankTab === value ? 'is-active' : ''}" data-bank-tab="${value}">${label}</button>`).join('')}</nav>${content}</div>`;
|
||||
}
|
||||
|
||||
function renderBankImportResult() {
|
||||
const result = data.bankImportResult;
|
||||
if (!result) return '';
|
||||
const job = result.detail?.job || result.job || {};
|
||||
const issues = result.detail?.issues || result.issues || [];
|
||||
return `<section class="version-card"><div><span class="status ${Number(job.errorCount || 0) ? 'warning' : 'success'}">${job.dryRun ? '预检完成' : '导入完成'}</span><h3>共 ${job.totalCount || 0} 条,${job.validCount || 0} 条有效</h3><p>新增题目 ${result.insertedQuestionCount || 0},更新 ${result.updatedQuestionCount || 0},跳过 ${result.skippedQuestionCount || 0},新建节点 ${result.createdNodeCount || 0}。</p>${issues.length ? `<small>${issues.slice(0, 8).map(item => `第 ${item.rowNo || '-'} 行:${escapeHtml(item.message)}`).join('<br>')}</small>` : '<small>没有发现数据问题。</small>'}</div></section>`;
|
||||
}
|
||||
|
||||
function renderCrm() {
|
||||
const activeConfigs = data.crmConfigs.filter(item => item.enabled).length;
|
||||
const failedLeads = data.crmLeads.filter(item => item.status === '推送失败').length;
|
||||
const notice = 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.permissions.includes('*') ? '全部平台权限' : role.permissions.join(' · ')}</small></div><b>${data.staff.filter(person => person.role === role.label).length}人</b><button class="button" data-action="new-staff" data-role-key="${key}">按此角色创建</button></article>`).join('')}</section></div></div>`;
|
||||
<div class="staff-layout"><section class="panel"><div class="panel-head"><div><h2>平台员工</h2><span>${staffRows.length} 名匹配 · ${data.staff.filter(item => item.status === '正常').length} 名活跃</span></div></div>${staffRows.map(person => `<article class="staff-row"><span>${escapeHtml(person.name.slice(0, 1))}</span><div><strong>${escapeHtml(person.name)}</strong><small>${escapeHtml(person.email)} · ${escapeHtml(person.phone)}</small></div><b>${escapeHtml(person.role)}</b><em>${person.permissions.includes('*') ? '全部平台功能' : `${person.permissions.length} 项功能`}</em><i class="status ${tone(person.status)}">${person.status}</i><div class="row-actions"><button class="row-action" data-action="edit-staff" data-staff-id="${person.id}">${icon('pencil')}</button><button class="switch ${person.status === '正常' ? 'is-on' : ''}" data-action="toggle-staff" data-staff-id="${person.id}"><i></i></button></div></article>`).join('') || emptyBlock('没有匹配员工', '调整姓名、邮箱、手机号、角色或状态关键词。')}</section><section class="panel"><div class="panel-head"><div><h2>平台角色与功能</h2><span>按岗位分配可使用的业务功能</span></div></div>${data.platformRoles.map(role => { const features = role.permissionCodes.map(code => data.platformPermissions[code] || code); return `<article class="role-row"><span>${icon('shield-check')}</span><div><strong>${escapeHtml(role.name)}</strong><small>${escapeHtml(role.description || (features.includes('*') ? '全部平台功能' : features.join('、')))}</small></div><b>${data.staff.filter(person => person.roleIds?.includes(role.id)).length}人</b><button class="button" data-action="new-staff" data-role-id="${role.id}">按此角色创建</button></article>`; }).join('') || emptyBlock('暂无可用平台角色', '请先创建平台角色并选择可用功能。')}</section></div></div>`;
|
||||
}
|
||||
|
||||
function renderAudit() {
|
||||
@@ -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('品牌名称', 'brandName')}${formField('简称', 'shortName')}${formField('主域名 *', 'primaryHost', '', 'required placeholder="study.example.cn"')}<label><span>初始套餐 *</span><select name="planCode">${planOptions('pro')}</select></label><label><span>业务模式</span><select name="mode"><option>试用</option><option>正式</option></select></label><label><span>业务状态</span><select name="status"><option>待配置</option><option>试用</option><option>正常</option></select></label><label><span>账务状态</span><select name="billingStatus"><option>正常</option><option>待付款</option></select></label><label><span>首期金额(元)</span><input name="amount" type="number" min="0" step="0.01" value="0" /></label></div></form>`, `<button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="createTenantForm" ${state.submitting === 'create-tenant' ? 'disabled' : ''}>${state.submitting === 'create-tenant' ? `${icon('loader-circle')}创建中` : '创建租户'}</button>`, { id: 'P-02 · 创建向导', wide: true });
|
||||
return modalFrame('开通租户', '创建租户和首位负责人账号;套餐、域名和账务信息可在开通后继续配置。', `<form id="createTenantForm" class="modal-form" novalidate><div class="form-error-summary" hidden tabindex="-1"></div><div class="form-grid">${formField('租户短编码 *', 'slug', '', 'required placeholder="例如 xuehai-zj"')}${formField('租户名称 *', 'name', '', 'required')}${formField('法定主体名称', 'legalName')}${formField('负责人姓名 *', 'ownerName', '', 'required')}${formField('负责人邮箱', 'ownerEmail', '', 'type="email"')}${formField('负责人手机号', 'ownerPhone', '', 'placeholder="11 位手机号"')}${formField('临时密码 *', 'temporaryPassword', '', 'required type="password" minlength="12" autocomplete="new-password" placeholder="至少 12 位,包含字母和数字"')}<label><span>业务状态</span><select name="status"><option>待配置</option><option>试用</option><option>正常</option></select></label><label><span>账务状态</span><select name="billingStatus"><option>试用</option><option>正常</option><option>暂停</option></select></label></div></form>`, `<button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="createTenantForm" ${state.submitting === 'create-tenant' ? 'disabled' : ''}>${state.submitting === 'create-tenant' ? `${icon('loader-circle')}开通中` : '开通租户'}</button>`, { id: 'P-02 · 租户开通', wide: true });
|
||||
},
|
||||
tenantStatus() {
|
||||
const tenant = tenantById(state.modalData.tenantId);
|
||||
@@ -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 });
|
||||
},
|
||||
grant() {
|
||||
const grant = data.grants.find(item => item.id === state.modalData.grantId) || {};
|
||||
return modalFrame(grant.id ? '编辑公共题库授权' : '新建公共题库授权', '授权范围与允许列表会按后端 DTO 保存;地区和科目字段使用 UUID。', `<form id="grantForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><input type="hidden" name="id" value="${grant.id || ''}" /><div class="form-grid"><label><span>来源题库 *</span><select name="bankId">${data.banks.map(bank => `<option value="${bank.id}" ${bank.id === (grant.bankId || state.selectedBank) ? 'selected' : ''}>${bank.name}</option>`).join('')}</select></label><label><span>授权范围 *</span><select name="scope"><option ${grant.scope === '全部活跃租户' ? 'selected' : ''}>全部活跃租户</option><option ${grant.scope === '指定套餐' ? 'selected' : ''}>指定套餐</option><option ${grant.scope === '指定租户' ? 'selected' : ''}>指定租户</option><option ${grant.scope === '套餐 + 租户' ? 'selected' : ''}>套餐 + 租户</option></select></label><label><span>允许套餐</span><select name="planCode"><option value="">不限制套餐</option>${planOptions(grant.allowedPlanCodes?.[0] || '')}</select></label><label><span>允许租户</span><select name="tenantId"><option value="">不指定租户</option>${tenantOptions(grant.allowedTenantIds?.[0] || '')}</select></label>${formField('地区 ID(UUID)', 'regionIds', grant.allowedRegionIds?.join(',') || '', 'placeholder="多个 UUID 用逗号分隔"')}${formField('科目 ID(UUID)', 'subjectIds', grant.allowedSubjectIds?.join(',') || '', 'placeholder="多个 UUID 用逗号分隔"')}<label><span>开始日期 *</span><input name="startsAt" type="date" value="${grant.startsAt || '2026-07-23'}" /></label><label><span>到期日期</span><input name="expiresAt" type="date" value="${grant.expiresAt || ''}" /></label><label class="check-row"><input name="localEdit" type="checkbox" ${grant.localEdit ? 'checked' : ''} /> 允许租户本地改编</label></div></form>`, `<button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="grantForm">保存授权</button>`, { id: 'P-09 · 授权规则', wide: true });
|
||||
bank() {
|
||||
const bank = bankById(state.modalData.bankId) || {};
|
||||
return modalFrame(bank.id ? '编辑公共题库' : '新建公共题库', '题库保存后可继续配置分类、科目、章节和试卷。', `<form id="bankForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><input type="hidden" name="id" value="${bank.id || ''}" /><div class="form-grid">${formField('题库名称 *', 'name', bank.name || '', 'required maxlength="300"')}${formField('内容版本', 'version', bank.version === '-' ? '' : (bank.version || ''), 'placeholder="例如:2026.1"')}</div></form>`, `<button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="bankForm">保存题库</button>`, { id: 'P-09 · 公共题库' });
|
||||
},
|
||||
bankNode() {
|
||||
const node = bankNodeById(state.modalData.nodeId) || {};
|
||||
const parents = data.bankNodes.filter(item => item.questionBankId === state.selectedBank && item.isActive && item.id !== node.id);
|
||||
return modalFrame(node.id ? '编辑内容节点' : '新建内容节点', '使用中文业务层级组织题目,章节和试卷可直接录入题目。', `<form id="bankNodeForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><input type="hidden" name="id" value="${node.id || ''}" /><div class="form-grid">${formField('节点名称 *', 'name', node.name || '', 'required maxlength="300"')}<label><span>节点类型 *</span><select name="nodeType"><option value="category" ${node.nodeType === 'category' ? 'selected' : ''}>分类</option><option value="subject" ${node.nodeType === 'subject' ? 'selected' : ''}>科目</option><option value="chapter" ${node.nodeType === 'chapter' ? 'selected' : ''}>章节</option><option value="paper" ${node.nodeType === 'paper' ? 'selected' : ''}>试卷</option></select></label><label><span>上级节点</span><select name="parentId"><option value="">题库根层级</option>${parents.map(item => `<option value="${item.id}" ${item.id === (node.parentId || state.selectedBankNode) ? 'selected' : ''}>${' '.repeat(item.depth || 0)}${escapeHtml(item.name)}</option>`).join('')}</select></label>${formField('排序', 'sortOrder', node.sortOrder || 0, 'type="number" min="0"')}</div></form>`, `<button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="bankNodeForm">保存节点</button>`, { id: 'P-09 · 内容结构' });
|
||||
},
|
||||
batchBankNodes() {
|
||||
return modalFrame('批量创建章节或试卷', '每行一个名称,一次最多创建 100 个。', `<form id="bankNodeBatchForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><div class="form-grid"><label><span>类型</span><select name="nodeType"><option value="chapter">章节</option><option value="paper">试卷</option></select></label><label><span>上级节点</span><select name="parentId"><option value="">题库根层级</option>${data.bankNodes.filter(item => item.questionBankId === state.selectedBank && item.isActive).map(item => `<option value="${item.id}" ${item.id === state.selectedBankNode ? 'selected' : ''}>${' '.repeat(item.depth || 0)}${escapeHtml(item.name)}</option>`).join('')}</select></label><label class="full"><span>名称列表 *</span><textarea name="names" rows="10" required placeholder="第一章 函数与极限 第二章 导数与微分"></textarea></label></div></form>`, `<button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="bankNodeBatchForm">批量创建</button>`, { id: 'P-09 · 批量创建', wide: true });
|
||||
},
|
||||
crmConfig() {
|
||||
const config = data.crmConfigs.find(item => item.id === state.modalData.crmId) || {};
|
||||
@@ -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:write', '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(role => `<option ${selectedRole === role ? 'selected' : ''}>${role}</option>`).join('')}</select></label></div><fieldset><legend>平台权限</legend><div class="permission-grid">${permissionList.map(permission => { const selected = person.permissions?.includes('*') || person.permissions?.includes(permission) || presetPermissions.includes('*') || presetPermissions.includes(permission); return `<label><input type="checkbox" name="permissions" value="${permission}" ${selected ? 'checked' : ''} />${permission}</label>`; }).join('')}</div><button type="button" class="link" data-action="select-permission-group">选择当前角色推荐权限</button><button type="button" class="link" data-action="clear-staff-form">清空整个表单</button></fieldset></form>`, `<button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="staffForm">保存员工</button>`, { id: 'P-13 · 员工权限', wide: true });
|
||||
const selectedRoleIds = person.roleIds?.length ? person.roleIds : (state.modalData.roleId ? [state.modalData.roleId] : []);
|
||||
const roleOptions = data.platformRoles.map(role => {
|
||||
const featureNames = role.permissionCodes.map(code => data.platformPermissions[code] || code).join('、') || '暂无功能';
|
||||
return `<label><input type="checkbox" name="roleIds" value="${role.id}" ${selectedRoleIds.includes(role.id) ? 'checked' : ''} /><span><strong>${escapeHtml(role.name)}</strong><small>${escapeHtml(role.description || featureNames)}</small><em>包含功能:${escapeHtml(featureNames)}</em></span></label>`;
|
||||
}).join('');
|
||||
return modalFrame(person.id ? '编辑平台员工' : '新建平台员工', '员工账号与平台角色由后端统一管理,功能说明来自当前权限目录。', `<form id="staffForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><input type="hidden" name="userId" value="${person.userId || person.id || ''}" /><div class="form-grid">${formField('姓名 *', 'name', person.name || '', 'required')}${formField('邮箱 *', 'email', person.email || '', 'required type="email"')}${formField('手机号', 'phone', editablePhone, 'placeholder="已脱敏号码留空表示不修改"')}</div><fieldset><legend>角色与功能选择</legend><div class="permission-grid">${roleOptions || '<p>当前账号无权读取平台角色目录。</p>'}</div><button type="button" class="link" data-action="clear-staff-form">清空整个表单</button></fieldset></form>`, `<button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="staffForm">保存员工</button>`, { id: 'P-13 · 员工角色', wide: true });
|
||||
},
|
||||
auditDetail() {
|
||||
const log = data.auditLogs.find(item => item.id === state.modalData.auditId);
|
||||
@@ -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', 'grantForm', 'crmConfigForm', 'smsChannelForm', 'paymentAppForm', 'staffForm', 'auditChannelForm']);
|
||||
const desktopOnlyForms = new Set(['createTenantForm', 'tenantStatusForm', 'billingProfileForm', 'subscriptionForm', 'invoiceForm', 'paymentForm', 'usageForm', 'dunningChannelForm', 'bankForm', 'bankNodeForm', 'bankNodeBatchForm', 'crmConfigForm', 'smsChannelForm', 'paymentAppForm', 'staffForm', 'auditChannelForm']);
|
||||
if (desktopOnlyForms.has(form.id) && requireDesktop('提交复杂配置或资金操作')) return;
|
||||
if (form.id === 'saasFeatureForm') {
|
||||
const values = Object.fromEntries(new FormData(form));
|
||||
simulateWrite({ key: 'saas-feature', permission: 'platform:plan:write', operationId: 'PlatformSaasController_upsertFeature', body: values, validate: () => !values.code || !values.name || !values.category ? { code: '功能编码、名称和分类不能为空' } : null, apply: payload => { const item = payload || { ...values, id: entityId('FEATURE'), referencePriceCents: Math.round(Number(values.referencePrice || 0) * 100) }; data.saasCatalog.features.unshift(item); return item; }, success: 'SaaS 功能已保存' });
|
||||
}
|
||||
if (form.id === 'saasOfferingForm') {
|
||||
const values = Object.fromEntries(new FormData(form));
|
||||
simulateWrite({ key: 'saas-offering', permission: 'platform:plan:write', operationId: 'PlatformSaasController_upsertOffering', body: values, validate: () => !values.code || !values.name ? { code: '商品编码和名称不能为空' } : null, apply: payload => { const item = payload || { ...values, id: entityId('OFFERING') }; data.saasCatalog.offerings.unshift(item); return item; }, success: 'SaaS 商品已保存' });
|
||||
}
|
||||
if (form.id === 'saasLimitForm') {
|
||||
const values = Object.fromEntries(new FormData(form)); values.isHardLimit = Boolean(new FormData(form).get('isHardLimit'));
|
||||
simulateWrite({ key: 'saas-limit', permission: 'platform:plan:write', operationId: 'PlatformSaasController_upsertFeatureLimit', body: values, validate: () => !values.metricCode || !values.featureCode || !values.name ? { metricCode: '指标编码、所属功能和限额名称不能为空' } : null, apply: payload => { const item = payload || { ...values, id: entityId('LIMIT') }; data.saasCatalog.limitDefinitions.unshift(item); return item; }, success: 'SaaS 功能限额已保存' });
|
||||
}
|
||||
if (form.id === 'tenantFeatureOverrideForm') {
|
||||
const values = Object.fromEntries(new FormData(form));
|
||||
simulateWrite({ key: 'tenant-feature-override', permission: 'platform:plan:write', operationId: 'PlatformSaasController_upsertTenantFeatureOverride', body: values, validate: () => !values.tenantId || !values.featureCode || !values.reason ? { reason: '租户、功能和操作原因不能为空' } : null, apply: payload => payload || values, success: '租户功能覆盖规则已保存' });
|
||||
}
|
||||
if (form.id === 'saasVersionForm') {
|
||||
const values = Object.fromEntries(new FormData(form));
|
||||
simulateWrite({ key: 'saas-version', permission: 'platform:plan:write', operationId: 'PlatformSaasController_upsertVersion', body: values, validate: () => !values.offeringId ? { offeringId: '必须选择所属商品' } : Number(values.amount) < 0 ? { amount: '售价不能小于 0' } : null, apply: payload => { const item = payload || { ...values, id: entityId('VERSION'), version: 1, status: 'draft', amountCents: Math.round(Number(values.amount || 0) * 100), originalAmountCents: Math.round(Number(values.originalAmount || 0) * 100) }; data.saasCatalog.versions.unshift(item); return item; }, success: '套餐版本草稿已保存' });
|
||||
}
|
||||
if (form.id === 'platformRoleForm') {
|
||||
const values = Object.fromEntries(new FormData(form)); const permissionCodes = new FormData(form).getAll('permissionCodes');
|
||||
let savedRole = null;
|
||||
simulateWrite({ key: 'platform-role', permission: 'platform:staff:write', request: async () => { savedRole = await platformApi.request('PlatformBackofficeController_upsertRole', { body: platformApi.buildBody('PlatformBackofficeController_upsertRole', values) }); await platformApi.request('PlatformBackofficeController_replaceRoleBindings', { pathParams: { roleId: savedRole.id }, body: platformApi.buildBody('PlatformBackofficeController_replaceRoleBindings', { permissionCodes, menuCodes: savedRole.menuCodes || [] }) }); return savedRole; }, validate: () => { const errors = {}; if (!values.code) errors.code = '角色编码不能为空'; if (!values.name) errors.name = '角色名称不能为空'; if (!permissionCodes.length) errors.permissionCodes = '至少选择一项平台功能'; return Object.keys(errors).length ? errors : null; }, apply: payload => { const existing = data.platformRoles.find(item => item.id === values.id); const next = { id: payload?.id || values.id || entityId('ROLE'), code: values.code, name: values.name, description: values.description || '', permissionCodes, menuCodes: payload?.menuCodes || existing?.menuCodes || [] }; if (existing) Object.assign(existing, next); else data.platformRoles.unshift(next); return next; }, success: '平台角色与功能绑定已保存' });
|
||||
}
|
||||
if (form.id === 'paymentChannelForm') {
|
||||
const values = Object.fromEntries(new FormData(form));
|
||||
simulateWrite({ key: 'payment-channel', permission: 'platform:payment:write', operationId: 'PlatformAdminPaymentSettingsController_upsertChannel', body: values, validate: () => { const errors = {}; if (!values.appId) errors.appId = '必须选择平台支付应用'; if (!values.displayName) errors.displayName = '渠道名称不能为空'; if (!values.provider) errors.provider = 'Provider 不能为空'; return Object.keys(errors).length ? errors : null; }, apply: payload => { const existing = data.paymentChannels.find(item => item.id === values.id); const next = payload || { ...values, id: values.id || entityId('PAYCH') }; if (existing) Object.assign(existing, next); else data.paymentChannels.unshift(next); return next; }, success: '平台支付渠道已保存' });
|
||||
}
|
||||
if (form.id === 'smsTemplateForm') {
|
||||
const values = Object.fromEntries(new FormData(form));
|
||||
simulateWrite({ key: 'sms-template', permission: 'platform:sms:write', operationId: 'PlatformAdminSmsController_upsertTemplate', body: values, validate: () => { const errors = {}; ['tenantId', 'channelId', 'code', 'name', 'content'].forEach(name => { if (!values[name]) errors[name] = `${name} 不能为空`; }); return Object.keys(errors).length ? errors : null; }, apply: payload => { const existing = data.smsTemplates.find(item => item.id === values.id); const next = payload ? platformApi.adapters.smsTemplate(payload) : { ...values, id: values.id || entityId('SMSTPL'), status: '草稿', templateStatus: '正常', sentCount: 0, failedCount: 0 }; if (existing) Object.assign(existing, next); else data.smsTemplates.unshift(next); return next; }, success: '短信模板草稿已保存' });
|
||||
}
|
||||
if (form.id === 'createTenantForm') {
|
||||
const slug = fieldValue(form, 'slug'); const host = fieldValue(form, 'primaryHost'); const name = fieldValue(form, 'name'); const amount = parseMoney(fieldValue(form, 'amount'));
|
||||
simulateWrite({ key: 'create-tenant', permission: 'platform:tenant:write', operationId: 'PlatformAdminTenantsController_create', body: () => ({ ...Object.fromEntries(new FormData(form)), amount }), validate: () => {
|
||||
const values = Object.fromEntries(new FormData(form)); const slug = values.slug; const name = values.name;
|
||||
simulateWrite({ key: 'create-tenant', permission: 'platform:tenant:write', operationId: 'PlatformAdminTenantsController_create', body: () => values, validate: () => {
|
||||
const errors = {}; if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) errors.slug = 'slug 仅支持小写字母、数字和连字符';
|
||||
if (!name) errors.name = '租户名称不能为空'; if (!/^([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 amount = parseMoney(values.amount);
|
||||
simulateWrite({ key: 'payment', permission: 'platform:billing:payment', operationId: 'PlatformAdminBillingController_confirmPayment', body: () => ({ ...values, tenantId: invoice.tenantId, invoiceId: invoice.backendId || invoice.id, amount }), validate: () => { const errors = {}; if (!amount || amount <= 0) errors.amount = '收款金额必须大于 0'; if (amount > invoice.balance) errors.amount = `收款金额不可超过余额 ${money(invoice.balance)}`; if (!values.paymentNo) errors.paymentNo = '平台收款号不能为空'; if (!values.tradeNo) errors.tradeNo = '交易流水号不能为空'; if (!new FormData(form).get('confirm')) errors.confirm = '请先确认已核对银行流水'; return Object.keys(errors).length ? errors : null; }, apply: () => { invoice.paid += amount; invoice.balance -= amount; invoice.status = invoice.balance === 0 ? '已支付' : '部分支付'; const tenant = tenantById(invoice.tenantId); tenant.balance = Math.max(0, tenant.balance - amount); tenant.billing = tenant.balance === 0 ? '正常' : '待付款'; if (tenant.status === '暂停' && tenant.balance === 0) tenant.status = '正常'; addAudit('invoice.payment.confirm', 'invoice', invoice.id, invoice.id, '高', `人工确认收款 ${money(amount)},交易流水仅保存脱敏摘要。`); }, success: '收款已确认,账单与租户余额已更新' });
|
||||
const invoice = invoiceById(state.modalData.invoiceId); const values = Object.fromEntries(new FormData(form)); const payment = paymentForInvoice(invoice); const amount = payment?.amount || invoice.balance;
|
||||
simulateWrite({ key: 'payment', permission: 'platform:billing:payment', operationId: 'PlatformAdminBillingController_confirmPayment', body: () => ({ ...values, paymentId: payment?.id, amount }), validate: () => { const errors = {}; if (platformApi?.isEnabled() && !payment) errors.paymentId = '该账单没有可确认的待支付记录'; if (!amount || amount <= 0) errors.amount = '收款金额必须大于 0'; if (amount > invoice.balance) errors.amount = `收款金额不可超过余额 ${money(invoice.balance)}`; if (!values.tradeNo) errors.tradeNo = '交易流水号不能为空'; if (!new FormData(form).get('confirm')) errors.confirm = '请先确认已核对银行流水'; return Object.keys(errors).length ? errors : null; }, apply: () => { invoice.paid += amount; invoice.balance -= amount; invoice.status = invoice.balance === 0 ? '已支付' : '部分支付'; if (payment) payment.status = 'succeeded'; const tenant = tenantById(invoice.tenantId); tenant.balance = Math.max(0, tenant.balance - amount); tenant.billing = tenant.balance === 0 ? '正常' : '待付款'; if (tenant.status === '暂停' && tenant.balance === 0) tenant.status = '正常'; addAudit('invoice.payment.confirm', 'invoice', invoice.id, invoice.id, '高', `人工确认收款 ${money(amount)},交易流水仅保存脱敏摘要。`); }, success: '收款已确认,账单与租户余额已更新' });
|
||||
}
|
||||
if (form.id === 'usageForm') {
|
||||
const values = Object.fromEntries(new FormData(form));
|
||||
@@ -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 === 'grantForm') {
|
||||
if (form.id === 'bankForm') {
|
||||
const values = Object.fromEntries(new FormData(form));
|
||||
simulateWrite({ key: 'grant', permission: 'platform:question_bank:grant', operationId: 'PlatformAdminQuestionBanksController_upsertGrant', body: () => ({ ...values, localEdit: Boolean(new FormData(form).get('localEdit')) }), validate: () => { const errors = {}; const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const splitScope = value => String(value || '').split(/[、,,]/).map(item => item.trim()).filter(Boolean); if (values.scope === '指定套餐' && !values.planCode) errors.planCode = '指定套餐范围必须选择套餐'; if (values.scope === '指定租户' && !values.tenantId) errors.tenantId = '指定租户范围必须选择租户'; if (values.scope === '套餐 + 租户' && (!values.planCode || !values.tenantId)) errors.planCode = '混合范围必须同时选择套餐与租户'; if (platformApi?.isEnabled() && splitScope(values.regionIds).some(value => !uuid.test(value))) errors.regionIds = '地区范围必须为 UUID,多个值用逗号分隔'; if (platformApi?.isEnabled() && splitScope(values.subjectIds).some(value => !uuid.test(value))) errors.subjectIds = '科目范围必须为 UUID,多个值用逗号分隔'; if (values.expiresAt && values.expiresAt <= values.startsAt) errors.expiresAt = '到期日期必须晚于开始日期'; return Object.keys(errors).length ? errors : null; }, apply: () => { const existing = data.grants.find(item => item.id === values.id); const splitScope = value => String(value || '').split(/[、,,]/).map(item => item.trim()).filter(Boolean); const next = { id: values.id || entityId('GRANT'), bankId: values.bankId, scope: values.scope, allowedPlanCodes: values.planCode ? [values.planCode] : [], allowedTenantIds: values.tenantId ? [values.tenantId] : [], allowedRegionIds: splitScope(values.regionIds), allowedSubjectIds: splitScope(values.subjectIds), status: existing?.status || '启用', startsAt: values.startsAt, expiresAt: values.expiresAt, localEdit: Boolean(new FormData(form).get('localEdit')) }; if (existing) Object.assign(existing, next); else data.grants.unshift(next); addAudit('bank.grant.update', 'grant', next.id, bankById(next.bankId).name, '高', `保存授权范围 ${next.scope};地区 ${next.allowedRegionIds.join('、') || '不限'};科目 ${next.allowedSubjectIds.join('、') || '不限'}。`); }, success: '公共题库授权范围已保存' });
|
||||
simulateWrite({ key: 'question-bank', permission: 'platform:question-bank:manage', operationId: 'PlatformQuestionBanksController_upsertBank', body: () => ({ id: values.id || null, name: values.name, metadata: values.version ? { version: values.version } : {} }), validate: () => !values.name?.trim() ? { name: '题库名称不能为空' } : null, apply: payload => { const item = payload ? platformApi.adapters.bank(payload, bankById(payload.id)) : { id: values.id || entityId('QB'), name: values.name, version: values.version || '-', status: '正常', source: '平台自建', catalog: '平台公共题库', nodes: 0, questions: 0 }; const existing = bankById(item.id); if (existing) Object.assign(existing, item); else data.banks.unshift(item); state.selectedBank = item.id; state.selectedBankNode = ''; return item; }, success: '公共题库已保存' });
|
||||
}
|
||||
if (form.id === 'bankNodeForm') {
|
||||
const values = Object.fromEntries(new FormData(form));
|
||||
simulateWrite({ key: 'question-bank-node', permission: 'platform:question-bank:manage', operationId: 'PlatformQuestionBanksController_upsertNode', body: () => ({ id: values.id || null, questionBankId: state.selectedBank, parentId: values.parentId || null, nodeKey: null, name: values.name, nodeType: values.nodeType, sortOrder: Number(values.sortOrder || 0), isSelectable: true, metadata: {} }), validate: () => !values.name?.trim() ? { name: '节点名称不能为空' } : null, apply: payload => { const item = payload ? adaptBankNode(payload) : { id: values.id || entityId('NODE'), questionBankId: state.selectedBank, parentId: values.parentId || null, name: values.name, nodeType: values.nodeType, sortOrder: Number(values.sortOrder || 0), depth: values.parentId ? Number(bankNodeById(values.parentId)?.depth || 0) + 1 : 0, isActive: true, questionCount: 0 }; const existing = bankNodeById(item.id); if (existing) Object.assign(existing, item); else data.bankNodes.push(item); state.selectedBankNode = item.id; return item; }, success: '内容节点已保存' });
|
||||
}
|
||||
if (form.id === 'bankNodeBatchForm') {
|
||||
const values = Object.fromEntries(new FormData(form)); const names = String(values.names || '').split(/\r?\n/).map(item => item.trim()).filter(Boolean);
|
||||
simulateWrite({ key: 'question-bank-node-batch', permission: 'platform:question-bank:manage', operationId: 'PlatformQuestionBanksController_batchCreateNodes', body: () => ({ questionBankId: state.selectedBank, parentId: values.parentId || null, nodeType: values.nodeType, names }), validate: () => !names.length ? { names: '至少填写一个章节或试卷名称' } : names.length > 100 ? { names: '一次最多创建 100 个节点' } : null, apply: payload => { const items = apiItems(payload); if (items.length) data.bankNodes.push(...items.map(adaptBankNode)); return items.length || names.length; }, success: count => `已创建 ${count} 个内容节点` });
|
||||
}
|
||||
if (form.id === 'bankQuestionForm') {
|
||||
const values = Object.fromEntries(new FormData(form)); const options = parseJsonText(values.options, []); const tags = String(values.tags || '').split(/[、,,]/).map(item => item.trim()).filter(Boolean);
|
||||
simulateWrite({ key: 'question-bank-question', permission: 'platform:question-bank:manage', operationId: 'PlatformQuestionBanksController_upsertQuestion', body: () => ({ id: values.id || null, questionBankId: state.selectedBank, contentNodeId: values.contentNodeId, legacyId: values.legacyId || null, type: values.type, typeLabel: questionTypeLabel(values.type), difficulty: Number(values.difficulty || 3), tags, content: values.content, options, correctOptionIndex: null, correctOptionIndices: [], answerText: values.answerText || null, explanation: values.explanation || null, subQuestions: [], codeLang: null, codeTemplate: null, mediaUrl: values.mediaUrl || null, status: 'published', examMarkers: {}, sourceHash: null }), validate: () => { const errors = {}; if (!values.contentNodeId) errors.contentNodeId = '请选择章节或试卷'; if (!values.content?.trim()) errors.content = '题干不能为空'; if (options === null || !Array.isArray(options)) errors.options = '选项必须是 JSON 数组'; return Object.keys(errors).length ? errors : null; }, apply: payload => { const item = payload ? adaptBankQuestion(payload) : { ...values, id: values.id || entityId('Q'), questionBankId: state.selectedBank, contentNodeId: values.contentNodeId, options, tags, versionNo: 1, status: 'published', statusLabel: '已发布' }; const existing = data.bankQuestions.find(row => row.id === item.id); if (existing) Object.assign(existing, item); else data.bankQuestions.unshift(item); return item; }, success: '题目已保存并生成新版本', close: false });
|
||||
}
|
||||
if (form.id === 'bankBatchImportForm' || form.id === 'bankStructuredImportForm') {
|
||||
const values = Object.fromEntries(new FormData(form)); const payload = parseJsonText(values.payload, null); const execute = event.submitter?.dataset.importMode === 'execute'; const format = form.id === 'bankStructuredImportForm' ? 'structured-v2' : 'simple';
|
||||
simulateWrite({ key: `question-bank-import-${format}`, permission: 'platform:question-bank:manage', operationId: execute ? 'PlatformQuestionBanksController_executeImport' : 'PlatformQuestionBanksController_previewImport', body: () => ({ questionBankId: state.selectedBank, contentNodeId: values.contentNodeId || null, format, sourceName: values.sourceName || (format === 'structured-v2' ? '结构化题库文件' : '普通题目批量导入'), payload }), validate: () => { const errors = {}; if (format === 'simple' && !values.contentNodeId) errors.contentNodeId = '普通批量导入必须选择章节或试卷'; if (payload === null) errors.payload = '请输入有效的 JSON'; return Object.keys(errors).length ? errors : null; }, apply: result => { data.bankImportResult = result || { detail: { job: { dryRun: !execute, totalCount: Array.isArray(payload) ? payload.length : 0, validCount: Array.isArray(payload) ? payload.length : 0, errorCount: 0 }, issues: [] }, insertedQuestionCount: execute && Array.isArray(payload) ? payload.length : 0, updatedQuestionCount: 0, skippedQuestionCount: 0, createdNodeCount: 0 }; return data.bankImportResult; }, success: execute ? '题库导入已执行' : '导入预检已完成', close: false });
|
||||
}
|
||||
if (form.id === 'crmConfigForm') {
|
||||
const values = Object.fromEntries(new FormData(form));
|
||||
@@ -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 permissions = new FormData(form).getAll('permissions');
|
||||
simulateWrite({ key: 'staff', permission: 'platform:staff:write', operationId: 'PlatformAdminOverviewController_upsertStaff', body: () => ({ ...values, permissions }), validate: () => { const errors = {}; ['authUserId', 'username', 'name', 'email'].forEach(name => { if (!values[name]) errors[name] = `${name} 不能为空`; }); if (platformApi?.isEnabled() && values.authUserId && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(values.authUserId)) errors.authUserId = '真实模式必须填写已存在的 Supabase Auth 用户 UUID'; if (!platformApi?.isEnabled() && values.authUserId && !values.authUserId.startsWith('auth-')) errors.authUserId = 'Mock Auth 用户不存在,请使用 auth- 前缀'; if (!permissions.length) errors.permissions = '至少授予一项平台权限'; return Object.keys(errors).length ? errors : null; }, apply: () => { const existing = data.staff.find(item => item.id === values.id); const next = { id: values.id || entityId('STAFF'), authUserId: values.authUserId, username: values.username, name: values.name, email: values.email, phone: values.phone || '-', role: values.role, permissions, status: existing?.status || '正常', lastActive: existing?.lastActive || '尚未登录' }; if (existing) Object.assign(existing, next); else data.staff.unshift(next); addAudit('staff.upsert', 'staff', next.id, next.name, '高', `保存平台员工及 ${permissions.length} 项有效权限。`); }, success: '平台员工与有效权限已保存' });
|
||||
const values = Object.fromEntries(new FormData(form)); const roleIds = new FormData(form).getAll('roleIds');
|
||||
simulateWrite({ key: 'staff', permission: 'platform:staff:write', operationId: 'PlatformAdminOverviewController_upsertStaff', body: () => ({ ...values, roleIds }), validate: () => { const errors = {}; if (!values.name) errors.name = '姓名不能为空'; if (!values.email) errors.email = '邮箱不能为空'; if (values.phone && !/^1\d{10}$/.test(values.phone)) errors.phone = '请输入 11 位手机号'; if (!roleIds.length) errors.roleIds = '至少选择一个平台角色'; return Object.keys(errors).length ? errors : null; }, apply: payload => { const existing = data.staff.find(item => item.id === values.userId); const assignedRoles = data.platformRoles.filter(role => roleIds.includes(role.id)); const fallback = { id: values.userId || entityId('STAFF'), userId: values.userId || '', name: values.name, email: values.email, phone: values.phone || existing?.phone || '-', roleIds, roleCodes: assignedRoles.map(role => role.code), role: assignedRoles.map(role => role.name).join('、'), permissions: [...new Set(assignedRoles.flatMap(role => role.permissionCodes))], status: existing?.status || '正常', lastActive: existing?.lastActive || '尚未登录' }; const next = payload ? { ...fallback, ...platformApi.adapters.staff(payload), roleIds, roleCodes: fallback.roleCodes, role: fallback.role, permissions: fallback.permissions } : fallback; if (existing) Object.assign(existing, next); else data.staff.unshift(next); addAudit('staff.upsert', 'staff', next.id, next.name, '高', `保存平台员工及 ${assignedRoles.length} 个角色。`); }, success: '平台员工与角色已保存' });
|
||||
}
|
||||
if (form.id === 'auditChannelForm') {
|
||||
const values = Object.fromEntries(new FormData(form));
|
||||
@@ -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_upsertChannel', { ...channel, enabled: nextEnabled }, apply); else apply(); return; }
|
||||
if (action === 'new-grant') { if (requireDesktop('新建公共题库授权')) return; openModal('grant'); return; }
|
||||
if (action === 'apply-bank-search') { runContractRead({ key: 'bank-query', actionId: 'act.p-09.01', operationId: 'PlatformAdminQuestionBanksController_banks', title: '题库搜索', suppliedRequestId: actionElement.dataset.contractRequestId, liveRequest: () => platformApi.request('PlatformAdminQuestionBanksController_banks', { query: { q: state.bankSearch || undefined, status: 'active', includeTenantBanks: state.bankIncludeTenant, limit: 200 } }), apply: payload => { 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-grant') { if (requireDesktop('编辑公共题库授权')) return; openModal('grant', { grantId: actionElement.dataset.grantId }); return; }
|
||||
if (action === 'toggle-grant') { if (requireDesktop('变更公共题库授权状态')) return; if (!canWrite('platform:question_bank:grant')) return; const grant = data.grants.find(item => item.id === actionElement.dataset.grantId); if (!grant) return; const next = grant.status === '启用' ? '已禁用' : '启用'; const apply = () => { grant.status = next; addAudit('bank.grant.status', 'grant', grant.id, bankById(grant.bankId)?.name || grant.bankId, '高', `授权规则状态更新为 ${next},租户快照保留。`); saveData(); render(); showToast('授权状态已更新'); }; const execute = () => platformApi?.isEnabled() ? executeBackendMutation('PlatformAdminQuestionBanksController_upsertGrant', { ...grant, status: next }, apply) : apply(); if (next === '已禁用') confirmAction({ title: '禁用公共题库授权', description: `${bankById(grant.bankId)?.name || grant.bankId} · ${grant.id}`, impact: '后续同步将停止,但租户已经采用的内容快照不会被删除。', confirmLabel: '确认禁用' }, execute); else execute(); return; }
|
||||
if (action === 'preview-bank-sync') { state.bankTab = 'sync'; if (platformApi?.isEnabled()) { runContractRead({ key: 'bank-sync', actionId: 'act.p-09.05', operationId: 'PlatformAdminQuestionBanksController_syncStatus', title: '采用同步状态', suppliedRequestId: actionElement.dataset.contractRequestId, liveRequest: () => platformApi.request('PlatformAdminQuestionBanksController_syncStatus', { query: { limit: 200 } }), apply: payload => { 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, execute: taskItem => { taskItem.rows = data.bankSync.length; taskItem.failedRows = data.bankSync.filter(item => item.status === '同步失败').length; } }); render(); showToast(`同步状态检查任务 ${task.id} 已创建`); return; }
|
||||
if (action === 'refresh-bank-sync') { if (state.modal === 'syncDetail') closeModal({ force: true }); runContractRead({ key: 'bank-sync', operationId: 'PlatformAdminQuestionBanksController_syncStatus', title: '采用同步状态', liveRequest: () => platformApi.request('PlatformAdminQuestionBanksController_syncStatus', { query: { limit: 200 } }), apply: payload => { 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('syncDetail', { 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_disableChannel', { channelId: channel.id, reason: '平台管理员停用催缴渠道' }, apply); return; } if (channel.endpointRedacted) { openModal('dunningChannel', { channelId: channel.id }); showToast('重新启用前请填写完整的 HTTPS 通知地址', 'error'); return; } executeBackendMutation('PlatformAdminDunningChannelsController_upsertChannel', { ...channel, enabled: true }, apply); return; }
|
||||
if (action === 'new-bank') { if (requireDesktop('新建公共题库')) return; openModal('bank'); return; }
|
||||
if (action === 'edit-bank') { if (requireDesktop('编辑公共题库')) return; openModal('bank', { bankId: state.selectedBank }); return; }
|
||||
if (action === 'archive-bank') { const bank = bankById(state.selectedBank); if (!bank) return; confirmAction({ title: '归档公共题库', description: bank.name, impact: '仅当题库中的题目和内容节点均已归档时才能归档,历史版本不会删除。', confirmLabel: '确认归档' }, () => executeBackendMutation('PlatformQuestionBanksController_archiveBank', {}, payload => { Object.assign(bank, payload ? platformApi.adapters.bank(payload, bank) : { status: '已归档' }); render(); showToast('公共题库已归档'); }, { refresh: false, pathParams: { bankId: bank.id } })); return; }
|
||||
if (action === 'new-bank-node') { if (requireDesktop('新建内容节点')) return; openModal('bankNode'); return; }
|
||||
if (action === 'edit-bank-node') { if (requireDesktop('编辑内容节点')) return; openModal('bankNode', { nodeId: state.selectedBankNode }); return; }
|
||||
if (action === 'batch-bank-nodes') { if (requireDesktop('批量创建章节或试卷')) return; openModal('batchBankNodes'); return; }
|
||||
if (action === 'shift-bank-node') { const node = bankNodeById(state.selectedBankNode); if (!node) return; const sortOrder = Number(node.sortOrder || 0) + Number(actionElement.dataset.sortDelta || 0); executeBackendMutation('PlatformQuestionBanksController_upsertNode', { id: node.id, questionBankId: node.questionBankId, parentId: node.parentId || null, nodeKey: node.nodeKey || null, name: node.name, nodeType: node.nodeType, sortOrder, isSelectable: node.isSelectable !== false, metadata: node.metadata || {} }, payload => { Object.assign(node, payload ? adaptBankNode(payload) : { sortOrder }); data.bankNodes.sort((left, right) => Number(left.sortOrder || 0) - Number(right.sortOrder || 0)); render(); showToast('内容节点顺序已保存'); }, { refresh: false }); return; }
|
||||
if (action === 'apply-bank-search') { runContractRead({ key: 'bank-query', actionId: 'act.p-09.01', operationId: 'PlatformQuestionBanksController_getBanks', title: '公共题库查询', suppliedRequestId: actionElement.dataset.contractRequestId, liveRequest: () => platformApi.request('PlatformQuestionBanksController_getBanks', { query: { keyword: state.bankSearch || undefined, status: 'all' } }), apply: payload => { const items = apiItems(payload); data.banks = items.map(item => platformApi.adapters.bank(item, bankById(item.id))); if (!bankById(state.selectedBank)) state.selectedBank = data.banks[0]?.id || ''; return `${data.banks.length} 个匹配题库`; } }); return; }
|
||||
if (action === 'load-bank-questions' || action === 'bank-question-page') { if (action === 'bank-question-page') state.bankQuestionPage = Number(actionElement.dataset.page || 1); const query = { questionBankId: state.selectedBank, contentNodeId: state.selectedBankNode || undefined, keyword: document.querySelector('#bankQuestionSearch')?.value || undefined, type: document.querySelector('#bankQuestionType')?.value || undefined, status: document.querySelector('#bankQuestionStatus')?.value || undefined, page: state.bankQuestionPage, pageSize: 20 }; if (!platformApi?.isEnabled()) { render(); return; } platformApi.request('PlatformQuestionBanksController_getQuestions', { query }).then(payload => { data.bankQuestions = apiItems(payload).map(adaptBankQuestion); state.bankQuestionTotal = Number(payload.total || data.bankQuestions.length); render(); }).catch(error => showToast(backendErrorText(error, '题目查询失败'), 'error')); return; }
|
||||
if (action === 'edit-bank-question') { const question = data.bankQuestions.find(item => item.id === actionElement.dataset.questionId); if (!question) return; state.bankTab = 'single'; state.selectedBankNode = question.contentNodeId; render(); requestAnimationFrame(() => { const form = document.querySelector('#bankQuestionForm'); if (!form) return; form.elements.id.value = question.id; form.elements.contentNodeId.value = question.contentNodeId; form.elements.type.value = question.type; form.elements.difficulty.value = question.difficulty || 3; form.elements.legacyId.value = question.legacyId || ''; form.elements.content.value = question.content || ''; form.elements.options.value = JSON.stringify(question.options || [], null, 2); form.elements.answerText.value = question.answerText || ''; form.elements.explanation.value = question.explanation || ''; form.elements.mediaUrl.value = question.mediaUrl || ''; form.elements.tags.value = Array.isArray(question.tags) ? question.tags.join('、') : ''; }); return; }
|
||||
if (action === 'archive-bank-question') { const questionId = actionElement.dataset.questionId; const apply = () => { const question = data.bankQuestions.find(item => item.id === questionId); if (question) { question.status = 'archived'; question.statusLabel = '已归档'; } render(); showToast('题目已归档'); }; confirmAction({ title: '归档题目', description: '归档后题目历史版本仍会保留。', impact: '该题目不会再进入公共题库正常查询结果。', confirmLabel: '确认归档' }, () => executeBackendMutation('PlatformQuestionBanksController_archiveQuestions', { questionIds: [questionId] }, apply, { refresh: false })); return; }
|
||||
if (action === 'archive-bank-node') { const node = bankNodeById(state.selectedBankNode); if (!node) return; confirmAction({ title: '归档内容节点', description: node.name, impact: '仅当节点没有启用的下级节点和未归档题目时才能归档。', confirmLabel: '确认归档' }, () => executeBackendMutation('PlatformQuestionBanksController_archiveNode', {}, () => { node.isActive = false; render(); showToast('内容节点已归档'); }, { refresh: false, pathParams: { nodeId: node.id } })); return; }
|
||||
if (action === 'upload-question-image') { const input = document.querySelector('#bankQuestionImage'); const file = input?.files?.[0]; if (!file) { showToast('请先选择题目图片', 'error'); return; } if (!platformApi?.isEnabled()) { document.querySelector('#bankQuestionForm').elements.mediaUrl.value = `https://static.example.test/questions/${encodeURIComponent(file.name)}`; showToast('图片地址已填入'); return; } actionElement.disabled = true; platformApi.request('PlatformQuestionBanksController_signAssetUpload', { body: { contentNodeId: state.selectedBankNode || null, fileName: file.name, mimeType: file.type || 'application/octet-stream', fileSizeBytes: file.size, title: file.name, metadata: { usage: 'question' } } }).then(async signed => { await fetch(signed.upload.url, { method: signed.upload.method || 'PUT', headers: signed.upload.headers || {}, body: file }); const confirmed = await platformApi.request('PlatformQuestionBanksController_confirmAssetUpload', { body: { assetId: signed.item.id, mimeType: file.type, fileSizeBytes: file.size } }); document.querySelector('#bankQuestionForm').elements.mediaUrl.value = confirmed.item.cdnUrl || String(signed.upload.url).split('?')[0]; markDirty('page'); showToast('题目图片已上传并填入地址'); }).catch(error => showToast(backendErrorText(error, '题目图片上传失败'), 'error')).finally(() => { actionElement.disabled = false; }); return; }
|
||||
if (action === 'preview-question') { const form = document.querySelector('#bankQuestionForm'); showToast(form?.elements.content.value?.trim() ? `题目预览:${form.elements.content.value.trim().slice(0, 80)}` : '请先填写题干', form?.elements.content.value?.trim() ? 'success' : 'error'); return; }
|
||||
if (action === 'new-crm-config' || action === 'edit-crm-config') { if (!canWrite('platform:crm:write')) return; openModal('crmConfig', { crmId: actionElement.dataset.crmId || '' }); return; }
|
||||
if (action === 'crm-rule-detail') { showToast('CRM 分配策略保存在接入配置的 assignmentConfig 中'); return; }
|
||||
if (action === 'toggle-crm-config') { if (!canWrite('platform:crm:write')) return; const config = data.crmConfigs.find(item => item.id === actionElement.dataset.crmId); if (!config) return; const nextEnabled = !config.enabled; const apply = payload => { const next = payload ? platformApi.adapters.crmConfig(payload) : { ...config, enabled: nextEnabled, status: nextEnabled ? '正常' : '未启用' }; Object.assign(config, next); addAudit('crm.config.status', 'crm_config', config.id, tenantById(config.tenantId)?.name || config.id, '中', `CRM 接入${config.enabled ? '启用' : '停用'}。`); saveData(); render(); showToast('CRM 接入状态已更新'); }; if (platformApi?.isEnabled()) executeBackendMutation('PlatformAdminCrmController_upsertConfig', { ...config, enabled: nextEnabled }, apply); else apply(); return; }
|
||||
if (action === 'retry-crm-lead') { if (!canWrite('platform:crm:write')) return; const lead = data.crmLeads.find(item => item.id === actionElement.dataset.leadId); if (!lead) return; const apply = payload => { const next = payload ? platformApi.adapters.crmLead(payload) : { ...lead, status: '重试中', attempts: lead.attempts + 1, lastError: '' }; Object.assign(lead, next); addAudit('crm.lead.retry', 'crm_lead', lead.id, lead.student, '中', `CRM 线索 ${lead.id} 已重试推送。`); saveData(); render(); showToast('线索已进入重试队列'); }; if (platformApi?.isEnabled()) executeBackendMutation('PlatformAdminCrmController_retryLead', lead, apply); else apply(); return; }
|
||||
if (action === 'new-sms-channel' || action === 'edit-sms-channel') { if (!canWrite('platform:sms:write')) return; openModal('smsChannel', { smsId: actionElement.dataset.smsId || '' }); return; }
|
||||
if (action === 'toggle-sms-channel') { if (!canWrite('platform:sms:write')) return; const channel = data.smsChannels.find(item => item.id === actionElement.dataset.smsId); if (!channel) return; const nextStatus = channel.enabled ? '未启用' : '正常'; const apply = payload => { const next = payload ? platformApi.adapters.smsChannel(payload) : { ...channel, enabled: nextStatus === '正常', status: nextStatus }; Object.assign(channel, next); addAudit('sms.channel.status', 'sms_channel', channel.id, tenantById(channel.tenantId)?.name || channel.id, '中', `短信渠道${channel.enabled ? '启用' : '停用'}。`); saveData(); render(); showToast('短信渠道状态已更新'); }; if (platformApi?.isEnabled()) 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 (disabling && person.role === '超级管理员' && data.staff.filter(item => item.role === '超级管理员' && item.status === '正常').length === 1) { showToast('不能禁用最后一名超级管理员', 'error'); return; } const apply = () => { person.status = disabling ? '已停用' : '正常'; addAudit('staff.status.update', 'staff', person.id, person.name, '高', `${disabling ? '停用并撤销会话' : '启用'}平台员工。`); saveData(); render(); showToast(`员工已${disabling ? '停用并撤销会话' : '启用'}`); }; const execute = () => platformApi?.isEnabled() ? executeBackendMutation('PlatformAdminOverviewController_updateStaffStatus', { staffId: person.id, status: disabling ? 'disabled' : 'active', reason: disabling ? '平台控制台停用员工' : '平台控制台启用员工', revokeSessions: disabling }, apply) : apply(); if (disabling) confirmAction({ title: '停用平台员工', description: `${person.name} · ${person.role}`, impact: '该员工的全部平台会话将立即撤销,后续无法登录平台端;租户端权限不在本域名内处理。', confirmLabel: '停用并撤销会话', confirmActionName: 'execute-staff-disable' }, execute); else execute(); return; }
|
||||
if (action === 'select-permission-group') { const form = overlayRoot.querySelector('#staffForm'); const role = fieldValue(form, 'role'); const recommended = role === '平台财务' ? ['platform:billing:read', 'platform:billing:write', 'platform:billing:payment', 'platform:billing:dunning', 'platform:billing:notification', 'platform:usage:read', 'platform:usage:write', '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 isSuperAdmin = person.roleCodes?.includes('platform_super_admin'); if (disabling && isSuperAdmin && data.staff.filter(item => item.roleCodes?.includes('platform_super_admin') && item.status === '正常').length === 1) { showToast('不能禁用最后一名超级管理员', 'error'); return; } const apply = () => { person.status = disabling ? '已停用' : '正常'; addAudit('staff.status.update', 'staff', person.id, person.name, '高', `${disabling ? '停用并撤销会话' : '启用'}平台员工。`); saveData(); render(); showToast(`员工已${disabling ? '停用并撤销会话' : '启用'}`); }; const execute = () => platformApi?.isEnabled() ? executeBackendMutation('PlatformAdminOverviewController_updateStaffStatus', { userId: person.userId || person.id, status: disabling ? 'disabled' : 'active', reason: disabling ? '平台控制台停用员工' : '平台控制台启用员工' }, apply) : apply(); if (disabling) confirmAction({ title: '停用平台员工', description: `${person.name} · ${person.role}`, impact: '该员工的全部平台会话将立即撤销,后续无法登录平台端;租户端权限不在本域名内处理。', confirmLabel: '停用并撤销会话', confirmActionName: 'execute-staff-disable' }, execute); else execute(); return; }
|
||||
if (action === 'select-permission-group') { const form = overlayRoot.querySelector('#staffForm'); const role = fieldValue(form, 'role'); const recommended = role === '平台财务' ? ['platform:billing:read', 'platform:billing:write', 'platform:billing:payment', 'platform:billing:dunning', 'platform:billing:notification', 'platform:usage:read', 'platform:usage:write', 'platform:payment:read', 'platform:payment:write'] : role === '安全审计' ? ['platform:audit:read', 'platform:audit:export', 'platform:audit:alert', 'platform:audit:notification'] : role === '题库运营' ? ['platform:dashboard:view', 'platform:question-bank:manage'] : role === '租户运营' ? ['platform:tenant:read', 'platform:tenant:write', 'platform:tenant:status', 'platform:tenant:billing_profile', 'platform:crm:read', 'platform:crm:write', 'platform:sms:read', 'platform:sms:write'] : Array.from(form.querySelectorAll('[name="permissions"]')).map(input => input.value); form.querySelectorAll('[name="permissions"]').forEach(input => { input.checked = recommended.includes(input.value); }); return; }
|
||||
if (action === 'clear-staff-form') { const form = overlayRoot.querySelector('#staffForm'); if (!form) return; form.querySelectorAll('input:not([type="hidden"]):not([type="checkbox"])').forEach(input => { input.value = ''; }); form.querySelectorAll('input[type="checkbox"]').forEach(input => { input.checked = false; }); state.modalData.dirty = true; form.querySelector('[name="name"]')?.focus(); return; }
|
||||
if (action === 'apply-staff-search') { render(); return; }
|
||||
if (action === 'export-audit') { if (!hasPermission('platform:audit:export')) { showToast('缺少导出权限:platform:audit:export', 'error'); return; } const format = actionElement.dataset.format || 'csv'; if (platformApi?.isEnabled()) { platformApi.request('PlatformAdminAuditController_exportLogs', { query: { format, q: state.auditSearch || undefined, limit: 500 } }).then(payload => { const item = platformApi.downloadExport(payload); showToast(`审计文件已下载 · ${item.rowCount ?? item.rows ?? 0} 行 · ${item.sha256 || item.hash || 'hash 见响应'}`); }).catch(error => showToast(backendErrorText(error, '审计导出失败'), 'error')); return; } const task = addTask(`导出平台审计日志 ${format.toUpperCase()}`, '任务中心下载文件', taskItem => { taskItem.file = `platform-audit-${Date.now()}.${format}`; taskItem.hash = `sha256:${Math.random().toString(36).slice(2).padEnd(32, '0')}`; taskItem.rows = data.auditLogs.length; }); showToast(`导出任务 ${task.id} 已创建`); return; }
|
||||
if (action === 'jump-related-alert') { state.alertStatus = 'all'; state.selectedAlert = data.alerts.find(alert => data.auditLogs.some(log => log.targetId === alert.targetId || log.targetId === alert.id))?.id || ''; navigate('alerts'); if (state.selectedAlert) openModal('alertDetail', { alertId: state.selectedAlert }); return; }
|
||||
if (action === 'jump-audit-target') { const log = data.auditLogs.find(item => item.id === actionElement.dataset.auditId); closeModal(); if (log.targetType === 'tenant') { state.selectedTenant = log.targetId; navigate('tenantDetail'); } else if (log.targetType === 'invoice') { state.selectedInvoice = log.targetId; navigate('billing'); } else if (log.targetType === 'question_bank' || 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.adapters.event(payload, 'dunning')); closeModal({ force: true }); render(); showToast('催缴通知事件已重新标记待发送'); }).catch(error => showToast(backendErrorText(error, '催缴通知重试失败'), 'error')); return; } notification.status = '重试中'; notification.attempts += 1; notification.updatedAt = `${todayTime()} CST`; saveData(); render(); setTimeout(() => { notification.status = '成功'; notification.error = ''; saveData(); render(); showToast('通知重试成功'); }, 800); return; }
|
||||
if (action === 'retry-task') { const task = data.tasks.find(item => item.id === actionElement.dataset.taskId); task.status = 'processing'; task.error = ''; task.updatedAt = todayTime(); saveData(); renderOverlay(); setTimeout(() => { task.status = 'completed'; task.updatedAt = todayTime(); saveData(); renderOverlay(); updateCounters(); showToast('任务重试完成'); }, 900); return; }
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
<link rel="icon" href="./assets/logo.png" />
|
||||
<link rel="stylesheet" href="./styles.css?v=20260728-api2" />
|
||||
<script src="https://unpkg.com/lucide@0.468.0/dist/umd/lucide.min.js" defer onerror="document.documentElement.classList.add('icons-unavailable')"></script>
|
||||
<script src="./runtime-config.js?v=20260728-api2" defer></script>
|
||||
<script src="./platform-auth.js?v=20260728-api2" defer></script>
|
||||
<script src="./spec-contract.js?v=20260728-api2" defer></script>
|
||||
<script src="./platform-api.js?v=20260728-api2" defer></script>
|
||||
<script src="./app.js?v=20260728-api2" defer></script>
|
||||
<script src="./runtime-config.js?v=20260730-question-bank1" defer></script>
|
||||
<script src="./platform-auth.js?v=20260730-question-bank1" defer></script>
|
||||
<script src="./spec-contract.js?v=20260730-question-bank1" defer></script>
|
||||
<script src="./platform-api.js?v=20260730-question-bank1" defer></script>
|
||||
<script src="./app.js?v=20260730-question-bank1" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="platformAuthGate" class="platform-auth-gate" hidden></div>
|
||||
|
||||
@@ -52,9 +52,10 @@
|
||||
return String(token || '').trim();
|
||||
}
|
||||
|
||||
function buildUrl(path, query = {}) {
|
||||
function buildUrl(path, query = {}, pathParams = {}) {
|
||||
const baseUrl = normalizeBaseUrl(runtime.apiBaseUrl);
|
||||
const url = new URL(`${baseUrl}${path.startsWith('/') ? path : `/${path}`}`, window.location.href);
|
||||
const resolvedPath = Object.entries(pathParams).reduce((value, [key, replacement]) => value.replace(`{${key}}`, encodeURIComponent(String(replacement))), path);
|
||||
const url = new URL(`${baseUrl}${resolvedPath.startsWith('/') ? resolvedPath : `/${resolvedPath}`}`, window.location.href);
|
||||
Object.entries(query).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null || value === '') return;
|
||||
url.searchParams.set(key, Array.isArray(value) ? value.join(',') : String(value));
|
||||
@@ -124,7 +125,7 @@
|
||||
else options.signal.addEventListener('abort', () => controller.abort(), { once: true });
|
||||
}
|
||||
try {
|
||||
const response = await fetch(buildUrl(operation.path, options.query), {
|
||||
const response = await fetch(buildUrl(operation.path, options.query, options.pathParams), {
|
||||
method: operation.method,
|
||||
headers: {
|
||||
...extraHeaders,
|
||||
@@ -196,9 +197,6 @@
|
||||
invoiceTypeFromApi: { subscription: '订阅服务费', usage: '用量超额费', usage_overage: '用量超额费', manual: '人工账单', adjustment: '人工调整' },
|
||||
severityToApi: { 低: 'low', 中: 'medium', 高: 'high', 严重: 'critical' },
|
||||
severityFromApi: { low: '低', medium: '中', high: '高', critical: '严重' },
|
||||
grantScopeToApi: { 全部活跃租户: 'all_active_tenants', 指定套餐: 'plans', 指定租户: 'tenants', '套餐 + 租户': 'mixed' },
|
||||
grantScopeFromApi: { all_active_tenants: '全部活跃租户', plans: '指定套餐', tenants: '指定租户', mixed: '套餐 + 租户' },
|
||||
grantStatusFromApi: { active: '启用', disabled: '已禁用', expired: '已过期' },
|
||||
providerToApi: { Webhook: 'generic', SMTP: 'generic', 邮件: 'generic', 短信: 'generic', 钉钉: 'dingtalk', 飞书: 'feishu', 企微: 'wecom' },
|
||||
providerFromApi: { generic: 'Webhook', dingtalk: '钉钉', feishu: '飞书', wecom: '企微' },
|
||||
eventStatusFromApi: { pending: '待处理', processing: '发送中', sent: '成功', retrying: '重试中', failed: '失败', discarded: '已丢弃' },
|
||||
@@ -211,6 +209,28 @@
|
||||
paymentStatusToApi: { 正常: 'active', 未启用: 'disabled', 测试中: 'testing' },
|
||||
};
|
||||
|
||||
const platformPermissionLabels = {
|
||||
'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': '管理平台支付设置',
|
||||
};
|
||||
|
||||
const platformRoleLabels = {
|
||||
platform_super_admin: { name: '超级管理员', description: '拥有全部平台管理权限' },
|
||||
};
|
||||
|
||||
function mapValue(map, value, fallback = value) {
|
||||
return Object.prototype.hasOwnProperty.call(map, value) ? map[value] : fallback;
|
||||
}
|
||||
@@ -277,18 +297,39 @@
|
||||
}
|
||||
|
||||
const bodyBuilders = {
|
||||
PlatformSaasController_upsertFeature(input) {
|
||||
return { id: input.id || undefined, code: input.code, name: input.name, category: input.category, description: input.description || undefined, referencePriceCents: yuanToCents(input.referencePrice), currency: 'CNY', status: input.status || 'active', sortOrder: Number(input.sortOrder || 100) };
|
||||
},
|
||||
PlatformSaasController_upsertOffering(input) {
|
||||
return { id: input.id || undefined, code: input.code, name: input.name, type: input.type || 'base_plan', status: input.status || 'active', description: input.description || undefined, sortOrder: Number(input.sortOrder || 100) };
|
||||
},
|
||||
PlatformSaasController_upsertFeatureLimit(input) {
|
||||
return { id: input.id || undefined, metricCode: input.metricCode, featureCode: input.featureCode, name: input.name, unit: input.unit || 'count', kind: input.kind || 'current', warningPercent: Number(input.warningPercent || 80), isHardLimit: input.isHardLimit !== false };
|
||||
},
|
||||
PlatformSaasController_upsertVersion(input) {
|
||||
return { id: input.id || undefined, offeringId: input.offeringId, billingCycle: input.billingCycle || 'yearly', originalAmountCents: yuanToCents(input.originalAmount), amountCents: yuanToCents(input.amount), currency: 'CNY', effectiveAt: input.effectiveAt ? new Date(input.effectiveAt).toISOString() : undefined, featureCodes: splitList(input.featureCodes), limits: {}, metadata: {} };
|
||||
},
|
||||
PlatformBackofficeController_upsertRole(input) {
|
||||
return { id: input.id || undefined, code: input.code, name: input.name, status: input.status || 'active', description: input.description || undefined };
|
||||
},
|
||||
PlatformBackofficeController_replaceRoleBindings(input) {
|
||||
return { permissionCodes: input.permissionCodes || [], menuCodes: input.menuCodes || [] };
|
||||
},
|
||||
PlatformSaasController_upsertTenantFeatureOverride(input) {
|
||||
return { tenantId: input.tenantId, featureCode: input.featureCode, mode: input.mode, expiresAt: input.expiresAt ? new Date(input.expiresAt).toISOString() : undefined, reason: input.reason };
|
||||
},
|
||||
PlatformAdminTenantsController_create(input) {
|
||||
return {
|
||||
slug: input.slug,
|
||||
name: input.name,
|
||||
legalName: input.legalName || undefined,
|
||||
brandName: input.brandName || undefined,
|
||||
shortName: input.shortName || undefined,
|
||||
primaryHost: input.primaryHost || undefined,
|
||||
planCode: input.planCode || undefined,
|
||||
status: mapValue(dictionaries.tenantStatusToApi, input.status, input.status),
|
||||
billingStatus: mapValue(dictionaries.billingStatusToApi, input.billingStatus, input.billingStatus),
|
||||
amountCents: yuanToCents(input.amount),
|
||||
metadata: input.metadata || {},
|
||||
ownerEmail: input.ownerEmail || undefined,
|
||||
ownerPhone: input.ownerPhone || undefined,
|
||||
ownerName: input.ownerName,
|
||||
temporaryPassword: input.temporaryPassword,
|
||||
};
|
||||
},
|
||||
PlatformAdminTenantsController_status(input) {
|
||||
@@ -399,52 +440,28 @@
|
||||
metadata: { source: 'platform_console_manual' },
|
||||
};
|
||||
},
|
||||
PlatformAdminQuestionBanksController_upsertGrant(input) {
|
||||
return {
|
||||
id: input.id || undefined,
|
||||
sourceQuestionBankId: input.bankId || input.sourceQuestionBankId,
|
||||
grantScope: mapValue(dictionaries.grantScopeToApi, input.scope, input.grantScope),
|
||||
allowedPlanCodes: input.allowedPlanCodes || (input.planCode ? [input.planCode] : []),
|
||||
allowedTenantIds: input.allowedTenantIds || (input.tenantId ? [input.tenantId] : []),
|
||||
allowedRegionIds: input.allowedRegionIds || splitList(input.regionIds),
|
||||
allowedSubjectIds: input.allowedSubjectIds || splitList(input.subjectIds),
|
||||
status: input.status === '已禁用' || input.status === 'disabled'
|
||||
? 'disabled'
|
||||
: input.status === '已过期' || input.status === 'expired'
|
||||
? 'expired'
|
||||
: 'active',
|
||||
startsAt: isoStartOfDay(input.startsAt),
|
||||
expiresAt: isoStartOfDay(input.expiresAt),
|
||||
metadata: { localEdit: Boolean(input.localEdit) },
|
||||
};
|
||||
},
|
||||
PlatformAdminOverviewController_upsertStaff(input) {
|
||||
const permissionList = input.permissions || [];
|
||||
return {
|
||||
id: input.id || undefined,
|
||||
authUserId: input.authUserId,
|
||||
username: input.username,
|
||||
userId: input.userId || undefined,
|
||||
email: input.email || undefined,
|
||||
phone: input.phone || undefined,
|
||||
name: input.name,
|
||||
status: input.status === '已停用' ? 'disabled' : 'active',
|
||||
platformPermissions: Object.fromEntries(permissionList.map(permission => [permission, true])),
|
||||
metadata: { displayRole: input.role || '平台管理员' },
|
||||
roleIds: input.roleIds || [],
|
||||
};
|
||||
},
|
||||
PlatformAdminOverviewController_updateStaffStatus(input) {
|
||||
return {
|
||||
staffId: input.staffId,
|
||||
userId: input.userId || input.staffId,
|
||||
status: input.status === '正常' || input.status === 'active' ? 'active' : 'disabled',
|
||||
reason: input.reason || undefined,
|
||||
revokeSessions: input.revokeSessions !== false,
|
||||
};
|
||||
},
|
||||
PlatformAdminAuditController_updateAlert(input) {
|
||||
return {
|
||||
alertId: input.alertId,
|
||||
status: input.status,
|
||||
note: input.note || undefined,
|
||||
resolutionNote: input.resolutionNote || input.note || undefined,
|
||||
};
|
||||
},
|
||||
PlatformAdminCrmController_upsertConfig(input) {
|
||||
@@ -536,6 +553,74 @@
|
||||
metadata: { source: 'platform_console' },
|
||||
};
|
||||
},
|
||||
PlatformQuestionBanksController_upsertBank(input) {
|
||||
return {
|
||||
id: input.id || undefined,
|
||||
name: input.name,
|
||||
metadata: input.metadata || {},
|
||||
};
|
||||
},
|
||||
PlatformQuestionBanksController_upsertNode(input) {
|
||||
return {
|
||||
id: input.id || undefined,
|
||||
questionBankId: input.questionBankId,
|
||||
parentId: input.parentId || undefined,
|
||||
nodeKey: input.nodeKey || undefined,
|
||||
name: input.name,
|
||||
nodeType: input.nodeType,
|
||||
sortOrder: Number(input.sortOrder || 0),
|
||||
isSelectable: input.isSelectable !== false,
|
||||
metadata: input.metadata || {},
|
||||
};
|
||||
},
|
||||
PlatformQuestionBanksController_batchCreateNodes(input) {
|
||||
return {
|
||||
questionBankId: input.questionBankId,
|
||||
parentId: input.parentId || undefined,
|
||||
nodeType: input.nodeType,
|
||||
names: Array.isArray(input.names) ? input.names : splitList(input.names),
|
||||
};
|
||||
},
|
||||
PlatformQuestionBanksController_upsertQuestion(input) {
|
||||
return {
|
||||
id: input.id || undefined,
|
||||
questionBankId: input.questionBankId,
|
||||
contentNodeId: input.contentNodeId,
|
||||
legacyId: input.legacyId || undefined,
|
||||
type: input.type,
|
||||
typeLabel: input.typeLabel || undefined,
|
||||
difficulty: input.difficulty == null ? undefined : Number(input.difficulty),
|
||||
tags: input.tags || [],
|
||||
content: input.content,
|
||||
options: input.options || [],
|
||||
correctOptionIndex: input.correctOptionIndex ?? undefined,
|
||||
correctOptionIndices: input.correctOptionIndices || [],
|
||||
answerText: input.answerText || undefined,
|
||||
explanation: input.explanation || undefined,
|
||||
subQuestions: input.subQuestions || [],
|
||||
codeLang: input.codeLang || undefined,
|
||||
codeTemplate: input.codeTemplate || undefined,
|
||||
mediaUrl: input.mediaUrl || undefined,
|
||||
status: input.status || 'published',
|
||||
examMarkers: input.examMarkers || {},
|
||||
sourceHash: input.sourceHash || undefined,
|
||||
};
|
||||
},
|
||||
PlatformQuestionBanksController_archiveQuestions(input) {
|
||||
return { questionIds: input.questionIds || [] };
|
||||
},
|
||||
PlatformQuestionBanksController_previewImport(input) {
|
||||
return {
|
||||
questionBankId: input.questionBankId,
|
||||
contentNodeId: input.contentNodeId || undefined,
|
||||
format: input.format,
|
||||
sourceName: input.sourceName || undefined,
|
||||
payload: input.payload,
|
||||
};
|
||||
},
|
||||
PlatformQuestionBanksController_executeImport(input) {
|
||||
return bodyBuilders.PlatformQuestionBanksController_previewImport(input);
|
||||
},
|
||||
};
|
||||
|
||||
function notificationBody(input) {
|
||||
@@ -699,6 +784,7 @@
|
||||
return {
|
||||
id: item.invoiceNo || item.id,
|
||||
backendId: item.id,
|
||||
orderId: item.orderId || '',
|
||||
tenantId: item.tenantId,
|
||||
type: mapValue(dictionaries.invoiceTypeFromApi, item.invoiceType, item.invoiceType || '-'),
|
||||
period: item.billingPeriodStart ? monthOnly(item.billingPeriodStart) : monthOnly(item.issuedAt || item.dueDate),
|
||||
@@ -710,6 +796,18 @@
|
||||
note: item.note || '',
|
||||
};
|
||||
},
|
||||
billingPayment(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
orderId: item.orderId,
|
||||
tenantId: item.tenantId,
|
||||
paymentNo: item.paymentNo || '',
|
||||
status: lowerEnum(item.status),
|
||||
amount: centsToYuan(item.amountCents),
|
||||
providerTradeNo: item.providerTradeNo || '',
|
||||
paidAt: item.paidAt || '',
|
||||
};
|
||||
},
|
||||
reminder(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
@@ -728,52 +826,26 @@
|
||||
...current,
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
catalog: item.regionName || item.tenantName || '公共题库',
|
||||
source: item.sourceScope === 'platform' ? '平台公共题库' : '租户自建',
|
||||
contentEntryId: item.contentEntryId || '',
|
||||
catalog: '平台公共题库',
|
||||
source: '平台自建',
|
||||
nodes: Number(item.nodeCount || 0),
|
||||
questions: Number(item.questionCount || 0),
|
||||
version: item.metadata?.version || item.metadata?.contentVersion || '-',
|
||||
status: item.status === 'active' ? '稳定版' : item.status || '-',
|
||||
};
|
||||
},
|
||||
grant(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
bankId: item.sourceQuestionBankId,
|
||||
scope: mapValue(dictionaries.grantScopeFromApi, item.grantScope, item.grantScope || '-'),
|
||||
allowedPlanCodes: item.allowedPlanCodes || [],
|
||||
allowedTenantIds: item.allowedTenantIds || [],
|
||||
allowedRegionIds: item.allowedRegionIds || [],
|
||||
allowedSubjectIds: item.allowedSubjectIds || [],
|
||||
status: mapValue(dictionaries.grantStatusFromApi, item.status, item.status || '-'),
|
||||
startsAt: dateOnly(item.startsAt),
|
||||
expiresAt: dateOnly(item.expiresAt),
|
||||
localEdit: Boolean(item.metadata?.localEdit),
|
||||
};
|
||||
},
|
||||
sync(item) {
|
||||
const progress = item.syncStatus === 'synced'
|
||||
? 100
|
||||
: Number(item.metadata?.lastSync?.progress || item.lastSyncCounts?.progress || 0);
|
||||
return {
|
||||
tenantId: item.tenantId,
|
||||
bankId: item.sourceQuestionBankId,
|
||||
current: item.metadata?.lastSync?.sourceVersion || '-',
|
||||
target: item.metadata?.lastSync?.targetVersion || '-',
|
||||
progress,
|
||||
conflicts: Number(item.conflictCount || 0),
|
||||
status: ({ pending: '同步中', synced: '已完成', failed: '同步失败' })[item.syncStatus] || item.syncStatus || item.status || '-',
|
||||
status: lowerEnum(item.status) === 'active' ? '正常' : lowerEnum(item.status) === 'archived' ? '已归档' : item.status || '-',
|
||||
};
|
||||
},
|
||||
staff(item) {
|
||||
const permissions = item.roleCodes?.includes('platform_super_admin') ? ['*'] : [];
|
||||
const roleCodes = item.roleCodes || [];
|
||||
const permissions = roleCodes.includes('platform_super_admin') ? ['*'] : [];
|
||||
return {
|
||||
id: item.userId || item.id,
|
||||
authUserId: item.userId || item.authUserId || '',
|
||||
username: item.email || '',
|
||||
userId: item.userId || item.id,
|
||||
name: item.name || item.email || '平台员工',
|
||||
email: item.email || '',
|
||||
phone: item.phoneMasked || item.phone || '-',
|
||||
role: permissions.includes('*') ? '超级管理员' : item.rawProfile?.displayRole || '平台管理员',
|
||||
roleCodes,
|
||||
role: permissions.includes('*') ? '超级管理员' : roleCodes.join('、') || '未分配角色',
|
||||
permissions,
|
||||
status: item.status === 'active' ? '正常' : '已停用',
|
||||
lastActive: dateTime(item.updatedAt),
|
||||
@@ -1027,16 +1099,19 @@
|
||||
}
|
||||
|
||||
const bootstrapDescriptors = [
|
||||
['backofficeBootstrap', 'PlatformBackofficeController_bootstrap', {}],
|
||||
['overview', 'PlatformAdminOverviewController_overview', {}],
|
||||
['domains', 'PlatformAdminTenantsController_domains', { query: { limit: 200 } }],
|
||||
['plans', 'PlatformAdminOverviewController_plans', {}],
|
||||
['tenants', 'PlatformAdminTenantsController_list', { query: { limit: 200 } }],
|
||||
['subscriptions', 'PlatformAdminBillingController_subscriptions', { query: { limit: 200 } }],
|
||||
['saasOrders', 'PlatformAdminBillingController_orders', { query: { limit: 200 } }],
|
||||
['saasRefunds', 'PlatformAdminBillingController_refunds', { query: { limit: 200 } }],
|
||||
['invoices', 'PlatformAdminBillingController_invoices', { query: { limit: 200 } }],
|
||||
['billingPayments', 'PlatformAdminBillingController_payments', { query: { limit: 200 } }],
|
||||
['usage', 'PlatformAdminBillingController_usage', { query: { limit: 200 } }],
|
||||
['reminders', 'PlatformAdminBillingController_reminders', { query: { limit: 100 } }],
|
||||
['banks', 'PlatformAdminQuestionBanksController_banks', { query: { limit: 200 } }],
|
||||
['grants', 'PlatformAdminQuestionBanksController_grants', { query: { limit: 200 } }],
|
||||
['bankSync', 'PlatformAdminQuestionBanksController_syncStatus', { query: { limit: 200 } }],
|
||||
['banks', 'PlatformQuestionBanksController_getBanks', { query: { status: 'all' } }],
|
||||
['staff', 'PlatformAdminOverviewController_staff', { query: { limit: 200 } }],
|
||||
['dunningChannels', 'PlatformAdminDunningChannelsController_channels', { query: { limit: 100 } }],
|
||||
['dunningEvents', 'PlatformAdminDunningEventsController_events', { query: { limit: 100 } }],
|
||||
@@ -1044,10 +1119,13 @@
|
||||
['alerts', 'PlatformAdminAuditController_alerts', { query: { limit: 100 } }],
|
||||
['crmConfigs', 'PlatformAdminCrmController_configs', { query: { limit: 200 } }],
|
||||
['crmLeads', 'PlatformAdminCrmController_leads', { query: { limit: 200 } }],
|
||||
['crmLogs', 'PlatformAdminCrmController_logs', { query: { limit: 200 } }],
|
||||
['smsChannels', 'PlatformAdminSmsController_channels', { query: { limit: 200 } }],
|
||||
['smsTemplates', 'PlatformAdminSmsController_templates', { query: { limit: 200 } }],
|
||||
['smsLogs', 'PlatformAdminSmsController_logs', { query: { limit: 200 } }],
|
||||
['platformPaymentApps', 'PlatformAdminPaymentSettingsController_apps', { query: { limit: 200 } }],
|
||||
['paymentChannels', 'PlatformAdminPaymentSettingsController_channels', { query: { limit: 200 } }],
|
||||
['rebateSummary', 'PlatformAdminPaymentSettingsController_rebateSummary', {}],
|
||||
['tenantPaymentApps', 'PlatformAdminTenantPaymentSettingsController_apps', { query: { limit: 200 } }],
|
||||
['platformPaymentEvents', 'PlatformAdminPaymentSettingsController_events', { query: { limit: 100 } }],
|
||||
['tenantPaymentEvents', 'PlatformAdminTenantPaymentSettingsController_events', { query: { limit: 100 } }],
|
||||
@@ -1056,7 +1134,6 @@
|
||||
async function bootstrap(options = {}) {
|
||||
const descriptors = bootstrapDescriptors.map(([key, operationId, requestOptions]) => {
|
||||
const nextOptions = { ...requestOptions, query: { ...(requestOptions.query || {}) } };
|
||||
if (key === 'banks') nextOptions.query.includeTenantBanks = Boolean(options.includeTenantBanks);
|
||||
return [key, operationId, nextOptions];
|
||||
});
|
||||
const results = await Promise.allSettled(
|
||||
@@ -1074,7 +1151,26 @@
|
||||
|
||||
function adaptBootstrap(payloads, currentData = {}) {
|
||||
const patch = {};
|
||||
const backoffice = payloads.backofficeBootstrap || {};
|
||||
const permissionItems = Array.isArray(backoffice.permissions) ? backoffice.permissions : [];
|
||||
const roleItems = Array.isArray(backoffice.roles) ? backoffice.roles : [];
|
||||
if (permissionItems.length) {
|
||||
patch.platformPermissions = Object.fromEntries(permissionItems.map(item => [
|
||||
item.code,
|
||||
platformPermissionLabels[item.code] || item.name || item.code,
|
||||
]));
|
||||
}
|
||||
if (roleItems.length) {
|
||||
patch.platformRoles = roleItems
|
||||
.filter(item => String(item.status || '').toLowerCase() === 'active')
|
||||
.map(item => {
|
||||
const localized = platformRoleLabels[item.code] || {};
|
||||
return { id: item.id, code: item.code, name: localized.name || item.name || item.code, description: localized.description || item.description || '', permissionCodes: item.permissionCodes || [], menuCodes: item.menuCodes || [] };
|
||||
});
|
||||
}
|
||||
if (payloads.overview) patch.apiOverview = itemOf(payloads.overview);
|
||||
if (payloads.domains) patch.domains = itemsOf(payloads.domains);
|
||||
if (payloads.plans) patch.saasCatalog = payloads.plans;
|
||||
if (payloads.permissions) patch.apiPermissions = itemOf(payloads.permissions);
|
||||
const planPayload = itemsOf(payloads.plans, 'versions').length ? catalogPlans(payloads.plans) : itemsOf(payloads.plans);
|
||||
if (planPayload.length) {
|
||||
@@ -1087,11 +1183,25 @@
|
||||
patch.tenants = tenants.map(item => adapters.tenant(item, currentById(currentData.tenants, item.id), planMap));
|
||||
}
|
||||
const staff = itemsOf(payloads.staff);
|
||||
if (staff.length) patch.staff = staff.map(adapters.staff);
|
||||
if (staff.length) {
|
||||
const roles = patch.platformRoles || currentData.platformRoles || [];
|
||||
patch.staff = staff.map(item => {
|
||||
const adapted = adapters.staff(item);
|
||||
const assignedRoles = roles.filter(role => adapted.roleCodes.includes(role.code));
|
||||
adapted.roleIds = assignedRoles.map(role => role.id);
|
||||
adapted.role = assignedRoles.map(role => role.name).join('、') || adapted.role;
|
||||
adapted.permissions = adapted.permissions.includes('*') ? adapted.permissions : [...new Set(assignedRoles.flatMap(role => role.permissionCodes))];
|
||||
return adapted;
|
||||
});
|
||||
}
|
||||
const invoices = itemsOf(payloads.invoices);
|
||||
if (invoices.length) patch.invoices = invoices.map(adapters.invoice);
|
||||
const billingPayments = itemsOf(payloads.billingPayments);
|
||||
if (billingPayments.length) patch.billingPayments = billingPayments.map(adapters.billingPayment);
|
||||
const subscriptions = itemsOf(payloads.subscriptions);
|
||||
if (subscriptions.length) patch.subscriptions = subscriptions.map(adapters.subscription);
|
||||
if (payloads.saasOrders) patch.saasOrders = itemsOf(payloads.saasOrders);
|
||||
if (payloads.saasRefunds) patch.saasRefunds = itemsOf(payloads.saasRefunds);
|
||||
const usage = itemsOf(payloads.usage);
|
||||
if (usage.length) patch.usage = aggregateUsage(usage);
|
||||
if (Array.isArray(payloads.usageCandidates?.items) && patch.usage) {
|
||||
@@ -1113,10 +1223,6 @@
|
||||
if (banks.length) {
|
||||
patch.banks = banks.map(item => adapters.bank(item, currentById(currentData.banks, item.id)));
|
||||
}
|
||||
const grants = itemsOf(payloads.grants);
|
||||
if (grants.length) patch.grants = grants.map(adapters.grant);
|
||||
const bankSync = itemsOf(payloads.bankSync);
|
||||
if (bankSync.length) patch.bankSync = bankSync.map(adapters.sync);
|
||||
const auditLogs = itemsOf(payloads.auditLogs);
|
||||
if (auditLogs.length) patch.auditLogs = auditLogs.map(adapters.audit);
|
||||
const alertRules = itemsOf(payloads.alertRules);
|
||||
@@ -1127,6 +1233,7 @@
|
||||
if (crmConfigs.length) patch.crmConfigs = crmConfigs.map(adapters.crmConfig);
|
||||
const crmLeads = itemsOf(payloads.crmLeads);
|
||||
if (crmLeads.length) patch.crmLeads = crmLeads.map(adapters.crmLead);
|
||||
if (payloads.crmLogs) patch.crmLogs = itemsOf(payloads.crmLogs);
|
||||
const smsChannels = itemsOf(payloads.smsChannels);
|
||||
if (smsChannels.length) patch.smsChannels = smsChannels.map(adapters.smsChannel);
|
||||
const smsTemplates = itemsOf(payloads.smsTemplates);
|
||||
@@ -1136,6 +1243,8 @@
|
||||
const platformPaymentApps = itemsOf(payloads.platformPaymentApps).map(adapters.platformPaymentApp);
|
||||
const tenantPaymentApps = itemsOf(payloads.tenantPaymentApps).map(adapters.tenantPaymentApp);
|
||||
if (platformPaymentApps.length || tenantPaymentApps.length) patch.paymentApps = [...platformPaymentApps, ...tenantPaymentApps];
|
||||
if (payloads.paymentChannels) patch.paymentChannels = itemsOf(payloads.paymentChannels);
|
||||
if (payloads.rebateSummary) patch.rebateSummary = itemOf(payloads.rebateSummary);
|
||||
const paymentEvents = [
|
||||
...itemsOf(payloads.platformPaymentEvents).map(adapters.platformPaymentEvent),
|
||||
...itemsOf(payloads.tenantPaymentEvents).map(adapters.platformPaymentEvent),
|
||||
|
||||
@@ -40,11 +40,11 @@
|
||||
['P-08', 'act.p-08.04', '查询提醒', 'command', 'native', 'read', '[data-billing-tab="reminders"]', 'PlatformAdminBillingController_reminders', 'allow'],
|
||||
['P-08', 'act.p-08.05', '新增/编辑/启停渠道', 'command', 'native', 'mutation', '[data-action="new-dunning-channel"],[data-action="edit-dunning-channel"],[data-action="toggle-dunning-channel"]', 'PlatformAdminDunningChannelsController_upsertChannel', 'block'],
|
||||
['P-08', 'act.p-08.06', '查看通知事件', 'command', 'contract', 'read', '[data-notification-detail]', 'PlatformAdminDunningEventsController_events', 'allow'],
|
||||
['P-09', 'act.p-09.01', '搜索', 'command', 'contract', 'read', '[data-action="apply-bank-search"]', 'PlatformAdminQuestionBanksController_banks', 'allow'],
|
||||
['P-09', 'act.p-09.02', '含租户库切换', 'command', 'native', 'read', '#includeTenantBanks', 'PlatformAdminQuestionBanksController_banks', 'allow'],
|
||||
['P-09', 'act.p-09.03', '新建/编辑/禁用授权', 'destructive', 'native', 'mutation', '[data-action="new-grant"],[data-action="edit-grant"],[data-action="toggle-grant"]', 'PlatformAdminQuestionBanksController_upsertGrant', 'block'],
|
||||
['P-09', 'act.p-09.04', '选择题库', 'command', 'native', 'read', '[data-bank-select]', 'PlatformAdminQuestionBanksController_banks', 'allow'],
|
||||
['P-09', 'act.p-09.05', '查看租户采用/同步', 'async', 'contract', 'task', '[data-action="preview-bank-sync"]', 'PlatformAdminQuestionBanksController_syncStatus', 'allow'],
|
||||
['P-09', 'act.p-09.01', '查询与选择题库', 'command', 'native', 'read', '[data-action="apply-bank-search"],[data-bank-select]', 'PlatformQuestionBanksController_getBanks', 'allow'],
|
||||
['P-09', 'act.p-09.02', '维护题库与内容结构', 'command', 'native', 'mutation', '[data-action="new-bank"],[data-action="edit-bank"],[data-action="new-bank-node"],[data-action="batch-bank-nodes"]', 'PlatformQuestionBanksController_upsertNode', 'block'],
|
||||
['P-09', 'act.p-09.03', '单题录入与题目管理', 'command', 'native', 'mutation', '#bankQuestionForm,[data-action="edit-bank-question"],[data-action="archive-bank-question"]', 'PlatformQuestionBanksController_upsertQuestion', 'block'],
|
||||
['P-09', 'act.p-09.04', '普通题目批量导入', 'async', 'native', 'task', '#bankBatchImportForm', 'PlatformQuestionBanksController_executeImport', 'block'],
|
||||
['P-09', 'act.p-09.05', '结构化题库文件导入', 'async', 'native', 'task', '#bankStructuredImportForm', 'PlatformQuestionBanksController_executeImport', 'block'],
|
||||
['P-10', 'act.p-10.01', '切换 CRM 视图', 'command', 'native', 'read', '[data-crm-tab]', 'PlatformAdminCrmController_configs', 'allow'],
|
||||
['P-10', 'act.p-10.02', '新建/编辑 CRM 接入', 'command', 'native', 'mutation', '[data-action="new-crm-config"],[data-action="edit-crm-config"]', 'PlatformAdminCrmController_upsertConfig', 'block'],
|
||||
['P-10', 'act.p-10.03', '启停 CRM 接入', 'destructive', 'native', 'mutation', '[data-action="toggle-crm-config"]', 'PlatformAdminCrmController_upsertConfig', 'block'],
|
||||
@@ -73,17 +73,30 @@
|
||||
].map(([pageId, id, label, kind, mode, effect, selector, operationId, mobile]) => ({ pageId, id, label, kind, mode, effect, selector, operationIds: [operationId], mobile, destructive: kind === 'destructive', implemented: true }));
|
||||
|
||||
const operationRows = [
|
||||
['P-13', 'PlatformBackofficeController_bootstrap', 'GET', '/api/backoffice/platform/bootstrap'],
|
||||
['P-01', 'PlatformAdminOverviewController_overview', 'GET', '/api/platform-admin/overview'],
|
||||
['P-01', 'PlatformAdminAuditController_alerts', 'GET', '/api/platform-admin/audit-alerts'],
|
||||
['P-01', 'PlatformAdminAuditController_logs', 'GET', '/api/platform-admin/audit-logs'],
|
||||
['P-02', 'PlatformAdminTenantsController_list', 'GET', '/api/platform-admin/tenants'],
|
||||
['P-02', 'PlatformAdminTenantsController_create', 'POST', '/api/platform-admin/tenants'],
|
||||
['P-02A', 'PlatformAdminTenantsController_domains', 'GET', '/api/platform-admin/domains'],
|
||||
['P-02A', 'PlatformAdminTenantsController_recheckDomain', 'POST', '/api/platform-admin/domains/{domainId}/recheck'],
|
||||
['P-03', 'PlatformAdminTenantsController_detail', 'GET', '/api/platform-admin/tenants/detail'],
|
||||
['P-03', 'PlatformAdminTenantsController_status', 'PATCH', '/api/platform-admin/tenants/status'],
|
||||
['P-03', 'PlatformAdminTenantsController_billingProfile', 'PUT', '/api/platform-admin/tenants/billing-profile'],
|
||||
['P-03', 'PlatformAdminBillingController_createSubscription', 'POST', '/api/platform-admin/saas/subscriptions'],
|
||||
['P-04', 'PlatformAdminTenantsController_billingProfile', 'PUT', '/api/platform-admin/tenants/billing-profile'],
|
||||
['P-05', 'PlatformAdminOverviewController_plans', 'GET', '/api/platform-admin/saas/catalog'],
|
||||
['P-05A', 'PlatformSaasController_upsertFeature', 'PUT', '/api/platform-admin/saas/features'],
|
||||
['P-05A', 'PlatformSaasController_upsertFeatureLimit', 'PUT', '/api/platform-admin/saas/feature-limits'],
|
||||
['P-05A', 'PlatformSaasController_upsertOffering', 'PUT', '/api/platform-admin/saas/offerings'],
|
||||
['P-05A', 'PlatformSaasController_upsertVersion', 'PUT', '/api/platform-admin/saas/offering-versions'],
|
||||
['P-05A', 'PlatformSaasController_publishVersion', 'POST', '/api/platform-admin/saas/offering-versions/{versionId}/publish'],
|
||||
['P-05A', 'PlatformSaasController_cloneVersion', 'POST', '/api/platform-admin/saas/offering-versions/{versionId}/clone'],
|
||||
['P-05A', 'PlatformSaasController_retireVersion', 'POST', '/api/platform-admin/saas/offering-versions/{versionId}/retire'],
|
||||
['P-05B', 'PlatformAdminBillingController_orders', 'GET', '/api/platform-admin/saas/orders'],
|
||||
['P-05B', 'PlatformAdminBillingController_refunds', 'GET', '/api/platform-admin/saas/refunds'],
|
||||
['P-05A', 'PlatformSaasController_upsertTenantFeatureOverride', 'PUT', '/api/platform-admin/saas/tenant-feature-overrides'],
|
||||
['P-05', 'PlatformAdminBillingController_subscriptions', 'GET', '/api/platform-admin/saas/subscriptions'],
|
||||
['P-05', 'PlatformAdminBillingController_createSubscription', 'POST', '/api/platform-admin/saas/subscriptions'],
|
||||
['P-06', 'PlatformAdminBillingController_invoices', 'GET', '/api/platform-admin/saas/invoices'],
|
||||
@@ -96,31 +109,51 @@
|
||||
['P-07', 'PlatformAdminBillingController_usageCandidates', 'GET', '/api/platform-admin/saas/invoices/usage-overage-candidates'],
|
||||
['P-07', 'PlatformAdminBillingController_fromUsage', 'POST', '/api/platform-admin/saas/invoices/from-usage-overage'],
|
||||
['P-08', 'PlatformAdminBillingController_confirmPayment', 'POST', '/api/platform-admin/saas/payments/manual/confirm'],
|
||||
['P-08', 'PlatformAdminBillingController_payments', 'GET', '/api/platform-admin/saas/payments'],
|
||||
['P-08', 'PlatformAdminBillingController_processOverdue', 'POST', '/api/platform-admin/saas/invoices/process-overdue'],
|
||||
['P-08', 'PlatformAdminBillingController_reminders', 'GET', '/api/platform-admin/saas/invoices/reminders'],
|
||||
['P-08', 'PlatformAdminDunningChannelsController_channels', 'GET', '/api/platform-admin/saas/dunning/channels'],
|
||||
['P-08', 'PlatformAdminDunningChannelsController_upsertChannel', 'PUT', '/api/platform-admin/saas/dunning/channels'],
|
||||
['P-08', 'PlatformAdminDunningChannelsController_disableChannel', 'POST', '/api/platform-admin/saas/dunning/channels/disable'],
|
||||
['P-08', 'PlatformAdminDunningEventsController_events', 'GET', '/api/platform-admin/saas/dunning/events'],
|
||||
['P-09', 'PlatformAdminQuestionBanksController_banks', 'GET', '/api/platform-admin/question-banks'],
|
||||
['P-09', 'PlatformAdminQuestionBanksController_grants', 'GET', '/api/platform-admin/question-bank-grants'],
|
||||
['P-09', 'PlatformAdminQuestionBanksController_upsertGrant', 'PUT', '/api/platform-admin/question-bank-grants'],
|
||||
['P-09', 'PlatformAdminQuestionBanksController_syncStatus', 'GET', '/api/platform-admin/question-bank-sync-status'],
|
||||
['P-08', 'PlatformAdminDunningEventsController_eventDetail', 'GET', '/api/platform-admin/saas/dunning/events/detail'],
|
||||
['P-08', 'PlatformAdminDunningEventsController_retryEvent', 'POST', '/api/platform-admin/saas/dunning/events/retry'],
|
||||
['P-09', 'PlatformQuestionBanksController_getBanks', 'GET', '/api/platform-admin/question-banks'],
|
||||
['P-09', 'PlatformQuestionBanksController_upsertBank', 'PUT', '/api/platform-admin/question-banks'],
|
||||
['P-09', 'PlatformQuestionBanksController_archiveBank', 'POST', '/api/platform-admin/question-banks/{bankId}/archive'],
|
||||
['P-09', 'PlatformQuestionBanksController_getNodes', 'GET', '/api/platform-admin/question-banks/{bankId}/nodes'],
|
||||
['P-09', 'PlatformQuestionBanksController_upsertNode', 'PUT', '/api/platform-admin/question-banks/nodes'],
|
||||
['P-09', 'PlatformQuestionBanksController_batchCreateNodes', 'POST', '/api/platform-admin/question-banks/nodes/batch'],
|
||||
['P-09', 'PlatformQuestionBanksController_archiveNode', 'POST', '/api/platform-admin/question-banks/nodes/{nodeId}/archive'],
|
||||
['P-09', 'PlatformQuestionBanksController_getQuestions', 'GET', '/api/platform-admin/question-banks/questions'],
|
||||
['P-09', 'PlatformQuestionBanksController_upsertQuestion', 'PUT', '/api/platform-admin/question-banks/questions'],
|
||||
['P-09', 'PlatformQuestionBanksController_archiveQuestions', 'POST', '/api/platform-admin/question-banks/questions/archive'],
|
||||
['P-09', 'PlatformQuestionBanksController_previewImport', 'POST', '/api/platform-admin/question-banks/imports/preview'],
|
||||
['P-09', 'PlatformQuestionBanksController_executeImport', 'POST', '/api/platform-admin/question-banks/imports'],
|
||||
['P-09', 'PlatformQuestionBanksController_getImport', 'GET', '/api/platform-admin/question-banks/imports/{jobId}'],
|
||||
['P-09', 'PlatformQuestionBanksController_signAssetUpload', 'POST', '/api/platform-admin/question-banks/assets/upload-sign'],
|
||||
['P-09', 'PlatformQuestionBanksController_confirmAssetUpload', 'POST', '/api/platform-admin/question-banks/assets/upload-confirm'],
|
||||
['P-10', 'PlatformAdminCrmController_configs', 'GET', '/api/platform-admin/tenant-capabilities/crm/configs'],
|
||||
['P-10', 'PlatformAdminCrmController_upsertConfig', 'PUT', '/api/platform-admin/tenant-capabilities/crm/configs'],
|
||||
['P-10', 'PlatformAdminCrmController_leads', 'GET', '/api/platform-admin/tenant-capabilities/crm/leads'],
|
||||
['P-10', 'PlatformAdminCrmController_retryLead', 'POST', '/api/platform-admin/tenant-capabilities/crm/leads/retry'],
|
||||
['P-10', 'PlatformAdminCrmController_logs', 'GET', '/api/platform-admin/tenant-capabilities/crm/logs'],
|
||||
['P-11', 'PlatformAdminSmsController_channels', 'GET', '/api/platform-admin/tenant-capabilities/sms/channels'],
|
||||
['P-11', 'PlatformAdminSmsController_disableChannel', 'POST', '/api/platform-admin/tenant-capabilities/sms/channels/{channelId}/disable'],
|
||||
['P-11', 'PlatformAdminSmsController_templates', 'GET', '/api/platform-admin/tenant-capabilities/sms/templates'],
|
||||
['P-11', 'PlatformAdminSmsController_submitTemplateReview', 'POST', '/api/platform-admin/tenant-capabilities/sms/templates/{templateId}/submit-review'],
|
||||
['P-11', 'PlatformAdminSmsController_disableTemplate', 'POST', '/api/platform-admin/tenant-capabilities/sms/templates/{templateId}/disable'],
|
||||
['P-11', 'PlatformAdminSmsController_logs', 'GET', '/api/platform-admin/tenant-capabilities/sms/logs'],
|
||||
['P-11', 'PlatformAdminSmsController_upsertChannel', 'PUT', '/api/platform-admin/tenant-capabilities/sms/channels'],
|
||||
['P-11', 'PlatformAdminSmsController_upsertTemplate', 'PUT', '/api/platform-admin/tenant-capabilities/sms/templates'],
|
||||
['P-12', 'PlatformAdminPaymentSettingsController_apps', 'GET', '/api/platform-admin/payment-settings/apps'],
|
||||
['P-12', 'PlatformAdminPaymentSettingsController_channels', 'GET', '/api/platform-admin/payment-settings/channels'],
|
||||
['P-12', 'PlatformAdminPaymentSettingsController_upsertChannel', 'PUT', '/api/platform-admin/payment-settings/channels'],
|
||||
['P-12', 'PlatformAdminPaymentSettingsController_disableChannel', 'POST', '/api/platform-admin/payment-settings/channels/{channelId}/disable'],
|
||||
['P-12', 'PlatformAdminPaymentSettingsController_rebateSummary', 'GET', '/api/platform-admin/payment-settings/rebates/summary'],
|
||||
['P-12', 'PlatformAdminPaymentSettingsController_events', 'GET', '/api/platform-admin/payment-settings/events'],
|
||||
['P-12', 'PlatformAdminPaymentSettingsController_rebates', 'GET', '/api/platform-admin/payment-settings/rebates/summary'],
|
||||
['P-12', 'PlatformAdminPaymentSettingsController_upsertApp', 'PUT', '/api/platform-admin/payment-settings/apps'],
|
||||
['P-12', 'PlatformAdminPaymentSettingsController_upsertChannel', 'PUT', '/api/platform-admin/payment-settings/channels'],
|
||||
['P-12', 'PlatformAdminTenantPaymentSettingsController_apps', 'GET', '/api/platform-admin/tenant-capabilities/payments/apps'],
|
||||
['P-12', 'PlatformAdminTenantPaymentSettingsController_events', 'GET', '/api/platform-admin/tenant-capabilities/payments/events'],
|
||||
['P-12', 'PlatformAdminTenantPaymentSettingsController_upsertApp', 'PUT', '/api/platform-admin/tenant-capabilities/payments/apps'],
|
||||
@@ -128,6 +161,8 @@
|
||||
['P-13', 'PlatformAdminOverviewController_staff', 'GET', '/api/platform-admin/staff'],
|
||||
['P-13', 'PlatformAdminOverviewController_upsertStaff', 'PUT', '/api/platform-admin/staff'],
|
||||
['P-13', 'PlatformAdminOverviewController_updateStaffStatus', 'PATCH', '/api/platform-admin/staff/status'],
|
||||
['P-13A', 'PlatformBackofficeController_upsertRole', 'POST', '/api/backoffice/platform/roles'],
|
||||
['P-13A', 'PlatformBackofficeController_replaceRoleBindings', 'PUT', '/api/backoffice/platform/roles/{roleId}/bindings'],
|
||||
['P-14', 'PlatformAdminAuditController_logs', 'GET', '/api/platform-admin/audit-logs'],
|
||||
['P-14', 'PlatformAdminAuditController_exportLogs', 'GET', '/api/platform-admin/audit-logs/export'],
|
||||
['P-15', 'PlatformAdminAuditController_rules', 'GET', '/api/platform-admin/audit-alert-rules'],
|
||||
@@ -157,9 +192,7 @@
|
||||
['nav.p-14.p-15', 'P-14', 'P-15', '[data-action="jump-related-alert"]'],
|
||||
].map(([id, fromPageId, toPageId, selector]) => ({ id, fromPageId, toPageId, selector, implemented: true }));
|
||||
|
||||
const supportingReads = [
|
||||
{ pageId: 'P-09', selector: '[data-action="sync-detail"],[data-action="refresh-bank-sync"]', operationId: 'PlatformAdminQuestionBanksController_syncStatus' },
|
||||
];
|
||||
const supportingReads = [];
|
||||
|
||||
const trace = [];
|
||||
const decoratedActionIds = new Set();
|
||||
|
||||
Reference in New Issue
Block a user