@windcoder/astro-theme-orchid 0.2.0 → 0.3.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.
@@ -0,0 +1,127 @@
1
+ import type { APIRoute } from "astro";
2
+
3
+ /**
4
+ * PWA Service Worker(§4.95):构建期生成 /sw.js 文本,手写、零客户端运行时依赖。
5
+ * - 缓存名带构建号(DEV 固定 "dev"):新构建上线后旧缓存整体作废,activate 全清;
6
+ * - 三条策略:① 导航请求 network-first,成功回填缓存,离线回落自身缓存、再兜底
7
+ * 预缓存的首页;② /_astro/ 产物带内容哈希,cache-first;③ 其余同源 GET 走
8
+ * stale-while-revalidate,缓存桶超 60 条删最早一条防膨胀;
9
+ * - 非 GET、跨域、/api/(随机数据端点)、/og/(分享图 PNG 较大)一律直连不缓存;
10
+ * - install 预缓存首页作离线兜底(不另造 offline 页),skipWaiting 即刻接管;
11
+ * activate 清全部旧缓存并 clients.claim()。
12
+ * SW 文本内不使用模板字符串(避免与本文件 TS 模板嵌套转义),常量经 JSON.stringify 注入。
13
+ */
14
+ export const GET: APIRoute = ({ site }) => {
15
+ const base = new URL("/", site ?? new URL("https://blog.example.com"))
16
+ .pathname;
17
+ const cacheName =
18
+ "orchid-pwa-" + (import.meta.env.DEV ? "dev" : Date.now().toString(36));
19
+ const sw = `// 由 astro-theme-orchid 构建期生成(§4.95),请勿手改
20
+ const CACHE = ${JSON.stringify(cacheName)};
21
+ const BASE = ${JSON.stringify(base)};
22
+ const SCOPE_MAX = 60;
23
+
24
+ self.addEventListener("install", function (event) {
25
+ event.waitUntil(
26
+ caches
27
+ .open(CACHE)
28
+ .then(function (cache) {
29
+ return cache.add(new Request(BASE, { cache: "reload" }));
30
+ })
31
+ .then(function () {
32
+ return self.skipWaiting();
33
+ })
34
+ );
35
+ });
36
+
37
+ self.addEventListener("activate", function (event) {
38
+ event.waitUntil(
39
+ caches
40
+ .keys()
41
+ .then(function (keys) {
42
+ return Promise.all(
43
+ keys
44
+ .filter(function (key) {
45
+ return key !== CACHE;
46
+ })
47
+ .map(function (key) {
48
+ return caches.delete(key);
49
+ })
50
+ );
51
+ })
52
+ .then(function () {
53
+ return self.clients.claim();
54
+ })
55
+ );
56
+ });
57
+
58
+ self.addEventListener("fetch", function (event) {
59
+ var req = event.request;
60
+ if (req.method !== "GET") return;
61
+ var url = new URL(req.url);
62
+ if (url.origin !== self.location.origin) return;
63
+ var path = url.pathname;
64
+ if (path.indexOf(BASE + "api/") === 0 || path.indexOf(BASE + "og/") === 0) return;
65
+
66
+ if (req.mode === "navigate") {
67
+ event.respondWith(
68
+ fetch(req)
69
+ .then(function (res) {
70
+ var copy = res.clone();
71
+ caches.open(CACHE).then(function (cache) {
72
+ cache.put(req, copy);
73
+ });
74
+ return res;
75
+ })
76
+ .catch(function () {
77
+ return caches.match(req).then(function (hit) {
78
+ return hit || caches.match(BASE);
79
+ });
80
+ })
81
+ );
82
+ return;
83
+ }
84
+
85
+ if (path.indexOf(BASE + "_astro/") === 0) {
86
+ event.respondWith(
87
+ caches.match(req).then(function (hit) {
88
+ return hit || fetch(req);
89
+ })
90
+ );
91
+ return;
92
+ }
93
+
94
+ event.respondWith(
95
+ caches.match(req).then(function (hit) {
96
+ var network = fetch(req)
97
+ .then(function (res) {
98
+ if (res && res.ok) {
99
+ var copy = res.clone();
100
+ caches.open(CACHE).then(function (cache) {
101
+ cache.put(req, copy).then(function () {
102
+ caches.open(CACHE).then(function (bucket) {
103
+ bucket.keys().then(function (keys) {
104
+ if (keys.length > SCOPE_MAX) bucket.delete(keys[0]);
105
+ });
106
+ });
107
+ });
108
+ });
109
+ }
110
+ return res;
111
+ })
112
+ .catch(function () {
113
+ return hit;
114
+ });
115
+ if (hit) {
116
+ event.waitUntil(network);
117
+ return hit;
118
+ }
119
+ return network;
120
+ })
121
+ );
122
+ });
123
+ `;
124
+ return new Response(sw, {
125
+ headers: { "Content-Type": "text/javascript; charset=utf-8" },
126
+ });
127
+ };
@@ -0,0 +1,51 @@
1
+ ---
2
+ import { getCollection } from "astro:content";
3
+ import BaseLayout from "virtual:theme/components/BaseLayout.astro";
4
+ import Pagination from "virtual:theme/components/Pagination.astro";
5
+ import config from "virtual:theme/config";
6
+ import { t } from "../../../i18n/index.js";
7
+ import { collectTags, formatDate, getSortedPosts } from "../../../utils.js";
8
+
9
+ /**
10
+ * 标签详情(§4.87 分页):每标签按 postList.pageSize 分页,第一页
11
+ * /tags/<tag>/(page 段省略),后续 /tags/<tag>/2/ 起。结构与分类详情页一致。
12
+ */
13
+ export async function getStaticPaths({ paginate }: { paginate: any }) {
14
+ const all = await getCollection("posts", ({ data }) =>
15
+ import.meta.env.PROD ? !data.draft : true,
16
+ );
17
+ // 先排序再聚合,保证每个标签内文章按新→旧排列
18
+ const sorted = getSortedPosts(all);
19
+ return [...collectTags(sorted).entries()].flatMap(([tag, posts]) =>
20
+ paginate(posts, {
21
+ params: { tag },
22
+ props: { tag },
23
+ pageSize: config.postList.pageSize,
24
+ }),
25
+ );
26
+ }
27
+
28
+ const { page, tag } = Astro.props;
29
+ const posts = page.data;
30
+ ---
31
+
32
+ <BaseLayout title={t('tagTitle', { tag })} page="tags">
33
+ <header class="archive-head reveal">
34
+ <h1 class="page-title">{tag}</h1>
35
+ <p class="page-sub">{t('totalPosts', { n: page.total })}</p>
36
+ </header>
37
+ <div class="tag-list">
38
+ {
39
+ posts.map((post: any) => (
40
+ <a class="related-card card tag-item reveal" href={`/posts/${post.id}/`}>
41
+ <div class="rc-title">{post.data.title}</div>
42
+ <div class="rc-meta">
43
+ {formatDate(post.data.published)}
44
+ {post.data.category ? ` · ${post.data.category}` : ''}
45
+ </div>
46
+ </a>
47
+ ))
48
+ }
49
+ </div>
50
+ <Pagination page={page.currentPage} total={page.lastPage} basePath={`/tags/${tag}/`} />
51
+ </BaseLayout>
package/src/schema.ts CHANGED
@@ -108,6 +108,91 @@ export const themeConfigSchema = z.object({
108
108
  quote: "",
109
109
  sub: "",
110
110
  }),
