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:
@@ -8,7 +8,8 @@
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"seed": "tsx src/seed.ts",
|
||||
"seed-articles": "tsx src/seed-articles.ts"
|
||||
"seed-articles": "tsx src/seed-articles.ts",
|
||||
"seed-music": "tsx src/seed-music.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@stamp/shared": "workspace:*",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { fileURLToPath } from "url";
|
||||
import authRoutes from "./routes/auth.js";
|
||||
import stampRoutes from "./routes/stamps.js";
|
||||
import articleRoutes from "./routes/articles.js";
|
||||
import musicRoutes from "./routes/music.js";
|
||||
import redemptionRoutes from "./routes/redemption.js";
|
||||
import adminRoutes from "./routes/admin.js";
|
||||
|
||||
@@ -28,6 +29,7 @@ app.get("/api/health", (_req, res) => {
|
||||
app.use("/api/auth", authRoutes);
|
||||
app.use("/api/stamps", stampRoutes);
|
||||
app.use("/api/articles", articleRoutes);
|
||||
app.use("/api/music", musicRoutes);
|
||||
app.use("/api/redemption", redemptionRoutes);
|
||||
|
||||
// Admin routes
|
||||
|
||||
@@ -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;
|
||||
|
||||
35
packages/server/src/routes/music.ts
Normal file
35
packages/server/src/routes/music.ts
Normal 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;
|
||||
45
packages/server/src/seed-music.ts
Normal file
45
packages/server/src/seed-music.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { prisma } from "@stamp/shared";
|
||||
|
||||
const tracks = [
|
||||
{
|
||||
title: "朝天宫之歌",
|
||||
subtitle: "金陵千年韵",
|
||||
audioFile: "/uploads/music/chaotiangong.m4a",
|
||||
},
|
||||
];
|
||||
|
||||
async function seed() {
|
||||
console.log("Seeding music...");
|
||||
|
||||
await prisma.music.deleteMany();
|
||||
|
||||
const created = [];
|
||||
for (let i = 0; i < tracks.length; i++) {
|
||||
const t = tracks[i];
|
||||
const music = await prisma.music.create({
|
||||
data: {
|
||||
title: t.title,
|
||||
subtitle: t.subtitle,
|
||||
audioFile: t.audioFile,
|
||||
sortOrder: i + 1,
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
created.push(music);
|
||||
}
|
||||
|
||||
console.log(`Created ${created.length} music track(s)`);
|
||||
console.log("\nMusic IDs for testing:");
|
||||
created.forEach((m) => {
|
||||
console.log(` ${m.sortOrder}. ${m.title}: /music/${m.id}`);
|
||||
});
|
||||
|
||||
console.log("\nSeed complete!");
|
||||
}
|
||||
|
||||
seed()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
BIN
packages/server/uploads/music/chaotiangong.m4a
Normal file
BIN
packages/server/uploads/music/chaotiangong.m4a
Normal file
Binary file not shown.
Reference in New Issue
Block a user