feat: integrate MinIO-backed user filesystem

This commit is contained in:
Codex
2026-06-03 12:06:34 +08:00
parent a27560102b
commit ffa1249403
56 changed files with 4810 additions and 116 deletions

View File

@ -21,49 +21,71 @@ import {
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import {
browseWorkspace,
getWorkspaceFile,
getWorkspaceDownloadUrl,
uploadToWorkspace,
deleteWorkspacePath,
createWorkspaceDir,
browseUserFiles,
getUserFile,
getUserFileDownloadUrl,
uploadUserFile,
deleteUserFile,
createUserFileDir,
getAccessToken,
} from '@/lib/api';
import type { WorkspaceFileContent, WorkspaceItem } from '@/lib/api';
import type { UserFileContent, UserFileItem } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { type AppLocale, pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
const LOAD_RETRY_DELAYS_MS = [0, 600, 1200];
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
window.setTimeout(resolve, ms);
});
}
export default function FilesPage() {
const { locale } = useAppI18n();
const [items, setItems] = useState<WorkspaceItem[]>([]);
const [items, setItems] = useState<UserFileItem[]>([]);
const [currentPath, setCurrentPath] = useState('');
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [showMkdir, setShowMkdir] = useState(false);
const [newDirName, setNewDirName] = useState('');
const [selectedFile, setSelectedFile] = useState<WorkspaceFileContent | null>(null);
const [selectedFile, setSelectedFile] = useState<UserFileContent | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [previewError, setPreviewError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const mkdirInputRef = useRef<HTMLInputElement>(null);
const load = useCallback(async (path: string = currentPath) => {
let lastError: unknown = null;
try {
setLoading(true);
const data = await browseWorkspace(path);
setItems(data.items);
setCurrentPath(data.path);
setSelectedFile(null);
setPreviewError(null);
} catch {
// ignore
setLoadError(null);
for (const delay of LOAD_RETRY_DELAYS_MS) {
if (delay > 0) {
await sleep(delay);
}
try {
const data = await browseUserFiles(path);
setItems(data.items);
setCurrentPath(data.path);
setSelectedFile(null);
setPreviewError(null);
return;
} catch (err) {
lastError = err;
}
}
const message = lastError instanceof Error ? lastError.message : pickAppText(locale, '加载文件失败', 'Failed to load files');
setLoadError(message);
setItems([]);
} finally {
setLoading(false);
}
}, [currentPath]);
}, [currentPath, locale]);
useEffect(() => {
load('');
@ -73,12 +95,12 @@ export default function FilesPage() {
load(path);
};
const openFile = async (item: WorkspaceItem) => {
const openFile = async (item: UserFileItem) => {
if (item.type !== 'file') return;
setPreviewLoading(true);
setPreviewError(null);
try {
setSelectedFile(await getWorkspaceFile(item.path));
setSelectedFile(await getUserFile(item.path));
} catch (err: any) {
setPreviewError(err.message || pickAppText(locale, '加载文件失败', 'Failed to load file'));
setSelectedFile(null);
@ -87,7 +109,7 @@ export default function FilesPage() {
}
};
const handleDelete = async (item: WorkspaceItem) => {
const handleDelete = async (item: UserFileItem) => {
const label = item.type === 'directory'
? pickAppText(locale, '文件夹', 'folder')
: pickAppText(locale, '文件', 'file');
@ -99,7 +121,7 @@ export default function FilesPage() {
return;
}
try {
await deleteWorkspacePath(item.path);
await deleteUserFile(item.path);
setItems((prev) => prev.filter((i) => i.path !== item.path));
if (selectedFile?.path === item.path) {
setSelectedFile(null);
@ -109,8 +131,8 @@ export default function FilesPage() {
}
};
const handleDownload = async (item: WorkspaceItem) => {
const url = getWorkspaceDownloadUrl(item.path);
const handleDownload = async (item: UserFileItem) => {
const url = getUserFileDownloadUrl(item.path);
const token = getAccessToken();
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
@ -138,7 +160,7 @@ export default function FilesPage() {
setUploadProgress(0);
try {
for (let i = 0; i < files.length; i++) {
await uploadToWorkspace(files[i], currentPath, (pct) => {
await uploadUserFile(files[i], currentPath || 'uploads', (pct) => {
setUploadProgress(Math.round((i / files.length) * 100 + pct / files.length));
});
}
@ -157,7 +179,7 @@ export default function FilesPage() {
if (!name) return;
try {
const dirPath = currentPath ? `${currentPath}/${name}` : name;
await createWorkspaceDir(dirPath);
await createUserFileDir(dirPath);
setShowMkdir(false);
setNewDirName('');
await load();
@ -176,7 +198,8 @@ export default function FilesPage() {
return `${bytes} B`;
};
const formatDate = (iso: string) => {
const formatDate = (iso: string | null | undefined) => {
if (!iso) return '';
try {
return new Date(iso).toLocaleString(locale, {
month: '2-digit',
@ -199,7 +222,7 @@ export default function FilesPage() {
variant="outline"
size="sm"
onClick={() => setShowMkdir(true)}
disabled={loading}
disabled={loading || !currentPath}
>
<FolderPlus className="w-4 h-4 mr-1" />
{pickAppText(locale, '新建文件夹', 'New folder')}
@ -208,7 +231,7 @@ export default function FilesPage() {
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
disabled={uploading || !currentPath}
>
{uploading ? (
<>
@ -246,7 +269,7 @@ export default function FilesPage() {
className="flex items-center gap-1 hover:text-foreground transition-colors px-1.5 py-0.5 rounded hover:bg-accent"
>
<Home className="w-3.5 h-3.5" />
{pickAppText(locale, '工作区', 'Workspace')}
{pickAppText(locale, '文件', 'Files')}
</button>
{breadcrumbs.map((segment, idx) => {
const path = breadcrumbs.slice(0, idx + 1).join('/');
@ -312,6 +335,16 @@ export default function FilesPage() {
<div className="flex items-center justify-center py-20 text-muted-foreground">
<Loader2 className="w-6 h-6 animate-spin" />
</div>
) : loadError ? (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<FolderOpen className="w-12 h-12 mb-4 opacity-50" />
<p className="text-lg font-medium">{pickAppText(locale, '加载失败', 'Failed to load')}</p>
<p className="max-w-sm text-center text-sm">{loadError}</p>
<Button className="mt-4" variant="outline" size="sm" onClick={() => load()}>
<RefreshCw className="mr-1 h-4 w-4" />
{pickAppText(locale, '重试', 'Retry')}
</Button>
</div>
) : items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<FolderOpen className="w-12 h-12 mb-4 opacity-50" />
@ -340,7 +373,7 @@ export default function FilesPage() {
{item.type === 'directory' ? (
<Folder className="w-5 h-5 text-blue-500" />
) : (
<FileIcon name={item.name} contentType={item.content_type} />
<FileIcon name={item.name} contentType={item.content_type || undefined} />
)}
</div>
@ -412,7 +445,7 @@ export default function FilesPage() {
error={previewError}
formatSize={formatSize}
formatDate={formatDate}
downloadUrl={selectedFile ? getWorkspaceDownloadUrl(selectedFile.path) : null}
downloadUrl={selectedFile ? getUserFileDownloadUrl(selectedFile.path) : null}
locale={locale}
/>
</div>
@ -429,11 +462,11 @@ function FilePreviewPanel({
downloadUrl,
locale,
}: {
file: WorkspaceFileContent | null;
file: UserFileContent | null;
loading: boolean;
error: string | null;
formatSize: (bytes: number | null) => string;
formatDate: (iso: string) => string;
formatDate: (iso: string | null | undefined) => string;
downloadUrl: string | null;
locale: AppLocale;
}) {
@ -516,10 +549,10 @@ function FileIcon({ name, contentType }: { name: string; contentType?: string })
return <FileText className="w-5 h-5 text-muted-foreground" />;
}
function isImage(file: WorkspaceFileContent): boolean {
function isImage(file: UserFileContent): boolean {
return file.content_type.startsWith('image/');
}
function isMarkdown(file: WorkspaceFileContent): boolean {
function isMarkdown(file: UserFileContent): boolean {
return file.path.toLowerCase().endsWith('.md') || file.content_type.includes('markdown');
}