PageSourceSearch

https://dsy4567.github.io/js/main.js

js dsy4567.github.io collected 2026-10-03 09:05:05 UTC 30,635 bytes, 864 lines download raw bytes

1/**
2 * @fileoverview 核心脚本,负责加载子模块与动态换页、拉取图标数据并触发渲染、主题控件与一言渲染,以及域名迁移、SW 移除等任务
3 * @author dsy4567
4 * @license
5 * Copyright (c) 2022-2026 dsy4567
6 * SPDX-License-Identifier: MIT
7 */
8
9// @ts-check
10"use strict";
11
12const /** @type {Record<string, string[]>} */ 加载清单 = {
13		"/": ["home"],
14		"/blog": ["blog"],
15		"/friends": ["friends"],
16	},
17	gr_sitekey = "6Ldo1dIkAAAAAM_2VtEneT3l7AE25HdWU45x03ng",
18	移动端界面最大宽度 = 1080;
19let 路径 = 获取清理后当前路径(),
20	正在动态加载 = false;
21
22//#region 加载模块、动态加载
23async function 加载模块() {
24	路径 = 获取清理后一级路径();
25	let 路径2 = 获取清理后当前路径();
26	for (const s of 加载清单[路径] || []) {
27		const i = import(`/js/${s}.js`);
28		await schedulerYield();
29		(await i).main(路径2);
30	}
31	路径 = 路径2;
32}
33/** 动态加载内容缓存:键为「清理后的路径不含查询字符串」,值为匹配到的正文与标题;仅存于内存,页面关闭时随会话销毁 */
34const 动态加载缓存 = /** @type {Map<string, { 正文: string, 标题: string }>} */ (new Map());
35/** @param {{ href: string; popstate?: boolean, replaceState?: boolean }} 元素 */
36function 动态加载(元素) {
37	if (正在动态加载) {
38		open(元素.href, "_self");
39		return 显示或隐藏进度条(false);
40	}
41	const 目标url = new URL(元素.href, location.href),
42		键 = 清理路径(目标url.pathname);
43	正在动态加载 = true;
44	已触发动态加载 = true;
45	显示或隐藏进度条(true);
46
47	gd("robots", true)?.setAttribute("content", "");
48
49	/** 将取得的页面正文应用到当前页面:更新历史状态、标题、正文并加载模块 @param {string} 正文 @param {string} 标题 */
50	const 应用正文 = (正文, 标题) => {
51		if (元素.replaceState) history.replaceState({ 路径: 键 }, "", 元素.href);
52		else if (!元素.popstate) history.pushState({ 路径: 键 }, "", 元素.href);
53		document.title = 标题 || "dsy4567 的小站";
54		dispatchEvent(URL发生变化事件);
55		try {
56			let 右 = qs("main > .右", true);
57			if (!右) return;
58			// 前进/后退导航时,滚动位置由浏览器按历史条目自行恢复,这里不再干预,否则会把已恢复的位置顶回顶部;
59			// 但带 hash 时浏览器无法定位动态换入后才出现的锚点,仍需自行处理
60			if (!元素.popstate && !location.hash) 回到顶部();
61			动态加载自增计数器++;
62			右.innerHTML = 正文;
63			加载模块().finally(() => {
64				正在动态加载 = false;
65
66				if (location.hash) {
67					schedulerYield();
68
69					let /** @type {Element | null} */ 目标 = null,
70						选择器 = `[id="${decodeURI(location.hash.substring(1))}"]`;
71					// 滚动到hash位置
72					// 不能通过赋值 location.hash(置空再恢复)触发滚动:赋值 hash 属于 fragment 导航,
73					// 会 push 新历史条目,既污染后退栈,也会清空前进栈(导致后退后前进按钮变灰)
74					try {
75						目标 = 右.querySelector(选择器);
76						目标?.nextElementSibling?.classList.add("标记");
77						目标?.scrollIntoView({
78							behavior: "smooth",
79						});
80					} catch (e) {
81						console.error(e);
82					}
83					if (!目标) console.warn("找不到元素", 选择器);
84				}
85
86				渲染图标();
87			});
88		} catch (e) {
89			console.error(e);
90			open(元素.href, "_self");
91			显示或隐藏进度条(false);
92		}
93	};
94
95	// 命中缓存则不再发起请求
96	const 缓存 = 动态加载缓存.get(键);
97	if (缓存) return 应用正文(缓存.正文, 缓存.标题);
98
99	fetch(元素.href)
100		.then(res => {
101			// 校验响应类型:请求失败、或并非 HTML 文档(如内链图片、静态资源等)时应拒绝,
102			// 避免将非 HTML 内容当作页面正文动态加载
103			if (!res.ok || !res.headers.get("content-type")?.toLowerCase().includes("html")) {
104				// 此时只收到了响应头,正文还没下载完;对大文件(压缩包、视频等)立即取消响应体,
105				// 避免明知不是 HTML 还把整个文件拉下来
106				res.body?.cancel().catch(() => {});
107				throw new Error("动态加载失败: 响应类型非 HTML");
108			}
109			return res.text();
110		})
111		.then(html => {
112			let m = html.match(/<!-- BEGIN MAIN -->.+<!-- END MAIN -->/s),
113				mt = html.match(/<title>.+<\/title>/s);
114			if (!m) throw new Error("动态加载失败: 匹配结果为空");
115			const 条目 = {
116				正文: m[0],
117				标题: mt ? mt[0].replaceAll("<title>", "").replaceAll("</title>", "") : "",
118			};
119			动态加载缓存.set(键, 条目);
120			应用正文(条目.正文, 条目.标题);
121		})
122		.catch(e => {
123			console.error(e);
124			// 目标可能是浏览器直接下载的文件(页面不会跳走),需复位状态,
125			// 否则后续所有站内链接点击都会退化成整页跳转
126			正在动态加载 = false;
127			显示或隐藏进度条(false);
128			open(元素.href, "_self");
129		});
130}
131//#endregion
132
133//#region 导出和加载模块
134_global["main.js"] = () => ({
135	loaded,
136	DOMContentLoaded,
137	路径,
138	图标,
139	正在动态加载,
140	加载模块,
141	动态加载,
142});
143
144加载模块();
145//#endregion
146
147//#region 网易云音乐
148网页访问者不为爬虫 && import("./ncm.js");
149//#endregion
150
151//#region 主题
152fetch("/json/theme.json")
153	.then(
154		res =>
155			new Promise((resolve, reject) => {
156				延迟执行("关键任务完成", () => resolve(res.json()), 3);
157			})
158	)
159	.then(
160		/** @type {Record<string, string>} */ 主题表 => {
161			const 控件容器 = gd("主题控件", true),
162				主题容器 = gd("所有主题", true);
163			if (!控件容器 || !主题容器) return;
164
165			const f = () => {
166				//#region 第一行:背景模式切换、自定义强调色调色盘
167				/** @type {HTMLButtonElement} */
168				let 模式按钮 = ce("button");
169				模式按钮.id = "背景模式";
170				const 更新模式按钮 = () => {
171					const 模式 = 读取背景模式();
172					模式按钮.title = "背景: " + 模式 + (模式 === "自动" ? "(跟随系统)" : "");
173				};
174				模式按钮.onclick = () => {
175					// 自动 → 浅色 → 深色 循环
176					const 列表 = /** @type {("自动" | "浅色" | "深色")[]} */ ([
177						"自动",
178						"浅色",
179						"深色",
180					]);
181					const 下一个 = 列表[(列表.indexOf(读取背景模式()) + 1) % 列表.length];
182					应用背景模式(下一个);
183					更新模式按钮();
184					提示("背景模式: " + 下一个);
185				};
186				更新模式按钮();
187				控件容器.append(模式按钮);
188
189				/** @type {HTMLButtonElement} */
190				let 调色盘按钮 = ce("button");
191				调色盘按钮.title = "自定义强调色";
192				调色盘按钮.role = "radio";
193				调色盘按钮.ariaChecked = "false";
194				调色盘按钮.innerHTML =
195					"<svg class='特小尺寸' data-icon='调色盘'></svg><input aria-label='自定义强调色调色盘' style='opacity:0;pointer-events:none;position:absolute;top:0;width:0;height:0' tabindex='-1' id='自定义强调色' type='color' />";
196				调色盘按钮.onclick = () => {
197					let 输入 = /** @type {HTMLInputElement} */ (gd("自定义强调色", true));
198					if (!输入) return;
199					输入.value = 读取强调色().toLowerCase();
200					输入.click();
201					输入.onchange = () => {
202						应用强调色(输入.value);
203						同步选中态();
204						提示("已切换自定义强调色");
205					};
206				};
207				控件容器.append(调色盘按钮);
208				//#endregion
209
210				//#region 下方:预设强调色色板
211				/** 根据当前强调色高亮对应色板,为自定义色时高亮调色盘 */
212				const 同步选中态 = () => {
213					const 当前 = 读取强调色();
214					let 命中预设 = false;
215					主题容器.querySelectorAll("button").forEach(元素 => {
216						const 命中 = 元素.dataset.hex === 当前;
217						元素.ariaChecked = "" + 命中;
218						if (命中) 命中预设 = true;
219					});
220					调色盘按钮.ariaChecked = "" + !命中预设;
221				};
222				/** @type {HTMLButtonElement[]} */
223				let 待添加元素 = [];
224				Object.entries(主题表).forEach(([名字, hex]) => {
225					/** @type {HTMLButtonElement} */
226					let btn = ce("button");
227					btn.dataset.hex = hex;
228					btn.style.backgroundColor = hex;
229					btn.title = "强调色: " + 名字;
230					btn.role = "radio";
231					btn.ariaChecked = "false";
232					btn.onclick = () => {
233						应用强调色(hex);
234						同步选中态();
235						提示("已切换强调色: " + 名字);
236					};
237					待添加元素.push(btn);
238				});
239				主题容器.append(...待添加元素);
240				同步选中态();
241				//#endregion
242
243				// 渲染图标();
244				// 此前指定元素渲染报“没有父节点”,根因是多个“渲染图标”调用并发时,
245				// 异步分批处理会处理到已被替换而脱离文档的元素,现已在“渲染图标”内跳过
246				渲染图标({
247					要渲染图标的元素: 调色盘按钮.getElementsByTagName("svg"),
248				});
249			};
250			gd("切换主题")?.addEventListener("mousemove", f, { once: true });
251		}
252	)
253	.catch(e => console.error(e));
254//#endregion
255
256//#region 图标
257fetch("/json/icon.json")
258	.then(
259		res =>
260			new Promise((resolve, reject) => {
261				延迟执行(
262					"DOMContentLoaded",
263					() => {
264						resolve(res.json());
265					},
266					1
267				);
268			})
269	)
270	.then(j => {
271		图标 = j;
272		渲染图标();
273	})
274	.catch(e => console.error(e));
275//#endregion
276
277(async () => {
278	//#region 一言
279	const 获取一言 = (/** @type {"text" | "json"} */ json类型) =>
280		fetch("https://dsy4567.icu/api/hitokoto?c=a&c=b&encode=json").then(
281			res =>
282				new Promise((resolve, reject) => {
283					延迟执行(
284						"关键任务完成",
285						() => {
286							resolve(/** @type {一言句子} */ res[json类型]());
287						},
288						2
289					);
290				})
291		);
292
293	try {
294		let 一言元素 = gd("一言", true),
295			语句 = ce("span"),
296			作者 = ce("span"),
297			// @ts-ignore
298			/** @type {HTMLAnchorElement | null} */ 链接 = qs("#一言+a"),
299			缓存一言 = localStorage.getItem("缓存一言"),
300			/** @type {一言句子} */ 一言json;
301		if (!一言元素 || !链接) return;
302		try {
303			if (缓存一言) 缓存一言 = JSON.parse(缓存一言);
304		} catch (e) {
305			缓存一言 = null;
306		}
307
308		await schedulerYield();
309		一言json = 缓存一言 || (await 获取一言("json"));
310		await schedulerYield();
311		语句.innerText = 一言json.hitokoto;
312		语句.classList.add("语句");
313		作者.innerText = `—— ${一言json.from_who || ""} 「${一言json.from || "未知"}」`;
314		作者.classList.add("作者");
315		一言元素.innerHTML = "";
316		一言元素.append(语句, 作者);
317		链接.href = "https://hitokoto.cn/?uuid=" + 一言json.uuid;
318
319		setTimeout(async () => {
320			try {
321				localStorage.setItem("缓存一言", await 获取一言("text"));
322			} catch (e) {
323				console.error(e);
324			}
325		}, 3000);
326	} catch (e) {
327		console.error(e);
328	}
329	//#endregion
330})();
331
332//#region 关注被关注码龄
333/** 将 GitHub 个人信息渲染到「关注被关注码龄」元素 @param {{ following: number, followers: number, created_at: string }} 个人信息 */
334const 渲染个人信息 = 个人信息 => {
335	const 关注被关注码龄 = gd("关注被关注码龄");
336	if (
337		!关注被关注码龄 ||
338		typeof 个人信息.following !== "number" ||
339		typeof 个人信息.followers !== "number" ||
340		typeof 个人信息.created_at !== "string"
341	)
342		return;
343	关注被关注码龄.innerText = ` 关注: ${个人信息.following} | 被关注: ${
344		个人信息.followers
345	} | 码龄: ${new Date().getFullYear() - new Date(个人信息.created_at).getFullYear()}年 `;
346};
347
348// 缓存键:GitHub 个人信息,命中时先立即渲染,再后台静默拉取最新数据覆盖
349const 个人信息缓存键 = "缓存个人信息";
350try {
351	const 缓存个人信息 = JSON.parse(localStorage.getItem(个人信息缓存键) || "null");
352	if (缓存个人信息) 渲染个人信息(缓存个人信息);
353} catch (e) {
354	// 缓存损坏(如手动改坏)时忽略,等待下面的拉取结果
355	console.error(e);
356}
357
358fetch("https://api.github.com/users/dsy4567")
359	.then(
360		res =>
361			new Promise((resolve, reject) => {
362				延迟执行(
363					"关键任务完成",
364					() => {
365						resolve(res.json());
366					},
367					2
368				);
369			})
370	)
371	.then(个人信息 => {
372		// 拉取成功即写入缓存并立即更新页面;拉取失败时保留缓存内容,继续展示旧数据
373		try {
374			localStorage.setItem(个人信息缓存键, JSON.stringify(个人信息));
375		} catch (e) {
376			console.error(e);
377		}
378		渲染个人信息(个人信息);
379	})
380	.catch(e => console.error(e));
381//#endregion
382
383延迟执行(
384	"关键任务完成",
385	() => {
386		try {
387			//#region 核心元素、事件、字体css等
388			gd("回到顶部")?.addEventListener("click", 回到顶部);
389			gd("分界线")?.addEventListener("click", () => {
390				document.body.classList.toggle("宽屏");
391			});
392			//#endregion
393
394			//#region reCAPTCHA 获取邮箱/tg
395			[gd("电子邮箱"), gd("tg")].forEach(元素 => {
396				元素?.addEventListener("click", 事件 => {
397					事件.preventDefault();
398					if (gd("recaptcha")) return;
399					let div = 添加悬浮卡片(
400						`
401            <div id="g-recaptcha"></div><br />
402            <button id="recaptcha">开始人机验证/提交</button>
403            <button id="close_recaptcha">关闭</button>
404            <a href="https://qwq.dsy4567.icu/api/getemail">在新标签页验证</a><br/>
405            要查看 dsy4567 的电子邮箱地址/TG 用户名,请通过 reCAPTCHA 人机验证。<br />
406            继续查看电子邮箱地址即代表您同意不向 dsy4567<br />
407            发送广告/要饭/雇童工/炒币等垃圾邮件。`,
408						// @ts-ignore
409						事件.pageX,
410						// @ts-ignore
411						事件.pageY,
412						false
413					);
414					添加脚本("https://www.recaptcha.net/recaptcha/api.js?render=explicit", null)
415						.then(() => {
416							gd("close_recaptcha")?.addEventListener("click", () => {
417								div.remove();
418							});
419							gd("recaptcha")?.addEventListener("click", async 事件 => {
420								const gr = gd("g-recaptcha");
421								if (!gr) return;
422								try {
423									const 回复 = grecaptcha.getResponse();
424									if (!回复) throw new Error();
425									gr.innerHTML = await (
426										await fetch(
427											"https://qwq.dsy4567.icu/api/getemail?g-recaptcha-response=" +
428												回复
429										)
430									).text();
431									gr.focus();
432								} catch (e) {
433									gr.tabIndex = 0;
434									gr.focus();
435									grecaptcha.render("g-recaptcha", {
436										sitekey: gr_sitekey,
437										theme: matchMedia("(prefers-color-scheme: dark)").matches
438											? "dark"
439											: "light",
440									});
441								}
442							});
443						})
444						.catch(() => {
445							console.error("无法加载 reCAPTCHA");
446							open("https://qwq.dsy4567.icu/api/getemail", "_blank");
447							div.remove();
448						});
449				});
450			});
451			//#endregion
452
453			//#region 备案
454			let fuck = gd("fuck");
455			if (fuck)
456				fuck.innerText =
457					["\u4f60\u5988", "\u5c3c\u739b", "\u4f60\u5927\u7237", "\u5bc4\u5427"][
458						随机含零自然数(3)
459					] || "\u4f60\u5988";
460			//#endregion
461
462			//#region 导航栏、标题高亮
463
464			/** 记录上一次触发样式切换时的滚动位置,作为累计滚动幅度的基准(不在每次滚动时更新) */
465			let scrollTop = 0,
466				/**
467				 * 当前导航栏的显示模式
468				 *  - -1:初始状态(尚未判断过)
469				 *  -  0:页面位于顶部(显示顶部样式)
470				 *  -  1:向下滚动(隐藏导航栏)
471				 *  -  2:向上滚动(显示导航栏,但不含顶部样式)
472				 */
473				状态 = -1;
474			let 顶部一定范围不隐藏阈值 = 494;
475
476			/** 触发样式切换所需的最小滚动幅度(px),避免小幅滚动频繁切换导致导航栏抖动 */
477			const 滚动阈值 = 25;
478			const 类列表 = document.body.classList;
479
480			/**
481			 * 首次监听滚动前的延迟时长(毫秒):首屏(含动态换页重建正文)期间浏览器可能派发滚动事件,
482			 * 此时读取 scrollY 会强制同步布局而引发重排,故延迟注册监听器,期间的滚动事件一律忽略
483			 */
484			const 滚动监听延迟 = 3000;
485
486			/**
487			 * 标题高亮判定线所在的视口高度百分比:标题顶端超过该线即视为“已滚过”,
488			 * 取最后一个已滚过的标题作为当前阅读位置(可视需要调整)
489			 */
490			const 滚动高亮视口百分比 = 0.65;
491			/** 参与高亮的标题选择器,与 global-nfp.css 中 ::before 的样式规则保持一致 */
492			const 标题选择器 = ":is(h1, h2, h3, h4, h5, h6):not(:has(svg))";
493
494			// 滚动与鼠标各自维护一个高亮类,互不干扰,因此无需在两者之间做竞争仲裁
495			/** 上一次被滚动逻辑高亮的标题 @type {Element | null} */
496			let 上次滚动高亮标题 = null;
497			/** 上一次被鼠标逻辑高亮的标题 @type {Element | null} */
498			let 上次鼠标高亮标题 = null;
499			/** 参与高亮的标题元素列表缓存 @type {Element[]} */
500			let 标题元素列表 = [];
501			/** 标题元素列表是否仍与正文一致,正文子节点变化后置否 */
502			let 标题列表有效 = false;
503
504			/**
505			 * 当前正文内参与高亮的标题元素列表,正文变化后自动重新获取
506			 * 正文常由其他模块异步写入(如 blog.js 拉取完文章后才替换加载提示),
507			 * 换页计数器无法预知这种迟到写入,故由观察正文子节点变化的 MutationObserver 置否
508			 * @returns {Element[]}
509			 */
510			const 取标题元素列表 = () => {
511				if (标题列表有效) return 标题元素列表;
512				标题列表有效 = true;
513				标题元素列表 = Array.from(
514					qs("main > .右", true)?.querySelectorAll(标题选择器) || []
515				);
516				// 不重置 上次滚动高亮标题/上次鼠标高亮标题:正文局部增删时旧高亮节点可能仍在文档中,
517				// 保留引用才能让 切换标题高亮 从它身上移除高亮类
518				return 标题元素列表;
519			};
520
521			/**
522			 * 把某个高亮类从旧目标迁移到新目标,新旧相同时不做任何事
523			 * @param {"高亮-滚动" | "高亮-鼠标"} 类名
524			 * @param {Element | null} 旧目标
525			 * @param {Element | null} 新目标
526			 * @returns {Element | null} 新目标
527			 */
528			const 切换标题高亮 = (类名, 旧目标, 新目标) => {
529				if (旧目标 === 新目标) return 新目标;
530				旧目标?.classList.remove(类名);
531				新目标?.classList.add(类名);
532				return 新目标;
533			};
534
535			setTimeout(() => {
536				const 窗口尺寸变化 = () => {
537					if (document.body.clientWidth <= 移动端界面最大宽度)
538						顶部一定范围不隐藏阈值 =
539							qs("main > .右")?.offsetTop || 顶部一定范围不隐藏阈值;
540				};
541				addEventListener("resize", 窗口尺寸变化);
542				窗口尺寸变化();
543
544				/** 根据当前滚动位置更新导航栏的显示模式 */
545				const 更新导航栏 = () => {
546					const 当前滚动位置 = scrollY;
547
548					// 情况一:滚动到页面顶部(scrollTop <= 顶部一定范围不隐藏阈值 - 64)且当前状态不是“顶部”
549					if (当前滚动位置 <= 顶部一定范围不隐藏阈值 - 64) {
550						if (状态 !== 0) {
551							类列表.add("顶部");
552							类列表.remove("隐藏导航栏");
553							状态 = 0;
554						}
555						scrollTop = 0;
556					}
557					// 情况二:向下滚动且累计幅度超过阈值,状态不是“隐藏导航栏”
558					else if (当前滚动位置 - scrollTop > 滚动阈值) {
559						if (状态 !== 1) {
560							类列表.remove("顶部");
561							类列表.add("隐藏导航栏");
562							状态 = 1;
563						}
564						scrollTop = 当前滚动位置;
565					}
566					// 情况三:向上滚动且累计幅度超过阈值,状态不是“显示导航栏”
567					else if (scrollTop - 当前滚动位置 > 滚动阈值) {
568						if (状态 !== 2) {
569							类列表.remove("顶部", "隐藏导航栏");
570							状态 = 2;
571						}
572						scrollTop = 当前滚动位置;
573					}
574				};
575
576				/** 高亮判定线上方最后一个标题,作为当前阅读位置 */
577				const 更新滚动高亮 = () => {
578					const 判定线 = innerHeight * 滚动高亮视口百分比;
579					/** @type {Element | null} */
580					let 命中 = null;
581					// 标题在文档中自上而下排列,顺序读取坐标即可;getBoundingClientRect 只读不写,
582					// 统一读完再改类名,避免逐元素读写交替造成的布局抖动
583					for (const 标题 of 取标题元素列表()) {
584						if (标题.getBoundingClientRect().top > 判定线) break;
585						命中 = 标题;
586					}
587					上次滚动高亮标题 = 切换标题高亮("高亮-滚动", 上次滚动高亮标题, 命中);
588				};
589
590				/** 高亮光标上方最近的标题;光标位于所有标题之上时清除高亮 @param {number} 光标Y */
591				const 更新鼠标高亮 = 光标Y => {
592					/** @type {Element | null} */
593					let 命中 = null;
594					for (const 标题 of 取标题元素列表()) {
595						if (标题.getBoundingClientRect().top > 光标Y) break;
596						命中 = 标题;
597					}
598					// 命中为 null(光标在所有标题之上)时,切换标题高亮 会顺带移除旧目标的高亮
599					上次鼠标高亮标题 = 切换标题高亮("高亮-鼠标", 上次鼠标高亮标题, 命中);
600				};
601
602				// rAF 节流:一帧内多次触发只执行一次,导航栏与标题高亮共用同一次帧回调
603				let 已排队 = false;
604				/** 把导航栏与滚动高亮的更新合并到同一帧执行 */
605				const 排队更新 = () => {
606					if (已排队) return;
607					已排队 = true;
608					requestAnimationFrame(() => {
609						已排队 = false;
610						更新导航栏();
611						更新滚动高亮();
612					});
613				};
614				addEventListener(
615					"scroll",
616					() => {
617						// console.log("onscroll");
618						排队更新();
619					},
620					{ passive: true }
621				);
622
623				// 观察正文子节点增删,覆盖换页以外的写入(含其他模块的异步写入与局部追加);
624				// 正文元素自身不随换页替换(动态换页只替换其 innerHTML),故可长期观察,
625				// 且只观察子节点、不观察属性,避免高亮类名的增删反过来触发观察器
626				const 正文元素 = qs("main > .右", true);
627				if (正文元素)
628					new MutationObserver(() => {
629						标题列表有效 = false;
630						// 正文写入时用户未必会滚动或移动鼠标,这里补一次更新,避免高亮长时间缺席
631						排队更新();
632					}).observe(正文元素, { childList: true, subtree: true });
633
634				let 鼠标已排队 = false,
635					光标Y = 0;
636				// 用 pointermove 而非 mousemove:除鼠标外还能覆盖手写笔的悬浮;
637				// 过滤 pointerType 排除触摸,触摸拖动不参与高亮(触摸也没有真正的「悬浮」)
638				addEventListener(
639					"pointermove",
640					事件 => {
641						if (事件.pointerType !== "mouse" && 事件.pointerType !== "pen") return;
642						光标Y = 事件.clientY;
643						if (鼠标已排队) return;
644						鼠标已排队 = true;
645						requestAnimationFrame(() => {
646							鼠标已排队 = false;
647							更新鼠标高亮(光标Y);
648						});
649					},
650					{ passive: true }
651				);
652			}, 滚动监听延迟);
653			//#endregion
654
655			//#region 双击打开图片/复制代码
656			addEventListener("dblclick", 事件 => {
657				// 双击打开图片
658				const /** @type {HTMLImageElement | undefined} */ t1 =
659						// @ts-ignore
660						事件.target?.tagName === "IMG"
661							? 事件.target
662							: // @ts-ignore
663								事件.target?.parentElement.tagName === "IMG"
664								? // @ts-ignore
665									事件?.target.parentElement
666								: undefined;
667				if (t1) return open(t1.src, "_blank");
668
669				// 复制代码
670				const /** @type {HTMLImageElement | undefined} */ t2 =
671						// @ts-ignore
672						事件.target?.classList.contains("hljs")
673							? 事件.target
674							: // @ts-ignore
675								事件.target?.parentElement.classList.contains("hljs")
676								? // @ts-ignore
677									事件?.target.parentElement
678								: undefined;
679				if (t2) {
680					t2.classList.add("已复制");
681					setTimeout(() => {
682						t2?.classList.remove("已复制");
683					}, 3000);
684					navigator.clipboard.writeText(t2.innerText);
685					事件.preventDefault();
686				}
687			});
688			//#endregion
689
690			//#region 引入统计脚本
691			import("./analytics.js");
692			//#endregion
693		} catch (e) {
694			console.error(e);
695		}
696	},
697	3
698);
699
700addEventListener("copy", () => {
701	提示("复制成功");
702});
703addEventListener("popstate", 事件 => {
704	// hash 变化执行默认行为
705	if (获取清理后当前路径() === 路径 || 事件.state?.路径 === 路径) return 事件.preventDefault();
706	动态加载({
707		href: location.href,
708		popstate: true,
709	});
710});
711/** 用事件委托统一处理动态加载链接的点击,避免给每个链接单独绑定监听器和闭包 */
712addEventListener("click", 事件 => {
713	const 目标 = 事件.target;
714	if (!(目标 instanceof Element)) return;
715	const a = 目标.closest("a");
716	if (!a) return;
717	// 下载链接、以及指定了非 _self 打开方式的链接交给浏览器自行处理,不发起动态加载请求
718	if (a.hasAttribute("download") || (a.target && a.target !== "_self")) return;
719
720	const 本站 = location.host,
721		当前路径 = location.pathname;
722
723	if (a.pathname === 当前路径 && a.hash) return;
724	else if (a.host === 本站) {
725		事件.preventDefault();
726		动态加载(a);
727	} else a.target = "_blank";
728});
729
730//#region 域名迁移
731// 域名迁移:非 .icu 域名自动跳转到新域名 dsy4567.icu
732// 流程:
733//   1. 爬虫 / 本地环境 / 已在新域名(且非刚从旧域名跳来)→ 不做任何处理
734//   2. 刚从旧域名跳来(URL 带 from-non-icu-tld)→ 在新域名上弹「回原域名」横幅
735//   3. 用户选择过「不跳转」(URL 参数或 localStorage 的 no-redirect)→ 仅弹横幅提示新域名
736//   4. 其余情况 → 自动跳转并在 URL 携带来源参数,供新域名页面弹「回原域名」横幅
737延迟执行(
738	"关键任务完成",
739	() => {
740		try {
741			// 所有改写先在 URL 副本上进行,最后统一生效
742			let U = new URL(location.href);
743			// 删除迁移流程专用的 URL 参数:
744			// from-hostname(来源域名)、from-non-icu-tld(来自非 .icu 域名)、no-redirect(用户选择不跳转)
745			const 删除url参数 = () => {
746				U.searchParams.delete("from-hostname");
747				U.searchParams.delete("from-non-icu-tld");
748				U.searchParams.delete("no-redirect");
749			};
750			const 本地域名 = ["dev.dsy4567.icu", "localhost"];
751			const 是否为纯ip = /** @param {string} hostname */ hostname => {
752				return hostname.split(".").every(p => {
753					const n = Number(p);
754					return Number.isInteger(n) && n >= 0 && n <= 255 && String(n) === p;
755				});
756			};
757
758			// 是否刚从旧域名跳来(URL 带 from-non-icu-tld 标记)
759			const 来自旧域名 = JSON.parse(U.searchParams.get("from-non-icu-tld") || "false");
760
761			// 已在 dsy4567.icu(含子域名)或黑名单域名上则无需处理;
762			// 例外:刚从旧域名跳来的请求需放行,否则下方情况 C 永远不会执行
763			if (
764				(location.hostname.endsWith("dsy4567.icu") && !来自旧域名) ||
765				location.hostname.endsWith(".ts.net") || // Tailscale
766				本地域名.includes(location.hostname) ||
767				是否为纯ip(location.hostname)
768			)
769				return;
770
771			// 情况 A:用户已选择「不跳转」→ 仅弹横幅提示,不强制跳转
772			// 来自旧域名时不适用:此时已在新域名上,弹「迁移至新域名」横幅没有意义
773			if (
774				!来自旧域名 &&
775				网页访问者不为爬虫 &&
776				(JSON.parse(U.searchParams.get("no-redirect") || "false") ||
777					JSON.parse(localStorage.getItem("no-redirect") || "false"))
778			) {
779				// 持久化选择,之后即使 URL 不带 no-redirect 也不再自动跳转
780				localStorage.setItem("no-redirect", "true");
781				// 弹横幅,并把横幅内链接指向新域名的同路径页面
782				let 横幅 = 添加横幅(
783						'本站已迁移至新域名 dsy4567.icu,您可以<a href="#">访问新域名</a>'
784					),
785					a = 横幅.querySelector("a");
786				删除url参数();
787				if (a?.href && a?.hostname) {
788					a.href = U.href;
789					a.hostname = "dsy4567.icu";
790				}
791			} else if (!来自旧域名) {
792				// 情况 B:默认自动跳转到新域名
793				if (网页访问者不为爬虫) {
794					// 在 URL 上记录来源信息,供新域名页面弹「回原域名」横幅使用
795					U.searchParams.set("from-non-icu-tld", "true");
796					U.searchParams.set("from-hostname", location.hostname);
797				} else 删除url参数();
798
799				U.hostname = "dsy4567.icu";
800				location.href = U.href;
801			}
802
803			// 情况 C:刚从旧域名跳来(URL 带 from-non-icu-tld、from-hostname)→ 在新域名上弹「回原域名」横幅
804			let 原域名 = U.searchParams.get("from-hostname");
805			if (网页访问者不为爬虫 && 来自旧域名) {
806				let 横幅 = 添加横幅('本站已迁移至新域名,您也可以<a href="#">访问原域名</a>'),
807					a = 横幅.querySelector("a");
808				删除url参数();
809				// 给回跳链接带上 no-redirect=true,用户点回原域名后不会再被自动跳回,避免两个域名互相重定向
810				U.searchParams.set("no-redirect", "true");
811				if (原域名 && a?.href && a?.hostname) {
812					a.href = U.href;
813					a.hostname = 原域名;
814				}
815			}
816			// 清理辅助参数并同步到地址栏(replaceState 不会新增历史记录)
817			删除url参数();
818			history.replaceState(history.state, "", U.href);
819		} catch (e) {
820			console.error(e);
821		}
822	},
823	3
824);
825//#endregion
826
827//#region localStorage、SW管理
828延迟执行(
829	"关键任务完成",
830	() => {
831		try {
832			// 清理旧版主题键(旧模型为多键存储,已被两级模型取代)
833			["theme", "主题色", "主题色h", "主题色s", "主题色l", "透明色", "字体色"].forEach(键 =>
834				localStorage.removeItem(键)
835			);
836
837			// "serviceWorker" in navigator && navigator.serviceWorker.register("/sw.js");
838
839			// 移除 Service Worker 并清理缓存
840			async function removeServiceWorker() {
841				if ("serviceWorker" in navigator) {
842					// 1. 获取所有注册
843					const registrations = await navigator.serviceWorker.getRegistrations();
844
845					// 2. 逐个注销
846					for (let registration of registrations) await registration.unregister();
847
848					// 3. 清理所有缓存
849					const cacheNames = await caches.keys();
850					await Promise.all(cacheNames.map(name => caches.delete(name)));
851					console.log("Service Worker 已移除,缓存已清空");
852				}
853			}
854
855			removeServiceWorker().catch(e => console.error(e));
856		} catch (e) {
857			console.error(e);
858		}
859	},
860	3
861);
862//#endregion
863
864export {};

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.