DSDSWeb/src/views/statistics/UuvUnitStatistics.vue

677 lines
19 KiB
Vue
Raw Normal View History

2026-04-03 02:05:29 +08:00
<template>
<div class="statistics">
<div class="banner">
<div class="banner-select-text banner-static-title">{{ currentTitle }}</div>
<div class="numbers">
<div v-for="(item, index) in numbers" :key="index" class="item">
<div class="value">{{ item.value }}<span class="unit">{{ item.unit }}</span></div>
<div class="label">{{ item.label }}</div>
</div>
</div>
</div>
2026-04-04 17:03:09 +08:00
<!-- 地图板块与科考船汇总统计页 ships 一致Mapbox + shipUnit -->
2026-04-03 02:05:29 +08:00
<div class="map-module">
<div class="bezel">
<div class="map-title">参航单位</div>
2026-04-03 04:09:35 +08:00
<div id="workUnitMap_uuv" ref="workUnitMap_uuv" style="height: 100%;width:100%;" />
2026-04-03 02:05:29 +08:00
</div>
</div>
2026-04-04 16:42:48 +08:00
<div class="unit-toolbar">
<div class="unit-toolbar-inner">
<div class="unit-filter-box">
<span class="banner-unit-label">单位名称</span>
<el-select v-model="selectedUnits" class="banner-unit-select" multiple filterable clearable size="small"
placeholder="请选择单位(不选表示全部)" value-key="unit_name" collapse-tags @change="onBannerUnitsChange">
<el-option v-for="unit in unitOptions" :key="unit.unit_id || unit.unit_name" :label="unit.unit_name"
:value="unit" />
</el-select>
</div>
</div>
</div>
2026-04-03 02:05:29 +08:00
<!-- echarts板块-->
<div class="echarts-module">
<div class="bezel">
2026-04-03 11:10:58 +08:00
<BarChart :title="barVoyageCount.title" :chart-data="barVoyageCount.salesData"
:categories="barVoyageCount.productCategories" chart-type="single" />
2026-04-03 02:05:29 +08:00
</div>
<div class="bezel">
2026-04-03 11:10:58 +08:00
<BarChart :title="barVoyageDays.title" :chart-data="barVoyageDays.salesData"
:categories="barVoyageDays.productCategories" chart-type="single" />
2026-04-03 02:05:29 +08:00
</div>
<div class="bezel">
2026-04-03 11:10:58 +08:00
<BarChart :title="barPeopleDiving.title" :chart-data="barPeopleDiving.salesData"
:categories="barPeopleDiving.productCategories" chart-type="multiple" />
2026-04-03 02:05:29 +08:00
</div>
<div class="bezel">
2026-04-03 11:10:58 +08:00
<BarChart :title="barFirstPeopleDiving.title" :chart-data="barFirstPeopleDiving.salesData"
:categories="barFirstPeopleDiving.productCategories" chart-type="multiple" />
2026-04-03 02:05:29 +08:00
</div>
<div class="bezel">
2026-04-03 11:10:58 +08:00
<BarChart :title="mixedMileage.title" :chart-data="mixedMileage.series" :categories="mixedMileage.categories"
chart-type="mixed" :dual-y-axis="true" left-axis-name="次数" right-axis-name="天数" />
2026-04-03 02:05:29 +08:00
</div>
</div>
</div>
</template>
<script>
2026-04-03 04:09:35 +08:00
import Vue from 'vue'
2026-04-03 02:05:29 +08:00
import BarChart from './components/BarChart.vue'
2026-04-03 04:09:35 +08:00
import { unitTotal, hovUnit, shipUnit, shipVoyage } from '../../api/statistics'
2026-04-03 02:05:29 +08:00
import { getYearRange } from '../../utils/index'
import { initMapbox } from '@/utils/mapbox-utils'
import { loadJsonPointFeature, showUnitReport } from '@/utils/vector-layer-utils'
2026-04-03 02:05:29 +08:00
/**
* @param {*} v 原始值
* @returns {number}
*/
function num(v) {
if (v == null || v === '') return 0
const n = Number(v)
return Number.isFinite(n) ? n : 0
}
export default {
components: {
BarChart
},
data() {
return {
currentTitle: '参航单位统计',
voyageYear: '',
unitOptions: [],
2026-04-03 04:09:35 +08:00
/** 与 Banner 多选绑定:筛选单位汇总图表(空表示全部) */
2026-04-03 02:05:29 +08:00
selectedUnits: [],
/** unitTotal 全量缓存,便于按 Banner 选择做前端过滤 */
unitTotalRawList: [],
numbers: [
{
label: '参航单位数量',
value: '',
unit: '家'
},
{
label: '参航人数',
value: '',
unit: '人'
},
{
label: '深潜次数',
value: '',
unit: '次'
}
],
/** 航行次数(柱状) */
barVoyageCount: { title: '航行次数', salesData: [], productCategories: [] },
/** 参航天数(柱状) */
barVoyageDays: { title: '参航天数', salesData: [], productCategories: [] },
/** 参航人员数、深潜次数(柱状,多系列) */
barPeopleDiving: {
title: '参航人员数、深潜次数',
salesData: [
{ name: '参航人员数', type: 'bar', data: [] },
{ name: '深潜次数', type: 'bar', data: [] }
],
productCategories: []
},
/** 首次参航、首次深潜(柱状,多系列) */
barFirstPeopleDiving: {
title: '首次参航人数、首次深潜人数',
salesData: [
{ name: '首次参航人数', type: 'bar', data: [] },
{ name: '首次深潜人数', type: 'bar', data: [] }
],
productCategories: []
},
/** 航行次数(柱)+ 参航天数(折) */
mixedMileage: {
title: '航行次数与参航天数',
categories: [],
series: [
{ name: '航行次数', type: 'bar', data: [], yAxisIndex: 0 },
{ name: '参航天数', type: 'line', data: [], yAxisIndex: 1 }
]
}
}
},
created() {
this.loadUnitTotalFromApi()
this.getHovUnit()
2026-04-03 04:09:35 +08:00
this.getShipUnit()
2026-04-03 02:05:29 +08:00
},
mounted() {
2026-04-03 04:09:35 +08:00
initMapbox('workUnitMap_uuv', {
tileKey: 'gaode',
zoom: 3,
2026-04-03 12:03:56 +08:00
center: [110, 33],
2026-04-03 04:09:35 +08:00
minZoom: 2,
maxZoom: 29
})
2026-04-03 02:05:29 +08:00
},
beforeDestroy() {
2026-04-03 04:09:35 +08:00
const unitMap = Vue.config.maps && Vue.config.maps['workUnitMap_uuv']
if (unitMap) {
unitMap.remove()
delete Vue.config.maps['workUnitMap_uuv']
2026-04-03 02:05:29 +08:00
}
},
methods: {
/**
2026-04-03 04:09:35 +08:00
* Banner 统计单位多选变更刷新汇总图表
2026-04-03 02:05:29 +08:00
* @returns {void}
*/
onBannerUnitsChange() {
this.applyFilteredUnitTotal()
},
/**
* Banner 选中单位过滤 unitTotal 再写入 Banner 与图表
* @returns {void}
*/
applyFilteredUnitTotal() {
const raw = this.unitTotalRawList || []
if (!raw.length) {
this.applyUnitTotalRows([])
return
}
const sel = this.selectedUnits || []
if (!sel.length) {
this.applyUnitTotalRows(raw)
return
}
const nameSet = new Set(sel.map(u => u && u.unit_name).filter(Boolean))
const filtered = raw.filter(row => nameSet.has(row.unit_name))
this.applyUnitTotalRows(filtered)
// 地图显示最后一个选中单位的相关信息
showUnitReport('workUnitMap_uuv', this.selectedUnits.at(-1).unit_name, [this.selectedUnits.at(-1).unit_lon, this.selectedUnits.at(-1).unit_lat])
2026-04-03 02:05:29 +08:00
},
/**
* 拉取单位汇总报表/report/unit/total.htm驱动 Banner 与各柱状图
* @returns {void}
*/
loadUnitTotalFromApi() {
unitTotal({})
.then(res => {
const list = res && Array.isArray(res.array) ? res.array : []
this.unitTotalRawList = list
this.applyFilteredUnitTotal()
})
.catch(() => {
this.unitTotalRawList = []
this.applyUnitTotalRows([])
})
},
/**
* unitTotal 返回的按单位行写入 Banner 与图表
* @param {Object[]} list 接口 array
* @returns {void}
*/
applyUnitTotalRows(list) {
if (!list.length) {
this.numbers[0].value = ''
this.numbers[1].value = ''
this.numbers[2].value = ''
this.barVoyageCount = { title: '航行次数', salesData: [], productCategories: [] }
this.barVoyageDays = { title: '参航天数', salesData: [], productCategories: [] }
this.barPeopleDiving = {
title: '参航人员数、深潜次数',
salesData: [
{ name: '参航人员数', type: 'bar', data: [] },
{ name: '深潜次数', type: 'bar', data: [] }
],
productCategories: []
}
this.barFirstPeopleDiving = {
title: '首次参航人数、首次深潜人数',
salesData: [
{ name: '首次参航人数', type: 'bar', data: [] },
{ name: '首次深潜人数', type: 'bar', data: [] }
],
productCategories: []
}
this.mixedMileage = {
title: '航行次数与参航天数',
categories: [],
series: [
{ name: '航行次数', type: 'bar', data: [], yAxisIndex: 0 },
{ name: '参航天数', type: 'line', data: [], yAxisIndex: 1 }
]
}
return
}
const sorted = [...list].sort((a, b) => num(b.voyage_total) - num(a.voyage_total))
const names = sorted.map(r => r.unit_name || '—')
const voyage = sorted.map(r => num(r.voyage_total))
const days = sorted.map(r => num(r.voyage_days))
const member = sorted.map(r => num(r.member_total))
const drving = sorted.map(r => num(r.drving_total))
const fVoyage = sorted.map(r => num(r.f_voyage_total))
const fDrving = sorted.map(r => num(r.f_drving_total))
this.numbers[0].value = sorted.length
this.numbers[1].value = sorted.reduce((s, r) => s + num(r.member_total), 0)
this.numbers[2].value = sorted.reduce((s, r) => s + num(r.drving_total), 0)
this.barVoyageCount = {
title: '航行次数',
salesData: voyage,
productCategories: names
}
this.barVoyageDays = {
title: '参航天数',
salesData: days,
productCategories: names
}
this.barPeopleDiving = {
title: '参航人员数、深潜次数',
salesData: [
{ name: '参航人员数', type: 'bar', data: member },
{ name: '深潜次数', type: 'bar', data: drving }
],
productCategories: names
}
this.barFirstPeopleDiving = {
title: '首次参航人数、首次深潜人数',
salesData: [
{ name: '首次参航人数', type: 'bar', data: fVoyage },
{ name: '首次深潜人数', type: 'bar', data: fDrving }
],
productCategories: names
}
this.mixedMileage = {
title: '航行次数与参航天数',
categories: names,
series: [
{ name: '航行次数', type: 'bar', data: voyage, yAxisIndex: 0 },
{ name: '参航天数', type: 'line', data: days, yAxisIndex: 1 }
]
}
},
2026-04-03 04:09:35 +08:00
/**
* 载人潜水器参航单位列表仅用于 Banner 多选筛选图表
* @returns {void}
*/
2026-04-03 02:05:29 +08:00
getHovUnit() {
hovUnit().then(res => {
const map = new Map()
2026-04-03 11:10:58 +08:00
; (res.array || []).forEach(unit => {
const k = unit.unit_id || unit.unitId || unit.unit_name
if (!map.has(k)) map.set(k, unit)
})
2026-04-03 02:05:29 +08:00
this.unitOptions = Array.from(map.values())
})
},
2026-04-03 04:09:35 +08:00
/**
* 科考船侧参航单位点位 ships 页地图同源接口通过 Mapbox GeoJSON 渲染
* @returns {void}
*/
getShipUnit() {
shipUnit().then(res => {
const geojson = {
type: 'FeatureCollection',
features: []
};
(res.array || []).forEach(element => {
geojson.features.push({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [element.unit_lon, element.unit_lat]
},
properties: {
unit_name: element.unit_name,
unit_type: element.unit_type,
create_time: element.create_time,
tsy_id: element.tsy_id
}
2026-04-03 04:09:35 +08:00
})
});
loadJsonPointFeature('workUnit', 'blue', geojson, 'unit_name', 'workUnitMap_uuv');
Vue.config.maps['workUnitMap_uuv'].on('click', 'workUnit', async (e) => {
if (!e.features || e.features.length <= 0)
return;
// 地图点选弹窗显示单位信息
showUnitReport('workUnitMap_uuv', e.features[0].properties.unit_name, e.features[0].geometry.coordinates,this.resetFilter);
// 同步更新单位统计图
this.applyUnitTotalRows((this.unitTotalRawList || []).filter(row => row.unit_name === e.features[0].properties.unit_name));
});
2026-04-03 02:05:29 +08:00
})
},
resetFilter(){
const raw = this.unitTotalRawList || []
const sel = this.selectedUnits || []
if (!sel.length) {
this.applyUnitTotalRows(raw)
return
}
const nameSet = new Set(sel.map(u => u && u.unit_name).filter(Boolean))
const filtered = raw.filter(row => nameSet.has(row.unit_name))
this.applyUnitTotalRows(filtered)
}
2026-04-03 02:05:29 +08:00
}
}
</script>
<style scoped lang="scss">
.statistics {
background-color: #ebf1f7;
.banner {
2026-04-03 11:17:11 +08:00
background-color: #012458;
2026-04-03 02:05:29 +08:00
background-image: url(../../../static/images/bannerny1.jpg);
background-repeat: no-repeat;
background-position: center;
2026-04-03 11:17:11 +08:00
background-size: contain;
2026-04-03 02:05:29 +08:00
width: 100%;
height: 480px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: #fff;
.banner-topic {
font-size: 28px;
font-weight: 700;
margin-bottom: 18px;
text-shadow: 0 6px 22px rgba(0, 0, 0, 0.25);
letter-spacing: 0.02em;
}
.banner-type-dropdown {
margin-bottom: 40px;
}
.banner-static-title {
margin-bottom: 40px;
}
.banner-select-trigger {
display: inline-flex;
align-items: center;
gap: 14px;
padding: 14px 24px 14px 28px;
border: 2px solid rgba(255, 255, 255, 0.88);
border-radius: 12px;
background: rgba(8, 40, 72, 0.42);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow:
0 4px 20px rgba(0, 0, 0, 0.28),
inset 0 1px 0 rgba(255, 255, 255, 0.14);
cursor: pointer;
transition:
background 0.25s ease,
border-color 0.25s ease,
box-shadow 0.25s ease,
transform 0.25s ease;
user-select: none;
outline: none;
&:hover {
background: rgba(12, 55, 95, 0.58);
border-color: rgba(255, 255, 255, 0.98);
transform: translateY(-3px);
box-shadow:
0 12px 36px rgba(0, 0, 0, 0.38),
inset 0 1px 0 rgba(255, 255, 255, 0.22);
}
&:focus-visible {
outline: 3px solid rgba(255, 255, 255, 0.85);
outline-offset: 3px;
}
}
.banner-select-text {
font-size: 52px;
font-weight: bold;
color: #fff;
line-height: 1.15;
max-width: #{"min(90vw, 920px)"};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.banner-select-chevron {
flex-shrink: 0;
opacity: 0.95;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.35));
}
.numbers {
display: flex;
justify-content: space-between;
gap: 80px;
.item {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
2026-04-03 11:10:58 +08:00
2026-04-03 02:05:29 +08:00
.value {
font-size: 56px;
font-weight: bold;
2026-04-03 11:10:58 +08:00
2026-04-03 02:05:29 +08:00
.unit {
font-size: 26px;
}
}
2026-04-03 11:10:58 +08:00
2026-04-03 02:05:29 +08:00
.label {
font-size: 18px;
}
}
}
}
/** Banner 下方:统计单位筛选条,整行容器 + 右侧对齐独立框 */
.unit-toolbar {
width: 100%;
padding: 8px 50px 8px;
box-sizing: border-box;
background-color: #ebf1f7;
.unit-toolbar-inner {
display: flex;
justify-content: flex-end;
align-items: center;
width: 100%;
}
.unit-filter-box {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 18px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(0, 50, 90, 0.12);
box-shadow: 0 2px 10px rgba(0, 40, 80, 0.06);
}
.banner-unit-label {
font-size: 15px;
font-weight: 600;
letter-spacing: 0.06em;
color: rgba(28, 45, 63, 0.88);
flex-shrink: 0;
}
.banner-unit-select {
width: #{"min(72vw, 520px)"};
min-width: 200px;
::v-deep .el-input__inner {
min-height: 34px;
border-radius: 8px;
border: 1px solid rgba(0, 50, 90, 0.14);
background: #fff;
font-size: 14px;
font-weight: 500;
color: #1c2d3f;
}
::v-deep .el-select__tags {
max-width: 100%;
flex-wrap: wrap;
}
}
}
.map-module {
2026-04-04 16:42:48 +08:00
padding: 10px 50px;
2026-04-03 02:05:29 +08:00
display: flex;
justify-content: space-between;
gap: 50px;
2026-04-03 11:10:58 +08:00
2026-04-03 02:05:29 +08:00
.bezel {
flex: 1;
width: 100%;
height: 520px;
border-radius: 10px;
overflow: hidden;
border: 1px solid #ccc;
position: relative;
.map-title {
position: absolute;
top: 20px;
left: 20px;
color: white;
z-index: 8888;
font-size: 22px;
}
.map-year-panel {
position: absolute;
top: 16px;
right: 16px;
z-index: 8888;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
padding: 6px 10px 6px 12px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.68);
border: 1px solid rgba(255, 255, 255, 0.55);
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.14);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
transition:
background 0.25s ease,
border-color 0.25s ease,
box-shadow 0.25s ease,
transform 0.25s ease;
&:hover {
background: rgba(255, 255, 255, 0.82);
border-color: rgba(255, 255, 255, 0.92);
transform: translateY(-2px);
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.2);
}
}
.map-year-label {
font-size: 13px;
font-weight: 600;
color: rgba(28, 42, 58, 0.78);
letter-spacing: 0.04em;
user-select: none;
}
.map-year {
width: 120px;
::v-deep .el-input__inner {
height: 34px;
line-height: 34px;
padding-left: 30px;
padding-right: 28px;
border-radius: 8px;
border: 1px solid rgba(0, 50, 90, 0.12);
background: rgba(255, 255, 255, 0.55);
font-size: 14px;
font-weight: 500;
color: #1c2d3f;
transition:
background 0.2s ease,
border-color 0.2s ease,
box-shadow 0.2s ease;
}
::v-deep .el-input__inner:hover,
::v-deep .el-input__inner:focus {
border-color: rgba(0, 90, 150, 0.42);
background: rgba(255, 255, 255, 0.78);
box-shadow: 0 2px 8px rgba(0, 50, 100, 0.1);
}
::v-deep .el-input__prefix {
left: 8px;
}
::v-deep .el-input__suffix {
right: 6px;
}
}
}
}
.echarts-module {
padding: 0 50px 50px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 50px;
grid-auto-rows: 300px;
.bezel {
width: 100%;
height: 100%;
border-radius: 10px;
overflow: hidden;
border: 1px solid #ccc;
position: relative;
.map-title {
position: absolute;
top: 20px;
left: 20px;
color: white;
z-index: 8888;
font-size: 22px;
}
}
}
}
</style>
<style lang="scss">
.banner-type-dropdown-popper.el-dropdown-menu {
min-width: 240px;
max-height: #{"min(60vh, 420px)"};
overflow-y: auto;
padding: 6px 0;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
}
.banner-type-dropdown-popper.el-dropdown-menu .el-dropdown-menu__item {
font-size: 16px;
line-height: 22px;
padding: 14px 22px;
}
</style>