feat: add platform public question bank workspace

This commit is contained in:
2026-07-30 12:18:14 +08:00
parent bf370605d4
commit 96c20a3b1d
17 changed files with 22100 additions and 212 deletions

View File

@@ -0,0 +1,161 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Tiku.Api.Contracts;
using Tiku.Application.Assets;
using Tiku.Application.PlatformAdmin;
using Tiku.Application.Security;
namespace Tiku.Api.Controllers;
[ApiController]
[Tags("平台端-公共题库")]
[Authorize(Policy = BackendPermissions.PlatformQuestionBankManage)]
[Produces("application/json")]
[Route("api/platform-admin/question-banks")]
public sealed class PlatformQuestionBanksController(
IPlatformQuestionBankService service,
ICurrentUser currentUser) : ControllerBase
{
[HttpGet]
[EndpointSummary("查询平台公共题库")]
public async Task<ActionResult<IReadOnlyCollection<PlatformQuestionBankItem>>> GetBanks(
[FromQuery] string? keyword,
[FromQuery] string? status,
CancellationToken cancellationToken)
{
return Ok(await service.GetBanksAsync(ResolveActor(), new PlatformQuestionBankFilter(Keyword: keyword, Status: status), cancellationToken));
}
[HttpPut]
[EndpointSummary("新增或更新平台公共题库")]
public async Task<ActionResult<PlatformQuestionBankItem>> UpsertBank(
UpsertPlatformQuestionBankCommand request,
CancellationToken cancellationToken)
{
return Ok(await service.UpsertBankAsync(ResolveActor(), request, cancellationToken));
}
[HttpPost("{bankId:guid}/archive")]
[EndpointSummary("归档平台公共题库")]
public async Task<ActionResult<PlatformQuestionBankItem>> ArchiveBank(Guid bankId, CancellationToken cancellationToken)
{
return Ok(await service.ArchiveBankAsync(ResolveActor(), bankId, cancellationToken));
}
[HttpGet("{bankId:guid}/nodes")]
[EndpointSummary("查询公共题库内容结构")]
public async Task<ActionResult<IReadOnlyCollection<PlatformQuestionBankNodeItem>>> GetNodes(Guid bankId, CancellationToken cancellationToken)
{
return Ok(await service.GetNodesAsync(ResolveActor(), bankId, cancellationToken));
}
[HttpPut("nodes")]
[EndpointSummary("新增或更新公共题库内容节点")]
public async Task<ActionResult<PlatformQuestionBankNodeItem>> UpsertNode(
UpsertPlatformQuestionBankNodeCommand request,
CancellationToken cancellationToken)
{
return Ok(await service.UpsertNodeAsync(ResolveActor(), request, cancellationToken));
}
[HttpPost("nodes/batch")]
[EndpointSummary("批量创建公共题库章节或试卷")]
public async Task<ActionResult<IReadOnlyCollection<PlatformQuestionBankNodeItem>>> BatchCreateNodes(
BatchCreatePlatformQuestionBankNodesCommand request,
CancellationToken cancellationToken)
{
return Ok(await service.BatchCreateNodesAsync(ResolveActor(), request, cancellationToken));
}
[HttpPost("nodes/{nodeId:guid}/archive")]
[EndpointSummary("归档公共题库内容节点")]
public async Task<ActionResult<PlatformQuestionBankNodeItem>> ArchiveNode(Guid nodeId, CancellationToken cancellationToken)
{
return Ok(await service.ArchiveNodeAsync(ResolveActor(), nodeId, cancellationToken));
}
[HttpGet("questions")]
[EndpointSummary("分页查询公共题库题目")]
public async Task<ActionResult<PlatformQuestionPage>> GetQuestions(
[FromQuery] Guid questionBankId,
[FromQuery] Guid? contentNodeId,
[FromQuery] string? keyword,
[FromQuery] string? type,
[FromQuery] int? difficulty,
[FromQuery] string? status,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
CancellationToken cancellationToken = default)
{
return Ok(await service.GetQuestionsAsync(ResolveActor(), new PlatformQuestionBankFilter(
questionBankId, contentNodeId, keyword, type, difficulty, status, page, pageSize), cancellationToken));
}
[HttpPut("questions")]
[EndpointSummary("新增或更新公共题库题目并保留版本")]
public async Task<ActionResult<PlatformQuestionItem>> UpsertQuestion(
UpsertPlatformQuestionCommand request,
CancellationToken cancellationToken)
{
return Ok(await service.UpsertQuestionAsync(ResolveActor(), request, cancellationToken));
}
[HttpPost("questions/archive")]
[EndpointSummary("批量归档公共题库题目")]
public async Task<ActionResult<int>> ArchiveQuestions(
ArchivePlatformQuestionsCommand request,
CancellationToken cancellationToken)
{
return Ok(await service.ArchiveQuestionsAsync(ResolveActor(), request, cancellationToken));
}
[HttpPost("imports/preview")]
[EndpointSummary("预检公共题库 JSON 导入")]
public async Task<ActionResult<PlatformQuestionImportResult>> PreviewImport(
PlatformQuestionImportCommand request,
CancellationToken cancellationToken)
{
return Ok(await service.PreviewImportAsync(ResolveActor(), request, cancellationToken));
}
[HttpPost("imports")]
[EndpointSummary("执行公共题库 JSON 导入")]
public async Task<ActionResult<PlatformQuestionImportResult>> ExecuteImport(
PlatformQuestionImportCommand request,
CancellationToken cancellationToken)
{
return Ok(await service.ExecuteImportAsync(ResolveActor(), request, cancellationToken));
}
[HttpGet("imports/{jobId:guid}")]
[EndpointSummary("查询公共题库导入结果")]
public async Task<ActionResult<ContentImportJobDetail>> GetImport(Guid jobId, CancellationToken cancellationToken)
{
return Ok(await service.GetImportAsync(ResolveActor(), jobId, cancellationToken));
}
[HttpPost("assets/upload-sign")]
[EndpointSummary("签发公共题库图片上传地址")]
public async Task<ActionResult<AssetUploadSignResult>> SignAssetUpload(
AssetUploadSignDto request,
CancellationToken cancellationToken)
{
return Ok(await service.SignQuestionAssetUploadAsync(ResolveActor(), request.ToCommand(), cancellationToken));
}
[HttpPost("assets/upload-confirm")]
[EndpointSummary("确认公共题库图片上传结果")]
public async Task<ActionResult<AssetUploadConfirmResult>> ConfirmAssetUpload(
AssetUploadConfirmDto request,
CancellationToken cancellationToken)
{
return Ok(await service.ConfirmQuestionAssetUploadAsync(ResolveActor(), request.ToCommand(), cancellationToken));
}
private PlatformAdminActor ResolveActor()
{
return currentUser.UserId is { } userId
? new PlatformAdminActor(userId)
: throw new PlatformAdminException("无法识别当前平台员工。", "platform_access_denied");
}
}

View File

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

View File

@@ -8,11 +8,11 @@
<link rel="icon" href="./assets/logo.png" />
<link rel="stylesheet" href="./styles.css?v=20260728-api2" />
<script src="https://unpkg.com/lucide@0.468.0/dist/umd/lucide.min.js" defer onerror="document.documentElement.classList.add('icons-unavailable')"></script>
<script src="./runtime-config.js?v=20260728-api2" defer></script>
<script src="./platform-auth.js?v=20260728-api2" defer></script>
<script src="./spec-contract.js?v=20260728-api2" defer></script>
<script src="./platform-api.js?v=20260728-api2" defer></script>
<script src="./app.js?v=20260728-api2" defer></script>
<script src="./runtime-config.js?v=20260730-question-bank1" defer></script>
<script src="./platform-auth.js?v=20260730-question-bank1" defer></script>
<script src="./spec-contract.js?v=20260730-question-bank1" defer></script>
<script src="./platform-api.js?v=20260730-question-bank1" defer></script>
<script src="./app.js?v=20260730-question-bank1" defer></script>
</head>
<body>
<div id="platformAuthGate" class="platform-auth-gate" hidden></div>

View File

