对接deepseek(全面版)【前端写全局图标和对话框】

前提:

技术栈:vue3+ant组件库

前端实现功能:全局图标点击和拖拽吸附侧边功能。点击图标弹出对话框,对话框支持自由拖拽和调节大小。对话框中实现基础问答,复制答案,会话整理等。

后端接口实现:对话标题修改,对话内容返回等等。

注意:难在对项目中页面优化适配功能。目前测试没有bug,仅供参考。

功能效果:

1.全局图标点击和拖拽左右吸附侧边功能。

鼠标拖拽,判断距离左右两侧那里更近就吸附哪边。

实现代码:

1.处理了鼠标拖拽和鼠标单击的不同。

2.处理了边界问题和吸附问题。

3.处理了自适应问题,确保图标在可视区域内。

javascript 复制代码
<template>
	<div class="draggableIconDom" ref="draggableIconDom">
		<div
			ref="draggableIcon"
			class="draggable-icon"
			:style="iconStyle"
			@mousedown="startDrag"
			@touchstart="startDragTouch"
		>
			<img :src="imgUrl" alt="全局图标" class="icon-img" />
		</div>
		<deepseekModal ref="aiModal"></deepseekModal>
	</div>
</template>

<script setup>
	import imgUrl from '@/assets/images/deepseek.png'
	import { viewTagsStore } from '@/store'
	import deepseekModal from '@/components/AIModal/deepSeekAIModal.vue'

	const storeTags = viewTagsStore()
	const draggableIcon = ref(null)
	const draggableIconDom = ref(null)
	const aiModal = ref(null)

	// 基础状态
	const state = reactive({
		isDragging: false,
		startX: 0,
		startY: 0,
		offsetX: 0,
		offsetY: 0,
		x: 0,
		y: 0,
		transition: 'all 0.3s ease',
		snapState: 'right', // 'left', 'right', 'none'
		dragThreshold: 5, // 拖动阈值,小于此值视为点击
		hasMoved: false, // 标记是否已经移动
		lastClickTime: 0 // 上次点击时间,用于防止双击
	})

	// 响应式图标尺寸
	const iconSize = computed(() => {
		// 根据窗口宽度动态调整图标大小
		const baseSize = 60
		const minSize = 40
		const maxSize = 80

		let size = baseSize
		if (windowSize.width < 768) {
			size = Math.max(minSize, baseSize * 0.8)
		} else if (windowSize.width > 1920) {
			size = Math.min(maxSize, baseSize * 1.2)
		}

		return {
			width: size,
			height: size
		}
	})

	// 窗口边界
	const windowBounds = computed(() => {
		return {
			width: windowSize.width,
			height: windowSize.height
		}
	})

	// 有效边界
	const validBounds = computed(() => {
		const buffer = 10
		return {
			minX: buffer,
			maxX: windowBounds.value.width - iconSize.value.width - buffer,
			minY: buffer,
			maxY: windowBounds.value.height - iconSize.value.height - buffer
		}
	})

	// 图标样式
	const iconStyle = computed(() => ({
		left: `${state.x}px`,
		top: `${state.y}px`,
		transition: state.transition,
		cursor: state.isDragging ? 'grabbing' : 'grab',
		transform: state.isDragging ? 'scale(1.1)' : 'scale(1)',
		width: `${iconSize.value.width}px`,
		height: `${iconSize.value.height}px`
	}))

	// 限制位置在有效边界内
	const constrainToBounds = (x, y) => {
		return {
			x: Math.max(validBounds.value.minX, Math.min(x, validBounds.value.maxX)),
			y: Math.max(validBounds.value.minY, Math.min(y, validBounds.value.maxY))
		}
	}

	// 计算吸附位置
	const getSnapPosition = (currentX, currentY) => {
		const iconCenterX = currentX + iconSize.value.width / 2
		const distanceToLeft = iconCenterX
		const distanceToRight = windowBounds.value.width - iconCenterX

		let snapX, snapSide

		if (distanceToLeft < distanceToRight) {
			snapSide = 'left'
			snapX = validBounds.value.minX
		} else {
			snapSide = 'right'
			snapX = validBounds.value.maxX
		}

		return { x: snapX, y: currentY, side: snapSide }
	}

	// 开始拖拽(鼠标)
	const startDrag = (e) => {
		// 只允许点击图标本身(.draggable-icon)时触发拖拽,排除子组件区域
		if (e.target !== draggableIcon.value && !draggableIcon.value.contains(e.target)) {
			return
		}
		e.preventDefault() // 只阻止默认行为(如文本选中),不阻止冒泡
		if (e.button !== 0) return // 只响应左键

		state.isDragging = true
		state.hasMoved = false
		const rect = draggableIcon.value.getBoundingClientRect()

		state.startX = e.clientX
		state.startY = e.clientY
		state.offsetX = e.clientX - rect.left
		state.offsetY = e.clientY - rect.top
		state.transition = 'none'

		document.addEventListener('mousemove', handleDrag)
		document.addEventListener('mouseup', stopDrag)
	}

	// 开始拖拽(触摸)
	const startDragTouch = (e) => {
		if (e.target !== draggableIcon.value && !draggableIcon.value.contains(e.target)) {
			return
		}
		e.preventDefault()
		if (e.touches.length !== 1) return

		state.isDragging = true
		state.hasMoved = false
		const rect = draggableIcon.value.getBoundingClientRect()
		const touch = e.touches[0]

		state.startX = touch.clientX
		state.startY = touch.clientY
		state.offsetX = touch.clientX - rect.left
		state.offsetY = touch.clientY - rect.top
		state.transition = 'none'

		document.addEventListener('touchmove', handleDragTouch, { passive: false })
		document.addEventListener('touchend', stopDragTouch)
	}

	// 处理拖拽(鼠标)
	const handleDrag = (e) => {
		if (!state.isDragging) return

		let newX = e.clientX - state.offsetX
		let newY = e.clientY - state.offsetY

		// 检查是否移动超过阈值
		const deltaX = Math.abs(e.clientX - state.startX)
		const deltaY = Math.abs(e.clientY - state.startY)
		if (deltaX > state.dragThreshold || deltaY > state.dragThreshold) {
			state.hasMoved = true
		}

		const constrained = constrainToBounds(newX, newY)
		state.x = constrained.x
		state.y = constrained.y
	}

	// 处理拖拽(触摸)
	const handleDragTouch = (e) => {
		if (!state.isDragging) return
		e.preventDefault()

		const touch = e.touches[0]
		let newX = touch.clientX - state.offsetX
		let newY = touch.clientY - state.offsetY

		// 检查是否移动超过阈值
		const deltaX = Math.abs(touch.clientX - state.startX)
		const deltaY = Math.abs(touch.clientY - state.startY)
		if (deltaX > state.dragThreshold || deltaY > state.dragThreshold) {
			state.hasMoved = true
		}

		const constrained = constrainToBounds(newX, newY)
		state.x = constrained.x
		state.y = constrained.y
	}

	// 停止拖拽(鼠标)
	const stopDrag = (e) => {
		if (!state.isDragging) return

		state.isDragging = false
		state.transition = 'all 0.3s ease'

		document.removeEventListener('mousemove', handleDrag)
		document.removeEventListener('mouseup', stopDrag)

		// 检查是否是有效的点击目标
		const isClickTarget = e.target === draggableIcon.value || draggableIcon.value.contains(e.target)

		// 如果移动距离很小且是有效点击目标,视为点击
		if (!state.hasMoved && isClickTarget) {
			const now = Date.now()
			if (now - state.lastClickTime > 300) {
				state.lastClickTime = now
				openModal()
			}
		} else if (state.hasMoved) {
			// 如果发生了移动(拖拽),进行吸附处理
			const snapPos = getSnapPosition(state.x, state.y)
			state.x = snapPos.x
			state.y = snapPos.y
			state.snapState = snapPos.side
		}

		// 重置移动状态
		state.hasMoved = false
	}

	// 停止拖拽(触摸)
	const stopDragTouch = (e) => {
		if (!state.isDragging) return

		state.isDragging = false
		state.transition = 'all 0.3s ease'

		document.removeEventListener('touchmove', handleDragTouch)
		document.removeEventListener('touchend', stopDragTouch)

		// 检查是否是有效的点击目标
		let isClickTarget = false
		if (e.changedTouches && e.changedTouches.length > 0) {
			const touch = e.changedTouches[0]
			const targetElement = document.elementFromPoint(touch.clientX, touch.clientY)
			isClickTarget = targetElement === draggableIcon.value || draggableIcon.value.contains(targetElement)
		}

		// 如果移动距离很小且是有效点击目标,视为点击
		if (!state.hasMoved && isClickTarget) {
			const now = Date.now()
			if (now - state.lastClickTime > 300) {
				state.lastClickTime = now
				openModal()
			}
		} else if (state.hasMoved) {
			// 如果发生了移动(拖拽),进行吸附处理
			const snapPos = getSnapPosition(state.x, state.y)
			state.x = snapPos.x
			state.y = snapPos.y
			state.snapState = snapPos.side
		}

		// 重置移动状态
		state.hasMoved = false
	}

	// 打开模态框
	const openModal = () => {
		storeTags.AIModalOpenChange(true)
	}
	// 窗口尺寸状态
	const windowSize = reactive({
		width: 0,
		height: 0
	})
	// 更新窗口尺寸
	const updateWindowSize = () => {
		windowSize.width = window.innerWidth
		windowSize.height = window.innerHeight
	}

	// 窗口大小变化处理
	const handleResize = () => {
		updateWindowSize()

		nextTick(() => {
			// 确保图标在可视区域内
			const constrained = constrainToBounds(state.x, state.y)
			state.x = constrained.x
			state.y = constrained.y

			// 重新计算吸附位置
			if (state.snapState !== 'none') {
				const snapPos = getSnapPosition(state.x, state.y)
				state.x = snapPos.x
				state.snapState = snapPos.side
			}
		})
	}

	onMounted(() => {
		updateWindowSize()

		if (draggableIcon.value) {
			// 初始位置在右下角
			const initX = windowBounds.value.width - iconSize.value.width - 20
			const initY = windowBounds.value.height - iconSize.value.height - 20
			const constrained = constrainToBounds(initX, initY)
			state.x = constrained.x
			state.y = constrained.y

			// 初始吸附到右侧
			const snapPos = getSnapPosition(state.x, state.y)
			state.x = snapPos.x
			state.snapState = snapPos.side
		}

		window.addEventListener('resize', handleResize)

		// 组件卸载时清理监听
		return () => {
			window.removeEventListener('resize', handleResize)
		}
	})
