feat: implement dashboard and heatmap with ECharts

feature/20260628/refactor-frontend-modernization
Lxy 3 weeks ago
parent 2eb6a4444e
commit 90ac382237

@ -0,0 +1,68 @@
<template>
<v-chart class="chart" :option="option" autoresize />
</template>
<script setup lang="ts">
import { computed } from 'vue'
import VChart from 'vue-echarts'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { BarChart } from 'echarts/charts'
import {
TitleComponent,
TooltipComponent,
GridComponent,
LegendComponent,
} from 'echarts/components'
import type { EChartsOption } from 'echarts'
use([
CanvasRenderer,
BarChart,
TitleComponent,
TooltipComponent,
GridComponent,
LegendComponent,
])
const props = withDefaults(defineProps<{
title?: string
data: { name: string; value: number }[]
color?: string
height?: string
}>(), {
title: '',
color: '#58a6ff',
height: '300px',
})
const option = computed<EChartsOption>(() => ({
title: props.title ? { text: props.title, textStyle: { color: '#e6edf3', fontSize: 12 } } : undefined,
tooltip: { trigger: 'axis' },
grid: { left: 40, right: 20, top: props.title ? 30 : 10, bottom: 30 },
xAxis: {
type: 'category',
data: props.data.map(d => d.name),
axisLine: { lineStyle: { color: '#30363d' } },
axisLabel: { color: '#8b949e', fontSize: 10 },
},
yAxis: {
type: 'value',
splitLine: { lineStyle: { color: '#21262d' } },
axisLabel: { color: '#8b949e' },
},
series: [{
type: 'bar',
data: props.data.map(d => d.value),
itemStyle: { color: props.color },
barWidth: '60%',
}],
}))
</script>
<style scoped>
.chart {
width: 100%;
height: v-bind(height);
}
</style>

@ -0,0 +1,85 @@
<template>
<v-chart class="chart" :option="option" autoresize />
</template>
<script setup lang="ts">
import { computed } from 'vue'
import VChart from 'vue-echarts'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { HeatmapChart } from 'echarts/charts'
import {
TitleComponent,
TooltipComponent,
GridComponent,
VisualMapComponent,
} from 'echarts/components'
import type { EChartsOption } from 'echarts'
use([
CanvasRenderer,
HeatmapChart,
TitleComponent,
TooltipComponent,
GridComponent,
VisualMapComponent,
])
const props = withDefaults(defineProps<{
title?: string
xAxisData: string[]
yAxisData: string[]
data: { x: number; y: number; value: number }[]
height?: string
}>(), {
title: '',
height: '400px',
})
const option = computed<EChartsOption>(() => ({
title: props.title ? { text: props.title, textStyle: { color: '#e6edf3', fontSize: 12 } } : undefined,
tooltip: {
position: 'top',
formatter: (params: any) => `${props.yAxisData[params.data[1]]} / ${props.xAxisData[params.data[0]]}: ${params.data[2]}`,
},
grid: { left: 80, right: 80, top: props.title ? 30 : 10, bottom: 40 },
xAxis: {
type: 'category',
data: props.xAxisData,
splitArea: { show: true, areaStyle: { color: ['#1c2333', '#161b22'] } },
axisLine: { lineStyle: { color: '#30363d' } },
axisLabel: { color: '#8b949e', fontSize: 10, rotate: 30 },
},
yAxis: {
type: 'category',
data: props.yAxisData,
splitArea: { show: true },
axisLine: { lineStyle: { color: '#30363d' } },
axisLabel: { color: '#8b949e', fontSize: 10 },
},
visualMap: {
min: 0,
max: Math.max(...props.data.map(d => d.value), 1),
calculable: true,
orient: 'horizontal',
left: 'center',
bottom: 0,
inRange: { color: ['#161b22', '#2d9cdb', '#36b37e', '#e3b341', '#ff6b6b', '#ff4444'] },
textStyle: { color: '#8b949e' },
},
series: [{
type: 'heatmap',
data: props.data.map(d => [d.x, d.y, d.value]),
label: { show: true, color: '#e6edf3', fontSize: 10 },
itemStyle: { borderColor: '#30363d', borderWidth: 1 },
emphasis: { itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0,0,0,0.5)' } },
}],
}))
</script>
<style scoped>
.chart {
width: 100%;
height: v-bind(height);
}
</style>

@ -0,0 +1,73 @@
<template>
<v-chart class="chart" :option="option" autoresize />
</template>
<script setup lang="ts">
import { computed } from 'vue'
import VChart from 'vue-echarts'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { LineChart } from 'echarts/charts'
import {
TitleComponent,
TooltipComponent,
GridComponent,
LegendComponent,
} from 'echarts/components'
import type { EChartsOption } from 'echarts'
use([
CanvasRenderer,
LineChart,
TitleComponent,
TooltipComponent,
GridComponent,
LegendComponent,
])
const props = withDefaults(defineProps<{
title?: string
xAxisData: string[]
series: { name: string; data: number[]; color?: string; areaStyle?: boolean }[]
height?: string
inverseY?: boolean
}>(), {
title: '',
height: '300px',
inverseY: false,
})
const option = computed<EChartsOption>(() => ({
title: props.title ? { text: props.title, textStyle: { color: '#e6edf3', fontSize: 12 } } : undefined,
tooltip: { trigger: 'axis' },
legend: props.series.length > 1 ? { data: props.series.map(s => s.name), textStyle: { color: '#8b949e' } } : undefined,
grid: { left: 40, right: 20, top: props.title ? 30 : 10, bottom: 30 },
xAxis: {
type: 'category',
data: props.xAxisData,
axisLine: { lineStyle: { color: '#30363d' } },
axisLabel: { color: '#8b949e', fontSize: 10 },
},
yAxis: {
type: 'value',
inverse: props.inverseY,
splitLine: { lineStyle: { color: '#21262d' } },
axisLabel: { color: '#8b949e' },
},
series: props.series.map(s => ({
name: s.name,
type: 'line',
data: s.data,
smooth: true,
itemStyle: { color: s.color || '#58a6ff' },
areaStyle: s.areaStyle ? { color: s.color ? `${s.color}33` : 'rgba(88,166,255,0.2)' } : undefined,
})),
}))
</script>
<style scoped>
.chart {
width: 100%;
height: v-bind(height);
}
</style>

