WholeProcessPlatform/frontend/src/modules/yunXingGaoJIng/Aisbdbyx/AISBTK.vue

1738 lines
47 KiB
Vue
Raw Normal View History

<template>
<div class="aisbtk-page">
<!-- 搜索表单 -->
<a-form layout="inline" class="search-form">
<a-form-item label="基地选择">
<a-select
v-model:value="searchData.baseid"
placeholder="请选择基地"
style="width: 180px"
:options="jidiOptions"
@change="handleBaseChange"
/>
</a-form-item>
<a-form-item label="电站名称">
<a-input
v-model:value="searchData.stnm"
placeholder="请输入电站"
allow-clear
style="width: 180px"
/>
</a-form-item>
<a-form-item label="时间">
<a-date-picker
v-model:value="searchData.month"
picker="month"
format="YYYY-MM"
:allow-clear="false"
style="width: 150px"
/>
</a-form-item>
<a-form-item>
<a-button type="primary" @click="handleSearch">查询</a-button>
</a-form-item>
</a-form>
<!-- Tabs + 图表表格 -->
<a-tabs v-model:activeKey="tabIndex" class="aisbtk-tabs">
<!-- 视图切换按钮 -->
<div class="view-switch-buttons">
<a-tooltip placement="top" title="视频">
<div
class="view-btn"
:class="{ active: vdListShow === '1' }"
@click="vdListShow = '1'"
>
<i class="icon iconfont icon-video"></i>
</div>
</a-tooltip>
<a-tooltip placement="top" title="数据列表">
<div
class="view-btn"
:class="{ active: vdListShow === '2' }"
@click="vdListShow = '2'"
>
<i class="icon iconfont icon-dataTable"></i>
</div>
</a-tooltip>
<a-tooltip placement="top" title="日历">
<div
class="view-btn"
:class="{ active: vdListShow === '3' }"
@click="vdListShow = '3'"
>
<i class="icon iconfont icon-calendar"></i>
</div>
</a-tooltip>
</div>
<a-tab-pane
v-for="item in tabDisplayList"
:key="String(item.key)"
:tab="item.tabLabel"
>
<!-- 数据列表视图饼图+表格 -->
<div v-show="vdListShow === '2'" class="chart-table-layout">
<!-- 左侧饼图 -->
<div class="chart-section">
<a-spin :spinning="chartLoading">
<div
:ref="(el: any) => setChartRef(el, String(item.key))"
class="chart-container"
></div>
</a-spin>
<div
v-if="!chartLoading && chartData.length === 0"
class="chart-empty-overlay"
>
<a-empty description="暂无数据" />
</div>
</div>
<!-- 右侧表格 -->
<div class="table-section">
<BasicTable
:ref="(el: any) => setTableRef(el, String(item.key))"
:columns="currentColumns"
:scroll-y="450"
:scroll-x="600"
:list-url="getAiChartTable"
:search-params="tableSearchParams"
:transform-data="customTransform"
>
<template #tm="{ record }">
{{ record.tm ? dayjs(record.tm).format('YYYY-MM-DD') : '-' }}
</template>
<template #fid="{ record }">
<a-button
type="link"
style="padding: 4px"
:disabled="!record.fid"
@click="handleViewAttachment(record.fid)"
>
查看附件
</a-button>
</template>
<template #isBigDebrisArea="{ record }">
<span v-if="record.isBigDebrisArea === 1" style="color: red"
></span
>
<span
v-else-if="record.isBigDebrisArea === 0"
style="color: green"
></span
>
<span v-else>-</span>
</template>
<template #aiVal="{ record }">
{{
record.aiVal === 'open'
? '开启'
: record.aiVal === 'close'
? '关闭'
: record.aiVal ?? '-'
}}
</template>
</BasicTable>
</div>
</div>
<!-- 视频视图 -->
<div v-show="vdListShow === '1'" class="chart-table-layout">
<!-- 左侧饼图 -->
<div class="chart-section">
<a-spin :spinning="chartLoading">
<div
:ref="(el: any) => setVideoChartRef(el, String(item.key))"
class="chart-container"
></div>
</a-spin>
<div
v-if="!chartLoading && chartData.length === 0"
class="chart-empty-overlay"
>
<a-empty description="暂无数据" />
</div>
</div>
<!-- 右侧视频列表 -->
<div class="video-section">
<div class="video-mode">
<div class="video-main" v-loading="videoLoading">
<div
v-if="activeMedia.type === 'image' && activeMedia.src"
class="media-display"
>
<img
:src="activeMedia.src"
style="width: 100%; height: 100%; object-fit: contain"
/>
</div>
<div
v-else-if="activeMedia.type === 'video' && activeMedia.src"
class="media-display"
>
<LiveVideoBox
:videoData="{ url: activeMedia.src }"
height="100%"
:showTitle="false"
/>
</div>
<div v-else class="media-empty">
<a-empty description="请选择右侧数据" />
</div>
</div>
<div class="video-sidebar" v-loading="videoLoading">
<div class="sidebar-list" v-if="videoList.length > 0">
<div
v-for="item in videoList"
:key="item.fid"
class="sidebar-item"
:class="{ active: activeFid === item.fid }"
@click="handleSelectMedia(item)"
>
<div class="item-thumb">
<img v-if="item.isImage" :src="item.thumbUrl" />
<div v-else-if="item.isVideo" class="thumb-placeholder">
<img :src="videoCover" />
</div>
<span v-else class="no-media">无媒体</span>
</div>
<div class="item-time">{{ item.ennm }}{{ item.tm }}</div>
</div>
</div>
<a-empty v-else description="暂无数据" class="sidebar-empty" />
<div class="sidebar-pagination">
<a-pagination
v-model:current="videoPage"
v-model:pageSize="videoTake"
:page-size-options="videoPageSizeOptions"
:total="videoTotal"
show-size-changer
size="small"
@change="handleVideoPageChange"
/>
</div>
</div>
</div>
</div>
</div>
<!-- 日历视图 -->
<div v-show="vdListShow === '3'" class="calendar-mode">
<a-spin :spinning="calendarLoading">
<a-calendar v-model:value="calendarValue" @panelChange="handleCalendarPanelChange">
<template #dateCellRender="{ current }">
<div class="calendar-content">
<template v-if="isCurrentMonth(current)">
<div v-if="getCalendarDataForDay(current)" class="content_state1">
监测
</div>
<div v-else class="content_state4">未监测</div>
<div
v-if="getCalendarDataForDay(current)?.status !== undefined"
:class="getStatusClass(getCalendarDataForDay(current)?.status)"
class="status-label"
@click.stop="handleCalendarDayClick(current, getCalendarDataForDay(current))"
>
{{ getStatusText(getCalendarDataForDay(current)?.status) }}
</div>
<div
v-if="getCalendarDataForDay(current)?.qpStatus !== undefined"
:class="getStatusClass(getCalendarDataForDay(current)?.qpStatus)"
class="status-label"
@click.stop="handleCalendarDayClick(current, getCalendarDataForDay(current))"
>
{{ getStatusText(getCalendarDataForDay(current)?.qpStatus) }}
</div>
</template>
</div>
</template>
</a-calendar>
</a-spin>
</div>
</a-tab-pane>
</a-tabs>
<!-- 附件预览弹框 -->
<CommonAttachmentModal
v-model:open="attachmentModalVisible"
:fid="currentAttachmentFid"
title="查看附件"
/>
<!-- 日历详情弹窗 -->
<a-modal
v-model:open="detailModalVisible"
title="视频详情"
width="80vw"
:footer="null"
destroyOnClose
:bodyStyle="{ height: '70vh' }"
>
<a-tabs v-model:activeKey="detailActiveKey">
<a-tab-pane v-for="item in detailTabItems" :key="item.key" :tab="item.label">
<div class="detail-video-content" v-loading="detailVideoLoading">
<div class="detail-video-layout">
<div class="detail-video-player">
<div v-if="detailActiveMedia.type === 'image' && detailActiveMedia.src" class="media-display">
<img :src="detailActiveMedia.src" style="width: 100%; height: 100%; object-fit: contain" />
</div>
<div v-else-if="detailActiveMedia.type === 'video' && detailActiveMedia.src" class="media-display">
<LiveVideoBox :videoData="{ url: detailActiveMedia.src }" height="100%" :showTitle="false" />
</div>
<div v-else class="media-empty">
<a-empty description="请选择右侧数据" />
</div>
</div>
<div class="detail-video-sidebar">
<div class="sidebar-list" v-if="detailVideoList.length > 0">
<div
v-for="item in detailVideoList"
:key="item.fid"
class="sidebar-item"
:class="{ active: detailActiveFid === item.fid }"
@click="handleSelectDetailMedia(item)"
>
<div class="item-thumb">
<img v-if="item.isImage" :src="item.thumbUrl" />
<div v-else-if="item.isVideo" class="thumb-placeholder">
<img :src="videoCover" />
</div>
<span v-else class="no-media">无媒体</span>
</div>
<div class="item-time">{{ item.ennm }}{{ item.tm }}</div>
</div>
</div>
<a-empty v-else description="暂无数据" class="sidebar-empty" />
<div class="sidebar-pagination">
<a-pagination
v-model:current="detailVideoPage"
v-model:pageSize="detailVideoTake"
:page-size-options="detailVideoPageSizeOptions"
:total="detailVideoTotal"
show-size-changer
size="small"
@change="handleDetailVideoPageChange"
/>
</div>
</div>
</div>
</div>
</a-tab-pane>
</a-tabs>
</a-modal>
<!-- 清漂附件预览弹框 -->
<CommonAttachmentModal
v-model:open="qingpiaoModalVisible"
:fid="qingpiaoFids.join(',')"
title="附件预览"
/>
</div>
</template>
<script lang="ts" setup>
import {
ref,
computed,
watch,
onMounted,
onBeforeUnmount,
nextTick
} from 'vue';
import * as echarts from 'echarts';
import type { ECharts } from 'echarts';
import BasicTable from '@/components/BasicTable/index.vue';
import CommonAttachmentModal from '@/components/MapModal/components/CommonAttachmentModal.vue';
import LiveVideoBox from '@/views/shiPinJianKong/components/LiveVideoBox.vue';
import { getAisbdbyxList, getAiChartTable, getAiPfwRecord, getAiRecord, getAiFile } from '@/api/zngj';
import { postIdUrl } from '@/api/mapModal';
import dayjs from 'dayjs';
import { useJidiSelectEventStore } from '@/store/modules/jidiSelectEvent';
import videoCover from '@/assets/images/videFm.jpg';
const baseUrl = import.meta.env.VITE_APP_ATTACHMENT_URL;
// ==================== Props ====================
const props = defineProps<{
month?: string;
tabIdx?: string;
baseid?: string;
}>();
// ==================== Store ====================
const JidiSelectEventStore = useJidiSelectEventStore();
// ==================== 搜索状态 ====================
const jidiOptions = ref<Array<{ label: string; value: string }>>([]);
const searchData = ref({
baseid: props.baseid || 'all',
stnm: '',
month: props.month ? dayjs(props.month) : dayjs()
});
// ==================== 弹框状态 ====================
const attachmentModalVisible = ref(false);
const currentAttachmentFid = ref('');
// ==================== Tabs 状态 ====================
const tabIndex = ref(props.tabIdx ?? '1');
const tabList = ref<any[]>([
{ key: 1, value: '漂浮物识别', unit: '次', count: 0 },
{ key: 3, value: '清漂识别', unit: '次', count: 0 },
{ key: 2, value: '叠梁门运行识别', unit: '次', count: 0 }
]);
// ==================== 视图切换状态 ====================
const vdListShow = ref<'1' | '2' | '3'>('2');
// Tab 显示列表(根据视图类型动态计算标题)
const tabDisplayList = computed(() =>
tabList.value.map(item => ({
...item,
tabLabel:
vdListShow.value === '3'
? item.value
: `${item.value}(${item.count}${item.unit})`
}))
);
// ==================== 图表状态 ====================
const chartRefs = ref<Map<string, HTMLElement>>(new Map());
let chartInstance: ECharts | null = null;
const chartData = ref<any[]>([]);
const chartLoading = ref(true);
const pieCode = ref<string[]>([]);
// ==================== 视频视图状态 ====================
const videoPage = ref(1);
const videoTake = ref(20);
const videoPageSizeOptions = ['20', '50', '100'];
const videoTotal = ref(0);
const videoLoading = ref(false);
const videoList = ref<any[]>([]);
const activeFid = ref('');
const activeMedia = ref<{ type: string; src: string }>({ type: '', src: '' });
// 视频视图饼图状态
const videoChartRefs = ref<Map<string, HTMLElement>>(new Map());
let videoChartInstance: ECharts | null = null;
// ==================== 日历视图状态 ====================
const calendarLoading = ref(false);
const calendarData = ref<any[]>([]);
const calendarValue = ref(dayjs());
const detailModalVisible = ref(false);
const detailActiveKey = ref('');
const detailVideoLoading = ref(false);
const detailVideoList = ref<any[]>([]);
const detailVideoPage = ref(1);
const detailVideoTake = ref(20);
const detailVideoPageSizeOptions = ['20', '50', '100'];
const detailVideoTotal = ref(0);
const detailActiveFid = ref('');
const detailActiveMedia = ref<{ type: string; src: string }>({ type: '', src: '' });
const detailTabItems = ref<any[]>([]);
const detailDate = ref('');
const detailType = ref('');
const detailDataType = ref('');
const qingpiaoModalVisible = ref(false);
const qingpiaoFids = ref<string[]>([]);
// 日历状态映射
const calendarStatusMap: Record<string, { name: string; className: string; type: string; dataType: string }> = {
'0': { name: '无漂浮物', className: 'content_state2', type: 'AI_5001', dataType: '3' },
'1': { name: '小面积漂浮物', className: 'content_state5', type: 'AI_5001', dataType: '4' },
'2': { name: '大面积漂浮物', className: 'content_state3', type: 'AI_5001', dataType: '5' },
'3': { name: '运行', className: 'content_state2', type: 'AI_5005', dataType: '2' },
'4': { name: '未运行', className: 'content_state3', type: 'AI_5005', dataType: '1' },
'5': { name: '清漂', className: 'content_state2', type: 'AI_5011', dataType: '3' },
'6': { name: '未清漂', className: 'content_state3', type: 'AI_5011', dataType: '3' }
};
// 函数式 ref 绑定
const setChartRef = (el: any, key: string) => {
if (el instanceof HTMLElement) {
chartRefs.value.set(key, el);
}
};
const setVideoChartRef = (el: any, key: string) => {
if (el instanceof HTMLElement) {
videoChartRefs.value.set(key, el);
}
};
// ==================== 表格引用 ====================
const tableRefs = ref<Map<string, any>>(new Map());
const setTableRef = (el: any, key: string) => {
if (el && !(el instanceof HTMLElement)) {
tableRefs.value.set(key, el);
}
};
// 安全获取 table 实例
const getTableInstance = () => {
return tableRefs.value.get(tabIndex.value) || null;
};
// ==================== 表格列配置 ====================
const pfwColumns = [
{
key: 'ennm',
title: '所属电站',
dataIndex: 'ennm',
fixed: 'left' as const,
width: 120,
ellipsis: true
},
{
key: 'stnm',
title: '设施名称',
dataIndex: 'stnm',
width: 150,
ellipsis: true
},
{
key: 'hbRvcdName',
title: '所在河流',
dataIndex: 'hbRvcdName',
width: 120,
ellipsis: true
},
{
key: 'tm',
title: '时间',
dataIndex: 'tm',
width: 150,
ellipsis: true,
slots: { customRender: 'tm' }
},
{
key: 'aiVal',
title: '漂浮物面积',
dataIndex: 'aiVal',
width: 120,
ellipsis: true
},
{
key: 'isBigDebrisArea',
title: '是否为大面积堆积',
dataIndex: 'isBigDebrisArea',
width: 140,
ellipsis: true,
slots: { customRender: 'isBigDebrisArea' }
},
{
key: 'fid',
title: '操作',
dataIndex: 'fid',
width: 100,
ellipsis: true,
slots: { customRender: 'fid' }
}
];
const dlmColumns = [
{
key: 'stnm',
title: '设施名称',
dataIndex: 'stnm',
fixed: 'left' as const,
width: 150,
ellipsis: true
},
{
key: 'ennm',
title: '所属电站',
dataIndex: 'ennm',
fixed: 'left' as const,
width: 120,
ellipsis: true
},
{
key: 'tm',
title: '操作时间',
dataIndex: 'tm',
width: 150,
ellipsis: true,
slots: { customRender: 'tm' }
},
{
key: 'aiVal',
title: '操作状态',
dataIndex: 'aiVal',
width: 120,
ellipsis: true,
slots: { customRender: 'aiVal' }
},
{
key: 'fid',
title: '操作',
dataIndex: 'fid',
width: 100,
ellipsis: true,
slots: { customRender: 'fid' }
}
];
const dlmCntColumns = [
{
key: 'hbRvcdName',
title: '所属河流',
dataIndex: 'hbRvcdName',
fixed: 'left' as const,
width: 150,
ellipsis: true
},
{
key: 'ennm',
title: '所属电站',
dataIndex: 'ennm',
fixed: 'left' as const,
width: 120,
ellipsis: true
},
{
key: 'tm',
title: '清漂时间',
dataIndex: 'tm',
width: 150,
ellipsis: true,
slots: { customRender: 'tm' }
},
{
key: 'fid',
title: '操作',
dataIndex: 'fid',
width: 100,
ellipsis: true,
slots: { customRender: 'fid' }
}
];
const currentColumns = computed(() => {
switch (tabIndex.value) {
case '1':
return pfwColumns;
case '2':
return dlmColumns;
case '3':
return dlmCntColumns;
default:
return pfwColumns;
}
});
// ==================== 获取 type ====================
const getType = () => {
switch (tabIndex.value) {
case '1':
return 'AI_5001';
case '2':
return 'AI_5005';
case '3':
return 'AI_5011';
default:
return 'AI_5001';
}
};
// ==================== 构建过滤条件 ====================
const buildFilter = () => {
const filters: any[] = [
{
field: 'type',
operator: 'eq',
value: getType()
},
pieCode.value.length > 0 && searchData.value.baseid === 'all'
? {
field: 'baseId',
operator: 'in',
dataType: 'string',
value: pieCode.value
}
: null,
pieCode.value.length > 0 && searchData.value.baseid !== 'all'
? {
field: 'rstcd',
operator: 'in',
dataType: 'string',
value: pieCode.value
}
: null,
searchData.value.baseid !== 'all' && pieCode.value.length === 0
? {
field: 'baseId',
operator: 'eq',
dataType: 'string',
value: searchData.value.baseid
}
: null,
{
field: 'tm',
operator: 'gte',
dataType: 'date',
value: dayjs(searchData.value.month)
.startOf('month')
.format('YYYY-MM-DD 00:00:00')
},
{
field: 'tm',
operator: 'lte',
dataType: 'date',
value: dayjs(searchData.value.month)
.endOf('month')
.format('YYYY-MM-DD 23:59:59')
},
searchData.value.stnm
? {
field: 'stnm',
operator: 'contains',
value: searchData.value.stnm
}
: null
].filter(Boolean);
return {
logic: 'and',
filters
};
};
// ==================== 表格搜索参数 ====================
const tableSearchParams = computed(() => ({
sort: [
{ field: 'rstcdStepSort', dir: 'asc' },
{ field: 'tm', dir: 'desc' }
],
group: [],
select: [],
groupResultFlat: false
}));
// ==================== 数据转换 ====================
const customTransform = (res: any) => {
const dataObj = res?.data;
const records = Array.isArray(dataObj) ? dataObj : dataObj?.data || [];
return {
records,
total: dataObj?.total || 0
};
};
// ==================== 获取 Tab 数量统计 ====================
const getTabList = async () => {
try {
const params = {
filter: {
logic: 'and',
filters: [
searchData.value.baseid !== 'all'
? {
field: 'baseId',
operator: 'eq',
value: searchData.value.baseid
}
: null,
{
field: 'startTime',
operator: 'gte',
dataType: 'date',
value: dayjs(searchData.value.month)
.startOf('month')
.format('YYYY-MM-DD 00:00:00')
},
{
field: 'endTime',
operator: 'lte',
dataType: 'date',
value: dayjs(searchData.value.month)
.endOf('month')
.format('YYYY-MM-DD 23:59:59')
},
searchData.value.stnm
? {
field: 'stnm',
operator: 'contains',
value: searchData.value.stnm
}
: null
].filter(Boolean)
}
};
const res = await getAisbdbyxList(params);
const data = res?.data ?? {};
tabList.value = [
{ key: 1, value: '漂浮物识别', unit: '次', count: data?.pfwCnt ?? 0 },
{ key: 3, value: '清漂识别', unit: '次', count: data?.pfwqlCnt ?? 0 },
{ key: 2, value: '叠梁门运行识别', unit: '次', count: data?.dlmCnt ?? 0 }
];
} catch (error) {
console.error('获取Tab统计失败:', error);
tabList.value = [
{ key: 1, value: '漂浮物识别', unit: '次', count: 0 },
{ key: 3, value: '清漂识别', unit: '次', count: 0 },
{ key: 2, value: '叠梁门运行识别', unit: '次', count: 0 }
];
}
};
// ==================== 获取饼图数据 ====================
const getChartData = async () => {
chartLoading.value = true;
try {
const filter = buildFilter();
const group =
searchData.value.baseid === 'all'
? [
{ dir: 'asc', field: 'baseStepSort' },
{ dir: 'asc', field: 'baseId' },
{ dir: 'asc', field: 'baseName' }
]
: [
{ dir: 'asc', field: 'rstcd' },
{ dir: 'asc', field: 'ennm' }
];
const params = {
filter,
group,
groupResultFlat: true
};
const res = await getAiChartTable(params);
const dataObj = res?.data?.data ?? {};
const rawData = Array.isArray(dataObj) ? dataObj : dataObj?.data || [];
const list = rawData.map((el: any) => {
if (searchData.value.baseid === 'all') {
return {
name: el?.baseName_value || el?.baseName || '-',
value: el?.count_baseName || el?.count_baseId || 0,
key: el?.baseId_value || el?.baseId
};
}
return {
name: el?.ennm_value || el?.ennm || '-',
value: el?.count_rstcd || el?.count_ennm || 0,
key: el?.rstcd || el?.rstcd_value
};
});
chartData.value = list;
} catch (error) {
console.error('获取饼图数据失败:', error);
chartData.value = [];
} finally {
chartLoading.value = false;
// 等待 DOM 更新后再初始化图表
await nextTick();
setTimeout(() => {
initChart();
initVideoChart();
}, 50);
}
};
// ==================== 初始化饼图 ====================
const initChart = (retryCount = 0) => {
// 根据当前 tabIndex 获取对应 tab 的 DOM
const dom = chartRefs.value.get(tabIndex.value);
if (!dom) {
console.warn(
`[initChart] DOM not found for tab ${tabIndex.value}, retrying...`
);
if (retryCount < 10) {
setTimeout(() => initChart(retryCount + 1), 100);
}
return;
}
// 检查 DOM 是否可见(宽高不为 0
const rect = dom.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) {
if (retryCount < 10) {
console.warn(
`[initChart] DOM has 0 dimensions, retrying in 100ms... (attempt ${
retryCount + 1
}/10)`
);
setTimeout(() => initChart(retryCount + 1), 100);
} else {
console.error(
'[initChart] DOM still has 0 dimensions after 10 retries, giving up'
);
}
return;
}
if (!chartInstance) {
chartInstance = echarts.init(dom);
}
const option: echarts.EChartsOption = {
tooltip: {
trigger: 'item',
formatter: (params: any) =>
`${params.marker} ${params.name}: ${params.value}(次)`
},
legend: {
orient: 'vertical',
right: 10,
top: 80,
textStyle: { fontSize: 12 }
},
series: []
};
if (!chartData.value || chartData.value.length === 0) {
chartInstance.setOption(option, true);
return;
}
const total = chartData.value.reduce(
(acc: number, item: any) => acc + (item.value || 0),
0
);
const typeMap: Record<string, string> = {
'1': '清漂(次)',
'2': '叠梁门开启(次)',
'3': '清漂识别(次)'
};
const titleText = typeMap[tabIndex.value] || '识别(次)';
option.series = [
{
type: 'pie',
radius: ['26%', '35%'],
center: ['35%', '50%'],
avoidLabelOverlap: false,
label: {
show: true,
formatter: '{c}'
},
labelLine: {
show: true,
length: 12,
length2: 12
},
data: chartData.value.map((item: any) => ({
name: item.name,
value: item.value
}))
}
];
option.title = {
text: String(total),
left: '34%',
top: '44%',
textAlign: 'center',
textStyle: {
fontSize: 24,
fontWeight: 'bold',
color: '#333'
},
subtext: titleText,
subtextStyle: {
fontSize: 12,
color: '#333'
}
};
chartInstance.setOption(option, true);
// 图例点击联动过滤表格
chartInstance.off('legendselectchanged');
chartInstance.on('legendselectchanged', (e: any) => {
const trueRivers = Object.keys(e.selected).filter(
(key: string) => e.selected[key]
);
const allFalse = Object.values(e.selected).every(
(value: any) => value === false
);
const selectedKeys = chartData.value
.filter((item: any) => trueRivers.includes(item.name))
.map((item: any) => item.key);
pieCode.value = allFalse ? [] : selectedKeys;
nextTick(() => {
const filter = buildFilter();
getTableInstance()?.getList(filter);
});
});
};
// ==================== 初始化视频视图饼图 ====================
const initVideoChart = (retryCount = 0) => {
const dom = videoChartRefs.value.get(tabIndex.value);
if (!dom) {
if (retryCount < 10) {
setTimeout(() => initVideoChart(retryCount + 1), 100);
}
return;
}
const rect = dom.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) {
if (retryCount < 10) {
setTimeout(() => initVideoChart(retryCount + 1), 100);
}
return;
}
if (!videoChartInstance) {
videoChartInstance = echarts.init(dom);
}
const option: echarts.EChartsOption = {
tooltip: {
trigger: 'item',
formatter: (params: any) =>
`${params.marker} ${params.name}: ${params.value}(次)`
},
legend: {
orient: 'vertical',
right: 10,
top: 80,
textStyle: { fontSize: 12 }
},
series: []
};
if (!chartData.value || chartData.value.length === 0) {
videoChartInstance.setOption(option, true);
return;
}
const total = chartData.value.reduce(
(acc: number, item: any) => acc + (item.value || 0),
0
);
const typeMap: Record<string, string> = {
'1': '清漂(次)',
'2': '叠梁门开启(次)',
'3': '清漂识别(次)'
};
const titleText = typeMap[tabIndex.value] || '识别(次)';
option.series = [
{
type: 'pie',
radius: ['26%', '35%'],
center: ['35%', '50%'],
avoidLabelOverlap: false,
label: {
show: true,
formatter: '{c}'
},
labelLine: {
show: true,
length: 12,
length2: 12
},
data: chartData.value.map((item: any) => ({
name: item.name,
value: item.value
}))
}
];
option.title = {
text: String(total),
left: '34%',
top: '44%',
textAlign: 'center',
textStyle: {
fontSize: 24,
fontWeight: 'bold',
color: '#333'
},
subtext: titleText,
subtextStyle: {
fontSize: 12,
color: '#333'
}
};
videoChartInstance.setOption(option, true);
// 图例点击联动过滤视频列表
videoChartInstance.off('legendselectchanged');
videoChartInstance.on('legendselectchanged', (e: any) => {
const trueRivers = Object.keys(e.selected).filter(
(key: string) => e.selected[key]
);
const allFalse = Object.values(e.selected).every(
(value: any) => value === false
);
const selectedKeys = chartData.value
.filter((item: any) => trueRivers.includes(item.name))
.map((item: any) => item.key);
pieCode.value = allFalse ? [] : selectedKeys;
nextTick(() => {
videoPage.value = 1;
activeFid.value = '';
activeMedia.value = { type: '', src: '' };
fetchVideoList();
});
});
};
// ==================== 获取视频列表 ====================
const fetchVideoList = async () => {
videoLoading.value = true;
try {
const filter = buildFilter();
const skip = (videoPage.value - 1) * videoTake.value;
const params = {
filter,
skip,
take: videoTake.value,
sort: [
{ field: 'rstcdStepSort', dir: 'asc' },
{ field: 'tm', dir: 'desc' }
]
};
const res = await getAiChartTable(params);
const dataObj = res?.data?.data ?? {};
const rawData = dataObj?.data || [];
videoTotal.value = dataObj?.total || 0;
// 收集所有 fid
const allFids: string[] = [];
rawData.forEach((item: any) => {
if (item.fid) {
item.fid.split(',').forEach((f: string) => {
const trimmed = f.trim();
if (trimmed) allFids.push(trimmed);
});
}
});
// 调用 postIdUrl 获取媒体详情
let mediaMap: Record<string, any> = {};
if (allFids.length > 0) {
const formData = new FormData();
formData.append('id', allFids.join(','));
const mediaRes: any = await postIdUrl(formData);
mediaMap = mediaRes?.data || {};
}
// 按 fid 展开为独立项
const flatList: any[] = [];
rawData.forEach((item: any) => {
const fids = item.fid
? item.fid
.split(',')
.map((f: string) => f.trim())
.filter(Boolean)
: [];
const tm = item.tm ? dayjs(item.tm).format('YYYY-MM-DD HH:mm:ss') : '-';
fids.forEach((fid: string) => {
const m = mediaMap[fid];
const ext = m?.ext || '';
const isVideo = ext === '.mp4';
const isImage = ext === '.jpg' || ext === '.png';
flatList.push({
...item,
fid,
media: m || null,
thumbUrl: isImage ? (baseUrl + '?' + fid + '&view=jpg') : '',
isVideo,
isImage,
tm
});
});
});
videoList.value = flatList;
// 默认选中第一条
if (videoList.value.length > 0 && !activeFid.value) {
handleSelectMedia(videoList.value[0]);
}
} catch (error) {
console.error('获取视频列表失败:', error);
videoList.value = [];
} finally {
videoLoading.value = false;
}
};
// 选择媒体
const handleSelectMedia = (item: any) => {
activeFid.value = item.fid;
const { media, isVideo, isImage } = item;
if (isImage) {
activeMedia.value = { type: 'image', src: baseUrl + '?' + item.fid + '&view=jpg' };
} else if (isVideo) {
activeMedia.value = { type: 'video', src: baseUrl + '?' + item.fid + '&view=jpg' };
} else {
activeMedia.value = { type: '', src: '' };
}
};
// 分页切换
const handleVideoPageChange = () => {
activeFid.value = '';
activeMedia.value = { type: '', src: '' };
fetchVideoList();
};
// ==================== 日历视图函数 ====================
// 判断是否是当前月份
const isCurrentMonth = (current: any): boolean => {
const selectedDate = dayjs(searchData.value.month);
return current.month() === selectedDate.month() && current.year() === selectedDate.year();
};
// 获取某天的日历数据
const getCalendarDataForDay = (current: any): any => {
if (!isCurrentMonth(current)) return null;
const day = current.format('DD');
return calendarData.value.find((item: any) => dayjs(item.dt).format('DD') === day);
};
// 获取状态样式类
const getStatusClass = (status: string) => {
return calendarStatusMap[status]?.className || '';
};
// 获取状态文本
const getStatusText = (status: string) => {
return calendarStatusMap[status]?.name || '';
};
// 获取日历数据
const fetchCalendarData = async () => {
calendarLoading.value = true;
try {
const type = getType();
const filters: any[] = [
{ field: 'type', operator: 'eq', value: type },
searchData.value.baseid !== 'all' ? { field: 'baseId', operator: 'eq', value: searchData.value.baseid } : null,
{
field: 'startTime',
operator: 'gte',
dataType: 'date',
value: dayjs(searchData.value.month).startOf('month').format('YYYY-MM-DD 00:00:00')
},
{
field: 'endTime',
operator: 'lte',
dataType: 'date',
value: dayjs(searchData.value.month).endOf('month').format('YYYY-MM-DD 23:59:59')
}
].filter(Boolean);
const params = { filter: { logic: 'and', filters } };
// 根据类型调用不同接口
const apiFn = type === 'AI_5005' ? getAiRecord : getAiPfwRecord;
const res: any = await apiFn(params);
calendarData.value = res?.data?.data || [];
} catch (error) {
console.error('获取日历数据失败:', error);
calendarData.value = [];
} finally {
calendarLoading.value = false;
}
};
// 日历面板切换
const handleCalendarPanelChange = (date: any) => {
calendarValue.value = date;
};
// 点击日历日期
const handleCalendarDayClick = async (current: any, dayData: any) => {
const date = dayjs(current).format('YYYY-MM-DD');
const status = dayData.status;
const statusInfo = calendarStatusMap[status];
if (!statusInfo) return;
// 清漂识别直接打开附件预览
if (tabIndex.value === '3') {
const fidStr = dayData.fid;
if (!fidStr) {
return;
}
qingpiaoFids.value = fidStr.split(',').map((s: string) => s.trim()).filter(Boolean);
qingpiaoModalVisible.value = true;
return;
}
// 漂浮物/叠梁门打开视频详情弹窗
detailDate.value = date;
detailType.value = statusInfo.type;
detailDataType.value = statusInfo.dataType;
// 设置 Tab 项
if (tabIndex.value === '1') {
detailTabItems.value = [
{ key: '3', label: '无漂浮物' },
{ key: '4', label: '小面积漂浮物' },
{ key: '5', label: '大面积漂浮物' }
];
} else if (tabIndex.value === '2') {
detailTabItems.value = [
{ key: '2', label: '运行' },
{ key: '1', label: '未运行' }
];
}
detailActiveKey.value = statusInfo.dataType;
detailModalVisible.value = true;
// 获取视频数据
fetchDetailVideoList();
};
// 获取详情视频列表
const fetchDetailVideoList = async () => {
detailVideoLoading.value = true;
try {
const filters: any[] = [
{ field: 'type', operator: 'eq', value: detailType.value },
searchData.value.baseid !== 'all' ? { field: 'baseId', operator: 'eq', value: searchData.value.baseid } : null,
{
field: 'tm',
operator: 'gte',
dataType: 'date',
value: dayjs(detailDate.value).startOf('day').format('YYYY-MM-DD 00:00:00')
},
{
field: 'tm',
operator: 'lte',
dataType: 'date',
value: dayjs(detailDate.value).endOf('day').format('YYYY-MM-DD 23:59:59')
},
{ field: 'dataType', operator: 'eq', value: detailDataType.value }
].filter(Boolean);
const params = {
filter: { logic: 'and', filters },
skip: (detailVideoPage.value - 1) * detailVideoTake.value,
take: detailVideoTake.value
};
const res: any = await getAiFile(params);
const dataObj = res?.data?.data || {};
const rawData = dataObj?.data || [];
detailVideoTotal.value = dataObj?.total || 0;
// 收集所有 fid
const allFids: string[] = [];
rawData.forEach((item: any) => {
if (item.fid) {
item.fid.split(',').forEach((f: string) => {
const trimmed = f.trim();
if (trimmed) allFids.push(trimmed);
});
}
});
// 调用 postIdUrl 获取媒体详情
let mediaMap: Record<string, any> = {};
if (allFids.length > 0) {
const formData = new FormData();
formData.append('id', allFids.join(','));
const mediaRes: any = await postIdUrl(formData);
mediaMap = mediaRes?.data || {};
}
// 按 fid 展开为独立项
const flatList: any[] = [];
rawData.forEach((item: any) => {
const fids = item.fid
? item.fid.split(',').map((f: string) => f.trim()).filter(Boolean)
: [];
const tm = item.tm ? dayjs(item.tm).format('YYYY-MM-DD HH:mm:ss') : '-';
fids.forEach((fid: string) => {
const m = mediaMap[fid];
const ext = m?.ext || '';
const isVideo = ext === '.mp4';
const isImage = ext === '.jpg' || ext === '.png';
flatList.push({
...item,
fid,
media: m || null,
thumbUrl: isImage ? (baseUrl + '?' + fid + '&view=jpg') : '',
isVideo,
isImage,
tm
});
});
});
detailVideoList.value = flatList;
// 默认选中第一条
if (detailVideoList.value.length > 0) {
handleSelectDetailMedia(detailVideoList.value[0]);
}
} catch (error) {
console.error('获取详情视频列表失败:', error);
detailVideoList.value = [];
} finally {
detailVideoLoading.value = false;
}
};
// 选择详情媒体
const handleSelectDetailMedia = (item: any) => {
detailActiveFid.value = item.fid;
const { isVideo, isImage } = item;
if (isImage) {
detailActiveMedia.value = { type: 'image', src: baseUrl + '?' + item.fid + '&view=jpg' };
} else if (isVideo) {
detailActiveMedia.value = { type: 'video', src: baseUrl + '?' + item.fid + '&view=jpg' };
} else {
detailActiveMedia.value = { type: '', src: '' };
}
};
// 详情分页切换
const handleDetailVideoPageChange = () => {
detailActiveFid.value = '';
detailActiveMedia.value = { type: '', src: '' };
fetchDetailVideoList();
};
// ==================== 事件处理 ====================
const handleBaseChange = () => {
pieCode.value = [];
handleSearch();
};
const handleSearch = () => {
getTabList();
getChartData();
videoPage.value = 1;
activeFid.value = '';
activeMedia.value = { type: '', src: '' };
fetchVideoList();
fetchCalendarData();
nextTick(() => {
const filter = buildFilter();
getTableInstance()?.getList(filter);
});
};
const handleViewAttachment = (fid: string) => {
currentAttachmentFid.value = fid;
attachmentModalVisible.value = true;
};
// ==================== 监听基地变化 ====================
watch(
() => JidiSelectEventStore.selectedItem,
newVal => {
if (newVal && newVal.wbsCode) {
searchData.value.baseid = newVal.wbsCode;
}
},
{ deep: true, immediate: true }
);
// ==================== 监听 Tab 切换 ====================
watch(tabIndex, () => {
pieCode.value = [];
// 清空视频状态
videoPage.value = 1;
activeFid.value = '';
activeMedia.value = { type: '', src: '' };
// 销毁旧实例,避免 setOption 到已隐藏的 DOM
if (chartInstance) {
chartInstance.dispose();
chartInstance = null;
}
if (videoChartInstance) {
videoChartInstance.dispose();
videoChartInstance = null;
}
// 等待新 tab DOM 渲染后再请求数据
nextTick(() => {
getChartData();
fetchVideoList();
fetchCalendarData();
nextTick(() => {
const filter = buildFilter();
getTableInstance()?.getList(filter);
});
});
});
// ==================== 窗口 resize ====================
const handleResize = () => {
const dom = chartRefs.value.get(tabIndex.value);
if (dom) {
chartInstance?.resize();
}
};
// ==================== 生命周期 ====================
onMounted(() => {
// 初始化基地选项
const jidiList = JidiSelectEventStore.jidiData || [];
jidiOptions.value = jidiList.map((item: any) => ({
label: item.wbsName,
value: item.wbsCode
}));
getTabList();
getChartData();
fetchVideoList();
fetchCalendarData();
nextTick(() => {
const filter = buildFilter();
getTableInstance()?.getList(filter);
});
window.addEventListener('resize', handleResize);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize);
chartInstance?.dispose();
chartInstance = null;
});
</script>
<style lang="scss" scoped>
.aisbtk-page {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
.search-form {
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 12px;
}
.aisbtk-tabs {
flex: 1;
display: flex;
flex-direction: column;
position: relative;
:deep(.ant-tabs-content) {
height: 100%;
}
:deep(.ant-tabs-tabpane) {
height: 100%;
}
}
.view-switch-buttons {
position: absolute;
right: 0;
top: -60px;
display: flex;
z-index: 10;
.view-btn {
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 10px;
border: 1px solid #ccdae7;
border-radius: 2px;
cursor: pointer;
color: #333;
background: transparent;
transition: all 0.3s;
&:hover {
border-color: #2f6b98;
color: #2f6b98;
}
&.active {
background: #2f6b98;
color: #ffffff;
border-color: #2f6b98;
}
}
}
.view-container {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.chart-table-layout {
display: flex;
gap: 16px;
height: 100%;
.chart-section {
width: 35%;
min-width: 300px;
position: relative;
.chart-container {
width: 100%;
height: 480px;
}
.chart-empty-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
background: rgba(255, 255, 255, 0.8);
}
}
.table-section {
flex: 1;
min-width: 0;
}
.video-section {
flex: 1;
min-width: 0;
}
}
.video-mode {
display: flex;
gap: 12px;
width: 100%;
height: 520px;
.video-main {
flex: 0 0 80%;
background: #000;
border-radius: 4px;
overflow: hidden;
.media-display {
width: 100%;
height: 100%;
}
.media-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
}
.video-sidebar {
flex: 0 0 20%;
display: flex;
flex-direction: column;
border: 1px solid #e8e8e8;
border-radius: 4px;
overflow: hidden;
.sidebar-list {
flex: 1;
overflow-y: auto;
.sidebar-item {
padding: 8px;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
transition: background 0.2s;
&:hover {
background: #f5f5f5;
}
&.active {
background: #e6f7ff;
}
.item-thumb {
width: 100%;
height: 110px;
background: #1a1a1a;
border-radius: 4px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.thumb-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
.no-media {
font-size: 12px;
color: #999;
}
}
}
.item-time {
margin-top: 6px;
font-size: 12px;
color: #666;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
.sidebar-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.sidebar-pagination {
padding: 8px;
text-align: center;
border-top: 1px solid #e8e8e8;
}
}
}
}
</style>