From 745dc0e8faaac17552bfb1d3155d99c36ef30e5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=E6=98=B5=E7=A7=B0=E9=87=8D=E8=A6=81=E5=90=97O?= Date: Fri, 13 Mar 2026 11:32:05 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=B5=AE=E5=8A=A8?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E7=AA=97=E5=8F=A3=E5=92=8C=E5=BC=B9=E5=87=BA?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加浮动列表窗口,支持拖拽、吸附和折叠功能,包含课程互动和辅导工具菜单 新增弹出窗口,提供主窗口、画板和退出等快捷操作 优化窗口创建逻辑,添加日志记录和焦点设置 --- src-tauri/src/window/create_popup_window.rs | 11 +- .../aa-components/floating-ball.vue | 244 ++++++++++ .../aa-components/floating-list.vue | 385 ++++++++++++++++ .../use-tauri-session-storage.ts | 88 ++++ .../floating-list-window.vue | 37 ++ src/other_pages/floating-list-window/main.ts | 7 + .../floating-list-window/move-window.ts | 340 ++++++++++++++ src/other_pages/popup-window/main.ts | 7 + src/other_pages/popup-window/popup-window.vue | 432 ++++++++++++++++++ 9 files changed, 1544 insertions(+), 7 deletions(-) create mode 100644 src/other_pages/floating-list-window/aa-components/floating-ball.vue create mode 100644 src/other_pages/floating-list-window/aa-components/floating-list.vue create mode 100644 src/other_pages/floating-list-window/aa-components/use-tauri-session-storage.ts create mode 100644 src/other_pages/floating-list-window/floating-list-window.vue create mode 100644 src/other_pages/floating-list-window/main.ts create mode 100644 src/other_pages/floating-list-window/move-window.ts create mode 100644 src/other_pages/popup-window/main.ts create mode 100644 src/other_pages/popup-window/popup-window.vue diff --git a/src-tauri/src/window/create_popup_window.rs b/src-tauri/src/window/create_popup_window.rs index 86ab5b3..801acd4 100644 --- a/src-tauri/src/window/create_popup_window.rs +++ b/src-tauri/src/window/create_popup_window.rs @@ -1,8 +1,8 @@ use crate::constants::window::*; use crate::window::{ + WindowLabel, derive::{PopupLocation, PopupSize, TargetLocation}, util::get_monitor_for_window, - WindowLabel, }; use tauri::{AppHandle, Manager, Runtime, WebviewUrl, WebviewWindowBuilder}; @@ -23,6 +23,7 @@ pub async fn create_popup_window_impl(app: &AppHandle, target: &T // 如果窗口已存在,直接关闭它 let _ = popup_window.close(); } + log::info!("创建弹出框窗口: {:?}", popup_location); let url = format!("popup-window.html?placement={}", popup_location.placement); // 创建弹出框窗口 WebviewWindowBuilder::new(app, label, WebviewUrl::App(url.into())) @@ -34,6 +35,7 @@ pub async fn create_popup_window_impl(app: &AppHandle, target: &T .decorations(false) .shadow(false) .focusable(true) + .focused(true) .accept_first_mouse(true) .skip_taskbar(true) .always_on_top(true) @@ -60,12 +62,7 @@ pub fn calculate_popup_size_sync() -> PopupSize { } /// 计算弹出框位置 -pub async fn calculate_placement( - target: &TargetLocation, - popup_size: &PopupSize, - placement: &str, - page_size: &tauri::LogicalSize, -) -> Result { +pub async fn calculate_placement(target: &TargetLocation, popup_size: &PopupSize, placement: &str, page_size: &tauri::LogicalSize) -> Result { let margin = 12; // 箭头距离触发元素的距离 let mar = 12; // 距离屏幕边缘的最小距离 diff --git a/src/other_pages/floating-list-window/aa-components/floating-ball.vue b/src/other_pages/floating-list-window/aa-components/floating-ball.vue new file mode 100644 index 0000000..6f5b207 --- /dev/null +++ b/src/other_pages/floating-list-window/aa-components/floating-ball.vue @@ -0,0 +1,244 @@ + + + + + diff --git a/src/other_pages/floating-list-window/aa-components/floating-list.vue b/src/other_pages/floating-list-window/aa-components/floating-list.vue new file mode 100644 index 0000000..5c49e56 --- /dev/null +++ b/src/other_pages/floating-list-window/aa-components/floating-list.vue @@ -0,0 +1,385 @@ + + + + + diff --git a/src/other_pages/floating-list-window/aa-components/use-tauri-session-storage.ts b/src/other_pages/floating-list-window/aa-components/use-tauri-session-storage.ts new file mode 100644 index 0000000..4ba07ea --- /dev/null +++ b/src/other_pages/floating-list-window/aa-components/use-tauri-session-storage.ts @@ -0,0 +1,88 @@ +import { isObject } from '@/utils/verify'; +import { STORAGE_COMMANDS, tauriSessionStorage } from '@/utils/tauri/tauri-session'; +import type { BriCourse, BriTutorship } from '@/api/select-classroom-type'; +import type { BriUserInfo } from '@/api/login'; +import { tauri_store_page_curr_course, tauri_store_page_curr_tutorship, tauri_store_page_user_info } from '@/utils/constant'; +import { getCurrentWindow } from '@tauri-apps/api/window'; +import { ref } from 'vue'; + +/** + * useTauriSessionStorage + */ +export function useTauriSessionStorage() { + const userInfo = ref(null); + const currTutorship = ref(null); + const currCourse = ref(null); + const currentWindow = getCurrentWindow(); + updateBaseDate(); + // 监听后端存储数据变更 + currentWindow.listen<[string, string]>(STORAGE_COMMANDS.CHANGE, ({ payload: [key, value] }) => { + if (key === tauri_store_page_curr_tutorship) { + const _currTutorship = isObject(value) ? value : null; + if (_currTutorship && 'id' in _currTutorship) { + currTutorship.value = _currTutorship as unknown as BriTutorship; + } + } else if (key === tauri_store_page_curr_course) { + const _currCourse = isObject(value) ? value : null; + if (_currCourse && 'id' in _currCourse && 'classId' in _currCourse) { + currCourse.value = _currCourse as unknown as BriCourse; + } + } else if (key === tauri_store_page_user_info) { + const _userInfo = isObject(value) ? value : null; + if (_userInfo && 'id' in _userInfo && 'schoolId' in _userInfo) { + userInfo.value = _userInfo as unknown as BriUserInfo; + } + } + }); + + // 监听后端数据删除事件 + currentWindow.listen(STORAGE_COMMANDS.REMOVE, (event) => { + const key = event.payload; + if (key === tauri_store_page_curr_tutorship) { + currTutorship.value = null; + } else if (key === tauri_store_page_curr_course) { + currCourse.value = null; + } else if (key === tauri_store_page_user_info) { + userInfo.value = null; + } + }); + + // 监听后端清空事件 + currentWindow.listen(STORAGE_COMMANDS.CLEAR, () => { + currTutorship.value = null; + currCourse.value = null; + userInfo.value = null; + }); + + /** + * 跟新基础数据 + */ + function updateBaseDate() { + tauriSessionStorage.get(tauri_store_page_user_info).then((value) => { + const obj = isObject(value) ? value : null; + if (obj && 'id' in obj && 'schoolId' in obj) { + userInfo.value = obj as unknown as BriUserInfo; + } + }); + tauriSessionStorage.get(tauri_store_page_curr_tutorship).then((value) => { + const obj = isObject(value) ? value : null; + if (obj && 'id') { + currTutorship.value = obj as unknown as BriTutorship; + } + }); + tauriSessionStorage.get(tauri_store_page_curr_course).then((value) => { + const obj = isObject(value) ? value : null; + if (obj && 'id' in obj && 'classId' in obj) { + currCourse.value = obj as unknown as BriCourse; + } + }); + } + + return { + /** 更新基础数据 */ + updateBaseDate, + userInfo, + currTutorship, + currCourse, + }; +} diff --git a/src/other_pages/floating-list-window/floating-list-window.vue b/src/other_pages/floating-list-window/floating-list-window.vue new file mode 100644 index 0000000..d771732 --- /dev/null +++ b/src/other_pages/floating-list-window/floating-list-window.vue @@ -0,0 +1,37 @@ + + + + + diff --git a/src/other_pages/floating-list-window/main.ts b/src/other_pages/floating-list-window/main.ts new file mode 100644 index 0000000..33d20e6 --- /dev/null +++ b/src/other_pages/floating-list-window/main.ts @@ -0,0 +1,7 @@ +import { createApp } from 'vue'; +import App from './floating-list-window.vue'; + +// 创建Vue应用实例 +const app = createApp(App); +// 挂载应用到DOM元素#app上 +app.mount('#app'); diff --git a/src/other_pages/floating-list-window/move-window.ts b/src/other_pages/floating-list-window/move-window.ts new file mode 100644 index 0000000..09d4fb2 --- /dev/null +++ b/src/other_pages/floating-list-window/move-window.ts @@ -0,0 +1,340 @@ +import { LogicalPosition, currentMonitor, getCurrentWindow } from '@tauri-apps/api/window'; +import { ref } from 'vue'; + +// 获取当前窗口实例 +const currentWindow = getCurrentWindow(); + +/** 当前触摸ID */ +let activeTouchId: number | null = null; +/** 鼠标初始X坐标 */ +let startMouseX = 0; +/** 鼠标初始Y坐标 */ +let startMouseY = 0; + +/** 触摸初始X坐标 */ +let startTouchX = 0; +/** 触摸初始Y坐标 */ +let startTouchY = 0; + +/** 窗口初始X坐标 */ +export const startWindowX = ref(0); +/** 窗口初始Y坐标 */ +export const startWindowY = ref(0); + +/** 屏幕宽度 */ +let screenWidth = 0; +/** 屏幕高度 */ +let screenHeight = 0; +/** 窗口宽度 */ +let windowWidth = 0; +/** 窗口高度 */ +let windowHeight = 0; + +/** + * 窗口吸附动画 + * @param startX - 起始X + * @param startY - 起始Y + * @param targetX - 目标X + * @param targetY - 目标Y + */ +function animateMove(startX: number, startY: number, targetX: number, targetY: number) { + const duration = 300; // 动画持续时间 ms + const startTime = performance.now(); + + /** + * 步骤函数 用于移动 + * @param currentTime - 当前时间 + */ + function step(currentTime: number) { + const elapsed = currentTime - startTime; + const progress = Math.min(elapsed / duration, 1); + + // easeOutCubic 缓动效果 + const ease = 1 - (1 - progress) ** 3; + + const newX = startX + (targetX - startX) * ease; + const newY = startY + (targetY - startY) * ease; + + currentWindow.setPosition(new LogicalPosition(newX, newY)); + + if (progress < 1) { + requestAnimationFrame(step); + } + } + + requestAnimationFrame(step); +} + +/** + * 自动吸附到屏幕边缘 + */ +async function snapToEdge() { + try { + const monitor = await currentMonitor(); + if (!monitor) { + return; + } + + const factor = await currentWindow.scaleFactor(); + const windowPhysicalPos = await currentWindow.outerPosition(); + const windowLogicalPos = windowPhysicalPos.toLogical(factor); + + const windowPhysicalSize = await currentWindow.outerSize(); + const windowLogicalSize = windowPhysicalSize.toLogical(factor); + + const screenLogicalSize = monitor.size.toLogical(factor); + + const currentX = windowLogicalPos.x; + const currentY = windowLogicalPos.y; + + const _windowWidth = windowLogicalSize.width; + const _screenWidth = screenLogicalSize.width; + + // 计算窗口中心点X坐标 + const centerX = currentX + _windowWidth / 2; + + let targetX = 0; + // 如果中心点在屏幕左半边,吸附到左边;否则吸附到右边 + if (centerX < _screenWidth / 2) { + targetX = 0; + } else { + targetX = _screenWidth - _windowWidth; + } + + // 执行动画移动到目标位置(Y轴保持不变) + animateMove(currentX, currentY, targetX, currentY); + } catch (error) { + console.error('自动吸附失败:', error); + } +} + +/******************************* 鼠标 ********************************************************/ +/******************************* 鼠标 ********************************************************/ +/******************************* 鼠标 ********************************************************/ +/******************************* 鼠标 ********************************************************/ +/** + * 鼠标移动事件处理函数 - 更新窗口位置 + * @param event - 鼠标事件 + */ +function mouseMoveHandler(event: MouseEvent) { + // 阻止默认行为 + event.preventDefault(); + + // 计算位置偏移 + const deltaX = event.screenX - startMouseX; + const deltaY = event.screenY - startMouseY; + + // 计算新位置 + let newX = startWindowX.value + deltaX; + let newY = startWindowY.value + deltaY; + + // 边界检查:限制在屏幕范围内 + if (newX < 0) { + newX = 0; + } + if (newX > screenWidth - windowWidth) { + newX = screenWidth - windowWidth; + } + + if (newY < 0) { + newY = 0; + } + if (newY > screenHeight - windowHeight) { + newY = screenHeight - windowHeight; + } + + // 使用Tauri API直接移动窗口 + currentWindow.setPosition(new LogicalPosition(newX, newY)); +} + +/** + * 鼠标释放事件处理函数 - 结束拖动 + * @param event - 鼠标事件 + */ +function mouseUpHandler() { + // 移除事件监听器 + document.removeEventListener('mousemove', mouseMoveHandler); + document.removeEventListener('mouseup', mouseUpHandler); + // 触发吸附 + snapToEdge(); +} + +/** + * 鼠标按下事件处理函数 - 开始拖动窗口 + * @param event - 鼠标事件 + */ +export async function handleMouseDown(event: MouseEvent) { + // 确保只处理左键点击 且 目标元素包含 global-move-windows 类 除非 isAll 为 true + if (event.button === 0) { + // 阻止默认行为,防止事件冒泡 + event.preventDefault(); + try { + // 获取缩放因子、窗口物理位置、窗口物理大小、当前显示器信息 + const [factor, windowPhysicalPosition, windowPhysicalSize, monitor] = await Promise.all([ + currentWindow.scaleFactor(), + currentWindow.outerPosition(), + currentWindow.outerSize(), + currentMonitor(), + ]); + // 转换为逻辑像素位置 + const windowPosition = windowPhysicalPosition.toLogical(factor); + const windowSize = windowPhysicalSize.toLogical(factor); + + // 保存窗口大小 + windowWidth = windowSize.width; + windowHeight = windowSize.height; + + // 保存屏幕大小 + if (monitor) { + const screenSize = monitor.size.toLogical(factor); + screenWidth = screenSize.width; + screenHeight = screenSize.height; + } + + // 记录窗口初始位置 + startWindowX.value = windowPosition.x; + startWindowY.value = windowPosition.y; + // 记录鼠标初始位置 + startMouseX = event.screenX; + startMouseY = event.screenY; + + // 添加鼠标移动和释放事件监听器 + document.addEventListener('mousemove', mouseMoveHandler); + document.addEventListener('mouseup', mouseUpHandler); + } catch (error: any) { + console.error('获取窗口位置失败:', error); + } + } +} + +/******************************* 触摸 ********************************************************/ +/******************************* 触摸 ********************************************************/ +/******************************* 触摸 ********************************************************/ +/******************************* 触摸 ********************************************************/ + +/** + * 触摸移动事件处理函数 - 更新窗口位置 + * @param event - 触摸事件 + */ +function touchMoveHandler(event: TouchEvent) { + if (activeTouchId === null) { + return; + } + + // 阻止默认行为,避免页面滚动 + event.preventDefault(); + + // 找到对应的触摸点,确保是同一个手指(通过触摸ID判断) + const touch = Array.from(event.touches).find((t) => t.identifier === activeTouchId); + + // 如果找不到匹配的触摸点(手指已经离开或更换了手指),则不执行拖动操作 + if (touch) { + // 计算位置偏移 + const deltaX = touch.screenX - startTouchX; + const deltaY = touch.screenY - startTouchY; + + // 计算新位置 + let newX = startWindowX.value + deltaX; + let newY = startWindowY.value + deltaY; + + // 边界检查:限制在屏幕范围内 + if (newX < 0) { + newX = 0; + } + if (newX > screenWidth - windowWidth) { + newX = screenWidth - windowWidth; + } + + if (newY < 0) { + newY = 0; + } + if (newY > screenHeight - windowHeight) { + newY = screenHeight - windowHeight; + } + + // 使用Tauri API直接移动窗口 + currentWindow.setPosition(new LogicalPosition(newX, newY)); + } +} + +/** + * 触摸结束事件处理函数 - 结束拖动 + * @param event - 触摸事件 + */ +function touchEndHandler(event: TouchEvent) { + if (activeTouchId === null) { + return; + } + + // 检查结束的触摸点是否是我们正在跟踪的触摸点 + const endedTouchId = event.changedTouches[0]?.identifier; + if (endedTouchId === activeTouchId) { + activeTouchId = null; + + // 移除事件监听器 + document.removeEventListener('touchmove', touchMoveHandler); + document.removeEventListener('touchend', touchEndHandler); + document.removeEventListener('touchcancel', touchEndHandler); + + // 触发吸附 + snapToEdge(); + } +} + +/** + * 触摸开始事件处理函数 - 开始拖动窗口 + * @param event - 触摸事件 + */ +export async function handleTouchStart(event: TouchEvent) { + // 确保只有一个触摸点 且 目标元素包含 global-move-windows 类 除非 isAll 为 true + if (event.touches.length === 1) { + // 阻止默认行为,防止页面滚动 + event.preventDefault(); + + const touch = event.touches[0]!; + + // 记录触摸点ID,用于后续判断是否是同一个手指 + const touchId = touch.identifier; + + try { + // 获取缩放因子、窗口物理位置、窗口物理大小、当前显示器信息 + const [factor, windowPhysicalPosition, windowPhysicalSize, monitor] = await Promise.all([ + currentWindow.scaleFactor(), + currentWindow.outerPosition(), + currentWindow.outerSize(), + currentMonitor(), + ]); + // 转换为逻辑像素位置 + const windowPosition = windowPhysicalPosition.toLogical(factor); + const windowSize = windowPhysicalSize.toLogical(factor); + + // 保存窗口大小 + windowWidth = windowSize.width; + windowHeight = windowSize.height; + + // 保存屏幕大小 + if (monitor) { + const screenSize = monitor.size.toLogical(factor); + screenWidth = screenSize.width; + screenHeight = screenSize.height; + } + + // 记录窗口初始位置 + startWindowX.value = windowPosition.x; + startWindowY.value = windowPosition.y; + + // 记录触摸初始位置和ID + startTouchX = touch.screenX; + startTouchY = touch.screenY; + activeTouchId = touchId; + + // 添加触摸移动和结束事件监听器 + document.addEventListener('touchmove', touchMoveHandler); + document.addEventListener('touchend', touchEndHandler); + document.addEventListener('touchcancel', touchEndHandler); + } catch (error: any) { + console.error('获取窗口位置失败:', error); + activeTouchId = null; + } + } +} diff --git a/src/other_pages/popup-window/main.ts b/src/other_pages/popup-window/main.ts new file mode 100644 index 0000000..bfb1ad4 --- /dev/null +++ b/src/other_pages/popup-window/main.ts @@ -0,0 +1,7 @@ +import { createApp } from 'vue'; +import App from './popup-window.vue'; + +// 创建Vue应用实例 +const app = createApp(App); +// 挂载应用到DOM元素#app上 +app.mount('#app'); diff --git a/src/other_pages/popup-window/popup-window.vue b/src/other_pages/popup-window/popup-window.vue new file mode 100644 index 0000000..1315ec7 --- /dev/null +++ b/src/other_pages/popup-window/popup-window.vue @@ -0,0 +1,432 @@ + + + + +