1
0
forked from boos/BoosAPI

feat: 将模型禁用功能从站点管理移至连接管理

- 在连接管理页面为每个账号添加独立的模型管理弹窗
- 弹窗内支持查看可用模型,进行「刷新模型」「全选」「全部禁用」「反选」等快捷操作
- 保存模型禁用配置后,系统会自动在后台触发并重建路由(rebuildRoutes),确保配置立即生效
- 从原站点编辑界面移除过时的禁用功能板块
- 修复因执行自动同步逻辑导致的 SQLite Drizzle Migration `duplicate column token_group` 报错,更新 bootstrap markers 及测试用例
This commit is contained in:
silent
2026-03-14 12:07:27 +08:00
parent ad791d87bb
commit 0d44d3ba10
16 changed files with 1299 additions and 357 deletions
+5 -2
View File
@@ -10,7 +10,7 @@ async function request(url: string, options: RequestOptions = {}) {
let timeoutHandle: ReturnType<typeof setTimeout> | null = setTimeout(() => {
controller.abort();
}, timeoutMs);
let cleanupExternalSignal = () => {};
let cleanupExternalSignal = () => { };
if (externalSignal) {
if (externalSignal.aborted) {
@@ -65,7 +65,7 @@ async function request(url: string, options: RequestOptions = {}) {
message = `${message}: ${text.slice(0, 120)}`;
}
}
} catch {}
} catch { }
throw new Error(message);
}
return res.json();
@@ -235,6 +235,8 @@ export const api = {
deleteSite: (id: number) => request(`/api/sites/${id}`, { method: 'DELETE' }),
batchUpdateSites: (data: any) => request('/api/sites/batch', { method: 'POST', body: JSON.stringify(data) }),
detectSite: (url: string) => request('/api/sites/detect', { method: 'POST', body: JSON.stringify({ url }) }),
getSiteDisabledModels: (siteId: number) => request(`/api/sites/${siteId}/disabled-models`),
updateSiteDisabledModels: (siteId: number, models: string[]) => request(`/api/sites/${siteId}/disabled-models`, { method: 'PUT', body: JSON.stringify({ models }) }),
// Accounts
getAccounts: () => request('/api/accounts'),
@@ -247,6 +249,7 @@ export const api = {
deleteAccount: (id: number) => request(`/api/accounts/${id}`, { method: 'DELETE' }),
batchUpdateAccounts: (data: any) => request('/api/accounts/batch', { method: 'POST', body: JSON.stringify(data) }),
refreshBalance: (id: number) => request(`/api/accounts/${id}/balance`, { method: 'POST' }),
getAccountModels: (id: number) => request(`/api/accounts/${id}/models`),
refreshAccountHealth: (data?: { accountId?: number; wait?: boolean }) => request('/api/accounts/health/refresh', {
method: 'POST',
body: JSON.stringify(data || {}),
+268 -4
View File
@@ -109,6 +109,23 @@ export default function Accounts() {
const [rebindVerifyResult, setRebindVerifyResult] = useState<any>(null);
const [rebindVerifying, setRebindVerifying] = useState(false);
const [rebindSaving, setRebindSaving] = useState(false);
const [modelModal, setModelModal] = useState<{
open: boolean;
account: any | null;
models: Array<{ name: string; latencyMs: number | null; disabled: boolean }>;
pendingDisabled: Set<string>;
loading: boolean;
saving: boolean;
siteName: string;
}>({
open: false,
account: null,
models: [],
pendingDisabled: new Set(),
loading: false,
saving: false,
siteName: '',
});
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastRebindTargetRef = useRef<any | null>(null);
@@ -388,6 +405,58 @@ export default function Accounts() {
}
};
const openModelModal = async (account: any) => {
setModelModal(s => ({ ...s, open: true, account, loading: true, models: [], pendingDisabled: new Set(), siteName: '' }));
try {
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 || '',
}));
} catch (e: any) {
toast.error(e.message || '加载模型列表失败');
setModelModal(s => ({ ...s, open: false, loading: false }));
}
};
const closeModelModal = () => {
setModelModal(s => ({ ...s, open: false, account: null }));
};
const toggleModelDisabled = (modelName: string) => {
setModelModal(s => {
const next = new Set(s.pendingDisabled);
if (next.has(modelName)) next.delete(modelName);
else next.add(modelName);
return { ...s, pendingDisabled: next };
});
};
const saveModelDisabled = async () => {
if (!modelModal.account) return;
const siteId = modelModal.account.siteId;
setModelModal(s => ({ ...s, saving: true }));
try {
await api.updateSiteDisabledModels(siteId, Array.from(modelModal.pendingDisabled));
// 禁用配置变更后自动重建路由,使变更立即生效(不刷新模型,在后台异步执行)
try {
await api.rebuildRoutes(false, false);
} catch { /* 路由重建失败不阻塞保存流程 */ }
toast.success('模型禁用设置已保存,路由已重建');
closeModelModal();
} catch (e: any) {
toast.error(e.message || '保存失败');
} finally {
setModelModal(s => ({ ...s, saving: false }));
}
};
const inputStyle: React.CSSProperties = {
width: '100%', padding: '10px 14px', border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)', fontSize: 13, outline: 'none',
@@ -1535,8 +1604,12 @@ export default function Accounts() {
{actionLoading[`refresh-${a.id}`] ? <span className="spinner spinner-sm" /> : '刷新'}
</button>
)}
<button onClick={() => withLoading(`models-${a.id}`, () => api.checkModels(a.id), '模型已更新')} disabled={actionLoading[`models-${a.id}`]} className="btn btn-link btn-link-info">
{actionLoading[`models-${a.id}`] ? <span className="spinner spinner-sm" /> : '模型'}
<button
onClick={() => openModelModal(a)}
disabled={actionLoading[`models-${a.id}`]}
className="btn btn-link btn-link-info"
>
模型
</button>
{capabilities.canCheckin && (
<button onClick={() => withLoading(`checkin-${a.id}`, () => api.triggerCheckin(a.id), '签到完成')} disabled={actionLoading[`checkin-${a.id}`]} className="btn btn-link btn-link-warning">
@@ -1721,8 +1794,12 @@ export default function Accounts() {
{actionLoading[`refresh-${a.id}`] ? <span className="spinner spinner-sm" /> : '刷新'}
</button>
)}
<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" /> : '模型'}
<button
onClick={() => openModelModal(a)}
disabled={actionLoading[`models-${a.id}`]}
className="btn btn-link btn-link-info"
>
模型
</button>
{capabilities.canCheckin && (
<button onClick={() => withLoading(`checkin-${a.id}`, () => api.triggerCheckin(a.id), '签到完成')} disabled={actionLoading[`checkin-${a.id}`]} className="btn btn-link btn-link-warning">
@@ -1765,6 +1842,193 @@ export default function Accounts() {
</div>
</>
)}
{/* Model management modal */}
<CenteredModal
open={modelModal.open}
onClose={closeModelModal}
title={modelModal.siteName ? `模型管理 · ${modelModal.siteName}` : '模型管理'}
maxWidth={600}
footer={
<>
<button onClick={closeModelModal} className="btn btn-ghost">取消</button>
<button
onClick={saveModelDisabled}
disabled={modelModal.saving || modelModal.loading}
className="btn btn-primary"
>
{modelModal.saving ? <><span className="spinner spinner-sm" />保存中...</> : '保存'}
</button>
</>
}
>
{modelModal.loading ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '48px 0', gap: 10 }}>
<span className="spinner" />
<span style={{ color: 'var(--color-text-muted)', fontSize: 14 }}>加载模型列表...</span>
</div>
) : modelModal.models.length === 0 ? (
<div style={{ textAlign: 'center', padding: '48px 0' }}>
<div style={{ fontSize: 32, marginBottom: 8 }}>🤖</div>
<div style={{ fontSize: 14, color: 'var(--color-text-muted)', marginBottom: 8 }}>暂无可用模型</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>请先点击账号操作栏中的「刷新」或「模型」按钮获取模型</div>
<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 }));
}
}}
style={{ marginTop: 16 }}
className="btn btn-soft-primary"
>
立即获取模型
</button>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{/* Header toolbar */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
{/* Left: select-all checkbox */}
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', userSelect: 'none' }}>
<input
type="checkbox"
checked={modelModal.pendingDisabled.size === 0}
ref={(el) => {
if (el) {
const total = modelModal.models.length;
const disabled = modelModal.pendingDisabled.size;
el.indeterminate = disabled > 0 && disabled < total;
}
}}
onChange={() => {
const allEnabled = modelModal.pendingDisabled.size === 0;
setModelModal(s => ({
...s,
pendingDisabled: allEnabled
? new Set(s.models.map((m) => m.name)) // 全选→全禁用
: new Set(), // 非全选→全启用
}));
}}
style={{ accentColor: 'var(--color-primary)', width: 15, height: 15 }}
/>
<span style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>
已启用 <strong style={{ color: 'var(--color-text-primary)' }}>{modelModal.models.length - modelModal.pendingDisabled.size}</strong> / {modelModal.models.length} 个模型
</span>
</label>
{/* Right: action buttons */}
<div style={{ display: 'flex', gap: 6 }}>
<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 }));
}
}}
disabled={modelModal.saving}
className="btn btn-ghost"
style={{ fontSize: 12, padding: '4px 10px' }}
>
刷新模型
</button>
<button
onClick={() => setModelModal(s => {
const next = new Set<string>();
for (const m of s.models) {
if (!s.pendingDisabled.has(m.name)) next.add(m.name);
}
return { ...s, pendingDisabled: next };
})}
className="btn btn-ghost"
style={{ fontSize: 12, padding: '4px 10px' }}
>
反选
</button>
<button
onClick={() => setModelModal(s => ({ ...s, pendingDisabled: new Set(s.models.map((m) => m.name)) }))}
className="btn btn-ghost"
style={{ fontSize: 12, padding: '4px 10px' }}
>
全部禁用
</button>
<button
onClick={() => setModelModal(s => ({ ...s, pendingDisabled: new Set() }))}
className="btn btn-ghost"
style={{ fontSize: 12, padding: '4px 10px' }}
>
全部启用
</button>
</div>
</div>
<div style={{
maxHeight: 380,
overflowY: 'auto',
border: '1px solid var(--color-border-light)',
borderRadius: 'var(--radius-sm)',
}}>
{modelModal.models.map((model, idx) => {
const isDisabled = modelModal.pendingDisabled.has(model.name);
return (
<label
key={model.name}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '9px 14px',
cursor: 'pointer',
background: isDisabled ? 'var(--color-bg)' : undefined,
borderBottom: idx < modelModal.models.length - 1 ? '1px solid var(--color-border-light)' : undefined,
opacity: isDisabled ? 0.55 : 1,
transition: 'opacity 0.15s, background 0.15s',
}}
>
<input
type="checkbox"
checked={!isDisabled}
onChange={() => toggleModelDisabled(model.name)}
style={{ accentColor: 'var(--color-primary)', width: 15, height: 15, flexShrink: 0 }}
/>
<span style={{ flex: 1, fontSize: 13, fontFamily: 'var(--font-mono)', wordBreak: 'break-all' }}>
{model.name}
</span>
{model.latencyMs != null && (
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', flexShrink: 0 }}>
{model.latencyMs}ms
</span>
)}
{isDisabled && (
<span className="badge badge-error" style={{ fontSize: 10, flexShrink: 0 }}>禁用</span>
)}
</label>
);
})}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
💡 禁用的模型将对整个站点生效,该站点下所有连接都不会使用这些模型进行代理。
</div>
</div>
)}
</CenteredModal>
</div>
);
}
+249 -150
View File
@@ -94,6 +94,10 @@ export default function Sites() {
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
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);
if (editor) lastEditorRef.current = editor;
const activeEditor = editor || lastEditorRef.current;
@@ -197,6 +201,43 @@ export default function Sites() {
setEditor({ mode: 'edit', editingSiteId: site.id });
setForm(siteFormFromSite(site));
scrollToEditorTop();
// Load disabled models for this site
setDisabledModelsLoading(true);
setDisabledModels([]);
setDisabledModelInput('');
api.getSiteDisabledModels(site.id)
.then((res: any) => setDisabledModels(Array.isArray(res?.models) ? res.models : []))
.catch(() => { })
.finally(() => 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 handleRemoveDisabledModel = (model: string) => {
setDisabledModels((prev) => prev.filter((m) => m !== model));
};
const handleSaveDisabledModels = async () => {
if (!editor || editor.mode !== 'edit') return;
setDisabledModelsSaving(true);
try {
await api.updateSiteDisabledModels(editor.editingSiteId, disabledModels);
toast.success('禁用模型列表已保存');
} catch (e: any) {
toast.error(e.message || '保存禁用模型失败');
} finally {
setDisabledModelsSaving(false);
}
};
const handleSave = async () => {
@@ -654,6 +695,71 @@ export default function Sites() {
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.6 }}>
按 key/value 逐条填写。整行留空会自动忽略;同名请求头不允许重复;请求本身显式传入的请求头优先级更高。
</div>
{isEditing && (
<div style={{ marginTop: 16, padding: '14px', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', background: 'var(--color-bg)' }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>禁用模型管理</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
在此站点禁用指定模型后,路由重建时将不为该站点的这些模型创建通道。
</div>
{disabledModelsLoading ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--color-text-muted)' }}>
<span className="spinner spinner-sm" /> 加载中...
</div>
) : (
<>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 10, minHeight: 24 }}>
{disabledModels.length === 0 && (
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无禁用模型</span>
)}
{disabledModels.map((model) => (
<span
key={model}
className="badge badge-muted"
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, padding: '3px 8px' }}
>
{model}
<button
type="button"
onClick={() => handleRemoveDisabledModel(model)}
style={{
background: 'none', border: 'none', cursor: 'pointer', padding: 0,
fontSize: 13, lineHeight: 1, color: 'var(--color-text-muted)',
}}
title={`移除 ${model}`}
>
×
</button>
</span>
))}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<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>
<button
onClick={handleSaveDisabledModels}
disabled={disabledModelsSaving}
className="btn btn-primary"
style={{ marginTop: 10, fontSize: 12, padding: '6px 16px' }}
>
{disabledModelsSaving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存禁用列表'}
</button>
</>
)}
</div>
)}
</div>
<label style={{
display: 'flex',
@@ -823,48 +929,47 @@ export default function Sites() {
</div>
) : (
<table className="data-table sites-table">
<thead>
<tr>
<th style={{ width: 44 }}>
<input
type="checkbox"
checked={sortedSites.length > 0 && selectedSiteIds.length === sortedSites.length}
onChange={(e) => toggleSelectAllVisible(e.target.checked)}
/>
</th>
<th>名称</th>
<th>外部签到站URL</th>
<th>总余额</th>
<th>状态</th>
<th>系统代理</th>
<th>权重</th>
<th>平台</th>
<th>创建时间</th>
<th className="sites-actions-col" style={{ textAlign: 'right' }}>操作</th>
</tr>
</thead>
<tbody>
{sortedSites.map((site, i) => (
<tr
key={site.id}
data-testid={`site-row-${site.id}`}
ref={(node) => {
if (node) rowRefs.current.set(site.id, node);
else rowRefs.current.delete(site.id);
}}
onClick={(event) => handleSiteRowClick(site.id, event)}
className={`animate-slide-up stagger-${Math.min(i + 1, 5)} row-selectable ${selectedSiteIds.includes(site.id) ? 'row-selected' : ''} ${highlightSiteId === site.id ? 'row-focus-highlight' : ''}`.trim()}
>
<td>
<thead>
<tr>
<th style={{ width: 44 }}>
<input
data-testid={`site-select-${site.id}`}
type="checkbox"
checked={selectedSiteIds.includes(site.id)}
onChange={(e) => toggleSiteSelection(site.id, e.target.checked)}
checked={sortedSites.length > 0 && selectedSiteIds.length === sortedSites.length}
onChange={(e) => toggleSelectAllVisible(e.target.checked)}
/>
</td>
<td style={{ fontWeight: 600 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-start' }}>
</th>
<th>名称</th>
<th>外部签到站URL</th>
<th>总余额</th>
<th>状态</th>
<th>系统代理</th>
<th>权重</th>
<th>平台</th>
<th>创建时间</th>
<th className="sites-actions-col" style={{ textAlign: 'right' }}>操作</th>
</tr>
</thead>
<tbody>
{sortedSites.map((site, i) => (
<tr
key={site.id}
data-testid={`site-row-${site.id}`}
ref={(node) => {
if (node) rowRefs.current.set(site.id, node);
else rowRefs.current.delete(site.id);
}}
onClick={(event) => handleSiteRowClick(site.id, event)}
className={`animate-slide-up stagger-${Math.min(i + 1, 5)} row-selectable ${selectedSiteIds.includes(site.id) ? 'row-selected' : ''} ${highlightSiteId === site.id ? 'row-focus-highlight' : ''}`.trim()}
>
<td>
<input
data-testid={`site-select-${site.id}`}
type="checkbox"
checked={selectedSiteIds.includes(site.id)}
onChange={(e) => toggleSiteSelection(site.id, e.target.checked)}
/>
</td>
<td style={{ fontWeight: 600 }}>
<a
href={site.url}
target="_blank"
@@ -876,124 +981,118 @@ export default function Sites() {
>
{site.name}
</a>
{hasConfiguredCustomHeaders(site.customHeaders) ? (
<span className="badge badge-info" style={{ fontSize: 11 }}>
自定义头
</span>
</td>
<td className="sites-url-cell" style={{ maxWidth: 300 }}>
{site.externalCheckinUrl ? (
<a
href={site.externalCheckinUrl}
target="_blank"
rel="noopener noreferrer"
className="sites-url-link"
style={{
fontSize: 12,
fontFamily: 'var(--font-mono)',
color: 'var(--color-primary)',
textDecoration: 'underline',
wordBreak: 'break-all',
}}
>
{site.externalCheckinUrl}
</a>
) : null}
</div>
</td>
<td className="sites-url-cell" style={{ maxWidth: 300 }}>
{site.externalCheckinUrl ? (
</td>
<td style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600 }}>
${(site.totalBalance || 0).toFixed(2)}
</td>
<td>
<span className={`badge ${site.status === 'disabled' ? 'badge-muted' : 'badge-success'}`} style={{ fontSize: 11 }}>
{site.status === 'disabled' ? '禁用' : '启用'}
</span>
</td>
<td>
<span className={`badge ${site.useSystemProxy ? 'badge-info' : 'badge-muted'}`} style={{ fontSize: 11 }}>
{site.useSystemProxy ? '已开启' : '未开启'}
</span>
</td>
<td style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600 }}>
{(site.globalWeight || 1).toFixed(2)}
</td>
<td>
<a
href={site.externalCheckinUrl}
href={site.url}
target="_blank"
rel="noopener noreferrer"
className="sites-url-link"
style={{
fontSize: 12,
fontFamily: 'var(--font-mono)',
color: 'var(--color-primary)',
textDecoration: 'underline',
wordBreak: 'break-all',
}}
style={{ textDecoration: 'none' }}
>
{site.externalCheckinUrl}
<span className={`badge ${platformColors[site.platform || ''] || 'badge-muted'}`}>
{site.platform || '-'}
</span>
</a>
) : null}
</td>
<td style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600 }}>
${(site.totalBalance || 0).toFixed(2)}
</td>
<td>
<span className={`badge ${site.status === 'disabled' ? 'badge-muted' : 'badge-success'}`} style={{ fontSize: 11 }}>
{site.status === 'disabled' ? '禁用' : '启用'}
</span>
</td>
<td>
<span className={`badge ${site.useSystemProxy ? 'badge-info' : 'badge-muted'}`} style={{ fontSize: 11 }}>
{site.useSystemProxy ? '已开启' : '未开启'}
</span>
</td>
<td style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600 }}>
{(site.globalWeight || 1).toFixed(2)}
</td>
<td>
<a
href={site.url}
target="_blank"
rel="noopener noreferrer"
style={{ textDecoration: 'none' }}
>
<span className={`badge ${platformColors[site.platform || ''] || 'badge-muted'}`}>
{site.platform || '-'}
</span>
</a>
</td>
<td style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
<a
href={site.url}
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--color-text-muted)', textDecoration: 'underline' }}
>
{formatDateTimeLocal(site.createdAt)}
</a>
</td>
<td className="sites-actions-cell" style={{ textAlign: 'right' }}>
<div className="sites-row-actions">
<button
onClick={() => handleTogglePin(site)}
disabled={pinningSiteId === site.id}
className={`btn btn-link ${site.isPinned ? 'btn-link-warning' : 'btn-link-primary'}`}
</td>
<td style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
<a
href={site.url}
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--color-text-muted)', textDecoration: 'underline' }}
>
{pinningSiteId === site.id ? <span className="spinner spinner-sm" /> : (site.isPinned ? '取消置顶' : '置顶')}
</button>
{sortMode === 'custom' && (
<>
<button
onClick={() => handleMoveCustomOrder(site, 'up')}
disabled={orderingSiteId === site.id}
className="btn btn-link btn-link-muted"
>
↑
</button>
<button
onClick={() => handleMoveCustomOrder(site, 'down')}
disabled={orderingSiteId === site.id}
className="btn btn-link btn-link-muted"
>
↓
</button>
</>
)}
<button
onClick={() => openEdit(site)}
className="btn btn-link btn-link-primary"
>
编辑
</button>
<button
onClick={() => handleToggleStatus(site)}
disabled={togglingSiteId === site.id}
className={`btn btn-link ${site.status === 'disabled' ? 'btn-link-primary' : 'btn-link-warning'}`}
>
{togglingSiteId === site.id ? <span className="spinner spinner-sm" /> : (site.status === 'disabled' ? '启用' : '禁用')}
</button>
<button
onClick={() => handleDelete(site)}
disabled={deleting === site.id}
className="btn btn-link btn-link-danger"
>
{deleting === site.id ? <span className="spinner spinner-sm" /> : null}
删除
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{formatDateTimeLocal(site.createdAt)}
</a>
</td>
<td className="sites-actions-cell" style={{ textAlign: 'right' }}>
<div className="sites-row-actions">
<button
onClick={() => handleTogglePin(site)}
disabled={pinningSiteId === site.id}
className={`btn btn-link ${site.isPinned ? 'btn-link-warning' : 'btn-link-primary'}`}
>
{pinningSiteId === site.id ? <span className="spinner spinner-sm" /> : (site.isPinned ? '取消置顶' : '置顶')}
</button>
{sortMode === 'custom' && (
<>
<button
onClick={() => handleMoveCustomOrder(site, 'up')}
disabled={orderingSiteId === site.id}
className="btn btn-link btn-link-muted"
>
↑
</button>
<button
onClick={() => handleMoveCustomOrder(site, 'down')}
disabled={orderingSiteId === site.id}
className="btn btn-link btn-link-muted"
>
↓
</button>
</>
)}
<button
onClick={() => openEdit(site)}
className="btn btn-link btn-link-primary"
>
编辑
</button>
<button
onClick={() => handleToggleStatus(site)}
disabled={togglingSiteId === site.id}
className={`btn btn-link ${site.status === 'disabled' ? 'btn-link-primary' : 'btn-link-warning'}`}
>
{togglingSiteId === site.id ? <span className="spinner spinner-sm" /> : (site.status === 'disabled' ? '启用' : '禁用')}
</button>
<button
onClick={() => handleDelete(site)}
disabled={deleting === site.id}
className="btn btn-link btn-link-danger"
>
{deleting === site.id ? <span className="spinner spinner-sm" /> : null}
删除
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)
) : (
<div className="empty-state">
+25 -9
View File
@@ -12,6 +12,7 @@ const { apiMock, toastMock } = vi.hoisted(() => ({
updateAccount: vi.fn(),
refreshAccountHealth: vi.fn(),
checkModels: vi.fn(),
getAccountModels: vi.fn(),
},
toastMock: {
success: vi.fn(),
@@ -62,6 +63,16 @@ describe('Accounts edit panel', () => {
]);
apiMock.updateAccount.mockResolvedValue({ success: true });
apiMock.refreshAccountHealth.mockResolvedValue({ success: true });
apiMock.getAccountModels.mockResolvedValue({
siteId: 1,
siteName: 'Site A',
models: [
{ name: 'gpt-4', latencyMs: 120, disabled: false },
{ name: 'gpt-3.5', latencyMs: 80, disabled: false },
],
totalCount: 2,
disabledCount: 0,
});
});
afterEach(() => {
@@ -103,13 +114,16 @@ describe('Accounts edit panel', () => {
}
});
it('shows model refresh toast on success', async () => {
apiMock.checkModels.mockResolvedValue({
refresh: {
status: 'success',
modelCount: 2,
modelsPreview: ['gpt-4', 'gpt-3.5'],
},
it('opens model modal when clicking model button', async () => {
apiMock.getAccountModels.mockResolvedValue({
siteId: 1,
siteName: 'Site A',
models: [
{ name: 'gpt-4', latencyMs: 120, disabled: false },
{ name: 'gpt-3.5', latencyMs: 80, disabled: false },
],
totalCount: 2,
disabledCount: 0,
});
let root: ReturnType<typeof create> | null = null;
@@ -139,10 +153,12 @@ describe('Accounts edit panel', () => {
});
await flushMicrotasks();
expect(toastMock.success).toHaveBeenCalledWith(expect.stringContaining('已获取到模型'));
expect(toastMock.success).toHaveBeenCalledWith(expect.stringContaining('(共 2 个)'));
// Should call getAccountModels to open the model management modal
expect(apiMock.getAccountModels).toHaveBeenCalledWith(1);
} finally {
root?.unmount();
}
});
});
@@ -9,6 +9,7 @@ const { apiMock } = vi.hoisted(() => ({
apiMock: {
getSites: vi.fn(),
addSite: vi.fn(),
getSiteDisabledModels: vi.fn().mockResolvedValue({ models: [] }),
},
}));
+1
View File
@@ -7,6 +7,7 @@ import Sites from './Sites.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getSites: vi.fn(),
getSiteDisabledModels: vi.fn().mockResolvedValue({ models: [] }),
},
}));