first commit

This commit is contained in:
2025-12-24 18:19:05 +08:00
commit 78407f1cbd
283 changed files with 170690 additions and 0 deletions

View File

@@ -0,0 +1,633 @@
<template>
<div :class="['FAQ-container',!historyShow ? 'FAQ-width' : '']">
<div :class="['container']">
<div class="title">
<span class="front">海知无涯智慧伴航千帆智瞰</span>
<span class="behind">引领你探索未知的海域</span>
</div>
<!-- 对话框内容 -->
<DialogRealCom @openVideo="openVideo" @openUav="openUav" v-if="dialog && type === 'real'" :answers="answers"/>
<DialogHistoryCom @openVideo="openVideo" v-if="dialog && type === 'history'" :answers="answers"/>
<div class="introduceRecommend" v-if="!dialog">
<div class="introduce">
<img src="@/assets/images/largeModel/icon-robot.png" alt="">
<div class="content">
<div v-for="(i,index) in describe" :key="index">{{i}}</div>
</div>
</div>
<div class="recommend">
<div class="header">
<div class="title">
<img src="@/assets/images/largeModel/icon-recommend.png" alt="">
推荐
</div>
</div>
<div class="content">
<div class="row" v-for="(item, index) in recommends" :key="index" @click="command(item)">
<div class="desc">{{index+1}}. {{ item }}</div>
</div>
</div>
</div>
</div>
</div>
<div class="input-container">
<!-- 图片预览区域 - 放在输入框上方 -->
<div v-if="previewImages.length > 0" class="image-preview-inline">
<div class="image-item" v-for="(image, index) in previewImages" :key="index">
<img :src="image.url" alt="预览图片" />
<el-icon class="image-actions" @click="removeImage(index)"><Close /></el-icon>
</div>
</div>
<el-input
v-model="message"
placeholder="点这里,尽管问"
:autosize="{ minRows: 2, maxRows: 10 }"
type="textarea"
@keyup.enter="send"
/>
<div class="input-actions">
<!-- <div class="button" @click="startSpeechRecognition" title="语音输入">
<img src="@/assets/images/largeModel/icon-voice.png" alt="">
</div> -->
<!-- <div class="button" title="上传图片">
<img src="@/assets/images/largeModel/icon-upload.png" alt="" @click="triggerFileInput">
<input
ref="fileInput"
type="file"
accept="image/*"
multiple
@change="handleFileUpload"
style="display: none;"
/>
</div> -->
<div class="button" @click="handleClick" :style="loading ? {background: 'linear-gradient(90deg, #1d91fe85 0%, #536ce8ab 100%)'} : {}">
<el-icon class="loading" v-if="loading"><Loading /></el-icon>
<img v-else src="@/assets/images/largeModel/icon-send.png" alt="">
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue'
import DialogRealCom from './realDialog.vue'
import DialogHistoryCom from './historyDialog.vue'
import { dayjs } from 'element-plus'
import { Close, Loading } from '@element-plus/icons-vue'
import { getAnswers, restart } from '@/api/model.js'
import axios from 'axios'
const emit = defineEmits([ 'initList', 'openVideo', 'openUav' ])
const props = defineProps({
historyShow: {
type: Boolean,
default: true
}
})
// 添加流式输出相关变量
const answersBuffer = ref('')
const isStreaming = ref(false)
// 添加文件上传相关的响应式变量
const fileInput = ref(null)
const previewImages = ref([])
const selectedFiles = ref([])
const getPeriod = computed(() => {
const hour = dayjs().hour()
if (hour >= 0 && hour < 6) {
return '凌晨'
}
if (hour >= 6 && hour < 12) {
return '上午'
}
if (hour >= 12 && hour < 18) {
return '下午'
}
return '晚上'
})
const message = ref('')
const describe = ref([])
const recommends = ref()
const loading = ref(false)
// 对话框
const dialog = ref(false)
const type = ref('real')
const answers = reactive({})
// 语音录入
let speechRecognition = null
const isListening = ref(false)
let resizeObserver = null
const openVideo = (data) => {
emit('openVideo', data)
}
const openUav = (data) => {
emit('openUav', data)
}
// 触发文件选择
const triggerFileInput = () => {
fileInput.value.click()
}
// 处理文件上传
const handleFileUpload = (event) => {
const files = event.target.files
if (files.length === 0) {
return
}
// 验证文件类型
for (let i = 0; i < files.length; i++) {
const file = files[i]
if (file.type.startsWith('image/')) {
// 创建预览
const reader = new FileReader()
reader.onload = (e) => {
previewImages.value.push({
url: e.target.result,
name: file.name,
size: file.size
})
}
reader.readAsDataURL(file)
// 存储原始文件
selectedFiles.value.push(file)
} else {
alert(`请选择有效的图片文件: ${file.name}`)
}
}
}
// 移除单个图片
const removeImage = (index) => {
previewImages.value.splice(index, 1)
selectedFiles.value.splice(index, 1)
if (fileInput.value) {
fileInput.value.value = ''
}
}
// 清除所有图片
const clearAllImages = () => {
previewImages.value = []
selectedFiles.value = []
if (fileInput.value) {
fileInput.value.value = ''
}
}
// 点击推荐
const command = (item) => {
message.value = item
nextTick(() => {
send()
})
}
// 终止回答
const stop = () => {
axios.post(restart, {}, {
headers: {
'Content-Type': 'application/json'
}
}).then(() => {
}).finally(() => {
loading.value = false
})
}
const send = () => {
if(!loading.value && (message.value !== '' || previewImages.value.length > 0)) {
loading.value = true
answersBuffer.value = ''
isStreaming.value = true
// 先清空
Object.keys(answers).forEach(key => {
answers[key] = ''
})
dialog.value = true
type.value = 'real'
// 请求问答
let params = { query: message.value }
answers.query = message.value
answers.previewImages = previewImages.value
if(previewImages.value.length > 0) {
params = { query: '查看这艘船的告警信息' }
}
fetch(getAnswers, {
method: 'POST',
body: JSON.stringify(params),
headers: {
'Content-Type': 'application/json' // 根据实际情况设置请求头
},
responseType: 'stream'
}).then(async(res) => {
// 判断响应是否正常
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`)
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
let result = true
try {
while (result) {
const { done, value } = await reader.read()
if (done) {
result = false
break
}
// 解码并累加到缓冲区
decoder.decode(value).split('\n')
.forEach((val) => {
if (!val) {
return
}
try {
// 后端返回的流式数据一般都是以data:开头的字符排除掉data:后就是需要的数据
// 具体返回结构可以跟后端约定
let txt = val?.replace('data:', '') || ''
let data = JSON.parse(txt)
if(data?.content) {
answersBuffer.value += data.content
}else if(data?.mp4_url || data?.image_url || data?.rtsp_url || data?.rtsp_data) {
let additionalContent = ''
if(data.image_url && Array.isArray(data.image_url)) {
additionalContent += '\n' + data.image_url.join(' ') + '\n'
}
if(data.mp4_url && Array.isArray(data.mp4_url)) {
additionalContent += '\n' + data.mp4_url.join(' ') + '\n'
}
if(data.rtsp_url && Array.isArray(data.rtsp_url)) {
console.log('data.rtsp_url', data.rtsp_url)
data.rtsp_url.forEach(item => {
additionalContent += '\n' + JSON.stringify(item) + '\n'
})
}
if(data.rtsp_data && Array.isArray(data.rtsp_data)) {
console.log('data.rtsp_data', data.rtsp_data)
data.rtsp_data.forEach(item => {
additionalContent += '\n' + JSON.stringify(item) + '\n'
})
}
answersBuffer.value += additionalContent
}else{
answersBuffer.value += ''
}
if(data.clear_previous) {
answersBuffer.value = ''
}
answers.answer = answersBuffer.value
nextTick(() => {
})
} catch (err) { }
})
// 更新答案内容
}
} catch (error) {
answers.answer = '抱歉,获取回答时出现错误。'
loading.value = false
} finally {
reader.releaseLock() // 显式释放 reader 锁
loading.value = false
isStreaming.value = false
}
}).catch(error => {
console.error('请求错误:', error)
loading.value = false
isStreaming.value = false
answers.answer = '抱歉,获取回答时出现错误。'
}).finally(() => {
loading.value = false
message.value = ''
previewImages.value = []
selectedFiles.value = []
clearAllImages()
emit('initList')
})
}
}
const handleClick = () => {
if(loading.value) {
stop()
}else {
send()
}
}
// 初始化语音识别
const initSpeechRecognition = () => {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition
if (!SpeechRecognition) {
console.warn('当前浏览器不支持语音识别功能')
return null
}
const recognition = new SpeechRecognition()
recognition.lang = 'zh-CN' // 设置语言为中文
recognition.continuous = false // 设置为单次识别
recognition.interimResults = true // 获取中间结果
recognition.onstart = () => {
isListening.value = true
console.log('语音识别已启动')
}
recognition.onresult = (event) => {
const transcript = Array.from(event.results)
.map(result => result[0])
.map(result => result.transcript)
.join('')
message.value = transcript
console.log('识别结果:', transcript)
}
recognition.onerror = (event) => {
console.error('语音识别出错:', event.error)
isListening.value = false
}
recognition.onend = () => {
isListening.value = false
console.log('语音识别已结束')
}
return recognition
}
// 开始语音识别
const startSpeechRecognition = () => {
// if (!checkSpeechRecognitionSupport()) {
// alert('当前浏览器不支持语音识别功能请使用Chrome或其他支持Web Speech API的浏览器')
// return
// }
if (!speechRecognition) {
speechRecognition = initSpeechRecognition()
}
if (isListening.value) {
// 如果正在监听,则停止
speechRecognition.stop()
isListening.value = false
} else {
// 开始语音识别
try {
speechRecognition.start()
} catch (error) {
console.error('启动语音识别失败:', error)
}
}
}
const changeDialog = (item) => {
dialog.value = item.flag
type.value = 'history'
if(item.data) {
Object.keys(JSON.parse(item.data)).forEach(key => {
answers[key] = JSON.parse(item.data)[key]
})
}
loading.value = false
}
const changeRecommend = (data) => {
describe.value = data.desc
recommends.value = data.recommends
}
const observeContainerResize = () => {
// 查找目标容器元素
const targetElement = document.querySelector('.large-model-container')
if (targetElement) {
// 创建 ResizeObserver 实例
resizeObserver = new ResizeObserver((entries) => {
for (let entry of entries) {
const height = entry.contentRect.height
if (height < 585) {
document.querySelector('.introduce').style.display = 'none'
}else{
document.querySelector('.introduce').style.display = 'flex'
}
if (height < 443) {
document.querySelector('.recommend').style.display = 'none'
}else{
document.querySelector('.recommend').style.display = 'block'
}
}
})
// 开始观察目标元素
resizeObserver.observe(targetElement)
} else {
console.log('未找到 .large-model-container 元素')
}
}
onMounted(() => {
nextTick(() => {
observeContainerResize()
})
})
onUnmounted(() => {
if (resizeObserver) {
resizeObserver.disconnect()
}
})
defineExpose({
changeDialog,
changeRecommend
})
</script>
<style lang="scss" scoped>
.FAQ-container {
position: relative;
width: calc(100% - 200px);
box-sizing: border-box;
font-family: 'SHSCNR';
&.FAQ-width{
width: 100%
}
.title{
font-size: 14px;
font-weight: 500;
display: inline;
.front{
color: #FFFFFF;
}
.behind{
color: #42D9FF;
}
}
.container{
width: 100%;
margin: 10% auto;
display: flex;
flex-direction: column;
gap: 20px;
height: calc(100% - 220px);
margin: 0 auto;
background: linear-gradient( 180deg, rgba(2,25,66,0.9) 1%, rgba(2,25,65,0.5) 99%);
border-radius: 5px 5px 5px 5px;
border: 1px solid;
border-image: linear-gradient(180deg, rgba(0, 228, 236, 0.5), rgba(79, 139, 152, 0.1)) 1 1;
margin-bottom: 10px;
padding: 20px;
box-sizing: border-box;
.introduceRecommend{
height: calc(100% - 200px);
display: flex;
flex-direction: column;
gap: 20px;
}
.introduce{
display: flex;
}
.introduce img{
width: 16px;
height: 16px;
margin-right: 10px;
}
.introduce .content{
font-weight: 400;
font-size: 14px;
line-height: 1.8;
// text-indent: 2em;
padding: 6px;
transition: all 0.3s ease;
background: rgba(89,175,255,0.15);
border-radius: 3px;
color: #fff;
}
.introduce.content:hover {
transform: scale(1.02);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.recommend,.describe{
margin-top: 40px;
.header{
display: flex;
justify-content: space-between;
align-items: center;
.title{
font-weight: bold;
font-size: 16px;
color: #42D9FF;
display: flex;
align-items: center;
gap: 5px;
}
}
.content{
display: flex;
margin-top: 20px;
width: 100%;
justify-content: space-between;
.row{
width: 23%;
img{
width: 100%;
height: 120px;
border-radius: 16px;
border: 2px solid #3B80FF;
}
.title{
font-weight: 500;
font-size: 13px;
color: #fff;
margin: 10px 0;
}
.desc{
font-weight: 400;
text-align: left;
font-size: 14px;
color: rgba(255, 255, 255, 0.7);
line-height: 24px;
margin-bottom: 6px;
}
}
}
}
.recommend{
margin-top: 10px;
.content{
display: block;
.row{
width: 100%;
cursor: pointer;
}
}
}
}
// 图片预览样式 - 内联显示
.image-preview-inline {
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 10px;
border-radius: 8px;
.image-item {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
width: 80px;
height: 80px;
img {
width: 80px;
height: 80px;
object-fit: cover;
border-radius: 4px;
}
.image-actions {
position: absolute;
right: 0;
background: #bbb6b6;
border-radius: 10px;
color: #fff;
font-size: 10px;
padding: 4px;
cursor: pointer;
}
}
}
// 输入框
.input-container{
width: 100%;
min-height: 200px;
background: linear-gradient( 180deg, rgba(2,25,66,0.9) 1%, rgba(2,25,65,0.5) 99%);
border-radius: 5px 5px 5px 5px;
border: 1px solid;
border-image: linear-gradient(180deg, rgba(0, 228, 236, 0.5), rgba(79, 139, 152, 0.1)) 1 1;
position: absolute;
bottom: 0;
:deep(.el-textarea__inner){
box-shadow: none;
border-radius: 5px;
background: transparent;
}
.input-actions{
display: flex;
gap: 10px;
justify-content: flex-end;
position: absolute;
right: 10px;
bottom: 0;
.button{
width: 24px;
height: 24px;
line-height: 36px;
cursor: pointer;
}
.button .loading{
color: #fff;
animation: rotate 2s linear infinite;
}
.button:last-child{
&:hover {
transform: scale(1.05);
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
}
}
}
}
}
// 定义旋转动画
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -0,0 +1,197 @@
<template>
<div :class="['history-container',show ? '' : 'history-hidden-container']">
<div class="list">
<div class="title" @click="toggleExpand">
<div>聊天历史</div>
<img @click="show = !show" src="@/assets/images/largeModel/icon-collapse.png" alt="">
</div>
<ul class="list-wrapper" v-if="list.length">
<li :class="['list-item', item.checked ? 'active' : '']" v-for="(item, index) in list" :key="index" @click="handle(item)">
<span :title="item.query" class="label">{{ item.query }}</span>
</li>
</ul>
</div>
<div class="border">
<img
:class="[show ? 'history-collapse' : 'history-expand']"
src="@/assets/images/largeModel/icon-collapse.png" alt=""
@click="show = !show">
</div>
</div>
</template>
<script setup>
import { nextTick, onMounted, onUnmounted, ref } from 'vue'
// import { getHistory } from '@/api/model'
import axios from 'axios'
const emit = defineEmits([ 'changeDialog' ])
// 聊天历史显示隐藏
const collapse = ref(true)
const list = ref([])
// 侧边栏显示隐藏
const show = ref(true)
let resizeObserver = null
const initList = () => {
// axios.get(getHistory, {}, {
// headers: {
// 'Content-Type': 'application/json'
// }
// }).then((res) => {
// if(res.status === 200) {
// list.value = res.data.reverse()
// }
// })
}
const toggleExpand = () => {
collapse.value = !collapse.value
if(collapse.value) {
initList()
}else{
list.value = []
}
}
const handle = (item) => {
const data = {
...item,
query: item.query,
items: item.result.items
}
emit('changeDialog', { flag: true, data: JSON.stringify(data) })
}
const observeContainerResize = () => {
// 查找目标容器元素
const targetElement = document.querySelector('.large-model-container')
if (targetElement) {
// 创建 ResizeObserver 实例
resizeObserver = new ResizeObserver((entries) => {
for (let entry of entries) {
const width = entry.contentRect.width
// 如果宽度小于825px隐藏侧边栏
if (width < 825) {
show.value = false
}else{
show.value = true
}
}
})
// 开始观察目标元素
resizeObserver.observe(targetElement)
} else {
console.log('未找到 .large-model-container 元素')
}
}
onMounted(() => {
initList()
nextTick(() => {
observeContainerResize()
})
})
onUnmounted(() => {
if (resizeObserver) {
resizeObserver.disconnect()
}
})
defineExpose({
show: show,
initList: initList
})
</script>
<style lang="scss" scoped>
.history-container{
width: 200px;
display: flex;
flex-direction: column;
padding: 10px 16px;
box-sizing: border-box;
position: relative;
transition: all 0.3s ease-in-out;
overflow: hidden;
background: linear-gradient( 180deg, rgba(7,52,132,0.9) 1%, rgba(5,47,121,0.5) 99%);
border-radius: 5px 5px 5px 5px;
border: 1px solid;
border-image: linear-gradient(180deg, rgba(0, 228, 236, 0.5), rgba(79, 139, 152, 0.1)) 1 1;
&.history-hidden-container{
width: 0;
padding: 0;
transition: all 0.3s ease-in-out;
}
.list{
font-family: 'SHSCNR';
height: 100%;
.title{
display: flex;
justify-content: space-around;
align-items: center;
cursor: pointer;
font-size: 12px;
color: #FFFFFF;
height: 36px;
background: rgba(111,210,253,0.15);
border-radius: 20px;
.expand{
transition: all 0.3s;
transform: rotate(180deg);
}
}
.list-wrapper{
width: 100%;
display: flex;
margin-top: 20px;
flex-direction: column;
height: calc(100% - 50px);
overflow: auto;
gap: 20px;
font-size: 12px;
color: #FFFFFF;
letter-spacing: 1px;
.list-item{
display: flex;
align-items: center;
cursor: pointer;
color: #838AA4;
z-index: 1;
&.active{
opacity: 1;
}
.label{
font-size: 12px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
}
}
.bottom{
position: absolute;
bottom: 0;
right: 0;
}
}
.border{
width: 20px;
.history-collapse{
position: absolute;
right: 160px;
bottom: 40px;
cursor: pointer;
transition: all 0.3s ease-in-out;
transform: translateX(0);
}
.history-expand{
position: absolute;
right: 10px;
bottom: 20px;
cursor: pointer;
z-index: 2;
transition: all 0.3s ease-in-out;
transform: translateX(0);
}
}
</style>

View File

@@ -0,0 +1,233 @@
<template>
<div class="dialog">
<div class="item" v-for="item in list" :key="item.id">
<div class="file">
<el-image
v-for="(i, index) in item.previewImages"
:key="index"
:src="i.url"
:zoom-rate="1.2"
:max-scale="7"
:min-scale="0.2"
:scale="0.8"
:preview-src-list="item.previewImages.map(i=>i.url)"
show-progress
:initial-index="4"
fit="object-fit"
/>
</div>
<div class="user">
{{ item.query }}
</div>
<div v-if="item.items && item.items.length>0" class="question">
<img src="@/assets/images/largeModel/icon-robot.png" alt="">
<div class="content-wrapper" v-for="(i, iindex) in item.items" :key="iindex">
<div class="content" v-html="markdownToHtml(i.content)"></div>
<div class="monitor" v-for="rtsp in i.rtsp_url" :key="rtsp.video_name" @click="openVideoHandler(rtsp)">
{{ rtsp.video_name }}
</div>
<el-image
v-for="(img, imgIndex) in i.image_url"
:key="imgIndex"
:src="img"
:zoom-rate="1.2"
:max-scale="7"
:min-scale="0.2"
:scale="0.8"
:preview-src-list="i.image_url"
show-progress
:initial-index="4"
fit="object-fit"
/>
<video
v-for="(mp4, mp4Index) in i.mp4_url"
autoplay
controls
class="image-item"
:key="mp4Index"
loop
muted
:src="mp4"
style="object-fit: fill;">
</video>
</div>
</div>
<div class="loading" v-else>
<span class="dot" v-for="i in 3" :key="i"></span>
</div>
</div>
</div>
</template>
<script setup>
import { nextTick, onMounted, ref } from 'vue'
import { marked } from 'marked'
const emit = defineEmits([ 'openVideo' ])
const props = defineProps({
answers: {
type: Object,
default: () => {
return {}
}
}
})
const list = ref([])
const videoArr = ref([])
const codes = ref([])
const visible = ref(false)
const initData = () => {
list.value = [ props.answers ]
const videos = []
// 提取 items 中的所有视频相关信息
if (props.answers.items && Array.isArray(props.answers.items)) {
props.answers.items.forEach(i => {
if (i.rtsp_url && Array.isArray(i.rtsp_url)) {
i.rtsp_url.forEach((url, index) => {
videos.push({ ...url })
})
}
})
}
videoArr.value = videos
}
// 添加 Markdown 转 HTML 的方法
const markdownToHtml = (markdown) => {
if (!markdown) {
return ''
}
// 配置和转换逻辑
return marked.parse(markdown)
}
const openVideoHandler = (data) => {
// 雷云一体机一个视频
if(data.video_name.indexOf('雷云') !== -1) {
const videos = videoArr.value.filter(i => i.video_name.indexOf('雷云') !== -1).sort((a, b) => {
const index1 = a.video_name[a.video_name.length - 1]
const index2 = b.video_name[b.video_name.length - 1]
return index1 - index2
})
codes.value = [ videos[0].video_code ]
}else{
if(data.video_name.indexOf('球机') !== -1) {
const videos = videoArr.value.filter(i => i.video_name.indexOf('球机') !== -1).sort((a, b) => {
const index1 = a.video_name[a.video_name.length - 1]
const index2 = b.video_name[b.video_name.length - 1]
return index1 - index2
})
codes.value = videos.map((i, index) => {
return i.rtsp_url
})
}else{
codes.value = [ data.video_code ]
}
}
nextTick(() => {
setTimeout(() => {
visible.value = true
emit('openVideo', { codes: codes.value, visible: visible.value })
// 再次延迟后触发窗口大小调整事件模拟resize
setTimeout(() => {
window.dispatchEvent(new Event('resize'))
}, 100)
}, 50)
})
}
onMounted(() => {
initData()
})
</script>
<style lang="scss" scoped>
.dialog{
height: calc(100% - 50px);
overflow: auto;
.item{
display: flex;
flex-direction: column;
margin-bottom: 20px;
.user,.file{
color: #E1F1FA;
max-width: calc(100% - 88px);
padding: 10px 16px;
font-size: 14px;
line-height: 24px;
width: fit-content;
margin-left: auto;
background: rgba(89,175,255,0.15);
border-radius: 3px 3px 3px 3px;
}
.file{
background: none;
padding-right: 0;
.el-image{
width:100px;
height: 100px;
margin-left: 10px;
cursor: pointer;
}
}
.question{
color: #E1F1FA;
border-radius: 22px;
max-width: calc(100% - 88px);
padding: 10px 0;
font-size: 14px;
line-height: 24px;
width: fit-content;
margin-right: auto;
text-align: left;
display: flex;
.content-wrapper{
background: rgba(89,175,255,0.15);
border-radius: 3px 3px 3px 3px;
padding: 6px;
}
img{
width: 16px;
height: 16px;
margin-right: 10px;
}
.content{
margin-bottom: 10px;
}
.el-image,video{
width: 260px;
margin-right: 10px;
height: 150px;
cursor: pointer;
}
}
.loading {
color: #0C1533;
border-radius: 22px;
max-width: calc(100% - 88px);
padding: 10px 16px;
font-size: 16px;
line-height: 24px;
width: fit-content;
margin-right: auto;
text-align: left;
}
.dot {
display: inline-block;
width: 6px;
height: 6px;
background-color: #ccc;
border-radius: 50%;
margin: 0 2px;
animation: dot-animation 1.5s infinite ease-in-out;
}
.monitor{
color:#1D91FE ;
cursor:pointer
}
@keyframes dot-animation {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
}
}
</style>

View File

@@ -0,0 +1,389 @@
<template>
<DialogComponent title="智能体问答" :center="true" class="largeModel-dialog" :style="{ resize: 'both', overflow: 'auto' }" width="1500" :draggable="true" :modal="false" @close="close">
<div class="large-model-container">
<ModuleCom ref="ModuleLeft" @changeRecommend="changeRecommend" @changeDialog="changeDialog"/>
<FAQCom ref="FAQ" :historyShow="historyShow" @initList="initList" @openVideo="openVideo" @openUav="openUav"/>
<HistoryCom ref="History" @changeDialog="changeDialog"/>
</div>
</DialogComponent>
<!-- 监控视频弹窗 -->
<DialogComponent class="video-dialog" style="height: 800px;" :visible="visible" width="1400" :draggable="true" @close="closePlayer" @mousedown.stop="drag">
<div class="video-wrapper" v-if="visible">
<div :class="[`video-windowlargeModel${index}`,'player-wrapper']" v-for="(item,index) in codes" :key="index">
<FlvPlayerComponent :id="'largeModel'+index+1" v-if="codes.length>1" :url="item"/>
<HikPlayerComponent v-else ref="HikLargeModel" :cameraIndexCode="item" :id="'largeModel'+index" :item="index"/>
</div>
</div>
</DialogComponent>
<!-- 无人机飞控弹窗 -->
<DialogComponent class="uav-largemodel-dialog" v-if="uavDialog.visible" title="" width="1900" :draggable="true" :modal="false" @close="uavDialog.visible=false">
<div class="content-wrapper">
<div class="left-wrapper">
<div class="uav-button" @click="handleAlgorithm">{{algorithmStatus ? '关闭' : '开启'}}算法</div>
<div class="Form">
<el-checkbox-group v-model="algorithmValue">
<el-checkbox
v-for="item in algorithms"
:key="item.value"
:label="item.label"
:value="item.value" />
</el-checkbox-group>
</div>
<div class="flv-container"><FlvPlayerComponent v-if="uavDialog.url" id="uav" :url="uavDialog.url"/></div>
</div>
<div class="resize-handle" @mousedown="startResize"></div>
<div class="right-wrapper">
<CockpitCom v-if="uavDialog.visible"/>
</div>
</div>
</DialogComponent>
</template>
<script setup>
import DialogComponent from '@/components/Dialog/largeModel.vue'
import ModuleCom from './module.vue'
import HistoryCom from './history.vue'
import FAQCom from './FAQ.vue'
import CockpitCom from '@/views/business/drone/cockpit.vue'
import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue'
import { initResizeDetection, cleanupAndRestoreHik
} from './resize'
import HikPlayerComponent from '@/components/Player/HikPlayer.vue'
import FlvPlayerComponent from '@/components/FlvPlayer/index.vue'
import { dragEvent } from '@/utils/common'
// import { getVideoStream, doStartOrStopUavAlgorithm } from '@/api/uav'
import { ElMessage } from 'element-plus'
import useMapStore from '@/store/modules/map'
const mapStore = useMapStore()
const uavData = computed(() => mapStore.uavs.data)
const ModuleLeft = ref(null)
const FAQ = ref(null)
const History = ref(null)
const historyShow = computed(() => History.value?.show)
const visible = ref(false)
const codes = ref([])
const HikLargeModel = ref(null)
// 算法开启关闭状态
const algorithmStatus = ref(false)
const algorithms = [
{ value: 120000, label: 'AIS挂牌/船牌识别' },
{ value: 140000, label: '船型识别' },
{ value: 139000, label: '未穿救生衣' },
{ value: 140009, label: '未悬挂国旗' },
{ value: 140008, label: '未封舱' }
]
const algorithmValue = ref([])
const uavDialog = reactive({
visible: false,
url: ''
})
let cleanupFunction = null
// 添加分割线相关的响应式数据
const isResizing = ref(false)
const leftWidth = ref('462px')
const rightWidth = ref('1391px')
const containerWidth = ref(0)
let containerBoundsLeft = 0 // 容器左边界
const closePlayer = () => {
codes.value = []
visible.value = false
}
const changeDialog = (flag) => {
FAQ.value.changeDialog(flag)
History.value.initList()
}
// 模块介绍/模块推荐问题
const changeRecommend = (data) => {
FAQ.value.changeRecommend(data)
}
const initList = () => {
History.value.initList()
}
const close = () => {
mapStore.updateLargeModel(false)
}
const openVideo = (data) => {
visible.value = data.visible
nextTick(() => {
codes.value = data.codes
})
}
// 开启无人机
const openUav = (data) => {
uavDialog.visible = true
uavDialog.url = ''
setTimeout(() => {
const params = {
status: 'stop',
sn: data.droneSn,
enable_orc: false
}
// getVideoStream(params).then(res => {
// if(res.success) {
// setTimeout(() => {
// uavDialog.url = res.result.httpUrl
// }, 10000)
// }
// })
}, 20000)
}
// 开启/关闭算法
const handleAlgorithm = () => {
if(algorithmStatus.value) {
closeAlgorithm()
} else {
let enable_orc = false
const valueArray = [ ...algorithmValue.value ]
const index = valueArray.indexOf(120000)
if (index > -1) {
valueArray.splice(index, 1)
enable_orc = true
}
const params = {
class_codes: valueArray.join(','),
enable_orc,
status: 'start',
sn: uavData.value.droneSn
}
// doStartOrStopUavAlgorithm(params).then(res => {
// if(res.success) {
// algorithmStatus.value = true
// ElMessage.success('算法已开启')
// }else {
// ElMessage.error(res.result)
// }
// })
}
}
// 算法关闭
const closeAlgorithm = () => {
const params = {
status: 'stop',
sn: uavData.value.droneSn
}
// doStartOrStopUavAlgorithm(params).then(res => {
// if(res.success) {
// algorithmStatus.value = false
// algorithmValue.value = []
// ElMessage.success(res.result)
// }else {
// ElMessage.error(res.result)
// }
// })
}
/**
* 拖拽事件
* @param e
*
*/
const drag = (e) => {
dragEvent(e, 'el-dialog', () => {
HikLargeModel.value[0].initResize(false)
})
}
onMounted(() => {
mapStore.updateHik({ level: 0, data: { left: 0, top: 0, width: 1000, height: 565 } })
// 初始化resize检测传入store实例
cleanupFunction = initResizeDetection(mapStore)
})
onUnmounted(() => {
mapStore.updateHik({ level: 1, data: { left: 0, top: 0, width: 1000, height: 565 } })
// 清理resize检测并恢复海康监控
cleanupAndRestoreHik(mapStore)
// 调用清理函数
if (cleanupFunction) {
cleanupFunction()
}
if(algorithmStatus.value) {
closeAlgorithm()
}
})
// 添加分割线控制相关方法
const startResize = (e) => {
isResizing.value = true
const containerRect = e.target.parentElement.getBoundingClientRect()
containerWidth.value = containerRect.width
// 保存容器的左边界位置
containerBoundsLeft = containerRect.left
document.addEventListener('mousemove', resize)
document.addEventListener('mouseup', stopResize)
}
const resize = (e) => {
if (isResizing.value) {
// 使用预先保存的容器左边界
const offsetX = e.clientX - containerBoundsLeft
// 计算左侧宽度百分比
let leftPercent = offsetX / containerWidth.value * 100
// 限制左右两侧的最小宽度
const minRightWidth = 1391
const minLeftWidth = 200
// 计算右侧最小百分比
const minRightPercent = minRightWidth / containerWidth.value * 100
const minLeftPercent = minLeftWidth / containerWidth.value * 100
// 限制拖动范围
if (leftPercent < minLeftPercent) {
leftPercent = minLeftPercent
} else if (leftPercent > 100 - minRightPercent) {
leftPercent = 100 - minRightPercent
}
leftWidth.value = `${leftPercent}%`
rightWidth.value = `${100 - leftPercent}%`
}
}
const stopResize = () => {
isResizing.value = false
document.removeEventListener('mousemove', resize)
document.removeEventListener('mouseup', stopResize)
}
</script>
<style lang="scss" scoped>
.large-model-container{
width: 100%;
height: 100%;
display: flex;
gap: 16px;
padding: 30px 0px 22px;
box-sizing: border-box;
}
.video-wrapper {
display: flex;
// gap: 7px;
width: 100%;
height: 100%;
.player-wrapper {
flex: 1;
flex-shrink: 0;
overflow: hidden;
}
}
:deep(table){
border: 1px solid #858aa2;
border-collapse: collapse;
th,td{
border: 1px solid #858aa2;
text-align: center;
padding: 0 10px;
}
th{
background-color: #274460;
}
}
// 无人机弹窗
.content-wrapper{
display: flex;
position: relative;
height: 100%;
video{
width: 100%;
height: 830px;
object-fit: fill;
}
// //播放按钮
video::-webkit-media-controls-play-button{display: none;}
//进度条
video::-webkit-media-controls-timeline{display: none;}
//观看的当前时间
video::-webkit-media-controls-current-time-display{display: none;}
//剩余时间
video::-webkit-media-controls-time-remaining-display{display: none;}
//音量按钮
video::-webkit-media-controls-mute-button{display: none;}
video::-webkit-media-controls-toggle-closed-captions-buttonf{display: none;}
//1音量的控制条
video::-webkit-media-controls-volume-slider{display: none;}
video::-webkit-media-controls-enclosuret{display: none;}
.uav-button{
width: fit-content;
cursor: pointer;
color: #00c0ff;
padding: 6px 10px;
background: rgba(22, 119, 255, 0.21);
border-radius: 2px 2px 2px 2px;
border: 1px solid #236ACE;
color: rgba(255, 255, 255, 0.9);
}
.left-wrapper{
// width: 60%;
width: v-bind(leftWidth);
height: 830px;
display: flex;
flex-direction: column;
gap: 10px;
.el-checkbox-group{
gap: 4px !important;
.el-checkbox{
width: 150px !important;
padding-left: 5px !important;
}
}
.flv-container{
flex: 1; // 占据剩余空间
overflow: hidden; // 隐藏溢出内容
}
}
.resize-handle {
width: 8px;
height: 100%;
background: transparent;
cursor: col-resize;
position: relative;
user-select: none;
z-index: 10;
&::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 2px;
// height: 40px;
background: #fff;
}
&:hover {
background: transparent;
}
}
.right-wrapper{
// width: 40%;
width: v-bind(rightWidth);
height: 830px;
// position: absolute;
// right: 0;
flex: 1;
}
.temporary{
position: absolute;
right: 0'';
right: 0;
top: 48px;
z-index: 999;
width: 1069px;
height: 429px;
}
}
</style>
<style>
.video-dialog.dialog-wrapper.el-dialog .el-dialog__body{
height: calc(100% - 56px);
}
.uav-largemodel-dialog.el-dialog{
height: 1000px !important;
margin-bottom: 10px;
}
</style>

View File

@@ -0,0 +1,231 @@
<template>
<div :class="['module-container',show ? '' : 'module-hidden-container']">
<div class="create" @click="create">
<el-icon><Plus /></el-icon>
新建对话
</div>
<div class="list">
<ul class="list-wrapper" v-if="list.length">
<li :class="['list-item', item.checked ? 'active' : '']" v-for="(item, index) in list" :key="index" @click="handle(item)">
<span :title="item.label" class="label">{{ item.label }}</span>
</li>
</ul>
</div>
</div>
</template>
<script setup>
import { Plus } from '@element-plus/icons-vue'
import { nextTick, onMounted, onUnmounted, ref } from 'vue'
const emit = defineEmits([ 'changeDialog' ])
const list = ref([
{ label: 'CCTV监控调取查看', checked: true,
desc: [ '支持自然语言提问调取查看任意CCTV监控点位。' ],
recommends: [
'查看江润大楼球机监控',
'查看永嘉桥下坦头南球机监控',
'查看卧旗山监控',
'查看永嘉梅岙镇监控',
'查看江润大楼云台监控'
]
},
{ label: '无人机远程调用与飞控', checked: false,
desc: [ '支持自然语言提问调用任意无人机,并可通过飞控界面直接对无人机进行远程飞行控制。', '支持通过参数设置一键自动飞行,也支持利用平移、转向等快捷按键直接操控无人机。' ],
recommends: [
'调用鹿城山福临江工业区南无人机',
'调用永嘉瓯北码头无人机',
'调用永嘉梅岙镇无人机',
'调用鹿城无人机',
'调用瓯北无人机'
]
},
{ label: '过船信息智能查询', checked: false,
desc: [ '支持按船名/船型/时间范围/监控设备等不同维度提问或多维度组合式提问过船数据。' ],
recommends: [
'查询2025年11月1日8:00到2025年11月1日12:00经过江润大楼监控的船舶信息',
'帮我查一下2025年12月3号上午经过永嘉梅岙镇的船舶',
'请问2025年11月18日到2025年11月20日经过永嘉桥下的货船有哪些',
'上个月以浙青田开头的船经过卧旗山的数量'
]
},
{ label: '告警信息智能查询', checked: false,
desc: [ '支持按船名/船型/时间范围/报警类型等不同维度提问或多维度组合式提问告警情况。' ],
recommends: [
'查询2025年12月3日10:00至12:00在江润大楼发生的告警信息',
'帮我査一下在2025年11月24号10:00到11:00辖区全部的告警',
'请问2025年10月发生的未封舱告警有多少',
'2025年9月到11月发生告警最多的类型是什么'
]
}
// { label: '船舶智能追踪', checked: false,
// desc: [ '支持对任意船舶发起光电联动追踪自动提供距离目标船舶最近的5个监控点位可按需选择。', '摄像头会迅速转动至面向目标船舶,确保目标船舶位于监控画面中央,并随船舶航行持续转动,实现追踪。' ],
// recommends: [
// '追踪浙温货8689船舶',
// '追踪浙青田货0950船舶',
// '追踪浙瓯江清威006船舶',
// '追踪浩博起重500船舶'
// ]
// }
])
// 侧边栏显示隐藏
const show = ref(true)
let resizeObserver = null
const create = () => {
// 对话框内容关闭
emit('changeDialog', false)
}
const handle = (item) => {
list.value.find((i) => {
i.checked = false
if(item.label === i.label) {
i.checked = true
}
})
emit('changeRecommend', { ...item })
}
const observeContainerResize = () => {
// 查找目标容器元素
const targetElement = document.querySelector('.large-model-container')
if (targetElement) {
// 创建 ResizeObserver 实例
resizeObserver = new ResizeObserver((entries) => {
for (let entry of entries) {
const width = entry.contentRect.width
// 如果宽度小于825px隐藏侧边栏
if (width < 825) {
show.value = false
}else{
show.value = true
}
}
})
// 开始观察目标元素
resizeObserver.observe(targetElement)
} else {
console.log('未找到 .large-model-container 元素')
}
}
onMounted(() => {
emit('changeRecommend', { ...list.value[0] })
nextTick(() => {
observeContainerResize()
})
})
onUnmounted(() => {
if (resizeObserver) {
resizeObserver.disconnect()
}
})
</script>
<style lang="scss" scoped>
.module-container{
display: flex;
flex-direction: column;
gap: 20px;
box-sizing: border-box;
transition: all 0.3s ease-in-out;
color: #fff;
&.module-hidden-container{
width: 0;
padding: 0;
transition: all 0.3s ease-in-out;
}
.create{
width: 140px;
height: 36px;
background: rgba(111,210,253,0.15);
border-radius: 20px 20px 20px 20px;
display: flex;
justify-content: center;
align-items: center;
gap: 6px;
cursor: pointer;
}
.search{
font-weight: 600;
font-size: 14px;
color: #0C1533;
width: 100%;
padding: 0 12px;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
}
.list{
font-family: 'PingFangMedium';
.title{
display: flex;
justify-content: space-between;
height: 32px;
align-items: center;
cursor: pointer;
font-size: 14px;
color: #E1F1FA;
.expand{
transition: all 0.3s;
transform: rotate(180deg);
}
}
.list-wrapper{
width: 100%;
display: flex;
flex-direction: column;
font-size: 14px;
overflow: auto;
gap: 20px;
.list-item{
display: flex;
align-items: center;
height: 46px;
cursor: pointer;
z-index: 1;
background-image: url('@/assets/images/largeModel/icon-module.png');
background-size: 100% 100%;
justify-content: center;
&.active{
background-image: url('@/assets/images/largeModel/icon-module-active.png');
background-size: 100% 100%;
}
.label{
font-size: 12px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
}
}
.bottom{
position: absolute;
bottom: 0;
left: 0;
}
}
.border{
width: 20px;
.module-collapse{
position: absolute;
left: 160px;
bottom: 40px;
cursor: pointer;
transition: all 0.3s ease-in-out;
transform: translateX(0);
}
.module-expand{
position: absolute;
left: 16px;
bottom: 20px;
cursor: pointer;
z-index: 2;
transition: all 0.3s ease-in-out;
transform: translateX(0);
}
}
</style>

View File

@@ -0,0 +1,378 @@
<template>
<div class="dialog">
<div class="file">
<el-image
v-for="(i, index) in answers.previewImages"
:key="index"
:src="i.url"
:zoom-rate="1.2"
:max-scale="7"
:min-scale="0.2"
:scale="0.8"
:preview-src-list="answers.previewImages.map(i=>i.url)"
show-progress
:initial-index="4"
fit="object-fit"
/>
</div>
<div v-if="answers.query" class="user">
{{ answers.query }}
</div>
<div v-if="answers.answer" class="ai-message">
<img src="@/assets/images/largeModel/icon-robot.png" alt="">
<div class="content" v-html="formattedAnswer(answers.answer)"></div>
</div>
<div class="loading" v-else>
<span class="dot" v-for="i in 3" :key="i"></span>
</div>
</div>
<el-image-viewer
v-if="showPreview"
:url-list="srcList"
show-progress
:max-scale="7"
:min-scale="0.2"
:scale="0.8"
fit="object-fit"
@close="showPreview = false"
/>
</template>
<script setup>
import { nextTick, onMounted, onUnmounted, ref } from 'vue'
import { marked } from 'marked'
// import { useStore } from 'vuex'
import GlobalMap from '@/components/map/js/GlobalMap.js'
// const store = useStore()
const emit = defineEmits([ 'openVideo', 'openUav' ])
const videoArr = ref([])
const uavArr = ref([])
const boatArr = ref([])
const codes = ref([])
const visible = ref(false)
// 创建自定义渲染器
const renderer = new marked.Renderer()
const showPreview = ref(false)
const srcList = ref([])
// 自定义链接渲染
renderer.link = function(e) {
if(e.href) {
return e.href.split(' ').map(i => {
return renderMedia(i)
})
}
}
// 渲染媒体元素的通用函数
const renderMedia = (url, text) => {
if (!url) {
return ''
}
console.log(url, 'url')
// 监控
if (url.startsWith('{') && url.includes('rtsp_url')) {
const videoNameMatch = JSON.parse(url).video_name
const codeMatch = JSON.parse(url).video_code
const previewUrlMatch = JSON.parse(url).rtsp_url
const videoObj = {
videoName: videoNameMatch,
videoCode: codeMatch,
previewUrl: previewUrlMatch
}
if (videoObj.videoCode) {
const exists = videoArr.value.some(item =>
item.videoCode === videoObj.videoCode && item.previewUrl === videoObj.previewUrl
)
let index
if (!exists) {
// 只有不存在时才添加
index = videoArr.value.length
videoArr.value.push(videoObj)
} else {
// 如果已存在,找到对应的索引
index = videoArr.value.findIndex(item =>
item.videoCode === videoObj.videoCode && item.previewUrl === videoObj.previewUrl
)
}
// 创建全局事件来处理视频弹窗
return `<div style="color:#1D91FE ;cursor:pointer" onclick="openVideoHandler('${index}')">
${videoObj.videoName}
</div>`
}
}
// 无人机
if(url.startsWith('{') && url.includes('droneSn')) {
if(JSON.parse(url).droneSn) {
const droneSnMatch = JSON.parse(url).droneSn
const gatewaySnMatch = JSON.parse(url).gatewaySn
const videoNameMatch = JSON.parse(url).video_name
const uavObj = {
droneSn: droneSnMatch,
gatewaySn: gatewaySnMatch,
videoName: videoNameMatch
}
if (uavObj.videoName) {
const exists = uavArr.value.some(item =>
item.videoName === uavObj.videoName
)
let index
if (!exists) {
// 只有不存在时才添加
index = uavArr.value.length
uavArr.value.push(uavObj)
} else {
// 如果已存在,找到对应的索引
index = uavArr.value.findIndex(item =>
item.droneSn === uavObj.droneSn || item.gatewaySnMatch === uavObj.gatewaySnMatch
)
}
// 创建全局事件来处理视频弹窗
return `<div style="color:#1D91FE ;cursor:pointer" onclick="openUavHandler('${index}')">
${uavObj.videoName}
</div>`
}
}
}
// 船舶智能追踪
if(url.startsWith('{') && url.includes('mmsi')) {
if(JSON.parse(url).mmsi) {
const terminalCodeMatch = JSON.parse(url).mmsi
const boatNameMatch = JSON.parse(url).boatName
const sogMatch = JSON.parse(url).sog
const cogMatch = JSON.parse(url).cog
const longitudeMatch = JSON.parse(url).longitude
const latitudeMatch = JSON.parse(url).latitude
const boatObj = {
terminalCode: terminalCodeMatch,
boatName: boatNameMatch,
sog: Number(sogMatch),
cog: 360 - Number(cogMatch),
angle: Number(cogMatch),
longitude: longitudeMatch,
latitude: latitudeMatch
}
if (boatObj.terminalCode) {
const exists = boatArr.value.some(item =>
item.terminalCode === boatObj.terminalCode
)
let index
if (!exists) {
// 只有不存在时才添加
index = boatArr.value.length
boatArr.value.push(boatObj)
} else {
// 如果已存在,找到对应的索引
index = boatArr.value.findIndex(item =>
item.terminalCode === boatObj.terminalCode || item.boatName === boatObj.boatName
)
}
// 创建全局事件来处理视频弹窗
return `<div style="color:#1D91FE ;cursor:pointer" onclick="openTrawlerHandler('${index}')">
${boatObj.boatName}
</div>`
}
}
}
// 检查是否为视频链接
if (url.match(/\.(mp4|webm|ogg)(\?.*)?$/i)) {
return `<div style="margin: 10px 0;">
<video controls loop muted style="width: 260px; height: 150px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); margin: 10px 10px 10px 0;">
<source src="${url}" type="video/${url.split('.').pop().split('?')[0]}">
您的浏览器不支持视频播放。
</video>
</div>`
}
// 检查是否为图片链接
if (url.match(/\.(jpg|jpeg|png|gif|bmp|webp)(\?.*)?$/i)) {
return `<img onclick="openImageHandle('${url}')" src="${url}" alt="${text || ''}" style="width: 260px; height: 150px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); margin: 10px 10px 10px 0;" />`
}
}
// 监控视频查看
const openVideoHandler = (index) => {
const data = videoArr.value[index]
// 雷云一体机一个视频
if(data.videoName.indexOf('雷云') !== -1) {
const videos = videoArr.value.filter(i => i.videoName.indexOf('雷云') !== -1).sort((a, b) => {
const index1 = a.videoName[a.videoName.length - 1]
const index2 = b.videoName[b.videoName.length - 1]
return index1 - index2
})
codes.value = [ videos[0].videoCode ]
}else{
if(data.videoName.indexOf('球机') !== -1) {
const videos = videoArr.value.filter(i => i.videoName.indexOf('球机') !== -1).sort((a, b) => {
const index1 = a.videoName[a.videoName.length - 1]
const index2 = b.videoName[b.videoName.length - 1]
return index1 - index2
})
codes.value = videos.map((i, index) => {
return i.previewUrl
})
}else{
codes.value = [ data.videoCode ]
}
}
nextTick(() => {
setTimeout(() => {
visible.value = true
emit('openVideo', { codes: codes.value, visible: visible.value })
// 再次延迟后触发窗口大小调整事件模拟resize
setTimeout(() => {
window.dispatchEvent(new Event('resize'))
}, 100)
}, 50)
})
}
// 预览图片
const openImageHandle = (url) => {
srcList.value = [ url ]
showPreview.value = true
}
// 开启无人机控制
const openUavHandler = (index) => {
const data = uavArr.value[index]
// store.commit('mapStore/updateUavData', { ...data })
emit('openUav', { ...data })
}
// 渔船追踪
const openTrawlerHandler = (index) => {
const data = boatArr.value[index]
// store.commit('mapStore/updateWindowInfo', {
// visible: true,
// type: '_trawler_dynamic',
// data: { ...data }
// })
GlobalMap.instance.map.animateTo(
{
zoom: 20,
center: [ data.longitude, data.latitude ]
},
{
duration: 1000 * 0.5,
easing: 'out'
}
)
}
// 配置 marked 选项并应用自定义渲染器
marked.setOptions({
renderer: renderer,
gfm: true,
breaks: true,
smartypants: false
})
const props = defineProps({
answers: {
type: Object,
default: () => {
return {}
}
}
})
const formattedAnswer = (answer) => {
if (!answer) {
return ''
}
const processedText = answer.replace(/{[^}]+}/g, (match) => {
return renderMedia(match)
})
return marked.parse(processedText)
}
onMounted(() => {
videoArr.value = []
window.openVideoHandler = openVideoHandler
window.openImageHandle = openImageHandle
// 无人机控制方法
window.openUavHandler = openUavHandler
// 渔船追踪
window.openTrawlerHandler = openTrawlerHandler
})
onUnmounted(() => {
delete window.openVideoHandler
delete window.openImageHandle
delete window.openUavHandler
delete window.openTrawlerHandler
})
</script>
<style lang="scss" scoped>
.dialog {
height: calc(100% - 50px);
overflow: auto;
}
.user,.file{
color: #E1F1FA;
max-width: calc(100% - 88px);
padding: 6px;
font-size: 14px;
line-height: 24px;
width: fit-content;
margin-left: auto;
background: rgba(89,175,255,0.15);
border-radius: 3px 3px 3px 3px;
}
.file{
background: none;
padding-right: 0;
.el-image{
width:100px;
height: 100px;
margin-left: 10px;
cursor: pointer;
}
}
.ai-message {
color: #E1F1FA;
border-radius: 22px;
max-width: calc(100% - 88px);
padding: 10px 0;
font-size: 14px;
line-height: 24px;
width: fit-content;
margin-right: auto;
text-align: left;
display: flex;
img{
width: 16px;
height: 16px;
margin-right: 10px;
}
.content{
margin-bottom: 10px;
background: rgba(89,175,255,0.15);
border-radius: 3px 3px 3px 3px;
padding: 6px;
}
}
.loading {
color: #0C1533;
border-radius: 22px;
max-width: calc(100% - 88px);
padding: 10px 16px;
font-size: 16px;
line-height: 24px;
width: fit-content;
margin-right: auto;
text-align: left;
}
.dot {
display: inline-block;
width: 6px;
height: 6px;
background-color: #ccc;
border-radius: 50%;
margin: 0 2px;
animation: dot-animation 1.5s infinite ease-in-out;
}
@keyframes dot-animation {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
</style>

View File

@@ -0,0 +1,394 @@
import { nextTick, reactive, ref } from 'vue'
// 响应式数据存储遮挡信息
const overlapInfo = ref([])
const dialogPosition = ref({
width: 0,
height: 0,
top: 0,
left: 0,
right: 0,
bottom: 0
})
// 存储上一次的遮挡数据,避免重复提交
const lastCommitData = ref(null)
const lastCommitLevel = ref(null)
// 监听器引用
export const resizeObserver = ref(null)
export const mutationObserver = ref(null)
export const checkInterval = ref(null)
// 获取dialog当前位置和尺寸信息支持多个dialog
export const getDialogInfo = (dialogSelector = '.largeModel-dialog') => {
const dialogElement = document.querySelector(dialogSelector)
if (dialogElement) {
const rect = dialogElement.getBoundingClientRect()
return {
width: rect.width,
height: rect.height,
top: rect.top,
left: rect.left,
right: rect.right,
bottom: rect.bottom
}
}
return null
}
// 获取所有海康监控组件信息
export const getHikComponentsInfo = () => {
// 根据实际情况调整选择器,匹配海康播放器组件
const hikElements = document.querySelectorAll('[id^="playWnd"], [class*="hik"], .hik-player-component')
const components = []
hikElements.forEach((element, index) => {
const rect = element.getBoundingClientRect()
const computedStyle = window.getComputedStyle(element)
components.push({
index: index,
element: element,
id: element.id || `hik-${index}`,
classList: Array.from(element.classList),
width: rect.width,
height: rect.height,
top: rect.top,
left: rect.left,
right: rect.right,
bottom: rect.bottom,
zIndex: parseInt(computedStyle.zIndex) || 0
})
})
return components
}
// 计算遮挡范围(支持多个遮挡元素)
export const calculateOverlapAreas = (hikComponents, dialogSelectors = [ '.largeModel-dialog' ]) => {
const overlaps = []
hikComponents.forEach(component => {
let maxOverlap = null
let maxOverlapArea = 0
// 检查所有可能的遮挡元素
dialogSelectors.forEach(selector => {
const dialogInfo = getDialogInfo(selector)
if (!dialogInfo) {
return
}
// 计算重叠区域
const left = Math.max(dialogInfo.left, component.left)
const top = Math.max(dialogInfo.top, component.top)
const right = Math.min(dialogInfo.right, component.right)
const bottom = Math.min(dialogInfo.bottom, component.bottom)
// 检查是否存在重叠
if (left < right && top < bottom) {
const overlapWidth = right - left
const overlapHeight = bottom - top
const overlapArea = overlapWidth * overlapHeight
// 计算遮挡百分比
const componentArea = component.width * component.height
const overlapPercentage = componentArea > 0 ? overlapArea / componentArea * 100 : 0
// 只有遮挡面积大于一定阈值时才认为是有效遮挡
if (overlapArea > 100) { // 至少100像素的遮挡才处理
if (overlapArea > maxOverlapArea) {
maxOverlapArea = overlapArea
maxOverlap = {
component: component,
dialogInfo: dialogInfo,
overlapRect: {
left: left,
top: top,
right: right,
bottom: bottom,
width: overlapWidth,
height: overlapHeight,
area: overlapArea
},
overlapPercentage: overlapPercentage,
// 相对于海康组件的坐标
relativePosition: {
left: left - component.left,
top: top - component.top,
width: overlapWidth,
height: overlapHeight
}
}
}
}
}
})
if (maxOverlap) {
overlaps.push(maxOverlap)
}
})
overlapInfo.value = overlaps
return overlaps
}
// 比较两个数据对象是否相等
const isDataEqual = (data1, data2) => {
if (!data1 && !data2) {
return true
}
if (!data1 || !data2) {
return false
}
return data1.left === data2.left &&
data1.top === data2.top &&
data1.width === data2.width &&
data1.height === data2.height
}
// 检查并计算遮挡信息
export const checkAndCalculateOverlap = (store) => {
// 检查store是否存在
if (!store) {
console.warn('Store instance is required for checkAndCalculateOverlap')
return []
}
const hikComponents = getHikComponentsInfo()
// 检查所有可能的dialog按层级顺序后面的层级更高
const dialogSelectors = [ '.largeModel-dialog', '.video-dialog', '.el-dialog' ]
const overlaps = calculateOverlapAreas(hikComponents, dialogSelectors)
// 默认的完整窗口数据
const fullWindowData = {
left: 0,
top: 0,
width: 1000,
height: 565
}
// 如果没有遮挡,恢复完整窗口
if (overlaps.length === 0) {
// 只有当上一次不是完整窗口时才提交
if (lastCommitLevel.value !== 1 || !isDataEqual(lastCommitData.value, fullWindowData)) {
store.updateHik({ level: 1, data: fullWindowData })
lastCommitData.value = { ...fullWindowData }
lastCommitLevel.value = 1
}
return []
}
// 有遮挡的情况
const overlapData = {
left: overlaps[0].relativePosition.left,
top: overlaps[0].relativePosition.top,
width: overlaps[0].relativePosition.width,
height: overlaps[0].relativePosition.height
}
// 只有当数据发生变化时才提交
if (lastCommitLevel.value !== 0 || !isDataEqual(lastCommitData.value, overlapData)) {
store.updateHik({ level: 1, data: fullWindowData })
nextTick(() => {
console.log('提交遮挡数据:', overlapData)
store.updateHik({ level: 0, data: { ...overlapData } })
lastCommitData.value = { ...overlapData }
lastCommitLevel.value = 0
})
}
return overlaps
}
// 使用ResizeObserver监听所有dialog尺寸变化
export const setupResizeObserver = (store) => {
// 初始化数组
if (!Array.isArray(resizeObserver.value)) {
resizeObserver.value = []
}
if (!Array.isArray(checkInterval.value)) {
checkInterval.value = []
}
// 监听所有可能的dialog元素
const observeDialogs = () => {
// 包含视频弹框的选择器
const dialogElements = document.querySelectorAll('.largeModel-dialog, .video-dialog, .el-dialog')
dialogElements.forEach(element => {
if (window.ResizeObserver) {
const observer = new ResizeObserver(() => {
// 添加防抖处理
setTimeout(() => {
checkAndCalculateOverlap(store)
}, 100)
})
observer.observe(element)
resizeObserver.value.push(observer)
}
})
}
// 初始观察
observeDialogs()
// 定期检查新出现的dialog元素
const checkNewDialogs = setInterval(() => {
observeDialogs()
}, 1000)
// 存储interval以便清理
checkInterval.value.push(checkNewDialogs)
// 监听DOM变化来检测新dialog
if (window.MutationObserver) {
const targetNode = document.body
const mutationObs = new MutationObserver((mutations) => {
let shouldCheck = false
mutations.forEach(mutation => {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach(node => {
if (node.nodeType === 1 &&
(node.classList && (
node.classList.contains('largeModel-dialog') ||
node.classList.contains('video-dialog') ||
node.classList.contains('el-dialog')
))) {
shouldCheck = true
}
})
}
})
if (shouldCheck) {
setTimeout(() => {
observeDialogs()
checkAndCalculateOverlap(store)
}, 200)
}
})
mutationObs.observe(targetNode, {
childList: true,
subtree: true
})
mutationObserver.value = mutationObs
}
}
// 设置定时检查(作为备用方案)
export const setupIntervalCheck = (store) => {
// 减少检查频率,避免过于频繁
const interval = setInterval(() => {
checkAndCalculateOverlap(store)
}, 2000) // 每2秒检查一次
if (!checkInterval.value) {
checkInterval.value = []
}
checkInterval.value.push(interval)
}
// 监听窗口变化
export const handleWindowResize = (store) => {
setTimeout(() => {
checkAndCalculateOverlap(store)
}, 200)
}
// 监听滚动事件
export const handleScroll = (store) => {
// 使用防抖
setTimeout(() => {
checkAndCalculateOverlap(store)
}, 200)
}
// 初始化函数
export const initResizeDetection = (store) => {
if (!store) {
console.error('Store instance is required for initResizeDetection')
return () => {}
}
// 重置上次提交的数据
lastCommitData.value = null
lastCommitLevel.value = null
// 初始化数组
resizeObserver.value = []
checkInterval.value = []
mutationObserver.value = null
// 初始化信息获取
checkAndCalculateOverlap(store)
// 设置监听器
setupResizeObserver(store)
setupIntervalCheck(store)
// 添加事件监听
const windowResizeHandler = () => handleWindowResize(store)
const windowScrollHandler = () => handleScroll(store)
window.addEventListener('resize', windowResizeHandler)
window.addEventListener('scroll', windowScrollHandler, true)
// 返回清理函数
return () => {
// 清理监听器
if (Array.isArray(resizeObserver.value)) {
resizeObserver.value.forEach(observer => observer.disconnect())
}
if (mutationObserver.value) {
mutationObserver.value.disconnect()
}
if (Array.isArray(checkInterval.value)) {
checkInterval.value.forEach(interval => clearInterval(interval))
}
// 移除事件监听
window.removeEventListener('resize', windowResizeHandler)
window.removeEventListener('scroll', windowScrollHandler, true)
// 重置数据
lastCommitData.value = null
lastCommitLevel.value = null
}
}
// 清理函数 - 用于关闭弹框时恢复海康监控
export const cleanupAndRestoreHik = (store) => {
if (store) {
// 恢复海康监控到完整窗口状态
store.updateHik({ level: 1, data: { left: 0, top: 0, width: 1000, height: 565 } })
}
// 清理监听器
if (resizeObserver.value) {
if (Array.isArray(resizeObserver.value)) {
resizeObserver.value.forEach(observer => observer.disconnect())
} else {
resizeObserver.value.disconnect()
}
}
if (mutationObserver.value) {
mutationObserver.value.disconnect()
}
if (checkInterval.value) {
if (Array.isArray(checkInterval.value)) {
checkInterval.value.forEach(interval => clearInterval(interval))
} else {
clearInterval(checkInterval.value)
}
}
// 重置数据
lastCommitData.value = null
lastCommitLevel.value = null
}