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..23af343 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,29 @@ 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)) { + const nextBrands = parsed.filter((b): b is string => typeof b === 'string').map((b) => b.trim()).filter(Boolean); + const prev = JSON.stringify(config.globalBlockedBrands); + config.globalBlockedBrands = nextBrands; + if (prev !== JSON.stringify(nextBrands)) { + startBackgroundTask( + { + type: 'maintenance', + title: '品牌屏蔽变更后重建路由', + dedupeKey: 'refresh-models-and-rebuild-routes', + }, + async () => refreshModelsAndRebuildRoutes(), + ); + } + } + } catch { + return; + } + return; + } case 'webhook_url': { if (typeof value !== 'string') return; config.webhookUrl = value.trim(); @@ -562,6 +587,7 @@ function getRuntimeSettingsResponse(currentAdminIp = '') { proxyErrorKeywords: config.proxyErrorKeywords, proxyEmptyContentFailEnabled: config.proxyEmptyContentFailEnabled, proxyTokenMasked: maskSecret(config.proxyToken), + globalBlockedBrands: config.globalBlockedBrands, }; } @@ -639,6 +665,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 +976,31 @@ export async function settingsRoutes(app: FastifyInstance) { upsertSetting('proxy_empty_content_fail_enabled', config.proxyEmptyContentFailEnabled); } + if (body.globalBlockedBrands !== undefined) { + if (!Array.isArray(body.globalBlockedBrands)) { + return reply.code(400).send({ error: 'globalBlockedBrands must be an array of strings' }); + } + const nextBrands = 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 (prev !== next) { + startBackgroundTask( + { + type: 'maintenance', + title: '品牌屏蔽变更后重建路由', + dedupeKey: 'refresh-models-and-rebuild-routes', + }, + async () => refreshModelsAndRebuildRoutes(), + ); + } + } + 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..ee9fddd 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.trim()), + ...tokenModels.map((r) => r.modelName.trim()), + ])).filter((m) => m.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..f4a93ac --- /dev/null +++ b/src/server/services/brandMatcher.ts @@ -0,0 +1,273 @@ +/** + * Server-side brand matching for global brand filtering. + * + * Context-building logic is ported from the frontend BrandIcon.tsx + * (collectBrandCandidates + buildMatchContext) to ensure identical + * matching behavior between route display and rebuild filtering. + */ + +type MatchMode = 'includes' | 'startsWith' | 'segment' | 'boundary'; +type KeywordRule = { keyword: string; mode: MatchMode }; + +const BRAND_RULES: Record = { + 'OpenAI': [ + { keyword: 'gpt', mode: 'startsWith' }, + { keyword: 'chatgpt', mode: 'startsWith' }, + { keyword: 'dall-e', mode: 'startsWith' }, + { keyword: 'whisper', mode: 'startsWith' }, + { keyword: 'text-embedding', mode: 'startsWith' }, + { keyword: 'text-moderation', mode: 'startsWith' }, + { keyword: 'davinci', mode: 'startsWith' }, + { keyword: 'babbage', mode: 'startsWith' }, + { keyword: 'codex-mini', mode: 'startsWith' }, + { keyword: 'o1', mode: 'startsWith' }, + { keyword: 'o3', mode: 'startsWith' }, + { keyword: 'o4', mode: 'startsWith' }, + { keyword: 'tts', mode: 'startsWith' }, + ], + 'Anthropic': [ + { keyword: 'claude', mode: 'includes' }, + ], + 'Google': [ + { keyword: 'gemini', mode: 'includes' }, + { keyword: 'gemma', mode: 'includes' }, + { keyword: 'google/', mode: 'includes' }, + { keyword: 'palm', mode: 'includes' }, + { keyword: 'paligemma', mode: 'includes' }, + { keyword: 'shieldgemma', mode: 'includes' }, + { keyword: 'recurrentgemma', mode: 'includes' }, + { keyword: 'deplot', mode: 'includes' }, + { keyword: 'codegemma', mode: 'includes' }, + { keyword: 'imagen', mode: 'includes' }, + { keyword: 'learnlm', mode: 'includes' }, + { keyword: 'aqa', mode: 'includes' }, + { keyword: 'veo', mode: 'startsWith' }, + ], + 'DeepSeek': [ + { keyword: 'deepseek', mode: 'includes' }, + { keyword: 'ds-chat', mode: 'segment' }, + ], + '通义千问': [ + { keyword: 'qwen', mode: 'includes' }, + { keyword: 'qwq', mode: 'includes' }, + { keyword: 'tongyi', mode: 'includes' }, + ], + '智谱 AI': [ + { keyword: 'glm', mode: 'includes' }, + { keyword: 'chatglm', mode: 'includes' }, + { keyword: 'codegeex', mode: 'includes' }, + { keyword: 'cogview', mode: 'includes' }, + { keyword: 'cogvideo', mode: 'includes' }, + ], + 'Meta': [ + { keyword: 'llama', mode: 'includes' }, + { keyword: 'code-llama', mode: 'includes' }, + { keyword: 'codellama', mode: 'includes' }, + ], + 'Mistral': [ + { keyword: 'mistral', mode: 'includes' }, + { keyword: 'mixtral', mode: 'includes' }, + { keyword: 'codestral', mode: 'includes' }, + { keyword: 'pixtral', mode: 'includes' }, + { keyword: 'ministral', mode: 'includes' }, + { keyword: 'voxtral', mode: 'includes' }, + { keyword: 'magistral', mode: 'includes' }, + ], + 'Moonshot': [ + { keyword: 'moonshot', mode: 'includes' }, + { keyword: 'kimi', mode: 'includes' }, + ], + '零一万物': [ + { keyword: 'yi-', mode: 'startsWith' }, + { keyword: 'yi', mode: 'boundary' }, + ], + '文心一言': [ + { keyword: 'ernie', mode: 'includes' }, + { keyword: 'eb-', mode: 'includes' }, + ], + '讯飞星火': [ + { keyword: 'spark', mode: 'includes' }, + { keyword: 'generalv', mode: 'includes' }, + ], + '腾讯混元': [ + { keyword: 'hunyuan', mode: 'includes' }, + { keyword: 'tencent-hunyuan', mode: 'includes' }, + ], + '豆包': [ + { keyword: 'doubao', mode: 'includes' }, + ], + 'MiniMax': [ + { keyword: 'minimax', mode: 'includes' }, + { keyword: 'abab', mode: 'includes' }, + { keyword: 'mini2.1', mode: 'segment' }, + ], + 'Cohere': [ + { keyword: 'command', mode: 'includes' }, + { keyword: 'c4ai-', mode: 'includes' }, + { keyword: 'embed-', mode: 'startsWith' }, + ], + 'Microsoft': [ + { keyword: 'microsoft/', mode: 'includes' }, + { keyword: 'phi-', mode: 'includes' }, + { keyword: 'kosmos', mode: 'includes' }, + { keyword: 'phi4', mode: 'segment' }, + ], + 'xAI': [ + { keyword: 'grok', mode: 'includes' }, + ], + '阶跃星辰': [ + { keyword: 'stepfun', mode: 'includes' }, + { keyword: 'step-', mode: 'startsWith' }, + { keyword: 'step3', mode: 'startsWith' }, + ], + 'Stability': [ + { keyword: 'flux', mode: 'includes' }, + { keyword: 'stablediffusion', mode: 'includes' }, + { keyword: 'stable-diffusion', mode: 'includes' }, + { keyword: 'sdxl', mode: 'includes' }, + { keyword: 'sd3', mode: 'startsWith' }, + ], + 'NVIDIA': [ + { keyword: 'nvidia/', mode: 'includes' }, + { keyword: 'nvclip', mode: 'includes' }, + { keyword: 'nemotron', mode: 'includes' }, + { keyword: 'nemoretriever', mode: 'includes' }, + { keyword: 'neva', mode: 'includes' }, + { keyword: 'riva-translate', mode: 'includes' }, + { keyword: 'cosmos', mode: 'includes' }, + { keyword: 'nv-', mode: 'startsWith' }, + ], + 'IBM': [ + { keyword: 'ibm/', mode: 'includes' }, + { keyword: 'granite', mode: 'includes' }, + ], + 'BAAI': [ + { keyword: 'baai/', mode: 'includes' }, + { keyword: 'bge-', mode: 'includes' }, + ], + 'ByteDance': [ + { keyword: 'bytedance', mode: 'includes' }, + { keyword: 'seed-oss', mode: 'includes' }, + { keyword: 'kolors', mode: 'includes' }, + { keyword: 'kwai', mode: 'includes' }, + { keyword: 'kwaipilot', mode: 'includes' }, + { keyword: 'wan-', mode: 'startsWith' }, + { keyword: 'kat-', mode: 'startsWith' }, + ], + 'InternLM': [ + { keyword: 'internlm', mode: 'includes' }, + ], + 'Midjourney': [ + { keyword: 'midjourney', mode: 'includes' }, + { keyword: 'mj_', mode: 'startsWith' }, + ], + 'DeepL': [ + { keyword: 'deepl/', mode: 'includes' }, + { keyword: 'deepl-', mode: 'startsWith' }, + ], + 'Jina AI': [ + { keyword: 'jina', mode: 'includes' }, + ], +}; + +// ── Context building (ported from frontend BrandIcon.tsx) ── + +function stripCommonWrappers(value: string): string { + return value + .replace(/^(?:\[[^\]]+\]|【[^】]+】)\s*/g, '') + .replace(/^re:\s*/g, '') + .replace(/^\^+/, '') + .replace(/\$+$/, '') + .trim(); +} + +/** + * Recursively split model name on `/`, `:`, `,` to build all candidate strings. + * Mirrors the frontend collectBrandCandidates exactly. + */ +function collectCandidates(modelName: string): string[] { + const queue: string[] = []; + const seen = new Set(); + const push = (value: string) => { + const normalized = value.trim().toLowerCase(); + if (!normalized || seen.has(normalized)) return; + seen.add(normalized); + queue.push(normalized); + }; + + push(modelName); + + for (let i = 0; i < queue.length; i++) { + const candidate = queue[i]!; + const cleaned = stripCommonWrappers(candidate); + push(cleaned); + if (cleaned.includes('/')) for (const part of cleaned.split('/')) push(part); + if (cleaned.includes(':')) for (const part of cleaned.split(':')) push(part); + if (cleaned.includes(',')) for (const part of cleaned.split(',')) push(part); + } + + return queue; +} + +type MatchContext = { + raw: string; // first candidate (normalized full name) + candidates: string[]; // all candidates from recursive splitting + segments: string[]; // candidates split on /,:,\s (NOT on - or _) +}; + +function buildMatchContext(modelName: string): MatchContext { + const candidates = collectCandidates(modelName); + const raw = candidates[0] || modelName.trim().toLowerCase(); + // Segments split on /,:,whitespace only — NOT on - or _ (matches frontend) + const segments = Array.from(new Set( + candidates + .flatMap((c) => c.split(/[/:,\s]+/g)) + .map((s) => s.trim()) + .filter(Boolean), + )); + return { raw, candidates, segments }; +} + +// ── Matching functions (mirror frontend includesAny / startsWithAny / hasExactSegment / matchesBoundary) ── + +function matchesRule(ctx: MatchContext, rule: KeywordRule): boolean { + switch (rule.mode) { + case 'includes': + return ctx.raw.includes(rule.keyword) + || ctx.candidates.some((c) => c.includes(rule.keyword)); + case 'startsWith': + return ctx.raw.startsWith(rule.keyword) + || ctx.segments.some((s) => s.startsWith(rule.keyword)) + || ctx.candidates.some((c) => c.startsWith(rule.keyword)); + case 'segment': + return ctx.segments.includes(rule.keyword); + case 'boundary': { + const pattern = new RegExp(`(^|[/:_\\-\\s])${rule.keyword}(?=$|[/:_\\-\\s])`); + return pattern.test(ctx.raw) || ctx.candidates.some((c) => pattern.test(c)); + } + default: + return false; + } +} + +// ── Public API ── + +export function getAllBrandNames(): string[] { + return Object.keys(BRAND_RULES); +} + +export function getBlockedBrandRules(blockedBrands: string[]): KeywordRule[] { + const rules: KeywordRule[] = []; + for (const brand of blockedBrands) { + const brandRules = BRAND_RULES[brand]; + if (brandRules) rules.push(...brandRules); + } + return rules; +} + +export function isModelBlockedByBrand(modelName: string, rules: KeywordRule[]): boolean { + if (!modelName || rules.length === 0) return false; + const ctx = buildMatchContext(modelName); + if (!ctx.raw) return false; + return rules.some((rule) => matchesRule(ctx, rule)); +} diff --git a/src/server/services/modelService.ts b/src/server/services/modelService.ts index 7ab8a30..667c8d1 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 { getBlockedBrandRules, 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 blockedBrandRules = getBlockedBrandRules(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 (blockedBrandRules.length > 0 && isModelBlockedByBrand(modelName, blockedBrandRules)) 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..4f1ece5 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(null); + const [blockedBrands, setBlockedBrands] = useState([]); + const [savingBrandFilter, setSavingBrandFilter] = useState(false); const [savingSecurity, setSavingSecurity] = useState(false); const [adminIpAllowlistText, setAdminIpAllowlistText] = useState(''); const [clearingCache, setClearingCache] = useState(false); @@ -451,7 +455,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') @@ -494,6 +500,10 @@ export default function Settings() { } finally { setLoading(false); } + // Load brand list in background (non-blocking, best-effort) + api.getBrandList() + .then((res: any) => setAllBrandNames(Array.isArray(res?.brands) ? res.brands : [])) + .catch(() => setAllBrandNames([])); }; useEffect(() => { @@ -816,6 +826,27 @@ export default function Settings() { })); }; + const handleSaveBrandFilter = async () => { + setSavingBrandFilter(true); + try { + const res = await api.updateRuntimeSettings({ globalBlockedBrands: blockedBrands }); + const resolved = Array.isArray(res?.globalBlockedBrands) ? res.globalBlockedBrands : blockedBrands; + setRuntime((prev) => ({ ...prev, globalBlockedBrands: resolved })); + setBlockedBrands(resolved); + 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 +1457,56 @@ export default function Settings() { + {/* Global Brand Filter */}
+
全局品牌屏蔽
+
+ 屏蔽选定品牌后,路由重建时将自动跳过匹配该品牌的所有模型。点击品牌切换屏蔽状态,保存后自动触发路由重建。 +
+
+ {(allBrandNames || []).map((brand) => { + const isBlocked = blockedBrands.includes(brand); + return ( + + ); + })} + {allBrandNames === null && ( + 加载品牌列表中... + )} + {allBrandNames !== null && 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..234c873 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'; @@ -215,6 +216,7 @@ export default function Sites() { count?: number; }>(null); const lastEditorRef = useRef(null); + const loadingModelsSiteIdRef = useRef(null); const rowRefs = useRef>(new Map()); const highlightTimerRef = useRef(null); const toast = useToast(); @@ -222,6 +224,34 @@ 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(''); + + const disabledModelSet = useMemo(() => new Set(disabledModels), [disabledModels]); + + const brandGroups = useMemo(() => { + const allModels = Array.from(new Set([...availableModels, ...disabledModels])); + const groups = new Map(); + for (const model of allModels) { + 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, disabledModels]); + + 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; @@ -326,14 +356,42 @@ export default function Sites() { setEditor({ mode: 'edit', editingSiteId: site.id }); setForm(siteFormFromSite(site)); scrollToEditorTop(); - // Load disabled 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; + loadingModelsSiteIdRef.current = loadSiteId; setDisabledModelsLoading(true); setDisabledModels([]); setDisabledModelInput(''); + setAvailableModels([]); + setDisabledModelSearch(''); + let pendingLoads = 2; + const markLoadFinished = () => { + pendingLoads -= 1; + if (pendingLoads <= 0 && loadingModelsSiteIdRef.current === loadSiteId) { + setDisabledModelsLoading(false); + } + }; + api.getSiteDisabledModels(site.id) - .then((res: any) => setDisabledModels(Array.isArray(res?.models) ? res.models : [])) - .catch(() => { }) - .finally(() => setDisabledModelsLoading(false)); + .then((disabledRes: any) => { + if (loadingModelsSiteIdRef.current !== loadSiteId) return; + 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 : []); + }) + .catch((err: any) => { + console.warn('Failed to load site available models:', err?.message || err); + }) + .finally(markLoadFinished); }; const handleAddDisabledModel = () => { @@ -348,10 +406,6 @@ export default function Sites() { setDisabledModelInput(''); }; - const handleRemoveDisabledModel = (model: string) => { - setDisabledModels((prev) => prev.filter((m) => m !== model)); - }; - const handleSaveDisabledModels = async () => { if (!editor || editor.mode !== 'edit') return; setDisabledModelsSaving(true); @@ -913,7 +967,7 @@ export default function Sites() {
禁用模型管理
- 在此站点禁用指定模型后,路由重建时将不为该站点的这些模型创建通道。 + 在此站点禁用指定模型后,路由重建时将不为该站点的这些模型创建通道。勾选表示禁用该模型。
{disabledModelsLoading ? (
@@ -921,55 +975,131 @@ export default function Sites() {
) : ( <> -
- {disabledModels.length === 0 && ( - 暂无禁用模型 - )} - {disabledModels.map((model) => ( - - {model} - - - ))} -
-
+ {/* Search and brand group controls */} + {brandGroups.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 */} +
+ 按品牌全选: + {brandGroups.map(([brandName, models]) => { + const allDisabled = models.every((m) => disabledModelSet.has(m)); + return ( + + ); + })} +
+ {/* Checkbox list */} +
+ {filteredBrandGroups.length === 0 ? ( +
无匹配模型
+ ) : filteredBrandGroups.map(([brandName, models]) => ( +
+
+ {brandName} ({models.length}) +
+ {models.map((model) => { + const isDisabled = disabledModelSet.has(model); + return ( + + ); + })} +
+ ))} +
+
+ ) : ( +
+ 暂无已发现模型,仍可手动添加需要屏蔽的模型名。 +
+ )} + +
setDisabledModelInput(e.target.value)} - onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddDisabledModel(); } }} + 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)', }} /> -
- + +
+ + + 已禁用 {disabledModels.length} 个模型 + +
)}
diff --git a/src/web/pages/TokenRoutes.tsx b/src/web/pages/TokenRoutes.tsx index 6608af8..fa02d4e 100644 --- a/src/web/pages/TokenRoutes.tsx +++ b/src/web/pages/TokenRoutes.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { arrayMove } from '@dnd-kit/sortable'; import type { DragEndEvent } from '@dnd-kit/core'; @@ -20,6 +20,7 @@ import { buildRouteMissingTokenIndex, normalizeMissingTokenModels, type MissingTokenModelsByName, + type RouteMissingTokenHint, } from './helpers/routeMissingTokenHints.js'; import { buildVisibleRouteList } from './helpers/routeListVisibility.js'; import { buildZeroChannelPlaceholderRoutes } from './helpers/zeroChannelRoutes.js'; @@ -54,7 +55,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'; @@ -106,6 +107,10 @@ function getRouteRoutingStrategySuccessMessage(value: RouteRoutingStrategy): str return '已切换为权重随机策略'; } +function escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + export default function TokenRoutes() { const navigate = useNavigate(); const [routeSummaries, setRouteSummaries] = useState([]); @@ -119,6 +124,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); @@ -155,6 +161,11 @@ export default function TokenRoutes() { const toast = useToast(); + const candidatesLoadedRef = useRef(false); + const candidatesPromiseRef = useRef | null>(null); + const candidatesVersionRef = useRef(0); + const candidatesSeqRef = useRef(0); + const loadRouteDecisions = async ( routeRows: RouteSummaryRow[], options?: { force?: boolean; refreshPricingCatalog?: boolean; persistSnapshots?: boolean }, @@ -222,22 +233,42 @@ export default function TokenRoutes() { } }; + const loadCandidates = (force?: boolean) => { + if (candidatesLoadedRef.current && !force) return; + if (candidatesPromiseRef.current && !force) return; + const seq = ++candidatesSeqRef.current; + candidatesLoadedRef.current = true; + const promise = (async () => { + try { + const candidateRows = await api.getModelTokenCandidates(); + if (candidatesSeqRef.current !== seq) return; // stale + startTransition(() => { + setModelCandidates((candidateRows?.models || {}) as RouteModelCandidatesByModelName); + setMissingTokenModelsByName( + normalizeMissingTokenModels((candidateRows?.modelsWithoutToken || {}) as MissingTokenModelsByName), + ); + setMissingTokenGroupModelsByName( + normalizeMissingTokenModels((candidateRows?.modelsMissingTokenGroups || {}) as MissingTokenModelsByName), + ); + setEndpointTypesByModel(candidateRows?.endpointTypesByModel || {}); + }); + candidatesVersionRef.current = Date.now(); + } catch { + if (candidatesSeqRef.current === seq) candidatesLoadedRef.current = false; + } finally { + if (candidatesPromiseRef.current === promise) { + candidatesPromiseRef.current = null; + } + } + })(); + candidatesPromiseRef.current = promise; + }; + const load = async () => { - const [summaryRows, candidateRows] = await Promise.all([ - api.getRoutesSummary(), - api.getModelTokenCandidates(), - ]); + const summaryRows = await api.getRoutesSummary(); const summaries = (summaryRows || []) as RouteSummaryRow[]; setRouteSummaries(summaries); - setModelCandidates((candidateRows?.models || {}) as RouteModelCandidatesByModelName); - setMissingTokenModelsByName( - normalizeMissingTokenModels((candidateRows?.modelsWithoutToken || {}) as MissingTokenModelsByName), - ); - setMissingTokenGroupModelsByName( - normalizeMissingTokenModels((candidateRows?.modelsMissingTokenGroups || {}) as MissingTokenModelsByName), - ); - setEndpointTypesByModel(candidateRows?.endpointTypesByModel || {}); const decisionPlaceholder: Record = {}; for (const route of summaries) { decisionPlaceholder[route.id] = route.decisionSnapshot || null; @@ -246,6 +277,11 @@ export default function TokenRoutes() { setDecisionAutoSkipped( summaries.some((route) => isRouteExactModel(route) && !route.decisionSnapshot), ); + + // Silently refresh candidates in the background if already loaded + if (candidatesLoadedRef.current) { + loadCandidates(true); + } }; useEffect(() => { @@ -255,6 +291,9 @@ export default function TokenRoutes() { } catch { toast.error('加载路由配置失败'); } + // Preload candidates in background after first paint + const scheduleIdle = typeof requestIdleCallback === 'function' ? requestIdleCallback : (cb: () => void) => setTimeout(cb, 0); + scheduleIdle(() => loadCandidates()); })(); }, []); @@ -264,12 +303,14 @@ export default function TokenRoutes() { const res = await api.rebuildRoutes(true); if (res?.queued) { toast.info(res.message || '已开始重建路由,请稍后查看日志'); + invalidateChannels(); await load(); return; } const createdRoutes = res?.rebuild?.createdRoutes ?? 0; const createdChannels = res?.rebuild?.createdChannels ?? 0; toast.success(`自动重建完成(新增 ${createdRoutes} 条路由 / ${createdChannels} 个通道)`); + invalidateChannels(); await load(); } catch (e: any) { toast.error(e.message || '重建路由失败'); @@ -312,6 +353,7 @@ export default function TokenRoutes() { }, [form.displayName, form.modelPattern, form.routeMode, form.sourceRouteIds.length, saving]); const previewModelSamples = useMemo(() => { + if (!showManual) return []; const names = new Set(); for (const modelName of Object.keys(modelCandidates || {})) { const normalized = modelName.trim(); @@ -325,7 +367,7 @@ export default function TokenRoutes() { return Array.from(names) .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })) .slice(0, 800); - }, [modelCandidates, routeSummaries]); + }, [showManual, modelCandidates, routeSummaries]); const exactSourceRouteOptions = useMemo( () => routeSummaries.filter((route) => isRouteExactModel(route)), @@ -394,6 +436,7 @@ export default function TokenRoutes() { }; const handleEditRoute = (route: RouteSummaryRow) => { + loadCandidates(); setEditingRouteId(route.id); const routeMode = normalizeRouteMode(route.routeMode); setForm({ @@ -471,10 +514,11 @@ export default function TokenRoutes() { }; // Stable derived value: only changes when route patterns change (not on enabled toggle) + const routePatternsKey = visibleRouteRows.map((r) => `${r.id}:${r.modelPattern}:${r.routeMode || 'pattern'}`).join(','); const routePatterns = useMemo( () => visibleRouteRows.map((r) => ({ id: r.id, modelPattern: r.modelPattern, routeMode: r.routeMode })), // eslint-disable-next-line react-hooks/exhaustive-deps - [visibleRouteRows.map((r) => `${r.id}:${r.modelPattern}:${r.routeMode || 'pattern'}`).join(',')], + [routePatternsKey], ); const routeBrandById = useMemo(() => { @@ -582,15 +626,17 @@ export default function TokenRoutes() { }, [listVisibleRoutes, routeEndpointTypesByRouteId]); const sourceEndpointTypesByRouteId = useMemo(() => { + if (!showManual) return {}; const next: Record = {}; for (const route of exactSourceRouteOptions) { next[route.id] = Array.from(routeEndpointTypesByRouteId[route.id] || new Set()) .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })); } return next; - }, [exactSourceRouteOptions, routeEndpointTypesByRouteId]); + }, [showManual, exactSourceRouteOptions, routeEndpointTypesByRouteId]); const routeBrandIconCandidates = useMemo(() => { + if (!showManual) return []; const byIcon = new Map(); for (const route of visibleRouteRows) { @@ -605,7 +651,7 @@ export default function TokenRoutes() { return Array.from(byIcon.values()) .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); - }, [visibleRouteRows, modelCandidates]); + }, [showManual, visibleRouteRows, modelCandidates]); const routeIconSelectOptions = useMemo(() => ([ ...ROUTE_ICON_OPTIONS, @@ -652,7 +698,8 @@ export default function TokenRoutes() { }) ), [listVisibleRoutes, sortBy, sortDir]); - const filteredRoutes = useMemo(() => { + // Shared base filter: all filters EXCEPT enabledFilter + const baseFilteredRoutes = useMemo(() => { let list = sortedRoutes; if (activeGroupFilter === '__all__') { @@ -670,9 +717,7 @@ export default function TokenRoutes() { } if (activeSite) { - list = list.filter((route) => - route.siteNames?.includes(activeSite), - ); + list = list.filter((route) => route.siteNames?.includes(activeSite)); } if (activeEndpointType) { @@ -692,16 +737,26 @@ export default function TokenRoutes() { } return list; - }, [ - sortedRoutes, - activeGroupFilter, - activeBrand, - activeSite, - activeEndpointType, - search, - routeBrandById, - routeEndpointTypesByRouteId, - ]); + }, [sortedRoutes, activeGroupFilter, activeBrand, activeSite, activeEndpointType, search, routeBrandById, routeEndpointTypesByRouteId]); + + const enabledCounts = useMemo(() => { + let enabled = 0; + let disabled = 0; + for (const route of baseFilteredRoutes) { + if (route.kind === 'zero_channel' || route.readOnly === true || route.isVirtual === true) continue; + if (route.enabled) enabled++; + else disabled++; + } + return { enabled, disabled }; + }, [baseFilteredRoutes]); + + const filteredRoutes = useMemo(() => { + if (enabledFilter === 'all') return baseFilteredRoutes; + return baseFilteredRoutes.filter((route) => { + if (route.kind === 'zero_channel' || route.readOnly === true || route.isVirtual === true) return false; + return enabledFilter === 'enabled' ? route.enabled : !route.enabled; + }); + }, [baseFilteredRoutes, enabledFilter]); useEffect(() => { setVisibleRouteCount(getInitialVisibleCount(filteredRoutes.length, ROUTE_RENDER_CHUNK)); @@ -713,6 +768,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,29 +779,68 @@ export default function TokenRoutes() { ); observer.observe(el); return () => observer.disconnect(); - }, [handleLoadMoreRoutes]); + }, [handleLoadMoreRoutes, shouldShowLoadMore]); const visibleRoutes = useMemo( () => filteredRoutes.slice(0, visibleRouteCount), [filteredRoutes, visibleRouteCount], ); - const routeModelCandidateIndex = useMemo( - () => buildRouteModelCandidatesIndex(routePatterns, modelCandidates, matchesModelPattern), - [routePatterns, modelCandidates], - ); - - const routeMissingTokenIndex = useMemo( - () => buildRouteMissingTokenIndex(routePatterns, missingTokenModelsByName, matchesModelPattern), - [routePatterns, missingTokenModelsByName], - ); - const routeMissingTokenGroupIndex = useMemo( - () => buildRouteMissingTokenIndex(routePatterns, missingTokenGroupModelsByName, matchesModelPattern), - [routePatterns, missingTokenGroupModelsByName], - ); + // Lazy per-route candidate index — only computes for routes actually accessed + const candidateIndexCacheRef = useRef<{ key: string; cache: Map }>({ key: '', cache: new Map() }); + const candidateIndexCacheKey = `${routePatternsKey}|${Object.keys(modelCandidates).length}|${candidatesVersionRef.current}`; + if (candidateIndexCacheRef.current.key !== candidateIndexCacheKey) { + candidateIndexCacheRef.current = { key: candidateIndexCacheKey, cache: new Map() }; + } const getRouteCandidateView = (routeId: number): RouteCandidateView => { - return routeModelCandidateIndex[routeId] || EMPTY_ROUTE_CANDIDATE_VIEW; + const cache = candidateIndexCacheRef.current.cache; + const cached = cache.get(routeId); + if (cached) return cached; + const route = routePatterns.find((r) => r.id === routeId); + if (!route) return EMPTY_ROUTE_CANDIDATE_VIEW; + const index = buildRouteModelCandidatesIndex([route], modelCandidates, matchesModelPattern); + const view = index[routeId] || EMPTY_ROUTE_CANDIDATE_VIEW; + cache.set(routeId, view); + return view; + }; + + // Lazy per-route missing token index + const missingTokenCacheRef = useRef<{ key: string; cache: Map }>({ key: '', cache: new Map() }); + const missingTokenCacheKey = `${routePatternsKey}|${Object.keys(missingTokenModelsByName).length}|${candidatesVersionRef.current}`; + if (missingTokenCacheRef.current.key !== missingTokenCacheKey) { + missingTokenCacheRef.current = { key: missingTokenCacheKey, cache: new Map() }; + } + + const getRouteMissingTokenHints = (routeId: number): RouteMissingTokenHint[] => { + const cache = missingTokenCacheRef.current.cache; + const cached = cache.get(routeId); + if (cached) return cached; + const route = routePatterns.find((r) => r.id === routeId); + if (!route) return []; + const index = buildRouteMissingTokenIndex([route], missingTokenModelsByName, matchesModelPattern); + const hints = index[routeId] || []; + cache.set(routeId, hints); + return hints; + }; + + // Lazy per-route missing token group index + const missingTokenGroupCacheRef = useRef<{ key: string; cache: Map }>({ key: '', cache: new Map() }); + const missingTokenGroupCacheKey = `${routePatternsKey}|${Object.keys(missingTokenGroupModelsByName).length}|${candidatesVersionRef.current}`; + if (missingTokenGroupCacheRef.current.key !== missingTokenGroupCacheKey) { + missingTokenGroupCacheRef.current = { key: missingTokenGroupCacheKey, cache: new Map() }; + } + + const getRouteMissingTokenGroupHints = (routeId: number): RouteMissingTokenHint[] => { + const cache = missingTokenGroupCacheRef.current.cache; + const cached = cache.get(routeId); + if (cached) return cached; + const route = routePatterns.find((r) => r.id === routeId); + if (!route) return []; + const index = buildRouteMissingTokenIndex([route], missingTokenGroupModelsByName, matchesModelPattern); + const hints = index[routeId] || []; + cache.set(routeId, hints); + return hints; }; const routeById = useMemo( @@ -763,12 +859,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 +906,20 @@ export default function TokenRoutes() { } }; + const handleToggleChannelEnabled = async (channelId: number, routeId: number, enabled: boolean) => { + if (updatingChannel[channelId]) return; + setUpdatingChannel((prev) => ({ ...prev, [channelId]: true })); + try { + await api.updateChannel(channelId, { enabled }); + toast.success(enabled ? '通道已启用' : '通道已禁用'); + await loadChannels(routeId, true); + } catch (e: any) { + toast.error(e.message || '更新通道状态失败'); + } finally { + setUpdatingChannel((prev) => ({ ...prev, [channelId]: false })); + } + }; + const handleChannelTokenSave = async (routeId: number, channelId: number, accountId: number) => { const tokenId = channelTokenDraft[channelId]; const tokenOptions = getRouteCandidateView(routeId).tokenOptionsByAccountId[accountId] || []; @@ -847,11 +990,67 @@ 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 && isExactModelPattern(route.modelPattern) ? route.modelPattern : '') || ''; + if (!modelName) { + toast.error('该通道没有精确模型名,无法使用站点屏蔽(通配符路由请在站点编辑中手动禁用)'); + return; + } + const siteName = channel.site.name || '未知站点'; + 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 = ` +
确认站点屏蔽
+
+ 将模型「${escapeHtml(modelName)}」加入站点「${escapeHtml(siteName)}」的禁用列表。
执行后将自动触发路由重建,该站点下此模型的通道将不再生成。 +
+
+ + +
+ `; + 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 { + 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); + invalidateChannels(); + await load(); + } catch (e: any) { + toast.error(e.message || '站点屏蔽模型失败'); + } + }; + const toggleExpand = async (routeId: number) => { const isCurrentlyExpanded = expandedRouteIds.includes(routeId); if (isCurrentlyExpanded) { setExpandedRouteIds((prev) => prev.filter((id) => id !== routeId)); } else { + loadCandidates(); setExpandedRouteIds((prev) => [...prev, routeId]); // Load channels on demand const route = routeById.get(routeId) || null; @@ -866,84 +1065,78 @@ export default function TokenRoutes() { } }; - const missingTokenSiteItemsByRouteId = useMemo(() => { - const result: Record = {}; - for (const routeId of Object.keys(routeMissingTokenIndex).map(Number)) { - const missingTokenHints = routeMissingTokenIndex[routeId] || []; - const siteMap = new Map(); - for (const hint of missingTokenHints) { - for (const account of hint.accounts) { - if (!Number.isFinite(account.accountId) || account.accountId <= 0) continue; - const siteName = (account.siteName || '').trim() || `site-${account.siteId || 'unknown'}`; - const key = `${account.siteId || 0}::${siteName.toLowerCase()}`; - const accountLabel = account.username || `account-${account.accountId}`; - const existing = siteMap.get(key); - if (!existing) { - siteMap.set(key, { key, siteName, accountId: account.accountId, accountLabel }); - continue; - } - if (account.accountId < existing.accountId) { - existing.accountId = account.accountId; - existing.accountLabel = accountLabel; - } + const getMissingTokenSiteItems = (routeId: number): MissingTokenRouteSiteActionItem[] => { + const missingTokenHints = getRouteMissingTokenHints(routeId); + if (missingTokenHints.length === 0) return EMPTY_MISSING_ITEMS; + const siteMap = new Map(); + for (const hint of missingTokenHints) { + for (const account of hint.accounts) { + if (!Number.isFinite(account.accountId) || account.accountId <= 0) continue; + const siteName = (account.siteName || '').trim() || `site-${account.siteId || 'unknown'}`; + const key = `${account.siteId || 0}::${siteName.toLowerCase()}`; + const accountLabel = account.username || `account-${account.accountId}`; + const existing = siteMap.get(key); + if (!existing) { + siteMap.set(key, { key, siteName, accountId: account.accountId, accountLabel }); + continue; + } + if (account.accountId < existing.accountId) { + existing.accountId = account.accountId; + existing.accountLabel = accountLabel; } } - result[routeId] = Array.from(siteMap.values()).sort((a, b) => ( - a.siteName.localeCompare(b.siteName, undefined, { sensitivity: 'base' }) - )); } - return result; - }, [routeMissingTokenIndex]); + return Array.from(siteMap.values()).sort((a, b) => ( + a.siteName.localeCompare(b.siteName, undefined, { sensitivity: 'base' }) + )); + }; - const missingTokenGroupItemsByRouteId = useMemo(() => { - const result: Record = {}; - for (const routeId of Object.keys(routeMissingTokenGroupIndex).map(Number)) { - const missingGroupHints = routeMissingTokenGroupIndex[routeId] || []; - const siteMap = new Map(); - for (const hint of missingGroupHints) { - for (const account of hint.accounts) { - if (!Number.isFinite(account.accountId) || account.accountId <= 0) continue; - const siteName = (account.siteName || '').trim() || `site-${account.siteId || 'unknown'}`; - const key = `${account.siteId || 0}::${siteName.toLowerCase()}`; - const accountLabel = account.username || `account-${account.accountId}`; - const missingGroups = Array.isArray(account.missingGroups) ? account.missingGroups : []; - const requiredGroups = Array.isArray(account.requiredGroups) ? account.requiredGroups : []; - const availableGroups = Array.isArray(account.availableGroups) ? account.availableGroups : []; - const existing = siteMap.get(key); - if (!existing) { - siteMap.set(key, { - key, - siteName, - accountId: account.accountId, - accountLabel, - missingGroups: [...missingGroups], - requiredGroups: [...requiredGroups], - availableGroups: [...availableGroups], - ...(account.groupCoverageUncertain === true ? { groupCoverageUncertain: true } : {}), - }); - continue; - } - if (account.accountId < existing.accountId) { - existing.accountId = account.accountId; - existing.accountLabel = accountLabel; - } - existing.missingGroups = Array.from(new Set([...existing.missingGroups, ...missingGroups])) - .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })); - existing.requiredGroups = Array.from(new Set([...existing.requiredGroups, ...requiredGroups])) - .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })); - existing.availableGroups = Array.from(new Set([...existing.availableGroups, ...availableGroups])) - .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })); - if (account.groupCoverageUncertain === true) { - existing.groupCoverageUncertain = true; - } + const getMissingTokenGroupItems = (routeId: number): MissingTokenGroupRouteSiteActionItem[] => { + const missingGroupHints = getRouteMissingTokenGroupHints(routeId); + if (missingGroupHints.length === 0) return EMPTY_MISSING_GROUP_ITEMS; + const siteMap = new Map(); + for (const hint of missingGroupHints) { + for (const account of hint.accounts) { + if (!Number.isFinite(account.accountId) || account.accountId <= 0) continue; + const siteName = (account.siteName || '').trim() || `site-${account.siteId || 'unknown'}`; + const key = `${account.siteId || 0}::${siteName.toLowerCase()}`; + const accountLabel = account.username || `account-${account.accountId}`; + const missingGroups = Array.isArray(account.missingGroups) ? account.missingGroups : []; + const requiredGroups = Array.isArray(account.requiredGroups) ? account.requiredGroups : []; + const availableGroups = Array.isArray(account.availableGroups) ? account.availableGroups : []; + const existing = siteMap.get(key); + if (!existing) { + siteMap.set(key, { + key, + siteName, + accountId: account.accountId, + accountLabel, + missingGroups: [...missingGroups], + requiredGroups: [...requiredGroups], + availableGroups: [...availableGroups], + ...(account.groupCoverageUncertain === true ? { groupCoverageUncertain: true } : {}), + }); + continue; + } + if (account.accountId < existing.accountId) { + existing.accountId = account.accountId; + existing.accountLabel = accountLabel; + } + existing.missingGroups = Array.from(new Set([...existing.missingGroups, ...missingGroups])) + .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })); + existing.requiredGroups = Array.from(new Set([...existing.requiredGroups, ...requiredGroups])) + .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })); + existing.availableGroups = Array.from(new Set([...existing.availableGroups, ...availableGroups])) + .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })); + if (account.groupCoverageUncertain === true) { + existing.groupCoverageUncertain = true; } } - result[routeId] = Array.from(siteMap.values()).sort((a, b) => ( - a.siteName.localeCompare(b.siteName, undefined, { sensitivity: 'base' }) - )); } - return result; - }, [routeMissingTokenGroupIndex]); + return Array.from(siteMap.values()).sort((a, b) => ( + a.siteName.localeCompare(b.siteName, undefined, { sensitivity: 'base' }) + )); + }; // Stable callbacks for RouteCard memo (use refs to avoid dependency on closure variables) const toggleExpandRef = useRef(toggleExpand); @@ -968,7 +1161,10 @@ export default function TokenRoutes() { (channelId: number, tokenId: number) => setChannelTokenDraft((prev) => ({ ...prev, [channelId]: tokenId })), [], ); - const stableAddChannel = useCallback((routeId: number) => setAddChannelModalRouteId(routeId), []); + const stableAddChannel = useCallback((routeId: number) => { + loadCandidates(); + setAddChannelModalRouteId(routeId); + }, []); const stableToggleSourceGroup = useCallback( (groupKey: string) => setExpandedSourceGroupMap((prev) => ({ ...prev, [groupKey]: !prev[groupKey] })), [], @@ -985,6 +1181,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 +1199,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 @@ -1087,6 +1295,7 @@ export default function TokenRoutes() { setShowFilters(false)} title={tr('筛选路由')}> ) : ( setFilterCollapsed((prev) => !prev)} + onToggle={() => { + if (filterCollapsed) loadCandidates(); + setFilterCollapsed((prev) => !prev); + }} /> )} @@ -1233,7 +1457,7 @@ export default function TokenRoutes() { @@ -1275,11 +1499,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} + missingTokenSiteItems={getMissingTokenSiteItems(route.id)} + missingTokenGroupItems={getMissingTokenGroupItems(route.id)} onCreateTokenForMissing={stableCreateTokenForMissing} onAddChannel={stableAddChannel} + onSiteBlockModel={stableSiteBlockModel} expandedSourceGroupMap={expandedSourceGroupMap} onToggleSourceGroup={stableToggleSourceGroup} /> @@ -1311,11 +1537,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} + missingTokenSiteItems={getMissingTokenSiteItems(route.id)} + missingTokenGroupItems={getMissingTokenGroupItems(route.id)} onCreateTokenForMissing={stableCreateTokenForMissing} onAddChannel={stableAddChannel} + onSiteBlockModel={stableSiteBlockModel} expandedSourceGroupMap={expandedSourceGroupMap} onToggleSourceGroup={stableToggleSourceGroup} /> @@ -1323,7 +1551,7 @@ export default function TokenRoutes() { })}
- {filteredRoutes.length > 0 && visibleRouteCount < filteredRoutes.length && ( + {shouldShowLoadMore && (
c.accountId))} /> diff --git a/src/web/pages/settings.factory-reset.test.tsx b/src/web/pages/settings.factory-reset.test.tsx index fac31e0..9e769b3 100644 --- a/src/web/pages/settings.factory-reset.test.tsx +++ b/src/web/pages/settings.factory-reset.test.tsx @@ -11,6 +11,7 @@ const { apiMock } = vi.hoisted(() => ({ 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..66ff774 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(), }, @@ -45,8 +46,11 @@ async function flushMicrotasks() { } describe('Sites disabled models save', () => { + let consoleWarnSpy: ReturnType; + beforeEach(() => { vi.clearAllMocks(); + consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); apiMock.getSites.mockResolvedValue([ { id: 1, @@ -57,39 +61,48 @@ describe('Sites disabled models save', () => { }, ]); apiMock.getSiteDisabledModels.mockResolvedValue({ models: [] }); + apiMock.getSiteAvailableModels.mockResolvedValue({ models: [] }); apiMock.updateSiteDisabledModels.mockResolvedValue({ success: true }); }); afterEach(() => { + consoleWarnSpy.mockRestore(); vi.clearAllMocks(); }); + async function renderSitesEditor() { + let root: ReturnType | null = null; + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + const editButton = root.root.find((node) => ( + node.type === 'button' + && typeof node.props.onClick === 'function' + && collectText(node).trim() === '编辑' + )); + + await act(async () => { + editButton.props.onClick(); + }); + 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 | null = null; try { - await act(async () => { - root = create( - - - - - , - ); - }); - await flushMicrotasks(); - - const editButton = root.root.find((node) => ( - node.type === 'button' - && typeof node.props.onClick === 'function' - && collectText(node).trim() === '编辑' - )); - - await act(async () => { - editButton.props.onClick(); - }); - await flushMicrotasks(); + root = await renderSitesEditor(); const saveButton = root.root.find((node) => ( node.type === 'button' @@ -110,4 +123,74 @@ describe('Sites disabled models save', () => { 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 | 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 | 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(); + } + }); }); 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 */} : '保存'} + + + {onSiteBlockModel && channel.site?.id ? ( + + ) : null} + + + + {onSiteBlockModel && channel.site?.id ? ( + + ) : null} + + +
+ {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 = {