forked from boos/BoosAPI
Merge origin/main into codex/single-source-consolidation
This commit is contained in:
@@ -114,6 +114,7 @@ export function buildConfig(env: NodeJS.ProcessEnv) {
|
||||
proxyFileRetentionPruneIntervalMinutes: Math.max(1, Math.trunc(parseNumber(env.PROXY_FILE_RETENTION_PRUNE_INTERVAL_MINUTES, 60))),
|
||||
proxyErrorKeywords: parseCsvList(env.PROXY_ERROR_KEYWORDS),
|
||||
proxyEmptyContentFailEnabled: parseBoolean(env.PROXY_EMPTY_CONTENT_FAIL, false),
|
||||
globalBlockedBrands: [] as string[],
|
||||
codexResponsesWebsocketBeta: parseOptionalSecret(env.CODEX_RESPONSES_WEBSOCKET_BETA) || 'responses_websockets=2026-02-06',
|
||||
codexHeaderDefaults: {
|
||||
userAgent: parseOptionalSecret(env.CODEX_HEADER_DEFAULTS_USER_AGENT),
|
||||
|
||||
@@ -147,6 +147,11 @@ function applyRuntimeSettings(settingsMap: Map<string, string>) {
|
||||
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');
|
||||
if (codexHeaderDefaults && typeof codexHeaderDefaults === 'object') {
|
||||
const next = codexHeaderDefaults as Record<string, unknown>;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { config } from '../../config.js';
|
||||
import { db, runtimeDbDialect, schema } from '../../db/index.js';
|
||||
import { upsertSetting } from '../../db/upsertSetting.js';
|
||||
import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js';
|
||||
import { getAllBrandNames } from '../../services/brandMatcher.js';
|
||||
import { updateBalanceRefreshCron, updateCheckinSchedule, updateLogCleanupSettings } from '../../services/checkinScheduler.js';
|
||||
import { sendNotification } from '../../services/notifyService.js';
|
||||
import {
|
||||
@@ -71,6 +72,7 @@ interface RuntimeSettingsBody {
|
||||
routingWeights?: Partial<RoutingWeights>;
|
||||
proxyErrorKeywords?: string[] | string;
|
||||
proxyEmptyContentFailEnabled?: boolean;
|
||||
globalBlockedBrands?: string[];
|
||||
}
|
||||
|
||||
interface DatabaseMigrationBody {
|
||||
@@ -395,6 +397,29 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'global_blocked_brands': {
|
||||
try {
|
||||
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
|
||||
if (Array.isArray(parsed)) {
|
||||
const nextBrands = parsed.filter((b): b is string => typeof b === 'string').map((b) => b.trim()).filter(Boolean);
|
||||
const prev = JSON.stringify(config.globalBlockedBrands);
|
||||
config.globalBlockedBrands = nextBrands;
|
||||
if (prev !== JSON.stringify(nextBrands)) {
|
||||
startBackgroundTask(
|
||||
{
|
||||
type: 'maintenance',
|
||||
title: '品牌屏蔽变更后重建路由',
|
||||
dedupeKey: 'refresh-models-and-rebuild-routes',
|
||||
},
|
||||
async () => routeRefreshWorkflow.refreshModelsAndRebuildRoutes(),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'webhook_url': {
|
||||
if (typeof value !== 'string') return;
|
||||
config.webhookUrl = value.trim();
|
||||
@@ -562,6 +587,7 @@ function getRuntimeSettingsResponse(currentAdminIp = '') {
|
||||
proxyErrorKeywords: config.proxyErrorKeywords,
|
||||
proxyEmptyContentFailEnabled: config.proxyEmptyContentFailEnabled,
|
||||
proxyTokenMasked: maskSecret(config.proxyToken),
|
||||
globalBlockedBrands: config.globalBlockedBrands,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -639,6 +665,10 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
return getRuntimeSettingsResponse(currentAdminIp);
|
||||
});
|
||||
|
||||
app.get('/api/settings/brand-list', async () => {
|
||||
return { brands: getAllBrandNames() };
|
||||
});
|
||||
|
||||
app.post<{ Body: SystemProxyTestBody }>('/api/settings/system-proxy/test', async (request, reply) => {
|
||||
const rawProxyUrl = request.body?.proxyUrl === undefined
|
||||
? config.systemProxyUrl
|
||||
@@ -946,6 +976,31 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
upsertSetting('proxy_empty_content_fail_enabled', config.proxyEmptyContentFailEnabled);
|
||||
}
|
||||
|
||||
if (body.globalBlockedBrands !== undefined) {
|
||||
if (!Array.isArray(body.globalBlockedBrands)) {
|
||||
return reply.code(400).send({ error: 'globalBlockedBrands must be an array of strings' });
|
||||
}
|
||||
const nextBrands = body.globalBlockedBrands.filter((b): b is string => typeof b === 'string').map((b) => b.trim()).filter(Boolean);
|
||||
const uniqueBrands = Array.from(new Set(nextBrands));
|
||||
const prev = JSON.stringify(config.globalBlockedBrands);
|
||||
const next = JSON.stringify(uniqueBrands);
|
||||
if (prev !== next) {
|
||||
changedLabels.push('全局品牌屏蔽');
|
||||
}
|
||||
config.globalBlockedBrands = uniqueBrands;
|
||||
upsertSetting('global_blocked_brands', JSON.stringify(uniqueBrands));
|
||||
if (prev !== next) {
|
||||
startBackgroundTask(
|
||||
{
|
||||
type: 'maintenance',
|
||||
title: '品牌屏蔽变更后重建路由',
|
||||
dedupeKey: 'refresh-models-and-rebuild-routes',
|
||||
},
|
||||
async () => routeRefreshWorkflow.refreshModelsAndRebuildRoutes(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (body.webhookUrl !== undefined) {
|
||||
if (String(body.webhookUrl || '').trim() !== config.webhookUrl) {
|
||||
changedLabels.push('Webhook 地址');
|
||||
|
||||
@@ -70,6 +70,32 @@ describe('sites proxy settings', () => {
|
||||
expect(payload.globalWeight).toBe(1.5);
|
||||
});
|
||||
|
||||
it('returns a conflict response when the same platform and url already exist', async () => {
|
||||
const first = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sites',
|
||||
payload: {
|
||||
name: 'existing-site',
|
||||
url: 'https://duplicate-site.example.com/',
|
||||
platform: 'new-api',
|
||||
},
|
||||
});
|
||||
expect(first.statusCode).toBe(200);
|
||||
|
||||
const duplicate = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sites',
|
||||
payload: {
|
||||
name: 'duplicate-site',
|
||||
url: 'https://duplicate-site.example.com',
|
||||
platform: 'new-api',
|
||||
},
|
||||
});
|
||||
|
||||
expect(duplicate.statusCode).toBe(409);
|
||||
expect((duplicate.json() as { error?: string }).error).toContain('already exists');
|
||||
});
|
||||
|
||||
it('rejects invalid useSystemProxy flag', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
@@ -163,6 +189,43 @@ describe('sites proxy settings', () => {
|
||||
expect(payload.useSystemProxy).toBe(true);
|
||||
});
|
||||
|
||||
it('returns a conflict response when updating a site to an existing platform and url', async () => {
|
||||
const first = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sites',
|
||||
payload: {
|
||||
name: 'first-site',
|
||||
url: 'https://first-site.example.com',
|
||||
platform: 'new-api',
|
||||
},
|
||||
});
|
||||
expect(first.statusCode).toBe(200);
|
||||
|
||||
const second = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sites',
|
||||
payload: {
|
||||
name: 'second-site',
|
||||
url: 'https://second-site.example.com',
|
||||
platform: 'new-api',
|
||||
},
|
||||
});
|
||||
expect(second.statusCode).toBe(200);
|
||||
|
||||
const { id } = second.json() as { id: number };
|
||||
const response = await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/sites/${id}`,
|
||||
payload: {
|
||||
url: 'https://first-site.example.com/',
|
||||
platform: 'new-api',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect((response.json() as { error?: string }).error).toContain('already exists');
|
||||
});
|
||||
|
||||
it('rejects invalid custom headers json', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import { db, schema } from '../../db/index.js';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { detectSite } from '../../services/siteDetector.js';
|
||||
@@ -76,6 +76,64 @@ function normalizeOptionalExternalCheckinUrl(input: unknown): {
|
||||
return { valid: true, present: true, url: parsed.toString().replace(/\/+$/, '') };
|
||||
}
|
||||
|
||||
type ErrorLike = {
|
||||
message?: string;
|
||||
code?: string | number;
|
||||
cause?: unknown;
|
||||
};
|
||||
|
||||
function normalizeSiteUrl(url: string): string {
|
||||
return url.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function getErrorChain(error: unknown): ErrorLike[] {
|
||||
const chain: ErrorLike[] = [];
|
||||
const seen = new Set<unknown>();
|
||||
let current: unknown = error;
|
||||
while (current && typeof current === 'object' && !seen.has(current)) {
|
||||
seen.add(current);
|
||||
chain.push(current as ErrorLike);
|
||||
current = (current as ErrorLike).cause;
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
function isSitesPlatformUrlConflict(error: unknown): boolean {
|
||||
return getErrorChain(error).some((entry) => {
|
||||
const message = String(entry.message || '');
|
||||
const lowered = message.toLowerCase();
|
||||
const code = String(entry.code || '');
|
||||
const isSqliteSitesUnique = (
|
||||
(code === 'SQLITE_CONSTRAINT' || code === 'SQLITE_CONSTRAINT_UNIQUE')
|
||||
&& lowered.includes('unique constraint failed: sites.platform, sites.url')
|
||||
);
|
||||
return (code === '23505' && lowered.includes('sites_platform_url_unique'))
|
||||
|| (code === 'ER_DUP_ENTRY' && lowered.includes('sites_platform_url_unique'))
|
||||
|| isSqliteSitesUnique
|
||||
|| (lowered.includes('duplicate key value violates unique constraint') && lowered.includes('sites_platform_url_unique'))
|
||||
|| (lowered.includes('duplicate entry') && lowered.includes('sites_platform_url_unique'));
|
||||
});
|
||||
}
|
||||
|
||||
function findExistingSiteBinding(
|
||||
siteRows: Array<{ id: number; url: string; platform: string }>,
|
||||
platform: string,
|
||||
normalizedUrl: string,
|
||||
excludedSiteId?: number,
|
||||
) {
|
||||
return siteRows.find((site) => (
|
||||
site.platform === platform
|
||||
&& site.url === normalizedUrl
|
||||
&& site.id !== excludedSiteId
|
||||
));
|
||||
}
|
||||
|
||||
function sendSiteBindingConflict(reply: FastifyReply, platform: string, normalizedUrl: string) {
|
||||
return reply.code(409).send({
|
||||
error: `A ${platform} site with URL ${normalizedUrl} already exists.`,
|
||||
});
|
||||
}
|
||||
|
||||
type SiteSubscriptionAggregate = {
|
||||
activeCount: number;
|
||||
totalUsedUsd: number;
|
||||
@@ -267,6 +325,7 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
|
||||
const existingSites = await db.select().from(schema.sites).all();
|
||||
const maxSortOrder = existingSites.reduce((max, site) => Math.max(max, site.sortOrder || 0), -1);
|
||||
const normalizedUrl = normalizeSiteUrl(url);
|
||||
|
||||
let detectedPlatform = platform;
|
||||
if (!detectedPlatform) {
|
||||
@@ -276,9 +335,16 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
if (!detectedPlatform) {
|
||||
return { error: 'Could not detect platform. Please specify manually.' };
|
||||
}
|
||||
const inserted = await db.insert(schema.sites).values({
|
||||
const conflictingSite = findExistingSiteBinding(existingSites, detectedPlatform, normalizedUrl);
|
||||
if (conflictingSite) {
|
||||
return sendSiteBindingConflict(reply, detectedPlatform, normalizedUrl);
|
||||
}
|
||||
|
||||
let inserted;
|
||||
try {
|
||||
inserted = await db.insert(schema.sites).values({
|
||||
name,
|
||||
url: url.replace(/\/+$/, ''),
|
||||
url: normalizedUrl,
|
||||
platform: detectedPlatform,
|
||||
proxyUrl: normalizedProxyUrl.proxyUrl,
|
||||
useSystemProxy: normalizedUseSystemProxy ?? false,
|
||||
@@ -289,6 +355,12 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
sortOrder: normalizedSortOrder ?? (maxSortOrder + 1),
|
||||
globalWeight: normalizedGlobalWeight ?? 1,
|
||||
}).run();
|
||||
} catch (error) {
|
||||
if (isSitesPlatformUrlConflict(error)) {
|
||||
return sendSiteBindingConflict(reply, detectedPlatform, normalizedUrl);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const siteId = Number(inserted.lastInsertRowid || 0);
|
||||
if (siteId <= 0) {
|
||||
return reply.code(500).send({ error: 'Create site failed' });
|
||||
@@ -360,8 +432,23 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
return reply.code(400).send({ error: normalizedCustomHeaders.error || 'Invalid customHeaders.' });
|
||||
}
|
||||
|
||||
const nextUrl = body.url !== undefined ? normalizeSiteUrl(body.url) : existingSite.url;
|
||||
const nextPlatform = body.platform !== undefined ? body.platform : existingSite.platform;
|
||||
const siteIdentityChanged = nextUrl !== existingSite.url || nextPlatform !== existingSite.platform;
|
||||
if (siteIdentityChanged) {
|
||||
const siteRows = await db.select({
|
||||
id: schema.sites.id,
|
||||
url: schema.sites.url,
|
||||
platform: schema.sites.platform,
|
||||
}).from(schema.sites).all();
|
||||
const conflictingSite = findExistingSiteBinding(siteRows, nextPlatform, nextUrl, id);
|
||||
if (conflictingSite) {
|
||||
return sendSiteBindingConflict(reply, nextPlatform, nextUrl);
|
||||
}
|
||||
}
|
||||
|
||||
if (body.name !== undefined) updates.name = body.name;
|
||||
if (body.url !== undefined) updates.url = body.url.replace(/\/+$/, '');
|
||||
if (body.url !== undefined) updates.url = nextUrl;
|
||||
if (body.platform !== undefined) updates.platform = body.platform;
|
||||
if (normalizedProxyUrl.present) updates.proxyUrl = normalizedProxyUrl.proxyUrl;
|
||||
if (body.useSystemProxy !== undefined) updates.useSystemProxy = normalizedUseSystemProxy;
|
||||
@@ -372,7 +459,14 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
if (body.sortOrder !== undefined) updates.sortOrder = normalizedSortOrder;
|
||||
if (body.globalWeight !== undefined) updates.globalWeight = normalizedGlobalWeight;
|
||||
updates.updatedAt = new Date().toISOString();
|
||||
try {
|
||||
await db.update(schema.sites).set(updates).where(eq(schema.sites.id, id)).run();
|
||||
} catch (error) {
|
||||
if (isSitesPlatformUrlConflict(error)) {
|
||||
return sendSiteBindingConflict(reply, nextPlatform, nextUrl);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (body.status !== undefined && normalizedStatus) {
|
||||
await applySiteStatusSideEffects(id, existingSite.name, normalizedStatus);
|
||||
@@ -497,6 +591,50 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
return { siteId: id, models: uniqueModels };
|
||||
});
|
||||
|
||||
// Get all discovered models for a site (from model_availability and token_model_availability)
|
||||
app.get<{ Params: { id: string } }>('/api/sites/:id/available-models', async (request, reply) => {
|
||||
const id = parseInt(request.params.id);
|
||||
if (Number.isNaN(id)) {
|
||||
return reply.code(400).send({ error: 'Invalid site id' });
|
||||
}
|
||||
const existingSite = await db.select().from(schema.sites).where(eq(schema.sites.id, id)).get();
|
||||
if (!existingSite) {
|
||||
return reply.code(404).send({ error: 'Site not found' });
|
||||
}
|
||||
|
||||
// Get models from model_availability (account-level)
|
||||
const accountModels = await db.select({ modelName: schema.modelAvailability.modelName })
|
||||
.from(schema.modelAvailability)
|
||||
.innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.accounts.siteId, id),
|
||||
eq(schema.modelAvailability.available, true),
|
||||
),
|
||||
)
|
||||
.all();
|
||||
|
||||
// Get models from token_model_availability (token-level)
|
||||
const tokenModels = await db.select({ modelName: schema.tokenModelAvailability.modelName })
|
||||
.from(schema.tokenModelAvailability)
|
||||
.innerJoin(schema.accountTokens, eq(schema.tokenModelAvailability.tokenId, schema.accountTokens.id))
|
||||
.innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.accounts.siteId, id),
|
||||
eq(schema.tokenModelAvailability.available, true),
|
||||
),
|
||||
)
|
||||
.all();
|
||||
|
||||
const models = Array.from(new Set([
|
||||
...accountModels.map((r) => r.modelName.trim()),
|
||||
...tokenModels.map((r) => r.modelName.trim()),
|
||||
])).filter((m) => m.length > 0).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
|
||||
|
||||
return { siteId: id, models };
|
||||
});
|
||||
|
||||
// Detect platform for a URL
|
||||
app.post<{ Body: { url: string } }>('/api/sites/detect', async (request) => {
|
||||
const result = await detectSite(request.body.url);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
supportsDirectAccountRoutingConnection,
|
||||
} from './accountExtraConfig.js';
|
||||
import { invalidateTokenRouterCache } from './tokenRouter.js';
|
||||
import { getBlockedBrandRules, isModelBlockedByBrand } from './brandMatcher.js';
|
||||
import { config } from '../config.js';
|
||||
import { setAccountRuntimeHealth } from './accountHealthService.js';
|
||||
import { clearAllRouteDecisionSnapshots } from './routeDecisionSnapshotStore.js';
|
||||
import { withAccountProxyOverride } from './siteProxy.js';
|
||||
@@ -917,11 +919,15 @@ export async function rebuildTokenRoutesFromAvailability() {
|
||||
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 addModelCandidate = (modelNameRaw: string | null | undefined, accountId: number, tokenId: number | null, siteId: number) => {
|
||||
const modelName = (modelNameRaw || '').trim();
|
||||
if (!modelName) return;
|
||||
if (isModelDisabledForSite(siteId, modelName)) return;
|
||||
if (blockedBrandRules.length > 0 && isModelBlockedByBrand(modelName, blockedBrandRules)) return;
|
||||
if (!modelCandidates.has(modelName)) modelCandidates.set(modelName, new Map());
|
||||
const candidateKey = `${accountId}:${tokenId ?? 'account'}`;
|
||||
modelCandidates.get(modelName)!.set(candidateKey, { accountId, tokenId });
|
||||
|
||||
@@ -451,6 +451,7 @@ export const api = {
|
||||
detectSite: (url: string) => request('/api/sites/detect', { method: 'POST', body: JSON.stringify({ url }) }),
|
||||
getSiteDisabledModels: (siteId: number) => request(`/api/sites/${siteId}/disabled-models`),
|
||||
updateSiteDisabledModels: (siteId: number, models: string[]) => request(`/api/sites/${siteId}/disabled-models`, { method: 'PUT', body: JSON.stringify({ models }) }),
|
||||
getSiteAvailableModels: (siteId: number) => request(`/api/sites/${siteId}/available-models`),
|
||||
|
||||
// Accounts
|
||||
getAccounts: () => request('/api/accounts'),
|
||||
@@ -600,6 +601,7 @@ export const api = {
|
||||
method: 'POST', body: JSON.stringify({ oldToken, newToken }),
|
||||
}),
|
||||
getRuntimeSettings: () => request('/api/settings/runtime'),
|
||||
getBrandList: () => request('/api/settings/brand-list'),
|
||||
updateRuntimeSettings: (data: any) => request('/api/settings/runtime', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
|
||||
@@ -57,6 +57,7 @@ type RuntimeSettings = {
|
||||
proxyTokenMasked?: string;
|
||||
adminIpAllowlist?: string[];
|
||||
currentAdminIp?: string;
|
||||
globalBlockedBrands?: string[];
|
||||
};
|
||||
|
||||
type SystemProxyTestState =
|
||||
@@ -208,6 +209,9 @@ export default function Settings() {
|
||||
const [savingProxyFailureRules, setSavingProxyFailureRules] = useState(false);
|
||||
const [savingRouting, setSavingRouting] = 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 [adminIpAllowlistText, setAdminIpAllowlistText] = useState('');
|
||||
const [clearingCache, setClearingCache] = useState(false);
|
||||
@@ -425,7 +429,9 @@ export default function Settings() {
|
||||
? runtimeInfo.adminIpAllowlist.filter((item: unknown) => typeof item === 'string')
|
||||
: [],
|
||||
currentAdminIp: typeof runtimeInfo.currentAdminIp === 'string' ? runtimeInfo.currentAdminIp : '',
|
||||
globalBlockedBrands: Array.isArray(runtimeInfo.globalBlockedBrands) ? runtimeInfo.globalBlockedBrands : [],
|
||||
});
|
||||
setBlockedBrands(Array.isArray(runtimeInfo.globalBlockedBrands) ? runtimeInfo.globalBlockedBrands : []);
|
||||
setProxyErrorKeywordsText(
|
||||
Array.isArray(runtimeInfo.proxyErrorKeywords)
|
||||
? runtimeInfo.proxyErrorKeywords.filter((item: unknown) => typeof item === 'string').join('\n')
|
||||
@@ -468,6 +474,10 @@ export default function Settings() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
// Load brand list in background (non-blocking, best-effort)
|
||||
api.getBrandList()
|
||||
.then((res: any) => setAllBrandNames(Array.isArray(res?.brands) ? res.brands : []))
|
||||
.catch(() => setAllBrandNames([]));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -790,6 +800,27 @@ export default function Settings() {
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSaveBrandFilter = async () => {
|
||||
setSavingBrandFilter(true);
|
||||
try {
|
||||
const res = await api.updateRuntimeSettings({ globalBlockedBrands: blockedBrands });
|
||||
const resolved = Array.isArray(res?.globalBlockedBrands) ? res.globalBlockedBrands : blockedBrands;
|
||||
setRuntime((prev) => ({ ...prev, globalBlockedBrands: resolved }));
|
||||
setBlockedBrands(resolved);
|
||||
toast.success('品牌屏蔽设置已保存');
|
||||
try {
|
||||
await api.rebuildRoutes(false);
|
||||
toast.success('路由已重建');
|
||||
} catch {
|
||||
toast.error('品牌屏蔽已保存,但路由重建失败,请手动重建');
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || '保存品牌屏蔽设置失败');
|
||||
} finally {
|
||||
setSavingBrandFilter(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveSecuritySettings = async () => {
|
||||
setSavingSecurity(true);
|
||||
try {
|
||||
@@ -1400,7 +1431,56 @@ export default function Settings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Global Brand Filter */}
|
||||
<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={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
|
||||
可先测试连接,再迁移数据;迁移完成后可保存为运行数据库配置(重启容器后生效)。
|
||||
|
||||
+162
-32
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import { getBrand } from '../components/BrandIcon.js';
|
||||
import CenteredModal from '../components/CenteredModal.js';
|
||||
import ResponsiveFilterPanel from '../components/ResponsiveFilterPanel.js';
|
||||
import ResponsiveBatchActionBar from '../components/ResponsiveBatchActionBar.js';
|
||||
@@ -215,6 +216,7 @@ export default function Sites() {
|
||||
count?: number;
|
||||
}>(null);
|
||||
const lastEditorRef = useRef<SiteEditorState | null>(null);
|
||||
const loadingModelsSiteIdRef = useRef<number | null>(null);
|
||||
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
|
||||
const highlightTimerRef = useRef<number | null>(null);
|
||||
const toast = useToast();
|
||||
@@ -222,6 +224,34 @@ export default function Sites() {
|
||||
const [disabledModelInput, setDisabledModelInput] = useState('');
|
||||
const [disabledModelsLoading, setDisabledModelsLoading] = useState(false);
|
||||
const [disabledModelsSaving, setDisabledModelsSaving] = useState(false);
|
||||
const [availableModels, setAvailableModels] = useState<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;
|
||||
const activeEditor = editor || lastEditorRef.current;
|
||||
@@ -326,14 +356,42 @@ export default function Sites() {
|
||||
setEditor({ mode: 'edit', editingSiteId: site.id });
|
||||
setForm(siteFormFromSite(site));
|
||||
scrollToEditorTop();
|
||||
// Load disabled models for this site
|
||||
// Load disabled models and discovered models independently so a best-effort
|
||||
// availability fetch cannot wipe the existing disabled-model state.
|
||||
const loadSiteId = site.id;
|
||||
loadingModelsSiteIdRef.current = loadSiteId;
|
||||
setDisabledModelsLoading(true);
|
||||
setDisabledModels([]);
|
||||
setDisabledModelInput('');
|
||||
setAvailableModels([]);
|
||||
setDisabledModelSearch('');
|
||||
let pendingLoads = 2;
|
||||
const markLoadFinished = () => {
|
||||
pendingLoads -= 1;
|
||||
if (pendingLoads <= 0 && loadingModelsSiteIdRef.current === loadSiteId) {
|
||||
setDisabledModelsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
api.getSiteDisabledModels(site.id)
|
||||
.then((res: any) => setDisabledModels(Array.isArray(res?.models) ? res.models : []))
|
||||
.catch(() => { })
|
||||
.finally(() => setDisabledModelsLoading(false));
|
||||
.then((disabledRes: any) => {
|
||||
if (loadingModelsSiteIdRef.current !== loadSiteId) return;
|
||||
setDisabledModels(Array.isArray(disabledRes?.models) ? disabledRes.models : []);
|
||||
})
|
||||
.catch((err: any) => {
|
||||
console.warn('Failed to load site disabled models:', err?.message || err);
|
||||
})
|
||||
.finally(markLoadFinished);
|
||||
|
||||
api.getSiteAvailableModels(site.id)
|
||||
.then((availableRes: any) => {
|
||||
if (loadingModelsSiteIdRef.current !== loadSiteId) return;
|
||||
setAvailableModels(Array.isArray(availableRes?.models) ? availableRes.models : []);
|
||||
})
|
||||
.catch((err: any) => {
|
||||
console.warn('Failed to load site available models:', err?.message || err);
|
||||
})
|
||||
.finally(markLoadFinished);
|
||||
};
|
||||
|
||||
const handleAddDisabledModel = () => {
|
||||
@@ -348,10 +406,6 @@ export default function Sites() {
|
||||
setDisabledModelInput('');
|
||||
};
|
||||
|
||||
const handleRemoveDisabledModel = (model: string) => {
|
||||
setDisabledModels((prev) => prev.filter((m) => m !== model));
|
||||
};
|
||||
|
||||
const handleSaveDisabledModels = async () => {
|
||||
if (!editor || editor.mode !== 'edit') return;
|
||||
setDisabledModelsSaving(true);
|
||||
@@ -891,7 +945,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={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>禁用模型管理</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
|
||||
在此站点禁用指定模型后,路由重建时将不为该站点的这些模型创建通道。
|
||||
在此站点禁用指定模型后,路由重建时将不为该站点的这些模型创建通道。勾选表示禁用该模型。
|
||||
</div>
|
||||
{disabledModelsLoading ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
@@ -899,55 +953,131 @@ export default function Sites() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 10, minHeight: 24 }}>
|
||||
{disabledModels.length === 0 && (
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无禁用模型</span>
|
||||
)}
|
||||
{disabledModels.map((model) => (
|
||||
<span
|
||||
key={model}
|
||||
className="badge badge-muted"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, padding: '3px 8px' }}
|
||||
>
|
||||
{model}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveDisabledModel(model)}
|
||||
{/* Search and brand group controls */}
|
||||
{brandGroups.length > 0 ? (
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<input
|
||||
placeholder="搜索模型名称..."
|
||||
value={disabledModelSearch}
|
||||
onChange={(e) => setDisabledModelSearch(e.target.value)}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 0,
|
||||
fontSize: 13, lineHeight: 1, color: 'var(--color-text-muted)',
|
||||
width: '100%', padding: '6px 10px', border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)', fontSize: 12, outline: 'none',
|
||||
background: 'var(--color-bg)', color: 'var(--color-text-primary)', marginBottom: 8,
|
||||
}}
|
||||
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>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</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
|
||||
placeholder="输入模型名称,如 gpt-4o"
|
||||
value={disabledModelInput}
|
||||
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={{
|
||||
flex: 1, padding: '8px 12px', border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)', fontSize: 12, outline: 'none',
|
||||
background: 'var(--color-bg)', color: 'var(--color-text-primary)',
|
||||
}}
|
||||
/>
|
||||
<button onClick={handleAddDisabledModel} className="btn btn-ghost" style={{ padding: '8px 14px', fontSize: 12, border: '1px solid var(--color-border)' }}>
|
||||
添加
|
||||
<button
|
||||
onClick={handleAddDisabledModel}
|
||||
className="btn btn-ghost"
|
||||
style={{ padding: '8px 14px', fontSize: 12, border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
添加模型
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 10 }}>
|
||||
<button
|
||||
onClick={handleSaveDisabledModels}
|
||||
disabled={disabledModelsSaving}
|
||||
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)' }} /> 保存中...</> : '保存禁用列表'}
|
||||
</button>
|
||||
<span style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>
|
||||
已禁用 {disabledModels.length} 个模型
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+310
-75
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { arrayMove } from '@dnd-kit/sortable';
|
||||
import type { DragEndEvent } from '@dnd-kit/core';
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
buildRouteMissingTokenIndex,
|
||||
normalizeMissingTokenModels,
|
||||
type MissingTokenModelsByName,
|
||||
type RouteMissingTokenHint,
|
||||
} from './helpers/routeMissingTokenHints.js';
|
||||
import { buildVisibleRouteList } from './helpers/routeListVisibility.js';
|
||||
import { buildZeroChannelPlaceholderRoutes } from './helpers/zeroChannelRoutes.js';
|
||||
@@ -54,7 +55,7 @@ import {
|
||||
getModelPatternError,
|
||||
} from './token-routes/utils.js';
|
||||
import { useRouteChannels } from './token-routes/useRouteChannels.js';
|
||||
import RouteFilterBar from './token-routes/RouteFilterBar.js';
|
||||
import RouteFilterBar, { type EnabledFilter } from './token-routes/RouteFilterBar.js';
|
||||
import ManualRoutePanel from './token-routes/ManualRoutePanel.js';
|
||||
import RouteCard from './token-routes/RouteCard.js';
|
||||
import AddChannelModal from './token-routes/AddChannelModal.js';
|
||||
@@ -106,6 +107,10 @@ function getRouteRoutingStrategySuccessMessage(value: RouteRoutingStrategy): str
|
||||
return '已切换为权重随机策略';
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
export default function TokenRoutes() {
|
||||
const navigate = useNavigate();
|
||||
const [routeSummaries, setRouteSummaries] = useState<RouteSummaryRow[]>([]);
|
||||
@@ -119,6 +124,7 @@ export default function TokenRoutes() {
|
||||
const [activeSite, setActiveSite] = useState<string | null>(null);
|
||||
const [activeEndpointType, setActiveEndpointType] = useState<string | null>(null);
|
||||
const [activeGroupFilter, setActiveGroupFilter] = useState<GroupFilter>(null);
|
||||
const [enabledFilter, setEnabledFilter] = useState<EnabledFilter>('all');
|
||||
const [filterCollapsed, setFilterCollapsed] = useState(true);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [showZeroChannelRoutes, setShowZeroChannelRoutes] = useState(false);
|
||||
@@ -155,6 +161,11 @@ export default function TokenRoutes() {
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const candidatesLoadedRef = useRef(false);
|
||||
const candidatesPromiseRef = useRef<Promise<void> | null>(null);
|
||||
const candidatesVersionRef = useRef(0);
|
||||
const candidatesSeqRef = useRef(0);
|
||||
|
||||
const loadRouteDecisions = async (
|
||||
routeRows: RouteSummaryRow[],
|
||||
options?: { force?: boolean; refreshPricingCatalog?: boolean; persistSnapshots?: boolean },
|
||||
@@ -222,14 +233,16 @@ export default function TokenRoutes() {
|
||||
}
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
const [summaryRows, candidateRows] = await Promise.all([
|
||||
api.getRoutesSummary(),
|
||||
api.getModelTokenCandidates(),
|
||||
]);
|
||||
|
||||
const summaries = (summaryRows || []) as RouteSummaryRow[];
|
||||
setRouteSummaries(summaries);
|
||||
const loadCandidates = (force?: boolean) => {
|
||||
if (candidatesLoadedRef.current && !force) return;
|
||||
if (candidatesPromiseRef.current && !force) return;
|
||||
const seq = ++candidatesSeqRef.current;
|
||||
candidatesLoadedRef.current = true;
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const candidateRows = await api.getModelTokenCandidates();
|
||||
if (candidatesSeqRef.current !== seq) return; // stale
|
||||
startTransition(() => {
|
||||
setModelCandidates((candidateRows?.models || {}) as RouteModelCandidatesByModelName);
|
||||
setMissingTokenModelsByName(
|
||||
normalizeMissingTokenModels((candidateRows?.modelsWithoutToken || {}) as MissingTokenModelsByName),
|
||||
@@ -238,6 +251,24 @@ export default function TokenRoutes() {
|
||||
normalizeMissingTokenModels((candidateRows?.modelsMissingTokenGroups || {}) as MissingTokenModelsByName),
|
||||
);
|
||||
setEndpointTypesByModel(candidateRows?.endpointTypesByModel || {});
|
||||
});
|
||||
candidatesVersionRef.current = Date.now();
|
||||
} catch {
|
||||
if (candidatesSeqRef.current === seq) candidatesLoadedRef.current = false;
|
||||
} finally {
|
||||
if (candidatesPromiseRef.current === promise) {
|
||||
candidatesPromiseRef.current = null;
|
||||
}
|
||||
}
|
||||
})();
|
||||
candidatesPromiseRef.current = promise;
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
const summaryRows = await api.getRoutesSummary();
|
||||
|
||||
const summaries = (summaryRows || []) as RouteSummaryRow[];
|
||||
setRouteSummaries(summaries);
|
||||
const decisionPlaceholder: Record<number, RouteDecision | null> = {};
|
||||
for (const route of summaries) {
|
||||
decisionPlaceholder[route.id] = route.decisionSnapshot || null;
|
||||
@@ -246,6 +277,11 @@ export default function TokenRoutes() {
|
||||
setDecisionAutoSkipped(
|
||||
summaries.some((route) => isRouteExactModel(route) && !route.decisionSnapshot),
|
||||
);
|
||||
|
||||
// Silently refresh candidates in the background if already loaded
|
||||
if (candidatesLoadedRef.current) {
|
||||
loadCandidates(true);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -255,6 +291,9 @@ export default function TokenRoutes() {
|
||||
} catch {
|
||||
toast.error('加载路由配置失败');
|
||||
}
|
||||
// Preload candidates in background after first paint
|
||||
const scheduleIdle = typeof requestIdleCallback === 'function' ? requestIdleCallback : (cb: () => void) => setTimeout(cb, 0);
|
||||
scheduleIdle(() => loadCandidates());
|
||||
})();
|
||||
}, []);
|
||||
|
||||
@@ -264,12 +303,14 @@ export default function TokenRoutes() {
|
||||
const res = await api.rebuildRoutes(true);
|
||||
if (res?.queued) {
|
||||
toast.info(res.message || '已开始重建路由,请稍后查看日志');
|
||||
invalidateChannels();
|
||||
await load();
|
||||
return;
|
||||
}
|
||||
const createdRoutes = res?.rebuild?.createdRoutes ?? 0;
|
||||
const createdChannels = res?.rebuild?.createdChannels ?? 0;
|
||||
toast.success(`自动重建完成(新增 ${createdRoutes} 条路由 / ${createdChannels} 个通道)`);
|
||||
invalidateChannels();
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || '重建路由失败');
|
||||
@@ -312,6 +353,7 @@ export default function TokenRoutes() {
|
||||
}, [form.displayName, form.modelPattern, form.routeMode, form.sourceRouteIds.length, saving]);
|
||||
|
||||
const previewModelSamples = useMemo(() => {
|
||||
if (!showManual) return [];
|
||||
const names = new Set<string>();
|
||||
for (const modelName of Object.keys(modelCandidates || {})) {
|
||||
const normalized = modelName.trim();
|
||||
@@ -325,7 +367,7 @@ export default function TokenRoutes() {
|
||||
return Array.from(names)
|
||||
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
|
||||
.slice(0, 800);
|
||||
}, [modelCandidates, routeSummaries]);
|
||||
}, [showManual, modelCandidates, routeSummaries]);
|
||||
|
||||
const exactSourceRouteOptions = useMemo(
|
||||
() => routeSummaries.filter((route) => isRouteExactModel(route)),
|
||||
@@ -394,6 +436,7 @@ export default function TokenRoutes() {
|
||||
};
|
||||
|
||||
const handleEditRoute = (route: RouteSummaryRow) => {
|
||||
loadCandidates();
|
||||
setEditingRouteId(route.id);
|
||||
const routeMode = normalizeRouteMode(route.routeMode);
|
||||
setForm({
|
||||
@@ -471,10 +514,11 @@ export default function TokenRoutes() {
|
||||
};
|
||||
|
||||
// Stable derived value: only changes when route patterns change (not on enabled toggle)
|
||||
const routePatternsKey = visibleRouteRows.map((r) => `${r.id}:${r.modelPattern}:${r.routeMode || 'pattern'}`).join(',');
|
||||
const routePatterns = useMemo(
|
||||
() => visibleRouteRows.map((r) => ({ id: r.id, modelPattern: r.modelPattern, routeMode: r.routeMode })),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[visibleRouteRows.map((r) => `${r.id}:${r.modelPattern}:${r.routeMode || 'pattern'}`).join(',')],
|
||||
[routePatternsKey],
|
||||
);
|
||||
|
||||
const routeBrandById = useMemo(() => {
|
||||
@@ -582,15 +626,17 @@ export default function TokenRoutes() {
|
||||
}, [listVisibleRoutes, routeEndpointTypesByRouteId]);
|
||||
|
||||
const sourceEndpointTypesByRouteId = useMemo(() => {
|
||||
if (!showManual) return {};
|
||||
const next: Record<number, string[]> = {};
|
||||
for (const route of exactSourceRouteOptions) {
|
||||
next[route.id] = Array.from(routeEndpointTypesByRouteId[route.id] || new Set<string>())
|
||||
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
|
||||
}
|
||||
return next;
|
||||
}, [exactSourceRouteOptions, routeEndpointTypesByRouteId]);
|
||||
}, [showManual, exactSourceRouteOptions, routeEndpointTypesByRouteId]);
|
||||
|
||||
const routeBrandIconCandidates = useMemo(() => {
|
||||
if (!showManual) return [];
|
||||
const byIcon = new Map<string, BrandInfo>();
|
||||
|
||||
for (const route of visibleRouteRows) {
|
||||
@@ -605,7 +651,7 @@ export default function TokenRoutes() {
|
||||
|
||||
return Array.from(byIcon.values())
|
||||
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
|
||||
}, [visibleRouteRows, modelCandidates]);
|
||||
}, [showManual, visibleRouteRows, modelCandidates]);
|
||||
|
||||
const routeIconSelectOptions = useMemo<RouteIconOption[]>(() => ([
|
||||
...ROUTE_ICON_OPTIONS,
|
||||
@@ -652,7 +698,8 @@ export default function TokenRoutes() {
|
||||
})
|
||||
), [listVisibleRoutes, sortBy, sortDir]);
|
||||
|
||||
const filteredRoutes = useMemo(() => {
|
||||
// Shared base filter: all filters EXCEPT enabledFilter
|
||||
const baseFilteredRoutes = useMemo(() => {
|
||||
let list = sortedRoutes;
|
||||
|
||||
if (activeGroupFilter === '__all__') {
|
||||
@@ -670,9 +717,7 @@ export default function TokenRoutes() {
|
||||
}
|
||||
|
||||
if (activeSite) {
|
||||
list = list.filter((route) =>
|
||||
route.siteNames?.includes(activeSite),
|
||||
);
|
||||
list = list.filter((route) => route.siteNames?.includes(activeSite));
|
||||
}
|
||||
|
||||
if (activeEndpointType) {
|
||||
@@ -692,16 +737,26 @@ export default function TokenRoutes() {
|
||||
}
|
||||
|
||||
return list;
|
||||
}, [
|
||||
sortedRoutes,
|
||||
activeGroupFilter,
|
||||
activeBrand,
|
||||
activeSite,
|
||||
activeEndpointType,
|
||||
search,
|
||||
routeBrandById,
|
||||
routeEndpointTypesByRouteId,
|
||||
]);
|
||||
}, [sortedRoutes, activeGroupFilter, activeBrand, activeSite, activeEndpointType, search, routeBrandById, routeEndpointTypesByRouteId]);
|
||||
|
||||
const enabledCounts = useMemo(() => {
|
||||
let enabled = 0;
|
||||
let disabled = 0;
|
||||
for (const route of baseFilteredRoutes) {
|
||||
if (route.kind === 'zero_channel' || route.readOnly === true || route.isVirtual === true) continue;
|
||||
if (route.enabled) enabled++;
|
||||
else disabled++;
|
||||
}
|
||||
return { enabled, disabled };
|
||||
}, [baseFilteredRoutes]);
|
||||
|
||||
const filteredRoutes = useMemo(() => {
|
||||
if (enabledFilter === 'all') return baseFilteredRoutes;
|
||||
return baseFilteredRoutes.filter((route) => {
|
||||
if (route.kind === 'zero_channel' || route.readOnly === true || route.isVirtual === true) return false;
|
||||
return enabledFilter === 'enabled' ? route.enabled : !route.enabled;
|
||||
});
|
||||
}, [baseFilteredRoutes, enabledFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleRouteCount(getInitialVisibleCount(filteredRoutes.length, ROUTE_RENDER_CHUNK));
|
||||
@@ -713,6 +768,8 @@ export default function TokenRoutes() {
|
||||
|
||||
const loadMoreSentinelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const shouldShowLoadMore = filteredRoutes.length > 0 && visibleRouteCount < filteredRoutes.length;
|
||||
|
||||
useEffect(() => {
|
||||
const el = loadMoreSentinelRef.current;
|
||||
if (!el) return;
|
||||
@@ -722,29 +779,68 @@ export default function TokenRoutes() {
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [handleLoadMoreRoutes]);
|
||||
}, [handleLoadMoreRoutes, shouldShowLoadMore]);
|
||||
|
||||
const visibleRoutes = useMemo(
|
||||
() => filteredRoutes.slice(0, visibleRouteCount),
|
||||
[filteredRoutes, visibleRouteCount],
|
||||
);
|
||||
|
||||
const routeModelCandidateIndex = useMemo(
|
||||
() => buildRouteModelCandidatesIndex(routePatterns, modelCandidates, matchesModelPattern),
|
||||
[routePatterns, modelCandidates],
|
||||
);
|
||||
|
||||
const routeMissingTokenIndex = useMemo(
|
||||
() => buildRouteMissingTokenIndex(routePatterns, missingTokenModelsByName, matchesModelPattern),
|
||||
[routePatterns, missingTokenModelsByName],
|
||||
);
|
||||
const routeMissingTokenGroupIndex = useMemo(
|
||||
() => buildRouteMissingTokenIndex(routePatterns, missingTokenGroupModelsByName, matchesModelPattern),
|
||||
[routePatterns, missingTokenGroupModelsByName],
|
||||
);
|
||||
// Lazy per-route candidate index — only computes for routes actually accessed
|
||||
const candidateIndexCacheRef = useRef<{ key: string; cache: Map<number, RouteCandidateView> }>({ key: '', cache: new Map() });
|
||||
const candidateIndexCacheKey = `${routePatternsKey}|${Object.keys(modelCandidates).length}|${candidatesVersionRef.current}`;
|
||||
if (candidateIndexCacheRef.current.key !== candidateIndexCacheKey) {
|
||||
candidateIndexCacheRef.current = { key: candidateIndexCacheKey, cache: new Map() };
|
||||
}
|
||||
|
||||
const getRouteCandidateView = (routeId: number): RouteCandidateView => {
|
||||
return routeModelCandidateIndex[routeId] || EMPTY_ROUTE_CANDIDATE_VIEW;
|
||||
const cache = candidateIndexCacheRef.current.cache;
|
||||
const cached = cache.get(routeId);
|
||||
if (cached) return cached;
|
||||
const route = routePatterns.find((r) => r.id === routeId);
|
||||
if (!route) return EMPTY_ROUTE_CANDIDATE_VIEW;
|
||||
const index = buildRouteModelCandidatesIndex([route], modelCandidates, matchesModelPattern);
|
||||
const view = index[routeId] || EMPTY_ROUTE_CANDIDATE_VIEW;
|
||||
cache.set(routeId, view);
|
||||
return view;
|
||||
};
|
||||
|
||||
// Lazy per-route missing token index
|
||||
const missingTokenCacheRef = useRef<{ key: string; cache: Map<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(
|
||||
@@ -763,12 +859,45 @@ export default function TokenRoutes() {
|
||||
};
|
||||
|
||||
const handleDeleteChannel = async (channelId: number, routeId: number) => {
|
||||
const dismissedKey = 'metapi:channel-delete-warning-dismissed';
|
||||
const dismissed = localStorage.getItem(dismissedKey) === 'true';
|
||||
if (!dismissed) {
|
||||
const dontAskAgain = { checked: false };
|
||||
const confirmed = await new Promise<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 {
|
||||
await api.deleteChannel(channelId);
|
||||
toast.success('通道已移除');
|
||||
// Reload channels for this route
|
||||
await loadChannels(routeId, true);
|
||||
// Update channel count in summary
|
||||
setRouteSummaries((prev) =>
|
||||
prev.map((r) => r.id === routeId ? { ...r, channelCount: Math.max(0, r.channelCount - 1) } : r),
|
||||
);
|
||||
@@ -777,6 +906,20 @@ export default function TokenRoutes() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleChannelEnabled = async (channelId: number, routeId: number, enabled: boolean) => {
|
||||
if (updatingChannel[channelId]) return;
|
||||
setUpdatingChannel((prev) => ({ ...prev, [channelId]: true }));
|
||||
try {
|
||||
await api.updateChannel(channelId, { enabled });
|
||||
toast.success(enabled ? '通道已启用' : '通道已禁用');
|
||||
await loadChannels(routeId, true);
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || '更新通道状态失败');
|
||||
} finally {
|
||||
setUpdatingChannel((prev) => ({ ...prev, [channelId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleChannelTokenSave = async (routeId: number, channelId: number, accountId: number) => {
|
||||
const tokenId = channelTokenDraft[channelId];
|
||||
const tokenOptions = getRouteCandidateView(routeId).tokenOptionsByAccountId[accountId] || [];
|
||||
@@ -847,11 +990,67 @@ export default function TokenRoutes() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSiteBlockModel = async (channelId: number, routeId: number) => {
|
||||
const channels = channelsByRouteId[routeId] || [];
|
||||
const channel = channels.find((c) => c.id === channelId);
|
||||
if (!channel?.site?.id) {
|
||||
toast.error('找不到通道对应的站点信息');
|
||||
return;
|
||||
}
|
||||
const route = routeSummaries.find((r) => r.id === routeId);
|
||||
const modelName = channel.sourceModel || (route && isExactModelPattern(route.modelPattern) ? route.modelPattern : '') || '';
|
||||
if (!modelName) {
|
||||
toast.error('该通道没有精确模型名,无法使用站点屏蔽(通配符路由请在站点编辑中手动禁用)');
|
||||
return;
|
||||
}
|
||||
const siteName = channel.site.name || '未知站点';
|
||||
const confirmed = await new Promise<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 isCurrentlyExpanded = expandedRouteIds.includes(routeId);
|
||||
if (isCurrentlyExpanded) {
|
||||
setExpandedRouteIds((prev) => prev.filter((id) => id !== routeId));
|
||||
} else {
|
||||
loadCandidates();
|
||||
setExpandedRouteIds((prev) => [...prev, routeId]);
|
||||
// Load channels on demand
|
||||
const route = routeById.get(routeId) || null;
|
||||
@@ -866,10 +1065,9 @@ export default function TokenRoutes() {
|
||||
}
|
||||
};
|
||||
|
||||
const missingTokenSiteItemsByRouteId = useMemo(() => {
|
||||
const result: Record<number, MissingTokenRouteSiteActionItem[]> = {};
|
||||
for (const routeId of Object.keys(routeMissingTokenIndex).map(Number)) {
|
||||
const missingTokenHints = routeMissingTokenIndex[routeId] || [];
|
||||
const getMissingTokenSiteItems = (routeId: number): MissingTokenRouteSiteActionItem[] => {
|
||||
const missingTokenHints = getRouteMissingTokenHints(routeId);
|
||||
if (missingTokenHints.length === 0) return EMPTY_MISSING_ITEMS;
|
||||
const siteMap = new Map<string, MissingTokenRouteSiteActionItem>();
|
||||
for (const hint of missingTokenHints) {
|
||||
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' })
|
||||
));
|
||||
}
|
||||
return result;
|
||||
}, [routeMissingTokenIndex]);
|
||||
};
|
||||
|
||||
const missingTokenGroupItemsByRouteId = useMemo(() => {
|
||||
const result: Record<number, MissingTokenGroupRouteSiteActionItem[]> = {};
|
||||
for (const routeId of Object.keys(routeMissingTokenGroupIndex).map(Number)) {
|
||||
const missingGroupHints = routeMissingTokenGroupIndex[routeId] || [];
|
||||
const getMissingTokenGroupItems = (routeId: number): MissingTokenGroupRouteSiteActionItem[] => {
|
||||
const missingGroupHints = getRouteMissingTokenGroupHints(routeId);
|
||||
if (missingGroupHints.length === 0) return EMPTY_MISSING_GROUP_ITEMS;
|
||||
const siteMap = new Map<string, MissingTokenGroupRouteSiteActionItem>();
|
||||
for (const hint of missingGroupHints) {
|
||||
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' })
|
||||
));
|
||||
}
|
||||
return result;
|
||||
}, [routeMissingTokenGroupIndex]);
|
||||
};
|
||||
|
||||
// Stable callbacks for RouteCard memo (use refs to avoid dependency on closure variables)
|
||||
const toggleExpandRef = useRef(toggleExpand);
|
||||
@@ -968,7 +1161,10 @@ export default function TokenRoutes() {
|
||||
(channelId: number, tokenId: number) => setChannelTokenDraft((prev) => ({ ...prev, [channelId]: tokenId })),
|
||||
[],
|
||||
);
|
||||
const stableAddChannel = useCallback((routeId: number) => setAddChannelModalRouteId(routeId), []);
|
||||
const stableAddChannel = useCallback((routeId: number) => {
|
||||
loadCandidates();
|
||||
setAddChannelModalRouteId(routeId);
|
||||
}, []);
|
||||
const stableToggleSourceGroup = useCallback(
|
||||
(groupKey: string) => setExpandedSourceGroupMap((prev) => ({ ...prev, [groupKey]: !prev[groupKey] })),
|
||||
[],
|
||||
@@ -985,6 +1181,12 @@ export default function TokenRoutes() {
|
||||
(channelId: number, routeId: number) => handleDeleteChannelRef.current(channelId, routeId),
|
||||
[],
|
||||
);
|
||||
const handleToggleChannelEnabledRef = useRef(handleToggleChannelEnabled);
|
||||
handleToggleChannelEnabledRef.current = handleToggleChannelEnabled;
|
||||
const stableToggleChannelEnabled = useCallback(
|
||||
(channelId: number, routeId: number, enabled: boolean) => handleToggleChannelEnabledRef.current(channelId, routeId, enabled),
|
||||
[],
|
||||
);
|
||||
const handleChannelDragEndRef = useRef(handleChannelDragEnd);
|
||||
handleChannelDragEndRef.current = handleChannelDragEnd;
|
||||
const stableChannelDragEnd = useCallback(
|
||||
@@ -997,6 +1199,12 @@ export default function TokenRoutes() {
|
||||
(accountId: number, modelName: string) => handleCreateTokenRef.current(accountId, modelName),
|
||||
[],
|
||||
);
|
||||
const handleSiteBlockModelRef = useRef(handleSiteBlockModel);
|
||||
handleSiteBlockModelRef.current = handleSiteBlockModel;
|
||||
const stableSiteBlockModel = useCallback(
|
||||
(channelId: number, routeId: number) => handleSiteBlockModelRef.current(channelId, routeId),
|
||||
[],
|
||||
);
|
||||
|
||||
const addChannelModalRoute = addChannelModalRouteId
|
||||
? routeSummaries.find((r) => r.id === addChannelModalRouteId) || null
|
||||
@@ -1087,6 +1295,7 @@ export default function TokenRoutes() {
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
loadCandidates();
|
||||
resetRouteForm();
|
||||
setShowManual(true);
|
||||
}}
|
||||
@@ -1099,7 +1308,10 @@ export default function TokenRoutes() {
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={showZeroChannelRoutes}
|
||||
onClick={() => setShowZeroChannelRoutes((prev) => !prev)}
|
||||
onClick={() => {
|
||||
if (!showZeroChannelRoutes) loadCandidates();
|
||||
setShowZeroChannelRoutes((prev) => !prev);
|
||||
}}
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
|
||||
>
|
||||
@@ -1116,14 +1328,24 @@ export default function TokenRoutes() {
|
||||
<ResponsiveFilterPanel
|
||||
isMobile={isMobile}
|
||||
mobileOpen={showFilters}
|
||||
onMobileOpen={() => setShowFilters(true)}
|
||||
onMobileClose={() => setShowFilters(false)}
|
||||
mobileTitle={tr('筛选路由')}
|
||||
mobileTriggerWrapperClassName=""
|
||||
mobileTriggerButtonStyle={{ border: '1px solid var(--color-border)', padding: '8px 14px', marginBottom: 12 }}
|
||||
mobileTrigger={(
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)', padding: '8px 14px', marginBottom: 12 }}
|
||||
onClick={() => {
|
||||
loadCandidates();
|
||||
setShowFilters(true);
|
||||
}}
|
||||
>
|
||||
{tr('筛选')}
|
||||
</button>
|
||||
)}
|
||||
mobileContent={(
|
||||
<RouteFilterBar
|
||||
totalRouteCount={listVisibleRoutes.length}
|
||||
totalRouteCount={baseFilteredRoutes.length}
|
||||
activeBrand={activeBrand}
|
||||
setActiveBrand={setActiveBrand}
|
||||
activeSite={activeSite}
|
||||
@@ -1132,6 +1354,9 @@ export default function TokenRoutes() {
|
||||
setActiveEndpointType={setActiveEndpointType}
|
||||
activeGroupFilter={activeGroupFilter}
|
||||
setActiveGroupFilter={setActiveGroupFilter}
|
||||
enabledFilter={enabledFilter}
|
||||
setEnabledFilter={setEnabledFilter}
|
||||
enabledCounts={enabledCounts}
|
||||
brandList={brandList}
|
||||
siteList={siteList}
|
||||
endpointTypeList={endpointTypeList}
|
||||
@@ -1142,7 +1367,7 @@ export default function TokenRoutes() {
|
||||
)}
|
||||
desktopContent={(
|
||||
<RouteFilterBar
|
||||
totalRouteCount={listVisibleRoutes.length}
|
||||
totalRouteCount={baseFilteredRoutes.length}
|
||||
activeBrand={activeBrand}
|
||||
setActiveBrand={setActiveBrand}
|
||||
activeSite={activeSite}
|
||||
@@ -1151,12 +1376,18 @@ export default function TokenRoutes() {
|
||||
setActiveEndpointType={setActiveEndpointType}
|
||||
activeGroupFilter={activeGroupFilter}
|
||||
setActiveGroupFilter={setActiveGroupFilter}
|
||||
enabledFilter={enabledFilter}
|
||||
setEnabledFilter={setEnabledFilter}
|
||||
enabledCounts={enabledCounts}
|
||||
brandList={brandList}
|
||||
siteList={siteList}
|
||||
endpointTypeList={endpointTypeList}
|
||||
groupRouteList={groupRouteList}
|
||||
collapsed={filterCollapsed}
|
||||
onToggle={() => setFilterCollapsed((prev) => !prev)}
|
||||
onToggle={() => {
|
||||
if (filterCollapsed) loadCandidates();
|
||||
setFilterCollapsed((prev) => !prev);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -1232,7 +1463,7 @@ export default function TokenRoutes() {
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-link"
|
||||
onClick={() => setAddChannelModalRouteId(route.id)}
|
||||
onClick={() => stableAddChannel(route.id)}
|
||||
>
|
||||
{tr('添加通道')}
|
||||
</button>
|
||||
@@ -1274,11 +1505,13 @@ export default function TokenRoutes() {
|
||||
onTokenDraftChange={stableTokenDraftChange}
|
||||
onSaveToken={stableChannelTokenSave}
|
||||
onDeleteChannel={stableDeleteChannel}
|
||||
onToggleChannelEnabled={stableToggleChannelEnabled}
|
||||
onChannelDragEnd={stableChannelDragEnd}
|
||||
missingTokenSiteItems={missingTokenSiteItemsByRouteId[route.id] || EMPTY_MISSING_ITEMS}
|
||||
missingTokenGroupItems={missingTokenGroupItemsByRouteId[route.id] || EMPTY_MISSING_GROUP_ITEMS}
|
||||
missingTokenSiteItems={getMissingTokenSiteItems(route.id)}
|
||||
missingTokenGroupItems={getMissingTokenGroupItems(route.id)}
|
||||
onCreateTokenForMissing={stableCreateTokenForMissing}
|
||||
onAddChannel={stableAddChannel}
|
||||
onSiteBlockModel={stableSiteBlockModel}
|
||||
expandedSourceGroupMap={expandedSourceGroupMap}
|
||||
onToggleSourceGroup={stableToggleSourceGroup}
|
||||
/>
|
||||
@@ -1310,11 +1543,13 @@ export default function TokenRoutes() {
|
||||
onTokenDraftChange={stableTokenDraftChange}
|
||||
onSaveToken={stableChannelTokenSave}
|
||||
onDeleteChannel={stableDeleteChannel}
|
||||
onToggleChannelEnabled={stableToggleChannelEnabled}
|
||||
onChannelDragEnd={stableChannelDragEnd}
|
||||
missingTokenSiteItems={missingTokenSiteItemsByRouteId[route.id] || EMPTY_MISSING_ITEMS}
|
||||
missingTokenGroupItems={missingTokenGroupItemsByRouteId[route.id] || EMPTY_MISSING_GROUP_ITEMS}
|
||||
missingTokenSiteItems={getMissingTokenSiteItems(route.id)}
|
||||
missingTokenGroupItems={getMissingTokenGroupItems(route.id)}
|
||||
onCreateTokenForMissing={stableCreateTokenForMissing}
|
||||
onAddChannel={stableAddChannel}
|
||||
onSiteBlockModel={stableSiteBlockModel}
|
||||
expandedSourceGroupMap={expandedSourceGroupMap}
|
||||
onToggleSourceGroup={stableToggleSourceGroup}
|
||||
/>
|
||||
@@ -1322,7 +1557,7 @@ export default function TokenRoutes() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{filteredRoutes.length > 0 && visibleRouteCount < filteredRoutes.length && (
|
||||
{shouldShowLoadMore && (
|
||||
<div
|
||||
ref={loadMoreSentinelRef}
|
||||
style={{ textAlign: 'center', padding: '12px 0', fontSize: 12, color: 'var(--color-text-muted)' }}
|
||||
@@ -1361,7 +1596,7 @@ export default function TokenRoutes() {
|
||||
routeTitle={resolveRouteTitle(addChannelModalRoute)}
|
||||
candidateView={getRouteCandidateView(addChannelModalRoute.id)}
|
||||
onSuccess={handleAddChannelSuccess}
|
||||
missingTokenHints={routeMissingTokenIndex[addChannelModalRoute.id] || []}
|
||||
missingTokenHints={getRouteMissingTokenHints(addChannelModalRoute.id)}
|
||||
onCreateTokenForMissing={handleCreateTokenForMissingAccount}
|
||||
existingChannelAccountIds={new Set((channelsByRouteId[addChannelModalRoute.id] || []).map((c) => c.accountId))}
|
||||
/>
|
||||
|
||||
@@ -11,6 +11,7 @@ const { apiMock } = vi.hoisted(() => ({
|
||||
getDownstreamApiKeys: vi.fn(),
|
||||
getRoutesLite: vi.fn(),
|
||||
getRuntimeDatabaseConfig: vi.fn(),
|
||||
getBrandList: vi.fn(),
|
||||
factoryReset: vi.fn(),
|
||||
},
|
||||
}));
|
||||
@@ -77,6 +78,7 @@ describe('Settings factory reset', () => {
|
||||
});
|
||||
apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] });
|
||||
apiMock.getRoutesLite.mockResolvedValue([]);
|
||||
apiMock.getBrandList.mockResolvedValue({ brands: [] });
|
||||
apiMock.getRuntimeDatabaseConfig.mockResolvedValue({
|
||||
active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false },
|
||||
saved: null,
|
||||
|
||||
@@ -12,6 +12,7 @@ const { apiMock } = vi.hoisted(() => ({
|
||||
getDownstreamApiKeys: vi.fn(),
|
||||
getRoutesLite: vi.fn(),
|
||||
getRuntimeDatabaseConfig: vi.fn(),
|
||||
getBrandList: vi.fn(),
|
||||
updateRuntimeSettings: vi.fn(),
|
||||
triggerCheckinAll: vi.fn(),
|
||||
},
|
||||
@@ -62,6 +63,7 @@ describe('Settings log cleanup schedule', () => {
|
||||
});
|
||||
apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] });
|
||||
apiMock.getRoutesLite.mockResolvedValue([]);
|
||||
apiMock.getBrandList.mockResolvedValue({ brands: [] });
|
||||
apiMock.getRuntimeDatabaseConfig.mockResolvedValue({
|
||||
active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false },
|
||||
saved: null,
|
||||
|
||||
@@ -11,6 +11,7 @@ const { apiMock } = vi.hoisted(() => ({
|
||||
getDownstreamApiKeys: vi.fn(),
|
||||
getRoutesLite: vi.fn(),
|
||||
getRuntimeDatabaseConfig: vi.fn(),
|
||||
getBrandList: vi.fn(),
|
||||
updateRuntimeSettings: vi.fn(),
|
||||
testSystemProxy: vi.fn(),
|
||||
},
|
||||
@@ -59,6 +60,7 @@ describe('Settings system proxy', () => {
|
||||
});
|
||||
apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] });
|
||||
apiMock.getRoutesLite.mockResolvedValue([]);
|
||||
apiMock.getBrandList.mockResolvedValue({ brands: [] });
|
||||
apiMock.getRuntimeDatabaseConfig.mockResolvedValue({
|
||||
active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false },
|
||||
saved: null,
|
||||
|
||||
@@ -10,6 +10,7 @@ const { apiMock } = vi.hoisted(() => ({
|
||||
getSites: vi.fn(),
|
||||
addSite: vi.fn(),
|
||||
getSiteDisabledModels: vi.fn().mockResolvedValue({ models: [] }),
|
||||
getSiteAvailableModels: vi.fn().mockResolvedValue({ models: [] }),
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ const { apiMock, toastMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getSites: vi.fn(),
|
||||
getSiteDisabledModels: vi.fn(),
|
||||
getSiteAvailableModels: vi.fn(),
|
||||
updateSiteDisabledModels: vi.fn(),
|
||||
rebuildRoutes: vi.fn(),
|
||||
},
|
||||
@@ -45,8 +46,11 @@ async function flushMicrotasks() {
|
||||
}
|
||||
|
||||
describe('Sites disabled models save', () => {
|
||||
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
apiMock.getSites.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
@@ -57,18 +61,17 @@ describe('Sites disabled models save', () => {
|
||||
},
|
||||
]);
|
||||
apiMock.getSiteDisabledModels.mockResolvedValue({ models: [] });
|
||||
apiMock.getSiteAvailableModels.mockResolvedValue({ models: [] });
|
||||
apiMock.updateSiteDisabledModels.mockResolvedValue({ success: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleWarnSpy.mockRestore();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('reports route rebuild failure after saving disabled models from sites editor', async () => {
|
||||
apiMock.rebuildRoutes.mockRejectedValue(new Error('rebuild failed'));
|
||||
|
||||
async function renderSitesEditor() {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/sites']}>
|
||||
@@ -91,6 +94,16 @@ describe('Sites disabled models save', () => {
|
||||
});
|
||||
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) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
@@ -110,4 +123,74 @@ describe('Sites disabled models save', () => {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves existing disabled models when available-model loading fails', async () => {
|
||||
apiMock.getSiteDisabledModels.mockResolvedValue({ models: ['gpt-4o'] });
|
||||
apiMock.getSiteAvailableModels.mockRejectedValue(new Error('available models failed'));
|
||||
apiMock.rebuildRoutes.mockResolvedValue({ success: true });
|
||||
|
||||
let root: ReturnType<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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getSites: vi.fn(),
|
||||
getSiteDisabledModels: vi.fn().mockResolvedValue({ models: [] }),
|
||||
getSiteAvailableModels: vi.fn().mockResolvedValue({ models: [] }),
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ type RouteCardProps = {
|
||||
onTokenDraftChange: (channelId: number, tokenId: number) => void;
|
||||
onSaveToken: (routeId: number, channelId: number, accountId: number) => void;
|
||||
onDeleteChannel: (channelId: number, routeId: number) => void;
|
||||
onToggleChannelEnabled: (channelId: number, routeId: number, enabled: boolean) => void;
|
||||
onChannelDragEnd: (routeId: number, event: DragEndEvent) => void;
|
||||
// Missing token hints
|
||||
missingTokenSiteItems: MissingTokenRouteSiteActionItem[];
|
||||
@@ -68,6 +69,8 @@ type RouteCardProps = {
|
||||
onCreateTokenForMissing: (accountId: number, modelName: string) => void;
|
||||
// Add channel
|
||||
onAddChannel: (routeId: number) => void;
|
||||
// Site block model
|
||||
onSiteBlockModel: (channelId: number, routeId: number) => void;
|
||||
// Source group expansion
|
||||
expandedSourceGroupMap: Record<string, boolean>;
|
||||
onToggleSourceGroup: (groupKey: string) => void;
|
||||
@@ -119,11 +122,13 @@ function RouteCardInner({
|
||||
onTokenDraftChange,
|
||||
onSaveToken,
|
||||
onDeleteChannel,
|
||||
onToggleChannelEnabled,
|
||||
onChannelDragEnd,
|
||||
missingTokenSiteItems,
|
||||
missingTokenGroupItems,
|
||||
onCreateTokenForMissing,
|
||||
onAddChannel,
|
||||
onSiteBlockModel,
|
||||
expandedSourceGroupMap,
|
||||
onToggleSourceGroup,
|
||||
}: RouteCardProps) {
|
||||
@@ -548,6 +553,7 @@ function RouteCardInner({
|
||||
onTokenDraftChange={onTokenDraftChange}
|
||||
onSaveToken={() => onSaveToken(route.id, channel.id, channel.accountId)}
|
||||
onDeleteChannel={() => onDeleteChannel(channel.id, route.id)}
|
||||
onToggleEnabled={(enabled) => onToggleChannelEnabled(channel.id, route.id, enabled)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -572,6 +578,8 @@ function RouteCardInner({
|
||||
onTokenDraftChange={onTokenDraftChange}
|
||||
onSaveToken={() => onSaveToken(route.id, channel.id, channel.accountId)}
|
||||
onDeleteChannel={() => onDeleteChannel(channel.id, route.id)}
|
||||
onToggleEnabled={(enabled) => onToggleChannelEnabled(channel.id, route.id, enabled)}
|
||||
onSiteBlockModel={() => onSiteBlockModel(channel.id, route.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { tr } from '../../i18n.js';
|
||||
import type { GroupFilter, GroupRouteItem } from './types.js';
|
||||
import { resolveEndpointTypeIconModel, siteAvatarLetters } from './utils.js';
|
||||
|
||||
export type EnabledFilter = 'all' | 'enabled' | 'disabled';
|
||||
|
||||
type RouteFilterBarProps = {
|
||||
totalRouteCount: number;
|
||||
activeBrand: string | null;
|
||||
@@ -15,6 +17,9 @@ type RouteFilterBarProps = {
|
||||
setActiveEndpointType: (endpointType: string | null) => void;
|
||||
activeGroupFilter: GroupFilter;
|
||||
setActiveGroupFilter: (filter: GroupFilter) => void;
|
||||
enabledFilter: EnabledFilter;
|
||||
setEnabledFilter: (filter: EnabledFilter) => void;
|
||||
enabledCounts: { enabled: number; disabled: number };
|
||||
brandList: { list: [string, { count: number; brand: BrandInfo }][]; otherCount: number };
|
||||
siteList: [string, { count: number; siteId: number }][];
|
||||
endpointTypeList: [string, number][];
|
||||
@@ -63,8 +68,11 @@ function ActiveFilterSummary({
|
||||
activeSite,
|
||||
activeGroupFilter,
|
||||
activeEndpointType,
|
||||
}: Pick<RouteFilterBarProps, 'activeBrand' | 'activeSite' | 'activeGroupFilter' | 'activeEndpointType'>) {
|
||||
enabledFilter,
|
||||
}: Pick<RouteFilterBarProps, 'activeBrand' | 'activeSite' | 'activeGroupFilter' | 'activeEndpointType' | 'enabledFilter'>) {
|
||||
const tags: string[] = [];
|
||||
if (enabledFilter === 'enabled') tags.push('状态=启用');
|
||||
else if (enabledFilter === 'disabled') tags.push('状态=禁用');
|
||||
if (activeBrand) tags.push(`品牌=${activeBrand === '__other__' ? '其他' : activeBrand}`);
|
||||
if (activeSite) tags.push(`站点=${activeSite}`);
|
||||
if (activeGroupFilter === '__all__') tags.push('群组=全部');
|
||||
@@ -86,6 +94,9 @@ export default function RouteFilterBar(props: RouteFilterBarProps) {
|
||||
setActiveEndpointType,
|
||||
activeGroupFilter,
|
||||
setActiveGroupFilter,
|
||||
enabledFilter,
|
||||
setEnabledFilter,
|
||||
enabledCounts,
|
||||
brandList,
|
||||
siteList,
|
||||
endpointTypeList,
|
||||
@@ -117,12 +128,38 @@ export default function RouteFilterBar(props: RouteFilterBarProps) {
|
||||
activeSite={activeSite}
|
||||
activeGroupFilter={activeGroupFilter}
|
||||
activeEndpointType={activeEndpointType}
|
||||
enabledFilter={enabledFilter}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Expanded panel */}
|
||||
{expandPresence.shouldRender && (
|
||||
<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 */}
|
||||
<FilterRow label={tr('品牌')}>
|
||||
<FilterChip
|
||||
|
||||
@@ -25,6 +25,8 @@ export function SortableChannelRow({
|
||||
onTokenDraftChange,
|
||||
onSaveToken,
|
||||
onDeleteChannel,
|
||||
onToggleEnabled,
|
||||
onSiteBlockModel,
|
||||
}: SortableChannelRowProps) {
|
||||
const {
|
||||
attributes,
|
||||
@@ -42,16 +44,17 @@ export function SortableChannelRow({
|
||||
const rowStyle: CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.72 : 1,
|
||||
opacity: isDragging ? 0.72 : channel.enabled === false ? 0.5 : 1,
|
||||
zIndex: isDragging ? 10 : 1,
|
||||
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',
|
||||
gap: 8,
|
||||
padding: mobile ? '10px 12px' : '8px 12px',
|
||||
borderLeft: '2px solid var(--color-primary)',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
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',
|
||||
};
|
||||
|
||||
@@ -263,6 +266,22 @@ export function SortableChannelRow({
|
||||
{isUpdatingToken ? <span className="spinner spinner-sm" /> : '保存'}
|
||||
</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
|
||||
onClick={onDeleteChannel}
|
||||
className="btn btn-link btn-link-danger"
|
||||
@@ -376,6 +395,10 @@ export function SortableChannelRow({
|
||||
</span>
|
||||
) : 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' }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>选中概率</span>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 6, minWidth: 120 }}>
|
||||
@@ -457,6 +480,22 @@ export function SortableChannelRow({
|
||||
{isUpdatingToken ? <span className="spinner spinner-sm" /> : '保存'}
|
||||
</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
|
||||
onClick={onDeleteChannel}
|
||||
className="btn btn-link btn-link-danger"
|
||||
@@ -501,12 +540,32 @@ export function SortableChannelRow({
|
||||
</button>
|
||||
</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
|
||||
onClick={onDeleteChannel}
|
||||
className="btn btn-link btn-link-danger"
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
) : null}
|
||||
|
||||
@@ -135,6 +135,8 @@ export type SortableChannelRowProps = {
|
||||
onTokenDraftChange: (channelId: number, tokenId: number) => void;
|
||||
onSaveToken: () => void;
|
||||
onDeleteChannel: () => void;
|
||||
onToggleEnabled: (enabled: boolean) => void;
|
||||
onSiteBlockModel?: () => void;
|
||||
};
|
||||
|
||||
export type GroupRouteItem = {
|
||||
|
||||
Reference in New Issue
Block a user