111
+ /**
112
+ * 全站背景壁纸(§4.92):body 级 fixed 视口背景,与顶部 banner(§4.42)相互独立。
113
+ * 经遮罩压暗后从页缝与卡片间隙透出;正文卡片保持不透明保证可读。
114
+ */
115
+ background: z
116
+ .object({
117
+ /** 总开关:false 时即使配了 src 也不渲染(默认关) */
118
+ enabled: z.boolean().default(false),
119
+ /** 图源(public 路径或外链):单图 URL 或多图数组(构建期随机取一) */
120
+ src: z.union([z.string(), z.array(z.string())]).optional(),
121
+ /** 浅色模式遮罩不透明度(0-1),压暗壁纸保证正文可读 */
122
+ dimOpacity: z.number().min(0).max(1).default(0.45),
123
+ /** 暗色模式遮罩不透明度(0-1),暗色下压更暗 */
124
+ darkDimOpacity: z.number().min(0).max(1).default(0.65),
125
+ /** 壁纸模糊半径 px(0-20):模糊时自动轻微放大防止边缘露底 */
126
+ blur: z.number().min(0).max(20).default(0),
127
+ })
128
+ .default({
129
+ enabled: false,
130
+ dimOpacity: 0.45,
131
+ darkDimOpacity: 0.65,
132
+ blur: 0,
133
+ }),
134
+ /**
135
+ * 番组追番页(§4.93):构建期从 bgm.tv v0 API 拉取用户「在看」动画收藏,
136
+ * 渲染静态 /bangumi 页。enabled 与 userId 同时满足才注入路由(默认关)。
137
+ */
138
+ bangumi: z
139
+ .object({
140
+ /** 总开关:与 userId 同时非空才生成 /bangumi 路由(默认关) */
141
+ enabled: z.boolean().default(false),
142
+ /** bgm.tv 用户名(https://bgm.tv/user/{username} 中的 username) */
143
+ userId: z.string().default(""),
144
+ /** 页面标题 */
145
+ title: z.string().default("追番"),
146
+ /** 页面副标题(留空不渲染) */
147
+ description: z.string().default(""),
148
+ })
149
+ .default({ enabled: false, userId: "", title: "追番", description: "" }),
150
+ /**
151
+ * PWA(§4.95):构建期产出 /manifest.webmanifest 与 /sw.js(手写 Service
152
+ * Worker,零运行时依赖),可安装 + 离线兜底。enabled=false 时两路由均不
153
+ * 注入,head 无 manifest 链接与注册脚本,零痕迹。
154
+ */
155
+ pwa: z
156
+ .object({
157
+ /** 总开关:false 时不生成 manifest 与 Service Worker(默认关) */
158
+ enabled: z.boolean().default(false),
159
+ /** 安装后的应用名:留空回落站点 title */
160
+ name: z.string().default(""),
161
+ /** 安装后启动器短名:留空回落 name */
162
+ shortName: z.string().default(""),
163
+ /** 应用描述:留空回落站点 description */
164
+ description: z.string().default(""),
165
+ /** 清单 theme_color:安装 UI / 标题栏着色(独立于页面 theme-color meta) */
166
+ themeColor: z.string().default("#f6f4ef"),
167
+ /** 清单 background_color:启动画面底色 */
168
+ backgroundColor: z.string().default("#f6f4ef"),
169
+ /** 显示形态:standalone 独立应用窗口(默认)/ fullscreen / minimal-ui / browser */
170
+ display: z
171
+ .enum(["standalone", "fullscreen", "minimal-ui", "browser"])
172
+ .default("standalone"),
173
+ /** 图标清单(public 下路径):至少 192x192 + 512x512 各一张才满足可安装性 */
174
+ icons: z
175
+ .array(
176
+ z.object({
177
+ src: z.string(),
178
+ sizes: z.string().default("512x512"),
179
+ type: z.string().default("image/png"),
180
+ /** maskable:安装后按平台形状裁切的容错图标,可与 any 各配一张 */
181
+ purpose: z.enum(["any", "maskable"]).default("any"),
182
+ }),
183
+ )
184
+ .default([]),
185
+ })
186
+ .default({
187
+ enabled: false,
188
+ name: "",
189
+ shortName: "",
190
+ description: "",
191
+ themeColor: "#f6f4ef",
192
+ backgroundColor: "#f6f4ef",
193
+ display: "standalone",
194
+ icons: [],
195
+ }),
111
196
  /**
112
197
  * hue 调色面板(M3b-b5):导航栏调色按钮 + 预设色板 + hue 滑杆。
113
198
  * 运行时覆写 --accent-h(s/l 保持配置值),持久化 localStorage('qy-hue')。
@@ -289,6 +374,16 @@ export const themeConfigSchema = z.object({
289
374
  * 切换后以 localStorage('qy-mode') 为准。
290
375
  */
