diff --git a/README.md b/README.md index 2569e62..4756f70 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,21 @@ --- +## 🌐 在线体验 + +> 无需部署,直接体验 Metapi 的完整功能: + +| | | +| ---------------------- | ---------------------------------------------------------- | +| 🔗**体验地址** | [metapi-t9od.onrender.com](https://metapi-t9od.onrender.com/) | +| 🔑**管理员令牌** | `123456` | + +> **⚠️ 安全提示**:体验站为公共环境,**请勿填入你的 API Key、账号密码或站点信息**。数据随时可能被清空。 + +> **ℹ️ 说明**:体验站使用 Render 免费方案 + OpenRouter 免费模型(仅 `:free` 后缀的模型可用)。 + +--- + ## 📖 介绍 现在 AI 生态里有越来越多基于 New API / One API 系列的聚合中转站,要管理多个站点的余额、模型列表和 API 密钥,往往既分散又费时。 @@ -68,14 +83,14 @@ - [AnyRouter](https://anyrouter.top) — 通用路由平台 - [Sub2API](https://github.com/Wei-Shaw/sub2api) — 订阅制中转 -| 痛点 | Metapi 怎么解决 | -| ------------------------------------- | --------------------------------------------------------------- | +| 痛点 | Metapi 怎么解决 | +| ------------------------------------- | ---------------------------------------------------------------------- | | 🔑 每个站点一个 Key,下游工具配置一堆 | **统一代理入口 + 可选多下游 Key 策略**,模型自动聚合到 `/v1/*` | -| 💸 不知道哪个站点用某个模型最便宜 | **智能路由** 自动按成本、余额、使用率选最优通道 | -| 🔄 某个站点挂了,手动切换好麻烦 | **自动故障转移**,一个通道失败自动冷却并切到下一个 | -| 📊 余额分散在各处,不知道还剩多少 | **集中看板** 一目了然,余额不足自动告警 | -| ✅ 每天得去各站签到领额度 | **自动签到** 定时执行,奖励自动追踪 | -| 🤷 不知道哪个站有什么模型 | **自动模型发现**,上游新增模型零配置出现在你的模型列表里 | +| 💸 不知道哪个站点用某个模型最便宜 | **智能路由** 自动按成本、余额、使用率选最优通道 | +| 🔄 某个站点挂了,手动切换好麻烦 | **自动故障转移**,一个通道失败自动冷却并切到下一个 | +| 📊 余额分散在各处,不知道还剩多少 | **集中看板** 一目了然,余额不足自动告警 | +| ✅ 每天得去各站签到领额度 | **自动签到** 定时执行,奖励自动追踪 | +| 🤷 不知道哪个站有什么模型 | **自动模型发现**,上游新增模型零配置出现在你的模型列表里 | --- @@ -144,7 +159,6 @@ - --- ## 🏛️ 架构概览 @@ -230,13 +244,13 @@ 支持五种通知渠道: -| 渠道 | 说明 | -| --- | --- | -| **Webhook** | 自定义 HTTP 推送 | -| **Bark** | iOS 推送通知 | -| **Server酱** | 微信通知 | +| 渠道 | 说明 | +| ---------------------- | ----------------- | +| **Webhook** | 自定义 HTTP 推送 | +| **Bark** | iOS 推送通知 | +| **Server酱** | 微信通知 | | **Telegram Bot** | Telegram 消息通知 | -| **SMTP 邮件** | 标准邮件通知 | +| **SMTP 邮件** | 标准邮件通知 | 告警场景:余额不足预警、站点/账号异常、签到失败、代理请求失败、Token 过期提醒、每日摘要报告。告警冷却机制(默认 300 秒)防止重复通知。 @@ -345,17 +359,17 @@ Docker Compose、桌面安装包、反向代理、升级与数据库选项等详 ## 🏗️ 技术栈 -| 层 | 技术 | -| -------------------- | --------------------------------------------------------------------------------------------------------- | -| **后端框架** | [Fastify](https://fastify.dev) — 高性能 Node.js 后端框架 | -| **前端框架** | [React 18](https://react.dev) + [Vite](https://vitejs.dev) | -| **语言** | [TypeScript](https://www.typescriptlang.org) — 端到端类型安全 | -| **样式** | [Tailwind CSS v4](https://tailwindcss.com) — 原子化样式框架 | -| **数据库** | SQLite / MySQL / PostgreSQL + [Drizzle ORM](https://orm.drizzle.team) | -| **数据可视化** | [VChart](https://visactor.io/vchart) (@visactor/react-vchart) | -| **定时任务** | [node-cron](https://github.com/node-cron/node-cron) | -| **容器化** | Docker (Alpine) + Docker Compose | -| **测试** | [Vitest](https://vitest.dev) | +| 层 | 技术 | +| -------------------- | ----------------------------------------------------------------- | +| **后端框架** | [Fastify](https://fastify.dev) — 高性能 Node.js 后端框架 | +| **前端框架** | [React 18](https://react.dev) + [Vite](https://vitejs.dev) | +| **语言** | [TypeScript](https://www.typescriptlang.org) — 端到端类型安全 | +| **样式** | [Tailwind CSS v4](https://tailwindcss.com) — 原子化样式框架 | +| **数据库** | SQLite / MySQL / PostgreSQL +[Drizzle ORM](https://orm.drizzle.team) | +| **数据可视化** | [VChart](https://visactor.io/vchart) (@visactor/react-vchart) | +| **定时任务** | [node-cron](https://github.com/node-cron/node-cron) | +| **容器化** | Docker (Alpine) + Docker Compose | +| **测试** | [Vitest](https://vitest.dev) | --- @@ -445,6 +459,6 @@ Metapi 完全自托管,所有数据(账号、令牌、路由、日志)均 **⭐ 如果 Metapi 对你有帮助,给个 Star 就是最大的支持!** -Built with ❤️ by the AI community +``Built with ❤️ by the AI community`` diff --git a/README_EN.md b/README_EN.md index c096ca7..3eb5eda 100644 --- a/README_EN.md +++ b/README_EN.md @@ -55,6 +55,21 @@ into one API Key, one endpoint, with automatic model discovery, --- +## 🌐 Live Demo + +> Try Metapi without deploying — full-featured demo instance: + +| | | +|---|---| +| 🔗 **Demo URL** | [metapi-t9od.onrender.com](https://metapi-t9od.onrender.com/) | +| 🔑 **Admin Token** | `123456` | + +> **⚠️ Security Notice**: This is a public demo. **Do NOT enter any real API keys, credentials, or site information.** Data may be reset at any time. + +> **ℹ️ Note**: Demo runs on Render free tier + OpenRouter free models (only `:free` suffixed models available). First visit may take 30-60s to wake up. + +--- + ## About The AI ecosystem is seeing a growing number of aggregation relay stations based on New API / One API and similar projects. Managing balances, model lists, and API keys across multiple sites is scattered and time-consuming. diff --git a/src/server/db/accountTokenSchemaCompatibility.ts b/src/server/db/accountTokenSchemaCompatibility.ts new file mode 100644 index 0000000..bd2ec40 --- /dev/null +++ b/src/server/db/accountTokenSchemaCompatibility.ts @@ -0,0 +1,64 @@ +export type AccountTokenSchemaDialect = 'sqlite' | 'mysql' | 'postgres'; + +export interface AccountTokenSchemaInspector { + dialect: AccountTokenSchemaDialect; + tableExists(table: string): Promise; + columnExists(table: string, column: string): Promise; + execute(sqlText: string): Promise; +} + +type AccountTokenColumnCompatibilitySpec = { + table: 'account_tokens'; + column: string; + addSql: Record; +}; + +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 { + try { + await inspector.execute(sqlText); + } catch (error) { + if (!isDuplicateColumnError(error)) { + throw error; + } + } +} + +export async function ensureAccountTokenSchemaCompatibility(inspector: AccountTokenSchemaInspector): Promise { + 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]); + } + } +} diff --git a/src/server/db/runtimeSchemaBootstrap.ts b/src/server/db/runtimeSchemaBootstrap.ts index 8ebe500..e76d71c 100644 --- a/src/server/db/runtimeSchemaBootstrap.ts +++ b/src/server/db/runtimeSchemaBootstrap.ts @@ -6,6 +6,7 @@ import { dirname, resolve } from 'node:path'; import { ensureSiteSchemaCompatibility, type SiteSchemaInspector } from './siteSchemaCompatibility.js'; import { ensureRouteGroupingSchemaCompatibility } from './routeGroupingSchemaCompatibility.js'; import { ensureProxyFileSchemaCompatibility } from './proxyFileSchemaCompatibility.js'; +import { ensureAccountTokenSchemaCompatibility } from './accountTokenSchemaCompatibility.js'; export type RuntimeSchemaDialect = 'sqlite' | 'mysql' | 'postgres'; @@ -241,6 +242,7 @@ export async function ensureRuntimeDatabaseSchema(client: RuntimeSchemaClient): await ensureSiteSchemaCompatibility(createSiteSchemaInspector(client)); await ensureRouteGroupingSchemaCompatibility(createSiteSchemaInspector(client)); await ensureProxyFileSchemaCompatibility(createSiteSchemaInspector(client)); + await ensureAccountTokenSchemaCompatibility(createSiteSchemaInspector(client)); } export async function bootstrapRuntimeDatabaseSchema(input: RuntimeSchemaConnectionInput): Promise { diff --git a/src/server/routes/api/tokens.ts b/src/server/routes/api/tokens.ts index 18f6ba0..5ab1ac0 100644 --- a/src/server/routes/api/tokens.ts +++ b/src/server/routes/api/tokens.ts @@ -1,15 +1,15 @@ import { FastifyInstance } from 'fastify'; -import { and, eq, inArray } from 'drizzle-orm'; -import { db, schema } from '../../db/index.js'; -import { rebuildTokenRoutesFromAvailability, refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; -import { invalidateTokenRouterCache, matchesModelPattern, tokenRouter } from '../../services/tokenRouter.js'; -import { startBackgroundTask } from '../../services/backgroundTaskService.js'; -import { - clearRouteDecisionSnapshot, - clearRouteDecisionSnapshots, - parseRouteDecisionSnapshot, - saveRouteDecisionSnapshots, -} from '../../services/routeDecisionSnapshotStore.js'; +import { and, eq, inArray } from 'drizzle-orm'; +import { db, schema } from '../../db/index.js'; +import { rebuildTokenRoutesFromAvailability, refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; +import { invalidateTokenRouterCache, matchesModelPattern, tokenRouter } from '../../services/tokenRouter.js'; +import { startBackgroundTask } from '../../services/backgroundTaskService.js'; +import { + clearRouteDecisionSnapshot, + clearRouteDecisionSnapshots, + parseRouteDecisionSnapshot, + saveRouteDecisionSnapshots, +} from '../../services/routeDecisionSnapshotStore.js'; function isExactModelPattern(modelPattern: string): boolean { const normalized = modelPattern.trim(); @@ -127,9 +127,9 @@ async function getMatchedExactRouteChannelCandidates(modelPattern: string): Prom })).filter((candidate) => candidate.sourceModel.length > 0); } -async function populateRouteChannelsByModelPattern(routeId: number, modelPattern: string): Promise { - const routeCandidates = await getMatchedExactRouteChannelCandidates(modelPattern); - const availabilityCandidates = (await getPatternTokenCandidates(modelPattern)).map((candidate) => ({ +async function populateRouteChannelsByModelPattern(routeId: number, modelPattern: string): Promise { + const routeCandidates = await getMatchedExactRouteChannelCandidates(modelPattern); + const availabilityCandidates = (await getPatternTokenCandidates(modelPattern)).map((candidate) => ({ tokenId: candidate.tokenId, accountId: candidate.accountId, sourceModel: candidate.sourceModel, @@ -171,59 +171,59 @@ async function populateRouteChannelsByModelPattern(routeId: number, modelPattern existingPairs.add(pairKey); created += 1; } - - return created; -} - -async function rebuildAutomaticRouteChannelsByModelPattern(routeId: number, modelPattern: string): Promise<{ - removedChannels: number; - createdChannels: number; -}> { - const removableChannels = await db.select().from(schema.routeChannels) - .where( - and( - eq(schema.routeChannels.routeId, routeId), - eq(schema.routeChannels.manualOverride, false), - ), - ) - .all(); - - for (const channel of removableChannels) { - await db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channel.id)).run(); - } - - const createdChannels = await populateRouteChannelsByModelPattern(routeId, modelPattern); - return { - removedChannels: removableChannels.length, - createdChannels, - }; -} + + return created; +} + +async function rebuildAutomaticRouteChannelsByModelPattern(routeId: number, modelPattern: string): Promise<{ + removedChannels: number; + createdChannels: number; +}> { + const removableChannels = await db.select().from(schema.routeChannels) + .where( + and( + eq(schema.routeChannels.routeId, routeId), + eq(schema.routeChannels.manualOverride, false), + ), + ) + .all(); + + for (const channel of removableChannels) { + await db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channel.id)).run(); + } + + const createdChannels = await populateRouteChannelsByModelPattern(routeId, modelPattern); + return { + removedChannels: removableChannels.length, + createdChannels, + }; +} type BatchChannelPriorityUpdate = { id: number; priority: number; }; -type BatchRouteDecisionModels = { - models: string[]; - refreshPricingCatalog?: boolean; - persistSnapshots?: boolean; -}; - -type BatchRouteDecisionRouteModels = { - items: Array<{ - routeId: number; - model: string; - }>; - refreshPricingCatalog?: boolean; - persistSnapshots?: boolean; -}; - -type BatchRouteWideDecisionRouteIds = { - routeIds: number[]; - refreshPricingCatalog?: boolean; - persistSnapshots?: boolean; -}; +type BatchRouteDecisionModels = { + models: string[]; + refreshPricingCatalog?: boolean; + persistSnapshots?: boolean; +}; + +type BatchRouteDecisionRouteModels = { + items: Array<{ + routeId: number; + model: string; + }>; + refreshPricingCatalog?: boolean; + persistSnapshots?: boolean; +}; + +type BatchRouteWideDecisionRouteIds = { + routeIds: number[]; + refreshPricingCatalog?: boolean; + persistSnapshots?: boolean; +}; function parseBatchChannelUpdates(input: unknown): { ok: true; updates: BatchChannelPriorityUpdate[] } | { ok: false; message: string } { if (!input || typeof input !== 'object') { @@ -264,9 +264,9 @@ function parseBatchChannelUpdates(input: unknown): { ok: true; updates: BatchCha return { ok: true, updates: normalized }; } -function parseBatchRouteDecisionModels( - input: unknown, -): { ok: true; models: string[]; refreshPricingCatalog: boolean; persistSnapshots: boolean } | { ok: false; message: string } { +function parseBatchRouteDecisionModels( + input: unknown, +): { ok: true; models: string[]; refreshPricingCatalog: boolean; persistSnapshots: boolean } | { ok: false; message: string } { if (!input || typeof input !== 'object') { return { ok: false, message: '请求体必须是对象' }; } @@ -291,17 +291,17 @@ function parseBatchRouteDecisionModels( return { ok: false, message: 'models 中没有有效模型名称' }; } - return { - ok: true, - models: normalized, - refreshPricingCatalog: (input as { refreshPricingCatalog?: unknown }).refreshPricingCatalog === true, - persistSnapshots: (input as { persistSnapshots?: unknown }).persistSnapshots === true, - }; -} - -function parseBatchRouteDecisionRouteModels( - input: unknown, -): { ok: true; items: Array<{ routeId: number; model: string }>; refreshPricingCatalog: boolean; persistSnapshots: boolean } | { ok: false; message: string } { + return { + ok: true, + models: normalized, + refreshPricingCatalog: (input as { refreshPricingCatalog?: unknown }).refreshPricingCatalog === true, + persistSnapshots: (input as { persistSnapshots?: unknown }).persistSnapshots === true, + }; +} + +function parseBatchRouteDecisionRouteModels( + input: unknown, +): { ok: true; items: Array<{ routeId: number; model: string }>; refreshPricingCatalog: boolean; persistSnapshots: boolean } | { ok: false; message: string } { if (!input || typeof input !== 'object') { return { ok: false, message: '请求体必须是对象' }; } @@ -335,17 +335,17 @@ function parseBatchRouteDecisionRouteModels( return { ok: false, message: 'items 中没有有效 routeId/model' }; } - return { - ok: true, - items: normalized, - refreshPricingCatalog: (input as { refreshPricingCatalog?: unknown }).refreshPricingCatalog === true, - persistSnapshots: (input as { persistSnapshots?: unknown }).persistSnapshots === true, - }; -} - -function parseBatchRouteWideDecisionRouteIds( - input: unknown, -): { ok: true; routeIds: number[]; refreshPricingCatalog: boolean; persistSnapshots: boolean } | { ok: false; message: string } { + return { + ok: true, + items: normalized, + refreshPricingCatalog: (input as { refreshPricingCatalog?: unknown }).refreshPricingCatalog === true, + persistSnapshots: (input as { persistSnapshots?: unknown }).persistSnapshots === true, + }; +} + +function parseBatchRouteWideDecisionRouteIds( + input: unknown, +): { ok: true; routeIds: number[]; refreshPricingCatalog: boolean; persistSnapshots: boolean } | { ok: false; message: string } { if (!input || typeof input !== 'object') { return { ok: false, message: '请求体必须是对象' }; } @@ -370,56 +370,211 @@ function parseBatchRouteWideDecisionRouteIds( return { ok: false, message: 'routeIds 中没有有效 routeId' }; } - return { - ok: true, - routeIds: normalized, - refreshPricingCatalog: (input as { refreshPricingCatalog?: unknown }).refreshPricingCatalog === true, - persistSnapshots: (input as { persistSnapshots?: unknown }).persistSnapshots === true, - }; -} + return { + ok: true, + routeIds: normalized, + refreshPricingCatalog: (input as { refreshPricingCatalog?: unknown }).refreshPricingCatalog === true, + persistSnapshots: (input as { persistSnapshots?: unknown }).persistSnapshots === true, + }; +} + +async function fetchChannelsForRoutes(routeIds: number[]): Promise> { + 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(); + + 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) { - // List all routes - app.get('/api/routes', async () => { - const routes = await db.select().from(schema.tokenRoutes).all(); - if (routes.length === 0) return []; + // List routes with basic info only (lightweight for selectors) + app.get('/api/routes/lite', async () => { + return await db.select({ + 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 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.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(); - + const aggByRoute = new Map }>(); 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, - }); + let agg = aggByRoute.get(row.routeId); + if (!agg) { + agg = { channelCount: 0, enabledChannelCount: 0, siteNames: new Set() }; + aggByRoute.set(row.routeId, agg); + } + agg.channelCount += 1; + if (row.enabled) agg.enabledChannelCount += 1; + if (row.siteName) agg.siteNames.add(row.siteName); } - return routes.map((route) => ({ - ...route, - decisionSnapshot: parseRouteDecisionSnapshot(route.decisionSnapshot), - decisionRefreshedAt: route.decisionRefreshedAt ?? null, - channels: channelsByRoute.get(route.id) || [], - })); - }); + return routes.map((route) => { + const agg = aggByRoute.get(route.id); + return { + id: 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( + 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) => { 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 不能为空' }); } - const decision = await tokenRouter.explainSelection(model); + const decision = await tokenRouter.explainSelection(model); return { success: true, decision }; }); - app.post<{ Body: BatchRouteDecisionModels }>('/api/routes/decision/batch', async (request, reply) => { - const parsed = parseBatchRouteDecisionModels(request.body); - if (!parsed.ok) { - return reply.code(400).send({ success: false, message: parsed.message }); - } - - const decisions: Record>> = {}; - 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() : 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: BatchRouteDecisionModels }>('/api/routes/decision/batch', async (request, reply) => { + const parsed = parseBatchRouteDecisionModels(request.body); + if (!parsed.ok) { + return reply.code(400).send({ success: false, message: parsed.message }); + } - app.post<{ Body: BatchRouteDecisionRouteModels }>('/api/routes/decision/by-route/batch', async (request, reply) => { - const parsed = parseBatchRouteDecisionRouteModels(request.body); - if (!parsed.ok) { - return reply.code(400).send({ success: false, message: parsed.message }); - } - - const decisions: Record>>> = {}; - const refreshedKeys = parsed.refreshPricingCatalog ? new Set() : 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 }; - }); + const decisions: Record>> = {}; + 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() : undefined; + for (const model of parsed.models) { + if (parsed.refreshPricingCatalog) { + await tokenRouter.refreshPricingReferenceCosts(model, { refreshedKeys }); + } + decisions[model] = await tokenRouter.explainSelection(model); + } - 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>> = {}; - const refreshedKeys = parsed.refreshPricingCatalog ? new Set() : 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 }; - }); + 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 parsed = parseBatchRouteDecisionRouteModels(request.body); + if (!parsed.ok) { + return reply.code(400).send({ success: false, message: parsed.message }); + } + + const decisions: Record>>> = {}; + const refreshedKeys = parsed.refreshPricingCatalog ? new Set() : 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>> = {}; + const refreshedKeys = parsed.refreshPricingCatalog ? new Set() : 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 app.post<{ Body: { modelPattern: string; displayName?: string; displayIcon?: string; modelMapping?: string; enabled?: boolean } }>('/api/routes', async (request) => { const body = request.body; - const insertedRoute = await db.insert(schema.tokenRoutes).values({ - modelPattern: body.modelPattern, - displayName: body.displayName, - displayIcon: body.displayIcon, - modelMapping: body.modelMapping, - enabled: body.enabled ?? true, - }).run(); - const routeId = Number(insertedRoute.lastInsertRowid || 0); - if (routeId <= 0) { - return { success: false, message: '创建路由失败' }; - } - const route = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get(); - if (!route) { - return { success: false, message: '创建路由失败' }; - } + const insertedRoute = await db.insert(schema.tokenRoutes).values({ + modelPattern: body.modelPattern, + displayName: body.displayName, + displayIcon: body.displayIcon, + modelMapping: body.modelMapping, + enabled: body.enabled ?? true, + }).run(); + const routeId = Number(insertedRoute.lastInsertRowid || 0); + if (routeId <= 0) { + return { success: false, message: '创建路由失败' }; + } + const route = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get(); + if (!route) { + return { success: false, message: '创建路由失败' }; + } - await populateRouteChannelsByModelPattern(route.id, body.modelPattern); + await populateRouteChannelsByModelPattern(route.id, body.modelPattern); invalidateTokenRouterCache(); return route; }); - // Update a route - app.put<{ Params: { id: string }; Body: any }>('/api/routes/:id', async (request, reply) => { - const id = parseInt(request.params.id, 10); - const body = request.body as Record; - const existingRoute = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).get(); - if (!existingRoute) { - return reply.code(404).send({ success: false, message: '路由不存在' }); - } - - const updates: Record = {}; - let nextModelPattern = existingRoute.modelPattern; - - if (body.displayName !== undefined) updates.displayName = body.displayName; - if (body.displayIcon !== undefined) updates.displayIcon = body.displayIcon; - if (body.modelPattern !== undefined) { - nextModelPattern = String(body.modelPattern); - updates.modelPattern = nextModelPattern; - } - if (body.modelMapping !== undefined) updates.modelMapping = body.modelMapping; - if (body.enabled !== undefined) updates.enabled = body.enabled; - updates.updatedAt = new Date().toISOString(); - - await db.update(schema.tokenRoutes).set(updates).where(eq(schema.tokenRoutes.id, id)).run(); - const modelPatternChanged = body.modelPattern !== undefined && nextModelPattern !== existingRoute.modelPattern; - const routeBehaviorChanged = modelPatternChanged || body.modelMapping !== undefined || body.enabled !== undefined; - if (modelPatternChanged) { - await rebuildAutomaticRouteChannelsByModelPattern(id, nextModelPattern); - } - if (routeBehaviorChanged) { - await clearRouteDecisionSnapshot(id); - } - invalidateTokenRouterCache(); - return await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).get(); - }); + // Update a route + app.put<{ Params: { id: string }; Body: any }>('/api/routes/:id', async (request, reply) => { + const id = parseInt(request.params.id, 10); + const body = request.body as Record; + const existingRoute = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).get(); + if (!existingRoute) { + return reply.code(404).send({ success: false, message: '路由不存在' }); + } + + const updates: Record = {}; + let nextModelPattern = existingRoute.modelPattern; + + if (body.displayName !== undefined) updates.displayName = body.displayName; + if (body.displayIcon !== undefined) updates.displayIcon = body.displayIcon; + if (body.modelPattern !== undefined) { + nextModelPattern = String(body.modelPattern); + updates.modelPattern = nextModelPattern; + } + if (body.modelMapping !== undefined) updates.modelMapping = body.modelMapping; + if (body.enabled !== undefined) updates.enabled = body.enabled; + updates.updatedAt = new Date().toISOString(); + + await db.update(schema.tokenRoutes).set(updates).where(eq(schema.tokenRoutes.id, id)).run(); + const modelPatternChanged = body.modelPattern !== undefined && nextModelPattern !== existingRoute.modelPattern; + const routeBehaviorChanged = modelPatternChanged || body.modelMapping !== undefined || body.enabled !== undefined; + if (modelPatternChanged) { + await rebuildAutomaticRouteChannelsByModelPattern(id, nextModelPattern); + } + if (routeBehaviorChanged) { + await clearRouteDecisionSnapshot(id); + } + invalidateTokenRouterCache(); + return await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).get(); + }); // Delete a route 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' ? body.sourceModel.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: '令牌不存在或不属于当前账号' }); } - 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: '该令牌不支持当前模型' }); } @@ -622,26 +777,26 @@ export async function tokensRoutes(app: FastifyInstance) { return reply.code(400).send({ success: false, message: '该来源模型的通道已存在' }); } - const insertedChannel = await db.insert(schema.routeChannels).values({ - routeId, - accountId: body.accountId, - tokenId: body.tokenId, - sourceModel: sourceModel || null, - priority: body.priority ?? 0, - weight: body.weight ?? 10, - }).run(); - const channelId = Number(insertedChannel.lastInsertRowid || 0); - if (channelId <= 0) { - return reply.code(500).send({ success: false, message: '创建通道失败' }); - } - const created = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get(); - if (!created) { - return reply.code(500).send({ success: false, message: '创建通道失败' }); - } - await clearRouteDecisionSnapshot(routeId); - invalidateTokenRouterCache(); - return created; - }); + const insertedChannel = await db.insert(schema.routeChannels).values({ + routeId, + accountId: body.accountId, + tokenId: body.tokenId, + sourceModel: sourceModel || null, + priority: body.priority ?? 0, + weight: body.weight ?? 10, + }).run(); + const channelId = Number(insertedChannel.lastInsertRowid || 0); + if (channelId <= 0) { + return reply.code(500).send({ success: false, message: '创建通道失败' }); + } + const created = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get(); + if (!created) { + return reply.code(500).send({ success: false, message: '创建通道失败' }); + } + await clearRouteDecisionSnapshot(routeId); + invalidateTokenRouterCache(); + return created; + }); // Batch update channel priorities 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(); } - const updatedChannels = await db.select().from(schema.routeChannels) - .where(inArray(schema.routeChannels.id, channelIds)) - .all(); - await clearRouteDecisionSnapshots(existingChannels.map((channel) => channel.routeId)); - invalidateTokenRouterCache(); - return { success: true, channels: updatedChannels }; - }); + const updatedChannels = await db.select().from(schema.routeChannels) + .where(inArray(schema.routeChannels.id, channelIds)) + .all(); + await clearRouteDecisionSnapshots(existingChannels.map((channel) => channel.routeId)); + invalidateTokenRouterCache(); + return { success: true, channels: updatedChannels }; + }); // Update a channel 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) { 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: '令牌不存在或不属于通道账号' }); } } - const nextTokenId = body.tokenId === undefined - ? (channel.tokenId ?? await getDefaultTokenId(channel.accountId)) - : (body.tokenId === null ? await getDefaultTokenId(channel.accountId) : Number(body.tokenId)); + const nextTokenId = body.tokenId === undefined + ? (channel.tokenId ?? await getDefaultTokenId(channel.accountId)) + : (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: '该令牌不支持当前模型' }); } @@ -714,24 +869,24 @@ export async function tokensRoutes(app: FastifyInstance) { for (const key of ['priority', 'weight', 'enabled', 'tokenId']) { if (body[key] !== undefined) updates[key] = body[key]; } - - await db.update(schema.routeChannels).set(updates).where(eq(schema.routeChannels.id, channelId)).run(); - await clearRouteDecisionSnapshot(channel.routeId); - invalidateTokenRouterCache(); - return await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get(); - }); - - // Delete a channel - app.delete<{ Params: { channelId: string } }>('/api/channels/:channelId', async (request) => { - const channelId = parseInt(request.params.channelId, 10); - 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(); - if (channel) { - await clearRouteDecisionSnapshot(channel.routeId); - } - invalidateTokenRouterCache(); - return { success: true }; - }); + + await db.update(schema.routeChannels).set(updates).where(eq(schema.routeChannels.id, channelId)).run(); + await clearRouteDecisionSnapshot(channel.routeId); + invalidateTokenRouterCache(); + return await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get(); + }); + + // Delete a channel + app.delete<{ Params: { channelId: string } }>('/api/channels/:channelId', async (request) => { + const channelId = parseInt(request.params.channelId, 10); + 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(); + if (channel) { + await clearRouteDecisionSnapshot(channel.routeId); + } + invalidateTokenRouterCache(); + return { success: true }; + }); // Rebuild routes/channels from model availability. app.post<{ Body?: { refreshModels?: boolean; wait?: boolean } }>('/api/routes/rebuild', async (request, reply) => { diff --git a/src/web/api.ts b/src/web/api.ts index cb93360..2a6df9e 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -277,6 +277,11 @@ export const 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) }), updateRoute: (id: number, data: any) => request(`/api/routes/${id}`, { method: 'PUT', body: JSON.stringify(data) }), deleteRoute: (id: number) => request(`/api/routes/${id}`, { method: 'DELETE' }), diff --git a/src/web/index.css b/src/web/index.css index d8a85f4..1b522b3 100644 --- a/src/web/index.css +++ b/src/web/index.css @@ -4510,3 +4510,162 @@ textarea { 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; + } +} diff --git a/src/web/pages/Settings.tsx b/src/web/pages/Settings.tsx index 8a0c823..c0caffc 100644 --- a/src/web/pages/Settings.tsx +++ b/src/web/pages/Settings.tsx @@ -352,7 +352,7 @@ export default function Settings() { const loadRouteSelectorRoutes = async () => { setSelectorLoading(true); try { - const rows = await api.getRoutes(); + const rows = await api.getRoutesLite(); setSelectorRoutes((Array.isArray(rows) ? rows : []).map((row: any) => ({ id: row.id, modelPattern: row.modelPattern, @@ -374,7 +374,7 @@ export default function Settings() { api.getAuthInfo(), api.getRuntimeSettings(), api.getDownstreamApiKeys(), - api.getRoutes(), + api.getRoutesLite(), api.getRuntimeDatabaseConfig(), ]); setMaskedToken(authInfo.masked || '****'); diff --git a/src/web/pages/TokenRoutes.tsx b/src/web/pages/TokenRoutes.tsx index cd50bc3..282566d 100644 --- a/src/web/pages/TokenRoutes.tsx +++ b/src/web/pages/TokenRoutes.tsx @@ -1,29 +1,13 @@ -import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { - DndContext, - KeyboardSensor, - PointerSensor, - closestCenter, - type DragEndEvent, - useSensor, - useSensors, -} from '@dnd-kit/core'; -import { - SortableContext, - arrayMove, - sortableKeyboardCoordinates, - useSortable, - verticalListSortingStrategy, -} from '@dnd-kit/sortable'; -import { CSS } from '@dnd-kit/utilities'; +import { arrayMove } from '@dnd-kit/sortable'; +import type { DragEndEvent } from '@dnd-kit/core'; import { api } from '../api.js'; -import { BrandGlyph, InlineBrandIcon, getBrand, hashColor, normalizeBrandIconKey, type BrandInfo } from '../components/BrandIcon.js'; +import { BrandGlyph, getBrand, InlineBrandIcon, type BrandInfo } from '../components/BrandIcon.js'; import { useToast } from '../components/Toast.js'; import ModernSelect from '../components/ModernSelect.js'; import { MobileCard, MobileField } from '../components/MobileCard.js'; import { MobileDrawer } from '../components/MobileDrawer.js'; -import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js'; import { useIsMobile } from '../components/useIsMobile.js'; import { tr } from '../i18n.js'; import { @@ -39,668 +23,48 @@ import { } from './helpers/routeMissingTokenHints.js'; import { buildVisibleRouteList } from './helpers/routeListVisibility.js'; -type RouteSortBy = 'modelPattern' | 'channelCount'; -type RouteSortDir = 'asc' | 'desc'; -type GroupFilter = null | '__all__' | number; +import type { + RouteSortBy, + RouteSortDir, + GroupFilter, + RouteSummaryRow, + RouteDecision, + RouteIconOption, + MissingTokenRouteSiteActionItem, + GroupRouteItem, +} from './token-routes/types.js'; +import { + AUTO_ROUTE_DECISION_LIMIT, + ROUTE_RENDER_CHUNK, + isExactModelPattern, + matchesModelPattern, + resolveRouteTitle, + resolveRouteBrand, + resolveRouteIcon, + toBrandIconValue, + normalizeRouteDisplayIconValue, + inferEndpointTypesFromPlatform, + getModelPatternError, +} from './token-routes/utils.js'; +import { useRouteChannels } from './token-routes/useRouteChannels.js'; +import RouteFilterBar from './token-routes/RouteFilterBar.js'; +import ManualRoutePanel from './token-routes/ManualRoutePanel.js'; +import RouteCard from './token-routes/RouteCard.js'; +import AddChannelModal from './token-routes/AddChannelModal.js'; -type RouteChannelDraft = { - accountId: number; - tokenId: number; - sourceModel: string; -}; - -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; -}; - -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[]; -}; - -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; -}; - -type RouteDecision = { - requestedModel: string; - actualModel: string; - matched: boolean; - selectedChannelId?: number; - selectedLabel?: string; - summary: string[]; - candidates: RouteDecisionCandidate[]; -}; - -type ChannelDecisionState = { - probability: number; - showBar: boolean; - reasonText: string; - reasonColor: string; -}; - -type RouteTokenOption = { - id: number; - name: string; - isDefault: boolean; - sourceModel?: string; -}; - -type RouteIconOption = { - value: string; - label: string; - description?: string; - iconNode?: ReactNode; - iconUrl?: string; - iconText?: string; -}; - -type MissingTokenRouteSiteActionItem = { - key: string; - siteName: string; - accountId: number; - accountLabel: string; -}; - -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; -}; - -const AUTO_ROUTE_DECISION_LIMIT = 80; -const ROUTE_RENDER_CHUNK = 40; -const ROUTE_BRAND_ICON_PREFIX = 'brand:'; const EMPTY_ROUTE_CANDIDATE_VIEW: RouteCandidateView = { routeCandidates: [], accountOptions: [], tokenOptionsByAccountId: {}, }; +const EMPTY_MISSING_ITEMS: MissingTokenRouteSiteActionItem[] = []; const ROUTE_ICON_OPTIONS: RouteIconOption[] = [ { value: '', label: '自动品牌图标', description: '按模型匹配规则自动识别品牌', iconText: '✦' }, ]; -const ENDPOINT_TYPE_ICON_MODEL_MAP: Record = { - openai: 'chatgpt', - gemini: 'gemini', - anthropic: 'claude', - anthroic: 'claude', - claude: 'claude', -}; -const PLATFORM_ENDPOINT_FALLBACK_MAP: Record = { - 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'], -}; -const PLATFORM_ALIASES: Record = { - anthropic: 'claude', - google: 'gemini', - 'new api': 'new-api', - newapi: 'new-api', - 'one api': 'one-api', - oneapi: 'one-api', -}; - -function isRegexModelPattern(modelPattern: string): boolean { - return modelPattern.trim().toLowerCase().startsWith('re:'); -} - -function isExactModelPattern(modelPattern: string): boolean { - const normalized = modelPattern.trim(); - if (!normalized) return false; - if (isRegexModelPattern(normalized)) return false; - return !/[\*\?\[]/.test(normalized); -} - -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; -} - -function matchesGlobPattern(model: string, pattern: string): boolean { - try { - return new RegExp(`^${globToRegexSource(pattern)}$`).test(model); - } catch { - return false; - } -} - -function matchesModelPattern(model: string, pattern: string): boolean { - const normalized = (pattern || '').trim(); - if (!normalized) return false; - if (normalized === model) return true; - - if (isRegexModelPattern(normalized)) { - const parsed = parseRegexModelPattern(normalized); - return !!parsed.regex && parsed.regex.test(model); - } - - return matchesGlobPattern(model, normalized); -} - -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}`; -} - -function resolveRouteTitle(route: RouteRow): string { - const title = (route.displayName || '').trim(); - return title || route.modelPattern; -} - -function resolveRouteBrand(route: Pick): BrandInfo | null { - const displayName = (route.displayName || '').trim(); - if (displayName) { - const byDisplayName = getBrand(displayName); - if (byDisplayName) return byDisplayName; - } - return getBrand(route.modelPattern); -} - -function toBrandIconValue(icon: string): string { - return `${ROUTE_BRAND_ICON_PREFIX}${icon}`; -} - -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); -} - -function normalizeRouteDisplayIconValue(raw: string | null | undefined): string { - const normalized = (raw || '').trim(); - const brandIcon = parseBrandIconValue(normalized); - if (brandIcon) return toBrandIconValue(brandIcon); - return normalized; -} - -function resolveEndpointTypeIconModel(endpointType: string): string | null { - const key = String(endpointType || '').trim().toLowerCase(); - if (!key) return null; - return ENDPOINT_TYPE_ICON_MODEL_MAP[key] || null; -} - -function normalizePlatformKey(platform: string | null | undefined): string { - const raw = String(platform || '').trim().toLowerCase(); - if (!raw) return ''; - return PLATFORM_ALIASES[raw] || raw; -} - -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 []; -} - -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(); -} - -function resolveRouteIcon(route: RouteRow): { 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 }; -} - -function normalizeRoutes(routeRows: any[]): RouteRow[] { - return (routeRows || []).map((route) => { - const channels = [...((route.channels || []) as RouteChannel[])].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; - }); - - return { - ...(route as RouteRow), - channels, - }; - }); -} - -function buildSourceGroupKey(routeId: number, sourceModel: string): string { - const normalizedSourceModel = sourceModel.trim() || '__ungrouped__'; - return `${routeId}::${normalizedSourceModel}`; -} - -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)', - }; -} - -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)'; -} - -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)', - }; -} - -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 ( -
-
- - - - P{channel.priority ?? 0} - - - - {channel.account?.username || `account-${channel.accountId}`} - - - - {channel.site?.name || 'unknown'} - - - - {channel.token?.name || '默认令牌'} - - - {channel.sourceModel ? ( - - {channel.sourceModel} - - ) : null} - - {channel.manualOverride ? ( - - 手动配置 - - ) : null} - -
- 选中概率 -
-
-
-
- 0 ? 'var(--color-text-secondary)' : decisionState.reasonColor, - fontVariantNumeric: 'tabular-nums', - whiteSpace: 'nowrap', - }} - > - {decisionState.probability.toFixed(1)}% - -
- - 成功/失败 - - {channel.successCount || 0} - / - {channel.failCount || 0} - -
-
- -
-
- 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="默认令牌" - /> -
- -
- - -
- ); -} - -function AnimatedCollapseSection({ open, children }: { open: boolean; children: ReactNode }) { - const presence = useAnimatedVisibility(open, 220); - if (!presence.shouldRender) return null; - return ( -
-
- {children} -
-
- ); -} export default function TokenRoutes() { const navigate = useNavigate(); - const [routes, setRoutes] = useState([]); + const [routeSummaries, setRouteSummaries] = useState([]); const [modelCandidates, setModelCandidates] = useState({}); const [missingTokenModelsByName, setMissingTokenModelsByName] = useState({}); const [endpointTypesByModel, setEndpointTypesByModel] = useState>({}); @@ -710,20 +74,17 @@ export default function TokenRoutes() { const [activeSite, setActiveSite] = useState(null); const [activeEndpointType, setActiveEndpointType] = useState(null); const [activeGroupFilter, setActiveGroupFilter] = useState(null); - const [filterCollapsed, setFilterCollapsed] = useState(false); + const [filterCollapsed, setFilterCollapsed] = useState(true); const [showFilters, setShowFilters] = useState(false); const [sortBy, setSortBy] = useState('channelCount'); const [sortDir, setSortDir] = useState('desc'); const [showManual, setShowManual] = useState(false); - const filterPanelPresence = useAnimatedVisibility(!filterCollapsed, 220); - const manualPanelPresence = useAnimatedVisibility(showManual, 220); const [form, setForm] = useState({ modelPattern: '', displayName: '', displayIcon: '' }); const [editingRouteId, setEditingRouteId] = useState(null); const [saving, setSaving] = useState(false); const [rebuilding, setRebuilding] = useState(false); - const [channelDraftByRoute, setChannelDraftByRoute] = useState>({}); const [channelTokenDraft, setChannelTokenDraft] = useState>({}); const [updatingChannel, setUpdatingChannel] = useState>({}); const [savingPriorityByRoute, setSavingPriorityByRoute] = useState>({}); @@ -734,234 +95,21 @@ export default function TokenRoutes() { const [visibleRouteCount, setVisibleRouteCount] = useState(ROUTE_RENDER_CHUNK); const [expandedSourceGroupMap, setExpandedSourceGroupMap] = useState>({}); const [expandedRouteIds, setExpandedRouteIds] = useState([]); + const [addChannelModalRouteId, setAddChannelModalRouteId] = useState(null); const isMobile = useIsMobile(768); - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { - distance: 4, - }, - }), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }), - ); + const { + channelsByRouteId, + loadingChannelsByRouteId, + loadChannels, + invalidateChannels, + setChannels, + } = useRouteChannels(); const toast = useToast(); - const openFilters = () => { - setFilterCollapsed(false); - setShowFilters(true); - }; - - const closeFilters = () => { - setFilterCollapsed(true); - setShowFilters(false); - }; - - const renderFilterPanelContent = () => ( -
-
-
- 品牌 - {activeBrand && } -
- -
setActiveBrand(null)}> - - ✦ - - 全部品牌 - {routes.length} -
- - {brandList.list.map(([brandName, { count, brand }]) => ( -
setActiveBrand(activeBrand === brandName ? null : brandName)} - > - - - - {brandName} - {count} -
- ))} - - {brandList.otherCount > 0 && ( -
setActiveBrand(activeBrand === '__other__' ? null : '__other__')} - > - - ? - - 其他 - {brandList.otherCount} -
- )} -
- -
-
- {tr('群组')} - {activeGroupFilter !== null && } -
- -
setActiveGroupFilter(activeGroupFilter === '__all__' ? null : '__all__')} - > - - ◎ - - {tr('全部群组')} - {groupRouteList.length} -
- - {groupRouteList.map((groupRoute) => ( -
setActiveGroupFilter(activeGroupFilter === groupRoute.id ? null : groupRoute.id)} - > - - {groupRoute.icon.kind === 'brand' ? ( - - ) : groupRoute.icon.kind === 'text' ? ( - {groupRoute.icon.value} - ) : groupRoute.brand ? ( - - ) : ( - - )} - - {groupRoute.title} - {groupRoute.channelCount} -
- ))} -
- - {siteList.length > 0 && ( -
-
- 站点 - {activeSite && } -
- -
setActiveSite(null)}> - - ⚡ - - 全部站点 - {routes.length} -
- - {siteList.map(([siteName, { count }]) => ( -
setActiveSite(activeSite === siteName ? null : siteName)} - > - - {siteAvatarLetters(siteName)} - - {siteName} - {count} -
- ))} -
- )} - -
-
- 接口能力 - {activeEndpointType && } -
- -
setActiveEndpointType(null)}> - - ⚙ - - 全部能力 - {routes.length} -
- - {endpointTypeList.map(([endpointType, count]) => ( -
setActiveEndpointType(activeEndpointType === endpointType ? null : endpointType)} - > - - {(() => { - const iconModel = resolveEndpointTypeIconModel(endpointType); - if (!iconModel) return ⚙; - return ; - })()} - - {endpointType} - {count} -
- ))} - - {endpointTypeList.length === 0 && ( -
- 暂无接口能力数据 -
- )} -
- - -
- ); - const loadRouteDecisions = async ( - routeRows: RouteRow[], + routeRows: RouteSummaryRow[], options?: { force?: boolean; refreshPricingCatalog?: boolean; persistSnapshots?: boolean }, ) => { const rows = routeRows || []; @@ -998,7 +146,7 @@ export default function TokenRoutes() { } : options?.persistSnapshots ? { persistSnapshots: true as const } - : undefined; + : undefined; const [exactRes, wildcardRes] = await Promise.all([ requestedModels.length > 0 ? api.getRouteDecisionsBatch(requestedModels, decisionRequestOptions) @@ -1028,25 +176,25 @@ export default function TokenRoutes() { }; const load = async () => { - const [routeRows, candidateRows] = await Promise.all([ - api.getRoutes(), + const [summaryRows, candidateRows] = await Promise.all([ + api.getRoutesSummary(), api.getModelTokenCandidates(), ]); - const normalizedRoutes = normalizeRoutes(routeRows || []); - setRoutes(normalizedRoutes); + const summaries = (summaryRows || []) as RouteSummaryRow[]; + setRouteSummaries(summaries); setModelCandidates((candidateRows?.models || {}) as RouteModelCandidatesByModelName); setMissingTokenModelsByName( normalizeMissingTokenModels((candidateRows?.modelsWithoutToken || {}) as MissingTokenModelsByName), ); setEndpointTypesByModel(candidateRows?.endpointTypesByModel || {}); const decisionPlaceholder: Record = {}; - for (const route of normalizedRoutes) { + for (const route of summaries) { decisionPlaceholder[route.id] = route.decisionSnapshot || null; } setDecisionByRoute(decisionPlaceholder); setDecisionAutoSkipped( - normalizedRoutes.some((route) => isExactModelPattern(route.modelPattern) && !route.decisionSnapshot), + summaries.some((route) => isExactModelPattern(route.modelPattern) && !route.decisionSnapshot), ); }; @@ -1082,7 +230,7 @@ export default function TokenRoutes() { const handleRefreshRouteDecisions = async () => { try { - await loadRouteDecisions(routes, { force: true, refreshPricingCatalog: true, persistSnapshots: true }); + await loadRouteDecisions(routeSummaries, { force: true, refreshPricingCatalog: true, persistSnapshots: true }); toast.success('路由选择概率已刷新'); } catch { toast.error('刷新路由选择概率失败'); @@ -1090,44 +238,30 @@ export default function TokenRoutes() { }; const exactRouteCount = useMemo( - () => buildVisibleRouteList(routes, isExactModelPattern, matchesModelPattern) + () => buildVisibleRouteList(routeSummaries, isExactModelPattern, matchesModelPattern) .filter((route) => isExactModelPattern(route.modelPattern)).length, - [routes], + [routeSummaries], ); - const modelPatternError = useMemo( - () => getModelPatternError(form.modelPattern), - [form.modelPattern], - ); + const canSaveRoute = !saving + && !!form.modelPattern.trim() + && !getModelPatternError(form.modelPattern); const previewModelSamples = useMemo(() => { const names = new Set(); - for (const modelName of Object.keys(modelCandidates || {})) { const normalized = modelName.trim(); if (normalized) names.add(normalized); } - - for (const route of routes) { + for (const route of routeSummaries) { if (!isExactModelPattern(route.modelPattern)) continue; const normalized = route.modelPattern.trim(); if (normalized) names.add(normalized); } - return Array.from(names) .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })) .slice(0, 800); - }, [modelCandidates, routes]); - - 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]); - - const canSaveRoute = !saving - && !!form.modelPattern.trim() - && !modelPatternError; + }, [modelCandidates, routeSummaries]); const resetRouteForm = () => { setForm({ modelPattern: '', displayName: '', displayIcon: '' }); @@ -1136,6 +270,7 @@ export default function TokenRoutes() { const handleAddRoute = async () => { if (!form.modelPattern.trim()) return; + const modelPatternError = getModelPatternError(form.modelPattern); if (modelPatternError) { toast.error(modelPatternError); return; @@ -1147,7 +282,7 @@ export default function TokenRoutes() { setSaving(true); try { if (editingRouteId) { - const currentRoute = routes.find((route) => route.id === editingRouteId) || null; + const currentRoute = routeSummaries.find((route) => route.id === editingRouteId) || null; const modelPatternChanged = !!currentRoute && currentRoute.modelPattern !== trimmedModelPattern; await api.updateRoute(editingRouteId, { modelPattern: trimmedModelPattern, @@ -1173,7 +308,7 @@ export default function TokenRoutes() { } }; - const handleEditRoute = (route: RouteRow) => { + const handleEditRoute = (route: RouteSummaryRow) => { setEditingRouteId(route.id); setForm({ modelPattern: route.modelPattern || '', @@ -1198,33 +333,40 @@ export default function TokenRoutes() { } }; - const handleToggleRouteEnabled = async (route: RouteRow) => { + const handleToggleRouteEnabled = async (route: RouteSummaryRow) => { const newEnabled = !route.enabled; - setRoutes((prev) => + setRouteSummaries((prev) => prev.map((item) => (item.id === route.id ? { ...item, enabled: newEnabled } : item)), ); try { await api.updateRoute(route.id, { enabled: newEnabled }); toast.success(newEnabled ? '路由已启用' : '路由已禁用'); } catch (e: any) { - setRoutes((prev) => + setRouteSummaries((prev) => prev.map((item) => (item.id === route.id ? { ...item, enabled: route.enabled } : item)), ); toast.error(e.message || '切换路由状态失败'); } }; + // Stable derived value: only changes when route patterns change (not on enabled toggle) + const routePatterns = useMemo( + () => routeSummaries.map((r) => ({ id: r.id, modelPattern: r.modelPattern })), + // eslint-disable-next-line react-hooks/exhaustive-deps + [routeSummaries.map((r) => `${r.id}:${r.modelPattern}`).join(',')], + ); + const routeBrandById = useMemo(() => { const next = new Map(); - for (const route of routes) { + for (const route of routeSummaries) { next.set(route.id, resolveRouteBrand(route)); } return next; - }, [routes]); + }, [routeSummaries]); const listVisibleRoutes = useMemo( - () => buildVisibleRouteList(routes, isExactModelPattern, matchesModelPattern), - [routes], + () => buildVisibleRouteList(routeSummaries, isExactModelPattern, matchesModelPattern), + [routeSummaries], ); const brandList = useMemo(() => { @@ -1250,7 +392,7 @@ export default function TokenRoutes() { list: [...grouped.entries()].sort((a, b) => { if (a[1].count === b[1].count) return a[0].localeCompare(b[0]); return b[1].count - a[1].count; - }), + }) as [string, { count: number; brand: BrandInfo }][], otherCount, }; }, [listVisibleRoutes, routeBrandById]); @@ -1260,17 +402,15 @@ export default function TokenRoutes() { for (const route of listVisibleRoutes) { const seenSites = new Set(); - for (const channel of route.channels || []) { - const siteName = channel.site?.name; - const siteId = channel.site?.id; - if (!siteName || !siteId || seenSites.has(siteName)) continue; + for (const siteName of route.siteNames || []) { + if (!siteName || seenSites.has(siteName)) continue; seenSites.add(siteName); const existing = grouped.get(siteName); if (existing) { existing.count++; } else { - grouped.set(siteName, { count: 1, siteId }); + grouped.set(siteName, { count: 1, siteId: 0 }); } } } @@ -1278,13 +418,13 @@ export default function TokenRoutes() { return [...grouped.entries()].sort((a, b) => { if (a[1].count === b[1].count) return a[0].localeCompare(b[0]); return b[1].count - a[1].count; - }); + }) as [string, { count: number; siteId: number }][]; }, [listVisibleRoutes]); const routeEndpointTypesByRouteId = useMemo(() => { const index: Record> = {}; const entries = Object.entries(endpointTypesByModel || {}); - for (const route of routes) { + for (const route of routePatterns) { const pattern = (route.modelPattern || '').trim(); if (!pattern) { index[route.id] = new Set(); @@ -1299,17 +439,12 @@ export default function TokenRoutes() { endpointTypes.add(endpointType); } } - if (endpointTypes.size === 0) { - for (const channel of route.channels || []) { - for (const endpointType of inferEndpointTypesFromPlatform(channel.site?.platform)) { - endpointTypes.add(endpointType); - } - } - } + // Fallback: infer from siteNames isn't possible without platform info, + // but we'll keep endpoint types from model availability index[route.id] = endpointTypes; } return index; - }, [routes, endpointTypesByModel]); + }, [routePatterns, endpointTypesByModel]); const endpointTypeList = useMemo(() => { const grouped = new Map(); @@ -1322,13 +457,13 @@ export default function TokenRoutes() { return [...grouped.entries()].sort((a, b) => { if (a[1] === b[1]) return a[0].localeCompare(b[0], undefined, { sensitivity: 'base' }); return b[1] - a[1]; - }); + }) as [string, number][]; }, [listVisibleRoutes, routeEndpointTypesByRouteId]); const routeBrandIconCandidates = useMemo(() => { const byIcon = new Map(); - for (const route of routes) { + for (const route of routeSummaries) { const brand = resolveRouteBrand(route); if (brand) byIcon.set(brand.icon, brand); } @@ -1340,7 +475,7 @@ export default function TokenRoutes() { return Array.from(byIcon.values()) .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); - }, [routes, modelCandidates]); + }, [routeSummaries, modelCandidates]); const routeIconSelectOptions = useMemo(() => ([ ...ROUTE_ICON_OPTIONS, @@ -1352,16 +487,7 @@ export default function TokenRoutes() { })), ]), [routeBrandIconCandidates]); - const routeIconOptionValues = useMemo( - () => new Set(routeIconSelectOptions.map((option) => option.value)), - [routeIconSelectOptions], - ); - - const routeIconSelectValue = routeIconOptionValues.has(normalizeRouteDisplayIconValue(form.displayIcon)) - ? normalizeRouteDisplayIconValue(form.displayIcon) - : ''; - - const groupRouteList = useMemo(() => ( + const groupRouteList = useMemo(() => ( listVisibleRoutes .filter((route) => !isExactModelPattern(route.modelPattern)) .map((route) => ({ @@ -1370,7 +496,7 @@ export default function TokenRoutes() { icon: resolveRouteIcon(route), brand: routeBrandById.get(route.id) || null, modelPattern: route.modelPattern, - channelCount: route.channels?.length ?? 0, + channelCount: route.channelCount, })) .sort((a, b) => { if (a.channelCount === b.channelCount) return a.title.localeCompare(b.title, undefined, { sensitivity: 'base' }); @@ -1386,7 +512,7 @@ export default function TokenRoutes() { const sortedRoutes = useMemo(() => ( [...listVisibleRoutes].sort((a, b) => { if (sortBy === 'channelCount') { - const countCmp = (a.channels?.length ?? 0) - (b.channels?.length ?? 0); + const countCmp = a.channelCount - b.channelCount; if (countCmp !== 0) return sortDir === 'asc' ? countCmp : -countCmp; } @@ -1414,13 +540,13 @@ export default function TokenRoutes() { if (activeSite) { list = list.filter((route) => - route.channels?.some((channel) => channel.site?.name === activeSite) + route.siteNames?.includes(activeSite), ); } if (activeEndpointType) { list = list.filter((route) => - (routeEndpointTypesByRouteId[route.id] || new Set()).has(activeEndpointType) + (routeEndpointTypesByRouteId[route.id] || new Set()).has(activeEndpointType), ); } @@ -1445,9 +571,22 @@ export default function TokenRoutes() { setVisibleRouteCount(getInitialVisibleCount(filteredRoutes.length, ROUTE_RENDER_CHUNK)); }, [filteredRoutes.length]); - const handleLoadMoreRoutes = () => { + const handleLoadMoreRoutes = useCallback(() => { setVisibleRouteCount((current) => getNextVisibleCount(current, filteredRoutes.length, ROUTE_RENDER_CHUNK)); - }; + }, [filteredRoutes.length]); + + const loadMoreSentinelRef = useRef(null); + + useEffect(() => { + const el = loadMoreSentinelRef.current; + if (!el) return; + const observer = new IntersectionObserver( + (entries) => { if (entries[0]?.isIntersecting) handleLoadMoreRoutes(); }, + { rootMargin: '200px' }, + ); + observer.observe(el); + return () => observer.disconnect(); + }, [handleLoadMoreRoutes]); const visibleRoutes = useMemo( () => filteredRoutes.slice(0, visibleRouteCount), @@ -1455,13 +594,13 @@ export default function TokenRoutes() { ); const routeModelCandidateIndex = useMemo( - () => buildRouteModelCandidatesIndex(routes, modelCandidates, matchesModelPattern), - [routes, modelCandidates], + () => buildRouteModelCandidatesIndex(routePatterns, modelCandidates, matchesModelPattern), + [routePatterns, modelCandidates], ); const routeMissingTokenIndex = useMemo( - () => buildRouteMissingTokenIndex(routes, missingTokenModelsByName, matchesModelPattern), - [routes, missingTokenModelsByName], + () => buildRouteMissingTokenIndex(routePatterns, missingTokenModelsByName, matchesModelPattern), + [routePatterns, missingTokenModelsByName], ); const getRouteCandidateView = (routeId: number): RouteCandidateView => { @@ -1478,74 +617,24 @@ export default function TokenRoutes() { navigate(`/tokens?${params.toString()}`); }; - const handleRouteAccountChange = (route: RouteRow, accountId: number) => { - const tokenOptions = getRouteCandidateView(route.id).tokenOptionsByAccountId[accountId] || []; - const defaultTokenOption = tokenOptions.find((token) => token.isDefault) || tokenOptions[0] || null; - setChannelDraftByRoute((prev) => ({ - ...prev, - [route.id]: { - accountId, - tokenId: defaultTokenOption?.id || 0, - sourceModel: defaultTokenOption?.sourceModel || '', - }, - })); - }; - - const handleRouteTokenChange = (routeId: number, tokenId: number, sourceModel: string) => { - setChannelDraftByRoute((prev) => ({ - ...prev, - [routeId]: { - accountId: prev[routeId]?.accountId || 0, - tokenId, - sourceModel: sourceModel || '', - }, - })); - }; - - const handleAddChannel = async (route: RouteRow) => { - const draft = channelDraftByRoute[route.id]; - if (!draft?.accountId) return; - - const tokenOptions = getRouteCandidateView(route.id).tokenOptionsByAccountId[draft.accountId] || []; - if (draft.tokenId && tokenOptions.length > 0 && !tokenOptions.some((token) => token.id === draft.tokenId)) { - toast.error('该令牌不支持当前模型'); - return; - } - - try { - await api.addChannel(route.id, { - accountId: draft.accountId, - tokenId: draft.tokenId || undefined, - sourceModel: draft.sourceModel || undefined, - }); - toast.success('通道已添加'); - setChannelDraftByRoute((prev) => ({ - ...prev, - [route.id]: { - accountId: 0, - tokenId: 0, - sourceModel: '', - }, - })); - await load(); - } catch (e: any) { - toast.error(e.message || '添加通道失败'); - } - }; - - const handleDeleteChannel = async (channelId: number) => { + const handleDeleteChannel = async (channelId: number, routeId: number) => { try { await api.deleteChannel(channelId); toast.success('通道已移除'); - await load(); + // Reload channels for this route + await loadChannels(routeId, true); + // Update channel count in summary + setRouteSummaries((prev) => + prev.map((r) => r.id === routeId ? { ...r, channelCount: Math.max(0, r.channelCount - 1) } : r), + ); } catch (e: any) { toast.error(e.message || '移除通道失败'); } }; - const handleChannelTokenSave = async (route: RouteRow, channelId: number, accountId: number) => { + const handleChannelTokenSave = async (routeId: number, channelId: number, accountId: number) => { const tokenId = channelTokenDraft[channelId]; - const tokenOptions = getRouteCandidateView(route.id).tokenOptionsByAccountId[accountId] || []; + const tokenOptions = getRouteCandidateView(routeId).tokenOptionsByAccountId[accountId] || []; if (tokenId && tokenOptions.length > 0 && !tokenOptions.some((token) => token.id === tokenId)) { toast.error('该令牌不支持当前模型'); @@ -1556,7 +645,7 @@ export default function TokenRoutes() { try { await api.updateChannel(channelId, { tokenId: tokenId || null }); toast.success('通道令牌已更新'); - await load(); + await loadChannels(routeId, true); } catch (e: any) { toast.error(e.message || '更新令牌失败'); } finally { @@ -1564,883 +653,438 @@ export default function TokenRoutes() { } }; - const handleChannelDragEnd = async (route: RouteRow, event: DragEndEvent) => { - if (savingPriorityByRoute[route.id]) return; + const handleChannelDragEnd = async (routeId: number, event: DragEndEvent) => { + if (savingPriorityByRoute[routeId]) return; const { active, over } = event; if (!over || active.id === over.id) return; - const channels = route.channels || []; + const channels = channelsByRouteId[routeId] || []; const oldIndex = channels.findIndex((channel) => channel.id === Number(active.id)); const newIndex = channels.findIndex((channel) => channel.id === Number(over.id)); if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) return; const previousChannels = [...channels]; - const reordered = arrayMove(channels, oldIndex, newIndex).map((channel: RouteChannel, index: number) => ({ + const reordered = arrayMove(channels, oldIndex, newIndex).map((channel, index) => ({ ...channel, priority: index, })); - setRoutes((prev) => - prev.map((item) => (item.id === route.id ? { ...item, channels: reordered } : item)), - ); - setSavingPriorityByRoute((prev) => ({ ...prev, [route.id]: true })); + setChannels(routeId, reordered); + setSavingPriorityByRoute((prev) => ({ ...prev, [routeId]: true })); try { await api.batchUpdateChannels( - reordered.map((channel: RouteChannel) => ({ + reordered.map((channel) => ({ id: channel.id, priority: channel.priority, })), ); - if (isExactModelPattern(route.modelPattern)) { + const route = routeSummaries.find((r) => r.id === routeId); + if (route && isExactModelPattern(route.modelPattern)) { try { const res = await api.getRouteDecision(route.modelPattern); setDecisionByRoute((prev) => ({ ...prev, - [route.id]: (res?.decision || null) as RouteDecision | null, + [routeId]: (res?.decision || null) as RouteDecision | null, })); } catch { // ignore route decision refresh failures after reorder } } } catch (e: any) { - setRoutes((prev) => - prev.map((item) => (item.id === route.id ? { ...item, channels: previousChannels } : item)), - ); + setChannels(routeId, previousChannels); toast.error(e.message || '保存通道优先级失败,已回滚'); } finally { - setSavingPriorityByRoute((prev) => ({ ...prev, [route.id]: false })); + setSavingPriorityByRoute((prev) => ({ ...prev, [routeId]: false })); } }; + const toggleExpand = async (routeId: number) => { + const isCurrentlyExpanded = expandedRouteIds.includes(routeId); + if (isCurrentlyExpanded) { + setExpandedRouteIds((prev) => prev.filter((id) => id !== routeId)); + } else { + setExpandedRouteIds((prev) => [...prev, routeId]); + // Load channels on demand + if (!channelsByRouteId[routeId]) { + try { + await loadChannels(routeId); + } catch { + toast.error('加载通道失败'); + } + } + } + }; + + const missingTokenSiteItemsByRouteId = useMemo(() => { + const result: Record = {}; + for (const routeId of Object.keys(routeMissingTokenIndex).map(Number)) { + const missingTokenHints = routeMissingTokenIndex[routeId] || []; + const siteMap = new Map(); + for (const hint of missingTokenHints) { + for (const account of hint.accounts) { + if (!Number.isFinite(account.accountId) || account.accountId <= 0) continue; + const siteName = (account.siteName || '').trim() || `site-${account.siteId || 'unknown'}`; + const key = `${account.siteId || 0}::${siteName.toLowerCase()}`; + const accountLabel = account.username || `account-${account.accountId}`; + const existing = siteMap.get(key); + if (!existing) { + siteMap.set(key, { key, siteName, accountId: account.accountId, accountLabel }); + continue; + } + if (account.accountId < existing.accountId) { + existing.accountId = account.accountId; + existing.accountLabel = accountLabel; + } + } + } + result[routeId] = Array.from(siteMap.values()).sort((a, b) => ( + a.siteName.localeCompare(b.siteName, undefined, { sensitivity: 'base' }) + )); + } + return result; + }, [routeMissingTokenIndex]); + + // Stable callbacks for RouteCard memo (use refs to avoid dependency on closure variables) + const toggleExpandRef = useRef(toggleExpand); + toggleExpandRef.current = toggleExpand; + const stableToggleExpand = useCallback((routeId: number) => toggleExpandRef.current(routeId), []); + const handleEditRouteRef = useRef(handleEditRoute); + handleEditRouteRef.current = handleEditRoute; + const stableEditRoute = useCallback((route: RouteSummaryRow) => handleEditRouteRef.current(route), []); + const handleDeleteRouteRef = useRef(handleDeleteRoute); + handleDeleteRouteRef.current = handleDeleteRoute; + const stableDeleteRoute = useCallback((routeId: number) => { handleDeleteRouteRef.current(routeId); }, []); + const handleToggleEnabledRef = useRef(handleToggleRouteEnabled); + handleToggleEnabledRef.current = handleToggleRouteEnabled; + const stableToggleEnabled = useCallback((route: RouteSummaryRow) => { handleToggleEnabledRef.current(route); }, []); + const stableTokenDraftChange = useCallback( + (channelId: number, tokenId: number) => setChannelTokenDraft((prev) => ({ ...prev, [channelId]: tokenId })), + [], + ); + const stableAddChannel = useCallback((routeId: number) => setAddChannelModalRouteId(routeId), []); + const stableToggleSourceGroup = useCallback( + (groupKey: string) => setExpandedSourceGroupMap((prev) => ({ ...prev, [groupKey]: !prev[groupKey] })), + [], + ); + const handleChannelTokenSaveRef = useRef(handleChannelTokenSave); + handleChannelTokenSaveRef.current = handleChannelTokenSave; + const stableChannelTokenSave = useCallback( + (routeId: number, channelId: number, accountId: number) => handleChannelTokenSaveRef.current(routeId, channelId, accountId), + [], + ); + const handleDeleteChannelRef = useRef(handleDeleteChannel); + handleDeleteChannelRef.current = handleDeleteChannel; + const stableDeleteChannel = useCallback( + (channelId: number, routeId: number) => handleDeleteChannelRef.current(channelId, routeId), + [], + ); + const handleChannelDragEndRef = useRef(handleChannelDragEnd); + handleChannelDragEndRef.current = handleChannelDragEnd; + const stableChannelDragEnd = useCallback( + (routeId: number, event: DragEndEvent) => handleChannelDragEndRef.current(routeId, event), + [], + ); + const handleCreateTokenRef = useRef(handleCreateTokenForMissingAccount); + handleCreateTokenRef.current = handleCreateTokenForMissingAccount; + const stableCreateTokenForMissing = useCallback( + (accountId: number, modelName: string) => handleCreateTokenRef.current(accountId, modelName), + [], + ); + + const addChannelModalRoute = addChannelModalRouteId + ? routeSummaries.find((r) => r.id === addChannelModalRouteId) || null + : null; + + const handleAddChannelSuccess = async () => { + if (!addChannelModalRouteId) return; + // Reload channels for this route + await loadChannels(addChannelModalRouteId, true); + // Refresh summary to update channel count + await load(); + }; + return ( -
- {!isMobile && filterPanelPresence.shouldRender && renderFilterPanelContent()} - {isMobile && ( - -
- {filterPanelPresence.shouldRender && renderFilterPanelContent()} -
-
- )} - -
-
-
-

- {activeGroupFilter === '__all__' - ? tr('群组路由') - : (activeGroupRoute - ? `${resolveRouteTitle(activeGroupRoute)} ${tr('路由')}` - : (activeBrand && activeBrand !== '__other__' ? `${activeBrand} ${tr('路由')}` : tr('模型路由')))} - - {tr('共')} {filteredRoutes.length} {tr('条路由')} - -

- {(activeBrand || activeGroupFilter !== null) && ( -

- {activeGroupFilter === '__all__' - ? tr('查看群组路由') - : (activeGroupRoute - ? `${tr('查看')} ${resolveRouteTitle(activeGroupRoute)} ${tr('群组路由')}` - : (activeBrand === '__other__' ? tr('查看未归类品牌路由') : `${tr('查看')} ${activeBrand} ${tr('品牌路由')}`))} -

- )} -
- -
- {isMobile ? ( - - ) : (filterCollapsed && ( - - ))} - - - - - - -
+
+ {/* Toolbar: search + sort + actions */} +
+
+ + + + setSearch(e.target.value)} + placeholder={tr('搜索模型路由...')} + />
-
-
- +
+
+ { + const nextSortBy = nextValue as RouteSortBy; + setSortBy(nextSortBy); + setSortDir(nextSortBy === 'modelPattern' ? 'asc' : 'desc'); + }} + options={[ + { value: 'modelPattern', label: tr('模型名称') }, + { value: 'channelCount', label: tr('通道数量') }, + ]} + placeholder={tr('排序字段')} + /> +
+ +
+ +
+ + + + + +
+ + + {tr('共')} {filteredRoutes.length} {tr('条路由')} + +
+ + {/* Collapsible filter panel */} + {isMobile ? ( + <> + + setShowFilters(false)}> +
+ setShowFilters(false)} + /> +
+
+ + ) : ( + setFilterCollapsed((prev) => !prev)} + /> + )} + + {/* Info tip */} +
+ {tr('系统会根据模型可用性自动生成路由。精确模型路由会自动过滤只支持该模型的账号和令牌。优先级 P0 最高,数字越大优先级越低。选中概率表示请求到达时该通道被选中的概率。成本来源优先级为:实测成本 → 账号配置成本 → 目录参考价 → 默认回退单价。')} +
+ + {/* Manual route panel */} + + + {/* Route card grid */} +
+ {visibleRoutes.map((route) => { + const isExpanded = expandedRouteIds.includes(route.id); + + if (isMobile) { + return ( + + {route.enabled ? tr('启用') : tr('禁用')} + + )} + > + + + +
+ +
+
+ ); + } + + return ( + + ); + })} +
+ + {filteredRoutes.length > 0 && visibleRouteCount < filteredRoutes.length && ( +
+ {tr('当前已加载路由')} {visibleRouteCount} / {filteredRoutes.length} +
+ )} + + {filteredRoutes.length === 0 && ( +
+
+ - setSearch(e.target.value)} - placeholder={tr('搜索模型路由...')} - /> -
- -
-
- { - const nextSortBy = nextValue as RouteSortBy; - setSortBy(nextSortBy); - setSortDir(nextSortBy === 'modelPattern' ? 'asc' : 'desc'); - }} - options={[ - { value: 'modelPattern', label: tr('模型名称') }, - { value: 'channelCount', label: tr('通道数量') }, - ]} - placeholder={tr('排序字段')} - /> +
{routeSummaries.length === 0 ? '暂无路由' : '没有匹配的路由'}
+
+ {routeSummaries.length === 0 + ? '点击"自动重建"可按当前模型可用性生成路由。' + : '请调整品牌筛选、搜索词或排序条件。'}
-
+ )} -
- {tr('系统会根据模型可用性自动生成路由。精确模型路由会自动过滤只支持该模型的账号和令牌。优先级 P0 最高,数字越大优先级越低。选中概率表示请求到达时该通道被选中的概率。成本来源优先级为:实测成本 → 账号配置成本 → 目录参考价 → 默认回退单价。')} - {decisionAutoSkipped ? ` ${tr('当前精确路由')} ${exactRouteCount} ${tr('条,为避免首屏卡顿,默认不自动计算概率,点击“加载选择解释”后按需获取。')}` : ''} -
- - {manualPanelPresence.shouldRender && ( -
-
- {editingRouteId - ? tr('编辑群组路由名称、图标和模型匹配规则;若修改正则,将按当前可用模型重新匹配自动通道。') - : tr('用于创建群组路由(聚合多个上游模型为一个下游模型名,即模型重定向);自动路由仍会保持开启。')} -
-
-
- 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)', - }} - /> -
- setForm((f) => ({ ...f, displayIcon: nextValue }))} - options={routeIconSelectOptions} - placeholder={tr('图标(可选,选择品牌图标)')} - emptyLabel={tr('暂无可选品牌图标')} - /> -
-
- 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)', - }} - /> -
- {tr('正则请使用 re: 前缀;例如 re:^claude-(opus|sonnet)-4-6$')} -
- {modelPatternError && ( -
- {modelPatternError} -
- )} - {form.modelPattern.trim() && !modelPatternError && ( -
-
- {tr('规则预览:命中样本')} {previewMatchedModels.length} / {previewModelSamples.length} -
- - {previewModelSamples.length === 0 ? ( -
- {tr('当前暂无可预览模型,请先同步模型。')} -
- ) : previewMatchedModels.length === 0 ? ( -
- {tr('当前规则未命中任何样本模型。')} -
- ) : ( -
- {previewMatchedModels.slice(0, 12).map((modelName) => ( - - {modelName} - - ))} -
- )} - - {previewMatchedModels.length > 12 && ( -
- {tr('仅展示前 12 个命中样本。')} -
- )} - -
- )} - -
- - {editingRouteId ? ( - - ) : null} -
-
-
- )} - -
- {visibleRoutes.map((route, i) => { - const candidateView = getRouteCandidateView(route.id); - const routeCandidates = candidateView.routeCandidates; - const accountOptions = candidateView.accountOptions; - const selectedAccountId = channelDraftByRoute[route.id]?.accountId || 0; - const selectedDraftTokenValue = (() => { - const draft = channelDraftByRoute[route.id]; - if (!draft || !draft.tokenId) return '0'; - return `${draft.tokenId}::${draft.sourceModel || ''}`; - })(); - const draftTokenOptions = selectedAccountId - ? (candidateView.tokenOptionsByAccountId[selectedAccountId] || []) - : []; - const candidateMode = routeCandidates.length > 0; - const missingTokenHints = routeMissingTokenIndex[route.id] || []; - const missingTokenSiteItems = (() => { - const siteMap = new Map(); - for (const hint of missingTokenHints) { - for (const account of hint.accounts) { - if (!Number.isFinite(account.accountId) || account.accountId <= 0) continue; - const siteName = (account.siteName || '').trim() || `site-${account.siteId || 'unknown'}`; - const key = `${account.siteId || 0}::${siteName.toLowerCase()}`; - const accountLabel = account.username || `account-${account.accountId}`; - const existing = siteMap.get(key); - if (!existing) { - siteMap.set(key, { - key, - siteName, - accountId: account.accountId, - accountLabel, - }); - continue; - } - if (account.accountId < existing.accountId) { - existing.accountId = account.accountId; - existing.accountLabel = accountLabel; - } - } - } - return Array.from(siteMap.values()).sort((a, b) => ( - a.siteName.localeCompare(b.siteName, undefined, { sensitivity: 'base' }) - )); - })(); - const routeDecision = decisionByRoute[route.id] || null; - const exactRoute = isExactModelPattern(route.modelPattern); - const decisionMap = new Map( - (routeDecision?.candidates || []).map((candidate) => [candidate.channelId, candidate]), - ); - const channelGroups = (() => { - const groups = new Map(); - for (const channel of route.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, channels]) => ({ - sourceModel: sourceModel === '__ungrouped__' ? '' : sourceModel, - channels, - })); - })(); - const routeIcon = resolveRouteIcon(route); - const routeBrand = routeBrandById.get(route.id) || null; - - const routeCard = ( -
-
-
- - {routeIcon.kind === 'brand' ? ( - - ) : routeIcon.kind === 'text' ? ( - - {routeIcon.value} - - ) : routeBrand ? ( - - ) : ( - - )} - {resolveRouteTitle(route)} - - {route.displayName && route.displayName.trim().length > 0 && route.displayName.trim() !== route.modelPattern ? ( - - {route.modelPattern} - - ) : null} - - - {route.channels?.length || 0} {tr('通道')} - - {candidateMode && ( - - {tr('按模型过滤')} - - )} - {missingTokenSiteItems.length > 0 && ( - - 待注册站点 {missingTokenSiteItems.length} - - )} - {channelGroups.length > 1 && ( - - {channelGroups.length} 来源分组 - - )} - {savingPriorityByRoute[route.id] ? ( - - {tr('排序保存中')} - - ) : null} -
- -
- {!exactRoute ? ( - - ) : null} - -
-
- - {!exactRoute && ( -
- {tr('通配符路由按请求实时决策;概率解释在当前路由内统一估算。')} -
- )} - -
- handleRouteAccountChange(route, Number.parseInt(nextValue, 10) || 0)} - options={[ - { value: '0', label: tr('选择账号') }, - ...accountOptions.map((option) => ({ - value: String(option.id), - label: option.label, - })), - ]} - placeholder={tr('选择账号')} - /> - - { - if (nextValue === '0') { - handleRouteTokenChange(route.id, 0, ''); - return; - } - const [tokenRaw, ...sourceParts] = nextValue.split('::'); - const nextTokenId = Number.parseInt(tokenRaw, 10) || 0; - const sourceModel = sourceParts.join('::'); - handleRouteTokenChange(route.id, nextTokenId, sourceModel); - }} - disabled={!selectedAccountId} - options={[ - { value: '0', label: '选择令牌(可选)' }, - ...draftTokenOptions.map((token) => ({ - value: `${token.id}::${token.sourceModel || ''}`, - label: `${token.name}${token.isDefault ? '(默认)' : ''}${token.sourceModel ? ` [${token.sourceModel}]` : ''}`, - })), - ]} - placeholder="选择令牌(可选)" - /> - - -
- - {accountOptions.length === 0 && ( -
- 当前没有任何账号/令牌可用此模型,请先同步令牌与模型。 -
- )} - - {missingTokenSiteItems.length > 0 && ( -
-
- 以下站点已提供该模型,但你未注册对应站点令牌;点击站点标签可跳转创建对应站点令牌: -
-
- {missingTokenSiteItems.map((item) => ( - - ))} -
-
- )} - - {route.channels?.length > 0 ? ( -
- { - void handleChannelDragEnd(route, event); - }} - > -
- {channelGroups.map((group) => ( -
- {(() => { - const groupKey = buildSourceGroupKey(route.id, group.sourceModel || ''); - const supportsCollapse = !exactRoute && !!group.sourceModel; - const isGroupExpanded = supportsCollapse ? !!expandedSourceGroupMap[groupKey] : true; - - return ( - <> - {group.sourceModel ? ( - supportsCollapse ? ( - - ) : ( -
- 来源模型 - - {group.sourceModel} - - {group.channels.length} 通道 -
- ) - ) : null} - - - channel.id)} - strategy={verticalListSortingStrategy} - > - {group.channels.map((channel) => { - const tokenOptions = candidateView.tokenOptionsByAccountId[channel.accountId] || []; - const activeTokenId = channelTokenDraft[channel.id] ?? channel.tokenId ?? 0; - - return ( - - setChannelTokenDraft((prev) => ({ ...prev, [channelId]: tokenId })) - } - onSaveToken={() => handleChannelTokenSave(route, channel.id, channel.accountId)} - onDeleteChannel={() => handleDeleteChannel(channel.id)} - /> - ); - })} - - - {!isGroupExpanded ? ( -
- 已收起,点击展开查看通道 -
- ) : null} - - ); - })()} -
- ))} -
-
-
- ) : ( -
暂无通道
- )} -
- ); - - const isExpanded = expandedRouteIds.includes(route.id); - - return isMobile ? ( - - {route.enabled ? tr('启用') : tr('禁用')} - - )} - > - - - - {isExpanded ? ( -
- {routeCard} -
- ) : null} -
- -
-
- ) : ( - routeCard - ); - })} - - {filteredRoutes.length > 0 && visibleRouteCount < filteredRoutes.length && ( -
-
- {tr('当前已加载路由')} {visibleRouteCount} / {filteredRoutes.length} -
- -
- )} - - {filteredRoutes.length === 0 && ( -
-
- - - -
{routes.length === 0 ? '暂无路由' : '没有匹配的路由'}
-
- {routes.length === 0 - ? '点击“自动重建”可按当前模型可用性生成路由。' - : '请调整品牌筛选、搜索词或排序条件。'} -
-
-
- )} -
-
-
- ); + {/* Add channel modal */} + {addChannelModalRoute && ( + setAddChannelModalRouteId(null)} + routeId={addChannelModalRoute.id} + routeTitle={resolveRouteTitle(addChannelModalRoute)} + candidateView={getRouteCandidateView(addChannelModalRoute.id)} + onSuccess={handleAddChannelSuccess} + missingTokenHints={routeMissingTokenIndex[addChannelModalRoute.id] || []} + onCreateTokenForMissing={handleCreateTokenForMissingAccount} + existingChannelAccountIds={new Set((channelsByRouteId[addChannelModalRoute.id] || []).map((c) => c.accountId))} + /> + )} +
+ ); } diff --git a/src/web/pages/token-routes/AddChannelModal.tsx b/src/web/pages/token-routes/AddChannelModal.tsx new file mode 100644 index 0000000..3d3c608 --- /dev/null +++ b/src/web/pages/token-routes/AddChannelModal.tsx @@ -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; +}; + +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>({}); + 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(); + 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 ( + + + {tr('已选')} {selectedCount} {tr('个通道')} + +
+ + +
+
+ } + > +
+
+ + + + setSearchQuery(e.target.value)} + placeholder={tr('搜索账号...')} + /> +
+ +
+ {filteredAccounts.length === 0 && filteredMissingAccounts.length === 0 ? ( +
+ {candidateView.accountOptions.length === 0 && missingAccounts.length === 0 + ? tr('当前没有可用的账号,请确认已有账号的令牌支持调用此模型') + : tr('没有匹配的账号')} +
+ ) : ( + <> + {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 ( +
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', + }} + > +
+ + {account.label} + {isExisting && ( + {tr('已添加')} + )} +
+ + {isSelected && tokens.length > 0 && ( +
e.stopPropagation()}> +
{tr('令牌')}:
+ { + 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('选择令牌')} + /> +
+ )} +
+ ); + })} + + {/* Missing token hints */} + {filteredMissingAccounts.length > 0 && ( +
+
+ {tr('以下账号可用此模型但缺少令牌')}: +
+ {filteredMissingAccounts.map((item) => ( +
+ {item.label} + {onCreateTokenForMissing && ( + + )} +
+ ))} +
+ )} + + )} +
+
+ + ); +} diff --git a/src/web/pages/token-routes/ManualRoutePanel.tsx b/src/web/pages/token-routes/ManualRoutePanel.tsx new file mode 100644 index 0000000..c8355e1 --- /dev/null +++ b/src/web/pages/token-routes/ManualRoutePanel.tsx @@ -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 ( +
+
+ {editingRouteId + ? tr('编辑群组路由名称、图标和模型匹配规则;若修改正则,将按当前可用模型重新匹配自动通道。') + : tr('用于创建群组路由(聚合多个上游模型为一个下游模型名,即模型重定向);自动路由仍会保持开启。')} +
+
+
+ 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)', + }} + /> +
+ setForm((f) => ({ ...f, displayIcon: nextValue }))} + options={routeIconSelectOptions} + placeholder={tr('图标(可选,选择品牌图标)')} + emptyLabel={tr('暂无可选品牌图标')} + /> +
+
+ 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)', + }} + /> +
+ {tr('正则请使用 re: 前缀;例如 re:^claude-(opus|sonnet)-4-6$')} +
+ {modelPatternError && ( +
+ {modelPatternError} +
+ )} + {form.modelPattern.trim() && !modelPatternError && ( +
+
+ {tr('规则预览:命中样本')} {previewMatchedModels.length} / {previewModelSamples.length} +
+ + {previewModelSamples.length === 0 ? ( +
+ {tr('当前暂无可预览模型,请先同步模型。')} +
+ ) : previewMatchedModels.length === 0 ? ( +
+ {tr('当前规则未命中任何样本模型。')} +
+ ) : ( +
+ {previewMatchedModels.slice(0, 12).map((modelName) => ( + + {modelName} + + ))} +
+ )} + + {previewMatchedModels.length > 12 && ( +
+ {tr('仅展示前 12 个命中样本。')} +
+ )} +
+ )} + +
+ + {editingRouteId ? ( + + ) : null} +
+
+
+ ); +} diff --git a/src/web/pages/token-routes/RouteCard.tsx b/src/web/pages/token-routes/RouteCard.tsx new file mode 100644 index 0000000..fc751bd --- /dev/null +++ b/src/web/pages/token-routes/RouteCard.tsx @@ -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; + updatingChannel: Record; + 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; + onToggleSourceGroup: (groupKey: string) => void; +}; + +function AnimatedCollapseSection({ open, children }: { open: boolean; children: ReactNode }) { + const presence = useAnimatedVisibility(open, 220); + if (!presence.shouldRender) return null; + return ( +
+
+ {children} +
+
+ ); +} + +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( + (routeDecision?.candidates || []).map((c) => [c.channelId, c]), + ); + + const channelGroups = (() => { + if (!channels || channels.length === 0) return []; + const groups = new Map(); + 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 ( +
onToggleExpand(route.id)} + style={{ cursor: 'pointer' }} + > +
+ + {routeIcon.kind === 'brand' ? ( + + ) : routeIcon.kind === 'text' ? ( + {routeIcon.value} + ) : brand ? ( + + ) : ( + + )} + + + + {title} + + + {route.displayName && route.displayName.trim() !== route.modelPattern ? ( + {route.modelPattern} + ) : null} + + + + + {route.channelCount} {tr('通道')} + + + + + +
+
+ ); + } + + // Expanded card + return ( +
+ {/* Header */} +
+
+ + {routeIcon.kind === 'brand' ? ( + + ) : routeIcon.kind === 'text' ? ( + + {routeIcon.value} + + ) : brand ? ( + + ) : ( + + )} + {title} + + {route.displayName && route.displayName.trim() !== route.modelPattern ? ( + {route.modelPattern} + ) : null} + + + {route.channelCount} {tr('通道')} + + {savingPriority && ( + {tr('排序保存中')} + )} +
+ +
+ {!exactRoute && ( + + )} + + +
+
+ + {!exactRoute && ( +
+ {tr('通配符路由按请求实时决策;概率解释在当前路由内统一估算。')} +
+ )} + + {/* Missing token hints + Add channel button */} +
+ {missingTokenSiteItems.length > 0 ? ( +
+ {tr('待注册站点')}: + {missingTokenSiteItems.map((item) => ( + + ))} +
+ ) :
} + +
+ + {/* Channel list */} + {loadingChannels ? ( +
+ + {tr('加载通道中...')} +
+ ) : channels && channels.length > 0 ? ( +
+ onChannelDragEnd(route.id, event)}> +
+ {channelGroups.map((group) => { + const groupKey = buildSourceGroupKey(route.id, group.sourceModel || ''); + const supportsCollapse = !exactRoute && !!group.sourceModel; + const isGroupExpanded = supportsCollapse ? !!expandedSourceGroupMap[groupKey] : true; + + return ( +
+ {group.sourceModel ? ( + supportsCollapse ? ( + + ) : ( +
+ 来源模型 + + {group.sourceModel} + + {group.channels.length} 通道 +
+ ) + ) : null} + + + c.id)} strategy={verticalListSortingStrategy}> + {group.channels.map((channel) => { + const tokenOptions = candidateView.tokenOptionsByAccountId[channel.accountId] || []; + const activeTokenId = channelTokenDraft[channel.id] ?? channel.tokenId ?? 0; + return ( + onSaveToken(route.id, channel.id, channel.accountId)} + onDeleteChannel={() => onDeleteChannel(channel.id, route.id)} + /> + ); + })} + + + {!isGroupExpanded && ( +
+ 已收起,点击展开查看通道 +
+ )} +
+ ); + })} +
+
+
+ ) : ( +
暂无通道
+ )} +
+ ); +} + +const RouteCard = memo(RouteCardInner); +export default RouteCard; diff --git a/src/web/pages/token-routes/RouteFilterBar.tsx b/src/web/pages/token-routes/RouteFilterBar.tsx new file mode 100644 index 0000000..b0bf30a --- /dev/null +++ b/src/web/pages/token-routes/RouteFilterBar.tsx @@ -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 ( + + ); +} + +function FilterRow({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ {label} +
{children}
+
+ ); +} + +function ActiveFilterSummary({ + activeBrand, + activeSite, + activeGroupFilter, + activeEndpointType, +}: Pick) { + 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 {tr('全部')}; + return {tags.join(', ')}; +} + +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 ( +
+ {/* Collapsed summary */} + + + {/* Expanded panel */} + {expandPresence.shouldRender && ( +
+ {/* Brand row */} + + ✦} + onClick={() => setActiveBrand(null)} + /> + {brandList.list.map(([brandName, { count, brand }]) => ( + } + onClick={() => setActiveBrand(activeBrand === brandName ? null : brandName)} + /> + ))} + {brandList.otherCount > 0 && ( + ?} + onClick={() => setActiveBrand(activeBrand === '__other__' ? null : '__other__')} + /> + )} + + + {/* Site row */} + {siteList.length > 0 && ( + + ⚡} + onClick={() => setActiveSite(null)} + /> + {siteList.map(([siteName, { count }]) => ( + + {siteAvatarLetters(siteName)} + + } + onClick={() => setActiveSite(activeSite === siteName ? null : siteName)} + /> + ))} + + )} + + {/* Group row */} + + ◎} + onClick={() => setActiveGroupFilter(activeGroupFilter === '__all__' ? null : '__all__')} + /> + {groupRouteList.map((groupRoute) => ( + + ) : groupRoute.icon.kind === 'text' ? ( + {groupRoute.icon.value} + ) : groupRoute.brand ? ( + + ) : ( + + ) + } + onClick={() => setActiveGroupFilter(activeGroupFilter === groupRoute.id ? null : groupRoute.id)} + /> + ))} + + + {/* Endpoint type row */} + + ⚙} + onClick={() => setActiveEndpointType(null)} + /> + {endpointTypeList.map(([endpointType, count]) => { + const iconModel = resolveEndpointTypeIconModel(endpointType); + return ( + : ⚙} + onClick={() => setActiveEndpointType(activeEndpointType === endpointType ? null : endpointType)} + /> + ); + })} + {endpointTypeList.length === 0 && ( + {tr('暂无接口能力数据')} + )} + + +
+ +
+
+ )} +
+ ); +} diff --git a/src/web/pages/token-routes/SortableChannelRow.tsx b/src/web/pages/token-routes/SortableChannelRow.tsx new file mode 100644 index 0000000..cbe7643 --- /dev/null +++ b/src/web/pages/token-routes/SortableChannelRow.tsx @@ -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 ( +
+
+ + + + P{channel.priority ?? 0} + + + + {channel.account?.username || `account-${channel.accountId}`} + + + + {channel.site?.name || 'unknown'} + + + + {channel.token?.name || '默认令牌'} + + + {channel.sourceModel ? ( + + {channel.sourceModel} + + ) : null} + + {channel.manualOverride ? ( + + 手动配置 + + ) : null} + +
+ 选中概率 +
+
+
+
+ 0 ? 'var(--color-text-secondary)' : decisionState.reasonColor, + fontVariantNumeric: 'tabular-nums', + whiteSpace: 'nowrap', + }} + > + {decisionState.probability.toFixed(1)}% + +
+ + 成功/失败 + + {channel.successCount || 0} + / + {channel.failCount || 0} + +
+
+ +
+
+ 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="默认令牌" + /> +
+ +
+ + +
+ ); +} diff --git a/src/web/pages/token-routes/types.ts b/src/web/pages/token-routes/types.ts new file mode 100644 index 0000000..3d71960 --- /dev/null +++ b/src/web/pages/token-routes/types.ts @@ -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; +}; diff --git a/src/web/pages/token-routes/useRouteChannels.ts b/src/web/pages/token-routes/useRouteChannels.ts new file mode 100644 index 0000000..b708fb2 --- /dev/null +++ b/src/web/pages/token-routes/useRouteChannels.ts @@ -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>({}); + const [loadingChannelsByRouteId, setLoadingChannelsByRouteId] = useState>({}); + 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, + }; +} diff --git a/src/web/pages/token-routes/utils.ts b/src/web/pages/token-routes/utils.ts new file mode 100644 index 0000000..457e719 --- /dev/null +++ b/src/web/pages/token-routes/utils.ts @@ -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 = { + openai: 'chatgpt', + gemini: 'gemini', + anthropic: 'claude', + anthroic: 'claude', + claude: 'claude', +}; + +export const PLATFORM_ENDPOINT_FALLBACK_MAP: Record = { + 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 = { + 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(); + +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(); +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): string { + const title = (route.displayName || '').trim(); + return title || route.modelPattern; +} + +export function resolveRouteBrand(route: Pick): 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): { 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)', + }; +} diff --git a/src/web/pages/tokenRoutes.group-collapse.test.tsx b/src/web/pages/tokenRoutes.group-collapse.test.tsx index 62fd7dd..c1e3bd6 100644 --- a/src/web/pages/tokenRoutes.group-collapse.test.tsx +++ b/src/web/pages/tokenRoutes.group-collapse.test.tsx @@ -6,7 +6,8 @@ import TokenRoutes from './TokenRoutes.js'; const { apiMock, getBrandMock } = vi.hoisted(() => ({ apiMock: { - getRoutes: vi.fn(), + getRoutesSummary: vi.fn(), + getRouteChannels: vi.fn(), getModelTokenCandidates: vi.fn(), getRouteDecisionsBatch: vi.fn(), getRouteWideDecisionsBatch: vi.fn(), @@ -67,6 +68,7 @@ describe('TokenRoutes grouped source models', () => { getBrandMock.mockReset(); getBrandMock.mockReturnValue(null); apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} }); + apiMock.getRouteChannels.mockResolvedValue([]); apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} }); apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} }); apiMock.updateRoute.mockResolvedValue({}); @@ -78,46 +80,31 @@ describe('TokenRoutes grouped source models', () => { }); it('collapses source-model groups by default for wildcard routes', async () => { - apiMock.getRoutes.mockResolvedValue([ + const channels = [ { - id: 1, - modelPattern: 're:^claude-opus-(4-6|4-5)$', - displayName: 'claude-opus-4-6', - enabled: true, - channels: [ - { - id: 11, - accountId: 101, - tokenId: 1001, - sourceModel: 'claude-opus-4-5', - priority: 0, - weight: 1, - enabled: true, - 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 }, - }, - { - 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 }, - }, - ], + id: 11, accountId: 101, tokenId: 1001, sourceModel: 'claude-opus-4-5', + priority: 0, weight: 1, enabled: true, 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 }, + }, + { + 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.getRoutesSummary.mockResolvedValue([ + { + id: 1, modelPattern: 're:^claude-opus-(4-6|4-5)$', displayName: 'claude-opus-4-6', + displayIcon: null, modelMapping: null, enabled: true, + channelCount: 2, enabledChannelCount: 2, siteNames: ['site-a', 'site-b'], + decisionSnapshot: null, decisionRefreshedAt: null, }, ]); + apiMock.getRouteChannels.mockResolvedValue(channels); let root: ReturnType | null = null; try { @@ -132,42 +119,52 @@ describe('TokenRoutes grouped source models', () => { }); await flushMicrotasks(); + // Card is collapsed by default, so channel detail is not visible const text = collectText(root.root); - expect(text).toContain('claude-opus-4-5'); expect(text).toContain('claude-opus-4-6'); expect(text).not.toContain('user_a'); 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 { root?.unmount(); } }); it('expands a source-model group after user click', async () => { - apiMock.getRoutes.mockResolvedValue([ + const channels = [ { - id: 1, - modelPattern: 're:^claude-opus-(4-6|4-5)$', - displayName: 'claude-opus-4-6', - enabled: true, - channels: [ - { - id: 11, - accountId: 101, - tokenId: 1001, - sourceModel: 'claude-opus-4-5', - priority: 0, - weight: 1, - enabled: true, - 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 }, - }, - ], + id: 11, accountId: 101, tokenId: 1001, sourceModel: 'claude-opus-4-5', + priority: 0, weight: 1, enabled: true, 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.getRoutesSummary.mockResolvedValue([ + { + id: 1, modelPattern: 're:^claude-opus-(4-6|4-5)$', displayName: 'claude-opus-4-6', + displayIcon: null, modelMapping: null, enabled: true, + channelCount: 1, enabledChannelCount: 1, siteNames: ['site-a'], + decisionSnapshot: null, decisionRefreshedAt: null, }, ]); + apiMock.getRouteChannels.mockResolvedValue(channels); let root: ReturnType | null = null; try { @@ -182,6 +179,15 @@ describe('TokenRoutes grouped source models', () => { }); 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'); 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 () => { - apiMock.getRoutes.mockResolvedValue([ + apiMock.getRoutesSummary.mockResolvedValue([ { - id: 1, - modelPattern: 'gpt-5.2-codex', - displayName: 'gpt-5.2-codex', - enabled: true, - channels: [], + id: 1, modelPattern: 'gpt-5.2-codex', displayName: 'gpt-5.2-codex', + displayIcon: null, modelMapping: null, enabled: true, + channelCount: 0, enabledChannelCount: 0, siteNames: [], + decisionSnapshot: null, decisionRefreshedAt: null, }, ]); apiMock.getModelTokenCandidates.mockResolvedValue({ models: {}, modelsWithoutToken: { '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 | null = null; try { @@ -233,6 +234,15 @@ describe('TokenRoutes grouped source models', () => { }); 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'); expect(String(siteButton.props.className || '')).toContain('missing-token-site-tag'); } 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 () => { - apiMock.getRoutes.mockResolvedValue([ + apiMock.getRoutesSummary.mockResolvedValue([ { - id: 1, - modelPattern: 'gpt-5.2-codex', - displayName: 'gpt-5.2-codex', - enabled: true, - channels: [], + id: 1, modelPattern: 'gpt-5.2-codex', displayName: 'gpt-5.2-codex', + displayIcon: null, modelMapping: null, enabled: true, + channelCount: 0, enabledChannelCount: 0, siteNames: [], + decisionSnapshot: null, decisionRefreshedAt: null, }, ]); apiMock.getModelTokenCandidates.mockResolvedValue({ models: {}, modelsWithoutToken: { '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 () => { - apiMock.getRoutes.mockResolvedValue([ + apiMock.getRoutesSummary.mockResolvedValue([ { - id: 1, - modelPattern: 'gpt-5.2-codex', - displayName: 'gpt-5.2-codex', - enabled: true, - 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 }, - }, - ], + id: 1, modelPattern: 'gpt-5.2-codex', displayName: 'gpt-5.2-codex', + displayIcon: null, modelMapping: null, enabled: true, + channelCount: 1, enabledChannelCount: 1, siteNames: ['Wong'], + decisionSnapshot: null, decisionRefreshedAt: null, }, ]); apiMock.getModelTokenCandidates.mockResolvedValue({ @@ -331,6 +318,15 @@ describe('TokenRoutes grouped source models', () => { }); 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); expect(text).toContain('chatgpt'); expect(text).toContain('gemini'); @@ -351,13 +347,12 @@ describe('TokenRoutes grouped source models', () => { } return null; }); - apiMock.getRoutes.mockResolvedValue([ + apiMock.getRoutesSummary.mockResolvedValue([ { - id: 91, - modelPattern: 'nvidia/vila', - displayName: 'nvidia/vila', - enabled: true, - channels: [], + id: 91, modelPattern: 'nvidia/vila', displayName: 'nvidia/vila', + displayIcon: null, modelMapping: null, enabled: true, + channelCount: 0, enabledChannelCount: 0, siteNames: [], + decisionSnapshot: null, decisionRefreshedAt: null, }, ]); @@ -374,37 +369,32 @@ describe('TokenRoutes grouped source models', () => { }); 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)).not.toContain('查看未归类品牌路由'); } finally { root?.unmount(); } }); 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, - modelPattern: 'gpt-4o-mini', - displayName: 'gpt-4o-mini', - enabled: true, - 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 }, - }, - ], + id: 1, modelPattern: 'gpt-4o-mini', displayName: 'gpt-4o-mini', + displayIcon: null, modelMapping: null, enabled: true, + channelCount: 1, enabledChannelCount: 1, siteNames: ['site-a'], + decisionSnapshot: null, decisionRefreshedAt: null, }, ]); apiMock.getModelTokenCandidates.mockResolvedValue({ @@ -425,22 +415,29 @@ describe('TokenRoutes grouped source models', () => { }); 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); - expect(text).toContain('接口能力'); - expect(text).toContain('openai'); + expect(text).toContain('能力'); } finally { root?.unmount(); } }); 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, - modelPattern: 'custom-model-without-channel', - displayName: 'custom-model-without-channel', - enabled: true, - channels: [], + id: 1, modelPattern: 'custom-model-without-channel', displayName: 'custom-model-without-channel', + displayIcon: null, modelMapping: null, enabled: true, + channelCount: 0, enabledChannelCount: 0, siteNames: [], + decisionSnapshot: null, decisionRefreshedAt: null, }, ]); apiMock.getModelTokenCandidates.mockResolvedValue({ @@ -461,8 +458,17 @@ describe('TokenRoutes grouped source models', () => { }); 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); - expect(text).toContain('接口能力'); + expect(text).toContain('能力'); expect(text).toContain('暂无接口能力数据'); } finally { 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 () => { - apiMock.getRoutes.mockResolvedValue([ + apiMock.getRoutesSummary.mockResolvedValue([ { - id: 1, - modelPattern: 'minimax-m2.1', - displayName: 'minimax-m2.1', - enabled: true, - channels: [], + id: 1, modelPattern: 'minimax-m2.1', displayName: 'minimax-m2.1', + displayIcon: null, modelMapping: null, enabled: true, + channelCount: 0, enabledChannelCount: 0, siteNames: [], + decisionSnapshot: null, decisionRefreshedAt: null, }, { - id: 2, - modelPattern: 'minimaxai/minimax-m2.1', - displayName: 'minimaxai/minimax-m2.1', - enabled: true, - channels: [], + id: 2, modelPattern: 'minimaxai/minimax-m2.1', displayName: 'minimaxai/minimax-m2.1', + displayIcon: null, modelMapping: null, enabled: true, + channelCount: 0, enabledChannelCount: 0, siteNames: [], + decisionSnapshot: null, decisionRefreshedAt: null, }, { - id: 3, - modelPattern: 're:^(minimax-m2\\.1|minimaxai/minimax-m2\\.1)$', - displayName: 'minimax2.1', - enabled: true, - channels: [], + id: 3, modelPattern: 're:^(minimax-m2\\.1|minimaxai/minimax-m2\\.1)$', displayName: 'minimax2.1', + displayIcon: null, modelMapping: null, enabled: true, + channelCount: 0, enabledChannelCount: 0, siteNames: [], + decisionSnapshot: null, decisionRefreshedAt: null, }, ]); @@ -516,16 +519,15 @@ describe('TokenRoutes grouped source models', () => { }); it('enters edit mode and seeds the group form with the current route values', async () => { - apiMock.getRoutes.mockResolvedValue([ + apiMock.getRoutesSummary.mockResolvedValue([ { - id: 31, - modelPattern: 're:^claude-(opus|sonnet)-4-6$', - displayName: 'claude-4-6-group', - displayIcon: 'anthropic', - enabled: true, - channels: [], + id: 31, modelPattern: 're:^claude-(opus|sonnet)-4-6$', displayName: 'claude-4-6-group', + displayIcon: 'anthropic', modelMapping: null, enabled: true, + channelCount: 0, enabledChannelCount: 0, siteNames: [], + decisionSnapshot: null, decisionRefreshedAt: null, }, ]); + apiMock.getRouteChannels.mockResolvedValue([]); let root: ReturnType | null = null; try { @@ -540,6 +542,15 @@ describe('TokenRoutes grouped source models', () => { }); 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, '编辑群组'); await act(async () => { editButton.props.onClick(); @@ -555,27 +566,24 @@ describe('TokenRoutes grouped source models', () => { }); it('updates route metadata from edit mode and reloads routes afterwards', async () => { - apiMock.getRoutes + apiMock.getRoutesSummary .mockResolvedValueOnce([ { - id: 41, - modelPattern: 're:^claude-.*$', - displayName: 'old-group', - displayIcon: '', - enabled: true, - channels: [], + id: 41, modelPattern: 're:^claude-.*$', displayName: 'old-group', + displayIcon: '', modelMapping: null, enabled: true, + channelCount: 0, enabledChannelCount: 0, siteNames: [], + decisionSnapshot: null, decisionRefreshedAt: null, }, ]) .mockResolvedValueOnce([ { - id: 41, - modelPattern: 're:^claude-.*$', - displayName: 'new-group', - displayIcon: '', - enabled: true, - channels: [], + id: 41, modelPattern: 're:^claude-.*$', displayName: 'new-group', + displayIcon: '', modelMapping: null, enabled: true, + channelCount: 0, enabledChannelCount: 0, siteNames: [], + decisionSnapshot: null, decisionRefreshedAt: null, }, ]); + apiMock.getRouteChannels.mockResolvedValue([]); let root: ReturnType | null = null; try { @@ -590,6 +598,15 @@ describe('TokenRoutes grouped source models', () => { }); 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 () => { findButtonByText(root.root, '编辑群组').props.onClick(); }); @@ -608,34 +625,31 @@ describe('TokenRoutes grouped source models', () => { displayName: 'new-group', modelPattern: 're:^claude-.*$', })); - expect(apiMock.getRoutes).toHaveBeenCalledTimes(2); + expect(apiMock.getRoutesSummary).toHaveBeenCalledTimes(2); } finally { root?.unmount(); } }); it('reloads route data after saving an edited model pattern', async () => { - apiMock.getRoutes + apiMock.getRoutesSummary .mockResolvedValueOnce([ { - id: 51, - modelPattern: 're:^claude-.*$', - displayName: 'group-a', - displayIcon: '', - enabled: true, - channels: [], + id: 51, modelPattern: 're:^claude-.*$', displayName: 'group-a', + displayIcon: '', modelMapping: null, enabled: true, + channelCount: 0, enabledChannelCount: 0, siteNames: [], + decisionSnapshot: null, decisionRefreshedAt: null, }, ]) .mockResolvedValueOnce([ { - id: 51, - modelPattern: 're:^gemini-.*$', - displayName: 'group-a', - displayIcon: '', - enabled: true, - channels: [], + id: 51, modelPattern: 're:^gemini-.*$', displayName: 'group-a', + displayIcon: '', modelMapping: null, enabled: true, + channelCount: 0, enabledChannelCount: 0, siteNames: [], + decisionSnapshot: null, decisionRefreshedAt: null, }, ]); + apiMock.getRouteChannels.mockResolvedValue([]); let root: ReturnType | null = null; try { @@ -650,6 +664,15 @@ describe('TokenRoutes grouped source models', () => { }); 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 () => { findButtonByText(root.root, '编辑群组').props.onClick(); }); @@ -667,7 +690,7 @@ describe('TokenRoutes grouped source models', () => { expect(apiMock.updateRoute).toHaveBeenCalledWith(51, expect.objectContaining({ modelPattern: 're:^gemini-.*$', })); - expect(apiMock.getRoutes).toHaveBeenCalledTimes(2); + expect(apiMock.getRoutesSummary).toHaveBeenCalledTimes(2); } finally { root?.unmount(); } diff --git a/src/web/pages/tokenRoutes.refresh-decision.test.tsx b/src/web/pages/tokenRoutes.refresh-decision.test.tsx index 0b9da89..569cd08 100644 --- a/src/web/pages/tokenRoutes.refresh-decision.test.tsx +++ b/src/web/pages/tokenRoutes.refresh-decision.test.tsx @@ -6,7 +6,8 @@ import TokenRoutes from './TokenRoutes.js'; const { apiMock, getBrandMock } = vi.hoisted(() => ({ apiMock: { - getRoutes: vi.fn(), + getRoutesSummary: vi.fn(), + getRouteChannels: vi.fn(), getModelTokenCandidates: vi.fn(), getRouteDecisionsBatch: vi.fn(), getRouteWideDecisionsBatch: vi.fn(), @@ -55,22 +56,21 @@ describe('TokenRoutes refresh decision action', () => { vi.clearAllMocks(); getBrandMock.mockReset(); getBrandMock.mockReturnValue(null); - apiMock.getRoutes.mockResolvedValue([ + apiMock.getRoutesSummary.mockResolvedValue([ { - id: 1, - modelPattern: 'gpt-4o-mini', - displayName: 'gpt-4o-mini', - enabled: true, - channels: [], + id: 1, modelPattern: 'gpt-4o-mini', displayName: 'gpt-4o-mini', + displayIcon: null, modelMapping: null, enabled: true, + channelCount: 0, enabledChannelCount: 0, siteNames: [], + decisionSnapshot: null, decisionRefreshedAt: null, }, { - id: 2, - modelPattern: 're:^claude-(opus|sonnet)-4-6$', - displayName: 'claude-group', - enabled: true, - channels: [], + id: 2, modelPattern: 're:^claude-(opus|sonnet)-4-6$', displayName: 'claude-group', + displayIcon: null, modelMapping: null, enabled: true, + channelCount: 0, enabledChannelCount: 0, siteNames: [], + decisionSnapshot: null, decisionRefreshedAt: null, }, ]); + apiMock.getRouteChannels.mockResolvedValue([]); apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} }); apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} }); apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} }); diff --git a/src/web/pages/tokenRoutes.snapshot-load.test.tsx b/src/web/pages/tokenRoutes.snapshot-load.test.tsx index db8ce40..8874fd5 100644 --- a/src/web/pages/tokenRoutes.snapshot-load.test.tsx +++ b/src/web/pages/tokenRoutes.snapshot-load.test.tsx @@ -6,7 +6,8 @@ import TokenRoutes from './TokenRoutes.js'; const { apiMock, getBrandMock } = vi.hoisted(() => ({ apiMock: { - getRoutes: vi.fn(), + getRoutesSummary: vi.fn(), + getRouteChannels: vi.fn(), getModelTokenCandidates: vi.fn(), getRouteDecisionsBatch: vi.fn(), getRouteWideDecisionsBatch: vi.fn(), @@ -50,12 +51,17 @@ describe('TokenRoutes cached snapshot load', () => { apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} }); apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} }); apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} }); - apiMock.getRoutes.mockResolvedValue([ + apiMock.getRoutesSummary.mockResolvedValue([ { id: 1, modelPattern: 'gpt-4o-mini', displayName: 'gpt-4o-mini', + displayIcon: null, + modelMapping: null, enabled: true, + channelCount: 1, + enabledChannelCount: 1, + siteNames: ['cached-site'], decisionSnapshot: { requestedModel: 'gpt-4o-mini', actualModel: 'gpt-4o-mini', @@ -81,23 +87,23 @@ describe('TokenRoutes cached snapshot load', () => { ], }, decisionRefreshedAt: '2026-03-08T01:23:45.000Z', - channels: [ - { - id: 11, - accountId: 101, - tokenId: 1001, - sourceModel: 'gpt-4o-mini', - priority: 0, - weight: 10, - enabled: true, - manualOverride: false, - successCount: 0, - failCount: 0, - account: { username: 'cached-user' }, - site: { name: 'cached-site' }, - token: { id: 1001, name: 'cached-token', accountId: 101, enabled: true, isDefault: true }, - }, - ], + }, + ]); + apiMock.getRouteChannels.mockResolvedValue([ + { + id: 11, + accountId: 101, + tokenId: 1001, + sourceModel: 'gpt-4o-mini', + priority: 0, + weight: 10, + enabled: true, + manualOverride: false, + successCount: 0, + failCount: 0, + 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(); + // 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); expect(text).toContain('88.8%'); expect(text).toContain('cached-user');