978 lines
42 KiB
TypeScript
Executable File
978 lines
42 KiB
TypeScript
Executable File
import { createFileRoute } from "@tanstack/react-router";
|
||
import { useState, useEffect, useMemo, Fragment } from "react";
|
||
import { collectionsApi } from "@/lib/api-client";
|
||
import { fetchStockQuote, fetchStockHistory, fetchStockFundFlow, fetchCompanyProfile, fetchBusinessSegments, fetchFinancialData, getStockBoard, type StockQuote, type KLineData, type FundFlowData, type FundFlowSummary, type CompanyProfile, type BusinessSegmentsResponse, type FinancialDataResponse } from "@/lib/stock-api";
|
||
import StockProfileTabs from "@/components/stock-profile-tabs";
|
||
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, TabsContent } from "@/components/ui/tabs";
|
||
import { Link } from "@tanstack/react-router";
|
||
import {
|
||
ComposedChart,
|
||
Line,
|
||
XAxis,
|
||
YAxis,
|
||
CartesianGrid,
|
||
Tooltip,
|
||
ResponsiveContainer,
|
||
Bar,
|
||
Cell,
|
||
Scatter,
|
||
Brush,
|
||
ReferenceLine,
|
||
PieChart,
|
||
Pie,
|
||
Legend,
|
||
} 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);
|
||
|
||
// 公司介绍、经营分析、财务分析
|
||
const [companyProfile, setCompanyProfile] = useState<CompanyProfile | null>(null);
|
||
const [businessSegments, setBusinessSegments] = useState<BusinessSegmentsResponse | null>(null);
|
||
const [financialData, setFinancialData] = useState<FinancialDataResponse | null>(null);
|
||
const [profileTab, setProfileTab] = useState("profile");
|
||
const [pieType, setPieType] = useState<"income" | "cost" | "profit">("income");
|
||
|
||
useEffect(() => {
|
||
loadStockData();
|
||
}, [code]);
|
||
|
||
// 加载公司概况、经营分析、财务分析(并行后置加载)
|
||
useEffect(() => {
|
||
if (!loading && stockInfo) {
|
||
Promise.all([
|
||
fetchCompanyProfile(code),
|
||
fetchBusinessSegments(code, "product", 30),
|
||
fetchFinancialData(code, 5),
|
||
]).then(([profile, segments, financial]) => {
|
||
if (profile) setCompanyProfile(profile);
|
||
if (segments) setBusinessSegments(segments);
|
||
if (financial) setFinancialData(financial);
|
||
});
|
||
}
|
||
}, [loading, stockInfo]);
|
||
|
||
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>
|
||
)}
|
||
|
||
{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>
|
||
</>
|
||
)}
|
||
|
||
<StockProfileTabs
|
||
code={code}
|
||
profileTab={profileTab}
|
||
setProfileTab={setProfileTab}
|
||
companyProfile={companyProfile}
|
||
businessSegments={businessSegments}
|
||
financialData={financialData}
|
||
setBusinessSegments={setBusinessSegments}
|
||
pieType={pieType}
|
||
setPieType={setPieType}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|