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:
@@ -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;
|
||||
|
||||
37
packages/server/src/routes/articles.ts
Normal file
37
packages/server/src/routes/articles.ts
Normal 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;
|
||||
Reference in New Issue
Block a user