Files
easy_tdx_max/web-ui/src/components/BoardDialog.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

471 lines
12 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">
// 板块详情弹窗(行业/概念板块指数):左 分时/日K(含技术指标),
// 右 成分股涨跌榜(点击行打开个股弹窗,快速定位人气股)。
// 板块代码 881xxx/885xxx(沪市板块指数),分时与日K走与个股相同的
// /minute、/bars 接口(协议天然支持板块)。
import { computed, onMounted, ref, watch } from 'vue'
import {
addWatchItem,
fetchBars,
fetchBoardMembers,
fetchMinute,
fetchWatchlist,
formatError,
removeWatchItem,
} from '../api'
import { dirClass, fmt2, fmtPctSigned } from '../format'
import { useQuoteStore } from '../stores/quotes'
import type { Bar, MinutePoint, RankRow } from '../types'
import IntradayChart from './IntradayChart.vue'
import StockKline, { type Overlay, type SubPane } from './StockKline.vue'
import StockDialog from './StockDialog.vue'
const props = defineProps<{
code: string // 881106 / 885418 ...
name: string
}>()
const emit = defineEmits<{ close: []; 'watchlist-changed': [] }>()
const quoteStore = useQuoteStore()
const quote = computed(() => quoteStore.getQuote(`SH${props.code}`))
const changePct = computed(() => {
const q = quote.value
if (!q?.price || !q.pre_close) return null
return (q.price / q.pre_close - 1) * 100
})
const tab = ref<'minute' | 'daily'>('minute')
const overlay = ref<Overlay>('ma')
const subPane = ref<SubPane>('macd')
const overlayOptions: Array<{ value: Overlay; label: string }> = [
{ value: 'ma', label: 'MA' },
{ value: 'boll', label: 'BOLL' },
{ value: 'ema', label: 'EMA' },
{ value: 'none', label: '主图无' },
]
const subOptions: Array<{ value: SubPane; label: string }> = [
{ value: 'macd', label: 'MACD' },
{ value: 'kdj', label: 'KDJ' },
{ value: 'rsi', label: 'RSI' },
{ value: 'none', label: '副图无' },
]
const minutePoints = ref<MinutePoint[]>([])
const dailyBars = ref<Bar[]>([])
const minuteError = ref('')
const dailyError = ref('')
const loading = ref(false)
async function loadMinute() {
minuteError.value = ''
try {
minutePoints.value = await fetchMinute('SH', props.code)
} catch (e) {
minutePoints.value = []
minuteError.value = formatError(e)
}
}
async function loadDaily() {
dailyError.value = ''
try {
const bars = await fetchBars('SH', props.code, 'DAY', undefined, undefined)
if (bars.length === 0) throw new Error('该板块无日K数据')
dailyBars.value = bars.slice(-250)
} catch (e) {
dailyBars.value = []
dailyError.value = formatError(e)
}
}
async function loadCharts() {
loading.value = true
await Promise.all([loadMinute(), loadDaily()])
loading.value = false
}
onMounted(loadCharts)
watch(() => props.code, loadCharts)
// ── 自选 ────────────────────────────────────────────────────────────────────
const inWatchlist = ref(false)
const watchBusy = ref(false)
async function refreshWatchState() {
try {
const resp = await fetchWatchlist()
inWatchlist.value = resp.items.some((i) => i.symbol === `SH${props.code}`)
} catch {
inWatchlist.value = false
}
}
onMounted(refreshWatchState)
watch(() => props.code, refreshWatchState)
async function toggleWatch() {
watchBusy.value = true
try {
if (inWatchlist.value) {
await removeWatchItem('SH', props.code)
inWatchlist.value = false
} else {
await addWatchItem('SH', props.code, props.name)
inWatchlist.value = true
}
emit('watchlist-changed')
} catch (e) {
alert(formatError(e))
} finally {
watchBusy.value = false
}
}
const preClose = computed(() => quote.value?.pre_close ?? null)
// ── 成分股涨跌榜 ────────────────────────────────────────────────────────────
const members = ref<RankRow[]>([])
const memberOrder = ref<'DESC' | 'ASC'>('DESC')
const membersError = ref('')
const membersLoading = ref(false)
// 全量拉取成分股(此前写死 120,大板块如 CPO 205 只/半导体 185 只被截断)。
// 后端单页 80 自动翻页,1000 覆盖最大概念板块,拉满后按成员清单自然终止。
const MEMBER_FETCH_COUNT = 1000
/** 请求序号守卫:升/降序快速连点、或 props.code 切换时,旧响应直接丢弃。 */
let memberSeq = 0
async function loadMembers() {
const my = ++memberSeq
membersLoading.value = true
membersError.value = ''
try {
const rows = await fetchBoardMembers(props.code, MEMBER_FETCH_COUNT, memberOrder.value)
// 响应已过期(顺序已再切换 / 板块已切换):不得覆盖新请求的结果
if (my !== memberSeq) return
members.value = rows
} catch (e) {
if (my !== memberSeq) return
members.value = []
membersError.value = formatError(e)
} finally {
if (my === memberSeq) membersLoading.value = false
}
}
function toggleMemberOrder() {
memberOrder.value = memberOrder.value === 'DESC' ? 'ASC' : 'DESC'
loadMembers()
}
onMounted(loadMembers)
watch(() => props.code, loadMembers)
/** 成分股行点击 → 叠开个股弹窗。 */
const stockDlg = ref<{ market: string; code: string; name: string } | null>(null)
function openMember(r: RankRow) {
stockDlg.value = {
market: String(r.market ?? ''),
code: String(r.code ?? ''),
name: String(r.name ?? ''),
}
}
</script>
<template>
<teleport to="body">
<div class="dlg-mask" @click.self="emit('close')">
<div class="dlg" :key="code">
<div class="dlg-head">
<div class="head-left">
<span class="board-name">{{ name }}</span>
<span class="board-code mono">SH{{ code }} · 板块指数</span>
</div>
<div class="head-quote">
<span v-if="quote" class="price mono" :class="dirClass(changePct)">{{ fmt2(quote.price) }}</span>
<span v-if="changePct !== null" class="chg mono" :class="dirClass(changePct)">
{{ fmtPctSigned(changePct) }}
</span>
<button
class="watch-btn"
:class="{ watched: inWatchlist }"
:disabled="watchBusy"
@click="toggleWatch"
>
{{ inWatchlist ? '★ 移除自选' : '☆ 加入自选' }}
</button>
<button class="close-btn" @click="emit('close')"></button>
</div>
</div>
<div class="dlg-body">
<!-- 分时 / 日K -->
<div class="chart-area">
<div class="tabs">
<button :class="{ active: tab === 'minute' }" @click="tab = 'minute'">分时</button>
<button :class="{ active: tab === 'daily' }" @click="tab = 'daily'">日K</button>
<template v-if="tab === 'daily'">
<span class="ind-group">
<button
v-for="opt in overlayOptions"
:key="opt.value"
class="chip"
:class="{ on: overlay === opt.value }"
@click="overlay = opt.value"
>
{{ opt.label }}
</button>
</span>
<span class="ind-group">
<button
v-for="opt in subOptions"
:key="opt.value"
class="chip"
:class="{ on: subPane === opt.value }"
@click="subPane = opt.value"
>
{{ opt.label }}
</button>
</span>
</template>
</div>
<div v-if="loading" class="chart-msg">加载中</div>
<template v-else>
<template v-if="tab === 'minute'">
<div v-if="minuteError" class="chart-msg error">分时{{ minuteError }}</div>
<IntradayChart v-else :points="minutePoints" :pre-close="preClose" />
</template>
<template v-else>
<div v-if="dailyError" class="chart-msg error">日K{{ dailyError }}</div>
<StockKline v-else :bars="dailyBars" :overlay="overlay" :sub-pane="subPane" />
</template>
</template>
</div>
<!-- 成分股涨跌榜 -->
<div class="members-panel">
<div class="members-head">
<h3>成分股 <span class="dim members-count">{{ members.length }}</span></h3>
<button class="order-btn" @click="toggleMemberOrder">
{{ memberOrder === 'DESC' ? '↓ 涨幅降序' : '↑ 涨幅升序' }}
</button>
</div>
<div v-if="membersError" class="chart-msg error">{{ membersError }}</div>
<div v-else-if="membersLoading" class="chart-msg">成分股加载中</div>
<div v-else class="members-list">
<div
v-for="(r, i) in members"
:key="i"
class="member-row"
@click="openMember(r)"
>
<span class="m-idx">{{ i + 1 }}</span>
<span class="m-name">{{ r.name }}</span>
<span class="m-code mono dim">{{ r.code }}</span>
<span class="m-price mono">{{ fmt2(r.price) }}</span>
<span class="m-pct mono" :class="dirClass(r.change_pct)">
{{ fmtPctSigned(r.change_pct) }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
<StockDialog
v-if="stockDlg"
:market="stockDlg.market"
:code="stockDlg.code"
:name="stockDlg.name"
@close="stockDlg = null"
@watchlist-changed="emit('watchlist-changed')"
/>
</teleport>
</template>
<style scoped>
.dlg-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.dlg {
width: min(1280px, 96vw);
max-height: 94vh;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 8px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.dlg-head {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
}
.head-left {
display: flex;
align-items: baseline;
gap: 10px;
}
.board-name {
font-size: 16px;
font-weight: 700;
}
.board-code {
font-size: 12px;
color: var(--text-dim);
}
.head-quote {
display: flex;
align-items: baseline;
gap: 12px;
}
.price {
font-size: 20px;
font-weight: 700;
}
.chg {
font-size: 13px;
}
.watch-btn {
font-size: 12px;
padding: 4px 10px;
margin-left: 10px;
align-self: center;
}
.watch-btn.watched {
border-color: var(--warn);
color: var(--warn);
}
.close-btn {
padding: 2px 8px;
font-size: 12px;
align-self: center;
}
.dlg-body {
display: flex;
overflow: hidden;
}
.chart-area {
flex: 1;
min-width: 0;
padding: 10px 12px;
overflow: auto;
}
.members-panel {
width: 330px;
flex-shrink: 0;
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.members-head {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px 6px;
}
.members-head h3 {
font-size: 13px;
font-weight: 600;
}
.members-count {
font-weight: 400;
font-size: 11px;
}
.order-btn {
font-size: 11px;
padding: 2px 8px;
}
.members-list {
flex: 1;
overflow-y: auto;
padding: 0 8px 10px;
}
.member-row {
display: grid;
grid-template-columns: 20px 1fr 66px 58px 62px;
align-items: center;
gap: 6px;
padding: 4px 6px;
font-size: 12px;
border-radius: 4px;
cursor: pointer;
}
.member-row:hover {
background: var(--bg-elevated);
}
.m-idx {
color: var(--text-dim);
font-size: 10.5px;
}
.m-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.m-code {
font-size: 10.5px;
}
.m-price {
text-align: right;
}
.m-pct {
text-align: right;
font-weight: 600;
}
.dim {
color: var(--text-dim);
}
.tabs {
display: flex;
gap: 8px;
margin-bottom: 6px;
align-items: center;
flex-wrap: wrap;
}
.tabs button.active {
border-color: var(--accent);
color: var(--accent);
}
.ind-group {
display: inline-flex;
gap: 4px;
margin-left: 12px;
}
.chip {
padding: 2px 8px;
font-size: 11px;
}
.chip.on {
border-color: var(--accent);
color: var(--accent);
background: rgba(74, 158, 255, 0.12);
}
.chart-msg {
padding: 40px 0;
text-align: center;
color: var(--text-dim);
}
.chart-msg.error {
color: var(--up);
}
</style>