diff --git a/AGENTS.md b/AGENTS.md index da8dcf7..d0e948c 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,8 +55,9 @@ - 生成分享链接时复用已有短链,避免每次生成新链接 - 全页面适配移动端:响应式字体、间距、布局断点(sm/md/lg) - K线图历史数据获取失败时直接报错,禁止使用模拟数据降级(避免"刷新数据变化"问题) -- K线天数逻辑:默认3个月(90天),自选日期到现在超过3个月则从自选日期开始 -- K线图使用 recharts Brush 组件实现移动端缩放和滑动查看 +- K线天数逻辑:日K一次拉取近365天(供拖动回看),默认视口只展示最近90个自然日(3个月),向左拖动查看更早数据;分钟K一次拉取320根,铺满展示 +- 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开头=北交(橙),主板不显示标签;标记位置:搜索候选、详情页标题、集合卡片股票列表、分享页股票卡片标题 - 详情页右上角外部跳转按钮:①"东方财富" `https://wap.eastmoney.com/quote/stock/{market}.{code}.html?appfenxiang=1`,market映射 688→6/60→1/其他→0;②"金十数据" `https://search.jin10.com/?keyword={股票名称URL编码}`(按名称搜索金十资讯) - 详情页资金流向模块:展示近30日资金流向分析,包含: diff --git a/src/components/kline-card.tsx b/src/components/kline-card.tsx index b184948..7c7485a 100644 --- a/src/components/kline-card.tsx +++ b/src/components/kline-card.tsx @@ -137,6 +137,22 @@ export function KLineCard({ code, addedAt, ready }: Props) { const displayData = chartPeriod === "d" ? dailyData : minuteData; 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 ( @@ -213,7 +229,7 @@ export function KLineCard({ code, addedAt, ready }: Props) { {currentLoading ? ( -
+
K线数据加载中... @@ -221,18 +237,11 @@ export function KLineCard({ code, addedAt, ready }: Props) {
) : ( ({ - time: d.dateMs, - open: d.open, - close: d.close, - high: d.high, - low: d.low, - volume: d.volume, - isAddedDate: d.isAddedDate, - }))} + data={chartData} mode={chartMode} hasAddedDate={ready} indicators={indicators} + defaultVisibleDays={chartPeriod === "d" ? 90 : undefined} /> )} diff --git a/src/components/kline-chart.tsx b/src/components/kline-chart.tsx index 3ef91cd..2efa3b8 100644 --- a/src/components/kline-chart.tsx +++ b/src/components/kline-chart.tsx @@ -12,6 +12,7 @@ import { ColorType, CrosshairMode, LineStyle, + TickMarkType, createSeriesMarkers, type IChartApi, type ISeriesApi, @@ -41,6 +42,8 @@ interface Props { mode: "line" | "candle"; hasAddedDate?: boolean; indicators: IndicatorToggles; + /** 默认只展示最近 N 个自然日(日线用),其余数据靠拖动查看;不传则铺满全部数据 */ + defaultVisibleDays?: number; } // lightweight-charts 无法解析 CSS 变量或 oklch() 颜色,直接用具体 hex 色。 @@ -61,9 +64,41 @@ const MACD_DIF = "#3b82f6"; const MACD_DEA = "#f59e0b"; 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(null); const chartRef = useRef(null); + // 记录上次填充的数据集:仅数据集变化(首次加载/切换周期)时调整视口, + // 指标开关/显示方式切换时保留用户拖动缩放后的位置 + const lastDataRef = useRef(null); const priceSeriesRef = useRef | null>(null); const volSeriesRef = useRef | null>(null); // 指标 series(重建用) @@ -73,8 +108,15 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) { // 容器高度随指标 pane 数量增长,避免主图被压缩 const extraPanes = [indicators.macd, indicators.rsi].filter(Boolean).length; - // 固定图表高度:主图+成交量 300,每个指标副图 +95 - const chartHeight = 300 + extraPanes * 95; + // 图表总高 = 各 pane 高度之和(主图 + 成交量 + 指标副图×N) + 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 成交量 useEffect(() => { @@ -94,7 +136,12 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) { horzLines: { color: GRID, style: LineStyle.Dashed, visible: true }, }, rightPriceScale: { borderColor: GRID }, - timeScale: { borderColor: GRID, timeVisible: false }, + localization: { timeFormatter: formatCrosshairTime }, + timeScale: { + borderColor: GRID, + timeVisible: false, + tickMarkFormatter: formatTickMark, + }, crosshair: { mode: CrosshairMode.Normal }, }); chartRef.current = chart; @@ -109,6 +156,7 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) { 1, ); volSeriesRef.current = vol; + applyPaneHeights(chart); return () => { chart.remove(); @@ -118,6 +166,8 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) { maSeriesRef.current = []; macdSeriesRef.current = []; rsiSeriesRef.current = []; + // 图表实例销毁后重置,重建时重新应用默认视口(StrictMode 重挂载同样生效) + lastDataRef.current = null; }; }, []); @@ -223,6 +273,8 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) { ); rsiSeriesRef.current.push(s); } + + applyPaneHeights(chart); } // 模式切换:重建价格 series + 指标 @@ -310,7 +362,23 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) { createSeriesMarkers(ps, []); } - chart.timeScale().fitContent(); + 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(); + } } // 颜色图例:根据启用的指标生成