新增连板页面

This commit is contained in:
2026-04-27 00:30:40 +08:00
parent aea0d42d52
commit 6959ae42e5
6 changed files with 1011 additions and 18 deletions
+38 -5
View File
@@ -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;
+8
View File
@@ -8,6 +8,14 @@ export function getAnalyzeData(data) {
})
}
export function getLimitBoardOverview(params) {
return request({
url: '/limit-board/overview',
method: 'get',
params
})
}
// 新增:获取历史K线
export function getKlineData(code) {
return request({
+30
View File
@@ -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
}
+7 -1
View File
@@ -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' }
}
]
+21 -10
View File
@@ -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 {
+905
View File
@@ -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>