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

1738 lines
47 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="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>