feat(web): T-WEB-008/009 管理端界面(歌曲/模型/动作/审核 + VRM 预览)
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
|
||||
interface NavItem {
|
||||
to: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
end?: boolean;
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ to: '/', label: '概览', hint: 'Dashboard', end: true },
|
||||
{ to: '/songs', label: '歌曲管理', hint: 'T-WEB-008' },
|
||||
{ to: '/models', label: '模型管理', hint: 'T-WEB-009' },
|
||||
{ to: '/motions', label: '动作管理', hint: 'T-WEB-004/006' },
|
||||
{ to: '/reviews', label: '审核', hint: 'T-WEB-007' },
|
||||
];
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE ?? '';
|
||||
|
||||
export function Layout() {
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<aside className="sidebar">
|
||||
<div className="brand">
|
||||
<span className="brand-logo" aria-hidden="true">
|
||||
OD
|
||||
</span>
|
||||
<div className="brand-text">
|
||||
<strong>OpenDance Studio</strong>
|
||||
<span>管理端 Admin</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="nav">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end ?? false}
|
||||
className={({ isActive }) => `nav-item${isActive ? ' nav-item-active' : ''}`}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
{item.hint ? <em>{item.hint}</em> : null}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className="sidebar-foot">
|
||||
<span>API 基址</span>
|
||||
<code>{API_BASE === '' ? '同源(dev proxy / nginx)' : API_BASE}</code>
|
||||
<span className="muted">契约 rest-api 0.1.0</span>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="content">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||
import { VRM, VRMLoaderPlugin, VRMUtils } from '@pixiv/three-vrm';
|
||||
|
||||
type PreviewState =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'loading'; ratio: number | null }
|
||||
| { kind: 'ready'; metaVersion: string }
|
||||
| { kind: 'error'; message: string };
|
||||
|
||||
/** 从 GLTFLoader 的 userData 中安全取出 VRM 实例(three 的 userData 是 any)。 */
|
||||
function extractVrm(userData: Record<string, unknown>): VRM | null {
|
||||
const candidate = userData['vrm'];
|
||||
return candidate instanceof VRM ? candidate : null;
|
||||
}
|
||||
|
||||
/** 兜底释放:VRMUtils.deepDispose 之外的几何/材质/贴图。 */
|
||||
function disposeObject(root: THREE.Object3D): void {
|
||||
root.traverse((object) => {
|
||||
const mesh = object as THREE.Mesh;
|
||||
if (mesh.geometry) mesh.geometry.dispose();
|
||||
const material = mesh.material;
|
||||
if (!material) return;
|
||||
const list: THREE.Material[] = Array.isArray(material) ? material : [material];
|
||||
for (const item of list) {
|
||||
for (const value of Object.values(item)) {
|
||||
if (value instanceof THREE.Texture) value.dispose();
|
||||
}
|
||||
item.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
interface VrmPreviewProps {
|
||||
fileUrl: string | null;
|
||||
name: string;
|
||||
/** 非 VRM 且无法用 GLTFLoader 解析时给出提示(如 fbx)。 */
|
||||
unsupportedReason?: string | null;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* VRM 3D 预览:GLTFLoader + VRMLoaderPlugin。
|
||||
* - 处理 VRM 0.x / 1.0(vrm.meta.metaVersion)
|
||||
* - 处理「未选择 / 加载中 / 加载失败」状态
|
||||
* - 鼠标拖拽旋转 + 滚轮缩放(自实现,未引入 OrbitControls)
|
||||
* - requestAnimationFrame 循环中调用 vrm.update(delta)
|
||||
* - 卸载时释放 renderer / geometry / material / texture
|
||||
*/
|
||||
export function VrmPreview({ fileUrl, name, unsupportedReason = null, height = 420 }: VrmPreviewProps) {
|
||||
const mountRef = useRef<HTMLDivElement | null>(null);
|
||||
const [state, setState] = useState<PreviewState>({ kind: 'idle' });
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const mount = mountRef.current;
|
||||
if (!mount) return;
|
||||
|
||||
if (!fileUrl) {
|
||||
setState({ kind: 'idle' });
|
||||
return;
|
||||
}
|
||||
if (unsupportedReason) {
|
||||
setState({ kind: 'error', message: unsupportedReason });
|
||||
return;
|
||||
}
|
||||
|
||||
setState({ kind: 'loading', ratio: null });
|
||||
|
||||
const width = Math.max(1, mount.clientWidth);
|
||||
const height2 = Math.max(1, mount.clientHeight || height);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x14181f);
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(30, width / height2, 0.1, 1000);
|
||||
|
||||
let renderer: THREE.WebGLRenderer;
|
||||
try {
|
||||
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
|
||||
} catch (cause) {
|
||||
// 无 WebGL 支持时不要让整个页面崩溃
|
||||
const detail = cause instanceof Error ? cause.message : String(cause);
|
||||
setState({ kind: 'error', message: `当前浏览器无法创建 WebGL 上下文:${detail}` });
|
||||
return;
|
||||
}
|
||||
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.setSize(width, height2, false);
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
mount.appendChild(renderer.domElement);
|
||||
|
||||
scene.add(new THREE.AmbientLight(0xffffff, 1.6));
|
||||
const keyLight = new THREE.DirectionalLight(0xffffff, 2.2);
|
||||
keyLight.position.set(1, 2, 1.5);
|
||||
scene.add(keyLight);
|
||||
const rimLight = new THREE.DirectionalLight(0x88bbff, 1.0);
|
||||
rimLight.position.set(-1.2, 1.2, -1.5);
|
||||
scene.add(rimLight);
|
||||
|
||||
const grid = new THREE.GridHelper(4, 8, 0x2a3340, 0x1e2530);
|
||||
scene.add(grid);
|
||||
|
||||
// 轨道参数(自实现的 drag rotate / wheel zoom)
|
||||
const orbit = { theta: 0, phi: Math.PI / 2.2, radius: 3, targetY: 1 };
|
||||
const updateCamera = (): void => {
|
||||
const sinPhi = Math.sin(orbit.phi);
|
||||
camera.position.set(
|
||||
orbit.radius * sinPhi * Math.sin(orbit.theta),
|
||||
orbit.targetY + orbit.radius * Math.cos(orbit.phi),
|
||||
orbit.radius * sinPhi * Math.cos(orbit.theta),
|
||||
);
|
||||
camera.lookAt(0, orbit.targetY, 0);
|
||||
};
|
||||
updateCamera();
|
||||
|
||||
let dragging = false;
|
||||
let lastX = 0;
|
||||
let lastY = 0;
|
||||
|
||||
const onPointerDown = (event: PointerEvent): void => {
|
||||
dragging = true;
|
||||
lastX = event.clientX;
|
||||
lastY = event.clientY;
|
||||
renderer.domElement.setPointerCapture(event.pointerId);
|
||||
};
|
||||
const onPointerMove = (event: PointerEvent): void => {
|
||||
if (!dragging) return;
|
||||
const dx = event.clientX - lastX;
|
||||
const dy = event.clientY - lastY;
|
||||
lastX = event.clientX;
|
||||
lastY = event.clientY;
|
||||
orbit.theta -= dx * 0.01;
|
||||
orbit.phi = Math.min(Math.PI - 0.15, Math.max(0.15, orbit.phi - dy * 0.01));
|
||||
updateCamera();
|
||||
};
|
||||
const onPointerUp = (event: PointerEvent): void => {
|
||||
dragging = false;
|
||||
if (renderer.domElement.hasPointerCapture(event.pointerId)) {
|
||||
renderer.domElement.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
};
|
||||
const onWheel = (event: WheelEvent): void => {
|
||||
event.preventDefault();
|
||||
orbit.radius = Math.min(20, Math.max(0.4, orbit.radius * (1 + event.deltaY * 0.001)));
|
||||
updateCamera();
|
||||
};
|
||||
|
||||
const canvas = renderer.domElement;
|
||||
canvas.addEventListener('pointerdown', onPointerDown);
|
||||
canvas.addEventListener('pointermove', onPointerMove);
|
||||
canvas.addEventListener('pointerup', onPointerUp);
|
||||
canvas.addEventListener('pointercancel', onPointerUp);
|
||||
canvas.addEventListener('wheel', onWheel, { passive: false });
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
const w = Math.max(1, mount.clientWidth);
|
||||
const h = Math.max(1, mount.clientHeight || height);
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(w, h, false);
|
||||
});
|
||||
resizeObserver.observe(mount);
|
||||
|
||||
const clock = new THREE.Clock();
|
||||
let rafId = 0;
|
||||
let vrm: VRM | null = null;
|
||||
let disposed = false;
|
||||
let loadedModel: THREE.Object3D | null = null;
|
||||
|
||||
const tick = (): void => {
|
||||
rafId = requestAnimationFrame(tick);
|
||||
const delta = clock.getDelta();
|
||||
if (vrm) vrm.update(delta);
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
rafId = requestAnimationFrame(tick);
|
||||
|
||||
const loader = new GLTFLoader();
|
||||
loader.register((parser) => new VRMLoaderPlugin(parser));
|
||||
|
||||
loader.load(
|
||||
fileUrl,
|
||||
(gltf) => {
|
||||
if (disposed) {
|
||||
disposeObject(gltf.scene);
|
||||
return;
|
||||
}
|
||||
const bag: Record<string, unknown> = gltf.userData;
|
||||
const loadedVrm = extractVrm(bag);
|
||||
const root: THREE.Object3D = loadedVrm ? loadedVrm.scene : gltf.scene;
|
||||
loadedModel = root;
|
||||
vrm = loadedVrm;
|
||||
|
||||
if (loadedVrm) {
|
||||
// VRM 0.x 面向 +Z,需要转 180°;1.0 不需要。
|
||||
VRMUtils.rotateVRM0(loadedVrm);
|
||||
}
|
||||
scene.add(root);
|
||||
|
||||
// 按包围盒自动取景:兼容米制与厘米制模型
|
||||
const box = new THREE.Box3().setFromObject(root);
|
||||
if (!box.isEmpty()) {
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
const maxDim = Math.max(size.x, size.y, size.z) || 1;
|
||||
orbit.targetY = center.y;
|
||||
orbit.radius = maxDim * 1.9;
|
||||
grid.position.y = box.min.y;
|
||||
updateCamera();
|
||||
}
|
||||
|
||||
const meta = loadedVrm ? loadedVrm.meta.metaVersion : null;
|
||||
setState({ kind: 'ready', metaVersion: meta === '1' ? '1.0' : meta === '0' ? '0.x' : '非 VRM(glTF)' });
|
||||
},
|
||||
(event) => {
|
||||
if (disposed) return;
|
||||
const ratio = event.total > 0 ? event.loaded / event.total : null;
|
||||
setState({ kind: 'loading', ratio });
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (disposed) return;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setState({ kind: 'error', message: `模型加载失败:${message}` });
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
cancelAnimationFrame(rafId);
|
||||
resizeObserver.disconnect();
|
||||
canvas.removeEventListener('pointerdown', onPointerDown);
|
||||
canvas.removeEventListener('pointermove', onPointerMove);
|
||||
canvas.removeEventListener('pointerup', onPointerUp);
|
||||
canvas.removeEventListener('pointercancel', onPointerUp);
|
||||
canvas.removeEventListener('wheel', onWheel);
|
||||
|
||||
if (loadedModel) {
|
||||
scene.remove(loadedModel);
|
||||
if (vrm) {
|
||||
try {
|
||||
VRMUtils.deepDispose(vrm.scene);
|
||||
} catch {
|
||||
disposeObject(loadedModel);
|
||||
}
|
||||
} else {
|
||||
disposeObject(loadedModel);
|
||||
}
|
||||
}
|
||||
scene.clear();
|
||||
renderer.dispose();
|
||||
if (canvas.parentNode === mount) mount.removeChild(canvas);
|
||||
};
|
||||
}, [fileUrl, unsupportedReason, reloadKey, height]);
|
||||
|
||||
const hasModel = fileUrl !== null && fileUrl !== '';
|
||||
|
||||
return (
|
||||
<div className="vrm-panel">
|
||||
<div className="vrm-panel-head">
|
||||
<div>
|
||||
<strong>3D 预览</strong>
|
||||
<span className="muted">{name || '未选择模型'}</span>
|
||||
</div>
|
||||
{hasModel ? (
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => setReloadKey((v) => v + 1)}>
|
||||
重新加载
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="vrm-stage" style={{ height }}>
|
||||
<div className="vrm-canvas" ref={mountRef} />
|
||||
{state.kind === 'idle' ? (
|
||||
<div className="vrm-overlay">
|
||||
<p>尚未选择模型</p>
|
||||
<p className="muted">在左侧列表点击「预览」以加载 VRM。</p>
|
||||
</div>
|
||||
) : null}
|
||||
{state.kind === 'loading' ? (
|
||||
<div className="vrm-overlay">
|
||||
<span className="spinner" aria-hidden="true" />
|
||||
<p>加载模型中… {state.ratio !== null ? `${Math.round(state.ratio * 100)}%` : ''}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{state.kind === 'error' ? (
|
||||
<div className="vrm-overlay vrm-overlay-error">
|
||||
<p>⚠ {state.message}</p>
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => setReloadKey((v) => v + 1)}>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="vrm-panel-foot">
|
||||
<span className="muted">
|
||||
拖拽旋转 · 滚轮缩放
|
||||
{state.kind === 'ready' ? ` · VRM 版本:${state.metaVersion}` : ''}
|
||||
</span>
|
||||
{fileUrl ? (
|
||||
<a className="ext-link" href={fileUrl} target="_blank" rel="noreferrer noopener">
|
||||
模型文件
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import type { ModelFormat, MotionFormat, PublishStatus, SourceFields, SourceInput, SourceType } from '../api/types';
|
||||
import { MODEL_FORMAT_LABELS, MOTION_FORMAT_LABELS, SOURCE_TYPE_OPTIONS, STATUS_LABELS, sourceTypeLabel } from '../lib/labels';
|
||||
|
||||
/* ------------------------------------------------------------ 状态占位 */
|
||||
|
||||
export function Loading({ text = '加载中…' }: { text?: string }) {
|
||||
return (
|
||||
<div className="state-box state-loading">
|
||||
<span className="spinner" aria-hidden="true" />
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBox({ message, onRetry }: { message: string; onRetry?: () => void }) {
|
||||
return (
|
||||
<div className="state-box state-error" role="alert">
|
||||
<div className="state-error-text">
|
||||
<strong>加载失败</strong>
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
{onRetry ? (
|
||||
<button type="button" className="btn btn-ghost" onClick={onRetry}>
|
||||
重试
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Empty({ text = '暂无数据' }: { text?: string }) {
|
||||
return <div className="state-box state-empty">{text}</div>;
|
||||
}
|
||||
|
||||
export function Notice({
|
||||
kind,
|
||||
children,
|
||||
onClose,
|
||||
}: {
|
||||
kind: 'success' | 'error' | 'info';
|
||||
children: ReactNode;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className={`notice notice-${kind}`} role={kind === 'error' ? 'alert' : 'status'}>
|
||||
<span>{children}</span>
|
||||
{onClose ? (
|
||||
<button type="button" className="icon-btn" onClick={onClose} aria-label="关闭提示">
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 弹层 */
|
||||
|
||||
interface ModalProps {
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
size?: 'md' | 'lg' | 'xl';
|
||||
}
|
||||
|
||||
export function Modal({ title, onClose, children, footer, size = 'md' }: ModalProps) {
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent): void => {
|
||||
if (event.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div
|
||||
className={`modal modal-${size}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header className="modal-head">
|
||||
<h3>{title}</h3>
|
||||
<button type="button" className="icon-btn" onClick={onClose} aria-label="关闭">
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<div className="modal-body">{children}</div>
|
||||
{footer ? <footer className="modal-foot">{footer}</footer> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
title,
|
||||
message,
|
||||
confirmText = '确认',
|
||||
danger = false,
|
||||
busy = false,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
title: string;
|
||||
message: ReactNode;
|
||||
confirmText?: string;
|
||||
danger?: boolean;
|
||||
busy?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
onClose={onCancel}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="btn btn-ghost" onClick={onCancel} disabled={busy}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${danger ? 'btn-danger' : 'btn-primary'}`}
|
||||
onClick={onConfirm}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? '处理中…' : confirmText}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="confirm-body">{message}</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 表单零件 */
|
||||
|
||||
export function Field({
|
||||
label,
|
||||
required = false,
|
||||
error,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className={`field${error ? ' field-invalid' : ''}`}>
|
||||
<span className="field-label">
|
||||
{label}
|
||||
{required ? <em className="req">*</em> : null}
|
||||
</span>
|
||||
{children}
|
||||
{hint && !error ? <span className="field-hint">{hint}</span> : null}
|
||||
{error ? <span className="field-error">{error}</span> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusBadge({ status }: { status: PublishStatus | string }) {
|
||||
const label = STATUS_LABELS[status as PublishStatus] ?? status;
|
||||
return <span className={`badge badge-status badge-${status}`}>{label}</span>;
|
||||
}
|
||||
|
||||
export function FormatBadge({ format, kind }: { format: ModelFormat | MotionFormat | string; kind: 'model' | 'motion' }) {
|
||||
const label =
|
||||
kind === 'model'
|
||||
? MODEL_FORMAT_LABELS[format as ModelFormat] ?? format
|
||||
: MOTION_FORMAT_LABELS[format as MotionFormat] ?? format;
|
||||
const highlight = format === 'vrm' ? ' badge-vrm' : '';
|
||||
return <span className={`badge badge-format${highlight}`}>{label}{format === 'vrm' ? ' ★' : ''}</span>;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- 链接 */
|
||||
|
||||
export function ExternalLink({ href, children }: { href: string; children?: ReactNode }) {
|
||||
if (!/^https?:\/\//i.test(href)) {
|
||||
return <span className="muted">{href || '—'}</span>;
|
||||
}
|
||||
return (
|
||||
<a className="ext-link" href={href} target="_blank" rel="noreferrer noopener" title={href}>
|
||||
{children ?? href}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------- 来源五元组 */
|
||||
|
||||
/** 表格内的来源五元组:摘要 + 可展开全部 5 项,sourceUrl 为外链。 */
|
||||
export function SourceCell({ source }: { source: SourceFields }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const tooltip =
|
||||
`来源类型:${sourceTypeLabel(source.sourceType)}(${source.sourceType})\n` +
|
||||
`来源名称:${source.sourceName}\n` +
|
||||
`来源链接:${source.sourceUrl}\n` +
|
||||
`许可:${source.license}\n` +
|
||||
`作者:${source.author}`;
|
||||
|
||||
return (
|
||||
<div className="source-cell" title={tooltip}>
|
||||
<div className="source-line">
|
||||
<span className="chip">{sourceTypeLabel(source.sourceType)}</span>
|
||||
<span className="source-name">{source.sourceName || '—'}</span>
|
||||
</div>
|
||||
<div className="source-line source-sub">
|
||||
<span>作者:{source.author || '—'}</span>
|
||||
<span>许可:{source.license || '—'}</span>
|
||||
</div>
|
||||
<div className="source-line source-sub">
|
||||
<ExternalLink href={source.sourceUrl} />
|
||||
</div>
|
||||
<button type="button" className="link-btn" onClick={() => setOpen((value) => !value)}>
|
||||
{open ? '收起来源详情' : '展开来源详情'}
|
||||
</button>
|
||||
{open ? (
|
||||
<dl className="source-detail">
|
||||
<div>
|
||||
<dt>sourceType</dt>
|
||||
<dd>
|
||||
{SOURCE_TYPE_OPTIONS.find((option) => option.value === source.sourceType)?.label ?? source.sourceType}
|
||||
<span className="muted">({source.sourceType})</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>sourceName</dt>
|
||||
<dd>{source.sourceName || '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>sourceUrl</dt>
|
||||
<dd>
|
||||
<ExternalLink href={source.sourceUrl} />
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>license</dt>
|
||||
<dd>{source.license || '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>author</dt>
|
||||
<dd>{source.author || '—'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- 分页 */
|
||||
|
||||
export function Pagination({
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
onChange: (page: number) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const totalPages = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));
|
||||
return (
|
||||
<div className="pagination">
|
||||
<span className="pagination-info">
|
||||
共 {total} 条 · 第 {page} / {totalPages} 页
|
||||
</span>
|
||||
<div className="pagination-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => onChange(1)}
|
||||
disabled={disabled || page <= 1}
|
||||
>
|
||||
首页
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => onChange(page - 1)}
|
||||
disabled={disabled || page <= 1}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => onChange(page + 1)}
|
||||
disabled={disabled || page >= totalPages}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => onChange(totalPages)}
|
||||
disabled={disabled || page >= totalPages}
|
||||
>
|
||||
末页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------ 来源五元组表单 */
|
||||
|
||||
export type SourceFormValue = SourceInput;
|
||||
|
||||
export function SourceFieldsForm({
|
||||
value,
|
||||
errors,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}: {
|
||||
value: SourceFormValue;
|
||||
errors: Record<string, string>;
|
||||
onChange: (patch: Partial<SourceFormValue>) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<fieldset className="fieldset" disabled={disabled}>
|
||||
<legend>来源五元组(全部必填)</legend>
|
||||
<div className="form-grid">
|
||||
<Field label="来源类型 sourceType" required error={errors.sourceType}>
|
||||
<select
|
||||
value={value.sourceType}
|
||||
onChange={(event) => onChange({ sourceType: event.target.value as SourceType | '' })}
|
||||
>
|
||||
<option value="">请选择来源类型…</option>
|
||||
{SOURCE_TYPE_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}({option.value})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="来源名称 sourceName" required error={errors.sourceName}>
|
||||
<input
|
||||
type="text"
|
||||
value={value.sourceName}
|
||||
maxLength={200}
|
||||
placeholder="如:官方曲库 / 作者名"
|
||||
onChange={(event) => onChange({ sourceName: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="来源链接 sourceUrl" required error={errors.sourceUrl} hint="必须为 http(s) URL">
|
||||
<input
|
||||
type="url"
|
||||
value={value.sourceUrl}
|
||||
maxLength={500}
|
||||
placeholder="https://example.com/source"
|
||||
onChange={(event) => onChange({ sourceUrl: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="许可 license" required error={errors.license}>
|
||||
<input
|
||||
type="text"
|
||||
value={value.license}
|
||||
maxLength={100}
|
||||
placeholder="CC-BY-4.0 / MIT / proprietary / unknown"
|
||||
onChange={(event) => onChange({ license: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="作者 author" required error={errors.author}>
|
||||
<input
|
||||
type="text"
|
||||
value={value.author}
|
||||
maxLength={200}
|
||||
onChange={(event) => onChange({ author: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user