- Add libs/api/admin with admin API endpoints: sync models, workspace credit, billing alert check - Add workspace_alert_config model and alert service - Add Session::no_op() for background tasks without user context - Add admin/ Next.js admin panel (AI models, billing, workspaces, audit) - Start billing alert background task every 30 minutes
170 lines
4.2 KiB
TypeScript
170 lines
4.2 KiB
TypeScript
"use client";
|
|
|
|
import { format, parseISO } from "date-fns";
|
|
|
|
interface DataPoint {
|
|
date: string;
|
|
value: number;
|
|
}
|
|
|
|
interface LineChartProps {
|
|
data: DataPoint[];
|
|
width?: number;
|
|
height?: number;
|
|
color?: string;
|
|
label: string;
|
|
unit?: string;
|
|
}
|
|
|
|
export default function LineChart({
|
|
data,
|
|
width = 600,
|
|
height = 200,
|
|
color = "#6366f1",
|
|
label,
|
|
unit = "",
|
|
}: LineChartProps) {
|
|
if (data.length === 0) {
|
|
return (
|
|
<div style={{ color: "#737373", fontSize: "13px", textAlign: "center", padding: "32px" }}>
|
|
暂无数据
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const padding = { top: 16, right: 16, bottom: 36, left: 44 };
|
|
const chartWidth = width - padding.left - padding.right;
|
|
const chartHeight = height - padding.top - padding.bottom;
|
|
|
|
const values = data.map((d) => d.value);
|
|
const maxValue = Math.max(...values, 1);
|
|
const minValue = 0;
|
|
|
|
function xScale(i: number) {
|
|
return padding.left + (i / (data.length - 1 || 1)) * chartWidth;
|
|
}
|
|
|
|
function yScale(v: number) {
|
|
return padding.top + chartHeight - ((v - minValue) / (maxValue - minValue || 1)) * chartHeight;
|
|
}
|
|
|
|
// Build SVG path
|
|
const pathD = data
|
|
.map((d, i) => `${i === 0 ? "M" : "L"} ${xScale(i).toFixed(1)} ${yScale(d.value).toFixed(1)}`)
|
|
.join(" ");
|
|
|
|
// Area path (fill under the line)
|
|
const areaD =
|
|
pathD +
|
|
` L ${xScale(data.length - 1).toFixed(1)} ${(padding.top + chartHeight).toFixed(1)}` +
|
|
` L ${padding.left} ${(padding.top + chartHeight).toFixed(1)} Z`;
|
|
|
|
// Y-axis ticks (5 ticks)
|
|
const yTicks = Array.from({ length: 5 }, (_, i) => {
|
|
const v = minValue + ((maxValue - minValue) * i) / 4;
|
|
return { v, y: yScale(v) };
|
|
});
|
|
|
|
// X-axis ticks (show every nth label based on data length)
|
|
const step = Math.max(1, Math.floor(data.length / 6));
|
|
const xTicks = data.filter((_, i) => i % step === 0 || i === data.length - 1);
|
|
|
|
const gradientId = `grad-${label.replace(/\s/g, "-")}`;
|
|
|
|
return (
|
|
<svg
|
|
width={width}
|
|
height={height}
|
|
style={{ display: "block", maxWidth: "100%" }}
|
|
aria-label={`${label} 趋势图`}
|
|
>
|
|
<defs>
|
|
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stopColor={color} stopOpacity={0.2} />
|
|
<stop offset="100%" stopColor={color} stopOpacity={0} />
|
|
</linearGradient>
|
|
</defs>
|
|
|
|
{/* Y-axis gridlines */}
|
|
{yTicks.map((t, i) => (
|
|
<line
|
|
key={i}
|
|
x1={padding.left}
|
|
y1={t.y}
|
|
x2={width - padding.right}
|
|
y2={t.y}
|
|
stroke="#e5e5e5"
|
|
strokeWidth={1}
|
|
/>
|
|
))}
|
|
|
|
{/* Y-axis labels */}
|
|
{yTicks.map((t, i) => (
|
|
<text
|
|
key={i}
|
|
x={padding.left - 6}
|
|
y={t.y + 4}
|
|
textAnchor="end"
|
|
fontSize={11}
|
|
fill="#737373"
|
|
>
|
|
{Math.round(t.v)}
|
|
</text>
|
|
))}
|
|
|
|
{/* X-axis labels */}
|
|
{xTicks.map((d, i) => {
|
|
const idx = data.findIndex((x) => x.date === d.date);
|
|
return (
|
|
<text
|
|
key={i}
|
|
x={xScale(idx)}
|
|
y={padding.top + chartHeight + 16}
|
|
textAnchor="middle"
|
|
fontSize={11}
|
|
fill="#737373"
|
|
>
|
|
{(() => {
|
|
try {
|
|
return format(parseISO(d.date), "MM-dd");
|
|
} catch {
|
|
return d.date.slice(5);
|
|
}
|
|
})()}
|
|
</text>
|
|
);
|
|
})}
|
|
|
|
{/* Area fill */}
|
|
<path d={areaD} fill={`url(#${gradientId})`} />
|
|
|
|
{/* Line */}
|
|
<path d={pathD} fill="none" stroke={color} strokeWidth={2} strokeLinejoin="round" />
|
|
|
|
{/* Data points (dots) */}
|
|
{data.map((d, i) => (
|
|
<circle
|
|
key={i}
|
|
cx={xScale(i)}
|
|
cy={yScale(d.value)}
|
|
r={3}
|
|
fill={color}
|
|
stroke="white"
|
|
strokeWidth={1.5}
|
|
style={{ cursor: "default" }}
|
|
>
|
|
<title>
|
|
{(() => {
|
|
try {
|
|
return `${format(parseISO(d.date), "yyyy-MM-dd")}: ${d.value}${unit}`;
|
|
} catch {
|
|
return `${d.date}: ${d.value}${unit}`;
|
|
}
|
|
})()}
|
|
</title>
|
|
</circle>
|
|
))}
|
|
</svg>
|
|
);
|
|
}
|