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