@ -0,0 +1,56 @@
<template>
<v-chart class="chart" :option="option" autoresize />
</template>
<script setup lang="ts">
import { computed } from 'vue'
import VChart from 'vue-echarts'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { PieChart } from 'echarts/charts'
import {
TitleComponent,
TooltipComponent,
LegendComponent,
} from 'echarts/components'
import type { EChartsOption } from 'echarts'
use([
CanvasRenderer,
PieChart,
TitleComponent,
TooltipComponent,
LegendComponent,
])
const props = withDefaults(defineProps<{
title?: string
data: { name: string; value: number }[]
height?: string
}>(), {
title: '',
height: '300px',
})
const option = computed<EChartsOption>(() => ({
title: props.title ? { text: props.title, textStyle: { color: '#e6edf3', fontSize: 12 } } : undefined,
tooltip: { trigger: 'item' },
legend: { orient: 'vertical', right: 10, top: 'center', textStyle: { color: '#8b949e' } },
series: [{
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: { borderRadius: 4, borderColor: '#1c2333', borderWidth: 2 },
label: { show: false },
emphasis: { label: { show: true, fontSize: 12, fontWeight: 'bold', color: '#e6edf3' } },
data: props.data,
}],
}))
</script>
<style scoped>
.chart {
width: 100%;
height: v-bind(height);
}
</style>

