Files
huya-go/frontend/src/App.tsx
T

1494 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState } from 'react'
import type { Key, ReactNode } from 'react'
import {
App as AntApp,
Button,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Statistic,
Switch,
Table,
Tabs,
Tag,
Tooltip,
Typography,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import {
ClipboardList,
ClipboardPaste,
ListChecks,
Pencil,
Play,
Plus,
RefreshCw,
Save,
ShoppingCart,
Square,
Settings,
Trash2,
} 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
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
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
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'>
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
}
function leftOf(row?: PrizeInfo): number {
if (!row) return 0
return row.left_num || 0
}
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: '抢完' },
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 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 || 'wss',
wssHost: values.wssHost || '',
sid: Number(values.sid || 0),
pid: Number(values.pid || 0),
name: values.name || '',
targetText: values.targetText || '',
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 normalizeExchangeResult(row: ExchangeResult, index: number): MonitorResult {
const message =
row.error ||
row.resp?.msg ||
getPreCondition(row.resp).tip ||
row.resp?.order_id ||
row.resp?.orderId ||
''
const category =
row.error
? 'error'
: row.resp?.status === 200
? 'success'
: message.includes('积分') && message.includes('不足')
? 'not_enough'
: 'failed'
return {
key: `exchange-${row.uid}-${index}-${Date.now()}`,
loop: 0,
uid: row.uid,
action: 'exchange',
category,
message,
exchange: row.resp,
error: row.error,
}
}
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 [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 [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 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))
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 }) => {
setRunning(Boolean(state?.running))
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])
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'].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 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,
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()
if (!scheduled) values.startAt = ''
setResults([])
return runAction('monitor', () => StartMonitor(values), () => setRunning(true))
}
const exchangeNow = () => {
const values = config()
if (values.pid <= 0) {
message.warning('请选择商品或填写 PID')
return
}
setResults([])
return runAction('exchange', () => Exchange(values), (data) => {
const items = (data || []) as ExchangeResult[]
setResults(items.map(normalizeExchangeResult))
})
}
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: 'num', dataIndex: 'id', width: 92 },
{ title: 'name', dataIndex: 'name', minWidth: 220, ellipsis: true },
{ title: '剩余', width: 84, render: (_, row) => leftOf(row) || '-' },
{ title: '总量', dataIndex: 'num', width: 84 },
{ 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>{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>
<Tag color="gold">{priceOf(item)} 积分</Tag>
<Tag color="green">库存 {leftOf(item) || '-'}</Tag>
</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>
<Button>等待操作</Button>
</div>
<Space size={12}>
<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>{formatClock(now)}</strong>
<span>账号</span>
<strong>
{selectedAccountCount}/{validAccounts.length}
</strong>
<span>成功</span>
<strong className="green">{successCount}</strong>
<span>异常</span>
<strong className="red">{errorCount}</strong>
<span>待处理</span>
<strong>{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={<ShoppingCart size={16} />}
loading={loading === 'exchange'}
disabled={running}
onClick={exchangeNow}
>
立即兑换
</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)
}}
>
停止
</Button>
</Space>
<Space>
<span className="muted">目标</span>
<Form.Item name="startAt" noStyle>
<Input className="target-time" />
</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="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 CurrentProduct({ prize }: { prize?: PrizeInfo }) {
const name = prize?.name || '未从 CK 列表选择'
return (
<div className="current-product">
<span>当前商品</span>
<strong>{name}</strong>
<div>
{prize ? (
<>
<Tag color="gold">{priceOf(prize)} 积分</Tag>
<Tag color="green">库存 {leftOf(prize) || '-'}</Tag>
<Tag>PID {prize.id}</Tag>
</>
) : (
<Tag>CK 商品源</Tag>
)}
</div>
</div>
)
}
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)}`
}