feat(regime): 市场环境页完善 — 顶部改造+日历热力图+趋势图修复

顶部 4 卡改造(去掉与看板重复的涨停/涨跌/成交额)
- 状态卡(保留) + 当前势头(连续天数+5日斜率+上次弱势距今)
- 四维拆解(赚钱/投机/抗跌/趋势 迷你水平条, 对应色填充)
- 状态转换(转换次数+节奏标签 稳定/中等/频繁)

日历热力图(纯 CSS 月份网格, 弃用 echarts calendar)
- 单行模式(默认): 月份网格横向排列+滚动条, 默认滚到最新, 月均分 tag
- 展开模式: flex-wrap 自动换行的完整网格
- 每日格子按状态色填充, 月均分用状态色 tag 凸显(不再灰色文字)

修复趋势图切换菜单后不显示(useEChart 容器条件渲染的 ref 时序问题)
- 日历热力图容器改为始终渲染(与趋势图/饼图一致)

其他
- 页面宽度 max-w-6xl → max-w-[1440px](大屏铺满, 与行业分析一致)
- 去掉最强/最弱 Top5 模块(用户反馈没用)
- 新增 scoreToColor 辅助函数(综合分→状态色)

验证: tsc + pnpm build 通过
This commit is contained in:
shy3130
2026-08-02 23:21:15 +08:00
parent 201d1b3c33
commit 6646d0a664
+235 -31
View File
@@ -11,8 +11,8 @@ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import * as echarts from 'echarts'
import {
Activity, RefreshCw, Loader2, Gauge, TrendingUp,
Flame, BarChart3, Pencil,
Activity, RefreshCw, Loader2, Gauge, TrendingUp, TrendingDown, Minus,
Pencil, CalendarDays, Repeat, Rows3, LayoutGrid,
} from 'lucide-react'
import {
api, type RegimeRow, type RegimeState,
@@ -20,13 +20,21 @@ import {
} from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { useChartTheme } from '@/lib/theme'
import { fmtBigNum } from '@/lib/format'
import { toast } from '@/components/Toast'
import { Modal } from '@/components/Modal'
import { cn } from '@/lib/cn'
const STATE_ORDER: RegimeState[] = ['strong', 'lean_strong', 'range', 'lean_weak', 'weak']
/** 综合分 → 对应状态色(与 classify_state 阈值一致: 70/55/45/30) */
function scoreToColor(score: number): string {
if (score >= 70) return REGIME_STATE_COLORS.strong
if (score >= 55) return REGIME_STATE_COLORS.lean_strong
if (score >= 45) return REGIME_STATE_COLORS.range
if (score >= 30) return REGIME_STATE_COLORS.lean_weak
return REGIME_STATE_COLORS.weak
}
// ── 时间范围 ──────────────────────────────────────────────
// 1年=250 交易日, 2年=500 交易日; 自定义 1~1000; 全部走 start/end 日期范围。
type RangePreset = '1y' | '2y' | 'all' | { custom: number }
@@ -106,6 +114,8 @@ export function Regime() {
const qc = useQueryClient()
const [range, setRange] = useState<RangePreset>('1y')
const [customOpen, setCustomOpen] = useState(false)
// 日历热力图显示模式: false=单行(月份网格横向排列+滚动条, 默认), true=展开(换行完整网格)
const [calendarExpanded, setCalendarExpanded] = useState(false)
const ct = useChartTheme()
// coverage: "全部"模式 + 标题展示依赖
@@ -134,6 +144,47 @@ export function Regime() {
const rows: RegimeRow[] = history.data?.rows ?? []
const latest = rows.length > 0 ? rows[rows.length - 1] : null
// ── 当前势头: 末尾连续同态天数 + score 5日斜率(改善/恶化) + 上次弱势距今 ──
const momentum = useMemo(() => {
if (rows.length === 0) return null
const lastState = rows[rows.length - 1].state
// 末尾连续同态天数
let streak = 1
for (let i = rows.length - 2; i >= 0; i--) {
if (rows[i].state === lastState) streak++
else break
}
// score 5日斜率(正=改善, 负=恶化)
const recent = rows.slice(-5)
const slope = recent.length >= 2
? (recent[recent.length - 1].score - recent[0].score) / (recent.length - 1)
: 0
// 上次弱势(weak/lean_weak)距今天数
let lastWeakGap = 0
for (let i = rows.length - 1; i >= 0; i--) {
if (rows[i].state === 'weak' || rows[i].state === 'lean_weak') {
lastWeakGap = rows.length - 1 - i
break
}
}
return { streak, state: lastState, slope, lastWeakGap }
}, [rows])
// ── 状态转换频率: 近 N 天相邻 state 变化次数 ──
const transitions = useMemo(() => {
if (rows.length < 2) return { count: 0, rate: 0, label: '数据不足' }
let count = 0
for (let i = 1; i < rows.length; i++) {
if (rows[i].state !== rows[i - 1].state) count++
}
// 频率 = 转换次数 / 天数; <0.2 稳定, 0.2-0.4 中等, >0.4 频繁
const rate = count / (rows.length - 1)
const label = rate < 0.2 ? '稳定' : rate < 0.4 ? '中等切换' : '频繁切换'
return { count, rate, label }
}, [rows])
// 趋势图: 综合分主线 + 4 子维度曲线(可切换) + 状态背景色带 + 涨停数柱状
const trendOption = useMemo<echarts.EChartsOption | null>(() => {
if (rows.length === 0) return null
@@ -250,6 +301,44 @@ export function Regime() {
}, [states.data, ct])
const pieRef = useEChart(pieOption, [pieOption])
// 日历热力图数据: 按月分组(纯 CSS 渲染, 不依赖 echarts calendar 的跨年怪异行为)。
// 结构: [{ year, month, label, weeks: [[cell|gap]×7]×N }]
// 每个 cell = { date, score, state } 或 null(该位置无交易日, 如月初前的空位)
const calendarMonths = useMemo(() => {
if (rows.length === 0) return []
const byMonth = new Map<string, RegimeRow[]>()
for (const r of rows) {
const ym = r.date.slice(0, 7) // YYYY-MM
if (!byMonth.has(ym)) byMonth.set(ym, [])
byMonth.get(ym)!.push(r)
}
const MONTH_LABELS = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']
return [...byMonth.entries()].sort().map(([ym, monthRows]) => {
const [y, m] = ym.split('-')
const year = Number(y), month = Number(m)
// 该月第一天是周几(0=周日 → 转成周一为起始: 周一=0)
const firstDay = new Date(year, month - 1, 1)
let dow = firstDay.getDay() // 0=Sun..6=Sat
dow = dow === 0 ? 6 : dow - 1 // 转成 周一=0..周日=6
const cells: (RegimeRow | null)[] = Array(dow).fill(null)
for (const r of monthRows) cells.push(r)
// 补齐到 7 的倍数(完整周)
while (cells.length % 7 !== 0) cells.push(null)
// 切成每周一组
const weeks: (RegimeRow | null)[][] = []
for (let i = 0; i < cells.length; i += 7) weeks.push(cells.slice(i, i + 7))
return { year, month, label: `${y}${MONTH_LABELS[month - 1]}`, weeks }
})
}, [rows])
// 日历热力图横向滚动容器: 单行模式下默认滚到最右侧(显示最新月份)
const calendarScrollRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!calendarExpanded && calendarScrollRef.current) {
calendarScrollRef.current.scrollLeft = calendarScrollRef.current.scrollWidth
}
}, [calendarExpanded, calendarMonths])
const handleRecompute = async () => {
setRecomputing(true)
try {
@@ -274,7 +363,7 @@ export function Regime() {
: '自定义'
return (
<div className="mx-auto max-w-6xl px-4 py-5 space-y-4">
<div className="mx-auto max-w-[1440px] px-4 py-5 space-y-4">
{/* ── 头部 (Dashboard 渐变条卡片) ── */}
<div className={cn(cardCls, 'relative overflow-hidden rounded-card bg-gradient-to-r from-surface/90 to-surface/70 px-4 py-3')}>
<div className="absolute left-0 top-0 h-full w-1 bg-gradient-to-b from-accent to-accent/20" />
@@ -322,10 +411,10 @@ export function Regime() {
</div>
</div>
{/* ── 最新日概览 (4 个指标卡) ── */}
{/* ── 最新日概览 (4 个指标卡, 去掉与看板重复的涨停/涨跌/成交额) ── */}
{latest ? (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{/* 状态卡 */}
{/* 状态卡(保留) */}
<div className={cn(cardCls, 'p-3')}>
<div className="flex items-center gap-1.5 text-[10px] text-muted">
<Gauge className="h-3 w-3" /> · {latest.date}
@@ -336,51 +425,68 @@ export function Regime() {
</span>
<span className="text-sm text-muted">{latest.score} </span>
</div>
{/* 评分进度条 0~100 */}
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-base">
<div className="h-full rounded-full transition-all"
style={{ width: `${Math.max(2, Math.min(100, latest.score))}%`, backgroundColor: REGIME_STATE_COLORS[latest.state] }} />
</div>
</div>
{/* 涨停 / 跌停 */}
{/* 当前势头(新) */}
<div className={cn(cardCls, 'p-3')}>
<div className="flex items-center gap-1.5 text-[10px] text-muted">
<Flame className="h-3 w-3" /> /
{(() => {
const TrendIcon = (momentum?.slope ?? 0) > 0.5 ? TrendingUp : (momentum?.slope ?? 0) < -0.5 ? TrendingDown : Minus
return <TrendIcon className={`h-3 w-3 ${(momentum?.slope ?? 0) > 0.5 ? 'text-bull' : (momentum?.slope ?? 0) < -0.5 ? 'text-bear' : 'text-muted'}`} />
})()}
</div>
<div className="mt-1.5 flex items-baseline gap-1 text-lg font-semibold">
<span className="text-bull">{latest.limit_up}</span>
<span className="mx-0.5 text-muted">/</span>
<span className="text-bear">{latest.limit_down}</span>
</div>
<div className="mt-1 text-[10px] text-muted"> {latest.max_consecutive} · {(latest.seal_rate * 100).toFixed(0)}%</div>
{momentum ? (
<>
<div className="mt-1.5 text-sm font-semibold text-foreground">
<span style={{ color: REGIME_STATE_COLORS[momentum.state] }}>{momentum.streak}</span> {REGIME_STATE_LABELS[momentum.state]}
</div>
<div className="mt-1 text-[10px] text-muted">
5{(momentum.slope > 0 ? '改善' : momentum.slope < 0 ? '恶化' : '持平')}
{momentum.lastWeakGap > 0 && ` · 上次弱势 ${momentum.lastWeakGap} 天前`}
</div>
</>
) : <div className="mt-1.5 text-sm text-muted"></div>}
</div>
{/* 涨跌家数比 */}
{/* 4 子维度迷你条(新) */}
<div className={cn(cardCls, 'p-3')}>
<div className="flex items-center gap-1.5 text-[10px] text-muted">
<TrendingUp className="h-3 w-3" />
<Activity className="h-3 w-3" /> · {latest.date}
</div>
<div className="mt-1.5 text-lg font-semibold text-foreground">{latest.up_ratio.toFixed(2)}</div>
<div className="mt-1 flex items-center gap-1.5 text-[10px]">
<span className="text-bull"> {latest.up_count}</span>
<span className="text-muted">·</span>
<span className="text-bear"> {latest.down_count}</span>
<div className="mt-2 space-y-1">
{([
{ label: '赚钱', val: latest.profit_score, color: '#f59e0b' },
{ label: '投机', val: latest.speculation_score, color: '#a855f7' },
{ label: '抗跌', val: latest.resilience_score, color: '#10b981' },
{ label: '趋势', val: latest.trend_score, color: '#3b82f6' },
] as const).map(d => (
<div key={d.label} className="flex items-center gap-1.5">
<span className="w-6 shrink-0 text-[9px] text-muted">{d.label}</span>
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-base">
<div className="h-full rounded-full transition-all"
style={{ width: `${d.val ?? 0}%`, backgroundColor: d.color }} />
</div>
<span className="w-5 shrink-0 text-right text-[9px] font-mono text-muted">{d.val ?? '—'}</span>
</div>
))}
</div>
</div>
{/* 成交额 */}
{/* 状态转换频率(新) */}
<div className={cn(cardCls, 'p-3')}>
<div className="flex items-center gap-1.5 text-[10px] text-muted">
<BarChart3 className="h-3 w-3" />
<Repeat className="h-3 w-3" /> · {days}
</div>
<div className="mt-1.5 text-lg font-semibold text-foreground">{fmtBigNum(latest.total_amount)}</div>
<div className="mt-2 flex items-center gap-1.5">
<span className="text-[10px] text-muted">MA20 {(latest.above_ma20_pct * 100).toFixed(0)}%</span>
<div className="ml-auto h-1.5 w-12 overflow-hidden rounded-full bg-base">
<div className="h-full rounded-full bg-accent"
style={{ width: `${Math.max(0, Math.min(100, latest.above_ma20_pct * 100))}%` }} />
</div>
<div className="mt-1.5 text-lg font-semibold text-foreground">
{transitions.count} <span className="text-xs font-normal text-muted"></span>
</div>
<div className="mt-1 text-[10px] text-muted">
<span className="text-accent">{transitions.label}</span>
<span className="ml-1">({(transitions.rate * 100).toFixed(0)}%/)</span>
</div>
</div>
</div>
@@ -427,6 +533,104 @@ export function Regime() {
</div>
</div>
{/* ── 日历热力图(每日综合分按状态色, 支持单行/展开切换) ── */}
{calendarMonths.length > 0 && (
<div className={cn(cardCls, 'p-3')}>
<SectionTitle icon={CalendarDays} title="日历热力图"
hint={
<button
onClick={() => setCalendarExpanded(v => !v)}
className="inline-flex items-center gap-1 rounded-btn border border-border bg-base px-2 py-0.5 text-[10px] text-secondary hover:text-accent hover:border-accent/40 transition-colors"
title={calendarExpanded ? '切换为单行紧凑' : '切换为月份展开'}
>
{calendarExpanded ? <><Rows3 className="h-3 w-3" /></> : <><LayoutGrid className="h-3 w-3" /></>}
</button>
}
/>
{calendarExpanded ? (
/* 展开模式: 按月分块的完整日历网格, 自动换行 */
<div className="mt-3 flex flex-wrap gap-x-5 gap-y-3">
{calendarMonths.map(mo => {
const monthRows = mo.weeks.flat().filter((c): c is RegimeRow => !!c)
const avgScore = monthRows.length > 0
? Math.round(monthRows.reduce((s, r) => s + r.score, 0) / monthRows.length) : 0
return (
<div key={`${mo.year}-${mo.month}`} className="shrink-0">
<div className="mb-1 flex items-center gap-1.5">
<span className="text-[10px] font-medium text-secondary">{mo.label}</span>
{avgScore > 0 && (
<span className="rounded px-1 py-px text-[9px] font-semibold"
style={{ color: scoreToColor(avgScore), backgroundColor: scoreToColor(avgScore) + '20' }}>
{avgScore}
</span>
)}
</div>
<div className="mb-0.5 grid grid-cols-7 gap-[2px] text-[8px] text-muted">
{['一', '二', '三', '四', '五', '六', '日'].map(d => (
<div key={d} className="text-center">{d}</div>
))}
</div>
<div className="grid grid-cols-7 gap-[2px]">
{mo.weeks.flat().map((cell, i) => (
cell ? (
<div key={i}
title={`${cell.date} ${REGIME_STATE_LABELS[cell.state]}(${cell.score})`}
className="h-[14px] w-[14px] rounded-[2px] transition-transform hover:scale-125 hover:z-10 cursor-default"
style={{ backgroundColor: REGIME_STATE_COLORS[cell.state] }}
/>
) : (
<div key={i} className="h-[14px] w-[14px]" />
)
))}
</div>
</div>
)
})}
</div>
) : (
/* 单行模式(默认): 月份网格横向一行+滚动条, 默认滚到最新, 每月份带月均分 */
<div ref={calendarScrollRef} className="mt-3 flex gap-x-5 overflow-x-auto pb-2">
{calendarMonths.map(mo => {
const monthRows = mo.weeks.flat().filter((c): c is RegimeRow => !!c)
const avgScore = monthRows.length > 0
? Math.round(monthRows.reduce((s, r) => s + r.score, 0) / monthRows.length) : 0
return (
<div key={`${mo.year}-${mo.month}`} className="shrink-0">
<div className="mb-1 flex items-center gap-1.5">
<span className="text-[10px] font-medium text-secondary">{mo.label}</span>
{avgScore > 0 && (
<span className="rounded px-1 py-px text-[9px] font-semibold"
style={{ color: scoreToColor(avgScore), backgroundColor: scoreToColor(avgScore) + '20' }}>
{avgScore}
</span>
)}
</div>
<div className="mb-0.5 grid grid-cols-7 gap-[2px] text-[8px] text-muted">
{['一', '二', '三', '四', '五', '六', '日'].map(d => (
<div key={d} className="text-center">{d}</div>
))}
</div>
<div className="grid grid-cols-7 gap-[2px]">
{mo.weeks.flat().map((cell, i) => (
cell ? (
<div key={i}
title={`${cell.date} ${REGIME_STATE_LABELS[cell.state]}(${cell.score})`}
className="h-[14px] w-[14px] rounded-[2px] transition-transform hover:scale-125 hover:z-10 cursor-default"
style={{ backgroundColor: REGIME_STATE_COLORS[cell.state] }}
/>
) : (
<div key={i} className="h-[14px] w-[14px]" />
)
))}
</div>
</div>
)
})}
</div>
)}
</div>
)}
{/* ── 自定义天数弹窗 ── */}
{customOpen && (
<CustomDaysModal