feat: 新增浮动列表窗口和弹出窗口功能

添加浮动列表窗口,支持拖拽、吸附和折叠功能,包含课程互动和辅导工具菜单
新增弹出窗口,提供主窗口、画板和退出等快捷操作
优化窗口创建逻辑,添加日志记录和焦点设置
This commit is contained in:
2026-03-13 11:32:05 +08:00
parent 3d215110fa
commit 745dc0e8fa
9 changed files with 1544 additions and 7 deletions

View File

@ -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<R: Runtime>(app: &AppHandle<R>, 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<R: Runtime>(app: &AppHandle<R>, 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<i32>,
) -> Result<PopupLocation, String> {
pub async fn calculate_placement(target: &TargetLocation, popup_size: &PopupSize, placement: &str, page_size: &tauri::LogicalSize<i32>) -> Result<PopupLocation, String> {
let margin = 12; // 箭头距离触发元素的距离
let mar = 12; // 距离屏幕边缘的最小距离

View File

@ -0,0 +1,244 @@
<template>
<div class="loader" @click="toggleFold" @mousedown="handleMouseDown" @touchstart="handleTouchStart">
<div class="loader-inner">
<ul class="pages-list">
<li class="page-item">
<svg class="page-svg" fill="currentColor" viewBox="0 0 90 120">
<path
d="M90,0 L90,120 L11,120 C4.92486775,120 0,115.075132 0,109 L0,11 C0,4.92486775 4.92486775,0 11,0 L90,0 Z M71.5,81 L18.5,81 C17.1192881,81 16,82.1192881 16,83.5 C16,84.8254834 17.0315359,85.9100387 18.3356243,85.9946823 L18.5,86 L71.5,86 C72.8807119,86 74,84.8807119 74,83.5 C74,82.1745166 72.9684641,81.0899613 71.6643757,81.0053177 L71.5,81 Z M71.5,57 L18.5,57 C17.1192881,57 16,58.1192881 16,59.5 C16,60.8254834 17.0315359,61.9100387 18.3356243,61.9946823 L18.5,62 L71.5,62 C72.8807119,62 74,60.8807119 74,59.5 C74,58.1192881 72.8807119,57 71.5,57 Z M71.5,33 L18.5,33 C17.1192881,33 16,34.1192881 16,35.5 C16,36.8254834 17.0315359,37.9100387 18.3356243,37.9946823 L18.5,38 L71.5,38 C72.8807119,38 74,36.8807119 74,35.5 C74,34.1192881 72.8807119,33 71.5,33 Z"
></path>
</svg>
</li>
<li class="page-item">
<svg class="page-svg" fill="currentColor" viewBox="0 0 90 120">
<path
d="M90,0 L90,120 L11,120 C4.92486775,120 0,115.075132 0,109 L0,11 C0,4.92486775 4.92486775,0 11,0 L90,0 Z M71.5,81 L18.5,81 C17.1192881,81 16,82.1192881 16,83.5 C16,84.8254834 17.0315359,85.9100387 18.3356243,85.9946823 L18.5,86 L71.5,86 C72.8807119,86 74,84.8807119 74,83.5 C74,82.1745166 72.9684641,81.0899613 71.6643757,81.0053177 L71.5,81 Z M71.5,57 L18.5,57 C17.1192881,57 16,58.1192881 16,59.5 C16,60.8254834 17.0315359,61.9100387 18.3356243,61.9946823 L18.5,62 L71.5,62 C72.8807119,62 74,60.8807119 74,59.5 C74,58.1192881 72.8807119,57 71.5,57 Z M71.5,33 L18.5,33 C17.1192881,33 16,34.1192881 16,35.5 C16,36.8254834 17.0315359,37.9100387 18.3356243,37.9946823 L18.5,38 L71.5,38 C72.8807119,38 74,36.8807119 74,35.5 C74,34.1192881 72.8807119,33 71.5,33 Z"
></path>
</svg>
</li>
<li class="page-item">
<svg class="page-svg" fill="currentColor" viewBox="0 0 90 120">
<path
d="M90,0 L90,120 L11,120 C4.92486775,120 0,115.075132 0,109 L0,11 C0,4.92486775 4.92486775,0 11,0 L90,0 Z M71.5,81 L18.5,81 C17.1192881,81 16,82.1192881 16,83.5 C16,84.8254834 17.0315359,85.9100387 18.3356243,85.9946823 L18.5,86 L71.5,86 C72.8807119,86 74,84.8807119 74,83.5 C74,82.1745166 72.9684641,81.0899613 71.6643757,81.0053177 L71.5,81 Z M71.5,57 L18.5,57 C17.1192881,57 16,58.1192881 16,59.5 C16,60.8254834 17.0315359,61.9100387 18.3356243,61.9946823 L18.5,62 L71.5,62 C72.8807119,62 74,60.8807119 74,59.5 C74,58.1192881 72.8807119,57 71.5,57 Z M71.5,33 L18.5,33 C17.1192881,33 16,34.1192881 16,35.5 C16,36.8254834 17.0315359,37.9100387 18.3356243,37.9946823 L18.5,38 L71.5,38 C72.8807119,38 74,36.8807119 74,35.5 C74,34.1192881 72.8807119,33 71.5,33 Z"
></path>
</svg>
</li>
<li class="page-item">
<svg class="page-svg" fill="currentColor" viewBox="0 0 90 120">
<path
d="M90,0 L90,120 L11,120 C4.92486775,120 0,115.075132 0,109 L0,11 C0,4.92486775 4.92486775,0 11,0 L90,0 Z M71.5,81 L18.5,81 C17.1192881,81 16,82.1192881 16,83.5 C16,84.8254834 17.0315359,85.9100387 18.3356243,85.9946823 L18.5,86 L71.5,86 C72.8807119,86 74,84.8807119 74,83.5 C74,82.1745166 72.9684641,81.0899613 71.6643757,81.0053177 L71.5,81 Z M71.5,57 L18.5,57 C17.1192881,57 16,58.1192881 16,59.5 C16,60.8254834 17.0315359,61.9100387 18.3356243,61.9946823 L18.5,62 L71.5,62 C72.8807119,62 74,60.8807119 74,59.5 C74,58.1192881 72.8807119,57 71.5,57 Z M71.5,33 L18.5,33 C17.1192881,33 16,34.1192881 16,35.5 C16,36.8254834 17.0315359,37.9100387 18.3356243,37.9946823 L18.5,38 L71.5,38 C72.8807119,38 74,36.8807119 74,35.5 C74,34.1192881 72.8807119,33 71.5,33 Z"
></path>
</svg>
</li>
<li class="page-item">
<svg class="page-svg" fill="currentColor" viewBox="0 0 90 120">
<path
d="M90,0 L90,120 L11,120 C4.92486775,120 0,115.075132 0,109 L0,11 C0,4.92486775 4.92486775,0 11,0 L90,0 Z M71.5,81 L18.5,81 C17.1192881,81 16,82.1192881 16,83.5 C16,84.8254834 17.0315359,85.9100387 18.3356243,85.9946823 L18.5,86 L71.5,86 C72.8807119,86 74,84.8807119 74,83.5 C74,82.1745166 72.9684641,81.0899613 71.6643757,81.0053177 L71.5,81 Z M71.5,57 L18.5,57 C17.1192881,57 16,58.1192881 16,59.5 C16,60.8254834 17.0315359,61.9100387 18.3356243,61.9946823 L18.5,62 L71.5,62 C72.8807119,62 74,60.8807119 74,59.5 C74,58.1192881 72.8807119,57 71.5,57 Z M71.5,33 L18.5,33 C17.1192881,33 16,34.1192881 16,35.5 C16,36.8254834 17.0315359,37.9100387 18.3356243,37.9946823 L18.5,38 L71.5,38 C72.8807119,38 74,36.8807119 74,35.5 C74,34.1192881 72.8807119,33 71.5,33 Z"
></path>
</svg>
</li>
<li class="page-item">
<svg class="page-svg" fill="currentColor" viewBox="0 0 90 120">
<path
d="M90,0 L90,120 L11,120 C4.92486775,120 0,115.075132 0,109 L0,11 C0,4.92486775 4.92486775,0 11,0 L90,0 Z M71.5,81 L18.5,81 C17.1192881,81 16,82.1192881 16,83.5 C16,84.8254834 17.0315359,85.9100387 18.3356243,85.9946823 L18.5,86 L71.5,86 C72.8807119,86 74,84.8807119 74,83.5 C74,82.1745166 72.9684641,81.0899613 71.6643757,81.0053177 L71.5,81 Z M71.5,57 L18.5,57 C17.1192881,57 16,58.1192881 16,59.5 C16,60.8254834 17.0315359,61.9100387 18.3356243,61.9946823 L18.5,62 L71.5,62 C72.8807119,62 74,60.8807119 74,59.5 C74,58.1192881 72.8807119,57 71.5,57 Z M71.5,33 L18.5,33 C17.1192881,33 16,34.1192881 16,35.5 C16,36.8254834 17.0315359,37.9100387 18.3356243,37.9946823 L18.5,38 L71.5,38 C72.8807119,38 74,36.8807119 74,35.5 C74,34.1192881 72.8807119,33 71.5,33 Z"
></path>
</svg>
</li>
</ul>
</div>
</div>
</template>
<script setup lang="ts">
import { playPromptTone } from '@/utils/prompt-tone';
import { handleMouseDown, handleTouchStart, startWindowX, startWindowY } from '../move-window';
import { LogicalSize, getCurrentWindow } from '@tauri-apps/api/window';
const isFold = defineModel<boolean>();
const currentWindow = getCurrentWindow();
// 使用Tauri API直接移动窗口保持与outerPosition()返回类型一致
currentWindow.setSize(new LogicalSize(60, 50));
/**
* 切换折叠状态
*/
async function toggleFold() {
// 获取缩放因子和窗口物理位置
const [factor, windowPhysicalPosition] = await Promise.all([currentWindow.scaleFactor(), currentWindow.outerPosition()]);
// 转换为逻辑像素位置
const windowPosition = windowPhysicalPosition.toLogical(factor);
const newX = windowPosition.x - startWindowX.value;
const newY = windowPosition.y - startWindowY.value;
if (Math.abs(newX) < 4 && Math.abs(newY) < 4) {
playPromptTone();
isFold.value = !isFold.value;
}
}
</script>
<style scoped lang="scss">
.loader {
--background: linear-gradient(135deg, #23c4f8, #275efe);
--text: #6c7486;
--page: rgba(255, 255, 255, 0.36);
--page-fold: rgba(255, 255, 255, 0.52);
--duration: 3s;
position: relative;
width: 100%;
height: 100%;
padding: 0;
margin: 0;
cursor: pointer;
background-image: var(--background);
border-radius: 16%;
}
.loader-inner {
position: relative;
z-index: 1;
width: 100%;
height: 100%;
}
.pages-list {
position: relative;
width: 100%;
height: 100%;
padding: 0;
margin: 0;
list-style: none;
}
.page-item {
--r: 180deg;
--o: 0;
--c: var(--page);
position: absolute;
top: 10%;
left: 10%;
width: 40%;
height: 80%;
color: var(--c);
opacity: var(--o);
transform: rotateY(var(--r));
transform-origin: 100% 50%;
animation: var(--duration) ease infinite;
}
.page-svg {
display: block;
width: 100%;
height: 100%;
}
.page-item:nth-child(2) {
--c: var(--page-fold);
animation-name: page-2;
}
.page-item:nth-child(3) {
--c: var(--page-fold);
animation-name: page-3;
}
.page-item:nth-child(4) {
--c: var(--page-fold);
animation-name: page-4;
}
.page-item:nth-child(5) {
--c: var(--page-fold);
animation-name: page-5;
}
.page-item:first-child {
--r: 0deg;
--o: 1;
}
.page-item:last-child {
--o: 1;
}
@keyframes page-2 {
0% {
opacity: 0;
transform: rotateY(180deg);
}
20% {
opacity: 1;
}
35%,
100% {
opacity: 0;
}
50%,
100% {
transform: rotateY(0deg);
}
}
@keyframes page-3 {
15% {
opacity: 0;
transform: rotateY(180deg);
}
35% {
opacity: 1;
}
50%,
100% {
opacity: 0;
}
65%,
100% {
transform: rotateY(0deg);
}
}
@keyframes page-4 {
30% {
opacity: 0;
transform: rotateY(180deg);
}
50% {
opacity: 1;
}
65%,
100% {
opacity: 0;
}
80%,
100% {
transform: rotateY(0deg);
}
}
@keyframes page-5 {
45% {
opacity: 0;
transform: rotateY(180deg);
}
65% {
opacity: 1;
}
80%,
100% {
opacity: 0;
}
95%,
100% {
transform: rotateY(0deg);
}
}
</style>

View File

@ -0,0 +1,385 @@
<template>
<div class="menu-container">
<div
style="box-sizing: border-box; width: 100%; padding: 10px; cursor: pointer"
@click="toggleFold()"
@mousedown="handleMouseDown"
@touchstart="handleTouchStart"
>
<div style="width: 100%; height: 6px; background: rgba(255, 255, 255, 0.6); border-radius: 4px"></div>
</div>
<div class="time-container">
<div class="time-box">
<span class="time-text">{{ timeData.hour }}:{{ timeData.minute }}</span>
</div>
</div>
<template v-if="isShowInteraction">
<div class="menu-item" @click="openChoiceTopic()">
<img class="menu-icon" src="@/assets/images/floating-list-window/icon_01_01.png" />
<span class="menu-text">选择题</span>
</div>
<div class="menu-item" @click="openRealTimeTopic()">
<img class="menu-icon" src="@/assets/images/floating-list-window/icon_01_02.png" />
<span class="menu-text">实时题</span>
</div>
<div class="menu-item" @click="openCompetitionTopic()">
<img class="menu-icon" src="@/assets/images/floating-list-window/icon_01_03.png" />
<span class="menu-text">竞赛题</span>
</div>
<div class="menu-item" @click="openJudgeTopic()">
<img class="menu-icon" src="@/assets/images/floating-list-window/icon_01_04.png" />
<span class="menu-text">判断题</span>
</div>
<div class="menu-item" @click="openCloudDisk()">
<img class="menu-icon" src="@/assets/images/floating-list-window/icon_01_05.png" />
<span class="menu-text">云盘</span>
</div>
<!--
<div class="menu-item" @click="openPk()">
<img class="menu-icon" src="@/assets/images/floating-list-window/icon_01_06.png" />
<span class="menu-text">PK赛</span>
</div>
-->
</template>
<template v-if="isShowTutorship">
<div style="width: calc(100% - 12px); height: 1px; margin: 9px 10px; background: rgba(180, 180, 180, 0.6)"></div>
<div class="menu-item tutorship-item" @click="openTutorship()">
<img class="menu-icon" style="width: 32px; height: 32px; margin-bottom: 2px" src="@/assets/images/floating-list-window/icon_02_01.png" />
<span class="menu-text">评讲</span>
</div>
<div class="menu-item tutorship-item" @click="openMeeting()">
<img class="menu-icon" src="@/assets/images/floating-list-window/icon_02_02.png" />
<span class="menu-text">会议控制</span>
</div>
<div class="menu-item tutorship-item" @click="openSubtitle()">
<img class="menu-icon" src="@/assets/images/floating-list-window/icon_02_03.png" />
<span class="menu-text">实时字幕</span>
</div>
</template>
<div style="width: calc(100% - 12px); height: 1px; margin: 9px 10px; background: rgba(180, 180, 180, 0.6)"></div>
<div class="menu-item" @click="openDrawingBoard()">
<img class="menu-icon" src="@/assets/images/floating-list-window/icon_03_01.png" />
<span class="menu-text">画板</span>
</div>
<div class="menu-item" @click="switchCourses()">
<img class="menu-icon" src="@/assets/images/floating-list-window/icon_03_03.png" />
<span class="menu-text">切换课程</span>
</div>
<div class="menu-item" @click="logout()">
<img class="menu-icon" src="@/assets/images/floating-list-window/icon_03_04.png" />
<span class="menu-text">退出登录</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { emitTo } from '@tauri-apps/api/event';
import { createDrawingBoardWindow, createMainWindow, createMeetingWindow, createSubtitleWindow } from '@/utils/tauri/windows-action';
import { windowLabel } from '@/utils/tauri/windows-util';
import { tauri_floating_list_page_action } from '@/utils/constant';
import { handleMouseDown, handleTouchStart, startWindowX, startWindowY } from '../move-window';
import { LogicalSize, getCurrentWindow } from '@tauri-apps/api/window';
import { useTauriSessionStorage } from './use-tauri-session-storage';
import { playPromptTone } from '@/utils/prompt-tone';
import type { FloatingListPageAction } from '@/utils/tauri/types';
const isFold = defineModel<boolean>();
const timeData = ref({ hour: '00', minute: '00' });
let timer: NodeJS.Timeout | null = null;
const currentWindow = getCurrentWindow();
const isShowInteraction = ref(false);
const isShowTutorship = ref(false);
const { userInfo, currTutorship, currCourse, updateBaseDate } = useTauriSessionStorage();
watch(
() => [currTutorship.value, currCourse.value, userInfo.value],
([_currTutorship, _currCourse, _userInfo]) => {
let item_num = 4;
if (_userInfo) {
if (currCourse.value) {
isShowInteraction.value = true;
item_num += 5;
} else {
isShowInteraction.value = false;
}
if (_currTutorship) {
isShowTutorship.value = true;
item_num += 3;
} else {
isShowTutorship.value = false;
}
} else {
isShowInteraction.value = false;
isShowTutorship.value = false;
}
// 总高度 = item高度 * 项目数 + 额外用于移动按钮和时间部分的高度
const popup_height = 64 * item_num + 60;
const popup_width = 60;
currentWindow.setSize(new LogicalSize(popup_width, popup_height));
}
);
onMounted(() => {
setInterval(() => {
updateBaseDate();
}, 2000);
updateTime(); // 初始化时立即更新
scheduleNextUpdate(); // 设置后续更新
updateBaseDate();
});
onBeforeUnmount(() => {
timer && clearTimeout(timer);
});
/**
* 计算到下一秒的毫秒数
*/
function getDelayToNextSecond(): number {
const now = new Date();
const delay = 1000 - now.getMilliseconds();
return delay;
}
/**
* 设置下一个更新周期
*/
function scheduleNextUpdate() {
const delay = getDelayToNextSecond();
timer = setTimeout(() => {
updateTime();
scheduleNextUpdate(); // 递归调用以继续更新
}, delay);
}
/**
* 更新当前时间数据和格式化字符串
*/
function updateTime() {
const now = new Date();
timeData.value = {
hour: now.getHours().toString().padStart(2, '0'),
minute: now.getMinutes().toString().padStart(2, '0'),
};
}
/**
* 切换折叠状态
*/
async function toggleFold() {
// 获取缩放因子和窗口物理位置
const [factor, windowPhysicalPosition] = await Promise.all([currentWindow.scaleFactor(), currentWindow.outerPosition()]);
// 转换为逻辑像素位置
const windowPosition = windowPhysicalPosition.toLogical(factor);
const newX = windowPosition.x - startWindowX.value;
const newY = windowPosition.y - startWindowY.value;
if (Math.abs(newX) < 4 && Math.abs(newY) < 4) {
playPromptTone();
isFold.value = !isFold.value;
}
}
/**
* 打开选择题
*/
async function openChoiceTopic() {
playPromptTone();
await createMainWindow();
sendtPageActionEmit('open-choice-topic');
}
/**
* 实时主观题
*/
async function openRealTimeTopic() {
playPromptTone();
await createMainWindow();
sendtPageActionEmit('open-real-time-topic');
}
/**
* 竞赛主观题
*/
async function openCompetitionTopic() {
playPromptTone();
await createMainWindow();
sendtPageActionEmit('open-competition-topic');
}
/**
* 判断题
*/
async function openJudgeTopic() {
playPromptTone();
await createMainWindow();
sendtPageActionEmit('open-judge-topic');
}
/**
* 打开辅导课程
*/
async function openTutorship() {
playPromptTone();
await createMainWindow();
sendtPageActionEmit('open-tutorship');
}
/**
* 打开云盘
*/
async function openCloudDisk() {
playPromptTone();
await createMainWindow();
sendtPageActionEmit('open-cloud-disk');
}
/**
* 打开PK赛
*/
// async function openPk() {
// playPromptTone();
// await createMainWindow();
// sendtPageActionEmit('open-pk');
// }
/**
* 切换课程
*/
async function switchCourses() {
playPromptTone();
await createMainWindow();
sendtPageActionEmit('switch-courses');
}
/**
* 打开会议
*/
async function openMeeting() {
playPromptTone();
await createMeetingWindow(2, currTutorship.value || null);
}
/**
* 打开字幕
*/
async function openSubtitle() {
playPromptTone();
await createSubtitleWindow(currTutorship.value || null);
}
/**
* 打开画板
*/
async function openDrawingBoard() {
playPromptTone();
await createDrawingBoardWindow();
}
/**
* 退出登录
*/
async function logout() {
playPromptTone();
await createMainWindow();
sendtPageActionEmit('logout');
}
/**
* 向某个窗口发送消息
*/
function sendtPageActionEmit(action: FloatingListPageAction) {
emitTo<FloatingListPageAction>(windowLabel.main, tauri_floating_list_page_action, action);
}
</script>
<style scoped lang="scss">
body {
width: 100vw;
height: 100vh;
padding: 0;
margin: 0;
}
div,
span,
img {
user-select: none;
-webkit-user-drag: none;
}
.time-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin-bottom: 10px;
.time-box {
display: flex;
align-items: flex-end;
justify-content: center;
width: 100%;
margin-bottom: 2px;
line-height: 1;
.time-text {
font-size: 18px;
font-weight: bold;
color: #ffffff;
text-shadow:
1px 1px 2px #000000,
0 0 4px #000000;
}
.time-unit {
margin-left: 2px;
font-size: 12px;
color: #ffffff;
text-shadow:
1px 1px 2px #000000,
0 0 4px #000000;
}
}
}
.menu-container {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100vw;
height: 100vh;
padding: 10px 0;
overflow: hidden;
background: rgba(0, 0, 0, 0.2);
border-radius: 6px;
}
.menu-container .menu-item {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
}
.menu-container .menu-item .menu-icon {
width: 36px;
height: 36px;
}
.menu-container .menu-item .menu-text {
font-size: 12px;
color: #ffffff;
text-shadow: 2px 2px 4px #000000;
}
</style>

View File

@ -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<BriUserInfo | null>(null);
const currTutorship = ref<BriTutorship | null>(null);
const currCourse = ref<BriCourse | null>(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<string>(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,
};
}

View File

@ -0,0 +1,37 @@
<template>
<div style="width: 100vw; height: 100vh; padding: 0; margin: 0">
<floating-ball v-if="isFold" v-model="isFold"></floating-ball>
<floating-list v-else v-model="isFold"></floating-list>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import FloatingBall from './aa-components/floating-ball.vue';
import FloatingList from './aa-components/floating-list.vue';
const isFold = ref(false);
onMounted(() => {
document.addEventListener('contextmenu', (e) => {
// 阻止默认的右键菜单
e.preventDefault();
});
});
</script>
<style scoped lang="scss">
body {
width: 100vw;
height: 100vh;
padding: 0;
margin: 0;
}
div,
span,
img {
user-select: none;
-webkit-user-drag: none;
}
</style>

View File

@ -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');

View File

@ -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;
}
}
}

