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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user