feat: K线增加分钟/小时周期切换,拆分独立KLineCard组件

- 后端:腾讯 mkline 新增 /api/stock/history-minute(m1/m5/m15/m30/m60)
- 新组件 kline-card.tsx:自包含周期/折线蜡烛/指标开关与K线数据加载,
  切周期只重拉K线,不刷新页面其他模块
- 详情页瘦身为独立模块:K线图 / 今开最高最低昨收 / 每日行情明细互不耦合
- 时间统一按 UTC 解析传 Unix 秒,修复日线 invalid date/N/A 与分钟线时区问题
- 资金流向失败降级为非致命,不再导致整页报错
- vite 构建拆分 recharts/lightweight-charts/router 独立 chunk
This commit is contained in:
Sakurasan
2026-08-28 17:43:11 +08:00
parent c4ed3346ab
commit b1f216b2a4
8 changed files with 396 additions and 253 deletions
+29 -251
View File
@@ -1,7 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";
import { useState, useEffect, useMemo, Fragment } from "react";
import { useState, useEffect, useMemo } from "react";
import { collectionsApi } from "@/lib/api-client";
import { fetchStockQuote, fetchStockFundFlow, fetchCompanyProfile, fetchBusinessSegments, fetchFinancialData, getStockBoard, type StockQuote, type KLineData, type FundFlowData, type FundFlowSummary, type CompanyProfile, type BusinessSegmentsResponse, type FinancialDataResponse, fetchStockHistoryMinute } from "@/lib/stock-api";
import { fetchStockQuote, fetchStockFundFlow, fetchCompanyProfile, fetchBusinessSegments, fetchFinancialData, getStockBoard, type StockQuote, type KLineData, type FundFlowData, type FundFlowSummary, type CompanyProfile, type BusinessSegmentsResponse, type FinancialDataResponse } from "@/lib/stock-api";
import { fetchStockHistoryV2 } from "@/lib/fuyao-api";
import StockProfileTabs from "@/components/stock-profile-tabs";
import { getUserId } from "@/lib/user-id";
@@ -11,7 +11,7 @@ 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 KLineChart from "@/components/kline-chart";
import KLineCard from "@/components/kline-card";
import {
ComposedChart,
Line,
@@ -37,18 +37,10 @@ export const Route = createFileRoute("/stock/$code")({
},
});
// K线周期标签映射
const PERIOD_LABEL: Record<"m1" | "m5" | "m15" | "m30" | "m60", string> = {
m1: "1分",
m5: "5分",
m15: "15分",
m30: "30分",
m60: "60分",
};
interface StockData {
date: string;
dateObj: Date;
dateMs: number; // Unix seconds UTC (lightweight-charts numeric time)
open: number;
close: number;
high: number;
@@ -94,16 +86,6 @@ function StockDetail() {
const [fundFlowLoading, setFundFlowLoading] = useState(false);
const [fundFlowPeriod, setFundFlowPeriod] = useState(21);
const [dailyTableDays, setDailyTableDays] = useState<number>(7);
const [chartRange, setChartRange] = useState(90); // 默认近3月
const [chartMode, setChartMode] = useState<"line" | "candle">("candle"); // 折线/蜡烛,默认蜡烛
// K线周期:日(d) / 60分(m60) / 30分(m30) / 15分(m15) / 5分(m5) / 1分(m1)
const [chartPeriod, setChartPeriod] = useState<"d" | "m60" | "m30" | "m15" | "m5" | "m1">("d");
// 分钟/小时线专用数据(独立于日K chartData,避免数据混用)
const [minuteData, setMinuteData] = useState<StockData[]>([]);
const [minuteLoading, setMinuteLoading] = useState(false);
const [indicators, setIndicators] = useState({ ma: true, macd: false, rsi: false });
const toggleIndicator = (key: keyof typeof indicators) =>
setIndicators((prev) => ({ ...prev, [key]: !prev[key] }));
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [inCollection, setInCollection] = useState(false);
@@ -117,7 +99,7 @@ function StockDetail() {
useEffect(() => {
loadStockData();
}, [code, chartPeriod]);
}, [code]);
// 加载公司概况、经营分析、财务分析(并行后置加载)
useEffect(() => {
@@ -138,9 +120,7 @@ function StockDetail() {
setLoading(true);
setError(null);
setChartData([]);
setMinuteData([]);
setChartLoading(false);
setMinuteLoading(false);
setFundFlowData([]);
setFundFlowSummary(null);
setFundFlowLoading(false);
@@ -193,74 +173,32 @@ function StockDetail() {
// 基础信息已就绪,结束主loading,先渲染页面框架
setLoading(false);
// 阶段2:根据周期加载不同的K线数据(后置加载,不阻塞首屏)
// 阶段2:并行拉取日K(供每日行情明细表格)+ 资金流向(非必需,失败不影响页面)
const addedDate = new Date(addedAt);
setChartLoading(true);
setFundFlowLoading(true);
const [historyResult, fundFlowResult] = await Promise.allSettled([
fetchStockHistoryV2(code, 30), // 明细表默认只展示近7/21日,30天足够
fetchStockFundFlow(code, quote.name, 21),
]);
if (chartPeriod === "d") {
// 日线:获取完整数据用于展示切片
setChartLoading(true);
setFundFlowLoading(true);
const [historyResult, fundFlowResult] = await Promise.allSettled([
fetchStockHistoryV2(code, 365), // 获取1年数据用于切片
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 };
}
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线数据失败,请稍后重试");
}
setChartData(historyData);
}
// 处理资金流向(非必需,失败不影响主流程)
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 : "未知错误");
}
if (fundFlowResult.status === "fulfilled" && fundFlowResult.value) {
setFundFlowData(fundFlowResult.value.data);
setFundFlowSummary(fundFlowResult.value.summary || null);
} else {
// 分钟/小时线:独立加载,不影响日K数据
setMinuteLoading(true);
const minuteResult = await fetchStockHistoryMinute(
code,
chartPeriod === "m60" ? "m60" :
chartPeriod === "m30" ? "m30" :
chartPeriod === "m15" ? "m15" :
chartPeriod === "m5" ? "m5" : "m1",
320 // 默认320根
);
if (minuteResult && minuteResult.length > 0) {
const minuteDataConverted = convertToStockData(minuteResult, addedDate);
// 如果添加日不是交易日(周末/节假日),标记最接近的K线
if (stockRecord && !minuteDataConverted.some(d => d.isAddedDate)) {
const closestIdx = findClosestDateIndex(minuteDataConverted, addedDate);
if (minuteDataConverted[closestIdx]) {
minuteDataConverted[closestIdx] = { ...minuteDataConverted[closestIdx], isAddedDate: true };
}
}
setMinuteData(minuteDataConverted);
} else {
console.error("无法获取分钟K线数据");
setError("获取分钟K线数据失败,请稍后重试");
}
setMinuteLoading(false);
console.error("获取资金流向数据失败(非致命):", fundFlowResult.status === "rejected" ? fundFlowResult.reason : "未获取到数据");
}
} catch (err) {
@@ -270,12 +208,11 @@ function StockDetail() {
} finally {
setLoading(false);
setChartLoading(false);
setMinuteLoading(false);
setFundFlowLoading(false);
}
};
// 将K线数据转换为图表格式
// 将K线数据转换为表格格式(每日行情明细用)
const convertToStockData = (klines: KLineData[], addedDate: Date): StockData[] => {
return klines.map(kline => {
const dateObj = new Date(kline.date);
@@ -284,6 +221,7 @@ function StockDetail() {
return {
date: dateObj.toLocaleDateString("zh-CN", { month: "2-digit", day: "2-digit" }),
dateObj,
dateMs: Math.floor(dateObj.getTime() / 1000), // Unix seconds UTC
open: kline.open,
close: kline.close,
high: kline.high,
@@ -339,21 +277,6 @@ function StockDetail() {
return typeof roe === "number" ? roe : null;
}, [financialData]);
// 展示数据:日线时按 chartRange 切片,分钟/小时线时直接使用 minuteData
// 必须在所有 early return 之前调用,保证 hook 顺序一致
const displayData = useMemo(() => {
if (chartPeriod !== "d") {
// 分钟/小时线:直接使用后端返回的数据(已经按时间升序)
return minuteData;
}
// 日线:按 chartRange 切片最近 N 天
if (chartData.length <= chartRange) return chartData;
return chartData.slice(chartData.length - chartRange);
}, [chartData, chartRange, chartPeriod, minuteData]);
// 当前 loading 状态:日线和分钟线用不同的 loading 标志
const currentChartLoading = chartPeriod === "d" ? chartLoading : minuteLoading;
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
@@ -397,31 +320,6 @@ function StockDetail() {
: 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 ? displayData.find(d => d.isAddedDate) : undefined;
// 计算X轴刻度:保证添加日期始终有标签
const tickDates = (() => {
if (displayData.length === 0) return [];
const first = displayData[0].date;
const last = displayData[displayData.length - 1].date;
const ticks = [first];
const added = addedDataPoint?.date;
const targetCount = Math.min(7, displayData.length);
const step = Math.max(1, Math.floor((displayData.length - 1) / (targetCount - 2)));
for (let i = step; i < displayData.length - 1; i += step) {
ticks.push(displayData[i].date);
}
if (added && !ticks.includes(added)) {
ticks.push(added);
}
ticks.push(last);
// 保持chartData的原始顺序(已按日期升序排列),不做二次排序
return [...new Set(ticks)];
})();
// 东方财富市场标识:0=深圳(000/002/300), 1=上海(60), 6=科创板(688)
const getEastMoneyMarket = (code: string): string => {
if (code.startsWith('688')) return '6';
@@ -575,128 +473,8 @@ function StockDetail() {
</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">
{currentChartLoading ? "(加载中...)" : (
chartPeriod === "d" ? `(${displayData.length}个交易日)` : `(${displayData.length}根${PERIOD_LABEL[chartPeriod as "m60" | "m30" | "m15" | "m5" | "m1"] || chartPeriod}K线)`
)}
</span>
</CardTitle>
<div className="flex items-center gap-1.5 flex-wrap">
{/* 显示方式切换:折线 / 蜡烛 */}
<div className="flex gap-0.5 text-xs border rounded-md overflow-hidden">
{(["line", "candle"] as const).map((m) => (
<button
key={m}
onClick={() => setChartMode(m)}
className={`px-2.5 py-1 transition-colors ${
chartMode === m ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"
}`}
>
{m === "line" ? "折线" : "蜡烛"}
</button>
))}
</div>
{/* 指标开关:MA / MACD / RSI */}
<div className="flex gap-0.5 text-xs border rounded-md overflow-hidden">
{([
{ key: "ma", label: "MA" },
{ key: "macd", label: "MACD" },
{ key: "rsi", label: "RSI" },
] as const).map((it) => (
<button
key={it.key}
onClick={() => toggleIndicator(it.key)}
className={`px-2.5 py-1 transition-colors ${
indicators[it.key] ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"
}`}
>
{it.label}
</button>
))}
</div>
{/* K线周期:日 / 60分 / 30分 / 15分 / 5分 / 1分 */}
<div className="flex gap-0.5 text-xs border rounded-md overflow-hidden">
{([
{ key: "d" as const, label: "日" },
{ key: "m60" as const, label: "60分" },
{ key: "m30" as const, label: "30分" },
{ key: "m15" as const, label: "15分" },
{ key: "m5" as const, label: "5分" },
{ key: "m1" as const, label: "1分" },
] as const).map((p) => (
<button
key={p.key}
onClick={() => setChartPeriod(p.key)}
className={`px-2 py-1 transition-colors ${
chartPeriod === p.key ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"
}`}
>
{p.label}
</button>
))}
</div>
{/* 时间范围:3月 / 6月 / 1年(仅日线显示) */}
{chartPeriod === "d" && (
<div className="flex gap-0.5 text-xs border rounded-md overflow-hidden">
{[
{ key: 90, label: "3月" },
{ key: 180, label: "6月" },
{ key: 365, label: "1年" },
].map((r) => (
<button
key={r.key}
onClick={() => setChartRange(r.key)}
className={`px-2.5 py-1 transition-colors ${
chartRange === r.key ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"
}`}
>
{r.label}
</button>
))}
</div>
)}
<span className="text-xs text-muted-foreground hidden sm:inline">
双指缩放 · 拖动查看
</span>
</div>
</div>
</CardHeader>
<CardContent className="px-2 md:px-6 pb-2 md:pb-6">
{currentChartLoading ? (
<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>
) : (
<>
<KLineChart
data={displayData.map((d) => ({
time: chartPeriod === "d"
? d.dateObj.toISOString().slice(0, 10)
: d.date, // 分钟/小时线保留完整日期时间字符串 YYYY-MM-DD HH:MM
open: d.open,
close: d.close,
high: d.high,
low: d.low,
volume: d.volume,
isAddedDate: d.isAddedDate,
}))}
mode={chartMode}
hasAddedDate={inCollection}
indicators={indicators}
/>
</>
)}
</CardContent>
</Card>
{/* Chart - 独立K线卡片组件(周期/显示方式/指标状态与数据加载均自包含) */}
<KLineCard code={code} addedAt={stockInfo.addedAt} ready={true} />
{/* Price Details */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4 mt-4 md:mt-6">