feat: 新增音乐播放模块

- 新增 Music 数据模型 + 迁移(title/subtitle/audioFile)
- 后端:公共 /api/music 查询接口 + 管理端 CRUD
  (音频上传专用 multer,限制 20MB)
- 移动端 /music/:id 播放页:
  - 金色印章式唱片 + 旋转虚线环 + 三重金色涟漪
  - preload=auto + HTTP Range 流式加载
  - 浏览器禁止 autoplay 时显示「轻点聆听」overlay
  - 自定义进度条与时间显示
- Admin:新增音乐管理三页(列表/表单/二维码)与侧栏入口
- 导入示例音乐:朝天宫之歌
- Dockerfile + entrypoint 增加 music 资产回灌

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-19 18:37:44 +08:00
parent dbe8ea5460
commit ae63cb1d85
19 changed files with 999 additions and 2 deletions

View File

@@ -18,6 +18,7 @@ const storage = multer.diskStorage({
},
});
const upload = multer({ storage, limits: { fileSize: 5 * 1024 * 1024 } });
const audioUpload = multer({ storage, limits: { fileSize: 20 * 1024 * 1024 } });
const router = Router();
router.use(requireAdmin);
@@ -282,4 +283,88 @@ router.get("/articles/:id/qrcode", async (req, res) => {
res.json({ success: true, data: { qrDataUrl, articleUrl, articleTitle: article.title } });
});
// ===== Music CRUD =====
router.get("/music", async (_req, res) => {
const music = await prisma.music.findMany({ orderBy: { sortOrder: "asc" } });
res.json({ success: true, data: music });
});
const musicSchema = z.object({
title: z.string().min(1, "标题不能为空"),
subtitle: z.string().optional(),
audioFile: z.string().optional(),
sortOrder: z.number().int().optional(),
enabled: z.boolean().optional(),
});
router.post("/music", async (req, res) => {
const parsed = musicSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ success: false, error: { code: "VALIDATION", message: parsed.error.issues[0].message } });
return;
}
const music = await prisma.music.create({
data: {
title: parsed.data.title,
subtitle: parsed.data.subtitle,
audioFile: parsed.data.audioFile ?? "",
sortOrder: parsed.data.sortOrder ?? 0,
enabled: parsed.data.enabled ?? true,
},
});
res.json({ success: true, data: music });
});
router.put("/music/:id", async (req, res) => {
const parsed = musicSchema.partial().safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ success: false, error: { code: "VALIDATION", message: parsed.error.issues[0].message } });
return;
}
const music = await prisma.music.update({
where: { id: req.params.id },
data: parsed.data,
}).catch(() => null);
if (!music) {
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "音乐不存在" } });
return;
}
res.json({ success: true, data: music });
});
router.delete("/music/:id", async (req, res) => {
await prisma.music.delete({ where: { id: req.params.id } }).catch(() => null);
res.json({ success: true, data: null });
});
router.post("/music/:id/upload", audioUpload.single("audio"), async (req, res) => {
if (!req.file) {
res.status(400).json({ success: false, error: { code: "NO_FILE", message: "请选择音频文件" } });
return;
}
const audioPath = `/uploads/${req.file.filename}`;
const music = await prisma.music.update({
where: { id: req.params.id },
data: { audioFile: audioPath },
}).catch(() => null);
if (!music) {
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "音乐不存在" } });
return;
}
res.json({ success: true, data: { path: audioPath } });
});
router.get("/music/:id/qrcode", async (req, res) => {
const music = await prisma.music.findUnique({ where: { id: req.params.id } });
if (!music) {
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "音乐不存在" } });
return;
}
const siteUrl = process.env.SITE_URL || "http://localhost:5173";
const musicUrl = `${siteUrl}/music/${music.id}`;
const qrDataUrl = await generateQRCodeDataURL(musicUrl, { width: 400 });
res.json({ success: true, data: { qrDataUrl, musicUrl, musicTitle: music.title } });
});
export default router;

View File

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