Files
assetx/webapp/components/alp/PriceHistoryCard.tsx
default 2ee4553b71 init: 初始化 AssetX 项目仓库
包含 webapp(Next.js 用户端)、webapp-back(Go 后端)、
antdesign(管理后台)、landingpage(营销落地页)、
数据库 SQL 和配置文件。
2026-03-27 11:26:43 +00:00

231 lines
8.4 KiB
TypeScript

"use client";
import { useState, useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { useApp } from "@/contexts/AppContext";
import * as echarts from "echarts";
interface HistoryPoint {
time: string;
ts: number;
poolAPR: number;
alpPrice: number;
feeSurplus: number;
poolValue: number;
}
export default function PriceHistoryCard() {
const { t } = useApp();
const [activeTab, setActiveTab] = useState<"price" | "apr">("price");
const chartRef = useRef<HTMLDivElement>(null);
const chartInstance = useRef<echarts.ECharts | null>(null);
const { data: history = [], isLoading } = useQuery<HistoryPoint[]>({
queryKey: ["alp-history"],
queryFn: async () => {
const res = await fetch("/api/alp/history?days=30");
const json = await res.json();
return json.data ?? [];
},
refetchInterval: 5 * 60 * 1000,
});
const isEmpty = history.length < 2;
const labels = isEmpty ? [] : history.map(p => p.time);
const priceData = isEmpty ? [] : history.map(p => p.alpPrice);
const aprData = isEmpty ? [] : history.map(p => p.poolAPR);
const activeData = activeTab === "price" ? priceData : aprData;
const activeColor = activeTab === "price" ? "#10b981" : "#1447e6";
const areaColor0 = activeTab === "price" ? "rgba(16,185,129,0.3)" : "rgba(20,71,230,0.25)";
const areaColor1 = activeTab === "price" ? "rgba(16,185,129,0)" : "rgba(20,71,230,0)";
const suffix = activeTab === "price" ? " USDC" : "%";
const precision = activeTab === "price" ? 4 : 4;
const highest = activeData.length > 0 ? Math.max(...activeData) : 0;
const lowest = activeData.length > 0 ? Math.min(...activeData) : 0;
const current = activeData.length > 0 ? activeData[activeData.length - 1] : 0;
// Avg APR from feeSurplus / poolValue delta
const actualDays = history.length >= 2
? (history[history.length - 1].ts - history[0].ts) / 86400
: 0;
const avgAPR = (() => {
if (history.length < 2 || actualDays <= 0) return 0;
const first = history[0];
const last = history[history.length - 1];
const surplus = (last.feeSurplus ?? 0) - (first.feeSurplus ?? 0);
const pv = last.poolValue ?? 0;
if (pv <= 0 || surplus <= 0) return 0;
return surplus / pv / actualDays * 365 * 100;
})();
const periodLabel = actualDays >= 1
? `Last ${Math.round(actualDays)} days`
: actualDays > 0
? `Last ${Math.round(actualDays * 24)}h`
: "Last 30 days";
const fmt = (v: number) =>
activeTab === "price" ? `$${v.toFixed(precision)}` : `${v.toFixed(precision)}%`;
const updateChart = () => {
if (!chartInstance.current) return;
// 自适应 Y 轴:以数据范围为基础,上下各留 20% 的 padding
const yMin = activeData.length > 0 ? Math.min(...activeData) : 0;
const yMax = activeData.length > 0 ? Math.max(...activeData) : 1;
const range = yMax - yMin || yMax * 0.01 || 0.01;
const yAxisMin = yMin - range * 0.2;
const yAxisMax = yMax + range * 0.2;
chartInstance.current.setOption({
grid: { left: 0, right: 0, top: 10, bottom: 24 },
tooltip: {
trigger: "axis",
confine: true,
backgroundColor: "rgba(17,24,39,0.9)",
borderColor: "#374151",
textStyle: { color: "#f9fafb", fontSize: 12 },
formatter: (params: any) => {
const d = params[0];
const val = Number(d.value).toFixed(precision);
const display = activeTab === "price" ? `$${val}` : `${val}%`;
return `<div style="padding:4px 8px"><span style="color:#9ca3af;font-size:11px">${d.name}</span><br/><span style="color:${activeColor};font-weight:600;font-size:14px">${display}</span></div>`;
},
},
xAxis: {
type: "category",
data: labels,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: {
show: true,
color: "#9ca3af",
fontSize: 11,
interval: Math.max(0, Math.floor(labels.length / 6) - 1),
},
boundaryGap: false,
},
yAxis: {
type: "value",
min: yAxisMin,
max: yAxisMax,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { show: false },
splitLine: { show: false },
},
series: [{
data: activeData,
type: "line",
smooth: true,
symbol: "circle",
symbolSize: 5,
lineStyle: { color: activeColor, width: 2 },
itemStyle: { color: activeColor },
areaStyle: {
color: {
type: "linear", x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: areaColor0 },
{ offset: 1, color: areaColor1 },
],
},
},
}],
}, true);
};
useEffect(() => {
const frame = requestAnimationFrame(() => {
if (!chartRef.current) return;
if (!chartInstance.current) {
chartInstance.current = echarts.init(chartRef.current);
}
updateChart();
chartInstance.current?.resize();
});
const handleResize = () => chartInstance.current?.resize();
window.addEventListener("resize", handleResize);
return () => { cancelAnimationFrame(frame); window.removeEventListener("resize", handleResize); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab, history]);
useEffect(() => {
return () => { chartInstance.current?.dispose(); };
}, []);
return (
<div className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 px-6 py-6 flex flex-col gap-0 h-full">
{/* Tabs */}
<div className="flex gap-6 border-b border-border-gray dark:border-gray-700 mb-6">
<button
onClick={() => setActiveTab("price")}
className={`pb-3 px-1 text-body-small font-bold transition-colors ${
activeTab === "price"
? "text-text-primary dark:text-white border-b-2 border-text-primary dark:border-white -mb-[1px]"
: "text-text-tertiary dark:text-gray-400"
}`}
>
{t("alp.priceHistory")}
</button>
<button
onClick={() => setActiveTab("apr")}
className={`pb-3 px-1 text-body-small font-bold transition-colors ${
activeTab === "apr"
? "text-text-primary dark:text-white border-b-2 border-text-primary dark:border-white -mb-[1px]"
: "text-text-tertiary dark:text-gray-400"
}`}
>
{t("alp.aprHistory")}
</button>
</div>
<div className="flex flex-col gap-6">
{/* Period label + avg for APR */}
<div className="flex items-center justify-between">
<div className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400">
{periodLabel}
</div>
{activeTab === "apr" && (
<div className="text-caption-tiny font-bold text-[#1447e6]">
Avg APR: {avgAPR.toFixed(4)}%
</div>
)}
</div>
{/* Chart */}
<div className="relative w-full border-b border-border-gray dark:border-gray-600" style={{ height: "260px" }}>
{isLoading ? (
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-sm text-text-tertiary dark:text-gray-400">{t("common.loading")}</span>
</div>
) : isEmpty ? (
<div className="w-full h-full flex items-center justify-center text-sm text-text-tertiary dark:text-gray-500">
{t("common.noData")}
</div>
) : (
<div ref={chartRef} className="w-full h-full" />
)}
</div>
{/* Stats */}
<div className="flex flex-col gap-3">
{[
{ label: t("alp.highest"), value: isEmpty ? "--" : fmt(highest) },
{ label: t("alp.lowest"), value: isEmpty ? "--" : fmt(lowest) },
{ label: t("alp.current"), value: isEmpty ? "--" : fmt(current) },
].map(({ label, value }) => (
<div key={label} className="flex items-start justify-between">
<div className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400">{label}</div>
<div className="text-caption-tiny font-bold text-text-primary dark:text-white tabular-nums">{value}</div>
</div>
))}
</div>
</div>
</div>
);
}