diff --git a/frontend/src/utils/exceptions.js b/frontend/src/utils/exceptions.js new file mode 100644 index 0000000..57a7b23 --- /dev/null +++ b/frontend/src/utils/exceptions.js @@ -0,0 +1,231 @@ +export 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: '入库失败' } +]; + +export const RESOLUTION_FILTERS = [ + { id: 'open', name: '开放中' }, + { id: 'resolved', name: '已解决' }, + { id: 'ignored', name: '已忽略' }, + { id: 'all', name: '全部' } +]; + +export 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: '删除文件' +}; + +export 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'] } +]; + +export 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'] +}; + +export const ITEMS_PER_PAGE = 8; +export const METADATA_QUEUE_TYPES = ['missing_tags', 'match_failed', 'low_score']; +export const METADATA_QUEUE_PAGE_SIZE = 100; +export const METADATA_FIELDS = ['title', 'artist', 'album', 'album_artist', 'track_number', 'disc_number', 'year', 'lyrics']; +export const REQUIRED_FIELDS = ['title', 'artist', 'album_artist']; + +export const WIZARD_STEPS = [ + { id: 'select', label: '选择歌曲' }, + { id: 'listen', label: '试听确认' }, + { id: 'match', label: '推荐匹配' }, + { id: 'edit', label: '手动编辑' }, + { id: 'confirm', label: '入库确认' } +]; + +// --- Utility functions --- + +export function compareTimestampDesc(a, b) { + return new Date(b || 0).getTime() - new Date(a || 0).getTime(); +} + +export function formatTimestamp(value) { + if (!value) return '--'; + try { + return new Intl.DateTimeFormat('zh-CN', { + month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' + }).format(new Date(value)); + } catch { return String(value); } +} + +export function formatSeconds(value) { + if (!Number.isFinite(value) || value <= 0) return '--:--'; + const total = Math.floor(value); + const minutes = Math.floor(total / 60); + const seconds = total % 60; + return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; +} + +export function formatConfidence(value) { + if (value == null) return '--'; + return `${Number(value).toFixed(1)} 分`; +} + +export function formatMetadataValue(value) { + if (value === null || value === undefined || value === '') return '--'; + return String(value); +} + +export function providerLabel(provider) { + const labels = { acoustid: 'AcoustID', musicbrainz: 'MusicBrainz', netease: '网易云', qq: 'QQ 音乐', spotify: 'Spotify' }; + const key = String(provider || '').toLowerCase(); + return labels[key] || provider || '推荐候选'; +} + +export function isTerminalRepairStatus(status) { + return status === 'completed' || status === 'failed'; +} + +export function normalizeActionParams(action, params) { + if (action === 'retry_match') { + return { provider_mode: params.provider_mode || 'all', providers: params.providers || [] }; + } + if (action === 'save_and_organize' || action === 'edit_metadata') { + return { metadata_patch: { ...(params.metadata_patch || {}) } }; + } + return params; +} + +export 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 {}; +} + +export function isMetadataWorkflowException(exceptionType) { + return ['missing_tags', 'match_failed', 'low_score'].includes(exceptionType); +} + +export function inferDefaultAction(detailRecord) { + if (!detailRecord) return ''; + const actions = detailRecord.available_actions || []; + if (actions.includes('save_and_organize')) return 'save_and_organize'; + if (actions.includes('retry_match')) return 'retry_match'; + if (actions.includes('retry_organize')) return 'retry_organize'; + return actions[0] || ''; +} + +export function getMissingRequiredFields(metadata) { + return REQUIRED_FIELDS + .filter((field) => !String(metadata?.[field] || '').trim()) + .map((field) => { + const labels = { title: '标题', artist: '艺术家', album_artist: '专辑艺术家' }; + return labels[field] || field; + }); +} + +// --- Styling helpers --- + +export function chipClass(active, secondary = false) { + if (secondary) { + return `rounded-full border px-3 py-1.5 text-xs transition ${ + active + ? 'border-slate-400 bg-slate-200 text-slate-900' + : 'border-slate-700 bg-slate-900 text-slate-400 hover:border-slate-500' + }`; + } + return `rounded-full border px-3 py-1.5 text-xs transition ${ + active + ? 'border-cyan-400/50 bg-cyan-500/15 text-cyan-100' + : 'border-slate-700 bg-slate-900 text-slate-400 hover:border-slate-500' + }`; +} + +export function inputClass() { + return 'w-full rounded-xl border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 outline-none transition placeholder:text-slate-500 focus:border-cyan-400/60'; +} + +export function actionButtonClass(enabled = true) { + return `inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-xs font-medium transition ${ + enabled + ? 'border border-cyan-400/40 bg-cyan-500/15 text-cyan-100 hover:bg-cyan-500/20' + : 'cursor-not-allowed border border-slate-800 bg-slate-900 text-slate-600' + }`; +} + +export function riskClass(riskLevel) { + return { + low: 'bg-emerald-500/10 text-emerald-200', + medium: 'bg-amber-500/10 text-amber-200', + high: 'bg-rose-500/10 text-rose-200' + }[riskLevel || 'low']; +} + +export function getFilterCount(summary, filterId) { + if (!summary) return '-'; + if (filterId === 'all') return summary.total ?? '-'; + return summary.counts_by_type?.[filterId] ?? '-'; +} + +export function getMetadataQueueCounts(queue) { + return METADATA_QUEUE_TYPES.reduce( + (counts, type) => ({ + ...counts, + [type]: queue.filter((item) => item.exception_type === type).length + }), + {} + ); +} + +export function shouldRefreshExceptionListFully(repairTask) { + const action = repairTask?.repair_plan_json?.action; + const itemCount = (repairTask?.repair_plan_json?.items || []).length; + if (!action) return true; + if (itemCount !== 1) return true; + return !['retry_match', 'select_match_candidate', 'edit_metadata'].includes(action); +}