View File

@ -0,0 +1,7 @@
import { createApp } from 'vue';
import App from './popup-window.vue';
// 创建Vue应用实例
const app = createApp(App);
// 挂载应用到DOM元素#app上
app.mount('#app');

View File

@ -0,0 +1,432 @@
<template>
<div class="page">
<div ref="transitionBoxRef" class="transition-box">
<div class="blob"></div>
<div class="blob"></div>
<div class="blob"></div>
<div class="blob"></div>
<div class="subject-box">
<!-- 画板 -->
<div class="menu-item" @click="openMainWindow">
<fluent-window24-regular class="menu-icon"></fluent-window24-regular>
<span id="drawing-board-text">打开主窗口</span>
</div>
<!-- 画板 -->
<div class="menu-item" @click="openBoardWindow">
<fluent-hand-draw24-regular class="menu-icon"></fluent-hand-draw24-regular>
<span id="drawing-board-text">打开画板</span>
</div>
<!-- 退出应用程序 -->
<div class="menu-item" @click="exitApp">
<solar-power-linear class="menu-icon"> </solar-power-linear>
<span>退出应用程序</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, useTemplateRef } from 'vue';
import FluentWindow24Regular from '~icons/fluent/window-24-regular';
import FluentHandDraw24Regular from '~icons/fluent/hand-draw-24-regular';
import SolarPowerLinear from '~icons/solar/power-linear';
import { playPromptTone } from '@/utils/prompt-tone';
import type { Placement } from '@/utils/tauri/windows-util';
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
import { exit } from '@tauri-apps/plugin-process';
import type { UnlistenFn } from '@tauri-apps/api/event';
import { createDrawingBoardWindow, createMainWindow } from '@/utils/tauri/windows-action';
const webviewWindow = WebviewWindow.getCurrent();
const placement = ref<Placement>('auto');
const transitionBoxRef = useTemplateRef('transitionBoxRef');
const isExitApp = ref(false);
let closeTimeout: NodeJS.Timeout | null = null;
const transitionName = computed(() => getTransitionName(placement.value));
let unlisten: UnlistenFn | undefined = undefined;
// 页面加载完成后初始化
onMounted(() => {
const params = new URLSearchParams(window.location.search);
placement.value = (params.get('placement') || 'auto') as Placement;
webviewWindow.setFocus();
webviewWindow
.listen('tauri://blur', () => {
hideWindow();
})
.then((_unlisten) => {
unlisten = _unlisten;
});
document.addEventListener('contextmenu', (e) => {
e.preventDefault();
});
applyTransitionClass();
});
onUnmounted(() => {
unlisten?.();
});
/**
* 应用过渡动画类
*/
function applyTransitionClass() {
// 设置过渡时间为0确保初始状态立即应用
transitionBoxRef.value!.style.transitionDuration = `0ms`;
transitionBoxRef.value!.classList.add(`${transitionName.value}-enter-active`);
transitionBoxRef.value!.classList.add(`${transitionName.value}-enter-from`);
transitionBoxRef.value!.style.transitionDuration = `300ms`;
transitionBoxRef.value!.classList.remove(`${transitionName.value}-enter-from`);
transitionBoxRef.value!.classList.add(`${transitionName.value}-enter-to`);
}
/**
* 退出应用程序
*/
function exitApp() {
playPromptTone();
isExitApp.value = true;
hideWindow();
}
/**
* 打开画板窗口
*/
async function openBoardWindow() {
playPromptTone();
await createDrawingBoardWindow();
hideWindow();
}
/**
* 退出登录
*/
function openMainWindow() {
playPromptTone();
createMainWindow();
}
/**
* 隐藏窗口
*/
function hideWindow() {
closeTimeout && clearTimeout(closeTimeout);
transitionBoxRef.value!.style.transitionDuration = `0ms`;
transitionBoxRef.value!.classList.remove(`${transitionName.value}-enter-to`);
transitionBoxRef.value!.classList.add(`${transitionName.value}-leave-from`);
nextTick(() => {
transitionBoxRef.value!.style.transitionDuration = `300ms`;
transitionBoxRef.value!.classList.remove(`${transitionName.value}-leave-from`);
transitionBoxRef.value!.classList.add(`${transitionName.value}-leave-to`);
// 动画结束后关闭窗口
closeTimeout = setTimeout(() => {
handleTransitionEnd();
}, 300);
});
}
/**
* 处理过渡动画结束事件
*/
function handleTransitionEnd() {
if (isExitApp.value) {
isExitApp.value = false;
exit(0);
} else {
webviewWindow.close();
}
}
/**
* 获取过渡动画名称
*/
function getTransitionName(_placement: Placement) {
switch (_placement) {
case 'top':
return 'zoom-in-bottom';
case 'bottom':
return 'zoom-in-top';
case 'left':
return 'zoom-in-left';
case 'right':
return 'zoom-in-right';
default:
return 'fade-in';
}
}
</script>
<style scoped lang="scss">
@keyframes color-change1 {
0% {
background: rgb(250, 230, 115);
}
25% {
background: rgb(250, 151, 201);
}
50% {
background: rgb(198, 139, 253);
}
75% {
background: rgb(130, 214, 250);
}
100% {
background: rgb(250, 230, 115);
}
}
@keyframes color-change2 {
0% {
background: rgb(198, 139, 253);
}
25% {
background: rgb(250, 151, 201);
}
50% {
background: rgb(250, 230, 115);
}
75% {
background: rgb(130, 214, 250);
}
100% {
background: rgb(198, 139, 253);
}
}
@keyframes color-change3 {
0% {
background: rgb(130, 214, 250);
}
25% {
background: rgb(250, 230, 115);
}
50% {
background: rgb(198, 139, 253);
}
75% {
background: rgb(250, 151, 201);
}
100% {
background: rgb(130, 214, 250);
}
}
@keyframes float-1 {
0% {
transform: translateY(0);
}
50% {
transform: translateY(70vh);
}
100% {
transform: translateY(0);
}
}
@keyframes float-2 {
0% {
transform: translateY(0);
}
50% {
transform: translateY(-60vh);
}
100% {
transform: translateY(0);
}
}
body {
padding: 0;
margin: 0;
}
.page {
width: 100vw;
height: 100vh;
overflow: hidden;
border-radius: 20px;
}
.transition-box {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100vw;
height: 100vh;
overflow: hidden;
background: #eeeeee;
border-radius: 20px;
}
.blob {
position: absolute;
z-index: 1;
width: 40px;
height: 40px;
filter: blur(30px);
&:nth-child(1) {
top: 20px;
left: 20px;
animation:
float-1 12s ease-in-out infinite,
color-change1 16s infinite;
}
&:nth-child(2) {
top: 20px;
right: 20px;
z-index: -1;
animation:
float-1 15s ease-in-out infinite,
color-change2 18s infinite;
}
&:nth-child(3) {
bottom: 30px;
left: 40px;
animation:
float-2 13s ease-in-out infinite,
color-change3 12s infinite;
}
&:nth-child(4) {
right: 40px;
bottom: 60px;
animation:
float-2 10s ease-in-out infinite,
color-change1 14s infinite;
}
}
.subject-box {
position: absolute;
top: 0;
left: 0;
z-index: 5;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
width: 100vw;
height: 100vh;
.menu-item {
display: flex;
flex: 1;
align-items: center;
width: 100%;
padding: 0 15px;
font-size: 16px;
color: #222222;
cursor: pointer;
user-select: none;
border-radius: 6px;
transition: background-color 0.2s;
&:hover {
background: rgba(207, 207, 207, 0.3);
}
.menu-icon {
width: 1em;
height: 1em;
margin-right: 12px;
font-size: 20px;
}
}
}
.fade-in-enter-active,
.fade-in-leave-active {
transition: opacity 0.3s linear;
}
.fade-in-enter-from,
.fade-in-leave-to {
opacity: 0;
}
.zoom-in-top-enter-active,
.zoom-in-top-leave-active {
transition:
opacity 0.3s cubic-bezier(0.23, 1, 0.32, 1),
transform 0.3s cubic-bezier(0.23, 1, 0.32, 1);
transform-origin: center top;
}
.zoom-in-top-enter-from,
.zoom-in-top-leave-to {
opacity: 0;
transform: scaleY(0);
}
.zoom-in-top-enter-to,
.zoom-in-top-leave-from {
opacity: 1;
transform: scaleY(1);
}
.zoom-in-bottom-enter-active,
.zoom-in-bottom-leave-active {
transition:
opacity 0.3s cubic-bezier(0.23, 1, 0.32, 1),
transform 0.3s cubic-bezier(0.23, 1, 0.32, 1);
transform-origin: center bottom;
}
.zoom-in-bottom-enter-from,
.zoom-in-bottom-leave-to {
opacity: 0;
transform: scaleY(0);
}
.zoom-in-bottom-enter-to,
.zoom-in-bottom-leave-from {
opacity: 1;
transform: scaleY(1);
}
.zoom-in-left-enter-active,
.zoom-in-left-leave-active {
transition:
opacity 0.3s cubic-bezier(0.23, 1, 0.32, 1),
transform 0.3s cubic-bezier(0.23, 1, 0.32, 1);
transform-origin: center left;
}
.zoom-in-left-enter-from,
.zoom-in-left-leave-to {
opacity: 0;
transform: scaleX(0);
}
.zoom-in-right-enter-active,
.zoom-in-right-leave-active {
transition:
opacity 0.3s cubic-bezier(0.23, 1, 0.32, 1),
transform 0.3s cubic-bezier(0.23, 1, 0.32, 1);
transform-origin: center right;
}
.zoom-in-right-enter-from,
.zoom-in-right-leave-to {
opacity: 0;
transform: scaleX(0);
}
</style>