feat: 详情页K线改用 TradingView Lightweight Charts,蜡烛图原生支持

- 新增 KLineChart 组件(lightweight-charts v5):原生蜡烛图/折线切换、成交量副图(涨红跌绿)、自选日标记、双指缩放拖动
- 保留时间范围切换(3月/6月/1年,默认3月,一次性拉取365天前端切片)
- 修复 lightweight-charts 无法解析 CSS 变量/oklch 颜色的问题(改用固定 hex)
- 替换原 recharts K线(recharts Bar 自定义 shape 无法渲染蜡烛的问题彻底解决)
This commit is contained in:
Sakurasan
2026-08-28 11:15:48 +08:00
parent 24c8b31301
commit 25c179dc06
4 changed files with 231 additions and 148 deletions
+185
View File
@@ -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<HTMLDivElement>(null);
const chartRef = useRef<IChartApi | null>(null);
const priceSeriesRef = useRef<ISeriesApi<"Candlestick" | "Line"> | null>(null);
const volSeriesRef = useRef<ISeriesApi<"Histogram"> | 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 <div ref={containerRef} className="h-[320px] sm:h-[380px] md:h-[440px] w-full" />;
}
export default KLineChart;
+30 -148
View File
@@ -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<number>(7);
const [chartRange, setChartRange] = useState(90); // 默认近3月
const [chartMode, setChartMode] = useState<"line" | "candle">("candle"); // 折线/蜡烛,默认蜡烛
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [inCollection, setInCollection] = useState(false);
@@ -528,6 +528,20 @@ function StockDetail() {
</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>
{/* 时间范围:3月 / 6月 / 1年 */}
<div className="flex gap-0.5 text-xs border rounded-md overflow-hidden">
{[
@@ -547,7 +561,7 @@ function StockDetail() {
))}
</div>
<span className="text-xs text-muted-foreground hidden sm:inline">
双指缩放 · 拖动选区查看
双指缩放 · 拖动查看
</span>
</div>
</div>
@@ -562,151 +576,19 @@ function StockDetail() {
</div>
) : (
<>
<div
className="h-[320px] sm:h-[380px] md:h-[440px] w-full touch-none select-none"
style={{ touchAction: "none", overscrollBehavior: "none", WebkitOverflowScrolling: "auto" }}
>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={displayData}
margin={{ top: 10, right: 8, left: 0, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" opacity={0.5} />
<XAxis
dataKey="date"
stroke="var(--muted-foreground)"
fontSize={10}
ticks={tickDates}
angle={-45}
textAnchor="end"
height={56}
tick={(props: any) => {
const { x, y, payload } = props;
const dataPoint = displayData.find(d => d.date === payload.value);
const isAddedDate = inCollection && dataPoint?.isAddedDate;
return (
<text
x={x}
y={y}
dy={16}
textAnchor="end"
fill={isAddedDate ? '#f59e0b' : 'var(--muted-foreground)'}
fontWeight={isAddedDate ? 'bold' : 'normal'}
fontSize={isAddedDate ? 11 : 10}
>
{payload.value}
</text>
);
}}
/>
<YAxis
yAxisId="left"
stroke="var(--muted-foreground)"
fontSize={10}
domain={["auto", "auto"]}
tickCount={5}
width={42}
/>
<YAxis
yAxisId="right"
orientation="right"
stroke="var(--muted-foreground)"
fontSize={10}
tickCount={5}
width={42}
/>
<Tooltip
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "8px",
fontSize: "12px",
padding: "8px 12px",
}}
labelFormatter={(label) => `日期: ${label}`}
formatter={(value: any, name: string) => {
if (name === 'close') return [`¥${Number(value).toFixed(2)}`, '收盘价'];
if (name === 'volume') return [Number(value).toLocaleString(), '成交量'];
return [value, name];
}}
/>
<Bar
yAxisId="right"
dataKey="volume"
fill="var(--primary)"
opacity={0.2}
/>
<Line
yAxisId="left"
type="monotone"
dataKey="close"
stroke="var(--primary)"
strokeWidth={2}
dot={false}
/>
{/* 高亮添加日期的K线点 */}
<Scatter
yAxisId="left"
dataKey="close"
fill="#fbbf24"
shape={(props: any) => {
const { cx, cy, payload } = props;
if (!payload.isAddedDate) {
return <circle cx={cx} cy={cy} r={0} fill="transparent" />;
}
return (
<circle
cx={cx}
cy={cy}
r={7}
fill="#fbbf24"
stroke="#f59e0b"
strokeWidth={2.5}
/>
);
}}
/>
{/* 添加日期参考线 */}
{addedDataPoint && (
<ReferenceLine
yAxisId="left"
x={addedDataPoint.date}
stroke="#f59e0b"
strokeDasharray="4 4"
opacity={0.7}
/>
)}
{/* 缩放/滑动控件:移动端可双指缩放,拖动选区查看局部 */}
<Brush
dataKey="date"
height={24}
stroke="var(--primary)"
fill="var(--muted)"
travellerWidth={8}
gap={8}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
{/* Legend */}
<div className="flex flex-wrap justify-center gap-3 md:gap-6 mt-2 md:mt-4 text-xs md:text-sm">
<div className="flex items-center gap-1.5 md:gap-2">
<div className="w-2.5 h-2.5 md:w-3 md:h-3 rounded-full bg-primary"></div>
<span>收盘价</span>
</div>
<div className="flex items-center gap-1.5 md:gap-2">
<div className="w-2.5 h-2.5 md:w-3 md:h-3 rounded-full bg-primary opacity-20"></div>
<span>成交量</span>
</div>
{inCollection && (
<div className="flex items-center gap-1.5 md:gap-2">
<div className="w-2.5 h-2.5 md:w-3 md:h-3 rounded-full" style={{ backgroundColor: '#fbbf24' }}></div>
<span>自选日期</span>
</div>
)}
</div>
<KLineChart
data={displayData.map((d) => ({
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}
/>
</>
)}
</CardContent>