</script>
<style scoped>
	.draggableIconDom {
		position: fixed;
		top: 0;
		left: 0;
		z-index: 999;
		pointer-events: auto;
		width: fit-content; /* 仅适配子元素宽度 */
		height: fit-content; /* 仅适配子元素高度 */
	}

	.draggable-icon {
		position: fixed;
		z-index: 9999;
		user-select: none;
		pointer-events: auto;
		line-height: 0;
		border-radius: 50%;
		display: flex;
		align-items: center;
		justify-content: center;
		transition:
			width 0.3s ease,
			height 0.3s ease;
	}

	.icon-img {
		width: 100%;
		height: 100%;
		border-radius: 50%;
		transition: transform 0.2s;
		display: block;
	}

	.draggable-icon:hover .icon-img {
		transform: scale(1.05);
	}

	.draggable-icon:active .icon-img {
		transform: scale(0.95);
	}

	.icon-pulse {
		position: absolute;
		top: 0;
		left: 0;
		width: 100%;
		height: 100%;
		border-radius: 50%;
		background: transparent;
		opacity: 0;
		transition: opacity 0.3s;
	}

	.icon-pulse.active {
		opacity: 1;
		animation: pulse 2s infinite;
	}

	@keyframes pulse {
		0% {
			box-shadow: 0 0 0 0 rgba(74, 144, 226, 0.7);
		}
		70% {
			box-shadow: 0 0 0 10px rgba(74, 144, 226, 0);
		}
		100% {
			box-shadow: 0 0 0 0 rgba(74, 144, 226, 0);
		}
	}

	/* 响应式设计 */
	@media (max-width: 768px) {
		.draggable-icon {
			width: 50px !important;
			height: 50px !important;
		}
	}

	@media (max-width: 480px) {
		.draggable-icon {
			width: 44px !important;
			height: 44px !important;
		}
	}
