Files
huya-go/frontend/src/App.tsx
T
2026-07-06 18:30:34 +08:00

703 lines
18 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import {
App as AntApp,
Button,
Col,
Divider,
Form,
Input,
InputNumber,
Row,
Select,
Space,
Statistic,
Switch,
Table,
Tabs,
Tag,
Tooltip,
Typography,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import {
ClipboardList,
ListChecks,
Play,
RefreshCw,
Search,
ShoppingCart,
Square,
Users,
} from 'lucide-react'
import {
DefaultConfig,
Exchange,
GetLogs,
GetRecords,
IsRunning,
ListPrizes,
PreviewAccounts,
QueryScores,
StartMonitor,
StopMonitor,
} from '../wailsjs/go/gui/App'
import { EventsOn } from '../wailsjs/runtime/runtime'
const storageKey = 'huya-go.gui.config'
type UiConfig = {
transport: string
wssHost: string
sid: number
pid: number
name: string
cookieText: string
loops: number
delayMs: number
concurrency: number
stopAfterSuccess: boolean
timeoutSec: number
startAt: string
}
type AccountPreview = {
line: number
uid: number
valid: boolean
error?: string
}
type LogEntry = {
time: string
level: string
message: string
}
type PrizeInfo = {
id: number
name: string
score?: number
new_score?: number
num?: number
left_num?: number
status?: number
percent?: string
}
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
action?: string
prize?: PrizeInfo
exchange?: ExchangeResp
error?: string
}
type RecordRow = UserPrizeInfo & {
key: string
uid: number
}
type SavedConfig = Omit<UiConfig, 'cookieText'>
function getExchangeInfo(resp?: ExchangeResp): ExchangeInfo {
return resp?.exchange_info || resp?.exchangeInfo || {}
}
function getPreCondition(resp?: ExchangeResp): ActPreCondition {
return resp?.act_pre_condition || resp?.actPreCondition || {}
}
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 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 || '',
cookieText: values.cookieText || '',
loops: Number(values.loops || 1),
delayMs: Number(values.delayMs || 150),
concurrency: Number(values.concurrency || 1),
stopAfterSuccess: Boolean(values.stopAfterSuccess),
timeoutSec: Number(values.timeoutSec || 15),
startAt: values.startAt || '',
}
}
function readSavedConfig(): Partial<SavedConfig> {
try {
return JSON.parse(localStorage.getItem(storageKey) || '{}') as Partial<SavedConfig>
} catch {
return {}
}
}
export default function Dashboard() {
const { message } = AntApp.useApp()
const [form] = Form.useForm<UiConfig>()
const [loading, setLoading] = useState('')
const [running, setRunning] = useState(false)
const [accounts, setAccounts] = useState<AccountPreview[]>([])
const [prizes, setPrizes] = useState<PrizeInfo[]>([])
const [scores, setScores] = useState<ScoreResult[]>([])
const [records, setRecords] = useState<RecordResult[]>([])
const [results, setResults] = useState<MonitorResult[]>([])
const [logs, setLogs] = useState<LogEntry[]>([])
useEffect(() => {
let mounted = true
Promise.all([DefaultConfig(), GetLogs(), IsRunning()])
.then(([defaults, logItems, isRunning]) => {
if (!mounted) return
form.setFieldsValue({
...(defaults as UiConfig),
...readSavedConfig(),
cookieText: '',
})
setLogs((logItems || []) as LogEntry[])
setRunning(Boolean(isRunning))
})
.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)
}
},
)
return () => {
mounted = false
offLog()
offResult()
offState()
}
}, [form, message])
const config = () => pickConfig(form.getFieldsValue(true))
const previewAccounts = async () => {
const items = (await PreviewAccounts(form.getFieldValue('cookieText') || '')) as AccountPreview[]
setAccounts(items || [])
}
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 saveSettings = () => {
const values = config()
const saved: SavedConfig = {
transport: values.transport,
wssHost: values.wssHost,
sid: values.sid,
pid: values.pid,
name: values.name,
loops: values.loops,
delayMs: values.delayMs,
concurrency: values.concurrency,
stopAfterSuccess: values.stopAfterSuccess,
timeoutSec: values.timeoutSec,
startAt: values.startAt,
}
localStorage.setItem(storageKey, JSON.stringify(saved))
message.success('已保存')
}
const prizeColumns: ColumnsType<PrizeInfo> = useMemo(
() => [
{ title: 'PID', dataIndex: 'id', width: 92 },
{ title: '名称', dataIndex: 'name', minWidth: 220, ellipsis: true },
{
title: '积分',
width: 90,
render: (_, row) => row.new_score || row.score || 0,
},
{ title: '总量', dataIndex: 'num', width: 84 },
{ title: '剩余', dataIndex: 'left_num', width: 84 },
{ title: '状态', dataIndex: 'status', width: 82 },
{ title: '比例', dataIndex: 'percent', width: 88 },
],
[],
)
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: 140 },
{ title: '动作', dataIndex: 'action', width: 120 },
{
title: '奖品',
minWidth: 220,
ellipsis: true,
render: (_, row) => row.prize?.name || getExchangeInfo(row.exchange).desc || '',
},
{
title: '状态',
width: 90,
render: (_, row) => statusTag(row.exchange?.status, row.error),
},
{
title: '消息',
render: (_, row) =>
row.error ||
row.exchange?.msg ||
getPreCondition(row.exchange).tip ||
row.exchange?.order_id ||
row.exchange?.orderId ||
'',
},
],
[],
)
return (
<div className="app-shell">
<aside className="side-panel">
<div className="brand-row">
<div>
<Typography.Title level={4}>虎牙抢购助手</Typography.Title>
<Typography.Text type="secondary">Wails / React / Ant Design</Typography.Text>
</div>
<Tag color={running ? 'processing' : 'default'}>
{running ? '运行中' : '空闲'}
</Tag>
</div>
<Form form={form} layout="vertical" size="small" className="config-form">
<Row gutter={8}>
<Col span={12}>
<Form.Item name="transport" label="传输">
<Select
options={[
{ value: 'http', label: 'HTTP' },
{ value: 'wss', label: 'WSS 实验' },
]}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="timeoutSec" label="超时">
<InputNumber min={1} max={120} addonAfter="s" className="full" />
</Form.Item>
</Col>
</Row>
<Form.Item name="wssHost" label="WSS Host">
<Input allowClear />
</Form.Item>
<Row gutter={8}>
<Col span={12}>
<Form.Item name="sid" label="SID">
<InputNumber min={1} className="full" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="pid" label="PID">
<InputNumber min={0} className="full" />
</Form.Item>
</Col>
</Row>
<Form.Item name="name" label="名称">
<Input allowClear />
</Form.Item>
<Row gutter={8}>
<Col span={8}>
<Form.Item name="loops" label="轮次">
<InputNumber min={1} max={9999} className="full" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="delayMs" label="间隔">
<InputNumber min={10} max={60000} addonAfter="ms" className="full" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="concurrency" label="并发">
<InputNumber min={1} max={100} className="full" />
</Form.Item>
</Col>
</Row>
<Row gutter={8} align="middle">
<Col span={13}>
<Form.Item name="startAt" label="开始时间">
<Input placeholder="YYYY-MM-DD HH:mm:ss" allowClear />
</Form.Item>
</Col>
<Col span={11}>
<Form.Item
name="stopAfterSuccess"
label="成功停止"
valuePropName="checked"
>
<Switch />
</Form.Item>
</Col>
</Row>
<Form.Item name="cookieText" label="Cookie">
<Input.TextArea rows={8} spellCheck={false} className="cookie-box" />
</Form.Item>
</Form>
<Space wrap className="action-bar">
<Button icon={<Users size={16} />} onClick={previewAccounts}>
账号
</Button>
<Button icon={<Search size={16} />} onClick={saveSettings}>
保存
</Button>
<Button
icon={<ListChecks size={16} />}
loading={loading === 'list'}
onClick={() =>
runAction('list', () => ListPrizes(config()), (data) => {
const result = data as { prizes?: PrizeInfo[] }
setPrizes(result?.prizes || [])
})
}
>
奖品
</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
danger
icon={<ShoppingCart size={16} />}
loading={loading === 'exchange'}
onClick={() =>
runAction('exchange', () => Exchange(config()), (data) => {
setResults(((data || []) as ExchangeResult[]).map(normalizeExchangeResult))
})
}
>
兑换
</Button>
</Space>
<Divider />
<Space className="run-row">
<Button
type="primary"
icon={<Play size={16} />}
disabled={running}
onClick={() =>
runAction('monitor', () => StartMonitor(config()), () => {
setRunning(true)
})
}
>
开始
</Button>
<Tooltip title="停止监控">
<Button
icon={<Square size={16} />}
disabled={!running}
onClick={async () => {
await StopMonitor()
setRunning(false)
}}
/>
</Tooltip>
</Space>
<div className="account-list">
{accounts.map((item) => (
<Tag key={item.line} color={item.valid ? 'blue' : 'error'}>
{item.valid ? item.uid : `第${item.line}行`}
</Tag>
))}
</div>
</aside>
<main className="main-panel">
<Row gutter={12} className="stats-row">
<Col span={6}>
<Statistic title="账号" value={accounts.filter((item) => item.valid).length} />
</Col>
<Col span={6}>
<Statistic title="奖品" value={prizes.length} />
</Col>
<Col span={6}>
<Statistic title="积分结果" value={scores.length} />
</Col>
<Col span={6}>
<Statistic title="抢购结果" value={results.length} />
</Col>
</Row>
<Tabs
items={[
{
key: 'prizes',
label: '奖品',
children: (
<Table
size="small"
rowKey={(row) => row.id}
columns={prizeColumns}
dataSource={prizes}
pagination={{ pageSize: 12, showSizeChanger: false }}
scroll={{ x: 760 }}
/>
),
},
{
key: 'scores',
label: '积分',
children: (
<Table
size="small"
rowKey={(row) => row.uid}
columns={scoreColumns}
dataSource={scores}
pagination={{ pageSize: 12, showSizeChanger: false }}
scroll={{ x: 680 }}
/>
),
},
{
key: 'records',
label: '记录',
children: (
<Table
size="small"
rowKey={(row) => row.key}
columns={recordColumns}
dataSource={recordRows}
pagination={{ pageSize: 12, showSizeChanger: false }}
scroll={{ x: 740 }}
/>
),
},
{
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 }}
/>
),
},
{
key: 'logs',
label: '日志',
children: <LogView logs={logs} />,
},
]}
/>
</main>
</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 normalizeExchangeResult(row: ExchangeResult, index: number): MonitorResult {
return {
key: `exchange-${row.uid}-${index}-${Date.now()}`,
loop: 0,
uid: row.uid,
action: 'exchange',
exchange: row.resp,
error: row.error,
}
}
function normalizeMonitorResult(row: MonitorResult): MonitorResult {
return {
...row,
key: `monitor-${row.loop || 0}-${row.uid}-${Date.now()}-${Math.random()}`,
}
}