Add tests for centered modal adoption, search modal results, and accounts batch actions

- Implement tests for the CenteredModal component to ensure it follows the shared modal shell pattern.
- Add comprehensive tests for the SearchModal results, including API interactions and UI behavior.
- Create tests for accounts batch actions, verifying the functionality of refreshing balances and selecting accounts via row clicks.
- Introduce tests for the accounts centered modal to confirm the use of CenteredModal for add/edit/rebind flows.
- Add tests for the accounts edit panel to ensure proper rendering and functionality when opening the edit modal.
- Implement tests for the accounts tokens header to verify the reuse of the accounts page header in the tokens segment.
- Create tests for the system proxy settings, ensuring the correct saving of the proxy URL.
- Add tests for the tokens actions layout to confirm the proper layout and styling of token row actions.
- Implement tests for the tokens batch actions, including deletion of selected tokens and row selection behavior.
- Introduce tests for the tokens focus navigation to ensure the correct highlighting of focused token rows.
This commit is contained in:
cita
2026-03-07 18:04:05 +08:00
parent c31bbcbb51
commit c437d93715
65 changed files with 3984 additions and 486 deletions
+4
View File
@@ -19,3 +19,7 @@ tmp/*
Thumbs.db Thumbs.db
docker/data/ docker/data/
docker/.env docker/.env
# Local planning docs
docs/plan/
docs/plans/
+1
View File
@@ -45,6 +45,7 @@ export function buildConfig(env: NodeJS.ProcessEnv) {
return { return {
authToken: env.AUTH_TOKEN || 'change-me-admin-token', authToken: env.AUTH_TOKEN || 'change-me-admin-token',
proxyToken: env.PROXY_TOKEN || 'change-me-proxy-sk-token', proxyToken: env.PROXY_TOKEN || 'change-me-proxy-sk-token',
systemProxyUrl: env.SYSTEM_PROXY_URL || '',
accountCredentialSecret: env.ACCOUNT_CREDENTIAL_SECRET || env.AUTH_TOKEN || 'change-me-admin-token', accountCredentialSecret: env.ACCOUNT_CREDENTIAL_SECRET || env.AUTH_TOKEN || 'change-me-admin-token',
checkinCron: env.CHECKIN_CRON || '0 8 * * *', checkinCron: env.CHECKIN_CRON || '0 8 * * *',
balanceRefreshCron: env.BALANCE_REFRESH_CRON || '0 * * * *', balanceRefreshCron: env.BALANCE_REFRESH_CRON || '0 * * * *',
+18
View File
@@ -175,6 +175,23 @@ function ensureSiteProxySchema() {
} }
} }
function ensureSiteUseSystemProxySchema() {
const sqlite = requireSqliteConnection();
if (!tableExists('sites')) {
return;
}
if (!tableColumnExists('sites', 'use_system_proxy')) {
sqlite.exec(`ALTER TABLE sites ADD COLUMN use_system_proxy integer DEFAULT 0;`);
}
sqlite.exec(`
UPDATE sites
SET use_system_proxy = 0
WHERE use_system_proxy IS NULL;
`);
}
function ensureSiteExternalCheckinUrlSchema() { function ensureSiteExternalCheckinUrlSchema() {
const sqlite = requireSqliteConnection(); const sqlite = requireSqliteConnection();
if (!tableExists('sites')) { if (!tableExists('sites')) {
@@ -604,6 +621,7 @@ function initSqliteDb() {
ensureTokenManagementSchema(); ensureTokenManagementSchema();
ensureSiteStatusSchema(); ensureSiteStatusSchema();
ensureSiteProxySchema(); ensureSiteProxySchema();
ensureSiteUseSystemProxySchema();
ensureSiteExternalCheckinUrlSchema(); ensureSiteExternalCheckinUrlSchema();
ensureSiteGlobalWeightSchema(); ensureSiteGlobalWeightSchema();
ensureRouteGroupingSchema(); ensureRouteGroupingSchema();
+1
View File
@@ -8,6 +8,7 @@ export const sites = sqliteTable('sites', {
externalCheckinUrl: text('external_checkin_url'), externalCheckinUrl: text('external_checkin_url'),
platform: text('platform').notNull(), // 'new-api' | 'one-api' | 'veloera' | 'one-hub' | 'done-hub' | 'sub2api' | 'openai' | 'claude' | 'gemini' platform: text('platform').notNull(), // 'new-api' | 'one-api' | 'veloera' | 'one-hub' | 'done-hub' | 'sub2api' | 'openai' | 'claude' | 'gemini'
proxyUrl: text('proxy_url'), proxyUrl: text('proxy_url'),
useSystemProxy: integer('use_system_proxy', { mode: 'boolean' }).default(false),
status: text('status').notNull().default('active'), // 'active' | 'disabled' status: text('status').notNull().default('active'), // 'active' | 'disabled'
isPinned: integer('is_pinned', { mode: 'boolean' }).default(false), isPinned: integer('is_pinned', { mode: 'boolean' }).default(false),
sortOrder: integer('sort_order').default(0), sortOrder: integer('sort_order').default(0),
+3
View File
@@ -111,6 +111,9 @@ function applyRuntimeSettings(settingsMap: Map<string, string>) {
const proxyToken = parseSettingFromMap<string>(settingsMap, 'proxy_token'); const proxyToken = parseSettingFromMap<string>(settingsMap, 'proxy_token');
if (typeof proxyToken === 'string' && proxyToken) config.proxyToken = proxyToken; if (typeof proxyToken === 'string' && proxyToken) config.proxyToken = proxyToken;
const systemProxyUrl = parseSettingFromMap<string>(settingsMap, 'system_proxy_url');
if (typeof systemProxyUrl === 'string') config.systemProxyUrl = systemProxyUrl;
const checkinCron = parseSettingFromMap<string>(settingsMap, 'checkin_cron'); const checkinCron = parseSettingFromMap<string>(settingsMap, 'checkin_cron');
if (typeof checkinCron === 'string' && checkinCron) config.checkinCron = checkinCron; if (typeof checkinCron === 'string' && checkinCron) config.checkinCron = checkinCron;
@@ -0,0 +1,137 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
const { deleteApiTokenMock } = vi.hoisted(() => ({
deleteApiTokenMock: vi.fn(),
}));
vi.mock('../../services/platforms/index.js', () => ({
getAdapter: vi.fn(() => ({
deleteApiToken: deleteApiTokenMock,
})),
}));
type DbModule = typeof import('../../db/index.js');
describe('account token batch routes', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-account-tokens-batch-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const routesModule = await import('./accountTokens.js');
db = dbModule.db;
schema = dbModule.schema;
app = Fastify();
await app.register(routesModule.accountTokensRoutes);
});
beforeEach(async () => {
deleteApiTokenMock.mockReset();
deleteApiTokenMock.mockResolvedValue(true);
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
await db.insert(schema.sites).values({
id: 1,
name: 'site-1',
url: 'https://site-1.example.com',
platform: 'new-api',
status: 'active',
}).run();
await db.insert(schema.accounts).values({
id: 1,
siteId: 1,
username: 'alpha',
accessToken: 'session-alpha',
status: 'active',
}).run();
await db.insert(schema.accountTokens).values([
{
id: 1,
accountId: 1,
name: 'token-1',
token: 'sk-token-1',
enabled: false,
},
{
id: 2,
accountId: 1,
name: 'token-2',
token: 'sk-token-2',
enabled: false,
},
]).run();
});
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('enables selected account tokens and reports failures', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/account-tokens/batch',
payload: {
ids: [1, 2, 999],
action: 'enable',
},
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
successIds?: number[];
failedItems?: Array<{ id: number; message: string }>;
};
expect(body.successIds).toEqual([1, 2]);
expect(body.failedItems).toHaveLength(1);
expect(body.failedItems?.[0]?.id).toBe(999);
const rows = await db.select().from(schema.accountTokens).all();
expect(rows.every((row) => row.enabled === true)).toBe(true);
});
it('deletes selected account tokens through the upstream adapter', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/account-tokens/batch',
payload: {
ids: [1],
action: 'delete',
},
});
expect(response.statusCode).toBe(200);
expect(deleteApiTokenMock).toHaveBeenCalledTimes(1);
const remaining = await db.select().from(schema.accountTokens).all();
expect(remaining.map((item) => item.id)).toEqual([2]);
});
it('rejects invalid account token batch action', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/account-tokens/batch',
payload: {
ids: [1],
action: 'nope',
},
});
expect(response.statusCode).toBe(400);
expect((response.json() as { message?: string }).message).toContain('action');
});
});
+118 -39
View File
@@ -136,6 +136,13 @@ function parseExpiredTime(value: unknown): number | undefined {
return seconds > 0 ? seconds : undefined; return seconds > 0 ? seconds : undefined;
} }
function normalizeBatchIds(input: unknown): number[] {
if (!Array.isArray(input)) return [];
return input
.map((item) => Number.parseInt(String(item), 10))
.filter((id) => Number.isFinite(id) && id > 0);
}
async function withTimeout<T>(fn: () => Promise<T>, timeoutMs: number, timeoutMessage: string): Promise<T> { async function withTimeout<T>(fn: () => Promise<T>, timeoutMs: number, timeoutMessage: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | null = null; let timer: ReturnType<typeof setTimeout> | null = null;
try { try {
@@ -494,6 +501,111 @@ export async function accountTokensRoutes(app: FastifyInstance) {
}; };
}); });
const deleteAccountTokenById = async (tokenId: number): Promise<{ success: boolean; message?: string }> => {
const row = await db.select()
.from(schema.accountTokens)
.innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(eq(schema.accountTokens.id, tokenId))
.get();
if (!row) {
return { success: false, message: '令牌不存在' };
}
if (isApiKeyConnection(row.accounts)) {
return { success: false, message: 'API Key 连接不支持管理账号令牌' };
}
const existing = row.account_tokens;
const account = row.accounts;
const site = row.sites;
const adapter = getAdapter(site.platform);
const shouldDeleteUpstream = !isSiteDisabled(site.status) && !!account.accessToken?.trim() && !!adapter;
if (shouldDeleteUpstream) {
const platformUserId = resolvePlatformUserId(account.extraConfig, account.username);
const upstreamDeleted = await adapter!.deleteApiToken(
site.url,
account.accessToken,
existing.token,
platformUserId,
);
if (!upstreamDeleted) {
return { success: false, message: '站点删除令牌失败,本地未删除' };
}
}
await db.delete(schema.accountTokens).where(eq(schema.accountTokens.id, tokenId)).run();
if (existing.isDefault) {
repairDefaultToken(existing.accountId);
}
return { success: true };
};
app.post<{ Body?: { ids?: number[]; action?: string } }>('/api/account-tokens/batch', async (request, reply) => {
const ids = normalizeBatchIds(request.body?.ids);
const action = String(request.body?.action || '').trim();
if (ids.length === 0) {
return reply.code(400).send({ message: 'ids is required' });
}
if (!['enable', 'disable', 'delete'].includes(action)) {
return reply.code(400).send({ message: 'Invalid action' });
}
const successIds: number[] = [];
const failedItems: Array<{ id: number; message: string }> = [];
for (const id of ids) {
try {
const existing = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, id)).get();
if (!existing) {
failedItems.push({ id, message: 'Token not found' });
continue;
}
const owner = await db.select().from(schema.accounts).where(eq(schema.accounts.id, existing.accountId)).get();
if (!owner) {
failedItems.push({ id, message: 'Account not found' });
continue;
}
if (isApiKeyConnection(owner)) {
failedItems.push({ id, message: 'API Key 连接不支持管理账号令牌' });
continue;
}
if (action === 'delete') {
const result = await deleteAccountTokenById(id);
if (!result.success) {
failedItems.push({ id, message: result.message || 'Batch operation failed' });
continue;
}
} else {
await db.update(schema.accountTokens)
.set({
enabled: action === 'enable',
updatedAt: new Date().toISOString(),
})
.where(eq(schema.accountTokens.id, id))
.run();
if (existing.isDefault && action === 'disable') {
repairDefaultToken(existing.accountId);
}
}
successIds.push(id);
} catch (error: any) {
failedItems.push({ id, message: error?.message || 'Batch operation failed' });
}
}
return {
success: true,
successIds,
failedItems,
};
});
app.put<{ Params: { id: string }; Body: { name?: string; token?: string; enabled?: boolean; isDefault?: boolean; source?: string } }>('/api/account-tokens/:id', async (request, reply) => { app.put<{ Params: { id: string }; Body: { name?: string; token?: string; enabled?: boolean; isDefault?: boolean; source?: string } }>('/api/account-tokens/:id', async (request, reply) => {
const tokenId = Number.parseInt(request.params.id, 10); const tokenId = Number.parseInt(request.params.id, 10);
if (Number.isNaN(tokenId)) { if (Number.isNaN(tokenId)) {
@@ -652,46 +764,13 @@ export async function accountTokensRoutes(app: FastifyInstance) {
if (Number.isNaN(tokenId)) { if (Number.isNaN(tokenId)) {
return reply.code(400).send({ success: false, message: '令牌 ID 无效' }); return reply.code(400).send({ success: false, message: '令牌 ID 无效' });
} }
const result = await deleteAccountTokenById(tokenId);
const row = await db.select() if (!result.success) {
.from(schema.accountTokens) const statusCode = result.message === '令牌不存在'
.innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id)) ? 404
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) : (result.message === 'API Key 连接不支持管理账号令牌' ? 400 : 502);
.where(eq(schema.accountTokens.id, tokenId)) return reply.code(statusCode).send({ success: false, message: result.message });
.get();
if (!row) {
return reply.code(404).send({ success: false, message: '令牌不存在' });
} }
if (isApiKeyConnection(row.accounts)) {
return reply.code(400).send({ success: false, message: 'API Key 连接不支持管理账号令牌' });
}
const existing = row.account_tokens;
const account = row.accounts;
const site = row.sites;
const adapter = getAdapter(site.platform);
const shouldDeleteUpstream = !isSiteDisabled(site.status) && !!account.accessToken?.trim() && !!adapter;
if (shouldDeleteUpstream) {
const platformUserId = resolvePlatformUserId(account.extraConfig, account.username);
const upstreamDeleted = await adapter!.deleteApiToken(
site.url,
account.accessToken,
existing.token,
platformUserId,
);
if (!upstreamDeleted) {
return reply.code(502).send({ success: false, message: '站点删除令牌失败,本地未删除' });
}
}
await db.delete(schema.accountTokens).where(eq(schema.accountTokens.id, tokenId)).run();
if (existing.isDefault) {
repairDefaultToken(existing.accountId);
}
return { success: true }; return { success: true };
}); });
@@ -0,0 +1,111 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
const { refreshBalanceMock } = vi.hoisted(() => ({
refreshBalanceMock: vi.fn(),
}));
vi.mock('../../services/balanceService.js', () => ({
refreshBalance: refreshBalanceMock,
}));
type DbModule = typeof import('../../db/index.js');
describe('accounts batch routes', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-accounts-batch-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const routesModule = await import('./accounts.js');
db = dbModule.db;
schema = dbModule.schema;
app = Fastify();
await app.register(routesModule.accountsRoutes);
});
beforeEach(async () => {
refreshBalanceMock.mockReset();
refreshBalanceMock.mockImplementation(async (id: number) => {
if (id === 999) return null;
return { id, balance: 12.5 };
});
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
await db.insert(schema.sites).values({
id: 1,
name: 'site-1',
url: 'https://site-1.example.com',
platform: 'new-api',
}).run();
await db.insert(schema.accounts).values([
{
id: 1,
siteId: 1,
username: 'alpha',
accessToken: 'session-alpha',
status: 'active',
},
{
id: 2,
siteId: 1,
username: 'beta',
accessToken: 'session-beta',
status: 'active',
},
]).run();
});
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('refreshes balance for selected accounts and reports failures', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/accounts/batch',
payload: {
ids: [1, 2, 999],
action: 'refreshBalance',
},
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
successIds?: number[];
failedItems?: Array<{ id: number; message: string }>;
};
expect(body.successIds).toEqual([1, 2]);
expect(body.failedItems).toHaveLength(1);
expect(body.failedItems?.[0]?.id).toBe(999);
expect(refreshBalanceMock).toHaveBeenCalledTimes(3);
});
it('rejects invalid accounts batch action', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/accounts/batch',
payload: {
ids: [1],
action: 'nope',
},
});
expect(response.statusCode).toBe(400);
expect((response.json() as { message?: string }).message).toContain('action');
});
});
@@ -129,6 +129,49 @@ describe('accounts credential mode', () => {
expect(parsedExtra.credentialMode).toBe('apikey'); expect(parsedExtra.credentialMode).toBe('apikey');
}); });
it('marks apikey connection healthy in account list after model discovery succeeds', async () => {
const site = await db.insert(schema.sites).values({
name: 'Healthy API Key Site',
url: 'https://healthy-apikey.example.com',
platform: 'new-api',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'Wong',
accessToken: '',
apiToken: 'sk-healthy-apikey',
checkinEnabled: false,
extraConfig: JSON.stringify({ credentialMode: 'apikey' }),
}).returning().get();
await db.insert(schema.modelAvailability).values({
accountId: account.id,
modelName: 'gpt-5.4',
available: true,
latencyMs: 1200,
checkedAt: '2026-03-07T07:35:00.000Z',
}).run();
const listResponse = await app.inject({
method: 'GET',
url: '/api/accounts',
});
expect(listResponse.statusCode).toBe(200);
const list = listResponse.json() as Array<{
id: number;
runtimeHealth?: { state?: string; reason?: string };
capabilities?: { proxyOnly?: boolean };
}>;
expect(list).toHaveLength(1);
expect(list[0]?.capabilities?.proxyOnly).toBe(true);
expect(list[0]?.runtimeHealth).toMatchObject({
state: 'healthy',
reason: '模型探测成功',
});
});
it('stores managed refresh token for sub2api session account', async () => { it('stores managed refresh token for sub2api session account', async () => {
verifyTokenMock.mockResolvedValueOnce({ verifyTokenMock.mockResolvedValueOnce({
tokenType: 'session', tokenType: 'session',
+86 -3
View File
@@ -25,7 +25,7 @@ import {
type RuntimeHealthState, type RuntimeHealthState,
} from '../../services/accountHealthService.js'; } from '../../services/accountHealthService.js';
import { appendSessionTokenRebindHint } from '../../services/alertRules.js'; import { appendSessionTokenRebindHint } from '../../services/alertRules.js';
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js'; import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
type AccountWithSiteRow = { type AccountWithSiteRow = {
accounts: typeof schema.accounts.$inferSelect; accounts: typeof schema.accounts.$inferSelect;
@@ -82,6 +82,13 @@ function buildCapabilitiesForAccount(account: typeof schema.accounts.$inferSelec
return buildCapabilitiesFromCredentialMode(credentialMode, hasSessionTokenValue(account.accessToken)); return buildCapabilitiesFromCredentialMode(credentialMode, hasSessionTokenValue(account.accessToken));
} }
function normalizeBatchIds(input: unknown): number[] {
if (!Array.isArray(input)) return [];
return input
.map((item) => Number.parseInt(String(item), 10))
.filter((id) => Number.isFinite(id) && id > 0);
}
function normalizePinnedFlag(input: unknown): boolean | null { function normalizePinnedFlag(input: unknown): boolean | null {
if (input === undefined || input === null) return null; if (input === undefined || input === null) return null;
if (typeof input === 'boolean') return input; if (typeof input === 'boolean') return input;
@@ -345,6 +352,19 @@ export async function accountsRoutes(app: FastifyInstance) {
spendByAccount[row.accountId] = Number(row.totalSpend || 0); spendByAccount[row.accountId] = Number(row.totalSpend || 0);
} }
const modelCountRows = await db.select({
accountId: schema.modelAvailability.accountId,
modelCount: sql<number>`count(*)`,
}).from(schema.modelAvailability)
.where(eq(schema.modelAvailability.available, true))
.groupBy(schema.modelAvailability.accountId)
.all();
const modelCountByAccount: Record<number, number> = {};
for (const row of modelCountRows) {
if (row.accountId == null) continue;
modelCountByAccount[row.accountId] = Number(row.modelCount || 0);
}
// Aggregate today's checkin rewards per account // Aggregate today's checkin rewards per account
const todayCheckins = await db.select({ const todayCheckins = await db.select({
accountId: schema.checkinLogs.accountId, accountId: schema.checkinLogs.accountId,
@@ -394,6 +414,7 @@ export async function accountsRoutes(app: FastifyInstance) {
credentialMode, credentialMode,
hasSessionTokenValue(r.accounts.accessToken), hasSessionTokenValue(r.accounts.accessToken),
).canRefreshBalance, ).canRefreshBalance,
hasDiscoveredModels: (modelCountByAccount[r.accounts.id] || 0) > 0,
}), }),
}; };
}); });
@@ -584,7 +605,7 @@ export async function accountsRoutes(app: FastifyInstance) {
try { try {
const testRes = await fetch( const testRes = await fetch(
`${site.url}/api/user/self`, `${site.url}/api/user/self`,
withExplicitProxyRequestInit(site.proxyUrl, { withSiteRecordProxyRequestInit(site, {
headers, headers,
signal: AbortSignal.timeout(ACCOUNT_VERIFY_DIAG_TIMEOUT_MS), signal: AbortSignal.timeout(ACCOUNT_VERIFY_DIAG_TIMEOUT_MS),
}), }),
@@ -763,7 +784,7 @@ export async function accountsRoutes(app: FastifyInstance) {
try { try {
const testRes = await fetch( const testRes = await fetch(
`${site.url}/api/user/self`, `${site.url}/api/user/self`,
withExplicitProxyRequestInit(site.proxyUrl, { withSiteRecordProxyRequestInit(site, {
headers, headers,
signal: AbortSignal.timeout(ACCOUNT_VERIFY_DIAG_TIMEOUT_MS), signal: AbortSignal.timeout(ACCOUNT_VERIFY_DIAG_TIMEOUT_MS),
}), }),
@@ -1193,6 +1214,68 @@ export async function accountsRoutes(app: FastifyInstance) {
return { success: true }; return { success: true };
}); });
app.post<{ Body?: { ids?: number[]; action?: string } }>('/api/accounts/batch', async (request, reply) => {
const ids = normalizeBatchIds(request.body?.ids);
const action = String(request.body?.action || '').trim();
if (ids.length === 0) {
return reply.code(400).send({ message: 'ids is required' });
}
if (!['enable', 'disable', 'delete', 'refreshBalance'].includes(action)) {
return reply.code(400).send({ message: 'Invalid action' });
}
const successIds: number[] = [];
const failedItems: Array<{ id: number; message: string }> = [];
let shouldRebuildRoutes = false;
for (const id of ids) {
try {
if (action === 'refreshBalance') {
const result = await refreshBalance(id);
if (!result) {
failedItems.push({ id, message: 'Account not found or balance refresh unsupported' });
continue;
}
successIds.push(id);
continue;
}
const existing = await db.select().from(schema.accounts).where(eq(schema.accounts.id, id)).get();
if (!existing) {
failedItems.push({ id, message: 'Account not found' });
continue;
}
if (action === 'delete') {
await db.delete(schema.accounts).where(eq(schema.accounts.id, id)).run();
shouldRebuildRoutes = true;
} else {
const nextStatus = action === 'enable' ? 'active' : 'disabled';
await db.update(schema.accounts)
.set({ status: nextStatus, updatedAt: new Date().toISOString() })
.where(eq(schema.accounts.id, id))
.run();
}
successIds.push(id);
} catch (error: any) {
failedItems.push({ id, message: error?.message || 'Batch operation failed' });
}
}
if (shouldRebuildRoutes) {
try {
await rebuildTokenRoutesFromAvailability();
} catch { }
}
return {
success: true,
successIds,
failedItems,
};
});
app.post<{ Body?: { accountId?: number; wait?: boolean } }>('/api/accounts/health/refresh', async (request, reply) => { app.post<{ Body?: { accountId?: number; wait?: boolean } }>('/api/accounts/health/refresh', async (request, reply) => {
const rawAccountId = request.body?.accountId as unknown; const rawAccountId = request.body?.accountId as unknown;
const hasAccountId = rawAccountId !== undefined && rawAccountId !== null && String(rawAccountId).trim() !== ''; const hasAccountId = rawAccountId !== undefined && rawAccountId !== null && String(rawAccountId).trim() !== '';
+140
View File
@@ -0,0 +1,140 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { mkdtempSync } from 'node:fs';
import { eq } from 'drizzle-orm';
import { mergeAccountExtraConfig } from '../../services/accountExtraConfig.js';
type DbModule = typeof import('../../db/index.js');
describe('search routes', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-search-route-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const routesModule = await import('./search.js');
db = dbModule.db;
schema = dbModule.schema;
app = Fastify();
await app.register(routesModule.searchRoutes);
});
beforeEach(async () => {
await db.delete(schema.accountTokens).run();
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.checkinLogs).run();
await db.delete(schema.proxyLogs).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 apikey connections and account tokens for global search', async () => {
const site = await db.insert(schema.sites).values({
name: 'searchable key site',
url: 'https://searchable-key.example.com',
platform: 'new-api',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: '',
accessToken: '',
apiToken: 'sk-searchable-key',
status: 'active',
extraConfig: mergeAccountExtraConfig(null, { credentialMode: 'apikey' }),
}).returning().get();
await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'searchable token',
token: 'sk-token-searchable',
tokenGroup: 'searchable-group',
enabled: true,
isDefault: true,
}).run();
const response = await app.inject({
method: 'POST',
url: '/api/search',
payload: {
query: 'searchable',
limit: 20,
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
accounts: [
expect.objectContaining({
id: account.id,
segment: 'apikey',
site: expect.objectContaining({
name: 'searchable key site',
}),
}),
],
accountTokens: [
expect.objectContaining({
name: 'searchable token',
accountId: account.id,
site: expect.objectContaining({
name: 'searchable key site',
}),
}),
],
});
});
it('finds apikey accounts by the API Key display label', async () => {
const site = await db.insert(schema.sites).values({
name: 'plain site',
url: 'https://plain.example.com',
platform: 'new-api',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: '',
accessToken: '',
apiToken: 'sk-only-key',
status: 'active',
extraConfig: mergeAccountExtraConfig(null, { credentialMode: 'apikey' }),
}).returning().get();
const response = await app.inject({
method: 'POST',
url: '/api/search',
payload: {
query: 'API Key',
limit: 20,
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
accounts: [
expect.objectContaining({
id: account.id,
segment: 'apikey',
}),
],
});
});
});
+76 -6
View File
@@ -1,7 +1,36 @@
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from 'fastify';
import { db, schema } from '../../db/index.js'; import { db, schema } from '../../db/index.js';
import { and, like, desc, eq } from 'drizzle-orm'; import { and, like, desc, eq, or } from 'drizzle-orm';
import { getProxyLogBaseSelectFields } from '../../services/proxyLogStore.js'; import { getProxyLogBaseSelectFields } from '../../services/proxyLogStore.js';
import { getCredentialModeFromExtraConfig } from '../../services/accountExtraConfig.js';
function hasSessionTokenValue(value: string | null | undefined): boolean {
return typeof value === 'string' && value.trim().length > 0;
}
function resolveAccountSearchSegment(account: typeof schema.accounts.$inferSelect): 'session' | 'apikey' {
const explicit = getCredentialModeFromExtraConfig(account.extraConfig);
if (explicit === 'apikey') return 'apikey';
if (explicit === 'session') return 'session';
return hasSessionTokenValue(account.accessToken) ? 'session' : 'apikey';
}
function normalizeSearchQuery(input: string): string {
return input.trim().toLowerCase().replace(/\s+/g, ' ');
}
function matchesApiKeyDisplayLabel(query: string): boolean {
const normalized = normalizeSearchQuery(query);
return [
'apikey',
'api key',
'api-key',
'api key 连接',
'apikey 连接',
'api key connection',
'apikey connection',
].some((keyword) => normalized.includes(keyword));
}
export async function searchRoutes(app: FastifyInstance) { export async function searchRoutes(app: FastifyInstance) {
const proxyLogBaseFields = getProxyLogBaseSelectFields(); const proxyLogBaseFields = getProxyLogBaseSelectFields();
@@ -9,11 +38,11 @@ export async function searchRoutes(app: FastifyInstance) {
app.post<{ Body: { query: string; limit?: number } }>('/api/search', async (request) => { app.post<{ Body: { query: string; limit?: number } }>('/api/search', async (request) => {
const { query, limit = 20 } = request.body; const { query, limit = 20 } = request.body;
if (!query || query.trim().length === 0) { if (!query || query.trim().length === 0) {
return { accounts: [], sites: [], checkinLogs: [], proxyLogs: [], models: [] }; return { accounts: [], accountTokens: [], sites: [], checkinLogs: [], proxyLogs: [], models: [] };
} }
const q = `%${query.trim()}%`; const q = `%${query.trim()}%`;
const perCategory = Math.min(Math.ceil(limit / 5), 10); const perCategory = Math.min(Math.ceil(limit / 6), 10);
// Search sites // Search sites
const sites = (await db.select().from(schema.sites) const sites = (await db.select().from(schema.sites)
@@ -30,9 +59,50 @@ export async function searchRoutes(app: FastifyInstance) {
// Search accounts (join with sites for site name) // Search accounts (join with sites for site name)
const accountResults = await db.select().from(schema.accounts) const accountResults = await db.select().from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(like(schema.accounts.username, q)) .where(or(
like(schema.accounts.username, q),
like(schema.sites.name, q),
))
.limit(perCategory).all(); .limit(perCategory).all();
const accounts = accountResults.map(r => ({ ...r.accounts, site: r.sites })); const apiKeyLabelMatches = matchesApiKeyDisplayLabel(query);
const apiKeyAccountResults = apiKeyLabelMatches
? await db.select().from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(or(
eq(schema.accounts.accessToken, ''),
like(schema.accounts.extraConfig, '%"credentialMode":"apikey"%'),
))
.limit(perCategory)
.all()
: [];
const accounts = [...new Map([...accountResults, ...apiKeyAccountResults].map((r) => [r.accounts.id, ({
...r.accounts,
segment: resolveAccountSearchSegment(r.accounts),
site: r.sites,
})])).values()].slice(0, perCategory);
// Search account tokens by token name/group/account/site
const tokenResults = await db.select().from(schema.accountTokens)
.innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(or(
like(schema.accountTokens.name, q),
like(schema.accountTokens.tokenGroup, q),
like(schema.accounts.username, q),
like(schema.sites.name, q),
))
.orderBy(desc(schema.accountTokens.updatedAt))
.limit(perCategory)
.all();
const accountTokens = tokenResults.map(r => ({
...r.account_tokens,
account: {
id: r.accounts.id,
username: r.accounts.username,
segment: resolveAccountSearchSegment(r.accounts),
},
site: r.sites,
}));
// Search checkin logs (by message) // Search checkin logs (by message)
const checkinLogs = (await db.select().from(schema.checkinLogs) const checkinLogs = (await db.select().from(schema.checkinLogs)
@@ -96,6 +166,6 @@ export async function searchRoutes(app: FastifyInstance) {
}) })
.slice(0, perCategory); .slice(0, perCategory);
return { accounts, sites: uniqueSites, checkinLogs, proxyLogs, models }; return { accounts, accountTokens, sites: uniqueSites, checkinLogs, proxyLogs, models };
}); });
} }
@@ -40,6 +40,7 @@ describe('settings and auth events', () => {
config.authToken = 'old-admin-token-123'; config.authToken = 'old-admin-token-123';
config.proxyToken = 'sk-old-proxy-token-123'; config.proxyToken = 'sk-old-proxy-token-123';
config.systemProxyUrl = '';
config.checkinCron = '0 8 * * *'; config.checkinCron = '0 8 * * *';
config.balanceRefreshCron = '0 * * * *'; config.balanceRefreshCron = '0 * * * *';
config.routingFallbackUnitCost = 1; config.routingFallbackUnitCost = 1;
@@ -191,6 +192,64 @@ describe('settings and auth events', () => {
expect(runtime.routingFallbackUnitCost).toBe(0.25); expect(runtime.routingFallbackUnitCost).toBe(0.25);
}); });
it('persists and returns system proxy url from runtime settings', async () => {
const updateResponse = await app.inject({
method: 'PUT',
url: '/api/settings/runtime',
payload: {
systemProxyUrl: 'http://127.0.0.1:7890',
},
});
expect(updateResponse.statusCode).toBe(200);
const updated = updateResponse.json() as { systemProxyUrl?: string };
expect(updated.systemProxyUrl).toBe('http://127.0.0.1:7890');
expect(config.systemProxyUrl).toBe('http://127.0.0.1:7890');
const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'system_proxy_url')).get();
expect(saved).toBeTruthy();
expect(saved?.value).toBe(JSON.stringify('http://127.0.0.1:7890'));
const getResponse = await app.inject({
method: 'GET',
url: '/api/settings/runtime',
});
expect(getResponse.statusCode).toBe(200);
const runtime = getResponse.json() as { systemProxyUrl?: string };
expect(runtime.systemProxyUrl).toBe('http://127.0.0.1:7890');
});
it('invalidates cached site proxy resolution when system proxy url changes', async () => {
await db.insert(schema.sites).values({
name: 'proxy-site',
url: 'https://proxy-site.example.com',
platform: 'new-api',
useSystemProxy: true,
}).run();
const { resolveSiteProxyUrlByRequestUrl } = await import('../../services/siteProxy.js');
const firstUpdate = await app.inject({
method: 'PUT',
url: '/api/settings/runtime',
payload: {
systemProxyUrl: 'http://127.0.0.1:7890',
},
});
expect(firstUpdate.statusCode).toBe(200);
expect(await resolveSiteProxyUrlByRequestUrl('https://proxy-site.example.com/v1/chat/completions')).toBe('http://127.0.0.1:7890');
const secondUpdate = await app.inject({
method: 'PUT',
url: '/api/settings/runtime',
payload: {
systemProxyUrl: 'http://127.0.0.1:7891',
},
});
expect(secondUpdate.statusCode).toBe(200);
expect(await resolveSiteProxyUrlByRequestUrl('https://proxy-site.example.com/v1/chat/completions')).toBe('http://127.0.0.1:7891');
});
it('rejects allowlist update that does not include current request IP', async () => { it('rejects allowlist update that does not include current request IP', async () => {
const response = await app.inject({ const response = await app.inject({
method: 'PUT', method: 'PUT',
+24 -1
View File
@@ -16,11 +16,13 @@ import {
} from '../../services/databaseMigrationService.js'; } from '../../services/databaseMigrationService.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { extractClientIp, isIpAllowed } from '../../middleware/auth.js'; import { extractClientIp, isIpAllowed } from '../../middleware/auth.js';
import { invalidateSiteProxyCache, normalizeSiteProxyUrl } from '../../services/siteProxy.js';
type RoutingWeights = typeof config.routingWeights; type RoutingWeights = typeof config.routingWeights;
interface RuntimeSettingsBody { interface RuntimeSettingsBody {
proxyToken?: string; proxyToken?: string;
systemProxyUrl?: string;
checkinCron?: string; checkinCron?: string;
balanceRefreshCron?: string; balanceRefreshCron?: string;
webhookUrl?: string; webhookUrl?: string;
@@ -156,6 +158,11 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
config.proxyToken = nextToken; config.proxyToken = nextToken;
return; return;
} }
case 'system_proxy_url': {
if (typeof value !== 'string') return;
config.systemProxyUrl = normalizeSiteProxyUrl(value) || '';
return;
}
case 'webhook_url': { case 'webhook_url': {
if (typeof value !== 'string') return; if (typeof value !== 'string') return;
config.webhookUrl = value.trim(); config.webhookUrl = value.trim();
@@ -295,6 +302,7 @@ function getRuntimeSettingsResponse(currentAdminIp = '') {
notifyCooldownSec: config.notifyCooldownSec, notifyCooldownSec: config.notifyCooldownSec,
adminIpAllowlist: config.adminIpAllowlist, adminIpAllowlist: config.adminIpAllowlist,
currentAdminIp, currentAdminIp,
systemProxyUrl: config.systemProxyUrl,
proxyTokenMasked: maskSecret(config.proxyToken), proxyTokenMasked: maskSecret(config.proxyToken),
}; };
} }
@@ -471,6 +479,22 @@ export async function settingsRoutes(app: FastifyInstance) {
upsertSetting('proxy_token', proxyToken); upsertSetting('proxy_token', proxyToken);
} }
if (body.systemProxyUrl !== undefined) {
const rawSystemProxyUrl = String(body.systemProxyUrl || '').trim();
const normalizedSystemProxyUrl = rawSystemProxyUrl
? normalizeSiteProxyUrl(rawSystemProxyUrl)
: '';
if (rawSystemProxyUrl && !normalizedSystemProxyUrl) {
return reply.code(400).send({ success: false, message: '系统代理地址无效,请填写合法的 http(s)/socks 代理 URL' });
}
if (normalizedSystemProxyUrl !== config.systemProxyUrl) {
changedLabels.push('系统代理');
}
config.systemProxyUrl = normalizedSystemProxyUrl || '';
upsertSetting('system_proxy_url', config.systemProxyUrl);
invalidateSiteProxyCache();
}
if (body.webhookUrl !== undefined) { if (body.webhookUrl !== undefined) {
if (String(body.webhookUrl || '').trim() !== config.webhookUrl) { if (String(body.webhookUrl || '').trim() !== config.webhookUrl) {
changedLabels.push('Webhook 地址'); changedLabels.push('Webhook 地址');
@@ -887,4 +911,3 @@ export async function settingsRoutes(app: FastifyInstance) {
}; };
}); });
} }
+92
View File
@@ -0,0 +1,92 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
type DbModule = typeof import('../../db/index.js');
describe('sites batch routes', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-sites-batch-'));
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.accounts).run();
await db.delete(schema.sites).run();
});
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('enables system proxy for selected sites and reports failures', async () => {
await db.insert(schema.sites).values([
{
id: 1,
name: 'site-1',
url: 'https://site-1.example.com',
platform: 'new-api',
useSystemProxy: false,
},
{
id: 2,
name: 'site-2',
url: 'https://site-2.example.com',
platform: 'new-api',
useSystemProxy: false,
},
]).run();
const response = await app.inject({
method: 'POST',
url: '/api/sites/batch',
payload: {
ids: [1, 2, 999],
action: 'enableSystemProxy',
},
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
successIds?: number[];
failedItems?: Array<{ id: number; message: string }>;
};
expect(body.successIds).toEqual([1, 2]);
expect(body.failedItems).toHaveLength(1);
expect(body.failedItems?.[0]?.id).toBe(999);
const rows = await db.select().from(schema.sites).all();
expect(rows.every((row) => row.useSystemProxy === true)).toBe(true);
});
it('rejects invalid sites batch action', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/sites/batch',
payload: {
ids: [1],
action: 'nope',
},
});
expect(response.statusCode).toBe(400);
expect((response.json() as { message?: string }).message).toContain('action');
});
});
+34 -8
View File
@@ -6,7 +6,7 @@ import { mkdtempSync } from 'node:fs';
type DbModule = typeof import('../../db/index.js'); type DbModule = typeof import('../../db/index.js');
describe('sites proxy url settings', () => { describe('sites system proxy settings', () => {
let app: FastifyInstance; let app: FastifyInstance;
let db: DbModule['db']; let db: DbModule['db'];
let schema: DbModule['schema']; let schema: DbModule['schema'];
@@ -36,7 +36,7 @@ describe('sites proxy url settings', () => {
delete process.env.DATA_DIR; delete process.env.DATA_DIR;
}); });
it('stores proxy url and external checkin url when creating a site', async () => { it('stores useSystemProxy and external checkin url when creating a site', async () => {
const response = await app.inject({ const response = await app.inject({
method: 'POST', method: 'POST',
url: '/api/sites', url: '/api/sites',
@@ -44,7 +44,7 @@ describe('sites proxy url settings', () => {
name: 'proxy-site', name: 'proxy-site',
url: 'https://proxy-site.example.com', url: 'https://proxy-site.example.com',
platform: 'new-api', platform: 'new-api',
proxyUrl: 'http://127.0.0.1:7890', useSystemProxy: true,
externalCheckinUrl: 'https://checkin.example.com/welfare', externalCheckinUrl: 'https://checkin.example.com/welfare',
globalWeight: 1.5, globalWeight: 1.5,
}, },
@@ -52,16 +52,16 @@ describe('sites proxy url settings', () => {
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);
const payload = response.json() as { const payload = response.json() as {
proxyUrl?: string | null; useSystemProxy?: boolean;
externalCheckinUrl?: string | null; externalCheckinUrl?: string | null;
globalWeight?: number; globalWeight?: number;
}; };
expect(payload.proxyUrl).toBe('http://127.0.0.1:7890'); expect(payload.useSystemProxy).toBe(true);
expect(payload.externalCheckinUrl).toBe('https://checkin.example.com/welfare'); expect(payload.externalCheckinUrl).toBe('https://checkin.example.com/welfare');
expect(payload.globalWeight).toBe(1.5); expect(payload.globalWeight).toBe(1.5);
}); });
it('rejects invalid proxy url', async () => { it('rejects invalid useSystemProxy flag', async () => {
const response = await app.inject({ const response = await app.inject({
method: 'POST', method: 'POST',
url: '/api/sites', url: '/api/sites',
@@ -69,12 +69,12 @@ describe('sites proxy url settings', () => {
name: 'proxy-site', name: 'proxy-site',
url: 'https://proxy-site.example.com', url: 'https://proxy-site.example.com',
platform: 'new-api', platform: 'new-api',
proxyUrl: 'not-a-url', useSystemProxy: 'not-a-boolean',
}, },
}); });
expect(response.statusCode).toBe(400); expect(response.statusCode).toBe(400);
expect((response.json() as { error?: string }).error).toContain('Invalid proxyUrl'); expect((response.json() as { error?: string }).error).toContain('Invalid useSystemProxy');
}); });
it('rejects invalid site global weight', async () => { it('rejects invalid site global weight', async () => {
@@ -108,4 +108,30 @@ describe('sites proxy url settings', () => {
expect(response.statusCode).toBe(400); expect(response.statusCode).toBe(400);
expect((response.json() as { error?: string }).error).toContain('Invalid externalCheckinUrl'); expect((response.json() as { error?: string }).error).toContain('Invalid externalCheckinUrl');
}); });
it('updates useSystemProxy for an existing site', async () => {
const created = await app.inject({
method: 'POST',
url: '/api/sites',
payload: {
name: 'toggle-site',
url: 'https://toggle-site.example.com',
platform: 'new-api',
useSystemProxy: false,
},
});
expect(created.statusCode).toBe(200);
const site = created.json() as { id: number };
const response = await app.inject({
method: 'PUT',
url: `/api/sites/${site.id}`,
payload: {
useSystemProxy: true,
},
});
expect(response.statusCode).toBe(200);
expect((response.json() as { useSystemProxy?: boolean }).useSystemProxy).toBe(true);
});
}); });
+125 -50
View File
@@ -2,7 +2,7 @@ import { FastifyInstance } from 'fastify';
import { db, schema } from '../../db/index.js'; import { db, schema } from '../../db/index.js';
import { and, eq, sql } from 'drizzle-orm'; import { and, eq, sql } from 'drizzle-orm';
import { detectSite } from '../../services/siteDetector.js'; import { detectSite } from '../../services/siteDetector.js';
import { invalidateSiteProxyCache, parseSiteProxyUrlInput } from '../../services/siteProxy.js'; import { invalidateSiteProxyCache } from '../../services/siteProxy.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
function normalizeSiteStatus(input: unknown): 'active' | 'disabled' | null { function normalizeSiteStatus(input: unknown): 'active' | 'disabled' | null {
@@ -25,6 +25,10 @@ function normalizePinnedFlag(input: unknown): boolean | null {
return null; return null;
} }
function normalizeUseSystemProxyFlag(input: unknown): boolean | null {
return normalizePinnedFlag(input);
}
function normalizeSortOrder(input: unknown): number | null { function normalizeSortOrder(input: unknown): number | null {
if (input === undefined || input === null || input === '') return null; if (input === undefined || input === null || input === '') return null;
const parsed = Number.parseInt(String(input), 10); const parsed = Number.parseInt(String(input), 10);
@@ -70,6 +74,59 @@ function normalizeOptionalExternalCheckinUrl(input: unknown): {
} }
export async function sitesRoutes(app: FastifyInstance) { export async function sitesRoutes(app: FastifyInstance) {
async function applySiteStatusSideEffects(
siteId: number,
existingSiteName: string,
normalizedStatus: 'active' | 'disabled',
) {
const now = new Date().toISOString();
if (normalizedStatus === 'disabled') {
await db.update(schema.accounts)
.set({ status: 'disabled', updatedAt: now })
.where(eq(schema.accounts.siteId, siteId))
.run();
try {
const createdAt = formatUtcSqlDateTime(new Date());
await db.insert(schema.events).values({
type: 'status',
title: '站点已禁用',
message: `${existingSiteName} 已禁用,关联账号已全部置为禁用`,
level: 'warning',
relatedId: siteId,
relatedType: 'site',
createdAt,
}).run();
} catch {}
return;
}
await db.update(schema.accounts)
.set({ status: 'active', updatedAt: now })
.where(and(eq(schema.accounts.siteId, siteId), eq(schema.accounts.status, 'disabled')))
.run();
try {
const createdAt = formatUtcSqlDateTime(new Date());
await db.insert(schema.events).values({
type: 'status',
title: '站点已启用',
message: `${existingSiteName} 已启用,关联禁用账号已恢复为活跃`,
level: 'info',
relatedId: siteId,
relatedType: 'site',
createdAt,
}).run();
} catch {}
}
function normalizeBatchIds(input: unknown): number[] {
if (!Array.isArray(input)) return [];
return input
.map((item) => Number.parseInt(String(item), 10))
.filter((id) => Number.isFinite(id) && id > 0);
}
// List all sites // List all sites
app.get('/api/sites', async () => { app.get('/api/sites', async () => {
const siteRows = await db.select().from(schema.sites).all(); const siteRows = await db.select().from(schema.sites).all();
@@ -96,21 +153,21 @@ export async function sitesRoutes(app: FastifyInstance) {
name: string; name: string;
url: string; url: string;
platform?: string; platform?: string;
proxyUrl?: string | null; useSystemProxy?: boolean;
externalCheckinUrl?: string | null; externalCheckinUrl?: string | null;
status?: string; status?: string;
isPinned?: boolean; isPinned?: boolean;
sortOrder?: number; sortOrder?: number;
globalWeight?: number; globalWeight?: number;
} }>('/api/sites', async (request, reply) => { } }>('/api/sites', async (request, reply) => {
const { name, url, platform, proxyUrl, externalCheckinUrl, status, isPinned, sortOrder, globalWeight } = request.body; const { name, url, platform, useSystemProxy, externalCheckinUrl, status, isPinned, sortOrder, globalWeight } = request.body;
const normalizedStatus = normalizeSiteStatus(status); const normalizedStatus = normalizeSiteStatus(status);
if (status !== undefined && !normalizedStatus) { if (status !== undefined && !normalizedStatus) {
return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' }); return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' });
} }
const parsedProxyUrl = parseSiteProxyUrlInput(proxyUrl); const normalizedUseSystemProxy = normalizeUseSystemProxyFlag(useSystemProxy);
if (!parsedProxyUrl.valid) { if (useSystemProxy !== undefined && normalizedUseSystemProxy === null) {
return reply.code(400).send({ error: 'Invalid proxyUrl. Expected a valid http(s)/socks proxy URL.' }); return reply.code(400).send({ error: 'Invalid useSystemProxy value. Expected boolean.' });
} }
const normalizedExternalCheckinUrl = normalizeOptionalExternalCheckinUrl(externalCheckinUrl); const normalizedExternalCheckinUrl = normalizeOptionalExternalCheckinUrl(externalCheckinUrl);
if (!normalizedExternalCheckinUrl.valid) { if (!normalizedExternalCheckinUrl.valid) {
@@ -144,7 +201,7 @@ export async function sitesRoutes(app: FastifyInstance) {
name, name,
url: url.replace(/\/+$/, ''), url: url.replace(/\/+$/, ''),
platform: detectedPlatform, platform: detectedPlatform,
proxyUrl: parsedProxyUrl.proxyUrl, useSystemProxy: normalizedUseSystemProxy ?? false,
externalCheckinUrl: normalizedExternalCheckinUrl.url, externalCheckinUrl: normalizedExternalCheckinUrl.url,
status: normalizedStatus ?? 'active', status: normalizedStatus ?? 'active',
isPinned: normalizedPinned ?? false, isPinned: normalizedPinned ?? false,
@@ -168,7 +225,7 @@ export async function sitesRoutes(app: FastifyInstance) {
name?: string; name?: string;
url?: string; url?: string;
platform?: string; platform?: string;
proxyUrl?: string | null; useSystemProxy?: boolean;
externalCheckinUrl?: string | null; externalCheckinUrl?: string | null;
status?: string; status?: string;
isPinned?: boolean; isPinned?: boolean;
@@ -191,9 +248,9 @@ export async function sitesRoutes(app: FastifyInstance) {
if (body.status !== undefined && !normalizedStatus) { if (body.status !== undefined && !normalizedStatus) {
return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' }); return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' });
} }
const parsedProxyUrl = parseSiteProxyUrlInput(body.proxyUrl); const normalizedUseSystemProxy = normalizeUseSystemProxyFlag(body.useSystemProxy);
if (!parsedProxyUrl.valid) { if (body.useSystemProxy !== undefined && normalizedUseSystemProxy === null) {
return reply.code(400).send({ error: 'Invalid proxyUrl. Expected a valid http(s)/socks proxy URL.' }); return reply.code(400).send({ error: 'Invalid useSystemProxy value. Expected boolean.' });
} }
const normalizedExternalCheckinUrl = normalizeOptionalExternalCheckinUrl(body.externalCheckinUrl); const normalizedExternalCheckinUrl = normalizeOptionalExternalCheckinUrl(body.externalCheckinUrl);
if (!normalizedExternalCheckinUrl.valid) { if (!normalizedExternalCheckinUrl.valid) {
@@ -215,7 +272,7 @@ export async function sitesRoutes(app: FastifyInstance) {
if (body.name !== undefined) updates.name = body.name; if (body.name !== undefined) updates.name = body.name;
if (body.url !== undefined) updates.url = body.url.replace(/\/+$/, ''); if (body.url !== undefined) updates.url = body.url.replace(/\/+$/, '');
if (body.platform !== undefined) updates.platform = body.platform; if (body.platform !== undefined) updates.platform = body.platform;
if (parsedProxyUrl.present) updates.proxyUrl = parsedProxyUrl.proxyUrl; if (body.useSystemProxy !== undefined) updates.useSystemProxy = normalizedUseSystemProxy;
if (normalizedExternalCheckinUrl.present) updates.externalCheckinUrl = normalizedExternalCheckinUrl.url; if (normalizedExternalCheckinUrl.present) updates.externalCheckinUrl = normalizedExternalCheckinUrl.url;
if (body.status !== undefined) updates.status = normalizedStatus; if (body.status !== undefined) updates.status = normalizedStatus;
if (body.isPinned !== undefined) updates.isPinned = normalizedPinned; if (body.isPinned !== undefined) updates.isPinned = normalizedPinned;
@@ -226,44 +283,7 @@ export async function sitesRoutes(app: FastifyInstance) {
invalidateSiteProxyCache(); invalidateSiteProxyCache();
if (body.status !== undefined && normalizedStatus) { if (body.status !== undefined && normalizedStatus) {
const now = new Date().toISOString(); await applySiteStatusSideEffects(id, existingSite.name, normalizedStatus);
if (normalizedStatus === 'disabled') {
await db.update(schema.accounts)
.set({ status: 'disabled', updatedAt: now })
.where(eq(schema.accounts.siteId, id))
.run();
try {
const createdAt = formatUtcSqlDateTime(new Date());
await db.insert(schema.events).values({
type: 'status',
title: '站点已禁用',
message: `${existingSite.name} 已禁用,关联账号已全部置为禁用`,
level: 'warning',
relatedId: id,
relatedType: 'site',
createdAt,
}).run();
} catch {}
} else {
await db.update(schema.accounts)
.set({ status: 'active', updatedAt: now })
.where(and(eq(schema.accounts.siteId, id), eq(schema.accounts.status, 'disabled')))
.run();
try {
const createdAt = formatUtcSqlDateTime(new Date());
await db.insert(schema.events).values({
type: 'status',
title: '站点已启用',
message: `${existingSite.name} 已启用,关联禁用账号已恢复为活跃`,
level: 'info',
relatedId: id,
relatedType: 'site',
createdAt,
}).run();
} catch {}
}
} }
return await db.select().from(schema.sites).where(eq(schema.sites.id, id)).get(); return await db.select().from(schema.sites).where(eq(schema.sites.id, id)).get();
@@ -277,6 +297,61 @@ export async function sitesRoutes(app: FastifyInstance) {
return { success: true }; return { success: true };
}); });
app.post<{ Body?: { ids?: number[]; action?: string } }>('/api/sites/batch', async (request, reply) => {
const ids = normalizeBatchIds(request.body?.ids);
const action = String(request.body?.action || '').trim();
if (ids.length === 0) {
return reply.code(400).send({ message: 'ids is required' });
}
if (!['enable', 'disable', 'delete', 'enableSystemProxy', 'disableSystemProxy'].includes(action)) {
return reply.code(400).send({ message: 'Invalid action' });
}
const successIds: number[] = [];
const failedItems: Array<{ id: number; message: string }> = [];
for (const id of ids) {
const existingSite = await db.select().from(schema.sites).where(eq(schema.sites.id, id)).get();
if (!existingSite) {
failedItems.push({ id, message: 'Site not found' });
continue;
}
try {
if (action === 'delete') {
await db.delete(schema.sites).where(eq(schema.sites.id, id)).run();
} else if (action === 'enableSystemProxy') {
await db.update(schema.sites)
.set({ useSystemProxy: true, updatedAt: new Date().toISOString() })
.where(eq(schema.sites.id, id))
.run();
} else if (action === 'disableSystemProxy') {
await db.update(schema.sites)
.set({ useSystemProxy: false, updatedAt: new Date().toISOString() })
.where(eq(schema.sites.id, id))
.run();
} else {
const nextStatus = action === 'enable' ? 'active' : 'disabled';
await db.update(schema.sites)
.set({ status: nextStatus, updatedAt: new Date().toISOString() })
.where(eq(schema.sites.id, id))
.run();
await applySiteStatusSideEffects(id, existingSite.name, nextStatus);
}
successIds.push(id);
} catch (error: any) {
failedItems.push({ id, message: error?.message || 'Batch operation failed' });
}
}
invalidateSiteProxyCache();
return {
success: true,
successIds,
failedItems,
};
});
// 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);
+4 -4
View File
@@ -8,7 +8,7 @@ import { isTokenExpiredError } from '../../services/alertRules.js';
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js'; import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js'; import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js'; import { resolveProxyUrlForSite, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
import { import {
type DownstreamFormat, type DownstreamFormat,
createStreamTransformContext, createStreamTransformContext,
@@ -183,7 +183,7 @@ async function handleChatProxyRequest(
try { try {
const endpointResult = await executeEndpointFlow({ const endpointResult = await executeEndpointFlow({
siteUrl: selected.site.url, siteUrl: selected.site.url,
proxyUrl: selected.site.proxyUrl, proxyUrl: resolveProxyUrlForSite(selected.site),
endpointCandidates, endpointCandidates,
buildRequest: (endpoint) => { buildRequest: (endpoint) => {
const endpointRequest = buildUpstreamEndpointRequest({ const endpointRequest = buildUpstreamEndpointRequest({
@@ -225,7 +225,7 @@ async function handleChatProxyRequest(
downstreamHeaders: request.headers as Record<string, unknown>, downstreamHeaders: request.headers as Record<string, unknown>,
}); });
const normalizedTargetUrl = `${selected.site.url}${normalizedClaudeRequest.path}`; const normalizedTargetUrl = `${selected.site.url}${normalizedClaudeRequest.path}`;
const normalizedResponse = await fetch(normalizedTargetUrl, withExplicitProxyRequestInit(selected.site.proxyUrl, { const normalizedResponse = await fetch(normalizedTargetUrl, withSiteRecordProxyRequestInit(selected.site, {
method: 'POST', method: 'POST',
headers: normalizedClaudeRequest.headers, headers: normalizedClaudeRequest.headers,
body: JSON.stringify(normalizedClaudeRequest.body), body: JSON.stringify(normalizedClaudeRequest.body),
@@ -264,7 +264,7 @@ async function handleChatProxyRequest(
return null; return null;
} }
const minimalResponse = await fetch(ctx.targetUrl, withExplicitProxyRequestInit(selected.site.proxyUrl, { const minimalResponse = await fetch(ctx.targetUrl, withSiteRecordProxyRequestInit(selected.site, {
method: 'POST', method: 'POST',
headers: minimalHeaders, headers: minimalHeaders,
body: JSON.stringify(ctx.request.body), body: JSON.stringify(ctx.request.body),
+2 -2
View File
@@ -9,7 +9,7 @@ import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js'; import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
import { mergeProxyUsage, parseProxyUsage, pullSseDataEvents } from '../../services/proxyUsageParser.js'; import { mergeProxyUsage, parseProxyUsage, pullSseDataEvents } from '../../services/proxyUsageParser.js';
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js'; import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js'; import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
import { composeProxyLogMessage } from './logPathMeta.js'; import { composeProxyLogMessage } from './logPathMeta.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { resolveProxyLogBilling } from './proxyBilling.js'; import { resolveProxyLogBilling } from './proxyBilling.js';
@@ -57,7 +57,7 @@ export async function completionsProxyRoute(app: FastifyInstance) {
const startTime = Date.now(); const startTime = Date.now();
try { try {
const upstream = await fetch(targetUrl, withExplicitProxyRequestInit(selected.site.proxyUrl, { const upstream = await fetch(targetUrl, withSiteRecordProxyRequestInit(selected.site, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
+2 -2
View File
@@ -9,7 +9,7 @@ import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js'; import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
import { parseProxyUsage } from '../../services/proxyUsageParser.js'; import { parseProxyUsage } from '../../services/proxyUsageParser.js';
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js'; import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js'; import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
import { composeProxyLogMessage } from './logPathMeta.js'; import { composeProxyLogMessage } from './logPathMeta.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { resolveProxyLogBilling } from './proxyBilling.js'; import { resolveProxyLogBilling } from './proxyBilling.js';
@@ -54,7 +54,7 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
const startTime = Date.now(); const startTime = Date.now();
try { try {
const upstream = await fetch(targetUrl, withExplicitProxyRequestInit(selected.site.proxyUrl, { const upstream = await fetch(targetUrl, withSiteRecordProxyRequestInit(selected.site, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
+2 -2
View File
@@ -8,7 +8,7 @@ import { isTokenExpiredError } from '../../services/alertRules.js';
import { estimateProxyCost } from '../../services/modelPricingService.js'; import { estimateProxyCost } from '../../services/modelPricingService.js';
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js'; import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js'; import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
import { composeProxyLogMessage } from './logPathMeta.js'; import { composeProxyLogMessage } from './logPathMeta.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
@@ -50,7 +50,7 @@ export async function imagesProxyRoute(app: FastifyInstance) {
const startTime = Date.now(); const startTime = Date.now();
try { try {
const upstream = await fetch(targetUrl, withExplicitProxyRequestInit(selected.site.proxyUrl, { const upstream = await fetch(targetUrl, withSiteRecordProxyRequestInit(selected.site, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -0,0 +1,140 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const fetchMock = vi.fn();
const selectChannelMock = vi.fn();
const selectNextChannelMock = vi.fn();
const recordSuccessMock = vi.fn();
const recordFailureMock = vi.fn();
const refreshModelsAndRebuildRoutesMock = vi.fn();
const reportProxyAllFailedMock = vi.fn();
const reportTokenExpiredMock = vi.fn();
const estimateProxyCostMock = vi.fn(async (_arg?: any) => 0);
const buildProxyBillingDetailsMock = vi.fn(async (_arg?: any) => null);
const fetchModelPricingCatalogMock = vi.fn(async (_arg?: any): Promise<any> => null);
const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) => ({
...usage,
estimatedCostFromQuota: 0,
recoveredFromSelfLog: false,
}));
const dbInsertMock = vi.fn((_arg?: any) => ({
values: () => ({
run: () => undefined,
}),
}));
vi.mock('undici', () => ({
fetch: (...args: unknown[]) => fetchMock(...args),
}));
vi.mock('../../services/tokenRouter.js', () => ({
tokenRouter: {
selectChannel: (...args: unknown[]) => selectChannelMock(...args),
selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args),
recordSuccess: (...args: unknown[]) => recordSuccessMock(...args),
recordFailure: (...args: unknown[]) => recordFailureMock(...args),
},
}));
vi.mock('../../services/modelService.js', () => ({
refreshModelsAndRebuildRoutes: (...args: unknown[]) => refreshModelsAndRebuildRoutesMock(...args),
}));
vi.mock('../../services/alertService.js', () => ({
reportProxyAllFailed: (...args: unknown[]) => reportProxyAllFailedMock(...args),
reportTokenExpired: (...args: unknown[]) => reportTokenExpiredMock(...args),
}));
vi.mock('../../services/alertRules.js', () => ({
isTokenExpiredError: () => false,
}));
vi.mock('../../services/modelPricingService.js', () => ({
estimateProxyCost: (arg: any) => estimateProxyCostMock(arg),
buildProxyBillingDetails: (arg: any) => buildProxyBillingDetailsMock(arg),
fetchModelPricingCatalog: (arg: any) => fetchModelPricingCatalogMock(arg),
}));
vi.mock('../../services/proxyRetryPolicy.js', () => ({
shouldRetryProxyRequest: () => false,
}));
vi.mock('../../services/proxyUsageFallbackService.js', () => ({
resolveProxyUsageWithSelfLogFallback: (arg: any) => resolveProxyUsageWithSelfLogFallbackMock(arg),
}));
vi.mock('../../db/index.js', () => ({
db: {
insert: (arg: any) => dbInsertMock(arg),
},
schema: {
proxyLogs: {},
},
}));
describe('responses proxy compact upstream routing', () => {
let app: FastifyInstance;
beforeAll(async () => {
const { responsesProxyRoute } = await import('./responses.js');
app = Fastify();
await app.register(responsesProxyRoute);
});
beforeEach(() => {
fetchMock.mockReset();
selectChannelMock.mockReset();
selectNextChannelMock.mockReset();
recordSuccessMock.mockReset();
recordFailureMock.mockReset();
refreshModelsAndRebuildRoutesMock.mockReset();
reportProxyAllFailedMock.mockReset();
reportTokenExpiredMock.mockReset();
estimateProxyCostMock.mockClear();
buildProxyBillingDetailsMock.mockClear();
fetchModelPricingCatalogMock.mockReset();
resolveProxyUsageWithSelfLogFallbackMock.mockClear();
dbInsertMock.mockClear();
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { id: 44, name: 'demo-site', url: 'https://upstream.example.com', platform: 'openai' },
account: { id: 33, username: 'demo-user' },
tokenName: 'default',
tokenValue: 'sk-demo',
actualModel: 'upstream-gpt',
});
selectNextChannelMock.mockReturnValue(null);
fetchModelPricingCatalogMock.mockResolvedValue(null);
});
afterAll(async () => {
await app.close();
});
it('forwards compact requests to the upstream /v1/responses/compact path first', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'resp_123',
object: 'response',
output_text: 'hello from compact',
usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/responses/compact',
payload: {
model: 'gpt-5.2',
input: 'hello',
},
});
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [targetUrl] = fetchMock.mock.calls[0] as [string, any];
expect(targetUrl).toContain('/v1/responses/compact');
});
});
@@ -0,0 +1,32 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
describe('responses proxy compact route', () => {
let app: FastifyInstance;
beforeAll(async () => {
const { responsesProxyRoute } = await import('./responses.js');
app = Fastify();
await app.register(responsesProxyRoute);
});
afterAll(async () => {
await app.close();
});
it('accepts /v1/responses/compact requests instead of returning 404', async () => {
const response = await app.inject({
method: 'POST',
url: '/v1/responses/compact',
payload: {},
});
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({
error: {
message: 'model is required',
type: 'invalid_request_error',
},
});
});
});
+22 -8
View File
@@ -8,7 +8,7 @@ import { isTokenExpiredError } from '../../services/alertRules.js';
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js'; import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js'; import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js'; import { resolveProxyUrlForSite, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
import { import {
createStreamTransformContext, createStreamTransformContext,
normalizeUpstreamFinalResponse, normalizeUpstreamFinalResponse,
@@ -1137,15 +1137,19 @@ function serializeConvertedResponsesEvents(input: {
} }
export async function responsesProxyRoute(app: FastifyInstance) { export async function responsesProxyRoute(app: FastifyInstance) {
app.post('/v1/responses', async (request: FastifyRequest, reply: FastifyReply) => { const handleResponsesRequest = async (
request: FastifyRequest,
reply: FastifyReply,
downstreamPath: string,
) => {
const body = request.body as any; const body = request.body as any;
const requestedModel = typeof body?.model === 'string' ? body.model.trim() : ''; const requestedModel = typeof body?.model === 'string' ? body.model.trim() : '';
const downstreamPath = '/v1/responses';
if (!requestedModel) { if (!requestedModel) {
return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } }); return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } });
} }
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return; if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
const downstreamPolicy = getDownstreamRoutingPolicy(request); const downstreamPolicy = getDownstreamRoutingPolicy(request);
const isCompactRequest = downstreamPath === '/v1/responses/compact';
const isStream = body.stream === true; const isStream = body.stream === true;
const excludeChannelIds: number[] = []; const excludeChannelIds: number[] = [];
@@ -1193,7 +1197,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
try { try {
const endpointResult = await executeEndpointFlow({ const endpointResult = await executeEndpointFlow({
siteUrl: selected.site.url, siteUrl: selected.site.url,
proxyUrl: selected.site.proxyUrl, proxyUrl: resolveProxyUrlForSite(selected.site),
endpointCandidates, endpointCandidates,
buildRequest: (endpoint) => { buildRequest: (endpoint) => {
const endpointRequest = buildUpstreamEndpointRequest({ const endpointRequest = buildUpstreamEndpointRequest({
@@ -1208,9 +1212,14 @@ export async function responsesProxyRoute(app: FastifyInstance) {
responsesOriginalBody: body, responsesOriginalBody: body,
downstreamHeaders: request.headers as Record<string, unknown>, downstreamHeaders: request.headers as Record<string, unknown>,
}); });
const upstreamPath = (
isCompactRequest && endpoint === 'responses'
? `${endpointRequest.path}/compact`
: endpointRequest.path
);
return { return {
endpoint, endpoint,
path: endpointRequest.path, path: upstreamPath,
headers: endpointRequest.headers, headers: endpointRequest.headers,
body: endpointRequest.body as Record<string, unknown>, body: endpointRequest.body as Record<string, unknown>,
}; };
@@ -1231,7 +1240,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
for (const compatibilityBody of compatibilityBodies) { for (const compatibilityBody of compatibilityBodies) {
const compatibilityResponse = await fetch( const compatibilityResponse = await fetch(
ctx.targetUrl, ctx.targetUrl,
withExplicitProxyRequestInit(selected.site.proxyUrl, { withSiteRecordProxyRequestInit(selected.site, {
method: 'POST', method: 'POST',
headers: compatibilityHeadersCandidate, headers: compatibilityHeadersCandidate,
body: JSON.stringify(compatibilityBody), body: JSON.stringify(compatibilityBody),
@@ -1266,7 +1275,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
}); });
const minimalResponse = await fetch( const minimalResponse = await fetch(
ctx.targetUrl, ctx.targetUrl,
withExplicitProxyRequestInit(selected.site.proxyUrl, { withSiteRecordProxyRequestInit(selected.site, {
method: 'POST', method: 'POST',
headers: minimalHeaders, headers: minimalHeaders,
body: JSON.stringify(ctx.request.body), body: JSON.stringify(ctx.request.body),
@@ -1554,7 +1563,12 @@ export async function responsesProxyRoute(app: FastifyInstance) {
}); });
} }
} }
}); };
app.post('/v1/responses', async (request: FastifyRequest, reply: FastifyReply) =>
handleResponsesRequest(request, reply, '/v1/responses'));
app.post('/v1/responses/compact', async (request: FastifyRequest, reply: FastifyReply) =>
handleResponsesRequest(request, reply, '/v1/responses/compact'));
} }
async function logProxy( async function logProxy(
@@ -35,6 +35,28 @@ describe('accountHealthService', () => {
expect(health.reason).not.toContain('访问令牌已过期'); expect(health.reason).not.toContain('访问令牌已过期');
}); });
it('ignores stored auth failures for proxy-only accounts', () => {
const health = buildRuntimeHealthForAccount({
accountStatus: 'active',
siteStatus: 'active',
sessionCapable: false,
extraConfig: JSON.stringify({
runtimeHealth: {
state: 'unhealthy',
reason: '访问令牌失效:无权进行此操作,access token 无效',
source: 'auth',
checkedAt: '2026-03-07T09:00:01.432Z',
},
}),
});
expect(health).toMatchObject({
state: 'unknown',
source: 'none',
});
expect(health.reason).not.toContain('访问令牌');
});
it('returns stored runtime health from extra config when available', () => { it('returns stored runtime health from extra config when available', () => {
const health = buildRuntimeHealthForAccount({ const health = buildRuntimeHealthForAccount({
accountStatus: 'active', accountStatus: 'active',
+27 -1
View File
@@ -82,11 +82,21 @@ export function extractRuntimeHealth(extraConfig?: string | null): RuntimeHealth
return normalizeRuntimeHealthRecord(parsed.runtimeHealth); return normalizeRuntimeHealthRecord(parsed.runtimeHealth);
} }
function isProxyOnlyAuthFailure(
health: RuntimeHealthInfo | null,
sessionCapable?: boolean,
): boolean {
if (!health || sessionCapable !== false) return false;
if (health.state !== 'unhealthy') return false;
return (health.source || '').toLowerCase() === 'auth';
}
export function buildRuntimeHealthForAccount(input: { export function buildRuntimeHealthForAccount(input: {
accountStatus?: string | null; accountStatus?: string | null;
siteStatus?: string | null; siteStatus?: string | null;
extraConfig?: string | null; extraConfig?: string | null;
sessionCapable?: boolean; sessionCapable?: boolean;
hasDiscoveredModels?: boolean;
}): RuntimeHealthInfo { }): RuntimeHealthInfo {
const accountStatus = (input.accountStatus || 'active').toLowerCase(); const accountStatus = (input.accountStatus || 'active').toLowerCase();
const siteStatus = (input.siteStatus || 'active').toLowerCase(); const siteStatus = (input.siteStatus || 'active').toLowerCase();
@@ -110,7 +120,23 @@ export function buildRuntimeHealthForAccount(input: {
} }
const stored = extractRuntimeHealth(input.extraConfig); const stored = extractRuntimeHealth(input.extraConfig);
if (stored) return stored; const ignoreStoredProxyOnlyAuthFailure = isProxyOnlyAuthFailure(stored, input.sessionCapable);
if (
stored
&& !ignoreStoredProxyOnlyAuthFailure
&& !(input.sessionCapable === false && input.hasDiscoveredModels && stored.state === 'unknown')
) {
return stored;
}
if (input.sessionCapable === false && input.hasDiscoveredModels) {
return {
state: 'healthy',
reason: '模型探测成功',
source: 'model-discovery',
checkedAt: stored?.checkedAt || null,
};
}
return { return {
state: 'unknown', state: 'unknown',
+1
View File
@@ -162,6 +162,7 @@ function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupS
externalCheckinUrl: null, externalCheckinUrl: null,
platform, platform,
proxyUrl: null, proxyUrl: null,
useSystemProxy: false,
status: 'active', status: 'active',
isPinned: false, isPinned: false,
sortOrder: sites.length, sortOrder: sites.length,
@@ -279,6 +279,47 @@ describe('balanceService auto relogin', () => {
expect(reportTokenExpiredMock).not.toHaveBeenCalled(); expect(reportTokenExpiredMock).not.toHaveBeenCalled();
}); });
it('skips balance refresh for api-key-only accounts without expiring them', async () => {
selectAllMock.mockReturnValue([
{
accounts: {
id: 10,
username: 'wong-key',
accessToken: '',
apiToken: 'sk-only-token',
balance: 5,
balanceUsed: 1,
quota: 6,
status: 'active',
extraConfig: JSON.stringify({
credentialMode: 'apikey',
}),
},
sites: {
id: 10,
name: 'wong',
url: 'https://wzw.pp.ua',
platform: 'new-api',
},
},
]);
const { refreshBalance } = await import('./balanceService.js');
const result = await refreshBalance(10);
expect(result).toEqual({
balance: 5,
used: 1,
quota: 6,
skipped: true,
reason: 'proxy_only',
});
expect(adapterMock.getBalance).not.toHaveBeenCalled();
expect(adapterMock.login).not.toHaveBeenCalled();
expect(reportTokenExpiredMock).not.toHaveBeenCalled();
expect(setAccountRuntimeHealthMock).not.toHaveBeenCalled();
});
it('keeps degraded health when checkin is unsupported but balance refresh succeeds', async () => { it('keeps degraded health when checkin is unsupported but balance refresh succeeds', async () => {
selectAllMock.mockReturnValue([ selectAllMock.mockReturnValue([
{ {
+20 -3
View File
@@ -5,6 +5,7 @@ import { appendSessionTokenRebindHint, isTokenExpiredError } from './alertRules.
import { reportTokenExpired } from './alertService.js'; import { reportTokenExpired } from './alertService.js';
import { import {
getAutoReloginConfig, getAutoReloginConfig,
getCredentialModeFromExtraConfig,
getSub2ApiAuthFromExtraConfig, getSub2ApiAuthFromExtraConfig,
mergeAccountExtraConfig, mergeAccountExtraConfig,
resolvePlatformUserId, resolvePlatformUserId,
@@ -13,12 +14,18 @@ import { decryptAccountPassword } from './accountCredentialService.js';
import { extractRuntimeHealth, setAccountRuntimeHealth } from './accountHealthService.js'; import { extractRuntimeHealth, setAccountRuntimeHealth } from './accountHealthService.js';
import { updateTodayIncomeSnapshot } from './todayIncomeRewardService.js'; import { updateTodayIncomeSnapshot } from './todayIncomeRewardService.js';
import type { BalanceInfo } from './platforms/base.js'; import type { BalanceInfo } from './platforms/base.js';
import { withExplicitProxyRequestInit } from './siteProxy.js'; import { resolveProxyUrlForSite, withExplicitProxyRequestInit, withSiteRecordProxyRequestInit } from './siteProxy.js';
function isSiteDisabled(status?: string | null): boolean { function isSiteDisabled(status?: string | null): boolean {
return (status || 'active') === 'disabled'; return (status || 'active') === 'disabled';
} }
function isApiKeyConnection(account: typeof schema.accounts.$inferSelect): boolean {
const explicit = getCredentialModeFromExtraConfig(account.extraConfig);
if (explicit && explicit !== 'auto') return explicit === 'apikey';
return !(account.accessToken || '').trim();
}
function shouldAttemptAutoRelogin(message?: string | null): boolean { function shouldAttemptAutoRelogin(message?: string | null): boolean {
if (!message) return false; if (!message) return false;
if (isTokenExpiredError({ message })) return true; if (isTokenExpiredError({ message })) return true;
@@ -196,7 +203,7 @@ async function refreshSub2ApiManagedSession(params: {
const { fetch } = await import('undici'); const { fetch } = await import('undici');
let payload: unknown = null; let payload: unknown = null;
try { try {
const response = await fetch(endpoint, withExplicitProxyRequestInit(params.site.proxyUrl, { const response = await fetch(endpoint, withSiteRecordProxyRequestInit(params.site, {
method: 'POST', method: 'POST',
headers, headers,
body: JSON.stringify({ refresh_token: refreshToken }), body: JSON.stringify({ refresh_token: refreshToken }),
@@ -363,6 +370,16 @@ export async function refreshBalance(accountId: number) {
const adapter = getAdapter(site.platform); const adapter = getAdapter(site.platform);
if (!adapter) return null; if (!adapter) return null;
if (isApiKeyConnection(account)) {
return {
balance: account.balance ?? 0,
used: account.balanceUsed ?? 0,
quota: account.quota ?? 0,
skipped: true,
reason: 'proxy_only',
};
}
const platformUserId = resolvePlatformUserId(account.extraConfig, account.username); const platformUserId = resolvePlatformUserId(account.extraConfig, account.username);
let activeAccessToken = account.accessToken; let activeAccessToken = account.accessToken;
let activeExtraConfig = account.extraConfig; let activeExtraConfig = account.extraConfig;
@@ -457,7 +474,7 @@ export async function refreshBalance(accountId: number) {
accessToken: activeAccessToken, accessToken: activeAccessToken,
platform: site.platform, platform: site.platform,
platformUserId, platformUserId,
proxyUrl: site.proxyUrl, proxyUrl: resolveProxyUrlForSite(site),
}); });
if (typeof fallbackIncome === 'number' && Number.isFinite(fallbackIncome)) { if (typeof fallbackIncome === 'number' && Number.isFinite(fallbackIncome)) {
balanceInfo.todayIncome = fallbackIncome; balanceInfo.todayIncome = fallbackIncome;
+10
View File
@@ -4,6 +4,7 @@ import { getAdapter } from './platforms/index.js';
import { ensureDefaultTokenForAccount, getPreferredAccountToken } from './accountTokenService.js'; import { ensureDefaultTokenForAccount, getPreferredAccountToken } from './accountTokenService.js';
import { getCredentialModeFromExtraConfig, resolvePlatformUserId } from './accountExtraConfig.js'; import { getCredentialModeFromExtraConfig, resolvePlatformUserId } from './accountExtraConfig.js';
import { invalidateTokenRouterCache } from './tokenRouter.js'; import { invalidateTokenRouterCache } from './tokenRouter.js';
import { setAccountRuntimeHealth } from './accountHealthService.js';
const API_TOKEN_DISCOVERY_TIMEOUT_MS = 8_000; const API_TOKEN_DISCOVERY_TIMEOUT_MS = 8_000;
const MODEL_DISCOVERY_TIMEOUT_MS = 12_000; const MODEL_DISCOVERY_TIMEOUT_MS = 12_000;
@@ -213,6 +214,15 @@ export async function refreshModelsForAccount(accountId: number) {
checkedAt, checkedAt,
})), })),
).run(); ).run();
if (isApiKeyConnection(account)) {
await setAccountRuntimeHealth(account.id, {
state: 'healthy',
reason: '模型探测成功',
source: 'model-discovery',
checkedAt,
});
}
} }
return { return {
@@ -4,7 +4,7 @@ import {
buildNewApiCookieCandidates, buildNewApiCookieCandidates,
fetchJsonWithShieldCookieRetry, fetchJsonWithShieldCookieRetry,
} from './platforms/newApiShield.js'; } from './platforms/newApiShield.js';
import { withExplicitProxyRequestInit } from './siteProxy.js'; import { withExplicitProxyRequestInit, withSiteRecordProxyRequestInit } from './siteProxy.js';
const SELF_LOG_FETCH_TIMEOUT_MS = 8_000; const SELF_LOG_FETCH_TIMEOUT_MS = 8_000;
const SELF_LOG_PAGE_SIZE = 20; const SELF_LOG_PAGE_SIZE = 20;
@@ -28,6 +28,7 @@ interface ProxyUsageFallbackInput {
url: string; url: string;
platform: string; platform: string;
apiKey?: string | null; apiKey?: string | null;
useSystemProxy?: boolean | null;
proxyUrl?: string | null; proxyUrl?: string | null;
}; };
account: { account: {
@@ -358,7 +359,7 @@ async function fetchSelfLogPayload(baseUrl: string, token: string, input: ProxyU
page_size: String(SELF_LOG_PAGE_SIZE), page_size: String(SELF_LOG_PAGE_SIZE),
model: input.modelName, model: input.modelName,
}); });
const response = await fetch(`${baseUrl}/api/v1/usage?${query.toString()}`, withExplicitProxyRequestInit(input.site.proxyUrl, { const response = await fetch(`${baseUrl}/api/v1/usage?${query.toString()}`, withSiteRecordProxyRequestInit(input.site, {
method: 'GET', method: 'GET',
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
@@ -412,7 +413,7 @@ async function fetchSelfLogPayload(baseUrl: string, token: string, input: ProxyU
} }
} }
const response = await fetch(url, withExplicitProxyRequestInit(input.site.proxyUrl, { const response = await fetch(url, withSiteRecordProxyRequestInit(input.site, {
method: 'GET', method: 'GET',
headers, headers,
signal: controller.signal, signal: controller.signal,
+23 -22
View File
@@ -2,6 +2,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync } from 'node:fs'; import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { sql } from 'drizzle-orm';
type DbModule = typeof import('../db/index.js'); type DbModule = typeof import('../db/index.js');
@@ -22,6 +23,7 @@ describe('siteProxy', () => {
beforeEach(async () => { beforeEach(async () => {
const { invalidateSiteProxyCache } = await import('./siteProxy.js'); const { invalidateSiteProxyCache } = await import('./siteProxy.js');
await db.delete(schema.accounts).run(); await db.delete(schema.accounts).run();
await db.delete(schema.settings).run();
await db.delete(schema.sites).run(); await db.delete(schema.sites).run();
invalidateSiteProxyCache(); invalidateSiteProxyCache();
}); });
@@ -30,36 +32,35 @@ describe('siteProxy', () => {
delete process.env.DATA_DIR; delete process.env.DATA_DIR;
}); });
it('resolves longest matched site proxy url', async () => { it('resolves system proxy only for sites that opt in', async () => {
await db.insert(schema.sites).values([ await db.insert(schema.settings).values({
{ key: 'system_proxy_url',
name: 'base-site', value: JSON.stringify('http://127.0.0.1:7890'),
url: 'https://relay.example.com', }).run();
platform: 'new-api',
proxyUrl: 'http://127.0.0.1:7891', await db.run(sql`
}, INSERT INTO sites (name, url, platform, use_system_proxy)
{ VALUES
name: 'openai-site', ('base-site', 'https://relay.example.com', 'new-api', 0),
url: 'https://relay.example.com/openai', ('openai-site', 'https://relay.example.com/openai', 'new-api', 1)
platform: 'new-api', `);
proxyUrl: 'http://127.0.0.1:7890',
},
]).run();
const { resolveSiteProxyUrlByRequestUrl } = await import('./siteProxy.js'); const { resolveSiteProxyUrlByRequestUrl } = await import('./siteProxy.js');
expect(await resolveSiteProxyUrlByRequestUrl('https://relay.example.com/openai/v1/models')) expect(await resolveSiteProxyUrlByRequestUrl('https://relay.example.com/openai/v1/models'))
.toBe('http://127.0.0.1:7890'); .toBe('http://127.0.0.1:7890');
expect(await resolveSiteProxyUrlByRequestUrl('https://relay.example.com/v1/models')) expect(await resolveSiteProxyUrlByRequestUrl('https://relay.example.com/v1/models'))
.toBe('http://127.0.0.1:7891'); .toBeNull();
}); });
it('injects dispatcher when proxy exists', async () => { it('injects dispatcher when a site opts into the configured system proxy', async () => {
await db.insert(schema.sites).values({ await db.insert(schema.settings).values({
name: 'proxy-site', key: 'system_proxy_url',
url: 'https://proxy-site.example.com', value: JSON.stringify('http://127.0.0.1:7890'),
platform: 'new-api',
proxyUrl: 'http://127.0.0.1:7890',
}).run(); }).run();
await db.run(sql`
INSERT INTO sites (name, url, platform, use_system_proxy)
VALUES ('proxy-site', 'https://proxy-site.example.com', 'new-api', 1)
`);
const { withSiteProxyRequestInit } = await import('./siteProxy.js'); const { withSiteProxyRequestInit } = await import('./siteProxy.js');
const requestInit = await withSiteProxyRequestInit('https://proxy-site.example.com/v1/chat/completions', { const requestInit = await withSiteProxyRequestInit('https://proxy-site.example.com/v1/chat/completions', {
+53 -13
View File
@@ -1,4 +1,6 @@
import { db, schema } from '../db/index.js'; import { db, schema } from '../db/index.js';
import { eq } from 'drizzle-orm';
import { config } from '../config.js';
import type { Dispatcher, RequestInit as UndiciRequestInit } from 'undici'; import type { Dispatcher, RequestInit as UndiciRequestInit } from 'undici';
import { ProxyAgent } from 'undici'; import { ProxyAgent } from 'undici';
@@ -15,7 +17,7 @@ const SUPPORTED_PROXY_PROTOCOLS = new Set([
type SiteProxyRow = { type SiteProxyRow = {
siteUrl: string; siteUrl: string;
proxyUrl: string | null; useSystemProxy: boolean;
}; };
type ParsedSiteProxyInput = { type ParsedSiteProxyInput = {
@@ -24,12 +26,18 @@ type ParsedSiteProxyInput = {
proxyUrl: string | null; proxyUrl: string | null;
}; };
type SiteProxyConfigLike = {
useSystemProxy?: boolean | null;
};
let siteProxyCache: { let siteProxyCache: {
loadedAt: number; loadedAt: number;
rows: SiteProxyRow[]; rows: SiteProxyRow[];
systemProxyUrl: string | null;
} = { } = {
loadedAt: 0, loadedAt: 0,
rows: [], rows: [],
systemProxyUrl: null,
}; };
const dispatcherCache = new Map<string, Dispatcher>(); const dispatcherCache = new Map<string, Dispatcher>();
@@ -54,23 +62,41 @@ async function getCachedSiteProxyRows(nowMs = Date.now()): Promise<SiteProxyRow[
} }
try { try {
const rows = await db const [rows, systemProxySetting] = await Promise.all([
.select({ db
siteUrl: schema.sites.url, .select({
proxyUrl: schema.sites.proxyUrl, siteUrl: schema.sites.url,
}) useSystemProxy: schema.sites.useSystemProxy,
.from(schema.sites) })
.all(); .from(schema.sites)
.all(),
db.select({ value: schema.settings.value })
.from(schema.settings)
.where(eq(schema.settings.key, 'system_proxy_url'))
.get(),
]);
const parsedSystemProxyUrl = normalizeSiteProxyUrl(
typeof systemProxySetting?.value === 'string'
? (() => {
try {
return JSON.parse(systemProxySetting.value);
} catch {
return systemProxySetting.value;
}
})()
: systemProxySetting?.value,
);
siteProxyCache = { siteProxyCache = {
loadedAt: nowMs, loadedAt: nowMs,
rows: rows.map((row) => ({ rows: rows.map((row) => ({
siteUrl: normalizeSiteUrl(row.siteUrl), siteUrl: normalizeSiteUrl(row.siteUrl),
proxyUrl: normalizeSiteProxyUrl(row.proxyUrl), useSystemProxy: !!row.useSystemProxy,
})), })),
systemProxyUrl: parsedSystemProxyUrl,
}; };
} catch { } catch {
siteProxyCache = { loadedAt: nowMs, rows: [] }; siteProxyCache = { loadedAt: nowMs, rows: [], systemProxyUrl: null };
} }
return siteProxyCache.rows; return siteProxyCache.rows;
@@ -138,7 +164,7 @@ export function parseSiteProxyUrlInput(input: unknown): ParsedSiteProxyInput {
} }
export function invalidateSiteProxyCache(): void { export function invalidateSiteProxyCache(): void {
siteProxyCache = { loadedAt: 0, rows: [] }; siteProxyCache = { loadedAt: 0, rows: [], systemProxyUrl: null };
} }
export async function resolveSiteProxyUrlByRequestUrl(requestUrl: string): Promise<string | null> { export async function resolveSiteProxyUrlByRequestUrl(requestUrl: string): Promise<string | null> {
@@ -146,11 +172,13 @@ export async function resolveSiteProxyUrlByRequestUrl(requestUrl: string): Promi
if (!normalizedRequestUrl) return null; if (!normalizedRequestUrl) return null;
const rows = await getCachedSiteProxyRows(); const rows = await getCachedSiteProxyRows();
const systemProxyUrl = siteProxyCache.systemProxyUrl;
if (!systemProxyUrl) return null;
let bestMatch: string | null = null; let bestMatch: string | null = null;
let bestMatchLength = -1; let bestMatchLength = -1;
for (const row of rows) { for (const row of rows) {
if (!row.proxyUrl) continue; if (!row.useSystemProxy) continue;
if (!row.siteUrl) continue; if (!row.siteUrl) continue;
const isPrefixMatch = ( const isPrefixMatch = (
@@ -161,7 +189,7 @@ export async function resolveSiteProxyUrlByRequestUrl(requestUrl: string): Promi
if (!isPrefixMatch) continue; if (!isPrefixMatch) continue;
if (row.siteUrl.length > bestMatchLength) { if (row.siteUrl.length > bestMatchLength) {
bestMatch = row.proxyUrl; bestMatch = systemProxyUrl;
bestMatchLength = row.siteUrl.length; bestMatchLength = row.siteUrl.length;
} }
} }
@@ -200,3 +228,15 @@ export function withExplicitProxyRequestInit(
dispatcher, dispatcher,
}; };
} }
export function resolveProxyUrlForSite(site: SiteProxyConfigLike | null | undefined): string | null {
if (!site?.useSystemProxy) return null;
return normalizeSiteProxyUrl(config.systemProxyUrl);
}
export function withSiteRecordProxyRequestInit(
site: SiteProxyConfigLike | null | undefined,
options?: UndiciRequestInit,
): UndiciRequestInit {
return withExplicitProxyRequestInit(resolveProxyUrlForSite(site), options);
}
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('App sidebar config', () => {
it('uses 连接管理 for /accounts and removes standalone /tokens navigation item', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
expect(source).toContain("{ to: '/accounts', label: '连接管理'");
expect(source).not.toContain("{ to: '/accounts', label: '账号'");
expect(source).not.toContain("{ to: '/tokens', label: '令牌管理'");
});
});
+1 -2
View File
@@ -321,8 +321,7 @@ const sidebarGroups = [
items: [ items: [
{ to: '/', label: '仪表盘', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M4 5a1 1 0 011-1h4a1 1 0 011 1v5a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v2a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 12a1 1 0 011-1h4a1 1 0 011 1v7a1 1 0 01-1 1h-4a1 1 0 01-1-1v-7z" /></svg> }, { to: '/', label: '仪表盘', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M4 5a1 1 0 011-1h4a1 1 0 011 1v5a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v2a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 12a1 1 0 011-1h4a1 1 0 011 1v7a1 1 0 01-1 1h-4a1 1 0 01-1-1v-7z" /></svg> },
{ to: '/sites', label: '站点', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg> }, { to: '/sites', label: '站点', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg> },
{ to: '/accounts', label: '账号', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg> }, { to: '/accounts', label: '连接管理', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg> },
{ to: '/tokens', label: '令牌管理', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg> },
{ to: '/checkin', label: '签到记录', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> }, { to: '/checkin', label: '签到记录', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
{ to: '/routes', label: '路由', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /></svg> }, { to: '/routes', label: '路由', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /></svg> },
{ to: '/logs', label: '使用日志', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg> }, { to: '/logs', label: '使用日志', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg> },
+3
View File
@@ -103,6 +103,7 @@ export const api = {
addSite: (data: any) => request('/api/sites', { method: 'POST', body: JSON.stringify(data) }), addSite: (data: any) => request('/api/sites', { method: 'POST', body: JSON.stringify(data) }),
updateSite: (id: number, data: any) => request(`/api/sites/${id}`, { method: 'PUT', body: JSON.stringify(data) }), updateSite: (id: number, data: any) => request(`/api/sites/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
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) }),
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 }) }),
// Accounts // Accounts
@@ -114,6 +115,7 @@ export const api = {
request(`/api/accounts/${id}/rebind-session`, { method: 'POST', body: JSON.stringify(data) }), request(`/api/accounts/${id}/rebind-session`, { method: 'POST', body: JSON.stringify(data) }),
updateAccount: (id: number, data: any) => request(`/api/accounts/${id}`, { method: 'PUT', body: JSON.stringify(data) }), updateAccount: (id: number, data: any) => request(`/api/accounts/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
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) }),
refreshBalance: (id: number) => request(`/api/accounts/${id}/balance`, { method: 'POST' }), refreshBalance: (id: number) => request(`/api/accounts/${id}/balance`, { method: 'POST' }),
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',
@@ -126,6 +128,7 @@ export const api = {
addAccountToken: (data: any) => request('/api/account-tokens', { method: 'POST', body: JSON.stringify(data) }), addAccountToken: (data: any) => request('/api/account-tokens', { method: 'POST', body: JSON.stringify(data) }),
updateAccountToken: (id: number, data: any) => request(`/api/account-tokens/${id}`, { method: 'PUT', body: JSON.stringify(data) }), updateAccountToken: (id: number, data: any) => request(`/api/account-tokens/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteAccountToken: (id: number) => request(`/api/account-tokens/${id}`, { method: 'DELETE' }), deleteAccountToken: (id: number) => request(`/api/account-tokens/${id}`, { method: 'DELETE' }),
batchUpdateAccountTokens: (data: any) => request('/api/account-tokens/batch', { method: 'POST', body: JSON.stringify(data) }),
getAccountTokenGroups: (accountId: number) => request(`/api/account-tokens/groups/${accountId}`), getAccountTokenGroups: (accountId: number) => request(`/api/account-tokens/groups/${accountId}`),
setDefaultAccountToken: (id: number) => request(`/api/account-tokens/${id}/default`, { method: 'POST' }), setDefaultAccountToken: (id: number) => request(`/api/account-tokens/${id}/default`, { method: 'POST' }),
getAccountTokenValue: (id: number) => request(`/api/account-tokens/${id}/value`), getAccountTokenValue: (id: number) => request(`/api/account-tokens/${id}/value`),
+68
View File
@@ -0,0 +1,68 @@
import React, { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
type CenteredModalProps = {
open: boolean;
onClose: () => void;
title: React.ReactNode;
children: React.ReactNode;
footer?: React.ReactNode;
maxWidth?: number;
bodyStyle?: React.CSSProperties;
};
export default function CenteredModal({
open,
onClose,
title,
children,
footer,
maxWidth = 860,
bodyStyle,
}: CenteredModalProps) {
const presence = useAnimatedVisibility(open, 220);
useEffect(() => {
if (!open || typeof document === 'undefined') return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}, [open]);
useEffect(() => {
if (!open || typeof document === 'undefined') return;
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeydown);
return () => {
document.removeEventListener('keydown', handleKeydown);
};
}, [open, onClose]);
if (!presence.shouldRender) return null;
const modal = (
<div
className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()}
onClick={onClose}
>
<div
className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()}
style={{ maxWidth }}
onClick={(event) => event.stopPropagation()}
>
<div className="modal-header">{title}</div>
<div className="modal-body" style={bodyStyle}>
{children}
</div>
{footer ? <div className="modal-footer">{footer}</div> : null}
</div>
</div>
);
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
}
+54 -4
View File
@@ -2,7 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { api } from '../api.js'; import { api } from '../api.js';
import { formatDateLocal, formatDateTimeMinuteLocal } from '../pages/helpers/checkinLogTime.js'; import { formatDateLocal, formatDateTimeMinuteLocal } from '../pages/helpers/checkinLogTime.js';
import { buildAccountFocusPath, buildSiteFocusPath } from '../pages/helpers/navigationFocus.js'; import { buildAccountFocusPath, buildSiteFocusPath, buildTokenFocusPath } from '../pages/helpers/navigationFocus.js';
import { useI18n } from '../i18n.js'; import { useI18n } from '../i18n.js';
import { useAnimatedVisibility } from './useAnimatedVisibility.js'; import { useAnimatedVisibility } from './useAnimatedVisibility.js';
@@ -17,6 +17,19 @@ interface AccountResult {
username: string | null; username: string | null;
status?: string | null; status?: string | null;
balance?: number | null; balance?: number | null;
segment?: 'session' | 'apikey';
site?: { name: string } | null;
}
interface AccountTokenResult {
id: number;
accountId: number;
name: string;
tokenGroup?: string | null;
account?: {
username?: string | null;
segment?: 'session' | 'apikey';
} | null;
site?: { name: string } | null; site?: { name: string } | null;
} }
@@ -45,6 +58,7 @@ interface ModelSearchResult {
interface SearchResult { interface SearchResult {
accounts: AccountResult[]; accounts: AccountResult[];
accountTokens: AccountTokenResult[];
sites: SiteResult[]; sites: SiteResult[];
checkinLogs: CheckinLogResult[]; checkinLogs: CheckinLogResult[];
proxyLogs: ProxyLogResult[]; proxyLogs: ProxyLogResult[];
@@ -81,6 +95,7 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
setResults({ setResults({
models: Array.isArray(res?.models) ? res.models : [], models: Array.isArray(res?.models) ? res.models : [],
accounts: Array.isArray(res?.accounts) ? res.accounts : [], accounts: Array.isArray(res?.accounts) ? res.accounts : [],
accountTokens: Array.isArray(res?.accountTokens) ? res.accountTokens : [],
sites: Array.isArray(res?.sites) ? res.sites : [], sites: Array.isArray(res?.sites) ? res.sites : [],
checkinLogs: Array.isArray(res?.checkinLogs) ? res.checkinLogs : [], checkinLogs: Array.isArray(res?.checkinLogs) ? res.checkinLogs : [],
proxyLogs: Array.isArray(res?.proxyLogs) ? res.proxyLogs : [], proxyLogs: Array.isArray(res?.proxyLogs) ? res.proxyLogs : [],
@@ -116,6 +131,7 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
const hasResults = results && ( const hasResults = results && (
results.models.length results.models.length
|| results.accounts.length || results.accounts.length
|| results.accountTokens.length
|| results.sites.length || results.sites.length
|| results.checkinLogs.length || results.checkinLogs.length
|| results.proxyLogs.length || results.proxyLogs.length
@@ -189,15 +205,49 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
<button <button
key={a.id} key={a.id}
className="search-result-item" className="search-result-item"
onClick={() => goTo(buildAccountFocusPath(a.id, { openRebind: a.status === 'expired' }))} onClick={() => goTo(buildAccountFocusPath(a.id, {
openRebind: a.status === 'expired',
segment: a.segment,
}))}
> >
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg> </svg>
<div> <div>
<div style={{ fontWeight: 500 }}>{a.username || `ID:${a.id}`}</div> <div style={{ fontWeight: 500 }}>
{a.username?.trim() || (a.segment === 'apikey' ? t('API Key 连接') : `ID:${a.id}`)}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}> <div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{a.site?.name || t('未关联站点')} · {t('余额')} ${(a.balance || 0).toFixed(2)} {a.site?.name || t('未关联站点')}
{a.segment === 'apikey' ? ` · ${t('API Key 连接')}` : ''}
{' · '}
{t('余额')} ${(a.balance || 0).toFixed(2)}
</div>
</div>
</button>
))}
</div>
) : null}
{results?.accountTokens.length ? (
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('账号令牌')}</div>
{results.accountTokens.map((token) => (
<button
key={token.id}
className="search-result-item"
onClick={() => goTo(buildTokenFocusPath(token.id))}
>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
<div>
<div style={{ fontWeight: 500 }}>{token.name}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{(token.account?.username?.trim() || (token.account?.segment === 'apikey' ? t('API Key 连接') : t('未命名')))}
{' · '}
{token.site?.name || t('未关联站点')}
{token.tokenGroup ? ` · ${token.tokenGroup}` : ''}
</div> </div>
</div> </div>
</button> </button>
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('CenteredModal component', () => {
it('uses the shared centered modal shell pattern', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/components/CenteredModal.tsx'), 'utf8');
expect(source).toContain('modal-backdrop');
expect(source).toContain('modal-content');
expect(source).toContain('useAnimatedVisibility');
expect(source).toContain('createPortal');
});
});
@@ -0,0 +1,132 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { MemoryRouter, useLocation } from 'react-router-dom';
import SearchModal from './SearchModal.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
search: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
vi.mock('../i18n.js', () => ({
useI18n: () => ({
t: (value: string) => value,
}),
}));
function collectText(node: ReactTestInstance): string {
const children = node.children || [];
return children.map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
function LocationProbe() {
const location = useLocation();
return <div id="location-probe">{`${location.pathname}${location.search}`}</div>;
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('SearchModal results', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
Object.defineProperty(globalThis, 'window', {
value: globalThis,
configurable: true,
writable: true,
});
Object.defineProperty(globalThis, 'document', {
value: {
addEventListener: () => undefined,
removeEventListener: () => undefined,
},
configurable: true,
writable: true,
});
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
vi.clearAllMocks();
});
it('renders account token results and navigates API key accounts to the apikey segment', async () => {
apiMock.search.mockResolvedValue({
models: [],
sites: [],
checkinLogs: [],
proxyLogs: [],
accounts: [
{
id: 8,
username: '',
balance: 0,
segment: 'apikey',
site: { name: 'Key Search Site' },
},
],
accountTokens: [
{
id: 15,
name: 'search-token',
tokenGroup: 'default',
accountId: 8,
account: { username: '' },
site: { name: 'Key Search Site' },
},
],
});
const onClose = vi.fn();
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<LocationProbe />
<SearchModal open onClose={onClose} />
</MemoryRouter>,
);
});
const input = root.root.findByType('input');
await act(async () => {
input.props.onChange({ target: { value: 'search' } });
vi.advanceTimersByTime(300);
await Promise.resolve();
});
await flushMicrotasks();
const rendered = JSON.stringify(root.toJSON());
expect(rendered).toContain('账号令牌');
expect(rendered).toContain('search-token');
expect(rendered).toContain('API Key 连接');
const buttons = root.root.findAll((node) => node.type === 'button');
const accountButton = buttons.find((node) => collectText(node).includes('API Key 连接'));
expect(accountButton).toBeTruthy();
await act(async () => {
accountButton!.props.onClick();
});
const locationAfterAccountClick = root.root.find((node) => node.props?.id === 'location-probe');
expect(collectText(locationAfterAccountClick)).toBe('/accounts?segment=apikey&focusAccountId=8');
} finally {
root?.unmount();
}
});
});
+2 -2
View File
@@ -273,7 +273,7 @@ export const zhToEnSupplemental: Record<string, string> = {
'令牌不存在或不属于通道账号': 'The token does not exist or does not belong to the channel account', '令牌不存在或不属于通道账号': 'The token does not exist or does not belong to the channel account',
'令牌不可用': 'Token not available', '令牌不可用': 'Token not available',
'令牌不能为空': 'Token cannot be empty', '令牌不能为空': 'Token cannot be empty',
'令牌管理': 'Token management', '令牌管理': 'Account tokens',
'令牌失效': 'Token expired', '令牌失效': 'Token expired',
'令牌数': 'Number of tokens', '令牌数': 'Number of tokens',
'令牌同步成功': 'Token synchronization successful', '令牌同步成功': 'Token synchronization successful',
@@ -696,7 +696,7 @@ export const zhToEnSupplemental: Record<string, string> = {
'账号 ID 无效': 'Invalid account ID', '账号 ID 无效': 'Invalid account ID',
'账号不存在': 'Account does not exist', '账号不存在': 'Account does not exist',
'账号访问令牌可能过期或无效,需更新认证信息。': 'The account access token may be expired or invalid, and the authentication information needs to be updated.', '账号访问令牌可能过期或无效,需更新认证信息。': 'The account access token may be expired or invalid, and the authentication information needs to be updated.',
'账号管理': 'Account management', '账号管理': 'Connection management',
'账号或站点已禁用': 'Account or site has been disabled', '账号或站点已禁用': 'Account or site has been disabled',
'账号路由': 'Account routing', '账号路由': 'Account routing',
'账号缺少访问令牌,无法创建站点令牌': 'The account is missing an access token and cannot create a site token.', '账号缺少访问令牌,无法创建站点令牌': 'The account is missing an access token and cannot create a site token.',
+2 -2
View File
@@ -38,7 +38,7 @@ const zhToEn: Record<string, string> = {
'账号': 'Accounts', '账号': 'Accounts',
'未关联站点': 'Unlinked Site', '未关联站点': 'Unlinked Site',
'余额': 'Balance', '余额': 'Balance',
'令牌管理': 'Token Management', '令牌管理': 'Account Tokens',
'签到记录': 'Check-in Logs', '签到记录': 'Check-in Logs',
'全部签到': 'Check In All', '全部签到': 'Check In All',
'签到中...': 'Checking In...', '签到中...': 'Checking In...',
@@ -82,7 +82,7 @@ const zhToEn: Record<string, string> = {
'收起侧边栏': 'Collapse Sidebar', '收起侧边栏': 'Collapse Sidebar',
'系统设置': 'System Settings', '系统设置': 'System Settings',
'站点管理': 'Site Management', '站点管理': 'Site Management',
'账号管理': 'Account Management', '账号管理': 'Connection Management',
'导入 / 导出': 'Import / Export', '导入 / 导出': 'Import / Export',
'监控内嵌': 'Embedded Monitor', '监控内嵌': 'Embedded Monitor',
'品牌': 'Brands', '品牌': 'Brands',
+81 -5
View File
@@ -1171,6 +1171,36 @@ body {
background: rgba(99, 102, 241, 0.06); background: rgba(99, 102, 241, 0.06);
} }
.data-table tbody tr.row-selectable {
cursor: pointer;
}
.data-table tbody tr.row-selectable:hover {
background: color-mix(in srgb, var(--color-primary) 6%, transparent);
}
[data-theme="dark"] .data-table tbody tr.row-selectable:hover {
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
}
.data-table tbody tr.row-selected {
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
box-shadow: inset 3px 0 0 color-mix(in srgb, var(--color-primary) 75%, transparent);
}
[data-theme="dark"] .data-table tbody tr.row-selected {
background: color-mix(in srgb, var(--color-primary) 16%, transparent);
box-shadow: inset 3px 0 0 color-mix(in srgb, var(--color-primary) 82%, transparent);
}
.data-table tbody tr.row-selectable.row-selected:hover {
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
}
[data-theme="dark"] .data-table tbody tr.row-selectable.row-selected:hover {
background: color-mix(in srgb, var(--color-primary) 20%, transparent);
}
@keyframes row-focus-glow { @keyframes row-focus-glow {
0% { 0% {
background: color-mix(in srgb, var(--color-primary) 18%, transparent); background: color-mix(in srgb, var(--color-primary) 18%, transparent);
@@ -1292,15 +1322,38 @@ body {
box-shadow: none; box-shadow: none;
} }
.token-table {
width: 100%;
table-layout: fixed;
}
.token-table th,
.token-table td {
white-space: normal;
word-break: break-word;
}
.token-table-actions-col { .token-table-actions-col {
min-width: 210px; width: 24%;
min-width: 0;
}
.token-actions-cell {
min-width: 0;
white-space: normal !important;
} }
.token-table-actions { .token-table-actions {
display: inline-flex; display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
gap: 2px; gap: 4px 6px;
width: 100%;
}
.token-table-actions .btn-link {
white-space: nowrap;
} }
.token-table-action-btn { .token-table-action-btn {
@@ -3137,7 +3190,8 @@ textarea {
} }
.accounts-page-actions .btn-soft-primary, .accounts-page-actions .btn-soft-primary,
.accounts-page-actions .btn-primary { .accounts-page-actions .btn-primary,
.accounts-page-actions .btn-ghost {
padding: 6px 12px; padding: 6px 12px;
font-size: 12px; font-size: 12px;
} }
@@ -3146,10 +3200,18 @@ textarea {
width: 28%; width: 28%;
} }
.token-table-actions-col {
width: 28%;
}
.accounts-actions-cell { .accounts-actions-cell {
min-width: 0; min-width: 0;
} }
.token-actions-cell {
min-width: 0;
}
.accounts-table th, .accounts-table th,
.accounts-table td { .accounts-table td {
padding-left: 10px; padding-left: 10px;
@@ -3161,6 +3223,11 @@ textarea {
font-size: 11px; font-size: 11px;
} }
.token-table-actions .btn-link {
padding: 3px 6px;
font-size: 11px;
}
.sites-actions-col { .sites-actions-col {
min-width: 165px; min-width: 165px;
} }
@@ -3205,7 +3272,8 @@ textarea {
} }
.accounts-page-actions .btn-soft-primary, .accounts-page-actions .btn-soft-primary,
.accounts-page-actions .btn-primary { .accounts-page-actions .btn-primary,
.accounts-page-actions .btn-ghost {
flex: 1 1 calc(50% - 4px); flex: 1 1 calc(50% - 4px);
min-width: 0; min-width: 0;
justify-content: center; justify-content: center;
@@ -3215,10 +3283,18 @@ textarea {
width: 30%; width: 30%;
} }
.token-table-actions-col {
width: 30%;
}
.accounts-actions-cell { .accounts-actions-cell {
min-width: 0; min-width: 0;
} }
.token-actions-cell {
min-width: 0;
}
.accounts-table th:nth-child(5), .accounts-table th:nth-child(5),
.accounts-table td:nth-child(5) { .accounts-table td:nth-child(5) {
display: none; display: none;
+334 -95
View File
@@ -1,10 +1,9 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { api } from '../api.js'; import { api } from '../api.js';
import CenteredModal from '../components/CenteredModal.js';
import { useToast } from '../components/Toast.js'; import { useToast } from '../components/Toast.js';
import ModernSelect from '../components/ModernSelect.js'; import ModernSelect from '../components/ModernSelect.js';
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
import { getAccountsAddPanelStyle } from './helpers/accountsPanelStyle.js';
import { import {
buildAddAccountPrereqHint, buildAddAccountPrereqHint,
buildVerifyFailureHint, buildVerifyFailureHint,
@@ -14,10 +13,31 @@ import { clearFocusParams, readFocusAccountIntent } from './helpers/navigationFo
import { TokensPanel } from './Tokens.js'; import { TokensPanel } from './Tokens.js';
import { tr } from '../i18n.js'; import { tr } from '../i18n.js';
import { buildCustomReorderUpdates, sortItemsForDisplay, type SortMode } from './helpers/listSorting.js'; import { buildCustomReorderUpdates, sortItemsForDisplay, type SortMode } from './helpers/listSorting.js';
import { shouldIgnoreRowSelectionClick } from './helpers/rowSelection.js';
import { SITE_DOCS_URL } from '../docsLink.js'; import { SITE_DOCS_URL } from '../docsLink.js';
type ConnectionsSegment = 'session' | 'apikey' | 'tokens'; type ConnectionsSegment = 'session' | 'apikey' | 'tokens';
function createLoginForm() {
return { siteId: 0, username: '', password: '' };
}
function createTokenForm(credentialMode: 'session' | 'apikey' = 'session') {
return {
siteId: 0,
username: '',
accessToken: '',
platformUserId: '',
refreshToken: '',
tokenExpiresAt: '',
credentialMode,
};
}
function createRebindForm(platformUserId = '') {
return { accessToken: '', platformUserId, refreshToken: '', tokenExpiresAt: '' };
}
function isTruthyFlag(value: string | null): boolean { function isTruthyFlag(value: string | null): boolean {
if (!value) return false; if (!value) return false;
const normalized = value.trim().toLowerCase(); const normalized = value.trim().toLowerCase();
@@ -46,33 +66,35 @@ export default function Accounts() {
const [highlightAccountId, setHighlightAccountId] = useState<number | null>(null); const [highlightAccountId, setHighlightAccountId] = useState<number | null>(null);
const [showAdd, setShowAdd] = useState(false); const [showAdd, setShowAdd] = useState(false);
const [addMode, setAddMode] = useState<'token' | 'login'>('token'); const [addMode, setAddMode] = useState<'token' | 'login'>('token');
const [loginForm, setLoginForm] = useState({ siteId: 0, username: '', password: '' }); const [loginForm, setLoginForm] = useState(createLoginForm);
const [tokenForm, setTokenForm] = useState({ const [tokenForm, setTokenForm] = useState(() => createTokenForm('session'));
siteId: 0,
username: '',
accessToken: '',
platformUserId: '',
refreshToken: '',
tokenExpiresAt: '',
credentialMode: 'session' as 'auto' | 'session' | 'apikey',
});
const [verifyResult, setVerifyResult] = useState<any>(null); const [verifyResult, setVerifyResult] = useState<any>(null);
const [verifying, setVerifying] = useState(false); const [verifying, setVerifying] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [actionLoading, setActionLoading] = useState<Record<string, boolean>>({}); const [actionLoading, setActionLoading] = useState<Record<string, boolean>>({});
const [embeddedTokenActions, setEmbeddedTokenActions] = useState<React.ReactNode>(null);
const [selectedAccountIds, setSelectedAccountIds] = useState<number[]>([]);
const [batchActionLoading, setBatchActionLoading] = useState(false);
const [editingAccount, setEditingAccount] = useState<any | null>(null);
const [editForm, setEditForm] = useState({
username: '',
status: 'active',
checkinEnabled: true,
unitCost: '',
accessToken: '',
apiToken: '',
isPinned: false,
refreshToken: '',
tokenExpiresAt: '',
});
const [savingEdit, setSavingEdit] = useState(false);
const [rebindTarget, setRebindTarget] = useState<any | null>(null); const [rebindTarget, setRebindTarget] = useState<any | null>(null);
const [rebindForm, setRebindForm] = useState({ accessToken: '', platformUserId: '', refreshToken: '', tokenExpiresAt: '' }); const [rebindForm, setRebindForm] = useState(() => createRebindForm());
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 [highlightRebindPanel, setHighlightRebindPanel] = useState(false);
const [rebindFocusTrigger, setRebindFocusTrigger] = useState(0);
const addPanelPresence = useAnimatedVisibility(showAdd, 220);
const rebindPanelPresence = useAnimatedVisibility(Boolean(rebindTarget), 220);
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 rebindPanelRef = useRef<HTMLDivElement | null>(null);
const rebindPanelTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastRebindTargetRef = useRef<any | null>(null); const lastRebindTargetRef = useRef<any | null>(null);
const toast = useToast(); const toast = useToast();
if (rebindTarget) lastRebindTargetRef.current = rebindTarget; if (rebindTarget) lastRebindTargetRef.current = rebindTarget;
@@ -85,7 +107,9 @@ export default function Accounts() {
api.getSites(), api.getSites(),
]); ]);
if (accountsResult.status === 'fulfilled') { if (accountsResult.status === 'fulfilled') {
setAccounts(accountsResult.value || []); const nextAccounts = accountsResult.value || [];
setAccounts(nextAccounts);
setSelectedAccountIds((current) => current.filter((id) => nextAccounts.some((account: any) => account.id === id)));
} else { } else {
toast.error('加载账号列表失败'); toast.error('加载账号列表失败');
} }
@@ -101,6 +125,21 @@ export default function Accounts() {
[sites, tokenForm.siteId], [sites, tokenForm.siteId],
); );
const isSub2ApiSelected = (selectedTokenSite?.platform || '').toLowerCase() === 'sub2api'; const isSub2ApiSelected = (selectedTokenSite?.platform || '').toLowerCase() === 'sub2api';
const activeAddCredentialMode = activeSegment === 'apikey' ? 'apikey' : 'session';
const resetAddForms = (credentialMode: 'session' | 'apikey' = activeAddCredentialMode) => {
setAddMode('token');
setLoginForm(createLoginForm());
setTokenForm(createTokenForm(credentialMode));
setVerifyResult(null);
};
const closeAddPanel = () => {
setShowAdd(false);
setVerifying(false);
setSaving(false);
resetAddForms();
};
const resolveAccountCredentialMode = (account: any): 'session' | 'apikey' => { const resolveAccountCredentialMode = (account: any): 'session' | 'apikey' => {
const rawMode = String(account?.credentialMode || '').trim().toLowerCase(); const rawMode = String(account?.credentialMode || '').trim().toLowerCase();
@@ -147,9 +186,14 @@ export default function Accounts() {
useEffect(() => { useEffect(() => {
if (activeSegment !== 'tokens') return; if (activeSegment !== 'tokens') return;
setShowAdd(false); closeAddPanel();
setVerifyResult(null);
if (rebindTarget) closeRebindPanel(); if (rebindTarget) closeRebindPanel();
setEditingAccount(null);
}, [activeSegment]);
useEffect(() => {
if (activeSegment === 'tokens') return;
setEmbeddedTokenActions(null);
}, [activeSegment]); }, [activeSegment]);
useEffect(() => { useEffect(() => {
@@ -162,14 +206,10 @@ export default function Accounts() {
setShowAdd(true); setShowAdd(true);
setAddMode('token'); setAddMode('token');
setVerifyResult(null); setVerifyResult(null);
setLoginForm(createLoginForm());
setTokenForm({ setTokenForm({
...createTokenForm('apikey'),
siteId: requestedSiteId, siteId: requestedSiteId,
username: '',
accessToken: '',
platformUserId: '',
refreshToken: '',
tokenExpiresAt: '',
credentialMode: 'apikey',
}); });
params.delete('create'); params.delete('create');
@@ -190,9 +230,6 @@ export default function Accounts() {
if (highlightTimerRef.current) { if (highlightTimerRef.current) {
clearTimeout(highlightTimerRef.current); clearTimeout(highlightTimerRef.current);
} }
if (rebindPanelTimerRef.current) {
clearTimeout(rebindPanelTimerRef.current);
}
}; };
}, []); }, []);
@@ -202,8 +239,7 @@ export default function Accounts() {
try { try {
const result = await api.loginAccount(loginForm); const result = await api.loginAccount(loginForm);
if (result.success) { if (result.success) {
setShowAdd(false); closeAddPanel();
setLoginForm({ siteId: 0, username: '', password: '' });
const msg = result.apiTokenFound const msg = result.apiTokenFound
? `账号 "${loginForm.username}" 已添加,API Key 已自动获取` ? `账号 "${loginForm.username}" 已添加,API Key 已自动获取`
: `账号 "${loginForm.username}" 已添加(未找到 API Key,请手动设置)`; : `账号 "${loginForm.username}" 已添加(未找到 API Key,请手动设置)`;
@@ -271,17 +307,7 @@ export default function Accounts() {
: undefined, : undefined,
credentialMode, credentialMode,
}); });
setShowAdd(false); closeAddPanel();
setTokenForm({
siteId: 0,
username: '',
accessToken: '',
platformUserId: '',
refreshToken: '',
tokenExpiresAt: '',
credentialMode: 'session',
});
setVerifyResult(null);
if (result.tokenType === 'apikey') { if (result.tokenType === 'apikey') {
toast.success('已添加为 API Key 账号(可用于代理转发)'); toast.success('已添加为 API Key 账号(可用于代理转发)');
} else { } else {
@@ -422,6 +448,115 @@ export default function Accounts() {
} }
}; };
const extractManagedSub2ApiAuth = (account: any) => {
try {
const parsed = JSON.parse(account?.extraConfig || '{}');
const auth = parsed?.sub2apiAuth || {};
return {
refreshToken: typeof auth.refreshToken === 'string' ? auth.refreshToken : '',
tokenExpiresAt: auth.tokenExpiresAt ? String(auth.tokenExpiresAt) : '',
};
} catch {
return { refreshToken: '', tokenExpiresAt: '' };
}
};
const openEditPanel = (account: any) => {
const managedAuth = extractManagedSub2ApiAuth(account);
closeAddPanel();
setRebindTarget(null);
setEditingAccount(account);
setEditForm({
username: account?.username || '',
status: account?.status || 'active',
checkinEnabled: account?.checkinEnabled !== false,
unitCost: account?.unitCost === null || account?.unitCost === undefined ? '' : String(account.unitCost),
accessToken: account?.accessToken || '',
apiToken: account?.apiToken || '',
isPinned: !!account?.isPinned,
refreshToken: managedAuth.refreshToken,
tokenExpiresAt: managedAuth.tokenExpiresAt,
});
};
const closeEditPanel = () => {
setEditingAccount(null);
setSavingEdit(false);
};
const saveEditPanel = async () => {
if (!editingAccount) return;
setSavingEdit(true);
try {
await api.updateAccount(editingAccount.id, {
username: editForm.username.trim() || undefined,
status: editForm.status,
checkinEnabled: editForm.checkinEnabled,
unitCost: editForm.unitCost.trim() ? Number(editForm.unitCost.trim()) : null,
accessToken: editForm.accessToken.trim(),
apiToken: editForm.apiToken.trim() || null,
isPinned: editForm.isPinned,
refreshToken: editForm.refreshToken.trim() || null,
tokenExpiresAt: editForm.tokenExpiresAt.trim() ? Number.parseInt(editForm.tokenExpiresAt.trim(), 10) : null,
});
toast.success('账号已更新');
closeEditPanel();
load();
} catch (e: any) {
toast.error(e.message || '更新账号失败');
} finally {
setSavingEdit(false);
}
};
const toggleAccountSelection = (accountId: number, checked: boolean) => {
setSelectedAccountIds((current) => (
checked
? Array.from(new Set([...current, accountId]))
: current.filter((id) => id !== accountId)
));
};
const toggleSelectAllVisibleAccounts = (checked: boolean) => {
if (!checked) {
setSelectedAccountIds([]);
return;
}
setSelectedAccountIds(visibleAccounts.map((account) => account.id));
};
const runBatchAccountAction = async (action: 'enable' | 'disable' | 'delete' | 'refreshBalance') => {
if (selectedAccountIds.length === 0) return;
if (action === 'delete' && typeof globalThis.confirm === 'function' && !globalThis.confirm(`确认删除选中的 ${selectedAccountIds.length} 个账号?`)) return;
setBatchActionLoading(true);
try {
const result = await api.batchUpdateAccounts({
ids: selectedAccountIds,
action,
});
const successIds = Array.isArray(result?.successIds) ? result.successIds.map((id: unknown) => Number(id)) : [];
const failedItems = Array.isArray(result?.failedItems) ? result.failedItems : [];
if (failedItems.length > 0) {
toast.info(`批量操作完成:成功 ${successIds.length},失败 ${failedItems.length}`);
} else {
toast.success(`批量操作完成:成功 ${successIds.length}`);
}
setSelectedAccountIds(failedItems.map((item: any) => Number(item.id)).filter((id: number) => Number.isFinite(id) && id > 0));
load();
} catch (e: any) {
toast.error(e.message || '批量操作失败');
} finally {
setBatchActionLoading(false);
}
};
const handleAccountRowClick = (accountId: number, event: React.MouseEvent<HTMLTableRowElement>) => {
if (shouldIgnoreRowSelectionClick(event.target)) return;
const isSelected = selectedAccountIds.includes(accountId);
toggleAccountSelection(accountId, !isSelected);
};
const extractPlatformUserId = (account: any): string => { const extractPlatformUserId = (account: any): string => {
try { try {
const parsed = JSON.parse(account?.extraConfig || '{}'); const parsed = JSON.parse(account?.extraConfig || '{}');
@@ -434,40 +569,21 @@ export default function Accounts() {
}; };
const openRebindPanel = (account: any) => { const openRebindPanel = (account: any) => {
closeAddPanel();
setEditingAccount(null);
setRebindTarget(account); setRebindTarget(account);
setRebindForm({ setRebindForm(createRebindForm(extractPlatformUserId(account)));
accessToken: '',
platformUserId: extractPlatformUserId(account),
refreshToken: '',
tokenExpiresAt: '',
});
setRebindVerifyResult(null); setRebindVerifyResult(null);
setRebindFocusTrigger((value) => value + 1);
}; };
const closeRebindPanel = () => { const closeRebindPanel = () => {
setRebindTarget(null); setRebindTarget(null);
setRebindForm({ accessToken: '', platformUserId: '', refreshToken: '', tokenExpiresAt: '' }); setRebindForm(createRebindForm());
setRebindVerifyResult(null); setRebindVerifyResult(null);
setRebindVerifying(false); setRebindVerifying(false);
setRebindSaving(false); setRebindSaving(false);
setHighlightRebindPanel(false);
}; };
useEffect(() => {
if (!rebindTarget || rebindFocusTrigger <= 0) return;
setHighlightRebindPanel(true);
rebindPanelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
if (rebindPanelTimerRef.current) {
clearTimeout(rebindPanelTimerRef.current);
}
rebindPanelTimerRef.current = setTimeout(() => {
setHighlightRebindPanel(false);
}, 2200);
}, [rebindFocusTrigger, rebindTarget]);
const handleVerifyRebindToken = async () => { const handleVerifyRebindToken = async () => {
if (!rebindTarget || !rebindForm.accessToken.trim()) return; if (!rebindTarget || !rebindForm.accessToken.trim()) return;
setRebindVerifying(true); setRebindVerifying(true);
@@ -598,13 +714,15 @@ export default function Accounts() {
</button> </button>
<button <button
onClick={() => { onClick={() => {
setShowAdd(!showAdd); const nextOpen = !showAdd;
setAddMode('token'); if (!nextOpen) {
setVerifyResult(null); closeAddPanel();
setTokenForm((current) => ({ return;
...current, }
credentialMode: activeSegment === 'apikey' ? 'apikey' : 'session', setEditingAccount(null);
})); closeRebindPanel();
setShowAdd(true);
resetAddForms(activeAddCredentialMode);
}} }}
className="btn btn-primary" className="btn btn-primary"
> >
@@ -612,6 +730,7 @@ export default function Accounts() {
</button> </button>
</div> </div>
)} )}
{activeSegment === 'tokens' && embeddedTokenActions}
</div> </div>
<div <div
@@ -652,12 +771,36 @@ export default function Accounts() {
))} ))}
</div> </div>
{activeSegment !== 'tokens' && selectedAccountIds.length > 0 && (
<div className="card" style={{ padding: 12, marginBottom: 12, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<span style={{ fontSize: 13, fontWeight: 600 }}>已选 {selectedAccountIds.length} 项</span>
<button data-testid="accounts-batch-refresh-balance" onClick={() => runBatchAccountAction('refreshBalance')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
批量刷新余额
</button>
<button onClick={() => runBatchAccountAction('enable')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
批量启用
</button>
<button onClick={() => runBatchAccountAction('disable')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
批量禁用
</button>
<button onClick={() => runBatchAccountAction('delete')} disabled={batchActionLoading} className="btn btn-link btn-link-danger">
批量删除
</button>
</div>
)}
{activeSegment === 'tokens' ? ( {activeSegment === 'tokens' ? (
<TokensPanel embedded /> <TokensPanel embedded onEmbeddedActionsChange={setEmbeddedTokenActions} />
) : ( ) : (
<> <>
{addPanelPresence.shouldRender && ( <CenteredModal
<div className={`card panel-presence ${addPanelPresence.isVisible ? '' : 'is-closing'}`.trim()} style={getAccountsAddPanelStyle()}> open={showAdd}
onClose={closeAddPanel}
title={activeSegment === 'apikey' ? '添加 API Key 连接' : (addMode === 'login' ? '账号密码登录' : '添加 Session 连接')}
maxWidth={860}
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
footer={<button onClick={closeAddPanel} className="btn btn-ghost">取消</button>}
>
{activeSegment === 'session' ? ( {activeSegment === 'session' ? (
<> <>
<div style={{ display: 'flex', gap: 0, background: 'var(--color-bg)', borderRadius: 'var(--radius-sm)', padding: 3, marginBottom: 16 }}> <div style={{ display: 'flex', gap: 0, background: 'var(--color-bg)', borderRadius: 'var(--radius-sm)', padding: 3, marginBottom: 16 }}>
@@ -967,21 +1110,19 @@ export default function Accounts() {
)} )}
</div> </div>
)} )}
</div> </CenteredModal>
)}
{activeSegment === 'session' && rebindPanelPresence.shouldRender && activeRebindTarget && ( {activeSegment === 'session' && (
<div <CenteredModal
ref={rebindPanelRef} open={Boolean(rebindTarget)}
className={`card panel-presence rebind-panel ${rebindPanelPresence.isVisible ? '' : 'is-closing'} ${highlightRebindPanel ? 'rebind-panel-highlight' : ''}`.trim()} onClose={closeRebindPanel}
style={{ marginBottom: 16, padding: 16 }} title="重新绑定 Session Token"
maxWidth={820}
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
footer={<button onClick={closeRebindPanel} className="btn btn-ghost">取消</button>}
> >
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}> {activeRebindTarget ? (
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--color-text-primary)' }}> <>
重新绑定 Session Token
</div>
<button className="btn btn-ghost" onClick={closeRebindPanel}>关闭</button>
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}> <div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
连接: {resolveAccountDisplayName(activeRebindTarget)} @ {activeRebindTarget.site?.name || '-'}。请粘贴新的 Session Token,验证成功后再绑定。 连接: {resolveAccountDisplayName(activeRebindTarget)} @ {activeRebindTarget.site?.name || '-'}。请粘贴新的 Session Token,验证成功后再绑定。
</div> </div>
@@ -1064,14 +1205,102 @@ export default function Accounts() {
: '确认重新绑定'} : '确认重新绑定'}
</button> </button>
</div> </div>
</div> </>
) : null}
</CenteredModal>
)} )}
<CenteredModal
open={Boolean(editingAccount)}
onClose={closeEditPanel}
title="编辑账号"
maxWidth={860}
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
footer={(
<>
<button onClick={closeEditPanel} className="btn btn-ghost">取消</button>
<button onClick={saveEditPanel} disabled={savingEdit} className="btn btn-primary">
{savingEdit ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存修改'}
</button>
</>
)}
>
{editingAccount ? (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<input
placeholder="账号名称"
value={editForm.username}
onChange={(e) => setEditForm((prev) => ({ ...prev, username: e.target.value }))}
style={inputStyle}
/>
<ModernSelect
value={editForm.status}
onChange={(value) => setEditForm((prev) => ({ ...prev, status: value }))}
options={[
{ value: 'active', label: 'active' },
{ value: 'disabled', label: 'disabled' },
{ value: 'expired', label: 'expired' },
]}
placeholder="状态"
/>
<input
placeholder="单位成本(可选)"
value={editForm.unitCost}
onChange={(e) => setEditForm((prev) => ({ ...prev, unitCost: e.target.value }))}
style={inputStyle}
/>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, ...inputStyle }}>
<input
type="checkbox"
checked={editForm.checkinEnabled}
onChange={(e) => setEditForm((prev) => ({ ...prev, checkinEnabled: e.target.checked }))}
/>
启用签到
</label>
<input
placeholder="Access Token"
value={editForm.accessToken}
onChange={(e) => setEditForm((prev) => ({ ...prev, accessToken: e.target.value }))}
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
/>
<input
placeholder="API Token(可选)"
value={editForm.apiToken}
onChange={(e) => setEditForm((prev) => ({ ...prev, apiToken: e.target.value }))}
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
/>
{((editingAccount?.site?.platform || '').toLowerCase() === 'sub2api') && (
<>
<input
placeholder="Sub2API refresh_token(可选)"
value={editForm.refreshToken}
onChange={(e) => setEditForm((prev) => ({ ...prev, refreshToken: e.target.value }))}
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
/>
<input
placeholder="token_expires_at(可选)"
value={editForm.tokenExpiresAt}
onChange={(e) => setEditForm((prev) => ({ ...prev, tokenExpiresAt: e.target.value.replace(/\D/g, '') }))}
style={inputStyle}
/>
</>
)}
</div>
) : null}
</CenteredModal>
<div className="card"> <div className="card">
{visibleAccounts.length > 0 ? ( {visibleAccounts.length > 0 ? (
<table className="data-table accounts-table"> <table className="data-table accounts-table">
<thead> <thead>
<tr> <tr>
<th style={{ width: 44 }}>
<input
type="checkbox"
checked={visibleAccounts.length > 0 && selectedAccountIds.length === visibleAccounts.length}
onChange={(e) => toggleSelectAllVisibleAccounts(e.target.checked)}
/>
</th>
<th>连接名称</th> <th>连接名称</th>
<th>站点</th> <th>站点</th>
<th>运行健康状态</th> <th>运行健康状态</th>
@@ -1088,21 +1317,28 @@ export default function Accounts() {
return ( return (
<tr <tr
key={a.id} key={a.id}
data-testid={`account-row-${a.id}`}
ref={(node) => { ref={(node) => {
if (node) rowRefs.current.set(a.id, node); if (node) rowRefs.current.set(a.id, node);
else rowRefs.current.delete(a.id); else rowRefs.current.delete(a.id);
}} }}
className={`animate-slide-up stagger-${Math.min(i + 1, 5)} ${highlightAccountId === a.id ? 'row-focus-highlight' : ''}`} onClick={(event) => handleAccountRowClick(a.id, event)}
className={`animate-slide-up stagger-${Math.min(i + 1, 5)} row-selectable ${selectedAccountIds.includes(a.id) ? 'row-selected' : ''} ${highlightAccountId === a.id ? 'row-focus-highlight' : ''}`.trim()}
> >
<td>
<input
data-testid={`account-select-${a.id}`}
type="checkbox"
checked={selectedAccountIds.includes(a.id)}
onChange={(e) => toggleAccountSelection(a.id, e.target.checked)}
/>
</td>
<td style={{ color: 'var(--color-text-primary)' }}> <td style={{ color: 'var(--color-text-primary)' }}>
<div style={{ fontWeight: 600 }}>{resolveAccountDisplayName(a)}</div> <div style={{ fontWeight: 600 }}>{resolveAccountDisplayName(a)}</div>
<div style={{ display: 'flex', gap: 4, marginTop: 4 }}> <div style={{ display: 'flex', gap: 4, marginTop: 4 }}>
<span className={`badge ${connectionMode === 'apikey' ? 'badge-warning' : 'badge-info'}`} style={{ fontSize: 10 }}> <span className={`badge ${connectionMode === 'apikey' ? 'badge-warning' : 'badge-info'}`} style={{ fontSize: 10 }}>
{connectionMode === 'apikey' ? 'API Key' : 'Session'} {connectionMode === 'apikey' ? 'API Key' : 'Session'}
</span> </span>
{capabilities.proxyOnly && (
<span className="badge badge-muted" style={{ fontSize: 10 }}>仅代理</span>
)}
</div> </div>
</td> </td>
<td> <td>
@@ -1229,6 +1465,9 @@ export default function Accounts() {
重新绑定 重新绑定
</button> </button>
)} )}
<button onClick={() => openEditPanel(a)} className="btn btn-link btn-link-info">
编辑
</button>
<button onClick={() => withLoading(`delete-${a.id}`, () => api.deleteAccount(a.id), '已删除')} disabled={actionLoading[`delete-${a.id}`]} className="btn btn-link btn-link-danger"> <button onClick={() => withLoading(`delete-${a.id}`, () => api.deleteAccount(a.id), '已删除')} disabled={actionLoading[`delete-${a.id}`]} className="btn btn-link btn-link-danger">
{actionLoading[`delete-${a.id}`] ? <span className="spinner spinner-sm" /> : '删除'} {actionLoading[`delete-${a.id}`] ? <span className="spinner spinner-sm" /> : '删除'}
</button> </button>
+41 -1
View File
@@ -24,6 +24,7 @@ type RuntimeSettings = {
balanceRefreshCron: string; balanceRefreshCron: string;
routingFallbackUnitCost: number; routingFallbackUnitCost: number;
routingWeights: RoutingWeights; routingWeights: RoutingWeights;
systemProxyUrl: string;
proxyTokenMasked?: string; proxyTokenMasked?: string;
adminIpAllowlist?: string[]; adminIpAllowlist?: string[];
currentAdminIp?: string; currentAdminIp?: string;
@@ -178,12 +179,14 @@ export default function Settings() {
balanceRefreshCron: '0 * * * *', balanceRefreshCron: '0 * * * *',
routingFallbackUnitCost: 1, routingFallbackUnitCost: 1,
routingWeights: defaultWeights, routingWeights: defaultWeights,
systemProxyUrl: '',
}); });
const [proxyTokenSuffix, setProxyTokenSuffix] = useState(''); const [proxyTokenSuffix, setProxyTokenSuffix] = useState('');
const [maskedToken, setMaskedToken] = useState(''); const [maskedToken, setMaskedToken] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [savingSchedule, setSavingSchedule] = useState(false); const [savingSchedule, setSavingSchedule] = useState(false);
const [savingToken, setSavingToken] = useState(false); const [savingToken, setSavingToken] = useState(false);
const [savingSystemProxy, setSavingSystemProxy] = useState(false);
const [savingRouting, setSavingRouting] = useState(false); const [savingRouting, setSavingRouting] = useState(false);
const [showAdvancedRouting, setShowAdvancedRouting] = useState(false); const [showAdvancedRouting, setShowAdvancedRouting] = useState(false);
const [savingSecurity, setSavingSecurity] = useState(false); const [savingSecurity, setSavingSecurity] = useState(false);
@@ -367,6 +370,7 @@ export default function Settings() {
...defaultWeights, ...defaultWeights,
...(runtimeInfo.routingWeights || {}), ...(runtimeInfo.routingWeights || {}),
}, },
systemProxyUrl: typeof runtimeInfo.systemProxyUrl === 'string' ? runtimeInfo.systemProxyUrl : '',
proxyTokenMasked: runtimeInfo.proxyTokenMasked || '', proxyTokenMasked: runtimeInfo.proxyTokenMasked || '',
adminIpAllowlist: Array.isArray(runtimeInfo.adminIpAllowlist) adminIpAllowlist: Array.isArray(runtimeInfo.adminIpAllowlist)
? runtimeInfo.adminIpAllowlist.filter((item: unknown) => typeof item === 'string') ? runtimeInfo.adminIpAllowlist.filter((item: unknown) => typeof item === 'string')
@@ -458,6 +462,26 @@ export default function Settings() {
} }
}; };
const saveSystemProxy = async () => {
setSavingSystemProxy(true);
try {
const res = await api.updateRuntimeSettings({
systemProxyUrl: runtime.systemProxyUrl.trim(),
});
setRuntime((prev) => ({
...prev,
systemProxyUrl: typeof res?.systemProxyUrl === 'string'
? res.systemProxyUrl
: prev.systemProxyUrl,
}));
toast.success('系统代理已保存');
} catch (err: any) {
toast.error(err?.message || '保存失败');
} finally {
setSavingSystemProxy(false);
}
};
const resetDownstreamForm = () => { const resetDownstreamForm = () => {
setEditingDownstreamId(null); setEditingDownstreamId(null);
setDownstreamCreate({ setDownstreamCreate({
@@ -851,6 +875,22 @@ export default function Settings() {
</div> </div>
<div className="card animate-slide-up stagger-3" style={{ padding: 20 }}> <div className="card animate-slide-up stagger-3" style={{ padding: 20 }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>系统代理</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
配置一个全局出站代理地址,站点页可按站点决定是否启用系统代理。
</div>
<input
value={runtime.systemProxyUrl}
onChange={(e) => setRuntime((prev) => ({ ...prev, systemProxyUrl: e.target.value }))}
placeholder="系统代理 URL(可选,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080)"
style={{ ...inputStyle, fontFamily: 'var(--font-mono)', marginBottom: 10 }}
/>
<button onClick={saveSystemProxy} disabled={savingSystemProxy} className="btn btn-primary">
{savingSystemProxy ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存系统代理'}
</button>
</div>
<div className="card animate-slide-up stagger-4" style={{ padding: 20 }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>下游访问令牌(PROXY_TOKEN)</div> <div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>下游访问令牌(PROXY_TOKEN)</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}> <div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
用于下游站点或客户端访问本服务代理接口。前缀 sk- 固定不可修改,只需填写后缀。 用于下游站点或客户端访问本服务代理接口。前缀 sk- 固定不可修改,只需填写后缀。
@@ -902,7 +942,7 @@ export default function Settings() {
</button> </button>
</div> </div>
<div className="card animate-slide-up stagger-4" style={{ padding: 20 }}> <div className="card animate-slide-up stagger-5" style={{ padding: 20 }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>下游 API Key 策略(按项目/分组)</div> <div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>下游 API Key 策略(按项目/分组)</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}> <div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
每个下游 Key 可独立配置过期、额度,并通过勾选界面限制可访问的模型与群组。 每个下游 Key 可独立配置过期、额度,并通过勾选界面限制可访问的模型与群组。
+128 -18
View File
@@ -8,6 +8,7 @@ import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
import { clearFocusParams, readFocusSiteId } from './helpers/navigationFocus.js'; import { clearFocusParams, readFocusSiteId } from './helpers/navigationFocus.js';
import { tr } from '../i18n.js'; import { tr } from '../i18n.js';
import { buildCustomReorderUpdates, sortItemsForDisplay, type SortMode } from './helpers/listSorting.js'; import { buildCustomReorderUpdates, sortItemsForDisplay, type SortMode } from './helpers/listSorting.js';
import { shouldIgnoreRowSelectionClick } from './helpers/rowSelection.js';
import { import {
buildSiteSaveAction, buildSiteSaveAction,
emptySiteForm, emptySiteForm,
@@ -23,7 +24,7 @@ type SiteRow = {
externalCheckinUrl?: string | null; externalCheckinUrl?: string | null;
platform?: string; platform?: string;
status?: string; status?: string;
proxyUrl?: string | null; useSystemProxy?: boolean;
globalWeight?: number; globalWeight?: number;
isPinned?: boolean; isPinned?: boolean;
sortOrder?: number; sortOrder?: number;
@@ -75,6 +76,8 @@ export default function Sites() {
const [togglingSiteId, setTogglingSiteId] = useState<number | null>(null); const [togglingSiteId, setTogglingSiteId] = useState<number | null>(null);
const [orderingSiteId, setOrderingSiteId] = useState<number | null>(null); const [orderingSiteId, setOrderingSiteId] = useState<number | null>(null);
const [pinningSiteId, setPinningSiteId] = useState<number | null>(null); const [pinningSiteId, setPinningSiteId] = useState<number | null>(null);
const [selectedSiteIds, setSelectedSiteIds] = useState<number[]>([]);
const [batchActionLoading, setBatchActionLoading] = useState(false);
const editorPresence = useAnimatedVisibility(Boolean(editor), 220); const editorPresence = useAnimatedVisibility(Boolean(editor), 220);
const lastEditorRef = useRef<SiteEditorState | null>(null); const lastEditorRef = useRef<SiteEditorState | null>(null);
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map()); const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
@@ -90,6 +93,7 @@ export default function Sites() {
try { try {
const rows = await api.getSites(); const rows = await api.getSites();
setSites(rows || []); setSites(rows || []);
setSelectedSiteIds((current) => current.filter((id) => (rows || []).some((site: SiteRow) => site.id === id)));
} catch { } catch {
toast.error('加载站点列表失败'); toast.error('加载站点列表失败');
} finally { } finally {
@@ -187,7 +191,7 @@ export default function Sites() {
url: form.url.trim(), url: form.url.trim(),
externalCheckinUrl: form.externalCheckinUrl.trim(), externalCheckinUrl: form.externalCheckinUrl.trim(),
platform: form.platform.trim(), platform: form.platform.trim(),
proxyUrl: form.proxyUrl.trim(), useSystemProxy: !!form.useSystemProxy,
globalWeight: Number(parsedGlobalWeight.toFixed(3)), globalWeight: Number(parsedGlobalWeight.toFixed(3)),
}; };
if (!payload.name || !payload.url) { if (!payload.name || !payload.url) {
@@ -295,6 +299,54 @@ export default function Sites() {
} }
}; };
const toggleSiteSelection = (siteId: number, checked: boolean) => {
setSelectedSiteIds((current) => (
checked
? Array.from(new Set([...current, siteId]))
: current.filter((id) => id !== siteId)
));
};
const toggleSelectAllVisible = (checked: boolean) => {
if (!checked) {
setSelectedSiteIds([]);
return;
}
setSelectedSiteIds(sortedSites.map((site) => site.id));
};
const runBatchAction = async (action: 'enable' | 'disable' | 'delete' | 'enableSystemProxy' | 'disableSystemProxy') => {
if (selectedSiteIds.length === 0) return;
if (action === 'delete' && typeof globalThis.confirm === 'function' && !globalThis.confirm(`确认删除选中的 ${selectedSiteIds.length} 个站点?`)) return;
setBatchActionLoading(true);
try {
const result = await api.batchUpdateSites({
ids: selectedSiteIds,
action,
});
const successIds = Array.isArray(result?.successIds) ? result.successIds.map((id: unknown) => Number(id)) : [];
const failedItems = Array.isArray(result?.failedItems) ? result.failedItems : [];
if (failedItems.length > 0) {
toast.info(`批量操作完成:成功 ${successIds.length},失败 ${failedItems.length}`);
} else {
toast.success(`批量操作完成:成功 ${successIds.length}`);
}
setSelectedSiteIds(failedItems.map((item: any) => Number(item.id)).filter((id: number) => Number.isFinite(id) && id > 0));
await load();
} catch (e: any) {
toast.error(e.message || '批量操作失败');
} finally {
setBatchActionLoading(false);
}
};
const handleSiteRowClick = (siteId: number, event: React.MouseEvent<HTMLTableRowElement>) => {
if (shouldIgnoreRowSelectionClick(event.target)) return;
const isSelected = selectedSiteIds.includes(siteId);
toggleSiteSelection(siteId, !isSelected);
};
return ( return (
<div className="animate-fade-in"> <div className="animate-fade-in">
<div className="page-header"> <div className="page-header">
@@ -319,6 +371,38 @@ export default function Sites() {
</div> </div>
</div> </div>
{selectedSiteIds.length > 0 && (
<div className="card" style={{ padding: 12, marginBottom: 12, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<span style={{ fontSize: 13, fontWeight: 600 }}>已选 {selectedSiteIds.length} 项</span>
<button
data-testid="sites-batch-enable-system-proxy"
onClick={() => runBatchAction('enableSystemProxy')}
disabled={batchActionLoading}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
批量开启系统代理
</button>
<button
onClick={() => runBatchAction('disableSystemProxy')}
disabled={batchActionLoading}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
批量关闭系统代理
</button>
<button onClick={() => runBatchAction('enable')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
批量启用
</button>
<button onClick={() => runBatchAction('disable')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
批量禁用
</button>
<button onClick={() => runBatchAction('delete')} disabled={batchActionLoading} className="btn btn-link btn-link-danger">
批量删除
</button>
</div>
)}
<div className="info-tip" style={{ marginBottom: 12 }}> <div className="info-tip" style={{ marginBottom: 12 }}>
站点权重说明:最终站点倍率 = 站点全局权重 × 设置页中下游 API Key 的站点倍率。它会与路由策略因子(基础权重、价值分、成本、余额、使用频次)共同作用。数值越大,该站点在同优先级下越容易被选中。建议范围 0.5-3,默认 1;长期不建议超过 5。 站点权重说明:最终站点倍率 = 站点全局权重 × 设置页中下游 API Key 的站点倍率。它会与路由策略因子(基础权重、价值分、成本、余额、使用频次)共同作用。数值越大,该站点在同优先级下越容易被选中。建议范围 0.5-3,默认 1;长期不建议超过 5。
</div> </div>
@@ -409,21 +493,24 @@ export default function Sites() {
color: 'var(--color-text-primary)', color: 'var(--color-text-primary)',
}} }}
/> />
<input <label style={{
placeholder="出站代理 URL(可选,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080)" display: 'flex',
value={form.proxyUrl} alignItems: 'center',
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))} gap: 10,
style={{ padding: '10px 14px',
width: '100%', border: '1px solid var(--color-border)',
padding: '10px 14px', borderRadius: 'var(--radius-sm)',
border: '1px solid var(--color-border)', fontSize: 13,
borderRadius: 'var(--radius-sm)', background: 'var(--color-bg)',
fontSize: 13, color: 'var(--color-text-primary)',
outline: 'none', }}>
background: 'var(--color-bg)', <input
color: 'var(--color-text-primary)', type="checkbox"
}} checked={form.useSystemProxy}
/> onChange={(e) => setForm((prev) => ({ ...prev, useSystemProxy: e.target.checked }))}
/>
使用系统代理
</label>
<input <input
placeholder="站点全局权重(默认 1)" placeholder="站点全局权重(默认 1)"
value={form.globalWeight} value={form.globalWeight}
@@ -459,10 +546,18 @@ export default function Sites() {
<table className="data-table sites-table"> <table className="data-table sites-table">
<thead> <thead>
<tr> <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>名称</th>
<th>外部签到站URL</th> <th>外部签到站URL</th>
<th>总余额</th> <th>总余额</th>
<th>状态</th> <th>状态</th>
<th>系统代理</th>
<th>权重</th> <th>权重</th>
<th>平台</th> <th>平台</th>
<th>创建时间</th> <th>创建时间</th>
@@ -473,12 +568,22 @@ export default function Sites() {
{sortedSites.map((site, i) => ( {sortedSites.map((site, i) => (
<tr <tr
key={site.id} key={site.id}
data-testid={`site-row-${site.id}`}
ref={(node) => { ref={(node) => {
if (node) rowRefs.current.set(site.id, node); if (node) rowRefs.current.set(site.id, node);
else rowRefs.current.delete(site.id); else rowRefs.current.delete(site.id);
}} }}
className={`animate-slide-up stagger-${Math.min(i + 1, 5)} ${highlightSiteId === site.id ? 'row-focus-highlight' : ''}`} 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 }}> <td style={{ fontWeight: 600 }}>
<a <a
href={site.url} href={site.url}
@@ -519,6 +624,11 @@ export default function Sites() {
{site.status === 'disabled' ? '禁用' : '启用'} {site.status === 'disabled' ? '禁用' : '启用'}
</span> </span>
</td> </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 }}> <td style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600 }}>
{(site.globalWeight || 1).toFixed(2)} {(site.globalWeight || 1).toFixed(2)}
</td> </td>
+518 -172
View File
@@ -1,16 +1,18 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Navigate, useLocation, useNavigate } from 'react-router-dom'; import { Navigate, useLocation, useNavigate } from 'react-router-dom';
import { api } from '../api.js'; import { api } from '../api.js';
import CenteredModal from '../components/CenteredModal.js';
import { useToast } from '../components/Toast.js'; import { useToast } from '../components/Toast.js';
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
import { formatDateTimeLocal } from './helpers/checkinLogTime.js'; import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
import ModernSelect from '../components/ModernSelect.js'; import ModernSelect from '../components/ModernSelect.js';
import { getAccountsAddPanelStyle } from './helpers/accountsPanelStyle.js'; import { clearFocusParams, readFocusTokenId } from './helpers/navigationFocus.js';
import { shouldIgnoreRowSelectionClick } from './helpers/rowSelection.js';
import { tr } from '../i18n.js'; import { tr } from '../i18n.js';
type SyncStatus = 'success' | 'skipped' | 'failed'; type SyncStatus = 'success' | 'skipped' | 'failed';
type TokensPanelProps = { type TokensPanelProps = {
embedded?: boolean; embedded?: boolean;
onEmbeddedActionsChange?: (actions: React.ReactNode | null) => void;
}; };
type AccountTokenSyncResult = { type AccountTokenSyncResult = {
@@ -100,7 +102,7 @@ function isTruthyFlag(input: string | null): boolean {
return normalized === '1' || normalized === 'true' || normalized === 'yes'; return normalized === '1' || normalized === 'true' || normalized === 'yes';
} }
export function TokensPanel({ embedded = false }: TokensPanelProps) { export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: TokensPanelProps) {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const initialCreateForm = { const initialCreateForm = {
@@ -121,19 +123,35 @@ export function TokensPanel({ embedded = false }: TokensPanelProps) {
const [syncing, setSyncing] = useState(false); const [syncing, setSyncing] = useState(false);
const [syncingAll, setSyncingAll] = useState(false); const [syncingAll, setSyncingAll] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [savingEdit, setSavingEdit] = useState(false);
const [editingToken, setEditingToken] = useState<any | null>(null);
const [editingTokenValueLoading, setEditingTokenValueLoading] = useState(false);
const [createHintModelName, setCreateHintModelName] = useState(''); const [createHintModelName, setCreateHintModelName] = useState('');
const [highlightTokenId, setHighlightTokenId] = useState<number | null>(null);
const [rowLoading, setRowLoading] = useState<Record<string, boolean>>({}); const [rowLoading, setRowLoading] = useState<Record<string, boolean>>({});
const [selectedTokenIds, setSelectedTokenIds] = useState<number[]>([]);
const [batchActionLoading, setBatchActionLoading] = useState(false);
const [form, setForm] = useState(initialCreateForm); const [form, setForm] = useState(initialCreateForm);
const [editForm, setEditForm] = useState({
name: '',
token: '',
enabled: true,
isDefault: false,
});
const [groupOptions, setGroupOptions] = useState<string[]>(['default']); const [groupOptions, setGroupOptions] = useState<string[]>(['default']);
const [groupLoading, setGroupLoading] = useState(false); const [groupLoading, setGroupLoading] = useState(false);
const addPanelPresence = useAnimatedVisibility(showAdd, 220); const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const editingTokenIdRef = useRef<number | null>(null);
const toast = useToast(); const toast = useToast();
const load = async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const [tokenRows, accountRows] = await Promise.all([api.getAccountTokens(), api.getAccounts()]); const [tokenRows, accountRows] = await Promise.all([api.getAccountTokens(), api.getAccounts()]);
setTokens(tokenRows || []); const nextTokens = tokenRows || [];
setTokens(nextTokens);
setSelectedTokenIds((current) => current.filter((id) => nextTokens.some((token: any) => token.id === id)));
const latestAccounts = accountRows || []; const latestAccounts = accountRows || [];
setAccounts(latestAccounts); setAccounts(latestAccounts);
@@ -147,10 +165,18 @@ export function TokensPanel({ embedded = false }: TokensPanelProps) {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, [syncingAccountId, toast]);
useEffect(() => { useEffect(() => {
load(); void load();
}, [load]);
useEffect(() => {
return () => {
if (highlightTimerRef.current) {
clearTimeout(highlightTimerRef.current);
}
};
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -232,6 +258,27 @@ export function TokensPanel({ embedded = false }: TokensPanelProps) {
); );
}, [activeAccounts, location.pathname, location.search, navigate, toast]); }, [activeAccounts, location.pathname, location.search, navigate, toast]);
useEffect(() => {
const focusTokenId = readFocusTokenId(location.search);
if (!focusTokenId || loading) return;
const row = rowRefs.current.get(focusTokenId);
const cleanedSearch = clearFocusParams(location.search);
if (!row) {
navigate({ pathname: location.pathname, search: cleanedSearch }, { replace: true });
return;
}
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
setHighlightTokenId(focusTokenId);
if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current);
highlightTimerRef.current = setTimeout(() => {
setHighlightTokenId((current) => (current === focusTokenId ? null : current));
}, 2200);
navigate({ pathname: location.pathname, search: cleanedSearch }, { replace: true });
}, [loading, location.pathname, location.search, navigate, tokens]);
const withRowLoading = async (key: string, fn: () => Promise<void>) => { const withRowLoading = async (key: string, fn: () => Promise<void>) => {
setRowLoading((prev) => ({ ...prev, [key]: true })); setRowLoading((prev) => ({ ...prev, [key]: true }));
try { try {
@@ -241,6 +288,118 @@ export function TokensPanel({ embedded = false }: TokensPanelProps) {
} }
}; };
const toggleTokenSelection = (tokenId: number, checked: boolean) => {
setSelectedTokenIds((current) => (
checked
? Array.from(new Set([...current, tokenId]))
: current.filter((id) => id !== tokenId)
));
};
const toggleSelectAllTokens = (checked: boolean) => {
if (!checked) {
setSelectedTokenIds([]);
return;
}
setSelectedTokenIds(tokens.map((token) => token.id));
};
const runBatchTokenAction = async (action: 'enable' | 'disable' | 'delete') => {
if (selectedTokenIds.length === 0) return;
if (action === 'delete' && typeof globalThis.confirm === 'function' && !globalThis.confirm(`确认删除选中的 ${selectedTokenIds.length} 个令牌?`)) return;
setBatchActionLoading(true);
try {
const result = await api.batchUpdateAccountTokens({
ids: selectedTokenIds,
action,
});
const successIds = Array.isArray(result?.successIds) ? result.successIds.map((id: unknown) => Number(id)) : [];
const failedItems = Array.isArray(result?.failedItems) ? result.failedItems : [];
if (failedItems.length > 0) {
toast.info(`批量操作完成:成功 ${successIds.length},失败 ${failedItems.length}`);
} else {
toast.success(`批量操作完成:成功 ${successIds.length}`);
}
setSelectedTokenIds(failedItems.map((item: any) => Number(item.id)).filter((id: number) => Number.isFinite(id) && id > 0));
await load();
} catch (e: any) {
toast.error(e.message || '批量操作失败');
} finally {
setBatchActionLoading(false);
}
};
const openEditPanel = useCallback((token: any) => {
setShowAdd(false);
setCreateHintModelName('');
setEditingToken(token);
editingTokenIdRef.current = token.id;
setEditForm({
name: token?.name || '',
token: '',
enabled: token?.enabled !== false,
isDefault: !!token?.isDefault,
});
setEditingTokenValueLoading(true);
void api.getAccountTokenValue(token.id)
.then((res: any) => {
if (editingTokenIdRef.current !== token.id) return;
setEditForm((prev) => ({
...prev,
token: typeof res?.token === 'string' ? res.token : prev.token,
}));
})
.catch((error: any) => {
if (editingTokenIdRef.current !== token.id) return;
toast.error(error?.message || '加载令牌详情失败');
})
.finally(() => {
if (editingTokenIdRef.current !== token.id) return;
setEditingTokenValueLoading(false);
});
}, [toast]);
const closeEditPanel = useCallback(() => {
editingTokenIdRef.current = null;
setEditingToken(null);
setSavingEdit(false);
setEditingTokenValueLoading(false);
setEditForm({
name: '',
token: '',
enabled: true,
isDefault: false,
});
}, []);
const saveEditPanel = async () => {
if (!editingToken) return;
setSavingEdit(true);
try {
await api.updateAccountToken(editingToken.id, {
name: editForm.name.trim() || editingToken.name,
token: editForm.token.trim() || undefined,
enabled: editForm.enabled,
isDefault: editForm.isDefault,
});
toast.success('令牌已更新');
closeEditPanel();
await load();
} catch (e: any) {
toast.error(e.message || '更新令牌失败');
} finally {
setSavingEdit(false);
}
};
const handleTokenRowClick = (tokenId: number, event: React.MouseEvent<HTMLTableRowElement>) => {
if (shouldIgnoreRowSelectionClick(event.target)) return;
const isSelected = selectedTokenIds.includes(tokenId);
toggleTokenSelection(tokenId, !isSelected);
};
const handleCopyToken = async (tokenId: number, tokenName: string) => { const handleCopyToken = async (tokenId: number, tokenName: string) => {
await withRowLoading(`token-${tokenId}-copy`, async () => { await withRowLoading(`token-${tokenId}-copy`, async () => {
const res = await api.getAccountTokenValue(tokenId); const res = await api.getAccountTokenValue(tokenId);
@@ -290,7 +449,7 @@ export function TokensPanel({ embedded = false }: TokensPanelProps) {
} }
}; };
const handleSync = async () => { const handleSync = useCallback(async () => {
if (!syncingAccountId) return; if (!syncingAccountId) return;
setSyncing(true); setSyncing(true);
try { try {
@@ -309,9 +468,9 @@ export function TokensPanel({ embedded = false }: TokensPanelProps) {
} finally { } finally {
setSyncing(false); setSyncing(false);
} }
}; }, [load, syncingAccountId, toast]);
const handleSyncAll = async () => { const handleSyncAll = useCallback(async () => {
setSyncingAll(true); setSyncingAll(true);
try { try {
const res = await api.syncAllAccountTokens(); const res = await api.syncAllAccountTokens();
@@ -365,7 +524,15 @@ export function TokensPanel({ embedded = false }: TokensPanelProps) {
} finally { } finally {
setSyncingAll(false); setSyncingAll(false);
} }
}; }, [load, toast]);
const handleToggleAdd = useCallback(() => {
setShowAdd((prev) => {
const nextOpen = !prev;
if (!nextOpen) setCreateHintModelName('');
return nextOpen;
});
}, []);
const inputStyle: React.CSSProperties = { const inputStyle: React.CSSProperties = {
width: '100%', width: '100%',
@@ -378,175 +545,321 @@ export function TokensPanel({ embedded = false }: TokensPanelProps) {
color: 'var(--color-text-primary)', color: 'var(--color-text-primary)',
}; };
const sectionCardStyle: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: 12,
padding: 14,
border: '1px solid var(--color-border-light)',
borderRadius: 'var(--radius-md)',
background: 'var(--color-bg-card)',
};
const sectionLabelStyle: React.CSSProperties = {
fontSize: 12,
fontWeight: 600,
color: 'var(--color-text-secondary)',
letterSpacing: '0.02em',
};
const toggleCardStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'flex-start',
gap: 10,
padding: '12px 14px',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
};
const headerActions = useMemo(() => (
<div className={`page-actions ${embedded ? 'accounts-page-actions' : ''}`.trim()}>
<div style={{ minWidth: 220, position: 'relative', zIndex: 20 }}>
<ModernSelect
size="sm"
value={String(syncingAccountId || 0)}
onChange={(nextValue) => setSyncingAccountId(Number.parseInt(nextValue, 10) || 0)}
options={[
{ value: '0', label: '选择账号后同步站点令牌' },
...activeAccounts.map((account) => ({
value: String(account.id),
label: `${account.username || `account-${account.id}`} @ ${account.site?.name || '-'}`,
})),
]}
placeholder="选择账号后同步站点令牌"
/>
</div>
<button
onClick={handleSync}
disabled={syncing || syncingAll || !syncingAccountId}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{syncing ? <><span className="spinner spinner-sm" /> 同步中...</> : '同步站点令牌'}
</button>
<button
onClick={handleSyncAll}
disabled={syncing || syncingAll || activeAccounts.length === 0}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{syncingAll ? <><span className="spinner spinner-sm" /> 同步中...</> : '同步全部账号'}
</button>
<button
onClick={handleToggleAdd}
className="btn btn-primary"
>
{showAdd ? '取消' : '+ 新增令牌'}
</button>
</div>
), [activeAccounts, embedded, handleSync, handleSyncAll, handleToggleAdd, showAdd, syncing, syncingAccountId, syncingAll]);
useEffect(() => {
if (!embedded || !onEmbeddedActionsChange) return;
onEmbeddedActionsChange(headerActions);
return () => {
onEmbeddedActionsChange(null);
};
}, [embedded, headerActions, onEmbeddedActionsChange]);
return ( return (
<div className={embedded ? '' : 'animate-fade-in'}> <div className={embedded ? '' : 'animate-fade-in'}>
<div className="page-header"> {(!embedded || !onEmbeddedActionsChange) && (
{!embedded ? <h2 className="page-title">{tr('账号令牌')}</h2> : <div />} <div className="page-header">
<div className="page-actions"> {!embedded ? <h2 className="page-title">{tr('账号令牌')}</h2> : <div />}
<div style={{ minWidth: 220, position: 'relative', zIndex: 20 }}> {headerActions}
<ModernSelect
size="sm"
value={String(syncingAccountId || 0)}
onChange={(nextValue) => setSyncingAccountId(Number.parseInt(nextValue, 10) || 0)}
options={[
{ value: '0', label: '选择账号后同步站点令牌' },
...activeAccounts.map((account) => ({
value: String(account.id),
label: `${account.username || `account-${account.id}`} @ ${account.site?.name || '-'}`,
})),
]}
placeholder="选择账号后同步站点令牌"
/>
</div>
<button
onClick={handleSync}
disabled={syncing || syncingAll || !syncingAccountId}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{syncing ? <><span className="spinner spinner-sm" /> 同步中...</> : '同步站点令牌'}
</button>
<button
onClick={handleSyncAll}
disabled={syncing || syncingAll || activeAccounts.length === 0}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{syncingAll ? <><span className="spinner spinner-sm" /> 同步中...</> : '同步全部账号'}
</button>
<button
onClick={() => {
setShowAdd((prev) => {
const nextOpen = !prev;
if (!nextOpen) setCreateHintModelName('');
return nextOpen;
});
}}
className="btn btn-primary"
>
{showAdd ? '取消' : '+ 新增令牌'}
</button>
</div> </div>
</div> )}
<div className="info-tip" style={{ marginBottom: 12 }}> <div className="info-tip" style={{ marginBottom: 12 }}>
新增令牌会调用站点 API 创建新密钥,再自动同步到本地。支持设置分组、额度、过期时间和 IP 白名单;已存在密钥可直接用“同步站点令牌”读取。 新增令牌会调用站点 API 创建新密钥,再自动同步到本地。支持设置分组、额度、过期时间和 IP 白名单;已存在密钥可直接用“同步站点令牌”读取。
</div> </div>
{addPanelPresence.shouldRender && ( <CenteredModal
<div className={`card panel-presence ${addPanelPresence.isVisible ? '' : 'is-closing'}`.trim()} style={getAccountsAddPanelStyle()}> open={Boolean(editingToken)}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}> onClose={closeEditPanel}
<div style={{ gridColumn: '1 / span 2' }}> title="编辑令牌"
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>所属账号</div> maxWidth={760}
<ModernSelect bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
value={String(form.accountId || 0)} footer={(
onChange={(nextValue) => { <>
setForm((prev) => ({ <button onClick={closeEditPanel} className="btn btn-ghost">取消</button>
...prev, <button onClick={saveEditPanel} disabled={savingEdit || editingTokenValueLoading} className="btn btn-primary">
accountId: Number.parseInt(nextValue, 10) || 0, {savingEdit ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存修改'}
group: '',
}));
}}
options={[
{ value: '0', label: '选择账号' },
...activeAccounts.map((account) => ({
value: String(account.id),
label: `${account.username || `account-${account.id}`} @ ${account.site?.name || '-'}`,
})),
]}
placeholder="选择账号"
/>
</div>
{createHintModelName ? (
<div
style={{
gridColumn: '1 / span 2',
fontSize: 12,
color: 'var(--color-text-secondary)',
background: 'color-mix(in srgb, var(--color-info) 10%, var(--color-bg))',
border: '1px solid color-mix(in srgb, var(--color-info) 30%, transparent)',
borderRadius: 'var(--radius-sm)',
padding: '6px 8px',
}}
>
来自路由提醒:为模型 <code style={{ fontSize: 11 }}>{createHintModelName}</code> 补充该账号令牌后,可自动生成对应通道。
</div>
) : null}
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>令牌名称(可选)</div>
<input
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
placeholder="例如 metapi"
style={inputStyle}
/>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>分组</div>
<ModernSelect
value={form.group || ''}
onChange={(nextValue) => setForm((prev) => ({ ...prev, group: nextValue }))}
options={(groupOptions.length > 0 ? groupOptions : ['default']).map((group) => ({
value: group,
label: group,
}))}
placeholder={groupLoading ? '分组加载中...' : '选择分组'}
disabled={!form.accountId || groupLoading}
/>
</div>
<div style={{ gridColumn: '1 / span 2', display: 'flex', alignItems: 'center', gap: 8 }}>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, color: 'var(--color-text-secondary)' }}>
<input
type="checkbox"
checked={form.unlimitedQuota}
onChange={(e) => setForm((prev) => ({ ...prev, unlimitedQuota: e.target.checked }))}
/>
不限额度
</label>
{!form.unlimitedQuota && (
<input
value={form.remainQuota}
onChange={(e) => setForm((prev) => ({ ...prev, remainQuota: e.target.value.replace(/[^\d]/g, '') }))}
placeholder="额度(正整数)"
style={{ ...inputStyle, maxWidth: 220 }}
/>
)}
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>过期时间(可选)</div>
<input
type="datetime-local"
value={form.expiredTime}
onChange={(e) => setForm((prev) => ({ ...prev, expiredTime: e.target.value }))}
style={inputStyle}
/>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>IP 白名单(可选)</div>
<input
value={form.allowIps}
onChange={(e) => setForm((prev) => ({ ...prev, allowIps: e.target.value }))}
placeholder="多个用英文逗号分隔"
style={inputStyle}
/>
</div>
<div style={{ gridColumn: '1 / span 2', display: 'flex', alignItems: 'center', fontSize: 12, color: 'var(--color-text-muted)' }}>
将在选中账号所属站点直接创建新密钥
</div>
</div>
<div style={{ marginTop: 12 }}>
<button
onClick={handleAddToken}
disabled={saving || !form.accountId}
className="btn btn-primary"
>
{saving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 创建中...</> : '创建并同步令牌'}
</button> </button>
</div> </>
)}
>
{editingToken ? (
<>
<div
style={{
fontSize: 12,
color: 'var(--color-text-secondary)',
background: 'color-mix(in srgb, var(--color-primary) 8%, var(--color-bg))',
border: '1px solid color-mix(in srgb, var(--color-primary) 18%, transparent)',
borderRadius: 'var(--radius-sm)',
padding: '8px 10px',
}}
>
账号: {editingToken.account?.username || `account-${editingToken.accountId}`} @ {editingToken.site?.name || '-'}
</div>
<div style={sectionCardStyle}>
<div style={sectionLabelStyle}>基本信息</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>令牌名称</div>
<input
placeholder="令牌名称"
value={editForm.name}
onChange={(e) => setEditForm((prev) => ({ ...prev, name: e.target.value }))}
style={inputStyle}
/>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>令牌值</div>
<textarea
placeholder={editingTokenValueLoading ? '令牌加载中...' : '令牌值'}
value={editForm.token}
onChange={(e) => setEditForm((prev) => ({ ...prev, token: e.target.value }))}
style={{
...inputStyle,
minHeight: 96,
resize: 'vertical',
fontFamily: 'var(--font-mono)',
lineHeight: 1.5,
}}
disabled={editingTokenValueLoading}
/>
</div>
</div>
<div style={sectionCardStyle}>
<div style={sectionLabelStyle}>状态设置</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 12 }}>
<label style={toggleCardStyle}>
<input
type="checkbox"
checked={editForm.enabled}
onChange={(e) => setEditForm((prev) => ({ ...prev, enabled: e.target.checked }))}
style={{ marginTop: 2 }}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span style={{ fontSize: 13, fontWeight: 600 }}>启用令牌</span>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>关闭后令牌不会参与分发</span>
</div>
</label>
<label style={toggleCardStyle}>
<input
type="checkbox"
checked={editForm.isDefault}
onChange={(e) => setEditForm((prev) => ({ ...prev, isDefault: e.target.checked }))}
style={{ marginTop: 2 }}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span style={{ fontSize: 13, fontWeight: 600 }}>设为默认令牌</span>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>优先作为该账号的默认转发令牌</span>
</div>
</label>
</div>
</div>
</>
) : null}
</CenteredModal>
{selectedTokenIds.length > 0 && (
<div className="card" style={{ padding: 12, marginBottom: 12, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<span style={{ fontSize: 13, fontWeight: 600 }}>已选 {selectedTokenIds.length} 项</span>
<button onClick={() => runBatchTokenAction('enable')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
批量启用
</button>
<button onClick={() => runBatchTokenAction('disable')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
批量禁用
</button>
<button data-testid="tokens-batch-delete" onClick={() => runBatchTokenAction('delete')} disabled={batchActionLoading} className="btn btn-link btn-link-danger">
批量删除
</button>
</div> </div>
)} )}
<CenteredModal
open={showAdd}
onClose={handleToggleAdd}
title="新增令牌"
maxWidth={820}
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div style={{ gridColumn: '1 / span 2' }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>所属账号</div>
<ModernSelect
value={String(form.accountId || 0)}
onChange={(nextValue) => {
setForm((prev) => ({
...prev,
accountId: Number.parseInt(nextValue, 10) || 0,
group: '',
}));
}}
options={[
{ value: '0', label: '选择账号' },
...activeAccounts.map((account) => ({
value: String(account.id),
label: `${account.username || `account-${account.id}`} @ ${account.site?.name || '-'}`,
})),
]}
placeholder="选择账号"
/>
</div>
{createHintModelName ? (
<div
style={{
gridColumn: '1 / span 2',
fontSize: 12,
color: 'var(--color-text-secondary)',
background: 'color-mix(in srgb, var(--color-info) 10%, var(--color-bg))',
border: '1px solid color-mix(in srgb, var(--color-info) 30%, transparent)',
borderRadius: 'var(--radius-sm)',
padding: '6px 8px',
}}
>
来自路由提醒:为模型 <code style={{ fontSize: 11 }}>{createHintModelName}</code> 补充该账号令牌后,可自动生成对应通道。
</div>
) : null}
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>令牌名称(可选)</div>
<input
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
placeholder="例如 metapi"
style={inputStyle}
/>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>分组</div>
<ModernSelect
value={form.group || ''}
onChange={(nextValue) => setForm((prev) => ({ ...prev, group: nextValue }))}
options={(groupOptions.length > 0 ? groupOptions : ['default']).map((group) => ({
value: group,
label: group,
}))}
placeholder={groupLoading ? '分组加载中...' : '选择分组'}
disabled={!form.accountId || groupLoading}
/>
</div>
<div style={{ gridColumn: '1 / span 2', display: 'flex', alignItems: 'center', gap: 8 }}>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, color: 'var(--color-text-secondary)' }}>
<input
type="checkbox"
checked={form.unlimitedQuota}
onChange={(e) => setForm((prev) => ({ ...prev, unlimitedQuota: e.target.checked }))}
/>
不限额度
</label>
{!form.unlimitedQuota && (
<input
value={form.remainQuota}
onChange={(e) => setForm((prev) => ({ ...prev, remainQuota: e.target.value.replace(/[^\d]/g, '') }))}
placeholder="额度(正整数)"
style={{ ...inputStyle, maxWidth: 220 }}
/>
)}
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>过期时间(可选)</div>
<input
type="datetime-local"
value={form.expiredTime}
onChange={(e) => setForm((prev) => ({ ...prev, expiredTime: e.target.value }))}
style={inputStyle}
/>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>IP 白名单(可选)</div>
<input
value={form.allowIps}
onChange={(e) => setForm((prev) => ({ ...prev, allowIps: e.target.value }))}
placeholder="多个用英文逗号分隔"
style={inputStyle}
/>
</div>
<div style={{ gridColumn: '1 / span 2', display: 'flex', alignItems: 'center', fontSize: 12, color: 'var(--color-text-muted)' }}>
将在选中账号所属站点直接创建新密钥
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, marginTop: 8 }}>
<button onClick={handleToggleAdd} className="btn btn-ghost">取消</button>
<button
onClick={handleAddToken}
disabled={saving || !form.accountId}
className="btn btn-primary"
>
{saving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 创建中...</> : '创建并同步令牌'}
</button>
</div>
</CenteredModal>
<div className="card" style={{ overflowX: 'auto' }}> <div className="card" style={{ overflowX: 'auto' }}>
{loading ? ( {loading ? (
<div style={{ padding: 20 }}> <div style={{ padding: 20 }}>
@@ -558,6 +871,13 @@ export function TokensPanel({ embedded = false }: TokensPanelProps) {
<table className="data-table token-table"> <table className="data-table token-table">
<thead> <thead>
<tr> <tr>
<th style={{ width: 44 }}>
<input
type="checkbox"
checked={tokens.length > 0 && selectedTokenIds.length === tokens.length}
onChange={(e) => toggleSelectAllTokens(e.target.checked)}
/>
</th>
<th>令牌名称</th> <th>令牌名称</th>
<th>令牌值</th> <th>令牌值</th>
<th>来源站点</th> <th>来源站点</th>
@@ -572,7 +892,25 @@ export function TokensPanel({ embedded = false }: TokensPanelProps) {
{tokens.map((token: any, i: number) => { {tokens.map((token: any, i: number) => {
const loadingPrefix = `token-${token.id}`; const loadingPrefix = `token-${token.id}`;
return ( return (
<tr key={token.id} className={`animate-slide-up stagger-${Math.min(i + 1, 5)}`}> <tr
key={token.id}
data-testid={`token-row-${token.id}`}
ref={(node) => {
if (node) rowRefs.current.set(token.id, node);
else rowRefs.current.delete(token.id);
}}
onClick={(event) => handleTokenRowClick(token.id, event)}
className={`animate-slide-up stagger-${Math.min(i + 1, 5)} row-selectable ${selectedTokenIds.includes(token.id) ? 'row-selected' : ''} ${highlightTokenId === token.id ? 'row-focus-highlight' : ''}`.trim()}
>
<td>
<input
data-testid={`token-select-${token.id}`}
type="checkbox"
checked={selectedTokenIds.includes(token.id)}
onChange={(e) => toggleTokenSelection(token.id, e.target.checked)}
onClick={(e) => e.stopPropagation()}
/>
</td>
<td style={{ fontWeight: 600 }}>{token.name || '-'}</td> <td style={{ fontWeight: 600 }}>{token.name || '-'}</td>
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 12 }}>{token.tokenMasked || '***'}</td> <td style={{ fontFamily: 'var(--font-mono)', fontSize: 12 }}>{token.tokenMasked || '***'}</td>
<td> <td>
@@ -582,6 +920,7 @@ export function TokensPanel({ embedded = false }: TokensPanelProps) {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="badge-link" className="badge-link"
onClick={(e) => e.stopPropagation()}
> >
<span className="badge badge-muted" style={{ fontSize: 11 }}> <span className="badge badge-muted" style={{ fontSize: 11 }}>
{token.site?.name || 'unknown'} {token.site?.name || 'unknown'}
@@ -601,7 +940,7 @@ export function TokensPanel({ embedded = false }: TokensPanelProps) {
</td> </td>
<td>{token.isDefault ? <span className="badge badge-warning" style={{ fontSize: 11 }}>默认</span> : '-'}</td> <td>{token.isDefault ? <span className="badge badge-warning" style={{ fontSize: 11 }}>默认</span> : '-'}</td>
<td style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{formatDateTimeLocal(token.updatedAt)}</td> <td style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{formatDateTimeLocal(token.updatedAt)}</td>
<td className="token-table-actions-col" style={{ textAlign: 'right' }}> <td className="token-actions-cell" style={{ textAlign: 'right' }}>
<div className="token-table-actions"> <div className="token-table-actions">
{!token.isDefault && ( {!token.isDefault && (
<button <button
@@ -620,9 +959,16 @@ export function TokensPanel({ embedded = false }: TokensPanelProps) {
onClick={() => handleCopyToken(token.id, token.name || '')} onClick={() => handleCopyToken(token.id, token.name || '')}
disabled={!!rowLoading[`${loadingPrefix}-copy`]} disabled={!!rowLoading[`${loadingPrefix}-copy`]}
className="btn btn-link btn-link-primary token-table-action-btn" className="btn btn-link btn-link-primary token-table-action-btn"
data-testid={`token-copy-${token.id}`}
> >
{rowLoading[`${loadingPrefix}-copy`] ? <span className="spinner spinner-sm" /> : '复制'} {rowLoading[`${loadingPrefix}-copy`] ? <span className="spinner spinner-sm" /> : '复制'}
</button> </button>
<button
onClick={() => openEditPanel(token)}
className="btn btn-link btn-link-info token-table-action-btn"
>
编辑
</button>
<button <button
onClick={() => withRowLoading(`${loadingPrefix}-toggle`, async () => { onClick={() => withRowLoading(`${loadingPrefix}-toggle`, async () => {
await api.updateAccountToken(token.id, { enabled: !token.enabled }); await api.updateAccountToken(token.id, { enabled: !token.enabled });
@@ -0,0 +1,175 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import Accounts from './Accounts.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getAccounts: vi.fn(),
getSites: vi.fn(),
batchUpdateAccounts: vi.fn(),
refreshAccountHealth: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('Accounts batch actions', () => {
beforeEach(() => {
vi.clearAllMocks();
apiMock.getSites.mockResolvedValue([
{ id: 1, name: 'Site A', platform: 'new-api', status: 'active' },
]);
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: 1,
username: 'beta',
accessToken: 'session-beta',
status: 'active',
site: { id: 1, name: 'Site A', status: 'active', platform: 'new-api' },
},
]);
apiMock.batchUpdateAccounts.mockResolvedValue({
success: true,
successIds: [1, 2],
failedItems: [],
});
apiMock.refreshAccountHealth.mockResolvedValue({ success: true });
});
afterEach(() => {
vi.clearAllMocks();
});
it('refreshes balance for selected accounts through the batch toolbar', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/accounts']}>
<ToastProvider>
<Accounts />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const checkboxA = root.root.find((node) => node.props['data-testid'] === 'account-select-1');
const checkboxB = root.root.find((node) => node.props['data-testid'] === 'account-select-2');
await act(async () => {
checkboxA.props.onChange({ target: { checked: true } });
checkboxB.props.onChange({ target: { checked: true } });
});
const batchButton = root.root.find((node) => node.props['data-testid'] === 'accounts-batch-refresh-balance');
await act(async () => {
batchButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.batchUpdateAccounts).toHaveBeenCalledWith({
ids: [1, 2],
action: 'refreshBalance',
});
} finally {
root?.unmount();
}
});
it('selects an account when clicking the row instead of only the checkbox', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/accounts']}>
<ToastProvider>
<Accounts />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const row = root.root.find((node) => node.props['data-testid'] === 'account-row-1');
await act(async () => {
row.props.onClick({ target: { closest: () => null } });
});
await flushMicrotasks();
const checkbox = root.root.find((node) => node.props['data-testid'] === 'account-select-1');
expect(checkbox.props.checked).toBe(true);
} finally {
root?.unmount();
}
});
it('selects an apikey connection when clicking the row in the apikey segment', async () => {
apiMock.getAccounts.mockResolvedValue([
{
id: 1,
siteId: 1,
username: 'session-user',
accessToken: 'session-alpha',
apiToken: 'sk-session',
credentialMode: 'session',
status: 'active',
site: { id: 1, name: 'Site A', status: 'active', platform: 'new-api' },
},
{
id: 2,
siteId: 1,
username: '',
accessToken: '',
apiToken: 'sk-apikey',
credentialMode: 'apikey',
status: 'active',
site: { id: 1, name: 'Site A', status: 'active', platform: 'new-api' },
},
]);
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/accounts?segment=apikey']}>
<ToastProvider>
<Accounts />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const row = root.root.find((node) => node.props['data-testid'] === 'account-row-2');
await act(async () => {
row.props.onClick({ target: { closest: () => null } });
});
await flushMicrotasks();
const checkbox = root.root.find((node) => node.props['data-testid'] === 'account-select-2');
expect(checkbox.props.checked).toBe(true);
} finally {
root?.unmount();
}
});
});
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('Accounts centered modal adoption', () => {
it('uses CenteredModal for add/edit/rebind flows instead of inline panel cards', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/Accounts.tsx'), 'utf8');
expect(source).toContain("import CenteredModal from '../components/CenteredModal.js'");
expect(source).toContain('<CenteredModal');
expect(source).not.toContain('className={`card panel-presence rebind-panel');
});
});
@@ -0,0 +1,92 @@
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 Accounts from './Accounts.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getAccounts: vi.fn(),
getSites: vi.fn(),
updateAccount: vi.fn(),
refreshAccountHealth: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
function collectText(node: ReactTestInstance): string {
return (node.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('Accounts edit panel', () => {
beforeEach(() => {
vi.clearAllMocks();
apiMock.getSites.mockResolvedValue([
{ id: 1, name: 'Site A', platform: 'new-api', status: 'active' },
]);
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' },
},
]);
apiMock.updateAccount.mockResolvedValue({ success: true });
apiMock.refreshAccountHealth.mockResolvedValue({ success: true });
});
afterEach(() => {
vi.clearAllMocks();
});
it('opens edit panel from account row action', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/accounts']}>
<ToastProvider>
<Accounts />
</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 usernameInput = root.root.find((node) => (
node.type === 'input'
&& node.props.placeholder === '账号名称'
));
expect(usernameInput.props.value).toBe('alpha');
} finally {
root?.unmount();
}
});
});
@@ -76,7 +76,7 @@ describe('Accounts proxy-only expired state', () => {
await flushMicrotasks(); await flushMicrotasks();
const rendered = JSON.stringify(root.toJSON()); const rendered = JSON.stringify(root.toJSON());
expect(rendered).toContain('仅代理'); expect(rendered).not.toContain('仅代理');
expect(rendered).not.toContain('访问令牌已过期'); expect(rendered).not.toContain('访问令牌已过期');
const actionTexts = root.root.findAll((node) => node.type === 'button').map((node) => collectText(node)); const actionTexts = root.root.findAll((node) => node.type === 'button').map((node) => collectText(node));
@@ -30,7 +30,7 @@ async function flushMicrotasks() {
}); });
} }
describe('Accounts rebind panel focus', () => { describe('Accounts rebind modal', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
@@ -39,7 +39,7 @@ describe('Accounts rebind panel focus', () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it('highlights rebind panel when clicking rebind action', async () => { it('opens centered rebind modal when clicking rebind action', async () => {
apiMock.getAccounts.mockResolvedValue([ apiMock.getAccounts.mockResolvedValue([
{ {
id: 1, id: 1,
@@ -82,12 +82,14 @@ describe('Accounts rebind panel focus', () => {
}); });
await flushMicrotasks(); await flushMicrotasks();
const panel = root.root.find((node) => ( const modal = root.root.find((node) => (
node.type === 'div' node.type === 'div'
&& typeof node.props.className === 'string' && typeof node.props.className === 'string'
&& node.props.className.includes('rebind-panel') && node.props.className.includes('modal-content')
)); ));
expect(String(panel.props.className)).toContain('rebind-panel-highlight'); expect(String(modal.props.className)).toContain('modal-content');
expect(JSON.stringify(root.toJSON())).toContain('重新绑定 Session Token');
expect(JSON.stringify(root.toJSON())).toContain('粘贴新的 Session Token');
} finally { } finally {
root?.unmount(); root?.unmount();
} }
@@ -78,6 +78,7 @@ describe('Accounts segmented connections view', () => {
expect(rendered).toContain('连接管理'); expect(rendered).toContain('连接管理');
expect(rendered).toContain('API Key 连接'); expect(rendered).toContain('API Key 连接');
expect(rendered).toContain('Key Site'); expect(rendered).toContain('Key Site');
expect(rendered).not.toContain('仅代理');
expect(rendered).not.toContain('session-user'); expect(rendered).not.toContain('session-user');
} finally { } finally {
root?.unmount(); root?.unmount();
@@ -0,0 +1,91 @@
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 Accounts from './Accounts.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getAccounts: vi.fn(),
getSites: vi.fn(),
getAccountTokens: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
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('Accounts tokens embedded header', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
it('reuses the accounts page header when showing the 账号令牌 segment', async () => {
apiMock.getAccounts.mockResolvedValue([
{
id: 1,
username: 'session-user',
accessToken: 'session-token',
status: 'active',
credentialMode: 'session',
capabilities: { canCheckin: true, canRefreshBalance: true, proxyOnly: false },
site: { id: 10, name: 'Session Site', platform: 'new-api', status: 'active', url: 'https://session.example.com' },
},
]);
apiMock.getSites.mockResolvedValue([
{ id: 10, name: 'Session Site', platform: 'new-api', status: 'active' },
]);
apiMock.getAccountTokens.mockResolvedValue([]);
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/accounts?segment=tokens']}>
<ToastProvider>
<Accounts />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const pageHeaders = root.root.findAll((node) => {
const className = node.props?.className;
return typeof className === 'string' && className.split(/\s+/).includes('page-header');
});
expect(pageHeaders).toHaveLength(1);
const buttonTexts = root.root
.findAll((node) => node.type === 'button')
.map((node) => collectText(node));
expect(buttonTexts).toContain('同步站点令牌');
expect(buttonTexts).toContain('同步全部账号');
expect(buttonTexts).toContain('+ 新增令牌');
} finally {
root?.unmount();
}
});
});
+18 -1
View File
@@ -1,5 +1,6 @@
const FOCUS_SITE_ID_KEY = 'focusSiteId'; const FOCUS_SITE_ID_KEY = 'focusSiteId';
const FOCUS_ACCOUNT_ID_KEY = 'focusAccountId'; const FOCUS_ACCOUNT_ID_KEY = 'focusAccountId';
const FOCUS_TOKEN_ID_KEY = 'focusTokenId';
const OPEN_REBIND_KEY = 'openRebind'; const OPEN_REBIND_KEY = 'openRebind';
function normalizePositiveId(input: unknown): number | null { function normalizePositiveId(input: unknown): number | null {
@@ -22,16 +23,26 @@ export function buildSiteFocusPath(siteId: number): string {
export function buildAccountFocusPath( export function buildAccountFocusPath(
accountId: number, accountId: number,
options?: { openRebind?: boolean }, options?: { openRebind?: boolean; segment?: 'session' | 'apikey' | 'tokens' },
): string { ): string {
const normalizedId = normalizePositiveId(accountId); const normalizedId = normalizePositiveId(accountId);
if (!normalizedId) return '/accounts'; if (!normalizedId) return '/accounts';
const params = new URLSearchParams(); const params = new URLSearchParams();
if (options?.segment && options.segment !== 'session') params.set('segment', options.segment);
params.set(FOCUS_ACCOUNT_ID_KEY, String(normalizedId)); params.set(FOCUS_ACCOUNT_ID_KEY, String(normalizedId));
if (options?.openRebind) params.set(OPEN_REBIND_KEY, '1'); if (options?.openRebind) params.set(OPEN_REBIND_KEY, '1');
return `/accounts?${params.toString()}`; return `/accounts?${params.toString()}`;
} }
export function buildTokenFocusPath(tokenId: number): string {
const normalizedId = normalizePositiveId(tokenId);
if (!normalizedId) return '/accounts?segment=tokens';
const params = new URLSearchParams();
params.set('segment', 'tokens');
params.set(FOCUS_TOKEN_ID_KEY, String(normalizedId));
return `/accounts?${params.toString()}`;
}
export function readFocusSiteId(search: string): number | null { export function readFocusSiteId(search: string): number | null {
const params = new URLSearchParams(search); const params = new URLSearchParams(search);
return normalizePositiveId(params.get(FOCUS_SITE_ID_KEY)); return normalizePositiveId(params.get(FOCUS_SITE_ID_KEY));
@@ -45,10 +56,16 @@ export function readFocusAccountIntent(search: string): { accountId: number | nu
}; };
} }
export function readFocusTokenId(search: string): number | null {
const params = new URLSearchParams(search);
return normalizePositiveId(params.get(FOCUS_TOKEN_ID_KEY));
}
export function clearFocusParams(search: string): string { export function clearFocusParams(search: string): string {
const params = new URLSearchParams(search); const params = new URLSearchParams(search);
params.delete(FOCUS_SITE_ID_KEY); params.delete(FOCUS_SITE_ID_KEY);
params.delete(FOCUS_ACCOUNT_ID_KEY); params.delete(FOCUS_ACCOUNT_ID_KEY);
params.delete(FOCUS_TOKEN_ID_KEY);
params.delete(OPEN_REBIND_KEY); params.delete(OPEN_REBIND_KEY);
const next = params.toString(); const next = params.toString();
return next ? `?${next}` : ''; return next ? `?${next}` : '';
+4
View File
@@ -0,0 +1,4 @@
export function shouldIgnoreRowSelectionClick(target: EventTarget | null): boolean {
const element = target as { closest?: (selector: string) => unknown } | null;
return !!element?.closest?.('button, a, input, textarea, select, label, [role="button"]');
}
+4 -4
View File
@@ -3,7 +3,7 @@ export type SiteForm = {
url: string; url: string;
externalCheckinUrl: string; externalCheckinUrl: string;
platform: string; platform: string;
proxyUrl: string; useSystemProxy: boolean;
globalWeight: string; globalWeight: string;
}; };
@@ -16,12 +16,12 @@ type SiteSaveAction =
| { kind: 'update'; id: number; payload: SiteForm }; | { kind: 'update'; id: number; payload: SiteForm };
export function emptySiteForm(): SiteForm { export function emptySiteForm(): SiteForm {
return { name: '', url: '', externalCheckinUrl: '', platform: '', proxyUrl: '', globalWeight: '1' }; return { name: '', url: '', externalCheckinUrl: '', platform: '', useSystemProxy: false, globalWeight: '1' };
} }
export function siteFormFromSite(site: Partial<SiteForm> & { export function siteFormFromSite(site: Partial<SiteForm> & {
proxyUrl?: string | null;
externalCheckinUrl?: string | null; externalCheckinUrl?: string | null;
useSystemProxy?: boolean | null;
globalWeight?: number | string | null; globalWeight?: number | string | null;
}): SiteForm { }): SiteForm {
const globalWeightRaw = Number(site.globalWeight); const globalWeightRaw = Number(site.globalWeight);
@@ -31,7 +31,7 @@ export function siteFormFromSite(site: Partial<SiteForm> & {
url: site.url ?? '', url: site.url ?? '',
externalCheckinUrl: site.externalCheckinUrl ?? '', externalCheckinUrl: site.externalCheckinUrl ?? '',
platform: site.platform ?? '', platform: site.platform ?? '',
proxyUrl: site.proxyUrl ?? '', useSystemProxy: !!site.useSystemProxy,
globalWeight, globalWeight,
}; };
} }
@@ -0,0 +1,110 @@
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 Settings from './Settings.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getAuthInfo: vi.fn(),
getRuntimeSettings: vi.fn(),
getDownstreamApiKeys: vi.fn(),
getRoutes: vi.fn(),
getRuntimeDatabaseConfig: vi.fn(),
updateRuntimeSettings: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
vi.mock('../components/BrandIcon.js', () => ({
InlineBrandIcon: () => null,
getBrand: () => null,
useIconCdn: () => '',
}));
function collectText(node: ReactTestInstance): string {
return (node.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('Settings system proxy', () => {
beforeEach(() => {
vi.clearAllMocks();
apiMock.getAuthInfo.mockResolvedValue({ masked: 'sk-****' });
apiMock.getRuntimeSettings.mockResolvedValue({
checkinCron: '0 8 * * *',
balanceRefreshCron: '0 * * * *',
routingFallbackUnitCost: 1,
routingWeights: {},
adminIpAllowlist: [],
systemProxyUrl: '',
});
apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] });
apiMock.getRoutes.mockResolvedValue([]);
apiMock.getRuntimeDatabaseConfig.mockResolvedValue({
active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false },
saved: null,
restartRequired: false,
});
apiMock.updateRuntimeSettings.mockResolvedValue({
success: true,
systemProxyUrl: 'http://127.0.0.1:7890',
});
});
afterEach(() => {
vi.clearAllMocks();
});
it('saves system proxy url from settings', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ToastProvider>
<Settings />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const proxyInput = root.root.find((node) => (
node.type === 'input'
&& node.props.placeholder === '系统代理 URL(可选,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080)'
));
await act(async () => {
proxyInput.props.onChange({ target: { value: 'http://127.0.0.1:7890' } });
});
const saveButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).trim() === '保存系统代理'
));
await act(async () => {
saveButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.updateRuntimeSettings).toHaveBeenCalledWith({
systemProxyUrl: 'http://127.0.0.1:7890',
});
} finally {
root?.unmount();
}
});
});
@@ -0,0 +1,120 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import Sites from './Sites.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getSites: vi.fn(),
batchUpdateSites: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('Sites system proxy bulk actions', () => {
beforeEach(() => {
vi.clearAllMocks();
apiMock.getSites.mockResolvedValue([
{
id: 1,
name: 'Site A',
url: 'https://a.example.com',
platform: 'new-api',
status: 'active',
useSystemProxy: false,
},
{
id: 2,
name: 'Site B',
url: 'https://b.example.com',
platform: 'new-api',
status: 'active',
useSystemProxy: false,
},
]);
apiMock.batchUpdateSites.mockResolvedValue({
success: true,
successIds: [1, 2],
failedItems: [],
});
});
afterEach(() => {
vi.clearAllMocks();
});
it('sends selected site ids to enable system proxy', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/sites']}>
<ToastProvider>
<Sites />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const checkboxA = root.root.find((node) => node.props['data-testid'] === 'site-select-1');
const checkboxB = root.root.find((node) => node.props['data-testid'] === 'site-select-2');
await act(async () => {
checkboxA.props.onChange({ target: { checked: true } });
checkboxB.props.onChange({ target: { checked: true } });
});
const batchButton = root.root.find((node) => node.props['data-testid'] === 'sites-batch-enable-system-proxy');
await act(async () => {
batchButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.batchUpdateSites).toHaveBeenCalledWith({
ids: [1, 2],
action: 'enableSystemProxy',
});
} finally {
root?.unmount();
}
});
it('selects a site when clicking the row instead of only the checkbox', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/sites']}>
<ToastProvider>
<Sites />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const row = root.root.find((node) => node.props['data-testid'] === 'site-row-1');
await act(async () => {
row.props.onClick({ target: { closest: () => null } });
});
await flushMicrotasks();
const checkbox = root.root.find((node) => node.props['data-testid'] === 'site-select-1');
expect(checkbox.props.checked).toBe(true);
} finally {
root?.unmount();
}
});
});
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('Tokens actions layout', () => {
it('reuses a wrapping actions cell layout so token row actions do not overflow', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/Tokens.tsx'), 'utf8');
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8');
expect(source).toContain('className="token-actions-cell"');
expect(css).toContain('.token-actions-cell');
expect(css).toContain('.token-table-actions {');
expect(css).toContain('flex-wrap: wrap;');
expect(css).toContain('.token-table {');
expect(css).toContain('table-layout: fixed;');
});
});
+141
View File
@@ -0,0 +1,141 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import { TokensPanel } from './Tokens.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getAccountTokens: vi.fn(),
getAccounts: vi.fn(),
getAccountTokenGroups: vi.fn(),
batchUpdateAccountTokens: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('Tokens batch actions', () => {
beforeEach(() => {
vi.clearAllMocks();
apiMock.getAccountTokens.mockResolvedValue([
{
id: 1,
accountId: 1,
name: 'token-1',
tokenMasked: 'sk-***1',
enabled: true,
isDefault: false,
account: { username: 'alpha' },
site: { name: 'Site A', url: 'https://site-a.example.com' },
},
{
id: 2,
accountId: 1,
name: 'token-2',
tokenMasked: 'sk-***2',
enabled: true,
isDefault: false,
account: { username: 'alpha' },
site: { name: 'Site A', url: 'https://site-a.example.com' },
},
]);
apiMock.getAccounts.mockResolvedValue([
{
id: 1,
username: 'alpha',
accessToken: 'session-alpha',
status: 'active',
site: { id: 1, name: 'Site A', status: 'active' },
},
]);
apiMock.getAccountTokenGroups.mockResolvedValue({ groups: ['default'] });
apiMock.batchUpdateAccountTokens.mockResolvedValue({
success: true,
successIds: [1, 2],
failedItems: [],
});
});
afterEach(() => {
vi.clearAllMocks();
});
it('deletes selected tokens through the batch toolbar', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<ToastProvider>
<MemoryRouter initialEntries={['/accounts?segment=tokens']}>
<TokensPanel />
</MemoryRouter>
</ToastProvider>,
);
});
await flushMicrotasks();
const checkboxA = root.root.find((node) => node.props['data-testid'] === 'token-select-1');
const checkboxB = root.root.find((node) => node.props['data-testid'] === 'token-select-2');
await act(async () => {
checkboxA.props.onChange({ target: { checked: true } });
checkboxB.props.onChange({ target: { checked: true } });
});
const batchButton = root.root.find((node) => node.props['data-testid'] === 'tokens-batch-delete');
const originalConfirm = globalThis.confirm;
globalThis.confirm = vi.fn(() => true);
try {
await act(async () => {
batchButton.props.onClick();
});
await flushMicrotasks();
} finally {
globalThis.confirm = originalConfirm;
}
expect(apiMock.batchUpdateAccountTokens).toHaveBeenCalledWith({
ids: [1, 2],
action: 'delete',
});
} finally {
root?.unmount();
}
});
it('selects a token when clicking the row instead of only the checkbox', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<ToastProvider>
<MemoryRouter initialEntries={['/accounts?segment=tokens']}>
<TokensPanel />
</MemoryRouter>
</ToastProvider>,
);
});
await flushMicrotasks();
const row = root.root.find((node) => node.props['data-testid'] === 'token-row-1');
await act(async () => {
row.props.onClick({ target: { closest: () => null } });
});
await flushMicrotasks();
const checkbox = root.root.find((node) => node.props['data-testid'] === 'token-select-1');
expect(checkbox.props.checked).toBe(true);
} finally {
root?.unmount();
}
});
});
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('Tokens centered modal adoption', () => {
it('uses CenteredModal for add/edit token flows instead of inline form cards', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/Tokens.tsx'), 'utf8');
expect(source).toContain("import CenteredModal from '../components/CenteredModal.js'");
expect(source).toContain('<CenteredModal');
expect(source).not.toContain('addPanelPresence.shouldRender && (');
});
});
@@ -0,0 +1,184 @@
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 Accounts from './Accounts.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getAccounts: vi.fn(),
getSites: vi.fn(),
getAccountTokens: vi.fn(),
getAccountTokenValue: vi.fn(),
updateAccountToken: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
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();
});
}
function buildRoot() {
return create(
<MemoryRouter initialEntries={['/accounts?segment=tokens']}>
<ToastProvider>
<Accounts />
</ToastProvider>
</MemoryRouter>,
{
createNodeMock: (element) => {
if (element.type === 'tr' || element.type === 'div') {
return {
scrollIntoView: () => undefined,
};
}
return {};
},
},
);
}
describe('Tokens edit modal and row selection', () => {
beforeEach(() => {
vi.clearAllMocks();
Object.defineProperty(globalThis, 'navigator', {
value: {
clipboard: {
writeText: vi.fn().mockResolvedValue(undefined),
},
},
configurable: true,
writable: true,
});
apiMock.getAccounts.mockResolvedValue([
{
id: 1,
username: 'session-user',
accessToken: 'session-token',
status: 'active',
credentialMode: 'session',
capabilities: { canCheckin: true, canRefreshBalance: true, proxyOnly: false },
site: { id: 10, name: 'Session Site', platform: 'new-api', status: 'active', url: 'https://session.example.com' },
},
]);
apiMock.getSites.mockResolvedValue([
{ id: 10, name: 'Session Site', platform: 'new-api', status: 'active' },
]);
apiMock.getAccountTokens.mockResolvedValue([
{
id: 22,
name: 'focus-token',
tokenMasked: 'sk-focus****',
enabled: true,
isDefault: false,
updatedAt: '2026-03-07 10:00:00',
accountId: 1,
account: { username: 'session-user' },
site: { name: 'Session Site', url: 'https://session.example.com' },
},
]);
apiMock.getAccountTokenValue.mockResolvedValue({
success: true,
token: 'sk-focus-real',
});
apiMock.updateAccountToken.mockResolvedValue({
success: true,
});
});
afterEach(() => {
vi.clearAllMocks();
});
it('opens the centered edit modal when editing a token', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = buildRoot();
});
await flushMicrotasks();
const editButton = root.root
.findAll((node) => node.type === 'button')
.find((node) => collectText(node).includes('编辑'));
expect(editButton).toBeTruthy();
await act(async () => {
editButton!.props.onClick({ stopPropagation: () => undefined });
});
await flushMicrotasks();
const rendered = JSON.stringify(root.toJSON());
expect(rendered).toContain('编辑令牌');
expect(rendered).toContain('保存修改');
expect(rendered).toContain('sk-focus-real');
expect(rendered).toContain('基本信息');
expect(rendered).toContain('状态设置');
const modals = root.root.findAll((node) => {
const className = typeof node.props?.className === 'string' ? node.props.className : '';
return className.includes('modal-content') && collectText(node).includes('编辑令牌');
});
expect(modals).toHaveLength(1);
} finally {
root?.unmount();
}
});
it('selects a token when clicking the row body, but not when clicking an action button', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = buildRoot();
});
await flushMicrotasks();
const tokenRow = root.root.findAll((node) => {
if (node.type !== 'tr') return false;
return collectText(node).includes('focus-token');
})[0];
expect(tokenRow).toBeTruthy();
await act(async () => {
tokenRow.props.onClick({
target: { closest: () => null },
});
});
await flushMicrotasks();
expect(JSON.stringify(root.toJSON())).toContain('已选 ');
expect(JSON.stringify(root.toJSON())).toContain('"1"');
const copyButton = root.root
.findAll((node) => node.type === 'button')
.find((node) => collectText(node).includes('复制'));
expect(copyButton).toBeTruthy();
await act(async () => {
copyButton!.props.onClick({ stopPropagation: () => undefined });
});
await flushMicrotasks();
expect(JSON.stringify(root.toJSON())).toContain('已选 ');
expect(JSON.stringify(root.toJSON())).toContain('"1"');
} finally {
root?.unmount();
}
});
});
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('Tokens embedded layout', () => {
it('reuses accounts-page-actions layout when embedded in 连接管理', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/Tokens.tsx'), 'utf8');
expect(source).toContain("className={`page-actions ${embedded ? 'accounts-page-actions' : ''}`.trim()}");
});
});
+107
View File
@@ -0,0 +1,107 @@
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 Accounts from './Accounts.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getAccounts: vi.fn(),
getSites: vi.fn(),
getAccountTokens: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
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('Tokens focus navigation', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
it('highlights the focused token row in the 账号令牌 segment', async () => {
apiMock.getAccounts.mockResolvedValue([
{
id: 1,
username: 'session-user',
accessToken: 'session-token',
status: 'active',
credentialMode: 'session',
capabilities: { canCheckin: true, canRefreshBalance: true, proxyOnly: false },
site: { id: 10, name: 'Session Site', platform: 'new-api', status: 'active', url: 'https://session.example.com' },
},
]);
apiMock.getSites.mockResolvedValue([
{ id: 10, name: 'Session Site', platform: 'new-api', status: 'active' },
]);
apiMock.getAccountTokens.mockResolvedValue([
{
id: 22,
name: 'focus-token',
tokenMasked: 'sk-focus****',
enabled: true,
isDefault: false,
updatedAt: '2026-03-07 10:00:00',
accountId: 1,
account: { username: 'session-user' },
site: { name: 'Session Site', url: 'https://session.example.com' },
},
]);
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/accounts?segment=tokens&focusTokenId=22']}>
<ToastProvider>
<Accounts />
</ToastProvider>
</MemoryRouter>,
{
createNodeMock: (element) => {
if (element.type === 'tr') {
return {
scrollIntoView: () => undefined,
};
}
return {};
},
},
);
});
await flushMicrotasks();
const highlightedRow = root.root.findAll((node) => {
if (node.type !== 'tr') return false;
const className = typeof node.props?.className === 'string' ? node.props.className : '';
return className.includes('row-focus-highlight') && collectText(node).includes('focus-token');
});
expect(highlightedRow).toHaveLength(1);
} finally {
root?.unmount();
}
});
});