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

@@ -37,6 +37,9 @@ RUN mkdir -p /app/stamps-seed \
RUN mkdir -p /app/articles-seed \ RUN mkdir -p /app/articles-seed \
&& cp packages/server/uploads/articles/*.jpg /app/articles-seed/ \ && cp packages/server/uploads/articles/*.jpg /app/articles-seed/ \
&& rm -rf packages/server/uploads/articles && rm -rf packages/server/uploads/articles
RUN mkdir -p /app/music-seed \
&& cp packages/server/uploads/music/* /app/music-seed/ 2>/dev/null || true \
&& rm -rf packages/server/uploads/music
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh RUN chmod +x /usr/local/bin/entrypoint.sh

View File

@@ -6,6 +6,7 @@ cd /app
mkdir -p /app/data mkdir -p /app/data
mkdir -p /app/packages/server/uploads/stamps mkdir -p /app/packages/server/uploads/stamps
mkdir -p /app/packages/server/uploads/articles mkdir -p /app/packages/server/uploads/articles
mkdir -p /app/packages/server/uploads/music
# Seed stamp assets on first run (idempotent) # Seed stamp assets on first run (idempotent)
if [ -z "$(ls -A /app/packages/server/uploads/stamps 2>/dev/null)" ]; then if [ -z "$(ls -A /app/packages/server/uploads/stamps 2>/dev/null)" ]; then
@@ -19,6 +20,12 @@ if [ -z "$(ls -A /app/packages/server/uploads/articles 2>/dev/null)" ]; then
cp /app/articles-seed/*.jpg /app/packages/server/uploads/articles/ 2>/dev/null || true cp /app/articles-seed/*.jpg /app/packages/server/uploads/articles/ 2>/dev/null || true
fi fi
# Seed music assets on first run (idempotent)
if [ -z "$(ls -A /app/packages/server/uploads/music 2>/dev/null)" ]; then
echo "→ Seeding music assets into uploads volume..."
cp /app/music-seed/* /app/packages/server/uploads/music/ 2>/dev/null || true
fi
echo "→ Applying database migrations..." echo "→ Applying database migrations..."
pnpm exec prisma migrate deploy pnpm exec prisma migrate deploy

View File

@@ -9,7 +9,8 @@
"db:migrate": "prisma migrate dev", "db:migrate": "prisma migrate dev",
"db:push": "prisma db push", "db:push": "prisma db push",
"db:seed": "pnpm --filter @stamp/server seed", "db:seed": "pnpm --filter @stamp/server seed",
"db:seed-articles": "pnpm --filter @stamp/server seed-articles" "db:seed-articles": "pnpm --filter @stamp/server seed-articles",
"db:seed-music": "pnpm --filter @stamp/server seed-music"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"

View File

@@ -8,7 +8,8 @@
"build": "tsc", "build": "tsc",
"start": "node dist/index.js", "start": "node dist/index.js",
"seed": "tsx src/seed.ts", "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": { "dependencies": {
"@stamp/shared": "workspace:*", "@stamp/shared": "workspace:*",

View File

@@ -5,6 +5,7 @@ import { fileURLToPath } from "url";
import authRoutes from "./routes/auth.js"; import authRoutes from "./routes/auth.js";
import stampRoutes from "./routes/stamps.js"; import stampRoutes from "./routes/stamps.js";
import articleRoutes from "./routes/articles.js"; import articleRoutes from "./routes/articles.js";
import musicRoutes from "./routes/music.js";
import redemptionRoutes from "./routes/redemption.js"; import redemptionRoutes from "./routes/redemption.js";
import adminRoutes from "./routes/admin.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/auth", authRoutes);
app.use("/api/stamps", stampRoutes); app.use("/api/stamps", stampRoutes);
app.use("/api/articles", articleRoutes); app.use("/api/articles", articleRoutes);
app.use("/api/music", musicRoutes);
app.use("/api/redemption", redemptionRoutes); app.use("/api/redemption", redemptionRoutes);
// Admin routes // Admin routes

View File

@@ -18,6 +18,7 @@ const storage = multer.diskStorage({
}, },
}); });
const upload = multer({ storage, limits: { fileSize: 5 * 1024 * 1024 } }); const upload = multer({ storage, limits: { fileSize: 5 * 1024 * 1024 } });
const audioUpload = multer({ storage, limits: { fileSize: 20 * 1024 * 1024 } });
const router = Router(); const router = Router();
router.use(requireAdmin); 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 } }); 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; 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;

View 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());

Binary file not shown.

View File

@@ -46,3 +46,19 @@ export type ArticleDetail = {
caption: string | null; caption: string | null;
sortOrder: number; 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;
};

View File

@@ -3,6 +3,7 @@ import { AuthProvider } from "./lib/auth";
import LandingPage from "./pages/LandingPage"; import LandingPage from "./pages/LandingPage";
import AlbumPage from "./pages/AlbumPage"; import AlbumPage from "./pages/AlbumPage";
import ArticlePage from "./pages/ArticlePage"; import ArticlePage from "./pages/ArticlePage";
import MusicPage from "./pages/MusicPage";
import AdminLogin from "./admin/AdminLogin"; import AdminLogin from "./admin/AdminLogin";
import AdminGuard from "./admin/AdminGuard"; import AdminGuard from "./admin/AdminGuard";
import AdminLayout from "./admin/AdminLayout"; import AdminLayout from "./admin/AdminLayout";
@@ -12,6 +13,9 @@ import StampQRCode from "./admin/StampQRCode";
import ArticleList from "./admin/ArticleList"; import ArticleList from "./admin/ArticleList";
import ArticleForm from "./admin/ArticleForm"; import ArticleForm from "./admin/ArticleForm";
import ArticleQRCode from "./admin/ArticleQRCode"; 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 RuleList from "./admin/RuleList";
import RuleForm from "./admin/RuleForm"; import RuleForm from "./admin/RuleForm";
import RedemptionLog from "./admin/RedemptionLog"; import RedemptionLog from "./admin/RedemptionLog";
@@ -30,6 +34,7 @@ export default function App() {
<Route path="/album" element={<AlbumPage />} /> <Route path="/album" element={<AlbumPage />} />
<Route path="/collect/:stampId" element={<CollectRedirect />} /> <Route path="/collect/:stampId" element={<CollectRedirect />} />
<Route path="/article/:id" element={<ArticlePage />} /> <Route path="/article/:id" element={<ArticlePage />} />
<Route path="/music/:id" element={<MusicPage />} />
{/* Admin panel */} {/* Admin panel */}
<Route path="/admin" element={<AdminLogin />} /> <Route path="/admin" element={<AdminLogin />} />
@@ -43,6 +48,10 @@ export default function App() {
<Route path="/admin/articles/new" element={<ArticleForm />} /> <Route path="/admin/articles/new" element={<ArticleForm />} />
<Route path="/admin/articles/:id/edit" element={<ArticleForm />} /> <Route path="/admin/articles/:id/edit" element={<ArticleForm />} />
<Route path="/admin/articles/:id/qrcode" element={<ArticleQRCode />} /> <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" element={<RuleList />} />
<Route path="/admin/rules/new" element={<RuleForm />} /> <Route path="/admin/rules/new" element={<RuleForm />} />
<Route path="/admin/rules/:id/edit" element={<RuleForm />} /> <Route path="/admin/rules/:id/edit" element={<RuleForm />} />

View File

@@ -3,6 +3,7 @@ import { NavLink, Outlet, useNavigate } from "react-router-dom";
const navItems = [ const navItems = [
{ path: "/admin/stamps", label: "图章管理" }, { path: "/admin/stamps", label: "图章管理" },
{ path: "/admin/articles", label: "文章管理" }, { path: "/admin/articles", label: "文章管理" },
{ path: "/admin/music", label: "音乐管理" },
{ path: "/admin/rules", label: "兑换规则" }, { path: "/admin/rules", label: "兑换规则" },
{ path: "/admin/redemptions", label: "兑换记录" }, { path: "/admin/redemptions", label: "兑换记录" },
]; ];

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -101,6 +101,16 @@
to { opacity: 1; transform: scale(1); } 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) ===== */ /* ===== Component Classes (in @layer components) ===== */
@layer components { @layer components {
.safe-bottom { .safe-bottom {
@@ -116,6 +126,54 @@
.animate-overlay-fade { animation: overlay-fade 0.25s ease-out both; } .animate-overlay-fade { animation: overlay-fade 0.25s ease-out both; }
.animate-float { animation: float 4s ease-in-out infinite; } .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-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 */
.stagger-children > * { .stagger-children > * {

View 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>
);
}

View File

@@ -0,0 +1,11 @@
-- CreateTable
CREATE TABLE "Music" (
"id" TEXT NOT NULL PRIMARY KEY,
"title" TEXT NOT NULL,
"subtitle" TEXT,
"audioFile" TEXT NOT NULL DEFAULT '',
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);

View File

@@ -79,3 +79,14 @@ model Article {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model Music {
id String @id @default(uuid())
title String
subtitle String?
audioFile String @default("")
sortOrder Int @default(0)
enabled Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}