1
0
forked from boos/BoosAPI

fix: preserve site disabled models on partial load failure

This commit is contained in:
Cita
2026-03-22 23:58:23 +08:00
parent 9a5e2bbc60
commit 90e9140c70
2 changed files with 170 additions and 36 deletions
+68 -15
View File
@@ -221,6 +221,7 @@ export default function Sites() {
const highlightTimerRef = useRef<number | null>(null); const highlightTimerRef = useRef<number | null>(null);
const toast = useToast(); const toast = useToast();
const [disabledModels, setDisabledModels] = useState<string[]>([]); const [disabledModels, setDisabledModels] = useState<string[]>([]);
const [disabledModelInput, setDisabledModelInput] = useState('');
const [disabledModelsLoading, setDisabledModelsLoading] = useState(false); const [disabledModelsLoading, setDisabledModelsLoading] = useState(false);
const [disabledModelsSaving, setDisabledModelsSaving] = useState(false); const [disabledModelsSaving, setDisabledModelsSaving] = useState(false);
const [availableModels, setAvailableModels] = useState<string[]>([]); const [availableModels, setAvailableModels] = useState<string[]>([]);
@@ -355,32 +356,54 @@ export default function Sites() {
setEditor({ mode: 'edit', editingSiteId: site.id }); setEditor({ mode: 'edit', editingSiteId: site.id });
setForm(siteFormFromSite(site)); setForm(siteFormFromSite(site));
scrollToEditorTop(); scrollToEditorTop();
// Load disabled models and available models for this site // Load disabled models and discovered models independently so a best-effort
// availability fetch cannot wipe the existing disabled-model state.
const loadSiteId = site.id; const loadSiteId = site.id;
loadingModelsSiteIdRef.current = loadSiteId; loadingModelsSiteIdRef.current = loadSiteId;
setDisabledModelsLoading(true); setDisabledModelsLoading(true);
setDisabledModels([]); setDisabledModels([]);
setDisabledModelInput('');
setAvailableModels([]); setAvailableModels([]);
setDisabledModelSearch(''); setDisabledModelSearch('');
Promise.all([ let pendingLoads = 2;
api.getSiteDisabledModels(site.id), const markLoadFinished = () => {
api.getSiteAvailableModels(site.id), pendingLoads -= 1;
]) if (pendingLoads <= 0 && loadingModelsSiteIdRef.current === loadSiteId) {
.then(([disabledRes, availableRes]: any[]) => { setDisabledModelsLoading(false);
// Guard: only apply if we're still editing the same site }
};
api.getSiteDisabledModels(site.id)
.then((disabledRes: any) => {
if (loadingModelsSiteIdRef.current !== loadSiteId) return; if (loadingModelsSiteIdRef.current !== loadSiteId) return;
setDisabledModels(Array.isArray(disabledRes?.models) ? disabledRes.models : []); setDisabledModels(Array.isArray(disabledRes?.models) ? disabledRes.models : []);
})
.catch((err: any) => {
console.warn('Failed to load site disabled models:', err?.message || err);
})
.finally(markLoadFinished);
api.getSiteAvailableModels(site.id)
.then((availableRes: any) => {
if (loadingModelsSiteIdRef.current !== loadSiteId) return;
setAvailableModels(Array.isArray(availableRes?.models) ? availableRes.models : []); setAvailableModels(Array.isArray(availableRes?.models) ? availableRes.models : []);
}) })
.catch((err: any) => { .catch((err: any) => {
console.warn('Failed to load site models:', err?.message || err); console.warn('Failed to load site available models:', err?.message || err);
// Preserve previous (empty) model lists — don't clear UI silently
}) })
.finally(() => { .finally(markLoadFinished);
if (loadingModelsSiteIdRef.current === loadSiteId) { };
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 handleSaveDisabledModels = async () => { const handleSaveDisabledModels = async () => {
@@ -953,7 +976,7 @@ export default function Sites() {
) : ( ) : (
<> <>
{/* Search and brand group controls */} {/* Search and brand group controls */}
{brandGroups.length > 0 && ( {brandGroups.length > 0 ? (
<div style={{ marginBottom: 10 }}> <div style={{ marginBottom: 10 }}>
<input <input
placeholder="搜索模型名称..." placeholder="搜索模型名称..."
@@ -1032,8 +1055,38 @@ export default function Sites() {
))} ))}
</div> </div>
</div> </div>
) : (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
暂无已发现模型,仍可手动添加需要屏蔽的模型名。
</div>
)} )}
<div style={{ display: 'flex', gap: 8, marginTop: 10, marginBottom: 10 }}>
<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>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 10 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 10 }}>
<button <button
onClick={handleSaveDisabledModels} onClick={handleSaveDisabledModels}
+102 -21
View File
@@ -46,8 +46,11 @@ async function flushMicrotasks() {
} }
describe('Sites disabled models save', () => { describe('Sites disabled models save', () => {
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
apiMock.getSites.mockResolvedValue([ apiMock.getSites.mockResolvedValue([
{ {
id: 1, id: 1,
@@ -63,35 +66,43 @@ describe('Sites disabled models save', () => {
}); });
afterEach(() => { afterEach(() => {
consoleWarnSpy.mockRestore();
vi.clearAllMocks(); vi.clearAllMocks();
}); });
async function renderSitesEditor() {
let root: ReturnType<typeof create> | null = null;
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();
return root;
}
it('reports route rebuild failure after saving disabled models from sites editor', async () => { it('reports route rebuild failure after saving disabled models from sites editor', async () => {
apiMock.rebuildRoutes.mockRejectedValue(new Error('rebuild failed')); apiMock.rebuildRoutes.mockRejectedValue(new Error('rebuild failed'));
let root: ReturnType<typeof create> | null = null; let root: ReturnType<typeof create> | null = null;
try { try {
await act(async () => { root = await renderSitesEditor();
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) => ( const saveButton = root.root.find((node) => (
node.type === 'button' node.type === 'button'
@@ -112,4 +123,74 @@ describe('Sites disabled models save', () => {
root?.unmount(); root?.unmount();
} }
}); });
it('preserves existing disabled models when available-model loading fails', async () => {
apiMock.getSiteDisabledModels.mockResolvedValue({ models: ['gpt-4o'] });
apiMock.getSiteAvailableModels.mockRejectedValue(new Error('available models failed'));
apiMock.rebuildRoutes.mockResolvedValue({ success: true });
let root: ReturnType<typeof create> | null = null;
try {
root = await renderSitesEditor();
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, ['gpt-4o']);
} finally {
root?.unmount();
}
});
it('allows adding a disabled model manually when no available models are discovered', async () => {
apiMock.rebuildRoutes.mockResolvedValue({ success: true });
let root: ReturnType<typeof create> | null = null;
try {
root = await renderSitesEditor();
const manualInput = root.root.find((node) => (
node.type === 'input'
&& node.props.placeholder === '输入模型名称,如 gpt-4o'
));
await act(async () => {
manualInput.props.onChange({ target: { value: 'gpt-4o' } });
});
const addButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).trim() === '添加模型'
));
await act(async () => {
addButton.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, ['gpt-4o']);
} finally {
root?.unmount();
}
});
}); });