mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 18:04:20 +08:00
feat(webui)!: 行情终端重大升级——市场看板/自选/个股与板块弹窗 + SSE 实时推送
展示层对标专业看盘终端(tick-stock-panel 模式),数据全部来自通达信协议直连: - 市场看板:五大指数实时条(迷你分时)、涨跌统计、四维情绪雷达、 全市场涨跌分布直方图(约 5500 只 22 桶、鼠标跟随浮窗)、涨停雷达、 行业/概念热冷榜(可下钻)、涨幅/跌幅/成交额/换手四联排行榜、异动雷达 - 自选行情:6 位代码即加(symbol-info 自动取名)、SSE 实时全表刷新、 行内迷你分时;SQLite 持久化(watchlist.db,幂等加删) - 个股弹窗:五档盘口 + 1/3/5 日分时 + 日K(MA/BOLL/EMA + MACD/KDJ/RSI 前端本地计算)+ 一键加自选 + 一键全策略寻优 - 板块弹窗:板块分时/日K + 成分股涨跌榜直达个股 - SSE 架构:QuoteStreamer 单循环轮询 fan-out(独立队列 + 背压丢旧、 无订阅休眠、盘中 8s/盘外 60s 降频);前端 pinia 单连接指数退避重连 - fix(codec): SH 000 系大盘指数与 881/885 板块指数报价按 2 位小数解析 (曾缩小 10 倍:科创50 1647.53→164.753);880 统计指数保持 3 位 - fix: 前端批量五档路径 /security/quotes→/quotes、SSE 五档白名单 bid1_vol→bid_vol1、MAC 排行 close 列归一化、日K v-if 撑满、 寻优查看跳转 /→/backtest、FastAPI 0.141 _IncludedRouter 测试适配 新增 27 个单测(streamer/自选/小数位语义),全套 1078 passed
This commit is contained in:
@@ -0,0 +1,458 @@
|
||||
<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)
|
||||
|
||||
async function loadMembers() {
|
||||
membersLoading.value = true
|
||||
membersError.value = ''
|
||||
try {
|
||||
members.value = await fetchBoardMembers(props.code, 120, memberOrder.value)
|
||||
} catch (e) {
|
||||
members.value = []
|
||||
membersError.value = formatError(e)
|
||||
} finally {
|
||||
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>
|
||||
Reference in New Issue
Block a user