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
+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>
);
}