Files
auv/src/routes/stock.$code.tsx
T
2026-07-06 19:44:40 +08:00

978 lines
42 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}