You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1206 lines
33 KiB
Vue

<template>
<div v-if="state.isShowSearch" class="ai-float-window ai-float-fullscreen">
<div class="ai-float-main">
<div class="ai-float-left" v-if="isShowLeft">
<component :is="currentComponent" v-bind="currentProps" v-if="currentComponent"/>
</div>
<div class="ai-float-right" v-loading="aiLoading">
<div class="ai-float-header" @mousedown="onDragStart">
<span>AI智能填报助手</span>
<el-icon class="ai-float-close" @click="close">
<Close/>
</el-icon>
</div>
<div class="ai-float-form-list ai-float-scroll">
<div class="ai-float-welcome">👋 欢迎使用AI智能填报助手</div>
<div class="form-list-title">请选择需要填报的表单</div>
<!-- 加载状态 -->
<div v-if="formsLoading" class="loading-container">
<el-icon class="is-loading">
<Loading/>
</el-icon>
<span>加载表单中...</span>
</div>
<!-- 表单列表 -->
<div v-else class="form-list">
<el-tag
v-for="aiFormSetting in aiFormSettingList"
:key="aiFormSetting.formSettingId"
:type="aiFormSetting.formSettingId === selectedFormSettingId ? 'success' : 'info'"
class="form-tag"
@click="selectForm(aiFormSetting)"
effect="dark"
>
{{ aiFormSetting.formName }}
</el-tag>
<el-button v-if="aiFormSettingList.length > 5" type="text" class="more-btn" @click="showMore = !showMore">
更多
</el-button>
</div>
<el-dialog v-model="showMore" title="选择其他表单" width="400px" @close="moreFilterText = ''">
<el-input
v-model="moreFilterText"
placeholder="输入表单名称过滤"
clearable
style="margin-bottom: 12px;"
/>
<el-scrollbar style="max-height:300px;">
<el-tag
v-for="form in filteredForms"
:key="form.formSettingId"
:type="form.formSettingId === selectedFormSettingId ? 'success' : 'info'"
class="form-tag"
style="margin-bottom: 8px;"
@click="selectForm(form); showMore = false"
effect="dark"
>
{{ form.formName }}
</el-tag>
</el-scrollbar>
</el-dialog>
</div>
<!-- 聊天区始终渲染内容为空时不显示消息 -->
<div class="ai-float-chat-list" ref="chatListRef">
<div v-for="(msg, idx) in chatList" :key="idx"
:class="['ai-float-chat-item', msg.from === 'ai' ? 'ai-chat-ai' : 'ai-chat-user']">
<template v-if="msg.from === 'ai'">
<div class="ai-avatar">🤖</div>
<div class="ai-float-chat-bubble ai-float-chat-bubble-ai">
<div v-if="msg.type === 'select' && msg.selectLists">
<div class="ai-select-title" style="font-weight:bold; margin-bottom:8px;">{{ msg.text }}</div>
<div class="ai-select-lists">
<div v-for="(list, lidx) in msg.selectLists" :key="lidx" class="ai-select-list-block">
<div class="ai-select-list-title">{{ list.title }}</div>
<div class="ai-select-options">
<el-button v-for="opt in list.options.slice(0,4)" :key="opt" size="small"
@click="() => handleSelectOption(opt)"
:type="selectedOption === opt ? 'primary' : 'default'">{{ opt }}
</el-button>
<el-button v-if="list.options.length > 4" size="small" type="primary" link
@click="() => openSelectDialog(list)">更多
</el-button>
</div>
</div>
</div>
</div>
<div v-else v-html="formatMsg(msg.text)"></div>
</div>
</template>
<template v-else>
<div class="ai-float-chat-bubble-user-wrap">
<div class="ai-float-chat-bubble" v-html="formatMsg(msg.text)"></div>
<div class="ai-avatar ai-avatar-user">👤</div>
</div>
</template>
</div>
</div>
<el-dialog v-model="selectDialog" title="更多选项" width="400px" @close="selectDialogFilterText = ''">
<div v-if="selectDialogList">
<div class="ai-select-title">{{ selectDialogList.title }}</div>
<el-input
v-model="selectDialogFilterText"
placeholder="输入关键字过滤"
clearable
style="margin-bottom: 12px;"
/>
<div class="ai-select-options">
<el-button v-for="opt in filteredSelectDialogOptions" :key="opt" size="small"
@click="() => handleSelectOption(opt)" :type="selectedOption === opt ? 'primary' : 'default'">
{{ opt }}
</el-button>
</div>
</div>
</el-dialog>
<div class="ai-float-input-area">
<div v-if="selectedFormSettingId" class="ai-float-form-info">
<div class="form-info-title">
当前表单{{ aiFormSettingList.find(f => f.formSettingId === selectedFormSettingId)?.formName }}
</div>
<div class="form-info-tip">请根据下方示例填写内容AI将自动为您智能填报</div>
<div class="form-info-example">示例"申请10个物料A用于生产线1计划交付日期为2024-07-01"</div>
</div>
<div class="ai-float-input-row">
<el-input
v-model="inputValue"
type="textarea"
:rows="2"
:autosize="{ minRows: 2, maxRows: 6 }"
:maxlength="300"
:disabled="!selectedFormSettingId || aiThinking"
placeholder="请输入内容最多300字符"
@keydown="onInputKeydown"
class="ai-float-input"
show-word-limit
clearable
/>
<el-button
class="ai-float-send-btn"
:disabled="!canSend || aiThinking"
icon="el-icon-s-promotion"
@click="() => handleSend()"
circle
type="primary"
/>
<el-button
@click="toggleRecording"
:class="{ 'recording': isRecording }"
v-loading="audioAsrLoading"
>
{{ isRecording ? '停止录音' : '开始录音' }}
</el-button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {ref, reactive, computed, onMounted, onBeforeUnmount, nextTick, shallowRef, watch, getCurrentInstance} from 'vue'
import {ElMessage} from 'element-plus'
import {Close, Loading} from '@element-plus/icons-vue'
import type {ComponentInternalInstance} from 'vue'
import {getAiFormSettingList, getAiFormSettingDetailList} from '@/api/ai/base/aiFormSetting';
import {AiFormSettingVO, AiFormSettingDetailVO} from '@/api/ai/base/aiFormSetting/types';
import {aiFillForm, recognizeSpeechByUrl, uploadFile} from '@/api/ai/skill/aiAssistant';
import router from "@/router"
const {proxy} = getCurrentInstance() as ComponentInternalInstance
3 months ago
const aiFormSettingList = ref<AiFormSettingVO[]>([])
const aiFormSettingDetailListMap = ref<Record<number, AiFormSettingDetailVO[]>>({});
const selectedFormSettingId = ref<number | null>(null)
const formsLoading = ref(false)
const isShowLeft = ref(false);
const aiLoading = ref(false);
// 用reactive对象直接定义state
const state = reactive({
isShowSearch: false,
menuQuery: '',
menuList: []
});
// 搜索弹窗打开
const openSearch = () => {
state.menuQuery = '';
state.isShowSearch = true;
nextTick(() => {
setTimeout(() => {
// layoutMenuAutocompleteRef.value.focus();
});
});
};
// 动态生成的表单页面映射
const formPageMap = ref<Record<number, { loader: () => Promise<any>, props: any }>>({})
// 加载表单数据并初始化formPageMap
const loadForms = async () => {
try {
formsLoading.value = true
const res = await getAiFormSettingList({})
if (res.code === 200) {
aiFormSettingList.value = res.data || []
// 动态生成formPageMap
initFormPageMap()
}
} catch (error) {
console.error('加载表单数据失败:', error)
ElMessage.error('加载表单数据失败')
} finally {
formsLoading.value = false
}
}
// 动态生成formPageMap
const initFormPageMap = () => {
const newFormPageMap: Record<number, { loader: () => Promise<any>, props: any }> = {}
aiFormSettingList.value.forEach(form => {
// // 根据表单的formPath字段动态生成组件路径
// let componentPath = form.formPath
// if(!form.dialogVisibleVariable || form.dialogVisibleVariable===''){
// componentPath = '../../../views/'+componentPath;
// }else{
// componentPath = "/"+componentPath;
// }
// 创建动态加载器
const loader = () => {
try {
// const dd = new URL(`@/views/system/user/index.vue`, import.meta.url).href;
// return import(`${componentPathUrl}`);
if (!form.dialogVisibleVariable || form.dialogVisibleVariable === '') {
const componentPath = '../../../views/' + form.formPath;
return import(/* @vite-ignore */ componentPath)
}
} catch (error) {
// console.error(`加载组件失败: ${componentPath}`, error)
// return import('@/views/ai/skill/aiForm/index.vue')
}
}
// 根据表单类型设置不同的props
const props = getFormProps(form)
newFormPageMap[form.formSettingId] = {loader, props}
})
formPageMap.value = newFormPageMap
}
// 根据表单信息生成props
const getFormProps = (form: AiFormSettingVO) => {
const baseProps = {
formSettingId: form.formSettingId,
formName: form.formName,
formType: form.formType,
dialogVisibleVariable: form.dialogVisibleVariable,
formPath: form.formPath,
userInput: ''
}
// 根据表单类型添加特定props
switch (form.formType) {
case 'system':
return {...baseProps, pageNum: 1, pageSize: 10}
case 'ai':
return {...baseProps, aiType: 'form', mode: 'edit'}
default:
return baseProps
}
}
const selectForm = async (aiFormSetting: AiFormSettingVO) => {
selectedFormSettingId.value = aiFormSetting.formSettingId
aiLoading.value = true
try {
if (!aiFormSettingDetailListMap.value[aiFormSetting.formSettingId]) {
const res = await getAiFormSettingDetailList(
{formSettingId: aiFormSetting.formSettingId, settingFlag: '1'})
if (res.code === 200) {
aiFormSettingDetailListMap.value[aiFormSetting.formSettingId] = res.data || []
}
}
} finally {
aiLoading.value = false
}
console.log(aiFormSettingDetailListMap.value[aiFormSetting.formSettingId])
}
// function selectForm(aiFormSetting: AiFormSettingVO) {
// selectedFormSettingId.value = aiFormSetting.formSettingId
// if(!aiFormSettingDetailListMap[aiFormSetting.formSettingId]){
// const res = await getAiFormSettingDetailList({formSettingId: aiFormSetting.formSettingId})
// if (res.code === 200) {
// aiFormSettingDetailListMap.value[aiFormSetting.formSettingId] = res.data || []
// }
// }
// }
const currentComponent = shallowRef(null)
const currentProps = ref({})
watch(selectedFormSettingId, async (formSettingId) => {
if (formSettingId && formPageMap.value[formSettingId]) {
try {
if (formPageMap.value[formSettingId].props && formPageMap.value[formSettingId].props.dialogVisibleVariable && formPageMap.value[formSettingId].props.dialogVisibleVariable !== '') {
isShowLeft.value = false
router.push("/" + formPageMap.value[formSettingId].props.formPath)
// proxy.$store.commit('aiFormSettingId', formSettingId)
// proxy.$store.commit('aiFormSetting', formPageMap.value[formSettingId])
} else {
isShowLeft.value = true
const mod = await formPageMap.value[formSettingId].loader()
console.log(formPageMap.value[formSettingId].props)
currentComponent.value = mod.default
currentProps.value = {...formPageMap.value[formSettingId].props, userInput: ''}
}
} catch (error) {
console.error('加载表单组件失败:', error)
ElMessage.error('加载表单组件失败')
currentComponent.value = null
currentProps.value = {}
}
} else {
currentComponent.value = null
currentProps.value = {}
}
})
// 暴露变量
defineExpose({
openSearch
});
watch(state, (newVal) => {
if (newVal.isShowSearch) {
loadForms()
}
});
const handleSend = async (selectedText?: string) => {
if ((!canSend.value || aiThinking.value) && !selectedText) return
const userMsg = selectedText || inputValue.value
chatList.value.push({text: userMsg, from: 'user', type: 'text'})
scrollToBottom()
aiThinking.value = true
inputValue.value = 'AI思考中....'
aiLoading.value = true
console.log(aiFormSettingDetailListMap.value[selectedFormSettingId.value]);
try {
let params = {
naturalLanguageQuery: userMsg,
formSettingId: selectedFormSettingId.value,
formSettingDetailList: aiFormSettingDetailListMap.value[selectedFormSettingId.value],
modelId: 1,
platformId: 1
}
// currentProps.value = {completeAmount:10,productionTime:16.6};
// return;
const response = await aiFillForm(params)
currentProps.value = response;
inputValue.value = ''
aiThinking.value = false
// 固定AI回复+多个选择列表
chatList.value.push({
text: '操作成功,请选择相关信息:',
from: 'ai',
type: 'select',
selectLists: [
{title: '物料信息', options: ['物料A', '物料B', '物料C', '物料D', '物料E', '物料F']},
{title: 'BOM信息', options: ['BOM-001', 'BOM-002', 'BOM-003', 'BOM-004', 'BOM-005']}
]
})
scrollToBottom()
console.log(1)
console.log(response)
console.log(2)
} catch (error) {
proxy?.$modal.msgError(error);
console.log("Error:" + error)
} finally {
aiLoading.value = false
}
}
// 录音相关对象
const isRecording = ref(false);
let mediaRecorder = null
let microphoneStream = null
let audioChunks = []
let audioContext = null
const targetSampleRate = 16000 // 目标采样率,可根据后端要求调整
// 状态变量
const audioAsrLoading = ref(false);
const isUploading = ref(false)
const uploadProgress = ref(0)
const recognitionResult = ref('')
const recordings = ref([])
const toggleRecording = () => {
if (isRecording.value) {
stopRecording();
} else {
startRecording();
}
}
// 开始录音
async function startRecording() {
alert('您的浏览器不支持录音功能1')
proxy.$modal.msgError('您的浏览器不支持录音功能')
try {
// 检查浏览器支持
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
proxy.$modal.msgError('您的浏览器不支持录音功能')
return
}
// 获取用户麦克风权限
microphoneStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
})
// 初始化Web Audio API用于后续处理
if (!audioContext) {
audioContext = new (window.AudioContext)()
}
// 设置MediaRecorder
const options = {
mimeType: 'audio/webm;codecs=opus' // 优先使用opus编码
}
// 降级处理,确保兼容性
if (!MediaRecorder.isTypeSupported(options.mimeType)) {
options.mimeType = 'audio/webm'
if (!MediaRecorder.isTypeSupported(options.mimeType)) {
options.mimeType = 'audio/ogg;codecs=opus'
if (!MediaRecorder.isTypeSupported(options.mimeType)) {
proxy.$modal.msgError('您的浏览器不支持高质量录音格式')
return
}
}
}
mediaRecorder = new MediaRecorder(microphoneStream, options)
audioChunks = []
// 收集音频数据
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunks.push(event.data)
}
}
// 录音开始
mediaRecorder.start()
isRecording.value = true
// addLog('录音已开始')
} catch (error) {
console.error('开始录音失败:', error)
// addLog(`开始录音失败: ${error.message}`)
stopRecording()
}
}
// 停止录音
function stopRecording() {
try {
if (!isRecording.value) return
// 更新录音状态
isRecording.value = false
// 停止MediaRecorder
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop()
// 录音停止后的处理
mediaRecorder.onstop = async () => {
audioAsrLoading.value = true;
try {
// 关闭麦克风流
if (microphoneStream) {
microphoneStream.getTracks().forEach(track => track.stop())
microphoneStream = null
}
// addLog('录音已停止,正在处理音频...')
// 将录音数据转换为WAV格式
const wavBlob = await convertToWav(audioChunks)
// 保存录音到历史记录
const recordingName = `recording_${new Date().toISOString().replace(/[:.]/g, '-')}.wav`
const recordingUrl = URL.createObjectURL(wavBlob)
recordings.value.unshift({
name: recordingName,
url: recordingUrl,
file: wavBlob
})
// 限制历史记录数量
if (recordings.value.length > 5) {
const oldestRecording = recordings.value.pop()
URL.revokeObjectURL(oldestRecording.url)
}
// addLog(`音频处理完成,文件大小: ${(wavBlob.size / 1024).toFixed(2)}KB`)
// 自动上传录音文件
await uploadAudioFile(wavBlob, recordingName)
} catch (error) {
console.error('处理录音文件失败:', error)
proxy.$modal.msgError('处理录音文件失败,请重试', error)
// addLog(`处理录音文件失败: ${error.message}`)
}finally{
}
}
}
} catch (error) {
console.error('停止录音失败:', error)
// addLog(`停止录音失败: ${error.message}`)
} finally {
}
}
// 将音频数据转换为WAV格式
async function convertToWav(audioChunks) {
// 将音频块合并为单个Blob
const audioBlob = new Blob(audioChunks)
// 读取音频数据并转换为WAV
return new Promise((resolve, reject) => {
const fileReader = new FileReader()
fileReader.onload = async (e) => {
try {
// 获取ArrayBuffer
const arrayBuffer = e.target.result
// 使用Web Audio API解码音频数据
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer)
// 转换为目标采样率的WAV格式
const wavBuffer = await resampleAndConvertToWav(audioBuffer, targetSampleRate)
resolve(new Blob([wavBuffer], {type: 'audio/wav'}))
} catch (error) {
reject(error)
}
}
fileReader.onerror = reject
fileReader.readAsArrayBuffer(audioBlob)
})
}
// 重采样并转换为WAV格式
async function resampleAndConvertToWav(audioBuffer, targetRate) {
// 获取原始音频数据
const channels = audioBuffer.numberOfChannels
const sampleRate = audioBuffer.sampleRate
const length = audioBuffer.length
// 计算重采样后的长度
const newLength = Math.floor(length * targetRate / sampleRate)
// 创建目标采样率的AudioContext
const offlineContext = new OfflineAudioContext(channels, newLength, targetRate)
// 创建缓冲区源节点
const source = offlineContext.createBufferSource()
source.buffer = audioBuffer
// 连接到输出
source.connect(offlineContext.destination)
// 开始播放
source.start()
// 渲染重采样后的音频
const renderedBuffer = await offlineContext.startRendering()
// 将AudioBuffer转换为WAV格式
return encodeWAV(renderedBuffer)
}
// 将AudioBuffer编码为WAV格式
function encodeWAV(audioBuffer) {
const numberOfChannels = audioBuffer.numberOfChannels
const sampleRate = audioBuffer.sampleRate
const bitDepth = 16
// 获取所有声道的数据
const channels = []
for (let i = 0; i < numberOfChannels; i++) {
channels.push(audioBuffer.getChannelData(i))
}
// 计算总样本数
const sampleCount = channels[0].length
const byteRate = sampleRate * numberOfChannels * bitDepth / 8
// 创建WAV文件头部
const wavHeader = new ArrayBuffer(44)
const view = new DataView(wavHeader)
// RIFF标识符
writeString(view, 0, 'RIFF')
// 文件大小
view.setUint32(4, 36 + sampleCount * numberOfChannels * 2, true)
// WAVE标识符
writeString(view, 8, 'WAVE')
// fmt子块标识符
writeString(view, 12, 'fmt ')
// fmt子块大小
view.setUint32(16, 16, true)
// 音频格式PCM
view.setUint16(20, 1, true)
// 声道数
view.setUint16(22, numberOfChannels, true)
// 采样率
view.setUint32(24, sampleRate, true)
// 字节率
view.setUint32(28, byteRate, true)
// 块对齐
view.setUint16(32, numberOfChannels * 2, true)
// 位深度
view.setUint16(34, bitDepth, true)
// data子块标识符
writeString(view, 36, 'data')
// data子块大小
view.setUint32(40, sampleCount * numberOfChannels * 2, true)
// 创建PCM数据
const pcmBytes = new ArrayBuffer(wavHeader.byteLength + sampleCount * numberOfChannels * 2)
const pcmView = new DataView(pcmBytes)
// 复制WAV头部
for (let i = 0; i < wavHeader.byteLength; i++) {
pcmView.setUint8(i, new Uint8Array(wavHeader)[i])
}
// 写入PCM数据
let offset = 44
for (let i = 0; i < sampleCount; i++) {
for (let channel = 0; channel < numberOfChannels; channel++) {
const sample = Math.max(-1, Math.min(1, channels[channel][i]))
// 将Float32转换为Int16
pcmView.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7FFF, true)
offset += 2
}
}
return pcmBytes
}
// 写入字符串到DataView
function writeString(view, offset, string) {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i))
}
}
// 上传音频文件
async function uploadAudioFile(blob, fileName) {
try {
isUploading.value = true
uploadProgress.value = 0
recognitionResult.value = ''
// addLog(`开始上传音频文件: ${fileName}`)
// 创建FormData对象
const formData = new FormData()
formData.append('file', blob, fileName)
const response = await uploadFile(formData);
console.log(response)
// 处理响应
if (response.code === 200) {
// addLog('音频上传成功,正在识别')
// alert(response.data.url)
const response1 = await recognizeSpeechByUrl(response.data.url);
if (response1.code === 200) {
console.log("--" + response1);
inputValue.value += response1.data.text
// recognitionResult.value = response1.data.text || '识别成功,但未返回文本结果'
// alert(recognitionResult.value)
// addLog('识别成功,结果为: ' + recognitionResult.value)
}
} else {
// addLog(`上传失败,状态码: ${response.status}`)
}
} catch (error) {
console.error('上传音频文件失败:', error)
proxy.$message.error('上传音频文件失败', error)
// addLog(`上传音频文件失败: ${error.message || '未知错误'}`)
} finally {
isUploading.value = false
uploadProgress.value = 0
}
}
function handleSend1(selectedText?: string) {
if ((!canSend.value || aiThinking.value) && !selectedText) return
const userMsg = selectedText || inputValue.value
chatList.value.push({text: userMsg, from: 'user', type: 'text'})
// 动态传递userInput参数给左侧组件
if (currentComponent.value) {
currentProps.value = {...currentProps.value, userInput: userMsg}
}
scrollToBottom()
aiThinking.value = true
inputValue.value = 'AI思考中....'
setTimeout(() => {
inputValue.value = ''
aiThinking.value = false
// 固定AI回复+多个选择列表
chatList.value.push({
text: '操作成功,请选择相关信息:',
from: 'ai',
type: 'select',
selectLists: [
{title: '物料信息', options: ['物料A', '物料B', '物料C', '物料D', '物料E', '物料F']},
{title: 'BOM信息', options: ['BOM-001', 'BOM-002', 'BOM-003', 'BOM-004', 'BOM-005']}
]
})
scrollToBottom()
}, 2000)
}
const showMore = ref(false)
const inputValue = ref('')
const canSend = computed(() => selectedFormSettingId.value && inputValue.value.trim().length > 0)
// 新增:更多弹窗过滤输入
const moreFilterText = ref('')
const filteredForms = computed(() =>
aiFormSettingList.value.filter(form => form.formName.includes(moreFilterText.value))
)
type ChatSelectList = { title: string, options: string[] }
type ChatMsg = { text: string, from: 'user' | 'ai', type?: 'text' | 'select', selectLists?: ChatSelectList[] }
const chatList = ref<ChatMsg[]>([])
const selectDialog = ref(false)
const selectDialogList = ref<ChatSelectList | null>(null)
const aiThinking = ref(false)
const chatListRef = ref<HTMLElement | null>(null)
const selectedOption = ref('')
const iframeRef = ref<HTMLIFrameElement | null>(null)
// 新增AI更多弹窗过滤输入
const selectDialogFilterText = ref('')
const filteredSelectDialogOptions = computed(() =>
selectDialogList.value
? selectDialogList.value.options.filter(opt => opt.includes(selectDialogFilterText.value))
: []
)
// 悬浮窗拖拽
const floatStyle = reactive({
left: '',
top: '0px',
width: '440px',
height: '100vh',
zIndex: 9999,
position: 'fixed' as const,
})
// 默认显示在最右侧
onMounted(() => {
floatStyle.left = (window.innerWidth - 440) + 'px';
floatStyle.top = '0px';
// 加载表单数据
// loadForms()
})
let drag = false
let offsetX = 0
let offsetY = 0
function onDragStart(e: MouseEvent) {
drag = true
offsetX = e.clientX - parseInt(floatStyle.left)
offsetY = e.clientY - parseInt(floatStyle.top)
document.addEventListener('mousemove', onDragging)
document.addEventListener('mouseup', onDragEnd)
}
function onDragging(e: MouseEvent) {
if (!drag) return
let newLeft = e.clientX - offsetX
let newTop = e.clientY - offsetY
// 限制在屏幕内
newLeft = Math.max(0, Math.min(newLeft, window.innerWidth - 440))
newTop = Math.max(0, Math.min(newTop, window.innerHeight - 100))
floatStyle.left = newLeft + 'px'
floatStyle.top = newTop + 'px'
}
function onDragEnd() {
drag = false
document.removeEventListener('mousemove', onDragging)
document.removeEventListener('mouseup', onDragEnd)
}
onBeforeUnmount(() => {
document.removeEventListener('mousemove', onDragging)
document.removeEventListener('mouseup', onDragEnd)
})
// 组件卸载时清理资源
onUnmounted(() => {
// 停止MediaRecorder
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop()
}
// 关闭音频上下文
if (audioContext) {
audioContext.close()
audioContext = null
}
// 释放URL对象
recordings.value.forEach(recording => {
URL.revokeObjectURL(recording.url)
})
})
function scrollToBottom() {
nextTick(() => {
if (chatListRef.value) {
chatListRef.value.scrollTop = chatListRef.value.scrollHeight
}
})
}
// 保证所有方法直接在<script setup>中定义
function handleSelectOption(opt: string) {
selectedOption.value = opt
}
function openSelectDialog(list: ChatSelectList) {
selectDialogList.value = list
selectDialog.value = true
}
function formatMsg(text: string) {
// 转换换行符为<br>并做简单转义防XSS
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>')
}
function onInputKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
// shift+enter 默认换行,无需处理
}
function close() {
state.isShowSearch = false
}
// 搜索弹窗关闭
const closeSearch = () => {
state.isShowSearch = false;
};
</script>
<style scoped lang="less">
.el-message {
z-index:8006 !important;
}
.ai-float-fullscreen {
position: fixed;
left: 0;
top: 0;
width: 100vw;
height: 100vh;
z-index: 7005;
background: #fff;
border-radius: 0;
box-shadow: none;
}
.ai-float-window {
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.18);
background: #fff;
border-radius: 12px;
padding: 0 0 16px 0;
min-height: 220px;
user-select: none;
display: flex;
flex-direction: column;
height: 100vh;
width: 60vw;
right: 0;
left: auto;
top: 0;
position: fixed;
z-index: 7005;
}
.ai-float-main {
display: flex;
height: 100vh;
}
.ai-float-left {
flex: 7 7 0;
min-width: 400px;
background: #f7f8fa;
border-right: 1px solid #e0e7ef;
overflow: auto;
height: 100%;
}
.ai-float-right {
flex: 3 3 0;
min-width: 260px;
height: 100%;
display: flex;
flex-direction: column;
}
.ai-float-header {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 18px;
font-weight: bold;
padding: 16px 24px 8px 24px;
cursor: move;
border-bottom: 1px solid #f0f0f0;
}
.ai-float-welcome {
font-size: 16px;
color: #409eff;
margin-bottom: 10px;
font-weight: 500;
}
.ai-float-close {
font-size: 20px;
cursor: pointer;
color: #888;
transition: color 0.2s;
}
.ai-float-close:hover {
color: #f56c6c;
}
.ai-float-form-list {
padding: 16px 24px 0 24px;
flex-shrink: 0;
max-height: 220px;
overflow-y: auto;
}
.form-list-title {
font-size: 15px;
margin-bottom: 8px;
color: #333;
}
.form-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.form-tag {
cursor: pointer;
margin-bottom: 4px;
}
.more-btn {
margin-left: 8px;
font-size: 13px;
padding: 0 4px;
}
.loading-container {
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
color: #666;
gap: 8px;
}
.ai-float-input-area {
padding: 6px 24px 0 24px;
flex-shrink: 0;
height: 140px;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.ai-float-form-info {
margin-bottom: 8px;
background: #f6f8fa;
border-radius: 1px;
padding: 10px 14px;
}
.form-info-title {
font-weight: bold;
color: #333;
margin-bottom: 2px;
}
.form-info-tip {
color: #666;
font-size: 13px;
margin-bottom: 2px;
}
.form-info-example {
color: #999;
font-size: 13px;
}
.ai-float-input-row {
display: flex;
align-items: flex-end;
gap: 8px;
}
.ai-float-input {
flex: 1 1 auto;
}
.ai-float-send-btn {
margin-bottom: 4px;
}
.ai-float-chat-list {
flex: 1 1 auto;
margin: 0 24px;
margin-top: 12px;
margin-bottom: 20px;
display: flex;
flex-direction: column;
gap: 8px;
overflow-y: auto;
min-height: 0;
max-height: calc(100vh - 170px - 140px - 32px);
}
.ai-float-chat-item {
display: flex;
justify-content: flex-end;
align-items: flex-end;
}
.ai-chat-ai {
justify-content: flex-start;
}
.ai-avatar {
width: 32px;
height: 32px;
background: linear-gradient(135deg, #e0e7ef 0%, #f6f8fa 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
margin-right: 8px;
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.08);
}
.ai-float-chat-bubble {
background: linear-gradient(135deg, #409eff 0%, #6ec1ff 100%);
color: #fff;
border-radius: 20px 20px 4px 24px;
padding: 12px 22px;
max-width: 80%;
font-size: 14px;
word-break: break-all;
box-shadow: 0 4px 16px rgba(64, 158, 255, 0.13);
line-height: 1.85;
min-height: 36px;
display: inline-block;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
}
.ai-float-chat-bubble-ai {
background: linear-gradient(135deg, #f4f4f4 0%, #e9f0fb 100%);
color: #333;
border-radius: 16px 16px 16px 6px;
box-shadow: 0 2px 12px rgba(64, 158, 255, 0.10);
border: 1px solid #e0e7ef;
}
.ai-select-lists {
display: flex;
flex-direction: column;
gap: 12px;
align-items: stretch;
}
.ai-select-list-block {
width: 100%;
text-align: left;
display: flex;
flex-direction: column;
gap: 4px;
padding: 0;
margin: 0;
}
.ai-select-list-title {
font-weight: 500;
color: #333;
text-align: left;
width: 100%;
margin: 0;
padding: 0;
}
.ai-select-options {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
text-align: left;
margin: 0;
padding: 0;
}
.ai-select-options .el-button {
width: 100%;
text-align: left;
margin: 0;
padding-left: 0 !important;
justify-content: flex-start;
}
.el-dialog__body .ai-select-title {
margin-bottom: 8px;
}
.el-dialog__body .ai-select-options {
width: 100%;
justify-content: flex-start;
gap: 8px;
}
.ai-float-chat-bubble-user-wrap {
display: flex;
align-items: flex-end;
justify-content: flex-end;
gap: 8px;
}
.ai-avatar-user {
background: linear-gradient(135deg, #e0e7ef 0%, #d0eaff 100%);
color: #409eff;
}
</style>