feat(web): T-WEB-008/009 管理端界面(歌曲/模型/动作/审核 + VRM 预览)

This commit is contained in:
2026-09-14 19:00:55 +08:00
parent 0f66b53480
commit 640736c77b
30 changed files with 6704 additions and 0 deletions
+605
View File
@@ -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>
);
}