9afb213b50
按 user_id/手机号查任意用户的全部比价记录:列表(平台/店菜/省/耗时/步数/LLM次数/重试/机型ROM/状态) + 详情抽屉(逐平台对比、卡在哪一步=platform_results 细分原因、设备环境、每次 LLM 调用 input/output JSON 结构化展开、trace 可点链接、raw_payload 全量)。侧栏新增「比价记录」入口。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
304 lines
13 KiB
TypeScript
304 lines
13 KiB
TypeScript
'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>
|
||
);
|
||
}
|