Files
easy_tdx_max/web-ui/src/views/BoardOverviewView.vue
T
Justin Gu e374a0da28 release: v1.32.6 — 两周改动深度审查全面修复(回测口径三件套/LLM 安全加固/涨停价舍入/时区统一/缓存与竞态等 58 处)
对 v1.21→v1.32.5 的 249 文件 4.2 万行改动做六路专项审查,本轮落地全部发现:

回测正确性:组合收益 fillna(0) 虚增、轮动停牌日过期价成交、单标的 WF 逐窗指标
被预热区稀释(三件套均带先红后绿回归);worst_drawdown 方向、grading 容错、
组合体检品种费率、寻优端点费率透传。

安全:LLM api_url 仅 http/https 且禁 userinfo(封死 file:// 读取与 Key 外送链)、
错误响应不回显原始 body、响应体 2MB 上限、配置原子写、坏配置字段级防御。

数据:涨跌停价整数分币舍入(67/318/90 个价位错 1 分漏判清零)、交易时段/采样/
provisional 统一沪时区、warehouse 增量缺口自动全量重拉、provisional 定点转正、
baostock 真故障抛错 + W/M 去 tradestatus(实测服务端报错,周月兜底此前从未工作)
+ 指数 vol 股→手(实测锚定)、ccpm 结构变更抛错。

Web API:缓存键补 count/vipdoc、NaN 清洗先于缓存、count>800 分页取全量、
submit 透传真实状态、pending 不再被淘汰成幽灵、watchlist/server 入参约束。

公式:FILTER 去副作用、0-1 值域误判收严、递归深度上限、REF 负移位显式禁止。

前端:4 处请求竞态序号守卫、Sparkline viewBox、北交所 market=2 映射、
空数据缓存死角、AI 弹窗卸载中止轮询、量能/资金日历口径修正。

CLI/CI:warehouse sync 失败 exit 1、参数校验干净报错、release 真实发布 SHA256、
CI 超时与缓存、spec 补 baostock 前提。

约 60 条回归测试先红后绿;pytest 1820 全过,ruff/mypy/vue-tsc/node --test 全绿。
2026-09-06 22:16:48 +08:00

497 lines
15 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">
// 行业/概念总览(/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)
/** 请求序号守卫:await 后比对,过期响应直接丢弃。 */
let overviewSeq = 0
async function loadOverview() {
const my = ++overviewSeq
loading.value = rows.value.length === 0
error.value = ''
try {
const resp = await fetchBoardOverview(activeType.value)
// 响应已过期(期间切换了一级/二级):不覆盖新类型的 rows,
// 也不进入 diffFlips 产生幽灵翻红/翻绿事件
if (my !== overviewSeq) return
rows.value = resp.rows
lastRefresh.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
diffFlips(resp.rows)
} catch (e) {
if (my !== overviewSeq) return
error.value = formatError(e)
} finally {
if (my === overviewSeq) 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>