feat: extract shared constants and utilities to utils/exceptions.js

Phase 1 of exception center redesign - all shared constants, utility
functions, and styling helpers extracted from ExceptionPage.jsx into
a single source of truth for use by new components and hooks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
liumangmang
2026-05-07 20:37:45 +08:00
parent d111d0def0
commit 30a8d8caa9
+231
View File
@@ -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);
}