1.5.5前端

AI表单快捷填报
master
xs 3 months ago
parent a6f187125e
commit e4cae92058

@ -62,6 +62,14 @@
<el-tooltip :content="$t('navbar.layoutSize')" effect="dark" placement="bottom">
<size-select id="size-select" class="right-menu-item hover-effect" />
</el-tooltip>
<ai-assistant ref="aiAssistantRef" />
<el-tooltip content="AI" effect="dark" placement="bottom">
<div class="right-menu-item hover-effect" @click="openAiAssistantMenu">
<svg-icon class-name="search-icon" icon-class="Ai" />
</div>
</el-tooltip>
</template>
<div class="avatar-container">
<el-dropdown class="right-menu-item hover-effect" trigger="click" @command="handleCommand">
@ -98,6 +106,7 @@ import { getTenantList } from '@/api/login';
import { dynamicClear, dynamicTenant } from '@/api/system/tenant';
import { TenantVO } from '@/api/types';
import notice from './notice/index.vue';
import AiAssistant from './TopBar/aiAssistant.vue';
const appStore = useAppStore();
const userStore = useUserStore();
@ -182,14 +191,23 @@ const handleCommand = (command: string) => {
commandMap[command]();
}
};
const aiAssistantRef = ref<InstanceType<typeof SearchMenu>>();
const openAiAssistantMenu = () => {
aiAssistantRef.value?.openSearch();
};
//
watch(
() => noticeStore.state.value.notices,
(newVal) => {
newNotice.value = newVal.filter((item: any) => !item.read).length;
},
{ deep: true }
);
// watch(
// () => noticeStore.state.value.notices,
// (newVal) => {
// newNotice.value = newVal.filter((item: any) => !item.read).length;
// },
// { deep: true }
// );
</script>
<style lang="scss" scoped>

@ -0,0 +1,766 @@
<template>
<div v-if="state.isShowSearch" class="ai-float-window ai-float-fullscreen">
<div class="ai-float-main">
<div class="ai-float-left">
<iframe
ref="iframeRef"
:src="iframeSrc"
class="ai-left-iframe"
@load="onIframeLoad"
/>
</div>
<div class="ai-float-right" ref="rightPaneRef">
<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"
/>
</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 router from "@/router"
import {getAiFormSettingList} from '@/api/ai/base/aiFormSetting';
import {AiFormSettingVO} from '@/api/ai/base/aiFormSetting/types';
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
// reactivestate
const state = reactive({
isShowSearch: false,
menuQuery: '',
menuList: []
});
const aiFormSettingList = ref<AiFormSettingVO[]>([])
const selectedFormSettingId = ref<number | null>(null)
const formsLoading = ref(false)
//
const openSearch = () => {
state.menuQuery = '';
state.isShowSearch = true;
nextTick(() => {
setTimeout(() => {
// layoutMenuAutocompleteRef.value.focus();
});
});
};
//
const closeSearch = () => {
state.isShowSearch = false;
};
//
defineExpose({
openSearch
});
//
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
const componentPath = `../../../views/${form.formPath}.vue`
//
const loader = () => {
try {
// const dd = new URL(`@/views/system/user/index.vue`, import.meta.url).href;
// alert("--"+componentPath.replace('@/', '../../../'))
// return import(`${componentPathUrl}`);
// localStorage.setItem("previousRoute", "ai");
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
}
function selectForm(aiFormSetting: AiFormSettingVO) {
selectedFormSettingId.value = aiFormSetting.formSettingId
console.log('选择表单:', aiFormSetting.formPath)
}
// props
const getFormProps = (form: AiFormSettingVO) => {
const baseProps = {
formSettingId: form.formSettingId,
formName: form.formName,
formType: form.formType,
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 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 rightPaneRef = ref<HTMLElement | null>(null)
const selectedOption = ref('')
const iframeRef = ref<HTMLIFrameElement | null>(null)
const iframeSrc = ref<string>('')
// 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(() => {
//
document.addEventListener('focusin', focusTrapBypass, true);
floatStyle.left = (window.innerWidth - 440) + 'px';
floatStyle.top = '0px';
//
})
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)
}
function focusTrapBypass(e: FocusEvent) {
const overlay = document.querySelector('.el-overlay') as HTMLElement | null
const dialog = overlay?.querySelector('.el-dialog') as HTMLElement | null
if (!overlay || !dialog) return
const rightPane = rightPaneRef.value
if (!rightPane) return
//
if (rightPane.contains(e.target as Node)) {
// Element Plus
e.stopPropagation()
}
}
onBeforeUnmount(() => {
document.removeEventListener('mousemove', onDragging)
document.removeEventListener('mouseup', onDragEnd)
document.removeEventListener('focusin', focusTrapBypass, true)
})
function scrollToBottom() {
nextTick(() => {
if (chatListRef.value) {
chatListRef.value.scrollTop = chatListRef.value.scrollHeight
}
})
}
function onIframeLoad() {
//
const form = aiFormSettingList.value.find(f => f.formSettingId === selectedFormSettingId.value)
if (form) {
alert(233333)
console.log('iframe 加载完成,开始传参');
sendToIframe({ type: 'init', payload:` getFormProps(form)` })
}
}
function sendToIframe(message: any) {
const iframeEl = iframeRef.value
try {
iframeEl?.contentWindow?.postMessage(message, '*')
} catch (e) {
//
console.error('传参失败:', e);
}
}
function handleSend(selectedText?: string) {
if ((!canSend.value || aiThinking.value) && !selectedText) return
const userMsg = selectedText || inputValue.value
chatList.value.push({ text: userMsg, from: 'user', type: 'text' })
// iframe
sendToIframe({ type: 'userInput', payload: 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)
}
// <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 currentComponent = shallowRef(null)
const currentProps = ref({})
function buildIframeSrc(form: AiFormSettingVO) {
try {
const resolved = router.resolve({ path: `/${form.formPath}` })
resolved.href += "?ai=true"
return resolved.href
} catch (e) {
return `/${form.formPath}`
}
}
watch(selectedFormSettingId, async (formSettingId) => {
const form = aiFormSettingList.value.find(f => f.formSettingId === formSettingId)
if (form) {
iframeSrc.value = buildIframeSrc(form)
// alert(iframeSrc.value)
// postMessage iframe
// 使 nextTick iframe
// nextTick(() => {
// sendToIframe({ type: 'init', payload: 'getFormProps(form)' })
// alert(12)
// })
}
})
watch(state, (newVal) => {
if (newVal.isShowSearch) {
loadForms()
}
});
</script>
<style scoped>
.ai-float-fullscreen {
position: fixed;
left: 0;
top: 0;
width: 100vw;
height: 100vh;
z-index: 2005;
background: #fff;
border-radius: 0;
box-shadow: none;
}
/* 让全局弹窗显示在浮窗之上 */
:deep(.el-overlay) {
z-index: 3000 !important;
}
:deep(.el-overlay .el-dialog) {
z-index: 3001 !important;
}
/* 允许遮罩不拦截右侧输入:只有遮罩变为不拦截,弹窗可交互 */
:deep(.el-overlay) {
pointer-events: none;
}
:deep(.el-overlay .el-dialog),
:deep(.el-overlay .el-message-box),
:deep(.el-overlay .el-drawer),
:deep(.el-overlay .el-popup) {
pointer-events: auto;
}
.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: 90vw;
right: 0;
left: auto;
top: 0;
position: fixed;
z-index: 2005;
}
.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: hidden;
height: 100%;
}
.ai-left-iframe {
width: 100%;
height: 100%;
border: 0;
display: block;
}
.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: 16px;
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>
Loading…
Cancel
Save