From c5852617b137e89c1b275d507f1488346d42f095 Mon Sep 17 00:00:00 2001 From: Wenjie Zhang Date: Thu, 30 Apr 2026 21:28:00 +0800 Subject: [PATCH 01/15] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E4=B8=AA?= =?UTF-8?q?=E4=BA=BA=E5=B7=A5=E4=BD=9C=E5=8C=BA=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增独立用户级 workspace 只读接口和工作区页面,支持文件列表浏览及宽屏内嵌/窄屏弹窗预览,便于后续扩展团队空间与知识库浏览。 Co-authored-by: Copilot --- .../yuxi/services/workspace_service.py | 124 +++++++ backend/server/routers/__init__.py | 2 + backend/server/routers/workspace_router.py | 33 ++ docs/develop-guides/roadmap.md | 3 + web/src/apis/workspace_api.js | 26 ++ web/src/assets/css/main.css | 1 - .../workspace/WorkspaceFileList.vue | 213 +++++++++++ .../workspace/WorkspacePreviewPane.vue | 91 +++++ .../components/workspace/WorkspaceSidebar.vue | 138 +++++++ web/src/layouts/AppLayout.vue | 8 + web/src/router/index.js | 13 + web/src/views/WorkspaceView.vue | 343 ++++++++++++++++++ 12 files changed, 994 insertions(+), 1 deletion(-) create mode 100644 backend/package/yuxi/services/workspace_service.py create mode 100644 backend/server/routers/workspace_router.py create mode 100644 web/src/apis/workspace_api.js create mode 100644 web/src/components/workspace/WorkspaceFileList.vue create mode 100644 web/src/components/workspace/WorkspacePreviewPane.vue create mode 100644 web/src/components/workspace/WorkspaceSidebar.vue create mode 100644 web/src/views/WorkspaceView.vue diff --git a/backend/package/yuxi/services/workspace_service.py b/backend/package/yuxi/services/workspace_service.py new file mode 100644 index 00000000..b4427b6b --- /dev/null +++ b/backend/package/yuxi/services/workspace_service.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import asyncio +import io +import mimetypes +from pathlib import Path, PurePosixPath +from urllib.parse import quote + +from fastapi import HTTPException +from fastapi.responses import FileResponse, StreamingResponse + +from yuxi.agents.backends.sandbox.paths import _global_user_data_dir +from yuxi.services.viewer_filesystem_service import _detect_preview_type +from yuxi.storage.postgres.models_business import User +from yuxi.utils.datetime_utils import utc_isoformat_from_timestamp +from yuxi.utils.paths import WORKSPACE_DIR_NAME + + +def _workspace_root(user: User) -> Path: + try: + root = _global_user_data_dir(str(user.id)) / WORKSPACE_DIR_NAME + except ValueError as exc: + raise HTTPException(status_code=403, detail="Access denied") from exc + root.mkdir(parents=True, exist_ok=True) + return root.resolve() + + +def _normalize_workspace_path(path: str | None) -> PurePosixPath: + raw_path = (path or "/").strip() or "/" + if not raw_path.startswith("/"): + raw_path = f"/{raw_path}" + normalized = PurePosixPath(raw_path) + if ".." in normalized.parts: + raise HTTPException(status_code=403, detail="Access denied") + return normalized + + +def _resolve_workspace_path(user: User, path: str | None) -> Path: + root = _workspace_root(user) + normalized = _normalize_workspace_path(path) + relative_parts = [part for part in normalized.parts if part not in {"/", ""}] + target = (root.joinpath(*relative_parts) if relative_parts else root).resolve() + try: + target.relative_to(root) + except ValueError as exc: + raise HTTPException(status_code=403, detail="Access denied") from exc + return target + + +def _entry_for_path(root: Path, path: Path) -> dict: + stat = path.stat() + is_dir = path.is_dir() + relative = path.relative_to(root).as_posix() + display_path = f"/{relative}" if relative else "/" + if is_dir and display_path != "/" and not display_path.endswith("/"): + display_path = f"{display_path}/" + return { + "path": display_path, + "name": path.name or "工作区", + "is_dir": is_dir, + "size": 0 if is_dir else stat.st_size, + "modified_at": utc_isoformat_from_timestamp(stat.st_mtime) or "", + } + + +def _sort_entries(entries: list[dict]) -> list[dict]: + return sorted(entries, key=lambda item: (not bool(item.get("is_dir")), str(item.get("name") or "").lower())) + + +def _list_directory(root: Path, target: Path) -> list[dict]: + entries = [_entry_for_path(root, child) for child in target.iterdir()] + return _sort_entries(entries) + + +async def list_workspace_tree(*, path: str, current_user: User) -> dict: + root = _workspace_root(current_user) + target = _resolve_workspace_path(current_user, path) + if not target.exists(): + return {"entries": []} + if not target.is_dir(): + raise HTTPException(status_code=400, detail="当前路径不是目录") + entries = await asyncio.to_thread(_list_directory, root, target) + return {"entries": entries} + + +async def read_workspace_file_content(*, path: str, current_user: User) -> dict: + target = _resolve_workspace_path(current_user, path) + if not target.exists(): + raise HTTPException(status_code=404, detail="文件不存在") + if not target.is_file(): + raise HTTPException(status_code=400, detail="当前路径是目录") + + raw_content = await asyncio.to_thread(target.read_bytes) + preview_type, supported, message = _detect_preview_type(path, raw_content) + if preview_type in {"image", "pdf"} or not supported: + return { + "content": None, + "preview_type": preview_type, + "supported": supported, + "message": message, + } + return { + "content": raw_content.decode("utf-8"), + "preview_type": preview_type, + "supported": supported, + "message": message, + } + + +async def download_workspace_file(*, path: str, current_user: User) -> StreamingResponse | FileResponse: + target = _resolve_workspace_path(current_user, path) + if not target.exists(): + raise HTTPException(status_code=404, detail="文件不存在") + if not target.is_file(): + raise HTTPException(status_code=400, detail="当前路径是目录") + + file_name = target.name or "download" + media_type = mimetypes.guess_type(file_name)[0] or "application/octet-stream" + headers = {"Content-Disposition": f"attachment; filename*=UTF-8''{quote(file_name)}"} + if target.stat().st_size > 1024 * 1024 * 16: + return FileResponse(path=target, media_type=media_type, headers=headers) + + content = await asyncio.to_thread(target.read_bytes) + return StreamingResponse(io.BytesIO(content), media_type=media_type, headers=headers) diff --git a/backend/server/routers/__init__.py b/backend/server/routers/__init__.py index 86112ec4..0413eea3 100644 --- a/backend/server/routers/__init__.py +++ b/backend/server/routers/__init__.py @@ -15,6 +15,7 @@ from server.routers.tool_router import tools from server.routers.apikey_router import apikey_router from server.routers.filesystem_router import filesystem_router +from server.routers.workspace_router import workspace _LITE_MODE = os.environ.get("LITE_MODE", "").lower() in ("true", "1") @@ -36,6 +37,7 @@ router.include_router(tools) # /api/system/tools/* 工具列表与配置 router.include_router(apikey_router) # /api/apikey/* API Key 管理 router.include_router(filesystem_router) # /api/viewer/filesystem/* 工作台文件系统视图 +router.include_router(workspace) # /api/workspace/* 用户个人工作区 if not _LITE_MODE: from server.routers.graph_router import graph diff --git a/backend/server/routers/workspace_router.py b/backend/server/routers/workspace_router.py new file mode 100644 index 00000000..31c5472d --- /dev/null +++ b/backend/server/routers/workspace_router.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query + +from server.utils.auth_middleware import get_required_user +from yuxi.services.workspace_service import download_workspace_file, list_workspace_tree, read_workspace_file_content +from yuxi.storage.postgres.models_business import User + +workspace = APIRouter(prefix="/workspace", tags=["workspace"]) + + +@workspace.get("/tree", response_model=dict) +async def get_workspace_tree( + path: str = Query("/", description="工作区目录路径"), + current_user: User = Depends(get_required_user), +): + return await list_workspace_tree(path=path, current_user=current_user) + + +@workspace.get("/file", response_model=dict) +async def get_workspace_file( + path: str = Query(..., description="工作区文件路径"), + current_user: User = Depends(get_required_user), +): + return await read_workspace_file_content(path=path, current_user=current_user) + + +@workspace.get("/download") +async def download_workspace( + path: str = Query(..., description="工作区文件路径"), + current_user: User = Depends(get_required_user), +): + return await download_workspace_file(path=path, current_user=current_user) diff --git a/docs/develop-guides/roadmap.md b/docs/develop-guides/roadmap.md index a969a546..6641ac7e 100644 --- a/docs/develop-guides/roadmap.md +++ b/docs/develop-guides/roadmap.md @@ -18,6 +18,8 @@ - 完善 Skills 的环境变量注入 - 拓宽检索的知识源,统一多知识源(channel),目前已知知识库/知识图谱/网页,可拓展:个人知识库、数据库、历史对话等 - 前置任务,多知识库并行检索(扩展 query_kb) + - 新增 query_keywords 工具,专门用于基于关键词命中的排序,也结合词频(和 BM25 的区别?) +- 评估 ### Bugs - 目前的知识库的图片存在公开访问风险 @@ -35,6 +37,7 @@ ### 0.6.2 开发记录 +- 新增个人工作区预览:提供独立于对话 thread 的用户级 workspace 只读 API,并增加“工作区”页面,用于浏览个人 workspace 文件、预览 Markdown/文本/代码/图片/PDF;知识库与团队空间入口先展示到占位层级。 - 扩展管理界面交互逻辑重构:将 MCP / Subagents / Skills 三个标签页从「左侧边栏 + 右侧详情面板」布局重构为「卡片式网格布局 + 路由跳转二级页面」布局,工具标签页改为卡片网格布局 + 弹窗详情(保持弹窗内容不变)。新增共享组件 `ExtensionCard`、`ExtensionCardGrid`、`ExtensionToolbar`、`ExtensionDetailLayout`,详情页(`McpDetailView`、`SubagentDetailView`、`SkillDetailView`)使用居中宽度限制,路由规划为 `/extensions/mcp/:name`、`/extensions/subagent/:name`、`/extensions/skill/:slug`。 - 统一卡片样式:`ExtensionCard` 新增 `tags` prop 支持传入 `[{label, color}]` 数组,内部使用 `` 渲染,与知识库卡片标签风格统一;知识库列表页 `DataBaseView` 改用 `ExtensionCard` + `ExtensionCardGrid` 替代原有自定义卡片,移除冗余 card 样式。 - 调整应用主导航:`AppLayout` 从默认窄栏升级为默认展开的侧边栏,保留折叠态图标导航;侧边栏样式收敛为 14px 文本 + 18px 图标的标准紧凑密度,并统一导航项、任务中心、GitHub、用户信息的图标与文字对齐。折叠态改为仅通过显式按钮展开,避免空白区域误触发。 diff --git a/web/src/apis/workspace_api.js b/web/src/apis/workspace_api.js new file mode 100644 index 00000000..37519b5e --- /dev/null +++ b/web/src/apis/workspace_api.js @@ -0,0 +1,26 @@ +import { apiGet } from './base' + +const buildQuery = (params) => { + const query = new URLSearchParams() + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') { + query.set(key, String(value)) + } + }) + return query.toString() +} + +export const getWorkspaceTree = (path = '/') => { + const query = buildQuery({ path }) + return apiGet(`/api/workspace/tree?${query}`) +} + +export const getWorkspaceFileContent = (path) => { + const query = buildQuery({ path }) + return apiGet(`/api/workspace/file?${query}`) +} + +export const downloadWorkspaceFile = (path) => { + const query = buildQuery({ path }) + return apiGet(`/api/workspace/download?${query}`, {}, true, 'blob') +} diff --git a/web/src/assets/css/main.css b/web/src/assets/css/main.css index df6d4255..16b51757 100644 --- a/web/src/assets/css/main.css +++ b/web/src/assets/css/main.css @@ -39,7 +39,6 @@ body { .layout-container { width: 100%; - padding: 0 var(--page-padding); h2 { margin: 20px 0 10px 0; diff --git a/web/src/components/workspace/WorkspaceFileList.vue b/web/src/components/workspace/WorkspaceFileList.vue new file mode 100644 index 00000000..2f492001 --- /dev/null +++ b/web/src/components/workspace/WorkspaceFileList.vue @@ -0,0 +1,213 @@ + + + + + diff --git a/web/src/components/workspace/WorkspacePreviewPane.vue b/web/src/components/workspace/WorkspacePreviewPane.vue new file mode 100644 index 00000000..3c0b172f --- /dev/null +++ b/web/src/components/workspace/WorkspacePreviewPane.vue @@ -0,0 +1,91 @@ + + + + + diff --git a/web/src/components/workspace/WorkspaceSidebar.vue b/web/src/components/workspace/WorkspaceSidebar.vue new file mode 100644 index 00000000..5d90bfe5 --- /dev/null +++ b/web/src/components/workspace/WorkspaceSidebar.vue @@ -0,0 +1,138 @@ + + + + + diff --git a/web/src/layouts/AppLayout.vue b/web/src/layouts/AppLayout.vue index e6d17ced..f5520446 100644 --- a/web/src/layouts/AppLayout.vue +++ b/web/src/layouts/AppLayout.vue @@ -8,6 +8,7 @@ import { ClipboardList, Blocks, Box, + FolderKanban, PanelLeftClose, PanelLeftOpen, MessageCirclePlus @@ -131,6 +132,13 @@ const mainList = computed(() => { } ] + items.push({ + name: '工作区', + path: '/workspace', + icon: FolderKanban, + activeIcon: FolderKanban + }) + if (userStore.isAdmin) { if (!isLiteMode) { items.push({ diff --git a/web/src/router/index.js b/web/src/router/index.js index 6de4557f..8ab901c6 100644 --- a/web/src/router/index.js +++ b/web/src/router/index.js @@ -51,6 +51,19 @@ const router = createRouter({ } ] }, + { + path: '/workspace', + name: 'workspace', + component: AppLayout, + children: [ + { + path: '', + name: 'WorkspaceComp', + component: () => import('../views/WorkspaceView.vue'), + meta: { keepAlive: true, requiresAuth: true } + } + ] + }, { path: '/graph', name: 'graph', diff --git a/web/src/views/WorkspaceView.vue b/web/src/views/WorkspaceView.vue new file mode 100644 index 00000000..045d0bf7 --- /dev/null +++ b/web/src/views/WorkspaceView.vue @@ -0,0 +1,343 @@ + + + + + + + From 35f09f0bc8681e43a8e7acd48043799baa131e7b Mon Sep 17 00:00:00 2001 From: Wenjie Zhang Date: Thu, 30 Apr 2026 22:07:48 +0800 Subject: [PATCH 02/15] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8C=BA=E6=96=87=E4=BB=B6=E7=AE=A1=E7=90=86=E8=83=BD?= =?UTF-8?q?=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yuxi/services/workspace_service.py | 97 ++++- backend/server/routers/workspace_router.py | 46 +- docs/develop-guides/roadmap.md | 2 +- web/src/apis/workspace_api.js | 21 +- .../workspace/WorkspaceFileList.vue | 192 ++++++++- .../components/workspace/WorkspaceSidebar.vue | 2 +- web/src/views/WorkspaceView.vue | 400 ++++++++++++++++-- 7 files changed, 712 insertions(+), 48 deletions(-) diff --git a/backend/package/yuxi/services/workspace_service.py b/backend/package/yuxi/services/workspace_service.py index b4427b6b..081e218d 100644 --- a/backend/package/yuxi/services/workspace_service.py +++ b/backend/package/yuxi/services/workspace_service.py @@ -1,12 +1,15 @@ from __future__ import annotations import asyncio +import contextlib import io import mimetypes +import shutil from pathlib import Path, PurePosixPath from urllib.parse import quote -from fastapi import HTTPException +import aiofiles +from fastapi import HTTPException, UploadFile from fastapi.responses import FileResponse, StreamingResponse from yuxi.agents.backends.sandbox.paths import _global_user_data_dir @@ -67,6 +70,37 @@ def _sort_entries(entries: list[dict]) -> list[dict]: return sorted(entries, key=lambda item: (not bool(item.get("is_dir")), str(item.get("name") or "").lower())) +def _validate_child_name(name: str, *, field_name: str) -> str: + clean_name = str(name or "").strip() + if not clean_name: + raise HTTPException(status_code=422, detail=f"{field_name} 不能为空") + if clean_name in {".", ".."} or "/" in clean_name or "\\" in clean_name: + raise HTTPException(status_code=422, detail=f"{field_name} 不能包含路径分隔符") + if PurePosixPath(clean_name).name != clean_name: + raise HTTPException(status_code=422, detail=f"{field_name} 不能包含路径分隔符") + return clean_name + + +def _resolve_parent_directory(user: User, parent_path: str) -> Path: + parent = _resolve_workspace_path(user, parent_path) + if not parent.exists(): + raise HTTPException(status_code=404, detail="目标目录不存在") + if not parent.is_dir(): + raise HTTPException(status_code=400, detail="目标路径不是目录") + return parent + + +def _resolve_new_child(root: Path, parent: Path, name: str) -> Path: + target = parent / name + try: + target.resolve(strict=False).relative_to(root) + except ValueError as exc: + raise HTTPException(status_code=403, detail="Access denied") from exc + if target.exists(): + raise HTTPException(status_code=400, detail="同名文件或文件夹已存在") + return target + + def _list_directory(root: Path, target: Path) -> list[dict]: entries = [_entry_for_path(root, child) for child in target.iterdir()] return _sort_entries(entries) @@ -107,6 +141,67 @@ async def read_workspace_file_content(*, path: str, current_user: User) -> dict: } +async def delete_workspace_path(*, path: str, current_user: User) -> dict: + root = _workspace_root(current_user) + target = _resolve_workspace_path(current_user, path) + if target == root: + raise HTTPException(status_code=400, detail="工作区根目录不允许删除") + if not target.exists(): + raise HTTPException(status_code=404, detail="文件不存在") + + try: + if target.is_dir(): + await asyncio.to_thread(shutil.rmtree, target) + else: + await asyncio.to_thread(target.unlink) + except PermissionError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + return {"success": True, "path": _normalize_workspace_path(path).as_posix()} + + +async def create_workspace_directory(*, parent_path: str, name: str, current_user: User) -> dict: + root = _workspace_root(current_user) + directory_name = _validate_child_name(name, field_name="文件夹名") + parent = _resolve_parent_directory(current_user, parent_path) + target = _resolve_new_child(root, parent, directory_name) + + try: + await asyncio.to_thread(target.mkdir) + except FileExistsError as exc: + raise HTTPException(status_code=400, detail="同名文件或文件夹已存在") from exc + except PermissionError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + return {"success": True, "entry": _entry_for_path(root, target)} + + +async def upload_workspace_file(*, parent_path: str, file: UploadFile, current_user: User) -> dict: + root = _workspace_root(current_user) + file_name = _validate_child_name(Path(file.filename or "").name, field_name="文件名") + parent = _resolve_parent_directory(current_user, parent_path) + target = _resolve_new_child(root, parent, file_name) + created_file = False + upload_completed = False + + try: + async with aiofiles.open(target, "xb") as buffer: + created_file = True + while chunk := await file.read(1024 * 1024): + await buffer.write(chunk) + upload_completed = True + except FileExistsError as exc: + raise HTTPException(status_code=400, detail="同名文件或文件夹已存在") from exc + except PermissionError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + finally: + if created_file and not upload_completed and target.exists(): + with contextlib.suppress(OSError): + await asyncio.to_thread(target.unlink) + + return {"success": True, "entry": _entry_for_path(root, target)} + + async def download_workspace_file(*, path: str, current_user: User) -> StreamingResponse | FileResponse: target = _resolve_workspace_path(current_user, path) if not target.exists(): diff --git a/backend/server/routers/workspace_router.py b/backend/server/routers/workspace_router.py index 31c5472d..ba713266 100644 --- a/backend/server/routers/workspace_router.py +++ b/backend/server/routers/workspace_router.py @@ -1,14 +1,27 @@ from __future__ import annotations -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, File, Form, Query, UploadFile +from pydantic import BaseModel from server.utils.auth_middleware import get_required_user -from yuxi.services.workspace_service import download_workspace_file, list_workspace_tree, read_workspace_file_content +from yuxi.services.workspace_service import ( + create_workspace_directory, + delete_workspace_path, + download_workspace_file, + list_workspace_tree, + read_workspace_file_content, + upload_workspace_file, +) from yuxi.storage.postgres.models_business import User workspace = APIRouter(prefix="/workspace", tags=["workspace"]) +class CreateWorkspaceDirectoryRequest(BaseModel): + parent_path: str + name: str + + @workspace.get("/tree", response_model=dict) async def get_workspace_tree( path: str = Query("/", description="工作区目录路径"), @@ -25,6 +38,35 @@ async def get_workspace_file( return await read_workspace_file_content(path=path, current_user=current_user) +@workspace.delete("/file", response_model=dict) +async def delete_workspace_file_route( + path: str = Query(..., description="工作区文件或目录路径"), + current_user: User = Depends(get_required_user), +): + return await delete_workspace_path(path=path, current_user=current_user) + + +@workspace.post("/directory", response_model=dict) +async def create_workspace_directory_route( + payload: CreateWorkspaceDirectoryRequest, + current_user: User = Depends(get_required_user), +): + return await create_workspace_directory( + parent_path=payload.parent_path, + name=payload.name, + current_user=current_user, + ) + + +@workspace.post("/upload", response_model=dict) +async def upload_workspace_file_route( + parent_path: str = Form(..., description="父目录路径"), + file: UploadFile = File(..., description="上传文件"), + current_user: User = Depends(get_required_user), +): + return await upload_workspace_file(parent_path=parent_path, file=file, current_user=current_user) + + @workspace.get("/download") async def download_workspace( path: str = Query(..., description="工作区文件路径"), diff --git a/docs/develop-guides/roadmap.md b/docs/develop-guides/roadmap.md index 6641ac7e..10fd42d1 100644 --- a/docs/develop-guides/roadmap.md +++ b/docs/develop-guides/roadmap.md @@ -37,7 +37,7 @@ ### 0.6.2 开发记录 -- 新增个人工作区预览:提供独立于对话 thread 的用户级 workspace 只读 API,并增加“工作区”页面,用于浏览个人 workspace 文件、预览 Markdown/文本/代码/图片/PDF;知识库与团队空间入口先展示到占位层级。 +- 新增个人工作区预览与管理:提供独立于对话 thread 的用户级 workspace API,并增加“工作区”页面,用于浏览个人 workspace 文件、预览 Markdown/文本/代码/图片/PDF;支持新建文件夹、上传文件、下载文件、删除文件/文件夹和多选删除;知识库与团队空间入口先展示到占位层级。 - 扩展管理界面交互逻辑重构:将 MCP / Subagents / Skills 三个标签页从「左侧边栏 + 右侧详情面板」布局重构为「卡片式网格布局 + 路由跳转二级页面」布局,工具标签页改为卡片网格布局 + 弹窗详情(保持弹窗内容不变)。新增共享组件 `ExtensionCard`、`ExtensionCardGrid`、`ExtensionToolbar`、`ExtensionDetailLayout`,详情页(`McpDetailView`、`SubagentDetailView`、`SkillDetailView`)使用居中宽度限制,路由规划为 `/extensions/mcp/:name`、`/extensions/subagent/:name`、`/extensions/skill/:slug`。 - 统一卡片样式:`ExtensionCard` 新增 `tags` prop 支持传入 `[{label, color}]` 数组,内部使用 `` 渲染,与知识库卡片标签风格统一;知识库列表页 `DataBaseView` 改用 `ExtensionCard` + `ExtensionCardGrid` 替代原有自定义卡片,移除冗余 card 样式。 - 调整应用主导航:`AppLayout` 从默认窄栏升级为默认展开的侧边栏,保留折叠态图标导航;侧边栏样式收敛为 14px 文本 + 18px 图标的标准紧凑密度,并统一导航项、任务中心、GitHub、用户信息的图标与文字对齐。折叠态改为仅通过显式按钮展开,避免空白区域误触发。 diff --git a/web/src/apis/workspace_api.js b/web/src/apis/workspace_api.js index 37519b5e..83a56715 100644 --- a/web/src/apis/workspace_api.js +++ b/web/src/apis/workspace_api.js @@ -1,4 +1,4 @@ -import { apiGet } from './base' +import { apiDelete, apiGet, apiPost } from './base' const buildQuery = (params) => { const query = new URLSearchParams() @@ -20,6 +20,25 @@ export const getWorkspaceFileContent = (path) => { return apiGet(`/api/workspace/file?${query}`) } +export const deleteWorkspacePath = (path) => { + const query = buildQuery({ path }) + return apiDelete(`/api/workspace/file?${query}`) +} + +export const createWorkspaceDirectory = (parentPath, name) => { + return apiPost('/api/workspace/directory', { + parent_path: parentPath, + name + }) +} + +export const uploadWorkspaceFile = (parentPath, file) => { + const formData = new FormData() + formData.append('parent_path', parentPath) + formData.append('file', file) + return apiPost('/api/workspace/upload', formData) +} + export const downloadWorkspaceFile = (path) => { const query = buildQuery({ path }) return apiGet(`/api/workspace/download?${query}`, {}, true, 'blob') diff --git a/web/src/components/workspace/WorkspaceFileList.vue b/web/src/components/workspace/WorkspaceFileList.vue index 2f492001..0373c8c1 100644 --- a/web/src/components/workspace/WorkspaceFileList.vue +++ b/web/src/components/workspace/WorkspaceFileList.vue @@ -13,24 +13,62 @@ {{ currentPath }} - {{ entries.length }} 项 +
+ {{ entries.length }} 项 + + 多选 + + + 删除选中 + +
-
+
+ + + 名称 大小 修改时间 + 操作
- + + + + + + +
@@ -54,17 +121,65 @@