新增实例槽位隔离与结构化日志,多开不再共用日志与账号

This commit is contained in:
yml2213
2026-10-14 21:32:49 +08:00
parent f96fa7d858
commit 8c4b15066f
27 changed files with 2011 additions and 468 deletions
+136 -20
View File
@@ -38,22 +38,50 @@ import {
import {
DefaultConfig,
Exchange,
GetInstanceInfo,
GetLogFilePath,
GetLogs,
GetLogStats,
GetRecords,
IsRunning,
ListPrizes,
OpenLogDir,
PreviewAccounts,
QueryScores,
SetLogLevel,
StartMonitor,
StopMonitor,
} from '../wailsjs/go/gui/App'
import { ClipboardGetText, ClipboardSetText, EventsOn } from '../wailsjs/runtime/runtime'
const storageKey = 'huya-go.gui.config.v2'
const cookieStorageKey = 'huya-go.gui.cookies.v1'
const prizeCacheStorageKey = 'huya-go.gui.prizes.v1'
const configStorageBase = 'huya-go.gui.config.v2'
const cookieStorageBase = 'huya-go.gui.cookies.v1'
const prizeCacheStorageBase = 'huya-go.gui.prizes.v1'
// 界面本地存储保留的日志上限,与后端的增量拉取配合使用。
const maxClientLogs = 1000
// 多开时按实例给本地存储分片:两个实例各存各的账号与配置,互不覆盖。
let storageSuffix = ''
function scopedKey(base: string): string {
return storageSuffix ? `${base}@${storageSuffix}` : base
}
function applyStorageScope(instanceID: string) {
const id = (instanceID || '').trim()
storageSuffix = !id || id === 'default' ? '' : id
}
// 日志等级筛选项(仅作用于界面显示,不影响后端记录)。
const logFilterOptions = [
{ value: 'all', label: '全部等级' },
{ value: 'debug', label: 'debug' },
{ value: 'info', label: 'info' },
{ value: 'success', label: 'success' },
{ value: 'warn', label: 'warn' },
{ value: 'error', label: 'error' },
]
type UiConfig = {
transport: string
@@ -100,11 +128,30 @@ type CookieEntry = {
}
type LogEntry = {
id: number
time: string
level: string
category: string
account: string
message: string
}
type LogStats = {
level: string
filePath: string
dir: string
dropped: number
count: number
}
type InstanceInfo = {
name: string
id: string
dataDir: string
logDir: string
logFile: string
}
type PrizeInfo = {
id: number
sid?: number
@@ -356,19 +403,19 @@ function categoryTag(category?: string): ReactNode {
function readSavedConfig(): Partial<SavedConfig> {
try {
return JSON.parse(localStorage.getItem(storageKey) || '{}') as Partial<SavedConfig>
return JSON.parse(localStorage.getItem(scopedKey(configStorageBase)) || '{}') as Partial<SavedConfig>
} catch {
return {}
}
}
function writeSavedConfigPatch(patch: Partial<SavedConfig>) {
localStorage.setItem(storageKey, JSON.stringify({ ...readSavedConfig(), ...patch }))
localStorage.setItem(scopedKey(configStorageBase), JSON.stringify({ ...readSavedConfig(), ...patch }))
}
function readSavedCookies(): CookieEntry[] {
try {
const items = JSON.parse(localStorage.getItem(cookieStorageKey) || '[]') as Partial<CookieEntry>[]
const items = JSON.parse(localStorage.getItem(scopedKey(cookieStorageBase)) || '[]') as Partial<CookieEntry>[]
return items
.map((item, index) => ({
id: item.id || `ck-${Date.now()}-${index}`,
@@ -385,12 +432,12 @@ function readSavedCookies(): CookieEntry[] {
}
function writeSavedCookies(items: CookieEntry[]) {
localStorage.setItem(cookieStorageKey, JSON.stringify(items))
localStorage.setItem(scopedKey(cookieStorageBase), JSON.stringify(items))
}
function readSavedPrizeCache(): PrizeCache {
try {
const cache = JSON.parse(localStorage.getItem(prizeCacheStorageKey) || '{}') as Partial<PrizeCache>
const cache = JSON.parse(localStorage.getItem(scopedKey(prizeCacheStorageBase)) || '{}') as Partial<PrizeCache>
const prizes = Array.isArray(cache.prizes)
? cache.prizes.filter((item) => Number(item?.id || 0) > 0 && String(item?.name || '').trim())
: []
@@ -406,7 +453,7 @@ function readSavedPrizeCache(): PrizeCache {
function writeSavedPrizeCache(prizes: PrizeInfo[], sid: number) {
localStorage.setItem(
prizeCacheStorageKey,
scopedKey(prizeCacheStorageBase),
JSON.stringify({
prizes,
sid,
@@ -480,6 +527,16 @@ function normalizeMonitorResult(row: MonitorResult): MonitorResult {
}
}
// mergeLogs 按自增 ID 合并日志:实时事件与初始拉取可能交错,按 ID 去重并保持顺序。
function mergeLogs(prev: LogEntry[], incoming: LogEntry[]): LogEntry[] {
if (incoming.length === 0) return prev
const lastID = prev.length > 0 ? prev[prev.length - 1].id : 0
const fresh = incoming.filter((item) => Number(item.id || 0) > lastID)
if (fresh.length === 0) return prev
fresh.sort((a, b) => a.id - b.id)
return [...prev, ...fresh].slice(-maxClientLogs)
}
export default function Dashboard() {
const { message, modal } = AntApp.useApp()
const [form] = Form.useForm<UiConfig>()
@@ -511,6 +568,10 @@ export default function Dashboard() {
const [prizeCacheUpdatedAt, setPrizeCacheUpdatedAt] = useState(0)
const [configLoaded, setConfigLoaded] = useState(false)
const [logPath, setLogPath] = useState('')
const [logStats, setLogStats] = useState<LogStats | null>(null)
const [logFilter, setLogFilter] = useState('all')
const [debugLog, setDebugLog] = useState(false)
const [instanceInfo, setInstanceInfo] = useState<InstanceInfo | null>(null)
const applyCookieEntries = async (entries: CookieEntry[], persist = true, refreshLatest = false) => {
const normalizedEntries = entries
@@ -555,9 +616,16 @@ export default function Dashboard() {
useEffect(() => {
let mounted = true
Promise.all([DefaultConfig(), GetLogs(), IsRunning(), GetLogFilePath()])
.then(([defaults, logItems, isRunning, savedLogPath]) => {
Promise.all([DefaultConfig(), GetLogs(0), IsRunning(), GetLogFilePath(), GetInstanceInfo(), GetLogStats()])
.then(([defaults, logItems, isRunning, savedLogPath, info, stats]) => {
if (!mounted) return
const instance = info as InstanceInfo | null
// 本地存储必须按实例分片后再读取,否则多开时两个实例会共用同一份账号与配置。
applyStorageScope(instance?.id || '')
setInstanceInfo(instance)
setLogStats((stats || null) as LogStats | null)
setDebugLog(String((stats as LogStats | null)?.level || '') === 'debug')
const savedCookies = readSavedCookies()
const savedPrizeCache = readSavedPrizeCache()
form.setFieldsValue({
@@ -568,8 +636,8 @@ export default function Dashboard() {
setCookieEntries(savedCookies)
setPrizes(savedPrizeCache.prizes)
setPrizeCacheUpdatedAt(savedPrizeCache.updatedAt)
setLogs((logItems || []) as LogEntry[])
setLogPath(String(savedLogPath || ''))
setLogs((prev) => mergeLogs(prev, (logItems || []) as LogEntry[]))
setLogPath(String(savedLogPath || instance?.logFile || ''))
setRunning(Boolean(isRunning))
setConfigLoaded(true)
if (savedCookies.length > 0) {
@@ -579,7 +647,7 @@ export default function Dashboard() {
.catch((err: unknown) => message.error(String(err)))
const offLog = EventsOn('gui:log', (entry: LogEntry) => {
setLogs((prev) => [...prev.slice(-499), entry])
setLogs((prev) => mergeLogs(prev, [entry]))
})
const offResult = EventsOn('gui:monitor-result', (result: MonitorResult) => {
setResults((prev) => [normalizeMonitorResult(result), ...prev].slice(0, 300))
@@ -716,6 +784,12 @@ export default function Dashboard() {
}, [prizes, productSearch])
const inventorySummary = useMemo(() => summarizeInventory(filteredPrizes), [filteredPrizes])
// 日志筛选只在界面上生效;后端的 debug 开关由「调试日志」按钮控制。
const visibleLogs = useMemo(
() => (logFilter === 'all' ? logs : logs.filter((item) => item.level === logFilter)),
[logs, logFilter],
)
const config = () => ({
...pickConfig(form.getFieldsValue(true)),
selectedAccountLines: selectedAccountLines.map(Number).filter((line) => line > 0),
@@ -904,7 +978,7 @@ export default function Dashboard() {
cancelText: '取消',
okButtonProps: { danger: true },
onOk: () => {
localStorage.removeItem(prizeCacheStorageKey)
localStorage.removeItem(scopedKey(prizeCacheStorageBase))
setPrizes([])
setSelectedPrizeKeys([])
setPrizeCacheUpdatedAt(0)
@@ -940,7 +1014,7 @@ export default function Dashboard() {
timeoutSec: values.timeoutSec,
startAt: values.startAt,
}
localStorage.setItem(storageKey, JSON.stringify(saved))
localStorage.setItem(scopedKey(configStorageBase), JSON.stringify(saved))
message.success('已保存')
}
@@ -1014,6 +1088,14 @@ export default function Dashboard() {
message.success('已复制日志路径')
}
// 调试日志开关:打开后后端会把逐条网络请求也写进日志(debug 级)。
const toggleDebugLog = async (checked: boolean) => {
const level = await SetLogLevel(checked ? 'debug' : 'info')
setDebugLog(level === 'debug')
setLogStats((prev) => (prev ? { ...prev, level } : prev))
message.success(level === 'debug' ? '已开启 debug 日志(含逐条网络请求)' : '已恢复 info 日志')
}
const selectAllAccounts = () => {
setSelectedAccountLines(validAccounts.map((item) => item.line))
}
@@ -1258,6 +1340,7 @@ export default function Dashboard() {
<div className="log-pane">
<div className="log-tools">
<span className="log-path" title={logPath || '日志文件不可用'}>
{instanceInfo ? `${instanceInfo.name} · ` : ''}
{logPath || '日志文件不可用'}
</span>
<Space size={6}>
@@ -1274,7 +1357,31 @@ export default function Dashboard() {
</Tooltip>
</Space>
</div>
<LogView logs={logs} />
<div className="log-tools">
<Select
size="small"
value={logFilter}
style={{ width: 116 }}
onChange={setLogFilter}
options={logFilterOptions}
/>
<Space size={8}>
<Tooltip title="开启后记录逐条网络请求(debug 级)">
<Switch
size="small"
checked={debugLog}
checkedChildren="调试"
unCheckedChildren="常规"
onChange={toggleDebugLog}
/>
</Tooltip>
<span className="log-path">共 {logs.length} 条</span>
</Space>
</div>
{logStats && logStats.dropped > 0 ? (
<div className="log-path">日志队列已丢弃 {logStats.dropped} 条(磁盘写入压力过大)</div>
) : null}
<LogView logs={visibleLogs} />
</div>
),
},
@@ -1301,6 +1408,7 @@ export default function Dashboard() {
<Statistic title="异常事件" value={errorCount} />
<Statistic title="待处理账号" value={pendingCount} />
<Statistic title="日志条数" value={logs.length} />
<Statistic title="丢弃日志" value={logStats?.dropped || 0} />
</div>
),
},
@@ -1401,6 +1509,11 @@ export default function Dashboard() {
</div>
<CountdownBadge countdown={scheduleCountdown} now={now} running={running} />
<Space size={12}>
<Tooltip
title={instanceInfo?.dataDir ? `实例数据目录: ${instanceInfo.dataDir}` : '实例信息未就绪'}
>
<Tag color="blue">{instanceInfo?.name || '默认实例'}</Tag>
</Tooltip>
<Tag>{String(watchedTransport || 'http').toUpperCase()}</Tag>
<Button icon={<Settings size={16} />} onClick={() => setSettingsOpen(true)}>
设置
@@ -1923,11 +2036,14 @@ function padNumber(value: number, size = 2): string {
function LogView({ logs }: { logs: LogEntry[] }) {
return (
<div className="log-view">
{logs.map((item, index) => (
<div className={`log-line level-${item.level}`} key={`${item.time}-${index}`}>
{logs.map((item) => (
<div className={`log-line level-${item.level}`} key={item.id}>
<span>{item.time}</span>
<Tag>{item.level}</Tag>
<code>{item.message}</code>
<code>
{item.category ? `[${item.category}]` : ''}
{item.account ? `[账号:${item.account}]` : ''} {item.message}
</code>
</div>
))}
</div>