fix: file lock

This commit is contained in:
tao.chen
2026-08-11 21:26:55 +08:00
parent d4013a07a9
commit dd6f176ca8
13 changed files with 345 additions and 97 deletions
+43 -3
View File
@@ -75,8 +75,9 @@
### 3.1 `GET /api/v1/workspace-tree` ### 3.1 `GET /api/v1/workspace-tree`
列出当前用户在 workspace 内的**目录树**(从 `StorageObjects.relative_path` 派生) 列出当前用户在 workspace 内的**目录树**。
- **来源**: 显式 `StorageObjects` 行 (`object_type='directory'`,见 §3.2) **并入**`Scripts.relative_path` 派生的祖先目录,**去重**。空目录(只有显式行、没有文件)也会出现。
- **鉴权**: workspace 成员 - **鉴权**: workspace 成员
- **请求体**: 无 - **请求体**: 无
- **响应**: - **响应**:
@@ -95,7 +96,7 @@
### 3.2 `POST /api/v1/workspace-directories` ### 3.2 `POST /api/v1/workspace-directories`
创建一个**逻辑目录**(对象存储上是隐式前缀,无需落对象) 创建一个**目录**。后端会落一行 `StorageObjects(object_type='directory', storage_backend='rustfs', storage_uri='inline://directory/{path}', visibility='private')`,因此空目录也能在 §3.1 树里出现并保留下来
- **请求体**: - **请求体**:
```json ```json
@@ -104,11 +105,23 @@
"parent_path": "scripts" "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**: - **响应 201**:
```json ```json
{ {
"request_id": "...", "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` | | | `script_type` | `python` \| `notebook` | |
| `visibility` | enum | | | `visibility` | enum | |
| `status` | `active` \| `deleted` | | | `status` | `active` \| `deleted` | |
| `is_locked` | boolean | 是否锁定。`PUT /scripts/{id}`(§3.8) 受此字段门禁;可通过 §3.16 切换 |
| `relative_path` | string \| null | 例如 `users/alice/scripts/etl/train.py` | | `relative_path` | string \| null | 例如 `users/alice/scripts/etl/train.py` |
| `content_hash` | string \| null | SHA-256 十六进制 | | `content_hash` | string \| null | SHA-256 十六进制 |
| `size_bytes` | int | | | `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 | 脚本不存在 / 非当前用户无权访问(不区分,避免暴露存在性) |
--- ---
## 四、调度 ## 四、调度
+5 -4
View File
@@ -1,10 +1,7 @@
from __future__ import annotations
from typing import Literal from typing import Literal
from pydantic import Field, field_validator
from common.schemas import StrictModel from common.schemas import StrictModel
from pydantic import Field, field_validator
class CreateResourceUploadRequest(StrictModel): class CreateResourceUploadRequest(StrictModel):
@@ -47,6 +44,10 @@ class UpdateScriptRequest(StrictModel):
content: str = Field(max_length=10 * 1024 * 1024) content: str = Field(max_length=10 * 1024 * 1024)
class LockScriptRequest(StrictModel):
is_locked: bool
class PublishVersionRequest(StrictModel): class PublishVersionRequest(StrictModel):
source_object_id: str | None = Field( source_object_id: str | None = Field(
default=None, default=None,
+139 -65
View File
@@ -1,5 +1,3 @@
from __future__ import annotations
import asyncio import asyncio
import base64 import base64
import hashlib import hashlib
@@ -8,7 +6,15 @@ import mimetypes
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import PurePosixPath from pathlib import PurePosixPath
from typing import Any 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 ( from fastapi import (
APIRouter, APIRouter,
BackgroundTasks, BackgroundTasks,
@@ -19,34 +25,29 @@ from fastapi import (
Request, Request,
status, status,
) )
from loguru import logger
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession 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 ( from backend.dependencies import (
RequestContext, RequestContext,
database_session, database_session,
request_context, request_context,
) )
from backend.runtime_client import RuntimeClientError 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 ( from backend.schemas import (
CreateScriptRequest, CreateScriptRequest,
CreateWorkspaceDirectoryRequest, CreateWorkspaceDirectoryRequest,
DownloadUrlRequest, DownloadUrlRequest,
LockScriptRequest,
PublishVersionRequest, PublishVersionRequest,
UpdateScriptRequest, UpdateScriptRequest,
) )
from backend.services.storage import (
create_download_url_payload,
create_server_object_payload,
)
router = APIRouter(tags=["scripts"]) router = APIRouter(tags=["scripts"])
@@ -170,7 +171,7 @@ def script_payload(
size_bytes = storage_object.size_bytes size_bytes = storage_object.size_bytes
workspace_prefix = f"{script.workspace_id}/" workspace_prefix = f"{script.workspace_id}/"
jupyter_path = ( jupyter_path = (
object_key[len(workspace_prefix):] object_key[len(workspace_prefix) :]
if object_key and object_key.startswith(workspace_prefix) if object_key and object_key.startswith(workspace_prefix)
else object_key else object_key
) )
@@ -183,6 +184,7 @@ def script_payload(
"script_type": script.script_type, "script_type": script.script_type,
"visibility": script.visibility, "visibility": script.visibility,
"status": script.status, "status": script.status,
"is_locked": bool(script.is_locked),
"relative_path": relative_path, "relative_path": relative_path,
"jupyter_path": jupyter_path, "jupyter_path": jupyter_path,
"content_hash": content_hash, "content_hash": content_hash,
@@ -249,8 +251,7 @@ async def get_script_row(
select(Scripts, StorageObjects) select(Scripts, StorageObjects)
.join( .join(
StorageObjects, StorageObjects,
StorageObjects.storage_object_id StorageObjects.storage_object_id == Scripts.current_object_id,
== Scripts.current_object_id,
) )
.where( .where(
Scripts.script_id == script_id, Scripts.script_id == script_id,
@@ -304,8 +305,10 @@ async def create_script_record(
session: AsyncSession, session: AsyncSession,
) -> tuple[Scripts, dict[str, Any]]: ) -> tuple[Scripts, dict[str, Any]]:
folder = ( folder = (
"scripts" if script_type == "python" else "notebooks" ("scripts" if script_type == "python" else "notebooks")
) if parent_path is None else normalize_user_path(parent_path) if parent_path is None
else normalize_user_path(parent_path)
)
child_path = f"{folder}/{name}" if folder else name child_path = f"{folder}/{name}" if folder else name
relative_path = user_relative_path(context, child_path) relative_path = user_relative_path(context, child_path)
logger.debug(relative_path) logger.debug(relative_path)
@@ -352,9 +355,7 @@ async def create_script_record(
workspace_id, workspace_id,
name=jupyter_name, name=jupyter_name,
content=content.decode("utf-8"), content=content.decode("utf-8"),
content_type=( content_type=(mimetypes.guess_type(jupyter_name)[0] or "text/plain"),
mimetypes.guess_type(jupyter_name)[0] or "text/plain"
),
) )
logger.debug(jupyter_resp) logger.debug(jupyter_resp)
except RuntimeClientError as exc: except RuntimeClientError as exc:
@@ -512,10 +513,11 @@ async def get_workspace_tree(
context: RequestContext = Depends(request_context), context: RequestContext = Depends(request_context),
session: AsyncSession = Depends(database_session), session: AsyncSession = Depends(database_session),
) -> dict[str, Any]: ) -> dict[str, Any]:
# Workspace object storage uses implicit directories (object key prefixes), # Workspace object storage uses implicit directories (object key prefixes)
# so we derive the tree from ``StorageObjects.relative_path`` rather than # plus explicit directory rows, so we derive the tree from
# walking a local filesystem. Only paths that start with the user's # ``StorageObjects.relative_path`` and ``StorageObjects.object_type``
# scoped prefix (and that are currently active) contribute. # 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) scoped_prefix = user_relative_path(context)
if scoped_prefix: if scoped_prefix:
like_prefix = f"{scoped_prefix}%" like_prefix = f"{scoped_prefix}%"
@@ -524,17 +526,16 @@ async def get_workspace_tree(
rows = ( rows = (
await session.execute( await session.execute(
select(StorageObjects.relative_path) select(StorageObjects.relative_path, StorageObjects.object_type).where(
.where(
StorageObjects.workspace_id == context.workspace.workspace_id, StorageObjects.workspace_id == context.workspace.workspace_id,
StorageObjects.object_status == "available", StorageObjects.object_status == "available",
StorageObjects.relative_path.like(like_prefix), StorageObjects.relative_path.like(like_prefix),
) )
) )
).scalars().all() ).all()
directories: dict[str, dict[str, str]] = {} directories: dict[str, dict[str, str]] = {}
for relative in rows: for relative, object_type in rows:
if not relative: if not relative:
continue continue
# Strip the scoped prefix so the returned paths are workspace-local. # Strip the scoped prefix so the returned paths are workspace-local.
@@ -544,7 +545,19 @@ async def get_workspace_tree(
continue continue
else: else:
trimmed = relative 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] parts = trimmed.split("/")[:-1]
for index in range(1, len(parts) + 1): for index in range(1, len(parts) + 1):
directory_path = "/".join(parts[:index]) directory_path = "/".join(parts[:index])
@@ -579,13 +592,16 @@ async def create_workspace_directory(
relative_path = user_relative_path(context, child_path) relative_path = user_relative_path(context, child_path)
scoped_prefix = user_relative_path(context) scoped_prefix = user_relative_path(context)
# Validate parent exists: there must be at least one StorageObject whose # 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: if parent:
parent_relative = f"{scoped_prefix}/{parent}"
existing_parent = await session.scalar( existing_parent = await session.scalar(
select(StorageObjects.storage_object_id).where( select(StorageObjects.storage_object_id).where(
StorageObjects.workspace_id == context.workspace.workspace_id, StorageObjects.workspace_id == context.workspace.workspace_id,
StorageObjects.object_status == "available", 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: if existing_parent is None:
@@ -607,9 +623,32 @@ async def create_workspace_directory(
status.HTTP_409_CONFLICT, status.HTTP_409_CONFLICT,
"a file or directory with the same path already exists", "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 { return {
"request_id": context.request_id, "request_id": context.request_id,
"data": { "data": {
"storage_object_id": directory.storage_object_id,
"path": child_path, "path": child_path,
"name": name, "name": name,
"parent_path": parent, "parent_path": parent,
@@ -636,15 +675,18 @@ async def delete_workspace_directory(
# to the runtime. ``directory_path`` is still returned in the # to the runtime. ``directory_path`` is still returned in the
# response for API compatibility. # response for API compatibility.
rows = ( rows = (
await session.execute( (
select(Scripts) await session.execute(
.where( select(Scripts).where(
Scripts.workspace_id == context.workspace.workspace_id, Scripts.workspace_id == context.workspace.workspace_id,
Scripts.owner_user_id == context.user.user_id, Scripts.owner_user_id == context.user.user_id,
Scripts.status == "active", Scripts.status == "active",
)
) )
) )
).scalars().all() .scalars()
.all()
)
runtime_client = request.app.state.runtime_client runtime_client = request.app.state.runtime_client
workspace_id = context.workspace.workspace_id workspace_id = context.workspace.workspace_id
for script in rows: for script in rows:
@@ -700,8 +742,7 @@ async def list_scripts(
return { return {
"request_id": context.request_id, "request_id": context.request_id,
"data": [ "data": [
script_payload(script, storage_object) script_payload(script, storage_object) for script, storage_object in rows
for script, storage_object in rows
], ],
"meta": {"count": len(rows)}, "meta": {"count": len(rows)},
} }
@@ -773,10 +814,7 @@ async def update_script(
workspace_id, workspace_id,
name=jupyter_name, name=jupyter_name,
content=content.decode("utf-8"), content=content.decode("utf-8"),
content_type=( content_type=(mimetypes.guess_type(jupyter_name)[0] or "text/plain"),
mimetypes.guess_type(jupyter_name)[0]
or "text/plain"
),
) )
except RuntimeClientError as exc: except RuntimeClientError as exc:
raise HTTPException( 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}") @router.delete("/api/v1/scripts/{script_id}")
async def delete_script( async def delete_script(
script_id: str, script_id: str,
@@ -905,18 +985,16 @@ async def publish_version(
workspace_id = context.workspace.workspace_id workspace_id = context.workspace.workspace_id
jupyter_name = _jupyter_path(script.script_type, script.script_id) jupyter_name = _jupyter_path(script.script_type, script.script_id)
try: try:
contents = await runtime_client.get_file( contents = await runtime_client.get_file(workspace_id, name=jupyter_name)
workspace_id, name=jupyter_name
)
except RuntimeClientError as exc: except RuntimeClientError as exc:
raise HTTPException( raise HTTPException(
status_code=exc.status_code, status_code=exc.status_code,
detail=exc.detail, detail=exc.detail,
) from exc ) from exc
if contents.get("type") == "notebook": if contents.get("type") == "notebook":
content = json.dumps( content = json.dumps(contents.get("content", {}), ensure_ascii=False).encode(
contents.get("content", {}), ensure_ascii=False "utf-8"
).encode("utf-8") )
else: else:
content = (contents.get("content") or "").encode("utf-8") content = (contents.get("content") or "").encode("utf-8")
@@ -934,8 +1012,7 @@ async def publish_version(
"meta": {"reused": True}, "meta": {"reused": True},
} }
content_type = ( content_type = (
mimetypes.guess_type(script.script_name)[0] mimetypes.guess_type(script.script_name)[0] or "application/octet-stream"
or "application/octet-stream"
) )
artifact = await create_server_object_payload( artifact = await create_server_object_payload(
ServerObjectRequest( ServerObjectRequest(
@@ -1034,9 +1111,12 @@ async def latest_version(
"script not found", "script not found",
) )
latest = await session.scalar( latest = await session.scalar(
select(Versions).where( select(Versions)
.where(
Versions.script_id == script_id, 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: if latest is None:
return { return {
@@ -1061,10 +1141,7 @@ async def get_version(
session: AsyncSession = Depends(database_session), session: AsyncSession = Depends(database_session),
) -> dict[str, Any]: ) -> dict[str, Any]:
version = await session.get(Versions, versions_id) version = await session.get(Versions, versions_id)
if ( if version is None or version.workspace_id != context.workspace.workspace_id:
version is None
or version.workspace_id != context.workspace.workspace_id
):
raise HTTPException(status.HTTP_404_NOT_FOUND, "version not found") raise HTTPException(status.HTTP_404_NOT_FOUND, "version not found")
return { return {
"request_id": context.request_id, "request_id": context.request_id,
@@ -1129,10 +1206,7 @@ async def version_download_url(
session: AsyncSession = Depends(database_session), session: AsyncSession = Depends(database_session),
) -> dict[str, Any]: ) -> dict[str, Any]:
version = await session.get(Versions, versions_id) version = await session.get(Versions, versions_id)
if ( if version is None or version.workspace_id != context.workspace.workspace_id:
version is None
or version.workspace_id != context.workspace.workspace_id
):
raise HTTPException(status.HTTP_404_NOT_FOUND, "version not found") raise HTTPException(status.HTTP_404_NOT_FOUND, "version not found")
data = await create_download_url_payload( data = await create_download_url_payload(
await session.get(StorageObjects, version.artifact_object_id), await session.get(StorageObjects, version.artifact_object_id),
+14
View File
@@ -17,6 +17,8 @@ type IconName =
| "close" | "close"
| "check" | "check"
| "info" | "info"
| "lock"
| "unlock"
| "workspace" | "workspace"
| "menu" | "menu"
| "external" | "external"
@@ -143,6 +145,18 @@ export default function Icon({
<path d="M12 11v5M12 8h.01" /> <path d="M12 11v5M12 8h.01" />
</> </>
), ),
lock: (
<>
<rect x="5" y="11" width="14" height="9" rx="2" />
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
</>
),
unlock: (
<>
<rect x="5" y="11" width="14" height="9" rx="2" />
<path d="M8 11V7a4 4 0 0 1 7.5-2" />
</>
),
workspace: ( workspace: (
<> <>
<rect x="3.5" y="5" width="17" height="14" rx="2" /> <rect x="3.5" y="5" width="17" height="14" rx="2" />
@@ -1,8 +1,8 @@
import { type ChangeEvent, type MouseEvent as ReactMouseEvent, type RefObject } from "react"; import { type ChangeEvent, type MouseEvent as ReactMouseEvent, type RefObject } from "react";
import Icon from "../common/Icon"; import Icon from "../common/Icon";
import { WorkspaceTreeGroup } from "../../features/platform/WorkspaceTree"; import { WorkspaceTreeGroup } from "~/features/platform/WorkspaceTree";
import type { ScriptItem, WorkspaceDirectory } from "../../services/api"; import type { ScriptItem, WorkspaceDirectory } from "~/services/api";
import type { AuthUser } from "../../context/AuthContext"; import type { AuthUser } from "~/context/AuthContext";
type ScriptExplorerProps = { type ScriptExplorerProps = {
scripts: ScriptItem[]; scripts: ScriptItem[];
@@ -51,15 +51,55 @@ export function ScriptExplorer({
onHandleUpload, onHandleUpload,
}: ScriptExplorerProps) { }: ScriptExplorerProps) {
const memberScriptGroups = (() => { const memberScriptGroups = (() => {
const currentUserScripts = filteredScripts.filter( const visibleScripts =
(item) => item.owner_user_id === user?.user_id, user?.is_system_admin === true
); ? filteredScripts
const inferred = inferredDirectories(currentUserScripts); : filteredScripts.filter(
return [{ (item) =>
user: user, item.owner_user_id === user?.user_id ||
scripts: currentUserScripts, item.visibility === "workspace" ||
directories: mergeDirectories(directories, inferred), item.visibility === "public",
}]; );
const byOwner = new Map<string, ScriptItem[]>();
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 ( return (
@@ -13,6 +13,7 @@ type TreeContextMenuProps = {
contextMenu: ContextMenuState | null; contextMenu: ContextMenuState | null;
onOpenScript: (scriptId: string) => void; onOpenScript: (scriptId: string) => void;
onRemoveScript: (script: ScriptItem) => void; onRemoveScript: (script: ScriptItem) => void;
onToggleLock: (script: ScriptItem) => void;
onOpenCreateDialog: (parentPath: string, scriptType: ScriptType) => void; onOpenCreateDialog: (parentPath: string, scriptType: ScriptType) => void;
onOpenFolderDialog: (parentPath: string) => void; onOpenFolderDialog: (parentPath: string) => void;
onChooseUpload: (parentPath: string) => void; onChooseUpload: (parentPath: string) => void;
@@ -24,6 +25,7 @@ export function TreeContextMenu({
contextMenu, contextMenu,
onOpenScript, onOpenScript,
onRemoveScript, onRemoveScript,
onToggleLock,
onOpenCreateDialog, onOpenCreateDialog,
onOpenFolderDialog, onOpenFolderDialog,
onChooseUpload, onChooseUpload,
@@ -33,7 +35,7 @@ export function TreeContextMenu({
if (!contextMenu) return null; if (!contextMenu) return null;
const width = 188; const width = 188;
const height = contextMenu.kind === "file" ? 92 : 190; const height = contextMenu.kind === "file" ? 124 : 190;
return ( return (
<div <div
@@ -60,6 +62,17 @@ export function TreeContextMenu({
<Icon name="script" size={16} /> <Icon name="script" size={16} />
</button> </button>
<button
type="button"
role="menuitem"
onClick={() => {
onToggleLock(contextMenu.script!);
onClose();
}}
>
<Icon name={contextMenu.script.is_locked ? "unlock" : "lock"} size={16} />
{contextMenu.script.is_locked ? "解锁文件" : "锁定文件"}
</button>
<button <button
className="is-danger" className="is-danger"
type="button" type="button"
+2
View File
@@ -228,6 +228,8 @@ export function useApi(): WorkspaceBoundApi {
updateScript: (scriptId, input) => updateScript: (scriptId, input) =>
rawApi.updateScript(workspaceId, scriptId, input), rawApi.updateScript(workspaceId, scriptId, input),
deleteScript: (scriptId) => rawApi.deleteScript(workspaceId, scriptId), deleteScript: (scriptId) => rawApi.deleteScript(workspaceId, scriptId),
setScriptLock: (scriptId, isLocked) =>
rawApi.setScriptLock(workspaceId, scriptId, isLocked),
listWorkspaceDirectories: () => rawApi.listWorkspaceDirectories(workspaceId), listWorkspaceDirectories: () => rawApi.listWorkspaceDirectories(workspaceId),
createWorkspaceDirectory: (directoryName, parentPath) => createWorkspaceDirectory: (directoryName, parentPath) =>
rawApi.createWorkspaceDirectory(workspaceId, directoryName, parentPath), rawApi.createWorkspaceDirectory(workspaceId, directoryName, parentPath),
@@ -48,6 +48,7 @@ export default function ScriptsPage() {
const createFolder = useScriptWorkspaceStore((s) => s.createFolder); const createFolder = useScriptWorkspaceStore((s) => s.createFolder);
const deleteScript = useScriptWorkspaceStore((s) => s.deleteScript); const deleteScript = useScriptWorkspaceStore((s) => s.deleteScript);
const deleteDirectory = useScriptWorkspaceStore((s) => s.deleteDirectory); const deleteDirectory = useScriptWorkspaceStore((s) => s.deleteDirectory);
const toggleScriptLock = useScriptWorkspaceStore((s) => s.toggleScriptLock);
const openPublishDialog = useScriptWorkspaceStore((s) => s.openPublishDialog); const openPublishDialog = useScriptWorkspaceStore((s) => s.openPublishDialog);
const submitPublish = useScriptWorkspaceStore((s) => s.submitPublish); const submitPublish = useScriptWorkspaceStore((s) => s.submitPublish);
@@ -292,6 +293,7 @@ export default function ScriptsPage() {
closeContextMenu(); closeContextMenu();
}} }}
onRemoveScript={(s) => void deleteScript(s)} onRemoveScript={(s) => void deleteScript(s)}
onToggleLock={(s) => void toggleScriptLock(s)}
onOpenCreateDialog={(parentPath, scriptType) => onOpenCreateDialog={(parentPath, scriptType) =>
openCreateDialog(parentPath, scriptType)} openCreateDialog(parentPath, scriptType)}
onOpenFolderDialog={(parentPath) => openFolderDialog(parentPath)} onOpenFolderDialog={(parentPath) => openFolderDialog(parentPath)}
@@ -318,4 +320,4 @@ export default function ScriptsPage() {
/> />
</section> </section>
); );
} }
@@ -4,7 +4,7 @@ import Icon from "../../components/common/Icon";
import type { import type {
ScriptItem, ScriptItem,
WorkspaceDirectory, WorkspaceDirectory,
} from "../../services/api"; } from "~/services/api";
export type WorkspaceTreeTarget = { export type WorkspaceTreeTarget = {
kind: "root" | "directory" | "file"; kind: "root" | "directory" | "file";
@@ -148,16 +148,21 @@ function WorkspaceTreeItems({
}) })
: undefined} : undefined}
> >
<span className={`file-icon file-icon--${item.script_type}`}> <span className={`file-icon file-icon--${item.script_type}`}>
<Icon name={scriptIcon(item)} size={17} /> <Icon name={scriptIcon(item)} size={17} />
</span> </span>
<span className="script-row__copy"> <span className="script-row__copy">
<strong title={item.script_name}>{item.script_name}</strong> <strong title={item.script_name}>{item.script_name}</strong>
<small>{formatTime(item.updated_at)}</small> <small>{formatTime(item.updated_at)}</small>
</span> </span>
{item.visibility !== "private" && ( {item.is_locked && (
<span className="visibility-dot" title="Workspace 可见" /> <span className="lock-badge" title="已锁定">
)} <Icon name="lock" size={12} />
</span>
)}
{item.visibility !== "private" && (
<span className="visibility-dot" title="Workspace 可见" />
)}
</button> </button>
))} ))}
</> </>
@@ -86,6 +86,7 @@ type State = {
createFolder: (name: string, parentPath: string) => Promise<void>; createFolder: (name: string, parentPath: string) => Promise<void>;
deleteScript: (script: ScriptItem) => Promise<void>; deleteScript: (script: ScriptItem) => Promise<void>;
deleteDirectory: (path: string) => Promise<void>; deleteDirectory: (path: string) => Promise<void>;
toggleScriptLock: (script: ScriptItem) => Promise<void>;
openPublishDialog: (script: ScriptItem) => void; openPublishDialog: (script: ScriptItem) => void;
submitPublish: (releaseNote: string, visibility: Visibility) => Promise<void>; submitPublish: (releaseNote: string, visibility: Visibility) => Promise<void>;
@@ -631,6 +632,28 @@ export const useScriptWorkspaceStore = create<State>((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) => { openPublishDialog: (script) => {
useUiStore.getState().openPublishDialog(script); useUiStore.getState().openPublishDialog(script);
}, },
+20
View File
@@ -177,6 +177,7 @@ export type ScriptItem = {
script_type: ScriptType; script_type: ScriptType;
visibility: Visibility; visibility: Visibility;
status: string; status: string;
is_locked: boolean;
relative_path: string; relative_path: string;
jupyter_path: string; jupyter_path: string;
content_hash: string; content_hash: string;
@@ -385,6 +386,21 @@ export async function uploadScript(
); );
} }
export async function setScriptLock(
workspaceId: string,
scriptId: string,
isLocked: boolean,
): Promise<ScriptItem> {
return apiRequest<ScriptItem>(
`/api/v1/scripts/${encodeURIComponent(scriptId)}/lock`,
{
method: "PATCH",
body: JSON.stringify({ is_locked: isLocked }),
},
workspaceId,
);
}
export async function updateScript( export async function updateScript(
workspaceId: string, workspaceId: string,
scriptId: string, scriptId: string,
@@ -1289,6 +1305,10 @@ export type WorkspaceBoundApi = {
deleteScript: ( deleteScript: (
scriptId: string, scriptId: string,
) => Promise<{ script_id: string; status: string; versions_preserved: boolean }>; ) => Promise<{ script_id: string; status: string; versions_preserved: boolean }>;
setScriptLock: (
scriptId: string,
isLocked: boolean,
) => Promise<ScriptItem>;
listWorkspaceDirectories: () => Promise<WorkspaceDirectory[]>; listWorkspaceDirectories: () => Promise<WorkspaceDirectory[]>;
createWorkspaceDirectory: ( createWorkspaceDirectory: (
directoryName: string, directoryName: string,
+8
View File
@@ -697,6 +697,14 @@ button {
box-shadow: 0 0 0 3px rgb(29 189 124 / 10%); 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 { .tree-group__empty {
margin: 2px 0 7px 35px; margin: 2px 0 7px 35px;
color: #a5b0bc; color: #a5b0bc;
+6
View File
@@ -1,9 +1,15 @@
import { reactRouter } from "@react-router/dev/vite"; import { reactRouter } from "@react-router/dev/vite";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import path from "node:path";
export default defineConfig({ export default defineConfig({
base: '/', base: '/',
plugins: [reactRouter()], plugins: [reactRouter()],
resolve: {
alias: {
"~": path.resolve(__dirname, "./app"),
},
},
server: { server: {
host: "0.0.0.0", host: "0.0.0.0",
port: 5173, port: 5173,