kuaifan 70ad8c394a feat(ai-assistant): 添加聊天窗口模式和页面上下文感知
- 新增 chat 显示模式,支持可拖拽的悬浮聊天窗口
  - 新增 page-context.js,根据当前路由提供针对性系统提示词
  - 优化浮动按钮:添加淡入淡出动画、修复右键菜单拖动问题、更新配色
  - 重构 Modal 为独立组件,支持 modal/chat 双模式切换
  - 恢复会话时自动滚动到底部
2026-01-15 15:06:38 +00:00

296 lines
8.5 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div v-if="displayMode === 'chat'" v-transfer-dom :data-transfer="true">
<transition name="fade">
<div
v-if="visible"
ref="chatWindow"
class="ai-assistant-chat"
:style="chatStyle">
<Icon class="ai-assistant-close" type="ios-close" @click="onClose"/>
<div
class="ai-assistant-drag-handle"
@mousedown.stop.prevent="onMouseDown">
<slot name="header"></slot>
</div>
<slot></slot>
</div>
</transition>
</div>
<Modal
v-else
v-model="visible"
:width="shouldCreateNewSession ? '440px' : '600px'"
:mask-closable="false"
:footer-hide="true"
class-name="ai-assistant-modal">
<template #header>
<slot name="header"></slot>
</template>
<slot></slot>
</Modal>
</template>
<script>
import TransferDom from "../../directives/transfer-dom";
export default {
name: 'AssistantModal',
directives: {TransferDom},
props: {
value: {
type: Boolean,
default: false
},
displayMode: {
type: String,
default: 'modal'
},
shouldCreateNewSession: {
type: Boolean,
default: false
}
},
data() {
return {
// 位置存储:只保存两个距离(水平一个、垂直一个)
position: {
x: 24, // 水平距离值
y: 24, // 垂直距离值
fromRight: true, // true: 距右边, false: 距左边
fromBottom: true, // true: 距底部, false: 距顶部
},
dragging: false,
positionLoaded: false,
cacheKey: 'aiAssistant.chatPosition',
windowSize: {
width: 460,
height: 640,
},
record: {},
};
},
computed: {
visible: {
get() {
return this.value;
},
set(val) {
this.$emit('input', val);
}
},
clientWidth() {
return this.windowWidth || document.documentElement.clientWidth;
},
clientHeight() {
return this.windowHeight || document.documentElement.clientHeight;
},
// 计算实际的 left 值
left() {
if (this.position.fromRight) {
return this.clientWidth - this.windowSize.width - this.position.x;
}
return this.position.x;
},
// 计算实际的 top 值
top() {
if (this.position.fromBottom) {
return this.clientHeight - this.windowSize.height - this.position.y;
}
return this.position.y;
},
chatStyle() {
if (!this.positionLoaded) {
return {
opacity: 0,
};
}
return {
left: `${this.left}px`,
top: `${this.top}px`,
};
},
},
watch: {
visible(val) {
if (val && this.displayMode === 'chat') {
this.$nextTick(() => {
this.updateWindowSize();
});
}
},
},
mounted() {
this.loadPosition();
window.addEventListener('resize', this.onResize);
},
beforeDestroy() {
window.removeEventListener('resize', this.onResize);
document.removeEventListener('mousemove', this.onMouseMove);
document.removeEventListener('mouseup', this.onMouseUp);
document.removeEventListener('contextmenu', this.onContextMenu);
},
methods: {
/**
* 更新窗口实际尺寸
*/
updateWindowSize() {
const el = this.$refs.chatWindow;
if (el) {
this.windowSize = {
width: el.offsetWidth,
height: el.offsetHeight,
};
}
},
/**
* 加载保存的位置
*/
async loadPosition() {
try {
const saved = await $A.IDBString(this.cacheKey);
if (saved) {
const pos = JSON.parse(saved);
if (pos && typeof pos.x === 'number' && typeof pos.y === 'number') {
this.position = pos;
this.$nextTick(() => {
this.checkBounds();
this.positionLoaded = true;
});
return;
}
}
} catch (e) {
// ignore
}
// 默认位置:右下角
this.position = {x: 24, y: 24, fromRight: true, fromBottom: true};
this.positionLoaded = true;
},
/**
* 保存位置
*/
savePosition() {
$A.IDBSave(this.cacheKey, JSON.stringify(this.position));
},
/**
* 根据当前 left/top 更新 position 对象
*/
updatePositionFromCoords(left, top) {
const centerX = left + this.windowSize.width / 2;
const centerY = top + this.windowSize.height / 2;
// 判断在哪个半区
const fromRight = centerX >= this.clientWidth / 2;
const fromBottom = centerY >= this.clientHeight / 2;
// 计算距离
const x = fromRight ? (this.clientWidth - this.windowSize.width - left) : left;
const y = fromBottom ? (this.clientHeight - this.windowSize.height - top) : top;
this.position = {x, y, fromRight, fromBottom};
},
/**
* 鼠标按下
*/
onMouseDown(e) {
// 只响应鼠标左键
if (e.button !== 0) return;
this.updateWindowSize();
this.record = {
time: Date.now(),
startLeft: this.left,
startTop: this.top,
offsetX: e.clientX - this.left,
offsetY: e.clientY - this.top,
};
this.dragging = true;
document.addEventListener('mousemove', this.onMouseMove);
document.addEventListener('mouseup', this.onMouseUp);
document.addEventListener('contextmenu', this.onContextMenu);
},
/**
* 右键菜单弹出时取消拖动
*/
onContextMenu() {
if (this.dragging) {
this.onMouseUp();
}
},
/**
* 鼠标移动
*/
onMouseMove(e) {
if (!this.dragging) return;
const minMargin = 12;
let newLeft = e.clientX - this.record.offsetX;
let newTop = e.clientY - this.record.offsetY;
// 边界限制最小边距12px
newLeft = Math.max(minMargin, Math.min(newLeft, this.clientWidth - this.windowSize.width - minMargin));
newTop = Math.max(minMargin, Math.min(newTop, this.clientHeight - this.windowSize.height - minMargin));
this.updatePositionFromCoords(newLeft, newTop);
},
/**
* 鼠标松开
*/
onMouseUp() {
document.removeEventListener('mousemove', this.onMouseMove);
document.removeEventListener('mouseup', this.onMouseUp);
document.removeEventListener('contextmenu', this.onContextMenu);
this.savePosition();
this.dragging = false;
},
/**
* 检查边界(仅在加载和窗口变化时调用)
*/
checkBounds() {
const minMargin = 12;
// 确保距离在有效范围内最小12px最大不超出屏幕
const maxX = this.clientWidth - this.windowSize.width - minMargin;
const maxY = this.clientHeight - this.windowSize.height - minMargin;
this.position.x = Math.max(minMargin, Math.min(this.position.x, maxX));
this.position.y = Math.max(minMargin, Math.min(this.position.y, maxY));
},
/**
* 窗口大小改变
*/
onResize() {
this.$nextTick(() => {
this.updateWindowSize();
this.checkBounds();
});
},
onClose() {
this.$emit('input', false);
}
}
};
</script>