703 lines
18 KiB
TypeScript
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()}`,
|
|
}
|
|
}
|