Files
erqi-web/src/views/business/largeModel/FAQ.vue
2025-12-24 18:19:05 +08:00

633 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div :class="['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>