diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7e0e3c1..eb9cb91 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -23,6 +23,7 @@ import { ClipboardPaste, Copy, FolderOpen, + History, ListChecks, Pencil, Play, @@ -747,6 +748,14 @@ export default function Dashboard() { const [productSearch, setProductSearch] = useState('') const [scores, setScores] = useState([]) const [records, setRecords] = useState([]) + const [panelTab, setPanelTab] = useState('logs') + const [recordModalData, setRecordModalData] = useState<{ + open: boolean + account?: AccountPreview + loading: boolean + records: UserPrizeInfo[] + error?: string + }>({ open: false, loading: false, records: [] }) const [results, setResults] = useState([]) const [logs, setLogs] = useState([]) const [now, setNow] = useState(new Date()) @@ -963,6 +972,12 @@ export default function Dashboard() { return map }, [results]) + const recordsByUID = useMemo(() => { + const map = new Map() + records.forEach((item) => map.set(item.uid, item)) + return map + }, [records]) + // 用服务端兑换记录兜底,界面刷新后也能看出该账号是否已抢到当前奖品。 const recordHitByUID = useMemo(() => { const map = new Map() @@ -1485,6 +1500,58 @@ export default function Dashboard() { setSelectedAccountLines([]) } + const viewAccountRecords = async (account: AccountPreview) => { + const cached = recordsByUID.get(account.uid) + if (cached && cached.records && cached.records.length > 0) { + setRecordModalData({ + open: true, + account, + loading: false, + records: cached.records, + error: cached.error, + }) + return + } + + setRecordModalData({ + open: true, + account, + loading: true, + records: cached?.records || [], + error: undefined, + }) + + try { + const values = config() + const singleConfig = { + ...values, + selectedAccountLines: [account.line], + } + const data = await GetRecords(singleConfig) + const res = (data || [])[0] + const fetchedRecords = res?.records || [] + setRecordModalData({ + open: true, + account, + loading: false, + records: fetchedRecords, + error: + res?.error || (res?.resp?.status && res.resp.status !== 200 ? res.resp.msg : undefined), + }) + if (res) { + setRecords((prev) => [...prev.filter((p) => p.uid !== account.uid), res]) + } + } catch (err: unknown) { + setRecordModalData({ + open: true, + account, + loading: false, + records: [], + error: String(err), + }) + } + } + const accountColumns: ColumnsType = useMemo( () => [ { @@ -1598,12 +1665,33 @@ export default function Dashboard() { return result?.error || result?.message || result?.exchange?.msg || '' }, }, + { + title: '兑换记录', + width: 104, + render: (_, row) => { + const rec = recordsByUID.get(row.uid) + const count = rec?.records?.length || 0 + return ( + + ) + }, + }, ], [ currentPrize?.name, exchangeByUID, latestResultByUID, recordHitByUID, + recordsByUID, running, scoreMap, watchedName, @@ -1694,15 +1782,30 @@ export default function Dashboard() { [], ) + const totalRecordCount = useMemo(() => { + return records.reduce((sum, item) => sum + (item.records?.length || 0), 0) + }, [records]) + const recordRows = useMemo(() => { 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 || '' }) + const emptyMsg = + item.error || + (item.resp?.status && item.resp.status !== 200 ? item.resp.msg : '(无兑换记录)') + rows.push({ + key: `${item.uid}-empty`, + uid: item.uid, + name: emptyMsg, + }) return } item.records.forEach((record, index) => { - rows.push({ key: `${item.uid}-${index}-${record.id || 'record'}`, uid: item.uid, ...record }) + rows.push({ + key: `${item.uid}-${index}-${record.id || 'record'}`, + uid: item.uid, + ...record, + }) }) }) return rows @@ -1711,13 +1814,63 @@ export default function Dashboard() { const recordColumns: ColumnsType = 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: 'order_id', minWidth: 200, ellipsis: true }, + { + title: '名称', + dataIndex: 'name', + minWidth: 200, + ellipsis: true, + render: (name, row) => ( + + {row.img ? ( + {name} + ) : null} + {name} + + ), + }, + { + title: '积分', + width: 90, + render: (_, row) => + row.new_score || row.score ? `${row.new_score || row.score} 分` : '-', + }, + { + title: '状态', + dataIndex: 'status', + width: 84, + render: (status) => + status === 200 || !status ? 成功 : {status}, + }, + { + title: '订单号', + dataIndex: 'order_id', + minWidth: 220, + render: (orderId) => + orderId ? ( +
+ {orderId} +
+ ) : ( + '-' + ), + }, { title: '时间', width: 160, render: (_, row) => formatUnixTime(row.create_time) }, ], - [], + [message], ) const resultColumns: ColumnsType = useMemo( @@ -1781,6 +1934,26 @@ export default function Dashboard() { options={logFilterOptions} /> + + 0 ? ` (${totalRecordCount})` : ''}`, children: ( ), }, @@ -2049,9 +2223,13 @@ export default function Dashboard() { message.warning('请先选择账号') return } - runAction('records', () => GetRecords(values), (data) => - setRecords((data || []) as RecordResult[]), - ) + runAction('records', () => GetRecords(values), (data) => { + const list = (data || []) as RecordResult[] + setRecords(list) + setPanelTab('records') + const total = list.reduce((sum, item) => sum + (item.records?.length || 0), 0) + message.success(`查询完成:${list.length} 个账号,共 ${total} 条兑换记录`) + }) }} > 兑换记录 @@ -2116,7 +2294,7 @@ export default function Dashboard() {
- +
@@ -2338,6 +2516,107 @@ export default function Dashboard() { + + 兑换记录明细 + {recordModalData.account ? ( + UID: {recordModalData.account.uid} + ) : null} + {!recordModalData.loading ? ( + 0 ? 'green' : 'default'}> + 共 {recordModalData.records.length} 条 + + ) : null} + + } + open={recordModalData.open} + width="min(860px, calc(100vw - 48px))" + footer={null} + onCancel={() => setRecordModalData((prev) => ({ ...prev, open: false }))} + > + {recordModalData.loading ? ( +
+ 正在向虎牙服务器查询该账号兑换记录... +
+ ) : recordModalData.error ? ( +
+ 查询失败 + {recordModalData.error} +
+ ) : recordModalData.records.length === 0 ? ( +
+ 暂无兑换记录(该账号在当前活动下尚无成功订单) +
+ ) : ( +
`${r.id || idx}-${r.order_id || ''}`} + dataSource={recordModalData.records} + pagination={{ pageSize: 8, showSizeChanger: false }} + columns={[ + { + title: '商品', + dataIndex: 'name', + render: (name, r) => ( + + {r.img ? ( + {name} + ) : null} +
+ {name || '未知商品'} + {r.prize_id ?
PID: {r.prize_id}
: null} +
+
+ ), + }, + { + title: '消耗积分', + width: 110, + render: (_, r) => {r.new_score || r.score || 0} 积分, + }, + { + title: '订单号', + dataIndex: 'order_id', + width: 260, + render: (orderId) => + orderId ? ( +
+ {orderId} +
+ ) : ( + '-' + ), + }, + { + title: '兑换时间', + width: 170, + render: (_, r) => formatUnixTime(r.create_time), + }, + { + title: '状态', + width: 80, + render: () => 成功, + }, + ]} + /> + )} + ) } @@ -2543,19 +2822,36 @@ function padNumber(value: number, size = 2): string { return String(value).padStart(size, '0') } +function parseLogTime(rawTime?: string): { local: string; huya?: string } { + if (!rawTime) return { local: '' } + const match = rawTime.match(/^(.*?)\(H(.*?)\)$/) + if (match) { + return { local: match[1], huya: match[2] } + } + return { local: rawTime } +} + function LogView({ logs }: { logs: LogEntry[] }) { return (
- {logs.map((item) => ( -
- {item.time} - {item.level} - - {item.category ? `[${item.category}]` : ''} - {item.account ? `[账号:${item.account}]` : ''} {item.message} - -
- ))} + {logs.map((item) => { + const { local, huya } = parseLogTime(item.time) + return ( +
+ {local} + {huya ? ( + + H:{huya} + + ) : null} + {item.level} + + {item.category ? `[${item.category}]` : ''} + {item.account ? `[账号:${item.account}]` : ''} {item.message} + +
+ ) + })}
) } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 91b189a..e2b82b6 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -452,20 +452,48 @@ body { } .log-line { - display: grid; - grid-template-columns: 88px 54px minmax(0, 1fr); - align-items: start; + display: flex; + align-items: flex-start; gap: 6px; min-height: 23px; color: #4b5565; font-size: 12px; line-height: 1.55; + padding: 1px 0; +} + +.log-line .log-local-time { + color: #64748b; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 11px; + white-space: nowrap; + flex-shrink: 0; +} + +.log-line .log-huya-tag { + margin: 0; + padding: 0 4px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 11px; + font-weight: 600; + line-height: 18px; + flex-shrink: 0; +} + +.log-line .log-level-tag { + margin: 0; + padding: 0 4px; + line-height: 18px; + font-size: 11px; + flex-shrink: 0; } .log-line code { color: #1677ff; white-space: pre-wrap; word-break: break-word; + flex: 1; + font-family: inherit; } .log-line.level-error code { @@ -476,6 +504,18 @@ body { color: #667085; } +.record-modal-table { + margin-top: 12px; +} + +.order-id-cell { + display: flex; + align-items: center; + gap: 6px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; +} + .stats-pane { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/internal/gui/app.go b/internal/gui/app.go index 8f6a6d9..f91dddf 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -422,6 +422,8 @@ func (a *App) GetRecords(cfg Config) ([]RecordResult, error) { defer closeClient() results := make([]RecordResult, len(accounts)) + totalRecords := 0 + var recordMu sync.Mutex a.runConcurrent(accounts, cfg.Concurrency, func(i int, account sniper.Account) { ctx, cancel := requestContext(cfg.TimeoutSec) defer cancel() @@ -429,10 +431,13 @@ func (a *App) GetRecords(cfg Config) ([]RecordResult, error) { item := RecordResult{UID: account.UID, Resp: resp, Error: errorString(err)} if resp != nil { item.Records = resp.Records + recordMu.Lock() + totalRecords += len(resp.Records) + recordMu.Unlock() } results[i] = item }) - a.addLog("info", "查询兑换记录完成: accounts=%d", len(accounts)) + a.addLog("info", "查询兑换记录完成: accounts=%d 总记录=%d", len(accounts), totalRecords) return results, nil }