merge: reconcile migrate bootstrap with duplicate-column recovery

This commit is contained in:
ciat-777
2026-03-14 19:12:23 +08:00
17 changed files with 1601 additions and 357 deletions
+6 -1
View File
@@ -30,7 +30,7 @@ type RecoveryMigrationRecord = MigrationRecord & {
tag: 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' },
@@ -47,8 +47,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) {
@@ -69,7 +69,7 @@ async function request(url: string, options: RequestOptions = {}) {
message = `${message}: ${text.slice(0, 120)}`;
}
}
} catch {}
} catch { }
throw new Error(message);
}
return res.json();
@@ -250,6 +250,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'),
@@ -262,6 +264,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 || {}),
+297 -4
View File
@@ -116,9 +116,27 @@ 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);
const modelModalRequestSeqRef = useRef(0);
const toast = useToast();
if (rebindTarget) lastRebindTargetRef.current = rebindTarget;
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 = {
width: '100%', padding: '10px 14px', border: '1px solid var(--color-border)',
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" /> : '刷新'}
</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">
@@ -1757,8 +1873,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">
@@ -1801,6 +1921,179 @@ 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;
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>
);
}
+254 -150
View File
@@ -101,6 +101,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;
@@ -204,6 +208,48 @@ 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);
try {
await api.rebuildRoutes(false, false);
toast.success('禁用模型列表已保存,路由已重建');
} catch {
toast.error('禁用模型列表已保存,但路由重建失败,请手动刷新路由');
}
} catch (e: any) {
toast.error(e.message || '保存禁用模型失败');
} finally {
setDisabledModelsSaving(false);
}
};
const handleSave = async () => {
@@ -689,6 +735,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',
@@ -858,48 +969,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"
@@ -911,124 +1021,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">
+180 -9
View File
@@ -10,8 +10,11 @@ const { apiMock, toastMock } = vi.hoisted(() => ({
getAccounts: vi.fn(),
getSites: vi.fn(),
updateAccount: vi.fn(),
updateSiteDisabledModels: vi.fn(),
rebuildRoutes: vi.fn(),
refreshAccountHealth: vi.fn(),
checkModels: vi.fn(),
getAccountModels: vi.fn(),
},
toastMock: {
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', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -61,7 +74,19 @@ describe('Accounts edit panel', () => {
},
]);
apiMock.updateAccount.mockResolvedValue({ success: true });
apiMock.updateSiteDisabledModels.mockResolvedValue({ success: true });
apiMock.rebuildRoutes.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 +128,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 +167,153 @@ 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();
}
});
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 {
root?.unmount();
}
});
});
@@ -9,6 +9,7 @@ const { apiMock } = vi.hoisted(() => ({
apiMock: {
getSites: 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();
}
});
});
+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: [] }),
},
}));