@@ -52,9 +52,10 @@
return String(token || '').trim();
}
function buildUrl(path, query = {}) {
function buildUrl(path, query = {}, pathParams = {}) {
const baseUrl = normalizeBaseUrl(runtime.apiBaseUrl);
const url = new URL(`${baseUrl}${path.startsWith('/') ? path : `/${path}`}`, window.location.href);
const resolvedPath = Object.entries(pathParams).reduce((value, [key, replacement]) => value.replace(`{${key}}`, encodeURIComponent(String(replacement))), path);
const url = new URL(`${baseUrl}${resolvedPath.startsWith('/') ? resolvedPath : `/${resolvedPath}`}`, window.location.href);
Object.entries(query).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') return;
url.searchParams.set(key, Array.isArray(value) ? value.join(',') : String(value));
@@ -124,7 +125,7 @@
else options.signal.addEventListener('abort', () => controller.abort(), { once: true });
}
try {
const response = await fetch(buildUrl(operation.path, options.query), {
const response = await fetch(buildUrl(operation.path, options.query, options.pathParams), {
method: operation.method,
headers: {
...extraHeaders,
@@ -196,9 +197,6 @@
invoiceTypeFromApi: { subscription: '订阅服务费', usage: '用量超额费', usage_overage: '用量超额费', manual: '人工账单', adjustment: '人工调整' },
severityToApi: { : 'low', : 'medium', : 'high', 严重: 'critical' },
severityFromApi: { low: '低', medium: '中', high: '高', critical: '严重' },
grantScopeToApi: { 全部活跃租户: 'all_active_tenants', 指定套餐: 'plans', 指定租户: 'tenants', '套餐 + 租户': 'mixed' },
grantScopeFromApi: { all_active_tenants: '全部活跃租户', plans: '指定套餐', tenants: '指定租户', mixed: '套餐 + 租户' },
grantStatusFromApi: { active: '启用', disabled: '已禁用', expired: '已过期' },
providerToApi: { Webhook: 'generic', SMTP: 'generic', 邮件: 'generic', 短信: 'generic', 钉钉: 'dingtalk', 飞书: 'feishu', 企微: 'wecom' },
providerFromApi: { generic: 'Webhook', dingtalk: '钉钉', feishu: '飞书', wecom: '企微' },
eventStatusFromApi: { pending: '待处理', processing: '发送中', sent: '成功', retrying: '重试中', failed: '失败', discarded: '已丢弃' },
@@ -211,6 +209,28 @@
paymentStatusToApi: { 正常: 'active', 未启用: 'disabled', 测试中: 'testing' },
};
const platformPermissionLabels = {
'platform:dashboard:view': '查看平台经营概览',
'platform:tenant:manage': '管理平台租户',
'platform:staff:manage': '管理平台员工',
'platform:role:manage': '管理平台角色与权限',
'platform:question-bank:manage': '运营平台公共题库',
'platform:audit:view': '查看平台审计与告警',
'platform:billing:notification': '管理账务催缴通知',
'platform:saas-catalog:manage': '管理 SaaS 商品与版本',
'platform:saas-billing:manage': '管理 SaaS 订单与账务',
'platform:crm:read': '查看租户 CRM 配置',
'platform:crm:write': '管理租户 CRM 配置',
'platform:sms:read': '查看租户短信服务',
'platform:sms:write': '管理租户短信服务',
'platform:payment:read': '查看平台支付设置',
'platform:payment:write': '管理平台支付设置',
};
const platformRoleLabels = {
platform_super_admin: { name: '超级管理员', description: '拥有全部平台管理权限' },
};
function mapValue(map, value, fallback = value) {
return Object.prototype.hasOwnProperty.call(map, value) ? map[value] : fallback;
}
@@ -277,18 +297,39 @@
}
const bodyBuilders = {
PlatformSaasController_upsertFeature(input) {
return { id: input.id || undefined, code: input.code, name: input.name, category: input.category, description: input.description || undefined, referencePriceCents: yuanToCents(input.referencePrice), currency: 'CNY', status: input.status || 'active', sortOrder: Number(input.sortOrder || 100) };
},
PlatformSaasController_upsertOffering(input) {
return { id: input.id || undefined, code: input.code, name: input.name, type: input.type || 'base_plan', status: input.status || 'active', description: input.description || undefined, sortOrder: Number(input.sortOrder || 100) };
},
PlatformSaasController_upsertFeatureLimit(input) {
return { id: input.id || undefined, metricCode: input.metricCode, featureCode: input.featureCode, name: input.name, unit: input.unit || 'count', kind: input.kind || 'current', warningPercent: Number(input.warningPercent || 80), isHardLimit: input.isHardLimit !== false };
},
PlatformSaasController_upsertVersion(input) {
return { id: input.id || undefined, offeringId: input.offeringId, billingCycle: input.billingCycle || 'yearly', originalAmountCents: yuanToCents(input.originalAmount), amountCents: yuanToCents(input.amount), currency: 'CNY', effectiveAt: input.effectiveAt ? new Date(input.effectiveAt).toISOString() : undefined, featureCodes: splitList(input.featureCodes), limits: {}, metadata: {} };
},
PlatformBackofficeController_upsertRole(input) {
return { id: input.id || undefined, code: input.code, name: input.name, status: input.status || 'active', description: input.description || undefined };
},
PlatformBackofficeController_replaceRoleBindings(input) {
return { permissionCodes: input.permissionCodes || [], menuCodes: input.menuCodes || [] };
},
PlatformSaasController_upsertTenantFeatureOverride(input) {
return { tenantId: input.tenantId, featureCode: input.featureCode, mode: input.mode, expiresAt: input.expiresAt ? new Date(input.expiresAt).toISOString() : undefined, reason: input.reason };
},
PlatformAdminTenantsController_create(input) {
return {
slug: input.slug,
name: input.name,
legalName: input.legalName || undefined,
brandName: input.brandName || undefined,
shortName: input.shortName || undefined,
primaryHost: input.primaryHost || undefined,
planCode: input.planCode || undefined,
status: mapValue(dictionaries.tenantStatusToApi, input.status, input.status),
billingStatus: mapValue(dictionaries.billingStatusToApi, input.billingStatus, input.billingStatus),
amountCents: yuanToCents(input.amount),
metadata: input.metadata || {},
ownerEmail: input.ownerEmail || undefined,
ownerPhone: input.ownerPhone || undefined,
ownerName: input.ownerName,
temporaryPassword: input.temporaryPassword,
};
},
PlatformAdminTenantsController_status(input) {
@@ -399,52 +440,28 @@
metadata: { source: 'platform_console_manual' },
};
},
PlatformAdminQuestionBanksController_upsertGrant(input) {
return {
id: input.id || undefined,
sourceQuestionBankId: input.bankId || input.sourceQuestionBankId,
grantScope: mapValue(dictionaries.grantScopeToApi, input.scope, input.grantScope),
allowedPlanCodes: input.allowedPlanCodes || (input.planCode ? [input.planCode] : []),
allowedTenantIds: input.allowedTenantIds || (input.tenantId ? [input.tenantId] : []),
allowedRegionIds: input.allowedRegionIds || splitList(input.regionIds),
allowedSubjectIds: input.allowedSubjectIds || splitList(input.subjectIds),
status: input.status === '已禁用' || input.status === 'disabled'
? 'disabled'
: input.status === '已过期' || input.status === 'expired'
? 'expired'
: 'active',
startsAt: isoStartOfDay(input.startsAt),
expiresAt: isoStartOfDay(input.expiresAt),
metadata: { localEdit: Boolean(input.localEdit) },
};
},
PlatformAdminOverviewController_upsertStaff(input) {
const permissionList = input.permissions || [];
return {
id: input.id || undefined,
authUserId: input.authUserId,
username: input.username,
userId: input.userId || undefined,
email: input.email || undefined,
phone: input.phone || undefined,
name: input.name,
status: input.status === '已停用' ? 'disabled' : 'active',
platformPermissions: Object.fromEntries(permissionList.map(permission => [permission, true])),
metadata: { displayRole: input.role || '平台管理员' },
roleIds: input.roleIds || [],
};
},
PlatformAdminOverviewController_updateStaffStatus(input) {
return {
staffId: input.staffId,
userId: input.userId || input.staffId,
status: input.status === '正常' || input.status === 'active' ? 'active' : 'disabled',
reason: input.reason || undefined,
revokeSessions: input.revokeSessions !== false,
};
},
PlatformAdminAuditController_updateAlert(input) {
return {
alertId: input.alertId,
status: input.status,
note: input.note || undefined,
resolutionNote: input.resolutionNote || input.note || undefined,
};
},
PlatformAdminCrmController_upsertConfig(input) {
@@ -536,6 +553,74 @@
metadata: { source: 'platform_console' },
};
},
PlatformQuestionBanksController_upsertBank(input) {
return {
id: input.id || undefined,
name: input.name,
metadata: input.metadata || {},
};
},
PlatformQuestionBanksController_upsertNode(input) {
return {
id: input.id || undefined,
questionBankId: input.questionBankId,
parentId: input.parentId || undefined,
nodeKey: input.nodeKey || undefined,
name: input.name,
nodeType: input.nodeType,
sortOrder: Number(input.sortOrder || 0),
isSelectable: input.isSelectable !== false,
metadata: input.metadata || {},
};
},
PlatformQuestionBanksController_batchCreateNodes(input) {
return {
questionBankId: input.questionBankId,
parentId: input.parentId || undefined,
nodeType: input.nodeType,
names: Array.isArray(input.names) ? input.names : splitList(input.names),
};
},
PlatformQuestionBanksController_upsertQuestion(input) {
return {
id: input.id || undefined,
questionBankId: input.questionBankId,
contentNodeId: input.contentNodeId,
legacyId: input.legacyId || undefined,
type: input.type,
typeLabel: input.typeLabel || undefined,
difficulty: input.difficulty == null ? undefined : Number(input.difficulty),
tags: input.tags || [],
content: input.content,
options: input.options || [],
correctOptionIndex: input.correctOptionIndex ?? undefined,
correctOptionIndices: input.correctOptionIndices || [],
answerText: input.answerText || undefined,
explanation: input.explanation || undefined,
subQuestions: input.subQuestions || [],
codeLang: input.codeLang || undefined,
codeTemplate: input.codeTemplate || undefined,
mediaUrl: input.mediaUrl || undefined,
status: input.status || 'published',
examMarkers: input.examMarkers || {},
sourceHash: input.sourceHash || undefined,
};
},
PlatformQuestionBanksController_archiveQuestions(input) {
return { questionIds: input.questionIds || [] };
},
PlatformQuestionBanksController_previewImport(input) {
return {
questionBankId: input.questionBankId,
contentNodeId: input.contentNodeId || undefined,
format: input.format,
sourceName: input.sourceName || undefined,
payload: input.payload,
};
},
PlatformQuestionBanksController_executeImport(input) {
return bodyBuilders.PlatformQuestionBanksController_previewImport(input);
},
};
function notificationBody(input) {
@@ -699,6 +784,7 @@
return {
id: item.invoiceNo || item.id,
backendId: item.id,
orderId: item.orderId || '',
tenantId: item.tenantId,
type: mapValue(dictionaries.invoiceTypeFromApi, item.invoiceType, item.invoiceType || '-'),
period: item.billingPeriodStart ? monthOnly(item.billingPeriodStart) : monthOnly(item.issuedAt || item.dueDate),
@@ -710,6 +796,18 @@
note: item.note || '',
};
},
billingPayment(item) {
return {
id: item.id,
orderId: item.orderId,
tenantId: item.tenantId,
paymentNo: item.paymentNo || '',
status: lowerEnum(item.status),
amount: centsToYuan(item.amountCents),
providerTradeNo: item.providerTradeNo || '',
paidAt: item.paidAt || '',
};
},
reminder(item) {
return {
id: item.id,
@@ -728,52 +826,26 @@
...current,
id: item.id,
name: item.name,
catalog: item.regionName || item.tenantName || '公共题库',
source: item.sourceScope === 'platform' ? '平台公共题库' : '租户自建',
contentEntryId: item.contentEntryId || '',
catalog: '平台公共题库',
source: '平台自建',
nodes: Number(item.nodeCount || 0),
questions: Number(item.questionCount || 0),
version: item.metadata?.version || item.metadata?.contentVersion || '-',
status: item.status === 'active' ? '稳定版' : item.status || '-',
};
},
grant(item) {
return {
id: item.id,
bankId: item.sourceQuestionBankId,
scope: mapValue(dictionaries.grantScopeFromApi, item.grantScope, item.grantScope || '-'),
allowedPlanCodes: item.allowedPlanCodes || [],
allowedTenantIds: item.allowedTenantIds || [],
allowedRegionIds: item.allowedRegionIds || [],
allowedSubjectIds: item.allowedSubjectIds || [],
status: mapValue(dictionaries.grantStatusFromApi, item.status, item.status || '-'),
startsAt: dateOnly(item.startsAt),
expiresAt: dateOnly(item.expiresAt),
localEdit: Boolean(item.metadata?.localEdit),
};
},
sync(item) {
const progress = item.syncStatus === 'synced'
? 100
: Number(item.metadata?.lastSync?.progress || item.lastSyncCounts?.progress || 0);
return {
tenantId: item.tenantId,
bankId: item.sourceQuestionBankId,
current: item.metadata?.lastSync?.sourceVersion || '-',
target: item.metadata?.lastSync?.targetVersion || '-',
progress,
conflicts: Number(item.conflictCount || 0),
status: ({ pending: '同步中', synced: '已完成', failed: '同步失败' })[item.syncStatus] || item.syncStatus || item.status || '-',
status: lowerEnum(item.status) === 'active' ? '正常' : lowerEnum(item.status) === 'archived' ? '已归档' : item.status || '-',
};
},
staff(item) {
const permissions = item.roleCodes?.includes('platform_super_admin') ? ['*'] : [];
const roleCodes = item.roleCodes || [];
const permissions = roleCodes.includes('platform_super_admin') ? ['*'] : [];
return {
id: item.userId || item.id,
authUserId: item.userId || item.authUserId || '',
username: item.email || '',
userId: item.userId || item.id,
name: item.name || item.email || '平台员工',
email: item.email || '',
phone: item.phoneMasked || item.phone || '-',
role: permissions.includes('*') ? '超级管理员' : item.rawProfile?.displayRole || '平台管理员',
roleCodes,
role: permissions.includes('*') ? '超级管理员' : roleCodes.join('、') || '未分配角色',
permissions,
status: item.status === 'active' ? '正常' : '已停用',
lastActive: dateTime(item.updatedAt),
@@ -1027,16 +1099,19 @@
}
const bootstrapDescriptors = [
['backofficeBootstrap', 'PlatformBackofficeController_bootstrap', {}],
['overview', 'PlatformAdminOverviewController_overview', {}],
['domains', 'PlatformAdminTenantsController_domains', { query: { limit: 200 } }],
['plans', 'PlatformAdminOverviewController_plans', {}],
['tenants', 'PlatformAdminTenantsController_list', { query: { limit: 200 } }],
['subscriptions', 'PlatformAdminBillingController_subscriptions', { query: { limit: 200 } }],
['saasOrders', 'PlatformAdminBillingController_orders', { query: { limit: 200 } }],
['saasRefunds', 'PlatformAdminBillingController_refunds', { query: { limit: 200 } }],
['invoices', 'PlatformAdminBillingController_invoices', { query: { limit: 200 } }],
['billingPayments', 'PlatformAdminBillingController_payments', { query: { limit: 200 } }],
['usage', 'PlatformAdminBillingController_usage', { query: { limit: 200 } }],
['reminders', 'PlatformAdminBillingController_reminders', { query: { limit: 100 } }],
['banks', 'PlatformAdminQuestionBanksController_banks', { query: { limit: 200 } }],
['grants', 'PlatformAdminQuestionBanksController_grants', { query: { limit: 200 } }],
['bankSync', 'PlatformAdminQuestionBanksController_syncStatus', { query: { limit: 200 } }],
['banks', 'PlatformQuestionBanksController_getBanks', { query: { status: 'all' } }],
['staff', 'PlatformAdminOverviewController_staff', { query: { limit: 200 } }],
['dunningChannels', 'PlatformAdminDunningChannelsController_channels', { query: { limit: 100 } }],
['dunningEvents', 'PlatformAdminDunningEventsController_events', { query: { limit: 100 } }],
@@ -1044,10 +1119,13 @@
['alerts', 'PlatformAdminAuditController_alerts', { query: { limit: 100 } }],
['crmConfigs', 'PlatformAdminCrmController_configs', { query: { limit: 200 } }],
['crmLeads', 'PlatformAdminCrmController_leads', { query: { limit: 200 } }],
['crmLogs', 'PlatformAdminCrmController_logs', { query: { limit: 200 } }],
['smsChannels', 'PlatformAdminSmsController_channels', { query: { limit: 200 } }],
['smsTemplates', 'PlatformAdminSmsController_templates', { query: { limit: 200 } }],
['smsLogs', 'PlatformAdminSmsController_logs', { query: { limit: 200 } }],
['platformPaymentApps', 'PlatformAdminPaymentSettingsController_apps', { query: { limit: 200 } }],
['paymentChannels', 'PlatformAdminPaymentSettingsController_channels', { query: { limit: 200 } }],
['rebateSummary', 'PlatformAdminPaymentSettingsController_rebateSummary', {}],
['tenantPaymentApps', 'PlatformAdminTenantPaymentSettingsController_apps', { query: { limit: 200 } }],
['platformPaymentEvents', 'PlatformAdminPaymentSettingsController_events', { query: { limit: 100 } }],
['tenantPaymentEvents', 'PlatformAdminTenantPaymentSettingsController_events', { query: { limit: 100 } }],
@@ -1056,7 +1134,6 @@
async function bootstrap(options = {}) {
const descriptors = bootstrapDescriptors.map(([key, operationId, requestOptions]) => {
const nextOptions = { ...requestOptions, query: { ...(requestOptions.query || {}) } };
if (key === 'banks') nextOptions.query.includeTenantBanks = Boolean(options.includeTenantBanks);
return [key, operationId, nextOptions];
});
const results = await Promise.allSettled(
@@ -1074,7 +1151,26 @@
function adaptBootstrap(payloads, currentData = {}) {
const patch = {};
const backoffice = payloads.backofficeBootstrap || {};
const permissionItems = Array.isArray(backoffice.permissions) ? backoffice.permissions : [];
const roleItems = Array.isArray(backoffice.roles) ? backoffice.roles : [];
if (permissionItems.length) {
patch.platformPermissions = Object.fromEntries(permissionItems.map(item => [
item.code,
platformPermissionLabels[item.code] || item.name || item.code,
]));
}
if (roleItems.length) {
patch.platformRoles = roleItems
.filter(item => String(item.status || '').toLowerCase() === 'active')
.map(item => {
const localized = platformRoleLabels[item.code] || {};
return { id: item.id, code: item.code, name: localized.name || item.name || item.code, description: localized.description || item.description || '', permissionCodes: item.permissionCodes || [], menuCodes: item.menuCodes || [] };
});
}
if (payloads.overview) patch.apiOverview = itemOf(payloads.overview);
if (payloads.domains) patch.domains = itemsOf(payloads.domains);
if (payloads.plans) patch.saasCatalog = payloads.plans;
if (payloads.permissions) patch.apiPermissions = itemOf(payloads.permissions);
const planPayload = itemsOf(payloads.plans, 'versions').length ? catalogPlans(payloads.plans) : itemsOf(payloads.plans);
if (planPayload.length) {
@@ -1087,11 +1183,25 @@
patch.tenants = tenants.map(item => adapters.tenant(item, currentById(currentData.tenants, item.id), planMap));
}
const staff = itemsOf(payloads.staff);
if (staff.length) patch.staff = staff.map(adapters.staff);
if (staff.length) {
const roles = patch.platformRoles || currentData.platformRoles || [];
patch.staff = staff.map(item => {
const adapted = adapters.staff(item);
const assignedRoles = roles.filter(role => adapted.roleCodes.includes(role.code));
adapted.roleIds = assignedRoles.map(role => role.id);
adapted.role = assignedRoles.map(role => role.name).join('、') || adapted.role;
adapted.permissions = adapted.permissions.includes('*') ? adapted.permissions : [...new Set(assignedRoles.flatMap(role => role.permissionCodes))];
return adapted;
});
}
const invoices = itemsOf(payloads.invoices);
if (invoices.length) patch.invoices = invoices.map(adapters.invoice);
const billingPayments = itemsOf(payloads.billingPayments);
if (billingPayments.length) patch.billingPayments = billingPayments.map(adapters.billingPayment);
const subscriptions = itemsOf(payloads.subscriptions);
if (subscriptions.length) patch.subscriptions = subscriptions.map(adapters.subscription);
if (payloads.saasOrders) patch.saasOrders = itemsOf(payloads.saasOrders);
if (payloads.saasRefunds) patch.saasRefunds = itemsOf(payloads.saasRefunds);
const usage = itemsOf(payloads.usage);
if (usage.length) patch.usage = aggregateUsage(usage);
if (Array.isArray(payloads.usageCandidates?.items) && patch.usage) {
@@ -1113,10 +1223,6 @@
if (banks.length) {
patch.banks = banks.map(item => adapters.bank(item, currentById(currentData.banks, item.id)));
}
const grants = itemsOf(payloads.grants);
if (grants.length) patch.grants = grants.map(adapters.grant);
const bankSync = itemsOf(payloads.bankSync);
if (bankSync.length) patch.bankSync = bankSync.map(adapters.sync);
const auditLogs = itemsOf(payloads.auditLogs);
if (auditLogs.length) patch.auditLogs = auditLogs.map(adapters.audit);
const alertRules = itemsOf(payloads.alertRules);
@@ -1127,6 +1233,7 @@
if (crmConfigs.length) patch.crmConfigs = crmConfigs.map(adapters.crmConfig);
const crmLeads = itemsOf(payloads.crmLeads);
if (crmLeads.length) patch.crmLeads = crmLeads.map(adapters.crmLead);
if (payloads.crmLogs) patch.crmLogs = itemsOf(payloads.crmLogs);
const smsChannels = itemsOf(payloads.smsChannels);
if (smsChannels.length) patch.smsChannels = smsChannels.map(adapters.smsChannel);
const smsTemplates = itemsOf(payloads.smsTemplates);
@@ -1136,6 +1243,8 @@
const platformPaymentApps = itemsOf(payloads.platformPaymentApps).map(adapters.platformPaymentApp);
const tenantPaymentApps = itemsOf(payloads.tenantPaymentApps).map(adapters.tenantPaymentApp);
if (platformPaymentApps.length || tenantPaymentApps.length) patch.paymentApps = [...platformPaymentApps, ...tenantPaymentApps];
if (payloads.paymentChannels) patch.paymentChannels = itemsOf(payloads.paymentChannels);
if (payloads.rebateSummary) patch.rebateSummary = itemOf(payloads.rebateSummary);
const paymentEvents = [
...itemsOf(payloads.platformPaymentEvents).map(adapters.platformPaymentEvent),
...itemsOf(payloads.tenantPaymentEvents).map(adapters.platformPaymentEvent),

View File

@@ -40,11 +40,11 @@
['P-08', 'act.p-08.04', '查询提醒', 'command', 'native', 'read', '[data-billing-tab="reminders"]', 'PlatformAdminBillingController_reminders', 'allow'],
['P-08', 'act.p-08.05', '新增/编辑/启停渠道', 'command', 'native', 'mutation', '[data-action="new-dunning-channel"],[data-action="edit-dunning-channel"],[data-action="toggle-dunning-channel"]', 'PlatformAdminDunningChannelsController_upsertChannel', 'block'],
['P-08', 'act.p-08.06', '查看通知事件', 'command', 'contract', 'read', '[data-notification-detail]', 'PlatformAdminDunningEventsController_events', 'allow'],
['P-09', 'act.p-09.01', '搜索', 'command', 'contract', 'read', '[data-action="apply-bank-search"]', 'PlatformAdminQuestionBanksController_banks', 'allow'],
['P-09', 'act.p-09.02', '含租户库切换', 'command', 'native', 'read', '#includeTenantBanks', 'PlatformAdminQuestionBanksController_banks', 'allow'],
['P-09', 'act.p-09.03', '新建/编辑/禁用授权', 'destructive', 'native', 'mutation', '[data-action="new-grant"],[data-action="edit-grant"],[data-action="toggle-grant"]', 'PlatformAdminQuestionBanksController_upsertGrant', 'block'],
['P-09', 'act.p-09.04', '选择题库', 'command', 'native', 'read', '[data-bank-select]', 'PlatformAdminQuestionBanksController_banks', 'allow'],
['P-09', 'act.p-09.05', '查看租户采用/同步', 'async', 'contract', 'task', '[data-action="preview-bank-sync"]', 'PlatformAdminQuestionBanksController_syncStatus', 'allow'],
['P-09', 'act.p-09.01', '查询与选择题库', 'command', 'native', 'read', '[data-action="apply-bank-search"],[data-bank-select]', 'PlatformQuestionBanksController_getBanks', 'allow'],
['P-09', 'act.p-09.02', '维护题库与内容结构', 'command', 'native', 'mutation', '[data-action="new-bank"],[data-action="edit-bank"],[data-action="new-bank-node"],[data-action="batch-bank-nodes"]', 'PlatformQuestionBanksController_upsertNode', 'block'],
['P-09', 'act.p-09.03', '单题录入与题目管理', 'command', 'native', 'mutation', '#bankQuestionForm,[data-action="edit-bank-question"],[data-action="archive-bank-question"]', 'PlatformQuestionBanksController_upsertQuestion', 'block'],
['P-09', 'act.p-09.04', '普通题目批量导入', 'async', 'native', 'task', '#bankBatchImportForm', 'PlatformQuestionBanksController_executeImport', 'block'],
['P-09', 'act.p-09.05', '结构化题库文件导入', 'async', 'native', 'task', '#bankStructuredImportForm', 'PlatformQuestionBanksController_executeImport', 'block'],
['P-10', 'act.p-10.01', '切换 CRM 视图', 'command', 'native', 'read', '[data-crm-tab]', 'PlatformAdminCrmController_configs', 'allow'],
['P-10', 'act.p-10.02', '新建/编辑 CRM 接入', 'command', 'native', 'mutation', '[data-action="new-crm-config"],[data-action="edit-crm-config"]', 'PlatformAdminCrmController_upsertConfig', 'block'],
['P-10', 'act.p-10.03', '启停 CRM 接入', 'destructive', 'native', 'mutation', '[data-action="toggle-crm-config"]', 'PlatformAdminCrmController_upsertConfig', 'block'],
@@ -73,17 +73,30 @@
].map(([pageId, id, label, kind, mode, effect, selector, operationId, mobile]) => ({ pageId, id, label, kind, mode, effect, selector, operationIds: [operationId], mobile, destructive: kind === 'destructive', implemented: true }));
const operationRows = [
['P-13', 'PlatformBackofficeController_bootstrap', 'GET', '/api/backoffice/platform/bootstrap'],
['P-01', 'PlatformAdminOverviewController_overview', 'GET', '/api/platform-admin/overview'],
['P-01', 'PlatformAdminAuditController_alerts', 'GET', '/api/platform-admin/audit-alerts'],
['P-01', 'PlatformAdminAuditController_logs', 'GET', '/api/platform-admin/audit-logs'],
['P-02', 'PlatformAdminTenantsController_list', 'GET', '/api/platform-admin/tenants'],
['P-02', 'PlatformAdminTenantsController_create', 'POST', '/api/platform-admin/tenants'],
['P-02A', 'PlatformAdminTenantsController_domains', 'GET', '/api/platform-admin/domains'],
['P-02A', 'PlatformAdminTenantsController_recheckDomain', 'POST', '/api/platform-admin/domains/{domainId}/recheck'],
['P-03', 'PlatformAdminTenantsController_detail', 'GET', '/api/platform-admin/tenants/detail'],
['P-03', 'PlatformAdminTenantsController_status', 'PATCH', '/api/platform-admin/tenants/status'],
['P-03', 'PlatformAdminTenantsController_billingProfile', 'PUT', '/api/platform-admin/tenants/billing-profile'],
['P-03', 'PlatformAdminBillingController_createSubscription', 'POST', '/api/platform-admin/saas/subscriptions'],
['P-04', 'PlatformAdminTenantsController_billingProfile', 'PUT', '/api/platform-admin/tenants/billing-profile'],
['P-05', 'PlatformAdminOverviewController_plans', 'GET', '/api/platform-admin/saas/catalog'],
['P-05A', 'PlatformSaasController_upsertFeature', 'PUT', '/api/platform-admin/saas/features'],
['P-05A', 'PlatformSaasController_upsertFeatureLimit', 'PUT', '/api/platform-admin/saas/feature-limits'],
['P-05A', 'PlatformSaasController_upsertOffering', 'PUT', '/api/platform-admin/saas/offerings'],
['P-05A', 'PlatformSaasController_upsertVersion', 'PUT', '/api/platform-admin/saas/offering-versions'],
['P-05A', 'PlatformSaasController_publishVersion', 'POST', '/api/platform-admin/saas/offering-versions/{versionId}/publish'],
['P-05A', 'PlatformSaasController_cloneVersion', 'POST', '/api/platform-admin/saas/offering-versions/{versionId}/clone'],
['P-05A', 'PlatformSaasController_retireVersion', 'POST', '/api/platform-admin/saas/offering-versions/{versionId}/retire'],
['P-05B', 'PlatformAdminBillingController_orders', 'GET', '/api/platform-admin/saas/orders'],
['P-05B', 'PlatformAdminBillingController_refunds', 'GET', '/api/platform-admin/saas/refunds'],
['P-05A', 'PlatformSaasController_upsertTenantFeatureOverride', 'PUT', '/api/platform-admin/saas/tenant-feature-overrides'],
['P-05', 'PlatformAdminBillingController_subscriptions', 'GET', '/api/platform-admin/saas/subscriptions'],
['P-05', 'PlatformAdminBillingController_createSubscription', 'POST', '/api/platform-admin/saas/subscriptions'],
['P-06', 'PlatformAdminBillingController_invoices', 'GET', '/api/platform-admin/saas/invoices'],
@@ -96,31 +109,51 @@
['P-07', 'PlatformAdminBillingController_usageCandidates', 'GET', '/api/platform-admin/saas/invoices/usage-overage-candidates'],
['P-07', 'PlatformAdminBillingController_fromUsage', 'POST', '/api/platform-admin/saas/invoices/from-usage-overage'],
['P-08', 'PlatformAdminBillingController_confirmPayment', 'POST', '/api/platform-admin/saas/payments/manual/confirm'],
['P-08', 'PlatformAdminBillingController_payments', 'GET', '/api/platform-admin/saas/payments'],
['P-08', 'PlatformAdminBillingController_processOverdue', 'POST', '/api/platform-admin/saas/invoices/process-overdue'],
['P-08', 'PlatformAdminBillingController_reminders', 'GET', '/api/platform-admin/saas/invoices/reminders'],
['P-08', 'PlatformAdminDunningChannelsController_channels', 'GET', '/api/platform-admin/saas/dunning/channels'],
['P-08', 'PlatformAdminDunningChannelsController_upsertChannel', 'PUT', '/api/platform-admin/saas/dunning/channels'],
['P-08', 'PlatformAdminDunningChannelsController_disableChannel', 'POST', '/api/platform-admin/saas/dunning/channels/disable'],
['P-08', 'PlatformAdminDunningEventsController_events', 'GET', '/api/platform-admin/saas/dunning/events'],
['P-09', 'PlatformAdminQuestionBanksController_banks', 'GET', '/api/platform-admin/question-banks'],
['P-09', 'PlatformAdminQuestionBanksController_grants', 'GET', '/api/platform-admin/question-bank-grants'],
['P-09', 'PlatformAdminQuestionBanksController_upsertGrant', 'PUT', '/api/platform-admin/question-bank-grants'],
['P-09', 'PlatformAdminQuestionBanksController_syncStatus', 'GET', '/api/platform-admin/question-bank-sync-status'],
['P-08', 'PlatformAdminDunningEventsController_eventDetail', 'GET', '/api/platform-admin/saas/dunning/events/detail'],
['P-08', 'PlatformAdminDunningEventsController_retryEvent', 'POST', '/api/platform-admin/saas/dunning/events/retry'],
['P-09', 'PlatformQuestionBanksController_getBanks', 'GET', '/api/platform-admin/question-banks'],
['P-09', 'PlatformQuestionBanksController_upsertBank', 'PUT', '/api/platform-admin/question-banks'],
['P-09', 'PlatformQuestionBanksController_archiveBank', 'POST', '/api/platform-admin/question-banks/{bankId}/archive'],
['P-09', 'PlatformQuestionBanksController_getNodes', 'GET', '/api/platform-admin/question-banks/{bankId}/nodes'],
['P-09', 'PlatformQuestionBanksController_upsertNode', 'PUT', '/api/platform-admin/question-banks/nodes'],
['P-09', 'PlatformQuestionBanksController_batchCreateNodes', 'POST', '/api/platform-admin/question-banks/nodes/batch'],
['P-09', 'PlatformQuestionBanksController_archiveNode', 'POST', '/api/platform-admin/question-banks/nodes/{nodeId}/archive'],
['P-09', 'PlatformQuestionBanksController_getQuestions', 'GET', '/api/platform-admin/question-banks/questions'],
['P-09', 'PlatformQuestionBanksController_upsertQuestion', 'PUT', '/api/platform-admin/question-banks/questions'],
['P-09', 'PlatformQuestionBanksController_archiveQuestions', 'POST', '/api/platform-admin/question-banks/questions/archive'],
['P-09', 'PlatformQuestionBanksController_previewImport', 'POST', '/api/platform-admin/question-banks/imports/preview'],
['P-09', 'PlatformQuestionBanksController_executeImport', 'POST', '/api/platform-admin/question-banks/imports'],
['P-09', 'PlatformQuestionBanksController_getImport', 'GET', '/api/platform-admin/question-banks/imports/{jobId}'],
['P-09', 'PlatformQuestionBanksController_signAssetUpload', 'POST', '/api/platform-admin/question-banks/assets/upload-sign'],
['P-09', 'PlatformQuestionBanksController_confirmAssetUpload', 'POST', '/api/platform-admin/question-banks/assets/upload-confirm'],
['P-10', 'PlatformAdminCrmController_configs', 'GET', '/api/platform-admin/tenant-capabilities/crm/configs'],
['P-10', 'PlatformAdminCrmController_upsertConfig', 'PUT', '/api/platform-admin/tenant-capabilities/crm/configs'],
['P-10', 'PlatformAdminCrmController_leads', 'GET', '/api/platform-admin/tenant-capabilities/crm/leads'],
['P-10', 'PlatformAdminCrmController_retryLead', 'POST', '/api/platform-admin/tenant-capabilities/crm/leads/retry'],
['P-10', 'PlatformAdminCrmController_logs', 'GET', '/api/platform-admin/tenant-capabilities/crm/logs'],
['P-11', 'PlatformAdminSmsController_channels', 'GET', '/api/platform-admin/tenant-capabilities/sms/channels'],
['P-11', 'PlatformAdminSmsController_disableChannel', 'POST', '/api/platform-admin/tenant-capabilities/sms/channels/{channelId}/disable'],
['P-11', 'PlatformAdminSmsController_templates', 'GET', '/api/platform-admin/tenant-capabilities/sms/templates'],
['P-11', 'PlatformAdminSmsController_submitTemplateReview', 'POST', '/api/platform-admin/tenant-capabilities/sms/templates/{templateId}/submit-review'],
['P-11', 'PlatformAdminSmsController_disableTemplate', 'POST', '/api/platform-admin/tenant-capabilities/sms/templates/{templateId}/disable'],
['P-11', 'PlatformAdminSmsController_logs', 'GET', '/api/platform-admin/tenant-capabilities/sms/logs'],
['P-11', 'PlatformAdminSmsController_upsertChannel', 'PUT', '/api/platform-admin/tenant-capabilities/sms/channels'],
['P-11', 'PlatformAdminSmsController_upsertTemplate', 'PUT', '/api/platform-admin/tenant-capabilities/sms/templates'],
['P-12', 'PlatformAdminPaymentSettingsController_apps', 'GET', '/api/platform-admin/payment-settings/apps'],
['P-12', 'PlatformAdminPaymentSettingsController_channels', 'GET', '/api/platform-admin/payment-settings/channels'],
['P-12', 'PlatformAdminPaymentSettingsController_upsertChannel', 'PUT', '/api/platform-admin/payment-settings/channels'],
['P-12', 'PlatformAdminPaymentSettingsController_disableChannel', 'POST', '/api/platform-admin/payment-settings/channels/{channelId}/disable'],
['P-12', 'PlatformAdminPaymentSettingsController_rebateSummary', 'GET', '/api/platform-admin/payment-settings/rebates/summary'],
['P-12', 'PlatformAdminPaymentSettingsController_events', 'GET', '/api/platform-admin/payment-settings/events'],
['P-12', 'PlatformAdminPaymentSettingsController_rebates', 'GET', '/api/platform-admin/payment-settings/rebates/summary'],
['P-12', 'PlatformAdminPaymentSettingsController_upsertApp', 'PUT', '/api/platform-admin/payment-settings/apps'],
['P-12', 'PlatformAdminPaymentSettingsController_upsertChannel', 'PUT', '/api/platform-admin/payment-settings/channels'],
['P-12', 'PlatformAdminTenantPaymentSettingsController_apps', 'GET', '/api/platform-admin/tenant-capabilities/payments/apps'],
['P-12', 'PlatformAdminTenantPaymentSettingsController_events', 'GET', '/api/platform-admin/tenant-capabilities/payments/events'],
['P-12', 'PlatformAdminTenantPaymentSettingsController_upsertApp', 'PUT', '/api/platform-admin/tenant-capabilities/payments/apps'],
@@ -128,6 +161,8 @@
['P-13', 'PlatformAdminOverviewController_staff', 'GET', '/api/platform-admin/staff'],
['P-13', 'PlatformAdminOverviewController_upsertStaff', 'PUT', '/api/platform-admin/staff'],
['P-13', 'PlatformAdminOverviewController_updateStaffStatus', 'PATCH', '/api/platform-admin/staff/status'],
['P-13A', 'PlatformBackofficeController_upsertRole', 'POST', '/api/backoffice/platform/roles'],
['P-13A', 'PlatformBackofficeController_replaceRoleBindings', 'PUT', '/api/backoffice/platform/roles/{roleId}/bindings'],
['P-14', 'PlatformAdminAuditController_logs', 'GET', '/api/platform-admin/audit-logs'],
['P-14', 'PlatformAdminAuditController_exportLogs', 'GET', '/api/platform-admin/audit-logs/export'],
['P-15', 'PlatformAdminAuditController_rules', 'GET', '/api/platform-admin/audit-alert-rules'],
@@ -157,9 +192,7 @@
['nav.p-14.p-15', 'P-14', 'P-15', '[data-action="jump-related-alert"]'],
].map(([id, fromPageId, toPageId, selector]) => ({ id, fromPageId, toPageId, selector, implemented: true }));
const supportingReads = [
{ pageId: 'P-09', selector: '[data-action="sync-detail"],[data-action="refresh-bank-sync"]', operationId: 'PlatformAdminQuestionBanksController_syncStatus' },
];
const supportingReads = [];
const trace = [];
const decoratedActionIds = new Set();

View File

@@ -0,0 +1,155 @@
using System.Text.Json;
using Tiku.Application.Assets;
using Tiku.Domain.Content;
using Tiku.Domain.QuestionBanks;
namespace Tiku.Application.PlatformAdmin;
public sealed record PlatformQuestionBankFilter(
Guid? QuestionBankId = null,
Guid? ContentNodeId = null,
string? Keyword = null,
string? Type = null,
int? Difficulty = null,
string? Status = null,
int Page = 1,
int PageSize = 20);
public sealed record PlatformQuestionBankItem(
Guid Id,
Guid? ContentEntryId,
string Name,
QuestionBankStatus Status,
int NodeCount,
int QuestionCount,
JsonElement Metadata,
DateTimeOffset CreatedAt,
DateTimeOffset? UpdatedAt);
public sealed record UpsertPlatformQuestionBankCommand(
Guid? Id,
string Name,
JsonElement Metadata);
public sealed record PlatformQuestionBankNodeItem(
Guid Id,
Guid QuestionBankId,
Guid EntryId,
Guid? ParentId,
string? NodeKey,
string Name,
ContentNodeType NodeType,
int Depth,
int SortOrder,
bool IsActive,
bool IsSelectable,
bool IsLeaf,
int QuestionCount,
JsonElement Metadata);
public sealed record UpsertPlatformQuestionBankNodeCommand(
Guid? Id,
Guid QuestionBankId,
Guid? ParentId,
string? NodeKey,
string Name,
ContentNodeType NodeType,
int SortOrder,
bool IsSelectable,
JsonElement Metadata);
public sealed record BatchCreatePlatformQuestionBankNodesCommand(
Guid QuestionBankId,
Guid? ParentId,
ContentNodeType NodeType,
IReadOnlyCollection<string> Names);
public sealed record PlatformQuestionItem(
Guid Id,
Guid? VersionId,
Guid QuestionBankId,
Guid EntryId,
Guid ContentNodeId,
string? LegacyId,
string Type,
string? TypeLabel,
int? Difficulty,
JsonElement Tags,
string? Content,
JsonElement Options,
int? CorrectOptionIndex,
JsonElement CorrectOptionIndices,
string? AnswerText,
string? Explanation,
JsonElement SubQuestions,
string? CodeLang,
string? CodeTemplate,
string? MediaUrl,
QuestionStatus Status,
int VersionNo,
DateTimeOffset CreatedAt,
DateTimeOffset? UpdatedAt);
public sealed record PlatformQuestionPage(
IReadOnlyCollection<PlatformQuestionItem> Items,
int Total,
int Page,
int PageSize);
public sealed record UpsertPlatformQuestionCommand(
Guid? Id,
Guid QuestionBankId,
Guid ContentNodeId,
string? LegacyId,
string? Type,
string? TypeLabel,
int? Difficulty,
JsonElement Tags,
string? Content,
JsonElement Options,
int? CorrectOptionIndex,
JsonElement CorrectOptionIndices,
string? AnswerText,
string? Explanation,
JsonElement SubQuestions,
string? CodeLang,
string? CodeTemplate,
string? MediaUrl,
string? Status,
JsonElement ExamMarkers,
string? SourceHash);
public sealed record ArchivePlatformQuestionsCommand(IReadOnlyCollection<Guid> QuestionIds);
public sealed record PlatformQuestionImportCommand(
Guid QuestionBankId,
Guid? ContentNodeId,
string Format,
string? SourceName,
JsonElement Payload);
public sealed record PlatformQuestionImportResult(
ContentImportJobDetail Detail,
int CreatedNodeCount,
int InsertedQuestionCount,
int UpdatedQuestionCount,
int SkippedQuestionCount);
public interface IPlatformQuestionBankService
{
Task<IReadOnlyCollection<PlatformQuestionBankItem>> GetBanksAsync(PlatformAdminActor actor, PlatformQuestionBankFilter filter, CancellationToken cancellationToken = default);
Task<PlatformQuestionBankItem> UpsertBankAsync(PlatformAdminActor actor, UpsertPlatformQuestionBankCommand command, CancellationToken cancellationToken = default);
Task<PlatformQuestionBankItem> ArchiveBankAsync(PlatformAdminActor actor, Guid bankId, CancellationToken cancellationToken = default);
Task<IReadOnlyCollection<PlatformQuestionBankNodeItem>> GetNodesAsync(PlatformAdminActor actor, Guid bankId, CancellationToken cancellationToken = default);
Task<PlatformQuestionBankNodeItem> UpsertNodeAsync(PlatformAdminActor actor, UpsertPlatformQuestionBankNodeCommand command, CancellationToken cancellationToken = default);
Task<IReadOnlyCollection<PlatformQuestionBankNodeItem>> BatchCreateNodesAsync(PlatformAdminActor actor, BatchCreatePlatformQuestionBankNodesCommand command, CancellationToken cancellationToken = default);
Task<PlatformQuestionBankNodeItem> ArchiveNodeAsync(PlatformAdminActor actor, Guid nodeId, CancellationToken cancellationToken = default);
Task<PlatformQuestionPage> GetQuestionsAsync(PlatformAdminActor actor, PlatformQuestionBankFilter filter, CancellationToken cancellationToken = default);
Task<PlatformQuestionItem> UpsertQuestionAsync(PlatformAdminActor actor, UpsertPlatformQuestionCommand command, CancellationToken cancellationToken = default);
Task<int> ArchiveQuestionsAsync(PlatformAdminActor actor, ArchivePlatformQuestionsCommand command, CancellationToken cancellationToken = default);
Task<PlatformQuestionImportResult> PreviewImportAsync(PlatformAdminActor actor, PlatformQuestionImportCommand command, CancellationToken cancellationToken = default);
Task<PlatformQuestionImportResult> ExecuteImportAsync(PlatformAdminActor actor, PlatformQuestionImportCommand command, CancellationToken cancellationToken = default);
Task<ContentImportJobDetail> GetImportAsync(PlatformAdminActor actor, Guid jobId, CancellationToken cancellationToken = default);
Task<AssetUploadSignResult> SignQuestionAssetUploadAsync(PlatformAdminActor actor, AssetUploadSignCommand command, CancellationToken cancellationToken = default);
Task<AssetUploadConfirmResult> ConfirmQuestionAssetUploadAsync(PlatformAdminActor actor, AssetUploadConfirmCommand command, CancellationToken cancellationToken = default);
}

View File

@@ -6,6 +6,7 @@ namespace Tiku.Domain.QuestionBanks;
public sealed class QuestionBank : AuditableTenantEntity
{
public Guid? RegionId { get; set; }
public Guid? ContentEntryId { get; set; }
public string Name { get; set; } = string.Empty;
public QuestionBankStatus Status { get; set; } = QuestionBankStatus.Active;
public JsonElement Metadata { get; set; } = JsonDefaults.Object();

View File

@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Security;
using Tiku.Domain.Common;
using Tiku.Domain.Operations;
using Tiku.Domain.Platform;
using Tiku.Domain.Tenancy;
@@ -124,6 +125,21 @@ public sealed class BuiltinBackofficeCatalogSeeder(TikuDbContext dbContext)
public async Task SeedAsync(CancellationToken cancellationToken = default)
{
var platformOwnedTenant = await dbContext.Tenants
.SingleOrDefaultAsync(item => item.Mode == TenantMode.PlatformOwned, cancellationToken);
if (platformOwnedTenant is null)
{
dbContext.Tenants.Add(new Tenant
{
Slug = "platform-public-content",
Name = "平台公共内容",
Status = TenantStatus.Active,
Mode = TenantMode.PlatformOwned,
BillingStatus = BillingStatus.Active,
Metadata = JsonDefaults.Object()
});
}
var featureCodes = Features.Select(item => item.Code).ToArray();
var existingFeatureCodes = await dbContext.SaasFeatures.AsNoTracking()
.Where(item => featureCodes.Contains(item.Code))

View File

@@ -133,6 +133,7 @@ public static class DependencyInjection
services.AddScoped<ITenantAdminDirectService, TenantAdminDirectService>();
services.AddScoped<IBackofficeService, BackofficeService>();
services.AddScoped<IPlatformAdminService, PlatformAdminService>();
services.AddScoped<IPlatformQuestionBankService, PlatformQuestionBankService>();
services.AddScoped<IPlatformCrmAdminService, PlatformCrmAdminService>();
services.AddScoped<IPlatformSmsAdminService, PlatformSmsAdminService>();
services.AddScoped<IPlatformPaymentSettingsService, PlatformPaymentSettingsService>();

View File

@@ -21,6 +21,10 @@ internal sealed class QuestionBankConfiguration : IEntityTypeConfiguration<Quest
.HasForeignKey(entity => new { entity.TenantId, entity.RegionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<ContentEntry>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.ContentEntryId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
}
}

View File

@@ -0,0 +1,80 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tiku.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddPlatformQuestionBankWorkspace : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "content_entry_id",
table: "question_banks",
type: "uuid",
nullable: true);
migrationBuilder.Sql("""
INSERT INTO content_entries (
id, tenant_id, entry_key, name, entry_type, visibility,
access_rules, layout_config, sort_order, is_active, created_at, updated_at)
SELECT
qb.id,
qb.tenant_id,
'public-question-bank-' || replace(qb.id::text, '-', ''),
qb.name,
'question_practice',
'public',
'{}'::jsonb,
'{}'::jsonb,
0,
true,
qb.created_at,
qb.updated_at
FROM question_banks qb
JOIN tenants t ON t.id = qb.tenant_id AND t.mode = 'platform_owned'
WHERE NOT EXISTS (SELECT 1 FROM content_entries ce WHERE ce.id = qb.id);
UPDATE question_banks qb
SET content_entry_id = qb.id
FROM tenants t
WHERE t.id = qb.tenant_id
AND t.mode = 'platform_owned'
AND qb.content_entry_id IS NULL
AND EXISTS (SELECT 1 FROM content_entries ce WHERE ce.tenant_id = qb.tenant_id AND ce.id = qb.id);
""");
migrationBuilder.CreateIndex(
name: "ix_question_banks_tenant_id_content_entry_id",
table: "question_banks",
columns: new[] { "tenant_id", "content_entry_id" });
migrationBuilder.AddForeignKey(
name: "fk_question_banks_content_entries_tenant_id_content_entry_id",
table: "question_banks",
columns: new[] { "tenant_id", "content_entry_id" },
principalTable: "content_entries",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_question_banks_content_entries_tenant_id_content_entry_id",
table: "question_banks");
migrationBuilder.DropIndex(
name: "ix_question_banks_tenant_id_content_entry_id",
table: "question_banks");
migrationBuilder.DropColumn(
name: "content_entry_id",
table: "question_banks");
}
}
}

View File

@@ -14290,6 +14290,10 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<Guid?>("ContentEntryId")
.HasColumnType("uuid")
.HasColumnName("content_entry_id");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
@@ -14334,6 +14338,9 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.HasAlternateKey("TenantId", "Id")
.HasName("ak_question_banks_tenant_id_id");
b.HasIndex("TenantId", "ContentEntryId")
.HasDatabaseName("ix_question_banks_tenant_id_content_entry_id");
b.HasIndex("TenantId", "RegionId")
.HasDatabaseName("ix_question_banks_tenant_id_region_id");
@@ -19706,6 +19713,13 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.IsRequired()
.HasConstraintName("fk_question_banks_tenants_tenant_id");
b.HasOne("Tiku.Domain.Content.ContentEntry", null)
.WithMany()
.HasForeignKey("TenantId", "ContentEntryId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("fk_question_banks_content_entries_tenant_id_content_entry_id");
b.HasOne("Tiku.Domain.Catalog.Region", null)
.WithMany()
.HasForeignKey("TenantId", "RegionId")

View File

@@ -0,0 +1,711 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Application.Assets;
using Tiku.Application.PlatformAdmin;
using Tiku.Application.Security;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
using Tiku.Domain.Operations;
using Tiku.Domain.QuestionBanks;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.PlatformAdmin;
internal sealed class PlatformQuestionBankService(
ICurrentAccessContext currentAccessContext,
ITenantExecutionScope tenantExecutionScope) : IPlatformQuestionBankService
{
private const int MaxPageSize = 100;
private static readonly HashSet<string> SupportedQuestionTypes = new(StringComparer.OrdinalIgnoreCase)
{
"choice", "multiple_choice", "true_false", "fill_blank", "short_answer", "reading", "programming"
};
public Task<IReadOnlyCollection<PlatformQuestionBankItem>> GetBanksAsync(
PlatformAdminActor actor,
PlatformQuestionBankFilter filter,
CancellationToken cancellationToken = default) =>
ExecuteAsync<IReadOnlyCollection<PlatformQuestionBankItem>>(actor, "查询平台公共题库", async (_, dbContext, tenantId, token) =>
{
var query = dbContext.QuestionBanks.AsNoTracking().Where(item => item.TenantId == tenantId);
if (!string.IsNullOrWhiteSpace(filter.Keyword))
{
var keyword = filter.Keyword.Trim();
query = query.Where(item => item.Name.Contains(keyword));
}
if (!string.IsNullOrWhiteSpace(filter.Status) && !filter.Status.Equals("all", StringComparison.OrdinalIgnoreCase))
{
var status = ParseBankStatus(filter.Status);
query = query.Where(item => item.Status == status);
}
var banks = await query.OrderBy(item => item.Name).ThenBy(item => item.CreatedAt).ToArrayAsync(token);
var entryIds = banks.Where(item => item.ContentEntryId.HasValue).Select(item => item.ContentEntryId!.Value).ToArray();
var nodeCounts = await dbContext.ContentNodes.AsNoTracking()
.Where(item => item.TenantId == tenantId && entryIds.Contains(item.EntryId) && item.IsActive)
.GroupBy(item => item.EntryId)
.Select(group => new { EntryId = group.Key, Count = group.Count() })
.ToDictionaryAsync(item => item.EntryId, item => item.Count, token);
var questionCounts = await dbContext.Questions.AsNoTracking()
.Where(item => item.TenantId == tenantId && item.QuestionBankId.HasValue && item.Status != QuestionStatus.Archived)
.GroupBy(item => item.QuestionBankId!.Value)
.Select(group => new { BankId = group.Key, Count = group.Count() })
.ToDictionaryAsync(item => item.BankId, item => item.Count, token);
return banks.Select(item => ToBankItem(
item,
item.ContentEntryId.HasValue && nodeCounts.TryGetValue(item.ContentEntryId.Value, out var nodes) ? nodes : 0,
questionCounts.TryGetValue(item.Id, out var questions) ? questions : 0)).ToArray();
}, cancellationToken);
public Task<PlatformQuestionBankItem> UpsertBankAsync(
PlatformAdminActor actor,
UpsertPlatformQuestionBankCommand command,
CancellationToken cancellationToken = default) =>
ExecuteAsync(actor, "保存平台公共题库", async (_, dbContext, tenantId, token) =>
{
if (string.IsNullOrWhiteSpace(command.Name))
{
throw Error("题库名称不能为空。", "question_bank_name_required");
}
var bank = command.Id.HasValue
? await dbContext.QuestionBanks.SingleOrDefaultAsync(item => item.TenantId == tenantId && item.Id == command.Id.Value, token)
: null;
if (command.Id.HasValue && bank is null)
{
throw Error("公共题库不存在。", "question_bank_not_found");
}
ContentEntry? trackedEntry = null;
if (bank is null)
{
var entry = new ContentEntry
{
TenantId = tenantId,
EntryKey = $"public-question-bank-{Guid.NewGuid():N}",
Name = command.Name.Trim(),
EntryType = ContentEntryType.QuestionPractice,
Visibility = ContentVisibility.Public,
IsActive = true,
CreatedBy = actor.UserId,
UpdatedBy = actor.UserId
};
trackedEntry = entry;
bank = new QuestionBank
{
TenantId = tenantId,
ContentEntryId = entry.Id,
Status = QuestionBankStatus.Active
};
dbContext.ContentEntries.Add(entry);
dbContext.QuestionBanks.Add(bank);
}
else if (!bank.ContentEntryId.HasValue)
{
var entry = new ContentEntry
{
TenantId = tenantId,
EntryKey = $"public-question-bank-{bank.Id:N}",
Name = command.Name.Trim(),
EntryType = ContentEntryType.QuestionPractice,
Visibility = ContentVisibility.Public,
IsActive = true,
CreatedBy = actor.UserId,
UpdatedBy = actor.UserId
};
trackedEntry = entry;
dbContext.ContentEntries.Add(entry);
bank.ContentEntryId = entry.Id;
}
bank.Name = command.Name.Trim();
bank.Metadata = ObjectOrDefault(command.Metadata);
if (bank.ContentEntryId.HasValue)
{
var entry = trackedEntry ?? await dbContext.ContentEntries.SingleAsync(item => item.TenantId == tenantId && item.Id == bank.ContentEntryId.Value, token);
entry.Name = bank.Name;
entry.UpdatedBy = actor.UserId;
}
AddAudit(dbContext, actor, "platform.question_bank.saved", bank.Id, new { bank.Name });
await dbContext.SaveChangesAsync(token);
return ToBankItem(bank, 0, 0);
}, cancellationToken);
public Task<PlatformQuestionBankItem> ArchiveBankAsync(
PlatformAdminActor actor,
Guid bankId,
CancellationToken cancellationToken = default) =>
ExecuteAsync(actor, "归档平台公共题库", async (_, dbContext, tenantId, token) =>
{
var bank = await RequireBankAsync(dbContext, tenantId, bankId, token);
if (await dbContext.Questions.AnyAsync(item => item.TenantId == tenantId && item.QuestionBankId == bankId && item.Status != QuestionStatus.Archived, token))
{
throw Error("题库中仍有未归档题目,不能归档题库。", "question_bank_not_empty");
}
if (bank.ContentEntryId.HasValue && await dbContext.ContentNodes.AnyAsync(item => item.TenantId == tenantId && item.EntryId == bank.ContentEntryId && item.IsActive, token))
{
throw Error("题库中仍有启用的内容层级,不能归档题库。", "question_bank_nodes_not_archived");
}
bank.Status = QuestionBankStatus.Archived;
AddAudit(dbContext, actor, "platform.question_bank.archived", bank.Id, new { bank.Name });
await dbContext.SaveChangesAsync(token);
return ToBankItem(bank, 0, 0);
}, cancellationToken);
public Task<IReadOnlyCollection<PlatformQuestionBankNodeItem>> GetNodesAsync(
PlatformAdminActor actor,
Guid bankId,
CancellationToken cancellationToken = default) =>
ExecuteAsync<IReadOnlyCollection<PlatformQuestionBankNodeItem>>(actor, "查询公共题库内容结构", async (_, dbContext, tenantId, token) =>
{
var bank = await RequireBankWithEntryAsync(dbContext, tenantId, bankId, token);
var nodes = await dbContext.ContentNodes.AsNoTracking()
.Where(item => item.TenantId == tenantId && item.EntryId == bank.ContentEntryId)
.OrderBy(item => item.Depth).ThenBy(item => item.SortOrder).ThenBy(item => item.Name)
.ToArrayAsync(token);
var counts = await dbContext.Questions.AsNoTracking()
.Where(item => item.TenantId == tenantId && item.QuestionBankId == bankId && item.ContentNodeId.HasValue && item.Status != QuestionStatus.Archived)
.GroupBy(item => item.ContentNodeId!.Value)
.Select(group => new { NodeId = group.Key, Count = group.Count() })
.ToDictionaryAsync(item => item.NodeId, item => item.Count, token);
return nodes.Select(item => ToNodeItem(item, bankId, counts.TryGetValue(item.Id, out var count) ? count : 0)).ToArray();
}, cancellationToken);
public Task<PlatformQuestionBankNodeItem> UpsertNodeAsync(
PlatformAdminActor actor,
UpsertPlatformQuestionBankNodeCommand command,
CancellationToken cancellationToken = default) =>
ExecuteAsync(actor, "保存公共题库内容节点", async (_, dbContext, tenantId, token) =>
{
var bank = await RequireBankWithEntryAsync(dbContext, tenantId, command.QuestionBankId, token);
return await UpsertNodeCoreAsync(dbContext, actor, tenantId, bank, command, token);
}, cancellationToken);
public Task<IReadOnlyCollection<PlatformQuestionBankNodeItem>> BatchCreateNodesAsync(
PlatformAdminActor actor,
BatchCreatePlatformQuestionBankNodesCommand command,
CancellationToken cancellationToken = default) =>
ExecuteAsync<IReadOnlyCollection<PlatformQuestionBankNodeItem>>(actor, "批量创建章节或试卷", async (_, dbContext, tenantId, token) =>
{
if (command.NodeType is not ContentNodeType.Chapter and not ContentNodeType.Paper)
{
throw Error("批量创建仅支持章节或试卷。", "node_batch_type_invalid");
}
var bank = await RequireBankWithEntryAsync(dbContext, tenantId, command.QuestionBankId, token);
var names = command.Names.Select(item => item.Trim()).Where(item => item.Length > 0).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
if (names.Length is 0 or > 100)
{
throw Error("请提供 1 至 100 个不重复的名称。", "node_batch_names_invalid");
}
var result = new List<PlatformQuestionBankNodeItem>();
var order = 0;
foreach (var name in names)
{
result.Add(await UpsertNodeCoreAsync(dbContext, actor, tenantId, bank, new UpsertPlatformQuestionBankNodeCommand(
null, command.QuestionBankId, command.ParentId, null, name, command.NodeType, order++, true, JsonDefaults.Object()), token));
}
return result;
}, cancellationToken);
public Task<PlatformQuestionBankNodeItem> ArchiveNodeAsync(
PlatformAdminActor actor,
Guid nodeId,
CancellationToken cancellationToken = default) =>
ExecuteAsync(actor, "归档公共题库内容节点", async (_, dbContext, tenantId, token) =>
{
var node = await dbContext.ContentNodes.SingleOrDefaultAsync(item => item.TenantId == tenantId && item.Id == nodeId, token)
?? throw Error("内容节点不存在。", "question_bank_node_not_found");
if (await dbContext.ContentNodes.AnyAsync(item => item.TenantId == tenantId && item.ParentId == nodeId && item.IsActive, token))
{
throw Error("该节点仍有启用的下级节点,不能归档。", "question_bank_node_has_children");
}
if (await dbContext.Questions.AnyAsync(item => item.TenantId == tenantId && item.ContentNodeId == nodeId && item.Status != QuestionStatus.Archived, token))
{
throw Error("该节点仍有未归档题目,不能归档。", "question_bank_node_has_questions");
}
node.IsActive = false;
node.UpdatedBy = actor.UserId;
var bankId = await dbContext.QuestionBanks.Where(item => item.TenantId == tenantId && item.ContentEntryId == node.EntryId).Select(item => item.Id).SingleAsync(token);
AddAudit(dbContext, actor, "platform.question_bank.node_archived", node.Id, new { node.Name });
await dbContext.SaveChangesAsync(token);
return ToNodeItem(node, bankId, 0);
}, cancellationToken);
public Task<PlatformQuestionPage> GetQuestionsAsync(
PlatformAdminActor actor,
PlatformQuestionBankFilter filter,
CancellationToken cancellationToken = default) =>
ExecuteAsync(actor, "查询公共题库题目", async (_, dbContext, tenantId, token) =>
{
if (!filter.QuestionBankId.HasValue)
{
throw Error("请选择公共题库。", "question_bank_id_required");
}
await RequireBankAsync(dbContext, tenantId, filter.QuestionBankId.Value, token);
var query = dbContext.Questions.AsNoTracking().Where(item => item.TenantId == tenantId && item.QuestionBankId == filter.QuestionBankId);
if (filter.ContentNodeId.HasValue) query = query.Where(item => item.ContentNodeId == filter.ContentNodeId);
if (!string.IsNullOrWhiteSpace(filter.Type)) { var type = filter.Type.Trim(); query = query.Where(item => item.Type == type); }
if (filter.Difficulty.HasValue) query = query.Where(item => item.Difficulty == filter.Difficulty);
if (!string.IsNullOrWhiteSpace(filter.Status) && !filter.Status.Equals("all", StringComparison.OrdinalIgnoreCase))
{
var status = ParseQuestionStatus(filter.Status);
query = query.Where(item => item.Status == status);
}
if (!string.IsNullOrWhiteSpace(filter.Keyword))
{
var keyword = filter.Keyword.Trim();
query = query.Where(item => item.Type.Contains(keyword) || dbContext.QuestionVersions.Any(version => version.TenantId == tenantId && version.QuestionId == item.Id && version.Id == item.CurrentVersionId && version.Content != null && version.Content.Contains(keyword)));
}
var page = Math.Max(1, filter.Page);
var pageSize = Math.Clamp(filter.PageSize, 1, MaxPageSize);
var total = await query.CountAsync(token);
var questions = await query.OrderByDescending(item => item.UpdatedAt).Skip((page - 1) * pageSize).Take(pageSize).ToArrayAsync(token);
var versionIds = questions.Where(item => item.CurrentVersionId.HasValue).Select(item => item.CurrentVersionId!.Value).ToArray();
var versions = await dbContext.QuestionVersions.AsNoTracking().Where(item => item.TenantId == tenantId && versionIds.Contains(item.Id)).ToDictionaryAsync(item => item.Id, token);
return new PlatformQuestionPage(questions.Select(item => ToQuestionItem(item, item.CurrentVersionId.HasValue && versions.TryGetValue(item.CurrentVersionId.Value, out var version) ? version : null)).ToArray(), total, page, pageSize);
}, cancellationToken);
public Task<PlatformQuestionItem> UpsertQuestionAsync(
PlatformAdminActor actor,
UpsertPlatformQuestionCommand command,
CancellationToken cancellationToken = default) =>
ExecuteAsync(actor, "保存公共题库题目", async (_, dbContext, tenantId, token) =>
{
var bank = await RequireBankWithEntryAsync(dbContext, tenantId, command.QuestionBankId, token);
var node = await RequireNodeAsync(dbContext, tenantId, bank, command.ContentNodeId, token);
ValidateQuestion(command);
var question = command.Id.HasValue
? await dbContext.Questions.SingleOrDefaultAsync(item => item.TenantId == tenantId && item.Id == command.Id.Value && item.QuestionBankId == bank.Id, token)
: null;
if (command.Id.HasValue && question is null) throw Error("题目不存在。", "question_not_found");
question ??= new Question { TenantId = tenantId, QuestionBankId = bank.Id, EntryId = bank.ContentEntryId, CreatedAt = DateTimeOffset.UtcNow };
if (!command.Id.HasValue) dbContext.Questions.Add(question);
ApplyQuestion(question, command, bank.ContentEntryId!.Value, node.Id);
await dbContext.SaveChangesAsync(token);
var nextVersion = await dbContext.QuestionVersions.Where(item => item.TenantId == tenantId && item.QuestionId == question.Id).Select(item => (int?)item.VersionNo).MaxAsync(token) ?? 0;
var version = BuildVersion(actor, tenantId, question.Id, nextVersion + 1, command);
dbContext.QuestionVersions.Add(version);
question.CurrentVersionId = version.Id;
AddAudit(dbContext, actor, "platform.question_bank.question_saved", question.Id, new { bankId = bank.Id, nodeId = node.Id, version = version.VersionNo });
await dbContext.SaveChangesAsync(token);
return ToQuestionItem(question, version);
}, cancellationToken);
public Task<int> ArchiveQuestionsAsync(
PlatformAdminActor actor,
ArchivePlatformQuestionsCommand command,
CancellationToken cancellationToken = default) =>
ExecuteAsync(actor, "归档公共题库题目", async (_, dbContext, tenantId, token) =>
{
var ids = command.QuestionIds.Distinct().Take(500).ToArray();
if (ids.Length == 0) throw Error("请选择需要归档的题目。", "question_ids_required");
var questions = await dbContext.Questions.Where(item => item.TenantId == tenantId && ids.Contains(item.Id)).ToArrayAsync(token);
foreach (var question in questions) question.Status = QuestionStatus.Archived;
AddAudit(dbContext, actor, "platform.question_bank.questions_archived", Guid.NewGuid(), new { questionIds = questions.Select(item => item.Id).ToArray() });
await dbContext.SaveChangesAsync(token);
return questions.Length;
}, cancellationToken);
public Task<PlatformQuestionImportResult> PreviewImportAsync(PlatformAdminActor actor, PlatformQuestionImportCommand command, CancellationToken cancellationToken = default) =>
ImportAsync(actor, command, false, cancellationToken);
public Task<PlatformQuestionImportResult> ExecuteImportAsync(PlatformAdminActor actor, PlatformQuestionImportCommand command, CancellationToken cancellationToken = default) =>
ImportAsync(actor, command, true, cancellationToken);
public Task<ContentImportJobDetail> GetImportAsync(PlatformAdminActor actor, Guid jobId, CancellationToken cancellationToken = default) =>
ExecuteAsync(actor, "查询公共题库导入结果", async (_, dbContext, tenantId, token) => await LoadImportDetailAsync(dbContext, tenantId, jobId, token), cancellationToken);
public Task<AssetUploadSignResult> SignQuestionAssetUploadAsync(PlatformAdminActor actor, AssetUploadSignCommand command, CancellationToken cancellationToken = default) =>
ExecuteAsync(actor, "签发公共题库图片上传地址", async (provider, _, tenantId, token) =>
await provider.GetRequiredService<IAssetManagementService>().SignUploadAsync(new AssetManagementActor(tenantId, actor.UserId), command with { AssetType = "image", Category = "question", IsPublic = true }, token), cancellationToken);
public Task<AssetUploadConfirmResult> ConfirmQuestionAssetUploadAsync(PlatformAdminActor actor, AssetUploadConfirmCommand command, CancellationToken cancellationToken = default) =>
ExecuteAsync(actor, "确认公共题库图片上传", async (provider, _, tenantId, token) =>
await provider.GetRequiredService<IAssetManagementService>().ConfirmUploadAsync(new AssetManagementActor(tenantId, actor.UserId), command, token), cancellationToken);
private async Task<PlatformQuestionImportResult> ImportAsync(PlatformAdminActor actor, PlatformQuestionImportCommand command, bool execute, CancellationToken cancellationToken)
{
return await ExecuteAsync(actor, execute ? "执行公共题库导入" : "预检公共题库导入", async (_, dbContext, tenantId, token) =>
{
var bank = await RequireBankWithEntryAsync(dbContext, tenantId, command.QuestionBankId, token);
var format = NormalizeImportFormat(command.Format);
ContentNode? targetNode = null;
if (command.ContentNodeId.HasValue) targetNode = await RequireNodeAsync(dbContext, tenantId, bank, command.ContentNodeId.Value, token);
if (format == "simple" && targetNode is null) throw Error("普通批量导入必须选择章节或试卷。", "import_target_node_required");
var job = new ContentImportJob
{
TenantId = tenantId,
CreatedBy = actor.UserId,
TargetQuestionBankId = bank.Id,
TargetContentNodeId = targetNode?.Id,
ImportType = ContentImportType.Questions,
SourceFormat = ImportSourceFormat.Json,
Status = execute ? ContentImportStatus.Importing : ContentImportStatus.Preview,
SourceName = string.IsNullOrWhiteSpace(command.SourceName) ? null : command.SourceName.Trim(),
SourceHash = Hash(command.Payload.GetRawText()),
DryRun = !execute,
RawPayload = command.Payload.Clone(),
StartedAt = execute ? DateTimeOffset.UtcNow : null
};
if (execute)
{
dbContext.ContentImportJobs.Add(job);
}
var rows = format == "simple"
? ExtractSimpleRows(command.Payload, targetNode!.Id)
: ExtractStructuredRows(command.Payload, targetNode?.Id);
job.TotalCount = rows.Count;
var importItems = new List<ContentImportItem>();
var issues = new List<ContentImportIssue>();
var createdNodes = 0;
var inserted = 0;
var updated = 0;
var skipped = 0;
var nodeCache = new Dictionary<string, ContentNode>(StringComparer.OrdinalIgnoreCase);
foreach (var row in rows)
{
var item = new ContentImportItem
{
TenantId = tenantId,
JobId = job.Id,
RowNo = row.RowNo,
ExternalId = GetString(row.Question, "legacyId") ?? GetString(row.Question, "id"),
SourcePayload = row.Question.Clone(),
NormalizedPayload = row.Question.Clone()
};
var validation = ValidateImportRow(row.Question);
if (validation is not null)
{
item.Status = ContentImportItemStatus.Invalid;
item.IssuesCount = 1;
issues.Add(NewIssue(tenantId, job.Id, item.Id, row.RowNo, validation.Value.Code, validation.Value.Field, validation.Value.Message));
job.ErrorCount++;
importItems.Add(item);
continue;
}
job.ValidCount++;
item.Status = ContentImportItemStatus.Valid;
if (execute)
{
var node = targetNode;
if (format == "structured")
{
(node, var made) = await EnsureStructuredPathAsync(dbContext, actor, tenantId, bank, row.Path, targetNode, nodeCache, token);
createdNodes += made;
}
if (node is null) throw Error("导入题目没有可用的目标章节。", "import_target_node_required");
var result = await UpsertImportedQuestionAsync(dbContext, actor, tenantId, bank, node, row.Question, token);
item.TargetType = "question";
item.TargetId = result.Question.Id;
item.ContentHash = result.SourceHash;
item.Status = result.Action switch
{
"inserted" => ContentImportItemStatus.Inserted,
"updated" => ContentImportItemStatus.Updated,
_ => ContentImportItemStatus.Skipped
};
if (result.Action == "inserted") inserted++;
else if (result.Action == "updated") updated++;
else skipped++;
}
importItems.Add(item);
}
job.InsertedCount = inserted;
job.UpdatedCount = updated;
job.SkippedCount = skipped;
job.Status = !execute ? ContentImportStatus.Preview : job.ErrorCount > 0 ? ContentImportStatus.CompletedWithErrors : ContentImportStatus.Completed;
job.FinishedAt = execute ? DateTimeOffset.UtcNow : null;
job.Summary = JsonSerializer.SerializeToElement(new { format, createdNodes, inserted, updated, skipped, invalid = job.ErrorCount });
job.NormalizedPayload = JsonSerializer.SerializeToElement(rows.Select(item => item.Question));
if (execute)
{
dbContext.ContentImportItems.AddRange(importItems);
dbContext.ContentImportIssues.AddRange(issues);
AddAudit(dbContext, actor, "platform.question_bank.import_executed", job.Id, new { bankId = bank.Id, format, job.TotalCount, job.ErrorCount });
await dbContext.SaveChangesAsync(token);
}
var detail = new ContentImportJobDetail(ToJobItem(job), importItems.Select(ToImportItem).ToArray(), issues.Select(ToIssueItem).ToArray());
return new PlatformQuestionImportResult(detail, createdNodes, inserted, updated, skipped);
}, cancellationToken);
}
private async Task<TResult> ExecuteAsync<TResult>(PlatformAdminActor actor, string reason, Func<IServiceProvider, TikuDbContext, Guid, CancellationToken, Task<TResult>> action, CancellationToken cancellationToken)
{
var access = await currentAccessContext.GetAsync(cancellationToken);
if (access.UserId != actor.UserId || !access.HasPlatformPermission(BackendPermissions.PlatformQuestionBankManage))
{
throw Error("需要平台公共题库管理权限。", "platform_question_bank_access_denied");
}
var correlationId = Guid.NewGuid().ToString("N");
var platformTenantId = await tenantExecutionScope.ExecuteAsync(
new SystemScopeRequest(null, SystemScopeCallerType.Platform, nameof(PlatformQuestionBankService), "解析平台公共题库所属租户", correlationId, IsGlobal: true),
async (provider, token) =>
{
var dbContext = provider.GetRequiredService<TikuDbContext>();
var tenantIds = await dbContext.Tenants.AsNoTracking().Where(item => item.Mode == TenantMode.PlatformOwned).Select(item => item.Id).Take(2).ToArrayAsync(token);
if (tenantIds.Length != 1)
{
throw Error(tenantIds.Length == 0 ? "平台内容所属租户尚未初始化,请先运行数据库迁移器。" : "检测到多个平台内容所属租户,请先修复数据。", tenantIds.Length == 0 ? "platform_question_owner_missing" : "platform_question_owner_ambiguous");
}
return tenantIds[0];
}, cancellationToken);
return await tenantExecutionScope.ExecuteAsync(
new SystemScopeRequest(platformTenantId, SystemScopeCallerType.Platform, nameof(PlatformQuestionBankService), reason, correlationId),
async (provider, token) =>
{
var dbContext = provider.GetRequiredService<TikuDbContext>();
return await action(provider, dbContext, platformTenantId, token);
}, cancellationToken);
}
private static async Task<PlatformQuestionBankNodeItem> UpsertNodeCoreAsync(TikuDbContext dbContext, PlatformAdminActor actor, Guid tenantId, QuestionBank bank, UpsertPlatformQuestionBankNodeCommand command, CancellationToken token)
{
if (string.IsNullOrWhiteSpace(command.Name)) throw Error("节点名称不能为空。", "question_bank_node_name_required");
ContentNode? parent = null;
if (command.ParentId.HasValue) parent = await RequireNodeAsync(dbContext, tenantId, bank, command.ParentId.Value, token);
var node = command.Id.HasValue ? await dbContext.ContentNodes.SingleOrDefaultAsync(item => item.TenantId == tenantId && item.Id == command.Id.Value && item.EntryId == bank.ContentEntryId, token) : null;
if (command.Id.HasValue && node is null) throw Error("内容节点不存在。", "question_bank_node_not_found");
node ??= new ContentNode { TenantId = tenantId, EntryId = bank.ContentEntryId!.Value, CreatedBy = actor.UserId };
if (!command.Id.HasValue) dbContext.ContentNodes.Add(node);
node.ParentId = parent?.Id;
node.NodeKey = string.IsNullOrWhiteSpace(command.NodeKey) ? $"node-{node.Id:N}" : command.NodeKey.Trim();
node.Name = command.Name.Trim();
node.NodeType = command.NodeType;
node.Depth = parent is null ? 0 : parent.Depth + 1;
node.Path = parent is null ? $"n{node.Id:N}" : $"{parent.Path}.n{node.Id:N}";
node.SortOrder = command.SortOrder;
node.IsActive = true;
node.IsSelectable = command.IsSelectable;
node.IsLeaf = command.NodeType is ContentNodeType.Chapter or ContentNodeType.Paper;
node.Metadata = ObjectOrDefault(command.Metadata);
node.UpdatedBy = actor.UserId;
AddAudit(dbContext, actor, "platform.question_bank.node_saved", node.Id, new { bankId = bank.Id, node.Name, node.NodeType });
await dbContext.SaveChangesAsync(token);
return ToNodeItem(node, bank.Id, 0);
}
private static async Task<(ContentNode Node, int Created)> EnsureStructuredPathAsync(TikuDbContext dbContext, PlatformAdminActor actor, Guid tenantId, QuestionBank bank, IReadOnlyCollection<ImportPathPart> path, ContentNode? root, Dictionary<string, ContentNode> cache, CancellationToken token)
{
var parent = root;
var created = 0;
foreach (var part in path)
{
var cacheKey = $"{parent?.Id:N}/{part.Key}";
if (!cache.TryGetValue(cacheKey, out var node))
{
var parentId = parent?.Id;
node = await dbContext.ContentNodes.SingleOrDefaultAsync(item => item.TenantId == tenantId && item.EntryId == bank.ContentEntryId && item.ParentId == parentId && item.NodeKey == part.Key, token);
if (node is null)
{
node = new ContentNode
{
TenantId = tenantId,
EntryId = bank.ContentEntryId!.Value,
ParentId = parent?.Id,
NodeKey = part.Key,
Name = part.Name,
NodeType = part.Type,
Depth = parent is null ? 0 : parent.Depth + 1,
Path = parent is null ? null : $"{parent.Path}.n{Guid.NewGuid():N}",
SortOrder = part.Order,
IsActive = true,
IsSelectable = true,
IsLeaf = part.Type is ContentNodeType.Chapter or ContentNodeType.Paper,
CreatedBy = actor.UserId,
UpdatedBy = actor.UserId
};
node.Path = parent is null ? $"n{node.Id:N}" : $"{parent.Path}.n{node.Id:N}";
dbContext.ContentNodes.Add(node);
await dbContext.SaveChangesAsync(token);
created++;
}
cache[cacheKey] = node;
}
parent = node;
}
if (parent is null) throw Error("结构化导入未包含可用的章节或试卷。", "structured_import_path_required");
return (parent, created);
}
private static async Task<(Question Question, string Action, string SourceHash)> UpsertImportedQuestionAsync(TikuDbContext dbContext, PlatformAdminActor actor, Guid tenantId, QuestionBank bank, ContentNode node, JsonElement payload, CancellationToken token)
{
var legacyId = GetString(payload, "legacyId") ?? GetString(payload, "id");
var sourceHash = GetString(payload, "sourceHash") ?? Hash(payload.GetRawText());
var question = !string.IsNullOrWhiteSpace(legacyId)
? await dbContext.Questions.SingleOrDefaultAsync(item => item.TenantId == tenantId && item.LegacyId == legacyId, token)
: await dbContext.Questions.Where(item => item.TenantId == tenantId && item.QuestionBankId == bank.Id)
.Join(dbContext.QuestionVersions.Where(item => item.TenantId == tenantId && item.SourceHash == sourceHash), item => item.CurrentVersionId, version => (Guid?)version.Id, (item, _) => item)
.SingleOrDefaultAsync(token);
if (question is not null && question.QuestionBankId != bank.Id) throw Error("题目稳定编号已被其他题库使用。", "question_legacy_id_conflict");
if (question?.CurrentVersionId is { } currentId && await dbContext.QuestionVersions.AnyAsync(item => item.TenantId == tenantId && item.Id == currentId && item.SourceHash == sourceHash, token)) return (question, "skipped", sourceHash);
var isNew = question is null;
question ??= new Question { TenantId = tenantId, QuestionBankId = bank.Id, EntryId = bank.ContentEntryId, LegacyId = legacyId };
if (isNew) dbContext.Questions.Add(question);
var command = FromImportPayload(bank.Id, node.Id, payload, legacyId, sourceHash);
ApplyQuestion(question, command, bank.ContentEntryId!.Value, node.Id);
await dbContext.SaveChangesAsync(token);
var nextVersion = await dbContext.QuestionVersions.Where(item => item.TenantId == tenantId && item.QuestionId == question.Id).Select(item => (int?)item.VersionNo).MaxAsync(token) ?? 0;
var version = BuildVersion(actor, tenantId, question.Id, nextVersion + 1, command);
dbContext.QuestionVersions.Add(version);
question.CurrentVersionId = version.Id;
await dbContext.SaveChangesAsync(token);
return (question, isNew ? "inserted" : "updated", sourceHash);
}
private static List<ImportRow> ExtractSimpleRows(JsonElement payload, Guid nodeId)
{
var array = payload.ValueKind == JsonValueKind.Array ? payload : payload.ValueKind == JsonValueKind.Object && payload.TryGetProperty("questions", out var questions) ? questions : default;
if (array.ValueKind != JsonValueKind.Array) throw Error("普通导入内容必须是题目数组,或包含 questions 数组。", "import_payload_invalid");
return array.EnumerateArray().Select((item, index) => new ImportRow(index + 1, item.Clone(), [], nodeId)).ToList();
}
private static List<ImportRow> ExtractStructuredRows(JsonElement payload, Guid? rootNodeId)
{
if (payload.ValueKind != JsonValueKind.Object) throw Error("结构化导入内容必须是 JSON 对象。", "structured_import_payload_invalid");
if (payload.TryGetProperty("_tikuExport", out var marker) && marker.GetString() != "2.0") throw Error("仅支持 2.0 结构化题库文件。", "structured_import_version_invalid");
var rows = new List<ImportRow>();
WalkStructured(payload, [], rows, rootNodeId);
if (rows.Count == 0) throw Error("结构化文件中没有找到题目。", "structured_import_questions_empty");
return rows;
}
private static void WalkStructured(JsonElement current, List<ImportPathPart> path, List<ImportRow> rows, Guid? rootNodeId)
{
if (current.ValueKind != JsonValueKind.Object) return;
if (current.TryGetProperty("questions", out var questions) && questions.ValueKind == JsonValueKind.Array)
{
foreach (var question in questions.EnumerateArray()) rows.Add(new ImportRow(rows.Count + 1, question.Clone(), path.ToArray(), rootNodeId));
}
string[] childProperties = ["categories", "children", "subjects", "chapters", "papers", "nodes"];
foreach (var property in childProperties)
{
if (!current.TryGetProperty(property, out var children) || children.ValueKind != JsonValueKind.Array) continue;
foreach (var child in children.EnumerateArray())
{
if (child.ValueKind != JsonValueKind.Object) continue;
var name = GetString(child, "name") ?? GetString(child, "title") ?? "未命名节点";
var key = GetString(child, "code") ?? GetString(child, "key") ?? GetString(child, "id") ?? $"{property}-{Hash(name)[..12]}";
var type = property switch
{
"subjects" => ContentNodeType.Subject,
"chapters" => ContentNodeType.Chapter,
"papers" => ContentNodeType.Paper,
_ => ParseNodeType(GetString(child, "type"), ContentNodeType.Category)
};
var next = new List<ImportPathPart>(path) { new(key, name, type, GetInt(child, "order") ?? path.Count) };
WalkStructured(child, next, rows, rootNodeId);
}
}
}
private static (string Code, string Field, string Message)? ValidateImportRow(JsonElement payload)
{
if (payload.ValueKind != JsonValueKind.Object) return ("question_payload_invalid", "$", "题目必须是 JSON 对象。");
if (string.IsNullOrWhiteSpace(GetString(payload, "content")) && string.IsNullOrWhiteSpace(GetString(payload, "title"))) return ("question_content_required", "content", "题干不能为空。");
var type = GetString(payload, "type") ?? "choice";
if (!SupportedQuestionTypes.Contains(type)) return ("question_type_invalid", "type", $"不支持的题型:{type}。");
return null;
}
private static void ValidateQuestion(UpsertPlatformQuestionCommand command)
{
if (string.IsNullOrWhiteSpace(command.Content)) throw Error("题干不能为空。", "question_content_required");
var type = string.IsNullOrWhiteSpace(command.Type) ? "choice" : command.Type.Trim();
if (!SupportedQuestionTypes.Contains(type)) throw Error("题型不受支持。", "question_type_invalid");
if (command.Difficulty is < 1 or > 5) throw Error("难度必须在 1 到 5 之间。", "question_difficulty_invalid");
}
private static void ApplyQuestion(Question question, UpsertPlatformQuestionCommand command, Guid entryId, Guid nodeId)
{
question.QuestionBankId = command.QuestionBankId;
question.EntryId = entryId;
question.ContentNodeId = nodeId;
question.LegacyId = Normalize(command.LegacyId);
question.Type = Normalize(command.Type) ?? "choice";
question.TypeLabel = Normalize(command.TypeLabel);
question.Difficulty = command.Difficulty;
question.Tags = ArrayOrDefault(command.Tags);
question.MediaUrl = Normalize(command.MediaUrl);
question.ExamMarkers = ObjectOrDefault(command.ExamMarkers);
question.Status = ParseQuestionStatus(command.Status);
}
private static QuestionVersion BuildVersion(PlatformAdminActor actor, Guid tenantId, Guid questionId, int versionNo, UpsertPlatformQuestionCommand command) => new()
{
TenantId = tenantId,
QuestionId = questionId,
VersionNo = versionNo,
Content = Normalize(command.Content),
Options = ArrayOrDefault(command.Options),
CorrectOptionIndex = command.CorrectOptionIndex,
CorrectOptionIndices = ArrayOrDefault(command.CorrectOptionIndices),
AnswerText = Normalize(command.AnswerText),
Explanation = Normalize(command.Explanation),
SubQuestions = ArrayOrDefault(command.SubQuestions),
CodeLang = Normalize(command.CodeLang),
CodeTemplate = Normalize(command.CodeTemplate),
SourceHash = Normalize(command.SourceHash) ?? Hash(JsonSerializer.Serialize(command)),
CreatedBy = actor.UserId
};
private static UpsertPlatformQuestionCommand FromImportPayload(Guid bankId, Guid nodeId, JsonElement payload, string? legacyId, string sourceHash) => new(
null, bankId, nodeId, legacyId, GetString(payload, "type") ?? "choice", GetString(payload, "typeLabel"), GetInt(payload, "difficulty"),
GetElement(payload, "tags", JsonDefaults.Array()), GetString(payload, "content") ?? GetString(payload, "title"), GetElement(payload, "options", JsonDefaults.Array()),
GetInt(payload, "correctOptionIndex"), GetElement(payload, "correctOptionIndices", JsonDefaults.Array()), GetString(payload, "answerText") ?? GetString(payload, "answer"),
GetString(payload, "explanation"), GetElement(payload, "subQuestions", JsonDefaults.Array()), GetString(payload, "codeLang"), GetString(payload, "codeTemplate"),
GetString(payload, "mediaUrl"), GetString(payload, "status") ?? "published", GetElement(payload, "examMarkers", JsonDefaults.Object()), sourceHash);
private static async Task<QuestionBank> RequireBankAsync(TikuDbContext dbContext, Guid tenantId, Guid bankId, CancellationToken token) =>
await dbContext.QuestionBanks.SingleOrDefaultAsync(item => item.TenantId == tenantId && item.Id == bankId, token) ?? throw Error("公共题库不存在。", "question_bank_not_found");
private static async Task<QuestionBank> RequireBankWithEntryAsync(TikuDbContext dbContext, Guid tenantId, Guid bankId, CancellationToken token)
{
var bank = await RequireBankAsync(dbContext, tenantId, bankId, token);
if (!bank.ContentEntryId.HasValue) throw Error("题库内容入口尚未初始化,请先编辑并保存题库。", "question_bank_entry_missing");
return bank;
}
private static async Task<ContentNode> RequireNodeAsync(TikuDbContext dbContext, Guid tenantId, QuestionBank bank, Guid nodeId, CancellationToken token) =>
await dbContext.ContentNodes.SingleOrDefaultAsync(item => item.TenantId == tenantId && item.EntryId == bank.ContentEntryId && item.Id == nodeId && item.IsActive, token) ?? throw Error("所选内容节点不存在或已归档。", "question_bank_node_not_found");
private static PlatformQuestionBankItem ToBankItem(QuestionBank item, int nodeCount, int questionCount) => new(item.Id, item.ContentEntryId, item.Name, item.Status, nodeCount, questionCount, item.Metadata, item.CreatedAt, item.UpdatedAt);
private static PlatformQuestionBankNodeItem ToNodeItem(ContentNode item, Guid bankId, int questionCount) => new(item.Id, bankId, item.EntryId, item.ParentId, item.NodeKey, item.Name, item.NodeType, item.Depth, item.SortOrder, item.IsActive, item.IsSelectable, item.IsLeaf, questionCount, item.Metadata);
private static PlatformQuestionItem ToQuestionItem(Question item, QuestionVersion? version) => new(item.Id, version?.Id, item.QuestionBankId!.Value, item.EntryId!.Value, item.ContentNodeId!.Value, item.LegacyId, item.Type, item.TypeLabel, item.Difficulty, item.Tags, version?.Content, version?.Options ?? JsonDefaults.Array(), version?.CorrectOptionIndex, version?.CorrectOptionIndices ?? JsonDefaults.Array(), version?.AnswerText, version?.Explanation, version?.SubQuestions ?? JsonDefaults.Array(), version?.CodeLang, version?.CodeTemplate, item.MediaUrl, item.Status, version?.VersionNo ?? 0, item.CreatedAt, item.UpdatedAt);
private static async Task<ContentImportJobDetail> LoadImportDetailAsync(TikuDbContext dbContext, Guid tenantId, Guid jobId, CancellationToken token)
{
var job = await dbContext.ContentImportJobs.AsNoTracking().SingleOrDefaultAsync(item => item.TenantId == tenantId && item.Id == jobId, token) ?? throw Error("导入任务不存在。", "question_import_not_found");
var items = await dbContext.ContentImportItems.AsNoTracking().Where(item => item.TenantId == tenantId && item.JobId == jobId).OrderBy(item => item.RowNo).Select(item => ToImportItem(item)).ToArrayAsync(token);
var issues = await dbContext.ContentImportIssues.AsNoTracking().Where(item => item.TenantId == tenantId && item.JobId == jobId).OrderBy(item => item.RowNo).Select(item => ToIssueItem(item)).ToArrayAsync(token);
return new ContentImportJobDetail(ToJobItem(job), items, issues);
}
private static ContentImportJobItem ToJobItem(ContentImportJob item) => new(item.Id, item.TargetRegionId, item.TargetSubjectId, item.TargetCategoryId, item.TargetContentNodeId, item.TargetQuestionBankId, item.ImportType, item.SourceFormat, item.Status, item.SourceName, item.SourceHash, item.DryRun, item.TotalCount, item.ValidCount, item.ErrorCount, item.WarningCount, item.InsertedCount, item.UpdatedCount, item.SkippedCount, item.Summary, item.ErrorMessage, item.StartedAt, item.FinishedAt, item.CreatedAt, item.UpdatedAt);
private static ContentImportItemModel ToImportItem(ContentImportItem item) => new(item.Id, item.JobId, item.RowNo, item.ExternalId, item.Status, item.TargetType, item.TargetId, item.SourcePayload, item.NormalizedPayload, item.ContentHash, item.IssuesCount);
private static ContentImportIssueModel ToIssueItem(ContentImportIssue item) => new(item.Id, item.JobId, item.ItemId, item.RowNo, item.Severity, item.Code, item.FieldPath, item.Message, item.Details);
private static ContentImportIssue NewIssue(Guid tenantId, Guid jobId, Guid itemId, int rowNo, string code, string field, string message) => new() { TenantId = tenantId, JobId = jobId, ItemId = itemId, RowNo = rowNo, Severity = ImportIssueSeverity.Error, Code = code, FieldPath = field, Message = message };
private static void AddAudit(TikuDbContext dbContext, PlatformAdminActor actor, string action, Guid targetId, object details) => dbContext.AuditLogs.Add(new AuditLog { ActorUserId = actor.UserId, Action = action, TargetType = "question_bank", TargetId = targetId.ToString("N"), Details = JsonSerializer.SerializeToElement(details) });
private static PlatformAdminException Error(string message, string code) => new(message, code);
private static string NormalizeImportFormat(string? value) => value?.Trim().ToLowerInvariant() switch { "simple" or "json" => "simple", "structured-v2" or "structured" or "v2" => "structured", _ => throw Error("导入格式不受支持。", "question_import_format_invalid") };
private static QuestionBankStatus ParseBankStatus(string? value) => value?.Trim().ToLowerInvariant() switch { "archived" or "已归档" => QuestionBankStatus.Archived, _ => QuestionBankStatus.Active };
private static QuestionStatus ParseQuestionStatus(string? value) => value?.Trim().ToLowerInvariant() switch { "draft" or "草稿" => QuestionStatus.Draft, "archived" or "已归档" => QuestionStatus.Archived, _ => QuestionStatus.Published };
private static ContentNodeType ParseNodeType(string? value, ContentNodeType fallback) => value?.Trim().ToLowerInvariant() switch { "subject" => ContentNodeType.Subject, "chapter" => ContentNodeType.Chapter, "paper" => ContentNodeType.Paper, "category" => ContentNodeType.Category, _ => fallback };
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static JsonElement ObjectOrDefault(JsonElement value) => value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDefaults.Object();
private static JsonElement ArrayOrDefault(JsonElement value) => value.ValueKind == JsonValueKind.Array ? value.Clone() : JsonDefaults.Array();
private static string Hash(string value) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
private static string? GetString(JsonElement value, string name) => value.ValueKind == JsonValueKind.Object && value.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.String ? property.GetString() : null;
private static int? GetInt(JsonElement value, string name) => value.ValueKind == JsonValueKind.Object && value.TryGetProperty(name, out var property) && property.TryGetInt32(out var result) ? result : null;
private static JsonElement GetElement(JsonElement value, string name, JsonElement fallback) => value.ValueKind == JsonValueKind.Object && value.TryGetProperty(name, out var property) ? property.Clone() : fallback;
private sealed record ImportPathPart(string Key, string Name, ContentNodeType Type, int Order);
private sealed record ImportRow(int RowNo, JsonElement Question, IReadOnlyCollection<ImportPathPart> Path, Guid? TargetNodeId);
}

View File

@@ -16,8 +16,8 @@ namespace Tiku.IntegrationTests.Api;
public sealed class AuthorizationManifestTests
{
private const int ExpectedActionCount = 382;
private const string ExpectedSha256 = "a1ca69bc9cfa439d5df1a456e60ba2f6b1f1baf1cca22e5a28be04a774682bb1";
private const int ExpectedActionCount = 397;
private const string ExpectedSha256 = "fe0636f609e86c8c7540d84914f8254106d20194ee5bc715d616c5a4f84c7a94";
[Fact]
public void Controller_authorization_surface_matches_reviewed_manifest()

View File

@@ -50,4 +50,68 @@ public sealed class PlatformAdminStaticEndpointTests
Assert.Contains("/api/auth/password/change-required", authentication, StringComparison.Ordinal);
Assert.DoesNotContain("/api/auth/mfa", authentication, StringComparison.Ordinal);
}
[Fact]
public async Task Platform_admin_shell_covers_current_platform_capabilities_and_dto_fields()
{
await using var factory = new ApiTestFactory();
using var client = factory.CreateClient();
var application = await client.GetStringAsync("/platform-admin/app.js");
var api = await client.GetStringAsync("/platform-admin/platform-api.js");
var contract = await client.GetStringAsync("/platform-admin/spec-contract.js");
Assert.Contains("租户域名与证书", application, StringComparison.Ordinal);
Assert.Contains("SaaS 商品与版本", application, StringComparison.Ordinal);
Assert.Contains("SaaS 订单与交易", application, StringComparison.Ordinal);
Assert.Contains("平台角色与功能", application, StringComparison.Ordinal);
Assert.Contains("function businessStatusLabel(value)", application, StringComparison.Ordinal);
Assert.Contains("function backendStatusLabel()", application, StringComparison.Ordinal);
Assert.DoesNotContain("业务功能名称和中文说明", application, StringComparison.Ordinal);
Assert.DoesNotContain("中文说明", application, StringComparison.Ordinal);
Assert.DoesNotContain("API 契约", application, StringComparison.Ordinal);
Assert.Contains("/api/backoffice/platform/bootstrap", contract, StringComparison.Ordinal);
Assert.Contains("/api/platform-admin/payment-settings/channels", contract, StringComparison.Ordinal);
Assert.Contains("/api/platform-admin/tenant-capabilities/crm/logs", contract, StringComparison.Ordinal);
Assert.Contains("userId: input.userId", api, StringComparison.Ordinal);
Assert.Contains("roleIds: input.roleIds", api, StringComparison.Ordinal);
Assert.Contains("resolutionNote: input.resolutionNote", api, StringComparison.Ordinal);
Assert.Contains("platformPermissionLabels[item.code]", api, StringComparison.Ordinal);
Assert.Contains("'platform:role:manage': '管理平台角色与权限'", api, StringComparison.Ordinal);
Assert.DoesNotContain("authUserId: input.authUserId", api, StringComparison.Ordinal);
Assert.DoesNotContain("platformPermissions:", api, StringComparison.Ordinal);
Assert.DoesNotContain("staffId: input.staffId", api, StringComparison.Ordinal);
}
[Fact]
public async Task Platform_question_bank_workspace_loads_cache_busted_scripts_and_real_api_bindings()
{
await using var factory = new ApiTestFactory();
using var client = factory.CreateClient();
var shell = await client.GetStringAsync("/platform-admin/");
var application = await client.GetStringAsync("/platform-admin/app.js");
var api = await client.GetStringAsync("/platform-admin/platform-api.js");
var contract = await client.GetStringAsync("/platform-admin/spec-contract.js");
Assert.Contains("app.js?v=20260730-question-bank1", shell, StringComparison.Ordinal);
Assert.Contains("platform-api.js?v=20260730-question-bank1", shell, StringComparison.Ordinal);
Assert.Contains("spec-contract.js?v=20260730-question-bank1", shell, StringComparison.Ordinal);
Assert.Contains("operationId: 'PlatformQuestionBanksController_upsertBank'", application, StringComparison.Ordinal);
Assert.Contains("operationId: 'PlatformQuestionBanksController_upsertNode'", application, StringComparison.Ordinal);
Assert.Contains("operationId: 'PlatformQuestionBanksController_upsertQuestion'", application, StringComparison.Ordinal);
Assert.Contains("PlatformQuestionBanksController_archiveQuestions", application, StringComparison.Ordinal);
Assert.Contains("PlatformQuestionBanksController_archiveNode", application, StringComparison.Ordinal);
Assert.Contains("PlatformQuestionBanksController_archiveBank", application, StringComparison.Ordinal);
Assert.Contains("data-action=\"shift-bank-node\"", application, StringComparison.Ordinal);
Assert.Contains("PlatformQuestionBanksController_signAssetUpload", application, StringComparison.Ordinal);
Assert.Contains("PlatformQuestionBanksController_confirmAssetUpload", application, StringComparison.Ordinal);
Assert.Contains("PlatformQuestionBanksController_upsertBank(input)", api, StringComparison.Ordinal);
Assert.Contains("PlatformQuestionBanksController_upsertQuestion(input)", api, StringComparison.Ordinal);
Assert.Contains("PlatformQuestionBanksController_executeImport(input)", api, StringComparison.Ordinal);
Assert.Contains("['P-09', 'PlatformQuestionBanksController_upsertBank', 'PUT', '/api/platform-admin/question-banks']", contract, StringComparison.Ordinal);
Assert.Contains("['P-09', 'PlatformQuestionBanksController_executeImport', 'POST', '/api/platform-admin/question-banks/imports']", contract, StringComparison.Ordinal);
Assert.DoesNotContain("question_bank:grant", application, StringComparison.Ordinal);
Assert.DoesNotContain("question_bank:ops", application, StringComparison.Ordinal);
}
}

View File

@@ -0,0 +1,219 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Application.PlatformAdmin;
using Tiku.Application.Security;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
using Tiku.Domain.Identity;
using Tiku.Domain.Operations;
using Tiku.Domain.Platform;
using Tiku.Domain.QuestionBanks;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Auth;
using Tiku.Infrastructure.Persistence;
namespace Tiku.IntegrationTests.Api;
public sealed class PlatformQuestionBankEndpointTests
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) }
};
[Fact]
public async Task Platform_question_bank_workflow_is_scoped_versioned_and_idempotent()
{
await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary<string, string?>
{
["Tenancy:Resolution:PlatformHosts:0"] = "localhost"
});
var platformTenantId = Guid.NewGuid();
var ordinaryTenantId = Guid.NewGuid();
var ordinaryBankId = Guid.NewGuid();
var platformUser = await SeedPlatformQuestionBankUserAsync(factory);
await factory.SeedAsync(
new Tenant { Id = platformTenantId, Slug = "platform-content-test", Name = "平台公共内容", Mode = TenantMode.PlatformOwned, Status = TenantStatus.Active, BillingStatus = BillingStatus.Active },
new Tenant { Id = ordinaryTenantId, Slug = "ordinary-question-bank", Name = "普通租户", Status = TenantStatus.Active, BillingStatus = BillingStatus.Active },
new QuestionBank { Id = ordinaryBankId, TenantId = ordinaryTenantId, Name = "普通租户私有题库" });
using var client = factory.CreateClient();
client.UseAccessToken(await client.LoginAsPlatformAsync(platformUser.Email));
var bankResponse = await client.PutAsJsonAsync("/api/platform-admin/question-banks", new UpsertPlatformQuestionBankCommand(null, "平台高等数学公共题库", JsonDefaults.Object()));
Assert.True(bankResponse.StatusCode == HttpStatusCode.OK, await bankResponse.Content.ReadAsStringAsync());
var bank = await bankResponse.Content.ReadFromJsonAsync<PlatformQuestionBankItem>(JsonOptions);
Assert.NotNull(bank);
Assert.NotNull(bank.ContentEntryId);
var nodeResponse = await client.PutAsJsonAsync("/api/platform-admin/question-banks/nodes", new UpsertPlatformQuestionBankNodeCommand(
null, bank.Id, null, "math-chapter-1", "函数、极限与连续", ContentNodeType.Chapter, 10, true, JsonDefaults.Object()));
Assert.Equal(HttpStatusCode.OK, nodeResponse.StatusCode);
var node = await nodeResponse.Content.ReadFromJsonAsync<PlatformQuestionBankNodeItem>(JsonOptions);
Assert.NotNull(node);
var createQuestion = QuestionCommand(null, bank.Id, node.Id, "platform-question-1", "函数极限的定义是什么?");
var questionResponse = await client.PutAsJsonAsync("/api/platform-admin/question-banks/questions", createQuestion);
Assert.Equal(HttpStatusCode.OK, questionResponse.StatusCode);
var question = await questionResponse.Content.ReadFromJsonAsync<PlatformQuestionItem>(JsonOptions);
Assert.NotNull(question);
Assert.Equal(1, question.VersionNo);
var updatedResponse = await client.PutAsJsonAsync(
"/api/platform-admin/question-banks/questions",
createQuestion with { Id = question.Id, Content = "请说明函数极限的严格定义。" });
Assert.Equal(HttpStatusCode.OK, updatedResponse.StatusCode);
var updated = await updatedResponse.Content.ReadFromJsonAsync<PlatformQuestionItem>(JsonOptions);
Assert.NotNull(updated);
Assert.Equal(2, updated.VersionNo);
var import = new PlatformQuestionImportCommand(
bank.Id,
node.Id,
"simple",
"平台普通 JSON 回归",
JsonSerializer.SerializeToElement(new object[]
{
new { legacyId = "import-stable-1", type = "true_false", content = "函数极限一定存在。", answerText = "错误" },
new { legacyId = "invalid-1", type = "choice", content = "" }
}));
var jobsBeforePreview = await CountImportJobsAsync(factory, platformTenantId);
var previewResponse = await client.PostAsJsonAsync("/api/platform-admin/question-banks/imports/preview", import);
Assert.Equal(HttpStatusCode.OK, previewResponse.StatusCode);
var preview = await previewResponse.Content.ReadFromJsonAsync<PlatformQuestionImportResult>(JsonOptions);
Assert.NotNull(preview);
Assert.Equal(2, preview.Detail.Job.TotalCount);
Assert.Equal(1, preview.Detail.Job.ValidCount);
Assert.Equal(1, preview.Detail.Job.ErrorCount);
Assert.Equal(jobsBeforePreview, await CountImportJobsAsync(factory, platformTenantId));
var firstImportResponse = await client.PostAsJsonAsync("/api/platform-admin/question-banks/imports", import);
var repeatedImportResponse = await client.PostAsJsonAsync("/api/platform-admin/question-banks/imports", import);
Assert.True(firstImportResponse.StatusCode == HttpStatusCode.OK, await firstImportResponse.Content.ReadAsStringAsync());
Assert.True(repeatedImportResponse.StatusCode == HttpStatusCode.OK, await repeatedImportResponse.Content.ReadAsStringAsync());
var firstImport = await firstImportResponse.Content.ReadFromJsonAsync<PlatformQuestionImportResult>(JsonOptions);
var repeatedImport = await repeatedImportResponse.Content.ReadFromJsonAsync<PlatformQuestionImportResult>(JsonOptions);
Assert.NotNull(firstImport);
Assert.NotNull(repeatedImport);
Assert.Equal(1, firstImport.InsertedQuestionCount);
Assert.Equal(1, repeatedImport.SkippedQuestionCount);
var structuredImport = new PlatformQuestionImportCommand(
bank.Id,
null,
"structured-v2",
"平台结构化 JSON 回归",
JsonSerializer.SerializeToElement(new
{
_tikuExport = "2.0",
subjects = new[]
{
new
{
code = "advanced-math",
name = "高等数学",
chapters = new[]
{
new
{
code = "limits",
name = "极限",
questions = new[]
{
new { legacyId = "structured-stable-1", type = "choice", content = "下列极限存在的是?", options = new[] { "A", "B" }, answerText = "A" }
}
}
}
}
}
}));
var structuredResponse = await client.PostAsJsonAsync("/api/platform-admin/question-banks/imports", structuredImport);
var repeatedStructuredResponse = await client.PostAsJsonAsync("/api/platform-admin/question-banks/imports", structuredImport);
Assert.True(structuredResponse.StatusCode == HttpStatusCode.OK, await structuredResponse.Content.ReadAsStringAsync());
Assert.True(repeatedStructuredResponse.StatusCode == HttpStatusCode.OK, await repeatedStructuredResponse.Content.ReadAsStringAsync());
var structured = await structuredResponse.Content.ReadFromJsonAsync<PlatformQuestionImportResult>(JsonOptions);
var repeatedStructured = await repeatedStructuredResponse.Content.ReadFromJsonAsync<PlatformQuestionImportResult>(JsonOptions);
Assert.NotNull(structured);
Assert.NotNull(repeatedStructured);
Assert.Equal(2, structured.CreatedNodeCount);
Assert.Equal(1, structured.InsertedQuestionCount);
Assert.Equal(0, repeatedStructured.CreatedNodeCount);
Assert.Equal(1, repeatedStructured.SkippedQuestionCount);
var listedResponse = await client.GetAsync($"/api/platform-admin/question-banks/questions?questionBankId={bank.Id}&page=1&pageSize=20");
Assert.Equal(HttpStatusCode.OK, listedResponse.StatusCode);
var listed = await listedResponse.Content.ReadFromJsonAsync<PlatformQuestionPage>(JsonOptions);
Assert.NotNull(listed);
Assert.Equal(3, listed.Total);
var bankList = await client.GetFromJsonAsync<PlatformQuestionBankItem[]>("/api/platform-admin/question-banks?status=all", JsonOptions);
Assert.NotNull(bankList);
Assert.Contains(bankList, item => item.Id == bank.Id);
Assert.DoesNotContain(bankList, item => item.Id == ordinaryBankId);
using var scope = factory.CreateSystemScope("验证平台公共题库租户隔离与版本");
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
Assert.Equal(platformTenantId, await dbContext.QuestionBanks.Where(item => item.Id == bank.Id).Select(item => item.TenantId).SingleAsync());
Assert.Equal(2, await dbContext.QuestionVersions.CountAsync(item => item.TenantId == platformTenantId && item.QuestionId == question.Id));
Assert.Equal(1, await dbContext.Questions.CountAsync(item => item.TenantId == platformTenantId && item.LegacyId == "import-stable-1"));
}
[Fact]
public async Task Platform_question_bank_rejects_missing_permission_and_non_platform_host()
{
await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary<string, string?>
{
["Tenancy:Resolution:PlatformHosts:0"] = "platform.example.test"
});
await factory.SeedAsync(
new Tenant { Id = Guid.NewGuid(), Slug = "platform-content-auth", Name = "平台公共内容", Mode = TenantMode.PlatformOwned });
var platformWithoutPermission = await SeedPlatformUserAsync(factory, BackendPermissions.PlatformDashboardView);
using var client = factory.CreateClient();
client.UseAccessToken(await client.LoginAsPlatformAsync(platformWithoutPermission.Email));
using var wrongHostRequest = new HttpRequestMessage(HttpMethod.Get, "/api/platform-admin/question-banks");
wrongHostRequest.Headers.Host = "tenant.example.test";
var wrongHost = await client.SendAsync(wrongHostRequest);
using var platformHostRequest = new HttpRequestMessage(HttpMethod.Get, "/api/platform-admin/question-banks");
platformHostRequest.Headers.Host = "platform.example.test";
var missingPermission = await client.SendAsync(platformHostRequest);
Assert.Equal(HttpStatusCode.NotFound, wrongHost.StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, missingPermission.StatusCode);
}
private static UpsertPlatformQuestionCommand QuestionCommand(Guid? id, Guid bankId, Guid nodeId, string legacyId, string content) => new(
id, bankId, nodeId, legacyId, "short_answer", "简答题", 3,
JsonSerializer.SerializeToElement(new[] { "极限" }), content, JsonDefaults.Array(), null, JsonDefaults.Array(),
"按定义作答", "考查函数极限定义", JsonDefaults.Array(), null, null, null, "published", JsonDefaults.Object(), null);
private static async Task<int> CountImportJobsAsync(ApiTestFactory factory, Guid tenantId)
{
using var scope = factory.CreateSystemScope("统计平台公共题库导入任务");
return await scope.ServiceProvider.GetRequiredService<TikuDbContext>().ContentImportJobs.CountAsync(item => item.TenantId == tenantId);
}
private static Task<(Guid UserId, string Email)> SeedPlatformQuestionBankUserAsync(ApiTestFactory factory) =>
SeedPlatformUserAsync(factory, BackendPermissions.PlatformQuestionBankManage);
private static async Task<(Guid UserId, string Email)> SeedPlatformUserAsync(ApiTestFactory factory, string permissionCode)
{
var userId = Guid.NewGuid();
var roleId = Guid.NewGuid();
var email = $"platform-question-bank-{Guid.NewGuid():N}@example.test";
var moduleCode = PermissionModuleCatalog.ResolvePermissionModuleCode(permissionCode);
await factory.SeedAsync(
new PermissionModule { Code = moduleCode, Name = "平台公共题库", Area = BackendPermissionArea.Platform },
new BackendPermission { Code = permissionCode, Name = "平台公共题库运营", Area = BackendPermissionArea.Platform, PermissionModuleCode = moduleCode, IsSystem = true },
new User { Id = userId, Email = email, NormalizedEmail = email.ToUpperInvariant(), UserName = email, NormalizedUserName = email.ToUpperInvariant(), Name = "平台题库运营", PrimaryRole = "platform_admin", RawProfile = JsonDefaults.Object() }.WithTestPassword(),
new PlatformBackendRole { Id = roleId, Code = $"platform_question_bank_{roleId:N}", Name = "平台题库运营", Status = BackendRoleStatus.Active },
new PlatformBackendRolePermission { RoleId = roleId, PermissionCode = permissionCode },
new PlatformBackendUserRole { UserId = userId, RoleId = roleId });
return (userId, email);
}
}