feat: 将模型禁用功能从站点管理移至连接管理

- 在连接管理页面为每个账号添加独立的模型管理弹窗
- 弹窗内支持查看可用模型,进行「刷新模型」「全选」「全部禁用」「反选」等快捷操作
- 保存模型禁用配置后,系统会自动在后台触发并重建路由(rebuildRoutes),确保配置立即生效
- 从原站点编辑界面移除过时的禁用功能板块
- 修复因执行自动同步逻辑导致的 SQLite Drizzle Migration `duplicate column token_group` 报错,更新 bootstrap markers 及测试用例
This commit is contained in:
silent
2026-03-14 12:07:27 +08:00
parent ad791d87bb
commit 0d44d3ba10
16 changed files with 1299 additions and 357 deletions
+13
View File
@@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS `site_disabled_models` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`site_id` integer NOT NULL,
`model_name` text NOT NULL,
`created_at` text DEFAULT (datetime('now')),
FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS `site_disabled_models_site_model_unique`
ON `site_disabled_models` (`site_id`, `model_name`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `site_disabled_models_site_id_idx`
ON `site_disabled_models` (`site_id`);
+1
View File
@@ -0,0 +1 @@
ALTER TABLE `account_tokens` ADD `token_group` text;
+15 -1
View File
@@ -36,6 +36,20 @@
"when": 1772320800000,
"tag": "0004_sorting_preferences",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1773500331000,
"tag": "0006_site_disabled_models",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1773500400000,
"tag": "0007_account_token_group",
"breakpoints": true
}
]
}
}
+6 -1
View File
@@ -26,7 +26,7 @@ type MigrationRecord = {
hash: string;
};
const VERIFIED_BOOTSTRAP_TAG = '0004_sorting_preferences';
const VERIFIED_BOOTSTRAP_TAG = '0007_account_token_group';
const VERIFIED_SCHEMA_MARKERS: SchemaMarker[] = [
{ table: 'sites' },
{ table: 'settings' },
@@ -43,8 +43,13 @@ const VERIFIED_SCHEMA_MARKERS: SchemaMarker[] = [
{ table: 'sites', column: 'sort_order' },
{ table: 'accounts', column: 'is_pinned' },
{ table: 'accounts', column: 'sort_order' },
// 0006: site_disabled_models table
{ table: 'site_disabled_models' },
// 0007: token_group column on account_tokens
{ table: 'account_tokens', column: 'token_group' },
];
function resolveSqliteDbPath(): string {
const raw = (config.dbUrl || '').trim();
if (!raw) return resolve(`${config.dataDir}/hub.db`);
+10
View File
@@ -21,6 +21,16 @@ export const sites = sqliteTable('sites', {
statusIdx: index('sites_status_idx').on(table.status),
}));
export const siteDisabledModels = sqliteTable('site_disabled_models', {
id: integer('id').primaryKey({ autoIncrement: true }),
siteId: integer('site_id').notNull().references(() => sites.id, { onDelete: 'cascade' }),
modelName: text('model_name').notNull(),
createdAt: text('created_at').default(sql`(datetime('now'))`),
}, (table) => ({
siteModelUnique: uniqueIndex('site_disabled_models_site_model_unique').on(table.siteId, table.modelName),
siteIdIdx: index('site_disabled_models_site_id_idx').on(table.siteId),
}));
export const accounts = sqliteTable('accounts', {
id: integer('id').primaryKey({ autoIncrement: true }),
siteId: integer('site_id').notNull().references(() => sites.id, { onDelete: 'cascade' }),
+55
View File
@@ -1343,4 +1343,59 @@ export async function accountsRoutes(app: FastifyInstance) {
return { message: err?.message || 'failed to fetch balance' };
}
});
// Get model list for an account (available models + disabled status at site level)
app.get<{ Params: { id: string } }>('/api/accounts/:id/models', async (request, reply) => {
const accountId = parseInt(request.params.id, 10);
if (!Number.isFinite(accountId) || accountId <= 0) {
return reply.code(400).send({ message: '账号 ID 无效' });
}
const account = await db.select().from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(eq(schema.accounts.id, accountId))
.get();
if (!account) {
return reply.code(404).send({ message: '账号不存在' });
}
const siteId = account.accounts.siteId;
// Get available models for this account
const modelRows = await db.select({
modelName: schema.modelAvailability.modelName,
available: schema.modelAvailability.available,
latencyMs: schema.modelAvailability.latencyMs,
}).from(schema.modelAvailability)
.where(eq(schema.modelAvailability.accountId, accountId))
.all();
// Get disabled models for this site
const disabledRows = await db.select({
modelName: schema.siteDisabledModels.modelName,
}).from(schema.siteDisabledModels)
.where(eq(schema.siteDisabledModels.siteId, siteId))
.all();
const disabledSet = new Set(disabledRows.map((r) => r.modelName));
const models = modelRows
.filter((r) => r.available)
.map((r) => ({
name: r.modelName,
latencyMs: r.latencyMs,
disabled: disabledSet.has(r.modelName),
}))
.sort((a, b) => a.name.localeCompare(b.name));
return {
siteId,
siteName: account.sites.name,
models,
totalCount: models.length,
disabledCount: models.filter((m) => m.disabled).length,
};
});
}
@@ -0,0 +1,218 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { describe, expect, it, beforeAll, beforeEach, afterAll } from 'vitest';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { mkdtempSync } from 'node:fs';
import { eq } from 'drizzle-orm';
type DbModule = typeof import('../../db/index.js');
describe('sites disabled models API', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-sites-disabled-models-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const routesModule = await import('./sites.js');
db = dbModule.db;
schema = dbModule.schema;
app = Fastify();
await app.register(routesModule.sitesRoutes);
});
beforeEach(async () => {
await db.delete(schema.siteDisabledModels).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('returns empty list for a new site', async () => {
const site = await db.insert(schema.sites).values({
name: 'test-site',
url: 'https://test-site.example.com',
platform: 'new-api',
}).returning().get();
const resp = await app.inject({
method: 'GET',
url: `/api/sites/${site.id}/disabled-models`,
});
expect(resp.statusCode).toBe(200);
const body = resp.json();
expect(body.siteId).toBe(site.id);
expect(body.models).toEqual([]);
});
it('sets and retrieves disabled models', async () => {
const site = await db.insert(schema.sites).values({
name: 'test-site',
url: 'https://test-site.example.com',
platform: 'new-api',
}).returning().get();
const putResp = await app.inject({
method: 'PUT',
url: `/api/sites/${site.id}/disabled-models`,
payload: { models: ['gpt-4o', 'claude-sonnet-4-5-20250929'] },
});
expect(putResp.statusCode).toBe(200);
const putBody = putResp.json();
expect(putBody.models).toHaveLength(2);
expect(putBody.models).toContain('gpt-4o');
expect(putBody.models).toContain('claude-sonnet-4-5-20250929');
const getResp = await app.inject({
method: 'GET',
url: `/api/sites/${site.id}/disabled-models`,
});
expect(getResp.statusCode).toBe(200);
const getBody = getResp.json();
expect(getBody.models).toHaveLength(2);
expect(getBody.models).toContain('gpt-4o');
expect(getBody.models).toContain('claude-sonnet-4-5-20250929');
});
it('replaces disabled models on subsequent PUT', async () => {
const site = await db.insert(schema.sites).values({
name: 'test-site',
url: 'https://test-site.example.com',
platform: 'new-api',
}).returning().get();
await app.inject({
method: 'PUT',
url: `/api/sites/${site.id}/disabled-models`,
payload: { models: ['gpt-4o', 'gpt-3.5-turbo'] },
});
const putResp = await app.inject({
method: 'PUT',
url: `/api/sites/${site.id}/disabled-models`,
payload: { models: ['claude-sonnet-4-5-20250929'] },
});
expect(putResp.statusCode).toBe(200);
const body = putResp.json();
expect(body.models).toEqual(['claude-sonnet-4-5-20250929']);
// Verify old models are removed
const getResp = await app.inject({
method: 'GET',
url: `/api/sites/${site.id}/disabled-models`,
});
const getBody = getResp.json();
expect(getBody.models).toEqual(['claude-sonnet-4-5-20250929']);
});
it('clears disabled models with empty array', async () => {
const site = await db.insert(schema.sites).values({
name: 'test-site',
url: 'https://test-site.example.com',
platform: 'new-api',
}).returning().get();
await app.inject({
method: 'PUT',
url: `/api/sites/${site.id}/disabled-models`,
payload: { models: ['gpt-4o'] },
});
const putResp = await app.inject({
method: 'PUT',
url: `/api/sites/${site.id}/disabled-models`,
payload: { models: [] },
});
expect(putResp.statusCode).toBe(200);
expect(putResp.json().models).toEqual([]);
const getResp = await app.inject({
method: 'GET',
url: `/api/sites/${site.id}/disabled-models`,
});
expect(getResp.json().models).toEqual([]);
});
it('returns 404 for non-existent site', async () => {
const getResp = await app.inject({
method: 'GET',
url: '/api/sites/99999/disabled-models',
});
expect(getResp.statusCode).toBe(404);
const putResp = await app.inject({
method: 'PUT',
url: '/api/sites/99999/disabled-models',
payload: { models: ['gpt-4o'] },
});
expect(putResp.statusCode).toBe(404);
});
it('returns 400 when models is not an array', async () => {
const site = await db.insert(schema.sites).values({
name: 'test-site',
url: 'https://test-site.example.com',
platform: 'new-api',
}).returning().get();
const resp = await app.inject({
method: 'PUT',
url: `/api/sites/${site.id}/disabled-models`,
payload: { models: 'not-an-array' },
});
expect(resp.statusCode).toBe(400);
});
it('deduplicates model names', async () => {
const site = await db.insert(schema.sites).values({
name: 'test-site',
url: 'https://test-site.example.com',
platform: 'new-api',
}).returning().get();
const putResp = await app.inject({
method: 'PUT',
url: `/api/sites/${site.id}/disabled-models`,
payload: { models: ['gpt-4o', 'gpt-4o', 'gpt-4o'] },
});
expect(putResp.statusCode).toBe(200);
expect(putResp.json().models).toEqual(['gpt-4o']);
});
it('deletes disabled models when site is deleted (cascade)', async () => {
const site = await db.insert(schema.sites).values({
name: 'delete-test',
url: 'https://delete-test.example.com',
platform: 'new-api',
}).returning().get();
await app.inject({
method: 'PUT',
url: `/api/sites/${site.id}/disabled-models`,
payload: { models: ['gpt-4o', 'claude-sonnet-4-5-20250929'] },
});
await db.delete(schema.sites).where(eq(schema.sites.id, site.id)).run();
const rows = await db.select().from(schema.siteDisabledModels)
.where(eq(schema.siteDisabledModels.siteId, site.id))
.all();
expect(rows).toHaveLength(0);
});
});
+53 -2
View File
@@ -104,7 +104,7 @@ export async function sitesRoutes(app: FastifyInstance) {
relatedType: 'site',
createdAt,
}).run();
} catch {}
} catch { }
return;
}
@@ -124,7 +124,7 @@ export async function sitesRoutes(app: FastifyInstance) {
relatedType: 'site',
createdAt,
}).run();
} catch {}
} catch { }
}
function normalizeBatchIds(input: unknown): number[] {
@@ -372,6 +372,57 @@ export async function sitesRoutes(app: FastifyInstance) {
};
});
// Get disabled models for a site
app.get<{ Params: { id: string } }>('/api/sites/:id/disabled-models', async (request, reply) => {
const id = parseInt(request.params.id);
if (Number.isNaN(id)) {
return reply.code(400).send({ error: 'Invalid site id' });
}
const existingSite = await db.select().from(schema.sites).where(eq(schema.sites.id, id)).get();
if (!existingSite) {
return reply.code(404).send({ error: 'Site not found' });
}
const rows = await db.select({ modelName: schema.siteDisabledModels.modelName })
.from(schema.siteDisabledModels)
.where(eq(schema.siteDisabledModels.siteId, id))
.all();
return { siteId: id, models: rows.map((r) => r.modelName) };
});
// Update disabled models for a site (full replace)
app.put<{ Params: { id: string }; Body: { models?: string[] } }>('/api/sites/:id/disabled-models', async (request, reply) => {
const id = parseInt(request.params.id);
if (Number.isNaN(id)) {
return reply.code(400).send({ error: 'Invalid site id' });
}
const existingSite = await db.select().from(schema.sites).where(eq(schema.sites.id, id)).get();
if (!existingSite) {
return reply.code(404).send({ error: 'Site not found' });
}
const rawModels = request.body?.models;
if (!Array.isArray(rawModels)) {
return reply.code(400).send({ error: 'models must be an array of strings' });
}
const models = rawModels
.filter((m): m is string => typeof m === 'string')
.map((m) => m.trim())
.filter((m) => m.length > 0);
const uniqueModels = Array.from(new Set(models));
await db.delete(schema.siteDisabledModels)
.where(eq(schema.siteDisabledModels.siteId, id))
.run();
if (uniqueModels.length > 0) {
await db.insert(schema.siteDisabledModels).values(
uniqueModels.map((modelName) => ({ siteId: id, modelName })),
).run();
}
invalidateSiteCaches();
return { siteId: id, models: uniqueModels };
});
// Detect platform for a URL
app.post<{ Body: { url: string } }>('/api/sites/detect', async (request) => {
const result = await detectSite(request.body.url);
@@ -0,0 +1,177 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { and, eq } from 'drizzle-orm';
type DbModule = typeof import('../db/index.js');
type ModelServiceModule = typeof import('./modelService.js');
describe('rebuildTokenRoutesFromAvailability with site disabled models', () => {
let db: DbModule['db'];
let schema: DbModule['schema'];
let rebuildTokenRoutesFromAvailability: ModelServiceModule['rebuildTokenRoutesFromAvailability'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-site-disabled-models-'));
process.env.DATA_DIR = dataDir;
await import('../db/migrate.js');
const dbModule = await import('../db/index.js');
const modelService = await import('./modelService.js');
db = dbModule.db;
schema = dbModule.schema;
rebuildTokenRoutesFromAvailability = modelService.rebuildTokenRoutesFromAvailability;
});
beforeEach(async () => {
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.tokenModelAvailability).run();
await db.delete(schema.modelAvailability).run();
await db.delete(schema.siteDisabledModels).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
afterAll(() => {
delete process.env.DATA_DIR;
});
it('does not create route/channel for a model disabled on its site', async () => {
const site = await db.insert(schema.sites).values({
name: 'site-a',
url: 'https://site-a.example.com',
platform: 'new-api',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'user-a',
accessToken: '',
apiToken: 'sk-test-disabled',
status: 'active',
extraConfig: JSON.stringify({ credentialMode: 'apikey' }),
}).returning().get();
await db.insert(schema.modelAvailability).values({
accountId: account.id,
modelName: 'gpt-4o',
available: true,
latencyMs: 500,
checkedAt: '2026-03-12T00:00:00.000Z',
}).run();
// Disable gpt-4o for this site
await db.insert(schema.siteDisabledModels).values({
siteId: site.id,
modelName: 'gpt-4o',
}).run();
const rebuild = await rebuildTokenRoutesFromAvailability();
expect(rebuild.models).toBe(0);
const routes = await db.select().from(schema.tokenRoutes)
.where(eq(schema.tokenRoutes.modelPattern, 'gpt-4o'))
.all();
expect(routes).toHaveLength(0);
});
it('only blocks the disabled site, not other sites providing the same model', async () => {
const siteA = await db.insert(schema.sites).values({
name: 'site-a',
url: 'https://site-a.example.com',
platform: 'new-api',
}).returning().get();
const siteB = await db.insert(schema.sites).values({
name: 'site-b',
url: 'https://site-b.example.com',
platform: 'new-api',
}).returning().get();
const accountA = await db.insert(schema.accounts).values({
siteId: siteA.id,
username: 'user-a',
accessToken: '',
apiToken: 'sk-site-a',
status: 'active',
extraConfig: JSON.stringify({ credentialMode: 'apikey' }),
}).returning().get();
const accountB = await db.insert(schema.accounts).values({
siteId: siteB.id,
username: 'user-b',
accessToken: '',
apiToken: 'sk-site-b',
status: 'active',
extraConfig: JSON.stringify({ credentialMode: 'apikey' }),
}).returning().get();
// Both sites have the same model
await db.insert(schema.modelAvailability).values([
{ accountId: accountA.id, modelName: 'claude-sonnet-4-5-20250929', available: true, latencyMs: 300 },
{ accountId: accountB.id, modelName: 'claude-sonnet-4-5-20250929', available: true, latencyMs: 400 },
]).run();
// Disable the model only on site A
await db.insert(schema.siteDisabledModels).values({
siteId: siteA.id,
modelName: 'claude-sonnet-4-5-20250929',
}).run();
const rebuild = await rebuildTokenRoutesFromAvailability();
expect(rebuild.models).toBe(1);
const route = await db.select().from(schema.tokenRoutes)
.where(eq(schema.tokenRoutes.modelPattern, 'claude-sonnet-4-5-20250929'))
.get();
expect(route).toBeDefined();
const channels = await db.select().from(schema.routeChannels)
.where(eq(schema.routeChannels.routeId, route!.id))
.all();
// Only site B's channel should exist
expect(channels).toHaveLength(1);
expect(channels[0]?.accountId).toBe(accountB.id);
});
it('allows model when no disabled models are configured', async () => {
const site = await db.insert(schema.sites).values({
name: 'normal-site',
url: 'https://normal.example.com',
platform: 'new-api',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'normal-user',
accessToken: '',
apiToken: 'sk-normal',
status: 'active',
extraConfig: JSON.stringify({ credentialMode: 'apikey' }),
}).returning().get();
await db.insert(schema.modelAvailability).values({
accountId: account.id,
modelName: 'gpt-5',
available: true,
latencyMs: 200,
}).run();
const rebuild = await rebuildTokenRoutesFromAvailability();
expect(rebuild.models).toBe(1);
const route = await db.select().from(schema.tokenRoutes)
.where(eq(schema.tokenRoutes.modelPattern, 'gpt-5'))
.get();
expect(route).toBeDefined();
});
});
+202 -188
View File
@@ -6,7 +6,7 @@ import { getCredentialModeFromExtraConfig, resolvePlatformUserId } from './accou
import { invalidateTokenRouterCache } from './tokenRouter.js';
import { setAccountRuntimeHealth } from './accountHealthService.js';
import { clearAllRouteDecisionSnapshots } from './routeDecisionSnapshotStore.js';
const API_TOKEN_DISCOVERY_TIMEOUT_MS = 8_000;
const MODEL_DISCOVERY_TIMEOUT_MS = 12_000;
const MODEL_REFRESH_BATCH_SIZE = 3;
@@ -38,38 +38,38 @@ function isApiKeyConnection(account: typeof schema.accounts.$inferSelect): boole
if (explicit && explicit !== 'auto') return explicit === 'apikey';
return !(account.accessToken || '').trim();
}
function normalizeModels(models: string[]): string[] {
return Array.from(new Set(models.filter((model) => typeof model === 'string' && model.trim().length > 0)));
}
function isExactModelPattern(modelPattern: string): boolean {
const normalized = modelPattern.trim();
if (!normalized) return false;
if (normalized.toLowerCase().startsWith('re:')) return false;
return !/[\*\?\[]/.test(normalized);
}
async function withTimeout<T>(fn: () => Promise<T>, timeoutMs: number, timeoutMessage: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | null = null;
try {
return await Promise.race([
fn(),
new Promise<T>((_, reject) => {
timer = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
export async function refreshModelsForAccount(accountId: number) {
const row = await db.select().from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(eq(schema.accounts.id, accountId))
.get();
function normalizeModels(models: string[]): string[] {
return Array.from(new Set(models.filter((model) => typeof model === 'string' && model.trim().length > 0)));
}
function isExactModelPattern(modelPattern: string): boolean {
const normalized = modelPattern.trim();
if (!normalized) return false;
if (normalized.toLowerCase().startsWith('re:')) return false;
return !/[\*\?\[]/.test(normalized);
}
async function withTimeout<T>(fn: () => Promise<T>, timeoutMs: number, timeoutMessage: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | null = null;
try {
return await Promise.race([
fn(),
new Promise<T>((_, reject) => {
timer = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
export async function refreshModelsForAccount(accountId: number) {
const row = await db.select().from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(eq(schema.accounts.id, accountId))
.get();
if (!row) {
return {
accountId,
@@ -82,26 +82,26 @@ export async function refreshModelsForAccount(accountId: number) {
reason: 'account_not_found',
};
}
const account = row.accounts;
const site = row.sites;
const adapter = getAdapter(site.platform);
const accountTokens = await db.select()
.from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, accountId))
.all();
await db.delete(schema.modelAvailability)
.where(eq(schema.modelAvailability.accountId, accountId))
.run();
for (const token of accountTokens) {
await db.delete(schema.tokenModelAvailability)
.where(eq(schema.tokenModelAvailability.tokenId, token.id))
.run();
}
const account = row.accounts;
const site = row.sites;
const adapter = getAdapter(site.platform);
const accountTokens = await db.select()
.from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, accountId))
.all();
await db.delete(schema.modelAvailability)
.where(eq(schema.modelAvailability.accountId, accountId))
.run();
for (const token of accountTokens) {
await db.delete(schema.tokenModelAvailability)
.where(eq(schema.tokenModelAvailability.tokenId, token.id))
.run();
}
if (isSiteDisabled(site.status)) {
return {
accountId,
@@ -127,44 +127,44 @@ export async function refreshModelsForAccount(accountId: number) {
reason: 'adapter_or_status',
};
}
const platformUserId = resolvePlatformUserId(account.extraConfig, account.username);
let discoveredApiToken: string | null = null;
if (!account.apiToken && account.accessToken) {
try {
discoveredApiToken = await withTimeout(
() => adapter.getApiToken(site.url, account.accessToken, platformUserId),
API_TOKEN_DISCOVERY_TIMEOUT_MS,
`api token discovery timeout (${Math.round(API_TOKEN_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
);
if (discoveredApiToken) {
ensureDefaultTokenForAccount(account.id, discoveredApiToken, { name: 'default', source: 'sync' });
await db.update(schema.accounts).set({
apiToken: discoveredApiToken,
updatedAt: new Date().toISOString(),
}).where(eq(schema.accounts.id, account.id)).run();
}
} catch {}
}
let enabledTokens = await db.select()
.from(schema.accountTokens)
.where(and(eq(schema.accountTokens.accountId, account.id), eq(schema.accountTokens.enabled, true)))
.all();
// Last fallback: if still no managed token but account has a legacy apiToken, mirror it into token table.
const platformUserId = resolvePlatformUserId(account.extraConfig, account.username);
let discoveredApiToken: string | null = null;
if (!account.apiToken && account.accessToken) {
try {
discoveredApiToken = await withTimeout(
() => adapter.getApiToken(site.url, account.accessToken, platformUserId),
API_TOKEN_DISCOVERY_TIMEOUT_MS,
`api token discovery timeout (${Math.round(API_TOKEN_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
);
if (discoveredApiToken) {
ensureDefaultTokenForAccount(account.id, discoveredApiToken, { name: 'default', source: 'sync' });
await db.update(schema.accounts).set({
apiToken: discoveredApiToken,
updatedAt: new Date().toISOString(),
}).where(eq(schema.accounts.id, account.id)).run();
}
} catch { }
}
let enabledTokens = await db.select()
.from(schema.accountTokens)
.where(and(eq(schema.accountTokens.accountId, account.id), eq(schema.accountTokens.enabled, true)))
.all();
// Last fallback: if still no managed token but account has a legacy apiToken, mirror it into token table.
if (!isApiKeyConnection(account) && enabledTokens.length === 0) {
const fallback = discoveredApiToken || account.apiToken || null;
if (fallback) {
ensureDefaultTokenForAccount(account.id, fallback, { name: 'default', source: 'legacy' });
enabledTokens = await db.select()
.from(schema.accountTokens)
.where(and(eq(schema.accountTokens.accountId, account.id), eq(schema.accountTokens.enabled, true)))
.all();
}
}
enabledTokens = await db.select()
.from(schema.accountTokens)
.where(and(eq(schema.accountTokens.accountId, account.id), eq(schema.accountTokens.enabled, true)))
.all();
}
}
const accountModels = new Set<string>();
const modelLatency = new Map<string, number | null>();
let scannedTokenCount = 0;
@@ -175,27 +175,27 @@ export async function refreshModelsForAccount(accountId: number) {
const message = (err as { message?: string })?.message || String(err || '');
if (message) failureMessages.push(message);
};
const mergeDiscoveredModels = (models: string[], latencyMs: number | null) => {
for (const modelName of models) {
accountModels.add(modelName);
const prev = modelLatency.get(modelName);
if (prev === undefined || prev === null) {
modelLatency.set(modelName, latencyMs);
continue;
}
if (latencyMs === null) continue;
if (latencyMs < prev) modelLatency.set(modelName, latencyMs);
}
};
const discoverModelsWithCredential = async (credentialRaw: string | null | undefined) => {
const credential = (credentialRaw || '').trim();
if (!credential) return;
if (attemptedCredentials.has(credential)) return;
attemptedCredentials.add(credential);
const startedAt = Date.now();
const mergeDiscoveredModels = (models: string[], latencyMs: number | null) => {
for (const modelName of models) {
accountModels.add(modelName);
const prev = modelLatency.get(modelName);
if (prev === undefined || prev === null) {
modelLatency.set(modelName, latencyMs);
continue;
}
if (latencyMs === null) continue;
if (latencyMs < prev) modelLatency.set(modelName, latencyMs);
}
};
const discoverModelsWithCredential = async (credentialRaw: string | null | undefined) => {
const credential = (credentialRaw || '').trim();
if (!credential) return;
if (attemptedCredentials.has(credential)) return;
attemptedCredentials.add(credential);
const startedAt = Date.now();
let models: string[] = [];
try {
models = normalizeModels(
@@ -210,18 +210,18 @@ export async function refreshModelsForAccount(accountId: number) {
models = [];
}
if (models.length === 0) return;
discoveredByCredential = true;
const latencyMs = Date.now() - startedAt;
mergeDiscoveredModels(models, latencyMs);
};
// Prefer account-level credential discovery so model availability does not rely on managed tokens.
await discoverModelsWithCredential(account.apiToken);
await discoverModelsWithCredential(discoveredApiToken);
await discoverModelsWithCredential(account.accessToken);
for (const token of enabledTokens) {
const startedAt = Date.now();
discoveredByCredential = true;
const latencyMs = Date.now() - startedAt;
mergeDiscoveredModels(models, latencyMs);
};
// Prefer account-level credential discovery so model availability does not rely on managed tokens.
await discoverModelsWithCredential(account.apiToken);
await discoverModelsWithCredential(discoveredApiToken);
await discoverModelsWithCredential(account.accessToken);
for (const token of enabledTokens) {
const startedAt = Date.now();
let models: string[] = [];
try {
@@ -236,26 +236,26 @@ export async function refreshModelsForAccount(accountId: number) {
recordFailure(err);
models = [];
}
if (models.length === 0) continue;
const latencyMs = Date.now() - startedAt;
const checkedAt = new Date().toISOString();
await db.insert(schema.tokenModelAvailability).values(
models.map((modelName) => ({
tokenId: token.id,
modelName,
available: true,
latencyMs,
checkedAt,
})),
).run();
scannedTokenCount++;
mergeDiscoveredModels(models, latencyMs);
}
if (models.length === 0) continue;
const latencyMs = Date.now() - startedAt;
const checkedAt = new Date().toISOString();
await db.insert(schema.tokenModelAvailability).values(
models.map((modelName) => ({
tokenId: token.id,
modelName,
available: true,
latencyMs,
checkedAt,
})),
).run();
scannedTokenCount++;
mergeDiscoveredModels(models, latencyMs);
}
if (accountModels.size === 0) {
const firstMessage = failureMessages[0] || '';
const errorCode = firstMessage ? classifyModelDiscoveryError(firstMessage) : 'empty_models';
@@ -312,33 +312,33 @@ export async function refreshModelsForAccount(accountId: number) {
discoveredApiToken: !!discoveredApiToken,
};
}
async function refreshModelsForAllActiveAccounts() {
const accounts = await db.select({ id: schema.accounts.id }).from(schema.accounts)
.where(eq(schema.accounts.status, 'active'))
.all();
async function refreshModelsForAllActiveAccounts() {
const accounts = await db.select({ id: schema.accounts.id }).from(schema.accounts)
.where(eq(schema.accounts.status, 'active'))
.all();
const results: any[] = [];
for (let offset = 0; offset < accounts.length; offset += MODEL_REFRESH_BATCH_SIZE) {
const batch = accounts.slice(offset, offset + MODEL_REFRESH_BATCH_SIZE);
const batchResults = await Promise.all(batch.map(async (account) => refreshModelsForAccount(account.id)));
results.push(...batchResults);
}
return results;
}
for (let offset = 0; offset < accounts.length; offset += MODEL_REFRESH_BATCH_SIZE) {
const batch = accounts.slice(offset, offset + MODEL_REFRESH_BATCH_SIZE);
const batchResults = await Promise.all(batch.map(async (account) => refreshModelsForAccount(account.id)));
results.push(...batchResults);
}
return results;
}
export async function rebuildTokenRoutesFromAvailability() {
const tokenRows = await db.select().from(schema.tokenModelAvailability)
.innerJoin(schema.accountTokens, eq(schema.tokenModelAvailability.tokenId, schema.accountTokens.id))
.innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(
and(
eq(schema.tokenModelAvailability.available, true),
eq(schema.accountTokens.enabled, true),
eq(schema.accounts.status, 'active'),
eq(schema.sites.status, 'active'),
),
.innerJoin(schema.accountTokens, eq(schema.tokenModelAvailability.tokenId, schema.accountTokens.id))
.innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(
and(
eq(schema.tokenModelAvailability.available, true),
eq(schema.accountTokens.enabled, true),
eq(schema.accounts.status, 'active'),
eq(schema.sites.status, 'active'),
),
)
.all();
@@ -354,29 +354,43 @@ export async function rebuildTokenRoutesFromAvailability() {
)
.all();
// Load site-level disabled models
const disabledModelRows = await db.select().from(schema.siteDisabledModels).all();
const disabledModelsBySite = new Map<number, Set<string>>();
for (const row of disabledModelRows) {
if (!disabledModelsBySite.has(row.siteId)) disabledModelsBySite.set(row.siteId, new Set());
disabledModelsBySite.get(row.siteId)!.add(row.modelName);
}
function isModelDisabledForSite(siteId: number, modelName: string): boolean {
const disabled = disabledModelsBySite.get(siteId);
return !!disabled && disabled.has(modelName);
}
const modelCandidates = new Map<string, Map<string, { accountId: number; tokenId: number | null }>>();
const addModelCandidate = (modelNameRaw: string | null | undefined, accountId: number, tokenId: number | null) => {
const addModelCandidate = (modelNameRaw: string | null | undefined, accountId: number, tokenId: number | null, siteId: number) => {
const modelName = (modelNameRaw || '').trim();
if (!modelName) return;
if (isModelDisabledForSite(siteId, modelName)) return;
if (!modelCandidates.has(modelName)) modelCandidates.set(modelName, new Map());
const candidateKey = `${accountId}:${tokenId ?? 'account'}`;
modelCandidates.get(modelName)!.set(candidateKey, { accountId, tokenId });
};
for (const row of tokenRows) {
addModelCandidate(row.token_model_availability.modelName, row.accounts.id, row.account_tokens.id);
addModelCandidate(row.token_model_availability.modelName, row.accounts.id, row.account_tokens.id, row.accounts.siteId);
}
for (const row of accountRows) {
if (!isApiKeyConnection(row.accounts)) continue;
if (!(row.accounts.apiToken || '').trim()) continue;
addModelCandidate(row.model_availability.modelName, row.accounts.id, null);
addModelCandidate(row.model_availability.modelName, row.accounts.id, null, row.accounts.siteId);
}
const routes = await db.select().from(schema.tokenRoutes).all();
const channels = await db.select().from(schema.routeChannels).all();
let createdRoutes = 0;
let createdRoutes = 0;
let createdChannels = 0;
let removedChannels = 0;
let removedRoutes = 0;
@@ -439,12 +453,12 @@ export async function rebuildTokenRoutesFromAvailability() {
.where(eq(schema.routeChannels.id, channel.id))
.run();
continue;
}
}
if (!channel.manualOverride) {
await db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channel.id)).run();
removedChannels++;
}
}
if (!channel.manualOverride) {
await db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channel.id)).run();
removedChannels++;
}
}
}
@@ -454,16 +468,16 @@ export async function rebuildTokenRoutesFromAvailability() {
const modelPattern = (route.modelPattern || '').trim();
if (!modelPattern || !isExactModelPattern(modelPattern) || latestModelNames.has(modelPattern)) {
continue;
}
const routeChannelCount = channels.filter((channel) => channel.routeId === route.id).length;
if (routeChannelCount > 0) {
removedChannels += routeChannelCount;
}
const deleted = (await db.delete(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, route.id)).run()).changes;
if (deleted > 0) {
removedRoutes += deleted;
}
const routeChannelCount = channels.filter((channel) => channel.routeId === route.id).length;
if (routeChannelCount > 0) {
removedChannels += routeChannelCount;
}
const deleted = (await db.delete(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, route.id)).run()).changes;
if (deleted > 0) {
removedRoutes += deleted;
}
}
@@ -472,7 +486,7 @@ export async function rebuildTokenRoutesFromAvailability() {
}
invalidateTokenRouterCache();
return {
models: modelCandidates.size,
createdRoutes,
@@ -480,8 +494,8 @@ export async function rebuildTokenRoutesFromAvailability() {
removedChannels,
removedRoutes,
};
}
}
export async function refreshModelsAndRebuildRoutes() {
const refresh = await refreshModelsForAllActiveAccounts();
const rebuild = await rebuildTokenRoutesFromAvailability();
+5 -2
View File
@@ -10,7 +10,7 @@ async function request(url: string, options: RequestOptions = {}) {
let timeoutHandle: ReturnType<typeof setTimeout> | null = setTimeout(() => {
controller.abort();
}, timeoutMs);
let cleanupExternalSignal = () => {};
let cleanupExternalSignal = () => { };
if (externalSignal) {
if (externalSignal.aborted) {
@@ -65,7 +65,7 @@ async function request(url: string, options: RequestOptions = {}) {
message = `${message}: ${text.slice(0, 120)}`;
}
}
} catch {}
} catch { }
throw new Error(message);
}
return res.json();
@@ -235,6 +235,8 @@ export const api = {
deleteSite: (id: number) => request(`/api/sites/${id}`, { method: 'DELETE' }),
batchUpdateSites: (data: any) => request('/api/sites/batch', { method: 'POST', body: JSON.stringify(data) }),
detectSite: (url: string) => request('/api/sites/detect', { method: 'POST', body: JSON.stringify({ url }) }),
getSiteDisabledModels: (siteId: number) => request(`/api/sites/${siteId}/disabled-models`),
updateSiteDisabledModels: (siteId: number, models: string[]) => request(`/api/sites/${siteId}/disabled-models`, { method: 'PUT', body: JSON.stringify({ models }) }),
// Accounts
getAccounts: () => request('/api/accounts'),
@@ -247,6 +249,7 @@ export const api = {
deleteAccount: (id: number) => request(`/api/accounts/${id}`, { method: 'DELETE' }),
batchUpdateAccounts: (data: any) => request('/api/accounts/batch', { method: 'POST', body: JSON.stringify(data) }),
refreshBalance: (id: number) => request(`/api/accounts/${id}/balance`, { method: 'POST' }),
getAccountModels: (id: number) => request(`/api/accounts/${id}/models`),
refreshAccountHealth: (data?: { accountId?: number; wait?: boolean }) => request('/api/accounts/health/refresh', {
method: 'POST',
body: JSON.stringify(data || {}),
+268 -4
View File
@@ -109,6 +109,23 @@ export default function Accounts() {
const [rebindVerifyResult, setRebindVerifyResult] = useState<any>(null);
const [rebindVerifying, setRebindVerifying] = useState(false);
const [rebindSaving, setRebindSaving] = useState(false);
const [modelModal, setModelModal] = useState<{
open: boolean;
account: any | null;
models: Array<{ name: string; latencyMs: number | null; disabled: boolean }>;
pendingDisabled: Set<string>;
loading: boolean;
saving: boolean;
siteName: string;
}>({
open: false,
account: null,
models: [],
pendingDisabled: new Set(),
loading: false,
saving: false,
siteName: '',
});
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastRebindTargetRef = useRef<any | null>(null);
@@ -388,6 +405,58 @@ export default function Accounts() {
}
};
const openModelModal = async (account: any) => {
setModelModal(s => ({ ...s, open: true, account, loading: true, models: [], pendingDisabled: new Set(), siteName: '' }));
try {
const result = await api.getAccountModels(account.id);
const models = Array.isArray(result?.models) ? result.models : [];
const disabledSet = new Set<string>(models.filter((m: any) => m.disabled).map((m: any) => m.name as string));
setModelModal(s => ({
...s,
loading: false,
models,
pendingDisabled: disabledSet,
siteName: result?.siteName || account.site?.name || '',
}));
} catch (e: any) {
toast.error(e.message || '加载模型列表失败');
setModelModal(s => ({ ...s, open: false, loading: false }));
}
};
const closeModelModal = () => {
setModelModal(s => ({ ...s, open: false, account: null }));
};
const toggleModelDisabled = (modelName: string) => {
setModelModal(s => {
const next = new Set(s.pendingDisabled);
if (next.has(modelName)) next.delete(modelName);
else next.add(modelName);
return { ...s, pendingDisabled: next };
});
};
const saveModelDisabled = async () => {
if (!modelModal.account) return;
const siteId = modelModal.account.siteId;
setModelModal(s => ({ ...s, saving: true }));
try {
await api.updateSiteDisabledModels(siteId, Array.from(modelModal.pendingDisabled));
// 禁用配置变更后自动重建路由,使变更立即生效(不刷新模型,在后台异步执行)
try {
await api.rebuildRoutes(false, false);
} catch { /* 路由重建失败不阻塞保存流程 */ }
toast.success('模型禁用设置已保存,路由已重建');
closeModelModal();
} catch (e: any) {
toast.error(e.message || '保存失败');
} finally {
setModelModal(s => ({ ...s, saving: false }));
}
};
const inputStyle: React.CSSProperties = {
width: '100%', padding: '10px 14px', border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)', fontSize: 13, outline: 'none',
@@ -1535,8 +1604,12 @@ export default function Accounts() {
{actionLoading[`refresh-${a.id}`] ? <span className="spinner spinner-sm" /> : '刷新'}
</button>
)}
<button onClick={() => withLoading(`models-${a.id}`, () => api.checkModels(a.id), '模型已更新')} disabled={actionLoading[`models-${a.id}`]} className="btn btn-link btn-link-info">
{actionLoading[`models-${a.id}`] ? <span className="spinner spinner-sm" /> : '模型'}
<button
onClick={() => openModelModal(a)}
disabled={actionLoading[`models-${a.id}`]}
className="btn btn-link btn-link-info"
>
模型
</button>
{capabilities.canCheckin && (
<button onClick={() => withLoading(`checkin-${a.id}`, () => api.triggerCheckin(a.id), '签到完成')} disabled={actionLoading[`checkin-${a.id}`]} className="btn btn-link btn-link-warning">
@@ -1721,8 +1794,12 @@ export default function Accounts() {
{actionLoading[`refresh-${a.id}`] ? <span className="spinner spinner-sm" /> : '刷新'}
</button>
)}
<button onClick={() => handleCheckModels(a.id)} disabled={actionLoading[`models-${a.id}`]} className="btn btn-link btn-link-info">
{actionLoading[`models-${a.id}`] ? <span className="spinner spinner-sm" /> : '模型'}
<button
onClick={() => openModelModal(a)}
disabled={actionLoading[`models-${a.id}`]}
className="btn btn-link btn-link-info"
>
模型
</button>
{capabilities.canCheckin && (
<button onClick={() => withLoading(`checkin-${a.id}`, () => api.triggerCheckin(a.id), '签到完成')} disabled={actionLoading[`checkin-${a.id}`]} className="btn btn-link btn-link-warning">
@@ -1765,6 +1842,193 @@ export default function Accounts() {
</div>
</>
)}
{/* Model management modal */}
<CenteredModal
open={modelModal.open}
onClose={closeModelModal}
title={modelModal.siteName ? `模型管理 · ${modelModal.siteName}` : '模型管理'}
maxWidth={600}
footer={
<>
<button onClick={closeModelModal} className="btn btn-ghost">取消</button>
<button
onClick={saveModelDisabled}
disabled={modelModal.saving || modelModal.loading}
className="btn btn-primary"
>
{modelModal.saving ? <><span className="spinner spinner-sm" />保存中...</> : '保存'}
</button>
</>
}
>
{modelModal.loading ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '48px 0', gap: 10 }}>
<span className="spinner" />
<span style={{ color: 'var(--color-text-muted)', fontSize: 14 }}>加载模型列表...</span>
</div>
) : modelModal.models.length === 0 ? (
<div style={{ textAlign: 'center', padding: '48px 0' }}>
<div style={{ fontSize: 32, marginBottom: 8 }}>🤖</div>
<div style={{ fontSize: 14, color: 'var(--color-text-muted)', marginBottom: 8 }}>暂无可用模型</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>请先点击账号操作栏中的「刷新」或「模型」按钮获取模型</div>
<button
onClick={async () => {
if (!modelModal.account) return;
setModelModal(s => ({ ...s, loading: true }));
try {
await api.checkModels(modelModal.account.id);
const result = await api.getAccountModels(modelModal.account.id);
const models = Array.isArray(result?.models) ? result.models : [];
const disabledSet = new Set<string>(models.filter((m: any) => m.disabled).map((m: any) => m.name as string));
setModelModal(s => ({ ...s, loading: false, models, pendingDisabled: disabledSet, siteName: result?.siteName || s.siteName }));
toast.success('模型列表已刷新');
} catch (e: any) {
toast.error(e.message || '刷新失败');
setModelModal(s => ({ ...s, loading: false }));
}
}}
style={{ marginTop: 16 }}
className="btn btn-soft-primary"
>
立即获取模型
</button>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{/* Header toolbar */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
{/* Left: select-all checkbox */}
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', userSelect: 'none' }}>
<input
type="checkbox"
checked={modelModal.pendingDisabled.size === 0}
ref={(el) => {
if (el) {
const total = modelModal.models.length;
const disabled = modelModal.pendingDisabled.size;
el.indeterminate = disabled > 0 && disabled < total;
}
}}
onChange={() => {
const allEnabled = modelModal.pendingDisabled.size === 0;
setModelModal(s => ({
...s,
pendingDisabled: allEnabled
? new Set(s.models.map((m) => m.name)) // 全选→全禁用
: new Set(), // 非全选→全启用
}));
}}
style={{ accentColor: 'var(--color-primary)', width: 15, height: 15 }}
/>
<span style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>
已启用 <strong style={{ color: 'var(--color-text-primary)' }}>{modelModal.models.length - modelModal.pendingDisabled.size}</strong> / {modelModal.models.length} 个模型
</span>
</label>
{/* Right: action buttons */}
<div style={{ display: 'flex', gap: 6 }}>
<button
onClick={async () => {
if (!modelModal.account) return;
setModelModal(s => ({ ...s, loading: true }));
try {
await api.checkModels(modelModal.account.id);
const result = await api.getAccountModels(modelModal.account.id);
const models = Array.isArray(result?.models) ? result.models : [];
const disabledSet = new Set<string>(models.filter((m: any) => m.disabled).map((m: any) => m.name as string));
setModelModal(s => ({ ...s, loading: false, models, pendingDisabled: disabledSet, siteName: result?.siteName || s.siteName }));
toast.success('模型列表已刷新');
} catch (e: any) {
toast.error(e.message || '刷新失败');
setModelModal(s => ({ ...s, loading: false }));
}
}}
disabled={modelModal.saving}
className="btn btn-ghost"
style={{ fontSize: 12, padding: '4px 10px' }}
>
刷新模型
</button>
<button
onClick={() => setModelModal(s => {
const next = new Set<string>();
for (const m of s.models) {
if (!s.pendingDisabled.has(m.name)) next.add(m.name);
}
return { ...s, pendingDisabled: next };
})}
className="btn btn-ghost"
style={{ fontSize: 12, padding: '4px 10px' }}
>
反选
</button>
<button
onClick={() => setModelModal(s => ({ ...s, pendingDisabled: new Set(s.models.map((m) => m.name)) }))}
className="btn btn-ghost"
style={{ fontSize: 12, padding: '4px 10px' }}
>
全部禁用
</button>
<button
onClick={() => setModelModal(s => ({ ...s, pendingDisabled: new Set() }))}
className="btn btn-ghost"
style={{ fontSize: 12, padding: '4px 10px' }}
>
全部启用
</button>
</div>
</div>
<div style={{
maxHeight: 380,
overflowY: 'auto',
border: '1px solid var(--color-border-light)',
borderRadius: 'var(--radius-sm)',
}}>
{modelModal.models.map((model, idx) => {
const isDisabled = modelModal.pendingDisabled.has(model.name);
return (
<label
key={model.name}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '9px 14px',
cursor: 'pointer',
background: isDisabled ? 'var(--color-bg)' : undefined,
borderBottom: idx < modelModal.models.length - 1 ? '1px solid var(--color-border-light)' : undefined,
opacity: isDisabled ? 0.55 : 1,
transition: 'opacity 0.15s, background 0.15s',
}}
>
<input
type="checkbox"
checked={!isDisabled}
onChange={() => toggleModelDisabled(model.name)}
style={{ accentColor: 'var(--color-primary)', width: 15, height: 15, flexShrink: 0 }}
/>
<span style={{ flex: 1, fontSize: 13, fontFamily: 'var(--font-mono)', wordBreak: 'break-all' }}>
{model.name}
</span>
{model.latencyMs != null && (
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', flexShrink: 0 }}>
{model.latencyMs}ms
</span>
)}
{isDisabled && (
<span className="badge badge-error" style={{ fontSize: 10, flexShrink: 0 }}>禁用</span>
)}
</label>
);
})}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
💡 禁用的模型将对整个站点生效,该站点下所有连接都不会使用这些模型进行代理。
</div>
</div>
)}
</CenteredModal>
</div>
);
}
+249 -150
View File
@@ -94,6 +94,10 @@ export default function Sites() {
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
const highlightTimerRef = useRef<number | null>(null);
const toast = useToast();
const [disabledModels, setDisabledModels] = useState<string[]>([]);
const [disabledModelInput, setDisabledModelInput] = useState('');
const [disabledModelsLoading, setDisabledModelsLoading] = useState(false);
const [disabledModelsSaving, setDisabledModelsSaving] = useState(false);
if (editor) lastEditorRef.current = editor;
const activeEditor = editor || lastEditorRef.current;
@@ -197,6 +201,43 @@ export default function Sites() {
setEditor({ mode: 'edit', editingSiteId: site.id });
setForm(siteFormFromSite(site));
scrollToEditorTop();
// Load disabled models for this site
setDisabledModelsLoading(true);
setDisabledModels([]);
setDisabledModelInput('');
api.getSiteDisabledModels(site.id)
.then((res: any) => setDisabledModels(Array.isArray(res?.models) ? res.models : []))
.catch(() => { })
.finally(() => setDisabledModelsLoading(false));
};
const handleAddDisabledModel = () => {
const model = disabledModelInput.trim();
if (!model) return;
if (disabledModels.includes(model)) {
toast.info(`模型 "${model}" 已在禁用列表中`);
setDisabledModelInput('');
return;
}
setDisabledModels((prev) => [...prev, model]);
setDisabledModelInput('');
};
const handleRemoveDisabledModel = (model: string) => {
setDisabledModels((prev) => prev.filter((m) => m !== model));
};
const handleSaveDisabledModels = async () => {
if (!editor || editor.mode !== 'edit') return;
setDisabledModelsSaving(true);
try {
await api.updateSiteDisabledModels(editor.editingSiteId, disabledModels);
toast.success('禁用模型列表已保存');
} catch (e: any) {
toast.error(e.message || '保存禁用模型失败');
} finally {
setDisabledModelsSaving(false);
}
};
const handleSave = async () => {
@@ -654,6 +695,71 @@ export default function Sites() {
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.6 }}>
按 key/value 逐条填写。整行留空会自动忽略;同名请求头不允许重复;请求本身显式传入的请求头优先级更高。
</div>
{isEditing && (
<div style={{ marginTop: 16, padding: '14px', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', background: 'var(--color-bg)' }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>禁用模型管理</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
在此站点禁用指定模型后,路由重建时将不为该站点的这些模型创建通道。
</div>
{disabledModelsLoading ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--color-text-muted)' }}>
<span className="spinner spinner-sm" /> 加载中...
</div>
) : (
<>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 10, minHeight: 24 }}>
{disabledModels.length === 0 && (
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无禁用模型</span>
)}
{disabledModels.map((model) => (
<span
key={model}
className="badge badge-muted"
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, padding: '3px 8px' }}
>
{model}
<button
type="button"
onClick={() => handleRemoveDisabledModel(model)}
style={{
background: 'none', border: 'none', cursor: 'pointer', padding: 0,
fontSize: 13, lineHeight: 1, color: 'var(--color-text-muted)',
}}
title={`移除 ${model}`}
>
×
</button>
</span>
))}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<input
placeholder="输入模型名称,如 gpt-4o"
value={disabledModelInput}
onChange={(e) => setDisabledModelInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddDisabledModel(); } }}
style={{
flex: 1, padding: '8px 12px', border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)', fontSize: 12, outline: 'none',
background: 'var(--color-bg)', color: 'var(--color-text-primary)',
}}
/>
<button onClick={handleAddDisabledModel} className="btn btn-ghost" style={{ padding: '8px 14px', fontSize: 12, border: '1px solid var(--color-border)' }}>
添加
</button>
</div>
<button
onClick={handleSaveDisabledModels}
disabled={disabledModelsSaving}
className="btn btn-primary"
style={{ marginTop: 10, fontSize: 12, padding: '6px 16px' }}
>
{disabledModelsSaving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存禁用列表'}
</button>
</>
)}
</div>
)}
</div>
<label style={{
display: 'flex',
@@ -823,48 +929,47 @@ export default function Sites() {
</div>
) : (
<table className="data-table sites-table">
<thead>
<tr>
<th style={{ width: 44 }}>
<input
type="checkbox"
checked={sortedSites.length > 0 && selectedSiteIds.length === sortedSites.length}
onChange={(e) => toggleSelectAllVisible(e.target.checked)}
/>
</th>
<th>名称</th>
<th>外部签到站URL</th>
<th>总余额</th>
<th>状态</th>
<th>系统代理</th>
<th>权重</th>
<th>平台</th>
<th>创建时间</th>
<th className="sites-actions-col" style={{ textAlign: 'right' }}>操作</th>
</tr>
</thead>
<tbody>
{sortedSites.map((site, i) => (
<tr
key={site.id}
data-testid={`site-row-${site.id}`}
ref={(node) => {
if (node) rowRefs.current.set(site.id, node);
else rowRefs.current.delete(site.id);
}}
onClick={(event) => handleSiteRowClick(site.id, event)}
className={`animate-slide-up stagger-${Math.min(i + 1, 5)} row-selectable ${selectedSiteIds.includes(site.id) ? 'row-selected' : ''} ${highlightSiteId === site.id ? 'row-focus-highlight' : ''}`.trim()}
>
<td>
<thead>
<tr>
<th style={{ width: 44 }}>
<input
data-testid={`site-select-${site.id}`}
type="checkbox"
checked={selectedSiteIds.includes(site.id)}
onChange={(e) => toggleSiteSelection(site.id, e.target.checked)}
checked={sortedSites.length > 0 && selectedSiteIds.length === sortedSites.length}
onChange={(e) => toggleSelectAllVisible(e.target.checked)}
/>
</td>
<td style={{ fontWeight: 600 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-start' }}>
</th>
<th>名称</th>
<th>外部签到站URL</th>
<th>总余额</th>
<th>状态</th>
<th>系统代理</th>
<th>权重</th>
<th>平台</th>
<th>创建时间</th>
<th className="sites-actions-col" style={{ textAlign: 'right' }}>操作</th>
</tr>
</thead>
<tbody>
{sortedSites.map((site, i) => (
<tr
key={site.id}
data-testid={`site-row-${site.id}`}
ref={(node) => {
if (node) rowRefs.current.set(site.id, node);
else rowRefs.current.delete(site.id);
}}
onClick={(event) => handleSiteRowClick(site.id, event)}
className={`animate-slide-up stagger-${Math.min(i + 1, 5)} row-selectable ${selectedSiteIds.includes(site.id) ? 'row-selected' : ''} ${highlightSiteId === site.id ? 'row-focus-highlight' : ''}`.trim()}
>
<td>
<input
data-testid={`site-select-${site.id}`}
type="checkbox"
checked={selectedSiteIds.includes(site.id)}
onChange={(e) => toggleSiteSelection(site.id, e.target.checked)}
/>
</td>
<td style={{ fontWeight: 600 }}>
<a
href={site.url}
target="_blank"
@@ -876,124 +981,118 @@ export default function Sites() {
>
{site.name}
</a>
{hasConfiguredCustomHeaders(site.customHeaders) ? (
<span className="badge badge-info" style={{ fontSize: 11 }}>
自定义头
</span>
</td>
<td className="sites-url-cell" style={{ maxWidth: 300 }}>
{site.externalCheckinUrl ? (
<a
href={site.externalCheckinUrl}
target="_blank"
rel="noopener noreferrer"
className="sites-url-link"
style={{
fontSize: 12,
fontFamily: 'var(--font-mono)',
color: 'var(--color-primary)',
textDecoration: 'underline',
wordBreak: 'break-all',
}}
>
{site.externalCheckinUrl}
</a>
) : null}
</div>
</td>
<td className="sites-url-cell" style={{ maxWidth: 300 }}>
{site.externalCheckinUrl ? (
</td>
<td style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600 }}>
${(site.totalBalance || 0).toFixed(2)}
</td>
<td>
<span className={`badge ${site.status === 'disabled' ? 'badge-muted' : 'badge-success'}`} style={{ fontSize: 11 }}>
{site.status === 'disabled' ? '禁用' : '启用'}
</span>
</td>
<td>
<span className={`badge ${site.useSystemProxy ? 'badge-info' : 'badge-muted'}`} style={{ fontSize: 11 }}>
{site.useSystemProxy ? '已开启' : '未开启'}
</span>
</td>
<td style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600 }}>
{(site.globalWeight || 1).toFixed(2)}
</td>
<td>
<a
href={site.externalCheckinUrl}
href={site.url}
target="_blank"
rel="noopener noreferrer"
className="sites-url-link"
style={{
fontSize: 12,
fontFamily: 'var(--font-mono)',
color: 'var(--color-primary)',
textDecoration: 'underline',
wordBreak: 'break-all',
}}
style={{ textDecoration: 'none' }}
>
{site.externalCheckinUrl}
<span className={`badge ${platformColors[site.platform || ''] || 'badge-muted'}`}>
{site.platform || '-'}
</span>
</a>
) : null}
</td>
<td style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600 }}>
${(site.totalBalance || 0).toFixed(2)}
</td>
<td>
<span className={`badge ${site.status === 'disabled' ? 'badge-muted' : 'badge-success'}`} style={{ fontSize: 11 }}>
{site.status === 'disabled' ? '禁用' : '启用'}
</span>
</td>
<td>
<span className={`badge ${site.useSystemProxy ? 'badge-info' : 'badge-muted'}`} style={{ fontSize: 11 }}>
{site.useSystemProxy ? '已开启' : '未开启'}
</span>
</td>
<td style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600 }}>
{(site.globalWeight || 1).toFixed(2)}
</td>
<td>
<a
href={site.url}
target="_blank"
rel="noopener noreferrer"
style={{ textDecoration: 'none' }}
>
<span className={`badge ${platformColors[site.platform || ''] || 'badge-muted'}`}>
{site.platform || '-'}
</span>
</a>
</td>
<td style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
<a
href={site.url}
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--color-text-muted)', textDecoration: 'underline' }}
>
{formatDateTimeLocal(site.createdAt)}
</a>
</td>
<td className="sites-actions-cell" style={{ textAlign: 'right' }}>
<div className="sites-row-actions">
<button
onClick={() => handleTogglePin(site)}
disabled={pinningSiteId === site.id}
className={`btn btn-link ${site.isPinned ? 'btn-link-warning' : 'btn-link-primary'}`}
</td>
<td style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
<a
href={site.url}
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--color-text-muted)', textDecoration: 'underline' }}
>
{pinningSiteId === site.id ? <span className="spinner spinner-sm" /> : (site.isPinned ? '取消置顶' : '置顶')}
</button>
{sortMode === 'custom' && (
<>
<button
onClick={() => handleMoveCustomOrder(site, 'up')}
disabled={orderingSiteId === site.id}
className="btn btn-link btn-link-muted"
>
↑
</button>
<button
onClick={() => handleMoveCustomOrder(site, 'down')}
disabled={orderingSiteId === site.id}
className="btn btn-link btn-link-muted"
>
↓
</button>
</>
)}
<button
onClick={() => openEdit(site)}
className="btn btn-link btn-link-primary"
>
编辑
</button>
<button
onClick={() => handleToggleStatus(site)}
disabled={togglingSiteId === site.id}
className={`btn btn-link ${site.status === 'disabled' ? 'btn-link-primary' : 'btn-link-warning'}`}
>
{togglingSiteId === site.id ? <span className="spinner spinner-sm" /> : (site.status === 'disabled' ? '启用' : '禁用')}
</button>
<button
onClick={() => handleDelete(site)}
disabled={deleting === site.id}
className="btn btn-link btn-link-danger"
>
{deleting === site.id ? <span className="spinner spinner-sm" /> : null}
删除
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{formatDateTimeLocal(site.createdAt)}
</a>
</td>
<td className="sites-actions-cell" style={{ textAlign: 'right' }}>
<div className="sites-row-actions">
<button
onClick={() => handleTogglePin(site)}
disabled={pinningSiteId === site.id}
className={`btn btn-link ${site.isPinned ? 'btn-link-warning' : 'btn-link-primary'}`}
>
{pinningSiteId === site.id ? <span className="spinner spinner-sm" /> : (site.isPinned ? '取消置顶' : '置顶')}
</button>
{sortMode === 'custom' && (
<>
<button
onClick={() => handleMoveCustomOrder(site, 'up')}
disabled={orderingSiteId === site.id}
className="btn btn-link btn-link-muted"
>
↑
</button>
<button
onClick={() => handleMoveCustomOrder(site, 'down')}
disabled={orderingSiteId === site.id}
className="btn btn-link btn-link-muted"
>
↓
</button>
</>
)}
<button
onClick={() => openEdit(site)}
className="btn btn-link btn-link-primary"
>
编辑
</button>
<button
onClick={() => handleToggleStatus(site)}
disabled={togglingSiteId === site.id}
className={`btn btn-link ${site.status === 'disabled' ? 'btn-link-primary' : 'btn-link-warning'}`}
>
{togglingSiteId === site.id ? <span className="spinner spinner-sm" /> : (site.status === 'disabled' ? '启用' : '禁用')}
</button>
<button
onClick={() => handleDelete(site)}
disabled={deleting === site.id}
className="btn btn-link btn-link-danger"
>
{deleting === site.id ? <span className="spinner spinner-sm" /> : null}
删除
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)
) : (
<div className="empty-state">
+25 -9
View File
@@ -12,6 +12,7 @@ const { apiMock, toastMock } = vi.hoisted(() => ({
updateAccount: vi.fn(),
refreshAccountHealth: vi.fn(),
checkModels: vi.fn(),
getAccountModels: vi.fn(),
},
toastMock: {
success: vi.fn(),
@@ -62,6 +63,16 @@ describe('Accounts edit panel', () => {
]);
apiMock.updateAccount.mockResolvedValue({ success: true });
apiMock.refreshAccountHealth.mockResolvedValue({ success: true });
apiMock.getAccountModels.mockResolvedValue({
siteId: 1,
siteName: 'Site A',
models: [
{ name: 'gpt-4', latencyMs: 120, disabled: false },
{ name: 'gpt-3.5', latencyMs: 80, disabled: false },
],
totalCount: 2,
disabledCount: 0,
});
});
afterEach(() => {
@@ -103,13 +114,16 @@ describe('Accounts edit panel', () => {
}
});
it('shows model refresh toast on success', async () => {
apiMock.checkModels.mockResolvedValue({
refresh: {
status: 'success',
modelCount: 2,
modelsPreview: ['gpt-4', 'gpt-3.5'],
},
it('opens model modal when clicking model button', async () => {
apiMock.getAccountModels.mockResolvedValue({
siteId: 1,
siteName: 'Site A',
models: [
{ name: 'gpt-4', latencyMs: 120, disabled: false },
{ name: 'gpt-3.5', latencyMs: 80, disabled: false },
],
totalCount: 2,
disabledCount: 0,
});
let root: ReturnType<typeof create> | null = null;
@@ -139,10 +153,12 @@ describe('Accounts edit panel', () => {
});
await flushMicrotasks();
expect(toastMock.success).toHaveBeenCalledWith(expect.stringContaining('已获取到模型'));
expect(toastMock.success).toHaveBeenCalledWith(expect.stringContaining('(共 2 个)'));
// Should call getAccountModels to open the model management modal
expect(apiMock.getAccountModels).toHaveBeenCalledWith(1);
} finally {
root?.unmount();
}
});
});
@@ -9,6 +9,7 @@ const { apiMock } = vi.hoisted(() => ({
apiMock: {
getSites: vi.fn(),
addSite: vi.fn(),
getSiteDisabledModels: vi.fn().mockResolvedValue({ models: [] }),
},
}));
+1
View File
@@ -7,6 +7,7 @@ import Sites from './Sites.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getSites: vi.fn(),
getSiteDisabledModels: vi.fn().mockResolvedValue({ models: [] }),
},
}));