Add MusicWorkshop application
This commit is contained in:
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
Reference in New Issue
Block a user