422 lines
15 KiB
TypeScript
422 lines
15 KiB
TypeScript
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>
|
||
);
|
||
}
|