feat: add Vite configuration for Tiku.PlatformAdmin.Web and update documentation
- Introduced Vite configuration files (vite.config.js, vite.config.ts, vite.config.d.ts) for the React frontend. - Configured server proxy settings for API endpoints. - Added Vitest configuration files (vitest.config.js, vitest.config.ts, vitest.config.d.ts) for testing. - Updated architecture overview to reflect the separation of the platform admin frontend into its own React project. - Modified quickstart documentation to guide users on starting the platform admin frontend.
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageVersion>
|
||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.SpaProxy" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.10" />
|
||||
|
||||
@@ -47,7 +47,7 @@ Development 首次迁移会创建平台管理员 `admin@tiku.local`,随机临
|
||||
|
||||
默认开发入口:
|
||||
|
||||
- 平台管理端:<http://localhost:5090/platform-admin/>
|
||||
- 平台管理端:首次在 `Tiku.PlatformAdmin.Web` 执行 `npm install`;之后启动 `Tiku.Api` 时会在 Development 自动启动前端,访问 <http://localhost:5173>
|
||||
- Scalar:<http://localhost:5090/scalar/v1>
|
||||
- OpenAPI:<http://localhost:5090/openapi/v1.json>
|
||||
- Liveness:<http://localhost:5090/api/health>
|
||||
|
||||
@@ -27,8 +27,6 @@ public static class ApplicationBuilderExtensions
|
||||
app.UseForwardedHeaders();
|
||||
app.UseHttpsRedirection();
|
||||
app.UseResponseCompression();
|
||||
app.UseDefaultFiles();
|
||||
app.UseStaticFiles();
|
||||
app.UseRouting();
|
||||
app.UseCors(CorsOptions.PolicyName);
|
||||
app.UseMiddleware<TenantResolutionMiddleware>();
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5090",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"ASPNETCORE_HOSTINGSTARTUPASSEMBLIES": "Microsoft.AspNetCore.SpaProxy"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
@@ -16,7 +17,8 @@
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "https://localhost:7093;http://localhost:5090",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"ASPNETCORE_HOSTINGSTARTUPASSEMBLIES": "Microsoft.AspNetCore.SpaProxy"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);1591</NoWarn>
|
||||
<SpaRoot>..\Tiku.PlatformAdmin.Web\</SpaRoot>
|
||||
<SpaProxyServerUrl>http://localhost:5173</SpaProxyServerUrl>
|
||||
<SpaProxyLaunchCommand>npm run dev</SpaProxyLaunchCommand>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -16,6 +19,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SpaProxy" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OutputCaching.StackExchangeRedis" />
|
||||
<PackageReference Include="Microsoft.OpenApi" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
|
||||
|
||||
1
Tiku.Api/wwwroot/.gitkeep
Normal file
1
Tiku.Api/wwwroot/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,35 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#111827" />
|
||||
<title>恭学题库 SaaS · 平台管理</title>
|
||||
<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=20260730-question-bank1" defer></script>
|
||||
<script src="./platform-auth.js?v=20260730-api-fix1" defer></script>
|
||||
<script src="./spec-contract.js?v=20260730-question-bank1" defer></script>
|
||||
<script src="./platform-api.js?v=20260730-api-fix1" defer></script>
|
||||
<script src="./app.js?v=20260730-api-fix1" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="platformAuthGate" class="platform-auth-gate" hidden></div>
|
||||
<div class="platform-app">
|
||||
<aside class="platform-sidebar">
|
||||
<button class="platform-brand" data-nav="overview"><img src="./assets/logo.png" alt="" /><span><strong>恭学 SaaS</strong><small>平台管理控制台</small></span></button>
|
||||
<div class="environment"><i></i><span>生产环境</span><b>CN</b></div>
|
||||
<nav id="sideNav"></nav>
|
||||
<div class="platform-foot"><button data-nav="staff"><i data-lucide="shield-check"></i><span>平台权限</span></button><div><span>陈</span><p><strong>陈浩</strong><small>超级管理员</small></p><button data-action="logout" aria-label="退出平台端"><i data-lucide="log-out"></i></button></div></div>
|
||||
</aside>
|
||||
<section class="platform-workspace">
|
||||
<header class="platform-topbar"><button class="icon-button mobile-menu" id="menuButton"><i data-lucide="menu"></i></button><div class="page-context"><small>PLATFORM / PROD</small><strong id="pageTitle">平台经营工作台</strong></div><label class="platform-search"><i data-lucide="search"></i><input id="globalSearch" placeholder="搜索租户、账单、告警或审计事件" /><kbd>⌘ K</kbd></label><div class="prototype-controls"><label title="切换角色以验证菜单和动作权限"><span>角色</span><select id="roleSelect"></select></label><label title="演练加载、空、错误和 403 页面状态"><span>页面状态</span><select id="scenarioSelect"></select></label><label title="控制下一次 Mock 写请求结果"><span>写请求</span><select id="responseSelect"></select></label></div><div class="top-status"><span><i></i>所有系统正常</span><button class="icon-button" data-action="open-task-center" aria-label="任务中心" title="任务中心"><i data-lucide="list-checks"></i><b id="taskCount">0</b></button><button class="icon-button" data-nav="alerts" aria-label="告警与通知" title="告警与通知"><i data-lucide="bell-ring"></i><b id="alertCount">0</b></button></div></header>
|
||||
<main id="mainView"></main>
|
||||
</section>
|
||||
</div>
|
||||
<button class="sidebar-backdrop" id="sidebarBackdrop" aria-label="关闭导航菜单"></button>
|
||||
<div id="overlayRoot"></div>
|
||||
<div class="toast"><i data-lucide="circle-check"></i><span></span></div>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,141 +0,0 @@
|
||||
(() => {
|
||||
const ACCESS_TOKEN_KEY = 'tiku_platform_access_token';
|
||||
const REFRESH_TOKEN_KEY = 'tiku_platform_refresh_token';
|
||||
const ACCESS_EXPIRES_KEY = 'tiku_platform_access_expires_at';
|
||||
const USER_KEY = 'tiku_platform_user';
|
||||
const runtime = window.GONGXUE_PLATFORM_RUNTIME_CONFIG || {};
|
||||
let challengeToken = '';
|
||||
let pendingResolve;
|
||||
|
||||
function apiUrl(path) {
|
||||
return `${String(runtime.apiBaseUrl || '').replace(/\/+$/, '')}${path}`;
|
||||
}
|
||||
|
||||
async function post(path, body) {
|
||||
const response = await fetch(apiUrl(path), {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
credentials: 'omit',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = {};
|
||||
if (text) {
|
||||
try { payload = JSON.parse(text); } catch { payload = { detail: text.slice(0, 500) }; }
|
||||
}
|
||||
if (!response.ok) throw new Error(payload.detail || payload.title || payload.message || `请求失败(HTTP ${response.status})`);
|
||||
return payload;
|
||||
}
|
||||
|
||||
function gate() { return document.querySelector('#platformAuthGate'); }
|
||||
|
||||
function shell(title, description, content) {
|
||||
gate().innerHTML = `<section class="platform-auth-card" role="dialog" aria-modal="true"><header><img src="./assets/logo.png" alt="" /><div><h1>${title}</h1><p>${description}</p></div></header>${content}<p class="platform-auth-error" id="platformAuthError" role="alert"></p></section>`;
|
||||
gate().hidden = false;
|
||||
document.querySelector('.platform-app')?.setAttribute('inert', '');
|
||||
}
|
||||
|
||||
function showError(error) {
|
||||
const target = document.querySelector('#platformAuthError');
|
||||
if (target) target.textContent = error?.message || '操作失败,请重试';
|
||||
}
|
||||
|
||||
function setBusy(form, busy) {
|
||||
form.querySelectorAll('button,input').forEach(control => { control.disabled = busy; });
|
||||
}
|
||||
|
||||
function storeAuthenticated(result) {
|
||||
const user = result?.user;
|
||||
const tokens = user?.tokens;
|
||||
if (!tokens?.accessToken || !tokens?.refreshToken) throw new Error('登录响应缺少令牌');
|
||||
sessionStorage.setItem(ACCESS_TOKEN_KEY, tokens.accessToken);
|
||||
sessionStorage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken);
|
||||
sessionStorage.setItem(ACCESS_EXPIRES_KEY, tokens.accessTokenExpiresAt || '');
|
||||
sessionStorage.setItem(USER_KEY, JSON.stringify({ userId: user.userId, email: user.email, name: user.name }));
|
||||
}
|
||||
|
||||
function completeGate() {
|
||||
gate().hidden = true;
|
||||
gate().innerHTML = '';
|
||||
document.querySelector('.platform-app')?.removeAttribute('inert');
|
||||
pendingResolve?.(true);
|
||||
pendingResolve = null;
|
||||
}
|
||||
|
||||
function finishAuthentication(result) {
|
||||
storeAuthenticated(result);
|
||||
completeGate();
|
||||
}
|
||||
|
||||
async function handleAuthenticationResult(result) {
|
||||
challengeToken = result.challengeToken || '';
|
||||
if (result.status === 'authenticated') { finishAuthentication(result); return; }
|
||||
if (result.status === 'password_change_required') { renderPasswordChange(); return; }
|
||||
throw new Error(`不支持的认证状态:${result.status || 'unknown'}`);
|
||||
}
|
||||
|
||||
function renderLogin() {
|
||||
shell('平台管理员登录', '连接真实 PostgreSQL 与 ASP.NET Core API。', `<form id="platformLoginForm"><label>平台账号<input name="identifier" type="email" autocomplete="username" required /></label><label>密码<input name="password" type="password" autocomplete="current-password" minlength="8" required /></label><button type="submit">登录</button></form>`);
|
||||
document.querySelector('#platformLoginForm').addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
setBusy(form, true);
|
||||
try {
|
||||
const result = await post('/api/auth/login/password', { realm: 'platform', identifier: form.elements.identifier.value.trim(), password: form.elements.password.value });
|
||||
await handleAuthenticationResult(result);
|
||||
} catch (error) { showError(error); setBusy(form, false); }
|
||||
});
|
||||
}
|
||||
|
||||
function renderPasswordChange() {
|
||||
shell('设置正式密码', '至少 8 位,且必须同时包含字母和数字。', `<form id="platformPasswordForm"><label>新密码<input name="newPassword" type="password" autocomplete="new-password" minlength="8" required /></label><label>确认新密码<input name="confirmPassword" type="password" autocomplete="new-password" minlength="8" required /></label><button type="submit">更新密码并继续</button></form>`);
|
||||
document.querySelector('#platformPasswordForm').addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
if (form.elements.newPassword.value !== form.elements.confirmPassword.value) { showError(new Error('两次输入的密码不一致')); return; }
|
||||
setBusy(form, true);
|
||||
try { await handleAuthenticationResult(await post('/api/auth/password/change-required', { challengeToken, newPassword: form.elements.newPassword.value })); }
|
||||
catch (error) { showError(error); setBusy(form, false); }
|
||||
});
|
||||
}
|
||||
|
||||
function clearSession() {
|
||||
[ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY, ACCESS_EXPIRES_KEY, USER_KEY].forEach(key => sessionStorage.removeItem(key));
|
||||
}
|
||||
|
||||
function hasSession() {
|
||||
const token = sessionStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
const expiresAt = Date.parse(sessionStorage.getItem(ACCESS_EXPIRES_KEY) || '');
|
||||
return Boolean(token) && Number.isFinite(expiresAt) && expiresAt > Date.now() + 15_000;
|
||||
}
|
||||
|
||||
async function requireSession() {
|
||||
if (hasSession()) return true;
|
||||
clearSession();
|
||||
renderLogin();
|
||||
return new Promise(resolve => { pendingResolve = resolve; });
|
||||
}
|
||||
|
||||
function requireReauthentication() {
|
||||
clearSession();
|
||||
renderLogin();
|
||||
pendingResolve = () => location.reload();
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
const refreshToken = sessionStorage.getItem(REFRESH_TOKEN_KEY);
|
||||
try { if (refreshToken) await post('/api/auth/logout', { refreshToken }); } catch { /* Clear the browser session even when revocation cannot be reached. */ }
|
||||
clearSession();
|
||||
location.reload();
|
||||
}
|
||||
|
||||
window.GongxuePlatformAuth = Object.freeze({
|
||||
getAccessToken: () => sessionStorage.getItem(ACCESS_TOKEN_KEY) || '',
|
||||
getUser: () => {
|
||||
try { return JSON.parse(sessionStorage.getItem(USER_KEY) || 'null'); } catch { return null; }
|
||||
},
|
||||
logout,
|
||||
requireReauthentication,
|
||||
requireSession,
|
||||
});
|
||||
})();
|
||||
@@ -1,24 +0,0 @@
|
||||
/*
|
||||
* 平台端公开运行时配置。
|
||||
*
|
||||
* 默认连接同源后端。其他部署环境可在本文件之前注入同名对象,
|
||||
* 或在部署时替换本文件。不要把 access token、service role key 或任何服务端密钥
|
||||
* 写入静态文件;getAccessToken 应从当前平台登录会话中按需读取短期 JWT。
|
||||
*
|
||||
* window.GONGXUE_PLATFORM_RUNTIME_CONFIG = {
|
||||
* mode: 'api',
|
||||
* apiBaseUrl: 'https://api.example.com',
|
||||
* fallbackToMock: true,
|
||||
* timeoutMs: 10000,
|
||||
* getAccessToken: async () => {
|
||||
* return sessionStorage.getItem('tiku_platform_access_token') || '';
|
||||
* },
|
||||
* };
|
||||
*/
|
||||
window.GONGXUE_PLATFORM_RUNTIME_CONFIG = window.GONGXUE_PLATFORM_RUNTIME_CONFIG || {
|
||||
mode: 'api',
|
||||
apiBaseUrl: '',
|
||||
fallbackToMock: false,
|
||||
timeoutMs: 10000,
|
||||
getAccessToken: async () => window.GongxuePlatformAuth?.getAccessToken() || '',
|
||||
};
|
||||
@@ -1,313 +0,0 @@
|
||||
(() => {
|
||||
const actions = [
|
||||
['P-01', 'act.p-01.01', '进入租户/账务/题库/审计', 'command', 'native', 'nav', '[data-nav="tenants"],[data-nav="billing"],[data-nav="bank"],[data-nav="audit"]', 'PlatformAdminOverviewController_overview', 'allow'],
|
||||
['P-01', 'act.p-01.02', '确认/解决告警', 'command', 'native', 'mutation', '[data-alert-detail],[data-action="transition-alert"]', 'PlatformAdminAuditController_alerts', 'allow'],
|
||||
['P-01', 'act.p-01.03', '导出审计', 'async', 'native', 'task', '[data-action="export-audit"]', 'PlatformAdminAuditController_logs', 'allow'],
|
||||
['P-01', 'act.p-01.04', '刷新', 'command', 'native', 'read', '[data-action="refresh"]', 'PlatformAdminOverviewController_overview', 'allow'],
|
||||
['P-02', 'act.p-02.01', '搜索', 'command', 'native', 'read', '#tenantSearch,[data-action="apply-tenant-query"]', 'PlatformAdminTenantsController_list', 'allow'],
|
||||
['P-02', 'act.p-02.02', '创建租户', 'command', 'native', 'mutation', '[data-action="create-tenant"],[form="createTenantForm"]', 'PlatformAdminTenantsController_create', 'block'],
|
||||
['P-02', 'act.p-02.03', '打开详情', 'command', 'native', 'nav', '[data-tenant-detail]', 'PlatformAdminTenantsController_list', 'allow'],
|
||||
['P-02', 'act.p-02.04', '准备暂停/恢复', 'destructive', 'native', 'mutation', '[data-action="prepare-tenant-status"],[form="tenantStatusForm"]', 'PlatformAdminTenantsController_status', 'block'],
|
||||
['P-02', 'act.p-02.05', '去账务', 'command', 'native', 'nav', '[data-action="tenant-to-billing"]', 'PlatformAdminBillingController_invoices', 'allow'],
|
||||
['P-03', 'act.p-03.01', '修改状态', 'command', 'native', 'mutation', '[data-action="prepare-tenant-status"],[form="tenantStatusForm"]', 'PlatformAdminTenantsController_status', 'block'],
|
||||
['P-03', 'act.p-03.02', '编辑账务资料', 'command', 'native', 'nav', '[data-action="edit-billing-profile"]', 'PlatformAdminTenantsController_billingProfile', 'block'],
|
||||
['P-03', 'act.p-03.03', '创建订阅', 'command', 'native', 'mutation', '[data-action="create-subscription-for-tenant"],[form="subscriptionForm"]', 'PlatformAdminBillingController_createSubscription', 'block'],
|
||||
['P-03', 'act.p-03.04', '查看账单/用量', 'command', 'native', 'nav', '[data-action="tenant-to-billing"],[data-action="tenant-to-usage"]', 'PlatformAdminTenantsController_detail', 'allow'],
|
||||
['P-03', 'act.p-03.05', '打开租户门户入口', 'command', 'native', 'nav', '[data-action="open-tenant-portal"]', 'PlatformAdminTenantsController_detail', 'allow'],
|
||||
['P-04', 'act.p-04.01', '编辑', 'command', 'native', 'mutation', '#billingProfileForm', 'PlatformAdminTenantsController_billingProfile', 'block'],
|
||||
['P-04', 'act.p-04.02', '保存', 'command', 'native', 'mutation', '[form="billingProfileForm"]', 'PlatformAdminTenantsController_billingProfile', 'block'],
|
||||
['P-04', 'act.p-04.03', '查看关联账单', 'command', 'native', 'nav', '[data-action="billing-profile-to-invoices"]', 'PlatformAdminTenantsController_billingProfile', 'allow'],
|
||||
['P-05', 'act.p-05.01', '选择套餐', 'command', 'native', 'mutation', '[data-action="select-plan"]', 'PlatformAdminOverviewController_plans', 'block'],
|
||||
['P-05', 'act.p-05.02', '创建订阅', 'command', 'native', 'mutation', '[data-action="create-subscription"],[form="subscriptionForm"]', 'PlatformAdminBillingController_createSubscription', 'block'],
|
||||
['P-05', 'act.p-05.03', '进入账单候选', 'command', 'native', 'nav', '[data-action="subscription-to-billing"]', 'PlatformAdminBillingController_createSubscription', 'allow'],
|
||||
['P-05', 'act.p-05.04', '查看租户详情', 'command', 'native', 'nav', '[data-tenant-detail]', 'PlatformAdminBillingController_createSubscription', 'allow'],
|
||||
['P-06', 'act.p-06.01', '查询', 'command', 'contract', 'read', '[data-action="apply-invoice-query"]', 'PlatformAdminBillingController_invoices', 'allow'],
|
||||
['P-06', 'act.p-06.02', '手工创建', 'command', 'native', 'mutation', '[data-action="create-invoice"],[form="invoiceForm"]', 'PlatformAdminBillingController_createInvoice', 'block'],
|
||||
['P-06', 'act.p-06.03', '预览候选', 'command', 'native', 'read', '[data-action="preview-invoice-candidates"]', 'PlatformAdminBillingController_subscriptionCandidates', 'block'],
|
||||
['P-06', 'act.p-06.04', '单个生成', 'async', 'contract', 'task', '[data-action="generate-subscription-invoice"]', 'PlatformAdminBillingController_fromSubscription', 'block'],
|
||||
['P-06', 'act.p-06.05', '批量 dry-run', 'command', 'native', 'read', '[data-action="run-invoice-dry-run"]', 'PlatformAdminBillingController_fromSubscriptionsBatch', 'block'],
|
||||
['P-06', 'act.p-06.06', '确认生成', 'async', 'native', 'task', '[data-action="confirm-generate-candidates"]', 'PlatformAdminBillingController_fromSubscriptionsBatch', 'block'],
|
||||
['P-06', 'act.p-06.07', '打开租户', 'command', 'native', 'nav', '[data-tenant-detail]', 'PlatformAdminBillingController_invoices', 'allow'],
|
||||
['P-07', 'act.p-07.01', '记录用量', 'command', 'native', 'mutation', '[data-action="record-usage"],[form="usageForm"]', 'PlatformAdminBillingController_recordUsage', 'block'],
|
||||
['P-07', 'act.p-07.02', '查询', 'command', 'native', 'read', '#usagePeriodFilter', 'PlatformAdminBillingController_usage', 'allow'],
|
||||
['P-07', 'act.p-07.03', '预览超额候选', 'command', 'native', 'read', '[data-action="preview-overage"]', 'PlatformAdminBillingController_usageCandidates', 'block'],
|
||||
['P-07', 'act.p-07.04', 'dry-run', 'command', 'native', 'read', '[data-action="run-overage-dry-run"]', 'PlatformAdminBillingController_fromUsage', 'block'],
|
||||
['P-07', 'act.p-07.05', '生成超额账单', 'async', 'native', 'task', '[data-action="confirm-overage-invoices"]', 'PlatformAdminBillingController_fromUsage', 'block'],
|
||||
['P-07', 'act.p-07.06', '跳租户详情', 'command', 'native', 'nav', '[data-tenant-detail]', 'PlatformAdminBillingController_usage', 'allow'],
|
||||
['P-08', 'act.p-08.01', '确认收款', 'command', 'native', 'mutation', '[data-action="confirm-payment"],[form="paymentForm"]', 'PlatformAdminBillingController_confirmPayment', 'block'],
|
||||
['P-08', 'act.p-08.02', '预览逾期', 'command', 'native', 'read', '[data-action="preview-overdue"]', 'PlatformAdminBillingController_processOverdue', 'block'],
|
||||
['P-08', 'act.p-08.03', '生成催缴', 'async', 'native', 'task', '[data-action="confirm-overdue-process"],[data-action="generate-reminder"]', 'PlatformAdminBillingController_processOverdue', 'block'],
|
||||
['P-08', 'act.p-08.04', '查询提醒', 'command', 'native', 'read', '[data-billing-tab="reminders"]', 'PlatformAdminBillingController_reminders', 'allow'],
|
||||
['P-08', 'act.p-08.05', '新增/编辑/启停渠道', 'command', 'native', 'mutation', '[data-action="new-dunning-channel"],[data-action="edit-dunning-channel"],[data-action="toggle-dunning-channel"]', 'PlatformAdminDunningChannelsController_upsertChannel', 'block'],
|
||||
['P-08', 'act.p-08.06', '查看通知事件', 'command', 'contract', 'read', '[data-notification-detail]', 'PlatformAdminDunningEventsController_events', 'allow'],
|
||||
['P-09', 'act.p-09.01', '查询与选择题库', 'command', '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'],
|
||||
['P-10', 'act.p-10.04', '重试线索推送', 'command', 'native', 'mutation', '[data-action="retry-crm-lead"]', 'PlatformAdminCrmController_retryLead', 'block'],
|
||||
['P-11', 'act.p-11.01', '切换短信视图', 'command', 'native', 'read', '[data-sms-tab]', 'PlatformAdminSmsController_channels', 'allow'],
|
||||
['P-11', 'act.p-11.02', '新建/编辑短信渠道', 'command', 'native', 'mutation', '[data-action="new-sms-channel"],[data-action="edit-sms-channel"]', 'PlatformAdminSmsController_upsertChannel', 'block'],
|
||||
['P-11', 'act.p-11.03', '启停短信渠道', 'destructive', 'native', 'mutation', '[data-action="toggle-sms-channel"]', 'PlatformAdminSmsController_upsertChannel', 'block'],
|
||||
['P-12', 'act.p-12.01', '切换支付设置视图', 'command', 'native', 'read', '[data-payment-tab]', 'PlatformAdminPaymentSettingsController_apps', 'allow'],
|
||||
['P-12', 'act.p-12.02', '新建/编辑支付应用', 'command', 'native', 'mutation', '[data-action="new-payment-app"],[data-action="edit-payment-app"]', 'PlatformAdminPaymentSettingsController_upsertApp', 'block'],
|
||||
['P-12', 'act.p-12.03', '启停支付应用', 'destructive', 'native', 'mutation', '[data-action="toggle-payment-app"]', 'PlatformAdminPaymentSettingsController_upsertApp', 'block'],
|
||||
['P-12', 'act.p-12.04', '查看风控规则', 'command', 'native', 'read', '[data-action="payment-rule-detail"]', 'PlatformAdminPaymentSettingsController_events', 'allow'],
|
||||
['P-13', 'act.p-13.01', '新建/编辑', 'command', 'native', 'mutation', '[data-action="new-staff"],[data-action="edit-staff"]', 'PlatformAdminOverviewController_upsertStaff', 'block'],
|
||||
['P-13', 'act.p-13.02', '全选/分组授权', 'command', 'native', 'mutation', '[data-action="select-permission-group"]', 'PlatformAdminOverviewController_permissions', 'block'],
|
||||
['P-13', 'act.p-13.03', '启用/禁用', 'destructive', 'native', 'mutation', '[data-action="toggle-staff"]', 'PlatformAdminOverviewController_updateStaffStatus', 'block'],
|
||||
['P-13', 'act.p-13.04', '禁用时撤销会话', 'destructive', 'native', 'audit', '[data-action="execute-staff-disable"]', 'PlatformAdminOverviewController_updateStaffStatus', 'block'],
|
||||
['P-13', 'act.p-13.05', '清空表单', 'command', 'contract', 'mutation', '[data-action="clear-staff-form"]', 'PlatformAdminOverviewController_upsertStaff', 'block'],
|
||||
['P-14', 'act.p-14.01', '筛选', 'command', 'native', 'read', '#auditSearch,#auditRangeFilter,[data-audit-severity]', 'PlatformAdminAuditController_logs', 'allow'],
|
||||
['P-14', 'act.p-14.02', '查看详情', 'command', 'native', 'read', '[data-audit-detail]', 'PlatformAdminAuditController_logs', 'allow'],
|
||||
['P-14', 'act.p-14.03', '导出 CSV/JSON', 'async', 'native', 'task', '[data-action="export-audit"]', 'PlatformAdminAuditController_exportLogs', 'allow'],
|
||||
['P-14', 'act.p-14.04', '复制 ID', 'command', 'native', 'read', '[data-copy]', 'PlatformAdminAuditController_logs', 'allow'],
|
||||
['P-14', 'act.p-14.05', '跳目标对象', 'command', 'native', 'nav', '[data-action="jump-audit-target"],[data-action="jump-related-alert"]', 'PlatformAdminAuditController_logs', 'allow'],
|
||||
['P-15', 'act.p-15.01', '确认/解决告警', 'command', 'native', 'mutation', '[data-action="transition-alert"]', 'PlatformAdminAuditController_updateAlert', 'allow'],
|
||||
['P-15', 'act.p-15.02', '查看证据', 'command', 'native', 'read', '[data-alert-detail]', 'PlatformAdminAuditController_alerts', 'allow'],
|
||||
['P-15', 'act.p-15.03', '新建/编辑/启停通知渠道', 'command', 'native', 'mutation', '[data-action="new-audit-channel"],[data-action="edit-audit-channel"],[data-action="toggle-audit-channel"]', 'PlatformAdminAuditController_upsertChannel', 'block'],
|
||||
['P-15', 'act.p-15.04', '查看失败事件', 'command', 'contract', 'read', '[data-notification-detail]', 'PlatformAdminAuditController_events', 'allow'],
|
||||
].map(([pageId, id, label, kind, mode, effect, selector, operationId, mobile]) => ({ pageId, id, label, kind, mode, effect, selector, operationIds: [operationId], mobile, destructive: kind === 'destructive', implemented: true }));
|
||||
|
||||
const operationRows = [
|
||||
['P-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'],
|
||||
['P-06', 'PlatformAdminBillingController_createInvoice', 'POST', '/api/platform-admin/saas/invoices'],
|
||||
['P-06', 'PlatformAdminBillingController_subscriptionCandidates', 'GET', '/api/platform-admin/saas/invoices/subscription-candidates'],
|
||||
['P-06', 'PlatformAdminBillingController_fromSubscription', 'POST', '/api/platform-admin/saas/invoices/from-subscription'],
|
||||
['P-06', 'PlatformAdminBillingController_fromSubscriptionsBatch', 'POST', '/api/platform-admin/saas/invoices/from-subscriptions-batch'],
|
||||
['P-07', 'PlatformAdminBillingController_usage', 'GET', '/api/platform-admin/saas/usage'],
|
||||
['P-07', 'PlatformAdminBillingController_recordUsage', 'POST', '/api/platform-admin/saas/usage'],
|
||||
['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-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', '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'],
|
||||
['P-13', 'PlatformAdminOverviewController_permissions', 'GET', '/api/platform-admin/permissions'],
|
||||
['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'],
|
||||
['P-15', 'PlatformAdminAuditController_alerts', 'GET', '/api/platform-admin/audit-alerts'],
|
||||
['P-15', 'PlatformAdminAuditController_updateAlert', 'POST', '/api/platform-admin/audit-alerts/status'],
|
||||
['P-15', 'PlatformAdminAuditController_channels', 'GET', '/api/platform-admin/audit-notification-channels'],
|
||||
['P-15', 'PlatformAdminAuditController_upsertChannel', 'PUT', '/api/platform-admin/audit-notification-channels'],
|
||||
['P-15', 'PlatformAdminAuditController_events', 'GET', '/api/platform-admin/audit-notification-events'],
|
||||
];
|
||||
const operations = operationRows.map(([pageId, operationId, method, path]) => ({ pageId, operationId, method, path }));
|
||||
const operationById = Object.fromEntries(operations.map(operation => [operation.operationId, operation]));
|
||||
|
||||
const navEdges = [
|
||||
['nav.p-01.p-02', 'P-01', 'P-02', '[data-nav="tenants"]'],
|
||||
['nav.p-01.p-06', 'P-01', 'P-06', '[data-nav="billing"]'],
|
||||
['nav.p-01.p-10', 'P-01', 'P-10', '[data-nav="crm"]'],
|
||||
['nav.p-01.p-11', 'P-01', 'P-11', '[data-nav="sms"]'],
|
||||
['nav.p-01.p-12', 'P-01', 'P-12', '[data-nav="paymentSettings"]'],
|
||||
['nav.p-01.p-15', 'P-01', 'P-15', '[data-nav="alerts"],[data-alert-detail]'],
|
||||
['nav.p-02.p-03', 'P-02', 'P-03', '[data-tenant-detail]'],
|
||||
['nav.p-03.p-04', 'P-03', 'P-04', '[data-action="edit-billing-profile"]'],
|
||||
['nav.p-03.p-05', 'P-03', 'P-05', '[data-action="create-subscription-for-tenant"]'],
|
||||
['nav.p-03.p-06', 'P-03', 'P-06', '[data-action="tenant-to-billing"]'],
|
||||
['nav.p-05.p-06', 'P-05', 'P-06', '[data-action="subscription-to-billing"]'],
|
||||
['nav.p-05.p-07', 'P-05', 'P-07', '[data-action="subscriptions-to-usage"]'],
|
||||
['nav.p-06.p-08', 'P-06', 'P-08', '[data-nav="dunning"]'],
|
||||
['nav.p-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 = [];
|
||||
|
||||
const trace = [];
|
||||
const decoratedActionIds = new Set();
|
||||
const selectorCollisions = new Set();
|
||||
const explicitResultActionIds = new Set(['act.p-06.01', 'act.p-06.04', 'act.p-09.01', 'act.p-09.05']);
|
||||
function pushTrace(entry) {
|
||||
trace.push({ at: new Date().toISOString(), ...entry });
|
||||
if (trace.length > 120) trace.splice(0, trace.length - 120);
|
||||
document.documentElement.dataset.platformContractEvents = String(trace.length);
|
||||
}
|
||||
|
||||
function decorate(root = document, pageId = '') {
|
||||
const shell = root.querySelector?.('.page-shell');
|
||||
if (shell && pageId) {
|
||||
const pageOperations = operations.filter(operation => operation.pageId === pageId);
|
||||
shell.dataset.specPageId = pageId;
|
||||
shell.dataset.operationIds = pageOperations.map(operation => operation.operationId).join(' ');
|
||||
shell.dataset.operationContracts = pageOperations.map(operation => `${operation.method} ${operation.path}`).join(' | ');
|
||||
}
|
||||
actions.filter(action => action.pageId === pageId).forEach(action => {
|
||||
root.querySelectorAll?.(action.selector).forEach(element => {
|
||||
const existingActionId = element.dataset.specActionId;
|
||||
if (existingActionId && existingActionId !== action.id) selectorCollisions.add(`${existingActionId} -> ${action.id}`);
|
||||
element.dataset.specActionId = action.id;
|
||||
element.dataset.specActionMode = action.mode;
|
||||
element.dataset.specEffect = action.effect;
|
||||
element.dataset.mobileBoundary = action.mobile;
|
||||
const operation = operationById[action.operationIds[0]];
|
||||
if (operation) {
|
||||
element.dataset.operationId = operation.operationId;
|
||||
element.dataset.operationMethod = operation.method;
|
||||
element.dataset.operationPath = operation.path;
|
||||
}
|
||||
decoratedActionIds.add(action.id);
|
||||
});
|
||||
});
|
||||
supportingReads.filter(read => read.pageId === pageId).forEach(read => {
|
||||
const operation = operationById[read.operationId];
|
||||
root.querySelectorAll?.(read.selector).forEach(element => {
|
||||
element.dataset.specSupportingRead = 'true';
|
||||
element.dataset.specEffect = 'read';
|
||||
if (operation) {
|
||||
element.dataset.operationId = operation.operationId;
|
||||
element.dataset.operationMethod = operation.method;
|
||||
element.dataset.operationPath = operation.path;
|
||||
}
|
||||
});
|
||||
});
|
||||
navEdges.filter(edge => edge.fromPageId === pageId).forEach(edge => {
|
||||
root.querySelectorAll?.(edge.selector).forEach(element => {
|
||||
element.dataset.navEdgeId = edge.id;
|
||||
element.dataset.navTargetPage = edge.toPageId;
|
||||
});
|
||||
});
|
||||
window.__PLATFORM_SPEC_COVERAGE__ = coverage();
|
||||
}
|
||||
|
||||
function eventContract(target) {
|
||||
const element = target?.closest?.('[data-spec-action-id]');
|
||||
if (!element) return null;
|
||||
return { element, actionId: element.dataset.specActionId, operationId: element.dataset.operationId || '', requestId: `req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}` };
|
||||
}
|
||||
|
||||
document.addEventListener('click', event => {
|
||||
const contract = eventContract(event.target);
|
||||
if (!contract) return;
|
||||
contract.element.dataset.contractRequestId = contract.requestId;
|
||||
pushTrace({ phase: 'before', actionId: contract.actionId, operationId: contract.operationId, requestId: contract.requestId });
|
||||
setTimeout(() => pushTrace({ phase: 'after', actionId: contract.actionId, operationId: contract.operationId, requestId: contract.requestId, result: 'handled', pageId: document.querySelector('.page-shell')?.dataset.specPageId || '' }), 0);
|
||||
}, true);
|
||||
|
||||
document.addEventListener('change', event => {
|
||||
const contract = eventContract(event.target);
|
||||
if (!contract) return;
|
||||
pushTrace({ phase: 'after', actionId: contract.actionId, operationId: contract.operationId, requestId: contract.requestId, result: 'changed' });
|
||||
}, true);
|
||||
|
||||
const coverage = () => ({
|
||||
expected: 70,
|
||||
total: actions.length,
|
||||
unique: new Set(actions.map(action => action.id)).size,
|
||||
catalogued: actions.filter(action => action.label && action.kind).length,
|
||||
selectorDeclared: actions.filter(action => action.selector).length,
|
||||
nativeMetadata: actions.filter(action => action.mode === 'native').length,
|
||||
contractMetadata: actions.filter(action => action.mode === 'contract').length,
|
||||
nativeMapped: actions.filter(action => action.mode === 'native' && decoratedActionIds.has(action.id)).length,
|
||||
dedicatedExecutor: explicitResultActionIds.size,
|
||||
dedicatedExecutorDefinition: 'actions with explicit success/failure result recording',
|
||||
reachable: actions.filter(action => action.implemented && action.selector).length,
|
||||
mountedThisSession: decoratedActionIds.size,
|
||||
decoratedInSession: decoratedActionIds.size,
|
||||
notYetDecorated: actions.filter(action => !decoratedActionIds.has(action.id)).map(action => action.id),
|
||||
implemented: actions.filter(action => action.implemented && action.selector).length,
|
||||
unimplemented: actions.filter(action => !action.implemented || !action.selector).map(action => action.id),
|
||||
selectorCollisions: Array.from(selectorCollisions),
|
||||
apiExact: operations.filter(operation => operation.operationId && operation.method && operation.path).length,
|
||||
uiOnlyExplained: actions.filter(action => action.operationIds.length === 0).length,
|
||||
effectDeclared: actions.filter(action => action.effect).length,
|
||||
mobileDeclared: actions.filter(action => action.mobile).length,
|
||||
actuallyExecuted: new Set(trace.filter(entry => entry.phase === 'result' && entry.result === 'success').map(entry => entry.actionId)).size,
|
||||
executionNote: 'catalogue and selector metadata are not counted as successful business execution',
|
||||
navigation: { expected: 14, total: navEdges.length, unique: new Set(navEdges.map(edge => edge.id)).size },
|
||||
pageBindings: { expected: 63, total: operations.length, unique: new Set(operations.map(operation => `${operation.pageId}:${operation.operationId}`)).size },
|
||||
});
|
||||
const assertions = {
|
||||
pages: new Set(actions.map(action => action.pageId)).size === 15,
|
||||
actionIds: actions.every(action => action.id.startsWith(`act.${action.pageId.toLowerCase()}.`)),
|
||||
duplicateActions: new Set(actions.map(action => action.id)).size === actions.length,
|
||||
operationContracts: operations.every(operation => /^(GET|POST|PUT|PATCH|DELETE)$/.test(operation.method) && operation.path.startsWith('/api/')),
|
||||
navTargets: navEdges.every(edge => actions.some(action => action.pageId === edge.fromPageId) && actions.some(action => action.pageId === edge.toPageId)),
|
||||
};
|
||||
window.GONGXUE_PLATFORM_SPEC = { actions, operations, navEdges, supportingReads };
|
||||
window.__GONGXUE_ACTION_CONTRACT__ = { coverage, assertions, trace };
|
||||
window.decoratePlatformSpec = decorate;
|
||||
window.recordPlatformSpecResult = (actionId, result = {}) => pushTrace({ phase: 'result', actionId, ...result });
|
||||
window.__PLATFORM_CONTRACT_EVENTS__ = trace;
|
||||
window.__PLATFORM_SPEC_COVERAGE__ = coverage();
|
||||
})();
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,23 @@
|
||||
using System.Net;
|
||||
|
||||
namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class PlatformAdminFrontendSeparationTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("/platform-admin")]
|
||||
[InlineData("/platform-admin/")]
|
||||
[InlineData("/platform-admin/app.js")]
|
||||
public async Task WebApi_does_not_host_platform_admin_frontend(string path)
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
using var client = factory.CreateClient(new()
|
||||
{
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
|
||||
using var response = await client.GetAsync(path);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
using System.Net;
|
||||
|
||||
namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class PlatformAdminStaticEndpointTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Platform_admin_static_shell_is_served_without_api_authentication()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/platform-admin/");
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Contains("恭学题库 SaaS · 平台管理", body, StringComparison.Ordinal);
|
||||
Assert.Contains("./platform-api.js", body, StringComparison.Ordinal);
|
||||
Assert.Contains("./platform-auth.js", body, StringComparison.Ordinal);
|
||||
Assert.Contains("platformAuthGate", body, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Platform_admin_short_path_redirects_to_static_shell_directory()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
using var client = factory.CreateClient(new()
|
||||
{
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
|
||||
var response = await client.GetAsync("/platform-admin");
|
||||
|
||||
Assert.Equal(HttpStatusCode.MovedPermanently, response.StatusCode);
|
||||
Assert.Equal("/platform-admin/", response.Headers.Location?.AbsolutePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Platform_admin_runtime_uses_real_api_without_mock_fallback()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var runtime = await client.GetStringAsync("/platform-admin/runtime-config.js");
|
||||
var authentication = await client.GetStringAsync("/platform-admin/platform-auth.js");
|
||||
|
||||
Assert.Contains("mode: 'api'", runtime, StringComparison.Ordinal);
|
||||
Assert.Contains("fallbackToMock: false", runtime, StringComparison.Ordinal);
|
||||
Assert.Contains("/api/auth/login/password", authentication, StringComparison.Ordinal);
|
||||
Assert.Contains("/api/auth/password/change-required", authentication, StringComparison.Ordinal);
|
||||
Assert.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);
|
||||
}
|
||||
}
|
||||
2
Tiku.PlatformAdmin.Web/.env.example
Normal file
2
Tiku.PlatformAdmin.Web/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_API_BASE_URL=http://localhost:5090
|
||||
OPENAPI_URL=http://localhost:5090/openapi/v1.json
|
||||
5
Tiku.PlatformAdmin.Web/.gitignore
vendored
Normal file
5
Tiku.PlatformAdmin.Web/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.generated/
|
||||
.env.local
|
||||
*.log
|
||||
25
Tiku.PlatformAdmin.Web/README.md
Normal file
25
Tiku.PlatformAdmin.Web/README.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Tiku 平台管理前端
|
||||
|
||||
独立部署的 React + TypeScript 平台管理端。业务接口契约只从 `Tiku.Api` 实时 OpenAPI 文档生成,不依赖旧静态前端的接口适配代码。
|
||||
|
||||
## 本地开发
|
||||
|
||||
首次使用先安装前端依赖:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
之后从仓库根目录运行 `dotnet run --project Tiku.Api`,ASP.NET Core 的开发代理会自动启动本工程的 Vite 服务。访问 <http://localhost:5173>。开发服务器默认代理 `/api` 到 `http://localhost:5090`;需要修改目标时设置 `VITE_DEV_API_TARGET`。
|
||||
|
||||
也可以在本目录单独执行 `npm run dev`,便于只调试前端。Production 环境不会由 API 启动 Node 进程,前端仍应独立构建和部署。
|
||||
|
||||
## 接口契约
|
||||
|
||||
```bash
|
||||
OPENAPI_URL=http://localhost:5090/openapi/v1.json npm run generate:api
|
||||
```
|
||||
|
||||
生成结果包括完整 OpenAPI TypeScript 类型,以及全部 `/api/platform-admin/**`、`/api/backoffice/platform/**` 接口的页面操作元数据。`npm test` 会验证每个后端平台接口都已归入且只归入一个页面组。
|
||||
|
||||
生产部署通过 `VITE_API_BASE_URL` 指定 API 地址;同域反向代理时可以留空。
|
||||
13
Tiku.PlatformAdmin.Web/index.html
Normal file
13
Tiku.PlatformAdmin.Web/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#173f72" />
|
||||
<title>恭学题库 SaaS · 平台管理</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3534
Tiku.PlatformAdmin.Web/package-lock.json
generated
Normal file
3534
Tiku.PlatformAdmin.Web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
Tiku.PlatformAdmin.Web/package.json
Normal file
31
Tiku.PlatformAdmin.Web/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@tiku/platform-admin",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"generate:api": "node scripts/generate-platform-operations.mjs",
|
||||
"test": "vitest run",
|
||||
"check": "npm run build && npm run test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.0.1",
|
||||
"antd": "6.5.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router": "^8.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"openapi-typescript": "^7.10.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.2",
|
||||
"vitest": "^4.0.8"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 283 KiB After Width: | Height: | Size: 283 KiB |
@@ -0,0 +1,51 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const openApiUrl = process.env.OPENAPI_URL || 'http://localhost:5090/openapi/v1.json';
|
||||
const response = await fetch(openApiUrl);
|
||||
if (!response.ok) throw new Error(`OpenAPI 下载失败:${response.status} ${response.statusText}`);
|
||||
const document = await response.json();
|
||||
|
||||
const generatedDir = path.join(projectRoot, '.generated');
|
||||
const apiDir = path.join(projectRoot, 'src', 'api');
|
||||
mkdirSync(generatedDir, { recursive: true });
|
||||
mkdirSync(apiDir, { recursive: true });
|
||||
const snapshotPath = path.join(generatedDir, 'openapi.json');
|
||||
writeFileSync(snapshotPath, `${JSON.stringify(document)}\n`);
|
||||
|
||||
const cli = path.join(projectRoot, 'node_modules', '.bin', process.platform === 'win32' ? 'openapi-typescript.cmd' : 'openapi-typescript');
|
||||
execFileSync(cli, [snapshotPath, '-o', path.join(apiDir, 'schema.generated.d.ts')], { stdio: 'inherit' });
|
||||
|
||||
const methods = new Set(['get', 'post', 'put', 'patch', 'delete']);
|
||||
const operations = [];
|
||||
for (const [route, pathItem] of Object.entries(document.paths || {})) {
|
||||
for (const [method, operation] of Object.entries(pathItem || {})) {
|
||||
if (!methods.has(method)) continue;
|
||||
const tags = operation.tags || [];
|
||||
const isPlatform = route.startsWith('/api/platform-admin/') || route === '/api/platform-admin' || route.startsWith('/api/backoffice/platform/');
|
||||
if (!isPlatform) continue;
|
||||
const requestSchema = operation.requestBody?.content?.['application/json']?.schema || null;
|
||||
const responseEntry = Object.entries(operation.responses || {}).find(([status]) => /^2/.test(status));
|
||||
const responseSchema = responseEntry?.[1]?.content?.['application/json']?.schema || null;
|
||||
operations.push({
|
||||
id: `${method.toUpperCase()} ${route}`,
|
||||
method: method.toUpperCase(),
|
||||
path: route,
|
||||
tag: tags[0] || '平台端',
|
||||
summary: operation.summary || `${method.toUpperCase()} ${route}`,
|
||||
description: operation.description || '',
|
||||
parameters: [...(pathItem.parameters || []), ...(operation.parameters || [])],
|
||||
requestSchema,
|
||||
responseSchema,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
operations.sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method));
|
||||
const schemas = document.components?.schemas || {};
|
||||
const source = `/* eslint-disable */\n// 由 scripts/generate-platform-operations.mjs 根据后端 OpenAPI 自动生成,请勿手改。\nimport type { PlatformOperation } from './types';\n\nexport const generatedAt = ${JSON.stringify(new Date().toISOString())};\nexport const platformOperations = ${JSON.stringify(operations, null, 2)} as const satisfies readonly PlatformOperation[];\nexport const openApiSchemas = ${JSON.stringify(schemas, null, 2)} as const;\n`;
|
||||
writeFileSync(path.join(apiDir, 'platform-operations.generated.ts'), source);
|
||||
console.log(`已生成 ${operations.length} 个平台接口契约,来源:${openApiUrl}`);
|
||||
18
Tiku.PlatformAdmin.Web/src/api/groups.test.ts
Normal file
18
Tiku.PlatformAdmin.Web/src/api/groups.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { platformOperations } from './platform-operations.generated';
|
||||
import { groupForOperation, platformGroups } from './groups';
|
||||
|
||||
describe('平台 OpenAPI 页面覆盖', () => {
|
||||
it('每个后端平台接口恰好归入一个页面组', () => {
|
||||
const knownGroups = new Set(platformGroups.map((group) => group.key));
|
||||
const grouped = platformOperations.map((operation) => ({ id: operation.id, group: groupForOperation(operation) }));
|
||||
expect(grouped).toHaveLength(platformOperations.length);
|
||||
expect(new Set(grouped.map((item) => item.id)).size).toBe(platformOperations.length);
|
||||
expect(grouped.every((item) => knownGroups.has(item.group))).toBe(true);
|
||||
});
|
||||
|
||||
it('契约只包含平台权限域接口', () => {
|
||||
expect(platformOperations.length).toBeGreaterThan(0);
|
||||
expect(platformOperations.every((operation) => operation.path.startsWith('/api/platform-admin/') || operation.path.startsWith('/api/backoffice/platform/'))).toBe(true);
|
||||
});
|
||||
});
|
||||
31
Tiku.PlatformAdmin.Web/src/api/groups.ts
Normal file
31
Tiku.PlatformAdmin.Web/src/api/groups.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { PlatformOperation } from './types';
|
||||
|
||||
export const platformGroups = [
|
||||
{ key: 'overview', label: '经营概览', description: '平台经营指标与实时概览' },
|
||||
{ key: 'tenants', label: '租户管理', description: '租户、详情、域名与账务资料' },
|
||||
{ key: 'catalog', label: 'SaaS 商品', description: '功能、限额、套餐与版本生命周期' },
|
||||
{ key: 'billing', label: '订阅与账务', description: '订单、订阅、支付、退款、发票、用量与催缴' },
|
||||
{ key: 'question-banks', label: '公共题库', description: '题库结构、题目、导入与资源上传' },
|
||||
{ key: 'crm', label: 'CRM 能力', description: '租户 CRM 配置、线索队列与推送日志' },
|
||||
{ key: 'sms', label: '短信能力', description: '租户短信渠道、模板与发送日志' },
|
||||
{ key: 'payments', label: '支付设置', description: '平台支付与租户支付应用托管' },
|
||||
{ key: 'staff', label: '平台权限', description: '平台员工、角色与权限绑定' },
|
||||
{ key: 'audit', label: '审计与告警', description: '平台审计日志、告警与通知' },
|
||||
] as const;
|
||||
|
||||
export type PlatformGroupKey = (typeof platformGroups)[number]['key'];
|
||||
|
||||
export function groupForOperation(operation: PlatformOperation): PlatformGroupKey {
|
||||
const route = operation.path;
|
||||
if (route === '/api/platform-admin/overview') return 'overview';
|
||||
if (route.startsWith('/api/platform-admin/tenants') || route.startsWith('/api/platform-admin/domains')) return 'tenants';
|
||||
if (route.startsWith('/api/platform-admin/question-banks')) return 'question-banks';
|
||||
if (route.startsWith('/api/platform-admin/tenant-capabilities/crm')) return 'crm';
|
||||
if (route.startsWith('/api/platform-admin/tenant-capabilities/sms')) return 'sms';
|
||||
if (route.startsWith('/api/platform-admin/payment-settings') || route.startsWith('/api/platform-admin/tenant-capabilities/payments')) return 'payments';
|
||||
if (route.startsWith('/api/backoffice/platform') || route.startsWith('/api/platform-admin/staff')) return 'staff';
|
||||
if (route.startsWith('/api/platform-admin/audit-')) return 'audit';
|
||||
if (route.startsWith('/api/platform-admin/saas/features') || route.startsWith('/api/platform-admin/saas/feature-limits') || route.startsWith('/api/platform-admin/saas/offerings') || route.startsWith('/api/platform-admin/saas/offering-versions') || route.startsWith('/api/platform-admin/saas/catalog') || route.startsWith('/api/platform-admin/saas/tenant-feature-overrides')) return 'catalog';
|
||||
if (route.startsWith('/api/platform-admin/saas/')) return 'billing';
|
||||
throw new Error(`平台接口尚未归入页面:${operation.id}`);
|
||||
}
|
||||
105
Tiku.PlatformAdmin.Web/src/api/http.ts
Normal file
105
Tiku.PlatformAdmin.Web/src/api/http.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { OperationInput, PlatformOperation } from './types';
|
||||
import { tokenStore, type TokenPair } from './token-store';
|
||||
|
||||
const apiBaseUrl = (import.meta.env.VITE_API_BASE_URL || '').replace(/\/$/, '');
|
||||
let refreshPromise: Promise<TokenPair | null> | null = null;
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
readonly details?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function parseResponse(response: Response): Promise<unknown> {
|
||||
if (response.status === 204) return null;
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (contentType.includes('json')) return response.json();
|
||||
const text = await response.text();
|
||||
return text || null;
|
||||
}
|
||||
|
||||
async function refreshTokens(): Promise<TokenPair | null> {
|
||||
const tokens = tokenStore.get();
|
||||
if (!tokens?.refreshToken) return null;
|
||||
const response = await fetch(`${apiBaseUrl}/api/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refreshToken: tokens.refreshToken }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
tokenStore.set(null);
|
||||
return null;
|
||||
}
|
||||
const next = (await response.json()) as TokenPair;
|
||||
tokenStore.set(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
async function authorizedFetch(url: string, init: RequestInit, retry = true): Promise<Response> {
|
||||
const accessToken = tokenStore.get()?.accessToken;
|
||||
const headers = new Headers(init.headers);
|
||||
if (accessToken) headers.set('Authorization', `Bearer ${accessToken}`);
|
||||
const response = await fetch(`${apiBaseUrl}${url}`, { ...init, headers });
|
||||
if (response.status !== 401 || !retry) return response;
|
||||
refreshPromise ??= refreshTokens().finally(() => { refreshPromise = null; });
|
||||
const refreshed = await refreshPromise;
|
||||
return refreshed ? authorizedFetch(url, init, false) : response;
|
||||
}
|
||||
|
||||
function appendQuery(url: URL, query: Record<string, unknown>) {
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value === undefined || value === null || value === '') continue;
|
||||
if (Array.isArray(value)) value.forEach((item) => url.searchParams.append(key, String(item)));
|
||||
else url.searchParams.set(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiRequest<T = unknown>(
|
||||
operation: PlatformOperation,
|
||||
input: OperationInput = {},
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
const pathValues = (input.path || {}) as Record<string, unknown>;
|
||||
let route = operation.path.replace(/\{([^}]+)\}/g, (_, name: string) => encodeURIComponent(String(pathValues[name] ?? '')));
|
||||
const url = new URL(route, window.location.origin);
|
||||
appendQuery(url, ((input.query || {}) as Record<string, unknown>));
|
||||
route = `${url.pathname}${url.search}`;
|
||||
const headers: Record<string, string> = {};
|
||||
const init: RequestInit = { method: operation.method, headers, signal };
|
||||
if (input.body !== undefined && operation.method !== 'GET') {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
init.body = JSON.stringify(input.body);
|
||||
}
|
||||
const response = await authorizedFetch(route, init);
|
||||
const payload = await parseResponse(response);
|
||||
if (!response.ok) {
|
||||
const problem = payload as { detail?: string; title?: string } | null;
|
||||
throw new ApiError(problem?.detail || problem?.title || `请求失败(HTTP ${response.status})`, response.status, payload);
|
||||
}
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
export async function authRequest<T>(path: string, body?: unknown): Promise<T> {
|
||||
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const payload = await parseResponse(response);
|
||||
if (!response.ok) {
|
||||
const problem = payload as { detail?: string; title?: string } | null;
|
||||
throw new ApiError(problem?.detail || problem?.title || `请求失败(HTTP ${response.status})`, response.status, payload);
|
||||
}
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
export async function getCurrentUser<T>(): Promise<T> {
|
||||
const response = await authorizedFetch('/api/me', { method: 'GET' });
|
||||
const payload = await parseResponse(response);
|
||||
if (!response.ok) throw new ApiError('登录会话已失效', response.status, payload);
|
||||
return payload as T;
|
||||
}
|
||||
31952
Tiku.PlatformAdmin.Web/src/api/platform-operations.generated.ts
Normal file
31952
Tiku.PlatformAdmin.Web/src/api/platform-operations.generated.ts
Normal file
File diff suppressed because it is too large
Load Diff
13
Tiku.PlatformAdmin.Web/src/api/platform.ts
Normal file
13
Tiku.PlatformAdmin.Web/src/api/platform.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { apiRequest } from './http';
|
||||
import { platformOperations } from './platform-operations.generated';
|
||||
import type { HttpMethod, OperationInput, PlatformOperation } from './types';
|
||||
|
||||
export function platformOperation(method: HttpMethod, path: string): PlatformOperation {
|
||||
const operation = platformOperations.find((item) => item.method === method && item.path === path);
|
||||
if (!operation) throw new Error(`平台接口契约缺失:${method} ${path}`);
|
||||
return operation;
|
||||
}
|
||||
|
||||
export function platformRequest<T>(method: HttpMethod, path: string, input: OperationInput = {}, signal?: AbortSignal) {
|
||||
return apiRequest<T>(platformOperation(method, path), input, signal);
|
||||
}
|
||||
37
Tiku.PlatformAdmin.Web/src/api/schema-utils.ts
Normal file
37
Tiku.PlatformAdmin.Web/src/api/schema-utils.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { openApiSchemas } from './platform-operations.generated';
|
||||
import type { JsonSchema } from './types';
|
||||
|
||||
const schemas = openApiSchemas as unknown as Record<string, JsonSchema>;
|
||||
|
||||
export function resolveSchema(schema: JsonSchema | null | undefined, seen = new Set<string>()): JsonSchema {
|
||||
if (!schema) return {};
|
||||
if (schema.$ref) {
|
||||
const name = schema.$ref.split('/').at(-1) || '';
|
||||
if (seen.has(name)) return { type: 'object', description: schema.description };
|
||||
const nextSeen = new Set(seen).add(name);
|
||||
return { ...resolveSchema(schemas[name], nextSeen), description: schema.description || schemas[name]?.description };
|
||||
}
|
||||
const alternatives = schema.oneOf || schema.anyOf;
|
||||
if (alternatives?.length) {
|
||||
const meaningful = alternatives.find((item) => item.type !== 'null') || alternatives[0];
|
||||
return { ...schema, ...resolveSchema(meaningful, seen), oneOf: undefined, anyOf: undefined };
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
|
||||
export function schemaType(schema: JsonSchema): string {
|
||||
const type = resolveSchema(schema).type;
|
||||
if (Array.isArray(type)) return type.find((item) => item !== 'null') || 'string';
|
||||
return typeof type === 'string' ? type : 'string';
|
||||
}
|
||||
|
||||
export function defaultForSchema(schema: JsonSchema): unknown {
|
||||
const resolved = resolveSchema(schema);
|
||||
if (resolved.default !== undefined) return resolved.default;
|
||||
switch (schemaType(resolved)) {
|
||||
case 'boolean': return false;
|
||||
case 'array': return [];
|
||||
case 'object': return {};
|
||||
default: return undefined;
|
||||
}
|
||||
}
|
||||
26144
Tiku.PlatformAdmin.Web/src/api/schema.generated.d.ts
vendored
Normal file
26144
Tiku.PlatformAdmin.Web/src/api/schema.generated.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
28
Tiku.PlatformAdmin.Web/src/api/token-store.ts
Normal file
28
Tiku.PlatformAdmin.Web/src/api/token-store.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export interface TokenPair {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
accessTokenExpiresAt: string;
|
||||
refreshTokenExpiresAt: string;
|
||||
}
|
||||
|
||||
const storageKey = 'tiku.platform-admin.tokens';
|
||||
let currentTokens: TokenPair | null = readTokens();
|
||||
|
||||
function readTokens(): TokenPair | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
return raw ? (JSON.parse(raw) as TokenPair) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const tokenStore = {
|
||||
get: () => currentTokens,
|
||||
set(tokens: TokenPair | null) {
|
||||
currentTokens = tokens;
|
||||
if (tokens) localStorage.setItem(storageKey, JSON.stringify(tokens));
|
||||
else localStorage.removeItem(storageKey);
|
||||
window.dispatchEvent(new CustomEvent('platform-auth-change'));
|
||||
},
|
||||
};
|
||||
43
Tiku.PlatformAdmin.Web/src/api/types.ts
Normal file
43
Tiku.PlatformAdmin.Web/src/api/types.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
|
||||
export interface JsonSchema {
|
||||
[key: string]: unknown;
|
||||
$ref?: string;
|
||||
type?: string | readonly string[];
|
||||
format?: string;
|
||||
description?: string;
|
||||
default?: unknown;
|
||||
enum?: readonly unknown[];
|
||||
oneOf?: readonly JsonSchema[];
|
||||
anyOf?: readonly JsonSchema[];
|
||||
items?: JsonSchema;
|
||||
properties?: Readonly<Record<string, JsonSchema>>;
|
||||
required?: readonly string[];
|
||||
minimum?: number;
|
||||
maximum?: number;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
pattern?: string;
|
||||
}
|
||||
|
||||
export interface OpenApiParameter {
|
||||
name: string;
|
||||
in: 'path' | 'query' | 'header';
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
schema?: JsonSchema;
|
||||
}
|
||||
|
||||
export interface PlatformOperation {
|
||||
id: string;
|
||||
method: HttpMethod;
|
||||
path: string;
|
||||
tag: string;
|
||||
summary: string;
|
||||
description: string;
|
||||
parameters: readonly OpenApiParameter[];
|
||||
requestSchema: JsonSchema | null;
|
||||
responseSchema: JsonSchema | null;
|
||||
}
|
||||
|
||||
export type OperationInput = Record<string, unknown>;
|
||||
106
Tiku.PlatformAdmin.Web/src/auth/AuthProvider.tsx
Normal file
106
Tiku.PlatformAdmin.Web/src/auth/AuthProvider.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type PropsWithChildren } from 'react';
|
||||
import { authRequest, getCurrentUser } from '../api/http';
|
||||
import { tokenStore, type TokenPair } from '../api/token-store';
|
||||
import type { components } from '../api/schema.generated';
|
||||
|
||||
type AuthenticationResult = components['schemas']['AuthenticationResultDto'];
|
||||
type CurrentUser = components['schemas']['MeResponse'];
|
||||
const platformRealm = 1;
|
||||
|
||||
interface AuthContextValue {
|
||||
loading: boolean;
|
||||
user: CurrentUser | null;
|
||||
challengeToken: string | null;
|
||||
login(identifier: string, password: string): Promise<void>;
|
||||
completePasswordChange(password: string): Promise<void>;
|
||||
logout(): Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
function tokensFrom(result: AuthenticationResult): TokenPair | null {
|
||||
const tokens = result.user?.tokens;
|
||||
return tokens?.accessToken && tokens.refreshToken ? (tokens as TokenPair) : null;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: PropsWithChildren) {
|
||||
const [loading, setLoading] = useState(Boolean(tokenStore.get()));
|
||||
const [user, setUser] = useState<CurrentUser | null>(null);
|
||||
const [challengeToken, setChallengeToken] = useState<string | null>(null);
|
||||
|
||||
const loadUser = useCallback(async () => {
|
||||
if (!tokenStore.get()) {
|
||||
setUser(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setUser(await getCurrentUser<CurrentUser>());
|
||||
} catch {
|
||||
tokenStore.set(null);
|
||||
setUser(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void loadUser(); }, [loadUser]);
|
||||
|
||||
const applyAuthentication = useCallback(async (result: AuthenticationResult) => {
|
||||
const tokens = tokensFrom(result);
|
||||
if (tokens) {
|
||||
tokenStore.set(tokens);
|
||||
setChallengeToken(null);
|
||||
setLoading(true);
|
||||
await loadUser();
|
||||
return;
|
||||
}
|
||||
if (result.challengeToken) {
|
||||
setChallengeToken(result.challengeToken);
|
||||
return;
|
||||
}
|
||||
throw new Error('认证响应中没有登录令牌或密码变更挑战');
|
||||
}, [loadUser]);
|
||||
|
||||
const value = useMemo<AuthContextValue>(() => ({
|
||||
loading,
|
||||
user,
|
||||
challengeToken,
|
||||
async login(identifier, password) {
|
||||
const result = await authRequest<AuthenticationResult>('/api/auth/login/password', {
|
||||
realm: platformRealm,
|
||||
tenantCode: null,
|
||||
identifier,
|
||||
phone: null,
|
||||
password,
|
||||
});
|
||||
await applyAuthentication(result);
|
||||
},
|
||||
async completePasswordChange(password) {
|
||||
if (!challengeToken) throw new Error('密码变更挑战已失效,请重新登录');
|
||||
const result = await authRequest<AuthenticationResult>('/api/auth/password/change-required', {
|
||||
challengeToken,
|
||||
newPassword: password,
|
||||
});
|
||||
await applyAuthentication(result);
|
||||
},
|
||||
async logout() {
|
||||
const refreshToken = tokenStore.get()?.refreshToken;
|
||||
try {
|
||||
if (refreshToken) await authRequest('/api/auth/logout', { refreshToken });
|
||||
} finally {
|
||||
tokenStore.set(null);
|
||||
setUser(null);
|
||||
setChallengeToken(null);
|
||||
}
|
||||
},
|
||||
}), [applyAuthentication, challengeToken, loading, user]);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const value = useContext(AuthContext);
|
||||
if (!value) throw new Error('useAuth 必须在 AuthProvider 内使用');
|
||||
return value;
|
||||
}
|
||||
45
Tiku.PlatformAdmin.Web/src/components/BusinessTable.tsx
Normal file
45
Tiku.PlatformAdmin.Web/src/components/BusinessTable.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Table, Tag, Typography } from 'antd';
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
id: '编号', tenantId: '租户', name: '名称', code: '编码', slug: '短编码', host: '域名', status: '状态', billingStatus: '账务状态',
|
||||
createdAt: '创建时间', updatedAt: '更新时间', expiresAt: '到期时间', dueAt: '到期时间', amountCents: '金额', totalAmountCents: '金额',
|
||||
balanceCents: '余额', phone: '手机号', email: '邮箱', ownerName: '负责人', provider: '服务商', enabled: '启用', appName: '应用名称',
|
||||
appCode: '应用代码', title: '标题', summary: '摘要', action: '动作', targetType: '目标类型', targetId: '目标编号', severity: '级别',
|
||||
version: '版本', billingCycle: '计费周期', effectiveAt: '生效时间', questionCount: '题目数', nodeType: '节点类型', sortOrder: '排序',
|
||||
};
|
||||
|
||||
function isScalar(value: unknown) { return value == null || ['string', 'number', 'boolean'].includes(typeof value); }
|
||||
function cell(value: unknown, key: string) {
|
||||
if (value === null || value === undefined || value === '') return '-';
|
||||
if (typeof value === 'boolean') return <Tag color={value ? 'green' : 'default'}>{value ? '是' : '否'}</Tag>;
|
||||
if (/status|severity/i.test(key)) return <Tag color={String(value).toLowerCase().includes('active') || String(value).includes('正常') ? 'green' : 'blue'}>{String(value)}</Tag>;
|
||||
if (/Cents$/.test(key) && typeof value === 'number') return `¥${(value / 100).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`;
|
||||
return <Typography.Text ellipsis={{ tooltip: String(value) }}>{String(value)}</Typography.Text>;
|
||||
}
|
||||
|
||||
export function rowsFromPayload(payload: unknown): Record<string, unknown>[] {
|
||||
if (Array.isArray(payload)) return payload.filter((item) => item && typeof item === 'object') as Record<string, unknown>[];
|
||||
if (!payload || typeof payload !== 'object') return [];
|
||||
const object = payload as Record<string, unknown>;
|
||||
for (const key of ['items', 'data', 'results', 'tenants', 'domains', 'staff', 'alerts', 'logs']) {
|
||||
if (Array.isArray(object[key])) return object[key] as Record<string, unknown>[];
|
||||
}
|
||||
return [object];
|
||||
}
|
||||
|
||||
export function BusinessTable({ payload, loading, onSelect }: { payload: unknown; loading: boolean; onSelect(record: Record<string, unknown> | null): void }) {
|
||||
const rows = rowsFromPayload(payload);
|
||||
const keys = [...new Set(rows.flatMap((row) => Object.keys(row).filter((key) => isScalar(row[key]))))].slice(0, 9);
|
||||
return (
|
||||
<Table
|
||||
loading={loading}
|
||||
dataSource={rows}
|
||||
rowKey={(row, index) => String(row.id || row.tenantId || row.code || index)}
|
||||
rowSelection={{ type: 'radio', onChange: (_, selected) => onSelect(selected[0] || null) }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (total) => `共 ${total} 条` }}
|
||||
locale={{ emptyText: '暂无业务数据' }}
|
||||
columns={keys.map((key) => ({ title: labels[key] || key, dataIndex: key, key, ellipsis: true, render: (value) => cell(value, key) }))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
61
Tiku.PlatformAdmin.Web/src/components/OperationForm.tsx
Normal file
61
Tiku.PlatformAdmin.Web/src/components/OperationForm.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Form, Input } from 'antd';
|
||||
import { SchemaFields } from './SchemaFields';
|
||||
import { resolveSchema, schemaType } from '../api/schema-utils';
|
||||
import type { JsonSchema, OperationInput, PlatformOperation } from '../api/types';
|
||||
|
||||
function parseJsonFields(target: Record<string, unknown>, schema: JsonSchema) {
|
||||
const resolved = resolveSchema(schema);
|
||||
for (const [name, propertySchema] of Object.entries(resolved.properties || {})) {
|
||||
const value = target[name];
|
||||
const type = schemaType(propertySchema);
|
||||
if ((type === 'object' || type === 'array') && typeof value === 'string' && value.trim()) {
|
||||
try {
|
||||
target[name] = JSON.parse(value);
|
||||
} catch {
|
||||
throw new Error(`${propertySchema.description || name} 必须是有效 JSON`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeOperationInput(operation: PlatformOperation, values: OperationInput): OperationInput {
|
||||
const input = structuredClone(values);
|
||||
if (input.body && typeof input.body === 'object' && operation.requestSchema) {
|
||||
parseJsonFields(input.body as Record<string, unknown>, operation.requestSchema);
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
export function OperationForm({ operation }: { operation: PlatformOperation }) {
|
||||
const pathParameters = operation.parameters.filter((item) => item.in === 'path');
|
||||
const queryParameters = operation.parameters.filter((item) => item.in === 'query');
|
||||
const bodySchema = resolveSchema(operation.requestSchema);
|
||||
return (
|
||||
<>
|
||||
{pathParameters.map((parameter) => (
|
||||
<Form.Item
|
||||
key={`path-${parameter.name}`}
|
||||
name={['path', parameter.name]}
|
||||
label={parameter.description || parameter.name}
|
||||
rules={[{ required: true, message: `请填写${parameter.description || parameter.name}` }]}
|
||||
tooltip={`路径参数:${parameter.name}`}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
))}
|
||||
{queryParameters.length > 0 && (
|
||||
<SchemaFields
|
||||
prefix="query"
|
||||
required={queryParameters.filter((item) => item.required).map((item) => item.name)}
|
||||
schema={{
|
||||
type: 'object',
|
||||
properties: Object.fromEntries(queryParameters.map((item) => [item.name, { ...item.schema, description: item.description || item.name }])),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{operation.requestSchema && schemaType(bodySchema) === 'object' && (
|
||||
<SchemaFields schema={bodySchema} prefix="body" required={bodySchema.required} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
54
Tiku.PlatformAdmin.Web/src/components/ResponseView.tsx
Normal file
54
Tiku.PlatformAdmin.Web/src/components/ResponseView.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Descriptions, Empty, Table, Tag, Typography } from 'antd';
|
||||
|
||||
function scalar(value: unknown) {
|
||||
if (value === null || value === undefined) return '-';
|
||||
if (typeof value === 'boolean') return value ? <Tag color="green">是</Tag> : <Tag>否</Tag>;
|
||||
if (typeof value === 'object') return <Typography.Text code>{JSON.stringify(value)}</Typography.Text>;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function rowsFrom(payload: unknown): Record<string, unknown>[] | null {
|
||||
if (Array.isArray(payload)) return payload.filter((item) => item && typeof item === 'object') as Record<string, unknown>[];
|
||||
if (!payload || typeof payload !== 'object') return null;
|
||||
const object = payload as Record<string, unknown>;
|
||||
for (const key of ['items', 'data', 'results']) {
|
||||
if (Array.isArray(object[key])) return object[key] as Record<string, unknown>[];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ResponseView({ payload }: { payload: unknown }) {
|
||||
if (payload === undefined) return <Empty description="填写查询条件后执行接口" />;
|
||||
if (payload === null) return <Empty description="操作已完成,后端未返回响应体" />;
|
||||
const rows = rowsFrom(payload);
|
||||
if (rows) {
|
||||
if (!rows.length) return <Empty description="暂无数据" />;
|
||||
const keys = [...new Set(rows.flatMap((row) => Object.keys(row)))].slice(0, 8);
|
||||
return (
|
||||
<Table
|
||||
size="small"
|
||||
rowKey={(row, index) => String(row.id || row.code || row.userId || index)}
|
||||
dataSource={rows}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{ pageSize: 20, showSizeChanger: true }}
|
||||
columns={keys.map((key) => ({
|
||||
title: key,
|
||||
dataIndex: key,
|
||||
key,
|
||||
ellipsis: true,
|
||||
render: scalar,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (typeof payload === 'object') {
|
||||
return (
|
||||
<Descriptions bordered size="small" column={{ xs: 1, sm: 1, md: 2 }}>
|
||||
{Object.entries(payload as Record<string, unknown>).map(([key, value]) => (
|
||||
<Descriptions.Item key={key} label={key}>{scalar(value)}</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
);
|
||||
}
|
||||
return <Typography.Text>{String(payload)}</Typography.Text>;
|
||||
}
|
||||
55
Tiku.PlatformAdmin.Web/src/components/SchemaFields.tsx
Normal file
55
Tiku.PlatformAdmin.Web/src/components/SchemaFields.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Form, Input, InputNumber, Select, Switch } from 'antd';
|
||||
import type { NamePath } from 'antd/es/form/interface';
|
||||
import { defaultForSchema, resolveSchema, schemaType } from '../api/schema-utils';
|
||||
import type { JsonSchema } from '../api/types';
|
||||
|
||||
interface SchemaFieldsProps {
|
||||
schema: JsonSchema;
|
||||
prefix: NamePath;
|
||||
required?: readonly string[];
|
||||
}
|
||||
|
||||
function fieldName(prefix: NamePath, name: string): NamePath {
|
||||
return [...(Array.isArray(prefix) ? prefix : [prefix]), name];
|
||||
}
|
||||
|
||||
export function SchemaFields({ schema, prefix, required = [] }: SchemaFieldsProps) {
|
||||
const resolved = resolveSchema(schema);
|
||||
const properties = resolved.properties || {};
|
||||
return Object.entries(properties).map(([name, propertySchema]) => {
|
||||
const property = resolveSchema(propertySchema);
|
||||
const type = schemaType(property);
|
||||
const label = property.description || name;
|
||||
const isRequired = required.includes(name);
|
||||
const rules = isRequired ? [{ required: true, message: `请填写${label}` }] : undefined;
|
||||
let control = <Input placeholder={property.format === 'uuid' ? 'UUID' : undefined} />;
|
||||
|
||||
if (property.enum?.length) {
|
||||
control = <Select options={property.enum.map((value) => ({ label: String(value), value }))} allowClear={!isRequired} />;
|
||||
} else if (type === 'boolean') {
|
||||
control = <Switch />;
|
||||
} else if (type === 'integer' || type === 'number') {
|
||||
control = <InputNumber min={property.minimum} max={property.maximum} style={{ width: '100%' }} />;
|
||||
} else if (type === 'object' || type === 'array') {
|
||||
control = <Input.TextArea autoSize={{ minRows: 3, maxRows: 10 }} placeholder={type === 'array' ? '[]' : '{}'} />;
|
||||
} else if (property.format === 'date-time') {
|
||||
control = <Input type="datetime-local" />;
|
||||
} else if (property.maxLength && property.maxLength > 200) {
|
||||
control = <Input.TextArea autoSize={{ minRows: 2, maxRows: 8 }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Form.Item
|
||||
key={name}
|
||||
name={fieldName(prefix, name)}
|
||||
label={label}
|
||||
rules={rules}
|
||||
valuePropName={type === 'boolean' ? 'checked' : 'value'}
|
||||
initialValue={defaultForSchema(property)}
|
||||
tooltip={name}
|
||||
>
|
||||
{control}
|
||||
</Form.Item>
|
||||
);
|
||||
});
|
||||
}
|
||||
95
Tiku.PlatformAdmin.Web/src/layout/AppLayout.tsx
Normal file
95
Tiku.PlatformAdmin.Web/src/layout/AppLayout.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
AlertOutlined,
|
||||
ApartmentOutlined,
|
||||
AuditOutlined,
|
||||
BankOutlined,
|
||||
BookOutlined,
|
||||
ContactsOutlined,
|
||||
CreditCardOutlined,
|
||||
DashboardOutlined,
|
||||
FileTextOutlined,
|
||||
LogoutOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
MessageOutlined,
|
||||
PieChartOutlined,
|
||||
ShopOutlined,
|
||||
UserSwitchOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Avatar, Button, Dropdown, Flex, Layout, Menu, Space, Spin, Typography } from 'antd';
|
||||
import { useState } from 'react';
|
||||
import { Navigate, Outlet, useLocation, useNavigate } from 'react-router';
|
||||
import { useAuth } from '../auth/AuthProvider';
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
const items = [
|
||||
{ key: '/', icon: <DashboardOutlined />, label: '经营概览' },
|
||||
{ type: 'group' as const, label: '业务运营', children: [
|
||||
{ key: '/tenants', icon: <ApartmentOutlined />, label: '租户管理' },
|
||||
{ key: '/subscriptions', icon: <ShopOutlined />, label: '套餐与订阅' },
|
||||
{ key: '/billing', icon: <BankOutlined />, label: '订单与发票' },
|
||||
{ key: '/usage', icon: <PieChartOutlined />, label: '用量计费' },
|
||||
{ key: '/dunning', icon: <FileTextOutlined />, label: '收款与催缴' },
|
||||
] },
|
||||
{ type: 'group' as const, label: '内容资产', children: [
|
||||
{ key: '/question-banks', icon: <BookOutlined />, label: '公共题库' },
|
||||
] },
|
||||
{ type: 'group' as const, label: '平台服务', children: [
|
||||
{ key: '/crm', icon: <ContactsOutlined />, label: 'CRM 服务' },
|
||||
{ key: '/sms', icon: <MessageOutlined />, label: '短信服务' },
|
||||
{ key: '/payments', icon: <CreditCardOutlined />, label: '支付服务' },
|
||||
] },
|
||||
{ type: 'group' as const, label: '安全与系统', children: [
|
||||
{ key: '/staff', icon: <UserSwitchOutlined />, label: '员工与角色' },
|
||||
{ key: '/audit', icon: <AuditOutlined />, label: '审计日志' },
|
||||
{ key: '/alerts', icon: <AlertOutlined />, label: '审计告警' },
|
||||
] },
|
||||
];
|
||||
|
||||
const pageTitles: Record<string, string> = {
|
||||
'/': '经营概览', '/tenants': '租户管理', '/subscriptions': '套餐与订阅', '/billing': '订单与发票',
|
||||
'/usage': '用量计费', '/dunning': '收款与催缴', '/question-banks': '公共题库', '/crm': 'CRM 服务',
|
||||
'/sms': '短信服务', '/payments': '支付服务', '/staff': '员工与角色', '/audit': '审计日志', '/alerts': '审计告警',
|
||||
};
|
||||
|
||||
export function AppLayout() {
|
||||
const auth = useAuth();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
if (auth.loading) return <div className="full-screen-loading"><Spin size="large" /></div>;
|
||||
if (!auth.user) return <Navigate to="/login" replace state={{ from: location.pathname }} />;
|
||||
const displayName = auth.user.name || auth.user.phone || auth.user.email || '平台管理员';
|
||||
return (
|
||||
<Layout className="app-shell">
|
||||
<Sider theme="light" collapsible collapsed={collapsed} collapsedWidth={76} trigger={null} width={248} className="app-sider">
|
||||
<div className="sider-brand">
|
||||
<div className="brand-mark"><img src="/logo.png" alt="Logo" /></div>
|
||||
{!collapsed && <div className="brand-copy"><strong>恭学题库</strong><span>平台管理中心</span></div>}
|
||||
</div>
|
||||
<div className="sider-navigation"><Menu theme="light" mode="inline" selectedKeys={[location.pathname]} items={items} onClick={({ key }) => navigate(key)} /></div>
|
||||
<div className="sider-footer">
|
||||
<Button type="text" block icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />} onClick={() => setCollapsed((value) => !value)}>{collapsed ? null : '收起导航'}</Button>
|
||||
</div>
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Header className="app-header">
|
||||
<Typography.Text className="header-page-title">{pageTitles[location.pathname] || '平台管理'}</Typography.Text>
|
||||
<Dropdown
|
||||
menu={{ items: [{ key: 'logout', icon: <LogoutOutlined />, label: '退出登录', onClick: () => void auth.logout() }] }}
|
||||
trigger={['click']}
|
||||
>
|
||||
<Flex align="center" gap={10} className="user-menu">
|
||||
<Avatar>{String(displayName).slice(0, 1)}</Avatar>
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Text strong>{displayName}</Typography.Text>
|
||||
<Typography.Text type="secondary" className="user-role">平台管理</Typography.Text>
|
||||
</Space>
|
||||
</Flex>
|
||||
</Dropdown>
|
||||
</Header>
|
||||
<Content className="app-content"><Outlet /></Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
24
Tiku.PlatformAdmin.Web/src/main.tsx
Normal file
24
Tiku.PlatformAdmin.Web/src/main.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { App as AntApp, ConfigProvider } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import { RouterProvider } from 'react-router';
|
||||
import { AuthProvider } from './auth/AuthProvider';
|
||||
import { router } from './router';
|
||||
import './styles.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ConfigProvider
|
||||
locale={zhCN}
|
||||
theme={{
|
||||
token: { colorPrimary: '#2169a8', borderRadius: 10, colorBgLayout: '#f3f6fa', fontFamily: "Inter, 'PingFang SC', 'Microsoft YaHei', sans-serif" },
|
||||
components: { Layout: { siderBg: '#102f52', headerBg: '#ffffff' }, Menu: { darkItemBg: '#102f52', darkItemSelectedBg: '#2169a8' } },
|
||||
}}
|
||||
>
|
||||
<AntApp>
|
||||
<AuthProvider><RouterProvider router={router} /></AuthProvider>
|
||||
</AntApp>
|
||||
</ConfigProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
91
Tiku.PlatformAdmin.Web/src/pages/BusinessPage.tsx
Normal file
91
Tiku.PlatformAdmin.Web/src/pages/BusinessPage.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { PlusOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import { App, Button, Card, Drawer, Empty, Flex, Form, Segmented, Space, Tabs, Typography } from 'antd';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { apiRequest } from '../api/http';
|
||||
import { platformOperations } from '../api/platform-operations.generated';
|
||||
import type { OperationInput, PlatformOperation } from '../api/types';
|
||||
import { BusinessTable } from '../components/BusinessTable';
|
||||
import { OperationForm, normalizeOperationInput } from '../components/OperationForm';
|
||||
import { businessPages } from './business-definitions';
|
||||
|
||||
function prefillFromRecord(operation: PlatformOperation, record: Record<string, unknown> | null): OperationInput {
|
||||
if (!record) return {};
|
||||
const path: Record<string, unknown> = {};
|
||||
for (const parameter of operation.parameters.filter((item) => item.in === 'path')) path[parameter.name] = record[parameter.name] ?? record.id;
|
||||
const body: Record<string, unknown> = {};
|
||||
const properties = operation.requestSchema && '$ref' in operation.requestSchema ? {} : operation.requestSchema?.properties || {};
|
||||
for (const name of Object.keys(properties)) if (record[name] !== undefined) body[name] = record[name];
|
||||
return { path, body };
|
||||
}
|
||||
|
||||
export function BusinessPage({ pageKey }: { pageKey: string }) {
|
||||
const definition = businessPages.find((page) => page.key === pageKey)!;
|
||||
const operations = useMemo(() => platformOperations.filter(definition.matches), [definition]);
|
||||
const reads = operations.filter((operation) => operation.method === 'GET');
|
||||
const writes = operations.filter((operation) => operation.method !== 'GET');
|
||||
const [activeReadId, setActiveReadId] = useState(reads[0]?.id || '');
|
||||
const [payload, setPayload] = useState<unknown>();
|
||||
const [selectedRecord, setSelectedRecord] = useState<Record<string, unknown> | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [drawerOperation, setDrawerOperation] = useState<PlatformOperation | null>(null);
|
||||
const [filterForm] = Form.useForm<OperationInput>();
|
||||
const [actionForm] = Form.useForm<OperationInput>();
|
||||
const { message, modal } = App.useApp();
|
||||
const activeRead = reads.find((operation) => operation.id === activeReadId) || reads[0];
|
||||
|
||||
const runRead = async () => {
|
||||
if (!activeRead) return;
|
||||
setLoading(true);
|
||||
try { setPayload(await apiRequest(activeRead, normalizeOperationInput(activeRead, filterForm.getFieldsValue(true)))); }
|
||||
catch (error) { message.error(error instanceof Error ? error.message : '数据加载失败'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
useEffect(() => { filterForm.resetFields(); setPayload(undefined); setSelectedRecord(null); if (activeRead) void runRead(); }, [activeReadId]);
|
||||
|
||||
const openAction = (operation: PlatformOperation) => {
|
||||
setDrawerOperation(operation);
|
||||
actionForm.resetFields();
|
||||
queueMicrotask(() => actionForm.setFieldsValue(prefillFromRecord(operation, selectedRecord)));
|
||||
};
|
||||
|
||||
const submitAction = async () => {
|
||||
if (!drawerOperation) return;
|
||||
const values = normalizeOperationInput(drawerOperation, await actionForm.validateFields());
|
||||
modal.confirm({
|
||||
title: `确认${drawerOperation.summary}`,
|
||||
content: selectedRecord ? '该操作将作用于当前选中的业务记录,并写入平台审计。' : '请确认表单内容无误。',
|
||||
okText: '确认执行', cancelText: '取消',
|
||||
onOk: async () => {
|
||||
setLoading(true);
|
||||
try { await apiRequest(drawerOperation, values); message.success(`${drawerOperation.summary}完成`); setDrawerOperation(null); await runRead(); }
|
||||
catch (error) { message.error(error instanceof Error ? error.message : '操作失败'); }
|
||||
finally { setLoading(false); }
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="business-page">
|
||||
<Flex justify="space-between" align="end" gap={16} wrap>
|
||||
<div><Typography.Text type="secondary">{definition.eyebrow}</Typography.Text><Typography.Title level={2}>{definition.title}</Typography.Title><Typography.Paragraph type="secondary">{definition.description}</Typography.Paragraph></div>
|
||||
<Space wrap>
|
||||
{writes.map((operation) => <Button key={operation.id} type={operation.method === 'PUT' || operation.method === 'POST' ? 'primary' : 'default'} icon={<PlusOutlined />} onClick={() => openAction(operation)}>{operation.summary}</Button>)}
|
||||
</Space>
|
||||
</Flex>
|
||||
<Card className="business-card">
|
||||
{reads.length > 1 && <Tabs activeKey={activeRead?.id} onChange={setActiveReadId} items={reads.map((operation) => ({ key: operation.id, label: operation.summary }))} />}
|
||||
{activeRead ? (
|
||||
<>
|
||||
<Form form={filterForm} layout="inline" className="business-filter"><OperationForm operation={activeRead} /><Button type="primary" icon={<SearchOutlined />} onClick={() => void runRead()}>查询</Button><Button icon={<ReloadOutlined />} onClick={() => { filterForm.resetFields(); void runRead(); }}>重置</Button></Form>
|
||||
<BusinessTable payload={payload} loading={loading} onSelect={setSelectedRecord} />
|
||||
</>
|
||||
) : <Empty description="当前业务暂时没有查询接口" />}
|
||||
</Card>
|
||||
<Drawer title={drawerOperation?.summary} width={600} open={Boolean(drawerOperation)} onClose={() => setDrawerOperation(null)} footer={<Flex justify="end" gap={8}><Button onClick={() => setDrawerOperation(null)}>取消</Button><Button type="primary" loading={loading} onClick={() => void submitAction()}>保存并执行</Button></Flex>}>
|
||||
{selectedRecord && <Typography.Paragraph type="secondary">已带入当前选中记录,可在提交前核对或修改。</Typography.Paragraph>}
|
||||
{drawerOperation && <Form form={actionForm} layout="vertical" requiredMark="optional"><OperationForm operation={drawerOperation} /></Form>}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
Tiku.PlatformAdmin.Web/src/pages/DashboardPage.tsx
Normal file
88
Tiku.PlatformAdmin.Web/src/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { ArrowRightOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { Alert, Button, Card, Col, Flex, Row, Skeleton, Statistic, Typography } from 'antd';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { apiRequest } from '../api/http';
|
||||
import { platformOperations } from '../api/platform-operations.generated';
|
||||
|
||||
const overviewOperation = platformOperations.find((operation) => operation.path === '/api/platform-admin/overview' && operation.method === 'GET');
|
||||
|
||||
const metricLabels: Record<string, string> = {
|
||||
tenantCount: '租户总数',
|
||||
activeTenantCount: '活跃租户',
|
||||
suspendedTenantCount: '暂停租户',
|
||||
activeSubscriptionCount: '有效订阅',
|
||||
overdueInvoiceCount: '逾期账单',
|
||||
outstandingAmountCents: '待收金额(分)',
|
||||
openAuditAlertCount: '未处理告警',
|
||||
};
|
||||
|
||||
const workspaces = [
|
||||
{ path: '/tenants', title: '租户管理', description: '租户创建、详情、域名、账务资料与生命周期' },
|
||||
{ path: '/subscriptions', title: '套餐与订阅', description: 'SaaS 功能、限额、套餐版本与租户订阅' },
|
||||
{ path: '/billing', title: '订单与发票', description: '服务费订单、退款、发票及交易记录' },
|
||||
{ path: '/usage', title: '用量计费', description: '租户用量与超额计费数据' },
|
||||
{ path: '/dunning', title: '收款与催缴', description: '人工收款、提醒渠道、通知事件与重试' },
|
||||
{ path: '/question-banks', title: '公共题库', description: '题库目录、题目、导入与资源管理' },
|
||||
{ path: '/crm', title: 'CRM 服务', description: '租户接入配置、线索队列与推送日志' },
|
||||
{ path: '/sms', title: '短信服务', description: '渠道、模板审核与发送日志' },
|
||||
{ path: '/payments', title: '支付服务', description: '支付应用、渠道、事件与返佣' },
|
||||
{ path: '/staff', title: '员工与角色', description: '平台员工、角色和权限绑定' },
|
||||
{ path: '/audit', title: '审计日志', description: '跨租户敏感操作检索' },
|
||||
{ path: '/alerts', title: '审计告警', description: '安全告警确认、解决与忽略' },
|
||||
];
|
||||
|
||||
export function DashboardPage() {
|
||||
const [data, setData] = useState<Record<string, unknown> | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const navigate = useNavigate();
|
||||
const load = useCallback(async () => {
|
||||
if (!overviewOperation) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
setData(await apiRequest<Record<string, unknown>>(overviewOperation));
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : '概览加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
const metrics = Object.entries(data || {}).filter(([, value]) => typeof value === 'number').slice(0, 8);
|
||||
return (
|
||||
<div className="dashboard-page">
|
||||
<Flex justify="space-between" align="end">
|
||||
<div>
|
||||
<Typography.Title level={2}>经营概览</Typography.Title>
|
||||
<Typography.Text type="secondary">平台运营、账务与安全状态</Typography.Text>
|
||||
</div>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void load()} loading={loading}>刷新</Button>
|
||||
</Flex>
|
||||
{error && <Alert type="error" showIcon message={error} action={<Button size="small" onClick={() => void load()}>重试</Button>} />}
|
||||
<Skeleton loading={loading} active paragraph={{ rows: 4 }}>
|
||||
<Row gutter={[16, 16]}>
|
||||
{metrics.map(([key, value]) => (
|
||||
<Col xs={24} sm={12} xl={6} key={key}>
|
||||
<Card className="metric-card"><Statistic title={metricLabels[key] || key} value={value as number} /></Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Skeleton>
|
||||
<Typography.Title level={4} className="dashboard-section-title">业务工作台</Typography.Title>
|
||||
<Row gutter={[16, 16]}>
|
||||
{workspaces.map((group) => (
|
||||
<Col xs={24} md={12} xl={8} key={group.path}>
|
||||
<Card hoverable className="portal-card" onClick={() => navigate(group.path)}>
|
||||
<Typography.Title level={5}>{group.title}</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">{group.description}</Typography.Paragraph>
|
||||
<Button type="link" icon={<ArrowRightOutlined />} iconPosition="end">进入工作台</Button>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
Tiku.PlatformAdmin.Web/src/pages/LoginPage.tsx
Normal file
57
Tiku.PlatformAdmin.Web/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { LockOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import { Alert, App, Button, Card, Form, Input, Typography } from 'antd';
|
||||
import { useState } from 'react';
|
||||
import { Navigate } from 'react-router';
|
||||
import { useAuth } from '../auth/AuthProvider';
|
||||
|
||||
export function LoginPage() {
|
||||
const auth = useAuth();
|
||||
const { message } = App.useApp();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
if (auth.user) return <Navigate to="/" replace />;
|
||||
|
||||
const submit = async (values: { identifier: string; password: string; newPassword?: string }) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (auth.challengeToken) await auth.completePasswordChange(values.newPassword || '');
|
||||
else await auth.login(values.identifier, values.password);
|
||||
message.success('登录成功');
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '登录失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-brand">
|
||||
<img src="/logo.png" alt="恭学题库" />
|
||||
<Typography.Title level={1}>恭学题库 SaaS</Typography.Title>
|
||||
<Typography.Paragraph>平台运营与资源管理中心</Typography.Paragraph>
|
||||
</div>
|
||||
<Card className="login-card" variant="borderless">
|
||||
<Typography.Title level={3}>{auth.challengeToken ? '设置新密码' : '平台管理员登录'}</Typography.Title>
|
||||
{auth.challengeToken && <Alert type="warning" showIcon message="首次登录必须修改密码" className="login-alert" />}
|
||||
<Form layout="vertical" size="large" onFinish={submit} requiredMark={false}>
|
||||
{!auth.challengeToken && (
|
||||
<>
|
||||
<Form.Item name="identifier" label="账号" rules={[{ required: true, message: '请输入手机号或账号' }]}>
|
||||
<Input prefix={<UserOutlined />} autoComplete="username" placeholder="手机号或账号" />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" label="密码" rules={[{ required: true, message: '请输入密码' }]}>
|
||||
<Input.Password prefix={<LockOutlined />} autoComplete="current-password" placeholder="登录密码" />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
{auth.challengeToken && (
|
||||
<Form.Item name="newPassword" label="新密码" rules={[{ required: true }, { min: 8, message: '密码至少 8 位' }]}>
|
||||
<Input.Password prefix={<LockOutlined />} autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Button type="primary" htmlType="submit" block loading={submitting}>{auth.challengeToken ? '设置密码并登录' : '登录'}</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
322
Tiku.PlatformAdmin.Web/src/pages/QuestionBankPage.tsx
Normal file
322
Tiku.PlatformAdmin.Web/src/pages/QuestionBankPage.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
import {
|
||||
StopOutlined,
|
||||
CloudUploadOutlined,
|
||||
DeleteOutlined,
|
||||
DownloadOutlined,
|
||||
EditOutlined,
|
||||
FileAddOutlined,
|
||||
FolderAddOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
App,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Empty,
|
||||
Flex,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
List,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Progress,
|
||||
Radio,
|
||||
Select,
|
||||
Space,
|
||||
Splitter,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Tree,
|
||||
Typography,
|
||||
Upload,
|
||||
} from 'antd';
|
||||
import type { UploadProps } from 'antd';
|
||||
import type { DataNode } from 'antd/es/tree';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { platformRequest } from '../api/platform';
|
||||
import type { components } from '../api/schema.generated';
|
||||
|
||||
type Bank = components['schemas']['PlatformQuestionBankItem'];
|
||||
type BankNode = components['schemas']['PlatformQuestionBankNodeItem'];
|
||||
type Question = components['schemas']['PlatformQuestionItem'];
|
||||
type QuestionPage = components['schemas']['PlatformQuestionPage'];
|
||||
type ImportResult = components['schemas']['PlatformQuestionImportResult'];
|
||||
type UploadSignResult = components['schemas']['AssetUploadSignResult'];
|
||||
type UploadConfirmResult = components['schemas']['AssetUploadConfirmResult'];
|
||||
type UploadRequestOption = Parameters<NonNullable<UploadProps['customRequest']>>[0];
|
||||
|
||||
const nodeTypes = [
|
||||
{ value: 0, label: '分类' },
|
||||
{ value: 1, label: '科目' },
|
||||
{ value: 2, label: '章节' },
|
||||
{ value: 3, label: '试卷' },
|
||||
{ value: 4, label: '院校' },
|
||||
{ value: 5, label: '专业' },
|
||||
{ value: 6, label: '考试方向' },
|
||||
{ value: 7, label: '资源组' },
|
||||
{ value: 8, label: '自定义' },
|
||||
];
|
||||
const questionTypes = [
|
||||
['choice', '单选题'], ['multi_choice', '多选题'], ['true_false', '判断题'], ['short_answer', '简答题'],
|
||||
['fill_blank', '填空题'], ['reading', '阅读理解'], ['case_analysis', '案例分析'], ['programming', '编程题'],
|
||||
].map(([value, label]) => ({ value, label }));
|
||||
const statusLabels: Record<string, string> = { '0': '草稿', '1': '已发布', '2': '已归档', draft: '草稿', published: '已发布', archived: '已归档' };
|
||||
|
||||
function toTree(nodes: BankNode[]): DataNode[] {
|
||||
const children = new Map<string | null, BankNode[]>();
|
||||
for (const node of nodes) {
|
||||
const key = node.parentId || null;
|
||||
children.set(key, [...(children.get(key) || []), node]);
|
||||
}
|
||||
const build = (parentId: string | null): DataNode[] => (children.get(parentId) || [])
|
||||
.sort((a, b) => Number(a.sortOrder) - Number(b.sortOrder))
|
||||
.map((node) => ({
|
||||
key: node.id,
|
||||
title: <Flex justify="space-between" gap={8}><span>{node.name}</span><Badge count={Number(node.questionCount)} showZero color="#b9c4cf" /></Flex>,
|
||||
children: build(node.id),
|
||||
}));
|
||||
return build(null);
|
||||
}
|
||||
|
||||
function parseJson(value: string | undefined, fallback: unknown) {
|
||||
if (!value?.trim()) return fallback;
|
||||
return JSON.parse(value);
|
||||
}
|
||||
|
||||
function downloadJson(name: string, value: unknown) {
|
||||
const url = URL.createObjectURL(new Blob([JSON.stringify(value, null, 2)], { type: 'application/json' }));
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = name;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function QuestionBankPage() {
|
||||
const { message, modal } = App.useApp();
|
||||
const [banks, setBanks] = useState<Bank[]>([]);
|
||||
const [bankKeyword, setBankKeyword] = useState('');
|
||||
const [bankStatus, setBankStatus] = useState('active');
|
||||
const [bankId, setBankId] = useState('');
|
||||
const [nodes, setNodes] = useState<BankNode[]>([]);
|
||||
const [nodeId, setNodeId] = useState('');
|
||||
const [questions, setQuestions] = useState<Question[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [filters, setFilters] = useState({ keyword: '', type: '', status: '' });
|
||||
const [selectedQuestionIds, setSelectedQuestionIds] = useState<React.Key[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [bankDrawer, setBankDrawer] = useState<Bank | null | undefined>();
|
||||
const [nodeDrawer, setNodeDrawer] = useState<BankNode | null | undefined>();
|
||||
const [batchNodeOpen, setBatchNodeOpen] = useState(false);
|
||||
const [questionDrawer, setQuestionDrawer] = useState<Question | null | undefined>();
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
||||
const [bankForm] = Form.useForm();
|
||||
const [nodeForm] = Form.useForm();
|
||||
const [batchForm] = Form.useForm();
|
||||
const [questionForm] = Form.useForm();
|
||||
const [importForm] = Form.useForm();
|
||||
|
||||
const bank = banks.find((item) => item.id === bankId);
|
||||
const node = nodes.find((item) => item.id === nodeId);
|
||||
|
||||
const loadBanks = useCallback(async (status = bankStatus, keyword = bankKeyword) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await platformRequest<Bank[]>('GET', '/api/platform-admin/question-banks', { query: { keyword, status } });
|
||||
setBanks(result);
|
||||
setBankId((current) => result.some((item) => item.id === current) ? current : result[0]?.id || '');
|
||||
} catch (error) { message.error(error instanceof Error ? error.message : '题库加载失败'); }
|
||||
finally { setLoading(false); }
|
||||
}, [bankKeyword, bankStatus, message]);
|
||||
|
||||
const loadNodes = useCallback(async () => {
|
||||
if (!bankId) { setNodes([]); setNodeId(''); return; }
|
||||
try {
|
||||
const result = await platformRequest<BankNode[]>('GET', '/api/platform-admin/question-banks/{bankId}/nodes', { path: { bankId } });
|
||||
setNodes(result);
|
||||
setNodeId((current) => result.some((item) => item.id === current) ? current : '');
|
||||
} catch (error) { message.error(error instanceof Error ? error.message : '目录加载失败'); }
|
||||
}, [bankId, message]);
|
||||
|
||||
const loadQuestions = useCallback(async () => {
|
||||
if (!bankId) { setQuestions([]); setTotal(0); return; }
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await platformRequest<QuestionPage>('GET', '/api/platform-admin/question-banks/questions', {
|
||||
query: { questionBankId: bankId, contentNodeId: nodeId || undefined, ...filters, page, pageSize },
|
||||
});
|
||||
setQuestions(result.items);
|
||||
setTotal(Number(result.total));
|
||||
setSelectedQuestionIds([]);
|
||||
} catch (error) { message.error(error instanceof Error ? error.message : '题目加载失败'); }
|
||||
finally { setLoading(false); }
|
||||
}, [bankId, filters, message, nodeId, page, pageSize]);
|
||||
|
||||
useEffect(() => { void loadBanks(); }, []);
|
||||
useEffect(() => { void loadNodes(); setPage(1); }, [bankId]);
|
||||
useEffect(() => { void loadQuestions(); }, [loadQuestions]);
|
||||
|
||||
const openBank = (record?: Bank) => {
|
||||
setBankDrawer(record || null);
|
||||
queueMicrotask(() => bankForm.setFieldsValue({ id: record?.id, name: record?.name, metadata: JSON.stringify(record?.metadata || {}, null, 2) }));
|
||||
};
|
||||
const saveBank = async () => {
|
||||
const values = await bankForm.validateFields();
|
||||
try {
|
||||
const saved = await platformRequest<Bank>('PUT', '/api/platform-admin/question-banks', { body: { id: values.id || null, name: values.name, metadata: parseJson(values.metadata, {}) } });
|
||||
message.success('题库已保存'); setBankDrawer(undefined); await loadBanks(); setBankId(saved.id);
|
||||
} catch (error) { message.error(error instanceof Error ? error.message : '题库保存失败'); }
|
||||
};
|
||||
|
||||
const openNode = (record?: BankNode) => {
|
||||
if (!bankId) return message.warning('请先创建题库');
|
||||
setNodeDrawer(record || null);
|
||||
queueMicrotask(() => nodeForm.setFieldsValue({
|
||||
id: record?.id, name: record?.name, parentId: record?.parentId || null, nodeKey: record?.nodeKey,
|
||||
nodeType: record?.nodeType ?? 2, sortOrder: Number(record?.sortOrder || 0), isSelectable: record?.isSelectable ?? true,
|
||||
metadata: JSON.stringify(record?.metadata || {}, null, 2),
|
||||
}));
|
||||
};
|
||||
const saveNode = async () => {
|
||||
const values = await nodeForm.validateFields();
|
||||
try {
|
||||
await platformRequest('PUT', '/api/platform-admin/question-banks/nodes', { body: { ...values, questionBankId: bankId, parentId: values.parentId || null, nodeKey: values.nodeKey || null, metadata: parseJson(values.metadata, {}) } });
|
||||
message.success('目录节点已保存'); setNodeDrawer(undefined); await loadNodes();
|
||||
} catch (error) { message.error(error instanceof Error ? error.message : '目录保存失败'); }
|
||||
};
|
||||
const saveBatchNodes = async () => {
|
||||
const values = await batchForm.validateFields();
|
||||
const names = String(values.names).split(/\n|,/).map((item) => item.trim()).filter(Boolean);
|
||||
if (names.length > 100) return message.error('一次最多创建 100 个节点');
|
||||
await platformRequest('POST', '/api/platform-admin/question-banks/nodes/batch', { body: { questionBankId: bankId, parentId: values.parentId || null, nodeType: values.nodeType, names } });
|
||||
message.success(`已创建 ${names.length} 个节点`); setBatchNodeOpen(false); batchForm.resetFields(); await loadNodes();
|
||||
};
|
||||
|
||||
const openQuestion = (record?: Question) => {
|
||||
if (!bankId || (!nodeId && !record)) return message.warning('请先选择一个章节或试卷');
|
||||
setQuestionDrawer(record || null);
|
||||
queueMicrotask(() => questionForm.setFieldsValue({
|
||||
id: record?.id, type: record?.type || 'choice', typeLabel: record?.typeLabel, difficulty: record?.difficulty ? Number(record.difficulty) : 3,
|
||||
status: record ? String(record.status) : 'published', content: record?.content, answerText: record?.answerText, explanation: record?.explanation,
|
||||
options: JSON.stringify(record?.options || [], null, 2), correctOptionIndex: record?.correctOptionIndex == null ? null : Number(record.correctOptionIndex),
|
||||
correctOptionIndices: JSON.stringify(record?.correctOptionIndices || [], null, 2), tags: JSON.stringify(record?.tags || [], null, 2),
|
||||
subQuestions: JSON.stringify(record?.subQuestions || [], null, 2), codeLang: record?.codeLang, codeTemplate: record?.codeTemplate,
|
||||
mediaUrl: record?.mediaUrl,
|
||||
}));
|
||||
};
|
||||
const saveQuestion = async () => {
|
||||
const values = await questionForm.validateFields();
|
||||
try {
|
||||
await platformRequest('PUT', '/api/platform-admin/question-banks/questions', { body: {
|
||||
...values, id: values.id || null, questionBankId: bankId, contentNodeId: questionDrawer?.contentNodeId || nodeId,
|
||||
legacyId: questionDrawer?.legacyId || null, options: parseJson(values.options, []), correctOptionIndices: parseJson(values.correctOptionIndices, []),
|
||||
tags: parseJson(values.tags, []), subQuestions: parseJson(values.subQuestions, []), examMarkers: {}, sourceHash: null,
|
||||
} });
|
||||
message.success(questionDrawer ? '题目已更新并生成新版本' : '题目已创建'); setQuestionDrawer(undefined); await Promise.all([loadQuestions(), loadNodes()]);
|
||||
} catch (error) { message.error(error instanceof Error ? error.message : '题目保存失败'); }
|
||||
};
|
||||
|
||||
const archiveQuestions = () => modal.confirm({
|
||||
title: `归档 ${selectedQuestionIds.length} 道题目`, content: '归档后不会再作为正常题目展示。', okButtonProps: { danger: true },
|
||||
onOk: async () => { await platformRequest('POST', '/api/platform-admin/question-banks/questions/archive', { body: { questionIds: selectedQuestionIds } }); message.success('题目已归档'); await loadQuestions(); },
|
||||
});
|
||||
|
||||
const runImport = async (preview: boolean) => {
|
||||
const values = await importForm.validateFields();
|
||||
try {
|
||||
const payload = parseJson(values.payload, []);
|
||||
const result = await platformRequest<ImportResult>('POST', preview ? '/api/platform-admin/question-banks/imports/preview' : '/api/platform-admin/question-banks/imports', {
|
||||
body: { questionBankId: bankId, contentNodeId: values.format === 'simple' ? (values.contentNodeId || nodeId || null) : null, format: values.format, sourceName: values.sourceName || null, payload },
|
||||
});
|
||||
setImportResult(result);
|
||||
message.success(preview ? '预检完成' : '导入完成');
|
||||
if (!preview) await Promise.all([loadQuestions(), loadNodes(), loadBanks()]);
|
||||
} catch (error) { message.error(error instanceof Error ? error.message : '导入处理失败'); }
|
||||
};
|
||||
|
||||
const uploadAsset = async ({ file, onError, onSuccess, onProgress }: UploadRequestOption) => {
|
||||
const blob = file as File;
|
||||
try {
|
||||
onProgress?.({ percent: 10 });
|
||||
const sign = await platformRequest<UploadSignResult>('POST', '/api/platform-admin/question-banks/assets/upload-sign', { body: {
|
||||
contentNodeId: nodeId || null, fileName: blob.name, mimeType: blob.type || 'application/octet-stream', fileSizeBytes: blob.size,
|
||||
title: blob.name, category: 'question', assetType: 'image', visibility: 'public', isPublic: true, metadata: {},
|
||||
} });
|
||||
const headers = new Headers(sign.upload.headers);
|
||||
const uploaded = await fetch(sign.upload.url, { method: sign.upload.method || 'PUT', headers, body: blob });
|
||||
if (!uploaded.ok) throw new Error(`文件上传失败(HTTP ${uploaded.status})`);
|
||||
onProgress?.({ percent: 80 });
|
||||
const confirmed = await platformRequest<UploadConfirmResult>('POST', '/api/platform-admin/question-banks/assets/upload-confirm', { body: { assetId: sign.item.id, mimeType: blob.type, fileSizeBytes: blob.size } });
|
||||
questionForm.setFieldValue('mediaUrl', confirmed.item.cdnUrl || sign.upload.url.split('?')[0]);
|
||||
onProgress?.({ percent: 100 }); onSuccess?.(confirmed); message.success('资源上传成功');
|
||||
} catch (error) { onError?.(error as Error); message.error(error instanceof Error ? error.message : '资源上传失败'); }
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '题型', dataIndex: 'typeLabel', width: 100, render: (_: unknown, record: Question) => record.typeLabel || questionTypes.find((item) => item.value === record.type)?.label || record.type },
|
||||
{ title: '题干', dataIndex: 'content', ellipsis: true, render: (value: string) => value || <Typography.Text type="secondary">无文字题干</Typography.Text> },
|
||||
{ title: '难度', dataIndex: 'difficulty', width: 90, render: (value: number | string | null) => value ? `${value} 级` : '-' },
|
||||
{ title: '状态', dataIndex: 'status', width: 90, render: (value: string | number) => <Tag color={String(value) === '1' || value === 'published' ? 'green' : 'default'}>{statusLabels[String(value)] || String(value)}</Tag> },
|
||||
{ title: '版本', dataIndex: 'versionNo', width: 75, render: (value: number | string) => `v${value}` },
|
||||
{ title: '操作', key: 'actions', width: 80, fixed: 'right' as const, render: (_: unknown, record: Question) => <Button type="link" icon={<EditOutlined />} onClick={() => openQuestion(record)}>编辑</Button> },
|
||||
];
|
||||
|
||||
const treeData = useMemo(() => toTree(nodes), [nodes]);
|
||||
return (
|
||||
<div className="question-bank-page">
|
||||
<Flex justify="space-between" align="end" gap={16} wrap>
|
||||
<div><Typography.Title level={2}>公共题库</Typography.Title><Typography.Text type="secondary">维护题库目录、题目版本、批量导入与图片资源</Typography.Text></div>
|
||||
<Space><Button icon={<CloudUploadOutlined />} disabled={!bankId} onClick={() => { setImportOpen(true); setImportResult(null); queueMicrotask(() => importForm.setFieldsValue({ format: 'simple', contentNodeId: nodeId, sourceName: '', payload: '[]' })); }}>导入题目</Button><Button type="primary" icon={<PlusOutlined />} onClick={() => openQuestion()}>录入题目</Button></Space>
|
||||
</Flex>
|
||||
<Splitter className="question-workspace">
|
||||
<Splitter.Panel defaultSize="22%" min="260px" max="360px">
|
||||
<div className="workspace-panel bank-panel">
|
||||
<Flex gap={8}><Input allowClear value={bankKeyword} onChange={(event) => setBankKeyword(event.target.value)} onPressEnter={() => void loadBanks()} prefix={<SearchOutlined />} placeholder="搜索题库" /><Button icon={<PlusOutlined />} onClick={() => openBank()} /></Flex>
|
||||
<Select value={bankStatus} onChange={(value) => { setBankStatus(value); void loadBanks(value); }} options={[{ value: 'active', label: '正常题库' }, { value: 'archived', label: '已归档' }, { value: 'all', label: '全部题库' }]} />
|
||||
<List loading={loading} dataSource={banks} locale={{ emptyText: '暂无题库' }} renderItem={(item) => <List.Item className={item.id === bankId ? 'selected-business-item' : ''} onClick={() => setBankId(item.id)} actions={[<Button key="edit" type="text" icon={<EditOutlined />} onClick={(event) => { event.stopPropagation(); openBank(item); }} />]}><List.Item.Meta title={item.name} description={`${item.nodeCount} 个节点 · ${item.questionCount} 道题`} /></List.Item>} />
|
||||
</div>
|
||||
</Splitter.Panel>
|
||||
<Splitter.Panel defaultSize="24%" min="270px" max="420px">
|
||||
<div className="workspace-panel node-panel">
|
||||
<Flex justify="space-between" align="center"><Typography.Title level={5}>内容目录</Typography.Title><Space size={2}><Button type="text" icon={<FolderAddOutlined />} disabled={!bankId} onClick={() => openNode()} /><Button type="text" icon={<FileAddOutlined />} disabled={!bankId} onClick={() => { setBatchNodeOpen(true); queueMicrotask(() => batchForm.setFieldsValue({ parentId: nodeId || null, nodeType: 2 })); }} /><Button type="text" icon={<ReloadOutlined />} onClick={() => void loadNodes()} /></Space></Flex>
|
||||
{treeData.length ? <Tree blockNode defaultExpandAll selectedKeys={nodeId ? [nodeId] : []} treeData={treeData} onSelect={(keys) => { setNodeId(String(keys[0] || '')); setPage(1); }} titleRender={(treeNode) => <div onDoubleClick={() => { const record = nodes.find((item) => item.id === treeNode.key); if (record) openNode(record); }}>{treeNode.title as React.ReactNode}</div>} /> : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="选择题库后维护目录" />}
|
||||
{node && <Card size="small" className="node-detail"><Flex justify="space-between"><div><Typography.Text strong>{node.name}</Typography.Text><br /><Typography.Text type="secondary">{nodeTypes.find((item) => item.value === node.nodeType)?.label} · 排序 {node.sortOrder}</Typography.Text></div><Space><Button size="small" icon={<EditOutlined />} onClick={() => openNode(node)}>编辑</Button><Popconfirm title="确认归档此节点?" onConfirm={async () => { await platformRequest('POST', '/api/platform-admin/question-banks/nodes/{nodeId}/archive', { path: { nodeId } }); message.success('节点已归档'); await loadNodes(); }}><Button size="small" danger icon={<StopOutlined />} /></Popconfirm></Space></Flex></Card>}
|
||||
</div>
|
||||
</Splitter.Panel>
|
||||
<Splitter.Panel min="520px">
|
||||
<div className="workspace-panel question-panel">
|
||||
<Flex justify="space-between" gap={8} wrap><Space wrap><Input allowClear placeholder="搜索题干" value={filters.keyword} onChange={(event) => setFilters((value) => ({ ...value, keyword: event.target.value }))} onPressEnter={() => { setPage(1); void loadQuestions(); }} /><Select allowClear placeholder="题型" value={filters.type || undefined} options={questionTypes} onChange={(value) => { setFilters((old) => ({ ...old, type: value || '' })); setPage(1); }} /><Select allowClear placeholder="状态" value={filters.status || undefined} options={[{ value: 'draft', label: '草稿' }, { value: 'published', label: '已发布' }, { value: 'archived', label: '已归档' }]} onChange={(value) => { setFilters((old) => ({ ...old, status: value || '' })); setPage(1); }} /></Space><Space><Button icon={<DownloadOutlined />} disabled={!questions.length} onClick={() => downloadJson(`${bank?.name || '公共题库'}-${node?.name || '全部题目'}.json`, questions)}>导出本页</Button><Button danger icon={<DeleteOutlined />} disabled={!selectedQuestionIds.length} onClick={archiveQuestions}>批量归档</Button></Space></Flex>
|
||||
<Table<Question> rowKey="id" loading={loading} dataSource={questions} columns={columns} scroll={{ x: 760 }} rowSelection={{ selectedRowKeys: selectedQuestionIds, preserveSelectedRowKeys: true, onChange: setSelectedQuestionIds }} pagination={{ current: page, pageSize, total, showSizeChanger: true, showTotal: (value) => `共 ${value} 道题`, onChange: (next, size) => { setPage(next); setPageSize(size); } }} locale={{ emptyText: nodeId ? '当前节点暂无题目' : '当前题库暂无题目' }} />
|
||||
</div>
|
||||
</Splitter.Panel>
|
||||
</Splitter>
|
||||
|
||||
<Drawer title={bankDrawer?.id ? '编辑题库' : '新建题库'} width={520} open={bankDrawer !== undefined} onClose={() => setBankDrawer(undefined)} extra={<Button type="primary" onClick={() => void saveBank()}>保存</Button>}>
|
||||
<Form form={bankForm} layout="vertical"><Form.Item name="id" hidden><Input /></Form.Item><Form.Item name="name" label="题库名称" rules={[{ required: true }]}><Input maxLength={200} /></Form.Item><Form.Item name="metadata" label="扩展配置(JSON)" rules={[{ validator: async (_, value) => { parseJson(value, {}); } }]}><Input.TextArea rows={8} /></Form.Item>{bankDrawer?.id && <Popconfirm title="确认归档此题库?" onConfirm={async () => { await platformRequest('POST', '/api/platform-admin/question-banks/{bankId}/archive', { path: { bankId: bankDrawer.id } }); setBankDrawer(undefined); await loadBanks(); }}><Button danger icon={<StopOutlined />}>归档题库</Button></Popconfirm>}</Form>
|
||||
</Drawer>
|
||||
<Drawer title={nodeDrawer?.id ? '编辑目录节点' : '新增目录节点'} width={560} open={nodeDrawer !== undefined} onClose={() => setNodeDrawer(undefined)} extra={<Button type="primary" onClick={() => void saveNode()}>保存</Button>}>
|
||||
<Form form={nodeForm} layout="vertical"><Form.Item name="id" hidden><Input /></Form.Item><Form.Item name="name" label="名称" rules={[{ required: true }]}><Input /></Form.Item><Form.Item name="parentId" label="上级节点"><Select allowClear options={nodes.filter((item) => item.id !== nodeDrawer?.id).map((item) => ({ value: item.id, label: item.name }))} /></Form.Item><Flex gap={12}><Form.Item name="nodeType" label="节点类型" rules={[{ required: true }]} className="flex-field"><Select options={nodeTypes} /></Form.Item><Form.Item name="sortOrder" label="排序" className="flex-field"><InputNumber min={0} /></Form.Item></Flex><Form.Item name="nodeKey" label="业务编码"><Input /></Form.Item><Form.Item name="isSelectable" label="可承载题目"><Radio.Group options={[{ value: true, label: '是' }, { value: false, label: '否' }]} /></Form.Item><Form.Item name="metadata" label="扩展配置(JSON)"><Input.TextArea rows={5} /></Form.Item></Form>
|
||||
</Drawer>
|
||||
<Modal title="批量创建章节或试卷" open={batchNodeOpen} onCancel={() => setBatchNodeOpen(false)} onOk={() => void saveBatchNodes()} okText="创建">
|
||||
<Form form={batchForm} layout="vertical"><Form.Item name="parentId" label="上级节点"><Select allowClear options={nodes.map((item) => ({ value: item.id, label: item.name }))} /></Form.Item><Form.Item name="nodeType" label="节点类型" rules={[{ required: true }]}><Select options={nodeTypes.filter((item) => [2, 3].includes(item.value))} /></Form.Item><Form.Item name="names" label="名称列表" extra="每行一个,最多 100 个" rules={[{ required: true }]}><Input.TextArea rows={9} placeholder={'第一章 基础知识\n第二章 核心方法'} /></Form.Item></Form>
|
||||
</Modal>
|
||||
<Drawer title={questionDrawer?.id ? `编辑题目 · v${questionDrawer.versionNo}` : '录入题目'} width={760} open={questionDrawer !== undefined} onClose={() => setQuestionDrawer(undefined)} extra={<Button type="primary" onClick={() => void saveQuestion()}>保存题目</Button>}>
|
||||
<Form form={questionForm} layout="vertical"><Form.Item name="id" hidden><Input /></Form.Item><Flex gap={12}><Form.Item name="type" label="题型" rules={[{ required: true }]} className="flex-field"><Select options={questionTypes} /></Form.Item><Form.Item name="difficulty" label="难度" className="flex-field"><Select options={[1,2,3,4,5].map((value) => ({ value, label: `${value} 级` }))} /></Form.Item><Form.Item name="status" label="状态" className="flex-field"><Select options={[{ value: 'draft', label: '草稿' }, { value: 'published', label: '已发布' }]} /></Form.Item></Flex><Form.Item name="typeLabel" label="题型显示名"><Input /></Form.Item><Form.Item name="content" label="题干" rules={[{ required: true }]}><Input.TextArea rows={5} /></Form.Item><Tabs items={[{ key: 'answer', label: '答案与解析', children: <><Form.Item name="options" label="选项(JSON 数组)"><Input.TextArea rows={5} /></Form.Item><Form.Item name="correctOptionIndex" label="单选答案索引"><InputNumber min={0} /></Form.Item><Form.Item name="correctOptionIndices" label="多选答案索引(JSON 数组)"><Input /></Form.Item><Form.Item name="answerText" label="文字答案"><Input.TextArea rows={3} /></Form.Item><Form.Item name="explanation" label="解析"><Input.TextArea rows={5} /></Form.Item></> }, { key: 'advanced', label: '扩展内容', children: <><Form.Item name="tags" label="标签(JSON 数组)"><Input /></Form.Item><Form.Item name="subQuestions" label="子题(JSON 数组)"><Input.TextArea rows={5} /></Form.Item><Flex gap={12}><Form.Item name="codeLang" label="代码语言" className="flex-field"><Input /></Form.Item><Form.Item name="mediaUrl" label="图片或视频地址" className="flex-field"><Input /></Form.Item></Flex><Form.Item name="codeTemplate" label="代码模板"><Input.TextArea rows={5} /></Form.Item><Upload customRequest={uploadAsset} showUploadList={false} accept="image/*"><Button icon={<CloudUploadOutlined />}>上传题目图片</Button></Upload></> }]} /></Form>
|
||||
</Drawer>
|
||||
<Modal title="题目 JSON 导入" width={900} open={importOpen} onCancel={() => setImportOpen(false)} footer={<Flex justify="space-between"><Upload beforeUpload={async (file) => { importForm.setFieldsValue({ sourceName: file.name, payload: await file.text() }); return false; }} showUploadList={false} accept=".json,application/json"><Button icon={<CloudUploadOutlined />}>读取 JSON 文件</Button></Upload><Space><Button onClick={() => void runImport(true)}>预检</Button><Button type="primary" onClick={() => void runImport(false)}>确认导入</Button></Space></Flex>}>
|
||||
<Form form={importForm} layout="vertical"><Flex gap={12}><Form.Item name="format" label="导入格式" className="flex-field"><Select options={[{ value: 'simple', label: '普通题目数组' }, { value: 'structured-v2', label: '科目/章节结构化数据' }]} /></Form.Item><Form.Item name="contentNodeId" label="目标章节或试卷" className="flex-field"><Select allowClear options={nodes.map((item) => ({ value: item.id, label: item.name }))} /></Form.Item><Form.Item name="sourceName" label="来源名称" className="flex-field"><Input /></Form.Item></Flex><Form.Item name="payload" label="JSON 内容" rules={[{ required: true }, { validator: async (_, value) => { parseJson(value, []); } }]}><Input.TextArea rows={14} className="code-editor" /></Form.Item></Form>
|
||||
{importResult && <Card size="small" title="处理结果"><Progress percent={importResult.detail.job.totalCount ? Math.round(Number(importResult.detail.job.validCount) / Number(importResult.detail.job.totalCount) * 100) : 0} status={Number(importResult.detail.job.errorCount) ? 'exception' : 'success'} /><Descriptions size="small" column={4} items={[{ key: 'total', label: '总数', children: importResult.detail.job.totalCount }, { key: 'valid', label: '有效', children: importResult.detail.job.validCount }, { key: 'errors', label: '错误', children: importResult.detail.job.errorCount }, { key: 'inserted', label: '新增', children: importResult.insertedQuestionCount }]} />{importResult.detail.issues.length > 0 && <Table size="small" pagination={false} dataSource={importResult.detail.issues} rowKey="id" columns={[{ title: '行号', dataIndex: 'rowNo' }, { title: '字段', dataIndex: 'fieldPath' }, { title: '级别', dataIndex: 'severity' }, { title: '问题', dataIndex: 'message' }]} />}</Card>}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
Tiku.PlatformAdmin.Web/src/pages/WorkspacePage.tsx
Normal file
115
Tiku.PlatformAdmin.Web/src/pages/WorkspacePage.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { ApiOutlined, PlayCircleOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { App, Button, Card, Drawer, Empty, Flex, Form, List, Modal, Segmented, Space, Tag, Typography } from 'antd';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { apiRequest } from '../api/http';
|
||||
import { groupForOperation, platformGroups, type PlatformGroupKey } from '../api/groups';
|
||||
import { platformOperations } from '../api/platform-operations.generated';
|
||||
import type { OperationInput, PlatformOperation } from '../api/types';
|
||||
import { OperationForm, normalizeOperationInput } from '../components/OperationForm';
|
||||
import { ResponseView } from '../components/ResponseView';
|
||||
|
||||
const methodColors: Record<string, string> = { GET: 'blue', POST: 'green', PUT: 'orange', PATCH: 'gold', DELETE: 'red' };
|
||||
|
||||
export function WorkspacePage({ groupKey }: { groupKey: PlatformGroupKey }) {
|
||||
const group = platformGroups.find((item) => item.key === groupKey)!;
|
||||
const operations = useMemo(() => platformOperations.filter((operation) => groupForOperation(operation) === groupKey), [groupKey]);
|
||||
const [selectedId, setSelectedId] = useState(operations[0]?.id || '');
|
||||
const [payload, setPayload] = useState<unknown>(undefined);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [methodFilter, setMethodFilter] = useState<string>('全部');
|
||||
const [form] = Form.useForm<OperationInput>();
|
||||
const { message, modal } = App.useApp();
|
||||
const selected = operations.find((operation) => operation.id === selectedId) || operations[0];
|
||||
const visibleOperations = methodFilter === '全部' ? operations : operations.filter((operation) => operation.method === methodFilter);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedId(operations[0]?.id || '');
|
||||
setPayload(undefined);
|
||||
}, [operations]);
|
||||
|
||||
const execute = async (operation: PlatformOperation, values: OperationInput) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await apiRequest(operation, normalizeOperationInput(operation, values));
|
||||
setPayload(result);
|
||||
setDrawerOpen(false);
|
||||
message.success(`${operation.summary}完成`);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '接口调用失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!selected) return;
|
||||
const values = await form.validateFields();
|
||||
if (selected.method !== 'GET') {
|
||||
modal.confirm({
|
||||
title: `确认执行:${selected.summary}`,
|
||||
content: `将调用 ${selected.method} ${selected.path}`,
|
||||
okText: '确认执行',
|
||||
cancelText: '取消',
|
||||
okButtonProps: { danger: /archive|disable|status/.test(selected.path) },
|
||||
onOk: () => execute(selected, values),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await execute(selected, values);
|
||||
};
|
||||
|
||||
const chooseOperation = (operation: PlatformOperation) => {
|
||||
setSelectedId(operation.id);
|
||||
setPayload(undefined);
|
||||
form.resetFields();
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
if (!selected) return <Empty description="当前页面没有后端接口" />;
|
||||
return (
|
||||
<div className="workspace-page">
|
||||
<Flex justify="space-between" align="end" gap={16} wrap>
|
||||
<div>
|
||||
<Typography.Title level={2}>{group.label}</Typography.Title>
|
||||
<Typography.Text type="secondary">{group.description} · {operations.length} 个后端接口</Typography.Text>
|
||||
</div>
|
||||
<Segmented value={methodFilter} onChange={(value) => setMethodFilter(String(value))} options={['全部', ...new Set(operations.map((item) => item.method))]} />
|
||||
</Flex>
|
||||
<div className="workspace-grid">
|
||||
<Card className="operation-list-card" title="接口操作" extra={<ApiOutlined />}>
|
||||
<List
|
||||
dataSource={visibleOperations}
|
||||
renderItem={(operation) => (
|
||||
<List.Item className={operation.id === selected.id ? 'operation-active' : ''} onClick={() => chooseOperation(operation)}>
|
||||
<List.Item.Meta
|
||||
title={<Space><Tag color={methodColors[operation.method]}>{operation.method}</Tag><span>{operation.summary}</span></Space>}
|
||||
description={<Typography.Text type="secondary" ellipsis>{operation.path}</Typography.Text>}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
<Card
|
||||
className="response-card"
|
||||
title={payload === undefined ? '执行结果' : selected.summary}
|
||||
extra={payload !== undefined && <Button icon={<ReloadOutlined />} onClick={() => chooseOperation(selected)}>重新执行</Button>}
|
||||
>
|
||||
<ResponseView payload={payload} />
|
||||
</Card>
|
||||
</div>
|
||||
<Drawer
|
||||
title={selected.summary}
|
||||
width={560}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
extra={<Tag color={methodColors[selected.method]}>{selected.method}</Tag>}
|
||||
footer={<Flex justify="end" gap={8}><Button onClick={() => setDrawerOpen(false)}>取消</Button><Button type="primary" icon={<PlayCircleOutlined />} loading={loading} onClick={() => void submit()}>执行</Button></Flex>}
|
||||
>
|
||||
<Typography.Paragraph type="secondary">{selected.description}</Typography.Paragraph>
|
||||
<Typography.Paragraph code copyable>{selected.path}</Typography.Paragraph>
|
||||
<Form form={form} layout="vertical" requiredMark="optional"><OperationForm operation={selected} /></Form>
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { platformOperations } from '../api/platform-operations.generated';
|
||||
import { businessPageForOperation } from './business-definitions';
|
||||
|
||||
describe('平台业务页面覆盖', () => {
|
||||
it('除经营概览外,每个 OpenAPI 平台接口只属于一个业务页面', () => {
|
||||
const operations = platformOperations.filter((operation) => operation.path !== '/api/platform-admin/overview');
|
||||
expect(operations.map((operation) => [operation.id, businessPageForOperation(operation).length])).toEqual(
|
||||
operations.map((operation) => [operation.id, 1]),
|
||||
);
|
||||
});
|
||||
});
|
||||
30
Tiku.PlatformAdmin.Web/src/pages/business-definitions.ts
Normal file
30
Tiku.PlatformAdmin.Web/src/pages/business-definitions.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { PlatformOperation } from '../api/types';
|
||||
|
||||
export interface BusinessPageDefinition {
|
||||
key: string;
|
||||
title: string;
|
||||
description: string;
|
||||
eyebrow: string;
|
||||
matches(operation: PlatformOperation): boolean;
|
||||
}
|
||||
|
||||
const starts = (operation: PlatformOperation, prefix: string) => operation.path.startsWith(prefix);
|
||||
|
||||
export const businessPages: readonly BusinessPageDefinition[] = [
|
||||
{ key: 'tenants', eyebrow: '租户管理', title: '租户列表与生命周期', description: '管理租户主体、域名、业务状态、账务状态与开票资料。', matches: (op) => starts(op, '/api/platform-admin/tenants') || starts(op, '/api/platform-admin/domains') },
|
||||
{ key: 'subscriptions', eyebrow: 'SaaS 账务', title: '套餐与订阅', description: '维护 SaaS 功能、限额、商品、版本与租户订阅。', matches: (op) => starts(op, '/api/platform-admin/saas/catalog') || starts(op, '/api/platform-admin/saas/features') || starts(op, '/api/platform-admin/saas/feature-limits') || starts(op, '/api/platform-admin/saas/offerings') || starts(op, '/api/platform-admin/saas/offering-versions') || starts(op, '/api/platform-admin/saas/tenant-feature-overrides') || starts(op, '/api/platform-admin/saas/subscriptions') },
|
||||
{ key: 'billing', eyebrow: 'SaaS 账务', title: '服务费账单与交易', description: '集中查看平台订单、退款和服务费账单。', matches: (op) => starts(op, '/api/platform-admin/saas/orders') || starts(op, '/api/platform-admin/saas/refunds') || op.path === '/api/platform-admin/saas/invoices' },
|
||||
{ key: 'usage', eyebrow: 'SaaS 账务', title: '用量与超额计费', description: '按租户和账期查看席位、题量、存储等 SaaS 用量。', matches: (op) => starts(op, '/api/platform-admin/saas/usage') },
|
||||
{ key: 'dunning', eyebrow: 'SaaS 账务', title: '收款、逾期与催缴', description: '处理人工收款、账单提醒、催缴渠道与失败事件重试。', matches: (op) => starts(op, '/api/platform-admin/saas/payments') || starts(op, '/api/platform-admin/saas/dunning') || starts(op, '/api/platform-admin/saas/invoices/reminders') },
|
||||
{ key: 'question-banks', eyebrow: '平台资产', title: '公共题库工作台', description: '维护题库、内容结构、题目版本、批量导入与资源上传。', matches: (op) => starts(op, '/api/platform-admin/question-banks') },
|
||||
{ key: 'crm', eyebrow: '客户服务', title: 'CRM 接入与线索队列', description: '管理租户 CRM 配置、线索推送队列、失败重试与投递日志。', matches: (op) => starts(op, '/api/platform-admin/tenant-capabilities/crm') },
|
||||
{ key: 'sms', eyebrow: '消息服务', title: '短信渠道、模板与发送日志', description: '管理租户短信渠道和模板审核,并检索短信发送记录。', matches: (op) => starts(op, '/api/platform-admin/tenant-capabilities/sms') },
|
||||
{ key: 'payments', eyebrow: '支付服务', title: '支付应用、渠道与事件', description: '管理平台和租户支付应用、支付渠道、回调事件及返佣汇总。', matches: (op) => starts(op, '/api/platform-admin/payment-settings') || starts(op, '/api/platform-admin/tenant-capabilities/payments') },
|
||||
{ key: 'staff', eyebrow: '安全治理', title: '平台员工与权限', description: '管理平台员工、角色、权限绑定与账号状态。', matches: (op) => starts(op, '/api/platform-admin/staff') || starts(op, '/api/backoffice/platform') },
|
||||
{ key: 'audit', eyebrow: '安全治理', title: '平台审计日志', description: '检索跨租户敏感操作、账务变更与权限事件。', matches: (op) => starts(op, '/api/platform-admin/audit-logs') },
|
||||
{ key: 'alerts', eyebrow: '安全治理', title: '审计告警与处理', description: '按开放、确认、解决或忽略状态处理平台安全告警。', matches: (op) => starts(op, '/api/platform-admin/audit-alerts') },
|
||||
];
|
||||
|
||||
export function businessPageForOperation(operation: PlatformOperation) {
|
||||
return businessPages.filter((page) => page.matches(operation));
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { platformOperations } from '../api/platform-operations.generated';
|
||||
|
||||
const expected = [
|
||||
['GET', '/api/platform-admin/question-banks'],
|
||||
['PUT', '/api/platform-admin/question-banks'],
|
||||
['POST', '/api/platform-admin/question-banks/{bankId}/archive'],
|
||||
['GET', '/api/platform-admin/question-banks/{bankId}/nodes'],
|
||||
['PUT', '/api/platform-admin/question-banks/nodes'],
|
||||
['POST', '/api/platform-admin/question-banks/nodes/batch'],
|
||||
['POST', '/api/platform-admin/question-banks/nodes/{nodeId}/archive'],
|
||||
['GET', '/api/platform-admin/question-banks/questions'],
|
||||
['PUT', '/api/platform-admin/question-banks/questions'],
|
||||
['POST', '/api/platform-admin/question-banks/questions/archive'],
|
||||
['POST', '/api/platform-admin/question-banks/imports/preview'],
|
||||
['POST', '/api/platform-admin/question-banks/imports'],
|
||||
['GET', '/api/platform-admin/question-banks/imports/{jobId}'],
|
||||
['POST', '/api/platform-admin/question-banks/assets/upload-sign'],
|
||||
['POST', '/api/platform-admin/question-banks/assets/upload-confirm'],
|
||||
] as const;
|
||||
|
||||
describe('公共题库页面接口契约', () => {
|
||||
it('页面使用的所有题库操作都存在于后端 OpenAPI', () => {
|
||||
const keys = new Set(platformOperations.map((operation) => `${operation.method} ${operation.path}`));
|
||||
expect(expected.filter(([method, path]) => !keys.has(`${method} ${path}`))).toEqual([]);
|
||||
});
|
||||
});
|
||||
30
Tiku.PlatformAdmin.Web/src/router.tsx
Normal file
30
Tiku.PlatformAdmin.Web/src/router.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { createBrowserRouter, Navigate } from 'react-router';
|
||||
import { AppLayout } from './layout/AppLayout';
|
||||
import { DashboardPage } from './pages/DashboardPage';
|
||||
import { LoginPage } from './pages/LoginPage';
|
||||
import { BusinessPage } from './pages/BusinessPage';
|
||||
import { QuestionBankPage } from './pages/QuestionBankPage';
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{ path: '/login', element: <LoginPage /> },
|
||||
{
|
||||
path: '/',
|
||||
element: <AppLayout />,
|
||||
children: [
|
||||
{ index: true, element: <DashboardPage /> },
|
||||
{ path: 'tenants', element: <BusinessPage pageKey="tenants" /> },
|
||||
{ path: 'subscriptions', element: <BusinessPage pageKey="subscriptions" /> },
|
||||
{ path: 'billing', element: <BusinessPage pageKey="billing" /> },
|
||||
{ path: 'usage', element: <BusinessPage pageKey="usage" /> },
|
||||
{ path: 'dunning', element: <BusinessPage pageKey="dunning" /> },
|
||||
{ path: 'question-banks', element: <QuestionBankPage /> },
|
||||
{ path: 'crm', element: <BusinessPage pageKey="crm" /> },
|
||||
{ path: 'sms', element: <BusinessPage pageKey="sms" /> },
|
||||
{ path: 'payments', element: <BusinessPage pageKey="payments" /> },
|
||||
{ path: 'staff', element: <BusinessPage pageKey="staff" /> },
|
||||
{ path: 'audit', element: <BusinessPage pageKey="audit" /> },
|
||||
{ path: 'alerts', element: <BusinessPage pageKey="alerts" /> },
|
||||
],
|
||||
},
|
||||
{ path: '*', element: <Navigate to="/" replace /> },
|
||||
]);
|
||||
78
Tiku.PlatformAdmin.Web/src/styles.css
Normal file
78
Tiku.PlatformAdmin.Web/src/styles.css
Normal file
@@ -0,0 +1,78 @@
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #root { min-width: 320px; min-height: 100%; margin: 0; }
|
||||
body { background: #f3f6fa; }
|
||||
.full-screen-loading { min-height: 100vh; display: grid; place-items: center; }
|
||||
.app-shell { min-height: 100vh; }
|
||||
.app-sider { position: sticky !important; top: 0; height: 100vh; overflow: hidden; border-right: 1px solid #e8edf3; box-shadow: 8px 0 28px rgba(24, 55, 88, .035); display: flex; flex-direction: column; }
|
||||
.app-sider .ant-layout-sider-children { display: flex; flex-direction: column; height: 100%; }
|
||||
.sider-brand { height: 76px; display: flex; align-items: center; gap: 12px; padding: 14px 17px; white-space: nowrap; overflow: hidden; border-bottom: 1px solid #eef2f6; }
|
||||
.brand-mark { width: 42px; height: 42px; flex: 0 0 42px; border-radius: 13px; overflow: hidden; box-shadow: 0 6px 16px rgba(28, 91, 175, .22); }
|
||||
.brand-mark img { width: 100%; height: 100%; display: block; object-fit: cover; }
|
||||
.brand-copy { min-width: 0; display: flex; flex-direction: column; line-height: 1.2; }
|
||||
.brand-copy strong { color: #172b4d; font-size: 16px; letter-spacing: .02em; }
|
||||
.brand-copy span { color: #8b98a8; font-size: 11px; margin-top: 5px; font-weight: 500; letter-spacing: .08em; }
|
||||
.sider-navigation { flex: 1; min-height: 0; overflow: auto; padding: 12px 10px; }
|
||||
.sider-navigation .ant-menu { border-inline-end: 0 !important; background: transparent; }
|
||||
.sider-navigation .ant-menu-item-group-title { padding: 15px 12px 7px !important; color: #9aa6b5 !important; font-size: 11px !important; font-weight: 600; letter-spacing: .09em; }
|
||||
.sider-navigation .ant-menu-item { height: 42px; line-height: 42px; margin: 3px 0; border-radius: 9px; color: #526173; font-weight: 500; }
|
||||
.sider-navigation .ant-menu-item .anticon { color: #7c8998; font-size: 17px; }
|
||||
.sider-navigation .ant-menu-item:hover { color: #1f66c2 !important; background: #f3f7fc !important; }
|
||||
.sider-navigation .ant-menu-item-selected { color: #165eb8 !important; background: #eaf3ff !important; font-weight: 600; }
|
||||
.sider-navigation .ant-menu-item-selected::after { display: none; }
|
||||
.sider-navigation .ant-menu-item-selected .anticon { color: #1d6fd1; }
|
||||
.ant-layout-sider-collapsed .sider-brand { padding-inline: 17px; }
|
||||
.ant-layout-sider-collapsed .sider-navigation { padding-inline: 10px; }
|
||||
.ant-layout-sider-collapsed .ant-menu-item-group-title { height: 12px; padding: 6px 0 !important; font-size: 0 !important; }
|
||||
.sider-footer { padding: 10px 12px 14px; border-top: 1px solid #eef2f6; }
|
||||
.sider-footer .ant-btn { height: 40px; color: #748294; text-align: left; border-radius: 9px; }
|
||||
.sider-footer .ant-btn:hover { color: #1f66c2 !important; background: #f3f7fc !important; }
|
||||
.ant-layout-sider-collapsed .sider-footer .ant-btn { text-align: center; }
|
||||
.app-header { height: 72px; padding: 0 24px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #e7ecf2; position: sticky; top: 0; z-index: 10; background: rgba(255,255,255,.92); backdrop-filter: blur(12px); }
|
||||
.header-page-title { color: #26374a; font-size: 16px; font-weight: 600; }
|
||||
.user-menu { cursor: pointer; padding: 8px; border-radius: 10px; }
|
||||
.user-menu:hover { background: #f5f7fa; }
|
||||
.user-role { font-size: 12px; }
|
||||
.app-content { padding: 28px; min-width: 0; }
|
||||
.business-page, .question-bank-page { display: flex; flex-direction: column; gap: 20px; min-width: 0; }
|
||||
.business-page h2, .question-bank-page h2 { margin: 0 !important; }
|
||||
.business-card { min-height: 520px; }
|
||||
.business-filter { margin-bottom: 18px; gap: 8px; }
|
||||
.question-workspace { height: calc(100vh - 190px); min-height: 640px; background: white; border: 1px solid #e6ebf0; border-radius: 12px; overflow: hidden; }
|
||||
.workspace-panel { height: 100%; padding: 16px; overflow: auto; }
|
||||
.workspace-panel + .workspace-panel { border-left: 1px solid #eef1f4; }
|
||||
.bank-panel { display: flex; flex-direction: column; gap: 12px; }
|
||||
.bank-panel .ant-list { flex: 1; overflow: auto; }
|
||||
.bank-panel .ant-list-item { padding: 12px; border-radius: 8px; cursor: pointer; }
|
||||
.bank-panel .ant-list-item:hover, .bank-panel .selected-business-item { background: #edf5fc; }
|
||||
.bank-panel .ant-list-item-action { margin-inline-start: 4px; }
|
||||
.node-panel { display: flex; flex-direction: column; gap: 10px; }
|
||||
.node-panel h5 { margin: 0 !important; }
|
||||
.node-panel .ant-tree { flex: 1; overflow: auto; }
|
||||
.node-panel .ant-tree-node-content-wrapper { min-width: 0; }
|
||||
.node-panel .ant-tree-title { display: block; min-width: 150px; }
|
||||
.node-detail { margin-top: auto; background: #f8fafc; }
|
||||
.question-panel { display: flex; flex-direction: column; gap: 16px; }
|
||||
.question-panel > .ant-table-wrapper { min-height: 0; }
|
||||
.flex-field { flex: 1; min-width: 0; }
|
||||
.flex-field .ant-input-number { width: 100%; }
|
||||
.code-editor textarea, textarea.code-editor { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
|
||||
.workspace-page, .dashboard-page { display: flex; flex-direction: column; gap: 22px; }
|
||||
.workspace-page h2, .dashboard-page h2 { margin: 0 !important; }
|
||||
.workspace-grid { display: grid; grid-template-columns: minmax(340px, 0.8fr) minmax(520px, 1.6fr); gap: 18px; align-items: start; }
|
||||
.operation-list-card .ant-card-body { padding: 0; max-height: calc(100vh - 245px); overflow: auto; }
|
||||
.operation-list-card .ant-list-item { padding: 14px 18px; cursor: pointer; transition: background .2s; }
|
||||
.operation-list-card .ant-list-item:hover, .operation-list-card .operation-active { background: #eef5fc; }
|
||||
.operation-list-card .ant-list-item-meta-title { margin-bottom: 6px !important; }
|
||||
.response-card { min-height: 460px; }
|
||||
.metric-card { border: 0; box-shadow: 0 6px 24px rgba(31, 62, 92, .06); }
|
||||
.dashboard-section-title { margin: 8px 0 -4px !important; }
|
||||
.portal-card { height: 100%; }
|
||||
.portal-card .ant-btn { padding: 0; }
|
||||
.login-page { min-height: 100vh; display: grid; grid-template-columns: minmax(420px, 1.2fr) minmax(420px, .8fr); background: linear-gradient(135deg, #0c3158 0%, #1f659f 55%, #eaf2f8 55%); }
|
||||
.login-brand { color: white; display: flex; flex-direction: column; justify-content: center; padding: 10vw; }
|
||||
.login-brand img { width: 88px; height: 88px; object-fit: contain; border-radius: 22px; background: white; padding: 6px; box-shadow: 0 18px 50px rgba(0,0,0,.2); }
|
||||
.login-brand h1, .login-brand .ant-typography { color: white; }
|
||||
.login-card { width: min(430px, calc(100vw - 40px)); margin: auto; box-shadow: 0 24px 70px rgba(19, 43, 69, .18); }
|
||||
.login-alert { margin-bottom: 18px; }
|
||||
@media (max-width: 1100px) { .workspace-grid { grid-template-columns: 1fr; } .operation-list-card .ant-card-body { max-height: 420px; } .question-workspace { overflow-x: auto; } }
|
||||
@media (max-width: 800px) { .login-page { grid-template-columns: 1fr; background: linear-gradient(160deg, #0c3158 0%, #1f659f 45%, #eef4f8 45%); } .login-brand { padding: 42px 28px 18px; } .login-brand img { width: 60px; height: 60px; } .login-brand h1 { font-size: 28px; margin: 12px 0 0; } .login-card { margin: 20px auto 48px; } .app-content { padding: 18px; } }
|
||||
22
Tiku.PlatformAdmin.Web/tsconfig.app.json
Normal file
22
Tiku.PlatformAdmin.Web/tsconfig.app.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"],
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
1
Tiku.PlatformAdmin.Web/tsconfig.app.tsbuildinfo
Normal file
1
Tiku.PlatformAdmin.Web/tsconfig.app.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
||||
{"root":["./src/main.tsx","./src/router.tsx","./src/api/groups.test.ts","./src/api/groups.ts","./src/api/http.ts","./src/api/platform-operations.generated.ts","./src/api/platform.ts","./src/api/schema-utils.ts","./src/api/schema.generated.d.ts","./src/api/token-store.ts","./src/api/types.ts","./src/auth/authprovider.tsx","./src/components/businesstable.tsx","./src/components/operationform.tsx","./src/components/responseview.tsx","./src/components/schemafields.tsx","./src/layout/applayout.tsx","./src/pages/businesspage.tsx","./src/pages/dashboardpage.tsx","./src/pages/loginpage.tsx","./src/pages/questionbankpage.tsx","./src/pages/workspacepage.tsx","./src/pages/business-definitions.test.ts","./src/pages/business-definitions.ts","./src/pages/question-bank-contract.test.ts"],"version":"5.9.3"}
|
||||
7
Tiku.PlatformAdmin.Web/tsconfig.json
Normal file
7
Tiku.PlatformAdmin.Web/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
11
Tiku.PlatformAdmin.Web/tsconfig.node.json
Normal file
11
Tiku.PlatformAdmin.Web/tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["vite.config.ts", "vitest.config.ts"]
|
||||
}
|
||||
1
Tiku.PlatformAdmin.Web/tsconfig.node.tsbuildinfo
Normal file
1
Tiku.PlatformAdmin.Web/tsconfig.node.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
2
Tiku.PlatformAdmin.Web/vite.config.d.ts
vendored
Normal file
2
Tiku.PlatformAdmin.Web/vite.config.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
declare const _default: import("vite").UserConfigFnObject;
|
||||
export default _default;
|
||||
17
Tiku.PlatformAdmin.Web/vite.config.js
Normal file
17
Tiku.PlatformAdmin.Web/vite.config.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
export default defineConfig(function (_a) {
|
||||
var mode = _a.mode;
|
||||
var env = loadEnv(mode, process.cwd(), '');
|
||||
var apiTarget = env.VITE_DEV_API_TARGET || 'http://localhost:5090';
|
||||
return {
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': { target: apiTarget, changeOrigin: true },
|
||||
'/openapi': { target: apiTarget, changeOrigin: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
29
Tiku.PlatformAdmin.Web/vite.config.ts
Normal file
29
Tiku.PlatformAdmin.Web/vite.config.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '');
|
||||
const apiTarget = env.VITE_DEV_API_TARGET || 'http://localhost:5090';
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (id.includes('node_modules/react') || id.includes('node_modules/scheduler')) return 'react';
|
||||
if (id.includes('node_modules/antd') || id.includes('node_modules/@ant-design') || id.includes('node_modules/rc-')) return 'antd';
|
||||
if (id.includes('node_modules')) return 'vendor';
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': { target: apiTarget, changeOrigin: true },
|
||||
'/openapi': { target: apiTarget, changeOrigin: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
2
Tiku.PlatformAdmin.Web/vitest.config.d.ts
vendored
Normal file
2
Tiku.PlatformAdmin.Web/vitest.config.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
declare const _default: import("vite").UserConfig;
|
||||
export default _default;
|
||||
6
Tiku.PlatformAdmin.Web/vitest.config.js
Normal file
6
Tiku.PlatformAdmin.Web/vitest.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
7
Tiku.PlatformAdmin.Web/vitest.config.ts
Normal file
7
Tiku.PlatformAdmin.Web/vitest.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
@@ -53,7 +53,7 @@ Forwarded Headers
|
||||
-> Controllers
|
||||
```
|
||||
|
||||
OpenAPI 和 Scalar 只在 Development 映射。平台管理静态文件由 `Tiku.Api/wwwroot` 同源托管,默认入口是 `/platform-admin/`。
|
||||
OpenAPI 和 Scalar 只在 Development 映射。平台管理端位于独立的 `Tiku.PlatformAdmin.Web` React 工程,由 OpenAPI 生成接口契约并单独构建、部署;`Tiku.Api` 不再托管平台前端静态文件。
|
||||
|
||||
### DbMigrator
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ dotnet run --project Tiku.Api
|
||||
|
||||
默认 Development 入口:
|
||||
|
||||
- 平台管理端:<http://localhost:5090/platform-admin/>
|
||||
- 平台管理端:首次在 `Tiku.PlatformAdmin.Web` 执行 `npm install`;之后启动 `Tiku.Api` 时会在 Development 自动启动前端,访问 <http://localhost:5173>
|
||||
- Scalar:<http://localhost:5090/scalar/v1>
|
||||
- OpenAPI JSON:<http://localhost:5090/openapi/v1.json>
|
||||
- Liveness:<http://localhost:5090/api/health>
|
||||
|
||||
Reference in New Issue
Block a user