up
This commit is contained in:
Executable
+940
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user