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

234 lines
8.1 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 { useState, useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { useApp } from "@/contexts/AppContext";
import { fetchProductHistory, HistoryPoint } from "@/lib/api/fundmarket";
import * as echarts from "echarts";
interface APYHistoryCardProps {
productId: number;
}
export default function APYHistoryCard({ productId }: APYHistoryCardProps) {
const { t } = useApp();
const [activeTab, setActiveTab] = useState<"apy" | "price">("apy");
const chartRef = useRef<HTMLDivElement>(null);
const chartInstance = useRef<echarts.ECharts | null>(null);
const { data: history = [] } = useQuery<HistoryPoint[]>({
queryKey: ["product-history", productId],
queryFn: () => fetchProductHistory(productId),
staleTime: 60 * 60 * 1000, // 1h
});
const colors = [
"#FBEADE", "#F5D4BE", "#EFBF9E", "#E9AA7E", "#E3955E",
"#DD804E", "#D76B3E", "#D1562E", "#C65122",
];
const isEmpty = history.length < 2;
const labels = isEmpty ? [] : history.map((p) => {
const d = new Date(p.time);
return `${d.getMonth() + 1}/${d.getDate()}`;
});
const apyData = isEmpty ? [] : history.map((p) => p.apy);
const priceData = isEmpty ? [] : history.map((p) => p.price);
const activeData = activeTab === "apy" ? apyData : priceData;
const highest = activeData.length > 0 ? Math.max(...activeData) : 0;
const lowest = activeData.length > 0 ? Math.min(...activeData) : 0;
const updateChart = () => {
if (!chartInstance.current) return;
// price tab 自适应 Y 轴,上下各留 20% paddingAPY bar 图保持从 0 开始
let yAxisMin: number | undefined;
let yAxisMax: number | undefined;
if (activeTab === "price" && priceData.length > 0) {
const yMin = Math.min(...priceData);
const yMax = Math.max(...priceData);
const range = yMax - yMin || yMax * 0.01 || 0.01;
yAxisMin = yMin - range * 0.2;
yAxisMax = yMax + range * 0.2;
}
const option: echarts.EChartsOption = {
grid: { left: 2, right: 2, top: 10, bottom: 0 },
tooltip: {
trigger: "axis",
show: true,
confine: true,
backgroundColor: "rgba(17, 24, 39, 0.9)",
borderColor: "#374151",
textStyle: { color: "#f9fafb", fontSize: 12, fontWeight: 500 },
formatter: (params: any) => {
const d = params[0];
const suffix = activeTab === "apy" ? "%" : " USDC";
return `<div style="padding:4px 8px">
<span style="color:#9ca3af;font-size:11px">${labels[d.dataIndex]}</span><br/>
<span style="color:#10b981;font-weight:600;font-size:14px">${Number(d.value).toFixed(activeTab === "apy" ? 2 : 4)}${suffix}</span>
</div>`;
},
},
xAxis: {
type: "category",
data: labels,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: {
show: labels.length > 0,
color: "#9ca3af",
fontSize: 10,
fontWeight: 500,
interval: Math.max(0, Math.floor(labels.length / 7) - 1),
},
},
yAxis: {
type: "value",
min: yAxisMin,
max: yAxisMax,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { show: false },
splitLine: { show: false },
},
series: [
activeTab === "apy"
? {
data: apyData.map((value, index) => ({
value,
itemStyle: {
color: colors[index % colors.length],
borderRadius: [2, 2, 0, 0],
},
})),
type: "bar",
barWidth: "60%",
barMaxWidth: 24,
barMinHeight: 2,
}
: {
data: priceData,
type: "line",
smooth: true,
symbol: "circle",
symbolSize: 6,
lineStyle: { color: "#10b981", width: 2 },
itemStyle: { color: "#10b981" },
areaStyle: {
color: {
type: "linear", x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: "rgba(16,185,129,0.3)" },
{ offset: 1, color: "rgba(16,185,129,0)" },
],
},
},
},
],
};
chartInstance.current.setOption(option, true);
};
useEffect(() => {
// Use requestAnimationFrame to ensure the container has been laid out
// before ECharts tries to measure its dimensions
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);
};
}, [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 p-6 flex flex-col gap-6">
{/* Tabs */}
<div className="flex gap-6 border-b border-border-gray dark:border-gray-700">
<button
onClick={() => setActiveTab("apy")}
className={`pb-3 px-1 text-body-small font-bold transition-colors ${
activeTab === "apy"
? "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("apy.apyHistory")}
</button>
<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("apy.priceHistory")}
</button>
</div>
{/* Chart Area */}
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<span className="text-caption-tiny font-medium text-text-tertiary dark:text-gray-400">
{isEmpty ? t("apy.lastDays") : `${t("apy.lastDays")} (${history.length} snapshots)`}
</span>
</div>
{/* ECharts Chart */}
{isEmpty ? (
<div className="w-full h-[140px] flex items-center justify-center text-caption-tiny text-text-tertiary dark:text-gray-500">
{t("common.noData")}
</div>
) : (
<div
ref={chartRef}
className="w-full"
style={{
height: "140px",
background: activeTab === "price"
? "linear-gradient(0deg, rgba(16, 185, 129, 0.1) 0%, transparent 100%)"
: undefined,
}}
/>
)}
{/* Stats */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-caption-tiny font-medium text-text-tertiary dark:text-gray-400">
{t("apy.highest")}
</span>
<span className="text-body-small font-bold text-text-primary dark:text-white">
{isEmpty ? '--' : activeTab === "apy" ? `${highest.toFixed(2)}%` : `$${highest.toFixed(4)}`}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-caption-tiny font-medium text-text-tertiary dark:text-gray-400">
{t("apy.lowest")}
</span>
<span className="text-body-small font-bold text-text-primary dark:text-white">
{isEmpty ? '--' : activeTab === "apy" ? `${lowest.toFixed(2)}%` : `$${lowest.toFixed(4)}`}
</span>
</div>
</div>
</div>
</div>
);
}