fix(pr-68): harden model disable flows

Co-authored-by: grassroadsZ <44087648+grassroadsZ@users.noreply.github.com>
This commit is contained in:
ciat-777
2026-03-14 17:22:06 +08:00
parent 0d44d3ba10
commit 595073cf4c
4 changed files with 343 additions and 41 deletions
+62 -33
View File
@@ -129,6 +129,7 @@ export default function Accounts() {
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map()); const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastRebindTargetRef = useRef<any | null>(null); const lastRebindTargetRef = useRef<any | null>(null);
const modelModalRequestSeqRef = useRef(0);
const toast = useToast(); const toast = useToast();
if (rebindTarget) lastRebindTargetRef.current = rebindTarget; if (rebindTarget) lastRebindTargetRef.current = rebindTarget;
const activeRebindTarget = rebindTarget || lastRebindTargetRef.current; const activeRebindTarget = rebindTarget || lastRebindTargetRef.current;
@@ -405,10 +406,7 @@ export default function Accounts() {
} }
}; };
const openModelModal = async (account: any) => { const applyLoadedModelModal = (account: any, result: any) => {
setModelModal(s => ({ ...s, open: true, account, loading: true, models: [], pendingDisabled: new Set(), siteName: '' }));
try {
const result = await api.getAccountModels(account.id);
const models = Array.isArray(result?.models) ? result.models : []; 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)); const disabledSet = new Set<string>(models.filter((m: any) => m.disabled).map((m: any) => m.name as string));
setModelModal(s => ({ setModelModal(s => ({
@@ -416,15 +414,59 @@ export default function Accounts() {
loading: false, loading: false,
models, models,
pendingDisabled: disabledSet, pendingDisabled: disabledSet,
siteName: result?.siteName || account.site?.name || '', 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) { } catch (e: any) {
toast.error(e.message || '加载模型列表失败'); if (modelModalRequestSeqRef.current !== requestId) return;
setModelModal(s => ({ ...s, open: false, loading: false })); 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 = () => { const closeModelModal = () => {
modelModalRequestSeqRef.current += 1;
setModelModal(s => ({ ...s, open: false, account: null })); setModelModal(s => ({ ...s, open: false, account: null }));
}; };
@@ -443,11 +485,12 @@ export default function Accounts() {
setModelModal(s => ({ ...s, saving: true })); setModelModal(s => ({ ...s, saving: true }));
try { try {
await api.updateSiteDisabledModels(siteId, Array.from(modelModal.pendingDisabled)); await api.updateSiteDisabledModels(siteId, Array.from(modelModal.pendingDisabled));
// 禁用配置变更后自动重建路由,使变更立即生效(不刷新模型,在后台异步执行)
try { try {
await api.rebuildRoutes(false, false); await api.rebuildRoutes(false, false);
} catch { /* 路由重建失败不阻塞保存流程 */ }
toast.success('模型禁用设置已保存,路由已重建'); toast.success('模型禁用设置已保存,路由已重建');
} catch {
toast.error('模型禁用设置已保存,但路由重建失败,请手动刷新路由');
}
closeModelModal(); closeModelModal();
} catch (e: any) { } catch (e: any) {
toast.error(e.message || '保存失败'); toast.error(e.message || '保存失败');
@@ -1875,18 +1918,11 @@ export default function Accounts() {
<button <button
onClick={async () => { onClick={async () => {
if (!modelModal.account) return; if (!modelModal.account) return;
setModelModal(s => ({ ...s, loading: true })); await loadModelModalModels(modelModal.account, {
try { refreshUpstream: true,
await api.checkModels(modelModal.account.id); successMessage: '模型列表已刷新',
const result = await api.getAccountModels(modelModal.account.id); errorMessage: '刷新失败',
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 || s.siteName }));
toast.success('模型列表已刷新');
} catch (e: any) {
toast.error(e.message || '刷新失败');
setModelModal(s => ({ ...s, loading: false }));
}
}} }}
style={{ marginTop: 16 }} style={{ marginTop: 16 }}
className="btn btn-soft-primary" className="btn btn-soft-primary"
@@ -1930,18 +1966,11 @@ export default function Accounts() {
<button <button
onClick={async () => { onClick={async () => {
if (!modelModal.account) return; if (!modelModal.account) return;
setModelModal(s => ({ ...s, loading: true })); await loadModelModalModels(modelModal.account, {
try { refreshUpstream: true,
await api.checkModels(modelModal.account.id); successMessage: '模型列表已刷新',
const result = await api.getAccountModels(modelModal.account.id); errorMessage: '刷新失败',
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 || s.siteName }));
toast.success('模型列表已刷新');
} catch (e: any) {
toast.error(e.message || '刷新失败');
setModelModal(s => ({ ...s, loading: false }));
}
}} }}
disabled={modelModal.saving} disabled={modelModal.saving}
className="btn btn-ghost" className="btn btn-ghost"
+6 -1
View File
@@ -232,7 +232,12 @@ export default function Sites() {
setDisabledModelsSaving(true); setDisabledModelsSaving(true);
try { try {
await api.updateSiteDisabledModels(editor.editingSiteId, disabledModels); await api.updateSiteDisabledModels(editor.editingSiteId, disabledModels);
toast.success('禁用模型列表已保存'); try {
await api.rebuildRoutes(false, false);
toast.success('禁用模型列表已保存,路由已重建');
} catch {
toast.error('禁用模型列表已保存,但路由重建失败,请手动刷新路由');
}
} catch (e: any) { } catch (e: any) {
toast.error(e.message || '保存禁用模型失败'); toast.error(e.message || '保存禁用模型失败');
} finally { } finally {
+155
View File
@@ -10,6 +10,8 @@ const { apiMock, toastMock } = vi.hoisted(() => ({
getAccounts: vi.fn(), getAccounts: vi.fn(),
getSites: vi.fn(), getSites: vi.fn(),
updateAccount: vi.fn(), updateAccount: vi.fn(),
updateSiteDisabledModels: vi.fn(),
rebuildRoutes: vi.fn(),
refreshAccountHealth: vi.fn(), refreshAccountHealth: vi.fn(),
checkModels: vi.fn(), checkModels: vi.fn(),
getAccountModels: vi.fn(), getAccountModels: vi.fn(),
@@ -45,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', () => { describe('Accounts edit panel', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -62,6 +74,8 @@ describe('Accounts edit panel', () => {
}, },
]); ]);
apiMock.updateAccount.mockResolvedValue({ success: true }); apiMock.updateAccount.mockResolvedValue({ success: true });
apiMock.updateSiteDisabledModels.mockResolvedValue({ success: true });
apiMock.rebuildRoutes.mockResolvedValue({ success: true });
apiMock.refreshAccountHealth.mockResolvedValue({ success: true }); apiMock.refreshAccountHealth.mockResolvedValue({ success: true });
apiMock.getAccountModels.mockResolvedValue({ apiMock.getAccountModels.mockResolvedValue({
siteId: 1, siteId: 1,
@@ -159,6 +173,147 @@ describe('Accounts edit panel', () => {
root?.unmount(); 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();
}
});
}); });
@@ -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();
}
});
});