diff --git a/src/app/(main)/comparison-records/page.tsx b/src/app/(main)/comparison-records/page.tsx index 113b9ac..b1dbbd5 100644 --- a/src/app/(main)/comparison-records/page.tsx +++ b/src/app/(main)/comparison-records/page.tsx @@ -28,6 +28,16 @@ const STUCK_LABEL: Record = { const fmtMs = (ms: number | null) => (ms == null ? '-' : `${(ms / 1000).toFixed(1)}s`); 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 就缩进展开,否则原样。 function pretty(s: string | null): string { if (!s) return ''; @@ -50,6 +60,18 @@ export default function ComparisonRecordsPage() { const [phone, setPhone] = useState(''); const [status, setStatus] = useState(); const [applied, setApplied] = useState>({}); + // LLM 单价(元/百万 token),本地持久化;仅用于前端估算成本,不入库、不影响其它页面 + const [pricePerMTok, setPricePerMTok] = useState(() => { + 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 } = usePagedList('/admin/api/comparison-records', applied); @@ -120,6 +142,18 @@ export default function ComparisonRecordsPage() { width: 50, render: (v: number | null) => (v ? {v} : 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', key: 'device', @@ -172,6 +206,15 @@ export default function ComparisonRecordsPage() { /> + `共 ${t} 条`, onChange, }} - scroll={{ x: 1320 }} + scroll={{ x: 1520 }} onRow={(r) => ({ onClick: () => openDetail(r.id), style: { cursor: 'pointer' } })} /> @@ -204,6 +247,17 @@ export default function ComparisonRecordsPage() { {fmtMs(detail.total_ms)}{detail.step_count ?? '-'}{detail.llm_call_count ?? '-'} / {detail.retry_count ?? '-'} + + {detail.input_tokens == null && detail.output_tokens == null + ? '-' + : `${detail.input_tokens ?? 0} / ${detail.output_tokens ?? 0} / ${(detail.input_tokens ?? 0) + (detail.output_tokens ?? 0)}`} + + + {fmtCost(calcCost(detail.input_tokens, detail.output_tokens, pricePerMTok))} + {pricePerMTok == null && (detail.input_tokens != null || detail.output_tokens != null) + ? (顶部填 LLM 单价后显示) + : null} + {detail.source_platform_name || '-'}({cents(detail.source_price_cents)}){detail.best_platform_name || '-'}({cents(detail.best_price_cents)}){cents(detail.saved_amount_cents)}{detail.is_source_best ? '(源平台最便宜)' : ''} diff --git a/src/lib/types.ts b/src/lib/types.ts index 53ccb0c..a0f6cee 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -261,6 +261,8 @@ export interface ComparisonRecordListItem { step_count: number | null; llm_call_count: number | null; retry_count: number | null; + input_tokens: number | null; + output_tokens: number | null; device_model: string | null; rom_vendor: string | null; rom_name: string | null;