</style>

2.点击图标弹出对话框,对话框支持自由拖拽和调节大小。

3.对话框中实现基础问答,复制答案,会话整理

实现代码:

1.实现窗口大小变化处理弹窗尺寸。

2.实现拖拽和缩放。

javascript 复制代码
<template>
	<div
		v-if="open"
		class="chat-container"
		ref="chatContainer"
		:style="{
			width: containerWidth + 'px',
			height: containerHeight + 'px',
			top: containerTop + 'px',
			left: containerLeft + 'px'
		}"
		@mousedown="startResize"
	>
		<div class="leftDom">
			<div class="chat-header1" @mousedown.stop="startDrag">降碳小助手</div>
			<div class="headerTitle"></div>
			<div class="sessionList">
				<div
					v-for="item in leftSessionList"
					:key="item.id"
					class="sessionItem"
					@click="sessionActiveChange(item)"
					:class="{ sessionSelect: item.id == sessionActive }"
				>
					<div class="stickIcon"><PushpinOutlined v-show="item.top == 1" /></div>
					<div
						class="content footprion-ellipsis"
						:title="item.sessionName"
						@mouseenter="handleMouseEnter(item.id)"
						@mouseleave="handleMouseLeave(item.id)"
						v-if="!reNameShow[item.id]"
					>
						{{ item.sessionName }}
					</div>
					<a-input
						v-else
						v-model:value="sessionName"
						@blur.stop="!isEnterTrigger && handleConfirm(item, 'blur')"
						@keydown.enter.exact.stop.prevent="handleEnter(item)"
						autocomplete="off"
						placeholder="回车修改内容"
						class="w-full"
						@mousedown.stop
					/>
					<div @mouseenter="itemHoverStatus[item.id] = true" @mouseleave="itemHoverStatus[item.id] = false">
						<a-dropdown class="moreButton" :placement="placement" arrow trigger="hover">
							<DashOutlined v-show="itemHoverStatus[item.id] || String(item.id) == String(sessionActive)" />
							<template #overlay>
								<a-menu>
									<a-menu-item>
										<div @click.stop="reNameClick(item)">重命名</div>
									</a-menu-item>
									<a-menu-item>
										<div @click.stop="delectSessionName(item)">删除</div>
									</a-menu-item>
									<a-menu-item>
										<div @click.stop="stick(item)">{{ item.top == 0 ? '置顶' : '取消置顶' }}</div>
									</a-menu-item>
								</a-menu>
							</template>
						</a-dropdown>
					</div>
				</div>
			</div>
			<div class="sendButton">
				<a-button class="w-full h-full" @click="newSession"><PlusOutlined />开启新会话</a-button>
			</div>
			<!-- <div class="endTime">当前数据截止至{{}}-----</div> -->
		</div>
		<div class="rightDom">
			<div @mousedown.stop="startDrag" class="chat-header2">
				<div class="closeButton" @click="closeChange" :disabled="false">
					<CloseOutlined />
				</div>
			</div>
			<div class="sendDom AiTitleContent" v-if="!sessionActive">开始和降碳小助手聊天吧!</div>
			<div class="sendDom" v-else>
				<div v-for="item in chatList" :key="item.id">
					<!-- 用户提问:右对齐 -->
					<div class="sendRight-date">
						{{ item.createTime }}
					</div>
					<div class="sendDom-right">
						<div class="sendDom-bubble right-bubble">
							<div class="sendDom-content" v-html="formatContent(item.sentMessage)"></div>
						</div>
					</div>
					<!-- 助手回答:左对齐 -->
					<div class="sendDom-left">
						<div class="sendDom-avatar">AI</div>
						<div class="sendDom-bubble left-bubble">
							<div
								class="sendDom-content"
								ref="leftIntter"
								v-if="item.returnMessage"
								v-html="formatContent(item.returnMessage)"
							></div>
							<div class="sendDom-content" v-else>
								<loading-outlined v-if="isLoading" />
								<div v-else>暂停请求</div>
							</div>
							<a-divider class="left-line" />
							<div class="flex justify-between">
								<div class="declaration"><SoundOutlined />&nbsp;&nbsp;本回答由 AI 生成,内容仅供参考,请仔细甄别。</div>
								<CopyOutlined class="copy-icon" id="copy-btn" @click="copyInnter(item)" />
							</div>
						</div>
					</div>
				</div>
			</div>
			<div class="templateDom">
				<div v-for="item in templateListData" :key="item.id" class="exTitle" @click="exTitleChange(item.title)">
					{{ item.title }}
				</div>
			</div>

			<div class="textArea">
				<a-textarea
					v-model:value="userInput"
					@keydown.enter.exact.prevent="sendMessage"
					:placeholder="'输入消息(Shift+Enter换行,Enter发送)...'"
					class="message-input h-4/6"
					show-count
					:maxlength="1000"
					@mousedown.stop
				/>
				<div class="buttonDom">
					<div class="sendButton" :title="'发送'" v-if="!isLoading">
						<UiwSend v-if="userInput" @click="sendMessage" /><UiwSend2 v-else />
					</div>
					<div class="sendButton" :title="'生成中'" v-else>
						<!-- <UiwStop2 @click="sendStop"></UiwStop2> -->
						<LoadingOutlined />
					</div>
				</div>
			</div>
		</div>
	</div>
