Add MusicWorkshop application
This commit is contained in:
Generated
+59
-1
@@ -10,7 +10,8 @@
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.525.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^7.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
@@ -1448,6 +1449,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz",
|
||||
@@ -2207,6 +2221,44 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.14.2",
|
||||
"resolved": "https://registry.npmmirror.com/react-router/-/react-router-7.14.2.tgz",
|
||||
"integrity": "sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.14.2",
|
||||
"resolved": "https://registry.npmmirror.com/react-router-dom/-/react-router-dom-7.14.2.tgz",
|
||||
"integrity": "sha512-YZcM5ES8jJSM+KrJ9BdvHHqlnGTg5tH3sC5ChFRj4inosKctdyzBDhOyyHdGk597q2OT6NTrCA1OvB/YDwfekQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.14.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/read-cache/-/read-cache-1.0.0.tgz",
|
||||
@@ -2351,6 +2403,12 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.525.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^7.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
|
||||
+64
-2165
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL || '/api';
|
||||
|
||||
async function parseResponse(response) {
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
let message = '请求失败';
|
||||
|
||||
try {
|
||||
const payload = await response.json();
|
||||
message = payload.detail || message;
|
||||
} catch {
|
||||
// Keep the fallback message when the response body is not JSON.
|
||||
}
|
||||
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
export async function fetchConfig() {
|
||||
const response = await fetch(`${API_BASE}/config`);
|
||||
return parseResponse(response);
|
||||
}
|
||||
|
||||
export async function fetchMetadataStatus(options = {}) {
|
||||
const response = await fetch(`${API_BASE}/config/metadata-status`, {
|
||||
signal: options.signal
|
||||
});
|
||||
return parseResponse(response);
|
||||
}
|
||||
|
||||
export async function saveConfig(config) {
|
||||
const response = await fetch(`${API_BASE}/config`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
|
||||
return parseResponse(response);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL || '/api';
|
||||
|
||||
async function parseResponse(response) {
|
||||
let payload = null;
|
||||
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
payload = null;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
const message =
|
||||
typeof payload?.detail === 'string'
|
||||
? payload.detail
|
||||
: payload?.detail?.message || '请求失败';
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
export async function fetchExceptionSummary(options = {}) {
|
||||
const response = await fetch(`${API_BASE}/exceptions/summary`, {
|
||||
signal: options.signal
|
||||
});
|
||||
return parseResponse(response);
|
||||
}
|
||||
|
||||
export async function fetchExceptionItems(
|
||||
{
|
||||
type = 'all',
|
||||
resolutionStatus = 'open',
|
||||
page = 1,
|
||||
pageSize = 50
|
||||
} = {},
|
||||
options = {}
|
||||
) {
|
||||
const query = new URLSearchParams({
|
||||
type,
|
||||
resolution_status: resolutionStatus,
|
||||
page: String(page),
|
||||
page_size: String(pageSize)
|
||||
});
|
||||
|
||||
const response = await fetch(`${API_BASE}/exceptions/items?${query.toString()}`, {
|
||||
signal: options.signal
|
||||
});
|
||||
return parseResponse(response);
|
||||
}
|
||||
|
||||
export async function fetchExceptionItem(exceptionId, options = {}) {
|
||||
const response = await fetch(`${API_BASE}/exceptions/items/${exceptionId}`, {
|
||||
signal: options.signal
|
||||
});
|
||||
return parseResponse(response);
|
||||
}
|
||||
|
||||
export function buildExceptionAudioUrl(exceptionId) {
|
||||
return `${API_BASE}/exceptions/items/${exceptionId}/audio`;
|
||||
}
|
||||
|
||||
export async function previewExceptionAction(payload, options = {}) {
|
||||
const response = await fetch(`${API_BASE}/exceptions/actions/preview`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal: options.signal
|
||||
});
|
||||
return parseResponse(response);
|
||||
}
|
||||
|
||||
export async function executeExceptionAction(payload, options = {}) {
|
||||
const response = await fetch(`${API_BASE}/exceptions/actions/execute`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal: options.signal
|
||||
});
|
||||
return parseResponse(response);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL || '/api';
|
||||
|
||||
async function parseResponse(response) {
|
||||
let payload = null;
|
||||
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
payload = null;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
const message =
|
||||
typeof payload?.detail === 'string'
|
||||
? payload.detail
|
||||
: payload?.detail?.message || '请求失败';
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
export async function fetchLibrarySummary(options = {}) {
|
||||
const response = await fetch(`${API_BASE}/library/summary`, {
|
||||
signal: options.signal
|
||||
});
|
||||
return parseResponse(response);
|
||||
}
|
||||
|
||||
export async function fetchLibraryTracks(params = {}, options = {}) {
|
||||
const query = new URLSearchParams();
|
||||
const normalizedParams = {
|
||||
page: params.page ?? 1,
|
||||
page_size: params.pageSize ?? 50,
|
||||
sort_by: params.sortBy ?? 'organized_at',
|
||||
sort_order: params.sortOrder ?? 'desc'
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries(normalizedParams)) {
|
||||
query.set(key, String(value));
|
||||
}
|
||||
|
||||
if (params.q?.trim()) {
|
||||
query.set('q', params.q.trim());
|
||||
}
|
||||
if (params.artist?.trim()) {
|
||||
query.set('artist', params.artist.trim());
|
||||
}
|
||||
if (params.album?.trim()) {
|
||||
query.set('album', params.album.trim());
|
||||
}
|
||||
if (params.format?.trim()) {
|
||||
query.set('format', params.format.trim());
|
||||
}
|
||||
if (params.hasProvenance === true) {
|
||||
query.set('has_provenance', 'true');
|
||||
}
|
||||
if (params.hasProvenance === false) {
|
||||
query.set('has_provenance', 'false');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/library/tracks?${query.toString()}`, {
|
||||
signal: options.signal
|
||||
});
|
||||
return parseResponse(response);
|
||||
}
|
||||
|
||||
export async function moveLibraryTrackToException(trackId, options = {}) {
|
||||
const response = await fetch(`${API_BASE}/library/tracks/${encodeURIComponent(trackId)}/move-to-exception`, {
|
||||
method: 'POST',
|
||||
signal: options.signal
|
||||
});
|
||||
return parseResponse(response);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createTaskStreamForNamespace } from './tasks';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL || '/api';
|
||||
|
||||
async function parseResponse(response) {
|
||||
let payload = null;
|
||||
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
payload = null;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
const message =
|
||||
typeof payload?.detail === 'string'
|
||||
? payload.detail
|
||||
: payload?.detail?.message || '请求失败';
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
export async function fetchCurrentRepairTask() {
|
||||
const response = await fetch(`${API_BASE}/repair-tasks/current`);
|
||||
return parseResponse(response);
|
||||
}
|
||||
|
||||
export async function fetchRepairTask(taskId) {
|
||||
const response = await fetch(`${API_BASE}/repair-tasks/${taskId}`);
|
||||
return parseResponse(response);
|
||||
}
|
||||
|
||||
export async function fetchRepairTaskLogs(taskId, page = 1, pageSize = 50) {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/repair-tasks/${taskId}/logs?page=${page}&page_size=${pageSize}`
|
||||
);
|
||||
return parseResponse(response);
|
||||
}
|
||||
|
||||
export function createRepairTaskStream(taskId) {
|
||||
return createTaskStreamForNamespace('repair-tasks', taskId);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL || '/api';
|
||||
|
||||
async function parseTaskResponse(response) {
|
||||
let payload = null;
|
||||
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
payload = null;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
const message =
|
||||
typeof payload?.detail === 'string'
|
||||
? payload.detail
|
||||
: payload?.detail?.message || '请求失败';
|
||||
const error = new Error(message);
|
||||
error.status = response.status;
|
||||
error.taskId = payload?.task_id || payload?.detail?.task_id || null;
|
||||
throw error;
|
||||
}
|
||||
|
||||
export async function runTask() {
|
||||
const response = await fetch(`${API_BASE}/tasks/run`, {
|
||||
method: 'POST'
|
||||
});
|
||||
return parseTaskResponse(response);
|
||||
}
|
||||
|
||||
export async function fetchCurrentTask() {
|
||||
const response = await fetch(`${API_BASE}/tasks/current`);
|
||||
return parseTaskResponse(response);
|
||||
}
|
||||
|
||||
export async function fetchTaskHistory({ page = 1, pageSize = 8 } = {}) {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/tasks?page=${page}&page_size=${pageSize}`
|
||||
);
|
||||
return parseTaskResponse(response);
|
||||
}
|
||||
|
||||
export async function fetchTask(taskId) {
|
||||
const response = await fetch(`${API_BASE}/tasks/${taskId}`);
|
||||
return parseTaskResponse(response);
|
||||
}
|
||||
|
||||
export async function fetchTaskItems(
|
||||
taskId,
|
||||
{
|
||||
page = 1,
|
||||
pageSize = 50,
|
||||
scanStatus = null,
|
||||
preprocessStatus = null,
|
||||
matchStatus = null,
|
||||
dedupeStatus = null,
|
||||
organizeStatus = null,
|
||||
activeOnly = false
|
||||
} = {}
|
||||
) {
|
||||
const query = new URLSearchParams({
|
||||
page: String(page),
|
||||
page_size: String(pageSize)
|
||||
});
|
||||
|
||||
if (scanStatus) {
|
||||
query.set('scan_status', scanStatus);
|
||||
}
|
||||
|
||||
if (preprocessStatus) {
|
||||
query.set('preprocess_status', preprocessStatus);
|
||||
}
|
||||
|
||||
if (matchStatus) {
|
||||
query.set('match_status', matchStatus);
|
||||
}
|
||||
|
||||
if (dedupeStatus) {
|
||||
query.set('dedupe_status', dedupeStatus);
|
||||
}
|
||||
|
||||
if (organizeStatus) {
|
||||
query.set('organize_status', organizeStatus);
|
||||
}
|
||||
|
||||
if (activeOnly) {
|
||||
query.set('active_only', 'true');
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${API_BASE}/tasks/${taskId}/items?${query.toString()}`
|
||||
);
|
||||
return parseTaskResponse(response);
|
||||
}
|
||||
|
||||
export async function fetchTaskLogs(taskId, page = 1, pageSize = 50) {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/tasks/${taskId}/logs?page=${page}&page_size=${pageSize}`
|
||||
);
|
||||
return parseTaskResponse(response);
|
||||
}
|
||||
|
||||
export function createTaskStream(taskId) {
|
||||
return createTaskStreamForNamespace('tasks', taskId);
|
||||
}
|
||||
|
||||
export function createTaskStreamForNamespace(namespace, taskId) {
|
||||
return new WebSocket(getTaskStreamUrl(namespace, taskId));
|
||||
}
|
||||
|
||||
function getTaskStreamUrl(namespace, taskId) {
|
||||
const apiUrl = new URL(API_BASE, window.location.origin);
|
||||
const protocol = apiUrl.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const basePath = apiUrl.pathname.replace(/\/$/, '');
|
||||
return `${protocol}//${apiUrl.host}${basePath}/${namespace}/${taskId}/stream`;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Database,
|
||||
History,
|
||||
LayoutDashboard,
|
||||
RefreshCw,
|
||||
Settings,
|
||||
Wifi
|
||||
} from 'lucide-react';
|
||||
import { NavLink, Outlet, useLocation } from 'react-router-dom';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: '/workbench', label: '工作台', icon: LayoutDashboard },
|
||||
{ to: '/library', label: '音乐库', icon: Database },
|
||||
{ to: '/exceptions', label: '异常中心', icon: AlertTriangle },
|
||||
{ to: '/history', label: '任务历史', icon: History },
|
||||
{ to: '/settings', label: '系统配置', icon: Settings }
|
||||
];
|
||||
|
||||
const PAGE_TITLES = {
|
||||
'/workbench': '工作台',
|
||||
'/library': '音乐库',
|
||||
'/exceptions': '异常中心',
|
||||
'/history': '任务历史',
|
||||
'/settings': '系统配置'
|
||||
};
|
||||
|
||||
export default function AppLayout({ connState, taskState }) {
|
||||
const location = useLocation();
|
||||
const pageTitle = PAGE_TITLES[location.pathname] || '工作台';
|
||||
const isWorkbenchPage = location.pathname === '/workbench';
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen overflow-hidden bg-slate-950 font-sans text-slate-300">
|
||||
<div className="flex w-64 flex-col border-r border-slate-800 bg-slate-900">
|
||||
<div className="flex items-center space-x-3 p-6 text-emerald-400">
|
||||
<Activity className="h-8 w-8" />
|
||||
<span className="text-2xl font-bold tracking-wider text-white">音流工坊</span>
|
||||
</div>
|
||||
<div className="mb-2 px-4 text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
主菜单
|
||||
</div>
|
||||
<nav className="flex-1 space-y-1 px-3">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<NavButton
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
icon={item.icon}
|
||||
label={item.label}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
<div className="border-t border-slate-800 p-4 text-xs text-slate-500">
|
||||
Navidrome Auto-Ingest Engine v1.2.0
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex h-full flex-1 flex-col overflow-hidden">
|
||||
<header className="flex h-16 shrink-0 items-center justify-between border-b border-slate-800 bg-slate-900/50 px-6">
|
||||
<h1 className="text-lg font-semibold text-white">{pageTitle}</h1>
|
||||
<div className="flex items-center space-x-4">
|
||||
{isWorkbenchPage && (
|
||||
<div className="flex items-center space-x-2 rounded-full border border-slate-700/50 bg-slate-800/50 px-3 py-1.5">
|
||||
{connState === 'connected' ? (
|
||||
<>
|
||||
<span className="relative flex h-2.5 w-2.5">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
|
||||
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-emerald-500" />
|
||||
</span>
|
||||
<Wifi className="h-4 w-4 text-emerald-400" />
|
||||
<span className="text-xs font-medium text-emerald-400">
|
||||
实时连接中 (WS)
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 animate-spin text-amber-400" />
|
||||
<span className="text-xs font-medium text-amber-400">轮询兜底中</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-slate-400">系统状态:</span>
|
||||
<span
|
||||
className={`rounded px-2.5 py-1 text-xs font-semibold ${
|
||||
taskState === 'unconfigured'
|
||||
? 'bg-slate-800 text-slate-400'
|
||||
: taskState === 'ready'
|
||||
? 'border border-blue-500/30 bg-blue-500/20 text-blue-400'
|
||||
: taskState === 'running'
|
||||
? 'border border-emerald-500/30 bg-emerald-500/20 text-emerald-400'
|
||||
: taskState === 'failed'
|
||||
? 'border border-rose-500/30 bg-rose-500/20 text-rose-400'
|
||||
: 'bg-slate-700 text-white'
|
||||
}`}
|
||||
>
|
||||
{taskState === 'unconfigured'
|
||||
? '未配置'
|
||||
: taskState === 'ready'
|
||||
? '已配置,待机中'
|
||||
: taskState === 'running'
|
||||
? '任务执行中'
|
||||
: taskState === 'failed'
|
||||
? '任务失败'
|
||||
: '批次完成'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main
|
||||
className={`flex-1 min-h-0 p-6 ${
|
||||
location.pathname === '/workbench'
|
||||
? 'overflow-hidden'
|
||||
: 'overflow-y-auto'
|
||||
}`}
|
||||
>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavButton({ to, icon: Icon, label }) {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
`flex w-full items-center space-x-3 rounded-lg px-3 py-3 transition-colors ${
|
||||
isActive
|
||||
? 'bg-slate-800 text-white'
|
||||
: 'hover:bg-slate-800/50 hover:text-slate-100'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
<span className="font-medium">{label}</span>
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
export default function Pagination({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPrev,
|
||||
onNext,
|
||||
summary
|
||||
}) {
|
||||
return (
|
||||
<div className="flex shrink-0 items-center justify-between border-t border-slate-800 bg-slate-900/50 px-5 py-4">
|
||||
<div className="text-xs text-slate-500">{summary}</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onPrev}
|
||||
disabled={currentPage === 1}
|
||||
className="flex items-center rounded bg-slate-800 px-3 py-1.5 text-xs text-slate-300 transition hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||
上一页
|
||||
</button>
|
||||
<div className="rounded border border-slate-800 bg-slate-950 px-3 py-1.5 font-mono text-xs text-slate-400">
|
||||
{currentPage} / {totalPages}
|
||||
</div>
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={currentPage === totalPages}
|
||||
className="flex items-center rounded bg-slate-800 px-3 py-1.5 text-xs text-slate-300 transition hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
下一页
|
||||
<ChevronRight className="ml-1 h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
export const STAGES = [
|
||||
{ id: 'scan', name: '扫描目录' },
|
||||
{ id: 'preprocess', name: '音频预处理' },
|
||||
{ id: 'match', name: '音乐匹配' },
|
||||
{ id: 'dedupe', name: '重复检测' },
|
||||
{ id: 'organize', name: '整理入库' },
|
||||
{ id: 'complete', name: '批次完成' }
|
||||
];
|
||||
|
||||
export const TRASH_REASONS = [
|
||||
{ id: 'low_score', name: '匹配分过低' },
|
||||
{ id: 'missing_tags', name: '元数据缺失' },
|
||||
{ id: 'duplicates', name: '文件重复' },
|
||||
{ id: 'convert_failed', name: '转码失败' },
|
||||
{ id: 'match_failed', name: '匹配失败' },
|
||||
{ id: 'organize_failed', name: '入库失败' }
|
||||
];
|
||||
|
||||
export const MOCK_LIBRARY_TRACKS = Array.from({ length: 45 }).map((_, idx) => ({
|
||||
id: idx + 1,
|
||||
file: `track_${1000 + idx}.flac`,
|
||||
title: `Beautiful Day ${idx + 1}`,
|
||||
artist: idx % 3 === 0 ? 'Coldplay Cover' : 'U2 Cover Band',
|
||||
album: `Greatest Hits ${2020 + (idx % 4)}`,
|
||||
hasCover: idx % 5 !== 0,
|
||||
hasLyrics: idx % 4 !== 0,
|
||||
time: `2023-10-26 14:${String(idx % 60).padStart(2, '0')}`
|
||||
}));
|
||||
|
||||
export function createInitialConfig() {
|
||||
return {
|
||||
input: '/volume1/downloads/music',
|
||||
output: '/volume1/docker/navidrome/music',
|
||||
trash: '/volume1/docker/navidrome/trash',
|
||||
schedule: {
|
||||
enabled: true,
|
||||
type: 'daily',
|
||||
dayOfWeek: '1',
|
||||
time: '02:00',
|
||||
cron: '0 2 * * *'
|
||||
},
|
||||
advancedStrategy: {
|
||||
metadataFallback: true,
|
||||
downloadAssets: true,
|
||||
replaceLowQualityDuplicates: false
|
||||
},
|
||||
notifications: {
|
||||
dingtalkWebhook: '',
|
||||
dingtalkSecret: '',
|
||||
telegramBotToken: '',
|
||||
telegramChatId: '',
|
||||
emailSmtp: '',
|
||||
emailUser: '',
|
||||
emailPass: '',
|
||||
emailTo: ''
|
||||
},
|
||||
metadata: {
|
||||
acoustidUrl: 'https://api.acoustid.org/v2',
|
||||
acoustidClientKey: '',
|
||||
musicbrainz: 'https://musicbrainz.org/ws/2/',
|
||||
netease: 'http://localhost:3000',
|
||||
qq: 'http://localhost:3300',
|
||||
spotifyUrl: 'https://api.spotify.com/v1',
|
||||
spotifyClientId: '',
|
||||
spotifySecret: '',
|
||||
discogsUrl: 'https://api.discogs.com',
|
||||
discogsToken: '',
|
||||
lastfmUrl: 'https://ws.audioscrobbler.com/2.0/',
|
||||
lastfmKey: '',
|
||||
geniusUrl: 'https://api.genius.com',
|
||||
geniusToken: ''
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function deriveTaskState(config, task = null) {
|
||||
if (!config.input?.trim() || !config.output?.trim() || !config.trash?.trim()) {
|
||||
return 'unconfigured';
|
||||
}
|
||||
|
||||
if (task?.status === 'pending' || task?.status === 'running') {
|
||||
return 'running';
|
||||
}
|
||||
|
||||
if (task?.status === 'completed') {
|
||||
return 'completed';
|
||||
}
|
||||
|
||||
if (task?.status === 'failed') {
|
||||
return 'ready';
|
||||
}
|
||||
|
||||
return 'ready';
|
||||
}
|
||||
|
||||
export function getStageIndex(stageId) {
|
||||
const stageIndex = STAGES.findIndex((stage) => stage.id === stageId);
|
||||
return stageIndex === -1 ? 0 : stageIndex;
|
||||
}
|
||||
|
||||
export function createEmptyProgress() {
|
||||
return {
|
||||
stageIndex: 0,
|
||||
percent: 0,
|
||||
currentFile: '-',
|
||||
stats: { total: 0, processed: 0, success: 0, failed: 0, skipped: 0 },
|
||||
logs: [],
|
||||
trashDistribution: {
|
||||
low_score: 0,
|
||||
missing_tags: 0,
|
||||
duplicates: 0,
|
||||
convert_failed: 0,
|
||||
match_failed: 0,
|
||||
organize_failed: 0
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -17,6 +17,93 @@ body {
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
html {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgb(71 85 105) transparent;
|
||||
}
|
||||
|
||||
body,
|
||||
#root,
|
||||
div,
|
||||
section,
|
||||
main,
|
||||
aside,
|
||||
nav,
|
||||
article,
|
||||
ul,
|
||||
ol,
|
||||
pre,
|
||||
textarea {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgb(71 85 105) transparent;
|
||||
}
|
||||
|
||||
html::-webkit-scrollbar,
|
||||
body::-webkit-scrollbar,
|
||||
#root::-webkit-scrollbar,
|
||||
div::-webkit-scrollbar,
|
||||
section::-webkit-scrollbar,
|
||||
main::-webkit-scrollbar,
|
||||
aside::-webkit-scrollbar,
|
||||
nav::-webkit-scrollbar,
|
||||
article::-webkit-scrollbar,
|
||||
ul::-webkit-scrollbar,
|
||||
ol::-webkit-scrollbar,
|
||||
pre::-webkit-scrollbar,
|
||||
textarea::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
html::-webkit-scrollbar-track,
|
||||
body::-webkit-scrollbar-track,
|
||||
#root::-webkit-scrollbar-track,
|
||||
div::-webkit-scrollbar-track,
|
||||
section::-webkit-scrollbar-track,
|
||||
main::-webkit-scrollbar-track,
|
||||
aside::-webkit-scrollbar-track,
|
||||
nav::-webkit-scrollbar-track,
|
||||
article::-webkit-scrollbar-track,
|
||||
ul::-webkit-scrollbar-track,
|
||||
ol::-webkit-scrollbar-track,
|
||||
pre::-webkit-scrollbar-track,
|
||||
textarea::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
html::-webkit-scrollbar-thumb,
|
||||
body::-webkit-scrollbar-thumb,
|
||||
#root::-webkit-scrollbar-thumb,
|
||||
div::-webkit-scrollbar-thumb,
|
||||
section::-webkit-scrollbar-thumb,
|
||||
main::-webkit-scrollbar-thumb,
|
||||
aside::-webkit-scrollbar-thumb,
|
||||
nav::-webkit-scrollbar-thumb,
|
||||
article::-webkit-scrollbar-thumb,
|
||||
ul::-webkit-scrollbar-thumb,
|
||||
ol::-webkit-scrollbar-thumb,
|
||||
pre::-webkit-scrollbar-thumb,
|
||||
textarea::-webkit-scrollbar-thumb {
|
||||
border-radius: 9999px;
|
||||
background-color: rgb(71 85 105 / 0.9);
|
||||
}
|
||||
|
||||
html::-webkit-scrollbar-thumb:hover,
|
||||
body::-webkit-scrollbar-thumb:hover,
|
||||
#root::-webkit-scrollbar-thumb:hover,
|
||||
div::-webkit-scrollbar-thumb:hover,
|
||||
section::-webkit-scrollbar-thumb:hover,
|
||||
main::-webkit-scrollbar-thumb:hover,
|
||||
aside::-webkit-scrollbar-thumb:hover,
|
||||
nav::-webkit-scrollbar-thumb:hover,
|
||||
article::-webkit-scrollbar-thumb:hover,
|
||||
ul::-webkit-scrollbar-thumb:hover,
|
||||
ol::-webkit-scrollbar-thumb:hover,
|
||||
pre::-webkit-scrollbar-thumb:hover,
|
||||
textarea::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgb(100 116 139 / 0.95);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -28,6 +115,11 @@ textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input.dark-native-control,
|
||||
select.dark-native-control {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.animate-in {
|
||||
animation-duration: 180ms;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,551 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
X,
|
||||
XCircle
|
||||
} from 'lucide-react';
|
||||
import { fetchTaskHistory, fetchTaskItems } from '../api/tasks';
|
||||
|
||||
const HISTORY_ITEMS_PER_PAGE = 8;
|
||||
const DETAILS_ITEMS_PER_PAGE = 8;
|
||||
|
||||
const EMPTY_DETAILS_STATE = {
|
||||
items: [],
|
||||
total: 0,
|
||||
isLoading: false,
|
||||
error: ''
|
||||
};
|
||||
|
||||
export default function HistoryPage() {
|
||||
const [selectedJob, setSelectedJob] = useState(null);
|
||||
const [historyPage, setHistoryPage] = useState(1);
|
||||
const [detailsPage, setDetailsPage] = useState(1);
|
||||
const [historyItems, setHistoryItems] = useState([]);
|
||||
const [historyTotal, setHistoryTotal] = useState(0);
|
||||
const [isHistoryLoading, setIsHistoryLoading] = useState(true);
|
||||
const [historyError, setHistoryError] = useState('');
|
||||
const [detailsState, setDetailsState] = useState(EMPTY_DETAILS_STATE);
|
||||
|
||||
const totalHistoryPages = Math.max(1, Math.ceil(historyTotal / HISTORY_ITEMS_PER_PAGE));
|
||||
const totalDetailsPages = Math.max(1, Math.ceil(detailsState.total / DETAILS_ITEMS_PER_PAGE));
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
|
||||
async function loadHistory() {
|
||||
setIsHistoryLoading(true);
|
||||
setHistoryError('');
|
||||
|
||||
try {
|
||||
const response = await fetchTaskHistory({
|
||||
page: historyPage,
|
||||
pageSize: HISTORY_ITEMS_PER_PAGE
|
||||
});
|
||||
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setHistoryItems(response.items.map(mapHistoryItem));
|
||||
setHistoryTotal(response.total);
|
||||
} catch (error) {
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setHistoryItems([]);
|
||||
setHistoryTotal(0);
|
||||
setHistoryError(error.message || '历史任务加载失败');
|
||||
} finally {
|
||||
if (!isCancelled) {
|
||||
setIsHistoryLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadHistory();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [historyPage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedJob?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isCancelled = false;
|
||||
setDetailsState((current) => ({
|
||||
...current,
|
||||
isLoading: true,
|
||||
error: '',
|
||||
total: current.total || selectedJob.total
|
||||
}));
|
||||
|
||||
async function loadDetails() {
|
||||
try {
|
||||
const response = await fetchTaskItems(selectedJob.id, {
|
||||
page: detailsPage,
|
||||
pageSize: DETAILS_ITEMS_PER_PAGE
|
||||
});
|
||||
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDetailsState({
|
||||
items: response.items.map(mapDetailItem),
|
||||
total: response.total,
|
||||
isLoading: false,
|
||||
error: ''
|
||||
});
|
||||
} catch (error) {
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDetailsState((current) => ({
|
||||
...current,
|
||||
items: [],
|
||||
isLoading: false,
|
||||
error: error.message || '任务详情加载失败'
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
loadDetails();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [detailsPage, selectedJob]);
|
||||
|
||||
function handleViewDetails(job) {
|
||||
setSelectedJob(job);
|
||||
setDetailsPage(1);
|
||||
setDetailsState({
|
||||
items: [],
|
||||
total: job.total,
|
||||
isLoading: true,
|
||||
error: ''
|
||||
});
|
||||
}
|
||||
|
||||
function handleCloseDetails() {
|
||||
setSelectedJob(null);
|
||||
setDetailsPage(1);
|
||||
setDetailsState(EMPTY_DETAILS_STATE);
|
||||
}
|
||||
|
||||
const historySummary = renderSummary(
|
||||
historyPage,
|
||||
HISTORY_ITEMS_PER_PAGE,
|
||||
historyTotal,
|
||||
'个历史任务'
|
||||
);
|
||||
const detailsSummary = renderSummary(
|
||||
detailsPage,
|
||||
DETAILS_ITEMS_PER_PAGE,
|
||||
detailsState.total,
|
||||
'条记录'
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-col py-6">
|
||||
<div className="mb-8 shrink-0">
|
||||
<h2 className="mb-2 text-2xl font-bold text-white">任务执行历史</h2>
|
||||
<p className="text-slate-400">查看过往每次入库执行的批次记录、统计摘要与批次报告。</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 flex flex-1 flex-col overflow-hidden rounded-xl border border-slate-800 bg-slate-900 shadow-lg">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="sticky top-0 z-10 bg-slate-950/50 text-xs uppercase text-slate-400 backdrop-blur-md">
|
||||
<tr>
|
||||
<th className="px-6 py-4 font-medium">任务批次号</th>
|
||||
<th className="px-6 py-4 font-medium">执行时间</th>
|
||||
<th className="px-6 py-4 font-medium">处理总数</th>
|
||||
<th className="px-6 py-4 font-medium text-emerald-500">成功入库</th>
|
||||
<th className="px-6 py-4 font-medium text-rose-500">异常回收</th>
|
||||
<th className="px-6 py-4 font-medium">批次报告</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/50 text-slate-300">
|
||||
{renderHistoryRows({
|
||||
items: historyItems,
|
||||
isLoading: isHistoryLoading,
|
||||
error: historyError,
|
||||
onViewDetails: handleViewDetails
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="z-10 flex shrink-0 items-center justify-between border-t border-slate-800 bg-slate-900/50 px-6 py-4">
|
||||
<div className="text-xs text-slate-500">{historySummary}</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setHistoryPage((page) => Math.max(1, page - 1))}
|
||||
disabled={historyPage === 1 || isHistoryLoading}
|
||||
className="flex items-center rounded bg-slate-800 px-3 py-1.5 text-xs text-slate-300 transition hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||
上一页
|
||||
</button>
|
||||
<div className="rounded border border-slate-800 bg-slate-950 px-3 py-1.5 font-mono text-xs text-slate-400">
|
||||
{historyPage} / {totalHistoryPages}
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
setHistoryPage((page) => Math.min(totalHistoryPages, page + 1))
|
||||
}
|
||||
disabled={historyPage >= totalHistoryPages || isHistoryLoading}
|
||||
className="flex items-center rounded bg-slate-800 px-3 py-1.5 text-xs text-slate-300 transition hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
下一页
|
||||
<ChevronRight className="ml-1 h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedJob && (
|
||||
<div className="animate-in fade-in fixed inset-0 z-50 flex items-center justify-center bg-slate-950/80 p-6 backdrop-blur-sm duration-200">
|
||||
<div className="flex max-h-[85vh] w-full max-w-5xl flex-col overflow-hidden rounded-xl border border-slate-700 bg-slate-900 shadow-2xl">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-slate-800 bg-slate-900/50 px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="h-5 w-5 text-blue-400" />
|
||||
<h3 className="font-mono text-lg font-semibold text-white">
|
||||
{selectedJob.id}{' '}
|
||||
<span className="ml-2 font-sans text-sm text-slate-400">批次运行报告</span>
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCloseDetails}
|
||||
className="rounded p-1 text-slate-400 transition hover:bg-slate-800 hover:text-white"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 gap-4 border-b border-slate-800 bg-slate-950/30 px-6 py-4">
|
||||
<div className="flex flex-1 items-center justify-between rounded-lg border border-slate-800 bg-slate-950 p-3">
|
||||
<span className="text-xs uppercase text-slate-500">执行时间</span>
|
||||
<span className="font-mono text-sm text-slate-300">{selectedJob.date}</span>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-between rounded-lg border border-blue-900/30 bg-blue-950/20 p-3">
|
||||
<span className="text-xs uppercase text-blue-500/70">处理总数</span>
|
||||
<span className="font-mono text-lg font-bold text-blue-400">
|
||||
{selectedJob.total}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-between rounded-lg border border-emerald-900/30 bg-emerald-950/20 p-3">
|
||||
<span className="text-xs uppercase text-emerald-500/70">成功入库</span>
|
||||
<span className="font-mono text-lg font-bold text-emerald-400">
|
||||
{selectedJob.success}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-between rounded-lg border border-rose-900/30 bg-rose-950/20 p-3">
|
||||
<span className="text-xs uppercase text-rose-500/70">异常回收</span>
|
||||
<span className="font-mono text-lg font-bold text-rose-400">
|
||||
{selectedJob.failed}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="sticky top-0 z-10 bg-slate-950/80 text-xs uppercase text-slate-400 backdrop-blur-md">
|
||||
<tr>
|
||||
<th className="px-6 py-3 font-medium">状态</th>
|
||||
<th className="px-6 py-3 font-medium">源文件</th>
|
||||
<th className="px-6 py-3 font-medium">识别艺人</th>
|
||||
<th className="px-6 py-3 font-medium">识别专辑</th>
|
||||
<th className="px-6 py-3 font-medium">详细信息</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/50 text-slate-300">
|
||||
{renderDetailRows(detailsState)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-between border-t border-slate-800 bg-slate-900/50 px-6 py-4">
|
||||
<div className="text-xs text-slate-500">{detailsSummary}</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setDetailsPage((page) => Math.max(1, page - 1))}
|
||||
disabled={detailsPage === 1 || detailsState.isLoading}
|
||||
className="flex items-center rounded bg-slate-800 px-3 py-1.5 text-xs text-slate-300 transition hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||
上一页
|
||||
</button>
|
||||
<div className="rounded border border-slate-800 bg-slate-950 px-3 py-1.5 font-mono text-xs text-slate-400">
|
||||
{detailsPage} / {totalDetailsPages}
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
setDetailsPage((page) => Math.min(totalDetailsPages, page + 1))
|
||||
}
|
||||
disabled={detailsPage >= totalDetailsPages || detailsState.isLoading}
|
||||
className="flex items-center rounded bg-slate-800 px-3 py-1.5 text-xs text-slate-300 transition hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
下一页
|
||||
<ChevronRight className="ml-1 h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderHistoryRows({ items, isLoading, error, onViewDetails }) {
|
||||
if (isLoading) {
|
||||
return [renderStateRow('正在加载历史任务...', 6)];
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return [renderStateRow(error, 6, 'text-rose-400')];
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return [renderStateRow('暂无历史任务', 6)];
|
||||
}
|
||||
|
||||
return items.map((job) => (
|
||||
<tr key={job.id} className="transition-colors hover:bg-slate-800/20">
|
||||
<td className="flex items-center px-6 py-4 font-mono text-xs font-semibold text-blue-400">
|
||||
{job.status === 'warning' ? (
|
||||
<AlertCircle className="mr-2 h-4 w-4 text-amber-500" />
|
||||
) : (
|
||||
<CheckCircle2 className="mr-2 h-4 w-4 text-emerald-500" />
|
||||
)}
|
||||
{job.id}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-slate-400">{job.date}</td>
|
||||
<td className="px-6 py-4 font-mono font-medium">{job.total}</td>
|
||||
<td className="px-6 py-4 font-mono font-medium text-emerald-400">{job.success}</td>
|
||||
<td className="px-6 py-4 font-mono font-medium text-rose-400">{job.failed}</td>
|
||||
<td className="px-6 py-4">
|
||||
<button
|
||||
onClick={() => onViewDetails(job)}
|
||||
className="flex items-center rounded border border-blue-500/20 bg-blue-500/10 px-3 py-1 text-xs text-blue-400 transition hover:bg-blue-500/20 hover:text-blue-300"
|
||||
>
|
||||
<FileText className="mr-1.5 h-4 w-4" />
|
||||
查看详情
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
));
|
||||
}
|
||||
|
||||
function renderDetailRows({ items, isLoading, error }) {
|
||||
if (isLoading) {
|
||||
return [renderStateRow('正在加载任务详情...', 5)];
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return [renderStateRow(error, 5, 'text-rose-400')];
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return [renderStateRow('当前批次暂无明细记录', 5)];
|
||||
}
|
||||
|
||||
return items.map((detail) => (
|
||||
<tr key={detail.id} className="transition-colors hover:bg-slate-800/30">
|
||||
<td className="px-6 py-3">
|
||||
{detail.status === 'success' ? (
|
||||
<span className="inline-flex items-center rounded border border-emerald-500/20 bg-emerald-500/10 px-2 py-1 text-xs font-medium text-emerald-400">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" /> 成功
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center rounded border border-rose-500/20 bg-rose-500/10 px-2 py-1 text-xs font-medium text-rose-400">
|
||||
<XCircle className="mr-1 h-3 w-3" /> 异常
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td
|
||||
className="max-w-[200px] truncate px-6 py-3 font-mono text-xs text-slate-400"
|
||||
title={detail.file}
|
||||
>
|
||||
{detail.file}
|
||||
</td>
|
||||
<td className="px-6 py-3">{detail.artist}</td>
|
||||
<td className="px-6 py-3 text-slate-400">{detail.album}</td>
|
||||
<td
|
||||
className={`px-6 py-3 text-xs ${
|
||||
detail.status === 'success' ? 'text-emerald-500/70' : 'text-rose-400'
|
||||
}`}
|
||||
>
|
||||
{detail.message}
|
||||
</td>
|
||||
</tr>
|
||||
));
|
||||
}
|
||||
|
||||
function renderStateRow(message, colSpan, className = 'text-slate-500') {
|
||||
return (
|
||||
<tr key={`${colSpan}-${message}`}>
|
||||
<td className={`px-6 py-10 text-center text-sm ${className}`} colSpan={colSpan}>
|
||||
{message}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function mapHistoryItem(item) {
|
||||
return {
|
||||
id: item.task_id,
|
||||
date: formatDateTime(item.started_at),
|
||||
status: item.report_status,
|
||||
total: item.total_items,
|
||||
success: item.success_items,
|
||||
failed: item.exception_items
|
||||
};
|
||||
}
|
||||
|
||||
function mapDetailItem(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
file: firstFilled(item.filename, item.relative_path, '-'),
|
||||
status: item.organize_status === 'organized' ? 'success' : 'failed',
|
||||
artist: firstFilled(
|
||||
item.matched_metadata_json?.artist,
|
||||
item.original_tags_json?.artist,
|
||||
'-'
|
||||
),
|
||||
album: firstFilled(
|
||||
item.matched_metadata_json?.album,
|
||||
item.original_tags_json?.album,
|
||||
'-'
|
||||
),
|
||||
message: resolveDetailMessage(item)
|
||||
};
|
||||
}
|
||||
|
||||
function resolveDetailMessage(item) {
|
||||
if (item.organize_status === 'organized') {
|
||||
return '成功入库';
|
||||
}
|
||||
|
||||
if (item.organize_status === 'trashed') {
|
||||
return firstFilled(item.organize_message, '入库失败后已移入回收站');
|
||||
}
|
||||
|
||||
if (item.organize_status === 'failed') {
|
||||
return firstFilled(item.organize_message, '整理入库失败');
|
||||
}
|
||||
|
||||
if (item.dedupe_status === 'duplicate_trashed') {
|
||||
return firstFilled(item.dedupe_message, '检测到重复文件,已移入回收站');
|
||||
}
|
||||
|
||||
if (item.dedupe_status === 'duplicate_replaced') {
|
||||
return firstFilled(item.dedupe_message, '当前文件质量更高,已替换库内旧文件');
|
||||
}
|
||||
|
||||
if (item.dedupe_status === 'failed') {
|
||||
return firstFilled(item.dedupe_message, '重复检测失败');
|
||||
}
|
||||
|
||||
if (item.match_status === 'low_score') {
|
||||
return firstFilled(item.match_message, '匹配分过低');
|
||||
}
|
||||
|
||||
if (item.match_status === 'not_found') {
|
||||
return firstFilled(item.match_message, '未找到匹配结果');
|
||||
}
|
||||
|
||||
if (item.match_status === 'failed') {
|
||||
return firstFilled(item.match_message, '元数据匹配失败');
|
||||
}
|
||||
|
||||
if (item.preprocess_status === 'failed') {
|
||||
return firstFilled(
|
||||
item.preprocess_message,
|
||||
item.preprocess_reason === 'convert_failed' ? '音频转码失败' : '预处理失败'
|
||||
);
|
||||
}
|
||||
|
||||
if (item.preprocess_status === 'warning') {
|
||||
return firstFilled(
|
||||
item.preprocess_message,
|
||||
String(item.preprocess_reason || '').includes('metadata_failed')
|
||||
? '无法提取有效元数据'
|
||||
: '预处理存在警告'
|
||||
);
|
||||
}
|
||||
|
||||
if (item.scan_status !== 'queued') {
|
||||
return firstFilled(item.scan_message, '扫描阶段已跳过');
|
||||
}
|
||||
|
||||
return '任务未完成,条目未最终入库';
|
||||
}
|
||||
|
||||
function renderSummary(page, pageSize, total, suffix) {
|
||||
if (total <= 0) {
|
||||
return (
|
||||
<>
|
||||
显示 0 到 0 条,共 <span className="font-mono text-slate-300">0</span> {suffix}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const start = (page - 1) * pageSize + 1;
|
||||
const end = Math.min(page * pageSize, total);
|
||||
return (
|
||||
<>
|
||||
显示 {start} 到 {end} 条,共{' '}
|
||||
<span className="font-mono text-slate-300">{total}</span> {suffix}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function firstFilled(...values) {
|
||||
for (const value of values) {
|
||||
if (typeof value === 'string') {
|
||||
if (value.trim()) {
|
||||
return value;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value != null) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return date.toLocaleString('zh-CN', {
|
||||
hour12: false,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,665 @@
|
||||
import { startTransition, useDeferredValue, useEffect, useState } from 'react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Disc3,
|
||||
FolderSearch,
|
||||
Layers3,
|
||||
Music4,
|
||||
RefreshCw,
|
||||
Search,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
import Pagination from '../components/Pagination';
|
||||
import { fetchLibrarySummary, fetchLibraryTracks } from '../api/library';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const EMPTY_SUMMARY = {
|
||||
total_tracks: 0,
|
||||
total_albums: 0,
|
||||
total_artists: 0,
|
||||
suspected_duplicates: 0,
|
||||
scanned_at: null
|
||||
};
|
||||
|
||||
export default function LibraryPage() {
|
||||
const [summary, setSummary] = useState(EMPTY_SUMMARY);
|
||||
const [tracksPage, setTracksPage] = useState({
|
||||
items: [],
|
||||
page: 1,
|
||||
page_size: PAGE_SIZE,
|
||||
total: 0
|
||||
});
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const deferredSearch = useDeferredValue(searchInput);
|
||||
const [filters, setFilters] = useState({
|
||||
artist: '',
|
||||
album: '',
|
||||
format: '',
|
||||
hasProvenance: 'all'
|
||||
});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [selectedTrack, setSelectedTrack] = useState(null);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isLoadingSummary, setIsLoadingSummary] = useState(true);
|
||||
const [isLoadingTracks, setIsLoadingTracks] = useState(true);
|
||||
const [summaryError, setSummaryError] = useState('');
|
||||
const [tracksError, setTracksError] = useState('');
|
||||
const [refreshToken, setRefreshToken] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
let cancelled = false;
|
||||
|
||||
async function loadSummary() {
|
||||
setIsLoadingSummary(true);
|
||||
setSummaryError('');
|
||||
|
||||
try {
|
||||
const payload = await fetchLibrarySummary({ signal: controller.signal });
|
||||
if (!cancelled) {
|
||||
setSummary(payload);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled && error.name !== 'AbortError') {
|
||||
setSummaryError(error.message);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoadingSummary(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadSummary();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [refreshToken]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
let cancelled = false;
|
||||
|
||||
async function loadTracks() {
|
||||
setIsLoadingTracks(true);
|
||||
setTracksError('');
|
||||
|
||||
try {
|
||||
const payload = await fetchLibraryTracks(
|
||||
{
|
||||
q: deferredSearch,
|
||||
artist: filters.artist,
|
||||
album: filters.album,
|
||||
format: filters.format,
|
||||
hasProvenance: toHasProvenanceValue(filters.hasProvenance),
|
||||
page: currentPage,
|
||||
pageSize: PAGE_SIZE,
|
||||
sortBy: 'organized_at',
|
||||
sortOrder: 'desc'
|
||||
},
|
||||
{ signal: controller.signal }
|
||||
);
|
||||
|
||||
if (!cancelled) {
|
||||
setTracksPage(payload);
|
||||
setSelectedTrack((currentTrack) => {
|
||||
if (!currentTrack) {
|
||||
return null;
|
||||
}
|
||||
return payload.items.find((item) => item.track_id === currentTrack.track_id) || null;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled && error.name !== 'AbortError') {
|
||||
setTracksError(error.message);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoadingTracks(false);
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadTracks();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [
|
||||
currentPage,
|
||||
deferredSearch,
|
||||
filters.album,
|
||||
filters.artist,
|
||||
filters.format,
|
||||
filters.hasProvenance,
|
||||
refreshToken
|
||||
]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil((tracksPage.total || 0) / PAGE_SIZE));
|
||||
|
||||
function handleRefresh() {
|
||||
setIsRefreshing(true);
|
||||
setSummaryError('');
|
||||
setTracksError('');
|
||||
setSelectedTrack(null);
|
||||
startTransition(() => {
|
||||
setRefreshToken((value) => value + 1);
|
||||
});
|
||||
}
|
||||
|
||||
function handleFilterChange(field, value) {
|
||||
startTransition(() => {
|
||||
setCurrentPage(1);
|
||||
setSelectedTrack(null);
|
||||
setFilters((current) => ({ ...current, [field]: value }));
|
||||
});
|
||||
}
|
||||
|
||||
function handleSearchChange(event) {
|
||||
const value = event.target.value;
|
||||
setSearchInput(value);
|
||||
startTransition(() => {
|
||||
setCurrentPage(1);
|
||||
setSelectedTrack(null);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-full py-6">
|
||||
<div className="mb-8 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h2 className="mb-2 text-2xl font-bold text-white">音乐库总览</h2>
|
||||
<p className="text-sm text-slate-400">
|
||||
直接扫描输出目录,展示曲目元数据、音频质量和最近一次入库痕迹。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
className="flex items-center justify-center rounded-lg border border-slate-700 bg-slate-800 px-4 py-2 text-sm text-white transition hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
重新扫描音乐库
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{summaryError ? (
|
||||
<div className="mb-4 rounded-xl border border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
|
||||
统计加载失败:{summaryError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mb-6 grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<MetricCard
|
||||
icon={Music4}
|
||||
label="总曲目数"
|
||||
value={isLoadingSummary ? '...' : formatInteger(summary.total_tracks)}
|
||||
note={summary.scanned_at ? `扫描时间 ${formatDateTime(summary.scanned_at)}` : '等待首次扫描'}
|
||||
iconClass="bg-sky-500/20 text-sky-300"
|
||||
/>
|
||||
<MetricCard
|
||||
icon={Disc3}
|
||||
label="专辑数量"
|
||||
value={isLoadingSummary ? '...' : formatInteger(summary.total_albums)}
|
||||
note="按扫描结果中的专辑标签去重"
|
||||
iconClass="bg-emerald-500/20 text-emerald-300"
|
||||
/>
|
||||
<MetricCard
|
||||
icon={Layers3}
|
||||
label="艺术家数量"
|
||||
value={isLoadingSummary ? '...' : formatInteger(summary.total_artists)}
|
||||
note="按主艺人字段统计"
|
||||
iconClass="bg-amber-500/20 text-amber-300"
|
||||
/>
|
||||
<MetricCard
|
||||
icon={AlertTriangle}
|
||||
label="疑似重复"
|
||||
value={isLoadingSummary ? '...' : formatInteger(summary.suspected_duplicates)}
|
||||
note="仅读取 recording/release/text 身份键"
|
||||
iconClass="bg-rose-500/20 text-rose-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-lg">
|
||||
<div className="border-b border-slate-800 bg-slate-900/70 px-5 py-4">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">库内曲目</h3>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
列表默认按最近入库时间排序;没有入库痕迹的文件回退到文件修改时间。
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative w-full max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchInput}
|
||||
onChange={handleSearchChange}
|
||||
placeholder="搜索文件名、标题、艺人、专辑..."
|
||||
className="w-full rounded-lg border border-slate-700 bg-slate-950 py-2 pl-9 pr-4 text-sm text-white focus:border-sky-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<FilterInput
|
||||
label="艺人"
|
||||
value={filters.artist}
|
||||
placeholder="精确匹配艺人"
|
||||
onChange={(event) => handleFilterChange('artist', event.target.value)}
|
||||
/>
|
||||
<FilterInput
|
||||
label="专辑"
|
||||
value={filters.album}
|
||||
placeholder="精确匹配专辑"
|
||||
onChange={(event) => handleFilterChange('album', event.target.value)}
|
||||
/>
|
||||
<FilterInput
|
||||
label="格式"
|
||||
value={filters.format}
|
||||
placeholder="如 FLAC / MP3"
|
||||
onChange={(event) => handleFilterChange('format', event.target.value)}
|
||||
/>
|
||||
<label className="block text-xs text-slate-500">
|
||||
<span className="mb-1 block">入库痕迹</span>
|
||||
<select
|
||||
value={filters.hasProvenance}
|
||||
onChange={(event) => handleFilterChange('hasProvenance', event.target.value)}
|
||||
className="dark-native-control w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-white focus:border-sky-500 focus:outline-none"
|
||||
>
|
||||
<option value="all">全部</option>
|
||||
<option value="yes">仅显示已关联任务</option>
|
||||
<option value="no">仅显示未关联任务</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tracksError ? (
|
||||
<div className="border-b border-slate-800 bg-rose-500/10 px-5 py-4 text-sm text-rose-200">
|
||||
列表加载失败:{tracksError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-left text-sm">
|
||||
<thead className="bg-slate-950/70 text-xs uppercase tracking-wide text-slate-400">
|
||||
<tr>
|
||||
<th className="px-5 py-3 font-medium">文件名 / 标题</th>
|
||||
<th className="px-5 py-3 font-medium">艺人</th>
|
||||
<th className="px-5 py-3 font-medium">专辑</th>
|
||||
<th className="px-5 py-3 font-medium">格式质量</th>
|
||||
<th className="px-5 py-3 font-medium">来源任务</th>
|
||||
<th className="px-5 py-3 font-medium">最近入库时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/60 text-slate-300">
|
||||
{isLoadingTracks ? (
|
||||
Array.from({ length: 6 }).map((_, index) => (
|
||||
<tr key={`skeleton-${index}`} className="animate-pulse">
|
||||
<td className="px-5 py-4">
|
||||
<div className="h-4 w-40 rounded bg-slate-800" />
|
||||
<div className="mt-2 h-3 w-56 rounded bg-slate-900" />
|
||||
</td>
|
||||
<td className="px-5 py-4"><div className="h-4 w-24 rounded bg-slate-800" /></td>
|
||||
<td className="px-5 py-4"><div className="h-4 w-32 rounded bg-slate-800" /></td>
|
||||
<td className="px-5 py-4"><div className="h-4 w-28 rounded bg-slate-800" /></td>
|
||||
<td className="px-5 py-4"><div className="h-4 w-36 rounded bg-slate-800" /></td>
|
||||
<td className="px-5 py-4"><div className="h-4 w-32 rounded bg-slate-800" /></td>
|
||||
</tr>
|
||||
))
|
||||
) : tracksPage.items.length > 0 ? (
|
||||
tracksPage.items.map((track) => (
|
||||
<tr
|
||||
key={track.track_id}
|
||||
onClick={() => setSelectedTrack(track)}
|
||||
className={`cursor-pointer transition-colors hover:bg-slate-800/40 ${
|
||||
selectedTrack?.track_id === track.track_id ? 'bg-slate-800/50' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="px-5 py-4">
|
||||
<div className="font-medium text-white">{track.title || track.filename}</div>
|
||||
<div className="mt-1 font-mono text-xs text-sky-300">{track.filename}</div>
|
||||
</td>
|
||||
<td className="px-5 py-4">{track.artist || '-'}</td>
|
||||
<td className="px-5 py-4 text-slate-400">{track.album || '-'}</td>
|
||||
<td className="px-5 py-4 text-slate-300">{formatQualityLabel(track)}</td>
|
||||
<td className="px-5 py-4">{renderTaskLabel(track.ingest_provenance)}</td>
|
||||
<td className="px-5 py-4 text-slate-400">
|
||||
{track.ingest_provenance?.organized_at
|
||||
? formatDateTime(track.ingest_provenance.organized_at)
|
||||
: '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-5 py-12 text-center">
|
||||
<div className="mx-auto flex max-w-sm flex-col items-center">
|
||||
<FolderSearch className="mb-3 h-10 w-10 text-slate-600" />
|
||||
<div className="text-base font-medium text-slate-300">没有匹配到任何曲目</div>
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
继续调整搜索或筛选条件,或者确认输出目录中已经存在音频文件。
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPrev={() =>
|
||||
startTransition(() => {
|
||||
setCurrentPage((page) => Math.max(1, page - 1));
|
||||
setSelectedTrack(null);
|
||||
})
|
||||
}
|
||||
onNext={() =>
|
||||
startTransition(() => {
|
||||
setCurrentPage((page) => Math.min(totalPages, page + 1));
|
||||
setSelectedTrack(null);
|
||||
})
|
||||
}
|
||||
summary={
|
||||
tracksPage.total > 0 ? (
|
||||
<>
|
||||
显示 {(currentPage - 1) * PAGE_SIZE + 1} 到{' '}
|
||||
{Math.min(currentPage * PAGE_SIZE, tracksPage.total)} 条,共{' '}
|
||||
<span className="font-mono text-slate-300">{tracksPage.total}</span> 条曲目
|
||||
</>
|
||||
) : (
|
||||
'当前没有可展示的曲目'
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedTrack ? (
|
||||
<TrackDetailsDrawer
|
||||
track={selectedTrack}
|
||||
onClose={() => setSelectedTrack(null)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ icon: Icon, label, value, note, iconClass }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-800 bg-slate-900 p-5 shadow-lg">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-wide text-slate-500">{label}</div>
|
||||
<div className="mt-3 font-mono text-3xl font-bold text-white">{value}</div>
|
||||
</div>
|
||||
<div className={`rounded-xl p-3 ${iconClass}`}>
|
||||
<Icon className="h-6 w-6" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-slate-500">{note}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterInput({ label, value, onChange, placeholder }) {
|
||||
return (
|
||||
<label className="block text-xs text-slate-500">
|
||||
<span className="mb-1 block">{label}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
className="w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-white focus:border-sky-500 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function TrackDetailsDrawer({ track, onClose }) {
|
||||
return (
|
||||
<div className="absolute inset-0 z-20 flex justify-end bg-slate-950/55 backdrop-blur-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="关闭详情"
|
||||
className="flex-1"
|
||||
/>
|
||||
<aside className="relative flex h-full w-full max-w-xl flex-col border-l border-slate-800 bg-slate-950 shadow-2xl">
|
||||
<div className="flex items-start justify-between border-b border-slate-800 px-5 py-4">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-wide text-slate-500">曲目详情</div>
|
||||
<h3 className="mt-2 text-xl font-semibold text-white">
|
||||
{track.title || track.filename}
|
||||
</h3>
|
||||
<p className="mt-1 font-mono text-xs text-slate-500">{track.library_relative_path}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-slate-700 bg-slate-900 p-2 text-slate-400 transition hover:bg-slate-800 hover:text-white"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-6 overflow-y-auto px-5 py-5">
|
||||
<DetailSection title="路径与入库">
|
||||
<DetailRow label="绝对路径" value={track.library_file_path} mono />
|
||||
<DetailRow label="库内相对路径" value={track.library_relative_path} mono />
|
||||
<DetailRow
|
||||
label="最近入库时间"
|
||||
value={
|
||||
track.ingest_provenance?.organized_at
|
||||
? formatDateTime(track.ingest_provenance.organized_at)
|
||||
: '未关联任务'
|
||||
}
|
||||
/>
|
||||
<DetailRow
|
||||
label="来源任务"
|
||||
value={track.ingest_provenance?.task_id || '未关联'}
|
||||
mono={Boolean(track.ingest_provenance?.task_id)}
|
||||
/>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="标准化元数据">
|
||||
<DetailRow label="标题" value={track.title || '-'} />
|
||||
<DetailRow label="艺人" value={track.artist || '-'} />
|
||||
<DetailRow label="专辑" value={track.album || '-'} />
|
||||
<DetailRow label="专辑艺人" value={track.album_artist || '-'} />
|
||||
<DetailRow label="音轨号" value={formatTrackIndex(track.disc_number, track.track_number)} />
|
||||
<DetailRow label="年份" value={track.year || '-'} />
|
||||
<DetailRow label="时长" value={formatDuration(track.duration_seconds)} />
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="音频参数">
|
||||
<DetailRow label="格式" value={track.format || '-'} />
|
||||
<DetailRow label="编码" value={track.codec || '-'} />
|
||||
<DetailRow label="采样率" value={track.sample_rate ? `${track.sample_rate} Hz` : '-'} />
|
||||
<DetailRow label="位深" value={track.bit_depth ? `${track.bit_depth} bit` : '-'} />
|
||||
<DetailRow label="比特率" value={track.bitrate ? `${Math.round(track.bitrate / 1000)} kbps` : '-'} />
|
||||
<DetailRow label="声道" value={track.channels || '-'} />
|
||||
<DetailRow label="文件大小" value={formatBytes(track.size_bytes)} />
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="来源任务与去重">
|
||||
<DetailRow
|
||||
label="匹配来源"
|
||||
value={track.ingest_provenance?.match_source || '未知'}
|
||||
/>
|
||||
<DetailRow
|
||||
label="匹配置信度"
|
||||
value={
|
||||
track.ingest_provenance?.match_confidence != null
|
||||
? `${track.ingest_provenance.match_confidence.toFixed(1)}`
|
||||
: '-'
|
||||
}
|
||||
/>
|
||||
<DetailRow
|
||||
label="去重结论"
|
||||
value={formatDedupeStatus(track.ingest_provenance?.dedupe_status)}
|
||||
/>
|
||||
<DetailRow
|
||||
label="文件修改时间"
|
||||
value={track.modified_at ? formatDateTime(track.modified_at) : '-'}
|
||||
/>
|
||||
</DetailSection>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailSection({ title, children }) {
|
||||
return (
|
||||
<section>
|
||||
<h4 className="mb-3 text-sm font-semibold text-white">{title}</h4>
|
||||
<div className="space-y-2 rounded-xl border border-slate-800 bg-slate-900/80 p-4">
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({ label, value, mono = false }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[6rem_minmax(0,1fr)] gap-3 text-sm">
|
||||
<div className="text-slate-500">{label}</div>
|
||||
<div className={`break-all text-slate-200 ${mono ? 'font-mono text-xs' : ''}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderTaskLabel(provenance) {
|
||||
if (!provenance) {
|
||||
return <span className="text-slate-500">未关联</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="font-mono text-xs text-emerald-300">{shortTaskId(provenance.task_id)}</div>
|
||||
<div className="mt-1 text-xs text-slate-500">
|
||||
{(provenance.match_source || 'unknown').toLowerCase()} · {formatDedupeStatus(provenance.dedupe_status)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function toHasProvenanceValue(value) {
|
||||
if (value === 'yes') {
|
||||
return true;
|
||||
}
|
||||
if (value === 'no') {
|
||||
return false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function shortTaskId(taskId) {
|
||||
if (!taskId) {
|
||||
return '-';
|
||||
}
|
||||
return taskId.length > 12 ? `${taskId.slice(0, 8)}...` : taskId;
|
||||
}
|
||||
|
||||
function formatInteger(value) {
|
||||
return new Intl.NumberFormat('zh-CN').format(value || 0);
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let size = value;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
|
||||
return `${size >= 10 || unitIndex === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return date.toLocaleString('zh-CN', {
|
||||
hour12: false,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function formatDuration(value) {
|
||||
if (value == null) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const totalSeconds = Math.max(0, Math.round(value));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatQualityLabel(track) {
|
||||
const parts = [];
|
||||
if (track.format) {
|
||||
parts.push(track.format);
|
||||
}
|
||||
if (track.bit_depth) {
|
||||
parts.push(`${track.bit_depth}bit`);
|
||||
}
|
||||
if (track.sample_rate) {
|
||||
parts.push(`${(track.sample_rate / 1000).toFixed(track.sample_rate % 1000 === 0 ? 0 : 1)}kHz`);
|
||||
}
|
||||
if (track.bitrate) {
|
||||
parts.push(`${Math.round(track.bitrate / 1000)}kbps`);
|
||||
}
|
||||
return parts.length > 0 ? parts.join(' · ') : '-';
|
||||
}
|
||||
|
||||
function formatTrackIndex(discNumber, trackNumber) {
|
||||
if (!discNumber && !trackNumber) {
|
||||
return '-';
|
||||
}
|
||||
if (!discNumber) {
|
||||
return `#${trackNumber}`;
|
||||
}
|
||||
return `Disc ${discNumber} · #${trackNumber || '-'}`;
|
||||
}
|
||||
|
||||
function formatDedupeStatus(status) {
|
||||
if (!status) {
|
||||
return '未知';
|
||||
}
|
||||
|
||||
const labels = {
|
||||
unique: '保留',
|
||||
duplicate_replaced: '替换旧库文件',
|
||||
duplicate_trashed: '移入回收站'
|
||||
};
|
||||
return labels[status] || status;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,270 @@
|
||||
const BACKUP_VERSION = 1;
|
||||
const BACKUP_SOURCE = 'music-workshop-settings';
|
||||
const BACKUP_ENCRYPTION_META = {
|
||||
algorithm: 'AES-GCM',
|
||||
kdf: 'PBKDF2',
|
||||
hash: 'SHA-256',
|
||||
iterations: 250000
|
||||
};
|
||||
|
||||
export const SENSITIVE_FIELD_PATHS = [
|
||||
'notifications.dingtalkWebhook',
|
||||
'notifications.dingtalkSecret',
|
||||
'notifications.telegramBotToken',
|
||||
'notifications.emailPass',
|
||||
'metadata.acoustidClientKey',
|
||||
'metadata.spotifySecret',
|
||||
'metadata.discogsToken',
|
||||
'metadata.lastfmKey',
|
||||
'metadata.geniusToken'
|
||||
];
|
||||
|
||||
export function isBackupSupported() {
|
||||
return (
|
||||
typeof window !== 'undefined'
|
||||
&& typeof window.crypto !== 'undefined'
|
||||
&& typeof window.crypto.subtle !== 'undefined'
|
||||
);
|
||||
}
|
||||
|
||||
export async function buildConfigBackup(config, password) {
|
||||
assertBackupPassword(password);
|
||||
|
||||
const sanitizedConfig = deepClone(config);
|
||||
const secrets = {};
|
||||
|
||||
SENSITIVE_FIELD_PATHS.forEach((path) => {
|
||||
const value = getValueAtPath(config, path);
|
||||
setValueAtPath(secrets, path, typeof value === 'string' ? value : '');
|
||||
setValueAtPath(sanitizedConfig, path, '');
|
||||
});
|
||||
|
||||
return {
|
||||
version: BACKUP_VERSION,
|
||||
exportedAt: new Date().toISOString(),
|
||||
source: BACKUP_SOURCE,
|
||||
config: sanitizedConfig,
|
||||
encryptedSecrets: await encryptSecrets(secrets, password)
|
||||
};
|
||||
}
|
||||
|
||||
export function parseConfigBackup(fileText) {
|
||||
let payload;
|
||||
|
||||
try {
|
||||
payload = JSON.parse(fileText);
|
||||
} catch {
|
||||
throw new Error('配置文件不是有效的 JSON。');
|
||||
}
|
||||
|
||||
validateBackupPayload(payload);
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function restoreConfigFromBackup(payload, password) {
|
||||
validateBackupPayload(payload);
|
||||
assertBackupPassword(password);
|
||||
|
||||
const secrets = await decryptSecrets(payload.encryptedSecrets, password);
|
||||
const restoredConfig = deepClone(payload.config);
|
||||
|
||||
SENSITIVE_FIELD_PATHS.forEach((path) => {
|
||||
setValueAtPath(restoredConfig, path, getValueAtPath(secrets, path) || '');
|
||||
});
|
||||
|
||||
return restoredConfig;
|
||||
}
|
||||
|
||||
export function createBackupFilename(timestamp = new Date()) {
|
||||
const year = String(timestamp.getFullYear());
|
||||
const month = String(timestamp.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(timestamp.getDate()).padStart(2, '0');
|
||||
const hours = String(timestamp.getHours()).padStart(2, '0');
|
||||
const minutes = String(timestamp.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(timestamp.getSeconds()).padStart(2, '0');
|
||||
|
||||
return `music-workshop-config-${year}${month}${day}-${hours}${minutes}${seconds}.json`;
|
||||
}
|
||||
|
||||
export function triggerJsonDownload(filename, payload) {
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], {
|
||||
type: 'application/json'
|
||||
});
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
|
||||
async function encryptSecrets(secrets, password) {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const key = await deriveAesKey(password, salt);
|
||||
const plaintext = new TextEncoder().encode(JSON.stringify(secrets));
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{ name: BACKUP_ENCRYPTION_META.algorithm, iv },
|
||||
key,
|
||||
plaintext
|
||||
);
|
||||
|
||||
return {
|
||||
...BACKUP_ENCRYPTION_META,
|
||||
salt: bytesToBase64(salt),
|
||||
iv: bytesToBase64(iv),
|
||||
fields: [...SENSITIVE_FIELD_PATHS],
|
||||
ciphertext: bytesToBase64(new Uint8Array(ciphertext))
|
||||
};
|
||||
}
|
||||
|
||||
async function decryptSecrets(encryptedSecrets, password) {
|
||||
try {
|
||||
const salt = base64ToBytes(encryptedSecrets.salt);
|
||||
const iv = base64ToBytes(encryptedSecrets.iv);
|
||||
const ciphertext = base64ToBytes(encryptedSecrets.ciphertext);
|
||||
const key = await deriveAesKey(password, salt);
|
||||
const plaintext = await crypto.subtle.decrypt(
|
||||
{ name: BACKUP_ENCRYPTION_META.algorithm, iv },
|
||||
key,
|
||||
ciphertext
|
||||
);
|
||||
const secrets = JSON.parse(new TextDecoder().decode(plaintext));
|
||||
|
||||
if (!isPlainObject(secrets)) {
|
||||
throw new Error('invalid secrets payload');
|
||||
}
|
||||
|
||||
return secrets;
|
||||
} catch {
|
||||
throw new Error('解密失败,请检查口令是否正确,或确认导出文件未损坏。');
|
||||
}
|
||||
}
|
||||
|
||||
async function deriveAesKey(password, salt) {
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(password),
|
||||
{ name: BACKUP_ENCRYPTION_META.kdf },
|
||||
false,
|
||||
['deriveKey']
|
||||
);
|
||||
|
||||
return crypto.subtle.deriveKey(
|
||||
{
|
||||
name: BACKUP_ENCRYPTION_META.kdf,
|
||||
salt,
|
||||
iterations: BACKUP_ENCRYPTION_META.iterations,
|
||||
hash: BACKUP_ENCRYPTION_META.hash
|
||||
},
|
||||
keyMaterial,
|
||||
{ name: BACKUP_ENCRYPTION_META.algorithm, length: 256 },
|
||||
false,
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
}
|
||||
|
||||
function validateBackupPayload(payload) {
|
||||
if (!isPlainObject(payload)) {
|
||||
throw new Error('配置文件结构无效。');
|
||||
}
|
||||
|
||||
if (payload.version !== BACKUP_VERSION) {
|
||||
throw new Error('配置文件版本不受支持。');
|
||||
}
|
||||
|
||||
if (payload.source !== BACKUP_SOURCE) {
|
||||
throw new Error('只支持导入由本系统导出的配置文件。');
|
||||
}
|
||||
|
||||
if (!isPlainObject(payload.config)) {
|
||||
throw new Error('配置文件缺少 config 内容。');
|
||||
}
|
||||
|
||||
if (!isPlainObject(payload.encryptedSecrets)) {
|
||||
throw new Error('配置文件缺少加密密钥信息。');
|
||||
}
|
||||
|
||||
const encryption = payload.encryptedSecrets;
|
||||
if (
|
||||
encryption.algorithm !== BACKUP_ENCRYPTION_META.algorithm
|
||||
|| encryption.kdf !== BACKUP_ENCRYPTION_META.kdf
|
||||
|| encryption.hash !== BACKUP_ENCRYPTION_META.hash
|
||||
|| encryption.iterations !== BACKUP_ENCRYPTION_META.iterations
|
||||
) {
|
||||
throw new Error('配置文件加密参数不受支持。');
|
||||
}
|
||||
|
||||
if (
|
||||
!Array.isArray(encryption.fields)
|
||||
|| encryption.fields.length !== SENSITIVE_FIELD_PATHS.length
|
||||
|| encryption.fields.some((field, index) => field !== SENSITIVE_FIELD_PATHS[index])
|
||||
) {
|
||||
throw new Error('配置文件中的加密字段列表无效。');
|
||||
}
|
||||
|
||||
if (
|
||||
typeof encryption.salt !== 'string'
|
||||
|| typeof encryption.iv !== 'string'
|
||||
|| typeof encryption.ciphertext !== 'string'
|
||||
) {
|
||||
throw new Error('配置文件缺少完整的加密载荷。');
|
||||
}
|
||||
}
|
||||
|
||||
function assertBackupPassword(password) {
|
||||
if (typeof password !== 'string' || !password.trim()) {
|
||||
throw new Error('请输入用于加密或解密的口令。');
|
||||
}
|
||||
}
|
||||
|
||||
function getValueAtPath(source, path) {
|
||||
return path.split('.').reduce((currentValue, segment) => {
|
||||
if (!isPlainObject(currentValue)) {
|
||||
return undefined;
|
||||
}
|
||||
return currentValue[segment];
|
||||
}, source);
|
||||
}
|
||||
|
||||
function setValueAtPath(target, path, value) {
|
||||
const segments = path.split('.');
|
||||
let currentTarget = target;
|
||||
|
||||
for (let index = 0; index < segments.length - 1; index += 1) {
|
||||
const segment = segments[index];
|
||||
if (!isPlainObject(currentTarget[segment])) {
|
||||
currentTarget[segment] = {};
|
||||
}
|
||||
currentTarget = currentTarget[segment];
|
||||
}
|
||||
|
||||
currentTarget[segments[segments.length - 1]] = value;
|
||||
}
|
||||
|
||||
function deepClone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes) {
|
||||
let binary = '';
|
||||
bytes.forEach((byte) => {
|
||||
binary += String.fromCharCode(byte);
|
||||
});
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function base64ToBytes(value) {
|
||||
const binary = atob(value);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
+10
-1
@@ -2,5 +2,14 @@ import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()]
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8000',
|
||||
ws: true,
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user