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.
@@ -46,3 +46,19 @@ export type ArticleDetail = {
|
||||
caption: string | null;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
export type MusicSummary = {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string | null;
|
||||
audioFile: string;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
export type MusicDetail = {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string | null;
|
||||
audioFile: string;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AuthProvider } from "./lib/auth";
|
||||
import LandingPage from "./pages/LandingPage";
|
||||
import AlbumPage from "./pages/AlbumPage";
|
||||
import ArticlePage from "./pages/ArticlePage";
|
||||
import MusicPage from "./pages/MusicPage";
|
||||
import AdminLogin from "./admin/AdminLogin";
|
||||
import AdminGuard from "./admin/AdminGuard";
|
||||
import AdminLayout from "./admin/AdminLayout";
|
||||
@@ -12,6 +13,9 @@ import StampQRCode from "./admin/StampQRCode";
|
||||
import ArticleList from "./admin/ArticleList";
|
||||
import ArticleForm from "./admin/ArticleForm";
|
||||
import ArticleQRCode from "./admin/ArticleQRCode";
|
||||
import MusicList from "./admin/MusicList";
|
||||
import MusicForm from "./admin/MusicForm";
|
||||
import MusicQRCode from "./admin/MusicQRCode";
|
||||
import RuleList from "./admin/RuleList";
|
||||
import RuleForm from "./admin/RuleForm";
|
||||
import RedemptionLog from "./admin/RedemptionLog";
|
||||
@@ -30,6 +34,7 @@ export default function App() {
|
||||
<Route path="/album" element={<AlbumPage />} />
|
||||
<Route path="/collect/:stampId" element={<CollectRedirect />} />
|
||||
<Route path="/article/:id" element={<ArticlePage />} />
|
||||
<Route path="/music/:id" element={<MusicPage />} />
|
||||
|
||||
{/* Admin panel */}
|
||||
<Route path="/admin" element={<AdminLogin />} />
|
||||
@@ -43,6 +48,10 @@ export default function App() {
|
||||
<Route path="/admin/articles/new" element={<ArticleForm />} />
|
||||
<Route path="/admin/articles/:id/edit" element={<ArticleForm />} />
|
||||
<Route path="/admin/articles/:id/qrcode" element={<ArticleQRCode />} />
|
||||
<Route path="/admin/music" element={<MusicList />} />
|
||||
<Route path="/admin/music/new" element={<MusicForm />} />
|
||||
<Route path="/admin/music/:id/edit" element={<MusicForm />} />
|
||||
<Route path="/admin/music/:id/qrcode" element={<MusicQRCode />} />
|
||||
<Route path="/admin/rules" element={<RuleList />} />
|
||||
<Route path="/admin/rules/new" element={<RuleForm />} />
|
||||
<Route path="/admin/rules/:id/edit" element={<RuleForm />} />
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||
const navItems = [
|
||||
{ path: "/admin/stamps", label: "图章管理" },
|
||||
{ path: "/admin/articles", label: "文章管理" },
|
||||
{ path: "/admin/music", label: "音乐管理" },
|
||||
{ path: "/admin/rules", label: "兑换规则" },
|
||||
{ path: "/admin/redemptions", label: "兑换记录" },
|
||||
];
|
||||
|
||||
198
packages/web/src/admin/MusicForm.tsx
Normal file
198
packages/web/src/admin/MusicForm.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { adminFetch } from "./adminApi";
|
||||
|
||||
type Music = {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string | null;
|
||||
audioFile: string;
|
||||
sortOrder: number;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export default function MusicForm() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const isEdit = !!id;
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [subtitle, setSubtitle] = useState("");
|
||||
const [audioFile, setAudioFile] = useState("");
|
||||
const [sortOrder, setSortOrder] = useState(0);
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
adminFetch<Music[]>("/music").then((list) => {
|
||||
const item = list.find((m) => m.id === id);
|
||||
if (item) {
|
||||
setTitle(item.title);
|
||||
setSubtitle(item.subtitle || "");
|
||||
setAudioFile(item.audioFile);
|
||||
setSortOrder(item.sortOrder);
|
||||
setEnabled(item.enabled);
|
||||
}
|
||||
});
|
||||
}, [id]);
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
if (!id) {
|
||||
setError("请先保存后再上传音频");
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
setUploading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("audio", file);
|
||||
const data = await adminFetch<{ path: string }>(`/music/${id}/upload`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
setAudioFile(data.path);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "上传失败");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setError("");
|
||||
if (!title.trim()) {
|
||||
setError("请输入标题");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
title: title.trim(),
|
||||
subtitle: subtitle.trim() || undefined,
|
||||
sortOrder,
|
||||
enabled,
|
||||
};
|
||||
if (isEdit) {
|
||||
await adminFetch(`/music/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
} else {
|
||||
const music = await adminFetch<Music>("/music", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
navigate(`/admin/music/${music.id}/edit`, { replace: true });
|
||||
return;
|
||||
}
|
||||
navigate("/admin/music");
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-xl">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-4">
|
||||
{isEdit ? "编辑音乐" : "添加音乐"}
|
||||
</h2>
|
||||
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-5 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">标题</label>
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="如:朝天宫之歌"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm
|
||||
focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">副标题</label>
|
||||
<input
|
||||
value={subtitle}
|
||||
onChange={(e) => setSubtitle(e.target.value)}
|
||||
placeholder="选填,如:金陵千年韵"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm
|
||||
focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">排序</label>
|
||||
<input
|
||||
type="number"
|
||||
value={sortOrder}
|
||||
onChange={(e) => setSortOrder(Number(e.target.value))}
|
||||
className="w-24 px-3 py-2 border border-gray-300 rounded-md text-sm
|
||||
focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center pt-6">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
/>
|
||||
启用
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isEdit && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
音频文件
|
||||
<span className="text-xs text-gray-400 font-normal ml-2">
|
||||
支持 MP3 / M4A / WAV,≤ 20 MB
|
||||
</span>
|
||||
</label>
|
||||
{audioFile && (
|
||||
<audio src={audioFile} controls preload="metadata" className="w-full mb-2" />
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
accept="audio/*,.mp3,.m4a,.wav,.ogg"
|
||||
onChange={(e) => e.target.files?.[0] && handleUpload(e.target.files[0])}
|
||||
disabled={uploading}
|
||||
className="text-xs text-gray-500"
|
||||
/>
|
||||
{uploading && <p className="text-xs text-gray-500 mt-1">上传中…</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isEdit && (
|
||||
<p className="text-xs text-gray-400">保存后可上传音频文件</p>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md
|
||||
hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "保存中..." : "保存"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate("/admin/music")}
|
||||
className="px-4 py-2 border border-gray-300 text-gray-600 text-sm rounded-md hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
118
packages/web/src/admin/MusicList.tsx
Normal file
118
packages/web/src/admin/MusicList.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { adminFetch } from "./adminApi";
|
||||
|
||||
type Music = {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string | null;
|
||||
audioFile: string;
|
||||
sortOrder: number;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export default function MusicList() {
|
||||
const [music, setMusic] = useState<Music[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchMusic = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await adminFetch<Music[]>("/music");
|
||||
setMusic(data);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { fetchMusic(); }, []);
|
||||
|
||||
const handleDelete = async (id: string, title: string) => {
|
||||
if (!confirm(`确定删除音乐「${title}」?`)) return;
|
||||
await adminFetch(`/music/${id}`, { method: "DELETE" });
|
||||
fetchMusic();
|
||||
};
|
||||
|
||||
const handleToggle = async (id: string, enabled: boolean) => {
|
||||
await adminFetch(`/music/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ enabled: !enabled }),
|
||||
});
|
||||
fetchMusic();
|
||||
};
|
||||
|
||||
if (loading) return <p className="text-gray-500">加载中...</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-800">音乐管理</h2>
|
||||
<Link
|
||||
to="/admin/music/new"
|
||||
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
|
||||
>
|
||||
添加音乐
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">标题</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">副标题</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">音频</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-gray-600">排序</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-gray-600">状态</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-gray-600">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{music.map((item) => (
|
||||
<tr key={item.id} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-gray-800 font-medium">{item.title}</td>
|
||||
<td className="px-4 py-3 text-gray-500 max-w-[220px] truncate">{item.subtitle || "—"}</td>
|
||||
<td className="px-4 py-3 text-gray-500 text-xs">
|
||||
{item.audioFile ? (
|
||||
<audio src={item.audioFile} controls preload="none" className="h-8 max-w-[220px]" />
|
||||
) : (
|
||||
<span className="text-gray-300">未上传</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center text-gray-500">{item.sortOrder}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<button
|
||||
onClick={() => handleToggle(item.id, item.enabled)}
|
||||
className={`px-2 py-0.5 rounded text-xs ${
|
||||
item.enabled ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{item.enabled ? "启用" : "禁用"}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right space-x-2 whitespace-nowrap">
|
||||
<Link to={`/admin/music/${item.id}/edit`} className="text-blue-600 hover:underline">
|
||||
编辑
|
||||
</Link>
|
||||
<Link to={`/admin/music/${item.id}/qrcode`} className="text-blue-600 hover:underline">
|
||||
二维码
|
||||
</Link>
|
||||
<button onClick={() => handleDelete(item.id, item.title)} className="text-red-500 hover:underline">
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{music.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-gray-400">
|
||||
暂无音乐,点击右上角添加
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
packages/web/src/admin/MusicQRCode.tsx
Normal file
120
packages/web/src/admin/MusicQRCode.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import { adminFetch } from "./adminApi";
|
||||
|
||||
type QRData = {
|
||||
qrDataUrl: string;
|
||||
musicUrl: string;
|
||||
musicTitle: string;
|
||||
};
|
||||
|
||||
export default function MusicQRCode() {
|
||||
const { id } = useParams();
|
||||
const [data, setData] = useState<QRData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
adminFetch<QRData>(`/music/${id}/qrcode`)
|
||||
.then(setData)
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || !canvasRef.current) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const padding = 20;
|
||||
const textHeight = 40;
|
||||
canvas.width = img.width + padding * 2;
|
||||
canvas.height = img.height + padding * 2 + textHeight;
|
||||
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
ctx.drawImage(img, padding, padding);
|
||||
|
||||
ctx.fillStyle = "#666666";
|
||||
ctx.font = "12px sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText(data.musicUrl, canvas.width / 2, img.height + padding * 2 + 12);
|
||||
};
|
||||
img.src = data.qrDataUrl;
|
||||
}, [data]);
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!canvasRef.current || !data) return;
|
||||
const link = document.createElement("a");
|
||||
link.download = `${data.musicTitle}-二维码.png`;
|
||||
link.href = canvasRef.current.toDataURL("image/png");
|
||||
link.click();
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!data) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(data.musicUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1800);
|
||||
} catch {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = data.musicUrl;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(ta);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1800);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <p className="text-gray-500">加载中...</p>;
|
||||
if (!data) return <p className="text-gray-500">音乐不存在</p>;
|
||||
|
||||
return (
|
||||
<div className="max-w-md">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Link to="/admin/music" className="text-gray-400 hover:text-gray-600">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
</Link>
|
||||
<h2 className="text-lg font-semibold text-gray-800">{data.musicTitle} — 点位链接</h2>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6 text-center space-y-4">
|
||||
<div className="inline-block">
|
||||
<img src={data.qrDataUrl} alt="二维码" className="w-64 h-64" />
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-500 break-all select-all">{data.musicUrl}</p>
|
||||
|
||||
<canvas ref={canvasRef} className="hidden" />
|
||||
|
||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 min-w-[160px]"
|
||||
>
|
||||
{copied ? "已复制 ✓" : "复制链接(写入 NFC)"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="px-4 py-2 border border-gray-300 text-gray-700 text-sm rounded-md hover:bg-gray-50"
|
||||
>
|
||||
下载二维码
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-400 pt-1">
|
||||
点位主要通过 NFC 触碰分发,二维码作为备用方式保留
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -101,6 +101,16 @@
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes music-ripple {
|
||||
0% { transform: scale(0.9); opacity: 0.5; }
|
||||
100% { transform: scale(2.0); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes music-disc-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ===== Component Classes (in @layer components) ===== */
|
||||
@layer components {
|
||||
.safe-bottom {
|
||||
@@ -116,6 +126,54 @@
|
||||
.animate-overlay-fade { animation: overlay-fade 0.25s ease-out both; }
|
||||
.animate-float { animation: float 4s ease-in-out infinite; }
|
||||
.animate-scale-in { opacity: 0; animation: scale-in 0.5s cubic-bezier(0.22, 1, 0.36, 1) both; }
|
||||
.animate-music-ripple { animation: music-ripple 2.4s cubic-bezier(0.22, 1, 0.36, 1) infinite; }
|
||||
.animate-music-spin { animation: music-disc-spin 18s linear infinite; }
|
||||
|
||||
.music-progress {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 3px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.music-progress::-webkit-slider-runnable-track {
|
||||
height: 3px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(to right,
|
||||
var(--gold) 0%,
|
||||
var(--gold) var(--progress, 0%),
|
||||
rgba(212, 165, 116, 0.2) var(--progress, 0%),
|
||||
rgba(212, 165, 116, 0.2) 100%);
|
||||
}
|
||||
.music-progress::-moz-range-track {
|
||||
height: 3px;
|
||||
border-radius: 999px;
|
||||
background: rgba(212, 165, 116, 0.2);
|
||||
}
|
||||
.music-progress::-moz-range-progress {
|
||||
height: 3px;
|
||||
border-radius: 999px;
|
||||
background: var(--gold);
|
||||
}
|
||||
.music-progress::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--gold-light);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
margin-top: -4.5px;
|
||||
box-shadow: 0 0 8px rgba(212, 165, 116, 0.5);
|
||||
}
|
||||
.music-progress::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--gold-light);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow: 0 0 8px rgba(212, 165, 116, 0.5);
|
||||
}
|
||||
|
||||
/* Stagger children */
|
||||
.stagger-children > * {
|
||||
|
||||
276
packages/web/src/pages/MusicPage.tsx
Normal file
276
packages/web/src/pages/MusicPage.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import { apiFetch } from "../lib/api";
|
||||
|
||||
type MusicDetail = {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string | null;
|
||||
audioFile: string;
|
||||
};
|
||||
|
||||
type LoadState = "loading" | "ok" | "error";
|
||||
|
||||
function fmt(seconds: number): string {
|
||||
if (!isFinite(seconds) || seconds < 0) return "0:00";
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export default function MusicPage() {
|
||||
const { id } = useParams();
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
const [music, setMusic] = useState<MusicDetail | null>(null);
|
||||
const [state, setState] = useState<LoadState>("loading");
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [needsGesture, setNeedsGesture] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setState("loading");
|
||||
apiFetch<MusicDetail>(`/music/${id}`)
|
||||
.then((data) => {
|
||||
setMusic(data);
|
||||
setState("ok");
|
||||
})
|
||||
.catch(() => setState("error"));
|
||||
}, [id]);
|
||||
|
||||
// Try to autoplay with sound once the audio is mounted. If the browser
|
||||
// blocks it (iOS/Android usually do), surface the tap-to-play overlay.
|
||||
useEffect(() => {
|
||||
if (!music || !audioRef.current) return;
|
||||
audioRef.current.play().catch(() => setNeedsGesture(true));
|
||||
}, [music]);
|
||||
|
||||
const togglePlay = () => {
|
||||
const el = audioRef.current;
|
||||
if (!el) return;
|
||||
if (el.paused) {
|
||||
el.play().catch(() => setNeedsGesture(true));
|
||||
} else {
|
||||
el.pause();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const el = audioRef.current;
|
||||
if (!el || !duration) return;
|
||||
const t = (Number(e.target.value) / 100) * duration;
|
||||
el.currentTime = t;
|
||||
setCurrentTime(t);
|
||||
};
|
||||
|
||||
const handleUserStart = () => {
|
||||
setNeedsGesture(false);
|
||||
audioRef.current?.play().catch(() => setNeedsGesture(true));
|
||||
};
|
||||
|
||||
if (state === "loading") {
|
||||
return (
|
||||
<div className="min-h-svh flex items-center justify-center bg-[var(--bg-dark-deep)]">
|
||||
<p className="text-[var(--gold)]/70 text-sm tracking-widest">加载中…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "error" || !music) {
|
||||
return (
|
||||
<div className="min-h-svh flex flex-col items-center justify-center gap-4 px-6 bg-[var(--bg-dark-deep)]">
|
||||
<p className="text-[var(--gold-light)]/80">音乐不存在或已下架</p>
|
||||
<Link
|
||||
to="/"
|
||||
className="px-5 py-2 rounded-full bg-[var(--gold)] text-[var(--bg-dark)] text-sm hover:bg-[var(--gold-hover)]"
|
||||
>
|
||||
返回首页
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className="relative min-h-svh overflow-hidden bg-[var(--bg-dark-deep)] grain-overlay">
|
||||
{/* Ambient gradient backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
backgroundImage: `
|
||||
radial-gradient(ellipse 80% 50% at 50% 30%, rgba(212, 165, 116, 0.18) 0%, transparent 70%),
|
||||
radial-gradient(ellipse 60% 40% at 50% 100%, rgba(199, 91, 57, 0.10) 0%, transparent 70%)
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Top back */}
|
||||
<Link
|
||||
to="/"
|
||||
aria-label="返回"
|
||||
className="fixed top-4 left-4 z-20 w-10 h-10 rounded-full bg-white/5 backdrop-blur border border-white/10
|
||||
flex items-center justify-center text-[var(--gold-light)] hover:bg-white/10"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
</Link>
|
||||
|
||||
<div className="relative z-10 flex flex-col items-center justify-center min-h-svh px-6 py-16">
|
||||
{/* Header */}
|
||||
<div className="text-center animate-fade-in-up">
|
||||
<div className="inline-flex items-center gap-3 mb-6">
|
||||
<span className="block w-8 h-px bg-[var(--gold)]/50" />
|
||||
<span className="text-[var(--gold)] text-[10px] tracking-[0.4em] uppercase"
|
||||
style={{ fontFamily: "'Playfair Display', serif" }}>
|
||||
CityWalk · Song
|
||||
</span>
|
||||
<span className="block w-8 h-px bg-[var(--gold)]/50" />
|
||||
</div>
|
||||
|
||||
<h1
|
||||
className="text-[var(--text-inverted)] leading-tight mb-2"
|
||||
style={{
|
||||
fontSize: "clamp(2rem, 7vw, 2.75rem)",
|
||||
fontFamily: "'Playfair Display', serif",
|
||||
fontWeight: 700,
|
||||
letterSpacing: "-0.01em",
|
||||
}}
|
||||
>
|
||||
{music.title}
|
||||
</h1>
|
||||
|
||||
{music.subtitle && (
|
||||
<p className="text-[var(--gold-light)]/70 text-sm tracking-[0.1em]">
|
||||
{music.subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Disc */}
|
||||
<div className="relative mt-14 animate-scale-in" style={{ animationDelay: "0.2s" }}>
|
||||
{/* Ripples */}
|
||||
{playing && (
|
||||
<>
|
||||
<span
|
||||
className="absolute inset-0 rounded-full pointer-events-none animate-music-ripple"
|
||||
style={{ border: "1.5px solid rgba(212, 165, 116, 0.55)" }}
|
||||
/>
|
||||
<span
|
||||
className="absolute inset-0 rounded-full pointer-events-none animate-music-ripple"
|
||||
style={{ border: "1.5px solid rgba(212, 165, 116, 0.55)", animationDelay: "0.8s" }}
|
||||
/>
|
||||
<span
|
||||
className="absolute inset-0 rounded-full pointer-events-none animate-music-ripple"
|
||||
style={{ border: "1.5px solid rgba(212, 165, 116, 0.55)", animationDelay: "1.6s" }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Rotating dashed ring */}
|
||||
<div
|
||||
className={`absolute -inset-3 rounded-full pointer-events-none ${playing ? "animate-music-spin" : ""}`}
|
||||
style={{
|
||||
border: "1.5px dashed rgba(212, 165, 116, 0.45)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute -inset-8 rounded-full pointer-events-none"
|
||||
style={{
|
||||
border: "1px solid rgba(212, 165, 116, 0.12)",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Central disc button */}
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
aria-label={playing ? "暂停" : "播放"}
|
||||
className="relative w-56 h-56 rounded-full flex items-center justify-center transition-transform active:scale-95"
|
||||
style={{
|
||||
background: "radial-gradient(circle at 35% 30%, rgba(232, 201, 160, 0.55) 0%, rgba(212, 165, 116, 0.25) 45%, rgba(26, 26, 46, 0.9) 100%)",
|
||||
boxShadow: "0 0 60px rgba(212, 165, 116, 0.25), inset 0 1px 0 rgba(255,255,255,0.15), inset 0 -8px 24px rgba(0,0,0,0.4)",
|
||||
}}
|
||||
>
|
||||
{/* Inner darker ring for "record" feel */}
|
||||
<span className="absolute inset-6 rounded-full pointer-events-none"
|
||||
style={{
|
||||
background: "radial-gradient(circle, rgba(10,10,18,0.7) 0%, rgba(26,26,46,0.4) 60%, transparent 70%)",
|
||||
border: "1px solid rgba(212, 165, 116, 0.2)",
|
||||
}}
|
||||
/>
|
||||
{/* Play / Pause glyph */}
|
||||
{playing ? (
|
||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="currentColor" className="relative z-10 text-[var(--gold-light)] drop-shadow-[0_0_8px_rgba(212,165,116,0.6)]">
|
||||
<rect x="6" y="5" width="4" height="14" rx="1" />
|
||||
<rect x="14" y="5" width="4" height="14" rx="1" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="currentColor" className="relative z-10 text-[var(--gold-light)] drop-shadow-[0_0_8px_rgba(212,165,116,0.6)] ml-1">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress & time */}
|
||||
<div className="w-full max-w-[320px] mt-14 animate-fade-in-up" style={{ animationDelay: "0.4s" }}>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.1}
|
||||
value={progress}
|
||||
onChange={handleSeek}
|
||||
className="w-full music-progress"
|
||||
style={{ ["--progress" as string]: `${progress}%` }}
|
||||
/>
|
||||
<div className="mt-2 flex justify-between text-[11px] text-[var(--gold-light)]/70 tabular-nums tracking-wider">
|
||||
<span>{fmt(currentTime)}</span>
|
||||
<span>{fmt(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hint text */}
|
||||
<p className="mt-10 text-[var(--gold-light)]/40 text-[11px] tracking-[0.25em]">
|
||||
{playing ? "PLAYING · 轻点圆盘暂停" : "PAUSED · 轻点圆盘播放"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Hidden audio element — preload auto so it starts buffering immediately */}
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={music.audioFile}
|
||||
preload="auto"
|
||||
playsInline
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onEnded={() => setPlaying(false)}
|
||||
onTimeUpdate={(e) => setCurrentTime(e.currentTarget.currentTime)}
|
||||
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration)}
|
||||
/>
|
||||
|
||||
{/* Tap-to-play overlay when autoplay is blocked */}
|
||||
{needsGesture && (
|
||||
<div
|
||||
className="fixed inset-0 z-30 flex flex-col items-center justify-center bg-[var(--bg-dark-deep)]/85 backdrop-blur-sm animate-overlay-fade"
|
||||
onClick={handleUserStart}
|
||||
>
|
||||
<div className="text-center px-8">
|
||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-[var(--gold)]/15 border border-[var(--gold)]/50 flex items-center justify-center animate-pulse-soft">
|
||||
<svg width="34" height="34" viewBox="0 0 24 24" fill="currentColor" className="text-[var(--gold)] ml-1">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-[var(--gold-light)] text-base mb-2" style={{ fontFamily: "'Playfair Display', serif" }}>
|
||||
轻点聆听
|
||||
</p>
|
||||
<p className="text-[var(--gold-light)]/50 text-xs tracking-[0.2em]">Tap anywhere to play</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user