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:
@@ -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> = {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user