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) => { 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(null); const [chartData, setChartData] = useState([]); const [chartLoading, setChartLoading] = useState(false); const [fundFlowData, setFundFlowData] = useState([]); const [fundFlowSummary, setFundFlowSummary] = useState(null); const [fundFlowLoading, setFundFlowLoading] = useState(false); const [fundFlowPeriod, setFundFlowPeriod] = useState(21); const [dailyTableDays, setDailyTableDays] = useState(7); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [inCollection, setInCollection] = useState(false); // 公司介绍、经营分析、财务分析 const [companyProfile, setCompanyProfile] = useState(null); const [businessSegments, setBusinessSegments] = useState(null); const [financialData, setFinancialData] = useState(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 (

加载中...

); } if (error) { return (

加载失败

{error}

); } if (!stockInfo) { return (

未找到股票信息

); } 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 (
{/* Top Action Bar: 返回 + 东方财富 */}
{from ? ( ) : ( )}
{/* Stock Info Header */}

{stockInfo.name}

{(() => { const board = getStockBoard(stockInfo.code); if (!board.label) return null; return ( {board.label} ); })()}

代码: {stockInfo.code}

自选日期: {new Date(stockInfo.addedAt).toLocaleDateString("zh-CN")}
{addedPrice && (

自选价格: ¥{addedPrice.toFixed(2)}

)}

¥{stockInfo.currentPrice.toFixed(2)}

{isPositive ? ( ) : ( )} {isPositive ? "+" : ""}{stockInfo.change.toFixed(2)} ({isPositive ? "+" : ""}{stockInfo.changePercent.toFixed(2)}%)

今日涨幅

{/* 累计涨跌幅 */} {cumulativeChange !== null && (

自添加以来累计

{cumulativeIsPositive ? ( ) : ( )} {cumulativeIsPositive ? "+" : ""}{cumulativeChange.toFixed(2)}%
)}
{/* Chart */}
K线图 {chartLoading ? "(加载中...)" : `(${chartData.length}个交易日)`} 双指缩放 · 拖动选区查看
{chartLoading ? (
K线数据加载中...
) : ( <>
{ const { x, y, payload } = props; const dataPoint = chartData.find(d => d.date === payload.value); const isAddedDate = inCollection && dataPoint?.isAddedDate; return ( {payload.value} ); }} /> `日期: ${label}`} formatter={(value: any, name: string) => { if (name === 'close') return [`¥${Number(value).toFixed(2)}`, '收盘价']; if (name === 'volume') return [Number(value).toLocaleString(), '成交量']; return [value, name]; }} /> {/* 高亮添加日期的K线点 */} { const { cx, cy, payload } = props; if (!payload.isAddedDate) { return ; } return ( ); }} /> {/* 添加日期参考线 */} {addedDataPoint && ( )} {/* 缩放/滑动控件:移动端可双指缩放,拖动选区查看局部 */}
{/* Legend */}
收盘价
成交量
{inCollection && (
自选日期
)}
)}
{/* Price Details */}

今开

¥{stockInfo.todayOpen.toFixed(2)}

最高

¥{stockInfo.high.toFixed(2)}

最低

¥{stockInfo.low.toFixed(2)}

昨收

¥{stockInfo.yesterdayClose.toFixed(2)}

{/* Daily Data Table - Independent from fund flow */} {chartData.length > 0 && (
每日行情明细 setDailyTableDays(Number(v))}> 近7日 近21日
{fundFlowData.length === 0 && (

资金流向数据未加载(可能因外部服务限制)

)}
{(() => { // 根据选择的天数截取最近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 ( ); }); })()}
日期 涨幅 主力净流入 成交额
{item.date} {isPositive ? '+' : ''}{changePercent.toFixed(2)}% = 0 ? 'text-red-500' : 'text-green-500') : ''}`}> {mainNetInflow !== null ? formatMoney(mainNetInflow) : '-'} {formatMoney(turnover)}
)} {fundFlowLoading && fundFlowData.length === 0 && ( 资金流向分析
资金流向数据加载中...
)} {/* Fund Flow Section */} {fundFlowData.length > 0 && fundFlowSummary && ( <>
{/* Period Summary Cards */}
资金流向概览 setFundFlowPeriod(Number(v))}> 近5日 近21日
{fundFlowLocalSummary && (

主力总净流入

= 0 ? 'text-red-500' : 'text-green-500'}`}> {formatMoney(fundFlowLocalSummary.totalMainNet)}

≈主买

大单流入资金

{formatMoney(fundFlowLocalSummary.totalLargeInflow ?? 0)}

日均主力流入

= 0 ? 'text-red-500' : 'text-green-500'}`}> {formatMoney(fundFlowLocalSummary.avgDailyMainNet)}

主力流入天数

{fundFlowLocalSummary.positiveDays}天

流出{fundFlowLocalSummary.negativeDays}天

)}
{/* Main Force Net Inflow Chart */} 主力资金流向
({ ...d, dateShort: d.date.slice(5), mainForceNet: d.mainForceNet, }))} margin={{ top: 10, right: 8, left: 0, bottom: 0 }} > { return (value / 100000000).toFixed(2) + '亿'; }} width={60} /> `日期: ${label}`} formatter={(value: any, name: string) => { const numValue = Number(value); if (name === 'mainForceNet' || name === '主力') return [formatMoney(numValue), '主力净额']; return [value, name]; }} /> {fundFlowSlice.map((entry, index) => ( = 0 ? '#ef4444' : '#22c55e'} /> ))}
净流入
净流出
{/* Cumulative Main Force Trend */} 累计主力资金趋势
({ ...d, dateShort: d.date.slice(5), cumulativeMainNet: d.cumulativeMainNet, }))} margin={{ top: 10, right: 8, left: 0, bottom: 0 }} > { return (value / 100000000).toFixed(2) + '亿'; }} width={60} /> `日期: ${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 ( ); })()}
累计主力
)}
); }