Merge pull request #68 from grassroadsZ/feature/site-model-disable
feat: 将模型禁用功能从站点移至连接管理
This commit is contained in:
+5
-2
@@ -10,7 +10,7 @@ async function request(url: string, options: RequestOptions = {}) {
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, timeoutMs);
|
||||
let cleanupExternalSignal = () => {};
|
||||
let cleanupExternalSignal = () => { };
|
||||
|
||||
if (externalSignal) {
|
||||
if (externalSignal.aborted) {
|
||||
@@ -69,7 +69,7 @@ async function request(url: string, options: RequestOptions = {}) {
|
||||
message = `${message}: ${text.slice(0, 120)}`;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
} catch { }
|
||||
throw new Error(message);
|
||||
}
|
||||
return res.json();
|
||||
@@ -250,6 +250,8 @@ export const api = {
|
||||
deleteSite: (id: number) => request(`/api/sites/${id}`, { method: 'DELETE' }),
|
||||
batchUpdateSites: (data: any) => request('/api/sites/batch', { method: 'POST', body: JSON.stringify(data) }),
|
||||
detectSite: (url: string) => request('/api/sites/detect', { method: 'POST', body: JSON.stringify({ url }) }),
|
||||
getSiteDisabledModels: (siteId: number) => request(`/api/sites/${siteId}/disabled-models`),
|
||||
updateSiteDisabledModels: (siteId: number, models: string[]) => request(`/api/sites/${siteId}/disabled-models`, { method: 'PUT', body: JSON.stringify({ models }) }),
|
||||
|
||||
// Accounts
|
||||
getAccounts: () => request('/api/accounts'),
|
||||
@@ -262,6 +264,7 @@ export const api = {
|
||||
deleteAccount: (id: number) => request(`/api/accounts/${id}`, { method: 'DELETE' }),
|
||||
batchUpdateAccounts: (data: any) => request('/api/accounts/batch', { method: 'POST', body: JSON.stringify(data) }),
|
||||
refreshBalance: (id: number) => request(`/api/accounts/${id}/balance`, { method: 'POST' }),
|
||||
getAccountModels: (id: number) => request(`/api/accounts/${id}/models`),
|
||||
refreshAccountHealth: (data?: { accountId?: number; wait?: boolean }) => request('/api/accounts/health/refresh', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data || {}),
|
||||
|
||||
+297
-4
@@ -116,9 +116,27 @@ export default function Accounts() {
|
||||
const [rebindVerifyResult, setRebindVerifyResult] = useState<any>(null);
|
||||
const [rebindVerifying, setRebindVerifying] = useState(false);
|
||||
const [rebindSaving, setRebindSaving] = useState(false);
|
||||
const [modelModal, setModelModal] = useState<{
|
||||
open: boolean;
|
||||
account: any | null;
|
||||
models: Array<{ name: string; latencyMs: number | null; disabled: boolean }>;
|
||||
pendingDisabled: Set<string>;
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
siteName: string;
|
||||
}>({
|
||||
open: false,
|
||||
account: null,
|
||||
models: [],
|
||||
pendingDisabled: new Set(),
|
||||
loading: false,
|
||||
saving: false,
|
||||
siteName: '',
|
||||
});
|
||||
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
|
||||
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastRebindTargetRef = useRef<any | null>(null);
|
||||
const modelModalRequestSeqRef = useRef(0);
|
||||
const toast = useToast();
|
||||
if (rebindTarget) lastRebindTargetRef.current = rebindTarget;
|
||||
const activeRebindTarget = rebindTarget || lastRebindTargetRef.current;
|
||||
@@ -396,6 +414,100 @@ export default function Accounts() {
|
||||
}
|
||||
};
|
||||
|
||||
const applyLoadedModelModal = (account: any, result: any) => {
|
||||
const models = Array.isArray(result?.models) ? result.models : [];
|
||||
const disabledSet = new Set<string>(models.filter((m: any) => m.disabled).map((m: any) => m.name as string));
|
||||
setModelModal(s => ({
|
||||
...s,
|
||||
loading: false,
|
||||
models,
|
||||
pendingDisabled: disabledSet,
|
||||
siteName: result?.siteName || account.site?.name || s.siteName,
|
||||
}));
|
||||
};
|
||||
|
||||
const loadModelModalModels = async (
|
||||
account: any,
|
||||
options: {
|
||||
refreshUpstream?: boolean;
|
||||
resetBeforeLoad?: boolean;
|
||||
closeOnError?: boolean;
|
||||
successMessage?: string | null;
|
||||
errorMessage?: string;
|
||||
} = {},
|
||||
) => {
|
||||
const requestId = ++modelModalRequestSeqRef.current;
|
||||
setModelModal(s => ({
|
||||
...s,
|
||||
open: true,
|
||||
account,
|
||||
loading: true,
|
||||
...(options.resetBeforeLoad ? { models: [], pendingDisabled: new Set<string>(), siteName: '' } : {}),
|
||||
}));
|
||||
try {
|
||||
if (options.refreshUpstream) {
|
||||
await api.checkModels(account.id);
|
||||
}
|
||||
const result = await api.getAccountModels(account.id);
|
||||
if (modelModalRequestSeqRef.current !== requestId) return;
|
||||
applyLoadedModelModal(account, result);
|
||||
if (options.successMessage) {
|
||||
toast.success(options.successMessage);
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (modelModalRequestSeqRef.current !== requestId) return;
|
||||
toast.error(e.message || options.errorMessage || '加载模型列表失败');
|
||||
setModelModal(s => (
|
||||
options.closeOnError
|
||||
? { ...s, open: false, account: null, loading: false }
|
||||
: { ...s, loading: false }
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
const openModelModal = async (account: any) => {
|
||||
await loadModelModalModels(account, {
|
||||
resetBeforeLoad: true,
|
||||
closeOnError: true,
|
||||
errorMessage: '加载模型列表失败',
|
||||
});
|
||||
};
|
||||
|
||||
const closeModelModal = () => {
|
||||
modelModalRequestSeqRef.current += 1;
|
||||
setModelModal(s => ({ ...s, open: false, account: null }));
|
||||
};
|
||||
|
||||
const toggleModelDisabled = (modelName: string) => {
|
||||
setModelModal(s => {
|
||||
const next = new Set(s.pendingDisabled);
|
||||
if (next.has(modelName)) next.delete(modelName);
|
||||
else next.add(modelName);
|
||||
return { ...s, pendingDisabled: next };
|
||||
});
|
||||
};
|
||||
|
||||
const saveModelDisabled = async () => {
|
||||
if (!modelModal.account) return;
|
||||
const siteId = modelModal.account.siteId;
|
||||
setModelModal(s => ({ ...s, saving: true }));
|
||||
try {
|
||||
await api.updateSiteDisabledModels(siteId, Array.from(modelModal.pendingDisabled));
|
||||
try {
|
||||
await api.rebuildRoutes(false, false);
|
||||
toast.success('模型禁用设置已保存,路由已重建');
|
||||
} catch {
|
||||
toast.error('模型禁用设置已保存,但路由重建失败,请手动刷新路由');
|
||||
}
|
||||
closeModelModal();
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || '保存失败');
|
||||
} finally {
|
||||
setModelModal(s => ({ ...s, saving: false }));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%', padding: '10px 14px', border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)', fontSize: 13, outline: 'none',
|
||||
@@ -1571,8 +1683,12 @@ export default function Accounts() {
|
||||
{actionLoading[`refresh-${a.id}`] ? <span className="spinner spinner-sm" /> : '刷新'}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => withLoading(`models-${a.id}`, () => api.checkModels(a.id), '模型已更新')} disabled={actionLoading[`models-${a.id}`]} className="btn btn-link btn-link-info">
|
||||
{actionLoading[`models-${a.id}`] ? <span className="spinner spinner-sm" /> : '模型'}
|
||||
<button
|
||||
onClick={() => openModelModal(a)}
|
||||
disabled={actionLoading[`models-${a.id}`]}
|
||||
className="btn btn-link btn-link-info"
|
||||
>
|
||||
模型
|
||||
</button>
|
||||
{capabilities.canCheckin && (
|
||||
<button onClick={() => withLoading(`checkin-${a.id}`, () => api.triggerCheckin(a.id), '签到完成')} disabled={actionLoading[`checkin-${a.id}`]} className="btn btn-link btn-link-warning">
|
||||
@@ -1757,8 +1873,12 @@ export default function Accounts() {
|
||||
{actionLoading[`refresh-${a.id}`] ? <span className="spinner spinner-sm" /> : '刷新'}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => handleCheckModels(a.id)} disabled={actionLoading[`models-${a.id}`]} className="btn btn-link btn-link-info">
|
||||
{actionLoading[`models-${a.id}`] ? <span className="spinner spinner-sm" /> : '模型'}
|
||||
<button
|
||||
onClick={() => openModelModal(a)}
|
||||
disabled={actionLoading[`models-${a.id}`]}
|
||||
className="btn btn-link btn-link-info"
|
||||
>
|
||||
模型
|
||||
</button>
|
||||
{capabilities.canCheckin && (
|
||||
<button onClick={() => withLoading(`checkin-${a.id}`, () => api.triggerCheckin(a.id), '签到完成')} disabled={actionLoading[`checkin-${a.id}`]} className="btn btn-link btn-link-warning">
|
||||
@@ -1801,6 +1921,179 @@ export default function Accounts() {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Model management modal */}
|
||||
<CenteredModal
|
||||
open={modelModal.open}
|
||||
onClose={closeModelModal}
|
||||
title={modelModal.siteName ? `模型管理 · ${modelModal.siteName}` : '模型管理'}
|
||||
maxWidth={600}
|
||||
footer={
|
||||
<>
|
||||
<button onClick={closeModelModal} className="btn btn-ghost">取消</button>
|
||||
<button
|
||||
onClick={saveModelDisabled}
|
||||
disabled={modelModal.saving || modelModal.loading}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{modelModal.saving ? <><span className="spinner spinner-sm" />保存中...</> : '保存'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{modelModal.loading ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '48px 0', gap: 10 }}>
|
||||
<span className="spinner" />
|
||||
<span style={{ color: 'var(--color-text-muted)', fontSize: 14 }}>加载模型列表...</span>
|
||||
</div>
|
||||
) : modelModal.models.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '48px 0' }}>
|
||||
<div style={{ fontSize: 32, marginBottom: 8 }}>🤖</div>
|
||||
<div style={{ fontSize: 14, color: 'var(--color-text-muted)', marginBottom: 8 }}>暂无可用模型</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>请先点击账号操作栏中的「刷新」或「模型」按钮获取模型</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!modelModal.account) return;
|
||||
await loadModelModalModels(modelModal.account, {
|
||||
refreshUpstream: true,
|
||||
successMessage: '模型列表已刷新',
|
||||
errorMessage: '刷新失败',
|
||||
});
|
||||
}}
|
||||
style={{ marginTop: 16 }}
|
||||
className="btn btn-soft-primary"
|
||||
>
|
||||
立即获取模型
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{/* Header toolbar */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
|
||||
{/* Left: select-all checkbox */}
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', userSelect: 'none' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={modelModal.pendingDisabled.size === 0}
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
const total = modelModal.models.length;
|
||||
const disabled = modelModal.pendingDisabled.size;
|
||||
el.indeterminate = disabled > 0 && disabled < total;
|
||||
}
|
||||
}}
|
||||
onChange={() => {
|
||||
const allEnabled = modelModal.pendingDisabled.size === 0;
|
||||
setModelModal(s => ({
|
||||
...s,
|
||||
pendingDisabled: allEnabled
|
||||
? new Set(s.models.map((m) => m.name)) // 全选→全禁用
|
||||
: new Set(), // 非全选→全启用
|
||||
}));
|
||||
}}
|
||||
style={{ accentColor: 'var(--color-primary)', width: 15, height: 15 }}
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>
|
||||
已启用 <strong style={{ color: 'var(--color-text-primary)' }}>{modelModal.models.length - modelModal.pendingDisabled.size}</strong> / {modelModal.models.length} 个模型
|
||||
</span>
|
||||
</label>
|
||||
{/* Right: action buttons */}
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!modelModal.account) return;
|
||||
await loadModelModalModels(modelModal.account, {
|
||||
refreshUpstream: true,
|
||||
successMessage: '模型列表已刷新',
|
||||
errorMessage: '刷新失败',
|
||||
});
|
||||
}}
|
||||
disabled={modelModal.saving}
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12, padding: '4px 10px' }}
|
||||
>
|
||||
刷新模型
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setModelModal(s => {
|
||||
const next = new Set<string>();
|
||||
for (const m of s.models) {
|
||||
if (!s.pendingDisabled.has(m.name)) next.add(m.name);
|
||||
}
|
||||
return { ...s, pendingDisabled: next };
|
||||
})}
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12, padding: '4px 10px' }}
|
||||
>
|
||||
反选
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setModelModal(s => ({ ...s, pendingDisabled: new Set(s.models.map((m) => m.name)) }))}
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12, padding: '4px 10px' }}
|
||||
>
|
||||
全部禁用
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setModelModal(s => ({ ...s, pendingDisabled: new Set() }))}
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12, padding: '4px 10px' }}
|
||||
>
|
||||
全部启用
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
maxHeight: 380,
|
||||
overflowY: 'auto',
|
||||
border: '1px solid var(--color-border-light)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}>
|
||||
{modelModal.models.map((model, idx) => {
|
||||
const isDisabled = modelModal.pendingDisabled.has(model.name);
|
||||
return (
|
||||
<label
|
||||
key={model.name}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '9px 14px',
|
||||
cursor: 'pointer',
|
||||
background: isDisabled ? 'var(--color-bg)' : undefined,
|
||||
borderBottom: idx < modelModal.models.length - 1 ? '1px solid var(--color-border-light)' : undefined,
|
||||
opacity: isDisabled ? 0.55 : 1,
|
||||
transition: 'opacity 0.15s, background 0.15s',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!isDisabled}
|
||||
onChange={() => toggleModelDisabled(model.name)}
|
||||
style={{ accentColor: 'var(--color-primary)', width: 15, height: 15, flexShrink: 0 }}
|
||||
/>
|
||||
<span style={{ flex: 1, fontSize: 13, fontFamily: 'var(--font-mono)', wordBreak: 'break-all' }}>
|
||||
{model.name}
|
||||
</span>
|
||||
{model.latencyMs != null && (
|
||||
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', flexShrink: 0 }}>
|
||||
{model.latencyMs}ms
|
||||
</span>
|
||||
)}
|
||||
{isDisabled && (
|
||||
<span className="badge badge-error" style={{ fontSize: 10, flexShrink: 0 }}>禁用</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
|
||||
💡 禁用的模型将对整个站点生效,该站点下所有连接都不会使用这些模型进行代理。
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CenteredModal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+254
-150
@@ -101,6 +101,10 @@ export default function Sites() {
|
||||
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
|
||||
const highlightTimerRef = useRef<number | null>(null);
|
||||
const toast = useToast();
|
||||
const [disabledModels, setDisabledModels] = useState<string[]>([]);
|
||||
const [disabledModelInput, setDisabledModelInput] = useState('');
|
||||
const [disabledModelsLoading, setDisabledModelsLoading] = useState(false);
|
||||
const [disabledModelsSaving, setDisabledModelsSaving] = useState(false);
|
||||
|
||||
if (editor) lastEditorRef.current = editor;
|
||||
const activeEditor = editor || lastEditorRef.current;
|
||||
@@ -204,6 +208,48 @@ export default function Sites() {
|
||||
setEditor({ mode: 'edit', editingSiteId: site.id });
|
||||
setForm(siteFormFromSite(site));
|
||||
scrollToEditorTop();
|
||||
// Load disabled models for this site
|
||||
setDisabledModelsLoading(true);
|
||||
setDisabledModels([]);
|
||||
setDisabledModelInput('');
|
||||
api.getSiteDisabledModels(site.id)
|
||||
.then((res: any) => setDisabledModels(Array.isArray(res?.models) ? res.models : []))
|
||||
.catch(() => { })
|
||||
.finally(() => setDisabledModelsLoading(false));
|
||||
};
|
||||
|
||||
const handleAddDisabledModel = () => {
|
||||
const model = disabledModelInput.trim();
|
||||
if (!model) return;
|
||||
if (disabledModels.includes(model)) {
|
||||
toast.info(`模型 "${model}" 已在禁用列表中`);
|
||||
setDisabledModelInput('');
|
||||
return;
|
||||
}
|
||||
setDisabledModels((prev) => [...prev, model]);
|
||||
setDisabledModelInput('');
|
||||
};
|
||||
|
||||
const handleRemoveDisabledModel = (model: string) => {
|
||||
setDisabledModels((prev) => prev.filter((m) => m !== model));
|
||||
};
|
||||
|
||||
const handleSaveDisabledModels = async () => {
|
||||
if (!editor || editor.mode !== 'edit') return;
|
||||
setDisabledModelsSaving(true);
|
||||
try {
|
||||
await api.updateSiteDisabledModels(editor.editingSiteId, disabledModels);
|
||||
try {
|
||||
await api.rebuildRoutes(false, false);
|
||||
toast.success('禁用模型列表已保存,路由已重建');
|
||||
} catch {
|
||||
toast.error('禁用模型列表已保存,但路由重建失败,请手动刷新路由');
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || '保存禁用模型失败');
|
||||
} finally {
|
||||
setDisabledModelsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -689,6 +735,71 @@ export default function Sites() {
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.6 }}>
|
||||
按 key/value 逐条填写。整行留空会自动忽略;同名请求头不允许重复;请求本身显式传入的请求头优先级更高。
|
||||
</div>
|
||||
{isEditing && (
|
||||
<div style={{ marginTop: 16, padding: '14px', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', background: 'var(--color-bg)' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>禁用模型管理</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
|
||||
在此站点禁用指定模型后,路由重建时将不为该站点的这些模型创建通道。
|
||||
</div>
|
||||
{disabledModelsLoading ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
<span className="spinner spinner-sm" /> 加载中...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 10, minHeight: 24 }}>
|
||||
{disabledModels.length === 0 && (
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无禁用模型</span>
|
||||
)}
|
||||
{disabledModels.map((model) => (
|
||||
<span
|
||||
key={model}
|
||||
className="badge badge-muted"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, padding: '3px 8px' }}
|
||||
>
|
||||
{model}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveDisabledModel(model)}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 0,
|
||||
fontSize: 13, lineHeight: 1, color: 'var(--color-text-muted)',
|
||||
}}
|
||||
title={`移除 ${model}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
placeholder="输入模型名称,如 gpt-4o"
|
||||
value={disabledModelInput}
|
||||
onChange={(e) => setDisabledModelInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddDisabledModel(); } }}
|
||||
style={{
|
||||
flex: 1, padding: '8px 12px', border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)', fontSize: 12, outline: 'none',
|
||||
background: 'var(--color-bg)', color: 'var(--color-text-primary)',
|
||||
}}
|
||||
/>
|
||||
<button onClick={handleAddDisabledModel} className="btn btn-ghost" style={{ padding: '8px 14px', fontSize: 12, border: '1px solid var(--color-border)' }}>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSaveDisabledModels}
|
||||
disabled={disabledModelsSaving}
|
||||
className="btn btn-primary"
|
||||
style={{ marginTop: 10, fontSize: 12, padding: '6px 16px' }}
|
||||
>
|
||||
{disabledModelsSaving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存禁用列表'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<label style={{
|
||||
display: 'flex',
|
||||
@@ -858,48 +969,47 @@ export default function Sites() {
|
||||
</div>
|
||||
) : (
|
||||
<table className="data-table sites-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 44 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sortedSites.length > 0 && selectedSiteIds.length === sortedSites.length}
|
||||
onChange={(e) => toggleSelectAllVisible(e.target.checked)}
|
||||
/>
|
||||
</th>
|
||||
<th>名称</th>
|
||||
<th>外部签到站URL</th>
|
||||
<th>总余额</th>
|
||||
<th>状态</th>
|
||||
<th>系统代理</th>
|
||||
<th>权重</th>
|
||||
<th>平台</th>
|
||||
<th>创建时间</th>
|
||||
<th className="sites-actions-col" style={{ textAlign: 'right' }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedSites.map((site, i) => (
|
||||
<tr
|
||||
key={site.id}
|
||||
data-testid={`site-row-${site.id}`}
|
||||
ref={(node) => {
|
||||
if (node) rowRefs.current.set(site.id, node);
|
||||
else rowRefs.current.delete(site.id);
|
||||
}}
|
||||
onClick={(event) => handleSiteRowClick(site.id, event)}
|
||||
className={`animate-slide-up stagger-${Math.min(i + 1, 5)} row-selectable ${selectedSiteIds.includes(site.id) ? 'row-selected' : ''} ${highlightSiteId === site.id ? 'row-focus-highlight' : ''}`.trim()}
|
||||
>
|
||||
<td>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 44 }}>
|
||||
<input
|
||||
data-testid={`site-select-${site.id}`}
|
||||
type="checkbox"
|
||||
checked={selectedSiteIds.includes(site.id)}
|
||||
onChange={(e) => toggleSiteSelection(site.id, e.target.checked)}
|
||||
checked={sortedSites.length > 0 && selectedSiteIds.length === sortedSites.length}
|
||||
onChange={(e) => toggleSelectAllVisible(e.target.checked)}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ fontWeight: 600 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-start' }}>
|
||||
</th>
|
||||
<th>名称</th>
|
||||
<th>外部签到站URL</th>
|
||||
<th>总余额</th>
|
||||
<th>状态</th>
|
||||
<th>系统代理</th>
|
||||
<th>权重</th>
|
||||
<th>平台</th>
|
||||
<th>创建时间</th>
|
||||
<th className="sites-actions-col" style={{ textAlign: 'right' }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedSites.map((site, i) => (
|
||||
<tr
|
||||
key={site.id}
|
||||
data-testid={`site-row-${site.id}`}
|
||||
ref={(node) => {
|
||||
if (node) rowRefs.current.set(site.id, node);
|
||||
else rowRefs.current.delete(site.id);
|
||||
}}
|
||||
onClick={(event) => handleSiteRowClick(site.id, event)}
|
||||
className={`animate-slide-up stagger-${Math.min(i + 1, 5)} row-selectable ${selectedSiteIds.includes(site.id) ? 'row-selected' : ''} ${highlightSiteId === site.id ? 'row-focus-highlight' : ''}`.trim()}
|
||||
>
|
||||
<td>
|
||||
<input
|
||||
data-testid={`site-select-${site.id}`}
|
||||
type="checkbox"
|
||||
checked={selectedSiteIds.includes(site.id)}
|
||||
onChange={(e) => toggleSiteSelection(site.id, e.target.checked)}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ fontWeight: 600 }}>
|
||||
<a
|
||||
href={site.url}
|
||||
target="_blank"
|
||||
@@ -911,124 +1021,118 @@ export default function Sites() {
|
||||
>
|
||||
{site.name}
|
||||
</a>
|
||||
{hasConfiguredCustomHeaders(site.customHeaders) ? (
|
||||
<span className="badge badge-info" style={{ fontSize: 11 }}>
|
||||
自定义头
|
||||
</span>
|
||||
</td>
|
||||
<td className="sites-url-cell" style={{ maxWidth: 300 }}>
|
||||
{site.externalCheckinUrl ? (
|
||||
<a
|
||||
href={site.externalCheckinUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="sites-url-link"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
color: 'var(--color-primary)',
|
||||
textDecoration: 'underline',
|
||||
wordBreak: 'break-all',
|
||||
}}
|
||||
>
|
||||
{site.externalCheckinUrl}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
<td className="sites-url-cell" style={{ maxWidth: 300 }}>
|
||||
{site.externalCheckinUrl ? (
|
||||
</td>
|
||||
<td style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600 }}>
|
||||
${(site.totalBalance || 0).toFixed(2)}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${site.status === 'disabled' ? 'badge-muted' : 'badge-success'}`} style={{ fontSize: 11 }}>
|
||||
{site.status === 'disabled' ? '禁用' : '启用'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${site.useSystemProxy ? 'badge-info' : 'badge-muted'}`} style={{ fontSize: 11 }}>
|
||||
{site.useSystemProxy ? '已开启' : '未开启'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600 }}>
|
||||
{(site.globalWeight || 1).toFixed(2)}
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
href={site.externalCheckinUrl}
|
||||
href={site.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="sites-url-link"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
color: 'var(--color-primary)',
|
||||
textDecoration: 'underline',
|
||||
wordBreak: 'break-all',
|
||||
}}
|
||||
style={{ textDecoration: 'none' }}
|
||||
>
|
||||
{site.externalCheckinUrl}
|
||||
<span className={`badge ${platformColors[site.platform || ''] || 'badge-muted'}`}>
|
||||
{site.platform || '-'}
|
||||
</span>
|
||||
</a>
|
||||
) : null}
|
||||
</td>
|
||||
<td style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600 }}>
|
||||
${(site.totalBalance || 0).toFixed(2)}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${site.status === 'disabled' ? 'badge-muted' : 'badge-success'}`} style={{ fontSize: 11 }}>
|
||||
{site.status === 'disabled' ? '禁用' : '启用'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${site.useSystemProxy ? 'badge-info' : 'badge-muted'}`} style={{ fontSize: 11 }}>
|
||||
{site.useSystemProxy ? '已开启' : '未开启'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600 }}>
|
||||
{(site.globalWeight || 1).toFixed(2)}
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
href={site.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ textDecoration: 'none' }}
|
||||
>
|
||||
<span className={`badge ${platformColors[site.platform || ''] || 'badge-muted'}`}>
|
||||
{site.platform || '-'}
|
||||
</span>
|
||||
</a>
|
||||
</td>
|
||||
<td style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
<a
|
||||
href={site.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: 'var(--color-text-muted)', textDecoration: 'underline' }}
|
||||
>
|
||||
{formatDateTimeLocal(site.createdAt)}
|
||||
</a>
|
||||
</td>
|
||||
<td className="sites-actions-cell" style={{ textAlign: 'right' }}>
|
||||
<div className="sites-row-actions">
|
||||
<button
|
||||
onClick={() => handleTogglePin(site)}
|
||||
disabled={pinningSiteId === site.id}
|
||||
className={`btn btn-link ${site.isPinned ? 'btn-link-warning' : 'btn-link-primary'}`}
|
||||
</td>
|
||||
<td style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
<a
|
||||
href={site.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: 'var(--color-text-muted)', textDecoration: 'underline' }}
|
||||
>
|
||||
{pinningSiteId === site.id ? <span className="spinner spinner-sm" /> : (site.isPinned ? '取消置顶' : '置顶')}
|
||||
</button>
|
||||
{sortMode === 'custom' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleMoveCustomOrder(site, 'up')}
|
||||
disabled={orderingSiteId === site.id}
|
||||
className="btn btn-link btn-link-muted"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleMoveCustomOrder(site, 'down')}
|
||||
disabled={orderingSiteId === site.id}
|
||||
className="btn btn-link btn-link-muted"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => openEdit(site)}
|
||||
className="btn btn-link btn-link-primary"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleToggleStatus(site)}
|
||||
disabled={togglingSiteId === site.id}
|
||||
className={`btn btn-link ${site.status === 'disabled' ? 'btn-link-primary' : 'btn-link-warning'}`}
|
||||
>
|
||||
{togglingSiteId === site.id ? <span className="spinner spinner-sm" /> : (site.status === 'disabled' ? '启用' : '禁用')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(site)}
|
||||
disabled={deleting === site.id}
|
||||
className="btn btn-link btn-link-danger"
|
||||
>
|
||||
{deleting === site.id ? <span className="spinner spinner-sm" /> : null}
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{formatDateTimeLocal(site.createdAt)}
|
||||
</a>
|
||||
</td>
|
||||
<td className="sites-actions-cell" style={{ textAlign: 'right' }}>
|
||||
<div className="sites-row-actions">
|
||||
<button
|
||||
onClick={() => handleTogglePin(site)}
|
||||
disabled={pinningSiteId === site.id}
|
||||
className={`btn btn-link ${site.isPinned ? 'btn-link-warning' : 'btn-link-primary'}`}
|
||||
>
|
||||
{pinningSiteId === site.id ? <span className="spinner spinner-sm" /> : (site.isPinned ? '取消置顶' : '置顶')}
|
||||
</button>
|
||||
{sortMode === 'custom' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleMoveCustomOrder(site, 'up')}
|
||||
disabled={orderingSiteId === site.id}
|
||||
className="btn btn-link btn-link-muted"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleMoveCustomOrder(site, 'down')}
|
||||
disabled={orderingSiteId === site.id}
|
||||
className="btn btn-link btn-link-muted"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => openEdit(site)}
|
||||
className="btn btn-link btn-link-primary"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleToggleStatus(site)}
|
||||
disabled={togglingSiteId === site.id}
|
||||
className={`btn btn-link ${site.status === 'disabled' ? 'btn-link-primary' : 'btn-link-warning'}`}
|
||||
>
|
||||
{togglingSiteId === site.id ? <span className="spinner spinner-sm" /> : (site.status === 'disabled' ? '启用' : '禁用')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(site)}
|
||||
disabled={deleting === site.id}
|
||||
className="btn btn-link btn-link-danger"
|
||||
>
|
||||
{deleting === site.id ? <span className="spinner spinner-sm" /> : null}
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
) : (
|
||||
<div className="empty-state">
|
||||
|
||||
@@ -10,8 +10,11 @@ const { apiMock, toastMock } = vi.hoisted(() => ({
|
||||
getAccounts: vi.fn(),
|
||||
getSites: vi.fn(),
|
||||
updateAccount: vi.fn(),
|
||||
updateSiteDisabledModels: vi.fn(),
|
||||
rebuildRoutes: vi.fn(),
|
||||
refreshAccountHealth: vi.fn(),
|
||||
checkModels: vi.fn(),
|
||||
getAccountModels: vi.fn(),
|
||||
},
|
||||
toastMock: {
|
||||
success: vi.fn(),
|
||||
@@ -44,6 +47,16 @@ async function flushMicrotasks() {
|
||||
});
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe('Accounts edit panel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -61,7 +74,19 @@ describe('Accounts edit panel', () => {
|
||||
},
|
||||
]);
|
||||
apiMock.updateAccount.mockResolvedValue({ success: true });
|
||||
apiMock.updateSiteDisabledModels.mockResolvedValue({ success: true });
|
||||
apiMock.rebuildRoutes.mockResolvedValue({ success: true });
|
||||
apiMock.refreshAccountHealth.mockResolvedValue({ success: true });
|
||||
apiMock.getAccountModels.mockResolvedValue({
|
||||
siteId: 1,
|
||||
siteName: 'Site A',
|
||||
models: [
|
||||
{ name: 'gpt-4', latencyMs: 120, disabled: false },
|
||||
{ name: 'gpt-3.5', latencyMs: 80, disabled: false },
|
||||
],
|
||||
totalCount: 2,
|
||||
disabledCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -103,13 +128,16 @@ describe('Accounts edit panel', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('shows model refresh toast on success', async () => {
|
||||
apiMock.checkModels.mockResolvedValue({
|
||||
refresh: {
|
||||
status: 'success',
|
||||
modelCount: 2,
|
||||
modelsPreview: ['gpt-4', 'gpt-3.5'],
|
||||
},
|
||||
it('opens model modal when clicking model button', async () => {
|
||||
apiMock.getAccountModels.mockResolvedValue({
|
||||
siteId: 1,
|
||||
siteName: 'Site A',
|
||||
models: [
|
||||
{ name: 'gpt-4', latencyMs: 120, disabled: false },
|
||||
{ name: 'gpt-3.5', latencyMs: 80, disabled: false },
|
||||
],
|
||||
totalCount: 2,
|
||||
disabledCount: 0,
|
||||
});
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
@@ -139,10 +167,153 @@ describe('Accounts edit panel', () => {
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(toastMock.success).toHaveBeenCalledWith(expect.stringContaining('已获取到模型'));
|
||||
expect(toastMock.success).toHaveBeenCalledWith(expect.stringContaining('(共 2 个)'));
|
||||
// Should call getAccountModels to open the model management modal
|
||||
expect(apiMock.getAccountModels).toHaveBeenCalledWith(1);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores stale model modal loads after switching accounts', async () => {
|
||||
const firstLoad = deferred<any>();
|
||||
const secondLoad = deferred<any>();
|
||||
apiMock.getAccounts.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
siteId: 1,
|
||||
username: 'alpha',
|
||||
accessToken: 'session-alpha',
|
||||
status: 'active',
|
||||
site: { id: 1, name: 'Site A', status: 'active', platform: 'new-api' },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
siteId: 2,
|
||||
username: 'beta',
|
||||
accessToken: 'session-beta',
|
||||
status: 'active',
|
||||
site: { id: 2, name: 'Site B', status: 'active', platform: 'new-api' },
|
||||
},
|
||||
]);
|
||||
apiMock.getSites.mockResolvedValue([
|
||||
{ id: 1, name: 'Site A', platform: 'new-api', status: 'active' },
|
||||
{ id: 2, name: 'Site B', platform: 'new-api', status: 'active' },
|
||||
]);
|
||||
apiMock.getAccountModels.mockImplementation((accountId: number) => {
|
||||
if (accountId === 1) return firstLoad.promise;
|
||||
if (accountId === 2) return secondLoad.promise;
|
||||
return Promise.resolve({ siteId: accountId, siteName: 'Unknown', models: [] });
|
||||
});
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/accounts']}>
|
||||
<ToastProvider>
|
||||
<Accounts />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const modelButtons = root.root.findAll((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('btn-link-info')
|
||||
&& collectText(node).trim() === '模型'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
void modelButtons[0]!.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
await act(async () => {
|
||||
void modelButtons[1]!.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
secondLoad.resolve({
|
||||
siteId: 2,
|
||||
siteName: 'Site B',
|
||||
models: [{ name: 'model-b', latencyMs: 66, disabled: false }],
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
firstLoad.resolve({
|
||||
siteId: 1,
|
||||
siteName: 'Site A',
|
||||
models: [{ name: 'model-a', latencyMs: 33, disabled: false }],
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const rendered = JSON.stringify(root.toJSON());
|
||||
expect(rendered).toContain('模型管理 · Site B');
|
||||
expect(rendered).toContain('model-b');
|
||||
expect(rendered).not.toContain('model-a');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports route rebuild failure without claiming success', async () => {
|
||||
apiMock.getAccountModels.mockResolvedValue({
|
||||
siteId: 1,
|
||||
siteName: 'Site A',
|
||||
models: [{ name: 'gpt-4', latencyMs: 120, disabled: false }],
|
||||
totalCount: 1,
|
||||
disabledCount: 0,
|
||||
});
|
||||
apiMock.rebuildRoutes.mockRejectedValue(new Error('rebuild failed'));
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/accounts']}>
|
||||
<ToastProvider>
|
||||
<Accounts />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const modelButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('btn-link-info')
|
||||
&& collectText(node).trim() === '模型'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
await modelButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const saveButtons = root.root.findAll((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).trim() === '保存'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
await saveButtons[saveButtons.length - 1]!.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.updateSiteDisabledModels).toHaveBeenCalledWith(1, []);
|
||||
expect(apiMock.rebuildRoutes).toHaveBeenCalledWith(false, false);
|
||||
expect(toastMock.error).toHaveBeenCalledWith('模型禁用设置已保存,但路由重建失败,请手动刷新路由');
|
||||
expect(toastMock.success).not.toHaveBeenCalledWith('模型禁用设置已保存,路由已重建');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getSites: vi.fn(),
|
||||
addSite: vi.fn(),
|
||||
getSiteDisabledModels: vi.fn().mockResolvedValue({ models: [] }),
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import Sites from './Sites.js';
|
||||
|
||||
const { apiMock, toastMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getSites: vi.fn(),
|
||||
getSiteDisabledModels: vi.fn(),
|
||||
updateSiteDisabledModels: vi.fn(),
|
||||
rebuildRoutes: vi.fn(),
|
||||
},
|
||||
toastMock: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
toast: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
vi.mock('../components/Toast.js', () => ({
|
||||
ToastProvider: ({ children }: { children: ReactNode }) => children,
|
||||
useToast: () => toastMock,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
const children = node.children || [];
|
||||
return children.map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('Sites disabled models save', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiMock.getSites.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
name: 'Demo Site',
|
||||
url: 'https://example.com',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
},
|
||||
]);
|
||||
apiMock.getSiteDisabledModels.mockResolvedValue({ models: [] });
|
||||
apiMock.updateSiteDisabledModels.mockResolvedValue({ success: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('reports route rebuild failure after saving disabled models from sites editor', async () => {
|
||||
apiMock.rebuildRoutes.mockRejectedValue(new Error('rebuild failed'));
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/sites']}>
|
||||
<ToastProvider>
|
||||
<Sites />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const editButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).trim() === '编辑'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
editButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const saveButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes('保存禁用列表')
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
await saveButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.updateSiteDisabledModels).toHaveBeenCalledWith(1, []);
|
||||
expect(apiMock.rebuildRoutes).toHaveBeenCalledWith(false, false);
|
||||
expect(toastMock.error).toHaveBeenCalledWith('禁用模型列表已保存,但路由重建失败,请手动刷新路由');
|
||||
expect(toastMock.success).not.toHaveBeenCalledWith('禁用模型列表已保存,路由已重建');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import Sites from './Sites.js';
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getSites: vi.fn(),
|
||||
getSiteDisabledModels: vi.fn().mockResolvedValue({ models: [] }),
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user