184 lines
5.4 KiB
Vue
184 lines
5.4 KiB
Vue
<script setup lang="ts">
|
|
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
import * as echarts from 'echarts'
|
|
import type { Candle } from '@/api/candles'
|
|
import { useThemeStore } from '@/stores/theme'
|
|
import { useMobile } from '@/composables/useMobile'
|
|
|
|
const props = defineProps<{ data: Candle[]; scores?: { trade_date: string; composite: number }[] }>()
|
|
const theme = useThemeStore()
|
|
const { isMobile } = useMobile()
|
|
|
|
const containerRef = ref<HTMLDivElement | null>(null)
|
|
let chart: echarts.ECharts | null = null
|
|
|
|
function ensureChart() {
|
|
if (!containerRef.value) return
|
|
if (chart) {
|
|
chart.dispose()
|
|
chart = null
|
|
}
|
|
chart = echarts.init(containerRef.value, theme.isDark ? 'dark' : undefined)
|
|
}
|
|
|
|
function render() {
|
|
if (!chart) return
|
|
const dates = props.data.map((c) => c.trade_date)
|
|
// ECharts K 线顺序: [open, close, low, high]
|
|
const ohlc = props.data.map((c) => [c.open, c.close, c.low, c.high])
|
|
const oi = props.data.map((c) => c.oi)
|
|
|
|
const scoreMap = new Map((props.scores || []).map((s) => [s.trade_date, s.composite]))
|
|
const compositeData = props.data.map((c) => scoreMap.get(c.trade_date) ?? null)
|
|
|
|
const hasScores = props.scores && props.scores.length > 0
|
|
const legendData = hasScores ? ['K 线', '持仓量', '综合分'] : ['K 线', '持仓量']
|
|
const xAxisIndices = hasScores ? [0, 1, 2] : [0, 1]
|
|
const grids: any[] = [
|
|
{ left: isMobile.value ? 48 : 60, right: isMobile.value ? 12 : 40, top: 40, height: hasScores ? '52%' : '60%' },
|
|
{ left: isMobile.value ? 48 : 60, right: isMobile.value ? 12 : 40, top: hasScores ? '72%' : '78%', height: hasScores ? '14%' : '18%' },
|
|
]
|
|
const xAxes: any[] = [
|
|
{ type: 'category', data: dates, scale: true, boundaryGap: false },
|
|
{ type: 'category', gridIndex: 1, data: dates, scale: true, boundaryGap: false, axisLabel: { show: false } },
|
|
]
|
|
const yAxes: any[] = [
|
|
{ scale: true, splitArea: { show: true }, name: '价格', nameLocation: 'end', nameTextStyle: { fontSize: 11 } },
|
|
{ gridIndex: 1, scale: true, splitNumber: 3, name: '持仓', nameLocation: 'end', nameTextStyle: { fontSize: 11 } },
|
|
]
|
|
const series: any[] = [
|
|
{
|
|
name: 'K 线',
|
|
type: 'candlestick',
|
|
data: ohlc,
|
|
itemStyle: {
|
|
color: '#ec3a3a',
|
|
color0: '#26a69a',
|
|
borderColor: '#ec3a3a',
|
|
borderColor0: '#26a69a',
|
|
},
|
|
},
|
|
{
|
|
name: '持仓量',
|
|
type: 'line',
|
|
xAxisIndex: 1,
|
|
yAxisIndex: 1,
|
|
data: oi,
|
|
smooth: true,
|
|
showSymbol: false,
|
|
lineStyle: { color: '#5470c6' },
|
|
areaStyle: { opacity: 0.15, color: '#5470c6' },
|
|
},
|
|
]
|
|
|
|
if (hasScores) {
|
|
grids.push({ left: isMobile.value ? 48 : 60, right: isMobile.value ? 12 : 40, top: '88%', height: '10%' })
|
|
xAxes.push({ type: 'category', gridIndex: 2, data: dates, scale: true, boundaryGap: false, axisLabel: { show: false } })
|
|
yAxes.push({ gridIndex: 2, min: 0, max: 100, splitNumber: 2, name: '综合分', nameLocation: 'end', nameTextStyle: { fontSize: 11 } })
|
|
series.push({
|
|
name: '综合分',
|
|
type: 'bar',
|
|
xAxisIndex: 2,
|
|
yAxisIndex: 2,
|
|
data: compositeData,
|
|
barWidth: '60%',
|
|
itemStyle: {
|
|
color: (params: any) => {
|
|
const val = params.value as number | null
|
|
if (val == null) return 'transparent'
|
|
if (val >= 80) return '#ec3a3a'
|
|
if (val >= 50) return '#f89898'
|
|
if (val >= 40) return '#89d6c7'
|
|
return '#26a69a'
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
chart.setOption(
|
|
{
|
|
backgroundColor: 'transparent',
|
|
tooltip: {
|
|
trigger: 'axis',
|
|
axisPointer: { type: 'cross' },
|
|
formatter: (params: any) => {
|
|
if (!Array.isArray(params)) return ''
|
|
const date = params[0]?.axisValue || ''
|
|
let html = `<strong>${date}</strong><br/>`
|
|
for (const p of params) {
|
|
if (p.seriesName === 'K 线') {
|
|
const ohlc = p.data as number[]
|
|
const labels = ['开盘', '收盘', '最低', '最高']
|
|
html += labels.map((n, i) => `${p.marker} ${n}: ${ohlc[i] ?? '-'}`).join('<br/>') + '<br/>'
|
|
continue
|
|
}
|
|
let name = p.seriesName
|
|
let val: string
|
|
if (name === '持仓量') val = (p.data as number)?.toLocaleString() ?? '-'
|
|
else if (name === '综合分') val = (p.data as number)?.toFixed(1) ?? '-'
|
|
else val = p.data ?? '-'
|
|
html += `${p.marker} ${name}: ${val}<br/>`
|
|
}
|
|
return html
|
|
},
|
|
},
|
|
legend: { data: legendData, top: 0 },
|
|
grid: grids,
|
|
xAxis: xAxes,
|
|
yAxis: yAxes,
|
|
dataZoom: [
|
|
{ type: 'inside', xAxisIndex: xAxisIndices },
|
|
{ type: 'slider', xAxisIndex: xAxisIndices, height: 18, bottom: 6 },
|
|
],
|
|
series,
|
|
},
|
|
true,
|
|
)
|
|
}
|
|
|
|
function resize() {
|
|
chart?.resize()
|
|
}
|
|
|
|
onMounted(() => {
|
|
ensureChart()
|
|
render()
|
|
window.addEventListener('resize', resize)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
window.removeEventListener('resize', resize)
|
|
chart?.dispose()
|
|
chart = null
|
|
})
|
|
|
|
watch(() => props.data, render, { deep: true })
|
|
watch(
|
|
() => theme.isDark,
|
|
() => {
|
|
ensureChart()
|
|
render()
|
|
},
|
|
)
|
|
watch(isMobile, () => {
|
|
ensureChart()
|
|
render()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div ref="containerRef" class="chart"></div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.chart {
|
|
width: 100%;
|
|
height: 560px;
|
|
}
|
|
@media (max-width: 768px) {
|
|
.chart {
|
|
height: 420px;
|
|
}
|
|
}
|
|
</style>
|