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>
|
||||
Reference in New Issue
Block a user