feat: new file

This commit is contained in:
Daniel
2026-03-18 18:57:58 +08:00
commit d0ff049899
31 changed files with 1507 additions and 0 deletions

View File

@@ -0,0 +1,228 @@
"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);
}