1
0
forked from boos/BoosAPI

Merge pull request #241 from Hureru/feat/model-filter-system

feat: add model filter system and route page performance optimization
This commit is contained in:
Cita
2026-03-23 00:19:29 +08:00
committed by GitHub
20 changed files with 1228 additions and 207 deletions
+1
View File
@@ -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))), proxyFileRetentionPruneIntervalMinutes: Math.max(1, Math.trunc(parseNumber(env.PROXY_FILE_RETENTION_PRUNE_INTERVAL_MINUTES, 60))),
proxyErrorKeywords: parseCsvList(env.PROXY_ERROR_KEYWORDS), proxyErrorKeywords: parseCsvList(env.PROXY_ERROR_KEYWORDS),
proxyEmptyContentFailEnabled: parseBoolean(env.PROXY_EMPTY_CONTENT_FAIL, false), proxyEmptyContentFailEnabled: parseBoolean(env.PROXY_EMPTY_CONTENT_FAIL, false),
globalBlockedBrands: [] as string[],
codexResponsesWebsocketBeta: parseOptionalSecret(env.CODEX_RESPONSES_WEBSOCKET_BETA) || 'responses_websockets=2026-02-06', codexResponsesWebsocketBeta: parseOptionalSecret(env.CODEX_RESPONSES_WEBSOCKET_BETA) || 'responses_websockets=2026-02-06',
codexHeaderDefaults: { codexHeaderDefaults: {
userAgent: parseOptionalSecret(env.CODEX_HEADER_DEFAULTS_USER_AGENT), userAgent: parseOptionalSecret(env.CODEX_HEADER_DEFAULTS_USER_AGENT),
+5
View File
@@ -146,6 +146,11 @@ function applyRuntimeSettings(settingsMap: Map<string, string>) {
config.proxyEmptyContentFailEnabled = proxyEmptyContentFailEnabled; config.proxyEmptyContentFailEnabled = proxyEmptyContentFailEnabled;
} }
const globalBlockedBrands = parseSettingFromMap<string[]>(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<unknown>(settingsMap, 'codex_header_defaults'); const codexHeaderDefaults = parseSettingFromMap<unknown>(settingsMap, 'codex_header_defaults');
if (codexHeaderDefaults && typeof codexHeaderDefaults === 'object') { if (codexHeaderDefaults && typeof codexHeaderDefaults === 'object') {
const next = codexHeaderDefaults as Record<string, unknown>; const next = codexHeaderDefaults as Record<string, unknown>;
+55
View File
@@ -5,6 +5,7 @@ import { config } from '../../config.js';
import { db, runtimeDbDialect, schema } from '../../db/index.js'; import { db, runtimeDbDialect, schema } from '../../db/index.js';
import { upsertSetting } from '../../db/upsertSetting.js'; import { upsertSetting } from '../../db/upsertSetting.js';
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import { getAllBrandNames } from '../../services/brandMatcher.js';
import { updateBalanceRefreshCron, updateCheckinSchedule, updateLogCleanupSettings } from '../../services/checkinScheduler.js'; import { updateBalanceRefreshCron, updateCheckinSchedule, updateLogCleanupSettings } from '../../services/checkinScheduler.js';
import { sendNotification } from '../../services/notifyService.js'; import { sendNotification } from '../../services/notifyService.js';
import { import {
@@ -71,6 +72,7 @@ interface RuntimeSettingsBody {
routingWeights?: Partial<RoutingWeights>; routingWeights?: Partial<RoutingWeights>;
proxyErrorKeywords?: string[] | string; proxyErrorKeywords?: string[] | string;
proxyEmptyContentFailEnabled?: boolean; proxyEmptyContentFailEnabled?: boolean;
globalBlockedBrands?: string[];
} }
interface DatabaseMigrationBody { interface DatabaseMigrationBody {
@@ -395,6 +397,29 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
} }
return; 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': { case 'webhook_url': {
if (typeof value !== 'string') return; if (typeof value !== 'string') return;
config.webhookUrl = value.trim(); config.webhookUrl = value.trim();
@@ -562,6 +587,7 @@ function getRuntimeSettingsResponse(currentAdminIp = '') {
proxyErrorKeywords: config.proxyErrorKeywords, proxyErrorKeywords: config.proxyErrorKeywords,
proxyEmptyContentFailEnabled: config.proxyEmptyContentFailEnabled, proxyEmptyContentFailEnabled: config.proxyEmptyContentFailEnabled,
proxyTokenMasked: maskSecret(config.proxyToken), proxyTokenMasked: maskSecret(config.proxyToken),
globalBlockedBrands: config.globalBlockedBrands,
}; };
} }
@@ -639,6 +665,10 @@ export async function settingsRoutes(app: FastifyInstance) {
return getRuntimeSettingsResponse(currentAdminIp); 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) => { app.post<{ Body: SystemProxyTestBody }>('/api/settings/system-proxy/test', async (request, reply) => {
const rawProxyUrl = request.body?.proxyUrl === undefined const rawProxyUrl = request.body?.proxyUrl === undefined
? config.systemProxyUrl ? config.systemProxyUrl
@@ -946,6 +976,31 @@ export async function settingsRoutes(app: FastifyInstance) {
upsertSetting('proxy_empty_content_fail_enabled', config.proxyEmptyContentFailEnabled); 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 (body.webhookUrl !== undefined) {
if (String(body.webhookUrl || '').trim() !== config.webhookUrl) { if (String(body.webhookUrl || '').trim() !== config.webhookUrl) {
changedLabels.push('Webhook 地址'); changedLabels.push('Webhook 地址');
+44
View File
@@ -497,6 +497,50 @@ export async function sitesRoutes(app: FastifyInstance) {
return { siteId: id, models: uniqueModels }; 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 // Detect platform for a URL
app.post<{ Body: { url: string } }>('/api/sites/detect', async (request) => { app.post<{ Body: { url: string } }>('/api/sites/detect', async (request) => {
const result = await detectSite(request.body.url); const result = await detectSite(request.body.url);
+273
View File
@@ -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<string, KeywordRule[]> = {
'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<string>();
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));
}
+6
View File
@@ -18,6 +18,8 @@ import {
supportsDirectAccountRoutingConnection, supportsDirectAccountRoutingConnection,
} from './accountExtraConfig.js'; } from './accountExtraConfig.js';
import { invalidateTokenRouterCache } from './tokenRouter.js'; import { invalidateTokenRouterCache } from './tokenRouter.js';
import { getBlockedBrandRules, isModelBlockedByBrand } from './brandMatcher.js';
import { config } from '../config.js';
import { setAccountRuntimeHealth } from './accountHealthService.js'; import { setAccountRuntimeHealth } from './accountHealthService.js';
import { clearAllRouteDecisionSnapshots } from './routeDecisionSnapshotStore.js'; import { clearAllRouteDecisionSnapshots } from './routeDecisionSnapshotStore.js';
import { withAccountProxyOverride, withExplicitProxyRequestInit, withSiteRecordProxyRequestInit } from './siteProxy.js'; import { withAccountProxyOverride, withExplicitProxyRequestInit, withSiteRecordProxyRequestInit } from './siteProxy.js';
@@ -1139,11 +1141,15 @@ export async function rebuildTokenRoutesFromAvailability() {
return !!disabled && disabled.has(modelName); return !!disabled && disabled.has(modelName);
} }
// Load global brand filter
const blockedBrandRules = getBlockedBrandRules(config.globalBlockedBrands);
const modelCandidates = new Map<string, Map<string, { accountId: number; tokenId: number | null }>>(); const modelCandidates = new Map<string, Map<string, { accountId: number; tokenId: number | null }>>();
const addModelCandidate = (modelNameRaw: string | null | undefined, accountId: number, tokenId: number | null, siteId: number) => { const addModelCandidate = (modelNameRaw: string | null | undefined, accountId: number, tokenId: number | null, siteId: number) => {
const modelName = (modelNameRaw || '').trim(); const modelName = (modelNameRaw || '').trim();
if (!modelName) return; if (!modelName) return;
if (isModelDisabledForSite(siteId, modelName)) return; if (isModelDisabledForSite(siteId, modelName)) return;
if (blockedBrandRules.length > 0 && isModelBlockedByBrand(modelName, blockedBrandRules)) return;
if (!modelCandidates.has(modelName)) modelCandidates.set(modelName, new Map()); if (!modelCandidates.has(modelName)) modelCandidates.set(modelName, new Map());
const candidateKey = `${accountId}:${tokenId ?? 'account'}`; const candidateKey = `${accountId}:${tokenId ?? 'account'}`;
modelCandidates.get(modelName)!.set(candidateKey, { accountId, tokenId }); modelCandidates.get(modelName)!.set(candidateKey, { accountId, tokenId });
+2
View File
@@ -421,6 +421,7 @@ export const api = {
detectSite: (url: string) => request('/api/sites/detect', { method: 'POST', body: JSON.stringify({ url }) }), detectSite: (url: string) => request('/api/sites/detect', { method: 'POST', body: JSON.stringify({ url }) }),
getSiteDisabledModels: (siteId: number) => request(`/api/sites/${siteId}/disabled-models`), 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 }) }), 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 // Accounts
getAccounts: () => request('/api/accounts'), getAccounts: () => request('/api/accounts'),
@@ -570,6 +571,7 @@ export const api = {
method: 'POST', body: JSON.stringify({ oldToken, newToken }), method: 'POST', body: JSON.stringify({ oldToken, newToken }),
}), }),
getRuntimeSettings: () => request('/api/settings/runtime'), getRuntimeSettings: () => request('/api/settings/runtime'),
getBrandList: () => request('/api/settings/brand-list'),
updateRuntimeSettings: (data: any) => request('/api/settings/runtime', { updateRuntimeSettings: (data: any) => request('/api/settings/runtime', {
method: 'PUT', method: 'PUT',
body: JSON.stringify(data), body: JSON.stringify(data),
+80
View File
@@ -54,6 +54,7 @@ type RuntimeSettings = {
proxyTokenMasked?: string; proxyTokenMasked?: string;
adminIpAllowlist?: string[]; adminIpAllowlist?: string[];
currentAdminIp?: string; currentAdminIp?: string;
globalBlockedBrands?: string[];
}; };
type SystemProxyTestState = type SystemProxyTestState =
@@ -234,6 +235,9 @@ export default function Settings() {
const [savingProxyFailureRules, setSavingProxyFailureRules] = useState(false); const [savingProxyFailureRules, setSavingProxyFailureRules] = useState(false);
const [savingRouting, setSavingRouting] = useState(false); const [savingRouting, setSavingRouting] = useState(false);
const [showAdvancedRouting, setShowAdvancedRouting] = useState(false); const [showAdvancedRouting, setShowAdvancedRouting] = useState(false);
const [allBrandNames, setAllBrandNames] = useState<string[] | null>(null);
const [blockedBrands, setBlockedBrands] = useState<string[]>([]);
const [savingBrandFilter, setSavingBrandFilter] = useState(false);
const [savingSecurity, setSavingSecurity] = useState(false); const [savingSecurity, setSavingSecurity] = useState(false);
const [adminIpAllowlistText, setAdminIpAllowlistText] = useState(''); const [adminIpAllowlistText, setAdminIpAllowlistText] = useState('');
const [clearingCache, setClearingCache] = useState(false); const [clearingCache, setClearingCache] = useState(false);
@@ -451,7 +455,9 @@ export default function Settings() {
? runtimeInfo.adminIpAllowlist.filter((item: unknown) => typeof item === 'string') ? runtimeInfo.adminIpAllowlist.filter((item: unknown) => typeof item === 'string')
: [], : [],
currentAdminIp: typeof runtimeInfo.currentAdminIp === 'string' ? runtimeInfo.currentAdminIp : '', currentAdminIp: typeof runtimeInfo.currentAdminIp === 'string' ? runtimeInfo.currentAdminIp : '',
globalBlockedBrands: Array.isArray(runtimeInfo.globalBlockedBrands) ? runtimeInfo.globalBlockedBrands : [],
}); });
setBlockedBrands(Array.isArray(runtimeInfo.globalBlockedBrands) ? runtimeInfo.globalBlockedBrands : []);
setProxyErrorKeywordsText( setProxyErrorKeywordsText(
Array.isArray(runtimeInfo.proxyErrorKeywords) Array.isArray(runtimeInfo.proxyErrorKeywords)
? runtimeInfo.proxyErrorKeywords.filter((item: unknown) => typeof item === 'string').join('\n') ? runtimeInfo.proxyErrorKeywords.filter((item: unknown) => typeof item === 'string').join('\n')
@@ -494,6 +500,10 @@ export default function Settings() {
} finally { } finally {
setLoading(false); 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(() => { 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 () => { const saveSecuritySettings = async () => {
setSavingSecurity(true); setSavingSecurity(true);
try { try {
@@ -1426,7 +1457,56 @@ export default function Settings() {
</div> </div>
</div> </div>
{/* Global Brand Filter */}
<div className="card animate-slide-up stagger-6" style={{ padding: 20 }}> <div className="card animate-slide-up stagger-6" style={{ padding: 20 }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>全局品牌屏蔽</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12, lineHeight: 1.6 }}>
屏蔽选定品牌后,路由重建时将自动跳过匹配该品牌的所有模型。点击品牌切换屏蔽状态,保存后自动触发路由重建。
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
{(allBrandNames || []).map((brand) => {
const isBlocked = blockedBrands.includes(brand);
return (
<button
key={brand}
type="button"
role="switch"
aria-checked={isBlocked}
onClick={() => {
if (isBlocked) {
setBlockedBrands((prev) => prev.filter((b) => b !== brand));
} else {
setBlockedBrands((prev) => [...prev, brand]);
}
}}
className={`badge ${isBlocked ? 'badge-warning' : 'badge-muted'}`}
style={{
fontSize: 12, cursor: 'pointer', border: 'none', padding: '5px 12px',
transition: 'all 0.15s ease',
}}
>
{brand}
</button>
);
})}
{allBrandNames === null && (
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>加载品牌列表中...</span>
)}
{allBrandNames !== null && allBrandNames.length === 0 && (
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无可用品牌</span>
)}
</div>
{blockedBrands.length > 0 && (
<div style={{ fontSize: 12, color: 'var(--color-warning)', marginBottom: 10 }}>
已屏蔽 {blockedBrands.length} 个品牌:{blockedBrands.join('、')}
</div>
)}
<button onClick={handleSaveBrandFilter} disabled={savingBrandFilter} className="btn btn-primary" style={{ fontSize: 12, padding: '6px 16px' }}>
{savingBrandFilter ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存品牌屏蔽'}
</button>
</div>
<div className="card animate-slide-up stagger-7" style={{ padding: 20 }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 10 }}>数据库迁移(SQLite / MySQL / PostgreSQL)</div> <div style={{ fontWeight: 600, fontSize: 14, marginBottom: 10 }}>数据库迁移(SQLite / MySQL / PostgreSQL)</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}> <div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
可先测试连接,再迁移数据;迁移完成后可保存为运行数据库配置(重启容器后生效)。 可先测试连接,再迁移数据;迁移完成后可保存为运行数据库配置(重启容器后生效)。
+162 -32
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { api } from '../api.js'; import { api } from '../api.js';
import { getBrand } from '../components/BrandIcon.js';
import CenteredModal from '../components/CenteredModal.js'; import CenteredModal from '../components/CenteredModal.js';
import MobileBatchBar from '../components/MobileBatchBar.js'; import MobileBatchBar from '../components/MobileBatchBar.js';
import MobileFilterSheet from '../components/MobileFilterSheet.js'; import MobileFilterSheet from '../components/MobileFilterSheet.js';
@@ -215,6 +216,7 @@ export default function Sites() {
count?: number; count?: number;
}>(null); }>(null);
const lastEditorRef = useRef<SiteEditorState | null>(null); const lastEditorRef = useRef<SiteEditorState | null>(null);
const loadingModelsSiteIdRef = useRef<number | null>(null);
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map()); const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
const highlightTimerRef = useRef<number | null>(null); const highlightTimerRef = useRef<number | null>(null);
const toast = useToast(); const toast = useToast();
@@ -222,6 +224,34 @@ export default function Sites() {
const [disabledModelInput, setDisabledModelInput] = useState(''); const [disabledModelInput, setDisabledModelInput] = useState('');
const [disabledModelsLoading, setDisabledModelsLoading] = useState(false); const [disabledModelsLoading, setDisabledModelsLoading] = useState(false);
const [disabledModelsSaving, setDisabledModelsSaving] = useState(false); const [disabledModelsSaving, setDisabledModelsSaving] = useState(false);
const [availableModels, setAvailableModels] = useState<string[]>([]);
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<string, string[]>();
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; if (editor) lastEditorRef.current = editor;
const activeEditor = editor || lastEditorRef.current; const activeEditor = editor || lastEditorRef.current;
@@ -326,14 +356,42 @@ export default function Sites() {
setEditor({ mode: 'edit', editingSiteId: site.id }); setEditor({ mode: 'edit', editingSiteId: site.id });
setForm(siteFormFromSite(site)); setForm(siteFormFromSite(site));
scrollToEditorTop(); 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); setDisabledModelsLoading(true);
setDisabledModels([]); setDisabledModels([]);
setDisabledModelInput(''); setDisabledModelInput('');
setAvailableModels([]);
setDisabledModelSearch('');
let pendingLoads = 2;
const markLoadFinished = () => {
pendingLoads -= 1;
if (pendingLoads <= 0 && loadingModelsSiteIdRef.current === loadSiteId) {
setDisabledModelsLoading(false);
}
};
api.getSiteDisabledModels(site.id) api.getSiteDisabledModels(site.id)
.then((res: any) => setDisabledModels(Array.isArray(res?.models) ? res.models : [])) .then((disabledRes: any) => {
.catch(() => { }) if (loadingModelsSiteIdRef.current !== loadSiteId) return;
.finally(() => setDisabledModelsLoading(false)); 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 = () => { const handleAddDisabledModel = () => {
@@ -348,10 +406,6 @@ export default function Sites() {
setDisabledModelInput(''); setDisabledModelInput('');
}; };
const handleRemoveDisabledModel = (model: string) => {
setDisabledModels((prev) => prev.filter((m) => m !== model));
};
const handleSaveDisabledModels = async () => { const handleSaveDisabledModels = async () => {
if (!editor || editor.mode !== 'edit') return; if (!editor || editor.mode !== 'edit') return;
setDisabledModelsSaving(true); setDisabledModelsSaving(true);
@@ -913,7 +967,7 @@ export default function Sites() {
<div style={{ marginTop: 16, padding: '14px', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', background: 'var(--color-bg)' }}> <div style={{ marginTop: 16, padding: '14px', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', background: 'var(--color-bg)' }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>禁用模型管理</div> <div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>禁用模型管理</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}> <div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
在此站点禁用指定模型后,路由重建时将不为该站点的这些模型创建通道。 在此站点禁用指定模型后,路由重建时将不为该站点的这些模型创建通道。勾选表示禁用该模型。
</div> </div>
{disabledModelsLoading ? ( {disabledModelsLoading ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--color-text-muted)' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--color-text-muted)' }}>
@@ -921,55 +975,131 @@ export default function Sites() {
</div> </div>
) : ( ) : (
<> <>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 10, minHeight: 24 }}> {/* Search and brand group controls */}
{disabledModels.length === 0 && ( {brandGroups.length > 0 ? (
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无禁用模型</span> <div style={{ marginBottom: 10 }}>
)} <input
{disabledModels.map((model) => ( placeholder="搜索模型名称..."
<span value={disabledModelSearch}
key={model} onChange={(e) => setDisabledModelSearch(e.target.value)}
className="badge badge-muted"
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, padding: '3px 8px' }}
>
{model}
<button
type="button"
onClick={() => handleRemoveDisabledModel(model)}
style={{ style={{
background: 'none', border: 'none', cursor: 'pointer', padding: 0, width: '100%', padding: '6px 10px', border: '1px solid var(--color-border)',
fontSize: 13, lineHeight: 1, color: 'var(--color-text-muted)', borderRadius: 'var(--radius-sm)', fontSize: 12, outline: 'none',
background: 'var(--color-bg)', color: 'var(--color-text-primary)', marginBottom: 8,
}} }}
title={`移除 ${model}`} />
{/* Brand group quick actions */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 8 }}>
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', lineHeight: '24px' }}>按品牌全选:</span>
{brandGroups.map(([brandName, models]) => {
const allDisabled = models.every((m) => disabledModelSet.has(m));
return (
<button
key={brandName}
type="button"
onClick={() => {
if (allDisabled) {
const removeSet = new Set(models);
setDisabledModels((prev) => prev.filter((m) => !removeSet.has(m)));
} else {
setDisabledModels((prev) => Array.from(new Set([...prev, ...models])));
}
}}
className={`badge ${allDisabled ? 'badge-warning' : 'badge-muted'}`}
style={{ fontSize: 10, cursor: 'pointer', border: 'none', padding: '3px 8px' }}
data-tooltip={allDisabled ? `取消禁用全部 ${brandName} 模型 (${models.length})` : `禁用全部 ${brandName} 模型 (${models.length})`}
> >
× {brandName} ({models.length})
</button> </button>
);
})}
</div>
{/* Checkbox list */}
<div style={{ maxHeight: 280, overflowY: 'auto', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', padding: '4px 0' }}>
{filteredBrandGroups.length === 0 ? (
<div style={{ padding: '8px 12px', fontSize: 12, color: 'var(--color-text-muted)' }}>无匹配模型</div>
) : filteredBrandGroups.map(([brandName, models]) => (
<div key={brandName}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-secondary)', padding: '4px 12px', background: 'var(--color-bg)', borderBottom: '1px solid var(--color-border-light)' }}>
{brandName} ({models.length})
</div>
{models.map((model) => {
const isDisabled = disabledModelSet.has(model);
return (
<label
key={model}
style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '3px 12px',
fontSize: 12, cursor: 'pointer', lineHeight: 1.6,
background: isDisabled ? 'color-mix(in srgb, var(--color-warning) 8%, transparent)' : 'transparent',
}}
>
<input
type="checkbox"
checked={isDisabled}
onChange={(e) => {
if (e.target.checked) {
setDisabledModels((prev) => Array.from(new Set([...prev, model])));
} else {
setDisabledModels((prev) => prev.filter((m) => m !== model));
}
}}
/>
<span style={{ color: isDisabled ? 'var(--color-warning)' : 'var(--color-text-primary)' }}>
{model}
</span> </span>
</label>
);
})}
</div>
))} ))}
</div> </div>
<div style={{ display: 'flex', gap: 8 }}> </div>
) : (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
暂无已发现模型,仍可手动添加需要屏蔽的模型名。
</div>
)}
<div style={{ display: 'flex', gap: 8, marginTop: 10, marginBottom: 10 }}>
<input <input
placeholder="输入模型名称,如 gpt-4o" placeholder="输入模型名称,如 gpt-4o"
value={disabledModelInput} value={disabledModelInput}
onChange={(e) => setDisabledModelInput(e.target.value)} onChange={(e) => setDisabledModelInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddDisabledModel(); } }} onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddDisabledModel();
}
}}
style={{ style={{
flex: 1, padding: '8px 12px', border: '1px solid var(--color-border)', flex: 1, padding: '8px 12px', border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)', fontSize: 12, outline: 'none', borderRadius: 'var(--radius-sm)', fontSize: 12, outline: 'none',
background: 'var(--color-bg)', color: 'var(--color-text-primary)', background: 'var(--color-bg)', color: 'var(--color-text-primary)',
}} }}
/> />
<button onClick={handleAddDisabledModel} className="btn btn-ghost" style={{ padding: '8px 14px', fontSize: 12, border: '1px solid var(--color-border)' }}> <button
添加 onClick={handleAddDisabledModel}
className="btn btn-ghost"
style={{ padding: '8px 14px', fontSize: 12, border: '1px solid var(--color-border)' }}
>
添加模型
</button> </button>
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 10 }}>
<button <button
onClick={handleSaveDisabledModels} onClick={handleSaveDisabledModels}
disabled={disabledModelsSaving} disabled={disabledModelsSaving}
className="btn btn-primary" className="btn btn-primary"
style={{ marginTop: 10, fontSize: 12, padding: '6px 16px' }} style={{ fontSize: 12, padding: '6px 16px' }}
> >
{disabledModelsSaving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存禁用列表'} {disabledModelsSaving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存禁用列表'}
</button> </button>
<span style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>
已禁用 {disabledModels.length} 个模型
</span>
</div>
</> </>
)} )}
</div> </div>
+302 -74
View File
@@ -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 { useNavigate } from 'react-router-dom';
import { arrayMove } from '@dnd-kit/sortable'; import { arrayMove } from '@dnd-kit/sortable';
import type { DragEndEvent } from '@dnd-kit/core'; import type { DragEndEvent } from '@dnd-kit/core';
@@ -20,6 +20,7 @@ import {
buildRouteMissingTokenIndex, buildRouteMissingTokenIndex,
normalizeMissingTokenModels, normalizeMissingTokenModels,
type MissingTokenModelsByName, type MissingTokenModelsByName,
type RouteMissingTokenHint,
} from './helpers/routeMissingTokenHints.js'; } from './helpers/routeMissingTokenHints.js';
import { buildVisibleRouteList } from './helpers/routeListVisibility.js'; import { buildVisibleRouteList } from './helpers/routeListVisibility.js';
import { buildZeroChannelPlaceholderRoutes } from './helpers/zeroChannelRoutes.js'; import { buildZeroChannelPlaceholderRoutes } from './helpers/zeroChannelRoutes.js';
@@ -54,7 +55,7 @@ import {
getModelPatternError, getModelPatternError,
} from './token-routes/utils.js'; } from './token-routes/utils.js';
import { useRouteChannels } from './token-routes/useRouteChannels.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 ManualRoutePanel from './token-routes/ManualRoutePanel.js';
import RouteCard from './token-routes/RouteCard.js'; import RouteCard from './token-routes/RouteCard.js';
import AddChannelModal from './token-routes/AddChannelModal.js'; import AddChannelModal from './token-routes/AddChannelModal.js';
@@ -106,6 +107,10 @@ function getRouteRoutingStrategySuccessMessage(value: RouteRoutingStrategy): str
return '已切换为权重随机策略'; return '已切换为权重随机策略';
} }
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
export default function TokenRoutes() { export default function TokenRoutes() {
const navigate = useNavigate(); const navigate = useNavigate();
const [routeSummaries, setRouteSummaries] = useState<RouteSummaryRow[]>([]); const [routeSummaries, setRouteSummaries] = useState<RouteSummaryRow[]>([]);
@@ -119,6 +124,7 @@ export default function TokenRoutes() {
const [activeSite, setActiveSite] = useState<string | null>(null); const [activeSite, setActiveSite] = useState<string | null>(null);
const [activeEndpointType, setActiveEndpointType] = useState<string | null>(null); const [activeEndpointType, setActiveEndpointType] = useState<string | null>(null);
const [activeGroupFilter, setActiveGroupFilter] = useState<GroupFilter>(null); const [activeGroupFilter, setActiveGroupFilter] = useState<GroupFilter>(null);
const [enabledFilter, setEnabledFilter] = useState<EnabledFilter>('all');
const [filterCollapsed, setFilterCollapsed] = useState(true); const [filterCollapsed, setFilterCollapsed] = useState(true);
const [showFilters, setShowFilters] = useState(false); const [showFilters, setShowFilters] = useState(false);
const [showZeroChannelRoutes, setShowZeroChannelRoutes] = useState(false); const [showZeroChannelRoutes, setShowZeroChannelRoutes] = useState(false);
@@ -155,6 +161,11 @@ export default function TokenRoutes() {
const toast = useToast(); const toast = useToast();
const candidatesLoadedRef = useRef(false);
const candidatesPromiseRef = useRef<Promise<void> | null>(null);
const candidatesVersionRef = useRef(0);
const candidatesSeqRef = useRef(0);
const loadRouteDecisions = async ( const loadRouteDecisions = async (
routeRows: RouteSummaryRow[], routeRows: RouteSummaryRow[],
options?: { force?: boolean; refreshPricingCatalog?: boolean; persistSnapshots?: boolean }, options?: { force?: boolean; refreshPricingCatalog?: boolean; persistSnapshots?: boolean },
@@ -222,14 +233,16 @@ export default function TokenRoutes() {
} }
}; };
const load = async () => { const loadCandidates = (force?: boolean) => {
const [summaryRows, candidateRows] = await Promise.all([ if (candidatesLoadedRef.current && !force) return;
api.getRoutesSummary(), if (candidatesPromiseRef.current && !force) return;
api.getModelTokenCandidates(), const seq = ++candidatesSeqRef.current;
]); candidatesLoadedRef.current = true;
const promise = (async () => {
const summaries = (summaryRows || []) as RouteSummaryRow[]; try {
setRouteSummaries(summaries); const candidateRows = await api.getModelTokenCandidates();
if (candidatesSeqRef.current !== seq) return; // stale
startTransition(() => {
setModelCandidates((candidateRows?.models || {}) as RouteModelCandidatesByModelName); setModelCandidates((candidateRows?.models || {}) as RouteModelCandidatesByModelName);
setMissingTokenModelsByName( setMissingTokenModelsByName(
normalizeMissingTokenModels((candidateRows?.modelsWithoutToken || {}) as MissingTokenModelsByName), normalizeMissingTokenModels((candidateRows?.modelsWithoutToken || {}) as MissingTokenModelsByName),
@@ -238,6 +251,24 @@ export default function TokenRoutes() {
normalizeMissingTokenModels((candidateRows?.modelsMissingTokenGroups || {}) as MissingTokenModelsByName), normalizeMissingTokenModels((candidateRows?.modelsMissingTokenGroups || {}) as MissingTokenModelsByName),
); );
setEndpointTypesByModel(candidateRows?.endpointTypesByModel || {}); 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 = await api.getRoutesSummary();
const summaries = (summaryRows || []) as RouteSummaryRow[];
setRouteSummaries(summaries);
const decisionPlaceholder: Record<number, RouteDecision | null> = {}; const decisionPlaceholder: Record<number, RouteDecision | null> = {};
for (const route of summaries) { for (const route of summaries) {
decisionPlaceholder[route.id] = route.decisionSnapshot || null; decisionPlaceholder[route.id] = route.decisionSnapshot || null;
@@ -246,6 +277,11 @@ export default function TokenRoutes() {
setDecisionAutoSkipped( setDecisionAutoSkipped(
summaries.some((route) => isRouteExactModel(route) && !route.decisionSnapshot), summaries.some((route) => isRouteExactModel(route) && !route.decisionSnapshot),
); );
// Silently refresh candidates in the background if already loaded
if (candidatesLoadedRef.current) {
loadCandidates(true);
}
}; };
useEffect(() => { useEffect(() => {
@@ -255,6 +291,9 @@ export default function TokenRoutes() {
} catch { } catch {
toast.error('加载路由配置失败'); 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); const res = await api.rebuildRoutes(true);
if (res?.queued) { if (res?.queued) {
toast.info(res.message || '已开始重建路由,请稍后查看日志'); toast.info(res.message || '已开始重建路由,请稍后查看日志');
invalidateChannels();
await load(); await load();
return; return;
} }
const createdRoutes = res?.rebuild?.createdRoutes ?? 0; const createdRoutes = res?.rebuild?.createdRoutes ?? 0;
const createdChannels = res?.rebuild?.createdChannels ?? 0; const createdChannels = res?.rebuild?.createdChannels ?? 0;
toast.success(`自动重建完成(新增 ${createdRoutes} 条路由 / ${createdChannels} 个通道)`); toast.success(`自动重建完成(新增 ${createdRoutes} 条路由 / ${createdChannels} 个通道)`);
invalidateChannels();
await load(); await load();
} catch (e: any) { } catch (e: any) {
toast.error(e.message || '重建路由失败'); toast.error(e.message || '重建路由失败');
@@ -312,6 +353,7 @@ export default function TokenRoutes() {
}, [form.displayName, form.modelPattern, form.routeMode, form.sourceRouteIds.length, saving]); }, [form.displayName, form.modelPattern, form.routeMode, form.sourceRouteIds.length, saving]);
const previewModelSamples = useMemo(() => { const previewModelSamples = useMemo(() => {
if (!showManual) return [];
const names = new Set<string>(); const names = new Set<string>();
for (const modelName of Object.keys(modelCandidates || {})) { for (const modelName of Object.keys(modelCandidates || {})) {
const normalized = modelName.trim(); const normalized = modelName.trim();
@@ -325,7 +367,7 @@ export default function TokenRoutes() {
return Array.from(names) return Array.from(names)
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })) .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
.slice(0, 800); .slice(0, 800);
}, [modelCandidates, routeSummaries]); }, [showManual, modelCandidates, routeSummaries]);
const exactSourceRouteOptions = useMemo( const exactSourceRouteOptions = useMemo(
() => routeSummaries.filter((route) => isRouteExactModel(route)), () => routeSummaries.filter((route) => isRouteExactModel(route)),
@@ -394,6 +436,7 @@ export default function TokenRoutes() {
}; };
const handleEditRoute = (route: RouteSummaryRow) => { const handleEditRoute = (route: RouteSummaryRow) => {
loadCandidates();
setEditingRouteId(route.id); setEditingRouteId(route.id);
const routeMode = normalizeRouteMode(route.routeMode); const routeMode = normalizeRouteMode(route.routeMode);
setForm({ setForm({
@@ -471,10 +514,11 @@ export default function TokenRoutes() {
}; };
// Stable derived value: only changes when route patterns change (not on enabled toggle) // 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( const routePatterns = useMemo(
() => visibleRouteRows.map((r) => ({ id: r.id, modelPattern: r.modelPattern, routeMode: r.routeMode })), () => visibleRouteRows.map((r) => ({ id: r.id, modelPattern: r.modelPattern, routeMode: r.routeMode })),
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[visibleRouteRows.map((r) => `${r.id}:${r.modelPattern}:${r.routeMode || 'pattern'}`).join(',')], [routePatternsKey],
); );
const routeBrandById = useMemo(() => { const routeBrandById = useMemo(() => {
@@ -582,15 +626,17 @@ export default function TokenRoutes() {
}, [listVisibleRoutes, routeEndpointTypesByRouteId]); }, [listVisibleRoutes, routeEndpointTypesByRouteId]);
const sourceEndpointTypesByRouteId = useMemo(() => { const sourceEndpointTypesByRouteId = useMemo(() => {
if (!showManual) return {};
const next: Record<number, string[]> = {}; const next: Record<number, string[]> = {};
for (const route of exactSourceRouteOptions) { for (const route of exactSourceRouteOptions) {
next[route.id] = Array.from(routeEndpointTypesByRouteId[route.id] || new Set<string>()) next[route.id] = Array.from(routeEndpointTypesByRouteId[route.id] || new Set<string>())
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })); .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
} }
return next; return next;
}, [exactSourceRouteOptions, routeEndpointTypesByRouteId]); }, [showManual, exactSourceRouteOptions, routeEndpointTypesByRouteId]);
const routeBrandIconCandidates = useMemo(() => { const routeBrandIconCandidates = useMemo(() => {
if (!showManual) return [];
const byIcon = new Map<string, BrandInfo>(); const byIcon = new Map<string, BrandInfo>();
for (const route of visibleRouteRows) { for (const route of visibleRouteRows) {
@@ -605,7 +651,7 @@ export default function TokenRoutes() {
return Array.from(byIcon.values()) return Array.from(byIcon.values())
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
}, [visibleRouteRows, modelCandidates]); }, [showManual, visibleRouteRows, modelCandidates]);
const routeIconSelectOptions = useMemo<RouteIconOption[]>(() => ([ const routeIconSelectOptions = useMemo<RouteIconOption[]>(() => ([
...ROUTE_ICON_OPTIONS, ...ROUTE_ICON_OPTIONS,
@@ -652,7 +698,8 @@ export default function TokenRoutes() {
}) })
), [listVisibleRoutes, sortBy, sortDir]); ), [listVisibleRoutes, sortBy, sortDir]);
const filteredRoutes = useMemo(() => { // Shared base filter: all filters EXCEPT enabledFilter
const baseFilteredRoutes = useMemo(() => {
let list = sortedRoutes; let list = sortedRoutes;
if (activeGroupFilter === '__all__') { if (activeGroupFilter === '__all__') {
@@ -670,9 +717,7 @@ export default function TokenRoutes() {
} }
if (activeSite) { if (activeSite) {
list = list.filter((route) => list = list.filter((route) => route.siteNames?.includes(activeSite));
route.siteNames?.includes(activeSite),
);
} }
if (activeEndpointType) { if (activeEndpointType) {
@@ -692,16 +737,26 @@ export default function TokenRoutes() {
} }
return list; return list;
}, [ }, [sortedRoutes, activeGroupFilter, activeBrand, activeSite, activeEndpointType, search, routeBrandById, routeEndpointTypesByRouteId]);
sortedRoutes,
activeGroupFilter, const enabledCounts = useMemo(() => {
activeBrand, let enabled = 0;
activeSite, let disabled = 0;
activeEndpointType, for (const route of baseFilteredRoutes) {
search, if (route.kind === 'zero_channel' || route.readOnly === true || route.isVirtual === true) continue;
routeBrandById, if (route.enabled) enabled++;
routeEndpointTypesByRouteId, 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(() => { useEffect(() => {
setVisibleRouteCount(getInitialVisibleCount(filteredRoutes.length, ROUTE_RENDER_CHUNK)); setVisibleRouteCount(getInitialVisibleCount(filteredRoutes.length, ROUTE_RENDER_CHUNK));
@@ -713,6 +768,8 @@ export default function TokenRoutes() {
const loadMoreSentinelRef = useRef<HTMLDivElement>(null); const loadMoreSentinelRef = useRef<HTMLDivElement>(null);
const shouldShowLoadMore = filteredRoutes.length > 0 && visibleRouteCount < filteredRoutes.length;
useEffect(() => { useEffect(() => {
const el = loadMoreSentinelRef.current; const el = loadMoreSentinelRef.current;
if (!el) return; if (!el) return;
@@ -722,29 +779,68 @@ export default function TokenRoutes() {
); );
observer.observe(el); observer.observe(el);
return () => observer.disconnect(); return () => observer.disconnect();
}, [handleLoadMoreRoutes]); }, [handleLoadMoreRoutes, shouldShowLoadMore]);
const visibleRoutes = useMemo( const visibleRoutes = useMemo(
() => filteredRoutes.slice(0, visibleRouteCount), () => filteredRoutes.slice(0, visibleRouteCount),
[filteredRoutes, visibleRouteCount], [filteredRoutes, visibleRouteCount],
); );
const routeModelCandidateIndex = useMemo( // Lazy per-route candidate index — only computes for routes actually accessed
() => buildRouteModelCandidatesIndex(routePatterns, modelCandidates, matchesModelPattern), const candidateIndexCacheRef = useRef<{ key: string; cache: Map<number, RouteCandidateView> }>({ key: '', cache: new Map() });
[routePatterns, modelCandidates], const candidateIndexCacheKey = `${routePatternsKey}|${Object.keys(modelCandidates).length}|${candidatesVersionRef.current}`;
); if (candidateIndexCacheRef.current.key !== candidateIndexCacheKey) {
candidateIndexCacheRef.current = { key: candidateIndexCacheKey, cache: new Map() };
const routeMissingTokenIndex = useMemo( }
() => buildRouteMissingTokenIndex(routePatterns, missingTokenModelsByName, matchesModelPattern),
[routePatterns, missingTokenModelsByName],
);
const routeMissingTokenGroupIndex = useMemo(
() => buildRouteMissingTokenIndex(routePatterns, missingTokenGroupModelsByName, matchesModelPattern),
[routePatterns, missingTokenGroupModelsByName],
);
const getRouteCandidateView = (routeId: number): RouteCandidateView => { 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<number, RouteMissingTokenHint[]> }>({ 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<number, RouteMissingTokenHint[]> }>({ 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( const routeById = useMemo(
@@ -763,12 +859,45 @@ export default function TokenRoutes() {
}; };
const handleDeleteChannel = async (channelId: number, routeId: number) => { 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<boolean>((resolve) => {
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9999;display:flex;align-items:center;justify-content:center';
const dialog = document.createElement('div');
dialog.style.cssText = 'background:var(--color-bg-card,#fff);border-radius:12px;padding:24px;max-width:420px;width:90%;box-shadow:0 8px 32px rgba(0,0,0,0.2)';
dialog.innerHTML = `
<div style="font-weight:600;font-size:15px;margin-bottom:12px">确认移除通道</div>
<div style="font-size:13px;color:var(--color-text-secondary);line-height:1.6;margin-bottom:16px">
移除的通道会在定时模型刷新时被自动重建恢复。<br/>如果只是想临时停用通道,建议使用<b>禁用开关</b>。
</div>
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--color-text-muted);margin-bottom:16px;cursor:pointer">
<input type="checkbox" id="__ch_del_dismiss" /> 以后不再提示
</label>
<div style="display:flex;justify-content:flex-end;gap:8px">
<button id="__ch_del_cancel" class="btn btn-ghost" style="padding:6px 16px">取消</button>
<button id="__ch_del_confirm" class="btn btn-danger" style="padding:6px 16px">确认移除</button>
</div>
`;
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 { try {
await api.deleteChannel(channelId); await api.deleteChannel(channelId);
toast.success('通道已移除'); toast.success('通道已移除');
// Reload channels for this route
await loadChannels(routeId, true); await loadChannels(routeId, true);
// Update channel count in summary
setRouteSummaries((prev) => setRouteSummaries((prev) =>
prev.map((r) => r.id === routeId ? { ...r, channelCount: Math.max(0, r.channelCount - 1) } : r), 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 handleChannelTokenSave = async (routeId: number, channelId: number, accountId: number) => {
const tokenId = channelTokenDraft[channelId]; const tokenId = channelTokenDraft[channelId];
const tokenOptions = getRouteCandidateView(routeId).tokenOptionsByAccountId[accountId] || []; 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<boolean>((resolve) => {
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9999;display:flex;align-items:center;justify-content:center';
const dialog = document.createElement('div');
dialog.style.cssText = 'background:var(--color-bg-card,#fff);border-radius:12px;padding:24px;max-width:420px;width:90%;box-shadow:0 8px 32px rgba(0,0,0,0.2)';
dialog.innerHTML = `
<div style="font-weight:600;font-size:15px;margin-bottom:12px">确认站点屏蔽</div>
<div style="font-size:13px;color:var(--color-text-secondary);line-height:1.6;margin-bottom:16px">
将模型「<b>${escapeHtml(modelName)}</b>」加入站点「<b>${escapeHtml(siteName)}</b>」的禁用列表。<br/>执行后将自动触发路由重建,该站点下此模型的通道将不再生成。
</div>
<div style="display:flex;justify-content:flex-end;gap:8px">
<button id="__sb_cancel" class="btn btn-ghost" style="padding:6px 16px">取消</button>
<button id="__sb_confirm" class="btn btn-warning" style="padding:6px 16px">确认屏蔽</button>
</div>
`;
overlay.appendChild(dialog);
document.body.appendChild(overlay);
dialog.querySelector('#__sb_cancel')!.addEventListener('click', () => { document.body.removeChild(overlay); resolve(false); });
dialog.querySelector('#__sb_confirm')!.addEventListener('click', () => { document.body.removeChild(overlay); resolve(true); });
overlay.addEventListener('click', (e) => { if (e.target === overlay) { document.body.removeChild(overlay); resolve(false); } });
});
if (!confirmed) return;
try {
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 toggleExpand = async (routeId: number) => {
const isCurrentlyExpanded = expandedRouteIds.includes(routeId); const isCurrentlyExpanded = expandedRouteIds.includes(routeId);
if (isCurrentlyExpanded) { if (isCurrentlyExpanded) {
setExpandedRouteIds((prev) => prev.filter((id) => id !== routeId)); setExpandedRouteIds((prev) => prev.filter((id) => id !== routeId));
} else { } else {
loadCandidates();
setExpandedRouteIds((prev) => [...prev, routeId]); setExpandedRouteIds((prev) => [...prev, routeId]);
// Load channels on demand // Load channels on demand
const route = routeById.get(routeId) || null; const route = routeById.get(routeId) || null;
@@ -866,10 +1065,9 @@ export default function TokenRoutes() {
} }
}; };
const missingTokenSiteItemsByRouteId = useMemo(() => { const getMissingTokenSiteItems = (routeId: number): MissingTokenRouteSiteActionItem[] => {
const result: Record<number, MissingTokenRouteSiteActionItem[]> = {}; const missingTokenHints = getRouteMissingTokenHints(routeId);
for (const routeId of Object.keys(routeMissingTokenIndex).map(Number)) { if (missingTokenHints.length === 0) return EMPTY_MISSING_ITEMS;
const missingTokenHints = routeMissingTokenIndex[routeId] || [];
const siteMap = new Map<string, MissingTokenRouteSiteActionItem>(); const siteMap = new Map<string, MissingTokenRouteSiteActionItem>();
for (const hint of missingTokenHints) { for (const hint of missingTokenHints) {
for (const account of hint.accounts) { for (const account of hint.accounts) {
@@ -888,17 +1086,14 @@ export default function TokenRoutes() {
} }
} }
} }
result[routeId] = Array.from(siteMap.values()).sort((a, b) => ( return Array.from(siteMap.values()).sort((a, b) => (
a.siteName.localeCompare(b.siteName, undefined, { sensitivity: 'base' }) a.siteName.localeCompare(b.siteName, undefined, { sensitivity: 'base' })
)); ));
} };
return result;
}, [routeMissingTokenIndex]);
const missingTokenGroupItemsByRouteId = useMemo(() => { const getMissingTokenGroupItems = (routeId: number): MissingTokenGroupRouteSiteActionItem[] => {
const result: Record<number, MissingTokenGroupRouteSiteActionItem[]> = {}; const missingGroupHints = getRouteMissingTokenGroupHints(routeId);
for (const routeId of Object.keys(routeMissingTokenGroupIndex).map(Number)) { if (missingGroupHints.length === 0) return EMPTY_MISSING_GROUP_ITEMS;
const missingGroupHints = routeMissingTokenGroupIndex[routeId] || [];
const siteMap = new Map<string, MissingTokenGroupRouteSiteActionItem>(); const siteMap = new Map<string, MissingTokenGroupRouteSiteActionItem>();
for (const hint of missingGroupHints) { for (const hint of missingGroupHints) {
for (const account of hint.accounts) { for (const account of hint.accounts) {
@@ -938,12 +1133,10 @@ export default function TokenRoutes() {
} }
} }
} }
result[routeId] = Array.from(siteMap.values()).sort((a, b) => ( return Array.from(siteMap.values()).sort((a, b) => (
a.siteName.localeCompare(b.siteName, undefined, { sensitivity: 'base' }) a.siteName.localeCompare(b.siteName, undefined, { sensitivity: 'base' })
)); ));
} };
return result;
}, [routeMissingTokenGroupIndex]);
// Stable callbacks for RouteCard memo (use refs to avoid dependency on closure variables) // Stable callbacks for RouteCard memo (use refs to avoid dependency on closure variables)
const toggleExpandRef = useRef(toggleExpand); const toggleExpandRef = useRef(toggleExpand);
@@ -968,7 +1161,10 @@ export default function TokenRoutes() {
(channelId: number, tokenId: number) => setChannelTokenDraft((prev) => ({ ...prev, [channelId]: tokenId })), (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( const stableToggleSourceGroup = useCallback(
(groupKey: string) => setExpandedSourceGroupMap((prev) => ({ ...prev, [groupKey]: !prev[groupKey] })), (groupKey: string) => setExpandedSourceGroupMap((prev) => ({ ...prev, [groupKey]: !prev[groupKey] })),
[], [],
@@ -985,6 +1181,12 @@ export default function TokenRoutes() {
(channelId: number, routeId: number) => handleDeleteChannelRef.current(channelId, routeId), (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); const handleChannelDragEndRef = useRef(handleChannelDragEnd);
handleChannelDragEndRef.current = handleChannelDragEnd; handleChannelDragEndRef.current = handleChannelDragEnd;
const stableChannelDragEnd = useCallback( const stableChannelDragEnd = useCallback(
@@ -997,6 +1199,12 @@ export default function TokenRoutes() {
(accountId: number, modelName: string) => handleCreateTokenRef.current(accountId, modelName), (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 const addChannelModalRoute = addChannelModalRouteId
? routeSummaries.find((r) => r.id === addChannelModalRouteId) || null ? routeSummaries.find((r) => r.id === addChannelModalRouteId) || null
@@ -1087,6 +1295,7 @@ export default function TokenRoutes() {
<button <button
onClick={() => { onClick={() => {
loadCandidates();
resetRouteForm(); resetRouteForm();
setShowManual(true); setShowManual(true);
}} }}
@@ -1099,7 +1308,10 @@ export default function TokenRoutes() {
<button <button
type="button" type="button"
aria-pressed={showZeroChannelRoutes} aria-pressed={showZeroChannelRoutes}
onClick={() => setShowZeroChannelRoutes((prev) => !prev)} onClick={() => {
if (!showZeroChannelRoutes) loadCandidates();
setShowZeroChannelRoutes((prev) => !prev);
}}
className="btn btn-ghost" className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }} style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
> >
@@ -1118,13 +1330,16 @@ export default function TokenRoutes() {
<button <button
className="btn btn-ghost" className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px', marginBottom: 12 }} style={{ border: '1px solid var(--color-border)', padding: '8px 14px', marginBottom: 12 }}
onClick={() => setShowFilters(true)} onClick={() => {
loadCandidates();
setShowFilters(true);
}}
> >
{tr('筛选')} {tr('筛选')}
</button> </button>
<MobileFilterSheet open={showFilters} onClose={() => setShowFilters(false)} title={tr('筛选路由')}> <MobileFilterSheet open={showFilters} onClose={() => setShowFilters(false)} title={tr('筛选路由')}>
<RouteFilterBar <RouteFilterBar
totalRouteCount={listVisibleRoutes.length} totalRouteCount={baseFilteredRoutes.length}
activeBrand={activeBrand} activeBrand={activeBrand}
setActiveBrand={setActiveBrand} setActiveBrand={setActiveBrand}
activeSite={activeSite} activeSite={activeSite}
@@ -1133,6 +1348,9 @@ export default function TokenRoutes() {
setActiveEndpointType={setActiveEndpointType} setActiveEndpointType={setActiveEndpointType}
activeGroupFilter={activeGroupFilter} activeGroupFilter={activeGroupFilter}
setActiveGroupFilter={setActiveGroupFilter} setActiveGroupFilter={setActiveGroupFilter}
enabledFilter={enabledFilter}
setEnabledFilter={setEnabledFilter}
enabledCounts={enabledCounts}
brandList={brandList} brandList={brandList}
siteList={siteList} siteList={siteList}
endpointTypeList={endpointTypeList} endpointTypeList={endpointTypeList}
@@ -1144,7 +1362,7 @@ export default function TokenRoutes() {
</> </>
) : ( ) : (
<RouteFilterBar <RouteFilterBar
totalRouteCount={listVisibleRoutes.length} totalRouteCount={baseFilteredRoutes.length}
activeBrand={activeBrand} activeBrand={activeBrand}
setActiveBrand={setActiveBrand} setActiveBrand={setActiveBrand}
activeSite={activeSite} activeSite={activeSite}
@@ -1153,12 +1371,18 @@ export default function TokenRoutes() {
setActiveEndpointType={setActiveEndpointType} setActiveEndpointType={setActiveEndpointType}
activeGroupFilter={activeGroupFilter} activeGroupFilter={activeGroupFilter}
setActiveGroupFilter={setActiveGroupFilter} setActiveGroupFilter={setActiveGroupFilter}
enabledFilter={enabledFilter}
setEnabledFilter={setEnabledFilter}
enabledCounts={enabledCounts}
brandList={brandList} brandList={brandList}
siteList={siteList} siteList={siteList}
endpointTypeList={endpointTypeList} endpointTypeList={endpointTypeList}
groupRouteList={groupRouteList} groupRouteList={groupRouteList}
collapsed={filterCollapsed} collapsed={filterCollapsed}
onToggle={() => setFilterCollapsed((prev) => !prev)} onToggle={() => {
if (filterCollapsed) loadCandidates();
setFilterCollapsed((prev) => !prev);
}}
/> />
)} )}
@@ -1233,7 +1457,7 @@ export default function TokenRoutes() {
<button <button
type="button" type="button"
className="btn btn-link" className="btn btn-link"
onClick={() => setAddChannelModalRouteId(route.id)} onClick={() => stableAddChannel(route.id)}
> >
{tr('添加通道')} {tr('添加通道')}
</button> </button>
@@ -1275,11 +1499,13 @@ export default function TokenRoutes() {
onTokenDraftChange={stableTokenDraftChange} onTokenDraftChange={stableTokenDraftChange}
onSaveToken={stableChannelTokenSave} onSaveToken={stableChannelTokenSave}
onDeleteChannel={stableDeleteChannel} onDeleteChannel={stableDeleteChannel}
onToggleChannelEnabled={stableToggleChannelEnabled}
onChannelDragEnd={stableChannelDragEnd} onChannelDragEnd={stableChannelDragEnd}
missingTokenSiteItems={missingTokenSiteItemsByRouteId[route.id] || EMPTY_MISSING_ITEMS} missingTokenSiteItems={getMissingTokenSiteItems(route.id)}
missingTokenGroupItems={missingTokenGroupItemsByRouteId[route.id] || EMPTY_MISSING_GROUP_ITEMS} missingTokenGroupItems={getMissingTokenGroupItems(route.id)}
onCreateTokenForMissing={stableCreateTokenForMissing} onCreateTokenForMissing={stableCreateTokenForMissing}
onAddChannel={stableAddChannel} onAddChannel={stableAddChannel}
onSiteBlockModel={stableSiteBlockModel}
expandedSourceGroupMap={expandedSourceGroupMap} expandedSourceGroupMap={expandedSourceGroupMap}
onToggleSourceGroup={stableToggleSourceGroup} onToggleSourceGroup={stableToggleSourceGroup}
/> />
@@ -1311,11 +1537,13 @@ export default function TokenRoutes() {
onTokenDraftChange={stableTokenDraftChange} onTokenDraftChange={stableTokenDraftChange}
onSaveToken={stableChannelTokenSave} onSaveToken={stableChannelTokenSave}
onDeleteChannel={stableDeleteChannel} onDeleteChannel={stableDeleteChannel}
onToggleChannelEnabled={stableToggleChannelEnabled}
onChannelDragEnd={stableChannelDragEnd} onChannelDragEnd={stableChannelDragEnd}
missingTokenSiteItems={missingTokenSiteItemsByRouteId[route.id] || EMPTY_MISSING_ITEMS} missingTokenSiteItems={getMissingTokenSiteItems(route.id)}
missingTokenGroupItems={missingTokenGroupItemsByRouteId[route.id] || EMPTY_MISSING_GROUP_ITEMS} missingTokenGroupItems={getMissingTokenGroupItems(route.id)}
onCreateTokenForMissing={stableCreateTokenForMissing} onCreateTokenForMissing={stableCreateTokenForMissing}
onAddChannel={stableAddChannel} onAddChannel={stableAddChannel}
onSiteBlockModel={stableSiteBlockModel}
expandedSourceGroupMap={expandedSourceGroupMap} expandedSourceGroupMap={expandedSourceGroupMap}
onToggleSourceGroup={stableToggleSourceGroup} onToggleSourceGroup={stableToggleSourceGroup}
/> />
@@ -1323,7 +1551,7 @@ export default function TokenRoutes() {
})} })}
</div> </div>
{filteredRoutes.length > 0 && visibleRouteCount < filteredRoutes.length && ( {shouldShowLoadMore && (
<div <div
ref={loadMoreSentinelRef} ref={loadMoreSentinelRef}
style={{ textAlign: 'center', padding: '12px 0', fontSize: 12, color: 'var(--color-text-muted)' }} style={{ textAlign: 'center', padding: '12px 0', fontSize: 12, color: 'var(--color-text-muted)' }}
@@ -1362,7 +1590,7 @@ export default function TokenRoutes() {
routeTitle={resolveRouteTitle(addChannelModalRoute)} routeTitle={resolveRouteTitle(addChannelModalRoute)}
candidateView={getRouteCandidateView(addChannelModalRoute.id)} candidateView={getRouteCandidateView(addChannelModalRoute.id)}
onSuccess={handleAddChannelSuccess} onSuccess={handleAddChannelSuccess}
missingTokenHints={routeMissingTokenIndex[addChannelModalRoute.id] || []} missingTokenHints={getRouteMissingTokenHints(addChannelModalRoute.id)}
onCreateTokenForMissing={handleCreateTokenForMissingAccount} onCreateTokenForMissing={handleCreateTokenForMissingAccount}
existingChannelAccountIds={new Set((channelsByRouteId[addChannelModalRoute.id] || []).map((c) => c.accountId))} existingChannelAccountIds={new Set((channelsByRouteId[addChannelModalRoute.id] || []).map((c) => c.accountId))}
/> />
@@ -11,6 +11,7 @@ const { apiMock } = vi.hoisted(() => ({
getDownstreamApiKeys: vi.fn(), getDownstreamApiKeys: vi.fn(),
getRoutesLite: vi.fn(), getRoutesLite: vi.fn(),
getRuntimeDatabaseConfig: vi.fn(), getRuntimeDatabaseConfig: vi.fn(),
getBrandList: vi.fn(),
factoryReset: vi.fn(), factoryReset: vi.fn(),
}, },
})); }));
@@ -77,6 +78,7 @@ describe('Settings factory reset', () => {
}); });
apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] }); apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] });
apiMock.getRoutesLite.mockResolvedValue([]); apiMock.getRoutesLite.mockResolvedValue([]);
apiMock.getBrandList.mockResolvedValue({ brands: [] });
apiMock.getRuntimeDatabaseConfig.mockResolvedValue({ apiMock.getRuntimeDatabaseConfig.mockResolvedValue({
active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false }, active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false },
saved: null, saved: null,
@@ -12,6 +12,7 @@ const { apiMock } = vi.hoisted(() => ({
getDownstreamApiKeys: vi.fn(), getDownstreamApiKeys: vi.fn(),
getRoutesLite: vi.fn(), getRoutesLite: vi.fn(),
getRuntimeDatabaseConfig: vi.fn(), getRuntimeDatabaseConfig: vi.fn(),
getBrandList: vi.fn(),
updateRuntimeSettings: vi.fn(), updateRuntimeSettings: vi.fn(),
triggerCheckinAll: vi.fn(), triggerCheckinAll: vi.fn(),
}, },
@@ -62,6 +63,7 @@ describe('Settings log cleanup schedule', () => {
}); });
apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] }); apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] });
apiMock.getRoutesLite.mockResolvedValue([]); apiMock.getRoutesLite.mockResolvedValue([]);
apiMock.getBrandList.mockResolvedValue({ brands: [] });
apiMock.getRuntimeDatabaseConfig.mockResolvedValue({ apiMock.getRuntimeDatabaseConfig.mockResolvedValue({
active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false }, active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false },
saved: null, saved: null,
@@ -11,6 +11,7 @@ const { apiMock } = vi.hoisted(() => ({
getDownstreamApiKeys: vi.fn(), getDownstreamApiKeys: vi.fn(),
getRoutesLite: vi.fn(), getRoutesLite: vi.fn(),
getRuntimeDatabaseConfig: vi.fn(), getRuntimeDatabaseConfig: vi.fn(),
getBrandList: vi.fn(),
updateRuntimeSettings: vi.fn(), updateRuntimeSettings: vi.fn(),
testSystemProxy: vi.fn(), testSystemProxy: vi.fn(),
}, },
@@ -59,6 +60,7 @@ describe('Settings system proxy', () => {
}); });
apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] }); apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] });
apiMock.getRoutesLite.mockResolvedValue([]); apiMock.getRoutesLite.mockResolvedValue([]);
apiMock.getBrandList.mockResolvedValue({ brands: [] });
apiMock.getRuntimeDatabaseConfig.mockResolvedValue({ apiMock.getRuntimeDatabaseConfig.mockResolvedValue({
active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false }, active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false },
saved: null, saved: null,
@@ -10,6 +10,7 @@ const { apiMock } = vi.hoisted(() => ({
getSites: vi.fn(), getSites: vi.fn(),
addSite: vi.fn(), addSite: vi.fn(),
getSiteDisabledModels: vi.fn().mockResolvedValue({ models: [] }), getSiteDisabledModels: vi.fn().mockResolvedValue({ models: [] }),
getSiteAvailableModels: vi.fn().mockResolvedValue({ models: [] }),
}, },
})); }));
@@ -9,6 +9,7 @@ const { apiMock, toastMock } = vi.hoisted(() => ({
apiMock: { apiMock: {
getSites: vi.fn(), getSites: vi.fn(),
getSiteDisabledModels: vi.fn(), getSiteDisabledModels: vi.fn(),
getSiteAvailableModels: vi.fn(),
updateSiteDisabledModels: vi.fn(), updateSiteDisabledModels: vi.fn(),
rebuildRoutes: vi.fn(), rebuildRoutes: vi.fn(),
}, },
@@ -45,8 +46,11 @@ async function flushMicrotasks() {
} }
describe('Sites disabled models save', () => { describe('Sites disabled models save', () => {
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
apiMock.getSites.mockResolvedValue([ apiMock.getSites.mockResolvedValue([
{ {
id: 1, id: 1,
@@ -57,18 +61,17 @@ describe('Sites disabled models save', () => {
}, },
]); ]);
apiMock.getSiteDisabledModels.mockResolvedValue({ models: [] }); apiMock.getSiteDisabledModels.mockResolvedValue({ models: [] });
apiMock.getSiteAvailableModels.mockResolvedValue({ models: [] });
apiMock.updateSiteDisabledModels.mockResolvedValue({ success: true }); apiMock.updateSiteDisabledModels.mockResolvedValue({ success: true });
}); });
afterEach(() => { afterEach(() => {
consoleWarnSpy.mockRestore();
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it('reports route rebuild failure after saving disabled models from sites editor', async () => { async function renderSitesEditor() {
apiMock.rebuildRoutes.mockRejectedValue(new Error('rebuild failed'));
let root: ReturnType<typeof create> | null = null; let root: ReturnType<typeof create> | null = null;
try {
await act(async () => { await act(async () => {
root = create( root = create(
<MemoryRouter initialEntries={['/sites']}> <MemoryRouter initialEntries={['/sites']}>
@@ -91,6 +94,16 @@ describe('Sites disabled models save', () => {
}); });
await flushMicrotasks(); 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<typeof create> | null = null;
try {
root = await renderSitesEditor();
const saveButton = root.root.find((node) => ( const saveButton = root.root.find((node) => (
node.type === 'button' node.type === 'button'
&& typeof node.props.onClick === 'function' && typeof node.props.onClick === 'function'
@@ -110,4 +123,74 @@ describe('Sites disabled models save', () => {
root?.unmount(); 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<typeof create> | 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<typeof create> | 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();
}
});
}); });
+1
View File
@@ -8,6 +8,7 @@ const { apiMock } = vi.hoisted(() => ({
apiMock: { apiMock: {
getSites: vi.fn(), getSites: vi.fn(),
getSiteDisabledModels: vi.fn().mockResolvedValue({ models: [] }), getSiteDisabledModels: vi.fn().mockResolvedValue({ models: [] }),
getSiteAvailableModels: vi.fn().mockResolvedValue({ models: [] }),
}, },
})); }));
+8
View File
@@ -61,6 +61,7 @@ type RouteCardProps = {
onTokenDraftChange: (channelId: number, tokenId: number) => void; onTokenDraftChange: (channelId: number, tokenId: number) => void;
onSaveToken: (routeId: number, channelId: number, accountId: number) => void; onSaveToken: (routeId: number, channelId: number, accountId: number) => void;
onDeleteChannel: (channelId: number, routeId: number) => void; onDeleteChannel: (channelId: number, routeId: number) => void;
onToggleChannelEnabled: (channelId: number, routeId: number, enabled: boolean) => void;
onChannelDragEnd: (routeId: number, event: DragEndEvent) => void; onChannelDragEnd: (routeId: number, event: DragEndEvent) => void;
// Missing token hints // Missing token hints
missingTokenSiteItems: MissingTokenRouteSiteActionItem[]; missingTokenSiteItems: MissingTokenRouteSiteActionItem[];
@@ -68,6 +69,8 @@ type RouteCardProps = {
onCreateTokenForMissing: (accountId: number, modelName: string) => void; onCreateTokenForMissing: (accountId: number, modelName: string) => void;
// Add channel // Add channel
onAddChannel: (routeId: number) => void; onAddChannel: (routeId: number) => void;
// Site block model
onSiteBlockModel: (channelId: number, routeId: number) => void;
// Source group expansion // Source group expansion
expandedSourceGroupMap: Record<string, boolean>; expandedSourceGroupMap: Record<string, boolean>;
onToggleSourceGroup: (groupKey: string) => void; onToggleSourceGroup: (groupKey: string) => void;
@@ -119,11 +122,13 @@ function RouteCardInner({
onTokenDraftChange, onTokenDraftChange,
onSaveToken, onSaveToken,
onDeleteChannel, onDeleteChannel,
onToggleChannelEnabled,
onChannelDragEnd, onChannelDragEnd,
missingTokenSiteItems, missingTokenSiteItems,
missingTokenGroupItems, missingTokenGroupItems,
onCreateTokenForMissing, onCreateTokenForMissing,
onAddChannel, onAddChannel,
onSiteBlockModel,
expandedSourceGroupMap, expandedSourceGroupMap,
onToggleSourceGroup, onToggleSourceGroup,
}: RouteCardProps) { }: RouteCardProps) {
@@ -548,6 +553,7 @@ function RouteCardInner({
onTokenDraftChange={onTokenDraftChange} onTokenDraftChange={onTokenDraftChange}
onSaveToken={() => onSaveToken(route.id, channel.id, channel.accountId)} onSaveToken={() => onSaveToken(route.id, channel.id, channel.accountId)}
onDeleteChannel={() => onDeleteChannel(channel.id, route.id)} onDeleteChannel={() => onDeleteChannel(channel.id, route.id)}
onToggleEnabled={(enabled) => onToggleChannelEnabled(channel.id, route.id, enabled)}
/> />
); );
})} })}
@@ -572,6 +578,8 @@ function RouteCardInner({
onTokenDraftChange={onTokenDraftChange} onTokenDraftChange={onTokenDraftChange}
onSaveToken={() => onSaveToken(route.id, channel.id, channel.accountId)} onSaveToken={() => onSaveToken(route.id, channel.id, channel.accountId)}
onDeleteChannel={() => onDeleteChannel(channel.id, route.id)} onDeleteChannel={() => onDeleteChannel(channel.id, route.id)}
onToggleEnabled={(enabled) => onToggleChannelEnabled(channel.id, route.id, enabled)}
onSiteBlockModel={() => onSiteBlockModel(channel.id, route.id)}
/> />
); );
})} })}
+38 -1
View File
@@ -5,6 +5,8 @@ import { tr } from '../../i18n.js';
import type { GroupFilter, GroupRouteItem } from './types.js'; import type { GroupFilter, GroupRouteItem } from './types.js';
import { resolveEndpointTypeIconModel, siteAvatarLetters } from './utils.js'; import { resolveEndpointTypeIconModel, siteAvatarLetters } from './utils.js';
export type EnabledFilter = 'all' | 'enabled' | 'disabled';
type RouteFilterBarProps = { type RouteFilterBarProps = {
totalRouteCount: number; totalRouteCount: number;
activeBrand: string | null; activeBrand: string | null;
@@ -15,6 +17,9 @@ type RouteFilterBarProps = {
setActiveEndpointType: (endpointType: string | null) => void; setActiveEndpointType: (endpointType: string | null) => void;
activeGroupFilter: GroupFilter; activeGroupFilter: GroupFilter;
setActiveGroupFilter: (filter: GroupFilter) => void; setActiveGroupFilter: (filter: GroupFilter) => void;
enabledFilter: EnabledFilter;
setEnabledFilter: (filter: EnabledFilter) => void;
enabledCounts: { enabled: number; disabled: number };
brandList: { list: [string, { count: number; brand: BrandInfo }][]; otherCount: number }; brandList: { list: [string, { count: number; brand: BrandInfo }][]; otherCount: number };
siteList: [string, { count: number; siteId: number }][]; siteList: [string, { count: number; siteId: number }][];
endpointTypeList: [string, number][]; endpointTypeList: [string, number][];
@@ -63,8 +68,11 @@ function ActiveFilterSummary({
activeSite, activeSite,
activeGroupFilter, activeGroupFilter,
activeEndpointType, activeEndpointType,
}: Pick<RouteFilterBarProps, 'activeBrand' | 'activeSite' | 'activeGroupFilter' | 'activeEndpointType'>) { enabledFilter,
}: Pick<RouteFilterBarProps, 'activeBrand' | 'activeSite' | 'activeGroupFilter' | 'activeEndpointType' | 'enabledFilter'>) {
const tags: string[] = []; const tags: string[] = [];
if (enabledFilter === 'enabled') tags.push('状态=启用');
else if (enabledFilter === 'disabled') tags.push('状态=禁用');
if (activeBrand) tags.push(`品牌=${activeBrand === '__other__' ? '其他' : activeBrand}`); if (activeBrand) tags.push(`品牌=${activeBrand === '__other__' ? '其他' : activeBrand}`);
if (activeSite) tags.push(`站点=${activeSite}`); if (activeSite) tags.push(`站点=${activeSite}`);
if (activeGroupFilter === '__all__') tags.push('群组=全部'); if (activeGroupFilter === '__all__') tags.push('群组=全部');
@@ -86,6 +94,9 @@ export default function RouteFilterBar(props: RouteFilterBarProps) {
setActiveEndpointType, setActiveEndpointType,
activeGroupFilter, activeGroupFilter,
setActiveGroupFilter, setActiveGroupFilter,
enabledFilter,
setEnabledFilter,
enabledCounts,
brandList, brandList,
siteList, siteList,
endpointTypeList, endpointTypeList,
@@ -117,12 +128,38 @@ export default function RouteFilterBar(props: RouteFilterBarProps) {
activeSite={activeSite} activeSite={activeSite}
activeGroupFilter={activeGroupFilter} activeGroupFilter={activeGroupFilter}
activeEndpointType={activeEndpointType} activeEndpointType={activeEndpointType}
enabledFilter={enabledFilter}
/> />
</button> </button>
{/* Expanded panel */} {/* Expanded panel */}
{expandPresence.shouldRender && ( {expandPresence.shouldRender && (
<div className={`route-filter-bar-expanded ${expandPresence.isVisible ? '' : 'is-closing'}`.trim()}> <div className={`route-filter-bar-expanded ${expandPresence.isVisible ? '' : 'is-closing'}`.trim()}>
{/* Status row */}
<FilterRow label={tr('状态')}>
<FilterChip
active={enabledFilter === 'all'}
label={tr('全部')}
count={totalRouteCount}
icon={<span style={{ fontSize: 10 }}>✦</span>}
onClick={() => setEnabledFilter('all')}
/>
<FilterChip
active={enabledFilter === 'enabled'}
label={tr('仅启用')}
count={enabledCounts.enabled}
icon={<span style={{ fontSize: 10, color: 'var(--color-success)' }}>●</span>}
onClick={() => setEnabledFilter(enabledFilter === 'enabled' ? 'all' : 'enabled')}
/>
<FilterChip
active={enabledFilter === 'disabled'}
label={tr('仅禁用')}
count={enabledCounts.disabled}
icon={<span style={{ fontSize: 10, color: 'var(--color-text-muted)' }}>●</span>}
onClick={() => setEnabledFilter(enabledFilter === 'disabled' ? 'all' : 'disabled')}
/>
</FilterRow>
{/* Brand row */} {/* Brand row */}
<FilterRow label={tr('品牌')}> <FilterRow label={tr('品牌')}>
<FilterChip <FilterChip
@@ -25,6 +25,8 @@ export function SortableChannelRow({
onTokenDraftChange, onTokenDraftChange,
onSaveToken, onSaveToken,
onDeleteChannel, onDeleteChannel,
onToggleEnabled,
onSiteBlockModel,
}: SortableChannelRowProps) { }: SortableChannelRowProps) {
const { const {
attributes, attributes,
@@ -42,16 +44,17 @@ export function SortableChannelRow({
const rowStyle: CSSProperties = { const rowStyle: CSSProperties = {
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
transition, transition,
opacity: isDragging ? 0.72 : 1, opacity: isDragging ? 0.72 : channel.enabled === false ? 0.5 : 1,
zIndex: isDragging ? 10 : 1, zIndex: isDragging ? 10 : 1,
display: 'grid', display: 'grid',
gridTemplateColumns: readOnly || mobile ? 'minmax(0, 1fr)' : 'minmax(0, 1fr) auto auto', gridTemplateColumns: readOnly || mobile ? 'minmax(0, 1fr)' : 'minmax(0, 1fr) auto auto auto',
alignItems: mobile ? 'stretch' : 'center', alignItems: mobile ? 'stretch' : 'center',
gap: 8, gap: 8,
padding: mobile ? '10px 12px' : '8px 12px', padding: mobile ? '10px 12px' : '8px 12px',
borderLeft: '2px solid var(--color-primary)', borderLeft: '2px solid var(--color-primary)',
borderBottom: '1px solid var(--color-border)',
borderRadius: '0 var(--radius-sm) var(--radius-sm) 0', borderRadius: '0 var(--radius-sm) var(--radius-sm) 0',
background: isDragging ? 'rgba(59,130,246,0.08)' : 'rgba(79,70,229,0.02)', background: isDragging ? 'rgba(59,130,246,0.08)' : 'var(--color-bg-card, rgba(79,70,229,0.02))',
boxShadow: isDragging ? 'var(--shadow-sm)' : 'none', boxShadow: isDragging ? 'var(--shadow-sm)' : 'none',
}; };
@@ -263,6 +266,22 @@ export function SortableChannelRow({
{isUpdatingToken ? <span className="spinner spinner-sm" /> : '保存'} {isUpdatingToken ? <span className="spinner spinner-sm" /> : '保存'}
</button> </button>
<button
onClick={() => onToggleEnabled(channel.enabled === false)}
className={`btn btn-link ${channel.enabled === false ? 'btn-link-info' : 'btn-link-warning'}`}
>
{channel.enabled === false ? '启用' : '禁用'}
</button>
{onSiteBlockModel && channel.site?.id ? (
<button
onClick={onSiteBlockModel}
className="btn btn-link btn-link-warning"
>
站点屏蔽
</button>
) : null}
<button <button
onClick={onDeleteChannel} onClick={onDeleteChannel}
className="btn btn-link btn-link-danger" className="btn btn-link btn-link-danger"
@@ -376,6 +395,10 @@ export function SortableChannelRow({
</span> </span>
) : null} ) : null}
{channel.enabled === false ? (
<span className="badge badge-muted" style={{ fontSize: 10 }}>已禁用</span>
) : null}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', marginTop: mobile ? 0 : 4, flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', marginTop: mobile ? 0 : 4, flexWrap: 'wrap' }}>
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>选中概率</span> <span style={{ fontSize: 11, color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>选中概率</span>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 6, minWidth: 120 }}> <div style={{ display: 'inline-flex', alignItems: 'center', gap: 6, minWidth: 120 }}>
@@ -457,6 +480,22 @@ export function SortableChannelRow({
{isUpdatingToken ? <span className="spinner spinner-sm" /> : '保存'} {isUpdatingToken ? <span className="spinner spinner-sm" /> : '保存'}
</button> </button>
<button
onClick={() => onToggleEnabled(channel.enabled === false)}
className={`btn btn-link ${channel.enabled === false ? 'btn-link-info' : 'btn-link-warning'}`}
>
{channel.enabled === false ? '启用' : '禁用'}
</button>
{onSiteBlockModel && channel.site?.id ? (
<button
onClick={onSiteBlockModel}
className="btn btn-link btn-link-warning"
>
站点屏蔽
</button>
) : null}
<button <button
onClick={onDeleteChannel} onClick={onDeleteChannel}
className="btn btn-link btn-link-danger" className="btn btn-link btn-link-danger"
@@ -501,12 +540,32 @@ export function SortableChannelRow({
</button> </button>
</div> </div>
<button
onClick={() => onToggleEnabled(channel.enabled === false)}
className={`btn btn-link ${channel.enabled === false ? 'btn-link-info' : 'btn-link-warning'}`}
data-tooltip={channel.enabled === false ? '启用此通道' : '禁用此通道'}
>
{channel.enabled === false ? '启用' : '禁用'}
</button>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
{onSiteBlockModel && channel.site?.id ? (
<button
onClick={onSiteBlockModel}
className="btn btn-link btn-link-warning"
data-tooltip={`将此模型加入站点「${channel.site?.name || '未知'}」的禁用列表,rebuild 后该站点的此模型通道将不再生成`}
>
站点屏蔽
</button>
) : null}
<button <button
onClick={onDeleteChannel} onClick={onDeleteChannel}
className="btn btn-link btn-link-danger" className="btn btn-link btn-link-danger"
> >
移除 移除
</button> </button>
</div>
</> </>
) )
) : null} ) : null}
+2
View File
@@ -161,6 +161,8 @@ export type SortableChannelRowProps = {
onTokenDraftChange: (channelId: number, tokenId: number) => void; onTokenDraftChange: (channelId: number, tokenId: number) => void;
onSaveToken: () => void; onSaveToken: () => void;
onDeleteChannel: () => void; onDeleteChannel: () => void;
onToggleEnabled: (enabled: boolean) => void;
onSiteBlockModel?: () => void;
}; };
export type GroupRouteItem = { export type GroupRouteItem = {