fix: preserve upload recovery and harden speech indexing
This commit is contained in:
+36
-10
@@ -48,7 +48,8 @@ type UploadTask = {
|
||||
bytes_received: number; size_bytes: number; message?: string; error?: string;
|
||||
relative_path: string; chunk_size: number; total_chunks: number; received_chunks: number[];
|
||||
stage?:string;stage_label?:string;stages?:Array<{key:string;state:"pending"|"active"|"completed"|"failed"|"cancelled";progress:number;label:string}>;failure_stage?:"transfer"|"commit"|"catalog";
|
||||
can_cancel?:boolean;can_retry?:boolean;
|
||||
can_cancel?:boolean;can_retry?:boolean;can_discard?:boolean;requires_reupload?:boolean;
|
||||
recovery_state?:"receiving"|"available"|"catalog"|"missing"|"released";recovery_mode?:"original"|"encrypted"|"catalog";
|
||||
content_sha256?:string;deduplicated?:boolean;retry_count?:number;next_retry_at?:string;
|
||||
transferred_bytes?:number;resume_mode?:"restart"|"offset"|"deduplicated";
|
||||
phase?:"receiving"|"queued"|"encrypting"|"transferring"|"external_copying"|"external_verifying"|"indexing"|"completed"|"failed"|"cancelled";
|
||||
@@ -57,6 +58,7 @@ type UploadTask = {
|
||||
collection_id?:string;collection_parent_id?:string;tag_ids?:string[];origin?:"browser"|"webdav";
|
||||
};
|
||||
type UploadPage = {items:UploadTask[];status_items:UploadTask[];active_count:number;failed_count:number;page:number;page_size:number;total:number;pages:number};
|
||||
type UploadFilters = {status:"all"|"active"|"failed"|"completed"|"cancelled";source_id:string;query:string};
|
||||
type DownloadTask = {
|
||||
id:string;kind:"url"|"magnet"|"torrent";display_name:string;source_id:string;relative_path:string;
|
||||
status:string;progress:number;total_bytes:number;completed_bytes:number;download_speed:number;
|
||||
@@ -957,8 +959,9 @@ function LibraryPage({ sources, videos, uploads, uploadCounts, downloads, transf
|
||||
}
|
||||
|
||||
function UploadsPage({ uploads, uploadPage, downloads, onDownloads, sources, reload, onPage, onUpload }: { uploads: UploadTask[];uploadPage:UploadPage;downloads:DownloadTask[];onDownloads:(items:DownloadTask[])=>void;sources:Source[];reload: () => void;onPage:(page:number)=>Promise<unknown>;onUpload:()=>void }) {
|
||||
uploads=uploads.map(task=>({...task,can_retry:task.can_retry??task.status==="failed",can_cancel:task.can_cancel??!["completed","cancelled","indexing"].includes(task.status)}));
|
||||
const [tab,setTab]=useState<"uploads"|"downloads">("uploads");const [paging,setPaging]=useState(false);const [runtime,setRuntime]=useState<{available:boolean;running:boolean}|null>(null);const [runtimeLoad,setRuntimeLoad]=useState<LoadStatus>(idleLoad());const [showAdd,setShowAdd]=useState(false);const [url,setUrl]=useState("");const [sourceId,setSourceId]=useState("");const [relativePath,setRelativePath]=useState("");const [busy,setBusy]=useState(false);const [error,setError]=useState("");const torrentInput=useRef<HTMLInputElement>(null);
|
||||
const [filteredPage,setFilteredPage]=useState<UploadPage|null>(null);const visiblePage=filteredPage||uploadPage;
|
||||
const visibleUploads=(filteredPage?.items||uploads).map(task=>({...task,can_retry:task.can_retry??task.status==="failed",can_cancel:task.can_cancel??!["completed","cancelled","indexing"].includes(task.status)}));
|
||||
const [tab,setTab]=useState<"uploads"|"downloads">("uploads");const [paging,setPaging]=useState(false);const [runtime,setRuntime]=useState<{available:boolean;running:boolean}|null>(null);const [runtimeLoad,setRuntimeLoad]=useState<LoadStatus>(idleLoad());const [showAdd,setShowAdd]=useState(false);const [url,setUrl]=useState("");const [sourceId,setSourceId]=useState("");const [relativePath,setRelativePath]=useState("");const [busy,setBusy]=useState(false);const [error,setError]=useState("");const [filters,setFilters]=useState<UploadFilters>({status:"all",source_id:"",query:""});const [queryDraft,setQueryDraft]=useState("");const [selected,setSelected]=useState<Set<string>>(new Set());const [taskBusy,setTaskBusy]=useState<Set<string>>(new Set());const torrentInput=useRef<HTMLInputElement>(null);
|
||||
const writable=useMemo(()=>sources.filter(source=>source.config.writable||source.config.driver==="alist"||source.config.storage_backend==="openlist_native"),[sources]);
|
||||
const loadDownloads=useCallback(async()=>{setRuntimeLoad(current=>({phase:current.phase==="ready"?"ready":"loading",refreshing:current.phase==="ready"}));setError("");try{const [items,state]=await Promise.all([api<DownloadTask[]>("/downloads"),api<{available:boolean;running:boolean}>("/downloads/runtime")]);onDownloads(items);setRuntime(state);setRuntimeLoad({phase:"ready",refreshing:false})}catch(reason){const message=(reason as Error).message;setError(message);setRuntimeLoad(current=>current.phase==="ready"?{phase:"ready",refreshing:false,error:message}:{phase:"error",refreshing:false,error:message})}},[onDownloads]);
|
||||
useEffect(()=>{if(tab!=="downloads")return;void loadDownloads();const refresh=()=>{if(!document.hidden)void loadDownloads()};const timer=window.setInterval(refresh,2000);document.addEventListener("visibilitychange",refresh);return()=>{window.clearInterval(timer);document.removeEventListener("visibilitychange",refresh)}},[tab,loadDownloads]);
|
||||
@@ -968,16 +971,36 @@ function UploadsPage({ uploads, uploadPage, downloads, onDownloads, sources, rel
|
||||
async function createTorrent(file?:File){if(!file||!sourceId)return;setBusy(true);setError("");try{const form=new FormData();form.append("file",file);form.append("source_id",sourceId);form.append("relative_path",relativePath);await api("/downloads/torrent",{method:"POST",body:form});setShowAdd(false);await loadDownloads()}catch(reason){setError((reason as Error).message)}finally{setBusy(false);if(torrentInput.current)torrentInput.current.value=""}}
|
||||
async function action(task:DownloadTask,name:"pause"|"resume"|"retry"){try{await api(`/downloads/${task.id}/${name}`,{method:"POST",body:"{}"});await loadDownloads()}catch(reason){setError((reason as Error).message)}}
|
||||
async function removeDownload(task:DownloadTask){if(!await appConfirm(`移除“${task.display_name}”`,"未完成的暂存文件会一并删除;已写入媒体库的文件不会受影响。",true))return;await api(`/downloads/${task.id}`,{method:"DELETE"});await loadDownloads()}
|
||||
async function changePage(next:number){if(paging||next<1||next>uploadPage.pages||next===uploadPage.page)return;setPaging(true);try{await onPage(next);document.querySelector(".transfer-center")?.scrollIntoView({block:"start",behavior:window.matchMedia("(prefers-reduced-motion: reduce)").matches?"auto":"smooth"})}finally{setPaging(false)}}
|
||||
const attentionCount=uploadPage.active_count+uploadPage.failed_count;
|
||||
const historyCount=Math.max(0,uploadPage.total-attentionCount);
|
||||
async function fetchFiltered(page:number,next:UploadFilters){const params=new URLSearchParams({page:String(page),page_size:"10",status:next.status});if(next.source_id)params.set("source_id",next.source_id);if(next.query)params.set("query",next.query);setFilteredPage(await api<UploadPage>(`/uploads?${params}`))}
|
||||
async function refreshUploadView(){if(filteredPage)await fetchFiltered(filteredPage.page,filters);else reload()}
|
||||
async function applyFilters(next:UploadFilters){setFilters(next);setSelected(new Set());setPaging(true);try{await fetchFiltered(1,next)}catch(reason){setError((reason as Error).message)}finally{setPaging(false)}}
|
||||
async function taskAction(task:UploadTask,action:"cancel"|"retry"|"discard"){
|
||||
if(taskBusy.has(task.id))return;
|
||||
const title=task.title?.trim()||cleanMediaTitle(task.filename);
|
||||
if(action==="cancel"&&!await appConfirm(`取消“${title}”`,"任务会停止,但恢复副本会保留,之后仍可重试。",true))return;
|
||||
if(action==="discard"&&!await appConfirm(`删除“${title}”及恢复副本`,"这会永久删除上传暂存和任务记录,无法撤销。",true))return;
|
||||
setTaskBusy(current=>new Set(current).add(task.id));setError("");
|
||||
try{await api(action==="discard"?`/uploads/${task.id}/recovery`:action==="retry"?`/uploads/${task.id}/retry`:`/uploads/${task.id}`,{method:action==="retry"?"POST":"DELETE",body:action==="retry"?"{}":undefined});await refreshUploadView()}catch(reason){setError((reason as Error).message)}finally{setTaskBusy(current=>{const next=new Set(current);next.delete(task.id);return next})}
|
||||
}
|
||||
async function bulkAction(action:"cancel"|"retry"){
|
||||
const ids=[...selected].filter(id=>{const task=visibleUploads.find(item=>item.id===id);return action==="cancel"?task?.can_cancel:task?.can_retry});if(!ids.length)return;
|
||||
if(action==="cancel"&&!await appConfirm(`取消 ${ids.length} 个上传任务`,"任务会停止,所有可恢复副本都会保留。",true))return;
|
||||
setBusy(true);setError("");try{const response=await api<{results:Array<{id:string;ok:boolean;error?:string}>}>("/uploads/bulk-actions",{method:"POST",body:JSON.stringify({upload_ids:ids,action})});const failures=response.results.filter(item=>!item.ok);if(failures.length)setError(`${response.results.length-failures.length} 条成功,${failures.length} 条失败:${failures[0].error||"状态不允许"}`);setSelected(new Set());await refreshUploadView()}catch(reason){setError((reason as Error).message)}finally{setBusy(false)}
|
||||
}
|
||||
async function changePage(next:number){if(paging||next<1||next>visiblePage.pages||next===visiblePage.page)return;setPaging(true);try{if(filteredPage)await fetchFiltered(next,filters);else await onPage(next);document.querySelector(".transfer-center")?.scrollIntoView({block:"start",behavior:window.matchMedia("(prefers-reduced-motion: reduce)").matches?"auto":"smooth"})}finally{setPaging(false)}}
|
||||
const attentionCount=visiblePage.active_count+visiblePage.failed_count;
|
||||
const historyCount=Math.max(0,visiblePage.total-attentionCount);
|
||||
const uploadControls=<><form className="upload-task-filters" onSubmit={event=>{event.preventDefault();void applyFilters({...filters,query:queryDraft.trim()})}}><label><span>状态</span><select value={filters.status} onChange={event=>void applyFilters({...filters,status:event.target.value as UploadFilters["status"]})}><option value="all">全部任务</option><option value="active">进行中</option><option value="failed">失败</option><option value="completed">已完成</option><option value="cancelled">已取消</option></select></label><label><span>媒体库</span><select value={filters.source_id} onChange={event=>void applyFilters({...filters,source_id:event.target.value})}><option value="">全部媒体库</option>{sources.map(source=><option key={source.id} value={source.id}>{source.name}</option>)}</select></label><label className="upload-task-search"><span>搜索任务</span><input value={queryDraft} onChange={event=>setQueryDraft(event.target.value)} placeholder="文件名或标题"/><button className="secondary" disabled={paging}><Search/>搜索</button></label></form>{selected.size>0&&<div className="upload-selection-bar" role="status"><strong>已选择 {selected.size} 条</strong><span/><button className="secondary" disabled={busy||![...selected].some(id=>uploads.find(item=>item.id===id)?.can_retry)} onClick={()=>void bulkAction("retry")}><RefreshCw/>批量重试</button><button className="danger-text" disabled={busy||![...selected].some(id=>uploads.find(item=>item.id===id)?.can_cancel)} onClick={()=>void bulkAction("cancel")}>批量取消</button><button className="icon-button" aria-label="清除选择" onClick={()=>setSelected(new Set())}><X/></button></div>}</>;
|
||||
function renderUploadTask(task:UploadTask){
|
||||
const target=sources.find(source=>source.id===task.source_id);const completed=task.status==="completed";const autoRetry=task.status==="queued"&&!!task.retry_count;const transferred=task.transferred_bytes?`${formatSize(task.transferred_bytes)} / ${formatSize(task.size_bytes)}`:"";const title=task.title?.trim()||cleanMediaTitle(task.filename);const stages=task.stages||[];
|
||||
return <article className={completed?"completed-history":""} key={task.id}><span className={`transfer-icon ${task.status}`}>{completed?<CheckCircle2/>:task.status==="failed"?<AlertCircle/>:<Upload/>}</span><div className="transfer-main"><div><strong>{title}</strong><span>{task.filename} · {task.stage_label||({receiving:"正在接收",queued:"等待上传",transferring:"正在转存",indexing:"正在加入媒体库",completed:"已完成",failed:"失败",cancelled:"已取消"} as Record<string,string>)[task.status]||"上传处理中"} · {formatSize(task.size_bytes)}</span></div><p className="transfer-target">目标:{target?.name||"媒体库"} · {target?sourceLabel(target):"媒体来源"}</p>{completed?<p className="transfer-completed"><CheckCircle2/>已加入媒体库,AI 识别已排队</p>:<div className="transfer-stages">{stages.map(step=><span className={step.state==="completed"?"done":step.state} key={step.key}><i><b style={{transform:`scaleX(${Math.max(0,Math.min(1,step.progress||0))})`}}/></i><small>{step.label}</small><em>{step.state==="completed"?"完成":step.state==="active"?`${Math.round((step.progress||0)*100)}%`:step.state==="failed"?"失败":step.state==="cancelled"?"已取消":"等待"}</em></span>)}</div>}<div className="transfer-meta">{task.deduplicated&&<b>SHA-256 安全秒传</b>}{task.resume_mode==="offset"&&<b>断点续传</b>}{autoRetry&&<b>自动重试中</b>}{transferred&&<span>{transferred}</span>}</div>{!completed&&<p>{task.error||task.message||"等待更新"}</p>}</div><div className="transfer-actions">{task.can_retry&&<button onClick={()=>api(`/uploads/${task.id}/retry`,{method:"POST",body:"{}"}).then(reload)}><RefreshCw/>{task.failure_stage==="catalog"?"重新入库":"重试"}</button>}{task.can_cancel&&<button className="danger-text" onClick={()=>api(`/uploads/${task.id}`,{method:"DELETE"}).then(reload)}>取消</button>}</div></article>;
|
||||
const checked=selected.has(task.id);const operating=taskBusy.has(task.id);
|
||||
// The ternary mutates a local Set copy and returns the boolean mutation result unused.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
return <article className={`${completed?"completed-history ":""}${checked?"selected":""}`} key={task.id}><label className="transfer-select"><input type="checkbox" checked={checked} onChange={()=>setSelected(current=>{const next=new Set(current);next.has(task.id)?next.delete(task.id):next.add(task.id);return next})}/><span className="sr-only">选择 {title}</span></label><span className={`transfer-icon ${task.status}`}>{completed?<CheckCircle2/>:task.status==="failed"?<AlertCircle/>:<Upload/>}</span><div className="transfer-main"><div><strong>{title}</strong><span>{task.filename} · {task.stage_label||({receiving:"正在接收",queued:"等待上传",transferring:"正在转存",indexing:"正在加入媒体库",completed:"已完成",failed:"失败",cancelled:"已取消"} as Record<string,string>)[task.status]||"上传处理中"} · {formatSize(task.size_bytes)}</span></div><p className="transfer-target">目标:{target?.name||"媒体库"} · {target?sourceLabel(target):"媒体来源"}</p>{completed?<p className="transfer-completed"><CheckCircle2/>已加入媒体库,AI 识别已排队</p>:<div className="transfer-stages">{stages.map(step=><span className={step.state==="completed"?"done":step.state} key={step.key}><i><b style={{transform:`scaleX(${Math.max(0,Math.min(1,step.progress||0))})`}}/></i><small>{step.label}</small><em>{step.state==="completed"?"完成":step.state==="active"?`${Math.round((step.progress||0)*100)}%`:step.state==="failed"?"失败":step.state==="cancelled"?"已取消":"等待"}</em></span>)}</div>}<div className="transfer-meta">{task.deduplicated&&<b>SHA-256 安全秒传</b>}{task.resume_mode==="offset"&&<b>断点续传</b>}{task.recovery_state==="available"&&<b>恢复副本已保留</b>}{task.requires_reupload&&<b className="failed">源文件已丢失,需重新上传</b>}{autoRetry&&<b>自动重试中</b>}{transferred&&<span>{transferred}</span>}</div>{!completed&&<p>{task.error||task.message||"等待更新"}</p>}</div><div className="transfer-actions">{task.can_retry&&<button disabled={operating} onClick={()=>void taskAction(task,"retry")}><RefreshCw className={operating?"spin":""}/>{task.failure_stage==="catalog"?"重新入库":"重试"}</button>}{task.can_cancel&&<button disabled={operating} className="danger-text" onClick={()=>void taskAction(task,"cancel")}>取消</button>}{task.can_discard&&<button disabled={operating} className="danger-text" onClick={()=>void taskAction(task,"discard")}><Trash2/>删除任务与副本</button>}</div></article>;
|
||||
}
|
||||
return <div className="page transfer-center"><header className="page-heading"><button className="icon-button page-back" aria-label="返回" onClick={()=>window.history.back()}><ArrowLeft/></button><div><p className="eyebrow">传输中心</p><h1>传输中心</h1><p>统一查看浏览器上传、aria2 后台下载、媒体库写入和资料解析。</p></div><div className="page-heading-actions"><button className="secondary" onClick={()=>tab==="uploads"?reload():loadDownloads()}><RefreshCw/>刷新</button>{tab==="uploads"?<button className="primary continue-upload" onClick={onUpload}><Upload/>继续上传</button>:<button className="primary" onClick={()=>setShowAdd(true)}><Download/>新建下载</button>}</div></header>
|
||||
<nav className="segment-nav transfer-tabs"><button className={tab==="uploads"?"active":""} onClick={()=>setTab("uploads")}>上传记录 {attentionCount>0&&<b aria-label={`${attentionCount} 条需要关注`}>{attentionCount}</b>}</button><button className={tab==="downloads"?"active":""} onClick={()=>setTab("downloads")}>后台下载 <b>{downloads.length}</b></button></nav>
|
||||
{error&&<div className="error banner"><AlertCircle/>{error}</div>}
|
||||
{error&&<div className="error banner"><AlertCircle/>{error}</div>}{tab==="uploads"&&uploadControls}
|
||||
{tab==="uploads"?<><div className="transfer-summary" role="status" aria-label={`上传记录:进行中 ${uploadPage.active_count},失败 ${uploadPage.failed_count},历史 ${historyCount}`}><span className="active"><Upload/>进行中 <b>{uploadPage.active_count}</b></span><span className={uploadPage.failed_count?"failed":""}><AlertCircle/>失败 <b>{uploadPage.failed_count}</b></span><span><Clock3/>历史 <b>{historyCount}</b></span></div><div className="transfer-list">{uploads.map(renderUploadTask)}{!uploads.length&&<div className="empty"><Upload/><h3>还没有上传记录</h3><p>点击“继续上传”,把视频导入本地或网盘媒体库。</p></div>}</div>{uploadPage.pages>1&&<nav className="transfer-pagination" aria-label="上传任务分页"><button className="secondary" disabled={paging||uploadPage.page<=1} onClick={()=>void changePage(uploadPage.page-1)}>上一页</button><span>第 {uploadPage.page} / {uploadPage.pages} 页<small>共 {uploadPage.total} 条</small></span><button className="secondary" disabled={paging||uploadPage.page>=uploadPage.pages} onClick={()=>void changePage(uploadPage.page+1)}>下一页</button></nav>}</>:<div className="transfer-list download-list">{runtimeLoad.phase==="loading"||runtimeLoad.phase==="idle"?<AsyncNotice label="正在检查 aria2 与后台下载…"/>:runtimeLoad.phase==="error"?<div className="empty compact async-error"><AlertCircle/><h3>后台下载状态加载失败</h3><p>{runtimeLoad.error}</p><button className="secondary" onClick={()=>void loadDownloads()}><RefreshCw/>重试</button></div>:<>{runtime&&!runtime.available&&<div className="warning-callout"><AlertCircle/><span><strong>系统 aria2c 不可用</strong>请先在飞牛中安装 aria2;ImageFind 不会私自下载另一套运行时。</span></div>}{downloads.map(task=>{const target=sources.find(source=>source.id===task.source_id);return <article key={task.id}><span className={`transfer-icon ${task.status}`}>{task.status==="completed"?<CheckCircle2/>:task.status==="failed"?<AlertCircle/>:<Download/>}</span><div className="transfer-main"><div><strong>{task.display_name}</strong><span>{labels[task.status]||task.status} · {Math.round(task.progress*100)}%</span></div><p className="transfer-target">目标:{target?.name||"媒体库"}/{task.relative_path} · {task.download_speed?`${formatSize(task.download_speed)}/s`:formatSize(task.total_bytes)}</p><i className="download-progress"><b style={{transform:`scaleX(${Math.max(0,Math.min(1,task.progress))})`}}/></i><p>{task.error||`${formatSize(task.completed_bytes)} / ${formatSize(task.total_bytes)}`}</p></div><div className="transfer-actions">{task.status==="downloading"&&<button onClick={()=>action(task,"pause")}><Pause/>暂停</button>}{task.status==="paused"&&<button onClick={()=>action(task,"resume")}><Play/>继续</button>}{task.status==="failed"&&<button onClick={()=>action(task,"retry")}><RefreshCw/>重试</button>}<button className="danger-text" onClick={()=>removeDownload(task)}><Trash2/>移除</button></div></article>})}{!downloads.length&&<div className="empty"><Download/><h3>还没有后台下载</h3><p>支持 HTTP/HTTPS/FTP、磁力链接和 Torrent。</p></div>}</>}</div>}
|
||||
{showAdd&&<div className="modal-backdrop"><form className="dialog download-dialog" onSubmit={createDownload}><header><div><p className="eyebrow">后台下载</p><h2>新建后台下载</h2></div><button type="button" className="icon-button" onClick={()=>setShowAdd(false)}><X/></button></header><label>下载地址<input autoFocus value={url} onChange={event=>setUrl(event.target.value)} placeholder="https://…、ftp://… 或 magnet:?…"/></label><label>目标媒体库<select value={sourceId} onChange={event=>setSourceId(event.target.value)}>{writable.map(source=><option key={source.id} value={source.id}>{source.name}</option>)}</select></label><label>保存目录<input value={relativePath} onChange={event=>setRelativePath(event.target.value)} placeholder="可留空,Torrent 会保留安全目录结构"/></label><button type="button" className="secondary wide" onClick={()=>torrentInput.current?.click()}><Folder/>选择 .torrent 文件</button><input ref={torrentInput} hidden type="file" accept=".torrent,application/x-bittorrent" onChange={event=>createTorrent(event.target.files?.[0])}/>{runtimeLoad.phase!=="ready"?<AsyncNotice compact label="正在检查 aria2…"/>:!runtime?.available&&<div className="error"><AlertCircle/>系统未安装 aria2c</div>}<footer><button type="button" className="secondary" onClick={()=>setShowAdd(false)}>取消</button><button className="primary" disabled={busy||runtimeLoad.phase!=="ready"||!runtime?.available||!url.trim()||!sourceId}>{busy?<RefreshCw className="spin"/>:<Download/>}开始下载</button></footer></form></div>}
|
||||
</div>;
|
||||
@@ -1460,11 +1483,14 @@ export default function App() {
|
||||
const loadSources=useCallback((quiet=false)=>runResource("sources",()=>api<Source[]>("/sources"),setSources,quiet),[runResource]);
|
||||
const loadHome=useCallback((quiet=false)=>runResource("home",()=>api<HomeFeed>("/home?item_limit=10&tag_limit=3"),setHomeFeed,quiet),[runResource]);
|
||||
const loadVideos=useCallback((quiet=false)=>runResource("videos",()=>api<Video[]>("/videos?limit=200"),setVideos,quiet),[runResource]);
|
||||
const loadUploads=useCallback((quiet=false,requestedPage?:number)=>{
|
||||
const uploadFiltersRef=useRef<UploadFilters>({status:"all",source_id:"",query:""});
|
||||
const loadUploads=useCallback((quiet=false,requestedPage?:number,nextFilters?:UploadFilters)=>{
|
||||
const target=requestedPage??uploadPageNumber.current;
|
||||
if(nextFilters)uploadFiltersRef.current=nextFilters;const filters=uploadFiltersRef.current;
|
||||
const requestSequence=++uploadRequestSequence.current;
|
||||
if(requestedPage!==undefined)uploadPageNumber.current=target;
|
||||
return runResource("uploads",()=>api<UploadPage|UploadTask[]>(`/uploads?page=${target}&page_size=10`),value=>{
|
||||
const params=new URLSearchParams({page:String(target),page_size:"10",status:filters.status});if(filters.source_id)params.set("source_id",filters.source_id);if(filters.query)params.set("query",filters.query);
|
||||
return runResource("uploads",()=>api<UploadPage|UploadTask[]>(`/uploads?${params}`),value=>{
|
||||
if(requestSequence!==uploadRequestSequence.current)return;
|
||||
const normalized=normalizeUploadPage(value,target);uploadPageNumber.current=normalized.page;setUploadPage(normalized);setUploads(normalized.status_items)
|
||||
},quiet);
|
||||
|
||||
@@ -615,6 +615,12 @@ html[data-theme="dark"] :is(.upload-batch-metadata,.upload-tag-groups fieldset,.
|
||||
@media(max-width:720px){.upload-batch-metadata{padding:11px}.upload-batch-metadata>header{align-items:flex-start}.upload-batch-metadata>header button{min-height:44px}.upload-tag-groups button,.collection-tag-rules fieldset button{min-height:44px}.collection-hero>div:last-child{grid-template-columns:1fr 1fr}.collection-tag-rules>div{grid-template-columns:1fr}.collection-tag-rules>footer{align-items:stretch;flex-direction:column}.collection-tag-rules>footer .primary{min-height:44px}.webdav-connection{grid-template-columns:1fr}.webdav-connection>button{min-height:44px}.player-marker-drawer{top:8px;right:8px;bottom:52px;width:calc(100% - 16px);max-height:none;padding:9px}.player-marker-drawer>header>button,.player-marker-list article>button:not(.marker-jump){width:40px;height:40px}.player-marker-add{min-height:40px}.player-marker-list article{grid-template-columns:minmax(0,1fr) 40px 40px}.marker-jump{min-height:40px}.pull-refresh{top:0}}
|
||||
@media(prefers-reduced-motion:reduce){.player-marker-dot:after,.pull-refresh{transition:none}.pull-refresh svg{transform:none}}
|
||||
|
||||
/* Upload task management and mobile-safe controls */
|
||||
.upload-task-filters{display:grid;grid-template-columns:minmax(150px,190px) minmax(180px,240px) minmax(260px,1fr);gap:10px;align-items:end;margin-bottom:14px;padding:13px;border:1px solid var(--line);border-radius:14px;background:var(--surface)}.upload-task-filters label{min-width:0;display:grid;gap:6px;color:var(--muted);font-size:11px;font-weight:700}.upload-task-filters select,.upload-task-filters input{width:100%;height:42px;padding:0 11px;color:var(--text);border:1px solid var(--line);border-radius:9px;outline:0;background:var(--surface);font:inherit}.upload-task-filters :is(select,input):focus{border-color:var(--brand);box-shadow:0 0 0 3px color-mix(in srgb,var(--brand) 14%,transparent)}.upload-task-search{grid-template-columns:minmax(0,1fr) auto}.upload-task-search>span{grid-column:1/-1}.upload-task-search button{height:42px}.upload-selection-bar{position:sticky;z-index:12;top:calc(var(--top) + 8px);display:grid;grid-template-columns:auto 1fr auto auto 40px;gap:8px;align-items:center;margin-bottom:12px;padding:9px 10px;border:1px solid #bed0f1;border-radius:12px;background:color-mix(in srgb,var(--surface) 94%,var(--brand-soft));box-shadow:0 8px 24px #24324c14}.upload-selection-bar strong{font-size:12px}.upload-selection-bar button{min-height:40px}.upload-task-list>article{grid-template-columns:24px 42px minmax(0,1fr) auto}.upload-task-list>article.selected{border-color:#93b3ec;background:color-mix(in srgb,var(--surface) 92%,var(--brand-soft))}.transfer-select{width:24px;height:44px;display:grid;place-items:center;align-self:start}.transfer-select input{width:18px;height:18px;accent-color:var(--brand)}.transfer-meta b.failed{color:var(--red);background:#fff0f1}.transfer-actions{flex-wrap:wrap;justify-content:flex-end}.transfer-actions button:disabled{cursor:wait;opacity:.55}
|
||||
html[data-theme="dark"] .upload-task-filters{border-color:var(--line);background:var(--surface)}html[data-theme="dark"] .upload-selection-bar{border-color:#30476f;background:#1d2b47}html[data-theme="dark"] .transfer-meta b.failed{color:#ff9aa4;background:#381d22}
|
||||
@media(max-width:720px){.mobile-page-uploads{--upload-action-height:52px;--upload-action-gap:14px;padding-bottom:calc(var(--upload-action-height) + var(--upload-action-gap) + 62px + env(safe-area-inset-bottom))}.upload-task-filters{grid-template-columns:1fr 1fr;padding:11px}.upload-task-search{grid-column:1/-1}.upload-task-filters select,.upload-task-filters input{min-height:44px;font-size:16px}.upload-task-search button{min-width:76px;height:44px}.upload-selection-bar{top:8px;grid-template-columns:1fr 44px;margin-right:-2px;margin-left:-2px}.upload-selection-bar>span{display:none}.upload-selection-bar>button:not(.icon-button){min-height:44px}.upload-selection-bar .secondary,.upload-selection-bar .danger-text{grid-row:2}.upload-selection-bar .secondary{grid-column:1}.upload-selection-bar .danger-text{grid-column:2}.upload-selection-bar .icon-button{grid-row:1;grid-column:2}.upload-task-list>article{grid-template-columns:24px 40px minmax(0,1fr)}.upload-task-list .transfer-main{grid-column:3}.upload-task-list .transfer-actions{grid-column:2/4;justify-content:stretch}.upload-task-list .transfer-actions button{flex:1;min-width:min(100%,130px)}.transfer-pagination{position:relative;z-index:4;margin-bottom:calc(var(--upload-action-height) + var(--upload-action-gap));padding:8px}.mobile-page-uploads .continue-upload{bottom:calc(62px + max(12px,env(safe-area-inset-bottom)))} }
|
||||
@media(max-width:360px){.upload-task-filters{grid-template-columns:1fr}.upload-task-search{grid-column:auto}.transfer-pagination{gap:6px}.transfer-pagination button{min-width:70px;padding-inline:8px}}
|
||||
|
||||
/* 0.3.40 mobile refresh, native WebDAV and player interaction polish */
|
||||
.pull-refresh svg{transform:rotate(var(--pull-rotation))}
|
||||
.webdav-fields label{display:grid;gap:6px;color:var(--muted);font-size:11px;font-weight:700}.webdav-fields input,.webdav-fields select{width:100%;height:44px;padding:0 36px 0 11px;color:var(--text);border:1px solid var(--line);border-radius:10px;outline:0;background:var(--surface);font:inherit}.webdav-fields input{padding-right:11px}.webdav-fields input:focus,.webdav-fields select:focus{border-color:var(--brand);box-shadow:0 0 0 3px color-mix(in srgb,var(--brand) 14%,transparent)}.webdav-connection>span:nth-of-type(2){grid-column:auto}.webdav-token-create{align-self:end}.webdav-token-once{grid-column:1/-1;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px;align-items:center;padding:10px;color:#176447;border:1px solid #bfe5d4;border-radius:9px;background:#edfaf4}.webdav-token-once>strong{grid-column:1/-1;font-size:10px}.webdav-token-once>code{min-width:0;overflow-wrap:anywhere;font-size:10px}.webdav-token-once>button{min-height:38px}.webdav-connection p b{color:var(--text)}.preference-item.disabled{opacity:.62}.preference-switch:disabled{cursor:not-allowed}.job-row-actions{display:flex;align-items:center;gap:7px}
|
||||
|
||||
Reference in New Issue
Block a user