feat: integrate MinIO-backed user filesystem
This commit is contained in:
@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user