- 新增 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>
121 lines
3.8 KiB
TypeScript
121 lines
3.8 KiB
TypeScript
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>
|
||
);
|
||
}
|