feat: K线图默认展示近3个月,拖动回看更早数据;自定义日期格式;主图加高成交量压低

This commit is contained in:
Sakurasan
2026-09-02 01:13:26 +08:00
parent 9a72f0bbf6
commit 375d196fef
3 changed files with 95 additions and 17 deletions
+3 -2
View File
@@ -55,8 +55,9 @@
- 生成分享链接时复用已有短链,避免每次生成新链接 - 生成分享链接时复用已有短链,避免每次生成新链接
- 全页面适配移动端:响应式字体、间距、布局断点(sm/md/lg) - 全页面适配移动端:响应式字体、间距、布局断点(sm/md/lg)
- K线图历史数据获取失败时直接报错,禁止使用模拟数据降级(避免"刷新数据变化"问题) - K线图历史数据获取失败时直接报错,禁止使用模拟数据降级(避免"刷新数据变化"问题)
- K线天数逻辑:默认3个月(90天),自选日期到现在超过3个月则从自选日期开始 - K线天数逻辑:日K一次拉取近365天(供拖动回看),默认视口只展示最近90个自然日(3个月),向左拖动查看更早数据;分钟K一次拉取320根,铺满展示
- K线图使用 recharts Brush 组件实现移动端缩放和滑动查看 - K线图基于 TradingView Lightweight Charts v5 实现(蜡烛/折线 + MA/MACD/RSI 副图);拖动/缩放查看数据,切换指标或显示方式保留当前视口(仅在数据集变化时重置)
- K线图时间格式自定义:时间轴刻度 年→`YYYY`、月→`YYYY-MM`、日→`MM-DD`,十字光标→`YYYY-MM-DD`(`tickMarkFormatter` + `localization.timeFormatter`);格式化必须用 UTC 取值(`getUTCFullYear` 等),因为时间戳按"北京时间墙钟视作 UTC"存储,用本地时区方法会错位一天
- 板块标记:688开头=科创(红)、300/301开头=创业(紫)、920/8/4开头=北交(橙),主板不显示标签;标记位置:搜索候选、详情页标题、集合卡片股票列表、分享页股票卡片标题 - 板块标记:688开头=科创(红)、300/301开头=创业(紫)、920/8/4开头=北交(橙),主板不显示标签;标记位置:搜索候选、详情页标题、集合卡片股票列表、分享页股票卡片标题
- 详情页右上角外部跳转按钮:①"东方财富" `https://wap.eastmoney.com/quote/stock/{market}.{code}.html?appfenxiang=1`,market映射 688→6/60→1/其他→0;②"金十数据" `https://search.jin10.com/?keyword={股票名称URL编码}`(按名称搜索金十资讯) - 详情页右上角外部跳转按钮:①"东方财富" `https://wap.eastmoney.com/quote/stock/{market}.{code}.html?appfenxiang=1`,market映射 688→6/60→1/其他→0;②"金十数据" `https://search.jin10.com/?keyword={股票名称URL编码}`(按名称搜索金十资讯)
- 详情页资金流向模块:展示近30日资金流向分析,包含: - 详情页资金流向模块:展示近30日资金流向分析,包含:
+19 -10
View File
@@ -137,6 +137,22 @@ export function KLineCard({ code, addedAt, ready }: Props) {
const displayData = chartPeriod === "d" ? dailyData : minuteData; const displayData = chartPeriod === "d" ? dailyData : minuteData;
const currentLoading = chartPeriod === "d" ? chartLoading : minuteLoading; const currentLoading = chartPeriod === "d" ? chartLoading : minuteLoading;
// 传给图表的数据引用需稳定(仅随 displayData 变化),
// 否则切换指标/显示方式时 KLineChart 会把新数组当成新数据集重置视口
const chartData = useMemo(
() =>
displayData.map((d) => ({
time: d.dateMs,
open: d.open,
close: d.close,
high: d.high,
low: d.low,
volume: d.volume,
isAddedDate: d.isAddedDate,
})),
[displayData],
);
return ( return (
<Card className="shadow-lg"> <Card className="shadow-lg">
<CardHeader className="pb-2 md:pb-4 px-3 md:px-6 pt-4 md:pt-6"> <CardHeader className="pb-2 md:pb-4 px-3 md:px-6 pt-4 md:pt-6">
@@ -213,7 +229,7 @@ export function KLineCard({ code, addedAt, ready }: Props) {
</CardHeader> </CardHeader>
<CardContent className="px-2 md:px-6 pb-2 md:pb-6"> <CardContent className="px-2 md:px-6 pb-2 md:pb-6">
{currentLoading ? ( {currentLoading ? (
<div className="h-[320px] sm:h-[380px] md:h-[440px] w-full flex items-center justify-center"> <div className="h-[390px] w-full flex items-center justify-center">
<div className="flex items-center text-muted-foreground text-sm"> <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> <div className="animate-pulse mr-2 h-2 w-2 rounded-full bg-primary"></div>
K线数据加载中... K线数据加载中...
@@ -221,18 +237,11 @@ export function KLineCard({ code, addedAt, ready }: Props) {
</div> </div>
) : ( ) : (
<KLineChart <KLineChart
data={displayData.map((d) => ({ data={chartData}
time: d.dateMs,
open: d.open,
close: d.close,
high: d.high,
low: d.low,
volume: d.volume,
isAddedDate: d.isAddedDate,
}))}
mode={chartMode} mode={chartMode}
hasAddedDate={ready} hasAddedDate={ready}
indicators={indicators} indicators={indicators}
defaultVisibleDays={chartPeriod === "d" ? 90 : undefined}
/> />
)} )}
</CardContent> </CardContent>
+72 -4
View File
@@ -12,6 +12,7 @@ import {
ColorType, ColorType,
CrosshairMode, CrosshairMode,
LineStyle, LineStyle,
TickMarkType,
createSeriesMarkers, createSeriesMarkers,
type IChartApi, type IChartApi,
type ISeriesApi, type ISeriesApi,
@@ -41,6 +42,8 @@ interface Props {
mode: "line" | "candle"; mode: "line" | "candle";
hasAddedDate?: boolean; hasAddedDate?: boolean;
indicators: IndicatorToggles; indicators: IndicatorToggles;
/** 默认只展示最近 N 个自然日(日线用),其余数据靠拖动查看;不传则铺满全部数据 */
defaultVisibleDays?: number;
} }
// lightweight-charts 无法解析 CSS 变量或 oklch() 颜色,直接用具体 hex 色。 // lightweight-charts 无法解析 CSS 变量或 oklch() 颜色,直接用具体 hex 色。
@@ -61,9 +64,41 @@ const MACD_DIF = "#3b82f6";
const MACD_DEA = "#f59e0b"; const MACD_DEA = "#f59e0b";
const RSI_COLOR = "#a855f7"; const RSI_COLOR = "#a855f7";
export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) { // 时间统一按 UTC 取值格式化(kline-card 把北京时间墙钟视作 UTC 存储),
// 用本地时区方法会导致日期错位一天
function timeToDate(t: Time): Date {
if (typeof t === "number") return new Date(t * 1000);
if (typeof t === "string") return new Date(t.length === 10 ? `${t}T00:00:00Z` : t);
return new Date(Date.UTC(t.year, t.month - 1, t.day));
}
const pad2 = (n: number) => String(n).padStart(2, "0");
/** 十字光标处的完整日期:2026-09-01 */
function formatCrosshairTime(t: Time): string {
const d = timeToDate(t);
return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;
}
/** 底部时间轴刻度:年→2026、月→2026-09、日→09-01(替代默认的 "01 9月 '26") */
function formatTickMark(t: Time, tickMarkType: TickMarkType): string {
const d = timeToDate(t);
if (tickMarkType === TickMarkType.Year) return `${d.getUTCFullYear()}`;
if (tickMarkType === TickMarkType.Month) return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}`;
return `${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;
}
// 各 pane 固定高度:主图加高、成交量压低,指标副图居中
const MAIN_PANE_H = 320;
const VOL_PANE_H = 70;
const IND_PANE_H = 95;
export function KLineChart({ data, mode, hasAddedDate, indicators, defaultVisibleDays }: Props) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<IChartApi | null>(null); const chartRef = useRef<IChartApi | null>(null);
// 记录上次填充的数据集:仅数据集变化(首次加载/切换周期)时调整视口,
// 指标开关/显示方式切换时保留用户拖动缩放后的位置
const lastDataRef = useRef<KLineItem[] | null>(null);
const priceSeriesRef = useRef<ISeriesApi<"Candlestick" | "Line"> | null>(null); const priceSeriesRef = useRef<ISeriesApi<"Candlestick" | "Line"> | null>(null);
const volSeriesRef = useRef<ISeriesApi<"Histogram"> | null>(null); const volSeriesRef = useRef<ISeriesApi<"Histogram"> | null>(null);
// 指标 series(重建用) // 指标 series(重建用)
@@ -73,8 +108,15 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
// 容器高度随指标 pane 数量增长,避免主图被压缩 // 容器高度随指标 pane 数量增长,避免主图被压缩
const extraPanes = [indicators.macd, indicators.rsi].filter(Boolean).length; const extraPanes = [indicators.macd, indicators.rsi].filter(Boolean).length;
// 固定图表高度:主图+成交量 300,每个指标副图 +95 // 图表总高 = 各 pane 高度之和(主图 + 成交量 + 指标副图×N)
const chartHeight = 300 + extraPanes * 95; const chartHeight = MAIN_PANE_H + VOL_PANE_H + extraPanes * IND_PANE_H;
// pane 顺序固定:0=主图 1=成交量 2+=指标副图;指标开关增删 pane 后重新应用高度
function applyPaneHeights(chart: IChartApi) {
chart.panes().forEach((pane, i) => {
pane.setHeight(i === 0 ? MAIN_PANE_H : i === 1 ? VOL_PANE_H : IND_PANE_H);
});
}
// 创建图表(仅一次):pane0 主图 + pane1 成交量 // 创建图表(仅一次):pane0 主图 + pane1 成交量
useEffect(() => { useEffect(() => {
@@ -94,7 +136,12 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
horzLines: { color: GRID, style: LineStyle.Dashed, visible: true }, horzLines: { color: GRID, style: LineStyle.Dashed, visible: true },
}, },
rightPriceScale: { borderColor: GRID }, rightPriceScale: { borderColor: GRID },
timeScale: { borderColor: GRID, timeVisible: false }, localization: { timeFormatter: formatCrosshairTime },
timeScale: {
borderColor: GRID,
timeVisible: false,
tickMarkFormatter: formatTickMark,
},
crosshair: { mode: CrosshairMode.Normal }, crosshair: { mode: CrosshairMode.Normal },
}); });
chartRef.current = chart; chartRef.current = chart;
@@ -109,6 +156,7 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
1, 1,
); );
volSeriesRef.current = vol; volSeriesRef.current = vol;
applyPaneHeights(chart);
return () => { return () => {
chart.remove(); chart.remove();
@@ -118,6 +166,8 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
maSeriesRef.current = []; maSeriesRef.current = [];
macdSeriesRef.current = []; macdSeriesRef.current = [];
rsiSeriesRef.current = []; rsiSeriesRef.current = [];
// 图表实例销毁后重置,重建时重新应用默认视口(StrictMode 重挂载同样生效)
lastDataRef.current = null;
}; };
}, []); }, []);
@@ -223,6 +273,8 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
); );
rsiSeriesRef.current.push(s); rsiSeriesRef.current.push(s);
} }
applyPaneHeights(chart);
} }
// 模式切换:重建价格 series + 指标 // 模式切换:重建价格 series + 指标
@@ -310,8 +362,24 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
createSeriesMarkers(ps, []); createSeriesMarkers(ps, []);
} }
if (lastDataRef.current === data) return;
lastDataRef.current = data;
if (defaultVisibleDays) {
// 默认视口只展示最近 N 个自然日,更早的数据靠向左拖动查看
const toMs = (t: string | number) =>
typeof t === "number" ? t * 1000 : new Date(t).getTime();
const lastMs = toMs(data[data.length - 1].time);
let fromIdx = data.findIndex(
(d) => toMs(d.time) >= lastMs - defaultVisibleDays * 86400_000,
);
if (fromIdx < 0) fromIdx = 0;
chart
.timeScale()
.setVisibleLogicalRange({ from: fromIdx, to: data.length - 1 + 2 });
} else {
chart.timeScale().fitContent(); chart.timeScale().fitContent();
} }
}
// 颜色图例:根据启用的指标生成 // 颜色图例:根据启用的指标生成
const legend: { color: string; label: string }[] = []; const legend: { color: string; label: string }[] = [];