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:
+334
-95
@@ -1,10 +1,9 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import CenteredModal from '../components/CenteredModal.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
|
||||
import { getAccountsAddPanelStyle } from './helpers/accountsPanelStyle.js';
|
||||
import {
|
||||
buildAddAccountPrereqHint,
|
||||
buildVerifyFailureHint,
|
||||
@@ -14,10 +13,31 @@ import { clearFocusParams, readFocusAccountIntent } from './helpers/navigationFo
|
||||
import { TokensPanel } from './Tokens.js';
|
||||
import { tr } from '../i18n.js';
|
||||
import { buildCustomReorderUpdates, sortItemsForDisplay, type SortMode } from './helpers/listSorting.js';
|
||||
import { shouldIgnoreRowSelectionClick } from './helpers/rowSelection.js';
|
||||
import { SITE_DOCS_URL } from '../docsLink.js';
|
||||
|
||||
type ConnectionsSegment = 'session' | 'apikey' | 'tokens';
|
||||
|
||||
function createLoginForm() {
|
||||
return { siteId: 0, username: '', password: '' };
|
||||
}
|
||||
|
||||
function createTokenForm(credentialMode: 'session' | 'apikey' = 'session') {
|
||||
return {
|
||||
siteId: 0,
|
||||
username: '',
|
||||
accessToken: '',
|
||||
platformUserId: '',
|
||||
refreshToken: '',
|
||||
tokenExpiresAt: '',
|
||||
credentialMode,
|
||||
};
|
||||
}
|
||||
|
||||
function createRebindForm(platformUserId = '') {
|
||||
return { accessToken: '', platformUserId, refreshToken: '', tokenExpiresAt: '' };
|
||||
}
|
||||
|
||||
function isTruthyFlag(value: string | null): boolean {
|
||||
if (!value) return false;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
@@ -46,33 +66,35 @@ export default function Accounts() {
|
||||
const [highlightAccountId, setHighlightAccountId] = useState<number | null>(null);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [addMode, setAddMode] = useState<'token' | 'login'>('token');
|
||||
const [loginForm, setLoginForm] = useState({ siteId: 0, username: '', password: '' });
|
||||
const [tokenForm, setTokenForm] = useState({
|
||||
siteId: 0,
|
||||
username: '',
|
||||
accessToken: '',
|
||||
platformUserId: '',
|
||||
refreshToken: '',
|
||||
tokenExpiresAt: '',
|
||||
credentialMode: 'session' as 'auto' | 'session' | 'apikey',
|
||||
});
|
||||
const [loginForm, setLoginForm] = useState(createLoginForm);
|
||||
const [tokenForm, setTokenForm] = useState(() => createTokenForm('session'));
|
||||
const [verifyResult, setVerifyResult] = useState<any>(null);
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState<Record<string, boolean>>({});
|
||||
const [embeddedTokenActions, setEmbeddedTokenActions] = useState<React.ReactNode>(null);
|
||||
const [selectedAccountIds, setSelectedAccountIds] = useState<number[]>([]);
|
||||
const [batchActionLoading, setBatchActionLoading] = useState(false);
|
||||
const [editingAccount, setEditingAccount] = useState<any | null>(null);
|
||||
const [editForm, setEditForm] = useState({
|
||||
username: '',
|
||||
status: 'active',
|
||||
checkinEnabled: true,
|
||||
unitCost: '',
|
||||
accessToken: '',
|
||||
apiToken: '',
|
||||
isPinned: false,
|
||||
refreshToken: '',
|
||||
tokenExpiresAt: '',
|
||||
});
|
||||
const [savingEdit, setSavingEdit] = useState(false);
|
||||
const [rebindTarget, setRebindTarget] = useState<any | null>(null);
|
||||
const [rebindForm, setRebindForm] = useState({ accessToken: '', platformUserId: '', refreshToken: '', tokenExpiresAt: '' });
|
||||
const [rebindForm, setRebindForm] = useState(() => createRebindForm());
|
||||
const [rebindVerifyResult, setRebindVerifyResult] = useState<any>(null);
|
||||
const [rebindVerifying, setRebindVerifying] = useState(false);
|
||||
const [rebindSaving, setRebindSaving] = useState(false);
|
||||
const [highlightRebindPanel, setHighlightRebindPanel] = useState(false);
|
||||
const [rebindFocusTrigger, setRebindFocusTrigger] = useState(0);
|
||||
const addPanelPresence = useAnimatedVisibility(showAdd, 220);
|
||||
const rebindPanelPresence = useAnimatedVisibility(Boolean(rebindTarget), 220);
|
||||
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
|
||||
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const rebindPanelRef = useRef<HTMLDivElement | null>(null);
|
||||
const rebindPanelTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastRebindTargetRef = useRef<any | null>(null);
|
||||
const toast = useToast();
|
||||
if (rebindTarget) lastRebindTargetRef.current = rebindTarget;
|
||||
@@ -85,7 +107,9 @@ export default function Accounts() {
|
||||
api.getSites(),
|
||||
]);
|
||||
if (accountsResult.status === 'fulfilled') {
|
||||
setAccounts(accountsResult.value || []);
|
||||
const nextAccounts = accountsResult.value || [];
|
||||
setAccounts(nextAccounts);
|
||||
setSelectedAccountIds((current) => current.filter((id) => nextAccounts.some((account: any) => account.id === id)));
|
||||
} else {
|
||||
toast.error('加载账号列表失败');
|
||||
}
|
||||
@@ -101,6 +125,21 @@ export default function Accounts() {
|
||||
[sites, tokenForm.siteId],
|
||||
);
|
||||
const isSub2ApiSelected = (selectedTokenSite?.platform || '').toLowerCase() === 'sub2api';
|
||||
const activeAddCredentialMode = activeSegment === 'apikey' ? 'apikey' : 'session';
|
||||
|
||||
const resetAddForms = (credentialMode: 'session' | 'apikey' = activeAddCredentialMode) => {
|
||||
setAddMode('token');
|
||||
setLoginForm(createLoginForm());
|
||||
setTokenForm(createTokenForm(credentialMode));
|
||||
setVerifyResult(null);
|
||||
};
|
||||
|
||||
const closeAddPanel = () => {
|
||||
setShowAdd(false);
|
||||
setVerifying(false);
|
||||
setSaving(false);
|
||||
resetAddForms();
|
||||
};
|
||||
|
||||
const resolveAccountCredentialMode = (account: any): 'session' | 'apikey' => {
|
||||
const rawMode = String(account?.credentialMode || '').trim().toLowerCase();
|
||||
@@ -147,9 +186,14 @@ export default function Accounts() {
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSegment !== 'tokens') return;
|
||||
setShowAdd(false);
|
||||
setVerifyResult(null);
|
||||
closeAddPanel();
|
||||
if (rebindTarget) closeRebindPanel();
|
||||
setEditingAccount(null);
|
||||
}, [activeSegment]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSegment === 'tokens') return;
|
||||
setEmbeddedTokenActions(null);
|
||||
}, [activeSegment]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -162,14 +206,10 @@ export default function Accounts() {
|
||||
setShowAdd(true);
|
||||
setAddMode('token');
|
||||
setVerifyResult(null);
|
||||
setLoginForm(createLoginForm());
|
||||
setTokenForm({
|
||||
...createTokenForm('apikey'),
|
||||
siteId: requestedSiteId,
|
||||
username: '',
|
||||
accessToken: '',
|
||||
platformUserId: '',
|
||||
refreshToken: '',
|
||||
tokenExpiresAt: '',
|
||||
credentialMode: 'apikey',
|
||||
});
|
||||
|
||||
params.delete('create');
|
||||
@@ -190,9 +230,6 @@ export default function Accounts() {
|
||||
if (highlightTimerRef.current) {
|
||||
clearTimeout(highlightTimerRef.current);
|
||||
}
|
||||
if (rebindPanelTimerRef.current) {
|
||||
clearTimeout(rebindPanelTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -202,8 +239,7 @@ export default function Accounts() {
|
||||
try {
|
||||
const result = await api.loginAccount(loginForm);
|
||||
if (result.success) {
|
||||
setShowAdd(false);
|
||||
setLoginForm({ siteId: 0, username: '', password: '' });
|
||||
closeAddPanel();
|
||||
const msg = result.apiTokenFound
|
||||
? `账号 "${loginForm.username}" 已添加,API Key 已自动获取`
|
||||
: `账号 "${loginForm.username}" 已添加(未找到 API Key,请手动设置)`;
|
||||
@@ -271,17 +307,7 @@ export default function Accounts() {
|
||||
: undefined,
|
||||
credentialMode,
|
||||
});
|
||||
setShowAdd(false);
|
||||
setTokenForm({
|
||||
siteId: 0,
|
||||
username: '',
|
||||
accessToken: '',
|
||||
platformUserId: '',
|
||||
refreshToken: '',
|
||||
tokenExpiresAt: '',
|
||||
credentialMode: 'session',
|
||||
});
|
||||
setVerifyResult(null);
|
||||
closeAddPanel();
|
||||
if (result.tokenType === 'apikey') {
|
||||
toast.success('已添加为 API Key 账号(可用于代理转发)');
|
||||
} else {
|
||||
@@ -422,6 +448,115 @@ export default function Accounts() {
|
||||
}
|
||||
};
|
||||
|
||||
const extractManagedSub2ApiAuth = (account: any) => {
|
||||
try {
|
||||
const parsed = JSON.parse(account?.extraConfig || '{}');
|
||||
const auth = parsed?.sub2apiAuth || {};
|
||||
return {
|
||||
refreshToken: typeof auth.refreshToken === 'string' ? auth.refreshToken : '',
|
||||
tokenExpiresAt: auth.tokenExpiresAt ? String(auth.tokenExpiresAt) : '',
|
||||
};
|
||||
} catch {
|
||||
return { refreshToken: '', tokenExpiresAt: '' };
|
||||
}
|
||||
};
|
||||
|
||||
const openEditPanel = (account: any) => {
|
||||
const managedAuth = extractManagedSub2ApiAuth(account);
|
||||
closeAddPanel();
|
||||
setRebindTarget(null);
|
||||
setEditingAccount(account);
|
||||
setEditForm({
|
||||
username: account?.username || '',
|
||||
status: account?.status || 'active',
|
||||
checkinEnabled: account?.checkinEnabled !== false,
|
||||
unitCost: account?.unitCost === null || account?.unitCost === undefined ? '' : String(account.unitCost),
|
||||
accessToken: account?.accessToken || '',
|
||||
apiToken: account?.apiToken || '',
|
||||
isPinned: !!account?.isPinned,
|
||||
refreshToken: managedAuth.refreshToken,
|
||||
tokenExpiresAt: managedAuth.tokenExpiresAt,
|
||||
});
|
||||
};
|
||||
|
||||
const closeEditPanel = () => {
|
||||
setEditingAccount(null);
|
||||
setSavingEdit(false);
|
||||
};
|
||||
|
||||
const saveEditPanel = async () => {
|
||||
if (!editingAccount) return;
|
||||
setSavingEdit(true);
|
||||
try {
|
||||
await api.updateAccount(editingAccount.id, {
|
||||
username: editForm.username.trim() || undefined,
|
||||
status: editForm.status,
|
||||
checkinEnabled: editForm.checkinEnabled,
|
||||
unitCost: editForm.unitCost.trim() ? Number(editForm.unitCost.trim()) : null,
|
||||
accessToken: editForm.accessToken.trim(),
|
||||
apiToken: editForm.apiToken.trim() || null,
|
||||
isPinned: editForm.isPinned,
|
||||
refreshToken: editForm.refreshToken.trim() || null,
|
||||
tokenExpiresAt: editForm.tokenExpiresAt.trim() ? Number.parseInt(editForm.tokenExpiresAt.trim(), 10) : null,
|
||||
});
|
||||
toast.success('账号已更新');
|
||||
closeEditPanel();
|
||||
load();
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || '更新账号失败');
|
||||
} finally {
|
||||
setSavingEdit(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAccountSelection = (accountId: number, checked: boolean) => {
|
||||
setSelectedAccountIds((current) => (
|
||||
checked
|
||||
? Array.from(new Set([...current, accountId]))
|
||||
: current.filter((id) => id !== accountId)
|
||||
));
|
||||
};
|
||||
|
||||
const toggleSelectAllVisibleAccounts = (checked: boolean) => {
|
||||
if (!checked) {
|
||||
setSelectedAccountIds([]);
|
||||
return;
|
||||
}
|
||||
setSelectedAccountIds(visibleAccounts.map((account) => account.id));
|
||||
};
|
||||
|
||||
const runBatchAccountAction = async (action: 'enable' | 'disable' | 'delete' | 'refreshBalance') => {
|
||||
if (selectedAccountIds.length === 0) return;
|
||||
if (action === 'delete' && typeof globalThis.confirm === 'function' && !globalThis.confirm(`确认删除选中的 ${selectedAccountIds.length} 个账号?`)) return;
|
||||
|
||||
setBatchActionLoading(true);
|
||||
try {
|
||||
const result = await api.batchUpdateAccounts({
|
||||
ids: selectedAccountIds,
|
||||
action,
|
||||
});
|
||||
const successIds = Array.isArray(result?.successIds) ? result.successIds.map((id: unknown) => Number(id)) : [];
|
||||
const failedItems = Array.isArray(result?.failedItems) ? result.failedItems : [];
|
||||
if (failedItems.length > 0) {
|
||||
toast.info(`批量操作完成:成功 ${successIds.length},失败 ${failedItems.length}`);
|
||||
} else {
|
||||
toast.success(`批量操作完成:成功 ${successIds.length}`);
|
||||
}
|
||||
setSelectedAccountIds(failedItems.map((item: any) => Number(item.id)).filter((id: number) => Number.isFinite(id) && id > 0));
|
||||
load();
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || '批量操作失败');
|
||||
} finally {
|
||||
setBatchActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAccountRowClick = (accountId: number, event: React.MouseEvent<HTMLTableRowElement>) => {
|
||||
if (shouldIgnoreRowSelectionClick(event.target)) return;
|
||||
const isSelected = selectedAccountIds.includes(accountId);
|
||||
toggleAccountSelection(accountId, !isSelected);
|
||||
};
|
||||
|
||||
const extractPlatformUserId = (account: any): string => {
|
||||
try {
|
||||
const parsed = JSON.parse(account?.extraConfig || '{}');
|
||||
@@ -434,40 +569,21 @@ export default function Accounts() {
|
||||
};
|
||||
|
||||
const openRebindPanel = (account: any) => {
|
||||
closeAddPanel();
|
||||
setEditingAccount(null);
|
||||
setRebindTarget(account);
|
||||
setRebindForm({
|
||||
accessToken: '',
|
||||
platformUserId: extractPlatformUserId(account),
|
||||
refreshToken: '',
|
||||
tokenExpiresAt: '',
|
||||
});
|
||||
setRebindForm(createRebindForm(extractPlatformUserId(account)));
|
||||
setRebindVerifyResult(null);
|
||||
setRebindFocusTrigger((value) => value + 1);
|
||||
};
|
||||
|
||||
const closeRebindPanel = () => {
|
||||
setRebindTarget(null);
|
||||
setRebindForm({ accessToken: '', platformUserId: '', refreshToken: '', tokenExpiresAt: '' });
|
||||
setRebindForm(createRebindForm());
|
||||
setRebindVerifyResult(null);
|
||||
setRebindVerifying(false);
|
||||
setRebindSaving(false);
|
||||
setHighlightRebindPanel(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!rebindTarget || rebindFocusTrigger <= 0) return;
|
||||
|
||||
setHighlightRebindPanel(true);
|
||||
rebindPanelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
|
||||
if (rebindPanelTimerRef.current) {
|
||||
clearTimeout(rebindPanelTimerRef.current);
|
||||
}
|
||||
rebindPanelTimerRef.current = setTimeout(() => {
|
||||
setHighlightRebindPanel(false);
|
||||
}, 2200);
|
||||
}, [rebindFocusTrigger, rebindTarget]);
|
||||
|
||||
const handleVerifyRebindToken = async () => {
|
||||
if (!rebindTarget || !rebindForm.accessToken.trim()) return;
|
||||
setRebindVerifying(true);
|
||||
@@ -598,13 +714,15 @@ export default function Accounts() {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAdd(!showAdd);
|
||||
setAddMode('token');
|
||||
setVerifyResult(null);
|
||||
setTokenForm((current) => ({
|
||||
...current,
|
||||
credentialMode: activeSegment === 'apikey' ? 'apikey' : 'session',
|
||||
}));
|
||||
const nextOpen = !showAdd;
|
||||
if (!nextOpen) {
|
||||
closeAddPanel();
|
||||
return;
|
||||
}
|
||||
setEditingAccount(null);
|
||||
closeRebindPanel();
|
||||
setShowAdd(true);
|
||||
resetAddForms(activeAddCredentialMode);
|
||||
}}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
@@ -612,6 +730,7 @@ export default function Accounts() {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{activeSegment === 'tokens' && embeddedTokenActions}
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -652,12 +771,36 @@ export default function Accounts() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeSegment !== 'tokens' && selectedAccountIds.length > 0 && (
|
||||
<div className="card" style={{ padding: 12, marginBottom: 12, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600 }}>已选 {selectedAccountIds.length} 项</span>
|
||||
<button data-testid="accounts-batch-refresh-balance" onClick={() => runBatchAccountAction('refreshBalance')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
|
||||
批量刷新余额
|
||||
</button>
|
||||
<button onClick={() => runBatchAccountAction('enable')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
|
||||
批量启用
|
||||
</button>
|
||||
<button onClick={() => runBatchAccountAction('disable')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
|
||||
批量禁用
|
||||
</button>
|
||||
<button onClick={() => runBatchAccountAction('delete')} disabled={batchActionLoading} className="btn btn-link btn-link-danger">
|
||||
批量删除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSegment === 'tokens' ? (
|
||||
<TokensPanel embedded />
|
||||
<TokensPanel embedded onEmbeddedActionsChange={setEmbeddedTokenActions} />
|
||||
) : (
|
||||
<>
|
||||
{addPanelPresence.shouldRender && (
|
||||
<div className={`card panel-presence ${addPanelPresence.isVisible ? '' : 'is-closing'}`.trim()} style={getAccountsAddPanelStyle()}>
|
||||
<CenteredModal
|
||||
open={showAdd}
|
||||
onClose={closeAddPanel}
|
||||
title={activeSegment === 'apikey' ? '添加 API Key 连接' : (addMode === 'login' ? '账号密码登录' : '添加 Session 连接')}
|
||||
maxWidth={860}
|
||||
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
|
||||
footer={<button onClick={closeAddPanel} className="btn btn-ghost">取消</button>}
|
||||
>
|
||||
{activeSegment === 'session' ? (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 0, background: 'var(--color-bg)', borderRadius: 'var(--radius-sm)', padding: 3, marginBottom: 16 }}>
|
||||
@@ -967,21 +1110,19 @@ export default function Accounts() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CenteredModal>
|
||||
|
||||
{activeSegment === 'session' && rebindPanelPresence.shouldRender && activeRebindTarget && (
|
||||
<div
|
||||
ref={rebindPanelRef}
|
||||
className={`card panel-presence rebind-panel ${rebindPanelPresence.isVisible ? '' : 'is-closing'} ${highlightRebindPanel ? 'rebind-panel-highlight' : ''}`.trim()}
|
||||
style={{ marginBottom: 16, padding: 16 }}
|
||||
{activeSegment === 'session' && (
|
||||
<CenteredModal
|
||||
open={Boolean(rebindTarget)}
|
||||
onClose={closeRebindPanel}
|
||||
title="重新绑定 Session Token"
|
||||
maxWidth={820}
|
||||
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
|
||||
footer={<button onClick={closeRebindPanel} className="btn btn-ghost">取消</button>}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--color-text-primary)' }}>
|
||||
重新绑定 Session Token
|
||||
</div>
|
||||
<button className="btn btn-ghost" onClick={closeRebindPanel}>关闭</button>
|
||||
</div>
|
||||
{activeRebindTarget ? (
|
||||
<>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
|
||||
连接: {resolveAccountDisplayName(activeRebindTarget)} @ {activeRebindTarget.site?.name || '-'}。请粘贴新的 Session Token,验证成功后再绑定。
|
||||
</div>
|
||||
@@ -1064,14 +1205,102 @@ export default function Accounts() {
|
||||
: '确认重新绑定'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</CenteredModal>
|
||||
)}
|
||||
|
||||
<CenteredModal
|
||||
open={Boolean(editingAccount)}
|
||||
onClose={closeEditPanel}
|
||||
title="编辑账号"
|
||||
maxWidth={860}
|
||||
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
|
||||
footer={(
|
||||
<>
|
||||
<button onClick={closeEditPanel} className="btn btn-ghost">取消</button>
|
||||
<button onClick={saveEditPanel} disabled={savingEdit} className="btn btn-primary">
|
||||
{savingEdit ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存修改'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{editingAccount ? (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||
<input
|
||||
placeholder="账号名称"
|
||||
value={editForm.username}
|
||||
onChange={(e) => setEditForm((prev) => ({ ...prev, username: e.target.value }))}
|
||||
style={inputStyle}
|
||||
/>
|
||||
<ModernSelect
|
||||
value={editForm.status}
|
||||
onChange={(value) => setEditForm((prev) => ({ ...prev, status: value }))}
|
||||
options={[
|
||||
{ value: 'active', label: 'active' },
|
||||
{ value: 'disabled', label: 'disabled' },
|
||||
{ value: 'expired', label: 'expired' },
|
||||
]}
|
||||
placeholder="状态"
|
||||
/>
|
||||
<input
|
||||
placeholder="单位成本(可选)"
|
||||
value={editForm.unitCost}
|
||||
onChange={(e) => setEditForm((prev) => ({ ...prev, unitCost: e.target.value }))}
|
||||
style={inputStyle}
|
||||
/>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, ...inputStyle }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.checkinEnabled}
|
||||
onChange={(e) => setEditForm((prev) => ({ ...prev, checkinEnabled: e.target.checked }))}
|
||||
/>
|
||||
启用签到
|
||||
</label>
|
||||
<input
|
||||
placeholder="Access Token"
|
||||
value={editForm.accessToken}
|
||||
onChange={(e) => setEditForm((prev) => ({ ...prev, accessToken: e.target.value }))}
|
||||
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
|
||||
/>
|
||||
<input
|
||||
placeholder="API Token(可选)"
|
||||
value={editForm.apiToken}
|
||||
onChange={(e) => setEditForm((prev) => ({ ...prev, apiToken: e.target.value }))}
|
||||
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
|
||||
/>
|
||||
{((editingAccount?.site?.platform || '').toLowerCase() === 'sub2api') && (
|
||||
<>
|
||||
<input
|
||||
placeholder="Sub2API refresh_token(可选)"
|
||||
value={editForm.refreshToken}
|
||||
onChange={(e) => setEditForm((prev) => ({ ...prev, refreshToken: e.target.value }))}
|
||||
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
|
||||
/>
|
||||
<input
|
||||
placeholder="token_expires_at(可选)"
|
||||
value={editForm.tokenExpiresAt}
|
||||
onChange={(e) => setEditForm((prev) => ({ ...prev, tokenExpiresAt: e.target.value.replace(/\D/g, '') }))}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</CenteredModal>
|
||||
|
||||
<div className="card">
|
||||
{visibleAccounts.length > 0 ? (
|
||||
<table className="data-table accounts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 44 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={visibleAccounts.length > 0 && selectedAccountIds.length === visibleAccounts.length}
|
||||
onChange={(e) => toggleSelectAllVisibleAccounts(e.target.checked)}
|
||||
/>
|
||||
</th>
|
||||
<th>连接名称</th>
|
||||
<th>站点</th>
|
||||
<th>运行健康状态</th>
|
||||
@@ -1088,21 +1317,28 @@ export default function Accounts() {
|
||||
return (
|
||||
<tr
|
||||
key={a.id}
|
||||
data-testid={`account-row-${a.id}`}
|
||||
ref={(node) => {
|
||||
if (node) rowRefs.current.set(a.id, node);
|
||||
else rowRefs.current.delete(a.id);
|
||||
}}
|
||||
className={`animate-slide-up stagger-${Math.min(i + 1, 5)} ${highlightAccountId === a.id ? 'row-focus-highlight' : ''}`}
|
||||
onClick={(event) => handleAccountRowClick(a.id, event)}
|
||||
className={`animate-slide-up stagger-${Math.min(i + 1, 5)} row-selectable ${selectedAccountIds.includes(a.id) ? 'row-selected' : ''} ${highlightAccountId === a.id ? 'row-focus-highlight' : ''}`.trim()}
|
||||
>
|
||||
<td>
|
||||
<input
|
||||
data-testid={`account-select-${a.id}`}
|
||||
type="checkbox"
|
||||
checked={selectedAccountIds.includes(a.id)}
|
||||
onChange={(e) => toggleAccountSelection(a.id, e.target.checked)}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ color: 'var(--color-text-primary)' }}>
|
||||
<div style={{ fontWeight: 600 }}>{resolveAccountDisplayName(a)}</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginTop: 4 }}>
|
||||
<span className={`badge ${connectionMode === 'apikey' ? 'badge-warning' : 'badge-info'}`} style={{ fontSize: 10 }}>
|
||||
{connectionMode === 'apikey' ? 'API Key' : 'Session'}
|
||||
</span>
|
||||
{capabilities.proxyOnly && (
|
||||
<span className="badge badge-muted" style={{ fontSize: 10 }}>仅代理</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@@ -1229,6 +1465,9 @@ export default function Accounts() {
|
||||
重新绑定
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => openEditPanel(a)} className="btn btn-link btn-link-info">
|
||||
编辑
|
||||
</button>
|
||||
<button onClick={() => withLoading(`delete-${a.id}`, () => api.deleteAccount(a.id), '已删除')} disabled={actionLoading[`delete-${a.id}`]} className="btn btn-link btn-link-danger">
|
||||
{actionLoading[`delete-${a.id}`] ? <span className="spinner spinner-sm" /> : '删除'}
|
||||
</button>
|
||||
|
||||
@@ -24,6 +24,7 @@ type RuntimeSettings = {
|
||||
balanceRefreshCron: string;
|
||||
routingFallbackUnitCost: number;
|
||||
routingWeights: RoutingWeights;
|
||||
systemProxyUrl: string;
|
||||
proxyTokenMasked?: string;
|
||||
adminIpAllowlist?: string[];
|
||||
currentAdminIp?: string;
|
||||
@@ -178,12 +179,14 @@ export default function Settings() {
|
||||
balanceRefreshCron: '0 * * * *',
|
||||
routingFallbackUnitCost: 1,
|
||||
routingWeights: defaultWeights,
|
||||
systemProxyUrl: '',
|
||||
});
|
||||
const [proxyTokenSuffix, setProxyTokenSuffix] = useState('');
|
||||
const [maskedToken, setMaskedToken] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [savingSchedule, setSavingSchedule] = useState(false);
|
||||
const [savingToken, setSavingToken] = useState(false);
|
||||
const [savingSystemProxy, setSavingSystemProxy] = useState(false);
|
||||
const [savingRouting, setSavingRouting] = useState(false);
|
||||
const [showAdvancedRouting, setShowAdvancedRouting] = useState(false);
|
||||
const [savingSecurity, setSavingSecurity] = useState(false);
|
||||
@@ -367,6 +370,7 @@ export default function Settings() {
|
||||
...defaultWeights,
|
||||
...(runtimeInfo.routingWeights || {}),
|
||||
},
|
||||
systemProxyUrl: typeof runtimeInfo.systemProxyUrl === 'string' ? runtimeInfo.systemProxyUrl : '',
|
||||
proxyTokenMasked: runtimeInfo.proxyTokenMasked || '',
|
||||
adminIpAllowlist: Array.isArray(runtimeInfo.adminIpAllowlist)
|
||||
? runtimeInfo.adminIpAllowlist.filter((item: unknown) => typeof item === 'string')
|
||||
@@ -458,6 +462,26 @@ export default function Settings() {
|
||||
}
|
||||
};
|
||||
|
||||
const saveSystemProxy = async () => {
|
||||
setSavingSystemProxy(true);
|
||||
try {
|
||||
const res = await api.updateRuntimeSettings({
|
||||
systemProxyUrl: runtime.systemProxyUrl.trim(),
|
||||
});
|
||||
setRuntime((prev) => ({
|
||||
...prev,
|
||||
systemProxyUrl: typeof res?.systemProxyUrl === 'string'
|
||||
? res.systemProxyUrl
|
||||
: prev.systemProxyUrl,
|
||||
}));
|
||||
toast.success('系统代理已保存');
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || '保存失败');
|
||||
} finally {
|
||||
setSavingSystemProxy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetDownstreamForm = () => {
|
||||
setEditingDownstreamId(null);
|
||||
setDownstreamCreate({
|
||||
@@ -851,6 +875,22 @@ export default function Settings() {
|
||||
</div>
|
||||
|
||||
<div className="card animate-slide-up stagger-3" style={{ padding: 20 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>系统代理</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
|
||||
配置一个全局出站代理地址,站点页可按站点决定是否启用系统代理。
|
||||
</div>
|
||||
<input
|
||||
value={runtime.systemProxyUrl}
|
||||
onChange={(e) => setRuntime((prev) => ({ ...prev, systemProxyUrl: e.target.value }))}
|
||||
placeholder="系统代理 URL(可选,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080)"
|
||||
style={{ ...inputStyle, fontFamily: 'var(--font-mono)', marginBottom: 10 }}
|
||||
/>
|
||||
<button onClick={saveSystemProxy} disabled={savingSystemProxy} className="btn btn-primary">
|
||||
{savingSystemProxy ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存系统代理'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card animate-slide-up stagger-4" style={{ padding: 20 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>下游访问令牌(PROXY_TOKEN)</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
|
||||
用于下游站点或客户端访问本服务代理接口。前缀 sk- 固定不可修改,只需填写后缀。
|
||||
@@ -902,7 +942,7 @@ export default function Settings() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card animate-slide-up stagger-4" style={{ padding: 20 }}>
|
||||
<div className="card animate-slide-up stagger-5" style={{ padding: 20 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>下游 API Key 策略(按项目/分组)</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
|
||||
每个下游 Key 可独立配置过期、额度,并通过勾选界面限制可访问的模型与群组。
|
||||
|
||||
+128
-18
@@ -8,6 +8,7 @@ import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
||||
import { clearFocusParams, readFocusSiteId } from './helpers/navigationFocus.js';
|
||||
import { tr } from '../i18n.js';
|
||||
import { buildCustomReorderUpdates, sortItemsForDisplay, type SortMode } from './helpers/listSorting.js';
|
||||
import { shouldIgnoreRowSelectionClick } from './helpers/rowSelection.js';
|
||||
import {
|
||||
buildSiteSaveAction,
|
||||
emptySiteForm,
|
||||
@@ -23,7 +24,7 @@ type SiteRow = {
|
||||
externalCheckinUrl?: string | null;
|
||||
platform?: string;
|
||||
status?: string;
|
||||
proxyUrl?: string | null;
|
||||
useSystemProxy?: boolean;
|
||||
globalWeight?: number;
|
||||
isPinned?: boolean;
|
||||
sortOrder?: number;
|
||||
@@ -75,6 +76,8 @@ export default function Sites() {
|
||||
const [togglingSiteId, setTogglingSiteId] = useState<number | null>(null);
|
||||
const [orderingSiteId, setOrderingSiteId] = useState<number | null>(null);
|
||||
const [pinningSiteId, setPinningSiteId] = useState<number | null>(null);
|
||||
const [selectedSiteIds, setSelectedSiteIds] = useState<number[]>([]);
|
||||
const [batchActionLoading, setBatchActionLoading] = useState(false);
|
||||
const editorPresence = useAnimatedVisibility(Boolean(editor), 220);
|
||||
const lastEditorRef = useRef<SiteEditorState | null>(null);
|
||||
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
|
||||
@@ -90,6 +93,7 @@ export default function Sites() {
|
||||
try {
|
||||
const rows = await api.getSites();
|
||||
setSites(rows || []);
|
||||
setSelectedSiteIds((current) => current.filter((id) => (rows || []).some((site: SiteRow) => site.id === id)));
|
||||
} catch {
|
||||
toast.error('加载站点列表失败');
|
||||
} finally {
|
||||
@@ -187,7 +191,7 @@ export default function Sites() {
|
||||
url: form.url.trim(),
|
||||
externalCheckinUrl: form.externalCheckinUrl.trim(),
|
||||
platform: form.platform.trim(),
|
||||
proxyUrl: form.proxyUrl.trim(),
|
||||
useSystemProxy: !!form.useSystemProxy,
|
||||
globalWeight: Number(parsedGlobalWeight.toFixed(3)),
|
||||
};
|
||||
if (!payload.name || !payload.url) {
|
||||
@@ -295,6 +299,54 @@ export default function Sites() {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSiteSelection = (siteId: number, checked: boolean) => {
|
||||
setSelectedSiteIds((current) => (
|
||||
checked
|
||||
? Array.from(new Set([...current, siteId]))
|
||||
: current.filter((id) => id !== siteId)
|
||||
));
|
||||
};
|
||||
|
||||
const toggleSelectAllVisible = (checked: boolean) => {
|
||||
if (!checked) {
|
||||
setSelectedSiteIds([]);
|
||||
return;
|
||||
}
|
||||
setSelectedSiteIds(sortedSites.map((site) => site.id));
|
||||
};
|
||||
|
||||
const runBatchAction = async (action: 'enable' | 'disable' | 'delete' | 'enableSystemProxy' | 'disableSystemProxy') => {
|
||||
if (selectedSiteIds.length === 0) return;
|
||||
if (action === 'delete' && typeof globalThis.confirm === 'function' && !globalThis.confirm(`确认删除选中的 ${selectedSiteIds.length} 个站点?`)) return;
|
||||
|
||||
setBatchActionLoading(true);
|
||||
try {
|
||||
const result = await api.batchUpdateSites({
|
||||
ids: selectedSiteIds,
|
||||
action,
|
||||
});
|
||||
const successIds = Array.isArray(result?.successIds) ? result.successIds.map((id: unknown) => Number(id)) : [];
|
||||
const failedItems = Array.isArray(result?.failedItems) ? result.failedItems : [];
|
||||
if (failedItems.length > 0) {
|
||||
toast.info(`批量操作完成:成功 ${successIds.length},失败 ${failedItems.length}`);
|
||||
} else {
|
||||
toast.success(`批量操作完成:成功 ${successIds.length}`);
|
||||
}
|
||||
setSelectedSiteIds(failedItems.map((item: any) => Number(item.id)).filter((id: number) => Number.isFinite(id) && id > 0));
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || '批量操作失败');
|
||||
} finally {
|
||||
setBatchActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSiteRowClick = (siteId: number, event: React.MouseEvent<HTMLTableRowElement>) => {
|
||||
if (shouldIgnoreRowSelectionClick(event.target)) return;
|
||||
const isSelected = selectedSiteIds.includes(siteId);
|
||||
toggleSiteSelection(siteId, !isSelected);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<div className="page-header">
|
||||
@@ -319,6 +371,38 @@ export default function Sites() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedSiteIds.length > 0 && (
|
||||
<div className="card" style={{ padding: 12, marginBottom: 12, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600 }}>已选 {selectedSiteIds.length} 项</span>
|
||||
<button
|
||||
data-testid="sites-batch-enable-system-proxy"
|
||||
onClick={() => runBatchAction('enableSystemProxy')}
|
||||
disabled={batchActionLoading}
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
批量开启系统代理
|
||||
</button>
|
||||
<button
|
||||
onClick={() => runBatchAction('disableSystemProxy')}
|
||||
disabled={batchActionLoading}
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
批量关闭系统代理
|
||||
</button>
|
||||
<button onClick={() => runBatchAction('enable')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
|
||||
批量启用
|
||||
</button>
|
||||
<button onClick={() => runBatchAction('disable')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
|
||||
批量禁用
|
||||
</button>
|
||||
<button onClick={() => runBatchAction('delete')} disabled={batchActionLoading} className="btn btn-link btn-link-danger">
|
||||
批量删除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="info-tip" style={{ marginBottom: 12 }}>
|
||||
站点权重说明:最终站点倍率 = 站点全局权重 × 设置页中下游 API Key 的站点倍率。它会与路由策略因子(基础权重、价值分、成本、余额、使用频次)共同作用。数值越大,该站点在同优先级下越容易被选中。建议范围 0.5-3,默认 1;长期不建议超过 5。
|
||||
</div>
|
||||
@@ -409,21 +493,24 @@ export default function Sites() {
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
placeholder="出站代理 URL(可选,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080)"
|
||||
value={form.proxyUrl}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 14px',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: 13,
|
||||
outline: 'none',
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
/>
|
||||
<label style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '10px 14px',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: 13,
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.useSystemProxy}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, useSystemProxy: e.target.checked }))}
|
||||
/>
|
||||
使用系统代理
|
||||
</label>
|
||||
<input
|
||||
placeholder="站点全局权重(默认 1)"
|
||||
value={form.globalWeight}
|
||||
@@ -459,10 +546,18 @@ export default function Sites() {
|
||||
<table className="data-table sites-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 44 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sortedSites.length > 0 && selectedSiteIds.length === sortedSites.length}
|
||||
onChange={(e) => toggleSelectAllVisible(e.target.checked)}
|
||||
/>
|
||||
</th>
|
||||
<th>名称</th>
|
||||
<th>外部签到站URL</th>
|
||||
<th>总余额</th>
|
||||
<th>状态</th>
|
||||
<th>系统代理</th>
|
||||
<th>权重</th>
|
||||
<th>平台</th>
|
||||
<th>创建时间</th>
|
||||
@@ -473,12 +568,22 @@ export default function Sites() {
|
||||
{sortedSites.map((site, i) => (
|
||||
<tr
|
||||
key={site.id}
|
||||
data-testid={`site-row-${site.id}`}
|
||||
ref={(node) => {
|
||||
if (node) rowRefs.current.set(site.id, node);
|
||||
else rowRefs.current.delete(site.id);
|
||||
}}
|
||||
className={`animate-slide-up stagger-${Math.min(i + 1, 5)} ${highlightSiteId === site.id ? 'row-focus-highlight' : ''}`}
|
||||
onClick={(event) => handleSiteRowClick(site.id, event)}
|
||||
className={`animate-slide-up stagger-${Math.min(i + 1, 5)} row-selectable ${selectedSiteIds.includes(site.id) ? 'row-selected' : ''} ${highlightSiteId === site.id ? 'row-focus-highlight' : ''}`.trim()}
|
||||
>
|
||||
<td>
|
||||
<input
|
||||
data-testid={`site-select-${site.id}`}
|
||||
type="checkbox"
|
||||
checked={selectedSiteIds.includes(site.id)}
|
||||
onChange={(e) => toggleSiteSelection(site.id, e.target.checked)}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ fontWeight: 600 }}>
|
||||
<a
|
||||
href={site.url}
|
||||
@@ -519,6 +624,11 @@ export default function Sites() {
|
||||
{site.status === 'disabled' ? '禁用' : '启用'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${site.useSystemProxy ? 'badge-info' : 'badge-muted'}`} style={{ fontSize: 11 }}>
|
||||
{site.useSystemProxy ? '已开启' : '未开启'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600 }}>
|
||||
{(site.globalWeight || 1).toFixed(2)}
|
||||
</td>
|
||||
|
||||
+518
-172
@@ -1,16 +1,18 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Navigate, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import CenteredModal from '../components/CenteredModal.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
|
||||
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
import { getAccountsAddPanelStyle } from './helpers/accountsPanelStyle.js';
|
||||
import { clearFocusParams, readFocusTokenId } from './helpers/navigationFocus.js';
|
||||
import { shouldIgnoreRowSelectionClick } from './helpers/rowSelection.js';
|
||||
import { tr } from '../i18n.js';
|
||||
|
||||
type SyncStatus = 'success' | 'skipped' | 'failed';
|
||||
type TokensPanelProps = {
|
||||
embedded?: boolean;
|
||||
onEmbeddedActionsChange?: (actions: React.ReactNode | null) => void;
|
||||
};
|
||||
|
||||
type AccountTokenSyncResult = {
|
||||
@@ -100,7 +102,7 @@ function isTruthyFlag(input: string | null): boolean {
|
||||
return normalized === '1' || normalized === 'true' || normalized === 'yes';
|
||||
}
|
||||
|
||||
export function TokensPanel({ embedded = false }: TokensPanelProps) {
|
||||
export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: TokensPanelProps) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const initialCreateForm = {
|
||||
@@ -121,19 +123,35 @@ export function TokensPanel({ embedded = false }: TokensPanelProps) {
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncingAll, setSyncingAll] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [savingEdit, setSavingEdit] = useState(false);
|
||||
const [editingToken, setEditingToken] = useState<any | null>(null);
|
||||
const [editingTokenValueLoading, setEditingTokenValueLoading] = useState(false);
|
||||
const [createHintModelName, setCreateHintModelName] = useState('');
|
||||
const [highlightTokenId, setHighlightTokenId] = useState<number | null>(null);
|
||||
const [rowLoading, setRowLoading] = useState<Record<string, boolean>>({});
|
||||
const [selectedTokenIds, setSelectedTokenIds] = useState<number[]>([]);
|
||||
const [batchActionLoading, setBatchActionLoading] = useState(false);
|
||||
const [form, setForm] = useState(initialCreateForm);
|
||||
const [editForm, setEditForm] = useState({
|
||||
name: '',
|
||||
token: '',
|
||||
enabled: true,
|
||||
isDefault: false,
|
||||
});
|
||||
const [groupOptions, setGroupOptions] = useState<string[]>(['default']);
|
||||
const [groupLoading, setGroupLoading] = useState(false);
|
||||
const addPanelPresence = useAnimatedVisibility(showAdd, 220);
|
||||
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
|
||||
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const editingTokenIdRef = useRef<number | null>(null);
|
||||
const toast = useToast();
|
||||
|
||||
const load = async () => {
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [tokenRows, accountRows] = await Promise.all([api.getAccountTokens(), api.getAccounts()]);
|
||||
setTokens(tokenRows || []);
|
||||
const nextTokens = tokenRows || [];
|
||||
setTokens(nextTokens);
|
||||
setSelectedTokenIds((current) => current.filter((id) => nextTokens.some((token: any) => token.id === id)));
|
||||
const latestAccounts = accountRows || [];
|
||||
setAccounts(latestAccounts);
|
||||
|
||||
@@ -147,10 +165,18 @@ export function TokensPanel({ embedded = false }: TokensPanelProps) {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [syncingAccountId, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (highlightTimerRef.current) {
|
||||
clearTimeout(highlightTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -232,6 +258,27 @@ export function TokensPanel({ embedded = false }: TokensPanelProps) {
|
||||
);
|
||||
}, [activeAccounts, location.pathname, location.search, navigate, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
const focusTokenId = readFocusTokenId(location.search);
|
||||
if (!focusTokenId || loading) return;
|
||||
|
||||
const row = rowRefs.current.get(focusTokenId);
|
||||
const cleanedSearch = clearFocusParams(location.search);
|
||||
if (!row) {
|
||||
navigate({ pathname: location.pathname, search: cleanedSearch }, { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
setHighlightTokenId(focusTokenId);
|
||||
if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current);
|
||||
highlightTimerRef.current = setTimeout(() => {
|
||||
setHighlightTokenId((current) => (current === focusTokenId ? null : current));
|
||||
}, 2200);
|
||||
|
||||
navigate({ pathname: location.pathname, search: cleanedSearch }, { replace: true });
|
||||
}, [loading, location.pathname, location.search, navigate, tokens]);
|
||||
|
||||
const withRowLoading = async (key: string, fn: () => Promise<void>) => {
|
||||
setRowLoading((prev) => ({ ...prev, [key]: true }));
|
||||
try {
|
||||
@@ -241,6 +288,118 @@ export function TokensPanel({ embedded = false }: TokensPanelProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTokenSelection = (tokenId: number, checked: boolean) => {
|
||||
setSelectedTokenIds((current) => (
|
||||
checked
|
||||
? Array.from(new Set([...current, tokenId]))
|
||||
: current.filter((id) => id !== tokenId)
|
||||
));
|
||||
};
|
||||
|
||||
const toggleSelectAllTokens = (checked: boolean) => {
|
||||
if (!checked) {
|
||||
setSelectedTokenIds([]);
|
||||
return;
|
||||
}
|
||||
setSelectedTokenIds(tokens.map((token) => token.id));
|
||||
};
|
||||
|
||||
const runBatchTokenAction = async (action: 'enable' | 'disable' | 'delete') => {
|
||||
if (selectedTokenIds.length === 0) return;
|
||||
if (action === 'delete' && typeof globalThis.confirm === 'function' && !globalThis.confirm(`确认删除选中的 ${selectedTokenIds.length} 个令牌?`)) return;
|
||||
|
||||
setBatchActionLoading(true);
|
||||
try {
|
||||
const result = await api.batchUpdateAccountTokens({
|
||||
ids: selectedTokenIds,
|
||||
action,
|
||||
});
|
||||
const successIds = Array.isArray(result?.successIds) ? result.successIds.map((id: unknown) => Number(id)) : [];
|
||||
const failedItems = Array.isArray(result?.failedItems) ? result.failedItems : [];
|
||||
if (failedItems.length > 0) {
|
||||
toast.info(`批量操作完成:成功 ${successIds.length},失败 ${failedItems.length}`);
|
||||
} else {
|
||||
toast.success(`批量操作完成:成功 ${successIds.length}`);
|
||||
}
|
||||
setSelectedTokenIds(failedItems.map((item: any) => Number(item.id)).filter((id: number) => Number.isFinite(id) && id > 0));
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || '批量操作失败');
|
||||
} finally {
|
||||
setBatchActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openEditPanel = useCallback((token: any) => {
|
||||
setShowAdd(false);
|
||||
setCreateHintModelName('');
|
||||
setEditingToken(token);
|
||||
editingTokenIdRef.current = token.id;
|
||||
setEditForm({
|
||||
name: token?.name || '',
|
||||
token: '',
|
||||
enabled: token?.enabled !== false,
|
||||
isDefault: !!token?.isDefault,
|
||||
});
|
||||
setEditingTokenValueLoading(true);
|
||||
|
||||
void api.getAccountTokenValue(token.id)
|
||||
.then((res: any) => {
|
||||
if (editingTokenIdRef.current !== token.id) return;
|
||||
setEditForm((prev) => ({
|
||||
...prev,
|
||||
token: typeof res?.token === 'string' ? res.token : prev.token,
|
||||
}));
|
||||
})
|
||||
.catch((error: any) => {
|
||||
if (editingTokenIdRef.current !== token.id) return;
|
||||
toast.error(error?.message || '加载令牌详情失败');
|
||||
})
|
||||
.finally(() => {
|
||||
if (editingTokenIdRef.current !== token.id) return;
|
||||
setEditingTokenValueLoading(false);
|
||||
});
|
||||
}, [toast]);
|
||||
|
||||
const closeEditPanel = useCallback(() => {
|
||||
editingTokenIdRef.current = null;
|
||||
setEditingToken(null);
|
||||
setSavingEdit(false);
|
||||
setEditingTokenValueLoading(false);
|
||||
setEditForm({
|
||||
name: '',
|
||||
token: '',
|
||||
enabled: true,
|
||||
isDefault: false,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const saveEditPanel = async () => {
|
||||
if (!editingToken) return;
|
||||
setSavingEdit(true);
|
||||
try {
|
||||
await api.updateAccountToken(editingToken.id, {
|
||||
name: editForm.name.trim() || editingToken.name,
|
||||
token: editForm.token.trim() || undefined,
|
||||
enabled: editForm.enabled,
|
||||
isDefault: editForm.isDefault,
|
||||
});
|
||||
toast.success('令牌已更新');
|
||||
closeEditPanel();
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || '更新令牌失败');
|
||||
} finally {
|
||||
setSavingEdit(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTokenRowClick = (tokenId: number, event: React.MouseEvent<HTMLTableRowElement>) => {
|
||||
if (shouldIgnoreRowSelectionClick(event.target)) return;
|
||||
const isSelected = selectedTokenIds.includes(tokenId);
|
||||
toggleTokenSelection(tokenId, !isSelected);
|
||||
};
|
||||
|
||||
const handleCopyToken = async (tokenId: number, tokenName: string) => {
|
||||
await withRowLoading(`token-${tokenId}-copy`, async () => {
|
||||
const res = await api.getAccountTokenValue(tokenId);
|
||||
@@ -290,7 +449,7 @@ export function TokensPanel({ embedded = false }: TokensPanelProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSync = async () => {
|
||||
const handleSync = useCallback(async () => {
|
||||
if (!syncingAccountId) return;
|
||||
setSyncing(true);
|
||||
try {
|
||||
@@ -309,9 +468,9 @@ export function TokensPanel({ embedded = false }: TokensPanelProps) {
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
}, [load, syncingAccountId, toast]);
|
||||
|
||||
const handleSyncAll = async () => {
|
||||
const handleSyncAll = useCallback(async () => {
|
||||
setSyncingAll(true);
|
||||
try {
|
||||
const res = await api.syncAllAccountTokens();
|
||||
@@ -365,7 +524,15 @@ export function TokensPanel({ embedded = false }: TokensPanelProps) {
|
||||
} finally {
|
||||
setSyncingAll(false);
|
||||
}
|
||||
};
|
||||
}, [load, toast]);
|
||||
|
||||
const handleToggleAdd = useCallback(() => {
|
||||
setShowAdd((prev) => {
|
||||
const nextOpen = !prev;
|
||||
if (!nextOpen) setCreateHintModelName('');
|
||||
return nextOpen;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
@@ -378,175 +545,321 @@ export function TokensPanel({ embedded = false }: TokensPanelProps) {
|
||||
color: 'var(--color-text-primary)',
|
||||
};
|
||||
|
||||
const sectionCardStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
padding: 14,
|
||||
border: '1px solid var(--color-border-light)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
background: 'var(--color-bg-card)',
|
||||
};
|
||||
|
||||
const sectionLabelStyle: React.CSSProperties = {
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-secondary)',
|
||||
letterSpacing: '0.02em',
|
||||
};
|
||||
|
||||
const toggleCardStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 10,
|
||||
padding: '12px 14px',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text-primary)',
|
||||
};
|
||||
|
||||
const headerActions = useMemo(() => (
|
||||
<div className={`page-actions ${embedded ? 'accounts-page-actions' : ''}`.trim()}>
|
||||
<div style={{ minWidth: 220, position: 'relative', zIndex: 20 }}>
|
||||
<ModernSelect
|
||||
size="sm"
|
||||
value={String(syncingAccountId || 0)}
|
||||
onChange={(nextValue) => setSyncingAccountId(Number.parseInt(nextValue, 10) || 0)}
|
||||
options={[
|
||||
{ value: '0', label: '选择账号后同步站点令牌' },
|
||||
...activeAccounts.map((account) => ({
|
||||
value: String(account.id),
|
||||
label: `${account.username || `account-${account.id}`} @ ${account.site?.name || '-'}`,
|
||||
})),
|
||||
]}
|
||||
placeholder="选择账号后同步站点令牌"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSync}
|
||||
disabled={syncing || syncingAll || !syncingAccountId}
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
|
||||
>
|
||||
{syncing ? <><span className="spinner spinner-sm" /> 同步中...</> : '同步站点令牌'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSyncAll}
|
||||
disabled={syncing || syncingAll || activeAccounts.length === 0}
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
|
||||
>
|
||||
{syncingAll ? <><span className="spinner spinner-sm" /> 同步中...</> : '同步全部账号'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleToggleAdd}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{showAdd ? '取消' : '+ 新增令牌'}
|
||||
</button>
|
||||
</div>
|
||||
), [activeAccounts, embedded, handleSync, handleSyncAll, handleToggleAdd, showAdd, syncing, syncingAccountId, syncingAll]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!embedded || !onEmbeddedActionsChange) return;
|
||||
onEmbeddedActionsChange(headerActions);
|
||||
return () => {
|
||||
onEmbeddedActionsChange(null);
|
||||
};
|
||||
}, [embedded, headerActions, onEmbeddedActionsChange]);
|
||||
|
||||
return (
|
||||
<div className={embedded ? '' : 'animate-fade-in'}>
|
||||
<div className="page-header">
|
||||
{!embedded ? <h2 className="page-title">{tr('账号令牌')}</h2> : <div />}
|
||||
<div className="page-actions">
|
||||
<div style={{ minWidth: 220, position: 'relative', zIndex: 20 }}>
|
||||
<ModernSelect
|
||||
size="sm"
|
||||
value={String(syncingAccountId || 0)}
|
||||
onChange={(nextValue) => setSyncingAccountId(Number.parseInt(nextValue, 10) || 0)}
|
||||
options={[
|
||||
{ value: '0', label: '选择账号后同步站点令牌' },
|
||||
...activeAccounts.map((account) => ({
|
||||
value: String(account.id),
|
||||
label: `${account.username || `account-${account.id}`} @ ${account.site?.name || '-'}`,
|
||||
})),
|
||||
]}
|
||||
placeholder="选择账号后同步站点令牌"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSync}
|
||||
disabled={syncing || syncingAll || !syncingAccountId}
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
|
||||
>
|
||||
{syncing ? <><span className="spinner spinner-sm" /> 同步中...</> : '同步站点令牌'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSyncAll}
|
||||
disabled={syncing || syncingAll || activeAccounts.length === 0}
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
|
||||
>
|
||||
{syncingAll ? <><span className="spinner spinner-sm" /> 同步中...</> : '同步全部账号'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAdd((prev) => {
|
||||
const nextOpen = !prev;
|
||||
if (!nextOpen) setCreateHintModelName('');
|
||||
return nextOpen;
|
||||
});
|
||||
}}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{showAdd ? '取消' : '+ 新增令牌'}
|
||||
</button>
|
||||
{(!embedded || !onEmbeddedActionsChange) && (
|
||||
<div className="page-header">
|
||||
{!embedded ? <h2 className="page-title">{tr('账号令牌')}</h2> : <div />}
|
||||
{headerActions}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="info-tip" style={{ marginBottom: 12 }}>
|
||||
新增令牌会调用站点 API 创建新密钥,再自动同步到本地。支持设置分组、额度、过期时间和 IP 白名单;已存在密钥可直接用“同步站点令牌”读取。
|
||||
</div>
|
||||
|
||||
{addPanelPresence.shouldRender && (
|
||||
<div className={`card panel-presence ${addPanelPresence.isVisible ? '' : 'is-closing'}`.trim()} style={getAccountsAddPanelStyle()}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div style={{ gridColumn: '1 / span 2' }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>所属账号</div>
|
||||
<ModernSelect
|
||||
value={String(form.accountId || 0)}
|
||||
onChange={(nextValue) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
accountId: Number.parseInt(nextValue, 10) || 0,
|
||||
group: '',
|
||||
}));
|
||||
}}
|
||||
options={[
|
||||
{ value: '0', label: '选择账号' },
|
||||
...activeAccounts.map((account) => ({
|
||||
value: String(account.id),
|
||||
label: `${account.username || `account-${account.id}`} @ ${account.site?.name || '-'}`,
|
||||
})),
|
||||
]}
|
||||
placeholder="选择账号"
|
||||
/>
|
||||
</div>
|
||||
{createHintModelName ? (
|
||||
<div
|
||||
style={{
|
||||
gridColumn: '1 / span 2',
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
background: 'color-mix(in srgb, var(--color-info) 10%, var(--color-bg))',
|
||||
border: '1px solid color-mix(in srgb, var(--color-info) 30%, transparent)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
padding: '6px 8px',
|
||||
}}
|
||||
>
|
||||
来自路由提醒:为模型 <code style={{ fontSize: 11 }}>{createHintModelName}</code> 补充该账号令牌后,可自动生成对应通道。
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>令牌名称(可选)</div>
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="例如 metapi"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>分组</div>
|
||||
<ModernSelect
|
||||
value={form.group || ''}
|
||||
onChange={(nextValue) => setForm((prev) => ({ ...prev, group: nextValue }))}
|
||||
options={(groupOptions.length > 0 ? groupOptions : ['default']).map((group) => ({
|
||||
value: group,
|
||||
label: group,
|
||||
}))}
|
||||
placeholder={groupLoading ? '分组加载中...' : '选择分组'}
|
||||
disabled={!form.accountId || groupLoading}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / span 2', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, color: 'var(--color-text-secondary)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.unlimitedQuota}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, unlimitedQuota: e.target.checked }))}
|
||||
/>
|
||||
不限额度
|
||||
</label>
|
||||
{!form.unlimitedQuota && (
|
||||
<input
|
||||
value={form.remainQuota}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, remainQuota: e.target.value.replace(/[^\d]/g, '') }))}
|
||||
placeholder="额度(正整数)"
|
||||
style={{ ...inputStyle, maxWidth: 220 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>过期时间(可选)</div>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={form.expiredTime}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, expiredTime: e.target.value }))}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>IP 白名单(可选)</div>
|
||||
<input
|
||||
value={form.allowIps}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, allowIps: e.target.value }))}
|
||||
placeholder="多个用英文逗号分隔"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / span 2', display: 'flex', alignItems: 'center', fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
将在选中账号所属站点直接创建新密钥
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<button
|
||||
onClick={handleAddToken}
|
||||
disabled={saving || !form.accountId}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{saving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 创建中...</> : '创建并同步令牌'}
|
||||
<CenteredModal
|
||||
open={Boolean(editingToken)}
|
||||
onClose={closeEditPanel}
|
||||
title="编辑令牌"
|
||||
maxWidth={760}
|
||||
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
|
||||
footer={(
|
||||
<>
|
||||
<button onClick={closeEditPanel} className="btn btn-ghost">取消</button>
|
||||
<button onClick={saveEditPanel} disabled={savingEdit || editingTokenValueLoading} className="btn btn-primary">
|
||||
{savingEdit ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存修改'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{editingToken ? (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
background: 'color-mix(in srgb, var(--color-primary) 8%, var(--color-bg))',
|
||||
border: '1px solid color-mix(in srgb, var(--color-primary) 18%, transparent)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
padding: '8px 10px',
|
||||
}}
|
||||
>
|
||||
账号: {editingToken.account?.username || `account-${editingToken.accountId}`} @ {editingToken.site?.name || '-'}
|
||||
</div>
|
||||
<div style={sectionCardStyle}>
|
||||
<div style={sectionLabelStyle}>基本信息</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>令牌名称</div>
|
||||
<input
|
||||
placeholder="令牌名称"
|
||||
value={editForm.name}
|
||||
onChange={(e) => setEditForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>令牌值</div>
|
||||
<textarea
|
||||
placeholder={editingTokenValueLoading ? '令牌加载中...' : '令牌值'}
|
||||
value={editForm.token}
|
||||
onChange={(e) => setEditForm((prev) => ({ ...prev, token: e.target.value }))}
|
||||
style={{
|
||||
...inputStyle,
|
||||
minHeight: 96,
|
||||
resize: 'vertical',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
disabled={editingTokenValueLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={sectionCardStyle}>
|
||||
<div style={sectionLabelStyle}>状态设置</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 12 }}>
|
||||
<label style={toggleCardStyle}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.enabled}
|
||||
onChange={(e) => setEditForm((prev) => ({ ...prev, enabled: e.target.checked }))}
|
||||
style={{ marginTop: 2 }}
|
||||
/>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600 }}>启用令牌</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>关闭后令牌不会参与分发</span>
|
||||
</div>
|
||||
</label>
|
||||
<label style={toggleCardStyle}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.isDefault}
|
||||
onChange={(e) => setEditForm((prev) => ({ ...prev, isDefault: e.target.checked }))}
|
||||
style={{ marginTop: 2 }}
|
||||
/>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600 }}>设为默认令牌</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>优先作为该账号的默认转发令牌</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</CenteredModal>
|
||||
|
||||
{selectedTokenIds.length > 0 && (
|
||||
<div className="card" style={{ padding: 12, marginBottom: 12, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600 }}>已选 {selectedTokenIds.length} 项</span>
|
||||
<button onClick={() => runBatchTokenAction('enable')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
|
||||
批量启用
|
||||
</button>
|
||||
<button onClick={() => runBatchTokenAction('disable')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
|
||||
批量禁用
|
||||
</button>
|
||||
<button data-testid="tokens-batch-delete" onClick={() => runBatchTokenAction('delete')} disabled={batchActionLoading} className="btn btn-link btn-link-danger">
|
||||
批量删除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CenteredModal
|
||||
open={showAdd}
|
||||
onClose={handleToggleAdd}
|
||||
title="新增令牌"
|
||||
maxWidth={820}
|
||||
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
|
||||
>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div style={{ gridColumn: '1 / span 2' }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>所属账号</div>
|
||||
<ModernSelect
|
||||
value={String(form.accountId || 0)}
|
||||
onChange={(nextValue) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
accountId: Number.parseInt(nextValue, 10) || 0,
|
||||
group: '',
|
||||
}));
|
||||
}}
|
||||
options={[
|
||||
{ value: '0', label: '选择账号' },
|
||||
...activeAccounts.map((account) => ({
|
||||
value: String(account.id),
|
||||
label: `${account.username || `account-${account.id}`} @ ${account.site?.name || '-'}`,
|
||||
})),
|
||||
]}
|
||||
placeholder="选择账号"
|
||||
/>
|
||||
</div>
|
||||
{createHintModelName ? (
|
||||
<div
|
||||
style={{
|
||||
gridColumn: '1 / span 2',
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
background: 'color-mix(in srgb, var(--color-info) 10%, var(--color-bg))',
|
||||
border: '1px solid color-mix(in srgb, var(--color-info) 30%, transparent)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
padding: '6px 8px',
|
||||
}}
|
||||
>
|
||||
来自路由提醒:为模型 <code style={{ fontSize: 11 }}>{createHintModelName}</code> 补充该账号令牌后,可自动生成对应通道。
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>令牌名称(可选)</div>
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="例如 metapi"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>分组</div>
|
||||
<ModernSelect
|
||||
value={form.group || ''}
|
||||
onChange={(nextValue) => setForm((prev) => ({ ...prev, group: nextValue }))}
|
||||
options={(groupOptions.length > 0 ? groupOptions : ['default']).map((group) => ({
|
||||
value: group,
|
||||
label: group,
|
||||
}))}
|
||||
placeholder={groupLoading ? '分组加载中...' : '选择分组'}
|
||||
disabled={!form.accountId || groupLoading}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / span 2', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, color: 'var(--color-text-secondary)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.unlimitedQuota}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, unlimitedQuota: e.target.checked }))}
|
||||
/>
|
||||
不限额度
|
||||
</label>
|
||||
{!form.unlimitedQuota && (
|
||||
<input
|
||||
value={form.remainQuota}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, remainQuota: e.target.value.replace(/[^\d]/g, '') }))}
|
||||
placeholder="额度(正整数)"
|
||||
style={{ ...inputStyle, maxWidth: 220 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>过期时间(可选)</div>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={form.expiredTime}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, expiredTime: e.target.value }))}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>IP 白名单(可选)</div>
|
||||
<input
|
||||
value={form.allowIps}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, allowIps: e.target.value }))}
|
||||
placeholder="多个用英文逗号分隔"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / span 2', display: 'flex', alignItems: 'center', fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
将在选中账号所属站点直接创建新密钥
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, marginTop: 8 }}>
|
||||
<button onClick={handleToggleAdd} className="btn btn-ghost">取消</button>
|
||||
<button
|
||||
onClick={handleAddToken}
|
||||
disabled={saving || !form.accountId}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{saving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 创建中...</> : '创建并同步令牌'}
|
||||
</button>
|
||||
</div>
|
||||
</CenteredModal>
|
||||
|
||||
<div className="card" style={{ overflowX: 'auto' }}>
|
||||
{loading ? (
|
||||
<div style={{ padding: 20 }}>
|
||||
@@ -558,6 +871,13 @@ export function TokensPanel({ embedded = false }: TokensPanelProps) {
|
||||
<table className="data-table token-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 44 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={tokens.length > 0 && selectedTokenIds.length === tokens.length}
|
||||
onChange={(e) => toggleSelectAllTokens(e.target.checked)}
|
||||
/>
|
||||
</th>
|
||||
<th>令牌名称</th>
|
||||
<th>令牌值</th>
|
||||
<th>来源站点</th>
|
||||
@@ -572,7 +892,25 @@ export function TokensPanel({ embedded = false }: TokensPanelProps) {
|
||||
{tokens.map((token: any, i: number) => {
|
||||
const loadingPrefix = `token-${token.id}`;
|
||||
return (
|
||||
<tr key={token.id} className={`animate-slide-up stagger-${Math.min(i + 1, 5)}`}>
|
||||
<tr
|
||||
key={token.id}
|
||||
data-testid={`token-row-${token.id}`}
|
||||
ref={(node) => {
|
||||
if (node) rowRefs.current.set(token.id, node);
|
||||
else rowRefs.current.delete(token.id);
|
||||
}}
|
||||
onClick={(event) => handleTokenRowClick(token.id, event)}
|
||||
className={`animate-slide-up stagger-${Math.min(i + 1, 5)} row-selectable ${selectedTokenIds.includes(token.id) ? 'row-selected' : ''} ${highlightTokenId === token.id ? 'row-focus-highlight' : ''}`.trim()}
|
||||
>
|
||||
<td>
|
||||
<input
|
||||
data-testid={`token-select-${token.id}`}
|
||||
type="checkbox"
|
||||
checked={selectedTokenIds.includes(token.id)}
|
||||
onChange={(e) => toggleTokenSelection(token.id, e.target.checked)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ fontWeight: 600 }}>{token.name || '-'}</td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 12 }}>{token.tokenMasked || '***'}</td>
|
||||
<td>
|
||||
@@ -582,6 +920,7 @@ export function TokensPanel({ embedded = false }: TokensPanelProps) {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="badge-link"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span className="badge badge-muted" style={{ fontSize: 11 }}>
|
||||
{token.site?.name || 'unknown'}
|
||||
@@ -601,7 +940,7 @@ export function TokensPanel({ embedded = false }: TokensPanelProps) {
|
||||
</td>
|
||||
<td>{token.isDefault ? <span className="badge badge-warning" style={{ fontSize: 11 }}>默认</span> : '-'}</td>
|
||||
<td style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{formatDateTimeLocal(token.updatedAt)}</td>
|
||||
<td className="token-table-actions-col" style={{ textAlign: 'right' }}>
|
||||
<td className="token-actions-cell" style={{ textAlign: 'right' }}>
|
||||
<div className="token-table-actions">
|
||||
{!token.isDefault && (
|
||||
<button
|
||||
@@ -620,9 +959,16 @@ export function TokensPanel({ embedded = false }: TokensPanelProps) {
|
||||
onClick={() => handleCopyToken(token.id, token.name || '')}
|
||||
disabled={!!rowLoading[`${loadingPrefix}-copy`]}
|
||||
className="btn btn-link btn-link-primary token-table-action-btn"
|
||||
data-testid={`token-copy-${token.id}`}
|
||||
>
|
||||
{rowLoading[`${loadingPrefix}-copy`] ? <span className="spinner spinner-sm" /> : '复制'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openEditPanel(token)}
|
||||
className="btn btn-link btn-link-info token-table-action-btn"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => withRowLoading(`${loadingPrefix}-toggle`, async () => {
|
||||
await api.updateAccountToken(token.id, { enabled: !token.enabled });
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import Accounts from './Accounts.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getAccounts: vi.fn(),
|
||||
getSites: vi.fn(),
|
||||
batchUpdateAccounts: vi.fn(),
|
||||
refreshAccountHealth: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('Accounts batch actions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiMock.getSites.mockResolvedValue([
|
||||
{ id: 1, name: 'Site A', platform: 'new-api', status: 'active' },
|
||||
]);
|
||||
apiMock.getAccounts.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
siteId: 1,
|
||||
username: 'alpha',
|
||||
accessToken: 'session-alpha',
|
||||
status: 'active',
|
||||
site: { id: 1, name: 'Site A', status: 'active', platform: 'new-api' },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
siteId: 1,
|
||||
username: 'beta',
|
||||
accessToken: 'session-beta',
|
||||
status: 'active',
|
||||
site: { id: 1, name: 'Site A', status: 'active', platform: 'new-api' },
|
||||
},
|
||||
]);
|
||||
apiMock.batchUpdateAccounts.mockResolvedValue({
|
||||
success: true,
|
||||
successIds: [1, 2],
|
||||
failedItems: [],
|
||||
});
|
||||
apiMock.refreshAccountHealth.mockResolvedValue({ success: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('refreshes balance for selected accounts through the batch toolbar', async () => {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/accounts']}>
|
||||
<ToastProvider>
|
||||
<Accounts />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const checkboxA = root.root.find((node) => node.props['data-testid'] === 'account-select-1');
|
||||
const checkboxB = root.root.find((node) => node.props['data-testid'] === 'account-select-2');
|
||||
await act(async () => {
|
||||
checkboxA.props.onChange({ target: { checked: true } });
|
||||
checkboxB.props.onChange({ target: { checked: true } });
|
||||
});
|
||||
|
||||
const batchButton = root.root.find((node) => node.props['data-testid'] === 'accounts-batch-refresh-balance');
|
||||
await act(async () => {
|
||||
batchButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.batchUpdateAccounts).toHaveBeenCalledWith({
|
||||
ids: [1, 2],
|
||||
action: 'refreshBalance',
|
||||
});
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('selects an account when clicking the row instead of only the checkbox', async () => {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/accounts']}>
|
||||
<ToastProvider>
|
||||
<Accounts />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const row = root.root.find((node) => node.props['data-testid'] === 'account-row-1');
|
||||
await act(async () => {
|
||||
row.props.onClick({ target: { closest: () => null } });
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const checkbox = root.root.find((node) => node.props['data-testid'] === 'account-select-1');
|
||||
expect(checkbox.props.checked).toBe(true);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('selects an apikey connection when clicking the row in the apikey segment', async () => {
|
||||
apiMock.getAccounts.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
siteId: 1,
|
||||
username: 'session-user',
|
||||
accessToken: 'session-alpha',
|
||||
apiToken: 'sk-session',
|
||||
credentialMode: 'session',
|
||||
status: 'active',
|
||||
site: { id: 1, name: 'Site A', status: 'active', platform: 'new-api' },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
siteId: 1,
|
||||
username: '',
|
||||
accessToken: '',
|
||||
apiToken: 'sk-apikey',
|
||||
credentialMode: 'apikey',
|
||||
status: 'active',
|
||||
site: { id: 1, name: 'Site A', status: 'active', platform: 'new-api' },
|
||||
},
|
||||
]);
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/accounts?segment=apikey']}>
|
||||
<ToastProvider>
|
||||
<Accounts />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const row = root.root.find((node) => node.props['data-testid'] === 'account-row-2');
|
||||
await act(async () => {
|
||||
row.props.onClick({ target: { closest: () => null } });
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const checkbox = root.root.find((node) => node.props['data-testid'] === 'account-select-2');
|
||||
expect(checkbox.props.checked).toBe(true);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
describe('Accounts centered modal adoption', () => {
|
||||
it('uses CenteredModal for add/edit/rebind flows instead of inline panel cards', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/Accounts.tsx'), 'utf8');
|
||||
|
||||
expect(source).toContain("import CenteredModal from '../components/CenteredModal.js'");
|
||||
expect(source).toContain('<CenteredModal');
|
||||
expect(source).not.toContain('className={`card panel-presence rebind-panel');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import Accounts from './Accounts.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getAccounts: vi.fn(),
|
||||
getSites: vi.fn(),
|
||||
updateAccount: vi.fn(),
|
||||
refreshAccountHealth: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('Accounts edit panel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiMock.getSites.mockResolvedValue([
|
||||
{ id: 1, name: 'Site A', platform: 'new-api', status: 'active' },
|
||||
]);
|
||||
apiMock.getAccounts.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
siteId: 1,
|
||||
username: 'alpha',
|
||||
accessToken: 'session-alpha',
|
||||
status: 'active',
|
||||
site: { id: 1, name: 'Site A', status: 'active', platform: 'new-api' },
|
||||
},
|
||||
]);
|
||||
apiMock.updateAccount.mockResolvedValue({ success: true });
|
||||
apiMock.refreshAccountHealth.mockResolvedValue({ success: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('opens edit panel from account row action', async () => {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/accounts']}>
|
||||
<ToastProvider>
|
||||
<Accounts />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const editButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).trim() === '编辑'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
editButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const usernameInput = root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props.placeholder === '账号名称'
|
||||
));
|
||||
expect(usernameInput.props.value).toBe('alpha');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -76,7 +76,7 @@ describe('Accounts proxy-only expired state', () => {
|
||||
await flushMicrotasks();
|
||||
|
||||
const rendered = JSON.stringify(root.toJSON());
|
||||
expect(rendered).toContain('仅代理');
|
||||
expect(rendered).not.toContain('仅代理');
|
||||
expect(rendered).not.toContain('访问令牌已过期');
|
||||
|
||||
const actionTexts = root.root.findAll((node) => node.type === 'button').map((node) => collectText(node));
|
||||
|
||||
@@ -30,7 +30,7 @@ async function flushMicrotasks() {
|
||||
});
|
||||
}
|
||||
|
||||
describe('Accounts rebind panel focus', () => {
|
||||
describe('Accounts rebind modal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -39,7 +39,7 @@ describe('Accounts rebind panel focus', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('highlights rebind panel when clicking rebind action', async () => {
|
||||
it('opens centered rebind modal when clicking rebind action', async () => {
|
||||
apiMock.getAccounts.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
@@ -82,12 +82,14 @@ describe('Accounts rebind panel focus', () => {
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const panel = root.root.find((node) => (
|
||||
const modal = root.root.find((node) => (
|
||||
node.type === 'div'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('rebind-panel')
|
||||
&& node.props.className.includes('modal-content')
|
||||
));
|
||||
expect(String(panel.props.className)).toContain('rebind-panel-highlight');
|
||||
expect(String(modal.props.className)).toContain('modal-content');
|
||||
expect(JSON.stringify(root.toJSON())).toContain('重新绑定 Session Token');
|
||||
expect(JSON.stringify(root.toJSON())).toContain('粘贴新的 Session Token');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ describe('Accounts segmented connections view', () => {
|
||||
expect(rendered).toContain('连接管理');
|
||||
expect(rendered).toContain('API Key 连接');
|
||||
expect(rendered).toContain('Key Site');
|
||||
expect(rendered).not.toContain('仅代理');
|
||||
expect(rendered).not.toContain('session-user');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import Accounts from './Accounts.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getAccounts: vi.fn(),
|
||||
getSites: vi.fn(),
|
||||
getAccountTokens: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
const children = node.children || [];
|
||||
return children
|
||||
.map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('Accounts tokens embedded header', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('reuses the accounts page header when showing the 账号令牌 segment', async () => {
|
||||
apiMock.getAccounts.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
username: 'session-user',
|
||||
accessToken: 'session-token',
|
||||
status: 'active',
|
||||
credentialMode: 'session',
|
||||
capabilities: { canCheckin: true, canRefreshBalance: true, proxyOnly: false },
|
||||
site: { id: 10, name: 'Session Site', platform: 'new-api', status: 'active', url: 'https://session.example.com' },
|
||||
},
|
||||
]);
|
||||
apiMock.getSites.mockResolvedValue([
|
||||
{ id: 10, name: 'Session Site', platform: 'new-api', status: 'active' },
|
||||
]);
|
||||
apiMock.getAccountTokens.mockResolvedValue([]);
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/accounts?segment=tokens']}>
|
||||
<ToastProvider>
|
||||
<Accounts />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const pageHeaders = root.root.findAll((node) => {
|
||||
const className = node.props?.className;
|
||||
return typeof className === 'string' && className.split(/\s+/).includes('page-header');
|
||||
});
|
||||
expect(pageHeaders).toHaveLength(1);
|
||||
|
||||
const buttonTexts = root.root
|
||||
.findAll((node) => node.type === 'button')
|
||||
.map((node) => collectText(node));
|
||||
expect(buttonTexts).toContain('同步站点令牌');
|
||||
expect(buttonTexts).toContain('同步全部账号');
|
||||
expect(buttonTexts).toContain('+ 新增令牌');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
const FOCUS_SITE_ID_KEY = 'focusSiteId';
|
||||
const FOCUS_ACCOUNT_ID_KEY = 'focusAccountId';
|
||||
const FOCUS_TOKEN_ID_KEY = 'focusTokenId';
|
||||
const OPEN_REBIND_KEY = 'openRebind';
|
||||
|
||||
function normalizePositiveId(input: unknown): number | null {
|
||||
@@ -22,16 +23,26 @@ export function buildSiteFocusPath(siteId: number): string {
|
||||
|
||||
export function buildAccountFocusPath(
|
||||
accountId: number,
|
||||
options?: { openRebind?: boolean },
|
||||
options?: { openRebind?: boolean; segment?: 'session' | 'apikey' | 'tokens' },
|
||||
): string {
|
||||
const normalizedId = normalizePositiveId(accountId);
|
||||
if (!normalizedId) return '/accounts';
|
||||
const params = new URLSearchParams();
|
||||
if (options?.segment && options.segment !== 'session') params.set('segment', options.segment);
|
||||
params.set(FOCUS_ACCOUNT_ID_KEY, String(normalizedId));
|
||||
if (options?.openRebind) params.set(OPEN_REBIND_KEY, '1');
|
||||
return `/accounts?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function buildTokenFocusPath(tokenId: number): string {
|
||||
const normalizedId = normalizePositiveId(tokenId);
|
||||
if (!normalizedId) return '/accounts?segment=tokens';
|
||||
const params = new URLSearchParams();
|
||||
params.set('segment', 'tokens');
|
||||
params.set(FOCUS_TOKEN_ID_KEY, String(normalizedId));
|
||||
return `/accounts?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function readFocusSiteId(search: string): number | null {
|
||||
const params = new URLSearchParams(search);
|
||||
return normalizePositiveId(params.get(FOCUS_SITE_ID_KEY));
|
||||
@@ -45,10 +56,16 @@ export function readFocusAccountIntent(search: string): { accountId: number | nu
|
||||
};
|
||||
}
|
||||
|
||||
export function readFocusTokenId(search: string): number | null {
|
||||
const params = new URLSearchParams(search);
|
||||
return normalizePositiveId(params.get(FOCUS_TOKEN_ID_KEY));
|
||||
}
|
||||
|
||||
export function clearFocusParams(search: string): string {
|
||||
const params = new URLSearchParams(search);
|
||||
params.delete(FOCUS_SITE_ID_KEY);
|
||||
params.delete(FOCUS_ACCOUNT_ID_KEY);
|
||||
params.delete(FOCUS_TOKEN_ID_KEY);
|
||||
params.delete(OPEN_REBIND_KEY);
|
||||
const next = params.toString();
|
||||
return next ? `?${next}` : '';
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export function shouldIgnoreRowSelectionClick(target: EventTarget | null): boolean {
|
||||
const element = target as { closest?: (selector: string) => unknown } | null;
|
||||
return !!element?.closest?.('button, a, input, textarea, select, label, [role="button"]');
|
||||
}
|
||||
@@ -3,7 +3,7 @@ export type SiteForm = {
|
||||
url: string;
|
||||
externalCheckinUrl: string;
|
||||
platform: string;
|
||||
proxyUrl: string;
|
||||
useSystemProxy: boolean;
|
||||
globalWeight: string;
|
||||
};
|
||||
|
||||
@@ -16,12 +16,12 @@ type SiteSaveAction =
|
||||
| { kind: 'update'; id: number; payload: SiteForm };
|
||||
|
||||
export function emptySiteForm(): SiteForm {
|
||||
return { name: '', url: '', externalCheckinUrl: '', platform: '', proxyUrl: '', globalWeight: '1' };
|
||||
return { name: '', url: '', externalCheckinUrl: '', platform: '', useSystemProxy: false, globalWeight: '1' };
|
||||
}
|
||||
|
||||
export function siteFormFromSite(site: Partial<SiteForm> & {
|
||||
proxyUrl?: string | null;
|
||||
externalCheckinUrl?: string | null;
|
||||
useSystemProxy?: boolean | null;
|
||||
globalWeight?: number | string | null;
|
||||
}): SiteForm {
|
||||
const globalWeightRaw = Number(site.globalWeight);
|
||||
@@ -31,7 +31,7 @@ export function siteFormFromSite(site: Partial<SiteForm> & {
|
||||
url: site.url ?? '',
|
||||
externalCheckinUrl: site.externalCheckinUrl ?? '',
|
||||
platform: site.platform ?? '',
|
||||
proxyUrl: site.proxyUrl ?? '',
|
||||
useSystemProxy: !!site.useSystemProxy,
|
||||
globalWeight,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import Settings from './Settings.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getAuthInfo: vi.fn(),
|
||||
getRuntimeSettings: vi.fn(),
|
||||
getDownstreamApiKeys: vi.fn(),
|
||||
getRoutes: vi.fn(),
|
||||
getRuntimeDatabaseConfig: vi.fn(),
|
||||
updateRuntimeSettings: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
vi.mock('../components/BrandIcon.js', () => ({
|
||||
InlineBrandIcon: () => null,
|
||||
getBrand: () => null,
|
||||
useIconCdn: () => '',
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('Settings system proxy', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiMock.getAuthInfo.mockResolvedValue({ masked: 'sk-****' });
|
||||
apiMock.getRuntimeSettings.mockResolvedValue({
|
||||
checkinCron: '0 8 * * *',
|
||||
balanceRefreshCron: '0 * * * *',
|
||||
routingFallbackUnitCost: 1,
|
||||
routingWeights: {},
|
||||
adminIpAllowlist: [],
|
||||
systemProxyUrl: '',
|
||||
});
|
||||
apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] });
|
||||
apiMock.getRoutes.mockResolvedValue([]);
|
||||
apiMock.getRuntimeDatabaseConfig.mockResolvedValue({
|
||||
active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false },
|
||||
saved: null,
|
||||
restartRequired: false,
|
||||
});
|
||||
apiMock.updateRuntimeSettings.mockResolvedValue({
|
||||
success: true,
|
||||
systemProxyUrl: 'http://127.0.0.1:7890',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('saves system proxy url from settings', async () => {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter>
|
||||
<ToastProvider>
|
||||
<Settings />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const proxyInput = root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props.placeholder === '系统代理 URL(可选,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080)'
|
||||
));
|
||||
await act(async () => {
|
||||
proxyInput.props.onChange({ target: { value: 'http://127.0.0.1:7890' } });
|
||||
});
|
||||
|
||||
const saveButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).trim() === '保存系统代理'
|
||||
));
|
||||
await act(async () => {
|
||||
saveButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.updateRuntimeSettings).toHaveBeenCalledWith({
|
||||
systemProxyUrl: 'http://127.0.0.1:7890',
|
||||
});
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import Sites from './Sites.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getSites: vi.fn(),
|
||||
batchUpdateSites: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('Sites system proxy bulk actions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiMock.getSites.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
name: 'Site A',
|
||||
url: 'https://a.example.com',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
useSystemProxy: false,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Site B',
|
||||
url: 'https://b.example.com',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
useSystemProxy: false,
|
||||
},
|
||||
]);
|
||||
apiMock.batchUpdateSites.mockResolvedValue({
|
||||
success: true,
|
||||
successIds: [1, 2],
|
||||
failedItems: [],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('sends selected site ids to enable system proxy', async () => {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/sites']}>
|
||||
<ToastProvider>
|
||||
<Sites />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const checkboxA = root.root.find((node) => node.props['data-testid'] === 'site-select-1');
|
||||
const checkboxB = root.root.find((node) => node.props['data-testid'] === 'site-select-2');
|
||||
|
||||
await act(async () => {
|
||||
checkboxA.props.onChange({ target: { checked: true } });
|
||||
checkboxB.props.onChange({ target: { checked: true } });
|
||||
});
|
||||
|
||||
const batchButton = root.root.find((node) => node.props['data-testid'] === 'sites-batch-enable-system-proxy');
|
||||
await act(async () => {
|
||||
batchButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.batchUpdateSites).toHaveBeenCalledWith({
|
||||
ids: [1, 2],
|
||||
action: 'enableSystemProxy',
|
||||
});
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('selects a site when clicking the row instead of only the checkbox', async () => {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/sites']}>
|
||||
<ToastProvider>
|
||||
<Sites />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const row = root.root.find((node) => node.props['data-testid'] === 'site-row-1');
|
||||
await act(async () => {
|
||||
row.props.onClick({ target: { closest: () => null } });
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const checkbox = root.root.find((node) => node.props['data-testid'] === 'site-select-1');
|
||||
expect(checkbox.props.checked).toBe(true);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
describe('Tokens actions layout', () => {
|
||||
it('reuses a wrapping actions cell layout so token row actions do not overflow', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/Tokens.tsx'), 'utf8');
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8');
|
||||
|
||||
expect(source).toContain('className="token-actions-cell"');
|
||||
expect(css).toContain('.token-actions-cell');
|
||||
expect(css).toContain('.token-table-actions {');
|
||||
expect(css).toContain('flex-wrap: wrap;');
|
||||
expect(css).toContain('.token-table {');
|
||||
expect(css).toContain('table-layout: fixed;');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import { TokensPanel } from './Tokens.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getAccountTokens: vi.fn(),
|
||||
getAccounts: vi.fn(),
|
||||
getAccountTokenGroups: vi.fn(),
|
||||
batchUpdateAccountTokens: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('Tokens batch actions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiMock.getAccountTokens.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
accountId: 1,
|
||||
name: 'token-1',
|
||||
tokenMasked: 'sk-***1',
|
||||
enabled: true,
|
||||
isDefault: false,
|
||||
account: { username: 'alpha' },
|
||||
site: { name: 'Site A', url: 'https://site-a.example.com' },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
accountId: 1,
|
||||
name: 'token-2',
|
||||
tokenMasked: 'sk-***2',
|
||||
enabled: true,
|
||||
isDefault: false,
|
||||
account: { username: 'alpha' },
|
||||
site: { name: 'Site A', url: 'https://site-a.example.com' },
|
||||
},
|
||||
]);
|
||||
apiMock.getAccounts.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
username: 'alpha',
|
||||
accessToken: 'session-alpha',
|
||||
status: 'active',
|
||||
site: { id: 1, name: 'Site A', status: 'active' },
|
||||
},
|
||||
]);
|
||||
apiMock.getAccountTokenGroups.mockResolvedValue({ groups: ['default'] });
|
||||
apiMock.batchUpdateAccountTokens.mockResolvedValue({
|
||||
success: true,
|
||||
successIds: [1, 2],
|
||||
failedItems: [],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('deletes selected tokens through the batch toolbar', async () => {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<ToastProvider>
|
||||
<MemoryRouter initialEntries={['/accounts?segment=tokens']}>
|
||||
<TokensPanel />
|
||||
</MemoryRouter>
|
||||
</ToastProvider>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const checkboxA = root.root.find((node) => node.props['data-testid'] === 'token-select-1');
|
||||
const checkboxB = root.root.find((node) => node.props['data-testid'] === 'token-select-2');
|
||||
await act(async () => {
|
||||
checkboxA.props.onChange({ target: { checked: true } });
|
||||
checkboxB.props.onChange({ target: { checked: true } });
|
||||
});
|
||||
|
||||
const batchButton = root.root.find((node) => node.props['data-testid'] === 'tokens-batch-delete');
|
||||
const originalConfirm = globalThis.confirm;
|
||||
globalThis.confirm = vi.fn(() => true);
|
||||
try {
|
||||
await act(async () => {
|
||||
batchButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
} finally {
|
||||
globalThis.confirm = originalConfirm;
|
||||
}
|
||||
|
||||
expect(apiMock.batchUpdateAccountTokens).toHaveBeenCalledWith({
|
||||
ids: [1, 2],
|
||||
action: 'delete',
|
||||
});
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('selects a token when clicking the row instead of only the checkbox', async () => {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<ToastProvider>
|
||||
<MemoryRouter initialEntries={['/accounts?segment=tokens']}>
|
||||
<TokensPanel />
|
||||
</MemoryRouter>
|
||||
</ToastProvider>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const row = root.root.find((node) => node.props['data-testid'] === 'token-row-1');
|
||||
await act(async () => {
|
||||
row.props.onClick({ target: { closest: () => null } });
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const checkbox = root.root.find((node) => node.props['data-testid'] === 'token-select-1');
|
||||
expect(checkbox.props.checked).toBe(true);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
describe('Tokens centered modal adoption', () => {
|
||||
it('uses CenteredModal for add/edit token flows instead of inline form cards', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/Tokens.tsx'), 'utf8');
|
||||
|
||||
expect(source).toContain("import CenteredModal from '../components/CenteredModal.js'");
|
||||
expect(source).toContain('<CenteredModal');
|
||||
expect(source).not.toContain('addPanelPresence.shouldRender && (');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import Accounts from './Accounts.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getAccounts: vi.fn(),
|
||||
getSites: vi.fn(),
|
||||
getAccountTokens: vi.fn(),
|
||||
getAccountTokenValue: vi.fn(),
|
||||
updateAccountToken: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
const children = node.children || [];
|
||||
return children
|
||||
.map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function buildRoot() {
|
||||
return create(
|
||||
<MemoryRouter initialEntries={['/accounts?segment=tokens']}>
|
||||
<ToastProvider>
|
||||
<Accounts />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
{
|
||||
createNodeMock: (element) => {
|
||||
if (element.type === 'tr' || element.type === 'div') {
|
||||
return {
|
||||
scrollIntoView: () => undefined,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe('Tokens edit modal and row selection', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: {
|
||||
clipboard: {
|
||||
writeText: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
},
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
apiMock.getAccounts.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
username: 'session-user',
|
||||
accessToken: 'session-token',
|
||||
status: 'active',
|
||||
credentialMode: 'session',
|
||||
capabilities: { canCheckin: true, canRefreshBalance: true, proxyOnly: false },
|
||||
site: { id: 10, name: 'Session Site', platform: 'new-api', status: 'active', url: 'https://session.example.com' },
|
||||
},
|
||||
]);
|
||||
apiMock.getSites.mockResolvedValue([
|
||||
{ id: 10, name: 'Session Site', platform: 'new-api', status: 'active' },
|
||||
]);
|
||||
apiMock.getAccountTokens.mockResolvedValue([
|
||||
{
|
||||
id: 22,
|
||||
name: 'focus-token',
|
||||
tokenMasked: 'sk-focus****',
|
||||
enabled: true,
|
||||
isDefault: false,
|
||||
updatedAt: '2026-03-07 10:00:00',
|
||||
accountId: 1,
|
||||
account: { username: 'session-user' },
|
||||
site: { name: 'Session Site', url: 'https://session.example.com' },
|
||||
},
|
||||
]);
|
||||
apiMock.getAccountTokenValue.mockResolvedValue({
|
||||
success: true,
|
||||
token: 'sk-focus-real',
|
||||
});
|
||||
apiMock.updateAccountToken.mockResolvedValue({
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('opens the centered edit modal when editing a token', async () => {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = buildRoot();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const editButton = root.root
|
||||
.findAll((node) => node.type === 'button')
|
||||
.find((node) => collectText(node).includes('编辑'));
|
||||
expect(editButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
editButton!.props.onClick({ stopPropagation: () => undefined });
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const rendered = JSON.stringify(root.toJSON());
|
||||
expect(rendered).toContain('编辑令牌');
|
||||
expect(rendered).toContain('保存修改');
|
||||
expect(rendered).toContain('sk-focus-real');
|
||||
expect(rendered).toContain('基本信息');
|
||||
expect(rendered).toContain('状态设置');
|
||||
const modals = root.root.findAll((node) => {
|
||||
const className = typeof node.props?.className === 'string' ? node.props.className : '';
|
||||
return className.includes('modal-content') && collectText(node).includes('编辑令牌');
|
||||
});
|
||||
expect(modals).toHaveLength(1);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('selects a token when clicking the row body, but not when clicking an action button', async () => {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = buildRoot();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const tokenRow = root.root.findAll((node) => {
|
||||
if (node.type !== 'tr') return false;
|
||||
return collectText(node).includes('focus-token');
|
||||
})[0];
|
||||
expect(tokenRow).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
tokenRow.props.onClick({
|
||||
target: { closest: () => null },
|
||||
});
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(JSON.stringify(root.toJSON())).toContain('已选 ');
|
||||
expect(JSON.stringify(root.toJSON())).toContain('"1"');
|
||||
|
||||
const copyButton = root.root
|
||||
.findAll((node) => node.type === 'button')
|
||||
.find((node) => collectText(node).includes('复制'));
|
||||
expect(copyButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
copyButton!.props.onClick({ stopPropagation: () => undefined });
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(JSON.stringify(root.toJSON())).toContain('已选 ');
|
||||
expect(JSON.stringify(root.toJSON())).toContain('"1"');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
describe('Tokens embedded layout', () => {
|
||||
it('reuses accounts-page-actions layout when embedded in 连接管理', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/Tokens.tsx'), 'utf8');
|
||||
|
||||
expect(source).toContain("className={`page-actions ${embedded ? 'accounts-page-actions' : ''}`.trim()}");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import Accounts from './Accounts.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getAccounts: vi.fn(),
|
||||
getSites: vi.fn(),
|
||||
getAccountTokens: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
const children = node.children || [];
|
||||
return children
|
||||
.map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('Tokens focus navigation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('highlights the focused token row in the 账号令牌 segment', async () => {
|
||||
apiMock.getAccounts.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
username: 'session-user',
|
||||
accessToken: 'session-token',
|
||||
status: 'active',
|
||||
credentialMode: 'session',
|
||||
capabilities: { canCheckin: true, canRefreshBalance: true, proxyOnly: false },
|
||||
site: { id: 10, name: 'Session Site', platform: 'new-api', status: 'active', url: 'https://session.example.com' },
|
||||
},
|
||||
]);
|
||||
apiMock.getSites.mockResolvedValue([
|
||||
{ id: 10, name: 'Session Site', platform: 'new-api', status: 'active' },
|
||||
]);
|
||||
apiMock.getAccountTokens.mockResolvedValue([
|
||||
{
|
||||
id: 22,
|
||||
name: 'focus-token',
|
||||
tokenMasked: 'sk-focus****',
|
||||
enabled: true,
|
||||
isDefault: false,
|
||||
updatedAt: '2026-03-07 10:00:00',
|
||||
accountId: 1,
|
||||
account: { username: 'session-user' },
|
||||
site: { name: 'Session Site', url: 'https://session.example.com' },
|
||||
},
|
||||
]);
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/accounts?segment=tokens&focusTokenId=22']}>
|
||||
<ToastProvider>
|
||||
<Accounts />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
{
|
||||
createNodeMock: (element) => {
|
||||
if (element.type === 'tr') {
|
||||
return {
|
||||
scrollIntoView: () => undefined,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const highlightedRow = root.root.findAll((node) => {
|
||||
if (node.type !== 'tr') return false;
|
||||
const className = typeof node.props?.className === 'string' ? node.props.className : '';
|
||||
return className.includes('row-focus-highlight') && collectText(node).includes('focus-token');
|
||||
});
|
||||
expect(highlightedRow).toHaveLength(1);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user