feat(admin): 比价记录 debug 页

按 user_id/手机号查任意用户的全部比价记录:列表(平台/店菜/省/耗时/步数/LLM次数/重试/机型ROM/状态)
+ 详情抽屉(逐平台对比、卡在哪一步=platform_results 细分原因、设备环境、每次 LLM 调用 input/output
JSON 结构化展开、trace 可点链接、raw_payload 全量)。侧栏新增「比价记录」入口。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 22:33:37 +08:00
parent 1a6e590a42
commit 9afb213b50
3 changed files with 369 additions and 0 deletions
+303
View File
@@ -0,0 +1,303 @@
'use client';
import { useState } from 'react';
import type { CSSProperties } from 'react';
import {
App, Button, Card, Collapse, Descriptions, Drawer, Input, InputNumber,
Select, Space, Spin, Table, Tag, Typography,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { api, errMsg } from '@/lib/api';
import { formatUtcTime, yuan } from '@/lib/format';
import { usePagedList } from '@/lib/usePagedList';
import type { ComparisonRecordDetail, ComparisonRecordListItem } from '@/lib/types';
const STATUS_COLOR: Record<string, string> = { success: 'green', failed: 'red' };
// 「卡在哪一步」: platform_results[*].status 翻成人话(找店/加菜/起送/读价)
const STUCK_LABEL: Record<string, string> = {
success: '成功',
store_not_found: '没找到店',
items_not_found: '菜没匹配上',
below_minimum: '未达起送',
unsupported: '平台不支持',
failed: '失败',
};
const fmtMs = (ms: number | null) => (ms == null ? '-' : `${(ms / 1000).toFixed(1)}s`);
const cents = (c: number | null) => (c == null ? '-' : yuan(c));
// LLM message content 常是 json.dumps 的卡片列表;能 parse 成 JSON 就缩进展开,否则原样。
function pretty(s: string | null): string {
if (!s) return '';
try {
return JSON.stringify(JSON.parse(s), null, 2);
} catch {
return s;
}
}
const preStyle: CSSProperties = {
background: '#f6f8fa', padding: 8, borderRadius: 6, fontSize: 12,
maxHeight: 340, overflow: 'auto', whiteSpace: 'pre-wrap', wordBreak: 'break-word', margin: 0,
};
export default function ComparisonRecordsPage() {
const { message } = App.useApp();
const [userId, setUserId] = useState<number | null>(null);
const [phone, setPhone] = useState('');
const [status, setStatus] = useState<string | undefined>();
const [applied, setApplied] = useState<Record<string, unknown>>({});
const { items, total, page, pageSize, loading, onChange } =
usePagedList<ComparisonRecordListItem>('/admin/api/comparison-records', applied);
const [detail, setDetail] = useState<ComparisonRecordDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const search = () =>
setApplied({ user_id: userId ?? undefined, phone: phone || undefined, status });
const reset = () => {
setUserId(null);
setPhone('');
setStatus(undefined);
setApplied({});
};
const openDetail = async (id: number) => {
setDetailLoading(true);
setDetail(null);
try {
const { data } = await api.get<ComparisonRecordDetail>(`/admin/api/comparison-records/${id}`);
setDetail(data);
} catch (e) {
message.error(errMsg(e));
} finally {
setDetailLoading(false);
}
};
const closeDetail = () => {
setDetail(null);
setDetailLoading(false);
};
const columns: ColumnsType<ComparisonRecordListItem> = [
{ title: 'ID', dataIndex: 'id', width: 64 },
{
title: '用户',
key: 'user',
width: 140,
render: (_, r) => (
<div>
<div>{r.phone || `#${r.user_id}`}</div>
{r.nickname && <div style={{ color: '#999', fontSize: 12 }}>{r.nickname}</div>}
</div>
),
},
{ title: '状态', dataIndex: 'status', width: 72, render: (s: string) => <Tag color={STATUS_COLOR[s]}>{s}</Tag> },
{ title: '源平台', dataIndex: 'source_platform_name', width: 90, render: (v) => v || '-' },
{ title: '店/商品', dataIndex: 'store_name', width: 150, ellipsis: true, render: (v) => v || '-' },
{ title: '最优', dataIndex: 'best_platform_name', width: 80, render: (v) => v || '-' },
{
title: '省',
key: 'saved',
width: 84,
render: (_, r) =>
r.saved_amount_cents == null ? '-' : (
<b style={{ color: r.saved_amount_cents > 0 ? '#3f8600' : '#999' }}>{yuan(r.saved_amount_cents)}</b>
),
},
{ title: '耗时', dataIndex: 'total_ms', width: 64, render: fmtMs },
{ title: '步', dataIndex: 'step_count', width: 48, render: (v) => v ?? '-' },
{ title: 'LLM', dataIndex: 'llm_call_count', width: 56, render: (v) => v ?? '-' },
{
title: '重试',
dataIndex: 'retry_count',
width: 50,
render: (v: number | null) => (v ? <span style={{ color: '#cf1322' }}>{v}</span> : v ?? '-'),
},
{
title: '机型/ROM',
key: 'device',
width: 150,
ellipsis: true,
render: (_, r) => [r.device_model, r.rom_name].filter(Boolean).join(' / ') || '-',
},
{ title: '时间', dataIndex: 'created_at', width: 150, render: (v: string) => formatUtcTime(v) },
{
title: '操作',
key: 'op',
fixed: 'right',
width: 80,
render: (_, r) =>
r.trace_url ? (
<a href={r.trace_url} target="_blank" rel="noreferrer" onClick={(e) => e.stopPropagation()}>trace</a>
) : (
<span style={{ color: '#ccc' }}>-</span>
),
},
];
const platformResults =
(detail?.raw_payload?.platform_results as Record<string, unknown>[] | undefined) || [];
return (
<div>
<h2>(debug)</h2>
<Space style={{ marginBottom: 16 }} wrap>
<InputNumber placeholder="user_id" value={userId} onChange={setUserId} style={{ width: 110 }} />
<Input
placeholder="手机号前缀"
value={phone}
onChange={(e) => setPhone(e.target.value)}
onPressEnter={search}
allowClear
style={{ width: 150 }}
/>
<Select
placeholder="状态"
value={status}
onChange={setStatus}
allowClear
style={{ width: 110 }}
options={[{ value: 'success', label: 'success' }, { value: 'failed', label: 'failed' }]}
/>
<Button type="primary" onClick={search}></Button>
<Button onClick={reset}></Button>
</Space>
<Table
rowKey="id"
columns={columns}
dataSource={items}
loading={loading}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
onChange,
}}
scroll={{ x: 1320 }}
onRow={(r) => ({ onClick: () => openDetail(r.id), style: { cursor: 'pointer' } })}
/>
<Drawer title={detail ? `比价记录 #${detail.id}` : '详情'} width={780} open={detailLoading || !!detail} onClose={closeDetail}>
{detailLoading && <Spin />}
{detail && (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Descriptions size="small" column={2} bordered>
<Descriptions.Item label="用户">
{detail.phone || `#${detail.user_id}`}{detail.nickname ? `(${detail.nickname})` : ''}
</Descriptions.Item>
<Descriptions.Item label="状态"><Tag color={STATUS_COLOR[detail.status]}>{detail.status}</Tag></Descriptions.Item>
<Descriptions.Item label="业务">{detail.business_type}</Descriptions.Item>
<Descriptions.Item label="耗时">{fmtMs(detail.total_ms)}</Descriptions.Item>
<Descriptions.Item label="步数">{detail.step_count ?? '-'}</Descriptions.Item>
<Descriptions.Item label="LLM 次数/重试">{detail.llm_call_count ?? '-'} / {detail.retry_count ?? '-'}</Descriptions.Item>
<Descriptions.Item label="源平台">{detail.source_platform_name || '-'}{cents(detail.source_price_cents)}</Descriptions.Item>
<Descriptions.Item label="最优">{detail.best_platform_name || '-'}{cents(detail.best_price_cents)}</Descriptions.Item>
<Descriptions.Item label="省" span={2}>{cents(detail.saved_amount_cents)}{detail.is_source_best ? '(源平台最便宜)' : ''}</Descriptions.Item>
<Descriptions.Item label="店/商品" span={2}>{detail.store_name || '-'}</Descriptions.Item>
<Descriptions.Item label="结论文案" span={2}>{detail.information || '-'}</Descriptions.Item>
<Descriptions.Item label="trace" span={2}>
{detail.trace_url
? <a href={detail.trace_url} target="_blank" rel="noreferrer">{detail.trace_url}</a>
: <span style={{ color: '#999' }}>{detail.trace_id} trace_url</span>}
</Descriptions.Item>
<Descriptions.Item label="定位" span={2}>
{detail.longitude != null ? `${detail.longitude}, ${detail.latitude}` : '-'}
</Descriptions.Item>
</Descriptions>
<Card size="small" title="设备环境">
<Descriptions size="small" column={2}>
<Descriptions.Item label="机型">{detail.device_model || '-'}{detail.device_manufacturer || '-'}</Descriptions.Item>
<Descriptions.Item label="ROM">{[detail.rom_vendor, detail.rom_name, detail.rom_version].filter(Boolean).join(' ') || '-'}</Descriptions.Item>
<Descriptions.Item label="Android">{detail.android_version || '-'}SDK {detail.android_sdk ?? '-'}</Descriptions.Item>
<Descriptions.Item label="App 版本">{detail.app_version || '-'}{detail.app_version_code ?? '-'}</Descriptions.Item>
<Descriptions.Item label="源 App 版本" span={2}>{detail.source_app_version || '-'}</Descriptions.Item>
</Descriptions>
</Card>
{platformResults.length > 0 && (
<Card size="small" title="逐平台结局(卡在哪一步)">
<Table
size="small"
rowKey={(_, i) => String(i)}
pagination={false}
dataSource={platformResults}
columns={[
{ title: '平台', dataIndex: 'platform_name', render: (v, r: Record<string, unknown>) => (v as string) || (r.platform_id as string) || '-' },
{ title: '结局', dataIndex: 'status', render: (s: string) => <Tag color={s === 'success' ? 'green' : 'orange'}>{STUCK_LABEL[s] || s || '-'}</Tag> },
{ title: '说明', dataIndex: 'reason', render: (v) => (v as string) || '-' },
]}
/>
</Card>
)}
{detail.comparison_results.length > 0 && (
<Collapse
size="small"
items={[{
key: 'cmp',
label: `逐平台对比(${detail.comparison_results.length}`,
children: <pre style={preStyle}>{JSON.stringify(detail.comparison_results, null, 2)}</pre>,
}]}
/>
)}
<Card size="small" title={`LLM 调用明细(${detail.llm_calls?.length ?? 0} 次)`}>
{detail.llm_calls && detail.llm_calls.length > 0 ? (
<Collapse
size="small"
items={detail.llm_calls.map((c, i) => ({
key: String(i),
label: (
<Space size={6} wrap>
<Tag color="blue">{c.scene || 'llm'}</Tag>
<span style={{ color: '#999', fontSize: 12 }}>{c.model}</span>
<span style={{ color: '#999', fontSize: 12 }}>{c.latency_ms ?? '-'}ms</span>
{c.usage?.total_tokens ? <span style={{ color: '#999', fontSize: 12 }}>{c.usage.total_tokens} tok</span> : null}
{c.error ? <Tag color="red">error</Tag> : null}
</Space>
),
children: (
<div>
{c.input_messages.map((m, j) => (
<div key={j} style={{ marginBottom: 8 }}>
<Tag>{m.role}</Tag>
<pre style={preStyle}>{pretty(m.content)}</pre>
</div>
))}
<div style={{ marginTop: 8 }}>
<Tag color="green">output</Tag>
<pre style={preStyle}>{c.error ? `【error】${c.error}` : pretty(c.output)}</pre>
</div>
</div>
),
}))}
/>
) : (
<Typography.Text type="secondary"> LLM / pricebot / LLM</Typography.Text>
)}
</Card>
<Collapse
size="small"
items={[{
key: 'raw',
label: '原始上报 raw_payload',
children: <pre style={preStyle}>{JSON.stringify(detail.raw_payload, null, 2)}</pre>,
}]}
/>
</Space>
)}
</Drawer>
</div>
);
}
+2
View File
@@ -11,6 +11,7 @@ import {
MessageOutlined, MessageOutlined,
MobileOutlined, MobileOutlined,
MoneyCollectOutlined, MoneyCollectOutlined,
ProfileOutlined,
SettingOutlined, SettingOutlined,
ShareAltOutlined, ShareAltOutlined,
TeamOutlined, TeamOutlined,
@@ -28,6 +29,7 @@ const MENU = [
{ key: '/devices', icon: <MobileOutlined />, label: '设备管理' }, { key: '/devices', icon: <MobileOutlined />, label: '设备管理' },
{ key: '/withdraws', icon: <MoneyCollectOutlined />, label: '提现管理' }, { key: '/withdraws', icon: <MoneyCollectOutlined />, label: '提现管理' },
{ key: '/price-reports', icon: <FlagOutlined />, label: '上报审核' }, { key: '/price-reports', icon: <FlagOutlined />, label: '上报审核' },
{ key: '/comparison-records', icon: <ProfileOutlined />, label: '比价记录' },
{ key: '/feedbacks', icon: <MessageOutlined />, label: '反馈工单' }, { key: '/feedbacks', icon: <MessageOutlined />, label: '反馈工单' },
{ key: '/ad-revenue', icon: <BarChartOutlined />, label: '广告数据' }, { key: '/ad-revenue', icon: <BarChartOutlined />, label: '广告数据' },
{ key: '/cps', icon: <ShareAltOutlined />, label: 'CPS 分发' }, { key: '/cps', icon: <ShareAltOutlined />, label: 'CPS 分发' },
+64
View File
@@ -189,6 +189,70 @@ export interface AuditLog {
} }
// 广告收益报表聚合行下钻的单条展示明细 // 广告收益报表聚合行下钻的单条展示明细
// ===== 比价记录(admin debug 页)=====
export interface ComparisonRecordListItem {
id: number;
user_id: number;
phone: string | null;
nickname: string | null;
business_type: string;
trace_id: string;
trace_url: string | null;
status: string;
information: string | null;
store_name: string | null;
source_platform_name: string | null;
best_platform_name: string | null;
source_price_cents: number | null;
best_price_cents: number | null;
saved_amount_cents: number | null;
total_ms: number | null;
step_count: number | null;
llm_call_count: number | null;
retry_count: number | null;
device_model: string | null;
rom_vendor: string | null;
rom_name: string | null;
android_version: string | null;
app_version: string | null;
created_at: string;
}
// 单次 LLM 调用明细(pricebot chat() 收口落盘,server 按 trace 拉来)
export interface LlmCall {
ts?: number;
scene: string;
model: string;
input_messages: { role: string; content: string }[];
output: string | null;
usage: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number } | null;
latency_ms: number | null;
error: string | null;
}
export interface ComparisonRecordDetail extends ComparisonRecordListItem {
source_platform_id: string | null;
source_package: string | null;
best_platform_id: string | null;
best_deeplink: string | null;
is_source_best: boolean | null;
total_dish_count: number | null;
skipped_dish_count: number | null;
device_id: string | null;
items: { name: string; qty?: number; specs?: string[] }[];
comparison_results: Record<string, unknown>[];
skipped_dish_names: string[];
device_manufacturer: string | null;
rom_version: number | null;
android_sdk: number | null;
app_version_code: number | null;
source_app_version: string | null;
longitude: number | null;
latitude: number | null;
llm_calls: LlmCall[] | null;
raw_payload: Record<string, unknown> | null;
}
export interface AdRevenueImpression { export interface AdRevenueImpression {
id: number; id: number;
created_at: string; created_at: string;