feat: 行业/概念总览 — WebUI 板块总览双栏目(统计条+热力图/表格+三榜/翻红翻绿+板块弹窗复用)+ /board-mac/overview 聚合端点(多排序归并+15s缓存)

This commit is contained in:
GitHub
2026-09-04 15:59:57 +08:00
parent d6478a1e1c
commit f6560f3c6d
11 changed files with 1587 additions and 0 deletions
+2
View File
@@ -26,6 +26,8 @@ const sseLabel: Record<string, string> = {
<nav class="side-nav">
<div class="nav-group">行情</div>
<RouterLink to="/" exact-active-class="active">市场看板</RouterLink>
<RouterLink to="/industries" active-class="active">行业总览</RouterLink>
<RouterLink to="/concepts" active-class="active">概念总览</RouterLink>
<RouterLink to="/watchlist" active-class="active">自选行情</RouterLink>
<RouterLink to="/leaders" active-class="active">龙头池</RouterLink>
<RouterLink to="/ccpm" active-class="active">期货持仓排名</RouterLink>
+15
View File
@@ -6,6 +6,7 @@ import type {
BacktestRequest,
BacktestResult,
Bar,
BoardOverviewResp,
BoardRow,
Category,
CoreLeaderRow,
@@ -567,6 +568,20 @@ export async function fetchBoards(boardType = 'HY', count = 60): Promise<BoardRo
})
}
/** 板块总览(服务端一次归并当日涨跌幅 + 涨速 + 多周期涨幅,15s 缓存)。
* 当日涨跌幅后端已按 price/pre_close-1 计算;未请求/缺失的周期指标为 null。 */
export async function fetchBoardOverview(
boardType: string,
metrics = 'SPEED,CHANGE_3D,CHANGE_5D,CHANGE_20D,YTD',
): Promise<BoardOverviewResp> {
const params = new URLSearchParams({ board_type: boardType, metrics })
const resp = await fetch(`${BASE}/board-mac/overview?${params}`)
if (!resp.ok) await throwError(resp)
// 后端 DictResponse 包装为 {data: {...}},这里解包
const body = (await resp.json()) as { data: BoardOverviewResp }
return body.data
}
/** 市场异动流(火箭发射/大笔买入/封涨停板/打开跌停板/快速反弹等)。 */
export async function fetchUnusual(market: 'SH' | 'SZ', count = 50): Promise<Record<string, unknown>[]> {
const params = new URLSearchParams({ market, count: String(count) })
+166
View File
@@ -0,0 +1,166 @@
<script setup lang="ts">
// 板块总览右栏:涨幅/跌幅/涨速异动 Top10 + 翻红翻绿时间线。
// 全部由父组件传入的全量 rows 在内存内计算,不额外发请求。
import { computed } from 'vue'
import { fmtPctSigned } from '../format'
import type { BoardFlipEvent, BoardOverviewRow } from '../types'
const props = defineProps<{
rows: BoardOverviewRow[]
flips: BoardFlipEvent[]
}>()
const emit = defineEmits<{ select: [row: BoardOverviewRow] }>()
const TOP_N = 10
function topBy(
rows: BoardOverviewRow[],
key: 'change_pct' | 'speed',
order: 'desc' | 'asc' = 'desc',
n = TOP_N,
): BoardOverviewRow[] {
const sorted = rows
.filter((r) => r[key] !== null && r[key] !== undefined)
.sort((a, b) => (b[key] as number) - (a[key] as number))
return (order === 'desc' ? sorted : sorted.slice().reverse()).slice(0, n)
}
const topGainers = computed(() => topBy(props.rows, 'change_pct'))
const topLosers = computed(() => topBy(props.rows, 'change_pct', 'asc'))
/** 涨速异动:按 |speed| 降序,急拉急杀都算;|speed| ≥ 0.5% 标闪烁。 */
const rowsByAbsSpeed = computed(() =>
props.rows
.filter((r) => r.speed !== null && r.speed !== undefined)
.sort((a, b) => Math.abs(b.speed as number) - Math.abs(a.speed as number)),
)
const speedMovers = computed(() => rowsByAbsSpeed.value.slice(0, TOP_N))
const recentFlips = computed(() => props.flips.slice(0, 12))
</script>
<template>
<div class="rail">
<div class="card rank-card">
<h3>涨幅榜 <span class="dim">Top{{ TOP_N }}</span></h3>
<div v-for="(r, i) in topGainers" :key="r.code" class="rank-row clickable" @click="emit('select', r)">
<span class="r-idx dim">{{ i + 1 }}</span>
<span class="r-name">{{ r.name }}</span>
<span class="r-pct mono up">{{ fmtPctSigned(r.change_pct) }}</span>
</div>
<div v-if="topGainers.length === 0" class="rank-empty dim">暂无数据</div>
</div>
<div class="card rank-card">
<h3>跌幅榜 <span class="dim">Top{{ TOP_N }}</span></h3>
<div v-for="(r, i) in topLosers" :key="r.code" class="rank-row clickable" @click="emit('select', r)">
<span class="r-idx dim">{{ i + 1 }}</span>
<span class="r-name">{{ r.name }}</span>
<span class="r-pct mono down">{{ fmtPctSigned(r.change_pct) }}</span>
</div>
<div v-if="topLosers.length === 0" class="rank-empty dim">暂无数据</div>
</div>
<div class="card rank-card">
<h3>异动 · 涨速 <span class="dim">|涨速| Top{{ TOP_N }}</span></h3>
<div
v-for="r in speedMovers"
:key="r.code"
class="rank-row clickable"
:class="{ flashing: r.speed !== null && Math.abs(r.speed) >= 0.5 }"
@click="emit('select', r)"
>
<span class="r-name">{{ r.name }}</span>
<span class="r-pct mono" :class="(r.speed ?? 0) >= 0 ? 'up' : 'down'">{{ fmtPctSigned(r.speed) }}</span>
</div>
<div v-if="speedMovers.length === 0" class="rank-empty dim">暂无数据</div>
</div>
<div class="card rank-card">
<h3>板块轮动 <span class="dim">翻红 / 翻绿</span></h3>
<div v-if="recentFlips.length === 0" class="rank-empty dim">刷新期间暂无翻红/翻绿</div>
<div v-for="(f, i) in recentFlips" :key="`${f.code}-${f.time}-${i}`" class="flip-row">
<span class="f-time mono dim">{{ f.time }}</span>
<span class="f-type" :class="f.type === 'up' ? 'up' : 'down'">{{ f.type === 'up' ? '翻红' : '翻绿' }}</span>
<span class="f-name">{{ f.name }}</span>
<span class="r-pct mono" :class="f.type === 'up' ? 'up' : 'down'">{{ fmtPctSigned(f.change_pct) }}</span>
</div>
</div>
</div>
</template>
<style scoped>
.rail {
display: flex;
flex-direction: column;
gap: 10px;
}
.rank-card {
padding: 10px 12px;
}
.rank-card h3 {
margin-bottom: 6px;
}
.rank-card h3 .dim {
font-size: 10.5px;
font-weight: 400;
}
.rank-row,
.flip-row {
display: flex;
align-items: center;
gap: 6px;
padding: 2.5px 4px;
font-size: 12px;
border-radius: 3px;
}
.rank-row.clickable {
cursor: pointer;
}
.rank-row.clickable:hover {
background: var(--bg-elevated);
}
.r-idx {
width: 16px;
font-size: 10.5px;
text-align: right;
}
.r-name,
.f-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.r-pct {
font-weight: 600;
}
.flashing {
animation: flash 1s ease-in-out infinite;
}
@keyframes flash {
0%,
100% {
background: transparent;
}
50% {
background: rgba(240, 160, 32, 0.18);
}
}
.flip-row {
font-size: 11.5px;
}
.f-time {
font-size: 10.5px;
}
.f-type {
width: 30px;
font-weight: 600;
}
.rank-empty {
padding: 8px 0;
font-size: 12px;
}
</style>
+171
View File
@@ -0,0 +1,171 @@
<script setup lang="ts">
// 板块总览统计条:板块广度(上涨/下跌/平盘)+ 涨幅中位数 + 全市场涨停/跌停
// + 涨跌幅分布直方图(纯 CSS 柱,±1% 一档,截断 ±3% 外两档)。
import { computed } from 'vue'
import { fmtPctSigned } from '../format'
import type { BoardOverviewRow, MarketStat } from '../types'
const props = defineProps<{
rows: BoardOverviewRow[]
stat: MarketStat | null
}>()
const breadth = computed(() => {
let up = 0
let down = 0
let flat = 0
for (const r of props.rows) {
const p = r.change_pct
if (p === null || p === undefined) continue
if (p > 0) up++
else if (p < 0) down++
else flat++
}
return { up, down, flat }
})
const medianPct = computed<number | null>(() => {
const vals = props.rows
.map((r) => r.change_pct)
.filter((v): v is number => v !== null && v !== undefined)
.sort((a, b) => a - b)
if (vals.length === 0) return null
const mid = Math.floor(vals.length / 2)
return vals.length % 2 ? vals[mid] : (vals[mid - 1] + vals[mid]) / 2
})
// 直方图:[-Inf,-3) [-3,-2) [-2,-1) [-1,0) [0,1) [1,2) [2,3) [3,+Inf)
const BUCKETS = [
{ label: '≤-3', dir: 'down' as const },
{ label: '-3~-2', dir: 'down' as const },
{ label: '-2~-1', dir: 'down' as const },
{ label: '-1~0', dir: 'down' as const },
{ label: '0~1', dir: 'up' as const },
{ label: '1~2', dir: 'up' as const },
{ label: '2~3', dir: 'up' as const },
{ label: '≥3', dir: 'up' as const },
]
const hist = computed(() => {
const counts = new Array(BUCKETS.length).fill(0) as number[]
for (const r of props.rows) {
const p = r.change_pct
if (p === null || p === undefined) continue
const idx = p < -3 ? 0 : p < -2 ? 1 : p < -1 ? 2 : p < 0 ? 3 : p < 1 ? 4 : p < 2 ? 5 : p < 3 ? 6 : 7
counts[idx]++
}
const max = Math.max(...counts, 1)
return BUCKETS.map((b, i) => ({
...b,
count: counts[i],
// 68% 上限给顶部数字留空间
height: counts[i] > 0 ? `${Math.max((counts[i] / max) * 68, 8)}%` : '0',
}))
})
</script>
<template>
<div class="stat-strip card">
<div class="stat-cell">
<span class="stat-label">板块广度</span>
<span class="stat-value">
<span class="mono">{{ rows.length }}</span>
<span class="mono up"> {{ breadth.up }}</span>
<span class="mono down"> {{ breadth.down }}</span>
<span v-if="breadth.flat" class="mono flat"> {{ breadth.flat }}</span>
</span>
</div>
<div class="stat-cell">
<span class="stat-label">涨幅中位数</span>
<span class="stat-value mono" :class="medianPct === null ? 'flat' : medianPct > 0 ? 'up' : medianPct < 0 ? 'down' : 'flat'">
{{ medianPct === null ? '-' : fmtPctSigned(medianPct) }}
</span>
</div>
<div class="stat-cell">
<span class="stat-label">全市场涨停 / 跌停</span>
<span class="stat-value mono">
<span class="up">{{ stat?.limit_up_count ?? '-' }}</span>
<span class="dim"> / </span>
<span class="down">{{ stat?.limit_down_count ?? '-' }}</span>
</span>
</div>
<div class="stat-cell hist-cell">
<span class="stat-label">板块涨跌幅分布</span>
<div class="hist">
<div v-for="b in hist" :key="b.label" class="hist-col">
<div class="hist-bar" :class="b.dir" :style="{ height: b.height }"></div>
<span v-if="b.count" class="hist-n mono">{{ b.count }}</span>
<div class="hist-label">{{ b.label }}</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.stat-strip {
display: flex;
align-items: stretch;
gap: 20px;
padding: 10px 14px;
}
.stat-cell {
display: flex;
flex-direction: column;
justify-content: center;
gap: 2px;
flex-shrink: 0;
}
.hist-cell {
flex: 1;
min-width: 260px;
}
.stat-label {
font-size: 11px;
color: var(--text-dim);
}
.stat-value {
font-size: 14px;
font-weight: 600;
}
.hist {
display: flex;
align-items: flex-end;
gap: 6px;
height: 44px;
margin-top: 2px;
}
.hist-col {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
height: 100%;
position: relative;
min-width: 0;
}
.hist-bar {
width: 100%;
border-radius: 2px 2px 0 0;
}
.hist-bar.up {
background: var(--up);
opacity: 0.75;
}
.hist-bar.down {
background: var(--down);
opacity: 0.75;
}
.hist-n {
font-size: 9.5px;
color: var(--text-muted);
line-height: 1.1;
}
.hist-label {
font-size: 9.5px;
color: var(--text-dim);
white-space: nowrap;
}
</style>
+106
View File
@@ -0,0 +1,106 @@
<script setup lang="ts">
// 板块热力图:等宽色块网格(纯 CSS Grid,红涨绿跌、透明度随涨跌幅增强)。
// 数据已由父组件排序过滤;单击色块打开板块弹窗。500 块内 DOM 足够流畅。
import { computed } from 'vue'
import { fmt2, fmtPctSigned } from '../format'
import type { BoardOverviewRow } from '../types'
const props = defineProps<{
rows: BoardOverviewRow[]
/** tile 最小宽度(概念页可传小值) */
tileMinWidth?: number
}>()
const emit = defineEmits<{ select: [row: BoardOverviewRow] }>()
/** 涨跌幅 → 背景色(A股红涨绿跌,|pct| 分 5 档增强)。 */
function tileStyle(r: BoardOverviewRow): Record<string, string> {
const p = r.change_pct
if (p === null || p === undefined || p === 0) {
return { background: 'var(--bg-elevated)', color: 'var(--text-muted)' }
}
const mag = Math.abs(p)
const tier = mag > 3 ? 0.82 : mag > 2 ? 0.62 : mag > 1 ? 0.42 : mag > 0.5 ? 0.26 : 0.14
const base = p > 0 ? '239, 65, 70' : '24, 160, 88' // var(--up) / var(--down) 的 rgb
return { background: `rgba(${base}, ${tier})`, color: '#fff' }
}
function tileTitle(r: BoardOverviewRow): string {
const parts = [`${r.name} (${r.code})`]
if (r.price) parts.push(`最新 ${fmt2(r.price)}`)
parts.push(`涨跌幅 ${fmtPctSigned(r.change_pct)}`)
if (r.speed !== null && r.speed !== undefined) parts.push(`涨速 ${fmtPctSigned(r.speed)}`)
if (r.leader_name) parts.push(`领涨 ${r.leader_name} ${fmtPctSigned(r.leader_change_pct)}`)
return parts.join('\n')
}
const gridStyle = computed(() => ({
gridTemplateColumns: `repeat(auto-fill, minmax(${props.tileMinWidth ?? 104}px, 1fr))`,
}))
</script>
<template>
<div class="tiles" :style="gridStyle">
<div
v-for="r in rows"
:key="r.code"
class="tile"
:style="tileStyle(r)"
:title="tileTitle(r)"
@click="emit('select', r)"
>
<span class="t-name">{{ r.name }}</span>
<span class="t-pct mono">{{ fmtPctSigned(r.change_pct) }}</span>
<span v-if="r.leader_name" class="t-leader">
{{ r.leader_name }} <span class="mono">{{ fmtPctSigned(r.leader_change_pct) }}</span>
</span>
</div>
<div v-if="rows.length === 0" class="tiles-empty">没有匹配的板块</div>
</div>
</template>
<style scoped>
.tiles {
display: grid;
gap: 4px;
}
.tile {
display: flex;
flex-direction: column;
gap: 1px;
padding: 7px 8px;
border-radius: 4px;
cursor: pointer;
min-height: 58px;
overflow: hidden;
transition: filter 0.1s;
}
.tile:hover {
filter: brightness(1.25);
}
.t-name {
font-size: 12px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.t-pct {
font-size: 13px;
font-weight: 700;
}
.t-leader {
font-size: 10px;
opacity: 0.85;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tiles-empty {
grid-column: 1 / -1;
padding: 40px 0;
text-align: center;
color: var(--text-dim);
}
</style>
+4
View File
@@ -1,6 +1,7 @@
import { createRouter, createWebHistory } from 'vue-router'
import BacktestView from './views/BacktestView.vue'
import BoardOverviewView from './views/BoardOverviewView.vue'
import CcpmView from './views/CcpmView.vue'
import CompareView from './views/CompareView.vue'
import CoreLeadersView from './views/CoreLeadersView.vue'
@@ -20,6 +21,9 @@ import WatchlistView from './views/WatchlistView.vue'
// + 设置(/settings 服务器 / /llm AI 模型)。
const routes = [
{ path: '/', name: 'dashboard', component: DashboardView },
// 行业/概念总览(同一视图组件,路由 props 区分板块类型)
{ path: '/industries', name: 'industries', component: BoardOverviewView, props: { boardType: 'HY' } },
{ path: '/concepts', name: 'concepts', component: BoardOverviewView, props: { boardType: 'GN' } },
{ path: '/watchlist', name: 'watchlist', component: WatchlistView },
{ path: '/backtest', name: 'backtest', component: BacktestView },
{ path: '/portfolio', name: 'portfolio', component: PortfolioView },
+40
View File
@@ -558,6 +558,46 @@ export interface RankRow {
[key: string]: unknown
}
// ── 行业/概念总览(GET /api/v1/board-mac/overview,服务端归并多排序键) ──────
/** 板块总览行。多周期指标未请求/缺失时为 null;当日涨跌幅由后端按 price/pre_close 计算。 */
export interface BoardOverviewRow {
market: number
code: string
name: string
price: number
pre_close: number
change_pct: number | null
speed: number | null
chg_3d: number | null
chg_5d: number | null
chg_10d: number | null
chg_20d: number | null
chg_60d: number | null
chg_ytd: number | null
leader_code: string
leader_name: string
leader_change_pct: number | null
}
export interface BoardOverviewResp {
board_type: string
/** 数据抓取时刻(epoch 秒) */
ts: number
count: number
rows: BoardOverviewRow[]
}
/** 板块翻红/翻绿事件(前端对相邻两次快照 diff 产生)。 */
export interface BoardFlipEvent {
code: string
name: string
/** up = 翻红(负转正),down = 翻绿(正转负) */
type: 'up' | 'down'
time: string
change_pct: number
}
// ── Walk-Forward 样本外验证(v1.27 POST /backtest/wf/run/async)──────────────
export interface WalkForwardWindow {
+488
View File
@@ -0,0 +1,488 @@
<script setup lang="ts">
// 行业/概念总览(/industries、/concepts 共用,路由 props 区分 board_type)。
// 顶部统计条 + [热力图|表格] 主区 + 右栏榜单异动;单击板块复用 BoardDialog。
// 数据走 /board-mac/overview(服务端归并多排序键,15s 缓存),30s 轮询、休市暂停。
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import {
fetchBoardOverview,
fetchMarketStat,
formatError,
} from '../api'
import { dirClass, fmt2, fmtPctSigned } from '../format'
import BoardDialog from '../components/BoardDialog.vue'
import BoardRankRail from '../components/BoardRankRail.vue'
import BoardStatStrip from '../components/BoardStatStrip.vue'
import BoardTiles from '../components/BoardTiles.vue'
import type { BoardFlipEvent, BoardOverviewRow, MarketStat } from '../types'
const props = defineProps<{
boardType: 'HY' | 'GN'
}>()
// 行业页可切一级(HY)/二级(HY2)分类;概念页固定 GN
const activeType = ref<'HY' | 'HY2' | 'GN'>(props.boardType)
const isIndustry = computed(() => props.boardType !== 'GN')
const title = computed(() => (props.boardType === 'GN' ? '概念总览' : '行业总览'))
const titleSub = computed(() =>
props.boardType !== 'GN' && activeType.value === 'HY2' ? '二级' : '',
)
// ── 数据状态 ──────────────────────────────────────────────────────────────────
const rows = ref<BoardOverviewRow[]>([])
const loading = ref(false)
const error = ref('')
const lastRefresh = ref('')
const stat = ref<MarketStat | null>(null)
async function loadOverview() {
loading.value = rows.value.length === 0
error.value = ''
try {
const resp = await fetchBoardOverview(activeType.value)
rows.value = resp.rows
lastRefresh.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
diffFlips(resp.rows)
} catch (e) {
error.value = formatError(e)
} finally {
loading.value = false
}
}
async function loadStat() {
try {
stat.value = await fetchMarketStat()
} catch {
stat.value = null // 统计条独立降级,不打扰主数据
}
}
// ── 翻红/翻绿:对相邻两次快照按涨跌幅符号 diff ────────────────────────────────
const flips = ref<BoardFlipEvent[]>([])
let snapshot: Map<string, number> | null = null
function diffFlips(fresh: BoardOverviewRow[]) {
const now = new Date().toLocaleTimeString('zh-CN', { hour12: false })
const next = new Map<string, number>()
const names = new Map<string, string>()
for (const r of fresh) {
if (r.change_pct !== null && r.change_pct !== undefined) next.set(r.code, r.change_pct)
names.set(r.code, r.name)
}
if (snapshot) {
const events: BoardFlipEvent[] = []
for (const [code, pct] of next) {
const before = snapshot.get(code)
if (before === undefined) continue
if (before <= 0 && pct > 0) {
events.push({ code, name: names.get(code) ?? code, type: 'up', time: now, change_pct: pct })
} else if (before >= 0 && pct < 0) {
events.push({ code, name: names.get(code) ?? code, type: 'down', time: now, change_pct: pct })
}
}
if (events.length > 0) flips.value = [...events, ...flips.value].slice(0, 30)
}
snapshot = next
}
// ── 工具行:视图模式 / 排序 / 搜索 / 分类切换 ─────────────────────────────────
type SortKey = 'change_pct' | 'speed' | 'chg_3d' | 'chg_5d' | 'chg_20d' | 'chg_ytd'
const viewMode = ref<'tiles' | 'table'>(
localStorage.getItem('board.viewMode') === 'table' ? 'table' : 'tiles',
)
function setViewMode(m: 'tiles' | 'table') {
viewMode.value = m
localStorage.setItem('board.viewMode', m)
}
const sortKey = ref<SortKey>('change_pct')
const sortOptions: Array<{ value: SortKey; label: string }> = [
{ value: 'change_pct', label: '涨跌幅' },
{ value: 'speed', label: '涨速' },
{ value: 'chg_3d', label: '3日' },
{ value: 'chg_5d', label: '5日' },
{ value: 'chg_20d', label: '20日' },
{ value: 'chg_ytd', label: '年初至今' },
]
const search = ref('')
const filteredRows = computed(() => {
const q = search.value.trim().toLowerCase()
if (!q) return rows.value
return rows.value.filter(
(r) => r.name.toLowerCase().includes(q) || r.code.includes(q),
)
})
const sortedRows = computed(() => {
const key = sortKey.value
return [...filteredRows.value].sort((a, b) => {
const av = a[key]
const bv = b[key]
if (av === null || av === undefined) return 1 // null 沉底
if (bv === null || bv === undefined) return -1
return bv - av
})
})
function setLevel(t: 'HY' | 'HY2') {
if (activeType.value === t) return
activeType.value = t
resetAndReload()
}
function resetAndReload() {
rows.value = []
flips.value = []
snapshot = null
loadOverview()
}
watch(
() => props.boardType,
(t) => {
activeType.value = t
search.value = ''
resetAndReload()
},
)
// ── 轮询调度(同 Dashboard:交易时段门控 + 页面隐藏暂停) ──────────────────────
let timer = 0
let sessionTimer = 0
const sessionGated = ref(localStorage.getItem('board.sessionGated') !== '0')
function onSessionToggle() {
localStorage.setItem('board.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
loadOverview()
loadStat()
}
function refreshAll() {
loadOverview()
loadStat()
}
onMounted(() => {
refreshAll()
timer = window.setInterval(tick, 30_000)
sessionTimer = window.setInterval(() => {
inSession.value = isTradeSession()
}, 60_000)
})
onBeforeUnmount(() => {
window.clearInterval(timer)
window.clearInterval(sessionTimer)
})
// ── 弹窗(板块详情) ─────────────────────────────────────────────────────────
const boardDialog = ref<{ code: string; name: string } | null>(null)
function openBoard(r: BoardOverviewRow) {
boardDialog.value = { code: r.code, name: r.name }
}
</script>
<template>
<div class="board-view">
<div class="view-head">
<h2>{{ title }}<span v-if="titleSub" class="dim head-sub">{{ titleSub }}</span></h2>
<template v-if="isIndustry">
<button class="chip" :class="{ on: activeType === 'HY' }" @click="setLevel('HY')">一级</button>
<button class="chip" :class="{ on: activeType === 'HY2' }" @click="setLevel('HY2')">二级</button>
</template>
</div>
<BoardStatStrip :rows="rows" :stat="stat" />
<!-- 工具行 -->
<div class="toolbar">
<span class="seg">
<button :class="{ on: viewMode === 'tiles' }" @click="setViewMode('tiles')">热力图</button>
<button :class="{ on: viewMode === 'table' }" @click="setViewMode('table')">表格</button>
</span>
<label class="tb-label">排序
<select v-model="sortKey">
<option v-for="o in sortOptions" :key="o.value" :value="o.value">{{ o.label }}</option>
</select>
</label>
<input v-model="search" class="search" type="text" :placeholder="isIndustry ? '搜索行业名/代码' : '搜索概念名/代码'" />
<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="refreshAll"> 刷新</button>
</div>
<div v-if="error" class="err card">
加载失败{{ error }}
<button @click="refreshAll">重试</button>
</div>
<div v-else-if="loading" class="loading">板块数据加载中</div>
<!-- 主区 + 右栏 -->
<div v-show="!error && !loading" class="content">
<div class="main-area card">
<BoardTiles
v-if="viewMode === 'tiles'"
:rows="sortedRows"
:tile-min-width="isIndustry ? 104 : 88"
@select="openBoard"
/>
<table v-else class="qtable board-table">
<thead>
<tr>
<th>名称</th>
<th>代码</th>
<th>最新价</th>
<th class="sortable" :class="{ on: sortKey === 'change_pct' }" @click="sortKey = 'change_pct'">
涨跌幅{{ sortKey === 'change_pct' ? ' ' : '' }}
</th>
<th class="sortable" :class="{ on: sortKey === 'speed' }" @click="sortKey = 'speed'">
涨速{{ sortKey === 'speed' ? ' ' : '' }}
</th>
<th class="sortable" :class="{ on: sortKey === 'chg_3d' }" @click="sortKey = 'chg_3d'">
3日{{ sortKey === 'chg_3d' ? ' ' : '' }}
</th>
<th class="sortable" :class="{ on: sortKey === 'chg_5d' }" @click="sortKey = 'chg_5d'">
5日{{ sortKey === 'chg_5d' ? ' ' : '' }}
</th>
<th class="sortable" :class="{ on: sortKey === 'chg_20d' }" @click="sortKey = 'chg_20d'">
20日{{ sortKey === 'chg_20d' ? ' ' : '' }}
</th>
<th class="sortable" :class="{ on: sortKey === 'chg_ytd' }" @click="sortKey = 'chg_ytd'">
YTD{{ sortKey === 'chg_ytd' ? ' ' : '' }}
</th>
<th>领涨股</th>
</tr>
</thead>
<tbody>
<tr v-for="r in sortedRows" :key="r.code" class="clickable" @click="openBoard(r)">
<td class="name-cell">{{ r.name }}</td>
<td class="dim">{{ r.code }}</td>
<td class="mono">{{ fmt2(r.price) }}</td>
<td class="mono" :class="dirClass(r.change_pct)">{{ fmtPctSigned(r.change_pct) }}</td>
<td class="mono" :class="dirClass(r.speed)">{{ fmtPctSigned(r.speed) }}</td>
<td class="mono" :class="dirClass(r.chg_3d)">{{ fmtPctSigned(r.chg_3d) }}</td>
<td class="mono" :class="dirClass(r.chg_5d)">{{ fmtPctSigned(r.chg_5d) }}</td>
<td class="mono" :class="dirClass(r.chg_20d)">{{ fmtPctSigned(r.chg_20d) }}</td>
<td class="mono" :class="dirClass(r.chg_ytd)">{{ fmtPctSigned(r.chg_ytd) }}</td>
<td class="leader-cell">
<span v-if="r.leader_name">{{ r.leader_name }}</span>
<span class="mono" :class="dirClass(r.leader_change_pct)">{{ fmtPctSigned(r.leader_change_pct) }}</span>
</td>
</tr>
</tbody>
</table>
<div v-if="viewMode === 'table' && sortedRows.length === 0" class="loading">没有匹配的板块</div>
</div>
<BoardRankRail class="rail-area" :rows="rows" :flips="flips" @select="openBoard" />
</div>
<BoardDialog
v-if="boardDialog"
:code="boardDialog.code"
:name="boardDialog.name"
@close="boardDialog = null"
/>
</div>
</template>
<style scoped>
.board-view {
height: 100%;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
/* flex 列内子项按内容占位,禁止被压缩(容器超高时靠 overflow-y 滚动) */
.view-head,
.toolbar,
.stat-strip,
.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;
font-weight: 400;
margin-left: 6px;
}
.chip {
padding: 2px 10px;
font-size: 11.5px;
}
.chip.on {
border-color: var(--accent);
color: var(--accent);
background: rgba(74, 158, 255, 0.12);
}
.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;
}
.search {
width: 180px;
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;
}
.err {
color: var(--up);
display: flex;
align-items: center;
gap: 10px;
}
.loading {
padding: 40px 0;
text-align: center;
color: var(--text-dim);
}
.content {
display: grid;
grid-template-columns: 1fr 300px;
gap: 10px;
align-items: start;
}
.main-area {
padding: 10px;
min-height: 300px;
}
.board-table th.sortable {
cursor: pointer;
}
.board-table th.sortable:hover,
.board-table th.sortable.on {
color: var(--accent);
}
.board-table td.name-cell {
font-weight: 600;
}
.board-table .leader-cell span:first-child {
margin-right: 8px;
}
@media (max-width: 1280px) {
.content {
grid-template-columns: 1fr;
}
}
</style>