mirror of
https://github.com/black-ant/Ant-Browser.git
synced 2026-07-14 18:48:55 +08:00
feat: improve logging notifications and filters
This commit is contained in:
@@ -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...)
|
||||
}
|
||||
@@ -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 返回所有缓冲日志(最新在后)
|
||||
|
||||
@@ -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 = `# 接口总览
|
||||
|
||||
## 基础地址
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
{/* 右侧操作 */}
|
||||
|
||||
@@ -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
@@ -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>>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user