@windcoder/astro-theme-orchid 0.3.0 → 0.4.0

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