first commit
This commit is contained in:
633
src/views/business/largeModel/FAQ.vue
Normal file
633
src/views/business/largeModel/FAQ.vue
Normal 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>
|
||||
197
src/views/business/largeModel/history.vue
Normal file
197
src/views/business/largeModel/history.vue
Normal 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>
|
||||
233
src/views/business/largeModel/historyDialog.vue
Normal file
233
src/views/business/largeModel/historyDialog.vue
Normal 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>
|
||||
389
src/views/business/largeModel/index.vue
Normal file
389
src/views/business/largeModel/index.vue
Normal 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>
|
||||
231
src/views/business/largeModel/module.vue
Normal file
231
src/views/business/largeModel/module.vue
Normal 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>
|
||||
378
src/views/business/largeModel/realDialog.vue
Normal file
378
src/views/business/largeModel/realDialog.vue
Normal 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>
|
||||
394
src/views/business/largeModel/resize.js
Normal file
394
src/views/business/largeModel/resize.js
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user