Files
easy_tdx_max/web-ui/src/views/HotspotView.vue
T
Justin Gu 0a205ffbf3 feat: 盘面洞察第四批 — 板块相关性热力图 + 异动雷达 + 量能仪表盘
- 相关性 /hotspots 页内新增「相关性」视图:/board-mac/hotspot-correlation
  复用热点历史矩阵缓存,对窗口内活跃板块(每日前 per_day 名并集,按上榜次数
  取前 N)两两算日涨跌幅 Pearson 相关;红=同涨同跌(抱团)、绿=跷跷板(轮动),
  ECharts 热力图 + 双向色阶 visualMap;无缓存时透传 building 状态
- 异动雷达 /radar:沪深异动流时间线(封板/炸板/大笔买入/逼近涨停…),
  每分钟异动密度柱 + 类型筛选 chips(带计数),行点击直达个股弹窗,15s 轮询
- 量能仪表盘并入市场情绪页:两市(上证+深成 5 分钟线)累计成交额曲线
  vs 近 5 日同期均值(虚线),标题给出偏离百分比——放量/缩量一眼可辨
- 热点缓存判定重构为 _hotspot_history_or_build 公共入口,correlation 与
  hotspot 共用同一构建状态机;新增相关性矩阵回归单测
2026-09-05 04:09:03 +08:00

