feat: new file
This commit is contained in:
20
frontend/Dockerfile
Normal file
20
frontend/Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
||||
FROM node:20-slim AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json /app/package.json
|
||||
RUN npm install
|
||||
|
||||
FROM node:20-slim AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules /app/node_modules
|
||||
COPY . /app
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-slim AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
COPY --from=builder /app/.next/standalone /app
|
||||
COPY --from=builder /app/.next/static /app/.next/static
|
||||
COPY --from=builder /app/public /app/public
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
62
frontend/app/api/[...path]/route.ts
Normal file
62
frontend/app/api/[...path]/route.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || "http://localhost:8000";
|
||||
|
||||
export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||
const { path } = await ctx.params;
|
||||
const url = new URL(req.url);
|
||||
const upstream = `${BACKEND_URL}/api/${path.join("/")}${url.search}`;
|
||||
return await safeUpstreamFetch(() => fetch(upstream, { headers: forwardHeaders(req) }));
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||
const { path } = await ctx.params;
|
||||
const url = new URL(req.url);
|
||||
const upstream = `${BACKEND_URL}/api/${path.join("/")}${url.search}`;
|
||||
const body = await req.text();
|
||||
return await safeUpstreamFetch(() =>
|
||||
fetch(upstream, {
|
||||
method: "POST",
|
||||
headers: { ...forwardHeaders(req), "content-type": req.headers.get("content-type") || "application/json" },
|
||||
body
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function forwardHeaders(req: NextRequest) {
|
||||
const h = new Headers();
|
||||
const auth = req.headers.get("authorization");
|
||||
if (auth) h.set("authorization", auth);
|
||||
return h;
|
||||
}
|
||||
|
||||
async function forwardResponse(r: Response) {
|
||||
const headers = new Headers(r.headers);
|
||||
headers.delete("access-control-allow-origin");
|
||||
return new Response(await r.arrayBuffer(), { status: r.status, headers });
|
||||
}
|
||||
|
||||
async function safeUpstreamFetch(doFetch: () => Promise<Response>) {
|
||||
const maxAttempts = 3;
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
try {
|
||||
const r = await doFetch();
|
||||
return await forwardResponse(r);
|
||||
} catch (e: any) {
|
||||
const code = e?.cause?.code || e?.code;
|
||||
const retryable = code === "ECONNREFUSED" || code === "ENOTFOUND" || code === "EAI_AGAIN";
|
||||
if (!retryable || i === maxAttempts - 1) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
detail: "上游后端不可达(backend 容器可能正在重启或未就绪)。",
|
||||
error: String(code || e?.message || e)
|
||||
}),
|
||||
{ status: 503, headers: { "content-type": "application/json; charset=utf-8" } }
|
||||
);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 200 * (i + 1)));
|
||||
}
|
||||
}
|
||||
return new Response(JSON.stringify({ detail: "unknown" }), { status: 503 });
|
||||
}
|
||||
|
||||
15
frontend/app/layout.tsx
Normal file
15
frontend/app/layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
export const metadata = {
|
||||
title: "Crawl BI Dashboard",
|
||||
description: "Sales BI + Trend Engine + AI insight"
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body style={{ margin: 0, fontFamily: "ui-sans-serif, system-ui, -apple-system" }}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
6
frontend/app/page.tsx
Normal file
6
frontend/app/page.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import Dashboard from "../components/Dashboard";
|
||||
|
||||
export default function Page() {
|
||||
return <Dashboard />;
|
||||
}
|
||||
|
||||
228
frontend/components/Dashboard.tsx
Normal file
228
frontend/components/Dashboard.tsx
Normal 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);
|
||||
}
|
||||
|
||||
3
frontend/next-env.d.ts
vendored
Normal file
3
frontend/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
8
frontend/next.config.js
Normal file
8
frontend/next.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
output: "standalone"
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
|
||||
24
frontend/package.json
Normal file
24
frontend/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "crawl-demo-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3000",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"echarts": "^5.6.0",
|
||||
"echarts-for-react": "^3.0.2",
|
||||
"next": "^15.2.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.13.10",
|
||||
"@types/react": "^19.0.12",
|
||||
"typescript": "^5.8.2"
|
||||
}
|
||||
}
|
||||
|
||||
1
frontend/public/.gitkeep
Normal file
1
frontend/public/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
21
frontend/tsconfig.json
Normal file
21
frontend/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user