feat: add delete confirmation modal and enhance token handling

- Implemented a DeleteConfirmModal component for confirming deletions across various pages (Accounts, Sites, Tokens).
- Updated Accounts and Sites pages to utilize the new modal for single and batch deletions.
- Enhanced TokensPanel to support group selection and improved token deletion handling with confirmation.
- Added functionality to skip confirmation for batch actions and handle token group fetching with error management.
- Improved error handling for expired sessions in NewApiAdapter and OneApiAdapter.
- Added tests for new delete confirmation behavior and token editing interactions.
This commit is contained in:
ciat-777
2026-03-14 13:31:15 +08:00
parent ad791d87bb
commit 1a1f0452b3
18 changed files with 595 additions and 58 deletions
+4
View File
@@ -24,7 +24,11 @@ async function request(url: string, options: RequestOptions = {}) {
const token = getAuthToken(localStorage);
if (!token) {
const hadToken = !!localStorage.getItem('auth_token');
clearAuthSession(localStorage);
if (hadToken && typeof window !== 'undefined' && typeof window.location?.reload === 'function') {
window.location.reload();
}
throw new Error('Session expired');
}
const headers: Record<string, string> = {
+49
View File
@@ -0,0 +1,49 @@
import React from 'react';
import CenteredModal from './CenteredModal.js';
type DeleteConfirmModalProps = {
open: boolean;
title?: string;
description: React.ReactNode;
confirmText?: string;
cancelText?: string;
loading?: boolean;
onConfirm: () => void;
onClose: () => void;
};
export default function DeleteConfirmModal({
open,
title = '确认删除',
description,
confirmText = '确认删除',
cancelText = '取消',
loading = false,
onConfirm,
onClose,
}: DeleteConfirmModalProps) {
return (
<CenteredModal
open={open}
onClose={onClose}
title={title}
maxWidth={560}
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
footer={(
<>
<button onClick={onClose} className="btn btn-ghost" disabled={loading}>{cancelText}</button>
<button onClick={onConfirm} className="btn btn-danger" disabled={loading}>
{loading
? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 删除中...</>
: confirmText}
</button>
</>
)}
>
<div className="alert alert-error" style={{ margin: 0 }}>
<div className="alert-title">此操作不可撤销</div>
<div style={{ marginTop: 6, fontSize: 13 }}>{description}</div>
</div>
</CenteredModal>
);
}
+41 -5
View File
@@ -6,6 +6,7 @@ import { useToast } from '../components/Toast.js';
import ModernSelect from '../components/ModernSelect.js';
import { MobileCard, MobileField } from '../components/MobileCard.js';
import { useIsMobile } from '../components/useIsMobile.js';
import DeleteConfirmModal from '../components/DeleteConfirmModal.js';
import {
buildAddAccountPrereqHint,
buildVerifyFailureHint,
@@ -91,6 +92,12 @@ export default function Accounts() {
const [embeddedTokenActions, setEmbeddedTokenActions] = useState<React.ReactNode>(null);
const [selectedAccountIds, setSelectedAccountIds] = useState<number[]>([]);
const [batchActionLoading, setBatchActionLoading] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState<null | {
mode: 'single' | 'batch';
accountId?: number;
accountName?: string;
count?: number;
}>(null);
const [editingAccount, setEditingAccount] = useState<any | null>(null);
const [editForm, setEditForm] = useState({
username: '',
@@ -127,7 +134,8 @@ export default function Accounts() {
setAccounts(nextAccounts);
setSelectedAccountIds((current) => current.filter((id) => nextAccounts.some((account: any) => account.id === id)));
} else {
toast.error('加载账号列表失败');
const reason = (accountsResult as PromiseRejectedResult).reason as any;
toast.error(reason?.message || '加载账号列表失败');
}
if (sitesResult.status === 'fulfilled') {
setSites(sitesResult.value || []);
@@ -586,9 +594,12 @@ export default function Accounts() {
));
};
const runBatchAccountAction = async (action: 'enable' | 'disable' | 'delete' | 'refreshBalance') => {
const runBatchAccountAction = async (action: 'enable' | 'disable' | 'delete' | 'refreshBalance', skipDeleteConfirm = false) => {
if (selectedAccountIds.length === 0) return;
if (action === 'delete' && typeof globalThis.confirm === 'function' && !globalThis.confirm(`确认删除选中的 ${selectedAccountIds.length} 个账号?`)) return;
if (action === 'delete' && !skipDeleteConfirm) {
setDeleteConfirm({ mode: 'batch', count: selectedAccountIds.length });
return;
}
setBatchActionLoading(true);
try {
@@ -612,6 +623,19 @@ export default function Accounts() {
}
};
const confirmDelete = async () => {
const target = deleteConfirm;
if (!target) return;
setDeleteConfirm(null);
if (target.mode === 'single' && target.accountId) {
await withLoading(`delete-${target.accountId}`, () => api.deleteAccount(target.accountId!), '已删除');
return;
}
await runBatchAccountAction('delete', true);
};
const handleAccountRowClick = (accountId: number, event: React.MouseEvent<HTMLTableRowElement>) => {
if (shouldIgnoreRowSelectionClick(event.target)) return;
const isSelected = selectedAccountIds.includes(accountId);
@@ -831,6 +855,18 @@ export default function Accounts() {
))}
</div>
<DeleteConfirmModal
open={Boolean(deleteConfirm)}
onClose={() => setDeleteConfirm(null)}
onConfirm={confirmDelete}
title="确认删除连接"
confirmText="确认删除"
loading={batchActionLoading || (deleteConfirm?.mode === 'single' && !!actionLoading[`delete-${deleteConfirm?.accountId}`])}
description={deleteConfirm?.mode === 'single'
? <>确定要删除连接 <strong>{deleteConfirm.accountName || `#${deleteConfirm.accountId}`}</strong> 吗?</>
: <>确定要删除选中的 <strong>{deleteConfirm?.count || 0}</strong> 个连接吗?</>}
/>
{!isMobile && 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>
@@ -1554,7 +1590,7 @@ export default function Accounts() {
<button onClick={() => openEditPanel(a)} className="btn btn-link btn-link-info">
编辑
</button>
<button onClick={() => withLoading(`delete-${a.id}`, () => api.deleteAccount(a.id), '已删除')} disabled={actionLoading[`delete-${a.id}`]} className="btn btn-link btn-link-danger">
<button onClick={() => setDeleteConfirm({ mode: 'single', accountId: a.id, accountName: resolveAccountDisplayName(a) })} disabled={actionLoading[`delete-${a.id}`]} className="btn btn-link btn-link-danger">
{actionLoading[`delete-${a.id}`] ? <span className="spinner spinner-sm" /> : '删除'}
</button>
</div>
@@ -1740,7 +1776,7 @@ export default function Accounts() {
<button onClick={() => openEditPanel(a)} className="btn btn-link btn-link-info">
编辑
</button>
<button onClick={() => withLoading(`delete-${a.id}`, () => api.deleteAccount(a.id), '已删除')} disabled={actionLoading[`delete-${a.id}`]} className="btn btn-link btn-link-danger">
<button onClick={() => setDeleteConfirm({ mode: 'single', accountId: a.id, accountName: resolveAccountDisplayName(a) })} disabled={actionLoading[`delete-${a.id}`]} className="btn btn-link btn-link-danger">
{actionLoading[`delete-${a.id}`] ? <span className="spinner spinner-sm" /> : '删除'}
</button>
</div>
+48 -13
View File
@@ -6,6 +6,7 @@ import { useToast } from '../components/Toast.js';
import ModernSelect from '../components/ModernSelect.js';
import { MobileCard, MobileField } from '../components/MobileCard.js';
import { useIsMobile } from '../components/useIsMobile.js';
import DeleteConfirmModal from '../components/DeleteConfirmModal.js';
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
import { clearFocusParams, readFocusSiteId } from './helpers/navigationFocus.js';
import { tr } from '../i18n.js';
@@ -90,6 +91,12 @@ export default function Sites() {
const [expandedSiteIds, setExpandedSiteIds] = useState<number[]>([]);
const isMobile = useIsMobile(768);
const [batchActionLoading, setBatchActionLoading] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState<null | {
mode: 'single' | 'batch';
siteId?: number;
siteName?: string;
count?: number;
}>(null);
const lastEditorRef = useRef<SiteEditorState | null>(null);
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
const highlightTimerRef = useRef<number | null>(null);
@@ -98,7 +105,7 @@ export default function Sites() {
if (editor) lastEditorRef.current = editor;
const activeEditor = editor || lastEditorRef.current;
const isEditing = activeEditor?.mode === 'edit';
const isAdding = activeEditor?.mode === 'add';
const isAdding = editor?.mode === 'add';
const formInputStyle = {
width: '100%',
padding: '10px 14px',
@@ -310,16 +317,7 @@ export default function Sites() {
};
const handleDelete = async (site: SiteRow) => {
setDeleting(site.id);
try {
await api.deleteSite(site.id);
toast.success(`站点 "${site.name}" 已删除`);
await load();
} catch (e: any) {
toast.error(e.message || '删除失败');
} finally {
setDeleting(null);
}
setDeleteConfirm({ mode: 'single', siteId: site.id, siteName: site.name });
};
const handleToggleStatus = async (site: SiteRow) => {
@@ -389,9 +387,12 @@ export default function Sites() {
));
};
const runBatchAction = async (action: 'enable' | 'disable' | 'delete' | 'enableSystemProxy' | 'disableSystemProxy') => {
const runBatchAction = async (action: 'enable' | 'disable' | 'delete' | 'enableSystemProxy' | 'disableSystemProxy', skipDeleteConfirm = false) => {
if (selectedSiteIds.length === 0) return;
if (action === 'delete' && typeof globalThis.confirm === 'function' && !globalThis.confirm(`确认删除选中的 ${selectedSiteIds.length} 个站点?`)) return;
if (action === 'delete' && !skipDeleteConfirm) {
setDeleteConfirm({ mode: 'batch', count: selectedSiteIds.length });
return;
}
setBatchActionLoading(true);
try {
@@ -415,6 +416,28 @@ export default function Sites() {
}
};
const confirmDelete = async () => {
const target = deleteConfirm;
if (!target) return;
setDeleteConfirm(null);
if (target.mode === 'single' && target.siteId) {
setDeleting(target.siteId);
try {
await api.deleteSite(target.siteId);
toast.success(`站点 "${target.siteName || target.siteId}" 已删除`);
await load();
} catch (e: any) {
toast.error(e.message || '删除失败');
} finally {
setDeleting(null);
}
return;
}
await runBatchAction('delete', true);
};
const handleSiteRowClick = (siteId: number, event: React.MouseEvent<HTMLTableRowElement>) => {
if (shouldIgnoreRowSelectionClick(event.target)) return;
const isSelected = selectedSiteIds.includes(siteId);
@@ -515,6 +538,18 @@ export default function Sites() {
站点权重说明:最终站点倍率 = 站点全局权重 × 设置页中下游 API Key 的站点倍率。它会与路由策略因子(基础权重、价值分、成本、余额、使用频次)共同作用。数值越大,该站点在同优先级下越容易被选中。建议范围 0.5-3,默认 1;长期不建议超过 5。
</div>
<DeleteConfirmModal
open={Boolean(deleteConfirm)}
onClose={() => setDeleteConfirm(null)}
onConfirm={confirmDelete}
title="确认删除站点"
confirmText="确认删除"
loading={batchActionLoading || (deleteConfirm?.mode === 'single' && deleting === deleteConfirm?.siteId)}
description={deleteConfirm?.mode === 'single'
? <>确定要删除站点 <strong>{deleteConfirm.siteName || `#${deleteConfirm.siteId}`}</strong> 吗?</>
: <>确定要删除选中的 <strong>{deleteConfirm?.count || 0}</strong> 个站点吗?</>}
/>
{activeEditor && (
<CenteredModal
open={Boolean(editor)}
+137 -26
View File
@@ -7,6 +7,7 @@ import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
import ModernSelect from '../components/ModernSelect.js';
import { MobileCard, MobileField } from '../components/MobileCard.js';
import { useIsMobile } from '../components/useIsMobile.js';
import DeleteConfirmModal from '../components/DeleteConfirmModal.js';
import { clearFocusParams, readFocusTokenId } from './helpers/navigationFocus.js';
import { shouldIgnoreRowSelectionClick } from './helpers/rowSelection.js';
import { tr } from '../i18n.js';
@@ -134,15 +135,24 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
const [rowLoading, setRowLoading] = useState<Record<string, boolean>>({});
const [selectedTokenIds, setSelectedTokenIds] = useState<number[]>([]);
const [batchActionLoading, setBatchActionLoading] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState<null | {
mode: 'single' | 'batch';
tokenId?: number;
tokenName?: string;
count?: number;
}>(null);
const [form, setForm] = useState(initialCreateForm);
const [editForm, setEditForm] = useState({
name: '',
token: '',
group: 'default',
enabled: true,
isDefault: false,
});
const [groupOptions, setGroupOptions] = useState<string[]>(['default']);
const [groupLoading, setGroupLoading] = useState(false);
const [editGroupOptions, setEditGroupOptions] = useState<string[]>(['default']);
const [editGroupLoading, setEditGroupLoading] = useState(false);
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const editingTokenIdRef = useRef<number | null>(null);
@@ -221,6 +231,60 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
};
}, [showAdd, form.accountId]);
useEffect(() => {
if (!editingToken?.id || !editingToken?.accountId) {
setEditGroupLoading(false);
setEditGroupOptions(['default']);
return;
}
const currentGroup = (editingToken?.tokenGroup || '').trim() || 'default';
let cancelled = false;
setEditGroupLoading(true);
api.getAccountTokenGroups(editingToken.accountId)
.then((res: any) => {
if (cancelled) return;
const groups = Array.isArray(res?.groups)
? res.groups.map((item: any) => String(item || '').trim()).filter(Boolean)
: [];
const normalized = Array.from(new Set(groups));
setEditGroupOptions((current) => {
const next = normalized.length > 0 ? normalized : ['default'];
if (next.includes(currentGroup)) return next;
return [...next, currentGroup];
});
})
.catch((error: any) => {
if (cancelled) return;
setEditGroupOptions((current) => (current.includes(currentGroup) ? current : [...current, currentGroup]));
toast.error(error?.message || '拉取分组失败,已保留当前分组');
})
.finally(() => {
if (cancelled) return;
setEditGroupLoading(false);
});
return () => {
cancelled = true;
};
}, [editingToken?.id, editingToken?.accountId]);
const accountClusteredTokens = useMemo(() => {
const accountLabel = (token: any) => String(token?.account?.username || `account-${token?.accountId || 0}`).toLowerCase();
const siteLabel = (token: any) => String(token?.site?.name || '').toLowerCase();
const tokenName = (token: any) => String(token?.name || '').toLowerCase();
return [...tokens].sort((left, right) => {
const accountCmp = accountLabel(left).localeCompare(accountLabel(right));
if (accountCmp !== 0) return accountCmp;
const siteCmp = siteLabel(left).localeCompare(siteLabel(right));
if (siteCmp !== 0) return siteCmp;
const nameCmp = tokenName(left).localeCompare(tokenName(right));
if (nameCmp !== 0) return nameCmp;
return Number(left?.id || 0) - Number(right?.id || 0);
});
}, [tokens]);
const activeAccounts = useMemo(() => accounts.filter(isAccountSyncable), [accounts]);
useEffect(() => {
@@ -304,12 +368,15 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
setSelectedTokenIds([]);
return;
}
setSelectedTokenIds(tokens.map((token) => token.id));
setSelectedTokenIds(accountClusteredTokens.map((token) => token.id));
};
const runBatchTokenAction = async (action: 'enable' | 'disable' | 'delete') => {
const runBatchTokenAction = async (action: 'enable' | 'disable' | 'delete', skipDeleteConfirm = false) => {
if (selectedTokenIds.length === 0) return;
if (action === 'delete' && typeof globalThis.confirm === 'function' && !globalThis.confirm(`确认删除选中的 ${selectedTokenIds.length} 个令牌?`)) return;
if (action === 'delete' && !skipDeleteConfirm) {
setDeleteConfirm({ mode: 'batch', count: selectedTokenIds.length });
return;
}
setBatchActionLoading(true);
try {
@@ -333,6 +400,23 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
}
};
const confirmDelete = async () => {
const target = deleteConfirm;
if (!target) return;
setDeleteConfirm(null);
if (target.mode === 'single' && target.tokenId) {
await withRowLoading(`token-${target.tokenId}-delete`, async () => {
await api.deleteAccountToken(target.tokenId!);
toast.success('令牌已删除');
await load();
});
return;
}
await runBatchTokenAction('delete', true);
};
const openEditPanel = useCallback((token: any) => {
setShowAdd(false);
setCreateHintModelName('');
@@ -341,6 +425,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
setEditForm({
name: token?.name || '',
token: '',
group: (token?.tokenGroup || '').trim() || 'default',
enabled: token?.enabled !== false,
isDefault: !!token?.isDefault,
});
@@ -372,6 +457,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
setEditForm({
name: '',
token: '',
group: 'default',
enabled: true,
isDefault: false,
});
@@ -384,6 +470,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
await api.updateAccountToken(editingToken.id, {
name: editForm.name.trim() || editingToken.name,
token: editForm.token.trim() || undefined,
group: editForm.group || 'default',
enabled: editForm.enabled,
isDefault: editForm.isDefault,
});
@@ -404,17 +491,21 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
};
const handleCopyToken = async (tokenId: number, tokenName: string) => {
await withRowLoading(`token-${tokenId}-copy`, async () => {
const res = await api.getAccountTokenValue(tokenId);
const tokenValue = (res?.token || '').trim();
if (!tokenValue) {
toast.error('令牌为空,无法复制');
return;
}
try {
await withRowLoading(`token-${tokenId}-copy`, async () => {
const res = await api.getAccountTokenValue(tokenId);
const tokenValue = (res?.token || '').trim();
if (!tokenValue) {
toast.error('令牌为空,无法复制');
return;
}
await copyText(tokenValue);
toast.success(`已复制令牌:${tokenName || `token-${tokenId}`}`);
});
await copyText(tokenValue);
toast.success(`已复制令牌:${tokenName || `token-${tokenId}`}`);
});
} catch (error: any) {
toast.error(error?.message || '复制令牌失败');
}
};
const handleAddToken = async () => {
@@ -639,6 +730,18 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
新增令牌会调用站点 API 创建新密钥,再自动同步到本地。支持设置分组、额度、过期时间和 IP 白名单;已存在密钥可直接用“同步站点令牌”读取。
</div>
<DeleteConfirmModal
open={Boolean(deleteConfirm)}
onClose={() => setDeleteConfirm(null)}
onConfirm={confirmDelete}
title="确认删除令牌"
confirmText="确认删除"
loading={batchActionLoading || (deleteConfirm?.mode === 'single' && !!rowLoading[`token-${deleteConfirm?.tokenId}-delete`])}
description={deleteConfirm?.mode === 'single'
? <>确定要删除令牌 <strong>{deleteConfirm.tokenName || `#${deleteConfirm.tokenId}`}</strong> 吗?</>
: <>确定要删除选中的 <strong>{deleteConfirm?.count || 0}</strong> 个令牌吗?</>}
/>
<CenteredModal
open={Boolean(editingToken)}
onClose={closeEditPanel}
@@ -695,6 +798,19 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
disabled={editingTokenValueLoading}
/>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>分组</div>
<ModernSelect
value={editForm.group || 'default'}
onChange={(nextValue) => setEditForm((prev) => ({ ...prev, group: nextValue || 'default' }))}
options={(editGroupOptions.length > 0 ? editGroupOptions : ['default']).map((group) => ({
value: group,
label: group,
}))}
placeholder={editGroupLoading ? '分组加载中...' : '选择分组'}
disabled={editGroupLoading}
/>
</div>
</div>
<div style={sectionCardStyle}>
<div style={sectionLabelStyle}>状态设置</div>
@@ -890,7 +1006,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
) : tokens.length > 0 ? (
isMobile ? (
<div className="mobile-card-list">
{tokens.map((token: any) => {
{accountClusteredTokens.map((token: any) => {
const loadingPrefix = `token-${token.id}`;
return (
<MobileCard
@@ -929,6 +1045,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
)}
/>
<MobileField label="账号" value={token.account?.username || `account-${token.accountId}`} />
<MobileField label="分组" value={token.tokenGroup || 'default'} />
<MobileField
label="状态"
value={(
@@ -982,11 +1099,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
{rowLoading[`${loadingPrefix}-toggle`] ? <span className="spinner spinner-sm" /> : (token.enabled ? '禁用' : '启用')}
</button>
<button
onClick={() => withRowLoading(`${loadingPrefix}-delete`, async () => {
await api.deleteAccountToken(token.id);
toast.success('令牌已删除');
await load();
})}
onClick={() => setDeleteConfirm({ mode: 'single', tokenId: token.id, tokenName: token.name || '' })}
disabled={!!rowLoading[`${loadingPrefix}-delete`]}
className="btn btn-link btn-link-danger"
>
@@ -1004,7 +1117,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
<th style={{ width: 44 }}>
<input
type="checkbox"
checked={tokens.length > 0 && selectedTokenIds.length === tokens.length}
checked={accountClusteredTokens.length > 0 && selectedTokenIds.length === accountClusteredTokens.length}
onChange={(e) => toggleSelectAllTokens(e.target.checked)}
/>
</th>
@@ -1012,6 +1125,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
<th>令牌值</th>
<th>来源站点</th>
<th>账号</th>
<th>分组</th>
<th>状态</th>
<th>默认</th>
<th>更新时间</th>
@@ -1019,7 +1133,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
</tr>
</thead>
<tbody>
{tokens.map((token: any, i: number) => {
{accountClusteredTokens.map((token: any, i: number) => {
const loadingPrefix = `token-${token.id}`;
return (
<tr
@@ -1063,6 +1177,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
)}
</td>
<td>{token.account?.username || `account-${token.accountId}`}</td>
<td>{token.tokenGroup || 'default'}</td>
<td>
<span className={`badge ${token.enabled ? 'badge-success' : 'badge-muted'}`} style={{ fontSize: 11 }}>
{token.enabled ? '启用' : '禁用'}
@@ -1111,11 +1226,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
{rowLoading[`${loadingPrefix}-toggle`] ? <span className="spinner spinner-sm" /> : (token.enabled ? '禁用' : '启用')}
</button>
<button
onClick={() => withRowLoading(`${loadingPrefix}-delete`, async () => {
await api.deleteAccountToken(token.id);
toast.success('令牌已删除');
await load();
})}
onClick={() => setDeleteConfirm({ mode: 'single', tokenId: token.id, tokenName: token.name || '' })}
disabled={!!rowLoading[`${loadingPrefix}-delete`]}
className="btn btn-link btn-link-danger token-table-action-btn"
>
+55
View File
@@ -85,4 +85,59 @@ describe('Sites edit behavior', () => {
root?.unmount();
}
});
it('restores header add button label after closing add modal', async () => {
apiMock.getSites.mockResolvedValue([]);
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/sites']}>
<ToastProvider>
<Sites />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const openAddButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& typeof node.props.className === 'string'
&& node.props.className.includes('btn btn-primary')
&& collectText(node).includes('添加站点')
));
await act(async () => {
openAddButton.props.onClick();
});
await flushMicrotasks();
const closeModalButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& typeof node.props.className === 'string'
&& node.props.className.includes('btn btn-ghost')
&& collectText(node).trim() === '取消'
));
await act(async () => {
closeModalButton.props.onClick();
});
await flushMicrotasks();
const headerAddButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.className === 'string'
&& node.props.className.includes('btn btn-primary')
&& collectText(node).includes('添加站点')
));
expect(collectText(headerAddButton)).toContain('添加站点');
} finally {
root?.unmount();
}
});
});
+14 -10
View File
@@ -92,16 +92,20 @@ describe('Tokens batch actions', () => {
});
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;
}
await act(async () => {
batchButton.props.onClick();
});
await flushMicrotasks();
const confirmButton = root.root
.findAll((node) => node.type === 'button')
.find((node) => Array.isArray(node.children) && node.children.some((child) => child === '确认删除'));
expect(confirmButton).toBeTruthy();
await act(async () => {
confirmButton!.props.onClick();
});
await flushMicrotasks();
expect(apiMock.batchUpdateAccountTokens).toHaveBeenCalledWith({
ids: [1, 2],
@@ -10,6 +10,7 @@ const { apiMock } = vi.hoisted(() => ({
getSites: vi.fn(),
getAccountTokens: vi.fn(),
getAccountTokenValue: vi.fn(),
getAccountTokenGroups: vi.fn(),
updateAccountToken: vi.fn(),
},
}));
@@ -98,6 +99,10 @@ describe('Tokens edit modal and row selection', () => {
success: true,
token: 'sk-focus-real',
});
apiMock.getAccountTokenGroups.mockResolvedValue({
success: true,
groups: ['default', 'vip'],
});
apiMock.updateAccountToken.mockResolvedValue({
success: true,
});
@@ -131,11 +136,25 @@ describe('Tokens edit modal and row selection', () => {
expect(rendered).toContain('sk-focus-real');
expect(rendered).toContain('基本信息');
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);
expect(apiMock.getAccountTokenGroups).toHaveBeenCalledWith(1);
const saveButton = root.root
.findAll((node) => node.type === 'button')
.find((node) => collectText(node).includes('保存修改'));
expect(saveButton).toBeTruthy();
await act(async () => {
saveButton!.props.onClick();
});
await flushMicrotasks();
expect(apiMock.updateAccountToken).toHaveBeenCalledWith(22, expect.objectContaining({ group: 'default' }));
} finally {
root?.unmount();
}
@@ -181,4 +200,32 @@ describe('Tokens edit modal and row selection', () => {
root?.unmount();
}
});
it('does not repeatedly refetch groups when edit-group loading fails once', async () => {
apiMock.getAccountTokenGroups.mockRejectedValueOnce(new Error('账号会话可能已过期,请重新登录后再拉取分组'));
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();
await flushMicrotasks();
expect(apiMock.getAccountTokenGroups).toHaveBeenCalledTimes(1);
} finally {
root?.unmount();
}
});
});