feat(ai-assistant): 浮动按钮支持拖拽到边缘自动收起

- 拖拽按钮到屏幕边缘(≤12px)松开后自动收起为窄条
  - 鼠标悬停窄条时自动展开,离开 1 秒后收起
  - 点击收起状态的窄条直接打开 AI 助手
  - 收起/展开过渡动画平滑,按钮中心位置保持不变
  - 仅在 AI 插件安装后显示浮动按钮
This commit is contained in:
kuaifan 2026-01-16 07:46:41 +00:00
parent 12d6bbea19
commit 53dd9dca0f

View File

@ -2,18 +2,28 @@
<transition name="fade"> <transition name="fade">
<div <div
v-if="visible" v-if="visible"
class="ai-float-button-wrapper"
:class="wrapperClass"
:style="wrapperStyle"
@mouseenter="onMouseEnter"
@mouseleave="onMouseLeave">
<div
ref="floatBtn" ref="floatBtn"
class="ai-float-button" class="ai-float-button"
:class="btnClass"
:style="btnStyle" :style="btnStyle"
@mousedown.stop.prevent="onMouseDown"> @mousedown.stop.prevent="onMouseDown">
<svg class="no-dark-content" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"> <!-- 完整图标 -->
<svg class="ai-float-button-icon no-dark-content" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<path d="M385.80516777 713.87417358c-12.76971517 0-24.13100586-7.79328205-28.82575409-19.62404756l-48.91927648-123.9413531c-18.40341303-46.75969229-55.77360888-84.0359932-102.53330118-102.53330117l-123.94135309-48.91927649c-11.83076552-4.69474822-19.62404757-16.05603892-19.62404757-28.8257541s7.79328205-24.13100586 19.62404757-28.82575407l123.94135309-48.91927649c46.75969229-18.40341303 84.0359932-55.77360888 102.53330118-102.53330119l48.91927648-123.94135308c4.69474822-11.83076552 16.05603892-19.62404757 28.8257541-19.62404757s24.13100586 7.79328205 28.82575408 19.62404757l48.91927648 123.94135308c18.40341303 46.75969229 55.77360888 84.0359932 102.53330118 102.53330119l123.94135309 48.91927649c11.83076552 4.69474822 19.62404757 16.05603892 19.62404757 28.82575407 0 12.76971517-7.79328205 24.13100586-19.62404757 28.8257541l-123.94135309 48.91927649c-46.75969229 18.40341303-84.0359932 55.77360888-102.53330118 102.53330117l-48.91927648 123.9413531c-4.69474822 11.83076552-16.14993388 19.62404757-28.82575408 19.62404756zM177.45224165 390.12433614l50.89107073 20.0935224c62.62794129 24.69437565 112.67395736 74.74039171 137.368333 137.36833299l20.09352239 50.89107073 20.0935224-50.89107073c24.69437565-62.62794129 74.74039171-112.67395736 137.368333-137.36833299l50.89107072-20.0935224-50.89107073-20.09352239c-62.62794129-24.69437565-112.67395736-74.74039171-137.36833299-137.36833301l-20.09352239-50.89107074-20.0935224 50.89107074c-24.69437565 62.62794129-74.74039171 112.67395736-137.368333 137.36833301l-50.89107073 20.09352239zM771.33789183 957.62550131c-12.76971517 0-24.13100586-7.79328205-28.82575409-19.62404758l-26.6661699-67.6043744c-8.63833672-21.87752672-26.10280012-39.34199011-47.98032684-47.98032684l-67.60437441-26.6661699c-11.83076552-4.69474822-19.62404757-16.05603892-19.62404757-28.82575409s7.79328205-24.13100586 19.62404757-28.82575409l67.60437441-26.6661699c21.87752672-8.63833672 39.34199011-26.10280012 47.98032684-47.98032685l26.6661699-67.6043744c4.69474822-11.83076552 16.05603892-19.62404757 28.82575409-19.62404757s24.13100586 7.79328205 28.82575409 19.62404757l26.66616991 67.6043744c8.63833672 21.87752672 26.10280012 39.34199011 47.98032684 47.98032685l67.6043744 26.6661699c11.83076552 4.69474822 19.62404757 16.05603892 19.62404757 28.82575409s-7.79328205 24.13100586-19.62404757 28.82575409l-67.6043744 26.6661699c-21.87752672 8.63833672-39.34199011 26.10280012-47.98032684 47.98032684l-26.66616991 67.6043744c-4.69474822 11.83076552-16.14993388 19.62404757-28.82575409 19.62404758z m-75.58544639-190.70067281c33.61439727 14.83540438 60.75004201 41.87715415 75.49155143 75.49155143 14.83540438-33.61439727 41.87715415-60.75004201 75.49155142-75.49155143-33.61439727-14.83540438-60.75004201-41.87715415-75.49155142-75.49155143-14.74150942 33.61439727-41.87715415 60.75004201-75.49155143 75.49155143z"/> <path d="M385.80516777 713.87417358c-12.76971517 0-24.13100586-7.79328205-28.82575409-19.62404756l-48.91927648-123.9413531c-18.40341303-46.75969229-55.77360888-84.0359932-102.53330118-102.53330117l-123.94135309-48.91927649c-11.83076552-4.69474822-19.62404757-16.05603892-19.62404757-28.8257541s7.79328205-24.13100586 19.62404757-28.82575407l123.94135309-48.91927649c46.75969229-18.40341303 84.0359932-55.77360888 102.53330118-102.53330119l48.91927648-123.94135308c4.69474822-11.83076552 16.05603892-19.62404757 28.8257541-19.62404757s24.13100586 7.79328205 28.82575408 19.62404757l48.91927648 123.94135308c18.40341303 46.75969229 55.77360888 84.0359932 102.53330118 102.53330119l123.94135309 48.91927649c11.83076552 4.69474822 19.62404757 16.05603892 19.62404757 28.82575407 0 12.76971517-7.79328205 24.13100586-19.62404757 28.8257541l-123.94135309 48.91927649c-46.75969229 18.40341303-84.0359932 55.77360888-102.53330118 102.53330117l-48.91927648 123.9413531c-4.69474822 11.83076552-16.14993388 19.62404757-28.82575408 19.62404756zM177.45224165 390.12433614l50.89107073 20.0935224c62.62794129 24.69437565 112.67395736 74.74039171 137.368333 137.36833299l20.09352239 50.89107073 20.0935224-50.89107073c24.69437565-62.62794129 74.74039171-112.67395736 137.368333-137.36833299l50.89107072-20.0935224-50.89107073-20.09352239c-62.62794129-24.69437565-112.67395736-74.74039171-137.36833299-137.36833301l-20.09352239-50.89107074-20.0935224 50.89107074c-24.69437565 62.62794129-74.74039171 112.67395736-137.368333 137.36833301l-50.89107073 20.09352239zM771.33789183 957.62550131c-12.76971517 0-24.13100586-7.79328205-28.82575409-19.62404758l-26.6661699-67.6043744c-8.63833672-21.87752672-26.10280012-39.34199011-47.98032684-47.98032684l-67.60437441-26.6661699c-11.83076552-4.69474822-19.62404757-16.05603892-19.62404757-28.82575409s7.79328205-24.13100586 19.62404757-28.82575409l67.60437441-26.6661699c21.87752672-8.63833672 39.34199011-26.10280012 47.98032684-47.98032685l26.6661699-67.6043744c4.69474822-11.83076552 16.05603892-19.62404757 28.82575409-19.62404757s24.13100586 7.79328205 28.82575409 19.62404757l26.66616991 67.6043744c8.63833672 21.87752672 26.10280012 39.34199011 47.98032684 47.98032685l67.6043744 26.6661699c11.83076552 4.69474822 19.62404757 16.05603892 19.62404757 28.82575409s-7.79328205 24.13100586-19.62404757 28.82575409l-67.6043744 26.6661699c-21.87752672 8.63833672-39.34199011 26.10280012-47.98032684 47.98032684l-26.66616991 67.6043744c-4.69474822 11.83076552-16.14993388 19.62404757-28.82575409 19.62404758z m-75.58544639-190.70067281c33.61439727 14.83540438 60.75004201 41.87715415 75.49155143 75.49155143 14.83540438-33.61439727 41.87715415-60.75004201 75.49155142-75.49155143-33.61439727-14.83540438-60.75004201-41.87715415-75.49155142-75.49155143-14.74150942 33.61439727-41.87715415 60.75004201-75.49155143 75.49155143z"/>
</svg> </svg>
</div> </div>
</div>
</transition> </transition>
</template> </template>
<script> <script>
import {mapState} from "vuex";
import emitter from "../../store/events"; import emitter from "../../store/events";
import {withLanguagePreferencePrompt} from "../../utils/ai"; import {withLanguagePreferencePrompt} from "../../utils/ai";
import {getPageContext} from "./page-context"; import {getPageContext} from "./page-context";
@ -29,47 +39,38 @@ export default {
y: 24, // y: 24, //
fromRight: true, // true: , false: fromRight: true, // true: , false:
fromBottom: true, // true: , false: fromBottom: true, // true: , false:
collapsed: false, //
}, },
dragging: false, dragging: false,
positionLoaded: false, positionLoaded: false,
cacheKey: 'aiAssistant.floatButtonPosition', cacheKey: 'aiAssistant.floatButtonPosition',
btnSize: 44, btnSize: 44,
collapsedHeight: 48, //
collapseThreshold: 12, //
collapseDelay: 1000, //
collapseTimer: null, //
record: {}, record: {},
}; };
}, },
computed: { computed: {
...mapState(['microAppsIds']),
aiInstalled() {
return this.microAppsIds?.includes('ai');
},
visible() { visible() {
return this.userId > 0 && return this.aiInstalled &&
this.userId > 0 &&
this.positionLoaded && this.positionLoaded &&
!this.windowPortrait && !this.windowPortrait &&
this.routeName !== 'login' && this.routeName !== 'login' &&
!this.$parent?.showModal; !this.$parent?.showModal;
}, },
// left collapsed() {
left() { return this.position.collapsed;
if (this.position.fromRight) {
return this.clientWidth - this.btnSize - this.position.x;
}
return this.position.x;
},
// top
top() {
if (this.position.fromBottom) {
return this.clientHeight - this.btnSize - this.position.y;
}
return this.position.y;
},
btnStyle() {
return {
left: `${this.left}px`,
top: `${this.top}px`,
width: `${this.btnSize}px`,
height: `${this.btnSize}px`,
};
}, },
clientWidth() { clientWidth() {
@ -79,6 +80,67 @@ export default {
clientHeight() { clientHeight() {
return this.windowHeight || document.documentElement.clientHeight; return this.windowHeight || document.documentElement.clientHeight;
}, },
// wrapper position.x
wrapperWidth() {
return this.btnSize + this.position.x;
},
// wrapper
wrapperLeft() {
if (this.position.fromRight) {
// ( - wrapper)
return this.clientWidth - this.wrapperWidth;
}
// 0
return 0;
},
wrapperTop() {
// /
const centerY = this.position.fromBottom
? this.clientHeight - this.btnSize / 2 - this.position.y
: this.position.y + this.btnSize / 2;
return centerY - this.wrapperHeight / 2;
},
wrapperClass() {
return {
'is-left': !this.position.fromRight,
'is-right': this.position.fromRight,
'is-dragging': this.dragging,
'is-collapsed': this.collapsed,
};
},
wrapperHeight() {
return this.collapsed ? this.collapsedHeight : this.btnSize;
},
wrapperStyle() {
return {
left: `${this.wrapperLeft}px`,
top: `${this.wrapperTop}px`,
width: `${this.wrapperWidth}px`,
height: `${this.wrapperHeight}px`,
};
},
btnClass() {
return {
'is-collapsed': this.collapsed,
};
},
btnStyle() {
// position.x
if (this.collapsed) {
return { transform: 'translateX(0)' };
}
//
const offset = this.position.fromRight ? -this.position.x : this.position.x;
return { transform: `translateX(${offset}px)` };
},
}, },
mounted() { mounted() {
@ -91,6 +153,7 @@ export default {
document.removeEventListener('mousemove', this.onMouseMove); document.removeEventListener('mousemove', this.onMouseMove);
document.removeEventListener('mouseup', this.onMouseUp); document.removeEventListener('mouseup', this.onMouseUp);
document.removeEventListener('contextmenu', this.onContextMenu); document.removeEventListener('contextmenu', this.onContextMenu);
this.clearCollapseTimer();
}, },
methods: { methods: {
@ -103,7 +166,8 @@ export default {
if (saved) { if (saved) {
const pos = JSON.parse(saved); const pos = JSON.parse(saved);
if (pos && typeof pos.x === 'number' && typeof pos.y === 'number') { if (pos && typeof pos.x === 'number' && typeof pos.y === 'number') {
this.position = pos; // collapsed
this.position = {...pos, collapsed: pos.collapsed ?? false};
this.$nextTick(() => { this.$nextTick(() => {
this.checkBounds(); this.checkBounds();
this.positionLoaded = true; this.positionLoaded = true;
@ -115,7 +179,7 @@ export default {
// ignore // ignore
} }
// //
this.position = {x: 24, y: 24, fromRight: true, fromBottom: true}; this.position = {x: 24, y: 24, fromRight: true, fromBottom: true, collapsed: false};
this.positionLoaded = true; this.positionLoaded = true;
}, },
@ -141,7 +205,7 @@ export default {
const x = fromRight ? (this.clientWidth - this.btnSize - left) : left; const x = fromRight ? (this.clientWidth - this.btnSize - left) : left;
const y = fromBottom ? (this.clientHeight - this.btnSize - top) : top; const y = fromBottom ? (this.clientHeight - this.btnSize - top) : top;
this.position = {x, y, fromRight, fromBottom}; this.position = {x, y, fromRight, fromBottom, collapsed: this.position.collapsed};
}, },
/** /**
@ -151,12 +215,20 @@ export default {
// //
if (e.button !== 0) return; if (e.button !== 0) return;
// AI
if (this.collapsed) {
this.onClick();
return;
}
//
const btnRect = this.$refs.floatBtn.getBoundingClientRect();
this.record = { this.record = {
time: Date.now(), time: Date.now(),
startLeft: this.left, startLeft: btnRect.left,
startTop: this.top, startTop: btnRect.top,
offsetX: e.clientX - this.left, offsetX: e.clientX - btnRect.left,
offsetY: e.clientY - this.top, offsetY: e.clientY - btnRect.top,
}; };
this.dragging = true; this.dragging = true;
@ -199,7 +271,8 @@ export default {
document.removeEventListener('mouseup', this.onMouseUp); document.removeEventListener('mouseup', this.onMouseUp);
document.removeEventListener('contextmenu', this.onContextMenu); document.removeEventListener('contextmenu', this.onContextMenu);
const moveDistance = Math.abs(this.left - this.record.startLeft) + Math.abs(this.top - this.record.startTop); const btnRect = this.$refs.floatBtn.getBoundingClientRect();
const moveDistance = Math.abs(btnRect.left - this.record.startLeft) + Math.abs(btnRect.top - this.record.startTop);
const duration = Date.now() - this.record.time; const duration = Date.now() - this.record.time;
this.savePosition(); this.savePosition();
@ -209,6 +282,55 @@ export default {
if (moveDistance < 5 && duration < 200) { if (moveDistance < 5 && duration < 200) {
this.onClick(); this.onClick();
} }
//
this.scheduleCollapse();
},
/**
* 鼠标进入
*/
onMouseEnter() {
this.clearCollapseTimer();
//
if (this.collapsed) {
this.position.collapsed = false;
this.savePosition();
}
},
/**
* 鼠标离开
*/
onMouseLeave() {
//
if (this.dragging) return;
//
this.scheduleCollapse();
},
/**
* 计划收起延迟执行
*/
scheduleCollapse() {
this.clearCollapseTimer();
//
if (this.position.x <= this.collapseThreshold) {
this.collapseTimer = setTimeout(() => {
this.position.collapsed = true;
this.savePosition();
}, this.collapseDelay);
}
},
/**
* 清除收起定时器
*/
clearCollapseTimer() {
if (this.collapseTimer) {
clearTimeout(this.collapseTimer);
this.collapseTimer = null;
}
}, },
/** /**
@ -270,9 +392,36 @@ export default {
</script> </script>
<style lang="scss"> <style lang="scss">
.ai-float-button { $btn-size: 44px;
$collapsed-width: 12px;
$collapsed-height: 48px;
.ai-float-button-wrapper {
position: fixed; position: fixed;
z-index: 1000; z-index: 1000;
display: flex;
align-items: center;
// transform
&.is-right {
justify-content: flex-end;
}
// transform
&.is-left {
justify-content: flex-start;
}
&.is-dragging {
.ai-float-button {
transition: none !important;
}
}
}
.ai-float-button {
width: $btn-size;
height: $btn-size;
border-radius: 50%; border-radius: 50%;
background: #8bcf70; background: #8bcf70;
box-shadow: 0 4px 12px lch(77 53.3 131.54 / 0.4); box-shadow: 0 4px 12px lch(77 53.3 131.54 / 0.4);
@ -280,24 +429,40 @@ export default {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: transform 0.2s, box-shadow 0.2s; transition: transform 0.25s ease-out, box-shadow 0.2s, width 0.25s ease-out, height 0.25s ease-out, border-radius 0.25s ease-out;
user-select: none; user-select: none;
flex-shrink: 0;
&:hover { &:not(.is-collapsed):hover {
transform: scale(1.08);
box-shadow: 0 6px 16px lch(77 53.3 131.54 / 0.5); box-shadow: 0 6px 16px lch(77 53.3 131.54 / 0.5);
} }
&:active { .ai-float-button-icon {
transform: scale(0.95);
}
svg {
width: 24px; width: 24px;
height: 24px; height: 24px;
fill: #fff; fill: #fff;
} }
//
&.is-collapsed {
width: $collapsed-width;
height: $collapsed-height;
box-shadow: 0 2px 8px lch(77 53.3 131.54 / 0.3);
.ai-float-button-icon {
display: none;
}
}
.is-left &.is-collapsed {
border-radius: 0 6px 6px 0;
}
.is-right &.is-collapsed {
border-radius: 6px 0 0 6px;
}
} }
body.dark-mode-reverse { body.dark-mode-reverse {
.ai-float-button { .ai-float-button {
box-shadow: none; box-shadow: none;