fix: preserve site disabled models on partial load failure
This commit is contained in:
+68
-15
@@ -221,6 +221,7 @@ export default function Sites() {
|
||||
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);
|
||||
const [availableModels, setAvailableModels] = useState<string[]>([]);
|
||||
@@ -355,32 +356,54 @@ export default function Sites() {
|
||||
setEditor({ mode: 'edit', editingSiteId: site.id });
|
||||
setForm(siteFormFromSite(site));
|
||||
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;
|
||||
loadingModelsSiteIdRef.current = loadSiteId;
|
||||
setDisabledModelsLoading(true);
|
||||
setDisabledModels([]);
|
||||
setDisabledModelInput('');
|
||||
setAvailableModels([]);
|
||||
setDisabledModelSearch('');
|
||||
Promise.all([
|
||||
api.getSiteDisabledModels(site.id),
|
||||
api.getSiteAvailableModels(site.id),
|
||||
])
|
||||
.then(([disabledRes, availableRes]: any[]) => {
|
||||
// Guard: only apply if we're still editing the same site
|
||||
let pendingLoads = 2;
|
||||
const markLoadFinished = () => {
|
||||
pendingLoads -= 1;
|
||||
if (pendingLoads <= 0 && loadingModelsSiteIdRef.current === loadSiteId) {
|
||||
setDisabledModelsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
api.getSiteDisabledModels(site.id)
|
||||
.then((disabledRes: any) => {
|
||||
if (loadingModelsSiteIdRef.current !== loadSiteId) return;
|
||||
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 : []);
|
||||
})
|
||||
.catch((err: any) => {
|
||||
console.warn('Failed to load site models:', err?.message || err);
|
||||
// Preserve previous (empty) model lists — don't clear UI silently
|
||||
console.warn('Failed to load site available models:', err?.message || err);
|
||||
})
|
||||
.finally(() => {
|
||||
if (loadingModelsSiteIdRef.current === loadSiteId) {
|
||||
setDisabledModelsLoading(false);
|
||||
}
|
||||
});
|
||||
.finally(markLoadFinished);
|
||||
};
|
||||
|
||||
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 () => {
|
||||
@@ -953,7 +976,7 @@ export default function Sites() {
|
||||
) : (
|
||||
<>
|
||||
{/* Search and brand group controls */}
|
||||
{brandGroups.length > 0 && (
|
||||
{brandGroups.length > 0 ? (
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<input
|
||||
placeholder="搜索模型名称..."
|
||||
@@ -1032,8 +1055,38 @@ export default function Sites() {
|
||||
))}
|
||||
</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 }}>
|
||||
<button
|
||||
onClick={handleSaveDisabledModels}
|
||||
|
||||
@@ -46,8 +46,11 @@ async function flushMicrotasks() {
|
||||
}
|
||||
|
||||
describe('Sites disabled models save', () => {
|
||||
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
apiMock.getSites.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
@@ -63,35 +66,43 @@ describe('Sites disabled models save', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleWarnSpy.mockRestore();
|
||||
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 () => {
|
||||
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();
|
||||
root = await renderSitesEditor();
|
||||
|
||||
const saveButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
@@ -112,4 +123,74 @@ describe('Sites disabled models save', () => {
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user