291
376
  colorMode: z.enum(["light", "dark", "system"]).default("system"),
377
+ /**
378
+ * 暗色模式适配(§4.89):adaptExternalImages 开启后,暗色下对外链内容图
379
+ * (data-external 标记,白底截图/图表易刺眼)施加亮度滤镜压暗;
380
+ * 本地图与 .no-adapt 单图豁免。默认关闭保持中性。
381
+ */
382
+ darkMode: z
383
+ .object({
384
+ adaptExternalImages: z.boolean().default(false),
385
+ })
386
+ .default({ adaptExternalImages: false }),
292
387
  /**
293
388
  * 代码高亮(§4.61):Expressive Code(Astro 生态标准方案)接管代码块渲染,
294
389
  * 替代 §4.17 自研 shiki 管线与 §4.37 自研折叠。light/dark 为 EC 支持的 Shiki
@@ -342,6 +437,8 @@ export const themeConfigSchema = z.object({
342
437
  gallery: z.boolean().default(true),
343
438
  /** 专题(§4.31):frontmatter series 聚合页 /series/ 与 /series/[name]/ */
344
439
  series: z.boolean().default(true),
440
+ /** 说说页(§4.87):/moments/ 短内容时间流;站点需定义 moments collection。默认关——老站点无该集合,开启前先在 content.config.ts 定义 */
441
+ moments: z.boolean().default(false),
345
442
  })
