606 lines
20 KiB
TypeScript
606 lines
20 KiB
TypeScript
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>
|
||
);
|
||
}
|