优化通知功能,增加 toast 消息显示,改善用户体验

This commit is contained in:
竟康 2026-06-03 15:11:49 +08:00
parent f37c6445d1
commit 47077a2b18
1 changed files with 143 additions and 15 deletions

View File

@ -17,8 +17,112 @@
const MIN_STUDY_SECONDS = 5 * 60;
const VIDEO_SELECTOR = "#vjs_video_3_html5_api";
const TEXT_SELECTOR = "#easyXDM_default2872_provider";
console.log("插件加载完成,等待界面渲染");
const TOAST_CONTAINER_ID = "qingshu-auto-toast-container";
const TOAST_STYLE_ID = "qingshu-auto-toast-style";
const TOAST_DEFAULT_DURATION = 4000;
// 创建右下角 toast 容器和样式。
// 每次通知都会创建一个新的 toast所以多个通知会一起堆叠显示。
const ensureToastContainer = () => {
let container = document.querySelector("#" + TOAST_CONTAINER_ID);
if (!document.querySelector("#" + TOAST_STYLE_ID)) {
const style = document.createElement("style");
style.id = TOAST_STYLE_ID;
style.textContent = `
#${TOAST_CONTAINER_ID} {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 2147483647;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 10px;
max-width: min(360px, calc(100vw - 36px));
pointer-events: none;
}
#${TOAST_CONTAINER_ID} .qingshu-toast {
box-sizing: border-box;
min-width: 220px;
max-width: 100%;
padding: 10px 12px;
border-left: 4px solid #3b82f6;
border-radius: 8px;
background: rgba(17, 24, 39, 0.94);
color: #fff;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.24);
font-size: 13px;
line-height: 1.45;
opacity: 0;
transform: translateY(10px);
transition: opacity 180ms ease, transform 180ms ease;
pointer-events: auto;
word-break: break-word;
}
#${TOAST_CONTAINER_ID} .qingshu-toast.show {
opacity: 1;
transform: translateY(0);
}
#${TOAST_CONTAINER_ID} .qingshu-toast.success {
border-left-color: #22c55e;
}
#${TOAST_CONTAINER_ID} .qingshu-toast.warning {
border-left-color: #f59e0b;
}
#${TOAST_CONTAINER_ID} .qingshu-toast.error {
border-left-color: #ef4444;
}
`;
(document.head || document.documentElement).appendChild(style);
}
if (!container) {
container = document.createElement("div");
container.id = TOAST_CONTAINER_ID;
(document.body || document.documentElement).appendChild(container);
}
return container;
};
// 显示一条 toast同时保留 console 输出。
// type 可用 info、success、warning、errorduration 是自动隐藏时间。
const notify = (message, type = "info", duration = TOAST_DEFAULT_DURATION) => {
const log = type === "error" ? console.error : console.log;
log(message);
const container = ensureToastContainer();
const toast = document.createElement("div");
toast.className = "qingshu-toast " + type;
toast.innerText = message;
container.appendChild(toast);
requestAnimationFrame(() => {
toast.classList.add("show");
});
const removeToast = () => {
toast.classList.remove("show");
setTimeout(() => {
toast.remove();
}, 180);
};
toast.addEventListener("click", removeToast);
setTimeout(removeToast, duration);
};
notify("插件加载完成,等待界面渲染", "info");
// 等待某个 DOM 条件成立。
// 页面里的课程列表、视频、文本课件经常是后加载的,所以不用固定延迟,
// 而是监听 DOM 变化,直到 getResult() 返回有效结果。
const waitForCondition = (getResult) => {
const result = getResult();
@ -43,6 +147,8 @@
});
};
// 把“共4分钟49秒”“共1小时2分钟3秒”这类文字换算成秒数
// 方便判断是否已经学满 5 分钟。
const parseStudySeconds = (text) => {
const hourMatch = text.match(/(\d+)\s*小时/);
const minuteMatch = text.match(/(\d+)\s*分钟/);
@ -55,6 +161,10 @@
);
};
// 判断一个课程节点是否需要进入:
// 1. 没有 study_being说明还没学过需要进入
// 2. 学过但累计时间低于 5 分钟,也需要再次进入;
// 3. 学习时间达到 5 分钟及以上,就跳过。
const shouldEnterCourseware = (item) => {
const mark = item.querySelector(".study_being");
@ -66,6 +176,8 @@
return studiedSeconds < MIN_STUDY_SECONDS;
};
// 返回课程列表页。
// 优先点击页面自带的返回按钮;如果没找到,就用浏览器历史记录兜底。
const returnToStudyPage = () => {
const backLink = document.querySelector(".back-link");
@ -77,6 +189,8 @@
history.back();
};
// 播放页可能是视频课件,也可能是文本课件。
// 这里会一直等到其中一种课件元素真正出现在页面上。
const waitForCoursewareElement = () =>
waitForCondition(
() =>
@ -85,10 +199,12 @@
);
const onPageLoad = async () => {
console.log("页面加载完成,开始执行脚本");
notify("页面加载完成,开始执行脚本", "info");
if (url.includes("CourseStudy")) {
console.log("进入准备阶段");
notify("进入准备阶段,等待课程列表", "info");
// 课程列表是异步渲染的,等到至少有一个课程节点后再开始遍历。
const items = await waitForCondition(() => {
const list = document.querySelector("#list");
const items = list ? list.querySelectorAll("li > a.node") : [];
@ -96,60 +212,72 @@
return items.length > 0 ? items : null;
});
console.log("获取到课程数量:", items.length);
notify("获取到课程数量:" + items.length, "success");
// 从上到下找到第一个“没学过”或“学习不足5分钟”的课件并进入。
for (const item of items) {
if (shouldEnterCourseware(item)) {
const mark = item.querySelector(".study_being");
const studiedSeconds = mark ? parseStudySeconds(mark.innerText) : 0;
console.log(
notify(
"点击未完成学习时长的视频:" +
item.querySelector(".title").innerText +
",已学习:" +
studiedSeconds +
"秒"
"秒",
"success",
6000
);
item.click();
return;
}
}
console.log("所有视频均已播放完毕,结束脚本");
notify("所有视频均已播放完毕,结束脚本", "success", 6000);
} else if (url.includes("CourseShow")) {
console.log("进入学习阶段");
// 学习阶段
notify("进入学习阶段,等待课件加载", "info");
// 等到播放页真正渲染出视频或文本课件,再决定如何处理。
const coursewareElement = await waitForCoursewareElement();
// 文本课件没有 video 标签,不需要播放;停留 5 分钟后返回列表页。
if (coursewareElement.matches(TEXT_SELECTOR)) {
console.log("检测到文本课件等待5分钟后返回准备页面");
notify("检测到文本课件等待5分钟后返回准备页面", "warning", 6000);
setTimeout(() => {
console.log("文本课件等待完成,返回准备页面");
notify("文本课件等待完成,返回准备页面", "success");
returnToStudyPage();
}, MIN_STUDY_SECONDS * 1000);
return;
}
// 视频课件需要周期性检查播放状态:启动播放、设置倍速、播完返回。
let videoMissingNotified = false;
const videoInterval = setInterval(() => {
const video = document.querySelector(VIDEO_SELECTOR);
// 有些播放器会重建 video 元素,短暂找不到时继续等下一轮。
if (!video) {
console.log("视频元素暂时不可用,继续等待");
if (!videoMissingNotified) {
notify("视频元素暂时不可用,继续等待", "warning");
videoMissingNotified = true;
}
return;
}
videoMissingNotified = false;
if (video.paused && video.currentTime === 0) {
video.play();
console.log("视频未播放,开始播放视频");
notify("视频未播放,开始播放视频", "success");
return;
}
if (video.currentTime >= video.duration - 1) {
console.log("视频播放结束,返回准备页面");
notify("视频播放结束,返回准备页面", "success");
clearInterval(videoInterval);
returnToStudyPage();
}
if (video.playbackRate === 1) {
console.log("将视频倍速设置为2倍速");
notify("将视频倍速设置为2倍速", "success");
video.playbackRate = 2;
}