@ -41,6 +41,26 @@ export const constantRoutes: RouteRecordRaw[] = [
}, },
], ],
}, },
{
path: '/dashboard',
component: () => import('@/layout/index.vue'),
redirect: '/dashboard/index',
meta: { title: '行情总览', icon: 'TrendCharts' },
children: [
{
path: 'index',
name: 'DashboardIndex',
component: () => import('@/views/dashboard/index.vue'),
meta: { title: '行情总览', affix: true },
},
{
path: 'heatmap',
name: 'DashboardHeatmap',
component: () => import('@/views/heatmap/index.vue'),
meta: { title: '趋势热力' },
},
],
},
// Catch-all 404 - must be added last // Catch-all 404 - must be added last
{ {
path: '/:pathMatch(.*)*', path: '/:pathMatch(.*)*',

@ -0,0 +1,706 @@
<template>
<div class="dashboard">
<!-- Demo notice -->
<div class="demo-note">Demo模式 - 数据为模拟展示点击任意详情可查看详情弹窗</div>
<!-- Market filter -->
<div class="mfilter">
<span class="mlbl">市场范围</span>
<div
v-for="m in markets"
:key="m"
:class="['mtab', { on: selectedMarket === m }]"
@click="selectedMarket = m"
>{{ m }}</div>
</div>
<!-- Mini stats -->
<div class="ministats">
<div class="ministat">
<div class="lbl">上涨家数</div>
<div class="val vr">2,618</div>
<div class="sub">占比 50.2%</div>
</div>
<div class="ministat">
<div class="lbl">下跌家数</div>
<div class="val vg">2,318</div>
<div class="sub">占比 44.4%</div>
</div>
<div class="ministat">
<div class="lbl">涨停家数</div>
<div class="val vr">67</div>
<div class="sub">较昨日 +12</div>
</div>
<div class="ministat">
<div class="lbl">跌停家数</div>
<div class="val vg">18</div>
<div class="sub">较昨日 -5</div>
</div>
</div>
<!-- Sentiment cards -->
<div class="sentrow">
<div class="sentcard hot">
<div class="snm">全A情绪</div>
<div class="sval shot">72</div>
<div class="slabel shot">偏热</div>
<div class="sbar"><div class="sf sf-h" style="width: 72%"></div></div>
<div class="smeta"><span>涨停 67</span><span>跌停 18</span><span>涨跌比 1.13</span></div>
</div>
<div class="sentcard warm">
<div class="snm">中证1000</div>
<div class="sval swarm">68</div>
<div class="slabel swarm">偏热</div>
<div class="sbar"><div class="sf sf-w" style="width: 68%"></div></div>
<div class="smeta"><span>涨停 45</span><span>跌停 12</span><span>涨跌比 1.21</span></div>
</div>
<div class="sentcard neutral">
<div class="snm">创业板</div>
<div class="sval sneutral">52</div>
<div class="slabel sneutral">中性</div>
<div class="sbar"><div class="sf sf-n" style="width: 52%"></div></div>
<div class="smeta"><span>涨停 28</span><span>跌停 8</span><span>涨跌比 0.95</span></div>
</div>
<div class="sentcard cool">
<div class="snm">科创板</div>
<div class="sval scool">38</div>
<div class="slabel scool">偏冷</div>
<div class="sbar"><div class="sf sf-c" style="width: 38%"></div></div>
<div class="smeta"><span>涨停 8</span><span>跌停 5</span><span>涨跌比 0.82</span></div>
</div>
</div>
<!-- Row: Distribution chart + Trend sectors -->
<div class="drow1">
<!-- Distribution chart -->
<div class="card">
<div class="chd">
<div class="cht"><div class="dot" style="background: var(--red)"></div>涨跌分布</div>
</div>
<div class="cbd" style="padding: 4px 8px 8px">
<div class="bchart">
<div v-for="b in distData" :key="b.label" class="bgrp">
<div class="bval">{{ b.value }}</div>
<div :class="['bar', `bar-${b.type}`]" :style="{ height: b.height + 'px' }"></div>
<div class="blbl">{{ b.label }}</div>
</div>
</div>
</div>
</div>
<!-- Trend sectors TOP 10 -->
<div class="card">
<div class="chd">
<div class="cht"><div class="dot" style="background: var(--blu)"></div>趋势板块 TOP 10</div>
</div>
<div class="cbd" style="padding: 0">
<div class="tw" style="max-height: 230px; overflow-y: auto">
<table>
<thead>
<tr>
<th style="width: 28px">#</th>
<th>板块</th>
<th style="text-align: center">动量</th>
<th style="text-align: center">涨停</th>
<th style="text-align: center">新高</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="s in topSectors" :key="s.name">
<td><span :class="['rb', rankClass(s.rank)]">{{ s.rank }}</span></td>
<td><b>{{ s.name }}</b></td>
<td style="text-align: center"><span :class="['sig', signalClass(s.momentum)]">Rank {{ s.momentum }}</span></td>
<td style="text-align: center" :class="{ vr: s.limitUp > 5 }">{{ s.limitUp }}</td>
<td style="text-align: center" :class="{ vr: s.newHigh > 3 }">{{ s.newHigh }}</td>
<td><button class="btn" @click="showDetail(s.name)"></button></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Row: Trend changes + Continuous stats -->
<div class="drow2">
<!-- Trend changes -->
<div class="card">
<div class="chd">
<div class="cht"><div class="dot" style="background: var(--org)"></div>趋势变化榜</div>
</div>
<div class="cbd">
<div v-for="c in trendChanges" :key="c.name" class="change-item">
<div class="name">{{ c.name }}</div>
<div class="rk">排名 {{ c.rank }}</div>
<div :class="['delta', c.delta > 0 ? 'arrow-up' : 'arrow-down']">
{{ c.delta > 0 ? '上升' : '下降' }} {{ Math.abs(c.delta) }}
</div>
</div>
</div>
</div>
<!-- Continuous stats -->
<div class="card">
<div class="chd">
<div class="cht"><div class="dot" style="background: var(--grn)"></div>强势板块连续统计</div>
</div>
<div class="cbd" style="padding: 0">
<table>
<thead>
<tr>
<th>板块</th>
<th style="text-align: center">连续天数</th>
<th style="text-align: center">当前排名</th>
<th style="text-align: center">平均排名</th>
</tr>
</thead>
<tbody>
<tr v-for="c in continuousStats" :key="c.name">
<td><b>{{ c.name }}</b></td>
<td style="text-align: center"><span :class="['sig', continuousClass(c.days)]">{{ c.days }}</span></td>
<td style="text-align: center">{{ c.currentRank }}</td>
<td style="text-align: center">{{ c.avgRank }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Detail modal -->
<Teleport to="body">
<div v-if="showModal" class="modal-ov" @click.self="showModal = false">
<div class="modal">
<div class="mhd">
<h3>{{ detailTitle }}</h3>
<button class="mcl" @click="showModal = false">X</button>
</div>
<div class="mbd">
<div class="detailrow">
<div class="stat-card"><div class="sl">当前动量</div><div class="sv">12.34</div></div>
<div class="stat-card"><div class="sl">近5日趋势</div><div class="sv">持续上升</div></div>
<div class="stat-card"><div class="sl">涨停家数</div><div class="sv">3</div></div>
<div class="stat-card"><div class="sl">创新高</div><div class="sv">7</div></div>
<div class="stat-card"><div class="sl">连续天数</div><div class="sv">5</div></div>
<div class="stat-card"><div class="sl">平均排名</div><div class="sv">2.4</div></div>
</div>
<div id="detail-kline" class="kline-container"></div>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, nextTick } from 'vue'
import * as echarts from 'echarts'
// Market tabs
const markets = ['全A', '上证', '深证', '创业板', '科创板', '中证1000']
const selectedMarket = ref('全A')
// Distribution data
const distData = [
{ label: '+10%', value: 67, height: 12, type: 'r' },
{ label: '7-10%', value: 47, height: 9, type: 'r' },
{ label: '5-7%', value: 89, height: 14, type: 'r' },
{ label: '3-5%', value: 230, height: 24, type: 'r' },
{ label: '0-3%', value: 2056, height: 82, type: 'r' },
{ label: '平', value: 207, height: 17, type: 'gr' },
{ label: '0-3%', value: 2174, height: 87, type: 'g' },
{ label: '3-5%', value: 218, height: 18, type: 'g' },
{ label: '5-7%', value: 49, height: 8, type: 'g' },
{ label: '-7%', value: 28, height: 6, type: 'g' },
{ label: '-10%', value: 18, height: 4, type: 'g' },
]
// Top sectors
const topSectors = [
{ rank: 1, name: '零售', momentum: 7, limitUp: 7, newHigh: 4 },
{ rank: 2, name: '化学制药', momentum: 6, limitUp: 4, newHigh: 5 },
{ rank: 3, name: '食品', momentum: 4, limitUp: 6, newHigh: 4 },
{ rank: 4, name: '畜牧业', momentum: 2, limitUp: 2, newHigh: 3 },
{ rank: 5, name: '贵金属', momentum: 5, limitUp: 3, newHigh: 4 },
{ rank: 6, name: '生物医药', momentum: 1, limitUp: 1, newHigh: 8 },
{ rank: 7, name: '物流', momentum: 7, limitUp: 7, newHigh: 4 },
{ rank: 8, name: '半导体', momentum: 2, limitUp: 2, newHigh: 2 },
{ rank: 9, name: '化学制品', momentum: 7, limitUp: 7, newHigh: 6 },
{ rank: 10, name: '商业物业', momentum: 1, limitUp: 1, newHigh: 0 },
]
// Trend changes
const trendChanges = [
{ name: '物流', rank: 7, delta: 5 },
{ name: '零售', rank: 1, delta: 4 },
{ name: '化学制药', rank: 2, delta: 3 },
{ name: '食品', rank: 3, delta: 2 },
{ name: '软件开发', rank: 35, delta: -8 },
{ name: '新能源', rank: 42, delta: -6 },
]
// Continuous stats
const continuousStats = [
{ name: '零售', days: 5, currentRank: 1, avgRank: 2.4 },
{ name: '食品', days: 4, currentRank: 3, avgRank: 3.2 },
{ name: '化学制药', days: 3, currentRank: 2, avgRank: 4.0 },
{ name: '物流', days: 3, currentRank: 7, avgRank: 12.0 },
{ name: '贵金属', days: 2, currentRank: 5, avgRank: 8.5 },
]
// Modal
const showModal = ref(false)
const detailTitle = ref('板块详情')
function showDetail(name: string) {
detailTitle.value = `${name}板块详情`
showModal.value = true
nextTick(() => initDetailChart())
}
function rankClass(rank: number): string {
if (rank === 1) return 'r1'
if (rank === 2) return 'r2'
if (rank === 3) return 'r3'
return 'ro'
}
function signalClass(momentum: number): string {
if (momentum >= 6) return 'sig-u'
if (momentum >= 4) return 'sig-o'
return 'sig-n'
}
function continuousClass(days: number): string {
if (days >= 5) return 'sig-u'
if (days >= 3) return 'sig-o'
return 'sig-n'
}
function initDetailChart() {
const container = document.getElementById('detail-kline')
if (!container || (container as any)._chart) return
const chart = echarts.init(container, undefined, { renderer: 'canvas' })
;(container as any)._chart = chart
chart.setOption({
title: { text: 'K线示意', textStyle: { color: '#e6edf3', fontSize: 11 } },
grid: { left: 50, right: 20, top: 30, bottom: 20 },
xAxis: {
data: ['04-01', '04-02', '04-03', '04-04', '04-05', '04-08', '04-09', '04-10', '04-11', '04-14', '04-15', '04-16', '04-17', '04-18', '04-21', '04-22'],
axisLine: { lineStyle: { color: '#30363d' } },
axisLabel: { color: '#8b949e' },
},
yAxis: {
splitLine: { lineStyle: { color: '#21262d' } },
axisLabel: { color: '#8b949e' },
},
series: [{
type: 'candlestick',
data: [
[10, 11, 9.5, 10.5], [10.5, 11.5, 10, 11], [11, 12, 10.5, 11.5],
[11.5, 12.5, 11, 12], [12, 13, 11.5, 12.5], [12.5, 13.5, 12, 13],
[13, 14, 12.5, 13.5], [13.5, 14.5, 13, 14], [14, 15, 13.5, 14.5],
[14.5, 15.5, 14, 15], [15, 16, 14.5, 15.5], [15.5, 16.5, 15, 16],
[16, 17, 15.5, 16.5], [16.5, 17.5, 16, 17], [17, 18, 16.5, 17.5],
[17.5, 18.5, 17, 18],
],
}],
})
}
</script>
<style scoped lang="scss">
.dashboard {
padding: 16px;
.demo-note {
background: rgba(88, 166, 255, 0.1);
border: 1px solid var(--blu);
border-radius: 5px;
padding: 8px 12px;
margin-bottom: 12px;
font-size: 10px;
color: var(--blu);
}
.mfilter {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 10px;
padding: 8px 12px;
background: var(--bg1);
border: 1px solid var(--bd);
border-radius: 6px;
flex-wrap: wrap;
.mlbl {
font-size: 10px;
color: var(--t2);
}
.mtab {
padding: 4px 12px;
font-size: 10px;
color: var(--t2);
cursor: pointer;
border-radius: 4px;
border: 1px solid transparent;
background: transparent;
transition: all 0.12s;
&:hover {
background: var(--bg3);
color: var(--t1);
}
&.on {
background: rgba(88, 166, 255, 0.1);
color: var(--blu);
border-color: var(--blu);
}
}
}
.ministats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
margin-bottom: 12px;
.ministat {
background: var(--bg2);
border: 1px solid var(--bd);
border-radius: 5px;
padding: 8px 12px;
.lbl {
font-size: 9px;
color: var(--tm);
margin-bottom: 2px;
}
.val {
font-size: 20px;
font-weight: 700;
}
.sub {
font-size: 9px;
color: var(--t2);
margin-top: 1px;
}
}
}
.sentrow {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
margin-bottom: 12px;
.sentcard {
background: var(--bg2);
border: 1px solid var(--bd);
border-radius: 6px;
padding: 10px 12px;
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
}
&.hot::before { background: linear-gradient(90deg, #ff8a65, #f44336); }
&.warm::before { background: linear-gradient(90deg, #ffd54f, #ff8a65); }
&.neutral::before { background: linear-gradient(90deg, #66bb6a, #ffd54f); }
&.cool::before { background: linear-gradient(90deg, #4fc3f7, #66bb6a); }
.snm { font-size: 11px; color: var(--t2); margin-bottom: 4px; }
.sval { font-size: 28px; font-weight: 700; line-height: 1; }
.slabel { font-size: 10px; margin-top: 2px; }
.sbar {
margin-top: 8px;
height: 4px;
background: var(--bg3);
border-radius: 2px;
overflow: hidden;
.sf {
height: 100%;
border-radius: 2px;
&.sf-h { background: linear-gradient(90deg, #ff8a65, #f44336); }
&.sf-w { background: linear-gradient(90deg, #ffd54f, #ff8a65); }
&.sf-n { background: linear-gradient(90deg, #66bb6a, #ffd54f); }
&.sf-c { background: linear-gradient(90deg, #4fc3f7, #66bb6a); }
}
}
.smeta {
margin-top: 6px;
display: flex;
justify-content: space-between;
font-size: 8px;
color: var(--tm);
}
}
}
.drow1 {
display: grid;
grid-template-columns: 300px 1fr;
gap: 12px;
margin-bottom: 12px;
}
.drow2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.card {
background: var(--bg2);
border: 1px solid var(--bd);
border-radius: 7px;
overflow: hidden;
.chd {
padding: 8px 12px;
border-bottom: 1px solid var(--bd);
display: flex;
align-items: center;
justify-content: space-between;
.cht {
font-size: 12px;
font-weight: 600;
display: flex;
align-items: center;
gap: 5px;
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
}
}
.cbd {
padding: 10px 12px;
}
}
.bchart {
display: flex;
align-items: flex-end;
justify-content: space-around;
height: 140px;
padding: 4px 0;
border-bottom: 1px solid var(--bd);
.bgrp {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
flex: 1;
}
.bar {
width: 18px;
border-radius: 2px 2px 0 0;
&.bar-r { background: var(--red); }
&.bar-g { background: var(--grn); }
&.bar-gr { background: var(--tm); }
}
.blbl { font-size: 7px; color: var(--tm); }
.bval { font-size: 7px; color: var(--t2); }
}
table {
width: 100%;
border-collapse: collapse;
font-size: 10px;
th {
background: var(--bg1);
padding: 5px 8px;
text-align: left;
font-weight: 600;
color: var(--t2);
border-bottom: 1px solid var(--bd);
position: sticky;
top: 0;
}
td {
padding: 5px 8px;
border-bottom: 1px solid var(--bd);
}
tr:hover td {
background: var(--bg3);
}
}
.tw {
overflow-x: auto;
}
.rb {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 4px;
font-size: 9px;
font-weight: 700;
&.r1 { background: linear-gradient(135deg, #ff6b6b, #ee5a24); color: #fff; }
&.r2 { background: linear-gradient(135deg, #ffa502, #e17009); color: #fff; }
&.r3 { background: linear-gradient(135deg, #e3b341, #d29922); color: #fff; }
&.ro { background: var(--bg1); color: var(--t2); }
}
.vr { color: var(--red); }
.vg { color: var(--grn); }
.sig {
display: inline-flex;
padding: 1px 5px;
border-radius: 8px;
font-size: 9px;
font-weight: 600;
&.sig-u { background: rgba(248, 81, 73, 0.12); color: var(--red); }
&.sig-o { background: rgba(210, 153, 34, 0.12); color: var(--org); }
&.sig-n { background: var(--bg3); color: var(--tm); }
}
.shot { color: #f44336; }
.swarm { color: #ff8a65; }
.sneutral { color: #ffd54f; }
.scool { color: #66bb6a; }
.change-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
border-bottom: 1px solid var(--bd);
&:last-child { border-bottom: none; }
.name { flex: 1; font-size: 11px; }
.rk { font-size: 10px; color: var(--t2); min-width: 60px; }
.delta { font-size: 10px; font-weight: 600; min-width: 50px; text-align: right; }
}
.arrow-up { color: var(--red); }
.arrow-down { color: var(--grn); }
.btn {
padding: 3px 8px;
border-radius: 3px;
border: 1px solid var(--bd);
background: var(--bg2);
color: var(--t1);
font-size: 9px;
cursor: pointer;
&:hover { background: var(--bg3); }
}
}
// Modal styles (unscoped since Teleport)
:global {
.modal-ov {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
}
.modal {
background: var(--bg1);
border: 1px solid var(--bd);
border-radius: 8px;
width: 92%;
max-width: 980px;
max-height: 85vh;
display: flex;
flex-direction: column;
overflow: hidden;
.mhd {
padding: 10px 14px;
border-bottom: 1px solid var(--bd);
display: flex;
align-items: center;
justify-content: space-between;
h3 { font-size: 13px; color: var(--t1); }
}
.mcl {
width: 22px;
height: 22px;
border-radius: 4px;
border: none;
background: var(--bg2);
color: var(--t2);
cursor: pointer;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
&:hover { background: var(--red); color: #fff; }
}
.mbd {
flex: 1;
overflow-y: auto;
padding: 12px;
}
}
.detailrow {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin-bottom: 10px;
}
.stat-card {
background: var(--bg1);
border: 1px solid var(--bd);
border-radius: 5px;
padding: 8px 12px;
.sl { font-size: 9px; color: var(--tm); margin-bottom: 2px; }
.sv { font-size: 22px; font-weight: 700; color: var(--t1); }
}
.kline-container {
width: 100%;
height: 180px;
}
}
</style>

@ -0,0 +1,507 @@
<template>
<div class="heatmap-page">
<!-- Filter bar -->
<div class="fbar">
<label>动量周期</label>
<el-select v-model="selectedCycle" size="small" style="width: 100px">
<el-option v-for="c in cycles" :key="c" :label="c" :value="c" />
</el-select>
<label>日期</label>
<el-date-picker
v-model="selectedDate"
type="date"
size="small"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
style="width: 140px"
/>
<el-button size="small" @click="refreshData"></el-button>
</div>
<!-- Mode switch -->
<div class="mode-switch">
<div :class="['mode-btn', { on: viewMode === 'hm' }]" @click="viewMode = 'hm'">热力矩阵</div>
<div :class="['mode-btn', { on: viewMode === 'tb' }]" @click="viewMode = 'tb'">增强表格</div>
</div>
<!-- Heatmap matrix view -->
<div v-show="viewMode === 'hm'" class="view-hm">
<div class="hmc">
<table class="hmt">
<thead>
<tr>
<th style="width: 80px; text-align: left; padding: 4px 10px; font-size: 10px">2级行业</th>
<th v-for="d in dates" :key="d">{{ d }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in heatmapData" :key="row.industry">
<td class="hnc">{{ row.industry }}</td>
<td v-for="(rank, idx) in row.ranks" :key="idx">
<div :class="['hcell', rankCellClass(rank)]" @click="showDetail(row.industry)">
<div class="hrk">{{ rank }}</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Legend -->
<div class="hmlabel">
<span>排名</span>
<div v-for="i in 10" :key="i" class="hmlabel-item">
<div :class="['hmlabel-c', `h${i}`]"></div>
<span>{{ i }}</span>
</div>
<div class="hmlabel-item">
<div class="hmlabel-c hd"></div>
<span>>10</span>
</div>
</div>
</div>
<!-- Enhanced table view -->
<div v-show="viewMode === 'tb'" class="view-tb">
<div class="card">
<div class="cbd" style="padding: 0">
<div class="tw" style="max-height: 500px; overflow-y: auto">
<table class="enhanced-tbl">
<thead>
<tr>
<th>板块</th>
<th v-for="d in dates" :key="d" style="text-align: center"></th>
<th>趋势</th>
</tr>
</thead>
<tbody>
<tr v-for="row in heatmapData" :key="row.industry">
<td><b>{{ row.industry }}</b></td>
<td v-for="(rank, idx) in row.ranks" :key="idx" style="text-align: center" :class="rankColorClass(rank, row.ranks)">
{{ rank }}
</td>
<td :class="trendClass(row)">
{{ row.trendText }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Detail modal -->
<Teleport to="body">
<div v-if="showModal" class="modal-ov" @click.self="showModal = false">
<div class="modal">
<div class="mhd">
<h3>{{ detailTitle }}</h3>
<button class="mcl" @click="showModal = false">X</button>
</div>
<div class="mbd">
<div class="detailrow">
<div class="stat-card"><div class="sl">当前动量</div><div class="sv">12.34</div></div>
<div class="stat-card"><div class="sl">近5日趋势</div><div class="sv">持续上升</div></div>
<div class="stat-card"><div class="sl">涨停家数</div><div class="sv">3</div></div>
<div class="stat-card"><div class="sl">创新高</div><div class="sv">7</div></div>
<div class="stat-card"><div class="sl">连续天数</div><div class="sv">5</div></div>
<div class="stat-card"><div class="sl">平均排名</div><div class="sv">2.4</div></div>
</div>
<div id="heatmap-kline" class="kline-container"></div>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, nextTick } from 'vue'
import * as echarts from 'echarts'
// Cycles
const cycles = ['1日', '3日', '5日', '10日', '15日', '20日', '30日']
const selectedCycle = ref('20日')
const selectedDate = ref('2025-04-22')
// View mode
const viewMode = ref<'hm' | 'tb'>('hm')
// Dates
const dates = ['04-22', '04-21', '04-18', '04-17', '04-16', '04-15', '04-14', '04-11', '04-10', '04-09']
// Heatmap data
interface HeatmapRow {
industry: string
ranks: number[]
trend: number
trendText: string
}
const heatmapData = ref<HeatmapRow[]>([
{ industry: '零售', ranks: [1, 1, 1, 3, 8, 10, 2, 5, 8, 8], trend: -7, trendText: '下降 7位' },
{ industry: '化学制药', ranks: [2, 4, 5, 5, 6, 2, 3, 3, 7, 11], trend: -9, trendText: '下降 9位' },
{ industry: '食品', ranks: [3, 2, 2, 1, 2, 5, 5, 2, 2, 2], trend: 1, trendText: '上升 1位' },
{ industry: '畜牧业', ranks: [4, 3, 9, 4, 4, 1, 4, 7, 5, 3], trend: 1, trendText: '上升 1位' },
{ industry: '贵金属', ranks: [5, 6, 4, 2, 3, 6, 10, 1, 6, 6], trend: -1, trendText: '下降 1位' },
{ industry: '生物医药', ranks: [6, 5, 6, 7, 5, 4, 1, 4, 3, 5], trend: 1, trendText: '上升 1位' },
{ industry: '物流', ranks: [7, 11, 15, 12, 18, 14, 12, 10, 12, 15], trend: -8, trendText: '下降 8位' },
{ industry: '半导体', ranks: [8, 7, 7, 6, 7, 3, 6, 8, 4, 4], trend: 4, trendText: '上升 4位' },
{ industry: '化学制品', ranks: [9, 8, 3, 8, 10, 8, 3, 6, 9, 9], trend: 0, trendText: '下降 0位' },
{ industry: '商业物业', ranks: [10, 9, 8, 10, 9, 7, 7, 15, 11, 12], trend: -2, trendText: '下降 2位' },
{ industry: '软件开发', ranks: [15, 12, 10, 9, 11, 9, 8, 9, 10, 10], trend: 5, trendText: '上升 5位' },
{ industry: '新能源', ranks: [20, 18, 14, 11, 12, 11, 9, 11, 14, 13], trend: 7, trendText: '上升 7位' },
{ industry: '传媒', ranks: [25, 22, 18, 15, 14, 12, 11, 12, 15, 14], trend: 11, trendText: '上升 11位' },
{ industry: '银行', ranks: [12, 14, 12, 14, 15, 13, 14, 13, 13, 11], trend: 1, trendText: '上升 1位' },
{ industry: '房地产', ranks: [18, 20, 22, 18, 16, 18, 15, 14, 18, 16], trend: 2, trendText: '上升 2位' },
{ industry: '汽车', ranks: [14, 15, 16, 16, 13, 15, 13, 18, 16, 18], trend: -4, trendText: '下降 4位' },
{ industry: '电子器件', ranks: [16, 16, 11, 13, 20, 16, 16, 16, 20, 17], trend: -1, trendText: '下降 1位' },
{ industry: '饮料', ranks: [11, 10, 13, 20, 22, 20, 18, 17, 17, 19], trend: -8, trendText: '下降 8位' },
{ industry: '建筑装饰', ranks: [22, 19, 20, 22, 25, 22, 20, 19, 22, 20], trend: 2, trendText: '上升 2位' },
{ industry: '通信', ranks: [30, 28, 25, 25, 28, 25, 22, 20, 25, 22], trend: 8, trendText: '上升 8位' },
])
// Modal
const showModal = ref(false)
const detailTitle = ref('板块详情')
function showDetail(name: string) {
detailTitle.value = `${name}板块详情`
showModal.value = true
nextTick(() => initDetailChart())
}
function rankCellClass(rank: number): string {
if (rank <= 10) return `h${rank}`
return 'hd'
}
function rankColorClass(rank: number, _ranks: number[]): string {
if (rank <= 5) return 'vr'
if (rank > 15) return 'vg'
return ''
}
function trendClass(row: HeatmapRow): string {
if (row.trend > 0) return 'arrow-down'
if (row.trend < 0) return 'arrow-up'
return ''
}
function refreshData() {
// Simulate refresh
}
function initDetailChart() {
const container = document.getElementById('heatmap-kline')
if (!container || (container as any)._chart) return
const chart = echarts.init(container, undefined, { renderer: 'canvas' })
;(container as any)._chart = chart
chart.setOption({
title: { text: 'K线示意', textStyle: { color: '#e6edf3', fontSize: 11 } },
grid: { left: 50, right: 20, top: 30, bottom: 20 },
xAxis: {
data: ['04-01', '04-02', '04-03', '04-04', '04-05', '04-08', '04-09', '04-10', '04-11', '04-14', '04-15', '04-16', '04-17', '04-18', '04-21', '04-22'],
axisLine: { lineStyle: { color: '#30363d' } },
axisLabel: { color: '#8b949e' },
},
yAxis: {
splitLine: { lineStyle: { color: '#21262d' } },
axisLabel: { color: '#8b949e' },
},
series: [{
type: 'candlestick',
data: [
[10, 11, 9.5, 10.5], [10.5, 11.5, 10, 11], [11, 12, 10.5, 11.5],
[11.5, 12.5, 11, 12], [12, 13, 11.5, 12.5], [12.5, 13.5, 12, 13],
[13, 14, 12.5, 13.5], [13.5, 14.5, 13, 14], [14, 15, 13.5, 14.5],
[14.5, 15.5, 14, 15], [15, 16, 14.5, 15.5], [15.5, 16.5, 15, 16],
[16, 17, 15.5, 16.5], [16.5, 17.5, 16, 17], [17, 18, 16.5, 17.5],
[17.5, 18.5, 17, 18],
],
}],
})
}
</script>
<style scoped lang="scss">
.heatmap-page {
padding: 16px;
.fbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 10px;
label {
font-size: 10px;
color: var(--t2);
}
}
.mode-switch {
display: flex;
gap: 0;
margin-bottom: 10px;
border: 1px solid var(--bd);
border-radius: 6px;
overflow: hidden;
width: fit-content;
.mode-btn {
padding: 6px 16px;
font-size: 11px;
color: var(--t2);
cursor: pointer;
background: var(--bg2);
border: none;
border-right: 1px solid var(--bd);
transition: all 0.1s;
&:hover {
color: var(--t1);
background: var(--bg3);
}
&.on {
color: var(--blu);
background: rgba(88, 166, 255, 0.1);
}
}
}
.card {
background: var(--bg2);
border: 1px solid var(--bd);
border-radius: 7px;
overflow: hidden;
.cbd {
padding: 10px 12px;
}
}
.hmc {
overflow-x: auto;
}
.hmt {
border-collapse: collapse;
min-width: 100%;
th {
background: var(--bg1);
padding: 4px 5px;
font-size: 9px;
text-align: center;
color: var(--t2);
border: 1px solid var(--bd);
}
td {
padding: 0;
border: 1px solid var(--bd);
text-align: center;
}
}
.hcell {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4px 2px;
min-width: 44px;
cursor: pointer;
transition: transform 0.1s, box-shadow 0.1s;
&:hover {
transform: scale(1.06);
z-index: 2;
box-shadow: 0 0 0 2px var(--blu);
}
.hrk {
font-size: 11px;
font-weight: 700;
}
}
.h1 { background: linear-gradient(135deg, #ff4444, #cc0000); color: #fff; }
.h2 { background: linear-gradient(135deg, #ff6b6b, #e04040); color: #fff; }
.h3 { background: linear-gradient(135deg, #ff9a76, #d94f4f); color: #fff; }
.h4 { background: linear-gradient(135deg, #ffa502, #c77f00); color: #fff; }
.h5 { background: linear-gradient(135deg, #e3b341, #b8911a); color: #333; }
.h6 { background: linear-gradient(135deg, #e6d34a, #c4b538); color: #333; }
.h7 { background: linear-gradient(135deg, #8bda5e, #5fb32e); color: #fff; }
.h8 { background: linear-gradient(135deg, #4dd08b, #28a360); color: #fff; }
.h9 { background: linear-gradient(135deg, #36b37e, #1e8c5e); color: #fff; }
.h10 { background: linear-gradient(135deg, #2d9cdb, #1a7ab5); color: #fff; }
.hd { background: var(--bg2); color: var(--t2); }
.hnc {
background: var(--bg2);
padding: 4px 8px;
text-align: left;
font-size: 10px;
font-weight: 500;
min-width: 64px;
position: sticky;
left: 0;
z-index: 1;
color: var(--t1);
}
.hmlabel {
padding: 8px 12px;
display: flex;
align-items: center;
gap: 8px;
font-size: 9px;
color: var(--t2);
flex-wrap: wrap;
.hmlabel-item {
display: flex;
align-items: center;
gap: 3px;
}
.hmlabel-c {
width: 12px;
height: 12px;
border-radius: 2px;
&.h1 { background: linear-gradient(135deg, #ff4444, #cc0000); }
&.h2 { background: linear-gradient(135deg, #ff6b6b, #e04040); }
&.h3 { background: linear-gradient(135deg, #ff9a76, #d94f4f); }
&.h4 { background: linear-gradient(135deg, #ffa502, #c77f00); }
&.h5 { background: linear-gradient(135deg, #e3b341, #b8911a); }
&.h6 { background: linear-gradient(135deg, #e6d34a, #c4b538); }
&.h7 { background: linear-gradient(135deg, #8bda5e, #5fb32e); }
&.h8 { background: linear-gradient(135deg, #4dd08b, #28a360); }
&.h9 { background: linear-gradient(135deg, #36b37e, #1e8c5e); }
&.h10 { background: linear-gradient(135deg, #2d9cdb, #1a7ab5); }
&.hd { background: var(--bg2); border: 1px solid var(--bd); }
}
}
.vr { color: var(--red); }
.vg { color: var(--grn); }
.arrow-up { color: var(--red); }
.arrow-down { color: var(--grn); }
.enhanced-tbl {
width: 100%;
border-collapse: collapse;
font-size: 11px;
th {
background: var(--bg1);
padding: 6px 8px;
text-align: left;
font-weight: 600;
color: var(--t2);
border-bottom: 1px solid var(--bd);
position: sticky;
top: 0;
}
td {
padding: 6px 8px;
border-bottom: 1px solid var(--bd);
}
tr:hover td {
background: var(--bg3);
}
}
.tw {
overflow-x: auto;
}
}
// Modal styles (unscoped since Teleport)
:global {
.modal-ov {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
}
.modal {
background: var(--bg1);
border: 1px solid var(--bd);
border-radius: 8px;
width: 92%;
max-width: 980px;
max-height: 85vh;
display: flex;
flex-direction: column;
overflow: hidden;
.mhd {
padding: 10px 14px;
border-bottom: 1px solid var(--bd);
display: flex;
align-items: center;
justify-content: space-between;
h3 { font-size: 13px; color: var(--t1); }
}
.mcl {
width: 22px;
height: 22px;
border-radius: 4px;
border: none;
background: var(--bg2);
color: var(--t2);
cursor: pointer;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
&:hover { background: var(--red); color: #fff; }
}
.mbd {
flex: 1;
overflow-y: auto;
padding: 12px;
}
}
.detailrow {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin-bottom: 10px;
}
.stat-card {
background: var(--bg1);
border: 1px solid var(--bd);
border-radius: 5px;
padding: 8px 12px;
.sl { font-size: 9px; color: var(--tm); margin-bottom: 2px; }
.sv { font-size: 22px; font-weight: 700; color: var(--t1); }
}
.kline-container {
width: 100%;
height: 180px;
}
}
</style>
Loading…
Cancel
Save