From 25c179dc06a1f0437c1360ead125a75284a3b138 Mon Sep 17 00:00:00 2001 From: Sakurasan <26715255+Sakurasan@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:15:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=AF=A6=E6=83=85=E9=A1=B5K=E7=BA=BF?= =?UTF-8?q?=E6=94=B9=E7=94=A8=20TradingView=20Lightweight=20Charts?= =?UTF-8?q?=EF=BC=8C=E8=9C=A1=E7=83=9B=E5=9B=BE=E5=8E=9F=E7=94=9F=E6=94=AF?= =?UTF-8?q?=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 KLineChart 组件(lightweight-charts v5):原生蜡烛图/折线切换、成交量副图(涨红跌绿)、自选日标记、双指缩放拖动 - 保留时间范围切换(3月/6月/1年,默认3月,一次性拉取365天前端切片) - 修复 lightweight-charts 无法解析 CSS 变量/oklch 颜色的问题(改用固定 hex) - 替换原 recharts K线(recharts Bar 自定义 shape 无法渲染蜡烛的问题彻底解决) --- package.json | 1 + pnpm-lock.yaml | 15 +++ src/components/kline-chart.tsx | 185 +++++++++++++++++++++++++++++++++ src/routes/stock.$code.tsx | 178 ++++++------------------------- 4 files changed, 231 insertions(+), 148 deletions(-) create mode 100644 src/components/kline-chart.tsx diff --git a/package.json b/package.json index 48c0e9e..61282e2 100755 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "embla-carousel-react": "^8.6.0", "framer-motion": "^11.18.2", "input-otp": "^1.4.2", + "lightweight-charts": "^5.2.1", "lucide-react": "^0.575.0", "react": "^19.2.7", "react-day-picker": "^9.14.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b714679..2f1e7fa 100755 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -125,6 +125,9 @@ importers: input-otp: specifier: ^1.4.2 version: 1.5.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + lightweight-charts: + specifier: ^5.2.1 + version: 5.2.1 lucide-react: specifier: ^0.575.0 version: 0.575.0(react@19.2.7) @@ -1616,6 +1619,9 @@ packages: eventemitter3@4.0.1: resolution: {integrity: sha512-MnI0l35oYL2C/c80rjJN7qu50MDx39yYE7y7oYck2YA3v+y7EaAenY8IU8AP4d1RWqE8VAKWFGSh3rfP87ll3g==} + fancy-canvas@2.1.0: + resolution: {integrity: sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==} + fast-equals@5.4.1: resolution: {integrity: sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==} engines: {node: '>=6.0.0'} @@ -1767,6 +1773,9 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + lightweight-charts@5.2.1: + resolution: {integrity: sha512-IVwoK1RLFiLPubaKIjNbtjWLnpPMqiABSrTay6whmNa8L1+19292VtHJ+BWyPUuLCwF0tcQlhEWd1CLB2a1nsQ==} + lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} @@ -3498,6 +3507,8 @@ snapshots: eventemitter3@4.0.1: {} + fancy-canvas@2.1.0: {} + fast-equals@5.4.1: {} fdir@6.5.0(picomatch@4.0.5): @@ -3590,6 +3601,10 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + lightweight-charts@5.2.1: + dependencies: + fancy-canvas: 2.1.0 + lodash@4.18.1: {} loose-envify@1.4.0: diff --git a/src/components/kline-chart.tsx b/src/components/kline-chart.tsx new file mode 100644 index 0000000..77957cb --- /dev/null +++ b/src/components/kline-chart.tsx @@ -0,0 +1,185 @@ +/** + * K 线图组件(基于 TradingView Lightweight Charts v5) + * 支持折线/蜡烛切换、成交量副图、自选日标记。 + */ +import { useEffect, useRef } from "react"; +import { + createChart, + CandlestickSeries, + LineSeries, + HistogramSeries, + ColorType, + CrosshairMode, + LineStyle, + createSeriesMarkers, + type IChartApi, + type ISeriesApi, + type Time, +} from "lightweight-charts"; + +export interface KLineItem { + time: string; // ISO 日期 yyyy-mm-dd(lightweight-charts 必需) + open: number; + close: number; + high: number; + low: number; + volume: number; + isAddedDate?: boolean; +} + +interface Props { + data: KLineItem[]; + mode: "line" | "candle"; + hasAddedDate?: boolean; // 是否在自选集合中(决定是否显示标记) +} + +// lightweight-charts 无法解析 CSS 变量或 oklch() 颜色,直接用具体 hex 色。 +// 涨红跌绿 + 中性灰边框,亮色模式为主(暗色下亦可读)。 +const UP = "#ef4444"; +const DOWN = "#22c55e"; +const PRIMARY = "#ef4444"; +const TEXT = "#71717a"; // muted-foreground 近似灰 +const GRID = "#e4e4e7"; // border 近似灰 +const MARKER = "#f59e0b"; // 自选日标记 + +export function KLineChart({ data, mode, hasAddedDate }: Props) { + const containerRef = useRef(null); + const chartRef = useRef(null); + const priceSeriesRef = useRef | null>(null); + const volSeriesRef = useRef | null>(null); + + // 创建图表(仅一次) + useEffect(() => { + if (!containerRef.current) return; + const chart = createChart(containerRef.current, { + autoSize: true, + layout: { + background: { type: ColorType.Solid, color: "transparent" }, + textColor: TEXT, + fontSize: 10, + }, + grid: { + vertLines: { color: GRID, style: LineStyle.Dashed, visible: true }, + horzLines: { color: GRID, style: LineStyle.Dashed, visible: true }, + }, + rightPriceScale: { borderColor: GRID }, + timeScale: { borderColor: GRID, timeVisible: false }, + crosshair: { mode: CrosshairMode.Normal }, + }); + chartRef.current = chart; + + // 成交量副图(pane 1) + const vol = chart.addSeries(HistogramSeries, { + priceFormat: { type: "volume" }, + priceScaleId: "vol", + }); + vol.priceScale().applyOptions({ + scaleMargins: { top: 0.8, bottom: 0 }, + }); + volSeriesRef.current = vol; + + return () => { + chart.remove(); + chartRef.current = null; + priceSeriesRef.current = null; + volSeriesRef.current = null; + }; + }, []); + + // 模式切换:重建价格 series + useEffect(() => { + const chart = chartRef.current; + if (!chart) return; + // 移除旧的价格 series + if (priceSeriesRef.current) { + chart.removeSeries(priceSeriesRef.current); + priceSeriesRef.current = null; + } + if (mode === "candle") { + priceSeriesRef.current = chart.addSeries(CandlestickSeries, { + upColor: UP, + downColor: DOWN, + borderUpColor: UP, + borderDownColor: DOWN, + wickUpColor: UP, + wickDownColor: DOWN, + }); + } else { + priceSeriesRef.current = chart.addSeries(LineSeries, { + color: PRIMARY, + lineWidth: 2, + }); + } + // 数据变更后重新填充 + fillData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mode]); + + // 数据更新 + useEffect(() => { + fillData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data, hasAddedDate]); + + function fillData() { + const chart = chartRef.current; + const ps = priceSeriesRef.current; + const vs = volSeriesRef.current; + if (!chart || !ps || !vs) return; + + if (data.length === 0) { + ps.setData([]); + vs.setData([]); + return; + } + + // 蜡烛/折线数据 + if (mode === "candle") { + (ps as ISeriesApi<"Candlestick">).setData( + data.map((d) => ({ + time: d.time as Time, + open: d.open, + high: d.high, + low: d.low, + close: d.close, + })), + ); + } else { + (ps as ISeriesApi<"Line">).setData( + data.map((d) => ({ time: d.time as Time, value: d.close })), + ); + } + + // 成交量柱(涨红跌绿,按当日开收) + vs.setData( + data.map((d) => ({ + time: d.time as Time, + value: d.volume, + color: d.close >= d.open ? UP : DOWN, + })), + ); + + // 自选日标记 + const markerItem = hasAddedDate ? data.find((d) => d.isAddedDate) : undefined; + if (markerItem) { + createSeriesMarkers(ps, [ + { + time: markerItem.time as Time, + position: "aboveBar", + color: MARKER, + shape: "circle", + text: "自选", + }, + ]); + } else { + createSeriesMarkers(ps, []); + } + + // 自适应可见范围 + chart.timeScale().fitContent(); + } + + return
; +} + +export default KLineChart; diff --git a/src/routes/stock.$code.tsx b/src/routes/stock.$code.tsx index 7a72c88..d610d4d 100755 --- a/src/routes/stock.$code.tsx +++ b/src/routes/stock.$code.tsx @@ -11,6 +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 { ComposedChart, Line, @@ -21,8 +22,6 @@ import { ResponsiveContainer, Bar, Cell, - Scatter, - Brush, ReferenceLine, PieChart, Pie, @@ -87,6 +86,7 @@ function StockDetail() { const [fundFlowPeriod, setFundFlowPeriod] = useState(21); const [dailyTableDays, setDailyTableDays] = useState(7); const [chartRange, setChartRange] = useState(90); // 默认近3月 + const [chartMode, setChartMode] = useState<"line" | "candle">("candle"); // 折线/蜡烛,默认蜡烛 const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [inCollection, setInCollection] = useState(false); @@ -528,6 +528,20 @@ function StockDetail() {
+ {/* 显示方式切换:折线 / 蜡烛 */} +
+ {(["line", "candle"] as const).map((m) => ( + + ))} +
{/* 时间范围:3月 / 6月 / 1年 */}
{[ @@ -547,7 +561,7 @@ function StockDetail() { ))}
- 双指缩放 · 拖动选区查看 + 双指缩放 · 拖动查看
@@ -562,151 +576,19 @@ function StockDetail() { ) : ( <> -
- - - - { - const { x, y, payload } = props; - const dataPoint = displayData.find(d => d.date === payload.value); - const isAddedDate = inCollection && dataPoint?.isAddedDate; - - return ( - - {payload.value} - - ); - }} - /> - - - `日期: ${label}`} - formatter={(value: any, name: string) => { - if (name === 'close') return [`¥${Number(value).toFixed(2)}`, '收盘价']; - if (name === 'volume') return [Number(value).toLocaleString(), '成交量']; - return [value, name]; - }} - /> - - - {/* 高亮添加日期的K线点 */} - { - const { cx, cy, payload } = props; - if (!payload.isAddedDate) { - return ; - } - return ( - - ); - }} - /> - {/* 添加日期参考线 */} - {addedDataPoint && ( - - )} - {/* 缩放/滑动控件:移动端可双指缩放,拖动选区查看局部 */} - - - -
- - {/* Legend */} -
-
-
- 收盘价 -
-
-
- 成交量 -
- {inCollection && ( -
-
- 自选日期 -
- )} -
+ ({ + time: d.dateObj.toISOString().slice(0, 10), + open: d.open, + close: d.close, + high: d.high, + low: d.low, + volume: d.volume, + isAddedDate: d.isAddedDate, + }))} + mode={chartMode} + hasAddedDate={inCollection} + /> )}