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:
@@ -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公司概况数据"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<ChartPeriod>("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<KBarInput[]>([]);
|
||||
const [minuteData, setMinuteData] = useState<KBarInput[]>([]);
|
||||
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<ChartPeriod, "d">) => {
|
||||
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 (
|
||||
<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">
|
||||
{currentLoading ? "(加载中...)" : (
|
||||
chartPeriod === "d"
|
||||
? `(${displayData.length}个交易日)`
|
||||
: `(${displayData.length}根${PERIOD_LABEL[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>
|
||||
<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">
|
||||
{currentLoading ? (
|
||||
<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: 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}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default KLineCard;
|
||||
@@ -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;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
export interface KBar {
|
||||
time: string;
|
||||
time: string | number; // yyyy-mm-dd 或 Unix 秒(分钟K线用数字时间)
|
||||
open: number;
|
||||
close: number;
|
||||
high: number;
|
||||
|
||||
@@ -181,6 +181,47 @@ export async function fetchStockQuote(code: string): Promise<StockQuote | null>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取股票分钟/小时级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<KLineData[]> {
|
||||
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位)
|
||||
|
||||
+29
-251
@@ -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">
|
||||
|
||||
@@ -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";
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user