Merge pull request #68 from grassroadsZ/feature/site-model-disable
feat: 将模型禁用功能从站点移至连接管理
This commit is contained in:
@@ -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`);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `account_tokens` ADD `token_group` text;
|
||||||
@@ -36,6 +36,20 @@
|
|||||||
"when": 1772320800000,
|
"when": 1772320800000,
|
||||||
"tag": "0004_sorting_preferences",
|
"tag": "0004_sorting_preferences",
|
||||||
"breakpoints": true
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,7 @@ type MigrationRecord = {
|
|||||||
hash: string;
|
hash: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const VERIFIED_BOOTSTRAP_TAG = '0004_sorting_preferences';
|
const VERIFIED_BOOTSTRAP_TAG = '0007_account_token_group';
|
||||||
const VERIFIED_SCHEMA_MARKERS: SchemaMarker[] = [
|
const VERIFIED_SCHEMA_MARKERS: SchemaMarker[] = [
|
||||||
{ table: 'sites' },
|
{ table: 'sites' },
|
||||||
{ table: 'settings' },
|
{ table: 'settings' },
|
||||||
@@ -43,8 +43,13 @@ const VERIFIED_SCHEMA_MARKERS: SchemaMarker[] = [
|
|||||||
{ table: 'sites', column: 'sort_order' },
|
{ table: 'sites', column: 'sort_order' },
|
||||||
{ table: 'accounts', column: 'is_pinned' },
|
{ table: 'accounts', column: 'is_pinned' },
|
||||||
{ table: 'accounts', column: 'sort_order' },
|
{ 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 {
|
function resolveSqliteDbPath(): string {
|
||||||
const raw = (config.dbUrl || '').trim();
|
const raw = (config.dbUrl || '').trim();
|
||||||
if (!raw) return resolve(`${config.dataDir}/hub.db`);
|
if (!raw) return resolve(`${config.dataDir}/hub.db`);
|
||||||
|
|||||||
@@ -21,6 +21,16 @@ export const sites = sqliteTable('sites', {
|
|||||||
statusIdx: index('sites_status_idx').on(table.status),
|
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', {
|
export const accounts = sqliteTable('accounts', {
|
||||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
siteId: integer('site_id').notNull().references(() => sites.id, { onDelete: 'cascade' }),
|
siteId: integer('site_id').notNull().references(() => sites.id, { onDelete: 'cascade' }),
|
||||||
|
|||||||
@@ -1343,4 +1343,59 @@ export async function accountsRoutes(app: FastifyInstance) {
|
|||||||
return { message: err?.message || 'failed to fetch balance' };
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
// Detect platform for a URL
|
||||||
app.post<{ Body: { url: string } }>('/api/sites/detect', async (request) => {
|
app.post<{ Body: { url: string } }>('/api/sites/detect', async (request) => {
|
||||||
const result = await detectSite(request.body.url);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -354,23 +354,37 @@ export async function rebuildTokenRoutesFromAvailability() {
|
|||||||
)
|
)
|
||||||
.all();
|
.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 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();
|
const modelName = (modelNameRaw || '').trim();
|
||||||
if (!modelName) return;
|
if (!modelName) return;
|
||||||
|
if (isModelDisabledForSite(siteId, modelName)) return;
|
||||||
if (!modelCandidates.has(modelName)) modelCandidates.set(modelName, new Map());
|
if (!modelCandidates.has(modelName)) modelCandidates.set(modelName, new Map());
|
||||||
const candidateKey = `${accountId}:${tokenId ?? 'account'}`;
|
const candidateKey = `${accountId}:${tokenId ?? 'account'}`;
|
||||||
modelCandidates.get(modelName)!.set(candidateKey, { accountId, tokenId });
|
modelCandidates.get(modelName)!.set(candidateKey, { accountId, tokenId });
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const row of tokenRows) {
|
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) {
|
for (const row of accountRows) {
|
||||||
if (!isApiKeyConnection(row.accounts)) continue;
|
if (!isApiKeyConnection(row.accounts)) continue;
|
||||||
if (!(row.accounts.apiToken || '').trim()) 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 routes = await db.select().from(schema.tokenRoutes).all();
|
||||||
|
|||||||
@@ -250,6 +250,8 @@ export const api = {
|
|||||||
deleteSite: (id: number) => request(`/api/sites/${id}`, { method: 'DELETE' }),
|
deleteSite: (id: number) => request(`/api/sites/${id}`, { method: 'DELETE' }),
|
||||||
batchUpdateSites: (data: any) => request('/api/sites/batch', { method: 'POST', body: JSON.stringify(data) }),
|
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 }) }),
|
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
|
// Accounts
|
||||||
getAccounts: () => request('/api/accounts'),
|
getAccounts: () => request('/api/accounts'),
|
||||||
@@ -262,6 +264,7 @@ export const api = {
|
|||||||
deleteAccount: (id: number) => request(`/api/accounts/${id}`, { method: 'DELETE' }),
|
deleteAccount: (id: number) => request(`/api/accounts/${id}`, { method: 'DELETE' }),
|
||||||
batchUpdateAccounts: (data: any) => request('/api/accounts/batch', { method: 'POST', body: JSON.stringify(data) }),
|
batchUpdateAccounts: (data: any) => request('/api/accounts/batch', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
refreshBalance: (id: number) => request(`/api/accounts/${id}/balance`, { method: 'POST' }),
|
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', {
|
refreshAccountHealth: (data?: { accountId?: number; wait?: boolean }) => request('/api/accounts/health/refresh', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data || {}),
|
body: JSON.stringify(data || {}),
|
||||||
|
|||||||
+297
-4
@@ -116,9 +116,27 @@ export default function Accounts() {
|
|||||||
const [rebindVerifyResult, setRebindVerifyResult] = useState<any>(null);
|
const [rebindVerifyResult, setRebindVerifyResult] = useState<any>(null);
|
||||||
const [rebindVerifying, setRebindVerifying] = useState(false);
|
const [rebindVerifying, setRebindVerifying] = useState(false);
|
||||||
const [rebindSaving, setRebindSaving] = 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 rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
|
||||||
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const lastRebindTargetRef = useRef<any | null>(null);
|
const lastRebindTargetRef = useRef<any | null>(null);
|
||||||
|
const modelModalRequestSeqRef = useRef(0);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
if (rebindTarget) lastRebindTargetRef.current = rebindTarget;
|
if (rebindTarget) lastRebindTargetRef.current = rebindTarget;
|
||||||
const activeRebindTarget = rebindTarget || lastRebindTargetRef.current;
|
const activeRebindTarget = rebindTarget || lastRebindTargetRef.current;
|
||||||
@@ -396,6 +414,100 @@ export default function Accounts() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const applyLoadedModelModal = (account: any, result: any) => {
|
||||||
|
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 || s.siteName,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadModelModalModels = async (
|
||||||
|
account: any,
|
||||||
|
options: {
|
||||||
|
refreshUpstream?: boolean;
|
||||||
|
resetBeforeLoad?: boolean;
|
||||||
|
closeOnError?: boolean;
|
||||||
|
successMessage?: string | null;
|
||||||
|
errorMessage?: string;
|
||||||
|
} = {},
|
||||||
|
) => {
|
||||||
|
const requestId = ++modelModalRequestSeqRef.current;
|
||||||
|
setModelModal(s => ({
|
||||||
|
...s,
|
||||||
|
open: true,
|
||||||
|
account,
|
||||||
|
loading: true,
|
||||||
|
...(options.resetBeforeLoad ? { models: [], pendingDisabled: new Set<string>(), siteName: '' } : {}),
|
||||||
|
}));
|
||||||
|
try {
|
||||||
|
if (options.refreshUpstream) {
|
||||||
|
await api.checkModels(account.id);
|
||||||
|
}
|
||||||
|
const result = await api.getAccountModels(account.id);
|
||||||
|
if (modelModalRequestSeqRef.current !== requestId) return;
|
||||||
|
applyLoadedModelModal(account, result);
|
||||||
|
if (options.successMessage) {
|
||||||
|
toast.success(options.successMessage);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
if (modelModalRequestSeqRef.current !== requestId) return;
|
||||||
|
toast.error(e.message || options.errorMessage || '加载模型列表失败');
|
||||||
|
setModelModal(s => (
|
||||||
|
options.closeOnError
|
||||||
|
? { ...s, open: false, account: null, loading: false }
|
||||||
|
: { ...s, loading: false }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openModelModal = async (account: any) => {
|
||||||
|
await loadModelModalModels(account, {
|
||||||
|
resetBeforeLoad: true,
|
||||||
|
closeOnError: true,
|
||||||
|
errorMessage: '加载模型列表失败',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModelModal = () => {
|
||||||
|
modelModalRequestSeqRef.current += 1;
|
||||||
|
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);
|
||||||
|
toast.success('模型禁用设置已保存,路由已重建');
|
||||||
|
} catch {
|
||||||
|
toast.error('模型禁用设置已保存,但路由重建失败,请手动刷新路由');
|
||||||
|
}
|
||||||
|
closeModelModal();
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e.message || '保存失败');
|
||||||
|
} finally {
|
||||||
|
setModelModal(s => ({ ...s, saving: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
const inputStyle: React.CSSProperties = {
|
const inputStyle: React.CSSProperties = {
|
||||||
width: '100%', padding: '10px 14px', border: '1px solid var(--color-border)',
|
width: '100%', padding: '10px 14px', border: '1px solid var(--color-border)',
|
||||||
borderRadius: 'var(--radius-sm)', fontSize: 13, outline: 'none',
|
borderRadius: 'var(--radius-sm)', fontSize: 13, outline: 'none',
|
||||||
@@ -1571,8 +1683,12 @@ export default function Accounts() {
|
|||||||
{actionLoading[`refresh-${a.id}`] ? <span className="spinner spinner-sm" /> : '刷新'}
|
{actionLoading[`refresh-${a.id}`] ? <span className="spinner spinner-sm" /> : '刷新'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button onClick={() => withLoading(`models-${a.id}`, () => api.checkModels(a.id), '模型已更新')} disabled={actionLoading[`models-${a.id}`]} className="btn btn-link btn-link-info">
|
<button
|
||||||
{actionLoading[`models-${a.id}`] ? <span className="spinner spinner-sm" /> : '模型'}
|
onClick={() => openModelModal(a)}
|
||||||
|
disabled={actionLoading[`models-${a.id}`]}
|
||||||
|
className="btn btn-link btn-link-info"
|
||||||
|
>
|
||||||
|
模型
|
||||||
</button>
|
</button>
|
||||||
{capabilities.canCheckin && (
|
{capabilities.canCheckin && (
|
||||||
<button onClick={() => withLoading(`checkin-${a.id}`, () => api.triggerCheckin(a.id), '签到完成')} disabled={actionLoading[`checkin-${a.id}`]} className="btn btn-link btn-link-warning">
|
<button onClick={() => withLoading(`checkin-${a.id}`, () => api.triggerCheckin(a.id), '签到完成')} disabled={actionLoading[`checkin-${a.id}`]} className="btn btn-link btn-link-warning">
|
||||||
@@ -1757,8 +1873,12 @@ export default function Accounts() {
|
|||||||
{actionLoading[`refresh-${a.id}`] ? <span className="spinner spinner-sm" /> : '刷新'}
|
{actionLoading[`refresh-${a.id}`] ? <span className="spinner spinner-sm" /> : '刷新'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button onClick={() => handleCheckModels(a.id)} disabled={actionLoading[`models-${a.id}`]} className="btn btn-link btn-link-info">
|
<button
|
||||||
{actionLoading[`models-${a.id}`] ? <span className="spinner spinner-sm" /> : '模型'}
|
onClick={() => openModelModal(a)}
|
||||||
|
disabled={actionLoading[`models-${a.id}`]}
|
||||||
|
className="btn btn-link btn-link-info"
|
||||||
|
>
|
||||||
|
模型
|
||||||
</button>
|
</button>
|
||||||
{capabilities.canCheckin && (
|
{capabilities.canCheckin && (
|
||||||
<button onClick={() => withLoading(`checkin-${a.id}`, () => api.triggerCheckin(a.id), '签到完成')} disabled={actionLoading[`checkin-${a.id}`]} className="btn btn-link btn-link-warning">
|
<button onClick={() => withLoading(`checkin-${a.id}`, () => api.triggerCheckin(a.id), '签到完成')} disabled={actionLoading[`checkin-${a.id}`]} className="btn btn-link btn-link-warning">
|
||||||
@@ -1801,6 +1921,179 @@ export default function Accounts() {
|
|||||||
</div>
|
</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;
|
||||||
|
await loadModelModalModels(modelModal.account, {
|
||||||
|
refreshUpstream: true,
|
||||||
|
successMessage: '模型列表已刷新',
|
||||||
|
errorMessage: '刷新失败',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
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;
|
||||||
|
await loadModelModalModels(modelModal.account, {
|
||||||
|
refreshUpstream: true,
|
||||||
|
successMessage: '模型列表已刷新',
|
||||||
|
errorMessage: '刷新失败',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+111
-7
@@ -101,6 +101,10 @@ export default function Sites() {
|
|||||||
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
|
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
|
||||||
const highlightTimerRef = useRef<number | null>(null);
|
const highlightTimerRef = useRef<number | null>(null);
|
||||||
const toast = useToast();
|
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;
|
if (editor) lastEditorRef.current = editor;
|
||||||
const activeEditor = editor || lastEditorRef.current;
|
const activeEditor = editor || lastEditorRef.current;
|
||||||
@@ -204,6 +208,48 @@ export default function Sites() {
|
|||||||
setEditor({ mode: 'edit', editingSiteId: site.id });
|
setEditor({ mode: 'edit', editingSiteId: site.id });
|
||||||
setForm(siteFormFromSite(site));
|
setForm(siteFormFromSite(site));
|
||||||
scrollToEditorTop();
|
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);
|
||||||
|
try {
|
||||||
|
await api.rebuildRoutes(false, false);
|
||||||
|
toast.success('禁用模型列表已保存,路由已重建');
|
||||||
|
} catch {
|
||||||
|
toast.error('禁用模型列表已保存,但路由重建失败,请手动刷新路由');
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e.message || '保存禁用模型失败');
|
||||||
|
} finally {
|
||||||
|
setDisabledModelsSaving(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
@@ -689,6 +735,71 @@ export default function Sites() {
|
|||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.6 }}>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.6 }}>
|
||||||
按 key/value 逐条填写。整行留空会自动忽略;同名请求头不允许重复;请求本身显式传入的请求头优先级更高。
|
按 key/value 逐条填写。整行留空会自动忽略;同名请求头不允许重复;请求本身显式传入的请求头优先级更高。
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<label style={{
|
<label style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -899,7 +1010,6 @@ export default function Sites() {
|
|||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ fontWeight: 600 }}>
|
<td style={{ fontWeight: 600 }}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-start' }}>
|
|
||||||
<a
|
<a
|
||||||
href={site.url}
|
href={site.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -911,12 +1021,6 @@ export default function Sites() {
|
|||||||
>
|
>
|
||||||
{site.name}
|
{site.name}
|
||||||
</a>
|
</a>
|
||||||
{hasConfiguredCustomHeaders(site.customHeaders) ? (
|
|
||||||
<span className="badge badge-info" style={{ fontSize: 11 }}>
|
|
||||||
自定义头
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="sites-url-cell" style={{ maxWidth: 300 }}>
|
<td className="sites-url-cell" style={{ maxWidth: 300 }}>
|
||||||
{site.externalCheckinUrl ? (
|
{site.externalCheckinUrl ? (
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ const { apiMock, toastMock } = vi.hoisted(() => ({
|
|||||||
getAccounts: vi.fn(),
|
getAccounts: vi.fn(),
|
||||||
getSites: vi.fn(),
|
getSites: vi.fn(),
|
||||||
updateAccount: vi.fn(),
|
updateAccount: vi.fn(),
|
||||||
|
updateSiteDisabledModels: vi.fn(),
|
||||||
|
rebuildRoutes: vi.fn(),
|
||||||
refreshAccountHealth: vi.fn(),
|
refreshAccountHealth: vi.fn(),
|
||||||
checkModels: vi.fn(),
|
checkModels: vi.fn(),
|
||||||
|
getAccountModels: vi.fn(),
|
||||||
},
|
},
|
||||||
toastMock: {
|
toastMock: {
|
||||||
success: vi.fn(),
|
success: vi.fn(),
|
||||||
@@ -44,6 +47,16 @@ async function flushMicrotasks() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (reason?: unknown) => void;
|
||||||
|
const promise = new Promise<T>((res, rej) => {
|
||||||
|
resolve = res;
|
||||||
|
reject = rej;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
describe('Accounts edit panel', () => {
|
describe('Accounts edit panel', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -61,7 +74,19 @@ describe('Accounts edit panel', () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
apiMock.updateAccount.mockResolvedValue({ success: true });
|
apiMock.updateAccount.mockResolvedValue({ success: true });
|
||||||
|
apiMock.updateSiteDisabledModels.mockResolvedValue({ success: true });
|
||||||
|
apiMock.rebuildRoutes.mockResolvedValue({ success: true });
|
||||||
apiMock.refreshAccountHealth.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(() => {
|
afterEach(() => {
|
||||||
@@ -103,13 +128,16 @@ describe('Accounts edit panel', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows model refresh toast on success', async () => {
|
it('opens model modal when clicking model button', async () => {
|
||||||
apiMock.checkModels.mockResolvedValue({
|
apiMock.getAccountModels.mockResolvedValue({
|
||||||
refresh: {
|
siteId: 1,
|
||||||
status: 'success',
|
siteName: 'Site A',
|
||||||
modelCount: 2,
|
models: [
|
||||||
modelsPreview: ['gpt-4', 'gpt-3.5'],
|
{ 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;
|
let root: ReturnType<typeof create> | null = null;
|
||||||
@@ -139,10 +167,153 @@ describe('Accounts edit panel', () => {
|
|||||||
});
|
});
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
|
|
||||||
expect(toastMock.success).toHaveBeenCalledWith(expect.stringContaining('已获取到模型'));
|
// Should call getAccountModels to open the model management modal
|
||||||
expect(toastMock.success).toHaveBeenCalledWith(expect.stringContaining('(共 2 个)'));
|
expect(apiMock.getAccountModels).toHaveBeenCalledWith(1);
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores stale model modal loads after switching accounts', async () => {
|
||||||
|
const firstLoad = deferred<any>();
|
||||||
|
const secondLoad = deferred<any>();
|
||||||
|
apiMock.getAccounts.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
siteId: 1,
|
||||||
|
username: 'alpha',
|
||||||
|
accessToken: 'session-alpha',
|
||||||
|
status: 'active',
|
||||||
|
site: { id: 1, name: 'Site A', status: 'active', platform: 'new-api' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
siteId: 2,
|
||||||
|
username: 'beta',
|
||||||
|
accessToken: 'session-beta',
|
||||||
|
status: 'active',
|
||||||
|
site: { id: 2, name: 'Site B', status: 'active', platform: 'new-api' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
apiMock.getSites.mockResolvedValue([
|
||||||
|
{ id: 1, name: 'Site A', platform: 'new-api', status: 'active' },
|
||||||
|
{ id: 2, name: 'Site B', platform: 'new-api', status: 'active' },
|
||||||
|
]);
|
||||||
|
apiMock.getAccountModels.mockImplementation((accountId: number) => {
|
||||||
|
if (accountId === 1) return firstLoad.promise;
|
||||||
|
if (accountId === 2) return secondLoad.promise;
|
||||||
|
return Promise.resolve({ siteId: accountId, siteName: 'Unknown', models: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
let root: ReturnType<typeof create> | null = null;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/accounts']}>
|
||||||
|
<ToastProvider>
|
||||||
|
<Accounts />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const modelButtons = root.root.findAll((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
&& typeof node.props.className === 'string'
|
||||||
|
&& node.props.className.includes('btn-link-info')
|
||||||
|
&& collectText(node).trim() === '模型'
|
||||||
|
));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
void modelButtons[0]!.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
void modelButtons[1]!.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
secondLoad.resolve({
|
||||||
|
siteId: 2,
|
||||||
|
siteName: 'Site B',
|
||||||
|
models: [{ name: 'model-b', latencyMs: 66, disabled: false }],
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
firstLoad.resolve({
|
||||||
|
siteId: 1,
|
||||||
|
siteName: 'Site A',
|
||||||
|
models: [{ name: 'model-a', latencyMs: 33, disabled: false }],
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const rendered = JSON.stringify(root.toJSON());
|
||||||
|
expect(rendered).toContain('模型管理 · Site B');
|
||||||
|
expect(rendered).toContain('model-b');
|
||||||
|
expect(rendered).not.toContain('model-a');
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports route rebuild failure without claiming success', async () => {
|
||||||
|
apiMock.getAccountModels.mockResolvedValue({
|
||||||
|
siteId: 1,
|
||||||
|
siteName: 'Site A',
|
||||||
|
models: [{ name: 'gpt-4', latencyMs: 120, disabled: false }],
|
||||||
|
totalCount: 1,
|
||||||
|
disabledCount: 0,
|
||||||
|
});
|
||||||
|
apiMock.rebuildRoutes.mockRejectedValue(new Error('rebuild failed'));
|
||||||
|
|
||||||
|
let root: ReturnType<typeof create> | null = null;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/accounts']}>
|
||||||
|
<ToastProvider>
|
||||||
|
<Accounts />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const modelButton = root.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
&& typeof node.props.className === 'string'
|
||||||
|
&& node.props.className.includes('btn-link-info')
|
||||||
|
&& collectText(node).trim() === '模型'
|
||||||
|
));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await modelButton.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const saveButtons = root.root.findAll((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
&& collectText(node).trim() === '保存'
|
||||||
|
));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await saveButtons[saveButtons.length - 1]!.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(apiMock.updateSiteDisabledModels).toHaveBeenCalledWith(1, []);
|
||||||
|
expect(apiMock.rebuildRoutes).toHaveBeenCalledWith(false, false);
|
||||||
|
expect(toastMock.error).toHaveBeenCalledWith('模型禁用设置已保存,但路由重建失败,请手动刷新路由');
|
||||||
|
expect(toastMock.success).not.toHaveBeenCalledWith('模型禁用设置已保存,路由已重建');
|
||||||
} finally {
|
} finally {
|
||||||
root?.unmount();
|
root?.unmount();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const { apiMock } = vi.hoisted(() => ({
|
|||||||
apiMock: {
|
apiMock: {
|
||||||
getSites: vi.fn(),
|
getSites: vi.fn(),
|
||||||
addSite: vi.fn(),
|
addSite: vi.fn(),
|
||||||
|
getSiteDisabledModels: vi.fn().mockResolvedValue({ models: [] }),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import { ToastProvider } from '../components/Toast.js';
|
||||||
|
import Sites from './Sites.js';
|
||||||
|
|
||||||
|
const { apiMock, toastMock } = vi.hoisted(() => ({
|
||||||
|
apiMock: {
|
||||||
|
getSites: vi.fn(),
|
||||||
|
getSiteDisabledModels: vi.fn(),
|
||||||
|
updateSiteDisabledModels: vi.fn(),
|
||||||
|
rebuildRoutes: vi.fn(),
|
||||||
|
},
|
||||||
|
toastMock: {
|
||||||
|
success: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
toast: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../api.js', () => ({
|
||||||
|
api: apiMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../components/Toast.js', () => ({
|
||||||
|
ToastProvider: ({ children }: { children: ReactNode }) => children,
|
||||||
|
useToast: () => toastMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function collectText(node: ReactTestInstance): string {
|
||||||
|
const children = node.children || [];
|
||||||
|
return children.map((child) => {
|
||||||
|
if (typeof child === 'string') return child;
|
||||||
|
return collectText(child);
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushMicrotasks() {
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Sites disabled models save', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
apiMock.getSites.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Demo Site',
|
||||||
|
url: 'https://example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
apiMock.getSiteDisabledModels.mockResolvedValue({ models: [] });
|
||||||
|
apiMock.updateSiteDisabledModels.mockResolvedValue({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports route rebuild failure after saving disabled models from sites editor', async () => {
|
||||||
|
apiMock.rebuildRoutes.mockRejectedValue(new Error('rebuild failed'));
|
||||||
|
|
||||||
|
let root: ReturnType<typeof create> | null = null;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/sites']}>
|
||||||
|
<ToastProvider>
|
||||||
|
<Sites />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const editButton = root.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
&& collectText(node).trim() === '编辑'
|
||||||
|
));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
editButton.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const saveButton = root.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
&& collectText(node).includes('保存禁用列表')
|
||||||
|
));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await saveButton.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(apiMock.updateSiteDisabledModels).toHaveBeenCalledWith(1, []);
|
||||||
|
expect(apiMock.rebuildRoutes).toHaveBeenCalledWith(false, false);
|
||||||
|
expect(toastMock.error).toHaveBeenCalledWith('禁用模型列表已保存,但路由重建失败,请手动刷新路由');
|
||||||
|
expect(toastMock.success).not.toHaveBeenCalledWith('禁用模型列表已保存,路由已重建');
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ import Sites from './Sites.js';
|
|||||||
const { apiMock } = vi.hoisted(() => ({
|
const { apiMock } = vi.hoisted(() => ({
|
||||||
apiMock: {
|
apiMock: {
|
||||||
getSites: vi.fn(),
|
getSites: vi.fn(),
|
||||||
|
getSiteDisabledModels: vi.fn().mockResolvedValue({ models: [] }),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user