@windcoder/astro-theme-orchid 0.4.5 → 0.4.6

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windcoder/astro-theme-orchid",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
4
4
  "description": "清新简约的 Astro 博客主题,以 Integration 形式提供,支持版本化升级与组件覆盖",
5
5
  "type": "module",
6
6
  "keywords": [
package/src/i18n/en.ts CHANGED
@@ -78,6 +78,14 @@ const en: Record<UiKey, string> = {
78
78
  avatarAlt: "Avatar of {name}",
79
79
  noFriends: "No friend links yet",
80
80
  applyFriend: "Apply for a link",
81
+ friendSearch: "Search links…",
82
+ friendAll: "All",
83
+ noFriendMatch: "No matching links",
84
+ copy: "Copy",
85
+ friendSiteName: "Site name",
86
+ friendSiteDesc: "Site description",
87
+ friendSiteUrl: "Site URL",
88
+ friendAvatarUrl: "Avatar URL",
81
89
  /* Bangumi watching page (§4.93) */
82
90
  bangumiEmpty: "Nothing being watched yet",
83
91
  bangumiFail: "Failed to fetch Bangumi data. Rebuild the site to retry.",
package/src/i18n/ja.ts CHANGED
@@ -78,6 +78,14 @@ const ja: Record<UiKey, string> = {
78
78
  avatarAlt: "{name} のアバター",
79
79
  noFriends: "リンクはまだありません",
80
80
  applyFriend: "リンク申請",
81
+ friendSearch: "リンクを検索…",
82
+ friendAll: "すべて",
83
+ noFriendMatch: "該当するリンクがありません",
84
+ copy: "コピー",
85
+ friendSiteName: "サイト名",
86
+ friendSiteDesc: "サイト説明",
87
+ friendSiteUrl: "サイトURL",
88
+ friendAvatarUrl: "アバターURL",
81
89
  /* 番組視聴ページ */
82
90
  bangumiEmpty: "視聴中の番組はまだありません",
83
91
  bangumiFail:
package/src/i18n/ko.ts CHANGED
@@ -77,6 +77,14 @@ const ko: Record<UiKey, string> = {
77
77
  avatarAlt: "{name}의 아바타",
78
78
  noFriends: "아직 링크가 없습니다",
79
79
  applyFriend: "링크 신청",
80
+ friendSearch: "링크 검색…",
81
+ friendAll: "전체",
82
+ noFriendMatch: "일치하는 링크가 없습니다",
83
+ copy: "복사",
84
+ friendSiteName: "사이트 이름",
85
+ friendSiteDesc: "사이트 설명",
86
+ friendSiteUrl: "사이트 URL",
87
+ friendAvatarUrl: "아바타 URL",
80
88
  /* 애니 시청 페이지 */
81
89
  bangumiEmpty: "시청 중인 애니메이션이 없습니다",
82
90
  bangumiFail:
package/src/i18n/ru.ts CHANGED
@@ -77,6 +77,14 @@ const ru: Record<UiKey, string> = {
77
77
  avatarAlt: "Аватар {name}",
78
78
  noFriends: "Ссылок пока нет",
79
79
  applyFriend: "Заявка на обмен ссылками",
80
+ friendSearch: "Поиск ссылок…",
81
+ friendAll: "Все",
82
+ noFriendMatch: "Ничего не найдено",
83
+ copy: "Копировать",
84
+ friendSiteName: "Название сайта",
85
+ friendSiteDesc: "Описание сайта",
86
+ friendSiteUrl: "Ссылка на сайт",
87
+ friendAvatarUrl: "Ссылка на аватар",
80
88
  /* Страница отслеживания аниме */
81
89
  bangumiEmpty: "Пока ничего не смотрю",
82
90
  bangumiFail:
package/src/i18n/zh-CN.ts CHANGED
@@ -91,6 +91,14 @@ const zhCN = {
91
91
  avatarAlt: "{name} 的头像",
92
92
  noFriends: "还没有友链",
93
93
  applyFriend: "申请友链",
94
+ friendSearch: "搜索友链…",
95
+ friendAll: "全部",
96
+ noFriendMatch: "没有匹配的友链",
97
+ copy: "复制",
98
+ friendSiteName: "站点名称",
99
+ friendSiteDesc: "站点描述",
100
+ friendSiteUrl: "站点链接",
101
+ friendAvatarUrl: "头像链接",
94
102
  /* 番组追番页(§4.93) */
95
103
  bangumiEmpty: "还没有在看中的番剧",
96
104
  bangumiFail: "番组数据拉取失败,请重新构建站点后重试",
package/src/i18n/zh-TW.ts CHANGED
@@ -77,6 +77,14 @@ const zhTW: Record<UiKey, string> = {
77
77
  avatarAlt: "{name} 的頭像",
78
78
  noFriends: "還沒有友鏈",
79
79
  applyFriend: "申請友鏈",
80
+ friendSearch: "搜尋友鏈…",
81
+ friendAll: "全部",
82
+ noFriendMatch: "沒有符合的友鏈",
83
+ copy: "複製",
84
+ friendSiteName: "站點名稱",
85
+ friendSiteDesc: "站點描述",
86
+ friendSiteUrl: "站點連結",
87
+ friendAvatarUrl: "頭像連結",
80
88
  /* 番組追番頁 */
81
89
  bangumiEmpty: "還沒有在看中的番劇",
82
90
  bangumiFail: "番組資料拉取失敗,請重新建置網站後重試",
@@ -22,19 +22,47 @@ const friendImgs = await Promise.all(
22
22
  ),
23
23
  );
24
24
 
25
- // 可选 spec 正文(src/content/spec/friends.md),渲染在卡片列表上方
25
+ // 可选 spec 正文(src/content/spec/friends.md),渲染在区块卡上方
26
26
  const entry = await getEntry("spec", "friends").catch(() => null);
27
27
  const { Content } = entry ? await render(entry) : { Content: null };
28
28
 
29
- const apply = config.friends.apply;
30
- ---
29
+ const friendsCfg = config.friends;
30
+ const apply = friendsCfg.apply;
31
+ const siteInfo = friendsCfg.siteInfo;
32
+ const notice = friendsCfg.notice;
33
+
34
+ // 分组 pills:由 list tags 聚合去重(保持首次出现顺序),无 tags 自动隐藏
35
+ const groupTags = [...new Set(friends.flatMap((f) => f.tags))].filter(Boolean);
36
+ const showGroups = friendsCfg.filter.groups && groupTags.length > 0;
37
+ const showSearch = friendsCfg.filter.search;
38
+
39
+ // 本站信息卡:name/desc/site/avatar 任一非空才渲染
40
+ const siteInfoRows = (
41
+ [
42
+ [t("friendSiteName"), siteInfo.name],
43
+ [t("friendSiteDesc"), siteInfo.desc],
44
+ [t("friendSiteUrl"), siteInfo.site],
45
+ [t("friendAvatarUrl"), siteInfo.avatar],
46
+ ] as [string, string][]
47
+ ).filter(([, v]) => v);
48
+ const hasSiteInfo = siteInfoRows.length > 0;
49
+ // 本站头像三格式解析(§4.97),空串跳过用字头像兜底
50
+ const siteAvatar = siteInfo.avatar
51
+ ? await resolveSiteImage(siteInfo.avatar)
52
+ : "";
53
+
54
+ // 申请模板整块复制文本:label: value 每行一条(模板块右上角一键复制)
55
+ const applyTemplateText = apply.template
56
+ .map((r) => `${r.label}: ${r.value}`)
57
+ .join("\n");
31
58
 
32
- <BaseLayout title={config.friends.title} page="friends">
33
- <header class="archive-head reveal">
34
- <h1 class="page-title">{config.friends.title}</h1>
35
- {config.friends.description && <p class="page-sub">{config.friends.description}</p>}
36
- </header>
59
+ // 申请卡:有步骤或模板时渲染富卡(与站点信息并排);否则回退旧的横向申请条
60
+ const hasApplyRich = Boolean(
61
+ apply.title && (apply.steps.length > 0 || apply.template.length > 0),
62
+ );
63
+ ---
37
64
 
65
+ <BaseLayout title={friendsCfg.title} page="friends">
38
66
  {
39
67
  Content && (
40
68
  <article class="card page-card prose reveal" data-lightbox-gallery>
@@ -43,14 +71,87 @@ const apply = config.friends.apply;
43
71
  )
44
72
  }
45
73
 
46
- {
47
- friends.length > 0 ? (
48
- <div class="friend-grid reveal">
49
- {friends.map((f, i) => (
50
- <a class="friend-card card" href={f.site} target="_blank" rel="noopener">
74
+ <section class="friend-zone card reveal" data-friend-zone>
75
+ <div class="friend-toolbar">
76
+ <div class="friend-zone-title">
77
+ <svg
78
+ xmlns="http://www.w3.org/2000/svg"
79
+ viewBox="0 0 24 24"
80
+ fill="none"
81
+ stroke="currentColor"
82
+ stroke-width="2"
83
+ stroke-linecap="round"
84
+ stroke-linejoin="round"
85
+ aria-hidden="true"
86
+ >
87
+ <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
88
+ <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
89
+ </svg>
90
+ <span>{friendsCfg.title}</span>
91
+ </div>
92
+ {
93
+ showSearch && (
94
+ <div class="friend-search">
95
+ <svg
96
+ xmlns="http://www.w3.org/2000/svg"
97
+ viewBox="0 0 24 24"
98
+ fill="none"
99
+ stroke="currentColor"
100
+ stroke-width="2"
101
+ stroke-linecap="round"
102
+ stroke-linejoin="round"
103
+ aria-hidden="true"
104
+ >
105
+ <circle cx="11" cy="11" r="8" />
106
+ <path d="m21 21-4.3-4.3" />
107
+ </svg>
108
+ <input
109
+ type="search"
110
+ placeholder={t("friendSearch")}
111
+ aria-label={t("friendSearch")}
112
+ data-friend-search
113
+ />
114
+ </div>
115
+ )
116
+ }
117
+ </div>
118
+ {friendsCfg.description && <p class="friend-zone-desc">{friendsCfg.description}</p>}
119
+ {
120
+ showGroups && (
121
+ <div class="friend-pills" role="group" aria-label={t("friendAll")}>
122
+ <button type="button" class="chip is-on" data-friend-pill="">
123
+ {t("friendAll")}
124
+ </button>
125
+ {groupTags.map((gt) => (
126
+ <button type="button" class="chip" data-friend-pill={gt}>
127
+ {gt}
128
+ </button>
129
+ ))}
130
+ </div>
131
+ )
132
+ }
133
+ <div class="friend-grid">
134
+ {
135
+ friends.map((f, i) => (
136
+ <a
137
+ class="friend-card card"
138
+ href={f.site}
139
+ target="_blank"
140
+ rel="noopener"
141
+ data-friend-card
142
+ data-search={[f.title, f.desc, ...f.tags]
143
+ .filter(Boolean)
144
+ .join(" ")
145
+ .toLowerCase()}
146
+ data-tags={JSON.stringify(f.tags)}
147
+ >
51
148
  <div class="friend-avatar">
52
149
  {f.image ? (
53
- <img src={friendImgs[i]} alt={t('avatarAlt', { name: f.title })} loading="lazy" />
150
+ <img
151
+ src={friendImgs[i]}
152
+ alt={t("avatarAlt", { name: f.title })}
153
+ loading="lazy"
154
+ />
54
155
  ) : (
55
156
  <span class="avatar-fallback">{f.title.slice(0, 1)}</span>
56
157
  )}
@@ -67,15 +168,191 @@ const apply = config.friends.apply;
67
168
  )}
68
169
  </div>
69
170
  </a>
70
- ))}
171
+ ))
172
+ }
173
+ </div>
174
+ <p class="tag-empty" data-friend-empty hidden>
175
+ {t("noFriendMatch")}
176
+ </p>
177
+ </section>
178
+
179
+ {
180
+ (hasSiteInfo || hasApplyRich) && (
181
+ <div class="friend-duo">
182
+ {hasSiteInfo && (
183
+ <section class="friend-siteinfo card reveal">
184
+ <div class="friend-card-title">
185
+ <svg
186
+ xmlns="http://www.w3.org/2000/svg"
187
+ viewBox="0 0 24 24"
188
+ fill="none"
189
+ stroke="currentColor"
190
+ stroke-width="2"
191
+ stroke-linecap="round"
192
+ stroke-linejoin="round"
193
+ aria-hidden="true"
194
+ >
195
+ <path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
196
+ <polyline points="9 22 9 12 15 12 15 22" />
197
+ </svg>
198
+ <span>{siteInfo.title}</span>
199
+ </div>
200
+ <div class="siteinfo-head">
201
+ <div
202
+ class="siteinfo-avatar"
203
+ data-siteinfo-avatar
204
+ data-avatar-name={siteInfo.name}
205
+ >
206
+ {siteAvatar ? (
207
+ <img src={siteAvatar} alt={siteInfo.name} loading="lazy" />
208
+ ) : (
209
+ <span class="avatar-fallback">
210
+ {(siteInfo.name || "?").slice(0, 1)}
211
+ </span>
212
+ )}
213
+ </div>
214
+ <div class="siteinfo-meta">
215
+ <div class="siteinfo-name">{siteInfo.name}</div>
216
+ {siteInfo.desc && <p class="siteinfo-desc">{siteInfo.desc}</p>}
217
+ </div>
218
+ </div>
219
+ {siteInfoRows.map(([label, value]) => (
220
+ <div class="kv-row">
221
+ <div class="kv-main">
222
+ <span class="kv-label">{label}</span>
223
+ <span class="kv-value">{value}</span>
224
+ </div>
225
+ <button
226
+ type="button"
227
+ class="copy-btn"
228
+ data-copy={value}
229
+ aria-label={t("copy")}
230
+ title={t("copy")}
231
+ >
232
+ <svg
233
+ class="icon-copy"
234
+ xmlns="http://www.w3.org/2000/svg"
235
+ viewBox="0 0 24 24"
236
+ fill="none"
237
+ stroke="currentColor"
238
+ stroke-width="2"
239
+ stroke-linecap="round"
240
+ stroke-linejoin="round"
241
+ aria-hidden="true"
242
+ >
243
+ <rect width="14" height="14" x="8" y="8" rx="2" />
244
+ <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
245
+ </svg>
246
+ <svg
247
+ class="icon-check"
248
+ xmlns="http://www.w3.org/2000/svg"
249
+ viewBox="0 0 24 24"
250
+ fill="none"
251
+ stroke="currentColor"
252
+ stroke-width="2"
253
+ stroke-linecap="round"
254
+ stroke-linejoin="round"
255
+ aria-hidden="true"
256
+ >
257
+ <path d="M20 6 9 17l-5-5" />
258
+ </svg>
259
+ </button>
260
+ </div>
261
+ ))}
262
+ </section>
263
+ )}
264
+ {hasApplyRich && (
265
+ <section class="friend-apply-card card reveal">
266
+ <div class="friend-card-title">
267
+ <svg
268
+ xmlns="http://www.w3.org/2000/svg"
269
+ viewBox="0 0 24 24"
270
+ fill="none"
271
+ stroke="currentColor"
272
+ stroke-width="2"
273
+ stroke-linecap="round"
274
+ stroke-linejoin="round"
275
+ aria-hidden="true"
276
+ >
277
+ <rect width="20" height="16" x="2" y="4" rx="2" />
278
+ <path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
279
+ </svg>
280
+ <span>{apply.title}</span>
281
+ </div>
282
+ {apply.description && <p class="fa-desc">{apply.description}</p>}
283
+ {apply.steps.length > 0 && (
284
+ <ol class="apply-steps">
285
+ {apply.steps.map((s, i) => (
286
+ <li class="apply-step">
287
+ <span class="apply-step-num">{i + 1}</span>
288
+ <div>
289
+ <div class="apply-step-title">{s.title}</div>
290
+ {s.description && <p class="apply-step-desc">{s.description}</p>}
291
+ </div>
292
+ </li>
293
+ ))}
294
+ </ol>
295
+ )}
296
+ {apply.template.length > 0 && (
297
+ <div class="apply-template">
298
+ <button
299
+ type="button"
300
+ class="copy-btn"
301
+ data-copy={applyTemplateText}
302
+ aria-label={t("copy")}
303
+ title={t("copy")}
304
+ >
305
+ <svg
306
+ class="icon-copy"
307
+ xmlns="http://www.w3.org/2000/svg"
308
+ viewBox="0 0 24 24"
309
+ fill="none"
310
+ stroke="currentColor"
311
+ stroke-width="2"
312
+ stroke-linecap="round"
313
+ stroke-linejoin="round"
314
+ aria-hidden="true"
315
+ >
316
+ <rect width="14" height="14" x="8" y="8" rx="2" />
317
+ <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
318
+ </svg>
319
+ <svg
320
+ class="icon-check"
321
+ xmlns="http://www.w3.org/2000/svg"
322
+ viewBox="0 0 24 24"
323
+ fill="none"
324
+ stroke="currentColor"
325
+ stroke-width="2"
326
+ stroke-linecap="round"
327
+ stroke-linejoin="round"
328
+ aria-hidden="true"
329
+ >
330
+ <path d="M20 6 9 17l-5-5" />
331
+ </svg>
332
+ </button>
333
+ <div class="apply-template-text">
334
+ {apply.template.map((row) => (
335
+ <div class="apply-template-line">
336
+ {row.label}: {row.value}
337
+ </div>
338
+ ))}
339
+ </div>
340
+ </div>
341
+ )}
342
+ {apply.link && (
343
+ <a class="btn" href={apply.link}>
344
+ {apply.label || t("applyFriend")}
345
+ </a>
346
+ )}
347
+ </section>
348
+ )}
71
349
  </div>
72
- ) : (
73
- <p class="tag-empty reveal">{t('noFriends')}</p>
74
350
  )
75
351
  }
76
352
 
77
353
  {
78
- apply.title && (
354
+ // 兼容旧配置:无步骤无模板时保留原横向申请条
355
+ apply.title && !hasApplyRich && (
79
356
  <section class="friend-apply card reveal">
80
357
  <div>
81
358
  <div class="fa-title">{apply.title}</div>
@@ -83,13 +360,116 @@ const apply = config.friends.apply;
83
360
  </div>
84
361
  {apply.link && (
85
362
  <a class="btn" href={apply.link}>
86
- {apply.label || t('applyFriend')}
363
+ {apply.label || t("applyFriend")}
87
364
  </a>
88
365
  )}
89
366
  </section>
90
367
  )
91
368
  }
92
369
 
370
+ {
371
+ notice.items.length > 0 && (
372
+ <section class="friend-notice card reveal">
373
+ <div class="friend-card-title">
374
+ <svg
375
+ xmlns="http://www.w3.org/2000/svg"
376
+ viewBox="0 0 24 24"
377
+ fill="none"
378
+ stroke="currentColor"
379
+ stroke-width="2"
380
+ stroke-linecap="round"
381
+ stroke-linejoin="round"
382
+ aria-hidden="true"
383
+ >
384
+ <path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
385
+ <path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
386
+ </svg>
387
+ <span>{notice.title}</span>
388
+ </div>
389
+ <dl class="notice-list">
390
+ {notice.items.map((item) => (
391
+ <div class="notice-item">
392
+ <dt>{item.term}</dt>
393
+ {item.description && <dd>{item.description}</dd>}
394
+ </div>
395
+ ))}
396
+ </dl>
397
+ </section>
398
+ )
399
+ }
400
+
93
401
  <!-- §4.113:友链页评论区,支持直接留言申请友链(provider 未启用时自动 no-op) -->
94
402
  <Comment path="/friends" />
95
403
  </BaseLayout>
404
+
405
+ <script>
406
+ // 友链区块卡:搜索 + 分组 pills 叠加过滤
407
+ const zone = document.querySelector<HTMLElement>("[data-friend-zone]");
408
+ if (zone) {
409
+ const search = zone.querySelector<HTMLInputElement>("[data-friend-search]");
410
+ const pills = Array.from(
411
+ zone.querySelectorAll<HTMLButtonElement>("[data-friend-pill]"),
412
+ );
413
+ const cards = Array.from(
414
+ zone.querySelectorAll<HTMLElement>("[data-friend-card]"),
415
+ );
416
+ const empty = zone.querySelector<HTMLElement>("[data-friend-empty]");
417
+ let keyword = "";
418
+ let group = "";
419
+ const applyFilter = () => {
420
+ let visible = 0;
421
+ for (const card of cards) {
422
+ const okKeyword =
423
+ !keyword || (card.dataset.search || "").includes(keyword);
424
+ let tags: string[] = [];
425
+ try {
426
+ tags = JSON.parse(card.dataset.tags || "[]") as string[];
427
+ } catch {
428
+ /* data-tags 解析失败时按无分组处理 */
429
+ }
430
+ const okGroup = !group || tags.includes(group);
431
+ const show = okKeyword && okGroup;
432
+ card.hidden = !show;
433
+ if (show) visible++;
434
+ }
435
+ if (empty) empty.hidden = visible > 0;
436
+ };
437
+ search?.addEventListener("input", () => {
438
+ keyword = (search.value || "").trim().toLowerCase();
439
+ applyFilter();
440
+ });
441
+ for (const pill of pills) {
442
+ pill.addEventListener("click", () => {
443
+ group = pill.dataset.friendPill || "";
444
+ for (const p of pills) p.classList.toggle("is-on", p === pill);
445
+ applyFilter();
446
+ });
447
+ }
448
+ }
449
+
450
+ // 复制按钮:点击复制 data-copy 内容,短暂切换成功态
451
+ for (const btn of document.querySelectorAll<HTMLButtonElement>("[data-copy]")) {
452
+ btn.addEventListener("click", async () => {
453
+ const value = btn.dataset.copy || "";
454
+ try {
455
+ await navigator.clipboard.writeText(value);
456
+ } catch {
457
+ /* 剪贴板不可用时静默降级(按钮仍给出成功反馈) */
458
+ }
459
+ btn.classList.add("is-copied");
460
+ setTimeout(() => btn.classList.remove("is-copied"), 1500);
461
+ });
462
+ }
463
+
464
+ // 本站头像加载失败时回退字头像(外链头像 404 / 防盗链兜底)
465
+ const avatarBox = document.querySelector<HTMLElement>("[data-siteinfo-avatar]");
466
+ const avatarImg = avatarBox?.querySelector("img");
467
+ if (avatarBox && avatarImg) {
468
+ avatarImg.addEventListener("error", () => {
469
+ const fb = document.createElement("span");
470
+ fb.className = "avatar-fallback";
471
+ fb.textContent = (avatarBox.dataset.avatarName || "?").slice(0, 1);
472
+ avatarImg.replaceWith(fb);
473
+ });
474
+ }
475
+ </script>
package/src/schema.ts CHANGED
@@ -706,6 +706,29 @@ export const themeConfigSchema = z.object({
706
706
  description: z.string().default(""),
707
707
  /** 页面级排序:weight 权重降序 / random 随机(构建期) */
708
708
  sort: z.enum(["weight", "random"]).default("weight"),
709
+ /** 友链区块卡工具条:搜索框与分组 pills 开关(分组由 list tags 聚合,无 tags 自动隐藏) */
710
+ filter: z
711
+ .object({
712
+ search: z.boolean().default(true),
713
+ groups: z.boolean().default(true),
714
+ })
715
+ .default({ search: true, groups: true }),
716
+ /** 本站信息卡(name/desc/site/avatar 任一非空才渲染整卡) */
717
+ siteInfo: z
718
+ .object({
719
+ title: z.string().default("本站信息"),
720
+ name: z.string().default(""),
721
+ desc: z.string().default(""),
722
+ site: z.string().default(""),
723
+ avatar: z.string().default(""),
724
+ })
725
+ .default({
726
+ title: "本站信息",
727
+ name: "",
728
+ desc: "",
729
+ site: "",
730
+ avatar: "",
731
+ }),
709
732
  /** 申请友链卡片(title 留空则整卡不渲染) */
710
733
  apply: z
711
734
  .object({
@@ -714,8 +737,47 @@ export const themeConfigSchema = z.object({
714
737
  /** 申请入口链接(mailto 或表单页) */
715
738
  link: z.string().default(""),
716
739
  label: z.string().default("申请友链"),
740
+ /** 申请步骤(空数组不渲染) */
741
+ steps: z
742
+ .array(
743
+ z.object({
744
+ title: z.string(),
745
+ description: z.string().default(""),
746
+ }),
747
+ )
748
+ .default([]),
749
+ /** 可复制申请资料模板行(空数组不渲染) */
750
+ template: z
751
+ .array(
752
+ z.object({
753
+ label: z.string(),
754
+ value: z.string(),
755
+ }),
756
+ )
757
+ .default([]),
717
758
  })
718
- .default({ title: "", description: "", link: "", label: "申请友链" }),
759
+ .default({
760
+ title: "",
761
+ description: "",
762
+ link: "",
763
+ label: "申请友链",
764
+ steps: [],
765
+ template: [],
766
+ }),
767
+ /** 注意事项卡(items 为空不渲染) */
768
+ notice: z
769
+ .object({
770
+ title: z.string().default("注意事项"),
771
+ items: z
772
+ .array(
773
+ z.object({
774
+ term: z.string(),
775
+ description: z.string().default(""),
776
+ }),
777
+ )
778
+ .default([]),
779
+ })
780
+ .default({ title: "注意事项", items: [] }),
719
781
  list: z
720
782
  .array(
721
783
  z.object({
@@ -738,7 +800,17 @@ export const themeConfigSchema = z.object({
738
800
  title: "友链",
739
801
  description: "",
740
802
  sort: "weight",
741
- apply: { title: "", description: "", link: "", label: "申请友链" },
803
+ filter: { search: true, groups: true },
804
+ siteInfo: { title: "本站信息", name: "", desc: "", site: "", avatar: "" },
805
+ apply: {
806
+ title: "",
807
+ description: "",
808
+ link: "",
809
+ label: "申请友链",
810
+ steps: [],
811
+ template: [],
812
+ },
813
+ notice: { title: "注意事项", items: [] },
742
814
  list: [],
743
815
  }),
744
816
  /**
@@ -3373,7 +3373,9 @@ html.dark .outdated-warning {
3373
3373
  border-radius: var(--radius-sm);
3374
3374
  font-size: 14px;
3375
3375
  color: var(--ink-2);
3376
- transition: background 0.15s ease, color 0.15s ease;
3376
+ transition:
3377
+ background 0.15s ease,
3378
+ color 0.15s ease;
3377
3379
  }
3378
3380
  .post-resources-item a:hover {
3379
3381
  background: var(--bg-soft);
@@ -4614,6 +4616,320 @@ html.dark .friend-card:hover .friend-name {
4614
4616
  padding: 1px 9px;
4615
4617
  }
4616
4618
 
4619
+ /* ---------- 10.3 友链区块卡(标题 + 搜索 + 分组 + 网格) ---------- */
4620
+ .friend-zone {
4621
+ margin-top: 26px;
4622
+ padding: 22px 24px;
4623
+ }
4624
+ .friend-toolbar {
4625
+ display: flex;
4626
+ align-items: center;
4627
+ gap: 12px;
4628
+ flex-wrap: wrap;
4629
+ }
4630
+ .friend-zone-title {
4631
+ display: flex;
4632
+ align-items: center;
4633
+ gap: 10px;
4634
+ font-family: var(--font-display);
4635
+ font-weight: 700;
4636
+ font-size: 17px;
4637
+ }
4638
+ .friend-zone-title svg {
4639
+ flex: none;
4640
+ width: 20px;
4641
+ height: 20px;
4642
+ color: var(--accent);
4643
+ }
4644
+ .friend-zone-desc {
4645
+ margin-top: 6px;
4646
+ font-size: 14px;
4647
+ color: var(--muted);
4648
+ line-height: 1.7;
4649
+ }
4650
+ .friend-search {
4651
+ margin-left: auto;
4652
+ display: flex;
4653
+ align-items: center;
4654
+ gap: 8px;
4655
+ width: min(230px, 100%);
4656
+ padding: 7px 12px;
4657
+ border: 1px solid var(--border);
4658
+ border-radius: 999px;
4659
+ background: var(--bg-soft);
4660
+ transition: border-color 0.15s;
4661
+ }
4662
+ .friend-search:focus-within {
4663
+ border-color: var(--accent);
4664
+ }
4665
+ .friend-search svg {
4666
+ flex: none;
4667
+ width: 15px;
4668
+ height: 15px;
4669
+ color: var(--muted);
4670
+ }
4671
+ .friend-search input {
4672
+ flex: 1;
4673
+ min-width: 0;
4674
+ border: none;
4675
+ background: transparent;
4676
+ outline: none;
4677
+ font-size: 13.5px;
4678
+ color: var(--ink);
4679
+ }
4680
+ .friend-search input::placeholder {
4681
+ color: var(--muted);
4682
+ }
4683
+ .friend-pills {
4684
+ margin-top: 14px;
4685
+ display: flex;
4686
+ flex-wrap: wrap;
4687
+ gap: 8px;
4688
+ }
4689
+ .friend-pills .chip {
4690
+ cursor: pointer;
4691
+ font-family: inherit;
4692
+ }
4693
+ .friend-zone .friend-grid {
4694
+ margin-top: 18px;
4695
+ }
4696
+ /* .friend-card 的 display:flex 会覆盖 UA 的 [hidden],需显式收回 */
4697
+ .friend-card[hidden],
4698
+ .tag-empty[hidden] {
4699
+ display: none;
4700
+ }
4701
+ .friend-zone .tag-empty {
4702
+ margin-top: 18px;
4703
+ }
4704
+
4705
+ /* ---------- 10.4 本站信息 / 申请友链 双卡与注意事项 ---------- */
4706
+ .friend-duo {
4707
+ margin-top: 20px;
4708
+ display: grid;
4709
+ grid-template-columns: repeat(2, minmax(0, 1fr));
4710
+ gap: 20px;
4711
+ }
4712
+ .friend-duo > .card:only-child {
4713
+ grid-column: 1 / -1;
4714
+ }
4715
+ @media (max-width: 767px) {
4716
+ .friend-duo {
4717
+ grid-template-columns: 1fr;
4718
+ }
4719
+ }
4720
+ .friend-siteinfo,
4721
+ .friend-apply-card {
4722
+ display: flex;
4723
+ flex-direction: column;
4724
+ }
4725
+ .friend-siteinfo,
4726
+ .friend-apply-card,
4727
+ .friend-notice {
4728
+ padding: 22px 24px;
4729
+ }
4730
+ .friend-card-title {
4731
+ display: flex;
4732
+ align-items: center;
4733
+ gap: 10px;
4734
+ font-family: var(--font-display);
4735
+ font-weight: 700;
4736
+ font-size: 17px;
4737
+ }
4738
+ .friend-card-title svg {
4739
+ flex: none;
4740
+ width: 18px;
4741
+ height: 18px;
4742
+ color: var(--accent);
4743
+ }
4744
+ /* 本站信息卡头:头像 + 名称 + 描述(对齐参考布局) */
4745
+ .siteinfo-head {
4746
+ display: flex;
4747
+ align-items: center;
4748
+ gap: 12px;
4749
+ margin-top: 16px;
4750
+ }
4751
+ .siteinfo-avatar {
4752
+ flex: none;
4753
+ display: flex;
4754
+ align-items: center;
4755
+ justify-content: center;
4756
+ width: 48px;
4757
+ height: 48px;
4758
+ border-radius: 50%;
4759
+ overflow: hidden;
4760
+ border: 1px solid var(--border);
4761
+ background: var(--bg-soft);
4762
+ }
4763
+ .siteinfo-avatar img {
4764
+ width: 100%;
4765
+ height: 100%;
4766
+ object-fit: cover;
4767
+ }
4768
+ .siteinfo-meta {
4769
+ min-width: 0;
4770
+ }
4771
+ .siteinfo-name {
4772
+ font-family: var(--font-display);
4773
+ font-weight: 700;
4774
+ font-size: 15px;
4775
+ }
4776
+ .siteinfo-desc {
4777
+ margin-top: 2px;
4778
+ font-size: 13px;
4779
+ color: var(--muted);
4780
+ line-height: 1.5;
4781
+ }
4782
+ .kv-row {
4783
+ display: flex;
4784
+ align-items: center;
4785
+ gap: 10px;
4786
+ margin-top: 10px;
4787
+ padding: 10px 12px;
4788
+ background: var(--bg-soft);
4789
+ border: 1px solid var(--border);
4790
+ border-radius: 8px;
4791
+ }
4792
+ .kv-main {
4793
+ flex: 1;
4794
+ min-width: 0;
4795
+ display: flex;
4796
+ flex-direction: column;
4797
+ gap: 2px;
4798
+ }
4799
+ .kv-label {
4800
+ font-size: 12px;
4801
+ color: var(--muted);
4802
+ }
4803
+ .kv-value {
4804
+ font-size: 13.5px;
4805
+ color: var(--ink);
4806
+ line-height: 1.5;
4807
+ word-break: break-all;
4808
+ }
4809
+ .copy-btn {
4810
+ flex: none;
4811
+ display: inline-flex;
4812
+ align-items: center;
4813
+ justify-content: center;
4814
+ width: 26px;
4815
+ height: 26px;
4816
+ border: 1px solid var(--border);
4817
+ border-radius: 6px;
4818
+ background: transparent;
4819
+ color: var(--muted);
4820
+ cursor: pointer;
4821
+ transition:
4822
+ color 0.15s,
4823
+ border-color 0.15s;
4824
+ }
4825
+ .copy-btn:hover {
4826
+ color: var(--accent-ink);
4827
+ border-color: var(--accent);
4828
+ }
4829
+ .copy-btn svg {
4830
+ width: 14px;
4831
+ height: 14px;
4832
+ }
4833
+ .copy-btn .icon-check {
4834
+ display: none;
4835
+ }
4836
+ .copy-btn.is-copied {
4837
+ color: var(--accent-ink);
4838
+ border-color: var(--accent);
4839
+ }
4840
+ .copy-btn.is-copied .icon-copy {
4841
+ display: none;
4842
+ }
4843
+ .copy-btn.is-copied .icon-check {
4844
+ display: block;
4845
+ }
4846
+ .friend-apply-card .fa-desc {
4847
+ margin-top: 6px;
4848
+ font-size: 14px;
4849
+ color: var(--muted);
4850
+ line-height: 1.7;
4851
+ }
4852
+ .apply-steps {
4853
+ list-style: none;
4854
+ margin: 16px 0 0;
4855
+ padding: 0;
4856
+ display: grid;
4857
+ gap: 12px;
4858
+ }
4859
+ .apply-step {
4860
+ display: flex;
4861
+ align-items: flex-start;
4862
+ gap: 12px;
4863
+ }
4864
+ .apply-step-num {
4865
+ flex: none;
4866
+ display: inline-flex;
4867
+ align-items: center;
4868
+ justify-content: center;
4869
+ width: 22px;
4870
+ height: 22px;
4871
+ border-radius: 50%;
4872
+ background: var(--accent-soft);
4873
+ color: var(--accent-ink);
4874
+ font-size: 12px;
4875
+ font-weight: 700;
4876
+ margin-top: 1px;
4877
+ }
4878
+ .apply-step-title {
4879
+ font-weight: 600;
4880
+ font-size: 14.5px;
4881
+ }
4882
+ .apply-step-desc {
4883
+ margin-top: 2px;
4884
+ font-size: 13px;
4885
+ color: var(--muted);
4886
+ line-height: 1.6;
4887
+ }
4888
+ /* 申请资料模板:整块展示 + 右上角一键复制(对齐参考布局) */
4889
+ .apply-template {
4890
+ position: relative;
4891
+ margin-top: 14px;
4892
+ padding: 12px 46px 12px 16px;
4893
+ background: var(--bg-soft);
4894
+ border: 1px solid var(--border);
4895
+ border-radius: 8px;
4896
+ }
4897
+ .apply-template .copy-btn {
4898
+ position: absolute;
4899
+ top: 8px;
4900
+ right: 8px;
4901
+ }
4902
+ .apply-template-line {
4903
+ font-size: 13px;
4904
+ color: var(--ink);
4905
+ line-height: 1.9;
4906
+ word-break: break-all;
4907
+ }
4908
+ .friend-apply-card .btn {
4909
+ margin-top: 16px;
4910
+ width: 100%;
4911
+ justify-content: center;
4912
+ }
4913
+ .friend-notice {
4914
+ margin-top: 20px;
4915
+ }
4916
+ .notice-list {
4917
+ margin: 14px 0 0;
4918
+ padding: 0;
4919
+ display: grid;
4920
+ gap: 12px;
4921
+ }
4922
+ .notice-item dt {
4923
+ font-weight: 700;
4924
+ font-size: 14px;
4925
+ }
4926
+ .notice-item dd {
4927
+ margin: 2px 0 0;
4928
+ font-size: 13.5px;
4929
+ color: var(--muted);
4930
+ line-height: 1.7;
4931
+ }
4932
+
4617
4933
  /* ---------- 10.1 字头像兜底(友链/打赏名单无图时) ---------- */
4618
4934
  .avatar-fallback {
4619
4935
  display: inline-flex;