diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d5afa11..e684118 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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_TEAM_ID_INPUT:-}" ]; then export APPLE_TEAM_ID="$APPLE_TEAM_ID_INPUT"; fi 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 break fi @@ -210,6 +210,20 @@ jobs: fi sleep 90 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 uses: actions/upload-artifact@v4 diff --git a/README.md b/README.md index 4756f70..34291fb 100644 --- a/README.md +++ b/README.md @@ -390,6 +390,7 @@ npm run dev npm run build # 构建前端 + 后端 npm run build:web # 仅构建前端(Vite) npm run build:server # 仅构建后端(TypeScript) +npm run dist:desktop:mac:intel # 构建 mac Intel (x64) 桌面安装包 npm test # 运行全部测试 npm run test:watch # 监听模式 npm run db:generate # 生成 Drizzle 迁移文件 diff --git a/README_EN.md b/README_EN.md index 3eb5eda..e357345 100644 --- a/README_EN.md +++ b/README_EN.md @@ -498,6 +498,7 @@ npm run dev npm run build # Build frontend + backend npm run build:web # Build frontend only (Vite) 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 run test:watch # Watch mode npm run db:generate # Generate Drizzle migration files diff --git a/package.json b/package.json index 912c631..fece62c 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,9 @@ "build:desktop": "tsc -p tsconfig.desktop.json", "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: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: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:build": "vitepress build docs", "docs:preview": "vitepress preview docs --host 0.0.0.0 --port 4173", diff --git a/src/server/routes/api/accountTokens.sync.test.ts b/src/server/routes/api/accountTokens.sync.test.ts index b493655..8b594d4 100644 --- a/src/server/routes/api/accountTokens.sync.test.ts +++ b/src/server/routes/api/accountTokens.sync.test.ts @@ -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, + }); + }); }); diff --git a/src/server/routes/api/accountTokens.ts b/src/server/routes/api/accountTokens.ts index 7ed061d..2ab0279 100644 --- a/src/server/routes/api/accountTokens.ts +++ b/src/server/routes/api/accountTokens.ts @@ -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 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, diff --git a/src/server/services/accountTokenService.ts b/src/server/services/accountTokenService.ts index 57cf713..56954fa 100644 --- a/src/server/services/accountTokenService.ts +++ b/src/server/services/accountTokenService.ts @@ -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) { }; }); } - + diff --git a/src/server/services/platforms/newApi.test.ts b/src/server/services/platforms/newApi.test.ts index fefb94f..b11d090 100644 --- a/src/server/services/platforms/newApi.test.ts +++ b/src/server/services/platforms/newApi.test.ts @@ -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((resolve, reject) => { server.close((err?: Error) => (err ? reject(err) : resolve())); diff --git a/src/server/services/platforms/newApi.ts b/src/server/services/platforms/newApi.ts index 98b0e8d..7d2c3fb 100644 --- a/src/server/services/platforms/newApi.ts +++ b/src/server/services/platforms/newApi.ts @@ -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 { 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(`${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(`${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(`${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(`${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']; } diff --git a/src/server/services/platforms/oneApi.ts b/src/server/services/platforms/oneApi.ts index 4c83b13..1b0851a 100644 --- a/src/server/services/platforms/oneApi.ts +++ b/src/server/services/platforms/oneApi.ts @@ -129,24 +129,49 @@ export class OneApiAdapter extends BasePlatformAdapter { async getUserGroups(baseUrl: string, accessToken: string): Promise { 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(`${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(`${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']; } diff --git a/src/web/api.ts b/src/web/api.ts index 2a6df9e..eedb55f 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -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 = { diff --git a/src/web/components/DeleteConfirmModal.tsx b/src/web/components/DeleteConfirmModal.tsx new file mode 100644 index 0000000..5c9f0d4 --- /dev/null +++ b/src/web/components/DeleteConfirmModal.tsx @@ -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 ( + + + + + )} + > +
+
此操作不可撤销
+
{description}
+
+
+ ); +} diff --git a/src/web/pages/Accounts.tsx b/src/web/pages/Accounts.tsx index b28584d..2e66b46 100644 --- a/src/web/pages/Accounts.tsx +++ b/src/web/pages/Accounts.tsx @@ -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(null); const [selectedAccountIds, setSelectedAccountIds] = useState([]); const [batchActionLoading, setBatchActionLoading] = useState(false); + const [deleteConfirm, setDeleteConfirm] = useState(null); const [editingAccount, setEditingAccount] = useState(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) => { if (shouldIgnoreRowSelectionClick(event.target)) return; const isSelected = selectedAccountIds.includes(accountId); @@ -831,6 +855,18 @@ export default function Accounts() { ))} + setDeleteConfirm(null)} + onConfirm={confirmDelete} + title="确认删除连接" + confirmText="确认删除" + loading={batchActionLoading || (deleteConfirm?.mode === 'single' && !!actionLoading[`delete-${deleteConfirm?.accountId}`])} + description={deleteConfirm?.mode === 'single' + ? <>确定要删除连接 {deleteConfirm.accountName || `#${deleteConfirm.accountId}`} 吗? + : <>确定要删除选中的 {deleteConfirm?.count || 0} 个连接吗?} + /> + {!isMobile && activeSegment !== 'tokens' && selectedAccountIds.length > 0 && (
已选 {selectedAccountIds.length} 项 @@ -1554,7 +1590,7 @@ export default function Accounts() { -
@@ -1740,7 +1776,7 @@ export default function Accounts() { - diff --git a/src/web/pages/Sites.tsx b/src/web/pages/Sites.tsx index e86993c..4d63527 100644 --- a/src/web/pages/Sites.tsx +++ b/src/web/pages/Sites.tsx @@ -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([]); const isMobile = useIsMobile(768); const [batchActionLoading, setBatchActionLoading] = useState(false); + const [deleteConfirm, setDeleteConfirm] = useState(null); const lastEditorRef = useRef(null); const rowRefs = useRef>(new Map()); const highlightTimerRef = useRef(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) => { 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。 + setDeleteConfirm(null)} + onConfirm={confirmDelete} + title="确认删除站点" + confirmText="确认删除" + loading={batchActionLoading || (deleteConfirm?.mode === 'single' && deleting === deleteConfirm?.siteId)} + description={deleteConfirm?.mode === 'single' + ? <>确定要删除站点 {deleteConfirm.siteName || `#${deleteConfirm.siteId}`} 吗? + : <>确定要删除选中的 {deleteConfirm?.count || 0} 个站点吗?} + /> + {activeEditor && ( >({}); const [selectedTokenIds, setSelectedTokenIds] = useState([]); const [batchActionLoading, setBatchActionLoading] = useState(false); + const [deleteConfirm, setDeleteConfirm] = useState(null); const [form, setForm] = useState(initialCreateForm); const [editForm, setEditForm] = useState({ name: '', token: '', + group: 'default', enabled: true, isDefault: false, }); const [groupOptions, setGroupOptions] = useState(['default']); const [groupLoading, setGroupLoading] = useState(false); + const [editGroupOptions, setEditGroupOptions] = useState(['default']); + const [editGroupLoading, setEditGroupLoading] = useState(false); const rowRefs = useRef>(new Map()); const highlightTimerRef = useRef | null>(null); const editingTokenIdRef = useRef(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 白名单;已存在密钥可直接用“同步站点令牌”读取。 + setDeleteConfirm(null)} + onConfirm={confirmDelete} + title="确认删除令牌" + confirmText="确认删除" + loading={batchActionLoading || (deleteConfirm?.mode === 'single' && !!rowLoading[`token-${deleteConfirm?.tokenId}-delete`])} + description={deleteConfirm?.mode === 'single' + ? <>确定要删除令牌 {deleteConfirm.tokenName || `#${deleteConfirm.tokenId}`} 吗? + : <>确定要删除选中的 {deleteConfirm?.count || 0} 个令牌吗?} + /> + +
+
分组
+ setEditForm((prev) => ({ ...prev, group: nextValue || 'default' }))} + options={(editGroupOptions.length > 0 ? editGroupOptions : ['default']).map((group) => ({ + value: group, + label: group, + }))} + placeholder={editGroupLoading ? '分组加载中...' : '选择分组'} + disabled={editGroupLoading} + /> +
状态设置
@@ -890,7 +1006,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token ) : tokens.length > 0 ? ( isMobile ? (
- {tokens.map((token: any) => { + {accountClusteredTokens.map((token: any) => { const loadingPrefix = `token-${token.id}`; return ( + : (token.enabled ? '禁用' : '启用')}