feat: 新增静态文章模块并支持 NFC 链接分发

- 新增 Article 数据模型 + 迁移(title/subtitle/body/coverImage/caption)
- 后端:公共 /api/articles 查询接口 + 管理端 CRUD/上传/二维码
- 前端:移动端 /article/:id 阅读页(Playfair + 纸张肌理 + 首行缩进)
- Admin:新增文章管理三页(列表/表单/二维码)与侧栏入口
- 导入 6 篇点位解说词:朝天宫/七家湾/运渎/打钉巷/绒庄街/熙南里
- Admin 二维码页增加「复制链接(写入 NFC)」按钮
- 落地页步骤文案从扫码改为 NFC 触碰
- Dockerfile + entrypoint 增加 articles 图片回灌
- 修复 deploy-stamp skill 构建轮询卡住(pgrep 模式错误)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-19 18:14:41 +08:00
parent 711f422558
commit dbe8ea5460
31 changed files with 1156 additions and 27 deletions

View File

@@ -4,6 +4,7 @@ import path from "path";
import { fileURLToPath } from "url";
import authRoutes from "./routes/auth.js";
import stampRoutes from "./routes/stamps.js";
import articleRoutes from "./routes/articles.js";
import redemptionRoutes from "./routes/redemption.js";
import adminRoutes from "./routes/admin.js";
@@ -26,6 +27,7 @@ app.get("/api/health", (_req, res) => {
// User-facing routes
app.use("/api/auth", authRoutes);
app.use("/api/stamps", stampRoutes);
app.use("/api/articles", articleRoutes);
app.use("/api/redemption", redemptionRoutes);
// Admin routes

View File

@@ -194,4 +194,92 @@ router.get("/stats", async (_req, res) => {
res.json({ success: true, data: { userCount, collectionCount, redemptionCount } });
});
// ===== Articles CRUD =====
router.get("/articles", async (_req, res) => {
const articles = await prisma.article.findMany({ orderBy: { sortOrder: "asc" } });
res.json({ success: true, data: articles });
});
const articleSchema = z.object({
title: z.string().min(1, "标题不能为空"),
subtitle: z.string().optional(),
body: z.string().min(1, "正文不能为空"),
coverImage: z.string().optional(),
caption: z.string().optional(),
sortOrder: z.number().int().optional(),
enabled: z.boolean().optional(),
});
router.post("/articles", async (req, res) => {
const parsed = articleSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ success: false, error: { code: "VALIDATION", message: parsed.error.issues[0].message } });
return;
}
const article = await prisma.article.create({
data: {
title: parsed.data.title,
subtitle: parsed.data.subtitle,
body: parsed.data.body,
coverImage: parsed.data.coverImage ?? "",
caption: parsed.data.caption,
sortOrder: parsed.data.sortOrder ?? 0,
enabled: parsed.data.enabled ?? true,
},
});
res.json({ success: true, data: article });
});
router.put("/articles/:id", async (req, res) => {
const parsed = articleSchema.partial().safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ success: false, error: { code: "VALIDATION", message: parsed.error.issues[0].message } });
return;
}
const article = await prisma.article.update({
where: { id: req.params.id },
data: parsed.data,
}).catch(() => null);
if (!article) {
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "文章不存在" } });
return;
}
res.json({ success: true, data: article });
});
router.delete("/articles/:id", async (req, res) => {
await prisma.article.delete({ where: { id: req.params.id } }).catch(() => null);
res.json({ success: true, data: null });
});
router.post("/articles/:id/upload", upload.single("image"), async (req, res) => {
if (!req.file) {
res.status(400).json({ success: false, error: { code: "NO_FILE", message: "请选择图片" } });
return;
}
const imagePath = `/uploads/${req.file.filename}`;
const article = await prisma.article.update({
where: { id: req.params.id },
data: { coverImage: imagePath },
}).catch(() => null);
if (!article) {
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "文章不存在" } });
return;
}
res.json({ success: true, data: { path: imagePath } });
});
router.get("/articles/:id/qrcode", async (req, res) => {
const article = await prisma.article.findUnique({ where: { id: req.params.id } });
if (!article) {
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "文章不存在" } });
return;
}
const siteUrl = process.env.SITE_URL || "http://localhost:5173";
const articleUrl = `${siteUrl}/article/${article.id}`;
const qrDataUrl = await generateQRCodeDataURL(articleUrl, { width: 400 });
res.json({ success: true, data: { qrDataUrl, articleUrl, articleTitle: article.title } });
});
export default router;

