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
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