From cf6ed7db916fc9e36207c73a896554529c8f89eb Mon Sep 17 00:00:00 2001 From: Hureru <3507039083@qq.com> Date: Sat, 21 Mar 2026 17:55:25 +0800 Subject: [PATCH] feat: add model filter system with site-level batch disable, global brand filter, route status filter, and channel site-block shortcut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Route status filter: add enabled/disabled filter chips to RouteFilterBar - Channel site-block: add "站点屏蔽" button on channel rows to quickly add a model to a site's disabled list and trigger rebuild - Site batch selection: new GET /api/sites/:id/available-models endpoint; Sites.tsx now shows a brand-grouped checkbox list for batch model disable - Global brand filter: new brandMatcher.ts service; settings API and UI for blocking entire brands from route generation - Fix infinite scroll race condition when sentinel ref is null during IntersectionObserver effect setup after filter changes --- src/server/config.ts | 1 + src/server/index.ts | 5 + src/server/routes/api/settings.ts | 32 +++ src/server/routes/api/sites.ts | 44 ++++ src/server/services/brandMatcher.ts | 69 ++++++ src/server/services/modelService.ts | 6 + src/web/api.ts | 2 + src/web/pages/Settings.tsx | 76 ++++++- src/web/pages/Sites.tsx | 213 ++++++++++++++---- src/web/pages/TokenRoutes.tsx | 129 ++++++++++- src/web/pages/settings.factory-reset.test.tsx | 2 + .../settings.schedule-log-cleanup.test.tsx | 2 + src/web/pages/settings.system-proxy.test.tsx | 2 + src/web/pages/sites.create-redirect.test.tsx | 1 + .../pages/sites.disabled-models-save.test.tsx | 2 + src/web/pages/sites.edit-scroll.test.tsx | 1 + src/web/pages/token-routes/RouteCard.tsx | 8 + src/web/pages/token-routes/RouteFilterBar.tsx | 39 +++- .../pages/token-routes/SortableChannelRow.tsx | 34 ++- src/web/pages/token-routes/types.ts | 2 + 20 files changed, 621 insertions(+), 49 deletions(-) create mode 100644 src/server/services/brandMatcher.ts diff --git a/src/server/config.ts b/src/server/config.ts index 2b85533..30cf5c6 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -114,6 +114,7 @@ export function buildConfig(env: NodeJS.ProcessEnv) { proxyFileRetentionPruneIntervalMinutes: Math.max(1, Math.trunc(parseNumber(env.PROXY_FILE_RETENTION_PRUNE_INTERVAL_MINUTES, 60))), proxyErrorKeywords: parseCsvList(env.PROXY_ERROR_KEYWORDS), proxyEmptyContentFailEnabled: parseBoolean(env.PROXY_EMPTY_CONTENT_FAIL, false), + globalBlockedBrands: [] as string[], codexResponsesWebsocketBeta: parseOptionalSecret(env.CODEX_RESPONSES_WEBSOCKET_BETA) || 'responses_websockets=2026-02-06', codexHeaderDefaults: { userAgent: parseOptionalSecret(env.CODEX_HEADER_DEFAULTS_USER_AGENT), diff --git a/src/server/index.ts b/src/server/index.ts index 5eb80ad..b57da48 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -146,6 +146,11 @@ function applyRuntimeSettings(settingsMap: Map) { config.proxyEmptyContentFailEnabled = proxyEmptyContentFailEnabled; } + const globalBlockedBrands = parseSettingFromMap(settingsMap, 'global_blocked_brands'); + if (Array.isArray(globalBlockedBrands)) { + config.globalBlockedBrands = globalBlockedBrands.filter((b): b is string => typeof b === 'string').map((b) => b.trim()).filter(Boolean); + } + const codexHeaderDefaults = parseSettingFromMap(settingsMap, 'codex_header_defaults'); if (codexHeaderDefaults && typeof codexHeaderDefaults === 'object') { const next = codexHeaderDefaults as Record; diff --git a/src/server/routes/api/settings.ts b/src/server/routes/api/settings.ts index f99f2ba..f7c5fd9 100644 --- a/src/server/routes/api/settings.ts +++ b/src/server/routes/api/settings.ts @@ -5,6 +5,7 @@ import { config } from '../../config.js'; import { db, runtimeDbDialect, schema } from '../../db/index.js'; import { upsertSetting } from '../../db/upsertSetting.js'; import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; +import { getAllBrandNames } from '../../services/brandMatcher.js'; import { updateBalanceRefreshCron, updateCheckinSchedule, updateLogCleanupSettings } from '../../services/checkinScheduler.js'; import { sendNotification } from '../../services/notifyService.js'; import { @@ -71,6 +72,7 @@ interface RuntimeSettingsBody { routingWeights?: Partial; proxyErrorKeywords?: string[] | string; proxyEmptyContentFailEnabled?: boolean; + globalBlockedBrands?: string[]; } interface DatabaseMigrationBody { @@ -395,6 +397,17 @@ function applyImportedSettingToRuntime(key: string, value: unknown) { } return; } + case 'global_blocked_brands': { + try { + const parsed = typeof value === 'string' ? JSON.parse(value) : value; + if (Array.isArray(parsed)) { + config.globalBlockedBrands = parsed.filter((b): b is string => typeof b === 'string').map((b) => b.trim()).filter(Boolean); + } + } catch { + return; + } + return; + } case 'webhook_url': { if (typeof value !== 'string') return; config.webhookUrl = value.trim(); @@ -562,6 +575,7 @@ function getRuntimeSettingsResponse(currentAdminIp = '') { proxyErrorKeywords: config.proxyErrorKeywords, proxyEmptyContentFailEnabled: config.proxyEmptyContentFailEnabled, proxyTokenMasked: maskSecret(config.proxyToken), + globalBlockedBrands: config.globalBlockedBrands, }; } @@ -639,6 +653,10 @@ export async function settingsRoutes(app: FastifyInstance) { return getRuntimeSettingsResponse(currentAdminIp); }); + app.get('/api/settings/brand-list', async () => { + return { brands: getAllBrandNames() }; + }); + app.post<{ Body: SystemProxyTestBody }>('/api/settings/system-proxy/test', async (request, reply) => { const rawProxyUrl = request.body?.proxyUrl === undefined ? config.systemProxyUrl @@ -946,6 +964,20 @@ export async function settingsRoutes(app: FastifyInstance) { upsertSetting('proxy_empty_content_fail_enabled', config.proxyEmptyContentFailEnabled); } + if (body.globalBlockedBrands !== undefined) { + const nextBrands = Array.isArray(body.globalBlockedBrands) + ? body.globalBlockedBrands.filter((b): b is string => typeof b === 'string').map((b) => b.trim()).filter(Boolean) + : []; + const uniqueBrands = Array.from(new Set(nextBrands)); + const prev = JSON.stringify(config.globalBlockedBrands); + const next = JSON.stringify(uniqueBrands); + if (prev !== next) { + changedLabels.push('全局品牌屏蔽'); + } + config.globalBlockedBrands = uniqueBrands; + upsertSetting('global_blocked_brands', JSON.stringify(uniqueBrands)); + } + if (body.webhookUrl !== undefined) { if (String(body.webhookUrl || '').trim() !== config.webhookUrl) { changedLabels.push('Webhook 地址'); diff --git a/src/server/routes/api/sites.ts b/src/server/routes/api/sites.ts index 294379d..0fdf23d 100644 --- a/src/server/routes/api/sites.ts +++ b/src/server/routes/api/sites.ts @@ -497,6 +497,50 @@ export async function sitesRoutes(app: FastifyInstance) { return { siteId: id, models: uniqueModels }; }); + // Get all discovered models for a site (from model_availability and token_model_availability) + app.get<{ Params: { id: string } }>('/api/sites/:id/available-models', async (request, reply) => { + const id = parseInt(request.params.id); + if (Number.isNaN(id)) { + return reply.code(400).send({ error: 'Invalid site id' }); + } + const existingSite = await db.select().from(schema.sites).where(eq(schema.sites.id, id)).get(); + if (!existingSite) { + return reply.code(404).send({ error: 'Site not found' }); + } + + // Get models from model_availability (account-level) + const accountModels = await db.select({ modelName: schema.modelAvailability.modelName }) + .from(schema.modelAvailability) + .innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id)) + .where( + and( + eq(schema.accounts.siteId, id), + eq(schema.modelAvailability.available, true), + ), + ) + .all(); + + // Get models from token_model_availability (token-level) + const tokenModels = await db.select({ modelName: schema.tokenModelAvailability.modelName }) + .from(schema.tokenModelAvailability) + .innerJoin(schema.accountTokens, eq(schema.tokenModelAvailability.tokenId, schema.accountTokens.id)) + .innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id)) + .where( + and( + eq(schema.accounts.siteId, id), + eq(schema.tokenModelAvailability.available, true), + ), + ) + .all(); + + const models = Array.from(new Set([ + ...accountModels.map((r) => r.modelName), + ...tokenModels.map((r) => r.modelName), + ])).filter((m) => m.trim().length > 0).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })); + + return { siteId: id, models }; + }); + // Detect platform for a URL app.post<{ Body: { url: string } }>('/api/sites/detect', async (request) => { const result = await detectSite(request.body.url); diff --git a/src/server/services/brandMatcher.ts b/src/server/services/brandMatcher.ts new file mode 100644 index 0000000..db247d5 --- /dev/null +++ b/src/server/services/brandMatcher.ts @@ -0,0 +1,69 @@ +/** + * Server-side brand matching for global brand filtering. + * + * Brand keywords are extracted from the frontend BrandIcon.tsx BRAND_DEFINITIONS. + * Each brand maps to a set of keywords that, when found in a model name, identify + * the model as belonging to that brand. + */ + +const BRAND_KEYWORDS: Record = { + 'OpenAI': ['gpt', 'chatgpt', 'dall-e', 'whisper', 'text-embedding', 'text-moderation', 'davinci', 'babbage', 'codex-mini', 'o1', 'o3', 'o4', 'tts'], + 'Anthropic': ['claude'], + 'Google': ['gemini', 'gemma', 'palm', 'paligemma', 'shieldgemma', 'recurrentgemma', 'deplot', 'codegemma', 'imagen', 'learnlm', 'aqa', 'veo'], + 'DeepSeek': ['deepseek', 'ds-chat'], + '通义千问': ['qwen', 'qwq', 'tongyi'], + '智谱 AI': ['glm', 'chatglm', 'codegeex', 'cogview', 'cogvideo'], + 'Meta': ['llama', 'code-llama', 'codellama'], + 'Mistral': ['mistral', 'mixtral', 'codestral', 'pixtral', 'ministral', 'voxtral', 'magistral'], + 'Moonshot': ['moonshot', 'kimi'], + '零一万物': ['yi-'], + '文心一言': ['ernie', 'eb-'], + '讯飞星火': ['spark', 'generalv'], + '腾讯混元': ['hunyuan', 'tencent-hunyuan'], + '豆包': ['doubao'], + 'MiniMax': ['minimax', 'abab'], + 'Cohere': ['command', 'c4ai-'], + 'Microsoft': ['phi-', 'kosmos'], + 'xAI': ['grok'], + '阶跃星辰': ['stepfun', 'step-'], + 'Stability': ['flux', 'stablediffusion', 'stable-diffusion', 'sdxl', 'sd3'], + 'NVIDIA': ['nemotron', 'nemoretriever', 'neva', 'riva-translate', 'cosmos'], + 'IBM': ['granite'], + 'BAAI': ['bge-'], + 'ByteDance': ['bytedance', 'seed-oss', 'kolors', 'kwai', 'kwaipilot', 'wan-', 'kat-'], + 'InternLM': ['internlm'], + 'Midjourney': ['midjourney', 'mj_'], + 'DeepL': ['deepl-', 'deepl/'], + 'Jina AI': ['jina'], +}; + +/** + * Get all available brand names. + */ +export function getAllBrandNames(): string[] { + return Object.keys(BRAND_KEYWORDS); +} + +/** + * Get the flattened list of keywords for blocked brands. + */ +export function getBlockedModelKeywords(blockedBrands: string[]): string[] { + const keywords: string[] = []; + for (const brand of blockedBrands) { + const brandKeywords = BRAND_KEYWORDS[brand]; + if (brandKeywords) { + keywords.push(...brandKeywords); + } + } + return keywords; +} + +/** + * Check if a model name matches any of the blocked keywords. + * Uses a case-insensitive substring match (same approach as frontend BrandIcon). + */ +export function isModelBlockedByBrand(modelName: string, blockedKeywords: string[]): boolean { + const lower = modelName.trim().toLowerCase(); + if (!lower) return false; + return blockedKeywords.some((keyword) => lower.includes(keyword)); +} diff --git a/src/server/services/modelService.ts b/src/server/services/modelService.ts index 7ab8a30..87fb0ef 100644 --- a/src/server/services/modelService.ts +++ b/src/server/services/modelService.ts @@ -18,6 +18,8 @@ import { supportsDirectAccountRoutingConnection, } from './accountExtraConfig.js'; import { invalidateTokenRouterCache } from './tokenRouter.js'; +import { getBlockedModelKeywords, isModelBlockedByBrand } from './brandMatcher.js'; +import { config } from '../config.js'; import { setAccountRuntimeHealth } from './accountHealthService.js'; import { clearAllRouteDecisionSnapshots } from './routeDecisionSnapshotStore.js'; import { withAccountProxyOverride, withExplicitProxyRequestInit, withSiteRecordProxyRequestInit } from './siteProxy.js'; @@ -1139,11 +1141,15 @@ export async function rebuildTokenRoutesFromAvailability() { return !!disabled && disabled.has(modelName); } + // Load global brand filter + const blockedBrandKeywords = getBlockedModelKeywords(config.globalBlockedBrands); + const modelCandidates = new Map>(); const addModelCandidate = (modelNameRaw: string | null | undefined, accountId: number, tokenId: number | null, siteId: number) => { const modelName = (modelNameRaw || '').trim(); if (!modelName) return; if (isModelDisabledForSite(siteId, modelName)) return; + if (blockedBrandKeywords.length > 0 && isModelBlockedByBrand(modelName, blockedBrandKeywords)) return; if (!modelCandidates.has(modelName)) modelCandidates.set(modelName, new Map()); const candidateKey = `${accountId}:${tokenId ?? 'account'}`; modelCandidates.get(modelName)!.set(candidateKey, { accountId, tokenId }); diff --git a/src/web/api.ts b/src/web/api.ts index 9b76f6e..ba86022 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -421,6 +421,7 @@ export const api = { 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 }) }), + getSiteAvailableModels: (siteId: number) => request(`/api/sites/${siteId}/available-models`), // Accounts getAccounts: () => request('/api/accounts'), @@ -570,6 +571,7 @@ export const api = { method: 'POST', body: JSON.stringify({ oldToken, newToken }), }), getRuntimeSettings: () => request('/api/settings/runtime'), + getBrandList: () => request('/api/settings/brand-list'), updateRuntimeSettings: (data: any) => request('/api/settings/runtime', { method: 'PUT', body: JSON.stringify(data), diff --git a/src/web/pages/Settings.tsx b/src/web/pages/Settings.tsx index a3ea17c..65950dd 100644 --- a/src/web/pages/Settings.tsx +++ b/src/web/pages/Settings.tsx @@ -54,6 +54,7 @@ type RuntimeSettings = { proxyTokenMasked?: string; adminIpAllowlist?: string[]; currentAdminIp?: string; + globalBlockedBrands?: string[]; }; type SystemProxyTestState = @@ -234,6 +235,9 @@ export default function Settings() { const [savingProxyFailureRules, setSavingProxyFailureRules] = useState(false); const [savingRouting, setSavingRouting] = useState(false); const [showAdvancedRouting, setShowAdvancedRouting] = useState(false); + const [allBrandNames, setAllBrandNames] = useState([]); + const [blockedBrands, setBlockedBrands] = useState([]); + const [savingBrandFilter, setSavingBrandFilter] = useState(false); const [savingSecurity, setSavingSecurity] = useState(false); const [adminIpAllowlistText, setAdminIpAllowlistText] = useState(''); const [clearingCache, setClearingCache] = useState(false); @@ -413,13 +417,15 @@ export default function Settings() { const loadSettings = async () => { setLoading(true); try { - const [authInfo, runtimeInfo, downstreamInfo, routeRows, runtimeDatabaseInfo] = await Promise.all([ + const [authInfo, runtimeInfo, downstreamInfo, routeRows, runtimeDatabaseInfo, brandListRes] = await Promise.all([ api.getAuthInfo(), api.getRuntimeSettings(), api.getDownstreamApiKeys(), api.getRoutesLite(), api.getRuntimeDatabaseConfig(), + api.getBrandList(), ]); + setAllBrandNames(Array.isArray(brandListRes?.brands) ? brandListRes.brands : []); setMaskedToken(authInfo.masked || '****'); setRuntime({ checkinCron: runtimeInfo.checkinCron || '0 8 * * *', @@ -451,7 +457,9 @@ export default function Settings() { ? runtimeInfo.adminIpAllowlist.filter((item: unknown) => typeof item === 'string') : [], currentAdminIp: typeof runtimeInfo.currentAdminIp === 'string' ? runtimeInfo.currentAdminIp : '', + globalBlockedBrands: Array.isArray(runtimeInfo.globalBlockedBrands) ? runtimeInfo.globalBlockedBrands : [], }); + setBlockedBrands(Array.isArray(runtimeInfo.globalBlockedBrands) ? runtimeInfo.globalBlockedBrands : []); setProxyErrorKeywordsText( Array.isArray(runtimeInfo.proxyErrorKeywords) ? runtimeInfo.proxyErrorKeywords.filter((item: unknown) => typeof item === 'string').join('\n') @@ -816,6 +824,28 @@ export default function Settings() { })); }; + const handleSaveBrandFilter = async () => { + setSavingBrandFilter(true); + try { + const res = await api.updateRuntimeSettings({ globalBlockedBrands: blockedBrands }); + setRuntime((prev) => ({ + ...prev, + globalBlockedBrands: Array.isArray(res?.globalBlockedBrands) ? res.globalBlockedBrands : blockedBrands, + })); + toast.success('品牌屏蔽设置已保存'); + try { + await api.rebuildRoutes(false); + toast.success('路由已重建'); + } catch { + toast.error('品牌屏蔽已保存,但路由重建失败,请手动重建'); + } + } catch (err: any) { + toast.error(err?.message || '保存品牌屏蔽设置失败'); + } finally { + setSavingBrandFilter(false); + } + }; + const saveSecuritySettings = async () => { setSavingSecurity(true); try { @@ -1426,7 +1456,51 @@ export default function Settings() { + {/* Global Brand Filter */}
+
全局品牌屏蔽
+
+ 屏蔽选定品牌后,路由重建时将自动跳过匹配该品牌的所有模型。点击品牌切换屏蔽状态,保存后自动触发路由重建。 +
+
+ {allBrandNames.map((brand) => { + const isBlocked = blockedBrands.includes(brand); + return ( + + ); + })} + {allBrandNames.length === 0 && ( + 加载品牌列表中... + )} +
+ {blockedBrands.length > 0 && ( +
+ 已屏蔽 {blockedBrands.length} 个品牌:{blockedBrands.join('、')} +
+ )} + +
+ +
数据库迁移(SQLite / MySQL / PostgreSQL)
可先测试连接,再迁移数据;迁移完成后可保存为运行数据库配置(重启容器后生效)。 diff --git a/src/web/pages/Sites.tsx b/src/web/pages/Sites.tsx index adcf76e..e54af28 100644 --- a/src/web/pages/Sites.tsx +++ b/src/web/pages/Sites.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { api } from '../api.js'; +import { getBrand } from '../components/BrandIcon.js'; import CenteredModal from '../components/CenteredModal.js'; import MobileBatchBar from '../components/MobileBatchBar.js'; import MobileFilterSheet from '../components/MobileFilterSheet.js'; @@ -222,6 +223,8 @@ export default function Sites() { const [disabledModelInput, setDisabledModelInput] = useState(''); const [disabledModelsLoading, setDisabledModelsLoading] = useState(false); const [disabledModelsSaving, setDisabledModelsSaving] = useState(false); + const [availableModels, setAvailableModels] = useState([]); + const [disabledModelSearch, setDisabledModelSearch] = useState(''); if (editor) lastEditorRef.current = editor; const activeEditor = editor || lastEditorRef.current; @@ -326,12 +329,20 @@ export default function Sites() { setEditor({ mode: 'edit', editingSiteId: site.id }); setForm(siteFormFromSite(site)); scrollToEditorTop(); - // Load disabled models for this site + // Load disabled models and available models for this site setDisabledModelsLoading(true); setDisabledModels([]); setDisabledModelInput(''); - api.getSiteDisabledModels(site.id) - .then((res: any) => setDisabledModels(Array.isArray(res?.models) ? res.models : [])) + setAvailableModels([]); + setDisabledModelSearch(''); + Promise.all([ + api.getSiteDisabledModels(site.id), + api.getSiteAvailableModels(site.id), + ]) + .then(([disabledRes, availableRes]: any[]) => { + setDisabledModels(Array.isArray(disabledRes?.models) ? disabledRes.models : []); + setAvailableModels(Array.isArray(availableRes?.models) ? availableRes.models : []); + }) .catch(() => { }) .finally(() => setDisabledModelsLoading(false)); }; @@ -913,7 +924,7 @@ export default function Sites() {
禁用模型管理
- 在此站点禁用指定模型后,路由重建时将不为该站点的这些模型创建通道。 + 在此站点禁用指定模型后,路由重建时将不为该站点的这些模型创建通道。勾选表示禁用该模型。
{disabledModelsLoading ? (
@@ -921,34 +932,155 @@ export default function Sites() {
) : ( <> -
- {disabledModels.length === 0 && ( - 暂无禁用模型 - )} - {disabledModels.map((model) => ( - - {model} - - - ))} -
+ {/* Search and brand group controls */} + {availableModels.length > 0 && ( +
+ setDisabledModelSearch(e.target.value)} + style={{ + width: '100%', padding: '6px 10px', border: '1px solid var(--color-border)', + borderRadius: 'var(--radius-sm)', fontSize: 12, outline: 'none', + background: 'var(--color-bg)', color: 'var(--color-text-primary)', marginBottom: 8, + }} + /> + {/* Brand group quick actions */} + {(() => { + const brandGroups = new Map(); + 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 ( +
+ 按品牌全选: + {sortedBrands.map(([brandName, models]) => { + const allDisabled = models.every((m) => disabledModels.includes(m)); + return ( + + ); + })} +
+ ); + })()} + {/* Checkbox list */} +
+ {(() => { + const q = disabledModelSearch.trim().toLowerCase(); + const filtered = q ? availableModels.filter((m) => m.toLowerCase().includes(q)) : availableModels; + if (filtered.length === 0) { + return
无匹配模型
; + } + // Group by brand + const brandGroups = new Map(); + 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]) => ( +
+
+ {brandName} ({models.length}) +
+ {models.map((model) => ( + + ))} +
+ )); + })()} +
+
+ )} + + {/* Manually disabled models not in available list */} + {(() => { + const manualOnly = disabledModels.filter((m) => !availableModels.includes(m)); + if (manualOnly.length === 0) return null; + return ( +
+
手动添加的禁用模型(不在站点已发现列表中):
+
+ {manualOnly.map((model) => ( + + {model} + + + ))} +
+
+ ); + })()} + + {/* Manual input for models not in the list */}
setDisabledModelInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddDisabledModel(); } }} @@ -962,14 +1094,19 @@ export default function Sites() { 添加
- +
+ + + 已禁用 {disabledModels.length} 个模型 + +
)}
diff --git a/src/web/pages/TokenRoutes.tsx b/src/web/pages/TokenRoutes.tsx index 6608af8..200a88f 100644 --- a/src/web/pages/TokenRoutes.tsx +++ b/src/web/pages/TokenRoutes.tsx @@ -54,7 +54,7 @@ import { getModelPatternError, } from './token-routes/utils.js'; import { useRouteChannels } from './token-routes/useRouteChannels.js'; -import RouteFilterBar from './token-routes/RouteFilterBar.js'; +import RouteFilterBar, { type EnabledFilter } from './token-routes/RouteFilterBar.js'; import ManualRoutePanel from './token-routes/ManualRoutePanel.js'; import RouteCard from './token-routes/RouteCard.js'; import AddChannelModal from './token-routes/AddChannelModal.js'; @@ -119,6 +119,7 @@ export default function TokenRoutes() { const [activeSite, setActiveSite] = useState(null); const [activeEndpointType, setActiveEndpointType] = useState(null); const [activeGroupFilter, setActiveGroupFilter] = useState(null); + const [enabledFilter, setEnabledFilter] = useState('all'); const [filterCollapsed, setFilterCollapsed] = useState(true); const [showFilters, setShowFilters] = useState(false); const [showZeroChannelRoutes, setShowZeroChannelRoutes] = useState(false); @@ -640,6 +641,16 @@ 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') { @@ -655,6 +666,12 @@ export default function TokenRoutes() { const filteredRoutes = useMemo(() => { let list = sortedRoutes; + if (enabledFilter === 'enabled') { + list = list.filter((route) => route.enabled); + } else if (enabledFilter === 'disabled') { + list = list.filter((route) => !route.enabled); + } + if (activeGroupFilter === '__all__') { list = list.filter((route) => !isRouteExactModel(route)); } else if (typeof activeGroupFilter === 'number') { @@ -694,6 +711,7 @@ export default function TokenRoutes() { return list; }, [ sortedRoutes, + enabledFilter, activeGroupFilter, activeBrand, activeSite, @@ -713,6 +731,8 @@ export default function TokenRoutes() { const loadMoreSentinelRef = useRef(null); + const shouldShowLoadMore = filteredRoutes.length > 0 && visibleRouteCount < filteredRoutes.length; + useEffect(() => { const el = loadMoreSentinelRef.current; if (!el) return; @@ -722,7 +742,7 @@ export default function TokenRoutes() { ); observer.observe(el); return () => observer.disconnect(); - }, [handleLoadMoreRoutes]); + }, [handleLoadMoreRoutes, shouldShowLoadMore]); const visibleRoutes = useMemo( () => filteredRoutes.slice(0, visibleRouteCount), @@ -763,12 +783,45 @@ export default function TokenRoutes() { }; const handleDeleteChannel = async (channelId: number, routeId: number) => { + const dismissedKey = 'metapi:channel-delete-warning-dismissed'; + const dismissed = localStorage.getItem(dismissedKey) === 'true'; + if (!dismissed) { + const dontAskAgain = { checked: false }; + const confirmed = await new Promise((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 = ` +
确认移除通道
+
+ 移除的通道会在定时模型刷新时被自动重建恢复。
如果只是想临时停用通道,建议使用禁用开关。 +
+ +
+ + +
+ `; + overlay.appendChild(dialog); + document.body.appendChild(overlay); + dialog.querySelector('#__ch_del_cancel')!.addEventListener('click', () => { document.body.removeChild(overlay); resolve(false); }); + dialog.querySelector('#__ch_del_confirm')!.addEventListener('click', () => { + dontAskAgain.checked = (dialog.querySelector('#__ch_del_dismiss') as HTMLInputElement).checked; + document.body.removeChild(overlay); + resolve(true); + }); + overlay.addEventListener('click', (e) => { if (e.target === overlay) { document.body.removeChild(overlay); resolve(false); } }); + }); + if (!confirmed) return; + if (dontAskAgain.checked) localStorage.setItem(dismissedKey, 'true'); + } try { await api.deleteChannel(channelId); toast.success('通道已移除'); - // Reload channels for this route await loadChannels(routeId, true); - // Update channel count in summary setRouteSummaries((prev) => prev.map((r) => r.id === routeId ? { ...r, channelCount: Math.max(0, r.channelCount - 1) } : r), ); @@ -777,6 +830,16 @@ export default function TokenRoutes() { } }; + const handleToggleChannelEnabled = async (channelId: number, routeId: number, enabled: boolean) => { + try { + await api.updateChannel(channelId, { enabled }); + toast.success(enabled ? '通道已启用' : '通道已禁用'); + await loadChannels(routeId, true); + } catch (e: any) { + toast.error(e.message || '更新通道状态失败'); + } + }; + const handleChannelTokenSave = async (routeId: number, channelId: number, accountId: number) => { const tokenId = channelTokenDraft[channelId]; const tokenOptions = getRouteCandidateView(routeId).tokenOptionsByAccountId[accountId] || []; @@ -847,6 +910,42 @@ export default function TokenRoutes() { } }; + const handleSiteBlockModel = async (channelId: number, routeId: number) => { + const channels = channelsByRouteId[routeId] || []; + const channel = channels.find((c) => c.id === channelId); + if (!channel?.site?.id) { + toast.error('找不到通道对应的站点信息'); + return; + } + const route = routeSummaries.find((r) => r.id === routeId); + const modelName = channel.sourceModel || route?.modelPattern || ''; + if (!modelName) { + toast.error('找不到要屏蔽的模型名'); + return; + } + const siteName = channel.site.name || '未知站点'; + const confirmed = window.confirm( + `确定将模型「${modelName}」加入站点「${siteName}」的禁用列表吗?\n\n执行后将自动触发路由重建,该站点下此模型的通道将不再生成。`, + ); + if (!confirmed) return; + + try { + const siteId = channel.site.id; + const existing = await api.getSiteDisabledModels(siteId); + const currentModels: string[] = existing?.models || []; + if (currentModels.includes(modelName)) { + toast.info(`模型「${modelName}」已在站点「${siteName}」的禁用列表中`); + return; + } + await api.updateSiteDisabledModels(siteId, [...currentModels, modelName]); + toast.success(`已将「${modelName}」加入站点「${siteName}」的禁用列表,正在重建路由...`); + await api.rebuildRoutes(false); + await load(); + } catch (e: any) { + toast.error(e.message || '站点屏蔽模型失败'); + } + }; + const toggleExpand = async (routeId: number) => { const isCurrentlyExpanded = expandedRouteIds.includes(routeId); if (isCurrentlyExpanded) { @@ -985,6 +1084,12 @@ export default function TokenRoutes() { (channelId: number, routeId: number) => handleDeleteChannelRef.current(channelId, routeId), [], ); + const handleToggleChannelEnabledRef = useRef(handleToggleChannelEnabled); + handleToggleChannelEnabledRef.current = handleToggleChannelEnabled; + const stableToggleChannelEnabled = useCallback( + (channelId: number, routeId: number, enabled: boolean) => handleToggleChannelEnabledRef.current(channelId, routeId, enabled), + [], + ); const handleChannelDragEndRef = useRef(handleChannelDragEnd); handleChannelDragEndRef.current = handleChannelDragEnd; const stableChannelDragEnd = useCallback( @@ -997,6 +1102,12 @@ export default function TokenRoutes() { (accountId: number, modelName: string) => handleCreateTokenRef.current(accountId, modelName), [], ); + const handleSiteBlockModelRef = useRef(handleSiteBlockModel); + handleSiteBlockModelRef.current = handleSiteBlockModel; + const stableSiteBlockModel = useCallback( + (channelId: number, routeId: number) => handleSiteBlockModelRef.current(channelId, routeId), + [], + ); const addChannelModalRoute = addChannelModalRouteId ? routeSummaries.find((r) => r.id === addChannelModalRouteId) || null @@ -1133,6 +1244,9 @@ export default function TokenRoutes() { setActiveEndpointType={setActiveEndpointType} activeGroupFilter={activeGroupFilter} setActiveGroupFilter={setActiveGroupFilter} + enabledFilter={enabledFilter} + setEnabledFilter={setEnabledFilter} + enabledCounts={enabledCounts} brandList={brandList} siteList={siteList} endpointTypeList={endpointTypeList} @@ -1153,6 +1267,9 @@ export default function TokenRoutes() { setActiveEndpointType={setActiveEndpointType} activeGroupFilter={activeGroupFilter} setActiveGroupFilter={setActiveGroupFilter} + enabledFilter={enabledFilter} + setEnabledFilter={setEnabledFilter} + enabledCounts={enabledCounts} brandList={brandList} siteList={siteList} endpointTypeList={endpointTypeList} @@ -1311,11 +1428,13 @@ export default function TokenRoutes() { onTokenDraftChange={stableTokenDraftChange} onSaveToken={stableChannelTokenSave} onDeleteChannel={stableDeleteChannel} + onToggleChannelEnabled={stableToggleChannelEnabled} onChannelDragEnd={stableChannelDragEnd} missingTokenSiteItems={missingTokenSiteItemsByRouteId[route.id] || EMPTY_MISSING_ITEMS} missingTokenGroupItems={missingTokenGroupItemsByRouteId[route.id] || EMPTY_MISSING_GROUP_ITEMS} onCreateTokenForMissing={stableCreateTokenForMissing} onAddChannel={stableAddChannel} + onSiteBlockModel={stableSiteBlockModel} expandedSourceGroupMap={expandedSourceGroupMap} onToggleSourceGroup={stableToggleSourceGroup} /> @@ -1323,7 +1442,7 @@ export default function TokenRoutes() { })}
- {filteredRoutes.length > 0 && visibleRouteCount < filteredRoutes.length && ( + {shouldShowLoadMore && (
({ getDownstreamApiKeys: vi.fn(), getRoutesLite: vi.fn(), getRuntimeDatabaseConfig: vi.fn(), + getBrandList: vi.fn(), factoryReset: vi.fn(), }, })); @@ -77,6 +78,7 @@ describe('Settings factory reset', () => { }); apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] }); apiMock.getRoutesLite.mockResolvedValue([]); + apiMock.getBrandList.mockResolvedValue({ brands: [] }); apiMock.getRuntimeDatabaseConfig.mockResolvedValue({ active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false }, saved: null, diff --git a/src/web/pages/settings.schedule-log-cleanup.test.tsx b/src/web/pages/settings.schedule-log-cleanup.test.tsx index d9e8aee..b64112d 100644 --- a/src/web/pages/settings.schedule-log-cleanup.test.tsx +++ b/src/web/pages/settings.schedule-log-cleanup.test.tsx @@ -12,6 +12,7 @@ const { apiMock } = vi.hoisted(() => ({ getDownstreamApiKeys: vi.fn(), getRoutesLite: vi.fn(), getRuntimeDatabaseConfig: vi.fn(), + getBrandList: vi.fn(), updateRuntimeSettings: vi.fn(), triggerCheckinAll: vi.fn(), }, @@ -62,6 +63,7 @@ describe('Settings log cleanup schedule', () => { }); apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] }); apiMock.getRoutesLite.mockResolvedValue([]); + apiMock.getBrandList.mockResolvedValue({ brands: [] }); apiMock.getRuntimeDatabaseConfig.mockResolvedValue({ active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false }, saved: null, diff --git a/src/web/pages/settings.system-proxy.test.tsx b/src/web/pages/settings.system-proxy.test.tsx index fab13e5..c899d41 100644 --- a/src/web/pages/settings.system-proxy.test.tsx +++ b/src/web/pages/settings.system-proxy.test.tsx @@ -11,6 +11,7 @@ const { apiMock } = vi.hoisted(() => ({ getDownstreamApiKeys: vi.fn(), getRoutesLite: vi.fn(), getRuntimeDatabaseConfig: vi.fn(), + getBrandList: vi.fn(), updateRuntimeSettings: vi.fn(), testSystemProxy: vi.fn(), }, @@ -59,6 +60,7 @@ describe('Settings system proxy', () => { }); apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] }); apiMock.getRoutesLite.mockResolvedValue([]); + apiMock.getBrandList.mockResolvedValue({ brands: [] }); apiMock.getRuntimeDatabaseConfig.mockResolvedValue({ active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false }, saved: null, diff --git a/src/web/pages/sites.create-redirect.test.tsx b/src/web/pages/sites.create-redirect.test.tsx index a8f488d..ff3774f 100644 --- a/src/web/pages/sites.create-redirect.test.tsx +++ b/src/web/pages/sites.create-redirect.test.tsx @@ -10,6 +10,7 @@ const { apiMock } = vi.hoisted(() => ({ getSites: vi.fn(), addSite: vi.fn(), getSiteDisabledModels: vi.fn().mockResolvedValue({ models: [] }), + getSiteAvailableModels: vi.fn().mockResolvedValue({ models: [] }), }, })); diff --git a/src/web/pages/sites.disabled-models-save.test.tsx b/src/web/pages/sites.disabled-models-save.test.tsx index 4cb7757..175e8a8 100644 --- a/src/web/pages/sites.disabled-models-save.test.tsx +++ b/src/web/pages/sites.disabled-models-save.test.tsx @@ -9,6 +9,7 @@ const { apiMock, toastMock } = vi.hoisted(() => ({ apiMock: { getSites: vi.fn(), getSiteDisabledModels: vi.fn(), + getSiteAvailableModels: vi.fn(), updateSiteDisabledModels: vi.fn(), rebuildRoutes: vi.fn(), }, @@ -57,6 +58,7 @@ describe('Sites disabled models save', () => { }, ]); apiMock.getSiteDisabledModels.mockResolvedValue({ models: [] }); + apiMock.getSiteAvailableModels.mockResolvedValue({ models: [] }); apiMock.updateSiteDisabledModels.mockResolvedValue({ success: true }); }); diff --git a/src/web/pages/sites.edit-scroll.test.tsx b/src/web/pages/sites.edit-scroll.test.tsx index 6b2c723..dc87e90 100644 --- a/src/web/pages/sites.edit-scroll.test.tsx +++ b/src/web/pages/sites.edit-scroll.test.tsx @@ -8,6 +8,7 @@ const { apiMock } = vi.hoisted(() => ({ apiMock: { getSites: vi.fn(), getSiteDisabledModels: vi.fn().mockResolvedValue({ models: [] }), + getSiteAvailableModels: vi.fn().mockResolvedValue({ models: [] }), }, })); diff --git a/src/web/pages/token-routes/RouteCard.tsx b/src/web/pages/token-routes/RouteCard.tsx index 06eaa99..3564e26 100644 --- a/src/web/pages/token-routes/RouteCard.tsx +++ b/src/web/pages/token-routes/RouteCard.tsx @@ -61,6 +61,7 @@ type RouteCardProps = { onTokenDraftChange: (channelId: number, tokenId: number) => void; onSaveToken: (routeId: number, channelId: number, accountId: number) => void; onDeleteChannel: (channelId: number, routeId: number) => void; + onToggleChannelEnabled: (channelId: number, routeId: number, enabled: boolean) => void; onChannelDragEnd: (routeId: number, event: DragEndEvent) => void; // Missing token hints missingTokenSiteItems: MissingTokenRouteSiteActionItem[]; @@ -68,6 +69,8 @@ type RouteCardProps = { onCreateTokenForMissing: (accountId: number, modelName: string) => void; // Add channel onAddChannel: (routeId: number) => void; + // Site block model + onSiteBlockModel: (channelId: number, routeId: number) => void; // Source group expansion expandedSourceGroupMap: Record; onToggleSourceGroup: (groupKey: string) => void; @@ -119,11 +122,13 @@ function RouteCardInner({ onTokenDraftChange, onSaveToken, onDeleteChannel, + onToggleChannelEnabled, onChannelDragEnd, missingTokenSiteItems, missingTokenGroupItems, onCreateTokenForMissing, onAddChannel, + onSiteBlockModel, expandedSourceGroupMap, onToggleSourceGroup, }: RouteCardProps) { @@ -548,6 +553,7 @@ function RouteCardInner({ onTokenDraftChange={onTokenDraftChange} onSaveToken={() => onSaveToken(route.id, channel.id, channel.accountId)} onDeleteChannel={() => onDeleteChannel(channel.id, route.id)} + onToggleEnabled={(enabled) => onToggleChannelEnabled(channel.id, route.id, enabled)} /> ); })} @@ -572,6 +578,8 @@ function RouteCardInner({ onTokenDraftChange={onTokenDraftChange} onSaveToken={() => onSaveToken(route.id, channel.id, channel.accountId)} onDeleteChannel={() => onDeleteChannel(channel.id, route.id)} + onToggleEnabled={(enabled) => onToggleChannelEnabled(channel.id, route.id, enabled)} + onSiteBlockModel={() => onSiteBlockModel(channel.id, route.id)} /> ); })} diff --git a/src/web/pages/token-routes/RouteFilterBar.tsx b/src/web/pages/token-routes/RouteFilterBar.tsx index edc3824..8285bd8 100644 --- a/src/web/pages/token-routes/RouteFilterBar.tsx +++ b/src/web/pages/token-routes/RouteFilterBar.tsx @@ -5,6 +5,8 @@ import { tr } from '../../i18n.js'; import type { GroupFilter, GroupRouteItem } from './types.js'; import { resolveEndpointTypeIconModel, siteAvatarLetters } from './utils.js'; +export type EnabledFilter = 'all' | 'enabled' | 'disabled'; + type RouteFilterBarProps = { totalRouteCount: number; activeBrand: string | null; @@ -15,6 +17,9 @@ type RouteFilterBarProps = { setActiveEndpointType: (endpointType: string | null) => void; activeGroupFilter: GroupFilter; setActiveGroupFilter: (filter: GroupFilter) => void; + enabledFilter: EnabledFilter; + setEnabledFilter: (filter: EnabledFilter) => void; + enabledCounts: { enabled: number; disabled: number }; brandList: { list: [string, { count: number; brand: BrandInfo }][]; otherCount: number }; siteList: [string, { count: number; siteId: number }][]; endpointTypeList: [string, number][]; @@ -63,8 +68,11 @@ function ActiveFilterSummary({ activeSite, activeGroupFilter, activeEndpointType, -}: Pick) { + enabledFilter, +}: Pick) { const tags: string[] = []; + if (enabledFilter === 'enabled') tags.push('状态=启用'); + else if (enabledFilter === 'disabled') tags.push('状态=禁用'); if (activeBrand) tags.push(`品牌=${activeBrand === '__other__' ? '其他' : activeBrand}`); if (activeSite) tags.push(`站点=${activeSite}`); if (activeGroupFilter === '__all__') tags.push('群组=全部'); @@ -86,6 +94,9 @@ export default function RouteFilterBar(props: RouteFilterBarProps) { setActiveEndpointType, activeGroupFilter, setActiveGroupFilter, + enabledFilter, + setEnabledFilter, + enabledCounts, brandList, siteList, endpointTypeList, @@ -117,12 +128,38 @@ export default function RouteFilterBar(props: RouteFilterBarProps) { activeSite={activeSite} activeGroupFilter={activeGroupFilter} activeEndpointType={activeEndpointType} + enabledFilter={enabledFilter} /> {/* Expanded panel */} {expandPresence.shouldRender && (
+ {/* Status row */} + + ✦} + onClick={() => setEnabledFilter('all')} + /> + ●} + onClick={() => setEnabledFilter(enabledFilter === 'enabled' ? 'all' : 'enabled')} + /> + ●} + onClick={() => setEnabledFilter(enabledFilter === 'disabled' ? 'all' : 'disabled')} + /> + + {/* Brand row */} ) : null} + {channel.enabled === false ? ( + 已禁用 + ) : null} +
选中概率
@@ -502,11 +508,31 @@ export function SortableChannelRow({
+ +
+ {onSiteBlockModel && channel.site?.id ? ( + + ) : null} + + +
) ) : null} diff --git a/src/web/pages/token-routes/types.ts b/src/web/pages/token-routes/types.ts index 6474033..c0a7197 100644 --- a/src/web/pages/token-routes/types.ts +++ b/src/web/pages/token-routes/types.ts @@ -161,6 +161,8 @@ export type SortableChannelRowProps = { onTokenDraftChange: (channelId: number, tokenId: number) => void; onSaveToken: () => void; onDeleteChannel: () => void; + onToggleEnabled: (enabled: boolean) => void; + onSiteBlockModel?: () => void; }; export type GroupRouteItem = {