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
+15 -1
View File
@@ -201,7 +201,7 @@ jobs:
if [ -n "${APPLE_APP_SPECIFIC_PASSWORD_INPUT:-}" ]; then export APPLE_APP_SPECIFIC_PASSWORD="$APPLE_APP_SPECIFIC_PASSWORD_INPUT"; fi if [ -n "${APPLE_APP_SPECIFIC_PASSWORD_INPUT:-}" ]; then export APPLE_APP_SPECIFIC_PASSWORD="$APPLE_APP_SPECIFIC_PASSWORD_INPUT"; fi
if [ -n "${APPLE_TEAM_ID_INPUT:-}" ]; then export APPLE_TEAM_ID="$APPLE_TEAM_ID_INPUT"; fi if [ -n "${APPLE_TEAM_ID_INPUT:-}" ]; then export APPLE_TEAM_ID="$APPLE_TEAM_ID_INPUT"; fi
for attempt in 1 2; do for attempt in 1 2; do
echo "Desktop build attempt ${attempt}/2" echo "Desktop build (host arch) attempt ${attempt}/2"
if npm run package:desktop; then if npm run package:desktop; then
break break
fi fi
@@ -210,6 +210,20 @@ jobs:
fi fi
sleep 90 sleep 90
done done
if [ "${RUNNER_ARCH:-}" != "X64" ]; then
for attempt in 1 2; do
echo "Desktop build (mac intel x64) attempt ${attempt}/2"
if npm run package:desktop:mac:intel; then
break
fi
if [ "$attempt" -eq 2 ]; then
exit 1
fi
sleep 90
done
else
echo "Runner is already X64, skip extra Intel build"
fi
- name: Upload desktop package - name: Upload desktop package
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
+1
View File
@@ -390,6 +390,7 @@ npm run dev
npm run build # 构建前端 + 后端 npm run build # 构建前端 + 后端
npm run build:web # 仅构建前端(Vite) npm run build:web # 仅构建前端(Vite)
npm run build:server # 仅构建后端(TypeScript) npm run build:server # 仅构建后端(TypeScript)
npm run dist:desktop:mac:intel # 构建 mac Intel (x64) 桌面安装包
npm test # 运行全部测试 npm test # 运行全部测试
npm run test:watch # 监听模式 npm run test:watch # 监听模式
npm run db:generate # 生成 Drizzle 迁移文件 npm run db:generate # 生成 Drizzle 迁移文件
+1
View File
@@ -498,6 +498,7 @@ npm run dev
npm run build # Build frontend + backend npm run build # Build frontend + backend
npm run build:web # Build frontend only (Vite) npm run build:web # Build frontend only (Vite)
npm run build:server # Build backend only (TypeScript) npm run build:server # Build backend only (TypeScript)
npm run dist:desktop:mac:intel # Build mac Intel (x64) desktop installer
npm test # Run all tests npm test # Run all tests
npm run test:watch # Watch mode npm run test:watch # Watch mode
npm run db:generate # Generate Drizzle migration files npm run db:generate # Generate Drizzle migration files
+2
View File
@@ -31,7 +31,9 @@
"build:desktop": "tsc -p tsconfig.desktop.json", "build:desktop": "tsc -p tsconfig.desktop.json",
"build": "npm run build:web && npm run build:server && npm run build:desktop", "build": "npm run build:web && npm run build:server && npm run build:desktop",
"dist:desktop": "npm run build && electron-builder --config electron-builder.yml --publish never", "dist:desktop": "npm run build && electron-builder --config electron-builder.yml --publish never",
"dist:desktop:mac:intel": "npm run build && electron-builder --config electron-builder.yml --publish never --mac --x64",
"package:desktop": "electron-builder --config electron-builder.yml --publish never", "package:desktop": "electron-builder --config electron-builder.yml --publish never",
"package:desktop:mac:intel": "electron-builder --config electron-builder.yml --publish never --mac --x64",
"docs:dev": "vitepress dev docs --host 0.0.0.0 --port 4173", "docs:dev": "vitepress dev docs --host 0.0.0.0 --port 4173",
"docs:build": "vitepress build docs", "docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs --host 0.0.0.0 --port 4173", "docs:preview": "vitepress preview docs --host 0.0.0.0 --port 4173",
@@ -138,6 +138,37 @@ describe('account tokens sync routes with site status', () => {
expect(tokenRows.length).toBe(0); 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 () => { it('rejects sync and token management for apikey connections', async () => {
const { account } = await seedAccount({ siteStatus: 'active', accessToken: '' }); const { account } = await seedAccount({ siteStatus: 'active', accessToken: '' });
await db.update(schema.accounts) 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(); const existing = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, token.id)).get();
expect(existing).toBeDefined(); 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,
});
});
}); });
+24 -1
View File
@@ -3,6 +3,7 @@ import { and, eq } from 'drizzle-orm';
import { db, schema } from '../../db/index.js'; import { db, schema } from '../../db/index.js';
import { import {
ensureDefaultTokenForAccount, ensureDefaultTokenForAccount,
isMaskedTokenValue,
listTokensWithRelations, listTokensWithRelations,
normalizeTokenForDisplay, normalizeTokenForDisplay,
maskToken, maskToken,
@@ -32,6 +33,7 @@ type SyncExecutionResult = {
synced: boolean; synced: boolean;
created: number; created: number;
updated: number; updated: number;
maskedSkipped?: number;
total: number; total: number;
defaultTokenId?: number | null; defaultTokenId?: number | null;
}; };
@@ -261,6 +263,16 @@ async function executeAccountTokenSync(row: AccountWithSiteRow): Promise<SyncExe
} }
const synced = await syncTokensFromUpstream(accountId, tokens); 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 { return {
...base, ...base,
status: 'synced', 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); const tokenId = Number.parseInt(request.params.id, 10);
if (Number.isNaN(tokenId)) { if (Number.isNaN(tokenId)) {
return reply.code(400).send({ success: false, message: '令牌 ID 无效' }); return reply.code(400).send({ success: false, message: '令牌 ID 无效' });
@@ -640,6 +652,10 @@ export async function accountTokensRoutes(app: FastifyInstance) {
updates.token = tokenValue; updates.token = tokenValue;
} }
if (body.group !== undefined) {
updates.tokenGroup = (body.group || '').trim() || null;
}
if (body.enabled !== undefined) updates.enabled = body.enabled; if (body.enabled !== undefined) updates.enabled = body.enabled;
if (body.source !== undefined) updates.source = body.source; if (body.source !== undefined) updates.source = body.source;
if (body.isDefault !== undefined) updates.isDefault = body.isDefault; 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 连接不支持管理账号令牌' }); 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); const tokenValue = normalizeTokenForDisplay(row.account_tokens.token, row.sites.platform);
return { return {
success: true, success: true,
+13 -1
View File
@@ -46,6 +46,12 @@ function normalizeTokenValue(token: string | null | undefined): string | null {
return trimmed.length > 0 ? trimmed : 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 { function normalizeTokenGroup(value: string | null | undefined, tokenName?: string | null): string | null {
const explicit = (value || '').trim(); const explicit = (value || '').trim();
if (explicit.length > 0) return explicit; if (explicit.length > 0) return explicit;
@@ -200,11 +206,16 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens:
let created = 0; let created = 0;
let updated = 0; let updated = 0;
let maskedSkipped = 0;
let index = existing.length + 1; let index = existing.length + 1;
for (const upstream of upstreamTokens) { for (const upstream of upstreamTokens) {
const tokenValue = normalizeTokenValue(upstream.key); const tokenValue = normalizeTokenValue(upstream.key);
if (!tokenValue) continue; if (!tokenValue) continue;
if (isMaskedTokenValue(tokenValue)) {
maskedSkipped++;
continue;
}
const tokenName = normalizeTokenName(upstream.name, index); const tokenName = normalizeTokenName(upstream.name, index);
const enabled = upstream.enabled ?? true; const enabled = upstream.enabled ?? true;
@@ -259,6 +270,7 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens:
return { return {
created, created,
updated, updated,
maskedSkipped,
total: existing.length, total: existing.length,
defaultTokenId: repaired?.id || null, 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_INVALID_URL_TOKEN = 'checkin-invalid-url-token';
const CHECKIN_CLOUDFLARE_530_TOKEN = 'checkin-cloudflare-530-token'; const CHECKIN_CLOUDFLARE_530_TOKEN = 'checkin-cloudflare-530-token';
const BALANCE_FAIL_TOKEN = 'balance-fail-token'; const BALANCE_FAIL_TOKEN = 'balance-fail-token';
const GROUP_EXPIRED_TOKEN = 'group-expired-token';
const SHIELD_LOGIN_USERNAME = 'shield-user'; const SHIELD_LOGIN_USERNAME = 'shield-user';
const SHIELD_LOGIN_PASSWORD = 'shield-pass'; const SHIELD_LOGIN_PASSWORD = 'shield-pass';
const SHIELD_LOGIN_TOKEN = 'login-session-token'; 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 (req.url === '/api/user/sign_in') {
if (typeof req.headers.cookie === 'string' && req.headers.cookie.includes(`session=${CHECKIN_ALREADY_TOKEN}`)) { if (typeof req.headers.cookie === 'string' && req.headers.cookie.includes(`session=${CHECKIN_ALREADY_TOKEN}`)) {
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -556,6 +570,20 @@ describe('NewApiAdapter', () => {
expect(result.message).toBe('今天已经签到过啦'); 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 () => { it('sends all compatibility user-id headers when userId is known', async () => {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
server.close((err?: Error) => (err ? reject(err) : resolve())); server.close((err?: Error) => (err ? reject(err) : resolve()));
+37
View File
@@ -248,6 +248,10 @@ export class NewApiAdapter extends BasePlatformAdapter {
} }
private parseGroupKeys(payload: any): string[] { private parseGroupKeys(payload: any): string[] {
if (payload && typeof payload === 'object' && payload?.success === false) {
return [];
}
const source = payload?.data ?? payload; const source = payload?.data ?? payload;
if (Array.isArray(source)) { if (Array.isArray(source)) {
return source return source
@@ -258,12 +262,28 @@ export class NewApiAdapter extends BasePlatformAdapter {
if (source && typeof source === 'object') { if (source && typeof source === 'object') {
return Object.keys(source) return Object.keys(source)
.map((key) => key.trim()) .map((key) => key.trim())
.filter((key) => !['success', 'message', 'code', 'data', 'error'].includes(key.toLowerCase()))
.filter(Boolean); .filter(Boolean);
} }
return []; 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[] { private normalizeTokenItems(items: any[]): ApiTokenInfo[] {
const normalized: ApiTokenInfo[] = []; const normalized: ApiTokenInfo[] = [];
for (let index = 0; index < items.length; index += 1) { 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[]> { async getUserGroups(baseUrl: string, accessToken: string, platformUserId?: number): Promise<string[]> {
const resolvedUserId = platformUserId || await this.discoverUserId(baseUrl, accessToken); const resolvedUserId = platformUserId || await this.discoverUserId(baseUrl, accessToken);
const dedupe = (groups: string[]) => Array.from(new Set(groups.map((item) => item.trim()).filter(Boolean))); const dedupe = (groups: string[]) => Array.from(new Set(groups.map((item) => item.trim()).filter(Boolean)));
let terminalError: string | null = null;
try { try {
const res = await this.fetchJson<any>(`${baseUrl}/api/user/self/groups`, { const res = await this.fetchJson<any>(`${baseUrl}/api/user/self/groups`, {
headers: this.authHeaders(accessToken, resolvedUserId || undefined), headers: this.authHeaders(accessToken, resolvedUserId || undefined),
}); });
if (res?.success === false) {
terminalError = this.resolveGroupFetchErrorMessage(res);
}
const parsed = dedupe(this.parseGroupKeys(res)); const parsed = dedupe(this.parseGroupKeys(res));
if (parsed.length > 0) return parsed; if (parsed.length > 0) return parsed;
} catch {} } catch {}
@@ -1154,6 +1178,9 @@ export class NewApiAdapter extends BasePlatformAdapter {
const res = await this.fetchJson<any>(`${baseUrl}/api/user_group_map`, { const res = await this.fetchJson<any>(`${baseUrl}/api/user_group_map`, {
headers: this.authHeaders(accessToken, resolvedUserId || undefined), headers: this.authHeaders(accessToken, resolvedUserId || undefined),
}); });
if (res?.success === false) {
terminalError = this.resolveGroupFetchErrorMessage(res);
}
const parsed = dedupe(this.parseGroupKeys(res)); const parsed = dedupe(this.parseGroupKeys(res));
if (parsed.length > 0) return parsed; if (parsed.length > 0) return parsed;
} catch {} } catch {}
@@ -1165,17 +1192,27 @@ export class NewApiAdapter extends BasePlatformAdapter {
try { try {
const res = await this.fetchJsonRaw<any>(`${baseUrl}/api/user/self/groups`, { headers }); 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)); const parsed = dedupe(this.parseGroupKeys(res));
if (parsed.length > 0) return parsed; if (parsed.length > 0) return parsed;
} catch {} } catch {}
try { try {
const res = await this.fetchJsonRaw<any>(`${baseUrl}/api/user_group_map`, { headers }); 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)); const parsed = dedupe(this.parseGroupKeys(res));
if (parsed.length > 0) return parsed; if (parsed.length > 0) return parsed;
} catch {} } catch {}
} }
if (terminalError) {
throw new Error(terminalError);
}
return ['default']; return ['default'];
} }
+26 -1
View File
@@ -129,24 +129,49 @@ export class OneApiAdapter extends BasePlatformAdapter {
async getUserGroups(baseUrl: string, accessToken: string): Promise<string[]> { async getUserGroups(baseUrl: string, accessToken: string): Promise<string[]> {
const headers = { Authorization: `Bearer ${accessToken}` }; 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[] => { const extractGroupKeys = (payload: any): string[] => {
if (payload && typeof payload === 'object' && payload?.success === false) return [];
const source = payload?.data || payload; const source = payload?.data || payload;
if (!source || typeof source !== 'object') return []; 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 { try {
const groupMap = await this.fetchJson<any>(`${baseUrl}/api/user_group_map`, { headers }); const groupMap = await this.fetchJson<any>(`${baseUrl}/api/user_group_map`, { headers });
if (groupMap?.success === false) terminalError = resolveGroupFetchErrorMessage(groupMap);
const keys = extractGroupKeys(groupMap); const keys = extractGroupKeys(groupMap);
if (keys.length > 0) return Array.from(new Set(keys)); if (keys.length > 0) return Array.from(new Set(keys));
} catch {} } catch {}
try { try {
const groups = await this.fetchJson<any>(`${baseUrl}/api/user/self/groups`, { headers }); const groups = await this.fetchJson<any>(`${baseUrl}/api/user/self/groups`, { headers });
if (groups?.success === false) terminalError = resolveGroupFetchErrorMessage(groups);
const keys = extractGroupKeys(groups); const keys = extractGroupKeys(groups);
if (keys.length > 0) return Array.from(new Set(keys)); if (keys.length > 0) return Array.from(new Set(keys));
} catch {} } catch {}
if (terminalError) {
throw new Error(terminalError);
}
return ['default']; return ['default'];
} }
+4
View File
@@ -24,7 +24,11 @@ async function request(url: string, options: RequestOptions = {}) {
const token = getAuthToken(localStorage); const token = getAuthToken(localStorage);
if (!token) { if (!token) {
const hadToken = !!localStorage.getItem('auth_token');
clearAuthSession(localStorage); clearAuthSession(localStorage);
if (hadToken && typeof window !== 'undefined' && typeof window.location?.reload === 'function') {
window.location.reload();
}
throw new Error('Session expired'); throw new Error('Session expired');
} }
const headers: Record<string, string> = { 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 ModernSelect from '../components/ModernSelect.js';
import { MobileCard, MobileField } from '../components/MobileCard.js'; import { MobileCard, MobileField } from '../components/MobileCard.js';
import { useIsMobile } from '../components/useIsMobile.js'; import { useIsMobile } from '../components/useIsMobile.js';
import DeleteConfirmModal from '../components/DeleteConfirmModal.js';
import { import {
buildAddAccountPrereqHint, buildAddAccountPrereqHint,
buildVerifyFailureHint, buildVerifyFailureHint,
@@ -91,6 +92,12 @@ export default function Accounts() {
const [embeddedTokenActions, setEmbeddedTokenActions] = useState<React.ReactNode>(null); const [embeddedTokenActions, setEmbeddedTokenActions] = useState<React.ReactNode>(null);
const [selectedAccountIds, setSelectedAccountIds] = useState<number[]>([]); const [selectedAccountIds, setSelectedAccountIds] = useState<number[]>([]);
const [batchActionLoading, setBatchActionLoading] = useState(false); 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 [editingAccount, setEditingAccount] = useState<any | null>(null);
const [editForm, setEditForm] = useState({ const [editForm, setEditForm] = useState({
username: '', username: '',
@@ -127,7 +134,8 @@ export default function Accounts() {
setAccounts(nextAccounts); setAccounts(nextAccounts);
setSelectedAccountIds((current) => current.filter((id) => nextAccounts.some((account: any) => account.id === id))); setSelectedAccountIds((current) => current.filter((id) => nextAccounts.some((account: any) => account.id === id)));
} else { } else {
toast.error('加载账号列表失败'); const reason = (accountsResult as PromiseRejectedResult).reason as any;
toast.error(reason?.message || '加载账号列表失败');
} }
if (sitesResult.status === 'fulfilled') { if (sitesResult.status === 'fulfilled') {
setSites(sitesResult.value || []); 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 (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); setBatchActionLoading(true);
try { 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>) => { const handleAccountRowClick = (accountId: number, event: React.MouseEvent<HTMLTableRowElement>) => {
if (shouldIgnoreRowSelectionClick(event.target)) return; if (shouldIgnoreRowSelectionClick(event.target)) return;
const isSelected = selectedAccountIds.includes(accountId); const isSelected = selectedAccountIds.includes(accountId);
@@ -831,6 +855,18 @@ export default function Accounts() {
))} ))}
</div> </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 && ( {!isMobile && activeSegment !== 'tokens' && selectedAccountIds.length > 0 && (
<div className="card" style={{ padding: 12, marginBottom: 12, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}> <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> <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 onClick={() => openEditPanel(a)} className="btn btn-link btn-link-info">
编辑 编辑
</button> </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" /> : '删除'} {actionLoading[`delete-${a.id}`] ? <span className="spinner spinner-sm" /> : '删除'}
</button> </button>
</div> </div>
@@ -1740,7 +1776,7 @@ export default function Accounts() {
<button onClick={() => openEditPanel(a)} className="btn btn-link btn-link-info"> <button onClick={() => openEditPanel(a)} className="btn btn-link btn-link-info">
编辑 编辑
</button> </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" /> : '删除'} {actionLoading[`delete-${a.id}`] ? <span className="spinner spinner-sm" /> : '删除'}
</button> </button>
</div> </div>
+48 -13
View File
@@ -6,6 +6,7 @@ import { useToast } from '../components/Toast.js';
import ModernSelect from '../components/ModernSelect.js'; import ModernSelect from '../components/ModernSelect.js';
import { MobileCard, MobileField } from '../components/MobileCard.js'; import { MobileCard, MobileField } from '../components/MobileCard.js';
import { useIsMobile } from '../components/useIsMobile.js'; import { useIsMobile } from '../components/useIsMobile.js';
import DeleteConfirmModal from '../components/DeleteConfirmModal.js';
import { formatDateTimeLocal } from './helpers/checkinLogTime.js'; import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
import { clearFocusParams, readFocusSiteId } from './helpers/navigationFocus.js'; import { clearFocusParams, readFocusSiteId } from './helpers/navigationFocus.js';
import { tr } from '../i18n.js'; import { tr } from '../i18n.js';
@@ -90,6 +91,12 @@ export default function Sites() {
const [expandedSiteIds, setExpandedSiteIds] = useState<number[]>([]); const [expandedSiteIds, setExpandedSiteIds] = useState<number[]>([]);
const isMobile = useIsMobile(768); const isMobile = useIsMobile(768);
const [batchActionLoading, setBatchActionLoading] = useState(false); 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 lastEditorRef = useRef<SiteEditorState | null>(null);
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map()); const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
const highlightTimerRef = useRef<number | null>(null); const highlightTimerRef = useRef<number | null>(null);
@@ -98,7 +105,7 @@ export default function Sites() {
if (editor) lastEditorRef.current = editor; if (editor) lastEditorRef.current = editor;
const activeEditor = editor || lastEditorRef.current; const activeEditor = editor || lastEditorRef.current;
const isEditing = activeEditor?.mode === 'edit'; const isEditing = activeEditor?.mode === 'edit';
const isAdding = activeEditor?.mode === 'add'; const isAdding = editor?.mode === 'add';
const formInputStyle = { const formInputStyle = {
width: '100%', width: '100%',
padding: '10px 14px', padding: '10px 14px',
@@ -310,16 +317,7 @@ export default function Sites() {
}; };
const handleDelete = async (site: SiteRow) => { const handleDelete = async (site: SiteRow) => {
setDeleting(site.id); setDeleteConfirm({ mode: 'single', siteId: site.id, siteName: site.name });
try {
await api.deleteSite(site.id);
toast.success(`站点 "${site.name}" 已删除`);
await load();
} catch (e: any) {
toast.error(e.message || '删除失败');
} finally {
setDeleting(null);
}
}; };
const handleToggleStatus = async (site: SiteRow) => { 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 (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); setBatchActionLoading(true);
try { 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>) => { const handleSiteRowClick = (siteId: number, event: React.MouseEvent<HTMLTableRowElement>) => {
if (shouldIgnoreRowSelectionClick(event.target)) return; if (shouldIgnoreRowSelectionClick(event.target)) return;
const isSelected = selectedSiteIds.includes(siteId); const isSelected = selectedSiteIds.includes(siteId);
@@ -515,6 +538,18 @@ export default function Sites() {
站点权重说明:最终站点倍率 = 站点全局权重 × 设置页中下游 API Key 的站点倍率。它会与路由策略因子(基础权重、价值分、成本、余额、使用频次)共同作用。数值越大,该站点在同优先级下越容易被选中。建议范围 0.5-3,默认 1;长期不建议超过 5。 站点权重说明:最终站点倍率 = 站点全局权重 × 设置页中下游 API Key 的站点倍率。它会与路由策略因子(基础权重、价值分、成本、余额、使用频次)共同作用。数值越大,该站点在同优先级下越容易被选中。建议范围 0.5-3,默认 1;长期不建议超过 5。
</div> </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 && ( {activeEditor && (
<CenteredModal <CenteredModal
open={Boolean(editor)} open={Boolean(editor)}
+137 -26
View File
@@ -7,6 +7,7 @@ import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
import ModernSelect from '../components/ModernSelect.js'; import ModernSelect from '../components/ModernSelect.js';
import { MobileCard, MobileField } from '../components/MobileCard.js'; import { MobileCard, MobileField } from '../components/MobileCard.js';
import { useIsMobile } from '../components/useIsMobile.js'; import { useIsMobile } from '../components/useIsMobile.js';
import DeleteConfirmModal from '../components/DeleteConfirmModal.js';
import { clearFocusParams, readFocusTokenId } from './helpers/navigationFocus.js'; import { clearFocusParams, readFocusTokenId } from './helpers/navigationFocus.js';
import { shouldIgnoreRowSelectionClick } from './helpers/rowSelection.js'; import { shouldIgnoreRowSelectionClick } from './helpers/rowSelection.js';
import { tr } from '../i18n.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 [rowLoading, setRowLoading] = useState<Record<string, boolean>>({});
const [selectedTokenIds, setSelectedTokenIds] = useState<number[]>([]); const [selectedTokenIds, setSelectedTokenIds] = useState<number[]>([]);
const [batchActionLoading, setBatchActionLoading] = useState(false); 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 [form, setForm] = useState(initialCreateForm);
const [editForm, setEditForm] = useState({ const [editForm, setEditForm] = useState({
name: '', name: '',
token: '', token: '',
group: 'default',
enabled: true, enabled: true,
isDefault: false, isDefault: false,
}); });
const [groupOptions, setGroupOptions] = useState<string[]>(['default']); const [groupOptions, setGroupOptions] = useState<string[]>(['default']);
const [groupLoading, setGroupLoading] = useState(false); 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 rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const editingTokenIdRef = useRef<number | null>(null); const editingTokenIdRef = useRef<number | null>(null);
@@ -221,6 +231,60 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
}; };
}, [showAdd, form.accountId]); }, [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]); const activeAccounts = useMemo(() => accounts.filter(isAccountSyncable), [accounts]);
useEffect(() => { useEffect(() => {
@@ -304,12 +368,15 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
setSelectedTokenIds([]); setSelectedTokenIds([]);
return; 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 (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); setBatchActionLoading(true);
try { 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) => { const openEditPanel = useCallback((token: any) => {
setShowAdd(false); setShowAdd(false);
setCreateHintModelName(''); setCreateHintModelName('');
@@ -341,6 +425,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
setEditForm({ setEditForm({
name: token?.name || '', name: token?.name || '',
token: '', token: '',
group: (token?.tokenGroup || '').trim() || 'default',
enabled: token?.enabled !== false, enabled: token?.enabled !== false,
isDefault: !!token?.isDefault, isDefault: !!token?.isDefault,
}); });
@@ -372,6 +457,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
setEditForm({ setEditForm({
name: '', name: '',
token: '', token: '',
group: 'default',
enabled: true, enabled: true,
isDefault: false, isDefault: false,
}); });
@@ -384,6 +470,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
await api.updateAccountToken(editingToken.id, { await api.updateAccountToken(editingToken.id, {
name: editForm.name.trim() || editingToken.name, name: editForm.name.trim() || editingToken.name,
token: editForm.token.trim() || undefined, token: editForm.token.trim() || undefined,
group: editForm.group || 'default',
enabled: editForm.enabled, enabled: editForm.enabled,
isDefault: editForm.isDefault, isDefault: editForm.isDefault,
}); });
@@ -404,17 +491,21 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
}; };
const handleCopyToken = async (tokenId: number, tokenName: string) => { const handleCopyToken = async (tokenId: number, tokenName: string) => {
await withRowLoading(`token-${tokenId}-copy`, async () => { try {
const res = await api.getAccountTokenValue(tokenId); await withRowLoading(`token-${tokenId}-copy`, async () => {
const tokenValue = (res?.token || '').trim(); const res = await api.getAccountTokenValue(tokenId);
if (!tokenValue) { const tokenValue = (res?.token || '').trim();
toast.error('令牌为空,无法复制'); if (!tokenValue) {
return; toast.error('令牌为空,无法复制');
} return;
}
await copyText(tokenValue); await copyText(tokenValue);
toast.success(`已复制令牌:${tokenName || `token-${tokenId}`}`); toast.success(`已复制令牌:${tokenName || `token-${tokenId}`}`);
}); });
} catch (error: any) {
toast.error(error?.message || '复制令牌失败');
}
}; };
const handleAddToken = async () => { const handleAddToken = async () => {
@@ -639,6 +730,18 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
新增令牌会调用站点 API 创建新密钥,再自动同步到本地。支持设置分组、额度、过期时间和 IP 白名单;已存在密钥可直接用“同步站点令牌”读取。 新增令牌会调用站点 API 创建新密钥,再自动同步到本地。支持设置分组、额度、过期时间和 IP 白名单;已存在密钥可直接用“同步站点令牌”读取。
</div> </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 <CenteredModal
open={Boolean(editingToken)} open={Boolean(editingToken)}
onClose={closeEditPanel} onClose={closeEditPanel}
@@ -695,6 +798,19 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
disabled={editingTokenValueLoading} disabled={editingTokenValueLoading}
/> />
</div> </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>
<div style={sectionCardStyle}> <div style={sectionCardStyle}>
<div style={sectionLabelStyle}>状态设置</div> <div style={sectionLabelStyle}>状态设置</div>
@@ -890,7 +1006,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
) : tokens.length > 0 ? ( ) : tokens.length > 0 ? (
isMobile ? ( isMobile ? (
<div className="mobile-card-list"> <div className="mobile-card-list">
{tokens.map((token: any) => { {accountClusteredTokens.map((token: any) => {
const loadingPrefix = `token-${token.id}`; const loadingPrefix = `token-${token.id}`;
return ( return (
<MobileCard <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.account?.username || `account-${token.accountId}`} />
<MobileField label="分组" value={token.tokenGroup || 'default'} />
<MobileField <MobileField
label="状态" label="状态"
value={( value={(
@@ -982,11 +1099,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
{rowLoading[`${loadingPrefix}-toggle`] ? <span className="spinner spinner-sm" /> : (token.enabled ? '禁用' : '启用')} {rowLoading[`${loadingPrefix}-toggle`] ? <span className="spinner spinner-sm" /> : (token.enabled ? '禁用' : '启用')}
</button> </button>
<button <button
onClick={() => withRowLoading(`${loadingPrefix}-delete`, async () => { onClick={() => setDeleteConfirm({ mode: 'single', tokenId: token.id, tokenName: token.name || '' })}
await api.deleteAccountToken(token.id);
toast.success('令牌已删除');
await load();
})}
disabled={!!rowLoading[`${loadingPrefix}-delete`]} disabled={!!rowLoading[`${loadingPrefix}-delete`]}
className="btn btn-link btn-link-danger" className="btn btn-link btn-link-danger"
> >
@@ -1004,7 +1117,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
<th style={{ width: 44 }}> <th style={{ width: 44 }}>
<input <input
type="checkbox" type="checkbox"
checked={tokens.length > 0 && selectedTokenIds.length === tokens.length} checked={accountClusteredTokens.length > 0 && selectedTokenIds.length === accountClusteredTokens.length}
onChange={(e) => toggleSelectAllTokens(e.target.checked)} onChange={(e) => toggleSelectAllTokens(e.target.checked)}
/> />
</th> </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>
<th>状态</th> <th>状态</th>
<th>默认</th> <th>默认</th>
<th>更新时间</th> <th>更新时间</th>
@@ -1019,7 +1133,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{tokens.map((token: any, i: number) => { {accountClusteredTokens.map((token: any, i: number) => {
const loadingPrefix = `token-${token.id}`; const loadingPrefix = `token-${token.id}`;
return ( return (
<tr <tr
@@ -1063,6 +1177,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
)} )}
</td> </td>
<td>{token.account?.username || `account-${token.accountId}`}</td> <td>{token.account?.username || `account-${token.accountId}`}</td>
<td>{token.tokenGroup || 'default'}</td>
<td> <td>
<span className={`badge ${token.enabled ? 'badge-success' : 'badge-muted'}`} style={{ fontSize: 11 }}> <span className={`badge ${token.enabled ? 'badge-success' : 'badge-muted'}`} style={{ fontSize: 11 }}>
{token.enabled ? '启用' : '禁用'} {token.enabled ? '启用' : '禁用'}
@@ -1111,11 +1226,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
{rowLoading[`${loadingPrefix}-toggle`] ? <span className="spinner spinner-sm" /> : (token.enabled ? '禁用' : '启用')} {rowLoading[`${loadingPrefix}-toggle`] ? <span className="spinner spinner-sm" /> : (token.enabled ? '禁用' : '启用')}
</button> </button>
<button <button
onClick={() => withRowLoading(`${loadingPrefix}-delete`, async () => { onClick={() => setDeleteConfirm({ mode: 'single', tokenId: token.id, tokenName: token.name || '' })}
await api.deleteAccountToken(token.id);
toast.success('令牌已删除');
await load();
})}
disabled={!!rowLoading[`${loadingPrefix}-delete`]} disabled={!!rowLoading[`${loadingPrefix}-delete`]}
className="btn btn-link btn-link-danger token-table-action-btn" className="btn btn-link btn-link-danger token-table-action-btn"
> >
+55
View File
@@ -85,4 +85,59 @@ describe('Sites edit behavior', () => {
root?.unmount(); 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 batchButton = root.root.find((node) => node.props['data-testid'] === 'tokens-batch-delete');
const originalConfirm = globalThis.confirm; await act(async () => {
globalThis.confirm = vi.fn(() => true); batchButton.props.onClick();
try { });
await act(async () => { await flushMicrotasks();
batchButton.props.onClick();
}); const confirmButton = root.root
await flushMicrotasks(); .findAll((node) => node.type === 'button')
} finally { .find((node) => Array.isArray(node.children) && node.children.some((child) => child === '确认删除'));
globalThis.confirm = originalConfirm; expect(confirmButton).toBeTruthy();
}
await act(async () => {
confirmButton!.props.onClick();
});
await flushMicrotasks();
expect(apiMock.batchUpdateAccountTokens).toHaveBeenCalledWith({ expect(apiMock.batchUpdateAccountTokens).toHaveBeenCalledWith({
ids: [1, 2], ids: [1, 2],
@@ -10,6 +10,7 @@ const { apiMock } = vi.hoisted(() => ({
getSites: vi.fn(), getSites: vi.fn(),
getAccountTokens: vi.fn(), getAccountTokens: vi.fn(),
getAccountTokenValue: vi.fn(), getAccountTokenValue: vi.fn(),
getAccountTokenGroups: vi.fn(),
updateAccountToken: vi.fn(), updateAccountToken: vi.fn(),
}, },
})); }));
@@ -98,6 +99,10 @@ describe('Tokens edit modal and row selection', () => {
success: true, success: true,
token: 'sk-focus-real', token: 'sk-focus-real',
}); });
apiMock.getAccountTokenGroups.mockResolvedValue({
success: true,
groups: ['default', 'vip'],
});
apiMock.updateAccountToken.mockResolvedValue({ apiMock.updateAccountToken.mockResolvedValue({
success: true, success: true,
}); });
@@ -131,11 +136,25 @@ describe('Tokens edit modal and row selection', () => {
expect(rendered).toContain('sk-focus-real'); expect(rendered).toContain('sk-focus-real');
expect(rendered).toContain('基本信息'); expect(rendered).toContain('基本信息');
expect(rendered).toContain('状态设置'); expect(rendered).toContain('状态设置');
expect(rendered).toContain('分组');
const modals = root.root.findAll((node) => { const modals = root.root.findAll((node) => {
const className = typeof node.props?.className === 'string' ? node.props.className : ''; const className = typeof node.props?.className === 'string' ? node.props.className : '';
return className.includes('modal-content') && collectText(node).includes('编辑令牌'); return className.includes('modal-content') && collectText(node).includes('编辑令牌');
}); });
expect(modals).toHaveLength(1); 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 { } finally {
root?.unmount(); root?.unmount();
} }
@@ -181,4 +200,32 @@ describe('Tokens edit modal and row selection', () => {
root?.unmount(); 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();
}
});
}); });