mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
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
@@ -485,6 +485,15 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /api/tasks/{id}/coverage", s.taskCoverage)
|
||||
mux.HandleFunc("GET /api/tasks/{id}/coverage-graph", s.taskCoverageGraph)
|
||||
mux.HandleFunc("GET /api/tasks/{id}/asset-refs", s.taskAssetRefs)
|
||||
|
||||
// 工作空间文件管理器(针对 workDir)
|
||||
mux.HandleFunc("GET /api/workspace/list", s.wsList)
|
||||
mux.HandleFunc("GET /api/workspace/read", s.wsRead)
|
||||
mux.HandleFunc("POST /api/workspace/write", s.wsWrite)
|
||||
mux.HandleFunc("POST /api/workspace/mkdir", s.wsMkdir)
|
||||
mux.HandleFunc("DELETE /api/workspace/delete", s.wsDelete)
|
||||
mux.HandleFunc("GET /api/workspace/download", s.wsDownload)
|
||||
mux.HandleFunc("POST /api/workspace/upload", s.wsUpload)
|
||||
mux.HandleFunc("GET /api/tasks/{id}/scope", s.taskScopeList)
|
||||
mux.HandleFunc("POST /api/tasks/{id}/control", s.control)
|
||||
mux.HandleFunc("POST /api/active", s.setActive)
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Workspace file manager — browse / view / edit / download / upload / delete the
|
||||
// shared work dir (s.m.dir), where all agents write their artifacts. Every path is
|
||||
// confined to the work dir root (traversal via ".." is neutralised). All routes sit
|
||||
// behind requireAuth (see Handler()).
|
||||
|
||||
const (
|
||||
maxWorkspaceRead = 2 << 20 // 2 MiB: files bigger than this aren't inlined for view/edit (download instead)
|
||||
maxWorkspaceUpload = 512 << 20 // 512 MiB per upload request
|
||||
)
|
||||
|
||||
// wsResolve maps a user-supplied relative path to an absolute path INSIDE the work
|
||||
// dir. It returns ok=false if the path would escape the root. filepath.Clean on a
|
||||
// rooted copy collapses any ".." so nothing can climb above the root.
|
||||
func (s *Server) wsResolve(rel string) (string, bool) {
|
||||
base := filepath.Clean(s.m.dir)
|
||||
rel = strings.TrimPrefix(strings.TrimSpace(rel), "/")
|
||||
clean := filepath.Clean("/" + rel) // e.g. "/a/../../etc" → "/etc" (still rooted at "/")
|
||||
abs := filepath.Clean(filepath.Join(base, clean))
|
||||
if abs != base && !strings.HasPrefix(abs, base+string(os.PathSeparator)) {
|
||||
return "", false
|
||||
}
|
||||
return abs, true
|
||||
}
|
||||
|
||||
// wsRel renders an absolute path back as a workspace-relative path (forward slashes).
|
||||
func (s *Server) wsRel(abs string) string {
|
||||
base := filepath.Clean(s.m.dir)
|
||||
rel, err := filepath.Rel(base, abs)
|
||||
if err != nil || rel == "." {
|
||||
return ""
|
||||
}
|
||||
return filepath.ToSlash(rel)
|
||||
}
|
||||
|
||||
type wsEntry struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Dir bool `json:"dir"`
|
||||
Size int64 `json:"size"`
|
||||
MTime int64 `json:"mtime"` // unix millis
|
||||
}
|
||||
|
||||
// GET /api/workspace/list?path=<rel>
|
||||
func (s *Server) wsList(w http.ResponseWriter, r *http.Request) {
|
||||
abs, ok := s.wsResolve(r.URL.Query().Get("path"))
|
||||
if !ok {
|
||||
writeErr(w, 400, "非法路径")
|
||||
return
|
||||
}
|
||||
fi, err := os.Stat(abs)
|
||||
if err != nil {
|
||||
writeErr(w, 404, "路径不存在")
|
||||
return
|
||||
}
|
||||
if !fi.IsDir() {
|
||||
writeErr(w, 400, "不是目录")
|
||||
return
|
||||
}
|
||||
ents, err := os.ReadDir(abs)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
out := make([]wsEntry, 0, len(ents))
|
||||
for _, e := range ents {
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, wsEntry{
|
||||
Name: e.Name(),
|
||||
Path: s.wsRel(filepath.Join(abs, e.Name())),
|
||||
Dir: e.IsDir(),
|
||||
Size: info.Size(),
|
||||
MTime: info.ModTime().UnixMilli(),
|
||||
})
|
||||
}
|
||||
// 目录在前,各自按名称排序。
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Dir != out[j].Dir {
|
||||
return out[i].Dir
|
||||
}
|
||||
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
|
||||
})
|
||||
writeJSON(w, 200, map[string]any{"path": s.wsRel(abs), "entries": out})
|
||||
}
|
||||
|
||||
// GET /api/workspace/read?path=<rel> — inline text for view/edit. Binary or oversize
|
||||
// files return {binary:true}/{too_large:true} with no content (use download instead).
|
||||
func (s *Server) wsRead(w http.ResponseWriter, r *http.Request) {
|
||||
abs, ok := s.wsResolve(r.URL.Query().Get("path"))
|
||||
if !ok {
|
||||
writeErr(w, 400, "非法路径")
|
||||
return
|
||||
}
|
||||
fi, err := os.Stat(abs)
|
||||
if err != nil {
|
||||
writeErr(w, 404, "文件不存在")
|
||||
return
|
||||
}
|
||||
if fi.IsDir() {
|
||||
writeErr(w, 400, "是目录,不能作为文件读取")
|
||||
return
|
||||
}
|
||||
if fi.Size() > maxWorkspaceRead {
|
||||
writeJSON(w, 200, map[string]any{"path": s.wsRel(abs), "size": fi.Size(), "too_large": true, "binary": true})
|
||||
return
|
||||
}
|
||||
data, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if bytes.IndexByte(data, 0) >= 0 || !utf8.Valid(data) {
|
||||
writeJSON(w, 200, map[string]any{"path": s.wsRel(abs), "size": fi.Size(), "binary": true})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"path": s.wsRel(abs), "size": fi.Size(), "binary": false, "content": string(data)})
|
||||
}
|
||||
|
||||
// POST /api/workspace/write {path, content} — create/overwrite a text file.
|
||||
func (s *Server) wsWrite(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := decode(r, &req); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
abs, ok := s.wsResolve(req.Path)
|
||||
if !ok || abs == filepath.Clean(s.m.dir) {
|
||||
writeErr(w, 400, "非法路径")
|
||||
return
|
||||
}
|
||||
if fi, err := os.Stat(abs); err == nil && fi.IsDir() {
|
||||
writeErr(w, 400, "目标是目录")
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(abs, []byte(req.Content), 0o644); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"ok": true, "path": s.wsRel(abs)})
|
||||
}
|
||||
|
||||
// POST /api/workspace/mkdir {path}
|
||||
func (s *Server) wsMkdir(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if err := decode(r, &req); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
abs, ok := s.wsResolve(req.Path)
|
||||
if !ok || abs == filepath.Clean(s.m.dir) {
|
||||
writeErr(w, 400, "非法路径")
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(abs, 0o755); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"ok": true, "path": s.wsRel(abs)})
|
||||
}
|
||||
|
||||
// DELETE /api/workspace/delete?path=<rel> — removes a file or a directory tree
|
||||
// (confined to the work dir; the root itself can't be deleted).
|
||||
func (s *Server) wsDelete(w http.ResponseWriter, r *http.Request) {
|
||||
abs, ok := s.wsResolve(r.URL.Query().Get("path"))
|
||||
if !ok {
|
||||
writeErr(w, 400, "非法路径")
|
||||
return
|
||||
}
|
||||
if abs == filepath.Clean(s.m.dir) {
|
||||
writeErr(w, 400, "不能删除工作区根目录")
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(abs); err != nil {
|
||||
writeErr(w, 404, "路径不存在")
|
||||
return
|
||||
}
|
||||
if err := os.RemoveAll(abs); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// GET /api/workspace/download?path=<rel> — stream a file as an attachment.
|
||||
func (s *Server) wsDownload(w http.ResponseWriter, r *http.Request) {
|
||||
abs, ok := s.wsResolve(r.URL.Query().Get("path"))
|
||||
if !ok {
|
||||
writeErr(w, 400, "非法路径")
|
||||
return
|
||||
}
|
||||
fi, err := os.Stat(abs)
|
||||
if err != nil || fi.IsDir() {
|
||||
writeErr(w, 404, "文件不存在")
|
||||
return
|
||||
}
|
||||
name := filepath.Base(abs)
|
||||
// RFC 5987 filename* keeps non-ASCII names intact; plain filename is the fallback.
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\""+sanitizeFilename(name)+"\"; filename*=UTF-8''"+url.PathEscape(name))
|
||||
http.ServeFile(w, r, abs)
|
||||
}
|
||||
|
||||
// POST /api/workspace/upload?path=<dir> — multipart form field "file" (one or more).
|
||||
func (s *Server) wsUpload(w http.ResponseWriter, r *http.Request) {
|
||||
dirAbs, ok := s.wsResolve(r.URL.Query().Get("path"))
|
||||
if !ok {
|
||||
writeErr(w, 400, "非法路径")
|
||||
return
|
||||
}
|
||||
if fi, err := os.Stat(dirAbs); err != nil || !fi.IsDir() {
|
||||
writeErr(w, 400, "目标目录不存在")
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxWorkspaceUpload)
|
||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||
writeErr(w, 400, "解析上传失败或超出大小限制:"+err.Error())
|
||||
return
|
||||
}
|
||||
files := r.MultipartForm.File["file"]
|
||||
if len(files) == 0 {
|
||||
writeErr(w, 400, "缺少上传文件(表单字段 file)")
|
||||
return
|
||||
}
|
||||
saved := 0
|
||||
for _, hdr := range files {
|
||||
name := filepath.Base(hdr.Filename) // strip any path component
|
||||
if name == "" || name == "." || name == ".." {
|
||||
continue
|
||||
}
|
||||
destAbs, okd := s.wsResolve(filepath.Join(s.wsRel(dirAbs), name))
|
||||
if !okd {
|
||||
continue
|
||||
}
|
||||
if err := saveUpload(hdr, destAbs); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
saved++
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"uploaded": saved})
|
||||
}
|
||||
|
||||
func saveUpload(hdr *multipart.FileHeader, dest string) error {
|
||||
src, err := hdr.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer src.Close()
|
||||
out, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
_, err = io.Copy(out, src)
|
||||
return err
|
||||
}
|
||||
|
||||
// sanitizeFilename strips characters unsafe for a Content-Disposition filename token.
|
||||
func sanitizeFilename(name string) string {
|
||||
name = strings.ReplaceAll(name, "\"", "")
|
||||
name = strings.ReplaceAll(name, "\\", "")
|
||||
name = strings.ReplaceAll(name, "\n", "")
|
||||
name = strings.ReplaceAll(name, "\r", "")
|
||||
return name
|
||||
}
|
||||
@@ -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