fix(media): move resource and folder actions into menus

This commit is contained in:
hectorqin committed 2026-10-02 09:48:16 +08:00
1 parent ef8c133f0a
commit 6ccc615db8
6 files changed
+25 -21

No files matched your search

+3 -3
View File
@@ -8,8 +8,8 @@ export interface CleanupPreview {
}
export interface CleanupResult extends CleanupPreview {returnPath:string}
export function FolderCleanup({api,libraryId,path,disabled,onBusy,onCleaned}:{
api:MediaApi;libraryId:string;path:string;disabled:boolean;onBusy:(busy:boolean)=>void;onCleaned:(result:CleanupResult)=>void;
export function FolderCleanup({api,libraryId,path,disabled,onBusy,onCleaned,menuOnly=false}:{
api:MediaApi;libraryId:string;path:string;disabled:boolean;onBusy:(busy:boolean)=>void;onCleaned:(result:CleanupResult)=>void;menuOnly?:boolean;
}){
const [preview,setPreview]=useState<CleanupPreview|null>(null),[message,setMessage]=useState(''),[error,setError]=useState('');
const request=useRef<AbortController|null>(null);
@@ -30,7 +30,7 @@ export function FolderCleanup({api,libraryId,path,disabled,onBusy,onCleaned}:{
finally{if(!abort.signal.aborted){request.current=null;onBusy(false);}}
}
return <>
<div className="media-folder-operation"><span><strong>失效资源</strong><small>检查当前目录及子目录的缺失记录</small></span><button disabled={disabled} onClick={()=>void run()}><Trash2 size={16} aria-hidden="true"/>清理失效资源</button></div>
<>{menuOnly?<button disabled={disabled} onClick={()=>void run()}><Trash2 size={16} aria-hidden="true"/>清理失效资源</button>:<div className="media-folder-operation"><span><strong>失效资源</strong><small>检查当前目录及子目录的缺失记录</small></span><button disabled={disabled} onClick={()=>void run()}><Trash2 size={16} aria-hidden="true"/>清理失效资源</button></div>}</>
{message&&<p role="status">{message}</p>}{error&&<p className="media-error" role="alert">{error}</p>}
{preview&&<FloatingConfirm theme="media" title="清理失效资源" confirmText="确认清理" cancelText="取消" onCancel={()=>setPreview(null)} onConfirm={()=>void run(true)} text={`清理「${path||'库内根目录'}」及其子目录中的 ${preview.assets} 个缺失文件记录。将移除 ${preview.parts} 个播放片段、${preview.editions} 个空版本和 ${preview.items} 个空作品/分类,以及所有用户关联的 ${preview.favorites} 条收藏、${preview.progress} 条播放进度和 ${preview.queue} 条队列记录。不会删除原始文件或仍有资源的作品。此操作不可撤销;如目录暂未挂载,请取消并在恢复后重新扫描。`}/>}
</>;
+2 -2
View File
@@ -11,7 +11,7 @@ interface Proposal {assetId:string;ref:string;before:{id:string;title:string;kin
interface Preview {id:string;items:Proposal[]}
const labels:Record<Mode,string>={auto:'自动识别',movie:'电影目录',series:'剧集目录',season:'指定季目录',ignore:'忽略目录'};
export function FolderRecognition({api,libraryId,path,disabled,onBusy,onApplied}:{api:MediaApi;libraryId:string;path:string;disabled:boolean;onBusy:(busy:boolean)=>void;onApplied:()=>void}){
export function FolderRecognition({api,libraryId,path,disabled,onBusy,onApplied,menuOnly=false}:{api:MediaApi;libraryId:string;path:string;disabled:boolean;onBusy:(busy:boolean)=>void;onApplied:()=>void;menuOnly?:boolean}){
const [open,setOpen]=useState(false),[busy,setBusy]=useState(false),[error,setError]=useState(''),[notice,setNotice]=useState('');
const [mode,setMode]=useState<Mode|'inherit'>('inherit'),[title,setTitle]=useState(''),[season,setSeason]=useState(''),[year,setYear]=useState(''),[strip,setStrip]=useState(false),[filePattern,setFilePattern]=useState('');
const [revision,setRevision]=useState(''),[inherited,setInherited]=useState<Rule|null>(null),[preview,setPreview]=useState<Preview|null>(null),[selected,setSelected]=useState<string[]>([]),[confirm,setConfirm]=useState(false),[page,setPage]=useState(0);
@@ -41,7 +41,7 @@ export function FolderRecognition({api,libraryId,path,disabled,onBusy,onApplied}
const visible=preview?.items.slice(page*30,page*30+30)??[];
const selectRows=(rows:Proposal[])=>setSelected(current=>[...new Set([...current,...rows.filter(selectable).map(row=>row.assetId)])]);
return <>
<div className="media-folder-operation"><span><strong>影视识别</strong><small>设置当前目录及子目录的识别规则</small></span><button disabled={disabled} onClick={show}><Settings2 size={16} aria-hidden="true"/>识别规则与预览</button></div>
<>{menuOnly?<button disabled={disabled} onClick={show}><Settings2 size={16} aria-hidden="true"/>识别规则与预览</button>:<div className="media-folder-operation"><span><strong>影视识别</strong><small>设置当前目录及子目录的识别规则</small></span><button disabled={disabled} onClick={show}><Settings2 size={16} aria-hidden="true"/>识别规则与预览</button></div>}</>
{open&&<Modal className="media-modal" title="目录识别规则" busy={busy} onClose={()=>setOpen(false)}><div className="media-form media-recognition-form">
<p className="media-folder-note">{path||'库内根目录'} · 包含子目录,子目录自己的规则优先。目录内文件统一应用下方文件正则。规则保存后用于后续扫描;已有作品通过下方预览确认重新识别。</p>
{inherited&&<p>继承自「{inherited.path||'库内根目录'}」:{labels[inherited.mode]}{inherited.title?' · '+inherited.title:''}</p>}
+2 -1
View File
@@ -41,11 +41,12 @@ export function MediaFolders({api,admin=false,video=false,libraryId,initialLocat
const segments=path?path.split('/'):[];
return <section className="media-folders" aria-label="影音文件夹">
<nav className="media-folder-path" aria-label="目录位置"><button disabled={busy} onClick={()=>open('')}>库内根目录</button>{segments.map((segment,index)=><button key={index} disabled={busy} onClick={()=>open(segments.slice(0,index+1).join('/'))}>/ {segment}</button>)}</nav>
{listing&&<details className="media-actions media-folder-actions"><summary aria-label="文件管理操作">更多操作</summary><nav aria-label="文件管理操作">{admin&&video&&<FolderRecognition key={libraryId+'-rules-menu-'+path} api={api} libraryId={libraryId} path={path} disabled={busy||loading} menuOnly onBusy={setBusy} onApplied={()=>{setCleanupNotice('已应用识别结果,原文件与播放进度保留。');setRetry(value=>value+1);}}/>}{admin&&<FolderCleanup key={libraryId+'-cleanup-menu-'+path} api={api} libraryId={libraryId} path={path} disabled={busy||loading} menuOnly onBusy={setBusy} onCleaned={result=>{setCleanupNotice('已清理 '+result.assets+' 个失效资源记录'+(result.returnPath!==path?',已返回上级有效目录。':'。'));setPath(result.returnPath);setOffset(0);setRetry(value=>value+1);}}/>}</nav></details>}
{(path||assetId)&&<button className="media-folder-back" disabled={busy} aria-label={'← '+(assetId?'返回文件列表':'上级目录')} onClick={()=>assetId?setAssetId(null):open(segments.slice(0,-1).join('/'))}><ChevronLeft size={16} aria-hidden="true"/>{assetId?'返回文件列表':'上级目录'}</button>}
{error&&<MediaScreenError fullPage error={cause} message={error} busy={loading} retryLabel="重新加载" onRetry={()=>setRetry(value=>value+1)}/>}{actionError&&<div className="media-error" role="alert">{actionError}</div>}{notice&&<p role="status">{notice}</p>}
{loading&&<MediaLoading layout={assetId?'tracks':'list'} square label={assetId?'正在读取文件与章节…':'正在读取目录…'}/>}
{cleanupNotice&&<p role="status">{cleanupNotice}</p>}
{listing&&<>{admin&&video&&<FolderRecognition key={libraryId+'-rules-'+path} api={api} libraryId={libraryId} path={path} disabled={busy||loading} onBusy={setBusy} onApplied={()=>{setCleanupNotice('已应用识别结果,原文件与播放进度保留。');setRetry(value=>value+1);}}/>}{admin&&<FolderCleanup key={libraryId+'-'+path} api={api} libraryId={libraryId} path={path} disabled={busy||loading} onBusy={setBusy} onCleaned={result=>{setCleanupNotice('已清理 '+result.assets+' 个失效资源记录'+(result.returnPath!==path?',已返回上级有效目录。':'。'));setPath(result.returnPath);setOffset(0);setRetry(value=>value+1);}}/>}{listing.items.map(entry=><div className="media-row media-folder-row" key={entry.path}><button disabled={busy} onClick={()=>entry.kind==='folder'?open(entry.path):setAssetId(entry.assetId)}>{entry.kind==='folder'?<Folder size={21} strokeWidth={1.5} aria-hidden="true"/>:<File size={21} strokeWidth={1.5} aria-hidden="true"/>}<span><strong>{entry.name}</strong><small>{entry.kind==='folder'?`${entry.files} 个媒体文件 · ${entry.availableFiles} 个可用`:`${(entry.size/1024/1024).toFixed(1)} MB · ${entry.availableFiles?'文件可用':'文件缺失'}`}</small></span><ChevronRight size={17} aria-hidden="true"/></button></div>)}{!listing.total&&<p className="media-empty">暂无已扫描媒体文件,请先扫描媒体库。</p>}{listing.total>60&&<nav className="media-toolbar" aria-label="文件分页"><button disabled={busy||!offset} onClick={()=>setOffset(Math.max(0,offset-60))}>上一页</button><span>{Math.floor(offset/60)+1} / {Math.ceil(listing.total/60)}</span><button disabled={busy||offset+60>=listing.total} onClick={()=>setOffset(offset+60)}>下一页</button></nav>}<p className="media-folder-note">按最近扫描结果显示媒体文件;不包含空目录及其他文件。原始目录只读。</p></>}
{listing&&<>{listing.items.map(entry=><div className="media-row media-folder-row" key={entry.path}><button disabled={busy} onClick={()=>entry.kind==='folder'?open(entry.path):setAssetId(entry.assetId)}>{entry.kind==='folder'?<Folder size={21} strokeWidth={1.5} aria-hidden="true"/>:<File size={21} strokeWidth={1.5} aria-hidden="true"/>}<span><strong>{entry.name}</strong><small>{entry.kind==='folder'?`${entry.files} 个媒体文件 · ${entry.availableFiles} 个可用`:`${(entry.size/1024/1024).toFixed(1)} MB · ${entry.availableFiles?'文件可用':'文件缺失'}`}</small></span><ChevronRight size={17} aria-hidden="true"/></button></div>)}{!listing.total&&<p className="media-empty">暂无已扫描媒体文件,请先扫描媒体库。</p>}{listing.total>60&&<nav className="media-toolbar" aria-label="文件分页"><button disabled={busy||!offset} onClick={()=>setOffset(Math.max(0,offset-60))}>上一页</button><span>{Math.floor(offset/60)+1} / {Math.ceil(listing.total/60)}</span><button disabled={busy||offset+60>=listing.total} onClick={()=>setOffset(offset+60)}>下一页</button></nav>}<p className="media-folder-note">按最近扫描结果显示媒体文件;不包含空目录及其他文件。原始目录只读。</p></>}
{file&&<><header className="media-folder-file-heading"><File size={27} strokeWidth={1.4} aria-hidden="true"/><div><h2>{file.path.split('/').at(-1)}</h2><small>{file.available?'文件可用':'文件已缺失'} · {file.items.length} 部关联作品</small></div></header>
<div className="media-folder-file-layout"><div>{file.items.map(item=><FileWork key={file.assetId+'-'+item.id} api={api} item={item} busy={busy} selected={editions[item.id]??''} onSelect={id=>setEditions({...editions,[item.id]:id})} positions={chapters} onPosition={(id,position)=>setChapters({...chapters,[id]:position})} onDetail={()=>onDetail(item.id,{path,offset,assetId,editions,chapters})} onRefresh={()=>setRetry(value=>value+1)} onPlay={(parts,index)=>void action(()=>onPlay(parts,index,item.title))} onQueue={ids=>void action(()=>onQueue(ids),true)}/>)}{!file.items.length&&<div className="media-folder-unlinked"><FolderOpen size={30} strokeWidth={1.4} aria-hidden="true"/><h3>暂未关联作品</h3><p>{file.available?'请管理员重新扫描媒体库,读取文件资料。':'恢复文件并重新扫描后可播放。'}</p></div>}</div><aside className="media-folder-file-resources"><h2>文件信息</h2><ResourceInfo key={file.assetId} api={api} assetId={file.assetId}/><p className="media-folder-note">仅显示此文件关联的版本片段。完整章节和其他文件中的版本,请打开作品详情。原始文件只读。</p></aside></div></>}
</section>;
+5 -6
View File
@@ -5,15 +5,14 @@ import {ResourceInfo} from './resource-info.tsx';
import {EditionTools,type EditionDetailsProps} from './edition-details.tsx';
import type {MediaApi} from './api.ts';
export function ResourcePanel({api,assets,summary,editionOptions,versionPicker,sourceInfo}:{api:MediaApi;assets:Array<{id:string;title:string}>;summary:string;versionPicker?:ComponentChildren;sourceInfo?:ComponentChildren;editionOptions?:EditionDetailsProps|undefined}){
export function ResourcePanel({api,assets,summary,editionOptions,versionPicker,sourceInfo,openPanel,onPanelChange}:{api:MediaApi;assets:Array<{id:string;title:string}>;summary:string;versionPicker?:ComponentChildren;sourceInfo?:ComponentChildren;openPanel?:'files'|'source'|null;onPanelChange?:(panel:'files'|'source'|null)=>void;editionOptions?:EditionDetailsProps|undefined}){
const [panel,setPanel]=useState<'files'|'versions'|'source'|null>(null),menu=useRef<HTMLDetailsElement>(null);
const files=[...new Map(assets.map(asset=>[asset.id,asset])).values()];
const manage=!!(editionOptions?.onUpdated||editionOptions?.onAssigned||editionOptions?.onRename);
useEffect(()=>{if(openPanel)setPanel(openPanel);},[openPanel]);
useEffect(()=>{const close=(event:PointerEvent)=>{if(menu.current&&!menu.current.contains(event.target as Node))menu.current.open=false;};document.addEventListener('pointerdown',close);return()=>document.removeEventListener('pointerdown',close);},[]);
function open(value:'files'|'versions'|'source'){if(menu.current){menu.current.open=false;menu.current.querySelector('summary')?.focus();}setPanel(value);}
function close(){setPanel(null);menu.current?.querySelector('summary')?.focus();}
return <section className="media-resource-panel" onClick={event=>{if(event.target instanceof HTMLDialogElement&&!editionOptions?.busy){const rect=event.target.getBoundingClientRect();if(event.clientX<rect.left||event.clientX>rect.right||event.clientY<rect.top||event.clientY>rect.bottom)close();}}}><header><h2>资源信息</h2>{(files.length>0||manage||versionPicker||sourceInfo)&&<details className="media-actions" ref={menu} onKeyDown={event=>{if(event.key==='Escape'){event.preventDefault();event.stopPropagation();menu.current!.open=false;menu.current!.querySelector('summary')?.focus();}}}><summary aria-label="资源信息操作" title="资源信息操作"><Ellipsis size={19} aria-hidden="true"/></summary><nav aria-label="资源信息操作">{files.length>0&&<button onClick={()=>open('files')}><FileText size={16} aria-hidden="true"/>资源信息</button>}{(manage||versionPicker)&&<button data-media-versions onClick={()=>open('versions')}><Layers size={16} aria-hidden="true"/>{manage?'版本管理':'播放版本'}</button>}{sourceInfo&&<button onClick={()=>open('source')}><FileText size={16} aria-hidden="true"/>来源</button>}</nav></details>}</header>
<p>{summary}</p>
{panel&&<Modal className="media-modal" title={panel==='files'?'资源信息':panel==='source'?'来源':manage?'版本管理':'播放版本'} busy={!!editionOptions?.busy} onClose={close}><div className="media-resource-dialog">{panel==='files'?files.map(asset=><ResourceInfo key={asset.id} api={api} assetId={asset.id} title={asset.title} expanded/>):panel==='source'?sourceInfo:<>{versionPicker}{manage&&editionOptions&&<EditionTools {...editionOptions} managementOnly/>}</>}</div></Modal>}
</section>;
function close(){setPanel(null);onPanelChange?.(null);menu.current?.querySelector('summary')?.focus();}
return <>{panel&&<Modal className="media-modal" title={panel==='files'?'资源信息':panel==='source'?'来源':manage?'版本管理':'播放版本'} busy={!!editionOptions?.busy} onClose={close}><div className="media-resource-dialog">{panel==='files'?files.map(asset=><ResourceInfo key={asset.id} api={api} assetId={asset.id} title={asset.title} expanded/>):panel==='source'?sourceInfo:<>{versionPicker}{manage&&editionOptions&&<EditionTools {...editionOptions} managementOnly/>}</>}</div></Modal>}<section className="media-resource-panel media-resource-trigger" onClick={event=>{if(event.target instanceof HTMLDialogElement&&!editionOptions?.busy){const rect=event.target.getBoundingClientRect();if(event.clientX<rect.left||event.clientX>rect.right||event.clientY<rect.top||event.clientY>rect.bottom)close();}}}><header><h2>资源信息</h2>{(files.length>0||manage||versionPicker||sourceInfo)&&<details className="media-actions" ref={menu} onKeyDown={event=>{if(event.key==='Escape'){event.preventDefault();event.stopPropagation();menu.current!.open=false;menu.current!.querySelector('summary')?.focus();}}}><summary aria-label="资源信息操作" title="资源信息操作"><Ellipsis size={19} aria-hidden="true"/></summary><nav aria-label="资源信息操作">{files.length>0&&<button onClick={()=>open('files')}><FileText size={16} aria-hidden="true"/>资源信息</button>}{(manage||versionPicker)&&<button data-media-versions onClick={()=>open('versions')}><Layers size={16} aria-hidden="true"/>{manage?'版本管理':'播放版本'}</button>}{sourceInfo&&<button onClick={()=>open('source')}><FileText size={16} aria-hidden="true"/>来源</button>}</nav></details>}</header><p>{summary}</p></section></> ;
}
+10 -9
View File
@@ -118,6 +118,7 @@ export class MediaScreen {
private historyTotal=0;
private activity:Activity[]=[];
private clearQueueIds:string[]|null=null;
private resourcePanelRequest:'files'|'source'|null=null;
private favorite=false;
private managing=false;
private managerTab:'libraries'|'tasks'='libraries';
@@ -460,19 +461,19 @@ export class MediaScreen {
}
private trackList(items:Item[]){return <div className="media-track-list" aria-label="音乐曲目">{items.map((item,index)=><div key={item.id} className="media-track-entry"><span className="media-track-number" aria-hidden="true">{String(this.offset+index+1).padStart(2,'0')}</span><button className="media-track-copy" disabled={this.busy} aria-label={'播放 '+item.title} aria-current={this.player.currentItemId===item.id?'true':undefined} onClick={()=>void this.run(()=>this.playTrack(item.id))}><strong title={item.title}>{item.title}</strong><small>{itemLabel(item)||'未知艺人'}</small></button><button className="media-track-detail" aria-label={'查看 '+item.title+' 详情'} title="曲目详情与版本" onClick={()=>this.openTrack(item.id)}><Ellipsis size={18} aria-hidden="true"/></button></div>)}</div>;}
private grid(items:Item[],compactSeasons=false){return <div className={compactSeasons?"media-grid media-season-picker":items.length&&items.every(item=>item.kind==='artist')?"media-grid media-people-grid":"media-grid"}>{items.map(item=><button key={item.id} className="media-tile" onClick={()=>this.navigate(this.channel,item.id)}>{this.cover(item)}<strong title={item.title}>{item.title}</strong><small>{itemLabel(item)}</small></button>)}</div>;}
private detailMenu(){const person=this.detail?.kind==='artist';return <details className="media-actions media-item-actions"><summary aria-label={person?'歌手操作':'作品操作'}><Ellipsis size={20} aria-hidden="true"/></summary><nav aria-label={person?'歌手操作':'作品操作'}><button disabled={this.busy} onClick={()=>void this.run(()=>this.toggleFavorite())}>{this.favorite?'取消收藏':person?'收藏歌手':'收藏作品'}</button>{!person&&<button onClick={event=>{const menu=event.currentTarget.closest('details');if(menu)menu.open=false;this.element.querySelector<HTMLDetailsElement>('[aria-label="资源信息操作"]')?.querySelector('summary')?.click();}}>资源信息与来源</button>}{this.admin&&<><button aria-label="管理元数据" onClick={()=>this.openMetadata('edit')}>{person?'编辑歌手资料':'编辑资料'}</button><button onClick={()=>this.openMetadata('match')}>{person?'匹配歌手资料':'匹配元数据'}</button></>}</nav></details>;}
private detailMenu(){const person=this.detail?.kind==='artist';return <details className="media-actions media-item-actions"><summary aria-label={person?'歌手操作':'作品操作'}><Ellipsis size={20} aria-hidden="true"/></summary><nav aria-label={person?'歌手操作':'作品操作'}><button disabled={this.busy} onClick={()=>void this.run(()=>this.toggleFavorite())}>{this.favorite?'取消收藏':person?'收藏歌手':'收藏作品'}</button>{!person&&<details className="media-menu-submenu"><summary>资源信息与来源</summary><nav><button onClick={event=>{event.preventDefault();this.resourcePanelRequest='files';this.draw();}}>资源信息</button><button onClick={event=>{event.preventDefault();this.resourcePanelRequest='source';this.draw();}}>资料来源</button></nav></details>}{this.admin&&<><button aria-label="管理元数据" onClick={()=>this.openMetadata('edit')}>{person?'编辑歌手资料':'编辑资料'}</button><button onClick={()=>this.openMetadata('match')}>{person?'匹配歌手资料':'匹配元数据'}</button></>}</nav></details>;}
private detailView(detail:Detail){
const edition=detail.editions.find(entry=>entry.id===this.selectedEditionId)||detail.editions[0];
const playable=edition?.parts.filter(part=>part.available)||[];
const plot=String(detail.overrides.plot??detail.metadata.plot??'').trim();
const favorite=<button className="media-detail-favorite" disabled={this.busy} aria-pressed={this.favorite} onClick={()=>void this.run(()=>this.toggleFavorite())}><Heart size={17} fill={this.favorite?'currentColor':'none'} aria-hidden="true"/><span className="media-visually-hidden">{this.favorite?'取消收藏':'收藏'}</span></button>;
const editionOptions:EditionDetailsProps|undefined=edition?{api:this.api,item:detail,edition,busy:this.busy,...(detail.editions.length>1?{onChooseVersion:()=>{this.element.querySelector<HTMLDetailsElement>('[aria-label="资源信息操作"]')?.querySelector('summary')?.click();}}:{}),onRefresh:()=>void this.run(async()=>{this.detail=await this.api.detail(detail.id,this.abort.signal);}),...(this.admin?{onUpdated:(updated:Detail)=>{this.detail=updated;this.draw();},onAssigned:(target:Detail)=>this.navigate(this.channel,target.id),onRename:(label:string)=>{edition.label=label;this.draw();}}:{}),onQueue:ids=>void this.run(()=>this.enqueue(ids)),onPlay:(parts,index)=>void this.run(()=>this.player.play(parts.map(part=>({part,title:detail.title+' · '+part.title,video:this.channel==='video'})),index))}:undefined;
const resourceAside=<aside className="media-detail-sidebar" hidden={detail.kind==='artist'}>
{detail.kind!=='artist'&&<ResourcePanel key={detail.id+'-'+(edition?.id??'album')} api={this.api} assets={edition?edition.parts.map(part=>({id:part.assetId,title:part.title})):detail.kind==='album'?this.albumAssets:[]} versionPicker={detail.editions.length>1&&<label className="media-edition-picker">版本<MediaSelect aria-label="播放版本" disabled={this.busy} value={edition?.id} onChange={event=>{this.selectedEditionId=event.currentTarget.value;if(this.location?.page)this.syncLocation(this.location.page,{...this.location.params,edition:this.selectedEditionId},this.detail?.id,true);this.draw();}}>{detail.editions.map(entry=><option key={entry.id} value={entry.id}>{entry.label}</option>)}</MediaSelect></label>} summary={edition?new Set(edition.parts.map(part=>part.assetId)).size+' 个文件 · '+edition.label:detail.children.length+(detail.kind==='album'?' 首曲目':detail.kind==='series'?' 季 · 资源见单集详情':' 项内容')} sourceInfo={<MetadataSources item={detail}/>} editionOptions={editionOptions}/>}
const resourceControl=<ResourcePanel key={detail.id+'-'+(edition?.id??'album')} api={this.api} assets={edition?edition.parts.map(part=>({id:part.assetId,title:part.title})):detail.kind==='album'?this.albumAssets:[]} versionPicker={detail.editions.length>1&&<label className="media-edition-picker">版本<MediaSelect aria-label="播放版本" disabled={this.busy} value={edition?.id} onChange={event=>{this.selectedEditionId=event.currentTarget.value;if(this.location?.page)this.syncLocation(this.location.page,{...this.location.params,edition:this.selectedEditionId},this.detail?.id,true);this.draw();}}>{detail.editions.map(entry=><option key={entry.id} value={entry.id}>{entry.label}</option>)}</MediaSelect></label>} summary={edition?new Set(edition.parts.map(part=>part.assetId)).size+' 个文件 · '+edition.label:detail.children.length+(detail.kind==='album'?' 首曲目':detail.kind==='series'?' 季 · 资源见单集详情':' 项内容')} sourceInfo={<MetadataSources item={detail}/>} openPanel={this.resourcePanelRequest} onPanelChange={panel=>{this.resourcePanelRequest=panel;this.draw();}} editionOptions={editionOptions}/>;
const resourceAside=<aside className="media-detail-sidebar media-resource-hidden" hidden={detail.kind==='artist'}>
{detail.kind==='album'&&plot&&<section className="media-detail-description"><h2>关于这张专辑</h2><p>{plot}</p></section>}
</aside>;
if(edition?.parts.length&&!playable.length)return <><MissingEdition title={detail.title} label={edition.label} busy={this.busy} onRefresh={editionOptions?.onRefresh} onChooseVersion={editionOptions?.onChooseVersion} {...(this.admin?{onManage:()=>this.goPage('settings/libraries')}:{})}/><div className="media-missing-resources">{resourceAside}</div></>;
return <article className={'media-detail-page'+(detail.kind==='artist'?' media-person-detail':'')}>
if(edition?.parts.length&&!playable.length)return <>{resourceControl}<MissingEdition title={detail.title} label={edition.label} busy={this.busy} onRefresh={editionOptions?.onRefresh} onChooseVersion={editionOptions?.onChooseVersion} {...(this.admin?{onManage:()=>this.goPage('settings/libraries')}:{})}/><div className="media-missing-resources">{resourceAside}</div></>;
return <>{resourceControl}<article className={'media-detail-page'+(detail.kind==='artist'?' media-person-detail':'')}>
<div className="media-hero">{this.cover(detail,true)}{detail.kind==='artist'?<div className="media-person-copy"><MediaDetailHeading item={detail} libraryName={this.allLibraries?.find(library=>library.id===detail.libraryId)?.name} edition={edition}/><span className="media-person-count">{detail.children.filter(item=>item.kind==='album').length} 张专辑</span></div>:<div className="media-detail-hero-copy"><MediaDetailHeading {...(detail.kind==='series'?{seasonCount:detail.children.length}:{})} {...(detail.kind==='album'?{trackCount:detail.children.length}:{})} item={detail} libraryName={this.allLibraries?.find(library=>library.id===detail.libraryId)?.name} edition={edition}/></div>}</div>
<div className="media-detail-actions">
{detail.kind==='series'&&<button className="media-primary" disabled={this.busy||!detail.children.length} onClick={()=>void this.run(async()=>{const result=await this.api.request<{episodes:SeasonEpisode[]}>('items/'+encodeURIComponent(detail.id)+'/series-playback');const queue=seasonQueue(result.episodes,0,{});if(queue.entries.length)await this.player.play(queue.entries);else if(result.episodes[0])this.navigate(this.channel,result.episodes[0].id);})}><Play size={17} aria-hidden="true"/>播放剧集</button>}
@@ -491,7 +492,7 @@ export class MediaScreen {
{!['album','series'].includes(detail.kind)&&detail.children.length>0&&<section className="media-detail-children"><h2>{detail.kind==='season'?'选集':detail.kind==='artist'?'全部专辑':'全部内容'}</h2>{detail.kind==='season'?<SeasonPlayback key={detail.id} api={this.api} id={detail.id} onPlay={entries=>this.player.play(entries)} onDetail={id=>this.navigate(this.channel,id)}/>:<MediaChildList key={detail.id} items={detail.children} label={detail.kind==='artist'?'专辑':'季'} renderItems={items=>this.grid(items)}/>}</section>}
{detail.kind==='artist'&&<ArtistTracks key={detail.id} api={this.api} artist={detail} onPlay={id=>this.playTrack(id)} onDetail={id=>this.openTrack(id)}/>}
</div>{detail.kind!=='album'&&resourceAside}</div>
</article>;
</article></>;
}
private metadataPage(hidden=false){return this.metadataView&&this.detail&&this.admin?<div key="metadata-page" className="media-metadata-host" hidden={hidden}><MediaMetadataPage key={this.detail.id} api={this.api} item={this.detail} initialView={this.metadataView} onViewChange={view=>this.openRoute(view==='edit'?'metadata':'match',{},this.detail?.id,true)} onUpdated={updated=>{this.detail=updated;this.draw();}} onBack={()=>this.closeMetadata()}/>{this.metadataView==='edit'&&<>{this.metadataEditionTools()}<ArtistInfo item={this.detail}/><VideoHierarchyEditor api={this.api} item={this.detail} onUpdated={updated=>{this.detail=updated;this.draw();}}/><MusicParentEditor api={this.api} item={this.detail} onUpdated={updated=>{this.detail=updated;this.draw();}}/></>}</div>:null;}
private metadataEditionTools(){
@@ -514,18 +515,18 @@ export class MediaScreen {
</>:<>
<nav className="media-task-tabs" aria-label="任务类型"><button aria-current={this.taskTab==='media'?'page':undefined} onClick={()=>{this.taskTab='media';this.draw();}}>媒体库扫描</button><button aria-current={this.taskTab==='ai'?'page':undefined} onClick={()=>{this.taskTab='ai';this.draw();}}>AI 扫描</button><button aria-current={this.taskTab==='scrape'?'page':undefined} onClick={()=>{this.taskTab='scrape';this.draw();}}>刮削</button></nav>
{this.taskTab==='media'?<>
<div className="media-manager-toolbar"><span>扫描所有媒体库并建立索引</span><button disabled={this.busy||!this.allLibraries.length||this.jobState!=='ready'} onClick={()=>void this.scanAll()}>扫描所有媒体库</button></div>{this.scanNotice&&<p className="media-manager-note" role="status">{this.scanNotice}</p>}
<div className="media-task-intro"><div><h2>媒体库扫描</h2><p>扫描所有媒体库并建立索引</p></div><button className="media-primary" disabled={this.busy||!this.allLibraries.length||this.jobState!=='ready'} onClick={()=>void this.scanAll()}>扫描所有媒体库</button></div>{this.scanNotice&&<p className="media-manager-note" role="status">{this.scanNotice}</p>}
{this.scanActionError&&<div className="media-error" role="alert"><p>{this.scanActionError}</p><button disabled={this.busy} onClick={()=>void this.run(async()=>{await this.loadJobs(this.jobLibraryId);this.scanActionError='';})}>重新核对任务</button></div>}
<div className="media-task-library"><label>媒体库<MediaSelect aria-label="扫描媒体库" value={this.jobLibraryId} disabled={this.busy||!this.allLibraries.length} onChange={event=>this.openManagerTasks(event.currentTarget.value)}><option value="">全部媒体库 · 最新任务</option>{this.allLibraries.map(l=><option key={l.id} value={l.id}>{l.name}</option>)}</MediaSelect></label><button disabled={this.busy||!this.jobLibraryId} onClick={()=>void this.scan(this.jobLibraryId)}><RefreshCw size={16} aria-hidden="true"/>扫描当前库</button><button disabled={this.busy} onClick={()=>void this.run(()=>this.loadJobs(this.jobLibraryId))} aria-label="刷新扫描任务" title="刷新扫描任务"><RefreshCw size={16} aria-hidden="true"/></button></div>
<h2 className="media-task-section-title">媒体库扫描历史{this.jobLibraryId?' · '+this.allLibraries.find(l=>l.id===this.jobLibraryId)?.name:''}</h2>
{this.jobState==='loading'&&!this.busy&&<FloatingNotice message="正在读取扫描任务…" busy/>}{this.jobState==='error'&&<div className="media-error" role="alert"><p>{this.jobError}</p><button disabled={this.busy} onClick={()=>void this.run(()=>this.loadJobs(this.jobLibraryId))}>重试读取扫描任务</button></div>}{this.jobState==='ready'&&this.jobs.length===0&&<p>暂无媒体库扫描历史。</p>}
<ScanJobs key={this.jobLibraryId} jobs={this.jobs} libraries={this.allLibraries} libraryName={this.allLibraries.find(l=>l.id===this.jobLibraryId)?.name||'媒体库'} busy={this.busy||this.jobState!=='ready'} onRetry={id=>void this.scan(id||this.jobLibraryId)} onCancel={id=>void this.run(async()=>{await this.api.request('jobs/'+id+'/cancel','POST');await this.loadJobs(this.jobLibraryId);})} onDelete={id=>void this.run(async()=>{await this.api.request('scan-jobs/'+id,'DELETE');await this.loadJobs(this.jobLibraryId);})}/>
</>:this.taskTab==='ai'?<>
<div className="media-manager-toolbar"><span>AI 识别媒体类型</span><button disabled={this.busy||!this.jobLibraryId} onClick={()=>void this.startAiScan(this.jobLibraryId)}>AI 扫描当前库</button><button disabled={this.busy||!this.allLibraries.length} onClick={()=>void this.startAiScan()}>AI 扫描所有媒体库</button></div>{this.scanNotice&&<p className="media-manager-note" role="status">{this.scanNotice}</p>}
<div className="media-task-intro"><div><h2>AI 扫描</h2><p>识别媒体类型并生成待处理结果</p></div><div className="media-task-actions"><button className="media-primary" disabled={this.busy||!this.jobLibraryId} onClick={()=>void this.startAiScan(this.jobLibraryId)}>扫描当前库</button><button disabled={this.busy||!this.allLibraries.length} onClick={()=>void this.startAiScan()}>扫描所有媒体库</button></div></div>{this.scanNotice&&<p className="media-manager-note" role="status">{this.scanNotice}</p>}
<div className="media-task-library"><label>媒体库<MediaSelect aria-label="AI 扫描媒体库" value={this.jobLibraryId} disabled={this.busy||!this.allLibraries.length} onChange={event=>{this.jobLibraryId=event.currentTarget.value;void this.loadAiJobs();}}><option value="">全部媒体库</option>{this.allLibraries.map(l=><option key={l.id} value={l.id}>{l.name}</option>)}</MediaSelect></label></div>
<h2 className="media-task-section-title">AI 扫描历史</h2><AiScanJobs api={this.api} jobs={this.aiJobs} libraries={this.allLibraries} busy={this.busy} onRefresh={()=>void this.loadAiJobs()} onDelete={id=>void this.run(async()=>{await this.api.deleteAiScanJob(id);await this.loadAiJobs();})}/>
</>:<>
<div className="media-manager-toolbar"><span>在线匹配媒体信息</span></div>
<div className="media-task-intro"><div><h2>刮削</h2><p>在线匹配媒体信息并复核结果</p></div></div>
<div className="media-manager-scraping"><ScrapeJobs api={this.api} libraries={this.allLibraries} navigate={this.navigate}/></div>
</>}
{this.taskTab!=='scrape'&&<p className="media-manager-note">目录不可访问时保留原有资料。扫描与在线匹配分开执行,匹配失败不影响本地播放。</p>}
+3
View File
@@ -519,3 +519,6 @@ body[data-media-theme] {--surface-muted:var(--media-theme-surface)}
.media-screen .media-folder-operation>button:hover{border-color:var(--media-accent)}
.media-scrape-manager>.media-task-create{margin-top:0}.media-scrape-manager>.media-task-history{margin-top:28px}
@media(max-width:520px){.media-screen .media-folder-operation{align-items:stretch;flex-direction:column}.media-folder-operation>button{align-self:flex-start}}
.media-resource-hidden{display:none!important}.media-menu-submenu{position:relative}.media-menu-submenu>summary{list-style:none;cursor:pointer}.media-menu-submenu>summary::-webkit-details-marker{display:none}.media-menu-submenu>nav{display:none;position:absolute;right:100%;top:-6px;min-width:150px;padding:6px;border:1px solid var(--border);border-radius:10px;background:var(--surface);box-shadow:0 8px 24px rgba(0,0,0,.12)}.media-menu-submenu[open]>nav{display:grid;gap:3px}.media-menu-submenu>nav button{text-align:left}.media-folder-actions{display:flex;justify-content:flex-end;margin:8px 0}.media-folder-actions>nav{display:grid;gap:4px}.media-task-intro{display:flex;align-items:center;justify-content:space-between;gap:20px;margin:4px 0 20px;padding:18px 20px;border:1px solid var(--border);border-radius:14px;background:var(--surface-muted,#f3f4ef)}.media-task-intro h2{margin:0;font-size:20px;color:var(--text)}.media-task-intro p{margin:6px 0 0;color:var(--muted);font-size:13px}.media-task-intro .media-task-actions{display:flex;flex-wrap:wrap;gap:8px}.media-task-intro button{min-height:40px}.media-screen .media-task-tabs{border-radius:10px;background:var(--surface-muted,#f3f4ef);padding:5px}.media-screen .media-task-tabs button[aria-current=page]{background:var(--media-accent);color:#fff;box-shadow:none}.media-screen .media-task-tabs button{font-weight:650}.media-screen .media-folder-actions>summary{padding:9px 14px;border:1px solid var(--border);border-radius:9px;background:var(--surface);cursor:pointer}.media-screen .media-folder-actions[open]>summary{border-color:var(--media-accent)}
@media(max-width:640px){.media-task-intro{align-items:stretch;flex-direction:column;gap:14px;margin-bottom:16px;padding:15px}.media-task-intro h2{font-size:18px}.media-task-intro .media-task-actions{display:grid;grid-template-columns:1fr}.media-task-intro .media-task-actions button,.media-task-intro>button{width:100%}.media-screen .media-task-tabs{display:grid;grid-template-columns:repeat(3,1fr);width:100%;box-sizing:border-box}.media-screen .media-task-tabs button{padding-inline:5px;font-size:13px}.media-menu-submenu>nav{right:0;top:100%}}