346
443
  .default({
347
444
  about: true,
@@ -350,6 +447,7 @@ export const themeConfigSchema = z.object({
350
447
  sponsor: true,
351
448
  gallery: true,
352
449
  series: true,
450
+ moments: false,
353
451
  }),
354
452
  /**
355
453
  * 画廊/相册(M3b-b8)。相册数据纯配置驱动,照片两个来源:
@@ -757,7 +855,7 @@ export const themeConfigSchema = z.object({
757
855
  displayCount: -1,
758
856
  }),
759
857
  /**
760
- * 站点统计(P1-16):GA / Clarity / Umami / 51la 四选多。
858
+ * 站点统计(P1-16):GA / Clarity / Umami / 51la 四选多,另配 Web Vitals 通用上报。
761
859
  * 对应 ID 留空即不注入该统计脚本,全部留空零副作用。
762
860
  */
763
861
  analytics: z
@@ -829,6 +927,19 @@ export const themeConfigSchema = z.object({
829
927
  hashMode: false,
830
928
  screenRecord: true,
831
929
  }),
930
+ /** Web Vitals 通用上报(§4.91):与统计厂商无关,POST JSON 到自建端点 */
931
+ webVitals: z
932
+ .object({
933
+ /** 上报端点(载荷以 text/plain 载 JSON 发送,免 CORS 预检;留空不采集) */
934
+ endpoint: z.string().default(""),
935
+ /** 站点标识,随载荷一起上报(多站共用一个端点时区分来源) */
936
+ siteId: z.string().default(""),
937
+ /** 采样率 0~1,默认全量 */
938
+ sampleRate: z.number().min(0).max(1).default(1),
939
+ /** 控制台打印上报载荷(调试用) */
940
+ debug: z.boolean().default(false),
941
+ })
942
+ .default({ endpoint: "", siteId: "", sampleRate: 1, debug: false }),
832
943
  })
