forked from boos/BoosAPI
fix(pr-68): harden model disable flows
Co-authored-by: grassroadsZ <44087648+grassroadsZ@users.noreply.github.com>
This commit is contained in:
+69
-40
@@ -129,6 +129,7 @@ export default function Accounts() {
|
||||
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;
|
||||
@@ -405,26 +406,67 @@ export default function Accounts() {
|
||||
}
|
||||
};
|
||||
|
||||
const openModelModal = async (account: any) => {
|
||||
setModelModal(s => ({ ...s, open: true, account, loading: true, models: [], pendingDisabled: new Set(), siteName: '' }));
|
||||
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);
|
||||
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 || '',
|
||||
}));
|
||||
if (modelModalRequestSeqRef.current !== requestId) return;
|
||||
applyLoadedModelModal(account, result);
|
||||
if (options.successMessage) {
|
||||
toast.success(options.successMessage);
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || '加载模型列表失败');
|
||||
setModelModal(s => ({ ...s, open: false, loading: false }));
|
||||
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 }));
|
||||
};
|
||||
|
||||
@@ -443,11 +485,12 @@ export default function Accounts() {
|
||||
setModelModal(s => ({ ...s, saving: true }));
|
||||
try {
|
||||
await api.updateSiteDisabledModels(siteId, Array.from(modelModal.pendingDisabled));
|
||||
// 禁用配置变更后自动重建路由,使变更立即生效(不刷新模型,在后台异步执行)
|
||||
try {
|
||||
await api.rebuildRoutes(false, false);
|
||||
} catch { /* 路由重建失败不阻塞保存流程 */ }
|
||||
toast.success('模型禁用设置已保存,路由已重建');
|
||||
toast.success('模型禁用设置已保存,路由已重建');
|
||||
} catch {
|
||||
toast.error('模型禁用设置已保存,但路由重建失败,请手动刷新路由');
|
||||
}
|
||||
closeModelModal();
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || '保存失败');
|
||||
@@ -1875,18 +1918,11 @@ export default function Accounts() {
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!modelModal.account) return;
|
||||
setModelModal(s => ({ ...s, loading: true }));
|
||||
try {
|
||||
await api.checkModels(modelModal.account.id);
|
||||
const result = await api.getAccountModels(modelModal.account.id);
|
||||
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 }));
|
||||
}
|
||||
await loadModelModalModels(modelModal.account, {
|
||||
refreshUpstream: true,
|
||||
successMessage: '模型列表已刷新',
|
||||
errorMessage: '刷新失败',
|
||||
});
|
||||
}}
|
||||
style={{ marginTop: 16 }}
|
||||
className="btn btn-soft-primary"
|
||||
@@ -1930,18 +1966,11 @@ export default function Accounts() {
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!modelModal.account) return;
|
||||
setModelModal(s => ({ ...s, loading: true }));
|
||||
try {
|
||||
await api.checkModels(modelModal.account.id);
|
||||
const result = await api.getAccountModels(modelModal.account.id);
|
||||
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 }));
|
||||
}
|
||||
await loadModelModalModels(modelModal.account, {
|
||||
refreshUpstream: true,
|
||||
successMessage: '模型列表已刷新',
|
||||
errorMessage: '刷新失败',
|
||||
});
|
||||
}}
|
||||
disabled={modelModal.saving}
|
||||
className="btn btn-ghost"
|
||||
|
||||
@@ -232,7 +232,12 @@ export default function Sites() {
|
||||
setDisabledModelsSaving(true);
|
||||
try {
|
||||
await api.updateSiteDisabledModels(editor.editingSiteId, disabledModels);
|
||||
toast.success('禁用模型列表已保存');
|
||||
try {
|
||||
await api.rebuildRoutes(false, false);
|
||||
toast.success('禁用模型列表已保存,路由已重建');
|
||||
} catch {
|
||||
toast.error('禁用模型列表已保存,但路由重建失败,请手动刷新路由');
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || '保存禁用模型失败');
|
||||
} finally {
|
||||
|
||||
@@ -10,6 +10,8 @@ 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(),
|
||||
@@ -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', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -62,6 +74,8 @@ 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,
|
||||
@@ -159,6 +173,147 @@ describe('Accounts edit panel', () => {
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user