@windcoder/astro-theme-orchid 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -104,16 +104,15 @@ function applySavedHue() {
104
104
  applySavedHue();
105
105
  doc.addEventListener("astro:after-swap", applySavedHue);
106
106
 
107
- /* 同步面板 UI:滑杆 / 徽章 / swatch 激活态(差值 <1° 判定,含 0/360 环绕) */
107
+ /* 同步面板 UI:滑杆 / 徽章 / swatch 激活态(差值 <1° 判定,含 0/360 环绕)。
108
+ swatch 可能在旧 palette-pop 或显示设置面板(§4.103 外观 Tab)内,全局查询 */
108
109
  function syncPaletteUI(h: number) {
109
- const pop = doc.getElementById("palettePop");
110
- if (!pop) return;
111
110
  const rounded = Math.round(((h % 360) + 360) % 360) % 360;
112
111
  const slider = doc.getElementById("hueSlider") as HTMLInputElement | null;
113
112
  if (slider) slider.value = String(rounded);
114
113
  const badge = doc.getElementById("hueBadge");
115
114
  if (badge) badge.textContent = String(rounded);
116
- pop.querySelectorAll<HTMLElement>(".swatch").forEach((sw) => {
115
+ doc.querySelectorAll<HTMLElement>(".swatch[data-hue]").forEach((sw) => {
117
116
  const dh = Math.abs(parseFloat(sw.dataset.hue || "0") - rounded);
118
117
  sw.classList.toggle("is-active", dh < 1 || dh > 359);
119
118
  });
@@ -133,11 +132,11 @@ function closePalette() {
133
132
  if (toggle) toggle.setAttribute("aria-expanded", "false");
134
133
  }
135
134
 
136
- /* 每次导航后同步面板状态并复位收起(面板 DOM 会随 swap 重建) */
135
+ /* 每次导航后同步面板状态并复位收起(面板 DOM 会随 swap 重建);
136
+ 显示设置面板形态(§4.103)无 palettePop,仍需同步 hue 控件 */
137
137
  function initPalette() {
138
138
  const pop = doc.getElementById("palettePop");
139
- if (!pop) return;
140
- closePalette();
139
+ if (pop) closePalette();
141
140
  syncPaletteUI(currentHue());
142
141
  }
143
142
 
@@ -193,7 +192,18 @@ async function initLightbox() {
193
192
  const img = el instanceof HTMLImageElement ? el : el?.querySelector("img");
194
193
  if (!img) return itemData;
195
194
  if (!itemData.src) itemData.src = img.currentSrc || img.src;
196
- if (img.naturalWidth > 0) {
195
+ /* §4.106:列表 img 可能是缩略图(data-full-* 携带原图 URL 与尺寸)——
196
+ 优先打开原图;无数据时沿用 img 自身 src 与 natural 尺寸 */
197
+ if (img.dataset.fullSrc) {
198
+ itemData.src = img.dataset.fullSrc;
199
+ if (
200
+ Number(img.dataset.fullWidth) > 0 &&
201
+ Number(img.dataset.fullHeight) > 0
202
+ ) {
203
+ itemData.width = Number(img.dataset.fullWidth);
204
+ itemData.height = Number(img.dataset.fullHeight);
205
+ }
206
+ } else if (img.naturalWidth > 0) {
197
207
  itemData.width = img.naturalWidth;
198
208
  itemData.height = img.naturalHeight;
199
209
  }
@@ -484,12 +494,15 @@ window.addEventListener(
484
494
  () => {
485
495
  updateBackTop();
486
496
  updateReadProgress();
497
+ updateWallpaperScroll();
487
498
  },
488
499
  { passive: true },
489
500
  );
490
501
  window.addEventListener("resize", updateReadProgress, { passive: true });
491
502
 
492
- /* ---------- 搜索浮层(M3b-b6):Pagefind 构建期索引,运行时惰性加载 ---------- */
503
+ /* ---------- 搜索(M3b-b6 → §4.104):Pagefind 构建期索引,标准动态 import 接入。
504
+ 导航浮层:输入即搜,最多 8 条,超出时尾部「查看全部」链接进独立搜索页;
505
+ 独立搜索页(/search/):URL ?q= 关键词 + ?page= 页码,客户端分页渲染 ---------- */
493
506
  type PagefindResultData = {
494
507
  url: string;
495
508
  excerpt: string;
@@ -504,15 +517,14 @@ type Pagefind = {
504
517
 
505
518
  /* undefined=未加载 null=不可用(dev 无索引/加载失败);模块级缓存,VT 换页不重复加载 */
506
519
  let pagefind: Pagefind | null | undefined;
507
- let searchSeq = 0;
508
520
 
509
521
  async function loadPagefind(): Promise<Pagefind | null> {
510
522
  if (pagefind !== undefined) return pagefind;
511
523
  try {
512
524
  const src = "/pagefind/pagefind.js";
513
- const res = await fetch(src, { method: "HEAD" });
514
- if (!res.ok) throw new Error("no index");
515
- // 变量形式 + @vite-ignore:构建产物不存在时(dev/无索引)不做静态解析,运行时才加载
525
+ // 标准 Pagefind 接入(官方推荐):运行时直接动态 import 构建期索引入口。
526
+ // 变量形式 + @vite-ignore:索引是 build 后才存在的产物,构建期不做静态解析,
527
+ // dev 无索引时 import 失败走 catch 降级提示
516
528
  const pf = (await import(/* @vite-ignore */ src)) as unknown as Pagefind;
517
529
  if (pf.options) await pf.options({ excerptLength: 20 });
518
530
  pagefind = pf;
@@ -522,6 +534,25 @@ async function loadPagefind(): Promise<Pagefind | null> {
522
534
  return pagefind;
523
535
  }
524
536
 
537
+ /* 浮层最多展示 8 条(§4.56 视口内条数),超出引导进独立搜索页(§4.104) */
538
+ const SEARCH_POP_MAX = 8;
539
+
540
+ /* 结果项(浮层与搜索页共用):标题 + pagefind 受限高亮摘要 */
541
+ function searchItemEl(item: PagefindResultData): HTMLAnchorElement {
542
+ const a = doc.createElement("a");
543
+ a.className = "search-item";
544
+ a.href = item.url;
545
+ const title = doc.createElement("div");
546
+ title.className = "search-item-title";
547
+ title.textContent = item.meta?.title || item.url;
548
+ const excerpt = doc.createElement("div");
549
+ excerpt.className = "search-item-excerpt";
550
+ /* pagefind 产出的受限高亮片段(<mark>/<strong>),非用户输入 */
551
+ excerpt.innerHTML = item.excerpt;
552
+ a.append(title, excerpt);
553
+ return a;
554
+ }
555
+
525
556
  function setSearchHint(text: string) {
526
557
  const box = doc.getElementById("searchResults");
527
558
  if (!box) return;
@@ -533,23 +564,24 @@ function setSearchHint(text: string) {
533
564
  box.hidden = false;
534
565
  }
535
566
 
536
- function renderSearchResults(items: PagefindResultData[]) {
567
+ /* 请求序号:只接受最后一次输入的响应,防乱序覆盖 */
568
+ let searchSeq = 0;
569
+
570
+ function renderSearchResults(
571
+ items: PagefindResultData[],
572
+ more?: { keyword: string; total: number },
573
+ ) {
537
574
  const box = doc.getElementById("searchResults");
538
575
  if (!box) return;
539
576
  box.replaceChildren();
540
- for (const item of items) {
541
- const a = doc.createElement("a");
542
- a.className = "search-item";
543
- a.href = item.url;
544
- const title = doc.createElement("div");
545
- title.className = "search-item-title";
546
- title.textContent = item.meta?.title || item.url;
547
- const excerpt = doc.createElement("div");
548
- excerpt.className = "search-item-excerpt";
549
- /* pagefind 产出的受限高亮片段(<mark>/<strong>),非用户输入 */
550
- excerpt.innerHTML = item.excerpt;
551
- a.append(title, excerpt);
552
- box.appendChild(a);
577
+ for (const item of items) box.appendChild(searchItemEl(item));
578
+ if (more) {
579
+ /* §4.104 超出浮层上限:尾部「查看全部 N 条结果」链接进独立搜索页 */
580
+ const link = doc.createElement("a");
581
+ link.className = "search-more";
582
+ link.href = "/search/?q=" + encodeURIComponent(more.keyword);
583
+ link.textContent = ui("searchMore").replace("{n}", String(more.total));
584
+ box.appendChild(link);
553
585
  }
554
586
  box.hidden = false;
555
587
  }
@@ -611,18 +643,177 @@ function runSearch(keyword: string) {
611
643
  }
612
644
  try {
613
645
  const res = await pf.search(keyword);
646
+ /* res.results.length 即命中总数(惰性 API);浮层只取前 SEARCH_POP_MAX 条加载详情 */
647
+ const total = res.results.length;
614
648
  const items = await Promise.all(
615
- res.results.slice(0, 8).map((r) => r.data()),
649
+ res.results.slice(0, SEARCH_POP_MAX).map((r) => r.data()),
616
650
  );
617
651
  if (seq !== searchSeq) return;
618
- if (items.length) renderSearchResults(items);
619
- else setSearchHint(ui("searchNoResults"));
652
+ if (total > SEARCH_POP_MAX) {
653
+ renderSearchResults(items, { keyword, total });
654
+ } else if (items.length) {
655
+ renderSearchResults(items);
656
+ } else {
657
+ setSearchHint(ui("searchNoResults"));
658
+ }
620
659
  } catch {
621
660
  if (seq === searchSeq) setSearchHint(ui("searchNoResults"));
622
661
  }
623
662
  }, 300);
624
663
  }
625
664
 
665
+ /* ---------- 独立搜索页(§4.104,/search/):URL ?q= 关键词 + ?page= 页码。
666
+ 静态外壳 + 客户端查询分页;输入即时刷新(replaceState 不经换页),
667
+ 翻页走真实链接经 VT 换页后由 astro:page-load 重新渲染 ---------- */
668
+ const SEARCH_PAGE_SIZE = 10;
669
+ let searchPageSeq = 0;
670
+ let searchPageTimer = 0;
671
+
672
+ /* 页码窗口:恒含首末页与当前页±2,间隙以省略号占位(与 Pagination.astro 同规则) */
673
+ function pagerNums(page: number, total: number): Array<number | "ellipsis"> {
674
+ const set = new Set<number>([1, total]);
675
+ for (let p = page - 2; p <= page + 2; p++) {
676
+ if (p >= 1 && p <= total) set.add(p);
677
+ }
678
+ const nums = [...set].sort((a, b) => a - b);
679
+ const out: Array<number | "ellipsis"> = [];
680
+ let prev = 0;
681
+ for (const n of nums) {
682
+ if (prev && n - prev > 1) out.push("ellipsis");
683
+ out.push(n);
684
+ prev = n;
685
+ }
686
+ return out;
687
+ }
688
+
689
+ function searchPageHref(q: string, page: number): string {
690
+ return (
691
+ "/search/?q=" + encodeURIComponent(q) + (page > 1 ? "&page=" + page : "")
692
+ );
693
+ }
694
+
695
+ function renderSearchPager(
696
+ pager: HTMLElement,
697
+ q: string,
698
+ cur: number,
699
+ pages: number,
700
+ ) {
701
+ if (pages <= 1) {
702
+ pager.hidden = true;
703
+ return;
704
+ }
705
+ pager.hidden = false;
706
+ const nums = doc.createElement("div");
707
+ nums.className = "pg-nums";
708
+ const mk = (href: string, label: string, cls: string) => {
709
+ const a = doc.createElement("a");
710
+ a.className = cls;
711
+ a.href = href;
712
+ a.textContent = label;
713
+ return a;
714
+ };
715
+ const span = (label: string) => {
716
+ const s = doc.createElement("span");
717
+ s.className = "page-item disabled";
718
+ s.textContent = label;
719
+ return s;
720
+ };
721
+ if (cur > 1)
722
+ nums.appendChild(mk(searchPageHref(q, cur - 1), "‹", "page-item"));
723
+ else nums.appendChild(span("‹"));
724
+ for (const n of pagerNums(cur, pages)) {
725
+ if (n === "ellipsis") {
726
+ const gap = doc.createElement("span");
727
+ gap.className = "pg-gap";
728
+ gap.textContent = "…";
729
+ nums.appendChild(gap);
730
+ } else if (n === cur) {
731
+ const s = doc.createElement("span");
732
+ s.className = "page-item current";
733
+ s.setAttribute("aria-current", "page");
734
+ s.textContent = String(n);
735
+ nums.appendChild(s);
736
+ } else {
737
+ nums.appendChild(mk(searchPageHref(q, n), String(n), "page-item"));
738
+ }
739
+ }
740
+ if (cur < pages)
741
+ nums.appendChild(mk(searchPageHref(q, cur + 1), "›", "page-item"));
742
+ else nums.appendChild(span("›"));
743
+
744
+ const adj = doc.createElement("div");
745
+ adj.className = "pg-adj";
746
+ if (cur > 1)
747
+ adj.appendChild(mk(searchPageHref(q, cur - 1), ui("prevPage"), "pg-prev"));
748
+ else adj.appendChild(span(ui("prevPage")));
749
+ const mid = doc.createElement("span");
750
+ mid.textContent = ui("pageOf")
751
+ .replace("{current}", String(cur))
752
+ .replace("{total}", String(pages));
753
+ adj.appendChild(mid);
754
+ if (cur < pages)
755
+ adj.appendChild(mk(searchPageHref(q, cur + 1), ui("nextPage"), "pg-next"));
756
+ else adj.appendChild(span(ui("nextPage")));
757
+
758
+ pager.replaceChildren(nums, adj);
759
+ }
760
+
761
+ async function renderSearchPage() {
762
+ const seq = ++searchPageSeq;
763
+ const input = doc.getElementById(
764
+ "searchPageInput",
765
+ ) as HTMLInputElement | null;
766
+ const meta = doc.getElementById("searchPageMeta");
767
+ const box = doc.getElementById("searchPageResults");
768
+ const pager = doc.getElementById("searchPagePager");
769
+ if (!input || !meta || !box || !pager) return;
770
+
771
+ const params = new URLSearchParams(location.search);
772
+ const q = (params.get("q") || "").trim();
773
+ const page = Math.max(1, parseInt(params.get("page") || "1", 10) || 1);
774
+ input.value = q;
775
+ pager.hidden = true;
776
+
777
+ if (!q) {
778
+ box.replaceChildren();
779
+ meta.textContent = ui("searchTypeSomething");
780
+ return;
781
+ }
782
+ meta.textContent = ui("searchLoading");
783
+
784
+ const pf = await loadPagefind();
785
+ if (seq !== searchPageSeq) return;
786
+ if (!pf) {
787
+ meta.textContent = ui("searchUnavailable");
788
+ box.replaceChildren();
789
+ return;
790
+ }
791
+ try {
792
+ const res = await pf.search(q);
793
+ const total = res.results.length;
794
+ if (seq !== searchPageSeq) return;
795
+ if (!total) {
796
+ box.replaceChildren();
797
+ meta.textContent = ui("searchNoResults");
798
+ return;
799
+ }
800
+ const pages = Math.ceil(total / SEARCH_PAGE_SIZE);
801
+ const cur = Math.min(page, pages);
802
+ const items = await Promise.all(
803
+ res.results
804
+ .slice((cur - 1) * SEARCH_PAGE_SIZE, cur * SEARCH_PAGE_SIZE)
805
+ .map((r) => r.data()),
806
+ );
807
+ if (seq !== searchPageSeq) return;
808
+ box.replaceChildren();
809
+ for (const item of items) box.appendChild(searchItemEl(item));
810
+ meta.textContent = ui("searchResultsTotal").replace("{n}", String(total));
811
+ renderSearchPager(pager, q, cur, pages);
812
+ } catch {
813
+ if (seq === searchPageSeq) meta.textContent = ui("searchNoResults");
814
+ }
815
+ }
816
+
626
817
  /* ---------- 加密文章(M3b-b7):Web Crypto 解密(与 utils/crypto.ts 构建期参数对齐) ---------- */
627
818
  const PW_KEY_PREFIX = "qy-pw:";
628
819
  const ENC_SALT_LEN = 16;
@@ -741,7 +932,7 @@ doc.addEventListener("click", (e) => {
741
932
  const target = e.target as Element;
742
933
  if (!target || typeof target.closest !== "function") return;
743
934
 
744
- /* 点击调色面板/搜索浮层外部时先收起(不拦截后续点击行为) */
935
+ /* 点击调色面板/搜索浮层/显示设置面板外部时先收起(不拦截后续点击行为) */
745
936
  if (!target.closest(".palette-wrap")) {
746
937
  const pop = doc.getElementById("palettePop");
747
938
  if (pop && !pop.hidden) closePalette();
@@ -750,6 +941,13 @@ doc.addEventListener("click", (e) => {
750
941
  const pop = doc.getElementById("searchPop");
751
942
  if (pop && !pop.hidden) closeSearch();
752
943
  }
944
+ if (
945
+ !target.closest(".ds-panel") &&
946
+ !target.closest("#displaySettingsToggle")
947
+ ) {
948
+ const panel = doc.getElementById("displaySettingsPanel");
949
+ if (panel && !panel.hidden) closeDsPanel();
950
+ }
753
951
 
754
952
  /* 搜索浮层开关(§4.56 移动端 icon 按钮;桌面内联框输入即搜,无开关) */
755
953
  if (target.closest("#searchToggle")) {
@@ -768,8 +966,8 @@ doc.addEventListener("click", (e) => {
768
966
  return;
769
967
  }
770
968
 
771
- /* 搜索结果点击:收起浮层后放行默认导航(VT 接管换页) */
772
- if (target.closest(".search-item")) {
969
+ /* 搜索结果/查看全部点击:浮层收起后放行默认导航(VT 接管换页;search-more 进独立搜索页) */
970
+ if (target.closest(".search-item, .search-more")) {
773
971
  closeSearch();
774
972
  }
775
973
 
@@ -787,8 +985,8 @@ doc.addEventListener("click", (e) => {
787
985
  return;
788
986
  }
789
987
 
790
- /* 预设色板 */
791
- const swatch = target.closest<HTMLElement>(".palette-pop .swatch");
988
+ /* 预设色板(旧 palette-pop 与显示设置面板外观 Tab 共用同组 swatch) */
989
+ const swatch = target.closest<HTMLElement>(".swatch[data-hue]");
792
990
  if (swatch) {
793
991
  const h = parseFloat(swatch.dataset.hue || "");
794
992
  if (Number.isFinite(h)) applyHue(h);
@@ -814,6 +1012,36 @@ doc.addEventListener("click", (e) => {
814
1012
  return;
815
1013
  }
816
1014
 
1015
+ /* 壁纸模式循环切换(§4.98):banner→fullscreen→overlay→none,localStorage 持久化 */
1016
+ if (target.closest("#wallpaperModeToggle")) {
1017
+ const cur = WP_MODES.indexOf(root.dataset.wallpaperMode || "banner");
1018
+ setWallpaperMode(WP_MODES[(cur + 1) % WP_MODES.length]!);
1019
+ return;
1020
+ }
1021
+
1022
+ /* 显示设置面板(§4.103):齿轮开合 / Tab 切换 / 模式与布局按钮 / 开关 / 组重置 */
1023
+ if (handleDsPanelClick(target)) return;
1024
+
1025
+ /* 壁纸视频播放/暂停(§4.98) */
1026
+ if (target.closest("#wpPlayerToggle")) {
1027
+ const video = doc.querySelector<HTMLVideoElement>(".wp-video");
1028
+ if (video) {
1029
+ if (video.paused) video.play().catch(() => {});
1030
+ else video.pause();
1031
+ }
1032
+ return;
1033
+ }
1034
+
1035
+ /* 壁纸视频上一段/下一段 */
1036
+ if (target.closest("#wpPlayerPrev")) {
1037
+ switchWallpaperVideo(-1);
1038
+ return;
1039
+ }
1040
+ if (target.closest("#wpPlayerNext")) {
1041
+ switchWallpaperVideo(1);
1042
+ return;
1043
+ }
1044
+
817
1045
  /* burger 导航 */
818
1046
  if (target.closest("#navToggle")) {
819
1047
  const nav = doc.getElementById("siteNav");
@@ -909,7 +1137,7 @@ doc.addEventListener(
909
1137
  true,
910
1138
  );
911
1139
 
912
- /* input 委托(VT 安全):hue 滑杆 + 搜索输入 */
1140
+ /* input 委托(VT 安全):hue 滑杆 + 搜索输入 + 显示设置面板透明滑块(§4.103) */
913
1141
  doc.addEventListener("input", (e) => {
914
1142
  const el = e.target as Element | null;
915
1143
  if (el && el.id === "hueSlider") {
@@ -919,16 +1147,37 @@ doc.addEventListener("input", (e) => {
919
1147
  if (el && (el.id === "searchInput" || el.id === "searchInputM")) {
920
1148
  runSearch((el as HTMLInputElement).value.trim());
921
1149
  }
1150
+ /* 独立搜索页输入(§4.104):300ms 防抖 + replaceState 同步 URL,客户端即时刷新不经换页 */
1151
+ if (el && el.id === "searchPageInput") {
1152
+ const q = (el as HTMLInputElement).value.trim();
1153
+ window.clearTimeout(searchPageTimer);
1154
+ searchPageTimer = window.setTimeout(() => {
1155
+ const url = new URL(location.href);
1156
+ if (q) url.searchParams.set("q", q);
1157
+ else url.searchParams.delete("q");
1158
+ url.searchParams.delete("page");
1159
+ history.replaceState(null, "", url);
1160
+ renderSearchPage();
1161
+ }, 300);
1162
+ }
1163
+ if (el instanceof HTMLInputElement && el.dataset.dsSlider) {
1164
+ handleDsSliderInput(el);
1165
+ }
922
1166
  });
923
1167
 
924
1168
  doc.addEventListener("keydown", (e) => {
925
- /* 搜索浮层:Esc 关闭 */
1169
+ /* 搜索浮层/显示设置面板:Esc 关闭 */
926
1170
  if (e.key === "Escape") {
927
1171
  const pop = doc.getElementById("searchPop");
928
1172
  if (pop && !pop.hidden) {
929
1173
  closeSearch();
930
1174
  return;
931
1175
  }
1176
+ const panel = doc.getElementById("displaySettingsPanel");
1177
+ if (panel && !panel.hidden) {
1178
+ closeDsPanel();
1179
+ return;
1180
+ }
932
1181
  }
933
1182
  /* 搜索快捷键(§4.12):/ 或 ⌘/Ctrl+K;输入中、灯箱(PhotoSwipe)打开时不触发 */
934
1183
  if (
@@ -1021,6 +1270,534 @@ function initSeriesNav() {
1021
1270
  }
1022
1271
  }
1023
1272
 
1273
+ /* ---------- 壁纸(§4.98):模式切换 / 图片轮播 / 打字机 / 视频播放器 ---------- */
1274
+ const WP_KEY = "qy-wallpaper";
1275
+ const WP_MODES = ["banner", "fullscreen", "overlay", "none"];
1276
+ // 模块级定时器/游标:VT 换页重复 initWallpaper 时先清后建防泄漏
1277
+ let wpCarouselTimer: number | undefined;
1278
+ let wpTyperTimer: number | undefined;
1279
+ let wpVideoIdx = 0;
1280
+
1281
+ function savedWallpaperMode(): string | null {
1282
+ try {
1283
+ const m = localStorage.getItem(WP_KEY);
1284
+ return m && WP_MODES.includes(m) ? m : null;
1285
+ } catch {
1286
+ return null;
1287
+ }
1288
+ }
1289
+
1290
+ /* 导航栏形态联动:banner/fullscreen 走配置的透明档,overlay/none 回实底 */
1291
+ function syncWallpaperNav(mode: string) {
1292
+ root.dataset.wallpaperNav =
1293
+ mode === "banner" || mode === "fullscreen"
1294
+ ? root.dataset.wpNavConfig || "solid"
1295
+ : "solid";
1296
+ }
1297
+
1298
+ /* 壁纸模式统一切换入口(§4.98 循环按钮与 §4.103 面板模式宫格共用):
1299
+ banner/fullscreen 解除 homeOnly 隐藏,overlay/none 停视频并回实底导航;
1300
+ data-wp-stage-hidden 同步供 hero 布局取消首屏下推 */
1301
+ function setWallpaperMode(next: string) {
1302
+ root.dataset.wallpaperMode = next;
1303
+ syncWallpaperNav(next);
1304
+ const stage = doc.querySelector<HTMLElement>(".wp-stage");
1305
+ if (stage) {
1306
+ if (next === "banner" || next === "fullscreen") {
1307
+ delete stage.dataset.wallpaperHidden;
1308
+ delete root.dataset.wpStageHidden;
1309
+ } else {
1310
+ stage.setAttribute("data-wallpaper-hidden", "");
1311
+ root.dataset.wpStageHidden = "true";
1312
+ }
1313
+ }
1314
+ if (next === "overlay" || next === "none") pauseWallpaperVideo();
1315
+ try {
1316
+ localStorage.setItem(WP_KEY, next);
1317
+ } catch {
1318
+ /* ignore */
1319
+ }
1320
+ syncDsPanelState();
1321
+ }
1322
+
1323
+ function pauseWallpaperVideo() {
1324
+ const video = doc.querySelector<HTMLVideoElement>(".wp-video");
1325
+ if (video && !video.paused) video.pause();
1326
+ }
1327
+
1328
+ /* 恢复访客保存的壁纸模式(after-swap 与 initWallpaper 双入口,幂等):
1329
+ boot 脚本负责首帧,这里兜底 VT 换页;显式偏好解除 homeOnly 的非首页隐藏;
1330
+ §4.103:面板开启时追加恢复布局/开关/透明三件套(html style 随 swap 重建需重应用)。
1331
+ 注意:即使保存模式与 SSR 模式相同,也必须同步 data-wallpaper-hidden,否则
1332
+ 非首页刷新时 SSR 渲染的隐藏标记不会被清除(banner/fullscreen 应始终可见) */
1333
+ function applySavedWallpaper() {
1334
+ const m =
1335
+ root.dataset.wpSwitchable === "true"
1336
+ ? savedWallpaperMode() || root.dataset.wallpaperMode
1337
+ : root.dataset.wallpaperMode;
1338
+ if (m && m !== root.dataset.wallpaperMode) {
1339
+ root.dataset.wallpaperMode = m;
1340
+ syncWallpaperNav(m);
1341
+ }
1342
+ const stage = doc.querySelector<HTMLElement>(".wp-stage");
1343
+ if (stage) {
1344
+ if (m === "banner" || m === "fullscreen") {
1345
+ delete stage.dataset.wallpaperHidden;
1346
+ delete root.dataset.wpStageHidden;
1347
+ } else {
1348
+ stage.setAttribute("data-wallpaper-hidden", "");
1349
+ root.dataset.wpStageHidden = "true";
1350
+ }
1351
+ }
1352
+ if (root.dataset.dsEnable === "true") applyDsPrefs();
1353
+ }
1354
+
1355
+ /* 图片轮播:desktop/mobile 两组独立游标,--wp-interval 供 kenburns/zoom 动画时长对齐;
1356
+ §4.103:访客经面板关闭轮播(html[data-wp-carousel="off"])时不建定时器 */
1357
+ function initWallpaperCarousel() {
1358
+ const stage = doc.querySelector<HTMLElement>(".wp-stage");
1359
+ if (!stage || stage.dataset.carousel !== "true") return;
1360
+ if (root.dataset.wpCarousel === "off") return;
1361
+ const groups = Array.from(stage.querySelectorAll<HTMLElement>(".wp-images"));
1362
+ if (groups.length === 0) return;
1363
+ const interval =
1364
+ Math.max(1000, parseInt(stage.dataset.carouselInterval || "5000", 10)) ||
1365
+ 5000;
1366
+ stage.style.setProperty("--wp-interval", `${(interval / 1000).toFixed(2)}s`);
1367
+ const cursors = groups.map(() => 0);
1368
+ wpCarouselTimer = window.setInterval(() => {
1369
+ groups.forEach((group, gi) => {
1370
+ const imgs = group.querySelectorAll(".wp-img");
1371
+ if (imgs.length < 2) return;
1372
+ imgs[cursors[gi]].classList.remove("active");
1373
+ cursors[gi] = (cursors[gi] + 1) % imgs.length;
1374
+ imgs[cursors[gi]].classList.add("active");
1375
+ });
1376
+ }, interval);
1377
+ }
1378
+
1379
+ /* 逐字素切分(中文/emoji 安全),环境不支持时退回码元切分 */
1380
+ function splitGraphemes(text: string): string[] {
1381
+ try {
1382
+ const seg = new Intl.Segmenter(undefined, { granularity: "grapheme" });
1383
+ return Array.from(seg.segment(text), (s) => s.segment);
1384
+ } catch {
1385
+ return Array.from(text);
1386
+ }
1387
+ }
1388
+
1389
+ /* 首页打字机:多条副标题循环(打完→停顿→删完→下一条),单条打完驻留 */
1390
+ function initWallpaperTyper() {
1391
+ const el = doc.getElementById("wpTyper");
1392
+ if (!el) return;
1393
+ let subs: string[] = [];
1394
+ try {
1395
+ subs = JSON.parse(el.dataset.subtitles || "[]");
1396
+ } catch {
1397
+ /* ignore */
1398
+ }
1399
+ subs = subs.filter(Boolean);
1400
+ if (subs.length === 0) return;
1401
+ const speed = parseInt(el.dataset.speed || "100", 10) || 100;
1402
+ const delSpeed = parseInt(el.dataset.deleteSpeed || "50", 10) || 50;
1403
+ const pause = parseInt(el.dataset.pauseTime || "2000", 10) || 2000;
1404
+ const chars = subs.map(splitGraphemes);
1405
+ let si = 0;
1406
+ let ci = 0;
1407
+ let deleting = false;
1408
+ const tick = () => {
1409
+ const cur = chars[si];
1410
+ if (!deleting) {
1411
+ ci += 1;
1412
+ el.textContent = cur.slice(0, ci).join("");
1413
+ if (ci >= cur.length) {
1414
+ if (subs.length === 1) return;
1415
+ deleting = true;
1416
+ wpTyperTimer = window.setTimeout(tick, pause);
1417
+ } else {
1418
+ wpTyperTimer = window.setTimeout(tick, speed);
1419
+ }
1420
+ } else {
1421
+ ci -= 1;
1422
+ el.textContent = cur.slice(0, ci).join("");
1423
+ if (ci <= 0) {
1424
+ deleting = false;
1425
+ si = (si + 1) % subs.length;
1426
+ wpTyperTimer = window.setTimeout(tick, 300);
1427
+ } else {
1428
+ wpTyperTimer = window.setTimeout(tick, delSpeed);
1429
+ }
1430
+ }
1431
+ };
1432
+ wpTyperTimer = window.setTimeout(tick, speed);
1433
+ }
1434
+
1435
+ /* 视频壁纸:初始源依 order/random 选取,自动播放(muted+playsinline 免手势),
1436
+ play/pause 事件同步按钮图标(is-playing)与 stage 播放态(data-video-playing) */
1437
+ function initWallpaperVideo() {
1438
+ const video = doc.querySelector<HTMLVideoElement>(".wp-video");
1439
+ if (!video) return;
1440
+ let urls: string[] = [];
1441
+ try {
1442
+ urls = JSON.parse(video.dataset.playerUrls || "[]");
1443
+ } catch {
1444
+ /* ignore */
1445
+ }
1446
+ urls = urls.filter(Boolean);
1447
+ if (urls.length === 0) return;
1448
+ wpVideoIdx =
1449
+ video.dataset.playerMode === "random"
1450
+ ? Math.floor(Math.random() * urls.length)
1451
+ : 0;
1452
+ video.src = urls[wpVideoIdx];
1453
+ video.loop = true;
1454
+ const sync = () => {
1455
+ const btn = doc.getElementById("wpPlayerToggle");
1456
+ if (btn) {
1457
+ btn.classList.toggle("is-playing", !video.paused);
1458
+ const label = video.paused ? ui("wpPlay") : ui("wpPause");
1459
+ btn.setAttribute("aria-label", label);
1460
+ btn.title = label;
1461
+ }
1462
+ doc
1463
+ .querySelector(".wp-stage")
1464
+ ?.toggleAttribute("data-video-playing", !video.paused);
1465
+ };
1466
+ video.addEventListener("play", sync);
1467
+ video.addEventListener("pause", sync);
1468
+ sync();
1469
+ video.play().catch(() => sync()); // 自动播放被策略拦截时保持暂停态
1470
+ }
1471
+
1472
+ /* 上一段/下一段视频(初始 idx 由 order/random 决定,此后线性切换) */
1473
+ function switchWallpaperVideo(dir: 1 | -1) {
1474
+ const video = doc.querySelector<HTMLVideoElement>(".wp-video");
1475
+ if (!video) return;
1476
+ let urls: string[] = [];
1477
+ try {
1478
+ urls = JSON.parse(video.dataset.playerUrls || "[]");
1479
+ } catch {
1480
+ return;
1481
+ }
1482
+ if (urls.length === 0) return;
1483
+ wpVideoIdx = (wpVideoIdx + dir + urls.length) % urls.length;
1484
+ video.src = urls[wpVideoIdx];
1485
+ video.play().catch(() => {});
1486
+ }
1487
+
1488
+ /* 导航栏滚动实底(html.wp-scrolled):顶部走半透明档,滚过阈值回实底 */
1489
+ function updateWallpaperScroll() {
1490
+ root.classList.toggle("wp-scrolled", (window.scrollY || 0) > 40);
1491
+ }
1492
+
1493
+ function initWallpaper() {
1494
+ if (wpCarouselTimer !== undefined) {
1495
+ window.clearInterval(wpCarouselTimer);
1496
+ wpCarouselTimer = undefined;
1497
+ }
1498
+ if (wpTyperTimer !== undefined) {
1499
+ window.clearTimeout(wpTyperTimer);
1500
+ wpTyperTimer = undefined;
1501
+ }
1502
+ applySavedWallpaper();
1503
+ updateWallpaperScroll();
1504
+ if (!doc.querySelector(".wp-stage")) return;
1505
+ initWallpaperCarousel();
1506
+ initWallpaperTyper();
1507
+ initWallpaperVideo();
1508
+ }
1509
+
1510
+ /* VT swap 替换 <html> 属性后立即恢复偏好模式(不等 page-load,防默认形态闪帧) */
1511
+ doc.addEventListener("astro:after-swap", applySavedWallpaper);
1512
+
1513
+ /* ---------- 显示设置面板(§4.103,对齐 Firefly) ----------
1514
+ 齿轮入口三 Tab 浮层(外观/壁纸/特效);localStorage 键表见 design.md §4.103。
1515
+ 应用机制:html data 属性(显隐类)+ html style CSS 变量(透明三件套),
1516
+ boot 内联脚本(BaseLayout)首帧应用,applyDsPrefs 为 after-swap/初始化双入口 */
1517
+ const DS_KEY = {
1518
+ fsLayout: "qy-wp-fs-layout",
1519
+ homeText: "qy-wp-home-text",
1520
+ carousel: "qy-wp-carousel",
1521
+ waves: "qy-wp-waves",
1522
+ gradient: "qy-wp-gradient",
1523
+ opacity: "qy-wp-opacity",
1524
+ blur: "qy-wp-blur",
1525
+ cardOpacity: "qy-wp-card-opacity",
1526
+ sakura: "qy-sakura",
1527
+ } as const;
1528
+ /* 开关键 ↔ html data 属性 ↔ localStorage 键 映射(on/off 语义) */
1529
+ const DS_SWITCH_MAP = {
1530
+ "home-text": { ls: DS_KEY.homeText, attr: "wpHomeText" },
1531
+ carousel: { ls: DS_KEY.carousel, attr: "wpCarousel" },
1532
+ waves: { ls: DS_KEY.waves, attr: "wpWaves" },
1533
+ gradient: { ls: DS_KEY.gradient, attr: "wpGradient" },
1534
+ sakura: { ls: DS_KEY.sakura, attr: "wpSakura" },
1535
+ } as const;
1536
+ type DsSwitchId = keyof typeof DS_SWITCH_MAP;
1537
+
1538
+ function lsGet(key: string): string | null {
1539
+ try {
1540
+ return localStorage.getItem(key);
1541
+ } catch {
1542
+ return null;
1543
+ }
1544
+ }
1545
+ function lsSet(key: string, val: string | null) {
1546
+ try {
1547
+ if (val === null) localStorage.removeItem(key);
1548
+ else localStorage.setItem(key, val);
1549
+ } catch {
1550
+ /* ignore */
1551
+ }
1552
+ }
1553
+
1554
+ /* 透明三件套应用到 html style(boot 同款逻辑;null 表示清除覆写回落配置默认) */
1555
+ function applyOverlayOpacity(v: number | null) {
1556
+ if (v === null) root.style.removeProperty("--wp-overlay-opacity");
1557
+ else root.style.setProperty("--wp-overlay-opacity", String(v));
1558
+ }
1559
+ function applyOverlayBlur(v: number | null) {
1560
+ if (v === null) {
1561
+ root.style.removeProperty("--wp-overlay-blur");
1562
+ root.style.removeProperty("--wp-overlay-scale");
1563
+ } else {
1564
+ root.style.setProperty("--wp-overlay-blur", `${v}px`);
1565
+ root.style.setProperty("--wp-overlay-scale", (1 + v / 100).toFixed(3));
1566
+ }
1567
+ }
1568
+ function applyCardOpacity(v: number | null) {
1569
+ if (v === null) root.style.removeProperty("--wp-card-opacity");
1570
+ else root.style.setProperty("--wp-card-opacity", `${Math.round(v * 100)}%`);
1571
+ }
1572
+
1573
+ /* 开关应用:html data 属性 + 副作用(轮播定时器清/建、樱花停/启) */
1574
+ function applyDsSwitch(id: DsSwitchId, on: boolean) {
1575
+ const { ls, attr } = DS_SWITCH_MAP[id];
1576
+ root.dataset[attr] = on ? "on" : "off";
1577
+ lsSet(ls, on ? "1" : "0");
1578
+ if (id === "carousel") {
1579
+ if (wpCarouselTimer !== undefined) {
1580
+ window.clearInterval(wpCarouselTimer);
1581
+ wpCarouselTimer = undefined;
1582
+ }
1583
+ if (on) initWallpaperCarousel();
1584
+ }
1585
+ if (id === "sakura") {
1586
+ doc.dispatchEvent(
1587
+ new CustomEvent("orchid:sakura-toggle", { detail: on ? "on" : "off" }),
1588
+ );
1589
+ }
1590
+ }
1591
+
1592
+ /* 恢复面板全部访客偏好(boot 负责首帧,这里兜底 VT swap 与初始化) */
1593
+ function applyDsPrefs() {
1594
+ const l = lsGet(DS_KEY.fsLayout);
1595
+ if (l === "classic" || l === "hero") root.dataset.wpFsLayout = l;
1596
+ for (const id of Object.keys(DS_SWITCH_MAP) as DsSwitchId[]) {
1597
+ const v = lsGet(DS_SWITCH_MAP[id].ls);
1598
+ if (v === "0" || v === "1")
1599
+ root.dataset[DS_SWITCH_MAP[id].attr] = v === "1" ? "on" : "off";
1600
+ }
1601
+ const op = parseFloat(lsGet(DS_KEY.opacity) || "");
1602
+ if (Number.isFinite(op)) applyOverlayOpacity(op);
1603
+ const bl = parseFloat(lsGet(DS_KEY.blur) || "");
1604
+ if (Number.isFinite(bl)) applyOverlayBlur(bl);
1605
+ const co = parseFloat(lsGet(DS_KEY.cardOpacity) || "");
1606
+ if (Number.isFinite(co)) applyCardOpacity(co);
1607
+ }
1608
+
1609
+ /* 面板根节点配置默认值(DisplaySettings.astro 输出的 data-def-*) */
1610
+ function dsPanel(): HTMLElement | null {
1611
+ return doc.getElementById("displaySettingsPanel");
1612
+ }
1613
+ function dsDef(key: string, fallback: number): number {
1614
+ const v = parseFloat(dsPanel()?.dataset[key] || "");
1615
+ return Number.isFinite(v) ? v : fallback;
1616
+ }
1617
+
1618
+ /* 同步面板控件态:模式/布局按钮激活、组显隐(data-modes)、开关、滑块与徽章。
1619
+ 模式或布局切换、面板打开、组重置、VT 换页后调用 */
1620
+ function syncDsPanelState() {
1621
+ const panel = dsPanel();
1622
+ if (!panel) return;
1623
+ const mode = root.dataset.wallpaperMode || "none";
1624
+ const layout = root.dataset.wpFsLayout || "classic";
1625
+ /* 当前生效的模式 token:fullscreen+hero 组合单独成一个虚拟模式 */
1626
+ const tokens = [mode];
1627
+ if (mode === "fullscreen" && layout === "hero")
1628
+ tokens.push("fullscreen-hero");
1629
+ panel.querySelectorAll<HTMLElement>("[data-modes]").forEach((el) => {
1630
+ const modes = (el.dataset.modes || "").split(/\s+/).filter(Boolean);
1631
+ const visible = tokens.some((tok) => modes.includes(tok));
1632
+ if (el.classList.contains("ds-group"))
1633
+ el.classList.toggle("is-hidden", !visible);
1634
+ else el.classList.toggle("ds-row-hidden", !visible);
1635
+ });
1636
+ panel
1637
+ .querySelectorAll<HTMLElement>(".ds-mode-btn[data-mode]")
1638
+ .forEach((b) => {
1639
+ b.classList.toggle("is-active", b.dataset.mode === mode);
1640
+ });
1641
+ panel
1642
+ .querySelectorAll<HTMLElement>(".ds-mode-btn[data-fs-layout]")
1643
+ .forEach((b) => {
1644
+ b.classList.toggle("is-active", b.dataset.fsLayout === layout);
1645
+ });
1646
+ /* 开关态:html data 属性为当前生效值(boot/applyDsPrefs 已写入,默认 on) */
1647
+ for (const id of Object.keys(DS_SWITCH_MAP) as DsSwitchId[]) {
1648
+ const sw = panel.querySelector<HTMLElement>(`[data-ds-switch="${id}"]`);
1649
+ if (sw)
1650
+ sw.setAttribute(
1651
+ "aria-checked",
1652
+ root.dataset[DS_SWITCH_MAP[id].attr] === "off" ? "false" : "true",
1653
+ );
1654
+ }
1655
+ /* 滑块与徽章:localStorage 覆写优先,否则面板 data-def-*(配置默认) */
1656
+ const opSaved = parseFloat(lsGet(DS_KEY.opacity) || "");
1657
+ const op = Number.isFinite(opSaved) ? opSaved : dsDef("defOpacity", 0.8);
1658
+ const blSaved = parseFloat(lsGet(DS_KEY.blur) || "");
1659
+ const bl = Number.isFinite(blSaved) ? blSaved : dsDef("defBlur", 0);
1660
+ const coSaved = parseFloat(lsGet(DS_KEY.cardOpacity) || "");
1661
+ const co = Number.isFinite(coSaved) ? coSaved : dsDef("defCardOpacity", 0.5);
1662
+ const setSlider = (id: string, val: number, text: string) => {
1663
+ const s = panel.querySelector<HTMLInputElement>(`[data-ds-slider="${id}"]`);
1664
+ if (s) s.value = String(val);
1665
+ const badge = panel.querySelector<HTMLElement>(`[data-ds-badge="${id}"]`);
1666
+ if (badge) badge.textContent = text;
1667
+ };
1668
+ setSlider("opacity", Math.round(op * 100), `${Math.round(op * 100)}%`);
1669
+ setSlider("blur", bl, `${bl}px`);
1670
+ setSlider("card-opacity", Math.round(co * 100), `${Math.round(co * 100)}%`);
1671
+ }
1672
+
1673
+ function closeDsPanel() {
1674
+ const panel = dsPanel();
1675
+ if (panel) panel.hidden = true;
1676
+ doc
1677
+ .getElementById("displaySettingsToggle")
1678
+ ?.setAttribute("aria-expanded", "false");
1679
+ }
1680
+
1681
+ /* 面板 click 委托(document 级):命中面板内控件返回 true 拦截后续处理 */
1682
+ function handleDsPanelClick(target: Element): boolean {
1683
+ /* 齿轮开合 */
1684
+ if (target.closest("#displaySettingsToggle")) {
1685
+ const panel = dsPanel();
1686
+ if (panel) {
1687
+ const open = panel.hidden;
1688
+ panel.hidden = !open;
1689
+ target
1690
+ .closest("#displaySettingsToggle")
1691
+ ?.setAttribute("aria-expanded", open ? "true" : "false");
1692
+ if (open) syncDsPanelState();
1693
+ }
1694
+ return true;
1695
+ }
1696
+ /* Tab 切换 */
1697
+ const tab = target.closest<HTMLElement>(".ds-tab[data-tab]");
1698
+ if (tab) {
1699
+ const panel = dsPanel();
1700
+ if (panel) {
1701
+ panel.querySelectorAll(".ds-tab").forEach((b) => {
1702
+ b.classList.toggle("is-active", b === tab);
1703
+ b.setAttribute("aria-selected", b === tab ? "true" : "false");
1704
+ });
1705
+ panel.querySelectorAll<HTMLElement>(".ds-page").forEach((p) => {
1706
+ p.classList.toggle("is-active", p.dataset.page === tab.dataset.tab);
1707
+ });
1708
+ }
1709
+ return true;
1710
+ }
1711
+ /* 壁纸模式四选一(复用统一切换入口) */
1712
+ const modeBtn = target.closest<HTMLElement>("[data-mode]");
1713
+ if (modeBtn && dsPanel()?.contains(modeBtn)) {
1714
+ const m = modeBtn.dataset.mode || "";
1715
+ if (WP_MODES.includes(m)) setWallpaperMode(m);
1716
+ return true;
1717
+ }
1718
+ /* 全屏布局切换 */
1719
+ const layoutBtn = target.closest<HTMLElement>("[data-fs-layout]");
1720
+ if (layoutBtn) {
1721
+ const l = layoutBtn.dataset.fsLayout || "";
1722
+ if (l === "classic" || l === "hero") {
1723
+ root.dataset.wpFsLayout = l;
1724
+ lsSet(DS_KEY.fsLayout, l);
1725
+ syncDsPanelState();
1726
+ }
1727
+ return true;
1728
+ }
1729
+ /* 开关行 */
1730
+ const sw = target.closest<HTMLElement>("[data-ds-switch]");
1731
+ if (sw) {
1732
+ const id = sw.dataset.dsSwitch as DsSwitchId;
1733
+ if (id in DS_SWITCH_MAP) {
1734
+ const on = sw.getAttribute("aria-checked") !== "true";
1735
+ applyDsSwitch(id, on);
1736
+ sw.setAttribute("aria-checked", on ? "true" : "false");
1737
+ }
1738
+ return true;
1739
+ }
1740
+ /* 组重置(↺):清本组 localStorage,回落配置默认并重应用 */
1741
+ const resetBtn = target.closest<HTMLElement>("[data-reset-group]");
1742
+ if (resetBtn) {
1743
+ if (resetBtn.dataset.resetGroup === "wallpaper") {
1744
+ lsSet(DS_KEY.homeText, null);
1745
+ lsSet(DS_KEY.carousel, null);
1746
+ lsSet(DS_KEY.waves, null);
1747
+ lsSet(DS_KEY.gradient, null);
1748
+ delete root.dataset.wpHomeText;
1749
+ delete root.dataset.wpCarousel;
1750
+ delete root.dataset.wpWaves;
1751
+ delete root.dataset.wpGradient;
1752
+ /* 轮播回到配置默认(开):先清后建 */
1753
+ if (wpCarouselTimer !== undefined) {
1754
+ window.clearInterval(wpCarouselTimer);
1755
+ wpCarouselTimer = undefined;
1756
+ }
1757
+ initWallpaperCarousel();
1758
+ }
1759
+ if (resetBtn.dataset.resetGroup === "overlay") {
1760
+ lsSet(DS_KEY.opacity, null);
1761
+ lsSet(DS_KEY.blur, null);
1762
+ lsSet(DS_KEY.cardOpacity, null);
1763
+ applyOverlayOpacity(null);
1764
+ applyOverlayBlur(null);
1765
+ applyCardOpacity(null);
1766
+ }
1767
+ syncDsPanelState();
1768
+ return true;
1769
+ }
1770
+ return false;
1771
+ }
1772
+
1773
+ /* 滑块 input(input 委托调用):实时应用 + 持久化 + 徽章 */
1774
+ function handleDsSliderInput(el: HTMLInputElement) {
1775
+ const id = el.dataset.dsSlider;
1776
+ const v = parseFloat(el.value);
1777
+ if (!Number.isFinite(v)) return;
1778
+ if (id === "opacity") {
1779
+ applyOverlayOpacity(v / 100);
1780
+ lsSet(DS_KEY.opacity, String(v / 100));
1781
+ } else if (id === "blur") {
1782
+ applyOverlayBlur(v);
1783
+ lsSet(DS_KEY.blur, String(v));
1784
+ } else if (id === "card-opacity") {
1785
+ applyCardOpacity(v / 100);
1786
+ lsSet(DS_KEY.cardOpacity, String(v / 100));
1787
+ } else return;
1788
+ const panel = dsPanel();
1789
+ const badge = panel?.querySelector<HTMLElement>(`[data-ds-badge="${id}"]`);
1790
+ if (badge) badge.textContent = id === "blur" ? `${v}px` : `${Math.round(v)}%`;
1791
+ }
1792
+
1793
+ /* 每次导航后重绑面板(DOM 随 swap 重建):复位收起 + 控件态同步 */
1794
+ function initDisplaySettings() {
1795
+ const panel = dsPanel();
1796
+ if (!panel) return;
1797
+ closeDsPanel();
1798
+ syncDsPanelState();
1799
+ }
1800
+
1024
1801
  /* ---------- 生命周期 ---------- */
1025
1802
  function initPage() {
1026
1803
  initLightbox();
@@ -1036,6 +1813,9 @@ function initPage() {
1036
1813
  initCategoryBar();
1037
1814
  initPostListLayout();
1038
1815
  initSeriesNav();
1816
+ initWallpaper();
1817
+ initDisplaySettings();
1818
+ renderSearchPage();
1039
1819
  }
1040
1820
  initPage();
1041
1821
  doc.addEventListener("astro:page-load", initPage);