From dd6f176ca8dc0aec5dab96cf9e50338984cdbad3 Mon Sep 17 00:00:00 2001
From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com>
Date: Tue, 11 Aug 2026 21:26:55 +0800
Subject: [PATCH] fix: file lock
---
API.md | 46 +++-
backend/src/backend/schemas.py | 9 +-
backend/src/backend/scripts.py | 204 ++++++++++++------
frontend/app/components/common/Icon.tsx | 14 ++
.../components/platform/ScriptExplorer.tsx | 64 ++++--
.../components/platform/TreeContextMenu.tsx | 15 +-
frontend/app/context/AuthContext.tsx | 2 +
.../app/features/platform/ScriptsPage.tsx | 4 +-
.../app/features/platform/WorkspaceTree.tsx | 27 ++-
.../platform/state/scriptWorkspaceStore.ts | 23 ++
frontend/app/services/api.ts | 20 ++
frontend/app/styles/platform.css | 8 +
frontend/vite.config.ts | 6 +
13 files changed, 345 insertions(+), 97 deletions(-)
diff --git a/API.md b/API.md
index 611fe51..fd93dc4 100644
--- a/API.md
+++ b/API.md
@@ -75,8 +75,9 @@
### 3.1 `GET /api/v1/workspace-tree`
-列出当前用户在 workspace 内的**目录树**(从 `StorageObjects.relative_path` 派生)。
+列出当前用户在 workspace 内的**目录树**。
+- **来源**: 显式 `StorageObjects` 行 (`object_type='directory'`,见 §3.2) **并入** 从 `Scripts.relative_path` 派生的祖先目录,**去重**。空目录(只有显式行、没有文件)也会出现。
- **鉴权**: workspace 成员
- **请求体**: 无
- **响应**:
@@ -95,7 +96,7 @@
### 3.2 `POST /api/v1/workspace-directories`
-创建一个**逻辑目录**(对象存储上是隐式前缀,无需落对象)。
+创建一个**目录**。后端会落一行 `StorageObjects(object_type='directory', storage_backend='rustfs', storage_uri='inline://directory/{path}', visibility='private')`,因此空目录也能在 §3.1 树里出现并保留下来。
- **请求体**:
```json
@@ -104,11 +105,23 @@
"parent_path": "scripts"
}
```
+ | 字段 | 必填 | 说明 |
+ |---|---|---|
+ | `directory_name` | 是 | 目录名(单段,不能含 `/`) |
+ | `parent_path` | 否 | 父目录相对路径,空字符串或缺省表示用户根目录 |
+
+- **父目录存在性校验**: 必须在 `StorageObjects` 存在 `relative_path == scoped_prefix/{parent}` 的行,或 `relative_path` 以 `scoped_prefix/{parent}/` 开头。否则 **404**。
+- **同名冲突**: 已有 `relative_path` 完全相等的行(无论 file / directory) → **409**。`uk_storage_workspace_path(workspace_id, storage_backend, path_hash)` 唯一索引保证幂等。
- **响应 201**:
```json
{
"request_id": "...",
- "data": {"path": "scripts/etl", "name": "etl", "parent_path": "scripts"}
+ "data": {
+ "storage_object_id": "01HXY...",
+ "path": "scripts/etl",
+ "name": "etl",
+ "parent_path": "scripts"
+ }
}
```
@@ -199,6 +212,7 @@ multipart/binary 形式上传大文件(走 server-proxied PUT,详见 §九)。
| `script_type` | `python` \| `notebook` | |
| `visibility` | enum | |
| `status` | `active` \| `deleted` | |
+| `is_locked` | boolean | 是否锁定。`PUT /scripts/{id}`(§3.8) 受此字段门禁;可通过 §3.16 切换 |
| `relative_path` | string \| null | 例如 `users/alice/scripts/etl/train.py` |
| `content_hash` | string \| null | SHA-256 十六进制 |
| `size_bytes` | int | |
@@ -268,6 +282,32 @@ multipart/binary 形式上传大文件(走 server-proxied PUT,详见 §九)。
}
```
+### 3.16 `PATCH /api/v1/scripts/{script_id}/lock`
+
+切换脚本的 `is_locked` 状态。**只切换锁**,不修改脚本内容。
+
+- **鉴权**: admin 或 `owner_user_id == 当前用户`(沿用 `require_script_modify_access`)。其余一律 **404**。
+- **请求体**:
+ ```json
+ {"is_locked": true}
+ ```
+ | 字段 | 必填 | 说明 |
+ |---|---|---|
+ | `is_locked` | 是 | 目标状态。`true` 锁定;`false` 解锁 |
+- **响应 200**: `data` 为更新后的 `ScriptPayload`(§3.10)。
+ ```json
+ {
+ "request_id": "...",
+ "data": { "...ScriptPayload 字段...": "is_locked: false" },
+ "meta": {}
+ }
+ ```
+- **行为**: 行级锁 (`SELECT ... FOR UPDATE`) 防止并发切换;提交后立即生效,影响后续 §3.8 `PUT /scripts/{id}` 的门禁判定。
+- **错误码**:
+ | 码 | 含义 |
+ |---|---|
+ | 404 | 脚本不存在 / 非当前用户无权访问(不区分,避免暴露存在性) |
+
---
## 四、调度
diff --git a/backend/src/backend/schemas.py b/backend/src/backend/schemas.py
index a17900b..8a8b55b 100644
--- a/backend/src/backend/schemas.py
+++ b/backend/src/backend/schemas.py
@@ -1,10 +1,7 @@
-from __future__ import annotations
-
from typing import Literal
-from pydantic import Field, field_validator
-
from common.schemas import StrictModel
+from pydantic import Field, field_validator
class CreateResourceUploadRequest(StrictModel):
@@ -47,6 +44,10 @@ class UpdateScriptRequest(StrictModel):
content: str = Field(max_length=10 * 1024 * 1024)
+class LockScriptRequest(StrictModel):
+ is_locked: bool
+
+
class PublishVersionRequest(StrictModel):
source_object_id: str | None = Field(
default=None,
diff --git a/backend/src/backend/scripts.py b/backend/src/backend/scripts.py
index 24734d3..0192473 100644
--- a/backend/src/backend/scripts.py
+++ b/backend/src/backend/scripts.py
@@ -1,5 +1,3 @@
-from __future__ import annotations
-
import asyncio
import base64
import hashlib
@@ -8,7 +6,15 @@ import mimetypes
from datetime import UTC, datetime
from pathlib import PurePosixPath
from typing import Any
-from loguru import logger
+
+from common.config import settings
+from common.db.models import (
+ Scripts,
+ StorageObjects,
+ Versions,
+)
+from common.ids import new_ulid
+from common.storage.schemas import ServerObjectRequest
from fastapi import (
APIRouter,
BackgroundTasks,
@@ -19,34 +25,29 @@ from fastapi import (
Request,
status,
)
+from loguru import logger
from sqlalchemy import func, select
+from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
-from common.config import settings
-from common.db.models import (
- Scripts,
- StorageObjects,
- Versions,
-)
-from common.ids import new_ulid
from backend.dependencies import (
RequestContext,
database_session,
request_context,
)
from backend.runtime_client import RuntimeClientError
-from backend.services.storage import (
- create_download_url_payload,
- create_server_object_payload,
-)
-from common.storage.schemas import ServerObjectRequest
from backend.schemas import (
CreateScriptRequest,
CreateWorkspaceDirectoryRequest,
DownloadUrlRequest,
+ LockScriptRequest,
PublishVersionRequest,
UpdateScriptRequest,
)
+from backend.services.storage import (
+ create_download_url_payload,
+ create_server_object_payload,
+)
router = APIRouter(tags=["scripts"])
@@ -170,7 +171,7 @@ def script_payload(
size_bytes = storage_object.size_bytes
workspace_prefix = f"{script.workspace_id}/"
jupyter_path = (
- object_key[len(workspace_prefix):]
+ object_key[len(workspace_prefix) :]
if object_key and object_key.startswith(workspace_prefix)
else object_key
)
@@ -183,6 +184,7 @@ def script_payload(
"script_type": script.script_type,
"visibility": script.visibility,
"status": script.status,
+ "is_locked": bool(script.is_locked),
"relative_path": relative_path,
"jupyter_path": jupyter_path,
"content_hash": content_hash,
@@ -249,8 +251,7 @@ async def get_script_row(
select(Scripts, StorageObjects)
.join(
StorageObjects,
- StorageObjects.storage_object_id
- == Scripts.current_object_id,
+ StorageObjects.storage_object_id == Scripts.current_object_id,
)
.where(
Scripts.script_id == script_id,
@@ -304,8 +305,10 @@ async def create_script_record(
session: AsyncSession,
) -> tuple[Scripts, dict[str, Any]]:
folder = (
- "scripts" if script_type == "python" else "notebooks"
- ) if parent_path is None else normalize_user_path(parent_path)
+ ("scripts" if script_type == "python" else "notebooks")
+ if parent_path is None
+ else normalize_user_path(parent_path)
+ )
child_path = f"{folder}/{name}" if folder else name
relative_path = user_relative_path(context, child_path)
logger.debug(relative_path)
@@ -352,9 +355,7 @@ async def create_script_record(
workspace_id,
name=jupyter_name,
content=content.decode("utf-8"),
- content_type=(
- mimetypes.guess_type(jupyter_name)[0] or "text/plain"
- ),
+ content_type=(mimetypes.guess_type(jupyter_name)[0] or "text/plain"),
)
logger.debug(jupyter_resp)
except RuntimeClientError as exc:
@@ -512,10 +513,11 @@ async def get_workspace_tree(
context: RequestContext = Depends(request_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
- # Workspace object storage uses implicit directories (object key prefixes),
- # so we derive the tree from ``StorageObjects.relative_path`` rather than
- # walking a local filesystem. Only paths that start with the user's
- # scoped prefix (and that are currently active) contribute.
+ # Workspace object storage uses implicit directories (object key prefixes)
+ # plus explicit directory rows, so we derive the tree from
+ # ``StorageObjects.relative_path`` and ``StorageObjects.object_type``
+ # rather than walking a local filesystem. Only paths that start with the
+ # user's scoped prefix (and that are currently active) contribute.
scoped_prefix = user_relative_path(context)
if scoped_prefix:
like_prefix = f"{scoped_prefix}%"
@@ -524,17 +526,16 @@ async def get_workspace_tree(
rows = (
await session.execute(
- select(StorageObjects.relative_path)
- .where(
+ select(StorageObjects.relative_path, StorageObjects.object_type).where(
StorageObjects.workspace_id == context.workspace.workspace_id,
StorageObjects.object_status == "available",
StorageObjects.relative_path.like(like_prefix),
)
)
- ).scalars().all()
+ ).all()
directories: dict[str, dict[str, str]] = {}
- for relative in rows:
+ for relative, object_type in rows:
if not relative:
continue
# Strip the scoped prefix so the returned paths are workspace-local.
@@ -544,7 +545,19 @@ async def get_workspace_tree(
continue
else:
trimmed = relative
- # Materialise every ancestor directory of the file.
+ # Explicit directory rows are included directly, then we still
+ # materialise every ancestor directory.
+ if object_type == "directory" and trimmed:
+ directories.setdefault(
+ trimmed,
+ {
+ "path": trimmed,
+ "name": trimmed.rsplit("/", 1)[-1],
+ "parent_path": ""
+ if "/" not in trimmed
+ else trimmed.rsplit("/", 1)[0],
+ },
+ )
parts = trimmed.split("/")[:-1]
for index in range(1, len(parts) + 1):
directory_path = "/".join(parts[:index])
@@ -579,13 +592,16 @@ async def create_workspace_directory(
relative_path = user_relative_path(context, child_path)
scoped_prefix = user_relative_path(context)
# Validate parent exists: there must be at least one StorageObject whose
- # relative_path is exactly the parent directory (or its prefix).
+ # relative_path is exactly the parent directory (the directory row itself)
+ # OR lives somewhere below the parent (any file/dir nested under it).
if parent:
+ parent_relative = f"{scoped_prefix}/{parent}"
existing_parent = await session.scalar(
select(StorageObjects.storage_object_id).where(
StorageObjects.workspace_id == context.workspace.workspace_id,
StorageObjects.object_status == "available",
- StorageObjects.relative_path.like(f"{scoped_prefix}%"),
+ (StorageObjects.relative_path == parent_relative)
+ | StorageObjects.relative_path.like(f"{parent_relative}/%"),
)
)
if existing_parent is None:
@@ -607,9 +623,32 @@ async def create_workspace_directory(
status.HTTP_409_CONFLICT,
"a file or directory with the same path already exists",
)
+ directory = StorageObjects(
+ storage_object_id=new_ulid(),
+ workspace_id=context.workspace.workspace_id,
+ object_type="directory",
+ usage_type="working_copy",
+ storage_backend="rustfs",
+ storage_uri=f"inline://directory/{relative_path}",
+ relative_path=relative_path,
+ path_hash=hashlib.sha256(relative_path.encode("utf-8")).digest(),
+ object_status="available",
+ size_bytes=0,
+ visibility="private",
+ created_by=context.user.user_id,
+ )
+ session.add(directory)
+ try:
+ await session.flush()
+ except IntegrityError as exc:
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ "directory already exists",
+ ) from exc
return {
"request_id": context.request_id,
"data": {
+ "storage_object_id": directory.storage_object_id,
"path": child_path,
"name": name,
"parent_path": parent,
@@ -636,15 +675,18 @@ async def delete_workspace_directory(
# to the runtime. ``directory_path`` is still returned in the
# response for API compatibility.
rows = (
- await session.execute(
- select(Scripts)
- .where(
- Scripts.workspace_id == context.workspace.workspace_id,
- Scripts.owner_user_id == context.user.user_id,
- Scripts.status == "active",
+ (
+ await session.execute(
+ select(Scripts).where(
+ Scripts.workspace_id == context.workspace.workspace_id,
+ Scripts.owner_user_id == context.user.user_id,
+ Scripts.status == "active",
+ )
)
)
- ).scalars().all()
+ .scalars()
+ .all()
+ )
runtime_client = request.app.state.runtime_client
workspace_id = context.workspace.workspace_id
for script in rows:
@@ -700,8 +742,7 @@ async def list_scripts(
return {
"request_id": context.request_id,
"data": [
- script_payload(script, storage_object)
- for script, storage_object in rows
+ script_payload(script, storage_object) for script, storage_object in rows
],
"meta": {"count": len(rows)},
}
@@ -773,10 +814,7 @@ async def update_script(
workspace_id,
name=jupyter_name,
content=content.decode("utf-8"),
- content_type=(
- mimetypes.guess_type(jupyter_name)[0]
- or "text/plain"
- ),
+ content_type=(mimetypes.guess_type(jupyter_name)[0] or "text/plain"),
)
except RuntimeClientError as exc:
raise HTTPException(
@@ -799,6 +837,48 @@ async def update_script(
}
+@router.patch("/api/v1/scripts/{script_id}/lock")
+async def set_script_lock(
+ script_id: str,
+ payload: LockScriptRequest,
+ context: RequestContext = Depends(request_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ script = await session.scalar(
+ select(Scripts)
+ .where(
+ Scripts.script_id == script_id,
+ Scripts.workspace_id == context.workspace.workspace_id,
+ Scripts.status == "active",
+ )
+ .with_for_update()
+ )
+ if script is None:
+ raise HTTPException(
+ status.HTTP_404_NOT_FOUND,
+ "script not found",
+ )
+ require_script_modify_access(
+ script,
+ user_id=context.user.user_id,
+ is_admin=context.is_admin,
+ )
+ script.is_locked = 1 if payload.is_locked else 0
+ await session.commit()
+ storage_object = await session.scalar(
+ select(StorageObjects).where(
+ StorageObjects.storage_object_id == script.current_object_id
+ )
+ )
+ return {
+ "request_id": context.request_id,
+ "data": script_payload(script, storage_object)
+ if storage_object
+ else script_payload(script, {}),
+ "meta": {},
+ }
+
+
@router.delete("/api/v1/scripts/{script_id}")
async def delete_script(
script_id: str,
@@ -905,18 +985,16 @@ async def publish_version(
workspace_id = context.workspace.workspace_id
jupyter_name = _jupyter_path(script.script_type, script.script_id)
try:
- contents = await runtime_client.get_file(
- workspace_id, name=jupyter_name
- )
+ contents = await runtime_client.get_file(workspace_id, name=jupyter_name)
except RuntimeClientError as exc:
raise HTTPException(
status_code=exc.status_code,
detail=exc.detail,
) from exc
if contents.get("type") == "notebook":
- content = json.dumps(
- contents.get("content", {}), ensure_ascii=False
- ).encode("utf-8")
+ content = json.dumps(contents.get("content", {}), ensure_ascii=False).encode(
+ "utf-8"
+ )
else:
content = (contents.get("content") or "").encode("utf-8")
@@ -934,8 +1012,7 @@ async def publish_version(
"meta": {"reused": True},
}
content_type = (
- mimetypes.guess_type(script.script_name)[0]
- or "application/octet-stream"
+ mimetypes.guess_type(script.script_name)[0] or "application/octet-stream"
)
artifact = await create_server_object_payload(
ServerObjectRequest(
@@ -1034,9 +1111,12 @@ async def latest_version(
"script not found",
)
latest = await session.scalar(
- select(Versions).where(
+ select(Versions)
+ .where(
Versions.script_id == script_id,
- ).order_by(Versions.version_no.desc()).limit(1)
+ )
+ .order_by(Versions.version_no.desc())
+ .limit(1)
)
if latest is None:
return {
@@ -1061,10 +1141,7 @@ async def get_version(
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
version = await session.get(Versions, versions_id)
- if (
- version is None
- or version.workspace_id != context.workspace.workspace_id
- ):
+ if version is None or version.workspace_id != context.workspace.workspace_id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "version not found")
return {
"request_id": context.request_id,
@@ -1129,10 +1206,7 @@ async def version_download_url(
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
version = await session.get(Versions, versions_id)
- if (
- version is None
- or version.workspace_id != context.workspace.workspace_id
- ):
+ if version is None or version.workspace_id != context.workspace.workspace_id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "version not found")
data = await create_download_url_payload(
await session.get(StorageObjects, version.artifact_object_id),
diff --git a/frontend/app/components/common/Icon.tsx b/frontend/app/components/common/Icon.tsx
index ec50beb..048cc3b 100644
--- a/frontend/app/components/common/Icon.tsx
+++ b/frontend/app/components/common/Icon.tsx
@@ -17,6 +17,8 @@ type IconName =
| "close"
| "check"
| "info"
+ | "lock"
+ | "unlock"
| "workspace"
| "menu"
| "external"
@@ -143,6 +145,18 @@ export default function Icon({
>
),
+ lock: (
+ <>
+
+
+ >
+ ),
+ unlock: (
+ <>
+
+
+ >
+ ),
workspace: (
<>
diff --git a/frontend/app/components/platform/ScriptExplorer.tsx b/frontend/app/components/platform/ScriptExplorer.tsx
index eb6eac3..694e4af 100644
--- a/frontend/app/components/platform/ScriptExplorer.tsx
+++ b/frontend/app/components/platform/ScriptExplorer.tsx
@@ -1,8 +1,8 @@
import { type ChangeEvent, type MouseEvent as ReactMouseEvent, type RefObject } from "react";
import Icon from "../common/Icon";
-import { WorkspaceTreeGroup } from "../../features/platform/WorkspaceTree";
-import type { ScriptItem, WorkspaceDirectory } from "../../services/api";
-import type { AuthUser } from "../../context/AuthContext";
+import { WorkspaceTreeGroup } from "~/features/platform/WorkspaceTree";
+import type { ScriptItem, WorkspaceDirectory } from "~/services/api";
+import type { AuthUser } from "~/context/AuthContext";
type ScriptExplorerProps = {
scripts: ScriptItem[];
@@ -51,15 +51,55 @@ export function ScriptExplorer({
onHandleUpload,
}: ScriptExplorerProps) {
const memberScriptGroups = (() => {
- const currentUserScripts = filteredScripts.filter(
- (item) => item.owner_user_id === user?.user_id,
- );
- const inferred = inferredDirectories(currentUserScripts);
- return [{
- user: user,
- scripts: currentUserScripts,
- directories: mergeDirectories(directories, inferred),
- }];
+ const visibleScripts =
+ user?.is_system_admin === true
+ ? filteredScripts
+ : filteredScripts.filter(
+ (item) =>
+ item.owner_user_id === user?.user_id ||
+ item.visibility === "workspace" ||
+ item.visibility === "public",
+ );
+
+ const byOwner = new Map();
+ for (const item of visibleScripts) {
+ const list = byOwner.get(item.owner_user_id) ?? [];
+ list.push(item);
+ byOwner.set(item.owner_user_id, list);
+ }
+
+ const groups: {
+ user: AuthUser | null;
+ scripts: ScriptItem[];
+ directories: WorkspaceDirectory[];
+ }[] = [];
+ for (const [ownerUserId, groupScripts] of byOwner.entries()) {
+ const groupUser =
+ ownerUserId === user?.user_id
+ ? user
+ : ({
+ user_id: ownerUserId,
+ username: ownerUserId,
+ display_name: ownerUserId,
+ email: null,
+ status: "unknown",
+ role_code: null,
+ is_system_admin: false,
+ } as AuthUser);
+ groups.push({
+ user: groupUser,
+ scripts: groupScripts,
+ directories: inferredDirectories(groupScripts),
+ });
+ }
+
+ groups.sort((a, b) => {
+ if (a.user?.user_id === user?.user_id) return -1;
+ if (b.user?.user_id === user?.user_id) return 1;
+ return (a.user?.user_id ?? "").localeCompare(b.user?.user_id ?? "");
+ });
+
+ return groups;
})();
return (
diff --git a/frontend/app/components/platform/TreeContextMenu.tsx b/frontend/app/components/platform/TreeContextMenu.tsx
index 014fceb..84da82a 100644
--- a/frontend/app/components/platform/TreeContextMenu.tsx
+++ b/frontend/app/components/platform/TreeContextMenu.tsx
@@ -13,6 +13,7 @@ type TreeContextMenuProps = {
contextMenu: ContextMenuState | null;
onOpenScript: (scriptId: string) => void;
onRemoveScript: (script: ScriptItem) => void;
+ onToggleLock: (script: ScriptItem) => void;
onOpenCreateDialog: (parentPath: string, scriptType: ScriptType) => void;
onOpenFolderDialog: (parentPath: string) => void;
onChooseUpload: (parentPath: string) => void;
@@ -24,6 +25,7 @@ export function TreeContextMenu({
contextMenu,
onOpenScript,
onRemoveScript,
+ onToggleLock,
onOpenCreateDialog,
onOpenFolderDialog,
onChooseUpload,
@@ -33,7 +35,7 @@ export function TreeContextMenu({
if (!contextMenu) return null;
const width = 188;
- const height = contextMenu.kind === "file" ? 92 : 190;
+ const height = contextMenu.kind === "file" ? 124 : 190;
return (
打开文件
+
))}
>
diff --git a/frontend/app/features/platform/state/scriptWorkspaceStore.ts b/frontend/app/features/platform/state/scriptWorkspaceStore.ts
index 3d84283..39076a3 100644
--- a/frontend/app/features/platform/state/scriptWorkspaceStore.ts
+++ b/frontend/app/features/platform/state/scriptWorkspaceStore.ts
@@ -86,6 +86,7 @@ type State = {
createFolder: (name: string, parentPath: string) => Promise;
deleteScript: (script: ScriptItem) => Promise;
deleteDirectory: (path: string) => Promise;
+ toggleScriptLock: (script: ScriptItem) => Promise;
openPublishDialog: (script: ScriptItem) => void;
submitPublish: (releaseNote: string, visibility: Visibility) => Promise;
@@ -631,6 +632,28 @@ export const useScriptWorkspaceStore = create((set, get) => {
}
},
+ toggleScriptLock: async (script) => {
+ const api = requireApi();
+ useUiStore.getState().closeContextMenu();
+ try {
+ const updated = await api.setScriptLock(script.script_id, !script.is_locked);
+ set((state) => ({
+ scripts: state.scripts.map((s) =>
+ s.script_id === updated.script_id ? updated : s,
+ ),
+ }));
+ pushToast(
+ "success",
+ `${updated.script_name} 已${updated.is_locked ? "锁定" : "解锁"}`,
+ );
+ } catch (error) {
+ pushToast(
+ "error",
+ error instanceof Error ? error.message : "锁定状态更新失败",
+ );
+ }
+ },
+
openPublishDialog: (script) => {
useUiStore.getState().openPublishDialog(script);
},
diff --git a/frontend/app/services/api.ts b/frontend/app/services/api.ts
index 8e4601a..75ef088 100644
--- a/frontend/app/services/api.ts
+++ b/frontend/app/services/api.ts
@@ -177,6 +177,7 @@ export type ScriptItem = {
script_type: ScriptType;
visibility: Visibility;
status: string;
+ is_locked: boolean;
relative_path: string;
jupyter_path: string;
content_hash: string;
@@ -385,6 +386,21 @@ export async function uploadScript(
);
}
+export async function setScriptLock(
+ workspaceId: string,
+ scriptId: string,
+ isLocked: boolean,
+): Promise {
+ return apiRequest(
+ `/api/v1/scripts/${encodeURIComponent(scriptId)}/lock`,
+ {
+ method: "PATCH",
+ body: JSON.stringify({ is_locked: isLocked }),
+ },
+ workspaceId,
+ );
+}
+
export async function updateScript(
workspaceId: string,
scriptId: string,
@@ -1289,6 +1305,10 @@ export type WorkspaceBoundApi = {
deleteScript: (
scriptId: string,
) => Promise<{ script_id: string; status: string; versions_preserved: boolean }>;
+ setScriptLock: (
+ scriptId: string,
+ isLocked: boolean,
+ ) => Promise;
listWorkspaceDirectories: () => Promise;
createWorkspaceDirectory: (
directoryName: string,
diff --git a/frontend/app/styles/platform.css b/frontend/app/styles/platform.css
index 8576e02..6d8c86c 100644
--- a/frontend/app/styles/platform.css
+++ b/frontend/app/styles/platform.css
@@ -697,6 +697,14 @@ button {
box-shadow: 0 0 0 3px rgb(29 189 124 / 10%);
}
+.lock-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: #c79a3a;
+ margin-right: 4px;
+}
+
.tree-group__empty {
margin: 2px 0 7px 35px;
color: #a5b0bc;
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index d397e9d..4c079a2 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -1,9 +1,15 @@
import { reactRouter } from "@react-router/dev/vite";
import { defineConfig } from "vite";
+import path from "node:path";
export default defineConfig({
base: '/',
plugins: [reactRouter()],
+ resolve: {
+ alias: {
+ "~": path.resolve(__dirname, "./app"),
+ },
+ },
server: {
host: "0.0.0.0",
port: 5173,