diff --git a/src/components/kline-chart.tsx b/src/components/kline-chart.tsx index b9542a0..b77b4bf 100644 --- a/src/components/kline-chart.tsx +++ b/src/components/kline-chart.tsx @@ -1,7 +1,7 @@ /** * K 线图组件(基于 TradingView Lightweight Charts v5) * 主图:蜡烛/折线 + MA(5/10/20/30); - * 副图:成交量(pane1)、MACD(pane2)、RSI(pane3)、KDJ(pane4),可开关。 + * 副图:成交量(pane1)、MACD(pane2)、RSI(pane3),可开关。 */ import { useEffect, useRef } from "react"; import { @@ -18,7 +18,7 @@ import { type ISeriesApi as ISeriesAny, type Time, } from "lightweight-charts"; -import { sma, macd as calcMacd, rsi as calcRsi, kdj as calcKdj, type KBar } from "@/lib/indicators"; +import { sma, macd as calcMacd, rsi as calcRsi, type KBar } from "@/lib/indicators"; export interface KLineItem { time: string; // ISO 日期 yyyy-mm-dd(lightweight-charts 必需) @@ -34,7 +34,6 @@ export interface IndicatorToggles { ma: boolean; macd: boolean; rsi: boolean; - kdj: boolean; } interface Props { @@ -47,6 +46,9 @@ interface Props { // lightweight-charts 无法解析 CSS 变量或 oklch() 颜色,直接用具体 hex 色。 const UP = "#ef4444"; const DOWN = "#22c55e"; +// 成交量柱:浅色调,避免与 K 线柱体视觉争夺 +const VOL_UP = "#fca5a5"; +const VOL_DOWN = "#86efac"; const PRIMARY = "#ef4444"; const TEXT = "#71717a"; const GRID = "#e4e4e7"; @@ -58,9 +60,6 @@ const MA_PERIODS = [5, 10, 20]; const MACD_DIF = "#3b82f6"; const MACD_DEA = "#f59e0b"; const RSI_COLOR = "#a855f7"; -const KDJ_K = "#3b82f6"; -const KDJ_D = "#f59e0b"; -const KDJ_J = "#a855f7"; export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) { const containerRef = useRef(null); @@ -71,10 +70,9 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) { const maSeriesRef = useRef[]>([]); const macdSeriesRef = useRef[]>([]); const rsiSeriesRef = useRef[]>([]); - const kdjSeriesRef = useRef[]>([]); // 容器高度随指标 pane 数量增长,避免主图被压缩 - const extraPanes = [indicators.macd, indicators.rsi, indicators.kdj].filter(Boolean).length; + const extraPanes = [indicators.macd, indicators.rsi].filter(Boolean).length; // 固定图表高度:主图+成交量 300,每个指标副图 +95 const chartHeight = 300 + extraPanes * 95; @@ -101,11 +99,15 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) { }); chartRef.current = chart; - const vol = chart.addSeries(HistogramSeries, { - priceFormat: { type: "volume" }, - priceScaleId: "vol", - }); - vol.priceScale().applyOptions({ scaleMargins: { top: 0.8, bottom: 0 } }); + // 成交量放到独立 pane1,避免与主图K线重叠 + const vol = chart.addSeries( + HistogramSeries, + { + priceFormat: { type: "volume" }, + priceScaleId: "vol", + }, + 1, + ); volSeriesRef.current = vol; return () => { @@ -116,7 +118,6 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) { maSeriesRef.current = []; macdSeriesRef.current = []; rsiSeriesRef.current = []; - kdjSeriesRef.current = []; }; }, []); @@ -222,25 +223,6 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) { ); rsiSeriesRef.current.push(s); } - - // --- KDJ(pane4)--- - removeGroup(kdjSeriesRef.current, chart); - if (indicators.kdj && bars.length > 0) { - const { k, d, j } = calcKdj(bars); - const mk = (color: string) => - chart.addSeries( - LineSeries, - { color, lineWidth: 1, priceLineVisible: false, lastValueVisible: false }, - 4, - ); - const kS = mk(KDJ_K); - const dS = mk(KDJ_D); - const jS = mk(KDJ_J); - kS.setData(bars.map((_, i) => ({ time: bars[i].time as Time, value: k[i] })).filter((x) => x.value != null) as { time: Time; value: number }[]); - dS.setData(bars.map((_, i) => ({ time: bars[i].time as Time, value: d[i] })).filter((x) => x.value != null) as { time: Time; value: number }[]); - jS.setData(bars.map((_, i) => ({ time: bars[i].time as Time, value: j[i] })).filter((x) => x.value != null) as { time: Time; value: number }[]); - kdjSeriesRef.current.push(kS, dS, jS); - } } // 模式切换:重建价格 series + 指标 @@ -309,7 +291,7 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) { data.map((d) => ({ time: d.time as Time, value: d.volume, - color: d.close >= d.open ? UP : DOWN, + color: d.close >= d.open ? VOL_UP : VOL_DOWN, })), ); @@ -340,9 +322,6 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) { legend.push({ color: MACD_DIF, label: "DIF" }, { color: MACD_DEA, label: "DEA" }); } if (indicators.rsi) legend.push({ color: RSI_COLOR, label: "RSI14" }); - if (indicators.kdj) { - legend.push({ color: KDJ_K, label: "K" }, { color: KDJ_D, label: "D" }, { color: KDJ_J, label: "J" }); - } return (
diff --git a/src/routes/stock.$code.tsx b/src/routes/stock.$code.tsx index 2a04dd3..249bcdd 100755 --- a/src/routes/stock.$code.tsx +++ b/src/routes/stock.$code.tsx @@ -1,7 +1,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { useState, useEffect, useMemo, Fragment } 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 } 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, fetchStockHistoryMinute } from "@/lib/stock-api"; import { fetchStockHistoryV2 } from "@/lib/fuyao-api"; import StockProfileTabs from "@/components/stock-profile-tabs"; import { getUserId } from "@/lib/user-id"; @@ -37,6 +37,15 @@ 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; @@ -87,7 +96,12 @@ function StockDetail() { const [dailyTableDays, setDailyTableDays] = useState(7); const [chartRange, setChartRange] = useState(90); // 默认近3月 const [chartMode, setChartMode] = useState<"line" | "candle">("candle"); // 折线/蜡烛,默认蜡烛 - const [indicators, setIndicators] = useState({ ma: true, macd: false, rsi: false, kdj: false }); + // 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([]); + 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); @@ -103,7 +117,7 @@ function StockDetail() { useEffect(() => { loadStockData(); - }, [code]); + }, [code, chartPeriod]); // 加载公司概况、经营分析、财务分析(并行后置加载) useEffect(() => { @@ -124,7 +138,9 @@ function StockDetail() { setLoading(true); setError(null); setChartData([]); + setMinuteData([]); setChartLoading(false); + setMinuteLoading(false); setFundFlowData([]); setFundFlowSummary(null); setFundFlowLoading(false); @@ -177,46 +193,76 @@ function StockDetail() { // 基础信息已就绪,结束主loading,先渲染页面框架 setLoading(false); - // 阶段2:并行加载历史K线 + 资金流向(后置加载,不阻塞首屏) + // 阶段2:根据周期加载不同的K线数据(后置加载,不阻塞首屏) const addedDate = new Date(addedAt); - // 一次性拉取最多 1 年(365天)K线,展示范围由 chartRange 前端切片 - const chartDays = 365; - setChartLoading(true); - setFundFlowLoading(true); + if (chartPeriod === "d") { + // 日线:获取完整数据用于展示切片 + setChartLoading(true); + setFundFlowLoading(true); - const [historyResult, fundFlowResult] = await Promise.allSettled([ - fetchStockHistoryV2(code, chartDays), - fetchStockFundFlow(code, quote.name, 21), - ]); + 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 }; + // 处理历史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线数据失败,请稍后重试"); } - setChartData(historyData); - if (historyResult.value.length < 2) { - console.warn("历史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 : "未知错误"); } } else { - console.error("无法获取历史K线数据"); - setError("获取历史K线数据失败,请稍后重试"); + // 分钟/小时线:独立加载,不影响日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); } - // 处理资金流向(非必需,失败不影响主流程) - 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 : "加载股票数据失败"; @@ -224,6 +270,7 @@ function StockDetail() { } finally { setLoading(false); setChartLoading(false); + setMinuteLoading(false); setFundFlowLoading(false); } }; @@ -292,12 +339,20 @@ function StockDetail() { return typeof roe === "number" ? roe : null; }, [financialData]); - // 展示数据 = 按 chartRange 切片最近 N 天(chartData 存了全年,这里只取窗口) + // 展示数据:日线时按 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]); + }, [chartData, chartRange, chartPeriod, minuteData]); + + // 当前 loading 状态:日线和分钟线用不同的 loading 标志 + const currentChartLoading = chartPeriod === "d" ? chartLoading : minuteLoading; if (loading) { return ( @@ -527,7 +582,9 @@ function StockDetail() { K线图 - {chartLoading ? "(加载中...)" : `(${displayData.length}个交易日)`} + {currentChartLoading ? "(加载中...)" : ( + chartPeriod === "d" ? `(${displayData.length}个交易日)` : `(${displayData.length}根${PERIOD_LABEL[chartPeriod as "m60" | "m30" | "m15" | "m5" | "m1"] || chartPeriod}K线)` + )}
@@ -545,13 +602,12 @@ function StockDetail() { ))}
- {/* 指标开关:MA / MACD / RSI / KDJ */} + {/* 指标开关:MA / MACD / RSI */}
{([ { key: "ma", label: "MA" }, { key: "macd", label: "MACD" }, { key: "rsi", label: "RSI" }, - { key: "kdj", label: "KDJ" }, ] as const).map((it) => (
- {/* 时间范围:3月 / 6月 / 1年 */} + {/* K线周期:日 / 60分 / 30分 / 15分 / 5分 / 1分 */}
- {[ - { key: 90, label: "3月" }, - { key: 180, label: "6月" }, - { key: 365, label: "1年" }, - ].map((r) => ( + {([ + { 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) => ( ))}
+ {/* 时间范围:3月 / 6月 / 1年(仅日线显示) */} + {chartPeriod === "d" && ( +
+ {[ + { key: 90, label: "3月" }, + { key: 180, label: "6月" }, + { key: 365, label: "1年" }, + ].map((r) => ( + + ))} +
+ )} 双指缩放 · 拖动查看 @@ -589,7 +668,7 @@ function StockDetail() {
- {chartLoading ? ( + {currentChartLoading ? (
@@ -600,7 +679,9 @@ function StockDetail() { <> ({ - time: d.dateObj.toISOString().slice(0, 10), + 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,