You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
511 lines
12 KiB
511 lines
12 KiB
<template>
|
|
<BaseCard title="交易所牦牛成交数据">
|
|
<template #headerExtra>
|
|
<DateIndicatorTabs
|
|
v-model="selectedPeriod"
|
|
:items="timePeriods"
|
|
/>
|
|
</template>
|
|
|
|
<div class="chart-wrapper">
|
|
<div ref="chartRef" class="chart"></div>
|
|
<img
|
|
ref="baseRef"
|
|
class="chart-base"
|
|
src="/images/底座.png"
|
|
alt=""
|
|
/>
|
|
</div>
|
|
</BaseCard>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
|
import * as echarts from 'echarts'
|
|
import BaseCard from './BaseCard.vue'
|
|
import DateIndicatorTabs from './DateIndicatorTabs.vue'
|
|
import { bindChartBaseSync, syncChartBaseToGrid } from '../utils/chartBaseLayout.js'
|
|
|
|
const chartRef = ref(null)
|
|
const baseRef = ref(null)
|
|
let chartInstance = null
|
|
let growAnimationFrame = null
|
|
let unbindChartBaseSync = null
|
|
|
|
const CHART_GRID_BOTTOM = 30
|
|
const CHART_GRID_BOTTOM_WEEK = 44
|
|
|
|
const easeOutCubic = (t) => 1 - (1 - t) ** 3
|
|
|
|
const selectedPeriod = ref('day')
|
|
const timePeriods = [
|
|
{ key: 'day', label: '日' },
|
|
{ key: 'week', label: '周' },
|
|
{ key: 'month', label: '月' }
|
|
]
|
|
|
|
const chartData = ref({
|
|
day: { labels: [], yakTradingVolume: [], orderCount: [] },
|
|
week: { labels: [], yakTradingVolume: [], orderCount: [] },
|
|
month: { labels: [], yakTradingVolume: [], orderCount: [] }
|
|
})
|
|
|
|
const API_URL = '/api/dashboard/yak-trading-data'
|
|
|
|
const BAR_WIDTH = 20
|
|
const BAR_DEPTH_X = 7
|
|
const BAR_DEPTH_Y = 5
|
|
const MIN_BAR_PX = 6
|
|
|
|
const calcAxisMax = (values, ratio = 1.3) => {
|
|
const maxVal = Math.max(0, ...values.map((v) => Number(v) || 0))
|
|
if (maxVal === 0) {
|
|
return 10
|
|
}
|
|
|
|
const raw = maxVal * ratio
|
|
const magnitude = Math.pow(10, Math.floor(Math.log10(raw)))
|
|
const normalized = raw / magnitude
|
|
let nice = 10
|
|
|
|
if (normalized <= 1) nice = 1
|
|
else if (normalized <= 2) nice = 2
|
|
else if (normalized <= 5) nice = 5
|
|
|
|
return nice * magnitude
|
|
}
|
|
|
|
// 0 值在轴上映射为极小高度,保证仍可见一小段柱体
|
|
const getZeroDisplayValue = (axisMax) => axisMax * 0.035
|
|
|
|
const create3DBarRenderer = (axisMax) => (params, api) => {
|
|
const categoryIndex = params.dataIndex
|
|
const rawValue = Number(api.value(1)) || 0
|
|
const displayValue = rawValue > 0 ? rawValue : getZeroDisplayValue(axisMax)
|
|
const isZero = rawValue === 0
|
|
|
|
const base = api.coord([categoryIndex, 0])
|
|
const top = api.coord([categoryIndex, displayValue])
|
|
|
|
const barHeight = Math.max(base[1] - top[1], MIN_BAR_PX)
|
|
const x = base[0] - BAR_WIDTH / 2
|
|
const y = base[1] - barHeight
|
|
|
|
const rightFace = {
|
|
type: 'polygon',
|
|
shape: {
|
|
points: [
|
|
[x + BAR_WIDTH, y],
|
|
[x + BAR_WIDTH + BAR_DEPTH_X, y - BAR_DEPTH_Y],
|
|
[x + BAR_WIDTH + BAR_DEPTH_X, base[1] - BAR_DEPTH_Y],
|
|
[x + BAR_WIDTH, base[1]]
|
|
]
|
|
},
|
|
style: {
|
|
fill: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
|
{ offset: 0, color: isZero ? '#1f6f9f' : '#1a7ab8' },
|
|
{ offset: 1, color: isZero ? '#0a3048' : '#043558' }
|
|
])
|
|
},
|
|
silent: false
|
|
}
|
|
|
|
const frontFace = {
|
|
type: 'rect',
|
|
shape: {
|
|
x,
|
|
y,
|
|
width: BAR_WIDTH,
|
|
height: barHeight
|
|
},
|
|
style: {
|
|
fill: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
|
{ offset: 0, color: isZero ? '#6ecdf0' : '#8ef0ff' },
|
|
{ offset: 0.45, color: isZero ? '#3aa8dc' : '#2dbdff' },
|
|
{ offset: 1, color: isZero ? '#0d4f7a' : '#0a5a96' }
|
|
])
|
|
},
|
|
silent: false
|
|
}
|
|
|
|
const topFace = {
|
|
type: 'polygon',
|
|
shape: {
|
|
points: [
|
|
[x, y],
|
|
[x + BAR_DEPTH_X, y - BAR_DEPTH_Y],
|
|
[x + BAR_WIDTH + BAR_DEPTH_X, y - BAR_DEPTH_Y],
|
|
[x + BAR_WIDTH, y]
|
|
]
|
|
},
|
|
style: {
|
|
fill: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
|
|
{ offset: 0, color: isZero ? '#9de6ff' : '#c8f6ff' },
|
|
{ offset: 1, color: isZero ? '#6ecdf0' : '#7ee8ff' }
|
|
])
|
|
},
|
|
silent: false
|
|
}
|
|
|
|
return {
|
|
type: 'group',
|
|
children: [rightFace, frontFace, topFace]
|
|
}
|
|
}
|
|
|
|
const loadData = async () => {
|
|
try {
|
|
const response = await fetch(API_URL)
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`)
|
|
}
|
|
|
|
const result = await response.json()
|
|
if (result.code !== 1 || !result.data) {
|
|
throw new Error(result.message || '接口返回异常')
|
|
}
|
|
|
|
chartData.value = {
|
|
day: result.data.day || { labels: [], yakTradingVolume: [], orderCount: [] },
|
|
week: result.data.week || { labels: [], yakTradingVolume: [], orderCount: [] },
|
|
month: result.data.month || { labels: [], yakTradingVolume: [], orderCount: [] }
|
|
}
|
|
updateChart(true)
|
|
} catch (error) {
|
|
console.error('加载牦牛成交数据失败:', error)
|
|
}
|
|
}
|
|
|
|
const initChart = () => {
|
|
if (!chartRef.value) return
|
|
|
|
chartInstance = echarts.init(chartRef.value)
|
|
unbindChartBaseSync?.()
|
|
unbindChartBaseSync = bindChartBaseSync(chartInstance, baseRef.value)
|
|
}
|
|
|
|
const buildChartOption = ({
|
|
timeLabels,
|
|
tradingSeries,
|
|
orderSeries,
|
|
yakAxisMax,
|
|
orderAxisMax,
|
|
tooltipTrading,
|
|
tooltipOrders
|
|
}) => ({
|
|
animation: false,
|
|
tooltip: {
|
|
trigger: 'axis',
|
|
backgroundColor: 'rgba(8, 32, 68, 0.92)',
|
|
borderColor: '#2dbdff',
|
|
borderWidth: 1,
|
|
padding: [10, 14],
|
|
textStyle: {
|
|
color: '#ffffff',
|
|
fontSize: 13,
|
|
fontFamily: 'Microsoft YaHei, sans-serif'
|
|
},
|
|
extraCssText: 'box-shadow: 0 0 16px rgba(45, 189, 255, 0.35); border-radius: 4px;',
|
|
formatter(params) {
|
|
const idx = params[0]?.dataIndex ?? 0
|
|
const yakVal = tooltipTrading[idx] ?? 0
|
|
const orderVal = tooltipOrders[idx] ?? 0
|
|
let result = `<div style="font-weight:700;margin-bottom:6px;">${params[0].axisValue}</div>`
|
|
result += `<div style="margin-top:4px;"><span style="display:inline-block;width:8px;height:8px;border-radius:2px;background:#4dd9ff;margin-right:6px;"></span>牦牛交易数量:${yakVal}头</div>`
|
|
result += `<div style="margin-top:4px;"><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#ffd56a;margin-right:6px;"></span>成交订单数量:${orderVal}单</div>`
|
|
return result
|
|
}
|
|
},
|
|
grid: {
|
|
left: 52,
|
|
right: 52,
|
|
bottom: selectedPeriod.value === 'week' ? CHART_GRID_BOTTOM_WEEK : CHART_GRID_BOTTOM,
|
|
top: 42,
|
|
containLabel: false
|
|
},
|
|
legend: {
|
|
data: [
|
|
{ name: '牦牛交易数量', icon: 'rect' },
|
|
{ name: '成交订单数量', icon: 'circle' }
|
|
],
|
|
top: 4,
|
|
left: 'center',
|
|
itemWidth: 12,
|
|
itemHeight: 12,
|
|
itemGap: 24,
|
|
textStyle: {
|
|
color: '#d8ecff',
|
|
fontSize: 13,
|
|
fontFamily: 'Microsoft YaHei, sans-serif'
|
|
}
|
|
},
|
|
xAxis: {
|
|
type: 'category',
|
|
data: timeLabels,
|
|
boundaryGap: true,
|
|
axisLine: {
|
|
show: false
|
|
},
|
|
axisTick: {
|
|
show: false
|
|
},
|
|
axisLabel: {
|
|
color: '#8ecfff',
|
|
fontSize: 12,
|
|
fontFamily: 'Microsoft YaHei, sans-serif',
|
|
margin: 8,
|
|
rotate: selectedPeriod.value === 'week' ? 35 : 0
|
|
}
|
|
},
|
|
yAxis: [
|
|
{
|
|
type: 'value',
|
|
name: '交易数量(头)',
|
|
position: 'left',
|
|
min: 0,
|
|
max: yakAxisMax,
|
|
splitNumber: 5,
|
|
alignTicks: false,
|
|
nameGap: 12,
|
|
splitLine: {
|
|
lineStyle: {
|
|
color: 'rgba(80, 160, 220, 0.22)',
|
|
type: 'dashed'
|
|
}
|
|
},
|
|
axisLine: {
|
|
show: false
|
|
},
|
|
axisTick: {
|
|
show: false
|
|
},
|
|
axisLabel: {
|
|
color: '#8ecfff',
|
|
fontSize: 12,
|
|
fontFamily: 'Microsoft YaHei, sans-serif'
|
|
},
|
|
nameTextStyle: {
|
|
color: '#8ecfff',
|
|
fontSize: 12,
|
|
fontFamily: 'Microsoft YaHei, sans-serif',
|
|
padding: [0, 0, 0, 0]
|
|
}
|
|
},
|
|
{
|
|
type: 'value',
|
|
name: '订单数量(单)',
|
|
position: 'right',
|
|
min: 0,
|
|
max: orderAxisMax,
|
|
splitNumber: 5,
|
|
alignTicks: false,
|
|
nameGap: 12,
|
|
splitLine: {
|
|
show: false
|
|
},
|
|
axisLine: {
|
|
show: false
|
|
},
|
|
axisTick: {
|
|
show: false
|
|
},
|
|
axisLabel: {
|
|
color: '#ffffff',
|
|
fontSize: 12,
|
|
fontFamily: 'Microsoft YaHei, sans-serif'
|
|
},
|
|
nameTextStyle: {
|
|
color: '#ffffff',
|
|
fontSize: 12,
|
|
fontFamily: 'Microsoft YaHei, sans-serif'
|
|
}
|
|
}
|
|
],
|
|
series: [
|
|
{
|
|
name: '牦牛交易数量',
|
|
type: 'custom',
|
|
yAxisIndex: 0,
|
|
renderItem: create3DBarRenderer(yakAxisMax),
|
|
data: tradingSeries.map((value, index) => [index, value]),
|
|
encode: {
|
|
x: 0,
|
|
y: 1
|
|
},
|
|
z: 2
|
|
},
|
|
{
|
|
name: '成交订单数量',
|
|
type: 'line',
|
|
yAxisIndex: 1,
|
|
data: orderSeries,
|
|
smooth: true,
|
|
symbol: 'circle',
|
|
symbolSize: 7,
|
|
lineStyle: {
|
|
color: '#f5b942',
|
|
width: 3,
|
|
shadowColor: 'rgba(245, 185, 66, 0.55)',
|
|
shadowBlur: 10
|
|
},
|
|
itemStyle: {
|
|
color: '#ffd56a',
|
|
borderColor: '#ffffff',
|
|
borderWidth: 2
|
|
},
|
|
z: 3
|
|
}
|
|
]
|
|
})
|
|
|
|
const cancelGrowAnimation = () => {
|
|
if (growAnimationFrame !== null) {
|
|
cancelAnimationFrame(growAnimationFrame)
|
|
growAnimationFrame = null
|
|
}
|
|
}
|
|
|
|
const updateChart = (animateFromZero = false) => {
|
|
if (!chartInstance) return
|
|
|
|
cancelGrowAnimation()
|
|
|
|
const periodData = chartData.value[selectedPeriod.value] || {
|
|
labels: [],
|
|
yakTradingVolume: [],
|
|
orderCount: []
|
|
}
|
|
const timeLabels = periodData.labels
|
|
const tradingData = periodData.yakTradingVolume
|
|
const orderData = periodData.orderCount
|
|
|
|
if (!timeLabels.length) {
|
|
return
|
|
}
|
|
|
|
const yakAxisMax = calcAxisMax(tradingData, 1.25)
|
|
const orderAxisMax = calcAxisMax(orderData, 2.4)
|
|
const zeroTrading = tradingData.map(() => 0)
|
|
const zeroOrders = orderData.map(() => 0)
|
|
|
|
const baseConfig = {
|
|
timeLabels,
|
|
yakAxisMax,
|
|
orderAxisMax,
|
|
tooltipTrading: tradingData,
|
|
tooltipOrders: orderData
|
|
}
|
|
|
|
const render = (tradingSeries, orderSeries, replaceAll = true) => {
|
|
chartInstance.setOption(
|
|
buildChartOption({
|
|
...baseConfig,
|
|
tradingSeries,
|
|
orderSeries
|
|
}),
|
|
replaceAll
|
|
)
|
|
}
|
|
|
|
const renderSeriesFrame = (tradingSeries, orderSeries) => {
|
|
chartInstance.setOption({
|
|
series: [
|
|
{
|
|
name: '牦牛交易数量',
|
|
data: tradingSeries.map((value, index) => [index, value])
|
|
},
|
|
{
|
|
name: '成交订单数量',
|
|
data: orderSeries
|
|
}
|
|
]
|
|
}, false)
|
|
}
|
|
|
|
const playGrowAnimation = () => {
|
|
render(zeroTrading, zeroOrders, true)
|
|
|
|
const startTime = performance.now()
|
|
const tick = (now) => {
|
|
const progress = Math.min((now - startTime) / GROW_ANIMATION_MS, 1)
|
|
const ratio = easeOutCubic(progress)
|
|
const currentTrading = tradingData.map((v) => (Number(v) || 0) * ratio)
|
|
const currentOrders = orderData.map((v) => (Number(v) || 0) * ratio)
|
|
|
|
renderSeriesFrame(currentTrading, currentOrders)
|
|
|
|
if (progress < 1) {
|
|
growAnimationFrame = requestAnimationFrame(tick)
|
|
return
|
|
}
|
|
|
|
growAnimationFrame = null
|
|
renderSeriesFrame(tradingData, orderData)
|
|
}
|
|
|
|
growAnimationFrame = requestAnimationFrame(tick)
|
|
}
|
|
|
|
if (!animateFromZero) {
|
|
render(tradingData, orderData, true)
|
|
return
|
|
}
|
|
|
|
playGrowAnimation()
|
|
}
|
|
|
|
watch(selectedPeriod, () => {
|
|
updateChart(true)
|
|
})
|
|
|
|
const handleResize = () => {
|
|
chartInstance?.resize()
|
|
syncChartBaseToGrid(chartInstance, baseRef.value)
|
|
}
|
|
|
|
onMounted(async () => {
|
|
initChart()
|
|
await loadData()
|
|
window.addEventListener('resize', handleResize)
|
|
window.addEventListener('dataReloaded', loadData)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
cancelGrowAnimation()
|
|
unbindChartBaseSync?.()
|
|
unbindChartBaseSync = null
|
|
window.removeEventListener('resize', handleResize)
|
|
window.removeEventListener('dataReloaded', loadData)
|
|
if (chartInstance) {
|
|
chartInstance.dispose()
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.chart-wrapper {
|
|
flex: 1;
|
|
min-height: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding: 0 4px;
|
|
overflow: visible;
|
|
}
|
|
|
|
.chart {
|
|
width: 100%;
|
|
flex: 1;
|
|
min-height: 0;
|
|
}
|
|
|
|
.chart-base {
|
|
display: block;
|
|
width: 100%;
|
|
height: 90px;
|
|
object-fit: fill;
|
|
flex-shrink: 0;
|
|
pointer-events: none;
|
|
user-select: none;
|
|
margin-top: -26px;
|
|
}
|
|
</style>
|
|
|