@ -1,23 +1,23 @@
<!-- SidePanelItem . vue -- >
< template >
< SidePanelItem :title ="title" :datetimePicker ="datetimePicker" >
< div
class = "container"
@ mouseenter = "handleMouseEnter"
@ mouseleave = "handleMouseLeave"
>
< SidePanelItem :title ="title" :datetimePicker ="datetimePicker" @update-values ="handlePanelChange1" >
<!-- 加载状态 -- >
< div v-if ="loading" class="loading-container" >
< a -spin tip = "加载中..." / >
< / div >
<!-- 无数据状态 -- >
< div v -else -if = " ! hasData " class = "empty-container" >
< a -empty description = "暂无数据" / >
< / div >
<!-- 正常显示图表 -- >
< div v -else class = "container" @mouseenter ="handleMouseEnter" @mouseleave ="handleMouseLeave" >
<!-- 跑马灯轨道容器 -- >
< div
class = "carousel-track"
: class = "{ 'no-transition': isTransitioning }"
: style = "{ transform: `translateX(-${currentIndex * 100}%)` }"
>
< div class = "carousel-track" : class = "{ 'no-transition': isTransitioning }"
: style = "{ transform: `translateX(-${currentIndex * 100}%)` }" >
<!-- 遍历所有图表项 ( 包含克隆项 ) -- >
< div
v - for = "(item, index) in renderChartData"
: key = "index"
class = "carousel-item"
>
< div v-for ="(item, index) in renderChartData" :key="index" class="carousel-item" >
< div class = "pie-chart-container" >
<!-- 图表区域 -- >
< div : ref = "el => setChartRef(el, index)" class = "chart" > < / div >
@ -31,21 +31,44 @@
< / template >
< script lang = "ts" setup >
import { ref , onMounted , onUnmounted , nextTick } from 'vue' ;
import { ref , onMounted , onUnmounted , nextTick , watch } from 'vue' ;
import * as echarts from 'echarts' ;
import type { ECharts } from 'echarts' ;
import SidePanelItem from '@/components/SidePanelItem/index.vue' ;
import { yearGetYearFpStatistics } from '@/api/gyss' ;
import { useJidiSelectEventStore } from "@/store/modules/jidiSelectEvent" ;
/ / A P I 数 据 类 型 定 义
interface FpFtpStatisticsVo {
baseId : string ;
baseName : string ;
ftp : string ;
fishName : string ;
fcnt : number ;
}
interface BasinData {
fpCount : number ;
baseId : string ;
baseName : string ;
fpFtpStatitcsVos : FpFtpStatisticsVo [ ] ;
}
/ / 定 义 组 件 名 ( 便 于 调 试 和 递 归 )
defineOptions ( {
name : 'GYZLLB'
} ) ;
const JidiSelectEventStore = useJidiSelectEventStore ( ) ;
const baseid = ref ( '' ) ;
const props = defineProps ( {
title : { / / 标 题
type : String ,
default : '过鱼总量'
} ,
} ) ;
const datetimePicker = ref ( {
show : true ,
value : ` ${ new Date ( ) . getFullYear ( ) - 1 } ` , / / 上 一 年
@ -54,6 +77,14 @@ const datetimePicker = ref({
options : [ ]
} ) ;
/ / 状 态 管 理
const loading = ref < boolean > ( false ) ;
const hasData = ref < boolean > ( true ) ;
/ / 数 据 缓 存
const cachedApiData = ref < BasinData [ ] > ( [ ] ) ; / / 缓 存 A P I 返 回 的 全 量 数 据
const currentYear = ref < string > ( '' ) ; / / 当 前 选 中 的 年 份
/ / 图 表 相 关
interface ChartDataItem {
title : string ;
@ -61,45 +92,7 @@ interface ChartDataItem {
}
/ / 原 始 图 表 数 据 ( 多 组 数 据 用 于 跑 马 灯 )
const originalChartData = ref < ChartDataItem [ ] > (
[
{
title : '过鱼总量统计 - 2025年' ,
data : [
{ name : '草鱼' , value : 1250 } ,
{ name : '鲢鱼' , value : 980 } ,
{ name : '鳙鱼' , value : 760 } ,
{ name : '青鱼' , value : 540 } ,
{ name : '鲤鱼' , value : 430 } ,
{ name : '鲫鱼' , value : 320 } ,
{ name : '其他' , value : 280 }
]
} ,
{
title : '过鱼总量统计 - 2024年' ,
data : [
{ name : '草鱼1' , value : 1250 } ,
{ name : '鲢鱼1' , value : 980 } ,
{ name : '鳙鱼1' , value : 760 } ,
{ name : '青鱼1' , value : 540 } ,
{ name : '鲤鱼1' , value : 430 } ,
{ name : '鲫鱼1' , value : 320 } ,
{ name : '其他1' , value : 280 }
]
} ,
{
title : '过鱼总量统计 - 2023年' ,
data : [
{ name : '草鱼2' , value : 1250 } ,
{ name : '鲢鱼2' , value : 980 } ,
{ name : '鳙鱼2' , value : 760 } ,
{ name : '青鱼2' , value : 540 } ,
{ name : '鲤鱼2' , value : 430 } ,
{ name : '鲫鱼2' , value : 320 } ,
{ name : '其他2' , value : 280 }
]
}
] ) ;
const originalChartData = ref < ChartDataItem [ ] > ( [ ] ) ;
/ / 克 隆 首 尾 项 后 的 渲 染 数 组 ( 用 于 无 缝 循 环 )
const renderChartData = ref < ChartDataItem [ ] > ( [ ] ) ;
@ -110,6 +103,16 @@ const currentIndex = ref(1); // 从1开始, 跳过克隆的首项
/ / 定 时 器 引 用
let timer : any = null ;
/ * *
* 停止自动轮播
* /
const stopAutoPlay = ( ) => {
if ( timer ) {
clearInterval ( timer ) ;
timer = null ;
}
} ;
/ / 鼠 标 悬 停 状 态
const isHovering = ref ( false ) ;
@ -140,7 +143,7 @@ const getUnitConfigByCode = (code: string, type: string): { unit: string } => {
/ / 生 成 随 机 颜 色 ( 根 据 名 称 ) - 生 成 适 中 亮 度 颜 色
const generateRandomColor = ( names : string [ ] ) : string [ ] => {
const colors : string [ ] = [ ] ;
names . forEach ( ( ) => {
/ / 生 成 H S L 颜 色 , 控 制 饱 和 度 和 亮 度 在 适 中 范 围
/ / H : 0 - 3 6 0 ( 色 相 )
@ -149,42 +152,42 @@ const generateRandomColor = (names: string[]): string[] => {
const hue = Math . floor ( Math . random ( ) * 360 ) ;
const saturation = 40 + Math . floor ( Math . random ( ) * 30 ) ; / / 4 0 - 7 0 %
const lightness = 45 + Math . floor ( Math . random ( ) * 20 ) ; / / 4 5 - 6 5 %
/ / 将 H S L 转 换 为 H E X
const h = hue / 360 ;
const s = saturation / 100 ;
const l = lightness / 100 ;
let r , g , b ;
if ( s === 0 ) {
r = g = b = l ;
} else {
const hue2rgb = ( p : number , q : number , t : number ) => {
if ( t < 0 ) t += 1 ;
if ( t > 1 ) t -= 1 ;
if ( t < 1 / 6 ) return p + ( q - p ) * 6 * t ;
if ( t < 1 / 2 ) return q ;
if ( t < 2 / 3 ) return p + ( q - p ) * ( 2 / 3 - t ) * 6 ;
if ( t < 1 / 6 ) return p + ( q - p ) * 6 * t ;
if ( t < 1 / 2 ) return q ;
if ( t < 2 / 3 ) return p + ( q - p ) * ( 2 / 3 - t ) * 6 ;
return p ;
} ;
const q = l < 0.5 ? l * ( 1 + s ) : l + s - l * s ;
const p = 2 * l - q ;
r = hue2rgb ( p , q , h + 1 / 3 ) ;
r = hue2rgb ( p , q , h + 1 / 3 ) ;
g = hue2rgb ( p , q , h ) ;
b = hue2rgb ( p , q , h - 1 / 3 ) ;
b = hue2rgb ( p , q , h - 1 / 3 ) ;
}
const toHex = ( x : number ) => {
const hex = Math . round ( x * 255 ) . toString ( 16 ) ;
return hex . length === 1 ? '0' + hex : hex ;
} ;
colors . push ( ` # ${ toHex ( r ) } ${ toHex ( g ) } ${ toHex ( b ) } ` ) ;
} ) ;
return colors ;
} ;
@ -220,7 +223,7 @@ const initChart = (index: number) => {
const updateChart = ( index : number ) => {
const chartInstance = chartInstances . value [ index ] ;
const chartData = renderChartData . value [ index ] ;
if ( ! chartInstance || ! chartData ) return ;
const colorNames = chartData . data . map ( item => item . name ) ;
@ -243,7 +246,7 @@ const updateChart = (index: number) => {
{
text : ` ${ totalAmount } ` ,
subtext : ` 总量( ${ unit } ) ` ,
left : ' 34 %',
left : ' 29 %',
top : '45%' ,
textAlign : 'center' ,
textVerticalAlign : 'middle' ,
@ -272,7 +275,7 @@ const updateChart = (index: number) => {
pageIconInactiveColor : '#ccc' ,
pageIconSize : 12 ,
pageFormatter : '{current}/{total}' ,
formatter : function ( name : string ) {
formatter : function ( name : string ) {
const found = chartData . data . find ( item => item . name === name ) ;
const value = found ? found . value : '-' ;
const maxLength = 7 ;
@ -280,7 +283,7 @@ const updateChart = (index: number) => {
return ` ${ truncatedName } ${ value } ${ unit } ` ;
} ,
textStyle : {
fontSize : 1 3 ,
fontSize : 1 2 ,
color : '#666'
}
} ,
@ -288,7 +291,7 @@ const updateChart = (index: number) => {
{
type : 'pie' ,
radius : [ '50%' , '70%' ] ,
center : [ '3 5 %', '50%' ] ,
center : [ '3 0 %', '50%' ] ,
avoidLabelOverlap : false ,
itemStyle : {
borderRadius : 4 ,
@ -337,13 +340,13 @@ const handleResize = () => {
const initRenderData = ( ) => {
const length = originalChartData . value . length ;
if ( length === 0 ) return ;
renderChartData . value = [
originalChartData . value [ length - 1 ] , / / 克 隆 最 后 一 项
... originalChartData . value , / / 原 始 数 据
originalChartData . value [ 0 ] / / 克 隆 第 一 项
] ;
/ / 初 始 化 图 表 实 例 数 组
chartInstances . value = new Array ( renderChartData . value . length ) . fill ( null ) ;
chartRefs . value = new Array ( renderChartData . value . length ) . fill ( null ) ;
@ -362,7 +365,7 @@ const startAutoPlay = () => {
/ / 切 换 到 下 一 张
const nextSlide = ( ) => {
currentIndex . value ++ ;
/ / 延 迟 检 查 是 否 需 要 无 缝 跳 转
setTimeout ( ( ) => {
checkSeamlessJump ( ) ;
@ -372,15 +375,15 @@ const nextSlide = () => {
/ / 检 查 是 否 需 要 无 缝 跳 转 ( 克 隆 项 处 理 )
const checkSeamlessJump = ( ) => {
const realLength = originalChartData . value . length ;
/ / 如 果 到 达 克 隆 的 最 后 一 项 ( 索 引 = r e a l L e n g t h + 1 )
if ( currentIndex . value >= realLength + 1 ) {
/ / 1 . 禁 用 过 渡 动 画 , 实 现 瞬 间 跳 转
isTransitioning . value = true ;
/ / 2 . 瞬 间 跳 转 到 真 实 的 第 二 项 ( 索 引 1 ) , 用 户 看 不 到 跳 变
currentIndex . value = 1 ;
/ / 3 . 等 待 两 帧 后 恢 复 过 渡 动 画 ( 确 保 D O M 已 更 新 )
requestAnimationFrame ( ( ) => {
requestAnimationFrame ( ( ) => {
@ -403,7 +406,7 @@ const handleMouseLeave = () => {
/ / 页 面 加 载 时 执 行
onMounted ( async ( ) => {
initRenderData ( ) ;
/ / 等 待 D O M 渲 染 完 成
await nextTick ( ) ;
setTimeout ( ( ) => {
@ -411,10 +414,10 @@ onMounted(async () => {
renderChartData . value . forEach ( ( _ , index ) => {
initChart ( index ) ;
} ) ;
/ / 监 听 窗 口 大 小 变 化
window . addEventListener ( 'resize' , handleResize ) ;
/ / 启 动 自 动 轮 播
startAutoPlay ( ) ;
} , 100 ) ;
@ -423,16 +426,241 @@ onMounted(async () => {
/ / 组 件 卸 载 时 清 理
onUnmounted ( ( ) => {
if ( timer ) clearInterval ( timer ) ;
/ / 销 毁 所 有 图 表 实 例
chartInstances . value . forEach ( instance => {
if ( instance ) {
instance . dispose ( ) ;
}
} ) ;
window . removeEventListener ( 'resize' , handleResize ) ;
} ) ;
/ * *
* 构建单一流域的图表数据
* /
const buildSingleBasinChartData = ( basin : BasinData , year : string ) : ChartDataItem => {
return {
title : ` ${ basin . baseName } ` ,
data : basin . fpFtpStatitcsVos . map ( ( fish : FpFtpStatisticsVo ) => ( {
name : fish . fishName ,
value : fish . fcnt
} ) )
} ;
} ;
/ * *
* 构建全部流域的图表数据 ( 包含流域对比图和各流域鱼类分布图 )
* /
const buildAllBasinsChartData = ( apiData : BasinData [ ] , year : string ) : ChartDataItem [ ] => {
const charts : ChartDataItem [ ] = [ ] ;
/ / 1 ️ ⃣ 第 一 张 : 各 流 域 总 量 对 比 图 ( 使 用 f p C o u n t )
const basinComparisonData = apiData
. filter ( basin => basin . fpFtpStatitcsVos && basin . fpFtpStatitcsVos . length > 0 )
. map ( basin => ( {
name : basin . baseName ,
value : basin . fpCount
} ) ) ;
if ( basinComparisonData . length > 0 ) {
charts . push ( {
title : ` 总量 ` ,
data : basinComparisonData
} ) ;
}
/ / 2 ️ ⃣ 后 续 : 每 个 流 域 的 鱼 类 分 布 图 ( 使 用 f c n t )
apiData . forEach ( basin => {
if ( basin . fpFtpStatitcsVos && basin . fpFtpStatitcsVos . length > 0 ) {
charts . push ( buildSingleBasinChartData ( basin , year ) ) ;
}
} ) ;
return charts ;
} ;
/ * *
* 重新构建图表实例 ( 用于完全重建 )
* /
const rebuildCharts = async ( ) => {
/ / 销 毁 旧 图 表 实 例
chartInstances . value . forEach ( instance => {
if ( instance ) {
instance . dispose ( ) ;
}
} ) ;
chartInstances . value = [ ] ;
/ / 重 新 初 始 化 渲 染 数 据
initRenderData ( ) ;
/ / 等 待 D O M 更 新
await nextTick ( ) ;
/ / 重 新 初 始 化 所 有 图 表
setTimeout ( ( ) => {
renderChartData . value . forEach ( ( _ , index ) => {
initChart ( index ) ;
} ) ;
} , 100 ) ;
} ;
/ * *
* 控制跑马灯的启停
* /
const controlCarousel = ( ) => {
/ / 判 断 是 否 需 要 停 止 跑 马 灯
const shouldStop = baseid . value !== 'all' || originalChartData . value . length <= 1 ;
if ( shouldStop ) {
stopAutoPlay ( ) ; / / 停 止 轮 播 , 但 不 重 置 c u r r e n t I n d e x
} else {
startAutoPlay ( ) ; / / 启 动 轮 播
}
} ;
/ * *
* 根据当前 baseid 更新图表显示 ( 不请求 API )
* /
const updateChartByBaseid = ( ) => {
if ( ! cachedApiData . value . length ) {
hasData . value = false ;
originalChartData . value = [ ] ;
renderChartData . value = [ ] ; / / 清 空 渲 染 数 据
stopAutoPlay ( ) ; / / 停 止 轮 播
return ;
}
if ( baseid . value === 'all' ) {
/ / = = = 全 部 流 域 模 式 = = =
originalChartData . value = buildAllBasinsChartData ( cachedApiData . value , currentYear . value ) ;
} else {
/ / = = = 单 一 流 域 模 式 = = =
const targetBasin = cachedApiData . value . find ( item => item . baseId === baseid . value ) ;
if ( ! targetBasin || ! targetBasin . fpFtpStatitcsVos || targetBasin . fpFtpStatitcsVos . length === 0 ) {
hasData . value = false ;
originalChartData . value = [ ] ;
renderChartData . value = [ ] ; / / 清 空 渲 染 数 据
stopAutoPlay ( ) ; / / 停 止 轮 播
return ;
}
originalChartData . value = [ buildSingleBasinChartData ( targetBasin , currentYear . value ) ] ;
}
/ / 先 构 建 渲 染 数 据
initRenderData ( ) ;
/ / 再 设 置 为 有 数 据 状 态
hasData . value = true ;
/ / 等 待 D O M 更 新 后 初 始 化 图 表
nextTick ( ( ) => {
/ / 销 毁 旧 图 表 实 例
chartInstances . value . forEach ( instance => {
if ( instance ) {
instance . dispose ( ) ;
}
} ) ;
chartInstances . value = new Array ( renderChartData . value . length ) . fill ( null ) ;
chartRefs . value = new Array ( renderChartData . value . length ) . fill ( null ) ;
setTimeout ( ( ) => {
renderChartData . value . forEach ( ( _ , index ) => {
initChart ( index ) ;
} ) ;
} , 100 ) ;
} ) ;
controlCarousel ( ) ; / / 控 制 跑 马 灯 启 停
} ;
/ * *
* 获取并处理图表数据
* /
const getechartsdata = async ( yearOne : string ) => {
if ( ! baseid . value ) {
hasData . value = false ;
originalChartData . value = [ ] ;
renderChartData . value = [ ] ;
stopAutoPlay ( ) ;
return ;
}
/ / 如 果 年 份 没 变 , 且 有 缓 存 数 据 , 直 接 使 用 缓 存
if ( yearOne === currentYear . value && cachedApiData . value . length > 0 ) {
updateChartByBaseid ( ) ;
return ;
}
loading . value = true ;
try {
const params = { year : yearOne } ;
const res = await yearGetYearFpStatistics ( params ) ;
/ / a x i o s 返 回 的 结 构 : r e s . d a t a 才 是 真 正 的 业 务 数 据
const responseData = res . data ;
if ( ! responseData || responseData . length === 0 ) {
console . warn ( '无有效数据' ) ;
hasData . value = false ;
originalChartData . value = [ ] ;
renderChartData . value = [ ] ;
cachedApiData . value = [ ] ;
currentYear . value = '' ;
stopAutoPlay ( ) ;
return ;
}
/ / d e b u g g e r
/ / 缓 存 全 量 数 据
cachedApiData . value = responseData ;
currentYear . value = yearOne ;
/ / 根 据 b a s e i d 更 新 显 示
updateChartByBaseid ( ) ;
} catch ( error ) {
console . error ( '获取过鱼统计数据失败:' , error ) ;
hasData . value = false ;
originalChartData . value = [ ] ;
renderChartData . value = [ ] ;
cachedApiData . value = [ ] ;
currentYear . value = '' ;
stopAutoPlay ( ) ;
} finally {
loading . value = false ;
}
} ;
const handlePanelChange1 = ( data : any ) => {
if ( data . datetime ) {
getechartsdata ( data . datetime ) ; / / 年 份 变 化 , 请 求 A P I
} else {
/ / 置 空 图 表 数 据 , 并 显 示 暂 无 数 据
hasData . value = false ;
originalChartData . value = [ ] ;
renderChartData . value = [ ] ; / / 清 空 渲 染 数 据
cachedApiData . value = [ ] ; / / 清 空 缓 存
currentYear . value = '' ;
stopAutoPlay ( ) ; / / 停 止 轮 播
}
} ;
watch (
( ) => JidiSelectEventStore . selectedItem ,
( newVal ) => {
if ( newVal && newVal . wbsCode ) {
baseid . value = newVal . wbsCode ;
/ / 切 换 流 域 时 , 不 请 求 A P I , 只 从 缓 存 中 筛 选 更 新 显 示
if ( cachedApiData . value . length > 0 ) {
updateChartByBaseid ( ) ;
}
}
} ,
{ deep : true , immediate : true }
) ;
< / script >
< style lang = "scss" scoped >
@ -443,26 +671,26 @@ onUnmounted(() => {
border - radius : 5 px ;
position : relative ;
overflow : hidden ; / / 隐 藏 超 出 容 器 的 内 容
/ / 跑 马 灯 轨 道
. carousel - track {
display : flex ; / / 横 向 排 列 所 有 媒 体 项
width : 100 % ;
height : 100 % ;
transition : transform 0.5 s ease - in - out ; / / 0 . 5 秒 平 滑 过 渡
/ / 禁 用 过 渡 动 画 ( 用 于 无 缝 跳 转 )
& . no - transition {
transition : none ;
}
/ / 单 个 图 表 项
. carousel - item {
min - width : 100 % ; / / 每 个 项 目 占 满 容 器 宽 度
height : 100 % ;
position : relative ;
flex - shrink : 0 ; / / 防 止 被 压 缩
. pie - chart - container {
width : 100 % ;
height : 100 % ;
@ -485,4 +713,22 @@ onUnmounted(() => {
}
}
}
/ / 加 载 状 态 样 式
. loading - container {
width : 100 % ;
height : 290 px ;
display : flex ;
align - items : center ;
justify - content : center ;
}
/ / 空 状 态 样 式
. empty - container {
width : 100 % ;
height : 290 px ;
display : flex ;
align - items : center ;
justify - content : center ;
}
< / style >