|
|
<template>
|
|
|
<div v-if="state.isShowSearch" :class="['ai-float-window', currentSizeClass]"
|
|
|
:style="currentSize === 'custom' ? {
|
|
|
width: windowSize.width,
|
|
|
height: windowSize.height,
|
|
|
left: typeof windowPosition.x === 'string' ? 'auto' : windowPosition.x + 'px',
|
|
|
right: typeof windowPosition.x === 'string' ? '0' : 'auto',
|
|
|
top: windowPosition.y + 'px'
|
|
|
} : {}">
|
|
|
<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="startDrag">
|
|
|
<span>AI智能填报助手</span>
|
|
|
<!-- 将两个图标按钮放在一个容器内 -->
|
|
|
<div class="ai-float-header-actions">
|
|
|
<el-icon v-if="currentSize==='minimize'" class="ai-float-minimize" @click.stop="toggleSize">
|
|
|
<Plus />
|
|
|
</el-icon>
|
|
|
<el-icon v-if="currentSize!=='minimize'" class="ai-float-minimize" @click.stop="toggleSize">
|
|
|
<Minus />
|
|
|
</el-icon>
|
|
|
|
|
|
<el-icon class="ai-float-close" @click="close">
|
|
|
<Close/>
|
|
|
</el-icon>
|
|
|
</div>
|
|
|
</div>
|
|
|
<div class="ai-float-form-list ai-float-scroll">
|
|
|
<div class="form-list-top">
|
|
|
<div class="ai-float-welcome">👋 欢迎使用AI智能填报助手</div>
|
|
|
<div class="ai-model-selector">
|
|
|
<el-select
|
|
|
v-model="selectedModelId"
|
|
|
placeholder="选择AI模型"
|
|
|
size="small"
|
|
|
popper-class="topIndex"
|
|
|
style="width: 150px"
|
|
|
:teleported="true"
|
|
|
append-to="body"
|
|
|
:disabled="aiThinking"
|
|
|
@change="onModelChange"
|
|
|
>
|
|
|
<el-option
|
|
|
v-for="model in aiModelList"
|
|
|
:key="model.modelId"
|
|
|
:label="model.modelName"
|
|
|
:value="model.modelId"
|
|
|
/>
|
|
|
</el-select>
|
|
|
</div>
|
|
|
</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,lidx)"
|
|
|
:type="selectedOption === opt ? 'primary' : 'default'">{{ opt.fieldValue }}
|
|
|
</el-button>
|
|
|
<el-button v-if="list.options.length > 4" size="small" type="primary" link
|
|
|
@click="() => openSelectDialog(list,lidx)">更多
|
|
|
</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.fieldValue }}
|
|
|
</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">{{ aiFormSettingList.find(f => f.formSettingId === selectedFormSettingId)?.remark ?? '如:申请10个物料A用于生产线1,计划交付日期为2024-07-01'}}</div>
|
|
|
</div>
|
|
|
<div class="ai-float-input-row">
|
|
|
<el-button
|
|
|
@click="toggleRecording"
|
|
|
:class="['recording-btn', { 'recording': isRecording }]"
|
|
|
v-loading="audioAsrLoading"
|
|
|
:disabled="aiThinking"
|
|
|
circle
|
|
|
type="primary"
|
|
|
>
|
|
|
<template #icon>
|
|
|
<el-icon v-if="isRecording"><VideoPause /></el-icon>
|
|
|
<el-icon v-else><Microphone /></el-icon>
|
|
|
</template>
|
|
|
</el-button>
|
|
|
<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="Top"
|
|
|
@click="() => handleSend()"
|
|
|
circle
|
|
|
type="success"
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<!-- 新增:调整大小的句柄 -->
|
|
|
<div class="ai-float-resize-handle" @mousedown="startResize"></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"
|
|
|
import {useSharedDataStore} from '@/api/monitorData'
|
|
|
import {getAiModelJoinList} from "@/api/ai/skill/aiChat";
|
|
|
import {AIModelVO} from "@/api/ai/skill/aiChat/types";
|
|
|
|
|
|
const sharedStore = useSharedDataStore()
|
|
|
|
|
|
const {proxy} = getCurrentInstance() as ComponentInternalInstance
|
|
|
|
|
|
const aiFormSettingList = ref<AiFormSettingVO[]>([])
|
|
|
const aiFormSettingDetailListMap = ref<Record<number, AiFormSettingDetailVO[]>>({});
|
|
|
const selectedFormSettingId = ref<number | null>(null)
|
|
|
const selectedForm = ref();
|
|
|
const formsLoading = ref(false)
|
|
|
const isShowLeft = ref(false);
|
|
|
const aiLoading = ref(false);
|
|
|
const currentSize = ref("menu");
|
|
|
|
|
|
const currentSizeClass = computed(() =>
|
|
|
`size-${currentSize.value}`
|
|
|
);
|
|
|
|
|
|
|
|
|
// 用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) => {
|
|
|
if( selectedFormSettingId.value === aiFormSetting.formSettingId) return;
|
|
|
selectedFormSettingId.value = aiFormSetting.formSettingId
|
|
|
selectedForm.value = aiFormSetting;
|
|
|
aiLoading.value = true
|
|
|
try {
|
|
|
resetAiAssistant();
|
|
|
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])
|
|
|
}
|
|
|
|
|
|
const resetAiAssistant = () =>{
|
|
|
chatList.value = [];
|
|
|
}
|
|
|
|
|
|
// 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
|
|
|
currentSize.value = "menu";
|
|
|
router.push({path: "/" + formPageMap.value[formSettingId].props.formPath, query: {from: "ai"}})
|
|
|
|
|
|
// proxy.$store.commit('aiFormSettingId', formSettingId)
|
|
|
// proxy.$store.commit('aiFormSetting', formPageMap.value[formSettingId])
|
|
|
} else {
|
|
|
isShowLeft.value = true
|
|
|
currentSize.value = "form";
|
|
|
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
|
|
|
});
|
|
|
|
|
|
|
|
|
const getAiModelList = async () => {
|
|
|
// loading.value = true;
|
|
|
const res = await getAiModelJoinList({modelTypeId: 1});
|
|
|
aiModelList.value = res.data;
|
|
|
await setDefaultAiModel();
|
|
|
// console.log(res)
|
|
|
// platformList.value = res.data;
|
|
|
// loading.value = false;
|
|
|
}
|
|
|
|
|
|
const setDefaultAiModel = async () => {
|
|
|
aiModelList.value.forEach((item: AIModelVO) => {
|
|
|
if (item.defaultFlag === "1") {
|
|
|
// alert(JSON.stringify(item))
|
|
|
selectedModelId.value = item.modelId;
|
|
|
selectedPlatformId.value = item.platformId;
|
|
|
// provider.value = item.platformCode;
|
|
|
// console.log(item.platformIcon)
|
|
|
// platformIcon.value = item.platformIcon;
|
|
|
}
|
|
|
})
|
|
|
}
|
|
|
|
|
|
function onModelChange() {
|
|
|
const model = aiModelList.value.find(m => m.modelId === selectedModelId.value)
|
|
|
if (model) {
|
|
|
selectedPlatformId.value = model.platformId
|
|
|
// platformIcon.value = model.platformIcon;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
watch(state, (newVal) => {
|
|
|
if (newVal.isShowSearch) {
|
|
|
loadForms()
|
|
|
getAiModelList();
|
|
|
}
|
|
|
});
|
|
|
|
|
|
const selectLists = ref([]);
|
|
|
|
|
|
// 存储fieldType为1的元素及其位置信息,显示使用
|
|
|
const type1Elements = ref([]);
|
|
|
// 存储fieldType为2的元素及其位置信息,赋值使用
|
|
|
const type2Elements = ref([]);
|
|
|
|
|
|
// 存储选中后对应的fieldType为2的元素
|
|
|
const selectedType2Elements = ref([]);
|
|
|
|
|
|
// 处理数据,找出所有fieldType为1的元素
|
|
|
const processData = (data) => {
|
|
|
type1Elements.value = [];
|
|
|
type2Elements.value = [];
|
|
|
let title = "";
|
|
|
data.forEach((subArray, subArrayIndex) => {
|
|
|
if (Array.isArray(subArray)) {
|
|
|
subArray.forEach(item => {
|
|
|
if (item.fieldType === '3' || item.fieldType === 3) {//请选择关联表的表名title信息
|
|
|
if (title == '') title = item.fieldValue;
|
|
|
}
|
|
|
|
|
|
if (item.fieldType === '1' || item.fieldType === 1) {//赋值
|
|
|
type1Elements.value.push({
|
|
|
...item,
|
|
|
subArrayIndex
|
|
|
});
|
|
|
}
|
|
|
|
|
|
if (item.fieldType === '2' || item.fieldType === 2) {//显示的名称
|
|
|
type2Elements.value.push({
|
|
|
...item,
|
|
|
subArrayIndex
|
|
|
});
|
|
|
}
|
|
|
});
|
|
|
|
|
|
}
|
|
|
});
|
|
|
selectLists.value.push({title: title, options: type2Elements.value, formOptions: type1Elements.value});
|
|
|
};
|
|
|
|
|
|
|
|
|
const sendForm = ref({});
|
|
|
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: selectedModelId.value,
|
|
|
platformId: selectedPlatformId.value
|
|
|
}
|
|
|
|
|
|
// chatList.value.push({
|
|
|
// text: '操作成功,请选择相关信息:',
|
|
|
// from: 'ai',
|
|
|
// type: 'select',
|
|
|
// selectLists: selectLists.value
|
|
|
// })
|
|
|
//
|
|
|
// sharedStore.updateDynamicValue({
|
|
|
// message: {deptId:100},
|
|
|
// timestamp: new Date().toLocaleString()
|
|
|
// })
|
|
|
// return;
|
|
|
|
|
|
const response = await aiFillForm(params)
|
|
|
sendForm.value = response;
|
|
|
inputValue.value = ''
|
|
|
aiThinking.value = false
|
|
|
traverseJSON();
|
|
|
|
|
|
if (selectedForm.value && selectedForm.value.dialogVisibleVariable && selectedForm.value.dialogVisibleVariable !== '') {
|
|
|
//打开菜单时赋值使用
|
|
|
sharedStore.updateDynamicValue({
|
|
|
message: sendForm.value,
|
|
|
timestamp: new Date().toLocaleString()
|
|
|
})
|
|
|
} else {
|
|
|
//直接打开表单赋值使用
|
|
|
currentProps.value = sendForm.value;
|
|
|
}
|
|
|
|
|
|
|
|
|
// 固定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']}
|
|
|
// ]
|
|
|
// })
|
|
|
|
|
|
|
|
|
if (selectLists.value.length > 0) {
|
|
|
chatList.value.push({
|
|
|
text: '操作成功,请选择相关信息:',
|
|
|
from: 'ai',
|
|
|
type: 'select',
|
|
|
selectLists: selectLists.value
|
|
|
})
|
|
|
} else {
|
|
|
chatList.value.push({
|
|
|
text: '操作成功',
|
|
|
from: 'ai',
|
|
|
})
|
|
|
}
|
|
|
|
|
|
|
|
|
scrollToBottom()
|
|
|
|
|
|
console.log(1)
|
|
|
console.log(response)
|
|
|
|
|
|
} catch (error) {
|
|
|
proxy?.$modal.msgError(error);
|
|
|
console.log("Error:" + error)
|
|
|
chatList.value.push({
|
|
|
text: '请重试:'+error,
|
|
|
from: 'ai',
|
|
|
})
|
|
|
} finally {
|
|
|
aiThinking.value = false
|
|
|
inputValue.value = ''
|
|
|
aiLoading.value = false
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
// 递归遍历JSON对象的函数
|
|
|
function traverseJSON() {
|
|
|
|
|
|
|
|
|
// 判断是否为数组
|
|
|
if (Array.isArray(sendForm.value)) {
|
|
|
console.log('这是一个数组,包含 ' + sendForm.value.length + ' 个元素:');
|
|
|
// 遍历数组中的每个元素
|
|
|
// obj.forEach((item, index) => {
|
|
|
// console.log(' '.repeat(indent) + '索引 ' + index + ':');
|
|
|
// });
|
|
|
} else {
|
|
|
// 遍历对象的所有key值
|
|
|
for (const key in sendForm.value) {
|
|
|
// 确保key是对象自身的属性(非继承)
|
|
|
if (sendForm.value.hasOwnProperty(key)) {
|
|
|
const value = sendForm.value[key];
|
|
|
|
|
|
// 判断value是否为数组
|
|
|
if (Array.isArray(value)) {
|
|
|
// selectLists: [
|
|
|
// {title: '物料信息', options: ['物料A', '物料B', '物料C', '物料D', '物料E', '物料F']},
|
|
|
// {title: 'BOM信息', options: ['BOM-001', 'BOM-002', 'BOM-003', 'BOM-004', 'BOM-005']}
|
|
|
// ]
|
|
|
sendForm.value[key] = '';
|
|
|
if (value.length == 1) {
|
|
|
|
|
|
const firstValue = value[0];
|
|
|
firstValue.forEach(item => {
|
|
|
if(item.fieldType ==='1' || item.fieldType===1){
|
|
|
sendForm.value[key] = item.fieldValue
|
|
|
}
|
|
|
});
|
|
|
|
|
|
// let selectList = {title: value[0].relateTableDesc, options: value}
|
|
|
// selectLists.value.push(selectList);
|
|
|
}else if (value.length > 1) {
|
|
|
processData(value)
|
|
|
}
|
|
|
|
|
|
// 遍历数组中的每个元素
|
|
|
// value.forEach((item, index) => {
|
|
|
// console.log(' '.repeat(indent + 1) + '数组元素 ' + index + ':');
|
|
|
// });
|
|
|
}
|
|
|
// 如果value是对象但不是数组,递归遍历
|
|
|
// else if (value !== null && typeof value === 'object') {
|
|
|
// console.log(' '.repeat(indent) + '值是一个对象:');
|
|
|
// traverseJSON(value, indent + 1);
|
|
|
// }
|
|
|
// 基本类型值直接输出
|
|
|
// else {
|
|
|
// console.log(' '.repeat(indent) + '值:', value);
|
|
|
// }
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
// 录音相关对象
|
|
|
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() {
|
|
|
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 {
|
|
|
audioAsrLoading.value = false;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
} 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('音频上传成功,正在识别')
|
|
|
|
|
|
const response1 = await recognizeSpeechByUrl(response.data.url);
|
|
|
if (response1.code === 200) {
|
|
|
console.log("--" + response1);
|
|
|
inputValue.value += response1.data.text
|
|
|
// recognitionResult.value = response1.data.text || '识别成功,但未返回文本结果'
|
|
|
// 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)
|
|
|
|
|
|
// AI模型相关数据
|
|
|
const aiModelList = ref([
|
|
|
{ modelId: 1, modelName: 'GPT-3.5' },
|
|
|
{ modelId: 2, modelName: 'GPT-4' },
|
|
|
{ modelId: 3, modelName: 'Claude-3' },
|
|
|
{ modelId: 4, modelName: 'Gemini-Pro' }
|
|
|
])
|
|
|
const selectedModelId = ref() // AI模型ID
|
|
|
const selectedPlatformId = ref() // AI平台ID
|
|
|
|
|
|
// 新增:更多弹窗过滤输入
|
|
|
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 selectDialogListIndex = ref();
|
|
|
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是否为对象,如果是则访问其字符串属性
|
|
|
// 这里假设对象有label或value属性,根据实际情况修改
|
|
|
const fieldValue = typeof opt === 'string' ? opt :
|
|
|
opt.fieldValue
|
|
|
return fieldValue.toLowerCase().includes(selectDialogFilterText.value.toLowerCase());
|
|
|
})
|
|
|
: []
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// 默认显示在最右侧
|
|
|
onMounted(() => {
|
|
|
|
|
|
// 加载表单数据
|
|
|
// loadForms()
|
|
|
//
|
|
|
// const testData = [[{"fieldKey":"relateTableDesc","fieldValue":"部门1","fieldType":"3"},
|
|
|
// {"fieldKey":"deptName","fieldValue":"测试部门1","fieldType":"2"},
|
|
|
// {"fieldKey":"deptId","fieldValue":105,"fieldType":"1"}],
|
|
|
// [{"fieldKey":"relateTableDesc","fieldValue":null,"fieldType":"3"},
|
|
|
// {"fieldKey":"deptName","fieldValue":"测试部门2","fieldType":"2"},
|
|
|
// {"fieldKey":"deptId","fieldValue":"1952572898270183426","fieldType":"1"}]
|
|
|
// ]
|
|
|
//
|
|
|
//
|
|
|
// processData(testData);
|
|
|
//
|
|
|
// const testData1 = [
|
|
|
// [{"fieldKey":"relateTableDesc","fieldValue":"部门2","fieldType":"3"},
|
|
|
// {"fieldKey":"deptName","fieldValue":"测试部门11","fieldType":"2"},
|
|
|
// {"fieldKey":"deptId","fieldValue":1051,"fieldType":"1"}],
|
|
|
// [{"fieldKey":"relateTableDesc","fieldValue":null,"fieldType":"3"},
|
|
|
// {"fieldKey":"deptName","fieldValue":"测试部门22","fieldType":"2"},
|
|
|
// {"fieldKey":"deptId","fieldValue":"19525728982701834261","fieldType":"1"}]
|
|
|
// ]
|
|
|
// processData(testData1);
|
|
|
})
|
|
|
|
|
|
onBeforeUnmount(() => {
|
|
|
|
|
|
})
|
|
|
|
|
|
// 组件卸载时清理资源
|
|
|
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, index) {
|
|
|
if (index === undefined || index === null) {
|
|
|
index = selectDialogListIndex.value
|
|
|
}
|
|
|
|
|
|
let selectList = selectLists.value[index];
|
|
|
selectedOption.value = opt
|
|
|
let selectFormOptions = selectList.formOptions;
|
|
|
const subArray = selectFormOptions[opt.subArrayIndex];
|
|
|
const newValueJson = {[subArray.fieldKey]: subArray.fieldValue}
|
|
|
sharedStore.updateDynamicValue({
|
|
|
message: newValueJson,
|
|
|
timestamp: new Date().toLocaleString()
|
|
|
})
|
|
|
|
|
|
// currentProps.value= newValueJson;
|
|
|
|
|
|
}
|
|
|
|
|
|
function openSelectDialog(list: ChatSelectList,listIndex:number) {
|
|
|
selectDialogList.value = list
|
|
|
selectDialog.value = true
|
|
|
selectDialogListIndex.value = listIndex;
|
|
|
}
|
|
|
|
|
|
function formatMsg(text: string) {
|
|
|
// 转换换行符为<br>,并做简单转义防XSS
|
|
|
if (text) {
|
|
|
return text
|
|
|
.replace(/&/g, '&')
|
|
|
.replace(/</g, '<')
|
|
|
.replace(/>/g, '>')
|
|
|
.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 isDragging = ref(false);
|
|
|
const isResizing = ref(false);
|
|
|
const windowPosition = ref({ x: 'auto', y: 0 });
|
|
|
const isSmallSize = ref(false);
|
|
|
const windowSize = ref({ width: '24%', height: '100vh' });
|
|
|
|
|
|
|
|
|
const startPos = ref({ x: 0, y: 0 });
|
|
|
const startSize = ref({ width: 0, height: 0 });
|
|
|
const isCustomPosition = ref(false); // 新增:标记是否为自定义位置
|
|
|
|
|
|
// 新增:拖拽功能实现
|
|
|
function startDrag(e: MouseEvent) {
|
|
|
if (isResizing.value) return;
|
|
|
isDragging.value = true;
|
|
|
|
|
|
// 获取窗口当前实际位置
|
|
|
const windowElement = document.querySelector('.ai-float-window') as HTMLElement;
|
|
|
if (windowElement) {
|
|
|
const rect = windowElement.getBoundingClientRect();
|
|
|
startPos.value = {
|
|
|
x: e.clientX - rect.left,
|
|
|
y: e.clientY - rect.top
|
|
|
};
|
|
|
}
|
|
|
|
|
|
// 标记为自定义位置
|
|
|
isCustomPosition.value = true;
|
|
|
|
|
|
document.addEventListener('mousemove', onDrag);
|
|
|
document.addEventListener('mouseup', stopDrag);
|
|
|
}
|
|
|
|
|
|
function onDrag(e: MouseEvent) {
|
|
|
if (!isDragging.value) return;
|
|
|
const newX = e.clientX - startPos.value.x;
|
|
|
const newY = e.clientY - startPos.value.y;
|
|
|
|
|
|
// 确保窗口不会超出视口边界(留出一些边距)
|
|
|
const maxX = window.innerWidth - 200; // 最小宽度200px
|
|
|
const maxY = window.innerHeight - 100; // 最小高度100px
|
|
|
|
|
|
windowPosition.value = {
|
|
|
x: Math.max(0, Math.min(newX, maxX)),
|
|
|
y: Math.max(0, Math.min(newY, maxY))
|
|
|
};
|
|
|
|
|
|
// 当窗口被拖动时,不再固定在右侧
|
|
|
if (currentSize.value === 'menu' || currentSize.value === 'form' || currentSize.value === 'minimize') {
|
|
|
currentSize.value = 'custom';
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function stopDrag() {
|
|
|
isDragging.value = false;
|
|
|
document.removeEventListener('mousemove', onDrag);
|
|
|
document.removeEventListener('mouseup', stopDrag);
|
|
|
}
|
|
|
|
|
|
// ... existing code ...
|
|
|
|
|
|
|
|
|
|
|
|
// 新增:调整大小功能实现
|
|
|
function startResize(e: MouseEvent) {
|
|
|
isResizing.value = true;
|
|
|
startPos.value = { x: e.clientX, y: e.clientY };
|
|
|
startSize.value = {
|
|
|
width: window.innerWidth * parseFloat(windowSize.value.width) / 100,
|
|
|
height: window.innerHeight * parseFloat(windowSize.value.height) / 100
|
|
|
};
|
|
|
|
|
|
document.addEventListener('mousemove', onResize);
|
|
|
document.addEventListener('mouseup', stopResize);
|
|
|
e.preventDefault(); // 防止拖动时选中文本
|
|
|
}
|
|
|
|
|
|
function onResize(e: MouseEvent) {
|
|
|
if (!isResizing.value) return;
|
|
|
|
|
|
const deltaX = e.clientX - startPos.value.x;
|
|
|
const deltaY = e.clientY - startPos.value.y;
|
|
|
|
|
|
// 计算新的宽度和高度,确保最小尺寸
|
|
|
let newWidth = startSize.value.width + deltaX;
|
|
|
let newHeight = startSize.value.height + deltaY;
|
|
|
|
|
|
newWidth = Math.max(200, newWidth); // 最小宽度200px
|
|
|
newHeight = Math.max(300, newHeight); // 最小高度300px
|
|
|
|
|
|
// 转换为视口百分比
|
|
|
windowSize.value = {
|
|
|
width: `${(newWidth / window.innerWidth) * 100}%`,
|
|
|
height: `${(newHeight / window.innerHeight) * 100}%`
|
|
|
};
|
|
|
|
|
|
// 当窗口大小被调整时,不再使用预设尺寸
|
|
|
if (currentSize.value !== 'custom') {
|
|
|
currentSize.value = 'custom';
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function stopResize() {
|
|
|
isResizing.value = false;
|
|
|
document.removeEventListener('mousemove', onResize);
|
|
|
document.removeEventListener('mouseup', stopResize);
|
|
|
}
|
|
|
|
|
|
// 新增:切换预设尺寸时更新窗口大小
|
|
|
watch(currentSize, (newSize) => {
|
|
|
if (newSize === 'menu') {
|
|
|
windowSize.value = { width: '24%', height: '100vh' };
|
|
|
windowPosition.value = { x: 'auto', y: 0 };
|
|
|
} else if (newSize === 'form') {
|
|
|
windowSize.value = { width: '80%', height: '100vh' };
|
|
|
windowPosition.value = { x: 'auto', y: 0 };
|
|
|
} else if(newSize === 'minimize'){
|
|
|
windowSize.value = { width: '24%', height: '360px' };
|
|
|
windowPosition.value = { x: 'auto', y: 0 };
|
|
|
}
|
|
|
|
|
|
// const windowSize = computed(() => {
|
|
|
// if (isSmallSize.value) {
|
|
|
// return {
|
|
|
// width: '100px',
|
|
|
// height: '100px',
|
|
|
// top: 'auto',
|
|
|
// bottom: '20px',
|
|
|
// left: 'auto',
|
|
|
// right: '20px',
|
|
|
// borderRadius: '8px',
|
|
|
// overflow: 'hidden'
|
|
|
// }
|
|
|
// }else{
|
|
|
// return {
|
|
|
// width: '24%', height: '100vh'
|
|
|
// }
|
|
|
// }
|
|
|
// return {}
|
|
|
// })
|
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
// 新增:调整窗口大小
|
|
|
function toggleSize() {
|
|
|
if(currentSize.value === 'minimize'){
|
|
|
if (!selectedForm.value) {//没有选择form时,原始大小
|
|
|
currentSize.value = 'menu';
|
|
|
}else{
|
|
|
if(selectedForm.value.dialogVisibleVariable && selectedForm.value.dialogVisibleVariable !== ''){
|
|
|
currentSize.value = 'menu'
|
|
|
} else{
|
|
|
currentSize.value = 'form'
|
|
|
}
|
|
|
}
|
|
|
}else{
|
|
|
currentSize.value = 'minimize'
|
|
|
}
|
|
|
}
|
|
|
|
|
|
</script>
|
|
|
|
|
|
<style scoped lang="less">
|
|
|
.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: 99vh;
|
|
|
max-height:99vh;
|
|
|
right: 0;
|
|
|
left: auto;
|
|
|
top: 0;
|
|
|
position: fixed;
|
|
|
z-index: 7005;
|
|
|
}
|
|
|
|
|
|
/* 确保Element Plus下拉列表显示在浮窗之上 */
|
|
|
::deep(.el-select-dropdown) {
|
|
|
z-index: 9000 !important;
|
|
|
}
|
|
|
::deep(.el-popper) {
|
|
|
z-index: 9000 !important;
|
|
|
}
|
|
|
::deep(.ai-select-popper) {
|
|
|
z-index: 9500 !important;
|
|
|
}
|
|
|
::deep(.el-overlay) {
|
|
|
z-index: 8000 !important;
|
|
|
}
|
|
|
::deep(.el-overlay .el-dialog) {
|
|
|
z-index: 8001 !important;
|
|
|
}
|
|
|
|
|
|
// 修复动态样式
|
|
|
&.size-custom {
|
|
|
right: v-bind('isCustomPosition ? "auto" : "0"');
|
|
|
left: v-bind('isCustomPosition ? windowPosition.x + "px" : "auto"');
|
|
|
top: v-bind('isCustomPosition ? windowPosition.y + "px" : "0"');
|
|
|
width: v-bind('windowSize.width');
|
|
|
height: v-bind('windowSize.height');
|
|
|
}
|
|
|
|
|
|
&.size-minimized {
|
|
|
right: auto !important;
|
|
|
left: v-bind('windowPosition.x + "px"') !important;
|
|
|
top: v-bind('windowPosition.y + "px"') !important;
|
|
|
width: v-bind('windowSize.width + "px"') !important;
|
|
|
height: v-bind('windowSize.height + "px"') !important;
|
|
|
border-radius: 20px;
|
|
|
padding: 0;
|
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
|
|
}
|
|
|
|
|
|
|
|
|
// 新增:最小化状态下隐藏内容
|
|
|
.size-minimized .ai-float-main,
|
|
|
.size-minimized .ai-float-form-list,
|
|
|
.size-minimized .ai-float-header > div:first-child {
|
|
|
display: none;
|
|
|
}
|
|
|
|
|
|
// 新增:最小化按钮样式
|
|
|
.size-minimized .ai-float-header {
|
|
|
padding: 0;
|
|
|
width: 100%;
|
|
|
height: 100%;
|
|
|
border-bottom: none;
|
|
|
cursor: default;
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
justify-content: center;
|
|
|
}
|
|
|
|
|
|
/* 确保头部右侧按钮组正确显示 */
|
|
|
.ai-float-header-actions {
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
}
|
|
|
|
|
|
// 优化最小化按钮样式
|
|
|
.ai-float-minimize {
|
|
|
font-size: 16px;
|
|
|
cursor: pointer;
|
|
|
color: #888;
|
|
|
transition: color 0.2s;
|
|
|
margin-right: 12px;
|
|
|
line-height: 1;
|
|
|
}
|
|
|
|
|
|
.size-form {
|
|
|
width: 80vw;
|
|
|
}
|
|
|
|
|
|
.size-menu {
|
|
|
width: 24%; //80vw的30%。跟下面al-float-right的flex定义匹配宽度
|
|
|
}
|
|
|
|
|
|
.size-minimize {
|
|
|
width: 24%;
|
|
|
height:360px;
|
|
|
}
|
|
|
|
|
|
.ai-float-minimize:hover {
|
|
|
color: #409eff;
|
|
|
}
|
|
|
|
|
|
// 最小化状态下隐藏关闭按钮
|
|
|
.size-minimized .ai-float-close {
|
|
|
display: none;
|
|
|
}
|
|
|
|
|
|
// 优化调整大小的句柄,使用通用图标样式
|
|
|
.ai-float-resize-handle {
|
|
|
position: absolute;
|
|
|
right: 4px;
|
|
|
bottom: 4px;
|
|
|
width: 16px;
|
|
|
height: 16px;
|
|
|
border-radius: 2px;
|
|
|
cursor: se-resize;
|
|
|
opacity: 0.6;
|
|
|
transition: opacity 0.2s;
|
|
|
z-index: 10;
|
|
|
|
|
|
// 使用CSS创建通用的调整大小图标
|
|
|
&::after {
|
|
|
content: '';
|
|
|
position: absolute;
|
|
|
right: -2px;
|
|
|
bottom: -2px;
|
|
|
width: 0;
|
|
|
height: 0;
|
|
|
border-style: solid;
|
|
|
border-width: 0 8px 8px 0;
|
|
|
border-color: transparent #fff transparent transparent;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
.ai-float-resize-handle:hover {
|
|
|
opacity: 1;
|
|
|
}
|
|
|
|
|
|
.ai-float-main {
|
|
|
display: flex;
|
|
|
height: 100vh;
|
|
|
}
|
|
|
|
|
|
.ai-float-left {
|
|
|
flex: 7 7 0;
|
|
|
background: #f7f8fa;
|
|
|
border-right: 1px solid #e0e7ef;
|
|
|
overflow: auto;
|
|
|
height: 100%;
|
|
|
}
|
|
|
|
|
|
.ai-float-right {
|
|
|
flex: 3 3 0;
|
|
|
min-width: 260px;
|
|
|
width: 500px;
|
|
|
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-header-actions {
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
}
|
|
|
|
|
|
.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-top {
|
|
|
display: flex;
|
|
|
justify-content: space-between;
|
|
|
align-items: center;
|
|
|
margin-bottom: 8px;
|
|
|
}
|
|
|
|
|
|
.form-list-header {
|
|
|
display: flex;
|
|
|
justify-content: space-between;
|
|
|
align-items: center;
|
|
|
margin-bottom: 8px;
|
|
|
}
|
|
|
|
|
|
.form-list-title {
|
|
|
font-size: 15px;
|
|
|
color: #333;
|
|
|
margin: 0;
|
|
|
margin-bottom:10px;
|
|
|
}
|
|
|
|
|
|
.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-model-selector {
|
|
|
flex-shrink: 0;
|
|
|
z-index:8000;
|
|
|
}
|
|
|
|
|
|
.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;
|
|
|
}
|
|
|
|
|
|
|
|
|
.recording-btn {
|
|
|
margin-bottom: 4px;
|
|
|
}
|
|
|
|
|
|
.recording-btn.recording {
|
|
|
color: #f56c6c;
|
|
|
}
|
|
|
|
|
|
</style>
|
|
|
<style>
|
|
|
|
|
|
.topIndex {
|
|
|
z-index: 99999999999999999999999 !important;
|
|
|
}
|
|
|
</style>
|