forked from boos/BoosAPI
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:
@@ -45,6 +45,7 @@ export function buildConfig(env: NodeJS.ProcessEnv) {
|
||||
return {
|
||||
authToken: env.AUTH_TOKEN || 'change-me-admin-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',
|
||||
checkinCron: env.CHECKIN_CRON || '0 8 * * *',
|
||||
balanceRefreshCron: env.BALANCE_REFRESH_CRON || '0 * * * *',
|
||||
|
||||
@@ -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() {
|
||||
const sqlite = requireSqliteConnection();
|
||||
if (!tableExists('sites')) {
|
||||
@@ -604,6 +621,7 @@ function initSqliteDb() {
|
||||
ensureTokenManagementSchema();
|
||||
ensureSiteStatusSchema();
|
||||
ensureSiteProxySchema();
|
||||
ensureSiteUseSystemProxySchema();
|
||||
ensureSiteExternalCheckinUrlSchema();
|
||||
ensureSiteGlobalWeightSchema();
|
||||
ensureRouteGroupingSchema();
|
||||
|
||||
@@ -8,6 +8,7 @@ export const sites = sqliteTable('sites', {
|
||||
externalCheckinUrl: text('external_checkin_url'),
|
||||
platform: text('platform').notNull(), // 'new-api' | 'one-api' | 'veloera' | 'one-hub' | 'done-hub' | 'sub2api' | 'openai' | 'claude' | 'gemini'
|
||||
proxyUrl: text('proxy_url'),
|
||||
useSystemProxy: integer('use_system_proxy', { mode: 'boolean' }).default(false),
|
||||
status: text('status').notNull().default('active'), // 'active' | 'disabled'
|
||||
isPinned: integer('is_pinned', { mode: 'boolean' }).default(false),
|
||||
sortOrder: integer('sort_order').default(0),
|
||||
|
||||
@@ -111,6 +111,9 @@ function applyRuntimeSettings(settingsMap: Map<string, string>) {
|
||||
const proxyToken = parseSettingFromMap<string>(settingsMap, 'proxy_token');
|
||||
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');
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -136,6 +136,13 @@ function parseExpiredTime(value: unknown): number | 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> {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
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) => {
|
||||
const tokenId = Number.parseInt(request.params.id, 10);
|
||||
if (Number.isNaN(tokenId)) {
|
||||
@@ -652,46 +764,13 @@ export async function accountTokensRoutes(app: FastifyInstance) {
|
||||
if (Number.isNaN(tokenId)) {
|
||||
return reply.code(400).send({ success: false, message: '令牌 ID 无效' });
|
||||
}
|
||||
|
||||
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 reply.code(404).send({ success: false, message: '令牌不存在' });
|
||||
const result = await deleteAccountTokenById(tokenId);
|
||||
if (!result.success) {
|
||||
const statusCode = result.message === '令牌不存在'
|
||||
? 404
|
||||
: (result.message === 'API Key 连接不支持管理账号令牌' ? 400 : 502);
|
||||
return reply.code(statusCode).send({ success: false, message: result.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 };
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
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 () => {
|
||||
verifyTokenMock.mockResolvedValueOnce({
|
||||
tokenType: 'session',
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
type RuntimeHealthState,
|
||||
} from '../../services/accountHealthService.js';
|
||||
import { appendSessionTokenRebindHint } from '../../services/alertRules.js';
|
||||
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js';
|
||||
import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
|
||||
|
||||
type AccountWithSiteRow = {
|
||||
accounts: typeof schema.accounts.$inferSelect;
|
||||
@@ -82,6 +82,13 @@ function buildCapabilitiesForAccount(account: typeof schema.accounts.$inferSelec
|
||||
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 {
|
||||
if (input === undefined || input === null) return null;
|
||||
if (typeof input === 'boolean') return input;
|
||||
@@ -345,6 +352,19 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
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
|
||||
const todayCheckins = await db.select({
|
||||
accountId: schema.checkinLogs.accountId,
|
||||
@@ -394,6 +414,7 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
credentialMode,
|
||||
hasSessionTokenValue(r.accounts.accessToken),
|
||||
).canRefreshBalance,
|
||||
hasDiscoveredModels: (modelCountByAccount[r.accounts.id] || 0) > 0,
|
||||
}),
|
||||
};
|
||||
});
|
||||
@@ -584,7 +605,7 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
try {
|
||||
const testRes = await fetch(
|
||||
`${site.url}/api/user/self`,
|
||||
withExplicitProxyRequestInit(site.proxyUrl, {
|
||||
withSiteRecordProxyRequestInit(site, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(ACCOUNT_VERIFY_DIAG_TIMEOUT_MS),
|
||||
}),
|
||||
@@ -763,7 +784,7 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
try {
|
||||
const testRes = await fetch(
|
||||
`${site.url}/api/user/self`,
|
||||
withExplicitProxyRequestInit(site.proxyUrl, {
|
||||
withSiteRecordProxyRequestInit(site, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(ACCOUNT_VERIFY_DIAG_TIMEOUT_MS),
|
||||
}),
|
||||
@@ -1193,6 +1214,68 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
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) => {
|
||||
const rawAccountId = request.body?.accountId as unknown;
|
||||
const hasAccountId = rawAccountId !== undefined && rawAccountId !== null && String(rawAccountId).trim() !== '';
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,36 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
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 { 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) {
|
||||
const proxyLogBaseFields = getProxyLogBaseSelectFields();
|
||||
@@ -9,11 +38,11 @@ export async function searchRoutes(app: FastifyInstance) {
|
||||
app.post<{ Body: { query: string; limit?: number } }>('/api/search', async (request) => {
|
||||
const { query, limit = 20 } = request.body;
|
||||
if (!query || query.trim().length === 0) {
|
||||
return { accounts: [], sites: [], checkinLogs: [], proxyLogs: [], models: [] };
|
||||
return { accounts: [], accountTokens: [], sites: [], checkinLogs: [], proxyLogs: [], models: [] };
|
||||
}
|
||||
|
||||
const q = `%${query.trim()}%`;
|
||||
const perCategory = Math.min(Math.ceil(limit / 5), 10);
|
||||
const perCategory = Math.min(Math.ceil(limit / 6), 10);
|
||||
|
||||
// Search 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)
|
||||
const accountResults = await db.select().from(schema.accounts)
|
||||
.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();
|
||||
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)
|
||||
const checkinLogs = (await db.select().from(schema.checkinLogs)
|
||||
@@ -96,6 +166,6 @@ export async function searchRoutes(app: FastifyInstance) {
|
||||
})
|
||||
.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.proxyToken = 'sk-old-proxy-token-123';
|
||||
config.systemProxyUrl = '';
|
||||
config.checkinCron = '0 8 * * *';
|
||||
config.balanceRefreshCron = '0 * * * *';
|
||||
config.routingFallbackUnitCost = 1;
|
||||
@@ -191,6 +192,64 @@ describe('settings and auth events', () => {
|
||||
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 () => {
|
||||
const response = await app.inject({
|
||||
method: 'PUT',
|
||||
|
||||
@@ -16,11 +16,13 @@ import {
|
||||
} from '../../services/databaseMigrationService.js';
|
||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||
import { extractClientIp, isIpAllowed } from '../../middleware/auth.js';
|
||||
import { invalidateSiteProxyCache, normalizeSiteProxyUrl } from '../../services/siteProxy.js';
|
||||
|
||||
type RoutingWeights = typeof config.routingWeights;
|
||||
|
||||
interface RuntimeSettingsBody {
|
||||
proxyToken?: string;
|
||||
systemProxyUrl?: string;
|
||||
checkinCron?: string;
|
||||
balanceRefreshCron?: string;
|
||||
webhookUrl?: string;
|
||||
@@ -156,6 +158,11 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
|
||||
config.proxyToken = nextToken;
|
||||
return;
|
||||
}
|
||||
case 'system_proxy_url': {
|
||||
if (typeof value !== 'string') return;
|
||||
config.systemProxyUrl = normalizeSiteProxyUrl(value) || '';
|
||||
return;
|
||||
}
|
||||
case 'webhook_url': {
|
||||
if (typeof value !== 'string') return;
|
||||
config.webhookUrl = value.trim();
|
||||
@@ -295,6 +302,7 @@ function getRuntimeSettingsResponse(currentAdminIp = '') {
|
||||
notifyCooldownSec: config.notifyCooldownSec,
|
||||
adminIpAllowlist: config.adminIpAllowlist,
|
||||
currentAdminIp,
|
||||
systemProxyUrl: config.systemProxyUrl,
|
||||
proxyTokenMasked: maskSecret(config.proxyToken),
|
||||
};
|
||||
}
|
||||
@@ -471,6 +479,22 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
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 (String(body.webhookUrl || '').trim() !== config.webhookUrl) {
|
||||
changedLabels.push('Webhook 地址');
|
||||
@@ -887,4 +911,3 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { mkdtempSync } from 'node:fs';
|
||||
|
||||
type DbModule = typeof import('../../db/index.js');
|
||||
|
||||
describe('sites proxy url settings', () => {
|
||||
describe('sites system proxy settings', () => {
|
||||
let app: FastifyInstance;
|
||||
let db: DbModule['db'];
|
||||
let schema: DbModule['schema'];
|
||||
@@ -36,7 +36,7 @@ describe('sites proxy url settings', () => {
|
||||
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({
|
||||
method: 'POST',
|
||||
url: '/api/sites',
|
||||
@@ -44,7 +44,7 @@ describe('sites proxy url settings', () => {
|
||||
name: 'proxy-site',
|
||||
url: 'https://proxy-site.example.com',
|
||||
platform: 'new-api',
|
||||
proxyUrl: 'http://127.0.0.1:7890',
|
||||
useSystemProxy: true,
|
||||
externalCheckinUrl: 'https://checkin.example.com/welfare',
|
||||
globalWeight: 1.5,
|
||||
},
|
||||
@@ -52,16 +52,16 @@ describe('sites proxy url settings', () => {
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const payload = response.json() as {
|
||||
proxyUrl?: string | null;
|
||||
useSystemProxy?: boolean;
|
||||
externalCheckinUrl?: string | null;
|
||||
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.globalWeight).toBe(1.5);
|
||||
});
|
||||
|
||||
it('rejects invalid proxy url', async () => {
|
||||
it('rejects invalid useSystemProxy flag', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sites',
|
||||
@@ -69,12 +69,12 @@ describe('sites proxy url settings', () => {
|
||||
name: 'proxy-site',
|
||||
url: 'https://proxy-site.example.com',
|
||||
platform: 'new-api',
|
||||
proxyUrl: 'not-a-url',
|
||||
useSystemProxy: 'not-a-boolean',
|
||||
},
|
||||
});
|
||||
|
||||
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 () => {
|
||||
@@ -108,4 +108,30 @@ describe('sites proxy url settings', () => {
|
||||
expect(response.statusCode).toBe(400);
|
||||
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
@@ -2,7 +2,7 @@ import { FastifyInstance } from 'fastify';
|
||||
import { db, schema } from '../../db/index.js';
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
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';
|
||||
|
||||
function normalizeSiteStatus(input: unknown): 'active' | 'disabled' | null {
|
||||
@@ -25,6 +25,10 @@ function normalizePinnedFlag(input: unknown): boolean | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeUseSystemProxyFlag(input: unknown): boolean | null {
|
||||
return normalizePinnedFlag(input);
|
||||
}
|
||||
|
||||
function normalizeSortOrder(input: unknown): number | null {
|
||||
if (input === undefined || input === null || input === '') return null;
|
||||
const parsed = Number.parseInt(String(input), 10);
|
||||
@@ -70,6 +74,59 @@ function normalizeOptionalExternalCheckinUrl(input: unknown): {
|
||||
}
|
||||
|
||||
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
|
||||
app.get('/api/sites', async () => {
|
||||
const siteRows = await db.select().from(schema.sites).all();
|
||||
@@ -96,21 +153,21 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
name: string;
|
||||
url: string;
|
||||
platform?: string;
|
||||
proxyUrl?: string | null;
|
||||
useSystemProxy?: boolean;
|
||||
externalCheckinUrl?: string | null;
|
||||
status?: string;
|
||||
isPinned?: boolean;
|
||||
sortOrder?: number;
|
||||
globalWeight?: number;
|
||||
} }>('/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);
|
||||
if (status !== undefined && !normalizedStatus) {
|
||||
return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' });
|
||||
}
|
||||
const parsedProxyUrl = parseSiteProxyUrlInput(proxyUrl);
|
||||
if (!parsedProxyUrl.valid) {
|
||||
return reply.code(400).send({ error: 'Invalid proxyUrl. Expected a valid http(s)/socks proxy URL.' });
|
||||
const normalizedUseSystemProxy = normalizeUseSystemProxyFlag(useSystemProxy);
|
||||
if (useSystemProxy !== undefined && normalizedUseSystemProxy === null) {
|
||||
return reply.code(400).send({ error: 'Invalid useSystemProxy value. Expected boolean.' });
|
||||
}
|
||||
const normalizedExternalCheckinUrl = normalizeOptionalExternalCheckinUrl(externalCheckinUrl);
|
||||
if (!normalizedExternalCheckinUrl.valid) {
|
||||
@@ -144,7 +201,7 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
name,
|
||||
url: url.replace(/\/+$/, ''),
|
||||
platform: detectedPlatform,
|
||||
proxyUrl: parsedProxyUrl.proxyUrl,
|
||||
useSystemProxy: normalizedUseSystemProxy ?? false,
|
||||
externalCheckinUrl: normalizedExternalCheckinUrl.url,
|
||||
status: normalizedStatus ?? 'active',
|
||||
isPinned: normalizedPinned ?? false,
|
||||
@@ -168,7 +225,7 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
name?: string;
|
||||
url?: string;
|
||||
platform?: string;
|
||||
proxyUrl?: string | null;
|
||||
useSystemProxy?: boolean;
|
||||
externalCheckinUrl?: string | null;
|
||||
status?: string;
|
||||
isPinned?: boolean;
|
||||
@@ -191,9 +248,9 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
if (body.status !== undefined && !normalizedStatus) {
|
||||
return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' });
|
||||
}
|
||||
const parsedProxyUrl = parseSiteProxyUrlInput(body.proxyUrl);
|
||||
if (!parsedProxyUrl.valid) {
|
||||
return reply.code(400).send({ error: 'Invalid proxyUrl. Expected a valid http(s)/socks proxy URL.' });
|
||||
const normalizedUseSystemProxy = normalizeUseSystemProxyFlag(body.useSystemProxy);
|
||||
if (body.useSystemProxy !== undefined && normalizedUseSystemProxy === null) {
|
||||
return reply.code(400).send({ error: 'Invalid useSystemProxy value. Expected boolean.' });
|
||||
}
|
||||
const normalizedExternalCheckinUrl = normalizeOptionalExternalCheckinUrl(body.externalCheckinUrl);
|
||||
if (!normalizedExternalCheckinUrl.valid) {
|
||||
@@ -215,7 +272,7 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
if (body.name !== undefined) updates.name = body.name;
|
||||
if (body.url !== undefined) updates.url = body.url.replace(/\/+$/, '');
|
||||
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 (body.status !== undefined) updates.status = normalizedStatus;
|
||||
if (body.isPinned !== undefined) updates.isPinned = normalizedPinned;
|
||||
@@ -226,44 +283,7 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
invalidateSiteProxyCache();
|
||||
|
||||
if (body.status !== undefined && normalizedStatus) {
|
||||
const now = new Date().toISOString();
|
||||
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 {}
|
||||
}
|
||||
await applySiteStatusSideEffects(id, existingSite.name, normalizedStatus);
|
||||
}
|
||||
|
||||
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 };
|
||||
});
|
||||
|
||||
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
|
||||
app.post<{ Body: { url: string } }>('/api/sites/detect', async (request) => {
|
||||
const result = await detectSite(request.body.url);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { isTokenExpiredError } from '../../services/alertRules.js';
|
||||
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
||||
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
|
||||
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
|
||||
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js';
|
||||
import { resolveProxyUrlForSite, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
|
||||
import {
|
||||
type DownstreamFormat,
|
||||
createStreamTransformContext,
|
||||
@@ -183,7 +183,7 @@ async function handleChatProxyRequest(
|
||||
try {
|
||||
const endpointResult = await executeEndpointFlow({
|
||||
siteUrl: selected.site.url,
|
||||
proxyUrl: selected.site.proxyUrl,
|
||||
proxyUrl: resolveProxyUrlForSite(selected.site),
|
||||
endpointCandidates,
|
||||
buildRequest: (endpoint) => {
|
||||
const endpointRequest = buildUpstreamEndpointRequest({
|
||||
@@ -225,7 +225,7 @@ async function handleChatProxyRequest(
|
||||
downstreamHeaders: request.headers as Record<string, unknown>,
|
||||
});
|
||||
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',
|
||||
headers: normalizedClaudeRequest.headers,
|
||||
body: JSON.stringify(normalizedClaudeRequest.body),
|
||||
@@ -264,7 +264,7 @@ async function handleChatProxyRequest(
|
||||
return null;
|
||||
}
|
||||
|
||||
const minimalResponse = await fetch(ctx.targetUrl, withExplicitProxyRequestInit(selected.site.proxyUrl, {
|
||||
const minimalResponse = await fetch(ctx.targetUrl, withSiteRecordProxyRequestInit(selected.site, {
|
||||
method: 'POST',
|
||||
headers: minimalHeaders,
|
||||
body: JSON.stringify(ctx.request.body),
|
||||
|
||||
@@ -9,7 +9,7 @@ import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
||||
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
|
||||
import { mergeProxyUsage, parseProxyUsage, pullSseDataEvents } from '../../services/proxyUsageParser.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 { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||
import { resolveProxyLogBilling } from './proxyBilling.js';
|
||||
@@ -57,7 +57,7 @@ export async function completionsProxyRoute(app: FastifyInstance) {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const upstream = await fetch(targetUrl, withExplicitProxyRequestInit(selected.site.proxyUrl, {
|
||||
const upstream = await fetch(targetUrl, withSiteRecordProxyRequestInit(selected.site, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -9,7 +9,7 @@ import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
||||
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
|
||||
import { parseProxyUsage } from '../../services/proxyUsageParser.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 { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||
import { resolveProxyLogBilling } from './proxyBilling.js';
|
||||
@@ -54,7 +54,7 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const upstream = await fetch(targetUrl, withExplicitProxyRequestInit(selected.site.proxyUrl, {
|
||||
const upstream = await fetch(targetUrl, withSiteRecordProxyRequestInit(selected.site, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -8,7 +8,7 @@ import { isTokenExpiredError } from '../../services/alertRules.js';
|
||||
import { estimateProxyCost } from '../../services/modelPricingService.js';
|
||||
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.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 { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||
|
||||
@@ -50,7 +50,7 @@ export async function imagesProxyRoute(app: FastifyInstance) {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const upstream = await fetch(targetUrl, withExplicitProxyRequestInit(selected.site.proxyUrl, {
|
||||
const upstream = await fetch(targetUrl, withSiteRecordProxyRequestInit(selected.site, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import { isTokenExpiredError } from '../../services/alertRules.js';
|
||||
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
||||
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
|
||||
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
|
||||
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js';
|
||||
import { resolveProxyUrlForSite, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
|
||||
import {
|
||||
createStreamTransformContext,
|
||||
normalizeUpstreamFinalResponse,
|
||||
@@ -1137,15 +1137,19 @@ function serializeConvertedResponsesEvents(input: {
|
||||
}
|
||||
|
||||
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 requestedModel = typeof body?.model === 'string' ? body.model.trim() : '';
|
||||
const downstreamPath = '/v1/responses';
|
||||
if (!requestedModel) {
|
||||
return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } });
|
||||
}
|
||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||
const isCompactRequest = downstreamPath === '/v1/responses/compact';
|
||||
|
||||
const isStream = body.stream === true;
|
||||
const excludeChannelIds: number[] = [];
|
||||
@@ -1193,7 +1197,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
try {
|
||||
const endpointResult = await executeEndpointFlow({
|
||||
siteUrl: selected.site.url,
|
||||
proxyUrl: selected.site.proxyUrl,
|
||||
proxyUrl: resolveProxyUrlForSite(selected.site),
|
||||
endpointCandidates,
|
||||
buildRequest: (endpoint) => {
|
||||
const endpointRequest = buildUpstreamEndpointRequest({
|
||||
@@ -1208,9 +1212,14 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
responsesOriginalBody: body,
|
||||
downstreamHeaders: request.headers as Record<string, unknown>,
|
||||
});
|
||||
const upstreamPath = (
|
||||
isCompactRequest && endpoint === 'responses'
|
||||
? `${endpointRequest.path}/compact`
|
||||
: endpointRequest.path
|
||||
);
|
||||
return {
|
||||
endpoint,
|
||||
path: endpointRequest.path,
|
||||
path: upstreamPath,
|
||||
headers: endpointRequest.headers,
|
||||
body: endpointRequest.body as Record<string, unknown>,
|
||||
};
|
||||
@@ -1231,7 +1240,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
for (const compatibilityBody of compatibilityBodies) {
|
||||
const compatibilityResponse = await fetch(
|
||||
ctx.targetUrl,
|
||||
withExplicitProxyRequestInit(selected.site.proxyUrl, {
|
||||
withSiteRecordProxyRequestInit(selected.site, {
|
||||
method: 'POST',
|
||||
headers: compatibilityHeadersCandidate,
|
||||
body: JSON.stringify(compatibilityBody),
|
||||
@@ -1266,7 +1275,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
});
|
||||
const minimalResponse = await fetch(
|
||||
ctx.targetUrl,
|
||||
withExplicitProxyRequestInit(selected.site.proxyUrl, {
|
||||
withSiteRecordProxyRequestInit(selected.site, {
|
||||
method: 'POST',
|
||||
headers: minimalHeaders,
|
||||
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(
|
||||
|
||||
@@ -35,6 +35,28 @@ describe('accountHealthService', () => {
|
||||
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', () => {
|
||||
const health = buildRuntimeHealthForAccount({
|
||||
accountStatus: 'active',
|
||||
|
||||
@@ -82,11 +82,21 @@ export function extractRuntimeHealth(extraConfig?: string | null): 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: {
|
||||
accountStatus?: string | null;
|
||||
siteStatus?: string | null;
|
||||
extraConfig?: string | null;
|
||||
sessionCapable?: boolean;
|
||||
hasDiscoveredModels?: boolean;
|
||||
}): RuntimeHealthInfo {
|
||||
const accountStatus = (input.accountStatus || 'active').toLowerCase();
|
||||
const siteStatus = (input.siteStatus || 'active').toLowerCase();
|
||||
@@ -110,7 +120,23 @@ export function buildRuntimeHealthForAccount(input: {
|
||||
}
|
||||
|
||||
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 {
|
||||
state: 'unknown',
|
||||
|
||||
@@ -162,6 +162,7 @@ function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupS
|
||||
externalCheckinUrl: null,
|
||||
platform,
|
||||
proxyUrl: null,
|
||||
useSystemProxy: false,
|
||||
status: 'active',
|
||||
isPinned: false,
|
||||
sortOrder: sites.length,
|
||||
|
||||
@@ -279,6 +279,47 @@ describe('balanceService auto relogin', () => {
|
||||
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 () => {
|
||||
selectAllMock.mockReturnValue([
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@ import { appendSessionTokenRebindHint, isTokenExpiredError } from './alertRules.
|
||||
import { reportTokenExpired } from './alertService.js';
|
||||
import {
|
||||
getAutoReloginConfig,
|
||||
getCredentialModeFromExtraConfig,
|
||||
getSub2ApiAuthFromExtraConfig,
|
||||
mergeAccountExtraConfig,
|
||||
resolvePlatformUserId,
|
||||
@@ -13,12 +14,18 @@ import { decryptAccountPassword } from './accountCredentialService.js';
|
||||
import { extractRuntimeHealth, setAccountRuntimeHealth } from './accountHealthService.js';
|
||||
import { updateTodayIncomeSnapshot } from './todayIncomeRewardService.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 {
|
||||
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 {
|
||||
if (!message) return false;
|
||||
if (isTokenExpiredError({ message })) return true;
|
||||
@@ -196,7 +203,7 @@ async function refreshSub2ApiManagedSession(params: {
|
||||
const { fetch } = await import('undici');
|
||||
let payload: unknown = null;
|
||||
try {
|
||||
const response = await fetch(endpoint, withExplicitProxyRequestInit(params.site.proxyUrl, {
|
||||
const response = await fetch(endpoint, withSiteRecordProxyRequestInit(params.site, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
@@ -363,6 +370,16 @@ export async function refreshBalance(accountId: number) {
|
||||
const adapter = getAdapter(site.platform);
|
||||
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);
|
||||
let activeAccessToken = account.accessToken;
|
||||
let activeExtraConfig = account.extraConfig;
|
||||
@@ -457,7 +474,7 @@ export async function refreshBalance(accountId: number) {
|
||||
accessToken: activeAccessToken,
|
||||
platform: site.platform,
|
||||
platformUserId,
|
||||
proxyUrl: site.proxyUrl,
|
||||
proxyUrl: resolveProxyUrlForSite(site),
|
||||
});
|
||||
if (typeof fallbackIncome === 'number' && Number.isFinite(fallbackIncome)) {
|
||||
balanceInfo.todayIncome = fallbackIncome;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getAdapter } from './platforms/index.js';
|
||||
import { ensureDefaultTokenForAccount, getPreferredAccountToken } from './accountTokenService.js';
|
||||
import { getCredentialModeFromExtraConfig, resolvePlatformUserId } from './accountExtraConfig.js';
|
||||
import { invalidateTokenRouterCache } from './tokenRouter.js';
|
||||
import { setAccountRuntimeHealth } from './accountHealthService.js';
|
||||
|
||||
const API_TOKEN_DISCOVERY_TIMEOUT_MS = 8_000;
|
||||
const MODEL_DISCOVERY_TIMEOUT_MS = 12_000;
|
||||
@@ -202,18 +203,27 @@ export async function refreshModelsForAccount(accountId: number) {
|
||||
mergeDiscoveredModels(models, latencyMs);
|
||||
}
|
||||
|
||||
if (accountModels.size > 0) {
|
||||
const checkedAt = new Date().toISOString();
|
||||
await db.insert(schema.modelAvailability).values(
|
||||
Array.from(accountModels).map((modelName) => ({
|
||||
if (accountModels.size > 0) {
|
||||
const checkedAt = new Date().toISOString();
|
||||
await db.insert(schema.modelAvailability).values(
|
||||
Array.from(accountModels).map((modelName) => ({
|
||||
accountId: account.id,
|
||||
modelName,
|
||||
available: true,
|
||||
latencyMs: modelLatency.get(modelName) ?? null,
|
||||
checkedAt,
|
||||
})),
|
||||
).run();
|
||||
}
|
||||
})),
|
||||
).run();
|
||||
|
||||
if (isApiKeyConnection(account)) {
|
||||
await setAccountRuntimeHealth(account.id, {
|
||||
state: 'healthy',
|
||||
reason: '模型探测成功',
|
||||
source: 'model-discovery',
|
||||
checkedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
accountId,
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
buildNewApiCookieCandidates,
|
||||
fetchJsonWithShieldCookieRetry,
|
||||
} 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_PAGE_SIZE = 20;
|
||||
@@ -28,6 +28,7 @@ interface ProxyUsageFallbackInput {
|
||||
url: string;
|
||||
platform: string;
|
||||
apiKey?: string | null;
|
||||
useSystemProxy?: boolean | null;
|
||||
proxyUrl?: string | null;
|
||||
};
|
||||
account: {
|
||||
@@ -358,7 +359,7 @@ async function fetchSelfLogPayload(baseUrl: string, token: string, input: ProxyU
|
||||
page_size: String(SELF_LOG_PAGE_SIZE),
|
||||
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',
|
||||
headers: {
|
||||
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',
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
type DbModule = typeof import('../db/index.js');
|
||||
|
||||
@@ -22,6 +23,7 @@ describe('siteProxy', () => {
|
||||
beforeEach(async () => {
|
||||
const { invalidateSiteProxyCache } = await import('./siteProxy.js');
|
||||
await db.delete(schema.accounts).run();
|
||||
await db.delete(schema.settings).run();
|
||||
await db.delete(schema.sites).run();
|
||||
invalidateSiteProxyCache();
|
||||
});
|
||||
@@ -30,36 +32,35 @@ describe('siteProxy', () => {
|
||||
delete process.env.DATA_DIR;
|
||||
});
|
||||
|
||||
it('resolves longest matched site proxy url', async () => {
|
||||
await db.insert(schema.sites).values([
|
||||
{
|
||||
name: 'base-site',
|
||||
url: 'https://relay.example.com',
|
||||
platform: 'new-api',
|
||||
proxyUrl: 'http://127.0.0.1:7891',
|
||||
},
|
||||
{
|
||||
name: 'openai-site',
|
||||
url: 'https://relay.example.com/openai',
|
||||
platform: 'new-api',
|
||||
proxyUrl: 'http://127.0.0.1:7890',
|
||||
},
|
||||
]).run();
|
||||
it('resolves system proxy only for sites that opt in', async () => {
|
||||
await db.insert(schema.settings).values({
|
||||
key: 'system_proxy_url',
|
||||
value: JSON.stringify('http://127.0.0.1:7890'),
|
||||
}).run();
|
||||
|
||||
await db.run(sql`
|
||||
INSERT INTO sites (name, url, platform, use_system_proxy)
|
||||
VALUES
|
||||
('base-site', 'https://relay.example.com', 'new-api', 0),
|
||||
('openai-site', 'https://relay.example.com/openai', 'new-api', 1)
|
||||
`);
|
||||
|
||||
const { resolveSiteProxyUrlByRequestUrl } = await import('./siteProxy.js');
|
||||
expect(await resolveSiteProxyUrlByRequestUrl('https://relay.example.com/openai/v1/models'))
|
||||
.toBe('http://127.0.0.1:7890');
|
||||
expect(await resolveSiteProxyUrlByRequestUrl('https://relay.example.com/v1/models'))
|
||||
.toBe('http://127.0.0.1:7891');
|
||||
.toBeNull();
|
||||
});
|
||||
|
||||
it('injects dispatcher when proxy exists', async () => {
|
||||
await db.insert(schema.sites).values({
|
||||
name: 'proxy-site',
|
||||
url: 'https://proxy-site.example.com',
|
||||
platform: 'new-api',
|
||||
proxyUrl: 'http://127.0.0.1:7890',
|
||||
it('injects dispatcher when a site opts into the configured system proxy', async () => {
|
||||
await db.insert(schema.settings).values({
|
||||
key: 'system_proxy_url',
|
||||
value: JSON.stringify('http://127.0.0.1:7890'),
|
||||
}).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 requestInit = await withSiteProxyRequestInit('https://proxy-site.example.com/v1/chat/completions', {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
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 { ProxyAgent } from 'undici';
|
||||
|
||||
@@ -15,7 +17,7 @@ const SUPPORTED_PROXY_PROTOCOLS = new Set([
|
||||
|
||||
type SiteProxyRow = {
|
||||
siteUrl: string;
|
||||
proxyUrl: string | null;
|
||||
useSystemProxy: boolean;
|
||||
};
|
||||
|
||||
type ParsedSiteProxyInput = {
|
||||
@@ -24,12 +26,18 @@ type ParsedSiteProxyInput = {
|
||||
proxyUrl: string | null;
|
||||
};
|
||||
|
||||
type SiteProxyConfigLike = {
|
||||
useSystemProxy?: boolean | null;
|
||||
};
|
||||
|
||||
let siteProxyCache: {
|
||||
loadedAt: number;
|
||||
rows: SiteProxyRow[];
|
||||
systemProxyUrl: string | null;
|
||||
} = {
|
||||
loadedAt: 0,
|
||||
rows: [],
|
||||
systemProxyUrl: null,
|
||||
};
|
||||
|
||||
const dispatcherCache = new Map<string, Dispatcher>();
|
||||
@@ -54,23 +62,41 @@ async function getCachedSiteProxyRows(nowMs = Date.now()): Promise<SiteProxyRow[
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await db
|
||||
.select({
|
||||
siteUrl: schema.sites.url,
|
||||
proxyUrl: schema.sites.proxyUrl,
|
||||
})
|
||||
.from(schema.sites)
|
||||
.all();
|
||||
const [rows, systemProxySetting] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
siteUrl: schema.sites.url,
|
||||
useSystemProxy: schema.sites.useSystemProxy,
|
||||
})
|
||||
.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 = {
|
||||
loadedAt: nowMs,
|
||||
rows: rows.map((row) => ({
|
||||
siteUrl: normalizeSiteUrl(row.siteUrl),
|
||||
proxyUrl: normalizeSiteProxyUrl(row.proxyUrl),
|
||||
useSystemProxy: !!row.useSystemProxy,
|
||||
})),
|
||||
systemProxyUrl: parsedSystemProxyUrl,
|
||||
};
|
||||
} catch {
|
||||
siteProxyCache = { loadedAt: nowMs, rows: [] };
|
||||
siteProxyCache = { loadedAt: nowMs, rows: [], systemProxyUrl: null };
|
||||
}
|
||||
|
||||
return siteProxyCache.rows;
|
||||
@@ -138,7 +164,7 @@ export function parseSiteProxyUrlInput(input: unknown): ParsedSiteProxyInput {
|
||||
}
|
||||
|
||||
export function invalidateSiteProxyCache(): void {
|
||||
siteProxyCache = { loadedAt: 0, rows: [] };
|
||||
siteProxyCache = { loadedAt: 0, rows: [], systemProxyUrl: 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;
|
||||
|
||||
const rows = await getCachedSiteProxyRows();
|
||||
const systemProxyUrl = siteProxyCache.systemProxyUrl;
|
||||
if (!systemProxyUrl) return null;
|
||||
let bestMatch: string | null = null;
|
||||
let bestMatchLength = -1;
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row.proxyUrl) continue;
|
||||
if (!row.useSystemProxy) continue;
|
||||
if (!row.siteUrl) continue;
|
||||
|
||||
const isPrefixMatch = (
|
||||
@@ -161,7 +189,7 @@ export async function resolveSiteProxyUrlByRequestUrl(requestUrl: string): Promi
|
||||
if (!isPrefixMatch) continue;
|
||||
|
||||
if (row.siteUrl.length > bestMatchLength) {
|
||||
bestMatch = row.proxyUrl;
|
||||
bestMatch = systemProxyUrl;
|
||||
bestMatchLength = row.siteUrl.length;
|
||||
}
|
||||
}
|
||||
@@ -200,3 +228,15 @@ export function withExplicitProxyRequestInit(
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -321,8 +321,7 @@ const sidebarGroups = [
|
||||
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: '/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: '/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: '/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: '/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: '/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> },
|
||||
|
||||
@@ -103,6 +103,7 @@ export const api = {
|
||||
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) }),
|
||||
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 }) }),
|
||||
|
||||
// Accounts
|
||||
@@ -114,6 +115,7 @@ export const api = {
|
||||
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) }),
|
||||
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' }),
|
||||
refreshAccountHealth: (data?: { accountId?: number; wait?: boolean }) => request('/api/accounts/health/refresh', {
|
||||
method: 'POST',
|
||||
@@ -126,6 +128,7 @@ export const api = {
|
||||
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) }),
|
||||
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}`),
|
||||
setDefaultAccountToken: (id: number) => request(`/api/account-tokens/${id}/default`, { method: 'POST' }),
|
||||
getAccountTokenValue: (id: number) => request(`/api/account-tokens/${id}/value`),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api.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 { useAnimatedVisibility } from './useAnimatedVisibility.js';
|
||||
|
||||
@@ -17,6 +17,19 @@ interface AccountResult {
|
||||
username: string | null;
|
||||
status?: string | 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;
|
||||
}
|
||||
|
||||
@@ -45,6 +58,7 @@ interface ModelSearchResult {
|
||||
|
||||
interface SearchResult {
|
||||
accounts: AccountResult[];
|
||||
accountTokens: AccountTokenResult[];
|
||||
sites: SiteResult[];
|
||||
checkinLogs: CheckinLogResult[];
|
||||
proxyLogs: ProxyLogResult[];
|
||||
@@ -81,6 +95,7 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
setResults({
|
||||
models: Array.isArray(res?.models) ? res.models : [],
|
||||
accounts: Array.isArray(res?.accounts) ? res.accounts : [],
|
||||
accountTokens: Array.isArray(res?.accountTokens) ? res.accountTokens : [],
|
||||
sites: Array.isArray(res?.sites) ? res.sites : [],
|
||||
checkinLogs: Array.isArray(res?.checkinLogs) ? res.checkinLogs : [],
|
||||
proxyLogs: Array.isArray(res?.proxyLogs) ? res.proxyLogs : [],
|
||||
@@ -116,6 +131,7 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
const hasResults = results && (
|
||||
results.models.length
|
||||
|| results.accounts.length
|
||||
|| results.accountTokens.length
|
||||
|| results.sites.length
|
||||
|| results.checkinLogs.length
|
||||
|| results.proxyLogs.length
|
||||
@@ -189,15 +205,49 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
<button
|
||||
key={a.id}
|
||||
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">
|
||||
<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>
|
||||
<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)' }}>
|
||||
{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>
|
||||
</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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -273,7 +273,7 @@ export const zhToEnSupplemental: Record<string, string> = {
|
||||
'令牌不存在或不属于通道账号': 'The token does not exist or does not belong to the channel account',
|
||||
'令牌不可用': 'Token not available',
|
||||
'令牌不能为空': 'Token cannot be empty',
|
||||
'令牌管理': 'Token management',
|
||||
'令牌管理': 'Account tokens',
|
||||
'令牌失效': 'Token expired',
|
||||
'令牌数': 'Number of tokens',
|
||||
'令牌同步成功': 'Token synchronization successful',
|
||||
@@ -696,7 +696,7 @@ export const zhToEnSupplemental: Record<string, string> = {
|
||||
'账号 ID 无效': 'Invalid account ID',
|
||||
'账号不存在': 'Account does not exist',
|
||||
'账号访问令牌可能过期或无效,需更新认证信息。': '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 routing',
|
||||
'账号缺少访问令牌,无法创建站点令牌': 'The account is missing an access token and cannot create a site token.',
|
||||
|
||||
+2
-2
@@ -38,7 +38,7 @@ const zhToEn: Record<string, string> = {
|
||||
'账号': 'Accounts',
|
||||
'未关联站点': 'Unlinked Site',
|
||||
'余额': 'Balance',
|
||||
'令牌管理': 'Token Management',
|
||||
'令牌管理': 'Account Tokens',
|
||||
'签到记录': 'Check-in Logs',
|
||||
'全部签到': 'Check In All',
|
||||
'签到中...': 'Checking In...',
|
||||
@@ -82,7 +82,7 @@ const zhToEn: Record<string, string> = {
|
||||
'收起侧边栏': 'Collapse Sidebar',
|
||||
'系统设置': 'System Settings',
|
||||
'站点管理': 'Site Management',
|
||||
'账号管理': 'Account Management',
|
||||
'账号管理': 'Connection Management',
|
||||
'导入 / 导出': 'Import / Export',
|
||||
'监控内嵌': 'Embedded Monitor',
|
||||
'品牌': 'Brands',
|
||||
|
||||
+81
-5
@@ -1171,6 +1171,36 @@ body {
|
||||
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 {
|
||||
0% {
|
||||
background: color-mix(in srgb, var(--color-primary) 18%, transparent);
|
||||
@@ -1292,15 +1322,38 @@ body {
|
||||
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 {
|
||||
min-width: 210px;
|
||||
width: 24%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.token-actions-cell {
|
||||
min-width: 0;
|
||||
white-space: normal !important;
|
||||
}
|
||||
|
||||
.token-table-actions {
|
||||
display: inline-flex;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 2px;
|
||||
gap: 4px 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.token-table-actions .btn-link {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.token-table-action-btn {
|
||||
@@ -3137,7 +3190,8 @@ textarea {
|
||||
}
|
||||
|
||||
.accounts-page-actions .btn-soft-primary,
|
||||
.accounts-page-actions .btn-primary {
|
||||
.accounts-page-actions .btn-primary,
|
||||
.accounts-page-actions .btn-ghost {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -3146,10 +3200,18 @@ textarea {
|
||||
width: 28%;
|
||||
}
|
||||
|
||||
.token-table-actions-col {
|
||||
width: 28%;
|
||||
}
|
||||
|
||||
.accounts-actions-cell {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.token-actions-cell {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.accounts-table th,
|
||||
.accounts-table td {
|
||||
padding-left: 10px;
|
||||
@@ -3161,6 +3223,11 @@ textarea {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.token-table-actions .btn-link {
|
||||
padding: 3px 6px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.sites-actions-col {
|
||||
min-width: 165px;
|
||||
}
|
||||
@@ -3205,7 +3272,8 @@ textarea {
|
||||
}
|
||||
|
||||
.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);
|
||||
min-width: 0;
|
||||
justify-content: center;
|
||||
@@ -3215,10 +3283,18 @@ textarea {
|
||||
width: 30%;
|
||||
}
|
||||
|
||||
.token-table-actions-col {
|
||||
width: 30%;
|
||||
}
|
||||
|
||||
.accounts-actions-cell {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.token-actions-cell {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.accounts-table th:nth-child(5),
|
||||
.accounts-table td:nth-child(5) {
|
||||
display: none;
|
||||
|
||||
+334
-95
@@ -1,10 +1,9 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import CenteredModal from '../components/CenteredModal.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
|
||||
import { getAccountsAddPanelStyle } from './helpers/accountsPanelStyle.js';
|
||||
import {
|
||||
buildAddAccountPrereqHint,
|
||||
buildVerifyFailureHint,
|
||||
@@ -14,10 +13,31 @@ import { clearFocusParams, readFocusAccountIntent } from './helpers/navigationFo
|
||||
import { TokensPanel } from './Tokens.js';
|
||||
import { tr } from '../i18n.js';
|
||||
import { buildCustomReorderUpdates, sortItemsForDisplay, type SortMode } from './helpers/listSorting.js';
|
||||
import { shouldIgnoreRowSelectionClick } from './helpers/rowSelection.js';
|
||||
import { SITE_DOCS_URL } from '../docsLink.js';
|
||||
|
||||
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 {
|
||||
if (!value) return false;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
@@ -46,33 +66,35 @@ export default function Accounts() {
|
||||
const [highlightAccountId, setHighlightAccountId] = useState<number | null>(null);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [addMode, setAddMode] = useState<'token' | 'login'>('token');
|
||||
const [loginForm, setLoginForm] = useState({ siteId: 0, username: '', password: '' });
|
||||
const [tokenForm, setTokenForm] = useState({
|
||||
siteId: 0,
|
||||
username: '',
|
||||
accessToken: '',
|
||||
platformUserId: '',
|
||||
refreshToken: '',
|
||||
tokenExpiresAt: '',
|
||||
credentialMode: 'session' as 'auto' | 'session' | 'apikey',
|
||||
});
|
||||
const [loginForm, setLoginForm] = useState(createLoginForm);
|
||||
const [tokenForm, setTokenForm] = useState(() => createTokenForm('session'));
|
||||
const [verifyResult, setVerifyResult] = useState<any>(null);
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
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 [rebindForm, setRebindForm] = useState({ accessToken: '', platformUserId: '', refreshToken: '', tokenExpiresAt: '' });
|
||||
const [rebindForm, setRebindForm] = useState(() => createRebindForm());
|
||||
const [rebindVerifyResult, setRebindVerifyResult] = useState<any>(null);
|
||||
const [rebindVerifying, setRebindVerifying] = 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 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 toast = useToast();
|
||||
if (rebindTarget) lastRebindTargetRef.current = rebindTarget;
|
||||
@@ -85,7 +107,9 @@ export default function Accounts() {
|
||||
api.getSites(),
|
||||
]);
|
||||
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 {
|
||||
toast.error('加载账号列表失败');
|
||||
}
|
||||
@@ -101,6 +125,21 @@ export default function Accounts() {
|
||||
[sites, tokenForm.siteId],
|
||||
);
|
||||
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 rawMode = String(account?.credentialMode || '').trim().toLowerCase();
|
||||
@@ -147,9 +186,14 @@ export default function Accounts() {
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSegment !== 'tokens') return;
|
||||
setShowAdd(false);
|
||||
setVerifyResult(null);
|
||||
closeAddPanel();
|
||||
if (rebindTarget) closeRebindPanel();
|
||||
setEditingAccount(null);
|
||||
}, [activeSegment]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSegment === 'tokens') return;
|
||||
setEmbeddedTokenActions(null);
|
||||
}, [activeSegment]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -162,14 +206,10 @@ export default function Accounts() {
|
||||
setShowAdd(true);
|
||||
setAddMode('token');
|
||||
setVerifyResult(null);
|
||||
setLoginForm(createLoginForm());
|
||||
setTokenForm({
|
||||
...createTokenForm('apikey'),
|
||||
siteId: requestedSiteId,
|
||||
username: '',
|
||||
accessToken: '',
|
||||
platformUserId: '',
|
||||
refreshToken: '',
|
||||
tokenExpiresAt: '',
|
||||
credentialMode: 'apikey',
|
||||
});
|
||||
|
||||
params.delete('create');
|
||||
@@ -190,9 +230,6 @@ export default function Accounts() {
|
||||
if (highlightTimerRef.current) {
|
||||
clearTimeout(highlightTimerRef.current);
|
||||
}
|
||||
if (rebindPanelTimerRef.current) {
|
||||
clearTimeout(rebindPanelTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -202,8 +239,7 @@ export default function Accounts() {
|
||||
try {
|
||||
const result = await api.loginAccount(loginForm);
|
||||
if (result.success) {
|
||||
setShowAdd(false);
|
||||
setLoginForm({ siteId: 0, username: '', password: '' });
|
||||
closeAddPanel();
|
||||
const msg = result.apiTokenFound
|
||||
? `账号 "${loginForm.username}" 已添加,API Key 已自动获取`
|
||||
: `账号 "${loginForm.username}" 已添加(未找到 API Key,请手动设置)`;
|
||||
@@ -271,17 +307,7 @@ export default function Accounts() {
|
||||
: undefined,
|
||||
credentialMode,
|
||||
});
|
||||
setShowAdd(false);
|
||||
setTokenForm({
|
||||
siteId: 0,
|
||||
username: '',
|
||||
accessToken: '',
|
||||
platformUserId: '',
|
||||
refreshToken: '',
|
||||
tokenExpiresAt: '',
|
||||
credentialMode: 'session',
|
||||
});
|
||||
setVerifyResult(null);
|
||||
closeAddPanel();
|
||||
if (result.tokenType === 'apikey') {
|
||||
toast.success('已添加为 API Key 账号(可用于代理转发)');
|
||||
} 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 => {
|
||||
try {
|
||||
const parsed = JSON.parse(account?.extraConfig || '{}');
|
||||
@@ -434,40 +569,21 @@ export default function Accounts() {
|
||||
};
|
||||
|
||||
const openRebindPanel = (account: any) => {
|
||||
closeAddPanel();
|
||||
setEditingAccount(null);
|
||||
setRebindTarget(account);
|
||||
setRebindForm({
|
||||
accessToken: '',
|
||||
platformUserId: extractPlatformUserId(account),
|
||||
refreshToken: '',
|
||||
tokenExpiresAt: '',
|
||||
});
|
||||
setRebindForm(createRebindForm(extractPlatformUserId(account)));
|
||||
setRebindVerifyResult(null);
|
||||
setRebindFocusTrigger((value) => value + 1);
|
||||
};
|
||||
|
||||
const closeRebindPanel = () => {
|
||||
setRebindTarget(null);
|
||||
setRebindForm({ accessToken: '', platformUserId: '', refreshToken: '', tokenExpiresAt: '' });
|
||||
setRebindForm(createRebindForm());
|
||||
setRebindVerifyResult(null);
|
||||
setRebindVerifying(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 () => {
|
||||
if (!rebindTarget || !rebindForm.accessToken.trim()) return;
|
||||
setRebindVerifying(true);
|
||||
@@ -598,13 +714,15 @@ export default function Accounts() {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAdd(!showAdd);
|
||||
setAddMode('token');
|
||||
setVerifyResult(null);
|
||||
setTokenForm((current) => ({
|
||||
...current,
|
||||
credentialMode: activeSegment === 'apikey' ? 'apikey' : 'session',
|
||||
}));
|
||||
const nextOpen = !showAdd;
|
||||
if (!nextOpen) {
|
||||
closeAddPanel();
|
||||
return;
|
||||
}
|
||||
setEditingAccount(null);
|
||||
closeRebindPanel();
|
||||
setShowAdd(true);
|
||||
resetAddForms(activeAddCredentialMode);
|
||||
}}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
@@ -612,6 +730,7 @@ export default function Accounts() {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{activeSegment === 'tokens' && embeddedTokenActions}
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -652,12 +771,36 @@ export default function Accounts() {
|
||||
))}
|
||||
</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' ? (
|
||||
<TokensPanel embedded />
|
||||
<TokensPanel embedded onEmbeddedActionsChange={setEmbeddedTokenActions} />
|
||||
) : (
|
||||
<>
|
||||
{addPanelPresence.shouldRender && (
|
||||
<div className={`card panel-presence ${addPanelPresence.isVisible ? '' : 'is-closing'}`.trim()} style={getAccountsAddPanelStyle()}>
|
||||
<CenteredModal
|
||||
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' ? (
|
||||
<>
|
||||
<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>
|
||||
)}
|
||||
</CenteredModal>
|
||||
|
||||
{activeSegment === 'session' && rebindPanelPresence.shouldRender && activeRebindTarget && (
|
||||
<div
|
||||
ref={rebindPanelRef}
|
||||
className={`card panel-presence rebind-panel ${rebindPanelPresence.isVisible ? '' : 'is-closing'} ${highlightRebindPanel ? 'rebind-panel-highlight' : ''}`.trim()}
|
||||
style={{ marginBottom: 16, padding: 16 }}
|
||||
{activeSegment === 'session' && (
|
||||
<CenteredModal
|
||||
open={Boolean(rebindTarget)}
|
||||
onClose={closeRebindPanel}
|
||||
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 }}>
|
||||
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--color-text-primary)' }}>
|
||||
重新绑定 Session Token
|
||||
</div>
|
||||
<button className="btn btn-ghost" onClick={closeRebindPanel}>关闭</button>
|
||||
</div>
|
||||
{activeRebindTarget ? (
|
||||
<>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
|
||||
连接: {resolveAccountDisplayName(activeRebindTarget)} @ {activeRebindTarget.site?.name || '-'}。请粘贴新的 Session Token,验证成功后再绑定。
|
||||
</div>
|
||||
@@ -1064,14 +1205,102 @@ export default function Accounts() {
|
||||
: '确认重新绑定'}
|
||||
</button>
|
||||
</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">
|
||||
{visibleAccounts.length > 0 ? (
|
||||
<table className="data-table accounts-table">
|
||||
<thead>
|
||||
<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>
|
||||
@@ -1088,21 +1317,28 @@ export default function Accounts() {
|
||||
return (
|
||||
<tr
|
||||
key={a.id}
|
||||
data-testid={`account-row-${a.id}`}
|
||||
ref={(node) => {
|
||||
if (node) rowRefs.current.set(a.id, node);
|
||||
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)' }}>
|
||||
<div style={{ fontWeight: 600 }}>{resolveAccountDisplayName(a)}</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginTop: 4 }}>
|
||||
<span className={`badge ${connectionMode === 'apikey' ? 'badge-warning' : 'badge-info'}`} style={{ fontSize: 10 }}>
|
||||
{connectionMode === 'apikey' ? 'API Key' : 'Session'}
|
||||
</span>
|
||||
{capabilities.proxyOnly && (
|
||||
<span className="badge badge-muted" style={{ fontSize: 10 }}>仅代理</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@@ -1229,6 +1465,9 @@ export default function Accounts() {
|
||||
重新绑定
|
||||
</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">
|
||||
{actionLoading[`delete-${a.id}`] ? <span className="spinner spinner-sm" /> : '删除'}
|
||||
</button>
|
||||
|
||||
@@ -24,6 +24,7 @@ type RuntimeSettings = {
|
||||
balanceRefreshCron: string;
|
||||
routingFallbackUnitCost: number;
|
||||
routingWeights: RoutingWeights;
|
||||
systemProxyUrl: string;
|
||||
proxyTokenMasked?: string;
|
||||
adminIpAllowlist?: string[];
|
||||
currentAdminIp?: string;
|
||||
@@ -178,12 +179,14 @@ export default function Settings() {
|
||||
balanceRefreshCron: '0 * * * *',
|
||||
routingFallbackUnitCost: 1,
|
||||
routingWeights: defaultWeights,
|
||||
systemProxyUrl: '',
|
||||
});
|
||||
const [proxyTokenSuffix, setProxyTokenSuffix] = useState('');
|
||||
const [maskedToken, setMaskedToken] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [savingSchedule, setSavingSchedule] = useState(false);
|
||||
const [savingToken, setSavingToken] = useState(false);
|
||||
const [savingSystemProxy, setSavingSystemProxy] = useState(false);
|
||||
const [savingRouting, setSavingRouting] = useState(false);
|
||||
const [showAdvancedRouting, setShowAdvancedRouting] = useState(false);
|
||||
const [savingSecurity, setSavingSecurity] = useState(false);
|
||||
@@ -367,6 +370,7 @@ export default function Settings() {
|
||||
...defaultWeights,
|
||||
...(runtimeInfo.routingWeights || {}),
|
||||
},
|
||||
systemProxyUrl: typeof runtimeInfo.systemProxyUrl === 'string' ? runtimeInfo.systemProxyUrl : '',
|
||||
proxyTokenMasked: runtimeInfo.proxyTokenMasked || '',
|
||||
adminIpAllowlist: Array.isArray(runtimeInfo.adminIpAllowlist)
|
||||
? 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 = () => {
|
||||
setEditingDownstreamId(null);
|
||||
setDownstreamCreate({
|
||||
@@ -851,6 +875,22 @@ export default function Settings() {
|
||||
</div>
|
||||
|
||||
<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={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
|
||||
用于下游站点或客户端访问本服务代理接口。前缀 sk- 固定不可修改,只需填写后缀。
|
||||
@@ -902,7 +942,7 @@ export default function Settings() {
|
||||
</button>
|
||||
</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={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
|
||||
每个下游 Key 可独立配置过期、额度,并通过勾选界面限制可访问的模型与群组。
|
||||
|
||||
+128
-18
@@ -8,6 +8,7 @@ import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
||||
import { clearFocusParams, readFocusSiteId } from './helpers/navigationFocus.js';
|
||||
import { tr } from '../i18n.js';
|
||||
import { buildCustomReorderUpdates, sortItemsForDisplay, type SortMode } from './helpers/listSorting.js';
|
||||
import { shouldIgnoreRowSelectionClick } from './helpers/rowSelection.js';
|
||||
import {
|
||||
buildSiteSaveAction,
|
||||
emptySiteForm,
|
||||
@@ -23,7 +24,7 @@ type SiteRow = {
|
||||
externalCheckinUrl?: string | null;
|
||||
platform?: string;
|
||||
status?: string;
|
||||
proxyUrl?: string | null;
|
||||
useSystemProxy?: boolean;
|
||||
globalWeight?: number;
|
||||
isPinned?: boolean;
|
||||
sortOrder?: number;
|
||||
@@ -75,6 +76,8 @@ export default function Sites() {
|
||||
const [togglingSiteId, setTogglingSiteId] = useState<number | null>(null);
|
||||
const [orderingSiteId, setOrderingSiteId] = 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 lastEditorRef = useRef<SiteEditorState | null>(null);
|
||||
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
|
||||
@@ -90,6 +93,7 @@ export default function Sites() {
|
||||
try {
|
||||
const rows = await api.getSites();
|
||||
setSites(rows || []);
|
||||
setSelectedSiteIds((current) => current.filter((id) => (rows || []).some((site: SiteRow) => site.id === id)));
|
||||
} catch {
|
||||
toast.error('加载站点列表失败');
|
||||
} finally {
|
||||
@@ -187,7 +191,7 @@ export default function Sites() {
|
||||
url: form.url.trim(),
|
||||
externalCheckinUrl: form.externalCheckinUrl.trim(),
|
||||
platform: form.platform.trim(),
|
||||
proxyUrl: form.proxyUrl.trim(),
|
||||
useSystemProxy: !!form.useSystemProxy,
|
||||
globalWeight: Number(parsedGlobalWeight.toFixed(3)),
|
||||
};
|
||||
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 (
|
||||
<div className="animate-fade-in">
|
||||
<div className="page-header">
|
||||
@@ -319,6 +371,38 @@ export default function Sites() {
|
||||
</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 }}>
|
||||
站点权重说明:最终站点倍率 = 站点全局权重 × 设置页中下游 API Key 的站点倍率。它会与路由策略因子(基础权重、价值分、成本、余额、使用频次)共同作用。数值越大,该站点在同优先级下越容易被选中。建议范围 0.5-3,默认 1;长期不建议超过 5。
|
||||
</div>
|
||||
@@ -409,21 +493,24 @@ export default function Sites() {
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
placeholder="出站代理 URL(可选,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080)"
|
||||
value={form.proxyUrl}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 14px',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: 13,
|
||||
outline: 'none',
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
/>
|
||||
<label style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '10px 14px',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: 13,
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.useSystemProxy}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, useSystemProxy: e.target.checked }))}
|
||||
/>
|
||||
使用系统代理
|
||||
</label>
|
||||
<input
|
||||
placeholder="站点全局权重(默认 1)"
|
||||
value={form.globalWeight}
|
||||
@@ -459,10 +546,18 @@ export default function Sites() {
|
||||
<table className="data-table sites-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 44 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sortedSites.length > 0 && selectedSiteIds.length === sortedSites.length}
|
||||
onChange={(e) => toggleSelectAllVisible(e.target.checked)}
|
||||
/>
|
||||
</th>
|
||||
<th>名称</th>
|
||||
<th>外部签到站URL</th>
|
||||
<th>总余额</th>
|
||||
<th>状态</th>
|
||||
<th>系统代理</th>
|
||||
<th>权重</th>
|
||||
<th>平台</th>
|
||||
<th>创建时间</th>
|
||||
@@ -473,12 +568,22 @@ export default function Sites() {
|
||||
{sortedSites.map((site, i) => (
|
||||
<tr
|
||||
key={site.id}
|
||||
data-testid={`site-row-${site.id}`}
|
||||
ref={(node) => {
|
||||
if (node) rowRefs.current.set(site.id, node);
|
||||
else rowRefs.current.delete(site.id);
|
||||
}}
|
||||
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 }}>
|
||||
<a
|
||||
href={site.url}
|
||||
@@ -519,6 +624,11 @@ export default function Sites() {
|
||||
{site.status === 'disabled' ? '禁用' : '启用'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${site.useSystemProxy ? 'badge-info' : 'badge-muted'}`} style={{ fontSize: 11 }}>
|
||||
{site.useSystemProxy ? '已开启' : '未开启'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600 }}>
|
||||
{(site.globalWeight || 1).toFixed(2)}
|
||||
</td>
|
||||
|
||||
+518
-172
@@ -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 { api } from '../api.js';
|
||||
import CenteredModal from '../components/CenteredModal.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
|
||||
import { formatDateTimeLocal } from './helpers/checkinLogTime.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';
|
||||
|
||||
type SyncStatus = 'success' | 'skipped' | 'failed';
|
||||
type TokensPanelProps = {
|
||||
embedded?: boolean;
|
||||
onEmbeddedActionsChange?: (actions: React.ReactNode | null) => void;
|
||||
};
|
||||
|
||||
type AccountTokenSyncResult = {
|
||||
@@ -100,7 +102,7 @@ function isTruthyFlag(input: string | null): boolean {
|
||||
return normalized === '1' || normalized === 'true' || normalized === 'yes';
|
||||
}
|
||||
|
||||
export function TokensPanel({ embedded = false }: TokensPanelProps) {
|
||||
export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: TokensPanelProps) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const initialCreateForm = {
|
||||
@@ -121,19 +123,35 @@ export function TokensPanel({ embedded = false }: TokensPanelProps) {
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncingAll, setSyncingAll] = 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 [highlightTokenId, setHighlightTokenId] = useState<number | null>(null);
|
||||
const [rowLoading, setRowLoading] = useState<Record<string, boolean>>({});
|
||||
const [selectedTokenIds, setSelectedTokenIds] = useState<number[]>([]);
|
||||
const [batchActionLoading, setBatchActionLoading] = useState(false);
|
||||
const [form, setForm] = useState(initialCreateForm);
|
||||
const [editForm, setEditForm] = useState({
|
||||
name: '',
|
||||
token: '',
|
||||
enabled: true,
|
||||
isDefault: false,
|
||||
});
|
||||
const [groupOptions, setGroupOptions] = useState<string[]>(['default']);
|
||||
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 load = async () => {
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
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 || [];
|
||||
setAccounts(latestAccounts);
|
||||
|
||||
@@ -147,10 +165,18 @@ export function TokensPanel({ embedded = false }: TokensPanelProps) {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [syncingAccountId, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (highlightTimerRef.current) {
|
||||
clearTimeout(highlightTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -232,6 +258,27 @@ export function TokensPanel({ embedded = false }: TokensPanelProps) {
|
||||
);
|
||||
}, [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>) => {
|
||||
setRowLoading((prev) => ({ ...prev, [key]: true }));
|
||||
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) => {
|
||||
await withRowLoading(`token-${tokenId}-copy`, async () => {
|
||||
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;
|
||||
setSyncing(true);
|
||||
try {
|
||||
@@ -309,9 +468,9 @@ export function TokensPanel({ embedded = false }: TokensPanelProps) {
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
}, [load, syncingAccountId, toast]);
|
||||
|
||||
const handleSyncAll = async () => {
|
||||
const handleSyncAll = useCallback(async () => {
|
||||
setSyncingAll(true);
|
||||
try {
|
||||
const res = await api.syncAllAccountTokens();
|
||||
@@ -365,7 +524,15 @@ export function TokensPanel({ embedded = false }: TokensPanelProps) {
|
||||
} finally {
|
||||
setSyncingAll(false);
|
||||
}
|
||||
};
|
||||
}, [load, toast]);
|
||||
|
||||
const handleToggleAdd = useCallback(() => {
|
||||
setShowAdd((prev) => {
|
||||
const nextOpen = !prev;
|
||||
if (!nextOpen) setCreateHintModelName('');
|
||||
return nextOpen;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
@@ -378,175 +545,321 @@ export function TokensPanel({ embedded = false }: TokensPanelProps) {
|
||||
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 (
|
||||
<div className={embedded ? '' : 'animate-fade-in'}>
|
||||
<div className="page-header">
|
||||
{!embedded ? <h2 className="page-title">{tr('账号令牌')}</h2> : <div />}
|
||||
<div className="page-actions">
|
||||
<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={() => {
|
||||
setShowAdd((prev) => {
|
||||
const nextOpen = !prev;
|
||||
if (!nextOpen) setCreateHintModelName('');
|
||||
return nextOpen;
|
||||
});
|
||||
}}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{showAdd ? '取消' : '+ 新增令牌'}
|
||||
</button>
|
||||
{(!embedded || !onEmbeddedActionsChange) && (
|
||||
<div className="page-header">
|
||||
{!embedded ? <h2 className="page-title">{tr('账号令牌')}</h2> : <div />}
|
||||
{headerActions}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="info-tip" style={{ marginBottom: 12 }}>
|
||||
新增令牌会调用站点 API 创建新密钥,再自动同步到本地。支持设置分组、额度、过期时间和 IP 白名单;已存在密钥可直接用“同步站点令牌”读取。
|
||||
</div>
|
||||
|
||||
{addPanelPresence.shouldRender && (
|
||||
<div className={`card panel-presence ${addPanelPresence.isVisible ? '' : 'is-closing'}`.trim()} style={getAccountsAddPanelStyle()}>
|
||||
<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={{ 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)' }} /> 创建中...</> : '创建并同步令牌'}
|
||||
<CenteredModal
|
||||
open={Boolean(editingToken)}
|
||||
onClose={closeEditPanel}
|
||||
title="编辑令牌"
|
||||
maxWidth={760}
|
||||
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
|
||||
footer={(
|
||||
<>
|
||||
<button onClick={closeEditPanel} className="btn btn-ghost">取消</button>
|
||||
<button onClick={saveEditPanel} disabled={savingEdit || editingTokenValueLoading} className="btn btn-primary">
|
||||
{savingEdit ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存修改'}
|
||||
</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>
|
||||
)}
|
||||
|
||||
<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' }}>
|
||||
{loading ? (
|
||||
<div style={{ padding: 20 }}>
|
||||
@@ -558,6 +871,13 @@ export function TokensPanel({ embedded = false }: TokensPanelProps) {
|
||||
<table className="data-table token-table">
|
||||
<thead>
|
||||
<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>
|
||||
@@ -572,7 +892,25 @@ export function TokensPanel({ embedded = false }: TokensPanelProps) {
|
||||
{tokens.map((token: any, i: number) => {
|
||||
const loadingPrefix = `token-${token.id}`;
|
||||
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={{ fontFamily: 'var(--font-mono)', fontSize: 12 }}>{token.tokenMasked || '***'}</td>
|
||||
<td>
|
||||
@@ -582,6 +920,7 @@ export function TokensPanel({ embedded = false }: TokensPanelProps) {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="badge-link"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span className="badge badge-muted" style={{ fontSize: 11 }}>
|
||||
{token.site?.name || 'unknown'}
|
||||
@@ -601,7 +940,7 @@ export function TokensPanel({ embedded = false }: TokensPanelProps) {
|
||||
</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 className="token-table-actions-col" style={{ textAlign: 'right' }}>
|
||||
<td className="token-actions-cell" style={{ textAlign: 'right' }}>
|
||||
<div className="token-table-actions">
|
||||
{!token.isDefault && (
|
||||
<button
|
||||
@@ -620,9 +959,16 @@ export function TokensPanel({ embedded = false }: TokensPanelProps) {
|
||||
onClick={() => handleCopyToken(token.id, token.name || '')}
|
||||
disabled={!!rowLoading[`${loadingPrefix}-copy`]}
|
||||
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" /> : '复制'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openEditPanel(token)}
|
||||
className="btn btn-link btn-link-info token-table-action-btn"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => withRowLoading(`${loadingPrefix}-toggle`, async () => {
|
||||
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();
|
||||
|
||||
const rendered = JSON.stringify(root.toJSON());
|
||||
expect(rendered).toContain('仅代理');
|
||||
expect(rendered).not.toContain('仅代理');
|
||||
expect(rendered).not.toContain('访问令牌已过期');
|
||||
|
||||
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(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -39,7 +39,7 @@ describe('Accounts rebind panel focus', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('highlights rebind panel when clicking rebind action', async () => {
|
||||
it('opens centered rebind modal when clicking rebind action', async () => {
|
||||
apiMock.getAccounts.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
@@ -82,12 +82,14 @@ describe('Accounts rebind panel focus', () => {
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const panel = root.root.find((node) => (
|
||||
const modal = root.root.find((node) => (
|
||||
node.type === 'div'
|
||||
&& 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 {
|
||||
root?.unmount();
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ describe('Accounts segmented connections view', () => {
|
||||
expect(rendered).toContain('连接管理');
|
||||
expect(rendered).toContain('API Key 连接');
|
||||
expect(rendered).toContain('Key Site');
|
||||
expect(rendered).not.toContain('仅代理');
|
||||
expect(rendered).not.toContain('session-user');
|
||||
} finally {
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
const FOCUS_SITE_ID_KEY = 'focusSiteId';
|
||||
const FOCUS_ACCOUNT_ID_KEY = 'focusAccountId';
|
||||
const FOCUS_TOKEN_ID_KEY = 'focusTokenId';
|
||||
const OPEN_REBIND_KEY = 'openRebind';
|
||||
|
||||
function normalizePositiveId(input: unknown): number | null {
|
||||
@@ -22,16 +23,26 @@ export function buildSiteFocusPath(siteId: number): string {
|
||||
|
||||
export function buildAccountFocusPath(
|
||||
accountId: number,
|
||||
options?: { openRebind?: boolean },
|
||||
options?: { openRebind?: boolean; segment?: 'session' | 'apikey' | 'tokens' },
|
||||
): string {
|
||||
const normalizedId = normalizePositiveId(accountId);
|
||||
if (!normalizedId) return '/accounts';
|
||||
const params = new URLSearchParams();
|
||||
if (options?.segment && options.segment !== 'session') params.set('segment', options.segment);
|
||||
params.set(FOCUS_ACCOUNT_ID_KEY, String(normalizedId));
|
||||
if (options?.openRebind) params.set(OPEN_REBIND_KEY, '1');
|
||||
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 {
|
||||
const params = new URLSearchParams(search);
|
||||
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 {
|
||||
const params = new URLSearchParams(search);
|
||||
params.delete(FOCUS_SITE_ID_KEY);
|
||||
params.delete(FOCUS_ACCOUNT_ID_KEY);
|
||||
params.delete(FOCUS_TOKEN_ID_KEY);
|
||||
params.delete(OPEN_REBIND_KEY);
|
||||
const next = params.toString();
|
||||
return next ? `?${next}` : '';
|
||||
|
||||
@@ -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"]');
|
||||
}
|
||||
@@ -3,7 +3,7 @@ export type SiteForm = {
|
||||
url: string;
|
||||
externalCheckinUrl: string;
|
||||
platform: string;
|
||||
proxyUrl: string;
|
||||
useSystemProxy: boolean;
|
||||
globalWeight: string;
|
||||
};
|
||||
|
||||
@@ -16,12 +16,12 @@ type SiteSaveAction =
|
||||
| { kind: 'update'; id: number; payload: 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> & {
|
||||
proxyUrl?: string | null;
|
||||
externalCheckinUrl?: string | null;
|
||||
useSystemProxy?: boolean | null;
|
||||
globalWeight?: number | string | null;
|
||||
}): SiteForm {
|
||||
const globalWeightRaw = Number(site.globalWeight);
|
||||
@@ -31,7 +31,7 @@ export function siteFormFromSite(site: Partial<SiteForm> & {
|
||||
url: site.url ?? '',
|
||||
externalCheckinUrl: site.externalCheckinUrl ?? '',
|
||||
platform: site.platform ?? '',
|
||||
proxyUrl: site.proxyUrl ?? '',
|
||||
useSystemProxy: !!site.useSystemProxy,
|
||||
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;');
|
||||
});
|
||||
});
|
||||
@@ -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()}");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user