feat(gui): 优化双时间日志呈现与账号兑换记录弹窗明细查看

- 参考斗鱼重构日志双时间展示:本地时间与虎牙估算时间拆分,青色高亮 Tag 醒目显示 H:xx:xx:xx.xxx,排版自适应不折行;新增一键复制带双时间格式日志与清空功能
- 增强账号兑换记录交互:账号列表新增专属「记录」按钮,点击弹出专属兑换记录明细 Modal(支持查看全部历史订单、缩略图、积分、格式化时间,单号一键复制)
- 优化顶部批量「兑换记录」流程:查询完成后自动切至「兑换记录」Tab(由原'复盘'重命名),并显示总记录条数与友好空状态提示
- 验证:Vite 前端打包与 Go 全套单测均干净通过
This commit is contained in:
yml2213
2026-10-17 19:23:19 +08:00
parent 4e8fb03705
commit f89b54dc49
3 changed files with 367 additions and 26 deletions
+318 -22
View File
@@ -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<ScoreResult[]>([])
const [records, setRecords] = useState<RecordResult[]>([])
const [panelTab, setPanelTab] = useState<string>('logs')
const [recordModalData, setRecordModalData] = useState<{
open: boolean
account?: AccountPreview
loading: boolean
records: UserPrizeInfo[]
error?: string
}>({ open: false, loading: false, records: [] })
const [results, setResults] = useState<MonitorResult[]>([])
const [logs, setLogs] = useState<LogEntry[]>([])
const [now, setNow] = useState(new Date())
@@ -963,6 +972,12 @@ export default function Dashboard() {
return map
}, [results])
const recordsByUID = useMemo(() => {
const map = new Map<number, RecordResult>()
records.forEach((item) => map.set(item.uid, item))
return map
}, [records])
// 用服务端兑换记录兜底,界面刷新后也能看出该账号是否已抢到当前奖品。
const recordHitByUID = useMemo(() => {
const map = new Map<number, UserPrizeInfo>()
@@ -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<AccountPreview> = 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 (
<Button
size="small"
icon={<History size={13} />}
onClick={(e) => {
e.stopPropagation()
viewAccountRecords(row)
}}
>
{count > 0 ? `记录 (${count})` : '记录'}
</Button>
)
},
},
],
[
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<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 || '' })
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<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: 'order_id', minWidth: 200, ellipsis: true },
{
title: '名称',
dataIndex: 'name',
minWidth: 200,
ellipsis: true,
render: (name, row) => (
<Space orientation="horizontal" size={6}>
{row.img ? (
<img
src={row.img}
alt={name}
style={{ width: 24, height: 24, borderRadius: 4, objectFit: 'cover' }}
/>
) : null}
<span>{name}</span>
</Space>
),
},
{
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 ? <Tag color="success">成功</Tag> : <Tag>{status}</Tag>,
},
{
title: '订单号',
dataIndex: 'order_id',
minWidth: 220,
render: (orderId) =>
orderId ? (
<div className="order-id-cell">
<span>{orderId}</span>
<Button
type="text"
size="small"
icon={<Copy size={12} />}
title="复制单号"
onClick={() => {
navigator.clipboard.writeText(orderId)
message.success('已复制单号')
}}
/>
</div>
) : (
'-'
),
},
{ title: '时间', width: 160, render: (_, row) => formatUnixTime(row.create_time) },
],
[],
[message],
)
const resultColumns: ColumnsType<MonitorResult> = useMemo(
@@ -1781,6 +1934,26 @@ export default function Dashboard() {
options={logFilterOptions}
/>
<Space size={8}>
<Button
size="small"
icon={<Copy size={13} />}
disabled={visibleLogs.length === 0}
onClick={() => {
const text = visibleLogs
.map(
(l) =>
`${l.time} [${l.category}][${l.level}]${l.account ? `[账号:${l.account}]` : ''} ${l.message}`,
)
.join('\n')
navigator.clipboard.writeText(text)
message.success(`已复制 ${visibleLogs.length} 条日志`)
}}
>
复制
</Button>
<Button size="small" disabled={logs.length === 0} onClick={() => setLogs([])}>
清空
</Button>
<Tooltip title="开启后记录逐条网络请求(debug 级)">
<Switch
size="small"
@@ -1829,7 +2002,7 @@ export default function Dashboard() {
},
{
key: 'records',
label: '复盘',
label: `兑换记录${totalRecordCount > 0 ? ` (${totalRecordCount})` : ''}`,
children: (
<Table
size="small"
@@ -1838,6 +2011,7 @@ export default function Dashboard() {
dataSource={recordRows}
pagination={{ pageSize: 12, showSizeChanger: false }}
scroll={{ x: 740, y: 520 }}
locale={{ emptyText: '暂无兑换记录,可勾选账号后点击上方「兑换记录」按钮或账号行的「记录」按钮进行查询' }}
/>
),
},
@@ -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() {
<div className="side-log-panel">
<div className="panel-tools">
<Tabs items={panelTabs} size="small" />
<Tabs activeKey={panelTab} onChange={setPanelTab} items={panelTabs} size="small" />
</div>
</div>
</section>
@@ -2338,6 +2516,107 @@ export default function Dashboard() {
</div>
</div>
</Modal>
<Modal
title={
<Space>
<span>兑换记录明细</span>
{recordModalData.account ? (
<Tag color="blue">UID: {recordModalData.account.uid}</Tag>
) : null}
{!recordModalData.loading ? (
<Tag color={recordModalData.records.length > 0 ? 'green' : 'default'}>
共 {recordModalData.records.length} 条
</Tag>
) : null}
</Space>
}
open={recordModalData.open}
width="min(860px, calc(100vw - 48px))"
footer={null}
onCancel={() => setRecordModalData((prev) => ({ ...prev, open: false }))}
>
{recordModalData.loading ? (
<div style={{ textAlign: 'center', padding: '36px 0' }}>
<Typography.Text type="secondary">正在向虎牙服务器查询该账号兑换记录...</Typography.Text>
</div>
) : recordModalData.error ? (
<div style={{ padding: '16px 0' }}>
<Tag color="error">查询失败</Tag>
<span>{recordModalData.error}</span>
</div>
) : recordModalData.records.length === 0 ? (
<div style={{ textAlign: 'center', padding: '36px 0' }}>
<Typography.Text type="secondary">暂无兑换记录(该账号在当前活动下尚无成功订单)</Typography.Text>
</div>
) : (
<Table
size="small"
className="record-modal-table"
rowKey={(r, idx) => `${r.id || idx}-${r.order_id || ''}`}
dataSource={recordModalData.records}
pagination={{ pageSize: 8, showSizeChanger: false }}
columns={[
{
title: '商品',
dataIndex: 'name',
render: (name, r) => (
<Space orientation="horizontal" size={8}>
{r.img ? (
<img
src={r.img}
alt={name}
style={{ width: 32, height: 32, borderRadius: 4, objectFit: 'cover' }}
/>
) : null}
<div>
<strong>{name || '未知商品'}</strong>
{r.prize_id ? <div className="muted">PID: {r.prize_id}</div> : null}
</div>
</Space>
),
},
{
title: '消耗积分',
width: 110,
render: (_, r) => <Tag color="orange">{r.new_score || r.score || 0} 积分</Tag>,
},
{
title: '订单号',
dataIndex: 'order_id',
width: 260,
render: (orderId) =>
orderId ? (
<div className="order-id-cell">
<span>{orderId}</span>
<Button
type="text"
size="small"
icon={<Copy size={12} />}
title="复制单号"
onClick={() => {
navigator.clipboard.writeText(orderId)
message.success('已复制单号')
}}
/>
</div>
) : (
'-'
),
},
{
title: '兑换时间',
width: 170,
render: (_, r) => formatUnixTime(r.create_time),
},
{
title: '状态',
width: 80,
render: () => <Tag color="success">成功</Tag>,
},
]}
/>
)}
</Modal>
</Form>
)
}
@@ -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 (
<div className="log-view">
{logs.map((item) => (
<div className={`log-line level-${item.level}`} key={item.id}>
<span>{item.time}</span>
<Tag>{item.level}</Tag>
<code>
{item.category ? `[${item.category}]` : ''}
{item.account ? `[账号:${item.account}]` : ''} {item.message}
</code>
</div>
))}
{logs.map((item) => {
const { local, huya } = parseLogTime(item.time)
return (
<div className={`log-line level-${item.level}`} key={item.id}>
<span className="log-local-time">{local}</span>
{huya ? (
<Tag color="cyan" className="log-huya-tag">
H:{huya}
</Tag>
) : null}
<Tag className="log-level-tag">{item.level}</Tag>
<code>
{item.category ? `[${item.category}]` : ''}
{item.account ? `[账号:${item.account}]` : ''} {item.message}
</code>
</div>
)
})}
</div>
)
}
+43 -3
View File
@@ -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));
+6 -1
View File
@@ -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
}