fix: preserve site disabled models on partial load failure
This commit is contained in:
+67
-14
@@ -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}
|
||||||
|
|||||||
@@ -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,14 +66,12 @@ describe('Sites disabled models save', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
consoleWarnSpy.mockRestore();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reports route rebuild failure after saving disabled models from sites editor', async () => {
|
async function renderSitesEditor() {
|
||||||
apiMock.rebuildRoutes.mockRejectedValue(new Error('rebuild failed'));
|
|
||||||
|
|
||||||
let root: ReturnType<typeof create> | null = null;
|
let root: ReturnType<typeof create> | null = null;
|
||||||
try {
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root = create(
|
root = create(
|
||||||
<MemoryRouter initialEntries={['/sites']}>
|
<MemoryRouter initialEntries={['/sites']}>
|
||||||
@@ -93,6 +94,16 @@ describe('Sites disabled models save', () => {
|
|||||||
});
|
});
|
||||||
await flushMicrotasks();
|
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 {
|
||||||
|
root = await renderSitesEditor();
|
||||||
|
|
||||||
const saveButton = root.root.find((node) => (
|
const saveButton = root.root.find((node) => (
|
||||||
node.type === 'button'
|
node.type === 'button'
|
||||||
&& typeof node.props.onClick === 'function'
|
&& typeof node.props.onClick === 'function'
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user