diff --git a/frontend/src/pages/ExceptionPage.jsx b/frontend/src/pages/ExceptionPage.jsx index 8594592..6689451 100644 --- a/frontend/src/pages/ExceptionPage.jsx +++ b/frontend/src/pages/ExceptionPage.jsx @@ -1,2338 +1,247 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useState, useMemo, useCallback } from 'react'; import { - AlertTriangle, - CheckCircle2, - Headphones, - Layers, - LoaderCircle, - Music2, - Pause, - Play, - Search, - ShieldAlert, - Sparkles, - Trash2, - Wand2, - Wrench -} from 'lucide-react'; -import { - buildExceptionAudioUrl, + previewExceptionAction, executeExceptionAction, - fetchExceptionItem, - fetchExceptionItems, - fetchExceptionSummary, - previewExceptionAction } from '../api/exceptions'; +import { fetchRepairTask, fetchRepairTaskLogs } from '../api/repairs'; import { - createRepairTaskStream, - fetchCurrentRepairTask, - fetchRepairTask, - fetchRepairTaskLogs -} from '../api/repairs'; -import Pagination from '../components/Pagination'; - -const EXCEPTION_FILTERS = [ - { id: 'all', name: '全部异常' }, - { id: 'missing_tags', name: '元数据缺失' }, - { id: 'duplicates', name: '文件重复' }, - { id: 'match_failed', name: '匹配失败' }, - { id: 'low_score', name: '匹配分过低' }, - { id: 'convert_failed', name: '转码失败' }, - { id: 'organize_failed', name: '入库失败' } -]; - -const RESOLUTION_FILTERS = [ - { id: 'open', name: '开放中' }, - { id: 'resolved', name: '已解决' }, - { id: 'ignored', name: '已忽略' }, - { id: 'all', name: '全部' } -]; - -const ACTION_LABELS = { - retry_match: '一键匹配', - select_match_candidate: '确认候选', - edit_metadata: '保存草稿', - save_and_organize: '加入音乐库', - keep_existing: '忽略并删除新版', - replace_existing: '覆盖替换旧版', - keep_both_with_rename: '重命名新版并保留双版本', - retry_preprocess: '重跑预处理', - move_to_review_trash: '移入 Review Trash', - retry_organize: '重试入库', - edit_target_path: '编辑目标路径', - ignore_exception: '永久忽略', - delete_file: '删除文件' -}; - -const PROVIDER_MODES = [ - { id: 'all', label: '多源并行', providers: [] }, - { id: 'authoritative', label: '权威优先', providers: ['acoustid', 'musicbrainz'] }, - { id: 'netease', label: '网易云', providers: ['netease'] }, - { id: 'qq', label: 'QQ 音乐', providers: ['qq'] }, - { id: 'spotify', label: 'Spotify', providers: ['spotify'] } -]; - -const BULK_ACTIONS = { - match_failed: ['retry_match', 'ignore_exception'], - low_score: ['retry_match', 'ignore_exception'], - organize_failed: ['ignore_exception', 'retry_organize'], - missing_tags: ['retry_match', 'ignore_exception'], - duplicates: ['ignore_exception'], - convert_failed: ['ignore_exception'] -}; - -const ITEMS_PER_PAGE = 8; - -function buildDefaultParams(action, detailRecord) { - if (action === 'retry_match') { - return { provider_mode: 'all', providers: [] }; - } - if (action === 'select_match_candidate') { - return { candidate_index: 0 }; - } - if (action === 'edit_metadata' || action === 'save_and_organize') { - const metadata = - detailRecord?.effective_metadata || - detailRecord?.matched_metadata_json || - detailRecord?.original_tags_json || - {}; - return { - metadata_patch: { - title: metadata.title || '', - artist: metadata.artist || '', - album: metadata.album || '', - album_artist: metadata.album_artist || '', - track_number: metadata.track_number ?? null, - disc_number: metadata.disc_number ?? null, - year: metadata.year ?? null, - lyrics: metadata.lyrics || '' - } - }; - } - if (action === 'retry_organize' || action === 'edit_target_path') { - return { target_relative_path: detailRecord?.library_relative_path || '' }; - } - return {}; -} + BULK_ACTIONS, normalizeActionParams, + getMetadataQueueCounts, shouldRefreshExceptionListFully +} from '../utils/exceptions'; +import useExceptionSummary from '../hooks/useExceptionSummary'; +import useExceptionList from '../hooks/useExceptionList'; +import useExceptionDetail from '../hooks/useExceptionDetail'; +import useRepairTask from '../hooks/useRepairTask'; +import useWizardState from '../hooks/useWizardState'; +import ExceptionWizard from '../components/exceptions/ExceptionWizard'; +import ExceptionListView from '../components/exceptions/ExceptionListView'; +import MissingTagsInlinePanel from '../components/MissingTagsInlinePanel'; export default function ExceptionPage() { - const [summary, setSummary] = useState(null); - const [items, setItems] = useState([]); - const [total, setTotal] = useState(0); - const [summaryError, setSummaryError] = useState(''); - const [listError, setListError] = useState(''); - const [detailError, setDetailError] = useState(''); - const [isListLoading, setIsListLoading] = useState(true); - const [isDetailLoading, setIsDetailLoading] = useState(false); - const [selectedExceptionId, setSelectedExceptionId] = useState(null); - const [selectedException, setSelectedException] = useState(null); - const [selectedIds, setSelectedIds] = useState([]); + const [viewMode, setViewMode] = useState('wizard'); const [activeFilter, setActiveFilter] = useState('all'); const [resolutionFilter, setResolutionFilter] = useState('open'); const [currentPage, setCurrentPage] = useState(1); - const [previewState, setPreviewState] = useState({ loading: false, payload: null, error: '', action: '' }); - const [selectedAction, setSelectedAction] = useState(''); - const [actionParams, setActionParams] = useState({}); - const [executeError, setExecuteError] = useState(''); - const [repairTask, setRepairTask] = useState(null); - const [repairLogs, setRepairLogs] = useState([]); - const [executionStateByExceptionId, setExecutionStateByExceptionId] = useState({}); - const [isMatchModalOpen, setIsMatchModalOpen] = useState(false); - const completedRepairRefreshRef = useRef(new Set()); + const [selectedIds, setSelectedIds] = useState([]); + const [selectedExceptionId, setSelectedExceptionId] = useState(null); + const [providerMode, setProviderMode] = useState('all'); + const [providers, setProviders] = useState([]); - const totalPages = Math.max(1, Math.ceil(total / ITEMS_PER_PAGE)); - const selectedListItem = items.find((item) => item.exception_id === selectedExceptionId) || null; - const detailRecord = selectedException || selectedListItem; - const availableActions = detailRecord?.available_actions || []; + const { summary, error: summaryError, refresh: refreshSummary } = useExceptionSummary(); + + const { + items, total, metadataQueue, metadataTotal, + isListLoading, isMetadataQueueLoading, + listError, metadataQueueError, + setItems, setMetadataQueue, + refreshList, refreshMetadataQueue + } = useExceptionList({ viewMode, activeFilter, resolutionFilter, currentPage }); + + const { + detail: detailRecord, loading: isDetailLoading, error: detailError, + refresh: refreshDetail + } = useExceptionDetail(selectedExceptionId); + + const { + repairTask, repairLogs, + executionStateByExceptionId, + registerExecution, setExecuting, setExecutionFailed, + completedRefreshRef, + setRepairTask, setRepairLogs + } = useRepairTask(); + + const { + wizardStep, setWizardStep, + selectedAction, setSelectedAction, + actionParams, setActionParams, + previewState, setPreviewState, + executeError, setExecuteError, + focusAction + } = useWizardState('select'); + + const metadataQueueCounts = useMemo(() => getMetadataQueueCounts(metadataQueue), [metadataQueue]); const detailExecutionState = detailRecord ? executionStateByExceptionId[detailRecord.exception_id] || null : null; - const detailRepairTaskId = detailExecutionState?.repairTaskId || getRepairTaskIdFromDetail(detailRecord); - const activeRepairTask = repairTask?.task_id === detailRepairTaskId ? repairTask : null; - const activeRepairLogs = activeRepairTask ? repairLogs : []; - - useEffect(() => { - const controller = new AbortController(); - fetchExceptionSummary({ signal: controller.signal }) - .then(setSummary) - .catch((error) => { - if (error.name !== 'AbortError') { - setSummaryError(error.message || '异常概览加载失败'); - } - }); - return () => controller.abort(); - }, []); - - useEffect(() => { - const controller = new AbortController(); - setIsListLoading(true); - setListError(''); - fetchExceptionItems( - { - type: activeFilter, - resolutionStatus: resolutionFilter, - page: currentPage, - pageSize: ITEMS_PER_PAGE - }, - { signal: controller.signal } - ) - .then((payload) => { - setItems(payload.items); - setTotal(payload.total); - setSelectedIds((previous) => - previous.filter((id) => payload.items.some((item) => item.exception_id === id)) - ); - setSelectedExceptionId((previous) => { - if (!payload.items.length) { - return null; - } - if (payload.items.some((item) => item.exception_id === previous)) { - return previous; - } - return payload.items[0].exception_id; - }); - }) - .catch((error) => { - if (error.name !== 'AbortError') { - setItems([]); - setTotal(0); - setListError(error.message || '异常列表加载失败'); - } - }) - .finally(() => { - if (!controller.signal.aborted) { - setIsListLoading(false); - } - }); - return () => controller.abort(); - }, [activeFilter, resolutionFilter, currentPage]); - - useEffect(() => { - if (!selectedExceptionId) { - setSelectedException(null); - return; - } - const controller = new AbortController(); - setIsDetailLoading(true); - setDetailError(''); - fetchExceptionItem(selectedExceptionId, { signal: controller.signal }) - .then((payload) => { - setSelectedException(payload); - if (!selectedAction || !payload.available_actions.includes(selectedAction)) { - const nextAction = inferDefaultAction(payload); - setSelectedAction(nextAction); - setActionParams(buildDefaultParams(nextAction, payload)); - } - }) - .catch((error) => { - if (error.name !== 'AbortError') { - setSelectedException(null); - setDetailError(error.message || '异常详情加载失败'); - } - }) - .finally(() => { - if (!controller.signal.aborted) { - setIsDetailLoading(false); - } - }); - return () => controller.abort(); - }, [selectedExceptionId]); - - useEffect(() => { - if (!repairTask?.task_id) { - return undefined; - } - const socket = createRepairTaskStream(repairTask.task_id); - socket.onmessage = async (event) => { - const payload = JSON.parse(event.data); - if (payload.type === 'task.snapshot') { - setRepairTask(payload.data.task); - setRepairLogs(payload.data.recent_logs || []); - return; - } - const taskPayload = await fetchRepairTask(repairTask.task_id); - const logsPayload = await fetchRepairTaskLogs(repairTask.task_id, 1, 20); - setRepairTask(taskPayload.task); - setRepairLogs(logsPayload.logs); - }; - return () => socket.close(); - }, [repairTask?.task_id]); - - useEffect(() => { - fetchCurrentRepairTask() - .then((payload) => { - if (!payload.task) { - return null; - } - setRepairTask(payload.task); - return fetchRepairTaskLogs(payload.task.task_id, 1, 20).then((logsPayload) => { - setRepairLogs(logsPayload.logs); - }); - }) - .catch(() => {}); - }, []); - - useEffect(() => { - if (!repairTask?.task_id) { - return; - } - - setExecutionStateByExceptionId((previous) => { - let changed = false; - const next = { ...previous }; - - Object.entries(previous).forEach(([exceptionId, state]) => { - if (!state || state.repairTaskId !== repairTask.task_id) { - return; - } - const nextStatus = - repairTask.status === 'completed' - ? 'completed' - : repairTask.status === 'failed' - ? 'failed' - : repairTask.status === 'running' - ? 'running' - : 'accepted'; - const nextError = repairTask.status === 'failed' ? repairTask.error_message || '修复任务执行失败' : ''; - if (state.status !== nextStatus || state.error !== nextError) { - next[exceptionId] = { ...state, status: nextStatus, error: nextError }; - changed = true; - } - }); - - return changed ? next : previous; - }); - - if (isTerminalRepairStatus(repairTask.status) && !completedRepairRefreshRef.current.has(repairTask.task_id)) { - completedRepairRefreshRef.current.add(repairTask.task_id); - if (shouldRefreshExceptionListFully(repairTask)) { - refreshSummary(); - refreshDetailAndList(); - } else { - refreshSelectedDetailAndListRow(); - } - } - }, [repairTask]); const bulkState = useMemo(() => { - if (!selectedIds.length) { - return { disabled: true, reason: '', actions: [] }; - } + if (!selectedIds.length) return { disabled: true, reason: '', actions: [] }; const selectedItems = items.filter((item) => selectedIds.includes(item.exception_id)); const types = [...new Set(selectedItems.map((item) => item.exception_type))]; - if (types.length !== 1) { - return { disabled: true, reason: '混合异常类型不能批量处理', actions: [] }; - } + if (types.length !== 1) return { disabled: true, reason: '混合异常类型不能批量处理', actions: [] }; const actions = BULK_ACTIONS[types[0]] || []; return { disabled: !actions.length, reason: actions.length ? '' : '当前类型不支持批量动作', actions }; }, [items, selectedIds]); - function refreshSummary() { - fetchExceptionSummary() - .then(setSummary) - .catch(() => {}); - } + const selectWizardItem = useCallback((exceptionId) => { + setSelectedExceptionId(exceptionId); + setWizardStep('listen'); + setSelectedIds([]); + }, [setWizardStep]); - function refreshSelectedDetailAndListRow() { - if (!selectedExceptionId) { - return; - } - fetchExceptionItem(selectedExceptionId) - .then((payload) => { - setSelectedException(payload); - setItems((previous) => - previous.map((item) => (item.exception_id === payload.exception_id ? { ...item, ...payload } : item)) - ); - }) - .catch(() => {}); - } + const switchToAdvanced = useCallback(() => { + setViewMode('advanced'); + setSelectedIds([]); + refreshList(); + }, [refreshList]); - function refreshDetailAndList() { - if (selectedExceptionId) { - fetchExceptionItem(selectedExceptionId).then(setSelectedException).catch(() => {}); - } - fetchExceptionItems({ - type: activeFilter, - resolutionStatus: resolutionFilter, - page: currentPage, - pageSize: ITEMS_PER_PAGE - }) - .then((payload) => { - setItems(payload.items); - setTotal(payload.total); - }) - .catch(() => {}); - } + const switchToWizard = useCallback(() => { + setViewMode('wizard'); + setSelectedIds([]); + setWizardStep(selectedExceptionId ? 'listen' : 'select'); + }, [selectedExceptionId, setWizardStep]); - function focusAction(action) { - setSelectedAction(action); - setActionParams(buildDefaultParams(action, detailRecord)); - setPreviewState({ loading: false, payload: null, error: '', action }); - setExecuteError(''); - } + const handleToggleSelect = useCallback((id) => { + setSelectedIds((prev) => + prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id] + ); + }, []); - async function handlePreview(targetIds = null, overrideAction = null) { - const exceptionIds = - targetIds || (selectedIds.length ? selectedIds : detailRecord ? [detailRecord.exception_id] : []); - const action = overrideAction || selectedAction; - if (!exceptionIds.length || !action) { - return; - } - const params = normalizeActionParams(action, actionParams); - if (overrideAction) { - setSelectedAction(overrideAction); - } + const handleToggleAll = useCallback((checked) => { + setSelectedIds(checked ? items.map((item) => item.exception_id) : []); + }, [items]); + + const handlePreview = useCallback(async (action) => { + const exceptionIds = selectedIds.length ? selectedIds : detailRecord ? [detailRecord.exception_id] : []; + if (!exceptionIds.length || !action) return; setPreviewState({ loading: true, payload: null, error: '', action }); setExecuteError(''); try { const payload = await previewExceptionAction({ - exception_ids: exceptionIds, - action, - params + exception_ids: exceptionIds, action, + params: normalizeActionParams(action, actionParams) }); setPreviewState({ loading: false, payload, error: '', action }); - } catch (error) { - setPreviewState({ loading: false, payload: null, error: error.message || '预览生成失败', action }); + } catch (err) { + setPreviewState({ loading: false, payload: null, error: err.message || '预览失败', action }); } - } + }, [selectedIds, detailRecord, actionParams, setPreviewState, setExecuteError]); - async function handleExecute(targetIds = null, overrideAction = null) { - const exceptionIds = - targetIds || (selectedIds.length ? selectedIds : detailRecord ? [detailRecord.exception_id] : []); - const action = overrideAction || selectedAction; - if (!exceptionIds.length || !action) { - return; - } - if ( - action === 'delete_file' && + const handleExecute = useCallback(async (action) => { + const exceptionIds = selectedIds.length ? selectedIds : detailRecord ? [detailRecord.exception_id] : []; + if (!exceptionIds.length || !action) return; + if (action === 'delete_file' && (!window.confirm('将永久删除选中的文件,且无法恢复。是否继续?') || - !window.confirm('请再次确认:这会真实删除文件,不是忽略。是否执行删除?')) - ) { - return; - } - const previewPayload = previewState.payload; - const isSingleItemExecution = !targetIds && detailRecord && exceptionIds.length === 1; - const currentExceptionId = isSingleItemExecution ? detailRecord.exception_id : null; + !window.confirm('请再次确认:这会真实删除文件,不是忽略。是否执行删除?')) + ) return; + + const isSingleItem = exceptionIds.length === 1; + const currentExceptionId = isSingleItem ? detailRecord?.exception_id : null; setExecuteError(''); + if (currentExceptionId) { - setExecutionStateByExceptionId((previous) => ({ - ...previous, - [currentExceptionId]: { - exceptionId: currentExceptionId, - action, - status: 'submitting', - repairTaskId: previous[currentExceptionId]?.repairTaskId || null, - submittedAt: new Date().toISOString(), - error: '', - previewPayload - } - })); + setExecuting(currentExceptionId, action, previewState.payload); } + try { const payload = await executeExceptionAction({ - exception_ids: exceptionIds, - action, + exception_ids: exceptionIds, action, params: normalizeActionParams(action, actionParams) }); if (currentExceptionId) { - setExecutionStateByExceptionId((previous) => ({ - ...previous, - [currentExceptionId]: { - ...(previous[currentExceptionId] || {}), - exceptionId: currentExceptionId, - action, - status: 'accepted', - repairTaskId: payload.repair_task_id, - submittedAt: previous[currentExceptionId]?.submittedAt || new Date().toISOString(), - error: '', - previewPayload: previous[currentExceptionId]?.previewPayload || previewPayload - } - })); + registerExecution(currentExceptionId, action, payload.repair_task_id, previewState.payload); } - const taskPayload = await fetchRepairTask(payload.repair_task_id); - const logsPayload = await fetchRepairTaskLogs(payload.repair_task_id, 1, 20); - setRepairTask(taskPayload.task); - setRepairLogs(logsPayload.logs); - } catch (error) { + const tp = await fetchRepairTask(payload.repair_task_id); + const lp = await fetchRepairTaskLogs(payload.repair_task_id, 1, 20); + setRepairTask(tp.task); + setRepairLogs(lp.logs); + } catch (err) { if (currentExceptionId) { - setExecutionStateByExceptionId((previous) => ({ - ...previous, - [currentExceptionId]: { - ...(previous[currentExceptionId] || {}), - exceptionId: currentExceptionId, - action, - status: 'failed', - error: error.message || '执行失败', - previewPayload: previous[currentExceptionId]?.previewPayload || previewPayload - } - })); + setExecutionFailed(currentExceptionId, action, err.message || '执行失败', previewState.payload); } - setExecuteError(error.message || '执行失败'); + setExecuteError(err.message || '执行失败'); } - } + }, [selectedIds, detailRecord, actionParams, previewState, setExecuting, registerExecution, setExecutionFailed, setRepairTask, setRepairLogs, setExecuteError]); - function updateMetadataParam(key, value) { - setActionParams((previous) => ({ - ...previous, - metadata_patch: { - ...(previous.metadata_patch || {}), - [key]: value - } + const handleUpdateMetadata = useCallback((key, value) => { + setActionParams((prev) => ({ + ...prev, + metadata_patch: { ...(prev.metadata_patch || {}), [key]: value } })); + setPreviewState({ loading: false, payload: null, error: '', action: '' }); + }, [setActionParams, setPreviewState]); + + const handleBulkAction = useCallback(async (action) => { + if (!selectedIds.length || !action) return; + if (action === 'delete_file' && + (!window.confirm('将永久删除选中文件,无法恢复。是否继续?') || + !window.confirm('请再次确认:是否执行删除?')) + ) return; + try { + setPreviewState({ loading: true, payload: null, error: '', action }); + const payload = await previewExceptionAction({ + exception_ids: selectedIds, action, params: {} + }); + setPreviewState({ loading: false, payload, error: '', action }); + const execPayload = await executeExceptionAction({ + exception_ids: selectedIds, action, params: {} + }); + const tp = await fetchRepairTask(execPayload.repair_task_id); + const lp = await fetchRepairTaskLogs(execPayload.repair_task_id, 1, 20); + setRepairTask(tp.task); + setRepairLogs(lp.logs); + } catch (err) { + setPreviewState({ loading: false, payload: null, error: err.message || '批量操作失败', action }); + } + }, [selectedIds, setPreviewState, setRepairTask, setRepairLogs]); + + if (viewMode === 'wizard' && detailRecord?.exception_type === 'missing_tags') { + return ; } - return ( -
-
-
-
-
-

异常决策台

-

- - 异常隔离池 -

-

- 左侧聚焦异常筛选与批量选择,右侧只处理当前文件决策,保留预览到执行的两步闭环。 -

- {summaryError ?

{summaryError}

: null} -
-
- - - - -
-
- -
-
- {EXCEPTION_FILTERS.map((filter) => ( - - ))} -
-
- 处理状态 - {RESOLUTION_FILTERS.map((filter) => ( - - ))} -
-
-
- -
- - - - - - - - - - - - {isListLoading ? ( - - - - ) : listError ? ( - - - - ) : !items.length ? ( - - - - ) : ( - items.map((item) => { - const selected = selectedExceptionId === item.exception_id; - return ( - setSelectedExceptionId(item.exception_id)} - className="cursor-pointer" - > - - - ); - }) - )} - -
- 0} - onChange={(event) => - setSelectedIds(event.target.checked ? items.map((item) => item.exception_id) : []) - } - disabled={!items.length} - /> - 异常文件分类状态原因
- 正在加载异常列表... -
- {listError} -
- 当前筛选条件下没有异常记录 -
-
-
event.stopPropagation()}> - { - setSelectedIds((previous) => - previous.includes(item.exception_id) - ? previous.filter((id) => id !== item.exception_id) - : [...previous, item.exception_id] - ); - }} - /> -
-
-
{item.display_title}
-
{item.filename}
-
{item.relative_path}
-
-
- - {item.type_label} - -
-
{renderResolutionBadge(item)}
-
-
{item.display_reason}
-
{formatTimestamp(item.captured_at)}
-
-
-
-
- - setCurrentPage((page) => Math.max(1, page - 1))} - onNext={() => setCurrentPage((page) => Math.min(totalPages, page + 1))} - summary={`显示 ${total === 0 ? 0 : (currentPage - 1) * ITEMS_PER_PAGE + 1} 到 ${Math.min(currentPage * ITEMS_PER_PAGE, total)} 条,共 ${total} 条记录`} - /> - - {selectedIds.length > 0 ? ( -
-
-
-
-
已选择 {selectedIds.length} 个异常项
-
- {bulkState.reason || '批量动作仍需先生成预览,再进入修复任务执行。'} -
-
-
- - - -
-
-
-
- ) : null} -
- - -
- ); -} - -function SummaryPanel({ detailRecord }) { - const audioProps = detailRecord?.audio_props_json || {}; - const duplicateQuality = detailRecord?.dedupe_decision_json?.quality_breakdown || {}; - return ( -
-
-
-

当前文件

-

{detailRecord?.display_title || '-'}

-

{detailRecord?.filename || '-'}

-
- {renderResolutionBadge(detailRecord)} -
- -
- - - - - - -
- - {detailRecord?.album_artist_reason ? ( -
- -
- ) : null} - -
- {detailRecord?.display_reason || '-'} -
- - {detailRecord?.exception_type === 'duplicates' ? ( -
- - -
- ) : null} -
- ); -} - -function AudioDecisionPlayer({ detailRecord }) { - const audioRef = useRef(null); - const [isPlaying, setIsPlaying] = useState(false); - const [currentTime, setCurrentTime] = useState(0); - const [duration, setDuration] = useState(0); - const [error, setError] = useState(''); - const audioUrl = detailRecord ? buildExceptionAudioUrl(detailRecord.exception_id) : ''; - const audioProps = detailRecord?.audio_props_json || {}; - - useEffect(() => { - setIsPlaying(false); - setCurrentTime(0); - setDuration(0); - setError(''); - if (audioRef.current) { - audioRef.current.pause(); - audioRef.current.load(); - } - }, [audioUrl]); - - function togglePlay() { - if (!audioRef.current) { - return; - } - if (audioRef.current.paused) { - audioRef.current.play().catch(() => setError('播放器启动失败')); - } else { - audioRef.current.pause(); - } - } - - return ( -
-
-
-

试听预览

-

- - 在线试听 -

-
- -
- -
- ); -} - -function MetadataWorkflowSections({ - detailRecord, - actionParams, - previewState, - executeError, - executionState, - onFocusAction, - onPreview, - onExecute, - onUpdateMetadata, - onSetActionParams, - onOpenMatchModal -}) { - const draft = actionParams.metadata_patch || detailRecord.effective_metadata || {}; - const requiredFields = ['title', 'artist', 'album_artist']; - const availableActions = detailRecord.available_actions || []; - const canIngest = requiredFields.every((field) => String(draft[field] || '').trim()); - const providerMode = actionParams.provider_mode || 'all'; - const candidateCount = (detailRecord.match_candidates_json || []).length; - - return ( - <> -
-
-
-

1. 原始元数据

-

这里只展示文件当前原始标签,不参与自动决策。

-
- - 只读 - -
- -
- -
-
-
-

2. 多源匹配

-

先拉取候选,再在弹窗里整组采用或按字段采用。

-
- - {candidateCount ? `${candidateCount} 个候选` : '待匹配'} - -
-
- {PROVIDER_MODES.map((mode) => { - const active = providerMode === mode.id; - return ( - - ); - })} -
-
- - - -
- {detailRecord.pending_ingest ? ( -
- 当前文件已进入待入库状态,确认候选后不会自动离开异常池。 -
- ) : null} - {renderPreviewBlock(previewState, 'retry_match')} -
- -
-
-
-

3. 手动补全

-

满足 `title / artist / album_artist` 后才能加入音乐库。

-
- - {canIngest ? '可入库' : '缺少必填'} - -
- {detailRecord.album_artist_reason ? ( -
- {detailRecord.album_artist_reason} - {detailRecord.normalization_strategy ? ` (${detailRecord.normalization_strategy})` : ''} -
- ) : null} - -
- {requiredFields.map((field) => ( -
- {field} -
- ))} -
-
- - - - -
- {renderPreviewBlock(previewState, ['edit_metadata', 'save_and_organize'])} -
- -
-
-
-

4. 处置动作

-

忽略只改状态,删除会真实删除文件且要求二次确认。

-
- {renderInlineExecutionBadge(executionState?.status)} -
-
- - - - -
- {renderPreviewBlock(previewState, ['ignore_exception', 'delete_file'])} - {executeError ? : null} -
- - ); -} - -function MatchComparisonModal({ - open, - detailRecord, - actionParams, - onClose, - onSetActionParams, - onUpdateMetadata, - onPreview, - onExecute -}) { - if (!open || !detailRecord) { - return null; - } - - const candidates = detailRecord.match_candidates_json || []; - const selectedIndex = Number(actionParams.candidate_index ?? 0); - const candidate = candidates[selectedIndex] || null; - const fields = ['title', 'artist', 'album', 'album_artist', 'track_number', 'disc_number', 'year', 'lyrics']; - - function adoptCandidateField(field) { - if (!candidate) { - return; - } - const nextValue = - field === 'album_artist' - ? candidate.album_artist || '' - : candidate[field] ?? ''; - onUpdateMetadata(field, nextValue); - } - - function adoptWholeCandidate() { - if (!candidate) { - return; - } - fields.forEach((field) => adoptCandidateField(field)); - } - - return ( -
-
-
-
-

多源匹配对比

-

左侧是原始元数据,右侧可按来源整组采用或按字段采用。

-
- -
-
-
-
原始元数据
- -
-
-
- {candidates.map((item, index) => ( - - ))} -
- {!candidate ? ( -
- 当前没有可对比的候选结果,请先执行匹配。 -
- ) : ( - <> -
- - - -
-
- {fields.map((field) => ( -
-
-
{field}
- -
-
- - -
-
- ))} -
- - )} -
-
-
-
- ); -} - -function DecisionModules({ - detailRecord, - availableActions, - selectedAction, - actionParams, - previewState, - executeError, - executionState, - onFocusAction, - onPreview, - onExecute, - onUpdateMetadata, - onSetActionParams -}) { - if (!detailRecord) { - return null; - } - if (!availableActions.length) { - return ( -
- 当前状态没有可执行动作 -
); } - if (['missing_tags', 'match_failed', 'low_score'].includes(detailRecord.exception_type)) { - return ( - <> - - onFocusAction('retry_match')} - primaryLabel="生成预览" - onPreview={() => onPreview(null, 'retry_match')} - onExecute={() => onExecute(null, 'retry_match')} - canExecute={Boolean(previewState.payload && previewState.action === 'retry_match')} - executionState={matchExecutionState(executionState, 'retry_match')} - > -
- {PROVIDER_MODES.map((mode) => { - const active = (actionParams.provider_mode || 'all') === mode.id; - return ( - - ); - })} -
- {renderPreviewBlock(previewState, 'retry_match')} -
- - {availableActions.includes('select_match_candidate') ? ( - onFocusAction('select_match_candidate')} - primaryLabel="生成预览" - onPreview={() => onPreview(null, 'select_match_candidate')} - onExecute={() => onExecute(null, 'select_match_candidate')} - canExecute={Boolean(previewState.payload && previewState.action === 'select_match_candidate')} - executionState={matchExecutionState(executionState, 'select_match_candidate')} - > - - - {renderPreviewBlock(previewState, 'select_match_candidate')} - - ) : null} - - - - {executeError ? : null} - - ); - } - - if (detailRecord.exception_type === 'duplicates') { - return ( - <> - - - {executeError ? : null} - - ); - } - - if (detailRecord.exception_type === 'convert_failed' || detailRecord.exception_type === 'organize_failed') { - return ( - <> - onFocusAction(defaultRecoveryAction(detailRecord))} - primaryLabel="生成预览" - onPreview={() => onPreview(null, defaultRecoveryAction(detailRecord))} - onExecute={() => onExecute(null, defaultRecoveryAction(detailRecord))} - canExecute={Boolean(previewState.payload && previewState.action === defaultRecoveryAction(detailRecord))} - executionState={matchExecutionState(executionState, defaultRecoveryAction(detailRecord))} - > - {detailRecord.exception_type === 'organize_failed' ? ( - - onSetActionParams((previous) => ({ - ...previous, - target_relative_path: event.target.value - })) - } - placeholder="目标相对路径" - /> - ) : null} - {renderPreviewBlock(previewState, defaultRecoveryAction(detailRecord))} - - - {detailRecord.exception_type === 'organize_failed' && availableActions.includes('edit_target_path') ? ( - onFocusAction('edit_target_path')} - primaryLabel="生成预览" - onPreview={() => onPreview(null, 'retry_organize')} - onExecute={() => onExecute(null, 'retry_organize')} - canExecute={Boolean(previewState.payload && previewState.action === 'retry_organize')} - executionState={matchExecutionState(executionState, 'retry_organize')} - > - - onSetActionParams((previous) => ({ - ...previous, - target_relative_path: event.target.value - })) - } - placeholder="目标相对路径" - /> - - ) : null} - - {availableActions.includes('move_to_review_trash') ? ( - onFocusAction('move_to_review_trash')} - primaryLabel="生成预览" - onPreview={() => onPreview(null, 'move_to_review_trash')} - onExecute={() => onExecute(null, 'move_to_review_trash')} - canExecute={Boolean(previewState.payload && previewState.action === 'move_to_review_trash')} - executionState={matchExecutionState(executionState, 'move_to_review_trash')} - > - {renderPreviewBlock(previewState, 'move_to_review_trash')} - - ) : null} - - - {executeError ? : null} - - ); - } - - return null; -} - -function MetadataEditorCard({ - detailRecord, - selectedAction, - actionParams, - previewState, - executionState, - onFocusAction, - onPreview, - onExecute, - onUpdateMetadata -}) { - const enrichment = detailRecord.match_enrichment_json || {}; return ( - onFocusAction('edit_metadata')} - primaryLabel="生成预览" - onPreview={() => - onPreview(null, selectedAction === 'save_and_organize' ? 'save_and_organize' : 'edit_metadata') - } - onExecute={() => - onExecute(null, selectedAction === 'save_and_organize' ? 'save_and_organize' : 'edit_metadata') - } - canExecute={Boolean( - previewState.payload && - ['edit_metadata', 'save_and_organize'].includes(previewState.action) - )} - executionState={matchExecutionState(executionState, ['edit_metadata', 'save_and_organize'])} - > -
- {['edit_metadata', 'save_and_organize'].map((action) => ( - - ))} -
-
- onUpdateMetadata('title', event.target.value)} placeholder="标题" /> - onUpdateMetadata('artist', event.target.value)} placeholder="艺术家" /> - onUpdateMetadata('album', event.target.value)} placeholder="专辑" /> - onUpdateMetadata('album_artist', event.target.value)} placeholder="专辑艺术家" /> -
-