DSDSWeb/src/views/statistics/UuvUnitStatistics.vue

767 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>
<!-- 地图板块(与科考船汇总统计页 ships 一致Mapbox + shipUnit + shipVoyage -->
<div class="map-module">
<div class="bezel">
<div class="map-title">参航单位</div>
<div id="workUnitMap_uuv" ref="workUnitMap_uuv" style="height: 100%;width:100%;" />
</div>
<!-- <div class="bezel">
<div class="map-title">航次地图</div>
<div class="map-year-panel">
<span class="map-year-label">年份</span>
<el-date-picker v-model="voyageYear" type="year" size="small" placeholder="全部" class="map-year" clearable
format="yyyy" @change="selectVoyageYear" />
</div>
<div id="voyageMap_uuv" ref="voyageMap_uuv" style="height: 100%;width:100%;" />
</div> -->
</div>
<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>
<!-- echarts板块-->
<div class="echarts-module">
<div class="bezel">
<BarChart :title="barVoyageCount.title"
:chart-data="barVoyageCount.salesData"
:categories="barVoyageCount.productCategories"
chart-type="single"
:hide-x-axis-labels="true" />
</div>
<div class="bezel">
<BarChart :title="barVoyageDays.title"
:chart-data="barVoyageDays.salesData"
:categories="barVoyageDays.productCategories"
chart-type="single"
:hide-x-axis-labels="true" />
</div>
<div class="bezel">
<BarChart :title="barPeopleDiving.title"
:chart-data="barPeopleDiving.salesData"
:categories="barPeopleDiving.productCategories"
chart-type="multiple"
:hide-x-axis-labels="true" />
</div>
<div class="bezel">
<BarChart :title="barFirstPeopleDiving.title"
:chart-data="barFirstPeopleDiving.salesData"
:categories="barFirstPeopleDiving.productCategories"
chart-type="multiple"
:hide-x-axis-labels="true" />
</div>
<div class="bezel">
<BarChart :title="mixedMileage.title"
:chart-data="mixedMileage.series"
:categories="mixedMileage.categories"
chart-type="mixed"
:dual-y-axis="true"
left-axis-name="次数"
right-axis-name="天数"
:hide-x-axis-labels="true" />
</div>
</div>
</div>
</template>
<script>
import Vue from 'vue'
import BarChart from './components/BarChart.vue'
import { unitTotal, hovUnit, shipUnit, shipVoyage } from '../../api/statistics'
import { getYearRange } from '../../utils/index'
import { initMapbox } from '@/utils/mapbox-utils'
import { loadJsonPointFeature, showUnitReport } from '@/utils/vector-layer-utils'
/**
* @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: [],
/** 与 Banner 多选绑定:筛选单位汇总图表(空表示全部) */
selectedUnits: [],
/** unitTotal 全量缓存,便于按 Banner 选择做前端过滤 */
unitTotalRawList: [],
// 全量单位列表,用于地图点选时绑定 Banner
totalUnits: [],
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()
this.getShipUnit()
},
mounted() {
initMapbox('workUnitMap_uuv', {
tileKey: 'gaode',
zoom: 2.8,
center: [120, 31],
minZoom: 2,
maxZoom: 29
})
},
beforeDestroy() {
const unitMap = Vue.config.maps && Vue.config.maps['workUnitMap_uuv']
if (unitMap) {
unitMap.remove()
delete Vue.config.maps['workUnitMap_uuv']
}
},
methods: {
/**
* Banner 统计单位多选变更:刷新汇总图表。
* @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)
console.log('applyFilteredUnitTotal', this.selectedUnits);
// 地图显示最后一个选中单位的相关信息
showUnitReport('workUnitMap_uuv', this.selectedUnits.at(-1).unit_name, [this.selectedUnits.at(-1).unit_lon, this.selectedUnits.at(-1).unit_lat])
},
/**
* 拉取「单位汇总」报表(/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 }
]
}
},
/**
* 载人潜水器参航单位列表:仅用于 Banner 多选筛选图表。
* @returns {void}
*/
getHovUnit() {
hovUnit().then(res => {
// 参航单位不一定下潜
// const map = new Map();
// (res.array || []).forEach(unit => {
// const k = unit.unit_id || unit.unitId || unit.unit_name
// if (!map.has(k)) map.set(k, unit)
// })
// this.unitOptions = Array.from(map.values());
})
},
/**
* 科考船侧参航单位点位(与 ships 页地图同源接口),通过 Mapbox GeoJSON 渲染。
* @returns {void}
*/
getShipUnit() {
shipUnit().then(res => {
this.totalUnits = res.array || [];
const map = new Map();
this.totalUnits.forEach(unit => {
const k = unit.unit_id || unit.unitId || unit.unit_name
if (!map.has(k)) map.set(k, unit)
})
this.unitOptions = Array.from(map.values())
const geojson = {
type: 'FeatureCollection',
features: []
};
this.totalUnits.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
}
})
});
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;
// 绑定地图单击的单位与 Banner 选项,保持地图点选与 Banner 选项同步
this.selectedUnits = this.totalUnits.filter(u => u.unit_name === e.features[0].properties.unit_name);
// 地图点选弹窗显示单位信息
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));
});
})
},
resetFilter() {
// 关闭地图弹窗后恢复全部单位统计显示
this.selectedUnits = [];
this.applyUnitTotalRows(this.unitTotalRawList || []);
}
}
}
</script>
<style scoped lang="scss">
.statistics {
background-color: #ebf1f7;
.banner {
background-color: #012458;
background-image: url(../../../static/images/bannerny1.jpg);
background-repeat: no-repeat;
// 超宽屏下 contain 会在左右留边cover 铺满裁切边缘
background-position: center;
background-size: cover;
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;
.value {
font-size: 56px;
font-weight: bold;
.unit {
font-size: 26px;
}
}
.label {
font-size: 18px;
}
}
}
}
/** 地图卡片下方:统计单位筛选条,整行容器 + 右侧对齐独立框 */
.unit-toolbar {
width: 100%;
padding: 20px 50px 12px;
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 {
padding: 50px 50px 0;
display: flex;
justify-content: space-between;
gap: 50px;
.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: 16px 50px 50px;
display: grid;
grid-template-columns: 1fr;
row-gap: 32px;
.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: 260px;
max-height: #{"min(60vh, 420px)"};
overflow-x: hidden;
overflow-y: auto;
padding: 8px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.38);
background: linear-gradient(
155deg,
rgba(255, 255, 255, 0.48) 0%,
rgba(248, 252, 255, 0.28) 50%,
rgba(255, 255, 255, 0.16) 100%
);
backdrop-filter: blur(22px) saturate(175%);
-webkit-backdrop-filter: blur(22px) saturate(175%);
box-shadow:
0 4px 6px rgba(0, 0, 0, 0.05),
0 18px 48px rgba(10, 40, 90, 0.16),
inset 0 1px 0 rgba(255, 255, 255, 0.42);
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
margin: 6px 0;
background: rgba(255, 255, 255, 0.14);
border-radius: 3px;
}
&::-webkit-scrollbar-thumb {
background: rgba(10, 132, 193, 0.38);
border-radius: 3px;
}
&::-webkit-scrollbar-thumb:hover {
background: rgba(10, 132, 193, 0.55);
}
}
.banner-type-dropdown-popper.el-dropdown-menu .el-dropdown-menu__item {
font-size: 18px;
font-weight: 500;
letter-spacing: 0.03em;
line-height: 1.45;
padding: 12px 18px;
margin: 2px 0;
border-radius: 10px;
color: rgba(28, 45, 63, 0.95);
transition:
background 0.2s ease,
color 0.2s ease;
}
.banner-type-dropdown-popper.el-dropdown-menu .el-dropdown-menu__item:not(.is-disabled):hover {
background: rgba(10, 132, 193, 0.2);
color: #0a84c1;
}
.banner-type-dropdown-popper.el-dropdown-menu .el-dropdown-menu__item:focus {
background: rgba(10, 132, 193, 0.17);
color: #0a84c1;
}
.banner-type-dropdown-popper.el-dropdown-menu .el-dropdown-menu__item.is-disabled {
color: rgba(28, 45, 63, 0.35);
}
</style>