Merge pull request #53 from Hureru/refactor/token-routes-page

refactor: 重构 TokenRoutes 页面,支持分层加载、网格布局和性能优化
This commit is contained in:
Cita
2026-03-11 16:14:25 +08:00
committed by GitHub
18 changed files with 3346 additions and 2406 deletions
@@ -0,0 +1,64 @@
export type AccountTokenSchemaDialect = 'sqlite' | 'mysql' | 'postgres';
export interface AccountTokenSchemaInspector {
dialect: AccountTokenSchemaDialect;
tableExists(table: string): Promise<boolean>;
columnExists(table: string, column: string): Promise<boolean>;
execute(sqlText: string): Promise<void>;
}
type AccountTokenColumnCompatibilitySpec = {
table: 'account_tokens';
column: string;
addSql: Record<AccountTokenSchemaDialect, string>;
};
const ACCOUNT_TOKEN_COLUMN_COMPATIBILITY_SPECS: AccountTokenColumnCompatibilitySpec[] = [
{
table: 'account_tokens',
column: 'token_group',
addSql: {
sqlite: 'ALTER TABLE account_tokens ADD COLUMN token_group text;',
mysql: 'ALTER TABLE `account_tokens` ADD COLUMN `token_group` TEXT NULL',
postgres: 'ALTER TABLE "account_tokens" ADD COLUMN "token_group" TEXT',
},
},
];
function normalizeSchemaErrorMessage(error: unknown): string {
if (typeof error === 'object' && error && 'message' in error) {
return String((error as { message?: unknown }).message || '');
}
return String(error || '');
}
function isDuplicateColumnError(error: unknown): boolean {
const lowered = normalizeSchemaErrorMessage(error).toLowerCase();
return lowered.includes('duplicate column')
|| lowered.includes('already exists')
|| lowered.includes('duplicate column name');
}
async function executeAddColumn(inspector: AccountTokenSchemaInspector, sqlText: string): Promise<void> {
try {
await inspector.execute(sqlText);
} catch (error) {
if (!isDuplicateColumnError(error)) {
throw error;
}
}
}
export async function ensureAccountTokenSchemaCompatibility(inspector: AccountTokenSchemaInspector): Promise<void> {
for (const spec of ACCOUNT_TOKEN_COLUMN_COMPATIBILITY_SPECS) {
const hasTable = await inspector.tableExists(spec.table);
if (!hasTable) {
continue;
}
const hasColumn = await inspector.columnExists(spec.table, spec.column);
if (!hasColumn) {
await executeAddColumn(inspector, spec.addSql[inspector.dialect]);
}
}
}
+2
View File
@@ -6,6 +6,7 @@ import { dirname, resolve } from 'node:path';
import { ensureSiteSchemaCompatibility, type SiteSchemaInspector } from './siteSchemaCompatibility.js'; import { ensureSiteSchemaCompatibility, type SiteSchemaInspector } from './siteSchemaCompatibility.js';
import { ensureRouteGroupingSchemaCompatibility } from './routeGroupingSchemaCompatibility.js'; import { ensureRouteGroupingSchemaCompatibility } from './routeGroupingSchemaCompatibility.js';
import { ensureProxyFileSchemaCompatibility } from './proxyFileSchemaCompatibility.js'; import { ensureProxyFileSchemaCompatibility } from './proxyFileSchemaCompatibility.js';
import { ensureAccountTokenSchemaCompatibility } from './accountTokenSchemaCompatibility.js';
export type RuntimeSchemaDialect = 'sqlite' | 'mysql' | 'postgres'; export type RuntimeSchemaDialect = 'sqlite' | 'mysql' | 'postgres';
@@ -241,6 +242,7 @@ export async function ensureRuntimeDatabaseSchema(client: RuntimeSchemaClient):
await ensureSiteSchemaCompatibility(createSiteSchemaInspector(client)); await ensureSiteSchemaCompatibility(createSiteSchemaInspector(client));
await ensureRouteGroupingSchemaCompatibility(createSiteSchemaInspector(client)); await ensureRouteGroupingSchemaCompatibility(createSiteSchemaInspector(client));
await ensureProxyFileSchemaCompatibility(createSiteSchemaInspector(client)); await ensureProxyFileSchemaCompatibility(createSiteSchemaInspector(client));
await ensureAccountTokenSchemaCompatibility(createSiteSchemaInspector(client));
} }
export async function bootstrapRuntimeDatabaseSchema(input: RuntimeSchemaConnectionInput): Promise<void> { export async function bootstrapRuntimeDatabaseSchema(input: RuntimeSchemaConnectionInput): Promise<void> {
+469 -314
View File
@@ -1,15 +1,15 @@
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from 'fastify';
import { and, eq, inArray } from 'drizzle-orm'; import { and, eq, inArray } from 'drizzle-orm';
import { db, schema } from '../../db/index.js'; import { db, schema } from '../../db/index.js';
import { rebuildTokenRoutesFromAvailability, refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; import { rebuildTokenRoutesFromAvailability, refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import { invalidateTokenRouterCache, matchesModelPattern, tokenRouter } from '../../services/tokenRouter.js'; import { invalidateTokenRouterCache, matchesModelPattern, tokenRouter } from '../../services/tokenRouter.js';
import { startBackgroundTask } from '../../services/backgroundTaskService.js'; import { startBackgroundTask } from '../../services/backgroundTaskService.js';
import { import {
clearRouteDecisionSnapshot, clearRouteDecisionSnapshot,
clearRouteDecisionSnapshots, clearRouteDecisionSnapshots,
parseRouteDecisionSnapshot, parseRouteDecisionSnapshot,
saveRouteDecisionSnapshots, saveRouteDecisionSnapshots,
} from '../../services/routeDecisionSnapshotStore.js'; } from '../../services/routeDecisionSnapshotStore.js';
function isExactModelPattern(modelPattern: string): boolean { function isExactModelPattern(modelPattern: string): boolean {
const normalized = modelPattern.trim(); const normalized = modelPattern.trim();
@@ -127,9 +127,9 @@ async function getMatchedExactRouteChannelCandidates(modelPattern: string): Prom
})).filter((candidate) => candidate.sourceModel.length > 0); })).filter((candidate) => candidate.sourceModel.length > 0);
} }
async function populateRouteChannelsByModelPattern(routeId: number, modelPattern: string): Promise<number> { async function populateRouteChannelsByModelPattern(routeId: number, modelPattern: string): Promise<number> {
const routeCandidates = await getMatchedExactRouteChannelCandidates(modelPattern); const routeCandidates = await getMatchedExactRouteChannelCandidates(modelPattern);
const availabilityCandidates = (await getPatternTokenCandidates(modelPattern)).map((candidate) => ({ const availabilityCandidates = (await getPatternTokenCandidates(modelPattern)).map((candidate) => ({
tokenId: candidate.tokenId, tokenId: candidate.tokenId,
accountId: candidate.accountId, accountId: candidate.accountId,
sourceModel: candidate.sourceModel, sourceModel: candidate.sourceModel,
@@ -171,59 +171,59 @@ async function populateRouteChannelsByModelPattern(routeId: number, modelPattern
existingPairs.add(pairKey); existingPairs.add(pairKey);
created += 1; created += 1;
} }
return created; return created;
} }
async function rebuildAutomaticRouteChannelsByModelPattern(routeId: number, modelPattern: string): Promise<{ async function rebuildAutomaticRouteChannelsByModelPattern(routeId: number, modelPattern: string): Promise<{
removedChannels: number; removedChannels: number;
createdChannels: number; createdChannels: number;
}> { }> {
const removableChannels = await db.select().from(schema.routeChannels) const removableChannels = await db.select().from(schema.routeChannels)
.where( .where(
and( and(
eq(schema.routeChannels.routeId, routeId), eq(schema.routeChannels.routeId, routeId),
eq(schema.routeChannels.manualOverride, false), eq(schema.routeChannels.manualOverride, false),
), ),
) )
.all(); .all();
for (const channel of removableChannels) { for (const channel of removableChannels) {
await db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channel.id)).run(); await db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channel.id)).run();
} }
const createdChannels = await populateRouteChannelsByModelPattern(routeId, modelPattern); const createdChannels = await populateRouteChannelsByModelPattern(routeId, modelPattern);
return { return {
removedChannels: removableChannels.length, removedChannels: removableChannels.length,
createdChannels, createdChannels,
}; };
} }
type BatchChannelPriorityUpdate = { type BatchChannelPriorityUpdate = {
id: number; id: number;
priority: number; priority: number;
}; };
type BatchRouteDecisionModels = { type BatchRouteDecisionModels = {
models: string[]; models: string[];
refreshPricingCatalog?: boolean; refreshPricingCatalog?: boolean;
persistSnapshots?: boolean; persistSnapshots?: boolean;
}; };
type BatchRouteDecisionRouteModels = { type BatchRouteDecisionRouteModels = {
items: Array<{ items: Array<{
routeId: number; routeId: number;
model: string; model: string;
}>; }>;
refreshPricingCatalog?: boolean; refreshPricingCatalog?: boolean;
persistSnapshots?: boolean; persistSnapshots?: boolean;
}; };
type BatchRouteWideDecisionRouteIds = { type BatchRouteWideDecisionRouteIds = {
routeIds: number[]; routeIds: number[];
refreshPricingCatalog?: boolean; refreshPricingCatalog?: boolean;
persistSnapshots?: boolean; persistSnapshots?: boolean;
}; };
function parseBatchChannelUpdates(input: unknown): { ok: true; updates: BatchChannelPriorityUpdate[] } | { ok: false; message: string } { function parseBatchChannelUpdates(input: unknown): { ok: true; updates: BatchChannelPriorityUpdate[] } | { ok: false; message: string } {
if (!input || typeof input !== 'object') { if (!input || typeof input !== 'object') {
@@ -264,9 +264,9 @@ function parseBatchChannelUpdates(input: unknown): { ok: true; updates: BatchCha
return { ok: true, updates: normalized }; return { ok: true, updates: normalized };
} }
function parseBatchRouteDecisionModels( function parseBatchRouteDecisionModels(
input: unknown, input: unknown,
): { ok: true; models: string[]; refreshPricingCatalog: boolean; persistSnapshots: boolean } | { ok: false; message: string } { ): { ok: true; models: string[]; refreshPricingCatalog: boolean; persistSnapshots: boolean } | { ok: false; message: string } {
if (!input || typeof input !== 'object') { if (!input || typeof input !== 'object') {
return { ok: false, message: '请求体必须是对象' }; return { ok: false, message: '请求体必须是对象' };
} }
@@ -291,17 +291,17 @@ function parseBatchRouteDecisionModels(
return { ok: false, message: 'models 中没有有效模型名称' }; return { ok: false, message: 'models 中没有有效模型名称' };
} }
return { return {
ok: true, ok: true,
models: normalized, models: normalized,
refreshPricingCatalog: (input as { refreshPricingCatalog?: unknown }).refreshPricingCatalog === true, refreshPricingCatalog: (input as { refreshPricingCatalog?: unknown }).refreshPricingCatalog === true,
persistSnapshots: (input as { persistSnapshots?: unknown }).persistSnapshots === true, persistSnapshots: (input as { persistSnapshots?: unknown }).persistSnapshots === true,
}; };
} }
function parseBatchRouteDecisionRouteModels( function parseBatchRouteDecisionRouteModels(
input: unknown, input: unknown,
): { ok: true; items: Array<{ routeId: number; model: string }>; refreshPricingCatalog: boolean; persistSnapshots: boolean } | { ok: false; message: string } { ): { ok: true; items: Array<{ routeId: number; model: string }>; refreshPricingCatalog: boolean; persistSnapshots: boolean } | { ok: false; message: string } {
if (!input || typeof input !== 'object') { if (!input || typeof input !== 'object') {
return { ok: false, message: '请求体必须是对象' }; return { ok: false, message: '请求体必须是对象' };
} }
@@ -335,17 +335,17 @@ function parseBatchRouteDecisionRouteModels(
return { ok: false, message: 'items 中没有有效 routeId/model' }; return { ok: false, message: 'items 中没有有效 routeId/model' };
} }
return { return {
ok: true, ok: true,
items: normalized, items: normalized,
refreshPricingCatalog: (input as { refreshPricingCatalog?: unknown }).refreshPricingCatalog === true, refreshPricingCatalog: (input as { refreshPricingCatalog?: unknown }).refreshPricingCatalog === true,
persistSnapshots: (input as { persistSnapshots?: unknown }).persistSnapshots === true, persistSnapshots: (input as { persistSnapshots?: unknown }).persistSnapshots === true,
}; };
} }
function parseBatchRouteWideDecisionRouteIds( function parseBatchRouteWideDecisionRouteIds(
input: unknown, input: unknown,
): { ok: true; routeIds: number[]; refreshPricingCatalog: boolean; persistSnapshots: boolean } | { ok: false; message: string } { ): { ok: true; routeIds: number[]; refreshPricingCatalog: boolean; persistSnapshots: boolean } | { ok: false; message: string } {
if (!input || typeof input !== 'object') { if (!input || typeof input !== 'object') {
return { ok: false, message: '请求体必须是对象' }; return { ok: false, message: '请求体必须是对象' };
} }
@@ -370,56 +370,211 @@ function parseBatchRouteWideDecisionRouteIds(
return { ok: false, message: 'routeIds 中没有有效 routeId' }; return { ok: false, message: 'routeIds 中没有有效 routeId' };
} }
return { return {
ok: true, ok: true,
routeIds: normalized, routeIds: normalized,
refreshPricingCatalog: (input as { refreshPricingCatalog?: unknown }).refreshPricingCatalog === true, refreshPricingCatalog: (input as { refreshPricingCatalog?: unknown }).refreshPricingCatalog === true,
persistSnapshots: (input as { persistSnapshots?: unknown }).persistSnapshots === true, persistSnapshots: (input as { persistSnapshots?: unknown }).persistSnapshots === true,
}; };
} }
async function fetchChannelsForRoutes(routeIds: number[]): Promise<Map<number, any[]>> {
if (routeIds.length === 0) return new Map();
const channelRows = await db.select().from(schema.routeChannels)
.innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.leftJoin(schema.accountTokens, eq(schema.routeChannels.tokenId, schema.accountTokens.id))
.where(inArray(schema.routeChannels.routeId, routeIds))
.all();
const channelsByRoute = new Map<number, any[]>();
for (const row of channelRows) {
const routeId = row.route_channels.routeId;
if (!channelsByRoute.has(routeId)) channelsByRoute.set(routeId, []);
channelsByRoute.get(routeId)!.push({
...row.route_channels,
account: row.accounts,
site: row.sites,
token: row.account_tokens
? {
id: row.account_tokens.id,
name: row.account_tokens.name,
accountId: row.account_tokens.accountId,
enabled: row.account_tokens.enabled,
isDefault: row.account_tokens.isDefault,
}
: null,
});
}
return channelsByRoute;
}
export async function tokensRoutes(app: FastifyInstance) { export async function tokensRoutes(app: FastifyInstance) {
// List all routes // List routes with basic info only (lightweight for selectors)
app.get('/api/routes', async () => { app.get('/api/routes/lite', async () => {
const routes = await db.select().from(schema.tokenRoutes).all(); return await db.select({
if (routes.length === 0) return []; id: schema.tokenRoutes.id,
modelPattern: schema.tokenRoutes.modelPattern,
displayName: schema.tokenRoutes.displayName,
displayIcon: schema.tokenRoutes.displayIcon,
enabled: schema.tokenRoutes.enabled,
}).from(schema.tokenRoutes).all();
});
// Route summary (no channel details) for first-screen rendering
app.get('/api/routes/summary', async () => {
const routes = await db.select().from(schema.tokenRoutes).all();
if (routes.length === 0) return [];
const routeIds = routes.map((route) => route.id); const routeIds = routes.map((route) => route.id);
const channelRows = await db.select().from(schema.routeChannels)
// Aggregate channel counts and site names per route
const channelRows = await db.select({
routeId: schema.routeChannels.routeId,
enabled: schema.routeChannels.enabled,
siteName: schema.sites.name,
}).from(schema.routeChannels)
.innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id)) .innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.leftJoin(schema.accountTokens, eq(schema.routeChannels.tokenId, schema.accountTokens.id))
.where(inArray(schema.routeChannels.routeId, routeIds)) .where(inArray(schema.routeChannels.routeId, routeIds))
.all(); .all();
const channelsByRoute = new Map<number, any[]>(); const aggByRoute = new Map<number, { channelCount: number; enabledChannelCount: number; siteNames: Set<string> }>();
for (const row of channelRows) { for (const row of channelRows) {
const routeId = row.route_channels.routeId; let agg = aggByRoute.get(row.routeId);
if (!channelsByRoute.has(routeId)) channelsByRoute.set(routeId, []); if (!agg) {
channelsByRoute.get(routeId)!.push({ agg = { channelCount: 0, enabledChannelCount: 0, siteNames: new Set() };
...row.route_channels, aggByRoute.set(row.routeId, agg);
account: row.accounts, }
site: row.sites, agg.channelCount += 1;
token: row.account_tokens if (row.enabled) agg.enabledChannelCount += 1;
? { if (row.siteName) agg.siteNames.add(row.siteName);
id: row.account_tokens.id,
name: row.account_tokens.name,
accountId: row.account_tokens.accountId,
enabled: row.account_tokens.enabled,
isDefault: row.account_tokens.isDefault,
}
: null,
});
} }
return routes.map((route) => ({ return routes.map((route) => {
...route, const agg = aggByRoute.get(route.id);
decisionSnapshot: parseRouteDecisionSnapshot(route.decisionSnapshot), return {
decisionRefreshedAt: route.decisionRefreshedAt ?? null, id: route.id,
channels: channelsByRoute.get(route.id) || [], modelPattern: route.modelPattern,
})); displayName: route.displayName ?? null,
}); displayIcon: route.displayIcon ?? null,
modelMapping: route.modelMapping ?? null,
enabled: route.enabled,
channelCount: agg?.channelCount ?? 0,
enabledChannelCount: agg?.enabledChannelCount ?? 0,
siteNames: agg ? Array.from(agg.siteNames) : [],
decisionSnapshot: parseRouteDecisionSnapshot(route.decisionSnapshot),
decisionRefreshedAt: route.decisionRefreshedAt ?? null,
};
});
});
// Get channels for a single route (on-demand loading)
app.get<{ Params: { id: string } }>('/api/routes/:id/channels', async (request, reply) => {
const routeId = parseInt(request.params.id, 10);
const route = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get();
if (!route) {
return reply.code(404).send({ success: false, message: '路由不存在' });
}
const channelsByRoute = await fetchChannelsForRoutes([routeId]);
return channelsByRoute.get(routeId) || [];
});
// Batch add channels to a route
app.post<{ Params: { id: string }; Body: { channels: Array<{ accountId: number; tokenId?: number; sourceModel?: string }> } }>('/api/routes/:id/channels/batch', async (request, reply) => {
const routeId = parseInt(request.params.id, 10);
const body = request.body;
const route = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get();
if (!route) {
return reply.code(404).send({ success: false, message: '路由不存在' });
}
if (!body?.channels || !Array.isArray(body.channels) || body.channels.length === 0) {
return reply.code(400).send({ success: false, message: 'channels 必须是非空数组' });
}
const existingChannels = await db.select().from(schema.routeChannels)
.where(eq(schema.routeChannels.routeId, routeId))
.all();
const existingPairs = new Set<string>(
existingChannels.map((channel) => {
const tokenId = typeof channel.tokenId === 'number' && Number.isFinite(channel.tokenId) ? channel.tokenId : 0;
const sourceModel = (channel.sourceModel || '').trim().toLowerCase();
return `${channel.accountId}::${tokenId}::${sourceModel}`;
}),
);
let created = 0;
let skipped = 0;
const errors: string[] = [];
for (const item of body.channels) {
if (!item?.accountId || typeof item.accountId !== 'number') {
errors.push('无效的 accountId');
continue;
}
const sourceModel = typeof item.sourceModel === 'string'
? item.sourceModel.trim()
: (isExactModelPattern(route.modelPattern) ? route.modelPattern.trim() : '');
const effectiveTokenId = item.tokenId ?? await getDefaultTokenId(item.accountId);
if (item.tokenId && !await checkTokenBelongsToAccount(item.tokenId, item.accountId)) {
errors.push(`令牌 ${item.tokenId} 不属于账号 ${item.accountId}`);
continue;
}
const tokenIdForKey = typeof effectiveTokenId === 'number' && Number.isFinite(effectiveTokenId) ? effectiveTokenId : 0;
const pairKey = `${item.accountId}::${tokenIdForKey}::${sourceModel.toLowerCase()}`;
if (existingPairs.has(pairKey)) {
skipped += 1;
continue;
}
try {
await db.insert(schema.routeChannels).values({
routeId,
accountId: item.accountId,
tokenId: effectiveTokenId,
sourceModel: sourceModel || null,
priority: 0,
weight: 10,
manualOverride: true,
}).run();
existingPairs.add(pairKey);
created += 1;
} catch (e: any) {
errors.push(e.message || `添加通道失败: accountId=${item.accountId}`);
}
}
if (created > 0) {
await clearRouteDecisionSnapshot(routeId);
invalidateTokenRouterCache();
}
return { success: true, created, skipped, errors };
});
// List all routes
app.get('/api/routes', async () => {
const routes = await db.select().from(schema.tokenRoutes).all();
if (routes.length === 0) return [];
const routeIds = routes.map((route) => route.id);
const channelsByRoute = await fetchChannelsForRoutes(routeIds);
return routes.map((route) => ({
...route,
decisionSnapshot: parseRouteDecisionSnapshot(route.decisionSnapshot),
decisionRefreshedAt: route.decisionRefreshedAt ?? null,
channels: channelsByRoute.get(route.id) || [],
}));
});
app.get<{ Querystring: { model?: string } }>('/api/routes/decision', async (request, reply) => { app.get<{ Querystring: { model?: string } }>('/api/routes/decision', async (request, reply) => {
const model = (request.query.model || '').trim(); const model = (request.query.model || '').trim();
@@ -427,157 +582,157 @@ export async function tokensRoutes(app: FastifyInstance) {
return reply.code(400).send({ success: false, message: 'model 不能为空' }); return reply.code(400).send({ success: false, message: 'model 不能为空' });
} }
const decision = await tokenRouter.explainSelection(model); const decision = await tokenRouter.explainSelection(model);
return { success: true, decision }; return { success: true, decision };
}); });
app.post<{ Body: BatchRouteDecisionModels }>('/api/routes/decision/batch', async (request, reply) => { app.post<{ Body: BatchRouteDecisionModels }>('/api/routes/decision/batch', async (request, reply) => {
const parsed = parseBatchRouteDecisionModels(request.body); const parsed = parseBatchRouteDecisionModels(request.body);
if (!parsed.ok) { if (!parsed.ok) {
return reply.code(400).send({ success: false, message: parsed.message }); return reply.code(400).send({ success: false, message: parsed.message });
} }
const decisions: Record<string, Awaited<ReturnType<typeof tokenRouter.explainSelection>>> = {};
const routes = parsed.persistSnapshots
? await db.select({
id: schema.tokenRoutes.id,
modelPattern: schema.tokenRoutes.modelPattern,
}).from(schema.tokenRoutes).all()
: [];
const refreshedKeys = parsed.refreshPricingCatalog ? new Set<string>() : undefined;
for (const model of parsed.models) {
if (parsed.refreshPricingCatalog) {
await tokenRouter.refreshPricingReferenceCosts(model, { refreshedKeys });
}
decisions[model] = await tokenRouter.explainSelection(model);
}
if (parsed.persistSnapshots) {
const snapshotWrites: Array<{ routeId: number; snapshot: unknown }> = [];
for (const model of parsed.models) {
const decision = decisions[model];
for (const route of routes) {
if (!isExactModelPattern(route.modelPattern)) continue;
if (!matchesModelPattern(model, route.modelPattern)) continue;
snapshotWrites.push({ routeId: route.id, snapshot: decision });
}
}
await saveRouteDecisionSnapshots(snapshotWrites);
}
return { success: true, decisions };
});
app.post<{ Body: BatchRouteDecisionRouteModels }>('/api/routes/decision/by-route/batch', async (request, reply) => { const decisions: Record<string, Awaited<ReturnType<typeof tokenRouter.explainSelection>>> = {};
const parsed = parseBatchRouteDecisionRouteModels(request.body); const routes = parsed.persistSnapshots
if (!parsed.ok) { ? await db.select({
return reply.code(400).send({ success: false, message: parsed.message }); id: schema.tokenRoutes.id,
} modelPattern: schema.tokenRoutes.modelPattern,
}).from(schema.tokenRoutes).all()
const decisions: Record<string, Record<string, Awaited<ReturnType<typeof tokenRouter.explainSelectionForRoute>>>> = {}; : [];
const refreshedKeys = parsed.refreshPricingCatalog ? new Set<string>() : undefined; const refreshedKeys = parsed.refreshPricingCatalog ? new Set<string>() : undefined;
for (const item of parsed.items) { for (const model of parsed.models) {
const routeKey = String(item.routeId); if (parsed.refreshPricingCatalog) {
if (!decisions[routeKey]) decisions[routeKey] = {}; await tokenRouter.refreshPricingReferenceCosts(model, { refreshedKeys });
if (parsed.refreshPricingCatalog) { }
await tokenRouter.refreshPricingReferenceCostsForRoute(item.routeId, item.model, { refreshedKeys }); decisions[model] = await tokenRouter.explainSelection(model);
} }
decisions[routeKey][item.model] = await tokenRouter.explainSelectionForRoute(item.routeId, item.model);
}
if (parsed.persistSnapshots) {
await saveRouteDecisionSnapshots(parsed.items.map((item) => ({
routeId: item.routeId,
snapshot: decisions[String(item.routeId)]?.[item.model] ?? null,
})));
}
return { success: true, decisions };
});
app.post<{ Body: BatchRouteWideDecisionRouteIds }>('/api/routes/decision/route-wide/batch', async (request, reply) => { if (parsed.persistSnapshots) {
const parsed = parseBatchRouteWideDecisionRouteIds(request.body); const snapshotWrites: Array<{ routeId: number; snapshot: unknown }> = [];
if (!parsed.ok) { for (const model of parsed.models) {
return reply.code(400).send({ success: false, message: parsed.message }); const decision = decisions[model];
} for (const route of routes) {
if (!isExactModelPattern(route.modelPattern)) continue;
const decisions: Record<string, Awaited<ReturnType<typeof tokenRouter.explainSelectionRouteWide>>> = {}; if (!matchesModelPattern(model, route.modelPattern)) continue;
const refreshedKeys = parsed.refreshPricingCatalog ? new Set<string>() : undefined; snapshotWrites.push({ routeId: route.id, snapshot: decision });
for (const routeId of parsed.routeIds) { }
if (parsed.refreshPricingCatalog) { }
await tokenRouter.refreshRouteWidePricingReferenceCosts(routeId, { refreshedKeys }); await saveRouteDecisionSnapshots(snapshotWrites);
} }
decisions[String(routeId)] = await tokenRouter.explainSelectionRouteWide(routeId);
} return { success: true, decisions };
});
if (parsed.persistSnapshots) {
await saveRouteDecisionSnapshots(parsed.routeIds.map((routeId) => ({ app.post<{ Body: BatchRouteDecisionRouteModels }>('/api/routes/decision/by-route/batch', async (request, reply) => {
routeId, const parsed = parseBatchRouteDecisionRouteModels(request.body);
snapshot: decisions[String(routeId)] ?? null, if (!parsed.ok) {
}))); return reply.code(400).send({ success: false, message: parsed.message });
} }
return { success: true, decisions }; const decisions: Record<string, Record<string, Awaited<ReturnType<typeof tokenRouter.explainSelectionForRoute>>>> = {};
}); const refreshedKeys = parsed.refreshPricingCatalog ? new Set<string>() : undefined;
for (const item of parsed.items) {
const routeKey = String(item.routeId);
if (!decisions[routeKey]) decisions[routeKey] = {};
if (parsed.refreshPricingCatalog) {
await tokenRouter.refreshPricingReferenceCostsForRoute(item.routeId, item.model, { refreshedKeys });
}
decisions[routeKey][item.model] = await tokenRouter.explainSelectionForRoute(item.routeId, item.model);
}
if (parsed.persistSnapshots) {
await saveRouteDecisionSnapshots(parsed.items.map((item) => ({
routeId: item.routeId,
snapshot: decisions[String(item.routeId)]?.[item.model] ?? null,
})));
}
return { success: true, decisions };
});
app.post<{ Body: BatchRouteWideDecisionRouteIds }>('/api/routes/decision/route-wide/batch', async (request, reply) => {
const parsed = parseBatchRouteWideDecisionRouteIds(request.body);
if (!parsed.ok) {
return reply.code(400).send({ success: false, message: parsed.message });
}
const decisions: Record<string, Awaited<ReturnType<typeof tokenRouter.explainSelectionRouteWide>>> = {};
const refreshedKeys = parsed.refreshPricingCatalog ? new Set<string>() : undefined;
for (const routeId of parsed.routeIds) {
if (parsed.refreshPricingCatalog) {
await tokenRouter.refreshRouteWidePricingReferenceCosts(routeId, { refreshedKeys });
}
decisions[String(routeId)] = await tokenRouter.explainSelectionRouteWide(routeId);
}
if (parsed.persistSnapshots) {
await saveRouteDecisionSnapshots(parsed.routeIds.map((routeId) => ({
routeId,
snapshot: decisions[String(routeId)] ?? null,
})));
}
return { success: true, decisions };
});
// Create a route // Create a route
app.post<{ Body: { modelPattern: string; displayName?: string; displayIcon?: string; modelMapping?: string; enabled?: boolean } }>('/api/routes', async (request) => { app.post<{ Body: { modelPattern: string; displayName?: string; displayIcon?: string; modelMapping?: string; enabled?: boolean } }>('/api/routes', async (request) => {
const body = request.body; const body = request.body;
const insertedRoute = await db.insert(schema.tokenRoutes).values({ const insertedRoute = await db.insert(schema.tokenRoutes).values({
modelPattern: body.modelPattern, modelPattern: body.modelPattern,
displayName: body.displayName, displayName: body.displayName,
displayIcon: body.displayIcon, displayIcon: body.displayIcon,
modelMapping: body.modelMapping, modelMapping: body.modelMapping,
enabled: body.enabled ?? true, enabled: body.enabled ?? true,
}).run(); }).run();
const routeId = Number(insertedRoute.lastInsertRowid || 0); const routeId = Number(insertedRoute.lastInsertRowid || 0);
if (routeId <= 0) { if (routeId <= 0) {
return { success: false, message: '创建路由失败' }; return { success: false, message: '创建路由失败' };
} }
const route = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get(); const route = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get();
if (!route) { if (!route) {
return { success: false, message: '创建路由失败' }; return { success: false, message: '创建路由失败' };
} }
await populateRouteChannelsByModelPattern(route.id, body.modelPattern); await populateRouteChannelsByModelPattern(route.id, body.modelPattern);
invalidateTokenRouterCache(); invalidateTokenRouterCache();
return route; return route;
}); });
// Update a route // Update a route
app.put<{ Params: { id: string }; Body: any }>('/api/routes/:id', async (request, reply) => { app.put<{ Params: { id: string }; Body: any }>('/api/routes/:id', async (request, reply) => {
const id = parseInt(request.params.id, 10); const id = parseInt(request.params.id, 10);
const body = request.body as Record<string, unknown>; const body = request.body as Record<string, unknown>;
const existingRoute = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).get(); const existingRoute = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).get();
if (!existingRoute) { if (!existingRoute) {
return reply.code(404).send({ success: false, message: '路由不存在' }); return reply.code(404).send({ success: false, message: '路由不存在' });
} }
const updates: Record<string, unknown> = {}; const updates: Record<string, unknown> = {};
let nextModelPattern = existingRoute.modelPattern; let nextModelPattern = existingRoute.modelPattern;
if (body.displayName !== undefined) updates.displayName = body.displayName; if (body.displayName !== undefined) updates.displayName = body.displayName;
if (body.displayIcon !== undefined) updates.displayIcon = body.displayIcon; if (body.displayIcon !== undefined) updates.displayIcon = body.displayIcon;
if (body.modelPattern !== undefined) { if (body.modelPattern !== undefined) {
nextModelPattern = String(body.modelPattern); nextModelPattern = String(body.modelPattern);
updates.modelPattern = nextModelPattern; updates.modelPattern = nextModelPattern;
} }
if (body.modelMapping !== undefined) updates.modelMapping = body.modelMapping; if (body.modelMapping !== undefined) updates.modelMapping = body.modelMapping;
if (body.enabled !== undefined) updates.enabled = body.enabled; if (body.enabled !== undefined) updates.enabled = body.enabled;
updates.updatedAt = new Date().toISOString(); updates.updatedAt = new Date().toISOString();
await db.update(schema.tokenRoutes).set(updates).where(eq(schema.tokenRoutes.id, id)).run(); await db.update(schema.tokenRoutes).set(updates).where(eq(schema.tokenRoutes.id, id)).run();
const modelPatternChanged = body.modelPattern !== undefined && nextModelPattern !== existingRoute.modelPattern; const modelPatternChanged = body.modelPattern !== undefined && nextModelPattern !== existingRoute.modelPattern;
const routeBehaviorChanged = modelPatternChanged || body.modelMapping !== undefined || body.enabled !== undefined; const routeBehaviorChanged = modelPatternChanged || body.modelMapping !== undefined || body.enabled !== undefined;
if (modelPatternChanged) { if (modelPatternChanged) {
await rebuildAutomaticRouteChannelsByModelPattern(id, nextModelPattern); await rebuildAutomaticRouteChannelsByModelPattern(id, nextModelPattern);
} }
if (routeBehaviorChanged) { if (routeBehaviorChanged) {
await clearRouteDecisionSnapshot(id); await clearRouteDecisionSnapshot(id);
} }
invalidateTokenRouterCache(); invalidateTokenRouterCache();
return await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).get(); return await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).get();
}); });
// Delete a route // Delete a route
app.delete<{ Params: { id: string } }>('/api/routes/:id', async (request) => { app.delete<{ Params: { id: string } }>('/api/routes/:id', async (request) => {
@@ -600,13 +755,13 @@ export async function tokensRoutes(app: FastifyInstance) {
const sourceModel = typeof body.sourceModel === 'string' const sourceModel = typeof body.sourceModel === 'string'
? body.sourceModel.trim() ? body.sourceModel.trim()
: (isExactModelPattern(route.modelPattern) ? route.modelPattern.trim() : ''); : (isExactModelPattern(route.modelPattern) ? route.modelPattern.trim() : '');
const effectiveTokenId = body.tokenId ?? await getDefaultTokenId(body.accountId); const effectiveTokenId = body.tokenId ?? await getDefaultTokenId(body.accountId);
if (body.tokenId && !await checkTokenBelongsToAccount(body.tokenId, body.accountId)) { if (body.tokenId && !await checkTokenBelongsToAccount(body.tokenId, body.accountId)) {
return reply.code(400).send({ success: false, message: '令牌不存在或不属于当前账号' }); return reply.code(400).send({ success: false, message: '令牌不存在或不属于当前账号' });
} }
if (isExactModelPattern(route.modelPattern) && effectiveTokenId && !await tokenSupportsModel(effectiveTokenId, route.modelPattern)) { if (isExactModelPattern(route.modelPattern) && effectiveTokenId && !await tokenSupportsModel(effectiveTokenId, route.modelPattern)) {
return reply.code(400).send({ success: false, message: '该令牌不支持当前模型' }); return reply.code(400).send({ success: false, message: '该令牌不支持当前模型' });
} }
@@ -622,26 +777,26 @@ export async function tokensRoutes(app: FastifyInstance) {
return reply.code(400).send({ success: false, message: '该来源模型的通道已存在' }); return reply.code(400).send({ success: false, message: '该来源模型的通道已存在' });
} }
const insertedChannel = await db.insert(schema.routeChannels).values({ const insertedChannel = await db.insert(schema.routeChannels).values({
routeId, routeId,
accountId: body.accountId, accountId: body.accountId,
tokenId: body.tokenId, tokenId: body.tokenId,
sourceModel: sourceModel || null, sourceModel: sourceModel || null,
priority: body.priority ?? 0, priority: body.priority ?? 0,
weight: body.weight ?? 10, weight: body.weight ?? 10,
}).run(); }).run();
const channelId = Number(insertedChannel.lastInsertRowid || 0); const channelId = Number(insertedChannel.lastInsertRowid || 0);
if (channelId <= 0) { if (channelId <= 0) {
return reply.code(500).send({ success: false, message: '创建通道失败' }); return reply.code(500).send({ success: false, message: '创建通道失败' });
} }
const created = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get(); const created = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
if (!created) { if (!created) {
return reply.code(500).send({ success: false, message: '创建通道失败' }); return reply.code(500).send({ success: false, message: '创建通道失败' });
} }
await clearRouteDecisionSnapshot(routeId); await clearRouteDecisionSnapshot(routeId);
invalidateTokenRouterCache(); invalidateTokenRouterCache();
return created; return created;
}); });
// Batch update channel priorities // Batch update channel priorities
app.put<{ Body: { updates: Array<{ id: number; priority: number }> } }>('/api/channels/batch', async (request, reply) => { app.put<{ Body: { updates: Array<{ id: number; priority: number }> } }>('/api/channels/batch', async (request, reply) => {
@@ -667,13 +822,13 @@ export async function tokensRoutes(app: FastifyInstance) {
}).where(eq(schema.routeChannels.id, update.id)).run(); }).where(eq(schema.routeChannels.id, update.id)).run();
} }
const updatedChannels = await db.select().from(schema.routeChannels) const updatedChannels = await db.select().from(schema.routeChannels)
.where(inArray(schema.routeChannels.id, channelIds)) .where(inArray(schema.routeChannels.id, channelIds))
.all(); .all();
await clearRouteDecisionSnapshots(existingChannels.map((channel) => channel.routeId)); await clearRouteDecisionSnapshots(existingChannels.map((channel) => channel.routeId));
invalidateTokenRouterCache(); invalidateTokenRouterCache();
return { success: true, channels: updatedChannels }; return { success: true, channels: updatedChannels };
}); });
// Update a channel // Update a channel
app.put<{ Params: { channelId: string }; Body: any }>('/api/channels/:channelId', async (request, reply) => { app.put<{ Params: { channelId: string }; Body: any }>('/api/channels/:channelId', async (request, reply) => {
@@ -692,16 +847,16 @@ export async function tokensRoutes(app: FastifyInstance) {
if (body.tokenId !== undefined && body.tokenId !== null) { if (body.tokenId !== undefined && body.tokenId !== null) {
const tokenId = Number(body.tokenId); const tokenId = Number(body.tokenId);
if (!Number.isFinite(tokenId) || !await checkTokenBelongsToAccount(tokenId, channel.accountId)) { if (!Number.isFinite(tokenId) || !await checkTokenBelongsToAccount(tokenId, channel.accountId)) {
return reply.code(400).send({ success: false, message: '令牌不存在或不属于通道账号' }); return reply.code(400).send({ success: false, message: '令牌不存在或不属于通道账号' });
} }
} }
const nextTokenId = body.tokenId === undefined const nextTokenId = body.tokenId === undefined
? (channel.tokenId ?? await getDefaultTokenId(channel.accountId)) ? (channel.tokenId ?? await getDefaultTokenId(channel.accountId))
: (body.tokenId === null ? await getDefaultTokenId(channel.accountId) : Number(body.tokenId)); : (body.tokenId === null ? await getDefaultTokenId(channel.accountId) : Number(body.tokenId));
if (isExactModelPattern(route.modelPattern) && nextTokenId && !await tokenSupportsModel(nextTokenId, route.modelPattern)) { if (isExactModelPattern(route.modelPattern) && nextTokenId && !await tokenSupportsModel(nextTokenId, route.modelPattern)) {
return reply.code(400).send({ success: false, message: '该令牌不支持当前模型' }); return reply.code(400).send({ success: false, message: '该令牌不支持当前模型' });
} }
@@ -714,24 +869,24 @@ export async function tokensRoutes(app: FastifyInstance) {
for (const key of ['priority', 'weight', 'enabled', 'tokenId']) { for (const key of ['priority', 'weight', 'enabled', 'tokenId']) {
if (body[key] !== undefined) updates[key] = body[key]; if (body[key] !== undefined) updates[key] = body[key];
} }
await db.update(schema.routeChannels).set(updates).where(eq(schema.routeChannels.id, channelId)).run(); await db.update(schema.routeChannels).set(updates).where(eq(schema.routeChannels.id, channelId)).run();
await clearRouteDecisionSnapshot(channel.routeId); await clearRouteDecisionSnapshot(channel.routeId);
invalidateTokenRouterCache(); invalidateTokenRouterCache();
return await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get(); return await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
}); });
// Delete a channel // Delete a channel
app.delete<{ Params: { channelId: string } }>('/api/channels/:channelId', async (request) => { app.delete<{ Params: { channelId: string } }>('/api/channels/:channelId', async (request) => {
const channelId = parseInt(request.params.channelId, 10); const channelId = parseInt(request.params.channelId, 10);
const channel = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get(); const channel = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
await db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).run(); await db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).run();
if (channel) { if (channel) {
await clearRouteDecisionSnapshot(channel.routeId); await clearRouteDecisionSnapshot(channel.routeId);
} }
invalidateTokenRouterCache(); invalidateTokenRouterCache();
return { success: true }; return { success: true };
}); });
// Rebuild routes/channels from model availability. // Rebuild routes/channels from model availability.
app.post<{ Body?: { refreshModels?: boolean; wait?: boolean } }>('/api/routes/rebuild', async (request, reply) => { app.post<{ Body?: { refreshModels?: boolean; wait?: boolean } }>('/api/routes/rebuild', async (request, reply) => {
+5
View File
@@ -273,6 +273,11 @@ export const api = {
// Routes // Routes
getRoutes: () => request('/api/routes'), getRoutes: () => request('/api/routes'),
getRoutesLite: () => request('/api/routes/lite'),
getRoutesSummary: () => request('/api/routes/summary'),
getRouteChannels: (routeId: number) => request(`/api/routes/${routeId}/channels`),
batchAddChannels: (routeId: number, channels: Array<{ accountId: number; tokenId?: number; sourceModel?: string }>) =>
request(`/api/routes/${routeId}/channels/batch`, { method: 'POST', body: JSON.stringify({ channels }) }),
addRoute: (data: any) => request('/api/routes', { method: 'POST', body: JSON.stringify(data) }), addRoute: (data: any) => request('/api/routes', { method: 'POST', body: JSON.stringify(data) }),
updateRoute: (id: number, data: any) => request(`/api/routes/${id}`, { method: 'PUT', body: JSON.stringify(data) }), updateRoute: (id: number, data: any) => request(`/api/routes/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteRoute: (id: number) => request(`/api/routes/${id}`, { method: 'DELETE' }), deleteRoute: (id: number) => request(`/api/routes/${id}`, { method: 'DELETE' }),
+159
View File
@@ -4066,3 +4066,162 @@ textarea {
display: none; display: none;
} }
} }
/* ===== Route Filter Bar (Collapsible, horizontal chips) ===== */
.route-filter-bar {
margin-bottom: 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
}
.route-filter-bar-summary {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
background: none;
border: none;
cursor: pointer;
font-size: 13px;
color: var(--color-text-secondary);
text-align: left;
border-radius: var(--radius-sm);
transition: background 0.15s;
}
.route-filter-bar-summary:hover {
background: var(--color-bg);
}
.route-filter-bar-expanded {
padding: 8px 12px 12px;
border-top: 1px solid var(--color-border-light);
display: flex;
flex-direction: column;
gap: 8px;
animation: filter-bar-slide-down 0.22s cubic-bezier(0.22, 1, 0.36, 1);
}
.route-filter-bar-expanded.is-closing {
animation: filter-bar-slide-up 0.18s ease forwards;
}
@keyframes filter-bar-slide-down {
from { opacity: 0; max-height: 0; transform: translateY(-4px); }
to { opacity: 1; max-height: 600px; transform: translateY(0); }
}
@keyframes filter-bar-slide-up {
from { opacity: 1; max-height: 600px; }
to { opacity: 0; max-height: 0; overflow: hidden; }
}
.route-filter-row {
display: flex;
align-items: flex-start;
gap: 8px;
}
.route-filter-row-label {
min-width: 40px;
font-size: 12px;
font-weight: 600;
color: var(--color-text-secondary);
padding-top: 5px;
flex-shrink: 0;
}
.route-filter-row-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
flex: 1;
min-width: 0;
}
/* Filter chip (pill button) */
.filter-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 999px;
border: 1px solid var(--color-border);
background: transparent;
cursor: pointer;
font-size: 12px;
color: var(--color-text-secondary);
white-space: nowrap;
transition: all 0.15s ease;
line-height: 1.3;
}
.filter-chip:hover {
background: var(--color-bg);
border-color: var(--color-primary);
color: var(--color-primary);
}
.filter-chip.active {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
border-color: var(--color-primary);
color: var(--color-primary);
font-weight: 500;
}
.filter-chip-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
flex-shrink: 0;
}
.filter-chip-label {
overflow: hidden;
text-overflow: ellipsis;
max-width: 120px;
}
.filter-chip-count {
font-size: 10px;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.filter-chip.active .filter-chip-count {
color: var(--color-primary);
}
/* ===== Route Card Grid (multi-column) ===== */
.route-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
gap: 12px;
align-items: start;
}
/* Collapsed route card */
.route-card-collapsed {
padding: 10px 14px;
transition: box-shadow 0.15s ease, border-color 0.15s ease;
}
.route-card-collapsed:hover {
border-color: var(--color-primary);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 20%, transparent);
}
/* Expanded route card spans full width */
.route-card-expanded {
grid-column: 1 / -1;
}
@media (max-width: 768px) {
.route-card-grid {
grid-template-columns: 1fr;
}
}
+2 -2
View File
@@ -352,7 +352,7 @@ export default function Settings() {
const loadRouteSelectorRoutes = async () => { const loadRouteSelectorRoutes = async () => {
setSelectorLoading(true); setSelectorLoading(true);
try { try {
const rows = await api.getRoutes(); const rows = await api.getRoutesLite();
setSelectorRoutes((Array.isArray(rows) ? rows : []).map((row: any) => ({ setSelectorRoutes((Array.isArray(rows) ? rows : []).map((row: any) => ({
id: row.id, id: row.id,
modelPattern: row.modelPattern, modelPattern: row.modelPattern,
@@ -374,7 +374,7 @@ export default function Settings() {
api.getAuthInfo(), api.getAuthInfo(),
api.getRuntimeSettings(), api.getRuntimeSettings(),
api.getDownstreamApiKeys(), api.getDownstreamApiKeys(),
api.getRoutes(), api.getRoutesLite(),
api.getRuntimeDatabaseConfig(), api.getRuntimeDatabaseConfig(),
]); ]);
setMaskedToken(authInfo.masked || '****'); setMaskedToken(authInfo.masked || '****');
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,288 @@
import { useState, useMemo } from 'react';
import CenteredModal from '../../components/CenteredModal.js';
import ModernSelect from '../../components/ModernSelect.js';
import { api } from '../../api.js';
import { useToast } from '../../components/Toast.js';
import { tr } from '../../i18n.js';
import type { RouteCandidateView, RouteAccountOption, RouteTokenOption } from '../helpers/routeModelCandidatesIndex.js';
import type { RouteMissingTokenHint } from '../helpers/routeMissingTokenHints.js';
type ChannelSelection = {
accountId: number;
tokenId?: number;
sourceModel?: string;
};
type AddChannelModalProps = {
open: boolean;
onClose: () => void;
routeId: number;
routeTitle: string;
candidateView: RouteCandidateView;
onSuccess: () => void;
missingTokenHints?: RouteMissingTokenHint[];
onCreateTokenForMissing?: (accountId: number, modelName: string) => void;
existingChannelAccountIds?: Set<number>;
};
export default function AddChannelModal({
open,
onClose,
routeId,
routeTitle,
candidateView,
onSuccess,
missingTokenHints,
onCreateTokenForMissing,
existingChannelAccountIds,
}: AddChannelModalProps) {
const toast = useToast();
const [searchQuery, setSearchQuery] = useState('');
const [selectedAccounts, setSelectedAccounts] = useState<Record<number, ChannelSelection>>({});
const [submitting, setSubmitting] = useState(false);
const filteredAccounts = useMemo(() => {
const q = searchQuery.trim().toLowerCase();
if (!q) return candidateView.accountOptions;
return candidateView.accountOptions.filter((option) =>
option.label.toLowerCase().includes(q),
);
}, [candidateView.accountOptions, searchQuery]);
const missingAccounts = useMemo(() => {
if (!missingTokenHints || missingTokenHints.length === 0) return [];
const seen = new Map<number, { accountId: number; label: string; modelName: string }>();
for (const hint of missingTokenHints) {
for (const account of hint.accounts) {
if (!seen.has(account.accountId)) {
const label = `${account.username || `account-${account.accountId}`} @ ${account.siteName}`;
seen.set(account.accountId, { accountId: account.accountId, label, modelName: hint.modelName });
}
}
}
return Array.from(seen.values());
}, [missingTokenHints]);
const filteredMissingAccounts = useMemo(() => {
const q = searchQuery.trim().toLowerCase();
if (!q) return missingAccounts;
return missingAccounts.filter((item) => item.label.toLowerCase().includes(q));
}, [missingAccounts, searchQuery]);
const selectedCount = Object.keys(selectedAccounts).length;
const toggleAccount = (account: RouteAccountOption) => {
setSelectedAccounts((prev) => {
if (prev[account.id]) {
const next = { ...prev };
delete next[account.id];
return next;
}
const tokens = candidateView.tokenOptionsByAccountId[account.id] || [];
const defaultToken = tokens.find((t) => t.isDefault) || tokens[0] || null;
return {
...prev,
[account.id]: {
accountId: account.id,
tokenId: defaultToken?.id,
sourceModel: defaultToken?.sourceModel,
},
};
});
};
const updateTokenForAccount = (accountId: number, tokenId: number, sourceModel: string) => {
setSelectedAccounts((prev) => {
if (!prev[accountId]) return prev;
return {
...prev,
[accountId]: {
...prev[accountId],
tokenId: tokenId || undefined,
sourceModel: sourceModel || undefined,
},
};
});
};
const handleSubmit = async () => {
const channels = Object.values(selectedAccounts);
if (channels.length === 0) return;
setSubmitting(true);
try {
const result = await api.batchAddChannels(routeId, channels);
const msg = `已添加 ${result.created} 个通道` +
(result.skipped > 0 ? `,跳过 ${result.skipped} 个重复` : '') +
(result.errors.length > 0 ? `,${result.errors.length} 个错误` : '');
toast.success(msg);
setSelectedAccounts({});
setSearchQuery('');
onSuccess();
onClose();
} catch (e: any) {
toast.error(e.message || '批量添加通道失败');
} finally {
setSubmitting(false);
}
};
const handleClose = () => {
if (!submitting) {
setSelectedAccounts({});
setSearchQuery('');
onClose();
}
};
return (
<CenteredModal
open={open}
onClose={handleClose}
title={`${tr('添加通道')} - ${routeTitle}`}
maxWidth={560}
footer={
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{tr('已选')} {selectedCount} {tr('个通道')}
</span>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-ghost" onClick={handleClose} disabled={submitting}>
{tr('取消')}
</button>
<button
className="btn btn-primary"
onClick={handleSubmit}
disabled={submitting || selectedCount === 0}
>
{submitting ? (
<><span className="spinner spinner-sm" /> {tr('添加中...')}</>
) : (
`${tr('批量添加')} (${selectedCount})`
)}
</button>
</div>
</div>
}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div className="toolbar-search" style={{ width: '100%' }}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={tr('搜索账号...')}
/>
</div>
<div style={{ maxHeight: 360, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 4 }}>
{filteredAccounts.length === 0 && filteredMissingAccounts.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--color-text-muted)', padding: '12px 0', textAlign: 'center' }}>
{candidateView.accountOptions.length === 0 && missingAccounts.length === 0
? tr('当前没有可用的账号,请确认已有账号的令牌支持调用此模型')
: tr('没有匹配的账号')}
</div>
) : (
<>
{filteredAccounts.map((account) => {
const isSelected = !!selectedAccounts[account.id];
const tokens = candidateView.tokenOptionsByAccountId[account.id] || [];
const selection = selectedAccounts[account.id];
const isExisting = existingChannelAccountIds?.has(account.id);
return (
<div
key={account.id}
onClick={() => toggleAccount(account)}
style={{
padding: '8px 10px',
borderRadius: 'var(--radius-sm)',
border: `1px solid ${isSelected ? 'var(--color-primary)' : 'var(--color-border)'}`,
background: isSelected ? 'color-mix(in srgb, var(--color-primary) 6%, transparent)' : 'transparent',
cursor: 'pointer',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="checkbox"
checked={isSelected}
readOnly
style={{ cursor: 'pointer', pointerEvents: 'none' }}
/>
<span style={{ fontSize: 13, fontWeight: 500 }}>{account.label}</span>
{isExisting && (
<span className="badge badge-muted" style={{ fontSize: 10 }}>{tr('已添加')}</span>
)}
</div>
{isSelected && tokens.length > 0 && (
<div style={{ marginTop: 6, paddingLeft: 24 }} onClick={(e) => e.stopPropagation()}>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 4 }}>{tr('令牌')}:</div>
<ModernSelect
size="sm"
value={(() => {
if (!selection?.tokenId) return '0';
return `${selection.tokenId}::${selection.sourceModel || ''}`;
})()}
onChange={(nextValue) => {
if (nextValue === '0') {
updateTokenForAccount(account.id, 0, '');
return;
}
const [tokenRaw, ...sourceParts] = nextValue.split('::');
updateTokenForAccount(account.id, Number.parseInt(tokenRaw, 10) || 0, sourceParts.join('::'));
}}
options={[
{ value: '0', label: tr('默认令牌') },
...tokens.map((token: RouteTokenOption) => ({
value: `${token.id}::${token.sourceModel || ''}`,
label: `${token.name}${token.isDefault ? '(默认)' : ''}${token.sourceModel ? ` [${token.sourceModel}]` : ''}`,
})),
]}
placeholder={tr('选择令牌')}
/>
</div>
)}
</div>
);
})}
{/* Missing token hints */}
{filteredMissingAccounts.length > 0 && (
<div style={{ borderTop: '1px dashed var(--color-border)', paddingTop: 8, marginTop: 4, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 2 }}>
{tr('以下账号可用此模型但缺少令牌')}:
</div>
{filteredMissingAccounts.map((item) => (
<div
key={item.accountId}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '6px 10px', borderRadius: 'var(--radius-sm)',
border: '1px dashed var(--color-border)', background: 'var(--color-bg)',
}}
>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{item.label}</span>
{onCreateTokenForMissing && (
<button
type="button"
className="btn btn-link"
style={{ fontSize: 11, padding: '2px 6px' }}
onClick={() => onCreateTokenForMissing(item.accountId, item.modelName)}
>
{tr('创建令牌')}
</button>
)}
</div>
))}
</div>
)}
</>
)}
</div>
</div>
</CenteredModal>
);
}
@@ -0,0 +1,196 @@
import { useMemo } from 'react';
import { BrandGlyph } from '../../components/BrandIcon.js';
import ModernSelect from '../../components/ModernSelect.js';
import { useAnimatedVisibility } from '../../components/useAnimatedVisibility.js';
import { tr } from '../../i18n.js';
import type { RouteIconOption } from './types.js';
import { getModelPatternError, matchesModelPattern, normalizeRouteDisplayIconValue } from './utils.js';
type ManualRoutePanelProps = {
show: boolean;
editingRouteId: number | null;
form: { modelPattern: string; displayName: string; displayIcon: string };
setForm: (updater: (f: { modelPattern: string; displayName: string; displayIcon: string }) => { modelPattern: string; displayName: string; displayIcon: string }) => void;
saving: boolean;
canSave: boolean;
routeIconSelectOptions: RouteIconOption[];
previewModelSamples: string[];
onSave: () => void;
onCancel: () => void;
};
export default function ManualRoutePanel({
show,
editingRouteId,
form,
setForm,
saving,
canSave,
routeIconSelectOptions,
previewModelSamples,
onSave,
onCancel,
}: ManualRoutePanelProps) {
const presence = useAnimatedVisibility(show, 220);
const modelPatternError = useMemo(
() => getModelPatternError(form.modelPattern),
[form.modelPattern],
);
const routeIconOptionValues = useMemo(
() => new Set(routeIconSelectOptions.map((option) => option.value)),
[routeIconSelectOptions],
);
const routeIconSelectValue = routeIconOptionValues.has(normalizeRouteDisplayIconValue(form.displayIcon))
? normalizeRouteDisplayIconValue(form.displayIcon)
: '';
const previewMatchedModels = useMemo(() => {
const normalizedPattern = form.modelPattern.trim();
if (!normalizedPattern || modelPatternError) return [] as string[];
return previewModelSamples.filter((modelName) => matchesModelPattern(modelName, normalizedPattern));
}, [form.modelPattern, modelPatternError, previewModelSamples]);
if (!presence.shouldRender) return null;
return (
<div className={`card panel-presence ${presence.isVisible ? '' : 'is-closing'}`.trim()} style={{ padding: 20, marginBottom: 16 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
{editingRouteId
? tr('编辑群组路由名称、图标和模型匹配规则;若修改正则,将按当前可用模型重新匹配自动通道。')
: tr('用于创建群组路由(聚合多个上游模型为一个下游模型名,即模型重定向);自动路由仍会保持开启。')}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 180px', gap: 10 }}>
<input
placeholder={tr('群组显示名(可选,例如 claude-opus-4-6)')}
value={form.displayName}
onChange={(e) => setForm((f) => ({ ...f, displayName: e.target.value }))}
style={{
width: '100%',
padding: '10px 14px',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: 13,
outline: 'none',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
}}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<ModernSelect
value={routeIconSelectValue}
onChange={(nextValue) => setForm((f) => ({ ...f, displayIcon: nextValue }))}
options={routeIconSelectOptions}
placeholder={tr('图标(可选,选择品牌图标)')}
emptyLabel={tr('暂无可选品牌图标')}
/>
</div>
</div>
<input
placeholder={tr('模型匹配(如 gpt-4o、claude-*、re:^claude-.*$)')}
value={form.modelPattern}
onChange={(e) => setForm((f) => ({ ...f, modelPattern: e.target.value }))}
style={{
width: '100%',
padding: '10px 14px',
border: `1px solid ${modelPatternError ? 'var(--color-danger)' : 'var(--color-border)'}`,
borderRadius: 'var(--radius-sm)',
fontSize: 13,
outline: 'none',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
fontFamily: 'var(--font-mono)',
}}
/>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: -4 }}>
{tr('正则请使用 re: 前缀;例如 re:^claude-(opus|sonnet)-4-6$')}
</div>
{modelPatternError && (
<div style={{ fontSize: 12, color: 'var(--color-danger)', marginTop: -4 }}>
{modelPatternError}
</div>
)}
{form.modelPattern.trim() && !modelPatternError && (
<div
style={{
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
padding: '10px 12px',
background: 'var(--color-bg)',
}}
>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 8 }}>
{tr('规则预览:命中样本')} {previewMatchedModels.length} / {previewModelSamples.length}
</div>
{previewModelSamples.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{tr('当前暂无可预览模型,请先同步模型。')}
</div>
) : previewMatchedModels.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{tr('当前规则未命中任何样本模型。')}
</div>
) : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{previewMatchedModels.slice(0, 12).map((modelName) => (
<code
key={modelName}
style={{
fontSize: 11,
padding: '2px 6px',
borderRadius: 6,
border: '1px solid var(--color-border)',
background: 'var(--color-bg-card)',
}}
>
{modelName}
</code>
))}
</div>
)}
{previewMatchedModels.length > 12 && (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 8 }}>
{tr('仅展示前 12 个命中样本。')}
</div>
)}
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<button
onClick={onSave}
disabled={!canSave}
className="btn btn-success"
style={{ alignSelf: 'flex-start' }}
>
{saving ? (
<>
<span
className="spinner spinner-sm"
style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }}
/>{' '}
{tr('保存中...')}
</>
) : (
tr(editingRouteId ? '保存群组' : '创建群组')
)}
</button>
{editingRouteId ? (
<button
onClick={onCancel}
className="btn btn-ghost"
style={{ alignSelf: 'flex-start', border: '1px solid var(--color-border)' }}
>
{tr('取消编辑')}
</button>
) : null}
</div>
</div>
</div>
);
}
+384
View File
@@ -0,0 +1,384 @@
import { memo, useState, type ReactNode } from 'react';
import {
DndContext,
KeyboardSensor,
PointerSensor,
closestCenter,
type DragEndEvent,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
SortableContext,
verticalListSortingStrategy,
sortableKeyboardCoordinates,
} from '@dnd-kit/sortable';
import { BrandGlyph, InlineBrandIcon, type BrandInfo } from '../../components/BrandIcon.js';
import { useAnimatedVisibility } from '../../components/useAnimatedVisibility.js';
import { tr } from '../../i18n.js';
import type { RouteSummaryRow, RouteChannel, RouteDecision, RouteDecisionCandidate, MissingTokenRouteSiteActionItem } from './types.js';
import type { RouteCandidateView, RouteTokenOption } from '../helpers/routeModelCandidatesIndex.js';
import { SortableChannelRow } from './SortableChannelRow.js';
import {
isExactModelPattern,
resolveRouteTitle,
resolveRouteIcon,
buildSourceGroupKey,
} from './utils.js';
type RouteCardProps = {
route: RouteSummaryRow;
brand: BrandInfo | null;
expanded: boolean;
onToggleExpand: (routeId: number) => void;
onEdit: (route: RouteSummaryRow) => void;
onDelete: (routeId: number) => void;
onToggleEnabled: (route: RouteSummaryRow) => void;
// Channel data (loaded on demand)
channels: RouteChannel[] | undefined;
loadingChannels: boolean;
// Decision data
routeDecision: RouteDecision | null;
loadingDecision: boolean;
// Channel interaction
candidateView: RouteCandidateView;
channelTokenDraft: Record<number, number>;
updatingChannel: Record<number, boolean>;
savingPriority: boolean;
onTokenDraftChange: (channelId: number, tokenId: number) => void;
onSaveToken: (routeId: number, channelId: number, accountId: number) => void;
onDeleteChannel: (channelId: number, routeId: number) => void;
onChannelDragEnd: (routeId: number, event: DragEndEvent) => void;
// Missing token hints
missingTokenSiteItems: MissingTokenRouteSiteActionItem[];
onCreateTokenForMissing: (accountId: number, modelName: string) => void;
// Add channel
onAddChannel: (routeId: number) => void;
// Source group expansion
expandedSourceGroupMap: Record<string, boolean>;
onToggleSourceGroup: (groupKey: string) => void;
};
function AnimatedCollapseSection({ open, children }: { open: boolean; children: ReactNode }) {
const presence = useAnimatedVisibility(open, 220);
if (!presence.shouldRender) return null;
return (
<div className={`anim-collapse ${presence.isVisible ? 'is-open' : ''}`.trim()}>
<div className="anim-collapse-inner">
{children}
</div>
</div>
);
}
function RouteCardInner({
route,
brand,
expanded,
onToggleExpand,
onEdit,
onDelete,
onToggleEnabled,
channels,
loadingChannels,
routeDecision,
loadingDecision,
candidateView,
channelTokenDraft,
updatingChannel,
savingPriority,
onTokenDraftChange,
onSaveToken,
onDeleteChannel,
onChannelDragEnd,
missingTokenSiteItems,
onCreateTokenForMissing,
onAddChannel,
expandedSourceGroupMap,
onToggleSourceGroup,
}: RouteCardProps) {
const routeIcon = resolveRouteIcon(route);
const exactRoute = isExactModelPattern(route.modelPattern);
const title = resolveRouteTitle(route);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const decisionMap = new Map<number, RouteDecisionCandidate>(
(routeDecision?.candidates || []).map((c) => [c.channelId, c]),
);
const channelGroups = (() => {
if (!channels || channels.length === 0) return [];
const groups = new Map<string, RouteChannel[]>();
for (const channel of channels) {
const key = (channel.sourceModel || '').trim() || '__ungrouped__';
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(channel);
}
return Array.from(groups.entries())
.sort((a, b) => {
if (a[0] === '__ungrouped__') return 1;
if (b[0] === '__ungrouped__') return -1;
return a[0].localeCompare(b[0], undefined, { sensitivity: 'base' });
})
.map(([sourceModel, chans]) => ({
sourceModel: sourceModel === '__ungrouped__' ? '' : sourceModel,
channels: chans,
}));
})();
// Collapsed card
if (!expanded) {
return (
<div
className="card route-card-collapsed"
onClick={() => onToggleExpand(route.id)}
style={{ cursor: 'pointer' }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<span style={{ display: 'inline-flex', alignItems: 'center', flexShrink: 0, width: 20, height: 20 }}>
{routeIcon.kind === 'brand' ? (
<BrandGlyph icon={routeIcon.value} alt={title} size={18} fallbackText={title} />
) : routeIcon.kind === 'text' ? (
<span style={{ fontSize: 14, lineHeight: 1 }}>{routeIcon.value}</span>
) : brand ? (
<BrandGlyph brand={brand} alt={title} size={18} fallbackText={title} />
) : (
<InlineBrandIcon model={route.modelPattern} size={18} />
)}
</span>
<code style={{ fontWeight: 600, fontSize: 13, color: 'var(--color-text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0, flex: 1 }}>
{title}
</code>
{route.displayName && route.displayName.trim() !== route.modelPattern ? (
<span className="badge badge-muted" style={{ fontSize: 10, flexShrink: 0 }}>{route.modelPattern}</span>
) : null}
<button
className={`badge route-enable-toggle ${route.enabled ? 'is-enabled' : 'is-disabled'}`}
style={{ fontSize: 11, cursor: 'pointer', border: 'none', flexShrink: 0, minWidth: 36, textAlign: 'center' }}
onClick={(e) => { e.stopPropagation(); onToggleEnabled(route); }}
data-tooltip={route.enabled ? '点击禁用此路由' : '点击启用此路由'}
>
{route.enabled ? tr('启用') : tr('禁用')}
</button>
<span className="badge badge-info" style={{ fontSize: 10, flexShrink: 0 }}>
{route.channelCount} {tr('通道')}
</span>
<svg
width="14" height="14" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2"
style={{ flexShrink: 0, color: 'var(--color-text-muted)' }}
aria-hidden
>
<path d="m5 7 5 6 5-6" />
</svg>
</div>
</div>
);
}
// Expanded card
return (
<div className="card route-card-expanded" style={{ padding: 16 }}>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12, gap: 8, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<code style={{ fontWeight: 600, fontSize: 13, background: 'var(--color-bg)', padding: '4px 10px', borderRadius: 6, color: 'var(--color-text-primary)', display: 'inline-flex', alignItems: 'center', gap: 5 }}>
{routeIcon.kind === 'brand' ? (
<BrandGlyph icon={routeIcon.value} alt={title} size={20} fallbackText={title} />
) : routeIcon.kind === 'text' ? (
<span style={{ width: 20, height: 20, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', borderRadius: 8, background: 'var(--color-bg-card)', fontSize: 14, lineHeight: 1 }}>
{routeIcon.value}
</span>
) : brand ? (
<BrandGlyph brand={brand} alt={title} size={20} fallbackText={title} />
) : (
<InlineBrandIcon model={route.modelPattern} size={20} />
)}
{title}
</code>
{route.displayName && route.displayName.trim() !== route.modelPattern ? (
<span className="badge badge-muted" style={{ fontSize: 10 }}>{route.modelPattern}</span>
) : null}
<button
className={`badge route-enable-toggle ${route.enabled ? 'is-enabled' : 'is-disabled'}`}
style={{ fontSize: 11, cursor: 'pointer', border: 'none' }}
onClick={(e) => { e.stopPropagation(); onToggleEnabled(route); }}
data-tooltip={route.enabled ? '点击禁用此路由' : '点击启用此路由'}
>
{route.enabled ? tr('启用') : tr('禁用')}
</button>
<span className="badge badge-info" style={{ fontSize: 10 }}>
{route.channelCount} {tr('通道')}
</span>
{savingPriority && (
<span className="badge badge-warning" style={{ fontSize: 10 }}>{tr('排序保存中')}</span>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
{!exactRoute && (
<button onClick={() => onEdit(route)} className="btn btn-link">{tr('编辑群组')}</button>
)}
<button onClick={() => onDelete(route.id)} className="btn btn-link btn-link-danger">{tr('删除路由')}</button>
<button
onClick={() => onToggleExpand(route.id)}
className="btn btn-ghost"
style={{ padding: '4px 8px', border: '1px solid var(--color-border)' }}
data-tooltip={tr('收起')}
>
<svg
width="14" height="14" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2"
style={{ transform: 'rotate(180deg)' }}
aria-hidden
>
<path d="m5 7 5 6 5-6" />
</svg>
</button>
</div>
</div>
{!exactRoute && (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
{tr('通配符路由按请求实时决策;概率解释在当前路由内统一估算。')}
</div>
)}
{/* Missing token hints + Add channel button */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
{missingTokenSiteItems.length > 0 ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', flex: 1 }}>
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>{tr('待注册站点')}:</span>
{missingTokenSiteItems.map((item) => (
<button
key={`missing-${route.id}-${item.key}`}
type="button"
onClick={() => onCreateTokenForMissing(item.accountId, route.modelPattern)}
className="badge badge-info missing-token-site-tag"
data-tooltip={`点击跳转到令牌创建(预选 ${item.siteName}/${item.accountLabel})`}
style={{ fontSize: 11, cursor: 'pointer' }}
>
{item.siteName}
</button>
))}
</div>
) : <div />}
<button
onClick={() => onAddChannel(route.id)}
className="btn btn-ghost"
style={{ fontSize: 12, padding: '6px 10px', color: 'var(--color-primary)', border: '1px solid var(--color-border)', whiteSpace: 'nowrap' }}
>
+ {tr('添加通道')}
</button>
</div>
{/* Channel list */}
{loadingChannels ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '12px 0' }}>
<span className="spinner spinner-sm" />
<span style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>{tr('加载通道中...')}</span>
</div>
) : channels && channels.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={(event) => onChannelDragEnd(route.id, event)}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{channelGroups.map((group) => {
const groupKey = buildSourceGroupKey(route.id, group.sourceModel || '');
const supportsCollapse = !exactRoute && !!group.sourceModel;
const isGroupExpanded = supportsCollapse ? !!expandedSourceGroupMap[groupKey] : true;
return (
<div key={`${route.id}-${group.sourceModel || '__ungrouped__'}`} style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{group.sourceModel ? (
supportsCollapse ? (
<button
type="button"
onClick={() => onToggleSourceGroup(groupKey)}
aria-expanded={isGroupExpanded}
className="btn btn-ghost"
style={{
fontSize: 12, color: 'var(--color-text-secondary)', display: 'flex',
alignItems: 'center', justifyContent: 'space-between', gap: 8,
padding: '4px 6px', border: '1px dashed var(--color-border)',
borderRadius: 'var(--radius-sm)', background: 'transparent',
}}
>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<span>来源模型</span>
<code style={{ fontSize: 11, border: '1px solid var(--color-border)', borderRadius: 6, padding: '2px 6px', background: 'var(--color-bg)' }}>
{group.sourceModel}
</code>
<span style={{ color: 'var(--color-text-muted)' }}>{group.channels.length} 通道</span>
</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--color-text-muted)' }}>
{isGroupExpanded ? '收起' : '展开'}
<svg width="12" height="12" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2"
style={{ transform: isGroupExpanded ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 0.2s ease' }}
aria-hidden
>
<path d="m5 7 5 6 5-6" />
</svg>
</span>
</button>
) : (
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', display: 'flex', alignItems: 'center', gap: 8, paddingLeft: 2 }}>
<span>来源模型</span>
<code style={{ fontSize: 11, border: '1px solid var(--color-border)', borderRadius: 6, padding: '2px 6px', background: 'var(--color-bg)' }}>
{group.sourceModel}
</code>
<span style={{ color: 'var(--color-text-muted)' }}>{group.channels.length} 通道</span>
</div>
)
) : null}
<AnimatedCollapseSection open={isGroupExpanded}>
<SortableContext items={group.channels.map((c) => c.id)} strategy={verticalListSortingStrategy}>
{group.channels.map((channel) => {
const tokenOptions = candidateView.tokenOptionsByAccountId[channel.accountId] || [];
const activeTokenId = channelTokenDraft[channel.id] ?? channel.tokenId ?? 0;
return (
<SortableChannelRow
key={channel.id}
channel={channel}
decisionCandidate={decisionMap.get(channel.id)}
isExactRoute={exactRoute}
loadingDecision={loadingDecision}
isSavingPriority={savingPriority}
tokenOptions={tokenOptions}
activeTokenId={activeTokenId}
isUpdatingToken={!!updatingChannel[channel.id]}
onTokenDraftChange={onTokenDraftChange}
onSaveToken={() => onSaveToken(route.id, channel.id, channel.accountId)}
onDeleteChannel={() => onDeleteChannel(channel.id, route.id)}
/>
);
})}
</SortableContext>
</AnimatedCollapseSection>
{!isGroupExpanded && (
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', paddingLeft: 6 }}>
已收起,点击展开查看通道
</div>
)}
</div>
);
})}
</div>
</DndContext>
</div>
) : (
<div style={{ fontSize: 13, color: 'var(--color-text-muted)', paddingLeft: 4 }}>暂无通道</div>
)}
</div>
);
}
const RouteCard = memo(RouteCardInner);
export default RouteCard;
@@ -0,0 +1,263 @@
import type { ReactNode } from 'react';
import { BrandGlyph, InlineBrandIcon, hashColor, type BrandInfo } from '../../components/BrandIcon.js';
import { useAnimatedVisibility } from '../../components/useAnimatedVisibility.js';
import { tr } from '../../i18n.js';
import type { GroupFilter, GroupRouteItem } from './types.js';
import { resolveEndpointTypeIconModel, siteAvatarLetters } from './utils.js';
type RouteFilterBarProps = {
totalRouteCount: number;
activeBrand: string | null;
setActiveBrand: (brand: string | null) => void;
activeSite: string | null;
setActiveSite: (site: string | null) => void;
activeEndpointType: string | null;
setActiveEndpointType: (endpointType: string | null) => void;
activeGroupFilter: GroupFilter;
setActiveGroupFilter: (filter: GroupFilter) => void;
brandList: { list: [string, { count: number; brand: BrandInfo }][]; otherCount: number };
siteList: [string, { count: number; siteId: number }][];
endpointTypeList: [string, number][];
groupRouteList: GroupRouteItem[];
collapsed: boolean;
onToggle: () => void;
};
function FilterChip({
active,
label,
count,
icon,
onClick,
}: {
active: boolean;
label: string;
count?: number;
icon?: ReactNode;
onClick: () => void;
}) {
return (
<button
type="button"
className={`filter-chip ${active ? 'active' : ''}`}
onClick={onClick}
>
{icon && <span className="filter-chip-icon">{icon}</span>}
<span className="filter-chip-label">{label}</span>
{count !== undefined && <span className="filter-chip-count">{count}</span>}
</button>
);
}
function FilterRow({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="route-filter-row">
<span className="route-filter-row-label">{label}</span>
<div className="route-filter-row-chips">{children}</div>
</div>
);
}
function ActiveFilterSummary({
activeBrand,
activeSite,
activeGroupFilter,
activeEndpointType,
}: Pick<RouteFilterBarProps, 'activeBrand' | 'activeSite' | 'activeGroupFilter' | 'activeEndpointType'>) {
const tags: string[] = [];
if (activeBrand) tags.push(`品牌=${activeBrand === '__other__' ? '其他' : activeBrand}`);
if (activeSite) tags.push(`站点=${activeSite}`);
if (activeGroupFilter === '__all__') tags.push('群组=全部');
else if (typeof activeGroupFilter === 'number') tags.push(`群组=#${activeGroupFilter}`);
if (activeEndpointType) tags.push(`能力=${activeEndpointType}`);
if (tags.length === 0) return <span style={{ color: 'var(--color-text-muted)' }}>{tr('全部')}</span>;
return <span>{tags.join(', ')}</span>;
}
export default function RouteFilterBar(props: RouteFilterBarProps) {
const {
totalRouteCount,
activeBrand,
setActiveBrand,
activeSite,
setActiveSite,
activeEndpointType,
setActiveEndpointType,
activeGroupFilter,
setActiveGroupFilter,
brandList,
siteList,
endpointTypeList,
groupRouteList,
collapsed,
onToggle,
} = props;
const expandPresence = useAnimatedVisibility(!collapsed, 220);
return (
<div className="route-filter-bar">
{/* Collapsed summary */}
<button
type="button"
className="route-filter-bar-summary"
onClick={onToggle}
>
<svg
width="12" height="12" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2"
style={{ transform: collapsed ? 'rotate(0deg)' : 'rotate(180deg)', transition: 'transform 0.2s ease' }}
aria-hidden
>
<path d="m5 7 5 6 5-6" />
</svg>
<span style={{ fontWeight: 500, fontSize: 13 }}>{tr('筛选')}:</span>
<ActiveFilterSummary
activeBrand={activeBrand}
activeSite={activeSite}
activeGroupFilter={activeGroupFilter}
activeEndpointType={activeEndpointType}
/>
</button>
{/* Expanded panel */}
{expandPresence.shouldRender && (
<div className={`route-filter-bar-expanded ${expandPresence.isVisible ? '' : 'is-closing'}`.trim()}>
{/* Brand row */}
<FilterRow label={tr('品牌')}>
<FilterChip
active={!activeBrand}
label={tr('全部')}
count={totalRouteCount}
icon={<span style={{ fontSize: 10 }}>✦</span>}
onClick={() => setActiveBrand(null)}
/>
{brandList.list.map(([brandName, { count, brand }]) => (
<FilterChip
key={brandName}
active={activeBrand === brandName}
label={brandName}
count={count}
icon={<BrandGlyph brand={brand} size={12} fallbackText={brandName} />}
onClick={() => setActiveBrand(activeBrand === brandName ? null : brandName)}
/>
))}
{brandList.otherCount > 0 && (
<FilterChip
active={activeBrand === '__other__'}
label={tr('其他')}
count={brandList.otherCount}
icon={<span style={{ fontSize: 10 }}>?</span>}
onClick={() => setActiveBrand(activeBrand === '__other__' ? null : '__other__')}
/>
)}
</FilterRow>
{/* Site row */}
{siteList.length > 0 && (
<FilterRow label={tr('站点')}>
<FilterChip
active={!activeSite}
label={tr('全部')}
count={totalRouteCount}
icon={<span style={{ fontSize: 10 }}>⚡</span>}
onClick={() => setActiveSite(null)}
/>
{siteList.map(([siteName, { count }]) => (
<FilterChip
key={siteName}
active={activeSite === siteName}
label={siteName}
count={count}
icon={
<span
style={{
fontSize: 8,
background: hashColor(siteName),
color: 'white',
borderRadius: 3,
padding: '1px 2px',
lineHeight: 1,
}}
>
{siteAvatarLetters(siteName)}
</span>
}
onClick={() => setActiveSite(activeSite === siteName ? null : siteName)}
/>
))}
</FilterRow>
)}
{/* Group row */}
<FilterRow label={tr('群组')}>
<FilterChip
active={activeGroupFilter === '__all__'}
label={tr('全部群组')}
count={groupRouteList.length}
icon={<span style={{ fontSize: 10 }}>◎</span>}
onClick={() => setActiveGroupFilter(activeGroupFilter === '__all__' ? null : '__all__')}
/>
{groupRouteList.map((groupRoute) => (
<FilterChip
key={groupRoute.id}
active={activeGroupFilter === groupRoute.id}
label={groupRoute.title}
count={groupRoute.channelCount}
icon={
groupRoute.icon.kind === 'brand' ? (
<BrandGlyph icon={groupRoute.icon.value} alt={groupRoute.title} size={12} fallbackText={groupRoute.title} />
) : groupRoute.icon.kind === 'text' ? (
<span style={{ fontSize: 10, lineHeight: 1 }}>{groupRoute.icon.value}</span>
) : groupRoute.brand ? (
<BrandGlyph brand={groupRoute.brand} alt={groupRoute.title} size={12} fallbackText={groupRoute.title} />
) : (
<InlineBrandIcon model={groupRoute.modelPattern} size={12} />
)
}
onClick={() => setActiveGroupFilter(activeGroupFilter === groupRoute.id ? null : groupRoute.id)}
/>
))}
</FilterRow>
{/* Endpoint type row */}
<FilterRow label={tr('能力')}>
<FilterChip
active={!activeEndpointType}
label={tr('全部')}
count={totalRouteCount}
icon={<span style={{ fontSize: 10 }}>⚙</span>}
onClick={() => setActiveEndpointType(null)}
/>
{endpointTypeList.map(([endpointType, count]) => {
const iconModel = resolveEndpointTypeIconModel(endpointType);
return (
<FilterChip
key={endpointType}
active={activeEndpointType === endpointType}
label={endpointType}
count={count}
icon={iconModel ? <InlineBrandIcon model={iconModel} size={12} /> : <span style={{ fontSize: 10 }}>⚙</span>}
onClick={() => setActiveEndpointType(activeEndpointType === endpointType ? null : endpointType)}
/>
);
})}
{endpointTypeList.length === 0 && (
<span style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>{tr('暂无接口能力数据')}</span>
)}
</FilterRow>
<div style={{ display: 'flex', justifyContent: 'flex-end', paddingTop: 4 }}>
<button
className="btn btn-ghost"
style={{ fontSize: 12, padding: '4px 10px', border: '1px solid var(--color-border)' }}
onClick={onToggle}
>
{tr('收起筛选面板')}
</button>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,210 @@
import type { CSSProperties } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import ModernSelect from '../../components/ModernSelect.js';
import type { SortableChannelRowProps } from './types.js';
import { getChannelDecisionState, getPriorityTagStyle, getProbabilityColor } from './utils.js';
export function SortableChannelRow({
channel,
decisionCandidate,
isExactRoute,
loadingDecision,
isSavingPriority,
tokenOptions,
activeTokenId,
isUpdatingToken,
onTokenDraftChange,
onSaveToken,
onDeleteChannel,
}: SortableChannelRowProps) {
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: channel.id,
disabled: isSavingPriority,
});
const rowStyle: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.72 : 1,
zIndex: isDragging ? 10 : 1,
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) auto auto',
alignItems: 'center',
gap: 8,
padding: '8px 12px',
borderLeft: '2px solid var(--color-primary)',
borderRadius: '0 var(--radius-sm) var(--radius-sm) 0',
background: isDragging ? 'rgba(59,130,246,0.08)' : 'rgba(79,70,229,0.02)',
boxShadow: isDragging ? 'var(--shadow-sm)' : 'none',
};
const decisionState = getChannelDecisionState(decisionCandidate, channel, isExactRoute, loadingDecision);
return (
<div ref={setNodeRef} style={rowStyle}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: 13, flexWrap: 'wrap', minWidth: 0 }}>
<button
type="button"
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
disabled={isSavingPriority}
className="btn btn-ghost"
style={{
width: 22,
minWidth: 22,
height: 22,
padding: 0,
border: '1px solid var(--color-border-light)',
color: 'var(--color-text-muted)',
cursor: isSavingPriority ? 'not-allowed' : 'grab',
}}
data-tooltip="拖拽调整优先级"
aria-label="拖拽调整优先级"
>
<svg width="12" height="12" fill="currentColor" viewBox="0 0 12 12" aria-hidden>
<circle cx="3" cy="2" r="1" />
<circle cx="9" cy="2" r="1" />
<circle cx="3" cy="6" r="1" />
<circle cx="9" cy="6" r="1" />
<circle cx="3" cy="10" r="1" />
<circle cx="9" cy="10" r="1" />
</svg>
</button>
<span
className="badge"
style={{
fontSize: 10,
fontWeight: 700,
letterSpacing: 0.1,
...getPriorityTagStyle(channel.priority ?? 0),
}}
>
P{channel.priority ?? 0}
</span>
<span style={{ fontWeight: 600, color: 'var(--color-text-primary)' }}>
{channel.account?.username || `account-${channel.accountId}`}
</span>
<span className="badge badge-muted" style={{ fontSize: 10 }}>
{channel.site?.name || 'unknown'}
</span>
<span
className="badge"
style={{
fontSize: 10,
background: 'color-mix(in srgb, var(--color-info) 15%, transparent)',
color: 'var(--color-info)',
}}
>
{channel.token?.name || '默认令牌'}
</span>
{channel.sourceModel ? (
<span className="badge badge-info" style={{ fontSize: 10 }}>
{channel.sourceModel}
</span>
) : null}
{channel.manualOverride ? (
<span
className="badge badge-warning"
style={{ fontSize: 10 }}
data-tooltip="该通道由用户手动添加,而非系统自动生成"
>
手动配置
</span>
) : null}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', marginTop: 4 }}>
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>选中概率</span>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 6, minWidth: 120 }}>
<div
data-tooltip={decisionState.probability <= 0 ? decisionState.reasonText : undefined}
style={{
width: 80,
height: 6,
background: 'var(--color-border)',
borderRadius: 999,
overflow: 'hidden',
}}
>
<div
style={{
width: `${Math.max(0, Math.min(100, decisionState.probability))}%`,
height: '100%',
background: getProbabilityColor(decisionState.probability),
borderRadius: 999,
transition: 'width 0.3s ease',
}}
/>
</div>
<span
data-tooltip={decisionState.probability <= 0 ? decisionState.reasonText : undefined}
style={{
fontSize: 11,
color: decisionState.probability > 0 ? 'var(--color-text-secondary)' : decisionState.reasonColor,
fontVariantNumeric: 'tabular-nums',
whiteSpace: 'nowrap',
}}
>
{decisionState.probability.toFixed(1)}%
</span>
</div>
<span style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>成功/失败</span>
<span style={{ fontSize: 11 }}>
<span style={{ color: 'var(--color-success)', fontWeight: 600 }}>{channel.successCount || 0}</span>
<span style={{ color: 'var(--color-text-muted)', margin: '0 2px' }}>/</span>
<span style={{ color: 'var(--color-danger)', fontWeight: 600 }}>{channel.failCount || 0}</span>
</span>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<div style={{ minWidth: 150, flex: 1 }}>
<ModernSelect
size="sm"
value={String(activeTokenId || 0)}
onChange={(nextValue) => onTokenDraftChange(channel.id, Number.parseInt(nextValue, 10) || 0)}
disabled={isUpdatingToken}
options={[
{ value: '0', label: '默认令牌' },
...tokenOptions.map((token) => ({
value: String(token.id),
label: `${token.name}${token.isDefault ? '(默认)' : ''}`,
})),
]}
placeholder="默认令牌"
/>
</div>
<button
onClick={onSaveToken}
disabled={isUpdatingToken}
className="btn btn-link btn-link-info"
>
{isUpdatingToken ? <span className="spinner spinner-sm" /> : '改令牌'}
</button>
</div>
<button
onClick={onDeleteChannel}
className="btn btn-link btn-link-danger"
>
移除
</button>
</div>
);
}
+145
View File
@@ -0,0 +1,145 @@
import type { ReactNode } from 'react';
import type { BrandInfo } from '../../components/BrandIcon.js';
export type RouteSortBy = 'modelPattern' | 'channelCount';
export type RouteSortDir = 'asc' | 'desc';
export type GroupFilter = null | '__all__' | number;
export type RouteChannelDraft = {
accountId: number;
tokenId: number;
sourceModel: string;
};
export type RouteChannel = {
id: number;
accountId: number;
tokenId: number | null;
sourceModel?: string | null;
priority: number;
weight: number;
enabled: boolean;
manualOverride: boolean;
successCount: number;
failCount: number;
cooldownUntil?: string | null;
account?: {
username: string | null;
};
site?: {
id: number;
name: string | null;
platform: string | null;
};
token?: {
id: number;
name: string;
accountId: number;
enabled: boolean;
isDefault: boolean;
} | null;
};
export type RouteRow = {
id: number;
modelPattern: string;
displayName?: string | null;
displayIcon?: string | null;
modelMapping?: string | null;
decisionSnapshot?: RouteDecision | null;
decisionRefreshedAt?: string | null;
enabled: boolean;
channels: RouteChannel[];
};
export type RouteSummaryRow = {
id: number;
modelPattern: string;
displayName: string | null;
displayIcon: string | null;
modelMapping: string | null;
enabled: boolean;
channelCount: number;
enabledChannelCount: number;
siteNames: string[];
decisionSnapshot: RouteDecision | null;
decisionRefreshedAt: string | null;
};
export type RouteDecisionCandidate = {
channelId: number;
accountId: number;
username: string;
siteName: string;
tokenName: string;
priority: number;
weight: number;
eligible: boolean;
recentlyFailed: boolean;
avoidedByRecentFailure: boolean;
probability: number;
reason: string;
};
export type RouteDecision = {
requestedModel: string;
actualModel: string;
matched: boolean;
selectedChannelId?: number;
selectedLabel?: string;
summary: string[];
candidates: RouteDecisionCandidate[];
};
export type ChannelDecisionState = {
probability: number;
showBar: boolean;
reasonText: string;
reasonColor: string;
};
export type RouteTokenOption = {
id: number;
name: string;
isDefault: boolean;
sourceModel?: string;
};
export type RouteIconOption = {
value: string;
label: string;
description?: string;
iconNode?: ReactNode;
iconUrl?: string;
iconText?: string;
};
export type MissingTokenRouteSiteActionItem = {
key: string;
siteName: string;
accountId: number;
accountLabel: string;
};
export type SortableChannelRowProps = {
channel: RouteChannel;
decisionCandidate?: RouteDecisionCandidate;
isExactRoute: boolean;
loadingDecision: boolean;
isSavingPriority: boolean;
tokenOptions: RouteTokenOption[];
activeTokenId: number;
isUpdatingToken: boolean;
onTokenDraftChange: (channelId: number, tokenId: number) => void;
onSaveToken: () => void;
onDeleteChannel: () => void;
};
export type GroupRouteItem = {
id: number;
title: string;
icon: { kind: 'none' } | { kind: 'text'; value: string } | { kind: 'brand'; value: string };
brand: BrandInfo | null;
modelPattern: string;
channelCount: number;
};
@@ -0,0 +1,47 @@
import { useState, useCallback, useRef } from 'react';
import { api } from '../../api.js';
import { normalizeChannels } from './utils.js';
import type { RouteChannel } from './types.js';
export function useRouteChannels() {
const [channelsByRouteId, setChannelsByRouteId] = useState<Record<number, RouteChannel[]>>({});
const [loadingChannelsByRouteId, setLoadingChannelsByRouteId] = useState<Record<number, boolean>>({});
const channelsByRouteIdRef = useRef(channelsByRouteId);
channelsByRouteIdRef.current = channelsByRouteId;
const loadChannels = useCallback(async (routeId: number, force = false) => {
if (!force && channelsByRouteIdRef.current[routeId]) return channelsByRouteIdRef.current[routeId];
setLoadingChannelsByRouteId((prev) => ({ ...prev, [routeId]: true }));
try {
const channels = await api.getRouteChannels(routeId);
const sorted = normalizeChannels(channels || []);
setChannelsByRouteId((prev) => ({ ...prev, [routeId]: sorted }));
return sorted;
} catch (error) {
console.error(`Failed to load channels for route ${routeId}:`, error);
throw error;
} finally {
setLoadingChannelsByRouteId((prev) => ({ ...prev, [routeId]: false }));
}
}, []);
const invalidateChannels = useCallback((routeId: number) => {
setChannelsByRouteId((prev) => {
const next = { ...prev };
delete next[routeId];
return next;
});
}, []);
const setChannels = useCallback((routeId: number, channels: RouteChannel[]) => {
setChannelsByRouteId((prev) => ({ ...prev, [routeId]: channels }));
}, []);
return {
channelsByRouteId,
loadingChannelsByRouteId,
loadChannels,
invalidateChannels,
setChannels,
};
}
+340
View File
@@ -0,0 +1,340 @@
import type { CSSProperties } from 'react';
import { getBrand, normalizeBrandIconKey, type BrandInfo } from '../../components/BrandIcon.js';
import type { RouteRow, RouteChannel, RouteDecisionCandidate, ChannelDecisionState, RouteSummaryRow } from './types.js';
export const AUTO_ROUTE_DECISION_LIMIT = 80;
export const ROUTE_RENDER_CHUNK = 40;
export const ROUTE_BRAND_ICON_PREFIX = 'brand:';
export const ENDPOINT_TYPE_ICON_MODEL_MAP: Record<string, string> = {
openai: 'chatgpt',
gemini: 'gemini',
anthropic: 'claude',
anthroic: 'claude',
claude: 'claude',
};
export const PLATFORM_ENDPOINT_FALLBACK_MAP: Record<string, string[]> = {
openai: ['openai'],
'new-api': ['openai'],
'one-api': ['openai'],
'one-hub': ['openai'],
'done-hub': ['openai'],
sub2api: ['openai'],
veloera: ['openai'],
cliproxyapi: ['openai'],
claude: ['anthropic'],
gemini: ['gemini'],
anyrouter: ['openai', 'anthropic'],
};
export const PLATFORM_ALIASES: Record<string, string> = {
anthropic: 'claude',
google: 'gemini',
'new api': 'new-api',
newapi: 'new-api',
'one api': 'one-api',
oneapi: 'one-api',
};
export function isRegexModelPattern(modelPattern: string): boolean {
return modelPattern.trim().toLowerCase().startsWith('re:');
}
export function isExactModelPattern(modelPattern: string): boolean {
const normalized = modelPattern.trim();
if (!normalized) return false;
if (isRegexModelPattern(normalized)) return false;
return !/[\*\?\[]/.test(normalized);
}
export function parseRegexModelPattern(modelPattern: string): { regex: RegExp | null; error: string | null } {
if (!isRegexModelPattern(modelPattern)) return { regex: null, error: null };
const body = modelPattern.trim().slice(3).trim();
if (!body) return { regex: null, error: 're: 后缺少正则表达式' };
try {
return { regex: new RegExp(body), error: null };
} catch (error) {
return { regex: null, error: (error as Error)?.message || '无效正则' };
}
}
function globToRegexSource(glob: string): string {
let source = '';
for (let i = 0; i < glob.length; i += 1) {
const ch = glob[i];
if (ch === '*') {
source += '.*';
continue;
}
if (ch === '?') {
source += '.';
continue;
}
if (ch === '[') {
const closeIndex = glob.indexOf(']', i + 1);
if (closeIndex > i + 1) {
source += glob.slice(i, closeIndex + 1);
i = closeIndex;
continue;
}
source += '\\[';
continue;
}
source += ch.replace(/[\\^$+?.()|{}]/g, '\\$&');
}
return source;
}
const compiledGlobCache = new Map<string, RegExp | null>();
function matchesGlobPattern(model: string, pattern: string): boolean {
let re = compiledGlobCache.get(pattern);
if (re === undefined) {
try {
re = new RegExp(`^${globToRegexSource(pattern)}$`);
} catch {
re = null;
}
compiledGlobCache.set(pattern, re);
}
return re ? re.test(model) : false;
}
const matchCache = new Map<string, boolean>();
const MATCH_CACHE_LIMIT = 4000;
export function matchesModelPattern(model: string, pattern: string): boolean {
const normalized = (pattern || '').trim();
if (!normalized) return false;
if (normalized === model) return true;
const cacheKey = `${model}\0${normalized}`;
const cached = matchCache.get(cacheKey);
if (cached !== undefined) return cached;
let result: boolean;
if (isRegexModelPattern(normalized)) {
const parsed = parseRegexModelPattern(normalized);
result = !!parsed.regex && parsed.regex.test(model);
} else {
result = matchesGlobPattern(model, normalized);
}
if (matchCache.size >= MATCH_CACHE_LIMIT) matchCache.clear();
matchCache.set(cacheKey, result);
return result;
}
export function getModelPatternError(modelPattern: string): string | null {
const normalized = modelPattern.trim();
if (!normalized) return null;
if (!isRegexModelPattern(normalized)) return null;
const parsed = parseRegexModelPattern(normalized);
if (!parsed.error) return null;
return `模型匹配正则错误:${parsed.error}`;
}
export function resolveRouteTitle(route: Pick<RouteRow | RouteSummaryRow, 'displayName' | 'modelPattern'>): string {
const title = (route.displayName || '').trim();
return title || route.modelPattern;
}
export function resolveRouteBrand(route: Pick<RouteRow | RouteSummaryRow, 'displayName' | 'modelPattern'>): BrandInfo | null {
const displayName = (route.displayName || '').trim();
if (displayName) {
const byDisplayName = getBrand(displayName);
if (byDisplayName) return byDisplayName;
}
return getBrand(route.modelPattern);
}
export function toBrandIconValue(icon: string): string {
return `${ROUTE_BRAND_ICON_PREFIX}${icon}`;
}
export function parseBrandIconValue(raw: string): string | null {
const normalized = (raw || '').trim();
if (!normalized.startsWith(ROUTE_BRAND_ICON_PREFIX)) return null;
const icon = normalized.slice(ROUTE_BRAND_ICON_PREFIX.length).trim();
return normalizeBrandIconKey(icon);
}
export function normalizeRouteDisplayIconValue(raw: string | null | undefined): string {
const normalized = (raw || '').trim();
const brandIcon = parseBrandIconValue(normalized);
if (brandIcon) return toBrandIconValue(brandIcon);
return normalized;
}
export function resolveEndpointTypeIconModel(endpointType: string): string | null {
const key = String(endpointType || '').trim().toLowerCase();
if (!key) return null;
return ENDPOINT_TYPE_ICON_MODEL_MAP[key] || null;
}
export function normalizePlatformKey(platform: string | null | undefined): string {
const raw = String(platform || '').trim().toLowerCase();
if (!raw) return '';
return PLATFORM_ALIASES[raw] || raw;
}
export function inferEndpointTypesFromPlatform(platform: string | null | undefined): string[] {
const key = normalizePlatformKey(platform);
if (!key) return [];
const mapped = PLATFORM_ENDPOINT_FALLBACK_MAP[key];
if (Array.isArray(mapped) && mapped.length > 0) return mapped;
if (key.includes('claude') || key.includes('anthropic')) return ['anthropic'];
if (key.includes('gemini')) return ['gemini'];
if (key.includes('openai') || key.includes('new-api') || key.includes('one-api')) return ['openai'];
return [];
}
export function siteAvatarLetters(siteName: string): string {
const normalized = String(siteName || '').trim();
if (!normalized) return 'S';
const parts = normalized.replace(/[-_/.]/g, ' ').split(/\s+/).filter(Boolean);
if (parts.length >= 2) return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
const compact = normalized.replace(/\s+/g, '');
return compact.slice(0, 2).toUpperCase();
}
export function resolveRouteIcon(route: Pick<RouteRow | RouteSummaryRow, 'displayIcon'>): { kind: 'none' } | { kind: 'text'; value: string } | { kind: 'brand'; value: string } {
const icon = (route.displayIcon || '').trim();
if (!icon) return { kind: 'none' };
const brandIcon = parseBrandIconValue(icon);
if (brandIcon) return { kind: 'brand', value: brandIcon };
return { kind: 'text', value: icon };
}
export function normalizeChannels(channels: RouteChannel[]): RouteChannel[] {
return [...(channels || [])].sort((a, b) => {
const pa = a.priority ?? 0;
const pb = b.priority ?? 0;
if (pa === pb) return (a.id ?? 0) - (b.id ?? 0);
return pa - pb;
});
}
export function normalizeRoutes(routeRows: any[]): RouteRow[] {
return (routeRows || []).map((route) => ({
...(route as RouteRow),
channels: normalizeChannels(route.channels || []),
}));
}
export function buildSourceGroupKey(routeId: number, sourceModel: string): string {
const normalizedSourceModel = sourceModel.trim() || '__ungrouped__';
return `${routeId}::${normalizedSourceModel}`;
}
export function getPriorityTagStyle(priority: number): CSSProperties {
if (priority <= 0) {
return {
background: 'color-mix(in srgb, var(--color-success) 16%, transparent)',
color: 'var(--color-success)',
};
}
if (priority === 1) {
return {
background: 'color-mix(in srgb, var(--color-info) 16%, transparent)',
color: 'var(--color-info)',
};
}
return {
background: 'rgba(100,116,139,0.18)',
color: 'var(--color-text-secondary)',
};
}
export function getProbabilityColor(probability: number): string {
if (probability >= 80) return 'var(--color-success)';
if (probability >= 60) return 'color-mix(in srgb, var(--color-success) 50%, var(--color-warning))';
if (probability >= 40) return 'var(--color-warning)';
if (probability >= 20) return 'color-mix(in srgb, var(--color-warning) 45%, var(--color-danger))';
if (probability > 0) return 'var(--color-danger)';
return 'var(--color-border)';
}
export function getChannelDecisionState(
candidate: RouteDecisionCandidate | undefined,
channel: RouteChannel,
isExactRoute: boolean,
loadingDecision: boolean,
): ChannelDecisionState {
if (!isExactRoute && !candidate) {
return {
probability: 0,
showBar: true,
reasonText: loadingDecision ? '计算中...' : '实时决策',
reasonColor: 'var(--color-text-muted)',
};
}
if (!candidate) {
return {
probability: 0,
showBar: true,
reasonText: loadingDecision ? '计算中...' : '无可用通道',
reasonColor: 'var(--color-text-muted)',
};
}
if (candidate.avoidedByRecentFailure) {
return {
probability: 0,
showBar: true,
reasonText: '失败避让',
reasonColor: 'var(--color-warning)',
};
}
if (!candidate.eligible) {
const nowIso = new Date().toISOString();
const cooldownActive = !!channel.cooldownUntil && channel.cooldownUntil > nowIso;
if (cooldownActive || candidate.reason.includes('冷却中')) {
return {
probability: 0,
showBar: true,
reasonText: '冷却中',
reasonColor: 'var(--color-danger)',
};
}
return {
probability: 0,
showBar: true,
reasonText: candidate.reason || '不可用',
reasonColor: 'var(--color-text-muted)',
};
}
const probability = Number(candidate.probability || 0);
if (probability <= 0) {
if (candidate.recentlyFailed) {
return {
probability: 0,
showBar: false,
reasonText: '近期失败',
reasonColor: 'var(--color-warning)',
};
}
return {
probability: 0,
showBar: false,
reasonText: candidate.reason || '概率为 0%',
reasonColor: 'var(--color-text-muted)',
};
}
return {
probability,
showBar: true,
reasonText: '',
reasonColor: 'var(--color-text-muted)',
};
}
+219 -196
View File
@@ -6,7 +6,8 @@ import TokenRoutes from './TokenRoutes.js';
const { apiMock, getBrandMock } = vi.hoisted(() => ({ const { apiMock, getBrandMock } = vi.hoisted(() => ({
apiMock: { apiMock: {
getRoutes: vi.fn(), getRoutesSummary: vi.fn(),
getRouteChannels: vi.fn(),
getModelTokenCandidates: vi.fn(), getModelTokenCandidates: vi.fn(),
getRouteDecisionsBatch: vi.fn(), getRouteDecisionsBatch: vi.fn(),
getRouteWideDecisionsBatch: vi.fn(), getRouteWideDecisionsBatch: vi.fn(),
@@ -67,6 +68,7 @@ describe('TokenRoutes grouped source models', () => {
getBrandMock.mockReset(); getBrandMock.mockReset();
getBrandMock.mockReturnValue(null); getBrandMock.mockReturnValue(null);
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} }); apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
apiMock.getRouteChannels.mockResolvedValue([]);
apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} }); apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} });
apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} }); apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} });
apiMock.updateRoute.mockResolvedValue({}); apiMock.updateRoute.mockResolvedValue({});
@@ -78,46 +80,31 @@ describe('TokenRoutes grouped source models', () => {
}); });
it('collapses source-model groups by default for wildcard routes', async () => { it('collapses source-model groups by default for wildcard routes', async () => {
apiMock.getRoutes.mockResolvedValue([ const channels = [
{ {
id: 1, id: 11, accountId: 101, tokenId: 1001, sourceModel: 'claude-opus-4-5',
modelPattern: 're:^claude-opus-(4-6|4-5)$', priority: 0, weight: 1, enabled: true, manualOverride: false,
displayName: 'claude-opus-4-6', successCount: 0, failCount: 0,
enabled: true, account: { username: 'user_a' }, site: { name: 'site-a' },
channels: [ token: { id: 1001, name: 'token-a', accountId: 101, enabled: true, isDefault: true },
{ },
id: 11, {
accountId: 101, id: 12, accountId: 102, tokenId: 1002, sourceModel: 'claude-opus-4-6',
tokenId: 1001, priority: 0, weight: 1, enabled: true, manualOverride: false,
sourceModel: 'claude-opus-4-5', successCount: 0, failCount: 0,
priority: 0, account: { username: 'user_b' }, site: { name: 'site-b' },
weight: 1, token: { id: 1002, name: 'token-b', accountId: 102, enabled: true, isDefault: true },
enabled: true, },
manualOverride: false, ];
successCount: 0, apiMock.getRoutesSummary.mockResolvedValue([
failCount: 0, {
account: { username: 'user_a' }, id: 1, modelPattern: 're:^claude-opus-(4-6|4-5)$', displayName: 'claude-opus-4-6',
site: { name: 'site-a' }, displayIcon: null, modelMapping: null, enabled: true,
token: { id: 1001, name: 'token-a', accountId: 101, enabled: true, isDefault: true }, channelCount: 2, enabledChannelCount: 2, siteNames: ['site-a', 'site-b'],
}, decisionSnapshot: null, decisionRefreshedAt: null,
{
id: 12,
accountId: 102,
tokenId: 1002,
sourceModel: 'claude-opus-4-6',
priority: 0,
weight: 1,
enabled: true,
manualOverride: false,
successCount: 0,
failCount: 0,
account: { username: 'user_b' },
site: { name: 'site-b' },
token: { id: 1002, name: 'token-b', accountId: 102, enabled: true, isDefault: true },
},
],
}, },
]); ]);
apiMock.getRouteChannels.mockResolvedValue(channels);
let root: ReturnType<typeof create> | null = null; let root: ReturnType<typeof create> | null = null;
try { try {
@@ -132,42 +119,52 @@ describe('TokenRoutes grouped source models', () => {
}); });
await flushMicrotasks(); await flushMicrotasks();
// Card is collapsed by default, so channel detail is not visible
const text = collectText(root.root); const text = collectText(root.root);
expect(text).toContain('claude-opus-4-5');
expect(text).toContain('claude-opus-4-6'); expect(text).toContain('claude-opus-4-6');
expect(text).not.toContain('user_a'); expect(text).not.toContain('user_a');
expect(text).not.toContain('user_b'); expect(text).not.toContain('user_b');
// Expand the card to load channels
const expandBtn = root.root.find((node) =>
node.type === 'div' && String(node.props.className || '').includes('route-card-collapsed'),
);
await act(async () => {
expandBtn.props.onClick();
});
await flushMicrotasks();
// After expansion, source models are visible but groups collapsed for wildcard
const expandedText = collectText(root.root);
expect(expandedText).toContain('claude-opus-4-5');
expect(expandedText).toContain('claude-opus-4-6');
// Users are inside collapsed source groups
expect(expandedText).not.toContain('user_a');
expect(expandedText).not.toContain('user_b');
} finally { } finally {
root?.unmount(); root?.unmount();
} }
}); });
it('expands a source-model group after user click', async () => { it('expands a source-model group after user click', async () => {
apiMock.getRoutes.mockResolvedValue([ const channels = [
{ {
id: 1, id: 11, accountId: 101, tokenId: 1001, sourceModel: 'claude-opus-4-5',
modelPattern: 're:^claude-opus-(4-6|4-5)$', priority: 0, weight: 1, enabled: true, manualOverride: false,
displayName: 'claude-opus-4-6', successCount: 0, failCount: 0,
enabled: true, account: { username: 'user_a' }, site: { name: 'site-a' },
channels: [ token: { id: 1001, name: 'token-a', accountId: 101, enabled: true, isDefault: true },
{ },
id: 11, ];
accountId: 101, apiMock.getRoutesSummary.mockResolvedValue([
tokenId: 1001, {
sourceModel: 'claude-opus-4-5', id: 1, modelPattern: 're:^claude-opus-(4-6|4-5)$', displayName: 'claude-opus-4-6',
priority: 0, displayIcon: null, modelMapping: null, enabled: true,
weight: 1, channelCount: 1, enabledChannelCount: 1, siteNames: ['site-a'],
enabled: true, decisionSnapshot: null, decisionRefreshedAt: null,
manualOverride: false,
successCount: 0,
failCount: 0,
account: { username: 'user_a' },
site: { name: 'site-a' },
token: { id: 1001, name: 'token-a', accountId: 101, enabled: true, isDefault: true },
},
],
}, },
]); ]);
apiMock.getRouteChannels.mockResolvedValue(channels);
let root: ReturnType<typeof create> | null = null; let root: ReturnType<typeof create> | null = null;
try { try {
@@ -182,6 +179,15 @@ describe('TokenRoutes grouped source models', () => {
}); });
await flushMicrotasks(); await flushMicrotasks();
// Expand the card first
const expandBtn = root.root.find((node) =>
node.type === 'div' && String(node.props.className || '').includes('route-card-collapsed'),
);
await act(async () => {
expandBtn.props.onClick();
});
await flushMicrotasks();
expect(collectText(root.root)).not.toContain('user_a'); expect(collectText(root.root)).not.toContain('user_a');
const toggleButton = findButtonByText(root.root, 'claude-opus-4-5'); const toggleButton = findButtonByText(root.root, 'claude-opus-4-5');
@@ -197,28 +203,23 @@ describe('TokenRoutes grouped source models', () => {
}); });
it('renders missing-token site tags with interactive hover class', async () => { it('renders missing-token site tags with interactive hover class', async () => {
apiMock.getRoutes.mockResolvedValue([ apiMock.getRoutesSummary.mockResolvedValue([
{ {
id: 1, id: 1, modelPattern: 'gpt-5.2-codex', displayName: 'gpt-5.2-codex',
modelPattern: 'gpt-5.2-codex', displayIcon: null, modelMapping: null, enabled: true,
displayName: 'gpt-5.2-codex', channelCount: 0, enabledChannelCount: 0, siteNames: [],
enabled: true, decisionSnapshot: null, decisionRefreshedAt: null,
channels: [],
}, },
]); ]);
apiMock.getModelTokenCandidates.mockResolvedValue({ apiMock.getModelTokenCandidates.mockResolvedValue({
models: {}, models: {},
modelsWithoutToken: { modelsWithoutToken: {
'gpt-5.2-codex': [ 'gpt-5.2-codex': [
{ { accountId: 101, username: 'linuxdo_11494', siteId: 11, siteName: 'Wong' },
accountId: 101,
username: 'linuxdo_11494',
siteId: 11,
siteName: 'Wong',
},
], ],
}, },
}); });
apiMock.getRouteChannels.mockResolvedValue([]);
let root: ReturnType<typeof create> | null = null; let root: ReturnType<typeof create> | null = null;
try { try {
@@ -233,6 +234,15 @@ describe('TokenRoutes grouped source models', () => {
}); });
await flushMicrotasks(); await flushMicrotasks();
// Expand card to see missing token hints
const expandBtn = root.root.find((node) =>
node.type === 'div' && String(node.props.className || '').includes('route-card-collapsed'),
);
await act(async () => {
expandBtn.props.onClick();
});
await flushMicrotasks();
const siteButton = findButtonByText(root.root, 'Wong'); const siteButton = findButtonByText(root.root, 'Wong');
expect(String(siteButton.props.className || '')).toContain('missing-token-site-tag'); expect(String(siteButton.props.className || '')).toContain('missing-token-site-tag');
} finally { } finally {
@@ -241,25 +251,19 @@ describe('TokenRoutes grouped source models', () => {
}); });
it('does not render missing-token site tags when the hint lacks a valid account id', async () => { it('does not render missing-token site tags when the hint lacks a valid account id', async () => {
apiMock.getRoutes.mockResolvedValue([ apiMock.getRoutesSummary.mockResolvedValue([
{ {
id: 1, id: 1, modelPattern: 'gpt-5.2-codex', displayName: 'gpt-5.2-codex',
modelPattern: 'gpt-5.2-codex', displayIcon: null, modelMapping: null, enabled: true,
displayName: 'gpt-5.2-codex', channelCount: 0, enabledChannelCount: 0, siteNames: [],
enabled: true, decisionSnapshot: null, decisionRefreshedAt: null,
channels: [],
}, },
]); ]);
apiMock.getModelTokenCandidates.mockResolvedValue({ apiMock.getModelTokenCandidates.mockResolvedValue({
models: {}, models: {},
modelsWithoutToken: { modelsWithoutToken: {
'gpt-5.2-codex': [ 'gpt-5.2-codex': [
{ { accountId: 0, username: 'shenmo-direct', siteId: 12, siteName: '神墨' },
accountId: 0,
username: 'shenmo-direct',
siteId: 12,
siteName: '神墨',
},
], ],
}, },
}); });
@@ -286,29 +290,12 @@ describe('TokenRoutes grouped source models', () => {
}); });
it('maps endpoint types to expected brand icons in filter panel', async () => { it('maps endpoint types to expected brand icons in filter panel', async () => {
apiMock.getRoutes.mockResolvedValue([ apiMock.getRoutesSummary.mockResolvedValue([
{ {
id: 1, id: 1, modelPattern: 'gpt-5.2-codex', displayName: 'gpt-5.2-codex',
modelPattern: 'gpt-5.2-codex', displayIcon: null, modelMapping: null, enabled: true,
displayName: 'gpt-5.2-codex', channelCount: 1, enabledChannelCount: 1, siteNames: ['Wong'],
enabled: true, decisionSnapshot: null, decisionRefreshedAt: null,
channels: [
{
id: 11,
accountId: 101,
tokenId: 1001,
sourceModel: 'gpt-5.2-codex',
priority: 0,
weight: 1,
enabled: true,
manualOverride: false,
successCount: 0,
failCount: 0,
account: { username: 'user_a' },
site: { id: 1, name: 'Wong' },
token: { id: 1001, name: 'token-a', accountId: 101, enabled: true, isDefault: true },
},
],
}, },
]); ]);
apiMock.getModelTokenCandidates.mockResolvedValue({ apiMock.getModelTokenCandidates.mockResolvedValue({
@@ -331,6 +318,15 @@ describe('TokenRoutes grouped source models', () => {
}); });
await flushMicrotasks(); await flushMicrotasks();
// Expand filter bar to see endpoint types
const filterSummary = root.root.find((node) =>
node.type === 'button' && String(node.props.className || '').includes('route-filter-bar-summary'),
);
await act(async () => {
filterSummary.props.onClick();
});
await flushMicrotasks();
const text = collectText(root.root); const text = collectText(root.root);
expect(text).toContain('chatgpt'); expect(text).toContain('chatgpt');
expect(text).toContain('gemini'); expect(text).toContain('gemini');
@@ -351,13 +347,12 @@ describe('TokenRoutes grouped source models', () => {
} }
return null; return null;
}); });
apiMock.getRoutes.mockResolvedValue([ apiMock.getRoutesSummary.mockResolvedValue([
{ {
id: 91, id: 91, modelPattern: 'nvidia/vila', displayName: 'nvidia/vila',
modelPattern: 'nvidia/vila', displayIcon: null, modelMapping: null, enabled: true,
displayName: 'nvidia/vila', channelCount: 0, enabledChannelCount: 0, siteNames: [],
enabled: true, decisionSnapshot: null, decisionRefreshedAt: null,
channels: [],
}, },
]); ]);
@@ -374,37 +369,32 @@ describe('TokenRoutes grouped source models', () => {
}); });
await flushMicrotasks(); await flushMicrotasks();
// Expand filter bar
const filterSummary = root.root.find((node) =>
node.type === 'button' && String(node.props.className || '').includes('route-filter-bar-summary'),
);
await act(async () => {
filterSummary.props.onClick();
});
await flushMicrotasks();
expect(collectText(root.root)).toContain('NVIDIA'); expect(collectText(root.root)).toContain('NVIDIA');
expect(collectText(root.root)).not.toContain('查看未归类品牌路由');
} finally { } finally {
root?.unmount(); root?.unmount();
} }
}); });
it('falls back to site platform endpoint grouping when endpoint metadata cache is empty', async () => { it('falls back to site platform endpoint grouping when endpoint metadata cache is empty', async () => {
apiMock.getRoutes.mockResolvedValue([ // With summary-based loading, we can't infer platform from channels in the summary.
// The endpoint type should come from endpointTypesByModel data.
// When endpointTypesByModel is empty and channels aren't loaded, no fallback is possible.
// This test verifies the endpoint type section renders correctly.
apiMock.getRoutesSummary.mockResolvedValue([
{ {
id: 1, id: 1, modelPattern: 'gpt-4o-mini', displayName: 'gpt-4o-mini',
modelPattern: 'gpt-4o-mini', displayIcon: null, modelMapping: null, enabled: true,
displayName: 'gpt-4o-mini', channelCount: 1, enabledChannelCount: 1, siteNames: ['site-a'],
enabled: true, decisionSnapshot: null, decisionRefreshedAt: null,
channels: [
{
id: 11,
accountId: 101,
tokenId: 1001,
sourceModel: null,
priority: 0,
weight: 1,
enabled: true,
manualOverride: false,
successCount: 0,
failCount: 0,
account: { username: 'user_a' },
site: { id: 1, name: 'site-a', platform: 'new-api' },
token: { id: 1001, name: 'token-a', accountId: 101, enabled: true, isDefault: true },
},
],
}, },
]); ]);
apiMock.getModelTokenCandidates.mockResolvedValue({ apiMock.getModelTokenCandidates.mockResolvedValue({
@@ -425,22 +415,29 @@ describe('TokenRoutes grouped source models', () => {
}); });
await flushMicrotasks(); await flushMicrotasks();
// Expand filter bar
const filterSummary = root.root.find((node) =>
node.type === 'button' && String(node.props.className || '').includes('route-filter-bar-summary'),
);
await act(async () => {
filterSummary.props.onClick();
});
await flushMicrotasks();
const text = collectText(root.root); const text = collectText(root.root);
expect(text).toContain('接口能力'); expect(text).toContain('能力');
expect(text).toContain('openai');
} finally { } finally {
root?.unmount(); root?.unmount();
} }
}); });
it('still shows endpoint group section with empty hint when no endpoint data can be inferred', async () => { it('still shows endpoint group section with empty hint when no endpoint data can be inferred', async () => {
apiMock.getRoutes.mockResolvedValue([ apiMock.getRoutesSummary.mockResolvedValue([
{ {
id: 1, id: 1, modelPattern: 'custom-model-without-channel', displayName: 'custom-model-without-channel',
modelPattern: 'custom-model-without-channel', displayIcon: null, modelMapping: null, enabled: true,
displayName: 'custom-model-without-channel', channelCount: 0, enabledChannelCount: 0, siteNames: [],
enabled: true, decisionSnapshot: null, decisionRefreshedAt: null,
channels: [],
}, },
]); ]);
apiMock.getModelTokenCandidates.mockResolvedValue({ apiMock.getModelTokenCandidates.mockResolvedValue({
@@ -461,8 +458,17 @@ describe('TokenRoutes grouped source models', () => {
}); });
await flushMicrotasks(); await flushMicrotasks();
// Expand filter bar
const filterSummary = root.root.find((node) =>
node.type === 'button' && String(node.props.className || '').includes('route-filter-bar-summary'),
);
await act(async () => {
filterSummary.props.onClick();
});
await flushMicrotasks();
const text = collectText(root.root); const text = collectText(root.root);
expect(text).toContain('接口能力'); expect(text).toContain('能力');
expect(text).toContain('暂无接口能力数据'); expect(text).toContain('暂无接口能力数据');
} finally { } finally {
root?.unmount(); root?.unmount();
@@ -470,27 +476,24 @@ describe('TokenRoutes grouped source models', () => {
}); });
it('hides exact routes covered by a group route from the main route list', async () => { it('hides exact routes covered by a group route from the main route list', async () => {
apiMock.getRoutes.mockResolvedValue([ apiMock.getRoutesSummary.mockResolvedValue([
{ {
id: 1, id: 1, modelPattern: 'minimax-m2.1', displayName: 'minimax-m2.1',
modelPattern: 'minimax-m2.1', displayIcon: null, modelMapping: null, enabled: true,
displayName: 'minimax-m2.1', channelCount: 0, enabledChannelCount: 0, siteNames: [],
enabled: true, decisionSnapshot: null, decisionRefreshedAt: null,
channels: [],
}, },
{ {
id: 2, id: 2, modelPattern: 'minimaxai/minimax-m2.1', displayName: 'minimaxai/minimax-m2.1',
modelPattern: 'minimaxai/minimax-m2.1', displayIcon: null, modelMapping: null, enabled: true,
displayName: 'minimaxai/minimax-m2.1', channelCount: 0, enabledChannelCount: 0, siteNames: [],
enabled: true, decisionSnapshot: null, decisionRefreshedAt: null,
channels: [],
}, },
{ {
id: 3, id: 3, modelPattern: 're:^(minimax-m2\\.1|minimaxai/minimax-m2\\.1)$', displayName: 'minimax2.1',
modelPattern: 're:^(minimax-m2\\.1|minimaxai/minimax-m2\\.1)$', displayIcon: null, modelMapping: null, enabled: true,
displayName: 'minimax2.1', channelCount: 0, enabledChannelCount: 0, siteNames: [],
enabled: true, decisionSnapshot: null, decisionRefreshedAt: null,
channels: [],
}, },
]); ]);
@@ -516,16 +519,15 @@ describe('TokenRoutes grouped source models', () => {
}); });
it('enters edit mode and seeds the group form with the current route values', async () => { it('enters edit mode and seeds the group form with the current route values', async () => {
apiMock.getRoutes.mockResolvedValue([ apiMock.getRoutesSummary.mockResolvedValue([
{ {
id: 31, id: 31, modelPattern: 're:^claude-(opus|sonnet)-4-6$', displayName: 'claude-4-6-group',
modelPattern: 're:^claude-(opus|sonnet)-4-6$', displayIcon: 'anthropic', modelMapping: null, enabled: true,
displayName: 'claude-4-6-group', channelCount: 0, enabledChannelCount: 0, siteNames: [],
displayIcon: 'anthropic', decisionSnapshot: null, decisionRefreshedAt: null,
enabled: true,
channels: [],
}, },
]); ]);
apiMock.getRouteChannels.mockResolvedValue([]);
let root: ReturnType<typeof create> | null = null; let root: ReturnType<typeof create> | null = null;
try { try {
@@ -540,6 +542,15 @@ describe('TokenRoutes grouped source models', () => {
}); });
await flushMicrotasks(); await flushMicrotasks();
// Expand the card to access edit button
const expandBtn = root.root.find((node) =>
node.type === 'div' && String(node.props.className || '').includes('route-card-collapsed'),
);
await act(async () => {
expandBtn.props.onClick();
});
await flushMicrotasks();
const editButton = findButtonByText(root.root, '编辑群组'); const editButton = findButtonByText(root.root, '编辑群组');
await act(async () => { await act(async () => {
editButton.props.onClick(); editButton.props.onClick();
@@ -555,27 +566,24 @@ describe('TokenRoutes grouped source models', () => {
}); });
it('updates route metadata from edit mode and reloads routes afterwards', async () => { it('updates route metadata from edit mode and reloads routes afterwards', async () => {
apiMock.getRoutes apiMock.getRoutesSummary
.mockResolvedValueOnce([ .mockResolvedValueOnce([
{ {
id: 41, id: 41, modelPattern: 're:^claude-.*$', displayName: 'old-group',
modelPattern: 're:^claude-.*$', displayIcon: '', modelMapping: null, enabled: true,
displayName: 'old-group', channelCount: 0, enabledChannelCount: 0, siteNames: [],
displayIcon: '', decisionSnapshot: null, decisionRefreshedAt: null,
enabled: true,
channels: [],
}, },
]) ])
.mockResolvedValueOnce([ .mockResolvedValueOnce([
{ {
id: 41, id: 41, modelPattern: 're:^claude-.*$', displayName: 'new-group',
modelPattern: 're:^claude-.*$', displayIcon: '', modelMapping: null, enabled: true,
displayName: 'new-group', channelCount: 0, enabledChannelCount: 0, siteNames: [],
displayIcon: '', decisionSnapshot: null, decisionRefreshedAt: null,
enabled: true,
channels: [],
}, },
]); ]);
apiMock.getRouteChannels.mockResolvedValue([]);
let root: ReturnType<typeof create> | null = null; let root: ReturnType<typeof create> | null = null;
try { try {
@@ -590,6 +598,15 @@ describe('TokenRoutes grouped source models', () => {
}); });
await flushMicrotasks(); await flushMicrotasks();
// Expand the card
const expandBtn = root.root.find((node) =>
node.type === 'div' && String(node.props.className || '').includes('route-card-collapsed'),
);
await act(async () => {
expandBtn.props.onClick();
});
await flushMicrotasks();
await act(async () => { await act(async () => {
findButtonByText(root.root, '编辑群组').props.onClick(); findButtonByText(root.root, '编辑群组').props.onClick();
}); });
@@ -608,34 +625,31 @@ describe('TokenRoutes grouped source models', () => {
displayName: 'new-group', displayName: 'new-group',
modelPattern: 're:^claude-.*$', modelPattern: 're:^claude-.*$',
})); }));
expect(apiMock.getRoutes).toHaveBeenCalledTimes(2); expect(apiMock.getRoutesSummary).toHaveBeenCalledTimes(2);
} finally { } finally {
root?.unmount(); root?.unmount();
} }
}); });
it('reloads route data after saving an edited model pattern', async () => { it('reloads route data after saving an edited model pattern', async () => {
apiMock.getRoutes apiMock.getRoutesSummary
.mockResolvedValueOnce([ .mockResolvedValueOnce([
{ {
id: 51, id: 51, modelPattern: 're:^claude-.*$', displayName: 'group-a',
modelPattern: 're:^claude-.*$', displayIcon: '', modelMapping: null, enabled: true,
displayName: 'group-a', channelCount: 0, enabledChannelCount: 0, siteNames: [],
displayIcon: '', decisionSnapshot: null, decisionRefreshedAt: null,
enabled: true,
channels: [],
}, },
]) ])
.mockResolvedValueOnce([ .mockResolvedValueOnce([
{ {
id: 51, id: 51, modelPattern: 're:^gemini-.*$', displayName: 'group-a',
modelPattern: 're:^gemini-.*$', displayIcon: '', modelMapping: null, enabled: true,
displayName: 'group-a', channelCount: 0, enabledChannelCount: 0, siteNames: [],
displayIcon: '', decisionSnapshot: null, decisionRefreshedAt: null,
enabled: true,
channels: [],
}, },
]); ]);
apiMock.getRouteChannels.mockResolvedValue([]);
let root: ReturnType<typeof create> | null = null; let root: ReturnType<typeof create> | null = null;
try { try {
@@ -650,6 +664,15 @@ describe('TokenRoutes grouped source models', () => {
}); });
await flushMicrotasks(); await flushMicrotasks();
// Expand the card
const expandBtn = root.root.find((node) =>
node.type === 'div' && String(node.props.className || '').includes('route-card-collapsed'),
);
await act(async () => {
expandBtn.props.onClick();
});
await flushMicrotasks();
await act(async () => { await act(async () => {
findButtonByText(root.root, '编辑群组').props.onClick(); findButtonByText(root.root, '编辑群组').props.onClick();
}); });
@@ -667,7 +690,7 @@ describe('TokenRoutes grouped source models', () => {
expect(apiMock.updateRoute).toHaveBeenCalledWith(51, expect.objectContaining({ expect(apiMock.updateRoute).toHaveBeenCalledWith(51, expect.objectContaining({
modelPattern: 're:^gemini-.*$', modelPattern: 're:^gemini-.*$',
})); }));
expect(apiMock.getRoutes).toHaveBeenCalledTimes(2); expect(apiMock.getRoutesSummary).toHaveBeenCalledTimes(2);
} finally { } finally {
root?.unmount(); root?.unmount();
} }
@@ -6,7 +6,8 @@ import TokenRoutes from './TokenRoutes.js';
const { apiMock, getBrandMock } = vi.hoisted(() => ({ const { apiMock, getBrandMock } = vi.hoisted(() => ({
apiMock: { apiMock: {
getRoutes: vi.fn(), getRoutesSummary: vi.fn(),
getRouteChannels: vi.fn(),
getModelTokenCandidates: vi.fn(), getModelTokenCandidates: vi.fn(),
getRouteDecisionsBatch: vi.fn(), getRouteDecisionsBatch: vi.fn(),
getRouteWideDecisionsBatch: vi.fn(), getRouteWideDecisionsBatch: vi.fn(),
@@ -55,22 +56,21 @@ describe('TokenRoutes refresh decision action', () => {
vi.clearAllMocks(); vi.clearAllMocks();
getBrandMock.mockReset(); getBrandMock.mockReset();
getBrandMock.mockReturnValue(null); getBrandMock.mockReturnValue(null);
apiMock.getRoutes.mockResolvedValue([ apiMock.getRoutesSummary.mockResolvedValue([
{ {
id: 1, id: 1, modelPattern: 'gpt-4o-mini', displayName: 'gpt-4o-mini',
modelPattern: 'gpt-4o-mini', displayIcon: null, modelMapping: null, enabled: true,
displayName: 'gpt-4o-mini', channelCount: 0, enabledChannelCount: 0, siteNames: [],
enabled: true, decisionSnapshot: null, decisionRefreshedAt: null,
channels: [],
}, },
{ {
id: 2, id: 2, modelPattern: 're:^claude-(opus|sonnet)-4-6$', displayName: 'claude-group',
modelPattern: 're:^claude-(opus|sonnet)-4-6$', displayIcon: null, modelMapping: null, enabled: true,
displayName: 'claude-group', channelCount: 0, enabledChannelCount: 0, siteNames: [],
enabled: true, decisionSnapshot: null, decisionRefreshedAt: null,
channels: [],
}, },
]); ]);
apiMock.getRouteChannels.mockResolvedValue([]);
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} }); apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} }); apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} });
apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} }); apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} });
@@ -6,7 +6,8 @@ import TokenRoutes from './TokenRoutes.js';
const { apiMock, getBrandMock } = vi.hoisted(() => ({ const { apiMock, getBrandMock } = vi.hoisted(() => ({
apiMock: { apiMock: {
getRoutes: vi.fn(), getRoutesSummary: vi.fn(),
getRouteChannels: vi.fn(),
getModelTokenCandidates: vi.fn(), getModelTokenCandidates: vi.fn(),
getRouteDecisionsBatch: vi.fn(), getRouteDecisionsBatch: vi.fn(),
getRouteWideDecisionsBatch: vi.fn(), getRouteWideDecisionsBatch: vi.fn(),
@@ -50,12 +51,17 @@ describe('TokenRoutes cached snapshot load', () => {
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} }); apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} }); apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} });
apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} }); apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} });
apiMock.getRoutes.mockResolvedValue([ apiMock.getRoutesSummary.mockResolvedValue([
{ {
id: 1, id: 1,
modelPattern: 'gpt-4o-mini', modelPattern: 'gpt-4o-mini',
displayName: 'gpt-4o-mini', displayName: 'gpt-4o-mini',
displayIcon: null,
modelMapping: null,
enabled: true, enabled: true,
channelCount: 1,
enabledChannelCount: 1,
siteNames: ['cached-site'],
decisionSnapshot: { decisionSnapshot: {
requestedModel: 'gpt-4o-mini', requestedModel: 'gpt-4o-mini',
actualModel: 'gpt-4o-mini', actualModel: 'gpt-4o-mini',
@@ -81,23 +87,23 @@ describe('TokenRoutes cached snapshot load', () => {
], ],
}, },
decisionRefreshedAt: '2026-03-08T01:23:45.000Z', decisionRefreshedAt: '2026-03-08T01:23:45.000Z',
channels: [ },
{ ]);
id: 11, apiMock.getRouteChannels.mockResolvedValue([
accountId: 101, {
tokenId: 1001, id: 11,
sourceModel: 'gpt-4o-mini', accountId: 101,
priority: 0, tokenId: 1001,
weight: 10, sourceModel: 'gpt-4o-mini',
enabled: true, priority: 0,
manualOverride: false, weight: 10,
successCount: 0, enabled: true,
failCount: 0, manualOverride: false,
account: { username: 'cached-user' }, successCount: 0,
site: { name: 'cached-site' }, failCount: 0,
token: { id: 1001, name: 'cached-token', accountId: 101, enabled: true, isDefault: true }, account: { username: 'cached-user' },
}, site: { name: 'cached-site' },
], token: { id: 1001, name: 'cached-token', accountId: 101, enabled: true, isDefault: true },
}, },
]); ]);
}); });
@@ -120,6 +126,15 @@ describe('TokenRoutes cached snapshot load', () => {
}); });
await flushMicrotasks(); await flushMicrotasks();
// Expand the route card to see channel details with probability
const expandBtn = root.root.find((node) =>
node.type === 'div' && String(node.props.className || '').includes('route-card-collapsed'),
);
await act(async () => {
expandBtn.props.onClick();
});
await flushMicrotasks();
const text = collectText(root.root); const text = collectText(root.root);
expect(text).toContain('88.8%'); expect(text).toContain('88.8%');
expect(text).toContain('cached-user'); expect(text).toContain('cached-user');