- 布局增加左右可拖动分割条:支持按住拖拽自由调节左侧作战表与右侧面板宽度比例(30%~85%),双击恢复默认 62%,支持 localStorage 本地记忆 - 紧凑优化作战表列宽:各列宽度微调,scroll 改为 max-content,彻底解决小分辨率下最右侧'兑换记录'按钮被横向滚动条遮挡问题 - 升级版本号至 0.5.3
2940 lines
95 KiB
TypeScript
2940 lines
95 KiB
TypeScript
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)}`
|
||
}
|