feat: add system test validation and router admin controls
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user