diff --git a/Dockerfile b/Dockerfile index 07c561d..774568c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,6 +37,9 @@ RUN mkdir -p /app/stamps-seed \ RUN mkdir -p /app/articles-seed \ && cp packages/server/uploads/articles/*.jpg /app/articles-seed/ \ && 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 RUN chmod +x /usr/local/bin/entrypoint.sh diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 1788e2f..8ae8552 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -6,6 +6,7 @@ cd /app mkdir -p /app/data mkdir -p /app/packages/server/uploads/stamps mkdir -p /app/packages/server/uploads/articles +mkdir -p /app/packages/server/uploads/music # Seed stamp assets on first run (idempotent) 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 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..." pnpm exec prisma migrate deploy diff --git a/package.json b/package.json index dc052d7..a1e6cdb 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "db:migrate": "prisma migrate dev", "db:push": "prisma db push", "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": { "node": ">=20" diff --git a/packages/server/package.json b/packages/server/package.json index 392fa6b..b587a4d 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -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:*", diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 2aa04d2..851ea01 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -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 diff --git a/packages/server/src/routes/admin.ts b/packages/server/src/routes/admin.ts index 8726d37..c528871 100644 --- a/packages/server/src/routes/admin.ts +++ b/packages/server/src/routes/admin.ts @@ -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; diff --git a/packages/server/src/routes/music.ts b/packages/server/src/routes/music.ts new file mode 100644 index 0000000..bf7f7d0 --- /dev/null +++ b/packages/server/src/routes/music.ts @@ -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; diff --git a/packages/server/src/seed-music.ts b/packages/server/src/seed-music.ts new file mode 100644 index 0000000..7910315 --- /dev/null +++ b/packages/server/src/seed-music.ts @@ -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()); diff --git a/packages/server/uploads/music/chaotiangong.m4a b/packages/server/uploads/music/chaotiangong.m4a new file mode 100644 index 0000000..ec30978 Binary files /dev/null and b/packages/server/uploads/music/chaotiangong.m4a differ diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 608a99c..4313da1 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -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; +}; diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index b20f184..20e79aa 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -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() { } /> } /> } /> + } /> {/* Admin panel */} } /> @@ -43,6 +48,10 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/packages/web/src/admin/AdminLayout.tsx b/packages/web/src/admin/AdminLayout.tsx index 055fc33..c7f62b0 100644 --- a/packages/web/src/admin/AdminLayout.tsx +++ b/packages/web/src/admin/AdminLayout.tsx @@ -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: "兑换记录" }, ]; diff --git a/packages/web/src/admin/MusicForm.tsx b/packages/web/src/admin/MusicForm.tsx new file mode 100644 index 0000000..036c0ce --- /dev/null +++ b/packages/web/src/admin/MusicForm.tsx @@ -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").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", { + 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 ( +
+

+ {isEdit ? "编辑音乐" : "添加音乐"} +

+ +
+
+ + 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" + /> +
+ +
+ + 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" + /> +
+ +
+
+ + 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" + /> +
+
+ +
+
+ + {isEdit && ( +
+ + {audioFile && ( +
+ )} + + {!isEdit && ( +

保存后可上传音频文件

+ )} + + {error &&

{error}

} + +
+ + +
+
+
+ ); +} diff --git a/packages/web/src/admin/MusicList.tsx b/packages/web/src/admin/MusicList.tsx new file mode 100644 index 0000000..f1c7bb2 --- /dev/null +++ b/packages/web/src/admin/MusicList.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + + const fetchMusic = async () => { + setLoading(true); + try { + const data = await adminFetch("/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

加载中...

; + + return ( +
+
+

音乐管理

+ + 添加音乐 + +
+ +
+ + + + + + + + + + + + + {music.map((item) => ( + + + + + + + + + ))} + {music.length === 0 && ( + + + + )} + +
标题副标题音频排序状态操作
{item.title}{item.subtitle || "—"} + {item.audioFile ? ( + {item.sortOrder} + + + + 编辑 + + + 二维码 + + +
+ 暂无音乐,点击右上角添加 +
+
+
+ ); +} diff --git a/packages/web/src/admin/MusicQRCode.tsx b/packages/web/src/admin/MusicQRCode.tsx new file mode 100644 index 0000000..b096a10 --- /dev/null +++ b/packages/web/src/admin/MusicQRCode.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [copied, setCopied] = useState(false); + const canvasRef = useRef(null); + + useEffect(() => { + if (!id) return; + adminFetch(`/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

加载中...

; + if (!data) return

音乐不存在

; + + return ( +
+
+ + + + + +

{data.musicTitle} — 点位链接

+
+ +
+
+ 二维码 +
+ +

{data.musicUrl}

+ + + +
+ + +
+ +

+ 点位主要通过 NFC 触碰分发,二维码作为备用方式保留 +

+
+
+ ); +} diff --git a/packages/web/src/index.css b/packages/web/src/index.css index 0298840..cd9dfef 100644 --- a/packages/web/src/index.css +++ b/packages/web/src/index.css @@ -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 > * { diff --git a/packages/web/src/pages/MusicPage.tsx b/packages/web/src/pages/MusicPage.tsx new file mode 100644 index 0000000..3bcb081 --- /dev/null +++ b/packages/web/src/pages/MusicPage.tsx @@ -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(null); + + const [music, setMusic] = useState(null); + const [state, setState] = useState("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(`/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) => { + 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 ( +
+

加载中…

+
+ ); + } + + if (state === "error" || !music) { + return ( +
+

音乐不存在或已下架

+ + 返回首页 + +
+ ); + } + + const progress = duration > 0 ? (currentTime / duration) * 100 : 0; + + return ( +
+ {/* Ambient gradient backdrop */} +
+ + {/* Top back */} + + + + + + +
+ {/* Header */} +
+
+ + + CityWalk · Song + + +
+ +

+ {music.title} +

+ + {music.subtitle && ( +

+ {music.subtitle} +

+ )} +
+ + {/* Disc */} +
+ {/* Ripples */} + {playing && ( + <> + + + + + )} + + {/* Rotating dashed ring */} +
+
+ + {/* Central disc button */} + +
+ + {/* Progress & time */} +
+ +
+ {fmt(currentTime)} + {fmt(duration)} +
+
+ + {/* Hint text */} +

+ {playing ? "PLAYING · 轻点圆盘暂停" : "PAUSED · 轻点圆盘播放"} +

+
+ + {/* Hidden audio element — preload auto so it starts buffering immediately */} +
+ ); +} diff --git a/prisma/migrations/20260419101832_add_music_model/migration.sql b/prisma/migrations/20260419101832_add_music_model/migration.sql new file mode 100644 index 0000000..3d3ee06 --- /dev/null +++ b/prisma/migrations/20260419101832_add_music_model/migration.sql @@ -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 +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index daa661c..dd90e23 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -79,3 +79,14 @@ model Article { createdAt DateTime @default(now()) 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 +}