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.
 
 
livestock-trading/src/components/YakTradingData.vue

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>