Files
WebDrop/web/src/features/channel/FileExplorer.tsx
T
justme a6f078d009
ci/woodpecker/pr/web-build Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
feat(files): add per-channel storage quota [CI]
2026-10-14 20:42:08 +08:00

123 lines
11 KiB
TypeScript

import { useRef, useState, type FormEvent } from 'react';
import type { FolderInfo } from '@/core-bridge/types';
import { useWorkspace } from '@/store/workspace';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import { ExplorerFileRow } from './ExplorerFileRow';
import { ExplorerFolderRow } from './ExplorerFolderRow';
import { ExplorerHover } from './ExplorerHover';
import { ExplorerMoveDialog } from './ExplorerMoveDialog';
import { useExplorerInteractions } from './useExplorerInteractions';
import { ContextMenu } from '@/ui/ContextMenu';
import styles from './FileExplorer.module.css';
import { formatSize } from '@/lib/format';
interface Props {
scope: string;
threadId?: string;
threadName: string;
fileBytes: number;
maxFileBytes?: number;
folders: FolderInfo[];
folderId?: string;
ids: string[];
selectedId?: string;
checked: ReadonlySet<string>;
enabled: boolean;
structureEnabled: boolean;
onFolder: (id?: string) => void;
onSelect: (id?: string) => void;
onOpen: (id: string) => void;
onToggle: (id: string) => void;
onSelection: (ids: ReadonlySet<string>) => void;
onFiles: (files: File[], folderId?: string) => void;
onCreateFolder: (name: string) => Promise<void>;
onRenameFolder: (id: string, name: string) => Promise<void>;
onDownloadItems: (ids: string[]) => Promise<void>;
onDeleteItems: (ids: string[]) => Promise<void>;
onMoveItems: (ids: string[], folderId?: string) => Promise<void>;
}
export function FileExplorer(props: Props) {
const input = useRef<HTMLInputElement>(null);
const [sort, setSort] = useState<'name' | 'size' | 'date'>('name');
const [edit, setEdit] = useState<string>();
const [name, setName] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [selectedFolder, setSelectedFolder] = useState<string>();
const byId = useWorkspace.getState().byId;
const ids = props.ids.slice().sort((a, b) => {
const first = byId[a]; const second = byId[b];
if (first?.kind !== 'file' || second?.kind !== 'file') return 0;
return sort === 'size' ? second.size - first.size : sort === 'date' ? second.ts - first.ts : first.name.localeCompare(second.name, 'zh-CN');
});
const path: FolderInfo[] = [];
const seen = new Set<string>();
let current = props.folders.find((folder) => folder.id === props.folderId);
while (current && !seen.has(current.id)) {
seen.add(current.id); path.unshift(current); current = props.folders.find((folder) => folder.id === current?.parentId);
}
const children = props.folders.filter((folder) => folder.parentId === props.folderId).sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
const maxFileBytes = props.maxFileBytes;
const usedRatio = maxFileBytes && maxFileBytes > 0 ? Math.min(1, props.fileBytes / maxFileBytes) : 0;
function beginFolder(id?: string) { const folder = props.folders.find((row) => row.id === id); setEdit(id ?? 'create'); setName(folder?.name ?? ''); setError(''); }
const interaction = useExplorerInteractions({ ...props, ids, onDownload: props.onDownloadItems, onDelete: props.onDeleteItems, onMove: props.onMoveItems,
onRenameFolder: (id) => beginFolder(id), onCreateFolder: () => beginFolder(), onUpload: () => input.current?.click(), onFolder: navigateFolder });
function navigateFolder(id?: string) { interaction.dismiss(); setSelectedFolder(undefined); props.onFolder(id); }
async function submit(event: FormEvent) {
event.preventDefault(); if (!edit || busy || !name.trim()) return;
setBusy(true); setError('');
try { if (edit === 'create') await props.onCreateFolder(name); else await props.onRenameFolder(edit, name); setEdit(undefined); }
catch (cause) { setError(cause instanceof Error ? cause.message : '目录操作失败'); }
finally { setBusy(false); }
}
return <section className={styles.explorer} aria-label="频道文件管理器" tabIndex={0} onKeyDown={interaction.keyDown}>
<header className={styles.heading}><strong>频道文件</strong><span>{props.threadName}</span></header>
<div className={styles.toolbar}>
<input type="file" multiple hidden ref={input} onChange={(event) => { if (event.target.files?.length) props.onFiles([...event.target.files], props.folderId); event.target.value = ''; }} />
<Button icon="share" disabled={!props.enabled} onClick={() => input.current?.click()}>上传文件</Button>
<Button variant="ghost" icon="folder" disabled={!props.structureEnabled || busy} onClick={() => beginFolder()}>新建文件夹</Button>
<Button variant="ghost" icon="eye" disabled={!props.selectedId} onClick={() => { if (props.selectedId) props.onOpen(props.selectedId); }}>预览</Button>
</div>
<nav className={styles.breadcrumb} aria-label="文件夹路径"><Button variant="ghost" icon="chevronLeft" label="返回上级文件夹" disabled={!props.folderId} onClick={() => navigateFolder(path[path.length - 1]?.parentId)} />
<button type="button" data-drop-folder="root" className={interaction.dropFolder === '__root' ? styles.dropTarget : ''} {...interaction.dropHandlers()} onClick={() => navigateFolder()}><Icon name="folder" size={16} />根目录</button>
{path.map((folder) => <span key={folder.id}><Icon name="chevronRight" size={12} /><button type="button" className={interaction.dropFolder === folder.id ? styles.dropTarget : ''} {...interaction.dropHandlers(folder.id)} onClick={() => navigateFolder(folder.id)}>{folder.name}</button></span>)}
</nav>
{edit ? <form className={styles.edit} onSubmit={(event) => { void submit(event); }}><input autoFocus maxLength={80} aria-label="文件夹名称" value={name} onChange={(event) => setName(event.target.value)} />
<Button type="submit" disabled={busy || !name.trim()}>{edit === 'create' ? '创建' : '保存'}</Button><Button variant="ghost" onClick={() => setEdit(undefined)}>取消</Button></form> : null}
<div className={`${styles.scroll} ${interaction.dropFolder === (props.folderId ?? '__root') ? styles.dropTarget : ''}`} {...interaction.dropHandlers(props.folderId)} onContextMenu={interaction.blankMenu}
onClick={(event) => { if (!(event.target as HTMLElement).closest('tr,button,input')) { props.onSelection(new Set()); props.onSelect(undefined); setSelectedFolder(undefined); event.currentTarget.focus(); } }} tabIndex={-1}>
<table className={styles.table}><thead><tr><th aria-label="选择" /><th><button type="button" onClick={() => setSort('name')}>名称{sort === 'name' ? ' ↑' : ''}</button></th>
<th><button type="button" onClick={() => setSort('size')}>大小{sort === 'size' ? ' ↓' : ''}</button></th><th className={styles.date}><button type="button" onClick={() => setSort('date')}>时间{sort === 'date' ? ' ↓' : ''}</button></th><th aria-label="操作" /></tr></thead>
<tbody>{children.map((folder) => <ExplorerFolderRow key={folder.id} folder={folder} selected={folder.id === selectedFolder} enabled={props.structureEnabled}
dropping={interaction.dropFolder === folder.id} drop={interaction.dropHandlers(folder.id)} onOpen={() => navigateFolder(folder.id)}
onSelect={() => { interaction.dismiss(); setSelectedFolder(folder.id); props.onSelection(new Set()); props.onSelect(undefined); }} onRename={() => beginFolder(folder.id)}
onMenu={(x, y) => { setSelectedFolder(folder.id); props.onSelection(new Set()); props.onSelect(undefined); interaction.showFolderMenu(folder.id, folder.name, x, y); }} />)}
{ids.map((id) => <ExplorerFileRow key={id} id={id} selected={id === props.selectedId} checked={props.checked.has(id)} enabled={props.enabled}
onSelect={(id, event) => { setSelectedFolder(undefined); interaction.select(id, event); }} onOpen={props.onOpen} onToggle={props.onToggle} onMenu={(id, x, y) => { setSelectedFolder(undefined); interaction.showFileMenu(id, x, y); }}
onHover={interaction.hoverFile} onLeave={interaction.leave} onDrag={interaction.drag} onDragEnd={interaction.dismiss} />)}</tbody>
</table>{ids.length === 0 && children.length === 0 ? <div className={styles.empty}><Icon name="folder" size={28} /><p>此文件夹还没有文件</p><span>上传文件或将文件拖到窗口中。</span></div> : null}</div>
{props.checked.size > 0 ? <div className={styles.batch}><span>已选 {props.checked.size} 项</span><Button variant="ghost" icon="download" onClick={() => { void props.onDownloadItems([...props.checked]); }}>下载所选</Button>
{props.enabled ? <select aria-label="移动所选文件到文件夹" value="__choose" onChange={(event) => {
const target = event.target.value; if (target !== '__choose') void props.onMoveItems([...props.checked], target === '__root' ? undefined : target).catch((cause: unknown) => setError(cause instanceof Error ? cause.message : '移动失败'));
}}><option value="__choose">移动到…</option><option value="__root">根目录</option>{props.folders.map((folder) => <option value={folder.id} key={folder.id}>{folder.name}</option>)}</select> : null}
</div> : null}
{error || interaction.error ? <p className={styles.error} role="alert">{error || interaction.error}</p> : null}
<footer className={styles.status}>{children.length} 个文件夹 · {ids.length} 个文件<span>双击打开 · 右键操作 · 拖动移动</span></footer>
<div className={styles.quota} role="group" aria-label="当前频道文件空间">
<div className={styles.quotaLabel}><span>频道文件空间</span><span>{formatSize(props.fileBytes)} / {maxFileBytes ? formatSize(maxFileBytes) : '读取中'}</span></div>
<div className={styles.quotaTrack} role="progressbar" aria-label="文件空间使用量" aria-valuemin={0}
aria-valuemax={maxFileBytes ?? 0} aria-valuenow={Math.min(props.fileBytes, maxFileBytes ?? 0)}>
<span className={props.fileBytes >= (maxFileBytes ?? Number.POSITIVE_INFINITY) ? styles.quotaFull : undefined}
style={{ width: `${usedRatio * 100}%` }} />
</div>
{maxFileBytes ? <span className={styles.quotaRemaining}>剩余 {formatSize(Math.max(0, maxFileBytes - props.fileBytes))}</span> : null}
</div>
{interaction.menu ? <ContextMenu point={interaction.menu} actions={interaction.actions} onClose={interaction.dismiss} /> : null}
{interaction.hover && ids.includes(interaction.hover.id) && !interaction.menu ? <ExplorerHover key={interaction.hover.id} {...interaction.hover} /> : null}
{interaction.moveIds ? <ExplorerMoveDialog folders={props.folders} count={interaction.moveIds.length} currentFolderId={props.folderId} onClose={() => interaction.setMoveIds(undefined)}
onMove={(folderId) => props.onMoveItems(interaction.moveIds!, folderId)} /> : null}
</section>;
}