Redesign upload confirmation with visible conflict choices

This commit is contained in:
toorux committed 2026-10-09 19:18:08 +08:00
1 parent a72cf7b3c1
commit 782e6076a7
5 files changed
+61 -7

No files matched your search

+3 -1
View File
@@ -31,8 +31,10 @@ const modal=()=>document.querySelector('.modal');const click=(label:string,paren
const folder=document.querySelector<HTMLElement>('.file-table [data-file-drop-directory]')!;drop(folder,true);await until(modal);
if(modal()!.querySelector('code')?.textContent!==dest)throw Error('Folder target mismatch');
if(calls.some(p=>p.operation==='upload.start')&&lang==='en-US')throw Error('Upload before confirmation');
if(modal()!.querySelectorAll('input[type="radio"]').length!==3||modal()!.querySelector('select'))throw Error('Conflict choices must be visible');
const choices=modal()!.querySelectorAll<HTMLInputElement>('input[type="radio"]');if(!choices[0].checked)throw Error('Keep both must be default');choices[2].click();await wait();if(!modal()!.querySelector('[role="status"]'))throw Error('Missing overwrite warning');choices[0].click();await wait();
await wait(1400);console.log('FRAMELY_PREVIEW_INSTALL_UPLOAD_CONFIRM_'+(lang==='en-US'?'EN':'ZH'));await wait(200);
click(lang==='en-US'?'Confirm':'确认',modal()!);await until(()=>!modal());
click(lang==='en-US'?'Start upload':'开始上传',modal()!);await until(()=>!modal());
if(document.querySelector('.file-upload-popover'))throw Error('Queue opened without a click');
document.querySelector<HTMLButtonElement>('.file-upload-toggle')!.click();await until(()=>document.querySelector('.file-upload-task.uploading'));
if(!document.querySelector('.file-upload-batch-count')?.textContent?.includes('2'))throw Error('Missing total file count');
+8 -1
View File
@@ -1047,6 +1047,13 @@
"Upload destination moved or changed": "Upload destination moved or changed",
"Upload file conflicts with a directory or special file": "Upload file conflicts with a directory or special file",
"Upload directory conflicts with a file or symbolic link": "Upload directory conflicts with a file or symbolic link",
"{0} 项进行中 · 共 {1} 项": "{0} active · {1} total"
"{0} 项进行中 · 共 {1} 项": "{0} active · {1} total",
"上传到": "Upload to",
"{0} 个文件": "{0} files",
"{0} 个文件夹": "{0} folders",
"自动重命名上传的文件,保留原文件。": "Rename incoming files and keep the originals.",
"不上传同名文件,保留原文件。": "Leave matching files unchanged without uploading them.",
"用上传的文件替换原文件。": "Replace existing files with the incoming files.",
"开始上传": "Start upload"
}
}
+8 -1
View File
@@ -1047,6 +1047,13 @@
"Upload destination moved or changed": "上传目标已被移动或替换。",
"Upload file conflicts with a directory or special file": "上传文件与目录或特殊文件同名。",
"Upload directory conflicts with a file or symbolic link": "上传文件夹与文件或符号链接同名。",
"{0} 项进行中 · 共 {1} 项": "{0} 项进行中 · 共 {1} 项"
"{0} 项进行中 · 共 {1} 项": "{0} 项进行中 · 共 {1} 项",
"上传到": "上传到",
"{0} 个文件": "{0} 个文件",
"{0} 个文件夹": "{0} 个文件夹",
"自动重命名上传的文件,保留原文件。": "自动重命名上传的文件,保留原文件。",
"不上传同名文件,保留原文件。": "不上传同名文件,保留原文件。",
"用上传的文件替换原文件。": "用上传的文件替换原文件。",
"开始上传": "开始上传"
}
}
+27
View File
@@ -305,3 +305,30 @@
@media(max-width:767px) and (prefers-reduced-motion:no-preference){.manager .file-manager .file-sidebar{transition:transform .18s ease-out,visibility .18s}}
.file-mobile-select-label{display:none}
@media(max-width:767px){.manager .file-table th.file-check{position:relative;height:44px}.manager .file-page-check{display:flex;align-items:center;margin:0}.manager .file-select-mode .file-mobile-select-label{display:block;position:absolute;left:42px;top:50%;transform:translateY(-50%);white-space:nowrap;font-size:13px;color:var(--file-muted)}}
/* Upload review: destination first, then a single mutually exclusive choice. */
.file-manager .modal.file-upload-confirm{width:min(540px,calc(100% - 32px));left:50%;right:auto;transform:translate(-50%,-50%)}
.file-upload-confirm h2{margin-bottom:24px}
.file-upload-destination{display:flex;align-items:flex-start;gap:14px}
.file-upload-destination>svg{flex:none;margin-top:4px;color:#c6c6ca}
.file-upload-destination>div{min-width:0}
.file-upload-destination span{display:block;font-size:14px;color:#bcbcc2;margin-bottom:6px}
.file-upload-review .file-upload-destination code{display:block;padding:0;background:none;border-radius:0;font-size:18px;line-height:1.5;color:#f1f1f3;white-space:normal;overflow-wrap:anywhere}
.file-upload-summary{display:flex;flex-wrap:wrap;gap:8px 18px;margin:18px 0 26px;padding-bottom:22px;border-bottom:1px solid #ffffff16;font-size:14px;color:#c6c6cc}
.file-upload-summary strong{margin-left:auto;font-weight:500;color:#ededf0}
.file-upload-policies{border:0;padding:0;margin:0;min-width:0}
.file-upload-policies legend{padding:0;margin-bottom:12px;font-size:15px;font-weight:600;color:#ededf0}
.file-manager .modal .file-upload-policies label{display:flex;flex-direction:row;align-items:center;gap:14px;padding:14px 16px;margin:0 0 8px;border:1px solid #ffffff18;border-radius:10px;background:#ffffff03;cursor:pointer}
.file-upload-policies label:hover{background:#ffffff08}
.file-upload-policies label.selected{border-color:#ffffff50;background:#ffffff0d;box-shadow:inset 0 1px 0 #ffffff09}
.file-manager .modal .file-upload-policies input[type=radio]{appearance:auto;flex:none;width:18px;height:18px;min-height:0;margin:0;padding:0;accent-color:#d9d9de}
.file-upload-policies label:focus-within{outline:2px solid #b9d6e9;outline-offset:2px}
.file-upload-policies strong{display:block;font-size:15px;font-weight:600}
.file-upload-policies small{display:block;font-size:13px;line-height:1.5;color:#bcbcc2;margin-top:4px}
.file-upload-review .file-upload-note{font-size:13px;line-height:1.6;color:#bcbcc2;margin:14px 0 0}
.file-upload-review .file-upload-warning{font-size:13px;line-height:1.6;color:#eed4ab;margin:12px 0 0}
.file-upload-confirm footer{margin-top:24px}
.file-upload-confirm footer button{white-space:nowrap}
@media(max-width:767px){.file-upload-confirm h2{margin-bottom:20px}.file-upload-review .file-upload-destination code{font-size:16px}.file-upload-policies label{min-height:70px}.file-upload-summary{margin-bottom:20px}}
.file-manager:has(.modal) .file-upload-queue{visibility:hidden}
+15 -4
View File
@@ -200,11 +200,22 @@ export function FileManager({active=true}:{active?:boolean}){
{active&&menu&&<div ref={menuElement} className="file-context-menu" role="menu" aria-label={t('文件操作')} style={{left:menu.x,top:menu.y}} onContextMenu={event=>event.preventDefault()} onClick={event=>{if((event.target as HTMLElement).closest('button:not(:disabled)'))setMenu(null);}} onKeyDown={event=>{const buttons=Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('button:not(:disabled)'));if(['ArrowDown','ArrowUp','Home','End'].includes(event.key)){event.preventDefault();const index=buttons.indexOf(document.activeElement as HTMLButtonElement),next=event.key==='Home'?0:event.key==='End'?buttons.length-1:(index+(event.key==='ArrowDown'?1:-1)+buttons.length)%buttons.length;buttons[next]?.focus();}if(event.key==='Escape')setMenu(null);}}>
{menu.blank?<><button role="menuitem" disabled={busy} onClick={()=>openDialog({title:t('新建文件夹'),kind:'folder'})}><IconFolderPlus size={18}/>{t('新建文件夹')}</button><button role="menuitem" disabled={busy} onClick={()=>openDialog({title:t('新建文件'),kind:'create'})}><IconPlus size={18}/>{t('新建文件')}</button><button role="menuitem" disabled={!clipboard||busy} onClick={()=>openDialog({title:t('粘贴文件'),kind:'paste'})}><IconClipboard size={18}/>{t('粘贴')}</button><div role="separator"/><button role="menuitem" disabled={busy} onClick={()=>fileInput.current?.click()}><IconUpload size={18}/>{t('上传文件')}</button><button role="menuitem" disabled={busy} onClick={()=>void chooseUploadDirectory()}><IconFolderPlus size={18}/>{t('上传文件夹')}</button><button role="menuitem" onClick={refresh}><IconRefresh size={18}/>{t('刷新')}</button></>:<>{actions}<div role="separator"/><button role="menuitem" disabled={!selectedCount||busy} onClick={()=>void getDownload()}><IconDownload size={18}/>{t('下载文件')}</button>{picked?.directory&&selectedCount===1&&<button role="menuitem" disabled={busy} onClick={()=>openDialog({title:t('添加到快速访问'),kind:'bookmark',path:picked.path,value:picked.name})}><IconStar size={18}/>{t('添加到快速访问')}</button>}</>}
</div>}
{active&&dialog&&<div className="modal" role="dialog" aria-modal="true" aria-label={dialog.title}><h2>{dialog.title}</h2>{error&&<p className="error">{error}</p>}{dialog.kind==='properties'&&dialog.entry&&<div className="file-details"><p>{dialog.entry.path}</p><p>{bytes(dialog.entry.size)} · UID {dialog.entry.uid} / GID {dialog.entry.gid}</p>{dialog.entry.linkTarget&&<p>{dialog.entry.linkTarget}</p>}</div>}{['create','folder','rename','bookmark','bookmark.rename','compress','extract','properties'].includes(dialog.kind)&&<label>{t(dialog.kind==='properties'?'权限(八进制)':dialog.kind==='extract'?'目标目录':'名称')}<input autoFocus value={value} onChange={e=>setValue(e.target.value)}/></label>}
{dialog.kind==='upload'&&<div className="file-upload-review"><p>{t('上传目标')}</p><code>{dialog.path}</code><p>{t('{0} 个文件 · {1} 个文件夹 · {2}',{0:dialog.entries?.filter(e=>!e.directory).length??0,1:dialog.entries?.filter(e=>e.directory).length??0,2:bytes(dialog.entries?.reduce((n,e)=>n+(e.file?.size??0),0)??0)})}</p><p>{t('同名文件夹合并,现有其他文件保留。')}</p>{dialog.entries?.some(e=>e.error)&&<p className="error">{t('部分路径存在冲突或不可访问,这些任务会单独显示失败。')}</p>}</div>}
{active&&dialog&&<div className={dialog.kind==='upload'?'modal file-upload-confirm':'modal'} role="dialog" aria-modal="true" aria-label={dialog.title}><h2>{dialog.title}</h2>{error&&<p className="error">{error}</p>}{dialog.kind==='properties'&&dialog.entry&&<div className="file-details"><p>{dialog.entry.path}</p><p>{bytes(dialog.entry.size)} · UID {dialog.entry.uid} / GID {dialog.entry.gid}</p>{dialog.entry.linkTarget&&<p>{dialog.entry.linkTarget}</p>}</div>}{['create','folder','rename','bookmark','bookmark.rename','compress','extract','properties'].includes(dialog.kind)&&<label>{t(dialog.kind==='properties'?'权限(八进制)':dialog.kind==='extract'?'目标目录':'名称')}<input autoFocus value={value} onChange={e=>setValue(e.target.value)}/></label>}
{dialog.kind==='upload'&&<div className="file-upload-review">
<div className="file-upload-destination"><IconFolder size={24}/><div><span>{t('上传到')}</span><code>{dialog.path}</code></div></div>
<div className="file-upload-summary"><span>{t('{0} 个文件',{0:dialog.entries?.filter(e=>!e.directory).length??0})}</span><span>{t('{0} 个文件夹',{0:dialog.entries?.filter(e=>e.directory).length??0})}</span><strong>{bytes(dialog.entries?.reduce((n,e)=>n+(e.file?.size??0),0)??0)}</strong></div>
<fieldset className="file-upload-policies"><legend>{t('遇到同名文件时')}</legend>{[
{id:'keep',name:'保留两份',description:'自动重命名上传的文件,保留原文件。'},
{id:'skip',name:'跳过',description:'不上传同名文件,保留原文件。'},
{id:'overwrite',name:'覆盖',description:'用上传的文件替换原文件。'}
].map(option=><label key={option.id} className={policy===option.id?'selected':''}><input type="radio" name="upload-conflict" value={option.id} checked={policy===option.id} onChange={()=>setPolicy(option.id)}/><span><strong>{t(option.name)}</strong><small>{t(option.description)}</small></span></label>)}</fieldset>
<p className="file-upload-note">{t('同名文件夹合并,现有其他文件保留。')}</p>
{policy==='overwrite'&&<p className="file-upload-warning" role="status">{t('确认后会替换同名文件,不删除目录中的其他文件。')}</p>}
{dialog.entries?.some(e=>e.error)&&<p className="error">{t('部分路径存在冲突或不可访问,这些任务会单独显示失败。')}</p>}
</div>}
{dialog.kind==='delete'&&<><p>{t('请选择删除方式。')}</p><Select label={t('删除方式')} value={permanent?'permanent':'trash'} onChange={v=>setPermanent(v==='permanent')} options={[{value:'trash',label:t('移至回收站(30 天)')},{value:'permanent',label:t('永久删除')}]}/>{permanent&&<p>{t('永久删除无法撤销。')}</p>}</>}
{['paste','upload','compress','extract','trash.restore'].includes(dialog.kind)&&<><p>{t('遇到同名文件时')}</p><Select label={t('同名处理')} value={policy} onChange={setPolicy} options={[{value:'keep',label:t('保留两份')},{value:'skip',label:t('跳过')},...(dialog.kind==='trash.restore'?[]:[{value:'overwrite',label:t('覆盖')}])]}/>{policy==='overwrite'&&<p>{t(dialog.kind==='upload'?'确认后会替换同名文件,不删除目录中的其他文件。':'确认后会覆盖同名文件或整个同名文件夹。')}</p>}</>}{dialog.kind==='compress'&&<p>{t('支持 .zip、.tar 和 .tar.gz。')}</p>}{dialog.kind==='trash.purge'&&<p>{t('永久删除无法撤销。')}</p>}{dialog.kind==='editor.discard'&&<p>{t('关闭将丢弃未保存的修改。')}</p>}{dialog.kind==='editor.overwrite'&&<p>{t('文件已被外部修改。可重新加载,或确认覆盖。')}</p>}
<footer>{dialog.kind==='editor.overwrite'&&<button onClick={()=>void perform({operation:'read',path:editor?.path}).then(v=>{setEditor(e=>e?{...e,...v}:null);setModified(false);setDialog(null);}).catch(()=>{})}>{t('重新加载')}</button>}<button disabled={busy} onClick={()=>setDialog(null)}>{t('取消')}</button><button disabled={busy} className={permanent||dialog.kind==='trash.purge'?'danger':'primary'} onClick={()=>void submit()}>{t('确认')}</button></footer></div>}
{['paste','compress','extract','trash.restore'].includes(dialog.kind)&&<><p>{t('遇到同名文件时')}</p><Select label={t('同名处理')} value={policy} onChange={setPolicy} options={[{value:'keep',label:t('保留两份')},{value:'skip',label:t('跳过')},...(dialog.kind==='trash.restore'?[]:[{value:'overwrite',label:t('覆盖')}])]}/>{policy==='overwrite'&&<p>{t(dialog.kind==='upload'?'确认后会替换同名文件,不删除目录中的其他文件。':'确认后会覆盖同名文件或整个同名文件夹。')}</p>}</>}{dialog.kind==='compress'&&<p>{t('支持 .zip、.tar 和 .tar.gz。')}</p>}{dialog.kind==='trash.purge'&&<p>{t('永久删除无法撤销。')}</p>}{dialog.kind==='editor.discard'&&<p>{t('关闭将丢弃未保存的修改。')}</p>}{dialog.kind==='editor.overwrite'&&<p>{t('文件已被外部修改。可重新加载,或确认覆盖。')}</p>}
<footer>{dialog.kind==='editor.overwrite'&&<button onClick={()=>void perform({operation:'read',path:editor?.path}).then(v=>{setEditor(e=>e?{...e,...v}:null);setModified(false);setDialog(null);}).catch(()=>{})}>{t('重新加载')}</button>}<button disabled={busy} onClick={()=>setDialog(null)}>{t('取消')}</button><button disabled={busy} className={permanent||dialog.kind==='trash.purge'?'danger':'primary'} onClick={()=>void submit()}>{t(dialog.kind==='upload'?'开始上传':'确认')}</button></footer></div>}
{active&&preview&&<FileMediaPreview entries={preview.entries} initial={preview.initial} onClose={()=>setPreview(null)} onDownload={path=>void getDownload([path])}/>}
{active&&editor&&!dialog&&<div className="modal file-editor-modal" role="dialog" aria-modal="true"><h2>{editor.path.split('/').pop()}{modified?' *':''}</h2>{error&&<p className="error">{error}</p>}<textarea aria-label={t('文本内容')} className="file-editor" value={editor.text} onChange={e=>{setEditor(v=>v?{...v,text:e.target.value}:null);setModified(true);}}/><footer><button disabled={busy} onClick={()=>void saveText()}>{t('保存')}</button><button onClick={()=>{if(!modified)setEditor(null);else openDialog({title:t('未保存的修改'),kind:'editor.discard'});}}>{t('关闭')}</button></footer></div>}
</section>;