</template>

<script setup name="deepSeekAIModal">
	import { ref, onMounted, nextTick, watch, onUnmounted, computed } from 'vue'
	import { Modal, message } from 'ant-design-vue'
	import { globalStore, viewTagsStore } from '@/store'
	import tool from '@/utils/tool'
	import UiwSend from '@/assets/icons/uiw/UiwSend.vue'
	import UiwSend2 from '@/assets/icons/uiw/UiwSend2.vue'
	import UiwStop2 from '@/assets/icons/uiw/UiwStop2.vue'
	const storeTags = viewTagsStore()
	import AIModalApi from '@/api/auth/AIModalApi'
	import Clipboard from 'clipboard'

	// 窗口尺寸状态
	const windowSize = reactive({
		width: window.innerWidth,
		height: window.innerHeight
	})

	// 容器尺寸和位置状态
	const chatContainer = ref(null)
	const containerWidth = ref(0)
	const containerHeight = ref(0)
	const containerTop = ref(0)
	const containerLeft = ref(0)

	// 响应式尺寸计算
	const responsiveSize = computed(() => {
		const { width: winWidth, height: winHeight } = windowSize

		// 根据窗口大小计算合适的弹窗尺寸
		let width = Math.min(winWidth * 0.8, 1200) // 最大为窗口宽度的80%,不超过1200px
		let height = Math.min(winHeight * 0.8, 900) // 最大为窗口高度的80%,不超过900px

		// 最小尺寸限制
		width = Math.max(width, 600)
		height = Math.max(height, 500)

		return { width, height }
	})

	// 最小尺寸限制
	const minHeight = computed(() => 500)
	const minWidth = computed(() => 600)

	// 拖拽和缩放状态
	const isDragging = ref(false)
	const dragStartPos = ref({ x: 0, y: 0 })
	const isResizing = ref(false)
	const resizeDir = ref('')
	const resizeStartPos = ref({ x: 0, y: 0 })
	const resizeStartSize = ref({ width: 0, height: 0 })
	const edgeSize = 5
	const placement = ref('bottomLeft')

	// 更新窗口尺寸
	const updateWindowSize = () => {
		windowSize.width = window.innerWidth
		windowSize.height = window.innerHeight
	}

	// 窗口大小变化处理 - 只调整尺寸
	const handleWindowResize = () => {
		updateWindowSize()

		// 如果弹窗打开状态,重新计算尺寸和位置
		if (open.value) {
			nextTick(() => {
				// 获取新的响应式尺寸
				const { width: respWidth, height: respHeight } = responsiveSize.value

				// 更新弹窗尺寸
				containerWidth.value = respWidth
				containerHeight.value = respHeight

				// 重新居中弹窗
				centerModal()

				// 确保弹窗在可视区域内
				constrainToBounds()
			})
		}
	}

	// 限制弹窗在可视区域内
	const constrainToBounds = () => {
		const { width: winWidth, height: winHeight } = windowSize

		// 确保弹窗不会超出屏幕边界
		containerLeft.value = Math.max(0, Math.min(containerLeft.value, winWidth - containerWidth.value))
		containerTop.value = Math.max(0, Math.min(containerTop.value, winHeight - containerHeight.value))

		// 确保弹窗尺寸不会小于最小值
		containerWidth.value = Math.max(minWidth.value, Math.min(containerWidth.value, winWidth))
		containerHeight.value = Math.max(minHeight.value, Math.min(containerHeight.value, winHeight))
	}

	// 居中显示弹窗
	const centerModal = () => {
		const { width: respWidth, height: respHeight } = responsiveSize.value
		containerWidth.value = respWidth
		containerHeight.value = respHeight
		containerTop.value = Math.max(0, (windowSize.height - respHeight) / 2)
		containerLeft.value = Math.max(0, (windowSize.width - respWidth) / 2)
	}

	// 初始化
	onMounted(() => {
		sessionActive.value = ''
		deepSeekSessionPage()

		// 监听全局点击事件
		document.addEventListener('mousemove', handleMouseMove)
		document.addEventListener('mouseup', handleMouseUp)
		window.addEventListener('resize', handleWindowResize)

		// 初始化窗口尺寸
		updateWindowSize()
	})

	// 清理事件监听
	onUnmounted(() => {
		document.removeEventListener('mousemove', handleMouseMove)
		document.removeEventListener('mouseup', handleMouseUp)
		window.removeEventListener('resize', handleWindowResize)
	})

	// 开始拖拽(顶部导航栏)
	const startDrag = (e) => {
		if (e.target.closest('.chat-header1') || e.target.closest('.chat-header2')) {
			e.preventDefault()
			isDragging.value = true
			dragStartPos.value = {
				x: e.clientX - containerLeft.value,
				y: e.clientY - containerTop.value
			}
			if (chatContainer.value) {
				chatContainer.value.style.cursor = 'grabbing'
			}
		}
	}

	// 处理鼠标移动(拖拽和缩放)
	const handleMouseMove = (e) => {
		// 处理拖拽
		if (isDragging.value) {
			let newLeft = e.clientX - dragStartPos.value.x
			let newTop = e.clientY - dragStartPos.value.y

			// 边界检查,确保窗口不会移出屏幕
			const { width: winWidth, height: winHeight } = windowSize

			newLeft = Math.max(0, newLeft)
			newLeft = Math.min(winWidth - containerWidth.value, newLeft)
			newTop = Math.max(0, newTop)
			newTop = Math.min(winHeight - containerHeight.value, newTop)

			containerLeft.value = newLeft
			containerTop.value = newTop
			return
		}

		// 处理缩放
		if (isResizing.value && resizeDir.value) {
			const dx = e.clientX - resizeStartPos.value.x
			const dy = e.clientY - resizeStartPos.value.y
			let newWidth = resizeStartSize.value.width
			let newHeight = resizeStartSize.value.height
			let newLeft = containerLeft.value
			let newTop = containerTop.value

			const { width: winWidth, height: winHeight } = windowSize

			switch (resizeDir.value) {
				case 'right':
					newWidth = Math.max(minWidth.value, Math.min(resizeStartSize.value.width + dx, winWidth - newLeft))
					break
				case 'left':
					newWidth = Math.max(minWidth.value, Math.min(resizeStartSize.value.width - dx, winWidth - newLeft))
					newLeft = Math.max(0, resizeStartPos.value.x - (newWidth - resizeStartSize.value.width))
					break
				case 'bottom':
					newHeight = Math.max(minHeight.value, Math.min(resizeStartSize.value.height + dy, winHeight - newTop))
					break
				case 'top':
					newHeight = Math.max(minHeight.value, Math.min(resizeStartSize.value.height - dy, winHeight - newTop))
					newTop = Math.max(0, resizeStartPos.value.y - (newHeight - resizeStartSize.value.height))
					break
				case 'bottom-right':
					newWidth = Math.max(minWidth.value, Math.min(resizeStartSize.value.width + dx, winWidth - newLeft))
					newHeight = Math.max(minHeight.value, Math.min(resizeStartSize.value.height + dy, winHeight - newTop))
					break
				case 'bottom-left':
					newWidth = Math.max(minWidth.value, Math.min(resizeStartSize.value.width - dx, winWidth - newLeft))
					newHeight = Math.max(minHeight.value, Math.min(resizeStartSize.value.height + dy, winHeight - newTop))
					newLeft = Math.max(0, resizeStartPos.value.x - (newWidth - resizeStartSize.value.width))
					break
				case 'top-right':
					newWidth = Math.max(minWidth.value, Math.min(resizeStartSize.value.width + dx, winWidth - newLeft))
					newHeight = Math.max(minHeight.value, Math.min(resizeStartSize.value.height - dy, winHeight - newTop))
					newTop = Math.max(0, resizeStartPos.value.y - (newHeight - resizeStartSize.value.height))
					break
				case 'top-left':
					newWidth = Math.max(minWidth.value, Math.min(resizeStartSize.value.width - dx, winWidth - newLeft))
					newHeight = Math.max(minHeight.value, Math.min(resizeStartSize.value.height - dy, winHeight - newTop))
					newLeft = Math.max(0, resizeStartPos.value.x - (newWidth - resizeStartSize.value.width))
					newTop = Math.max(0, resizeStartPos.value.y - (newHeight - resizeStartSize.value.height))
					break
			}

			// 边界检查
			newLeft = Math.max(0, newLeft)
			newLeft = Math.min(winWidth - newWidth, newLeft)
			newTop = Math.max(0, newTop)
			newTop = Math.min(winHeight - newHeight, newTop)

			containerWidth.value = newWidth
			containerHeight.value = newHeight
			containerLeft.value = newLeft
			containerTop.value = newTop

			return
		}

		// 检测鼠标是否在边缘,显示相应光标
		if (chatContainer.value) {
			const rect = chatContainer.value.getBoundingClientRect()
			const x = e.clientX
			const y = e.clientY

			resizeDir.value = ''
			chatContainer.value.style.cursor = 'default'

			const isLeft = x >= rect.left && x <= rect.left + edgeSize
			const isRight = x >= rect.right - edgeSize && x <= rect.right
			const isTop = y >= rect.top && y <= rect.top + edgeSize
			const isBottom = y >= rect.bottom - edgeSize && y <= rect.bottom

			if (isLeft && isTop) {
				resizeDir.value = 'top-left'
				chatContainer.value.style.cursor = 'nwse-resize'
			} else if (isRight && isTop) {
				resizeDir.value = 'top-right'
				chatContainer.value.style.cursor = 'nesw-resize'
			} else if (isLeft && isBottom) {
				resizeDir.value = 'bottom-left'
				chatContainer.value.style.cursor = 'nesw-resize'
			} else if (isRight && isBottom) {
				resizeDir.value = 'bottom-right'
				chatContainer.value.style.cursor = 'nwse-resize'
			} else if (isLeft) {
				resizeDir.value = 'left'
				chatContainer.value.style.cursor = 'ew-resize'
			} else if (isRight) {
				resizeDir.value = 'right'
				chatContainer.value.style.cursor = 'ew-resize'
			} else if (isTop) {
				resizeDir.value = 'top'
				chatContainer.value.style.cursor = 'ns-resize'
			} else if (isBottom) {
				resizeDir.value = 'bottom'
				chatContainer.value.style.cursor = 'ns-resize'
			}
		}
	}

	// 开始缩放
	const startResize = (e) => {
		// 只有点击边缘(resizeDir有值)且不是消息区域时,才执行缩放逻辑
		if (resizeDir.value) {
			e.preventDefault()
			isResizing.value = true
			resizeStartPos.value = { x: e.clientX, y: e.clientY }
			resizeStartSize.value = {
				width: containerWidth.value,
				height: containerHeight.value
			}
		}
	}

	// 结束拖拽和缩放
	const handleMouseUp = () => {
		if (isDragging.value) {
			isDragging.value = false
			if (chatContainer.value) {
				chatContainer.value.style.cursor = 'default'
			}
		}

		if (isResizing.value) {
			isResizing.value = false
		}
	}

	// 关闭弹窗
	const open = ref(false)
	const closeChange = () => {
		storeTags.AIModalOpenChange(false)
	}

	// 格式化AI内容
	const formatContent = (content) => {
		if (!content) return ''
		console.log('content', content)
		return (
			content
				// 替换所有换行符(包括 \n、\r\n、\r)
				.replace(/\r?\n|\r/g, '<br/>')
				// 处理可能的转义换行符(如果后端返回 \\n)
				.replace(/\\n/g, '<br/>')
				//原有格式处理(加粗、斜体)
				.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
				.replace(/\*(.*?)\*/g, '<em>$1</em>')
		)
	}
	//左侧会话列表数据
	const itemHoverStatus = ref({})
	const handleMouseEnter = (itemId) => {
		itemHoverStatus.value[itemId] = true
	}

	// 鼠标离开容器(完全离开父组件和下拉框)
	const handleMouseLeave = (itemId) => {
		itemHoverStatus.value[itemId] = false
	}
	const leftSessionList = ref([])
	const deepSeekSessionPage = async () => {
		try {
			const res = await AIModalApi.getDeepSeekSessionPage()
			leftSessionList.value = res.records
			res.records.forEach((item) => {
				itemHoverStatus.value[item.id] = false // 明确初始值,确保响应式
			})
			await nextTick()
		} catch {}
	}

	const sessionActive = ref('')
	const sessionActiveChange = (item) => {
		sessionActive.value = item.id
		sessionIdClick()
	}
	//修改选中会话title
	const sessionName = ref('')
	const reNameShow = ref({})
	const reNameClick = (item) => {
		sessionName.value = item.sessionName
		reNameShow.value[item.id] = true
	}
	//置顶
	const stick = async (item) => {
		console.log('item', item)
		if (item.top == 0) {
			await AIModalApi.deepseekSessionEdit({
				id: item.id,
				top: 1
			})
		} else {
			await AIModalApi.deepseekSessionEdit({
				id: item.id,
				top: 0
			})
		}
		deepSeekSessionPage()
	}
	const isEnterTrigger = ref(false)
	const handleEnter = async (item) => {
		isEnterTrigger.value = true // 标记为回车触发
		await handleConfirm(item, 'enter') // 执行确认逻辑
		// 重置标记(避免后续失焦误触发)
		setTimeout(() => {
			isEnterTrigger.value = false
		}, 100)
	}
	const handleConfirm = (item, trigger) => {
		// 输入为空时不触发
		if (!sessionName.value.trim()) {
			message.warning('标题不能为空')
			// 失焦时如果输入为空,恢复显示原标题(可选)
			if (trigger === 'blur') {
				sessionName.value = item.sessionName // 假设item有原标题字段
				reNameShow.value[item.id] = false
			}
			return
		}

		Modal.confirm({
			title: `是否要将标题修改为:${sessionName.value}`,
			onOk: async () => {
				try {
					await AIModalApi.deepseekSessionEdit({
						id: item.id,
						sessionName: sessionName.value
					})
					// message.success('修改成功')
					return Promise.resolve()
				} catch (error) {
					// message.warning('修改失败')
				} finally {
					deepSeekSessionPage() // 刷新列表
					reNameShow.value[item.id] = false // 隐藏输入框,显示文本
				}
			},
			onCancel() {
				sessionName.value = item.sessionName
				reNameShow.value[item.id] = false
			}
		})
	}
	//删除会话
	const delectSessionName = (item) => {
		Modal.confirm({
			title: `是否要删除'${item.sessionName}'这一项`,
			onOk: async () => {
				try {
					await AIModalApi.deepseekSessionDelete([
						{
							id: item.id
						}
					])
					sessionActive.value = ''
					return Promise.resolve()
				} catch (error) {
					message.warning('删除失败')
				} finally {
					deepSeekSessionPage()
					chatList.value = []
				}
			},
			onCancel() {
				console.log('取消删除')
			}
		})
	}
	//添加新会话
	const newSession = () => {
		sessionActive.value = ''
		chatList.value = []
	}
	//右侧
	const userInput = ref('')
	const isLoading = ref(false)
	const tempMessageId = ref(null)
	//控制器实例
	const abortController = ref(null)
	const sendMessage = async () => {
		scrollToBottom()
		if (!userInput.value.trim()) return
		const tempId = Date.now()
		const inputValue = userInput.value
		userInput.value = ''
		tempMessageId.value = tempId

		// 创建新的AbortController实例
		abortController.value = new AbortController()
		const { signal } = abortController.value
		isLoading.value = true
		const userMessage = {
			id: tempId,
			sentMessage: inputValue.trim(),
			createTime: new Date().toLocaleString()
		}
		chatList.value.push(userMessage)
		await nextTick()
		try {
			// 新会话,就赋值
			if (!sessionActive.value) {
				const sessionRes = await AIModalApi.deepseekSessionDeleteAdd({ sessionName: inputValue })
				deepSeekSessionPage()
				sessionActive.value = sessionRes
			}
			try {
				const requestData = {
					message: userMessage.sentMessage,
					sessionId: sessionActive.value || ''
				}

				// 将signal传入请求配置
				const res = await AIModalApi.getdeepseekApi(requestData, { signal })
				isLoading.value = false

				// 替换加载消息为实际回复
				const index = chatList.value.findIndex((item) => item.id == tempId)
				if (index !== -1) {
					chatList.value.splice(index, 1, {
						id: Date.now(),
						sentMessage: inputValue.trim(),
						returnMessage: res,
						createTime: new Date().toLocaleString()
					})
				}
			} catch (error) {
				// 忽略中断错误,处理其他错误
				if (error.name !== 'AbortError') {
					const index = chatList.value.findIndex((item) => item.id === tempId)
					if (index !== -1) {
						chatList.value.splice(index, 1, {
							id: Date.now(),
							sentMessage: inputValue.trim(),
							returnMessage: '服务器繁忙,请稍后再试。',
							createTime: new Date().toLocaleString()
						})
					}
				}
			} finally {
				tempMessageId.value = null
				isLoading.value = false // 确保重置加载状态
				abortController.value = null // 清空控制器
				await nextTick()
				scrollToBottom()
			}
		} catch {
			message.warning('新增会话失败。')
		}
	}

	// 修改sendStop方法,实现中断请求
	const sendStop = () => {
		if (abortController.value) {
			// 中断当前请求
			abortController.value.abort()
			// 立即更新加载状态
			isLoading.value = false
			// 清除临时消息的加载状态
			if (tempMessageId.value) {
				const index = chatList.value.findIndex((item) => item.id === tempMessageId.value)
				if (index !== -1) {
					chatList.value.splice(index, 1, {
						id: Date.now(),
						sentMessage: chatList.value[index].sentMessage,
						returnMessage: '请求已取消',
						createTime: new Date().toLocaleString()
					})
				}
				tempMessageId.value = null
			}
			abortController.value = null
		}
	}
	// 滚动到底部的方法
	const scrollToBottom = () => {
		const sendDom = document.querySelector('.sendDom')
		if (sendDom) {
			sendDom.scrollTop = sendDom.scrollHeight
		}
	}
	// 监听窗口打开状态
	watch(
		() => storeTags.AIModalOpen,
		(newValue) => {
			open.value = newValue
			if (newValue) {
				centerModal()
				deepSeekSessionPage()
			}
		},
		{ immediate: true }
	)
	//聊天区
	const chatList = ref([])
	const sessionIdClick = async () => {
		if (sessionActive.value) {
			const res = await AIModalApi.getdeepSeekLogQueryBySessionId({ sessionId: sessionActive.value })
			chatList.value = res
		}
	}
	//文本复制
	const leftIntter = ref(null)
	const copyInnter = async (item) => {
		try {
			if (!leftIntter.value) {
				message.error('复制失败:未找到内容')
				return
			}
			const content = item.returnMessage

			if (!content.trim()) {
				message.warn('没有可复制的内容')
				return
			}

			// 写入剪贴板
			await navigator.clipboard.writeText(content)
			message.success('复制成功!')
		} catch (error) {
			message.error('复制失败,请重试')
		}
	}

	const templateListData = [
		{ id: 1, title: '预测下周能耗情况' },
		{ id: 2, title: '预测下月能耗情况' },
		{ id: 3, title: '预测明年能耗情况' }
	]
	const exTitleChange = (value) => {
		userInput.value = value
		sendMessage()
	}
