WholeProcessPlatform/frontend/src/modules/liuyu/ai/spdzwaijc/AISPTK.vue
2026-07-14 16:08:10 +08:00

1959 lines
49 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="aisptk-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 v-if="vdListShow !== '3'" label="电站名称">
<a-input
v-model:value="searchData.stnm"
placeholder="请输入电站"
allow-clear
style="width: 180px"
/>
</a-form-item>
<a-form-item v-else label="电站选择">
<a-select
v-model:value="searchData.rstcd"
placeholder="请选择电站"
style="width: 180px"
:options="stcdData"
allow-clear
/>
</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="aisptk-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="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, 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, 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>
</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, 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="video in videoList"
:key="video.fid"
class="sidebar-item"
:class="{ active: activeFid === video.fid }"
@click="handleSelectMedia(video)"
>
<div class="item-thumb">
<img v-if="video.type === 'img'" :src="video.imgPath" />
<div
v-else-if="video.type === 'vdsp'"
class="thumb-placeholder"
>
<img :src="videoCover" />
</div>
<span v-else class="no-media">无媒体</span>
</div>
<div class="item-time">
{{ video.stName }}{{ video.tm }}
</div>
</div>
</div>
<a-empty v-else description="暂无数据" class="sidebar-empty" />
</div>
</div>
<div
class="sidebar-pagination"
style="margin-top: 10px; display: flex; justify-content: end"
>
<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 v-show="vdListShow === '3'" class="calendar-mode">
<a-spin :spinning="calendarLoading">
<a-calendar
v-model:value="calendarValue"
@panelChange="handleCalendarPanelChange"
@select="handleCalendarSelect"
>
<template #headerRender="{ current }"> </template>
<template #dateCellRender="{ current }">
<div class="calendar-content">
<template v-if="isCurrentMonth(current)">
<div
class="content_state1"
v-if="getCalendarDataForDay(current)"
>
监测
</div>
<div class="content_state4" v-else>未监测</div>
<div
v-if="getCalendarDataForDay(current)?.status"
:class="
getStatusClass(getCalendarDataForDay(current)?.status)
"
@click.stop="
handleCalendarDayClick(
current,
getCalendarDataForDay(current)
)
"
>
{{
getStatusText(getCalendarDataForDay(current)?.status)
}}
</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
:teleport="false"
:bodyStyle="{ height: '70vh' }"
>
<VideoDetails
v-if="detailModalVisible"
:baseid="searchData.baseid"
:month="searchData.month"
:type="tabIndex"
:vd-date="detailDate"
:active-key="detailActiveKey"
:stnm="searchData.stnm"
:rstcd="searchData.rstcd"
:pie-code="pieCode"
/>
</a-modal>
</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 VideoDetails from './components/VideoDetails.vue';
import {
getAiGyssList,
getAiChartTable,
getAiRecord,
getAiFile,
getAiRstcd
} 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';
import { useDraggable } from '@/utils/drag';
const baseUrl = import.meta.env.VITE_APP_ATTACHMENT_URL;
// ==================== Props ====================
const props = defineProps<{
month?: string;
action?: string;
baseid?: string;
lyName?: string;
}>();
// ==================== Store ====================
const JidiSelectEventStore = useJidiSelectEventStore();
// ==================== 搜索状态 ====================
const jidiOptions = ref<Array<{ label: string; value: string }>>([]);
const searchData = ref({
baseid: props.baseid || 'all',
stnm: '',
rstcd: '',
month: props.month ? dayjs(props.month) : dayjs()
});
const stcdData = ref<Array<{ label: string; value: string }>>([]);
// ==================== 弹框状态 ====================
const attachmentModalVisible = ref(false);
const currentAttachmentFid = ref('');
// ==================== Tabs 状态 ====================
const tabIndex = ref(props.action ?? 'AI_5006');
const tabList = ref<any[]>([
{ key: 'AI_5006', value: '赶鱼栅运行', unit: '次', count: 0 },
{ key: 'AI_5007', value: props.lyName || '升鱼机运行', unit: '次', count: 0 },
{ key: 'AI_5008', value: 'AGV小车', unit: '次', count: 0 },
{ key: 'AI_5002', value: '运鱼车运行', unit: '次', count: 0 },
{ key: 'AI_5003', value: '运鱼船出港', unit: '次', count: 0 },
{ key: 'AI_5012', 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(searchData.value.month);
const detailModalVisible = ref(false);
const detailActiveKey = ref('2');
const detailDate = ref('');
useDraggable(detailModalVisible, { boundary: true, resetOnOpen: true })
// 日历状态映射(运行/未运行)
const calendarStatusMap: Record<
string,
{ name: string; className: string; type: string; dataType: string }
> = {
'3': {
name: '运行',
className: 'content_state2',
type: '',
dataType: '2'
},
'4': {
name: '未运行',
className: 'content_state3',
type: '',
dataType: '1'
}
};
// 函数式 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;
};
// ==================== 表格列配置 ====================
// 赶鱼栅运行 (AI_5006)
const gysyxColumns = [
{
key: 'stnm',
title: '设施名称',
dataIndex: 'stnm',
fixed: 'left' as const,
width: 150,
ellipsis: true
},
{
key: 'ennm',
title: '所属电站',
dataIndex: 'ennm',
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' }
}
];
// 升鱼机运行 (AI_5007)
const syjyxColumns = [
{
key: 'stnm',
title: '设施名称',
dataIndex: 'stnm',
fixed: 'left' as const,
width: 150,
ellipsis: true
},
{
key: 'ennm',
title: '所属电站',
dataIndex: 'ennm',
width: 120,
ellipsis: true
},
{
key: 'tm',
title: props.lyName ? `${props.lyName}时间` : '升鱼机运行时间',
dataIndex: 'tm',
width: 150,
ellipsis: true,
slots: { customRender: 'tm' }
},
{
key: 'fid',
title: '操作',
dataIndex: 'fid',
width: 100,
ellipsis: true,
slots: { customRender: 'fid' }
}
];
// AGV小车 (AI_5008)
const zwyyhColumns = [
{
key: 'stnm',
title: '设施名称',
dataIndex: 'stnm',
fixed: 'left' as const,
width: 150,
ellipsis: true
},
{
key: 'ennm',
title: '所属电站',
dataIndex: 'ennm',
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' }
}
];
// 运鱼车运行 (AI_5002)
const ycydColumns = [
{
key: 'stnm',
title: '设施名称',
dataIndex: 'stnm',
fixed: 'left' as const,
width: 150,
ellipsis: true
},
{
key: 'ennm',
title: '所属电站',
dataIndex: 'ennm',
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' }
}
];
// 运鱼船出港 (AI_5003)
const yyccgColumns = [
{
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 'AI_5006':
return gysyxColumns;
case 'AI_5007':
return syjyxColumns;
case 'AI_5008':
return zwyyhColumns;
case 'AI_5002':
return ycydColumns;
case 'AI_5003':
return yyccgColumns;
case 'AI_5012':
return zwyyhColumns; // 鱼道放流使用相同列配置
default:
return gysyxColumns;
}
});
// ==================== 构建过滤条件 ====================
const buildFilter = () => {
const filters: any[] = [
{
field: 'type',
operator: 'eq',
value: tabIndex.value
},
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) => {
return {
records: res?.data?.data || [],
total: res?.data?.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 getAiGyssList(params);
const data = res?.data ?? {};
tabList.value = [
{
key: 'AI_5006',
value: '赶鱼栅运行',
unit: '次',
count: data?.fpGysCnt ?? 0
},
{
key: 'AI_5007',
value: props.lyName || '升鱼机运行',
unit: '次',
count: data?.fpSyjCnt ?? 0
},
{
key: 'AI_5008',
value: 'AGV小车',
unit: '次',
count: data?.fpAgvCnt ?? 0
},
{
key: 'AI_5002',
value: '运鱼车运行',
unit: '次',
count: data?.fpYychCnt ?? 0
},
{
key: 'AI_5003',
value: '运鱼船出港',
unit: '次',
count: data?.fpYycCnt ?? 0
},
{
key: 'AI_5012',
value: '鱼道放流',
unit: '次',
count: data?.fpYdhCnt ?? 0
}
];
} catch (error) {
console.error('获取Tab统计失败:', error);
tabList.value = [
{ key: 'AI_5006', value: '赶鱼栅运行', unit: '次', count: 0 },
{
key: 'AI_5007',
value: props.lyName || '升鱼机运行',
unit: '次',
count: 0
},
{ key: 'AI_5008', value: 'AGV小车', unit: '次', count: 0 },
{ key: 'AI_5002', value: '运鱼车运行', unit: '次', count: 0 },
{ key: 'AI_5003', value: '运鱼船出港', unit: '次', count: 0 },
{ key: 'AI_5012', 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,
sort: [
{
field: 'rstcdStepSort',
dir: 'asc'
},
{
field: 'tm',
dir: 'desc'
}
]
};
const res = await getAiChartTable(params);
const dataObj = res?.data?.data ?? {};
const rawData = Array.isArray(dataObj) ? dataObj : dataObj?.data || [];
const list: { name: string; value: number; key: string }[] = [];
if (searchData.value.baseid === 'all') {
rawData.forEach((level1: any) => {
if (level1?.items?.length) {
level1.items.forEach((level2: any) => {
if (level2?.items?.length) {
level2.items.forEach((level3: any) => {
if (level3?.items?.length) {
level3.items.forEach((item: any) => {
list.push({
name: item?.BASENAME || level3.key || '-',
value:
item?.COUNT_BASENAME ||
item?.COUNT_BASEID ||
level3.count ||
0,
key: item?.BASEID || level2.key || ''
});
});
} else {
list.push({
name: level3.key || '-',
value: level3.count || 0,
key: level2.key || ''
});
}
});
}
});
}
});
} else {
rawData.forEach((el: any) => {
list.push({
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 currentTab = tabList.value.find(t => t.key === tabIndex.value);
const titleText = currentTab ? `${currentTab.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 currentTab = tabList.value.find(t => t.key === tabIndex.value);
const titleText = currentTab ? `${currentTab.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 rData = res?.data ?? {};
const rawData = rData?.data || [];
videoTotal.value = rData?.total || rawData.length;
// 按 fid 展开(支持逗号分隔的多个 fid
const expandedData = rawData
.map((item: any) => {
const fids = item.fid ? item.fid.split(',') : [];
return fids.map((fid: string) => ({ ...item, fid: fid.trim() }));
})
.flat();
// 收集所有 fid
const allFids = expandedData
.filter((item: any) => item.fid)
.map((item: any) => item.fid)
.join(',');
// 调用 postIdUrl 获取媒体详情
let mediaMap: Record<string, any> = {};
if (allFids) {
const formData = new FormData();
formData.append('id', allFids);
const mediaRes: any = await postIdUrl(formData);
mediaMap = mediaRes?.data || {};
}
// 构建列表
const list: any[] = [];
expandedData.forEach((vd: any) => {
const fileInfo = mediaMap[vd.fid];
const hasValidExtension =
fileInfo && ['.jpg', '.png'].includes(fileInfo.ext);
let flpth = '';
let imgPath = '';
let type = '';
if (hasValidExtension !== undefined && !hasValidExtension) {
// 非图片(视频)
flpth = fileInfo.fullpath;
type = 'vdsp';
} else if (hasValidExtension !== undefined) {
// 图片
imgPath = fileInfo.fullpath;
type = 'img';
}
const tm = vd.tm ? dayjs(vd.tm).format('YYYY-MM-DD HH:mm:ss') : '-';
list.push({
...vd,
flpth,
imgPath,
flnm: `${vd.ennm || ''}${tm}`,
type,
tm
});
});
// 过滤掉无效项
const listMap = list.filter(
item => item.flpth !== '' || item.imgPath !== ''
);
videoList.value = listMap;
// 默认选中第一条
if (listMap.length > 0 && !activeFid.value) {
handleSelectMedia(listMap[0]);
}
} catch (error) {
console.error('获取视频列表失败:', error);
videoList.value = [];
} finally {
videoLoading.value = false;
}
};
// 选择媒体
const handleSelectMedia = (item: any) => {
activeFid.value = item.fid;
if (item.type === 'img') {
activeMedia.value = {
type: 'image',
src: baseUrl + '?' + item.fid + '&view=jpg'
};
} else if (item.type === 'vdsp') {
activeMedia.value = {
type: 'video',
src: baseUrl + '?' + item.fid + '&view=jpg'
};
} else {
activeMedia.value = { type: '', src: '' };
}
};
// 分页切换
const handleVideoPageChange = () => {
activeFid.value = '';
activeMedia.value = { type: '', src: '' };
fetchVideoList();
};
// ==================== 获取电站列表用于日历视图的rstcd下拉 ====================
const colStcdData = async () => {
try {
const filters: any[] = [
{ field: 'type', operator: 'eq', value: tabIndex.value },
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 res: any = await getAiRstcd(params);
const _data = res?.data?.data ?? [];
const list = _data.map((item: any) => ({
label: item.stnm,
value: item.stcd
}));
// 如果当前选中的rstcd不在新列表中重置为第一个
const selected = list?.filter(
(item: any) => item.value === searchData.value.rstcd
);
if (selected?.length === 0) {
searchData.value.rstcd = list?.[0]?.value ?? '';
}
stcdData.value = list;
} catch (error) {
console.error('获取电站列表失败:', error);
}
};
// ==================== 日历视图函数 ====================
// 判断是否是当前月份
const isCurrentMonth = (current: any): boolean => {
return (
current.month() === calendarValue.value.month() &&
current.year() === calendarValue.value.year()
);
};
// 获取某天的日历数据
const getCalendarDataForDay = (current: any): any => {
return calendarData.value.find(
(item: any) => dayjs(item.dt).format('DD') === current.format('DD')
);
};
// 获取状态样式类
const getStatusClass = (status: number | string) => {
const statusKey = String(status);
return calendarStatusMap[statusKey]?.className || '';
};
// 获取状态文本
const getStatusText = (status: number | string) => {
const statusKey = String(status);
return calendarStatusMap[statusKey]?.name || '';
};
// 获取日历数据
const fetchCalendarData = async () => {
calendarLoading.value = true;
try {
const filters: any[] = [
// baseId过滤支持饼图联动
pieCode.value.length > 0 && searchData.value.baseid === 'all'
? {
field: 'baseId',
operator: 'in',
dataType: 'string',
value: pieCode.value
}
: searchData.value.baseid !== 'all'
? { field: 'baseId', operator: 'eq', value: searchData.value.baseid }
: null,
// rstcd过滤饼图联动时
pieCode.value.length > 0 && searchData.value.baseid !== 'all'
? {
field: 'rstcd',
operator: 'in',
dataType: 'string',
value: pieCode.value
}
: null,
// type过滤
{ field: 'type', operator: 'eq', value: tabIndex.value },
// 时间范围
{
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')
},
// stnm文本搜索
searchData.value.stnm
? { field: 'stnm', operator: 'contains', value: searchData.value.stnm }
: null,
// rstcd电站选择日历视图下
searchData.value.rstcd
? {
field: 'rstcd',
operator: 'in',
dataType: 'string',
value: [searchData.value.rstcd]
}
: null
].filter(Boolean);
const params = {
filter: { logic: 'and', filters },
sort: [{ field: 'tm', dir: 'desc' }]
};
const res: any = await getAiRecord(params);
const payload = res?.data || {};
calendarData.value = Array.isArray(payload) ? payload : payload.data || [];
} catch (error) {
console.error('获取日历数据失败:', error);
calendarData.value = [];
} finally {
calendarLoading.value = false;
}
};
// 日历面板切换
const handleCalendarPanelChange = (date: any) => {
calendarValue.value = date;
fetchCalendarData();
};
// 日历日期选择
const handleCalendarSelect = (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[String(status)];
if (!statusInfo) return;
detailDate.value = date;
detailActiveKey.value = statusInfo.dataType;
detailModalVisible.value = true;
};
// ==================== 事件处理 ====================
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 }
);
// ==================== 监听视图切换 ====================
watch(vdListShow, () => {
if (vdListShow.value === '1') {
fetchVideoList();
getChartData();
} else if (vdListShow.value === '2') {
getChartData();
nextTick(() => {
const filter = buildFilter();
getTableInstance()?.getList(filter);
});
} else if (vdListShow.value === '3') {
colStcdData();
fetchCalendarData();
}
});
// ==================== 监听baseid/tabIndex/month变化更新电站列表 ====================
watch(
() => [searchData.value.baseid, tabIndex.value, searchData.value.month],
() => {
colStcdData();
}
);
// ==================== 监听月份变化,同步日历显示 ====================
watch(
() => searchData.value.month,
(newMonth) => {
calendarValue.value = newMonth;
}
);
// ==================== 监听 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>
.aisptk-page {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
.search-form {
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 12px;
}
.aisptk-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;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
background:#fff;
}
}
.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;
flex-direction: column;
}
.sidebar-pagination {
padding: 8px;
text-align: center;
border-top: 1px solid #e8e8e8;
}
}
}
.calendar-mode {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
height: 60vh;
overflow: auto;
padding-top: 200px;
:deep(.ant-picker-calendar) {
width: 90%;
max-width: 1200px;
}
:deep(.ant-picker-content thead th) {
font-size: 0;
}
:deep(.ant-picker-content thead th)::before {
font-size: 16px;
font-weight: 700;
padding-right: 10px;
}
:deep(.ant-picker-content thead tr th:nth-child(1))::before {
content: '周一';
}
:deep(.ant-picker-content thead tr th:nth-child(2))::before {
content: '周二';
}
:deep(.ant-picker-content thead tr th:nth-child(3))::before {
content: '周三';
}
:deep(.ant-picker-content thead tr th:nth-child(4))::before {
content: '周四';
}
:deep(.ant-picker-content thead tr th:nth-child(5))::before {
content: '周五';
}
:deep(.ant-picker-content thead tr th:nth-child(6))::before {
content: '周六';
}
:deep(.ant-picker-content thead tr th:nth-child(7))::before {
content: '周日';
}
:deep(.ant-picker-calendar) {
.ant-picker-calendar-date-value {
font-size: 18px !important;
padding-right: 8px;
}
.ant-picker-calendar-date {
border: 1px solid rgb(213, 225, 233) !important;
margin: 0px !important;
}
.ant-picker-panel {
.ant-picker-content {
thead {
th {
padding: 6px 0;
border: 1px solid rgb(213, 225, 233) !important;
}
}
td {
padding: 0;
.ant-picker-calendar-date {
padding: 3px;
border: none;
}
&.ant-picker-cell-selected .ant-picker-calendar-date {
background: transparent;
}
&.ant-picker-cell-today .ant-picker-calendar-date::before {
display: none;
}
}
}
}
}
.calendar-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 1px;
padding: 2px;
text-align: right;
.content_state1 {
width: 100%;
background-color: #67c23a;
color: #000;
padding: 2px 0;
border-radius: 3px;
cursor: auto;
padding-right: 8px;
font-size: 12px;
}
.content_state4 {
width: 100%;
background-color: #e6a23c;
padding: 2px 0;
border-radius: 3px;
cursor: auto;
padding-right: 8px;
font-size: 12px;
}
.content_state2 {
width: 100%;
background-color: #e1f3d8;
padding: 2px 0;
border-radius: 3px;
cursor: pointer;
padding-right: 8px;
font-size: 12px;
&:hover {
opacity: 0.8;
}
}
.content_state3 {
width: 100%;
background-color: #faecd8;
padding: 2px 0;
border-radius: 3px;
cursor: pointer;
padding-right: 8px;
font-size: 12px;
&:hover {
opacity: 0.8;
}
}
}
}
.detail-empty {
height: 600px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
}
</style>