@@ -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 >
) ;
}