fix: address review issues in model filter system
- brandMatcher: split keywords into includes/startsWith/segment/boundary modes to align with frontend BrandIcon.tsx matching semantics, preventing false-positive blocking from short keywords like o1, tts, command - handleSiteBlockModel: reject wildcard route patterns (e.g. gpt-*) from being written to site disabled list; only allow exact model names - Sites.tsx: extract brand grouping to useMemo, convert disabledModels to Set for O(1) lookup, remove duplicate IIFE computations in JSX - handleSiteBlockModel: replace window.confirm with project-style DOM dialog for visual consistency - enabledCounts: compute from filtered scope (excluding enabled filter itself) so counts reflect current brand/site/search filters - Fix declaration order: move enabledCounts after sortedRoutes to avoid ReferenceError
This commit is contained in:
+70
-74
@@ -226,6 +226,31 @@ export default function Sites() {
|
||||
const [availableModels, setAvailableModels] = useState<string[]>([]);
|
||||
const [disabledModelSearch, setDisabledModelSearch] = useState('');
|
||||
|
||||
const disabledModelSet = useMemo(() => new Set(disabledModels), [disabledModels]);
|
||||
|
||||
const brandGroups = useMemo(() => {
|
||||
const groups = new Map<string, string[]>();
|
||||
for (const model of availableModels) {
|
||||
const brand = getBrand(model);
|
||||
const brandName = brand?.name || '其他';
|
||||
if (!groups.has(brandName)) groups.set(brandName, []);
|
||||
groups.get(brandName)!.push(model);
|
||||
}
|
||||
return [...groups.entries()].sort((a, b) => {
|
||||
if (a[0] === '其他') return 1;
|
||||
if (b[0] === '其他') return -1;
|
||||
return a[0].localeCompare(b[0], undefined, { sensitivity: 'base' });
|
||||
});
|
||||
}, [availableModels]);
|
||||
|
||||
const filteredBrandGroups = useMemo(() => {
|
||||
const q = disabledModelSearch.trim().toLowerCase();
|
||||
if (!q) return brandGroups;
|
||||
return brandGroups
|
||||
.map(([brandName, models]) => [brandName, models.filter((m) => m.toLowerCase().includes(q))] as [string, string[]])
|
||||
.filter(([, models]) => models.length > 0);
|
||||
}, [brandGroups, disabledModelSearch]);
|
||||
|
||||
if (editor) lastEditorRef.current = editor;
|
||||
const activeEditor = editor || lastEditorRef.current;
|
||||
const isEditing = activeEditor?.mode === 'edit';
|
||||
@@ -350,7 +375,7 @@ export default function Sites() {
|
||||
const handleAddDisabledModel = () => {
|
||||
const model = disabledModelInput.trim();
|
||||
if (!model) return;
|
||||
if (disabledModels.includes(model)) {
|
||||
if (disabledModelSet.has(model)) {
|
||||
toast.info(`模型 "${model}" 已在禁用列表中`);
|
||||
setDisabledModelInput('');
|
||||
return;
|
||||
@@ -946,84 +971,54 @@ export default function Sites() {
|
||||
}}
|
||||
/>
|
||||
{/* Brand group quick actions */}
|
||||
{(() => {
|
||||
const brandGroups = new Map<string, string[]>();
|
||||
for (const model of availableModels) {
|
||||
const brand = getBrand(model);
|
||||
const brandName = brand?.name || '其他';
|
||||
if (!brandGroups.has(brandName)) brandGroups.set(brandName, []);
|
||||
brandGroups.get(brandName)!.push(model);
|
||||
}
|
||||
const sortedBrands = [...brandGroups.entries()].sort((a, b) => {
|
||||
if (a[0] === '其他') return 1;
|
||||
if (b[0] === '其他') return -1;
|
||||
return a[0].localeCompare(b[0], undefined, { sensitivity: 'base' });
|
||||
});
|
||||
return (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 8 }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', lineHeight: '24px' }}>按品牌全选:</span>
|
||||
{sortedBrands.map(([brandName, models]) => {
|
||||
const allDisabled = models.every((m) => disabledModels.includes(m));
|
||||
return (
|
||||
<button
|
||||
key={brandName}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (allDisabled) {
|
||||
setDisabledModels((prev) => prev.filter((m) => !models.includes(m)));
|
||||
} else {
|
||||
setDisabledModels((prev) => Array.from(new Set([...prev, ...models])));
|
||||
}
|
||||
}}
|
||||
className={`badge ${allDisabled ? 'badge-warning' : 'badge-muted'}`}
|
||||
style={{ fontSize: 10, cursor: 'pointer', border: 'none', padding: '3px 8px' }}
|
||||
data-tooltip={allDisabled ? `取消禁用全部 ${brandName} 模型 (${models.length})` : `禁用全部 ${brandName} 模型 (${models.length})`}
|
||||
>
|
||||
{brandName} ({models.length})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 8 }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', lineHeight: '24px' }}>按品牌全选:</span>
|
||||
{brandGroups.map(([brandName, models]) => {
|
||||
const allDisabled = models.every((m) => disabledModelSet.has(m));
|
||||
return (
|
||||
<button
|
||||
key={brandName}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (allDisabled) {
|
||||
const removeSet = new Set(models);
|
||||
setDisabledModels((prev) => prev.filter((m) => !removeSet.has(m)));
|
||||
} else {
|
||||
setDisabledModels((prev) => Array.from(new Set([...prev, ...models])));
|
||||
}
|
||||
}}
|
||||
className={`badge ${allDisabled ? 'badge-warning' : 'badge-muted'}`}
|
||||
style={{ fontSize: 10, cursor: 'pointer', border: 'none', padding: '3px 8px' }}
|
||||
data-tooltip={allDisabled ? `取消禁用全部 ${brandName} 模型 (${models.length})` : `禁用全部 ${brandName} 模型 (${models.length})`}
|
||||
>
|
||||
{brandName} ({models.length})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Checkbox list */}
|
||||
<div style={{ maxHeight: 280, overflowY: 'auto', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', padding: '4px 0' }}>
|
||||
{(() => {
|
||||
const q = disabledModelSearch.trim().toLowerCase();
|
||||
const filtered = q ? availableModels.filter((m) => m.toLowerCase().includes(q)) : availableModels;
|
||||
if (filtered.length === 0) {
|
||||
return <div style={{ padding: '8px 12px', fontSize: 12, color: 'var(--color-text-muted)' }}>无匹配模型</div>;
|
||||
}
|
||||
// Group by brand
|
||||
const brandGroups = new Map<string, string[]>();
|
||||
for (const model of filtered) {
|
||||
const brand = getBrand(model);
|
||||
const brandName = brand?.name || '其他';
|
||||
if (!brandGroups.has(brandName)) brandGroups.set(brandName, []);
|
||||
brandGroups.get(brandName)!.push(model);
|
||||
}
|
||||
const sortedBrands = [...brandGroups.entries()].sort((a, b) => {
|
||||
if (a[0] === '其他') return 1;
|
||||
if (b[0] === '其他') return -1;
|
||||
return a[0].localeCompare(b[0], undefined, { sensitivity: 'base' });
|
||||
});
|
||||
return sortedBrands.map(([brandName, models]) => (
|
||||
<div key={brandName}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-secondary)', padding: '4px 12px', background: 'var(--color-bg)', borderBottom: '1px solid var(--color-border-light)' }}>
|
||||
{brandName} ({models.length})
|
||||
</div>
|
||||
{models.map((model) => (
|
||||
{filteredBrandGroups.length === 0 ? (
|
||||
<div style={{ padding: '8px 12px', fontSize: 12, color: 'var(--color-text-muted)' }}>无匹配模型</div>
|
||||
) : filteredBrandGroups.map(([brandName, models]) => (
|
||||
<div key={brandName}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-secondary)', padding: '4px 12px', background: 'var(--color-bg)', borderBottom: '1px solid var(--color-border-light)' }}>
|
||||
{brandName} ({models.length})
|
||||
</div>
|
||||
{models.map((model) => {
|
||||
const isDisabled = disabledModelSet.has(model);
|
||||
return (
|
||||
<label
|
||||
key={model}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, padding: '3px 12px',
|
||||
fontSize: 12, cursor: 'pointer', lineHeight: 1.6,
|
||||
background: disabledModels.includes(model) ? 'color-mix(in srgb, var(--color-warning) 8%, transparent)' : 'transparent',
|
||||
background: isDisabled ? 'color-mix(in srgb, var(--color-warning) 8%, transparent)' : 'transparent',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={disabledModels.includes(model)}
|
||||
checked={isDisabled}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setDisabledModels((prev) => [...prev, model]);
|
||||
@@ -1032,21 +1027,22 @@ export default function Sites() {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span style={{ color: disabledModels.includes(model) ? 'var(--color-warning)' : 'var(--color-text-primary)' }}>
|
||||
<span style={{ color: isDisabled ? 'var(--color-warning)' : 'var(--color-text-primary)' }}>
|
||||
{model}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manually disabled models not in available list */}
|
||||
{(() => {
|
||||
const manualOnly = disabledModels.filter((m) => !availableModels.includes(m));
|
||||
const availableSet = new Set(availableModels);
|
||||
const manualOnly = disabledModels.filter((m) => !availableSet.has(m));
|
||||
if (manualOnly.length === 0) return null;
|
||||
return (
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
|
||||
@@ -641,16 +641,6 @@ export default function TokenRoutes() {
|
||||
return listVisibleRoutes.find((route) => route.id === activeGroupFilter) || null;
|
||||
}, [activeGroupFilter, listVisibleRoutes]);
|
||||
|
||||
const enabledCounts = useMemo(() => {
|
||||
let enabled = 0;
|
||||
let disabled = 0;
|
||||
for (const route of listVisibleRoutes) {
|
||||
if (route.enabled) enabled++;
|
||||
else disabled++;
|
||||
}
|
||||
return { enabled, disabled };
|
||||
}, [listVisibleRoutes]);
|
||||
|
||||
const sortedRoutes = useMemo(() => (
|
||||
[...listVisibleRoutes].sort((a, b) => {
|
||||
if (sortBy === 'channelCount') {
|
||||
@@ -663,6 +653,47 @@ export default function TokenRoutes() {
|
||||
})
|
||||
), [listVisibleRoutes, sortBy, sortDir]);
|
||||
|
||||
const enabledCounts = useMemo(() => {
|
||||
// Count enabled/disabled within the currently filtered scope (excluding the enabled filter itself)
|
||||
let base = sortedRoutes;
|
||||
|
||||
if (activeGroupFilter === '__all__') {
|
||||
base = base.filter((route) => !isRouteExactModel(route));
|
||||
} else if (typeof activeGroupFilter === 'number') {
|
||||
base = base.filter((route) => route.id === activeGroupFilter);
|
||||
}
|
||||
if (activeBrand) {
|
||||
if (activeBrand === '__other__') {
|
||||
base = base.filter((route) => !(routeBrandById.get(route.id) || null));
|
||||
} else {
|
||||
base = base.filter((route) => (routeBrandById.get(route.id)?.name || '') === activeBrand);
|
||||
}
|
||||
}
|
||||
if (activeSite) {
|
||||
base = base.filter((route) => route.siteNames?.includes(activeSite));
|
||||
}
|
||||
if (activeEndpointType) {
|
||||
base = base.filter((route) => (routeEndpointTypesByRouteId[route.id] || new Set<string>()).has(activeEndpointType));
|
||||
}
|
||||
if (search.trim()) {
|
||||
const q = search.trim().toLowerCase();
|
||||
base = base.filter((route) => {
|
||||
const modelPattern = route.modelPattern.toLowerCase();
|
||||
const displayName = (route.displayName || '').toLowerCase();
|
||||
const title = resolveRouteTitle(route).toLowerCase();
|
||||
return modelPattern.includes(q) || displayName.includes(q) || title.includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
let enabled = 0;
|
||||
let disabled = 0;
|
||||
for (const route of base) {
|
||||
if (route.enabled) enabled++;
|
||||
else disabled++;
|
||||
}
|
||||
return { enabled, disabled };
|
||||
}, [sortedRoutes, activeGroupFilter, activeBrand, activeSite, activeEndpointType, search, routeBrandById, routeEndpointTypesByRouteId]);
|
||||
|
||||
const filteredRoutes = useMemo(() => {
|
||||
let list = sortedRoutes;
|
||||
|
||||
@@ -918,15 +949,33 @@ export default function TokenRoutes() {
|
||||
return;
|
||||
}
|
||||
const route = routeSummaries.find((r) => r.id === routeId);
|
||||
const modelName = channel.sourceModel || route?.modelPattern || '';
|
||||
const modelName = channel.sourceModel || (route && isExactModelPattern(route.modelPattern) ? route.modelPattern : '') || '';
|
||||
if (!modelName) {
|
||||
toast.error('找不到要屏蔽的模型名');
|
||||
toast.error('该通道没有精确模型名,无法使用站点屏蔽(通配符路由请在站点编辑中手动禁用)');
|
||||
return;
|
||||
}
|
||||
const siteName = channel.site.name || '未知站点';
|
||||
const confirmed = window.confirm(
|
||||
`确定将模型「${modelName}」加入站点「${siteName}」的禁用列表吗?\n\n执行后将自动触发路由重建,该站点下此模型的通道将不再生成。`,
|
||||
);
|
||||
const confirmed = await new Promise<boolean>((resolve) => {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9999;display:flex;align-items:center;justify-content:center';
|
||||
const dialog = document.createElement('div');
|
||||
dialog.style.cssText = 'background:var(--color-bg-card,#fff);border-radius:12px;padding:24px;max-width:420px;width:90%;box-shadow:0 8px 32px rgba(0,0,0,0.2)';
|
||||
dialog.innerHTML = `
|
||||
<div style="font-weight:600;font-size:15px;margin-bottom:12px">确认站点屏蔽</div>
|
||||
<div style="font-size:13px;color:var(--color-text-secondary);line-height:1.6;margin-bottom:16px">
|
||||
将模型「<b>${modelName}</b>」加入站点「<b>${siteName}</b>」的禁用列表。<br/>执行后将自动触发路由重建,该站点下此模型的通道将不再生成。
|
||||
</div>
|
||||
<div style="display:flex;justify-content:flex-end;gap:8px">
|
||||
<button id="__sb_cancel" class="btn btn-ghost" style="padding:6px 16px">取消</button>
|
||||
<button id="__sb_confirm" class="btn btn-warning" style="padding:6px 16px">确认屏蔽</button>
|
||||
</div>
|
||||
`;
|
||||
overlay.appendChild(dialog);
|
||||
document.body.appendChild(overlay);
|
||||
dialog.querySelector('#__sb_cancel')!.addEventListener('click', () => { document.body.removeChild(overlay); resolve(false); });
|
||||
dialog.querySelector('#__sb_confirm')!.addEventListener('click', () => { document.body.removeChild(overlay); resolve(true); });
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) { document.body.removeChild(overlay); resolve(false); } });
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user