Files
beaver_project/app-instance/frontend/app/(app)/files/page.tsx
2026-06-03 12:06:34 +08:00

559 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
Trash2,
Download,
Loader2,
FolderOpen,
Folder,
FileText,
Upload,
FolderPlus,
ChevronRight,
Home,
RefreshCw,
FileImage,
FileCode,
FileArchive,
FileSpreadsheet,
} from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import {
browseUserFiles,
getUserFile,
getUserFileDownloadUrl,
uploadUserFile,
deleteUserFile,
createUserFileDir,
getAccessToken,
} 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<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<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);
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, locale]);
useEffect(() => {
load('');
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const navigateTo = (path: string) => {
load(path);
};
const openFile = async (item: UserFileItem) => {
if (item.type !== 'file') return;
setPreviewLoading(true);
setPreviewError(null);
try {
setSelectedFile(await getUserFile(item.path));
} catch (err: any) {
setPreviewError(err.message || pickAppText(locale, '加载文件失败', 'Failed to load file'));
setSelectedFile(null);
} finally {
setPreviewLoading(false);
}
};
const handleDelete = async (item: UserFileItem) => {
const label = item.type === 'directory'
? pickAppText(locale, '文件夹', 'folder')
: pickAppText(locale, '文件', 'file');
if (!confirm(pickAppText(
locale,
`确定删除${label} "${item.name}"${item.type === 'directory' ? '(包含所有子文件)' : ''}`,
`Delete ${label} "${item.name}"?${item.type === 'directory' ? ' (including all nested files)' : ''}`
))) {
return;
}
try {
await deleteUserFile(item.path);
setItems((prev) => prev.filter((i) => i.path !== item.path));
if (selectedFile?.path === item.path) {
setSelectedFile(null);
}
} catch {
// ignore
}
};
const handleDownload = async (item: UserFileItem) => {
const url = getUserFileDownloadUrl(item.path);
const token = getAccessToken();
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
try {
const res = await fetch(url, { headers });
const blob = await res.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = item.name;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(a.href);
} catch {
// ignore
}
};
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
setUploading(true);
setUploadProgress(0);
try {
for (let i = 0; i < files.length; i++) {
await uploadUserFile(files[i], currentPath || 'uploads', (pct) => {
setUploadProgress(Math.round((i / files.length) * 100 + pct / files.length));
});
}
await load();
} catch {
// ignore
} finally {
setUploading(false);
setUploadProgress(0);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const handleCreateDir = async () => {
const name = newDirName.trim();
if (!name) return;
try {
const dirPath = currentPath ? `${currentPath}/${name}` : name;
await createUserFileDir(dirPath);
setShowMkdir(false);
setNewDirName('');
await load();
} catch {
// ignore
}
};
// Build breadcrumbs
const breadcrumbs = currentPath ? currentPath.split('/') : [];
const formatSize = (bytes: number | null) => {
if (bytes === null || bytes === undefined) return '';
if (bytes > 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
if (bytes > 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${bytes} B`;
};
const formatDate = (iso: string | null | undefined) => {
if (!iso) return '';
try {
return new Date(iso).toLocaleString(locale, {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return '';
}
};
return (
<div className="mx-auto max-w-7xl p-6">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-bold">{pickAppText(locale, '文件管理', 'Files')}</h1>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setShowMkdir(true)}
disabled={loading || !currentPath}
>
<FolderPlus className="w-4 h-4 mr-1" />
{pickAppText(locale, '新建文件夹', 'New folder')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={uploading || !currentPath}
>
{uploading ? (
<>
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
{uploadProgress}%
</>
) : (
<>
<Upload className="w-4 h-4 mr-1" />
{pickAppText(locale, '上传', 'Upload')}
</>
)}
</Button>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleUpload}
/>
<Button variant="outline" size="sm" onClick={() => load()} disabled={loading}>
{loading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<RefreshCw className="w-4 h-4" />
)}
</Button>
</div>
</div>
{/* Breadcrumbs */}
<div className="flex items-center gap-1 mb-4 text-sm text-muted-foreground flex-wrap">
<button
onClick={() => navigateTo('')}
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, '文件', 'Files')}
</button>
{breadcrumbs.map((segment, idx) => {
const path = breadcrumbs.slice(0, idx + 1).join('/');
const isLast = idx === breadcrumbs.length - 1;
return (
<React.Fragment key={path}>
<ChevronRight className="w-3 h-3 flex-shrink-0" />
<button
onClick={() => navigateTo(path)}
className={`px-1.5 py-0.5 rounded transition-colors ${
isLast
? 'text-foreground font-medium'
: 'hover:text-foreground hover:bg-accent'
}`}
>
{segment}
</button>
</React.Fragment>
);
})}
</div>
{/* New directory input */}
{showMkdir && (
<div className="flex items-center gap-2 mb-4">
<Folder className="w-4 h-4 text-muted-foreground" />
<input
ref={mkdirInputRef}
type="text"
value={newDirName}
onChange={(e) => setNewDirName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreateDir();
if (e.key === 'Escape') {
setShowMkdir(false);
setNewDirName('');
}
}}
placeholder={pickAppText(locale, '文件夹名称', 'Folder name')}
className="flex-1 px-3 py-1.5 text-sm border border-border rounded-md bg-background focus:outline-none focus:ring-1 focus:ring-ring"
autoFocus
/>
<Button size="sm" onClick={handleCreateDir}>
{pickAppText(locale, '创建', 'Create')}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
setShowMkdir(false);
setNewDirName('');
}}
>
{pickAppText(locale, '取消', 'Cancel')}
</Button>
</div>
)}
<div className="grid gap-4 lg:grid-cols-[minmax(360px,440px)_minmax(0,1fr)]">
{/* File list */}
<div className="min-h-[520px] rounded-lg border border-border bg-card">
{loading && items.length === 0 ? (
<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" />
<p className="text-lg font-medium">{pickAppText(locale, '空文件夹', 'Empty folder')}</p>
<p className="text-sm">{pickAppText(locale, '点击上方"上传"或"新建文件夹"按钮开始使用', 'Use "Upload" or "New folder" above to get started')}</p>
</div>
) : (
<ScrollArea className="h-[calc(100vh-15rem)] min-h-[520px]">
<div className="space-y-1 p-2">
{items.map((item) => (
<button
key={item.path}
type="button"
className={`group flex w-full items-center gap-3 rounded-lg border px-3 py-2.5 text-left transition-colors hover:bg-accent/30 ${
selectedFile?.path === item.path ? 'border-primary bg-accent/40' : 'border-border bg-card'
}`}
onClick={() => {
if (item.type === 'directory') {
navigateTo(item.path);
} else {
void openFile(item);
}
}}
>
<div className="flex-shrink-0">
{item.type === 'directory' ? (
<Folder className="w-5 h-5 text-blue-500" />
) : (
<FileIcon name={item.name} contentType={item.content_type || undefined} />
)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{item.name}</div>
<p className="text-xs text-muted-foreground">
{item.type === 'file' && formatSize(item.size)}
{item.modified && (
<>
{item.type === 'file' && ' · '}
{formatDate(item.modified)}
</>
)}
</p>
</div>
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
{item.type === 'file' && (
<span
role="button"
tabIndex={0}
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-accent"
onClick={(event) => {
event.stopPropagation();
void handleDownload(item);
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
void handleDownload(item);
}
}}
title={pickAppText(locale, '下载', 'Download')}
>
<Download className="w-4 h-4" />
</span>
)}
<span
role="button"
tabIndex={0}
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-destructive hover:bg-accent hover:text-destructive"
onClick={(event) => {
event.stopPropagation();
void handleDelete(item);
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
void handleDelete(item);
}
}}
title={pickAppText(locale, '删除', 'Delete')}
>
<Trash2 className="w-4 h-4" />
</span>
</div>
</button>
))}
</div>
</ScrollArea>
)}
</div>
<FilePreviewPanel
file={selectedFile}
loading={previewLoading}
error={previewError}
formatSize={formatSize}
formatDate={formatDate}
downloadUrl={selectedFile ? getUserFileDownloadUrl(selectedFile.path) : null}
locale={locale}
/>
</div>
</div>
);
}
function FilePreviewPanel({
file,
loading,
error,
formatSize,
formatDate,
downloadUrl,
locale,
}: {
file: UserFileContent | null;
loading: boolean;
error: string | null;
formatSize: (bytes: number | null) => string;
formatDate: (iso: string | null | undefined) => string;
downloadUrl: string | null;
locale: AppLocale;
}) {
return (
<div className="min-h-[520px] rounded-lg border border-border bg-card p-4">
{loading ? (
<div className="flex h-[420px] items-center justify-center text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : error ? (
<div className="flex h-[420px] items-center justify-center text-center text-sm text-destructive">{error}</div>
) : !file ? (
<div className="flex h-[420px] flex-col items-center justify-center text-center text-muted-foreground">
<FileText className="mb-3 h-10 w-10 opacity-50" />
<p className="text-sm font-medium">{pickAppText(locale, '点击左侧文件查看内容', 'Click a file to preview its contents')}</p>
</div>
) : (
<div className="space-y-3">
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-border pb-3">
<div className="min-w-0">
<h2 className="break-all text-base font-semibold">{file.name}</h2>
<p className="mt-1 text-xs text-muted-foreground">
{formatSize(file.size)} · {formatDate(file.modified)} · {file.content_type}
{file.is_truncated ? ` · ${pickAppText(locale, '仅预览前 1MB', 'Showing first 1MB')}` : ''}
</p>
</div>
{downloadUrl && (
<Button variant="outline" size="sm" asChild>
<a href={downloadUrl}>
<Download className="mr-2 h-4 w-4" />
{pickAppText(locale, '下载', 'Download')}
</a>
</Button>
)}
</div>
{isImage(file) && downloadUrl ? (
<div className="flex max-h-[620px] items-start justify-center overflow-auto rounded-md border border-border bg-muted/20 p-3">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={downloadUrl} alt={file.name} className="max-h-[580px] max-w-full object-contain" />
</div>
) : file.is_binary ? (
<div className="flex h-[360px] flex-col items-center justify-center text-center text-muted-foreground">
<FileArchive className="mb-3 h-10 w-10 opacity-50" />
<p className="text-sm font-medium">{pickAppText(locale, '该文件不能直接预览', 'This file cannot be previewed')}</p>
</div>
) : isMarkdown(file) ? (
<div className="prose prose-sm max-h-[620px] max-w-none overflow-auto text-black prose-a:text-black prose-code:text-black prose-headings:text-black prose-li:text-black prose-p:text-black prose-pre:bg-muted prose-pre:text-black prose-strong:text-black [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{file.content || ''}</ReactMarkdown>
</div>
) : (
<pre className="max-h-[620px] overflow-auto whitespace-pre-wrap rounded-md border border-border bg-background p-4 text-xs leading-5 text-black">
{file.content || ''}
</pre>
)}
</div>
)}
</div>
);
}
function FileIcon({ name, contentType }: { name: string; contentType?: string }) {
const ext = name.split('.').pop()?.toLowerCase() || '';
const ct = contentType || '';
if (ct.startsWith('image/') || ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'].includes(ext)) {
return <FileImage className="w-5 h-5 text-green-500" />;
}
if (
['js', 'ts', 'tsx', 'jsx', 'py', 'rb', 'go', 'rs', 'java', 'c', 'cpp', 'h', 'css', 'html', 'json', 'yaml', 'yml', 'toml', 'md', 'sh'].includes(ext)
) {
return <FileCode className="w-5 h-5 text-orange-500" />;
}
if (['zip', 'tar', 'gz', 'bz2', 'rar', '7z', 'xz'].includes(ext)) {
return <FileArchive className="w-5 h-5 text-yellow-500" />;
}
if (['csv', 'xls', 'xlsx', 'tsv'].includes(ext)) {
return <FileSpreadsheet className="w-5 h-5 text-emerald-500" />;
}
return <FileText className="w-5 h-5 text-muted-foreground" />;
}
function isImage(file: UserFileContent): boolean {
return file.content_type.startsWith('image/');
}
function isMarkdown(file: UserFileContent): boolean {
return file.path.toLowerCase().endsWith('.md') || file.content_type.includes('markdown');
}