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, Copy, FolderOpen, History, ListChecks, Pencil, Play, Plus, RefreshCw, Save, ShieldCheck, ShoppingCart, Square, Settings, Trash2, Zap, } from 'lucide-react' import { CheckCookies, DefaultConfig, Exchange, GetInstanceInfo, GetLogFilePath, GetLogs, GetLogStats, GetRecords, GetVersion, IsRunning, ListPrizes, OpenLogDir, PreviewAccounts, QueryScores, SetLogLevel, StartMonitor, StopMonitor, } from '../wailsjs/go/gui/App' import { ClipboardGetText, ClipboardSetText, EventsOn } from '../wailsjs/runtime/runtime' const configStorageBase = 'huya-go.gui.config.v2' const cookieStorageBase = 'huya-go.gui.cookies.v1' const prizeCacheStorageBase = 'huya-go.gui.prizes.v1' const leadDefaultMigrationBase = 'huya-go.gui.lead-default.v1' // 旧版本默认把提前量写成 2500ms、线程写成 5 或 2。 // 现已调优为单实例 30 账号免排队出击(默认 30 并发)。 const legacyDefaultLeadMs = 2500 const concurrencyDefaultMigrationBase = 'huya-go.gui.concurrency-v30.v1' const defaultConcurrency = 30 const legacyDefaultSID = 2203 const defaultSID = 2277 const sidDefaultMigrationBase = 'huya-go.gui.sid-v2277.v1' const defaultMainSplit = 62 const splitStorageKeyBase = 'huya-go.gui.main-split.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 wssHost: string sid: number pid: number name: string // category 是类目筛选(类目 id 字符串,空串=全部),只影响监控时的自动选品范围。 category: string targetText: string maxPrice: number minLeft: number cookieText: string selectedAccountLines: number[] delayMs: number concurrency: number // windowSeconds 是抢购窗口秒数(窗口模式下跑到「开抢+窗口」才收工)。 windowSeconds: number stopAfterSuccess: boolean stopAccountAfterSuccess: boolean prewarm: boolean directExchange: boolean sharePrizeList: boolean autoSelect: boolean refreshAfterSuccess: boolean leadMs: number // timeSync 表示定时前用 pingSignal 校准服务端时间,并把 startAt 视为服务端时间。 timeSync: boolean frequentBackoffMs: number errorBackoffMs: number timeoutSec: number startAt: string } type CookieCheckStatus = 'valid' | 'expired' | 'unknown' | 'invalid' type CookieCheckResult = { line: number uid: number status: CookieCheckStatus message?: string statusCode?: number checkedAt: number } type AccountPreview = { line: number uid: number valid: boolean error?: string // checkStatus 是服务端登录态检查结果;valid 仅表示本地能解析出 uid。 checkStatus?: CookieCheckStatus checkMessage?: string checkedAt?: number } type CookieEntry = { id: string text: string uid: number valid: boolean error?: string checkStatus?: CookieCheckStatus checkMessage?: string checkedAt?: number updatedAt: number } 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 name: string score?: number new_score?: number num?: number left_num?: number status?: number percent?: string is_show_num?: number img?: string // tags 是类目 id(逗号分隔):99=限量返场、101=精英专享(以服务端下发的类目字典为准)。 tags?: string publish_time?: number start_time?: number end_time?: number daily_start?: string daily_end?: string } type PrizeTag = { id: number name: string weight?: number } type CategoryStat = { id: number name: string count?: number left?: number total?: number } type PrizeCache = { prizes: PrizeInfo[] categories?: PrizeTag[] categoryStats?: CategoryStat[] 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 order_id?: string img?: string prize_id?: number item_id?: number exchange_date?: 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 } // exchangeSummary 汇总某个账号本次运行里的兑换结果,供「兑换结果」列展示。 type ExchangeSummary = { success?: MonitorResult failed?: MonitorResult attempts: number skipped: number } function isExchangeAttempt(action?: string): boolean { return action === 'exchange' || action === 'exchange_error' } function exchangeOrderID(exchange?: ExchangeResp): string { return exchange?.order_id || exchange?.orderId || '' } type SavedConfig = Omit 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 } // categoryIDsOf 解析奖品 tag14 里的类目 id(服务端可能下发多个,逗号分隔)。 function categoryIDsOf(row?: PrizeInfo): number[] { return String(row?.tags || '') .split(',') .map((item) => Number(item.trim())) .filter((id) => Number.isFinite(id) && id > 0) } // categoryNamesOf 把类目 id 翻译成类目名;字典里没有时用「类目」兜底。 function categoryNamesOf(row: PrizeInfo | undefined, categories: PrizeTag[]): string[] { return categoryIDsOf(row).map((id) => categories.find((tag) => Number(tag.id) === id)?.name || `类目${id}`) } function formatDateTime(seconds?: number): string { if (!seconds || seconds <= 0) return '' const date = new Date(seconds * 1000) const pad = (value: number) => String(value).padStart(2, '0') return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}` } function formatCountdown(ms: number): string { if (ms <= 0) return '' const total = Math.floor(ms / 1000) const hours = Math.floor(total / 3600) const minutes = Math.floor((total % 3600) / 60) const seconds = total % 60 const pad = (value: number) => String(value).padStart(2, '0') return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}` } // saleStateOf 描述商品的上下架状态:限量返场是「预告上架」,页面的倒计时来自 start_time。 function saleStateOf(row: PrizeInfo | undefined, now: Date): { onSale: boolean; label: string; countdown: string } { const start = Number(row?.start_time || 0) if (!start || start <= 0) { return { onSale: true, label: '', countdown: '' } } const remains = start * 1000 - now.getTime() if (remains > 0) { return { onSale: false, label: `${formatDateTime(start)} 上架`, countdown: formatCountdown(remains) } } return { onSale: true, label: '', countdown: '' } } 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 formatUnixTime(value?: number): string { const ts = Number(value || 0) if (!ts) return '' // 服务端返回秒级时间戳,兼容毫秒。 const ms = ts > 1e12 ? ts : ts * 1000 return new Date(ms).toLocaleString('zh-CN', { hour12: false }) } function statusTag(status?: number, error?: string): ReactNode { if (error) return 失败 if (status === 200) return 成功 if (status) return {status} return 待处理 } function categoryTag(category?: string): ReactNode { const map: Record = { 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: '异常' }, // protocol 是回包结构异常(缺 tRsp/tResp),单独归类便于排查服务端错误信封。 protocol: { color: 'error', label: '协议异常' }, // session_lost 是「账号未绑定」这类瞬时会话失效,通常下一轮就能恢复。 session_lost: { color: 'volcano', label: '会话失效' }, } if (!category) return 待处理 const item = map[category] || { color: 'default', label: category } return {item.label} } function normalizeCookieCheckStatus(value: unknown): CookieCheckStatus | undefined { const status = String(value || '').trim() if (status === 'valid' || status === 'expired' || status === 'unknown' || status === 'invalid') { return status } return undefined } function isCookieCheckUnusable(status?: CookieCheckStatus): boolean { return status === 'expired' || status === 'unknown' || status === 'invalid' } function cookieCheckTag(status?: CookieCheckStatus, message?: string): ReactNode { const config: Record = { valid: { color: 'success', label: '可用' }, expired: { color: 'error', label: '失效' }, unknown: { color: 'error', label: '失效' }, invalid: { color: 'error', label: '无效' }, unchecked: { color: 'default', label: '未检查' }, } const item = config[status || 'unchecked'] const tag = {item.label} return message ? {tag} : tag } function readSavedConfig(): Partial { try { const configKey = scopedKey(configStorageBase) const saved = JSON.parse(localStorage.getItem(configKey) || '{}') as Partial let dirty = false // v0.5.0 移除了轮次和全局降速器配置,顺手清掉本地旧字段。 const legacyFields = saved as Record for (const key of ['loops', 'adaptiveThrottle', 'disableAdaptiveThrottle']) { if (key in legacyFields) { delete legacyFields[key] dirty = true } } const leadMigrationKey = scopedKey(leadDefaultMigrationBase) if (!localStorage.getItem(leadMigrationKey)) { if (Number(saved.leadMs || 0) === legacyDefaultLeadMs) { saved.leadMs = 0 dirty = true } localStorage.setItem(leadMigrationKey, '1') } const concurrencyMigrationKey = scopedKey(concurrencyDefaultMigrationBase) if (!localStorage.getItem(concurrencyMigrationKey)) { if (Number(saved.concurrency || 0) <= 5) { saved.concurrency = defaultConcurrency dirty = true } localStorage.setItem(concurrencyMigrationKey, '1') } const sidMigrationKey = scopedKey(sidDefaultMigrationBase) if (!localStorage.getItem(sidMigrationKey)) { if (Number(saved.sid || 0) === legacyDefaultSID || !saved.sid) { saved.sid = defaultSID dirty = true } localStorage.setItem(sidMigrationKey, '1') } if (dirty) { // 迁移必须立即落盘,否则下一次 readSavedConfig 又会读回旧值。 localStorage.setItem(configKey, JSON.stringify(saved)) } return saved } catch { return {} } } function writeSavedConfigPatch(patch: Partial) { localStorage.setItem(scopedKey(configStorageBase), JSON.stringify({ ...readSavedConfig(), ...patch })) } function readSavedCookies(): CookieEntry[] { try { const items = JSON.parse(localStorage.getItem(scopedKey(cookieStorageBase)) || '[]') as Partial[] 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, checkStatus: normalizeCookieCheckStatus(item.checkStatus), checkMessage: String(item.checkMessage || '').trim() || undefined, checkedAt: Number(item.checkedAt || 0) || undefined, updatedAt: Number(item.updatedAt || Date.now()), })) .filter((item) => item.text) } catch { return [] } } function writeSavedCookies(items: CookieEntry[]) { localStorage.setItem(scopedKey(cookieStorageBase), JSON.stringify(items)) } function readSavedPrizeCache(): PrizeCache { try { const cache = JSON.parse(localStorage.getItem(scopedKey(prizeCacheStorageBase)) || '{}') as Partial const prizes = Array.isArray(cache.prizes) ? cache.prizes.filter((item) => Number(item?.id || 0) > 0 && String(item?.name || '').trim()) : [] return { prizes: prizes as PrizeInfo[], categories: Array.isArray(cache.categories) ? (cache.categories as PrizeTag[]) : [], categoryStats: Array.isArray(cache.categoryStats) ? (cache.categoryStats as CategoryStat[]) : [], updatedAt: Number(cache.updatedAt || 0), sid: Number(cache.sid || 0) || undefined, } } catch { return { prizes: [], categories: [], categoryStats: [], updatedAt: 0 } } } function writeSavedPrizeCache( prizes: PrizeInfo[], sid: number, categories: PrizeTag[] = [], categoryStats: CategoryStat[] = [], ) { localStorage.setItem( scopedKey(prizeCacheStorageBase), JSON.stringify({ prizes, categories, categoryStats, 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 { return { transport: values.transport || 'wss', wssHost: values.wssHost || '', sid: Number(values.sid || defaultSID), pid: Number(values.pid || 0), name: values.name || '', category: values.category || '', targetText: values.targetText || '', maxPrice: Number(values.maxPrice || 0), minLeft: Number(values.minLeft || 0), cookieText: values.cookieText || '', selectedAccountLines: values.selectedAccountLines || [], delayMs: Number(values.delayMs || 120), concurrency: Number(values.concurrency || defaultConcurrency), windowSeconds: Number(values.windowSeconds || 30), 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), timeSync: values.timeSync !== false, 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()}`, } } // 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() 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([]) const [selectedAccountLines, setSelectedAccountLines] = useState([]) const [prizes, setPrizes] = useState([]) const [prizeCategories, setPrizeCategories] = useState([]) const [categoryStats, setCategoryStats] = useState([]) // activeCategory 是当前选中的类目(空串=全部);监控时的自动选品也按它过滤。 const [activeCategory, setActiveCategory] = useState('') const [selectedPrizeKeys, setSelectedPrizeKeys] = useState([]) const [productSearch, setProductSearch] = useState('') const [scores, setScores] = useState([]) const [records, setRecords] = useState([]) const [panelTab, setPanelTab] = useState('logs') const [recordModalData, setRecordModalData] = useState<{ open: boolean account?: AccountPreview loading: boolean records: UserPrizeInfo[] error?: string }>({ open: false, loading: false, records: [] }) const [results, setResults] = useState([]) const [logs, setLogs] = useState([]) const [now, setNow] = useState(new Date()) const [scheduleCountdown, setScheduleCountdown] = useState(null) const [settingsOpen, setSettingsOpen] = useState(false) const [cookieManagerOpen, setCookieManagerOpen] = useState(false) const [cookieEditOpen, setCookieEditOpen] = useState(false) const [editingCookie, setEditingCookie] = useState(null) const [cookieDraft, setCookieDraft] = useState('') const [cookieEntries, setCookieEntries] = useState([]) const [prizeCacheUpdatedAt, setPrizeCacheUpdatedAt] = useState(0) const [configLoaded, setConfigLoaded] = useState(false) const [logPath, setLogPath] = useState('') const [logStats, setLogStats] = useState(null) const [logFilter, setLogFilter] = useState('all') const [debugLog, setDebugLog] = useState(false) const [instanceInfo, setInstanceInfo] = useState(null) const [appVersion, setAppVersion] = useState('') const [mainSplit, setMainSplit] = useState(() => { try { const saved = Number(localStorage.getItem(scopedKey(splitStorageKeyBase))) if (saved >= 30 && saved <= 85) return saved } catch {} return defaultMainSplit }) const [isDraggingSplit, setIsDraggingSplit] = useState(false) const mainGridRef = useRef(null) const handleSplitMouseDown = (e: React.MouseEvent) => { e.preventDefault() setIsDraggingSplit(true) const container = mainGridRef.current if (!container) return const rect = container.getBoundingClientRect() const onMouseMove = (moveEvent: MouseEvent) => { const offsetX = moveEvent.clientX - rect.left const percent = Math.min(85, Math.max(30, (offsetX / rect.width) * 100)) setMainSplit(percent) } const onMouseUp = () => { setIsDraggingSplit(false) window.removeEventListener('mousemove', onMouseMove) window.removeEventListener('mouseup', onMouseUp) setMainSplit((latest) => { try { localStorage.setItem(scopedKey(splitStorageKeyBase), String(Math.round(latest))) } catch {} return latest }) } window.addEventListener('mousemove', onMouseMove) window.addEventListener('mouseup', onMouseUp) } const handleSplitDoubleClick = () => { setMainSplit(defaultMainSplit) try { localStorage.setItem(scopedKey(splitStorageKeyBase), String(defaultMainSplit)) } catch {} message.info('已恢复默认左右宽度 (62%)') } 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) const enrichedPreviews = (previews || []).map((preview) => { const entry = enriched[preview.line - 1] return { ...preview, checkStatus: entry?.checkStatus, checkMessage: entry?.checkMessage, checkedAt: entry?.checkedAt, } }) setAccounts(enrichedPreviews) const selectableLines = enrichedPreviews .filter((item) => item.valid && !isCookieCheckUnusable(item.checkStatus)) .map((item) => item.line) setSelectedAccountLines(selectableLines) if (persist) writeSavedCookies(enriched) if (refreshLatest && selectableLines.length > 0) { window.setTimeout(() => queryPrizes(true, selectableLines), 0) } return enriched } useEffect(() => { let mounted = true Promise.all([ DefaultConfig(), GetLogs(0), IsRunning(), GetLogFilePath(), GetInstanceInfo(), GetLogStats(), GetVersion(), ]) .then(([defaults, logItems, isRunning, savedLogPath, info, stats, version]) => { if (!mounted) return const instance = info as InstanceInfo | null // 本地存储必须按实例分片后再读取,否则多开时两个实例会共用同一份账号与配置。 applyStorageScope(instance?.id || '') setInstanceInfo(instance) setAppVersion(String(version || '')) setLogStats((stats || null) as LogStats | null) setDebugLog(String((stats as LogStats | null)?.level || '') === 'debug') const savedCookies = readSavedCookies() const savedPrizeCache = readSavedPrizeCache() const savedConfig = readSavedConfig() form.setFieldsValue({ ...(defaults as UiConfig), ...savedConfig, cookieText: cookieTextFromEntries(savedCookies), }) setCookieEntries(savedCookies) setActiveCategory(String(savedConfig.category || '')) setPrizes(savedPrizeCache.prizes) setPrizeCategories(savedPrizeCache.categories || []) setCategoryStats(savedPrizeCache.categoryStats || []) setPrizeCacheUpdatedAt(savedPrizeCache.updatedAt) setLogs((prev) => mergeLogs(prev, (logItems || []) as LogEntry[])) setLogPath(String(savedLogPath || instance?.logFile || '')) 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) => mergeLogs(prev, [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 && !isCookieCheckUnusable(item.checkStatus)), [accounts], ) const cookieCheckStats = useMemo(() => { const stats = { valid: 0, expired: 0, unknown: 0, invalid: 0, unchecked: 0 } cookieEntries.forEach((entry) => { if (entry.checkStatus === 'valid') stats.valid += 1 else if (entry.checkStatus === 'expired') stats.expired += 1 else if (entry.checkStatus === 'unknown') stats.unknown += 1 else if (entry.checkStatus === 'invalid') stats.invalid += 1 else stats.unchecked += 1 }) return stats }, [cookieEntries]) const failedCookieCount = cookieCheckStats.expired + cookieCheckStats.unknown + cookieCheckStats.invalid 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() scores.forEach((item) => map.set(item.uid, item)) return map }, [scores]) const latestResultByUID = useMemo(() => { const map = new Map() results.forEach((item) => { if (!map.has(item.uid)) map.set(item.uid, item) }) return map }, [results]) // 本次运行里各账号的兑换结果(results 为最新在前)。 const exchangeByUID = useMemo(() => { const map = new Map() results.forEach((item) => { if (item.action === 'skip_duplicate') { const entry = map.get(item.uid) ?? { attempts: 0, skipped: 0 } entry.skipped += 1 map.set(item.uid, entry) return } if (!isExchangeAttempt(item.action)) return const entry = map.get(item.uid) ?? { attempts: 0, skipped: 0 } entry.attempts += 1 if (item.category === 'success') { if (!entry.success) entry.success = item } else if (!entry.failed) { entry.failed = item } map.set(item.uid, entry) }) return map }, [results]) const recordsByUID = useMemo(() => { const map = new Map() records.forEach((item) => map.set(item.uid, item)) return map }, [records]) // 用服务端兑换记录兜底,界面刷新后也能看出该账号是否已抢到当前奖品。 const recordHitByUID = useMemo(() => { const map = new Map() const pid = Number(watchedPID || 0) const name = String(watchedName || '').trim() if (pid <= 0 && !name) return map records.forEach((item) => { ;(item.records || []).forEach((record) => { const hitPID = pid > 0 && Number(record.prize_id || 0) === pid const hitName = name !== '' && String(record.name || '').includes(name) if (!hitPID && !hitName) return const prev = map.get(item.uid) if (!prev || Number(record.create_time || 0) > Number(prev.create_time || 0)) { map.set(item.uid, record) } }) }) return map }, [records, watchedPID, watchedName]) 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', 'protocol'].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() const categoryID = Number(activeCategory || 0) return prizes.filter((item) => { if (categoryID > 0 && !categoryIDsOf(item).includes(categoryID)) return false if (!keyword) return true const text = [item.name, String(item.id), String(priceOf(item)), ...categoryNamesOf(item, prizeCategories)] .join(' ') .toLowerCase() return text.includes(keyword) }) }, [prizes, productSearch, activeCategory, prizeCategories]) const inventorySummary = useMemo(() => summarizeInventory(filteredPrizes), [filteredPrizes]) // 类目页签与服务端下发的类目字典同序(限量返场/精英专享),数量优先用服务端统计。 const categoryItems = useMemo(() => { const items = [{ key: '', label: `全部 ${prizes.length}` }] prizeCategories.forEach((tag) => { const id = Number(tag.id) const stat = categoryStats.find((item) => Number(item.id) === id) const count = stat?.count ?? prizes.filter((item) => categoryIDsOf(item).includes(id)).length items.push({ key: String(id), label: `${tag.name} ${count}` }) }) return items }, [prizeCategories, categoryStats, prizes]) // 日志筛选只在界面上生效;后端的 debug 开关由「调试日志」按钮控制。 const visibleLogs = useMemo( () => (logFilter === 'all' ? logs : logs.filter((item) => item.level === logFilter)), [logs, logFilter], ) const config = (selectedLines: Key[] = selectedAccountLines) => ({ ...pickConfig(form.getFieldsValue(true)), category: activeCategory, selectedAccountLines: selectedLines.map(Number).filter((line) => line > 0), }) const runAction = async ( key: string, action: () => Promise, 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, checkStatus: undefined, checkMessage: undefined, checkedAt: undefined, 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') } // 检查走服务端 getUserScore;失效结果连续确认 3 次,避免偶发「账号未绑定」误判。 const checkCookies = async () => { if (running) { message.warning('抢购运行中,请先停止再检查 CK') return } if (cookieEntries.length === 0) { message.warning('没有可检查的 CK') return } setLoading('cookie-check') try { const snapshot = cookieEntries const results = (await CheckCookies(config())) as CookieCheckResult[] const byLine = new Map() const byUID = new Map() results.forEach((item) => { byLine.set(Number(item.line || 0), item) if (Number(item.uid || 0) > 0) byUID.set(Number(item.uid), item) }) const nextEntries = snapshot.map((entry, index) => { const result = byLine.get(index + 1) || (entry.uid > 0 ? byUID.get(entry.uid) : undefined) if (!result) return entry return { ...entry, uid: Number(result.uid || entry.uid || 0), checkStatus: normalizeCookieCheckStatus(result.status), checkMessage: result.status === 'valid' ? '' : String(result.message || '').trim(), checkedAt: Number(result.checkedAt || Date.now()), } }) const stats = { valid: 0, expired: 0, unknown: 0, invalid: 0 } nextEntries.forEach((entry) => { if (entry.checkStatus === 'valid') stats.valid += 1 else if (entry.checkStatus === 'expired') stats.expired += 1 else if (entry.checkStatus === 'unknown') stats.unknown += 1 else if (entry.checkStatus === 'invalid') stats.invalid += 1 }) const failed = stats.expired + stats.unknown + stats.invalid setCookieEntries(nextEntries) writeSavedCookies(nextEntries) setAccounts((prev) => prev.map((account) => { const result = byLine.get(account.line) || (account.uid > 0 ? byUID.get(account.uid) : undefined) if (!result) return account return { ...account, checkStatus: normalizeCookieCheckStatus(result.status), checkMessage: result.status === 'valid' ? '' : String(result.message || '').trim(), checkedAt: Number(result.checkedAt || Date.now()), } }), ) const failedLines = new Set( nextEntries .map((entry, index) => (isCookieCheckUnusable(entry.checkStatus) ? index + 1 : 0)) .filter((line) => line > 0), ) setSelectedAccountLines((prev) => prev.filter((line) => !failedLines.has(Number(line)))) const summary = `检查完成:可用 ${stats.valid},失效 ${failed}` if (failed > 0) { message.warning(summary) } else { message.success(summary) } } catch (err: unknown) { message.error(`检查 CK 失败: ${String(err)}`) } finally { setLoading('') } } const clearExpiredCookies = () => { if (failedCookieCount === 0) return modal.confirm({ title: '清理失效 CK', content: `确认删除 ${failedCookieCount} 条失效 CK?未检查的 CK 会保留。`, okText: '清理', cancelText: '取消', okButtonProps: { danger: true }, onOk: async () => { const remaining = cookieEntries.filter((entry) => !isCookieCheckUnusable(entry.checkStatus)) await applyCookieEntries(remaining, true, false) message.success(`已清理 ${failedCookieCount} 条失效 CK`) }, }) } function queryPrizes(quiet = false, selectedLines: Key[] = selectedAccountLines) { if (!String(form.getFieldValue('cookieText') || '').trim()) { if (!quiet) message.warning('Cookie 不能为空') return } if (selectedLines.length === 0) { if (!quiet) message.warning('请先选择账号') return } setLoading('list') ListPrizes(config(selectedLines)) .then((data) => { const result = data as { prizes?: PrizeInfo[] categories?: PrizeTag[] categoryStats?: CategoryStat[] } const nextPrizes = result?.prizes || [] if (nextPrizes.length === 0) { if (!quiet) message.warning('当前 CK 未返回可选商品') return } const nextCategories = result?.categories || [] const nextStats = result?.categoryStats || [] const updatedAt = Date.now() setPrizes(nextPrizes) setPrizeCategories(nextCategories) setCategoryStats(nextStats) setActiveCategory((prev) => prev && nextCategories.some((tag) => String(tag.id) === prev) ? prev : '', ) setPrizeCacheUpdatedAt(updatedAt) writeSavedPrizeCache(nextPrizes, Number(form.getFieldValue('sid') || 0), nextCategories, nextStats) setSelectedPrizeKeys((prev) => prev.filter((key) => nextPrizes.some((item) => item.id === Number(key))), ) if (!quiet) { const summary = nextStats.map((item) => `${item.name} ${item.count ?? 0}`).join(' / ') message.success(summary ? `商品已刷新:${summary}` : '商品已刷新') } }) .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(scopedKey(prizeCacheStorageBase)) setPrizes([]) setPrizeCategories([]) setCategoryStats([]) setActiveCategory('') 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, category: values.category, targetText: values.targetText, maxPrice: values.maxPrice, minLeft: values.minLeft, delayMs: values.delayMs, concurrency: values.concurrency, windowSeconds: values.windowSeconds, stopAfterSuccess: values.stopAfterSuccess, stopAccountAfterSuccess: values.stopAccountAfterSuccess, prewarm: values.prewarm, directExchange: values.directExchange, sharePrizeList: values.sharePrizeList, autoSelect: values.autoSelect, refreshAfterSuccess: values.refreshAfterSuccess, leadMs: values.leadMs, timeSync: values.timeSync, frequentBackoffMs: values.frequentBackoffMs, errorBackoffMs: values.errorBackoffMs, timeoutSec: values.timeoutSec, startAt: values.startAt, } localStorage.setItem(scopedKey(configStorageBase), 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, // pid/名称已经代表第一个奖品,这里只补其余奖品,避免同一奖品被提交两次。 targetText: selected.slice(1).map((item) => String(item.id)).join('\n'), autoSelect: false, }) } const startMonitor = (scheduled: boolean) => { const values = config() if (values.selectedAccountLines.length === 0) { message.warning('请先选择账号') return undefined } 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 (values.selectedAccountLines.length === 0) { message.warning('请先选择账号') return undefined } 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 openLogDir = () => runAction('logdir', () => OpenLogDir()) const copyLogPath = async () => { if (!logPath) return await ClipboardSetText(logPath) 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)) } const clearAccounts = () => { setSelectedAccountLines([]) } const viewAccountRecords = async (account: AccountPreview) => { const cached = recordsByUID.get(account.uid) if (cached && cached.records && cached.records.length > 0) { setRecordModalData({ open: true, account, loading: false, records: cached.records, error: cached.error, }) return } setRecordModalData({ open: true, account, loading: true, records: cached?.records || [], error: undefined, }) try { const values = config() const singleConfig = { ...values, selectedAccountLines: [account.line], } const data = await GetRecords(singleConfig) const res = (data || [])[0] const fetchedRecords = res?.records || [] setRecordModalData({ open: true, account, loading: false, records: fetchedRecords, error: res?.error || (res?.resp?.status && res.resp.status !== 200 ? res.resp.msg : undefined), }) if (res) { setRecords((prev) => [...prev.filter((p) => p.uid !== account.uid), res]) } } catch (err: unknown) { setRecordModalData({ open: true, account, loading: false, records: [], error: String(err), }) } } const accountColumns: ColumnsType = useMemo( () => [ { title: '账号', width: 130, render: (_, row) => (
{row.uid || `第${row.line}行`}
{isCookieCheckUnusable(row.checkStatus) ? row.checkMessage || 'CK 已失效' : row.valid ? row.checkStatus === 'valid' ? 'CK 可用' : '已加入任务' : row.error || '未选择'}
), }, { title: '凭据', width: 85, render: (_, row) => row.checkStatus ? ( cookieCheckTag(row.checkStatus, row.checkMessage) ) : row.valid ? ( 已加载 ) : ( 无效 ), }, { title: '商品', width: 120, ellipsis: true, render: () => currentPrize?.name || watchedName || '未选择', }, { title: '积分', width: 70, render: (_, row) => scoreMap.get(row.uid)?.score ?? '-', }, { title: '兑换结果', width: 150, render: (_, row) => { const exchange = exchangeByUID.get(row.uid) if (exchange?.success) { const orderID = exchangeOrderID(exchange.success.exchange) return (
已抢到
{exchange.success.prize?.name || currentPrize?.name || ''}
{orderID ? (
单号 {orderID}
) : null}
) } if (exchange?.failed) { return (
未成功
{exchange.failed.error || exchange.failed.message || exchange.failed.exchange?.msg || '兑换失败'}
{exchange.attempts > 1 ? (
已尝试 {exchange.attempts} 次
) : null}
) } const record = recordHitByUID.get(row.uid) if (record) { return (
已兑换
{record.name || ''}
{record.order_id ? (
单号 {record.order_id}
) : null}
) } return (
{running ? '抢购中' : '待抢购'} {exchange?.skipped ?
已跳过重复兑换
: null}
) }, }, { title: '状态', width: 80, render: (_, row) => categoryTag(latestResultByUID.get(row.uid)?.category), }, { title: '最后结果', width: 130, ellipsis: true, render: (_, row) => { const result = latestResultByUID.get(row.uid) return result?.error || result?.message || result?.exchange?.msg || '' }, }, { title: '兑换记录', width: 90, render: (_, row) => { const rec = recordsByUID.get(row.uid) const count = rec?.records?.length || 0 return ( ) }, }, ], [ currentPrize?.name, exchangeByUID, latestResultByUID, recordHitByUID, recordsByUID, running, scoreMap, watchedName, ], ) const cookieColumns: ColumnsType = useMemo( () => [ { title: '账号', width: 140, render: (_, row) => (
{row.uid || '-'}
{isCookieCheckUnusable(row.checkStatus) ? 'CK 已失效' : row.valid ? '可解析 UID' : row.error || '待校验'}
), }, { title: '格式', width: 78, render: (_, row) => (row.valid ? 有效 : 无效), }, { title: 'CK 状态', width: 190, render: (_, row) => (
{cookieCheckTag(row.checkStatus, row.checkMessage)} {row.checkStatus && row.checkStatus !== 'valid' && row.checkMessage ? (
{row.checkMessage}
) : null} {row.checkedAt ? (
检查于 {new Date(row.checkedAt).toLocaleString('zh-CN', { hour12: false })}
) : null}
), }, { title: 'CK', ellipsis: true, render: (_, row) => {row.text}, }, { title: '更新时间', width: 170, render: (_, row) => new Date(row.updatedAt).toLocaleString('zh-CN', { hour12: false }), }, { title: '操作', width: 150, render: (_, row) => ( ), }, ], [cookieEntries], ) const prizeColumns: ColumnsType = 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 = 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 totalRecordCount = useMemo(() => { return records.reduce((sum, item) => sum + (item.records?.length || 0), 0) }, [records]) const recordRows = useMemo(() => { const rows: RecordRow[] = [] records.forEach((item) => { if (!item.records?.length) { const emptyMsg = item.error || (item.resp?.status && item.resp.status !== 200 ? item.resp.msg : '(无兑换记录)') rows.push({ key: `${item.uid}-empty`, uid: item.uid, name: emptyMsg, }) 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 = useMemo( () => [ { title: 'UID', dataIndex: 'uid', width: 140 }, { title: '名称', dataIndex: 'name', minWidth: 200, ellipsis: true, render: (name, row) => ( {row.img ? ( {name} ) : null} {name} ), }, { title: '积分', width: 90, render: (_, row) => row.new_score || row.score ? `${row.new_score || row.score} 分` : '-', }, { title: '状态', dataIndex: 'status', width: 84, render: (status) => status === 200 || !status ? 成功 : {status}, }, { title: '订单号', dataIndex: 'order_id', minWidth: 220, render: (orderId) => orderId ? (
{orderId}
) : ( '-' ), }, { title: '时间', width: 160, render: (_, row) => formatUnixTime(row.create_time) }, ], [message], ) const resultColumns: ColumnsType = 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: (
{instanceInfo ? `${instanceInfo.name} · ` : ''} {logPath || '日志文件不可用'}
setCookieManagerOpen(false)} footer={null} >
{cookieEntries.length} 条 CK {cookieCheckStats.valid} 可用 {failedCookieCount > 0 ? {failedCookieCount} 失效 : null} {cookieCheckStats.unchecked > 0 ? {cookieCheckStats.unchecked} 未检查 : null}
row.id} columns={cookieColumns} dataSource={cookieEntries} pagination={{ pageSize: 8, showSizeChanger: false }} scroll={{ x: 1000, y: 430 }} /> { setCookieEditOpen(false) setCookieDraft('') setEditingCookie(null) }} > setCookieDraft(event.target.value)} /> setSettingsOpen(false)} footer={[ , , ]} >
CK 来源 {importedCookieCount > 0 ? `已导入 ${validAccounts.length}/${importedCookieCount} 个有效账号` : '未导入 CK'}
商品缓存 {prizes.length > 0 ? `${prizes.length} 个 · ${productListUpdatedAt || '未知时间'}` : '未缓存'}
兑换记录明细 {recordModalData.account ? ( UID: {recordModalData.account.uid} ) : null} {!recordModalData.loading ? ( 0 ? 'green' : 'default'}> 共 {recordModalData.records.length} 条 ) : null} } open={recordModalData.open} width="min(860px, calc(100vw - 48px))" footer={null} onCancel={() => setRecordModalData((prev) => ({ ...prev, open: false }))} > {recordModalData.loading ? (
正在向虎牙服务器查询该账号兑换记录...
) : recordModalData.error ? (
查询失败 {recordModalData.error}
) : recordModalData.records.length === 0 ? (
暂无兑换记录(该账号在当前活动下尚无成功订单)
) : (
`${r.id || idx}-${r.order_id || ''}`} dataSource={recordModalData.records} pagination={{ pageSize: 8, showSizeChanger: false }} columns={[ { title: '商品', dataIndex: 'name', render: (name, r) => ( {r.img ? ( {name} ) : null}
{name || '未知商品'} {r.prize_id ?
PID: {r.prize_id}
: null}
), }, { title: '消耗积分', width: 110, render: (_, r) => {r.new_score || r.score || 0} 积分, }, { title: '订单号', dataIndex: 'order_id', width: 260, render: (orderId) => orderId ? (
{orderId}
) : ( '-' ), }, { title: '兑换时间', width: 170, render: (_, r) => formatUnixTime(r.create_time), }, { title: '状态', width: 80, render: () => 成功, }, ]} /> )} ) } 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 ( 剩余 {left} 总量 {total} {info.percent ? {info.percent} : null} ) } function CurrentProduct({ prize, now, categories, }: { prize?: PrizeInfo now: Date categories: PrizeTag[] }) { const name = prize?.name || '未从 CK 列表选择' const sale = saleStateOf(prize, now) return (
当前商品 {name}
{prize ? ( <> {priceOf(prize)} 积分 PID {prize.id} {categoryNamesOf(prize, categories).map((item) => ( {item} ))} {sale.onSale ? : {sale.label}} ) : ( CK 商品源 )}
) } function CountdownBadge({ countdown, now, running, }: { countdown: ScheduleCountdown | null now: Date running: boolean }) { if (!running || !countdown) return
const remaining = countdown.fireAt - now.getTime() const ready = remaining <= 0 return (
{ready ? '开抢中' : '距开抢'} {ready ? '00:00:00.000' : formatCountdownDuration(remaining)} 目标 {formatTimeOfDay(new Date(countdown.targetAt))}
) } 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 (
updatePart(0, next)} /> : updatePart(1, next)} /> : updatePart(2, next)} />
) } 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 parseLogTime(rawTime?: string): { local: string; huya?: string } { if (!rawTime) return { local: '' } const match = rawTime.match(/^(.*?)\(H(.*?)\)$/) if (match) { return { local: match[1], huya: match[2] } } return { local: rawTime } } function LogView({ logs }: { logs: LogEntry[] }) { return (
{logs.map((item) => { const { local, huya } = parseLogTime(item.time) return (
{local} {huya ? ( H:{huya} ) : null} {item.level} {item.category ? `[${item.category}]` : ''} {item.account ? `[账号:${item.account}]` : ''} {item.message}
) })}
) } 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)}` }