mirror of
https://github.com/black-ant/Ant-Browser.git
synced 2026-07-14 18:48:55 +08:00
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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> 唤起实例,适合 Playwright、Selenium、自研调度器等自动化流程。
|
||||
</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>指纹浏览器 > 内核管理</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>指纹浏览器 > 代理池配置</code>。</p>
|
||||
<p>你可以逐条添加代理,也可以通过 YAML 批量导入。</p>
|
||||
<p>保存后,实例编辑页里即可选择这些代理节点。</p>
|
||||
</StepCard>
|
||||
|
||||
<StepCard icon={<Monitor className="w-4 h-4" />} title="4) 创建实例并启动">
|
||||
<p>进入 <code>指纹浏览器 > 实例列表</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'
|
||||
@@ -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: '模拟高配游戏 PC,NVIDIA 显卡,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 GPU,Retina 分辨率',
|
||||
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';
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 未就绪' }
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Dashboard 模块导出
|
||||
export { DashboardPage } from './DashboardPage'
|
||||
export * from './types'
|
||||
export * from './api'
|
||||
@@ -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(/\/$/, '')
|
||||
}
|
||||
@@ -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 })),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ProfilePage } from './ProfilePage'
|
||||
export { AdminKeygenPage } from './AdminKeygenPage'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)) || {}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Settings 模块导出
|
||||
export { SettingsPage } from './SettingsPage'
|
||||
export * from './types'
|
||||
export * from './api'
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user