Add MusicWorkshop application

This commit is contained in:
liumangmang
2026-04-30 14:34:28 +08:00
parent 4cb403c956
commit 796f19990f
62 changed files with 21614 additions and 2168 deletions
+59 -1
View File
@@ -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",
+2 -1
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -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);
}
+81
View File
@@ -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);
}
+74
View File
@@ -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);
}
+44
View File
@@ -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);
}
+118
View File
@@ -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`;
}
+143
View File
@@ -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>
);
}
+36
View File
@@ -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>
);
}
+117
View File
@@ -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
}
};
}
+92
View File
@@ -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
+551
View File
@@ -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'
});
}
+665
View File
@@ -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
+270
View File
@@ -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
View File
@@ -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
}
}
}
});