feat: 重构布局

This commit is contained in:
wenjinbo 2025-10-13 16:09:07 +08:00
parent c6b4744840
commit dd1fdd16ec
2 changed files with 1064 additions and 0 deletions

View File

@ -0,0 +1,590 @@
<template>
<div class="main-container" :class="{ 'fullscreen-mode': isFullscreen }">
<!-- 全屏按钮 -->
<div class="fullscreen-button" @click="toggleFullscreen">
<el-tooltip :content="isFullscreen ? '退出全屏' : '全屏显示'" placement="left">
<el-icon :size="20">
<component :is="isFullscreen ? 'Close' : 'FullScreen'" />
</el-icon>
</el-tooltip>
</div>
<!-- 知识库选择按钮 -->
<div class="knowledge-button" @click="openKnowledgeDialog">
<el-tooltip content="选择知识库" placement="left">
<el-badge :value="boundKnowledgeBases.length" :hidden="boundKnowledgeBases.length === 0" type="primary">
<el-icon :size="20">
<Collection />
</el-icon>
</el-badge>
</el-tooltip>
</div>
<ChatBox
v-if="isDataLoaded"
:chat-type="chatType"
:user-id="userId"
:placeholder="placeholder"
:recommend-questions="recommendQuestions"
@stream-complete="handleStreamComplete"
@source-click="handleSourceClick"
@message-received="handleMessageReceived"
@chat-created="handleChatCreated"
@chat-switched="handleChatSwitched"
/>
<!-- 知识库选择对话框 -->
<el-dialog
v-model="knowledgeDialogVisible"
title="选择知识库"
width="600px"
append-to-body
:z-index="isFullscreen ? 20000 : 2000"
@close="closeKnowledgeDialog"
>
<div class="knowledge-dialog-content">
<div class="bound-knowledge" v-if="boundKnowledgeBases.length > 0">
<div class="section-title">已绑定知识库</div>
<div class="knowledge-list">
<el-tag
v-for="kb in boundKnowledgeBases"
:key="kb.id"
closable
@close="removeKnowledgeBase(kb.id)"
style="margin-right: 8px; margin-bottom: 8px;"
type="success"
>
{{ kb.name }}
</el-tag>
</div>
</div>
<div v-else class="no-binding-hint">
<el-empty description="暂未绑定知识库" :image-size="80" />
</div>
<div class="add-knowledge">
<div class="section-title">添加知识库</div>
<el-select
v-model="selectedKnowledgeBaseIds"
placeholder="请选择要绑定的知识库"
style="width: 100%"
filterable
multiple
:loading="isLoadingKnowledgeBases"
:popper-class="isFullscreen ? 'fullscreen-select-dropdown' : ''"
:teleported="true"
>
<el-option
v-for="kb in availableKnowledgeBases"
:key="kb.id"
:label="kb.name"
:value="kb.id"
>
<div style="display: flex; flex-direction: column;">
<span>{{ kb.name }}</span>
<span v-if="kb.description" style="font-size: 12px; color: #909399;">
{{ kb.description }}
</span>
</div>
</el-option>
</el-select>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="closeKnowledgeDialog">取消</el-button>
<el-button type="primary" @click="confirmBindKnowledgeBases" :loading="isBinding">
确定
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import ChatBox from '@/views/chatweb/components/ChatBox.vue'
import { useAclStore } from '@/store/modules/acl'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useI18n } from 'vue-i18n'
import { getRecommendations } from '@/api/prologue'
import { FullScreen, Close, Collection } from '@element-plus/icons-vue'
import {
getWorkflowAndDatasetTableData,
getAllUserDatasets,
addDatasetsToWorkflow,
removeDatasetsFromWorkflow
} from '@/api/functionKnowledgeConfig'
const { t, locale } = useI18n()
const aclStore = useAclStore()
//
const placeholder = ref('给AI助手发送消息')
const chatType = ref('7')
const userId = computed(() => aclStore.getUserId)
const recommendQuestions = ref<string[]>([])
const isDataLoaded = ref(false)
const isFullscreen = ref(false)
// appId
const APP_ID = '29286309-5c1c-4fea-84a6-406c69936a45'
//
const knowledgeDialogVisible = ref(false)
const boundKnowledgeBases = ref<Array<{ id: string; name: string; description?: string }>>([])
const allKnowledgeBases = ref<Array<{ id: string; name: string; description?: string }>>([])
const selectedKnowledgeBaseIds = ref<string[]>([])
const isLoadingKnowledgeBases = ref(false)
const isBinding = ref(false)
//
const availableKnowledgeBases = computed(() => {
const boundIds = boundKnowledgeBases.value.map(kb => kb.id)
return allKnowledgeBases.value.filter(kb => !boundIds.includes(kb.id))
})
//
//
const fetchAllKnowledgeBases = async () => {
try {
isLoadingKnowledgeBases.value = true
const response: any = await getAllUserDatasets()
if (response && response.code === 200 && response.data) {
const userDatasets = response.data
//
const knowledgeBasesMap = new Map()
userDatasets.forEach((item: any) => {
if (!knowledgeBasesMap.has(item.datasetId)) {
knowledgeBasesMap.set(item.datasetId, {
id: item.datasetId,
name: item.datasetName,
description: item.datasetDescription,
createAt: item.datasetCreateAt,
updateAt: item.datasetUpdateAt
})
}
})
allKnowledgeBases.value = Array.from(knowledgeBasesMap.values())
console.log('获取所有知识库成功:', allKnowledgeBases.value)
}
} catch (error) {
console.error('获取所有知识库失败:', error)
ElMessage.error('获取知识库列表失败')
} finally {
isLoadingKnowledgeBases.value = false
}
}
//
const fetchBoundKnowledgeBases = async () => {
try {
const response: any = await getWorkflowAndDatasetTableData()
if (response && response.code === 200 && response.data) {
// APP_ID
const currentApp = response.data.find((item: any) => item.appId === APP_ID)
if (currentApp && currentApp.datasetIds && currentApp.datasetIds.length > 0) {
boundKnowledgeBases.value = []
for (let i = 0; i < currentApp.datasetIds.length; i++) {
const datasetId = currentApp.datasetIds[i]
const datasetName = currentApp.datasetNames[i] || datasetId
boundKnowledgeBases.value.push({
id: datasetId,
name: datasetName
})
}
console.log('获取已绑定知识库成功:', boundKnowledgeBases.value)
} else {
boundKnowledgeBases.value = []
console.log('当前对话未绑定知识库')
}
}
} catch (error) {
console.error('获取已绑定知识库失败:', error)
}
}
//
const openKnowledgeDialog = async () => {
knowledgeDialogVisible.value = true
selectedKnowledgeBaseIds.value = []
//
if (allKnowledgeBases.value.length === 0) {
await fetchAllKnowledgeBases()
}
}
//
const closeKnowledgeDialog = () => {
knowledgeDialogVisible.value = false
selectedKnowledgeBaseIds.value = []
}
//
const confirmBindKnowledgeBases = async () => {
if (!selectedKnowledgeBaseIds.value || selectedKnowledgeBaseIds.value.length === 0) {
ElMessage.warning('请选择要绑定的知识库')
return
}
try {
isBinding.value = true
// API
const response: any = await addDatasetsToWorkflow({
appId: APP_ID,
datasetIds: selectedKnowledgeBaseIds.value
})
if (response.code === 200) {
//
selectedKnowledgeBaseIds.value.forEach(id => {
const kb = allKnowledgeBases.value.find(k => k.id === id)
if (kb && !boundKnowledgeBases.value.find(b => b.id === id)) {
boundKnowledgeBases.value.push(kb)
}
})
ElMessage.success(`成功绑定 ${selectedKnowledgeBaseIds.value.length} 个知识库`)
selectedKnowledgeBaseIds.value = []
closeKnowledgeDialog()
} else {
ElMessage.error(response.msg || '绑定失败')
}
} catch (error: any) {
console.error('绑定知识库失败:', error)
ElMessage.error('绑定失败:' + (error.message || error))
} finally {
isBinding.value = false
}
}
//
const removeKnowledgeBase = async (knowledgeBaseId: string) => {
try {
// API
const response: any = await removeDatasetsFromWorkflow({
appId: APP_ID,
datasetIds: [knowledgeBaseId]
})
if (response.code === 200) {
//
const index = boundKnowledgeBases.value.findIndex(kb => kb.id === knowledgeBaseId)
if (index > -1) {
const removedKb = boundKnowledgeBases.value[index]
boundKnowledgeBases.value.splice(index, 1)
ElMessage({
message: `已移除知识库:${removedKb.name}`,
type: 'info',
duration: 2000,
showClose: true,
customClass: isFullscreen.value ? 'fullscreen-message' : ''
})
}
} else {
ElMessage({
message: response.msg || '移除失败',
type: 'error',
duration: 2000,
showClose: true,
customClass: isFullscreen.value ? 'fullscreen-message' : ''
})
}
} catch (error: any) {
console.error('移除知识库失败:', error)
ElMessage({
message: '移除失败:' + (error.message || error),
type: 'error',
duration: 2000,
showClose: true,
customClass: isFullscreen.value ? 'fullscreen-message' : ''
})
}
}
// ChatBox
//
const handleStreamComplete = (data: {
conversationId: string
messageId: string
content: string
}) => {
console.log('通用问答 - 对话完成:', data)
}
//
const handleSourceClick = (source: any) => {
console.log('通用问答 - 点击文件来源:', source)
ElMessage.info(`点击了文件: ${source.fileName}`)
}
//
const handleMessageReceived = (message: any) => {
console.log('通用问答 - 收到新消息:', message)
}
//
const handleChatCreated = (chatIndex: number) => {
console.log('通用问答 - 创建新会话:', chatIndex)
}
//
const handleChatSwitched = (chatIndex: number) => {
console.log('通用问答 - 切换会话:', chatIndex)
}
//
const toggleFullscreen = () => {
isFullscreen.value = !isFullscreen.value
//
const layoutWrapper = document.querySelector('.vue-admin-better-wrapper') as HTMLElement
if (layoutWrapper) {
if (isFullscreen.value) {
layoutWrapper.style.overflow = 'hidden'
// body z-index
document.body.classList.add('chat-fullscreen-mode')
} else {
layoutWrapper.style.overflow = ''
//
document.body.classList.remove('chat-fullscreen-mode')
}
}
}
// ESC 退
const handleEscKey = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isFullscreen.value) {
toggleFullscreen()
}
}
//
onMounted(async () => {
console.log('组件已挂载,开始加载数据...')
// await fetchRecommendations()
//
await fetchBoundKnowledgeBases()
isDataLoaded.value = true
console.log('数据加载完成')
//
window.addEventListener('keydown', handleEscKey)
})
//
onUnmounted(() => {
window.removeEventListener('keydown', handleEscKey)
// 退
if (isFullscreen.value) {
const layoutWrapper = document.querySelector('.vue-admin-better-wrapper') as HTMLElement
if (layoutWrapper) {
layoutWrapper.style.overflow = ''
}
//
document.body.classList.remove('chat-fullscreen-mode')
}
})
</script>
<style scoped>
.main-container {
position: relative;
height: 100vh;
padding: 8px;
display: flex;
flex-direction: column;
overflow: hidden;
box-sizing: border-box;
}
/* 全屏按钮 */
.fullscreen-button {
position: absolute;
top: 16px;
right: 16px;
z-index: 100;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color);
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.fullscreen-button:hover {
background: var(--el-color-primary);
color: white;
border-color: var(--el-color-primary);
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.fullscreen-button :deep(.el-icon) {
transition: color 0.3s ease;
}
.fullscreen-button:hover :deep(.el-icon) {
color: white;
}
/* 知识库按钮 */
.knowledge-button {
position: absolute;
top: 16px;
right: 64px;
z-index: 100;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color);
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.knowledge-button:hover {
background: var(--el-color-primary);
color: white;
border-color: var(--el-color-primary);
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.knowledge-button :deep(.el-icon) {
transition: color 0.3s ease;
}
.knowledge-button:hover :deep(.el-icon) {
color: white;
}
.knowledge-button :deep(.el-badge__content) {
border: none;
}
/* 全屏模式样式 */
.main-container.fullscreen-mode {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100vw;
height: 100vh;
z-index: 9999;
background: var(--el-bg-color);
padding: 0;
margin: 0;
}
.main-container.fullscreen-mode .fullscreen-button,
.main-container.fullscreen-mode .knowledge-button {
z-index: 10000;
}
/* 知识库对话框样式 */
.knowledge-dialog-content {
padding: 10px 0;
}
.section-title {
margin-bottom: 12px;
font-weight: 500;
color: #606266;
font-size: 14px;
}
.bound-knowledge {
margin-bottom: 24px;
padding: 16px;
background-color: #f5f7fa;
border-radius: 8px;
}
.knowledge-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.no-binding-hint {
margin-bottom: 24px;
text-align: center;
}
.add-knowledge {
margin-bottom: 10px;
}
.dialog-footer {
text-align: right;
}
.el-tag {
font-size: 13px;
padding: 6px 12px;
}
</style>
<style>
/* 全局样式:全屏模式下的下拉框和弹出框 z-index */
.fullscreen-select-dropdown {
z-index: 20001 !important;
}
/* 全屏模式下的消息提示框样式 */
.fullscreen-message {
z-index: 20003 !important;
position: fixed !important;
top: 20px !important;
left: 50% !important;
transform: translateX(-50%) !important;
min-width: 380px !important;
padding: 15px 20px !important;
border-radius: 8px !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
}
/* 全屏模式下的其他弹出框 */
.main-container.fullscreen-mode ~ .el-dialog__wrapper {
z-index: 20000 !important;
}
.main-container.fullscreen-mode ~ .el-loading-mask {
z-index: 20001 !important;
}
.main-container.fullscreen-mode ~ .el-notification {
z-index: 20003 !important;
}
/* 全屏模式下的遮罩层 */
.main-container.fullscreen-mode ~ .v-modal {
z-index: 19999 !important;
}
</style>

View File

@ -0,0 +1,474 @@
<template>
<div class="centered-input-wrapper">
<div class="large-input-container">
<el-input
v-model="localMessage"
:placeholder="placeholder"
:autosize="{ minRows: 2, maxRows: 6 }"
type="textarea"
resize="none"
@keydown.enter.exact="handleSend"
class="large-message-input"
/>
<!-- 底部工具栏 -->
<div class="input-toolbar">
<!-- 左侧选择知识库和附件 -->
<div class="toolbar-left">
<!-- <el-select
v-model="selectedKnowledgeBase"
placeholder="选择知识库"
size="default"
class="knowledge-select"
clearable
>
<el-option
v-for="kb in knowledgeBases"
:key="kb.value"
:label="kb.label"
:value="kb.value"
/>
</el-select>
<el-upload
ref="uploadRef"
:show-file-list="false"
:on-change="handleFileChange"
:auto-upload="false"
accept="image/*,.pdf,.doc,.docx,.txt"
class="upload-wrapper"
>
<el-button
text
:icon="Paperclip"
class="attach-btn"
title="添加附件"
>
<span v-if="attachedFiles.length > 0" class="file-count">{{ attachedFiles.length }}</span>
</el-button>
</el-upload>
<div v-if="attachedFiles.length > 0" class="attached-files">
<el-tag
v-for="(file, index) in attachedFiles"
:key="index"
closable
@close="removeFile(index)"
size="small"
type="info"
>
{{ file.name }}
</el-tag>
</div> -->
</div>
<!-- 右侧操作按钮和提示 -->
<div class="toolbar-right">
<span v-if="localMessage.trim() && !isLoading" class="char-count">{{ localMessage.length }}</span>
<span v-if="!isLoading" class="shortcut-hint">Enter</span>
<el-button
v-if="!isLoading"
circle
text
@click="handleVoiceInput"
:icon="Microphone"
class="action-btn"
title="语音输入"
/>
<!-- 发送按钮 / 停止按钮切换 -->
<el-button
v-if="!isLoading"
circle
type="primary"
@click="handleSend"
:disabled="!localMessage.trim()"
:icon="Position"
class="send-btn"
title="发送消息 (Enter)"
/>
<el-button
v-else
circle
@click="handleStop"
class="stop-btn"
title="停止生成"
>
<svg viewBox="0 0 24 24" class="stop-icon">
<rect x="4" y="4" width="15" height="15" rx="3" />
</svg>
</el-button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { Microphone, Position, Paperclip } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import type { UploadFile } from 'element-plus'
interface Props {
modelValue: string
placeholder?: string
isLoading?: boolean
}
interface Emits {
(e: 'update:modelValue', value: string): void
(e: 'send-message', message: string): void
(e: 'stop-stream'): void
}
const props = withDefaults(defineProps<Props>(), {
placeholder: '给AI助手发送消息',
isLoading: false
})
const emit = defineEmits<Emits>()
const localMessage = ref(props.modelValue)
const selectedKnowledgeBase = ref('')
const attachedFiles = ref<UploadFile[]>([])
const uploadRef = ref()
//
watch(() => props.modelValue, (val) => {
localMessage.value = val
})
watch(localMessage, (val) => {
emit('update:modelValue', val)
})
//
const knowledgeBases = ref([
{ label: '通用知识库', value: 'general' },
{ label: '技术文档库', value: 'tech' },
{ label: '业务知识库', value: 'business' },
{ label: '检修手册库', value: 'manual' },
])
//
const handleFileChange = (file: UploadFile) => {
if (attachedFiles.value.length >= 5) {
ElMessage.warning('最多只能上传5个文件')
return
}
attachedFiles.value.push(file)
ElMessage.success(`已添加文件: ${file.name}`)
}
//
const removeFile = (index: number) => {
attachedFiles.value.splice(index, 1)
}
//
const handleSend = (event?: KeyboardEvent | MouseEvent) => {
if (event) {
event.preventDefault()
}
if (!localMessage.value.trim() || props.isLoading) {
return
}
emit('send-message', localMessage.value)
localMessage.value = ''
attachedFiles.value = []
selectedKnowledgeBase.value = ''
}
//
const handleStop = () => {
emit('stop-stream')
}
//
const handleVoiceInput = () => {
ElMessage.info('语音输入功能开发中...')
}
</script>
<style scoped lang="scss">
.centered-input-wrapper {
width: 100%;
max-width: 900px;
animation: fadeInUp 0.6s ease-out;
}
.large-input-container {
position: relative;
display: flex;
flex-direction: column;
background: #ffffff;
border-radius: 16px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
transition: all 0.3s ease;
overflow: hidden;
&:hover {
box-shadow: 0 6px 30px rgba(0, 0, 0, 0.12);
}
.large-message-input {
:deep(.el-textarea__inner) {
border: none;
border-radius: 16px;
padding: 20px 24px;
padding-bottom: 70px;
font-size: 15px;
line-height: 1.8;
min-height: 120px;
transition: all 0.3s ease;
box-shadow: none;
background: transparent;
&:focus {
outline: none;
box-shadow: none;
}
&::placeholder {
color: #9ca3af;
}
}
}
.input-toolbar {
position: absolute;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
background: transparent;
.toolbar-left {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
.knowledge-select {
width: 200px;
:deep(.el-input__wrapper) {
border-radius: 8px;
box-shadow: 0 0 0 1px #e5e7eb inset;
&:hover {
box-shadow: 0 0 0 1px #6366f1 inset;
}
&.is-focus {
box-shadow: 0 0 0 1px #6366f1 inset;
}
}
}
.upload-wrapper {
display: inline-block;
}
.attach-btn {
position: relative;
font-size: 20px;
color: #6b7280;
transition: all 0.3s ease;
width: 36px;
height: 36px;
background: rgba(99, 102, 241, 0.08);
&:hover {
color: #6366f1;
background: rgba(99, 102, 241, 0.15);
}
.file-count {
position: absolute;
top: -4px;
right: -4px;
background: #ef4444;
color: #ffffff;
font-size: 10px;
width: 16px;
height: 16px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
}
}
.attached-files {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
}
.toolbar-right {
display: flex;
gap: 8px;
align-items: center;
.char-count {
font-size: 12px;
color: #9ca3af;
padding: 0 8px;
animation: fadeIn 0.3s ease;
}
.shortcut-hint {
font-size: 11px;
color: #9ca3af;
background: rgba(99, 102, 241, 0.06);
padding: 4px 8px;
border-radius: 6px;
font-family: monospace;
border: 1px solid rgba(99, 102, 241, 0.1);
}
.action-btn {
font-size: 20px;
color: #6b7280;
transition: all 0.3s ease;
width: 36px;
height: 36px;
background: rgba(99, 102, 241, 0.08);
&:hover {
color: #6366f1;
transform: scale(1.1);
background: rgba(99, 102, 241, 0.15);
}
}
.send-btn {
width: 40px;
height: 40px;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
border: none;
transition: all 0.3s ease;
&:hover:not(:disabled) {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
}
&:disabled {
background: #93c5fd;
cursor: not-allowed;
}
}
.stop-btn {
width: 40px;
height: 40px;
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
border: none;
transition: all 0.3s ease;
animation: pulseGlow 2s ease-in-out infinite;
&:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.5);
animation: none;
}
&:active {
transform: scale(0.95);
}
.stop-icon {
width: 16px;
height: 16px;
fill: white;
transition: all 0.3s ease;
}
&:hover .stop-icon {
transform: scale(1.1);
}
}
}
}
}
@keyframes pulseGlow {
0%, 100% {
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.3);
}
50% {
box-shadow: 0 4px 16px rgba(239, 68, 68, 0.6);
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
//
@media (max-width: 768px) {
.large-input-container {
.large-message-input {
:deep(.el-textarea__inner) {
padding: 16px;
padding-bottom: 120px;
min-height: 100px;
}
}
.input-toolbar {
padding: 12px 16px;
flex-direction: column;
gap: 12px;
align-items: stretch;
.toolbar-left {
width: 100%;
.knowledge-select {
width: 100%;
}
.attached-files {
width: 100%;
}
}
.toolbar-right {
justify-content: space-between;
.shortcut-hint {
display: none;
}
}
}
}
}
</style>