feat(comparison-records): 展示 LLM token(入/出)与按单价估算成本
比价记录页新增 Token 列与成本列;顶部输入框配置每百万 token 单价(localStorage 持久化),仅前端估算不入库。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,16 @@ const STUCK_LABEL: Record<string, string> = {
|
|||||||
const fmtMs = (ms: number | null) => (ms == null ? '-' : `${(ms / 1000).toFixed(1)}s`);
|
const fmtMs = (ms: number | null) => (ms == null ? '-' : `${(ms / 1000).toFixed(1)}s`);
|
||||||
const cents = (c: number | null) => (c == null ? '-' : yuan(c));
|
const cents = (c: number | null) => (c == null ? '-' : yuan(c));
|
||||||
|
|
||||||
|
// LLM 成本估算:(input + output) token / 1e6 × 每百万 token 单价(元)。单价由页面输入框配置(localStorage)。
|
||||||
|
const calcCost = (inTok: number | null, outTok: number | null, price: number | null): number | null => {
|
||||||
|
if (price == null || Number.isNaN(price)) return null;
|
||||||
|
if (inTok == null && outTok == null) return null;
|
||||||
|
return (((inTok ?? 0) + (outTok ?? 0)) / 1_000_000) * price;
|
||||||
|
};
|
||||||
|
const fmtCost = (c: number | null) => (c == null ? '-' : `¥${c.toFixed(4)}`);
|
||||||
|
const fmtTok = (inTok: number | null, outTok: number | null) =>
|
||||||
|
inTok == null && outTok == null ? '-' : `${inTok ?? 0}/${outTok ?? 0}`;
|
||||||
|
|
||||||
// LLM message content 常是 json.dumps 的卡片列表;能 parse 成 JSON 就缩进展开,否则原样。
|
// LLM message content 常是 json.dumps 的卡片列表;能 parse 成 JSON 就缩进展开,否则原样。
|
||||||
function pretty(s: string | null): string {
|
function pretty(s: string | null): string {
|
||||||
if (!s) return '';
|
if (!s) return '';
|
||||||
@@ -50,6 +60,18 @@ export default function ComparisonRecordsPage() {
|
|||||||
const [phone, setPhone] = useState('');
|
const [phone, setPhone] = useState('');
|
||||||
const [status, setStatus] = useState<string | undefined>();
|
const [status, setStatus] = useState<string | undefined>();
|
||||||
const [applied, setApplied] = useState<Record<string, unknown>>({});
|
const [applied, setApplied] = useState<Record<string, unknown>>({});
|
||||||
|
// LLM 单价(元/百万 token),本地持久化;仅用于前端估算成本,不入库、不影响其它页面
|
||||||
|
const [pricePerMTok, setPricePerMTok] = useState<number | null>(() => {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
const v = localStorage.getItem('llm_price_per_mtok');
|
||||||
|
return v != null && v !== '' ? Number(v) : null;
|
||||||
|
});
|
||||||
|
const onPriceChange = (v: number | null) => {
|
||||||
|
setPricePerMTok(v);
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
if (v == null) localStorage.removeItem('llm_price_per_mtok');
|
||||||
|
else localStorage.setItem('llm_price_per_mtok', String(v));
|
||||||
|
};
|
||||||
|
|
||||||
const { items, total, page, pageSize, loading, onChange } =
|
const { items, total, page, pageSize, loading, onChange } =
|
||||||
usePagedList<ComparisonRecordListItem>('/admin/api/comparison-records', applied);
|
usePagedList<ComparisonRecordListItem>('/admin/api/comparison-records', applied);
|
||||||
@@ -120,6 +142,18 @@ export default function ComparisonRecordsPage() {
|
|||||||
width: 50,
|
width: 50,
|
||||||
render: (v: number | null) => (v ? <span style={{ color: '#cf1322' }}>{v}</span> : v ?? '-'),
|
render: (v: number | null) => (v ? <span style={{ color: '#cf1322' }}>{v}</span> : v ?? '-'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Token(入/出)',
|
||||||
|
key: 'tokens',
|
||||||
|
width: 110,
|
||||||
|
render: (_, r) => fmtTok(r.input_tokens, r.output_tokens),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '成本',
|
||||||
|
key: 'cost',
|
||||||
|
width: 90,
|
||||||
|
render: (_, r) => fmtCost(calcCost(r.input_tokens, r.output_tokens, pricePerMTok)),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '机型/ROM',
|
title: '机型/ROM',
|
||||||
key: 'device',
|
key: 'device',
|
||||||
@@ -172,6 +206,15 @@ export default function ComparisonRecordsPage() {
|
|||||||
/>
|
/>
|
||||||
<Button type="primary" onClick={search}>查询</Button>
|
<Button type="primary" onClick={search}>查询</Button>
|
||||||
<Button onClick={reset}>重置</Button>
|
<Button onClick={reset}>重置</Button>
|
||||||
|
<InputNumber
|
||||||
|
placeholder="LLM 单价"
|
||||||
|
value={pricePerMTok}
|
||||||
|
onChange={onPriceChange}
|
||||||
|
min={0}
|
||||||
|
step={0.1}
|
||||||
|
style={{ width: 210 }}
|
||||||
|
addonAfter="元/百万token"
|
||||||
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
<Table
|
<Table
|
||||||
@@ -187,7 +230,7 @@ export default function ComparisonRecordsPage() {
|
|||||||
showTotal: (t) => `共 ${t} 条`,
|
showTotal: (t) => `共 ${t} 条`,
|
||||||
onChange,
|
onChange,
|
||||||
}}
|
}}
|
||||||
scroll={{ x: 1320 }}
|
scroll={{ x: 1520 }}
|
||||||
onRow={(r) => ({ onClick: () => openDetail(r.id), style: { cursor: 'pointer' } })}
|
onRow={(r) => ({ onClick: () => openDetail(r.id), style: { cursor: 'pointer' } })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -204,6 +247,17 @@ export default function ComparisonRecordsPage() {
|
|||||||
<Descriptions.Item label="耗时">{fmtMs(detail.total_ms)}</Descriptions.Item>
|
<Descriptions.Item label="耗时">{fmtMs(detail.total_ms)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="步数">{detail.step_count ?? '-'}</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="LLM 次数/重试">{detail.llm_call_count ?? '-'} / {detail.retry_count ?? '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Token(入/出/合计)">
|
||||||
|
{detail.input_tokens == null && detail.output_tokens == null
|
||||||
|
? '-'
|
||||||
|
: `${detail.input_tokens ?? 0} / ${detail.output_tokens ?? 0} / ${(detail.input_tokens ?? 0) + (detail.output_tokens ?? 0)}`}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="估算成本">
|
||||||
|
{fmtCost(calcCost(detail.input_tokens, detail.output_tokens, pricePerMTok))}
|
||||||
|
{pricePerMTok == null && (detail.input_tokens != null || detail.output_tokens != null)
|
||||||
|
? <span style={{ color: '#999', fontSize: 12 }}>(顶部填 LLM 单价后显示)</span>
|
||||||
|
: null}
|
||||||
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="源平台">{detail.source_platform_name || '-'}({cents(detail.source_price_cents)})</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="最优">{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}>{cents(detail.saved_amount_cents)}{detail.is_source_best ? '(源平台最便宜)' : ''}</Descriptions.Item>
|
||||||
|
|||||||
@@ -261,6 +261,8 @@ export interface ComparisonRecordListItem {
|
|||||||
step_count: number | null;
|
step_count: number | null;
|
||||||
llm_call_count: number | null;
|
llm_call_count: number | null;
|
||||||
retry_count: number | null;
|
retry_count: number | null;
|
||||||
|
input_tokens: number | null;
|
||||||
|
output_tokens: number | null;
|
||||||
device_model: string | null;
|
device_model: string | null;
|
||||||
rom_vendor: string | null;
|
rom_vendor: string | null;
|
||||||
rom_name: string | null;
|
rom_name: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user