This commit is contained in:
yml2213
2026-07-06 18:08:23 +08:00
commit 4235cce4f5
49 changed files with 9880 additions and 0 deletions
+702
View File
@@ -0,0 +1,702 @@
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 || 'wss',
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: 'wss', label: 'WSS' },
{ value: 'http', label: 'HTTP' },
]}
/>
</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()}`,
}
}
+37
View File
@@ -0,0 +1,37 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import { App as AntApp, ConfigProvider } from 'antd'
import 'antd/dist/reset.css'
import './styles.css'
import Dashboard from './App'
createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ConfigProvider
theme={{
token: {
borderRadius: 6,
colorPrimary: '#1677ff',
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
},
components: {
Button: {
controlHeight: 34,
},
Table: {
cellPaddingBlock: 8,
cellPaddingInline: 10,
},
Tabs: {
horizontalMargin: '0 0 10px',
},
},
}}
>
<AntApp>
<Dashboard />
</AntApp>
</ConfigProvider>
</React.StrictMode>,
)
+169
View File
@@ -0,0 +1,169 @@
* {
box-sizing: border-box;
}
html,
body,
#root {
width: 100%;
height: 100%;
margin: 0;
}
body {
color: #1f2937;
background: #eef2f7;
}
.app-shell {
display: grid;
grid-template-columns: 380px minmax(0, 1fr);
gap: 12px;
height: 100vh;
padding: 12px;
overflow: hidden;
}
.side-panel,
.main-panel {
min-height: 0;
border: 1px solid #d9e1ea;
border-radius: 8px;
background: #fff;
}
.side-panel {
display: flex;
flex-direction: column;
padding: 14px;
overflow: auto;
}
.main-panel {
display: flex;
flex-direction: column;
padding: 14px;
overflow: hidden;
}
.brand-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
margin-bottom: 14px;
}
.brand-row h4 {
margin: 0 0 2px;
}
.config-form .ant-form-item {
margin-bottom: 10px;
}
.full {
width: 100%;
}
.cookie-box textarea,
.cookie-box {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.action-bar {
display: flex;
margin-top: 2px;
}
.run-row {
width: 100%;
}
.run-row .ant-btn-primary {
flex: 1;
}
.account-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
min-height: 30px;
padding-top: 12px;
}
.stats-row {
margin-bottom: 12px;
}
.stats-row .ant-statistic {
padding: 10px 12px;
border: 1px solid #e5ebf2;
border-radius: 6px;
background: #f8fafc;
}
.main-panel .ant-tabs {
min-height: 0;
flex: 1;
}
.main-panel .ant-tabs-content,
.main-panel .ant-tabs-tabpane {
height: 100%;
}
.log-view {
height: calc(100vh - 170px);
min-height: 360px;
overflow: auto;
border: 1px solid #e5ebf2;
border-radius: 6px;
background: #0f172a;
padding: 10px;
}
.log-line {
display: grid;
grid-template-columns: 92px 64px minmax(0, 1fr);
align-items: start;
gap: 8px;
min-height: 24px;
color: #cbd5e1;
font-size: 12px;
line-height: 1.6;
}
.log-line code {
white-space: pre-wrap;
word-break: break-word;
color: inherit;
}
.log-line.level-error {
color: #fecaca;
}
.log-line.level-debug {
color: #93c5fd;
}
.ant-table-wrapper {
height: 100%;
}
@media (max-width: 980px) {
.app-shell {
grid-template-columns: 1fr;
overflow: auto;
}
.side-panel,
.main-panel {
overflow: visible;
}
.log-view {
height: 420px;
}
}