Commit 0871b91b by pangchong

feat(aiAssistant): 实现悬浮球拖拽与磁力吸边功能

- 支持悬浮球自由拖拽,拖动时样式动态更新为抓取样式
- 松开时根据屏幕中心自动磁力吸附至左侧或右侧边缘
- 气泡方向根据吸附边自动反转,避免遮挡编辑界面
- 增加悬浮球上下锚点,支持顶部或底部多点吸附
- 对话窗口可拖动,未拖动时保持右下角贴边自适应
- 窗口位置和悬浮球位置在视口变化时自动调整约束
- 添加悬浮球位置和停靠边状态持久化存储支持
- 新增帮助文档条目,介绍悬浮球移动及吸边操作流程
parent 462e6b32
...@@ -1250,6 +1250,38 @@ export const FAQ_DATABASE: FaqItem[] = [ ...@@ -1250,6 +1250,38 @@ export const FAQ_DATABASE: FaqItem[] = [
'展示内容包含 Naive UI 全部语义化颜色(Primary, Info, Success, Warning, Error)、字阶规范、圆角与阴影 Token,并支持一键复制色值。' '展示内容包含 Naive UI 全部语义化颜色(Primary, Info, Success, Warning, Error)、字阶规范、圆角与阴影 Token,并支持一键复制色值。'
], ],
relatedQuestions: ['faq_theme_settings', 'faq_primary_colors'] relatedQuestions: ['faq_theme_settings', 'faq_primary_colors']
},
{
id: 'faq_ai_assistant_drag_dock',
category: 'settings',
categoryName: '设置与快捷键',
title: '如何移动工卡智能助手悬浮球?悬浮球如何磁力吸边?',
keywords: [
'智能助手',
'悬浮球',
'吸边',
'移动助手',
'磁力吸边',
'ai位置',
'ai助手',
'拖动小助手',
'ai',
'znzs',
'xfq',
'xb',
'clxb'
],
synonyms: ['怎么拖动智能小助手', '悬浮客服可以挪动吗', '小助手吸附边缘', '悬浮图标位置调整'],
answer: '智能工卡助手悬浮球支持自由拖动与智能磁力吸边自适应:',
steps: [
'【自由拖动】:按住右下角(或左侧)的智能助手悬浮球,即可在屏幕可视区域内任意拖动位置。',
'【磁力吸附】:松开鼠标或触摸后,悬浮球会自动根据屏幕中线判断,平滑吸附停靠到最近的左侧或右侧边缘(吸边间距 16px)。',
'【自适应窗口与控制台】:悬浮球采用响应式边缘停靠机制,无论打开/关闭浏览器 F12 控制台还是缩放窗口,都会紧贴边缘,永不脱轨或滞留在屏幕中央。'
],
highlights: [
'悬浮球靠右吸附时,气泡与提示语向左侧展开;靠左吸附时自动反转方向(头像在左、气泡向右),确保不遮挡主要编辑界面。'
],
relatedQuestions: ['faq_theme_settings', 'faq_keyboard_shortcuts']
} }
] ]
......
...@@ -5,19 +5,20 @@ ...@@ -5,19 +5,20 @@
<div <div
v-if="!isOpen" v-if="!isOpen"
ref="badgeRef" ref="badgeRef"
class="fixed z-40 flex items-center flex-nowrap whitespace-nowrap group cursor-grab active:cursor-grabbing select-none" class="fixed z-40 flex items-center flex-nowrap whitespace-nowrap group select-none"
:class="[isBadgeSnapping ? 'transition-all duration-300 ease-out' : '']" :class="[
:style="{ isDraggingBadge ? 'cursor-grabbing' : 'cursor-grab',
left: `${badgePos.x}px`, isBadgeSnapping ? 'transition-all duration-300 ease-out' : '',
top: `${badgePos.y}px`, badgeSide === 'left' ? 'flex-row-reverse' : 'flex-row'
touchAction: 'none' ]"
}" :style="badgeStyle"
@mousedown="handleBadgeMouseDown" @mousedown="handleBadgeMouseDown"
@touchstart.passive="handleBadgeTouchStart" @touchstart.passive="handleBadgeTouchStart"
> >
<!-- 悬浮小气泡提示语(强制单行不换行) --> <!-- 悬浮小气泡提示语(强制单行不换行) -->
<div <div
class="mr-3 px-3 py-1.5 rounded-full bg-card border border-divider shadow-lg text-xs font-medium text-color1 flex items-center gap-1.5 whitespace-nowrap shrink-0 opacity-90 group-hover:opacity-100 group-hover:scale-105 transition-all duration-200 pointer-events-none" class="px-3 py-1.5 rounded-full bg-card border border-divider shadow-lg text-xs font-medium text-color1 flex items-center gap-1.5 whitespace-nowrap shrink-0 opacity-90 group-hover:opacity-100 group-hover:scale-105 transition-all duration-200 pointer-events-none"
:class="[badgeSide === 'left' ? 'ml-3' : 'mr-3']"
:style="{ :style="{
borderColor: `color-mix(in srgb, ${themeVars.primaryColor} 30%, transparent)`, borderColor: `color-mix(in srgb, ${themeVars.primaryColor} 30%, transparent)`,
boxShadow: `0 4px 16px color-mix(in srgb, ${themeVars.primaryColor} 20%, transparent)` boxShadow: `0 4px 16px color-mix(in srgb, ${themeVars.primaryColor} 20%, transparent)`
...@@ -67,11 +68,7 @@ ...@@ -67,11 +68,7 @@
ref="windowRef" ref="windowRef"
class="fixed z-50 flex flex-col rounded-2xl border border-divider bg-card shadow-2xl overflow-hidden text-color1" class="fixed z-50 flex flex-col rounded-2xl border border-divider bg-card shadow-2xl overflow-hidden text-color1"
:class="[isMinimized ? 'h-[52px] w-[320px]' : 'h-[580px] w-[390px] max-h-[calc(100vh-32px)] max-w-[calc(100vw-32px)]']" :class="[isMinimized ? 'h-[52px] w-[320px]' : 'h-[580px] w-[390px] max-h-[calc(100vh-32px)] max-w-[calc(100vw-32px)]']"
:style="{ :style="winStyle"
left: `${winPos.x}px`,
top: `${winPos.y}px`,
boxShadow: `0 12px 36px rgba(0, 0, 0, 0.18), 0 0 1px ${themeVars.primaryColor}40`
}"
> >
<!-- 头部工具栏(按住可拖动窗口) --> <!-- 头部工具栏(按住可拖动窗口) -->
<div <div
...@@ -617,6 +614,10 @@ const { ...@@ -617,6 +614,10 @@ const {
isMinimized, isMinimized,
unreadCount, unreadCount,
activeCategory, activeCategory,
badgeSide,
badgeStyle,
winStyle,
isDraggingBadge,
inputQuery, inputQuery,
isTyping, isTyping,
isStreaming, isStreaming,
......
import type { AiAssistantState, Position } from './types' import type { AiAssistantState, Position, DockSide, AnchorY } from './types'
import type { ChatMessage, FaqCategory } from '@/layouts/components/AiAssistant/constants' import type { ChatMessage, FaqCategory } from '@/layouts/components/AiAssistant/constants'
import { DEFAULT_WELCOME_MESSAGE, HOT_QUESTIONS } from '@/layouts/components/AiAssistant/constants' import { DEFAULT_WELCOME_MESSAGE, HOT_QUESTIONS } from '@/layouts/components/AiAssistant/constants'
...@@ -11,8 +11,12 @@ export const useAiAssistantStore = defineStore('aiAssistant', { ...@@ -11,8 +11,12 @@ export const useAiAssistantStore = defineStore('aiAssistant', {
isMinimized: false, isMinimized: false,
unreadCount: 1, unreadCount: 1,
activeCategory: 'all', activeCategory: 'all',
badgeSide: 'right',
badgeAnchorY: 'bottom',
badgeOffsetY: 120,
badgePos: { x: 0, y: 0 }, badgePos: { x: 0, y: 0 },
winPos: { x: 0, y: 0 }, winPos: { x: 0, y: 0 },
winHasMoved: false,
messageList: [ messageList: [
{ {
id: 'msg_welcome', id: 'msg_welcome',
...@@ -33,12 +37,20 @@ export const useAiAssistantStore = defineStore('aiAssistant', { ...@@ -33,12 +37,20 @@ export const useAiAssistantStore = defineStore('aiAssistant', {
toggleMinimize() { toggleMinimize() {
this.isMinimized = !this.isMinimized this.isMinimized = !this.isMinimized
}, },
setBadgeDock(dock: { side: DockSide; anchorY?: AnchorY; offsetY?: number }) {
this.badgeSide = dock.side
if (dock.anchorY) this.badgeAnchorY = dock.anchorY
if (typeof dock.offsetY === 'number') this.badgeOffsetY = dock.offsetY
},
setBadgePos(pos: Position) { setBadgePos(pos: Position) {
this.badgePos = pos this.badgePos = pos
}, },
setWinPos(pos: Position) { setWinPos(pos: Position) {
this.winPos = pos this.winPos = pos
}, },
setWinHasMoved(moved: boolean) {
this.winHasMoved = moved
},
setActiveCategory(category: FaqCategory) { setActiveCategory(category: FaqCategory) {
this.activeCategory = category this.activeCategory = category
}, },
...@@ -70,7 +82,9 @@ export const useAiAssistantStore = defineStore('aiAssistant', { ...@@ -70,7 +82,9 @@ export const useAiAssistantStore = defineStore('aiAssistant', {
] ]
} }
}, },
persist: true persist: {
pick: ['unreadCount', 'activeCategory', 'badgeSide', 'badgeAnchorY', 'badgeOffsetY', 'messageList']
}
}) })
export * from './types' export * from './types'
...@@ -5,12 +5,20 @@ export interface Position { ...@@ -5,12 +5,20 @@ export interface Position {
y: number y: number
} }
export type DockSide = 'left' | 'right'
export type AnchorY = 'top' | 'bottom'
export interface AiAssistantState { export interface AiAssistantState {
isOpen: boolean isOpen: boolean
isMinimized: boolean isMinimized: boolean
unreadCount: number unreadCount: number
activeCategory: FaqCategory activeCategory: FaqCategory
badgeSide: DockSide
badgeAnchorY: AnchorY
badgeOffsetY: number
badgePos: Position badgePos: Position
winPos: Position winPos: Position
winHasMoved: boolean
messageList: ChatMessage[] messageList: ChatMessage[]
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment