Merge PR #45: feat 亮色主题 — 明暗切换 + 全部图表主题化

This commit is contained in:
shy3130
2026-07-03 14:12:13 +08:00
15 changed files with 359 additions and 160 deletions
+14 -1
View File
@@ -1,7 +1,20 @@
<!doctype html>
<html lang="zh-CN" class="dark">
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<script>
// 预渲染主题脚本: 首屏前设置 html.dark, 避免亮暗闪烁 (FOUC)。
// 默认暗色; 用户切过亮色后 (localStorage tf-theme=light) 保持亮色。
(function () {
try {
if (localStorage.getItem('tf-theme') !== 'light') {
document.documentElement.classList.add('dark')
}
} catch (e) {
document.documentElement.classList.add('dark')
}
})()
</script>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="theme-color" content="#8B5CF6" />
+20 -8
View File
@@ -7,6 +7,7 @@ import {
type CandlestickData,
type HistogramData,
} from 'lightweight-charts'
import { useChartTheme } from '@/lib/theme'
export interface OHLC {
date: string
@@ -24,11 +25,9 @@ export function fmtBigNum(v: number): string {
return v.toFixed(0)
}
// 序列颜色 (双主题通用); 画布文字/网格/边框主题相关色走 ChartTheme
const THEME = {
background: 'transparent',
textColor: '#A1A1AA',
gridColor: 'rgba(255,255,255,0.04)',
borderColor: '#27272A',
bull: '#F04438',
bear: '#12B76A',
volBull: 'rgba(240,68,56,0.4)',
@@ -45,6 +44,19 @@ export function CandlestickChart({ data, height = 480 }: Props) {
const chartRef = useRef<IChartApi | null>(null)
const candleRef = useRef<ISeriesApi<'Candlestick'> | null>(null)
const volRef = useRef<ISeriesApi<'Histogram'> | null>(null)
const ct = useChartTheme()
const ctRef = useRef(ct)
ctRef.current = ct
// 主题切换: 无需重建 chart, applyOptions 即时生效
useEffect(() => {
chartRef.current?.applyOptions({
layout: { textColor: ct.text },
grid: { vertLines: { color: ct.grid }, horzLines: { color: ct.grid } },
rightPriceScale: { borderColor: ct.border },
timeScale: { borderColor: ct.border },
})
}, [ct])
useEffect(() => {
if (!containerRef.current) return
@@ -55,22 +67,22 @@ export function CandlestickChart({ data, height = 480 }: Props) {
height,
layout: {
background: { color: THEME.background },
textColor: THEME.textColor,
textColor: ctRef.current.text,
fontFamily: 'JetBrains Mono, monospace',
fontSize: 11,
},
grid: {
vertLines: { color: THEME.gridColor },
horzLines: { color: THEME.gridColor },
vertLines: { color: ctRef.current.grid },
horzLines: { color: ctRef.current.grid },
},
crosshair: {
mode: CrosshairMode.Normal,
vertLine: { labelVisible: false },
horzLine: { labelVisible: false },
},
rightPriceScale: { borderColor: THEME.borderColor },
rightPriceScale: { borderColor: ctRef.current.border },
timeScale: {
borderColor: THEME.borderColor,
borderColor: ctRef.current.border,
timeVisible: false,
secondsVisible: false,
},
+44 -40
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useCallback, useMemo } from 'react'
import { chartTheme, getTheme, useTheme } from '@/lib/theme'
import * as echarts from 'echarts'
import type { ECharts, EChartsOption } from 'echarts'
@@ -293,6 +294,7 @@ interface Props {
activeIndicators?: string[]
}
// 序列颜色 (双主题通用); 画布轴/网格/文字等主题相关色走 CT() 动态取
const THEME = {
bull: '#C74040',
bear: '#2D9B65',
@@ -302,12 +304,12 @@ const THEME = {
ma10: '#3B82F6',
ma20: '#F97316',
ma60: '#8B5CF6',
text: '#A1A1AA',
grid: 'rgba(255,255,255,0.04)',
border: '#27272A',
bg: 'transparent',
}
/** 当前主题的图表调色板 (buildOption/信息栏在渲染时调用; 主题切换由组件 effect 触发重建)。 */
const CT = () => chartTheme(getTheme())
/** 可见蜡烛超过此数量时,涨停/炸板标签切换为小圆点。 */
const COMPACT_THRESHOLD = 60
@@ -429,7 +431,7 @@ function buildOption(
const isSell = m.kind === 'sell'
if (m.above) {
const dotColor = m.color ?? (isBuy ? '#FACC15' : THEME.text)
const dotColor = m.color ?? (isBuy ? '#FACC15' : CT().text)
if (compact) {
markPointData.push({
name: m.date, coord: [m.date, d.high],
@@ -457,11 +459,11 @@ function buildOption(
symbol: 'arrow', symbolSize: 12,
symbolRotate: isBuy ? 0 : 180,
symbolOffset: isBuy ? [0, '60%'] : [0, '-60%'],
itemStyle: { color: isBuy ? THEME.bull : isSell ? THEME.bear : THEME.text },
itemStyle: { color: isBuy ? THEME.bull : isSell ? THEME.bear : CT().text },
label: {
show: !!m.label, formatter: m.label ?? '',
position: isBuy ? 'bottom' : 'top', distance: 8,
color: THEME.text, fontSize: 10,
color: CT().text, fontSize: 10,
fontFamily: 'JetBrains Mono, monospace',
},
})
@@ -497,8 +499,8 @@ function buildOption(
grids.push({ left, right, top: topPad, height: candleAvail })
xAxes.push({
type: 'category', data: dates, boundaryGap: true,
axisLine: { lineStyle: { color: THEME.border } },
axisLabel: { color: THEME.text, fontSize: 10, fontFamily: 'JetBrains Mono, monospace' },
axisLine: { lineStyle: { color: CT().border } },
axisLabel: { color: CT().text, fontSize: 10, fontFamily: 'JetBrains Mono, monospace' },
axisTick: { show: false },
splitLine: { show: false },
})
@@ -508,8 +510,8 @@ function buildOption(
boundaryGap: [0.03, 0.03],
splitArea: { show: false },
axisLine: { show: false }, axisTick: { show: false },
splitLine: { lineStyle: { color: THEME.grid } },
axisLabel: { color: THEME.text, fontSize: 10, fontFamily: 'JetBrains Mono, monospace' },
splitLine: { lineStyle: { color: CT().grid } },
axisLabel: { color: CT().text, fontSize: 10, fontFamily: 'JetBrains Mono, monospace' },
})
xAxisIndices.push(0)
@@ -524,8 +526,8 @@ function buildOption(
show: !!r.label,
position: 'insideTop',
distance: 8,
color: '#DBEAFE',
backgroundColor: 'rgba(15,23,42,0.72)',
color: CT().tooltipText,
backgroundColor: CT().tooltipBg,
borderColor: 'rgba(59,130,246,0.35)',
borderWidth: 1,
borderRadius: 4,
@@ -541,7 +543,7 @@ function buildOption(
.filter(line => Number.isFinite(line.value))
.map(line => {
const lineStyle = {
color: line.color ?? THEME.text,
color: line.color ?? CT().text,
type: 'dashed' as const,
width: 1,
opacity: 0.92,
@@ -550,8 +552,8 @@ function buildOption(
show: !!line.label,
formatter: line.label ?? '',
position: 'insideEndTop' as const,
color: line.color ?? THEME.text,
backgroundColor: 'rgba(15,23,42,0.72)',
color: line.color ?? CT().text,
backgroundColor: CT().tooltipBg,
borderRadius: 4,
padding: [2, 6],
fontSize: 10,
@@ -577,7 +579,7 @@ function buildOption(
color: '#3B82F6',
fontSize: 10,
fontFamily: 'JetBrains Mono, monospace',
backgroundColor: 'rgba(24,24,27,0.85)',
backgroundColor: CT().tooltipBg,
borderColor: '#3B82F6',
borderWidth: 1,
padding: [1, 4],
@@ -642,7 +644,7 @@ function buildOption(
top: chartTop,
height: def.height,
show: true,
borderColor: 'rgba(255,255,255,0.06)',
borderColor: CT().grid,
borderWidth: 1,
})
@@ -660,9 +662,9 @@ function buildOption(
gridIndex: gridIdx,
splitNumber: 2,
axisLine: { show: false }, axisTick: { show: false },
splitLine: { lineStyle: { color: THEME.grid } },
splitLine: { lineStyle: { color: CT().grid } },
axisLabel: {
show: true, color: THEME.text, fontSize: 9,
show: true, color: CT().text, fontSize: 9,
fontFamily: 'JetBrains Mono, monospace',
},
})
@@ -686,7 +688,7 @@ function buildOption(
backgroundColor: THEME.bg,
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross', crossStyle: { color: '#555' } },
axisPointer: { type: 'cross', crossStyle: { color: CT().crosshair } },
backgroundColor: 'transparent',
borderWidth: 0,
textStyle: { fontSize: 0 },
@@ -695,7 +697,7 @@ function buildOption(
axisPointer: {
link: [{ xAxisIndex: 'all' }],
label: {
backgroundColor: '#333',
backgroundColor: CT().crosshairLabelBg,
fontFamily: 'JetBrains Mono, monospace',
fontSize: 10,
},
@@ -742,6 +744,8 @@ export function EChartsCandlestick({
dataRef.current = data
const onDateClickRef = useRef(onDateClick)
onDateClickRef.current = onDateClick
// 主题: buildOption/信息栏内部通过 CT() 动态取调色板, 这里只负责切换时触发重建
const theme = useTheme()
// --- 全部用 ref,避免高频交互触发 React 重渲染 ---
const infoIdxRef = useRef<number>(data.length - 1)
@@ -819,14 +823,14 @@ export function EChartsCandlestick({
const turnoverRate = floatShares && d.volume ? (d.volume * 100 / floatShares * 100) : null
let html = `<div style="display:flex;align-items:center;gap:6px;padding:0 8px;font:11px 'JetBrains Mono',monospace;select:none;height:20px;flex-wrap:wrap">`
html += `<span style="color:${THEME.text}">${d.date}</span>`
html += `<span style="color:${THEME.text}">开</span>`
html += `<span style="color:${CT().text}">${d.date}</span>`
html += `<span style="color:${CT().text}">开</span>`
html += `<span style="color:${d.open >= d.close ? THEME.bear : THEME.bull}">${d.open.toFixed(2)}</span>`
html += `<span style="color:${THEME.text}">高</span>`
html += `<span style="color:${CT().text}">高</span>`
html += `<span style="color:${THEME.bull}">${d.high.toFixed(2)}</span>`
html += `<span style="color:${THEME.text}">低</span>`
html += `<span style="color:${CT().text}">低</span>`
html += `<span style="color:${THEME.bear}">${d.low.toFixed(2)}</span>`
html += `<span style="color:${THEME.text}">收</span>`
html += `<span style="color:${CT().text}">收</span>`
html += `<span style="color:${clr};font-weight:600">${d.close.toFixed(2)}</span>`
// 涨跌幅 (收盘后, 换手前; 和收间隔一些距离)
if (prev) {
@@ -834,8 +838,8 @@ export function EChartsCandlestick({
html += `<span style="color:${clr};margin-left:8px">${isUp ? '+' : ''}${chgPct.toFixed(2)}%</span>`
}
if (turnoverRate != null) {
html += `<span style="color:${THEME.text}">换手</span>`
html += `<span style="color:${THEME.text}">${turnoverRate.toFixed(2)}%</span>`
html += `<span style="color:${CT().text}">换手</span>`
html += `<span style="color:${CT().text}">${turnoverRate.toFixed(2)}%</span>`
}
html += `</div>`
@@ -961,7 +965,7 @@ export function EChartsCandlestick({
const isBuy = m.kind === 'buy'
const isSell = m.kind === 'sell'
if (m.above) {
const dotColor = m.color ?? (isBuy ? '#FACC15' : THEME.text)
const dotColor = m.color ?? (isBuy ? '#FACC15' : CT().text)
if (compact) {
markPointData.push({
name: m.date, coord: [m.date, d.high],
@@ -989,11 +993,11 @@ export function EChartsCandlestick({
symbol: 'arrow', symbolSize: 12,
symbolRotate: isBuy ? 0 : 180,
symbolOffset: isBuy ? [0, '60%'] : [0, '-60%'],
itemStyle: { color: isBuy ? THEME.bull : isSell ? THEME.bear : THEME.text },
itemStyle: { color: isBuy ? THEME.bull : isSell ? THEME.bear : CT().text },
label: {
show: !!m.label, formatter: m.label ?? '',
position: isBuy ? 'bottom' : 'top', distance: 8,
color: THEME.text, fontSize: 10,
color: CT().text, fontSize: 10,
fontFamily: 'JetBrains Mono, monospace',
},
})
@@ -1038,7 +1042,7 @@ export function EChartsCandlestick({
if (infoEl) {
infoEl.innerHTML = getInfoBarHTML()
}
}, [data, markers, ranges, priceLines, linkedPrice, showMA, showMarkersProp, activeIndicators, chartHeight, dates, dateIndexMap, initialZoom, getInfoBarHTML])
}, [data, markers, ranges, priceLines, linkedPrice, showMA, showMarkersProp, activeIndicators, chartHeight, dates, dateIndexMap, initialZoom, getInfoBarHTML, theme])
// 渲染信息栏容器 (内容由 JS 直接写入)
const initialHTML = useMemo(() => {
@@ -1048,14 +1052,14 @@ export function EChartsCandlestick({
const floatShares = stockInfo?.float_shares
const turnoverRate = floatShares && d.volume ? (d.volume * 100 / floatShares * 100) : null
let html = `<div style="display:flex;align-items:center;gap:6px;padding:0 8px;font:11px 'JetBrains Mono',monospace;height:20px;flex-wrap:wrap">`
html += `<span style="color:${THEME.text}">${d.date}</span>`
html += `<span style="color:${THEME.text}">开</span>`
html += `<span style="color:${CT().text}">${d.date}</span>`
html += `<span style="color:${CT().text}">开</span>`
html += `<span style="color:${d.open >= d.close ? THEME.bear : THEME.bull}">${d.open.toFixed(2)}</span>`
html += `<span style="color:${THEME.text}">高</span>`
html += `<span style="color:${CT().text}">高</span>`
html += `<span style="color:${THEME.bull}">${d.high.toFixed(2)}</span>`
html += `<span style="color:${THEME.text}">低</span>`
html += `<span style="color:${CT().text}">低</span>`
html += `<span style="color:${THEME.bear}">${d.low.toFixed(2)}</span>`
html += `<span style="color:${THEME.text}">收</span>`
html += `<span style="color:${CT().text}">收</span>`
const prevClose0 = data[idx-1]?.close ?? d.close
const clr0 = d.close >= prevClose0 ? THEME.bull : THEME.bear
html += `<span style="color:${clr0};font-weight:600">${d.close.toFixed(2)}</span>`
@@ -1065,8 +1069,8 @@ export function EChartsCandlestick({
html += `<span style="color:${clr0};margin-left:8px">${chgPct0 >= 0 ? '+' : ''}${chgPct0.toFixed(2)}%</span>`
}
if (turnoverRate != null) {
html += `<span style="color:${THEME.text}">换手</span>`
html += `<span style="color:${THEME.text}">${turnoverRate.toFixed(2)}%</span>`
html += `<span style="color:${CT().text}">换手</span>`
html += `<span style="color:${CT().text}">${turnoverRate.toFixed(2)}%</span>`
}
html += `</div>`
if (showMA) {
@@ -1088,7 +1092,7 @@ export function EChartsCandlestick({
<div className="w-full">
{/* 主图信息栏 — 内容由 JS 直接操作 innerHTML */}
{showInfoBar && (
<div ref={infoBarRef} style={{ backgroundColor: 'rgba(39,39,42,0.6)' }}
<div ref={infoBarRef} style={{ backgroundColor: CT().infoBarBg }}
dangerouslySetInnerHTML={{ __html: initialHTML }} />
)}
+22 -23
View File
@@ -2,19 +2,17 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import * as echarts from 'echarts'
import type { ECharts, EChartsOption } from 'echarts'
import type { MinuteKlineRow } from '@/lib/api'
import { useChartTheme, type ChartTheme } from '@/lib/theme'
type YMode = 'adaptive' | 'limit'
// 序列颜色 (双主题通用); 画布轴/网格/十字线等主题相关色走 ChartTheme
const THEME = {
line: '#3B82F6',
areaFill: 'rgba(59,130,246,0.40)',
avgLine: '#F59E0B',
refLine: 'rgba(255,255,255,0.25)',
volUp: 'rgba(240,68,56,0.6)',
volDown: 'rgba(18,183,106,0.6)',
text: '#A1A1AA',
grid: 'rgba(255,255,255,0.04)',
border: '#27272A',
}
interface Props {
@@ -108,7 +106,7 @@ function getLimitPrices(prevClose: number, symbol?: string): {
return { limitUp, limitDown, upPct, downPct }
}
function buildOption(data: MinuteKlineRow[], prevClose: number | undefined, avgPrices: number[], lineColor: string, areaColor: string, yMode: YMode, symbol?: string, showLimitLines = true, showAvgLine = true): EChartsOption {
function buildOption(data: MinuteKlineRow[], prevClose: number | undefined, avgPrices: number[], lineColor: string, areaColor: string, yMode: YMode, ct: ChartTheme, symbol?: string, showLimitLines = true, showAvgLine = true): EChartsOption {
// 将数据映射到全天时间轴上的正确位置
const timeIndexMap = new Map(FULL_DAY_TIMES.map((t, i) => [t, i]))
const closes = new Array(FULL_DAY_TIMES.length).fill(null) as (number | null)[]
@@ -150,7 +148,7 @@ function buildOption(data: MinuteKlineRow[], prevClose: number | undefined, avgP
if (prevClose != null) {
markLineData.push({
yAxis: prevClose,
lineStyle: { color: THEME.refLine, type: 'dashed', width: 1 },
lineStyle: { color: ct.crosshair, type: 'dashed', width: 1 },
label: { show: false },
symbol: 'none',
})
@@ -237,16 +235,16 @@ function buildOption(data: MinuteKlineRow[], prevClose: number | undefined, avgP
type: 'cross',
label: {
show: true,
backgroundColor: 'rgba(39,39,42,0.9)',
borderColor: 'rgba(255,255,255,0.1)',
backgroundColor: ct.tooltipBg,
borderColor: ct.tooltipBorder,
borderWidth: 1,
padding: [2, 5],
color: '#A1A1AA',
color: ct.tooltipText,
fontSize: 10,
fontFamily: 'JetBrains Mono, monospace',
},
crossStyle: { color: 'rgba(255,255,255,0.2)', type: 'dashed', width: 1 },
lineStyle: { color: 'rgba(255,255,255,0.2)', type: 'dashed', width: 1 },
crossStyle: { color: ct.crosshair, type: 'dashed', width: 1 },
lineStyle: { color: ct.crosshair, type: 'dashed', width: 1 },
},
},
axisPointer: {
@@ -263,14 +261,14 @@ function buildOption(data: MinuteKlineRow[], prevClose: number | undefined, avgP
boundaryGap: false,
axisPointer: {
show: true,
lineStyle: { color: 'rgba(255,255,255,0.2)', type: 'dashed', width: 1 },
lineStyle: { color: ct.crosshair, type: 'dashed', width: 1 },
label: {
show: true,
backgroundColor: 'rgba(39,39,42,0.9)',
borderColor: 'rgba(255,255,255,0.1)',
backgroundColor: ct.tooltipBg,
borderColor: ct.tooltipBorder,
borderWidth: 1,
padding: [2, 4],
color: '#A1A1AA',
color: ct.tooltipText,
fontSize: 10,
fontFamily: 'JetBrains Mono, monospace',
formatter: (params: any) => {
@@ -280,7 +278,7 @@ function buildOption(data: MinuteKlineRow[], prevClose: number | undefined, avgP
},
axisLine: { show: false },
axisLabel: {
color: THEME.text,
color: ct.text,
fontSize: 10,
fontFamily: 'JetBrains Mono, monospace',
formatter: xAxisLabelFormatter,
@@ -289,7 +287,7 @@ function buildOption(data: MinuteKlineRow[], prevClose: number | undefined, avgP
axisTick: { show: false },
splitLine: {
show: true,
lineStyle: { color: 'rgba(255,255,255,0.04)' },
lineStyle: { color: ct.grid },
},
},
{
@@ -312,7 +310,7 @@ function buildOption(data: MinuteKlineRow[], prevClose: number | undefined, avgP
splitArea: { show: false },
axisLine: { show: false },
axisTick: { show: false },
splitLine: { lineStyle: { color: THEME.grid } },
splitLine: { lineStyle: { color: ct.grid } },
axisPointer: {
label: {
formatter: (params: any) => {
@@ -322,7 +320,7 @@ function buildOption(data: MinuteKlineRow[], prevClose: number | undefined, avgP
},
},
axisLabel: {
color: THEME.text,
color: ct.text,
fontSize: 10,
fontFamily: 'JetBrains Mono, monospace',
formatter: (v: number) => v.toFixed(2),
@@ -360,7 +358,7 @@ function buildOption(data: MinuteKlineRow[], prevClose: number | undefined, avgP
},
},
axisLabel: {
color: THEME.text,
color: ct.text,
fontSize: 10,
fontFamily: 'JetBrains Mono, monospace',
formatter: (v: number) => {
@@ -420,6 +418,7 @@ export function EChartsIntraday({ data, height = 320, prevClose, date, symbol, o
const [infoIdx, setInfoIdx] = useState(data.length - 1)
const [yMode, setYMode] = useState<YMode>('adaptive')
const ct = useChartTheme()
const avgPrices = useMemo(() => computeAvgPrice(data), [data])
// 分时线颜色:基于最新价 vs 昨收
@@ -494,11 +493,11 @@ export function EChartsIntraday({ data, height = 320, prevClose, date, symbol, o
}
fullDayToDataIdx.current = mapping
chart.setOption(buildOption(data, prevClose, avgPrices, lineColor, areaFill, yMode, symbol, showLimitLines, showAvgLine), true)
chart.setOption(buildOption(data, prevClose, avgPrices, lineColor, areaFill, yMode, ct, symbol, showLimitLines, showAvgLine), true)
} else {
chart.clear()
}
}, [data, prevClose, height, lineColor, areaFill, yMode, symbol, showLimitLines, showAvgLine])
}, [data, prevClose, height, lineColor, areaFill, yMode, ct, symbol, showLimitLines, showAvgLine])
useEffect(() => {
return () => {
@@ -548,7 +547,7 @@ export function EChartsIntraday({ data, height = 320, prevClose, date, symbol, o
</button>
</div>
</div>}
<div style={{ backgroundColor: 'rgba(39,39,42,0.6)' }}>
<div style={{ backgroundColor: ct.infoBarBg }}>
{/* 第一行: 日期 + OHLC */}
<div className="flex items-center gap-x-2 px-2 font-mono text-[11px] select-none flex-wrap" style={{ height: 20 }}>
{!d && <span className="text-muted"></span>}
@@ -296,8 +296,8 @@ export function ExtDimensionAnalysis({
使 detail_columns
</p>
</div>
<div className="absolute right-8 top-6 hidden h-28 w-28 rounded-full border border-white/10 bg-white/[0.03] lg:flex items-center justify-center">
<BarChart3 className="h-12 w-12 text-white/20" />
<div className="absolute right-8 top-6 hidden h-28 w-28 rounded-full border border-foreground/10 bg-foreground/[0.03] lg:flex items-center justify-center">
<BarChart3 className="h-12 w-12 text-foreground/20" />
</div>
</section>
+20
View File
@@ -43,11 +43,14 @@ import {
CheckCircle2,
BookOpenCheck,
ExternalLink,
Sun,
Moon,
X,
} from 'lucide-react'
import { Logo } from './Logo'
import { api, type IndexQuote } from '@/lib/api'
import { cn } from '@/lib/cn'
import { toggleTheme, useTheme } from '@/lib/theme'
import { setCurrentTotal as setAlertTotal, useUnreadAlerts } from '@/lib/monitorBadge'
// 品牌色 — 只用于 logo / brand 区域,不影响功能语义色
@@ -80,6 +83,22 @@ const nav = [
{ to: '/data', label: '数据', icon: Database },
] as const
/** 亮/暗主题切换 — 状态存 localStorage, 生效见 lib/theme.ts */
function ThemeToggle() {
const theme = useTheme()
const dark = theme === 'dark'
return (
<button
onClick={() => toggleTheme()}
className="flex w-full items-center gap-3 rounded-btn px-3 py-2 text-sm text-foreground/80 transition-colors duration-150 ease-smooth hover:bg-elevated hover:text-foreground cursor-pointer"
title={dark ? '切换到亮色模式' : '切换到暗色模式'}
>
{dark ? <Sun className="h-4 w-4 shrink-0" /> : <Moon className="h-4 w-4 shrink-0" />}
<span>{dark ? '亮色模式' : '暗色模式'}</span>
</button>
)
}
function fmtIndexValue(v: number | null | undefined) {
if (v == null || Number.isNaN(Number(v))) return '--'
return Number(v).toFixed(2)
@@ -547,6 +566,7 @@ export function Layout() {
</div>
<div className="border-t border-border px-2 py-3 space-y-0.5 shrink-0">
<ThemeToggle />
<NavLink
to="/settings"
className={({ isActive }) =>
@@ -1,4 +1,5 @@
import { useEffect, useRef, useMemo, useState } from 'react'
import { chartTheme, getTheme, useTheme } from '@/lib/theme'
import * as echarts from 'echarts'
import type { ECharts, EChartsOption } from 'echarts'
import type { KlineRow, LevelSeries } from '@/lib/api'
@@ -18,16 +19,17 @@ import type { KlineRow, LevelSeries } from '@/lib/api'
* - 指标副图: 后续如需 MACD/KDJ,按 SUB_CHARTS 模式扩展
*/
// ===== 配色(与主图一致的红涨绿跌,深色背景) =====
// ===== 配色(红涨绿跌, 双主题通用); 画布轴/网格主题相关色走 CT() =====
const THEME = {
bull: '#C74040',
bear: '#2D9B65',
text: '#A1A1AA',
grid: 'rgba(255,255,255,0.04)',
volUp: 'rgba(240,68,56,0.5)',
volDown: 'rgba(18,183,106,0.5)',
}
/** 当前主题的图表调色板 (buildOption 渲染时调用; 切换由组件 effect 触发重建)。 */
const CT = () => chartTheme(getTheme())
// ===== 价位类型(与后端 levels.py 的 LEVEL_TYPES 对齐) =====
export type LevelType = 'sr' | 'pivot' | 'extreme' | 'boll' | 'keltner_s' | 'keltner_m' | 'keltner_l' | 'atr_stop' | 'gap' | 'fib' | 'round'
@@ -123,6 +125,8 @@ export function AnalysisKChart({
}: Props) {
const chartRef = useRef<HTMLDivElement>(null)
const chartInstRef = useRef<ECharts | null>(null)
// 主题: buildOption 内部用 CT() 动态取色, 这里只负责切换时触发重建
const theme = useTheme()
const [activeTypes, setActiveTypes] = useState<Set<LevelType>>(new Set(defaultLevelTypes))
/** 枢轴点显示到第几档:1=只P+R1/S1, 2=到R2/S2, 3=全档(R3/S3) */
const [pivotRank, setPivotRank] = useState<1 | 2 | 3>(1)
@@ -289,8 +293,8 @@ export function AnalysisKChart({
xAxis: [
{
type: 'category', data: dates, boundaryGap: true,
axisLine: { lineStyle: { color: THEME.grid } },
axisLabel: { color: THEME.text, fontSize: 10 },
axisLine: { lineStyle: { color: CT().grid } },
axisLabel: { color: CT().text, fontSize: 10 },
splitLine: { show: false },
axisPointer: { show: true, label: { show: false } },
},
@@ -300,19 +304,19 @@ export function AnalysisKChart({
},
],
yAxis: [
{ scale: true, splitLine: { lineStyle: { color: THEME.grid } },
axisLabel: { color: THEME.text, fontSize: 10, fontFamily: 'JetBrains Mono, monospace' } },
{ scale: true, splitLine: { lineStyle: { color: CT().grid } },
axisLabel: { color: CT().text, fontSize: 10, fontFamily: 'JetBrains Mono, monospace' } },
{ scale: true, gridIndex: 1, splitNumber: 2,
// 成交量区不画背景横线
splitLine: { show: false },
axisLabel: { color: THEME.text, fontSize: 9, fontFamily: 'JetBrains Mono, monospace',
axisLabel: { color: CT().text, fontSize: 9, fontFamily: 'JetBrains Mono, monospace',
formatter: (v: number) => fmtVol(v) } },
],
dataZoom: [
{ type: 'inside', xAxisIndex: [0, 1], start: zoomStart, end: 100 },
{ type: 'slider', xAxisIndex: [0, 1], bottom: sliderBottom, height: SLIDER_H, start: zoomStart, end: 100,
borderColor: 'transparent', fillerColor: 'rgba(255,255,255,0.06)',
handleStyle: { color: '#52525B' }, textStyle: { color: THEME.text, fontSize: 10 } },
borderColor: 'transparent', fillerColor: CT().zoomFill,
handleStyle: { color: '#52525B' }, textStyle: { color: CT().text, fontSize: 10 } },
],
// 不弹 hover tooltip(用户要求);但保留十字线 axisPointer 作为缩放/定位参照
tooltip: { show: false },
@@ -335,7 +339,7 @@ export function AnalysisKChart({
}
chartInstRef.current.setOption(buildOption(), true)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rows, levels, series, seriesDates, activeTypes, pivotRank, markers, ranges, height])
}, [rows, levels, series, seriesDates, activeTypes, pivotRank, markers, ranges, height, theme])
// resize
useEffect(() => {
@@ -464,7 +468,7 @@ function LevelOverview({
}
const Row = ({ p }: { p: PriceLevel }) => {
const color = LEVEL_GROUPS.find(g => g.key === p.type)?.color ?? THEME.text
const color = LEVEL_GROUPS.find(g => g.key === p.type)?.color ?? CT().text
return (
<div className="flex items-center gap-2 py-0.5">
<span className="h-1.5 w-1.5 rounded-full shrink-0" style={{ backgroundColor: color }} />
+121
View File
@@ -0,0 +1,121 @@
// 主题管理 — 暗色(默认) / 亮色切换
//
// 机制:
// - 状态存 localStorage('tf-theme'), 默认 dark (保持老用户体验不变)
// - 生效方式: html.dark class (index.css 的 CSS variables + Tailwind darkMode:class)
// - index.html 里有预渲染内联脚本, 首屏前就设好 class, 避免闪烁 (FOUC)
// - UI token (bg-surface/text-foreground 等) 自动跟随;
// 图表画布不吃 CSS 变量, 统一走 useChartTheme() 取调色板
import { useEffect, useState } from 'react'
const KEY = 'tf-theme'
const EVENT = 'tf-theme-change'
export type Theme = 'dark' | 'light'
export function getTheme(): Theme {
try {
return localStorage.getItem(KEY) === 'light' ? 'light' : 'dark'
} catch {
return 'dark'
}
}
export function setTheme(theme: Theme) {
try { localStorage.setItem(KEY, theme) } catch { /* ignore */ }
document.documentElement.classList.toggle('dark', theme === 'dark')
window.dispatchEvent(new CustomEvent(EVENT, { detail: theme }))
}
export function toggleTheme(): Theme {
const next: Theme = getTheme() === 'dark' ? 'light' : 'dark'
setTheme(next)
return next
}
/** 订阅当前主题 (本页切换 + 其他标签页切换均同步)。 */
export function useTheme(): Theme {
const [theme, set] = useState<Theme>(getTheme)
useEffect(() => {
const onChange = () => set(getTheme())
window.addEventListener(EVENT, onChange)
window.addEventListener('storage', onChange) // 跨标签页同步
return () => {
window.removeEventListener(EVENT, onChange)
window.removeEventListener('storage', onChange)
}
}, [])
return theme
}
// ================================================================
// 图表调色板 — ECharts / lightweight-charts 画布不吃 CSS 变量,
// 所有图表组件统一从这里取色, 主题切换时依赖 useTheme 重建 option。
// bull/bear/accent 等语义色双主题一致, 不在此重复定义。
// ================================================================
export interface ChartTheme {
/** 轴刻度/图例等常规文字 */
text: string
/** 信息条/图例里的强调文字 */
textStrong: string
/** 网格线 */
grid: string
/** 轴线/边框 */
border: string
/** 十字光标线 */
crosshair: string
/** 十字光标轴标签背景 */
crosshairLabelBg: string
/** tooltip 背景 */
tooltipBg: string
/** tooltip 边框 */
tooltipBorder: string
/** tooltip 文字 */
tooltipText: string
/** 半透明信息条背景 (K线图左上角 OHLC 条) */
infoBarBg: string
/** dataZoom 滑块填充 */
zoomFill: string
/** 分时图均价线以外的弱填充 */
fillSubtle: string
}
const DARK: ChartTheme = {
text: '#A1A1AA',
textStrong: '#E4E4E7',
grid: 'rgba(255,255,255,0.06)',
border: '#27272A',
crosshair: 'rgba(255,255,255,0.25)',
crosshairLabelBg: '#333',
tooltipBg: 'rgba(24,24,27,0.95)',
tooltipBorder: 'rgba(255,255,255,0.1)',
tooltipText: '#E4E4E7',
infoBarBg: 'rgba(39,39,42,0.6)',
zoomFill: 'rgba(255,255,255,0.06)',
fillSubtle: 'rgba(255,255,255,0.04)',
}
const LIGHT: ChartTheme = {
text: '#71717A',
textStrong: '#27272A',
grid: 'rgba(0,0,0,0.06)',
border: '#E4E4E7',
crosshair: 'rgba(0,0,0,0.3)',
crosshairLabelBg: '#52525B',
tooltipBg: 'rgba(255,255,255,0.97)',
tooltipBorder: 'rgba(0,0,0,0.1)',
tooltipText: '#27272A',
infoBarBg: 'rgba(244,244,245,0.85)',
zoomFill: 'rgba(0,0,0,0.06)',
fillSubtle: 'rgba(0,0,0,0.04)',
}
export function chartTheme(theme: Theme): ChartTheme {
return theme === 'dark' ? DARK : LIGHT
}
/** hook: 当前主题的图表调色板 (主题切换自动触发重渲染)。 */
export function useChartTheme(): ChartTheme {
return chartTheme(useTheme())
}
+8 -7
View File
@@ -325,24 +325,25 @@ function EmotionRadar({ radar, score }: { radar: OverviewMarket['radar']; score:
<stop offset="0%" stopColor={`${color}57`} />
<stop offset="100%" stopColor={`${color}1f`} />
</radialGradient>
{/* 中心/网格用 CSS 变量取色, 亮暗主题自动切换 (SVG 属性支持 hsl(var(--x))) */}
<radialGradient id="emotionRadarCenter" cx="50%" cy="50%" r="55%">
<stop offset="0%" stopColor="rgba(15,23,42,0.92)" />
<stop offset="68%" stopColor="rgba(15,23,42,0.70)" />
<stop offset="100%" stopColor="rgba(15,23,42,0)" />
<stop offset="0%" stopColor="hsl(var(--surface) / 0.92)" />
<stop offset="68%" stopColor="hsl(var(--surface) / 0.70)" />
<stop offset="100%" stopColor="hsl(var(--surface) / 0)" />
</radialGradient>
</defs>
{gridPolygons.map(g => (
<polygon
key={g.level}
points={g.points}
fill={g.idx % 2 === 0 ? 'rgba(30,41,59,0.26)' : 'rgba(15,23,42,0.16)'}
stroke={g.level === 1 ? 'rgba(148,163,184,0.22)' : 'rgba(148,163,184,0.12)'}
fill={g.idx % 2 === 0 ? 'hsl(var(--elevated) / 0.55)' : 'hsl(var(--elevated) / 0.3)'}
stroke={g.level === 1 ? 'hsl(var(--border) / 0.9)' : 'hsl(var(--border) / 0.5)'}
strokeWidth={g.level === 1 ? 1.2 : 0.8}
/>
))}
{points.map(p => <line key={p.key} x1={cx} y1={cy} x2={p.gx} y2={p.gy} stroke="rgba(148,163,184,0.08)" />)}
{points.map(p => <line key={p.key} x1={cx} y1={cy} x2={p.gx} y2={p.gy} stroke="hsl(var(--border) / 0.4)" />)}
<polygon points={polygon} fill="url(#emotionRadarFill)" stroke={color} strokeWidth="2" />
{points.map(p => <circle key={p.key} cx={p.x} cy={p.y} r="2.8" fill={color} stroke="rgba(15,23,42,0.9)" strokeWidth="1" />)}
{points.map(p => <circle key={p.key} cx={p.x} cy={p.y} r="2.8" fill={color} stroke="hsl(var(--surface) / 0.9)" strokeWidth="1" />)}
<circle cx={cx} cy={cy} r="29" fill="url(#emotionRadarCenter)" />
<text x={cx} y={cy + 7} textAnchor="middle" className="fill-foreground font-mono text-[24px] font-bold">{score}</text>
{points.map(p => (
+33 -17
View File
@@ -10,6 +10,7 @@ import { storage } from '@/lib/storage'
import { fmtPct, priceColorClass } from '@/lib/format'
import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState'
import { useTheme } from '@/lib/theme'
import { useCapabilities, usePreferences } from '@/lib/useSharedQueries'
import { SealedBadge } from '@/components/SealedBadge'
import type { ExtColumnDisplayConfig } from '@/lib/watchlist-columns'
@@ -149,7 +150,8 @@ const STATUS_STYLE: Record<string, { bg: string; bar: string; nameCls: string; c
limit_up: {
bg: '',
bar: 'border-l-2 border-bull/50',
nameCls: 'text-rose-50 text-[13px]',
// 亮色用深酒红, 暗色保持近白 — 卡片底是淡红渐变, 双主题都要有对比度
nameCls: 'text-rose-900 dark:text-rose-50 text-[13px]',
codeCls: 'text-muted/80',
badge: '',
badgeText: '',
@@ -162,7 +164,7 @@ const STATUS_STYLE: Record<string, { bg: string; bar: string; nameCls: string; c
limit_down: {
bg: '',
bar: 'border-l-2 border-bear/50',
nameCls: 'text-green-50 text-[13px]',
nameCls: 'text-emerald-900 dark:text-green-50 text-[13px]',
codeCls: 'text-muted/80',
badge: '',
badgeText: '',
@@ -778,12 +780,14 @@ function TagStats({ title, tiers, extFields, fieldKey, color, selectedTag, onSel
tiers: LimitLadderTier[]
extFields: ExtFieldConfig
fieldKey: 'concept' | 'industry'
color: { text: [number, number, number]; bg: [number, number, number] }
/** text=暗色文字, textLight=亮色文字 (亮底需要更深的色阶), bg=底色 */
color: { text: [number, number, number]; textLight: [number, number, number]; bg: [number, number, number] }
selectedTag: { fieldKey: 'concept' | 'industry'; tag: string } | null
onSelect: (sel: { fieldKey: 'concept' | 'industry'; tag: string } | null) => void
direction: Direction
}) {
const [expanded, setExpanded] = useState(false)
const isDark = useTheme() === 'dark'
const mainStatus = direction === 'down' ? 'limit_down' : 'limit_up'
const stats = useMemo(() => {
@@ -805,7 +809,7 @@ function TagStats({ title, tiers, extFields, fieldKey, color, selectedTag, onSel
if (stats.length === 0) return null
const maxCount = stats[0]?.[1] ?? 1
const [r, g, b] = color.text
const [r, g, b] = isDark ? color.text : color.textLight
const [br, bg, bb] = color.bg
const needsExpand = stats.length > 10
@@ -839,10 +843,13 @@ function TagStats({ title, tiers, extFields, fieldKey, color, selectedTag, onSel
onClick={() => onSelect(isSelected ? null : { fieldKey, tag: name })}
className="text-[11px] px-2 py-1 rounded-sm whitespace-nowrap cursor-pointer hover:brightness-110 transition-all"
style={{
color: isSelected ? '#fff' : `rgba(${r},${g},${b},${0.6 + intensity * 0.4})`,
// 亮色: 深色阶文字 + 更淡的底; 选中态不用白字 (黄底白字在亮色下不可读)
color: isSelected
? (isDark ? '#fff' : `rgb(${r},${g},${b})`)
: `rgba(${r},${g},${b},${isDark ? 0.6 + intensity * 0.4 : 0.75 + intensity * 0.25})`,
backgroundColor: isSelected
? `rgba(${br},${bg},${bb},0.7)`
: `rgba(${br},${bg},${bb},${intensity * 0.2})`,
? `rgba(${br},${bg},${bb},${isDark ? 0.7 : 0.28})`
: `rgba(${br},${bg},${bb},${intensity * (isDark ? 0.2 : 0.14)})`,
outline: isSelected ? `1px solid rgba(${r},${g},${b},0.8)` : 'none',
outlineOffset: 1,
}}
@@ -883,6 +890,7 @@ function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick,
onMonitorChange: () => void
hasDepth: boolean
}) {
const isDarkTheme = useTheme() === 'dark'
const [open, setOpen] = useState(defaultOpen)
const cfg = { ...DEFAULT_BF, ...bf }
const mainStatus = direction === 'down' ? 'limit_down' : 'limit_up'
@@ -963,7 +971,7 @@ function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick,
<div className="px-3 pt-1 pb-2 space-y-1">
{groupConceptStats.length > 0 && (
<div className="flex flex-wrap gap-1 items-center">
<span className="text-[9px] tracking-wider text-yellow-400/70 mr-0.5"></span>
<span className="text-[9px] tracking-wider text-yellow-700/80 dark:text-yellow-400/70 mr-0.5"></span>
{groupConceptStats.slice(0, 20).map(([name, count]) => {
const isSelected = selectedTag?.fieldKey === 'concept' && selectedTag?.tag === name
return (
@@ -972,9 +980,13 @@ function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick,
onClick={() => onSelectTag(isSelected ? null : { fieldKey: 'concept', tag: name })}
className="text-[10px] px-1.5 py-0.5 rounded-sm whitespace-nowrap cursor-pointer hover:brightness-110 transition-all"
style={{
color: isSelected ? '#fff' : 'rgba(250,204,21,0.8)',
backgroundColor: isSelected ? 'rgba(234,179,8,0.7)' : 'rgba(234,179,8,0.12)',
outline: isSelected ? '1px solid rgba(250,204,21,0.8)' : 'none',
color: isSelected
? (isDarkTheme ? '#fff' : 'rgb(161,98,7)')
: (isDarkTheme ? 'rgba(250,204,21,0.8)' : 'rgba(161,98,7,0.9)'),
backgroundColor: isSelected
? `rgba(234,179,8,${isDarkTheme ? 0.7 : 0.28})`
: `rgba(234,179,8,${isDarkTheme ? 0.12 : 0.1})`,
outline: isSelected ? `1px solid rgba(${isDarkTheme ? '250,204,21' : '161,98,7'},0.8)` : 'none',
outlineOffset: 1,
}}
>
@@ -986,7 +998,7 @@ function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick,
)}
{groupIndustryStats.length > 0 && (
<div className="flex flex-wrap gap-1 items-center">
<span className="text-[9px] tracking-wider text-blue-400/70 mr-0.5"></span>
<span className="text-[9px] tracking-wider text-blue-700/80 dark:text-blue-400/70 mr-0.5"></span>
{groupIndustryStats.slice(0, 20).map(([name, count]) => {
const isSelected = selectedTag?.fieldKey === 'industry' && selectedTag?.tag === name
return (
@@ -995,9 +1007,13 @@ function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick,
onClick={() => onSelectTag(isSelected ? null : { fieldKey: 'industry', tag: name })}
className="text-[10px] px-1.5 py-0.5 rounded-sm whitespace-nowrap cursor-pointer hover:brightness-110 transition-all"
style={{
color: isSelected ? '#fff' : 'rgba(96,165,250,0.8)',
backgroundColor: isSelected ? 'rgba(59,130,246,0.7)' : 'rgba(59,130,246,0.12)',
outline: isSelected ? '1px solid rgba(96,165,250,0.8)' : 'none',
color: isSelected
? (isDarkTheme ? '#fff' : 'rgb(29,78,216)')
: (isDarkTheme ? 'rgba(96,165,250,0.8)' : 'rgba(29,78,216,0.9)'),
backgroundColor: isSelected
? `rgba(59,130,246,${isDarkTheme ? 0.7 : 0.22})`
: `rgba(59,130,246,${isDarkTheme ? 0.12 : 0.08})`,
outline: isSelected ? `1px solid rgba(${isDarkTheme ? '96,165,250' : '29,78,216'},0.8)` : 'none',
outlineOffset: 1,
}}
>
@@ -1627,7 +1643,7 @@ export function LimitUpLadder() {
tiers={tiers}
extFields={resolveExtFields(extFields, showConcept, showIndustry)}
fieldKey="concept"
color={{ text: [250, 204, 21], bg: [234, 179, 8] }}
color={{ text: [250, 204, 21], textLight: [161, 98, 7], bg: [234, 179, 8] }}
selectedTag={selectedTag}
onSelect={handleSelectTag}
direction={direction}
@@ -1640,7 +1656,7 @@ export function LimitUpLadder() {
tiers={tiers}
extFields={resolveExtFields(extFields, showConcept, showIndustry)}
fieldKey="industry"
color={{ text: [96, 165, 250], bg: [59, 130, 246] }}
color={{ text: [96, 165, 250], textLight: [29, 78, 216], bg: [59, 130, 246] }}
selectedTag={selectedTag}
onSelect={handleSelectTag}
direction={direction}
@@ -186,7 +186,8 @@ const fmtLots = (v: number | null | undefined) => {
}
const statValueColor = (v: number | null | undefined) => {
if (v == null || Number.isNaN(v) || v === 0) return '#f8fafc'
// 中性值继承页面前景色 (亮暗主题都可读), 不再写死近白色
if (v == null || Number.isNaN(v) || v === 0) return 'inherit'
return v > 0 ? '#f87171' : '#34d399'
}
@@ -383,7 +384,7 @@ function Stat({ label, value, color }: { label: ReactNode; value: string; color?
<div className="text-[11px] text-secondary">{label}</div>
<div
className="mt-1 break-words text-sm font-mono font-semibold leading-tight tracking-tight num xl:text-base"
style={{ color: color ?? '#f8fafc' }}
style={{ color: color ?? 'inherit' }}
title={value}
>
{value}
@@ -1,6 +1,7 @@
import { useMemo } from 'react'
import { useECharts } from './useECharts'
import type { FactorBacktestResult } from '@/lib/api'
import { useChartTheme } from '@/lib/theme'
const GROUP_COLORS = [
'#6366f1', // Q1 indigo
@@ -20,6 +21,7 @@ interface Props {
}
export function FactorGroupNavChart({ result }: Props) {
const ct = useChartTheme()
const option = useMemo(() => {
if (!result.group_nav.length) return null
@@ -58,12 +60,12 @@ export function FactorGroupNavChart({ result }: Props) {
grid: { left: 56, right: 16, top: 12, bottom: 28 },
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(15,23,42,0.95)',
borderColor: 'rgba(148,163,184,0.2)',
textStyle: { color: '#e2e8f0', fontSize: 12 },
backgroundColor: ct.tooltipBg,
borderColor: ct.tooltipBorder,
textStyle: { color: ct.tooltipText, fontSize: 12 },
formatter: (params: any) => {
const date = params[0]?.axisValue ?? ''
let html = `<div style="font-size:11px;color:#94a3b8;margin-bottom:4px">${date}</div>`
let html = `<div style="font-size:11px;color:${ct.text};margin-bottom:4px">${date}</div>`
for (const p of params) {
if (p.value == null) continue
html += `<div style="display:flex;justify-content:space-between;gap:16px">
@@ -80,22 +82,22 @@ export function FactorGroupNavChart({ result }: Props) {
xAxis: {
type: 'category',
data: dates,
axisLabel: { color: '#64748b', fontSize: 10, interval: Math.floor(dates.length / 6) },
axisLine: { lineStyle: { color: '#334155' } },
axisLabel: { color: ct.text, fontSize: 10, interval: Math.floor(dates.length / 6) },
axisLine: { lineStyle: { color: ct.border } },
axisTick: { show: false },
},
yAxis: {
type: 'value',
scale: true,
axisLabel: { color: '#64748b', fontSize: 10 },
splitLine: { lineStyle: { color: '#1e293b' } },
axisLabel: { color: ct.text, fontSize: 10 },
splitLine: { lineStyle: { color: ct.grid } },
axisLine: { show: false },
},
series,
} as any
}, [result.group_nav, result.long_short_nav, result.run_id])
}, [result.group_nav, result.long_short_nav, result.run_id, ct])
const chartRef = useECharts(option, [result.run_id])
const chartRef = useECharts(option, [result.run_id, ct])
// 图例
const groupCols = result.group_nav.length > 0
@@ -1,12 +1,14 @@
import { useMemo } from 'react'
import { useECharts } from './useECharts'
import type { FactorBacktestResult } from '@/lib/api'
import { useChartTheme } from '@/lib/theme'
interface Props {
result: FactorBacktestResult
}
export function FactorICChart({ result }: Props) {
const ct = useChartTheme()
const option = useMemo(() => {
if (!result.ic_series.length) return null
@@ -26,12 +28,12 @@ export function FactorICChart({ result }: Props) {
grid: { left: 50, right: 16, top: 16, bottom: 28 },
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(15,23,42,0.95)',
borderColor: 'rgba(148,163,184,0.2)',
textStyle: { color: '#e2e8f0', fontSize: 12 },
backgroundColor: ct.tooltipBg,
borderColor: ct.tooltipBorder,
textStyle: { color: ct.tooltipText, fontSize: 12 },
formatter: (params: any) => {
const date = params[0]?.axisValue ?? ''
let html = `<div style="font-size:11px;color:#94a3b8;margin-bottom:4px">${date}</div>`
let html = `<div style="font-size:11px;color:${ct.text};margin-bottom:4px">${date}</div>`
for (const p of params) {
if (p.value == null) continue
html += `<div style="display:flex;justify-content:space-between;gap:16px">
@@ -45,14 +47,14 @@ export function FactorICChart({ result }: Props) {
xAxis: {
type: 'category',
data: dates,
axisLabel: { color: '#64748b', fontSize: 10, interval: Math.floor(dates.length / 6) },
axisLine: { lineStyle: { color: '#334155' } },
axisLabel: { color: ct.text, fontSize: 10, interval: Math.floor(dates.length / 6) },
axisLine: { lineStyle: { color: ct.border } },
axisTick: { show: false },
},
yAxis: {
type: 'value',
axisLabel: { color: '#64748b', fontSize: 10, formatter: (v: number) => `${(v * 100).toFixed(0)}%` },
splitLine: { lineStyle: { color: '#1e293b' } },
axisLabel: { color: ct.text, fontSize: 10, formatter: (v: number) => `${(v * 100).toFixed(0)}%` },
splitLine: { lineStyle: { color: ct.grid } },
axisLine: { show: false },
},
series: [
@@ -80,9 +82,9 @@ export function FactorICChart({ result }: Props) {
},
],
} as any
}, [result.ic_series])
}, [result.ic_series, ct])
const chartRef = useECharts(option, [result.run_id])
const chartRef = useECharts(option, [result.run_id, ct])
return <div ref={chartRef} className="h-[200px]" />
}
@@ -1,6 +1,7 @@
import { useMemo } from 'react'
import { useECharts } from './useECharts'
import type { EChartsOption } from 'echarts'
import { useChartTheme } from '@/lib/theme'
interface DistBin {
range: string
@@ -13,6 +14,7 @@ interface DistBin {
* 柱子颜色按收益正负区分(正红负绿),零轴居中。
*/
export function ReturnDistributionChart({ distribution }: { distribution: DistBin[] }) {
const ct = useChartTheme()
const option = useMemo<EChartsOption>(() => {
const cats = distribution.map(d => d.range)
const vals = distribution.map(d => d.count)
@@ -20,7 +22,7 @@ export function ReturnDistributionChart({ distribution }: { distribution: DistBi
const colors = distribution.map(d => {
const lo = parseFloat(d.range)
// 中心档(跨 0) 用中性色
if (lo < 0 && parseFloat(d.range.split('~')[1]) > 0) return '#a1a1aa'
if (lo < 0 && parseFloat(d.range.split('~')[1]) > 0) return ct.text
return lo >= 0 ? '#ef4444' : '#22c55e'
})
@@ -39,13 +41,13 @@ export function ReturnDistributionChart({ distribution }: { distribution: DistBi
xAxis: {
type: 'category',
data: cats,
axisLabel: { color: '#a1a1aa', fontSize: 10, rotate: 45, interval: 1 },
axisLine: { lineStyle: { color: '#3f3f46' } },
axisLabel: { color: ct.text, fontSize: 10, rotate: 45, interval: 1 },
axisLine: { lineStyle: { color: ct.border } },
},
yAxis: {
type: 'value',
axisLabel: { color: '#a1a1aa', fontSize: 10 },
splitLine: { lineStyle: { color: '#27272a' } },
axisLabel: { color: ct.text, fontSize: 10 },
splitLine: { lineStyle: { color: ct.grid } },
},
series: [
{
@@ -55,9 +57,9 @@ export function ReturnDistributionChart({ distribution }: { distribution: DistBi
},
],
}
}, [distribution])
}, [distribution, ct])
const chartRef = useECharts(option, [distribution])
const chartRef = useECharts(option, [distribution, ct])
return <div ref={chartRef} className="h-48 w-full" />
}
@@ -1,12 +1,14 @@
import { useMemo } from 'react'
import { useECharts } from './useECharts'
import type { StrategyBacktestResult } from '@/lib/api'
import { useChartTheme } from '@/lib/theme'
interface Props {
result: StrategyBacktestResult
}
export function StrategyNavChart({ result }: Props) {
const ct = useChartTheme()
const option = useMemo(() => {
if (!result.equity_curve.length) return null
@@ -28,7 +30,7 @@ export function StrategyNavChart({ result }: Props) {
animation: false,
axisPointer: {
link: [{ xAxisIndex: 'all' }],
label: { backgroundColor: '#334155' },
label: { backgroundColor: ct.crosshairLabelBg },
},
grid: [
{ left: 64, right: hasBenchmark ? 64 : 16, top: 14, bottom: '40%' },
@@ -39,14 +41,14 @@ export function StrategyNavChart({ result }: Props) {
type: 'category', data: dates, gridIndex: 0,
axisLabel: { show: false }, axisTick: { show: false },
axisPointer: { show: true, type: 'line' },
axisLine: { lineStyle: { color: '#334155' } },
axisLine: { lineStyle: { color: ct.border } },
},
{
type: 'category', data: dates, gridIndex: 1,
axisLabel: { color: '#64748b', fontSize: 10, interval: Math.floor(dates.length / 6) },
axisLabel: { color: ct.text, fontSize: 10, interval: Math.floor(dates.length / 6) },
axisTick: { show: false },
axisPointer: { show: true, type: 'line' },
axisLine: { lineStyle: { color: '#334155' } },
axisLine: { lineStyle: { color: ct.border } },
},
],
yAxis: [
@@ -54,13 +56,13 @@ export function StrategyNavChart({ result }: Props) {
type: 'value', gridIndex: 0,
scale: true,
name: hasBenchmark ? '上证点位' : '策略资金',
nameTextStyle: { color: hasBenchmark ? 'rgba(148,163,184,0.55)' : '#64748b', fontSize: 10, padding: [0, 0, 4, 0] },
nameTextStyle: { color: hasBenchmark ? ct.text : ct.text, fontSize: 10, padding: [0, 0, 4, 0] },
axisLabel: {
color: hasBenchmark ? 'rgba(148,163,184,0.55)' : '#64748b',
color: hasBenchmark ? ct.text : ct.text,
fontSize: 10,
formatter: hasBenchmark ? ((v: number) => v.toFixed(0)) : axisMoneyFmt,
},
splitLine: { lineStyle: { color: '#1e293b' } },
splitLine: { lineStyle: { color: ct.grid } },
axisLine: { show: false },
},
{
@@ -68,10 +70,10 @@ export function StrategyNavChart({ result }: Props) {
position: 'right',
scale: true,
name: hasBenchmark ? '策略资金' : '',
nameTextStyle: { color: '#64748b', fontSize: 10, padding: [0, 0, 4, 0] },
nameTextStyle: { color: ct.text, fontSize: 10, padding: [0, 0, 4, 0] },
axisLabel: {
show: hasBenchmark,
color: '#64748b',
color: ct.text,
fontSize: 10,
formatter: axisMoneyFmt,
},
@@ -83,10 +85,10 @@ export function StrategyNavChart({ result }: Props) {
position: 'right',
max: 0,
axisLabel: {
color: '#64748b', fontSize: 10,
color: ct.text, fontSize: 10,
formatter: (v: number) => `${v.toFixed(1)}%`,
},
splitLine: { lineStyle: { color: '#1e293b' } },
splitLine: { lineStyle: { color: ct.grid } },
axisLine: { show: false },
},
],
@@ -105,22 +107,22 @@ export function StrategyNavChart({ result }: Props) {
filterMode: 'filter',
height: 16,
bottom: 10,
borderColor: 'rgba(148,163,184,0.18)',
backgroundColor: 'rgba(15,23,42,0.55)',
borderColor: ct.border,
backgroundColor: ct.zoomFill,
fillerColor: 'rgba(59,130,246,0.18)',
handleStyle: { color: '#64748b', borderColor: '#94a3b8' },
textStyle: { color: '#64748b', fontSize: 10 },
handleStyle: { color: ct.text, borderColor: '#94a3b8' },
textStyle: { color: ct.text, fontSize: 10 },
brushSelect: false,
},
],
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(15,23,42,0.95)',
borderColor: 'rgba(148,163,184,0.2)',
textStyle: { color: '#e2e8f0', fontSize: 12 },
backgroundColor: ct.tooltipBg,
borderColor: ct.tooltipBorder,
textStyle: { color: ct.tooltipText, fontSize: 12 },
formatter: (params: any) => {
const date = params[0]?.axisValue ?? ''
let html = `<div style="font-size:11px;color:#94a3b8;margin-bottom:4px">${date}</div>`
let html = `<div style="font-size:11px;color:${ct.text};margin-bottom:4px">${date}</div>`
for (const p of params) {
if (p.value == null) continue
const isDrawdown = p.seriesName === '回撤'
@@ -180,9 +182,9 @@ export function StrategyNavChart({ result }: Props) {
},
],
} as any
}, [result.equity_curve, result.drawdown_curve, result.benchmark_curve, result.run_id])
}, [result.equity_curve, result.drawdown_curve, result.benchmark_curve, result.run_id, ct])
const chartRef = useECharts(option, [result.run_id])
const chartRef = useECharts(option, [result.run_id, ct])
return (
<div>