feat: refine model refresh toasts and failure handling

This commit is contained in:
cita
2026-03-10 16:36:21 +08:00
parent 01bf2212f8
commit 747b985513
2 changed files with 94 additions and 2 deletions
+37 -1
View File
@@ -348,6 +348,42 @@ export default function Accounts() {
} }
}; };
const formatModelSuccess = (refresh: any) => {
const models = Array.isArray(refresh?.modelsPreview) ? refresh.modelsPreview : [];
const count = Number.isFinite(refresh?.modelCount) ? refresh.modelCount : models.length;
if (models.length === 0) return `已获取到模型(共 ${count} 个)`;
const preview = models.slice(0, 6).join('、');
const suffix = `(共 ${count} 个)`;
return `已获取到模型:${preview}${suffix}`;
};
const formatModelFailure = (refresh: any, messageFallback?: string) => {
const code = refresh?.errorCode;
if (code === 'timeout') return '模型获取失败(请求超时)';
if (code === 'unauthorized') return '模型获取失败,API Key 已无效';
if (code === 'empty_models') return '模型获取失败:未获取到可用模型';
return messageFallback || refresh?.errorMessage || '模型获取失败';
};
const handleCheckModels = async (accountId: number) => {
const key = `models-${accountId}`;
setActionLoading(s => ({ ...s, [key]: true }));
try {
const result = await api.checkModels(accountId);
const refresh = result?.refresh;
if (!refresh || refresh.status !== 'success') {
toast.error(formatModelFailure(refresh, result?.message));
} else {
toast.success(formatModelSuccess(refresh));
}
} catch (e: any) {
toast.error(e.message || '模型获取失败');
} finally {
setActionLoading(s => ({ ...s, [key]: false }));
void load();
}
};
const inputStyle: React.CSSProperties = { const inputStyle: React.CSSProperties = {
width: '100%', padding: '10px 14px', border: '1px solid var(--color-border)', width: '100%', padding: '10px 14px', border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)', fontSize: 13, outline: 'none', borderRadius: 'var(--radius-sm)', fontSize: 13, outline: 'none',
@@ -1461,7 +1497,7 @@ export default function Accounts() {
{actionLoading[`refresh-${a.id}`] ? <span className="spinner spinner-sm" /> : '刷新'} {actionLoading[`refresh-${a.id}`] ? <span className="spinner spinner-sm" /> : '刷新'}
</button> </button>
)} )}
<button onClick={() => withLoading(`models-${a.id}`, () => api.checkModels(a.id), '模型已更新')} disabled={actionLoading[`models-${a.id}`]} className="btn btn-link btn-link-info"> <button onClick={() => handleCheckModels(a.id)} disabled={actionLoading[`models-${a.id}`]} className="btn btn-link btn-link-info">
{actionLoading[`models-${a.id}`] ? <span className="spinner spinner-sm" /> : '模型'} {actionLoading[`models-${a.id}`] ? <span className="spinner spinner-sm" /> : '模型'}
</button> </button>
{capabilities.canCheckin && ( {capabilities.canCheckin && (
+57 -1
View File
@@ -1,15 +1,23 @@
import type { ReactNode } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer'; import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js'; import { ToastProvider } from '../components/Toast.js';
import Accounts from './Accounts.js'; import Accounts from './Accounts.js';
const { apiMock } = vi.hoisted(() => ({ const { apiMock, toastMock } = vi.hoisted(() => ({
apiMock: { apiMock: {
getAccounts: vi.fn(), getAccounts: vi.fn(),
getSites: vi.fn(), getSites: vi.fn(),
updateAccount: vi.fn(), updateAccount: vi.fn(),
refreshAccountHealth: vi.fn(), refreshAccountHealth: vi.fn(),
checkModels: vi.fn(),
},
toastMock: {
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
toast: vi.fn(),
}, },
})); }));
@@ -17,6 +25,11 @@ vi.mock('../api.js', () => ({
api: apiMock, api: apiMock,
})); }));
vi.mock('../components/Toast.js', () => ({
ToastProvider: ({ children }: { children: ReactNode }) => children,
useToast: () => toastMock,
}));
function collectText(node: ReactTestInstance): string { function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => { return (node.children || []).map((child) => {
if (typeof child === 'string') return child; if (typeof child === 'string') return child;
@@ -89,4 +102,47 @@ describe('Accounts edit panel', () => {
root?.unmount(); root?.unmount();
} }
}); });
it('shows model refresh toast on success', async () => {
apiMock.checkModels.mockResolvedValue({
refresh: {
status: 'success',
modelCount: 2,
modelsPreview: ['gpt-4', 'gpt-3.5'],
},
});
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() === '模型'
));
expect(modelButtons.length).toBeGreaterThan(0);
await act(async () => {
await modelButtons[0]!.props.onClick();
});
await flushMicrotasks();
expect(toastMock.success).toHaveBeenCalledWith(expect.stringContaining('已获取到模型'));
expect(toastMock.success).toHaveBeenCalledWith(expect.stringContaining('(共 2 个)'));
} finally {
root?.unmount();
}
});
}); });