Skip to content

Commit 1d37d64

Browse files
feat: 日记模块接入 Memos API,支持客户端动态获取数据 (#458)
通过客户端 JS 从 Memos 实例公开 API 获取 diary 数据, 无需重新构建站点即可更新日记内容。API 不可用时静默回退到静态数据。 - 新增 diaryApiUrl 配置项及类型定义 - 新增 moment-card.template.ts(Memos 类型、转换和渲染参考模板) - diary.astro 添加 is:inline 脚本,支持 DOMContentLoaded 和 Swup 页面切换 - MomentCard.astro 样式改为 is:global,确保客户端渲染卡片样式一致 - filter-tabs-handler.js 暴露 __initFilterTabs,支持动态重建 filter tabs Co-authored-by: StarGazer114 <ljsh666@outlook.com>
1 parent e767bbf commit 1d37d64

6 files changed

Lines changed: 519 additions & 5 deletions

File tree

public/js/filter-tabs-handler.js

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@
44
// Cards/entries should have a matching data attribute (e.g. data-category, data-type)
55

66
(function () {
7-
function initFilterTabs() {
7+
function initFilterTabs(reset) {
88
var containers = document.querySelectorAll(".filter-tabs");
99

1010
containers.forEach(function (container) {
11-
if (container.dataset.initialized) return;
11+
if (!reset && container.dataset.initialized) return;
1212
container.dataset.initialized = "true";
1313

1414
var tabs = container.querySelectorAll(".filter-tabs-item");
@@ -53,9 +53,14 @@
5353
});
5454
}
5555

56+
// Expose for dynamic tab rebuild (e.g. Memos API fetch)
57+
window.__initFilterTabs = function () {
58+
initFilterTabs(true);
59+
};
60+
5661
function onInit() {
5762
if (document.querySelector(".filter-tabs")) {
58-
initFilterTabs();
63+
initFilterTabs(false);
5964
}
6065
}
6166

src/components/features/diary/MomentCard.astro

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ const relativeTime = formatRelativeTime(
119119
</div>
120120
</div>
121121

122-
<style>
122+
<style is:global>
123123
.moment-card {
124124
animation: fadeInUp 0.5s ease-out forwards;
125125
opacity: 0;
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
// Memos API 集成 — 类型定义、数据转换和卡片渲染
2+
// 参考: DIARY_MEMOS_SETUP.md
3+
4+
import type { DiaryItem } from "../../../data/diary";
5+
6+
// --- Memos API 响应类型 ---
7+
8+
export interface MemoAttachment {
9+
name: string;
10+
filename: string;
11+
type: string;
12+
size: string;
13+
memo: string;
14+
}
15+
16+
export interface MemoLocation {
17+
placeholder: string;
18+
latitude: number;
19+
longitude: number;
20+
}
21+
22+
export interface Memo {
23+
name: string;
24+
state: string;
25+
creator: string;
26+
createTime: string;
27+
displayTime: string;
28+
content: string;
29+
visibility: string;
30+
pinned: boolean;
31+
tags: string[];
32+
attachments: MemoAttachment[];
33+
location?: MemoLocation;
34+
snippet: string;
35+
}
36+
37+
export interface MemosResponse {
38+
memos: Memo[];
39+
nextPageToken: string;
40+
}
41+
42+
// --- 数据转换 ---
43+
44+
export function transformMemosToDiary(
45+
memos: Memo[],
46+
baseUrl: string,
47+
): DiaryItem[] {
48+
return memos
49+
.filter((m) => m.visibility === "PUBLIC" && m.state === "NORMAL")
50+
.map((m, i) => ({
51+
id: i,
52+
content: m.content,
53+
date: m.createTime,
54+
tags: m.tags.length > 0 ? m.tags : undefined,
55+
images:
56+
m.attachments.length > 0
57+
? m.attachments
58+
.filter((a) => a.type.startsWith("image/"))
59+
.map((a) => `${baseUrl}/file/${a.name}/${a.filename}`)
60+
: undefined,
61+
location: m.location?.placeholder,
62+
mood: undefined,
63+
}))
64+
.sort((a, b) => {
65+
// pinned 优先,其次按时间倒序
66+
const aM = memos.find((m) => m.createTime === a.date);
67+
const bM = memos.find((m) => m.createTime === b.date);
68+
if (aM?.pinned && !bM?.pinned) return -1;
69+
if (!aM?.pinned && bM?.pinned) return 1;
70+
return new Date(b.date).getTime() - new Date(a.date).getTime();
71+
});
72+
}
73+
74+
// --- 相对时间格式化(客户端版本) ---
75+
76+
export function formatRelativeTime(
77+
dateString: string,
78+
timeZone: number,
79+
minutesAgo: string,
80+
hoursAgo: string,
81+
daysAgo: string,
82+
): string {
83+
const now = new Date();
84+
const utc = now.getTime() + now.getTimezoneOffset() * 60 * 1000;
85+
const localNow = utc + timeZone * 60 * 60 * 1000;
86+
const date = new Date(dateString);
87+
const diffInMinutes = Math.floor(
88+
(localNow - date.getTime()) / (1000 * 60),
89+
);
90+
91+
if (diffInMinutes < 60) return `${diffInMinutes}${minutesAgo}`;
92+
if (diffInMinutes < 1440) {
93+
return `${Math.floor(diffInMinutes / 60)}${hoursAgo}`;
94+
}
95+
return `${Math.floor(diffInMinutes / 1440)}${daysAgo}`;
96+
}
97+
98+
// --- 单张卡片 HTML 生成 ---
99+
100+
function getImageLayoutClass(count: number): string {
101+
if (count === 1) return "diary-images-single";
102+
if (count === 2) return "diary-images-double";
103+
if (count === 3) return "diary-images-triple";
104+
return "diary-images-grid";
105+
}
106+
107+
function escapeHtml(text: string): string {
108+
const map: Record<string, string> = {
109+
"&": "&amp;",
110+
"<": "&lt;",
111+
">": "&gt;",
112+
'"': "&quot;",
113+
"'": "&#039;",
114+
};
115+
return text.replace(/[&<>"']/g, (c) => map[c]);
116+
}
117+
118+
function renderMomentCard(
119+
moment: DiaryItem,
120+
index: number,
121+
opts: {
122+
minutesAgo: string;
123+
hoursAgo: string;
124+
daysAgo: string;
125+
timeZone: number;
126+
},
127+
): string {
128+
const relativeTime = formatRelativeTime(
129+
moment.date,
130+
opts.timeZone,
131+
opts.minutesAgo,
132+
opts.hoursAgo,
133+
opts.daysAgo,
134+
);
135+
136+
const tagsAttr = moment.tags?.join(",") || "";
137+
138+
let imagesHtml = "";
139+
if (moment.images && moment.images.length > 0) {
140+
const layoutClass = getImageLayoutClass(moment.images.length);
141+
const imgs = moment.images
142+
.map(
143+
(img, i) => `
144+
<div class="relative rounded-lg overflow-hidden aspect-square cursor-pointer">
145+
<a href="javascript:void(0)" data-src="${escapeHtml(img)}" data-fancybox="diary-${index}-${i}" class="block w-full h-full">
146+
<img src="${escapeHtml(img)}" alt="diary moment image" class="w-full h-full object-cover transition-transform duration-300 hover:scale-105" loading="lazy" decoding="async" />
147+
</a>
148+
</div>`,
149+
)
150+
.join("");
151+
imagesHtml = `<div class="diary-images grid gap-2 mb-3 ${layoutClass}">${imgs}</div>`;
152+
}
153+
154+
let tagsHtml = "";
155+
if (moment.tags && moment.tags.length > 0) {
156+
const tagSpans = moment.tags
157+
.map(
158+
(tag) =>
159+
`<span class="btn-regular h-6 text-xs px-2 rounded-lg">${escapeHtml(tag)}</span>`,
160+
)
161+
.join("");
162+
tagsHtml = `<div class="flex flex-wrap gap-1.5 mb-3">${tagSpans}</div>`;
163+
}
164+
165+
const locationHtml = moment.location
166+
? `<span class="flex items-center gap-1"><iconify-icon icon="material-symbols:location-on" class="text-xs w-3.5 h-3.5"></iconify-icon>${escapeHtml(moment.location)}</span>`
167+
: "";
168+
169+
return `
170+
<div class="moment-card group relative bg-transparent rounded-xl border border-black/10 dark:border-white/10 overflow-hidden transition-all duration-300 hover:shadow-xl hover:-translate-y-1" data-tags="${tagsAttr}">
171+
<div class="p-5">
172+
<p class="text-sm md:text-base text-black/90 dark:text-white/90 leading-relaxed mb-3">${escapeHtml(moment.content)}</p>
173+
${imagesHtml}
174+
${tagsHtml}
175+
<hr class="border-t border-black/5 dark:border-white/5 my-3" />
176+
<div class="flex items-center justify-between text-xs text-black/50 dark:text-white/50 flex-wrap gap-2">
177+
<div class="flex items-center gap-1.5">
178+
<iconify-icon icon="material-symbols:schedule" class="text-xs w-3.5 h-3.5"></iconify-icon>
179+
<time datetime="${escapeHtml(moment.date)}">${relativeTime}</time>
180+
</div>
181+
<div class="flex items-center gap-3">
182+
${locationHtml}
183+
</div>
184+
</div>
185+
</div>
186+
<div class="absolute inset-0 bg-gradient-to-br from-[var(--primary)]/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none rounded-xl"></div>
187+
</div>`;
188+
}
189+
190+
// --- 全部卡片 HTML 生成 ---
191+
192+
export function renderMomentCards(
193+
moments: DiaryItem[],
194+
opts: {
195+
minutesAgo: string;
196+
hoursAgo: string;
197+
daysAgo: string;
198+
timeZone: number;
199+
},
200+
): string {
201+
return moments.map((m, i) => renderMomentCard(m, i, opts)).join("");
202+
}

src/config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,9 @@ export const siteConfig: SiteConfig = {
9090
mode: "local", // 番剧页面模式:"bangumi" 使用Bangumi API,"local" 使用本地配置,"bilibili" 使用Bilibili API
9191
},
9292

93+
// 日记页面 Memos API 地址,留空则使用静态数据
94+
diaryApiUrl: "",
95+
9396
// 文章列表布局配置
9497
postListLayout: {
9598
// 默认布局模式:"list" 列表模式(单列布局),"grid" 网格模式(双列布局)

0 commit comments

Comments
 (0)