diff --git a/fs-backend/README.md b/fs-backend/README.md new file mode 100644 index 0000000..63086e3 --- /dev/null +++ b/fs-backend/README.md @@ -0,0 +1,68 @@ +# OpenCode Backend + +基于 Python + FastAPI 的后端服务项目 + +## 项目结构 + +``` +OpenCode-backend/ +├── app/ +│ ├── __init__.py +│ ├── main.py # 应用主入口 +│ ├── core/ # 核心配置 +│ │ ├── __init__.py +│ │ └── config.py # 应用配置 +│ ├── routers/ # 路由模块 +│ │ ├── __init__.py +│ │ └── fs.py # 文件系统路由 +│ └── schemas/ # Pydantic 模式 +│ ├── __init__.py +│ └── project.py # 项目相关模式 +├── requirements.txt +└── README.md +``` + +## 快速开始 + +### 1. 安装依赖 + +```bash +pip install -r requirements.txt +``` + +### 2. 启动服务 + +```bash +uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload +``` + +### 3. 访问 API 文档 + +启动后访问:http://localhost:8000/docs + +## API 接口 + +### 创建项目 + +**接口:** `POST /fs/project/create` + +**请求体:** +```json +{ + "project_name": "我的项目" +} +``` + +**响应:** +```json +{ + "success": true, + "message": "项目 '我的项目' 创建成功", + "project_path": "/root/workspace/我的项目" +} +``` + +**校验规则:** +- 项目名称仅允许:中英文、数字、下划线 (_)、横杠 (-) +- 禁止包含路径逃逸字符:`../`、`/`、`\` +- 所有项目被锁定在 `/root/workspace` 目录内 diff --git a/fs-backend/app/__init__.py b/fs-backend/app/__init__.py new file mode 100644 index 0000000..5edfefd --- /dev/null +++ b/fs-backend/app/__init__.py @@ -0,0 +1,7 @@ +""" +包初始化文件 +""" +from app.core.config import settings +from app.routers.fs import router as fs_router + +__all__ = ["settings", "fs_router"] diff --git a/fs-backend/app/core/__init__.py b/fs-backend/app/core/__init__.py new file mode 100644 index 0000000..703883f --- /dev/null +++ b/fs-backend/app/core/__init__.py @@ -0,0 +1,3 @@ +""" +核心模块初始化 +""" diff --git a/fs-backend/app/core/config.py b/fs-backend/app/core/config.py new file mode 100644 index 0000000..405a2e5 --- /dev/null +++ b/fs-backend/app/core/config.py @@ -0,0 +1,28 @@ +""" +应用配置模块 +""" +from pydantic_settings import BaseSettings +from typing import List + + +class Settings(BaseSettings): + """应用配置""" + + # 工作空间基础路径 + BASE_WORKSPACE: str = "/Users/mac/test/workspace" + + # 可通过环境变量覆盖:export BASE_WORKSPACE=/custom/path + + # CORS 配置 + CORS_ORIGINS: List[str] = ["*"] + # CORS_ORIGINS: List[str] = ["http://localhost:5173", "http://127.0.0.1:5173"] + + # 项目名验证正则:仅允许中英文、数字、下划线、横杠 + PROJECT_NAME_PATTERN: str = r"^[\w一-龥\-]+$" + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + + +settings = Settings() diff --git a/fs-backend/app/main.py b/fs-backend/app/main.py new file mode 100644 index 0000000..a6eb2f0 --- /dev/null +++ b/fs-backend/app/main.py @@ -0,0 +1,40 @@ +""" +FastAPI 后端服务主入口 +""" +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.core.config import settings +from app.routers import fs + + +def create_app() -> FastAPI: + """创建并配置 FastAPI 应用实例""" + app = FastAPI( + title="OpenCode Backend API", + description="OpenCode 后端服务接口", + version="1.0.0", + ) + + # 配置 CORS + app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # 注册路由 + app.include_router(fs.router, prefix="/fs", tags=["文件系统"]) + + return app + + +app = create_app() + + +@app.get("/") +async def root(): + """健康检查接口""" + return {"status": "ok", "message": "OpenCode Backend API is running"} diff --git a/fs-backend/app/routers/__init__.py b/fs-backend/app/routers/__init__.py new file mode 100644 index 0000000..f47be39 --- /dev/null +++ b/fs-backend/app/routers/__init__.py @@ -0,0 +1,3 @@ +""" +路由模块初始化 +""" diff --git a/fs-backend/app/routers/fs.py b/fs-backend/app/routers/fs.py new file mode 100644 index 0000000..0f45656 --- /dev/null +++ b/fs-backend/app/routers/fs.py @@ -0,0 +1,472 @@ +""" +文件系统相关路由 +""" +import os +import shutil +import subprocess +from typing import List + +from fastapi import APIRouter, HTTPException, Query + +from app.core.config import settings +from app.schemas.fs import ( + FileContentResponse, + FileContentUpdateRequest, + FileContentUpdateResponse, + FileCreateRequest, + FileOperationResponse, + FileTreeItem, +) +from app.schemas.project import ProjectCreateRequest, ProjectCreateResponse + +router = APIRouter() + + +def _is_git_ignored(directory: str, absolute_path: str) -> bool: + """ + 判断指定路径是否被 git 忽略。 + + 依赖目标目录是否为 git 仓库: + - 若是 git 仓库,使用 `git check-ignore` 判定; + - 若不是 git 仓库或 git 不可用,默认返回 False。 + """ + try: + result = subprocess.run( + ["git", "-C", directory, "check-ignore", absolute_path], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=5, + ) + # git check-ignore 退出码:0 表示被忽略,1 表示未被忽略 + return result.returncode == 0 + except (FileNotFoundError, subprocess.TimeoutExpired): + # git 未安装或超时,按非忽略处理 + return False + + +def _assert_path_allowed(real_directory: str, real_target: str) -> None: + """ + 确保解析后的真实路径在允许范围内。 + + 允许范围: + - 项目目录 real_directory 内(常规文件访问); + - BASE_WORKSPACE 工作空间内(允许 tables_meta 这类指向工作空间根的共享软链接)。 + + 这样既阻止通过 `..` 或外部软链接逃逸出工作空间,又允许项目内合法的共享软链接。 + """ + real_workspace = os.path.realpath(settings.BASE_WORKSPACE) + within_dir = real_target == real_directory or real_target.startswith(real_directory + os.sep) + within_workspace = real_target == real_workspace or real_target.startswith(real_workspace + os.sep) + if not (within_dir or within_workspace): + raise HTTPException( + status_code=400, + detail="非法的查询路径,检测到路径逃逸尝试" + ) + + +@router.get("/file", response_model=List[FileTreeItem]) +async def list_file_tree( + directory: str = Query(..., description="项目顶层绝对目录(URL 编码传输)"), + path: str = Query(..., description="相对于 directory 的查询路径,. 表示根目录"), +): + """ + 查询指定目录下的文件树列表 + + - directory:项目顶层绝对目录,固定值(如 /root/workspace/test) + - path:相对查询路径 + - 传 `.`:读取 directory 根目录下所有文件 / 文件夹 + - 传 `文件夹名/`(如 `ningxia/`):读取 directory/文件夹名 子目录内文件树 + """ + # 规范化 directory:去除尾部斜杠 + directory = os.path.normpath(directory) + + # 安全校验:directory 必须存在且为目录 + if not os.path.isdir(directory): + raise HTTPException( + status_code=400, + detail=f"目录不存在或不是文件夹:{directory}" + ) + + # 规范化 path:仅允许 `.` 或以 `/` 结尾的相对子路径 + # 处理 path 为 `.` 的根目录情形 + if path == "." or path == "": + target_dir = directory + else: + # 去除首尾空白,确保以 / 结尾(约定:文件夹末尾带 /) + rel_path = path.strip() + if not rel_path.endswith("/"): + rel_path = rel_path + "/" + # 去除结尾的 / 用于拼接 + rel_path_clean = rel_path.rstrip("/") + # 禁止路径逃逸 + if ".." in rel_path_clean.split("/"): + raise HTTPException( + status_code=400, + detail="非法的相对路径,禁止路径逃逸 (..)" + ) + # 禁止绝对路径 + if os.path.isabs(rel_path_clean): + raise HTTPException( + status_code=400, + detail="相对路径 path 不能为绝对路径" + ) + target_dir = os.path.join(directory, rel_path_clean) + + # 解析真实物理路径,确保最终路径在允许范围内(防逃逸,允许工作空间内软链接) + real_directory = os.path.realpath(directory) + real_target = os.path.realpath(target_dir) + _assert_path_allowed(real_directory, real_target) + + if not os.path.isdir(real_target): + raise HTTPException( + status_code=404, + detail=f"查询的子目录不存在:{path}" + ) + + # 计算 path 前缀(基于 directory 的相对路径,用于拼接每个条目的 path) + # path 为 `.` 时前缀为空;否则前缀为 `文件夹名/` + if path == "." or path == "": + rel_prefix = "" + else: + rel_prefix = path.strip() + if not rel_prefix.endswith("/"): + rel_prefix = rel_prefix + "/" + + items: List[FileTreeItem] = [] + try: + entries = sorted(os.listdir(real_target)) + except OSError as e: + raise HTTPException( + status_code=500, + detail=f"读取目录失败:{str(e)}" + ) + + for entry in entries: + abs_path = os.path.join(real_target, entry) + is_dir = os.path.isdir(abs_path) + + # 基于 directory 的相对路径:文件夹末尾带 /,文件不带 + rel_path = rel_prefix + entry + ("/" if is_dir else "") + + items.append( + FileTreeItem( + name=entry, + path=rel_path, + absolute=abs_path, + type="directory" if is_dir else "file", + ignored=_is_git_ignored(directory, abs_path), + ) + ) + + return items + + +def _resolve_file_path(directory: str, path: str): + """ + 解析并校验「相对文件路径」对应的真实物理路径。 + + 校验规则: + - directory 必须存在且为目录 + - path 必须指向具体文件,不能为根目录(. / 空) + - 禁止路径逃逸(..)与绝对路径 + - realpath 解析后必须仍位于 directory 内(防符号链接逃逸) + + 返回:(real_directory, real_target, rel_path) + """ + # 规范化 directory + directory = os.path.normpath(directory) + if not os.path.isdir(directory): + raise HTTPException( + status_code=400, + detail=f"目录不存在或不是文件夹:{directory}" + ) + + rel_path = path.strip().rstrip("/") + # 仅允许相对路径,禁止路径逃逸 + if rel_path in ("", "."): + raise HTTPException( + status_code=400, + detail="path 必须指向具体文件,不能为根目录" + ) + if ".." in rel_path.split("/"): + raise HTTPException( + status_code=400, + detail="非法的相对路径,禁止路径逃逸 (..)" + ) + if os.path.isabs(rel_path): + raise HTTPException( + status_code=400, + detail="相对路径 path 不能为绝对路径" + ) + + target = os.path.join(directory, rel_path) + + # 解析真实物理路径,确保最终路径在允许范围内(防逃逸,允许工作空间内软链接) + real_directory = os.path.realpath(directory) + real_target = os.path.realpath(target) + _assert_path_allowed(real_directory, real_target) + + return real_directory, real_target, rel_path + + +@router.get("/file/content", response_model=FileContentResponse) +async def get_file_content( + directory: str = Query(..., description="项目顶层绝对目录(URL 编码传输)"), + path: str = Query(..., description="相对于 directory 的文件路径,如 appfile/main.py"), +): + """ + 预览文件内容 + + - directory:项目顶层绝对目录 + - path:相对于 directory 的文件路径(不带尾部 /,指向具体文件) + - 返回文本文件内容;二进制文件返回 type=binary 且 content 为空 + """ + _real_directory, real_target, _rel_path = _resolve_file_path(directory, path) + + if not os.path.exists(real_target): + raise HTTPException( + status_code=404, + detail=f"文件不存在:{path}" + ) + if os.path.isdir(real_target): + raise HTTPException( + status_code=400, + detail=f"目标路径是文件夹,不是文件:{path}" + ) + + # 限制预览文件大小,避免读取超大文件 + max_size = 5 * 1024 * 1024 # 5MB + try: + file_size = os.path.getsize(real_target) + if file_size > max_size: + raise HTTPException( + status_code=413, + detail=f"文件过大,无法预览({file_size} 字节,上限 {max_size} 字节)" + ) + except OSError as e: + raise HTTPException( + status_code=500, + detail=f"读取文件信息失败:{str(e)}" + ) + + # 先按二进制读取前若干字节判断是否为文本 + try: + with open(real_target, "rb") as f: + chunk = f.read(1024) + # 含 NUL 字节通常视为二进制 + if b"\x00" in chunk: + return FileContentResponse(type="binary", content="") + + # 尝试以 UTF-8 解码全文 + with open(real_target, "r", encoding="utf-8") as f: + content = f.read() + return FileContentResponse(type="text", content=content) + except UnicodeDecodeError: + # 非 UTF-8 文本,按二进制处理 + return FileContentResponse(type="binary", content="") + except OSError as e: + raise HTTPException( + status_code=500, + detail=f"读取文件失败:{str(e)}" + ) + + +@router.put("/file/content", response_model=FileContentUpdateResponse) +async def update_file_content( + request: FileContentUpdateRequest, + directory: str = Query(..., description="项目顶层绝对目录(URL 编码传输)"), + path: str = Query(..., description="相对于 directory 的文件路径,如 appfile/main.py"), +): + """ + 修改(覆盖写入)文件内容 + + - directory:项目顶层绝对目录 + - path:相对于 directory 的文件路径(不带尾部 /,指向具体文件) + - 请求体 content:文件新内容(UTF-8 文本,整文件覆盖写入) + """ + _real_directory, real_target, rel_path = _resolve_file_path(directory, path) + + if not os.path.exists(real_target): + raise HTTPException( + status_code=404, + detail=f"文件不存在:{path}" + ) + if os.path.isdir(real_target): + raise HTTPException( + status_code=400, + detail=f"目标路径是文件夹,不是文件:{path}" + ) + + try: + # 整文件覆盖写入(UTF-8) + with open(real_target, "w", encoding="utf-8") as f: + f.write(request.content) + except OSError as e: + raise HTTPException( + status_code=500, + detail=f"写入文件失败:{str(e)}" + ) + + return FileContentUpdateResponse( + success=True, + message="文件已保存", + path=rel_path, + absolute=real_target, + ) + + +@router.post("/file", response_model=FileOperationResponse) +async def create_file_entry( + request: FileCreateRequest, + directory: str = Query(..., description="项目顶层绝对目录(URL 编码传输)"), + path: str = Query(..., description="相对于 directory 的新建项路径,如 appfile/new.py"), +): + """ + 新建文件 / 文件夹 + + - directory:项目顶层绝对目录 + - path:相对于 directory 的新建项路径(不带尾部 /) + - 请求体 type:file / directory;type=file 时可用 content 指定初始内容 + - 目标已存在返回 409;新建文件时会自动创建所需父目录 + """ + _real_directory, real_target, rel_path = _resolve_file_path(directory, path) + + if os.path.exists(real_target): + raise HTTPException( + status_code=409, + detail=f"目标已存在:{path}" + ) + + item_type = request.type + try: + if item_type == "directory": + os.makedirs(real_target, exist_ok=False) + else: + # 文件:确保父目录存在后写入初始内容 + parent_dir = os.path.dirname(real_target) + if parent_dir: + os.makedirs(parent_dir, exist_ok=True) + with open(real_target, "w", encoding="utf-8") as f: + f.write(request.content or "") + except OSError as e: + raise HTTPException( + status_code=500, + detail=f"新建失败:{str(e)}" + ) + + return FileOperationResponse( + success=True, + message=f"{'文件夹' if item_type == 'directory' else '文件'}已创建", + path=rel_path, + absolute=real_target, + type=item_type, + ) + + +@router.delete("/file", response_model=FileOperationResponse) +async def delete_file_entry( + directory: str = Query(..., description="项目顶层绝对目录(URL 编码传输)"), + path: str = Query(..., description="相对于 directory 的待删除项路径"), +): + """ + 删除文件 / 文件夹 + + - directory:项目顶层绝对目录 + - path:相对于 directory 的待删除项路径(文件夹可带尾部 /) + - 文件夹递归删除;不存在返回 404 + - path 不能为根目录(.),不会删除 directory 本身 + """ + _real_directory, real_target, rel_path = _resolve_file_path(directory, path) + + if not os.path.exists(real_target): + raise HTTPException( + status_code=404, + detail=f"目标不存在:{path}" + ) + + is_dir = os.path.isdir(real_target) + try: + if is_dir: + shutil.rmtree(real_target) + else: + os.remove(real_target) + except OSError as e: + raise HTTPException( + status_code=500, + detail=f"删除失败:{str(e)}" + ) + + return FileOperationResponse( + success=True, + message=f"{'文件夹' if is_dir else '文件'}已删除", + path=rel_path, + absolute=real_target, + type="directory" if is_dir else "file", + ) + + +@router.post("/project/create", response_model=ProjectCreateResponse) +async def create_project(request: ProjectCreateRequest): + """ + 创建新项目 + + - 校验项目名格式 + - 拼接完整路径 + - 确保路径在安全目录内 + - 创建项目目录 + - 软链接 BASE_WORKSPACE/tables_meta 到新项目下(共享表元数据) + """ + project_name = request.project_name + + # 二次校验:禁止路径逃逸字符(防御性编程) + if ".." in project_name or "/" in project_name or "\\" in project_name: + raise HTTPException( + status_code=400, + detail="项目名称不能包含路径逃逸字符 (../ 或 / 或 \\)" + ) + + # 拼接完整路径 + project_path = os.path.join(settings.BASE_WORKSPACE, project_name) + + # 安全校验:确保最终路径在 base_workspace 内 + real_workspace = os.path.realpath(settings.BASE_WORKSPACE) + real_project_path = os.path.realpath(os.path.dirname(project_path)) + + # 检查路径是否逃逸 + if not real_project_path.startswith(real_workspace): + raise HTTPException( + status_code=400, + detail="非法的项目路径,检测到路径逃逸尝试" + ) + + try: + # 创建项目目录 + os.makedirs(project_path, exist_ok=True) + except OSError as e: + raise HTTPException( + status_code=500, + detail=f"创建项目目录失败:{str(e)}" + ) + + # 软链接 BASE_WORKSPACE/tables_meta 到新项目下(共享表元数据) + # 使用相对路径,便于工作空间整体迁移;已存在则不覆盖 + tables_meta_src = os.path.join(settings.BASE_WORKSPACE, "tables_meta") + tables_meta_link = os.path.join(project_path, "tables_meta") + symlink_note = "" + try: + if os.path.lexists(tables_meta_link): + # 已存在(可能是历史软链接或实际文件),不覆盖 + pass + elif not os.path.exists(tables_meta_src): + symlink_note = "(未找到 tables_meta 源,已跳过软链接)" + else: + rel_src = os.path.relpath(tables_meta_src, project_path) + os.symlink(rel_src, tables_meta_link) + except OSError as e: + symlink_note = f"(软链接 tables_meta 失败:{str(e)})" + + return ProjectCreateResponse( + success=True, + message=f"项目 '{project_name}' 创建成功{symlink_note}", + project_path=project_path + ) diff --git a/fs-backend/app/schemas/__init__.py b/fs-backend/app/schemas/__init__.py new file mode 100644 index 0000000..9f1bee6 --- /dev/null +++ b/fs-backend/app/schemas/__init__.py @@ -0,0 +1,3 @@ +""" +数据模式模块初始化 +""" diff --git a/fs-backend/app/schemas/fs.py b/fs-backend/app/schemas/fs.py new file mode 100644 index 0000000..981a9f1 --- /dev/null +++ b/fs-backend/app/schemas/fs.py @@ -0,0 +1,61 @@ +""" +文件系统相关 Pydantic 模式定义 +""" +from typing import Literal, Optional + +from pydantic import BaseModel, Field + + +class FileTreeItem(BaseModel): + """文件树单个条目(文件 / 文件夹)""" + + name: str = Field(..., description="文件 / 文件夹展示名称") + path: str = Field( + ..., + description="基于 directory 的相对路径;文件夹末尾带 /,文件不带", + ) + absolute: str = Field(..., description="服务器完整绝对物理路径") + type: str = Field(..., description="资源类型:directory / file") + ignored: bool = Field(..., description="是否属于被忽略的文件(如 git 忽略文件)") + + +class FileContentResponse(BaseModel): + """文件内容预览响应""" + + type: str = Field( + ..., + description="内容类型:text(文本,可预览)/ binary(二进制,不可直接预览)", + ) + content: str = Field(..., description="文件文本内容;二进制文件为空字符串") + + +class FileContentUpdateRequest(BaseModel): + """文件内容修改请求""" + + content: str = Field(..., description="文件新内容(UTF-8 文本)") + + +class FileContentUpdateResponse(BaseModel): + """文件内容修改响应""" + + success: bool = True + message: str = Field(..., description="操作结果说明") + path: str = Field(..., description="基于 directory 的相对文件路径") + absolute: str = Field(..., description="服务器完整绝对物理路径") + + +class FileCreateRequest(BaseModel): + """文件 / 文件夹新建请求""" + + type: Literal["file", "directory"] = Field(..., description="新建类型:file / directory") + content: Optional[str] = Field(default=None, description="文件初始内容(仅 type=file 生效)") + + +class FileOperationResponse(BaseModel): + """文件操作(新建 / 删除)响应""" + + success: bool = True + message: str = Field(..., description="操作结果说明") + path: str = Field(..., description="基于 directory 的相对路径") + absolute: str = Field(..., description="服务器完整绝对物理路径") + type: str = Field(..., description="资源类型:directory / file") diff --git a/fs-backend/app/schemas/project.py b/fs-backend/app/schemas/project.py new file mode 100644 index 0000000..8620c14 --- /dev/null +++ b/fs-backend/app/schemas/project.py @@ -0,0 +1,38 @@ +""" +Pydantic 模式定义 +""" +from pydantic import BaseModel, Field, field_validator +import re + + +class ProjectCreateRequest(BaseModel): + """项目创建请求""" + + project_name: str = Field(..., description="项目文件夹名称", min_length=1, max_length=100) + + @field_validator("project_name") + @classmethod + def validate_project_name(cls, v: str) -> str: + """ + 验证项目名: + - 仅允许中英文、数字、下划线、横杠 + - 禁止路径逃逸字符 + """ + # 检查是否包含路径逃逸字符 + if ".." in v or "/" in v or "\\" in v: + raise ValueError("项目名称不能包含路径逃逸字符 (../ 或 / 或 \\)") + + # 验证项目名称格式:仅允许中英文、数字、下划线、横杠 + pattern = r"^[\w一-龥\-]+$" + if not re.match(pattern, v): + raise ValueError("项目名称仅允许包含中英文、数字、下划线、横杠") + + return v + + +class ProjectCreateResponse(BaseModel): + """项目创建响应""" + + success: bool = True + message: str + project_path: str diff --git a/fs-backend/docs/前端连接指南.md b/fs-backend/docs/前端连接指南.md new file mode 100644 index 0000000..c867c94 --- /dev/null +++ b/fs-backend/docs/前端连接指南.md @@ -0,0 +1,188 @@ +# 前后端连接指南 + +## 前端配置方式 + +### 1. 环境变量配置(推荐) + +前端项目使用 `.env` 文件配置后端地址: + +```bash +# OpenCodeUI/.env +VITE_API_BASE_URL=http://localhost:8000 +``` + +### 2. 修改位置 + +前端项目中的 API 地址配置文件: +- **文件路径**: `OpenCodeUI/src/constants/api.ts` +- **配置项**: `API_BASE_URL` + +```typescript +export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://127.0.0.1:4096' +``` + +## 添加项目创建接口到前端 + +### 步骤 1: 创建 API 调用函数 + +在 `OpenCodeUI/src/api/` 目录下创建 `project.ts`: + +```typescript +// OpenCodeUI/src/api/project.ts + +import { getApiBaseUrl, getAuthHeader } from './http' + +export interface ProjectCreateRequest { + project_name: string +} + +export interface ProjectCreateResponse { + success: boolean + message: string + project_path: string +} + +/** + * 创建新项目 + * POST /fs/project/create + */ +export async function createProject( + params: ProjectCreateRequest +): Promise { + const baseUrl = getApiBaseUrl() + const url = `${baseUrl}/fs/project/create` + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...getAuthHeader(), + }, + body: JSON.stringify(params), + }) + + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: '请求失败' })) + throw new Error(error.detail || response.statusText) + } + + return response.json() +} +``` + +### 步骤 2: 在 React 组件中使用 + +```tsx +// OpenCodeUI/src/components/ProjectCreate.tsx + +import React, { useState } from 'react' +import { createProject } from '@/api/project' + +export function ProjectCreate() { + const [projectName, setProjectName] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + setError('') + + try { + const result = await createProject({ project_name: projectName }) + alert(result.message) + setProjectName('') + } catch (err) { + setError(err instanceof Error ? err.message : '创建失败') + } finally { + setLoading(false) + } + } + + return ( +
+ setProjectName(e.target.value)} + placeholder="输入项目名称" + /> + + {error &&

{error}

} +
+ ) +} +``` + +## 本地开发联调 + +### 1. 启动后端服务 + +```bash +cd OpenCode-backend +pip install -r requirements.txt +uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload +``` + +### 2. 启动前端开发服务器 + +```bash +cd OpenCodeUI +# 确保 .env 文件中配置了正确的后端地址 +echo "VITE_API_BASE_URL=http://localhost:8000" > .env.local +npm install +npm run dev +``` + +### 3. 跨域配置(如需要) + +后端已配置 CORS,允许所有来源访问。如需限制,修改: + +```python +# OpenCode-backend/app/core/config.py +class Settings(BaseSettings): + CORS_ORIGINS: List[str] = ["http://localhost:5173", "http://127.0.0.1:5173"] +``` + +## API 接口列表 + +| 接口 | 方法 | 地址 | 描述 | +|------|------|------|------| +| 创建项目 | POST | `/fs/project/create` | 创建新项目目录 | +| 健康检查 | GET | `/` | 服务状态检查 | + +## 请求示例 + +### cURL + +```bash +curl -X POST "http://localhost:8000/fs/project/create" \ + -H "Content-Type: application/json" \ + -d '{"project_name": "my_project"}' +``` + +### 成功响应 + +```json +{ + "success": true, + "message": "项目 'my_project' 创建成功", + "project_path": "/root/workspace/my_project" +} +``` + +### 错误响应 + +```json +{ + "detail": "项目名称仅允许包含中英文、数字、下划线、横杠" +} +``` + +## 安全说明 + +1. **路径安全**: 后端已锁定所有文件操作在 `/root/workspace` 目录内 +2. **输入校验**: 项目名经过正则校验,禁止路径逃逸字符 +3. **CORS**: 生产环境建议配置具体的允许来源 diff --git a/fs-backend/requirements.txt b/fs-backend/requirements.txt new file mode 100644 index 0000000..149e6e8 --- /dev/null +++ b/fs-backend/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +pydantic==2.9.0 +pydantic-settings==2.5.2