This commit is contained in:
2026-07-18 09:13:04 +08:00 Unverified
parent 271f0c4efd
commit 8bbc9da1e5
62 changed files with 2048 additions and 9 deletions
+3 -1
View File
@@ -2,4 +2,6 @@ node_modules/
dist/
.astro/
*.log
.env.local
.env.local
.tmp/
gzhh/
Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

+116
View File
@@ -0,0 +1,116 @@
import json
import math
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont, ImageOps
ROOT = Path(__file__).resolve().parents[1]
BASE = ROOT / ".tmp" / "gzhh-images"
RAW = BASE / "raw"
MANIFEST = BASE / "manifest.json"
AUDIT = BASE / "audit.json"
SHEETS = BASE / "contact-sheets"
CELL_WIDTH = 280
CELL_HEIGHT = 220
COLS = 5
ROWS = 4
PER_SHEET = COLS * ROWS
def font(size: int):
paths = [
Path("C:/Windows/Fonts/msyh.ttc"),
Path("C:/Windows/Fonts/simhei.ttf"),
]
for path in paths:
if path.exists():
return ImageFont.truetype(str(path), size)
return ImageFont.load_default()
small_font = font(14)
label_font = font(16)
title_font = font(22)
manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
unique_entries = []
seen_files = set()
for entry in manifest:
file_name = entry.get("file")
if entry.get("status") != "ok" or not file_name or file_name in seen_files:
continue
seen_files.add(file_name)
image_path = RAW / file_name
record = {
"id": len(unique_entries) + 1,
"file": file_name,
"path": image_path.relative_to(ROOT).as_posix(),
"sources": entry.get("sources", []),
"alts": entry.get("alts", []),
"bytes": entry.get("bytes", 0),
"hash": entry.get("hash", ""),
}
try:
with Image.open(image_path) as image:
record["width"], record["height"] = image.size
record["format"] = image.format
preview = image.convert("RGB")
preview.thumbnail((128, 128))
record["entropy"] = round(preview.convert("L").entropy(), 3)
record["candidate"] = (
record["width"] * record["height"] >= 200_000
and min(record["width"], record["height"]) >= 280
and 0.32 <= record["width"] / record["height"] <= 3.2
and record["entropy"] >= 3.2
)
except Exception as error:
record["error"] = str(error)
record["candidate"] = False
unique_entries.append(record)
AUDIT.write_text(json.dumps(unique_entries, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
SHEETS.mkdir(parents=True, exist_ok=True)
candidates = [item for item in unique_entries if item.get("candidate")]
for sheet_index in range(math.ceil(len(candidates) / PER_SHEET)):
page_items = candidates[sheet_index * PER_SHEET : (sheet_index + 1) * PER_SHEET]
sheet = Image.new("RGB", (CELL_WIDTH * COLS, 48 + CELL_HEIGHT * ROWS), "#f7f3e8")
draw = ImageDraw.Draw(sheet)
draw.text(
(18, 10),
f"公众号图片候选 {sheet_index + 1}/{math.ceil(len(candidates) / PER_SHEET)} · {len(candidates)}",
fill="#1f2b2a",
font=title_font,
)
for item_index, item in enumerate(page_items):
col = item_index % COLS
row = item_index // COLS
x = col * CELL_WIDTH
y = 48 + row * CELL_HEIGHT
draw.rectangle((x + 5, y + 5, x + CELL_WIDTH - 5, y + CELL_HEIGHT - 5), fill="#ffffff", outline="#d6d7cf")
try:
with Image.open(ROOT / item["path"]) as source:
preview = ImageOps.contain(source.convert("RGB"), (CELL_WIDTH - 24, 154))
px = x + (CELL_WIDTH - preview.width) // 2
py = y + 12 + (154 - preview.height) // 2
sheet.paste(preview, (px, py))
except Exception:
pass
source_name = Path(item["sources"][0]).stem if item["sources"] else "未知来源"
source_name = source_name[:20]
draw.text((x + 12, y + 170), f"#{item['id']:03d} {item['width']}×{item['height']}", fill="#376d5a", font=label_font)
draw.text((x + 12, y + 192), source_name, fill="#596563", font=small_font)
sheet.save(SHEETS / f"sheet-{sheet_index + 1:02d}.jpg", quality=90)
summary = {
"unique": len(unique_entries),
"candidates": len(candidates),
"rejected_by_shape_or_size": len(unique_entries) - len(candidates),
"sheets": math.ceil(len(candidates) / PER_SHEET),
}
print(json.dumps(summary, ensure_ascii=False))
+108
View File
@@ -0,0 +1,108 @@
import { createHash } from "node:crypto";
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const sourceDirectory = path.join(projectRoot, "gzhh");
const outputDirectory = path.join(projectRoot, ".tmp", "gzhh-images", "raw");
const manifestPath = path.join(projectRoot, ".tmp", "gzhh-images", "manifest.json");
const concurrency = 8;
const maxBytes = 20 * 1024 * 1024;
const extensionByType = new Map([
["image/jpeg", ".jpg"],
["image/png", ".png"],
["image/webp", ".webp"],
["image/gif", ".gif"],
["image/svg+xml", ".svg"]
]);
const markdownFiles = (await readdir(sourceDirectory)).filter((name) => name.endsWith(".md"));
const imageMap = new Map();
for (const source of markdownFiles) {
const markdown = await readFile(path.join(sourceDirectory, source), "utf8");
for (const match of markdown.matchAll(/!\[([^\]]*)\]\((https?:\/\/[^\s\)]+)\)/g)) {
const alt = match[1].trim();
const url = match[2].replaceAll("&amp;", "&").replaceAll("\\_", "_");
let parsed;
try {
parsed = new URL(url);
} catch {
continue;
}
if (parsed.hostname !== "mmbiz.qpic.cn") continue;
const existing = imageMap.get(parsed.href) ?? { url: parsed.href, sources: [], alts: [] };
if (!existing.sources.includes(source)) existing.sources.push(source);
if (alt && !existing.alts.includes(alt)) existing.alts.push(alt);
imageMap.set(parsed.href, existing);
}
}
await mkdir(outputDirectory, { recursive: true });
const entries = [...imageMap.values()];
const results = new Array(entries.length);
const contentFiles = new Map();
let cursor = 0;
let completed = 0;
const download = async (entry, index) => {
try {
const response = await fetch(entry.url, {
headers: {
"user-agent": "Mozilla/5.0 (compatible; ClassArchive/1.0)",
referer: "https://mp.weixin.qq.com/"
},
signal: AbortSignal.timeout(45_000)
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const contentType = response.headers.get("content-type")?.split(";")[0].toLowerCase() ?? "";
if (!contentType.startsWith("image/")) throw new Error(`Unexpected content type: ${contentType || "unknown"}`);
const declaredLength = Number(response.headers.get("content-length") ?? 0);
if (declaredLength > maxBytes) throw new Error(`Image exceeds ${maxBytes} bytes`);
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.byteLength > maxBytes) throw new Error(`Image exceeds ${maxBytes} bytes`);
const hash = createHash("sha256").update(buffer).digest("hex");
const extension = extensionByType.get(contentType) ?? ".img";
let file = contentFiles.get(hash);
let duplicate = true;
if (!file) {
duplicate = false;
file = `${String(index + 1).padStart(4, "0")}-${hash.slice(0, 12)}${extension}`;
await writeFile(path.join(outputDirectory, file), buffer);
contentFiles.set(hash, file);
}
return { ...entry, status: "ok", file, contentType, bytes: buffer.byteLength, hash, duplicate };
} catch (error) {
return { ...entry, status: "error", error: error instanceof Error ? error.message : String(error) };
}
};
const worker = async () => {
while (true) {
const index = cursor++;
if (index >= entries.length) return;
results[index] = await download(entries[index], index);
completed += 1;
if (completed % 50 === 0 || completed === entries.length) {
console.log(`Downloaded ${completed}/${entries.length}`);
}
}
};
await Promise.all(Array.from({ length: concurrency }, () => worker()));
await writeFile(manifestPath, `${JSON.stringify(results, null, 2)}\n`, "utf8");
const successful = results.filter((item) => item.status === "ok");
console.log(
JSON.stringify({
references: entries.length,
successful: successful.length,
failed: results.length - successful.length,
uniqueContent: successful.filter((item) => !item.duplicate).length,
bytes: successful.filter((item) => !item.duplicate).reduce((sum, item) => sum + item.bytes, 0),
manifest: path.relative(projectRoot, manifestPath)
})
);
+56
View File
@@ -0,0 +1,56 @@
import json
from pathlib import Path
from PIL import Image, ImageOps
ROOT = Path(__file__).resolve().parents[1]
BASE = ROOT / ".tmp" / "gzhh-images"
AUDIT = BASE / "audit.json"
OUTPUT = ROOT / "public" / "assets" / "wechat-archive"
SELECTED_IDS = [
81, 84, 85, 88, 89,
117, 121, 129, 132,
145, 146, 150, 151, 152, 153,
159, 160, 161, 162,
176, 178, 180, 181,
193, 196, 197,
368, 370, 373, 375,
382, 387, 388, 391,
396, 405, 408, 411,
446, 455, 459,
470, 480, 490, 499,
]
records = {item["id"]: item for item in json.loads(AUDIT.read_text(encoding="utf-8"))}
OUTPUT.mkdir(parents=True, exist_ok=True)
exported = []
for image_id in SELECTED_IDS:
record = records[image_id]
source_path = ROOT / record["path"]
output_name = f"wechat-{image_id:03d}.webp"
output_path = OUTPUT / output_name
with Image.open(source_path) as image:
frame = ImageOps.exif_transpose(image).convert("RGB")
frame.thumbnail((1800, 1800), Image.Resampling.LANCZOS)
frame.save(output_path, "WEBP", quality=84, method=6)
exported.append({
"id": image_id,
"file": output_name,
"source": record.get("sources", [""])[0],
"width": frame.width,
"height": frame.height,
"bytes": output_path.stat().st_size,
})
(BASE / "selected-manifest.json").write_text(
json.dumps(exported, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
print(json.dumps({
"selected": len(exported),
"bytes": sum(item["bytes"] for item in exported),
"output": str(OUTPUT.relative_to(ROOT)),
}, ensure_ascii=False))
+89
View File
@@ -0,0 +1,89 @@
import { readdir, readFile, writeFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import path from "node:path";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const sourceDirectory = path.join(projectRoot, "gzhh");
const outputFile = path.join(projectRoot, "src", "data", "articles.generated.json");
const cleanMarkdown = (value) =>
value
.replace(/!\[[^\]]*\]\([^\)]+\)/g, " ")
.replace(/\[([^\]]+)\]\([^\)]+\)/g, "$1")
.replace(/[*_`>#]/g, " ")
.replace(/\s+/g, " ")
.trim();
const getExcerpt = (body, title) => {
const ignored = /(?:data:image|%3Csvg|transform=|阅读|点赞|分享|推荐|留言|2024届612|font-family|__bottom-bar__|sns_opr_btn|picture_content|page_content)/i;
const lines = body
.split(/\r?\n/)
.map((line) => cleanMarkdown(line))
.filter((line) => {
if (!line || line === "(unknown)" || line === cleanMarkdown(title) || ignored.test(line)) return false;
if (/^(?:原创|原文地址|图\d+|内容来自|[=\-]{3,})/.test(line)) return false;
return /[\p{L}\p{N}]/u.test(line);
});
const excerptText = lines[0]?.length >= 32 ? lines[0] : lines.slice(0, 3).join(" ");
return excerptText.length > 88 ? `${excerptText.slice(0, 88)}` : excerptText;
};
const getTitle = (raw, fileName) => {
const heading = raw.match(/\r?\n([^\r\n]{2,120})\r?\n={3,}\r?\n/);
const title = heading?.[1]?.trim();
return title && title !== "(unknown)"
? title
: fileName.replace(/\.md$/i, "").replaceAll("_", " ");
};
const getCategory = (title) => {
if (/毕业|高考|成人礼|壮行|远足|百日誓师|教师风采|班级之星|612|陆壹贰|往年今日/.test(title)) {
return /毕业|高考|成人礼|壮行/.test(title) ? "毕业时刻" : "班级记忆";
}
if (/考试|四六级|四.六级|CET|竞赛|考生|准考证|成绩|普通话|数学建模|挂科|重修|招生/.test(title)) {
return "考试升学";
}
if (/春节|元旦|除夕|小年|圣诞|清明|父亲节|母亲节|儿童节|六一|五四|新年/.test(title)) {
return "节日来信";
}
if (/通知|通告|说明|提醒|返校|课程表|网站|公众号|AI功能|招聘|邀请函/.test(title)) {
return "校园通知";
}
return "班级记忆";
};
const parseArticle = (fileName, raw) => {
const title = getTitle(raw, fileName);
const date = raw.match(/\b(20\d{2}-\d{2}-\d{2})\b/)?.[1] ?? "";
const sourceUrl = raw.match(/原文地址:\s*\[?((?:https?:\/\/)?mp\.weixin\.qq\.com\/s\/[^\s\]\)]+)/)?.[1]
?.replaceAll("\\_", "_") ?? "";
const contentStart = raw.search(/>\s*原文地址:/);
const body = contentStart >= 0 ? raw.slice(contentStart).split(/\r?\n/).slice(2).join("\n") : raw;
const images = [...body.matchAll(/!\[[^\]]*\]\((https?:\/\/[^\s\)]+)\)/g)]
.map((match) => match[1])
.filter((url) => /mmbiz\.qpic\.cn/.test(url));
const excerptText = getExcerpt(body, title);
return {
title,
date,
displayDate: date ? date.replaceAll("-", ".") : "日期待补",
sourceUrl,
cover: images[0] ?? "",
excerpt: excerptText.length > 88 ? `${excerptText.slice(0, 88)}` : excerptText || "一页从公众号找回的班级记录。",
category: getCategory(title),
fileName
};
};
const fileNames = (await readdir(sourceDirectory)).filter((fileName) => fileName.endsWith(".md"));
const articles = (
await Promise.all(
fileNames.map(async (fileName) => parseArticle(fileName, await readFile(path.join(sourceDirectory, fileName), "utf8")))
)
)
.filter((article) => article.sourceUrl)
.sort((a, b) => b.date.localeCompare(a.date) || a.title.localeCompare(b.title, "zh-CN"));
await writeFile(outputFile, `${JSON.stringify(articles, null, 2)}\n`, "utf8");
console.log(`Imported ${articles.length} articles from gzhh into ${path.relative(projectRoot, outputFile)}.`);
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
export type ArticleCategory = "班级记忆" | "毕业时刻" | "节日来信" | "考试升学" | "校园通知";
export interface WechatArticle {
title: string;
date: string;
displayDate: string;
sourceUrl: string;
cover: string;
excerpt: string;
category: ArticleCategory;
fileName: string;
}
import articleData from "./articles.generated.json";
export const articles = articleData as WechatArticle[];
export const articleCategories: ArticleCategory[] = [
"班级记忆",
"毕业时刻",
"节日来信",
"考试升学",
"校园通知"
];
export const featuredArticles = articles.slice(0, 3);
+5 -2
View File
@@ -1,10 +1,11 @@
import type { PhotoTopic } from "../photo-types";
import { wechatPhoto } from "./wechat-photo";
export const candidTopic: PhotoTopic = {
slug: "candid",
title: "没被摆拍的瞬间",
text: "走廊、食堂、晚霞和笑场。真正会让人停下来的,常常是不太整齐的照片。",
cover: "",
cover: "/assets/wechat-archive/wechat-499.webp",
photos: [
{
title: "走廊偶遇",
@@ -25,6 +26,8 @@ export const candidTopic: PhotoTopic = {
title: "笑场",
caption: "最不端正的照片,往往最像我们。",
image: ""
}
},
wechatPhoto(490, "恶魔角合照", "没有正式站姿,反而最像我们真实的青春。"),
wechatPhoto(499, "草地上的伙伴", "坐下来聊聊天,晚风把时间吹得很慢。")
]
};
+12 -2
View File
@@ -1,10 +1,11 @@
import type { PhotoTopic } from "../photo-types";
import { wechatPhoto } from "./wechat-photo";
export const classroomTopic: PhotoTopic = {
slug: "classroom",
title: "教室日常",
text: "黑板、课桌、窗边、试卷,还有那些写在草稿纸边角的小情绪。",
cover: "",
cover: "/assets/wechat-archive/wechat-373.webp",
photos: [
{
title: "窗边",
@@ -30,6 +31,15 @@ export const classroomTopic: PhotoTopic = {
title: "询问老师",
caption: "课后向老师请教问题",
image: "https://pic.biss.click/image/95c03bd4-d0d3-4c38-9855-3342fd582080.jpg"
}
},
wechatPhoto(159, "清晨校园", "天还没完全亮,教学楼已经开始新的一天。"),
wechatPhoto(160, "空走廊", "下课铃没有响起时,走廊安静得像一张旧照片。"),
wechatPhoto(161, "教室一角", "寒冷的早晨、厚厚的毯子和靠窗的位置。"),
wechatPhoto(162, "雪天课间", "楼下有人追着雪跑,楼上的人隔窗看热闹。"),
wechatPhoto(193, "黑板前", "老师写下的每一行,都曾是我们共同的日常。"),
wechatPhoto(196, "忙碌的教室", "有人整理桌面,有人继续写题,生活没有暂停。"),
wechatPhoto(197, "围在桌边", "讲一道题的时间,也能成为多年后的回忆。"),
wechatPhoto(373, "夕阳自习", "光从窗边落进来,照亮高中的普通一天。"),
wechatPhoto(375, "教室合照", "镜头前挤成一团,是班级最真实的样子。")
]
};
+18 -2
View File
@@ -1,10 +1,11 @@
import type { PhotoTopic } from "../photo-types";
import { wechatPhoto } from "./wechat-photo";
export const eventsTopic: PhotoTopic = {
slug: "events",
title: "班级活动",
text: "运动会、晚会、春游、比赛,所有离开课桌之后还在一起发光的时刻。",
cover: "",
cover: "/assets/wechat-archive/wechat-455.webp",
photos: [
{
title: "高一下学期表彰大会",
@@ -20,6 +21,21 @@ export const eventsTopic: PhotoTopic = {
title: "百日誓师大会",
caption: "高三的第一次全体活动,大家都很随意地在听讲,虽然最后还是没能坚持到最后。",
image: "https://pic.biss.click/image/45bfbe79-bd7b-40f3-9670-1b34cc0957b2.png"
}
},
wechatPhoto(117, "考前舞台", "紧张的日子里,也需要一次大声唱出来。"),
wechatPhoto(121, "看台上的我们", "一片红色校服,聚成高三最后的集体活动。"),
wechatPhoto(129, "操场热身", "阳光很好,口号和动作都比平时更有力量。"),
wechatPhoto(132, "坐在一起", "台上发生什么已经模糊,身边的人还记得。"),
wechatPhoto(176, "元旦歌咏", "整齐站上舞台,是青春里难得的郑重。"),
wechatPhoto(178, "领奖时刻", "证书会褪色,但被认可的那一刻不会。"),
wechatPhoto(180, "操场挑战", "跨过栏架,也跨过一个个觉得做不到的瞬间。"),
wechatPhoto(181, "百日倒计时", "数字揭开的那一刻,高考忽然变得很近。"),
wechatPhoto(368, "军训合影", "刚认识不久的我们,第一次以班级的名字站在一起。"),
wechatPhoto(370, "方阵出发", "步子未必完全整齐,方向却是一致的。"),
wechatPhoto(446, "远足路上的笑脸", "路很长,但和同学一起走就没有那么累。"),
wechatPhoto(455, "山脚合影", "走过很远的路,当然要认真留下一张合照。"),
wechatPhoto(459, "班旗与伙伴", "把班级的名字举进镜头,也举进三年的记忆。"),
wechatPhoto(470, "操场晚会", "天黑之后,灯光和欢呼把操场变成了舞台。"),
wechatPhoto(480, "荧光人群", "挥动的光棒让普通夜晚有了节日的颜色。")
]
};
+22 -2
View File
@@ -1,10 +1,11 @@
import type { PhotoTopic } from "../photo-types";
import { wechatPhoto } from "./wechat-photo";
export const graduationDayTopic: PhotoTopic = {
slug: "graduation-day",
title: "毕业那天",
text: "合照、签名、花束、校门和没说完的话,都放在这个专题里。",
cover: "https://pic.biss.click/image/c1528af5-62da-47c3-8309-cd809f779288.jpg",
cover: "/assets/wechat-archive/wechat-145.webp",
photos: [
{
title: "最后一张合照",
@@ -30,6 +31,25 @@ export const graduationDayTopic: PhotoTopic = {
title: "散场之前",
caption: "那一刻大家都在笑,但心里都知道要分别了。",
image: "https://pic.biss.click/image/f8d180c1-4827-4bfd-9550-4bfad4b97640.jpg"
}
},
wechatPhoto(145, "壮行合影", "红色班服、彩色气球,和出发前最完整的一次站在一起。"),
wechatPhoto(146, "气球升起之前", "所有人挤进镜头,等一个一起松手的瞬间。"),
wechatPhoto(150, "飞向夏天", "气球越过教学楼,属于高中的倒计时也到了最后。"),
wechatPhoto(151, "人群里的告别", "欢呼、拥抱和一句句被现场声音盖住的话。"),
wechatPhoto(152, "穿过祝福", "从熟悉的人群中走过,前面就是新的路。"),
wechatPhoto(153, "鲜花与笑脸", "最后一程依然有人在身旁鼓掌。"),
wechatPhoto(81, "毕业典礼合影", "红色幕布前,老师和同学一起留下毕业的证据。"),
wechatPhoto(84, "典礼现场", "从看台望向舞台,礼堂装下了整届人的夏天。"),
wechatPhoto(85, "把花送给老师", "镜头拼在一起,也拼出那天的感谢。"),
wechatPhoto(88, "走过红毯", "熟悉的老师站在两侧,目送这一届学生离开。"),
wechatPhoto(89, "并肩入场", "毕业不是一个人的终点,是一群人的共同章节。"),
wechatPhoto(382, "最后一天的教室", "书本还摊在桌上,离别已经悄悄开始。"),
wechatPhoto(387, "签在衣服上", "名字、祝福和画得歪歪扭扭的小图案。"),
wechatPhoto(388, "气球装点的教室", "最后一次把教室布置得像节日。"),
wechatPhoto(391, "散场之后", "桌椅、书本和气球,保留着刚刚发生过的热闹。"),
wechatPhoto(396, "捧着花出发", "校门口的人群里,每个人都有自己的告别方式。"),
wechatPhoto(405, "校车旁的合照", "气球还在手里,下一段旅程已经在等候。"),
wechatPhoto(408, "放飞之前", "大家抬头看向同一个方向。"),
wechatPhoto(411, "毕业气球", "彩色气球升空,校门口只剩挥手的人群。")
]
};
+7
View File
@@ -0,0 +1,7 @@
import type { PhotoItem } from "../photo-types";
export const wechatPhoto = (id: number, title: string, caption: string): PhotoItem => ({
title,
caption,
image: `/assets/wechat-archive/wechat-${String(id).padStart(3, "0")}.webp`
});
+1
View File
@@ -31,6 +31,7 @@ export const navItems = [
{ label: "首页", href: "/" },
{ label: "三年时间线", href: "/timeline/" },
{ label: "照片墙", href: "/gallery/" },
{ label: "青春刊物", href: "/articles/" },
{ label: "如今的我们", href: "/people/" },
{ label: "综合试卷", href: "/exam/" },
{ label: "留言墙", href: "/messages/" }
+1
View File
@@ -25,6 +25,7 @@ const buildTime = new Intl.DateTimeFormat("zh-CN", {
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="referrer" content="no-referrer" />
<title>{title}</title>
<link
rel="stylesheet"
+114
View File
@@ -0,0 +1,114 @@
---
import BaseLayout from "../layouts/BaseLayout.astro";
import { articleCategories, articles } from "../data/articles";
const categoryCounts = Object.fromEntries(
articleCategories.map((category) => [category, articles.filter((article) => article.category === category).length])
);
---
<BaseLayout title="青春刊物 · 2024届612班">
<main class="page-main publication-page">
<section class="page-hero publication-hero">
<div class="section-inner">
<a class="back-link" href="/">返回首页</a>
<p class="eyebrow">CLASS PUBLICATION · {articles.length} 篇</p>
<h1>青春刊物</h1>
<p>这里收录公众号里的班级日志、校园时刻与毕业后的来信。原文仍属于公众号,这里为它们做一份更容易寻找的目录。</p>
</div>
</section>
<section class="article-archive">
<div class="section-inner">
<div class="archive-tools" aria-label="文章筛选工具">
<label class="article-search">
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
<span class="sr-only">搜索文章</span>
<input id="article-search" type="search" placeholder="搜索标题或摘要…" autocomplete="off" />
</label>
<div class="category-filters" role="group" aria-label="按分类筛选">
<button class="is-active" type="button" data-category="全部">全部 <span>{articles.length}</span></button>
{
articleCategories.map((category) => (
<button type="button" data-category={category}>{category} <span>{categoryCounts[category]}</span></button>
))
}
</div>
</div>
<p class="archive-status" aria-live="polite">正在展示 <strong>{articles.length}</strong> 篇文章</p>
<div class="article-list" id="article-list">
{
articles.map((article, index) => (
<a
class="archive-card"
href={article.sourceUrl}
target="_blank"
rel="noreferrer"
data-category={article.category}
data-search={`${article.title} ${article.excerpt}`.toLocaleLowerCase("zh-CN")}
>
<div
class="archive-cover"
style={article.cover ? `--article-cover: url("${article.cover}")` : ""}
>
{!article.cover && <span>{String(index + 1).padStart(3, "0")}</span>}
</div>
<div class="archive-copy">
<div class="archive-meta">
<span>{article.category}</span>
<time datetime={article.date}>{article.displayDate}</time>
</div>
<h2>{article.title}</h2>
<p>{article.excerpt}</p>
<span class="archive-read">公众号原文 <i class="fa-solid fa-arrow-up-right-from-square"></i></span>
</div>
</a>
))
}
</div>
<div class="archive-empty" hidden>
<span>没有找到相符的文章</span>
<p>换一个关键词,或者查看其他分类吧。</p>
</div>
</div>
</section>
</main>
<script>
const searchInput = document.querySelector<HTMLInputElement>("#article-search");
const filterButtons = [...document.querySelectorAll<HTMLButtonElement>("[data-category]")];
const cards = [...document.querySelectorAll<HTMLElement>(".archive-card")];
const status = document.querySelector<HTMLElement>(".archive-status");
const empty = document.querySelector<HTMLElement>(".archive-empty");
let activeCategory = "全部";
const updateResults = () => {
const query = searchInput?.value.trim().toLocaleLowerCase("zh-CN") ?? "";
let visibleCount = 0;
cards.forEach((card) => {
const categoryMatches = activeCategory === "全部" || card.dataset.category === activeCategory;
const searchMatches = !query || card.dataset.search?.includes(query);
const isVisible = Boolean(categoryMatches && searchMatches);
card.hidden = !isVisible;
if (isVisible) visibleCount += 1;
});
if (status) status.innerHTML = `正在展示 <strong>${visibleCount}</strong> 篇文章`;
if (empty) empty.hidden = visibleCount !== 0;
};
filterButtons.forEach((button) => {
button.addEventListener("click", () => {
activeCategory = button.dataset.category ?? "全部";
filterButtons.forEach((item) => item.classList.toggle("is-active", item === button));
updateResults();
});
});
searchInput?.addEventListener("input", updateResults);
</script>
</BaseLayout>
+43
View File
@@ -5,6 +5,7 @@ import { featuredMessage, messages, messagesIntro } from "../data/messages";
import { people, peopleIntro } from "../data/people";
import { site, stats } from "../data/site";
import { timeline, timelineIntro } from "../data/timeline";
import { articles, featuredArticles } from "../data/articles";
const previewTimeline = timeline.slice(0, 4);
const previewPhotoTopics = photoTopics.slice(0, 4);
@@ -136,6 +137,48 @@ const previewPeople = people.slice(0, 6);
</div>
</section>
<section id="articles" class="publication-band">
<div class="section-inner">
<div class="section-title publication-title">
<div>
<p class="eyebrow">FROM WECHAT · 青春刊物</p>
<h2>散落在公众号里的故事,也该被好好收藏</h2>
</div>
<p>
已整理 {articles.length} 篇公众号文章。从班级日志、毕业典礼到节日来信,按下时间的书签,重新读一遍当时的我们。
</p>
</div>
<div class="featured-articles">
{
featuredArticles.map((article, index) => (
<a
class:list={["article-card", index === 0 && "article-card-featured"]}
href={article.sourceUrl}
target="_blank"
rel="noreferrer"
>
<div
class="article-cover"
style={article.cover ? `--article-cover: url("${article.cover}")` : ""}
>
<span>{article.category}</span>
</div>
<div class="article-copy">
<time datetime={article.date}>{article.displayDate}</time>
<h3>{article.title}</h3>
<p>{article.excerpt}</p>
<span class="read-more">阅读原文 <i class="fa-solid fa-arrow-up-right-from-square"></i></span>
</div>
</a>
))
}
</div>
<a class="section-link publication-link" href="/articles/">翻阅全部 {articles.length} 篇</a>
</div>
</section>
<section id="messages" class="gallery-band">
<div class="section-inner">
<div class="section-title">
+365
View File
@@ -1672,6 +1672,371 @@ h2 {
font-size: 14px;
}
/* WeChat publication archive */
.publication-band {
color: #fffdf7;
background:
radial-gradient(circle at 90% 10%, rgba(232, 168, 76, 0.18), transparent 28%),
linear-gradient(145deg, #172522, #294a40);
}
.publication-title {
align-items: start;
}
.publication-title .eyebrow {
margin: 0 0 14px;
}
.publication-title > p {
color: rgba(255, 253, 247, 0.7);
}
.featured-articles {
display: grid;
grid-template-columns: 1.25fr 1fr;
gap: 16px;
}
.article-card {
min-height: 210px;
display: grid;
grid-template-columns: 180px 1fr;
overflow: hidden;
border: 1px solid rgba(255, 253, 247, 0.15);
border-radius: 10px;
background: rgba(255, 253, 247, 0.08);
transition: transform 180ms ease, background 180ms ease;
}
.article-card:hover {
transform: translateY(-4px);
background: rgba(255, 253, 247, 0.12);
}
.article-card-featured {
grid-row: span 2;
grid-template-columns: 1fr;
}
.article-cover,
.archive-cover {
position: relative;
min-height: 170px;
background:
linear-gradient(0deg, rgba(20, 32, 29, 0.48), rgba(20, 32, 29, 0.06)),
var(--article-cover, linear-gradient(135deg, #376d5a, #e8a84c));
background-position: center;
background-size: cover;
}
.article-card-featured .article-cover {
min-height: 300px;
}
.article-cover > span {
position: absolute;
top: 16px;
left: 16px;
padding: 5px 10px;
border-radius: 999px;
background: rgba(23, 37, 34, 0.72);
font-size: 12px;
font-weight: 800;
backdrop-filter: blur(8px);
}
.article-copy {
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 22px;
}
.article-copy time {
color: #f3cf8b;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.08em;
}
.article-copy h3 {
margin: 8px 0 10px;
font-size: 21px;
line-height: 1.35;
}
.article-card-featured .article-copy h3 {
font-size: clamp(24px, 3vw, 34px);
}
.article-copy p {
display: -webkit-box;
overflow: hidden;
margin: 0;
color: rgba(255, 253, 247, 0.68);
font-size: 14px;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.read-more {
margin-top: auto;
padding-top: 18px;
color: #f3cf8b;
font-size: 13px;
font-weight: 800;
}
.read-more i,
.archive-read i {
margin-left: 5px;
font-size: 11px;
}
.publication-link {
border-color: rgba(255, 253, 247, 0.2);
color: #fffdf7;
background: rgba(255, 253, 247, 0.08);
}
.publication-hero {
background:
radial-gradient(circle at 82% 30%, rgba(232, 168, 76, 0.28), transparent 25%),
linear-gradient(135deg, #172522, #376d5a);
}
.article-archive {
padding-top: clamp(36px, 5vw, 64px);
}
.archive-tools {
position: sticky;
z-index: 5;
top: 16px;
display: grid;
gap: 14px;
margin-bottom: 22px;
padding: 16px;
border: 1px solid var(--line);
border-radius: 12px;
background: rgba(255, 253, 247, 0.92);
box-shadow: 0 14px 40px rgba(39, 55, 52, 0.1);
backdrop-filter: blur(16px);
}
.article-search {
display: flex;
align-items: center;
gap: 12px;
min-height: 48px;
padding: 0 16px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
color: var(--muted);
}
.article-search:focus-within {
border-color: var(--green);
box-shadow: 0 0 0 3px rgba(55, 109, 90, 0.12);
}
.article-search input {
width: 100%;
border: 0;
outline: 0;
background: transparent;
color: var(--ink);
font: inherit;
}
.category-filters {
display: flex;
gap: 8px;
overflow-x: auto;
padding-bottom: 2px;
}
.category-filters button {
flex: 0 0 auto;
min-height: 36px;
padding: 7px 12px;
border: 1px solid var(--line);
border-radius: 999px;
background: #fff;
color: var(--muted);
font: inherit;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.category-filters button span {
margin-left: 4px;
opacity: 0.65;
}
.category-filters button.is-active {
border-color: var(--green);
background: var(--green);
color: #fff;
}
.archive-status {
margin: 0 0 18px;
color: var(--muted);
font-size: 14px;
}
.article-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.archive-card {
min-height: 220px;
display: grid;
grid-template-columns: 180px minmax(0, 1fr);
overflow: hidden;
border: 1px solid var(--line);
border-radius: 10px;
background: #fff;
box-shadow: 0 8px 28px rgba(39, 55, 52, 0.06);
transition: transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease;
}
.archive-card:hover {
transform: translateY(-3px);
border-color: rgba(55, 109, 90, 0.34);
box-shadow: 0 16px 38px rgba(39, 55, 52, 0.12);
}
.archive-card[hidden] {
display: none;
}
.archive-cover {
display: grid;
place-items: center;
}
.archive-cover > span {
color: rgba(255, 255, 255, 0.82);
font-size: 30px;
font-weight: 900;
letter-spacing: 0.1em;
}
.archive-copy {
display: flex;
flex-direction: column;
align-items: flex-start;
min-width: 0;
padding: 20px;
}
.archive-meta {
display: flex;
flex-wrap: wrap;
gap: 10px;
color: var(--muted);
font-size: 12px;
font-weight: 800;
}
.archive-meta span {
color: var(--green);
}
.archive-copy h2 {
display: -webkit-box;
overflow: hidden;
margin: 10px 0 8px;
font-size: 19px;
line-height: 1.35;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.archive-copy p {
display: -webkit-box;
overflow: hidden;
margin: 0;
color: var(--muted);
font-size: 13px;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.archive-read {
margin-top: auto;
padding-top: 14px;
color: var(--green);
font-size: 12px;
font-weight: 800;
}
.archive-empty {
padding: 80px 20px;
text-align: center;
color: var(--muted);
}
.archive-empty span {
color: var(--ink);
font-size: 22px;
font-weight: 800;
}
.archive-empty p {
margin: 8px 0 0;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
}
@media (max-width: 920px) {
.featured-articles,
.article-list {
grid-template-columns: 1fr;
}
.article-card-featured {
grid-row: auto;
}
.article-card-featured .article-cover {
min-height: 260px;
}
}
@media (max-width: 620px) {
.article-card,
.article-card-featured,
.archive-card {
grid-template-columns: 1fr;
}
.article-card .article-cover,
.article-card-featured .article-cover,
.archive-cover {
min-height: 210px;
}
.archive-tools {
top: 8px;
margin-inline: -8px;
}
}
.site-footer p {
margin: 0;
}