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

${group}

${visible.map(([id, glyph, label]) => ``).join('')}
`; + }).join(''); + const staffShortcut = document.querySelector('.platform-foot>button'); + if (staffShortcut) staffShortcut.hidden = !hasPermission('platform:staff:read'); + const accountRole = document.querySelector('.platform-foot small'); + if (accountRole) accountRole.textContent = roleDefinitions[state.role]?.label || '平台管理员'; + const authenticatedUser = window.GongxuePlatformAuth?.getUser?.(); + const accountName = document.querySelector('.platform-foot strong'); + const accountAvatar = document.querySelector('.platform-foot>div>span'); + const displayName = authenticatedUser?.name || authenticatedUser?.email; + if (accountName && displayName) accountName.textContent = displayName; + if (accountAvatar && displayName) accountAvatar.textContent = displayName.slice(0, 1).toUpperCase(); +} +function updateCounters() { + const activeTasks = data.tasks.filter(task => ['pending', 'processing'].includes(task.status)).length; + const openAlerts = data.alerts.filter(alert => alert.status === 'open').length; + taskCount.textContent = activeTasks; + taskCount.hidden = activeTasks === 0; + alertCount.textContent = openAlerts; + alertCount.hidden = openAlerts === 0; +} +function pageHead(title, description, actions = '', options = {}) { + const level = options.level || pageMeta[state.view]?.level || 1; + const nav = level > 1 ? `
` : ''; + return `
${nav}${pageMeta[state.view]?.id || ''}${level > 1 ? ` · L${level}` : ''}

${title}

${description}

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

${note}

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

${title}

${description}

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

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

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

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

401 · 请重新登录

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

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

403 · 权限不足

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

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

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

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

正在提交

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

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

操作成功

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

`; + if (state.scenario === 'error') return `
${pageHead(pageMeta[state.view].title, '数据加载未完成。')}${requestErrorBlock()}
`; + if (state.scenario === 'empty') return `
${pageHead(pageMeta[state.view].title, '当前演示数据集为空。')}${emptyBlock('当前页面没有数据', '这是只存在于 Mock 原型的空状态演练。恢复“正常”即可继续测试真实交互。', '')}
`; + if (state.scenario === 'partial_error') { + const closingIndex = content.lastIndexOf(''); + return closingIndex >= 0 ? `${content.slice(0, closingIndex)}${requestErrorBlock(true)}${content.slice(closingIndex)}` : `${content}${requestErrorBlock(true)}`; + } + return content; +} + +function renderOverview() { + if (platformApi?.isEnabled()) return renderApiOverview(); + const active = data.tenants.filter(tenant => tenant.status === '正常').length; + const overdue = data.invoices.filter(invoice => invoice.status === '已逾期').reduce((sum, invoice) => sum + invoice.balance, 0); + const openAlerts = data.alerts.filter(alert => alert.status === 'open').slice(0, 4); + const totalStudents = data.tenants.reduce((sum, tenant) => sum + tenant.students, 0); + const totalQuestions = data.banks.reduce((sum, bank) => sum + bank.questions, 0); + return `
${pageHead('平台经营工作台', '查看租户规模、订阅账务、平台用量与安全态势。数据更新时间:2026-07-23 10:10 CST。', ``)} +
${metric('有效租户', active, `共 ${data.tenants.length} 家`, 'building-2')}${metric('活跃订阅', data.subscriptions.filter(item => item.status === '活跃').length, '未来30天到期 2 条', 'badge-dollar-sign', 'green')}${metric('逾期应收', money(overdue), `涉及 ${data.invoices.filter(item => item.status === '已逾期').length} 家租户`, 'receipt-text', 'orange')}${metric('平台学生规模', formatNumber(totalStudents), 'Mock 最近30日活跃 82%', 'users', 'purple')}${metric('公共题库题目', formatNumber(totalQuestions), `${data.banks.length} 个公共来源库`, 'database-zap', 'cyan')}
+

平台收入与租户增长

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

需要关注

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

租户状态

可进入租户工作列表处理
${data.tenants.length}全部租户
${[['正常', data.tenants.filter(t => t.status === '正常').length, 68], ['试用', data.tenants.filter(t => t.status === '试用').length, 16], ['待配置', data.tenants.filter(t => t.status === '待配置').length, 16], ['暂停/逾期', data.tenants.filter(t => t.status === '暂停' || t.billing === '逾期').length, 16]].map(([label, count, percent]) => `

${label}${count}

`).join('')}
+

账务结论

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

最近平台事件

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

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

`).join('')}
`; +} + +function renderApiOverview() { + const overview = data.apiOverview || {}; + const openAlerts = data.alerts.filter(alert => alert.status === 'open').slice(0, 4); + return `
${pageHead('平台经营工作台', `真实 PostgreSQL 数据 · ${state.apiLoadedAt || '正在加载'}`, ``)} +
${metric('全部租户', overview.tenantCount ?? data.tenants.length, `活跃 ${overview.activeTenantCount ?? 0} 家`, 'building-2')}${metric('暂停租户', overview.suspendedTenantCount ?? 0, '来自真实租户状态', 'pause-circle', 'orange')}${metric('已支付订单', overview.paidOrderCount ?? 0, `全部订单 ${overview.orderCount ?? 0}`, 'receipt-text', 'green')}${metric('平台收入', money(Number(overview.revenueCents || 0) / 100), '按已支付订单统计', 'circle-dollar-sign', 'cyan')}${metric('题库 / 题目', `${overview.questionBankCount ?? 0} / ${formatNumber(overview.questionCount ?? 0)}`, `学习活跃用户 ${overview.learningActiveUserCount ?? 0}`, 'database-zap', 'purple')}
+

租户状态

来自 /api/platform-admin/tenants
${data.tenants.length}全部租户
${[['正常', data.tenants.filter(item => item.status === '正常').length, 100], ['待配置', data.tenants.filter(item => item.status === '待配置').length, 100], ['暂停', data.tenants.filter(item => item.status === '暂停').length, 100]].map(([label, count, percent]) => `

${label}${count}

`).join('')}
+

开放告警

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

最近平台事件

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

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

`).join('') || '

暂无审计事件

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

主体、负责人和域名

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

关联工作区

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

生命周期轨迹

状态变更写入平台审计

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

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

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

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

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

开票与联系人

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

发票与银行信息

