Files
citywalk-stamp/packages/web/src/admin/MusicForm.tsx
YANG JIANKUAN ae63cb1d85 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>
2026-04-19 18:37:44 +08:00

199 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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