forked from boos/BoosAPI
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:
@@ -138,6 +138,37 @@ describe('account tokens sync routes with site status', () => {
|
||||
expect(tokenRows.length).toBe(0);
|
||||
});
|
||||
|
||||
it('skips masked upstream token values and does not store them', async () => {
|
||||
const { account } = await seedAccount({ siteStatus: 'active' });
|
||||
getApiTokensMock.mockResolvedValue([
|
||||
{ name: 'masked-only', key: 'sk-abc***xyz', enabled: true },
|
||||
]);
|
||||
getApiTokenMock.mockResolvedValue(null);
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/account-tokens/sync/${account.id}`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
success: true,
|
||||
synced: false,
|
||||
status: 'skipped',
|
||||
reason: 'upstream_masked_tokens',
|
||||
maskedSkipped: 1,
|
||||
total: 0,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
});
|
||||
|
||||
const tokenRows = await db.select()
|
||||
.from(schema.accountTokens)
|
||||
.where(eq(schema.accountTokens.accountId, account.id))
|
||||
.all();
|
||||
expect(tokenRows.length).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects sync and token management for apikey connections', async () => {
|
||||
const { account } = await seedAccount({ siteStatus: 'active', accessToken: '' });
|
||||
await db.update(schema.accounts)
|
||||
@@ -450,4 +481,26 @@ describe('account tokens sync routes with site status', () => {
|
||||
const existing = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, token.id)).get();
|
||||
expect(existing).toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects retrieving token value when stored token is masked', async () => {
|
||||
const { account } = await seedAccount({ siteStatus: 'active' });
|
||||
const token = await db.insert(schema.accountTokens).values({
|
||||
accountId: account.id,
|
||||
name: 'masked-token',
|
||||
token: 'sk-mask***tail',
|
||||
source: 'sync',
|
||||
enabled: true,
|
||||
isDefault: false,
|
||||
}).returning().get();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/account-tokens/${token.id}/value`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json()).toMatchObject({
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { and, eq } from 'drizzle-orm';
|
||||
import { db, schema } from '../../db/index.js';
|
||||
import {
|
||||
ensureDefaultTokenForAccount,
|
||||
isMaskedTokenValue,
|
||||
listTokensWithRelations,
|
||||
normalizeTokenForDisplay,
|
||||
maskToken,
|
||||
@@ -32,6 +33,7 @@ type SyncExecutionResult = {
|
||||
synced: boolean;
|
||||
created: number;
|
||||
updated: number;
|
||||
maskedSkipped?: number;
|
||||
total: number;
|
||||
defaultTokenId?: number | null;
|
||||
};
|
||||
@@ -261,6 +263,16 @@ async function executeAccountTokenSync(row: AccountWithSiteRow): Promise<SyncExe
|
||||
}
|
||||
|
||||
const synced = await syncTokensFromUpstream(accountId, tokens);
|
||||
if ((synced.created + synced.updated) === 0 && synced.maskedSkipped > 0) {
|
||||
return {
|
||||
...base,
|
||||
status: 'skipped',
|
||||
reason: 'upstream_masked_tokens',
|
||||
message: `上游仅返回脱敏令牌(含*),跳过 ${synced.maskedSkipped} 条,无法同步明文令牌`,
|
||||
synced: false,
|
||||
...synced,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
status: 'synced',
|
||||
@@ -606,7 +618,7 @@ export async function accountTokensRoutes(app: FastifyInstance) {
|
||||
};
|
||||
});
|
||||
|
||||
app.put<{ Params: { id: string }; Body: { name?: string; token?: string; enabled?: boolean; isDefault?: boolean; source?: string } }>('/api/account-tokens/:id', async (request, reply) => {
|
||||
app.put<{ Params: { id: string }; Body: { name?: string; token?: string; group?: string; enabled?: boolean; isDefault?: boolean; source?: string } }>('/api/account-tokens/:id', async (request, reply) => {
|
||||
const tokenId = Number.parseInt(request.params.id, 10);
|
||||
if (Number.isNaN(tokenId)) {
|
||||
return reply.code(400).send({ success: false, message: '令牌 ID 无效' });
|
||||
@@ -640,6 +652,10 @@ export async function accountTokensRoutes(app: FastifyInstance) {
|
||||
updates.token = tokenValue;
|
||||
}
|
||||
|
||||
if (body.group !== undefined) {
|
||||
updates.tokenGroup = (body.group || '').trim() || null;
|
||||
}
|
||||
|
||||
if (body.enabled !== undefined) updates.enabled = body.enabled;
|
||||
if (body.source !== undefined) updates.source = body.source;
|
||||
if (body.isDefault !== undefined) updates.isDefault = body.isDefault;
|
||||
@@ -707,6 +723,13 @@ export async function accountTokensRoutes(app: FastifyInstance) {
|
||||
return reply.code(400).send({ success: false, message: 'API Key 连接不支持管理账号令牌' });
|
||||
}
|
||||
|
||||
if (isMaskedTokenValue(row.account_tokens.token)) {
|
||||
return reply.code(409).send({
|
||||
success: false,
|
||||
message: '当前仅保存了脱敏令牌,无法展开/复制。请在站点重新生成并同步,或手动更新为完整令牌。',
|
||||
});
|
||||
}
|
||||
|
||||
const tokenValue = normalizeTokenForDisplay(row.account_tokens.token, row.sites.platform);
|
||||
return {
|
||||
success: true,
|
||||
|
||||
@@ -46,6 +46,12 @@ function normalizeTokenValue(token: string | null | undefined): string | null {
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export function isMaskedTokenValue(token: string | null | undefined): boolean {
|
||||
const value = (token || '').trim();
|
||||
if (!value) return false;
|
||||
return value.includes('*') || value.includes('•');
|
||||
}
|
||||
|
||||
function normalizeTokenGroup(value: string | null | undefined, tokenName?: string | null): string | null {
|
||||
const explicit = (value || '').trim();
|
||||
if (explicit.length > 0) return explicit;
|
||||
@@ -200,11 +206,16 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens:
|
||||
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
let maskedSkipped = 0;
|
||||
let index = existing.length + 1;
|
||||
|
||||
for (const upstream of upstreamTokens) {
|
||||
const tokenValue = normalizeTokenValue(upstream.key);
|
||||
if (!tokenValue) continue;
|
||||
if (isMaskedTokenValue(tokenValue)) {
|
||||
maskedSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const tokenName = normalizeTokenName(upstream.name, index);
|
||||
const enabled = upstream.enabled ?? true;
|
||||
@@ -259,6 +270,7 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens:
|
||||
return {
|
||||
created,
|
||||
updated,
|
||||
maskedSkipped,
|
||||
total: existing.length,
|
||||
defaultTokenId: repaired?.id || null,
|
||||
};
|
||||
@@ -295,4 +307,4 @@ export async function listTokensWithRelations(accountId?: number) {
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ const CHECKIN_ALREADY_TOKEN = 'checkin-already-token';
|
||||
const CHECKIN_INVALID_URL_TOKEN = 'checkin-invalid-url-token';
|
||||
const CHECKIN_CLOUDFLARE_530_TOKEN = 'checkin-cloudflare-530-token';
|
||||
const BALANCE_FAIL_TOKEN = 'balance-fail-token';
|
||||
const GROUP_EXPIRED_TOKEN = 'group-expired-token';
|
||||
const SHIELD_LOGIN_USERNAME = 'shield-user';
|
||||
const SHIELD_LOGIN_PASSWORD = 'shield-pass';
|
||||
const SHIELD_LOGIN_TOKEN = 'login-session-token';
|
||||
@@ -366,6 +367,19 @@ describe('NewApiAdapter', () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (req.url === '/api/user/self/groups') {
|
||||
if (typeof req.headers.authorization === 'string' && req.headers.authorization === `Bearer ${GROUP_EXPIRED_TOKEN}`) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, message: 'access token expired' }));
|
||||
return;
|
||||
}
|
||||
if (typeof req.headers.authorization === 'string' && req.headers.authorization === 'Bearer session-token') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true, data: { default: true, gemini: true } }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (req.url === '/api/user/sign_in') {
|
||||
if (typeof req.headers.cookie === 'string' && req.headers.cookie.includes(`session=${CHECKIN_ALREADY_TOKEN}`)) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
@@ -556,6 +570,20 @@ describe('NewApiAdapter', () => {
|
||||
expect(result.message).toBe('今天已经签到过啦');
|
||||
});
|
||||
|
||||
it('returns clean groups from data object without envelope keys', async () => {
|
||||
const adapter = new NewApiAdapter();
|
||||
const groups = await adapter.getUserGroups(baseUrl, 'session-token', 11494);
|
||||
|
||||
expect(groups).toEqual(['default', 'gemini']);
|
||||
expect(groups).not.toContain('success');
|
||||
expect(groups).not.toContain('message');
|
||||
});
|
||||
|
||||
it('throws expired-session error when group endpoint reports invalid access token', async () => {
|
||||
const adapter = new NewApiAdapter();
|
||||
await expect(adapter.getUserGroups(baseUrl, GROUP_EXPIRED_TOKEN, 11494)).rejects.toThrow('账号会话可能已过期');
|
||||
});
|
||||
|
||||
it('sends all compatibility user-id headers when userId is known', async () => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((err?: Error) => (err ? reject(err) : resolve()));
|
||||
|
||||
@@ -248,6 +248,10 @@ export class NewApiAdapter extends BasePlatformAdapter {
|
||||
}
|
||||
|
||||
private parseGroupKeys(payload: any): string[] {
|
||||
if (payload && typeof payload === 'object' && payload?.success === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const source = payload?.data ?? payload;
|
||||
if (Array.isArray(source)) {
|
||||
return source
|
||||
@@ -258,12 +262,28 @@ export class NewApiAdapter extends BasePlatformAdapter {
|
||||
if (source && typeof source === 'object') {
|
||||
return Object.keys(source)
|
||||
.map((key) => key.trim())
|
||||
.filter((key) => !['success', 'message', 'code', 'data', 'error'].includes(key.toLowerCase()))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private resolveGroupFetchErrorMessage(payload: any): string {
|
||||
const message = typeof payload?.message === 'string' ? payload.message.trim() : '';
|
||||
const normalized = message.toLowerCase();
|
||||
const indicatesExpired = normalized.includes('expired')
|
||||
|| normalized.includes('invalid token')
|
||||
|| normalized.includes('access token')
|
||||
|| normalized.includes('unauthorized')
|
||||
|| normalized.includes('forbidden')
|
||||
|| normalized.includes('未登录')
|
||||
|| normalized.includes('登录')
|
||||
|| normalized.includes('过期');
|
||||
if (indicatesExpired) return '账号会话可能已过期,请重新登录后再拉取分组';
|
||||
return message || '拉取分组失败';
|
||||
}
|
||||
|
||||
private normalizeTokenItems(items: any[]): ApiTokenInfo[] {
|
||||
const normalized: ApiTokenInfo[] = [];
|
||||
for (let index = 0; index < items.length; index += 1) {
|
||||
@@ -1141,11 +1161,15 @@ export class NewApiAdapter extends BasePlatformAdapter {
|
||||
async getUserGroups(baseUrl: string, accessToken: string, platformUserId?: number): Promise<string[]> {
|
||||
const resolvedUserId = platformUserId || await this.discoverUserId(baseUrl, accessToken);
|
||||
const dedupe = (groups: string[]) => Array.from(new Set(groups.map((item) => item.trim()).filter(Boolean)));
|
||||
let terminalError: string | null = null;
|
||||
|
||||
try {
|
||||
const res = await this.fetchJson<any>(`${baseUrl}/api/user/self/groups`, {
|
||||
headers: this.authHeaders(accessToken, resolvedUserId || undefined),
|
||||
});
|
||||
if (res?.success === false) {
|
||||
terminalError = this.resolveGroupFetchErrorMessage(res);
|
||||
}
|
||||
const parsed = dedupe(this.parseGroupKeys(res));
|
||||
if (parsed.length > 0) return parsed;
|
||||
} catch {}
|
||||
@@ -1154,6 +1178,9 @@ export class NewApiAdapter extends BasePlatformAdapter {
|
||||
const res = await this.fetchJson<any>(`${baseUrl}/api/user_group_map`, {
|
||||
headers: this.authHeaders(accessToken, resolvedUserId || undefined),
|
||||
});
|
||||
if (res?.success === false) {
|
||||
terminalError = this.resolveGroupFetchErrorMessage(res);
|
||||
}
|
||||
const parsed = dedupe(this.parseGroupKeys(res));
|
||||
if (parsed.length > 0) return parsed;
|
||||
} catch {}
|
||||
@@ -1165,17 +1192,27 @@ export class NewApiAdapter extends BasePlatformAdapter {
|
||||
|
||||
try {
|
||||
const res = await this.fetchJsonRaw<any>(`${baseUrl}/api/user/self/groups`, { headers });
|
||||
if (res?.success === false) {
|
||||
terminalError = this.resolveGroupFetchErrorMessage(res);
|
||||
}
|
||||
const parsed = dedupe(this.parseGroupKeys(res));
|
||||
if (parsed.length > 0) return parsed;
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const res = await this.fetchJsonRaw<any>(`${baseUrl}/api/user_group_map`, { headers });
|
||||
if (res?.success === false) {
|
||||
terminalError = this.resolveGroupFetchErrorMessage(res);
|
||||
}
|
||||
const parsed = dedupe(this.parseGroupKeys(res));
|
||||
if (parsed.length > 0) return parsed;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (terminalError) {
|
||||
throw new Error(terminalError);
|
||||
}
|
||||
|
||||
return ['default'];
|
||||
}
|
||||
|
||||
|
||||
@@ -129,24 +129,49 @@ export class OneApiAdapter extends BasePlatformAdapter {
|
||||
|
||||
async getUserGroups(baseUrl: string, accessToken: string): Promise<string[]> {
|
||||
const headers = { Authorization: `Bearer ${accessToken}` };
|
||||
const resolveGroupFetchErrorMessage = (payload: any): string => {
|
||||
const message = typeof payload?.message === 'string' ? payload.message.trim() : '';
|
||||
const normalized = message.toLowerCase();
|
||||
const indicatesExpired = normalized.includes('expired')
|
||||
|| normalized.includes('invalid token')
|
||||
|| normalized.includes('access token')
|
||||
|| normalized.includes('unauthorized')
|
||||
|| normalized.includes('forbidden')
|
||||
|| normalized.includes('未登录')
|
||||
|| normalized.includes('登录')
|
||||
|| normalized.includes('过期');
|
||||
if (indicatesExpired) return '账号会话可能已过期,请重新登录后再拉取分组';
|
||||
return message || '拉取分组失败';
|
||||
};
|
||||
const extractGroupKeys = (payload: any): string[] => {
|
||||
if (payload && typeof payload === 'object' && payload?.success === false) return [];
|
||||
const source = payload?.data || payload;
|
||||
if (!source || typeof source !== 'object') return [];
|
||||
return Object.keys(source).map((key) => key.trim()).filter(Boolean);
|
||||
return Object.keys(source)
|
||||
.map((key) => key.trim())
|
||||
.filter((key) => !['success', 'message', 'code', 'data', 'error'].includes(key.toLowerCase()))
|
||||
.filter(Boolean);
|
||||
};
|
||||
let terminalError: string | null = null;
|
||||
|
||||
try {
|
||||
const groupMap = await this.fetchJson<any>(`${baseUrl}/api/user_group_map`, { headers });
|
||||
if (groupMap?.success === false) terminalError = resolveGroupFetchErrorMessage(groupMap);
|
||||
const keys = extractGroupKeys(groupMap);
|
||||
if (keys.length > 0) return Array.from(new Set(keys));
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const groups = await this.fetchJson<any>(`${baseUrl}/api/user/self/groups`, { headers });
|
||||
if (groups?.success === false) terminalError = resolveGroupFetchErrorMessage(groups);
|
||||
const keys = extractGroupKeys(groups);
|
||||
if (keys.length > 0) return Array.from(new Set(keys));
|
||||
} catch {}
|
||||
|
||||
if (terminalError) {
|
||||
throw new Error(terminalError);
|
||||
}
|
||||
|
||||
return ['default'];
|
||||
}
|
||||
|
||||
|
||||
@@ -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