feat(theme): 亮色主题基础设施 + 全部图表主题化

- lib/theme.ts: localStorage 持久化 + useTheme hook (本页/跨标签页
  同步) + ChartTheme 图表调色板 (ECharts/lightweight-charts 画布
  不吃 CSS 变量, 统一从这里取色)
- index.html: 去掉写死的 class=dark, 换预渲染内联脚本 (默认暗色,
  切过亮色则保持), 避免首屏闪烁
- Layout: 侧边栏底部亮/暗切换按钮 (设置入口上方)
- 图表主题化 (切换即时生效, 无需刷新):
  · EChartsIntraday / EChartsCandlestick / CandlestickChart
    (lightweight-charts applyOptions) / AnalysisKChart
  · 回测 4 图 (StrategyNav/FactorIC/FactorGroupNav/ReturnDistribution)
  · Sentiment 页双图
  序列颜色 (红涨绿跌/MA线) 双主题通用保持不变; 轴/网格/十字线/
  tooltip/信息栏背景按主题切换

CSS 变量亮色板 index.css 里本就有 (:root), 首次真正启用
This commit is contained in:
Gundy
2026-07-03 13:51:17 +08:00
parent 114a52969c
commit 8abf7e338a
11 changed files with 313 additions and 132 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>}
+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())
}
@@ -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>