833
944
  .default({
834
945
  googleAnalyticsId: "",
@@ -855,6 +966,7 @@ export const themeConfigSchema = z.object({
855
966
  hashMode: false,
856
967
  screenRecord: true,
857
968
  },
969
+ webVitals: { endpoint: "", siteId: "", sampleRate: 1, debug: false },
858
970
  }),
859
971
  /* ---------- LaTeX 数学公式(M3b-b4) ---------- */
860
972
  latex: z
@@ -915,6 +1027,26 @@ export const themeConfigSchema = z.object({
915
1027
  method: z.enum(["base64", "rot13"]).default("base64"),
916
1028
  })
917
1029
  .default({ enabled: false, method: "base64" }),
1030
+ /**
1031
+ * 图片防盗链(§4.87):部分图床/CDN(如 B 站)校验 Referer,站内引用返回 403。
1032
+ * 命中域名列表的外链图片构建期追加 referrerpolicy="no-referrer"。
1033
+ * 裸域名匹配自身与全部子域;*.example.com 只匹配子域。留空不处理。
1034
+ */
1035
+ noReferrer: z
1036
+ .object({
1037
+ domains: z.array(z.string()).default([]),
1038
+ })
1039
+ .default({ domains: [] }),
1040
+ /**
1041
+ * RSS 输出(§4.87):fullText 开启后 item 带 content:encoded 全文
1042
+ * (构建期逐篇容器渲染,构建时间相应增加);limit 控制输出条数。
1043
+ */
1044
+ rss: z
1045
+ .object({
1046
+ fullText: z.boolean().default(false),
1047
+ limit: z.number().int().min(1).default(20),
1048
+ })
1049
+ .default({ fullText: false, limit: 20 }),
918
1050
  /**
919
1051
  * favicon 图标列表(§4.30):数组非空时替换默认 /favicon.svg;
920
1052
  * theme 指定 "light"/"dark" 时按系统深浅色经 media 切换(可配双图标)。
@@ -1216,3 +1348,24 @@ export function definePostSchema(extensions: z.ZodRawShape = {}) {
1216
1348
  })
1217
1349
  .extend(extensions);
1218
1350
  }
1351
+
1352
+ /**
1353
+ * 内容 schema 工厂:说说集合 frontmatter(§4.87 说说页)。
1354
+ * 站点 content.config.ts 定义 moments collection(loader 扫描
1355
+ * src/content/moments 目录下全部 .md 文件,schema 用本工厂)。
1356
+ * 正文即说说内容(Markdown,支持主题全部增强语法);主题开关 pages.moments
1357
+ * 打开后注入 /moments/(时间流,按 published 新→旧,每页 20 条)。
1358
+ */
1359
+ export function defineMomentSchema(extensions: z.ZodRawShape = {}) {
1360
+ return z
1361
+ .object({
1362
+ published: z.coerce.date(),
1363
+ /** 心情 emoji(卡片右上角展示) */
1364
+ mood: z.string().optional(),
1365
+ tags: z.array(z.string()).default([]),
1366
+ /** 配图(public 路径或外链,网格展示 + 灯箱浏览) */
1367
+ images: z.array(z.string()).default([]),
1368
+ draft: z.boolean().default(false),
1369
+ })
1370
+ .extend(extensions);
1371
+ }
@@ -699,6 +699,112 @@ html.dark #modeToggle .i-sun {
699
699
  text-shadow: 0 1px 10px rgba(0, 0, 0, 0.4);
700
700
  }
701
701
 
