feat: add profile import export package

This commit is contained in:
ant-black
2026-06-23 23:40:05 +08:00
parent 9b8158e573
commit 4a696713cf
14 changed files with 766 additions and 27 deletions
+24
View File
@@ -1,5 +1,29 @@
# Changelog
## 1.3.0 - 2026-06-23
- 自动化增强:完善自动化脚本能力,支持更完整的脚本导入、运行、目标实例选择和执行记录管理。
- 插件管理:新增插件包管理能力,支持插件安装、导入、启停、删除、实例限制和单实例插件配置。
- VPN 优化:优化代理/VPN 连接链路,完善 Xray、sing-box、Mihomo 等连接栈的启动、测速、检测和预热能力。
- 实例迁移:支持浏览器实例导入导出,可将实例配置和用户数据打包迁移到新环境。
- 数据保留:实例导出包包含完整浏览器用户数据目录,可保留 Cookie、LocalStorage、IndexedDB、扩展本地数据等浏览器状态。
- 代理适配:实例导入时按代理名称匹配本地同名代理,匹配不到或同名不唯一时自动清空代理。
- 批量操作:实例列表支持批量导出、批量启动、批量停止、批量删除等操作,提升多实例管理效率。
- 单例操作:单个实例的“更多”菜单新增导出入口,便于快速迁移单个实例。
- 分组管理:完善实例分组、标签、关键字等管理能力,方便按业务场景组织实例。
- 文档中心:补充启动 API、自动化、插件管理、代理配置等说明,降低接入和排查成本。
- 状态检测:增强实例运行状态、调试端口、代理健康、测速结果等状态反馈。
- 界面优化:优化实例列表、关键字展示、操作菜单和导入导出入口,减少页面拥挤和无效信息。
## 1.2.0 - 2026-05-09
- 接口调用:Launch API 补齐实例增删改查、按 code / selector 启动、runtime session / status / stop 和统一 CDP 入口,方便外部系统直接调用浏览器能力。
- 自动化接口:脚本执行支持 selector / params 覆盖和 timeoutMs 超时控制,双实例 runtime 流程支持超时取消与错误返回。
- 代理池增强:新增链式代理导入、编辑和预览能力,支持 HTTP / SOCKS5 两层链路,并优化直连代理批量导入。
- 代理检测:新增测速目标、IP 健康检测目标和桥接启动超时配置,链式代理也可以参与测速与健康检测。
- 实例启动:代理异常时支持本次直连启动,不修改实例原有代理配置;默认代理池只保留直连节点。
- 书签能力:新增 IP 检测站点默认书签,支持设置启动时自动打开,并可同步到已有未运行实例。
## 1.1.0 - 2026-03-19
- 完善 Linux 支持:补齐 Linux 环境下的开发、打包、安装、启动与运行链路,并持续修复安装版启动与退出稳定性问题。
+13
View File
@@ -51,6 +51,15 @@ Ant Browser 适合以下场景:
## 近期更新
### 1.3.0 · 2026-06-23
- 自动化增强:完善自动化脚本导入、运行、目标实例选择和执行记录管理,提升多实例自动化编排能力
- 插件管理:新增插件包管理能力,支持插件安装、导入、启停、删除、实例限制和单实例插件配置
- VPN 优化:优化代理/VPN 连接链路,完善 Xray、sing-box、Mihomo 等连接栈的启动、测速、检测和预热能力
- 实例迁移:支持实例导入导出,可将实例配置和完整浏览器用户数据目录打包迁移到新环境
- 代理适配:实例导入时按代理名称匹配本地同名代理,匹配不到或同名不唯一时自动清空代理
- 界面优化:优化实例列表、关键字展示、操作菜单和导入导出入口,减少页面拥挤和无效信息
### 1.2.0 · 2026-05-09
- 重点升级接口调用:Launch API 补齐实例增删改查、按 code / selector 启动、runtime session / status / stop 和统一 CDP 入口,方便外部系统直接调用浏览器能力
@@ -83,6 +92,10 @@ Ant Browser 适合以下场景:
- 内核管理:支持维护多个 Chrome 内核版本,并设置默认内核
- 快捷启动:支持通过实例 Code 和 `Ctrl + K` 快速打开目标实例
- 标签与检索:支持按标签、关键字、状态、代理、内核、分组进行筛选
- 自动化脚本:支持脚本导入、运行、目标实例选择、执行记录和外部接口调用
- 插件管理:支持插件安装、导入、启停、删除、实例限制和单实例插件配置
- 实例迁移:支持将实例配置和浏览器用户数据目录导出为 ZIP,并导入为新实例
- VPN / 代理检测:支持连接栈预热、测速、IP 健康检测和代理异常处理
- 本地化存储:配置和实例数据保存在本地,适合长期使用和备份
## 界面预览
+536
View File
@@ -0,0 +1,536 @@
package backend
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"time"
"ant-chrome/backend/internal/browser"
"github.com/google/uuid"
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
const profilePackageFormat = "ant-chrome-profile-package"
type ProfilePackageManifest struct {
Format string `json:"format"`
Version int `json:"version"`
ExportedAt string `json:"exportedAt"`
ProfileCount int `json:"profileCount"`
}
type ProfilePackageExportResult struct {
Cancelled bool `json:"cancelled"`
ZipPath string `json:"zipPath"`
ProfileCount int `json:"profileCount"`
FileCount int `json:"fileCount"`
Message string `json:"message"`
}
type ProfilePackageImportResult struct {
Cancelled bool `json:"cancelled"`
ImportedCount int `json:"importedCount"`
ProfileMappings map[string]string `json:"profileMappings"`
Message string `json:"message"`
}
// BrowserProfilePackageExport 导出选中的实例配置和浏览器用户数据目录。
func (a *App) BrowserProfilePackageExport(profileIds []string) (ProfilePackageExportResult, error) {
a.maintenanceMu.Lock()
defer a.maintenanceMu.Unlock()
ids := normalizeProfilePackageIDs(profileIds)
if len(ids) == 0 {
return ProfilePackageExportResult{}, fmt.Errorf("请选择要导出的实例")
}
if a.ctx == nil {
return ProfilePackageExportResult{}, fmt.Errorf("应用上下文未初始化")
}
profiles, err := a.collectProfilesForPackage(ids)
if err != nil {
return ProfilePackageExportResult{}, err
}
defaultName := fmt.Sprintf("ant-chrome-profile-package-%s.zip", time.Now().Format("20060102-150405"))
savePath, err := wailsruntime.SaveFileDialog(a.ctx, wailsruntime.SaveDialogOptions{
Title: "导出实例",
DefaultFilename: defaultName,
Filters: []wailsruntime.FileFilter{
{DisplayName: "ZIP 文件 (*.zip)", Pattern: "*.zip"},
},
})
if err != nil {
return ProfilePackageExportResult{}, fmt.Errorf("打开保存对话框失败: %w", err)
}
if strings.TrimSpace(savePath) == "" {
return ProfilePackageExportResult{Cancelled: true, Message: "已取消导出"}, nil
}
savePath = ensureZipSuffix(savePath)
fileCount, err := a.writeProfilePackage(savePath, profiles)
if err != nil {
return ProfilePackageExportResult{}, err
}
return ProfilePackageExportResult{
Cancelled: false,
ZipPath: savePath,
ProfileCount: len(profiles),
FileCount: fileCount,
Message: "导出完成",
}, nil
}
// BrowserProfilePackageImport 导入实例包,冲突时始终生成新实例和新目录。
func (a *App) BrowserProfilePackageImport() (ProfilePackageImportResult, error) {
a.maintenanceMu.Lock()
defer a.maintenanceMu.Unlock()
if a.ctx == nil {
return ProfilePackageImportResult{}, fmt.Errorf("应用上下文未初始化")
}
zipPath, err := wailsruntime.OpenFileDialog(a.ctx, wailsruntime.OpenDialogOptions{
Title: "导入实例",
Filters: []wailsruntime.FileFilter{
{DisplayName: "ZIP 文件 (*.zip)", Pattern: "*.zip"},
},
})
if err != nil {
return ProfilePackageImportResult{}, fmt.Errorf("打开文件对话框失败: %w", err)
}
if strings.TrimSpace(zipPath) == "" {
return ProfilePackageImportResult{Cancelled: true, Message: "已取消导入"}, nil
}
return a.importProfilePackageFromPath(zipPath)
}
func (a *App) collectProfilesForPackage(profileIds []string) ([]browser.Profile, error) {
a.browserMgr.InitData()
a.browserMgr.Mutex.Lock()
defer a.browserMgr.Mutex.Unlock()
profiles := make([]browser.Profile, 0, len(profileIds))
missing := make([]string, 0)
running := make([]string, 0)
for _, id := range profileIds {
profile := a.browserMgr.Profiles[id]
if profile == nil {
missing = append(missing, id)
continue
}
if profile.Running {
running = append(running, profile.ProfileName)
continue
}
copyProfile := *profile
copyProfile.LaunchCode = ""
copyProfile.Running = false
copyProfile.DebugPort = 0
copyProfile.DebugReady = false
copyProfile.Pid = 0
copyProfile.RuntimeWarning = ""
copyProfile.LastError = ""
a.prepareProfileProxyForPackage(&copyProfile)
profiles = append(profiles, copyProfile)
}
if len(missing) > 0 {
return nil, fmt.Errorf("实例不存在: %s", strings.Join(missing, ", "))
}
if len(running) > 0 {
return nil, fmt.Errorf("请先停止实例再导出: %s", strings.Join(running, ", "))
}
return profiles, nil
}
func (a *App) writeProfilePackage(zipPath string, profiles []browser.Profile) (int, error) {
if err := os.MkdirAll(filepath.Dir(zipPath), 0o755); err != nil {
return 0, fmt.Errorf("创建导出目录失败: %w", err)
}
tmpPath := zipPath + ".tmp"
_ = os.Remove(tmpPath)
out, err := os.Create(tmpPath)
if err != nil {
return 0, fmt.Errorf("创建导出文件失败: %w", err)
}
zipWriter := zip.NewWriter(out)
fileCount := 0
writeErr := func() error {
manifest := ProfilePackageManifest{
Format: profilePackageFormat,
Version: 1,
ExportedAt: time.Now().Format(time.RFC3339),
ProfileCount: len(profiles),
}
if err := writeProfilePackageJSON(zipWriter, "manifest.json", manifest); err != nil {
return err
}
fileCount++
if err := writeProfilePackageJSON(zipWriter, "profiles.json", profiles); err != nil {
return err
}
fileCount++
for i := range profiles {
profile := &profiles[i]
userDataDir := a.browserMgr.ResolveUserDataDir(profile)
if _, err := os.Stat(userDataDir); err != nil {
if os.IsNotExist(err) {
continue
}
return fmt.Errorf("读取用户数据目录失败: %w", err)
}
added, err := writeProfilePackageDir(zipWriter, userDataDir, "user-data/"+profile.ProfileId)
if err != nil {
return fmt.Errorf("打包用户数据失败 [%s]: %w", profile.ProfileName, err)
}
fileCount += added
}
return nil
}()
closeZipErr := zipWriter.Close()
closeFileErr := out.Close()
if writeErr != nil {
_ = os.Remove(tmpPath)
return 0, writeErr
}
if closeZipErr != nil {
_ = os.Remove(tmpPath)
return 0, closeZipErr
}
if closeFileErr != nil {
_ = os.Remove(tmpPath)
return 0, closeFileErr
}
if err := os.Rename(tmpPath, zipPath); err != nil {
_ = os.Remove(tmpPath)
return 0, fmt.Errorf("保存导出文件失败: %w", err)
}
return fileCount, nil
}
func (a *App) importProfilePackageFromPath(zipPath string) (ProfilePackageImportResult, error) {
reader, err := zip.OpenReader(zipPath)
if err != nil {
return ProfilePackageImportResult{}, fmt.Errorf("打开实例包失败: %w", err)
}
defer reader.Close()
var manifest ProfilePackageManifest
if err := readProfilePackageJSON(reader.File, "manifest.json", &manifest); err != nil {
return ProfilePackageImportResult{}, err
}
if manifest.Format != profilePackageFormat || manifest.Version != 1 {
return ProfilePackageImportResult{}, fmt.Errorf("不支持的实例包格式")
}
var profiles []browser.Profile
if err := readProfilePackageJSON(reader.File, "profiles.json", &profiles); err != nil {
return ProfilePackageImportResult{}, err
}
if len(profiles) == 0 {
return ProfilePackageImportResult{}, fmt.Errorf("实例包为空")
}
a.browserMgr.InitData()
if a.config.App.MaxProfileLimit > 0 {
a.browserMgr.Mutex.Lock()
currentCount := len(a.browserMgr.Profiles)
a.browserMgr.Mutex.Unlock()
if currentCount+len(profiles) > a.config.App.MaxProfileLimit {
return ProfilePackageImportResult{}, fmt.Errorf("实例数量已达上限 (%d个),无法导入 %d 个实例", a.config.App.MaxProfileLimit, len(profiles))
}
}
now := time.Now().Format(time.RFC3339)
mappings := make(map[string]string, len(profiles))
prepared := make([]browser.Profile, 0, len(profiles))
for _, source := range profiles {
oldID := strings.TrimSpace(source.ProfileId)
if oldID == "" {
oldID = uuid.NewString()
}
newID := uuid.NewString()
source.ProfileId = newID
source.ProfileName = buildImportedProfileName(source.ProfileName)
source.UserDataDir = newID
source.Running = false
source.DebugPort = 0
source.DebugReady = false
source.Pid = 0
source.RuntimeWarning = ""
source.LastError = ""
source.LaunchCode = ""
source.CreatedAt = now
source.UpdatedAt = now
source.DeletedAt = ""
a.applyImportedProfileProxyByName(&source)
prepared = append(prepared, source)
mappings[oldID] = newID
}
for _, profile := range prepared {
if err := a.extractProfileUserData(reader.File, mappings, profile.ProfileId); err != nil {
return ProfilePackageImportResult{}, err
}
}
a.browserMgr.Mutex.Lock()
for i := range prepared {
profile := &prepared[i]
a.browserMgr.Profiles[profile.ProfileId] = profile
if a.launchCodeSvc != nil {
if code, err := a.launchCodeSvc.EnsureCode(profile.ProfileId); err == nil {
profile.LaunchCode = code
}
}
}
a.browserMgr.Mutex.Unlock()
if err := a.browserMgr.SaveProfiles(); err != nil {
return ProfilePackageImportResult{}, err
}
return ProfilePackageImportResult{
Cancelled: false,
ImportedCount: len(prepared),
ProfileMappings: mappings,
Message: "导入完成",
}, nil
}
func (a *App) extractProfileUserData(files []*zip.File, mappings map[string]string, newProfileID string) error {
oldProfileID := ""
for oldID, mappedID := range mappings {
if mappedID == newProfileID {
oldProfileID = oldID
break
}
}
if oldProfileID == "" {
return fmt.Errorf("实例映射不存在: %s", newProfileID)
}
profile := &browser.Profile{ProfileId: newProfileID, UserDataDir: newProfileID}
destDir := a.browserMgr.ResolveUserDataDir(profile)
if err := os.RemoveAll(destDir); err != nil {
return fmt.Errorf("清理用户数据目录失败: %w", err)
}
if err := os.MkdirAll(destDir, 0o755); err != nil {
return fmt.Errorf("创建用户数据目录失败: %w", err)
}
prefix := "user-data/" + oldProfileID + "/"
for _, file := range files {
name := filepath.ToSlash(file.Name)
if !strings.HasPrefix(name, prefix) {
continue
}
rel := strings.TrimPrefix(name, prefix)
if rel == "" {
continue
}
if err := extractProfilePackageFile(file, destDir, rel); err != nil {
return err
}
}
return nil
}
func writeProfilePackageJSON(zipWriter *zip.Writer, name string, value any) error {
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
return err
}
writer, err := zipWriter.Create(name)
if err != nil {
return err
}
_, err = writer.Write(data)
return err
}
func writeProfilePackageDir(zipWriter *zip.Writer, srcDir string, destPrefix string) (int, error) {
count := 0
err := filepath.WalkDir(srcDir, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
rel, err := filepath.Rel(srcDir, path)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
if rel == "." {
return nil
}
zipName := filepath.ToSlash(filepath.Join(destPrefix, rel))
if entry.IsDir() {
_, err := zipWriter.Create(strings.TrimSuffix(zipName, "/") + "/")
return err
}
writer, err := zipWriter.Create(zipName)
if err != nil {
return err
}
file, err := os.Open(path)
if err != nil {
return err
}
_, copyErr := io.Copy(writer, file)
closeErr := file.Close()
if copyErr != nil {
return copyErr
}
if closeErr != nil {
return closeErr
}
count++
return nil
})
return count, err
}
func readProfilePackageJSON(files []*zip.File, name string, target any) error {
for _, file := range files {
if filepath.ToSlash(file.Name) != name {
continue
}
reader, err := file.Open()
if err != nil {
return err
}
defer reader.Close()
return json.NewDecoder(reader).Decode(target)
}
return fmt.Errorf("实例包缺少 %s", name)
}
func extractProfilePackageFile(file *zip.File, destDir string, rel string) error {
cleanRel := filepath.Clean(filepath.FromSlash(rel))
if cleanRel == "." || strings.HasPrefix(cleanRel, "..") || filepath.IsAbs(cleanRel) {
return fmt.Errorf("非法路径: %s", rel)
}
target := filepath.Join(destDir, cleanRel)
cleanDest := filepath.Clean(destDir)
cleanTarget := filepath.Clean(target)
if cleanTarget != cleanDest && !strings.HasPrefix(cleanTarget, cleanDest+string(os.PathSeparator)) {
return fmt.Errorf("非法路径: %s", rel)
}
if file.FileInfo().IsDir() {
return os.MkdirAll(target, 0o755)
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
reader, err := file.Open()
if err != nil {
return err
}
defer reader.Close()
out, err := os.Create(target)
if err != nil {
return err
}
_, copyErr := io.Copy(out, reader)
closeErr := out.Close()
if copyErr != nil {
return copyErr
}
return closeErr
}
func normalizeProfilePackageIDs(ids []string) []string {
seen := make(map[string]struct{}, len(ids))
result := make([]string, 0, len(ids))
for _, id := range ids {
id = strings.TrimSpace(id)
if id == "" {
continue
}
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
result = append(result, id)
}
sort.Strings(result)
return result
}
func ensureZipSuffix(path string) string {
if strings.EqualFold(filepath.Ext(path), ".zip") {
return path
}
return path + ".zip"
}
func buildImportedProfileName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
name = "导入实例"
}
return name + "(导入)"
}
func (a *App) prepareProfileProxyForPackage(profile *browser.Profile) {
if profile == nil {
return
}
proxyName := strings.TrimSpace(profile.ProxyBindName)
if proxyName == "" {
if proxy, ok := a.browserMgr.GetProxyByID(profile.ProxyId); ok {
proxyName = strings.TrimSpace(proxy.ProxyName)
}
}
profile.ProxyId = ""
profile.ProxyConfig = ""
profile.ProxyBindSourceID = ""
profile.ProxyBindSourceURL = ""
profile.ProxyBindName = proxyName
profile.ProxyBindUpdatedAt = ""
}
func (a *App) applyImportedProfileProxyByName(profile *browser.Profile) {
if profile == nil {
return
}
proxyName := strings.TrimSpace(profile.ProxyBindName)
profile.ProxyId = ""
profile.ProxyConfig = ""
profile.ProxyBindSourceID = ""
profile.ProxyBindSourceURL = ""
profile.ProxyBindUpdatedAt = ""
if proxyName == "" {
profile.ProxyBindName = ""
return
}
if proxy, ok := a.findUniqueProxyByName(proxyName); ok {
browser.BindProfileToProxy(profile, proxy, true)
return
}
profile.ProxyBindName = ""
}
func (a *App) findUniqueProxyByName(proxyName string) (browser.Proxy, bool) {
target := strings.ToLower(strings.TrimSpace(proxyName))
if target == "" {
return browser.Proxy{}, false
}
proxies := browser.ListProxiesWithFallback(a.browserMgr.ProxyDAO, a.config.Browser.Proxies)
var hit browser.Proxy
matched := 0
for _, proxy := range proxies {
if strings.ToLower(strings.TrimSpace(proxy.ProxyName)) != target {
continue
}
hit = proxy
matched++
if matched > 1 {
return browser.Proxy{}, false
}
}
return hit, matched == 1
}
+34 -1
View File
@@ -1,6 +1,12 @@
import { applyBrowserProfileCopyOptionsToArgs, createBrowserProfileCopyOptions } from '../copyOptions'
import { buildBrowserProfileCopyName } from '../copyName'
import type { BrowserProfile, BrowserProfileCopyOptions, BrowserProfileInput } from '../types'
import type {
BrowserProfile,
BrowserProfileCopyOptions,
BrowserProfileInput,
BrowserProfilePackageExportResult,
BrowserProfilePackageImportResult,
} from '../types'
import { getBindings, getMockProfiles, nowISOString, setMockProfiles } from './runtime'
export async function fetchBrowserProfiles(): Promise<BrowserProfile[]> {
@@ -38,6 +44,33 @@ export async function fetchAllTags(): Promise<string[]> {
return Array.from(tags).sort()
}
export async function exportBrowserProfilePackage(profileIds: string[]): Promise<BrowserProfilePackageExportResult> {
const bindings: any = await getBindings()
if (bindings?.BrowserProfilePackageExport) {
return await bindings.BrowserProfilePackageExport(profileIds)
}
return {
cancelled: true,
zipPath: '',
profileCount: 0,
fileCount: 0,
message: '当前环境不支持导出实例',
}
}
export async function importBrowserProfilePackage(): Promise<BrowserProfilePackageImportResult> {
const bindings: any = await getBindings()
if (bindings?.BrowserProfilePackageImport) {
return await bindings.BrowserProfilePackageImport()
}
return {
cancelled: true,
importedCount: 0,
profileMappings: {},
message: '当前环境不支持导入实例',
}
}
export async function createBrowserProfile(input: BrowserProfileInput): Promise<BrowserProfile | null> {
const bindings: any = await getBindings()
if (bindings?.BrowserProfileCreate) {
@@ -1,5 +1,5 @@
import { Link } from 'react-router-dom'
import { Activity, CheckCircle, ChevronRight, ChevronUp, Edit2, FileText, Gift, LayoutGrid, List, Play, Plus, RefreshCw, Sliders, Square, Star, Trash2, XCircle } from 'lucide-react'
import { Activity, CheckCircle, ChevronRight, ChevronUp, Edit2, FileText, Gift, LayoutGrid, List, Play, Plus, RefreshCw, Sliders, Square, Star, Trash2, Upload, XCircle } from 'lucide-react'
import { Button, Card, FormItem, Input, Modal, StatCard, Switch, Table, Textarea } from '../../../shared/components'
import type { TableColumn } from '../../../shared/components/Table'
@@ -27,6 +27,8 @@ interface BrowserListHeaderProps {
onOpenSettings: () => void
onOpenExpandModal: () => void
onOpenTrash: () => void
onImportProfiles: () => void
importingProfiles?: boolean
onViewModeChange: (next: BrowserViewMode) => void
}
@@ -47,6 +49,8 @@ export function BrowserListHeader({
onOpenSettings,
onOpenExpandModal,
onOpenTrash,
onImportProfiles,
importingProfiles = false,
onViewModeChange,
}: BrowserListHeaderProps) {
return (
@@ -75,6 +79,9 @@ export function BrowserListHeader({
<Button variant="secondary" size="sm" onClick={onOpenTrash}>
<Trash2 className="w-4 h-4" />
</Button>
<Button variant="secondary" size="sm" onClick={onImportProfiles} loading={importingProfiles}>
<Upload className="w-4 h-4" />
</Button>
<Button
variant="secondary"
size="sm"
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { ChevronDown, ChevronUp, Copy, Pencil, Play, RefreshCw, Square, Trash2 } from 'lucide-react'
import { ChevronDown, ChevronUp, Copy, Download, Pencil, Play, RefreshCw, Square, Trash2 } from 'lucide-react'
import { Button, toast } from '../../../shared/components'
import { regenerateBrowserProfileCode, setBrowserProfileCode } from '../api'
@@ -11,8 +11,10 @@ interface BatchToolbarProps {
onDeselectAll: () => void
onBatchStart: () => void
onBatchStop: () => void
onBatchExport: () => void
onBatchDelete: () => void
batchLoading: boolean
exporting?: boolean
}
export function BatchToolbar({
@@ -22,8 +24,10 @@ export function BatchToolbar({
onDeselectAll,
onBatchStart,
onBatchStop,
onBatchExport,
onBatchDelete,
batchLoading,
exporting = false,
}: BatchToolbarProps) {
if (selectedCount === 0) return null
@@ -39,6 +43,9 @@ export function BatchToolbar({
<Button size="sm" variant="secondary" onClick={onBatchStop} loading={batchLoading} title="批量停止">
<Square className="w-3.5 h-3.5" />
</Button>
<Button size="sm" variant="secondary" onClick={onBatchExport} loading={exporting} title="导出实例">
<Download className="w-3.5 h-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
@@ -151,16 +158,16 @@ export function KeywordInlineRow({ keywords }: KeywordInlineRowProps) {
}
return (
<div className="flex items-start gap-4 w-full">
<div className="flex items-start gap-4 w-full min-w-0">
<div
ref={containerRef}
className={`flex flex-wrap gap-2 flex-1 transition-all duration-300 ${expanded ? '' : 'overflow-hidden max-h-[32px]'}`}
className={`flex flex-wrap gap-2 flex-1 min-w-0 transition-all duration-300 ${expanded ? '' : 'overflow-hidden max-h-[32px]'}`}
>
{keywords.map((keyword, index) => (
<button
type="button"
key={index}
className="inline-flex max-w-[200px] items-center gap-1.5 rounded-md border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] px-2.5 py-1 text-left text-xs text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-accent)] hover:text-[var(--color-accent)] focus:outline-none focus:ring-1 focus:ring-[var(--color-accent)]"
className="inline-flex max-w-full min-w-0 items-center gap-1.5 rounded-md border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] px-2.5 py-1 text-left text-xs text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-accent)] hover:text-[var(--color-accent)] focus:outline-none focus:ring-1 focus:ring-[var(--color-accent)]"
title={`点击复制:${keyword}`}
onClick={() => { void handleCopyKeyword(keyword) }}
>
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Link } from 'react-router-dom'
import { Copy, Key, Loader2, MoreHorizontal, Play, Puzzle, Repeat2, RotateCcw, Settings, Square, Trash2, Wifi } from 'lucide-react'
import { Copy, Download, Key, Loader2, MoreHorizontal, Play, Puzzle, Repeat2, RotateCcw, Settings, Square, Trash2, Wifi } from 'lucide-react'
import { Badge, Button, Card, Table } from '../../../shared/components'
import type { TableColumn } from '../../../shared/components/Table'
@@ -39,6 +39,7 @@ interface BrowserProfilesPanelProps {
onRestart: (profileId: string) => void
onOpenKeywords: (profile: BrowserProfile) => void
onOpenExtensions: (profile: BrowserProfile) => void
onExport: (profile: BrowserProfile) => void
onOpenCopy: (profile: BrowserProfile) => void
onOpenProxyPicker: (profile: BrowserProfile) => void
onDelete: (profileId: string) => void
@@ -151,6 +152,7 @@ function ProfileMoreActions({
onRestart,
onOpenKeywords,
onOpenExtensions,
onExport,
}: {
open: boolean
disabled: boolean
@@ -159,6 +161,7 @@ function ProfileMoreActions({
onRestart: () => void
onOpenKeywords: () => void
onOpenExtensions: () => void
onExport: () => void
}) {
const triggerRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
@@ -170,7 +173,7 @@ function ProfileMoreActions({
const rect = triggerRef.current?.getBoundingClientRect()
if (!rect) return
const menuWidth = 128
const menuHeight = 128
const menuHeight = 168
const gap = 8
const left = Math.max(8, Math.min(rect.right - menuWidth, window.innerWidth - menuWidth - 8))
const belowTop = rect.bottom + gap
@@ -246,6 +249,14 @@ function ProfileMoreActions({
<Puzzle className="w-3.5 h-3.5" />
</button>
<button
type="button"
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-muted)] hover:text-[var(--color-text-primary)]"
onClick={() => runAndClose(onExport)}
>
<Download className="w-3.5 h-3.5" />
</button>
</div>,
document.body
)}
@@ -400,6 +411,7 @@ export function BrowserProfilesPanel({
onRestart,
onOpenKeywords,
onOpenExtensions,
onExport,
onOpenCopy,
onOpenProxyPicker,
onDelete,
@@ -528,6 +540,7 @@ export function BrowserProfilesPanel({
onRestart={() => onRestart(record.profileId)}
onOpenKeywords={() => onOpenKeywords(record)}
onOpenExtensions={() => onOpenExtensions(record)}
onExport={() => onExport(record)}
/>
<Button size="sm" variant="ghost" onClick={() => onDelete(record.profileId)} title="删除" disabled={isBusy}><Trash2 className="w-3.5 h-3.5 text-red-500" /></Button>
</div>
@@ -27,17 +27,17 @@ export function KeywordsExpandRow({ keywords, colSpan }: Props) {
{!keywords?.length ? (
<span className="text-xs text-[var(--color-text-muted)]">-</span>
) : (
<div className="flex items-start gap-4">
<div className="flex items-start gap-4 min-w-0">
<div
ref={containerRef}
className={`flex flex-wrap gap-2 flex-1 transition-all duration-300 ${expanded ? '' : 'overflow-hidden max-h-[32px]'}`}
className={`flex flex-wrap gap-2 flex-1 min-w-0 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
className="inline-flex max-w-full min-w-0 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]"
text-[var(--color-text-secondary)]"
title={kw}
>
<span className="text-[var(--color-text-muted)] font-mono shrink-0">{i + 1}.</span>
@@ -18,7 +18,9 @@ import { warmupProfileProxyBeforeStart } from '../utils/proxyWarmup'
import {
copyBrowserProfile,
deleteBrowserProfile,
exportBrowserProfilePackage,
fetchBrowserProfileTrash,
importBrowserProfilePackage,
permanentlyDeleteBrowserProfile,
restoreBrowserProfile,
startBrowserInstance,
@@ -40,6 +42,7 @@ export function BrowserListPage() {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [batchLoading, setBatchLoading] = useState(false)
const [profilePackageBusy, setProfilePackageBusy] = useState(false)
const [deleteConfirm, setDeleteConfirm] = useState<{
open: boolean
mode: 'single' | 'batch'
@@ -259,6 +262,62 @@ export function BrowserListPage() {
loadProfiles()
}
const handleBatchExport = async () => {
const ids = Array.from(selectedIds)
if (ids.length === 0 || profilePackageBusy) return
const runningNames = profiles
.filter(profile => ids.includes(profile.profileId) && profile.running)
.map(profile => profile.profileName)
if (runningNames.length > 0) {
toast.error(`请先停止实例再导出:${runningNames.slice(0, 3).join('、')}${runningNames.length > 3 ? ' 等' : ''}`)
return
}
setProfilePackageBusy(true)
try {
const result = await exportBrowserProfilePackage(ids)
if (result.cancelled) return
toast.success(`已导出 ${result.profileCount} 个实例`)
} catch (error: any) {
toast.error(error?.message || '导出实例失败')
} finally {
setProfilePackageBusy(false)
}
}
const handleExportProfile = async (profile: BrowserProfile) => {
if (profilePackageBusy) return
if (profile.running) {
toast.error(`请先停止实例再导出:${profile.profileName}`)
return
}
setProfilePackageBusy(true)
try {
const result = await exportBrowserProfilePackage([profile.profileId])
if (result.cancelled) return
toast.success(`已导出:${profile.profileName}`)
} catch (error: any) {
toast.error(error?.message || '导出实例失败')
} finally {
setProfilePackageBusy(false)
}
}
const handleImportProfiles = async () => {
if (profilePackageBusy) return
setProfilePackageBusy(true)
try {
const result = await importBrowserProfilePackage()
if (result.cancelled) return
toast.success(`已导入 ${result.importedCount} 个实例`)
setSelectedIds(new Set())
await loadProfiles()
} catch (error: any) {
toast.error(error?.message || '导入实例失败')
} finally {
setProfilePackageBusy(false)
}
}
const openDeleteConfirm = (profileId: string) => {
const profile = profiles.find(item => item.profileId === profileId)
setDeleteConfirm({
@@ -419,6 +478,8 @@ export function BrowserListPage() {
onRefresh={() => { void loadProfiles() }}
onOpenSettings={handleOpenSettings}
onOpenTrash={openTrashModal}
onImportProfiles={handleImportProfiles}
importingProfiles={profilePackageBusy}
onOpenExpandModal={() => {
setExpandModalOpen(true)
loadQuota()
@@ -434,8 +495,10 @@ export function BrowserListPage() {
onDeselectAll={handleDeselectAll}
onBatchStart={handleBatchStart}
onBatchStop={handleBatchStop}
onBatchExport={handleBatchExport}
onBatchDelete={openBatchDeleteConfirm}
batchLoading={batchLoading}
exporting={profilePackageBusy}
/>
<BrowserProfilesPanel
@@ -459,6 +522,7 @@ export function BrowserListPage() {
onRestart={(profileId) => { void handleRestart(profileId) }}
onOpenKeywords={openKwModal}
onOpenExtensions={openExtensionModal}
onExport={(profile) => { void handleExportProfile(profile) }}
onOpenCopy={openCopyModal}
onOpenProxyPicker={setProxyPickerProfile}
onDelete={openDeleteConfirm}
@@ -1,22 +1,22 @@
export const DOC_CHANGELOG = `# 更新日志
## 1.3.0 - 2026-06-18
## 1.3.0 - 2026-06-23
- 插件包管理:支持商店链接 / 插件 ID 安装、本地插件包导入、本地插件目录导入、插件启停、删除和安装历史
- 插件实例限制:限制实例弹窗按分组展示实例,支持整组选择 / 取消,并显示每组已选数量
- 单实例插件配置:实例列表可进入单实例插件配置,支持从全局继承切换为独立插件清单
- 代理核心管理:补齐 xray、sing-box、mihomo 运行核心检测、下载、清理和启动锁处理
- 内核全局设置:全局设置只读字段统一为一致的灰底展示,减少普通值和多行参数的样式割裂
- 文档中心:补齐插件包管理、操作流程和全局设置说明,并接回脚本自动化章节内容
- 自动化增强:完善自动化脚本导入、运行、目标实例选择和执行记录管理
- 插件管理:支持插件安装、导入、启停、删除、实例限制和单实例插件配置
- VPN 优化:优化 Xray、sing-box、Mihomo 等连接栈的启动、测速、检测和预热能力
- 实例迁移:支持实例导入导出,可将实例配置和完整浏览器用户数据目录打包迁移到新环境
- 代理适配:实例导入时按代理名称匹配本地同名代理,匹配不到或同名不唯一时自动清空代理
- 界面优化:优化实例列表、关键字展示、操作菜单和导入导出入口
## 1.2.0 - 2026-05-05
## 1.2.0 - 2026-05-09
- 文档中心改版:把使用教程、内核、代理、接口和排障内容收进同一个入口,接口详情改为按章节逐步查看
- Launch API 完整化:补齐实例增删改查、按 code / selector 启动、runtime session、runtime status、runtime stop 和统一 CDP 入口
- 自动化脚本中心:支持脚本列表、详情、执行和运行记录;脚本可复用默认 selector / params,也可在执行时覆盖
- OpenClaw 对接:新增 ant-chrome-openclaw skill、安装脚本、HTTP 调用参考和同机远程 CDP 接管流程
- 代理能力增强:代理池支持更多导入与检测场景,补齐链式代理编辑、IP 健康检查和测速链路
- 工程拆分:实例启动、备份恢复、自动化运行时、发布脚本等模块拆分,减少单文件堆叠,便于后续维护
- 接口调用:Launch API 补齐实例增删改查、按 code / selector 启动、runtime session / status / stop 和统一 CDP 入口
- 自动化接口:脚本执行支持 selector / params 覆盖和 timeoutMs 超时控制,双实例 runtime 流程支持超时取消与错误返回
- 代理池增强:新增链式代理导入、编辑和预览能力,支持 HTTP / SOCKS5 两层链路,并优化直连代理批量导入
- 代理检测:新增测速目标、IP 健康检测目标和桥接启动超时配置,链式代理也可以参与测速与健康检测
- 实例启动:代理异常时支持本次直连启动,不修改实例原有代理配置;默认代理池只保留直连节点
- 书签能力:新增 IP 检测站点默认书签,支持设置启动时自动打开,并可同步到已有未运行实例
## 1.1.0 - 2026-03-19
@@ -7,6 +7,7 @@
代理池配置 -> 导入 / 添加代理
插件包管理 -> 安装 / 导入插件
实例列表 -> 新建实例 -> 选择内核 / 代理 -> 启动
实例列表 -> 导入实例 / 选中实例 -> 导出
\`\`\`
## 最小上手顺序
@@ -18,6 +19,19 @@
5. 打开 \`指纹浏览器 > 实例列表\`,新建实例并按需配置插件
6. 保存后直接启动
## 实例迁移
\`\`\`text
导出:实例列表 -> 勾选实例 -> 导出
单个导出:实例列表 -> 更多 -> 导出
导入:实例列表 -> 导入实例 -> 选择 ZIP
\`\`\`
- 导出前先停止实例,避免浏览器数据文件被占用
- 导出包包含实例配置和完整浏览器用户数据目录
- 导入时会生成新实例,不覆盖当前已有实例
- 代理只按名称适配本地同名代理,未匹配时自动清空代理
## 最小 HTTP 对接
\`\`\`bash
@@ -74,6 +88,7 @@ export const DOC_OPERATION_FLOW = `# 操作流程
3. 插件包管理:安装插件,按需限制实例
4. 实例列表:创建实例并选择内核、代理、分组
5. 启动实例:确认 debugReady 后再接管
6. 迁移实例:停止实例后导出 ZIP,新环境中导入为新实例
\`\`\`
## 插件接入流程
+15
View File
@@ -41,6 +41,21 @@ export interface BrowserProfileInput {
groupId?: string
}
export interface BrowserProfilePackageExportResult {
cancelled: boolean
zipPath: string
profileCount: number
fileCount: number
message: string
}
export interface BrowserProfilePackageImportResult {
cancelled: boolean
importedCount: number
profileMappings: Record<string, string>
message: string
}
export type BrowserProfileCopyMode = 'auto_fingerprint' | 'regular'
export type BrowserProfileAutomationTarget =
+5 -1
View File
@@ -1,4 +1,4 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {automation} from '../models';
import {backend} from '../models';
@@ -172,6 +172,10 @@ export function BrowserProfileList():Promise<Array<browser.Profile>>;
export function BrowserProfileListByTag(arg1:string):Promise<Array<browser.Profile>>;
export function BrowserProfilePackageExport(arg1:Array<string>):Promise<any>;
export function BrowserProfilePackageImport():Promise<any>;
export function BrowserProfilePermanentlyDelete(arg1:string):Promise<void>;
export function BrowserProfileRegenerateCode(arg1:string):Promise<string>;
+8
View File
@@ -326,6 +326,14 @@ export function BrowserProfileListByTag(arg1) {
return window['go']['main']['App']['BrowserProfileListByTag'](arg1);
}
export function BrowserProfilePackageExport(arg1) {
return window['go']['main']['App']['BrowserProfilePackageExport'](arg1);
}
export function BrowserProfilePackageImport() {
return window['go']['main']['App']['BrowserProfilePackageImport']();
}
export function BrowserProfilePermanentlyDelete(arg1) {
return window['go']['main']['App']['BrowserProfilePermanentlyDelete'](arg1);
}