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

- Implement tests for the CenteredModal component to ensure it follows the shared modal shell pattern.
- Add comprehensive tests for the SearchModal results, including API interactions and UI behavior.
- Create tests for accounts batch actions, verifying the functionality of refreshing balances and selecting accounts via row clicks.
- Introduce tests for the accounts centered modal to confirm the use of CenteredModal for add/edit/rebind flows.
- Add tests for the accounts edit panel to ensure proper rendering and functionality when opening the edit modal.
- Implement tests for the accounts tokens header to verify the reuse of the accounts page header in the tokens segment.
- Create tests for the system proxy settings, ensuring the correct saving of the proxy URL.
- Add tests for the tokens actions layout to confirm the proper layout and styling of token row actions.
- Implement tests for the tokens batch actions, including deletion of selected tokens and row selection behavior.
- Introduce tests for the tokens focus navigation to ensure the correct highlighting of focused token rows.
This commit is contained in:
cita
2026-03-07 18:04:05 +08:00
parent c31bbcbb51
commit c437d93715
65 changed files with 3984 additions and 486 deletions
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('App sidebar config', () => {
it('uses 连接管理 for /accounts and removes standalone /tokens navigation item', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
expect(source).toContain("{ to: '/accounts', label: '连接管理'");
expect(source).not.toContain("{ to: '/accounts', label: '账号'");
expect(source).not.toContain("{ to: '/tokens', label: '令牌管理'");
});
});
+1 -2
View File
@@ -321,8 +321,7 @@ const sidebarGroups = [
items: [
{ 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> },
+3
View File
@@ -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`),
+68
View File
@@ -0,0 +1,68 @@
import React, { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
type CenteredModalProps = {
open: boolean;
onClose: () => void;
title: React.ReactNode;
children: React.ReactNode;
footer?: React.ReactNode;
maxWidth?: number;
bodyStyle?: React.CSSProperties;
};
export default function CenteredModal({
open,
onClose,
title,
children,
footer,
maxWidth = 860,
bodyStyle,
}: CenteredModalProps) {
const presence = useAnimatedVisibility(open, 220);
useEffect(() => {
if (!open || typeof document === 'undefined') return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}, [open]);
useEffect(() => {
if (!open || typeof document === 'undefined') return;
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeydown);
return () => {
document.removeEventListener('keydown', handleKeydown);
};
}, [open, onClose]);
if (!presence.shouldRender) return null;
const modal = (
<div
className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()}
onClick={onClose}
>
<div
className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()}
style={{ maxWidth }}
onClick={(event) => event.stopPropagation()}
>
<div className="modal-header">{title}</div>
<div className="modal-body" style={bodyStyle}>
{children}
</div>
{footer ? <div className="modal-footer">{footer}</div> : null}
</div>
</div>
);
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
}
+54 -4
View File
@@ -2,7 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { 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();
}
});
});
+2 -2
View File
@@ -273,7 +273,7 @@ export const zhToEnSupplemental: Record<string, string> = {
'令牌不存在或不属于通道账号': 'The token does not exist or does not belong to the channel account',
'令牌不可用': '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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+41 -1
View File
@@ -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
View File
@@ -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
View File
@@ -1,16 +1,18 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Navigate, useLocation, useNavigate } from 'react-router-dom';
import { 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();
}
});
});
+18 -1
View File
@@ -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}` : '';
+4
View File
@@ -0,0 +1,4 @@
export function shouldIgnoreRowSelectionClick(target: EventTarget | null): boolean {
const element = target as { closest?: (selector: string) => unknown } | null;
return !!element?.closest?.('button, a, input, textarea, select, label, [role="button"]');
}
+4 -4
View File
@@ -3,7 +3,7 @@ export type SiteForm = {
url: string;
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;');
});
});
+141
View File
@@ -0,0 +1,141 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import { TokensPanel } from './Tokens.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getAccountTokens: vi.fn(),
getAccounts: vi.fn(),
getAccountTokenGroups: vi.fn(),
batchUpdateAccountTokens: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('Tokens batch actions', () => {
beforeEach(() => {
vi.clearAllMocks();
apiMock.getAccountTokens.mockResolvedValue([
{
id: 1,
accountId: 1,
name: 'token-1',
tokenMasked: 'sk-***1',
enabled: true,
isDefault: false,
account: { username: 'alpha' },
site: { name: 'Site A', url: 'https://site-a.example.com' },
},
{
id: 2,
accountId: 1,
name: 'token-2',
tokenMasked: 'sk-***2',
enabled: true,
isDefault: false,
account: { username: 'alpha' },
site: { name: 'Site A', url: 'https://site-a.example.com' },
},
]);
apiMock.getAccounts.mockResolvedValue([
{
id: 1,
username: 'alpha',
accessToken: 'session-alpha',
status: 'active',
site: { id: 1, name: 'Site A', status: 'active' },
},
]);
apiMock.getAccountTokenGroups.mockResolvedValue({ groups: ['default'] });
apiMock.batchUpdateAccountTokens.mockResolvedValue({
success: true,
successIds: [1, 2],
failedItems: [],
});
});
afterEach(() => {
vi.clearAllMocks();
});
it('deletes selected tokens through the batch toolbar', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<ToastProvider>
<MemoryRouter initialEntries={['/accounts?segment=tokens']}>
<TokensPanel />
</MemoryRouter>
</ToastProvider>,
);
});
await flushMicrotasks();
const checkboxA = root.root.find((node) => node.props['data-testid'] === 'token-select-1');
const checkboxB = root.root.find((node) => node.props['data-testid'] === 'token-select-2');
await act(async () => {
checkboxA.props.onChange({ target: { checked: true } });
checkboxB.props.onChange({ target: { checked: true } });
});
const batchButton = root.root.find((node) => node.props['data-testid'] === 'tokens-batch-delete');
const originalConfirm = globalThis.confirm;
globalThis.confirm = vi.fn(() => true);
try {
await act(async () => {
batchButton.props.onClick();
});
await flushMicrotasks();
} finally {
globalThis.confirm = originalConfirm;
}
expect(apiMock.batchUpdateAccountTokens).toHaveBeenCalledWith({
ids: [1, 2],
action: 'delete',
});
} finally {
root?.unmount();
}
});
it('selects a token when clicking the row instead of only the checkbox', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<ToastProvider>
<MemoryRouter initialEntries={['/accounts?segment=tokens']}>
<TokensPanel />
</MemoryRouter>
</ToastProvider>,
);
});
await flushMicrotasks();
const row = root.root.find((node) => node.props['data-testid'] === 'token-row-1');
await act(async () => {
row.props.onClick({ target: { closest: () => null } });
});
await flushMicrotasks();
const checkbox = root.root.find((node) => node.props['data-testid'] === 'token-select-1');
expect(checkbox.props.checked).toBe(true);
} finally {
root?.unmount();
}
});
});
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('Tokens centered modal adoption', () => {
it('uses CenteredModal for add/edit token flows instead of inline form cards', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/Tokens.tsx'), 'utf8');
expect(source).toContain("import CenteredModal from '../components/CenteredModal.js'");
expect(source).toContain('<CenteredModal');
expect(source).not.toContain('addPanelPresence.shouldRender && (');
});
});
@@ -0,0 +1,184 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import Accounts from './Accounts.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getAccounts: vi.fn(),
getSites: vi.fn(),
getAccountTokens: vi.fn(),
getAccountTokenValue: vi.fn(),
updateAccountToken: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
function collectText(node: ReactTestInstance): string {
const children = node.children || [];
return children
.map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
})
.join('');
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
function buildRoot() {
return create(
<MemoryRouter initialEntries={['/accounts?segment=tokens']}>
<ToastProvider>
<Accounts />
</ToastProvider>
</MemoryRouter>,
{
createNodeMock: (element) => {
if (element.type === 'tr' || element.type === 'div') {
return {
scrollIntoView: () => undefined,
};
}
return {};
},
},
);
}
describe('Tokens edit modal and row selection', () => {
beforeEach(() => {
vi.clearAllMocks();
Object.defineProperty(globalThis, 'navigator', {
value: {
clipboard: {
writeText: vi.fn().mockResolvedValue(undefined),
},
},
configurable: true,
writable: true,
});
apiMock.getAccounts.mockResolvedValue([
{
id: 1,
username: 'session-user',
accessToken: 'session-token',
status: 'active',
credentialMode: 'session',
capabilities: { canCheckin: true, canRefreshBalance: true, proxyOnly: false },
site: { id: 10, name: 'Session Site', platform: 'new-api', status: 'active', url: 'https://session.example.com' },
},
]);
apiMock.getSites.mockResolvedValue([
{ id: 10, name: 'Session Site', platform: 'new-api', status: 'active' },
]);
apiMock.getAccountTokens.mockResolvedValue([
{
id: 22,
name: 'focus-token',
tokenMasked: 'sk-focus****',
enabled: true,
isDefault: false,
updatedAt: '2026-03-07 10:00:00',
accountId: 1,
account: { username: 'session-user' },
site: { name: 'Session Site', url: 'https://session.example.com' },
},
]);
apiMock.getAccountTokenValue.mockResolvedValue({
success: true,
token: 'sk-focus-real',
});
apiMock.updateAccountToken.mockResolvedValue({
success: true,
});
});
afterEach(() => {
vi.clearAllMocks();
});
it('opens the centered edit modal when editing a token', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = buildRoot();
});
await flushMicrotasks();
const editButton = root.root
.findAll((node) => node.type === 'button')
.find((node) => collectText(node).includes('编辑'));
expect(editButton).toBeTruthy();
await act(async () => {
editButton!.props.onClick({ stopPropagation: () => undefined });
});
await flushMicrotasks();
const rendered = JSON.stringify(root.toJSON());
expect(rendered).toContain('编辑令牌');
expect(rendered).toContain('保存修改');
expect(rendered).toContain('sk-focus-real');
expect(rendered).toContain('基本信息');
expect(rendered).toContain('状态设置');
const modals = root.root.findAll((node) => {
const className = typeof node.props?.className === 'string' ? node.props.className : '';
return className.includes('modal-content') && collectText(node).includes('编辑令牌');
});
expect(modals).toHaveLength(1);
} finally {
root?.unmount();
}
});
it('selects a token when clicking the row body, but not when clicking an action button', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = buildRoot();
});
await flushMicrotasks();
const tokenRow = root.root.findAll((node) => {
if (node.type !== 'tr') return false;
return collectText(node).includes('focus-token');
})[0];
expect(tokenRow).toBeTruthy();
await act(async () => {
tokenRow.props.onClick({
target: { closest: () => null },
});
});
await flushMicrotasks();
expect(JSON.stringify(root.toJSON())).toContain('已选 ');
expect(JSON.stringify(root.toJSON())).toContain('"1"');
const copyButton = root.root
.findAll((node) => node.type === 'button')
.find((node) => collectText(node).includes('复制'));
expect(copyButton).toBeTruthy();
await act(async () => {
copyButton!.props.onClick({ stopPropagation: () => undefined });
});
await flushMicrotasks();
expect(JSON.stringify(root.toJSON())).toContain('已选 ');
expect(JSON.stringify(root.toJSON())).toContain('"1"');
} finally {
root?.unmount();
}
});
});
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('Tokens embedded layout', () => {
it('reuses accounts-page-actions layout when embedded in 连接管理', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/Tokens.tsx'), 'utf8');
expect(source).toContain("className={`page-actions ${embedded ? 'accounts-page-actions' : ''}`.trim()}");
});
});
+107
View File
@@ -0,0 +1,107 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import Accounts from './Accounts.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getAccounts: vi.fn(),
getSites: vi.fn(),
getAccountTokens: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
function collectText(node: ReactTestInstance): string {
const children = node.children || [];
return children
.map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
})
.join('');
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('Tokens focus navigation', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
it('highlights the focused token row in the 账号令牌 segment', async () => {
apiMock.getAccounts.mockResolvedValue([
{
id: 1,
username: 'session-user',
accessToken: 'session-token',
status: 'active',
credentialMode: 'session',
capabilities: { canCheckin: true, canRefreshBalance: true, proxyOnly: false },
site: { id: 10, name: 'Session Site', platform: 'new-api', status: 'active', url: 'https://session.example.com' },
},
]);
apiMock.getSites.mockResolvedValue([
{ id: 10, name: 'Session Site', platform: 'new-api', status: 'active' },
]);
apiMock.getAccountTokens.mockResolvedValue([
{
id: 22,
name: 'focus-token',
tokenMasked: 'sk-focus****',
enabled: true,
isDefault: false,
updatedAt: '2026-03-07 10:00:00',
accountId: 1,
account: { username: 'session-user' },
site: { name: 'Session Site', url: 'https://session.example.com' },
},
]);
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/accounts?segment=tokens&focusTokenId=22']}>
<ToastProvider>
<Accounts />
</ToastProvider>
</MemoryRouter>,
{
createNodeMock: (element) => {
if (element.type === 'tr') {
return {
scrollIntoView: () => undefined,
};
}
return {};
},
},
);
});
await flushMicrotasks();
const highlightedRow = root.root.findAll((node) => {
if (node.type !== 'tr') return false;
const className = typeof node.props?.className === 'string' ? node.props.className : '';
return className.includes('row-focus-highlight') && collectText(node).includes('focus-token');
});
expect(highlightedRow).toHaveLength(1);
} finally {
root?.unmount();
}
});
});