702
+ /* ---------- 3.5b 全站背景壁纸(§4.92):fixed 视口背景,与顶部 banner(§4.42)独立 ---------- */
703
+ /* body 背景传播为画布底色:负 z-index 定位子元素画在其上、内容之下——壁纸加载中仍有底色兜底,
704
+ body 自身的径向渐变装饰同样在画布层,被壁纸自然覆盖 */
705
+ .site-wallpaper {
706
+ position: fixed;
707
+ inset: 0;
708
+ z-index: -1;
709
+ overflow: hidden;
710
+ pointer-events: none;
711
+ }
712
+ .site-wallpaper img {
713
+ width: 100%;
714
+ height: 100%;
715
+ object-fit: cover;
716
+ filter: blur(var(--wp-blur, 0px));
717
+ /* 模糊边缘泛出透明底:按模糊量轻微放大补偿(服务端算好 --wp-scale) */
718
+ transform: scale(var(--wp-scale, 1));
719
+ }
720
+ /* 遮罩层:浅/暗双档不透明度(--wp-dim / --wp-dim-dark),保证任意壁纸下正文可读 */
721
+ .site-wallpaper-dim {
722
+ position: absolute;
723
+ inset: 0;
724
+ background: rgb(0 0 0 / var(--wp-dim, 0.45));
725
+ }
726
+ html.dark .site-wallpaper-dim {
727
+ background: rgb(0 0 0 / var(--wp-dim-dark, 0.65));
728
+ }
729
+
730
+ /* ---------- 3.5c 番组追番页(§4.93) ---------- */
731
+ .bangumi-grid {
732
+ display: grid;
733
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
734
+ gap: 20px;
735
+ }
736
+ .bangumi-card {
737
+ display: flex;
738
+ flex-direction: column;
739
+ padding: 0;
740
+ overflow: hidden;
741
+ color: var(--ink);
742
+ transition:
743
+ box-shadow 0.2s,
744
+ transform 0.2s,
745
+ border-color 0.2s;
746
+ }
747
+ .bangumi-card:hover {
748
+ box-shadow: var(--shadow-md);
749
+ transform: translateY(-2px);
750
+ border-color: var(--border-2);
751
+ color: var(--ink);
752
+ }
753
+ .bangumi-cover {
754
+ position: relative;
755
+ aspect-ratio: 3 / 4;
756
+ overflow: hidden;
757
+ /* 封面加载前的底色兜底 */
758
+ background: var(--border);
759
+ }
760
+ .bangumi-cover img {
761
+ width: 100%;
762
+ height: 100%;
763
+ object-fit: cover;
764
+ transition: transform 0.3s;
765
+ }
766
+ .bangumi-card:hover .bangumi-cover img {
767
+ transform: scale(1.05);
768
+ }
769
+ /* bgm.tv 封面在暗色下偏刺眼:柔和压暗降饱和(类级固定,无配置项) */
770
+ html.dark .bangumi-cover img {
771
+ filter: brightness(0.85) saturate(0.9);
772
+ }
773
+ .bangumi-score {
774
+ position: absolute;
775
+ top: 8px;
776
+ right: 8px;
777
+ padding: 2px 7px;
778
+ border-radius: 999px;
779
+ background: rgb(0 0 0 / 0.62);
780
+ color: #ffd76a;
781
+ font-size: 12px;
782
+ font-weight: 700;
783
+ font-variant-numeric: tabular-nums;
784
+ }
785
+ .bangumi-title {
786
+ padding: 10px 12px 4px;
787
+ font-size: 13.5px;
788
+ font-weight: 600;
789
+ line-height: 1.45;
790
+ /* 两行截断:长译名不出卡片 */
791
+ display: -webkit-box;
792
+ -webkit-box-orient: vertical;
793
+ -webkit-line-clamp: 2;
794
+ overflow: hidden;
795
+ }
796
+ .bangumi-meta {
797
+ display: flex;
798
+ flex-wrap: wrap;
799
+ gap: 4px 10px;
800
+ padding: 0 12px 12px;
801
+ font-size: 12px;
802
+ color: var(--ink-2);
803
+ }
804
+ .bangumi-rate {
805
+ color: var(--accent, var(--ink-2));
806
+ }
807
+
702
808
  /* ---------- 4. Main 通用 ---------- */
