chore: add project configs, backend repair services, docs, and code quality tooling
- Add pre-commit hooks (ruff, black, prettier) and ESLint/Prettier configs - Add backend repair services (execution, orchestration, preview) with tests - Add project documentation (CLAUDE.md, README.md, design specs and plans) - Add MissingTagsInlinePanel component for exception handling - Add pyproject.toml with ruff/black configuration Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
7d003ff822
commit
be3c086975
@@ -0,0 +1 @@
|
||||
{"env":{"browser":true,"es2021":true,"node":true},"extends":["eslint:recommended","plugin:react/recommended","plugin:react-hooks/recommended","plugin:jsx-a11y/recommended"],"parserOptions":{"ecmaFeatures":{"jsx":true},"ecmaVersion":"latest","sourceType":"module"},"root":true,"rules":{"no-console":["warn",{"allow":["warn","error"]}],"no-unused-vars":["warn",{"argsIgnorePattern":"^_"}],"react/prop-types":"warn","react/react-in-jsx-scope":"off"},"settings":{"react":{"version":"detect"}}}
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
coverage/
|
||||
.env
|
||||
.env.local
|
||||
.DS_Store
|
||||
*.log
|
||||
@@ -0,0 +1 @@
|
||||
{"arrowParens":"always","bracketSpacing":true,"endOfLine":"lf","printWidth":100,"semi":true,"singleQuote":true,"tabWidth":2,"trailingComma":"es5"}
|
||||
@@ -0,0 +1,909 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
|
||||
import {
|
||||
CheckCircle2,
|
||||
Headphones,
|
||||
LoaderCircle,
|
||||
Music2,
|
||||
Pause,
|
||||
Play,
|
||||
Search,
|
||||
ShieldAlert,
|
||||
Sparkles,
|
||||
Trash2
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
buildExceptionAudioUrl,
|
||||
executeExceptionAction,
|
||||
fetchExceptionItem,
|
||||
fetchExceptionItems,
|
||||
previewExceptionAction
|
||||
} from '../api/exceptions';
|
||||
import {
|
||||
createRepairTaskStream,
|
||||
fetchCurrentRepairTask,
|
||||
fetchRepairTask,
|
||||
fetchRepairTaskLogs
|
||||
} from '../api/repairs';
|
||||
|
||||
// ── Constants (mirrored from ExceptionPage.jsx) ──────────────────────────────
|
||||
|
||||
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 METADATA_FIELDS = ['title', 'artist', 'album', 'album_artist', 'track_number', 'disc_number', 'year', 'lyrics'];
|
||||
const REQUIRED_FIELDS = ['title', 'artist', 'album_artist'];
|
||||
const METADATA_QUEUE_TYPES = ['missing_tags'];
|
||||
const METADATA_QUEUE_PAGE_SIZE = 100;
|
||||
|
||||
// ── Utility functions (mirrored from ExceptionPage.jsx) ────────────────────
|
||||
|
||||
function chipClass(active) {
|
||||
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'
|
||||
}`;
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
function actionButtonClass(enabled) {
|
||||
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'
|
||||
}`;
|
||||
}
|
||||
|
||||
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'];
|
||||
}
|
||||
|
||||
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 value; }
|
||||
}
|
||||
|
||||
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')}`;
|
||||
}
|
||||
|
||||
function formatConfidence(value) {
|
||||
if (value == null) return '--';
|
||||
return `${Number(value).toFixed(1)} 分`;
|
||||
}
|
||||
|
||||
function formatMetadataValue(value) {
|
||||
if (value === null || value === undefined || value === '') return '--';
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function providerLabel(provider) {
|
||||
const labels = { acoustid: 'AcoustID', musicbrainz: 'MusicBrainz', netease: '网易云', qq: 'QQ 音乐', spotify: 'Spotify' };
|
||||
const key = String(provider || '').toLowerCase();
|
||||
return labels[key] || provider || '推荐候选';
|
||||
}
|
||||
|
||||
function compareTimestampDesc(a, b) {
|
||||
return new Date(b || 0).getTime() - new Date(a || 0).getTime();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function getMissingRequiredFields(metadata) {
|
||||
return REQUIRED_FIELDS
|
||||
.filter((field) => !String(metadata?.[field] || '').trim())
|
||||
.map((field) => {
|
||||
const labels = { title: '标题', artist: '艺术家', album_artist: '专辑艺术家' };
|
||||
return labels[field] || field;
|
||||
});
|
||||
}
|
||||
|
||||
function isTerminalRepairStatus(status) {
|
||||
return status === 'completed' || status === 'failed';
|
||||
}
|
||||
|
||||
// ── Sub-components ──────────────────────────────────────────────────────────
|
||||
|
||||
function InfoField({ label, value, mono = false }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-800 bg-slate-900/70 p-3">
|
||||
<div className="text-[11px] uppercase tracking-[0.14em] text-slate-500">{label}</div>
|
||||
<div className={`mt-1 text-sm text-slate-100 ${mono ? 'break-all font-mono text-[11px]' : ''}`}>{value || '--'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorText({ message }) {
|
||||
return <p className="mt-3 text-xs text-rose-300">{message}</p>;
|
||||
}
|
||||
|
||||
function renderInlineBadge(status) {
|
||||
if (!status) return null;
|
||||
const map = {
|
||||
submitting: 'border-amber-500/30 bg-amber-500/10 text-amber-200',
|
||||
accepted: 'border-cyan-500/30 bg-cyan-500/10 text-cyan-200',
|
||||
running: 'border-cyan-500/30 bg-cyan-500/10 text-cyan-200',
|
||||
completed: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-200',
|
||||
failed: 'border-rose-500/30 bg-rose-500/10 text-rose-200'
|
||||
};
|
||||
const label = { submitting: '提交中', accepted: '已提交', running: '执行中', completed: '已完成', failed: '失败' }[status];
|
||||
if (!label) return null;
|
||||
return <span className={`rounded-full border px-2.5 py-1 text-xs ${map[status]}`}>{label}</span>;
|
||||
}
|
||||
|
||||
// Memoized queue item component to prevent unnecessary re-renders
|
||||
const QueueItem = memo(({ item, selectedId, onSelect }) => {
|
||||
const ap = item.audio_props_json || {};
|
||||
const selected = selectedId === item.exception_id;
|
||||
return (
|
||||
<button
|
||||
onClick={() => onSelect(item.exception_id)}
|
||||
className={`w-full rounded-2xl border p-4 text-left transition ${
|
||||
selected ? 'border-cyan-400/60 bg-cyan-500/10 shadow-[0_0_0_1px_rgba(34,211,238,0.08)]'
|
||||
: 'border-slate-800 bg-slate-900/65 hover:border-slate-700 hover:bg-slate-900'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium text-slate-100">{item.display_title}</div>
|
||||
<div className="mt-1 truncate font-mono text-[11px] text-cyan-300/80">{item.filename}</div>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-full border border-slate-700 bg-slate-950 px-2.5 py-1 text-[11px] text-slate-300">
|
||||
{item.type_label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 line-clamp-2 text-xs leading-5 text-slate-400">{item.display_reason}</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 text-[11px] text-slate-500">
|
||||
<span>{formatSeconds(ap.duration_seconds)}</span>
|
||||
<span className="text-right">{ap.bitrate ? `${Math.round(ap.bitrate / 1000)} kbps` : '--'}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
// ── Main component ──────────────────────────────────────────────────────────
|
||||
|
||||
export default function MissingTagsInlinePanel({
|
||||
onSwitchToAdvanced
|
||||
}) {
|
||||
// Queue state
|
||||
const [queue, setQueue] = useState([]);
|
||||
const [isQueueLoading, setIsQueueLoading] = useState(true);
|
||||
const [queueError, setQueueError] = useState('');
|
||||
|
||||
// Selected item state
|
||||
const [selectedId, setSelectedId] = useState(null);
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [isDetailLoading, setIsDetailLoading] = useState(false);
|
||||
const [detailError, setDetailError] = useState('');
|
||||
|
||||
// Metadata editor state
|
||||
const [metadataPatch, setMetadataPatch] = useState({});
|
||||
const [providerMode, setProviderMode] = useState('all');
|
||||
const [providers, setProviders] = useState([]);
|
||||
|
||||
// Combined state using useReducer
|
||||
const [previewState, setPreviewState] = useReducer(
|
||||
(state, action) => {
|
||||
switch (action.type) {
|
||||
case 'START': return { ...state, loading: true, error: '', action: action.action };
|
||||
case 'SUCCESS': return { ...state, loading: false, payload: action.payload, error: '' };
|
||||
case 'ERROR': return { ...state, loading: false, payload: null, error: action.error };
|
||||
case 'CLEAR': return { loading: false, payload: null, error: '', action: '' };
|
||||
default: return state;
|
||||
}
|
||||
},
|
||||
{ loading: false, payload: null, error: '', action: '' }
|
||||
);
|
||||
|
||||
const [executionState, setExecutionState] = useReducer(
|
||||
(state, action) => {
|
||||
switch (action.type) {
|
||||
case 'SUBMIT': return { ...action.payload, status: 'submitting', repairTaskId: null, error: '' };
|
||||
case 'ACCEPT': return { ...state, status: 'accepted', repairTaskId: action.repairTaskId, error: '' };
|
||||
case 'RUNNING': return { ...state, status: 'running' };
|
||||
case 'COMPLETE': return { ...state, status: 'completed' };
|
||||
case 'FAIL': return { ...state, status: 'failed', error: action.error };
|
||||
case 'CLEAR': return null;
|
||||
default: return state;
|
||||
}
|
||||
},
|
||||
null
|
||||
);
|
||||
|
||||
const [repairTask, setRepairTask] = useState(null);
|
||||
const [repairLogs, setRepairLogs] = useState([]);
|
||||
const completedRefreshRef = useRef(new Set());
|
||||
|
||||
// Derived values
|
||||
const draft = metadataPatch && Object.keys(metadataPatch).length > 0
|
||||
? metadataPatch
|
||||
: detail?.effective_metadata || {};
|
||||
const missingFields = getMissingRequiredFields(draft);
|
||||
const canIngest = missingFields.length === 0 && (detail?.available_actions || []).includes('save_and_organize');
|
||||
const candidates = detail?.match_candidates_json || [];
|
||||
const finalPreviewItem = previewState.action === 'save_and_organize' && previewState.payload
|
||||
? previewState.payload.items?.find((item) => item.exception_id === selectedId) || null
|
||||
: null;
|
||||
const finalPreview = finalPreviewItem?.final_library_preview || null;
|
||||
|
||||
// ── Load queue ──────────────────────────────────────────────────────────
|
||||
const loadQueue = useCallback((keepSelection = true) => {
|
||||
setIsQueueLoading(true);
|
||||
setQueueError('');
|
||||
Promise.all(
|
||||
METADATA_QUEUE_TYPES.map((type) =>
|
||||
fetchExceptionItems({ type, resolutionStatus: 'open', page: 1, pageSize: METADATA_QUEUE_PAGE_SIZE })
|
||||
)
|
||||
)
|
||||
.then((payloads) => {
|
||||
const all = payloads
|
||||
.flatMap((p) => p.items)
|
||||
.sort((a, b) => compareTimestampDesc(a.captured_at, b.captured_at));
|
||||
setQueue(all);
|
||||
if (keepSelection) {
|
||||
setSelectedId((prev) => {
|
||||
if (!all.length) return null;
|
||||
if (prev && all.some((item) => item.exception_id === prev)) return prev;
|
||||
return all[0]?.exception_id || null;
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((err) => setQueueError(err.message || '队列加载失败'))
|
||||
.finally(() => setIsQueueLoading(false));
|
||||
}, []);
|
||||
|
||||
// ── Load detail ──────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!selectedId) { setDetail(null); return; }
|
||||
const controller = new AbortController();
|
||||
setIsDetailLoading(true);
|
||||
setDetailError('');
|
||||
fetchExceptionItem(selectedId, { signal: controller.signal })
|
||||
.then((payload) => {
|
||||
setDetail(payload);
|
||||
const md = payload.effective_metadata || payload.matched_metadata_json || payload.original_tags_json || {};
|
||||
setMetadataPatch({
|
||||
title: md.title || '', artist: md.artist || '', album: md.album || '',
|
||||
album_artist: md.album_artist || '', track_number: md.track_number ?? null,
|
||||
disc_number: md.disc_number ?? null, year: md.year ?? null, lyrics: md.lyrics || ''
|
||||
});
|
||||
setPreviewState({ type: 'CLEAR' });
|
||||
setExecutionState({ type: 'CLEAR' });
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name !== 'AbortError') { setDetail(null); setDetailError(err.message || '详情加载失败'); }
|
||||
})
|
||||
.finally(() => { if (!controller.signal.aborted) setIsDetailLoading(false); });
|
||||
return () => controller.abort();
|
||||
}, [selectedId]);
|
||||
|
||||
// ── Initial load ─────────────────────────────────────────────────────────
|
||||
useEffect(() => { loadQueue(false); }, []);
|
||||
useEffect(() => {
|
||||
fetchCurrentRepairTask().then((p) => {
|
||||
if (p.task) { setRepairTask(p.task); fetchRepairTaskLogs(p.task.task_id, 1, 20).then((lp) => setRepairLogs(lp.logs)); }
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// ── Repair task WebSocket ────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!repairTask?.task_id) return;
|
||||
let socket = null;
|
||||
let isMounted = true;
|
||||
|
||||
const setupSocket = () => {
|
||||
socket = createRepairTaskStream(repairTask.task_id);
|
||||
socket.onmessage = async (event) => {
|
||||
if (!isMounted) return;
|
||||
const p = JSON.parse(event.data);
|
||||
if (p.type === 'task.snapshot') {
|
||||
setRepairTask(p.data.task);
|
||||
setRepairLogs(p.data.recent_logs || []);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const tp = await fetchRepairTask(repairTask.task_id);
|
||||
const lp = await fetchRepairTaskLogs(repairTask.task_id, 1, 20);
|
||||
if (isMounted) {
|
||||
setRepairTask(tp.task);
|
||||
setRepairLogs(lp.logs);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to refresh repair task state', err);
|
||||
}
|
||||
};
|
||||
socket.onerror = (err) => {
|
||||
console.error('Repair task WebSocket error', err);
|
||||
};
|
||||
};
|
||||
|
||||
setupSocket();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
if (socket) {
|
||||
try {
|
||||
socket.close();
|
||||
} catch (err) {
|
||||
console.warn('WebSocket close error', err);
|
||||
}
|
||||
socket = null;
|
||||
}
|
||||
};
|
||||
}, [repairTask?.task_id]);
|
||||
|
||||
// ── React to repair task completion ─────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!repairTask?.task_id) return;
|
||||
setExecutionState((prev) => {
|
||||
if (!prev || prev.repairTaskId !== repairTask.task_id) return prev;
|
||||
const nextStatus = repairTask.status === 'completed' ? 'completed'
|
||||
: repairTask.status === 'failed' ? 'failed'
|
||||
: repairTask.status === 'running' ? 'running' : 'accepted';
|
||||
if (prev.status === nextStatus) return prev;
|
||||
return { ...prev, status: nextStatus, error: repairTask.status === 'failed' ? repairTask.error_message || '执行失败' : '' };
|
||||
});
|
||||
if (isTerminalRepairStatus(repairTask.status) && !completedRefreshRef.current.has(repairTask.task_id)) {
|
||||
completedRefreshRef.current.add(repairTask.task_id);
|
||||
loadQueue(true);
|
||||
}
|
||||
}, [repairTask]);
|
||||
|
||||
// ── Metadata update handler ──────────────────────────────────────────────
|
||||
const updateMetadata = useCallback((key, value) => {
|
||||
setMetadataPatch((prev) => ({ ...prev, [key]: value }));
|
||||
setPreviewState({ type: 'CLEAR' });
|
||||
}, []);
|
||||
|
||||
// ── Preview handler ──────────────────────────────────────────────────────
|
||||
const handlePreview = useCallback(async (action) => {
|
||||
if (!selectedId || !action) return;
|
||||
const params = normalizeActionParams(action, {
|
||||
metadata_patch: metadataPatch,
|
||||
provider_mode: providerMode,
|
||||
providers
|
||||
});
|
||||
setPreviewState({ type: 'START', action });
|
||||
try {
|
||||
const payload = await previewExceptionAction({ exception_ids: [selectedId], action, params });
|
||||
setPreviewState({ type: 'SUCCESS', payload });
|
||||
} catch (err) {
|
||||
setPreviewState({ type: 'ERROR', error: err.message || '预览生成失败' });
|
||||
}
|
||||
}, [selectedId, metadataPatch, providerMode, providers]);
|
||||
|
||||
// ── Execute handler ──────────────────────────────────────────────────────
|
||||
const handleExecute = useCallback(async (action) => {
|
||||
if (!selectedId || !action) return;
|
||||
if (action === 'delete_file') {
|
||||
if (!window.confirm('将永久删除选中的文件,且无法恢复。是否继续?')) return;
|
||||
if (!window.confirm('请再次确认:这会真实删除文件,不是忽略。是否执行删除?')) return;
|
||||
}
|
||||
setExecutionState({ type: 'SUBMIT', payload: { exceptionId: selectedId, action, submittedAt: new Date().toISOString() } });
|
||||
try {
|
||||
const params = normalizeActionParams(action, {
|
||||
metadata_patch: metadataPatch,
|
||||
provider_mode: providerMode,
|
||||
providers
|
||||
});
|
||||
const payload = await executeExceptionAction({ exception_ids: [selectedId], action, params });
|
||||
setExecutionState({ type: 'ACCEPT', repairTaskId: payload.repair_task_id });
|
||||
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) {
|
||||
setExecutionState({ type: 'FAIL', error: err.message || '执行失败' });
|
||||
}
|
||||
}, [selectedId, metadataPatch, providerMode, providers]);
|
||||
|
||||
// ── Smart ingest: preview then execute ────────────────────────────────────
|
||||
const handleIngest = useCallback(async () => {
|
||||
if (!canIngest) return;
|
||||
try {
|
||||
const params = normalizeActionParams('save_and_organize', { metadata_patch: metadataPatch, provider_mode: providerMode, providers });
|
||||
const previewP = await previewExceptionAction({ exception_ids: [selectedId], action: 'save_and_organize', params });
|
||||
setPreviewState({ type: 'SUCCESS', payload: previewP, action: 'save_and_organize' });
|
||||
// Directly execute after preview
|
||||
await handleExecute('save_and_organize');
|
||||
} catch (err) {
|
||||
setPreviewState({ type: 'ERROR', error: err.message || '预览生成失败' });
|
||||
// Also clear any stale execution state
|
||||
setExecutionState({ type: 'CLEAR' });
|
||||
}
|
||||
}, [canIngest, metadataPatch, providerMode, providers, selectedId, handleExecute]);
|
||||
|
||||
// ── Audio player state ───────────────────────────────────────────────────
|
||||
const audioRef = useRef(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [audioError, setAudioError] = useState('');
|
||||
const audioUrl = detail ? buildExceptionAudioUrl(detail.exception_id) : '';
|
||||
const audioProps = detail?.audio_props_json || {};
|
||||
|
||||
useEffect(() => {
|
||||
setIsPlaying(false); setCurrentTime(0); setDuration(0); setAudioError('');
|
||||
if (audioRef.current) { audioRef.current.pause(); audioRef.current.load(); }
|
||||
}, [audioUrl]);
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
if (!audioRef.current) return;
|
||||
if (audioRef.current.paused) {
|
||||
audioRef.current.play().catch(() => setAudioError('播放器启动失败'));
|
||||
} else {
|
||||
audioRef.current.pause();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="flex min-h-[calc(100vh-120px)] flex-col gap-6 py-6">
|
||||
{/* Header */}
|
||||
<section className="rounded-[28px] border border-slate-800/90 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.08),_transparent_30%),linear-gradient(180deg,rgba(15,23,42,0.98),rgba(2,6,23,0.98))] p-5 shadow-[0_24px_80px_rgba(2,6,23,0.45)]">
|
||||
<div className="flex flex-col gap-5 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.28em] text-cyan-300/70">元数据缺失 · 快速补全</p>
|
||||
<h2 className="-mt-1 flex items-center gap-3 text-2xl font-semibold text-white">
|
||||
<Music2 className="h-6 w-6 text-cyan-300" />
|
||||
元数据缺失处理
|
||||
</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm text-slate-400">
|
||||
左侧编辑元数据,右侧实时预览入库路径。补全必填字段后一键入库。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<button onClick={onSwitchToAdvanced} className={actionButtonClass(true)}>
|
||||
高级处理
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Main content: queue + workspace */}
|
||||
<div className="grid min-h-0 flex-1 gap-6 xl:grid-cols-[minmax(300px,0.32fr)_minmax(0,1fr)]">
|
||||
{/* ── Left: Queue ────────────────────────────────────────────────── */}
|
||||
<section className="min-h-[420px] overflow-hidden rounded-[28px] border border-slate-800/90 bg-slate-950/80 shadow-[0_24px_80px_rgba(2,6,23,0.35)]">
|
||||
<div className="border-b border-slate-800/80 p-5">
|
||||
<p className="text-xs uppercase tracking-[0.22em] text-slate-500">元数据缺失队列</p>
|
||||
<h3 className="mt-2 text-lg font-semibold text-white">{queue.length} 个待处理</h3>
|
||||
</div>
|
||||
<div className="max-h-[calc(100vh-330px)] min-h-[320px] overflow-auto p-3">
|
||||
{isQueueLoading ? (
|
||||
<div className="flex min-h-[280px] items-center justify-center text-sm text-slate-500">正在加载...</div>
|
||||
) : queueError ? (
|
||||
<div className="rounded-2xl border border-rose-900/50 bg-rose-950/20 p-4 text-sm text-rose-200">{queueError}</div>
|
||||
) : !queue.length ? (
|
||||
<div className="flex min-h-[280px] flex-col items-center justify-center text-center text-slate-500">
|
||||
<CheckCircle2 className="mb-4 h-12 w-12 text-emerald-300/60" />
|
||||
<p className="text-sm text-slate-300">元数据缺失异常已处理完成</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{queue.map((item) => (
|
||||
<QueueItem
|
||||
key={item.exception_id}
|
||||
item={item}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Right: Workspace ───────────────────────────────────────────── */}
|
||||
<section className="min-h-0 overflow-auto rounded-[28px] border border-slate-800/90 bg-[radial-gradient(circle_at_top_right,_rgba(34,197,94,0.08),_transparent_28%),linear-gradient(180deg,rgba(15,23,42,0.98),rgba(2,6,23,0.98))] p-5 shadow-[0_24px_80px_rgba(2,6,23,0.45)]">
|
||||
{!detail && !isDetailLoading ? (
|
||||
<div className="flex h-full min-h-[420px] flex-col items-center justify-center text-slate-500">
|
||||
<Music2 className="mb-4 h-12 w-12 opacity-30" />
|
||||
<p className="text-sm">从左侧队列选择一个文件开始处理</p>
|
||||
</div>
|
||||
) : detailError ? (
|
||||
<div className="rounded-2xl border border-rose-900/50 bg-rose-950/20 p-4 text-sm text-rose-200">{detailError}</div>
|
||||
) : isDetailLoading ? (
|
||||
<div className="flex h-full min-h-[420px] items-center justify-center">
|
||||
<LoaderCircle className="h-8 w-8 animate-spin text-cyan-300/60" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 pb-8">
|
||||
{/* File summary */}
|
||||
<div className="rounded-[24px] border border-slate-800/80 bg-slate-950/75 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">当前文件</p>
|
||||
<h3 className="mt-2 truncate text-lg font-semibold text-white">{detail.display_title || '-'}</h3>
|
||||
<p className="mt-1 truncate font-mono text-[11px] text-cyan-300/80">{detail.filename || '-'}</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-rose-500/30 bg-rose-500/10 px-2.5 py-1 text-xs text-rose-200">开放中</span>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-2 gap-3 text-xs text-slate-300">
|
||||
<InfoField label="匹配来源" value={detail.match_source || '--'} />
|
||||
<InfoField label="匹配分数" value={formatConfidence(detail.match_confidence)} />
|
||||
<InfoField label="编码" value={audioProps.codec || '--'} />
|
||||
<InfoField label="时长" value={formatSeconds(audioProps.duration_seconds)} />
|
||||
</div>
|
||||
<div className="mt-4 rounded-2xl border border-slate-800 bg-slate-900/70 p-3 text-xs text-slate-300">
|
||||
{detail.display_reason || '-'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Two-column layout: editor | preview */}
|
||||
<div className="grid gap-4 xl:grid-cols-[1fr_minmax(320px,0.9fr)]">
|
||||
{/* ── Left column: Edit ─────────────────────────────────── */}
|
||||
<div className="space-y-4">
|
||||
{/* Audio player */}
|
||||
<div className="rounded-[24px] border border-slate-800/80 bg-slate-950/75 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">试听预览</p>
|
||||
<h4 className="mt-2 flex items-center gap-2 text-sm font-medium text-white">
|
||||
<Headphones className="h-4 w-4 text-emerald-300" />在线试听
|
||||
</h4>
|
||||
</div>
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className="flex h-11 w-11 items-center justify-center rounded-full border border-emerald-500/40 bg-emerald-500/10 text-emerald-200 transition hover:bg-emerald-500/20"
|
||||
>
|
||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="ml-0.5 h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<audio ref={audioRef} src={audioUrl} preload="metadata"
|
||||
onPlay={() => setIsPlaying(true)} onPause={() => setIsPlaying(false)}
|
||||
onTimeUpdate={(e) => setCurrentTime(e.currentTarget.currentTime)}
|
||||
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration || 0)}
|
||||
onError={() => setAudioError('音频文件不可用或已丢失')}
|
||||
className="hidden"
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<input type="range" min="0" max={duration || 0} step="0.1"
|
||||
value={Math.min(currentTime, duration || 0)}
|
||||
onChange={(e) => { const v = Number(e.target.value); setCurrentTime(v); if (audioRef.current) audioRef.current.currentTime = v; }}
|
||||
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-800 accent-emerald-400"
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-between font-mono text-[11px] text-slate-500">
|
||||
<span>{formatSeconds(currentTime)}</span><span>{formatSeconds(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-2 gap-3 text-xs text-slate-300">
|
||||
<InfoField label="格式" value={audioProps.format || '--'} />
|
||||
<InfoField label="采样率" value={audioProps.sample_rate ? `${audioProps.sample_rate} Hz` : '--'} />
|
||||
<InfoField label="比特率" value={audioProps.bitrate ? `${Math.round(audioProps.bitrate / 1000)} kbps` : '--'} />
|
||||
<InfoField label="位深" value={audioProps.bit_depth ? `${audioProps.bit_depth} bit` : '--'} />
|
||||
</div>
|
||||
{audioError ? <p className="mt-3 text-xs text-rose-300">{audioError}</p> : null}
|
||||
</div>
|
||||
|
||||
{/* Match retry */}
|
||||
<div className="rounded-[24px] border border-slate-800/80 bg-slate-950/75 p-4">
|
||||
<h4 className="text-sm font-medium text-white">重新匹配</h4>
|
||||
<p className="mt-2 text-xs leading-5 text-slate-400">选择匹配来源后执行重新匹配,结果会自动填充到下方编辑区。</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{PROVIDER_MODES.map((mode) => {
|
||||
const active = providerMode === mode.id;
|
||||
return (
|
||||
<button key={mode.id} onClick={() => { setProviderMode(mode.id); setProviders(mode.providers); }}
|
||||
className={chipClass(active)}>
|
||||
{mode.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<button onClick={() => { handlePreview('retry_match'); }} className={actionButtonClass(true)}>
|
||||
<Search className="h-3.5 w-3.5" />预览匹配
|
||||
</button>
|
||||
<button onClick={() => handleExecute('retry_match')}
|
||||
className="rounded-xl bg-cyan-500 px-3 py-2 text-sm font-medium text-slate-950">
|
||||
执行匹配
|
||||
</button>
|
||||
</div>
|
||||
{previewState.action === 'retry_match' && previewState.loading ? (
|
||||
<div className="mt-3 flex items-center gap-2 text-xs text-slate-400">
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" />正在生成匹配预览...
|
||||
</div>
|
||||
) : null}
|
||||
{executionState?.action === 'retry_match' ? (
|
||||
<div className="mt-3">{renderInlineBadge(executionState.status)}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Metadata editor */}
|
||||
<div className="rounded-[24px] border border-slate-800/80 bg-slate-950/75 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-white">元数据编辑</h4>
|
||||
<p className="mt-2 text-xs leading-5 text-slate-400">补全 title / artist / album_artist 后可入库。</p>
|
||||
</div>
|
||||
<span className={`rounded-full border px-2.5 py-1 text-[11px] ${
|
||||
canIngest ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-200'
|
||||
: 'border-rose-500/30 bg-rose-500/10 text-rose-200'
|
||||
}`}>
|
||||
{canIngest ? '可入库' : '缺少必填'}
|
||||
</span>
|
||||
</div>
|
||||
{detail.album_artist_reason ? (
|
||||
<div className="mt-4 rounded-2xl border border-slate-800 bg-slate-900/70 p-3 text-xs text-slate-300">
|
||||
{detail.album_artist_reason}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-4 grid grid-cols-2 gap-2">
|
||||
<input className={inputClass()} value={draft.title || ''} onChange={(e) => updateMetadata('title', e.target.value)} placeholder="标题 *" />
|
||||
<input className={inputClass()} value={draft.artist || ''} onChange={(e) => updateMetadata('artist', e.target.value)} placeholder="艺术家 *" />
|
||||
<input className={inputClass()} value={draft.album_artist || ''} onChange={(e) => updateMetadata('album_artist', e.target.value)} placeholder="专辑艺术家 *" />
|
||||
<input className={inputClass()} value={draft.album || ''} onChange={(e) => updateMetadata('album', e.target.value)} placeholder="专辑" />
|
||||
<input className={inputClass()} value={draft.track_number ?? ''} onChange={(e) => updateMetadata('track_number', e.target.value === '' ? null : Number(e.target.value))} placeholder="曲目号" />
|
||||
<input className={inputClass()} value={draft.disc_number ?? ''} onChange={(e) => updateMetadata('disc_number', e.target.value === '' ? null : Number(e.target.value))} placeholder="碟号" />
|
||||
<input className={inputClass()} value={draft.year ?? ''} onChange={(e) => updateMetadata('year', e.target.value === '' ? null : Number(e.target.value))} placeholder="年份" />
|
||||
</div>
|
||||
<textarea className={`${inputClass()} mt-2 min-h-[96px] resize-y`}
|
||||
value={draft.lyrics || ''} onChange={(e) => updateMetadata('lyrics', e.target.value)} placeholder="歌词" />
|
||||
<div className="mt-4 grid grid-cols-3 gap-2 text-xs">
|
||||
{REQUIRED_FIELDS.map((field) => {
|
||||
const labels = { title: '标题', artist: '艺术家', album_artist: '专辑艺术家' };
|
||||
const present = String(draft[field] || '').trim();
|
||||
return (
|
||||
<div key={field} className={`rounded-2xl border px-3 py-2 ${
|
||||
present ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-100'
|
||||
: 'border-rose-500/30 bg-rose-500/10 text-rose-100'
|
||||
}`}>{labels[field]}</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Candidate preview (if preview matched) */}
|
||||
{previewState.action === 'retry_match' && previewState.payload && !previewState.loading ? (
|
||||
<div className="mt-4 rounded-2xl border border-amber-900/40 bg-amber-950/20 p-4">
|
||||
<h5 className="flex items-center gap-2 text-sm font-medium text-white">
|
||||
<ShieldAlert className="h-4 w-4 text-amber-300" />匹配预览结果
|
||||
</h5>
|
||||
<div className="mt-3 space-y-2">
|
||||
{previewState.payload.items?.map((item) => (
|
||||
<div key={item.exception_id} className="rounded-2xl border border-amber-900/30 bg-slate-950/60 p-3 text-xs text-slate-300">
|
||||
<div className="font-medium text-slate-100">{item.filename}</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
{item.planned_operations?.map((op, i) => (
|
||||
<div key={`${op.type}-${i}`} className="text-slate-400">{op.description}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{previewState.error && previewState.action === 'retry_match' ? <ErrorText message={previewState.error} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Right column: Preview ──────────────────────────────── */}
|
||||
<div className="space-y-4">
|
||||
{/* Refresh preview button */}
|
||||
<div className="rounded-[24px] border border-slate-800/80 bg-slate-950/75 p-4">
|
||||
<h4 className="text-sm font-medium text-white">入库预览</h4>
|
||||
<p className="mt-2 text-xs leading-5 text-slate-400">
|
||||
点击下方按钮生成后端计算的最终元数据和入库路径。
|
||||
</p>
|
||||
<button onClick={() => handlePreview('save_and_organize')}
|
||||
disabled={!canIngest}
|
||||
className={`mt-4 inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm font-medium transition ${
|
||||
canIngest ? 'bg-slate-100 text-slate-900' : 'cursor-not-allowed bg-slate-800 text-slate-500'
|
||||
}`}>
|
||||
<Sparkles className="h-3.5 w-3.5" />刷新入库预览
|
||||
</button>
|
||||
|
||||
{previewState.action === 'save_and_organize' && previewState.loading ? (
|
||||
<div className="mt-4 flex items-center gap-2 rounded-2xl border border-slate-800 bg-slate-900/70 p-3 text-xs text-slate-400">
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" />正在生成入库确认...
|
||||
</div>
|
||||
) : previewState.error && previewState.action === 'save_and_organize' ? (
|
||||
<ErrorText message={previewState.error} />
|
||||
) : finalPreview ? (
|
||||
<div className="mt-4 space-y-3">
|
||||
{/* Target paths */}
|
||||
<div className="rounded-2xl border border-amber-900/40 bg-amber-950/20 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h5 className="flex items-center gap-2 text-sm font-medium text-white">
|
||||
<ShieldAlert className="h-4 w-4 text-amber-300" />入库确认
|
||||
</h5>
|
||||
<span className={`rounded-full px-2 py-1 text-[11px] ${riskClass(previewState.payload?.risk_level)}`}>
|
||||
风险 {previewState.payload?.risk_level}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 text-xs text-slate-300">
|
||||
<InfoField label="目标相对路径" value={finalPreview.target_relative_path} mono />
|
||||
<InfoField label="完整目标文件路径" value={finalPreview.target_file_path} mono />
|
||||
</div>
|
||||
</div>
|
||||
{/* Final metadata table */}
|
||||
<div className="overflow-x-auto rounded-2xl border border-slate-800 bg-slate-950/60">
|
||||
<table className="min-w-[620px] w-full border-collapse text-left text-xs">
|
||||
<thead className="bg-slate-900/70 text-[11px] uppercase tracking-[0.14em] text-slate-500">
|
||||
<tr>
|
||||
<th className="w-40 px-3 py-3 font-medium">字段</th>
|
||||
<th className="px-3 py-3 font-medium">最终值</th>
|
||||
<th className="w-36 px-3 py-3 font-medium">来源</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{METADATA_FIELDS.map((field) => (
|
||||
<tr key={field} className="border-t border-slate-800/80">
|
||||
<td className="px-3 py-3 font-mono text-[11px] text-cyan-100">{field}</td>
|
||||
<td className="px-3 py-3 whitespace-pre-wrap break-all text-slate-100">
|
||||
{formatMetadataValue(finalPreview.metadata?.[field])}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-slate-400">{finalPreview.metadata_sources?.[field] || '--'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/* Planned operations */}
|
||||
<div className="rounded-2xl border border-slate-800 bg-slate-950/60 p-4">
|
||||
<h5 className="text-sm font-medium text-white">计划操作</h5>
|
||||
<div className="mt-3 space-y-2 text-xs text-slate-300">
|
||||
{(finalPreviewItem?.planned_operations || []).map((op, i) => (
|
||||
<div key={`${op.type}-${i}`} className="rounded-xl border border-slate-800 bg-slate-900/70 p-3">
|
||||
<div className="text-slate-100">{op.description}</div>
|
||||
{op.target_path ? (
|
||||
<div className="mt-1 break-all font-mono text-[11px] text-slate-500">{op.target_path}</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{(finalPreviewItem?.warnings || []).concat(previewState.payload?.warnings || []).length > 0 ? (
|
||||
<div className="mt-3 space-y-1 text-xs text-amber-200">
|
||||
{finalPreviewItem?.warnings?.map((w, i) => <div key={`iw-${i}`}>{w}</div>)}
|
||||
{previewState.payload?.warnings?.map((w, i) => <div key={`pw-${i}`}>{w}</div>)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Action bar ────────────────────────────────────────────── */}
|
||||
<div className="rounded-[24px] border border-slate-800/80 bg-slate-950/75 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-white">执行操作</h4>
|
||||
<p className="mt-1 text-xs text-slate-400">入库前请确认右侧预览结果。忽略只改状态,删除会真实删除文件。</p>
|
||||
</div>
|
||||
{renderInlineBadge(executionState?.status)}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{/* Primary: Ingest */}
|
||||
<button onClick={handleIngest} disabled={!canIngest}
|
||||
className={`rounded-xl px-3 py-2 text-sm font-medium transition ${
|
||||
canIngest ? 'bg-cyan-500 text-slate-950 hover:bg-cyan-400' : 'cursor-not-allowed bg-slate-800 text-slate-500'
|
||||
}`}>
|
||||
入库
|
||||
</button>
|
||||
|
||||
{/* Save draft */}
|
||||
<button onClick={() => handleExecute('edit_metadata')}
|
||||
className="rounded-xl border border-slate-700 bg-slate-900 px-3 py-2 text-sm font-medium text-slate-100">
|
||||
保存草稿
|
||||
</button>
|
||||
|
||||
{/* Ignore */}
|
||||
<button onClick={() => { handlePreview('ignore_exception'); }}
|
||||
className="rounded-xl border border-slate-700 bg-slate-900 px-3 py-2 text-sm font-medium text-slate-100">
|
||||
预览忽略
|
||||
</button>
|
||||
<button onClick={() => handleExecute('ignore_exception')}
|
||||
className="rounded-xl border border-slate-700 bg-slate-900 px-3 py-2 text-sm font-medium text-slate-100">
|
||||
确认忽略
|
||||
</button>
|
||||
|
||||
{/* Delete */}
|
||||
<button onClick={() => { handlePreview('delete_file'); }}
|
||||
className="rounded-xl bg-rose-100 px-3 py-2 text-sm font-medium text-rose-950">
|
||||
预览删除
|
||||
</button>
|
||||
<button onClick={() => handleExecute('delete_file')}
|
||||
className="rounded-xl bg-rose-500 px-3 py-2 text-sm font-medium text-white">
|
||||
删除文件
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Preview for ignore/delete */}
|
||||
{(previewState.action === 'ignore_exception' || previewState.action === 'delete_file') && previewState.payload && !previewState.loading ? (
|
||||
<div className="mt-4 rounded-2xl border border-amber-900/40 bg-amber-950/20 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h5 className="flex items-center gap-2 text-sm font-medium text-white">
|
||||
<ShieldAlert className="h-4 w-4 text-amber-300" />预览结果
|
||||
</h5>
|
||||
<span className={`rounded-full px-2 py-1 text-[11px] ${riskClass(previewState.payload?.risk_level)}`}>
|
||||
风险 {previewState.payload?.risk_level}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{previewState.payload.items?.map((item) => (
|
||||
<div key={item.exception_id} className="rounded-2xl border border-amber-900/30 bg-slate-950/60 p-3 text-xs text-slate-300">
|
||||
<div className="font-medium text-slate-100">{item.filename}</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
{item.planned_operations?.map((op, i) => (
|
||||
<div key={`${op.type}-${i}`} className="text-slate-400">{op.description}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{executionState?.error ? <ErrorText message={executionState.error} /> : null}
|
||||
|
||||
{/* Execution feedback */}
|
||||
{executionState?.status ? (
|
||||
<div className="mt-4 rounded-2xl border border-cyan-900/40 bg-cyan-950/20 p-3 text-xs text-cyan-100/85">
|
||||
<div className="font-medium">
|
||||
{executionState.status === 'submitting' ? '正在提交执行请求'
|
||||
: executionState.status === 'accepted' ? '任务已提交'
|
||||
: executionState.status === 'running' ? '任务执行中'
|
||||
: executionState.status === 'completed' ? '执行完成'
|
||||
: executionState.status === 'failed' ? '执行失败' : ''}
|
||||
</div>
|
||||
<div className="mt-1 text-slate-400">
|
||||
{executionState.repairTaskId ? `任务号 ${executionState.repairTaskId}` : '等待返回任务号'}
|
||||
{executionState.submittedAt ? `,提交时间 ${formatTimestamp(executionState.submittedAt)}` : ''}
|
||||
</div>
|
||||
{executionState.error ? <div className="mt-1 text-rose-200">{executionState.error}</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Repair task logs */}
|
||||
{repairTask && repairLogs.length > 0 && executionState?.repairTaskId === repairTask.task_id ? (
|
||||
<div className="mt-4 rounded-2xl border border-slate-800 bg-slate-950/60 p-3">
|
||||
<div className="flex items-center gap-2 text-xs text-slate-400">
|
||||
<LoaderCircle className={`h-3 w-3 ${!isTerminalRepairStatus(repairTask.status) ? 'animate-spin' : ''}`} />
|
||||
任务日志 ({repairTask.status})
|
||||
</div>
|
||||
<div className="mt-2 max-h-[160px] overflow-auto space-y-1 font-mono text-[11px] text-slate-500">
|
||||
{repairLogs.map((log, i) => (
|
||||
<div key={i}>{log.message || log.stage || '--'}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user