publish: 1.0.0 snapshot (bad2ec1)

channel: master
version: 1.0.0
source-ref: master
published-at-utc: 2026-03-13T15:19:28Z
This commit is contained in:
Ant Browser Release Bot
2026-03-13 23:19:29 +08:00
commit 6f58a6c19a
230 changed files with 44624 additions and 0 deletions
+767
View File
@@ -0,0 +1,767 @@
import type { BrowserProfile, BrowserProfileInput, BrowserTab, BrowserSettings, BrowserCore, BrowserCoreInput, BrowserCoreValidateResult, BrowserProxy, BrowserCoreExtended, CookieInfo, SnapshotInfo, BrowserBookmark, BrowserGroup, BrowserGroupInput, BrowserGroupWithCount, ProxyIPHealthResult } from './types'
const getBindings = async () => {
try {
return await import('../../wailsjs/go/main/App')
} catch {
return null
}
}
let mockProfiles: BrowserProfile[] = [
{
profileId: 'mock-1',
profileName: '默认指纹配置',
userDataDir: 'data/default',
coreId: 'default',
fingerprintArgs: ['--fingerprint-brand=Chrome', '--fingerprint-platform=windows'],
proxyId: '',
proxyConfig: '',
launchArgs: ['--disable-features=Translate'],
tags: ['默认'],
keywords: [],
running: false,
debugPort: 0,
pid: 0,
lastError: '',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
]
let mockCores: BrowserCore[] = []
let mockProxies: BrowserProxy[] = []
// ============================================================================
// Profile API
// ============================================================================
export async function fetchBrowserProfiles(): Promise<BrowserProfile[]> {
const bindings: any = await getBindings()
if (bindings?.BrowserProfileList) {
return (await bindings.BrowserProfileList()) || []
}
return mockProfiles
}
export async function fetchBrowserProfilesByTag(tag: string): Promise<BrowserProfile[]> {
const bindings: any = await getBindings()
if (bindings?.BrowserProfileListByTag) {
return (await bindings.BrowserProfileListByTag(tag)) || []
}
return mockProfiles.filter(p => p.tags?.includes(tag))
}
export async function fetchAllTags(): Promise<string[]> {
const bindings: any = await getBindings()
if (bindings?.BrowserGetAllTags) {
return (await bindings.BrowserGetAllTags()) || []
}
const set = new Set<string>()
mockProfiles.forEach(p => p.tags?.forEach(t => set.add(t)))
return Array.from(set).sort()
}
export async function createBrowserProfile(input: BrowserProfileInput): Promise<BrowserProfile | null> {
const bindings: any = await getBindings()
if (bindings?.BrowserProfileCreate) {
return (await bindings.BrowserProfileCreate(input)) || null
}
const profile: BrowserProfile = {
profileId: `mock-${Date.now()}`,
...input,
keywords: input.keywords || {},
running: false,
debugPort: 0,
pid: 0,
lastError: '',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
mockProfiles = [profile, ...mockProfiles]
return profile
}
export async function updateBrowserProfile(profileId: string, input: BrowserProfileInput): Promise<BrowserProfile | null> {
const bindings: any = await getBindings()
if (bindings?.BrowserProfileUpdate) {
return (await bindings.BrowserProfileUpdate(profileId, input)) || null
}
const index = mockProfiles.findIndex(item => item.profileId === profileId)
if (index === -1) return null
mockProfiles[index] = { ...mockProfiles[index], ...input, updatedAt: new Date().toISOString() }
return mockProfiles[index]
}
export async function deleteBrowserProfile(profileId: string): Promise<boolean> {
const bindings: any = await getBindings()
if (bindings?.BrowserProfileDelete) {
await bindings.BrowserProfileDelete(profileId)
return true
}
mockProfiles = mockProfiles.filter(item => item.profileId !== profileId)
return true
}
export async function copyBrowserProfile(profileId: string, newName: string): Promise<BrowserProfile | null> {
const bindings: any = await getBindings()
if (bindings?.BrowserProfileCopy) {
return (await bindings.BrowserProfileCopy(profileId, newName)) || null
}
// mock
const src = mockProfiles.find(p => p.profileId === profileId)
if (!src) return null
const copy: BrowserProfile = {
...src,
profileId: `mock-${Date.now()}`,
profileName: newName || src.profileName + ' (副本)',
userDataDir: `mock-${Date.now()}`,
running: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
mockProfiles = [copy, ...mockProfiles]
return copy
}
// ============================================================================
// Instance API
// ============================================================================
export async function startBrowserInstance(profileId: string): Promise<BrowserProfile | null> {
const bindings: any = await getBindings()
if (bindings?.BrowserInstanceStart) {
return (await bindings.BrowserInstanceStart(profileId)) || null
}
mockProfiles = mockProfiles.map(item =>
item.profileId === profileId ? { ...item, running: true, debugPort: 9222, pid: Math.floor(Math.random() * 100000), lastStartAt: new Date().toISOString() } : item
)
return mockProfiles.find(item => item.profileId === profileId) || null
}
export async function startBrowserInstanceByCode(code: string): Promise<BrowserProfile | null> {
const bindings: any = await getBindings()
if (bindings?.BrowserInstanceStartByCode) {
return (await bindings.BrowserInstanceStartByCode(code)) || null
}
const normalized = code.trim().toUpperCase()
const profile = mockProfiles.find(item => (item.launchCode || '').toUpperCase() === normalized)
if (!profile) {
throw new Error('launch code not found')
}
return await startBrowserInstance(profile.profileId)
}
export async function stopBrowserInstance(profileId: string): Promise<BrowserProfile | null> {
const bindings: any = await getBindings()
if (bindings?.BrowserInstanceStop) {
return (await bindings.BrowserInstanceStop(profileId)) || null
}
mockProfiles = mockProfiles.map(item =>
item.profileId === profileId ? { ...item, running: false, pid: 0, lastStopAt: new Date().toISOString() } : item
)
return mockProfiles.find(item => item.profileId === profileId) || null
}
export async function restartBrowserInstance(profileId: string): Promise<BrowserProfile | null> {
const bindings: any = await getBindings()
if (bindings?.BrowserInstanceRestart) {
return (await bindings.BrowserInstanceRestart(profileId)) || null
}
await stopBrowserInstance(profileId)
return await startBrowserInstance(profileId)
}
export async function openBrowserUrl(profileId: string, targetUrl: string): Promise<boolean> {
const bindings: any = await getBindings()
if (bindings?.BrowserInstanceOpenUrl) {
return (await bindings.BrowserInstanceOpenUrl(profileId, targetUrl)) === true
}
return true
}
export async function fetchBrowserTabs(profileId: string): Promise<BrowserTab[]> {
const bindings: any = await getBindings()
if (bindings?.BrowserInstanceGetTabs) {
return (await bindings.BrowserInstanceGetTabs(profileId)) || []
}
return [
{ tabId: 'tab-1', title: '新标签页', url: 'about:blank', active: true },
{ tabId: 'tab-2', title: '示例站点', url: 'https://example.com', active: false },
]
}
// ============================================================================
// Settings API
// ============================================================================
export async function fetchBrowserSettings(): Promise<BrowserSettings> {
const bindings: any = await getBindings()
if (bindings?.GetBrowserSettings) {
return (await bindings.GetBrowserSettings()) || { userDataRoot: 'data', defaultFingerprintArgs: [], defaultLaunchArgs: [], defaultProxy: '' }
}
return { userDataRoot: 'data', defaultFingerprintArgs: [], defaultLaunchArgs: [], defaultProxy: '' }
}
export async function saveBrowserSettings(settings: BrowserSettings): Promise<boolean> {
const bindings: any = await getBindings()
if (bindings?.SaveBrowserSettings) {
await bindings.SaveBrowserSettings(settings)
return true
}
return true
}
// ============================================================================
// Core API
// ============================================================================
export async function fetchBrowserCores(): Promise<BrowserCore[]> {
const bindings: any = await getBindings()
if (bindings?.BrowserCoreList) {
return (await bindings.BrowserCoreList()) || []
}
return mockCores
}
export async function saveBrowserCore(input: BrowserCoreInput): Promise<boolean> {
const bindings: any = await getBindings()
if (bindings?.BrowserCoreSave) {
await bindings.BrowserCoreSave(input)
return true
}
const index = mockCores.findIndex(c => c.coreId === input.coreId)
if (index >= 0) {
mockCores[index] = input
} else {
mockCores.push({ ...input, coreId: input.coreId || `core-${Date.now()}` })
}
return true
}
export async function deleteBrowserCore(coreId: string): Promise<boolean> {
const bindings: any = await getBindings()
if (bindings?.BrowserCoreDelete) {
await bindings.BrowserCoreDelete(coreId)
return true
}
mockCores = mockCores.filter(c => c.coreId !== coreId)
return true
}
export async function setDefaultBrowserCore(coreId: string): Promise<boolean> {
const bindings: any = await getBindings()
if (bindings?.BrowserCoreSetDefault) {
await bindings.BrowserCoreSetDefault(coreId)
return true
}
mockCores = mockCores.map(c => ({ ...c, isDefault: c.coreId === coreId }))
return true
}
export async function validateBrowserCorePath(corePath: string): Promise<BrowserCoreValidateResult> {
const bindings: any = await getBindings()
if (bindings?.BrowserCoreValidate) {
return (await bindings.BrowserCoreValidate(corePath)) || { valid: false, message: '验证失败' }
}
return { valid: true, message: '路径有效(模拟)' }
}
export async function fetchCoreExtendedInfo(): Promise<BrowserCoreExtended[]> {
const bindings: any = await getBindings()
if (bindings?.BrowserCoreExtendedInfo) {
return (await bindings.BrowserCoreExtendedInfo()) || []
}
return []
}
export async function scanBrowserCores(): Promise<BrowserCore[]> {
const bindings: any = await getBindings()
if (bindings?.BrowserCoreScan) {
return (await bindings.BrowserCoreScan()) || []
}
return mockCores
}
export async function BrowserCoreDownload(coreName: string, url: string, proxyConfig?: string): Promise<boolean> {
const bindings: any = await getBindings()
if (bindings?.BrowserCoreDownload) {
await bindings.BrowserCoreDownload(coreName, url, proxyConfig || '')
return true
}
return true
}
// ============================================================================
// Proxy API
// ============================================================================
export async function fetchBrowserProxies(): Promise<BrowserProxy[]> {
const bindings: any = await getBindings()
if (bindings?.BrowserProxyList) {
return (await bindings.BrowserProxyList()) || []
}
return mockProxies
}
export async function fetchBrowserProxyGroups(): Promise<string[]> {
const bindings: any = await getBindings()
if (bindings?.BrowserProxyListGroups) {
return (await bindings.BrowserProxyListGroups()) || []
}
return []
}
export async function fetchBrowserProxiesByGroup(groupName: string): Promise<BrowserProxy[]> {
const bindings: any = await getBindings()
if (bindings?.BrowserProxyListByGroup) {
return (await bindings.BrowserProxyListByGroup(groupName)) || []
}
return mockProxies.filter(p => p.groupName === groupName)
}
export interface ClashImportURLResult {
url: string
content: string
proxyCount: number
dnsServers?: string
suggestedGroup?: string
}
export async function fetchClashImportFromURL(targetURL: string): Promise<ClashImportURLResult> {
const bindings: any = await getBindings()
if (bindings?.BrowserProxyFetchClashByURL) {
return (await bindings.BrowserProxyFetchClashByURL(targetURL)) || {
url: targetURL,
content: '',
proxyCount: 0,
}
}
// 兜底:wailsjs 尚未刷新时,直接通过 window.go 调用后端绑定
const goApp = (window as any).go?.main?.App
if (goApp?.BrowserProxyFetchClashByURL) {
return (await goApp.BrowserProxyFetchClashByURL(targetURL)) || {
url: targetURL,
content: '',
proxyCount: 0,
}
}
throw new Error('当前环境不支持 URL 导入 Clash 配置')
}
export async function saveBrowserProxies(proxies: BrowserProxy[]): Promise<boolean> {
const bindings: any = await getBindings()
if (bindings?.SaveBrowserProxies) {
await bindings.SaveBrowserProxies(proxies)
return true
}
mockProxies = proxies
return true
}
export async function validateProxyConfig(proxyConfig: string, proxyId: string): Promise<{ supported: boolean; errorMsg: string }> {
const bindings: any = await getBindings()
if (bindings?.ValidateProxyConfig) {
return (await bindings.ValidateProxyConfig(proxyConfig, proxyId)) || { supported: true, errorMsg: '' }
}
return { supported: true, errorMsg: '' }
}
export async function testProxyConnectivity(proxyId: string, proxyConfig: string): Promise<{ proxyId: string; ok: boolean; latencyMs: number; error: string }> {
const bindings: any = await getBindings()
if (bindings?.TestProxyConnectivity) {
return (await bindings.TestProxyConnectivity(proxyId, proxyConfig)) || { proxyId, ok: false, latencyMs: 0, error: '调用失败' }
}
// mock: simulate latency
await new Promise(r => setTimeout(r, 300 + Math.random() * 500))
return { proxyId, ok: true, latencyMs: Math.floor(100 + Math.random() * 200), error: '' }
}
export async function testProxyRealConnectivity(proxyId: string): Promise<{ proxyId: string; ok: boolean; latencyMs: number; error: string }> {
const bindings: any = await getBindings()
if (bindings?.TestProxyRealConnectivity) {
return (await bindings.TestProxyRealConnectivity(proxyId)) || { proxyId, ok: false, latencyMs: 0, error: '调用失败' }
}
// mock: simulate latency 300-800ms
await new Promise(r => setTimeout(r, 300 + Math.random() * 500))
return { proxyId, ok: true, latencyMs: Math.floor(100 + Math.random() * 400), error: '' }
}
export async function browserProxyTestSpeed(proxyId: string): Promise<{ proxyId: string; ok: boolean; latencyMs: number; error: string }> {
const bindings: any = await getBindings()
if (bindings?.BrowserProxyTestSpeed) {
return (await bindings.BrowserProxyTestSpeed(proxyId)) || { proxyId, ok: false, latencyMs: 0, error: '调用失败' }
}
await new Promise(r => setTimeout(r, 300 + Math.random() * 500))
return { proxyId, ok: true, latencyMs: Math.floor(100 + Math.random() * 400), error: '' }
}
export async function browserProxyBatchTestSpeed(proxyIds: string[], concurrency: number = 20): Promise<{ proxyId: string; ok: boolean; latencyMs: number; error: string }[]> {
const bindings: any = await getBindings()
if (bindings?.BrowserProxyBatchTestSpeed) {
return (await bindings.BrowserProxyBatchTestSpeed(proxyIds, concurrency)) || []
}
// mock
await new Promise(r => setTimeout(r, 1000))
return proxyIds.map(id => ({ proxyId: id, ok: true, latencyMs: Math.floor(100 + Math.random() * 400), error: '' }))
}
export async function browserProxyCheckIPHealth(proxyId: string): Promise<ProxyIPHealthResult> {
const bindings: any = await getBindings()
if (bindings?.BrowserProxyCheckIPHealth) {
return (await bindings.BrowserProxyCheckIPHealth(proxyId)) || {
proxyId,
ok: false,
source: 'ippure',
error: '调用失败',
ip: '',
fraudScore: 0,
isResidential: false,
isBroadcast: false,
country: '',
region: '',
city: '',
asOrganization: '',
rawData: {},
updatedAt: new Date().toISOString(),
}
}
await new Promise(r => setTimeout(r, 600))
return {
proxyId,
ok: true,
source: 'ippure',
error: '',
ip: '127.0.0.1',
fraudScore: Math.floor(Math.random() * 100),
isResidential: Math.random() > 0.5,
isBroadcast: false,
country: 'Mock',
region: 'Mock',
city: 'Mock',
asOrganization: 'Mock ISP',
rawData: {},
updatedAt: new Date().toISOString(),
}
}
export async function browserProxyBatchCheckIPHealth(proxyIds: string[], concurrency: number = 10): Promise<ProxyIPHealthResult[]> {
const bindings: any = await getBindings()
if (bindings?.BrowserProxyBatchCheckIPHealth) {
return (await bindings.BrowserProxyBatchCheckIPHealth(proxyIds, concurrency)) || []
}
await new Promise(r => setTimeout(r, 1200))
return proxyIds.map(proxyId => ({
proxyId,
ok: true,
source: 'ippure',
error: '',
ip: '127.0.0.1',
fraudScore: Math.floor(Math.random() * 100),
isResidential: Math.random() > 0.5,
isBroadcast: false,
country: 'Mock',
region: 'Mock',
city: 'Mock',
asOrganization: 'Mock ISP',
rawData: {},
updatedAt: new Date().toISOString(),
}))
}
export async function openUserDataDir(userDataDir: string): Promise<boolean> {
const bindings: any = await getBindings()
if (bindings?.OpenUserDataDir) {
await bindings.OpenUserDataDir(userDataDir)
return true
}
return false
}
export async function openCorePath(corePath: string): Promise<boolean> {
const bindings: any = await getBindings()
if (bindings?.OpenCorePath) {
await bindings.OpenCorePath(corePath)
return true
}
return false
}
// ============================================================================
// Cookie API
// ============================================================================
export async function fetchBrowserCookies(profileId: string): Promise<CookieInfo[]> {
const bindings: any = await getBindings()
if (bindings?.BrowserGetCookies) {
return (await bindings.BrowserGetCookies(profileId)) || []
}
// mock data
return [
{ name: 'session', value: 'abc123', domain: '.example.com', path: '/', expires: Date.now() / 1000 + 3600, httpOnly: true, secure: true, sameSite: 'Lax' },
{ name: 'pref', value: 'dark', domain: 'example.com', path: '/', expires: -1, httpOnly: false, secure: false, sameSite: 'None' },
]
}
export async function clearBrowserCookies(profileId: string): Promise<boolean> {
const bindings: any = await getBindings()
if (bindings?.BrowserClearCookies) {
await bindings.BrowserClearCookies(profileId)
return true
}
return true
}
export async function exportBrowserCookies(profileId: string): Promise<string> {
const bindings: any = await getBindings()
if (bindings?.BrowserExportCookies) {
return (await bindings.BrowserExportCookies(profileId)) || ''
}
return '# Netscape HTTP Cookie File\n# Generated by BrowserManager\n\n.example.com\tTRUE\t/\tTRUE\t0\tsession\tabc123\n'
}
// ============================================================================
// Snapshot API
// ============================================================================
export async function listSnapshots(profileId: string): Promise<SnapshotInfo[]> {
const bindings: any = await getBindings()
if (bindings?.BrowserSnapshotList) {
return (await bindings.BrowserSnapshotList(profileId)) || []
}
return []
}
export async function createSnapshot(profileId: string, name: string): Promise<SnapshotInfo | null> {
const bindings: any = await getBindings()
if (bindings?.BrowserSnapshotCreate) {
return (await bindings.BrowserSnapshotCreate(profileId, name)) || null
}
// mock
return {
snapshotId: `snap-${Date.now()}`,
profileId,
name,
sizeMB: 12.5,
createdAt: new Date().toISOString(),
}
}
export async function restoreSnapshot(profileId: string, snapshotId: string): Promise<boolean> {
const bindings: any = await getBindings()
if (bindings?.BrowserSnapshotRestore) {
await bindings.BrowserSnapshotRestore(profileId, snapshotId)
return true
}
return true
}
export async function deleteSnapshot(profileId: string, snapshotId: string): Promise<boolean> {
const bindings: any = await getBindings()
if (bindings?.BrowserSnapshotDelete) {
await bindings.BrowserSnapshotDelete(profileId, snapshotId)
return true
}
return true
}
// ============================================================================
// Bookmark API
// ============================================================================
export async function fetchBookmarks(): Promise<BrowserBookmark[]> {
const bindings: any = await getBindings()
if (bindings?.BookmarkList) {
return (await bindings.BookmarkList()) || []
}
return [
{ name: 'Google', url: 'https://www.google.com/' },
{ name: 'Gmail', url: 'https://mail.google.com/' },
{ name: 'Claude', url: 'https://claude.ai/' },
{ name: 'ChatGPT', url: 'https://chatgpt.com/' },
{ name: 'YouTube', url: 'https://www.youtube.com/' },
]
}
export async function saveBookmarks(items: BrowserBookmark[]): Promise<boolean> {
const bindings: any = await getBindings()
if (bindings?.BookmarkSave) {
await bindings.BookmarkSave(items)
return true
}
return true
}
export async function resetBookmarks(): Promise<boolean> {
const bindings: any = await getBindings()
if (bindings?.BookmarkReset) {
await bindings.BookmarkReset()
return true
}
return true
}
// ============================================================================
// Keywords API
// ============================================================================
export async function setProfileKeywords(profileId: string, keywords: string[]): Promise<BrowserProfile | null> {
const bindings: any = await getBindings()
if (bindings?.BrowserProfileSetKeywords) {
return (await bindings.BrowserProfileSetKeywords(profileId, keywords)) || null
}
mockProfiles = mockProfiles.map(p =>
p.profileId === profileId ? { ...p, keywords, updatedAt: new Date().toISOString() } : p
)
return mockProfiles.find(p => p.profileId === profileId) || null
}
// ============================================================================
// LaunchCode API
// ============================================================================
export interface LaunchServerInfo {
host: string
port: number
preferredPort: number
baseUrl: string
ready: boolean
}
function normalizeLaunchServerInfo(payload: any): LaunchServerInfo {
const host = String(payload?.host || '127.0.0.1')
const port = Number(payload?.port) || 0
const preferredPort = Number(payload?.preferredPort) || 0
const fallbackPort = preferredPort > 0 ? preferredPort : 19876
const effectivePort = port > 0 ? port : fallbackPort
const baseUrl = String(payload?.baseUrl || (effectivePort > 0 ? `http://${host}:${effectivePort}` : ''))
return {
host,
port: effectivePort,
preferredPort,
baseUrl,
ready: !!payload?.ready && port > 0,
}
}
export async function fetchLaunchServerInfo(): Promise<LaunchServerInfo> {
const bindings: any = await getBindings()
if (bindings?.GetLaunchServerInfo) {
return normalizeLaunchServerInfo(await bindings.GetLaunchServerInfo())
}
const goApp = (window as any).go?.main?.App
if (goApp?.GetLaunchServerInfo) {
return normalizeLaunchServerInfo(await goApp.GetLaunchServerInfo())
}
return {
host: '127.0.0.1',
port: 19876,
preferredPort: 19876,
baseUrl: 'http://127.0.0.1:19876',
ready: false,
}
}
export async function getBrowserProfileCode(profileId: string): Promise<string> {
const bindings: any = await getBindings()
if (bindings?.BrowserProfileGetCode) {
return (await bindings.BrowserProfileGetCode(profileId)) || ''
}
return ''
}
export async function regenerateBrowserProfileCode(profileId: string): Promise<string> {
const bindings: any = await getBindings()
if (bindings?.BrowserProfileRegenerateCode) {
return (await bindings.BrowserProfileRegenerateCode(profileId)) || ''
}
return ''
}
export async function setBrowserProfileCode(profileId: string, code: string): Promise<string> {
const bindings: any = await getBindings()
if (bindings?.BrowserProfileSetCode) {
return (await bindings.BrowserProfileSetCode(profileId, code)) || ''
}
return code.trim().toUpperCase()
}
export async function batchSetProfileTags(profileIds: string[], tags: string[], replace: boolean): Promise<boolean> {
const bindings: any = await getBindings()
if (bindings?.BrowserProfileBatchSetTags) {
await bindings.BrowserProfileBatchSetTags(profileIds, tags, replace)
return true
}
return true
}
export async function batchRemoveProfileTags(profileIds: string[], tags: string[]): Promise<boolean> {
const bindings: any = await getBindings()
if (bindings?.BrowserProfileBatchRemoveTags) {
await bindings.BrowserProfileBatchRemoveTags(profileIds, tags)
return true
}
return true
}
export async function renameBrowserTag(oldName: string, newName: string): Promise<boolean> {
const bindings: any = await getBindings()
if (bindings?.BrowserRenameTag) {
await bindings.BrowserRenameTag(oldName, newName)
return true
}
return true
}
// ============================================================================
// Group API
// ============================================================================
export async function fetchGroups(): Promise<BrowserGroupWithCount[]> {
const bindings: any = await getBindings()
if (bindings?.ListGroups) {
return (await bindings.ListGroups()) || []
}
return []
}
export async function createGroup(input: BrowserGroupInput): Promise<BrowserGroup | null> {
const bindings: any = await getBindings()
if (bindings?.CreateGroup) {
return (await bindings.CreateGroup(input)) || null
}
return null
}
export async function updateGroup(groupId: string, input: BrowserGroupInput): Promise<BrowserGroup | null> {
const bindings: any = await getBindings()
if (bindings?.UpdateGroup) {
return (await bindings.UpdateGroup(groupId, input)) || null
}
return null
}
export async function deleteGroup(groupId: string): Promise<boolean> {
const bindings: any = await getBindings()
if (bindings?.DeleteGroup) {
await bindings.DeleteGroup(groupId)
return true
}
return false
}
export async function moveInstancesToGroup(profileIds: string[], groupId: string): Promise<boolean> {
const bindings: any = await getBindings()
if (bindings?.MoveInstancesToGroup) {
await bindings.MoveInstancesToGroup(profileIds, groupId)
return true
}
return false
}
@@ -0,0 +1,160 @@
import { useState } from 'react'
import { CheckCircle, Edit2, Plus, Star, Trash2, XCircle } from 'lucide-react'
import { Button, Card, FormItem, Input, Modal, Table, Textarea, toast } from '../../../shared/components'
import type { TableColumn } from '../../../shared/components/Table'
import type { BrowserCore, BrowserCoreInput, BrowserSettings } from '../types'
import {
deleteBrowserCore,
saveBrowserCore,
saveBrowserSettings,
setDefaultBrowserCore,
validateBrowserCorePath,
} from '../api'
interface BrowserSettingsModalProps {
open: boolean
onClose: () => void
settings: BrowserSettings
cores: BrowserCore[]
onCoresChange: (cores: BrowserCore[]) => void
}
export function BrowserSettingsModal({ open, onClose, settings: initSettings, cores, onCoresChange }: BrowserSettingsModalProps) {
const [settings, setSettings] = useState<BrowserSettings>(initSettings)
const [fingerprintText, setFingerprintText] = useState((initSettings.defaultFingerprintArgs || []).join('\n'))
const [launchText, setLaunchText] = useState((initSettings.defaultLaunchArgs || []).join('\n'))
const [saving, setSaving] = useState(false)
// 内核编辑弹窗
const [coreModalOpen, setCoreModalOpen] = useState(false)
const [coreForm, setCoreForm] = useState<BrowserCoreInput>({ coreId: '', coreName: '', corePath: '', isDefault: false })
const [coreValidation, setCoreValidation] = useState<{ valid: boolean; message: string } | null>(null)
const [savingCore, setSavingCore] = useState(false)
const handleSave = async () => {
setSaving(true)
try {
await saveBrowserSettings({
...settings,
defaultFingerprintArgs: fingerprintText.split('\n').map(s => s.trim()).filter(Boolean),
defaultLaunchArgs: launchText.split('\n').map(s => s.trim()).filter(Boolean),
})
toast.success('配置已保存')
onClose()
} catch (error: any) {
toast.error(error?.message || '保存失败')
} finally {
setSaving(false)
}
}
const handleOpenCoreModal = (core?: BrowserCore) => {
setCoreForm(core ? { ...core } : { coreId: '', coreName: '', corePath: '', isDefault: false })
setCoreValidation(null)
setCoreModalOpen(true)
}
const handleValidateCorePath = async () => {
if (!coreForm.corePath.trim()) { setCoreValidation({ valid: false, message: '请输入路径' }); return }
setCoreValidation(await validateBrowserCorePath(coreForm.corePath))
}
const handleSaveCore = async () => {
if (!coreForm.coreName.trim()) { toast.error('请输入内核名称'); return }
if (!coreForm.corePath.trim()) { toast.error('请输入内核路径'); return }
setSavingCore(true)
try {
await saveBrowserCore(coreForm)
toast.success('内核已保存')
setCoreModalOpen(false)
// 刷新 cores 列表
const { fetchBrowserCores } = await import('../api')
onCoresChange(await fetchBrowserCores())
} catch (error: any) {
toast.error(error?.message || '保存失败')
} finally {
setSavingCore(false)
}
}
const handleDeleteCore = async (coreId: string) => {
if (cores.length <= 1) { toast.error('至少保留一个内核'); return }
await deleteBrowserCore(coreId)
toast.success('内核已删除')
const { fetchBrowserCores } = await import('../api')
onCoresChange(await fetchBrowserCores())
}
const handleSetDefaultCore = async (coreId: string) => {
await setDefaultBrowserCore(coreId)
toast.success('已设为默认')
const { fetchBrowserCores } = await import('../api')
onCoresChange(await fetchBrowserCores())
}
const coreColumns: TableColumn<BrowserCore>[] = [
{ key: 'coreName', title: '名称' },
{ key: 'corePath', title: '路径' },
{ key: 'isDefault', title: '默认', render: (v) => v ? <Star className="w-4 h-4 text-yellow-500 fill-yellow-500" /> : null },
{
key: 'actions', title: '操作', align: 'right',
render: (_, record) => (
<div className="flex justify-end gap-1">
{!record.isDefault && <Button size="sm" variant="ghost" onClick={() => handleSetDefaultCore(record.coreId)} title="设为默认"><Star className="w-4 h-4" /></Button>}
<Button size="sm" variant="ghost" onClick={() => handleOpenCoreModal(record)} title="编辑"><Edit2 className="w-4 h-4" /></Button>
<Button size="sm" variant="ghost" onClick={() => handleDeleteCore(record.coreId)} title="删除"><Trash2 className="w-4 h-4" /></Button>
</div>
),
},
]
return (
<>
<Modal open={open} onClose={onClose} title="基础配置" width="700px"
footer={<><Button variant="secondary" onClick={onClose}></Button><Button onClick={handleSave} loading={saving}></Button></>}>
<div className="space-y-6">
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-[var(--color-text-primary)]"></span>
<Button size="sm" onClick={() => handleOpenCoreModal()}><Plus className="w-4 h-4" /></Button>
</div>
<Card padding="none"><Table columns={coreColumns} data={cores} rowKey="coreId" /></Card>
</div>
<FormItem label="用户数据根目录">
<Input value={settings.userDataRoot} onChange={e => setSettings(p => ({ ...p, userDataRoot: e.target.value }))} placeholder="data" />
</FormItem>
<FormItem label="默认指纹参数(每行一个)">
<Textarea value={fingerprintText} onChange={e => setFingerprintText(e.target.value)} rows={3} placeholder="--fingerprint-brand=Chrome" />
</FormItem>
<FormItem label="默认启动参数(每行一个)">
<Textarea value={launchText} onChange={e => setLaunchText(e.target.value)} rows={3} placeholder="--disable-sync" />
</FormItem>
<FormItem label="默认代理">
<Input value={settings.defaultProxy} onChange={e => setSettings(p => ({ ...p, defaultProxy: e.target.value }))} placeholder="http://127.0.0.1:7890" />
</FormItem>
</div>
</Modal>
<Modal open={coreModalOpen} onClose={() => setCoreModalOpen(false)} title={coreForm.coreId ? '编辑内核' : '新增内核'} width="500px"
footer={<><Button variant="secondary" onClick={() => setCoreModalOpen(false)}></Button><Button onClick={handleSaveCore} loading={savingCore}></Button></>}>
<div className="space-y-4">
<FormItem label="内核名称" required>
<Input value={coreForm.coreName} onChange={e => setCoreForm(p => ({ ...p, coreName: e.target.value }))} placeholder="Chrome 142" />
</FormItem>
<FormItem label="内核路径" required>
<div className="flex gap-2">
<Input value={coreForm.corePath} onChange={e => { setCoreForm(p => ({ ...p, corePath: e.target.value })); setCoreValidation(null) }} placeholder="chrome 或 D:/browsers/chrome-120" className="flex-1" />
<Button variant="secondary" onClick={handleValidateCorePath}></Button>
</div>
{coreValidation && (
<div className={`flex items-center gap-1 mt-1 text-sm ${coreValidation.valid ? 'text-green-600' : 'text-red-600'}`}>
{coreValidation.valid ? <CheckCircle className="w-4 h-4" /> : <XCircle className="w-4 h-4" />}
{coreValidation.message}
</div>
)}
</FormItem>
</div>
</Modal>
</>
)
}
@@ -0,0 +1,157 @@
import { useEffect, useMemo, useState } from 'react'
import { Download, RefreshCw, Trash2 } from 'lucide-react'
import { Badge, Button, Card, Input, Table, toast } from '../../../shared/components'
import type { TableColumn } from '../../../shared/components/Table'
import type { CookieInfo } from '../types'
import { clearBrowserCookies, exportBrowserCookies, fetchBrowserCookies } from '../api'
interface Props {
profileId: string
profileName: string
running: boolean
}
const formatExpires = (expires: number) => {
if (expires <= 0) return 'Session'
return new Date(expires * 1000).toLocaleString('zh-CN')
}
export function CookieManagerCard({ profileId, profileName, running }: Props) {
const [cookies, setCookies] = useState<CookieInfo[]>([])
const [filterDomain, setFilterDomain] = useState('')
const [loading, setLoading] = useState(false)
const [clearing, setClearing] = useState(false)
const [showConfirm, setShowConfirm] = useState(false)
const loadCookies = async () => {
if (!running) return
setLoading(true)
try {
const list = await fetchBrowserCookies(profileId)
setCookies(list)
} catch {
toast.error('获取 Cookie 失败')
} finally {
setLoading(false)
}
}
useEffect(() => {
if (running) loadCookies()
else setCookies([])
}, [profileId, running])
const filteredCookies = useMemo(() => {
if (!filterDomain.trim()) return cookies
const kw = filterDomain.toLowerCase()
return cookies.filter(c => c.domain.toLowerCase().includes(kw))
}, [cookies, filterDomain])
const handleClear = async () => {
setClearing(true)
try {
await clearBrowserCookies(profileId)
setCookies([])
toast.success('Cookie 已清除')
} catch {
toast.error('清除 Cookie 失败')
} finally {
setClearing(false)
setShowConfirm(false)
}
}
const handleExport = async () => {
try {
const content = await exportBrowserCookies(profileId)
const date = new Date().toISOString().slice(0, 10)
const filename = `cookies_${profileName}_${date}.txt`
const blob = new Blob([content], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
toast.success('Cookie 已导出')
} catch {
toast.error('导出 Cookie 失败')
}
}
const columns: TableColumn<CookieInfo>[] = [
{ key: 'domain', title: '域名', render: v => <span className="font-mono text-xs">{v}</span> },
{ key: 'name', title: '名称', render: v => <span className="font-mono text-xs">{v}</span> },
{
key: 'value',
title: '值',
render: v => (
<span className="font-mono text-xs max-w-[120px] truncate block" title={v as string}>{v}</span>
),
},
{ key: 'expires', title: '过期时间', render: v => formatExpires(v as number) },
{
key: 'httpOnly',
title: 'HttpOnly',
render: v => <Badge variant={v ? 'success' : 'default'}>{v ? '是' : '否'}</Badge>,
},
{
key: 'secure',
title: 'Secure',
render: v => <Badge variant={v ? 'success' : 'default'}>{v ? '是' : '否'}</Badge>,
},
]
const subtitle = running
? `${cookies.length}${filterDomain ? `,已过滤 ${filteredCookies.length}` : ''}`
: '实例未运行,无法管理 Cookie'
return (
<Card title="Cookie 管理" subtitle={subtitle}>
{!running ? (
<p className="text-sm text-[var(--color-text-muted)] py-4 text-center">
Cookie
</p>
) : (
<div className="space-y-3">
<div className="flex flex-col sm:flex-row gap-2 items-start sm:items-center justify-between">
<Input
placeholder="按域名过滤..."
value={filterDomain}
onChange={e => setFilterDomain(e.target.value)}
className="w-full sm:w-64"
/>
<div className="flex gap-2 flex-shrink-0">
<Button size="sm" variant="ghost" onClick={loadCookies} disabled={loading}>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
<Button size="sm" variant="ghost" onClick={handleExport}>
<Download className="w-4 h-4" />
Netscape
</Button>
<Button size="sm" variant="secondary" onClick={() => setShowConfirm(true)} disabled={clearing}>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
{showConfirm && (
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-elevated)] p-4 flex items-center justify-between gap-4">
<span className="text-sm text-[var(--color-text-secondary)]">
Cookie
</span>
<div className="flex gap-2 flex-shrink-0">
<Button size="sm" variant="ghost" onClick={() => setShowConfirm(false)}></Button>
<Button size="sm" onClick={handleClear} disabled={clearing}></Button>
</div>
</div>
)}
<Table columns={columns} data={filteredCookies} rowKey="name" />
</div>
)}
</Card>
)
}
@@ -0,0 +1,441 @@
import { useEffect, useState } from 'react'
import { ChevronDown, ChevronUp, RefreshCw, Wand2 } from 'lucide-react'
import { ConfirmModal, FormItem, Input, Select, Textarea } from '../../../shared/components'
import {
type FingerprintConfig,
FINGERPRINT_PRESETS,
PRESET_RESOLUTIONS,
deserialize,
getSystemTimezone,
randomFingerprintSeed,
serialize,
} from '../utils/fingerprintSerializer'
interface FingerprintPanelProps {
value: string[]
onChange: (args: string[]) => void
}
const BRAND_OPTIONS = [
{ value: '', label: '不设置' },
{ value: 'Chrome', label: 'Chrome' },
{ value: 'Edge', label: 'Edge' },
{ value: 'Firefox', label: 'Firefox' },
{ value: 'Safari', label: 'Safari' },
]
const PLATFORM_OPTIONS = [
{ value: '', label: '不设置' },
{ value: 'windows', label: 'Windows' },
{ value: 'mac', label: 'macOS' },
{ value: 'linux', label: 'Linux' },
]
const LANG_OPTIONS = [
{ value: '', label: '不设置' },
{ value: 'zh-CN', label: '中文 (zh-CN)' },
{ value: 'en-US', label: 'English (en-US)' },
{ value: 'en-GB', label: 'English (en-GB)' },
{ value: 'ja-JP', label: '日本語 (ja-JP)' },
{ value: 'ko-KR', label: '한국어 (ko-KR)' },
{ value: 'fr-FR', label: 'Français (fr-FR)' },
{ value: 'de-DE', label: 'Deutsch (de-DE)' },
]
const TIMEZONE_OPTIONS = [
{ value: '', label: '不设置' },
{ value: 'system', label: '跟随系统时区' },
// 亚洲
{ value: 'Asia/Shanghai', label: 'Asia/Shanghai (UTC+8)' },
{ value: 'Asia/Tokyo', label: 'Asia/Tokyo (UTC+9)' },
{ value: 'Asia/Seoul', label: 'Asia/Seoul (UTC+9)' },
{ value: 'Asia/Singapore', label: 'Asia/Singapore (UTC+8)' },
{ value: 'Asia/Hong_Kong', label: 'Asia/Hong_Kong (UTC+8)' },
{ value: 'Asia/Dubai', label: 'Asia/Dubai (UTC+4)' },
{ value: 'Asia/Kolkata', label: 'Asia/Kolkata (UTC+5:30)' },
// 美洲
{ value: 'America/New_York', label: 'America/New_York (UTC-5)' },
{ value: 'America/Los_Angeles', label: 'America/Los_Angeles (UTC-8)' },
{ value: 'America/Chicago', label: 'America/Chicago (UTC-6)' },
{ value: 'America/Denver', label: 'America/Denver (UTC-7)' },
{ value: 'America/Toronto', label: 'America/Toronto (UTC-5)' },
{ value: 'America/Sao_Paulo', label: 'America/Sao_Paulo (UTC-3)' },
// 欧洲
{ value: 'Europe/London', label: 'Europe/London (UTC+0)' },
{ value: 'Europe/Paris', label: 'Europe/Paris (UTC+1)' },
{ value: 'Europe/Berlin', label: 'Europe/Berlin (UTC+1)' },
{ value: 'Europe/Moscow', label: 'Europe/Moscow (UTC+3)' },
// 大洋洲
{ value: 'Australia/Sydney', label: 'Australia/Sydney (UTC+10)' },
{ value: 'Pacific/Auckland', label: 'Pacific/Auckland (UTC+12)' },
]
const RESOLUTION_OPTIONS = [
{ value: '', label: '不设置' },
...PRESET_RESOLUTIONS.map(r => ({ value: r, label: r })),
{ value: 'custom', label: '自定义...' },
]
const WEBGL_VENDOR_OPTIONS = [
{ value: '', label: '不设置' },
{ value: 'Intel', label: 'Intel' },
{ value: 'NVIDIA', label: 'NVIDIA' },
{ value: 'AMD', label: 'AMD' },
{ value: 'Apple', label: 'Apple' },
]
const WEBGL_RENDERER_OPTIONS: Record<string, { value: string; label: string }[]> = {
Intel: [
{ value: '', label: '不设置' },
{ value: 'Intel(R) UHD Graphics 630', label: 'UHD Graphics 630' },
{ value: 'Intel(R) UHD Graphics 620', label: 'UHD Graphics 620' },
{ value: 'Intel(R) HD Graphics 520', label: 'HD Graphics 520' },
{ value: 'Intel(R) Iris(R) Xe Graphics', label: 'Iris Xe Graphics' },
{ value: 'custom', label: '自定义...' },
],
NVIDIA: [
{ value: '', label: '不设置' },
{ value: 'NVIDIA GeForce RTX 3080', label: 'GeForce RTX 3080' },
{ value: 'NVIDIA GeForce RTX 3060', label: 'GeForce RTX 3060' },
{ value: 'NVIDIA GeForce GTX 1660', label: 'GeForce GTX 1660' },
{ value: 'NVIDIA GeForce GTX 1080 Ti', label: 'GeForce GTX 1080 Ti' },
{ value: 'custom', label: '自定义...' },
],
AMD: [
{ value: '', label: '不设置' },
{ value: 'AMD Radeon RX 6600', label: 'Radeon RX 6600' },
{ value: 'AMD Radeon RX 580', label: 'Radeon RX 580' },
{ value: 'AMD Radeon Vega 8', label: 'Radeon Vega 8' },
{ value: 'custom', label: '自定义...' },
],
Apple: [
{ value: '', label: '不设置' },
{ value: 'Apple M1', label: 'Apple M1' },
{ value: 'Apple M2', label: 'Apple M2' },
{ value: 'Apple M3', label: 'Apple M3' },
{ value: 'custom', label: '自定义...' },
],
}
const BOOL_OPTIONS = [
{ value: '', label: '不设置' },
{ value: 'true', label: '启用' },
{ value: 'false', label: '禁用' },
]
const HARDWARE_CONCURRENCY_OPTIONS = [
{ value: '', label: '不设置' },
{ value: '2', label: '2 核' },
{ value: '4', label: '4 核' },
{ value: '6', label: '6 核' },
{ value: '8', label: '8 核' },
{ value: '10', label: '10 核' },
{ value: '12', label: '12 核' },
{ value: '16', label: '16 核' },
]
const DEVICE_MEMORY_OPTIONS = [
{ value: '', label: '不设置' },
{ value: '2', label: '2 GB' },
{ value: '4', label: '4 GB' },
{ value: '8', label: '8 GB' },
{ value: '16', label: '16 GB' },
{ value: '32', label: '32 GB' },
]
const COLOR_DEPTH_OPTIONS = [
{ value: '', label: '不设置' },
{ value: '24', label: '24 位(标准)' },
{ value: '30', label: '30 位(HDR' },
{ value: '32', label: '32 位' },
]
const WEBRTC_OPTIONS = [
{ value: '', label: '不设置' },
{ value: 'disable_non_proxied_udp', label: '禁用非代理 UDP(推荐)' },
{ value: 'default_public_interface_only', label: '仅公网接口' },
{ value: 'default_public_and_private_interfaces', label: '公网+私网接口' },
]
const TOUCH_POINTS_OPTIONS = [
{ value: '', label: '不设置' },
{ value: '0', label: '0(无触摸)' },
{ value: '1', label: '1 点触摸' },
{ value: '5', label: '5 点触摸' },
{ value: '10', label: '10 点触摸' },
]
const PRESET_OPTIONS = [
{ value: '', label: '选择预设...' },
...FINGERPRINT_PRESETS.map(p => ({ value: p.id, label: p.name })),
]
export function FingerprintPanel({ value, onChange }: FingerprintPanelProps) {
const [config, setConfig] = useState<FingerprintConfig>(() => deserialize(value))
const [advancedOpen, setAdvancedOpen] = useState(false)
const [, setCustomRenderer] = useState('')
const [confirmSeedOpen, setConfirmSeedOpen] = useState(false)
useEffect(() => {
setConfig(deserialize(value))
}, [value.join('\n')])
const update = (patch: Partial<FingerprintConfig>) => {
const next = { ...config, ...patch }
setConfig(next)
onChange(serialize(next))
}
const handlePresetChange = (presetId: string) => {
if (!presetId) return
const preset = FINGERPRINT_PRESETS.find(p => p.id === presetId)
if (!preset) return
// 应用预设时自动生成新种子,保留未知参数
const next: FingerprintConfig = {
...preset.config,
seed: randomFingerprintSeed(),
unknownArgs: config.unknownArgs,
}
setConfig(next)
onChange(serialize(next))
}
const handleAdvancedChange = (text: string) => {
const args = text.split('\n').map(s => s.trim()).filter(Boolean)
const parsed = deserialize(args)
setConfig(parsed)
onChange(serialize(parsed))
}
const rendererOptions = config.webglVendor
? (WEBGL_RENDERER_OPTIONS[config.webglVendor] ?? [{ value: '', label: '不设置' }, { value: 'custom', label: '自定义...' }])
: [{ value: '', label: '不设置' }]
const isCustomRenderer = config.webglRenderer
? !rendererOptions.some(o => o.value === config.webglRenderer && o.value !== 'custom')
: false
const advancedText = serialize(config).join('\n')
return (
<div className="space-y-4">
{/* 指纹种子 */}
<div className="p-3 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-[var(--color-text-muted)] uppercase tracking-wide">Fingerprint Seed</span>
<span className="text-xs text-[var(--color-text-muted)]"> = </span>
</div>
<div className="flex items-center gap-2">
<Input
value={config.seed ?? ''}
onChange={e => update({ seed: e.target.value || undefined })}
placeholder="留空则由系统按 ProfileId 自动生成"
className="flex-1 font-mono text-sm"
/>
<button
type="button"
title="随机生成新种子"
onClick={() => {
if (config.seed) {
setConfirmSeedOpen(true)
} else {
update({ seed: randomFingerprintSeed() })
}
}}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs bg-[var(--color-primary)] text-white hover:opacity-90 transition-opacity shrink-0"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
</div>
<ConfirmModal
open={confirmSeedOpen}
onClose={() => setConfirmSeedOpen(false)}
onConfirm={() => update({ seed: randomFingerprintSeed() })}
title="重新生成指纹种子"
content="重新生成后,当前指纹将完全改变,浏览器的 Canvas、WebGL、Audio 等所有噪声特征都会随之变化。确定继续?"
confirmText="确定重新生成"
danger
/>
{/* 预设选择 */}
<div className="flex items-center gap-3 p-3 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)]">
<Wand2 className="w-4 h-4 text-[var(--color-text-muted)] shrink-0" />
<div className="flex-1 min-w-0">
<Select
value=""
onChange={e => handlePresetChange(e.target.value)}
options={PRESET_OPTIONS}
/>
</div>
<span className="text-xs text-[var(--color-text-muted)] shrink-0"></span>
</div>
{/* 基础身份 */}
<div>
<p className="text-xs font-medium text-[var(--color-text-muted)] mb-2 uppercase tracking-wide"></p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormItem label="浏览器品牌">
<Select value={config.brand ?? ''} onChange={e => update({ brand: e.target.value || undefined })} options={BRAND_OPTIONS} />
</FormItem>
<FormItem label="操作系统">
<Select value={config.platform ?? ''} onChange={e => update({ platform: e.target.value || undefined })} options={PLATFORM_OPTIONS} />
</FormItem>
<FormItem label="语言">
<Select value={config.lang ?? ''} onChange={e => update({ lang: e.target.value || undefined })} options={LANG_OPTIONS} />
</FormItem>
<FormItem label="时区">
<Select value={config.timezone ?? ''} onChange={e => update({ timezone: e.target.value || undefined })} options={TIMEZONE_OPTIONS.map(opt =>
opt.value === 'system'
? { ...opt, label: `跟随系统时区 (当前: ${getSystemTimezone()})` }
: opt
)} />
</FormItem>
</div>
</div>
{/* 屏幕与硬件 */}
<div>
<p className="text-xs font-medium text-[var(--color-text-muted)] mb-2 uppercase tracking-wide"></p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormItem label="分辨率">
<Select
value={config.resolution ?? ''}
onChange={e => update({ resolution: e.target.value || undefined, customResolution: undefined })}
options={RESOLUTION_OPTIONS}
/>
</FormItem>
{config.resolution === 'custom' && (
<FormItem label="自定义分辨率">
<Input value={config.customResolution ?? ''} onChange={e => update({ customResolution: e.target.value || undefined })} placeholder="1600,900" />
</FormItem>
)}
<FormItem label="色深">
<Select value={config.colorDepth ?? ''} onChange={e => update({ colorDepth: e.target.value || undefined })} options={COLOR_DEPTH_OPTIONS} />
</FormItem>
<FormItem label="CPU 核心数">
<Select value={config.hardwareConcurrency ?? ''} onChange={e => update({ hardwareConcurrency: e.target.value || undefined })} options={HARDWARE_CONCURRENCY_OPTIONS} />
</FormItem>
<FormItem label="设备内存">
<Select value={config.deviceMemory ?? ''} onChange={e => update({ deviceMemory: e.target.value || undefined })} options={DEVICE_MEMORY_OPTIONS} />
</FormItem>
<FormItem label="触摸点数">
<Select value={config.touchPoints ?? ''} onChange={e => update({ touchPoints: e.target.value || undefined })} options={TOUCH_POINTS_OPTIONS} />
</FormItem>
</div>
</div>
{/* 渲染指纹 */}
<div>
<p className="text-xs font-medium text-[var(--color-text-muted)] mb-2 uppercase tracking-wide"></p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormItem label="WebGL 供应商">
<Select
value={config.webglVendor ?? ''}
onChange={e => update({ webglVendor: e.target.value || undefined, webglRenderer: undefined })}
options={WEBGL_VENDOR_OPTIONS}
/>
</FormItem>
<FormItem label="WebGL 渲染器">
{isCustomRenderer ? (
<Input
value={config.webglRenderer ?? ''}
onChange={e => update({ webglRenderer: e.target.value || undefined })}
placeholder="自定义渲染器名称"
/>
) : (
<Select
value={config.webglRenderer ?? ''}
onChange={e => {
if (e.target.value === 'custom') {
setCustomRenderer('')
update({ webglRenderer: undefined })
} else {
update({ webglRenderer: e.target.value || undefined })
}
}}
options={rendererOptions}
disabled={!config.webglVendor}
/>
)}
</FormItem>
<FormItem label="Canvas 噪声">
<Select
value={config.canvasNoise === undefined ? '' : String(config.canvasNoise)}
onChange={e => { const v = e.target.value; update({ canvasNoise: v === '' ? undefined : v === 'true' }) }}
options={BOOL_OPTIONS}
/>
</FormItem>
<FormItem label="Audio 噪声">
<Select
value={config.audioNoise === undefined ? '' : String(config.audioNoise)}
onChange={e => { const v = e.target.value; update({ audioNoise: v === '' ? undefined : v === 'true' }) }}
options={BOOL_OPTIONS}
/>
</FormItem>
</div>
</div>
{/* 网络与隐私 */}
<div>
<p className="text-xs font-medium text-[var(--color-text-muted)] mb-2 uppercase tracking-wide"></p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormItem label="WebRTC 策略">
<Select value={config.webrtcPolicy ?? ''} onChange={e => update({ webrtcPolicy: e.target.value || undefined })} options={WEBRTC_OPTIONS} />
</FormItem>
<FormItem label="Do Not Track">
<Select
value={config.doNotTrack === undefined ? '' : String(config.doNotTrack)}
onChange={e => { const v = e.target.value; update({ doNotTrack: v === '' ? undefined : v === 'true' }) }}
options={BOOL_OPTIONS}
/>
</FormItem>
<FormItem label="媒体设备 (摄像头,麦克风,扬声器)">
<Input
value={config.mediaDevices ?? ''}
onChange={e => update({ mediaDevices: e.target.value || undefined })}
placeholder="2,1,1"
/>
</FormItem>
</div>
</div>
{/* 字体 */}
<div>
<p className="text-xs font-medium text-[var(--color-text-muted)] mb-2 uppercase tracking-wide"></p>
<FormItem label="字体列表">
<Input
value={config.fonts ?? ''}
onChange={e => update({ fonts: e.target.value || undefined })}
placeholder="Arial,Helvetica,Times New Roman(逗号分隔)"
/>
</FormItem>
</div>
{/* 高级模式 */}
<div className="border border-[var(--color-border)] rounded-lg overflow-hidden">
<button
type="button"
className="w-full flex items-center justify-between px-4 py-2.5 text-sm text-[var(--color-text-muted)] hover:bg-[var(--color-bg-hover)] transition-colors"
onClick={() => setAdvancedOpen(v => !v)}
>
<span></span>
{advancedOpen ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
{advancedOpen && (
<div className="px-4 pb-4 pt-2 border-t border-[var(--color-border)]">
<p className="text-xs text-[var(--color-text-muted)] mb-2"></p>
<Textarea
value={advancedText}
onChange={e => handleAdvancedChange(e.target.value)}
rows={6}
placeholder="--fingerprint-brand=Chrome"
/>
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,60 @@
import { useMemo } from 'react'
import type { BrowserGroup } from '../types'
interface GroupSelectorProps {
groups: BrowserGroup[]
value: string
onChange: (groupId: string) => void
placeholder?: string
className?: string
}
interface FlatGroup extends BrowserGroup {
level: number
}
// 将分组列表扁平化并计算层级
function flattenGroups(groups: BrowserGroup[]): FlatGroup[] {
const map = new Map<string, BrowserGroup>()
groups.forEach(g => map.set(g.groupId, g))
const getLevel = (g: BrowserGroup): number => {
if (!g.parentId || !map.has(g.parentId)) return 0
return 1 + getLevel(map.get(g.parentId)!)
}
const result: FlatGroup[] = []
const addChildren = (parentId: string, level: number) => {
groups
.filter(g => g.parentId === parentId)
.sort((a, b) => a.sortOrder - b.sortOrder)
.forEach(g => {
result.push({ ...g, level })
addChildren(g.groupId, level + 1)
})
}
// 先添加根级分组
addChildren('', 0)
return result
}
export function GroupSelector({ groups, value, onChange, placeholder = '选择分组', className = '' }: GroupSelectorProps) {
const flatGroups = useMemo(() => flattenGroups(groups), [groups])
return (
<select
className={`px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600 ${className}`}
value={value}
onChange={e => onChange(e.target.value)}
>
<option value="">{placeholder}</option>
{flatGroups.map(g => (
<option key={g.groupId} value={g.groupId}>
{' '.repeat(g.level)}{g.groupName}
</option>
))}
</select>
)
}
@@ -0,0 +1,290 @@
import { useState, useMemo } from 'react'
import { ChevronRight, ChevronDown, Folder, FolderOpen, Plus, Pencil, Trash2, FolderInput } from 'lucide-react'
import type { BrowserGroupWithCount, BrowserGroupInput } from '../types'
import { createGroup, updateGroup, deleteGroup } from '../api'
interface GroupTreeNavProps {
groups: BrowserGroupWithCount[]
selectedGroupId: string | null
onSelectGroup: (groupId: string | null) => void
onRefresh: () => void
}
interface TreeNode extends BrowserGroupWithCount {
children: TreeNode[]
level: number
}
// 构建树形结构
function buildTree(groups: BrowserGroupWithCount[]): TreeNode[] {
const map = new Map<string, TreeNode>()
const roots: TreeNode[] = []
// 初始化所有节点
groups.forEach(g => {
map.set(g.groupId, { ...g, children: [], level: 0 })
})
// 构建父子关系
groups.forEach(g => {
const node = map.get(g.groupId)!
if (g.parentId && map.has(g.parentId)) {
const parent = map.get(g.parentId)!
node.level = parent.level + 1
parent.children.push(node)
} else {
roots.push(node)
}
})
// 按 sortOrder 排序
const sortNodes = (nodes: TreeNode[]) => {
nodes.sort((a, b) => a.sortOrder - b.sortOrder)
nodes.forEach(n => sortNodes(n.children))
}
sortNodes(roots)
return roots
}
export function GroupTreeNav({ groups, selectedGroupId, onSelectGroup, onRefresh }: GroupTreeNavProps) {
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const [showCreateModal, setShowCreateModal] = useState(false)
const [createParentId, setCreateParentId] = useState<string>('')
const [newGroupName, setNewGroupName] = useState('')
const [editingGroup, setEditingGroup] = useState<BrowserGroupWithCount | null>(null)
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; group: BrowserGroupWithCount } | null>(null)
const tree = useMemo(() => buildTree(groups), [groups])
const toggleExpand = (groupId: string) => {
setExpanded(prev => {
const next = new Set(prev)
if (next.has(groupId)) {
next.delete(groupId)
} else {
next.add(groupId)
}
return next
})
}
const handleCreate = async () => {
if (!newGroupName.trim()) return
const input: BrowserGroupInput = {
groupName: newGroupName.trim(),
parentId: createParentId,
sortOrder: 0,
}
await createGroup(input)
setShowCreateModal(false)
setNewGroupName('')
setCreateParentId('')
onRefresh()
}
const handleRename = async () => {
if (!editingGroup || !newGroupName.trim()) return
const input: BrowserGroupInput = {
groupName: newGroupName.trim(),
parentId: editingGroup.parentId,
sortOrder: editingGroup.sortOrder,
}
await updateGroup(editingGroup.groupId, input)
setEditingGroup(null)
setNewGroupName('')
onRefresh()
}
const handleDelete = async (groupId: string) => {
if (!confirm('确定删除此分组?子分组和实例将移动到父分组。')) return
await deleteGroup(groupId)
if (selectedGroupId === groupId) {
onSelectGroup(null)
}
onRefresh()
}
const handleContextMenu = (e: React.MouseEvent, group: BrowserGroupWithCount) => {
e.preventDefault()
setContextMenu({ x: e.clientX, y: e.clientY, group })
}
const renderNode = (node: TreeNode) => {
const isExpanded = expanded.has(node.groupId)
const isSelected = selectedGroupId === node.groupId
const hasChildren = node.children.length > 0
return (
<div key={node.groupId}>
<div
className={`flex items-center gap-2 px-3 py-1.5 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-gray-700 ${
isSelected ? 'bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400' : ''
}`}
style={{ paddingLeft: `${node.level * 16 + 12}px` }}
onClick={() => onSelectGroup(node.groupId)}
onContextMenu={(e) => handleContextMenu(e, node)}
>
{hasChildren ? (
<button
className="p-0 hover:bg-gray-200 dark:hover:bg-gray-600 rounded shrink-0"
onClick={(e) => { e.stopPropagation(); toggleExpand(node.groupId) }}
>
{isExpanded ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
</button>
) : null}
{isExpanded && hasChildren ? (
<FolderOpen className="w-4 h-4 text-yellow-500 shrink-0" />
) : (
<Folder className="w-4 h-4 text-yellow-500 shrink-0" />
)}
<span className="flex-1 truncate text-sm">{node.groupName}</span>
<span className="text-xs text-gray-400">{node.instanceCount}</span>
</div>
{isExpanded && node.children.map(child => renderNode(child))}
</div>
)
}
return (
<div className="w-48 border-r border-gray-200 dark:border-gray-700 flex flex-col h-full">
<div className="p-2 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<span className="text-sm font-medium"></span>
<button
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
onClick={() => { setCreateParentId(''); setShowCreateModal(true) }}
title="新建分组"
>
<Plus className="w-4 h-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto py-1">
{/* 全部 */}
<div
className={`flex items-center gap-2 px-3 py-1.5 cursor-pointer rounded mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 ${
selectedGroupId === null ? 'bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400' : ''
}`}
onClick={() => onSelectGroup(null)}
>
<Folder className="w-4 h-4 text-gray-400" />
<span className="flex-1 text-sm"></span>
</div>
{/* 未分组 */}
<div
className={`flex items-center gap-2 px-3 py-1.5 cursor-pointer rounded mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 ${
selectedGroupId === '__ungrouped__' ? 'bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400' : ''
}`}
onClick={() => onSelectGroup('__ungrouped__')}
>
<FolderInput className="w-4 h-4 text-gray-400" />
<span className="flex-1 text-sm"></span>
</div>
{/* 分组树 */}
{tree.length > 0 && (
<div className="mt-2 mx-1">
<div className="px-2 py-1 text-xs font-medium text-gray-400 uppercase tracking-wider"></div>
{tree.map(node => renderNode(node))}
</div>
)}
</div>
{/* 创建分组弹窗 */}
{showCreateModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setShowCreateModal(false)}>
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 w-80" onClick={e => e.stopPropagation()}>
<h3 className="text-lg font-medium mb-3"></h3>
<input
type="text"
className="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600"
placeholder="分组名称"
value={newGroupName}
onChange={e => setNewGroupName(e.target.value)}
autoFocus
/>
{groups.length > 0 && (
<select
className="w-full mt-2 px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600"
value={createParentId}
onChange={e => setCreateParentId(e.target.value)}
>
<option value=""></option>
{groups.map(g => (
<option key={g.groupId} value={g.groupId}>{g.groupName}</option>
))}
</select>
)}
<div className="flex justify-end gap-2 mt-4">
<button className="px-3 py-1.5 text-sm rounded hover:bg-gray-100 dark:hover:bg-gray-700" onClick={() => setShowCreateModal(false)}>
</button>
<button className="px-3 py-1.5 text-sm bg-blue-500 text-white rounded hover:bg-blue-600" onClick={handleCreate}>
</button>
</div>
</div>
</div>
)}
{/* 重命名弹窗 */}
{editingGroup && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setEditingGroup(null)}>
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 w-80" onClick={e => e.stopPropagation()}>
<h3 className="text-lg font-medium mb-3"></h3>
<input
type="text"
className="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600"
placeholder="分组名称"
value={newGroupName}
onChange={e => setNewGroupName(e.target.value)}
autoFocus
/>
<div className="flex justify-end gap-2 mt-4">
<button className="px-3 py-1.5 text-sm rounded hover:bg-gray-100 dark:hover:bg-gray-700" onClick={() => setEditingGroup(null)}>
</button>
<button className="px-3 py-1.5 text-sm bg-blue-500 text-white rounded hover:bg-blue-600" onClick={handleRename}>
</button>
</div>
</div>
</div>
)}
{/* 右键菜单 */}
{contextMenu && (
<div
className="fixed bg-white dark:bg-gray-800 border dark:border-gray-700 rounded shadow-lg py-1 z-50"
style={{ left: contextMenu.x, top: contextMenu.y }}
onClick={() => setContextMenu(null)}
>
<button
className="w-full px-4 py-1.5 text-sm text-left hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
onClick={() => { setCreateParentId(contextMenu.group.groupId); setShowCreateModal(true) }}
>
<Plus className="w-4 h-4" />
</button>
<button
className="w-full px-4 py-1.5 text-sm text-left hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
onClick={() => { setNewGroupName(contextMenu.group.groupName); setEditingGroup(contextMenu.group) }}
>
<Pencil className="w-4 h-4" />
</button>
<button
className="w-full px-4 py-1.5 text-sm text-left hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2 text-red-500"
onClick={() => handleDelete(contextMenu.group.groupId)}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
)}
{/* 点击其他地方关闭右键菜单 */}
{contextMenu && (
<div className="fixed inset-0 z-40" onClick={() => setContextMenu(null)} />
)}
</div>
)
}
@@ -0,0 +1,138 @@
import { useState } from 'react'
import { ChevronDown, ChevronRight, Filter, X } from 'lucide-react'
import { Input, Select } from '../../../shared/components'
import { TagFilterBar } from './TagFilterBar'
import type { BrowserCore, BrowserProxy, BrowserGroupWithCount } from '../types'
export interface InstanceFilters {
keyword: string
status: '' | 'running' | 'stopped'
proxyId: string
coreId: string
tags: Set<string>
kwSearch: string
groupId: string // '' = 全部, '__ungrouped__' = 未分组, 其他 = 具体分组ID
}
export const EMPTY_FILTERS: InstanceFilters = {
keyword: '',
status: '',
proxyId: '',
coreId: '',
tags: new Set(),
kwSearch: '',
groupId: '',
}
export function isFiltersEmpty(f: InstanceFilters) {
return !f.keyword && !f.status && !f.proxyId && !f.coreId && f.tags.size === 0 && !f.kwSearch && !f.groupId
}
interface Props {
filters: InstanceFilters
onChange: (f: InstanceFilters) => void
proxies: BrowserProxy[]
cores: BrowserCore[]
allTags: string[]
groups: BrowserGroupWithCount[]
}
export function InstanceFilterBar({ filters, onChange, proxies, cores, allTags, groups }: Props) {
const [collapsed, setCollapsed] = useState(false)
const set = <K extends keyof InstanceFilters>(key: K, value: InstanceFilters[K]) =>
onChange({ ...filters, [key]: value })
const hasFilter = !isFiltersEmpty(filters)
const activeCount = [filters.keyword, filters.status, filters.proxyId, filters.coreId, filters.kwSearch, filters.groupId].filter(Boolean).length + filters.tags.size
return (
<div className="space-y-2">
<div
className="flex items-center gap-1.5 cursor-pointer select-none text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] transition-colors"
onClick={() => setCollapsed(prev => !prev)}
>
{collapsed ? <ChevronRight className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
<Filter className="w-3.5 h-3.5" />
<span></span>
{collapsed && activeCount > 0 && (
<span className="ml-1 px-1.5 py-0.5 text-[10px] font-medium bg-[var(--color-accent)]/10 text-[var(--color-accent)] rounded-full">
{activeCount}
</span>
)}
</div>
{!collapsed && (
<>
<div className="flex items-center gap-2 flex-wrap">
<Input
value={filters.keyword}
onChange={e => set('keyword', e.target.value)}
placeholder="搜索名称..."
style={{ width: '180px' }}
/>
<Select
value={filters.status}
onChange={e => set('status', e.target.value as InstanceFilters['status'])}
options={[
{ value: '', label: '全部状态' },
{ value: 'running', label: '运行中' },
{ value: 'stopped', label: '已停止' },
]}
style={{ width: '120px' }}
/>
<Select
value={filters.proxyId}
onChange={e => set('proxyId', e.target.value)}
options={[
{ value: '', label: '全部代理' },
{ value: '__none__', label: '无代理' },
...proxies.map(p => ({ value: p.proxyId, label: p.proxyName || p.proxyId })),
]}
style={{ width: '150px' }}
/>
<Select
value={filters.coreId}
onChange={e => set('coreId', e.target.value)}
options={[
{ value: '', label: '全部内核' },
...cores.map(c => ({ value: c.coreId, label: c.coreName })),
]}
style={{ width: '140px' }}
/>
<Select
value={filters.groupId}
onChange={e => set('groupId', e.target.value)}
options={[
{ value: '', label: '全部分组' },
{ value: '__ungrouped__', label: '未分组' },
...groups.map(g => ({ value: g.groupId, label: g.groupName })),
]}
style={{ width: '140px' }}
/>
<Input
value={filters.kwSearch}
onChange={e => set('kwSearch', e.target.value)}
placeholder="搜索关键字值..."
className="flex-1 min-w-[160px]"
/>
{hasFilter && (
<button
onClick={() => onChange({ ...EMPTY_FILTERS, tags: new Set() })}
className="flex items-center gap-1 px-2 py-1 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-error)] hover:bg-[var(--color-bg-muted)] rounded transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
<TagFilterBar
tags={allTags}
selected={filters.tags}
onChange={tags => set('tags', tags)}
/>
</>
)}
</div>
)
}
@@ -0,0 +1,82 @@
import { useState } from 'react'
import { Copy, Play, RefreshCw, Square, Trash2 } from 'lucide-react'
import { Button, toast } from '../../../shared/components'
import { regenerateBrowserProfileCode } from '../api'
// LaunchCode 单元格
export function LaunchCodeCell({ profileId, code, onRefresh }: { profileId: string; code: string; onRefresh: () => void }) {
const [loading, setLoading] = useState(false)
const handleCopy = () => {
if (!code) return
navigator.clipboard.writeText(code).then(() => toast.success('已复制快捷码'))
}
const handleRegenerate = async () => {
setLoading(true)
try {
await regenerateBrowserProfileCode(profileId)
onRefresh()
toast.success('快捷码已重新生成')
} catch {
toast.error('重新生成失败')
} finally {
setLoading(false)
}
}
if (!code) return <span className="text-[var(--color-text-muted)] text-xs">-</span>
return (
<div className="flex items-center gap-1">
<code className="text-xs font-mono bg-[var(--color-bg-secondary)] px-1.5 py-0.5 rounded text-[var(--color-accent)]">{code}</code>
<button onClick={handleCopy} className="p-0.5 hover:text-[var(--color-accent)] text-[var(--color-text-muted)] transition-colors" title="复制">
<Copy className="w-3 h-3" />
</button>
<button onClick={handleRegenerate} disabled={loading} className="p-0.5 hover:text-[var(--color-accent)] text-[var(--color-text-muted)] transition-colors disabled:opacity-50" title="重新生成">
<RefreshCw className="w-3 h-3" />
</button>
</div>
)
}
// 批量操作工具栏
export function BatchToolbar({
selectedCount,
totalCount,
onSelectAll,
onDeselectAll,
onBatchStart,
onBatchStop,
onBatchDelete,
batchLoading,
}: {
selectedCount: number
totalCount: number
onSelectAll: () => void
onDeselectAll: () => void
onBatchStart: () => void
onBatchStop: () => void
onBatchDelete: () => void
batchLoading: boolean
}) {
if (selectedCount === 0) return null
return (
<div className="flex items-center gap-3 px-4 py-2.5 bg-[var(--color-accent)]/10 border border-[var(--color-accent)]/20 rounded-lg">
<span className="text-sm font-medium text-[var(--color-accent)]"> {selectedCount} / {totalCount}</span>
<div className="flex gap-1.5 ml-auto">
<Button size="sm" variant="ghost" onClick={onSelectAll}></Button>
<Button size="sm" variant="ghost" onClick={onDeselectAll}></Button>
<Button size="sm" onClick={onBatchStart} loading={batchLoading}>
<Play className="w-3.5 h-3.5" />
</Button>
<Button size="sm" variant="secondary" onClick={onBatchStop} loading={batchLoading}>
<Square className="w-3.5 h-3.5" />
</Button>
<Button size="sm" variant="ghost" onClick={onBatchDelete} className="text-red-500 hover:text-red-600">
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
)
}
@@ -0,0 +1,66 @@
import { useState, useRef, useEffect } from 'react'
import { ChevronDown, ChevronUp } from 'lucide-react'
interface Props {
keywords: string[]
colSpan: number
}
export function KeywordsExpandRow({ keywords, colSpan }: Props) {
const [expanded, setExpanded] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const [isOverflowing, setIsOverflowing] = useState(false)
useEffect(() => {
if (containerRef.current) {
// 检查内容实际高度是否超过了 1 行的高度 (约 32px)
setIsOverflowing(containerRef.current.scrollHeight > 36)
}
}, [keywords])
return (
<tr>
<td
colSpan={colSpan}
className="px-6 py-3 bg-[var(--color-bg-muted)]/30 border-b border-[var(--color-border-muted)]"
>
{!keywords?.length ? (
<span className="text-xs text-[var(--color-text-muted)] italic"></span>
) : (
<div className="flex items-start gap-4">
<div
ref={containerRef}
className={`flex flex-wrap gap-2 flex-1 transition-all duration-300 ${expanded ? '' : 'overflow-hidden max-h-[32px]'}`}
>
{keywords.map((kw, i) => (
<span
key={i}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs
bg-[var(--color-bg-surface)] border border-[var(--color-border-default)]
text-[var(--color-text-secondary)] max-w-[200px]"
title={kw}
>
<span className="text-[var(--color-text-muted)] font-mono shrink-0">{i + 1}.</span>
<span className="truncate">{kw}</span>
</span>
))}
</div>
{isOverflowing && (
<button
onClick={() => setExpanded(!expanded)}
className="shrink-0 flex items-center gap-1 text-xs text-[var(--color-accent)] hover:underline mt-1 focus:outline-none"
>
{expanded ? (
<> <ChevronUp className="w-3.5 h-3.5" /></>
) : (
<> <ChevronDown className="w-3.5 h-3.5" /></>
)}
</button>
)}
</div>
)}
</td>
</tr>
)
}
@@ -0,0 +1,90 @@
import { useEffect, useState } from 'react'
import { Plus, Trash2 } from 'lucide-react'
import { Button, Input, Modal, toast } from '../../../shared/components'
import { setProfileKeywords } from '../api'
interface Props {
profileId: string
profileName: string
initialKeywords: string[]
open: boolean
onClose: () => void
onSaved: (keywords: string[]) => void
}
export function KeywordsModal({ profileId, profileName, initialKeywords, open, onClose, onSaved }: Props) {
const [items, setItems] = useState<string[]>([])
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!open) return
const init = (initialKeywords || []).filter(Boolean)
setItems(init.length > 0 ? [...init] : [''])
}, [open, initialKeywords])
const setItem = (i: number, val: string) =>
setItems(prev => prev.map((v, idx) => idx === i ? val : v))
const addItem = () => setItems(prev => [...prev, ''])
const removeItem = (i: number) =>
setItems(prev => prev.length === 1 ? [''] : prev.filter((_, idx) => idx !== i))
const handleSave = async () => {
const keywords = items.map(s => s.trim()).filter(Boolean)
setSaving(true)
try {
await setProfileKeywords(profileId, keywords)
toast.success('关键字已保存')
onSaved(keywords)
onClose()
} catch {
toast.error('保存失败')
} finally {
setSaving(false)
}
}
return (
<Modal
open={open}
onClose={onClose}
title={`关键字 — ${profileName}`}
width="420px"
footer={
<>
<Button variant="secondary" onClick={onClose}></Button>
<Button onClick={handleSave} loading={saving}></Button>
</>
}
>
<div className="space-y-2">
<p className="text-xs text-[var(--color-text-muted)] mb-3">
</p>
{items.map((item, i) => (
<div key={i} className="flex items-center gap-2">
<Input
value={item}
onChange={e => setItem(i, e.target.value)}
placeholder="输入关键字"
className="flex-1"
/>
<button
onClick={() => removeItem(i)}
className="p-1.5 text-[var(--color-text-muted)] hover:text-[var(--color-error)] hover:bg-[var(--color-bg-muted)] rounded transition-colors shrink-0"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
<Button variant="ghost" size="sm" onClick={addItem} className="mt-1">
<Plus className="w-4 h-4" />
</Button>
</div>
</Modal>
)
}
@@ -0,0 +1,308 @@
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Check, Loader2, Search, Wifi, X } from 'lucide-react'
import type { BrowserProxy } from '../types'
import { browserProxyBatchTestSpeed, browserProxyTestSpeed, fetchBrowserProxies, fetchBrowserProxyGroups } from '../api'
import { EventsOn } from '../../../wailsjs/runtime/runtime'
interface ProxyPickerModalProps {
open: boolean
currentProxyId: string
onSelect: (proxy: BrowserProxy) => void
onClose: () => void
}
type SpeedResult = { ok: boolean; latencyMs: number; error: string }
const ALL_GROUP = '__all__'
const BATCH_TEST_CONCURRENCY = 20
export function ProxyPickerModal({ open, currentProxyId, onSelect, onClose }: ProxyPickerModalProps) {
const [groups, setGroups] = useState<string[]>([])
const [allProxies, setAllProxies] = useState<BrowserProxy[]>([])
const [displayProxies, setDisplayProxies] = useState<BrowserProxy[]>([])
const [selectedGroup, setSelectedGroup] = useState<string>(ALL_GROUP)
const [search, setSearch] = useState('')
const [loading, setLoading] = useState(false)
// proxyId -> speed result
const [speedMap, setSpeedMap] = useState<Record<string, SpeedResult>>({})
const [testingIds, setTestingIds] = useState<Set<string>>(new Set())
const abortRef = useRef(false)
useEffect(() => {
if (!open) return
setSelectedGroup(ALL_GROUP)
setSearch('')
setSpeedMap({})
setTestingIds(new Set())
abortRef.current = false
loadData()
return () => { abortRef.current = true }
}, [open])
const loadData = async () => {
setLoading(true)
try {
const [groupList, proxyList] = await Promise.all([
fetchBrowserProxyGroups(),
fetchBrowserProxies(),
])
setGroups(groupList)
setAllProxies(proxyList)
// 从代理数据初始化已有测速结果
const initMap: Record<string, SpeedResult> = {}
proxyList.forEach(p => {
if (p.lastTestedAt) {
initMap[p.proxyId] = { ok: p.lastTestOk ?? false, latencyMs: p.lastLatencyMs ?? -1, error: '' }
}
})
setSpeedMap(initMap)
} finally {
setLoading(false)
}
}
useEffect(() => {
let list = allProxies
if (selectedGroup !== ALL_GROUP) {
list = list.filter(p => p.groupName === selectedGroup)
}
if (search.trim()) {
const q = search.trim().toLowerCase()
list = list.filter(p =>
(p.proxyName || '').toLowerCase().includes(q) ||
(p.proxyConfig || '').toLowerCase().includes(q)
)
}
const getSortTuple = (proxy: BrowserProxy): [number, number, string] => {
const latest = speedMap[proxy.proxyId]
const fromHistory = proxy.lastTestedAt
? { ok: proxy.lastTestOk ?? false, latencyMs: proxy.lastLatencyMs ?? -1 }
: undefined
const result = latest || fromHistory
if (result?.ok && result.latencyMs >= 0) {
return [0, result.latencyMs, proxy.proxyName || '']
}
if (proxy.proxyConfig === 'direct://') {
return [2, Number.MAX_SAFE_INTEGER, proxy.proxyName || '']
}
if (result && !result.ok) {
return [3, Number.MAX_SAFE_INTEGER, proxy.proxyName || '']
}
return [4, Number.MAX_SAFE_INTEGER, proxy.proxyName || '']
}
list = [...list].sort((a, b) => {
const [rankA, latencyA, nameA] = getSortTuple(a)
const [rankB, latencyB, nameB] = getSortTuple(b)
if (rankA !== rankB) return rankA - rankB
if (latencyA !== latencyB) return latencyA - latencyB
return nameA.localeCompare(nameB, 'zh-CN')
})
setDisplayProxies(list)
}, [selectedGroup, search, allProxies, speedMap])
const testOne = async (proxyId: string, e: React.MouseEvent) => {
e.stopPropagation()
if (testingIds.has(proxyId)) return
setTestingIds(prev => new Set(prev).add(proxyId))
try {
const result = await browserProxyTestSpeed(proxyId)
if (!abortRef.current) {
setSpeedMap(prev => ({ ...prev, [proxyId]: { ok: result.ok, latencyMs: result.latencyMs, error: result.error } }))
}
} finally {
setTestingIds(prev => { const s = new Set(prev); s.delete(proxyId); return s })
}
}
const testAll = async () => {
const ids = displayProxies.map(p => p.proxyId).filter(id => id !== '__direct__')
if (ids.length === 0) return
abortRef.current = false
setTestingIds(new Set(ids))
const idSet = new Set(ids)
const off = EventsOn('proxy:speed:result', (data: { proxyId: string; ok: boolean; latencyMs: number; error: string }) => {
if (abortRef.current || !idSet.has(data.proxyId)) return
setSpeedMap(prev => ({ ...prev, [data.proxyId]: { ok: data.ok, latencyMs: data.latencyMs, error: data.error } }))
setTestingIds(prev => {
const next = new Set(prev)
next.delete(data.proxyId)
return next
})
})
try {
const results = await browserProxyBatchTestSpeed(ids, BATCH_TEST_CONCURRENCY)
if (!abortRef.current) {
setSpeedMap(prev => {
const next = { ...prev }
results.forEach(result => {
if (idSet.has(result.proxyId)) {
next[result.proxyId] = { ok: result.ok, latencyMs: result.latencyMs, error: result.error }
}
})
return next
})
}
} finally {
off()
setTestingIds(prev => {
const next = new Set(prev)
ids.forEach(id => next.delete(id))
return next
})
}
}
if (!open) return null
return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onClose}>
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
<div
className="relative bg-[var(--color-bg-elevated)] border border-[var(--color-border)] rounded-xl shadow-2xl w-[720px] max-h-[580px] flex flex-col"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--color-border)]">
<span className="font-semibold text-[var(--color-text-primary)]"></span>
<button onClick={onClose} className="text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] transition-colors">
<X className="w-4 h-4" />
</button>
</div>
<div className="flex flex-1 min-h-0">
{/* Left: group list */}
<div className="w-44 border-r border-[var(--color-border)] flex flex-col py-2 overflow-y-auto shrink-0 bg-[var(--color-bg-muted)]">
<GroupItem label="全部" active={selectedGroup === ALL_GROUP} count={allProxies.length} onClick={() => setSelectedGroup(ALL_GROUP)} />
{groups.map(g => (
<GroupItem key={g} label={g} active={selectedGroup === g}
count={allProxies.filter(p => p.groupName === g).length}
onClick={() => setSelectedGroup(g)} />
))}
{groups.length === 0 && <p className="text-xs text-[var(--color-text-muted)] px-3 py-2"></p>}
</div>
{/* Right: proxy list */}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
{/* Search + test all */}
<div className="px-3 py-2 border-b border-[var(--color-border)] flex gap-2 items-center">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[var(--color-text-muted)]" />
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="搜索代理名称或配置..."
className="w-full pl-8 pr-3 py-1.5 text-sm bg-[var(--color-bg-input)] border border-[var(--color-border)] rounded-lg text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] focus:outline-none focus:border-[var(--color-primary)]"
/>
</div>
<button
onClick={testAll}
disabled={testingIds.size > 0 || displayProxies.length === 0}
className="shrink-0 flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border border-[var(--color-border)] text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] hover:border-[var(--color-primary)] disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<Wifi className="w-3.5 h-3.5" />
</button>
</div>
{/* List */}
<div className="flex-1 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center h-24 text-sm text-[var(--color-text-muted)]">...</div>
) : displayProxies.length === 0 ? (
<div className="flex items-center justify-center h-24 text-sm text-[var(--color-text-muted)]"></div>
) : (
displayProxies.map(proxy => (
<ProxyRow
key={proxy.proxyId}
proxy={proxy}
selected={proxy.proxyId === currentProxyId}
testing={testingIds.has(proxy.proxyId)}
speedResult={speedMap[proxy.proxyId]}
onSelect={() => { onSelect(proxy); onClose() }}
onTest={e => testOne(proxy.proxyId, e)}
/>
))
)}
</div>
</div>
</div>
{/* Footer */}
<div className="px-5 py-3 border-t border-[var(--color-border)] text-xs text-[var(--color-text-muted)]">
{displayProxies.length}
</div>
</div>
</div>,
document.body
)
}
function GroupItem({ label, active, count, onClick }: { label: string; active: boolean; count: number; onClick: () => void }) {
return (
<button
onClick={onClick}
className={`w-full text-left px-3 py-2 text-sm flex items-center justify-between gap-2 transition-colors ${
active
? 'bg-[var(--color-primary)]/10 text-[var(--color-primary)] font-medium'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)]'
}`}
>
<span className="truncate">{label}</span>
<span className="text-xs opacity-60 shrink-0">{count}</span>
</button>
)
}
interface ProxyRowProps {
proxy: BrowserProxy
selected: boolean
testing: boolean
speedResult?: SpeedResult
onSelect: () => void
onTest: (e: React.MouseEvent) => void
}
function SpeedBadge({ testing, result }: { testing: boolean; result?: SpeedResult }) {
if (testing) return <Loader2 className="w-3.5 h-3.5 animate-spin text-[var(--color-text-muted)] shrink-0" />
if (!result) return null
if (!result.ok) return <span className="text-xs text-red-500 shrink-0"></span>
const color = result.latencyMs < 200 ? 'text-green-500' : result.latencyMs < 500 ? 'text-yellow-500' : 'text-red-500'
return <span className={`text-xs font-medium shrink-0 ${color}`}>{result.latencyMs}ms</span>
}
function ProxyRow({ proxy, selected, testing, speedResult, onSelect, onTest }: ProxyRowProps) {
return (
<div
onClick={onSelect}
className={`w-full px-4 py-2.5 flex items-center gap-3 cursor-pointer transition-colors border-b border-[var(--color-border)]/40 last:border-0 overflow-hidden ${
selected ? 'bg-[var(--color-primary)]/10' : 'hover:bg-[var(--color-bg-hover)]'
}`}
>
<div className="flex-1 min-w-0 overflow-hidden">
<div className="text-sm font-medium text-[var(--color-text-primary)] truncate">
{proxy.proxyName || proxy.proxyId}
{proxy.groupName && <span className="ml-2 text-xs text-[var(--color-primary)]/70 font-normal">[{proxy.groupName}]</span>}
</div>
<div className="text-xs text-[var(--color-text-muted)] truncate mt-0.5 w-0 min-w-full">
{proxy.proxyConfig}
</div>
</div>
<SpeedBadge testing={testing} result={speedResult} />
<button
onClick={onTest}
disabled={testing}
title="测速"
className="shrink-0 p-1 rounded text-[var(--color-text-muted)] hover:text-[var(--color-primary)] hover:bg-[var(--color-primary)]/10 disabled:opacity-40 transition-colors"
>
<Wifi className="w-3.5 h-3.5" />
</button>
{selected && <Check className="w-4 h-4 text-[var(--color-primary)] shrink-0" />}
</div>
)
}
@@ -0,0 +1,557 @@
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react'
import { Keyboard, Play, Search, Tag } from 'lucide-react'
import { Badge, Button, Modal, toast } from '../../../shared/components'
import { fetchBrowserProfiles, fetchGroups, startBrowserInstanceByCode } from '../api'
import type { BrowserGroupWithCount, BrowserProfile } from '../types'
import { resolveActionErrorMessage } from '../utils/actionErrors'
interface QuickLaunchModalProps {
open: boolean
onClose: () => void
}
interface ProfileTagSection {
tag: string
items: BrowserProfile[]
}
interface GroupFilterOption {
id: string
name: string
count: number
}
const UNTAGGED_LABEL = '未打标签'
const GROUP_ALL = '__all__'
const GROUP_UNGROUPED = '__ungrouped__'
function normalizeText(v?: string): string {
return (v || '').trim().toLowerCase()
}
function normalizeCode(v?: string): string {
return normalizeText(v).toUpperCase()
}
function buildSearchText(profile: BrowserProfile): string {
return [
profile.profileName,
profile.launchCode || '',
...(profile.tags || []),
...(profile.keywords || []),
]
.join(' ')
.toLowerCase()
}
function sortProfiles(a: BrowserProfile, b: BrowserProfile): number {
if (a.running !== b.running) {
return a.running ? -1 : 1
}
return a.profileName.localeCompare(b.profileName, 'zh-CN')
}
function pickPrimaryTag(profile: BrowserProfile): string {
const tags = (profile.tags || []).map(t => t.trim()).filter(Boolean)
return tags.length > 0 ? tags[0] : UNTAGGED_LABEL
}
export function QuickLaunchModal({ open, onClose }: QuickLaunchModalProps) {
const [profiles, setProfiles] = useState<BrowserProfile[]>([])
const [groups, setGroups] = useState<BrowserGroupWithCount[]>([])
const [loading, setLoading] = useState(false)
const [query, setQuery] = useState('')
const [groupFilter, setGroupFilter] = useState(GROUP_ALL)
const [selectedIndex, setSelectedIndex] = useState(0)
const [startingCode, setStartingCode] = useState('')
const [activeTag, setActiveTag] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const sectionScrollRef = useRef<HTMLDivElement>(null)
const sectionRefs = useRef<Record<string, HTMLElement | null>>({})
const sectionsScrollableRef = useRef(false)
const autoScrollingRef = useRef(false)
const autoScrollTimerRef = useRef<number | null>(null)
useEffect(() => {
if (!open) return
let alive = true
setQuery('')
setGroupFilter(GROUP_ALL)
setSelectedIndex(0)
setLoading(true)
Promise.allSettled([fetchBrowserProfiles(), fetchGroups()])
.then(([profilesResult, groupsResult]) => {
if (!alive) return
if (profilesResult.status === 'fulfilled') {
setProfiles((profilesResult.value || []).slice().sort(sortProfiles))
} else {
toast.error('加载实例列表失败')
setProfiles([])
}
if (groupsResult.status === 'fulfilled') {
setGroups(groupsResult.value || [])
} else {
setGroups([])
}
})
.finally(() => {
if (!alive) return
setLoading(false)
setTimeout(() => inputRef.current?.focus(), 0)
})
return () => {
alive = false
setStartingCode('')
setActiveTag('')
}
}, [open])
const groupNameMap = useMemo(() => {
const map = new Map<string, string>()
groups.forEach((group) => {
map.set(group.groupId, group.groupName)
})
return map
}, [groups])
const groupOptions = useMemo<GroupFilterOption[]>(() => {
const countMap = new Map<string, number>()
profiles.forEach((profile) => {
const key = (profile.groupId || '').trim() || GROUP_UNGROUPED
countMap.set(key, (countMap.get(key) || 0) + 1)
})
const options: GroupFilterOption[] = groups.map((group) => ({
id: group.groupId,
name: group.groupName,
count: countMap.get(group.groupId) || 0,
}))
for (const [key, count] of countMap.entries()) {
if (key === GROUP_UNGROUPED) continue
if (!options.some((item) => item.id === key)) {
options.push({ id: key, name: groupNameMap.get(key) || `分组 ${key}`, count })
}
}
options.push({
id: GROUP_UNGROUPED,
name: '未分组',
count: countMap.get(GROUP_UNGROUPED) || 0,
})
return options
}, [profiles, groups, groupNameMap])
useEffect(() => {
if (groupFilter === GROUP_ALL) return
if (!groupOptions.some((item) => item.id === groupFilter)) {
setGroupFilter(GROUP_ALL)
}
}, [groupFilter, groupOptions])
const groupFilteredProfiles = useMemo(() => {
if (groupFilter === GROUP_ALL) return profiles
if (groupFilter === GROUP_UNGROUPED) {
return profiles.filter((profile) => !(profile.groupId || '').trim())
}
return profiles.filter((profile) => (profile.groupId || '').trim() === groupFilter)
}, [profiles, groupFilter])
const filteredProfiles = useMemo(() => {
const q = normalizeText(query)
if (!q) return groupFilteredProfiles
return groupFilteredProfiles.filter((item) => buildSearchText(item).includes(q))
}, [groupFilteredProfiles, query])
useEffect(() => {
if (filteredProfiles.length === 0) {
setSelectedIndex(0)
return
}
if (selectedIndex >= filteredProfiles.length) {
setSelectedIndex(0)
}
}, [filteredProfiles, selectedIndex])
const profileIndexMap = useMemo(() => {
const map = new Map<string, number>()
filteredProfiles.forEach((profile, index) => {
map.set(profile.profileId, index)
})
return map
}, [filteredProfiles])
const tagSections = useMemo<ProfileTagSection[]>(() => {
if (!filteredProfiles.length) return []
const bucket = new Map<string, BrowserProfile[]>()
for (const profile of filteredProfiles) {
const tag = pickPrimaryTag(profile)
if (!bucket.has(tag)) {
bucket.set(tag, [])
}
bucket.get(tag)!.push(profile)
}
const tags = Array.from(bucket.keys()).sort((a, b) => {
if (a === UNTAGGED_LABEL) return 1
if (b === UNTAGGED_LABEL) return -1
return a.localeCompare(b, 'zh-CN')
})
return tags.map((tag) => ({ tag, items: bucket.get(tag)! }))
}, [filteredProfiles])
useEffect(() => {
if (tagSections.length === 0) {
setActiveTag('')
return
}
if (!activeTag || !tagSections.some(s => s.tag === activeTag)) {
setActiveTag(tagSections[0].tag)
}
}, [tagSections, activeTag])
useEffect(() => {
const target = filteredProfiles[selectedIndex]
if (!target) return
if (!sectionsScrollableRef.current) return
setActiveTag(pickPrimaryTag(target))
}, [selectedIndex, filteredProfiles])
useEffect(() => {
const container = sectionScrollRef.current
if (!container || tagSections.length === 0) return
const isScrollable = container.scrollHeight > container.clientHeight + 4
sectionsScrollableRef.current = isScrollable
if (!isScrollable) {
autoScrollingRef.current = false
return
}
const syncActiveTagByScroll = () => {
if (autoScrollingRef.current) return
const containerRect = container.getBoundingClientRect()
const anchorTop = containerRect.top + 16
let nextActiveTag = tagSections[0].tag
for (const section of tagSections) {
const el = sectionRefs.current[section.tag]
if (!el) continue
if (el.getBoundingClientRect().top <= anchorTop) {
nextActiveTag = section.tag
} else {
break
}
}
if (container.scrollTop + container.clientHeight >= container.scrollHeight - 4) {
nextActiveTag = tagSections[tagSections.length - 1].tag
}
setActiveTag((prev) => (prev === nextActiveTag ? prev : nextActiveTag))
}
syncActiveTagByScroll()
container.addEventListener('scroll', syncActiveTagByScroll, { passive: true })
return () => {
container.removeEventListener('scroll', syncActiveTagByScroll)
}
}, [tagSections])
useEffect(() => {
return () => {
if (autoScrollTimerRef.current != null) {
window.clearTimeout(autoScrollTimerRef.current)
}
}
}, [])
const startByCode = async (code: string): Promise<boolean> => {
const normalized = normalizeCode(code)
if (!normalized || startingCode) return false
setStartingCode(normalized)
try {
const profile = await startBrowserInstanceByCode(normalized)
toast.success(profile?.running ? `实例「${profile.profileName}」已在运行` : `实例「${profile?.profileName || normalized}」已启动`)
onClose()
return true
} catch (error: any) {
toast.error(resolveActionErrorMessage(error, '按 Code 启动失败'))
return false
} finally {
setStartingCode('')
}
}
const startProfile = async (profile: BrowserProfile) => {
if (!profile.launchCode) {
toast.error('该实例尚未分配 Code,请先在实例列表设置')
return
}
await startByCode(profile.launchCode)
}
const onPanelKeyDown = async (event: ReactKeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Escape') {
event.preventDefault()
onClose()
return
}
if (event.key === 'Enter') {
event.preventDefault()
if (query.trim()) {
const ok = await startByCode(query)
if (ok) return
}
const target = filteredProfiles[selectedIndex]
if (target) {
void startProfile(target)
}
return
}
if (!filteredProfiles.length) return
if (event.key === 'ArrowDown') {
event.preventDefault()
setSelectedIndex((prev) => (prev + 1) % filteredProfiles.length)
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
setSelectedIndex((prev) => (prev - 1 + filteredProfiles.length) % filteredProfiles.length)
}
}
const jumpToTag = (tag: string) => {
setActiveTag(tag)
const container = sectionScrollRef.current
const target = sectionRefs.current[tag]
if (!container || !target) return
if (!sectionsScrollableRef.current) return
const containerRect = container.getBoundingClientRect()
const targetRect = target.getBoundingClientRect()
const targetTop = Math.max(container.scrollTop + (targetRect.top - containerRect.top) - 8, 0)
autoScrollingRef.current = true
if (autoScrollTimerRef.current != null) {
window.clearTimeout(autoScrollTimerRef.current)
}
container.scrollTo({
top: targetTop,
behavior: 'smooth',
})
autoScrollTimerRef.current = window.setTimeout(() => {
autoScrollingRef.current = false
}, 420)
}
return (
<Modal open={open} onClose={onClose} title="快速启动浏览器" width="1120px">
<div className="space-y-4" onKeyDown={onPanelKeyDown}>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-[minmax(300px,420px)_1fr] lg:items-center">
<div className="flex items-center gap-2 rounded-lg border border-[var(--color-border-default)] px-3 bg-[var(--color-bg-surface)]">
<Search className="w-4 h-4 text-[var(--color-text-muted)]" />
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="输入 Code 或实例名 / 标签 / 关键字"
className="h-9 w-full border-0 bg-transparent text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-muted)] focus:outline-none px-0"
/>
<Button
size="sm"
className="shrink-0 whitespace-nowrap px-2.5"
loading={!!startingCode}
onClick={() => void startByCode(query)}
disabled={!query.trim()}
>
Code启动
</Button>
</div>
<div className="min-w-0 rounded-lg border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] px-3 py-2">
<div className="flex items-center gap-2 overflow-x-auto">
<button
type="button"
onClick={() => {
setGroupFilter(GROUP_ALL)
setSelectedIndex(0)
}}
className={[
'shrink-0 px-3 py-1.5 rounded-md text-xs transition-colors',
groupFilter === GROUP_ALL
? 'bg-[var(--color-accent)] text-[var(--color-text-inverse)]'
: 'text-[var(--color-text-secondary)] bg-[var(--color-bg-secondary)] hover:bg-[var(--color-accent-muted)] hover:text-[var(--color-text-primary)]',
].join(' ')}
>
{profiles.length}
</button>
{groupOptions.map((option) => (
<button
key={option.id}
type="button"
onClick={() => {
setGroupFilter(option.id)
setSelectedIndex(0)
}}
className={[
'shrink-0 px-3 py-1.5 rounded-md text-xs transition-colors',
groupFilter === option.id
? 'bg-[var(--color-accent)] text-[var(--color-text-inverse)]'
: 'text-[var(--color-text-secondary)] bg-[var(--color-bg-secondary)] hover:bg-[var(--color-accent-muted)] hover:text-[var(--color-text-primary)]',
].join(' ')}
>
{option.name}{option.count}
</button>
))}
</div>
</div>
</div>
<div className="rounded-lg border border-[var(--color-border-default)] overflow-hidden">
<div className="h-[520px] bg-[var(--color-bg-elevated)] flex">
<aside className="w-52 shrink-0 border-r border-[var(--color-border-default)] bg-[var(--color-bg-surface)] p-2 overflow-y-auto">
<div className="px-2 py-2 text-xs font-semibold text-[var(--color-text-muted)] uppercase tracking-wide">
</div>
<div className="space-y-1">
{tagSections.map(section => (
<button
key={section.tag}
type="button"
onClick={() => jumpToTag(section.tag)}
className={[
'w-full flex items-center justify-between rounded-md px-2.5 py-2 text-left transition-colors',
activeTag === section.tag
? 'bg-[var(--color-accent)] text-[var(--color-text-inverse)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-accent-muted)] hover:text-[var(--color-text-primary)]',
].join(' ')}
>
<span className="inline-flex items-center gap-1.5 truncate text-sm">
<Tag className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">{section.tag}</span>
</span>
<span className="text-xs opacity-80">{section.items.length}</span>
</button>
))}
</div>
</aside>
<div ref={sectionScrollRef} className="flex-1 overflow-y-auto p-4">
{loading ? (
<div className="px-4 py-8 text-sm text-[var(--color-text-muted)] text-center">...</div>
) : filteredProfiles.length === 0 ? (
<div className="px-4 py-8 text-sm text-[var(--color-text-muted)] text-center"></div>
) : (
<div className="space-y-4">
{tagSections.map((section) => (
<section
key={section.tag}
className="space-y-2"
ref={(el) => {
sectionRefs.current[section.tag] = el
}}
>
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-[var(--color-text-primary)] inline-flex items-center gap-1.5">
<Tag className="w-3.5 h-3.5" /> {section.tag}
</h3>
<span className="text-xs text-[var(--color-text-muted)]">{section.items.length} </span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{section.items.map((profile) => {
const index = profileIndexMap.get(profile.profileId) ?? -1
const selected = index === selectedIndex
const profileCode = normalizeCode(profile.launchCode)
return (
<button
key={profile.profileId}
type="button"
onMouseEnter={() => setSelectedIndex(index >= 0 ? index : 0)}
onDoubleClick={() => void startProfile(profile)}
className={[
'w-full text-left rounded-lg border p-3 transition-colors',
selected
? 'border-[var(--color-accent)] bg-[var(--color-accent-muted)]'
: 'border-[var(--color-border-default)] hover:bg-[var(--color-bg-secondary)]',
].join(' ')}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-[var(--color-text-primary)] truncate max-w-[180px]">{profile.profileName}</span>
<Badge variant={profile.running ? 'success' : 'warning'} size="sm" dot>
{profile.running ? '运行中' : '已停止'}
</Badge>
</div>
<div className="mt-1.5">
{profile.launchCode ? (
<code className="text-[11px] px-1.5 py-0.5 rounded bg-[var(--color-bg-secondary)] text-[var(--color-accent)] border border-[var(--color-border-muted)]">
{profile.launchCode}
</code>
) : (
<Badge size="sm" variant="warning"> Code</Badge>
)}
</div>
{(profile.tags?.length || 0) > 0 && (
<div className="mt-2 flex items-center gap-1.5 flex-wrap">
{profile.tags.slice(0, 3).map((tag) => (
<Badge key={tag} size="sm">{tag}</Badge>
))}
</div>
)}
</div>
<Button
size="sm"
loading={!!startingCode && profileCode === startingCode}
onClick={(e) => {
e.stopPropagation()
void startProfile(profile)
}}
disabled={!profile.launchCode}
>
<Play className="w-3.5 h-3.5 fill-current" />
</Button>
</div>
</button>
)
})}
</div>
</section>
))}
</div>
)}
</div>
</div>
</div>
<div className="flex items-center justify-between text-xs text-[var(--color-text-muted)]">
<div className="inline-flex items-center gap-1.5">
<Keyboard className="w-3.5 h-3.5" />
<span> Code / Esc </span>
</div>
<span>全局快捷键: Ctrl/Cmd + K</span>
</div>
</div>
</Modal>
)
}
@@ -0,0 +1,171 @@
import { useEffect, useState } from 'react'
import { Archive, RotateCcw, Trash2 } from 'lucide-react'
import { Button, Card, Input, Table, toast } from '../../../shared/components'
import type { TableColumn } from '../../../shared/components/Table'
import type { SnapshotInfo } from '../types'
import { createSnapshot, deleteSnapshot, listSnapshots, restoreSnapshot } from '../api'
interface Props {
profileId: string
running: boolean
}
const defaultName = () => {
const now = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
return `快照_${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}_${pad(now.getHours())}:${pad(now.getMinutes())}`
}
const formatSize = (mb: number) => (mb < 1 ? `${(mb * 1024).toFixed(0)} KB` : `${mb.toFixed(1)} MB`)
const formatTime = (value: string) => {
const d = new Date(value)
return Number.isNaN(d.getTime()) ? '-' : d.toLocaleString('zh-CN')
}
export function SnapshotTab({ profileId, running }: Props) {
const [snapshots, setSnapshots] = useState<SnapshotInfo[]>([])
const [loading, setLoading] = useState(false)
const [creating, setCreating] = useState(false)
const [newName, setNewName] = useState(defaultName)
const [confirmRestore, setConfirmRestore] = useState<string | null>(null)
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
const [actionLoading, setActionLoading] = useState<string | null>(null)
const load = async () => {
setLoading(true)
try {
setSnapshots(await listSnapshots(profileId))
} finally {
setLoading(false)
}
}
useEffect(() => { load() }, [profileId])
const handleCreate = async () => {
if (!newName.trim()) return
setCreating(true)
try {
await createSnapshot(profileId, newName.trim())
toast.success('快照创建成功')
setNewName(defaultName())
await load()
} catch {
toast.error('快照创建失败')
} finally {
setCreating(false)
}
}
const handleRestore = async (snapshotId: string) => {
setActionLoading(snapshotId)
try {
await restoreSnapshot(profileId, snapshotId)
toast.success('快照恢复成功')
} catch {
toast.error('快照恢复失败')
} finally {
setActionLoading(null)
setConfirmRestore(null)
}
}
const handleDelete = async (snapshotId: string) => {
setActionLoading(snapshotId)
try {
await deleteSnapshot(profileId, snapshotId)
toast.success('快照已删除')
await load()
} catch {
toast.error('快照删除失败')
} finally {
setActionLoading(null)
setConfirmDelete(null)
}
}
const columns: TableColumn<SnapshotInfo>[] = [
{ key: 'name', title: '名称' },
{ key: 'sizeMB', title: '大小', render: v => formatSize(v as number) },
{ key: 'createdAt', title: '创建时间', render: v => formatTime(v as string) },
{
key: 'snapshotId',
title: '操作',
render: (snapshotId) => {
const sid = snapshotId as string
if (confirmRestore === sid) {
return (
<div className="flex items-center gap-2 text-sm">
<span className="text-[var(--color-text-muted)]"></span>
<Button size="sm" onClick={() => handleRestore(sid)} disabled={actionLoading === sid}></Button>
<Button size="sm" variant="ghost" onClick={() => setConfirmRestore(null)}></Button>
</div>
)
}
if (confirmDelete === sid) {
return (
<div className="flex items-center gap-2 text-sm">
<span className="text-[var(--color-text-muted)]"></span>
<Button size="sm" onClick={() => handleDelete(sid)} disabled={actionLoading === sid}></Button>
<Button size="sm" variant="ghost" onClick={() => setConfirmDelete(null)}></Button>
</div>
)
}
return (
<div className="flex gap-2">
<Button
size="sm"
variant="ghost"
disabled={running}
title={running ? '请先停止实例' : '恢复此快照'}
onClick={() => setConfirmRestore(sid)}
>
<RotateCcw className="w-3.5 h-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setConfirmDelete(sid)}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
)
},
},
]
return (
<div className="space-y-4">
<Card title="创建快照" subtitle={running ? '实例运行中,请先停止后再创建快照' : '将当前用户数据目录压缩为快照'}>
<div className="flex flex-col sm:flex-row gap-3">
<Input
value={newName}
onChange={e => setNewName(e.target.value)}
placeholder="快照名称"
disabled={running || creating}
className="flex-1"
/>
<Button onClick={handleCreate} disabled={running || creating || !newName.trim()}>
<Archive className="w-4 h-4" />
{creating ? '创建中...' : '创建快照'}
</Button>
</div>
</Card>
<Card
title="快照列表"
subtitle={loading ? '加载中...' : `${snapshots.length} 个快照`}
>
{snapshots.length === 0 && !loading ? (
<p className="text-sm text-[var(--color-text-muted)] py-6 text-center"></p>
) : (
<Table columns={columns} data={snapshots} rowKey="snapshotId" />
)}
</Card>
</div>
)
}
@@ -0,0 +1,46 @@
interface TagFilterBarProps {
tags: string[]
selected: Set<string>
onChange: (next: Set<string>) => void
}
export function TagFilterBar({ tags, selected, onChange }: TagFilterBarProps) {
if (tags.length === 0) return null
const toggle = (tag: string) => {
const next = new Set(selected)
next.has(tag) ? next.delete(tag) : next.add(tag)
onChange(next)
}
const isAllSelected = selected.size === 0
return (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-[var(--color-text-muted)] shrink-0"></span>
<button
onClick={() => onChange(new Set())}
className={`px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors cursor-pointer ${
isAllSelected
? 'bg-[var(--color-accent)] text-white'
: 'bg-[var(--color-bg-muted)] text-[var(--color-text-muted)] hover:bg-[var(--color-bg-subtle)]'
}`}
>
</button>
{tags.map(tag => (
<button
key={tag}
onClick={() => toggle(tag)}
className={`px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors cursor-pointer ${
selected.has(tag)
? 'bg-[var(--color-accent)] text-white'
: 'bg-[var(--color-bg-muted)] text-[var(--color-text-muted)] hover:bg-[var(--color-bg-subtle)]'
}`}
>
{tag}
</button>
))}
</div>
)
}
@@ -0,0 +1,102 @@
import { useEffect, useRef, useState } from 'react'
import { X } from 'lucide-react'
interface TagInputProps {
value: string[]
onChange: (tags: string[]) => void
suggestions?: string[]
placeholder?: string
}
export function TagInput({ value, onChange, suggestions = [], placeholder = '输入标签后按回车' }: TagInputProps) {
const [input, setInput] = useState('')
const [showSuggestions, setShowSuggestions] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const filtered = suggestions.filter(
s => s.toLowerCase().includes(input.toLowerCase()) && !value.includes(s)
)
const addTag = (tag: string) => {
const t = tag.trim()
if (!t || value.includes(t)) return
onChange([...value, t])
setInput('')
setShowSuggestions(false)
}
const removeTag = (tag: string) => {
onChange(value.filter(t => t !== tag))
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
addTag(input)
} else if (e.key === 'Backspace' && !input && value.length > 0) {
removeTag(value[value.length - 1])
} else if (e.key === 'Escape') {
setShowSuggestions(false)
}
}
// 点击外部关闭建议
useEffect(() => {
const handler = (e: MouseEvent) => {
if (inputRef.current && !inputRef.current.closest('.tag-input-wrap')?.contains(e.target as Node)) {
setShowSuggestions(false)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [])
return (
<div className="tag-input-wrap relative">
<div
className="min-h-9 flex flex-wrap gap-1.5 items-center px-3 py-1.5 rounded-md border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] cursor-text focus-within:border-[var(--color-accent)] transition-colors"
onClick={() => inputRef.current?.focus()}
>
{value.map(tag => (
<span
key={tag}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium bg-[var(--color-accent-muted)] text-[var(--color-accent)]"
>
{tag}
<button
type="button"
onClick={e => { e.stopPropagation(); removeTag(tag) }}
className="hover:text-[var(--color-error)] transition-colors"
>
<X className="w-3 h-3" />
</button>
</span>
))}
<input
ref={inputRef}
value={input}
onChange={e => { setInput(e.target.value); setShowSuggestions(true) }}
onKeyDown={handleKeyDown}
onFocus={() => setShowSuggestions(true)}
placeholder={value.length === 0 ? placeholder : ''}
className="flex-1 min-w-24 bg-transparent text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-muted)] outline-none"
/>
</div>
{showSuggestions && filtered.length > 0 && (
<div className="absolute z-20 top-full mt-1 w-full bg-[var(--color-bg-surface)] border border-[var(--color-border-default)] rounded-md shadow-lg overflow-hidden">
{filtered.slice(0, 8).map(s => (
<button
key={s}
type="button"
onMouseDown={e => { e.preventDefault(); addTag(s) }}
className="w-full text-left px-3 py-2 text-sm text-[var(--color-text-primary)] hover:bg-[var(--color-bg-muted)] transition-colors"
>
{s}
</button>
))}
</div>
)}
</div>
)
}
+15
View File
@@ -0,0 +1,15 @@
export {
TagManagementPage,
BrowserListPage,
BrowserDetailPage,
BrowserEditPage,
BrowserCopyPage,
BrowserLogsPage,
ProxyPoolPage,
CoreManagementPage,
BookmarkSettingsPage,
LaunchApiDocsPage,
AutomationPage,
UsageTutorialPage,
} from './pages'
@@ -0,0 +1,139 @@
import { useEffect, useState } from 'react'
import { Bot, Copy, Rocket } from 'lucide-react'
import { Button, Card, toast } from '../../../shared/components'
import { fetchLaunchServerInfo } from '../api'
const DEFAULT_LAUNCH_BASE_URL = 'http://127.0.0.1:19876'
function buildSampleRequest(baseUrl: string): string {
return `curl -X POST ${baseUrl}/api/launch \\
-H "Content-Type: application/json" \\
-d '{
"code": "A3F9K2",
"launchArgs": ["--window-size=1280,800", "--lang=en-US"],
"startUrls": ["https://example.com"],
"skipDefaultStartUrls": true
}'`
}
const sampleResponse = `{
"ok": true,
"profileId": "550e8400-e29b-41d4-a716-446655440000",
"profileName": "账号 A",
"pid": 12345,
"debugPort": 9222
}`
function buildSampleLogsRequest(baseUrl: string): string {
return `curl ${baseUrl}/api/launch/logs?limit=20`
}
function CopyCodeButton({ text }: { text: string }) {
return (
<Button
size="sm"
variant="secondary"
onClick={() => {
navigator.clipboard.writeText(text).then(() => toast.success('已复制'))
}}
>
<Copy className="w-3.5 h-3.5" />
</Button>
)
}
export function AutomationPage() {
const [launchBaseUrl, setLaunchBaseUrl] = useState(DEFAULT_LAUNCH_BASE_URL)
const [launchServerReady, setLaunchServerReady] = useState(false)
useEffect(() => {
let disposed = false
void fetchLaunchServerInfo()
.then((info) => {
if (disposed) return
if (info.baseUrl) {
setLaunchBaseUrl(info.baseUrl)
}
setLaunchServerReady(info.ready)
})
.catch(() => {})
return () => {
disposed = true
}
}, [])
const sampleRequest = buildSampleRequest(launchBaseUrl)
const sampleLogsRequest = buildSampleLogsRequest(launchBaseUrl)
return (
<div className="space-y-5 animate-fade-in">
<Card>
<div className="flex items-start justify-between gap-4">
<div>
<div className="inline-flex items-center gap-2 px-2.5 py-1 rounded-full bg-[var(--color-accent-muted)] text-[var(--color-accent)] text-xs font-medium mb-3">
<Bot className="w-3.5 h-3.5" />
</div>
<h1 className="text-xl font-semibold text-[var(--color-text-primary)]"></h1>
<p className="text-sm text-[var(--color-text-secondary)] mt-2">
<code> Code + </code> PlaywrightSelenium
</p>
<p className="text-xs text-[var(--color-text-muted)] mt-2">
Launch <code>{launchBaseUrl}</code>
{!launchServerReady ? '(服务启动后会自动刷新)' : ''}
</p>
</div>
</div>
</Card>
<Card
title="1) 参数化唤起接口"
subtitle="POST /api/launch"
actions={<CopyCodeButton text={sampleRequest} />}
>
<pre className="text-xs leading-relaxed font-mono text-[var(--color-text-primary)] bg-[var(--color-bg-secondary)] border border-[var(--color-border-muted)] rounded-lg p-3 overflow-x-auto">
{sampleRequest}
</pre>
<div className="mt-3 text-sm text-[var(--color-text-secondary)] space-y-1">
<p><code>code</code>: </p>
<p><code>launchArgs</code>: Chrome </p>
<p><code>startUrls</code>: </p>
<p><code>skipDefaultStartUrls</code>: <code>true</code> </p>
</div>
</Card>
<Card
title="2) 响应结构"
subtitle="成功返回 pid + debugPort,可直接接 CDP"
actions={<CopyCodeButton text={sampleResponse} />}
>
<pre className="text-xs leading-relaxed font-mono text-[var(--color-text-primary)] bg-[var(--color-bg-secondary)] border border-[var(--color-border-muted)] rounded-lg p-3 overflow-x-auto">
{sampleResponse}
</pre>
</Card>
<Card
title="3) 调用记录"
subtitle="GET /api/launch/logs?limit=20"
actions={<CopyCodeButton text={sampleLogsRequest} />}
>
<pre className="text-xs leading-relaxed font-mono text-[var(--color-text-primary)] bg-[var(--color-bg-secondary)] border border-[var(--color-border-muted)] rounded-lg p-3 overflow-x-auto">
{sampleLogsRequest}
</pre>
<p className="mt-3 text-sm text-[var(--color-text-secondary)]">
50 200
</p>
</Card>
<Card>
<div className="flex items-start gap-2 text-sm text-[var(--color-text-secondary)]">
<Rocket className="w-4 h-4 mt-0.5 text-[var(--color-accent)]" />
<p>
</p>
</div>
</Card>
</div>
)
}
@@ -0,0 +1,148 @@
import { useEffect, useState } from 'react'
import { Plus, Trash2, RotateCcw, GripVertical } from 'lucide-react'
import { Button, Card, ConfirmModal, Input, toast } from '../../../shared/components'
import type { BrowserBookmark } from '../types'
import { fetchBookmarks, resetBookmarks, saveBookmarks } from '../api'
export function BookmarkSettingsPage() {
const [items, setItems] = useState<BrowserBookmark[]>([])
const [saving, setSaving] = useState(false)
const [resetOpen, setResetOpen] = useState(false)
const [dragIndex, setDragIndex] = useState<number | null>(null)
useEffect(() => {
fetchBookmarks().then(setItems)
}, [])
const handleChange = (index: number, field: keyof BrowserBookmark, value: string) => {
setItems(prev => prev.map((item, i) => i === index ? { ...item, [field]: value } : item))
}
const handleAdd = () => {
setItems(prev => [...prev, { name: '', url: '' }])
}
const handleDelete = (index: number) => {
setItems(prev => prev.filter((_, i) => i !== index))
}
const handleSave = async () => {
const valid = items.filter(i => i.name.trim() && i.url.trim())
if (valid.length !== items.length) {
toast.error('存在空的名称或 URL,请填写完整后保存')
return
}
setSaving(true)
try {
await saveBookmarks(items)
toast.success('书签已保存,下次新建实例时生效')
} finally {
setSaving(false)
}
}
const handleReset = async () => {
await resetBookmarks()
const fresh = await fetchBookmarks()
setItems(fresh)
toast.success('已恢复默认书签')
}
// 拖拽排序
const handleDragStart = (index: number) => setDragIndex(index)
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault()
if (dragIndex === null || dragIndex === index) return
setItems(prev => {
const next = [...prev]
const [moved] = next.splice(dragIndex, 1)
next.splice(index, 0, moved)
return next
})
setDragIndex(index)
}
const handleDragEnd = () => setDragIndex(null)
return (
<div className="space-y-5 animate-fade-in">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-[var(--color-text-primary)]"></h1>
<p className="text-sm text-[var(--color-text-muted)] mt-1"></p>
</div>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={() => setResetOpen(true)}>
<RotateCcw className="w-4 h-4 mr-1.5" />
</Button>
<Button size="sm" onClick={handleSave} loading={saving}></Button>
</div>
</div>
<Card title={`书签列表(${items.length} 项)`} subtitle="拖拽左侧图标可调整顺序">
<div className="space-y-2">
{items.map((item, index) => (
<div
key={index}
draggable
onDragStart={() => handleDragStart(index)}
onDragOver={e => handleDragOver(e, index)}
onDragEnd={handleDragEnd}
className={`flex items-center gap-2 p-2 rounded-lg border transition-colors ${
dragIndex === index
? 'border-[var(--color-primary)] bg-[var(--color-bg-hover)]'
: 'border-[var(--color-border)] hover:border-[var(--color-border-hover)]'
}`}
>
<GripVertical className="w-4 h-4 text-[var(--color-text-muted)] cursor-grab shrink-0" />
<Input
value={item.name}
onChange={e => handleChange(index, 'name', e.target.value)}
placeholder="名称,如 Google"
className="w-36 shrink-0"
/>
<Input
value={item.url}
onChange={e => handleChange(index, 'url', e.target.value)}
placeholder="https://..."
className="flex-1"
/>
<button
type="button"
onClick={() => handleDelete(index)}
className="p-1.5 rounded text-[var(--color-text-muted)] hover:text-red-500 hover:bg-red-50 transition-colors shrink-0"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
{items.length === 0 && (
<p className="text-sm text-[var(--color-text-muted)] text-center py-6">
</p>
)}
</div>
<button
type="button"
onClick={handleAdd}
className="mt-3 w-full flex items-center justify-center gap-2 py-2 rounded-lg border border-dashed border-[var(--color-border)] text-sm text-[var(--color-text-muted)] hover:border-[var(--color-primary)] hover:text-[var(--color-primary)] transition-colors"
>
<Plus className="w-4 h-4" />
</button>
</Card>
<ConfirmModal
open={resetOpen}
onClose={() => setResetOpen(false)}
onConfirm={handleReset}
title="恢复默认书签"
content="将清除当前所有自定义书签,恢复为内置默认列表。确定继续?"
confirmText="确定恢复"
danger
/>
</div>
)
}
@@ -0,0 +1,82 @@
import { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Button, Card, FormItem, Input, Select, toast } from '../../../shared/components'
import type { BrowserProfile } from '../types'
import { createBrowserProfile, fetchBrowserProfiles } from '../api'
export function BrowserCopyPage() {
const { id } = useParams()
const navigate = useNavigate()
const [profiles, setProfiles] = useState<BrowserProfile[]>([])
const [sourceId, setSourceId] = useState(id || '')
const [targetName, setTargetName] = useState('')
const [saving, setSaving] = useState(false)
useEffect(() => {
const loadProfiles = async () => {
const list = await fetchBrowserProfiles()
setProfiles(list)
if (!sourceId && list.length > 0) {
setSourceId(list[0].profileId)
}
}
loadProfiles()
}, [])
const sourceProfile = profiles.find(item => item.profileId === sourceId)
const handleCopy = async () => {
if (!sourceProfile || !targetName) {
toast.error('请填写目标名称')
return
}
setSaving(true)
try {
await createBrowserProfile({
profileName: targetName,
userDataDir: `${sourceProfile.userDataDir}-copy`,
coreId: sourceProfile.coreId,
fingerprintArgs: sourceProfile.fingerprintArgs,
proxyId: sourceProfile.proxyId,
proxyConfig: sourceProfile.proxyConfig,
launchArgs: sourceProfile.launchArgs,
tags: sourceProfile.tags,
keywords: sourceProfile.keywords || [],
})
toast.success('配置已复制')
navigate('/browser/list')
} finally {
setSaving(false)
}
}
return (
<div className="space-y-5 animate-fade-in">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-[var(--color-text-primary)]"></h1>
<p className="text-sm text-[var(--color-text-muted)] mt-1"></p>
</div>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={() => navigate('/browser/list')}></Button>
<Button size="sm" onClick={handleCopy} loading={saving}></Button>
</div>
</div>
<Card title="复制设置" subtitle="选择源配置并设置名称">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormItem label="源配置">
<Select
value={sourceId}
onChange={e => setSourceId(e.target.value)}
options={profiles.map(item => ({ value: item.profileId, label: item.profileName }))}
/>
</FormItem>
<FormItem label="新配置名称">
<Input value={targetName} onChange={e => setTargetName(e.target.value)} placeholder="请输入名称" />
</FormItem>
</div>
</Card>
</div>
)
}
@@ -0,0 +1,294 @@
import { useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { Copy, Globe, Play, RefreshCw, RotateCcw, Square } from 'lucide-react'
import { Badge, Button, Card, Input, Table, toast } from '../../../shared/components'
import type { TableColumn } from '../../../shared/components/Table'
import type { BrowserProfile, BrowserTab } from '../types'
import {
fetchBrowserProfiles,
fetchBrowserTabs,
openBrowserUrl,
regenerateBrowserProfileCode,
restartBrowserInstance,
startBrowserInstance,
stopBrowserInstance,
} from '../api'
import { CookieManagerCard } from '../components/CookieManagerCard'
import { SnapshotTab } from '../components/SnapshotTab'
import { resolveActionErrorMessage } from '../utils/actionErrors'
const statusVariant = (running: boolean) => (running ? 'success' : 'warning')
const formatTime = (value?: string) => {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '-'
return date.toLocaleString('zh-CN')
}
type TabKey = 'overview' | 'snapshot'
const TABS: { key: TabKey; label: string }[] = [
{ key: 'overview', label: '概览' },
{ key: 'snapshot', label: '快照管理' },
]
export function BrowserDetailPage() {
const { id } = useParams()
const [profile, setProfile] = useState<BrowserProfile | null>(null)
const [tabs, setTabs] = useState<BrowserTab[]>([])
const [targetUrl, setTargetUrl] = useState('https://example.com')
const [activeTab, setActiveTab] = useState<TabKey>('overview')
const loadProfile = async () => {
const list = await fetchBrowserProfiles()
const current = list.find(item => item.profileId === id) || null
setProfile(current)
}
const loadTabs = async () => {
if (!id) return
const list = await fetchBrowserTabs(id)
setTabs(list)
}
useEffect(() => { loadProfile() }, [id])
useEffect(() => { loadTabs() }, [id])
if (!profile) {
return (
<div className="flex items-center justify-center h-64 text-sm text-[var(--color-text-muted)]">
</div>
)
}
const handleOpenUrl = async () => {
await openBrowserUrl(profile.profileId, targetUrl)
toast.success('已发送打开指令')
}
const handleStart = async () => {
try {
await startBrowserInstance(profile.profileId)
toast.success('实例已启动')
} catch (error: any) {
toast.error(resolveActionErrorMessage(error, '实例启动失败'))
} finally {
loadProfile()
}
}
const handleStop = async () => {
try {
await stopBrowserInstance(profile.profileId)
toast.success('实例已停止')
} catch (error: any) {
toast.error(resolveActionErrorMessage(error, '实例停止失败'))
} finally {
loadProfile()
}
}
const handleRestart = async () => {
try {
await restartBrowserInstance(profile.profileId)
toast.success('实例已重启')
} catch (error: any) {
toast.error(resolveActionErrorMessage(error, '实例重启失败'))
} finally {
loadProfile()
}
}
const tabsColumns: TableColumn<BrowserTab>[] = [
{ key: 'title', title: '标题' },
{ key: 'url', title: '地址' },
{
key: 'active',
title: '状态',
render: value => (
<Badge variant={value ? 'success' : 'default'}>{value ? '当前' : '后台'}</Badge>
),
},
]
return (
<div className="space-y-5 animate-fade-in">
{/* 页头 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-[var(--color-text-primary)]"></h1>
<p className="text-sm text-[var(--color-text-muted)] mt-1">{profile.profileName}</p>
</div>
<div className="flex gap-2">
<Link to={`/browser/edit/${profile.profileId}`}>
<Button variant="secondary" size="sm"></Button>
</Link>
<Link to="/browser/list">
<Button variant="ghost" size="sm"></Button>
</Link>
</div>
</div>
{/* Tab 导航 */}
<div className="flex border-b border-[var(--color-border)]">
{TABS.map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={[
'px-4 py-2 text-sm font-medium transition-colors',
activeTab === tab.key
? 'border-b-2 border-[var(--color-primary)] text-[var(--color-primary)]'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]',
].join(' ')}
>
{tab.label}
</button>
))}
</div>
{/* 概览 Tab */}
{activeTab === 'overview' && (
<div className="space-y-4">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card title="运行信息" subtitle="实例运行状态与端口信息">
<div className="space-y-3 text-sm text-[var(--color-text-secondary)]">
<div className="flex justify-between">
<span></span>
<Badge variant={statusVariant(profile.running)} dot>{profile.running ? '运行中' : '已停止'}</Badge>
</div>
<div className="flex justify-between">
<span> PID</span>
<span>{profile.pid || '-'}</span>
</div>
<div className="flex justify-between">
<span></span>
<span>{profile.debugPort || '-'}</span>
</div>
<div className="flex justify-between">
<span></span>
<span>{formatTime(profile.lastStartAt)}</span>
</div>
<div className="flex justify-between">
<span></span>
<span>{formatTime(profile.lastStopAt)}</span>
</div>
</div>
</Card>
<Card title="配置摘要" subtitle="指纹与启动参数">
<div className="space-y-3 text-sm text-[var(--color-text-secondary)]">
<div className="flex justify-between">
<span></span>
<span>{profile.userDataDir}</span>
</div>
<div className="flex justify-between">
<span></span>
<span>{profile.coreId || '默认'}</span>
</div>
<div className="flex justify-between">
<span></span>
<span>{profile.proxyConfig || '-'}</span>
</div>
<div className="flex justify-between">
<span></span>
<span>{profile.fingerprintArgs?.length || 0} </span>
</div>
<div className="flex justify-between">
<span></span>
<span>{profile.launchArgs?.length || 0} </span>
</div>
<div className="flex justify-between">
<span></span>
<span>{profile.tags?.join(', ') || '-'}</span>
</div>
<div className="flex justify-between items-center">
<span></span>
<div className="flex items-center gap-1">
{profile.launchCode ? (
<>
<code className="text-xs font-mono bg-[var(--color-bg-secondary)] px-1.5 py-0.5 rounded text-[var(--color-accent)]">{profile.launchCode}</code>
<button
onClick={() => navigator.clipboard.writeText(profile.launchCode!).then(() => toast.success('已复制快捷码'))}
className="p-0.5 hover:text-[var(--color-accent)] text-[var(--color-text-muted)] transition-colors"
title="复制"
>
<Copy className="w-3 h-3" />
</button>
<button
onClick={async () => {
await regenerateBrowserProfileCode(profile.profileId)
loadProfile()
toast.success('快捷码已重新生成')
}}
className="p-0.5 hover:text-[var(--color-accent)] text-[var(--color-text-muted)] transition-colors"
title="重新生成"
>
<RefreshCw className="w-3 h-3" />
</button>
</>
) : (
<span className="text-[var(--color-text-muted)]">-</span>
)}
</div>
</div>
</div>
</Card>
</div>
<Card title="快捷操作" subtitle="快速控制实例">
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" onClick={handleStart}>
<Play className="w-4 h-4" />
</Button>
<Button size="sm" variant="secondary" onClick={handleStop}>
<Square className="w-4 h-4" />
</Button>
<Button size="sm" variant="ghost" onClick={handleRestart}>
<RotateCcw className="w-4 h-4" />
</Button>
</div>
</Card>
{profile.lastError && (
<Card title="最近错误" subtitle="最近一次启动或运行失败原因">
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 whitespace-pre-line">
{profile.lastError}
</div>
</Card>
)}
<Card title="打开地址" subtitle="向实例发送打开 URL 指令">
<div className="flex flex-col md:flex-row gap-3">
<Input value={targetUrl} onChange={e => setTargetUrl(e.target.value)} placeholder="请输入目标地址" />
<Button onClick={handleOpenUrl}>
<Globe className="w-4 h-4" />
</Button>
</div>
</Card>
<Card title="标签页列表" subtitle="当前实例标签页信息">
<Table columns={tabsColumns} data={tabs} rowKey="tabId" />
</Card>
<CookieManagerCard
profileId={profile.profileId}
profileName={profile.profileName}
running={profile.running}
/>
</div>
)}
{/* 快照管理 Tab */}
{activeTab === 'snapshot' && (
<SnapshotTab profileId={profile.profileId} running={profile.running} />
)}
</div>
)
}
@@ -0,0 +1,256 @@
import { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { FolderOpen, Layers } from 'lucide-react'
import { Button, Card, ConfirmModal, FormItem, Input, Modal, Select, Textarea, toast } from '../../../shared/components'
import type { BrowserCore, BrowserProfileInput, BrowserProxy, BrowserGroup } from '../types'
import { createBrowserProfile, fetchAllTags, fetchBrowserCores, fetchBrowserProfiles, fetchBrowserProxies, fetchGroups, openUserDataDir, updateBrowserProfile } from '../api'
import { FingerprintPanel } from '../components/FingerprintPanel'
import { TagInput } from '../components/TagInput'
import { GroupSelector } from '../components/GroupSelector'
import { ProxyPickerModal } from '../components/ProxyPickerModal'
export function BrowserEditPage() {
const { id } = useParams()
const navigate = useNavigate()
const isCreate = id === 'new'
const [formData, setFormData] = useState<BrowserProfileInput>({
profileName: '',
userDataDir: '',
coreId: '',
fingerprintArgs: [],
proxyId: '',
proxyConfig: '',
launchArgs: [],
tags: [],
keywords: [],
groupId: '',
})
const [cores, setCores] = useState<BrowserCore[]>([])
const [proxies, setProxies] = useState<BrowserProxy[]>([])
const [groups, setGroups] = useState<BrowserGroup[]>([])
const [launchArgsText, setLaunchArgsText] = useState('')
const [allTags, setAllTags] = useState<string[]>([])
const [saving, setSaving] = useState(false)
const [proxyPickerOpen, setProxyPickerOpen] = useState(false)
const [isDirty, setIsDirty] = useState(false)
const [leaveConfirm, setLeaveConfirm] = useState(false)
const [saveError, setSaveError] = useState('')
useEffect(() => {
const loadData = async () => {
const [coreList, proxyList, tagList, groupList] = await Promise.all([
fetchBrowserCores(),
fetchBrowserProxies(),
fetchAllTags(),
fetchGroups(),
])
setCores(coreList)
setProxies(proxyList)
setAllTags(tagList)
setGroups(groupList)
if (isCreate) return
const list = await fetchBrowserProfiles()
const current = list.find(item => item.profileId === id)
if (!current) return
setFormData({
profileName: current.profileName,
userDataDir: current.userDataDir,
coreId: current.coreId,
fingerprintArgs: current.fingerprintArgs,
proxyId: current.proxyId,
proxyConfig: current.proxyConfig,
launchArgs: current.launchArgs,
tags: current.tags,
keywords: current.keywords || [],
groupId: current.groupId || '',
})
setLaunchArgsText(current.launchArgs.join('\n'))
}
loadData()
}, [id, isCreate])
const handleChange = (field: keyof BrowserProfileInput, value: string | string[]) => {
setIsDirty(true)
setFormData(prev => ({ ...prev, [field]: value }))
}
const handleSave = async () => {
setSaving(true)
const payload: BrowserProfileInput = {
...formData,
launchArgs: launchArgsText.split('\n').map((s: string) => s.trim()).filter(Boolean),
}
try {
if (isCreate) {
await createBrowserProfile(payload)
toast.success('配置已创建')
} else if (id) {
await updateBrowserProfile(id, payload)
toast.success('配置已更新')
}
setIsDirty(false)
navigate('/browser/list')
} catch (error: any) {
setSaveError(typeof error === 'string' ? error : error?.message || '保存失败')
} finally {
setSaving(false)
}
}
const handleBack = () => {
if (isDirty) { setLeaveConfirm(true) } else { navigate('/browser/list') }
}
const defaultCore = cores.find(c => c.isDefault)
const handleOpenUserDataDir = async () => {
if (!formData.userDataDir.trim()) {
toast.error('请先输入用户数据目录')
return
}
try {
await openUserDataDir(formData.userDataDir)
} catch (error: unknown) {
toast.error((error as Error)?.message || '打开目录失败')
}
}
return (
<div className="space-y-5 animate-fade-in">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-[var(--color-text-primary)]">{isCreate ? '新建配置' : '编辑配置'}</h1>
<p className="text-sm text-[var(--color-text-muted)] mt-1"></p>
</div>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={handleBack}></Button>
<Button size="sm" onClick={handleSave} loading={saving}></Button>
</div>
</div>
<Card title="基础信息" subtitle="实例与配置名称">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormItem label="配置名称" required>
<Input value={formData.profileName} onChange={e => handleChange('profileName', e.target.value)} placeholder="请输入配置名称" />
</FormItem>
<FormItem label="用户数据目录(留空自动生成)">
<div className="flex gap-2">
<Input
value={formData.userDataDir}
onChange={e => handleChange('userDataDir', e.target.value)}
placeholder="留空自动生成"
className="flex-1"
/>
<Button variant="secondary" size="sm" onClick={handleOpenUserDataDir} title="在资源管理器中打开">
<FolderOpen className="w-4 h-4" />
</Button>
</div>
</FormItem>
<FormItem label="内核">
<Select
value={formData.coreId}
onChange={e => handleChange('coreId', e.target.value)}
options={
cores.length > 0 ? [
{ value: '', label: defaultCore ? `使用默认 (${defaultCore.coreName})` : '使用默认内核' },
...cores.map(c => ({ value: c.coreId, label: c.coreName })),
] : [
{ value: '', label: '暂无内核,请添加内核' }
]
}
/>
</FormItem>
<FormItem label="标签">
<TagInput
value={formData.tags}
onChange={tags => handleChange('tags', tags)}
suggestions={allTags}
placeholder="输入标签后按回车,支持从已有标签选择"
/>
</FormItem>
<FormItem label="分组">
<GroupSelector
groups={groups}
value={formData.groupId || ''}
onChange={groupId => handleChange('groupId', groupId)}
placeholder="未分组"
className="w-full"
/>
</FormItem>
</div>
</Card>
<Card title="代理配置" subtitle="选择代理池中的代理或手动输入">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormItem label="代理池选择">
<div className="flex gap-2">
<Select
value={formData.proxyId}
onChange={e => handleChange('proxyId', e.target.value)}
options={[
{ value: '', label: '不使用代理池' },
...proxies.map(p => ({ value: p.proxyId, label: p.proxyName || p.proxyId })),
]}
className="flex-1"
/>
<Button variant="secondary" size="sm" onClick={() => setProxyPickerOpen(true)} title="按分组选择代理">
<Layers className="w-4 h-4" />
</Button>
</div>
</FormItem>
<FormItem label="手动代理配置">
<Input
value={formData.proxyConfig}
onChange={e => handleChange('proxyConfig', e.target.value)}
placeholder="http://127.0.0.1:7890"
disabled={!!formData.proxyId}
/>
</FormItem>
</div>
{formData.proxyId && (
<p className="text-xs text-[var(--color-text-muted)] mt-2"></p>
)}
</Card>
<ProxyPickerModal
open={proxyPickerOpen}
currentProxyId={formData.proxyId}
onSelect={proxy => handleChange('proxyId', proxy.proxyId)}
onClose={() => setProxyPickerOpen(false)}
/>
<Card title="指纹配置" subtitle="配置浏览器指纹参数">
<FingerprintPanel
value={formData.fingerprintArgs}
onChange={args => handleChange('fingerprintArgs', args)}
/>
</Card>
<Card title="启动参数" subtitle="每行一个参数">
<Textarea value={launchArgsText} onChange={e => { setLaunchArgsText(e.target.value); setIsDirty(true) }} rows={6} placeholder="--disable-sync" />
</Card>
<ConfirmModal
open={leaveConfirm}
onClose={() => setLeaveConfirm(false)}
onConfirm={() => navigate('/browser/list')}
title="放弃未保存的更改?"
content="当前页面有未保存的修改,离开后将丢失这些更改。"
confirmText="放弃并离开"
cancelText="继续编辑"
danger
/>
<Modal
open={!!saveError}
onClose={() => setSaveError('')}
title="保存失败"
width="420px"
footer={<Button onClick={() => setSaveError('')}></Button>}
>
<div className="text-[var(--color-text-secondary)]">{saveError}</div>
</Modal>
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,193 @@
import { useEffect, useRef, useState } from 'react'
import { RefreshCw, Trash2 } from 'lucide-react'
import { Badge, Button, Card } from '../../../shared/components'
interface LogEntry {
time: string
level: string
component: string
message: string
fields?: Record<string, any>
}
const LEVELS = ['ALL', 'DEBUG', 'INFO', 'WARN', 'ERROR']
const levelVariant = (level: string) => {
switch (level) {
case 'ERROR': return 'error'
case 'WARN': return 'warning'
case 'DEBUG': return 'default'
default: return 'info'
}
}
const levelColor = (level: string) => {
switch (level) {
case 'ERROR': return 'text-red-500'
case 'WARN': return 'text-yellow-500'
case 'DEBUG': return 'text-[var(--color-text-muted)]'
default: return 'text-[var(--color-text-secondary)]'
}
}
async function fetchLogs(): Promise<LogEntry[]> {
try {
const bindings: any = await import('../../../wailsjs/go/main/App')
return (await bindings.GetAppLogs()) || []
} catch { return [] }
}
async function clearLogs() {
try {
const bindings: any = await import('../../../wailsjs/go/main/App')
await bindings.ClearAppLogs()
} catch { /* ignore */ }
}
export function BrowserLogsPage() {
const [logs, setLogs] = useState<LogEntry[]>([])
const [levelFilter, setLevelFilter] = useState('ALL')
const [keyword, setKeyword] = useState('')
const [autoScroll, setAutoScroll] = useState(true)
const [loading, setLoading] = useState(false)
const bottomRef = useRef<HTMLDivElement>(null)
const load = async () => {
setLoading(true)
try {
const data = await fetchLogs()
setLogs(data)
} finally {
setLoading(false)
}
}
useEffect(() => {
load()
const timer = setInterval(load, 3000)
return () => clearInterval(timer)
}, [])
useEffect(() => {
if (autoScroll) {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}
}, [logs, autoScroll])
const handleClear = async () => {
await clearLogs()
setLogs([])
}
const filtered = logs.filter(entry => {
if (levelFilter !== 'ALL' && entry.level !== levelFilter) return false
if (keyword && !entry.message.toLowerCase().includes(keyword.toLowerCase()) &&
!entry.component.toLowerCase().includes(keyword.toLowerCase())) return false
return true
})
return (
<div className="space-y-4 animate-fade-in">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-[var(--color-text-primary)]"></h1>
<p className="text-sm text-[var(--color-text-muted)] mt-1"> 3 </p>
</div>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={load} loading={loading}>
<RefreshCw className="w-4 h-4" />
</Button>
<Button variant="secondary" size="sm" onClick={handleClear}>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
{/* 工具栏 */}
<div className="flex items-center gap-3 flex-wrap">
{/* 级别过滤 */}
<div className="flex gap-1">
{LEVELS.map(l => (
<button
key={l}
onClick={() => setLevelFilter(l)}
className={`px-2.5 py-1 text-xs rounded-md transition-colors ${
levelFilter === l
? 'bg-[var(--color-accent)] text-white'
: 'bg-[var(--color-bg-muted)] text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)]'
}`}
>
{l}
</button>
))}
</div>
<input
value={keyword}
onChange={e => setKeyword(e.target.value)}
placeholder="搜索消息或组件..."
className="px-3 py-1.5 text-sm rounded-md border border-[var(--color-border-default)] bg-[var(--color-bg-secondary)] text-[var(--color-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-accent)] w-48"
/>
<label className="flex items-center gap-1.5 text-xs text-[var(--color-text-muted)] cursor-pointer select-none ml-auto">
<input
type="checkbox"
checked={autoScroll}
onChange={e => setAutoScroll(e.target.checked)}
className="w-3.5 h-3.5"
/>
</label>
<span className="text-xs text-[var(--color-text-muted)]">
{filtered.length} / {logs.length}
</span>
</div>
{/* 日志列表 */}
<Card padding="none">
<div
className="overflow-auto font-mono text-xs"
style={{ maxHeight: 'calc(100vh - 280px)' }}
>
{filtered.length === 0 ? (
<div className="py-16 text-center text-sm text-[var(--color-text-muted)]"></div>
) : (
<table className="min-w-full">
<thead className="sticky top-0 z-10 bg-[var(--color-bg-muted)]">
<tr>
<th className="px-3 py-2 text-left text-[var(--color-text-muted)] font-semibold w-40"></th>
<th className="px-3 py-2 text-left text-[var(--color-text-muted)] font-semibold w-16"></th>
<th className="px-3 py-2 text-left text-[var(--color-text-muted)] font-semibold w-28"></th>
<th className="px-3 py-2 text-left text-[var(--color-text-muted)] font-semibold"></th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--color-border-muted)]">
{filtered.map((entry, i) => (
<tr key={i} className="hover:bg-[var(--color-bg-muted)]/40">
<td className="px-3 py-1.5 text-[var(--color-text-muted)] whitespace-nowrap">{entry.time}</td>
<td className="px-3 py-1.5">
<Badge variant={levelVariant(entry.level)} className="text-[10px]">{entry.level}</Badge>
</td>
<td className="px-3 py-1.5 text-[var(--color-text-muted)] truncate max-w-[112px]" title={entry.component}>
{entry.component}
</td>
<td className={`px-3 py-1.5 ${levelColor(entry.level)}`}>
<span>{entry.message}</span>
{entry.fields && Object.keys(entry.fields).length > 0 && (
<span className="ml-2 text-[var(--color-text-muted)]">
{Object.entries(entry.fields).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(' ')}
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
<div ref={bottomRef} />
</div>
</Card>
</div>
)
}
@@ -0,0 +1,648 @@
import { useEffect, useState, useCallback } from 'react'
import { FolderOpen, Settings, Edit2 } from 'lucide-react'
import { Badge, Button, Card, ConfirmModal, FormItem, Input, Modal, Table, Textarea, toast } from '../../../shared/components'
import type { TableColumn } from '../../../shared/components/Table'
import type { BrowserCore, BrowserCoreInput, BrowserCoreValidateResult, BrowserSettings, BrowserCoreExtended, BrowserProxy } from '../types'
import { fetchBrowserCores, saveBrowserCore, deleteBrowserCore, setDefaultBrowserCore, validateBrowserCorePath, openCorePath, fetchBrowserSettings, saveBrowserSettings, fetchCoreExtendedInfo, scanBrowserCores, BrowserCoreDownload, fetchBrowserProxies } from '../api'
import { EventsOn, EventsOff, BrowserOpenURL } from '../../../wailsjs/runtime/runtime'
interface CoreDisplayInfo {
coreId: string
coreName: string
corePath: string
isDefault: boolean
pathValid: boolean
pathMessage: string
chromeVersion: string
instanceCount: number
}
export function CoreManagementPage() {
const [cores, setCores] = useState<BrowserCore[]>([])
const [displayList, setDisplayList] = useState<CoreDisplayInfo[]>([])
const [loading, setLoading] = useState(true)
const [scanning, setScanning] = useState(false)
// 全局设置状态
const [settings, setSettings] = useState<BrowserSettings>({
userDataRoot: '',
defaultFingerprintArgs: [],
defaultLaunchArgs: [],
defaultProxy: '',
})
const [settingsModalOpen, setSettingsModalOpen] = useState(false)
const [settingsForm, setSettingsForm] = useState({
userDataRoot: '',
defaultProxy: '',
defaultFingerprintArgs: '',
defaultLaunchArgs: '',
})
const [savingSettings, setSavingSettings] = useState(false)
// 编辑弹窗状态
const [editModalOpen, setEditModalOpen] = useState(false)
const [editingCore, setEditingCore] = useState<BrowserCore | null>(null)
const [editForm, setEditForm] = useState({ coreName: '', corePath: '' })
const [saving, setSaving] = useState(false)
const [pathValidating, setPathValidating] = useState(false)
const [pathValidResult, setPathValidResult] = useState<BrowserCoreValidateResult | null>(null)
// 删除确认状态
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const [deletingCore, setDeletingCore] = useState<CoreDisplayInfo | null>(null)
// 内核下载
const [downloadModalOpen, setDownloadModalOpen] = useState(false)
const [downloadForm, setDownloadForm] = useState({ name: '', url: '', proxyMode: 'system', proxyId: '' })
const [downloadProgress, setDownloadProgress] = useState<{ phase: string; progress: number; message: string } | null>(null)
const [proxies, setProxies] = useState<BrowserProxy[]>([])
useEffect(() => {
loadData()
// 监听下载进度
const onDownloadProgress = (data: { phase: string; progress: number; message: string }) => {
setDownloadProgress(data)
if (data.phase === 'done') {
toast.success(data.message)
setTimeout(() => {
setDownloadModalOpen(false)
setDownloadProgress(null)
loadData() // 更新内核列表
}, 1500)
} else if (data.phase === 'error') {
toast.error(data.message)
setDownloadProgress(null) // 清理进度使其可以重新开始
}
}
EventsOn('download:progress', onDownloadProgress)
return () => {
EventsOff('download:progress')
}
}, [])
const loadData = async () => {
setLoading(true)
try {
// 并行加载设置、内核列表和扩展信息
const [settingsData, coreList, extendedInfo] = await Promise.all([
fetchBrowserSettings(),
fetchBrowserCores(),
fetchCoreExtendedInfo(),
])
setSettings(settingsData)
setCores(coreList)
// 创建扩展信息映射
const extendedMap = new Map<string, BrowserCoreExtended>()
extendedInfo.forEach(info => extendedMap.set(info.coreId, info))
// 验证所有路径并合并扩展信息
const displayInfoList: CoreDisplayInfo[] = await Promise.all(
coreList.map(async (core) => {
const result = await validateBrowserCorePath(core.corePath)
const extended = extendedMap.get(core.coreId)
return {
coreId: core.coreId,
coreName: core.coreName,
corePath: core.corePath,
isDefault: core.isDefault,
pathValid: result.valid,
pathMessage: result.message,
chromeVersion: extended?.chromeVersion || '',
instanceCount: extended?.instanceCount || 0,
}
})
)
setDisplayList(displayInfoList)
} finally {
setLoading(false)
}
}
// 防抖验证路径
const validatePath = useCallback(async (path: string) => {
if (!path.trim()) {
setPathValidResult(null)
return
}
setPathValidating(true)
try {
const result = await validateBrowserCorePath(path)
setPathValidResult(result)
} finally {
setPathValidating(false)
}
}, [])
// 路径输入变化时触发验证(防抖)
useEffect(() => {
fetchBrowserProxies().then(setProxies)
const timer = setTimeout(() => {
if (editModalOpen && editForm.corePath) {
validatePath(editForm.corePath)
}
}, 500)
return () => clearTimeout(timer)
}, [editForm.corePath, editModalOpen, validatePath])
// 表格列定义
const columns: TableColumn<CoreDisplayInfo>[] = [
{ key: 'coreName', title: '内核名称', width: '150px' },
{ key: 'corePath', title: '内核路径', width: '180px' },
{
key: 'chromeVersion',
title: 'Chrome 版本',
width: '130px',
render: (val) => val || '-',
},
{
key: 'instanceCount',
title: '使用实例',
width: '90px',
render: (val) => <Badge variant="default">{val}</Badge>,
},
{
key: 'isDefault',
title: '默认',
width: '70px',
render: (val) => val ? <Badge variant="info"></Badge> : null,
},
{
key: 'pathValid',
title: '状态',
width: '80px',
render: (val) => (
<Badge variant={val ? 'success' : 'error'}>
{val ? '有效' : '无效'}
</Badge>
),
},
{
key: 'actions',
title: '操作',
width: '220px',
render: (_, record) => (
<div className="flex gap-2">
<Button size="sm" variant="ghost" onClick={(e) => { e.stopPropagation(); handleOpenPath(record.corePath) }} title="打开目录">
<FolderOpen className="w-4 h-4" />
</Button>
<Button size="sm" variant="ghost" onClick={(e) => { e.stopPropagation(); handleEdit(record) }}>
</Button>
{!record.isDefault && (
<Button size="sm" variant="ghost" onClick={(e) => { e.stopPropagation(); handleSetDefault(record.coreId) }}>
</Button>
)}
<Button size="sm" variant="danger" onClick={(e) => { e.stopPropagation(); handleDeleteClick(record) }}>
</Button>
</div>
),
},
]
// 打开内核路径
const handleOpenPath = async (corePath: string) => {
try {
await openCorePath(corePath)
} catch (error: any) {
toast.error(error?.message || '打开目录失败')
}
}
// 扫描 chrome 目录,自动注册新内核
const handleScan = async () => {
setScanning(true)
try {
await scanBrowserCores()
await loadData()
toast.success('扫描完成')
} catch (error: any) {
toast.error(error?.message || '扫描失败')
} finally {
setScanning(false)
}
}
// 新增内核
const handleAdd = () => {
setEditingCore(null)
setEditForm({ coreName: '', corePath: '' })
setPathValidResult(null)
setEditModalOpen(true)
}
// 编辑内核
const handleEdit = (record: CoreDisplayInfo) => {
const core = cores.find(c => c.coreId === record.coreId)
if (core) {
setEditingCore(core)
setEditForm({ coreName: core.coreName, corePath: core.corePath })
setPathValidResult({ valid: record.pathValid, message: record.pathMessage })
setEditModalOpen(true)
}
}
// 保存内核
const handleSaveCore = async () => {
if (!editForm.coreName.trim()) {
toast.error('请输入内核名称')
return
}
if (!editForm.corePath.trim()) {
toast.error('请输入内核路径')
return
}
setSaving(true)
try {
const input: BrowserCoreInput = {
coreId: editingCore?.coreId || `core-${Date.now()}`,
coreName: editForm.coreName.trim(),
corePath: editForm.corePath.trim(),
isDefault: editingCore?.isDefault || false,
}
await saveBrowserCore(input)
await loadData()
setEditModalOpen(false)
toast.success(editingCore ? '内核已更新' : '内核已添加')
} catch (error: any) {
toast.error(error?.message || '保存失败')
} finally {
setSaving(false)
}
}
// 删除点击
const handleDeleteClick = (record: CoreDisplayInfo) => {
if (record.isDefault) {
toast.warning('默认内核不能删除')
return
}
setDeletingCore(record)
setDeleteConfirmOpen(true)
}
// 确认删除
const handleDeleteConfirm = async () => {
if (!deletingCore) return
try {
await deleteBrowserCore(deletingCore.coreId)
await loadData()
toast.success('内核已删除')
} catch (error: any) {
toast.error(error?.message || '删除失败')
}
setDeletingCore(null)
}
// 设为默认
const handleSetDefault = async (coreId: string) => {
try {
await setDefaultBrowserCore(coreId)
await loadData()
toast.success('已设为默认内核')
} catch (error: any) {
toast.error(error?.message || '设置失败')
}
}
// 开始下载
const handleStartDownloadCore = async () => {
if (!downloadForm.name.trim() || !downloadForm.url.trim()) {
toast.error('请输入名称和下载地址')
return
}
if (cores.some(c => c.coreName.toLowerCase() === downloadForm.name.trim().toLowerCase())) {
toast.error('该内核名称已存在')
return
}
setDownloadProgress({ phase: 'starting', progress: 0, message: '准备下载...' })
try {
// 在这儿我们需要从 proxies 中寻找匹配到的代理设定,如果有则传过去的 url
let targetProxy = ''
if (downloadForm.proxyMode === 'system') {
targetProxy = '__system__'
} else if (downloadForm.proxyMode === 'direct') {
targetProxy = '__direct__'
} else {
const proxyProfile = proxies.find(p => p.proxyId === downloadForm.proxyId)
targetProxy = downloadForm.proxyId
if (proxyProfile && proxyProfile.proxyConfig) {
targetProxy = proxyProfile.proxyConfig
}
}
await BrowserCoreDownload(downloadForm.name.trim(), downloadForm.url.trim(), targetProxy)
} catch (err: any) {
toast.error(err.message || '内部启动下载失败')
setDownloadProgress(null)
}
}
// 打开设置编辑弹窗
const handleEditSettings = () => {
setSettingsForm({
userDataRoot: settings.userDataRoot,
defaultProxy: settings.defaultProxy,
defaultFingerprintArgs: settings.defaultFingerprintArgs.join('\n'),
defaultLaunchArgs: settings.defaultLaunchArgs.join('\n'),
})
setSettingsModalOpen(true)
}
// 保存设置
const handleSaveSettings = async () => {
setSavingSettings(true)
try {
const newSettings: BrowserSettings = {
userDataRoot: settingsForm.userDataRoot.trim(),
defaultProxy: settingsForm.defaultProxy.trim(),
defaultFingerprintArgs: settingsForm.defaultFingerprintArgs.split('\n').map(s => s.trim()).filter(Boolean),
defaultLaunchArgs: settingsForm.defaultLaunchArgs.split('\n').map(s => s.trim()).filter(Boolean),
}
await saveBrowserSettings(newSettings)
setSettings(newSettings)
setSettingsModalOpen(false)
toast.success('设置已保存')
} catch (error: any) {
toast.error(error?.message || '保存失败')
} finally {
setSavingSettings(false)
}
}
return (
<div className="space-y-5 animate-fade-in">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-[var(--color-text-primary)]"></h1>
<p className="text-sm text-[var(--color-text-muted)] mt-1"> Chrome </p>
</div>
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={() => setDownloadModalOpen(true)}></Button>
<Button size="sm" variant="secondary" onClick={handleScan} loading={scanning}></Button>
<Button size="sm" onClick={handleAdd}></Button>
</div>
</div>
{/* 全局设置卡片 */}
<Card>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Settings className="w-5 h-5 text-[var(--color-text-muted)]" />
<h3 className="text-base font-medium text-[var(--color-text-primary)]"></h3>
</div>
<Button size="sm" variant="ghost" onClick={handleEditSettings}>
<Edit2 className="w-4 h-4 mr-1" />
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p className="text-xs text-[var(--color-text-muted)] mb-1"></p>
<p className="text-sm text-[var(--color-text-primary)]">{settings.userDataRoot || '-'}</p>
</div>
<div>
<p className="text-xs text-[var(--color-text-muted)] mb-1"></p>
<p className="text-sm text-[var(--color-text-primary)]">{settings.defaultProxy || '-'}</p>
</div>
<div>
<p className="text-xs text-[var(--color-text-muted)] mb-1"></p>
{settings.defaultFingerprintArgs.length > 0 ? (
<pre className="text-xs text-[var(--color-text-secondary)] bg-[var(--color-bg-subtle)] p-2 rounded max-h-20 overflow-auto">
{settings.defaultFingerprintArgs.join('\n')}
</pre>
) : (
<p className="text-sm text-[var(--color-text-primary)]">-</p>
)}
</div>
<div>
<p className="text-xs text-[var(--color-text-muted)] mb-1"></p>
{settings.defaultLaunchArgs.length > 0 ? (
<pre className="text-xs text-[var(--color-text-secondary)] bg-[var(--color-bg-subtle)] p-2 rounded max-h-20 overflow-auto">
{settings.defaultLaunchArgs.join('\n')}
</pre>
) : (
<p className="text-sm text-[var(--color-text-primary)]">-</p>
)}
</div>
</div>
</Card>
{/* 内核列表卡片 */}
<Card title="内核列表" subtitle="已配置的 Chrome 内核">
<Table
columns={columns}
data={displayList}
rowKey="coreId"
loading={loading}
emptyText="暂无内核,请添加内核"
/>
</Card>
{/* 全局设置编辑弹窗 */}
<Modal
open={settingsModalOpen}
onClose={() => setSettingsModalOpen(false)}
title="编辑全局设置"
width="550px"
footer={
<>
<Button variant="secondary" onClick={() => setSettingsModalOpen(false)}></Button>
<Button onClick={handleSaveSettings} loading={savingSettings}></Button>
</>
}
>
<div className="space-y-4">
<FormItem label="用户数据根目录">
<Input
value={settingsForm.userDataRoot}
onChange={e => setSettingsForm(prev => ({ ...prev, userDataRoot: e.target.value }))}
placeholder="例如:data"
/>
</FormItem>
<FormItem label="默认代理配置">
<Input
value={settingsForm.defaultProxy}
onChange={e => setSettingsForm(prev => ({ ...prev, defaultProxy: e.target.value }))}
placeholder="例如:http://127.0.0.1:7890"
/>
</FormItem>
<FormItem label="默认指纹参数">
<Textarea
value={settingsForm.defaultFingerprintArgs}
onChange={e => setSettingsForm(prev => ({ ...prev, defaultFingerprintArgs: e.target.value }))}
rows={4}
placeholder="每行一个参数,如 --fingerprint-brand=Chrome"
/>
</FormItem>
<FormItem label="默认启动参数">
<Textarea
value={settingsForm.defaultLaunchArgs}
onChange={e => setSettingsForm(prev => ({ ...prev, defaultLaunchArgs: e.target.value }))}
rows={4}
placeholder="每行一个参数,如 --disable-sync"
/>
</FormItem>
</div>
</Modal>
{/* 新增/编辑内核弹窗 */}
<Modal
open={editModalOpen}
onClose={() => setEditModalOpen(false)}
title={editingCore ? '编辑内核' : '新增内核'}
width="500px"
footer={
<>
<Button variant="secondary" onClick={() => setEditModalOpen(false)}></Button>
<Button onClick={handleSaveCore} loading={saving}></Button>
</>
}
>
<div className="space-y-4">
<FormItem label="内核名称" required>
<Input
value={editForm.coreName}
onChange={e => setEditForm(prev => ({ ...prev, coreName: e.target.value }))}
placeholder="例如:Chrome 142"
/>
</FormItem>
<FormItem label="内核路径" required>
<Input
value={editForm.corePath}
onChange={e => setEditForm(prev => ({ ...prev, corePath: e.target.value }))}
placeholder="相对路径(如 chrome)或绝对路径"
/>
{pathValidating && (
<p className="text-xs text-[var(--color-text-muted)] mt-1">...</p>
)}
{!pathValidating && pathValidResult && (
<p className={`text-xs mt-1 ${pathValidResult.valid ? 'text-green-600' : 'text-red-500'}`}>
{pathValidResult.message}
</p>
)}
</FormItem>
</div>
</Modal>
{/* 删除确认弹窗 */}
<ConfirmModal
open={deleteConfirmOpen}
onClose={() => setDeleteConfirmOpen(false)}
onConfirm={handleDeleteConfirm}
title="确认删除"
content={`确定要删除内核"${deletingCore?.coreName}"吗?此操作不可恢复。`}
confirmText="删除"
danger
/>
{/* 内核下载弹窗 */}
<Modal open={downloadModalOpen} onClose={() => {
if (downloadProgress && downloadProgress.phase !== 'done' && downloadProgress.phase !== 'error') {
toast.warning('正在下载中,请稍候...')
return
}
setDownloadModalOpen(false)
setDownloadProgress(null)
}} title="下载内核" width="480px"
footer={
<>
<Button variant="secondary" onClick={() => {
if (downloadProgress && downloadProgress.phase !== 'done' && downloadProgress.phase !== 'error') return;
setDownloadModalOpen(false)
}} disabled={downloadProgress !== null && downloadProgress.phase !== 'error'}></Button>
<Button onClick={handleStartDownloadCore} loading={downloadProgress !== null && downloadProgress.phase !== 'error'}></Button>
</>
}>
<div className="space-y-4">
<FormItem label="内核名称" required>
<Input
value={downloadForm.name}
onChange={e => setDownloadForm(prev => ({ ...prev, name: e.target.value }))}
placeholder="例如: chrome-139"
disabled={downloadProgress !== null}
/>
<p className="text-xs text-[var(--color-text-muted)] mt-1"></p>
</FormItem>
<FormItem label="下载地址 (ZIP)" required>
<Input
value={downloadForm.url}
onChange={e => setDownloadForm(prev => ({ ...prev, url: e.target.value }))}
placeholder="https://github.com/.../release.zip"
disabled={downloadProgress !== null}
/>
<div className="text-xs text-[var(--color-text-muted)] mt-2 flex items-center justify-between bg-[var(--color-bg-muted)] p-2 rounded">
<span>推荐指纹内核: fingerprint-chromium</span>
<button
type="button"
onClick={() => BrowserOpenURL('https://github.com/adryfish/fingerprint-chromium/releases')}
className="text-[var(--color-accent)] hover:underline cursor-pointer font-medium"
>
Releases
</button>
</div>
</FormItem>
<FormItem label="下载代理设置">
<select
value={downloadForm.proxyMode}
onChange={e => {
const mode = e.target.value
setDownloadForm(prev => ({
...prev,
proxyMode: mode,
proxyId: mode === 'custom' && proxies.length > 0 ? proxies[0].proxyId : ''
}))
}}
className="w-full h-9 px-3 rounded-md border border-[var(--color-border-default)] bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--color-accent)] focus:border-[var(--color-accent)]"
disabled={downloadProgress !== null}
>
<option value="system"></option>
<option value="direct"> (使)</option>
{proxies.length > 0 && <option value="custom">...</option>}
</select>
</FormItem>
{downloadForm.proxyMode === 'custom' && (
<FormItem label="选择代理池节点" required>
<select
value={downloadForm.proxyId}
onChange={e => setDownloadForm(prev => ({ ...prev, proxyId: e.target.value }))}
className="w-full h-9 px-3 rounded-md border border-[var(--color-border-default)] bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--color-accent)] focus:border-[var(--color-accent)]"
disabled={downloadProgress !== null}
>
{proxies.map(p => (
<option key={p.proxyId} value={p.proxyId}>
{p.proxyName} ({p.proxyConfig})
</option>
))}
</select>
</FormItem>
)}
{downloadProgress && (
<div className="mt-4 p-4 border border-[var(--color-border-default)] rounded-lg bg-[var(--color-bg-secondary)]">
<div className="flex justify-between text-sm mb-2">
<span className="font-medium text-[var(--color-text-primary)]">{downloadProgress.message}</span>
<span className="text-[var(--color-text-muted)]">{downloadProgress.progress}%</span>
</div>
<div className="w-full bg-[var(--color-bg-surface)] rounded-full h-2 overflow-hidden border border-[var(--color-border-muted)]">
<div
className="bg-[var(--color-accent)] h-2 rounded-full transition-all duration-300"
style={{ width: `${Math.max(0, Math.min(100, downloadProgress.progress))}%` }}
></div>
</div>
</div>
)}
</div>
</Modal>
</div>
)
}
@@ -0,0 +1,712 @@
import { useEffect, useState } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { CheckCircle, ChevronRight, Copy, FileText } from 'lucide-react'
import { toast } from '../../../shared/components'
import { BrowserOpenURL } from '../../../wailsjs/runtime/runtime'
import { fetchLaunchServerInfo } from '../api'
// ============================================================================
// 文档内容(自动化优先重构版)
// ============================================================================
const DOC_OVERVIEW = `# 自动化接口文档(重构版)
## 文档目标
本页仅聚焦 **自动化集成** 场景,回答三个核心问题:
1. 如何通过 Code 唤起浏览器实例
2. 如何带参数启动并对接 CDP
3. 如何查询调用记录做排障
## 适用场景
- 自动化脚本(Python / Node.js / PowerShell
- 本地调度器或 RPA 任务编排
- 多实例批量启动与状态观测
## 运行前提
- 应用已启动
- Launch 服务监听本机(地址见本页顶部)
- 实例已分配可用 Code(支持自定义)
## 自动化链路
\`\`\`
脚本 -> HTTP 接口 -> 实例启动 -> 返回 debugPort -> CDP 接管
\`\`\`
`
const DOC_QUICKSTART = `# 快速接入(3 分钟)
## 第一步:拿到实例 Code
在 **实例列表** 的“快捷码”列获取 Code。
- 可直接使用已有 Code
- 也可点击编辑设置自定义 Code
## 第二步:健康检查
\`\`\`bash
curl http://127.0.0.1:19876/api/health
# {"ok":true}
\`\`\`
## 第三步:按 Code 启动
\`\`\`bash
curl http://127.0.0.1:19876/api/launch/A3F9K2
\`\`\`
成功后会返回 \`debugPort\`,即可连接 CDP。
## 第四步:带参数启动(推荐自动化)
\`\`\`bash
curl -X POST http://127.0.0.1:19876/api/launch \\
-H "Content-Type: application/json" \\
-d '{
"code":"A3F9K2",
"launchArgs":["--window-size=1280,800"],
"startUrls":["https://example.com"],
"skipDefaultStartUrls":true
}'
\`\`\`
`
const DOC_API_INDEX = `# 接口总览
| 能力 | 方法 | 路径 | 用途 |
|------|------|------|------|
| 健康检查 | GET | \`/api/health\` | 检查服务可用性 |
| 按 Code 启动 | GET | \`/api/launch/{code}\` | 快速启动实例 |
| 参数化启动 | POST | \`/api/launch\` | 自动化脚本标准入口 |
| 调用记录 | GET | \`/api/launch/logs?limit=50\` | 最近调用排障 |
`
const DOC_API_HEALTH = `# 接口:健康检查
\`\`\`
GET /api/health
\`\`\`
## 请求示例
\`\`\`bash
curl http://127.0.0.1:19876/api/health
\`\`\`
## 成功响应
\`\`\`json
{
"ok": true
}
\`\`\`
`
const DOC_API_LAUNCH_GET = `# 接口:按 Code 启动
\`\`\`
GET /api/launch/{code}
\`\`\`
## 说明
- 用于最简启动流程
- 实例已运行时返回当前运行信息(幂等)
## 请求示例
\`\`\`bash
curl http://127.0.0.1:19876/api/launch/A3F9K2
\`\`\`
## 成功响应
\`\`\`json
{
"ok": true,
"profileId": "550e8400-e29b-41d4-a716-446655440000",
"profileName": "账号 A",
"pid": 12345,
"debugPort": 9222
}
\`\`\`
`
const DOC_API_LAUNCH_POST = `# 接口:参数化启动(自动化主入口)
\`\`\`
POST /api/launch
\`\`\`
## 请求体
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| \`code\` | string | 是 | 实例 Code |
| \`launchArgs\` | string[] | 否 | 本次附加启动参数 |
| \`startUrls\` | string[] | 否 | 本次启动打开 URL 列表 |
| \`skipDefaultStartUrls\` | boolean | 否 | 跳过系统默认起始页 |
## 请求示例
\`\`\`bash
curl -X POST http://127.0.0.1:19876/api/launch \\
-H "Content-Type: application/json" \\
-d '{
"code":"A3F9K2",
"launchArgs":["--lang=en-US","--window-size=1366,768"],
"startUrls":["https://example.com"],
"skipDefaultStartUrls":true
}'
\`\`\`
## 成功响应
\`\`\`json
{
"ok": true,
"profileId": "550e8400-e29b-41d4-a716-446655440000",
"profileName": "账号 A",
"pid": 12345,
"debugPort": 9222
}
\`\`\`
`
const DOC_API_LOGS = `# 接口:调用记录
\`\`\`
GET /api/launch/logs?limit=50
\`\`\`
## 说明
- 默认返回最近 50 条
- 最大支持 200 条
- 返回顺序:按时间倒序(最新在前)
## 请求示例
\`\`\`bash
curl http://127.0.0.1:19876/api/launch/logs?limit=20
\`\`\`
## 成功响应
\`\`\`json
{
"ok": true,
"items": [
{
"timestamp": "2026-03-01T12:00:00+08:00",
"method": "POST",
"path": "/api/launch",
"clientIp": "127.0.0.1",
"code": "A3F9K2",
"profileId": "550e8400-e29b-41d4-a716-446655440000",
"profileName": "账号 A",
"params": {
"launchArgs": ["--window-size=1280,800"],
"startUrls": ["https://example.com"],
"skipDefaultStartUrls": true
},
"ok": true,
"status": 200,
"error": "",
"durationMs": 156
}
]
}
\`\`\`
`
const DOC_ERRORS = `# 错误码与重试策略
| 状态码 | 场景 | 建议处理 |
|--------|------|----------|
| 400 | 请求体非法 / 缺少字段 | 修复参数后重试 |
| 403 | 非 localhost 访问 | 改为本机请求 |
| 404 | Code 不存在 | 检查 Code 是否正确 |
| 405 | 方法错误 | 使用正确 HTTP 方法 |
| 500 | 启动失败 | 查 \`/api/launch/logs\` + 应用日志 |
## 自动化建议
- 设置请求超时(3-10 秒)
- 对 \`500\` 可短暂重试(指数退避)
- 对 \`400/404\` 不建议盲目重试
`
const DOC_EXAMPLES = `# 自动化示例
## Python:启动并连接 CDP
\`\`\`python
import requests
from playwright.sync_api import sync_playwright
BASE = "http://127.0.0.1:19876"
def launch_by_code(code: str) -> dict:
r = requests.post(
f"{BASE}/api/launch",
json={
"code": code,
"launchArgs": ["--window-size=1280,800"],
"skipDefaultStartUrls": True,
},
timeout=10,
)
r.raise_for_status()
data = r.json()
if not data.get("ok"):
raise RuntimeError(data.get("error", "launch failed"))
return data
with sync_playwright() as p:
data = launch_by_code("A3F9K2")
browser = p.chromium.connect_over_cdp(f"http://127.0.0.1:{data['debugPort']}")
page = browser.contexts[0].new_page()
page.goto("https://example.com")
\`\`\`
## Node.js:最小调用
\`\`\`javascript
const BASE = 'http://127.0.0.1:19876'
async function launch(code) {
const res = await fetch(\`\${BASE}/api/launch\`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, skipDefaultStartUrls: true })
})
const data = await res.json()
if (!res.ok || !data.ok) throw new Error(data.error || \`HTTP \${res.status}\`)
return data
}
\`\`\`
`
const DOC_PRACTICES = `# 最佳实践
## 1) Code 管理
- 为每个实例分配稳定 Code
- 高风险脚本使用专用 Code,不与人工操作混用
- 变更 Code 后同步更新你的任务配置
## 2) 启动参数策略
- 把通用参数放在实例默认配置
- 仅把“任务相关参数”放在 \`POST /api/launch\`\`launchArgs\`
## 3) 排障流程
1. 先调 \`/api/health\`
2. 再调启动接口
3. 失败时查 \`/api/launch/logs\`
4. 最后结合应用日志定位
`
const DOC_TROUBLESHOOT = `# 常见问题
## Q1:返回 \`launch code not found\`
- Code 拼写错误或未分配
- 检查实例列表中的 Code 是否一致
## Q2:返回 \`forbidden: only localhost is allowed\`
- 当前服务只允许本机访问
- 请在同一台机器发起请求
## Q3:返回 \`500\` 启动失败
- 先查 \`/api/launch/logs\`\`error\`
- 再检查内核路径、代理配置、启动参数是否合法
`
// ============================================================================
// 文档树结构
// ============================================================================
interface DocNode {
id: string
label: string
children?: DocNode[]
content?: string
}
const DOC_TREE: DocNode[] = [
{
id: 'overview',
label: '文档说明',
content: DOC_OVERVIEW,
},
{
id: 'quickstart',
label: '快速接入',
content: DOC_QUICKSTART,
},
{
id: 'api-index',
label: '接口总览',
content: DOC_API_INDEX,
},
{
id: 'api',
label: '核心接口',
children: [
{ id: 'api-health', label: '健康检查', content: DOC_API_HEALTH },
{ id: 'api-launch-get', label: '按 Code 启动', content: DOC_API_LAUNCH_GET },
{ id: 'api-launch-post', label: '参数化启动', content: DOC_API_LAUNCH_POST },
{ id: 'api-logs', label: '调用记录', content: DOC_API_LOGS },
],
},
{
id: 'errors',
label: '错误与重试',
content: DOC_ERRORS,
},
{
id: 'examples',
label: '代码示例',
content: DOC_EXAMPLES,
},
{
id: 'practices',
label: '最佳实践',
content: DOC_PRACTICES,
},
{
id: 'troubleshoot',
label: '常见问题',
content: DOC_TROUBLESHOOT,
},
]
const DEFAULT_LAUNCH_BASE_URL = 'http://127.0.0.1:19876'
function renderDocWithLaunchBase(raw: string, baseUrl: string): string {
if (!raw) return raw
const safeBase = baseUrl.trim() || DEFAULT_LAUNCH_BASE_URL
const hostPort = safeBase.replace(/^https?:\/\//, '')
return raw
.split('http://127.0.0.1:19876').join(safeBase)
.split('127.0.0.1:19876').join(hostPort)
}
// ============================================================================
// 组件
// ============================================================================
function DocTreeItem({
node,
depth,
activeId,
onSelect,
expandedIds,
onToggle,
}: {
node: DocNode
depth: number
activeId: string
onSelect: (id: string, content: string) => void
expandedIds: Set<string>
onToggle: (id: string) => void
}) {
const hasChildren = !!node.children?.length
const isExpanded = expandedIds.has(node.id)
const isActive = activeId === node.id
const handleClick = () => {
if (hasChildren) {
onToggle(node.id)
} else if (node.content) {
onSelect(node.id, node.content)
}
}
return (
<div>
<button
onClick={handleClick}
className={[
'w-full flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm transition-colors text-left',
isActive && !hasChildren
? 'bg-[var(--color-accent)] text-[var(--color-text-inverse)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-accent-muted)] hover:text-[var(--color-text-primary)]',
].join(' ')}
style={{ paddingLeft: `${12 + depth * 14}px` }}
>
{hasChildren ? (
<ChevronRight
className={`w-3.5 h-3.5 shrink-0 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
/>
) : (
<FileText className="w-3.5 h-3.5 shrink-0 opacity-60" />
)}
<span className="truncate">{node.label}</span>
</button>
{hasChildren && isExpanded && (
<div>
{node.children!.map(child => (
<DocTreeItem
key={child.id}
node={child}
depth={depth + 1}
activeId={activeId}
onSelect={onSelect}
expandedIds={expandedIds}
onToggle={onToggle}
/>
))}
</div>
)}
</div>
)
}
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false)
return (
<button
onClick={() => {
navigator.clipboard.writeText(text).then(() => {
setCopied(true)
toast.success('已复制')
setTimeout(() => setCopied(false), 2000)
})
}}
className="flex items-center gap-1 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-accent)] transition-colors"
>
{copied ? <CheckCircle className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
{copied ? '已复制' : '复制'}
</button>
)
}
function MarkdownContent({ content }: { content: string }) {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children }) => (
<h1 className="text-2xl font-bold text-[var(--color-text-primary)] mb-6 pb-3 border-b border-[var(--color-border-default)]">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-lg font-semibold text-[var(--color-text-primary)] mt-8 mb-3 flex items-center gap-2">
<span className="w-1 h-5 bg-[var(--color-accent)] rounded-full inline-block shrink-0" />
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-base font-semibold text-[var(--color-text-primary)] mt-6 mb-2">
{children}
</h3>
),
p: ({ children }) => (
<p className="text-sm text-[var(--color-text-secondary)] leading-relaxed mb-3">
{children}
</p>
),
ul: ({ children }) => (
<ul className="space-y-1 mb-4 pl-5 list-disc marker:text-[var(--color-accent)]">{children}</ul>
),
ol: ({ children }) => (
<ol className="space-y-1 mb-4 pl-5 list-decimal marker:text-[var(--color-accent)]">{children}</ol>
),
li: ({ children }) => (
<li className="text-sm text-[var(--color-text-secondary)] leading-relaxed">
{children}
</li>
),
code: ({ children, className }) => {
const isBlock = className?.includes('language-')
if (isBlock) return null
return (
<code className="text-xs font-mono bg-[var(--color-bg-secondary)] text-[var(--color-accent)] px-1.5 py-0.5 rounded border border-[var(--color-border-muted)]">
{children}
</code>
)
},
pre: ({ children }) => {
const codeEl = (children as any)?.props
const lang = codeEl?.className?.replace('language-', '') || ''
const codeText = codeEl?.children || ''
return (
<div className="my-4 rounded-lg overflow-hidden border border-[var(--color-border-default)]">
<div className="flex items-center justify-between px-4 py-2 bg-[var(--color-bg-surface)] border-b border-[var(--color-border-muted)]">
<span className="text-xs font-mono text-[var(--color-text-muted)]">{lang || 'code'}</span>
<CopyButton text={String(codeText).replace(/\n$/, '')} />
</div>
<pre className="p-4 bg-[var(--color-bg-secondary)] overflow-x-auto text-sm font-mono text-[var(--color-text-primary)] leading-relaxed">
{children}
</pre>
</div>
)
},
table: ({ children }) => (
<div className="my-4 overflow-x-auto rounded-lg border border-[var(--color-border-default)]">
<table className="w-full text-sm">{children}</table>
</div>
),
thead: ({ children }) => (
<thead className="bg-[var(--color-bg-surface)] border-b border-[var(--color-border-default)]">
{children}
</thead>
),
th: ({ children }) => (
<th className="px-4 py-2.5 text-left text-xs font-semibold text-[var(--color-text-muted)] uppercase tracking-wide">
{children}
</th>
),
td: ({ children }) => (
<td className="px-4 py-2.5 text-[var(--color-text-secondary)] border-t border-[var(--color-border-muted)]">
{children}
</td>
),
blockquote: ({ children }) => (
<blockquote className="my-3 pl-4 border-l-2 border-[var(--color-accent)] text-[var(--color-text-muted)] italic">
{children}
</blockquote>
),
strong: ({ children }) => (
<strong className="font-semibold text-[var(--color-text-primary)]">{children}</strong>
),
hr: () => <hr className="my-6 border-[var(--color-border-default)]" />,
a: ({ href, children }) => (
<a
href={href}
onClick={(e) => {
e.preventDefault()
if (href) {
BrowserOpenURL(href)
}
}}
className="text-[var(--color-accent)] hover:underline cursor-pointer"
title={href}
>
{children}
</a>
),
}}
>
{content}
</ReactMarkdown>
)
}
// ============================================================================
// 主页面
// ============================================================================
function findFirstLeaf(nodes: DocNode[]): DocNode | null {
for (const n of nodes) {
if (!n.children) return n
const found = findFirstLeaf(n.children)
if (found) return found
}
return null
}
function collectParentIds(nodes: DocNode[], targetId: string, path: string[] = []): string[] {
for (const n of nodes) {
if (n.id === targetId) return path
if (n.children) {
const found = collectParentIds(n.children, targetId, [...path, n.id])
if (found.length) return found
}
}
return []
}
export function LaunchApiDocsPage() {
const firstLeaf = findFirstLeaf(DOC_TREE)!
const [activeId, setActiveId] = useState(firstLeaf.id)
const [activeContent, setActiveContent] = useState(firstLeaf.content || '')
const [launchBaseUrl, setLaunchBaseUrl] = useState(DEFAULT_LAUNCH_BASE_URL)
const [launchServerReady, setLaunchServerReady] = useState(false)
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => {
const parents = collectParentIds(DOC_TREE, firstLeaf.id)
return new Set(parents)
})
useEffect(() => {
let disposed = false
void fetchLaunchServerInfo()
.then((info) => {
if (disposed) return
if (info.baseUrl) {
setLaunchBaseUrl(info.baseUrl)
}
setLaunchServerReady(info.ready)
})
.catch(() => {})
return () => {
disposed = true
}
}, [])
const handleSelect = (id: string, content: string) => {
setActiveId(id)
setActiveContent(content)
}
const handleToggle = (id: string) => {
setExpandedIds(prev => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
return next
})
}
const renderedContent = renderDocWithLaunchBase(activeContent, launchBaseUrl)
return (
<div className="flex h-full -m-5 overflow-hidden">
<aside className="w-52 shrink-0 border-r border-[var(--color-border-default)] bg-[var(--color-bg-surface)] flex flex-col overflow-hidden">
<div className="px-4 py-3 border-b border-[var(--color-border-muted)]">
<p className="text-xs font-semibold text-[var(--color-text-muted)] uppercase tracking-widest"></p>
</div>
<nav className="flex-1 overflow-y-auto py-2 px-2 space-y-0.5">
{DOC_TREE.map(node => (
<DocTreeItem
key={node.id}
node={node}
depth={0}
activeId={activeId}
onSelect={handleSelect}
expandedIds={expandedIds}
onToggle={handleToggle}
/>
))}
</nav>
</aside>
<main className="flex-1 overflow-y-auto">
<div className="max-w-3xl mx-auto px-10 py-8">
<div className="mb-4 px-3 py-2 text-xs rounded-lg border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] text-[var(--color-text-secondary)]">
Launch <code>{launchBaseUrl}</code>
{!launchServerReady ? '(服务启动后会自动刷新)' : ''}
</div>
<MarkdownContent content={renderedContent} />
</div>
</main>
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,423 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Plus, Tag, Trash2, X } from 'lucide-react'
import { Badge, Button, Card, toast } from '../../../shared/components'
import type { BrowserProfile } from '../types'
import { batchRemoveProfileTags, batchSetProfileTags, fetchBrowserProfiles, renameBrowserTag } from '../api'
// ─── 左侧标签面板 ────────────────────────────────────────────────────────────
interface TagPanelProps {
tags: string[]
selected: string | null
profilesByTag: Record<string, number>
totalCount: number
onSelect: (tag: string | null) => void
onCreateTag: (tag: string) => void
onRenameTag: (oldName: string, newName: string) => void
}
function TagPanel({ tags, selected, profilesByTag, totalCount, onSelect, onCreateTag, onRenameTag }: TagPanelProps) {
const [creating, setCreating] = useState(false)
const [newTag, setNewTag] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const [editingTag, setEditingTag] = useState<string | null>(null)
const [editValue, setEditValue] = useState('')
const commit = () => {
const t = newTag.trim()
if (t && !tags.includes(t)) {
onCreateTag(t)
onSelect(t)
}
setNewTag('')
setCreating(false)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') commit()
if (e.key === 'Escape') { setNewTag(''); setCreating(false) }
}
const startEdit = (tag: string) => {
setEditingTag(tag)
setEditValue(tag)
}
const commitEdit = () => {
const newVal = editValue.trim()
if (newVal && editingTag && newVal !== editingTag) {
onRenameTag(editingTag, newVal)
}
setEditingTag(null)
}
return (
<div className="w-52 shrink-0 border-r border-[var(--color-border)] flex flex-col bg-[var(--color-bg-surface)]">
<div className="px-4 py-3 border-b border-[var(--color-border)] flex items-center justify-between">
<span className="text-xs font-semibold text-[var(--color-text-muted)] uppercase tracking-wider"></span>
<button
onClick={() => { setCreating(true); setTimeout(() => inputRef.current?.focus(), 50) }}
title="新建标签"
className="p-0.5 rounded text-[var(--color-text-muted)] hover:text-[var(--color-primary)] hover:bg-[var(--color-primary)]/10 transition-colors"
>
<Plus className="w-3.5 h-3.5" />
</button>
</div>
<div className="flex-1 overflow-y-auto py-2">
<button
onClick={() => onSelect(null)}
className={`w-full text-left px-4 py-2 text-sm flex items-center justify-between transition-colors ${selected === null
? 'bg-[var(--color-primary)]/10 text-[var(--color-primary)] font-medium'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)]'
}`}
>
<span></span>
<span className="text-xs opacity-60">{totalCount}</span>
</button>
{tags.map(tag => (
<div
key={tag}
onContextMenu={e => { e.preventDefault(); startEdit(tag) }}
onClick={() => onSelect(tag)}
className={`w-full text-left px-4 py-2 text-sm flex items-center justify-between gap-2 transition-colors cursor-pointer group ${selected === tag
? 'bg-[var(--color-primary)]/10 text-[var(--color-primary)] font-medium'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)]'
}`}
title="右键可以重命名"
>
{editingTag === tag ? (
<input
autoFocus
value={editValue}
onChange={e => setEditValue(e.target.value)}
onBlur={commitEdit}
onKeyDown={e => {
if (e.key === 'Enter') commitEdit()
if (e.key === 'Escape') setEditingTag(null)
}}
onClick={e => e.stopPropagation()}
className="flex-1 min-w-0 px-1.5 py-0.5 text-xs rounded border border-[var(--color-primary)] bg-[var(--color-bg-input)] text-[var(--color-text-primary)] focus:outline-none"
/>
) : (
<span className="flex items-center gap-1.5 truncate">
<Tag className="w-3.5 h-3.5 shrink-0 opacity-60" />
<span className="truncate">{tag}</span>
</span>
)}
{editingTag !== tag && (
<span className="text-xs opacity-60 shrink-0">{profilesByTag[tag] ?? 0}</span>
)}
</div>
))}
{tags.length === 0 && !creating && (
<p className="px-4 py-3 text-xs text-[var(--color-text-muted)]"> + </p>
)}
{/* 内联新建输入框 */}
{creating && (
<div className="px-3 py-2 flex items-center gap-1">
<input
ref={inputRef}
value={newTag}
onChange={e => setNewTag(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={commit}
placeholder="标签名称"
className="flex-1 min-w-0 px-2 py-1 text-xs rounded border border-[var(--color-primary)] bg-[var(--color-bg-input)] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] focus:outline-none"
/>
</div>
)}
</div>
</div>
)
}
// ─── 批量操作工具栏 ───────────────────────────────────────────────────────────
interface ActionBarProps {
selectedCount: number
allTags: string[]
onAddTags: (tags: string[]) => void
onRemoveTags: (tags: string[]) => void
onClear: () => void
}
function ActionBar({ selectedCount, allTags, onAddTags, onRemoveTags, onClear }: ActionBarProps) {
const [addInput, setAddInput] = useState('')
const [removeTag, setRemoveTag] = useState('')
if (selectedCount === 0) return null
const handleAdd = () => {
const tags = addInput.split(/[,\s]+/).map(t => t.trim()).filter(Boolean)
if (!tags.length) return
onAddTags(tags)
setAddInput('')
}
return (
<div className="flex items-center gap-3 px-4 py-2.5 bg-[var(--color-primary)]/5 border border-[var(--color-primary)]/20 rounded-lg text-sm">
<span className="text-[var(--color-primary)] font-medium shrink-0"> {selectedCount} </span>
<div className="flex items-center gap-1.5 flex-1 flex-wrap">
{/* 添加标签 */}
<div className="flex items-center gap-1">
<input
value={addInput}
onChange={e => setAddInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAdd()}
placeholder="输入标签,逗号分隔"
className="px-2 py-1 text-xs rounded border border-[var(--color-border)] bg-[var(--color-bg-input)] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] focus:outline-none focus:border-[var(--color-primary)] w-40"
/>
<Button size="sm" onClick={handleAdd} disabled={!addInput.trim()}>
<Plus className="w-3.5 h-3.5" />
</Button>
</div>
{/* 移除标签 */}
{allTags.length > 0 && (
<div className="flex items-center gap-1">
<select
value={removeTag}
onChange={e => setRemoveTag(e.target.value)}
className="px-2 py-1 text-xs rounded border border-[var(--color-border)] bg-[var(--color-bg-input)] text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-primary)]"
>
<option value=""></option>
{allTags.map(t => <option key={t} value={t}>{t}</option>)}
</select>
<Button size="sm" variant="secondary" onClick={() => { if (removeTag) { onRemoveTags([removeTag]); setRemoveTag('') } }} disabled={!removeTag}>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
)}
</div>
<button onClick={onClear} className="shrink-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)]">
<X className="w-4 h-4" />
</button>
</div>
)
}
// ─── 主页面 ───────────────────────────────────────────────────────────────────
export function TagManagementPage() {
const [profiles, setProfiles] = useState<BrowserProfile[]>([])
const [loading, setLoading] = useState(true)
const [selectedTag, setSelectedTag] = useState<string | null>(null)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [saving, setSaving] = useState(false)
// 用户新建但尚未分配给任何实例的标签(纯前端暂存)
const [pendingTags, setPendingTags] = useState<string[]>([])
// 合并:实例已有标签 + 用户新建的待分配标签
const allTagsWithPending = useMemo(() => {
const set = new Set<string>()
profiles.forEach(p => p.tags?.forEach(t => set.add(t)))
pendingTags.forEach(t => set.add(t))
return Array.from(set).sort()
}, [profiles, pendingTags])
const handleCreateTag = (tag: string) => {
if (!allTagsWithPending.includes(tag)) {
setPendingTags(prev => [...prev, tag])
}
}
const load = async () => {
setLoading(true)
try {
const data = await fetchBrowserProfiles()
setProfiles(data)
// 清理已被实例使用的 pendingTags
const usedTags = new Set<string>()
data.forEach(p => p.tags?.forEach(t => usedTags.add(t)))
setPendingTags(prev => prev.filter(t => !usedTags.has(t)))
} finally { setLoading(false) }
}
useEffect(() => { load() }, [])
// 重置勾选当切换标签时
useEffect(() => { setSelectedIds(new Set()) }, [selectedTag])
const allTags = allTagsWithPending
const profilesByTag = useMemo(() => {
const map: Record<string, number> = {}
profiles.forEach(p => p.tags?.forEach(t => { map[t] = (map[t] || 0) + 1 }))
return map
}, [profiles])
const displayProfiles = useMemo(() => {
if (selectedTag === null) return profiles
return profiles.filter(p => p.tags?.includes(selectedTag))
}, [profiles, selectedTag])
// 勾选逻辑
const isAllSelected = displayProfiles.length > 0 && displayProfiles.every(p => selectedIds.has(p.profileId))
const isIndeterminate = !isAllSelected && displayProfiles.some(p => selectedIds.has(p.profileId))
const toggleAll = () => {
if (isAllSelected) setSelectedIds(new Set())
else setSelectedIds(new Set(displayProfiles.map(p => p.profileId)))
}
const toggleOne = (id: string) => setSelectedIds(prev => {
const next = new Set(prev); next.has(id) ? next.delete(id) : next.add(id); return next
})
// 批量添加标签
const handleAddTags = async (tags: string[]) => {
const ids = Array.from(selectedIds)
setSaving(true)
try {
await batchSetProfileTags(ids, tags, false)
toast.success(`已为 ${ids.length} 个实例添加标签`)
await load()
} catch (e: any) {
toast.error(e?.message || '操作失败')
} finally { setSaving(false) }
}
// 批量移除标签
const handleRemoveTags = async (tags: string[]) => {
const ids = Array.from(selectedIds)
setSaving(true)
try {
await batchRemoveProfileTags(ids, tags)
toast.success(`已从 ${ids.length} 个实例移除标签`)
await load()
} catch (e: any) {
toast.error(e?.message || '操作失败')
} finally { setSaving(false) }
}
// 重命名标签
const handleRenameTag = async (oldName: string, newName: string) => {
if (oldName === newName || !newName.trim()) return
if (allTags.includes(newName.trim())) {
toast.error('标签名称已存在')
return
}
setSaving(true)
try {
await renameBrowserTag(oldName, newName.trim())
toast.success('标签重命名成功')
if (pendingTags.includes(oldName)) {
setPendingTags(prev => prev.map(t => t === oldName ? newName.trim() : t))
}
if (selectedTag === oldName) {
setSelectedTag(newName.trim())
}
await load()
} catch (e: any) {
toast.error(e?.message || '重命名失败')
} finally {
setSaving(false)
}
}
return (
<div className="flex h-full animate-fade-in">
{/* 左侧标签面板 */}
<TagPanel
tags={allTags}
selected={selectedTag}
profilesByTag={profilesByTag}
totalCount={profiles.length}
onSelect={setSelectedTag}
onCreateTag={handleCreateTag}
onRenameTag={handleRenameTag}
/>
{/* 右侧内容区 */}
<div className="flex-1 flex flex-col overflow-hidden p-5 gap-4">
{/* 页头 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-[var(--color-text-primary)]"></h1>
<p className="text-sm text-[var(--color-text-muted)] mt-0.5">
{selectedTag ? (
<> <span className="text-[var(--color-primary)]">{selectedTag}</span> {displayProfiles.length} </>
) : (
<> {profiles.length} </>
)}
</p>
</div>
</div>
{/* 批量操作栏 */}
<ActionBar
selectedCount={selectedIds.size}
allTags={allTags}
onAddTags={handleAddTags}
onRemoveTags={handleRemoveTags}
onClear={() => setSelectedIds(new Set())}
/>
{/* 实例表格 */}
<Card padding="none" className="flex-1 overflow-hidden">
<div className="overflow-auto h-full">
<table className="min-w-full">
<thead className="sticky top-0 z-10">
<tr>
<th className="px-4 py-3 bg-[var(--color-bg-muted)] w-10">
<input
type="checkbox"
className="w-4 h-4 rounded cursor-pointer accent-[var(--color-accent)]"
checked={isAllSelected}
ref={el => { if (el) el.indeterminate = isIndeterminate }}
onChange={toggleAll}
/>
</th>
<th className="px-4 py-3 text-xs font-semibold text-[var(--color-text-muted)] uppercase tracking-wider bg-[var(--color-bg-muted)] text-left"></th>
<th className="px-4 py-3 text-xs font-semibold text-[var(--color-text-muted)] uppercase tracking-wider bg-[var(--color-bg-muted)] text-left"></th>
<th className="px-4 py-3 text-xs font-semibold text-[var(--color-text-muted)] uppercase tracking-wider bg-[var(--color-bg-muted)] text-left"></th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--color-border-muted)] bg-[var(--color-bg-surface)]">
{loading ? (
<tr><td colSpan={4} className="px-4 py-16 text-center text-sm text-[var(--color-text-muted)]">...</td></tr>
) : displayProfiles.length === 0 ? (
<tr><td colSpan={4} className="px-4 py-16 text-center text-sm text-[var(--color-text-muted)]"></td></tr>
) : displayProfiles.map(p => (
<tr
key={p.profileId}
className={`transition-colors cursor-pointer ${selectedIds.has(p.profileId) ? 'bg-[var(--color-primary)]/5' : 'hover:bg-[var(--color-bg-muted)]/50'}`}
onClick={() => toggleOne(p.profileId)}
>
<td className="px-4 py-3" onClick={e => e.stopPropagation()}>
<input
type="checkbox"
className="w-4 h-4 rounded cursor-pointer accent-[var(--color-accent)]"
checked={selectedIds.has(p.profileId)}
onChange={() => toggleOne(p.profileId)}
/>
</td>
<td className="px-4 py-3 text-sm font-medium text-[var(--color-text-primary)]">{p.profileName}</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{p.tags?.length ? p.tags.map(t => (
<Badge key={t} variant={t === selectedTag ? 'info' : 'default'}>{t}</Badge>
)) : <span className="text-xs text-[var(--color-text-muted)]"></span>}
</div>
</td>
<td className="px-4 py-3">
<Badge variant={p.running ? 'success' : 'warning'} dot>{p.running ? '运行中' : '已停止'}</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
{saving && (
<div className="fixed inset-0 bg-black/20 z-50 flex items-center justify-center">
<div className="bg-[var(--color-bg-elevated)] rounded-lg px-6 py-4 text-sm text-[var(--color-text-primary)] shadow-xl">
...
</div>
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,155 @@
import { useEffect, useState } from 'react'
import { BookOpen, Download, Globe, Keyboard, Layers, Monitor, Rocket } from 'lucide-react'
import { Button, Card } from '../../../shared/components'
import { BrowserOpenURL } from '../../../wailsjs/runtime/runtime'
import type { ReactNode } from 'react'
import { fetchLaunchServerInfo } from '../api'
const DEFAULT_LAUNCH_BASE_URL = 'http://127.0.0.1:19876'
function StepCard({
icon,
title,
children,
}: {
icon: ReactNode
title: string
children: ReactNode
}) {
return (
<Card>
<div className="flex items-start gap-3">
<div className="w-9 h-9 rounded-lg bg-[var(--color-accent-muted)] text-[var(--color-accent)] flex items-center justify-center shrink-0">
{icon}
</div>
<div className="flex-1 min-w-0">
<h2 className="text-base font-semibold text-[var(--color-text-primary)]">{title}</h2>
<div className="mt-2 text-sm text-[var(--color-text-secondary)] leading-relaxed space-y-2">{children}</div>
</div>
</div>
</Card>
)
}
function LinkButton({ url, children }: { url: string; children: ReactNode }) {
return (
<Button
size="sm"
variant="secondary"
onClick={() => {
void BrowserOpenURL(url)
}}
>
{children}
</Button>
)
}
export function UsageTutorialPage() {
const [launchBaseUrl, setLaunchBaseUrl] = useState(DEFAULT_LAUNCH_BASE_URL)
const [launchServerReady, setLaunchServerReady] = useState(false)
useEffect(() => {
let disposed = false
void fetchLaunchServerInfo()
.then((info) => {
if (disposed) return
if (info.baseUrl) {
setLaunchBaseUrl(info.baseUrl)
}
setLaunchServerReady(info.ready)
})
.catch(() => {})
return () => {
disposed = true
}
}, [])
const launchCodeCurlSample = `# 按 Code 启动
curl ${launchBaseUrl}/api/launch/A3F9K2
# 带参数启动
curl -X POST ${launchBaseUrl}/api/launch \\
-H "Content-Type: application/json" \\
-d '{"code":"A3F9K2","launchArgs":["--window-size=1280,800"]}'`
return (
<div className="space-y-5 animate-fade-in">
<Card>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<div className="inline-flex items-center gap-2 px-2.5 py-1 rounded-full bg-[var(--color-accent-muted)] text-[var(--color-accent)] text-xs font-medium mb-3">
<BookOpen className="w-3.5 h-3.5" /> 使
</div>
<h1 className="text-xl font-semibold text-[var(--color-text-primary)]"> 0 </h1>
<p className="text-sm text-[var(--color-text-secondary)] mt-2">
</p>
</div>
</div>
</Card>
<StepCard icon={<Download className="w-4 h-4" />} title="1) 下载并准备浏览器内核">
<p>使 fingerprint-chromium</p>
<pre className="text-xs font-mono bg-[var(--color-bg-secondary)] border border-[var(--color-border-muted)] rounded-lg p-3 overflow-x-auto">
{`左侧菜单 -> 指纹浏览器 -> 内核管理 -> 下载内核`}
</pre>
<p>使 GitHub ZIP <code>chrome/</code> </p>
<p></p>
<pre className="text-xs font-mono bg-[var(--color-bg-secondary)] border border-[var(--color-border-muted)] rounded-lg p-3 overflow-x-auto">
{`chrome/
chrome142/
chrome.exe
...`}
</pre>
<div className="flex items-center gap-2 flex-wrap">
<LinkButton url="https://github.com/adryfish/fingerprint-chromium"></LinkButton>
<LinkButton url="https://github.com/adryfish/fingerprint-chromium/releases">Releases </LinkButton>
</div>
</StepCard>
<StepCard icon={<Layers className="w-4 h-4" />} title="2) 在“内核管理”中确认可用内核">
<p> <code> &gt; </code></p>
<p></p>
<p> <code>chrome.exe</code></p>
</StepCard>
<StepCard icon={<Globe className="w-4 h-4" />} title="3) 创建代理池(HTTP/SOCKS5/Vmess/Vless/Trojan">
<p> <code> &gt; </code></p>
<p> YAML </p>
<p></p>
</StepCard>
<StepCard icon={<Monitor className="w-4 h-4" />} title="4) 创建实例并启动">
<p> <code> &gt; </code></p>
<p></p>
<p></p>
</StepCard>
<StepCard icon={<Keyboard className="w-4 h-4" />} title="5) 使用快捷键快速启动">
<p> <code>Ctrl + K</code>Mac <code>Cmd + K</code></p>
<p> Code </p>
<p></p>
<pre className="text-xs font-mono bg-[var(--color-bg-secondary)] border border-[var(--color-border-muted)] rounded-lg p-3 overflow-x-auto">
{`Ctrl/Cmd + K 呼出/收起快速启动弹窗
Enter 优先按输入的 Code 启动
↑ / ↓ 在实例列表中切换选中项
Esc 关闭弹窗`}
</pre>
</StepCard>
<StepCard icon={<Rocket className="w-4 h-4" />} title="6) 自动化启动(可选)">
<p> Code Code </p>
<p>
Launch <code>{launchBaseUrl}</code>
{!launchServerReady ? '(服务启动后会自动刷新)' : ''}
</p>
<pre className="text-xs font-mono bg-[var(--color-bg-secondary)] border border-[var(--color-border-muted)] rounded-lg p-3 overflow-x-auto">
{launchCodeCurlSample}
</pre>
</StepCard>
</div>
)
}
@@ -0,0 +1,13 @@
export { TagManagementPage } from './TagManagementPage'
export { BrowserListPage } from './BrowserListPage'
export { BrowserDetailPage } from './BrowserDetailPage'
export { BrowserEditPage } from './BrowserEditPage'
export { BrowserCopyPage } from './BrowserCopyPage'
export { BrowserLogsPage } from './BrowserLogsPage'
export { ProxyPoolPage } from './ProxyPoolPage'
export { CoreManagementPage } from './CoreManagementPage'
export { BookmarkSettingsPage } from './BookmarkSettingsPage'
export { LaunchApiDocsPage } from './LaunchApiDocsPage'
export { AutomationPage } from './AutomationPage'
export { UsageTutorialPage } from './UsageTutorialPage'
+154
View File
@@ -0,0 +1,154 @@
export interface BrowserProfile {
profileId: string
profileName: string
userDataDir: string
coreId: string
fingerprintArgs: string[]
proxyId: string
proxyConfig: string
launchArgs: string[]
tags: string[]
keywords: string[]
groupId?: string
running: boolean
debugPort: number
pid: number
lastError: string
createdAt: string
updatedAt: string
lastStartAt?: string
lastStopAt?: string
launchCode?: string
}
export interface BrowserProfileInput {
profileName: string
userDataDir: string
coreId: string
fingerprintArgs: string[]
proxyId: string
proxyConfig: string
launchArgs: string[]
tags: string[]
keywords: string[]
groupId?: string
}
export interface BrowserTab {
tabId: string
title: string
url: string
active: boolean
}
export interface BrowserSettings {
userDataRoot: string
defaultFingerprintArgs: string[]
defaultLaunchArgs: string[]
defaultProxy: string
}
export interface BrowserCore {
coreId: string
coreName: string
corePath: string
isDefault: boolean
}
export interface BrowserCoreInput {
coreId: string
coreName: string
corePath: string
isDefault: boolean
}
export interface BrowserCoreValidateResult {
valid: boolean
message: string
}
export interface BrowserProxy {
proxyId: string
proxyName: string
proxyConfig: string
dnsServers?: string
groupName?: string
sourceId?: string
sourceUrl?: string
sourceNamePrefix?: string
sourceAutoRefresh?: boolean
sourceRefreshIntervalM?: number
sourceLastRefreshAt?: string
lastLatencyMs?: number
lastTestOk?: boolean
lastTestedAt?: string
lastIPHealthJson?: string
}
export interface ProxyIPHealthResult {
proxyId: string
ok: boolean
source: string
error: string
ip: string
fraudScore: number
isResidential: boolean
isBroadcast: boolean
country: string
region: string
city: string
asOrganization: string
rawData: Record<string, any>
updatedAt: string
}
export interface BrowserCoreExtended {
coreId: string
chromeVersion: string
instanceCount: number
}
export interface CookieInfo {
name: string
value: string
domain: string
path: string
expires: number
httpOnly: boolean
secure: boolean
sameSite: string
}
export interface SnapshotInfo {
snapshotId: string
profileId: string
name: string
sizeMB: number
createdAt: string
}
export interface BrowserBookmark {
name: string
url: string
}
// 分组相关类型
export interface BrowserGroup {
groupId: string
groupName: string
parentId: string
sortOrder: number
createdAt: string
updatedAt: string
}
export interface BrowserGroupInput {
groupName: string
parentId: string
sortOrder: number
}
export interface BrowserGroupWithCount extends BrowserGroup {
instanceCount: number
}
@@ -0,0 +1,15 @@
export function resolveActionErrorMessage(error: unknown, fallback: string): string {
const message =
typeof error === 'string'
? error
: error && typeof error === 'object' && 'message' in error
? String((error as { message?: unknown }).message || '')
: ''
const normalized = message.trim()
if (normalized) {
return normalized
}
return `${fallback},但系统没有返回明确原因。请在实例详情中查看最近错误,或检查应用日志。`
}
@@ -0,0 +1,350 @@
// 指纹参数序列化/反序列化工具
/**
* 获取系统当前时区
* @returns IANA 时区标识符,如 "Asia/Shanghai"
*/
export function getSystemTimezone(): string {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone
} catch {
return 'UTC'
}
}
export interface FingerprintConfig {
// 指纹种子(核心)
seed?: string // --fingerprint=<seed> 控制所有随机噪声的根种子
// 基础身份
brand?: string // --fingerprint-brand=
platform?: string // --fingerprint-platform=
lang?: string // --lang=
timezone?: string // --timezone=
// 屏幕与窗口
resolution?: string // --window-size=(预设值或 'custom'
customResolution?: string // 当 resolution === 'custom' 时使用
colorDepth?: string // --fingerprint-color-depth=
// 硬件信息
hardwareConcurrency?: string // --fingerprint-hardware-concurrency=
deviceMemory?: string // --fingerprint-device-memory=
// 渲染指纹
canvasNoise?: boolean // --fingerprint-canvas-noise=
webglVendor?: string // --fingerprint-webgl-vendor=
webglRenderer?: string // --fingerprint-webgl-renderer=
audioNoise?: boolean // --fingerprint-audio-noise=
// 字体
fonts?: string // --fingerprint-fonts=
// 网络与隐私
webrtcPolicy?: string // --webrtc-ip-handling-policy=
doNotTrack?: boolean // --fingerprint-do-not-track=
// 媒体设备
mediaDevices?: string // --fingerprint-media-devices= (格式: "2,1,0" 摄像头,麦克风,扬声器)
// 触摸
touchPoints?: string // --fingerprint-touch-points=
unknownArgs?: string[] // 无法识别的原始参数,原样保留
}
export const PRESET_RESOLUTIONS = ['1920,1080', '1440,900', '1366,768', '2560,1440', '1280,800', '1600,900']
// CLI 参数前缀 → FingerprintConfig 字段映射
export const KEY_MAP: Record<string, keyof FingerprintConfig> = {
'--fingerprint': 'seed',
'--fingerprint-brand': 'brand',
'--fingerprint-platform': 'platform',
'--lang': 'lang',
'--timezone': 'timezone',
'--window-size': 'resolution',
'--fingerprint-color-depth': 'colorDepth',
'--fingerprint-hardware-concurrency': 'hardwareConcurrency',
'--fingerprint-device-memory': 'deviceMemory',
'--fingerprint-canvas-noise': 'canvasNoise',
'--fingerprint-webgl-vendor': 'webglVendor',
'--fingerprint-webgl-renderer': 'webglRenderer',
'--fingerprint-audio-noise': 'audioNoise',
'--fingerprint-fonts': 'fonts',
'--webrtc-ip-handling-policy': 'webrtcPolicy',
'--fingerprint-do-not-track': 'doNotTrack',
'--fingerprint-media-devices': 'mediaDevices',
'--fingerprint-touch-points': 'touchPoints',
}
// FingerprintConfig → string[]
export function serialize(config: FingerprintConfig): string[] {
const args: string[] = []
if (config.seed) args.push(`--fingerprint=${config.seed}`)
if (config.brand) args.push(`--fingerprint-brand=${config.brand}`)
if (config.platform) args.push(`--fingerprint-platform=${config.platform}`)
if (config.lang) args.push(`--lang=${config.lang}`)
if (config.timezone) {
// 如果是 system,替换为实际系统时区
const tz = config.timezone === 'system' ? getSystemTimezone() : config.timezone
args.push(`--timezone=${tz}`)
}
const res = config.resolution === 'custom' ? config.customResolution : config.resolution
if (res) args.push(`--window-size=${res}`)
if (config.colorDepth) args.push(`--fingerprint-color-depth=${config.colorDepth}`)
if (config.hardwareConcurrency) args.push(`--fingerprint-hardware-concurrency=${config.hardwareConcurrency}`)
if (config.deviceMemory) args.push(`--fingerprint-device-memory=${config.deviceMemory}`)
if (config.canvasNoise !== undefined) args.push(`--fingerprint-canvas-noise=${config.canvasNoise}`)
if (config.webglVendor) args.push(`--fingerprint-webgl-vendor=${config.webglVendor}`)
if (config.webglRenderer) args.push(`--fingerprint-webgl-renderer=${config.webglRenderer}`)
if (config.audioNoise !== undefined) args.push(`--fingerprint-audio-noise=${config.audioNoise}`)
if (config.fonts) args.push(`--fingerprint-fonts=${config.fonts}`)
if (config.webrtcPolicy) args.push(`--webrtc-ip-handling-policy=${config.webrtcPolicy}`)
if (config.doNotTrack !== undefined) args.push(`--fingerprint-do-not-track=${config.doNotTrack}`)
if (config.mediaDevices) args.push(`--fingerprint-media-devices=${config.mediaDevices}`)
if (config.touchPoints) args.push(`--fingerprint-touch-points=${config.touchPoints}`)
return [...args, ...(config.unknownArgs ?? [])]
}
// string[] → FingerprintConfig
export function deserialize(args: string[]): FingerprintConfig {
const config: FingerprintConfig = { unknownArgs: [] }
for (const arg of args) {
const eqIdx = arg.indexOf('=')
if (eqIdx === -1) {
config.unknownArgs!.push(arg)
continue
}
const key = arg.slice(0, eqIdx)
const val = arg.slice(eqIdx + 1)
const field = KEY_MAP[key]
if (!field) {
config.unknownArgs!.push(arg)
continue
}
if (field === 'canvasNoise' || field === 'audioNoise' || field === 'doNotTrack') {
(config as Record<string, unknown>)[field] = val === 'true'
} else if (field === 'resolution') {
if (PRESET_RESOLUTIONS.includes(val)) {
config.resolution = val
} else {
config.resolution = 'custom'
config.customResolution = val
}
} else {
(config as Record<string, unknown>)[field] = val
}
}
return config
}
// 生成随机指纹种子(32位正整数)
export function randomFingerprintSeed(): string {
return String(Math.floor(Math.random() * 2147483647) + 1)
}
// ─── 预设指纹配置 ────────────────────────────────────────────────────────────
export interface FingerprintPreset {
id: string
name: string
description: string
config: Partial<FingerprintConfig>
}
export const FINGERPRINT_PRESETS: FingerprintPreset[] = [
{
id: 'win-chrome-office',
name: 'Windows / Chrome / 办公',
description: '模拟国内办公室 Windows 用户,中文环境,1920x1080',
config: {
brand: 'Chrome',
platform: 'windows',
lang: 'zh-CN',
timezone: 'Asia/Shanghai',
resolution: '1920,1080',
colorDepth: '24',
hardwareConcurrency: '8',
deviceMemory: '8',
canvasNoise: true,
audioNoise: true,
webglVendor: 'Intel',
webglRenderer: 'Intel(R) UHD Graphics 630',
fonts: 'Arial,Microsoft YaHei,SimSun,SimHei,Helvetica,Times New Roman',
webrtcPolicy: 'disable_non_proxied_udp',
doNotTrack: false,
touchPoints: '0',
},
},
{
id: 'win-chrome-gaming',
name: 'Windows / Chrome / 游戏主机',
description: '模拟高配游戏 PCNVIDIA 显卡,2560x1440',
config: {
brand: 'Chrome',
platform: 'windows',
lang: 'en-US',
timezone: 'America/New_York',
resolution: '2560,1440',
colorDepth: '24',
hardwareConcurrency: '16',
deviceMemory: '16',
canvasNoise: true,
audioNoise: true,
webglVendor: 'NVIDIA',
webglRenderer: 'NVIDIA GeForce RTX 3080',
fonts: 'Arial,Helvetica,Times New Roman,Courier New,Verdana',
webrtcPolicy: 'disable_non_proxied_udp',
doNotTrack: false,
touchPoints: '0',
},
},
{
id: 'mac-chrome-designer',
name: 'macOS / Chrome / 设计师',
description: '模拟 Mac 设计师用户,Apple GPURetina 分辨率',
config: {
brand: 'Chrome',
platform: 'mac',
lang: 'zh-CN',
timezone: 'Asia/Shanghai',
resolution: '2560,1440',
colorDepth: '30',
hardwareConcurrency: '10',
deviceMemory: '16',
canvasNoise: true,
audioNoise: true,
webglVendor: 'Apple',
webglRenderer: 'Apple M2',
fonts: 'Arial,Helvetica,PingFang SC,Hiragino Sans GB,STHeiti,Times New Roman',
webrtcPolicy: 'disable_non_proxied_udp',
doNotTrack: true,
touchPoints: '0',
},
},
{
id: 'win-edge-enterprise',
name: 'Windows / Edge / 企业',
description: '模拟企业 Windows 用户,Edge 浏览器,标准配置',
config: {
brand: 'Edge',
platform: 'windows',
lang: 'zh-CN',
timezone: 'Asia/Shanghai',
resolution: '1366,768',
colorDepth: '24',
hardwareConcurrency: '4',
deviceMemory: '4',
canvasNoise: true,
audioNoise: false,
webglVendor: 'Intel',
webglRenderer: 'Intel(R) HD Graphics 520',
fonts: 'Arial,Microsoft YaHei,Calibri,Segoe UI,Times New Roman',
webrtcPolicy: 'default_public_interface_only',
doNotTrack: false,
touchPoints: '0',
},
},
{
id: 'win-chrome-us-user',
name: 'Windows / Chrome / 美国用户',
description: '模拟美国普通用户,英文环境,AMD 显卡',
config: {
brand: 'Chrome',
platform: 'windows',
lang: 'en-US',
timezone: 'America/Los_Angeles',
resolution: '1920,1080',
colorDepth: '24',
hardwareConcurrency: '8',
deviceMemory: '8',
canvasNoise: true,
audioNoise: true,
webglVendor: 'AMD',
webglRenderer: 'AMD Radeon RX 6600',
fonts: 'Arial,Helvetica,Times New Roman,Courier New,Georgia',
webrtcPolicy: 'disable_non_proxied_udp',
doNotTrack: false,
touchPoints: '0',
},
},
{
id: 'mac-safari-jp',
name: 'macOS / Safari / 日本用户',
description: '模拟日本 Mac 用户,Safari 风格,日语环境',
config: {
brand: 'Safari',
platform: 'mac',
lang: 'ja-JP',
timezone: 'Asia/Tokyo',
resolution: '1440,900',
colorDepth: '24',
hardwareConcurrency: '8',
deviceMemory: '8',
canvasNoise: true,
audioNoise: true,
webglVendor: 'Apple',
webglRenderer: 'Apple M1',
fonts: 'Arial,Helvetica,Hiragino Kaku Gothic ProN,Yu Gothic,Times New Roman',
webrtcPolicy: 'disable_non_proxied_udp',
doNotTrack: true,
touchPoints: '0',
},
},
{
id: 'win-chrome-uk-office',
name: 'Windows / Chrome / 英国-办公',
description: '模拟英国办公室 Windows 用户,英文环境 (en-GB)',
config: {
brand: 'Chrome',
platform: 'windows',
lang: 'en-GB',
timezone: 'Europe/London',
resolution: '1920,1080',
colorDepth: '24',
hardwareConcurrency: '8',
deviceMemory: '8',
canvasNoise: true,
audioNoise: true,
webglVendor: 'Intel',
webglRenderer: 'Intel(R) UHD Graphics 630',
fonts: 'Arial,Helvetica,Times New Roman,Courier New,Verdana',
webrtcPolicy: 'disable_non_proxied_udp',
doNotTrack: false,
touchPoints: '0',
},
},
{
id: 'mac-chrome-us-edu',
name: 'macOS / Chrome / 美国-教育',
description: '模拟美国大学教育网 Mac 用户,英文环境 (en-US)',
config: {
brand: 'Chrome',
platform: 'mac',
lang: 'en-US',
timezone: 'America/New_York',
resolution: '1440,900',
colorDepth: '24',
hardwareConcurrency: '8',
deviceMemory: '8',
canvasNoise: true,
audioNoise: true,
webglVendor: 'Apple',
webglRenderer: 'Apple M1',
fonts: 'Arial,Helvetica,Times New Roman,Courier New,Georgia',
webrtcPolicy: 'disable_non_proxied_udp',
doNotTrack: false,
touchPoints: '0',
},
},
]
@@ -0,0 +1,60 @@
import {
BarChartExample,
LineChartExample,
PieChartExample,
AreaChartExample,
ComposedChartExample
} from './components';
export function ChartsPage() {
return (
<div className="p-6 space-y-8">
<div className="mb-6">
<h1 className="text-2xl font-semibold text-[var(--color-text-primary)]"></h1>
<p className="text-[var(--color-text-secondary)]"></p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 柱状图 */}
<div className="bg-[var(--color-bg-surface)] p-4 rounded-lg border border-[var(--color-border-default)] shadow-sm">
<h2 className="text-lg font-medium mb-4 text-[var(--color-text-primary)]"></h2>
<div className="h-[300px]">
<BarChartExample />
</div>
</div>
{/* 折线图 */}
<div className="bg-[var(--color-bg-surface)] p-4 rounded-lg border border-[var(--color-border-default)] shadow-sm">
<h2 className="text-lg font-medium mb-4 text-[var(--color-text-primary)]">线</h2>
<div className="h-[300px]">
<LineChartExample />
</div>
</div>
{/* 饼图 */}
<div className="bg-[var(--color-bg-surface)] p-4 rounded-lg border border-[var(--color-border-default)] shadow-sm">
<h2 className="text-lg font-medium mb-4 text-[var(--color-text-primary)]"></h2>
<div className="h-[300px]">
<PieChartExample />
</div>
</div>
{/* 面积图 */}
<div className="bg-[var(--color-bg-surface)] p-4 rounded-lg border border-[var(--color-border-default)] shadow-sm">
<h2 className="text-lg font-medium mb-4 text-[var(--color-text-primary)]"></h2>
<div className="h-[300px]">
<AreaChartExample />
</div>
</div>
{/* 组合图表 */}
<div className="bg-[var(--color-bg-surface)] p-4 rounded-lg border border-[var(--color-border-default)] shadow-sm col-span-1 md:col-span-2">
<h2 className="text-lg font-medium mb-4 text-[var(--color-text-primary)]"></h2>
<div className="h-[400px]">
<ComposedChartExample />
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,65 @@
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
const data = [
{ name: '周一', 系统A: 4000, 系统B: 2400, 系统C: 1800 },
{ name: '周二', 系统A: 3000, 系统B: 1398, 系统C: 2300 },
{ name: '周三', 系统A: 2000, 系统B: 9800, 系统C: 2500 },
{ name: '周四', 系统A: 2780, 系统B: 3908, 系统C: 1908 },
{ name: '周五', 系统A: 1890, 系统B: 4800, 系统C: 2800 },
{ name: '周六', 系统A: 2390, 系统B: 3800, 系统C: 3200 },
{ name: '周日', 系统A: 3490, 系统B: 4300, 系统C: 2100 },
];
export function AreaChartExample() {
return (
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={data}
margin={{ top: 10, right: 30, left: 0, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border-muted)" />
<XAxis
dataKey="name"
tick={{ fill: 'var(--color-text-secondary)' }}
/>
<YAxis
tick={{ fill: 'var(--color-text-secondary)' }}
/>
<Tooltip
contentStyle={{
backgroundColor: 'var(--color-bg-default)',
borderColor: 'var(--color-border-default)',
color: 'var(--color-text-primary)'
}}
/>
<Legend
wrapperStyle={{ color: 'var(--color-text-primary)' }}
/>
<Area
type="monotone"
dataKey="系统A"
stackId="1"
stroke="#8884d8"
fill="#8884d8"
fillOpacity={0.6}
/>
<Area
type="monotone"
dataKey="系统B"
stackId="1"
stroke="#82ca9d"
fill="#82ca9d"
fillOpacity={0.6}
/>
<Area
type="monotone"
dataKey="系统C"
stackId="1"
stroke="#ffc658"
fill="#ffc658"
fillOpacity={0.6}
/>
</AreaChart>
</ResponsiveContainer>
);
}
@@ -0,0 +1,43 @@
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
const data = [
{ name: '1月', 产品A: 4000, 产品B: 2400 },
{ name: '2月', 产品A: 3000, 产品B: 1398 },
{ name: '3月', 产品A: 2000, 产品B: 9800 },
{ name: '4月', 产品A: 2780, 产品B: 3908 },
{ name: '5月', 产品A: 1890, 产品B: 4800 },
{ name: '6月', 产品A: 2390, 产品B: 3800 },
{ name: '7月', 产品A: 3490, 产品B: 4300 },
];
export function BarChartExample() {
return (
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={data}
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border-muted)" />
<XAxis
dataKey="name"
tick={{ fill: 'var(--color-text-secondary)' }}
/>
<YAxis
tick={{ fill: 'var(--color-text-secondary)' }}
/>
<Tooltip
contentStyle={{
backgroundColor: 'var(--color-bg-default)',
borderColor: 'var(--color-border-default)',
color: 'var(--color-text-primary)'
}}
/>
<Legend
wrapperStyle={{ color: 'var(--color-text-primary)' }}
/>
<Bar dataKey="产品A" fill="var(--color-accent)" radius={[4, 4, 0, 0]} />
<Bar dataKey="产品B" fill="#82ca9d" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
);
}
@@ -0,0 +1,76 @@
import { ComposedChart, Line, Area, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
const data = [
{ name: 'Q1', 收入: 800, 支出: 300, 利润: 500, 增长率: 20 },
{ name: 'Q2', 收入: 967, 支出: 467, 利润: 500, 增长率: 10 },
{ name: 'Q3', 收入: 1098, 支出: 749, 利润: 349, 增长率: 15 },
{ name: 'Q4', 收入: 1200, 支出: 880, 利润: 320, 增长率: 12 },
{ name: 'Q5', 收入: 1108, 支出: 600, 利润: 508, 增长率: 22 },
{ name: 'Q6', 收入: 1300, 支出: 700, 利润: 600, 增长率: 25 },
];
export function ComposedChartExample() {
return (
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={data}
margin={{ top: 20, right: 20, bottom: 20, left: 20 }}
>
<CartesianGrid stroke="var(--color-border-muted)" strokeDasharray="3 3" />
<XAxis
dataKey="name"
tick={{ fill: 'var(--color-text-secondary)' }}
label={{ value: '季度', position: 'insideBottomRight', offset: 0, fill: 'var(--color-text-secondary)' }}
/>
<YAxis
yAxisId="left"
tick={{ fill: 'var(--color-text-secondary)' }}
label={{ value: '金额 (万元)', angle: -90, position: 'insideLeft', fill: 'var(--color-text-secondary)' }}
/>
<YAxis
yAxisId="right"
orientation="right"
tick={{ fill: 'var(--color-text-secondary)' }}
label={{ value: '增长率 (%)', angle: 90, position: 'insideRight', fill: 'var(--color-text-secondary)' }}
/>
<Tooltip
contentStyle={{
backgroundColor: 'var(--color-bg-default)',
borderColor: 'var(--color-border-default)',
color: 'var(--color-text-primary)'
}}
/>
<Legend wrapperStyle={{ color: 'var(--color-text-primary)' }} />
<Area
yAxisId="left"
dataKey="利润"
fill="#8884d8"
stroke="#8884d8"
fillOpacity={0.3}
/>
<Bar
yAxisId="left"
dataKey="收入"
barSize={20}
fill="var(--color-accent)"
radius={[4, 4, 0, 0]}
/>
<Bar
yAxisId="left"
dataKey="支出"
barSize={20}
fill="#82ca9d"
radius={[4, 4, 0, 0]}
/>
<Line
yAxisId="right"
dataKey="增长率"
type="monotone"
stroke="#ff7300"
strokeWidth={2}
activeDot={{ r: 6 }}
/>
</ComposedChart>
</ResponsiveContainer>
);
}
@@ -0,0 +1,53 @@
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
const data = [
{ name: '周一', 访问量: 4000, 用户数: 2400 },
{ name: '周二', 访问量: 3000, 用户数: 1398 },
{ name: '周三', 访问量: 2000, 用户数: 9800 },
{ name: '周四', 访问量: 2780, 用户数: 3908 },
{ name: '周五', 访问量: 1890, 用户数: 4800 },
{ name: '周六', 访问量: 2390, 用户数: 3800 },
{ name: '周日', 访问量: 3490, 用户数: 4300 },
];
export function LineChartExample() {
return (
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={data}
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border-muted)" />
<XAxis
dataKey="name"
tick={{ fill: 'var(--color-text-secondary)' }}
/>
<YAxis
tick={{ fill: 'var(--color-text-secondary)' }}
/>
<Tooltip
contentStyle={{
backgroundColor: 'var(--color-bg-default)',
borderColor: 'var(--color-border-default)',
color: 'var(--color-text-primary)'
}}
/>
<Legend wrapperStyle={{ color: 'var(--color-text-primary)' }} />
<Line
type="monotone"
dataKey="访问量"
stroke="var(--color-accent)"
strokeWidth={2}
activeDot={{ r: 6 }}
/>
<Line
type="monotone"
dataKey="用户数"
stroke="#82ca9d"
strokeWidth={2}
activeDot={{ r: 6 }}
/>
</LineChart>
</ResponsiveContainer>
);
}
@@ -0,0 +1,48 @@
import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer } from 'recharts';
const data = [
{ name: '分类A', value: 400 },
{ name: '分类B', value: 300 },
{ name: '分类C', value: 300 },
{ name: '分类D', value: 200 },
{ name: '分类E', value: 100 },
];
const COLORS = ['#8884d8', '#83a6ed', '#8dd1e1', '#82ca9d', '#a4de6c'];
export function PieChartExample() {
return (
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
labelLine={true}
outerRadius={80}
fill="#8884d8"
dataKey="value"
label={({ name, percent }) => `${name}: ${(percent * 100).toFixed(0)}%`}
>
{data.map((_, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: 'var(--color-bg-default)',
borderColor: 'var(--color-border-default)',
color: 'var(--color-text-primary)'
}}
formatter={(value: number) => [`${value}`, '数量']}
/>
<Legend
layout="horizontal"
verticalAlign="bottom"
align="center"
wrapperStyle={{ color: 'var(--color-text-primary)' }}
/>
</PieChart>
</ResponsiveContainer>
);
}
@@ -0,0 +1,5 @@
export { BarChartExample } from './BarChartExample';
export { LineChartExample } from './LineChartExample';
export { PieChartExample } from './PieChartExample';
export { AreaChartExample } from './AreaChartExample';
export { ComposedChartExample } from './ComposedChartExample';
+1
View File
@@ -0,0 +1 @@
export { ChartsPage } from './ChartsPage'
@@ -0,0 +1,234 @@
import { useEffect, useState } from 'react'
import { Monitor, Play, Shield, Cpu, ArrowRight, Globe, Settings } from 'lucide-react'
import { Link } from 'react-router-dom'
import { Card, Button, Modal, toast } from '../../shared/components'
import { fetchDashboardStats, redeemCDKey, redeemGithubStar, reloadConfig } from './api'
import type { DashboardStats } from './types'
import { BrowserOpenURL } from '../../wailsjs/runtime/runtime'
import { PROJECT_GITHUB_URL } from '../../config/links'
interface StatCardProps {
title: string
value: string | number
icon: React.ReactNode
color: string
}
function StatCard({ title, value, icon, color }: StatCardProps) {
return (
<div className="rounded-xl border border-[var(--color-border-default)] bg-[var(--color-bg-card)] p-5">
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-[var(--color-text-muted)]">{title}</span>
<div className={`w-9 h-9 rounded-lg flex items-center justify-center ${color}`}>
{icon}
</div>
</div>
<div className="text-2xl font-semibold text-[var(--color-text-primary)]">{value}</div>
</div>
)
}
const QUICK_LINKS = [
{ to: '/browser', icon: <Monitor className="w-5 h-5" />, label: '浏览器实例', desc: '管理所有指纹浏览器' },
{ to: '/browser/proxy-pool', icon: <Shield className="w-5 h-5" />, label: '代理池', desc: '配置和测试代理节点' },
{ to: '/browser/cores', icon: <Cpu className="w-5 h-5" />, label: '内核管理', desc: '管理 Chrome 内核版本' },
{ to: '/settings', icon: <Settings className="w-5 h-5" />, label: '系统设置', desc: '全局参数配置' },
]
export function DashboardPage() {
const [stats, setStats] = useState<DashboardStats>({
totalInstances: 0,
runningInstances: 0,
proxyCount: 0,
coreCount: 0,
memUsedMB: 0,
maxProfileLimit: 3,
})
const [loading, setLoading] = useState(true)
const [cdKey, setCdKey] = useState('')
const [redeeming, setRedeeming] = useState(false)
const [promoModalMsg, setPromoModalMsg] = useState('')
useEffect(() => {
load()
}, [])
const load = async () => {
setLoading(true)
try {
await reloadConfig() // 强制从本地磁盘刷一次最新配置,解决各种情况下的容量不同步
setStats(await fetchDashboardStats())
} finally {
setLoading(false)
}
}
const handleRedeem = async () => {
if (!cdKey.trim()) return
setRedeeming(true)
const result = await redeemCDKey(cdKey.trim())
setRedeeming(false)
if (result.success) {
toast.success('兑换成功!此名额已到账')
setCdKey('')
load() // Refresh stats
} else {
setPromoModalMsg(result.message || '兑换失败')
}
}
const handleAcceptPromo = async () => {
setPromoModalMsg('')
BrowserOpenURL(PROJECT_GITHUB_URL)
setRedeeming(true)
const starRes = await redeemGithubStar()
setRedeeming(false)
if (starRes.success) {
toast.success('感谢您的支持!已为您增加 3 个永久额度!')
setCdKey('')
load()
} else {
toast.error(starRes.message || '领取失败')
}
}
const v = (n: number) => loading ? '-' : n.toString()
return (
<div className="space-y-6 animate-fade-in">
<div>
<h1 className="text-xl font-semibold text-[var(--color-text-primary)]"></h1>
<p className="text-sm text-[var(--color-text-muted)] mt-1"></p>
</div>
{/* 统计卡片 */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
title="实例总数"
value={v(stats.totalInstances)}
icon={<Monitor className="w-4 h-4 text-blue-500" />}
color="bg-blue-50 dark:bg-blue-900/20"
/>
<StatCard
title="运行中"
value={v(stats.runningInstances)}
icon={<Play className="w-4 h-4 text-green-500" />}
color="bg-green-50 dark:bg-green-900/20"
/>
<StatCard
title="代理节点"
value={v(stats.proxyCount)}
icon={<Globe className="w-4 h-4 text-purple-500" />}
color="bg-purple-50 dark:bg-purple-900/20"
/>
<StatCard
title="内核版本"
value={v(stats.coreCount)}
icon={<Cpu className="w-4 h-4 text-orange-500" />}
color="bg-orange-50 dark:bg-orange-900/20"
/>
</div>
{/* 快捷操作 + 系统信息 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card title="快捷操作">
<div className="grid grid-cols-2 gap-3">
{QUICK_LINKS.map(link => (
<Link
key={link.to}
to={link.to}
className="group flex items-center gap-3 p-4 rounded-xl border border-[var(--color-border-default)] bg-[var(--color-bg-subtle)]
hover:border-[var(--color-border-strong)] hover:bg-[var(--color-bg-muted)] transition-all duration-150"
>
<div className="w-10 h-10 rounded-xl bg-[var(--color-accent-muted)] flex items-center justify-center text-[var(--color-text-secondary)]
group-hover:bg-[var(--color-accent)] group-hover:text-[var(--color-text-inverse)] transition-colors shrink-0">
{link.icon}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-[var(--color-text-primary)]">{link.label}</p>
<p className="text-xs text-[var(--color-text-muted)] truncate">{link.desc}</p>
</div>
<ArrowRight className="w-4 h-4 text-[var(--color-text-muted)] opacity-0 -translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all shrink-0" />
</Link>
))}
</div>
</Card>
<Card title="系统信息">
<div className="space-y-1">
{[
{ label: '系统版本', value: '1.0.0' },
{ label: '运行环境', value: 'Wails v2 + React' },
{ label: '数据存储', value: 'SQLite + YAML' },
{ label: '内存占用', value: loading ? '-' : `${stats.memUsedMB} MB` },
{ label: '实例运行', value: loading ? '-' : `${stats.runningInstances} / ${stats.totalInstances}` },
].map(item => (
<div
key={item.label}
className="flex justify-between items-center py-3 border-b border-[var(--color-border-muted)] last:border-0"
>
<span className="text-sm text-[var(--color-text-muted)]">{item.label}</span>
<span className="text-sm font-medium text-[var(--color-text-primary)]">{item.value}</span>
</div>
))}
</div>
<div className="mt-6 pt-6 border-t border-[var(--color-border-muted)]">
<h3 className="text-sm font-medium text-[var(--color-text-primary)] mb-3"></h3>
<div className="flex gap-2">
<input
type="text"
placeholder="输入兑换码 (如 ANT-...)"
value={cdKey}
onChange={e => setCdKey(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleRedeem()}
className="flex-1 px-3 py-2 text-sm rounded-lg border border-[var(--color-border-default)]
bg-[var(--color-bg-input)] text-[var(--color-text-primary)]
focus:outline-none focus:border-[var(--color-primary)] placeholder-[var(--color-text-muted)]"
/>
<Button onClick={handleRedeem} loading={redeeming} disabled={!cdKey.trim()}>
</Button>
</div>
<p className="mt-2 text-xs text-[var(--color-text-muted)] flex items-center justify-between">
<span></span>
<span className={`font-medium ${stats.totalInstances >= stats.maxProfileLimit ? 'text-red-500' : 'text-[var(--color-success)]'}`}>
{loading ? '-' : `${stats.totalInstances} / ${stats.maxProfileLimit}`}
</span>
</p>
</div>
</Card>
</div>
<Modal
open={!!promoModalMsg}
onClose={() => setPromoModalMsg('')}
title="获取更多额度"
width="400px"
footer={
<>
<Button variant="secondary" onClick={() => setPromoModalMsg('')}></Button>
<Button onClick={handleAcceptPromo}> GitHub </Button>
</>
}
>
<div className="space-y-4">
<div className="text-[var(--color-error)] font-medium">
{promoModalMsg}
</div>
<div className="text-sm text-[var(--color-text-secondary)] leading-relaxed">
<p className="mb-2"></p>
<p> <strong>Star</strong> <strong>3</strong> </p>
<p className="text-xs text-[var(--color-text-muted)] mt-2">
<button type="button" className="ml-1 break-all text-[var(--color-accent)] underline underline-offset-2" onClick={() => BrowserOpenURL(PROJECT_GITHUB_URL)}>
{PROJECT_GITHUB_URL}
</button>
</p>
<p className="text-xs text-[var(--color-text-muted)] mt-2"></p>
</div>
</div>
</Modal>
</div>
)
}
+80
View File
@@ -0,0 +1,80 @@
import type { DashboardStats } from './types'
const getBindings = async () => {
try {
return await import('../../wailsjs/go/main/App')
} catch {
return null
}
}
export async function fetchDashboardStats(): Promise<DashboardStats> {
const bindings: any = await getBindings()
if (bindings?.GetDashboardStats) {
try {
const data = await bindings.GetDashboardStats()
const licenseStatus = bindings.GetLicenseStatus ? await bindings.GetLicenseStatus() : { maxLimit: 3 }
return {
totalInstances: data?.totalInstances ?? 0,
runningInstances: data?.runningInstances ?? 0,
proxyCount: data?.proxyCount ?? 0,
coreCount: data?.coreCount ?? 0,
memUsedMB: data?.memUsedMB ?? 0,
maxProfileLimit: licenseStatus?.maxLimit ?? 3,
}
} catch (e) {
console.error('fetchDashboardStats error:', e)
}
}
return { totalInstances: 0, runningInstances: 0, proxyCount: 0, coreCount: 0, memUsedMB: 0, maxProfileLimit: 3 }
}
export async function redeemCDKey(cdkey: string): Promise<{ success: boolean, message?: string }> {
const bindings: any = await getBindings()
if (bindings?.RedeemCDKey) {
try {
await bindings.RedeemCDKey(cdkey)
return { success: true }
} catch (e: any) {
return { success: false, message: e.message || '兑换失败' }
}
}
return { success: false, message: '系统 API 未就绪' }
}
export async function redeemGithubStar(): Promise<{ success: boolean, message?: string }> {
const bindings: any = await getBindings()
if (bindings?.RedeemGithubStar) {
try {
await bindings.RedeemGithubStar()
return { success: true }
} catch (e: any) {
return { success: false, message: e.message || '领取失败' }
}
}
return { success: false, message: '系统 API 未就绪' }
}
export async function reloadConfig(): Promise<void> {
const bindings: any = await getBindings()
if (bindings?.ReloadConfig) {
try {
await bindings.ReloadConfig()
} catch (e) {
console.error('reloadConfig error:', e)
}
}
}
export async function generateCDKeys(count: number): Promise<{ success: boolean, keys: string[], message?: string }> {
const bindings: any = await getBindings()
if (bindings?.GenerateCDKeys) {
try {
const keys = await bindings.GenerateCDKeys(count)
return { success: true, keys: keys || [] }
} catch (e: any) {
return { success: false, keys: [], message: e.message || '生成失败' }
}
}
return { success: false, keys: [], message: '系统 API 未就绪' }
}
+4
View File
@@ -0,0 +1,4 @@
// Dashboard 模块导出
export { DashboardPage } from './DashboardPage'
export * from './types'
export * from './api'
+8
View File
@@ -0,0 +1,8 @@
export interface DashboardStats {
totalInstances: number
runningInstances: number
proxyCount: number
coreCount: number
memUsedMB: number
maxProfileLimit: number
}
@@ -0,0 +1,140 @@
import { FormEvent, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, Button, Input, Modal, toast, Textarea } from '../../shared/components'
import { Key } from 'lucide-react'
import { generateCDKeys } from '../dashboard/api'
const ADMIN_PAGE_PASSWORD = '志字辈小蚂蚁'
export function AdminKeygenPage() {
const navigate = useNavigate()
const [count, setCount] = useState<number>(10)
const [keys, setKeys] = useState<string[]>([])
const [loading, setLoading] = useState(false)
const [accessGranted, setAccessGranted] = useState(false)
const [passwordInput, setPasswordInput] = useState('')
const handleGenerate = async () => {
if (count <= 0 || count > 1000) {
toast.error('生成数量必须在 1 ~ 1000 之间')
return
}
setLoading(true)
const res = await generateCDKeys(count)
setLoading(false)
if (res.success) {
setKeys(res.keys)
toast.success(`成功生成 ${res.keys.length} 个兑换码`)
} else {
toast.error(res.message || '生成失败')
}
}
const handleCopyAll = async () => {
if (keys.length === 0) return
try {
await navigator.clipboard.writeText(keys.join('\n'))
toast.success('已复制全部兑换码到剪贴板')
} catch {
toast.error('复制失败,请手动选择复制')
}
}
const handleVerifyPassword = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (passwordInput.trim() === ADMIN_PAGE_PASSWORD) {
setAccessGranted(true)
setPasswordInput('')
toast.success('验证通过,已进入兑换码生成页面')
return
}
toast.error('密码错误,请重试')
setPasswordInput('')
}
return (
<>
<Modal
open={!accessGranted}
onClose={() => navigate('/profile')}
title="管理员验证"
width="420px"
closable={false}
>
<form className="space-y-4" onSubmit={handleVerifyPassword}>
<p className="text-sm text-[var(--color-text-secondary)]">
访
</p>
<Input
type="text"
value={passwordInput}
onChange={(e) => setPasswordInput(e.target.value)}
placeholder="请输入密码"
autoFocus
autoComplete="off"
inputMode="text"
spellCheck={false}
/>
<div className="flex justify-end gap-3 pt-1">
<Button type="button" variant="secondary" onClick={() => navigate('/profile')}>
</Button>
<Button type="submit">
</Button>
</div>
</form>
</Modal>
{accessGranted && (
<div className="space-y-6 animate-fade-in max-w-4xl mx-auto">
<div>
<h1 className="text-xl font-semibold text-[var(--color-text-primary)]"> - CDKey </h1>
<p className="text-sm text-[var(--color-text-muted)] mt-1"> 3 </p>
</div>
<Card>
<div className="flex flex-col gap-4">
<div className="flex items-end gap-3">
<div className="flex-1">
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1"></label>
<Input
type="number"
min={1}
max={100}
value={count}
onChange={(e) => setCount(parseInt(e.target.value) || 0)}
placeholder="10"
/>
</div>
<Button onClick={handleGenerate} loading={loading} className="w-32">
<Key className="w-4 h-4 mr-2" />
</Button>
</div>
<div className="mt-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-[var(--color-text-primary)]"></span>
<Button size="sm" variant="secondary" onClick={handleCopyAll} disabled={keys.length === 0}>
</Button>
</div>
<Textarea
value={keys.length > 0 ? keys.join('\n') : '点击上方按钮生成...'}
readOnly
rows={15}
className="font-mono text-sm leading-relaxed"
/>
</div>
</div>
</Card>
</div>
)}
</>
)
}
@@ -0,0 +1,250 @@
import {
Github,
Mail,
Globe,
BookOpen,
MessageSquare,
Calendar,
MapPin,
Coffee,
Terminal,
ExternalLink,
} from 'lucide-react'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Badge, Button, Card } from '../../shared/components'
import { createDefaultProfilePageData, loadProfilePageData } from './api'
import type { IconKey, ProfilePageData } from './types'
const ICON_MAP = {
'book-open': BookOpen,
globe: Globe,
'message-square': MessageSquare,
github: Github,
mail: Mail,
'external-link': ExternalLink,
}
const CHANNEL_ICON_CLASS: Partial<Record<IconKey, string>> = {
'book-open': 'text-[#1e80ff]',
globe: 'text-[#0f766e]',
'message-square': 'text-[#16a34a]',
github: 'text-[var(--color-text-primary)]',
mail: 'text-[var(--color-accent)]',
}
export function ProfilePage() {
const navigate = useNavigate()
const [clickCount, setClickCount] = useState(0)
const [pageData, setPageData] = useState<ProfilePageData>(() => createDefaultProfilePageData())
useEffect(() => {
let active = true
const syncProfile = async () => {
const data = await loadProfilePageData()
if (!active) return
setPageData(data)
}
void syncProfile()
return () => {
active = false
}
}, [])
const handleAuthorClick = () => {
const newCount = clickCount + 1
setClickCount(newCount)
if (newCount >= 5) {
navigate('/admin/keygen')
setClickCount(0)
}
}
const openExternal = (url: string) => {
window.open(url, '_blank', 'noopener,noreferrer')
}
const authorInfo = pageData.author
const projectInfo = pageData.project
const metaItems = [
{
label: authorInfo.location,
icon: MapPin,
},
{
label: `加入于 ${authorInfo.joinDate}`,
icon: Calendar,
},
].filter((item) => item.label.trim())
return (
<div className="mx-auto max-w-5xl space-y-6 animate-fade-in">
<Card padding="lg" className="rounded-[26px]">
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
<div className="flex min-w-0 flex-col gap-5 sm:flex-row sm:items-start">
<div className="flex h-20 w-20 shrink-0 items-center justify-center rounded-[20px] bg-[#1f2d46] text-[34px] font-bold tracking-[0.08em] text-white shadow-sm">
{authorInfo.initial}
</div>
<div className="min-w-0 space-y-4">
<div className="space-y-1">
<h1
className="cursor-pointer select-none text-[34px] font-bold leading-none tracking-tight text-[var(--color-text-primary)] sm:text-[38px]"
onClick={handleAuthorClick}
title={clickCount > 0 ? `再点 ${5 - clickCount} 次进入开发者模式` : ''}
>
{authorInfo.name}
</h1>
<p className="text-base text-[var(--color-text-muted)]">{authorInfo.title}</p>
</div>
<p className="max-w-3xl text-[15px] leading-8 text-[var(--color-text-secondary)]">
{authorInfo.bio}
</p>
<div className="flex flex-wrap items-center gap-x-5 gap-y-3 text-sm text-[var(--color-text-muted)]">
{metaItems.map(({ label, icon: Icon }) => (
<span key={label} className="inline-flex items-center gap-1.5">
<Icon className="h-4 w-4" />
{label}
</span>
))}
{authorInfo.website ? (
<button
type="button"
className="inline-flex items-center gap-1.5 text-[var(--color-text-primary)] transition-colors hover:text-[var(--color-accent)]"
onClick={() => openExternal(authorInfo.website)}
>
<Globe className="h-4 w-4" />
{stripProtocol(authorInfo.website)}
</button>
) : null}
</div>
</div>
</div>
<div className="flex flex-wrap gap-3 lg:justify-end">
{authorInfo.github ? (
<Button
variant="ghost"
className="h-11 rounded-2xl border border-transparent px-4 text-[var(--color-text-primary)] hover:border-[var(--color-border-default)] hover:bg-[var(--color-bg-muted)]"
onClick={() => openExternal(authorInfo.github)}
>
<Github className="h-4 w-4" />
GitHub
</Button>
) : null}
</div>
</div>
</Card>
<div className="grid gap-4 md:grid-cols-3">
{authorInfo.channels.map((channel) => {
const Icon = getIcon(channel.icon)
const iconClassName = CHANNEL_ICON_CLASS[channel.icon || 'globe'] || 'text-[var(--color-text-primary)]'
const content = (
<Card
className="h-full rounded-[22px] border-[var(--color-border-default)] transition-all duration-200 hover:-translate-y-0.5 hover:border-[var(--color-border-strong)] hover:shadow-[var(--shadow-md)]"
padding="lg"
>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-5">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--color-bg-muted)]">
<Icon className={`h-5 w-5 ${iconClassName}`} />
</div>
<div className="space-y-1 text-left">
<p className="text-[28px] font-bold leading-none text-[var(--color-text-primary)]">
{channel.name}
</p>
<p className="text-sm text-[var(--color-text-muted)]">{channel.description}</p>
<p className="text-xs text-[var(--color-text-secondary)]">{channel.detail}</p>
</div>
</div>
{channel.href ? <ExternalLink className="h-4 w-4 shrink-0 text-[var(--color-text-muted)]" /> : null}
</div>
</Card>
)
if (!channel.href) {
return <div key={channel.name}>{content}</div>
}
return (
<a
key={channel.name}
href={channel.href}
target="_blank"
rel="noopener noreferrer"
className="block h-full"
>
{content}
</a>
)
})}
</div>
<Card
title="技术栈"
actions={<Terminal className="h-4 w-4 text-[var(--color-text-muted)]" />}
className="rounded-[24px]"
padding="lg"
>
<div className="flex flex-wrap gap-x-8 gap-y-4 text-[15px] font-semibold text-[var(--color-text-primary)]">
{authorInfo.skills.map((skill) => (
<span key={skill}>{skill}</span>
))}
</div>
</Card>
<Card
title="关于本项目"
actions={<Coffee className="h-4 w-4 text-[var(--color-text-muted)]" />}
className="rounded-[24px]"
padding="lg"
>
<div className="space-y-4 text-[15px] leading-8 text-[var(--color-text-secondary)]">
<p>
<Badge className="mr-1 rounded-xl px-3 py-1">{projectInfo.introBadge}</Badge>
{projectInfo.introText}
</p>
<div className="flex flex-wrap gap-2">
{projectInfo.techStack.map((item) => (
<Badge key={item} className="rounded-xl px-3 py-1">
{item}
</Badge>
))}
</div>
<p>{projectInfo.description}</p>
<div className="flex flex-wrap items-center gap-3 pt-2">
{projectInfo.actions.map((action) => {
const Icon = getIcon(action.icon)
return (
<Button
key={action.label}
variant="ghost"
className="h-10 rounded-xl border border-[var(--color-border-default)] px-3 text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-muted)]"
onClick={() => openExternal(action.href)}
>
<Icon className="h-4 w-4" />
{action.label}
<ExternalLink className="h-3 w-3" />
</Button>
)
})}
</div>
</div>
</Card>
</div>
)
}
function getIcon(icon?: IconKey) {
return ICON_MAP[icon || 'globe'] || Globe
}
function stripProtocol(value: string): string {
return value.replace(/^https?:\/\//, '').replace(/\/$/, '')
}
+248
View File
@@ -0,0 +1,248 @@
import profilePageConfig from '../../config/profile.config'
import type { AuthorProfile, IconKey, ProfileChannel, ProfilePageData, ProfileProject } from './types'
const PROFILE_ICON_KEYS: IconKey[] = [
'book-open',
'globe',
'message-square',
'github',
'mail',
'external-link',
]
const CHANNEL_ICON_BY_NAME: Record<string, IconKey> = {
: 'book-open',
: 'globe',
: 'globe',
: 'message-square',
: 'message-square',
github: 'github',
: 'mail',
}
const getBindings = async () => {
try {
return await import('../../wailsjs/go/main/App')
} catch {
return null
}
}
export function createDefaultProfilePageData(): ProfilePageData {
return {
author: cloneAuthor(profilePageConfig.defaultAuthor),
project: cloneProject(profilePageConfig.project),
meta: {
source: 'default',
},
}
}
export async function loadProfilePageData(): Promise<ProfilePageData> {
const defaultData = createDefaultProfilePageData()
const authorURL = profilePageConfig.remoteAuthor.authorURL.trim()
const timeoutMs = profilePageConfig.remoteAuthor.timeoutMs
if (!authorURL) {
return defaultData
}
try {
const payload = await fetchRemoteAuthorPayload(authorURL, timeoutMs)
return {
author: normalizeAuthorProfile(payload, defaultData.author),
project: defaultData.project,
meta: {
source: 'remote',
},
}
} catch (error: any) {
return {
...defaultData,
meta: {
source: 'default',
message: error?.message || '远程作者配置不可用,已切换为默认资料。',
},
}
}
}
async function fetchRemoteAuthorPayload(authorURL: string, timeoutMs: number): Promise<Record<string, any>> {
const bindings: any = await getBindings()
if (bindings?.FetchRemoteAuthorProfile) {
return (await bindings.FetchRemoteAuthorProfile(authorURL, timeoutMs)) || {}
}
const goApp = (window as any).go?.main?.App
if (goApp?.FetchRemoteAuthorProfile) {
return (await goApp.FetchRemoteAuthorProfile(authorURL, timeoutMs)) || {}
}
return await fetchRemoteAuthorPayloadViaBrowser(authorURL, timeoutMs)
}
async function fetchRemoteAuthorPayloadViaBrowser(authorURL: string, timeoutMs: number): Promise<Record<string, any>> {
const controller = new AbortController()
const timer = window.setTimeout(() => controller.abort(), clampTimeout(timeoutMs))
try {
const response = await fetch(authorURL, {
method: 'GET',
headers: {
Accept: 'application/json',
},
signal: controller.signal,
})
if (!response.ok) {
throw new Error(`远程作者配置返回异常状态码: ${response.status}`)
}
const payload = await response.json()
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new Error('远程作者配置格式无效')
}
return payload as Record<string, any>
} catch (error: any) {
if (error?.name === 'AbortError') {
throw new Error('远程作者配置请求超时')
}
throw error
} finally {
window.clearTimeout(timer)
}
}
function normalizeAuthorProfile(payload: Record<string, any>, fallback: AuthorProfile): AuthorProfile {
const source = extractAuthorPayload(payload)
const name = normalizeString(source.name, fallback.name)
const initial = normalizeString(source.initial, name.charAt(0) || fallback.initial).charAt(0) || fallback.initial
return {
name,
initial,
title: normalizeString(source.title, fallback.title),
bio: normalizeString(source.bio, fallback.bio),
location: normalizeString(source.location, fallback.location),
joinDate: normalizeString(source.joinDate, fallback.joinDate),
email: normalizeString(source.email, fallback.email),
website: normalizeString(source.website, fallback.website),
github: normalizeString(source.github, fallback.github),
skills: normalizeStringArray(source.skills, fallback.skills),
channels: normalizeChannels(source.channels, fallback.channels),
}
}
function normalizeChannels(value: unknown, fallback: ProfileChannel[]): ProfileChannel[] {
if (!Array.isArray(value)) {
return cloneChannels(fallback)
}
const channels = value
.map((item) => normalizeChannel(item))
.filter((item): item is ProfileChannel => !!item)
return channels.length > 0 ? channels : cloneChannels(fallback)
}
function normalizeChannel(value: any): ProfileChannel | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null
}
const name = normalizeString(value.name)
if (!name) {
return null
}
const href = normalizeOptionalString(value.href ?? value.url)
const detail = normalizeString(value.detail ?? value.value, href ? stripProtocol(href) : '')
return {
name,
description: normalizeString(value.description),
detail,
href,
icon: normalizeIconKey(value.icon, name),
}
}
function normalizeIconKey(value: unknown, name: string): IconKey | undefined {
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase() as IconKey
if (PROFILE_ICON_KEYS.includes(normalized)) {
return normalized
}
}
return CHANNEL_ICON_BY_NAME[name] || CHANNEL_ICON_BY_NAME[name.toLowerCase()]
}
function extractAuthorPayload(payload: Record<string, any>): Record<string, any> {
if (payload.author && typeof payload.author === 'object' && !Array.isArray(payload.author)) {
return payload.author
}
return payload
}
function normalizeString(value: unknown, fallback = ''): string {
if (typeof value !== 'string') {
return fallback
}
const trimmed = value.trim()
return trimmed || fallback
}
function normalizeOptionalString(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined
}
const trimmed = value.trim()
return trimmed || undefined
}
function normalizeStringArray(value: unknown, fallback: string[]): string[] {
if (!Array.isArray(value)) {
return [...fallback]
}
const items = value
.map((item) => normalizeString(item))
.filter(Boolean)
return items.length > 0 ? items : [...fallback]
}
function stripProtocol(value: string): string {
return value.replace(/^https?:\/\//, '').replace(/\/$/, '')
}
function clampTimeout(timeoutMs: number): number {
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
return 3000
}
return Math.min(timeoutMs, 15000)
}
function cloneAuthor(author: AuthorProfile): AuthorProfile {
return {
...author,
skills: [...author.skills],
channels: cloneChannels(author.channels),
}
}
function cloneChannels(channels: ProfileChannel[]): ProfileChannel[] {
return channels.map((channel) => ({ ...channel }))
}
function cloneProject(project: ProfileProject): ProfileProject {
return {
...project,
techStack: [...project.techStack],
actions: project.actions.map((action) => ({ ...action })),
}
}
+2
View File
@@ -0,0 +1,2 @@
export { ProfilePage } from './ProfilePage'
export { AdminKeygenPage } from './AdminKeygenPage'
+24
View File
@@ -0,0 +1,24 @@
import type {
AuthorProfileConfig,
ProfileChannelConfig,
ProfileIconKey,
ProjectProfileActionConfig,
ProjectProfileConfig,
} from '../../config/profile.config'
export type ProfileChannel = ProfileChannelConfig
export type AuthorProfile = AuthorProfileConfig
export type ProfileAction = ProjectProfileActionConfig
export type ProfileProject = ProjectProfileConfig
export type IconKey = ProfileIconKey
export interface ProfileLoadMeta {
source: 'remote' | 'default'
message?: string
}
export interface ProfilePageData {
author: AuthorProfile
project: ProfileProject
meta: ProfileLoadMeta
}
@@ -0,0 +1,629 @@
import { useEffect, useRef, useState } from 'react'
import { Save, RotateCcw, Upload, Download } from 'lucide-react'
import { Card, Button, FormItem, Input, Select, Switch, ThemeSwitcher, toast, Modal, Progress } from '../../shared/components'
import { fetchSettings, saveSettings, resetSettings, initializeSystemData, exportSystemConfig, importSystemConfig } from './api'
import type { AppSettings } from './types'
import { defaultSettings } from './types'
import { EventsOn, EventsOff } from '../../wailsjs/runtime/runtime'
import { useBackupStore } from '../../store/backupStore'
interface BackupExportProgress {
phase: string
progress: number
message: string
componentId?: string
componentName?: string
entryIndex?: number
entryTotal?: number
timestamp?: string
}
interface BackupExportLogItem {
id: number
phase: string
time: string
text: string
}
export function SettingsPage() {
const [settings, setSettings] = useState<AppSettings>(defaultSettings)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [hasChanges, setHasChanges] = useState(false)
const [importModalOpen, setImportModalOpen] = useState(false)
const [actionLoading, setActionLoading] = useState<'none' | 'init' | 'export' | 'import-reset' | 'import-merge'>('none')
const [exportProgress, setExportProgress] = useState<BackupExportProgress | null>(null)
const [importProgress, setImportProgress] = useState<BackupExportProgress | null>(null)
const [exportLogs, setExportLogs] = useState<BackupExportLogItem[]>([])
const exportLogsRef = useRef<HTMLDivElement | null>(null)
const setImportState = useBackupStore((s) => s.setImportState)
const clearImportState = useBackupStore((s) => s.clearImportState)
useEffect(() => {
loadSettings()
}, [])
useEffect(() => {
const onExportProgress = (payload: BackupExportProgress) => {
if (!payload || typeof payload !== 'object') {
return
}
const phase = typeof payload.phase === 'string' ? payload.phase : 'writing'
if (phase === 'cancelled') {
setExportProgress(null)
setExportLogs([])
return
}
const progress = Number.isFinite(payload.progress) ? Math.max(0, Math.min(100, Math.round(payload.progress))) : 0
const message = typeof payload.message === 'string' && payload.message.trim() ? payload.message.trim() : '正在导出...'
const componentId = typeof payload.componentId === 'string' ? payload.componentId.trim() : ''
const componentName = typeof payload.componentName === 'string' ? payload.componentName.trim() : ''
const entryIndex = Number.isFinite(payload.entryIndex) ? Math.max(0, Math.round(payload.entryIndex || 0)) : 0
const entryTotal = Number.isFinite(payload.entryTotal) ? Math.max(0, Math.round(payload.entryTotal || 0)) : 0
const timestamp = typeof payload.timestamp === 'string' && payload.timestamp.trim()
? payload.timestamp.trim()
: new Date().toLocaleTimeString('zh-CN', { hour12: false })
setExportProgress({
phase,
progress,
message,
componentId: componentId || undefined,
componentName: componentName || undefined,
entryIndex: entryIndex || undefined,
entryTotal: entryTotal || undefined,
timestamp,
})
const prefix = componentName ? `[${componentName}] ` : componentId ? `[${componentId}] ` : ''
const text = `${prefix}${message}`
setExportLogs(prev => {
const last = prev[prev.length - 1]
if (last && last.text === text && last.phase === phase) {
return prev
}
const next = [...prev, { id: Date.now() + Math.floor(Math.random() * 1000), phase, time: timestamp, text }]
return next.length > 120 ? next.slice(next.length - 120) : next
})
}
EventsOn('backup:export:progress', onExportProgress)
return () => {
EventsOff('backup:export:progress')
}
}, [])
useEffect(() => {
const onImportProgress = (payload: BackupExportProgress) => {
if (!payload || typeof payload !== 'object') {
return
}
const phase = typeof payload.phase === 'string' ? payload.phase : 'importing'
if (phase === 'cancelled') {
setImportProgress(null)
return
}
const progress = Number.isFinite(payload.progress) ? Math.max(0, Math.min(100, Math.round(payload.progress))) : 0
const message = typeof payload.message === 'string' && payload.message.trim() ? payload.message.trim() : '正在加载配置...'
const componentId = typeof payload.componentId === 'string' ? payload.componentId.trim() : ''
const componentName = typeof payload.componentName === 'string' ? payload.componentName.trim() : ''
const entryIndex = Number.isFinite(payload.entryIndex) ? Math.max(0, Math.round(payload.entryIndex || 0)) : 0
const entryTotal = Number.isFinite(payload.entryTotal) ? Math.max(0, Math.round(payload.entryTotal || 0)) : 0
const timestamp = typeof payload.timestamp === 'string' && payload.timestamp.trim()
? payload.timestamp.trim()
: new Date().toLocaleTimeString('zh-CN', { hour12: false })
setImportProgress({
phase,
progress,
message,
componentId: componentId || undefined,
componentName: componentName || undefined,
entryIndex: entryIndex || undefined,
entryTotal: entryTotal || undefined,
timestamp,
})
}
EventsOn('backup:import:progress', onImportProgress)
return () => {
EventsOff('backup:import:progress')
}
}, [])
useEffect(() => {
const isImporting = actionLoading === 'import-reset' || actionLoading === 'import-merge'
if (isImporting) {
setImportState({
inProgress: true,
progress: importProgress?.progress ?? 0,
message: importProgress?.message || '正在加载配置...',
})
return
}
clearImportState()
}, [actionLoading, importProgress?.progress, importProgress?.message, setImportState, clearImportState])
useEffect(() => {
return () => {
clearImportState()
}
}, [clearImportState])
useEffect(() => {
if (!exportLogsRef.current) {
return
}
exportLogsRef.current.scrollTop = exportLogsRef.current.scrollHeight
}, [exportLogs])
const loadSettings = async () => {
setLoading(true)
try {
const data = await fetchSettings()
setSettings(data)
} finally {
setLoading(false)
}
}
const handleChange = <K extends keyof AppSettings>(key: K, value: AppSettings[K]) => {
setSettings(prev => ({ ...prev, [key]: value }))
setHasChanges(true)
}
const handleSave = async () => {
setSaving(true)
try {
const success = await saveSettings(settings)
if (success) {
setHasChanges(false)
toast.success('设置已保存')
}
} catch (error: any) {
toast.error(error?.message || '保存失败,请检查配置')
} finally {
setSaving(false)
}
}
const handleReset = async () => {
if (confirm('确定要重置所有设置吗?')) {
const data = await resetSettings()
setSettings(data)
setHasChanges(false)
}
}
const handleInitializeSystem = async () => {
if (!confirm('初始化会清空当前数据并恢复默认状态,是否继续?')) {
return
}
setActionLoading('init')
try {
const res = await initializeSystemData()
if (res.cancelled) {
toast.info('已取消初始化')
return
}
toast.success(res.message || '初始化完成')
} catch (error: any) {
toast.error(error?.message || '初始化失败')
} finally {
setActionLoading('none')
}
}
const handleExportSystem = async () => {
setActionLoading('export')
setExportLogs([])
setExportProgress({ phase: 'starting', progress: 0, message: '准备导出...' })
try {
const res = await exportSystemConfig()
if (res.cancelled) {
setExportProgress(null)
setExportLogs([])
toast.info('已取消导出')
return
}
setExportProgress(prev => prev?.phase === 'done'
? prev
: { phase: 'done', progress: 100, message: res.message || '导出完成' })
toast.success(res.message || '导出完成')
} catch (error: any) {
setExportProgress(prev => ({
phase: 'error',
progress: prev?.progress ?? 0,
message: error?.message || '导出失败',
}))
setExportLogs(prev => {
const timestamp = new Date().toLocaleTimeString('zh-CN', { hour12: false })
const text = error?.message || '导出失败'
const next = [...prev, { id: Date.now() + Math.floor(Math.random() * 1000), phase: 'error', time: timestamp, text }]
return next.length > 120 ? next.slice(next.length - 120) : next
})
toast.error(error?.message || '导出失败')
} finally {
setActionLoading('none')
}
}
const handleImportSystem = async (resetFirst: boolean) => {
setActionLoading(resetFirst ? 'import-reset' : 'import-merge')
setImportProgress({
phase: 'starting',
progress: 0,
message: resetFirst ? '等待选择 ZIP 配置(先初始化后加载)...' : '等待选择 ZIP 配置(判重合并)...',
})
try {
const res = await importSystemConfig(resetFirst)
if (res.cancelled) {
setImportProgress(null)
toast.info('已取消加载')
return
}
const imported = res.imported ?? 0
const skipped = res.skipped ?? 0
const conflicts = res.conflicts ?? 0
const componentFailed = Number.isFinite(res.componentFailed) ? Math.max(0, Math.round(res.componentFailed || 0)) : 0
const componentTotal = Number.isFinite(res.componentTotal) ? Math.max(0, Math.round(res.componentTotal || 0)) : 0
const failedComponents = Array.isArray(res.failedComponents) ? res.failedComponents : []
if (res.partial || componentFailed > 0) {
const moduleNames = failedComponents
.map(item => (item?.componentName || item?.componentId || '').trim())
.filter(Boolean)
const moduleHint = moduleNames.length > 0
? `${moduleNames.slice(0, 3).join('、')}${moduleNames.length > 3 ? `${moduleNames.length} 个模块` : ''}`
: ''
if (componentTotal > 0) {
const componentSuccess = Math.max(0, componentTotal - componentFailed)
toast.warning(`加载完成(部分成功):模块成功 ${componentSuccess}/${componentTotal},异常 ${componentFailed}${moduleHint}`)
} else {
toast.warning(`加载完成(部分成功):异常模块 ${componentFailed}${moduleHint}`)
}
} else {
toast.success(`加载完成:导入 ${imported},跳过 ${skipped},冲突 ${conflicts}`)
}
setImportModalOpen(false)
setImportProgress(null)
} catch (error: any) {
setImportProgress(prev => ({
phase: 'error',
progress: prev?.progress ?? 0,
message: error?.message || '加载失败',
}))
toast.error(error?.message || '加载失败')
} finally {
setActionLoading('none')
}
}
const importRunning = actionLoading === 'import-reset' || actionLoading === 'import-merge'
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="w-6 h-6 border-2 border-[var(--color-border-default)] border-t-[var(--color-accent)] rounded-full animate-spin" />
</div>
)
}
return (
<div className="space-y-6 w-full animate-fade-in">
{/* 页面标题 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-[var(--color-text-primary)]"></h1>
<p className="text-sm text-[var(--color-text-muted)] mt-1"></p>
</div>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={handleReset}>
<RotateCcw className="w-4 h-4" />
</Button>
<Button variant="danger" size="sm" onClick={handleSave} loading={saving} disabled={!hasChanges}>
<Save className="w-4 h-4" />
</Button>
</div>
</div>
{/* 主题设置 */}
<Card title="主题设置" subtitle="选择您喜欢的界面主题">
<ThemeSwitcher />
</Card>
{/* 基础设置 */}
<Card title="基础设置" subtitle="应用的基本信息配置">
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormItem label="应用名称" required>
<Input
value={settings.appName}
onChange={e => handleChange('appName', e.target.value)}
placeholder="请输入应用名称"
/>
</FormItem>
<FormItem label="语言">
<Select
value={settings.language}
onChange={e => handleChange('language', e.target.value)}
options={[
{ value: 'zh-CN', label: '简体中文' },
{ value: 'en-US', label: 'English' },
]}
/>
</FormItem>
</div>
<FormItem label="应用描述">
<Input
value={settings.appDescription}
onChange={e => handleChange('appDescription', e.target.value)}
placeholder="请输入应用描述"
/>
</FormItem>
</div>
</Card>
{/* 功能设置 */}
<Card title="功能设置" subtitle="启用或禁用特定功能">
<div className="space-y-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-[var(--color-text-primary)]"></p>
<p className="text-xs text-[var(--color-text-muted)] mt-0.5"></p>
</div>
<Switch
checked={settings.enableNotifications}
onChange={v => handleChange('enableNotifications', v)}
/>
</div>
<div className="h-px bg-[var(--color-border-muted)]" />
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-[var(--color-text-primary)]"></p>
<p className="text-xs text-[var(--color-text-muted)] mt-0.5"></p>
</div>
<Switch
checked={settings.enableAutoSave}
onChange={v => handleChange('enableAutoSave', v)}
/>
</div>
{settings.enableAutoSave && (
<div className="pl-4 border-l-2 border-[var(--color-border-muted)]">
<FormItem label="自动保存间隔(秒)">
<Input
type="number"
value={settings.autoSaveInterval}
onChange={e => handleChange('autoSaveInterval', parseInt(e.target.value) || 30)}
min={5}
max={300}
className="max-w-[120px]"
/>
</FormItem>
</div>
)}
<div className="h-px bg-[var(--color-border-muted)]" />
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-[var(--color-text-primary)]"></p>
<p className="text-xs text-[var(--color-text-muted)] mt-0.5"></p>
</div>
<Switch
checked={settings.cacheEnabled}
onChange={v => handleChange('cacheEnabled', v)}
/>
</div>
</div>
</Card>
{/* 高级设置 */}
<Card title="高级设置" subtitle="高级配置选项">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<FormItem label="最大上传大小(MB">
<Input
type="number"
value={settings.maxUploadSize}
onChange={e => handleChange('maxUploadSize', parseInt(e.target.value) || 10)}
min={1}
max={100}
/>
</FormItem>
<FormItem label="会话超时(分钟)">
<Input
type="number"
value={settings.sessionTimeout}
onChange={e => handleChange('sessionTimeout', parseInt(e.target.value) || 30)}
min={5}
max={120}
/>
</FormItem>
<FormItem label="日志级别">
<Select
value={settings.logLevel}
onChange={e => handleChange('logLevel', e.target.value as AppSettings['logLevel'])}
options={[
{ value: 'debug', label: 'Debug' },
{ value: 'info', label: 'Info' },
{ value: 'warn', label: 'Warning' },
{ value: 'error', label: 'Error' },
]}
/>
</FormItem>
</div>
</Card>
<Card title="配置备份与恢复" subtitle="初始化、导出、加载全量配置与浏览器数据">
<div className="space-y-3">
<p className="text-xs text-[var(--color-text-muted)]">
</p>
<div className="flex flex-wrap gap-2">
<Button
variant="danger"
size="sm"
onClick={handleInitializeSystem}
loading={actionLoading === 'init'}
>
<RotateCcw className="w-4 h-4" />
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleExportSystem}
loading={actionLoading === 'export'}
>
<Download className="w-4 h-4" />
</Button>
<Button
size="sm"
onClick={() => {
setImportProgress(null)
setImportModalOpen(true)
}}
>
<Upload className="w-4 h-4" />
</Button>
</div>
{exportProgress && (
<div className="rounded-md border border-[var(--color-border-default)] bg-[var(--color-bg-secondary)] px-3 py-2 space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-[var(--color-text-secondary)]">{exportProgress.message}</span>
{exportProgress.phase === 'error' && <span className="text-[var(--color-error)]"></span>}
{exportProgress.phase === 'done' && <span className="text-[var(--color-success)]"></span>}
{exportProgress.phase !== 'done' && exportProgress.phase !== 'error' && (
<span className="text-[var(--color-text-muted)]"></span>
)}
</div>
<div className="text-xs text-[var(--color-text-muted)]">
{' '}
{exportProgress.componentName || exportProgress.componentId || '准备中'}
{exportProgress.entryIndex && exportProgress.entryTotal
? `${exportProgress.entryIndex}/${exportProgress.entryTotal}`
: ''}
</div>
<Progress
percent={exportProgress.progress}
size="sm"
status={exportProgress.phase === 'error' ? 'error' : exportProgress.phase === 'done' ? 'success' : 'normal'}
/>
<div className="rounded border border-[var(--color-border-muted)] bg-[var(--color-bg-primary)] px-2 py-2">
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-[var(--color-text-secondary)]"></span>
<span className="text-[var(--color-text-muted)]">{exportLogs.length} </span>
</div>
<div ref={exportLogsRef} className="max-h-36 overflow-y-auto pr-1 space-y-1">
{exportLogs.length === 0 && (
<p className="text-xs text-[var(--color-text-muted)]">...</p>
)}
{exportLogs.map(item => (
<div key={item.id} className="text-xs leading-5 font-mono">
<span className="text-[var(--color-text-muted)] mr-2">{item.time}</span>
<span className={item.phase === 'error' ? 'text-[var(--color-error)]' : item.phase === 'done' ? 'text-[var(--color-success)]' : 'text-[var(--color-text-secondary)]'}>
{item.text}
</span>
</div>
))}
</div>
</div>
</div>
)}
</div>
</Card>
<Modal
open={importModalOpen}
onClose={() => {
if (actionLoading !== 'none') {
return
}
setImportModalOpen(false)
setImportProgress(null)
}}
title="加载配置"
width="520px"
closable={!importRunning}
footer={
<>
{!importRunning && (
<Button
variant="secondary"
onClick={() => {
setImportModalOpen(false)
setImportProgress(null)
}}
>
</Button>
)}
<Button
variant="danger"
onClick={() => handleImportSystem(true)}
loading={actionLoading === 'import-reset'}
disabled={actionLoading !== 'none' && actionLoading !== 'import-reset'}
>
</Button>
<Button
onClick={() => handleImportSystem(false)}
loading={actionLoading === 'import-merge'}
disabled={actionLoading !== 'none' && actionLoading !== 'import-merge'}
>
</Button>
</>
}
>
<div className="space-y-3 text-sm text-[var(--color-text-secondary)]">
<p> ZIP </p>
<p className="text-xs text-[var(--color-text-muted)]">
</p>
{importProgress && (
<div className="rounded-md border border-[var(--color-border-default)] bg-[var(--color-bg-secondary)] px-3 py-2 space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-[var(--color-text-secondary)]">{importProgress.message}</span>
{importProgress.phase === 'error' && <span className="text-[var(--color-error)]"></span>}
{importProgress.phase === 'done' && <span className="text-[var(--color-success)]"></span>}
{importProgress.phase !== 'done' && importProgress.phase !== 'error' && (
<span className="text-[var(--color-text-muted)]"></span>
)}
</div>
<Progress
percent={importProgress.progress}
size="sm"
status={importProgress.phase === 'error' ? 'error' : importProgress.phase === 'done' ? 'success' : 'normal'}
/>
{(importProgress.componentName || importProgress.componentId) && (
<div className="text-xs text-[var(--color-text-muted)]">
{' '}
{importProgress.componentName || importProgress.componentId}
{importProgress.entryIndex && importProgress.entryTotal
? `${importProgress.entryIndex}/${importProgress.entryTotal}`
: ''}
</div>
)}
</div>
)}
{importRunning && (
<p className="text-xs text-[var(--color-warning)]">
</p>
)}
</div>
</Modal>
</div>
)
}
+87
View File
@@ -0,0 +1,87 @@
// Settings 模块 API
import type { AppSettings } from './types'
import { defaultSettings } from './types'
// 本地存储 key
const SETTINGS_KEY = 'app_settings'
const getBindings = async () => {
try {
return await import('../../wailsjs/go/main/App')
} catch {
return null
}
}
export interface BackupActionResult {
cancelled?: boolean
message?: string
zipPath?: string
resetFirst?: boolean
imported?: number
skipped?: number
conflicts?: number
partial?: boolean
componentTotal?: number
componentSuccess?: number
componentFailed?: number
failedComponents?: Array<{
componentId?: string
componentName?: string
error?: string
}>
}
// 获取设置
export async function fetchSettings(): Promise<AppSettings> {
try {
const stored = localStorage.getItem(SETTINGS_KEY)
if (stored) {
return { ...defaultSettings, ...JSON.parse(stored) }
}
} catch (error) {
console.error('Failed to load settings:', error)
}
return defaultSettings
}
// 保存设置
export async function saveSettings(settings: AppSettings): Promise<boolean> {
try {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings))
return true
} catch (error) {
console.error('Failed to save settings:', error)
return false
}
}
// 重置设置
export async function resetSettings(): Promise<AppSettings> {
localStorage.removeItem(SETTINGS_KEY)
return defaultSettings
}
export async function initializeSystemData(): Promise<BackupActionResult> {
const bindings: any = await getBindings()
if (!bindings?.BackupInitializeSystem) {
return { cancelled: false, message: '当前环境不支持后端初始化接口' }
}
return (await bindings.BackupInitializeSystem()) || {}
}
export async function exportSystemConfig(): Promise<BackupActionResult> {
const bindings: any = await getBindings()
if (!bindings?.BackupExportPackage) {
return { cancelled: false, message: '当前环境不支持后端导出接口' }
}
return (await bindings.BackupExportPackage()) || {}
}
export async function importSystemConfig(resetFirst: boolean): Promise<BackupActionResult> {
const bindings: any = await getBindings()
if (!bindings?.BackupImportPackage) {
return { cancelled: false, message: '当前环境不支持后端加载接口' }
}
return (await bindings.BackupImportPackage(resetFirst)) || {}
}
+4
View File
@@ -0,0 +1,4 @@
// Settings 模块导出
export { SettingsPage } from './SettingsPage'
export * from './types'
export * from './api'
+45
View File
@@ -0,0 +1,45 @@
// Settings 模块类型定义
export interface AppSettings {
id?: number
// 基础设置
appName: string
appDescription: string
// 外观设置
theme: 'light' | 'dark' | 'system'
primaryColor: string
language: string
// 功能设置
enableNotifications: boolean
enableAutoSave: boolean
autoSaveInterval: number
// 高级设置
maxUploadSize: number
sessionTimeout: number
cacheEnabled: boolean
logLevel: 'debug' | 'info' | 'warn' | 'error'
// 运行时设置
maxMemoryMB: number
gcPercent: number
}
export const defaultSettings: AppSettings = {
appName: 'Ant Browser',
appDescription: '基于 Wails + React 的桌面应用',
theme: 'light',
primaryColor: '#3B82F6',
language: 'zh-CN',
enableNotifications: true,
enableAutoSave: true,
autoSaveInterval: 30,
maxUploadSize: 10,
sessionTimeout: 30,
cacheEnabled: true,
logLevel: 'info',
maxMemoryMB: 1024,
gcPercent: 100,
}