703
809
  .site-main {
704
810
  min-height: calc(100vh - var(--header-h) - 200px);
@@ -2218,6 +2324,18 @@ html.dark .ec-collapse__toggle:hover {
2218
2324
  .prose figure img {
2219
2325
  width: 100%;
2220
2326
  }
2327
+ /* 暗色外链图适配(§4.89):rehypeExternalLinks 给外链 img 打 data-external 标记,
2328
+ 暗色下压亮度防白底截图/图表刺眼。--img-adapt-b 由 BaseLayout 按配置注入(关闭时
2329
+ brightness(1) 无操作);.no-adapt 供单图豁免(markdown 内联 HTML class) */
2330
+ html.dark .prose img[data-external],
2331
+ html.dark .moment-content img[data-external] {
2332
+ filter: brightness(var(--img-adapt-b, 1));
2333
+ transition: filter 0.3s ease;
2334
+ }
2335
+ html.dark .prose img[data-external].no-adapt,
2336
+ html.dark .moment-content img[data-external].no-adapt {
2337
+ filter: none;
2338
+ }
2221
2339
  .prose figcaption {
2222
2340
  padding: 8px 14px;
2223
2341
  font-size: 13px;
@@ -3088,6 +3206,9 @@ html.dark .series-nav-item.cur .series-nav-row {
3088
3206
  .gallery-banner {
3089
3207
  position: relative;
3090
3208
  overflow: hidden;
3209
+ /* 显式定宽阻断 aspect-ratio 反推:窄容器下 min-height 拉高后 3/1 会
3210
+ 反解出 200×3=600px 的宽度穿透容器,导致页面横向滚动(§4.86) */
3211
+ width: 100%;
3091
3212
  border-radius: var(--radius);
3092
3213
  aspect-ratio: 3 / 1;
3093
3214
  min-height: 200px;
@@ -3208,6 +3329,8 @@ gallery-filter {
3208
3329
  .gallery-masonry {
3209
3330
  column-count: 2;
3210
3331
  column-gap: 12px;
3332
+ /* 与封面横幅/降级头部的间隔(§4.86,原来零间距贴合) */
3333
+ margin-top: 24px;
3211
3334
  }
3212
3335
  @media (min-width: 640px) {
3213
3336
  .gallery-masonry {
@@ -3234,6 +3357,84 @@ gallery-filter {
3234
3357
  transform: scale(1.025);
3235
3358
  }
3236
3359
 
3360
+ /* 说说页(§4.87):时间流卡片 */
3361
+ .moments-list {
3362
+ display: flex;
3363
+ flex-direction: column;
3364
+ gap: 16px;
3365
+ }
3366
+ .moment {
3367
+ padding: 18px 22px;
3368
+ }
3369
+ .moment-meta {
3370
+ display: flex;
3371
+ align-items: center;
3372
+ justify-content: space-between;
3373
+ margin-bottom: 10px;
3374
+ font-size: 13px;
3375
+ color: var(--muted);
3376
+ }
3377
+ .moment-mood {
3378
+ font-size: 18px;
3379
+ line-height: 1;
3380
+ }
3381
+ .moment-content {
3382
+ font-size: 15px;
3383
+ line-height: 1.75;
3384
+ }
3385
+ .moment-content > :first-child {
3386
+ margin-top: 0;
3387
+ }
3388
+ .moment-content > :last-child {
3389
+ margin-bottom: 0;
3390
+ }
3391
+ .moment-images {
3392
+ display: grid;
3393
+ grid-template-columns: repeat(3, 1fr);
3394
+ gap: 8px;
3395
+ margin-top: 12px;
3396
+ }
3397
+ @media (max-width: 480px) {
3398
+ .moment-images {
3399
+ grid-template-columns: repeat(2, 1fr);
3400
+ }
3401
+ }
3402
+ .moment-img {
3403
+ overflow: hidden;
3404
+ border-radius: var(--radius-sm);
3405
+ background: var(--bg-soft);
3406
+ cursor: zoom-in;
3407
+ }
3408
+ .moment-img img {
3409
+ display: block;
3410
+ width: 100%;
3411
+ height: 100%;
3412
+ object-fit: cover;
3413
+ aspect-ratio: 1 / 1;
3414
+ transition: transform 0.35s ease;
3415
+ }
3416
+ .moment-img:hover img {
3417
+ transform: scale(1.04);
3418
+ }
3419
+ .moment-tags {
3420
+ display: flex;
3421
+ flex-wrap: wrap;
3422
+ gap: 8px;
3423
+ margin-top: 12px;
3424
+ }
3425
+ .moment-tag {
3426
+ padding: 1px 10px;
3427
+ border-radius: 999px;
3428
+ font-size: 12px;
3429
+ color: var(--ink-2);
3430
+ background: var(--bg-soft);
3431
+ transition: background-color 0.15s;
3432
+ }
3433
+ .moment-tag:hover {
3434
+ background: var(--accent-soft);
3435
+ color: var(--ink);
3436
+ }
3437
+
3237
3438
  /* 灯箱(§4.69):样式由 photoswipe/photoswipe.css 提供(BaseLayout 引入) */
3238
3439
  /* 灯箱自定义 UI(§4.70/§4.71):全屏/自动播放按钮与缩略图条(可开关、内容居中)。
3239
3440
  自定义图标用 .qy-pswp-icn 独立类名,避开 .pswp__icn 的 fill 覆盖(图标走 stroke 描边) */
@@ -1,37 +0,0 @@
1
- ---
2
- import { getCollection } from "astro:content";
3
- import BaseLayout from "virtual:theme/components/BaseLayout.astro";
4
- import { t } from "../../i18n/index.js";
5
- import { collectCategories, formatDate, getSortedPosts } from "../../utils.js";
6
-
7
- // 先排序再聚合,保证每个分类内文章按新→旧排列
8
- export async function getStaticPaths() {
9
- const all = await getCollection("posts", ({ data }) =>
10
- import.meta.env.PROD ? !data.draft : true,
11
- );
12
- const sorted = getSortedPosts(all);
13
- return [...collectCategories(sorted).entries()].map(([category, posts]) => ({
14
- params: { category },
15
- props: { category, posts },
16
- }));
17
- }
18
-
19
- const { category, posts } = Astro.props;
20
- ---
21
-
22
- <BaseLayout title={t('categoryTitle', { category })} page="categories">
23
- <header class="archive-head reveal">
24
- <h1 class="page-title">{category}</h1>
25
- <p class="page-sub">{t('totalPosts', { n: posts.length })}</p>
26
- </header>
27
- <div class="tag-list">
28
- {
29
- posts.map((post) => (
30
- <a class="related-card card tag-item reveal" href={`/posts/${post.id}/`}>
31
- <div class="rc-title">{post.data.title}</div>
32
- <div class="rc-meta">{formatDate(post.data.published)}</div>
33
- </a>
34
- ))
35
- }
36
- </div>
37
- </BaseLayout>
@@ -1,40 +0,0 @@
1
- ---
2
- import { getCollection } from "astro:content";
3
- import BaseLayout from "virtual:theme/components/BaseLayout.astro";
4
- import { t } from "../../i18n/index.js";
5
- import { collectTags, formatDate, getSortedPosts } from "../../utils.js";
6
-
7
- // 先排序再聚合,保证每个标签内文章按新→旧排列
8
- export async function getStaticPaths() {
9
- const all = await getCollection("posts", ({ data }) =>
10
- import.meta.env.PROD ? !data.draft : true,
11
- );
12
- const sorted = getSortedPosts(all);
13
- return [...collectTags(sorted).entries()].map(([tag, posts]) => ({
14
- params: { tag },
15
- props: { tag, posts },
16
- }));
17
- }
18
-
19
- const { tag, posts } = Astro.props;
20
- ---
21
-
22
- <BaseLayout title={t('tagTitle', { tag })} page="tags">
23
- <header class="archive-head reveal">
24
- <h1 class="page-title">{tag}</h1>
25
- <p class="page-sub">{t('totalPosts', { n: posts.length })}</p>
26
- </header>
27
- <div class="tag-list">
28
- {
29
- posts.map((post) => (
30
- <a class="related-card card tag-item reveal" href={`/posts/${post.id}/`}>
31
- <div class="rc-title">{post.data.title}</div>
32
- <div class="rc-meta">
33
- {formatDate(post.data.published)}
34
- {post.data.category ? ` · ${post.data.category}` : ''}
35
- </div>
36
- </a>
37
- ))
38
- }
39
- </div>
40
- </BaseLayout>