feat: improve logging notifications and filters

This commit is contained in:
ant-black
2026-06-18 23:36:57 +08:00
parent 59cbe36ca0
commit bf85bc122c
16 changed files with 589 additions and 110 deletions
+21
View File
@@ -0,0 +1,21 @@
package backend
import "ant-chrome/backend/internal/logger"
// FrontendOperationLog records frontend-triggered Wails operation results into the app log.
func (a *App) FrontendOperationLog(level string, method string, success bool, durationMs int64, message string) {
log := logger.New("Frontend")
fields := []logger.Field{
logger.F("method", method),
logger.F("success", success),
logger.F("duration_ms", durationMs),
}
if message != "" {
fields = append(fields, logger.F("message", message))
}
if !success || level == "error" || level == "ERROR" {
log.Error("前端操作失败", fields...)
return
}
log.Info("前端操作完成", fields...)
}
+18 -1
View File
@@ -1,10 +1,11 @@
package logger
import (
"strings"
"sync"
)
const defaultMemoryBufferSize = 500
const defaultMemoryBufferSize = 2000
// MemoryLogEntry 内存日志条目(供前端消费)
type MemoryLogEntry struct {
@@ -40,6 +41,9 @@ func (w *MemoryWriter) Write(entry *LogEntry) error {
if entry == nil {
return nil
}
if shouldSkipMemoryLog(entry) {
return nil
}
w.mu.Lock()
defer w.mu.Unlock()
@@ -57,6 +61,19 @@ func (w *MemoryWriter) Write(entry *LogEntry) error {
return nil
}
func shouldSkipMemoryLog(entry *LogEntry) bool {
if entry.Level != INFO || entry.Component != "Xray" {
return false
}
message := strings.TrimSpace(entry.Message)
switch message {
case "xray 内核进程已启动", "回收空闲桥接进程", "复用 xray 桥接进程", "复用已就绪 xray 桥接进程":
return true
default:
return false
}
}
func (w *MemoryWriter) Close() error { return nil }
// GetEntries 返回所有缓冲日志(最新在后)
+44
View File
@@ -8,6 +8,7 @@ import { AppRoutes } from "./routes/AppRoutes";
import { lazyNamed } from "./routes/lazyNamed";
import { useNotificationStore } from "./store/notificationStore";
import { useBackupStore } from "./store/backupStore";
import { installWailsOperationLogger } from "./utils/wailsOperationLogger";
import {
ForceQuit as ForceQuitApp,
QuitAppOnly as QuitAppOnlyApp,
@@ -73,6 +74,45 @@ function useWailsNotifications() {
}, [addNotification]);
}
function useGlobalErrorNotifications() {
const addNotification = useNotificationStore((s) => s.addNotification);
useEffect(() => {
const toMessage = (value: unknown) => {
if (value instanceof Error) return value.message || String(value);
if (typeof value === "string") return value;
try {
return JSON.stringify(value);
} catch {
return String(value);
}
};
const handleError = (event: ErrorEvent) => {
addNotification({
type: "error",
title: "前端异常",
message: event.message || toMessage(event.error) || "未知脚本错误",
});
};
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
addNotification({
type: "error",
title: "未处理异步异常",
message: toMessage(event.reason) || "未知 Promise 异常",
});
};
window.addEventListener("error", handleError);
window.addEventListener("unhandledrejection", handleUnhandledRejection);
return () => {
window.removeEventListener("error", handleError);
window.removeEventListener("unhandledrejection", handleUnhandledRejection);
};
}, [addNotification]);
}
function CloseConfirmModal() {
const [open, setOpen] = useState(false);
const [platform, setPlatform] = useState("windows");
@@ -246,7 +286,11 @@ function CloseConfirmModal() {
}
function App() {
useEffect(() => {
installWailsOperationLogger();
}, []);
useWailsNotifications();
useGlobalErrorNotifications();
const [quickLaunchOpen, setQuickLaunchOpen] = useState(false);
const routeFallback = (
<div className="flex min-h-[240px] items-center justify-center py-10">
@@ -223,15 +223,7 @@ export function AutomationScriptHistoryModal({
}
>
<div className="space-y-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-medium text-[var(--color-text-primary)]">
</div>
<div className="mt-1 text-xs text-[var(--color-text-muted)]">
</div>
</div>
<div className="flex items-center justify-end">
<Button
size="sm"
variant="secondary"
@@ -274,31 +266,31 @@ export function AutomationScriptHistoryModal({
) : (
<div className="overflow-hidden rounded-2xl border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] shadow-[var(--shadow-sm)]">
<div className="max-h-[58vh] overflow-auto">
<table className="w-full min-w-[1040px]">
<table className="w-full min-w-[1120px]">
<thead className="sticky top-0 z-10 bg-[var(--color-bg-muted)]">
<tr>
<th className="w-14 px-3 py-3 text-left text-xs font-semibold uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
<th className="w-14 whitespace-nowrap px-3 py-3 text-left text-xs font-semibold uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
<th className="w-[170px] whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
<th className="w-[230px] whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
<th className="w-[90px] whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
<th className="w-[150px] whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
<th className="w-[110px] whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
<th className="whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
<th className="w-[120px] whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
</th>
</tr>
@@ -316,7 +308,7 @@ export function AutomationScriptHistoryModal({
onKeyDown={(event) => handleRowKeyDown(event, run.id)}
className="cursor-pointer transition-colors duration-150 hover:bg-[var(--color-bg-muted)]/55 focus:outline-none focus-visible:bg-[var(--color-accent-muted)]/40"
>
<td className="px-3 py-4 align-top text-[var(--color-text-muted)]">
<td className="whitespace-nowrap px-3 py-4 align-top text-[var(--color-text-muted)]">
<span className="inline-flex h-7 w-7 items-center justify-center rounded-lg border border-[var(--color-border-muted)] bg-[var(--color-bg-muted)]">
{expanded ? (
<ChevronDown className="h-4 w-4" />
@@ -325,12 +317,12 @@ export function AutomationScriptHistoryModal({
)}
</span>
</td>
<td className="px-4 py-4 align-top text-sm text-[var(--color-text-primary)]">
<td className="whitespace-nowrap px-4 py-4 align-top text-sm text-[var(--color-text-primary)]">
<div className="whitespace-nowrap font-medium">
{formatDateTime(run.startedAt)}
</div>
</td>
<td className="px-4 py-4 align-top text-sm text-[var(--color-text-primary)]">
<td className="whitespace-nowrap px-4 py-4 align-top text-sm text-[var(--color-text-primary)]">
<div
className="max-w-[180px] truncate font-medium"
title={run.scriptName || "未命名脚本"}
@@ -338,16 +330,18 @@ export function AutomationScriptHistoryModal({
{run.scriptName || "未命名脚本"}
</div>
</td>
<td className="px-4 py-4 align-top text-sm">
<Badge
variant={getRunStatusBadgeVariant(run.status)}
size="sm"
dot
>
{getRunStatusLabel(run.status)}
</Badge>
<td className="whitespace-nowrap px-4 py-4 align-top text-sm">
<span className="inline-flex whitespace-nowrap">
<Badge
variant={getRunStatusBadgeVariant(run.status)}
size="sm"
dot
>
{getRunStatusLabel(run.status)}
</Badge>
</span>
</td>
<td className="px-4 py-4 align-top text-sm text-[var(--color-text-secondary)]">
<td className="whitespace-nowrap px-4 py-4 align-top text-sm text-[var(--color-text-secondary)]">
<div
className="max-w-[140px] truncate"
title={run.scriptType || "-"}
@@ -355,10 +349,10 @@ export function AutomationScriptHistoryModal({
{run.scriptType || "-"}
</div>
</td>
<td className="px-4 py-4 align-top text-sm text-[var(--color-text-primary)]">
<td className="whitespace-nowrap px-4 py-4 align-top text-sm text-[var(--color-text-primary)]">
{formatDuration(run.durationMs)}
</td>
<td className="px-4 py-4 align-top text-sm text-[var(--color-text-secondary)]">
<td className="whitespace-nowrap px-4 py-4 align-top text-sm text-[var(--color-text-secondary)]">
<div
className="max-w-[320px] truncate"
title={run.summary || "未返回摘要"}
@@ -366,7 +360,7 @@ export function AutomationScriptHistoryModal({
{run.summary || "未返回摘要"}
</div>
</td>
<td className="px-4 py-4 align-top text-sm">
<td className="whitespace-nowrap px-4 py-4 align-top text-sm">
<Button
size="sm"
variant="secondary"
@@ -47,8 +47,15 @@ async function clearLogs() {
export function BrowserLogsPage() {
const [logs, setLogs] = useState<LogEntry[]>([])
const [levelFilter, setLevelFilter] = useState('ALL')
const [componentFilter, setComponentFilter] = useState('ALL')
const [methodFilter, setMethodFilter] = useState('ALL')
const [keyword, setKeyword] = useState('')
const [autoScroll, setAutoScroll] = useState(true)
const [fieldKeyword, setFieldKeyword] = useState('')
const [quickFilter, setQuickFilter] = useState('ALL')
const [durationMin, setDurationMin] = useState('')
const [timeFrom, setTimeFrom] = useState('')
const [timeTo, setTimeTo] = useState('')
const [autoScroll, setAutoScroll] = useState(false)
const [loading, setLoading] = useState(false)
const bottomRef = useRef<HTMLDivElement>(null)
@@ -81,10 +88,40 @@ export function BrowserLogsPage() {
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
if (componentFilter !== 'ALL' && entry.component !== componentFilter) return false
const method = String(entry.fields?.method || '')
const duration = Number(entry.fields?.duration_ms || entry.fields?.durationMs || 0)
if (methodFilter !== 'ALL' && method !== methodFilter) return false
if (quickFilter === 'ERRORS' && entry.level !== 'ERROR') return false
if (quickFilter === 'SLOW' && duration < 1000) return false
if (quickFilter === 'FRONTEND' && entry.component !== 'Frontend') return false
if (quickFilter === 'BACKEND' && entry.component === 'Frontend') return false
if (durationMin && duration < Number(durationMin)) return false
if (timeFrom && entry.time < timeFrom.replace('T', ' ')) return false
if (timeTo && entry.time > timeTo.replace('T', ' ')) return false
const fieldText = entry.fields ? JSON.stringify(entry.fields).toLowerCase() : ''
const q = keyword.trim().toLowerCase()
if (q && !entry.message.toLowerCase().includes(q) &&
!entry.component.toLowerCase().includes(q) &&
!method.toLowerCase().includes(q) &&
!fieldText.includes(q)) return false
const fq = fieldKeyword.trim().toLowerCase()
if (fq && !fieldText.includes(fq)) return false
return true
})
const components = Array.from(new Set(logs.map(entry => entry.component).filter(Boolean))).sort()
const methods = Array.from(new Set(logs.map(entry => String(entry.fields?.method || '')).filter(Boolean))).sort()
const resetFilters = () => {
setLevelFilter('ALL')
setComponentFilter('ALL')
setMethodFilter('ALL')
setQuickFilter('ALL')
setKeyword('')
setFieldKeyword('')
setDurationMin('')
setTimeFrom('')
setTimeTo('')
}
return (
<div className="space-y-4 animate-fade-in">
@@ -103,10 +140,8 @@ export function BrowserLogsPage() {
</div>
</div>
{/* 工具栏 */}
<div className="flex items-center gap-3 flex-wrap">
{/* 级别过滤 */}
<div className="flex gap-1">
<div className="rounded-xl border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] p-3">
<div className="flex flex-wrap items-center gap-2">
{LEVELS.map(l => (
<button
key={l}
@@ -120,28 +155,100 @@ export function BrowserLogsPage() {
{l}
</button>
))}
{[
['ALL', '全部'],
['ERRORS', '只看异常'],
['SLOW', '慢调用'],
['FRONTEND', '前端操作'],
['BACKEND', '后端组件'],
].map(([value, label]) => (
<button
key={value}
onClick={() => setQuickFilter(value)}
className={`px-2.5 py-1 text-xs rounded-md transition-colors ${
quickFilter === value
? 'bg-[var(--color-text-primary)] text-white'
: 'bg-[var(--color-bg-muted)] text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)]'
}`}
>
{label}
</button>
))}
<span className="ml-auto text-xs text-[var(--color-text-muted)]">
{filtered.length} / {logs.length}
</span>
</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">
<div className="mt-3 grid grid-cols-1 gap-2 lg:grid-cols-6">
<input
type="checkbox"
checked={autoScroll}
onChange={e => setAutoScroll(e.target.checked)}
className="w-3.5 h-3.5"
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)] lg:col-span-2"
/>
</label>
<input
value={fieldKeyword}
onChange={e => setFieldKeyword(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)]"
/>
<select
value={componentFilter}
onChange={e => setComponentFilter(e.target.value)}
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)]"
>
<option value="ALL"></option>
{components.map(component => (
<option key={component} value={component}>{component}</option>
))}
</select>
<select
value={methodFilter}
onChange={e => setMethodFilter(e.target.value)}
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)]"
>
<option value="ALL"></option>
{methods.map(method => (
<option key={method} value={method}>{method}</option>
))}
</select>
<input
type="number"
min="0"
value={durationMin}
onChange={e => setDurationMin(e.target.value)}
placeholder="最小耗时 ms"
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)]"
/>
</div>
<span className="text-xs text-[var(--color-text-muted)]">
{filtered.length} / {logs.length}
</span>
<div className="mt-2 flex flex-wrap items-center gap-2">
<input
type="datetime-local"
value={timeFrom}
onChange={e => setTimeFrom(e.target.value)}
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)]"
/>
<span className="text-xs text-[var(--color-text-muted)]"></span>
<input
type="datetime-local"
value={timeTo}
onChange={e => setTimeTo(e.target.value)}
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)]"
/>
<label className="ml-auto flex items-center gap-1.5 text-xs text-[var(--color-text-muted)] cursor-pointer select-none">
<input
type="checkbox"
checked={autoScroll}
onChange={e => setAutoScroll(e.target.checked)}
className="w-3.5 h-3.5"
/>
</label>
<Button variant="secondary" size="sm" onClick={resetFilters}></Button>
</div>
</div>
{/* 日志列表 */}
@@ -1,11 +1,14 @@
import { useEffect, useMemo, useState } from 'react'
import { ExternalLink, Search } from 'lucide-react'
import { Button, Input, Modal, toast } from '../../../shared/components'
import type { BrowserExtension, BrowserProfile, BrowserProfileExtensionSettings } from '../types'
import type { BrowserExtension, BrowserGroupWithCount, BrowserProfile, BrowserProfileExtensionSettings } from '../types'
import { fetchBrowserProfileExtensionSettings, saveBrowserProfileExtensionSettings, type BrowserExtensionManualDownloadFile, type BrowserExtensionManualInstallGuide } from '../api/extensions'
import { fetchGroups } from '../api/groups'
import { fetchBrowserProfiles } from '../api/profiles'
import { extensionHistoryActionLabel, formatExtensionTime, sameStringSet, type ExtensionHistoryRecord } from './extensionManagementUtils'
const UNGROUPED_PROFILE_GROUP_ID = '__ungrouped__'
export interface ExtensionProfileLimitModalProps {
open: boolean
extension: BrowserExtension | null
@@ -15,6 +18,7 @@ export interface ExtensionProfileLimitModalProps {
export function ExtensionProfileLimitModal({ open, extension, allExtensions, onClose }: ExtensionProfileLimitModalProps) {
const [profiles, setProfiles] = useState<BrowserProfile[]>([])
const [groups, setGroups] = useState<BrowserGroupWithCount[]>([])
const [settingsByProfile, setSettingsByProfile] = useState<Record<string, BrowserProfileExtensionSettings>>({})
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [loading, setLoading] = useState(false)
@@ -25,11 +29,55 @@ export function ExtensionProfileLimitModal({ open, extension, allExtensions, onC
() => allExtensions.filter((item) => item.enabled).map((item) => item.extensionId),
[allExtensions],
)
const groupNameMap = useMemo(() => {
const map = new Map<string, string>()
groups.forEach((group) => map.set(group.groupId, group.groupName))
return map
}, [groups])
const profileGroups = useMemo(() => {
const buckets = new Map<string, BrowserProfile[]>()
profiles.forEach((profile) => {
const groupId = (profile.groupId || '').trim() || UNGROUPED_PROFILE_GROUP_ID
if (!buckets.has(groupId)) buckets.set(groupId, [])
buckets.get(groupId)!.push(profile)
})
const sections = groups
.filter((group) => buckets.has(group.groupId))
.sort((a, b) => a.sortOrder - b.sortOrder || a.groupName.localeCompare(b.groupName))
.map((group) => ({
groupId: group.groupId,
groupName: group.groupName,
profiles: buckets.get(group.groupId) || [],
}))
for (const [groupId, items] of buckets.entries()) {
if (groupId === UNGROUPED_PROFILE_GROUP_ID) continue
if (!sections.some((section) => section.groupId === groupId)) {
sections.push({
groupId,
groupName: groupNameMap.get(groupId) || `分组 ${groupId}`,
profiles: items,
})
}
}
if (buckets.has(UNGROUPED_PROFILE_GROUP_ID)) {
sections.push({
groupId: UNGROUPED_PROFILE_GROUP_ID,
groupName: '未分组',
profiles: buckets.get(UNGROUPED_PROFILE_GROUP_ID) || [],
})
}
return sections
}, [profiles, groups, groupNameMap])
useEffect(() => {
if (!open || !extension) return
setLoading(true)
fetchBrowserProfiles().then(async (profileItems) => {
Promise.all([fetchBrowserProfiles(), fetchGroups()]).then(async ([profileItems, groupItems]) => {
const profileSettings = await Promise.all(profileItems.map(async (profile) => ({
profile,
settings: await fetchBrowserProfileExtensionSettings(profile.profileId),
@@ -39,6 +87,7 @@ export function ExtensionProfileLimitModal({ open, extension, allExtensions, onC
settingsMap[profile.profileId] = settings
})
setProfiles(profileItems)
setGroups(groupItems)
setSettingsByProfile(settingsMap)
setSelectedIds(profileItems
.filter((profile) => {
@@ -58,6 +107,17 @@ export function ExtensionProfileLimitModal({ open, extension, allExtensions, onC
})
}
const toggleGroup = (profileIds: string[], checked: boolean) => {
setSelectedIds((current) => {
const next = new Set(current)
profileIds.forEach((profileId) => {
if (checked) next.add(profileId)
else next.delete(profileId)
})
return Array.from(next)
})
}
const handleSave = async () => {
if (!extension) return
setSaving(true)
@@ -105,25 +165,46 @@ export function ExtensionProfileLimitModal({ open, extension, allExtensions, onC
<div className="rounded-xl border border-[var(--color-border-default)] bg-[var(--color-bg-muted)] px-3 py-2 text-sm text-[var(--color-text-secondary)]">
</div>
<div className="max-h-[420px] space-y-2 overflow-auto pr-1">
{profiles.map((profile) => (
<label key={profile.profileId} className="flex items-start gap-3 rounded-xl border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] px-3 py-2">
<input
type="checkbox"
checked={selectedSet.has(profile.profileId)}
onChange={(event) => toggleProfile(profile.profileId, event.target.checked)}
className="mt-1 h-4 w-4 shrink-0 rounded accent-[var(--color-accent)]"
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2 text-sm font-medium text-[var(--color-text-primary)]">
<span>{profile.profileName || profile.profileId}</span>
{profile.running ? <span className="rounded bg-green-50 px-1.5 py-0.5 text-xs text-green-700"></span> : null}
{settingsByProfile[profile.profileId]?.configured ? <span className="rounded bg-[var(--color-bg-muted)] px-1.5 py-0.5 text-xs font-normal text-[var(--color-text-muted)]"></span> : null}
<div className="max-h-[420px] space-y-3 overflow-auto pr-1">
{profileGroups.map((group) => {
const groupProfileIds = group.profiles.map((profile) => profile.profileId)
const selectedCount = groupProfileIds.filter((profileId) => selectedSet.has(profileId)).length
const allSelected = groupProfileIds.length > 0 && selectedCount === groupProfileIds.length
return (
<section key={group.groupId} className="rounded-xl border border-[var(--color-border-default)] bg-[var(--color-bg-surface)]">
<div className="flex items-center justify-between gap-3 border-b border-[var(--color-border-muted)] px-3 py-2">
<div className="min-w-0">
<div className="truncate text-sm font-medium text-[var(--color-text-primary)]">{group.groupName}</div>
<div className="text-xs text-[var(--color-text-muted)]"> {selectedCount} / {groupProfileIds.length}</div>
</div>
<Button size="sm" variant="ghost" onClick={() => toggleGroup(groupProfileIds, !allSelected)}>
{allSelected ? '取消本组' : '选择本组'}
</Button>
</div>
<div className="mt-1 break-all font-mono text-xs text-[var(--color-text-muted)]">{profile.profileId}</div>
</div>
</label>
))}
<div className="divide-y divide-[var(--color-border-muted)]">
{group.profiles.map((profile) => (
<label key={profile.profileId} className="flex items-start gap-3 px-3 py-2">
<input
type="checkbox"
checked={selectedSet.has(profile.profileId)}
onChange={(event) => toggleProfile(profile.profileId, event.target.checked)}
className="mt-1 h-4 w-4 shrink-0 rounded accent-[var(--color-accent)]"
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2 text-sm font-medium text-[var(--color-text-primary)]">
<span>{profile.profileName || profile.profileId}</span>
{profile.running ? <span className="rounded bg-green-50 px-1.5 py-0.5 text-xs text-green-700"></span> : null}
{settingsByProfile[profile.profileId]?.configured ? <span className="rounded bg-[var(--color-bg-muted)] px-1.5 py-0.5 text-xs font-normal text-[var(--color-text-muted)]"></span> : null}
</div>
<div className="mt-1 break-all font-mono text-xs text-[var(--color-text-muted)]">{profile.profileId}</div>
</div>
</label>
))}
</div>
</section>
)
})}
{profiles.length === 0 ? (
<div className="rounded-xl border border-dashed border-[var(--color-border-default)] bg-[var(--color-bg-muted)] px-4 py-8 text-center text-sm text-[var(--color-text-muted)]">
@@ -21,10 +21,7 @@ export function CoreSettingsCard({ settings, onEdit }: CoreSettingsCardProps) {
</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>
<SettingsValue label="用户数据根目录" value={settings.userDataRoot || '-'} />
<SettingsList label="默认指纹参数" values={settings.defaultFingerprintArgs} />
<SettingsList label="默认启动参数" values={settings.defaultLaunchArgs} />
<SettingsList label="默认启动页面" values={settings.defaultStartUrls} />
@@ -41,7 +38,9 @@ function SettingsValue({ label, value }: { label: string; value: string }) {
return (
<div>
<p className="text-xs text-[var(--color-text-muted)] mb-1">{label}</p>
<p className="text-sm text-[var(--color-text-primary)]">{value}</p>
<div className="min-h-9 rounded-md bg-[var(--color-bg-subtle)] px-3 py-2 text-sm leading-5 text-[var(--color-text-primary)]">
{value}
</div>
</div>
)
}
@@ -51,11 +50,13 @@ function SettingsList({ label, values }: { label: string; values: string[] }) {
<div>
<p className="text-xs text-[var(--color-text-muted)] mb-1">{label}</p>
{values.length > 0 ? (
<pre className="text-xs text-[var(--color-text-secondary)] bg-[var(--color-bg-subtle)] p-2 rounded max-h-20 overflow-auto">
<pre className="min-h-9 max-h-20 overflow-auto rounded-md bg-[var(--color-bg-subtle)] px-3 py-2 text-sm leading-5 text-[var(--color-text-primary)]">
{values.join('\n')}
</pre>
) : (
<p className="text-sm text-[var(--color-text-primary)]">-</p>
<div className="min-h-9 rounded-md bg-[var(--color-bg-subtle)] px-3 py-2 text-sm leading-5 text-[var(--color-text-primary)]">
-
</div>
)}
</div>
)
@@ -3,12 +3,15 @@ import { DEFAULT_API_AUTH, DEFAULT_LAUNCH_BASE_URL } from '../../launchContext'
import {
DOC_API_OVERVIEW,
DOC_CORE_INTRO,
DOC_EXTENSION_INTRO,
DOC_OPERATION_FLOW,
DOC_PROXY_INTRO,
DOC_SKILL_USAGE,
DOC_TUTORIAL,
} from './contentIntro'
import { DOC_CHANGELOG } from './contentChangelog'
import {
DOC_API_AUTOMATION,
DOC_API_PROFILES_LAUNCH,
DOC_API_RUNTIME,
} from './contentApi'
@@ -57,7 +60,7 @@ export const DOC_GROUPS: LaunchDocGroup[] = [
id: 'tutorial-flow',
label: '操作流程',
summary: '按步骤串起内核、代理、实例和接口调用。',
content: '',
content: DOC_OPERATION_FLOW,
},
],
},
@@ -97,6 +100,18 @@ export const DOC_GROUPS: LaunchDocGroup[] = [
},
],
},
{
id: 'extension',
label: '插件介绍',
items: [
{
id: 'extension-usage',
label: '插件包管理',
summary: '插件安装、实例限制、分组选择和单实例配置。',
content: DOC_EXTENSION_INTRO,
},
],
},
{
id: 'api',
label: '接口介绍',
@@ -123,7 +138,7 @@ export const DOC_GROUPS: LaunchDocGroup[] = [
id: 'api-automation',
label: '脚本自动化',
summary: '自动化接口总览、字段规则和调用顺序。',
content: '',
content: DOC_API_AUTOMATION,
},
{
id: 'api-support',
@@ -1,5 +1,14 @@
export const DOC_CHANGELOG = `# 更新日志
## 1.3.0 - 2026-06-18
- 插件包管理:支持商店链接 / 插件 ID 安装、本地插件包导入、本地插件目录导入、插件启停、删除和安装历史。
- 插件实例限制:限制实例弹窗按分组展示实例,支持整组选择 / 取消,并显示每组已选数量。
- 单实例插件配置:实例列表可进入单实例插件配置,支持从全局继承切换为独立插件清单。
- 代理核心管理:补齐 xray、sing-box、mihomo 运行核心检测、下载、清理和启动锁处理。
- 内核全局设置:全局设置只读字段统一为一致的灰底展示,减少普通值和多行参数的样式割裂。
- 文档中心:补齐插件包管理、操作流程和全局设置说明,并接回脚本自动化章节内容。
## 1.2.0 - 2026-05-05
- 文档中心改版:把使用教程、内核、代理、接口和排障内容收进同一个入口,接口详情改为按章节逐步查看。
@@ -5,6 +5,7 @@
\`\`\`text
内核管理 -> 下载 / 识别内核
代理池配置 -> 导入 / 添加代理
插件包管理 -> 安装 / 导入插件
实例列表 -> 新建实例 -> 选择内核 / 代理 -> 启动
\`\`\`
@@ -13,8 +14,9 @@
1. 打开 \`指纹浏览器 > 内核管理\`
2. 准备一个可用内核
3. 打开 \`指纹浏览器 > 代理池配置\`,按需导入代理
4. 打开 \`指纹浏览器 > 实例列表\`,新建实例
5. 保存后直接启动
4. 需要固定插件时,打开 \`指纹浏览器 > 插件包管理\` 安装或导入插件
5. 打开 \`指纹浏览器 > 实例列表\`,新建实例并按需配置插件
6. 保存后直接启动
## 最小 HTTP 对接
@@ -62,6 +64,44 @@ const browser = await chromium.connectOverCDP(data.cdpUrl);
需要稳定接管时,不要自己轮询,直接用 \`POST /api/runtime/session\`
`
export const DOC_OPERATION_FLOW = `# 操作流程
## 首次配置
\`\`\`text
1. 内核管理:准备 Chrome 内核
2. 代理池配置:导入或录入代理
3. 插件包管理:安装插件,按需限制实例
4. 实例列表:创建实例并选择内核、代理、分组
5. 启动实例:确认 debugReady 后再接管
\`\`\`
## 插件接入流程
\`\`\`text
插件包管理 -> 输入商店链接 / 插件 ID -> 安装
插件包管理 -> 限制实例 -> 按分组选择实例 -> 保存
实例列表 -> 单个实例 -> 插件 -> 开启单独配置(可选)
\`\`\`
## 分组限制怎么用
- 在实例列表或分组管理里先维护实例分组
- 在插件包管理里点插件卡片的 \`限制实例\`
- 弹窗会按分组展示实例,并显示每组已选数量
- 点 \`选择本组\` 可以快速让整组实例加载该插件
- 点 \`取消本组\` 可以快速让整组实例排除此插件
## 启动前检查
\`\`\`text
1. 实例有可用内核
2. 代理可连通,或确认不需要代理
3. 必要插件已安装,并且该实例在插件限制范围内
4. 启动后 debugReady=true
\`\`\`
`
export const DOC_SKILL_USAGE = `# SKILL 使用说明
## 先准备好 3 个前提
@@ -205,6 +245,18 @@ chrome/
实例编辑页 -> 选择内核
\`\`\`
## 全局设置
\`\`\`text
内核管理 -> 全局设置 -> 编辑
\`\`\`
- 用户数据根目录决定实例数据保存位置
- 默认启动参数会追加到实例启动参数里
- 默认指纹参数会追加到指纹内核启动参数里
- 默认启动页面为空时,不会额外打开页面
- 轻启动模式用于减少启动阶段的额外等待
## 自检
\`\`\`text
@@ -273,6 +325,52 @@ dns:
如果 \`proxyId\` 无效且 \`proxyConfig\` 也为空,请求会直接报错。
`
export const DOC_EXTENSION_INTRO = `# 插件包管理
## 入口
\`\`\`text
指纹浏览器 -> 插件包管理
\`\`\`
## 安装方式
- 输入 Chrome Web Store 链接或 32 位插件 ID 后安装
- 本地已有 \`.crx\` / \`.zip\` 时,用手动安装导入文件
- 已解压的插件目录可直接导入目录
- 安装完成后,插件默认按全局启用状态参与实例启动
## 限制实例
\`\`\`text
插件包管理 -> 插件卡片 -> 限制实例
\`\`\`
- 勾选的实例会加载该插件
- 未勾选的实例会排除该插件
- 弹窗按实例分组展示,未设置分组的实例进入 \`未分组\`
- 每个分组支持 \`选择本组\`\`取消本组\`
- 分组标题会显示该组 \`已选 / 总数\`
## 单实例插件配置
\`\`\`text
实例列表 -> 实例行 -> 插件
\`\`\`
- 默认情况下,实例继承全局已启用插件和插件限制规则
- 打开单独配置后,该实例只加载弹窗内勾选的插件
- 单独配置适合少量特殊实例,不建议替代分组限制
## 推荐用法
\`\`\`text
同一批业务实例 -> 放入同一分组
同一批业务需要的插件 -> 在限制实例里选择对应分组
个别实例例外 -> 再用单实例插件配置微调
\`\`\`
`
export const DOC_API_OVERVIEW = `# 接口总览
## 基础地址
+13 -2
View File
@@ -1,5 +1,6 @@
import { CheckCircle, XCircle, AlertCircle, Info, X } from 'lucide-react'
import { create } from 'zustand'
import { useNotificationStore } from '../../store/notificationStore'
type ToastType = 'success' | 'error' | 'warning' | 'info'
@@ -41,11 +42,21 @@ export const useToastStore = create<ToastStore>((set) => ({
}))
// Toast 工具函数
function recordErrorNotification(message: string) {
useNotificationStore.getState().addNotification({
type: 'error',
title: '操作异常',
message,
})
}
export const toast = {
success: (message: string, duration?: number) =>
useToastStore.getState().addToast({ type: 'success', message, duration }),
error: (message: string, duration?: number) =>
useToastStore.getState().addToast({ type: 'error', message, duration }),
error: (message: string, duration?: number) => {
useToastStore.getState().addToast({ type: 'error', message, duration })
recordErrorNotification(message)
},
warning: (message: string, duration?: number) =>
useToastStore.getState().addToast({ type: 'warning', message, duration }),
info: (message: string, duration?: number) =>
+3 -16
View File
@@ -1,5 +1,5 @@
import { useState, useRef, useEffect } from 'react'
import { Bell, Search, User, Settings, Check, Trash2, Info, AlertCircle, CheckCircle } from 'lucide-react'
import { Bell, User, Settings, Check, Trash2, Info, AlertCircle, CheckCircle } from 'lucide-react'
import { Link } from 'react-router-dom'
import clsx from 'clsx'
import { useNotificationStore, type Notification } from '../../store/notificationStore'
@@ -31,7 +31,7 @@ function NotificationDropdown({
{/* Header */}
<div className="px-4 py-3 border-b border-[var(--color-border-muted)] flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-[var(--color-text-primary)]"></span>
<span className="text-sm font-semibold text-[var(--color-text-primary)]"></span>
{unreadCount > 0 && (
<span className="px-1.5 py-0.5 text-xs font-medium bg-[var(--color-accent)] text-white rounded-full">
{unreadCount}
@@ -63,7 +63,7 @@ function NotificationDropdown({
{notifications.length === 0 ? (
<div className="py-8 text-center text-[var(--color-text-muted)]">
<Bell className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p className="text-sm"></p>
<p className="text-sm"></p>
</div>
) : (
notifications.map((notification) => (
@@ -136,19 +136,6 @@ export function Topbar() {
return (
<header className="h-14 bg-[var(--color-bg-surface)] border-b border-[var(--color-border-default)] px-4 flex items-center justify-between gap-4">
{/* 搜索框 - 固定宽度,不随容器拉伸 */}
<div className="w-64">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--color-text-muted)]" />
<input
type="text"
placeholder="搜索..."
className="w-full h-8 pl-9 pr-3 bg-[var(--color-bg-muted)] border border-transparent rounded-md text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-muted)] focus:outline-none focus:bg-[var(--color-bg-surface)] focus:border-[var(--color-border-strong)] transition-all duration-150"
/>
</div>
</div>
{/* 中间留白 */}
<div className="flex-1" />
{/* 右侧操作 */}
+15 -3
View File
@@ -1,4 +1,5 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export interface Notification {
id: string
@@ -17,17 +18,25 @@ interface NotificationState {
clearNotifications: () => void
}
export const useNotificationStore = create<NotificationState>((set) => ({
const MAX_NOTIFICATIONS = 100
function formatNotificationTime() {
const now = new Date()
const pad = (value: number) => String(value).padStart(2, '0')
return `${now.getFullYear()}/${pad(now.getMonth() + 1)}/${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
}
export const useNotificationStore = create<NotificationState>()(persist((set) => ({
notifications: [],
addNotification: (data) => set((state) => {
const newNotification: Notification = {
...data,
id: Math.random().toString(36).substring(2, 9),
time: '刚刚',
time: formatNotificationTime(),
read: false,
}
return { notifications: [newNotification, ...state.notifications] }
return { notifications: [newNotification, ...state.notifications].slice(0, MAX_NOTIFICATIONS) }
}),
markAsRead: (id) => set((state) => ({
@@ -41,4 +50,7 @@ export const useNotificationStore = create<NotificationState>((set) => ({
})),
clearNotifications: () => set({ notifications: [] }),
}), {
name: 'ant-browser-notifications',
partialize: (state) => ({ notifications: state.notifications }),
}))
@@ -0,0 +1,76 @@
const MUTATION_METHOD_PATTERN = /(Create|Update|Delete|Save|Set|Import|Export|Install|Uninstall|Download|Start|Stop|Launch|Run|Clear|Move|Copy|Refresh|Sync|Validate|Toggle|Switch|Enable|Disable|Force|Quit|Open|Close|Apply|Bind|Unbind|Restart|Warmup|Probe|Check|Test)/
const SKIP_METHODS = new Set(['GetAppLogs', 'FrontendOperationLog'])
let installed = false
function stringifyError(error: unknown) {
if (error instanceof Error) return error.message || String(error)
if (typeof error === 'string') return error
try {
return JSON.stringify(error)
} catch {
return String(error)
}
}
function shouldLogSuccess(method: string) {
if (SKIP_METHODS.has(method)) return false
return MUTATION_METHOD_PATTERN.test(method)
}
function shouldLogFailure(method: string) {
return !SKIP_METHODS.has(method)
}
function recordOperation(level: 'info' | 'error', method: string, success: boolean, durationMs: number, message = '') {
const app = (window as any)?.go?.main?.App
const logger = app?.FrontendOperationLog
if (typeof logger !== 'function') return
try {
void logger(level, method, success, Math.max(0, Math.round(durationMs)), String(message || '').slice(0, 1200))
} catch {
// Logging must never break user operations.
}
}
export function installWailsOperationLogger() {
if (installed) return
installed = true
const app = (window as any)?.go?.main?.App
if (!app || typeof app !== 'object') return
Object.keys(app).forEach((method) => {
const original = app[method]
if (typeof original !== 'function' || SKIP_METHODS.has(method)) return
app[method] = (...args: unknown[]) => {
const startedAt = performance.now()
try {
const result = original(...args)
if (result && typeof result.then === 'function') {
return result.then((value: unknown) => {
if (shouldLogSuccess(method)) {
recordOperation('info', method, true, performance.now() - startedAt)
}
return value
}).catch((error: unknown) => {
if (shouldLogFailure(method)) {
recordOperation('error', method, false, performance.now() - startedAt, stringifyError(error))
}
throw error
})
}
if (shouldLogSuccess(method)) {
recordOperation('info', method, true, performance.now() - startedAt)
}
return result
} catch (error) {
if (shouldLogFailure(method)) {
recordOperation('error', method, false, performance.now() - startedAt, stringifyError(error))
}
throw error
}
}
})
}
+2
View File
@@ -236,6 +236,8 @@ export function FetchRemoteAuthorProfile(arg1:string,arg2:number):Promise<Record
export function ForceQuit():Promise<void>;
export function FrontendOperationLog(arg1:string,arg2:string,arg3:boolean,arg4:number,arg5:string):Promise<void>;
export function GenerateCDKeys(arg1:number):Promise<Array<string>>;
export function GetAppConfig():Promise<Record<string, any>>;
+4
View File
@@ -454,6 +454,10 @@ export function ForceQuit() {
return window['go']['main']['App']['ForceQuit']();
}
export function FrontendOperationLog(arg1, arg2, arg3, arg4, arg5) {
return window['go']['main']['App']['FrontendOperationLog'](arg1, arg2, arg3, arg4, arg5);
}
export function GenerateCDKeys(arg1) {
return window['go']['main']['App']['GenerateCDKeys'](arg1);
}