633 lines
18 KiB
Vue
633 lines
18 KiB
Vue
<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> |