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:
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>
|
||||
);
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user