feat(workspace): 新增工作空间文件管理器(针对 workDir)

后端 server/workspace.go(全部在 requireAuth 之后,路径限定在 workDir):
- list/read/write/mkdir/delete/download/upload 七个端点
- wsResolve 用 Clean("/"+rel)+前缀校验防 .. 遍历;上传经 filepath.Base
  再二次校验;读文本 2MB 上限(超限/二进制仅下载);上传 512MB 上限;
  下载文件名过滤 + filename* UTF-8 编码

前端:
- 侧边栏「工作空间」入口(/function/workspace)
- api/types:workspaceList/read/write/mkdir/delete + upload(FormData)
  + download(blob 带 token)
- page:面包屑导航 + 目录表格 + 上传/新建文件夹/刷新 + 每行下载/删除
  + 右侧 Sheet 文本编辑器(二进制/超大仅下载)

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Autumn-27andClaude Opus 4.8 committed 2026-08-09 01:01:19 -04:00
1 parent 5f0fac33aa
commit 6bffd491ee
6 files changed
+726

No files matched your search

@@ -0,0 +1,364 @@
"use client";
import * as React from "react";
import {
DownloadIcon,
FileIcon,
FolderIcon,
FolderPlusIcon,
HardDriveIcon,
RefreshCwIcon,
SaveIcon,
Trash2Icon,
UploadIcon,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { api } from "@/lib/api";
import type { WorkspaceEntry, WorkspaceFile } from "@/lib/types";
function fmtSize(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
return `${(n / 1024 / 1024 / 1024).toFixed(1)} GB`;
}
function fmtTime(ms: number): string {
return new Date(ms).toLocaleString("zh-CN", {
year: "2-digit",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
type EditState = {
file: WorkspaceFile;
content: string;
dirty: boolean;
saving: boolean;
};
export default function WorkspacePage() {
const [path, setPath] = React.useState("");
const [entries, setEntries] = React.useState<WorkspaceEntry[]>([]);
const [loading, setLoading] = React.useState(true);
const [edit, setEdit] = React.useState<EditState | null>(null);
const [mkdirOpen, setMkdirOpen] = React.useState(false);
const [mkdirName, setMkdirName] = React.useState("");
const uploadRef = React.useRef<HTMLInputElement>(null);
const load = React.useCallback(
(p: string) => {
setLoading(true);
api
.workspaceList(p)
.then((r) => {
setEntries(r.entries);
setPath(r.path);
})
.catch((e) => toast.error(`读取目录失败:${(e as Error).message}`))
.finally(() => setLoading(false));
},
[],
);
React.useEffect(() => {
load("");
}, [load]);
const crumbs = React.useMemo(() => {
const parts = path ? path.split("/") : [];
const acc: { name: string; path: string }[] = [{ name: "工作空间", path: "" }];
let cur = "";
for (const part of parts) {
cur = cur ? `${cur}/${part}` : part;
acc.push({ name: part, path: cur });
}
return acc;
}, [path]);
const openFile = (e: WorkspaceEntry) => {
api
.workspaceRead(e.path)
.then((f) => setEdit({ file: f, content: f.content ?? "", dirty: false, saving: false }))
.catch((err) => toast.error(`打开文件失败:${(err as Error).message}`));
};
const saveFile = () => {
if (!edit) return;
setEdit({ ...edit, saving: true });
api
.workspaceWrite(edit.file.path, edit.content)
.then(() => {
toast.success("已保存");
setEdit((cur) => (cur ? { ...cur, dirty: false, saving: false } : cur));
load(path);
})
.catch((err) => {
toast.error(`保存失败:${(err as Error).message}`);
setEdit((cur) => (cur ? { ...cur, saving: false } : cur));
});
};
const del = (e: WorkspaceEntry) => {
if (!window.confirm(`确认删除 ${e.dir ? "目录" : "文件"} “${e.name}”?${e.dir ? "(含其下所有内容)" : ""}`)) return;
api
.workspaceDelete(e.path)
.then(() => {
toast.success("已删除");
load(path);
})
.catch((err) => toast.error(`删除失败:${(err as Error).message}`));
};
const doUpload = (files: FileList | null) => {
if (!files || files.length === 0) return;
api
.workspaceUpload(path, Array.from(files))
.then((r) => {
toast.success(`已上传 ${r.uploaded} 个文件`);
load(path);
})
.catch((err) => toast.error(`上传失败:${(err as Error).message}`))
.finally(() => {
if (uploadRef.current) uploadRef.current.value = "";
});
};
const doMkdir = () => {
const name = mkdirName.trim();
if (!name) return;
const target = path ? `${path}/${name}` : name;
api
.workspaceMkdir(target)
.then(() => {
toast.success("已创建目录");
setMkdirOpen(false);
setMkdirName("");
load(path);
})
.catch((err) => toast.error(`创建失败:${(err as Error).message}`));
};
return (
<div className="flex flex-col gap-4">
{/* 头部:面包屑 + 操作 */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-1 text-sm">
<HardDriveIcon className="text-muted-foreground mr-1 size-4 shrink-0" />
{crumbs.map((c, i) => (
<React.Fragment key={c.path}>
{i > 0 && <span className="text-muted-foreground">/</span>}
<button
type="button"
onClick={() => load(c.path)}
className={cn(
"max-w-[160px] truncate rounded px-1.5 py-0.5 hover:bg-accent",
i === crumbs.length - 1 ? "text-foreground font-medium" : "text-muted-foreground",
)}
>
{c.name}
</button>
</React.Fragment>
))}
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setMkdirOpen(true)}>
<FolderPlusIcon /> 新建文件夹
</Button>
<Button variant="outline" size="sm" onClick={() => uploadRef.current?.click()}>
<UploadIcon /> 上传
</Button>
<Button variant="ghost" size="icon" className="size-8" onClick={() => load(path)} title="刷新">
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
</Button>
<input
ref={uploadRef}
type="file"
multiple
className="hidden"
onChange={(e) => doUpload(e.target.files)}
/>
</div>
</div>
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>名称</TableHead>
<TableHead className="w-28 text-right">大小</TableHead>
<TableHead className="w-40">修改时间</TableHead>
<TableHead className="w-24 text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{entries.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="text-muted-foreground py-10 text-center text-sm">
{loading ? "加载中…" : "空目录"}
</TableCell>
</TableRow>
)}
{entries.map((e) => (
<TableRow key={e.path}>
<TableCell>
<button
type="button"
onClick={() => (e.dir ? load(e.path) : openFile(e))}
className="flex items-center gap-2 text-left hover:underline"
>
{e.dir ? (
<FolderIcon className="size-4 shrink-0 text-blue-500" />
) : (
<FileIcon className="text-muted-foreground size-4 shrink-0" />
)}
<span className="truncate font-mono text-sm">{e.name}</span>
</button>
</TableCell>
<TableCell className="text-muted-foreground text-right font-mono text-xs">
{e.dir ? "—" : fmtSize(e.size)}
</TableCell>
<TableCell className="text-muted-foreground text-xs">{fmtTime(e.mtime)}</TableCell>
<TableCell>
<div className="flex items-center justify-end gap-1">
{!e.dir && (
<Button
variant="ghost"
size="icon"
className="size-7"
title="下载"
onClick={() => api.workspaceDownload(e.path).catch((err) => toast.error(`下载失败:${(err as Error).message}`))}
>
<DownloadIcon className="size-3.5" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="text-destructive size-7"
title="删除"
onClick={() => del(e)}
>
<Trash2Icon className="size-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* 文件查看 / 编辑 */}
<Sheet open={edit !== null} onOpenChange={(o) => !o && setEdit(null)}>
<SheetContent side="right" className="flex w-full flex-col gap-0 p-0 sm:max-w-2xl">
{edit && (
<>
<SheetHeader className="border-b p-4">
<SheetTitle className="flex items-center gap-2 truncate font-mono text-sm">
<FileIcon className="size-4 shrink-0" />
<span className="truncate" title={edit.file.path}>
{edit.file.path}
</span>
</SheetTitle>
<span className="text-muted-foreground text-xs">{fmtSize(edit.file.size)}</span>
</SheetHeader>
{edit.file.binary || edit.file.too_large ? (
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-8 text-center">
<p className="text-muted-foreground text-sm">
{edit.file.too_large ? "文件过大,不支持在线预览/编辑。" : "二进制文件,不支持在线预览/编辑。"}
</p>
<Button variant="outline" onClick={() => api.workspaceDownload(edit.file.path)}>
<DownloadIcon /> 下载文件
</Button>
</div>
) : (
<>
<div className="min-h-0 flex-1 p-3">
<Textarea
value={edit.content}
onChange={(ev) => setEdit({ ...edit, content: ev.target.value, dirty: true })}
spellCheck={false}
className="h-full min-h-[50vh] resize-none font-mono text-xs leading-relaxed"
/>
</div>
<SheetFooter className="flex-row items-center justify-between border-t p-3">
<span className="text-muted-foreground text-xs">{edit.dirty ? "未保存的修改" : "已同步"}</span>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => api.workspaceDownload(edit.file.path)}
>
<DownloadIcon /> 下载
</Button>
<Button onClick={saveFile} disabled={!edit.dirty || edit.saving}>
<SaveIcon /> {edit.saving ? "保存中…" : "保存"}
</Button>
</div>
</SheetFooter>
</>
)}
</>
)}
</SheetContent>
</Sheet>
{/* 新建文件夹 */}
<Dialog open={mkdirOpen} onOpenChange={setMkdirOpen}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>新建文件夹</DialogTitle>
</DialogHeader>
<Input
autoFocus
value={mkdirName}
onChange={(e) => setMkdirName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && doMkdir()}
placeholder="文件夹名称"
/>
<DialogFooter>
<Button variant="outline" onClick={() => setMkdirOpen(false)}>
取消
</Button>
<Button onClick={doMkdir} disabled={!mkdirName.trim()}>
创建
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+41
View File
@@ -12,6 +12,7 @@ import type {
Tool, Conversation, AgentTrigger,
InterceptRule, InterceptPending, InterceptApprovalRow,
TaskAssetView, SSProject, SSTask, ConvTokenSummary, CoverageGraphData, CoverageAssetRefs,
WorkspaceListing, WorkspaceFile,
CommandRecord, LLMRecordItem, LLMRecordDetail,
} from "@/lib/types";
@@ -131,6 +132,46 @@ export const api = {
taskAssetRefs: (id: string, assetId: number) =>
get<CoverageAssetRefs>(`/tasks/${id}/asset-refs?asset_id=${assetId}`),
// ---- workspace file manager (workDir) ----
workspaceList: (path = "") =>
get<WorkspaceListing>(`/workspace/list?path=${encodeURIComponent(path)}`),
workspaceRead: (path: string) =>
get<WorkspaceFile>(`/workspace/read?path=${encodeURIComponent(path)}`),
workspaceWrite: (path: string, content: string) =>
post<{ ok: boolean; path: string }>(`/workspace/write`, { path, content }),
workspaceMkdir: (path: string) =>
post<{ ok: boolean; path: string }>(`/workspace/mkdir`, { path }),
workspaceDelete: (path: string) =>
del<{ ok: boolean }>(`/workspace/delete?path=${encodeURIComponent(path)}`),
workspaceUpload: async (dir: string, files: File[]) => {
const fd = new FormData();
for (const f of files) fd.append("file", f);
const token = getToken();
const r = await fetch(`/api/workspace/upload?path=${encodeURIComponent(dir)}`, {
method: "POST",
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: fd,
});
if (!r.ok) throw new Error(`上传失败: ${r.status}`);
return r.json() as Promise<{ uploaded: number }>;
},
workspaceDownload: async (path: string) => {
const token = getToken();
const r = await fetch(`/api/workspace/download?path=${encodeURIComponent(path)}`, {
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}) },
});
if (!r.ok) throw new Error(`下载失败: ${r.status}`);
const blob = await r.blob();
const objUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = objUrl;
a.download = path.split("/").pop() || "download";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(objUrl);
},
// ---- assets ----
// Server-side paginated: pass limit/offset, get back the page + full match total.
assets: (type = "", limit = 50, offset = 0) =>
+20
View File
@@ -178,6 +178,26 @@ export interface CoverageAssetRefs {
findings: CoverageAssetRef[];
}
// ---- Workspace file manager (workDir) ----
export interface WorkspaceEntry {
name: string;
path: string; // workspace-relative, forward slashes
dir: boolean;
size: number;
mtime: number; // unix millis
}
export interface WorkspaceListing {
path: string;
entries: WorkspaceEntry[];
}
export interface WorkspaceFile {
path: string;
size: number;
binary: boolean;
too_large?: boolean;
content?: string;
}
// 公司资产范围规则的一条(归属唯一真值来源)。
export interface ScopeRow {
id: number;
@@ -4,6 +4,7 @@ import {
Brain,
Bug,
ClipboardList,
FolderOpen,
FolderSync,
LayoutDashboard,
type LucideIcon,
@@ -72,6 +73,7 @@ export const sidebarItems: NavGroup[] = [
{ id: "llm-records", title: "LLM 录制", url: "/function/llm-records", icon: Radio },
{ id: "assets", title: "资产", url: "/function/assets", icon: Network },
{ id: "sync", title: "资产同步", url: "/function/sync", icon: FolderSync },
{ id: "workspace", title: "工作空间", url: "/function/workspace", icon: FolderOpen },
],
},
{