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:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 分发' },
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user