feat(web): T-WEB-008/009 管理端界面(歌曲/模型/动作/审核 + VRM 预览)
This commit is contained in:
@@ -0,0 +1,605 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user