From b1f216b2a49ab82dd1168a48b104a511fdab6469 Mon Sep 17 00:00:00 2001 From: Sakurasan <26715255+Sakurasan@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:43:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20K=E7=BA=BF=E5=A2=9E=E5=8A=A0=E5=88=86?= =?UTF-8?q?=E9=92=9F/=E5=B0=8F=E6=97=B6=E5=91=A8=E6=9C=9F=E5=88=87?= =?UTF-8?q?=E6=8D=A2=EF=BC=8C=E6=8B=86=E5=88=86=E7=8B=AC=E7=AB=8BKLineCard?= =?UTF-8?q?=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端:腾讯 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 --- backend/routes/stock.py | 17 ++ backend/services/tencent.py | 58 +++++++ src/components/kline-card.tsx | 243 ++++++++++++++++++++++++++++ src/components/kline-chart.tsx | 2 +- src/lib/indicators.ts | 2 +- src/lib/stock-api.ts | 41 +++++ src/routes/stock.$code.tsx | 280 ++++----------------------------- vite.config.ts | 6 + 8 files changed, 396 insertions(+), 253 deletions(-) create mode 100644 src/components/kline-card.tsx diff --git a/backend/routes/stock.py b/backend/routes/stock.py index e427948..4fed2e5 100644 --- a/backend/routes/stock.py +++ b/backend/routes/stock.py @@ -84,6 +84,23 @@ async def stock_history( raise HTTPException(status_code=404, detail="未获取到K线数据") +@router.get("/history-minute", summary="分钟/小时级K线") +async def stock_history_minute( + code: str = Query(..., description="6位股票代码"), + period: str = Query("60", description="周期:m1/m5/m15/m30/m60(60=小时线)"), + count: int = Query(320, description="K线数量"), +): + if not re.match(r"^\d{6}$", code): + raise HTTPException(status_code=400, detail="股票代码格式错误,需为6位数字") + if period not in ("m1", "m5", "m15", "m30", "m60"): + raise HTTPException(status_code=400, detail="period 仅支持 m1/m5/m15/m30/m60") + + klines = await tencent.fetch_history_minute(code, period, count) + if not klines: + raise HTTPException(status_code=404, detail="未获取到分钟K线数据") + return {"data": klines, "count": len(klines), "source": "tencent-minute"} + + @router.get("/profile", response_model=dict, summary="公司概况") async def company_profile(code: str = Query(..., description="6位股票代码")): """获取东方财富F10公司概况数据""" diff --git a/backend/services/tencent.py b/backend/services/tencent.py index 615028a..22d651b 100644 --- a/backend/services/tencent.py +++ b/backend/services/tencent.py @@ -257,6 +257,64 @@ async def fetch_history(code: str, days: int = 90) -> List[dict]: return [] +async def fetch_history_minute(code: str, period: str = "60", count: int = 320) -> List[dict]: + """获取分钟级 K 线(腾讯 mkline 接口) + + period: m1/m5/m15/m30/m60(60=小时线) + 返回格式与日K一致,date 为 'YYYY-MM-DD HH:MM'(北京时间)。 + """ + market = get_market_prefix(code) + stock_code = f"{market}{code}" + # 腾讯 mkline:param=代码,周期,,数量 + url = f"https://ifzq.gtimg.cn/appstock/app/kline/mkline?param={stock_code},{period},,{count}" + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Referer": "https://stockapp.finance.qq.com/", + } + + async with httpx.AsyncClient() as client: + try: + resp = await client.get(url, headers=headers, timeout=10) + if resp.status_code != 200: + return [] + data = resp.json() + stock_data = data.get("data", {}).get(stock_code, {}) + raw = stock_data.get(period) or [] + if not isinstance(raw, list): + return [] + result = [] + prev_close = 0 + for record in raw: + if not isinstance(record, list) or len(record) < 6: + continue + # record[0] = 'YYYYMMDDHHMM',转成 'YYYY-MM-DD HH:MM' + raw_dt = str(record[0]) + try: + dt_str = f"{raw_dt[0:4]}-{raw_dt[4:6]}-{raw_dt[6:8]} {raw_dt[8:10]}:{raw_dt[10:12]}" + except Exception: + continue + close = float(record[2]) if record[2] else 0 + change_pct = 0 + if prev_close > 0: + change_pct = (close - prev_close) / prev_close * 100 + result.append({ + "date": dt_str, + "open": float(record[1]) if record[1] else 0, + "close": close, + "high": float(record[3]) if record[3] else 0, + "low": float(record[4]) if record[4] else 0, + "volume": int(float(record[5])) if record[5] else 0, + "changePercent": round(change_pct, 2), + }) + prev_close = close + # 丢弃最旧1条(prev_close=0 涨跌幅失真) + if len(result) > 1: + result = result[1:] + return result + except Exception: + return [] + + async def fetch_kline_map(code: str, days: int = 30) -> dict: """获取K线数据并返回 { date: { close, changePercent, turnover } } 映射""" market = get_market_prefix(code) diff --git a/src/components/kline-card.tsx b/src/components/kline-card.tsx new file mode 100644 index 0000000..b184948 --- /dev/null +++ b/src/components/kline-card.tsx @@ -0,0 +1,243 @@ +/** + * K 线图卡片:独立组件,自带周期(日/60分/...)、显示方式(折线/蜡烛)、 + * 指标开关(MA/MACD/RSI)状态与数据加载逻辑,与页面其他模块解耦。 + * 行情就绪(stockInfo 就绪)后自动拉取;切换周期只重拉 K 线,不影响页面其它数据。 + */ +import { useEffect, useMemo, useState } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import KLineChart from "@/components/kline-chart"; +import { + fetchStockHistoryV2, +} from "@/lib/fuyao-api"; +import { fetchStockHistoryMinute } from "@/lib/stock-api"; + +type ChartPeriod = "d" | "m60" | "m30" | "m15" | "m5" | "m1"; + +// 分钟周期标签映射 +const PERIOD_LABEL: Record<"m1" | "m5" | "m15" | "m30" | "m60", string> = { + m1: "1分", + m5: "5分", + m15: "15分", + m30: "30分", + m60: "60分", +}; + +interface KBarInput { + date: string; + dateMs: number; // Unix 秒(lightweight-charts UTCTimestamp) + open: number; + close: number; + high: number; + low: number; + volume: number; + isAddedDate: boolean; +} + +interface Props { + code: string; + addedAt: string; // 自选日期 ISO,用于标记K线 + /** 行情是否就绪:就绪后才开始拉 K 线 */ + ready: boolean; +} + +export function KLineCard({ code, addedAt, ready }: Props) { + const [chartPeriod, setChartPeriod] = useState("d"); + const [chartMode, setChartMode] = useState<"line" | "candle">("candle"); + const [indicators, setIndicators] = useState({ ma: true, macd: false, rsi: false }); + const toggleIndicator = (key: keyof typeof indicators) => + setIndicators((prev) => ({ ...prev, [key]: !prev[key] })); + + const [dailyData, setDailyData] = useState([]); + const [minuteData, setMinuteData] = useState([]); + const [chartLoading, setChartLoading] = useState(false); + const [minuteLoading, setMinuteLoading] = useState(false); + + const toggleMs = useMemo(() => new Date(addedAt).getTime(), [addedAt]); + + // KLineData → KBarInput(含 isAddedDate 标记,非交易日标最近一根) + // 时间统一按 UTC 解析(wall-clock 视作 UTC),lightweight-charts 以 UTC 渲染, + // 这样日线标签不偏移、分钟线显示的正是北京时间。 + const toBars = ( + klines: Array<{ date: string; open: number; close: number; high: number; low: number; volume: number }>, + ): KBarInput[] => { + const bars: KBarInput[] = klines.map((k) => { + const iso = k.date.includes(" ") + ? k.date.replace(" ", "T") + ":00Z" // "2026-05-08 13:30" → 2026-05-08T13:30:00Z + : k.date + "T00:00:00Z"; // "2026-05-08" → 2026-05-08T00:00:00Z + const ms = new Date(iso).getTime(); + const dateMs = Number.isFinite(ms) ? Math.floor(ms / 1000) : 0; + return { + date: k.date, + dateMs, + open: k.open, + close: k.close, + high: k.high, + low: k.low, + volume: k.volume, + isAddedDate: new Date(ms).toDateString() === new Date(toggleMs).toDateString(), + }; + }); + if (!bars.some((b) => b.isAddedDate) && bars.length > 0) { + let closestIdx = 0; + let closestDiff = Infinity; + bars.forEach((b, i) => { + const diff = Math.abs(b.dateMs * 1000 - toggleMs); + if (diff < closestDiff) { + closestDiff = diff; + closestIdx = i; + } + }); + bars[closestIdx] = { ...bars[closestIdx], isAddedDate: true }; + } + return bars; + }; + + // 拉日K + const loadDaily = async () => { + setChartLoading(true); + try { + const data = await fetchStockHistoryV2(code, 365); + if (data && data.length > 0) setDailyData(toBars(data)); + } catch (err) { + console.error("[kline-card] 拉取日K失败:", err); + } finally { + setChartLoading(false); + } + }; + + // 拉分钟/小时K + const loadMinute = async (period: Exclude) => { + setMinuteLoading(true); + try { + const data = await fetchStockHistoryMinute(code, period, 320); + if (data && data.length > 0) setMinuteData(toBars(data)); + else setMinuteData([]); + } catch (err) { + console.error("[kline-card] 拉取分钟K失败:", err); + setMinuteData([]); + } finally { + setMinuteLoading(false); + } + }; + + // 行情就绪后拉当前周期的K线;切换周期只重拉K线,不动页面其它数据 + useEffect(() => { + if (!ready) return; + if (chartPeriod === "d") { + if (dailyData.length > 0) return; // 已加载过,复用 + loadDaily(); + } else { + setMinuteData([]); // 清空上一个周期旧数据,避免串显 + loadMinute(chartPeriod); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chartPeriod, ready, code]); + + // 展示数据:日线直接用,分钟线用 minuteData + const displayData = chartPeriod === "d" ? dailyData : minuteData; + const currentLoading = chartPeriod === "d" ? chartLoading : minuteLoading; + + return ( + + +
+ + K线图 + + {currentLoading ? "(加载中...)" : ( + chartPeriod === "d" + ? `(${displayData.length}个交易日)` + : `(${displayData.length}根${PERIOD_LABEL[chartPeriod]}K线)` + )} + + +
+ {/* 显示方式切换:折线 / 蜡烛 */} +
+ {(["line", "candle"] as const).map((m) => ( + + ))} +
+ {/* 指标开关:MA / MACD / RSI */} +
+ {([ + { key: "ma", label: "MA" }, + { key: "macd", label: "MACD" }, + { key: "rsi", label: "RSI" }, + ] as const).map((it) => ( + + ))} +
+ {/* K线周期:日 / 60分 / 30分 / 15分 / 5分 / 1分 */} +
+ {([ + { 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) => ( + + ))} +
+ + 双指缩放 · 拖动查看 + +
+
+
+ + {currentLoading ? ( +
+
+
+ K线数据加载中... +
+
+ ) : ( + ({ + time: d.dateMs, + open: d.open, + close: d.close, + high: d.high, + low: d.low, + volume: d.volume, + isAddedDate: d.isAddedDate, + }))} + mode={chartMode} + hasAddedDate={ready} + indicators={indicators} + /> + )} +
+
+ ); +} + +export default KLineCard; diff --git a/src/components/kline-chart.tsx b/src/components/kline-chart.tsx index b77b4bf..3ef91cd 100644 --- a/src/components/kline-chart.tsx +++ b/src/components/kline-chart.tsx @@ -21,7 +21,7 @@ import { import { sma, macd as calcMacd, rsi as calcRsi, type KBar } from "@/lib/indicators"; export interface KLineItem { - time: string; // ISO 日期 yyyy-mm-dd(lightweight-charts 必需) + time: string | number; // ISO 日期 yyyy-mm-dd 或 Unix seconds UTC(lightweight-charts 两者均支持) open: number; close: number; high: number; diff --git a/src/lib/indicators.ts b/src/lib/indicators.ts index fe0ecc8..62bdd15 100644 --- a/src/lib/indicators.ts +++ b/src/lib/indicators.ts @@ -4,7 +4,7 @@ */ export interface KBar { - time: string; + time: string | number; // yyyy-mm-dd 或 Unix 秒(分钟K线用数字时间) open: number; close: number; high: number; diff --git a/src/lib/stock-api.ts b/src/lib/stock-api.ts index 0d3825a..ec6a7bf 100755 --- a/src/lib/stock-api.ts +++ b/src/lib/stock-api.ts @@ -181,6 +181,47 @@ export async function fetchStockQuote(code: string): Promise } } +/** + * 获取股票分钟/小时级K线数据 + * @param code 股票代码(6位) + * @param period 周期:m1/m5/m15/m30/m60(60=小时线) + * @param count K线数量(默认320) + */ +export async function fetchStockHistoryMinute( + code: string, + period: "m1" | "m5" | "m15" | "m30" | "m60" = "m60", + count: number = 320, +): Promise { + if (!/^\d{6}$/.test(code)) { + throw new Error("股票代码格式错误,需为6位数字"); + } + + const baseUrl = getApiBaseUrl(); + const url = `${baseUrl}/api/stock/history-minute?code=${code}&period=${period}&count=${count}`; + + try { + const resp = await fetch(url, { method: "GET" }); + if (!resp.ok) { + let errorMsg = `请求失败 (${resp.status})`; + try { + const errData = await resp.json(); + errorMsg = errData.detail || errorMsg; + } catch { + // 忽略 JSON 解析错误 + } + throw new Error(errorMsg); + } + const result = await resp.json(); + if (!result.data || !Array.isArray(result.data)) { + throw new Error(result.detail || "未获取到分钟K线数据"); + } + return result.data as KLineData[]; + } catch (err) { + console.error("[stock-api] 获取分钟K线数据失败:", err); + return []; + } +} + /** * 获取股票历史K线数据(通过Edge Function代理腾讯财经API) * @param code 股票代码(6位) diff --git a/src/routes/stock.$code.tsx b/src/routes/stock.$code.tsx index 249bcdd..32c2e27 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 { 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(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([]); - 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(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 (
@@ -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() { - {/* Chart */} - - -
- - K线图 - - {currentChartLoading ? "(加载中...)" : ( - chartPeriod === "d" ? `(${displayData.length}个交易日)` : `(${displayData.length}根${PERIOD_LABEL[chartPeriod as "m60" | "m30" | "m15" | "m5" | "m1"] || chartPeriod}K线)` - )} - - -
- {/* 显示方式切换:折线 / 蜡烛 */} -
- {(["line", "candle"] as const).map((m) => ( - - ))} -
- {/* 指标开关:MA / MACD / RSI */} -
- {([ - { key: "ma", label: "MA" }, - { key: "macd", label: "MACD" }, - { key: "rsi", label: "RSI" }, - ] as const).map((it) => ( - - ))} -
- {/* K线周期:日 / 60分 / 30分 / 15分 / 5分 / 1分 */} -
- {([ - { 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) => ( - - ))} -
- )} - - 双指缩放 · 拖动查看 - -
-
-
- - {currentChartLoading ? ( -
-
-
- K线数据加载中... -
-
- ) : ( - <> - ({ - 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} - /> - - )} -
-
+ {/* Chart - 独立K线卡片组件(周期/显示方式/指标状态与数据加载均自包含) */} + {/* Price Details */}
diff --git a/vite.config.ts b/vite.config.ts index 1f9d838..c96474c 100755 --- a/vite.config.ts +++ b/vite.config.ts @@ -52,6 +52,12 @@ export default defineConfig({ entryFileNames: "assets/[name]-[hash].js", chunkFileNames: "assets/[name]-[hash].js", assetFileNames: "assets/[name]-[hash][extname]", + manualChunks(id) { + // 把常用大库拆到独立 chunk,减少主 chunk 体积 + if (id.includes("node_modules/recharts")) return "recharts"; + if (id.includes("node_modules/lightweight-charts")) return "lightweight-charts"; + if (id.includes("node_modules/@tanstack/react-router")) return "router"; + }, }, }, },