View File

@@ -0,0 +1,37 @@
import { Router } from "express";
import { prisma } from "@stamp/shared";
const router = Router();
router.get("/", async (_req, res) => {
const articles = await prisma.article.findMany({
where: { enabled: true },
orderBy: { sortOrder: "asc" },
select: { id: true, title: true, subtitle: true, coverImage: true, sortOrder: true },
});
res.json({ success: true, data: articles });
});
router.get("/:id", async (req, res) => {
const article = await prisma.article.findUnique({
where: { id: req.params.id },
select: {
id: true,
title: true,
subtitle: true,
body: true,
coverImage: true,
caption: true,
sortOrder: true,
enabled: true,
},
});
if (!article || !article.enabled) {
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "文章不存在" } });
return;
}
const { enabled: _, ...data } = article;
res.json({ success: true, data });
});
export default router;

View File

@@ -0,0 +1,127 @@
import { prisma } from "@stamp/shared";
const articles = [
{
title: "朝天宫",
subtitle: "千年冶山,文脉绵延",
caption: "1910 年的朝天宫大成殿旧影",
body: `冶山之巅,文脉悠悠,朝天宫的岁月,始于春秋的冶铸星火。吴王夫差在此筑冶城,熔铁铸剑,炉火映照着金陵最初的城邑印记,冶山之名,自此镌刻在古都的肌理之中。
历经六朝更迭,这片土地几经变迁。东晋立寺,南朝设学,唐宋元数易其名,从道观到学宫,始终是金陵文化氤氲之地。明洪武十七年,朱元璋下诏重建,赐名"朝天宫",黄瓦朱墙间,成为皇家祈福、百官习仪的圣地,尽显大明都城的雍容气象。
清时风云变幻,太平天国战火焚毁旧日宫观,曾国藩主持重建,将其改为文庙与江宁府学,琉璃红墙里,藏起书香文脉,定格下如今的建筑格局。民国岁月,它曾承载司法印记,也见证过乱世里的文化坚守。
如今,朝天宫化作南京市博物馆,古建筑与文物相映成辉。从冶城剑火到明清宫宇,从礼仪圣地到文博殿堂,千年时光在此沉淀。一砖一瓦藏旧事,一草一木诉沧桑,它静静伫立,将金陵千年过往,妥帖珍藏,续写着古都不绝的文脉华章。`,
},
{
title: "七家湾",
subtitle: "曲巷藏史,旧说流芳",
caption: "清代江宁省城图里的七家湾",
body: `城南巷弄深处,七家湾静卧运渎之畔,名字里藏着几段缠绕数百年的金陵旧梦。元代起,这里已是人烟渐聚的市井,而真正让它声名远播的,是明初那几个交织着市井烟火与皇家风云的传说。
流传最广的是"七姓聚居说"。相传明初,陶、马、丁、姚、哈、莫、白七户人家在此结邻而居,街巷曲回如环,"湾"字便描摹了这蜿蜒肌理,七家湾之名由此诞生,成为城南一处安稳的栖居地。
更具传奇色彩的是"幸存七户说"。清末陈作霖《运渎桥道小志》载,朱元璋上元夜微行至此,见灯笼上画着"不缠足妇女怀抱西瓜",疑为讥讽大脚马皇后,龙颜大怒,下令屠尽巷中张灯人家,唯余七户未张灯者幸免于难,街巷遂得名七家湾。这则带着血腥味的传说,为巷陌添了几分悲壮色彩。
还有"七家转弯说"。老南京人常说"七家湾扛毛竹——转不过湾来",此地街巷如迷宫,每过七户人家便要转入另一条街巷,曲折迂回间,"七家湾"的名字也随着行人的脚步流传开来。
另有"从龙七将说",称朱元璋定都南京后,将七位有功的回族将领安置于此,建回回街,七姓人家在此繁衍生息,成为城南一处独特的聚居地。
岁月流转,明清时这里已是城南重要的市井街区,民国年间更添几分繁华。如今的七家湾,依旧保留着老城南的街巷肌理,那些古老传说在青石板路上静静流淌,与现代生活交融共生。一弯巷陌藏旧事,几段传奇映古今,七家湾以它独有的方式,诉说着金陵城的千年沧桑。`,
},
{
title: "运渎",
subtitle: "碧水通衢,灯影千年",
caption: "明代《南都繁会图卷》局部,运渎两岸的各种店招",
body: `秦淮之畔碧水蜿蜒运渎的岁月始于东吴赤乌三年240的凿河声中。孙权定都建业为解宫城漕运之需命左台侍御史郗俭督率民夫开凿了这条南连秦淮河、北接潮沟的人工河道成为向宫城仓城输送粮草物资的水上生命线。作为建康城内第一条人工运河它串联起长干里商埠与台城宫殿也为六朝海上贸易铺就了关键的城内通道。
六朝时期,运渎迎来鼎盛。东吴派朱应、康泰出使南海诸国,开辟长江流域的"海上丝绸之路",来自扶南、林邑等地的香料、珍宝,经石头城入秦淮,循运渎直达宫城,与江南漕粮一同在水面上往来穿梭。这条碧水通衢,见证了建康作为"海上丝路"东方起点的繁华,成为中外文明交融的重要纽带。
隋唐以降,运渎功能渐变,至明清时,河畔笪桥一带兴起笪桥灯市,成为与夫子庙齐名的秦淮灯会发源地。甘熙《白下琐言》载:"笪桥灯市,由来已久,正月初鱼龙杂沓,有银花火树之观",剪纸灯彩与彩帛花灯交相辉映,康熙南巡亦曾微服观灯,灯火映照下的运渎,成了金陵最热闹的元宵胜景。
岁月流转,运渎河道虽渐隐于市井,但其文脉绵延不绝。如今,熙南里街区复兴了笪桥灯市,重现"星河万盏,夜市千灯"的盛景。从东吴漕运要道到六朝贸易枢纽,从明清灯影胜地到今日文化地标,运渎以它独有的方式,将千年漕运史、海上贸易史与民俗文化史,静静流淌在南京的街巷之间。
一渠碧水通古今,两岸灯影映繁华,运渎的每一朵浪花,都在诉说着金陵城的水运传奇与文明交融的故事,在时光长河中,愈发清澈绵长。`,
},
{
title: "打钉巷",
subtitle: "铁铺遗音,锅贴传香",
caption: "1948 年南京市街道详图里的打钉巷",
body: `城南窄巷,烟火绵延,打钉巷的岁月,始于明代"金陵十八坊"的铁砧声中。朱元璋定都南京,"百工各有区肆",铁匠们在此聚集,叮叮当当的敲打声终日不绝,打钉巷之名,自此嵌入老城南的肌理。两百余米的街巷,西接七家湾,东连评事街,成为城南手工业与市井生活的交汇点。
明清更迭,铁铺渐稀,回民聚居于此,清真文化悄然扎根。清乾隆年间,草桥清真寺在巷中兴起,成为回民精神家园。民国时期,这里人口稠密,商肆林立,李记清真馆的前身"李洪兴牛坊"在此营生,草桥扁食店的技艺也于此时传承。铁匠的锤声虽淡,市井的烟火却愈发浓郁。
岁月流转,打钉巷成了南京清真美食的地标。李记清真馆扎根打钉巷 1 号,百年技艺代代相传,招牌牛肉锅贴外酥里嫩,汁水丰盈,获米其林必比登推荐,成为南京人清晨排队的念想。几步之遥的草桥清真牛肉锅贴扁食店,坚守传统风味,扁食与锅贴并香,是老南京心中的地道滋味。两家老店,一巷双璧,共同续写着打钉巷的美食传奇。
如今,打钉巷依旧保留着老城南的街巷肌理,青砖黛瓦间,铁铺遗韵与美食芬芳交融。李记门前长队不绝,草桥店内烟火氤氲,一口锅贴,一碗扁食,藏着百年匠心与市井温情。铁砧声远,滋味长存,这条古巷以舌尖上的传承,诉说着金陵城的人间烟火,在岁月长河中,愈发醇厚绵长。`,
},
{
title: "绒庄街",
subtitle: "丝绒映彩,锦韵流长",
caption: "非遗绒花",
body: `城南古巷,经纬交织,绒庄街的岁月,始于明代"帽儿行"的叫卖声中。朱元璋定都南京,丝织业兴起,这里聚集了众多制帽商户,因"帽儿行"谐音"冇儿行"犯了忌讳,街上大户绒庄老板刘万丰牵头改名,"绒庄街"之名自此嵌入老城南的肌理。百余米的街巷,东接南捕厅,西连绫庄巷,成为城南丝织业的核心枢纽。
明清之际,南京丝织业达至鼎盛,绒庄街成了绒缎批发集散地,《运渎桥道小志》载:"绒庄街,以绒庄得名"。街面两侧绒庄林立,大户临街设店,后屋为坊,蚕丝经打线、染色、起绒,化作华美的绒缎。太平天国时期,绣花馆设于此处,中兴源丝织厂早期也在此营生,成为南京最早的近代工厂之一。这里的绒花技艺更是独树一帜,以蚕丝染色成绒,钢丝勾条制作,谐音"荣华",成了宫廷与民间的吉祥饰物。
影视光影,唤醒非遗记忆。《延禧攻略》中富察皇后"不御珠翠,惟插通草绒花"的清雅形象,《如懿传》里李玉赠予惢心的那支绒花信物,皆出自南京绒花非遗传承人赵树宪之手。这些剧中的绒花,正是绒庄街百年技艺的延续——参照故宫清代皇室发饰收藏,经打绒、传丝、劈丝、勾条等数十道工序,方才绽放出"荣华高升"的美好寓意。2018 年剧集热播,让这条古巷的柔软记忆,随荧幕传遍四海。
岁月流转,绒庄街依旧保留着老城南的街巷肌理,市井巷陌间,丝绒芬芳与影视传奇交融。如今,熙南里街区复兴了绒花技艺,非遗工作室里,蚕丝与铜条在匠人的指尖"变身"成五彩花朵,重现"荣华"之美。从明代帽行到明清绒庄,从太平天国绣馆到今日影视非遗,绒庄街以它独有的方式,将千年丝织史、民俗文化史与影视传播史,静静编织在南京的街巷之间。
一缕丝绒连古今,半巷荣华映光影,绒庄街的每一寸土地,都在诉说着金陵城的丝绸传奇与匠心传承的故事,在时光长河中,愈发柔软绵长。`,
},
{
title: "熙南里",
subtitle: "古巷新声,宅韵悠远",
caption: "号称\"九十九间半\"的甘熙故居俯瞰",
body: `老城南的市井风华,尽数凝萃于熙南里的街巷之间,这片依循南都繁会图景焕新的街区,承载着金陵数百年的人居烟火与人文底蕴,在时代更迭中始终守着城南的根脉与风骨。
清代中期,藏书家甘熙于此营建宅第,这座被称作"九十九间半"的甘熙故居,依巷而筑、格局雅致,是金陵传统民居的经典代表,也成为熙南里的文化核心。宅院之中藏着书香与民俗,见证着城南世家的风雅日常,也留存下老南京的生活印记与民俗技艺,历经岁月洗礼,依旧诉说着旧时城南的人文盛景。
与甘熙故居相依的大板巷,古称习艺街,自古便是匠作云集、市井喧闹之地,街巷串联起周边阡陌巷弄,是老城南商贸与生活的重要脉络。明清至民国年间,大板巷商号林立、人声熙攘,织就出一派鲜活的城南市井画卷,街巷的肌理与烟火气,也就此深深烙印在熙南里的岁月之中。
时光流转,熙南里在保留原有街巷格局与历史遗存的基础上,让古老街区焕发新生。传统民居的雅致底蕴与现代生活的鲜活气息在此相融,甘熙故居里的民俗展演延续着非遗文脉,大板巷间的新潮业态与创意小店则为古巷注入年轻活力。
昔日的城南市井盛景在此重现,非遗技艺、文创潮品、市井烟火交织共生,没有生硬的复刻,只有自然的传承与新生。从清代宅第的书香雅致,到古巷老街的市井喧闹,再到如今古今交融的文化街区,熙南里始终扎根城南土壤,将历史底蕴与当代生活温柔联结。
一宅藏旧事,一巷纳新风,熙南里以温润的姿态,留住老城南的烟火与风雅,让金陵的人文记忆在街巷间缓缓流淌,在岁月更迭中,书写着属于城南的崭新篇章。`,
},
];
async function seed() {
console.log("Seeding articles...");
await prisma.article.deleteMany();
const created = [];
for (let i = 0; i < articles.length; i++) {
const a = articles[i];
const pos = String(i + 1).padStart(2, "0");
const article = await prisma.article.create({
data: {
title: a.title,
subtitle: a.subtitle,
body: a.body,
caption: a.caption,
coverImage: `/uploads/articles/article-${pos}.jpg`,
sortOrder: i + 1,
enabled: true,
},
});
created.push(article);
}
console.log(`Created ${created.length} articles`);
console.log("\nArticle IDs for testing:");
created.forEach((a) => {
console.log(` ${a.sortOrder}. ${a.title}: /article/${a.id}`);
});
console.log("\nSeed complete!");
}
seed()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());