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