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

@@ -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;