import { useState, type FormEvent } from 'react'; import { errorMessage } from '../api/client'; import { createSong, deleteSong, listSongs, updateSong, uploadSongAudio } from '../api/endpoints'; import type { PublishStatus, Song, SongCreate, SourceType } from '../api/types'; import { SONG_LANGUAGES } from '../api/types'; import { formatDuration, formatNumber, parseTags, tagsToText } from '../lib/format'; import { LANGUAGE_LABELS, STATUS_OPTIONS } from '../lib/labels'; import { hasErrors, parseOptionalNumber, requireText, validateSourceFields, type FieldErrors } from '../lib/validation'; import { useAsync } from '../lib/useAsync'; import { ConfirmDialog, Empty, ErrorBox, Field, Loading, Modal, Notice, Pagination, SourceCell, SourceFieldsForm, StatusBadge, type SourceFormValue, } from '../components/ui'; const PAGE_SIZE = 20; interface SongFilters { q: string; status: PublishStatus | ''; artist: string; page: number; } interface SongFormState extends SourceFormValue { title: string; artist: string; album: string; language: string; durationSec: string; bpm: string; difficulty: string; tags: string; status: PublishStatus; } const EMPTY_SONG_FORM: SongFormState = { title: '', artist: '', album: '', language: '', durationSec: '', bpm: '', difficulty: '', tags: '', status: 'draft', sourceType: '', sourceName: '', sourceUrl: '', license: '', author: '', }; function songToForm(song: Song): SongFormState { return { title: song.title, artist: song.artist, album: song.album ?? '', language: song.language ?? '', durationSec: song.durationMs === null || song.durationMs === undefined ? '' : String(song.durationMs / 1000), bpm: song.bpm === null || song.bpm === undefined ? '' : String(song.bpm), difficulty: song.difficulty === null || song.difficulty === undefined ? '' : String(song.difficulty), tags: tagsToText(song.tags), status: song.status, sourceType: song.sourceType, sourceName: song.sourceName, sourceUrl: song.sourceUrl, license: song.license, author: song.author, }; } function validateSongForm(form: SongFormState): FieldErrors { const errors: FieldErrors = {}; requireText(form.title, 'title', '曲名', errors, 200); requireText(form.artist, 'artist', '演唱者', errors, 200); if (form.album.trim().length > 200) errors.album = '专辑不超过 200 字'; const seconds = parseOptionalNumber(form.durationSec); if (seconds !== undefined && (!Number.isFinite(seconds) || seconds <= 0)) { errors.durationSec = '时长需为正数(秒)'; } const bpm = parseOptionalNumber(form.bpm); if (bpm !== undefined && (!Number.isFinite(bpm) || bpm <= 0 || bpm > 400)) { errors.bpm = 'BPM 需为 0–400 之间的数字'; } if (form.difficulty !== '') { const difficulty = Number(form.difficulty); if (!Number.isInteger(difficulty) || difficulty < 1 || difficulty > 5) { errors.difficulty = '难度为 1–5'; } } validateSourceFields(form, errors); return errors; } function toSongPayload(form: SongFormState): SongCreate { if (form.sourceType === '') { // 已被 validateSongForm 拦截;此处仅为类型收窄。 throw new Error('请选择来源类型 sourceType'); } const sourceType: SourceType = form.sourceType; const payload: SongCreate = { title: form.title.trim(), artist: form.artist.trim(), status: form.status, sourceType, sourceName: form.sourceName.trim(), sourceUrl: form.sourceUrl.trim(), license: form.license.trim(), author: form.author.trim(), }; if (form.album.trim() !== '') payload.album = form.album.trim(); if (form.language !== '') payload.language = form.language; const seconds = parseOptionalNumber(form.durationSec); if (seconds !== undefined && Number.isFinite(seconds) && seconds > 0) { payload.durationMs = Math.round(seconds * 1000); } const bpm = parseOptionalNumber(form.bpm); if (bpm !== undefined && Number.isFinite(bpm)) payload.bpm = bpm; if (form.difficulty !== '') payload.difficulty = Number(form.difficulty); const tags = parseTags(form.tags); if (tags.length > 0) payload.tags = tags; return payload; } /* --------------------------------------------------------------- 表单弹窗 */ function SongFormModal({ song, onClose, onSaved, }: { song: Song | null; onClose: () => void; onSaved: (message: string) => void; }) { const [form, setForm] = useState(song ? songToForm(song) : EMPTY_SONG_FORM); const [errors, setErrors] = useState({}); const [saving, setSaving] = useState(false); const [serverError, setServerError] = useState(null); const patch = (part: Partial): void => { setForm((previous) => ({ ...previous, ...part })); }; const onSubmit = async (event: FormEvent): Promise => { event.preventDefault(); const nextErrors = validateSongForm(form); setErrors(nextErrors); if (hasErrors(nextErrors)) return; setSaving(true); setServerError(null); try { const payload = toSongPayload(form); if (song) { await updateSong(song.id, payload); } else { await createSong(payload); } onSaved(song ? '曲目已更新' : '曲目已创建'); } catch (cause) { setServerError(errorMessage(cause)); } finally { setSaving(false); } }; return ( } >
{serverError ? {serverError} : null}
patch({ title: event.target.value })} /> patch({ artist: event.target.value })} /> patch({ album: event.target.value })} /> patch({ durationSec: event.target.value })} /> patch({ bpm: event.target.value })} /> patch({ tags: event.target.value })} />
); } /* --------------------------------------------------------------- 音频弹窗 */ function AudioUploadModal({ song, onClose, onSaved, }: { song: Song; onClose: () => void; onSaved: (message: string) => void; }) { const [file, setFile] = useState(null); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const submit = async (): Promise => { if (!file) { setError('请选择音频文件'); return; } setSaving(true); setError(null); try { await uploadSongAudio(song.id, file); onSaved('音频已上传'); } catch (cause) { setError(errorMessage(cause)); } finally { setSaving(false); } }; return ( } >
{error ? {error} : null} setFile(event.target.files?.[0] ?? null)} /> {song.audioUrl ? (
当前音频
) : null}
); } /* ------------------------------------------------------------------ 页面 */ export function SongsPage() { const [filters, setFilters] = useState({ q: '', status: '', artist: '', page: 1 }); const [qInput, setQInput] = useState(''); const [statusInput, setStatusInput] = useState(''); const [artistInput, setArtistInput] = useState(''); const [editingSong, setEditingSong] = useState(undefined); const [audioTarget, setAudioTarget] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); const [deleting, setDeleting] = useState(false); const [notice, setNotice] = useState<{ kind: 'success' | 'error'; text: string } | null>(null); const state = useAsync( () => listSongs({ page: filters.page, pageSize: PAGE_SIZE, q: filters.q, status: filters.status, artist: filters.artist }), [filters.q, filters.status, filters.artist, filters.page], ); const applyFilters = (next?: { status?: PublishStatus | '' }): void => { setFilters({ q: qInput.trim(), status: next?.status ?? statusInput, artist: artistInput.trim(), page: 1, }); }; const resetFilters = (): void => { setQInput(''); setStatusInput(''); setArtistInput(''); setFilters({ q: '', status: '', artist: '', page: 1 }); }; const onSaved = (message: string): void => { setEditingSong(undefined); setAudioTarget(null); setNotice({ kind: 'success', text: message }); state.reload(); }; const confirmDelete = async (): Promise => { if (!deleteTarget) return; setDeleting(true); try { await deleteSong(deleteTarget.id); setDeleteTarget(null); setNotice({ kind: 'success', text: `已删除曲目「${deleteTarget.title}」(软删除)` }); state.reload(); } catch (cause) { setNotice({ kind: 'error', text: errorMessage(cause) }); } finally { setDeleting(false); } }; const items = state.data?.items ?? []; return (

歌曲管理

T-WEB-008 · 曲目元数据、来源五元组、音频上传与软删除

{notice ? ( setNotice(null)}> {notice.text} ) : null}
{ event.preventDefault(); applyFilters(); }} > setQInput(event.target.value)} /> setArtistInput(event.target.value)} />
{state.error ? : null} {state.loading && !state.data ? : null} {!state.loading && !state.error && items.length === 0 ? ( ) : null} {items.length > 0 ? (
{items.map((song) => ( ))}
ID 曲名 / 演唱者 专辑 语言 时长 BPM 难度 标签 来源五元组 状态 音频 操作
{song.id}
{song.title}
{song.artist}
{song.album || '—'} {LANGUAGE_LABELS[song.language ?? ''] ?? song.language ?? '—'} {formatDuration(song.durationMs)} {formatNumber(song.bpm)} {song.difficulty ?? '—'} {tagsToText(song.tags) || '—'} {song.audioUrl ? ( <>
) : null} {state.data ? ( setFilters((previous) => ({ ...previous, page }))} /> ) : null}
{editingSong !== undefined ? ( setEditingSong(undefined)} onSaved={onSaved} /> ) : null} {audioTarget ? ( setAudioTarget(null)} onSaved={onSaved} /> ) : null} {deleteTarget ? (

确认删除曲目 {deleteTarget.title}(#{deleteTarget.id})?

DELETE /api/songs/{deleteTarget.id} · 服务端执行软删除。

} onConfirm={confirmDelete} onCancel={() => setDeleteTarget(null)} /> ) : null}
); }