Files
huya-go/frontend/src/App.tsx
T
yml2213 ab755b8d04 feat(ui): 增加作战表与日志面板中间可拖拽调节分割条,紧凑化表格列宽
- 布局增加左右可拖动分割条:支持按住拖拽自由调节左侧作战表与右侧面板宽度比例(30%~85%),双击恢复默认 62%,支持 localStorage 本地记忆
- 紧凑优化作战表列宽:各列宽度微调,scroll 改为 max-content,彻底解决小分辨率下最右侧'兑换记录'按钮被横向滚动条遮挡问题
- 升级版本号至 0.5.3
2026-10-17 19:52:32 +08:00

2940 lines
95 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,
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<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
}
// 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 翻译成类目名;字典里没有时用「类目<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 <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: '异常' },
// protocol 是回包结构异常(缺 tRsp/tResp),单独归类便于排查服务端错误信封。
protocol: { color: 'error', label: '协议异常' },
// session_lost 是「账号未绑定」这类瞬时会话失效,通常下一轮就能恢复。
session_lost: { color: 'volcano', label: '会话失效' },
}
if (!category) return <Tag>待处理</Tag>
const item = map[category] || { color: 'default', label: category }
return <Tag color={item.color}>{item.label}</Tag>
}
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<CookieCheckStatus | 'unchecked', { color: string; label: string }> = {
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 = <Tag color={item.color}>{item.label}</Tag>
return message ? <Tooltip title={message}>{tag}</Tooltip> : tag
}
function readSavedConfig(): Partial<SavedConfig> {
try {
const configKey = scopedKey(configStorageBase)
const saved = JSON.parse(localStorage.getItem(configKey) || '{}') as Partial<SavedConfig>
let dirty = false
// v0.5.0 移除了轮次和全局降速器配置,顺手清掉本地旧字段。
const legacyFields = saved as Record<string, unknown>
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<SavedConfig>) {
localStorage.setItem(scopedKey(configStorageBase), JSON.stringify({ ...readSavedConfig(), ...patch }))
}
function readSavedCookies(): CookieEntry[] {
try {
const items = JSON.parse(localStorage.getItem(scopedKey(cookieStorageBase)) || '[]') 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,
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<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[],
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>): 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<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 [prizeCategories, setPrizeCategories] = useState<PrizeTag[]>([])
const [categoryStats, setCategoryStats] = useState<CategoryStat[]>([])
// activeCategory 是当前选中的类目(空串=全部);监控时的自动选品也按它过滤。
const [activeCategory, setActiveCategory] = useState('')
const [selectedPrizeKeys, setSelectedPrizeKeys] = useState<Key[]>([])
const [productSearch, setProductSearch] = useState('')
const [scores, setScores] = useState<ScoreResult[]>([])
const [records, setRecords] = useState<RecordResult[]>([])
const [panelTab, setPanelTab] = useState<string>('logs')
const [recordModalData, setRecordModalData] = useState<{
open: boolean
account?: AccountPreview
loading: boolean
records: UserPrizeInfo[]
error?: string
}>({ open: false, loading: false, records: [] })
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 [logPath, setLogPath] = useState('')
const [logStats, setLogStats] = useState<LogStats | null>(null)
const [logFilter, setLogFilter] = useState('all')
const [debugLog, setDebugLog] = useState(false)
const [instanceInfo, setInstanceInfo] = useState<InstanceInfo | null>(null)
const [appVersion, setAppVersion] = useState('')
const [mainSplit, setMainSplit] = useState<number>(() => {
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<HTMLElement>(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<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])
// 本次运行里各账号的兑换结果(results 为最新在前)。
const exchangeByUID = useMemo(() => {
const map = new Map<number, ExchangeSummary>()
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<number, RecordResult>()
records.forEach((item) => map.set(item.uid, item))
return map
}, [records])
// 用服务端兑换记录兜底,界面刷新后也能看出该账号是否已抢到当前奖品。
const recordHitByUID = useMemo(() => {
const map = new Map<number, UserPrizeInfo>()
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 <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,
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<number, CookieCheckResult>()
const byUID = new Map<number, CookieCheckResult>()
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<AccountPreview> = useMemo(
() => [
{
title: '账号',
width: 130,
render: (_, row) => (
<div>
<strong>{row.uid || `第${row.line}行`}</strong>
<div className="muted">
{isCookieCheckUnusable(row.checkStatus)
? row.checkMessage || 'CK 已失效'
: row.valid
? row.checkStatus === 'valid'
? 'CK 可用'
: '已加入任务'
: row.error || '未选择'}
</div>
</div>
),
},
{
title: '凭据',
width: 85,
render: (_, row) =>
row.checkStatus ? (
cookieCheckTag(row.checkStatus, row.checkMessage)
) : row.valid ? (
<Tag color="green">已加载</Tag>
) : (
<Tag>无效</Tag>
),
},
{
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 (
<div>
<Tag color="success">已抢到</Tag>
<div className="muted">{exchange.success.prize?.name || currentPrize?.name || ''}</div>
{orderID ? (
<div className="muted" title={orderID}>
单号 {orderID}
</div>
) : null}
</div>
)
}
if (exchange?.failed) {
return (
<div>
<Tag color="error">未成功</Tag>
<div className="muted">
{exchange.failed.error ||
exchange.failed.message ||
exchange.failed.exchange?.msg ||
'兑换失败'}
</div>
{exchange.attempts > 1 ? (
<div className="muted">已尝试 {exchange.attempts} 次</div>
) : null}
</div>
)
}
const record = recordHitByUID.get(row.uid)
if (record) {
return (
<div>
<Tag color="blue">已兑换</Tag>
<div className="muted">{record.name || ''}</div>
{record.order_id ? (
<div className="muted" title={record.order_id}>
单号 {record.order_id}
</div>
) : null}
</div>
)
}
return (
<div>
<Tag color={running ? 'processing' : 'default'}>{running ? '抢购中' : '待抢购'}</Tag>
{exchange?.skipped ? <div className="muted">已跳过重复兑换</div> : null}
</div>
)
},
},
{
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 (
<Button
size="small"
icon={<History size={13} />}
onClick={(e) => {
e.stopPropagation()
viewAccountRecords(row)
}}
>
{count > 0 ? `记录 (${count})` : '记录'}
</Button>
)
},
},
],
[
currentPrize?.name,
exchangeByUID,
latestResultByUID,
recordHitByUID,
recordsByUID,
running,
scoreMap,
watchedName,
],
)
const cookieColumns: ColumnsType<CookieEntry> = useMemo(
() => [
{
title: '账号',
width: 140,
render: (_, row) => (
<div>
<strong>{row.uid || '-'}</strong>
<div className="muted">
{isCookieCheckUnusable(row.checkStatus) ? 'CK 已失效' : row.valid ? '可解析 UID' : row.error || '待校验'}
</div>
</div>
),
},
{
title: '格式',
width: 78,
render: (_, row) => (row.valid ? <Tag color="green">有效</Tag> : <Tag color="default">无效</Tag>),
},
{
title: 'CK 状态',
width: 190,
render: (_, row) => (
<div>
{cookieCheckTag(row.checkStatus, row.checkMessage)}
{row.checkStatus && row.checkStatus !== 'valid' && row.checkMessage ? (
<div className="muted ck-check-message" title={row.checkMessage}>
{row.checkMessage}
</div>
) : null}
{row.checkedAt ? (
<div className="muted">检查于 {new Date(row.checkedAt).toLocaleString('zh-CN', { hour12: false })}</div>
) : null}
</div>
),
},
{
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 totalRecordCount = useMemo(() => {
return records.reduce((sum, item) => sum + (item.records?.length || 0), 0)
}, [records])
const recordRows = useMemo<RecordRow[]>(() => {
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<RecordRow> = useMemo(
() => [
{ title: 'UID', dataIndex: 'uid', width: 140 },
{
title: '名称',
dataIndex: 'name',
minWidth: 200,
ellipsis: true,
render: (name, row) => (
<Space orientation="horizontal" size={6}>
{row.img ? (
<img
src={row.img}
alt={name}
style={{ width: 24, height: 24, borderRadius: 4, objectFit: 'cover' }}
/>
) : null}
<span>{name}</span>
</Space>
),
},
{
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 ? <Tag color="success">成功</Tag> : <Tag>{status}</Tag>,
},
{
title: '订单号',
dataIndex: 'order_id',
minWidth: 220,
render: (orderId) =>
orderId ? (
<div className="order-id-cell">
<span>{orderId}</span>
<Button
type="text"
size="small"
icon={<Copy size={12} />}
title="复制单号"
onClick={() => {
navigator.clipboard.writeText(orderId)
message.success('已复制单号')
}}
/>
</div>
) : (
'-'
),
},
{ title: '时间', width: 160, render: (_, row) => formatUnixTime(row.create_time) },
],
[message],
)
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: (
<div className="log-pane">
<div className="log-tools">
<span className="log-path" title={logPath || '日志文件不可用'}>
{instanceInfo ? `${instanceInfo.name} · ` : ''}
{logPath || '日志文件不可用'}
</span>
<Space size={6}>
<Tooltip title="打开日志目录">
<Button
size="small"
icon={<FolderOpen size={14} />}
disabled={!logPath}
onClick={openLogDir}
/>
</Tooltip>
<Tooltip title="复制日志路径">
<Button size="small" icon={<Copy size={14} />} disabled={!logPath} onClick={copyLogPath} />
</Tooltip>
</Space>
</div>
<div className="log-tools">
<Select
size="small"
value={logFilter}
style={{ width: 116 }}
onChange={setLogFilter}
options={logFilterOptions}
/>
<Space size={8}>
<Button
size="small"
icon={<Copy size={13} />}
disabled={visibleLogs.length === 0}
onClick={() => {
const text = visibleLogs
.map(
(l) =>
`${l.time} [${l.category}][${l.level}]${l.account ? `[账号:${l.account}]` : ''} ${l.message}`,
)
.join('\n')
navigator.clipboard.writeText(text)
message.success(`已复制 ${visibleLogs.length} 条日志`)
}}
>
复制
</Button>
<Button size="small" disabled={logs.length === 0} onClick={() => setLogs([])}>
清空
</Button>
<Tooltip title="开启后记录逐条网络请求(debug 级)">
<Switch
size="small"
checked={debugLog}
checkedChildren="调试"
unCheckedChildren="常规"
onChange={toggleDebugLog}
/>
</Tooltip>
<span className="log-path">共 {logs.length} 条</span>
</Space>
</div>
{logStats && logStats.dropped > 0 ? (
<div className="log-path">日志队列已丢弃 {logStats.dropped} 条(磁盘写入压力过大)</div>
) : null}
<LogView logs={visibleLogs} />
</div>
),
},
{
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} />
<Statistic title="丢弃日志" value={logStats?.dropped || 0} />
</div>
),
},
{
key: 'records',
label: `兑换记录${totalRecordCount > 0 ? ` (${totalRecordCount})` : ''}`,
children: (
<Table
size="small"
rowKey={(row) => row.key}
columns={recordColumns}
dataSource={recordRows}
pagination={{ pageSize: 12, showSizeChanger: false }}
scroll={{ x: 740, y: 520 }}
locale={{ emptyText: '暂无兑换记录,可勾选账号后点击上方「兑换记录」按钮或账号行的「记录」按钮进行查询' }}
/>
),
},
]
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>{appVersion || '…'}</Tag>
</div>
<Button
type="primary"
block
icon={<RefreshCw size={16} />}
loading={loading === 'list'}
disabled={selectedAccountCount === 0}
onClick={() => queryPrizes()}
>
用 CK 刷新商品
</Button>
<Input.Search
allowClear
className="product-search"
value={productSearch}
onChange={(event) => setProductSearch(event.target.value)}
onSearch={setProductSearch}
/>
{categoryItems.length > 1 ? (
<Tabs
className="category-tabs"
size="small"
activeKey={activeCategory}
onChange={(key) => setActiveCategory(String(key))}
items={categoryItems}
/>
) : null}
<CurrentProduct prize={currentPrize} now={now} categories={prizeCategories} />
<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) => {
const sale = saleStateOf(item, now)
return (
<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>
{categoryNamesOf(item, prizeCategories).map((name) => (
<Tag key={name} color="purple">
{name}
</Tag>
))}
{sale.onSale ? (
<InventoryTags prize={item} compact />
) : (
<Tag color="orange">{sale.label}</Tag>
)}
</div>
{sale.onSale ? null : (
<span className="product-hint">{sale.countdown} 后上架</span>
)}
</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}>
<Tooltip
title={instanceInfo?.dataDir ? `实例数据目录: ${instanceInfo.dataDir}` : '实例信息未就绪'}
>
<Tag color="blue">{instanceInfo?.name || '默认实例'}</Tag>
</Tooltip>
<Tag>{String(watchedTransport || 'wss').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 || selectedAccountCount === 0}
onClick={() => startMonitor(false)}
>
立即抢购
</Button>
<Button
className="green-button"
type="primary"
icon={<Play size={16} />}
loading={loading === 'monitor'}
disabled={running || selectedAccountCount === 0}
onClick={() => startMonitor(true)}
>
开启定时
</Button>
<Button
icon={<Zap size={16} />}
loading={loading === 'exchange'}
disabled={running || selectedAccountCount === 0}
onClick={directExchange}
>
直接兑换
</Button>
<Button
icon={<ClipboardPaste size={16} />}
loading={loading === 'clipboard'}
onClick={importCookiesFromClipboard}
>
剪贴板导入 CK
</Button>
<Button
icon={<RefreshCw size={16} />}
loading={loading === 'score'}
disabled={selectedAccountCount === 0}
onClick={() => {
const values = config()
if (values.selectedAccountLines.length === 0) {
message.warning('请先选择账号')
return
}
runAction('score', () => QueryScores(values), (data) =>
setScores((data || []) as ScoreResult[]),
)
}}
>
查询积分
</Button>
<Button
icon={<ShieldCheck size={16} />}
loading={loading === 'cookie-check'}
disabled={running || cookieEntries.length === 0}
onClick={checkCookies}
>
CK 有效性
</Button>
<Button
icon={<ClipboardList size={16} />}
loading={loading === 'records'}
disabled={selectedAccountCount === 0}
onClick={() => {
const values = config()
if (values.selectedAccountLines.length === 0) {
message.warning('请先选择账号')
return
}
runAction('records', () => GetRecords(values), (data) => {
const list = (data || []) as RecordResult[]
setRecords(list)
setPanelTab('records')
const total = list.reduce((sum, item) => sum + (item.records?.length || 0), 0)
message.success(`查询完成:${list.length} 个账号,共 ${total} 条兑换记录`)
})
}}
>
兑换记录
</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 ${isDraggingSplit ? 'dragging-split' : ''}`}
ref={mainGridRef}
>
<div
className="account-panel"
style={{
width: `calc(${mainSplit}% - 11px)`,
flexShrink: 0,
}}
>
<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 || isCookieCheckUnusable(row.checkStatus),
}),
}}
columns={accountColumns}
dataSource={accounts}
pagination={false}
rowClassName={(row) =>
exchangeByUID.get(row.uid)?.success ? 'account-row-success' : ''
}
scroll={{ x: 'max-content', y: 560 }}
/>
</div>
<div
className={`main-resizer ${isDraggingSplit ? 'active' : ''}`}
onMouseDown={handleSplitMouseDown}
onDoubleClick={handleSplitDoubleClick}
title="按住左右拖动调整宽度,双击恢复默认"
>
<div className="resizer-bar" />
</div>
<div
className="side-log-panel"
style={{
flex: 1,
minWidth: 0,
}}
>
<div className="panel-tools">
<Tabs activeKey={panelTab} onChange={setPanelTab} 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="min(1080px, calc(100vw - 48px))"
style={{ maxWidth: 'calc(100vw - 24px)' }}
styles={{
body: {
maxHeight: 'calc(100vh - 180px)',
overflowY: 'auto',
overflowX: 'hidden',
},
}}
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>
<Button
icon={<ShieldCheck size={16} />}
loading={loading === 'cookie-check'}
disabled={running || cookieEntries.length === 0}
onClick={checkCookies}
>
检查 CK 有效性
</Button>
</Space>
<Space wrap>
<Tag>{cookieEntries.length} 条 CK</Tag>
<Tag color="green">{cookieCheckStats.valid} 可用</Tag>
{failedCookieCount > 0 ? <Tag color="error">{failedCookieCount} 失效</Tag> : null}
{cookieCheckStats.unchecked > 0 ? <Tag>{cookieCheckStats.unchecked} 未检查</Tag> : null}
<Button
danger
icon={<Trash2 size={16} />}
disabled={failedCookieCount === 0}
onClick={clearExpiredCookies}
>
清理失效
</Button>
<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: 1000, 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 placeholder="留空自动解析" />
</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="windowSeconds" label="窗口秒" tooltip="跑到「开抢 + 窗口秒数」才收工;账号会在整个窗口内独立重试,直到成功、窗口结束或收到停止指令">
<InputNumber min={1} max={600} addonAfter="s" />
</Form.Item>
<Form.Item name="delayMs" label="延时">
<InputNumber min={10} max={60000} addonAfter="ms" />
</Form.Item>
<Form.Item name="concurrency" label="线程" tooltip="同时在途请求上限,默认 30。保证单实例 30 账号在开抢首波 300ms 错峰期间全员免排队出击;5 实例多开整机并发在途约 150">
<InputNumber min={1} max={100} />
</Form.Item>
<Form.Item name="leadMs" label="提前" tooltip="留空或 0 = 按服务端时钟校准误差动态计算(通常几十毫秒);填了就用固定提前量">
<InputNumber min={0} max={10000} addonAfter="ms" placeholder="0=动态" />
</Form.Item>
</div>
<div className="settings-switch-grid">
<Form.Item
name="prewarm"
label="预热"
valuePropName="checked"
tooltip="默认开启。开抢前 10 秒在后台异步建连,开抢前 5 秒就绪待命,享受首发零延迟;独立协程运行,绝不阻塞发令枪"
>
<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>
<Form.Item name="timeSync" label="时间校准" valuePropName="checked" tooltip="定时前用 pingSignal 校准服务端时间,按钟差对齐触发时刻">
<Switch />
</Form.Item>
</div>
</div>
</Modal>
<Modal
title={
<Space>
<span>兑换记录明细</span>
{recordModalData.account ? (
<Tag color="blue">UID: {recordModalData.account.uid}</Tag>
) : null}
{!recordModalData.loading ? (
<Tag color={recordModalData.records.length > 0 ? 'green' : 'default'}>
共 {recordModalData.records.length} 条
</Tag>
) : null}
</Space>
}
open={recordModalData.open}
width="min(860px, calc(100vw - 48px))"
footer={null}
onCancel={() => setRecordModalData((prev) => ({ ...prev, open: false }))}
>
{recordModalData.loading ? (
<div style={{ textAlign: 'center', padding: '36px 0' }}>
<Typography.Text type="secondary">正在向虎牙服务器查询该账号兑换记录...</Typography.Text>
</div>
) : recordModalData.error ? (
<div style={{ padding: '16px 0' }}>
<Tag color="error">查询失败</Tag>
<span>{recordModalData.error}</span>
</div>
) : recordModalData.records.length === 0 ? (
<div style={{ textAlign: 'center', padding: '36px 0' }}>
<Typography.Text type="secondary">暂无兑换记录(该账号在当前活动下尚无成功订单)</Typography.Text>
</div>
) : (
<Table
size="small"
className="record-modal-table"
rowKey={(r, idx) => `${r.id || idx}-${r.order_id || ''}`}
dataSource={recordModalData.records}
pagination={{ pageSize: 8, showSizeChanger: false }}
columns={[
{
title: '商品',
dataIndex: 'name',
render: (name, r) => (
<Space orientation="horizontal" size={8}>
{r.img ? (
<img
src={r.img}
alt={name}
style={{ width: 32, height: 32, borderRadius: 4, objectFit: 'cover' }}
/>
) : null}
<div>
<strong>{name || '未知商品'}</strong>
{r.prize_id ? <div className="muted">PID: {r.prize_id}</div> : null}
</div>
</Space>
),
},
{
title: '消耗积分',
width: 110,
render: (_, r) => <Tag color="orange">{r.new_score || r.score || 0} 积分</Tag>,
},
{
title: '订单号',
dataIndex: 'order_id',
width: 260,
render: (orderId) =>
orderId ? (
<div className="order-id-cell">
<span>{orderId}</span>
<Button
type="text"
size="small"
icon={<Copy size={12} />}
title="复制单号"
onClick={() => {
navigator.clipboard.writeText(orderId)
message.success('已复制单号')
}}
/>
</div>
) : (
'-'
),
},
{
title: '兑换时间',
width: 170,
render: (_, r) => formatUnixTime(r.create_time),
},
{
title: '状态',
width: 80,
render: () => <Tag color="success">成功</Tag>,
},
]}
/>
)}
</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,
now,
categories,
}: {
prize?: PrizeInfo
now: Date
categories: PrizeTag[]
}) {
const name = prize?.name || '未从 CK 列表选择'
const sale = saleStateOf(prize, now)
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>
{categoryNamesOf(prize, categories).map((item) => (
<Tag key={item} color="purple">
{item}
</Tag>
))}
{sale.onSale ? <InventoryTags prize={prize} /> : <Tag color="orange">{sale.label}</Tag>}
</>
) : (
<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 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 (
<div className="log-view">
{logs.map((item) => {
const { local, huya } = parseLogTime(item.time)
return (
<div className={`log-line level-${item.level}`} key={item.id}>
<span className="log-local-time">{local}</span>
{huya ? (
<Tag color="cyan" className="log-huya-tag">
H:{huya}
</Tag>
) : null}
<Tag className="log-level-tag">{item.level}</Tag>
<code>
{item.category ? `[${item.category}]` : ''}
{item.account ? `[账号:${item.account}]` : ''} {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)}`
}