This commit is contained in:
Sakurasan
2026-07-05 21:29:17 +08:00
commit 013ebdaffe
150 changed files with 15614 additions and 0 deletions
+940
View File
@@ -0,0 +1,940 @@
import { createFileRoute } from "@tanstack/react-router";
import { useState, useEffect, useMemo } from "react";
import { collectionsApi } from "@/lib/api-client";
import { fetchStockQuote, fetchStockHistory, fetchStockFundFlow, getStockBoard, type StockQuote, type KLineData, type FundFlowData, type FundFlowSummary } from "@/lib/stock-api";
import { getUserId } from "@/lib/user-id";
import { formatMoney } from "@/lib/utils";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ArrowLeft, TrendingUp, TrendingDown, Calendar, ExternalLink, Newspaper } from "lucide-react";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Link } from "@tanstack/react-router";
import {
ComposedChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Bar,
Cell,
Scatter,
Brush,
ReferenceLine,
} from "recharts";
export const Route = createFileRoute("/stock/$code")({
component: StockDetail,
validateSearch: (search: Record<string, unknown>) => {
return {
from: typeof search.from === "string" ? search.from : undefined,
};
},
});
interface StockData {
date: string;
dateObj: Date;
open: number;
close: number;
high: number;
low: number;
volume: number;
isAddedDate: boolean;
}
interface StockInfo {
code: string;
name: string;
currentPrice: number;
changePercent: number;
change: number;
high: number;
low: number;
todayOpen: number;
yesterdayClose: number;
addedAt: string;
addedPrice: number | null;
outerDisk: number; // 外盘(手)
innerDisk: number; // 内盘(手)
amount: number; // 成交额(元)
quoteDate: string; // 行情日期 YYYY-MM-DD
}
function StockDetail() {
const { code } = Route.useParams();
const { from } = Route.useSearch();
const userId = getUserId();
const [stockInfo, setStockInfo] = useState<StockInfo | null>(null);
const [chartData, setChartData] = useState<StockData[]>([]);
const [chartLoading, setChartLoading] = useState(false);
const [fundFlowData, setFundFlowData] = useState<FundFlowData[]>([]);
const [fundFlowSummary, setFundFlowSummary] = useState<FundFlowSummary | null>(null);
const [fundFlowLoading, setFundFlowLoading] = useState(false);
const [fundFlowPeriod, setFundFlowPeriod] = useState(21);
const [dailyTableDays, setDailyTableDays] = useState<number>(7);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [inCollection, setInCollection] = useState(false);
useEffect(() => {
loadStockData();
}, [code]);
const loadStockData = async () => {
setLoading(true);
setError(null);
setChartData([]);
setChartLoading(false);
setFundFlowData([]);
setFundFlowSummary(null);
setFundFlowLoading(false);
try {
// 阶段1:并行加载基础数据(数据库记录 + 实时行情)
const [stockRecordResult, quoteResult] = await Promise.allSettled([
collectionsApi.findStock(code),
fetchStockQuote(code),
]);
// 处理数据库查询结果
const stockRecord = stockRecordResult.status === "fulfilled" ? stockRecordResult.value : null;
setInCollection(stockRecord !== null);
// 处理实时行情结果
if (quoteResult.status !== "fulfilled" || !quoteResult.value) {
setError("未找到股票数据");
setLoading(false);
return;
}
const quote = quoteResult.value;
const addedAt = stockRecord?.added_at || new Date().toISOString();
const addedPrice = stockRecord?.added_price || quote.currentPrice;
setStockInfo({
code: quote.code,
name: quote.name,
currentPrice: quote.currentPrice,
changePercent: quote.changePercent,
change: quote.change,
high: quote.high,
low: quote.low,
todayOpen: quote.todayOpen,
yesterdayClose: quote.yesterdayClose,
addedAt,
addedPrice,
outerDisk: quote.outerDisk || 0,
innerDisk: quote.innerDisk || 0,
amount: (quote.amount || 0) * 10000,
quoteDate: quote.date,
});
// 基础信息已就绪,结束主loading,先渲染页面框架
setLoading(false);
// 阶段2:并行加载历史K线 + 资金流向(后置加载,不阻塞首屏)
const addedDate = new Date(addedAt);
const now = new Date();
const daysSinceAdded = Math.floor((now.getTime() - addedDate.getTime()) / (1000 * 60 * 60 * 24));
const chartDays = Math.min(Math.max(90, daysSinceAdded), 120);
setChartLoading(true);
setFundFlowLoading(true);
const [historyResult, fundFlowResult] = await Promise.allSettled([
fetchStockHistory(code, chartDays),
fetchStockFundFlow(code, quote.name, 21),
]);
// 处理历史K线(必需)
if (historyResult.status === "fulfilled" && historyResult.value && historyResult.value.length > 0) {
const historyData = convertToStockData(historyResult.value, addedDate);
// 如果添加日不是交易日(周末/节假日),标记最接近的K线日期
if (stockRecord && !historyData.some(d => d.isAddedDate)) {
const closestIdx = findClosestDateIndex(historyData, addedDate);
if (historyData[closestIdx]) {
historyData[closestIdx] = { ...historyData[closestIdx], isAddedDate: true };
}
}
setChartData(historyData);
if (historyResult.value.length < 2) {
console.warn("历史K线数据过少(新股或上市首日),仅显示有限数据");
}
} else {
console.error("无法获取历史K线数据");
setError("获取历史K线数据失败,请稍后重试");
}
// 处理资金流向(非必需,失败不影响主流程)
if (fundFlowResult.status === "fulfilled" && fundFlowResult.value) {
console.log("[stock-detail] 资金流向数据:", fundFlowResult.value);
setFundFlowData(fundFlowResult.value.data);
setFundFlowSummary(fundFlowResult.value.summary || null);
} else {
console.error("获取资金流向数据失败:", fundFlowResult.status === "rejected" ? fundFlowResult.reason : "未知错误");
}
} catch (err) {
console.error("加载失败:", err);
const msg = err instanceof Error ? err.message : "加载股票数据失败";
setError(msg);
} finally {
setLoading(false);
setChartLoading(false);
setFundFlowLoading(false);
}
};
// 将K线数据转换为图表格式
const convertToStockData = (klines: KLineData[], addedDate: Date): StockData[] => {
return klines.map(kline => {
const dateObj = new Date(kline.date);
const isAddedDate = dateObj.toDateString() === addedDate.toDateString();
return {
date: dateObj.toLocaleDateString("zh-CN", { month: "2-digit", day: "2-digit" }),
dateObj,
open: kline.open,
close: kline.close,
high: kline.high,
low: kline.low,
volume: kline.volume,
isAddedDate,
};
});
};
// 在K线数据中找到最接近目标日期的索引(当添加日非交易日时使用)
const findClosestDateIndex = (data: StockData[], target: Date): number => {
let closestIdx = 0;
let closestDiff = Infinity;
data.forEach((d, i) => {
const diff = Math.abs(d.dateObj.getTime() - target.getTime());
if (diff < closestDiff) {
closestDiff = diff;
closestIdx = i;
}
});
return closestIdx;
};
// 资金流向按周期切片
const fundFlowSlice = useMemo(
() => fundFlowData.slice(-fundFlowPeriod),
[fundFlowData, fundFlowPeriod]
);
const fundFlowLocalSummary = useMemo(() => {
if (fundFlowSlice.length === 0) return null;
const totalMain = fundFlowSlice.reduce((s, d) => s + d.mainForceNet, 0);
const totalTurnover = fundFlowSlice.reduce((s, d) => s + d.turnover, 0);
const totalLargeInflow = fundFlowSlice.reduce((s, d) => s + d.largeInflow, 0);
const positive = fundFlowSlice.filter(d => d.mainForceNet > 0).length;
const negative = fundFlowSlice.filter(d => d.mainForceNet < 0).length;
return {
totalMainNet: totalMain,
totalTurnover,
totalLargeInflow,
avgDailyMainNet: totalMain / fundFlowSlice.length,
positiveDays: positive,
negativeDays: negative,
};
}, [fundFlowSlice]);
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<p className="text-muted-foreground">...</p>
</div>
);
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<p className="text-xl font-semibold mb-2"></p>
<p className="text-muted-foreground mb-4">{error}</p>
<Link to="/">
<Button variant="outline">
<ArrowLeft className="mr-2 h-4 w-4" />
</Button>
</Link>
</div>
</div>
);
}
if (!stockInfo) {
return (
<div className="min-h-screen flex items-center justify-center">
<p className="text-muted-foreground"></p>
</div>
);
}
const isPositive = stockInfo.changePercent >= 0;
const trendColor = isPositive ? "text-red-500" : "text-green-500";
// 计算自添加以来的累计涨跌幅
const addedPrice = stockInfo.addedPrice;
const cumulativeChange = addedPrice && addedPrice > 0
? ((stockInfo.currentPrice - addedPrice) / addedPrice) * 100
: null;
const cumulativeIsPositive = cumulativeChange !== null && cumulativeChange >= 0;
// 找到添加日期在图表中的位置
const addedDateObj = stockInfo ? new Date(stockInfo.addedAt) : null;
const addedDateStr = addedDateObj ? addedDateObj.toLocaleDateString("zh-CN", { month: "2-digit", day: "2-digit" }) : "";
const addedDataPoint = inCollection ? chartData.find(d => d.isAddedDate) : undefined;
// 计算X轴刻度:保证添加日期始终有标签
const tickDates = (() => {
if (chartData.length === 0) return [];
const first = chartData[0].date;
const last = chartData[chartData.length - 1].date;
const ticks = [first];
const added = addedDataPoint?.date;
const targetCount = Math.min(7, chartData.length);
const step = Math.max(1, Math.floor((chartData.length - 1) / (targetCount - 2)));
for (let i = step; i < chartData.length - 1; i += step) {
ticks.push(chartData[i].date);
}
if (added && !ticks.includes(added)) {
ticks.push(added);
}
ticks.push(last);
return [...new Set(ticks)].sort((a, b) => {
// 按日期排序(MM/DD格式需按MM和DD比较)
const [am, ad] = a.split('/').map(Number);
const [bm, bd] = b.split('/').map(Number);
return am - bm || ad - bd;
});
})();
// 东方财富市场标识:0=深圳(000/002/300), 1=上海(60), 6=科创板(688)
const getEastMoneyMarket = (code: string): string => {
if (code.startsWith('688')) return '6';
if (code.startsWith('60')) return '1';
return '0';
};
const eastMoneyUrl = `https://wap.eastmoney.com/quote/stock/${getEastMoneyMarket(stockInfo.code)}.${stockInfo.code}.html?appfenxiang=1`;
const jin10Url = `https://search.jin10.com/?keyword=${encodeURIComponent(stockInfo.name)}`;
return (
<div className="min-h-screen bg-gradient-to-br from-background via-muted/30 to-background overscroll-none">
<div className="container mx-auto px-4 py-4 md:py-8 max-w-6xl">
{/* Top Action Bar: 返回 + 东方财富 */}
<div className="flex items-center justify-between mb-4 md:mb-6 gap-2">
{from ? (
<Link to={`/${from}`}>
<Button variant="ghost" size="sm">
<ArrowLeft className="mr-2 h-4 w-4" />
</Button>
</Link>
) : (
<Link to="/">
<Button variant="ghost" size="sm">
<ArrowLeft className="mr-2 h-4 w-4" />
</Button>
</Link>
)}
<a href={eastMoneyUrl} target="_blank" rel="noopener noreferrer">
<Button variant="outline" size="sm">
<ExternalLink className="mr-2 h-4 w-4" />
</Button>
</a>
<a href={jin10Url} target="_blank" rel="noopener noreferrer">
<Button variant="outline" size="sm">
<Newspaper className="mr-2 h-4 w-4" />
</Button>
</a>
</div>
{/* Stock Info Header */}
<Card className="mb-4 md:mb-6 shadow-lg">
<CardContent className="p-4 md:p-6">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<div className="flex items-center gap-2 mb-2 flex-wrap">
<h1 className="text-xl md:text-3xl font-bold">{stockInfo.name}</h1>
{(() => {
const board = getStockBoard(stockInfo.code);
if (!board.label) return null;
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border ${board.className}`}>
{board.label}
</span>
);
})()}
</div>
<p className="text-sm md:text-base text-muted-foreground">: {stockInfo.code}</p>
<div className="flex items-center gap-2 mt-2 text-xs md:text-sm text-muted-foreground">
<Calendar className="h-3 w-3 md:h-4 md:w-4" />
<span>: {new Date(stockInfo.addedAt).toLocaleDateString("zh-CN")}</span>
</div>
{addedPrice && (
<p className="text-xs md:text-sm mt-1">
<span className="text-muted-foreground"></span>
<span className="font-medium">¥{addedPrice.toFixed(2)}</span>
</p>
)}
</div>
<div className="text-right space-y-2">
<div>
<p className="text-2xl md:text-4xl font-bold">¥{stockInfo.currentPrice.toFixed(2)}</p>
<div className={`flex items-center justify-end gap-1 md:gap-2 mt-1 ${trendColor}`}>
{isPositive ? (
<TrendingUp className="h-4 w-4 md:h-5 md:w-5" />
) : (
<TrendingDown className="h-4 w-4 md:h-5 md:w-5" />
)}
<span className="text-base md:text-xl font-semibold">
{isPositive ? "+" : ""}{stockInfo.change.toFixed(2)} ({isPositive ? "+" : ""}{stockInfo.changePercent.toFixed(2)}%)
</span>
</div>
<p className="text-xs md:text-sm text-muted-foreground mt-1"></p>
</div>
{/* 累计涨跌幅 */}
{cumulativeChange !== null && (
<div className={`pt-2 border-t`}>
<p className="text-sm text-muted-foreground"></p>
<div className={`flex items-center justify-end gap-2 mt-1 ${cumulativeIsPositive ? "text-red-500" : "text-green-500"}`}>
{cumulativeIsPositive ? (
<TrendingUp className="h-4 w-4" />
) : (
<TrendingDown className="h-4 w-4" />
)}
<span className="text-lg font-semibold">
{cumulativeIsPositive ? "+" : ""}{cumulativeChange.toFixed(2)}%
</span>
</div>
</div>
)}
</div>
</div>
</CardContent>
</Card>
{/* Chart */}
<Card className="shadow-lg">
<CardHeader className="pb-2 md:pb-4 px-3 md:px-6 pt-4 md:pt-6">
<div className="flex items-center justify-between flex-wrap gap-2">
<CardTitle className="text-base md:text-lg">
K线图
<span className="text-xs md:text-sm font-normal text-muted-foreground ml-2">
{chartLoading ? "(加载中..." : `${chartData.length}个交易日)`}
</span>
</CardTitle>
<span className="text-xs text-muted-foreground hidden sm:inline">
·
</span>
</div>
</CardHeader>
<CardContent className="px-2 md:px-6 pb-2 md:pb-6">
{chartLoading ? (
<div className="h-[320px] sm:h-[380px] md:h-[440px] w-full flex items-center justify-center">
<div className="flex items-center text-muted-foreground text-sm">
<div className="animate-pulse mr-2 h-2 w-2 rounded-full bg-primary"></div>
K线数据加载中...
</div>
</div>
) : (
<>
<div
className="h-[320px] sm:h-[380px] md:h-[440px] w-full touch-none select-none"
style={{ touchAction: "none", overscrollBehavior: "none", WebkitOverflowScrolling: "auto" }}
>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={chartData}
margin={{ top: 10, right: 8, left: 0, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" opacity={0.5} />
<XAxis
dataKey="date"
stroke="var(--muted-foreground)"
fontSize={10}
ticks={tickDates}
angle={-45}
textAnchor="end"
height={56}
tick={(props: any) => {
const { x, y, payload } = props;
const dataPoint = chartData.find(d => d.date === payload.value);
const isAddedDate = inCollection && dataPoint?.isAddedDate;
return (
<text
x={x}
y={y}
dy={16}
textAnchor="end"
fill={isAddedDate ? '#f59e0b' : 'var(--muted-foreground)'}
fontWeight={isAddedDate ? 'bold' : 'normal'}
fontSize={isAddedDate ? 11 : 10}
>
{payload.value}
</text>
);
}}
/>
<YAxis
yAxisId="left"
stroke="var(--muted-foreground)"
fontSize={10}
domain={["auto", "auto"]}
tickCount={5}
width={42}
/>
<YAxis
yAxisId="right"
orientation="right"
stroke="var(--muted-foreground)"
fontSize={10}
tickCount={5}
width={42}
/>
<Tooltip
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "8px",
fontSize: "12px",
padding: "8px 12px",
}}
labelFormatter={(label) => `日期: ${label}`}
formatter={(value: any, name: string) => {
if (name === 'close') return [`¥${Number(value).toFixed(2)}`, '收盘价'];
if (name === 'volume') return [Number(value).toLocaleString(), '成交量'];
return [value, name];
}}
/>
<Bar
yAxisId="right"
dataKey="volume"
fill="var(--primary)"
opacity={0.2}
/>
<Line
yAxisId="left"
type="monotone"
dataKey="close"
stroke="var(--primary)"
strokeWidth={2}
dot={false}
/>
{/* 高亮添加日期的K线点 */}
<Scatter
yAxisId="left"
dataKey="close"
fill="#fbbf24"
shape={(props: any) => {
const { cx, cy, payload } = props;
if (!payload.isAddedDate) {
return <circle cx={cx} cy={cy} r={0} fill="transparent" />;
}
return (
<circle
cx={cx}
cy={cy}
r={7}
fill="#fbbf24"
stroke="#f59e0b"
strokeWidth={2.5}
/>
);
}}
/>
{/* 添加日期参考线 */}
{addedDataPoint && (
<ReferenceLine
yAxisId="left"
x={addedDataPoint.date}
stroke="#f59e0b"
strokeDasharray="4 4"
opacity={0.7}
/>
)}
{/* 缩放/滑动控件:移动端可双指缩放,拖动选区查看局部 */}
<Brush
dataKey="date"
height={24}
stroke="var(--primary)"
fill="var(--muted)"
travellerWidth={8}
gap={8}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
{/* Legend */}
<div className="flex flex-wrap justify-center gap-3 md:gap-6 mt-2 md:mt-4 text-xs md:text-sm">
<div className="flex items-center gap-1.5 md:gap-2">
<div className="w-2.5 h-2.5 md:w-3 md:h-3 rounded-full bg-primary"></div>
<span></span>
</div>
<div className="flex items-center gap-1.5 md:gap-2">
<div className="w-2.5 h-2.5 md:w-3 md:h-3 rounded-full bg-primary opacity-20"></div>
<span></span>
</div>
{inCollection && (
<div className="flex items-center gap-1.5 md:gap-2">
<div className="w-2.5 h-2.5 md:w-3 md:h-3 rounded-full" style={{ backgroundColor: '#fbbf24' }}></div>
<span></span>
</div>
)}
</div>
</>
)}
</CardContent>
</Card>
{/* Price Details */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4 mt-4 md:mt-6">
<Card>
<CardContent className="p-3 md:p-4 text-center">
<p className="text-xs md:text-sm text-muted-foreground mb-1"></p>
<p className="text-lg md:text-xl font-semibold">¥{stockInfo.todayOpen.toFixed(2)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-3 md:p-4 text-center">
<p className="text-xs md:text-sm text-muted-foreground mb-1"></p>
<p className="text-lg md:text-xl font-semibold">¥{stockInfo.high.toFixed(2)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-3 md:p-4 text-center">
<p className="text-xs md:text-sm text-muted-foreground mb-1"></p>
<p className="text-lg md:text-xl font-semibold">¥{stockInfo.low.toFixed(2)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-3 md:p-4 text-center">
<p className="text-xs md:text-sm text-muted-foreground mb-1"></p>
<p className="text-lg md:text-xl font-semibold">¥{stockInfo.yesterdayClose.toFixed(2)}</p>
</CardContent>
</Card>
</div>
{/* Daily Data Table - Independent from fund flow */}
{chartData.length > 0 && (
<Card className="mt-4 md:mt-6 shadow-lg">
<CardHeader className="pb-2 md:pb-4 px-3 md:px-6 pt-4 md:pt-6">
<div className="flex items-center justify-between flex-wrap gap-3">
<CardTitle className="text-base md:text-lg"></CardTitle>
<Tabs value={String(dailyTableDays)} onValueChange={(v) => setDailyTableDays(Number(v))}>
<TabsList>
<TabsTrigger value="7">7</TabsTrigger>
<TabsTrigger value="21">21</TabsTrigger>
</TabsList>
</Tabs>
</div>
{fundFlowData.length === 0 && (
<p className="text-xs text-muted-foreground mt-2"></p>
)}
</CardHeader>
<CardContent className="px-3 md:px-6 pb-3 md:pb-6">
<div className="overflow-x-auto">
<table className="w-full text-xs md:text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left py-2 px-2 font-medium text-muted-foreground"></th>
<th className="text-right py-2 px-2 font-medium text-muted-foreground"></th>
<th className="text-right py-2 px-2 font-medium text-muted-foreground"></th>
<th className="text-right py-2 px-2 font-medium text-muted-foreground"></th>
</tr>
</thead>
<tbody>
{(() => {
// 根据选择的天数截取最近N个交易日的数据并倒序(最新日期在前)
const displayData = chartData.slice(-dailyTableDays).reverse();
return displayData.map((item, idx) => {
// 计算涨幅:与后一天收盘价比较(因为已倒序,后一天是更早的日期)
let changePercent = 0;
if (idx < displayData.length - 1) {
const nextClose = displayData[idx + 1].close;
if (nextClose > 0) {
changePercent = ((item.close - nextClose) / nextClose) * 100;
}
}
const isPositive = changePercent >= 0;
// 查找对应的资金流向数据
// 使用本地时区格式化日期,避免 toISOString() 的 UTC 时区偏移导致日期错位
const dateStr = `${item.dateObj.getFullYear()}-${String(item.dateObj.getMonth() + 1).padStart(2, '0')}-${String(item.dateObj.getDate()).padStart(2, '0')}`;
const fundFlowItem = fundFlowData.find(f => f.date === dateStr);
// 主力净流入:从 fundFlowData 获取,匹配不到则显示 "-"
let mainNetInflow: number | null = null;
if (fundFlowItem) {
mainNetInflow = fundFlowItem.mainForceNet || 0;
}
// 成交额:优先从 fundFlowData,其次最末行用行情成交额,最后 volume × close 近似
const turnover = fundFlowItem?.turnover
|| (idx === 0 && stockInfo?.amount ? stockInfo.amount : 0)
|| item.volume * item.close;
return (
<tr key={idx} className="border-b border-border/50 hover:bg-muted/20">
<td className="py-2 px-2">{item.date}</td>
<td className={`text-right py-2 px-2 font-medium ${isPositive ? 'text-red-500' : 'text-green-500'}`}>
{isPositive ? '+' : ''}{changePercent.toFixed(2)}%
</td>
<td className={`text-right py-2 px-2 font-medium ${mainNetInflow !== null ? (mainNetInflow >= 0 ? 'text-red-500' : 'text-green-500') : ''}`}>
{mainNetInflow !== null ? formatMoney(mainNetInflow) : '-'}
</td>
<td className="text-right py-2 px-2">
{formatMoney(turnover)}
</td>
</tr>
);
});
})()}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
{/* Fund Flow Loading Skeleton */}
{fundFlowLoading && fundFlowData.length === 0 && (
<Card className="mt-4 md:mt-6 shadow-lg">
<CardHeader className="pb-2 md:pb-4 px-3 md:px-6 pt-4 md:pt-6">
<CardTitle className="text-base md:text-lg"></CardTitle>
</CardHeader>
<CardContent className="px-3 md:px-6 pb-3 md:pb-6">
<div className="flex items-center justify-center py-8 text-muted-foreground text-sm">
<div className="animate-pulse mr-2 h-2 w-2 rounded-full bg-primary"></div>
...
</div>
</CardContent>
</Card>
)}
{/* Fund Flow Section */}
{fundFlowData.length > 0 && fundFlowSummary && (
<>
<div className="mt-4 md:mt-6 space-y-4 md:space-y-6">
{/* Period Summary Cards */}
<Card className="shadow-lg">
<CardHeader className="pb-2 md:pb-4 px-3 md:px-6 pt-4 md:pt-6">
<div className="flex items-center justify-between flex-wrap gap-3">
<CardTitle className="text-base md:text-lg"></CardTitle>
<Tabs value={String(fundFlowPeriod)} onValueChange={(v) => setFundFlowPeriod(Number(v))}>
<TabsList>
<TabsTrigger value="5">5</TabsTrigger>
<TabsTrigger value="21">21</TabsTrigger>
</TabsList>
</Tabs>
</div>
</CardHeader>
<CardContent className="px-3 md:px-6 pb-3 md:pb-6">
{fundFlowLocalSummary && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4">
<div className="text-center p-3 rounded-lg bg-muted/30">
<p className="text-xs text-muted-foreground mb-1"></p>
<p className={`text-lg font-bold ${fundFlowLocalSummary.totalMainNet >= 0 ? 'text-red-500' : 'text-green-500'}`}>
{formatMoney(fundFlowLocalSummary.totalMainNet)}
</p>
</div>
<div className="text-center p-3 rounded-lg bg-orange-500/5 border border-orange-500/20 relative">
<span className="absolute top-1 right-1 text-[10px] text-orange-500 bg-orange-500/10 px-1.5 py-0.5 rounded"></span>
<p className="text-xs text-muted-foreground mb-1"></p>
<p className="text-lg font-bold text-red-500">
{formatMoney(fundFlowLocalSummary.totalLargeInflow ?? 0)}
</p>
</div>
<div className="text-center p-3 rounded-lg bg-muted/30">
<p className="text-xs text-muted-foreground mb-1"></p>
<p className={`text-lg font-bold ${fundFlowLocalSummary.avgDailyMainNet >= 0 ? 'text-red-500' : 'text-green-500'}`}>
{formatMoney(fundFlowLocalSummary.avgDailyMainNet)}
</p>
</div>
<div className="text-center p-3 rounded-lg bg-muted/30">
<p className="text-xs text-muted-foreground mb-1"></p>
<p className="text-lg font-bold text-red-500">{fundFlowLocalSummary.positiveDays}</p>
<p className="text-xs text-muted-foreground">{fundFlowLocalSummary.negativeDays}</p>
</div>
</div>
)}
</CardContent>
</Card>
{/* Main Force Net Inflow Chart */}
<Card className="shadow-lg">
<CardHeader className="pb-2 md:pb-4 px-3 md:px-6 pt-4 md:pt-6">
<CardTitle className="text-base md:text-lg"></CardTitle>
</CardHeader>
<CardContent className="px-3 md:px-6 pb-3 md:pb-6">
<div className="h-[280px] sm:h-[320px] md:h-[360px] w-full">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={fundFlowSlice.map(d => ({
...d,
dateShort: d.date.slice(5),
mainForceNet: d.mainForceNet,
}))}
margin={{ top: 10, right: 8, left: 0, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" opacity={0.5} />
<XAxis
dataKey="dateShort"
stroke="var(--muted-foreground)"
fontSize={10}
tickCount={6}
angle={-45}
textAnchor="end"
height={56}
interval="preserveStartEnd"
/>
<YAxis
stroke="var(--muted-foreground)"
fontSize={10}
tickFormatter={(value) => {
return (value / 100000000).toFixed(2) + '亿';
}}
width={60}
/>
<Tooltip
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "8px",
fontSize: "12px",
padding: "8px 12px",
}}
labelFormatter={(label) => `日期: ${label}`}
formatter={(value: any, name: string) => {
const numValue = Number(value);
if (name === 'mainForceNet' || name === '主力') return [formatMoney(numValue), '主力净额'];
return [value, name];
}}
/>
<Bar dataKey="mainForceNet" opacity={0.6} name="主力">
{fundFlowSlice.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.mainForceNet >= 0 ? '#ef4444' : '#22c55e'} />
))}
</Bar>
<ReferenceLine y={0} stroke="var(--border)" strokeWidth={1} />
</ComposedChart>
</ResponsiveContainer>
</div>
<div className="flex justify-center gap-4 mt-3 text-xs">
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-sm bg-red-500"></div>
<span></span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-sm bg-green-500"></div>
<span></span>
</div>
</div>
</CardContent>
</Card>
{/* Cumulative Main Force Trend */}
<Card className="shadow-lg">
<CardHeader className="pb-2 md:pb-4 px-3 md:px-6 pt-4 md:pt-6">
<CardTitle className="text-base md:text-lg"></CardTitle>
</CardHeader>
<CardContent className="px-3 md:px-6 pb-3 md:pb-6">
<div className="h-[280px] sm:h-[320px] md:h-[360px] w-full">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={fundFlowSlice.map(d => ({
...d,
dateShort: d.date.slice(5),
cumulativeMainNet: d.cumulativeMainNet,
}))}
margin={{ top: 10, right: 8, left: 0, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" opacity={0.5} />
<XAxis
dataKey="dateShort"
stroke="var(--muted-foreground)"
fontSize={10}
tickCount={6}
angle={-45}
textAnchor="end"
height={56}
interval="preserveStartEnd"
/>
<YAxis
stroke="var(--muted-foreground)"
fontSize={10}
tickFormatter={(value) => {
return (value / 100000000).toFixed(2) + '亿';
}}
width={60}
/>
<Tooltip
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "8px",
fontSize: "12px",
padding: "8px 12px",
}}
labelFormatter={(label) => `日期: ${label}`}
formatter={(value: any, name: string, props: any) => {
const numValue = Number(value);
if (props?.dataKey === 'cumulativeMainNet') {
return [(numValue / 100000000).toFixed(2) + '亿', '累计主力'];
}
return [value, name];
}}
/>
{(() => {
const vals = fundFlowSlice.map(d => d.cumulativeMainNet);
const mx = Math.max(...vals, 0);
const mn = Math.min(...vals, 0);
const rng = mx - mn;
const zeroPct = rng > 0 ? (mx / rng * 100) : 50;
return (
<defs>
<linearGradient id="cumulativeLineGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#ef4444" />
<stop offset={`${zeroPct}%`} stopColor="#ef4444" />
<stop offset={`${zeroPct}%`} stopColor="#22c55e" />
<stop offset="100%" stopColor="#22c55e" />
</linearGradient>
</defs>
);
})()}
<Line
type="monotone"
dataKey="cumulativeMainNet"
stroke="url(#cumulativeLineGradient)"
strokeWidth={2}
dot={false}
name="累计主力"
/>
<ReferenceLine y={0} stroke="var(--border)" strokeWidth={1} />
</ComposedChart>
</ResponsiveContainer>
</div>
<div className="flex justify-center gap-4 mt-3 text-xs">
<div className="flex items-center gap-1.5">
<div className="w-3 h-0.5 bg-red-500"></div>
<span></span>
</div>
</div>
</CardContent>
</Card>
</div>
</>
)}
</div>
</div>
);
}