Files
citywalk-stamp/packages/web/src/admin/MusicForm.tsx
YANG JIANKUAN b4a0e23c7e refactor: 重构管理后台为现代化编辑风 UI 并改用模态交互
- 参考收集端落地页的奶油纸质感 + 深海蓝侧栏 + Playfair Display + 金/陶/玉配色,重塑整体视觉
- 编辑、二维码从跳转路由改为模态弹窗,新增"复制链接"快捷操作
- 抽取 Modal / Toast / QRCodeModal / PageHeader / FormPrimitives 通用基建
- 合并三份 QRCode 页面为统一组件,精简路由配置

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-19 19:18:37 +08:00

203 lines
6.4 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 Modal from "./Modal";
import { adminFetch } from "./adminApi";
import { useToast } from "./Toast";
import { Field, ErrorRow, FormFooter, HintRow, fieldCls } from "./FormPrimitives";
type Music = {
id: string;
title: string;
subtitle: string | null;
audioFile: string;
sortOrder: number;
enabled: boolean;
};
type Props = {
open: boolean;
id: string | null;
onClose: () => void;
onSaved: () => void;
};
export default function MusicForm({ open, id, onClose, onSaved }: Props) {
const toast = useToast();
const [currentId, setCurrentId] = useState<string | null>(null);
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("");
const isEdit = !!currentId;
useEffect(() => {
if (!open) return;
setCurrentId(id);
setError("");
if (!id) {
setTitle(""); setSubtitle(""); setAudioFile("");
setSortOrder(0); setEnabled(true);
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);
}
});
}, [open, id]);
const handleUpload = async (file: File) => {
if (!currentId) {
setError("请先保存后再上传音频");
return;
}
setError("");
setUploading(true);
try {
const formData = new FormData();
formData.append("audio", file);
const data = await adminFetch<{ path: string }>(`/music/${currentId}/upload`, {
method: "POST",
body: formData,
});
setAudioFile(data.path);
toast.show("音频已上传");
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "上传失败");
} finally {
setUploading(false);
}
};
const handleSave = async () => {
setError("");
if (!title.trim()) return setError("请输入标题");
setSaving(true);
try {
const payload = {
title: title.trim(),
subtitle: subtitle.trim() || undefined,
sortOrder,
enabled,
};
if (isEdit) {
await adminFetch(`/music/${currentId}`, {
method: "PUT",
body: JSON.stringify(payload),
});
toast.show("已保存");
onSaved();
onClose();
} else {
const music = await adminFetch<Music>("/music", {
method: "POST",
body: JSON.stringify(payload),
});
setCurrentId(music.id);
toast.show("已创建,现在可以上传音频");
onSaved();
}
} catch (e) {
setError(e instanceof Error ? e.message : "保存失败");
} finally {
setSaving(false);
}
};
return (
<Modal
open={open}
onClose={onClose}
size="md"
eyebrow={isEdit ? "Edit Music" : "New Music"}
title={isEdit ? "编辑音乐" : "添加音乐"}
subtitle={isEdit ? "调整信息与上传音频" : "先保存基础信息,再上传音频文件"}
>
<div className="px-7 py-6 space-y-5">
<Field label="标题" required>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="如:朝天宫之歌"
className={fieldCls}
/>
</Field>
<Field label="副标题">
<input
value={subtitle}
onChange={(e) => setSubtitle(e.target.value)}
placeholder="选填,如:金陵千年韵"
className={fieldCls}
/>
</Field>
<div className="grid grid-cols-[auto_1fr] gap-5 items-end">
<Field label="排序">
<input
type="number"
value={sortOrder}
onChange={(e) => setSortOrder(Number(e.target.value))}
className={fieldCls + " w-28"}
/>
</Field>
<label className="flex items-center gap-2.5 cursor-pointer select-none pb-2.5">
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
className="w-4 h-4 accent-[var(--jade)]"
/>
<span className="text-sm text-[var(--text-secondary)]">访访</span>
</label>
</div>
{isEdit ? (
<Field label="音频文件" hint="MP3 / M4A / WAV≤ 20 MB">
<div className="rounded-xl border border-[var(--border-muted)] bg-white p-4 space-y-3">
{audioFile ? (
<audio src={audioFile} controls preload="metadata" className="w-full" />
) : (
<div className="text-xs text-[var(--text-muted)] py-4 text-center border border-dashed border-[var(--border-muted)] rounded-lg">
</div>
)}
<label className="cursor-pointer inline-flex items-center gap-2 text-[13px] text-[var(--text-secondary)] hover:text-[var(--terracotta)] transition-colors">
<input
type="file"
accept="audio/*,.mp3,.m4a,.wav,.ogg"
onChange={(e) => e.target.files?.[0] && handleUpload(e.target.files[0])}
disabled={uploading}
className="hidden"
/>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12" />
</svg>
<span className="underline underline-offset-2 decoration-dotted">
{uploading ? "上传中…" : audioFile ? "更换音频" : "选择音频文件"}
</span>
</label>
</div>
</Field>
) : (
<HintRow text="保存基础信息后,即可上传音频文件" />
)}
{error && <ErrorRow text={error} />}
</div>
<FormFooter onCancel={onClose} onSave={handleSave} saving={saving} primaryLabel={isEdit ? "保存" : "创建"} />
</Modal>
);
}