</script>

<style scoped lang="less">
	.chat-container {
		display: flex;
		position: absolute;
		background-color: #eef0f8;
		border: 2px solid rgba(5, 56, 131, 0.8);
		border-radius: 10px;
		z-index: 999;
		width: 100%;
		touch-action: none;
		overflow: hidden;
		user-select: text !important;
		-webkit-user-select: text !important;
		pointer-events: auto;
		min-width: 600px;
		min-height: 500px;
		box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);

		.leftDom {
			width: 30%;
			border-right: 1px solid #ccc;
			height: 100%;
			padding: 10px;
			box-sizing: border-box;
			background-color: #e1e5fa;
			display: flex;
			flex-direction: column;
			min-width: 180px;

			.chat-header1 {
				height: 40px;
				font-size: 20px;
				cursor: pointer;
				display: flex;
				align-items: center;
				font-weight: bold;
				color: #053883;
			}
			.sessionList {
				flex: 1;
				overflow: hidden;
				overflow-y: auto;
				min-height: 200px;

				.sessionItem {
					height: 50px;
					line-height: 50px;
					padding: 0 10px;
					margin-bottom: 5px;
					border-radius: 10px;
					display: flex;
					justify-content: space-between;
					align-items: center;
					transition: all 0.2s ease;

					.content {
						flex: 1;
						overflow: hidden;
						text-overflow: ellipsis;
						white-space: nowrap;
					}
					.iconMore {
						cursor: pointer;
					}
					.moreButton {
						width: 30px;
						margin: 0 auto;
						pointer-events: auto;
					}
					.stickIcon {
						color: #8d8d8d;
						margin-right: 10px;
						display: flex;
						align-items: center;
					}
				}
				.sessionItem:hover {
					background-color: #aab4e7de;
					transform: translateY(-1px);
				}
				.sessionSelect {
					background-color: #aab4e7de;
				}
			}
			.sendButton {
				height: 40px;
				margin: 10px 0;
				min-height: 40px;

				:deep(.ant-btn) {
					height: 100%;
					display: flex;
					align-items: center;
					justify-content: center;
				}
			}
			.endTime {
				height: 20px;
				color: #5e5e5e;
				font-size: 12px;
				text-align: center;
			}
		}
		.rightDom {
			width: 70%;
			height: 100%;
			padding: 10px;
			display: flex;
			flex-direction: column;
			box-sizing: border-box;
			min-width: 420px;

			.chat-header2 {
				height: 40px;
				line-height: 40px;
				cursor: pointer;
				display: flex;
				justify-content: flex-end;

				.closeButton {
					width: 50px;
					text-align: center;
					font-size: 16px;
					transition: all 0.2s ease;
					border-radius: 5px;
					&:hover {
						background-color: rgba(0, 0, 0, 0.1);
					}
				}
			}
			.AiTitleContent {
				display: flex;
				justify-content: center;
				align-items: center;
				font-size: 20px;
				color: #1026a077;
				height: 100%;
				font-weight: bold;
			}
			.loadingSend {
				color: #000;
			}
			.sendDom {
				flex: 1;
				overflow-y: auto;
				box-sizing: border-box;
				padding: 10px;
				min-height: 300px;

				.sendRight-date {
					display: flex;
					justify-content: flex-end;
					font-size: 12px;
					color: #666;
					margin-bottom: 5px;
				}
				.sendDom-left,
				.sendDom-right {
					display: flex;
					align-items: flex-start;
					gap: 8px;
					margin-bottom: 15px;
				}
				.sendDom-left {
					justify-content: flex-start;

					.left-line {
						background-color: rgb(0, 0, 0);
						margin: 10px 0;
					}
					.declaration {
						display: flex;
						color: rgb(53, 182, 92);
						font-size: 12px;
						align-items: center;
					}
					.copy-icon {
						cursor: pointer;
						color: #666;
						transition: color 0.2s;

						&:hover {
							color: #1890ff;
						}
					}
				}

				.sendDom-right {
					justify-content: flex-end;
				}
				.sendDom-avatar {
					width: 40px;
					height: 40px;
					border-radius: 50%;
					background-color: #f8b045;
					display: flex;
					align-items: center;
					justify-content: center;
					color: #fff;
					flex-shrink: 0;
					font-weight: bold;
					font-size: 14px;
				}
				.sendDom-bubble {
					max-width: 80%;
					padding: 15px;
					border-radius: 10px;
					box-sizing: border-box;
					position: relative;
					word-wrap: break-word;
				}
				.right-bubble {
					background-color: #41f098;
					border: 1px solid #e5e5e5;
					border-top-right-radius: 0px;
				}
				.left-bubble {
					width: 80%;
					background-color: #ffffff;
					border: 1px solid #e5e5e5;
					border-top-left-radius: 0px;
				}
				.sendDom-content {
					line-height: 1.5;
				}
			}
			.textArea {
				height: 150px;
				border: 1px solid rgb(187, 187, 187);
				background-color: #dde1fa;
				padding: 10px;
				border-radius: 10px;
				box-sizing: border-box;
				min-height: 120px;
				display: flex;
				flex-direction: column;

				:deep(.ant-input) {
					flex: 1;
					resize: none;
				}

				.buttonDom {
					display: flex;
					width: 100%;
					justify-content: flex-end;
					cursor: pointer;
					margin-top: 15px;
					.sendButton {
						width: 30px;
						height: 30px;
						display: flex;
						align-items: center;
						justify-content: center;
						border-radius: 5px;
						transition: all 0.2s;

						&:hover {
							background-color: rgba(0, 0, 0, 0.1);
						}

						.icon {
							font-size: 10px;
							width: 30px;
							height: 30px;
						}
					}
				}
			}
			::v-deep(textarea),
			::v-deep(.ant-input:focus) {
				border: none !important;
				box-shadow: none !important;
				background: transparent;
			}
		}

		::-webkit-scrollbar-thumb {
			background-color: #8183f170 !important;
			border-radius: 0.02083rem;
		}

		::-webkit-scrollbar-track {
			background-color: #8c878700;
			border-radius: 0.02083rem;
		}
	}
	:deep(.ant-pagination.ant-pagination-simple .ant-pagination-simple-pager input) {
		background: rgba(255, 0, 0, 0) !important;
	}

	:deep(.ant-pagination) {
		color: #000 !important;
	}

	:deep(.ant-input) {
		color: #000;
		border: 1px solid rgb(141, 141, 141);
	}
	.templateDom {
		width: 100%;
		max-height: 50px;
		overflow-x: auto;
		overflow-y: hidden;
		display: flex;
		margin-top: 10px;
		min-height: 40px;

		.exTitle {
			width: fit-content;
			border: 1px solid rgba(7, 32, 70, 0.3);
			border-radius: 10px;
			font-size: 14px;
			padding: 5px 10px;
			white-space: nowrap;
			margin-right: 10px;
			margin-bottom: 10px;
			cursor: pointer;
			transition: all 0.2s;
			background: white;

			&:hover {
				background: #f0f0f0;
				transform: translateY(-1px);
			}
		}
	}
</style>
相关推荐
222you3 小时前
SpringBoot+Vue项目创建
前端·javascript·vue.js
天问一3 小时前
前端通过用户权限来显示对应权限的页面
前端·html
222you3 小时前
vue目录文件夹的作用
前端·javascript·vue.js
月屯3 小时前
pandoc安装与使用(html、makdown转docx、pdf)
前端·pdf·html·pandoc·转docx、pdf
TechTrek3 小时前
英伟达推出CUDA 13.1版本,DeepSeek V3到V3.2技术演进全解析
英伟达·deepseek·cuda 13.1·lightx2v
我爱学习_zwj3 小时前
Node.js模块化入门指南
前端·node.js
Shirley~~3 小时前
开源项目PPtist分享
前端·typescript·vue
yanghuashuiyue4 小时前
TypeScript是JavaScript超集-百度AI灵魂拷问
前端·javascript·typescript
光头程序员4 小时前
Vite 前端项目 - CSS变量智能提示
前端·css