feat: add system test validation and router admin controls

This commit is contained in:
john
2026-07-04 23:01:30 +08:00
parent d2b97491ef
commit f63bb894d9
12 changed files with 981 additions and 3 deletions
+3
View File
@@ -10,6 +10,7 @@ import { MindSpacePage } from './admin/pages/MindSpacePage';
import { MemoryV2Page } from './admin/pages/MemoryV2Page';
import { ProvidersPage } from './admin/pages/ProvidersPage';
import { SkillsPage } from './admin/pages/SkillsPage';
import { SystemTestsPage } from './admin/pages/SystemTestsPage';
import { UserDetailPage } from './admin/pages/UserDetailPage';
import { UsersPage } from './admin/pages/UsersPage';
import { WechatPage } from './admin/pages/WechatPage';
@@ -114,6 +115,7 @@ function AdminApp({ user, onLogout }: { user: PortalUser; onLogout: () => void }
<Route path="billing/*" element={<BillingPage />} />
<Route path="capabilities" element={<CapabilitiesPage />} />
<Route path="skills" element={<SkillsPage />} />
<Route path="system-tests" element={<SystemTestsPage />} />
<Route path="policies" element={<PoliciesPage />} />
<Route path="mindspace" element={<MindSpacePage />} />
<Route path="memory-v2" element={<MemoryV2Page />} />
@@ -158,6 +160,7 @@ function loginRedirectPath(pathname: string, role: string | undefined) {
|| pathname.startsWith('/billing')
|| pathname.startsWith('/capabilities')
|| pathname.startsWith('/skills')
|| pathname.startsWith('/system-tests')
|| pathname.startsWith('/policies')
|| pathname.startsWith('/mindspace')
|| pathname.startsWith('/memory-v2')
+1
View File
@@ -29,6 +29,7 @@ const NAV_SECTIONS: NavSection[] = [
{ to: '/wechat', label: '服务号' },
{ to: '/mindspace', label: 'MindSpace 配置' },
{ to: '/memory-v2', label: 'Memory V2' },
{ to: '/system-tests', label: '系统测试验证' },
{ to: '/capabilities', label: '能力' },
{ to: '/skills', label: '技能' },
{ to: '/policies', label: '策略' },
+2 -1
View File
@@ -10,7 +10,8 @@ const QUICK_LINKS = [
{ to: '/billing/recharge', label: '充值', desc: '为用户账户充值' },
{ to: '/providers', label: '统一模型中心', desc: 'Provider、执行器模型与启动控制' },
{ to: '/mindspace', label: 'MindSpace 配置', desc: '公开页上限与空间发布参数' },
{ to: '/memory-v2', label: 'Memory V2', desc: '统一配置长期记忆 backend 与开关' },
{ to: '/memory-v2', label: 'Memory V2', desc: '长期记忆 backend 与前置路由开关' },
{ to: '/system-tests', label: '系统测试验证', desc: '选择测试账号执行联调并汇总问题反馈' },
{ to: '/capabilities', label: '能力权限', desc: '扩展与工具开关' },
] as const;
+186 -2
View File
@@ -139,6 +139,17 @@ function defaultConfig(): MemoryV2AdminConfig {
vectorEnabled: false,
failOpen: true,
},
chatIntentRouter: {
enabled: false,
modelProviderKeyId: '',
model: '',
modelApiType: 'chat',
minConfidence: '0.65',
memoryResolveEnabled: true,
memoryResolveLimit: '8',
timeoutMs: '1500',
fallbackRoute: 'agent_orchestration',
},
pgvector: {},
qdrant: {},
weaviate: {},
@@ -154,6 +165,7 @@ function normalizeConfig(config?: Partial<MemoryV2AdminConfig> | null): MemoryV2
const base = defaultConfig();
return {
global: { ...base.global, ...(config?.global ?? {}) },
chatIntentRouter: { ...base.chatIntentRouter, ...(config?.chatIntentRouter ?? {}) },
pgvector: { ...base.pgvector, ...(config?.pgvector ?? {}) },
qdrant: { ...base.qdrant, ...(config?.qdrant ?? {}) },
weaviate: { ...base.weaviate, ...(config?.weaviate ?? {}) },
@@ -216,7 +228,7 @@ export function MemoryV2Page() {
const [globalModelSettings, setGlobalModelSettings] = useState<LlmGlobalSettings | null>(null);
const [supportedApiTypes, setSupportedApiTypes] = useState<MemoryV2ModelApiType[]>(['chat', 'response']);
const [loading, setLoading] = useState(true);
const [busyScope, setBusyScope] = useState<'global' | BackendKey | 'reload' | null>(null);
const [busyScope, setBusyScope] = useState<'global' | 'chatIntentRouter' | BackendKey | 'reload' | null>(null);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
@@ -261,6 +273,20 @@ export function MemoryV2Page() {
}));
};
const updateRouterFlag = (key: string) => (event: ChangeEvent<HTMLInputElement>) => {
setDraft((current) => ({
...current,
chatIntentRouter: { ...current.chatIntentRouter, [key]: event.target.checked },
}));
};
const updateRouterValue = (key: string) => (event: ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
setDraft((current) => ({
...current,
chatIntentRouter: { ...current.chatIntentRouter, [key]: event.target.value },
}));
};
const updateBackendFlag = (backend: BackendKey) => (event: ChangeEvent<HTMLInputElement>) => {
setDraft((current) => ({
...current,
@@ -298,6 +324,27 @@ export function MemoryV2Page() {
});
};
const updateRouterModelProvider = (event: ChangeEvent<HTMLSelectElement>) => {
const nextProviderKeyId = event.target.value;
const nextProviderKey = findProviderKey(providerKeys, nextProviderKeyId);
const nextOptions = modelOptionsForProvider(nextProviderKey, globalModelSettings);
setDraft((current) => {
const currentSection = current.chatIntentRouter;
const currentModel = String(currentSection.model ?? '').trim();
const nextModel = nextOptions.includes(currentModel)
? currentModel
: String(nextProviderKey?.defaultModel ?? globalModelSettings?.globalModel ?? '').trim();
return {
...current,
chatIntentRouter: {
...currentSection,
modelProviderKeyId: nextProviderKeyId,
model: nextModel,
},
};
});
};
const applySavedConfig = (result: MemoryV2AdminConfigResponse, successMessage: string) => {
setPayload(result);
setDraft(normalizeConfig(result.config));
@@ -306,7 +353,7 @@ export function MemoryV2Page() {
const saveConfigSection = async (
patch: Partial<MemoryV2AdminConfig>,
scope: 'global' | BackendKey,
scope: 'global' | 'chatIntentRouter' | BackendKey,
successMessage: string,
) => {
setBusyScope(scope);
@@ -330,6 +377,14 @@ export function MemoryV2Page() {
);
};
const saveRouterConfig = async () => {
await saveConfigSection(
{ chatIntentRouter: draft.chatIntentRouter },
'chatIntentRouter',
'前置 LLM 意图路由配置已保存。关闭时主站会退回原有 Direct Chat / Agent 判定链路。',
);
};
const saveBackendConfig = async (backend: BackendKey) => {
await saveConfigSection(
{ [backend]: draft[backend] },
@@ -339,6 +394,10 @@ export function MemoryV2Page() {
};
const current = payload?.config ? normalizeConfig(payload.config) : draft;
const routerSection = draft.chatIntentRouter;
const currentRouterSection = current.chatIntentRouter;
const routerProviderKey = findProviderKey(providerKeys, String(routerSection.modelProviderKeyId ?? ''));
const routerAvailableModels = modelOptionsForProvider(routerProviderKey, globalModelSettings);
return (
<div className="admin-page">
@@ -401,6 +460,131 @@ export function MemoryV2Page() {
)}
</section>
<section className="admin-card">
<div className="admin-card-head">
<div>
<h2> LLM </h2>
<p className="muted">退 Direct Chat / Agent LLM Memory V2 </p>
</div>
<div className="admin-actions">
<label className="inline-check">
<input
type="checkbox"
checked={Boolean(routerSection.enabled)}
onChange={updateRouterFlag('enabled')}
/>
</label>
<button
type="button"
className="send-btn"
disabled={loading || busyScope !== null}
onClick={() => void saveRouterConfig()}
>
{busyScope === 'chatIntentRouter' ? '保存中...' : '保存路由'}
</button>
</div>
</div>
<div className="admin-form-grid">
<label>
<span></span>
<select
value={String(routerSection.modelProviderKeyId ?? '')}
onChange={updateRouterModelProvider}
>
<option value=""></option>
{providerKeys.map((item) => (
<option key={item.id} value={item.id}>{providerLabel(item)}</option>
))}
</select>
</label>
<label>
<span></span>
<select
value={String(routerSection.model ?? '')}
onChange={updateRouterValue('model')}
>
<option value="">
{routerProviderKey ? '使用当前 Provider 默认模型' : '跟随统一模型中心默认模型'}
</option>
{routerAvailableModels.map((model) => (
<option key={model} value={model}>{model}</option>
))}
</select>
</label>
<label>
<span></span>
<select
value={String(routerSection.modelApiType ?? 'chat')}
onChange={updateRouterValue('modelApiType')}
>
{MODEL_API_TYPES.map((apiType) => (
<option key={apiType.value} value={apiType.value}>{apiType.label}</option>
))}
</select>
</label>
<label>
<span></span>
<input
type="number"
min="0"
max="1"
step="0.05"
value={String(routerSection.minConfidence ?? '0.65')}
onChange={updateRouterValue('minConfidence')}
/>
</label>
<label>
<span></span>
<input
type="number"
min="1"
max="50"
value={String(routerSection.memoryResolveLimit ?? '8')}
onChange={updateRouterValue('memoryResolveLimit')}
/>
</label>
<label>
<span> Ms</span>
<input
type="number"
min="0"
max="30000"
value={String(routerSection.timeoutMs ?? '1500')}
onChange={updateRouterValue('timeoutMs')}
/>
</label>
<label>
<span> / 退</span>
<select
value={String(routerSection.fallbackRoute ?? 'agent_orchestration')}
onChange={updateRouterValue('fallbackRoute')}
>
<option value="agent_orchestration">Agent </option>
<option value="direct_chat">Direct Chat</option>
</select>
</label>
<label className="inline-check">
<input
type="checkbox"
checked={Boolean(routerSection.memoryResolveEnabled)}
onChange={updateRouterFlag('memoryResolveEnabled')}
/>
Memory V2
</label>
</div>
<p className="muted">
<strong>{currentRouterSection.enabled ? '已启用' : '未启用'}</strong>
{' · '}
<strong>{describeEffectiveModelSource(routerSection, providerKeys, globalModelSettings)}</strong>
</p>
{!providerKeys.length && (
<p className="muted">
key Provider key
</p>
)}
</section>
{BACKENDS.map((backend) => {
const section = draft[backend.key];
const currentSection = current[backend.key];
+421
View File
@@ -0,0 +1,421 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
createSystemTestAccount,
deleteSystemTestAccount,
listSkillCatalog,
listSystemTestAccounts,
runSystemSkillValidation,
} from '../../api/client';
import type { AdminSystemTestAccount, AdminSystemTestReport, SkillDefinition } from '../../types';
import { formatTime } from '../utils/format';
const DEFAULT_SKILL = 'service-integration-smoke';
function statusLabel(status: 'passed' | 'warning' | 'failed') {
if (status === 'passed') return '通过';
if (status === 'warning') return '待处理';
return '失败';
}
function AddAccountModal({
onClose,
onSave,
}: {
onClose: () => void;
onSave: (payload: { label: string; username: string; password: string }) => Promise<void>;
}) {
const [label, setLabel] = useState('');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
const normalizedUsername = username.trim();
const normalizedLabel = label.trim() || normalizedUsername;
if (!normalizedUsername || !password) return;
setSaving(true);
setError(null);
try {
await onSave({
label: normalizedLabel,
username: normalizedUsername,
password,
});
} catch (err) {
setError(err instanceof Error ? err.message : '保存测试账号失败');
} finally {
setSaving(false);
}
};
return (
<div className="modal-backdrop" onClick={(event) => event.target === event.currentTarget && onClose()}>
<div className="modal-box" style={{ maxWidth: 520 }}>
<div className="modal-head">
<div>
<h3></h3>
<p className="muted"></p>
</div>
<button type="button" className="modal-close" onClick={onClose}>×</button>
</div>
{error && <p className="banner banner-error">{error}</p>}
<form className="admin-form plan-form" onSubmit={handleSubmit}>
<label className="plan-form-row">
<span></span>
<input
type="text"
value={label}
onChange={(event) => setLabel(event.target.value)}
placeholder="例如 John 测试号"
/>
</label>
<label className="plan-form-row">
<span></span>
<input
type="text"
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="例如 john"
autoComplete="username"
required
/>
</label>
<label className="plan-form-row">
<span></span>
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="输入该账号密码"
autoComplete="current-password"
required
/>
</label>
<div className="plan-form-actions">
<button type="button" className="admin-btn-secondary" onClick={onClose} disabled={saving}></button>
<button type="submit" className="send-btn" disabled={saving || !username.trim() || !password}>
{saving ? '保存中…' : '保存账号'}
</button>
</div>
</form>
</div>
</div>
);
}
export function SystemTestsPage() {
const [catalog, setCatalog] = useState<SkillDefinition[]>([]);
const [skillName, setSkillName] = useState(DEFAULT_SKILL);
const [accounts, setAccounts] = useState<AdminSystemTestAccount[]>([]);
const [selectedAccountId, setSelectedAccountId] = useState('');
const [loading, setLoading] = useState(true);
const [running, setRunning] = useState(false);
const [accountsLoading, setAccountsLoading] = useState(true);
const [accountBusyId, setAccountBusyId] = useState<string | null>(null);
const [showAddAccount, setShowAddAccount] = useState(false);
const [error, setError] = useState<string | null>(null);
const [report, setReport] = useState<AdminSystemTestReport | null>(null);
const loadCatalog = useCallback(async () => {
setLoading(true);
setError(null);
try {
const nextCatalog = await listSkillCatalog();
setCatalog(nextCatalog);
if (nextCatalog.some((item) => item.name === DEFAULT_SKILL)) {
setSkillName(DEFAULT_SKILL);
} else if (nextCatalog[0]?.name) {
setSkillName(nextCatalog[0].name);
}
} catch (err) {
setError(err instanceof Error ? err.message : '加载 skill 列表失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadCatalog();
}, [loadCatalog]);
const loadAccounts = useCallback(async () => {
setAccountsLoading(true);
try {
const nextAccounts = await listSystemTestAccounts();
setAccounts(nextAccounts);
setSelectedAccountId((current) =>
nextAccounts.some((item) => item.id === current) ? current : (nextAccounts[0]?.id ?? ''),
);
} catch (err) {
setError(err instanceof Error ? err.message : '加载测试账号失败');
} finally {
setAccountsLoading(false);
}
}, []);
useEffect(() => {
void loadAccounts();
}, [loadAccounts]);
const selectedSkill = useMemo(
() => catalog.find((item) => item.name === skillName) ?? null,
[catalog, skillName],
);
const selectedAccount = useMemo(
() => accounts.find((item) => item.id === selectedAccountId) ?? null,
[accounts, selectedAccountId],
);
const handleSaveAccount = async (payload: { label: string; username: string; password: string }) => {
const account = await createSystemTestAccount(payload);
setAccounts((current) => [account, ...current]);
setSelectedAccountId(account.id);
setShowAddAccount(false);
};
const handleRemoveAccount = async () => {
if (!selectedAccount) return;
setAccountBusyId(selectedAccount.id);
setError(null);
try {
await deleteSystemTestAccount(selectedAccount.id);
const nextAccounts = accounts.filter((item) => item.id !== selectedAccount.id);
setAccounts(nextAccounts);
setSelectedAccountId(nextAccounts[0]?.id ?? '');
} catch (err) {
setError(err instanceof Error ? err.message : '删除测试账号失败');
} finally {
setAccountBusyId(null);
}
};
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!selectedAccount) {
setError('请先选择一个测试账号,或先添加账号');
return;
}
setRunning(true);
setError(null);
try {
const nextReport = await runSystemSkillValidation({
accountId: selectedAccount.id,
username: '',
password: '',
skillName,
});
setReport(nextReport);
} catch (err) {
setError(err instanceof Error ? err.message : '系统测试执行失败');
} finally {
setRunning(false);
}
};
return (
<div className="admin-page">
<div className="admin-page-head">
<h2></h2>
<p className="muted"> skill </p>
</div>
{error && <p className="banner banner-error">{error}</p>}
<section className="admin-card">
<div className="admin-card-head">
<div>
<h2></h2>
<p className="muted">使 service-integration-smoke skill </p>
</div>
</div>
{loading ? (
<p className="muted"> skill </p>
) : (
<form className="admin-form" onSubmit={handleSubmit}>
<label>
<span> Skill</span>
<select value={skillName} onChange={(e) => setSkillName(e.target.value)} className="admin-select">
{catalog.map((item) => (
<option key={item.name} value={item.name}>
{item.label}
</option>
))}
</select>
</label>
<div className="form-span-all system-test-account-panel">
<label className="system-test-account-select">
<span></span>
<select
value={selectedAccountId}
onChange={(event) => setSelectedAccountId(event.target.value)}
className="admin-select"
disabled={accounts.length === 0 || accountsLoading}
>
{accountsLoading ? (
<option value=""></option>
) : accounts.length === 0 ? (
<option value=""></option>
) : (
accounts.map((account) => (
<option key={account.id} value={account.id}>
{account.label} ({account.username})
</option>
))
)}
</select>
</label>
<div className="system-test-account-actions">
<button type="button" className="admin-btn-secondary" onClick={() => setShowAddAccount(true)}>
+
</button>
<button
type="button"
className="admin-btn-secondary"
onClick={() => void handleRemoveAccount()}
disabled={!selectedAccount || accountBusyId === selectedAccount?.id}
>
{accountBusyId === selectedAccount?.id ? '删除中…' : '删除当前账号'}
</button>
<button type="button" className="admin-btn-secondary" onClick={() => void loadAccounts()} disabled={accountsLoading}>
{accountsLoading ? '刷新中…' : '刷新账号'}
</button>
</div>
{selectedAccount ? (
<div className="system-test-account-summary muted">
使: <strong>{selectedAccount.label}</strong> · @{selectedAccount.username} · {selectedAccount.passwordMasked}
</div>
) : (
<div className="system-test-account-summary muted">
</div>
)}
</div>
{selectedSkill && (
<p className="muted">
skill: <strong>{selectedSkill.label}</strong> · {selectedSkill.description}
</p>
)}
<button type="submit" className="send-btn" disabled={running || loading}>
{running ? '验证执行中…' : '开始验证'}
</button>
</form>
)}
</section>
{showAddAccount && (
<AddAccountModal
onClose={() => setShowAddAccount(false)}
onSave={handleSaveAccount}
/>
)}
{report && (
<>
<section className="admin-card">
<div className="admin-card-head">
<div>
<h2></h2>
<p className="muted">
@{report.account.username} · skill {report.selectedSkill} · Portal {report.portalBaseUrl}
</p>
</div>
</div>
<div className="admin-stat-grid">
<div className="admin-stat-card">
<div className="admin-stat-label"></div>
<div className="admin-stat-value">{report.ok ? '通过' : '有问题'}</div>
<div className="muted">
{formatTime(report.startedAt)} / {formatTime(report.finishedAt)}
</div>
</div>
<div className="admin-stat-card">
<div className="admin-stat-label"></div>
<div className="admin-stat-value">{report.summary.passed}</div>
</div>
<div className="admin-stat-card">
<div className="admin-stat-label"></div>
<div className="admin-stat-value">{report.summary.warnings}</div>
</div>
<div className="admin-stat-card">
<div className="admin-stat-label"></div>
<div className="admin-stat-value">{report.summary.failed}</div>
</div>
</div>
</section>
<section className="admin-card">
<div className="admin-card-head">
<h2></h2>
</div>
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{report.steps.map((step) => (
<tr key={step.key}>
<td>{step.label}</td>
<td>{statusLabel(step.status)}</td>
<td>
<div>{step.message}</div>
{step.details && (
<details style={{ marginTop: 8 }}>
<summary className="muted"></summary>
<pre style={{ whiteSpace: 'pre-wrap', marginTop: 8 }}>
{JSON.stringify(step.details, null, 2)}
</pre>
</details>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section className="admin-card">
<div className="admin-card-head">
<h2></h2>
</div>
{report.issues.length === 0 ? (
<p className="muted"></p>
) : (
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{report.issues.map((issue, index) => (
<tr key={`${issue.stepKey}-${index}`}>
<td>{issue.severity === 'error' ? '错误' : '提醒'}</td>
<td>{issue.stepKey}</td>
<td>
<div>{issue.title}</div>
<div className="muted" style={{ marginTop: 6 }}>{issue.detail}</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</>
)}
</div>
);
}
+37
View File
@@ -2,6 +2,8 @@ import type {
AdminDashboardSummary,
AdminServiceRestartAction,
AdminServiceRestartResult,
AdminSystemTestReport,
AdminSystemTestAccount,
AdminSubscription,
AdminUserRow,
AuthStatus,
@@ -310,6 +312,41 @@ export async function listMemoryV2ModelOptions(): Promise<{
};
}
export async function runSystemSkillValidation(payload: {
accountId?: string;
username: string;
password: string;
skillName: string;
}): Promise<AdminSystemTestReport> {
return portalFetch('/admin-api/system-tests/skill-validation', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function listSystemTestAccounts(): Promise<AdminSystemTestAccount[]> {
const result = await portalFetch<{ accounts: AdminSystemTestAccount[] }>('/admin-api/system-tests/accounts');
return result.accounts ?? [];
}
export async function createSystemTestAccount(payload: {
label?: string;
username: string;
password: string;
}): Promise<AdminSystemTestAccount> {
const result = await portalFetch<{ account: AdminSystemTestAccount }>('/admin-api/system-tests/accounts', {
method: 'POST',
body: JSON.stringify(payload),
});
return result.account;
}
export async function deleteSystemTestAccount(accountId: string): Promise<void> {
await portalFetch(`/admin-api/system-tests/accounts/${encodeURIComponent(accountId)}`, {
method: 'DELETE',
});
}
export async function listWechatBindings(params?: {
search?: string;
limit?: number;
+24
View File
@@ -1649,6 +1649,30 @@ body,
margin-top: 4px;
}
.system-test-account-panel {
display: grid;
gap: 10px;
padding: 14px;
border: 1px solid var(--color-border-input);
border-radius: var(--radius-md);
background: var(--color-bg-subtle, rgba(0, 0, 0, 0.02));
}
.system-test-account-select {
display: grid;
gap: 8px;
}
.system-test-account-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.system-test-account-summary {
font-size: 13px;
}
/* ── Plan catalog table extras ──────────────────────────────────── */
.plan-desc {
font-size: 11px;
+48
View File
@@ -267,6 +267,7 @@ export type MemoryV2AdminConfig = {
vectorEnabled?: boolean;
failOpen?: boolean;
};
chatIntentRouter: MemoryV2AdminSection;
pgvector: MemoryV2AdminSection;
qdrant: MemoryV2AdminSection;
weaviate: MemoryV2AdminSection;
@@ -283,6 +284,53 @@ export type MemoryV2AdminConfigResponse = {
updatedBy: string | null;
};
export type AdminSystemTestStepStatus = 'passed' | 'warning' | 'failed';
export type AdminSystemTestStep = {
key: string;
label: string;
status: AdminSystemTestStepStatus;
message: string;
details?: Record<string, unknown> | null;
};
export type AdminSystemTestIssue = {
stepKey: string;
severity: 'warning' | 'error';
title: string;
detail: string;
};
export type AdminSystemTestReport = {
ok: boolean;
startedAt: number;
finishedAt: number;
portalBaseUrl: string;
selectedSkill: string;
account: {
userId?: string;
username: string;
displayName?: string | null;
};
summary: {
passed: number;
warnings: number;
failed: number;
};
steps: AdminSystemTestStep[];
issues: AdminSystemTestIssue[];
};
export type AdminSystemTestAccount = {
id: string;
label: string;
username: string;
passwordMasked: string;
createdAt: number;
updatedAt: number;
updatedBy?: string | null;
};
export type WechatBinding = {
userId: string;
username: string;