mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-28 01:03:50 +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", s.taskCoverage)
|
||||||
mux.HandleFunc("GET /api/tasks/{id}/coverage-graph", s.taskCoverageGraph)
|
mux.HandleFunc("GET /api/tasks/{id}/coverage-graph", s.taskCoverageGraph)
|
||||||
mux.HandleFunc("GET /api/tasks/{id}/asset-refs", s.taskAssetRefs)
|
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("GET /api/tasks/{id}/scope", s.taskScopeList)
|
||||||
mux.HandleFunc("POST /api/tasks/{id}/control", s.control)
|
mux.HandleFunc("POST /api/tasks/{id}/control", s.control)
|
||||||
mux.HandleFunc("POST /api/active", s.setActive)
|
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,
|
Tool, Conversation, AgentTrigger,
|
||||||
InterceptRule, InterceptPending, InterceptApprovalRow,
|
InterceptRule, InterceptPending, InterceptApprovalRow,
|
||||||
TaskAssetView, SSProject, SSTask, ConvTokenSummary, CoverageGraphData, CoverageAssetRefs,
|
TaskAssetView, SSProject, SSTask, ConvTokenSummary, CoverageGraphData, CoverageAssetRefs,
|
||||||
|
WorkspaceListing, WorkspaceFile,
|
||||||
CommandRecord, LLMRecordItem, LLMRecordDetail,
|
CommandRecord, LLMRecordItem, LLMRecordDetail,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
|
|
||||||
@@ -131,6 +132,46 @@ export const api = {
|
|||||||
taskAssetRefs: (id: string, assetId: number) =>
|
taskAssetRefs: (id: string, assetId: number) =>
|
||||||
get<CoverageAssetRefs>(`/tasks/${id}/asset-refs?asset_id=${assetId}`),
|
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 ----
|
// ---- assets ----
|
||||||
// Server-side paginated: pass limit/offset, get back the page + full match total.
|
// Server-side paginated: pass limit/offset, get back the page + full match total.
|
||||||
assets: (type = "", limit = 50, offset = 0) =>
|
assets: (type = "", limit = 50, offset = 0) =>
|
||||||
|
|||||||
@@ -178,6 +178,26 @@ export interface CoverageAssetRefs {
|
|||||||
findings: CoverageAssetRef[];
|
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 {
|
export interface ScopeRow {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Brain,
|
Brain,
|
||||||
Bug,
|
Bug,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
|
FolderOpen,
|
||||||
FolderSync,
|
FolderSync,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
@@ -72,6 +73,7 @@ export const sidebarItems: NavGroup[] = [
|
|||||||
{ id: "llm-records", title: "LLM 录制", url: "/function/llm-records", icon: Radio },
|
{ id: "llm-records", title: "LLM 录制", url: "/function/llm-records", icon: Radio },
|
||||||
{ id: "assets", title: "资产", url: "/function/assets", icon: Network },
|
{ id: "assets", title: "资产", url: "/function/assets", icon: Network },
|
||||||
{ id: "sync", title: "资产同步", url: "/function/sync", icon: FolderSync },
|
{ id: "sync", title: "资产同步", url: "/function/sync", icon: FolderSync },
|
||||||
|
{ id: "workspace", title: "工作空间", url: "/function/workspace", icon: FolderOpen },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in new issue
Block a user