import { useRef, useState, type FormEvent, type ReactNode } from 'react'; import type { FileServiceStatus } from './useFileDomain'; import type { FolderInfo } from '@/domain/types'; import { useWorkspace } from '@/store/space/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/text/format'; interface Props { scope: string; threadId?: string; threadName: string; /** 文件服务的状态:不可用时在头部下面说明 */ fileService?: FileServiceStatus; /** 过滤条(类型筛选、搜索),放在工具栏下面 */ filterBar?: ReactNode; fileBytes: number; maxFileBytes?: number; folders: FolderInfo[]; folderId?: string; ids: string[]; selectedId?: string; checked: ReadonlySet; enabled: boolean; structureEnabled: boolean; onFolder: (id?: string) => void; onSelect: (id?: string) => void; onOpen: (id: string) => void; onToggle: (id: string) => void; onSelection: (ids: ReadonlySet) => void; onFiles: (files: File[], folderId?: string) => void; onCreateFolder: (name: string) => Promise; onRenameFolder: (id: string, name: string) => Promise; onDownloadItems: (ids: string[]) => Promise; onDeleteItems: (ids: string[]) => Promise; onMoveItems: (ids: string[], folderId?: string) => Promise; } export function FileExplorer(props: Props) { const input = useRef(null); const [sort, setSort] = useState<'name' | 'size' | 'date'>('name'); const [edit, setEdit] = useState(); const [name, setName] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState(''); const [selectedFolder, setSelectedFolder] = useState(); 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(); 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
{props.threadName} 频道文件 · {children.length} 个文件夹 · {ids.length} 个文件
{formatSize(props.fileBytes)} / {maxFileBytes ? formatSize(maxFileBytes) : '…'}
= (maxFileBytes ?? Number.POSITIVE_INFINITY) ? styles.meterFull : undefined} style={{ width: `${usedRatio * 100}%` }} />
{props.fileService === 'disabled' ?

服务端没有开启文件服务,暂时不能上传和下载文件。聊天不受影响。

: null} {props.fileService === 'down' ?

文件服务暂时连不上(可能还在部署或所在机器离线)。聊天不受影响;上传会排队,恢复后继续。

: null} { if (event.target.files?.length) props.onFiles([...event.target.files], props.folderId); event.target.value = ''; }} /> {props.filterBar} {edit ?
{ void submit(event); }}> setName(event.target.value)} />
: null}
{ if (!(event.target as HTMLElement).closest('tr,button,input')) { props.onSelection(new Set()); props.onSelect(undefined); setSelectedFolder(undefined); event.currentTarget.focus(); } }} tabIndex={-1}> {children.map((folder) => 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) => { 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} />)}
{ids.length === 0 && children.length === 0 ?

此频道内的文件夹还没有文件

上传文件或将文件拖到窗口中。
: null}
{props.checked.size > 0 ?
已选 {props.checked.size} 项 {props.enabled ? : null}
: null} {error || interaction.error ?

{error || interaction.error}

: null}
双击打开 · 右键操作 · 拖动移动
{interaction.menu ? : null} {interaction.hover && ids.includes(interaction.hover.id) && !interaction.menu ? : null} {interaction.moveIds ? interaction.setMoveIds(undefined)} onMove={(folderId) => props.onMoveItems(interaction.moveIds!, folderId)} /> : null}
; }