新增连板页面
This commit is contained in:
+38
-5
@@ -3,7 +3,7 @@
|
||||
<aside :class="['sidebar', { collapsed: isSidebarCollapsed }]">
|
||||
<div class="sidebar-top">
|
||||
<button class="sidebar-toggle" type="button" @click="toggleSidebar">
|
||||
<el-icon><component :is="isSidebarCollapsed ? Expand : Fold" /></el-icon>
|
||||
<el-icon><component :is="isSidebarCollapsed ? ArrowRightBold : ArrowLeftBold" /></el-icon>
|
||||
</button>
|
||||
<div v-if="!isSidebarCollapsed" class="brand-block">
|
||||
<span class="brand-title">Stock Lab</span>
|
||||
@@ -24,8 +24,10 @@
|
||||
:index="item.path"
|
||||
@click="navigateTo(item.path)"
|
||||
>
|
||||
<el-icon><DataAnalysis /></el-icon>
|
||||
<template #title>{{ item.title }}</template>
|
||||
<div class="menu-item-inner">
|
||||
<el-icon><component :is="item.icon" /></el-icon>
|
||||
<span class="menu-title">{{ item.title }}</span>
|
||||
</div>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</aside>
|
||||
@@ -39,18 +41,28 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { DataAnalysis, Expand, Fold } from '@element-plus/icons-vue'
|
||||
import { ArrowLeftBold, ArrowRightBold, Histogram, TrendCharts } from '@element-plus/icons-vue'
|
||||
import { fetchTradeCalendar } from '@/composables/useTradeCalendar'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const isSidebarCollapsed = ref(false)
|
||||
const iconMap = {
|
||||
Histogram,
|
||||
TrendCharts
|
||||
}
|
||||
|
||||
fetchTradeCalendar().catch((err) => {
|
||||
console.error('预加载交易日历失败', err)
|
||||
})
|
||||
|
||||
const menuItems = computed(() =>
|
||||
router.options.routes
|
||||
.filter((item) => item.path && item.meta?.title && !item.redirect)
|
||||
.map((item) => ({
|
||||
path: item.path,
|
||||
title: item.meta.title
|
||||
title: item.meta.title,
|
||||
icon: iconMap[item.meta.icon] || TrendCharts
|
||||
}))
|
||||
)
|
||||
|
||||
@@ -94,6 +106,7 @@ const navigateTo = (path) => {
|
||||
.sidebar-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
min-height: 52px;
|
||||
margin-bottom: 18px;
|
||||
@@ -139,6 +152,20 @@ const navigateTo = (path) => {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.menu-item-inner {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.menu-title {
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.page-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -149,6 +176,8 @@ const navigateTo = (path) => {
|
||||
margin-bottom: 8px;
|
||||
border-radius: 12px;
|
||||
color: #41536d;
|
||||
justify-content: center;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
:deep(.nav-menu .el-menu-item .el-icon) {
|
||||
@@ -170,6 +199,10 @@ const navigateTo = (path) => {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .menu-title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.sidebar {
|
||||
width: 88px;
|
||||
|
||||
+9
-1
@@ -8,10 +8,18 @@ export function getAnalyzeData(data) {
|
||||
})
|
||||
}
|
||||
|
||||
export function getLimitBoardOverview(params) {
|
||||
return request({
|
||||
url: '/limit-board/overview',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 新增:获取历史K线
|
||||
export function getKlineData(code) {
|
||||
return request({
|
||||
url: `/stock/kline/${code}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { getTradeCalendar } from '@/api/common'
|
||||
|
||||
let tradeCalendarPromise = null
|
||||
let tradeCalendarCache = null
|
||||
|
||||
const normalizeTradeCalendar = (data = {}) => ({
|
||||
open_dates: Array.isArray(data.open_dates) ? data.open_dates : [],
|
||||
max_date: data.max_date || ''
|
||||
})
|
||||
|
||||
export async function fetchTradeCalendar(force = false) {
|
||||
if (tradeCalendarCache && !force) return tradeCalendarCache
|
||||
|
||||
if (!tradeCalendarPromise || force) {
|
||||
tradeCalendarPromise = getTradeCalendar()
|
||||
.then((data) => {
|
||||
tradeCalendarCache = normalizeTradeCalendar(data)
|
||||
return tradeCalendarCache
|
||||
})
|
||||
.finally(() => {
|
||||
tradeCalendarPromise = null
|
||||
})
|
||||
}
|
||||
|
||||
return tradeCalendarPromise
|
||||
}
|
||||
|
||||
export function getCachedTradeCalendar() {
|
||||
return tradeCalendarCache
|
||||
}
|
||||
+8
-2
@@ -9,7 +9,13 @@ const routes = [
|
||||
path: '/stock/analyze',
|
||||
name: 'StockAnalyze',
|
||||
component: () => import('@/views/stock/Analyze.vue'),
|
||||
meta: { title: '涨停回落分析' }
|
||||
meta: { title: '涨停回落分析', icon: 'TrendCharts' }
|
||||
},
|
||||
{
|
||||
path: '/stock/limit-board',
|
||||
name: 'StockLimitBoard',
|
||||
component: () => import('@/views/stock/LimitBoard.vue'),
|
||||
meta: { title: '涨停连板概览', icon: 'Histogram' }
|
||||
}
|
||||
]
|
||||
|
||||
@@ -18,4 +24,4 @@ const router = createRouter({
|
||||
routes
|
||||
})
|
||||
|
||||
export default router
|
||||
export default router
|
||||
|
||||
+21
-10
@@ -1,5 +1,10 @@
|
||||
<template>
|
||||
<div class="analyze-container">
|
||||
<div
|
||||
v-loading="loading"
|
||||
class="analyze-container"
|
||||
element-loading-text="正在加载涨停回落分析..."
|
||||
element-loading-background="rgba(244, 247, 249, 0.72)"
|
||||
>
|
||||
<el-row :gutter="15" class="top-row">
|
||||
<el-col :xs="24" :sm="24" :md="24" :lg="12">
|
||||
<div class="glass-card filter-container">
|
||||
@@ -128,7 +133,7 @@
|
||||
:title="dialogTitle"
|
||||
class="kline-dialog"
|
||||
:width="dialogWidth"
|
||||
top="5vh"
|
||||
align-center
|
||||
destroy-on-close
|
||||
draggable
|
||||
overflow
|
||||
@@ -143,7 +148,7 @@
|
||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import dayjs from 'dayjs'
|
||||
import { getTradeCalendar } from '@/api/common'
|
||||
import { fetchTradeCalendar } from '@/composables/useTradeCalendar'
|
||||
import { getAnalyzeData, getKlineData } from '@/api/stock'
|
||||
|
||||
const DEFAULT_START_DATE = '2026-04-22'
|
||||
@@ -197,13 +202,17 @@ onMounted(async () => {
|
||||
window.addEventListener('resize', syncViewport)
|
||||
|
||||
try {
|
||||
const res = await getTradeCalendar()
|
||||
const res = await fetchTradeCalendar()
|
||||
const { open_dates: openDates = [], max_date: maxDate = '' } = res || {}
|
||||
|
||||
openDatesSet.value = new Set(openDates)
|
||||
maxTradeDate.value = maxDate
|
||||
endDate.value = endDate.value || maxDate
|
||||
startDate.value = clampToMaxDate(startDate.value)
|
||||
|
||||
if (startDate.value && endDate.value) {
|
||||
await handleQuery()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('初始化分析页失败', err)
|
||||
}
|
||||
@@ -301,16 +310,17 @@ const openKlineDialog = async (row) => {
|
||||
axisPointer: { type: 'cross' },
|
||||
formatter(params) {
|
||||
const point = params[0]
|
||||
const pct = point.value[4] || 0
|
||||
const currentItem = rawData[point.dataIndex] || {}
|
||||
const pct = Number(currentItem.pct_chg || 0)
|
||||
const color = pct >= 0 ? '#f56c6c' : '#67c23a'
|
||||
return `
|
||||
<div style="font-size:12px; min-width:120px;">
|
||||
<b>${point.name}</b><hr style="margin:5px 0;border:none;border-top:1px solid #eee"/>
|
||||
涨跌幅: <span style="color:${color};font-weight:bold;">${pct > 0 ? '+' : ''}${pct.toFixed(2)}%</span><br/>
|
||||
开盘价: ${formatPrice(point.value[0])}<br/>
|
||||
收盘价: ${formatPrice(point.value[1])}<br/>
|
||||
最低价: ${formatPrice(point.value[2])}<br/>
|
||||
最高价: ${formatPrice(point.value[3])}
|
||||
开盘价: ${formatPrice(currentItem.open)}<br/>
|
||||
收盘价: ${formatPrice(currentItem.close)}<br/>
|
||||
最低价: ${formatPrice(currentItem.low)}<br/>
|
||||
最高价: ${formatPrice(currentItem.high)}
|
||||
</div>`
|
||||
}
|
||||
},
|
||||
@@ -383,7 +393,8 @@ const openKlineDialog = async (row) => {
|
||||
|
||||
/* 弹窗标题栏可移动提示 */
|
||||
:deep(.el-dialog__header) { cursor: move; margin-right: 0; padding-bottom: 15px; border-bottom: 1px solid #f5f5f5; }
|
||||
:deep(.kline-dialog) { margin: 0 !important; max-width: 1200px; }
|
||||
:deep(.kline-dialog) { max-width: 1200px; }
|
||||
:deep(.kline-dialog .el-overlay-dialog) { display: flex; align-items: center; justify-content: center; padding: 24px; box-sizing: border-box; }
|
||||
:deep(.kline-dialog .el-dialog) { border-radius: 18px; overflow: hidden; }
|
||||
|
||||
.status-tag {
|
||||
|
||||
@@ -0,0 +1,905 @@
|
||||
<template>
|
||||
<div
|
||||
v-loading="pageLoading"
|
||||
class="limit-board-page"
|
||||
element-loading-text="正在加载涨停连板数据..."
|
||||
element-loading-background="rgba(244, 248, 252, 0.72)"
|
||||
>
|
||||
<section class="toolbar-card">
|
||||
<div class="toolbar-main">
|
||||
<div class="toolbar-copy">
|
||||
<h1>涨停连板概览</h1>
|
||||
<p>默认展示最新交易日的连板概览,并回看近 30 个交易日的最高连板强度。</p>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<el-date-picker
|
||||
v-model="selectedDate"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
:disabled-date="disableDates"
|
||||
:editable="false"
|
||||
placeholder="选择交易日"
|
||||
class="date-picker"
|
||||
/>
|
||||
<el-button type="primary" :loading="pageLoading" :disabled="!selectedDate" @click="handleQuery">
|
||||
查询
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-grid">
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">查询交易日</span>
|
||||
<strong class="summary-value">{{ overview.trade_date || '--' }}</strong>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">最高连板</span>
|
||||
<strong class="summary-value">{{ summary.max_board_count || 0 }}</strong>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">涨停总数</span>
|
||||
<strong class="summary-value">{{ summary.limit_count || 0 }}</strong>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">分组数量</span>
|
||||
<strong class="summary-value">{{ summary.group_count || 0 }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="heatmap-card">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>近 30 个交易日强度</h2>
|
||||
<p>展示近 30 个交易日每天的最高连板数走势。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="strength-line-chart" class="strength-chart"></div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-loading="detailLoading"
|
||||
class="groups-card"
|
||||
element-loading-text="正在刷新涨停明细..."
|
||||
element-loading-background="rgba(255, 255, 255, 0.7)"
|
||||
>
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>涨停分组明细 <span class="detail-date">{{ detailTradeDateLabel }}</span></h2>
|
||||
<p v-if="detailGroups.length">按连板高度从高到低展示当日涨停股票。</p>
|
||||
<p v-else>当日无涨停数据。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="detailGroups.length" class="group-list">
|
||||
<article v-for="group in detailGroups" :key="group.board_count" class="group-panel">
|
||||
<header class="group-head">
|
||||
<div class="group-title-wrap">
|
||||
<span class="group-badge">{{ group.label }}</span>
|
||||
<h3>{{ group.label }}</h3>
|
||||
</div>
|
||||
<span class="group-count">{{ group.count }} 只</span>
|
||||
</header>
|
||||
|
||||
<div class="stock-grid">
|
||||
<button
|
||||
v-for="stock in group.stocks"
|
||||
:key="stock.ts_code"
|
||||
type="button"
|
||||
class="stock-card"
|
||||
@click="openKlineDialog(stock)"
|
||||
>
|
||||
<div class="stock-head">
|
||||
<span class="stock-name">{{ stock.name }}</span>
|
||||
<span class="stock-status">{{ stock.display_status }}</span>
|
||||
</div>
|
||||
<div class="stock-meta">
|
||||
<span>{{ stock.symbol }}</span>
|
||||
<span>{{ stock.market || 'A股' }}</span>
|
||||
</div>
|
||||
<div class="stock-metrics">
|
||||
<span>收盘 {{ formatPrice(stock.close) }}</span>
|
||||
<span :class="['pct', stock.pct_chg >= 0 ? 'up' : 'down']">
|
||||
{{ formatPct(stock.pct_chg) }}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
class="kline-dialog"
|
||||
:title="dialogTitle"
|
||||
:width="dialogWidth"
|
||||
align-center
|
||||
destroy-on-close
|
||||
draggable
|
||||
overflow
|
||||
@closed="disposeChart"
|
||||
>
|
||||
<div id="limit-board-kline-chart" :style="{ width: '100%', height: `${chartHeight}px` }"></div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref, nextTick } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import dayjs from 'dayjs'
|
||||
import { fetchTradeCalendar } from '@/composables/useTradeCalendar'
|
||||
import { getKlineData, getLimitBoardOverview } from '@/api/stock'
|
||||
|
||||
const PHONE_BREAKPOINT = 820
|
||||
const IPAD_MINI_BREAKPOINT = 768
|
||||
const TABLET_BREAKPOINT = 1024
|
||||
|
||||
const viewportWidth = ref(window.innerWidth)
|
||||
const selectedDate = ref('')
|
||||
const maxTradeDate = ref('')
|
||||
const openDates = ref([])
|
||||
const openDatesSet = ref(new Set())
|
||||
const pageLoading = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const overview = ref({
|
||||
trade_date: '',
|
||||
summary: {
|
||||
max_board_count: 0,
|
||||
limit_count: 0,
|
||||
group_count: 0
|
||||
},
|
||||
recent_max_board_stats: [],
|
||||
groups: []
|
||||
})
|
||||
const strengthSeries = ref([])
|
||||
const detailTradeDate = ref('')
|
||||
const detailGroups = ref([])
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('')
|
||||
let strengthChartInstance = null
|
||||
let chartInstance = null
|
||||
|
||||
const summary = computed(() => overview.value.summary || {})
|
||||
const isPhoneViewport = computed(() => viewportWidth.value <= PHONE_BREAKPOINT)
|
||||
const isIpadMiniViewport = computed(() => viewportWidth.value <= IPAD_MINI_BREAKPOINT)
|
||||
const isTabletViewport = computed(() => viewportWidth.value <= TABLET_BREAKPOINT)
|
||||
const detailTradeDateLabel = computed(() => detailTradeDate.value || overview.value.trade_date || '--')
|
||||
const dialogWidth = computed(() => {
|
||||
if (isPhoneViewport.value) return '96%'
|
||||
if (isIpadMiniViewport.value) return '94%'
|
||||
if (isTabletViewport.value) return '92%'
|
||||
return '84%'
|
||||
})
|
||||
const chartHeight = computed(() => {
|
||||
if (isPhoneViewport.value) return 360
|
||||
if (isIpadMiniViewport.value) return 420
|
||||
if (isTabletViewport.value) return 460
|
||||
return 560
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('resize', syncViewport)
|
||||
await initializePage()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', syncViewport)
|
||||
disposeStrengthChart()
|
||||
disposeChart()
|
||||
})
|
||||
|
||||
const syncViewport = () => {
|
||||
viewportWidth.value = window.innerWidth
|
||||
strengthChartInstance?.resize()
|
||||
chartInstance?.resize()
|
||||
}
|
||||
|
||||
const initializePage = async () => {
|
||||
pageLoading.value = true
|
||||
try {
|
||||
const calendar = await fetchTradeCalendar()
|
||||
const { open_dates: dates = [], max_date: maxDate = '' } = calendar || {}
|
||||
|
||||
openDates.value = dates
|
||||
openDatesSet.value = new Set(dates)
|
||||
maxTradeDate.value = maxDate
|
||||
selectedDate.value = maxDate
|
||||
|
||||
if (selectedDate.value) {
|
||||
await fetchOverviewPage(selectedDate.value)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('初始化涨停连板概览页失败', err)
|
||||
} finally {
|
||||
pageLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const disableDates = (time) => {
|
||||
const date = dayjs(time).format('YYYY-MM-DD')
|
||||
if (maxTradeDate.value && date > maxTradeDate.value) return true
|
||||
return openDatesSet.value.size > 0 ? !openDatesSet.value.has(date) : false
|
||||
}
|
||||
|
||||
const fetchOverviewPage = async (tradeDate) => {
|
||||
const currentOverview = await getLimitBoardOverview({ trade_date: tradeDate })
|
||||
|
||||
overview.value = normalizeOverview(currentOverview, tradeDate)
|
||||
strengthSeries.value = overview.value.recent_max_board_stats
|
||||
detailTradeDate.value = overview.value.trade_date
|
||||
detailGroups.value = overview.value.groups
|
||||
|
||||
await nextTick()
|
||||
renderStrengthChart()
|
||||
}
|
||||
|
||||
const fetchDetailGroups = async (tradeDate) => {
|
||||
if (!tradeDate) return
|
||||
|
||||
detailLoading.value = true
|
||||
try {
|
||||
const detailOverview = await getLimitBoardOverview({ trade_date: tradeDate })
|
||||
const normalized = normalizeOverview(detailOverview, tradeDate)
|
||||
detailTradeDate.value = normalized.trade_date
|
||||
detailGroups.value = normalized.groups
|
||||
} catch (err) {
|
||||
console.error('刷新涨停分组明细失败', err)
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeOverview = (data, fallbackTradeDate = '') => ({
|
||||
trade_date: data?.trade_date || fallbackTradeDate,
|
||||
summary: {
|
||||
max_board_count: data?.summary?.max_board_count || 0,
|
||||
limit_count: data?.summary?.limit_count || 0,
|
||||
group_count: data?.summary?.group_count || 0
|
||||
},
|
||||
recent_max_board_stats: Array.isArray(data?.recent_max_board_stats)
|
||||
? data.recent_max_board_stats.map((item) => ({
|
||||
trade_date: item?.trade_date || '',
|
||||
max_board_count: Number(item?.max_board_count || 0)
|
||||
}))
|
||||
: [],
|
||||
groups: Array.isArray(data?.groups) ? data.groups : []
|
||||
})
|
||||
|
||||
const handleQuery = async () => {
|
||||
if (!selectedDate.value) return
|
||||
|
||||
pageLoading.value = true
|
||||
try {
|
||||
await fetchOverviewPage(selectedDate.value)
|
||||
} catch (err) {
|
||||
console.error('查询涨停连板概览失败', err)
|
||||
} finally {
|
||||
pageLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formatPrice = (value) => Number(value ?? 0).toFixed(2)
|
||||
|
||||
const formatPct = (value) => {
|
||||
const amount = Number(value ?? 0)
|
||||
return `${amount > 0 ? '+' : ''}${amount.toFixed(2)}%`
|
||||
}
|
||||
|
||||
const disposeStrengthChart = () => {
|
||||
if (!strengthChartInstance) return
|
||||
const chartDom = strengthChartInstance.getDom()
|
||||
if (chartDom?._strengthClickHandler) {
|
||||
chartDom.removeEventListener('click', chartDom._strengthClickHandler)
|
||||
delete chartDom._strengthClickHandler
|
||||
}
|
||||
strengthChartInstance.dispose()
|
||||
strengthChartInstance = null
|
||||
}
|
||||
|
||||
const triggerDetailForDate = (tradeDate) => {
|
||||
if (!tradeDate || tradeDate === detailTradeDate.value) return
|
||||
fetchDetailGroups(tradeDate)
|
||||
}
|
||||
|
||||
const triggerDetailForIndex = (dataIndex) => {
|
||||
if (!Number.isInteger(dataIndex) || dataIndex < 0 || dataIndex >= strengthSeries.value.length) return
|
||||
const clickedDate = strengthSeries.value[dataIndex]?.trade_date
|
||||
triggerDetailForDate(clickedDate)
|
||||
}
|
||||
|
||||
const renderStrengthChart = () => {
|
||||
const chartDom = document.getElementById('strength-line-chart')
|
||||
if (!chartDom) return
|
||||
|
||||
disposeStrengthChart()
|
||||
strengthChartInstance = echarts.init(chartDom)
|
||||
|
||||
strengthChartInstance.setOption({
|
||||
animation: false,
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
axisPointer: {
|
||||
type: 'shadow',
|
||||
lineStyle: {
|
||||
color: '#8db4e8',
|
||||
width: 1
|
||||
}
|
||||
},
|
||||
formatter(point) {
|
||||
const axisLabel = point?.name || point?.axisValueLabel || point?.axisValue || '--'
|
||||
const boardCount = Number(point?.value ?? point?.data ?? 0)
|
||||
return `
|
||||
<div style="font-size:12px; min-width:128px;">
|
||||
<b>${axisLabel}</b><br/>
|
||||
最高连板数: ${boardCount}
|
||||
</div>`
|
||||
}
|
||||
},
|
||||
grid: { left: 42, right: 18, top: 30, bottom: 36 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: strengthSeries.value.map((item) => item.trade_date),
|
||||
axisLabel: {
|
||||
formatter(value) {
|
||||
return dayjs(value).format('MM/DD')
|
||||
}
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
minInterval: 1,
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: '#e8eef6'
|
||||
}
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '近30日强度',
|
||||
type: 'bar',
|
||||
barMaxWidth: 22,
|
||||
cursor: 'pointer',
|
||||
itemStyle: {
|
||||
color: '#6ea2e7',
|
||||
borderRadius: [6, 6, 0, 0]
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'top',
|
||||
color: '#365c89',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
formatter({ value }) {
|
||||
return Number(value ?? 0)
|
||||
}
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
color: '#3d7edb'
|
||||
}
|
||||
},
|
||||
data: strengthSeries.value.map((item) => item.max_board_count),
|
||||
z: 1
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
strengthChartInstance.off('click')
|
||||
strengthChartInstance.on('click', (params) => {
|
||||
triggerDetailForIndex(params.dataIndex)
|
||||
})
|
||||
|
||||
strengthChartInstance.resize()
|
||||
}
|
||||
|
||||
const disposeChart = () => {
|
||||
if (!chartInstance) return
|
||||
chartInstance.dispose()
|
||||
chartInstance = null
|
||||
}
|
||||
|
||||
const openKlineDialog = async (stock) => {
|
||||
dialogTitle.value = `${stock.name} (${stock.ts_code || stock.symbol}) - K线走势`
|
||||
dialogVisible.value = true
|
||||
|
||||
try {
|
||||
const rawData = await getKlineData(stock.ts_code || stock.symbol)
|
||||
|
||||
await nextTick()
|
||||
const chartDom = document.getElementById('limit-board-kline-chart')
|
||||
if (!chartDom) return
|
||||
|
||||
disposeChart()
|
||||
chartInstance = echarts.init(chartDom)
|
||||
|
||||
const dates = rawData.map((item) => dayjs(item.trade_date).format('YYYY-MM-DD'))
|
||||
const values = rawData.map((item) => [
|
||||
item.open,
|
||||
item.close,
|
||||
item.low,
|
||||
item.high,
|
||||
item.pct_chg
|
||||
])
|
||||
|
||||
chartInstance.setOption({
|
||||
animation: false,
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'cross' },
|
||||
formatter(params) {
|
||||
const point = params[0]
|
||||
const currentItem = rawData[point.dataIndex] || {}
|
||||
const pct = Number(currentItem.pct_chg || 0)
|
||||
const color = pct >= 0 ? '#d9485f' : '#2f9e62'
|
||||
return `
|
||||
<div style="font-size:12px; min-width:128px;">
|
||||
<b>${point.name}</b><hr style="margin:5px 0;border:none;border-top:1px solid #eee"/>
|
||||
涨跌幅: <span style="color:${color};font-weight:bold;">${formatPct(pct)}</span><br/>
|
||||
开盘价: ${formatPrice(currentItem.open)}<br/>
|
||||
收盘价: ${formatPrice(currentItem.close)}<br/>
|
||||
最低价: ${formatPrice(currentItem.low)}<br/>
|
||||
最高价: ${formatPrice(currentItem.high)}
|
||||
</div>`
|
||||
}
|
||||
},
|
||||
grid: { left: 56, right: 28, top: 42, bottom: '12%' },
|
||||
xAxis: { type: 'category', data: dates, scale: true, boundaryGap: false },
|
||||
yAxis: { scale: true, splitArea: { show: true } },
|
||||
dataZoom: [
|
||||
{ type: 'inside', start: 80, end: 100 },
|
||||
{ show: true, type: 'slider', top: '92%' }
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '日K',
|
||||
type: 'candlestick',
|
||||
data: values,
|
||||
itemStyle: {
|
||||
color: '#e85d75',
|
||||
color0: '#45b37b',
|
||||
borderColor: '#e85d75',
|
||||
borderColor0: '#45b37b'
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
chartInstance.resize()
|
||||
} catch (err) {
|
||||
console.error('加载K线详情失败', err)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.limit-board-page {
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
background: #f4f8fc;
|
||||
}
|
||||
|
||||
.toolbar-card,
|
||||
.heatmap-card,
|
||||
.groups-card {
|
||||
background: #ffffff;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 10px 30px rgba(34, 72, 118, 0.08);
|
||||
}
|
||||
|
||||
.toolbar-card {
|
||||
padding: 22px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.toolbar-main {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.toolbar-copy h1,
|
||||
.section-head h2,
|
||||
.group-head h3 {
|
||||
margin: 0;
|
||||
color: #1f2a37;
|
||||
}
|
||||
|
||||
.detail-date {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #68809d;
|
||||
}
|
||||
|
||||
.toolbar-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toolbar-copy h1 {
|
||||
font-size: 28px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.toolbar-copy p,
|
||||
.section-head p {
|
||||
margin: 6px 0 0;
|
||||
color: #6b7a90;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.date-picker {
|
||||
width: 168px;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
padding: 16px 18px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, #f7fbff 0%, #eef5fd 100%);
|
||||
border: 1px solid #e2ebf7;
|
||||
min-height: 106px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #77859d;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: 26px;
|
||||
line-height: 32px;
|
||||
color: #153a66;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.heatmap-card,
|
||||
.groups-card {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.heatmap-card {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.strength-chart {
|
||||
width: 100%;
|
||||
height: 280px;
|
||||
}
|
||||
|
||||
.group-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.group-panel {
|
||||
border: 1px solid #e5edf6;
|
||||
border-radius: 16px;
|
||||
padding: 18px;
|
||||
background: #fbfdff;
|
||||
}
|
||||
|
||||
.group-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.group-title-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.group-badge,
|
||||
.group-count,
|
||||
.stock-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.group-badge {
|
||||
min-width: 54px;
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
color: #13559d;
|
||||
background: #e4f0ff;
|
||||
}
|
||||
|
||||
.group-count {
|
||||
min-width: 56px;
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
color: #627590;
|
||||
background: #eff4fa;
|
||||
}
|
||||
|
||||
.stock-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stock-card {
|
||||
min-height: 132px;
|
||||
border: 1px solid #dfe9f5;
|
||||
border-radius: 14px;
|
||||
padding: 14px;
|
||||
text-align: left;
|
||||
background: #ffffff;
|
||||
cursor: pointer;
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
|
||||
}
|
||||
|
||||
.stock-card:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: #9ec3f1;
|
||||
box-shadow: 0 8px 22px rgba(58, 105, 165, 0.12);
|
||||
}
|
||||
|
||||
.stock-head,
|
||||
.stock-meta,
|
||||
.stock-metrics {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stock-head {
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.stock-name {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #1e2e45;
|
||||
line-height: 1.35;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.stock-status {
|
||||
padding: 0 10px;
|
||||
height: 26px;
|
||||
color: #8a4b00;
|
||||
background: #fff1d6;
|
||||
}
|
||||
|
||||
.stock-meta,
|
||||
.stock-metrics {
|
||||
color: #607289;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.stock-meta {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.pct.up {
|
||||
color: #d9485f;
|
||||
}
|
||||
|
||||
.pct.down {
|
||||
color: #2f9e62;
|
||||
}
|
||||
|
||||
:deep(.el-input__wrapper),
|
||||
:deep(.el-input__inner) {
|
||||
cursor: pointer !important;
|
||||
}
|
||||
|
||||
:deep(.kline-dialog) {
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
:deep(.kline-dialog .el-overlay-dialog) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:deep(.kline-dialog .el-dialog) {
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.kline-dialog .el-dialog__header) {
|
||||
cursor: move;
|
||||
margin-right: 0;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid #f0f3f7;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.limit-board-page {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.toolbar-main {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.strength-chart {
|
||||
height: 240px;
|
||||
}
|
||||
|
||||
.stock-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.limit-board-page {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.toolbar-card,
|
||||
.heatmap-card,
|
||||
.groups-card {
|
||||
padding: 18px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.toolbar-main {
|
||||
gap: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.toolbar-copy h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 112px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.date-picker {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
min-height: 96px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: 22px;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.strength-chart {
|
||||
height: 228px;
|
||||
}
|
||||
|
||||
.group-panel {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.group-head {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stock-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stock-card {
|
||||
min-height: 124px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.stock-head,
|
||||
.stock-meta,
|
||||
.stock-metrics {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stock-name {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.limit-board-page {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.toolbar-card,
|
||||
.heatmap-card,
|
||||
.groups-card {
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.toolbar-main,
|
||||
.toolbar-actions,
|
||||
.group-head {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.toolbar-actions,
|
||||
.date-picker {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.summary-grid,
|
||||
.stock-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.strength-chart {
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
.stock-head,
|
||||
.stock-meta,
|
||||
.stock-metrics,
|
||||
.group-title-wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.summary-grid,
|
||||
.stock-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user