feat(files): add per-channel storage quota [CI]
This commit is contained in:
1 parent
441ed0e5f0
commit
a6f078d009
19 files changed
+172
-15
No files matched your search
@@ -24,10 +24,11 @@ export function ExplorerFileRow(props: Props) {
|
||||
onMouseEnter={(event) => props.onHover(id, event.currentTarget.getBoundingClientRect())} onMouseLeave={props.onLeave}
|
||||
draggable={props.enabled && available} onDragStart={(event) => props.onDrag(id, event)} onDragEnd={props.onDragEnd}>
|
||||
<td><input type="checkbox" aria-label={`选择文件 ${item.name}`} checked={props.checked} onChange={() => props.onToggle(id)} onClick={(event) => event.stopPropagation()} /></td>
|
||||
<td><button className={styles.name} type="button" onKeyDown={(event) => { if (event.key === 'Enter') { event.stopPropagation(); if (available) props.onOpen(id); }
|
||||
if (event.key === 'ContextMenu' || event.shiftKey && event.key === 'F10') { event.preventDefault(); const rect = event.currentTarget.getBoundingClientRect(); props.onMenu(id, rect.left, rect.bottom); } }}>
|
||||
<Icon name={item.state.kind === 'tampered' ? 'warn' : iconOf(item)} size={18} /><span>{item.name}</span>
|
||||
</button>{item.state.kind === 'receiving' ? <span className={styles.progress}>{formatTransferProgress(item.state)}</span> : null}
|
||||
<td><button className={styles.name} type="button" onKeyDown={(event) => { if (event.key === 'Enter') { event.stopPropagation(); if (available) props.onOpen(id); }
|
||||
if (event.key === 'ContextMenu' || event.shiftKey && event.key === 'F10') { event.preventDefault(); const rect = event.currentTarget.getBoundingClientRect(); props.onMenu(id, rect.left, rect.bottom); } }}>
|
||||
<Icon name={item.state.kind === 'tampered' ? 'warn' : iconOf(item)} size={18} /><span>{item.name}</span>
|
||||
</button>{item.author ? <span className={styles.uploader}>上传者:{item.author.displayName || item.author.username}</span> : null}
|
||||
{item.state.kind === 'receiving' ? <span className={styles.progress}>{formatTransferProgress(item.state)}</span> : null}
|
||||
{item.state.kind === 'tampered' ? <span className={styles.error}>内容校验失败</span> : null}</td>
|
||||
<td className={styles.numeric}>{formatSize(item.size)}</td><td className={styles.date}>{formatTime(item.ts)}</td>
|
||||
<td><Button variant="ghost" icon="more" label={`文件操作 ${item.name}`} onClick={(event) => { event.stopPropagation(); const rect = event.currentTarget.getBoundingClientRect(); props.onMenu(id, rect.right, rect.bottom); }} /></td>
|
||||
|
||||
@@ -16,7 +16,7 @@ export function ExplorerHover({ id, point }: { id: string; point: { x: number; y
|
||||
const preview = encrypted && !item.gated && item.state.kind === 'complete' && (item.preview === 'image' || item.preview === 'text');
|
||||
return createPortal(<aside className={styles.hover} role="tooltip" aria-label="悬浮文件预览" style={{ left: position.x, top: position.y }}>
|
||||
<div className={styles.hoverTitle}><Icon name={iconOf(item)} size={20} /><strong>{item.name}</strong></div>
|
||||
<p>{formatSize(item.size)} · {item.mime}</p><p>{formatTime(item.ts)} · {item.deviceId}</p>
|
||||
<p>{formatSize(item.size)} · {item.mime}</p><p>{formatTime(item.ts)} · 上传者 {item.author?.displayName ?? item.author?.username ?? item.deviceId} · {item.deviceId}</p>
|
||||
{preview ? <HoverContent id={id} image={item.preview === 'image'} /> : <p>{item.gated ? '受限文件:悬停不读取内容,点击预览后再解密。' : item.state.kind === 'tampered' ? '内容校验失败,已拒绝预览。' : '双击文件打开完整预览。'}</p>}
|
||||
</aside>, document.body);
|
||||
}
|
||||
|
||||
@@ -189,6 +189,16 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.uploader {
|
||||
display: block;
|
||||
margin-inline-start: 28px;
|
||||
overflow: hidden;
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.name span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -219,6 +229,44 @@
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.quota {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.quotaLabel,
|
||||
.quotaRemaining {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.quotaTrack {
|
||||
height: 4px;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
.quotaTrack span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.quotaTrack .quotaFull {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.quotaRemaining {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.edit input,
|
||||
.batch select {
|
||||
min-width: 0;
|
||||
|
||||
@@ -10,11 +10,14 @@ 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[];
|
||||
@@ -56,6 +59,8 @@ export function FileExplorer(props: Props) {
|
||||
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 });
|
||||
@@ -99,7 +104,16 @@ export function FileExplorer(props: Props) {
|
||||
}}><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>
|
||||
<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)}
|
||||
|
||||
@@ -22,12 +22,14 @@ import { DevicePanel } from './DevicePanel';
|
||||
import { ChannelSettingsSheet } from './ChannelSettingsSheet';
|
||||
import { UploadOptionsSheet } from './UploadOptionsSheet';
|
||||
import styles from './Workspace.module.css';
|
||||
import { useServerSettings } from '@/store/server';
|
||||
|
||||
const PreviewSheet = lazy(() => import('@/features/item/PreviewSheet'));
|
||||
const ScanSheet = lazy(() => import('@/features/scan/ScanSheet').then((m) => ({ default: m.ScanSheet })));
|
||||
|
||||
export function Workspace({ onCreateChannel }: { onCreateChannel: () => void }) {
|
||||
const desktop = useMediaQuery('(width >= 900px) and (height >= 500px)');
|
||||
const { info: serverInfo } = useServerSettings();
|
||||
const channel = useWorkspace((s) => s.channel);
|
||||
const threads = useWorkspace((s) => s.threads);
|
||||
const groups = useWorkspace((s) => s.groups);
|
||||
@@ -108,7 +110,9 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void })
|
||||
const chat = <ChatPane threadName={activeThread?.name ?? '选择频道'} ids={messages} enabled={enabled} onSend={(text) => sendText(text, activeThreadId)} onDelete={deleteItem} onOpenFile={setPreviewId} onFiles={requestFiles} />;
|
||||
const explorer = <>
|
||||
<FilterBar filter={filter} searchOpen={searchOpen} onChange={setFilter} />
|
||||
<FileExplorer key={activeThreadId} scope={`${channel?.id}:${activeThreadId}`} threadId={activeThreadId} threadName={activeThread?.name ?? '选择频道'} folders={activeFolders} folderId={activeFolderId} ids={files}
|
||||
<FileExplorer key={activeThreadId} scope={`${channel?.id}:${activeThreadId}`} threadId={activeThreadId} threadName={activeThread?.name ?? '选择频道'}
|
||||
fileBytes={activeThread?.fileBytes ?? 0} maxFileBytes={serverInfo?.limits.max_workspace_channel_bytes}
|
||||
folders={activeFolders} folderId={activeFolderId} ids={files}
|
||||
selectedId={files.includes(selectedId ?? '') ? selectedId : undefined} checked={checked} enabled={enabled} structureEnabled={Boolean(activeThreadId && channel?.canManageStructure)} onFolder={selectFolder} onSelect={setSelectedId} onOpen={setPreviewId} onToggle={toggle} onSelection={setChecked}
|
||||
onFiles={(incoming, target) => requestFiles(incoming, { folderId: target })}
|
||||
onCreateFolder={(name) => activeThreadId ? createFolder(activeThreadId, name, activeFolderId) : Promise.reject(new Error('请先选择频道'))}
|
||||
|
||||
Reference in new issue
Block a user