first commit

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

View File

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