Files
Crawl_demo/frontend/components/Dashboard.tsx
2026-03-18 18:57:58 +08:00

229 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import React from "react";
import ReactECharts from "echarts-for-react";
type OverviewResponse = {
schema: any;
metrics: Record<string, any>;
};
type Winner = {
product_id: string;
title?: string | null;
category?: string | null;
units?: number;
gmv?: number;
potential_score?: number;
burst_score?: number;
follow_score?: number;
lifecycle?: string;
};
async function getJSON<T>(path: string): Promise<T> {
const r = await fetch(path, { cache: "no-store" });
if (!r.ok) throw new Error(await r.text());
return (await r.json()) as T;
}
async function postJSON<T>(path: string, body: any): Promise<T> {
const r = await fetch(path, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body)
});
if (!r.ok) throw new Error(await r.text());
return (await r.json()) as T;
}
export default function Dashboard() {
const [overview, setOverview] = React.useState<OverviewResponse | null>(null);
const [winners, setWinners] = React.useState<Winner[]>([]);
const [productId, setProductId] = React.useState("");
const [series, setSeries] = React.useState<{ ds: string; units: number; gmv: number }[]>([]);
const [forecast, setForecast] = React.useState<{ ds: string; units_hat: number }[]>([]);
const [aiQuery, setAiQuery] = React.useState("给我当前最值得跟卖的3个品类/商品,并说明理由与风险。");
const [aiAnswer, setAiAnswer] = React.useState<string>("");
const [err, setErr] = React.useState<string>("");
React.useEffect(() => {
(async () => {
try {
const o = await getJSON<OverviewResponse>("/api/metrics/overview");
setOverview(o);
const w = await getJSON<{ items: Winner[] }>("/api/trend/potential-winners?days=14&limit=30");
setWinners(w.items);
} catch (e: any) {
setErr(String(e?.message || e));
}
})();
}, []);
async function loadProduct(p: string) {
setErr("");
if (!p.trim()) {
setErr("请输入 product_id不能为空");
return;
}
try {
const ts = await getJSON<{ product_id: string; points: any[] }>(
`/api/metrics/sales/timeseries?product_id=${encodeURIComponent(p)}&days=60`
);
setSeries(ts.points as any);
const fc = await getJSON<{ forecast: any[] }>(
`/api/trend/forecast?product_id=${encodeURIComponent(p)}&days=60&horizon=14`
);
setForecast(fc.forecast as any);
} catch (e: any) {
setErr(String(e?.message || e));
}
}
async function runAI() {
setErr("");
try {
const r = await postJSON<{ answer: string }>("/api/ai/insight", {
query: aiQuery,
product_id: productId || undefined,
top_k: 6
});
setAiAnswer(r.answer || "");
} catch (e: any) {
setErr(String(e?.message || e));
}
}
const chartOption = {
tooltip: { trigger: "axis" },
legend: { data: ["units", "gmv", "forecast_units"] },
xAxis: { type: "category", data: series.map((p) => p.ds.slice(0, 10)) },
yAxis: [{ type: "value" }, { type: "value" }],
series: [
{ name: "units", type: "line", smooth: true, data: series.map((p) => p.units) },
{ name: "gmv", type: "line", smooth: true, yAxisIndex: 1, data: series.map((p) => p.gmv) },
{
name: "forecast_units",
type: "line",
smooth: true,
lineStyle: { type: "dashed" },
data: new Array(Math.max(0, series.length - 1)).fill(null).concat(forecast.map((f) => f.units_hat))
}
]
};
return (
<div style={{ padding: 18, maxWidth: 1200, margin: "0 auto" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline" }}>
<h2 style={{ margin: 0 }}> BI </h2>
<div />
</div>
{err ? (
<pre style={{ background: "#fff2f0", border: "1px solid #ffccc7", padding: 12, whiteSpace: "pre-wrap" }}>
{err}
</pre>
) : null}
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 12, marginTop: 16 }}>
<Card title="产品数">{overview?.metrics?.products ?? "-"}</Card>
<Card title="近30天销量">{overview?.metrics?.units_30d ?? "-"}</Card>
<Card title="近30天GMV">{overview?.metrics?.gmv_30d ?? "-"}</Card>
<Card title="近30天记录数">{overview?.metrics?.rows_30d ?? "-"}</Card>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1.3fr 1fr", gap: 12, marginTop: 16 }}>
<div style={{ border: "1px solid #eee", borderRadius: 10, padding: 12 }}>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<strong>线 + </strong>
<input
value={productId}
onChange={(e) => setProductId(e.target.value)}
placeholder="输入 product_id"
style={{ flex: 1, padding: "8px 10px", borderRadius: 8, border: "1px solid #ddd" }}
/>
<button onClick={() => loadProduct(productId)} style={btn}>
</button>
</div>
<div style={{ marginTop: 12 }}>
<ReactECharts option={chartOption} style={{ height: 320 }} />
</div>
</div>
<div style={{ border: "1px solid #eee", borderRadius: 10, padding: 12 }}>
<strong>Potential Winners</strong>
<div style={{ marginTop: 10, maxHeight: 360, overflow: "auto" }}>
{winners.map((w) => (
<div
key={w.product_id}
style={{
padding: "10px 10px",
border: "1px solid #f0f0f0",
borderRadius: 10,
marginBottom: 10,
cursor: "pointer"
}}
onClick={() => {
setProductId(w.product_id);
loadProduct(w.product_id);
}}
>
<div style={{ display: "flex", justifyContent: "space-between", gap: 10 }}>
<div style={{ fontWeight: 600 }}>{w.title || w.product_id}</div>
<div style={{ fontSize: 12, opacity: 0.8 }}>{w.lifecycle}</div>
</div>
<div style={{ fontSize: 12, opacity: 0.85, marginTop: 6 }}>
units={fmt(w.units)} gmv={fmt(w.gmv)} · potential={fmt(w.potential_score)} · burst={fmt(w.burst_score)} ·
follow={fmt(w.follow_score)}
</div>
</div>
))}
</div>
</div>
</div>
<div style={{ border: "1px solid #eee", borderRadius: 10, padding: 12, marginTop: 16 }}>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<strong>AI </strong>
<button onClick={runAI} style={btn}>
</button>
</div>
<textarea
value={aiQuery}
onChange={(e) => setAiQuery(e.target.value)}
rows={3}
style={{ width: "100%", marginTop: 10, padding: 10, borderRadius: 10, border: "1px solid #ddd" }}
/>
<pre style={{ marginTop: 10, background: "#0b1220", color: "#e6edf3", padding: 12, borderRadius: 10, whiteSpace: "pre-wrap" }}>
{aiAnswer || "(未生成)"}
</pre>
</div>
</div>
);
}
function Card({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div style={{ border: "1px solid #eee", borderRadius: 10, padding: 12 }}>
<div style={{ fontSize: 12, opacity: 0.75 }}>{title}</div>
<div style={{ fontSize: 20, fontWeight: 700, marginTop: 6 }}>{children}</div>
</div>
);
}
const btn: React.CSSProperties = {
padding: "8px 12px",
borderRadius: 10,
border: "1px solid #ddd",
background: "#fff",
cursor: "pointer"
};
function fmt(v: any) {
if (v === null || v === undefined) return "-";
if (typeof v === "number") return Number.isFinite(v) ? v.toFixed(3).replace(/\.?0+$/, "") : "-";
return String(v);
}