529 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
// 市场热点滚动(/hotspots):交易日 × 板块涨跌矩阵,直观展示热点形成/持续/轮动/领跌。
// 首次构建走后端后台任务(1s 轮询进度),完成后当日缓存;盘中 60s 轮询仅滚动今日列。
// 单击板块复用 BoardDialog。
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { fetchBoardHotspot, formatError } from '../api'
import BoardDialog from '../components/BoardDialog.vue'
import HotspotCorrelation from '../components/HotspotCorrelation.vue'
import HotspotMatrix from '../components/HotspotMatrix.vue'
import HotspotStatStrip from '../components/HotspotStatStrip.vue'
import type { HotspotResp, HotspotRow } from '../types'
// boardType 可由路由 props 注入(/styles → FG 风格轮动),页内仍可自由切换
const props = defineProps<{ boardType?: 'HY' | 'GN' | 'FG' }>()
const PER_DAY = 5 // 每日前 5 名入选(与后端默认一致)
type SortKey = 'days_in' | 'sum_pct' | 'first_date'
const boardType = ref<'HY' | 'GN' | 'FG'>(props.boardType ?? 'HY')
const days = ref<number>(20)
const mode = ref<'top' | 'bottom'>('top')
const viewMode = ref<'matrix' | 'corr'>('matrix')
watch(
() => props.boardType,
(t) => {
if (t && t !== boardType.value) setType(t)
},
)
const typeLabels: Record<'HY' | 'GN' | 'FG', string> = { HY: '行业', GN: '概念', FG: '风格' }
const dayOptions: Array<{ v: number; label: string }> = [
{ v: 1, label: '今日' },
{ v: 10, label: '近10日' },
{ v: 20, label: '近20日' },
{ v: 30, label: '近30日' },
]
// ── 数据状态机:loading → building(进度) → ready / error ─────────────────────
const resp = ref<HotspotResp | null>(null)
const buildingProgress = ref<number | null>(null) // null = 非构建中
const buildError = ref('')
const loading = ref(false)
const lastRefresh = ref('')
let buildTimer = 0
function stopBuildPoll() {
if (buildTimer) {
window.clearInterval(buildTimer)
buildTimer = 0
}
}
async function load(retry = false) {
try {
const r = await fetchBoardHotspot(boardType.value, days.value, mode.value, PER_DAY, retry)
if (r.status === 'building') {
buildError.value = ''
resp.value = null
loading.value = false
buildingProgress.value = r.progress ?? 0
if (!buildTimer) buildTimer = window.setInterval(pollBuild, 1000)
return
}
stopBuildPoll()
buildingProgress.value = null
if (r.status === 'error') {
buildError.value = r.error || '热点矩阵构建失败'
return
}
buildError.value = ''
resp.value = r
loading.value = false
lastRefresh.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
} catch (e) {
stopBuildPoll()
buildingProgress.value = null
buildError.value = formatError(e)
}
}
function pollBuild() {
if (!document.hidden) load()
}
const buildPct = computed(() => Math.round((buildingProgress.value ?? 0) * 100))
function setType(t: 'HY' | 'GN' | 'FG') {
if (boardType.value === t) return
boardType.value = t
resetAndLoad()
}
function setDays(d: number) {
if (days.value === d) return
days.value = d
resetAndLoad()
}
function setMode(m: 'top' | 'bottom') {
if (mode.value === m) return
mode.value = m
resetAndLoad()
}
function resetAndLoad() {
stopBuildPoll()
resp.value = null
buildingProgress.value = null
buildError.value = ''
loading.value = true
load()
}
// ── 行排序 / 过滤(数据已全量在内存,纯前端) ─────────────────────────────────
const search = ref('')
const onlyMulti = ref(false)
const sortKey = ref<SortKey>('days_in')
const dates = computed(() => resp.value?.dates ?? [])
const perDay = computed(() => resp.value?.per_day ?? PER_DAY)
const todayIndex = computed(() => resp.value?.today_index ?? null)
const displayRows = computed<HotspotRow[]>(() => {
let out = resp.value?.rows ?? []
const q = search.value.trim().toLowerCase()
if (q) out = out.filter((r) => r.name.toLowerCase().includes(q) || r.code.includes(q))
if (onlyMulti.value) out = out.filter((r) => r.days_in >= 2)
return [...out].sort((a, b) => {
switch (sortKey.value) {
case 'sum_pct':
return (b.sum_pct ?? -Infinity) - (a.sum_pct ?? -Infinity)
case 'first_date':
return (b.first_date ?? '').localeCompare(a.first_date ?? '') // 新热点在前
default:
return b.days_in - a.days_in || (b.sum_pct ?? 0) - (a.sum_pct ?? 0)
}
})
})
// ── 轮询调度(同行业总览:交易时段门控 + 页面隐藏暂停) ───────────────────────
let refreshTimer = 0
let sessionTimer = 0
const sessionGated = ref(localStorage.getItem('hotspot.sessionGated') !== '0')
function onSessionToggle() {
localStorage.setItem('hotspot.sessionGated', sessionGated.value ? '1' : '0')
}
function isTradeSession(now = new Date()): boolean {
const day = now.getDay()
if (day === 0 || day === 6) return false
const m = now.getHours() * 60 + now.getMinutes()
return (m >= 555 && m <= 690) || (m >= 780 && m <= 905)
}
const inSession = ref(isTradeSession())
const autoPaused = computed(() => sessionGated.value && !inSession.value)
const sessionLabel = computed(() => {
if (!sessionGated.value) return '全天候模式'
return inSession.value ? '交易中' : '休市 · 自动刷新已暂停'
})
function tick() {
inSession.value = isTradeSession()
if (autoPaused.value || document.hidden) return
if (resp.value) load() // 历史列服务端当日缓存,仅今日列滚动,请求很快
}
onMounted(() => {
load()
refreshTimer = window.setInterval(tick, 60_000)
sessionTimer = window.setInterval(() => {
inSession.value = isTradeSession()
}, 60_000)
})
onBeforeUnmount(() => {
stopBuildPoll()
window.clearInterval(refreshTimer)
window.clearInterval(sessionTimer)
})
// ── 弹窗(板块详情) ─────────────────────────────────────────────────────────
const boardDialog = ref<{ code: string; name: string } | null>(null)
function openBoard(r: HotspotRow) {
boardDialog.value = { code: r.code, name: r.name }
}
</script>
<template>
<div class="hotspot-view">
<div class="view-head">
<h2>热点滚动</h2>
<span class="dim head-sub">板块热点的形成 · 持续 · 轮动</span>
<span v-if="resp?.total_boards" class="dim head-sub">参与板块 {{ resp.total_boards }}</span>
<span v-if="resp?.session === 'live'" class="live-badge">今日列 · 实时</span>
</div>
<!-- 工具行 -->
<div class="toolbar">
<span class="seg">
<button
v-for="t in (['HY', 'GN', 'FG'] as const)"
:key="t"
:class="{ on: boardType === t }"
@click="setType(t)"
>
{{ typeLabels[t] }}
</button>
</span>
<span class="seg">
<button
v-for="o in dayOptions"
:key="o.v"
:class="{ on: days === o.v }"
:title="o.v === 1 ? '今日领涨/领跌前5名' : undefined"
@click="setDays(o.v)"
>
{{ o.label }}
</button>
</span>
<span class="seg">
<button :class="{ on: mode === 'top' }" @click="setMode('top')">领涨</button>
<button :class="{ on: mode === 'bottom' }" @click="setMode('bottom')">领跌</button>
</span>
<span class="seg">
<button :class="{ on: viewMode === 'matrix' }" @click="viewMode = 'matrix'">热点矩阵</button>
<button :class="{ on: viewMode === 'corr' }" @click="viewMode = 'corr'">相关性</button>
</span>
<label class="tb-label">排序
<select v-model="sortKey">
<option value="days_in">上榜次数</option>
<option value="sum_pct">累计涨跌</option>
<option value="first_date">最新上榜</option>
</select>
</label>
<label class="tb-label multi-toggle">
<input v-model="onlyMulti" type="checkbox" />
只看上榜2
</label>
<input v-model="search" class="search" type="text" placeholder="搜索板块名/代码" />
<span class="tb-spacer"></span>
<span class="dot" :class="{ live: !autoPaused }"></span>
<span class="session-label" :class="{ paused: autoPaused }">{{ sessionLabel }}</span>
<label class="session-toggle">
<input v-model="sessionGated" type="checkbox" @change="onSessionToggle" />
仅交易时段刷新
</label>
<span v-if="lastRefresh" class="dim refresh-ts">{{ lastRefresh }}</span>
<button class="manual-refresh" @click="load()"> 刷新</button>
</div>
<!-- 构建中 / 失败 -->
<div v-if="buildingProgress !== null" class="building card">
<div class="build-text">正在构建板块日K矩阵 {{ buildPct }}%</div>
<div class="build-bar">
<div class="build-fill" :style="{ width: buildPct + '%' }"></div>
</div>
<div class="dim build-hint">
首次构建需逐板块拉取日K{{ boardType === 'GN' ? '概念板块数量多,' : '' }}约需数十秒),
完成后当日缓存秒级刷新
</div> </div>
<div v-else-if="buildError" class="err card">
构建失败{{ buildError }}
<button @click="load(true)">重试</button>
</div>
<div v-else-if="loading" class="loading">加载中</div>
<!-- 主区统计卡 + 图例 + 矩阵 / 相关性 -->
<template v-else-if="resp && viewMode === 'matrix'">
<HotspotStatStrip :rows="resp.rows ?? []" :dates="dates" :mode="mode" @select="openBoard" />
<div class="legend">
<span class="lg-title">色阶</span>
<i class="sw u1"></i><i class="sw u2"></i><i class="sw u3"></i>
<span></span>
<i class="sw d1"></i><i class="sw d2"></i><i class="sw d3"></i>
<span>越深幅度越大</span>
<span class="lg-sep">|</span>
<span><b class="lg-rank">~</b> 当日前 {{ perDay }} {{ mode === 'top' ? '最强' : '最弱' }}</span>
<span>描边 = 当日前 3 </span>
<span class="lg-sep">|</span>
<span>单击板块看分时/日K/成分股</span>
</div>
<div class="matrix-card card">
<HotspotMatrix
:dates="dates"
:rows="displayRows"
:per-day="perDay"
:today-index="todayIndex"
:mode="mode"
@select="openBoard"
/>
</div>
</template>
<!-- 相关性视图活跃板块两两日涨跌幅相关系数 -->
<template v-else-if="viewMode === 'corr'">
<HotspotCorrelation :board-type="boardType" :days="days" :per-day="PER_DAY" />
</template>
<BoardDialog
v-if="boardDialog"
:code="boardDialog.code"
:name="boardDialog.name"
@close="boardDialog = null"
/>
</div>
</template>
<style scoped>
.hotspot-view {
height: 100%;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.view-head,
.toolbar,
.legend,
.building,
.err,
.loading {
flex-shrink: 0;
}
.view-head {
display: flex;
align-items: center;
gap: 8px;
}
.view-head h2 {
font-size: 17px;
font-weight: 700;
}
.head-sub {
font-size: 12px;
}
.live-badge {
font-size: 11px;
color: var(--up);
border: 1px solid var(--up);
border-radius: 3px;
padding: 0 6px;
}
.toolbar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.seg {
display: inline-flex;
}
.seg button {
border-radius: 0;
font-size: 12px;
padding: 4px 12px;
}
.seg button:first-child {
border-radius: var(--radius) 0 0 var(--radius);
}
.seg button:last-child {
border-radius: 0 var(--radius) var(--radius) 0;
margin-left: -1px;
}
.seg button.on {
border-color: var(--accent);
color: var(--accent);
background: rgba(74, 158, 255, 0.12);
position: relative;
z-index: 1;
}
.tb-label {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 0;
font-size: 12px;
color: var(--text-muted);
}
.tb-label select {
width: auto;
padding: 4px 8px;
font-size: 12px;
}
.multi-toggle input {
width: auto;
}
.search {
width: 170px;
padding: 4px 10px;
font-size: 12px;
}
.tb-spacer {
flex: 1;
}
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--text-dim);
}
.dot.live {
background: var(--up);
box-shadow: 0 0 4px var(--up);
}
.session-label {
font-size: 11.5px;
color: var(--text-muted);
}
.session-label.paused {
color: var(--warn);
}
.session-toggle {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 0;
font-size: 11.5px;
color: var(--text-muted);
white-space: nowrap;
}
.session-toggle input {
width: auto;
}
.refresh-ts {
font-family: var(--font-mono);
font-size: 11.5px;
}
.manual-refresh {
font-size: 12px;
padding: 4px 10px;
}
.building {
padding: 18px 20px;
}
.build-text {
font-size: 13px;
font-weight: 600;
margin-bottom: 8px;
}
.build-bar {
height: 8px;
background: var(--bg-elevated);
border-radius: 4px;
overflow: hidden;
}
.build-fill {
height: 100%;
background: var(--accent);
border-radius: 4px;
transition: width 0.4s ease;
}
.build-hint {
font-size: 11.5px;
margin-top: 8px;
}
.err {
color: var(--up);
display: flex;
align-items: center;
gap: 10px;
}
.loading {
padding: 40px 0;
text-align: center;
color: var(--text-dim);
}
.legend {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
font-size: 11.5px;
color: var(--text-muted);
}
.lg-title {
color: var(--text-dim);
}
.sw {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 2px;
margin: 0 1px;
vertical-align: -2px;
}
.sw.u1 {
background: rgba(239, 65, 70, 0.26);
}
.sw.u2 {
background: rgba(239, 65, 70, 0.5);
}
.sw.u3 {
background: rgba(239, 65, 70, 0.82);
}
.sw.d1 {
background: rgba(24, 160, 88, 0.26);
}
.sw.d2 {
background: rgba(24, 160, 88, 0.5);
}
.sw.d3 {
background: rgba(24, 160, 88, 0.82);
}
.lg-sep {
color: var(--border);
}
.lg-rank {
color: var(--text);
}
.matrix-card {
padding: 8px;
}
.matrix-card :deep(.matrix-wrap) {
max-height: calc(100vh - 380px);
}
</style>