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
+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>