Files
huya-go/frontend/src/App.tsx
T
yml2213 e8bb4eb571 补全过滤入口与直接兑换,优化兑换分类和 WSS 建连
- GUI 接线 Exchange 绑定:新增「直接兑换」按钮,结果带 category/message 并进入结果面板
- 补 -max-price/-min-left(CLI 与 GUI 设置),Target 的价格与库存过滤不再悬空
- 删除死代码 sendUpdateUserInfo 及其请求结构体(浏览器流程不含该帧)
- ClassifyExchange 改为多路判定:合并 msg/actPreCondition/exchangeInfo 文案,status=200 且有 orderId 视为成功,新增 condition 分类(precondition 门禁)
- WSS 建连改为锁外 dial、锁内注册,同 uid 并发调用共享一次握手
- 默认传输层统一为 http(CLI/GUI/README),.gitignore 忽略 .DS_Store
- 同步 Wails 依赖升级(2.12.0 -> 2.15.0)并重新生成前端绑定
2026-10-13 19:57:08 +08:00

1765 lines
53 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState } from 'react'
import type { Key, ReactNode } from 'react'
import {
App as AntApp,
Button,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Statistic,
Switch,
Table,
Tabs,
Tag,
Tooltip,
Typography,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import {
ClipboardList,
ClipboardPaste,
ListChecks,
Pencil,
Play,
Plus,
RefreshCw,
Save,
ShoppingCart,
Square,
Settings,
Trash2,
Zap,
} from 'lucide-react'
import {
DefaultConfig,
Exchange,
GetLogs,
GetRecords,
IsRunning,
ListPrizes,
PreviewAccounts,
QueryScores,
StartMonitor,
StopMonitor,
} from '../wailsjs/go/gui/App'
import { ClipboardGetText, 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'
type UiConfig = {
transport: string
wssHost: string
sid: number
pid: number
name: string
targetText: string
maxPrice: number
minLeft: number
cookieText: string
selectedAccountLines: number[]
loops: number
delayMs: number
concurrency: number
stopAfterSuccess: boolean
stopAccountAfterSuccess: boolean
prewarm: boolean
directExchange: boolean
sharePrizeList: boolean
autoSelect: boolean
refreshAfterSuccess: boolean
leadMs: number
frequentBackoffMs: number
errorBackoffMs: number
timeoutSec: number
startAt: string
}
type AccountPreview = {
line: number
uid: number
valid: boolean
error?: string
}
type CookieEntry = {
id: string
text: string
uid: number
valid: boolean
error?: string
updatedAt: number
}
type LogEntry = {
time: string
level: string
message: string
}
type PrizeInfo = {
id: number
sid?: number
name: string
score?: number
new_score?: number
num?: number
left_num?: number
status?: number
percent?: string
is_show_num?: number
img?: string
}
type PrizeCache = {
prizes: PrizeInfo[]
updatedAt: number
sid?: number
}
type ScoreResult = {
uid: number
score: number
resp?: {
status?: number
msg?: string
}
error?: string
}
type UserPrizeInfo = {
id?: number
name?: string
score?: number
new_score?: number
status?: number
create_time?: number
}
type RecordResult = {
uid: number
records?: UserPrizeInfo[]
resp?: {
msg?: string
}
error?: string
}
type ExchangeInfo = {
pid?: number
exchange_score?: number
exchangeScore?: number
desc?: string
}
type ActPreCondition = {
tip?: string
}
type ExchangeResp = {
status?: number
msg?: string
order_id?: string
orderId?: string
exchange_info?: ExchangeInfo
exchangeInfo?: ExchangeInfo
act_pre_condition?: ActPreCondition
actPreCondition?: ActPreCondition
}
type ExchangeResult = {
uid: number
resp?: ExchangeResp
category?: string
message?: string
error?: string
}
type MonitorResult = {
key?: string
loop?: number
uid: number
target_index?: number
targetIndex?: number
action?: string
category?: string
message?: string
done?: boolean
prize?: PrizeInfo
exchange?: ExchangeResp
score?: ScoreResult['resp']
records?: RecordResult['resp']
error?: string
}
type RecordRow = UserPrizeInfo & {
key: string
uid: number
}
type SavedConfig = Omit<UiConfig, 'cookieText' | 'selectedAccountLines'>
type ScheduleCountdown = {
targetAt: number
fireAt: number
}
function getExchangeInfo(resp?: ExchangeResp): ExchangeInfo {
return resp?.exchange_info || resp?.exchangeInfo || {}
}
function getPreCondition(resp?: ExchangeResp): ActPreCondition {
return resp?.act_pre_condition || resp?.actPreCondition || {}
}
function priceOf(row?: PrizeInfo): number {
if (!row) return 0
return row.new_score || row.score || 0
}
type InventoryInfo = {
left?: number
total?: number
percent?: string
estimated: boolean
}
function inventoryOf(row?: PrizeInfo): InventoryInfo {
if (!row) return { estimated: false }
const total = Number(row.num || 0)
const percent = normalizePercent(row.percent)
const rawLeft = Number(row.left_num || 0)
if (rawLeft > 0 || percent === '0%') {
return {
left: rawLeft,
total: total > 0 ? total : undefined,
percent,
estimated: false,
}
}
if (total > 0 && percent) {
const percentValue = Number(percent.replace('%', ''))
if (Number.isFinite(percentValue)) {
return {
left: Math.floor((total * percentValue) / 100),
total,
percent,
estimated: true,
}
}
}
return {
total: total > 0 ? total : undefined,
percent,
estimated: false,
}
}
function normalizePercent(value?: string): string | undefined {
const text = String(value || '').trim()
if (!text) return undefined
const numeric = Number(text.replace('%', ''))
if (!Number.isFinite(numeric)) return text
return `${numeric}%`
}
function inventoryText(row?: PrizeInfo): string {
const info = inventoryOf(row)
const left = info.left === undefined ? '-' : `${info.estimated ? '约 ' : ''}${info.left}`
const total = info.total === undefined ? '-' : String(info.total)
return `剩余 ${left} / 总量 ${total}${info.percent ? ` · ${info.percent}` : ''}`
}
function summarizeInventory(items: PrizeInfo[]): string {
let left = 0
let total = 0
let leftKnown = 0
let totalKnown = 0
items.forEach((item) => {
const info = inventoryOf(item)
if (info.left !== undefined) {
left += info.left
leftKnown += 1
}
if (info.total !== undefined) {
total += info.total
totalKnown += 1
}
})
if (leftKnown === 0 && totalKnown === 0) return '库存未公开'
return `剩余 ${leftKnown ? left : '-'} / 总量 ${totalKnown ? total : '-'}`
}
function statusTag(status?: number, error?: string): ReactNode {
if (error) return <Tag color="error">失败</Tag>
if (status === 200) return <Tag color="success">成功</Tag>
if (status) return <Tag color="warning">{status}</Tag>
return <Tag>待处理</Tag>
}
function categoryTag(category?: string): ReactNode {
const map: Record<string, { color: string; label: string }> = {
ready: { color: 'blue', label: '预热' },
success: { color: 'success', label: '成功' },
not_found: { color: 'default', label: '未命中' },
not_open: { color: 'processing', label: '未开放' },
too_frequent: { color: 'warning', label: '频控' },
not_enough: { color: 'error', label: '不足' },
already_done: { color: 'purple', label: '已兑' },
off_shelf: { color: 'error', label: '下架' },
sold_out: { color: 'error', label: '抢完' },
condition: { color: 'geekblue', label: '条件' },
failed: { color: 'warning', label: '失败' },
error: { color: 'error', label: '异常' },
}
if (!category) return <Tag>待处理</Tag>
const item = map[category] || { color: 'default', label: category }
return <Tag color={item.color}>{item.label}</Tag>
}
function readSavedConfig(): Partial<SavedConfig> {
try {
return JSON.parse(localStorage.getItem(storageKey) || '{}') as Partial<SavedConfig>
} catch {
return {}
}
}
function writeSavedConfigPatch(patch: Partial<SavedConfig>) {
localStorage.setItem(storageKey, JSON.stringify({ ...readSavedConfig(), ...patch }))
}
function readSavedCookies(): CookieEntry[] {
try {
const items = JSON.parse(localStorage.getItem(cookieStorageKey) || '[]') as Partial<CookieEntry>[]
return items
.map((item, index) => ({
id: item.id || `ck-${Date.now()}-${index}`,
text: String(item.text || '').trim(),
uid: Number(item.uid || 0),
valid: Boolean(item.valid),
error: item.error,
updatedAt: Number(item.updatedAt || Date.now()),
}))
.filter((item) => item.text)
} catch {
return []
}
}
function writeSavedCookies(items: CookieEntry[]) {
localStorage.setItem(cookieStorageKey, JSON.stringify(items))
}
function readSavedPrizeCache(): PrizeCache {
try {
const cache = JSON.parse(localStorage.getItem(prizeCacheStorageKey) || '{}') as Partial<PrizeCache>
const prizes = Array.isArray(cache.prizes)
? cache.prizes.filter((item) => Number(item?.id || 0) > 0 && String(item?.name || '').trim())
: []
return {
prizes: prizes as PrizeInfo[],
updatedAt: Number(cache.updatedAt || 0),
sid: Number(cache.sid || 0) || undefined,
}
} catch {
return { prizes: [], updatedAt: 0 }
}
}
function writeSavedPrizeCache(prizes: PrizeInfo[], sid: number) {
localStorage.setItem(
prizeCacheStorageKey,
JSON.stringify({
prizes,
sid,
updatedAt: Date.now(),
}),
)
}
function normalizeCookieText(text: string): string {
const normalized = text
.replace(/\r\n?/g, '\n')
.replace(/\\r\\n|\\r|\\n/g, '\n')
const withLineBreaks =
normalized.includes('\n') || !/\/r\/n|\/r/.test(normalized)
? normalized
: normalized.replace(/\/r\/n|\/r|\/n/g, '\n')
return withLineBreaks
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.join('\n')
}
function cookieTextFromEntries(items: CookieEntry[]): string {
return items
.map((item) => item.text.trim())
.filter(Boolean)
.join('\n')
}
function newCookieID(): string {
return `ck-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
}
function pickConfig(values: Partial<UiConfig>): UiConfig {
return {
transport: values.transport || 'http',
wssHost: values.wssHost || '',
sid: Number(values.sid || 0),
pid: Number(values.pid || 0),
name: values.name || '',
targetText: values.targetText || '',
maxPrice: Number(values.maxPrice || 0),
minLeft: Number(values.minLeft || 0),
cookieText: values.cookieText || '',
selectedAccountLines: values.selectedAccountLines || [],
loops: Number(values.loops || 1),
delayMs: Number(values.delayMs || 150),
concurrency: Number(values.concurrency || 1),
stopAfterSuccess: Boolean(values.stopAfterSuccess),
stopAccountAfterSuccess: values.stopAccountAfterSuccess !== false,
prewarm: values.prewarm !== false,
directExchange: values.directExchange !== false,
sharePrizeList: values.sharePrizeList !== false,
autoSelect: Boolean(values.autoSelect),
refreshAfterSuccess: values.refreshAfterSuccess !== false,
leadMs: Number(values.leadMs || 0),
frequentBackoffMs: Number(values.frequentBackoffMs || 300),
errorBackoffMs: Number(values.errorBackoffMs || 300),
timeoutSec: Number(values.timeoutSec || 15),
startAt: values.startAt || '',
}
}
function normalizeMonitorResult(row: MonitorResult): MonitorResult {
return {
...row,
key: `monitor-${row.loop || 0}-${row.uid}-${Date.now()}-${Math.random()}`,
}
}
export default function Dashboard() {
const { message, modal } = AntApp.useApp()
const [form] = Form.useForm<UiConfig>()
const previewedCookieText = useRef('')
const watchedTransport = Form.useWatch('transport', form)
const watchedPID = Form.useWatch('pid', form)
const watchedName = Form.useWatch('name', form)
const watchedCookieText = Form.useWatch('cookieText', form)
const watchedStartAt = Form.useWatch('startAt', form)
const [loading, setLoading] = useState('')
const [running, setRunning] = useState(false)
const [accounts, setAccounts] = useState<AccountPreview[]>([])
const [selectedAccountLines, setSelectedAccountLines] = useState<Key[]>([])
const [prizes, setPrizes] = useState<PrizeInfo[]>([])
const [selectedPrizeKeys, setSelectedPrizeKeys] = useState<Key[]>([])
const [productSearch, setProductSearch] = useState('')
const [scores, setScores] = useState<ScoreResult[]>([])
const [records, setRecords] = useState<RecordResult[]>([])
const [results, setResults] = useState<MonitorResult[]>([])
const [logs, setLogs] = useState<LogEntry[]>([])
const [now, setNow] = useState(new Date())
const [scheduleCountdown, setScheduleCountdown] = useState<ScheduleCountdown | null>(null)
const [settingsOpen, setSettingsOpen] = useState(false)
const [cookieManagerOpen, setCookieManagerOpen] = useState(false)
const [cookieEditOpen, setCookieEditOpen] = useState(false)
const [editingCookie, setEditingCookie] = useState<CookieEntry | null>(null)
const [cookieDraft, setCookieDraft] = useState('')
const [cookieEntries, setCookieEntries] = useState<CookieEntry[]>([])
const [prizeCacheUpdatedAt, setPrizeCacheUpdatedAt] = useState(0)
const [configLoaded, setConfigLoaded] = useState(false)
const applyCookieEntries = async (entries: CookieEntry[], persist = true, refreshLatest = false) => {
const normalizedEntries = entries
.map((entry) => ({
...entry,
text: normalizeCookieText(entry.text).split('\n')[0] || '',
}))
.filter((entry) => entry.text)
const cookieText = cookieTextFromEntries(normalizedEntries)
previewedCookieText.current = cookieText
form.setFieldsValue({ cookieText })
setSelectedPrizeKeys([])
if (!cookieText) {
setCookieEntries([])
setAccounts([])
setSelectedAccountLines([])
if (persist) writeSavedCookies([])
return []
}
const previews = (await PreviewAccounts(cookieText)) as AccountPreview[]
const enriched = normalizedEntries.map((entry, index) => {
const preview = previews.find((item) => item.line === index + 1)
return {
...entry,
uid: preview?.uid || 0,
valid: Boolean(preview?.valid),
error: preview?.error,
}
})
setCookieEntries(enriched)
setAccounts(previews || [])
setSelectedAccountLines((previews || []).filter((item) => item.valid).map((item) => item.line))
if (persist) writeSavedCookies(enriched)
if (refreshLatest && enriched.some((item) => item.valid)) {
window.setTimeout(() => queryPrizes(true), 0)
}
return enriched
}
useEffect(() => {
let mounted = true
Promise.all([DefaultConfig(), GetLogs(), IsRunning()])
.then(([defaults, logItems, isRunning]) => {
if (!mounted) return
const savedCookies = readSavedCookies()
const savedPrizeCache = readSavedPrizeCache()
form.setFieldsValue({
...(defaults as UiConfig),
...readSavedConfig(),
cookieText: cookieTextFromEntries(savedCookies),
})
setCookieEntries(savedCookies)
setPrizes(savedPrizeCache.prizes)
setPrizeCacheUpdatedAt(savedPrizeCache.updatedAt)
setLogs((logItems || []) as LogEntry[])
setRunning(Boolean(isRunning))
setConfigLoaded(true)
if (savedCookies.length > 0) {
applyCookieEntries(savedCookies, true, true).catch((err: unknown) => message.error(String(err)))
}
})
.catch((err: unknown) => message.error(String(err)))
const offLog = EventsOn('gui:log', (entry: LogEntry) => {
setLogs((prev) => [...prev.slice(-499), entry])
})
const offResult = EventsOn('gui:monitor-result', (result: MonitorResult) => {
setResults((prev) => [normalizeMonitorResult(result), ...prev].slice(0, 300))
})
const offState = EventsOn(
'gui:monitor-state',
(state: { running?: boolean; message?: string }) => {
const nextRunning = Boolean(state?.running)
setRunning(nextRunning)
if (!nextRunning) setScheduleCountdown(null)
if (state?.message) message.info(state.message)
},
)
const tick = window.setInterval(() => setNow(new Date()), 250)
return () => {
mounted = false
offLog()
offResult()
offState()
window.clearInterval(tick)
}
}, [form, message])
useEffect(() => {
if ((watchedCookieText || '') === previewedCookieText.current) return
setAccounts([])
setSelectedAccountLines([])
setSelectedPrizeKeys([])
}, [watchedCookieText])
useEffect(() => {
if (!configLoaded) return
writeSavedConfigPatch({ startAt: String(form.getFieldValue('startAt') || '') })
}, [configLoaded, form, watchedStartAt])
const validAccounts = useMemo(() => accounts.filter((item) => item.valid), [accounts])
const importedCookieCount = useMemo(
() => normalizeCookieText(watchedCookieText || '').split('\n').filter(Boolean).length,
[watchedCookieText],
)
const selectedAccountCount = selectedAccountLines.length
const selectedPrizeID =
selectedPrizeKeys.length === 1 ? Number(selectedPrizeKeys[0]) || undefined : undefined
const scoreMap = useMemo(() => {
const map = new Map<number, ScoreResult>()
scores.forEach((item) => map.set(item.uid, item))
return map
}, [scores])
const latestResultByUID = useMemo(() => {
const map = new Map<number, MonitorResult>()
results.forEach((item) => {
if (!map.has(item.uid)) map.set(item.uid, item)
})
return map
}, [results])
const successCount = useMemo(
() => new Set(results.filter((item) => item.category === 'success').map((item) => item.uid)).size,
[results],
)
const errorCount = useMemo(
() =>
results.filter((item) =>
['error', 'failed', 'not_enough', 'off_shelf', 'sold_out', 'condition'].includes(
item.category || '',
),
).length,
[results],
)
const pendingCount = Math.max(selectedAccountCount - successCount, 0)
const currentPrize = useMemo(() => {
const fromSelection = prizes.find((item) => item.id === selectedPrizeID)
if (fromSelection) return fromSelection
const pid = Number(watchedPID || 0)
return prizes.find((item) => item.id === pid)
}, [prizes, selectedPrizeID, watchedPID])
const filteredPrizes = useMemo(() => {
const keyword = productSearch.trim().toLowerCase()
if (!keyword) return prizes
return prizes.filter(
(item) =>
item.name.toLowerCase().includes(keyword) ||
String(item.id).includes(keyword) ||
String(priceOf(item)).includes(keyword),
)
}, [prizes, productSearch])
const inventorySummary = useMemo(() => summarizeInventory(filteredPrizes), [filteredPrizes])
const config = () => ({
...pickConfig(form.getFieldsValue(true)),
selectedAccountLines: selectedAccountLines.map(Number).filter((line) => line > 0),
})
const runAction = async <T,>(
key: string,
action: () => Promise<T>,
onDone?: (data: T) => void,
) => {
setLoading(key)
try {
const data = await action()
onDone?.(data)
message.success('完成')
} catch (err: unknown) {
message.error(String(err))
} finally {
setLoading('')
}
}
const upsertCookieLines = async (lines: string[]) => {
const normalizedText = normalizeCookieText(lines.join('\n'))
const nextLines = normalizedText.split('\n').filter(Boolean)
if (nextLines.length === 0) return { added: 0, updated: 0 }
const previews = (await PreviewAccounts(nextLines.join('\n'))) as AccountPreview[]
const nextEntries = [...cookieEntries]
let added = 0
let updated = 0
nextLines.forEach((text, index) => {
const preview = previews.find((item) => item.line === index + 1)
const uid = preview?.uid || 0
const valid = Boolean(preview?.valid)
const existingIndex =
valid && uid > 0
? nextEntries.findIndex((item) => item.valid && item.uid === uid)
: nextEntries.findIndex((item) => item.text === text)
const entry: CookieEntry = {
id: existingIndex >= 0 ? nextEntries[existingIndex].id : newCookieID(),
text,
uid,
valid,
error: preview?.error,
updatedAt: Date.now(),
}
if (existingIndex >= 0) {
nextEntries[existingIndex] = entry
updated += 1
} else {
nextEntries.push(entry)
added += 1
}
})
await applyCookieEntries(nextEntries, true, true)
return { added, updated }
}
const importCookiesFromClipboard = async () => {
setLoading('clipboard')
try {
const clipboardText = await ClipboardGetText()
const cookieText = normalizeCookieText(clipboardText || '')
if (!cookieText) {
message.warning('剪贴板没有可导入的 CK')
return
}
const result = await upsertCookieLines(cookieText.split('\n'))
message.success(`已导入 ${result.added} 条,更新 ${result.updated} 条 CK`)
} catch (err: unknown) {
message.error(`剪贴板导入失败: ${String(err)}`)
} finally {
setLoading('')
}
}
const openNewCookie = () => {
setEditingCookie(null)
setCookieDraft('')
setCookieEditOpen(true)
}
const openEditCookie = (entry: CookieEntry) => {
setEditingCookie(entry)
setCookieDraft(entry.text)
setCookieEditOpen(true)
}
const saveCookieDraft = async () => {
const normalizedText = normalizeCookieText(cookieDraft)
if (!normalizedText) {
message.warning('CK 不能为空')
return
}
setLoading('cookie-save')
try {
if (editingCookie) {
const text = normalizedText.split('\n')[0]
const nextEntries = cookieEntries.map((entry) =>
entry.id === editingCookie.id ? { ...entry, text, updatedAt: Date.now() } : entry,
)
await applyCookieEntries(nextEntries, true, true)
message.success('CK 已更新')
} else {
const result = await upsertCookieLines(normalizedText.split('\n'))
message.success(`已新增 ${result.added} 条,更新 ${result.updated} 条 CK`)
}
setCookieEditOpen(false)
setCookieDraft('')
setEditingCookie(null)
} catch (err: unknown) {
message.error(String(err))
} finally {
setLoading('')
}
}
const deleteCookie = async (entry: CookieEntry) => {
await applyCookieEntries(cookieEntries.filter((item) => item.id !== entry.id))
message.success('CK 已删除')
}
const clearCookies = () => {
modal.confirm({
title: '清空 CK',
content: '确认删除所有已保存 CK?',
okText: '清空',
cancelText: '取消',
okButtonProps: { danger: true },
onOk: async () => {
await applyCookieEntries([])
message.success('CK 已清空')
},
})
}
const reloadSavedCookies = async () => {
const savedCookies = readSavedCookies()
await applyCookieEntries(savedCookies, true, true)
message.success('已重新载入本地 CK')
}
function queryPrizes(quiet = false) {
if (!String(form.getFieldValue('cookieText') || '').trim()) {
if (!quiet) message.warning('Cookie 不能为空')
return
}
setLoading('list')
ListPrizes(config())
.then((data) => {
const result = data as { prizes?: PrizeInfo[] }
const nextPrizes = result?.prizes || []
if (nextPrizes.length === 0) {
if (!quiet) message.warning('当前 CK 未返回可选商品')
return
}
const updatedAt = Date.now()
setPrizes(nextPrizes)
setPrizeCacheUpdatedAt(updatedAt)
writeSavedPrizeCache(nextPrizes, Number(form.getFieldValue('sid') || 0))
setSelectedPrizeKeys((prev) =>
prev.filter((key) => nextPrizes.some((item) => item.id === Number(key))),
)
if (!quiet) message.success('商品已刷新')
})
.catch((err: unknown) => {
if (!quiet) message.error(String(err))
})
.finally(() => {
setLoading('')
})
}
const productListUpdatedAt = prizeCacheUpdatedAt
? new Date(prizeCacheUpdatedAt).toLocaleString('zh-CN', { hour12: false })
: ''
const clearPrizeCache = () => {
modal.confirm({
title: '清空商品缓存',
content: '确认清空本地缓存的商品列表?',
okText: '清空',
cancelText: '取消',
okButtonProps: { danger: true },
onOk: () => {
localStorage.removeItem(prizeCacheStorageKey)
setPrizes([])
setSelectedPrizeKeys([])
setPrizeCacheUpdatedAt(0)
message.success('商品缓存已清空')
}
})
}
const saveSettings = () => {
const values = config()
const saved: SavedConfig = {
transport: values.transport,
wssHost: values.wssHost,
sid: values.sid,
pid: values.pid,
name: values.name,
targetText: values.targetText,
maxPrice: values.maxPrice,
minLeft: values.minLeft,
loops: values.loops,
delayMs: values.delayMs,
concurrency: values.concurrency,
stopAfterSuccess: values.stopAfterSuccess,
stopAccountAfterSuccess: values.stopAccountAfterSuccess,
prewarm: values.prewarm,
directExchange: values.directExchange,
sharePrizeList: values.sharePrizeList,
autoSelect: values.autoSelect,
refreshAfterSuccess: values.refreshAfterSuccess,
leadMs: values.leadMs,
frequentBackoffMs: values.frequentBackoffMs,
errorBackoffMs: values.errorBackoffMs,
timeoutSec: values.timeoutSec,
startAt: values.startAt,
}
localStorage.setItem(storageKey, JSON.stringify(saved))
message.success('已保存')
}
const applyPrizeSelection = (keys: Key[]) => {
const normalized = keys.map(Number).filter((item) => item > 0)
setSelectedPrizeKeys(normalized)
const selected = prizes.filter((item) => normalized.includes(item.id))
if (selected.length === 0) {
form.setFieldsValue({ pid: 0, name: '', targetText: '' })
return
}
form.setFieldsValue({
pid: selected[0].id,
name: selected[0].name,
targetText: selected.map((item) => String(item.id)).join('\n'),
autoSelect: false,
})
}
const startMonitor = (scheduled: boolean) => {
const values = config()
const countdown = scheduled ? buildScheduleCountdown(values.startAt, values.leadMs, now) : null
if (scheduled && !countdown) {
message.warning('请先填写定时目标时间(HH:mm:ss)')
return undefined
}
if (!scheduled) {
values.startAt = ''
setScheduleCountdown(null)
}
setResults([])
return runAction('monitor', () => StartMonitor(values), () => {
setRunning(true)
setScheduleCountdown(countdown)
})
}
const directExchange = () => {
const values = config()
if (Number(values.pid || 0) <= 0) {
message.warning('请先在左侧商品列表中选择要兑换的商品')
return undefined
}
return runAction(
'exchange',
() => Exchange(values),
(data) => {
const items = (data || []) as ExchangeResult[]
setResults(
items.map((item, index) => ({
key: `exchange-${item.uid}-${index}-${Date.now()}`,
uid: item.uid,
action: 'exchange',
category: item.category || (item.error ? 'error' : 'failed'),
message: item.message || item.error || item.resp?.msg || '',
exchange: item.resp,
})),
)
const success = items.filter((item) => item.category === 'success').length
message.info(`直接兑换:成功 ${success}/${items.length}`)
},
)
}
const selectAllAccounts = () => {
setSelectedAccountLines(validAccounts.map((item) => item.line))
}
const clearAccounts = () => {
setSelectedAccountLines([])
}
const accountColumns: ColumnsType<AccountPreview> = useMemo(
() => [
{
title: '账号',
width: 150,
render: (_, row) => (
<div>
<strong>{row.uid || `第${row.line}行`}</strong>
<div className="muted">{row.valid ? '已加入任务' : row.error || '未选择'}</div>
</div>
),
},
{
title: '凭据',
width: 90,
render: (_, row) => (row.valid ? <Tag color="green">已加载</Tag> : <Tag>无效</Tag>),
},
{
title: '商品',
width: 150,
ellipsis: true,
render: () => currentPrize?.name || watchedName || '未选择',
},
{
title: '积分',
width: 84,
render: (_, row) => scoreMap.get(row.uid)?.score ?? '-',
},
{
title: '状态',
width: 98,
render: (_, row) => categoryTag(latestResultByUID.get(row.uid)?.category),
},
{
title: '最后结果',
ellipsis: true,
render: (_, row) => {
const result = latestResultByUID.get(row.uid)
return result?.error || result?.message || result?.exchange?.msg || ''
},
},
],
[currentPrize?.name, latestResultByUID, scoreMap, watchedName],
)
const cookieColumns: ColumnsType<CookieEntry> = useMemo(
() => [
{
title: '账号',
width: 150,
render: (_, row) => (
<div>
<strong>{row.uid || '-'}</strong>
<div className="muted">{row.valid ? '有效 CK' : row.error || '待校验'}</div>
</div>
),
},
{
title: '状态',
width: 86,
render: (_, row) => (row.valid ? <Tag color="green">有效</Tag> : <Tag color="error">无效</Tag>),
},
{
title: 'CK',
ellipsis: true,
render: (_, row) => <code className="ck-text-preview">{row.text}</code>,
},
{
title: '更新时间',
width: 170,
render: (_, row) => new Date(row.updatedAt).toLocaleString('zh-CN', { hour12: false }),
},
{
title: '操作',
width: 150,
render: (_, row) => (
<Space size={6}>
<Button size="small" icon={<Pencil size={14} />} onClick={() => openEditCookie(row)}>
编辑
</Button>
<Button size="small" danger icon={<Trash2 size={14} />} onClick={() => deleteCookie(row)}>
删除
</Button>
</Space>
),
},
],
[cookieEntries],
)
const prizeColumns: ColumnsType<PrizeInfo> = useMemo(
() => [
{ title: 'PID', dataIndex: 'id', width: 92 },
{ title: '名称', dataIndex: 'name', minWidth: 220, ellipsis: true },
{ title: '库存', minWidth: 190, render: (_, row) => inventoryText(row) },
{ title: '价格', width: 84, render: (_, row) => priceOf(row) || '-' },
],
[],
)
const scoreColumns: ColumnsType<ScoreResult> = useMemo(
() => [
{ title: 'UID', dataIndex: 'uid', width: 140 },
{ title: '可用积分', dataIndex: 'score', width: 110 },
{ title: '状态', width: 90, render: (_, row) => statusTag(row.resp?.status, row.error) },
{ title: '消息', render: (_, row) => row.error || row.resp?.msg || '' },
],
[],
)
const recordRows = useMemo<RecordRow[]>(() => {
const rows: RecordRow[] = []
records.forEach((item) => {
if (!item.records?.length) {
rows.push({ key: `${item.uid}-empty`, uid: item.uid, name: item.error || item.resp?.msg || '' })
return
}
item.records.forEach((record, index) => {
rows.push({ key: `${item.uid}-${index}-${record.id || 'record'}`, uid: item.uid, ...record })
})
})
return rows
}, [records])
const recordColumns: ColumnsType<RecordRow> = useMemo(
() => [
{ title: 'UID', dataIndex: 'uid', width: 140 },
{ title: '名称', dataIndex: 'name', minWidth: 220, ellipsis: true },
{ title: '积分', width: 90, render: (_, row) => row.new_score || row.score || 0 },
{ title: '状态', dataIndex: 'status', width: 84 },
{ title: '时间', dataIndex: 'create_time', width: 130 },
],
[],
)
const resultColumns: ColumnsType<MonitorResult> = useMemo(
() => [
{ title: '轮次', dataIndex: 'loop', width: 70 },
{ title: 'UID', dataIndex: 'uid', width: 132 },
{ title: '动作', dataIndex: 'action', width: 112 },
{ title: '分类', width: 90, render: (_, row) => categoryTag(row.category) },
{
title: '奖品',
minWidth: 220,
ellipsis: true,
render: (_, row) => row.prize?.name || getExchangeInfo(row.exchange).desc || '',
},
{
title: '消息',
render: (_, row) =>
row.error ||
row.message ||
row.exchange?.msg ||
getPreCondition(row.exchange).tip ||
row.exchange?.order_id ||
row.exchange?.orderId ||
'',
},
],
[],
)
const panelTabs = [
{
key: 'logs',
label: '日志',
children: <LogView logs={logs} />,
},
{
key: 'results',
label: '结果',
children: (
<Table
size="small"
rowKey={(row) => row.key || `${row.uid}-${row.loop}`}
columns={resultColumns}
dataSource={results}
pagination={{ pageSize: 12, showSizeChanger: false }}
scroll={{ x: 820, y: 520 }}
/>
),
},
{
key: 'stats',
label: '统计',
children: (
<div className="stats-pane">
<Statistic title="成功账号" value={successCount} />
<Statistic title="异常事件" value={errorCount} />
<Statistic title="待处理账号" value={pendingCount} />
<Statistic title="日志条数" value={logs.length} />
</div>
),
},
{
key: 'records',
label: '复盘',
children: (
<Table
size="small"
rowKey={(row) => row.key}
columns={recordColumns}
dataSource={recordRows}
pagination={{ pageSize: 12, showSizeChanger: false }}
scroll={{ x: 740, y: 520 }}
/>
),
},
]
return (
<Form form={form} component={false}>
<div className="app-shell">
<aside className="product-sidebar">
<div className="sidebar-head">
<div>
<Typography.Title level={4}>虎牙抢购</Typography.Title>
<Typography.Text type="secondary">实时库存 · 多账号 · 定时触发</Typography.Text>
</div>
<Tag>v0.2.0</Tag>
</div>
<Button
type="primary"
block
icon={<RefreshCw size={16} />}
loading={loading === 'list'}
disabled={validAccounts.length === 0}
onClick={() => queryPrizes()}
>
用 CK 刷新商品
</Button>
<Input.Search
allowClear
className="product-search"
value={productSearch}
onChange={(event) => setProductSearch(event.target.value)}
onSearch={setProductSearch}
/>
<CurrentProduct prize={currentPrize} />
<div className="inventory-line">
<span>商品缓存 {filteredPrizes.length} 个</span>
<span>{inventorySummary}</span>
<span>{productListUpdatedAt ? `更新 ${productListUpdatedAt}` : '未缓存'}</span>
</div>
<div className="product-list">
{filteredPrizes.length === 0 ? (
<div className="product-empty">
<strong>暂无商品缓存</strong>
<span>导入有效 CK 后刷新最新商品</span>
</div>
) : (
filteredPrizes.map((item) => (
<button
type="button"
className={`product-row ${selectedPrizeKeys.includes(item.id) ? 'selected' : ''}`}
key={item.id}
onClick={() => applyPrizeSelection([item.id])}
>
<div className="product-thumb">
{item.img ? (
<img src={item.img} alt="" />
) : (
<span>{String(item.id).slice(-2)}</span>
)}
</div>
<div className="product-meta">
<strong>{item.name}</strong>
<div className="product-tags">
<Tag color="gold">{priceOf(item)} 积分</Tag>
<InventoryTags prize={item} compact />
</div>
</div>
</button>
))
)}
</div>
</aside>
<main className="desk">
<header className="topbar">
<div className="tab-strip">
<Button type="primary">抢购控制台</Button>
<Button onClick={() => setCookieManagerOpen(true)}>CK 管理</Button>
</div>
<CountdownBadge countdown={scheduleCountdown} now={now} running={running} />
<Space size={12}>
<Tag>{String(watchedTransport || 'http').toUpperCase()}</Tag>
<Button icon={<Settings size={16} />} onClick={() => setSettingsOpen(true)}>
设置
</Button>
<Tag color={running ? 'processing' : 'default'}>{running ? '运行中' : '空闲'}</Tag>
</Space>
</header>
<section className="statusbar">
<span>服务器</span>
<strong className="status-time">{formatClock(now)}</strong>
<span>账号</span>
<strong className="status-count status-account-count">
{selectedAccountCount}/{validAccounts.length}
</strong>
<span>成功</span>
<strong className="status-count green">{successCount}</strong>
<span>异常</span>
<strong className="status-count red">{errorCount}</strong>
<span>待处理</span>
<strong className="status-count">{pendingCount}</strong>
</section>
<section className="actionbar">
<Space wrap>
<Button
danger
type="primary"
icon={<ShoppingCart size={16} />}
loading={loading === 'monitor'}
disabled={running}
onClick={() => startMonitor(false)}
>
立即抢购
</Button>
<Button
className="green-button"
type="primary"
icon={<Play size={16} />}
loading={loading === 'monitor'}
disabled={running}
onClick={() => startMonitor(true)}
>
开启定时
</Button>
<Button
icon={<Zap size={16} />}
loading={loading === 'exchange'}
disabled={running || validAccounts.length === 0}
onClick={directExchange}
>
直接兑换
</Button>
<Button
icon={<ClipboardPaste size={16} />}
loading={loading === 'clipboard'}
onClick={importCookiesFromClipboard}
>
剪贴板导入 CK
</Button>
<Button
icon={<RefreshCw size={16} />}
loading={loading === 'score'}
onClick={() =>
runAction('score', () => QueryScores(config()), (data) =>
setScores((data || []) as ScoreResult[]),
)
}
>
查询积分
</Button>
<Button
icon={<ClipboardList size={16} />}
loading={loading === 'records'}
onClick={() =>
runAction('records', () => GetRecords(config()), (data) =>
setRecords((data || []) as RecordResult[]),
)
}
>
兑换记录
</Button>
<Button
icon={<Square size={16} />}
disabled={!running}
onClick={async () => {
await StopMonitor()
setRunning(false)
setScheduleCountdown(null)
}}
>
停止
</Button>
</Space>
<Space className="schedule-target">
<span className="muted">定时目标</span>
<Form.Item name="startAt" noStyle>
<ScheduleTimeInput />
</Form.Item>
</Space>
</section>
<section className="main-grid">
<div className="account-panel">
<div className="panel-head">
<Typography.Title level={5}>账号作战表</Typography.Title>
<Space>
<Tag>{validAccounts.length} 个账号</Tag>
<Tag color="blue">已选 {selectedAccountCount}</Tag>
<Button size="small" icon={<ListChecks size={14} />} onClick={selectAllAccounts}>
全选
</Button>
<Button size="small" icon={<Square size={14} />} onClick={clearAccounts}>
清空
</Button>
<Tooltip title="保存设置">
<Button size="small" icon={<Save size={14} />} onClick={saveSettings} />
</Tooltip>
</Space>
</div>
<Table
size="small"
rowKey={(row) => row.line}
rowSelection={{
selectedRowKeys: selectedAccountLines,
onChange: setSelectedAccountLines,
getCheckboxProps: (row) => ({ disabled: !row.valid }),
}}
columns={accountColumns}
dataSource={accounts}
pagination={false}
scroll={{ x: 850, y: 560 }}
/>
</div>
<div className="side-log-panel">
<div className="panel-tools">
<Tabs items={panelTabs} size="small" />
</div>
</div>
</section>
</main>
</div>
<Form.Item name="name" hidden>
<Input />
</Form.Item>
<Form.Item name="targetText" hidden>
<Input />
</Form.Item>
<Form.Item name="cookieText" hidden>
<Input />
</Form.Item>
<Modal
title="CK 管理"
open={cookieManagerOpen}
width={980}
onCancel={() => setCookieManagerOpen(false)}
footer={null}
>
<div className="ck-manager">
<div className="ck-manager-tools">
<Space wrap>
<Button type="primary" icon={<Plus size={16} />} onClick={openNewCookie}>
新增 CK
</Button>
<Button
icon={<ClipboardPaste size={16} />}
loading={loading === 'clipboard'}
onClick={importCookiesFromClipboard}
>
剪贴板导入
</Button>
<Button icon={<RefreshCw size={16} />} onClick={reloadSavedCookies}>
重新载入
</Button>
</Space>
<Space wrap>
<Tag>{cookieEntries.length} 条 CK</Tag>
<Tag color="green">{validAccounts.length} 个有效</Tag>
<Button
danger
icon={<Trash2 size={16} />}
disabled={cookieEntries.length === 0}
onClick={clearCookies}
>
清空
</Button>
</Space>
</div>
<Table
size="small"
rowKey={(row) => row.id}
columns={cookieColumns}
dataSource={cookieEntries}
pagination={{ pageSize: 8, showSizeChanger: false }}
scroll={{ x: 900, y: 430 }}
/>
</div>
</Modal>
<Modal
title={editingCookie ? '编辑 CK' : '新增 CK'}
open={cookieEditOpen}
okText="保存"
cancelText="取消"
confirmLoading={loading === 'cookie-save'}
onOk={saveCookieDraft}
onCancel={() => {
setCookieEditOpen(false)
setCookieDraft('')
setEditingCookie(null)
}}
>
<Input.TextArea
rows={editingCookie ? 5 : 8}
spellCheck={false}
value={cookieDraft}
onChange={(event) => setCookieDraft(event.target.value)}
/>
</Modal>
<Modal
title="设置"
open={settingsOpen}
width={760}
onCancel={() => setSettingsOpen(false)}
footer={[
<Button key="close" onClick={() => setSettingsOpen(false)}>
关闭
</Button>,
<Button
key="save"
type="primary"
icon={<Save size={16} />}
onClick={() => {
saveSettings()
setSettingsOpen(false)
}}
>
保存
</Button>,
]}
>
<div className="settings-modal">
<div className="settings-grid">
<Form.Item name="transport" label="传输">
<Select
options={[
{ value: 'wss', label: 'WSS' },
{ value: 'http', label: 'HTTP' },
]}
/>
</Form.Item>
<Form.Item name="wssHost" label="WSS Host">
<Input allowClear />
</Form.Item>
<div className="settings-summary settings-wide">
<Typography.Text type="secondary">CK 来源</Typography.Text>
<strong>
{importedCookieCount > 0
? `已导入 ${validAccounts.length}/${importedCookieCount} 个有效账号`
: '未导入 CK'}
</strong>
</div>
<div className="settings-summary settings-wide">
<Typography.Text type="secondary">商品缓存</Typography.Text>
<Space size={8}>
<strong>
{prizes.length > 0
? `${prizes.length} 个 · ${productListUpdatedAt || '未知时间'}`
: '未缓存'}
</strong>
<Button size="small" disabled={prizes.length === 0} onClick={clearPrizeCache}>
清空
</Button>
</Space>
</div>
</div>
<div className="settings-param-grid">
<Form.Item name="sid" label="SID">
<InputNumber min={1} />
</Form.Item>
<Form.Item name="pid" label="PID">
<InputNumber min={0} />
</Form.Item>
<Form.Item name="maxPrice" label="最高积分">
<InputNumber min={0} />
</Form.Item>
<Form.Item name="minLeft" label="最少库存">
<InputNumber min={0} />
</Form.Item>
<Form.Item name="loops" label="次数">
<InputNumber min={1} max={9999} />
</Form.Item>
<Form.Item name="delayMs" label="延时">
<InputNumber min={10} max={60000} addonAfter="ms" />
</Form.Item>
<Form.Item name="concurrency" label="线程">
<InputNumber min={1} max={100} />
</Form.Item>
<Form.Item name="leadMs" label="提前">
<InputNumber min={0} max={10000} addonAfter="ms" />
</Form.Item>
</div>
<div className="settings-switch-grid">
<Form.Item name="prewarm" label="预热" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="directExchange" label="直兑" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="sharePrizeList" label="共享列表" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="autoSelect" label="自动选品" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="stopAccountAfterSuccess" label="账号成功停" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="refreshAfterSuccess" label="成功刷新" valuePropName="checked">
<Switch />
</Form.Item>
</div>
</div>
</Modal>
</Form>
)
}
function InventoryTags({ prize, compact = false }: { prize?: PrizeInfo; compact?: boolean }) {
const info = inventoryOf(prize)
const left = info.left === undefined ? '-' : `${info.estimated ? '约 ' : ''}${info.left}`
const total = info.total === undefined ? '-' : String(info.total)
return (
<span className={`inventory-tags${compact ? ' compact' : ''}`}>
<Tag color={info.left === 0 ? 'red' : 'green'}>剩余 {left}</Tag>
<Tag>总量 {total}</Tag>
{info.percent ? <Tag color="blue">{info.percent}</Tag> : null}
</span>
)
}
function CurrentProduct({ prize }: { prize?: PrizeInfo }) {
const name = prize?.name || '未从 CK 列表选择'
return (
<div className="current-product">
<span>当前商品</span>
<strong>{name}</strong>
<div className="current-product-tags">
{prize ? (
<>
<Tag color="gold">{priceOf(prize)} 积分</Tag>
<Tag>PID {prize.id}</Tag>
<InventoryTags prize={prize} />
</>
) : (
<Tag>CK 商品源</Tag>
)}
</div>
</div>
)
}
function CountdownBadge({
countdown,
now,
running,
}: {
countdown: ScheduleCountdown | null
now: Date
running: boolean
}) {
if (!running || !countdown) return <div className="countdown-slot" />
const remaining = countdown.fireAt - now.getTime()
const ready = remaining <= 0
return (
<div className={`countdown-badge${ready ? ' ready' : ''}`}>
<span>{ready ? '开抢中' : '距开抢'}</span>
<strong>{ready ? '00:00:00.000' : formatCountdownDuration(remaining)}</strong>
<small>目标 {formatTimeOfDay(new Date(countdown.targetAt))}</small>
</div>
)
}
function buildScheduleCountdown(
startAt: string,
leadMs: number,
base: Date,
): ScheduleCountdown | null {
const parts = parseScheduleTime(startAt)
if (parts.every((part) => part === null)) return null
const [hour, minute, second] = parts.map((part) => Math.max(0, Number(part || 0)))
const target = new Date(base)
target.setHours(Math.min(hour, 23), Math.min(minute, 59), Math.min(second, 59), 0)
if (target.getTime() <= base.getTime()) {
target.setDate(target.getDate() + 1)
}
const lead = Math.max(0, Number(leadMs || 0))
return {
targetAt: target.getTime(),
fireAt: target.getTime() - lead,
}
}
function formatCountdownDuration(ms: number): string {
const safeMs = Math.max(0, Math.floor(ms))
const hours = Math.floor(safeMs / 3600000)
const minutes = Math.floor((safeMs % 3600000) / 60000)
const seconds = Math.floor((safeMs % 60000) / 1000)
const milliseconds = safeMs % 1000
return `${padNumber(hours)}:${padNumber(minutes)}:${padNumber(seconds)}.${padNumber(
milliseconds,
3,
)}`
}
function formatTimeOfDay(value: Date): string {
return `${padNumber(value.getHours())}:${padNumber(value.getMinutes())}:${padNumber(
value.getSeconds(),
)}`
}
type ScheduleTimePart = number | null
function ScheduleTimeInput({
value,
onChange,
}: {
value?: string
onChange?: (value: string) => void
}) {
const [parts, setParts] = useState<[ScheduleTimePart, ScheduleTimePart, ScheduleTimePart]>([
null,
null,
null,
])
useEffect(() => {
const parsed = parseScheduleTime(value || '')
setParts(parsed)
const normalized = formatScheduleTime(parsed)
if (value && normalized && value !== normalized) {
onChange?.(normalized)
}
}, [value])
const updatePart = (index: number, nextValue: number | string | null) => {
const next = [...parts] as [ScheduleTimePart, ScheduleTimePart, ScheduleTimePart]
next[index] =
nextValue === null || nextValue === ''
? null
: Math.max(0, Math.floor(Number(nextValue) || 0))
setParts(next)
onChange?.(formatScheduleTime(next))
}
return (
<div className="schedule-time-input">
<InputNumber
controls={false}
min={0}
max={23}
precision={0}
placeholder="时"
value={parts[0]}
onChange={(next) => updatePart(0, next)}
/>
<span className="time-separator">:</span>
<InputNumber
controls={false}
min={0}
max={59}
precision={0}
placeholder="分"
value={parts[1]}
onChange={(next) => updatePart(1, next)}
/>
<span className="time-separator">:</span>
<InputNumber
controls={false}
min={0}
max={59}
precision={0}
placeholder="秒"
value={parts[2]}
onChange={(next) => updatePart(2, next)}
/>
</div>
)
}
function parseScheduleTime(value: string): [ScheduleTimePart, ScheduleTimePart, ScheduleTimePart] {
const match = value.trim().match(/(?:^|\s)(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?$/)
if (!match) return [null, null, null]
return [
Math.min(Number(match[1]), 23),
Math.min(Number(match[2]), 59),
Math.min(Number(match[3] || 0), 59),
]
}
function formatScheduleTime(parts: [ScheduleTimePart, ScheduleTimePart, ScheduleTimePart]): string {
if (parts.every((part) => part === null)) return ''
const [hour, minute, second] = parts.map((part) => Math.max(0, Number(part || 0)))
return `${padNumber(Math.min(hour, 23))}:${padNumber(Math.min(minute, 59))}:${padNumber(
Math.min(second, 59),
)}`
}
function padNumber(value: number, size = 2): string {
return String(value).padStart(size, '0')
}
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}`}>
<span>{item.time}</span>
<Tag>{item.level}</Tag>
<code>{item.message}</code>
</div>
))}
</div>
)
}
function formatClock(value: Date): string {
const pad = (num: number, size = 2) => String(num).padStart(size, '0')
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())} ${pad(
value.getHours(),
)}:${pad(value.getMinutes())}:${pad(value.getSeconds())}.${pad(value.getMilliseconds(), 3)}`
}