平台只接受脱敏账户尾号
+
所有时间以 Asia/Shanghai 展示 · 保存将写入审计日志${!readOnly ? `` : ''}
`; +} + +function renderSubscriptions() { + const selectedTenant = state.selectedTenant ? tenantById(state.selectedTenant) : null; + const rows = state.scenario === 'empty' ? [] : data.subscriptions; + return `
${pageHead('SaaS 套餐与订阅', '维护套餐能力边界,并为租户创建或续订订阅。当前 API 只提供套餐查询,因此不提供套餐编辑。', `${canWrite('platform:billing:write') ? `` : ''}`)} +
${data.plans.map((plan, index) => `
${plan.name}

${plan.description}

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

活跃与试用订阅

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

待收与逾期队列

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

全局催缴策略

可由平台任务生成提醒

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

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

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

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

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

${escapeHtml(selected.name)}

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

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

${selected.version}

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

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

租户采用与同步状态

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

平台员工

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

权限目录

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

告警规则

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

${escapeHtml(rule.target)}

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

告警队列

开放 ${data.alerts.filter(a => a.status === 'open').length} · 已确认 ${data.alerts.filter(a => a.status === 'acknowledged').length} · 已结束 ${data.alerts.filter(a => ['resolved', 'ignored'].includes(a.status)).length}
${[['all', '全部'], ['open', '开放'], ['acknowledged', '已确认'], ['resolved', '已解决'], ['ignored', '已忽略']].map(([value, label]) => ``).join('')}
${alerts.length ? alerts.map(alert => `
${alert.severity}
${alert.id} · ${alert.owner}${escapeHtml(alert.title)}

${escapeHtml(alert.summary)}

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

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

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

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

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

当前没有逾期候选。

'}
`, `${candidates.length ? '' : ''}`, { id: 'P-08 · Dry-run' }); + }, + recordUsage() { + return modalFrame('记录平台用量', '人工记录会标记来源;同租户同账期重复记录会被阻止。', ``, ``, { id: 'P-07 · 人工用量', wide: true }); + }, + overagePreview() { + const candidates = data.usage.filter(item => item.period === state.usagePeriod && item.estimated > 0 && !data.invoices.some(invoice => invoice.note === `超额 ${item.period}` && invoice.tenantId === item.tenantId)); + return modalFrame('超额账单候选 · Dry-run', `${state.usagePeriod} 账期预览;确认后通过异步任务生成账单。`, `
${candidates.map(item => ``).join('') || '

当前账期无超额候选。

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

${escapeHtml(impact)}

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

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

${task.status === 'failed' ? `` : statusBadge(taskStatusLabel(task.status))}
`).join('') : emptyBlock('没有异步任务', '批量生成、导出或重试后会出现在这里。')}
`, ``, { id: '全局 · 任务中心', wide: true }); + }, +}; + +function confirmAction(config, execute) { + openModal('confirmAction', config); + state.modalData.execute = execute; +} + +function validateBillingProfile(form) { + const errors = {}; + ['billingName', 'contactName', 'phone', 'email', 'invoiceTitle'].forEach(name => { if (!fieldValue(form, name)) errors[name] = `${form.elements[name].previousElementSibling?.textContent.replace('*', '').trim() || name}不能为空`; }); + const phone = fieldValue(form, 'phone'); if (phone && !/^1\d{10}$/.test(phone)) errors.phone = '请输入 11 位中国大陆手机号'; + const email = fieldValue(form, 'email'); if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) errors.email = '请输入有效邮箱地址'; + const taxId = fieldValue(form, 'taxId'); if (taxId && !/^[0-9A-Z]{15,20}$/.test(taxId)) errors.taxId = '税号应为 15-20 位大写字母或数字'; + const account = fieldValue(form, 'bankAccountMasked'); if (account && !account.includes('*')) errors.bankAccountMasked = '不得提交完整银行账号,请使用脱敏值'; + return Object.keys(errors).length ? errors : null; +} +function validateNotificationChannel(values) { + const errors = {}; + if (!values.code) errors.code = '渠道代码不能为空'; + if (!values.name) errors.name = '渠道名称不能为空'; + if (!values.endpoint) errors.endpoint = 'Webhook URL 不能为空'; + if (platformApi?.isEnabled() && values.endpoint) { + try { + const url = new URL(values.endpoint); + const local = ['localhost', '127.0.0.1', '::1'].includes(url.hostname); + if (url.protocol !== 'https:' && !local) errors.endpoint = '联调/生产 Webhook 必须使用 HTTPS'; + } catch { + errors.endpoint = '请输入完整有效的 Webhook URL'; + } + } + return Object.keys(errors).length ? errors : null; +} + +document.addEventListener('submit', event => { + event.preventDefault(); + const form = event.target; + const desktopOnlyForms = new Set(['createTenantForm', 'tenantStatusForm', 'billingProfileForm', 'subscriptionForm', 'invoiceForm', 'paymentForm', 'usageForm', 'dunningChannelForm', 'grantForm', 'staffForm', 'auditChannelForm']); + if (desktopOnlyForms.has(form.id) && requireDesktop('提交复杂配置或资金操作')) return; + if (form.id === 'createTenantForm') { + const slug = fieldValue(form, 'slug'); const host = fieldValue(form, 'primaryHost'); const name = fieldValue(form, 'name'); const amount = parseMoney(fieldValue(form, 'amount')); + simulateWrite({ key: 'create-tenant', permission: 'platform:tenant:write', operationId: 'PlatformAdminTenantsController_create', body: () => ({ ...Object.fromEntries(new FormData(form)), amount }), validate: () => { + const errors = {}; if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) errors.slug = 'slug 仅支持小写字母、数字和连字符'; + if (!name) errors.name = '租户名称不能为空'; if (!/^([a-z0-9-]+\.)+[a-z]{2,}$/i.test(host)) errors.primaryHost = '请输入有效域名'; + if (data.tenants.some(item => item.slug === slug)) errors.slug = 'slug 已存在'; if (data.tenants.some(item => item.host === host)) errors.primaryHost = '域名已被其他租户使用'; return Object.keys(errors).length ? errors : null; + }, apply: payload => { const planCode = fieldValue(form, 'planCode'); const plan = planByCode(planCode); const fallback = { id: `tenant_${slug.replaceAll('-', '_')}`, slug, name, legal: fieldValue(form, 'legalName') || name, brand: fieldValue(form, 'brandName') || name, shortName: fieldValue(form, 'shortName') || name, host, plan: plan.name, planCode, status: fieldValue(form, 'status'), billing: fieldValue(form, 'billingStatus') || (amount > 0 ? '待付款' : '正常'), mode: fieldValue(form, 'mode'), students: 0, questions: 0, storageGB: 0, trafficTB: 0, expires: '', balance: amount, owner: '待配置', ownerPhone: '-', createdAt: todayTime() }; const tenant = payload?.item ? platformApi.adapters.tenant({ ...payload.item, planCode, brandName: fallback.brand }, fallback, Object.fromEntries(data.plans.map(item => [item.code, item]))) : fallback; tenant.host = host; tenant.shortName = fallback.shortName; data.tenants.unshift(tenant); if (!platformApi?.isEnabled() && amount > 0) data.invoices.unshift({ id: entityId('INV'), tenantId: tenant.id, type: '首期服务费', period: '首期', total: amount, paid: 0, balance: amount, due: '2026-08-15', status: '待付款', note: '创建租户首期金额' }); state.selectedTenant = tenant.id; addAudit('tenant.create', 'tenant', tenant.id, tenant.name, '高', `创建租户 ${tenant.name},初始套餐 ${tenant.plan},账务状态 ${tenant.billing},首期金额 ${money(amount)}。`); state.modal = ''; navigate('tenantDetail'); return tenant; }, success: '租户与首期账务已创建,已进入租户详情', close: false, rerender: false }); + } + if (form.id === 'tenantStatusForm') { + const tenant = tenantById(state.modalData.tenantId); const reason = fieldValue(form, 'reason'); + simulateWrite({ key: 'tenant-status', permission: 'platform:tenant:status', operationId: 'PlatformAdminTenantsController_status', body: () => ({ tenantId: tenant.id, status: tenant.status === '暂停' ? '正常' : '暂停', billingStatus: fieldValue(form, 'billingStatus'), reason }), validate: () => !reason ? { reason: '必须填写可审计的变更原因' } : !new FormData(form).get('confirm') ? { confirm: '请确认业务影响' } : null, apply: () => { const from = tenant.status; const billingFrom = tenant.billing; tenant.status = from === '暂停' ? '正常' : '暂停'; tenant.billing = fieldValue(form, 'billingStatus') || tenant.billing; addAudit('tenant.status.update', 'tenant', tenant.id, tenant.name, '高', `业务状态 ${from} → ${tenant.status};账务状态 ${billingFrom} → ${tenant.billing}。原因:${reason}`); return tenant.status; }, success: nextStatus => `租户已${nextStatus === '暂停' ? '暂停' : '恢复'},账务状态已同步` }); + } + if (form.id === 'billingProfileForm') { + simulateWrite({ key: 'billing-profile', permission: 'platform:tenant:billing_profile', operationId: 'PlatformAdminTenantsController_billingProfile', body: () => ({ tenantId: state.selectedTenant, ...Object.fromEntries(new FormData(form)) }), validate: () => validateBillingProfile(form), apply: () => { data.billingProfiles[state.selectedTenant] = Object.fromEntries(new FormData(form)); addAudit('tenant.billing_profile.update', 'tenant', state.selectedTenant, tenantById(state.selectedTenant).name, '高', '更新租户账务与开票资料,银行账号保持脱敏。'); navigateBack(); }, success: '账务资料已保存并回到租户详情', close: false, rerender: false }); + } + if (form.id === 'subscriptionForm') { + const values = Object.fromEntries(new FormData(form)); + simulateWrite({ key: 'subscription', permission: 'platform:billing:write', operationId: 'PlatformAdminBillingController_createSubscription', body: () => ({ ...values, autoRenew: Boolean(new FormData(form).get('autoRenew')) }), validate: () => { const errors = {}; if (data.subscriptions.some(item => item.tenantId === values.tenantId && item.status === '活跃')) errors.tenantId = '该租户已有活跃订阅,请先处理原订阅'; if (!values.amount || Number(values.amount) < 0) errors.amount = '金额必须大于等于 0'; if (!values.start) errors.start = '开始日期不能为空'; if (!values.expires || values.expires <= values.start) errors.expires = '到期日期必须晚于开始日期'; return Object.keys(errors).length ? errors : null; }, apply: () => { const subscription = { id: entityId('SUB'), tenantId: values.tenantId, planCode: values.planCode, status: values.status, cycle: values.cycle, amount: Number(values.amount), start: values.start, expires: values.expires, autoRenew: Boolean(new FormData(form).get('autoRenew')) }; data.subscriptions.unshift(subscription); const tenant = tenantById(values.tenantId); tenant.planCode = values.planCode; tenant.plan = planByCode(values.planCode).name; tenant.expires = values.expires; addAudit('subscription.create', 'subscription', subscription.id, tenant.name, '高', `创建 ${subscription.cycle} ${tenant.plan} 订阅,金额 ${money(subscription.amount)}。`); }, success: '订阅已创建并更新租户套餐' }); + } + if (form.id === 'invoiceForm') { + const values = Object.fromEntries(new FormData(form)); + simulateWrite({ key: 'invoice', permission: 'platform:billing:write', operationId: 'PlatformAdminBillingController_createInvoice', body: values, validate: () => { const errors = {}; if (!values.amount || Number(values.amount) <= 0) errors.amount = '金额必须大于 0'; if (!values.due) errors.due = '到期日不能为空'; if (!values.period) errors.period = '账期不能为空'; return Object.keys(errors).length ? errors : null; }, apply: () => { const invoice = { id: entityId('INV'), tenantId: values.tenantId, type: values.type, period: values.period, total: Number(values.amount), paid: 0, balance: Number(values.amount), due: values.due, status: '待付款', note: values.note || values.type }; data.invoices.unshift(invoice); const tenant = tenantById(values.tenantId); tenant.balance += invoice.balance; tenant.billing = '待付款'; addAudit('invoice.create', 'invoice', invoice.id, invoice.id, '高', `手工创建 ${money(invoice.total)} 服务费账单。`); }, success: '账单草稿已创建' }); + } + if (form.id === 'paymentForm') { + const invoice = invoiceById(state.modalData.invoiceId); const values = Object.fromEntries(new FormData(form)); const amount = parseMoney(values.amount); + simulateWrite({ key: 'payment', permission: 'platform:billing:payment', operationId: 'PlatformAdminBillingController_confirmPayment', body: () => ({ ...values, tenantId: invoice.tenantId, invoiceId: invoice.backendId || invoice.id, amount }), validate: () => { const errors = {}; if (!amount || amount <= 0) errors.amount = '收款金额必须大于 0'; if (amount > invoice.balance) errors.amount = `收款金额不可超过余额 ${money(invoice.balance)}`; if (!values.paymentNo) errors.paymentNo = '平台收款号不能为空'; if (!values.tradeNo) errors.tradeNo = '交易流水号不能为空'; if (!new FormData(form).get('confirm')) errors.confirm = '请先确认已核对银行流水'; return Object.keys(errors).length ? errors : null; }, apply: () => { invoice.paid += amount; invoice.balance -= amount; invoice.status = invoice.balance === 0 ? '已支付' : '部分支付'; const tenant = tenantById(invoice.tenantId); tenant.balance = Math.max(0, tenant.balance - amount); tenant.billing = tenant.balance === 0 ? '正常' : '待付款'; if (tenant.status === '暂停' && tenant.balance === 0) tenant.status = '正常'; addAudit('invoice.payment.confirm', 'invoice', invoice.id, invoice.id, '高', `人工确认收款 ${money(amount)},交易流水仅保存脱敏摘要。`); }, success: '收款已确认,账单与租户余额已更新' }); + } + if (form.id === 'usageForm') { + const values = Object.fromEntries(new FormData(form)); + simulateWrite({ key: 'usage', permission: 'platform:usage:write', operationId: 'PlatformAdminBillingController_recordUsage', request: () => Promise.all([['students', values.students], ['questions', values.questions], ['storage_gb', values.storageGB], ['traffic_tb', values.trafficTB]].map(([metricKey, metricValue]) => platformApi.request('PlatformAdminBillingController_recordUsage', { body: platformApi.buildBody('PlatformAdminBillingController_recordUsage', { tenantId: values.tenantId, period: values.period, metricKey, metricValue }) }))), validate: () => data.usage.some(item => item.tenantId === values.tenantId && item.period === values.period) ? { period: '该租户账期已有记录,避免周期重叠或重复' } : null, apply: () => { data.usage.unshift({ tenantId: values.tenantId, period: values.period, source: '人工补录', students: Number(values.students), questions: Number(values.questions), storageGB: Number(values.storageGB), trafficTB: Number(values.trafficTB), overage: [], estimated: 0 }); addAudit('usage.create', 'tenant', values.tenantId, tenantById(values.tenantId).name, '中', `人工补录 ${values.period} 平台用量。`); }, success: '用量记录已保存并标记人工来源' }); + } + if (form.id === 'dunningChannelForm') { + const values = Object.fromEntries(new FormData(form)); + const existing = data.dunningChannels.find(item => item.id === values.id); + simulateWrite({ key: 'dunning-channel', permission: 'platform:billing:notification', operationId: 'PlatformAdminDunningChannelsController_upsertChannel', body: () => ({ ...values, secretRef: existing?.secretRef, reminderTypes: existing?.reminderTypes, reminderChannels: existing?.reminderChannels, minReminderLevel: existing?.minReminderLevel, enabled: Boolean(new FormData(form).get('enabled')) }), validate: () => validateNotificationChannel(values), apply: () => { const next = { id: values.id || entityId('DUN'), code: values.code, name: values.name, provider: values.provider, endpoint: values.endpoint, endpointRedacted: false, secretRef: existing?.secretRef || '', retry: Number(values.retry), enabled: Boolean(new FormData(form).get('enabled')) }; if (existing) Object.assign(existing, next); else data.dunningChannels.unshift(next); addAudit('dunning.channel.upsert', 'notification_channel', next.id, next.name, '高', `保存催缴渠道 ${next.code},secret 保持只写。`); }, success: '催缴通知渠道已保存' }); + } + if (form.id === 'grantForm') { + const values = Object.fromEntries(new FormData(form)); + simulateWrite({ key: 'grant', permission: 'platform:question_bank:grant', operationId: 'PlatformAdminQuestionBanksController_upsertGrant', body: () => ({ ...values, localEdit: Boolean(new FormData(form).get('localEdit')) }), validate: () => { const errors = {}; const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const splitScope = value => String(value || '').split(/[、,,]/).map(item => item.trim()).filter(Boolean); if (values.scope === '指定套餐' && !values.planCode) errors.planCode = '指定套餐范围必须选择套餐'; if (values.scope === '指定租户' && !values.tenantId) errors.tenantId = '指定租户范围必须选择租户'; if (values.scope === '套餐 + 租户' && (!values.planCode || !values.tenantId)) errors.planCode = '混合范围必须同时选择套餐与租户'; if (platformApi?.isEnabled() && splitScope(values.regionIds).some(value => !uuid.test(value))) errors.regionIds = '地区范围必须为 UUID,多个值用逗号分隔'; if (platformApi?.isEnabled() && splitScope(values.subjectIds).some(value => !uuid.test(value))) errors.subjectIds = '科目范围必须为 UUID,多个值用逗号分隔'; if (values.expiresAt && values.expiresAt <= values.startsAt) errors.expiresAt = '到期日期必须晚于开始日期'; return Object.keys(errors).length ? errors : null; }, apply: () => { const existing = data.grants.find(item => item.id === values.id); const splitScope = value => String(value || '').split(/[、,,]/).map(item => item.trim()).filter(Boolean); const next = { id: values.id || entityId('GRANT'), bankId: values.bankId, scope: values.scope, allowedPlanCodes: values.planCode ? [values.planCode] : [], allowedTenantIds: values.tenantId ? [values.tenantId] : [], allowedRegionIds: splitScope(values.regionIds), allowedSubjectIds: splitScope(values.subjectIds), status: existing?.status || '启用', startsAt: values.startsAt, expiresAt: values.expiresAt, localEdit: Boolean(new FormData(form).get('localEdit')) }; if (existing) Object.assign(existing, next); else data.grants.unshift(next); addAudit('bank.grant.update', 'grant', next.id, bankById(next.bankId).name, '高', `保存授权范围 ${next.scope};地区 ${next.allowedRegionIds.join('、') || '不限'};科目 ${next.allowedSubjectIds.join('、') || '不限'}。`); }, success: '公共题库授权范围已保存' }); + } + if (form.id === 'staffForm') { + const values = Object.fromEntries(new FormData(form)); const permissions = new FormData(form).getAll('permissions'); + simulateWrite({ key: 'staff', permission: 'platform:staff:write', operationId: 'PlatformAdminOverviewController_upsertStaff', body: () => ({ ...values, permissions }), validate: () => { const errors = {}; ['authUserId', 'username', 'name', 'email'].forEach(name => { if (!values[name]) errors[name] = `${name} 不能为空`; }); if (platformApi?.isEnabled() && values.authUserId && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(values.authUserId)) errors.authUserId = '真实模式必须填写已存在的 Supabase Auth 用户 UUID'; if (!platformApi?.isEnabled() && values.authUserId && !values.authUserId.startsWith('auth-')) errors.authUserId = 'Mock Auth 用户不存在,请使用 auth- 前缀'; if (!permissions.length) errors.permissions = '至少授予一项平台权限'; return Object.keys(errors).length ? errors : null; }, apply: () => { const existing = data.staff.find(item => item.id === values.id); const next = { id: values.id || entityId('STAFF'), authUserId: values.authUserId, username: values.username, name: values.name, email: values.email, phone: values.phone || '-', role: values.role, permissions, status: existing?.status || '正常', lastActive: existing?.lastActive || '尚未登录' }; if (existing) Object.assign(existing, next); else data.staff.unshift(next); addAudit('staff.upsert', 'staff', next.id, next.name, '高', `保存平台员工及 ${permissions.length} 项有效权限。`); }, success: '平台员工与有效权限已保存' }); + } + if (form.id === 'auditChannelForm') { + const values = Object.fromEntries(new FormData(form)); + const existing = data.auditChannels.find(item => item.id === values.id); + simulateWrite({ key: 'audit-channel', permission: 'platform:audit:notification', operationId: 'PlatformAdminAuditController_upsertChannel', body: () => ({ ...values, secretRef: existing?.secretRef, enabled: Boolean(new FormData(form).get('enabled')) }), validate: () => validateNotificationChannel(values), apply: () => { const next = { id: values.id || entityId('CH'), code: values.code, name: values.name, provider: values.provider, endpoint: values.endpoint, endpointRedacted: false, secretRef: existing?.secretRef || '', minSeverity: values.minSeverity, statusFilter: values.statusFilter, timeout: Number(values.timeout), maxAttempts: Number(values.maxAttempts), enabled: Boolean(new FormData(form).get('enabled')), lastStatus: existing?.lastStatus || '正常' }; if (existing) Object.assign(existing, next); else data.auditChannels.unshift(next); addAudit('audit.channel.upsert', 'notification_channel', next.id, next.name, '高', `保存审计告警渠道 ${next.code},secret 保持只写。`); }, success: '审计通知渠道已保存' }); + } +}); + +document.addEventListener('click', event => { + if (event.target.classList.contains('modal-backdrop')) { + closeModal(); + return; + } + const copy = event.target.closest('[data-copy]'); if (copy) { navigator.clipboard?.writeText(copy.dataset.copy); showToast('已复制到剪贴板'); return; } + const nav = event.target.closest('[data-nav]'); if (nav) { requestLeave(() => { state.selectedTenant = ''; navigate(nav.dataset.nav, { force: true }); }); return; } + const tenant = event.target.closest('[data-tenant-detail]'); if (tenant) { requestLeave(() => { state.selectedTenant = tenant.dataset.tenantDetail; navigate('tenantDetail', { force: true }); }); return; } + const invoice = event.target.closest('[data-invoice-detail]'); if (invoice) { if (requireDesktop('确认账单收款')) return; state.selectedInvoice = invoice.dataset.invoiceDetail; openModal('payment', { invoiceId: invoice.dataset.invoiceDetail }); return; } + const audit = event.target.closest('[data-audit-detail]'); if (audit) { openModal('auditDetail', { auditId: audit.dataset.auditDetail }); return; } + const alertDetail = event.target.closest('[data-alert-detail]'); if (alertDetail) { openModal('alertDetail', { alertId: alertDetail.dataset.alertDetail }); return; } + const notificationDetail = event.target.closest('[data-notification-detail]'); if (notificationDetail) { openModal('notificationDetail', { eventId: notificationDetail.dataset.notificationDetail }); return; } + const tf = event.target.closest('[data-tenant-filter]'); if (tf) { state.tenantFilter = tf.dataset.tenantFilter; syncNavigationState(); render(); return; } + const billingTab = event.target.closest('[data-billing-tab]'); if (billingTab) { state.billingTab = billingTab.dataset.billingTab; syncNavigationState(); render(); return; } + const bankTab = event.target.closest('[data-bank-tab]'); if (bankTab) { state.bankTab = bankTab.dataset.bankTab; syncNavigationState(); render(); return; } + const bankSelect = event.target.closest('[data-bank-select]'); if (bankSelect) { state.selectedBank = bankSelect.dataset.bankSelect; syncNavigationState(); render(); return; } + const auditSeverity = event.target.closest('[data-audit-severity]'); if (auditSeverity) { state.auditSeverity = auditSeverity.dataset.auditSeverity; syncNavigationState(); render(); return; } + const alertStatus = event.target.closest('[data-alert-status]'); if (alertStatus) { state.alertStatus = alertStatus.dataset.alertStatus; syncNavigationState(); render(); return; } + const actionElement = event.target.closest('[data-action]'); const action = actionElement?.dataset.action; if (!action) return; + if (action === 'close-modal') { closeModal(); return; } + if (action === 'execute-confirmed' || action === 'execute-staff-disable') { if (requireDesktop('执行高风险确认操作')) return; const execute = state.modalData.execute; closeModal({ force: true }); execute?.(); return; } + if (action === 'subpage-back') { navigateBack(); return; } + if (action === 'subpage-close') { closeSubpage(); return; } + if (action === 'retry-page') { state.scenario = 'normal'; scenarioSelect.value = 'normal'; render(); return; } + if (action === 'restore-session') { state.scenario = 'normal'; state.view = 'overview'; state.previousView = ''; state.history = []; scenarioSelect.value = 'normal'; render(); showToast('Mock 会话已恢复'); return; } + if (action === 'logout' && platformApi?.isEnabled()) { window.GongxuePlatformAuth?.logout(); return; } + if (action === 'logout') { closeModal(); state.scenario = 'session_invalid'; state.view = 'overview'; state.previousView = ''; state.history = []; scenarioSelect.value = 'session_invalid'; addAudit('auth.logout', 'application', 'platform-console', '平台管理控制台', '中', '超级管理员主动退出当前平台端会话。'); saveData(); render(); return; } + if (action === 'refresh') { + if (platformApi?.isEnabled()) { hydrateFromBackend(); return; } + state.scenario = 'loading'; scenarioSelect.value = 'loading'; render(); + setTimeout(() => { state.scenario = 'normal'; scenarioSelect.value = 'normal'; render(); showToast('平台静态演示数据已刷新'); }, 700); + return; + } + if (action === 'reset-mock') { if (requireDesktop('重置平台 Mock 数据')) return; confirmAction({ title: '重置平台 Mock 数据', description: '恢复到 v1.1 初始演示数据。', impact: '所有本地创建的租户、账单、任务、授权、员工修改和告警状态将被清除。', confirmLabel: '确认重置' }, () => { localStorage.removeItem(STORAGE_KEY); data = clone(seed); state.selectedTenant = data.tenants[0].id; saveData(); render(); showToast('平台 Mock 数据已重置'); }); return; } + if (action === 'open-task-center') { openModal('tasks'); return; } + if (action === 'create-tenant') { if (requireDesktop('创建租户')) return; openModal('createTenant'); return; } + if (action === 'prepare-tenant-status') { if (requireDesktop('变更租户生命周期')) return; openModal('tenantStatus', { tenantId: actionElement.dataset.tenantId || state.selectedTenant }); return; } + if (action === 'open-tenant-portal') { const tenant = tenantById(state.selectedTenant); let portalWindow = null; try { portalWindow = window.open(TENANT_PORTAL_ORIGIN, '_blank'); if (portalWindow) portalWindow.opener = null; } catch {} showToast(portalWindow ? `已在新标签打开独立租户端 · ${tenant?.name || '当前租户'}` : '已请求打开独立租户端;如未出现,请允许本站弹出窗口', portalWindow ? 'success' : 'error'); return; } + if (action === 'edit-billing-profile') { if (requireDesktop('编辑租户账务资料')) return; navigate('billingProfile'); return; } + if (action === 'billing-profile-to-invoices' || action === 'tenant-to-billing') { if (actionElement.dataset.tenantId) state.selectedTenant = actionElement.dataset.tenantId; navigate('billing'); return; } + if (action === 'tenant-to-usage') { state.selectedTenant = actionElement.dataset.tenantId; navigate('usage'); return; } + if (action === 'clear-tenant-context') { state.selectedTenant = ''; render(); return; } + if (action === 'create-subscription-for-tenant') { if (requireDesktop('创建租户订阅')) return; navigate('subscriptions'); openModal('createSubscription'); return; } + if (action === 'create-subscription') { if (requireDesktop('创建租户订阅')) return; openModal('createSubscription'); return; } + if (action === 'select-plan') { if (requireDesktop('选择套餐并创建订阅')) return; openModal('createSubscription', { planCode: actionElement.dataset.planCode }); return; } + if (action === 'subscription-to-billing') { navigate('billing'); return; } + if (action === 'subscriptions-to-usage') { navigate('usage'); return; } + if (action === 'toggle-auto-renew') { if (requireDesktop('变更订阅自动续费')) return; if (platformApi?.isEnabled()) { showToast('后端暂未提供订阅更新接口,真实模式禁止仅改本地状态', 'error'); return; } const item = data.subscriptions.find(subscription => subscription.id === actionElement.dataset.subscriptionId); if (!canWrite('platform:billing:write')) return; item.autoRenew = !item.autoRenew; addAudit('subscription.auto_renew.update', 'subscription', item.id, item.id, '中', `自动续费更新为 ${item.autoRenew ? '开启' : '关闭'}。`); saveData(); render(); showToast('自动续费设置已更新'); return; } + if (action === 'create-invoice') { if (requireDesktop('手工创建账单')) return; openModal('createInvoice'); return; } + if (action === 'apply-invoice-query') { runContractRead({ key: 'invoice-query', actionId: 'act.p-06.01', operationId: 'PlatformAdminBillingController_invoices', title: '账单查询', suppliedRequestId: actionElement.dataset.contractRequestId, liveRequest: () => platformApi.request('PlatformAdminBillingController_invoices', { query: { tenantId: state.selectedTenant || undefined, status: ({ 草稿: 'draft', 待付款: 'issued', 已支付: 'paid', 已作废: 'void', 已逾期: 'overdue' })[state.invoiceStatus], limit: 200 } }), apply: payload => { if (Array.isArray(payload?.items)) data.invoices = payload.items.map(platformApi.adapters.invoice); return `${data.invoices.filter(invoice => (!state.selectedTenant || invoice.tenantId === state.selectedTenant) && (state.invoiceStatus === 'all' || invoice.status === state.invoiceStatus) && (!state.invoiceSearch.trim() || [invoice.id, invoice.type, invoice.period, invoice.note, tenantById(invoice.tenantId)?.name].some(value => String(value || '').toLowerCase().includes(state.invoiceSearch.trim().toLowerCase())))).length} 张匹配账单`; } }); return; } + if (action === 'preview-invoice-candidates') { if (requireDesktop('批量生成账单')) return; openModal('invoiceCandidates'); return; } + if (action === 'run-invoice-dry-run' && platformApi?.isEnabled()) { const subscriptionIds = Array.from(overlayRoot.querySelectorAll('[name="candidate"]:checked')).map(input => input.value); executeBackendMutation('PlatformAdminBillingController_fromSubscriptionsBatch', { subscriptionIds, dryRun: true }, payload => { const result = payload?.item || {}; showToast(`Dry-run 完成 · 可生成 ${result.items?.length || 0} 条,跳过 ${result.skippedCount || 0} 条`); }, { refresh: false }); return; } + if (action === 'run-invoice-dry-run') { const count = overlayRoot.querySelectorAll('[name="candidate"]:checked').length; showToast(`Dry-run 已校验 ${count} 条订阅候选,未写入账单`); return; } + if (action === 'generate-subscription-invoice' && platformApi?.isEnabled()) { if (requireDesktop('为单个订阅生成账单')) return; const subscription = data.subscriptions.find(item => item.id === actionElement.dataset.subscriptionId); if (!subscription) { showToast('订阅不存在或不可用', 'error'); return; } executeBackendMutation('PlatformAdminBillingController_fromSubscription', { tenantId: subscription.tenantId, subscriptionId: subscription.id, status: 'issued' }, payload => { closeModal({ force: true }); window.recordPlatformSpecResult?.('act.p-06.04', { result: 'success', operationId: 'PlatformAdminBillingController_fromSubscription', requestId: payload?.meta?.requestId || '' }); showToast('订阅账单已由后端生成'); }); return; } + if (action === 'generate-subscription-invoice') { if (requireDesktop('为单个订阅生成账单')) return; const subscription = data.subscriptions.find(item => item.id === actionElement.dataset.subscriptionId); const suppliedRequestId = actionElement.dataset.contractRequestId || requestId(); if (!subscription || data.invoices.some(invoice => invoice.note.includes(subscription.id))) { state.contractResults['invoice-query'] = { status: 'error', title: '单订阅生成失败', detail: '该订阅已存在账单或订阅不可用', requestId: suppliedRequestId }; addAudit('invoice.from_subscription.failed', 'subscription', subscription?.id || '-', subscription?.id || '-', '中', `幂等冲突。Request ID:${suppliedRequestId}。`, '失败'); window.recordPlatformSpecResult?.('act.p-06.04', { result: 'failed', operationId: 'PlatformAdminBillingController_fromSubscription', requestId: suppliedRequestId }); render(); return; } const task = addContractTask({ actionId: 'act.p-06.04', operationId: 'PlatformAdminBillingController_fromSubscription', label: `为订阅 ${subscription.id} 生成账单`, resultTarget: '服务费账单', suppliedRequestId, execute: taskItem => { const invoice = { id: entityId('INV'), tenantId: subscription.tenantId, type: '订阅服务费', period: '下一订阅账期', total: subscription.amount, paid: 0, balance: subscription.amount, due: subscription.expires, status: '待付款', note: `订阅 ${subscription.id}` }; data.invoices.unshift(invoice); tenantById(subscription.tenantId).balance += invoice.balance; taskItem.invoiceId = invoice.id; addAudit('invoice.from_subscription.create', 'invoice', invoice.id, invoice.id, '高', `通过任务 ${taskItem.id} 为订阅 ${subscription.id} 生成单张账单。`); } }); showToast(`单订阅账单任务 ${task.id} 已创建`); return; } + if (action === 'confirm-generate-candidates' && platformApi?.isEnabled()) { if (requireDesktop('批量生成账单')) return; const subscriptionIds = Array.from(overlayRoot.querySelectorAll('[name="candidate"]:checked')).map(input => input.value); if (!subscriptionIds.length) { showToast('请至少选择一条订阅账单候选', 'error'); return; } executeBackendMutation('PlatformAdminBillingController_fromSubscriptionsBatch', { subscriptionIds, dryRun: false, status: 'issued' }, payload => { closeModal({ force: true }); const result = payload?.item || {}; showToast(`后端已生成 ${result.createdCount || 0} 张账单,跳过 ${result.skippedCount || 0} 条`); }); return; } + if (action === 'confirm-generate-candidates') { if (requireDesktop('批量生成账单')) return; const selectedIds = Array.from(overlayRoot.querySelectorAll('[name="candidate"]:checked')).map(input => input.value); if (!selectedIds.length) { showToast('请至少选择一条订阅账单候选', 'error'); return; } const candidates = data.subscriptions.filter(subscription => selectedIds.includes(subscription.id) && !data.invoices.some(invoice => invoice.note.includes(subscription.id))); closeModal(); const task = addTask(`批量生成 ${candidates.length} 张订阅账单`, '服务费账单', () => { candidates.forEach(subscription => { const invoice = { id: entityId('INV'), tenantId: subscription.tenantId, type: '订阅服务费', period: '下一订阅账期', total: subscription.amount, paid: 0, balance: subscription.amount, due: subscription.expires, status: '待付款', note: `订阅 ${subscription.id}` }; data.invoices.unshift(invoice); const tenant = tenantById(subscription.tenantId); tenant.balance += invoice.balance; tenant.billing = '待付款'; }); addAudit('invoice.batch.create', 'invoice_batch', task.id, task.id, '高', `批量生成 ${candidates.length} 张订阅账单。`); }); showToast(`任务 ${task.id} 已创建`); return; } + if (action === 'confirm-payment') { if (requireDesktop('确认账单收款')) return; openModal('payment', { invoiceId: actionElement.dataset.invoiceId }); return; } + if (action === 'preview-overdue' && platformApi?.isEnabled()) { if (requireDesktop('批量处理逾期账单')) return; executeBackendMutation('PlatformAdminBillingController_processOverdue', { dryRun: true, limit: 100, channel: 'internal' }, payload => { openModal('overduePreview'); showToast(`后端 Dry-run:${payload?.item?.processed || payload?.item?.items?.length || 0} 条候选`); }, { refresh: false }); return; } + if (action === 'preview-overdue') { if (requireDesktop('批量处理逾期账单')) return; openModal('overduePreview'); return; } + if (action === 'confirm-overdue-process' && platformApi?.isEnabled()) { showToast('后端逾期处理接口不支持指定 invoiceIds,真实模式禁止按当前勾选直接执行', 'error'); return; } + if (action === 'confirm-overdue-process') { if (requireDesktop('批量处理逾期账单')) return; const selectedIds = Array.from(overlayRoot.querySelectorAll('[name="candidate"]:checked')).map(input => input.value); if (!selectedIds.length) { showToast('请至少选择一条逾期账单候选', 'error'); return; } const overdue = data.invoices.filter(invoice => invoice.status === '已逾期' && selectedIds.includes(invoice.id)); closeModal(); const task = addTask(`处理 ${overdue.length} 张逾期账单`, '收款与催缴', () => { overdue.forEach(invoice => data.reminders.unshift({ id: entityId('REM'), invoiceId: invoice.id, tenantId: invoice.tenantId, level: 3, channel: '邮件 + 短信', status: '发送中', date: todayTime(), message: `服务费账单 ${invoice.id} 已逾期,请尽快处理。` })); addAudit('invoice.overdue.process', 'invoice_batch', task.id, task.id, '高', `生成 ${overdue.length} 条催缴记录。`); }); showToast(`催缴任务 ${task.id} 已创建`); return; } + if (action === 'generate-reminder' && platformApi?.isEnabled()) { showToast('后端仅提供批量逾期处理,没有单账单催缴创建接口', 'error'); return; } + if (action === 'generate-reminder') { if (requireDesktop('生成账单催缴')) return; const invoice = invoiceById(actionElement.dataset.invoiceId); const reminder = { id: entityId('REM'), invoiceId: invoice.id, tenantId: invoice.tenantId, level: invoice.status === '已逾期' ? 3 : 1, channel: '邮件 + 短信', status: '已加入队列', date: todayTime(), message: invoice.status === '已逾期' ? `账单已逾期,请尽快支付 ${money(invoice.balance)}。` : `账单即将到期,应付 ${money(invoice.balance)}。` }; data.reminders.unshift(reminder); addAudit('invoice.reminder.create', 'reminder', reminder.id, reminder.id, '中', `为 ${invoice.id} 创建 L${reminder.level} 催缴。`); saveData(); render(); showToast('催缴已加入发送队列'); return; } + if (action === 'record-usage') { if (requireDesktop('人工记录平台用量')) return; openModal('recordUsage'); return; } + if (action === 'preview-overage') { if (requireDesktop('批量生成超额账单')) return; openModal('overagePreview'); return; } + if (action === 'run-overage-dry-run' && platformApi?.isEnabled()) { const tenantIds = Array.from(overlayRoot.querySelectorAll('[name="candidate"]:checked')).map(input => input.value.split(':')[0]); executeBackendMutation('PlatformAdminBillingController_fromUsage', { tenantIds, period: state.usagePeriod, dryRun: true }, payload => { const result = payload?.item || {}; showToast(`超额 Dry-run 完成 · 可生成 ${result.items?.length || 0} 条`); }, { refresh: false }); return; } + if (action === 'run-overage-dry-run') { const count = overlayRoot.querySelectorAll('[name="candidate"]:checked').length; showToast(`Dry-run 已校验 ${count} 条超额候选,未写入账单`); return; } + if (action === 'confirm-overage-invoices' && platformApi?.isEnabled()) { if (requireDesktop('批量生成超额账单')) return; const tenantIds = Array.from(overlayRoot.querySelectorAll('[name="candidate"]:checked')).map(input => input.value.split(':')[0]); if (!tenantIds.length) { showToast('请至少选择一条超额账单候选', 'error'); return; } executeBackendMutation('PlatformAdminBillingController_fromUsage', { tenantIds, period: state.usagePeriod, dryRun: false, status: 'issued' }, payload => { closeModal({ force: true }); showToast(`后端已生成 ${payload?.item?.createdCount || 0} 张超额账单`); }); return; } + if (action === 'confirm-overage-invoices') { if (requireDesktop('批量生成超额账单')) return; const selectedKeys = Array.from(overlayRoot.querySelectorAll('[name="candidate"]:checked')).map(input => input.value); if (!selectedKeys.length) { showToast('请至少选择一条超额账单候选', 'error'); return; } const candidates = data.usage.filter(item => item.period === state.usagePeriod && selectedKeys.includes(`${item.tenantId}:${item.period}`) && item.estimated > 0 && !data.invoices.some(invoice => invoice.note === `超额 ${item.period}` && invoice.tenantId === item.tenantId)); closeModal(); const task = addTask(`生成 ${candidates.length} 张超额账单`, '用量与超额计费', () => { candidates.forEach(item => { const invoice = { id: entityId('INV'), tenantId: item.tenantId, type: '用量超额费', period: item.period, total: item.estimated, paid: 0, balance: item.estimated, due: '2026-08-15', status: '待付款', note: `超额 ${item.period}` }; data.invoices.unshift(invoice); const tenant = tenantById(item.tenantId); tenant.balance += invoice.balance; tenant.billing = '待付款'; }); addAudit('usage.invoice.batch.create', 'invoice_batch', task.id, task.id, '高', `生成 ${candidates.length} 张用量超额账单。`); }); showToast(`超额账单任务 ${task.id} 已创建`); return; } + if (action === 'new-dunning-channel') { if (requireDesktop('新建催缴渠道')) return; openModal('dunningChannel'); return; } + if (action === 'edit-dunning-channel') { if (requireDesktop('编辑催缴渠道')) return; openModal('dunningChannel', { channelId: actionElement.dataset.channelId }); return; } + if (action === 'toggle-dunning-channel') { if (requireDesktop('变更催缴渠道状态')) return; if (!canWrite('platform:billing:notification')) return; const channel = data.dunningChannels.find(item => item.id === actionElement.dataset.channelId); if (!channel) return; if (platformApi?.isEnabled() && channel.endpointRedacted) { openModal('dunningChannel', { channelId: channel.id }); showToast('后端不回显完整 Webhook;请重新输入完整 HTTPS URL 后保存状态', 'error'); return; } const nextEnabled = !channel.enabled; const apply = () => { channel.enabled = nextEnabled; addAudit('dunning.channel.status', 'notification_channel', channel.id, channel.name, '高', `催缴渠道${channel.enabled ? '启用' : '停用'}。`); saveData(); render(); showToast('渠道状态已更新'); }; if (platformApi?.isEnabled()) executeBackendMutation('PlatformAdminDunningChannelsController_upsertChannel', { ...channel, enabled: nextEnabled }, apply); else apply(); return; } + if (action === 'new-grant') { if (requireDesktop('新建公共题库授权')) return; openModal('grant'); return; } + if (action === 'apply-bank-search') { runContractRead({ key: 'bank-query', actionId: 'act.p-09.01', operationId: 'PlatformAdminQuestionBanksController_banks', title: '题库搜索', suppliedRequestId: actionElement.dataset.contractRequestId, liveRequest: () => platformApi.request('PlatformAdminQuestionBanksController_banks', { query: { q: state.bankSearch || undefined, status: 'active', includeTenantBanks: state.bankIncludeTenant, limit: 200 } }), apply: payload => { if (Array.isArray(payload?.items)) data.banks = payload.items.map(item => platformApi.adapters.bank(item, bankById(item.id))); return `${data.banks.filter(bank => !state.bankSearch.trim() || [bank.name, bank.catalog, bank.source, bank.version].some(value => String(value).toLowerCase().includes(state.bankSearch.trim().toLowerCase()))).length} 个匹配题库`; } }); return; } + if (action === 'edit-grant') { if (requireDesktop('编辑公共题库授权')) return; openModal('grant', { grantId: actionElement.dataset.grantId }); return; } + if (action === 'toggle-grant') { if (requireDesktop('变更公共题库授权状态')) return; if (!canWrite('platform:question_bank:grant')) return; const grant = data.grants.find(item => item.id === actionElement.dataset.grantId); if (!grant) return; const next = grant.status === '启用' ? '已禁用' : '启用'; const apply = () => { grant.status = next; addAudit('bank.grant.status', 'grant', grant.id, bankById(grant.bankId)?.name || grant.bankId, '高', `授权规则状态更新为 ${next},租户快照保留。`); saveData(); render(); showToast('授权状态已更新'); }; const execute = () => platformApi?.isEnabled() ? executeBackendMutation('PlatformAdminQuestionBanksController_upsertGrant', { ...grant, status: next }, apply) : apply(); if (next === '已禁用') confirmAction({ title: '禁用公共题库授权', description: `${bankById(grant.bankId)?.name || grant.bankId} · ${grant.id}`, impact: '后续同步将停止,但租户已经采用的内容快照不会被删除。', confirmLabel: '确认禁用' }, execute); else execute(); return; } + if (action === 'preview-bank-sync') { state.bankTab = 'sync'; if (platformApi?.isEnabled()) { runContractRead({ key: 'bank-sync', actionId: 'act.p-09.05', operationId: 'PlatformAdminQuestionBanksController_syncStatus', title: '采用同步状态', suppliedRequestId: actionElement.dataset.contractRequestId, liveRequest: () => platformApi.request('PlatformAdminQuestionBanksController_syncStatus', { query: { limit: 200 } }), apply: payload => { if (Array.isArray(payload?.items)) data.bankSync = payload.items.map(platformApi.adapters.sync); return `${data.bankSync.length} 条采用记录,${data.bankSync.filter(item => item.status === '同步失败').length} 条失败`; } }); return; } const task = addContractTask({ actionId: 'act.p-09.05', operationId: 'PlatformAdminQuestionBanksController_syncStatus', label: '检查租户题库采用与同步状态', resultTarget: '公共题库 · 采用与同步', suppliedRequestId: actionElement.dataset.contractRequestId, execute: taskItem => { taskItem.rows = data.bankSync.length; taskItem.failedRows = data.bankSync.filter(item => item.status === '同步失败').length; } }); render(); showToast(`同步状态检查任务 ${task.id} 已创建`); return; } + if (action === 'refresh-bank-sync') { if (state.modal === 'syncDetail') closeModal({ force: true }); runContractRead({ key: 'bank-sync', operationId: 'PlatformAdminQuestionBanksController_syncStatus', title: '采用同步状态', liveRequest: () => platformApi.request('PlatformAdminQuestionBanksController_syncStatus', { query: { limit: 200 } }), apply: payload => { if (Array.isArray(payload?.items)) data.bankSync = payload.items.map(platformApi.adapters.sync); return `${data.bankSync.length} 条采用记录,${data.bankSync.filter(item => item.status === '同步失败').length} 条失败`; } }); return; } + if (action === 'sync-detail') { openModal('syncDetail', { syncKey: actionElement.dataset.syncKey }); return; } + if (action === 'new-staff') { if (requireDesktop('新建平台员工与授权')) return; openModal('staff', { roleKey: actionElement.dataset.roleKey || '' }); return; } + if (action === 'edit-staff') { if (requireDesktop('编辑平台员工与授权')) return; openModal('staff', { staffId: actionElement.dataset.staffId }); return; } + if (action === 'toggle-staff') { if (requireDesktop('变更平台员工状态')) return; if (!canWrite('platform:staff:status')) return; const person = data.staff.find(item => item.id === actionElement.dataset.staffId); if (!person) return; const disabling = person.status === '正常'; if (disabling && person.role === '超级管理员' && data.staff.filter(item => item.role === '超级管理员' && item.status === '正常').length === 1) { showToast('不能禁用最后一名超级管理员', 'error'); return; } const apply = () => { person.status = disabling ? '已停用' : '正常'; addAudit('staff.status.update', 'staff', person.id, person.name, '高', `${disabling ? '停用并撤销会话' : '启用'}平台员工。`); saveData(); render(); showToast(`员工已${disabling ? '停用并撤销会话' : '启用'}`); }; const execute = () => platformApi?.isEnabled() ? executeBackendMutation('PlatformAdminOverviewController_updateStaffStatus', { staffId: person.id, status: disabling ? 'disabled' : 'active', reason: disabling ? '平台控制台停用员工' : '平台控制台启用员工', revokeSessions: disabling }, apply) : apply(); if (disabling) confirmAction({ title: '停用平台员工', description: `${person.name} · ${person.role}`, impact: '该员工的全部平台会话将立即撤销,后续无法登录平台端;租户端权限不在本域名内处理。', confirmLabel: '停用并撤销会话', confirmActionName: 'execute-staff-disable' }, execute); else execute(); return; } + if (action === 'select-permission-group') { const form = overlayRoot.querySelector('#staffForm'); const role = fieldValue(form, 'role'); const recommended = role === '平台财务' ? ['platform:billing:read', 'platform:billing:write', 'platform:billing:payment', 'platform:billing:dunning', 'platform:billing:notification', 'platform:usage:read', 'platform:usage:write'] : role === '安全审计' ? ['platform:audit:read', 'platform:audit:export', 'platform:audit:alert', 'platform:audit:notification'] : role === '题库运营' ? ['platform:question_bank:read', 'platform:question_bank:grant', 'platform:question_bank:ops'] : role === '租户运营' ? ['platform:tenant:read', 'platform:tenant:write', 'platform:tenant:status', 'platform:tenant:billing_profile'] : Array.from(form.querySelectorAll('[name="permissions"]')).map(input => input.value); form.querySelectorAll('[name="permissions"]').forEach(input => { input.checked = recommended.includes(input.value); }); return; } + if (action === 'clear-staff-form') { const form = overlayRoot.querySelector('#staffForm'); if (!form) return; form.querySelectorAll('input:not([type="hidden"]):not([type="checkbox"])').forEach(input => { input.value = ''; }); form.querySelectorAll('input[type="checkbox"]').forEach(input => { input.checked = false; }); form.elements.role.value = '租户运营'; state.modalData.dirty = true; form.querySelector('[name="authUserId"]')?.focus(); return; } + if (action === 'apply-staff-search') { render(); return; } + if (action === 'export-audit') { if (!hasPermission('platform:audit:export')) { showToast('缺少导出权限:platform:audit:export', 'error'); return; } const format = actionElement.dataset.format || 'csv'; if (platformApi?.isEnabled()) { platformApi.request('PlatformAdminAuditController_exportLogs', { query: { format, q: state.auditSearch || undefined, limit: 500 } }).then(payload => { const item = platformApi.downloadExport(payload); showToast(`审计文件已下载 · ${item.rowCount ?? item.rows ?? 0} 行 · ${item.sha256 || item.hash || 'hash 见响应'}`); }).catch(error => showToast(`${error.message || '审计导出失败'}${error.requestId ? ` · ${error.requestId}` : ''}`, 'error')); return; } const task = addTask(`导出平台审计日志 ${format.toUpperCase()}`, '任务中心下载文件', taskItem => { taskItem.file = `platform-audit-${Date.now()}.${format}`; taskItem.hash = `sha256:${Math.random().toString(36).slice(2).padEnd(32, '0')}`; taskItem.rows = data.auditLogs.length; }); showToast(`导出任务 ${task.id} 已创建`); return; } + if (action === 'jump-related-alert') { state.alertStatus = 'all'; state.selectedAlert = data.alerts.find(alert => data.auditLogs.some(log => log.targetId === alert.targetId || log.targetId === alert.id))?.id || ''; navigate('alerts'); if (state.selectedAlert) openModal('alertDetail', { alertId: state.selectedAlert }); return; } + if (action === 'jump-audit-target') { const log = data.auditLogs.find(item => item.id === actionElement.dataset.auditId); closeModal(); if (log.targetType === 'tenant') { state.selectedTenant = log.targetId; navigate('tenantDetail'); } else if (log.targetType === 'invoice') { state.selectedInvoice = log.targetId; navigate('billing'); } else if (log.targetType === 'question_bank' || log.targetType === 'grant') navigate('bank'); else navigate('alerts'); return; } + if (action === 'transition-alert') { if (!canWrite('platform:audit:alert')) return; const alert = data.alerts.find(item => item.id === actionElement.dataset.alertId); const next = actionElement.dataset.nextStatus; if (!alert) return; if (!['open', 'acknowledged'].includes(alert.status)) { showToast('已结束告警禁止重新打开或再次处理', 'error'); return; } if (alert.status === 'open' && !['acknowledged', 'resolved', 'ignored'].includes(next)) return; if (alert.status === 'acknowledged' && !['resolved', 'ignored'].includes(next)) return; const note = document.querySelector('#alertNote')?.value.trim() || ''; const apply = () => { alert.status = next; alert.note = note; alert.updatedAt = '刚刚'; addAudit('audit.alert.status.update', 'audit_alert', alert.id, alert.title, '高', `告警状态更新为 ${alertStatusLabel(next)}${note ? `,备注:${note}` : ''}。`); clearDirty('modal'); saveData(); closeModal({ force: true }); render(); showToast(`告警已${alertStatusLabel(next)}`); }; if (platformApi?.isEnabled()) executeBackendMutation('PlatformAdminAuditController_updateAlert', { alertId: alert.id, status: next, note }, apply); else apply(); return; } + if (action === 'new-audit-channel') { if (requireDesktop('新建审计通知渠道')) return; openModal('auditChannel'); return; } + if (action === 'edit-audit-channel') { if (requireDesktop('编辑审计通知渠道')) return; openModal('auditChannel', { channelId: actionElement.dataset.channelId }); return; } + if (action === 'toggle-audit-channel') { if (requireDesktop('变更审计通知渠道状态')) return; if (!canWrite('platform:audit:notification')) return; const channel = data.auditChannels.find(item => item.id === actionElement.dataset.channelId); if (!channel) return; if (platformApi?.isEnabled() && channel.endpointRedacted) { openModal('auditChannel', { channelId: channel.id }); showToast('后端不回显完整 Webhook;请重新输入完整 HTTPS URL 后保存状态', 'error'); return; } const nextEnabled = !channel.enabled; const apply = () => { channel.enabled = nextEnabled; addAudit('audit.channel.status', 'notification_channel', channel.id, channel.name, '高', `审计告警渠道${channel.enabled ? '启用' : '停用'}。`); saveData(); render(); showToast('审计通知渠道状态已更新'); }; if (platformApi?.isEnabled()) executeBackendMutation('PlatformAdminAuditController_upsertChannel', { ...channel, enabled: nextEnabled }, apply); else apply(); return; } + if (action === 'retry-notification') { if (platformApi?.isEnabled()) { showToast('后端未提供通知事件重试接口,真实模式禁止伪造成功状态', 'error'); return; } const notification = [...(data.notificationEvents || []), ...(data.dunningEvents || [])].find(item => item.id === actionElement.dataset.eventId); if (!notification) return; notification.status = '重试中'; notification.attempts += 1; notification.updatedAt = `${todayTime()} CST`; saveData(); render(); setTimeout(() => { notification.status = '成功'; notification.error = ''; saveData(); render(); showToast('通知重试成功'); }, 800); return; } + if (action === 'retry-task') { const task = data.tasks.find(item => item.id === actionElement.dataset.taskId); task.status = 'processing'; task.error = ''; task.updatedAt = todayTime(); saveData(); renderOverlay(); setTimeout(() => { task.status = 'completed'; task.updatedAt = todayTime(); saveData(); renderOverlay(); updateCounters(); showToast('任务重试完成'); }, 900); return; } +}); + +document.addEventListener('input', event => { + if (state.modal && event.target.closest('#overlayRoot') && event.target.matches('input,select,textarea')) markDirty('modal'); + if (event.target.closest('#mainView form')) markDirty('page'); + if (event.target.id === 'tenantSearch') { state.tenantSearch = event.target.value; clearTimeout(state.searchTimer); state.searchTimer = setTimeout(render, 180); } + if (event.target.id === 'invoiceSearch') state.invoiceSearch = event.target.value; + if (event.target.id === 'bankSearch') state.bankSearch = event.target.value; + if (event.target.id === 'staffSearch') { state.staffSearch = event.target.value; clearTimeout(state.staffTimer); state.staffTimer = setTimeout(render, 180); } + if (event.target.id === 'auditSearch') { state.auditSearch = event.target.value; clearTimeout(state.auditTimer); state.auditTimer = setTimeout(render, 180); } + if (event.target.id === 'globalSearch') state.globalSearch = event.target.value; + if (['tenantSearch', 'invoiceSearch', 'bankSearch', 'staffSearch', 'auditSearch'].includes(event.target.id)) syncNavigationState(); +}); +document.addEventListener('change', event => { + if (state.modal && event.target.closest('#overlayRoot') && event.target.matches('input,select,textarea')) markDirty('modal'); + if (event.target.closest('#mainView form')) markDirty('page'); + if (event.target === roleSelect) { state.role = event.target.value; state.scenario = 'normal'; if (!hasPermission(pageMeta[state.view].permission)) state.view = 'overview'; render(); showToast(`已切换为${roleDefinitions[state.role].label}`); } + if (event.target === scenarioSelect) { state.scenario = event.target.value; render(); } + if (event.target === responseSelect) { state.response = event.target.value; showToast('下一次 Mock 写请求结果已设置'); } + if (event.target.id === 'tenantPlanFilter') { state.tenantPlan = event.target.value; syncNavigationState(); render(); } + if (event.target.id === 'invoiceStatusFilter') { state.invoiceStatus = event.target.value; syncNavigationState(); } + if (event.target.id === 'usagePeriodFilter') { state.usagePeriod = event.target.value; syncNavigationState(); render(); } + if (event.target.id === 'auditRangeFilter') { state.auditRange = event.target.value; syncNavigationState(); render(); } + if (event.target.id === 'includeTenantBanks') { + state.bankIncludeTenant = event.target.checked; + syncNavigationState(); + render(); + if (platformApi?.isEnabled()) hydrateFromBackend({ silent: true }); + } +}); +menuButton.addEventListener('click', toggleMobileMenu); +document.querySelector('#sidebarBackdrop').addEventListener('click', closeMobileMenu); +document.addEventListener('keydown', event => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { event.preventDefault(); document.querySelector('#globalSearch').focus(); } + if (event.key === 'Enter' && event.target.id === 'globalSearch') { + event.preventDefault(); + const query = event.target.value.trim().toLowerCase(); + if (!query) { showToast('请输入租户、账单、告警或审计关键词', 'error'); return; } + const tenant = data.tenants.find(item => [item.name, item.slug, item.legal, item.host].some(value => value.toLowerCase().includes(query))); + if (tenant) { state.selectedTenant = tenant.id; navigate('tenantDetail'); showToast(`已定位租户:${tenant.name}`); return; } + const invoice = data.invoices.find(item => [item.id, item.type, item.period, item.note].some(value => String(value).toLowerCase().includes(query))); + if (invoice) { state.selectedTenant = invoice.tenantId; state.selectedInvoice = invoice.id; navigate('billing'); showToast(`已定位账单:${invoice.id}`); return; } + const alert = data.alerts.find(item => [item.id, item.title, item.summary].some(value => value.toLowerCase().includes(query))); + if (alert) { state.selectedAlert = alert.id; navigate('alerts'); openModal('alertDetail', { alertId: alert.id }); return; } + const audit = data.auditLogs.find(item => [item.id, item.actor, item.action, item.target, item.details].some(value => value.toLowerCase().includes(query))); + if (audit) { navigate('audit'); openModal('auditDetail', { auditId: audit.id }); return; } + showToast('没有找到匹配的租户、账单、告警或审计事件', 'error'); + } + if (event.key === 'Tab' && state.modal) { + const modal = overlayRoot.querySelector('.modal'); + const focusable = modal ? Array.from(modal.querySelectorAll('button:not([disabled]),input:not([disabled]):not([type="hidden"]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])')).filter(element => element.offsetParent !== null) : []; + if (focusable.length) { + const first = focusable[0]; const last = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } + else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } + } + } + if (event.key === 'Escape') { if (state.modal) closeModal(); else if (app.classList.contains('menu-open')) closeMobileMenu(); else if (pageMeta[state.view]?.level > 1) navigateBack(); } +}); +document.addEventListener('click', event => { + const choice = event.target.closest('[data-leave-choice]')?.dataset.leaveChoice; + if (!choice) return; + event.preventDefault(); event.stopImmediatePropagation(); + if (choice === 'stay') { pendingLeave = null; closeLeaveGuard(); return; } + const leave = pendingLeave; pendingLeave = null; clearDirty(leave?.scope || ''); closeLeaveGuard(); leave?.execute?.(); +}, true); +window.addEventListener('beforeunload', event => { if (!hasUnsavedChanges()) return; event.preventDefault(); event.returnValue = ''; }); +window.addEventListener('DOMContentLoaded', async () => { + if (platformApi?.isEnabled()) { + const authenticated = await window.GongxuePlatformAuth?.requireSession(); + if (!authenticated) return; + document.documentElement.dataset.platformMode = 'api'; + } + setupPrototypeControls(); + if (platformApi?.isEnabled()) document.querySelector('.prototype-controls')?.setAttribute('hidden', ''); + render(); + const rootSnapshot = navigationSnapshot(); + history.replaceState({ [HISTORY_APP_KEY]: true, rootGuard: true, sequence: historySequence, snapshot: rootSnapshot }, '', location.href); + historySequence += 1; + history.pushState({ [HISTORY_APP_KEY]: true, sequence: historySequence, snapshot: rootSnapshot }, '', location.href); + if (platformApi?.isEnabled()) hydrateFromBackend({ silent: true }); +}); diff --git a/Tiku.Api/wwwroot/platform-admin/assets/logo.png b/Tiku.Api/wwwroot/platform-admin/assets/logo.png new file mode 100644 index 0000000..3288dae Binary files /dev/null and b/Tiku.Api/wwwroot/platform-admin/assets/logo.png differ diff --git a/Tiku.Api/wwwroot/platform-admin/index.html b/Tiku.Api/wwwroot/platform-admin/index.html new file mode 100644 index 0000000..fe50e8c --- /dev/null +++ b/Tiku.Api/wwwroot/platform-admin/index.html @@ -0,0 +1,35 @@ + + + + + + + 恭学题库 SaaS · 平台管理 + + + + + + + + + + + +
+ +
+
PLATFORM / PROD平台经营工作台
所有系统正常
+
+
+
+ +
+
+ + diff --git a/Tiku.Api/wwwroot/platform-admin/platform-api.js b/Tiku.Api/wwwroot/platform-admin/platform-api.js new file mode 100644 index 0000000..7387306 --- /dev/null +++ b/Tiku.Api/wwwroot/platform-admin/platform-api.js @@ -0,0 +1,886 @@ +(() => { + const defaults = { + mode: 'mock', + apiBaseUrl: '', + fallbackToMock: true, + timeoutMs: 10000, + getAccessToken: null, + }; + const runtime = { ...defaults, ...(window.GONGXUE_PLATFORM_RUNTIME_CONFIG || {}) }; + const operationCatalog = Object.fromEntries( + (window.GONGXUE_PLATFORM_SPEC?.operations || []).map(operation => [operation.operationId, operation]), + ); + const trace = []; + + class PlatformApiError extends Error { + constructor(message, options = {}) { + super(message); + this.name = 'PlatformApiError'; + this.status = Number(options.status || 0); + this.code = options.code || 'PLATFORM_API_ERROR'; + this.requestId = options.requestId || ''; + this.operationId = options.operationId || ''; + this.details = options.details; + } + } + + function normalizeBaseUrl(value) { + return String(value || '').trim().replace(/\/+$/, ''); + } + + function isEnabled() { + return runtime.mode === 'api' || (runtime.mode === 'auto' && Boolean(normalizeBaseUrl(runtime.apiBaseUrl))); + } + + function publicConfig() { + return { + mode: runtime.mode, + apiBaseUrl: normalizeBaseUrl(runtime.apiBaseUrl), + fallbackToMock: runtime.fallbackToMock !== false, + timeoutMs: Number(runtime.timeoutMs) || defaults.timeoutMs, + configured: isEnabled(), + }; + } + + async function accessToken() { + const provider = typeof runtime.getAccessToken === 'function' + ? runtime.getAccessToken + : typeof window.GONGXUE_PLATFORM_ACCESS_TOKEN_PROVIDER === 'function' + ? window.GONGXUE_PLATFORM_ACCESS_TOKEN_PROVIDER + : null; + const token = provider ? await provider() : ''; + return String(token || '').trim(); + } + + function buildUrl(path, query = {}) { + const baseUrl = normalizeBaseUrl(runtime.apiBaseUrl); + const url = new URL(`${baseUrl}${path.startsWith('/') ? path : `/${path}`}`, 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)); + }); + return url.toString(); + } + + function responseRequestId(payload, response) { + return String( + payload?.meta?.requestId + || payload?.requestId + || response?.headers?.get?.('x-request-id') + || '', + ); + } + + async function request(operationId, options = {}) { + const operation = operationCatalog[operationId]; + if (!operation) { + throw new PlatformApiError(`未知平台接口:${operationId}`, { + code: 'PLATFORM_OPERATION_UNKNOWN', + operationId, + }); + } + if (!isEnabled()) { + throw new PlatformApiError('平台 API 未启用', { + code: 'PLATFORM_API_DISABLED', + operationId, + }); + } + const token = await accessToken(); + if (!token) { + throw new PlatformApiError('缺少平台管理员 access token', { + status: 401, + code: 'PLATFORM_ACCESS_TOKEN_MISSING', + operationId, + }); + } + const extraHeaders = Object.fromEntries(Object.entries(options.headers || {}).map(([key, value]) => [key, String(value)])); + const forbiddenHeader = Object.keys(extraHeaders).find((key) => { + const normalized = key.toLowerCase(); + return ['authorization', 'x-user-id', 'x-platform-admin-key'].includes(normalized) + || normalized.startsWith('x-tenant-'); + }); + if (forbiddenHeader) { + throw new PlatformApiError(`禁止页面覆盖身份头:${forbiddenHeader}`, { + code: 'PLATFORM_IDENTITY_HEADER_FORBIDDEN', + operationId, + }); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), Number(runtime.timeoutMs) || defaults.timeoutMs); + const startedAt = Date.now(); + if (options.signal) { + if (options.signal.aborted) controller.abort(); + else options.signal.addEventListener('abort', () => controller.abort(), { once: true }); + } + try { + const response = await fetch(buildUrl(operation.path, options.query), { + method: operation.method, + headers: { + ...extraHeaders, + Accept: 'application/json', + Authorization: `Bearer ${token}`, + ...(operation.method === 'GET' ? {} : { 'Content-Type': 'application/json' }), + }, + body: operation.method === 'GET' || options.body === undefined + ? undefined + : JSON.stringify(options.body), + credentials: 'omit', + signal: controller.signal, + }); + const text = await response.text(); + let payload = {}; + if (text) { + try { + payload = JSON.parse(text); + } catch { + payload = { error: text.slice(0, 500) }; + } + } + const requestId = responseRequestId(payload, response); + trace.push({ + operationId, + method: operation.method, + path: operation.path, + status: response.status, + requestId, + durationMs: Date.now() - startedAt, + at: new Date().toISOString(), + }); + if (trace.length > 120) trace.splice(0, trace.length - 120); + if (!response.ok) { + throw new PlatformApiError( + String(payload.message || payload.error || `请求失败(HTTP ${response.status})`), + { + status: response.status, + code: String(payload.code || 'PLATFORM_API_REQUEST_FAILED'), + requestId, + operationId, + details: payload, + }, + ); + } + return payload; + } catch (error) { + if (error instanceof PlatformApiError) throw error; + const aborted = error?.name === 'AbortError'; + throw new PlatformApiError(aborted ? '平台接口请求超时' : (error?.message || '平台接口请求失败'), { + code: aborted ? 'PLATFORM_API_TIMEOUT' : 'PLATFORM_API_NETWORK_ERROR', + operationId, + details: error, + }); + } finally { + clearTimeout(timeout); + } + } + + const dictionaries = { + tenantStatusToApi: { 待配置: 'draft', 试用: 'active', 正常: 'active', 暂停: 'suspended', 已归档: 'archived' }, + tenantStatusFromApi: { draft: '待配置', active: '正常', suspended: '暂停', archived: '已归档' }, + billingStatusToApi: { 试用: 'trial', 正常: 'active', 待付款: 'active', 逾期: 'past_due', 暂停: 'suspended', 已取消: 'cancelled' }, + billingStatusFromApi: { trial: '试用', active: '正常', past_due: '逾期', suspended: '暂停', cancelled: '已取消' }, + subscriptionStatusToApi: { 试用中: 'trial', 活跃: 'active', 暂停: 'suspended', 已取消: 'cancelled', 已到期: 'expired' }, + subscriptionStatusFromApi: { trial: '试用中', active: '活跃', suspended: '暂停', cancelled: '已取消', expired: '已到期' }, + invoiceStatusFromApi: { draft: '草稿', issued: '待付款', paid: '已支付', void: '已作废', overdue: '已逾期' }, + invoiceTypeToApi: { 订阅服务费: 'subscription', 用量超额费: 'usage', 人工调整: 'adjustment', 首期服务费: 'manual' }, + 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: '已丢弃' }, + }; + + function mapValue(map, value, fallback = value) { + return Object.prototype.hasOwnProperty.call(map, value) ? map[value] : fallback; + } + + function centsToYuan(value) { + const number = Number(value || 0); + return Number.isFinite(number) ? number / 100 : 0; + } + + function yuanToCents(value) { + const number = Number(value || 0); + return Number.isFinite(number) ? Math.round(number * 100) : 0; + } + + function dateOnly(value) { + return value ? String(value).slice(0, 10) : ''; + } + + function monthOnly(value) { + return value ? String(value).slice(0, 7) : ''; + } + + function dateTime(value) { + if (!value) return '-'; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return String(value); + return new Intl.DateTimeFormat('zh-CN', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }).format(parsed).replaceAll('/', '-'); + } + + function isoStartOfDay(value) { + if (!value) return undefined; + return `${String(value).slice(0, 10)}T00:00:00+08:00`; + } + + function splitList(value) { + if (Array.isArray(value)) return value.map(String).map(item => item.trim()).filter(Boolean); + return String(value || '').split(/[、,,]/).map(item => item.trim()).filter(Boolean); + } + + function periodBounds(period) { + const match = String(period || '').match(/^(\d{4})-(\d{2})$/); + if (!match) return { periodStart: '', periodEnd: '' }; + const year = Number(match[1]); + const month = Number(match[2]); + const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate(); + return { + periodStart: `${match[1]}-${match[2]}-01`, + periodEnd: `${match[1]}-${match[2]}-${String(lastDay).padStart(2, '0')}`, + }; + } + + function invoicePeriodBounds(label) { + const match = String(label || '').match(/^(\d{4})年(\d{1,2})月$/); + return match ? periodBounds(`${match[1]}-${String(match[2]).padStart(2, '0')}`) : {}; + } + + const bodyBuilders = { + 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), + }; + }, + PlatformAdminTenantsController_status(input) { + return { + tenantId: input.tenantId, + status: mapValue(dictionaries.tenantStatusToApi, input.status, input.status), + billingStatus: mapValue(dictionaries.billingStatusToApi, input.billingStatus, input.billingStatus), + reason: input.reason, + }; + }, + PlatformAdminTenantsController_billingProfile(input) { + return { + tenantId: input.tenantId, + billingName: input.billingName || undefined, + taxId: input.taxId || undefined, + contactName: input.contactName || undefined, + contactPhone: input.phone || input.contactPhone || undefined, + contactEmail: input.email || input.contactEmail || undefined, + billingAddress: input.billingAddress || undefined, + invoiceTitle: input.invoiceTitle || undefined, + invoiceType: ({ 增值税专用发票: 'special_vat', 增值税普通发票: 'normal_vat', 不开票: 'none' })[input.type || input.invoiceType] || input.invoiceType || 'none', + bankName: input.bankName || undefined, + bankAccountMasked: input.bankAccountMasked || undefined, + }; + }, + PlatformAdminBillingController_createSubscription(input) { + return { + tenantId: input.tenantId, + planCode: input.planCode, + status: mapValue(dictionaries.subscriptionStatusToApi, input.status, input.status || 'active'), + billingCycle: ({ 年度: 'yearly', 月度: 'monthly', 试用: 'trial' })[input.cycle] || input.billingCycle, + amountCents: yuanToCents(input.amount), + startsAt: isoStartOfDay(input.start || input.startsAt), + expiresAt: isoStartOfDay(input.expires || input.expiresAt), + metadata: { autoRenew: Boolean(input.autoRenew), source: 'static-platform-console' }, + }; + }, + PlatformAdminBillingController_createInvoice(input) { + const bounds = invoicePeriodBounds(input.period); + return { + tenantId: input.tenantId, + invoiceType: mapValue(dictionaries.invoiceTypeToApi, input.type, input.invoiceType || 'manual'), + status: input.status || 'draft', + dueDate: input.due || input.dueDate, + billingPeriodStart: input.billingPeriodStart || bounds.periodStart, + billingPeriodEnd: input.billingPeriodEnd || bounds.periodEnd, + items: input.items || [{ + itemType: mapValue(dictionaries.invoiceTypeToApi, input.type, 'manual'), + description: input.note || input.type || '平台手工账单', + quantity: 1, + unitAmountCents: yuanToCents(input.amount), + metadata: { periodLabel: input.period || '' }, + }], + note: input.note || undefined, + }; + }, + PlatformAdminBillingController_fromSubscription(input) { + return { + tenantId: input.tenantId, + subscriptionId: input.subscriptionId || undefined, + status: input.status || 'issued', + dueDate: input.dueDate || undefined, + note: input.note || undefined, + }; + }, + PlatformAdminBillingController_fromSubscriptionsBatch(input) { + return { + tenantIds: input.tenantIds || undefined, + subscriptionIds: input.subscriptionIds || undefined, + daysAhead: input.daysAhead ?? 45, + status: input.status || 'issued', + dueDate: input.dueDate || undefined, + note: input.note || undefined, + dryRun: Boolean(input.dryRun), + }; + }, + PlatformAdminBillingController_fromUsage(input) { + const bounds = input.periodStart && input.periodEnd ? input : { ...input, ...periodBounds(input.period) }; + return { + tenantIds: bounds.tenantIds || undefined, + periodStart: bounds.periodStart, + periodEnd: bounds.periodEnd, + dueDate: bounds.dueDate || undefined, + note: bounds.note || undefined, + status: bounds.status || 'issued', + dryRun: Boolean(bounds.dryRun), + }; + }, + PlatformAdminBillingController_processOverdue(input) { + return { dryRun: Boolean(input.dryRun), channel: input.channel || 'internal', limit: Number(input.limit || 100) }; + }, + PlatformAdminBillingController_confirmPayment(input) { + return { + tenantId: input.tenantId, + invoiceId: input.invoiceId, + amountCents: yuanToCents(input.amount), + paymentNo: input.paymentNo || undefined, + provider: input.provider || 'manual', + method: input.method || 'manual', + paidAt: input.paidAt ? new Date(input.paidAt).toISOString() : undefined, + providerTradeNo: input.tradeNo || input.providerTradeNo || undefined, + }; + }, + PlatformAdminBillingController_recordUsage(input) { + const bounds = input.periodStart && input.periodEnd ? input : { ...input, ...periodBounds(input.period) }; + return { + tenantId: bounds.tenantId, + metricKey: bounds.metricKey, + metricValue: Number(bounds.metricValue || 0), + periodStart: bounds.periodStart, + periodEnd: bounds.periodEnd, + 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, + 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 || '平台管理员' }, + }; + }, + PlatformAdminOverviewController_updateStaffStatus(input) { + return { + staffId: 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, + }; + }, + }; + + function notificationBody(input) { + const maxAttempts = Number(input.maxAttempts || input.retry || 3); + return { + id: input.id || undefined, + channelCode: input.code || input.channelCode, + name: input.name, + enabled: input.enabled !== false, + provider: mapValue(dictionaries.providerToApi, input.provider, input.provider || 'generic'), + webhookUrl: input.endpoint || input.webhookUrl, + secret: input.secret || undefined, + secretRef: input.secretRef || undefined, + minSeverity: mapValue(dictionaries.severityToApi, input.minSeverity, input.minSeverity || 'medium'), + statusFilter: splitList(input.statusFilter || 'open,acknowledged'), + timeoutSec: Number(input.timeout || input.timeoutSec || 10), + maxAttempts, + metadata: { ...(input.metadata || {}), maxAttempts }, + }; + } + bodyBuilders.PlatformAdminAuditController_upsertChannel = notificationBody; + bodyBuilders.PlatformAdminDunningChannelsController_upsertChannel = input => ({ + ...notificationBody(input), + minReminderLevel: Number(input.minReminderLevel || 1), + reminderTypes: input.reminderTypes || ['overdue', 'final_notice'], + reminderChannels: input.reminderChannels || ['internal'], + }); + + function buildBody(operationId, input = {}) { + const builder = bodyBuilders[operationId]; + return builder ? builder(input) : input; + } + + function jsonSummary(value) { + if (!value) return ''; + if (typeof value === 'string') return value; + try { + return JSON.stringify(value); + } catch { + return String(value); + } + } + + function safeEndpoint(item) { + if (!item?.webhook) return ''; + const protocol = item.webhook.protocol ? `${item.webhook.protocol}://` : ''; + return `${protocol}${item.webhook.host || ''}${item.webhook.pathname || ''}`; + } + + function currentById(items, id) { + return (items || []).find(item => item.id === id) || {}; + } + + const adapters = { + plan(item, current = {}) { + const quotas = item.includedQuotas && typeof item.includedQuotas === 'object' + ? Object.entries(item.includedQuotas).map(([key, value]) => `${key}: ${value}`) + : current.quotas || []; + return { + ...current, + code: item.code, + name: item.name, + description: item.description || '', + cycle: ({ yearly: '年度', monthly: '月度' })[item.billingCycle] || item.billingCycle || '-', + amount: centsToYuan(item.baseAmountCents), + quotas, + tenantCount: Number(current.tenantCount || 0), + status: item.status, + }; + }, + tenant(item, current = {}, planMap = {}) { + const plan = planMap[item.planCode]; + return { + students: 0, + questions: 0, + storageGB: 0, + trafficTB: 0, + balance: 0, + host: '-', + owner: '待配置', + ownerPhone: '-', + ...current, + id: item.id, + slug: item.slug, + name: item.name, + legal: item.legalName || current.legal || item.name, + brand: item.brandName || current.brand || item.name, + planCode: item.planCode || '', + plan: plan?.name || current.plan || item.planCode || '未订阅', + status: mapValue(dictionaries.tenantStatusFromApi, item.status, item.status || '-'), + billing: mapValue(dictionaries.billingStatusFromApi, item.billingStatus, item.billingStatus || '-'), + mode: item.mode === 'saas' ? '正式' : item.mode || current.mode || '-', + expires: dateOnly(item.subscriptionExpiresAt), + balance: centsToYuan(item.openBalanceCents), + createdAt: dateTime(item.createdAt), + }; + }, + subscription(item) { + return { + id: item.subscriptionId || item.id, + tenantId: item.tenantId, + planCode: item.planCode || '', + status: mapValue(dictionaries.subscriptionStatusFromApi, item.status, item.status || '-'), + cycle: ({ yearly: '年度', monthly: '月度', trial: '试用' })[item.billingCycle] || item.billingCycle || '-', + amount: centsToYuan(item.amountCents), + start: dateOnly(item.startsAt), + expires: dateOnly(item.expiresAt), + autoRenew: Boolean(item.metadata?.autoRenew), + }; + }, + invoice(item) { + return { + id: item.invoiceNo || item.id, + backendId: item.id, + tenantId: item.tenantId, + type: mapValue(dictionaries.invoiceTypeFromApi, item.invoiceType, item.invoiceType || '-'), + period: item.billingPeriodStart ? monthOnly(item.billingPeriodStart) : monthOnly(item.issuedAt || item.dueDate), + total: centsToYuan(item.totalCents), + paid: centsToYuan(item.paidCents), + balance: centsToYuan(item.balanceCents), + due: dateOnly(item.dueDate), + status: mapValue(dictionaries.invoiceStatusFromApi, item.status, item.status || '-'), + note: item.note || '', + }; + }, + reminder(item) { + return { + id: item.id, + invoiceId: item.invoiceNo || item.invoiceId, + backendInvoiceId: item.invoiceId, + tenantId: item.tenantId, + level: Number(item.reminderLevel || 1), + channel: item.channel || '-', + status: mapValue(dictionaries.eventStatusFromApi, item.status, item.status || '-'), + date: dateTime(item.reminderDate || item.createdAt), + message: item.message || '', + }; + }, + bank(item, current = {}) { + return { + ...current, + id: item.id, + name: item.name, + catalog: item.regionName || item.tenantName || '公共题库', + source: item.sourceScope === 'platform' ? '平台公共题库' : '租户自建', + 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 || '-', + }; + }, + staff(item) { + const permissions = item.roleCodes?.includes('platform_super_admin') ? ['*'] : []; + return { + id: item.userId || item.id, + authUserId: item.userId || item.authUserId || '', + username: item.email || '', + name: item.name || item.email || '平台员工', + email: item.email || '', + phone: item.phoneMasked || item.phone || '-', + role: permissions.includes('*') ? '超级管理员' : item.rawProfile?.displayRole || '平台管理员', + permissions, + status: item.status === 'active' ? '正常' : '已停用', + lastActive: dateTime(item.updatedAt), + }; + }, + audit(item) { + const details = jsonSummary(item.details); + return { + id: item.id, + time: `${dateTime(item.createdAt)} CST`, + actor: item.actorName || item.actorUsername || item.actorUserId || 'system', + role: item.actorUserId ? '平台管理员' : '系统任务', + action: item.action || '-', + targetType: item.targetType || '-', + targetId: item.targetId || '', + target: item.tenantName || item.targetId || item.targetType || '-', + severity: mapValue(dictionaries.severityFromApi, item.details?.severity, item.details?.severity || '中'), + result: item.details?.result === 'failed' ? '失败' : '成功', + ip: item.ipAddress || '-', + details, + }; + }, + alert(item) { + const evidence = Array.isArray(item.details?.evidence) + ? item.details.evidence.map(String) + : Object.entries(item.details || {}).slice(0, 8).map(([key, value]) => `${key}: ${jsonSummary(value)}`); + return { + id: item.id, + title: item.title || item.ruleName || '平台审计告警', + summary: item.summary || item.action || '', + severity: mapValue(dictionaries.severityFromApi, item.severity, item.severity || '-'), + status: item.status || 'open', + owner: item.acknowledgedByName || item.resolvedByName || item.tenantName || item.ruleName || '待认领', + targetType: item.targetType || '-', + targetId: item.targetId || '', + evidence, + updatedAt: dateTime(item.updatedAt || item.createdAt), + note: item.resolutionNote || '', + }; + }, + rule(item) { + return { + id: item.id, + code: item.code, + name: item.name, + severity: mapValue(dictionaries.severityFromApi, item.severity, item.severity || '-'), + enabled: item.enabled !== false, + window: item.conditions?.window || item.metadata?.window || '-', + threshold: item.conditions?.threshold || item.metadata?.threshold || '-', + target: item.description || (item.targetTypes || []).join('、') || '-', + }; + }, + channel(item) { + return { + id: item.id, + code: item.channelCode, + name: item.name, + provider: mapValue(dictionaries.providerFromApi, item.provider, item.provider || 'Webhook'), + endpoint: safeEndpoint(item), + enabled: item.enabled !== false, + retry: Number(item.maxAttempts || item.metadata?.maxAttempts || 3), + minSeverity: mapValue(dictionaries.severityFromApi, item.minSeverity, item.minSeverity || '中'), + statusFilter: (item.statusFilter || []).join(','), + timeout: Number(item.timeoutSec || 10), + maxAttempts: Number(item.maxAttempts || item.metadata?.maxAttempts || 3), + lastStatus: '正常', + secretRef: item.secretRef || '', + endpointRedacted: true, + reminderTypes: item.reminderTypes || [], + reminderChannels: item.reminderChannels || [], + minReminderLevel: Number(item.minReminderLevel || 1), + }; + }, + event(item, type = 'audit') { + return { + id: item.id, + channelId: item.channelId, + subject: type === 'dunning' + ? item.reminderMessage || item.invoiceNo || '催缴通知' + : item.alertTitle || item.alertAction || '审计告警通知', + status: mapValue(dictionaries.eventStatusFromApi, item.status, item.status || '-'), + attempts: Number(item.attempts || 0), + updatedAt: dateTime(item.updatedAt || item.sentAt || item.createdAt), + error: item.lastError || '', + }; + }, + }; + + function aggregateUsage(items) { + const records = new Map(); + items.forEach(item => { + const period = monthOnly(item.periodStart || item.periodEnd); + const key = `${item.tenantId}:${period}`; + const record = records.get(key) || { + tenantId: item.tenantId, + period, + source: item.metadata?.source === 'platform_usage_worker' ? 'Worker 自动采集' : '人工补录', + students: 0, + questions: 0, + storageGB: 0, + trafficTB: 0, + overage: [], + estimated: 0, + }; + const metricMap = { + students: 'students', + questions: 'questions', + storage_gb: 'storageGB', + traffic_tb: 'trafficTB', + }; + const field = metricMap[item.metricKey]; + if (field) record[field] = Number(item.metricValue || 0); + records.set(key, record); + }); + return Array.from(records.values()); + } + + function currentPeriod() { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + }).formatToParts(new Date()); + const values = Object.fromEntries(parts.map(part => [part.type, part.value])); + return `${values.year}-${values.month}`; + } + + const bootstrapDescriptors = [ + ['overview', 'PlatformAdminOverviewController_overview', {}], + ['plans', 'PlatformAdminOverviewController_plans', { query: { includeArchived: false } }], + ['tenants', 'PlatformAdminTenantsController_list', { query: { limit: 200 } }], + ['staff', 'PlatformAdminOverviewController_staff', { query: { limit: 200 } }], + ['dunningChannels', 'PlatformAdminDunningChannelsController_channels', { query: { limit: 100 } }], + ['dunningEvents', 'PlatformAdminDunningEventsController_events', { query: { limit: 100 } }], + ['auditLogs', 'PlatformAdminAuditController_logs', { query: { limit: 200 } }], + ['alerts', 'PlatformAdminAuditController_alerts', { query: { limit: 100 } }], + ]; + + 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( + descriptors.map(([, operationId, requestOptions]) => request(operationId, requestOptions)), + ); + const payloads = {}; + const errors = {}; + results.forEach((result, index) => { + const [key] = descriptors[index]; + if (result.status === 'fulfilled') payloads[key] = result.value; + else errors[key] = result.reason; + }); + return { payloads, errors, requested: descriptors.length }; + } + + function adaptBootstrap(payloads, currentData = {}) { + const patch = {}; + if (payloads.overview) patch.apiOverview = payloads.overview.item || payloads.overview; + if (payloads.permissions?.item) patch.apiPermissions = payloads.permissions.item; + if (Array.isArray(payloads.plans?.items)) { + patch.plans = payloads.plans.items.map(item => adapters.plan(item, currentById(currentData.plans, item.code))); + } + const planItems = patch.plans || currentData.plans || []; + const planMap = Object.fromEntries(planItems.map(item => [item.code, item])); + if (Array.isArray(payloads.tenants?.items)) { + patch.tenants = payloads.tenants.items.map(item => adapters.tenant(item, currentById(currentData.tenants, item.id), planMap)); + } + if (Array.isArray(payloads.staff?.items)) patch.staff = payloads.staff.items.map(adapters.staff); + if (Array.isArray(payloads.invoices?.items)) patch.invoices = payloads.invoices.items.map(adapters.invoice); + if (Array.isArray(payloads.subscriptions?.items)) { + patch.subscriptions = payloads.subscriptions.items + .filter(item => item.subscriptionId) + .map(adapters.subscription); + } + if (Array.isArray(payloads.usage?.items)) patch.usage = aggregateUsage(payloads.usage.items); + if (Array.isArray(payloads.usageCandidates?.items) && patch.usage) { + const usageByKey = new Map(patch.usage.map(item => [`${item.tenantId}:${item.period}`, item])); + payloads.usageCandidates.items.forEach(candidate => { + const record = usageByKey.get(`${candidate.tenantId}:${monthOnly(candidate.periodStart)}`); + if (!record) return; + record.estimated = centsToYuan(candidate.totalCents); + record.overage = (candidate.items || []).map(item => item.description || item.itemType).filter(Boolean); + }); + } + if (Array.isArray(payloads.reminders?.items)) patch.reminders = payloads.reminders.items.map(adapters.reminder); + if (Array.isArray(payloads.dunningChannels?.items)) patch.dunningChannels = payloads.dunningChannels.items.map(adapters.channel); + if (Array.isArray(payloads.dunningEvents?.items)) patch.dunningEvents = payloads.dunningEvents.items.map(item => adapters.event(item, 'dunning')); + if (Array.isArray(payloads.banks?.items)) { + patch.banks = payloads.banks.items.map(item => adapters.bank(item, currentById(currentData.banks, item.id))); + } + if (Array.isArray(payloads.grants?.items)) patch.grants = payloads.grants.items.map(adapters.grant); + if (Array.isArray(payloads.bankSync?.items)) patch.bankSync = payloads.bankSync.items.map(adapters.sync); + if (Array.isArray(payloads.auditLogs?.items)) patch.auditLogs = payloads.auditLogs.items.map(adapters.audit); + if (Array.isArray(payloads.alertRules?.items)) patch.alertRules = payloads.alertRules.items.map(adapters.rule); + if (Array.isArray(payloads.alerts?.items)) patch.alerts = payloads.alerts.items.map(adapters.alert); + if (Array.isArray(payloads.auditChannels?.items)) patch.auditChannels = payloads.auditChannels.items.map(adapters.channel); + if (Array.isArray(payloads.notificationEvents?.items)) { + patch.notificationEvents = payloads.notificationEvents.items.map(item => adapters.event(item, 'audit')); + } + return patch; + } + + async function tenantDetail(tenantId) { + const payload = await request('PlatformAdminTenantsController_detail', { query: { tenantId } }); + const item = payload.item || {}; + return { + tenant: item.tenant || null, + domains: item.domains || [], + subscriptions: (item.subscriptions || []).map(adapters.subscription), + invoices: (item.invoices || []).map(adapters.invoice), + usage: aggregateUsage(item.usage || []), + billingProfile: item.tenant ? { + billingName: item.tenant.billingName || '', + taxId: item.tenant.taxId || '', + contactName: item.tenant.contactName || '', + phone: item.tenant.contactPhone || '', + email: item.tenant.contactEmail || '', + billingAddress: item.tenant.billingAddress || '', + invoiceTitle: item.tenant.invoiceTitle || '', + type: ({ special_vat: '增值税专用发票', normal_vat: '增值税普通发票', none: '不开票' })[item.tenant.invoiceType] || item.tenant.invoiceType || '', + bankName: item.tenant.bankName || '', + bankAccountMasked: item.tenant.bankAccountMasked || '', + } : null, + }; + } + + function downloadExport(payload) { + const item = payload?.item; + if (!item?.contentBase64) { + throw new PlatformApiError('审计导出响应缺少文件内容', { code: 'AUDIT_EXPORT_INVALID' }); + } + const bytes = Uint8Array.from(atob(item.contentBase64), character => character.charCodeAt(0)); + const blob = new Blob([bytes], { type: item.mimeType || 'application/octet-stream' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = item.filename || `platform-audit.${item.format || 'csv'}`; + anchor.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + return item; + } + + window.GongxuePlatformApi = Object.freeze({ + PlatformApiError, + adapters, + adaptBootstrap, + bootstrap, + buildBody, + dictionaries, + downloadExport, + isEnabled, + operationCatalog, + periodBounds, + publicConfig, + request, + tenantDetail, + trace, + }); +})(); diff --git a/Tiku.Api/wwwroot/platform-admin/platform-auth.js b/Tiku.Api/wwwroot/platform-admin/platform-auth.js new file mode 100644 index 0000000..b61c8c4 --- /dev/null +++ b/Tiku.Api/wwwroot/platform-admin/platform-auth.js @@ -0,0 +1,172 @@ +(() => { + const ACCESS_TOKEN_KEY = 'tiku_platform_access_token'; + const REFRESH_TOKEN_KEY = 'tiku_platform_refresh_token'; + const ACCESS_EXPIRES_KEY = 'tiku_platform_access_expires_at'; + const USER_KEY = 'tiku_platform_user'; + const runtime = window.GONGXUE_PLATFORM_RUNTIME_CONFIG || {}; + let challengeToken = ''; + let pendingResolve; + + function apiUrl(path) { + return `${String(runtime.apiBaseUrl || '').replace(/\/+$/, '')}${path}`; + } + + async function post(path, body) { + const response = await fetch(apiUrl(path), { + method: 'POST', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + credentials: 'omit', + body: JSON.stringify(body), + }); + const text = await response.text(); + let payload = {}; + if (text) { + try { payload = JSON.parse(text); } catch { payload = { detail: text.slice(0, 500) }; } + } + if (!response.ok) throw new Error(payload.detail || payload.title || payload.message || `请求失败(HTTP ${response.status})`); + return payload; + } + + function gate() { return document.querySelector('#platformAuthGate'); } + + function escapeHtml(value) { + return String(value ?? '').replace(/[&<>'"]/g, character => ({ + '&': '&', '<': '<', '>': '>', "'": ''', '"': '"', + })[character]); + } + + function shell(title, description, content) { + gate().innerHTML = ``; + gate().hidden = false; + document.querySelector('.platform-app')?.setAttribute('inert', ''); + } + + function showError(error) { + const target = document.querySelector('#platformAuthError'); + if (target) target.textContent = error?.message || '操作失败,请重试'; + } + + function setBusy(form, busy) { + form.querySelectorAll('button,input').forEach(control => { control.disabled = busy; }); + } + + function storeAuthenticated(result) { + const user = result?.user; + const tokens = user?.tokens; + if (!tokens?.accessToken || !tokens?.refreshToken) throw new Error('登录响应缺少令牌'); + sessionStorage.setItem(ACCESS_TOKEN_KEY, tokens.accessToken); + sessionStorage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken); + sessionStorage.setItem(ACCESS_EXPIRES_KEY, tokens.accessTokenExpiresAt || ''); + sessionStorage.setItem(USER_KEY, JSON.stringify({ userId: user.userId, email: user.email, name: user.name })); + } + + function completeGate() { + gate().hidden = true; + gate().innerHTML = ''; + document.querySelector('.platform-app')?.removeAttribute('inert'); + pendingResolve?.(true); + pendingResolve = null; + } + + function finishAuthentication(result, recoveryCodes = []) { + storeAuthenticated(result); + if (recoveryCodes.length) { + shell('保存恢复代码', '这些代码只显示一次,请保存到安全位置。', `
${recoveryCodes.map(code => `
${escapeHtml(code)}
`).join('')}

保存后再进入平台控制台。

`); + document.querySelector('#platformAuthContinue').addEventListener('click', completeGate); + return; + } + completeGate(); + } + + async function handleAuthenticationResult(result) { + challengeToken = result.challengeToken || ''; + if (result.status === 'authenticated') { finishAuthentication(result); return; } + if (result.status === 'password_change_required') { renderPasswordChange(); return; } + if (result.status === 'mfa_enrollment_required') { await renderMfaEnrollment(); return; } + if (result.status === 'mfa_required') { renderMfaVerification(); return; } + throw new Error(`不支持的认证状态:${result.status || 'unknown'}`); + } + + function renderLogin() { + shell('平台管理员登录', '连接真实 PostgreSQL 与 ASP.NET Core API。', `
`); + document.querySelector('#platformLoginForm').addEventListener('submit', async event => { + event.preventDefault(); + const form = event.currentTarget; + setBusy(form, true); + try { + const result = await post('/api/auth/login/password', { realm: 'platform', identifier: form.elements.identifier.value.trim(), password: form.elements.password.value }); + await handleAuthenticationResult(result); + } catch (error) { showError(error); setBusy(form, false); } + }); + } + + function renderPasswordChange() { + shell('设置正式密码', '首次登录必须先替换临时密码。', `
`); + document.querySelector('#platformPasswordForm').addEventListener('submit', async event => { + event.preventDefault(); + const form = event.currentTarget; + if (form.elements.newPassword.value !== form.elements.confirmPassword.value) { showError(new Error('两次输入的密码不一致')); return; } + setBusy(form, true); + try { await handleAuthenticationResult(await post('/api/auth/password/change-required', { challengeToken, newPassword: form.elements.newPassword.value })); } + catch (error) { showError(error); setBusy(form, false); } + }); + } + + async function renderMfaEnrollment() { + const setup = await post('/api/auth/mfa/totp/setup', { challengeToken }); + shell('绑定双重验证', '在认证器中添加密钥,然后输入当前 6 位验证码。', `${escapeHtml(setup.sharedKey)}

也可在支持的认证器中导入:${escapeHtml(setup.authenticatorUri)}

`); + document.querySelector('#platformMfaForm').addEventListener('submit', async event => { + event.preventDefault(); + const form = event.currentTarget; + setBusy(form, true); + try { + const result = await post('/api/auth/mfa/totp/confirm', { challengeToken, code: form.elements.code.value.trim() }); + finishAuthentication(result.authentication, result.recoveryCodes || []); + } catch (error) { showError(error); setBusy(form, false); } + }); + } + + function renderMfaVerification() { + shell('双重验证', '输入认证器中的当前 6 位验证码。', `
`); + document.querySelector('#platformMfaForm').addEventListener('submit', async event => { + event.preventDefault(); + const form = event.currentTarget; + setBusy(form, true); + try { await handleAuthenticationResult(await post('/api/auth/mfa/totp/verify', { challengeToken, code: form.elements.code.value.trim() })); } + catch (error) { showError(error); setBusy(form, false); } + }); + } + + function clearSession() { + [ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY, ACCESS_EXPIRES_KEY, USER_KEY].forEach(key => sessionStorage.removeItem(key)); + } + + function hasSession() { + const token = sessionStorage.getItem(ACCESS_TOKEN_KEY); + const expiresAt = Date.parse(sessionStorage.getItem(ACCESS_EXPIRES_KEY) || ''); + return Boolean(token) && Number.isFinite(expiresAt) && expiresAt > Date.now() + 15_000; + } + + async function requireSession() { + if (hasSession()) return true; + clearSession(); + renderLogin(); + return new Promise(resolve => { pendingResolve = resolve; }); + } + + async function logout() { + const refreshToken = sessionStorage.getItem(REFRESH_TOKEN_KEY); + try { if (refreshToken) await post('/api/auth/logout', { refreshToken }); } catch { /* Clear the browser session even when revocation cannot be reached. */ } + clearSession(); + location.reload(); + } + + window.GongxuePlatformAuth = Object.freeze({ + getAccessToken: () => sessionStorage.getItem(ACCESS_TOKEN_KEY) || '', + getUser: () => { + try { return JSON.parse(sessionStorage.getItem(USER_KEY) || 'null'); } catch { return null; } + }, + logout, + requireSession, + }); +})(); diff --git a/Tiku.Api/wwwroot/platform-admin/runtime-config.js b/Tiku.Api/wwwroot/platform-admin/runtime-config.js new file mode 100644 index 0000000..cc4d436 --- /dev/null +++ b/Tiku.Api/wwwroot/platform-admin/runtime-config.js @@ -0,0 +1,24 @@ +/* + * 平台端公开运行时配置。 + * + * 默认连接同源后端。其他部署环境可在本文件之前注入同名对象, + * 或在部署时替换本文件。不要把 access token、service role key 或任何服务端密钥 + * 写入静态文件;getAccessToken 应从当前平台登录会话中按需读取短期 JWT。 + * + * window.GONGXUE_PLATFORM_RUNTIME_CONFIG = { + * mode: 'api', + * apiBaseUrl: 'https://api.example.com', + * fallbackToMock: true, + * timeoutMs: 10000, + * getAccessToken: async () => { + * return sessionStorage.getItem('tiku_platform_access_token') || ''; + * }, + * }; + */ +window.GONGXUE_PLATFORM_RUNTIME_CONFIG = window.GONGXUE_PLATFORM_RUNTIME_CONFIG || { + mode: 'api', + apiBaseUrl: '', + fallbackToMock: false, + timeoutMs: 10000, + getAccessToken: async () => window.GongxuePlatformAuth?.getAccessToken() || '', +}; diff --git a/Tiku.Api/wwwroot/platform-admin/spec-contract.js b/Tiku.Api/wwwroot/platform-admin/spec-contract.js new file mode 100644 index 0000000..319f650 --- /dev/null +++ b/Tiku.Api/wwwroot/platform-admin/spec-contract.js @@ -0,0 +1,246 @@ +(() => { + const actions = [ + ['P-01', 'act.p-01.01', '进入租户/账务/题库/审计', 'command', 'native', 'nav', '[data-nav="tenants"],[data-nav="billing"],[data-nav="bank"],[data-nav="audit"]', 'PlatformAdminOverviewController_overview', 'allow'], + ['P-01', 'act.p-01.02', '确认/解决告警', 'command', 'native', 'mutation', '[data-alert-detail],[data-action="transition-alert"]', 'PlatformAdminAuditController_alerts', 'allow'], + ['P-01', 'act.p-01.03', '导出审计', 'async', 'native', 'task', '[data-action="export-audit"]', 'PlatformAdminAuditController_logs', 'allow'], + ['P-01', 'act.p-01.04', '刷新', 'command', 'native', 'read', '[data-action="refresh"]', 'PlatformAdminOverviewController_overview', 'allow'], + ['P-02', 'act.p-02.01', '搜索', 'command', 'native', 'read', '#tenantSearch,[data-action="apply-tenant-query"]', 'PlatformAdminTenantsController_list', 'allow'], + ['P-02', 'act.p-02.02', '创建租户', 'command', 'native', 'mutation', '[data-action="create-tenant"],[form="createTenantForm"]', 'PlatformAdminTenantsController_create', 'block'], + ['P-02', 'act.p-02.03', '打开详情', 'command', 'native', 'nav', '[data-tenant-detail]', 'PlatformAdminTenantsController_list', 'allow'], + ['P-02', 'act.p-02.04', '准备暂停/恢复', 'destructive', 'native', 'mutation', '[data-action="prepare-tenant-status"],[form="tenantStatusForm"]', 'PlatformAdminTenantsController_status', 'block'], + ['P-02', 'act.p-02.05', '去账务', 'command', 'native', 'nav', '[data-action="tenant-to-billing"]', 'PlatformAdminBillingController_invoices', 'allow'], + ['P-03', 'act.p-03.01', '修改状态', 'command', 'native', 'mutation', '[data-action="prepare-tenant-status"],[form="tenantStatusForm"]', 'PlatformAdminTenantsController_status', 'block'], + ['P-03', 'act.p-03.02', '编辑账务资料', 'command', 'native', 'nav', '[data-action="edit-billing-profile"]', 'PlatformAdminTenantsController_billingProfile', 'block'], + ['P-03', 'act.p-03.03', '创建订阅', 'command', 'native', 'mutation', '[data-action="create-subscription-for-tenant"],[form="subscriptionForm"]', 'PlatformAdminBillingController_createSubscription', 'block'], + ['P-03', 'act.p-03.04', '查看账单/用量', 'command', 'native', 'nav', '[data-action="tenant-to-billing"],[data-action="tenant-to-usage"]', 'PlatformAdminTenantsController_detail', 'allow'], + ['P-03', 'act.p-03.05', '跳转租户门户(只做链接,不冒充登录)', 'command', 'native', 'nav', '[data-action="open-tenant-portal"]', 'PlatformAdminTenantsController_detail', 'allow'], + ['P-04', 'act.p-04.01', '编辑', 'command', 'native', 'mutation', '#billingProfileForm', 'PlatformAdminTenantsController_billingProfile', 'block'], + ['P-04', 'act.p-04.02', '保存', 'command', 'native', 'mutation', '[form="billingProfileForm"]', 'PlatformAdminTenantsController_billingProfile', 'block'], + ['P-04', 'act.p-04.03', '查看关联账单', 'command', 'native', 'nav', '[data-action="billing-profile-to-invoices"]', 'PlatformAdminTenantsController_billingProfile', 'allow'], + ['P-05', 'act.p-05.01', '选择套餐', 'command', 'native', 'mutation', '[data-action="select-plan"]', 'PlatformAdminOverviewController_plans', 'block'], + ['P-05', 'act.p-05.02', '创建订阅', 'command', 'native', 'mutation', '[data-action="create-subscription"],[form="subscriptionForm"]', 'PlatformAdminBillingController_createSubscription', 'block'], + ['P-05', 'act.p-05.03', '进入账单候选', 'command', 'native', 'nav', '[data-action="subscription-to-billing"]', 'PlatformAdminBillingController_createSubscription', 'allow'], + ['P-05', 'act.p-05.04', '查看租户详情', 'command', 'native', 'nav', '[data-tenant-detail]', 'PlatformAdminBillingController_createSubscription', 'allow'], + ['P-06', 'act.p-06.01', '查询', 'command', 'contract', 'read', '[data-action="apply-invoice-query"]', 'PlatformAdminBillingController_invoices', 'allow'], + ['P-06', 'act.p-06.02', '手工创建', 'command', 'native', 'mutation', '[data-action="create-invoice"],[form="invoiceForm"]', 'PlatformAdminBillingController_createInvoice', 'block'], + ['P-06', 'act.p-06.03', '预览候选', 'command', 'native', 'read', '[data-action="preview-invoice-candidates"]', 'PlatformAdminBillingController_subscriptionCandidates', 'block'], + ['P-06', 'act.p-06.04', '单个生成', 'async', 'contract', 'task', '[data-action="generate-subscription-invoice"]', 'PlatformAdminBillingController_fromSubscription', 'block'], + ['P-06', 'act.p-06.05', '批量 dry-run', 'command', 'native', 'read', '[data-action="run-invoice-dry-run"]', 'PlatformAdminBillingController_fromSubscriptionsBatch', 'block'], + ['P-06', 'act.p-06.06', '确认生成', 'async', 'native', 'task', '[data-action="confirm-generate-candidates"]', 'PlatformAdminBillingController_fromSubscriptionsBatch', 'block'], + ['P-06', 'act.p-06.07', '打开租户', 'command', 'native', 'nav', '[data-tenant-detail]', 'PlatformAdminBillingController_invoices', 'allow'], + ['P-07', 'act.p-07.01', '记录用量', 'command', 'native', 'mutation', '[data-action="record-usage"],[form="usageForm"]', 'PlatformAdminBillingController_recordUsage', 'block'], + ['P-07', 'act.p-07.02', '查询', 'command', 'native', 'read', '#usagePeriodFilter', 'PlatformAdminBillingController_usage', 'allow'], + ['P-07', 'act.p-07.03', '预览超额候选', 'command', 'native', 'read', '[data-action="preview-overage"]', 'PlatformAdminBillingController_usageCandidates', 'block'], + ['P-07', 'act.p-07.04', 'dry-run', 'command', 'native', 'read', '[data-action="run-overage-dry-run"]', 'PlatformAdminBillingController_fromUsage', 'block'], + ['P-07', 'act.p-07.05', '生成超额账单', 'async', 'native', 'task', '[data-action="confirm-overage-invoices"]', 'PlatformAdminBillingController_fromUsage', 'block'], + ['P-07', 'act.p-07.06', '跳租户详情', 'command', 'native', 'nav', '[data-tenant-detail]', 'PlatformAdminBillingController_usage', 'allow'], + ['P-08', 'act.p-08.01', '确认收款', 'command', 'native', 'mutation', '[data-action="confirm-payment"],[form="paymentForm"]', 'PlatformAdminBillingController_confirmPayment', 'block'], + ['P-08', 'act.p-08.02', '预览逾期', 'command', 'native', 'read', '[data-action="preview-overdue"]', 'PlatformAdminBillingController_processOverdue', 'block'], + ['P-08', 'act.p-08.03', '生成催缴', 'async', 'native', 'task', '[data-action="confirm-overdue-process"],[data-action="generate-reminder"]', 'PlatformAdminBillingController_processOverdue', 'block'], + ['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-10', 'act.p-10.01', '新建/编辑', 'command', 'native', 'mutation', '[data-action="new-staff"],[data-action="edit-staff"]', 'PlatformAdminOverviewController_upsertStaff', 'block'], + ['P-10', 'act.p-10.02', '全选/分组授权', 'command', 'native', 'mutation', '[data-action="select-permission-group"]', 'PlatformAdminOverviewController_permissions', 'block'], + ['P-10', 'act.p-10.03', '启用/禁用', 'destructive', 'native', 'mutation', '[data-action="toggle-staff"]', 'PlatformAdminOverviewController_updateStaffStatus', 'block'], + ['P-10', 'act.p-10.04', '禁用时撤销会话', 'destructive', 'native', 'audit', '[data-action="execute-staff-disable"]', 'PlatformAdminOverviewController_updateStaffStatus', 'block'], + ['P-10', 'act.p-10.05', '清空表单', 'command', 'contract', 'mutation', '[data-action="clear-staff-form"]', 'PlatformAdminOverviewController_upsertStaff', 'block'], + ['P-11', 'act.p-11.01', '筛选', 'command', 'native', 'read', '#auditSearch,#auditRangeFilter,[data-audit-severity]', 'PlatformAdminAuditController_logs', 'allow'], + ['P-11', 'act.p-11.02', '查看详情', 'command', 'native', 'read', '[data-audit-detail]', 'PlatformAdminAuditController_logs', 'allow'], + ['P-11', 'act.p-11.03', '导出 CSV/JSON', 'async', 'native', 'task', '[data-action="export-audit"]', 'PlatformAdminAuditController_exportLogs', 'allow'], + ['P-11', 'act.p-11.04', '复制 ID', 'command', 'native', 'read', '[data-copy]', 'PlatformAdminAuditController_logs', 'allow'], + ['P-11', 'act.p-11.05', '跳目标对象', 'command', 'native', 'nav', '[data-action="jump-audit-target"],[data-action="jump-related-alert"]', 'PlatformAdminAuditController_logs', 'allow'], + ['P-12', 'act.p-12.01', '确认/解决告警', 'command', 'native', 'mutation', '[data-action="transition-alert"]', 'PlatformAdminAuditController_updateAlert', 'allow'], + ['P-12', 'act.p-12.02', '查看证据', 'command', 'native', 'read', '[data-alert-detail]', 'PlatformAdminAuditController_alerts', 'allow'], + ['P-12', 'act.p-12.03', '新建/编辑/启停通知渠道', 'command', 'native', 'mutation', '[data-action="new-audit-channel"],[data-action="edit-audit-channel"],[data-action="toggle-audit-channel"]', 'PlatformAdminAuditController_upsertChannel', 'block'], + ['P-12', 'act.p-12.04', '查看失败事件', 'command', 'contract', 'read', '[data-notification-detail]', 'PlatformAdminAuditController_events', 'allow'], + ].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-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-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/subscriptions'], + ['P-04', 'PlatformAdminTenantsController_billingProfile', 'PUT', '/api/platform-admin/tenants/billing-profile'], + ['P-05', 'PlatformAdminOverviewController_plans', 'GET', '/api/platform-admin/plans'], + ['P-05', 'PlatformAdminBillingController_createSubscription', 'POST', '/api/platform-admin/subscriptions'], + ['P-06', 'PlatformAdminBillingController_invoices', 'GET', '/api/platform-admin/invoices'], + ['P-06', 'PlatformAdminBillingController_createInvoice', 'POST', '/api/platform-admin/invoices'], + ['P-06', 'PlatformAdminBillingController_subscriptionCandidates', 'GET', '/api/platform-admin/invoices/subscription-candidates'], + ['P-06', 'PlatformAdminBillingController_fromSubscription', 'POST', '/api/platform-admin/invoices/from-subscription'], + ['P-06', 'PlatformAdminBillingController_fromSubscriptionsBatch', 'POST', '/api/platform-admin/invoices/from-subscriptions-batch'], + ['P-07', 'PlatformAdminBillingController_usage', 'GET', '/api/platform-admin/usage'], + ['P-07', 'PlatformAdminBillingController_recordUsage', 'POST', '/api/platform-admin/usage'], + ['P-07', 'PlatformAdminBillingController_usageCandidates', 'GET', '/api/platform-admin/invoices/usage-overage-candidates'], + ['P-07', 'PlatformAdminBillingController_fromUsage', 'POST', '/api/platform-admin/invoices/from-usage-overage'], + ['P-08', 'PlatformAdminBillingController_confirmPayment', 'POST', '/api/platform-admin/invoices/payments/manual-confirm'], + ['P-08', 'PlatformAdminBillingController_processOverdue', 'POST', '/api/platform-admin/invoices/process-overdue'], + ['P-08', 'PlatformAdminBillingController_reminders', 'GET', '/api/platform-admin/invoices/reminders'], + ['P-08', 'PlatformAdminDunningChannelsController_channels', 'GET', '/api/platform-admin/dunning-notification-channels'], + ['P-08', 'PlatformAdminDunningChannelsController_upsertChannel', 'PUT', '/api/platform-admin/dunning-notification-channels'], + ['P-08', 'PlatformAdminDunningEventsController_events', 'GET', '/api/platform-admin/dunning-notification-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-10', 'PlatformAdminOverviewController_permissions', 'GET', '/api/platform-admin/permissions'], + ['P-10', 'PlatformAdminOverviewController_staff', 'GET', '/api/platform-admin/staff'], + ['P-10', 'PlatformAdminOverviewController_upsertStaff', 'PUT', '/api/platform-admin/staff'], + ['P-10', 'PlatformAdminOverviewController_updateStaffStatus', 'PATCH', '/api/platform-admin/staff/status'], + ['P-11', 'PlatformAdminAuditController_logs', 'GET', '/api/platform-admin/audit-logs'], + ['P-11', 'PlatformAdminAuditController_exportLogs', 'GET', '/api/platform-admin/audit-logs/export'], + ['P-12', 'PlatformAdminAuditController_rules', 'GET', '/api/platform-admin/audit-alert-rules'], + ['P-12', 'PlatformAdminAuditController_alerts', 'GET', '/api/platform-admin/audit-alerts'], + ['P-12', 'PlatformAdminAuditController_updateAlert', 'POST', '/api/platform-admin/audit-alerts/status'], + ['P-12', 'PlatformAdminAuditController_channels', 'GET', '/api/platform-admin/audit-notification-channels'], + ['P-12', 'PlatformAdminAuditController_upsertChannel', 'PUT', '/api/platform-admin/audit-notification-channels'], + ['P-12', 'PlatformAdminAuditController_events', 'GET', '/api/platform-admin/audit-notification-events'], + ]; + const operations = operationRows.map(([pageId, operationId, method, path]) => ({ pageId, operationId, method, path })); + const operationById = Object.fromEntries(operations.map(operation => [operation.operationId, operation])); + + const navEdges = [ + ['nav.p-01.p-02', 'P-01', 'P-02', '[data-nav="tenants"]'], + ['nav.p-01.p-06', 'P-01', 'P-06', '[data-nav="billing"]'], + ['nav.p-01.p-12', 'P-01', 'P-12', '[data-nav="alerts"],[data-alert-detail]'], + ['nav.p-02.p-03', 'P-02', 'P-03', '[data-tenant-detail]'], + ['nav.p-03.p-04', 'P-03', 'P-04', '[data-action="edit-billing-profile"]'], + ['nav.p-03.p-05', 'P-03', 'P-05', '[data-action="create-subscription-for-tenant"]'], + ['nav.p-03.p-06', 'P-03', 'P-06', '[data-action="tenant-to-billing"]'], + ['nav.p-05.p-06', 'P-05', 'P-06', '[data-action="subscription-to-billing"]'], + ['nav.p-05.p-07', 'P-05', 'P-07', '[data-action="subscriptions-to-usage"]'], + ['nav.p-06.p-08', 'P-06', 'P-08', '[data-nav="dunning"]'], + ['nav.p-11.p-12', 'P-11', 'P-12', '[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 trace = []; + const decoratedActionIds = new Set(); + const selectorCollisions = new Set(); + const explicitResultActionIds = new Set(['act.p-06.01', 'act.p-06.04', 'act.p-09.01', 'act.p-09.05']); + function pushTrace(entry) { + trace.push({ at: new Date().toISOString(), ...entry }); + if (trace.length > 120) trace.splice(0, trace.length - 120); + document.documentElement.dataset.platformContractEvents = String(trace.length); + } + + function decorate(root = document, pageId = '') { + const shell = root.querySelector?.('.page-shell'); + if (shell && pageId) { + const pageOperations = operations.filter(operation => operation.pageId === pageId); + shell.dataset.specPageId = pageId; + shell.dataset.operationIds = pageOperations.map(operation => operation.operationId).join(' '); + shell.dataset.operationContracts = pageOperations.map(operation => `${operation.method} ${operation.path}`).join(' | '); + } + actions.filter(action => action.pageId === pageId).forEach(action => { + root.querySelectorAll?.(action.selector).forEach(element => { + const existingActionId = element.dataset.specActionId; + if (existingActionId && existingActionId !== action.id) selectorCollisions.add(`${existingActionId} -> ${action.id}`); + element.dataset.specActionId = action.id; + element.dataset.specActionMode = action.mode; + element.dataset.specEffect = action.effect; + element.dataset.mobileBoundary = action.mobile; + const operation = operationById[action.operationIds[0]]; + if (operation) { + element.dataset.operationId = operation.operationId; + element.dataset.operationMethod = operation.method; + element.dataset.operationPath = operation.path; + } + decoratedActionIds.add(action.id); + }); + }); + supportingReads.filter(read => read.pageId === pageId).forEach(read => { + const operation = operationById[read.operationId]; + root.querySelectorAll?.(read.selector).forEach(element => { + element.dataset.specSupportingRead = 'true'; + element.dataset.specEffect = 'read'; + if (operation) { + element.dataset.operationId = operation.operationId; + element.dataset.operationMethod = operation.method; + element.dataset.operationPath = operation.path; + } + }); + }); + navEdges.filter(edge => edge.fromPageId === pageId).forEach(edge => { + root.querySelectorAll?.(edge.selector).forEach(element => { + element.dataset.navEdgeId = edge.id; + element.dataset.navTargetPage = edge.toPageId; + }); + }); + window.__PLATFORM_SPEC_COVERAGE__ = coverage(); + } + + function eventContract(target) { + const element = target?.closest?.('[data-spec-action-id]'); + if (!element) return null; + return { element, actionId: element.dataset.specActionId, operationId: element.dataset.operationId || '', requestId: `req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}` }; + } + + document.addEventListener('click', event => { + const contract = eventContract(event.target); + if (!contract) return; + contract.element.dataset.contractRequestId = contract.requestId; + pushTrace({ phase: 'before', actionId: contract.actionId, operationId: contract.operationId, requestId: contract.requestId }); + setTimeout(() => pushTrace({ phase: 'after', actionId: contract.actionId, operationId: contract.operationId, requestId: contract.requestId, result: 'handled', pageId: document.querySelector('.page-shell')?.dataset.specPageId || '' }), 0); + }, true); + + document.addEventListener('change', event => { + const contract = eventContract(event.target); + if (!contract) return; + pushTrace({ phase: 'after', actionId: contract.actionId, operationId: contract.operationId, requestId: contract.requestId, result: 'changed' }); + }, true); + + const coverage = () => ({ + expected: 59, + total: actions.length, + unique: new Set(actions.map(action => action.id)).size, + catalogued: actions.filter(action => action.label && action.kind).length, + selectorDeclared: actions.filter(action => action.selector).length, + nativeMetadata: actions.filter(action => action.mode === 'native').length, + contractMetadata: actions.filter(action => action.mode === 'contract').length, + nativeMapped: actions.filter(action => action.mode === 'native' && decoratedActionIds.has(action.id)).length, + dedicatedExecutor: explicitResultActionIds.size, + dedicatedExecutorDefinition: 'actions with explicit success/failure result recording', + reachable: actions.filter(action => action.implemented && action.selector).length, + mountedThisSession: decoratedActionIds.size, + decoratedInSession: decoratedActionIds.size, + notYetDecorated: actions.filter(action => !decoratedActionIds.has(action.id)).map(action => action.id), + implemented: actions.filter(action => action.implemented && action.selector).length, + unimplemented: actions.filter(action => !action.implemented || !action.selector).map(action => action.id), + selectorCollisions: Array.from(selectorCollisions), + apiExact: operations.filter(operation => operation.operationId && operation.method && operation.path).length, + uiOnlyExplained: actions.filter(action => action.operationIds.length === 0).length, + effectDeclared: actions.filter(action => action.effect).length, + mobileDeclared: actions.filter(action => action.mobile).length, + actuallyExecuted: new Set(trace.filter(entry => entry.phase === 'result' && entry.result === 'success').map(entry => entry.actionId)).size, + executionNote: 'catalogue and selector metadata are not counted as successful business execution', + navigation: { expected: 11, total: navEdges.length, unique: new Set(navEdges.map(edge => edge.id)).size }, + pageBindings: { expected: 43, total: operations.length, unique: new Set(operations.map(operation => `${operation.pageId}:${operation.operationId}`)).size }, + }); + const assertions = { + pages: new Set(actions.map(action => action.pageId)).size === 12, + actionIds: actions.every(action => action.id.startsWith(`act.${action.pageId.toLowerCase()}.`)), + duplicateActions: new Set(actions.map(action => action.id)).size === actions.length, + operationContracts: operations.every(operation => /^(GET|POST|PUT|PATCH|DELETE)$/.test(operation.method) && operation.path.startsWith('/api/')), + navTargets: navEdges.every(edge => actions.some(action => action.pageId === edge.fromPageId) && actions.some(action => action.pageId === edge.toPageId)), + }; + window.GONGXUE_PLATFORM_SPEC = { actions, operations, navEdges, supportingReads }; + window.__GONGXUE_ACTION_CONTRACT__ = { coverage, assertions, trace }; + window.decoratePlatformSpec = decorate; + window.recordPlatformSpecResult = (actionId, result = {}) => pushTrace({ phase: 'result', actionId, ...result }); + window.__PLATFORM_CONTRACT_EVENTS__ = trace; + window.__PLATFORM_SPEC_COVERAGE__ = coverage(); +})(); diff --git a/Tiku.Api/wwwroot/platform-admin/styles.css b/Tiku.Api/wwwroot/platform-admin/styles.css new file mode 100644 index 0000000..87a5542 --- /dev/null +++ b/Tiku.Api/wwwroot/platform-admin/styles.css @@ -0,0 +1,50 @@ +:root{--navy:#111827;--navy-2:#1d2939;--blue:#1455ff;--blue-50:#f3f6ff;--blue-100:#e7edff;--ink:#101828;--ink-2:#344054;--muted:#667085;--line:#e4e7ec;--canvas:#f5f6f8;--green:#159455;--orange:#ed8b00;--red:#d92d20;--purple:#6941c6;--cyan:#088ab2;--sidebar:230px;font-family:Inter,"PingFang SC","Microsoft YaHei",system-ui,sans-serif;color:var(--ink);background:var(--canvas);font-synthesis:none}*{box-sizing:border-box}html,body{margin:0;min-height:100%;background:var(--canvas)}body{min-width:320px;letter-spacing:0}button,input,select,textarea{font:inherit;letter-spacing:0}button{cursor:pointer;color:inherit}button:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid rgba(20,85,255,.2);outline-offset:2px}svg{width:19px;height:19px}.platform-app{min-height:100vh;display:grid;grid-template-columns:var(--sidebar) minmax(0,1fr)}.platform-sidebar{position:fixed;inset:0 auto 0 0;z-index:30;width:var(--sidebar);display:flex;flex-direction:column;padding:17px 14px;background:var(--navy);color:#d0d5dd;overflow:auto}.platform-brand{height:53px;display:flex;align-items:center;gap:10px;padding:4px 7px;border:0;background:transparent;color:#fff;text-align:left}.platform-brand img{width:37px;height:37px;border-radius:7px}.platform-brand span{display:grid;gap:2px}.platform-brand strong{font-size:15px}.platform-brand small{color:#98a2b3;font-size:8px}.environment{height:34px;display:grid;grid-template-columns:8px 1fr auto;align-items:center;gap:7px;margin:13px 6px 21px;padding:0 9px;border:1px solid #344054;background:#182230;font-size:8px}.environment i{width:7px;height:7px;border-radius:50%;background:#32d583;box-shadow:0 0 0 3px rgba(50,213,131,.1)}.environment b{color:#98a2b3;font-size:7px}#sideNav{display:grid;gap:19px}#sideNav section{display:grid;gap:3px}#sideNav h2{margin:0 10px 5px;color:#667085;font-size:7.5px;text-transform:uppercase}#sideNav button,.platform-foot>button{position:relative;width:100%;height:40px;display:grid;grid-template-columns:20px 1fr auto;align-items:center;gap:8px;padding:0 10px;border:0;border-radius:5px;background:transparent;color:#98a2b3;text-align:left;font-size:10px;font-weight:650}#sideNav button:hover,.platform-foot>button:hover{background:#1d2939;color:#fff}#sideNav button.is-active{background:#23335d;color:#fff}#sideNav button.is-active:before{content:"";position:absolute;left:-14px;width:3px;height:25px;background:#6b8cff}#sideNav svg,.platform-foot svg{width:16px}#sideNav b{min-width:18px;height:18px;display:grid;place-items:center;border-radius:9px;background:#d92d20;color:#fff;font-size:7px}.platform-foot{margin-top:auto;padding-top:14px;border-top:1px solid #344054}.platform-foot>div{height:54px;display:grid;grid-template-columns:31px 1fr 24px;align-items:center;gap:8px;margin:8px 4px 0}.platform-foot>div>span{width:30px;height:30px;display:grid;place-items:center;border-radius:50%;background:#344054;color:#fff;font-size:9px}.platform-foot p{display:grid;gap:3px;margin:0}.platform-foot strong{color:#fff;font-size:9px}.platform-foot small{color:#667085;font-size:7.5px}.platform-foot>div button{border:0;background:transparent;color:#667085}.platform-foot>div svg{width:14px}.platform-workspace{grid-column:2;min-width:0}.platform-topbar{position:sticky;top:0;z-index:20;height:66px;display:flex;align-items:center;gap:22px;padding:0 30px;border-bottom:1px solid var(--line);background:rgba(255,255,255,.96);backdrop-filter:blur(12px)}.page-context{display:grid;gap:3px;min-width:190px}.page-context small{color:#98a2b3;font-size:7px}.page-context strong{font-size:11px}.platform-search{width:min(470px,37vw);height:37px;display:flex;align-items:center;gap:8px;padding:0 10px;border:1px solid transparent;background:#f3f5f7}.platform-search:focus-within{border-color:#a9c0ff;background:#fff}.platform-search svg{width:15px;color:var(--muted)}.platform-search input{min-width:0;width:100%;border:0;outline:0;background:transparent;font-size:9px}.platform-search kbd{padding:2px 5px;border:1px solid var(--line);background:#fff;color:var(--muted);font-size:7px}.top-status{margin-left:auto;display:flex;align-items:center;gap:13px}.top-status>span{display:flex;align-items:center;gap:6px;color:var(--green);font-size:8px}.top-status>span i{width:7px;height:7px;border-radius:50%;background:#32d583}.icon-button{position:relative;width:36px;height:36px;display:grid;place-items:center;border:1px solid var(--line);background:#fff}.icon-button svg{width:16px}.icon-button b{position:absolute;right:-3px;top:-4px;min-width:17px;height:17px;display:grid;place-items:center;border:2px solid #fff;border-radius:9px;background:var(--red);color:#fff;font-size:7px}.mobile-menu{display:none}#mainView{min-height:calc(100vh - 66px);padding:26px 30px 48px}.page-shell{width:min(1540px,100%);margin:0 auto}.page-head{min-height:52px;display:flex;align-items:flex-start;justify-content:space-between;gap:20px;margin-bottom:18px}.page-head h1{margin:0;font-size:21px}.page-head p{margin:5px 0 0;color:var(--muted);font-size:9.5px}.page-head>div:last-child{display:flex;gap:8px}.button{min-height:36px;display:inline-flex;align-items:center;justify-content:center;gap:7px;padding:0 12px;border:1px solid #d0d5dd;background:#fff;font-size:9px;font-weight:650}.button:hover{border-color:#9db9ff;color:var(--blue)}.button.primary{border-color:var(--blue);background:var(--blue);color:#fff}.button.danger{border-color:#ffb4ae;color:var(--red)}.button svg{width:14px}.control{height:36px;border:1px solid var(--line);background:#fff;padding:0 9px;font-size:8px}.link{padding:0;border:0;background:transparent;color:var(--blue);font-size:8px;font-weight:700}.metric-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:11px}.metric{min-height:112px;display:grid;grid-template-columns:40px 1fr;align-items:center;gap:12px;padding:17px;border:1px solid var(--line);background:#fff}.metric-icon{width:38px;height:38px;display:grid;place-items:center;border-radius:6px;background:var(--blue-100);color:var(--blue)}.metric-icon.green{background:#edf9f2;color:var(--green)}.metric-icon.orange{background:#fff5e8;color:var(--orange)}.metric-icon.red{background:#fff0ef;color:var(--red)}.metric-icon.purple{background:#f3f0ff;color:var(--purple)}.metric-icon.cyan{background:#edfafd;color:var(--cyan)}.metric-icon svg{width:17px}.metric div{display:grid;gap:5px}.metric small,.metric p{margin:0;color:var(--muted);font-size:7.5px}.metric strong{font-size:20px}.panel,.table-panel{border:1px solid var(--line);background:#fff}.panel-head{min-height:60px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:0 17px;border-bottom:1px solid var(--line)}.panel-head h2{margin:0;font-size:11px}.panel-head span{display:block;margin-top:4px;color:var(--muted);font-size:7.5px}.overview-grid{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(310px,.72fr);gap:11px;margin-top:11px}.revenue-panel{min-width:0}.legend{display:flex;gap:12px;color:var(--muted);font-size:7.5px}.legend span{display:flex!important;align-items:center;gap:5px}.legend i{width:7px;height:7px;border-radius:50%}.legend .blue{background:var(--blue)}.legend .green{background:var(--green)}.revenue-chart{height:278px;display:grid;grid-template-columns:38px 1fr;padding:24px 18px 18px}.chart-axis{display:flex;flex-direction:column;justify-content:space-between;color:#98a2b3;font-size:7px}.chart-plot{position:relative}.chart-plot .grid{position:absolute;inset:0 0 20px;background:repeating-linear-gradient(to bottom,var(--line) 0,var(--line) 1px,transparent 1px,transparent 33.333%)}.bars{position:absolute;inset:0;display:flex;align-items:end;gap:8px}.bars>span{height:100%;flex:1;display:flex;align-items:end;justify-content:center;gap:3px;position:relative;padding-bottom:20px}.bars i{width:45%;background:var(--blue);opacity:.78}.bars b{width:18%;background:var(--green)}.bars em{position:absolute;bottom:0;color:#98a2b3;font-size:6.5px;font-style:normal}.alert-row{width:100%;min-height:62px;display:grid;grid-template-columns:34px minmax(0,1fr) 14px;align-items:center;gap:9px;padding:9px 14px;border:0;border-bottom:1px solid var(--line);background:#fff;text-align:left}.alert-row:last-child{border-bottom:0}.alert-row:hover{background:#fafbfc}.alert-row>span{width:32px;height:32px;display:grid;place-items:center;border-radius:6px;background:#fff5e8;color:var(--orange)}.alert-row svg{width:15px}.alert-row div{display:grid;gap:4px}.alert-row strong{font-size:8.5px}.alert-row small{color:var(--muted);font-size:7px}.alert-row>svg{color:#98a2b3}.overview-lower{display:grid;grid-template-columns:.8fr 1fr 1fr;gap:11px;margin-top:11px}.tenant-health{min-height:192px;display:grid;grid-template-columns:140px 1fr;align-items:center;gap:10px;padding:17px}.health-donut{width:118px;height:118px;display:grid;place-items:center;align-content:center;gap:2px;border-radius:50%;background:conic-gradient(var(--blue) 79%,#6b8cff 0 89%,#a9bbff 0 95%,#e4e7ec 0);position:relative}.health-donut:before{content:"";position:absolute;width:82px;height:82px;border-radius:50%;background:#fff}.health-donut strong,.health-donut span{position:relative}.health-donut strong{font-size:19px}.health-donut span{color:var(--muted);font-size:7px}.tenant-health>div:last-child{display:grid;gap:10px}.tenant-health p{display:grid;grid-template-columns:55px 1fr 20px;align-items:center;gap:7px;margin:0;font-size:7.5px}.tenant-health p i{height:4px;background:#edf0f4}.tenant-health p i b{display:block;height:100%;background:var(--blue)}.usage-summary{display:grid;grid-template-columns:repeat(3,1fr);padding:18px}.usage-summary>div{display:grid;gap:6px;padding:0 13px;border-right:1px solid var(--line)}.usage-summary>div:first-child{padding-left:0}.usage-summary>div:last-child{border-right:0}.usage-summary span,.usage-summary small{color:var(--muted);font-size:7px}.usage-summary strong{font-size:15px}.usage-summary i,.usage-kpis i,.usage-cell i,.drawer-usage i{height:4px;background:#edf0f4}.usage-summary i b,.usage-kpis i b,.usage-cell i b,.drawer-usage i b{display:block;height:100%;background:var(--blue)}.platform-activity{display:grid;padding:7px 14px}.platform-activity>div{min-height:51px;display:grid;grid-template-columns:27px 1fr;align-items:center;gap:8px;border-bottom:1px solid var(--line)}.platform-activity>div:last-child{border-bottom:0}.platform-activity>div>span{width:26px;height:26px;display:grid;place-items:center;border-radius:50%;background:#f2f4f7;font-size:7px}.platform-activity p{display:grid;gap:4px;margin:0;font-size:7.5px}.platform-activity small{color:var(--muted);font-size:6.5px}.filter-bar{min-height:59px;display:flex;align-items:center;gap:8px;padding:10px 12px;border:1px solid var(--line);background:#fff}.filter-bar label{min-width:300px;height:36px;display:flex;align-items:center;gap:7px;padding:0 9px;border:1px solid var(--line);background:#f8fafc}.filter-bar label svg{width:14px;color:var(--muted)}.filter-bar input{min-width:0;width:100%;border:0;outline:0;background:transparent;font-size:8px}.filter-bar select{height:36px;border:1px solid var(--line);background:#fff;padding:0 28px 0 9px;font-size:8px}.filter-segment{display:flex;gap:3px;margin-left:auto;padding:3px;background:#f2f4f7}.filter-segment button{height:28px;padding:0 9px;border:0;background:transparent;color:var(--muted);font-size:7px;white-space:nowrap}.filter-segment button.is-active{background:#fff;color:var(--blue);box-shadow:0 1px 4px rgba(16,24,40,.08);font-weight:700}.table-panel{overflow:auto;border-top:0}.platform-table{width:100%;min-width:1040px;border-collapse:collapse}.platform-table th{height:42px;padding:0 12px;background:#f8fafc;color:var(--muted);font-size:7px;font-weight:650;text-align:left}.platform-table td{height:65px;padding:8px 12px;border-top:1px solid var(--line);font-size:8px;vertical-align:middle}.platform-table tr:hover td{background:#fcfcfd}.platform-table td>strong,.platform-table td>small{display:block}.platform-table td>small{margin-top:4px;color:var(--muted);font-size:6.5px}.tenant-cell{display:flex;align-items:center;gap:9px}.tenant-cell>span{width:31px;height:31px;display:grid;place-items:center;border-radius:6px;background:var(--blue-100);color:var(--blue);font-size:8px;font-weight:800}.tenant-cell>div{display:grid;gap:4px}.tenant-cell strong{font-size:8.5px}.tenant-cell small{color:var(--muted);font-size:6.5px}.status{display:inline-flex;align-items:center;justify-content:center;min-height:21px;padding:0 7px;border-radius:4px;font-size:6.5px!important;font-style:normal;font-weight:700;white-space:nowrap}.status.success{background:#edf9f2;color:var(--green)}.status.warning{background:#fff5e8;color:#b96700}.status.danger{background:#fff0ef;color:var(--red)}.status.neutral{background:#f2f4f7;color:var(--muted)}.money-due{color:var(--red)}.row-action{width:28px;height:28px;display:grid;place-items:center;border:0;background:transparent;color:var(--muted)}.row-action:hover{background:var(--blue-100);color:var(--blue)}.row-action svg{width:13px}.table-panel>footer{height:48px;display:flex;align-items:center;justify-content:space-between;padding:0 13px;border-top:1px solid var(--line);color:var(--muted);font-size:7px}.table-panel>footer button{display:flex;align-items:center;gap:5px;border:0;background:transparent;color:var(--blue);font-size:7px}.table-panel>footer svg{width:12px}.drawer-backdrop{position:fixed;z-index:60;inset:0;background:rgba(16,24,40,.28)}.tenant-drawer{position:absolute;right:0;top:0;bottom:0;width:min(620px,100%);display:flex;flex-direction:column;background:#fff;box-shadow:-18px 0 50px rgba(16,24,40,.18);overflow:auto}.tenant-drawer>header{min-height:92px;display:flex;align-items:center;justify-content:space-between;padding:16px 21px;border-bottom:1px solid var(--line)}.tenant-drawer>header>div{display:flex;align-items:center;gap:12px}.tenant-drawer>header>div>span{width:45px;height:45px;display:grid;place-items:center;border-radius:8px;background:var(--blue-100);color:var(--blue);font-size:12px;font-weight:800}.tenant-drawer header small{color:var(--blue);font-size:7px}.tenant-drawer h2{margin:4px 0;font-size:15px}.tenant-drawer header p{margin:0;color:var(--muted);font-size:7px}.tenant-drawer header>button{border:0;background:transparent}.tenant-drawer header svg{width:17px}.drawer-status{display:grid!important;grid-template-columns:repeat(4,1fr);padding:0!important;border-bottom:1px solid var(--line)}.drawer-status>div{min-height:73px;display:grid;align-content:center;gap:6px;padding:13px;border-right:1px solid var(--line)}.drawer-status>div:last-child{border-right:0}.drawer-status span{color:var(--muted);font-size:7px}.drawer-status strong{font-size:8px}.drawer-tabs{height:43px;display:flex;gap:16px;padding:0 20px;border-bottom:1px solid var(--line)}.drawer-tabs button{border:0;background:transparent;color:var(--muted);font-size:7.5px}.drawer-tabs button.is-active{color:var(--blue);border-bottom:2px solid var(--blue);font-weight:700}.tenant-drawer>section{padding:19px 21px;border-bottom:1px solid var(--line)}.tenant-drawer>section h3{margin:0 0 12px;font-size:10px}.tenant-drawer dl{display:grid;grid-template-columns:1fr 1fr;gap:13px;margin:0}.tenant-drawer dl div{display:grid;gap:4px}.tenant-drawer dt{color:var(--muted);font-size:7px}.tenant-drawer dd{margin:0;font-size:8px}.domain-item{min-height:49px;display:grid;grid-template-columns:24px 1fr auto;align-items:center;gap:8px;border-bottom:1px solid var(--line)}.domain-item>svg{width:15px;color:var(--blue)}.domain-item span{display:grid;gap:4px}.domain-item strong{font-size:8px}.domain-item small{color:var(--muted);font-size:6.5px}.drawer-usage{display:grid;gap:13px}.drawer-usage>div{display:grid;grid-template-columns:80px 1fr;align-items:center;gap:6px 12px}.drawer-usage span{color:var(--muted);font-size:7px}.drawer-usage strong{font-size:8px}.drawer-usage i{grid-column:2}.tenant-drawer>footer{position:sticky;bottom:0;display:flex;justify-content:flex-end;gap:7px;margin-top:auto;padding:12px 20px;border-top:1px solid var(--line);background:#fff}.lifecycle-summary{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}.lifecycle-summary article{min-height:92px;display:grid;grid-template-columns:42px 1fr 20px;align-items:center;gap:10px;padding:15px;border:1px solid var(--line);background:#fff}.stage-number{width:40px;height:40px;display:grid;place-items:center;border-radius:50%;background:var(--blue-100);color:var(--blue);font-size:15px;font-weight:800}.stage-number.risk{background:#fff0ef;color:var(--red)}.lifecycle-summary article>div{display:grid;gap:5px}.lifecycle-summary strong{font-size:9px}.lifecycle-summary small{color:var(--muted);font-size:7px}.lifecycle-summary button{border:0;background:transparent;color:#98a2b3}.lifecycle-summary svg{width:13px}.lifecycle-grid{display:grid;grid-template-columns:1.2fr .8fr;gap:11px;margin-top:11px}.lifecycle-row{min-height:64px;display:grid;grid-template-columns:31px minmax(0,1fr) 42px auto;align-items:center;gap:9px;padding:8px 15px;border-bottom:1px solid var(--line)}.lifecycle-row>span{width:29px;height:29px;display:grid;place-items:center;border-radius:6px;background:var(--blue-100);color:var(--blue);font-size:8px}.lifecycle-row>div{display:grid;gap:4px}.lifecycle-row strong{font-size:8px}.lifecycle-row small{color:var(--muted);font-size:6.5px}.lifecycle-row>b{color:var(--orange);font-size:8px}.setup-card{padding:17px}.setup-card header{display:flex;justify-content:space-between;margin-bottom:12px}.setup-card header span{font-size:9px;font-weight:700}.setup-card header b{color:var(--blue);font-size:8px}.setup-card p{display:flex;align-items:center;gap:7px;margin:0;min-height:35px;color:var(--muted);font-size:7.5px}.setup-card p.done{color:var(--green)}.setup-card p svg{width:14px}.setup-card .button{width:100%;margin-top:12px}.plan-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:11px}.plan-card{min-height:330px;padding:20px;border:1px solid var(--line);background:#fff}.plan-card.is-featured{border:2px solid var(--blue);padding:19px}.plan-card header{display:flex;justify-content:space-between;gap:10px}.plan-card header span{font-size:13px;font-weight:800}.plan-card header p{margin:5px 0 0;color:var(--muted);font-size:7.5px}.plan-card header b{height:21px;padding:0 7px;display:flex;align-items:center;background:var(--blue-100);color:var(--blue);font-size:6.5px;white-space:nowrap}.plan-card>strong{display:block;margin:26px 0 22px;color:var(--blue);font-size:31px}.plan-card>strong small{color:var(--muted);font-size:8px}.plan-card ul{display:grid;gap:12px;margin:0 0 25px;padding:18px 0;border-block:1px solid var(--line);list-style:none}.plan-card li{display:flex;align-items:center;gap:7px;font-size:8px}.plan-card li svg{width:13px;color:var(--green)}.plan-card>.button{width:100%}.subscription-table{margin-top:11px;border-top:1px solid var(--line)}.table-head{height:58px;display:flex;align-items:center;justify-content:space-between;padding:0 15px;border-bottom:1px solid var(--line)}.table-head h2{margin:0;font-size:10px}.table-head span{color:var(--muted);font-size:7px}.table-head label{width:220px;height:32px;display:flex;align-items:center;gap:6px;padding:0 8px;border:1px solid var(--line);background:#f8fafc}.table-head svg{width:13px;color:var(--muted)}.table-head input{min-width:0;width:100%;border:0;outline:0;background:transparent;font-size:7.5px}.switch{width:32px;height:18px;padding:2px;border:0;border-radius:9px;background:#d0d5dd}.switch i{display:block;width:14px;height:14px;border-radius:50%;background:#fff}.switch.is-on{background:var(--blue)}.switch.is-on i{transform:translateX(14px)}.billing-metrics{grid-template-columns:repeat(4,1fr)}.billing-tabs{height:47px;display:flex;gap:22px;margin-top:11px;padding:0 15px;border:1px solid var(--line);border-bottom:0;background:#fff}.billing-tabs button{border:0;background:transparent;color:var(--muted);font-size:8px}.billing-tabs button.is-active{color:var(--blue);border-bottom:2px solid var(--blue);font-weight:700}.collection-console{display:grid;grid-template-columns:1.2fr .8fr;gap:11px}.collection-console>.panel{border-top:0}.collection-console article{min-height:70px;display:grid;grid-template-columns:34px minmax(0,1fr) auto auto;align-items:center;gap:9px;padding:9px 15px;border-bottom:1px solid var(--line)}.collection-console article>span{width:32px;height:32px;display:grid;place-items:center;background:#fff0ef;color:var(--red)}.collection-console article svg{width:15px}.collection-console article>div{display:grid;gap:4px}.collection-console article strong{font-size:8.5px}.collection-console article small{color:var(--muted);font-size:7px}.collection-console article>b{color:var(--red);font-size:8px}.policy-timeline{padding:15px}.policy-timeline p{position:relative;display:grid;gap:4px;margin:0;padding:0 0 20px 20px;border-left:1px solid var(--line)}.policy-timeline p:last-child{padding-bottom:0}.policy-timeline p:before{content:"";position:absolute;left:-4px;top:2px;width:7px;height:7px;border-radius:50%;background:var(--blue)}.policy-timeline b{font-size:8px}.policy-timeline span{color:var(--muted);font-size:7px}.usage-kpis{display:grid;grid-template-columns:repeat(4,1fr);margin-bottom:11px;border:1px solid var(--line);background:#fff}.usage-kpis>div{min-height:110px;display:grid;gap:6px;align-content:center;padding:16px 18px;border-right:1px solid var(--line)}.usage-kpis>div:last-child{border-right:0}.usage-kpis span,.usage-kpis small{color:var(--muted);font-size:7px}.usage-kpis strong{font-size:20px}.usage-kpis i.orange b{background:var(--orange)}.usage-cell{min-width:110px;display:grid;gap:6px}.usage-cell span{font-size:7px}.bank-layout{min-height:600px;display:grid;grid-template-columns:280px minmax(0,1fr);border:1px solid var(--line);background:#fff}.bank-tree{border-right:1px solid var(--line)}.bank-tree header{height:51px;display:flex;align-items:center;justify-content:space-between;padding:0 13px;border-bottom:1px solid var(--line)}.bank-tree header strong{font-size:9px}.bank-tree header button{border:0;background:transparent;color:var(--blue)}.bank-tree header svg{width:14px}.bank-tree>button{width:100%;min-height:57px;display:grid;grid-template-columns:28px 1fr 14px;align-items:center;gap:8px;padding:8px 12px;border:0;border-bottom:1px solid var(--line);background:#fff;text-align:left}.bank-tree>button.is-active{background:var(--blue-50);box-shadow:inset 3px 0 var(--blue)}.bank-tree>button>svg:first-child{width:16px;color:var(--blue)}.bank-tree>button>svg:last-child{width:13px;color:#98a2b3}.bank-tree>button span{display:grid;gap:4px}.bank-tree strong{font-size:8px}.bank-tree small{color:var(--muted);font-size:6.5px}.bank-content{min-width:0;padding:22px}.bank-content>header{display:flex;align-items:flex-start;justify-content:space-between}.bank-content header span{color:var(--blue);font-size:7px}.bank-content h2{margin:7px 0 5px;font-size:16px}.bank-content header p{margin:0;color:var(--muted);font-size:7.5px}.bank-stats{display:grid;grid-template-columns:repeat(4,1fr);margin:20px 0;border:1px solid var(--line)}.bank-stats>div{min-height:75px;display:grid;align-content:center;gap:5px;padding:13px;border-right:1px solid var(--line)}.bank-stats>div:last-child{border-right:0}.bank-stats span{color:var(--muted);font-size:7px}.bank-stats strong{font-size:17px}.version-card{min-height:115px;display:flex;align-items:center;justify-content:space-between;gap:15px;padding:17px;border:1px solid var(--line);margin-top:10px}.version-card h3{margin:8px 0 6px;font-size:12px}.version-card p{margin:0;color:var(--ink-2);font-size:8px}.version-card small{display:block;margin-top:8px;color:var(--muted);font-size:6.5px}.version-card>div:last-child{display:flex;gap:7px}.audit-filter label{min-width:320px}.audit-table code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:6.5px}.severity{display:inline-flex;align-items:center;justify-content:center;min-width:34px;height:20px;border-radius:4px;font-size:6.5px;font-weight:800}.severity.success{background:#edf9f2;color:var(--green)}.severity.warning{background:#fff5e8;color:#b96700}.severity.danger{background:#fff0ef;color:var(--red)}.severity.neutral{background:#f2f4f7;color:var(--muted)}.alert-layout{display:grid;grid-template-columns:minmax(0,1.35fr) minmax(320px,.65fr);gap:11px}.security-alert{min-height:94px;display:grid;grid-template-columns:40px minmax(0,1fr) auto auto;align-items:center;gap:11px;padding:12px 15px;border-bottom:1px solid var(--line)}.alert-level{width:38px;height:38px;display:grid;place-items:center;border-radius:50%;font-size:7px;font-weight:800}.alert-level.danger{background:#fff0ef;color:var(--red)}.alert-level.warning{background:#fff5e8;color:#b96700}.security-alert>div{display:grid;gap:4px}.security-alert small,.security-alert em{color:var(--muted);font-size:6.5px;font-style:normal}.security-alert strong{font-size:8.5px}.security-alert p{margin:0;color:var(--ink-2);font-size:7px}.channel-panel{display:grid;align-content:start;gap:11px}.channel-row{min-height:65px;display:grid;grid-template-columns:32px minmax(0,1fr) auto 22px;align-items:center;gap:8px;padding:9px 13px;border-bottom:1px solid var(--line)}.channel-row>span{width:30px;height:30px;display:grid;place-items:center;background:var(--blue-100);color:var(--blue)}.channel-row svg{width:14px}.channel-row>div{display:grid;gap:4px}.channel-row strong{font-size:8px}.channel-row small{color:var(--muted);font-size:6.5px}.channel-row>button{border:0;background:transparent}.failure-panel>div:last-child{padding:15px}.failure-panel strong{font-size:8px}.failure-panel p{color:var(--muted);font-size:7px;line-height:1.6}.staff-layout{display:grid;grid-template-columns:1.1fr .9fr;gap:11px}.staff-row{min-height:67px;display:grid;grid-template-columns:32px minmax(0,1fr) 82px 90px auto 22px;align-items:center;gap:9px;padding:9px 14px;border-bottom:1px solid var(--line)}.staff-row>span{width:30px;height:30px;display:grid;place-items:center;border-radius:50%;background:var(--blue-100);color:var(--blue);font-size:8px}.staff-row>div{display:grid;gap:4px}.staff-row strong{font-size:8px}.staff-row small{color:var(--muted);font-size:6.5px}.staff-row>b,.staff-row>em{font-size:7px;font-style:normal;font-weight:500}.staff-row>button{border:0;background:transparent}.role-row{min-height:68px;display:grid;grid-template-columns:32px minmax(0,1fr) 30px auto;align-items:center;gap:9px;padding:9px 14px;border-bottom:1px solid var(--line)}.role-row>span{width:30px;height:30px;display:grid;place-items:center;background:#edf9f2;color:var(--green)}.role-row svg{width:14px}.role-row>div{display:grid;gap:4px}.role-row strong{font-size:8px}.role-row small{color:var(--muted);font-size:6.5px}.role-row>b{font-size:7px}.toast{position:fixed;z-index:100;left:50%;bottom:24px;min-height:40px;display:flex;align-items:center;gap:8px;padding:0 13px;background:#101828;color:#fff;box-shadow:0 12px 30px rgba(16,24,40,.24);transform:translate(-50%,16px);opacity:0;pointer-events:none;transition:.2s;font-size:8px}.toast.is-visible{transform:translate(-50%,0);opacity:1}.toast svg{width:14px;color:#78e2a8} +@media(max-width:1240px){:root{--sidebar:210px}.metric-grid{grid-template-columns:repeat(3,1fr)}.overview-grid{grid-template-columns:1fr}.overview-lower{grid-template-columns:1fr 1fr}.activity-panel{grid-column:1/-1}.lifecycle-grid,.collection-console,.alert-layout{grid-template-columns:1fr}.staff-layout{grid-template-columns:1fr}} +@media(max-width:900px){.platform-app{display:block}.platform-sidebar{transform:translateX(-100%);transition:.2s;box-shadow:18px 0 48px rgba(16,24,40,.28)}.platform-app.menu-open .platform-sidebar{transform:translateX(0)}.platform-workspace{min-width:0}.platform-topbar{height:58px;padding:0 12px;gap:9px}.mobile-menu{display:grid}.page-context small,.platform-search kbd,.top-status>span{display:none}.page-context{min-width:120px}.platform-search{width:auto;flex:1}#mainView{padding:18px 12px 32px}.page-head{align-items:flex-start;flex-direction:column}.page-head>div:last-child{width:100%;overflow:auto}.metric-grid{grid-template-columns:1fr 1fr}.overview-lower{grid-template-columns:1fr}.activity-panel{grid-column:auto}.filter-bar{align-items:stretch;flex-wrap:wrap}.filter-bar label{min-width:100%}.filter-bar select{flex:1}.filter-segment{width:100%;margin-left:0;overflow:auto}.filter-segment button{flex:1}.table-panel{border-top:1px solid var(--line)}.lifecycle-summary{grid-template-columns:1fr 1fr}.plan-grid{grid-template-columns:1fr}.collection-console{grid-template-columns:1fr}.usage-kpis{grid-template-columns:1fr 1fr}.bank-layout{display:block}.bank-tree{border-right:0;border-bottom:1px solid var(--line)}.bank-tree>button{display:inline-grid;width:260px;border-right:1px solid var(--line)}.bank-tree{overflow:auto;white-space:nowrap}.bank-tree header{position:sticky;left:0;background:#fff}.bank-content{padding:16px}.staff-layout{grid-template-columns:1fr}} +@media(max-width:560px){.page-context{display:none}.top-status .icon-button{display:none}.metric-grid{grid-template-columns:1fr}.metric{min-height:94px}.revenue-chart{height:230px;padding-inline:8px}.overview-lower{grid-template-columns:1fr}.tenant-health{grid-template-columns:1fr;justify-items:center}.usage-summary{grid-template-columns:1fr}.usage-summary>div{padding:12px 0;border-right:0;border-bottom:1px solid var(--line)}.usage-summary>div:last-child{border-bottom:0}.lifecycle-summary{grid-template-columns:1fr}.drawer-status{grid-template-columns:1fr 1fr}.drawer-status>div:nth-child(2){border-right:0}.drawer-status>div:nth-child(-n+2){border-bottom:1px solid var(--line)}.drawer-tabs{overflow:auto}.drawer-tabs button{min-width:max-content}.tenant-drawer dl{grid-template-columns:1fr}.billing-metrics{grid-template-columns:1fr}.billing-tabs{overflow:auto}.billing-tabs button{min-width:max-content}.usage-kpis{grid-template-columns:1fr}.usage-kpis>div{border-right:0;border-bottom:1px solid var(--line)}.bank-stats{grid-template-columns:1fr 1fr}.bank-stats>div:nth-child(2){border-right:0}.bank-stats>div:nth-child(-n+2){border-bottom:1px solid var(--line)}.version-card{align-items:flex-start;flex-direction:column}.security-alert{grid-template-columns:38px minmax(0,1fr)}.security-alert>b,.security-alert>.button{grid-column:2;width:max-content}.staff-row{grid-template-columns:31px minmax(0,1fr) auto}.staff-row>em,.staff-row>i{display:none}.role-row{grid-template-columns:30px minmax(0,1fr) auto}.role-row>.button{grid-column:2/-1}.tenant-drawer{width:100%}} +@media(prefers-reduced-motion:reduce){*,*:before,*:after{transition:none!important}} + +/* Global productive typography floor: dense, but never miniature. */ +:root{--text-caption:12px;--text-label:13px;--text-body:14px;--text-heading:18px;--text-title:24px;--line-caption:18px;--line-body:22px} +.platform-brand small,.environment,.environment b,.environment-switch,.environment-switch small,#sideNav section h2,.platform-foot small,.platform-foot>div>span,.platform-foot strong,.page-context small,.platform-search input,.platform-search kbd,.top-status,.top-status>span,.icon-button b,.page-head p,.metric small,.metric p,.panel-head span,.legend,.chart-axis,.bars em,.alert-row small,.health-donut span,.tenant-health p,.usage-summary span,.usage-summary small,.platform-activity>span,.platform-activity small,.filter-bar input,.filter-bar select,.platform-table th,.platform-table td>small,.tenant-cell small,.table-panel>footer,.tenant-drawer header small,.tenant-drawer header p,.drawer-status span,.drawer-tabs button,.tenant-drawer dt,.domain-item small,.drawer-usage span,.setup-card p,.plan-card header p,.plan-card header b,.plan-card>strong small,.table-head span,.table-head input,.billing-tabs button,.collection-console article small,.policy-timeline span,.usage-kpis span,.usage-kpis small,.usage-cell span,.bank-tree small,.bank-content header span,.bank-content header p,.bank-stats span,.version-card small,.audit-table code,.severity,.security-alert small,.security-alert em,.security-alert p,.alert-level,.channel-row small,.failure-panel p,.staff-row small,.staff-row>b,.staff-row>em,.role-row small,.role-row>b{font-size:var(--text-caption)!important;line-height:var(--line-caption)} +#sideNav button,.platform-foot>button,.page-context strong,.button,.control,.link,.filter-segment button,.platform-table td,.platform-table footer button,.tenant-drawer h3,.tenant-drawer dd,.domain-item strong,.drawer-usage strong,.setup-card header span,.setup-card header b,.plan-card li,.table-head h2,.collection-console article strong,.collection-console article>b,.policy-timeline b,.bank-tree header strong,.bank-tree strong,.version-card p,.security-alert strong,.channel-row strong,.failure-panel strong,.staff-row strong,.role-row strong,.status,.toast{font-size:var(--text-label)!important;line-height:20px} +.page-head h1{font-size:var(--text-title);line-height:32px}.panel-head h2,.tenant-drawer h2,.bank-content h2{font-size:var(--text-heading);line-height:26px}.metric strong{font-size:28px;line-height:36px}.usage-kpis strong{font-size:24px;line-height:32px}.plan-card>strong{font-size:36px;line-height:44px}.platform-table th{height:46px}.platform-table td{height:68px}.button,.control,.platform-search,.filter-bar label,.filter-bar select{min-height:40px}.icon-button{width:40px;height:40px}.status,.severity{min-height:24px;padding-inline:8px}.tenant-drawer{width:min(640px,100%)}.tenant-drawer>header>button{width:40px;height:40px;display:grid;place-items:center}.drawer-tabs{height:48px}.drawer-tabs button{padding-inline:12px}.tenant-drawer>footer{gap:8px} +@media(max-width:900px){:root{--text-body:15px;--text-label:14px}.button,.control,.icon-button{min-height:44px}.platform-table td{font-size:14px!important}.platform-table th{font-size:12px!important}} + +/* v1.1 specification interaction layer */ +.prototype-controls{display:flex;align-items:center;gap:7px}.prototype-controls label{height:40px;display:flex;align-items:center;gap:6px;padding:0 8px;border:1px solid var(--line);background:#fff}.prototype-controls span{color:var(--muted);font-size:12px;white-space:nowrap}.prototype-controls select{max-width:118px;border:0;outline:0;background:transparent;font-size:12px;color:var(--ink)} +.page-id{display:block;margin:0 0 5px;color:var(--blue);font-size:12px;font-weight:750}.page-head.is-subpage{padding-bottom:14px;border-bottom:1px solid var(--line)}.subpage-nav{display:flex!important;align-items:center;gap:8px!important;margin-bottom:14px}.subpage-nav .icon-button{min-height:40px}.cell-link{padding:0;border:0;background:transparent;color:inherit;text-align:left;font:inherit}.tenant-cell.cell-link{display:flex}.row-actions,.inline-actions{display:flex;align-items:center;gap:6px}.platform-table tr.is-highlighted td{background:var(--blue-50)} +.state-panel{min-height:300px;display:grid;place-items:center;align-content:center;gap:12px;padding:36px;border:1px solid var(--line);background:#fff;text-align:center}.state-panel>span{width:52px;height:52px;display:grid;place-items:center;border-radius:50%;background:var(--blue-100);color:var(--blue)}.state-panel>span svg{width:24px}.state-panel h2,.state-panel p{margin:0}.state-panel h2{font-size:20px}.state-panel p{max-width:650px;color:var(--muted);font-size:14px;line-height:22px}.state-panel>div{display:flex;gap:8px}.state-panel.is-error>span{background:#fff0ef;color:var(--red)}.state-panel.is-forbidden>span{background:#fff5e8;color:var(--orange)}.state-panel.is-partial{min-height:180px;margin-top:12px;background:#fffdf7}.skeleton-stack{display:grid;gap:13px;padding:28px;border:1px solid var(--line);background:#fff}.skeleton-stack i{width:var(--w);height:42px;background:linear-gradient(90deg,#f2f4f7,#fafafa,#f2f4f7);background-size:200% 100%;animation:skeleton 1.2s linear infinite}.state-hint{color:var(--muted);font-size:13px}@keyframes skeleton{to{background-position:-200% 0}} +.detail-summary{display:grid;grid-template-columns:repeat(5,1fr);margin-bottom:12px;border:1px solid var(--line);background:#fff}.detail-summary>div{min-height:84px;display:grid;align-content:center;gap:7px;padding:14px 17px;border-right:1px solid var(--line)}.detail-summary>div:last-child{border-right:0}.detail-summary span{color:var(--muted);font-size:12px}.detail-summary strong{font-size:16px}.detail-grid{display:grid;grid-template-columns:minmax(0,1.25fr) minmax(340px,.75fr);gap:12px}.detail-list{display:grid;grid-template-columns:1fr 1fr;gap:20px;margin:0;padding:22px}.detail-list div{display:grid;gap:6px}.detail-list dt{color:var(--muted);font-size:12px}.detail-list dd{margin:0;font-size:14px;line-height:22px}.action-panel>button{width:100%;min-height:72px;display:grid;grid-template-columns:32px 1fr 20px;align-items:center;gap:10px;padding:12px 16px;border:0;border-bottom:1px solid var(--line);background:#fff;text-align:left}.action-panel>button:hover{background:var(--blue-50)}.action-panel>button>svg:first-child{color:var(--blue)}.action-panel>button>span{display:grid;gap:5px}.action-panel>button strong{font-size:14px}.action-panel>button small{color:var(--muted);font-size:12px}.timeline-panel{margin-top:12px}.timeline{display:grid;grid-template-columns:repeat(4,1fr);padding:22px}.timeline p{position:relative;display:grid;gap:6px;margin:0;padding:0 20px 0 25px;border-top:2px solid var(--line)}.timeline p:before{content:"";position:absolute;left:0;top:-7px;width:12px;height:12px;border-radius:50%;background:#d0d5dd}.timeline p.done{border-color:#98b1ff}.timeline p.done:before{background:var(--blue)}.timeline p.risk{border-color:#ffb4ae}.timeline p.risk:before{background:var(--red)}.timeline b{padding-top:16px;font-size:14px}.timeline span{color:var(--muted);font-size:12px;line-height:18px} +.form-workspace{display:grid;gap:12px}.form-section{overflow:hidden}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;padding:22px}.form-grid label,.modal-form>label,.alert-evidence>label{display:grid;align-content:start;gap:7px}.form-grid label.wide,.modal-detail .wide{grid-column:1/-1}.form-grid label>span,.modal-form label>span,.alert-evidence label>span{font-size:13px;font-weight:700}.form-grid input,.form-grid select,.form-grid textarea,.modal-form input,.modal-form select,.modal-form textarea,.alert-evidence textarea{width:100%;min-height:42px;padding:9px 11px;border:1px solid #d0d5dd;background:#fff;color:var(--ink);font-size:14px}.form-grid textarea,.modal-form textarea{resize:vertical}.form-grid input:disabled,.form-grid select:disabled{background:#f2f4f7;color:var(--muted)}.form-actions{position:sticky;bottom:0;z-index:4;display:flex;justify-content:flex-end;align-items:center;gap:8px;padding:14px 18px;border:1px solid var(--line);background:#fff}.form-actions>span{margin-right:auto;color:var(--muted);font-size:12px}.form-error-summary{margin:0;padding:14px 17px;border:1px solid #ffb4ae;background:#fff0ef;color:#b42318;font-size:13px}.form-error-summary strong{font-size:14px}.form-error-summary ul{margin:8px 0 0;padding-left:20px}.field-error{color:#b42318;font-size:12px;line-height:18px}.is-invalid{border-color:var(--red)!important;box-shadow:0 0 0 3px rgba(217,45,32,.08)} +.toggle-label{display:flex;align-items:center;gap:7px;padding:0 10px;border:1px solid var(--line);background:#fff;font-size:13px}.modal-backdrop{position:fixed;z-index:80;inset:0;display:grid;place-items:center;padding:24px;background:rgba(16,24,40,.42)}.modal{width:min(620px,100%);max-height:min(820px,calc(100vh - 48px));display:flex;flex-direction:column;border:1px solid var(--line);background:#fff;box-shadow:0 24px 80px rgba(16,24,40,.25)}.modal.is-wide{width:min(880px,100%)}.modal>header{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;padding:20px 22px;border-bottom:1px solid var(--line)}.modal>header small{color:var(--blue);font-size:12px;font-weight:750}.modal>header h2{margin:5px 0;font-size:20px}.modal>header p{margin:0;color:var(--muted);font-size:13px;line-height:20px}.modal>header .icon-button{flex:0 0 auto}.modal>footer{display:flex;justify-content:flex-end;gap:8px;padding:14px 20px;border-top:1px solid var(--line);background:#fff}.modal-form{min-height:0;display:grid;gap:16px;padding:20px 22px;overflow:auto}.modal-form .form-grid{padding:0}.check-row{display:flex!important;align-items:center!important;grid-template-columns:20px 1fr;gap:8px!important;min-height:42px}.check-row input{width:18px!important;min-height:18px!important}.danger-notice{display:flex;gap:8px;padding:12px;border:1px solid #ffb4ae;background:#fff0ef;color:#b42318;font-size:13px;line-height:20px}.modal-form fieldset{margin:0;padding:16px;border:1px solid var(--line)}.modal-form legend{padding:0 6px;font-size:14px;font-weight:700}.permission-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:12px}.permission-grid label{display:flex;align-items:center;gap:7px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}.permission-grid input{width:16px;min-height:16px}.modal-detail{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin:0;padding:22px;overflow:auto}.modal-detail div{display:grid;gap:6px}.modal-detail dt{color:var(--muted);font-size:12px}.modal-detail dd{margin:0;font-size:14px;line-height:22px}.candidate-list,.task-list{display:grid;gap:0;min-height:0;overflow:auto}.candidate-list label{min-height:68px;display:grid;grid-template-columns:20px 1fr;align-items:center;gap:10px;padding:12px 22px;border-bottom:1px solid var(--line)}.candidate-list label>span{display:grid;gap:5px}.candidate-list strong{font-size:14px}.candidate-list small{color:var(--muted);font-size:12px}.candidate-list>p{padding:30px;text-align:center;color:var(--muted)}.alert-evidence{display:grid;gap:18px;padding:22px;overflow:auto}.evidence-head{display:grid;grid-template-columns:42px 1fr auto;align-items:center;gap:12px}.evidence-head>div{display:grid;gap:5px}.evidence-head strong{font-size:15px}.evidence-head small{color:var(--muted);font-size:12px}.alert-evidence ul{display:grid;gap:8px;margin:0;padding:0;list-style:none}.alert-evidence li{display:flex;align-items:center;gap:8px;padding:10px 12px;background:#f8fafc;font-size:13px}.alert-evidence li svg{width:15px;color:var(--blue)}.task-list article{min-height:80px;display:grid;grid-template-columns:38px 1fr auto;align-items:center;gap:12px;padding:12px 20px;border-bottom:1px solid var(--line)}.task-state{width:36px;height:36px;display:grid;place-items:center;border-radius:50%;background:#f2f4f7;color:var(--muted)}.task-state.success{background:#edf9f2;color:var(--green)}.task-state.danger{background:#fff0ef;color:var(--red)}.task-state.warning svg{animation:spin 1s linear infinite}.task-list article>div{display:grid;gap:4px}.task-list strong{font-size:14px}.task-list small,.task-list p{margin:0;color:var(--muted);font-size:12px;line-height:18px}@keyframes spin{to{transform:rotate(360deg)}} +.toast.is-error svg{color:#ffb4ae}.failure-panel article{padding:0}.channel-row .row-actions{justify-content:flex-end}.collection-console article .inline-actions{justify-content:flex-end}.collection-console article .inline-actions .button{white-space:nowrap} +.confirm-impact{display:grid;grid-template-columns:42px 1fr;align-items:center;gap:12px;padding:22px}.confirm-impact>span{width:40px;height:40px;display:grid;place-items:center;border-radius:50%;background:#fff0ef;color:var(--red)}.confirm-impact p{margin:0;font-size:14px;line-height:22px}.icons-unavailable [data-lucide]{display:none}.icons-unavailable .button:empty,.icons-unavailable .icon-button:empty{display:none} +.unsaved-leave-guard{position:fixed;z-index:140;inset:0;display:grid;place-items:center;padding:24px;background:rgba(16,24,40,.5)}.leave-guard-card{width:min(520px,100%);box-shadow:0 28px 90px rgba(16,24,40,.28)} +.alert-row strong,.platform-activity p{font-size:13px;line-height:20px}.platform-activity p strong{font-size:13px}.tenant-health p strong{font-size:13px}.failure-panel article strong{font-size:13px}.failure-panel article p{font-size:12px;line-height:18px} +.platform-activity>div>span,#sideNav b{font-size:12px!important}.platform-foot>div>span{font-size:13px!important}.mobile-boundary{display:grid;grid-template-columns:48px 1fr;align-items:start;gap:14px;padding:22px}.mobile-boundary>span{width:46px;height:46px;display:grid;place-items:center;background:var(--blue-100);color:var(--blue)}.mobile-boundary strong{font-size:15px}.mobile-boundary p{margin:6px 0 0;color:var(--muted);font-size:13px;line-height:20px} +.tenant-cell>span,.staff-row>span{font-size:12px!important}.tenant-cell strong{font-size:13px!important;line-height:20px} +.sidebar-backdrop{display:none;position:fixed;z-index:29;inset:0;border:0;background:rgba(16,24,40,.42)} +@media(max-width:1320px){.prototype-controls label span{display:none}.prototype-controls select{max-width:105px}.detail-summary{grid-template-columns:repeat(3,1fr)}.detail-summary>div:nth-child(3){border-right:0}.detail-summary>div:nth-child(-n+3){border-bottom:1px solid var(--line)}} +@media(max-width:1050px){.prototype-controls{display:none}.detail-grid{grid-template-columns:1fr}.timeline{grid-template-columns:1fr}.timeline p{padding-bottom:16px;border-top:0;border-left:2px solid var(--line)}.timeline p:before{top:0;left:-7px}.timeline b{padding-top:0}.detail-summary{grid-template-columns:1fr 1fr}.detail-summary>div:nth-child(3){border-right:1px solid var(--line)}.detail-summary>div:nth-child(even){border-right:0}.detail-summary>div{border-bottom:1px solid var(--line)}} +@media(max-width:900px){body.menu-locked{overflow:hidden}.platform-app.menu-open+.sidebar-backdrop{display:block}.modal-backdrop{padding:0}.modal,.modal.is-wide{width:100%;height:100%;max-height:none}.modal>header{padding:16px}.modal>footer{padding:12px 14px}.form-grid,.modal-form .form-grid,.modal-detail,.permission-grid{grid-template-columns:1fr}.form-grid label.wide,.modal-detail .wide{grid-column:auto}.form-actions{position:static;bottom:auto;z-index:auto;flex-wrap:wrap}.form-actions>span{width:100%;margin:0}.row-action,.switch{position:relative;width:44px;height:44px;flex:0 0 44px}.row-action{background:transparent}.row-action:before{content:"";position:absolute;inset:8px;background:transparent}.row-action:hover{background:transparent}.row-action:hover:before{background:var(--blue-100)}.row-action svg{position:relative;z-index:1}.switch{display:grid;place-items:center;padding:0;border-radius:0;background:transparent}.switch:before{content:"";position:absolute;left:6px;top:13px;width:32px;height:18px;border-radius:9px;background:#d0d5dd}.switch i{position:relative;z-index:1;transform:translateX(-7px)}.switch.is-on{background:transparent}.switch.is-on:before{background:var(--blue)}.switch.is-on i{transform:translateX(7px)}.detail-summary{grid-template-columns:1fr 1fr}.collection-console article{grid-template-columns:34px minmax(0,1fr)}.collection-console article>b,.collection-console article>.inline-actions{grid-column:2}.platform-topbar{overflow:hidden}.platform-search{min-width:0}} +@media(max-width:900px){.unsaved-leave-guard{padding:0;align-items:end}.unsaved-leave-guard .modal{width:100%;height:auto;max-height:100vh}.leave-guard-card footer{flex-wrap:wrap}.leave-guard-card footer .button{min-height:44px;flex:1}} +@media(max-width:560px){.detail-summary{grid-template-columns:1fr}.detail-summary>div{border-right:0!important}.page-head.is-subpage>div:last-child{display:none}.modal>footer{overflow:auto;justify-content:flex-start}.modal>footer .button{white-space:nowrap}.form-actions .button{flex:1;min-width:0;white-space:nowrap}.alert-layout,.channel-panel,.panel{min-width:0}.panel-head{align-items:flex-start;flex-wrap:wrap;padding-block:12px}.panel-head>.filter-segment{max-width:100%;overflow:auto}.security-alert{grid-template-columns:38px minmax(0,1fr)}.security-alert .inline-actions{grid-column:2;flex-wrap:wrap;min-width:0}.security-alert .inline-actions .button{min-width:0;white-space:normal}.task-list article{grid-template-columns:34px 1fr}.task-list article>.button,.task-list article>.status{grid-column:2;width:max-content}.evidence-head{grid-template-columns:40px 1fr}.evidence-head>.status{grid-column:2;width:max-content}.top-status .icon-button:first-of-type{display:grid}.staff-row{grid-template-columns:32px minmax(0,1fr) auto}.staff-row>b,.staff-row>em,.staff-row>i{display:none}.mobile-boundary{grid-template-columns:1fr}.mobile-boundary>span{width:44px;height:44px}} +.contract-result{display:grid;grid-template-columns:24px minmax(0,1fr);align-items:center;gap:10px;margin:0 0 12px;padding:10px 12px;border:1px solid #b7e4c7;background:#f2fbf5;color:#17663a}.contract-result>svg{width:18px}.contract-result>span{display:grid;gap:3px}.contract-result strong{font-size:13px}.contract-result small{color:#52705f;font-size:12px;line-height:18px}.contract-result.is-loading{border-color:#bfd0ff;background:var(--blue-50);color:var(--blue)}.contract-result.is-loading>svg{animation:spin 1s linear infinite}.contract-result.is-error{border-color:#ffb4ae;background:#fff0ef;color:#b42318}.contract-result.is-error small{color:#8f2d25}.candidate-list label:has(>.button){grid-template-columns:20px minmax(0,1fr) auto}.alert-rules{margin-bottom:12px}.alert-rules .panel-head code{max-width:46%;overflow:hidden;color:var(--muted);font-size:12px;text-overflow:ellipsis;white-space:nowrap}.rule-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr))}.rule-grid article{min-height:106px;display:grid;grid-template-columns:42px minmax(0,1fr) auto;align-items:start;gap:10px;padding:16px;border-right:1px solid var(--line);border-top:1px solid var(--line)}.rule-grid article:last-child{border-right:0}.rule-grid article>div{display:grid;gap:5px}.rule-grid strong{font-size:13px}.rule-grid small,.rule-grid p{margin:0;color:var(--muted);font-size:12px;line-height:18px}.tree-empty{padding:18px;color:var(--muted);font-size:13px;text-align:center} +@media(max-width:1050px){.rule-grid{grid-template-columns:1fr}.rule-grid article{border-right:0}.candidate-list label:has(>.button){grid-template-columns:20px minmax(0,1fr)}.candidate-list label>.button{grid-column:2;width:max-content}} +/* Real API authentication gate. Tokens are held in sessionStorage by platform-auth.js. */ +.platform-auth-gate { position: fixed; inset: 0; z-index: 10000; display: grid; place-items: center; padding: 24px; background: radial-gradient(circle at 20% 10%, #17345f 0, #0b1220 42%, #060a12 100%); color: #e5e7eb; } +.platform-auth-gate[hidden] { display: none; } +.platform-auth-card { width: min(460px, 100%); padding: 32px; border: 1px solid rgba(148,163,184,.22); border-radius: 18px; background: rgba(15,23,42,.96); box-shadow: 0 24px 80px rgba(0,0,0,.45); } +.platform-auth-card header { display: flex; gap: 14px; align-items: center; margin-bottom: 26px; } +.platform-auth-card header img { width: 44px; height: 44px; border-radius: 12px; } +.platform-auth-card h1 { margin: 0; font-size: 22px; } +.platform-auth-card header p, .platform-auth-card .auth-help { margin: 5px 0 0; color: #94a3b8; font-size: 13px; line-height: 1.6; } +.platform-auth-card form { display: grid; gap: 16px; } +.platform-auth-card label { display: grid; gap: 7px; color: #cbd5e1; font-size: 13px; } +.platform-auth-card input { width: 100%; box-sizing: border-box; padding: 11px 12px; border: 1px solid #334155; border-radius: 9px; background: #0b1220; color: #f8fafc; font: inherit; } +.platform-auth-card input:focus { outline: 2px solid #3b82f6; outline-offset: 1px; } +.platform-auth-card button { width: 100%; min-height: 42px; margin-top: 14px; border: 0; border-radius: 9px; background: #2563eb; color: white; font: inherit; font-weight: 700; cursor: pointer; } +.platform-auth-card button:disabled { opacity: .55; cursor: wait; } +.platform-auth-card code, .platform-auth-recovery { display: block; margin-top: 8px; padding: 12px; border-radius: 8px; background: #020617; color: #93c5fd; overflow-wrap: anywhere; } +.platform-auth-error { min-height: 20px; margin: 14px 0 0; color: #fca5a5; font-size: 13px; } diff --git a/Tiku.DbMigrator/Program.cs b/Tiku.DbMigrator/Program.cs index 127a3dd..46a002b 100644 --- a/Tiku.DbMigrator/Program.cs +++ b/Tiku.DbMigrator/Program.cs @@ -3,12 +3,19 @@ using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; using Tiku.Infrastructure; using Tiku.Infrastructure.Persistence; using Tiku.Infrastructure.Bootstrap; using Tiku.Application; var builder = Host.CreateApplicationBuilder(args); +builder.Logging.AddFilter("Microsoft.EntityFrameworkCore", LogLevel.Warning); +var isDevelopment = builder.Environment.IsDevelopment() || + string.Equals( + Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"), + Environments.Development, + StringComparison.OrdinalIgnoreCase); var bootstrapPlatformAdmin = args.Contains("--bootstrap-platform-admin", StringComparer.Ordinal); PlatformAdminBootstrapOptions? bootstrapOptions = null; if (bootstrapPlatformAdmin) @@ -22,11 +29,17 @@ if (bootstrapPlatformAdmin) var connectionString = builder.Configuration.GetConnectionString("Database") ?? Environment.GetEnvironmentVariable("DATABASE_URL") ?? - throw new InvalidOperationException( - "Database connection is required. Configure ConnectionStrings:Database or DATABASE_URL."); + (isDevelopment + ? $"Host=localhost;Database=tiku;Username={Environment.UserName}" + : throw new InvalidOperationException( + "Database connection is required outside Development. Configure ConnectionStrings:Database or DATABASE_URL.")); builder.Services.AddApplication(); -builder.Services.AddInfrastructure(connectionString); +builder.Services.AddInfrastructure( + connectionString, + isDevelopment && !bootstrapPlatformAdmin + ? DevelopmentPlatformAdminSeeder.Configure + : null); // Resolving UserManager also activates Identity's default token providers. // Bootstrap never issues a reset token, so the migrator uses a process-local provider; // the API remains the sole owner of the persisted, certificate-protected key ring. diff --git a/Tiku.Infrastructure/Bootstrap/DevelopmentPlatformAdminSeeder.cs b/Tiku.Infrastructure/Bootstrap/DevelopmentPlatformAdminSeeder.cs new file mode 100644 index 0000000..b59af7d --- /dev/null +++ b/Tiku.Infrastructure/Bootstrap/DevelopmentPlatformAdminSeeder.cs @@ -0,0 +1,192 @@ +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Npgsql; +using Tiku.Application.Security; +using Tiku.Domain.Identity; +using Tiku.Domain.Operations; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.Bootstrap; + +public static class DevelopmentPlatformAdminSeeder +{ + public const string Email = "admin@tiku.local"; + public const string RoleCode = PlatformAdminBootstrapper.SuperAdminRoleCode; + + public static void Configure(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder + .UseSeeding((context, _) => Seed((TikuDbContext)context)) + .UseAsyncSeeding((context, _, cancellationToken) => + SeedAsync((TikuDbContext)context, cancellationToken)); + } + + public static bool Seed(TikuDbContext dbContext) + { + ReloadPostgresTypes(dbContext); + + if (dbContext.PlatformBackendUserRoles.Any()) + { + return false; + } + + var temporaryPassword = GenerateTemporaryPassword(); + EnsureEmailIsAvailable(dbContext.Users.Any(user => + user.NormalizedEmail == Email.ToUpperInvariant() || + user.NormalizedUserName == Email.ToUpperInvariant())); + var permissionCodes = BackendPermissions.Platform.ToArray(); + var existingPermissionCodes = dbContext.BackendPermissions + .Where(permission => permissionCodes.Contains(permission.Code)) + .Select(permission => permission.Code) + .ToHashSet(StringComparer.Ordinal); + AddSeedGraph(dbContext, temporaryPassword, permissionCodes, existingPermissionCodes); + dbContext.SaveChanges(); + WriteFirstLoginInstructions(temporaryPassword); + return true; + } + + public static async Task SeedAsync( + TikuDbContext dbContext, + CancellationToken cancellationToken = default) + { + await ReloadPostgresTypesAsync(dbContext, cancellationToken); + + if (await dbContext.PlatformBackendUserRoles.AnyAsync(cancellationToken)) + { + return false; + } + + var temporaryPassword = GenerateTemporaryPassword(); + var normalizedEmail = Email.ToUpperInvariant(); + EnsureEmailIsAvailable(await dbContext.Users.AnyAsync(user => + user.NormalizedEmail == normalizedEmail || + user.NormalizedUserName == normalizedEmail, cancellationToken)); + var permissionCodes = BackendPermissions.Platform.ToArray(); + var existingPermissionCodes = (await dbContext.BackendPermissions + .Where(permission => permissionCodes.Contains(permission.Code)) + .Select(permission => permission.Code) + .ToArrayAsync(cancellationToken)) + .ToHashSet(StringComparer.Ordinal); + AddSeedGraph(dbContext, temporaryPassword, permissionCodes, existingPermissionCodes); + await dbContext.SaveChangesAsync(cancellationToken); + WriteFirstLoginInstructions(temporaryPassword); + return true; + } + + private static void AddSeedGraph( + TikuDbContext dbContext, + string temporaryPassword, + IReadOnlyCollection permissionCodes, + IReadOnlySet existingPermissionCodes) + { + var normalizedEmail = Email.ToUpperInvariant(); + var user = new User + { + Email = Email, + NormalizedEmail = normalizedEmail, + UserName = Email, + NormalizedUserName = normalizedEmail, + Name = "Local Platform Administrator", + EmailConfirmed = true, + Status = UserStatus.Active, + ForcePasswordChange = true, + TwoFactorEnabled = false + }; + var passwordHasher = new PasswordHasher(Options.Create(new PasswordHasherOptions + { + IterationCount = 210_000 + })); + user.PasswordHash = passwordHasher.HashPassword(user, temporaryPassword); + + var role = new PlatformBackendRole + { + Code = RoleCode, + Name = "Platform Super Administrator", + Description = "Built-in Development administrator created by EF Core data seeding.", + Status = BackendRoleStatus.Active, + IsSystem = true + }; + dbContext.Users.Add(user); + dbContext.PlatformBackendRoles.Add(role); + dbContext.BackendPermissions.AddRange( + permissionCodes + .Where(code => !existingPermissionCodes.Contains(code)) + .Select(code => new BackendPermission + { + Code = code, + Name = code, + Area = BackendPermissionArea.Platform, + Module = "platform", + Description = "Built-in platform permission.", + IsSystem = true + })); + dbContext.PlatformBackendRolePermissions.AddRange( + permissionCodes.Select(code => new PlatformBackendRolePermission + { + RoleId = role.Id, + PermissionCode = code + })); + dbContext.PlatformBackendUserRoles.Add(new PlatformBackendUserRole + { + UserId = user.Id, + RoleId = role.Id + }); + dbContext.AuditLogs.Add(new AuditLog + { + ActorUserId = user.Id, + Action = "platform.bootstrap_admin.created", + TargetType = "users", + TargetId = user.Id.ToString(), + Details = JsonSerializer.SerializeToElement(new + { + user.Email, + RoleCode, + ForcePasswordChange = true, + MfaEnrollmentRequired = true, + Source = "ef_core_use_seeding" + }) + }); + } + + private static string GenerateTemporaryPassword() => + $"Tiku!{Convert.ToHexString(RandomNumberGenerator.GetBytes(16))}9a"; + + private static void ReloadPostgresTypes(TikuDbContext dbContext) + { + if (dbContext.Database.IsNpgsql()) + { + ((NpgsqlConnection)dbContext.Database.GetDbConnection()).ReloadTypes(); + } + } + + private static async Task ReloadPostgresTypesAsync( + TikuDbContext dbContext, + CancellationToken cancellationToken) + { + if (dbContext.Database.IsNpgsql()) + { + await ((NpgsqlConnection)dbContext.Database.GetDbConnection()) + .ReloadTypesAsync(cancellationToken); + } + } + + private static void EnsureEmailIsAvailable(bool isAssigned) + { + if (isAssigned) + { + throw new InvalidOperationException( + $"Cannot create the Development platform administrator because '{Email}' is already assigned."); + } + } + + private static void WriteFirstLoginInstructions(string temporaryPassword) + { + Console.WriteLine("Development platform administrator created by EF Core data seeding."); + Console.WriteLine($" Account: {Email}"); + Console.WriteLine($" Temporary password: {temporaryPassword}"); + Console.WriteLine(" Change the password and enroll TOTP MFA at first sign-in. This password is shown only once."); + } +} diff --git a/Tiku.Infrastructure/DependencyInjection.cs b/Tiku.Infrastructure/DependencyInjection.cs index 336e9b4..ed2e177 100644 --- a/Tiku.Infrastructure/DependencyInjection.cs +++ b/Tiku.Infrastructure/DependencyInjection.cs @@ -51,7 +51,8 @@ public static class DependencyInjection { public static IServiceCollection AddInfrastructure( this IServiceCollection services, - string connectionString) + string connectionString, + Action? configureDatabase = null) { ArgumentException.ThrowIfNullOrWhiteSpace(connectionString); @@ -62,6 +63,7 @@ public static class DependencyInjection var dataSource = serviceProvider.GetRequiredService(); options.UseNpgsql(dataSource, npgsql => npgsql.MigrationsAssembly(typeof(TikuDbContext).Assembly.FullName)); + configureDatabase?.Invoke(options); options.AddInterceptors(serviceProvider.GetRequiredService()); }); services.AddIdentityCore(options => diff --git a/Tiku.IntegrationTests/Api/PlatformAdminStaticEndpointTests.cs b/Tiku.IntegrationTests/Api/PlatformAdminStaticEndpointTests.cs new file mode 100644 index 0000000..8a29e76 --- /dev/null +++ b/Tiku.IntegrationTests/Api/PlatformAdminStaticEndpointTests.cs @@ -0,0 +1,53 @@ +using System.Net; + +namespace Tiku.IntegrationTests.Api; + +public sealed class PlatformAdminStaticEndpointTests +{ + [Fact] + public async Task Platform_admin_static_shell_is_served_without_api_authentication() + { + await using var factory = new ApiTestFactory(); + using var client = factory.CreateClient(); + + var response = await client.GetAsync("/platform-admin/"); + var body = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("恭学题库 SaaS · 平台管理", body, StringComparison.Ordinal); + Assert.Contains("./platform-api.js", body, StringComparison.Ordinal); + Assert.Contains("./platform-auth.js", body, StringComparison.Ordinal); + Assert.Contains("platformAuthGate", body, StringComparison.Ordinal); + } + + [Fact] + public async Task Platform_admin_short_path_redirects_to_static_shell_directory() + { + await using var factory = new ApiTestFactory(); + using var client = factory.CreateClient(new() + { + AllowAutoRedirect = false + }); + + var response = await client.GetAsync("/platform-admin"); + + Assert.Equal(HttpStatusCode.MovedPermanently, response.StatusCode); + Assert.Equal("/platform-admin/", response.Headers.Location?.AbsolutePath); + } + + [Fact] + public async Task Platform_admin_runtime_uses_real_api_without_mock_fallback() + { + await using var factory = new ApiTestFactory(); + using var client = factory.CreateClient(); + + var runtime = await client.GetStringAsync("/platform-admin/runtime-config.js"); + var authentication = await client.GetStringAsync("/platform-admin/platform-auth.js"); + + Assert.Contains("mode: 'api'", runtime, StringComparison.Ordinal); + Assert.Contains("fallbackToMock: false", runtime, StringComparison.Ordinal); + Assert.Contains("/api/auth/login/password", authentication, StringComparison.Ordinal); + Assert.Contains("/api/auth/password/change-required", authentication, StringComparison.Ordinal); + Assert.Contains("/api/auth/mfa/totp/confirm", authentication, StringComparison.Ordinal); + } +} diff --git a/Tiku.UnitTests/Bootstrap/PlatformAdminBootstrapperTests.cs b/Tiku.UnitTests/Bootstrap/PlatformAdminBootstrapperTests.cs index 03e0560..2e530c6 100644 --- a/Tiku.UnitTests/Bootstrap/PlatformAdminBootstrapperTests.cs +++ b/Tiku.UnitTests/Bootstrap/PlatformAdminBootstrapperTests.cs @@ -82,6 +82,43 @@ public sealed class PlatformAdminBootstrapperTests Assert.Equal("bootstrap_user_already_exists", exception.Code); } + [Fact] + public async Task Development_async_seed_creates_one_forced_enrollment_administrator() + { + await using var provider = CreateProvider(); + await using var scope = provider.CreateAsyncScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + var created = await DevelopmentPlatformAdminSeeder.SeedAsync(context); + var createdAgain = await DevelopmentPlatformAdminSeeder.SeedAsync(context); + + Assert.True(created); + Assert.False(createdAgain); + var user = await context.Users.SingleAsync(); + Assert.Equal(DevelopmentPlatformAdminSeeder.Email, user.Email); + Assert.True(user.ForcePasswordChange); + Assert.False(user.TwoFactorEnabled); + Assert.Equal(BackendPermissions.Platform.Count, await context.PlatformBackendRolePermissions.CountAsync()); + Assert.Single(context.PlatformBackendUserRoles); + Assert.Single(context.AuditLogs); + } + + [Fact] + public async Task Development_sync_seed_is_idempotent() + { + await using var provider = CreateProvider(); + await using var scope = provider.CreateAsyncScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + var created = DevelopmentPlatformAdminSeeder.Seed(context); + var createdAgain = DevelopmentPlatformAdminSeeder.Seed(context); + + Assert.True(created); + Assert.False(createdAgain); + Assert.Single(context.Users); + Assert.Single(context.PlatformBackendUserRoles); + } + private static ServiceProvider CreateProvider() { var services = new ServiceCollection(); diff --git a/Tiku.Worker/appsettings.Development.json b/Tiku.Worker/appsettings.Development.json index b2dcdb6..fc7057b 100644 --- a/Tiku.Worker/appsettings.Development.json +++ b/Tiku.Worker/appsettings.Development.json @@ -2,6 +2,7 @@ "Logging": { "LogLevel": { "Default": "Information", + "Microsoft.EntityFrameworkCore": "Warning", "Microsoft.Hosting.Lifetime": "Information" } } diff --git a/Tiku.Worker/appsettings.json b/Tiku.Worker/appsettings.json index f6742fb..1cbdfef 100644 --- a/Tiku.Worker/appsettings.json +++ b/Tiku.Worker/appsettings.json @@ -12,6 +12,7 @@ "Logging": { "LogLevel": { "Default": "Information", + "Microsoft.EntityFrameworkCore": "Warning", "Microsoft.Hosting.Lifetime": "Information" } } diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 0000000..a3e7bb2 --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,138 @@ +# 本地开发快速开始 + +这份文档用于从全新开发环境启动 TIKU Backend、初始化 PostgreSQL,并完成平台管理员的首次登录。 + +## 1. 准备环境 + +需要安装: + +- .NET 10 SDK; +- PostgreSQL(当前本地开发已验证 PostgreSQL 18); +- `psql`、`createdb` 等 PostgreSQL 命令行工具。 + +确认工具可用: + +```bash +dotnet --version +pg_isready -h 127.0.0.1 -p 5432 +psql --version +``` + +## 2. 获取并还原项目 + +```bash +git clone TIKU-BACKEND +cd TIKU-BACKEND +dotnet restore TIKU-BACKEND.slnx +dotnet build TIKU-BACKEND.slnx --no-restore +``` + +## 3. 创建本地数据库 + +如果本机 PostgreSQL 允许当前系统用户无密码登录,可以直接执行: + +```bash +createdb -h 127.0.0.1 -U "$(whoami)" tiku +``` + +Development 环境未显式配置连接串时,API 和 DbMigrator 默认使用: + +```text +Host=localhost;Database=tiku;Username=<当前系统用户> +``` + +如果数据库用户名、端口或认证方式不同,通过环境变量传入连接串: + +```bash +export DATABASE_URL='Host=127.0.0.1;Port=5432;Database=tiku;Username=<数据库用户>;Password=<本地密码>' +``` + +不要把包含密码的连接串写进 README、`appsettings*.json` 或提交到 Git。团队成员应各自使用环境变量、.NET Secret Manager 或受控密钥存储。 + +## 4. 执行迁移并初始化管理员 + +```bash +ASPNETCORE_ENVIRONMENT=Development dotnet run --project Tiku.DbMigrator +``` + +DbMigrator 会执行全部 EF Core Migration,并在全新 Development 数据库中自动创建平台超级管理员: + +```text +账号:admin@tiku.local +密码:首次初始化时安全随机生成,只在当前终端输出一次 +``` + +请立即保存终端显示的临时密码。重复执行 DbMigrator 是幂等的,不会重复创建管理员、重置密码或再次显示密码。 + +管理员首次登录后必须: + +1. 修改临时密码; +2. 绑定 TOTP MFA; +3. 保存一次性恢复码。 + +如果数据库已经包含平台管理员,自动初始化会跳过。不要为了重新获取密码删除包含业务数据的数据库。 + +## 5. 启动 API 和平台后台 + +```bash +dotnet run --project Tiku.Api +``` + +默认开发地址: + +- 平台后台: +- Scalar API 文档: +- OpenAPI JSON: +- 健康检查: + +平台后台默认连接同源真实 API,不会回退到 Mock 数据。当前开放的是已有后端契约的概览、租户、员工、审计和告警等页面;尚未接入真实接口的模块暂不开放。 + +## 6. 可选:启动 Worker + +需要调试后台任务时,另开终端并使用相同数据库连接: + +```bash +dotnet run --project Tiku.Worker +``` + +普通 API 开发不要求同时启动 Worker。 + +## 7. 开发前验证 + +```bash +curl --fail http://localhost:5090/api/health +dotnet test TIKU-BACKEND.slnx --no-build +dotnet format TIKU-BACKEND.slnx --verify-no-changes --no-restore +dotnet ef migrations has-pending-model-changes \ + --project Tiku.Infrastructure \ + --startup-project Tiku.DbMigrator \ + --no-build +git diff --check +``` + +PostgreSQL 特有的 Migration、约束、事务和租户隔离行为必须使用真实 PostgreSQL 验证,不能只依赖 EF InMemory 测试。 + +## 常见问题 + +### 连接 PostgreSQL 失败 + +先检查服务和实际登录信息: + +```bash +pg_isready -h 127.0.0.1 -p 5432 +psql -h 127.0.0.1 -U <数据库用户> -d postgres -c 'select current_user;' +``` + +然后确认当前终端中的 `DATABASE_URL` 指向正确的主机、端口、数据库和用户。 + +### 首次迁移无法创建扩展 + +Migration 会创建 `citext` 和 `ltree` 扩展。初始化数据库的 PostgreSQL 用户必须有安装这些扩展所需的权限;请让本地数据库管理员预先安装扩展或授予对应权限。 + +### 没看到管理员临时密码 + +临时密码只在全新 Development 数据库首次创建管理员时显示。如果管理员绑定已经存在,迁移会安全跳过。请使用已有管理员账号的密码恢复流程,不要在源码或文档中添加固定密码。 + +### API 启动后出现 HTTPS 重定向警告 + +本地仅使用 HTTP profile 时可能看到无法确定 HTTPS 端口的警告,不影响 `http://localhost:5090` 的开发访问。需要验证 HTTPS 时使用项目的 `https` launch profile。