Files
opendance-web/web-server/admin/src/pages/Songs.tsx
T

606 lines
20 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, 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<SongFormState>(song ? songToForm(song) : EMPTY_SONG_FORM);
const [errors, setErrors] = useState<FieldErrors>({});
const [saving, setSaving] = useState(false);
const [serverError, setServerError] = useState<string | null>(null);
const patch = (part: Partial<SongFormState>): void => {
setForm((previous) => ({ ...previous, ...part }));
};
const onSubmit = async (event: FormEvent): Promise<void> => {
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 (
<Modal
title={song ? `编辑曲目 #${song.id}` : '新建曲目'}
size="xl"
onClose={onClose}
footer={
<>
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={saving}>
取消
</button>
<button type="submit" form="song-form" className="btn btn-primary" disabled={saving}>
{saving ? '保存中…' : '保存'}
</button>
</>
}
>
<form id="song-form" className="form" onSubmit={onSubmit}>
{serverError ? <Notice kind="error">{serverError}</Notice> : null}
<div className="form-grid">
<Field label="曲名 title" required error={errors.title}>
<input
type="text"
value={form.title}
maxLength={200}
onChange={(event) => patch({ title: event.target.value })}
/>
</Field>
<Field label="演唱者 artist" required error={errors.artist}>
<input
type="text"
value={form.artist}
maxLength={200}
onChange={(event) => patch({ artist: event.target.value })}
/>
</Field>
<Field label="专辑 album" error={errors.album}>
<input
type="text"
value={form.album}
maxLength={200}
onChange={(event) => patch({ album: event.target.value })}
/>
</Field>
<Field label="语言 language">
<select value={form.language} onChange={(event) => patch({ language: event.target.value })}>
<option value="">未指定</option>
{SONG_LANGUAGES.map((language) => (
<option key={language} value={language}>
{LANGUAGE_LABELS[language] ?? language}({language})
</option>
))}
</select>
</Field>
<Field label="时长(秒)" error={errors.durationSec} hint="提交时自动换算为 durationMs">
<input
type="number"
step="0.1"
min="0"
value={form.durationSec}
onChange={(event) => patch({ durationSec: event.target.value })}
/>
</Field>
<Field label="BPM" error={errors.bpm}>
<input
type="number"
step="0.1"
min="0"
value={form.bpm}
onChange={(event) => patch({ bpm: event.target.value })}
/>
</Field>
<Field label="难度 difficulty" error={errors.difficulty}>
<select value={form.difficulty} onChange={(event) => patch({ difficulty: event.target.value })}>
<option value="">未指定</option>
{[1, 2, 3, 4, 5].map((level) => (
<option key={level} value={String(level)}>
{level}
</option>
))}
</select>
</Field>
<Field label="状态 status">
<select
value={form.status}
onChange={(event) => patch({ status: event.target.value as PublishStatus })}
>
{STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</Field>
<Field label="标签 tags" hint="英文/中文逗号分隔">
<input
type="text"
value={form.tags}
placeholder="pop, 快歌"
onChange={(event) => patch({ tags: event.target.value })}
/>
</Field>
</div>
<SourceFieldsForm value={form} errors={errors} onChange={patch} disabled={saving} />
</form>
</Modal>
);
}
/* --------------------------------------------------------------- 音频弹窗 */
function AudioUploadModal({
song,
onClose,
onSaved,
}: {
song: Song;
onClose: () => void;
onSaved: (message: string) => void;
}) {
const [file, setFile] = useState<File | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async (): Promise<void> => {
if (!file) {
setError('请选择音频文件');
return;
}
setSaving(true);
setError(null);
try {
await uploadSongAudio(song.id, file);
onSaved('音频已上传');
} catch (cause) {
setError(errorMessage(cause));
} finally {
setSaving(false);
}
};
return (
<Modal
title={`上传音频 · ${song.title}`}
onClose={onClose}
footer={
<>
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={saving}>
取消
</button>
<button type="button" className="btn btn-primary" onClick={submit} disabled={saving || !file}>
{saving ? '上传中…' : '上传'}
</button>
</>
}
>
<div className="form">
{error ? <Notice kind="error">{error}</Notice> : null}
<Field label="音频文件" required hint="POST /api/songs/:id/audio · multipart 字段名 file">
<input type="file" accept="audio/*" onChange={(event) => setFile(event.target.files?.[0] ?? null)} />
</Field>
{song.audioUrl ? (
<div className="kv">
<span className="muted">当前音频</span>
<audio controls preload="none" src={song.audioUrl} />
<code className="break">{song.audioUrl}</code>
</div>
) : null}
</div>
</Modal>
);
}
/* ------------------------------------------------------------------ 页面 */
export function SongsPage() {
const [filters, setFilters] = useState<SongFilters>({ q: '', status: '', artist: '', page: 1 });
const [qInput, setQInput] = useState('');
const [statusInput, setStatusInput] = useState<PublishStatus | ''>('');
const [artistInput, setArtistInput] = useState('');
const [editingSong, setEditingSong] = useState<Song | null | undefined>(undefined);
const [audioTarget, setAudioTarget] = useState<Song | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Song | null>(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<void> => {
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 (
<div className="page">
<header className="page-head">
<div>
<h1>歌曲管理</h1>
<p className="muted">T-WEB-008 · 曲目元数据、来源五元组、音频上传与软删除</p>
</div>
<div className="page-head-actions">
<button type="button" className="btn btn-ghost" onClick={state.reload} disabled={state.loading}>
{state.loading ? '刷新中…' : '刷新'}
</button>
<button type="button" className="btn btn-primary" onClick={() => setEditingSong(null)}>
+ 新建曲目
</button>
</div>
</header>
{notice ? (
<Notice kind={notice.kind} onClose={() => setNotice(null)}>
{notice.text}
</Notice>
) : null}
<form
className="filter-bar"
onSubmit={(event) => {
event.preventDefault();
applyFilters();
}}
>
<input
type="search"
placeholder="搜索曲名 / 演唱者 / 专辑(q)"
value={qInput}
onChange={(event) => setQInput(event.target.value)}
/>
<select
value={statusInput}
onChange={(event) => {
const value = event.target.value as PublishStatus | '';
setStatusInput(value);
applyFilters({ status: value });
}}
>
<option value="">全部状态</option>
{STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<input
type="text"
placeholder="按演唱者筛选(artist)"
value={artistInput}
onChange={(event) => setArtistInput(event.target.value)}
/>
<button type="submit" className="btn btn-ghost">
查询
</button>
<button type="button" className="btn btn-ghost" onClick={resetFilters}>
重置
</button>
</form>
<section className="panel">
{state.error ? <ErrorBox message={state.error} onRetry={state.reload} /> : null}
{state.loading && !state.data ? <Loading text="正在加载曲目…" /> : null}
{!state.loading && !state.error && items.length === 0 ? (
<Empty text="没有符合条件的曲目,试试调整筛选条件或新建曲目。" />
) : null}
{items.length > 0 ? (
<div className="table-wrap">
<table className="table">
<thead>
<tr>
<th>ID</th>
<th>曲名 / 演唱者</th>
<th>专辑</th>
<th>语言</th>
<th>时长</th>
<th>BPM</th>
<th>难度</th>
<th>标签</th>
<th className="col-source">来源五元组</th>
<th>状态</th>
<th className="col-audio">音频</th>
<th className="col-actions">操作</th>
</tr>
</thead>
<tbody>
{items.map((song) => (
<tr key={song.id}>
<td className="mono">{song.id}</td>
<td>
<div className="cell-title">{song.title}</div>
<div className="muted small">{song.artist}</div>
</td>
<td>{song.album || '—'}</td>
<td>{LANGUAGE_LABELS[song.language ?? ''] ?? song.language ?? '—'}</td>
<td className="mono">{formatDuration(song.durationMs)}</td>
<td className="mono">{formatNumber(song.bpm)}</td>
<td className="mono">{song.difficulty ?? '—'}</td>
<td className="small">{tagsToText(song.tags) || '—'}</td>
<td className="col-source">
<SourceCell source={song} />
</td>
<td>
<StatusBadge status={song.status} />
</td>
<td className="col-audio">
{song.audioUrl ? (
<>
<audio className="mini-audio" controls preload="none" src={song.audioUrl} />
<a
className="ext-link small"
href={song.audioUrl}
target="_blank"
rel="noreferrer noopener"
title={song.audioUrl}
>
音频地址
</a>
</>
) : (
<span className="muted small">未上传</span>
)}
<button type="button" className="link-btn" onClick={() => setAudioTarget(song)}>
{song.audioUrl ? '替换音频' : '上传音频'}
</button>
</td>
<td className="col-actions">
<button type="button" className="link-btn" onClick={() => setEditingSong(song)}>
编辑
</button>
<button type="button" className="link-btn" onClick={() => setAudioTarget(song)}>
音频
</button>
<button type="button" className="link-btn link-danger" onClick={() => setDeleteTarget(song)}>
删除
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
{state.data ? (
<Pagination
page={state.data.page ?? filters.page}
pageSize={state.data.pageSize ?? PAGE_SIZE}
total={state.data.total ?? 0}
disabled={state.loading}
onChange={(page) => setFilters((previous) => ({ ...previous, page }))}
/>
) : null}
</section>
{editingSong !== undefined ? (
<SongFormModal song={editingSong} onClose={() => setEditingSong(undefined)} onSaved={onSaved} />
) : null}
{audioTarget ? (
<AudioUploadModal song={audioTarget} onClose={() => setAudioTarget(null)} onSaved={onSaved} />
) : null}
{deleteTarget ? (
<ConfirmDialog
title="删除曲目"
danger
busy={deleting}
confirmText="删除"
message={
<>
<p>
确认删除曲目 <strong>{deleteTarget.title}</strong>(#{deleteTarget.id})?
</p>
<p className="muted small">DELETE /api/songs/{deleteTarget.id} · 服务端执行软删除。</p>
</>
}
onConfirm={confirmDelete}
onCancel={() => setDeleteTarget(null)}
/>
) : null}
</div>
);
}