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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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:*",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
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;
|
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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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 />} />
|
||||||
|
|||||||
@@ -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: "兑换记录" },
|
||||||
];
|
];
|
||||||
|
|||||||
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); }
|
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 > * {
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user