This commit is contained in:
xiaozhu
2026-08-13 10:06:46 +08:00
17 changed files with 1118 additions and 246 deletions
+41 -3
View File
@@ -73,9 +73,12 @@
> 是它在对象存储里的"工作副本",`Versions` 是 immutable 的稳定版本。 > 是它在对象存储里的"工作副本",`Versions` 是 immutable 的稳定版本。
> 写操作受 **is_locked 门禁 + owner 校验** 保护(架构 V3.1 §4)。 > 写操作受 **is_locked 门禁 + owner 校验** 保护(架构 V3.1 §4)。
### 3.1 `GET /api/v1/workspace-tree` ### 3.1 `GET /api/v1/workspace-tree` (**legacy / 全量视图**)
列出当前用户在 workspace 内的**目录树**。 > **Deprecated**: 新代码请走 §3.3.1 (按 `parent_path` 单层)。该接口仍保留供调试 / 兼容使用,
> 谓词已统一为 `object_status='available' AND is_deleted=0`,与 §3.3.1 保持一致。
列出当前用户在 workspace 内的**整个目录树**(扁平数组)。
- **来源**: 显式 `StorageObjects` 行 (`object_type='directory'`,见 §3.2) **并入**`Scripts.relative_path` 派生的祖先目录,**去重**。空目录(只有显式行、没有文件)也会出现。 - **来源**: 显式 `StorageObjects` 行 (`object_type='directory'`,见 §3.2) **并入**`Scripts.relative_path` 派生的祖先目录,**去重**。空目录(只有显式行、没有文件)也会出现。
- **鉴权**: workspace 成员 - **鉴权**: workspace 成员
@@ -145,6 +148,41 @@
} }
``` ```
### 3.3.1 `GET /api/v1/workspace-directories?parent_path=...`
列出指定父目录下的**直接子目录**(单层),用于前端懒加载。**新代码请走本接口**;§3.1 仅作为 legacy / 全量保留。
- **行为**: `parent_path` 为空字符串或缺省 → 用户根目录;非空 → 该父目录的直接子目录(workspace-relative)。仅返回 `object_status='available' AND is_deleted=0` 的 `StorageObjects` 行。
- **鉴权**: workspace 成员
- **查询参数**:
| 名 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `parent_path` | string | 否 | 父目录相对路径,空字符串或缺省表示用户根目录 |
- **谓词(SQL 等价)**: `relative_path LIKE '<prefix>/%' AND relative_path NOT LIKE '<prefix>/%/%'`,其中 `prefix = scoped_prefix/{parent_path}`,索引走 `idx_storage_workspace_relative_path(workspace_id, relative_path(255))`。
- **响应**:
```json
{
"request_id": "...",
"data": {
"directories": [
{"path": "scripts", "name": "scripts", "parent_path": "", "has_children": true},
{"path": "scripts/etl", "name": "etl", "parent_path": "scripts", "has_children": false},
{"path": "scripts/etl/daily", "name": "daily", "parent_path": "scripts/etl", "has_children": false}
]
},
"meta": {"directory_count": 3}
}
```
- 字段表(继承 §3.1):
| 字段 | 类型 | 说明 |
|---|---|---|
| `path` | string | workspace 内相对路径 |
| `name` | string | `path` 的最后一段 |
| `parent_path` | string | 父目录相对路径,根目录用空串 |
| `has_children` | bool | 该目录下是否还有直接子目录(后端额外 `EXISTS` 查询,可为空目录为 `false`) |
- **空结果**: 不返回 404,空目录列表即 `directories: []`。
- **错误**: 401(未登录)/ 403(非 workspace 成员)同其他接口。
### 3.4 `GET /api/v1/scripts` ### 3.4 `GET /api/v1/scripts`
列出当前 workspace 内**全部 active 脚本**。不受 is_locked 影响(读路径不锁)。 列出当前 workspace 内**全部 active 脚本**。不受 is_locked 影响(读路径不锁)。
@@ -213,7 +251,7 @@ multipart/binary 形式上传大文件(走 server-proxied PUT,详见 §九)。
| `visibility` | enum | | | `visibility` | enum | |
| `status` | `active` \| `deleted` | | | `status` | `active` \| `deleted` | |
| `is_locked` | boolean | 是否锁定。`PUT /scripts/{id}`(§3.8) 受此字段门禁;可通过 §3.16 切换 | | `is_locked` | boolean | 是否锁定。`PUT /scripts/{id}`(§3.8) 受此字段门禁;可通过 §3.16 切换 |
| `relative_path` | string \| null | 例如 `users/alice/scripts/etl/train.py` | | `relative_path` | string \| null | 例如 `workspace/01HXX.../scripts/etl/train.py`(以 `user_id` 为作用域) |
| `content_hash` | string \| null | SHA-256 十六进制 | | `content_hash` | string \| null | SHA-256 十六进制 |
| `size_bytes` | int | | | `size_bytes` | int | |
| `created_at` / `updated_at` | ISO-8601 | | | `created_at` / `updated_at` | ISO-8601 | |
+211 -87
View File
@@ -11,10 +11,12 @@ from common.config import settings
from common.db.models import ( from common.db.models import (
Scripts, Scripts,
StorageObjects, StorageObjects,
Users,
Versions, Versions,
) )
from common.ids import new_ulid from common.ids import new_ulid
from common.storage.schemas import ServerObjectRequest from common.storage.schemas import ServerObjectRequest
from common.storage import build_storage_uri
from fastapi import ( from fastapi import (
APIRouter, APIRouter,
BackgroundTasks, BackgroundTasks,
@@ -88,8 +90,17 @@ def safe_directory_name(value: str) -> str:
return name return name
# Version/run artifacts are not part of the user's workspace directory tree.
TREE_EXCLUDED_USAGE_TYPES = (
"version_artifact",
"snapshot",
"run_log",
"run_result",
)
def user_relative_path(context: RequestContext, child_path: str = "") -> str: def user_relative_path(context: RequestContext, child_path: str = "") -> str:
base = f"users/{context.user.username}" base = f"workspace/{context.user.user_id}"
normalized = normalize_user_path(child_path) normalized = normalize_user_path(child_path)
return f"{base}/{normalized}" if normalized else base return f"{base}/{normalized}" if normalized else base
@@ -123,11 +134,36 @@ def _jupyter_path(script_type: str, script_id: str) -> str:
(``/jupyter/<ws>/notebooks/<file>.ipynb``) is jupyter's URL route (``/jupyter/<ws>/notebooks/<file>.ipynb``) is jupyter's URL route
for the editor view, not a filesystem path — jupyter routes that for the editor view, not a filesystem path — jupyter routes that
URL to the file at the workspace root. URL to the file at the workspace root.
NOTE: This function returns the flat basename only (``{script_id}.{ext}``).
It must NOT be used directly as a Jupyter path for scripts that live
inside sub-directories. Callers must combine it with the parent
directory ULID (for newly-created files) or derive the real path from
``StorageObjects.object_key`` (for existing files).
""" """
ext = ".ipynb" if script_type == "notebook" else ".py" ext = ".ipynb" if script_type == "notebook" else ".py"
return f"{script_id}{ext}" return f"{script_id}{ext}"
def _derive_jupyter_path(
storage_object: StorageObjects | None,
workspace_id: str,
script_type: str,
script_id: str,
) -> str:
"""Return the real Jupyter path for an existing script.
For normal workspace files the path is taken from
``StorageObjects.object_key`` with the workspace prefix removed.
For jupyter-only scripts that have no StorageObjects row, fall back
to the flat ``_jupyter_path()`` basename so existing behavior is
preserved.
"""
if storage_object is None or not storage_object.object_key:
return _jupyter_path(script_type, script_id)
return storage_object.object_key.removeprefix(f"{workspace_id}/")
def validate_script_content(content: str, script_type: str) -> bytes: def validate_script_content(content: str, script_type: str) -> bytes:
encoded = content.encode("utf-8") encoded = content.encode("utf-8")
if len(encoded) > 10 * 1024 * 1024: if len(encoded) > 10 * 1024 * 1024:
@@ -156,9 +192,16 @@ def validate_script_content(content: str, script_type: str) -> bytes:
def script_payload( def script_payload(
script: Scripts, script: Scripts,
storage_object: StorageObjects | dict[str, Any], storage_object: StorageObjects | dict[str, Any] | None,
*,
owner_display_name: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
if isinstance(storage_object, dict): if storage_object is None:
relative_path = None
object_key = None
content_hash = None
size_bytes = 0
elif isinstance(storage_object, dict):
relative_path = storage_object.get("relative_path") relative_path = storage_object.get("relative_path")
object_key = storage_object.get("object_key") object_key = storage_object.get("object_key")
content_hash = storage_object.get("content_hash") content_hash = storage_object.get("content_hash")
@@ -169,16 +212,20 @@ def script_payload(
content_hash = storage_object.content_hash content_hash = storage_object.content_hash
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 = ( if object_key:
object_key[len(workspace_prefix) :] jupyter_path = (
if object_key and object_key.startswith(workspace_prefix) object_key[len(workspace_prefix) :]
else object_key if object_key.startswith(workspace_prefix)
) else object_key
)
else:
jupyter_path = _jupyter_path(script.script_type, script.script_id)
return { return {
"script_id": script.script_id, "script_id": script.script_id,
"workspace_id": script.workspace_id, "workspace_id": script.workspace_id,
"current_object_id": script.current_object_id, "current_object_id": script.current_object_id,
"owner_user_id": script.owner_user_id, "owner_user_id": script.owner_user_id,
"owner_display_name": owner_display_name,
"script_name": script.script_name, "script_name": script.script_name,
"script_type": script.script_type, "script_type": script.script_type,
"visibility": script.visibility, "visibility": script.visibility,
@@ -219,7 +266,8 @@ async def get_script_row(
session: AsyncSession, session: AsyncSession,
*, *,
for_update: bool = False, for_update: bool = False,
) -> tuple[Scripts, StorageObjects]: allow_missing_storage_object: bool = False,
) -> tuple[Scripts, StorageObjects | None]:
if for_update: if for_update:
script = await session.scalar( script = await session.scalar(
select(Scripts) select(Scripts)
@@ -239,25 +287,39 @@ async def get_script_row(
StorageObjects, StorageObjects,
script.current_object_id, script.current_object_id,
) )
if storage_object is None: if storage_object is None and not allow_missing_storage_object:
raise HTTPException( raise HTTPException(
status.HTTP_409_CONFLICT, status.HTTP_409_CONFLICT,
"script working-copy metadata is missing", "script working-copy metadata is missing",
) )
row = (script, storage_object) row = (script, storage_object)
else: else:
statement = ( if allow_missing_storage_object:
select(Scripts, StorageObjects) statement = (
.join( select(Scripts, StorageObjects)
StorageObjects, .outerjoin(
StorageObjects.storage_object_id == Scripts.current_object_id, StorageObjects,
StorageObjects.storage_object_id == Scripts.current_object_id,
)
.where(
Scripts.script_id == script_id,
Scripts.workspace_id == context.workspace.workspace_id,
Scripts.status == "active",
)
) )
.where( else:
Scripts.script_id == script_id, statement = (
Scripts.workspace_id == context.workspace.workspace_id, select(Scripts, StorageObjects)
Scripts.status == "active", .join(
StorageObjects,
StorageObjects.storage_object_id == Scripts.current_object_id,
)
.where(
Scripts.script_id == script_id,
Scripts.workspace_id == context.workspace.workspace_id,
Scripts.status == "active",
)
) )
)
row = (await session.execute(statement)).one_or_none() row = (await session.execute(statement)).one_or_none()
if row is None: if row is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "script not found") raise HTTPException(status.HTTP_404_NOT_FOUND, "script not found")
@@ -412,7 +474,7 @@ async def create_script_record(
bucket_name=bucket_name, bucket_name=bucket_name,
object_key=object_key, object_key=object_key,
object_key_hash=hashlib.sha256(object_key.encode("utf-8")).digest(), object_key_hash=hashlib.sha256(object_key.encode("utf-8")).digest(),
storage_uri=f"s3://{bucket_name}/{object_key}", storage_uri=build_storage_uri(bucket_name, object_key),
file_name=name, file_name=name,
file_extension=PurePosixPath(jupyter_name).suffix.lower() or None, file_extension=PurePosixPath(jupyter_name).suffix.lower() or None,
mime_type=mime_type, mime_type=mime_type,
@@ -571,7 +633,9 @@ async def get_workspace_tree(
select(StorageObjects.relative_path, StorageObjects.object_type).where( select(StorageObjects.relative_path, StorageObjects.object_type).where(
StorageObjects.workspace_id == context.workspace.workspace_id, StorageObjects.workspace_id == context.workspace.workspace_id,
StorageObjects.object_status == "available", StorageObjects.object_status == "available",
StorageObjects.is_deleted == 0,
StorageObjects.relative_path.like(like_prefix), StorageObjects.relative_path.like(like_prefix),
StorageObjects.usage_type.notin_(TREE_EXCLUDED_USAGE_TYPES),
) )
) )
).all() ).all()
@@ -612,6 +676,78 @@ async def get_workspace_tree(
}, },
) )
sorted_dirs = sorted(directories.values(), key=lambda item: item["path"]) sorted_dirs = sorted(directories.values(), key=lambda item: item["path"])
return {
"request_id": context.request_id,
"data": {"directories": sorted_dirs},
"meta": {"directory_count": len(sorted_dirs)},
}
@router.get("/api/v1/workspace-directories")
async def list_workspace_directories(
parent_path: str = Query(default=""),
context: RequestContext = Depends(request_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
"""List direct child directories of a workspace path.
Empty ``parent_path`` returns the directories immediately under the
user's scoped root. Only available, non-deleted StorageObjects are
considered.
"""
scoped_prefix = user_relative_path(context)
parent = normalize_user_path(parent_path)
target_prefix = f"{scoped_prefix}/{parent}" if parent else scoped_prefix
descendant_prefix = f"{target_prefix}/"
rows = (
await session.execute(
select(StorageObjects.relative_path).where(
StorageObjects.workspace_id == context.workspace.workspace_id,
StorageObjects.object_status == "available",
StorageObjects.is_deleted == 0,
StorageObjects.relative_path.like(f"{descendant_prefix}%"),
~StorageObjects.relative_path.like(f"{descendant_prefix}%/%"),
StorageObjects.object_type == "directory",
StorageObjects.usage_type.notin_(TREE_EXCLUDED_USAGE_TYPES),
)
)
).all()
directories: dict[str, dict[str, Any]] = {}
for (relative,) in rows:
if not relative or not relative.startswith(descendant_prefix):
continue
suffix = relative[len(descendant_prefix) :]
if "/" in suffix:
continue
child_path = f"{parent}/{suffix}" if parent else suffix
directories.setdefault(
child_path,
{
"path": child_path,
"name": suffix,
"parent_path": parent,
"has_children": False,
},
)
for directory in directories.values():
# directory['path'] is already workspace-relative and includes the parent segment.
child_prefix = f"{scoped_prefix}/{directory['path']}/"
has_children = await session.scalar(
select(StorageObjects.storage_object_id).where(
StorageObjects.workspace_id == context.workspace.workspace_id,
StorageObjects.object_status == "available",
StorageObjects.is_deleted == 0,
StorageObjects.relative_path.like(f"{child_prefix}%"),
~StorageObjects.relative_path.like(f"{child_prefix}%/%"),
StorageObjects.usage_type.notin_(TREE_EXCLUDED_USAGE_TYPES),
).limit(1)
)
directory["has_children"] = has_children is not None
sorted_dirs = sorted(directories.values(), key=lambda item: item["path"])
return { return {
"request_id": context.request_id, "request_id": context.request_id,
"data": {"directories": sorted_dirs}, "data": {"directories": sorted_dirs},
@@ -873,11 +1009,12 @@ async def list_scripts(
session: AsyncSession = Depends(database_session), session: AsyncSession = Depends(database_session),
) -> dict[str, Any]: ) -> dict[str, Any]:
statement = ( statement = (
select(Scripts, StorageObjects) select(Scripts, StorageObjects, Users.display_name)
.join( .join(
StorageObjects, StorageObjects,
StorageObjects.storage_object_id == Scripts.current_object_id, StorageObjects.storage_object_id == Scripts.current_object_id,
) )
.outerjoin(Users, Users.user_id == Scripts.owner_user_id)
.where( .where(
Scripts.workspace_id == context.workspace.workspace_id, Scripts.workspace_id == context.workspace.workspace_id,
Scripts.status == "active", Scripts.status == "active",
@@ -888,7 +1025,8 @@ async def list_scripts(
return { return {
"request_id": context.request_id, "request_id": context.request_id,
"data": [ "data": [
script_payload(script, storage_object) for script, storage_object in rows script_payload(script, storage_object, owner_display_name=owner_display_name)
for script, storage_object, owner_display_name in rows
], ],
"meta": {"count": len(rows)}, "meta": {"count": len(rows)},
} }
@@ -971,24 +1109,16 @@ async def update_script(
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]:
# Look up the Scripts row on its own: jupyter-only scripts do not # Load the working-copy StorageObject as well as the Scripts row.
# have a StorageObjects row to JOIN against, and update is an # Jupyter-only scripts may not have a StorageObjects row; allow that
# in-place overwrite of the same jupyter path, so we do not need # case and fall back to the flat _jupyter_path() name.
# any object-store metadata. script, storage_object = await get_script_row(
script = await session.scalar( script_id,
select(Scripts) context,
.where( session,
Scripts.script_id == script_id, for_update=True,
Scripts.workspace_id == context.workspace.workspace_id, allow_missing_storage_object=True,
Scripts.status == "active",
)
.with_for_update()
) )
if script is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND,
"script not found",
)
require_script_modify_access( require_script_modify_access(
script, script,
user_id=context.user.user_id, user_id=context.user.user_id,
@@ -997,21 +1127,23 @@ async def update_script(
content = validate_script_content(payload.content, script.script_type) content = validate_script_content(payload.content, script.script_type)
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
jupyter_name = _jupyter_path(script.script_type, script.script_id) jupyter_path = _derive_jupyter_path(
storage_object, workspace_id, script.script_type, script.script_id
)
try: try:
if script.script_type == "notebook": if script.script_type == "notebook":
notebook = json.loads(content.decode("utf-8")) notebook = json.loads(content.decode("utf-8"))
jupyter_resp = await runtime_client.create_notebook( jupyter_resp = await runtime_client.create_notebook(
workspace_id, workspace_id,
name=jupyter_name, name=jupyter_path,
cells=notebook.get("cells"), cells=notebook.get("cells"),
) )
else: else:
jupyter_resp = await runtime_client.upload_file( jupyter_resp = await runtime_client.upload_file(
workspace_id, workspace_id,
name=jupyter_name, name=jupyter_path,
content=content.decode("utf-8"), content=content.decode("utf-8"),
content_type=(mimetypes.guess_type(jupyter_name)[0] or "text/plain"), content_type=(mimetypes.guess_type(jupyter_path)[0] or "text/plain"),
) )
except RuntimeClientError as exc: except RuntimeClientError as exc:
raise HTTPException( raise HTTPException(
@@ -1022,8 +1154,8 @@ async def update_script(
script.updated_at = datetime.now(UTC).replace(tzinfo=None) script.updated_at = datetime.now(UTC).replace(tzinfo=None)
storage_data = { storage_data = {
"storage_object_id": script.current_object_id, "storage_object_id": script.current_object_id,
"relative_path": jupyter_name, "relative_path": jupyter_path,
"object_key": f"{workspace_id}/{jupyter_name}", "object_key": f"{workspace_id}/{jupyter_path}",
"content_hash": hashlib.sha256(content).hexdigest(), "content_hash": hashlib.sha256(content).hexdigest(),
"size_bytes": len(content), "size_bytes": len(content),
} }
@@ -1083,23 +1215,16 @@ async def delete_script(
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]:
# Scripts created via the jupyter path do not have a corresponding # Load the working-copy StorageObject if it exists. Jupyter-only
# StorageObjects row, so we look up the Scripts row on its own and # scripts may not have a StorageObjects row; fall back to the flat
# forward the delete to the workspace's live Jupyter instance. # _jupyter_path() name in that case.
script = await session.scalar( script, storage_object = await get_script_row(
select(Scripts) script_id,
.where( context,
Scripts.script_id == script_id, session,
Scripts.workspace_id == context.workspace.workspace_id, for_update=True,
Scripts.status == "active", allow_missing_storage_object=True,
)
.with_for_update()
) )
if script is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND,
"script not found",
)
require_script_modify_access( require_script_modify_access(
script, script,
user_id=context.user.user_id, user_id=context.user.user_id,
@@ -1107,17 +1232,23 @@ async def delete_script(
) )
runtime_client = request.app.state.runtime_client runtime_client = request.app.state.runtime_client
workspace_id = context.workspace.workspace_id
jupyter_path = _derive_jupyter_path(
storage_object, workspace_id, script.script_type, script.script_id
)
try: try:
await runtime_client.delete_file( await runtime_client.delete_file(workspace_id, name=jupyter_path)
context.workspace.workspace_id,
name=_jupyter_path(script.script_type, script.script_id),
)
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 storage_object:
storage_object.object_status = "deleted"
storage_object.is_deleted = 1
storage_object.deleted_at = datetime.now(UTC).replace(tzinfo=None)
script.status = "deleted" script.status = "deleted"
script.deleted_at = datetime.now(UTC).replace(tzinfo=None) script.deleted_at = datetime.now(UTC).replace(tzinfo=None)
return { return {
@@ -1142,25 +1273,16 @@ async def publish_version(
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]:
# Jupyter-only scripts do not have a StorageObjects row, so the # Load the working-copy StorageObject if it exists. Jupyter-only
# legacy get_script_row helper raises 409 before we even get here. # scripts may not have a StorageObjects row; fall back to the flat
# Look up the Scripts row on its own — version publication is a # _jupyter_path() name when reading from Jupyter.
# metadata operation, we do not need the working-copy object script, storage_object = await get_script_row(
# metadata. script_id,
script = await session.scalar( context,
select(Scripts) session,
.where( for_update=True,
Scripts.script_id == script_id, allow_missing_storage_object=True,
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( require_script_modify_access(
script, script,
user_id=context.user.user_id, user_id=context.user.user_id,
@@ -1180,9 +1302,11 @@ async def publish_version(
# files come back as a UTF-8 string. # files come back as a UTF-8 string.
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
jupyter_name = _jupyter_path(script.script_type, script.script_id) jupyter_path = _derive_jupyter_path(
storage_object, workspace_id, script.script_type, script.script_id
)
try: try:
contents = await runtime_client.get_file(workspace_id, name=jupyter_name) contents = await runtime_client.get_file(workspace_id, name=jupyter_path)
except RuntimeClientError as exc: except RuntimeClientError as exc:
raise HTTPException( raise HTTPException(
status_code=exc.status_code, status_code=exc.status_code,
@@ -1241,7 +1365,7 @@ async def publish_version(
artifact_object_id=artifact_data["storage_object_id"], artifact_object_id=artifact_data["storage_object_id"],
version_no=version_no, version_no=version_no,
version_label=f"v{version_no}.0", version_label=f"v{version_no}.0",
source_path=jupyter_name, source_path=jupyter_path,
artifact_path=artifact_data["storage_uri"], artifact_path=artifact_data["storage_uri"],
content_hash=content_hash, content_hash=content_hash,
file_size_bytes=len(content), file_size_bytes=len(content),
+3 -2
View File
@@ -47,6 +47,7 @@ from common.storage.schemas import (
DownloadUrlRequest, DownloadUrlRequest,
ServerObjectRequest, ServerObjectRequest,
) )
from common.storage import build_storage_uri
# ── shared low-level helpers (module-private) ──────────────────────────── # ── shared low-level helpers (module-private) ────────────────────────────
@@ -90,7 +91,7 @@ def _build_storage_object(
bucket_name=upload.bucket_name, bucket_name=upload.bucket_name,
object_key=upload.object_key, object_key=upload.object_key,
object_key_hash=upload.object_key_hash, object_key_hash=upload.object_key_hash,
storage_uri=f"s3://{upload.bucket_name}/{upload.object_key}", storage_uri=build_storage_uri(upload.bucket_name, upload.object_key),
file_name=safe_name, file_name=safe_name,
file_extension=PurePosixPath(safe_name).suffix.lower() or None, file_extension=PurePosixPath(safe_name).suffix.lower() or None,
mime_type=content_type, mime_type=content_type,
@@ -519,4 +520,4 @@ async def soft_delete_object(
"trash_key": item.trash_key, "trash_key": item.trash_key,
"trash_bucket": settings.s3_trash_bucket, "trash_bucket": settings.s3_trash_bucket,
} }
} }
+2 -2
View File
@@ -19,7 +19,7 @@ from common.db.models import (
Workspaces, Workspaces,
) )
from common.ids import new_ulid from common.ids import new_ulid
from common.storage import actual_bucket_name from common.storage import actual_bucket_name, build_storage_uri
from common.storage.schemas import ( from common.storage.schemas import (
CreateUploadRequest, CreateUploadRequest,
DownloadUrlRequest, DownloadUrlRequest,
@@ -350,7 +350,7 @@ async def upload_bytes_to_session(
bucket_name=upload.bucket_name, bucket_name=upload.bucket_name,
object_key=upload.object_key, object_key=upload.object_key,
object_key_hash=upload.object_key_hash, object_key_hash=upload.object_key_hash,
storage_uri=f"s3://{upload.bucket_name}/{upload.object_key}", storage_uri=build_storage_uri(upload.bucket_name, upload.object_key),
file_name=file_name, file_name=file_name,
file_extension=PurePosixPath(file_name).suffix.lower() or None, file_extension=PurePosixPath(file_name).suffix.lower() or None,
mime_type=upload.content_type, mime_type=upload.content_type,
+2
View File
@@ -23,6 +23,7 @@ from .factory import (
PURPOSE_BUCKETS, PURPOSE_BUCKETS,
RCLONE_REMOTE_NAME, RCLONE_REMOTE_NAME,
actual_bucket_name, actual_bucket_name,
build_storage_uri,
build_storage_config, build_storage_config,
create_storage, create_storage,
rclone_remote_spec, rclone_remote_spec,
@@ -34,6 +35,7 @@ __all__ = [
"create_storage", "create_storage",
"build_storage_config", "build_storage_config",
"actual_bucket_name", "actual_bucket_name",
"build_storage_uri",
"workspaces_root", "workspaces_root",
"rclone_remote_spec", "rclone_remote_spec",
"RCLONE_REMOTE_NAME", "RCLONE_REMOTE_NAME",
+18
View File
@@ -78,6 +78,24 @@ def actual_bucket_name(purpose: str) -> str:
return getattr(settings, f"s3_{purpose}_bucket") return getattr(settings, f"s3_{purpose}_bucket")
def build_storage_uri(bucket_name: str, object_key: str) -> str:
"""根据 ``settings.storage_backend`` 构造对象的 storage_uri。
- s3 模式:``s3://{bucket_name}/{object_key}``
- local 模式:``file://{absolute_bucket_path}/{object_key}``
local 模式下 ``bucket_name`` 是文件系统路径(见 ``actual_bucket_name``),
不能直接用 ``s3://`` 前缀,否则会产生 ``s3:///data/version/...`` 这种
非法 URI。因此返回 ``file://`` URI,并把相对路径先转成绝对路径。
"""
from common.config import settings # 延迟 import 避免循环
if settings.storage_backend == "local":
absolute_path = Path(bucket_name).absolute().as_posix()
return f"file://{absolute_path}/{object_key}"
return f"s3://{bucket_name}/{object_key}"
def build_storage_config(bucket_name: str) -> Dict[str, Any]: def build_storage_config(bucket_name: str) -> Dict[str, Any]:
"""根据 ``settings.storage_backend`` 构造 ``create_storage()`` 的入参。 """根据 ``settings.storage_backend`` 构造 ``create_storage()`` 的入参。
+26 -1
View File
@@ -3,6 +3,11 @@ services:
build: build:
context: . context: .
dockerfile: backend/Dockerfile dockerfile: backend/Dockerfile
logging:
driver: json-file
options:
max-size: "200m"
max-file: "10"
restart: "no" restart: "no"
command: command:
- uv - uv
@@ -21,6 +26,11 @@ services:
build: build:
context: . context: .
dockerfile: frontend/Dockerfile dockerfile: frontend/Dockerfile
logging:
driver: json-file
options:
max-size: "200m"
max-file: "10"
restart: unless-stopped restart: unless-stopped
# Architecture §2.2: this is the only service exposed to the host. The # Architecture §2.2: this is the only service exposed to the host. The
# default.conf file is mounted as a template; scripts/nginx-entrypoint.sh # default.conf file is mounted as a template; scripts/nginx-entrypoint.sh
@@ -50,6 +60,11 @@ services:
build: build:
context: . context: .
dockerfile: backend/Dockerfile dockerfile: backend/Dockerfile
logging:
driver: json-file
options:
max-size: "200m"
max-file: "10"
restart: unless-stopped restart: unless-stopped
# No host port: architecture §2.2 — only Nginx is externally reachable. # No host port: architecture §2.2 — only Nginx is externally reachable.
# No local-FS volume: backend stores everything in S3 (S3_*). # No local-FS volume: backend stores everything in S3 (S3_*).
@@ -73,7 +88,7 @@ services:
S3_RUN_LOG_BUCKET: ${S3_RUN_LOG_BUCKET:-run-log} S3_RUN_LOG_BUCKET: ${S3_RUN_LOG_BUCKET:-run-log}
S3_TRASH_BUCKET: ${S3_TRASH_BUCKET:-trash} S3_TRASH_BUCKET: ${S3_TRASH_BUCKET:-trash}
S3_TRASH_RETENTION_DAYS: ${S3_TRASH_RETENTION_DAYS:-30} S3_TRASH_RETENTION_DAYS: ${S3_TRASH_RETENTION_DAYS:-30}
READINESS_TARGETS: ${MYSQL_HOST:?MYSQL_HOST is required}:${MYSQL_PORT:-3306},${S3_HOST:-s3}:${S3_PORT:-9000},runtime:8000 READINESS_TARGETS: ${MYSQL_HOST:?MYSQL_HOST is required}:${MYSQL_PORT:-3306},runtime:8000
depends_on: depends_on:
migrate: migrate:
condition: service_completed_successfully condition: service_completed_successfully
@@ -96,6 +111,11 @@ services:
build: build:
context: . context: .
dockerfile: runtime/Dockerfile dockerfile: runtime/Dockerfile
logging:
driver: json-file
options:
max-size: "200m"
max-file: "10"
restart: unless-stopped restart: unless-stopped
ports: ports:
- 8892:8000 - 8892:8000
@@ -150,6 +170,11 @@ services:
build: build:
context: . context: .
dockerfile: schedule/Dockerfile dockerfile: schedule/Dockerfile
logging:
driver: json-file
options:
max-size: "200m"
max-file: "10"
restart: unless-stopped restart: unless-stopped
# No host port: architecture §2.2 — only Nginx is externally reachable. # No host port: architecture §2.2 — only Nginx is externally reachable.
# No local-FS volume: schedule executes nodes via tempfile.TemporaryDirectory # No local-FS volume: schedule executes nodes via tempfile.TemporaryDirectory
@@ -1,8 +1,9 @@
import { type ChangeEvent, type MouseEvent as ReactMouseEvent, type RefObject, useEffect, useMemo, useState } from "react"; import { type ChangeEvent, type MouseEvent as ReactMouseEvent, type RefObject, useMemo } 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, WorkspaceMember } from "~/services/api"; import { useScriptWorkspaceStore } from "~/features/platform/state/scriptWorkspaceStore";
import { useApi, useAuth, type AuthUser } from "~/context/AuthContext"; import type { ScriptItem, WorkspaceDirectory } from "~/services/api";
import { type AuthUser } from "~/context/AuthContext";
type ScriptExplorerProps = { type ScriptExplorerProps = {
scripts: ScriptItem[]; scripts: ScriptItem[];
@@ -50,36 +51,11 @@ export function ScriptExplorer({
uploadInputRef, uploadInputRef,
onHandleUpload, onHandleUpload,
}: ScriptExplorerProps) { }: ScriptExplorerProps) {
const api = useApi(); const expandedPaths = useScriptWorkspaceStore((s) => s.expandedPaths);
const { currentWorkspace } = useAuth(); const loadingChildrenPaths = useScriptWorkspaceStore(
const [members, setMembers] = useState<WorkspaceMember[]>([]); (s) => s.loadingChildrenPaths,
);
useEffect(() => { const onToggle = useScriptWorkspaceStore((s) => s.toggleExpanded);
if (!currentWorkspace) {
setMembers([]);
return;
}
let cancelled = false;
api
.listWorkspaceMembers(currentWorkspace.workspace_id)
.then((list) => {
if (!cancelled) setMembers(list);
})
.catch(() => {
if (!cancelled) setMembers([]);
});
return () => {
cancelled = true;
};
}, [api, currentWorkspace]);
const displayNameByUserId = useMemo(() => {
const map = new Map<string, string>();
for (const m of members) {
map.set(m.user_id, m.display_name || m.username || m.user_id);
}
return map;
}, [members]);
const memberScriptGroups = useMemo(() => { const memberScriptGroups = useMemo(() => {
const visibleScripts = const visibleScripts =
@@ -93,6 +69,10 @@ export function ScriptExplorer({
); );
const byOwner = new Map<string, ScriptItem[]>(); const byOwner = new Map<string, ScriptItem[]>();
// 当前用户的目录树即使没有脚本也要渲染,所以预置空组。
if (user?.user_id) {
byOwner.set(user.user_id, []);
}
for (const item of visibleScripts) { for (const item of visibleScripts) {
const list = byOwner.get(item.owner_user_id) ?? []; const list = byOwner.get(item.owner_user_id) ?? [];
list.push(item); list.push(item);
@@ -106,7 +86,7 @@ export function ScriptExplorer({
}[] = []; }[] = [];
for (const [ownerUserId, groupScripts] of byOwner.entries()) { for (const [ownerUserId, groupScripts] of byOwner.entries()) {
const displayName = const displayName =
displayNameByUserId.get(ownerUserId) ?? groupScripts[0]?.owner_display_name ??
(ownerUserId === user?.user_id ? user?.display_name : null) ?? (ownerUserId === user?.user_id ? user?.display_name : null) ??
`${ownerUserId.slice(-6)}`; `${ownerUserId.slice(-6)}`;
const groupUser = const groupUser =
@@ -139,7 +119,7 @@ export function ScriptExplorer({
}); });
return groups; return groups;
}, [filteredScripts, user, displayNameByUserId]); }, [filteredScripts, directories, user]);
return ( return (
<aside className="explorer"> <aside className="explorer">
@@ -202,23 +182,30 @@ export function ScriptExplorer({
</div> </div>
) : ( ) : (
<> <>
{memberScriptGroups.map((group) => ( {memberScriptGroups.map((group) => {
<WorkspaceTreeGroup const ownerKey = group.user?.user_id ?? "anon";
key={group.user?.user_id ?? "anon"} return (
title={`${group.user?.display_name}`} <WorkspaceTreeGroup
scripts={group.scripts} key={ownerKey}
directories={group.directories} groupKey={`__group__${ownerKey}`}
selectedId={selectedId} title={`${group.user?.display_name}`}
onSelect={onSelect} scripts={group.scripts}
onContextMenu={ directories={group.directories}
group.user?.user_id === user?.user_id selectedId={selectedId}
? onContextMenu onSelect={onSelect}
: undefined onContextMenu={
} group.user?.user_id === user?.user_id
readOnly={group.user?.user_id !== user?.user_id} ? onContextMenu
/> : undefined
))} }
{filteredScripts.length === 0 && ( readOnly={group.user?.user_id !== user?.user_id}
expandedPaths={expandedPaths}
onToggle={onToggle}
loadingChildrenPaths={loadingChildrenPaths}
/>
);
})}
{filteredScripts.length === 0 && directories.length === 0 && (
<div className="tree-empty"> <div className="tree-empty">
<span className="tree-empty__icon"> <span className="tree-empty__icon">
<Icon name="script" size={24} /> <Icon name="script" size={24} />
+2 -1
View File
@@ -230,7 +230,8 @@ export function useApi(): WorkspaceBoundApi {
deleteScript: (scriptId) => rawApi.deleteScript(workspaceId, scriptId), deleteScript: (scriptId) => rawApi.deleteScript(workspaceId, scriptId),
setScriptLock: (scriptId, isLocked) => setScriptLock: (scriptId, isLocked) =>
rawApi.setScriptLock(workspaceId, scriptId, isLocked), rawApi.setScriptLock(workspaceId, scriptId, isLocked),
listWorkspaceDirectories: () => rawApi.listWorkspaceDirectories(workspaceId), listWorkspaceDirectories: (parentPath?: string) =>
rawApi.listWorkspaceDirectories(workspaceId, parentPath ?? ""),
createWorkspaceDirectory: (directoryName, parentPath) => createWorkspaceDirectory: (directoryName, parentPath) =>
rawApi.createWorkspaceDirectory(workspaceId, directoryName, parentPath), rawApi.createWorkspaceDirectory(workspaceId, directoryName, parentPath),
deleteWorkspaceDirectory: (path) => deleteWorkspaceDirectory: (path) =>
@@ -0,0 +1,129 @@
import { useEffect, useRef, useState } from "react";
import Editor from "@monaco-editor/react";
import Icon from "../../components/common/Icon";
import type { ScriptItem } from "../../services/api";
interface PythonEditorProps {
script: ScriptItem;
scriptId: string;
initialContent: string;
saving: boolean;
dirty: boolean;
loading: boolean;
loadError?: string | null;
onChange: (scriptId: string, value: string) => void;
onSave: (scriptId: string) => void;
onEndEditing: (scriptId: string) => void;
onCloseTab: (scriptId: string) => void;
}
export function PythonEditor({
script,
scriptId,
initialContent,
saving,
dirty,
loading,
loadError,
onChange,
onSave,
onEndEditing,
onCloseTab,
}: PythonEditorProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const [value, setValue] = useState(initialContent);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const mod = e.metaKey || e.ctrlKey;
if (!mod) return;
if (e.key.toLowerCase() === "s") {
e.preventDefault();
if (dirty && !saving) onSave(scriptId);
} else if (e.key.toLowerCase() === "w") {
e.preventDefault();
onCloseTab(scriptId);
}
};
containerRef.current?.addEventListener("keydown", onKey);
return () => containerRef.current?.removeEventListener("keydown", onKey);
}, [dirty, saving, onSave, onCloseTab, scriptId]);
useEffect(() => {
if (!dirty || saving) return;
const t = window.setTimeout(() => onSave(scriptId), 30_000);
return () => window.clearTimeout(t);
}, [value, dirty, saving, onSave, scriptId]);
const hasSyncedRef = useRef(false);
useEffect(() => {
if (!loading && initialContent !== null && !hasSyncedRef.current) {
setValue(initialContent);
hasSyncedRef.current = true;
}
}, [loading, initialContent]);
if (loading) {
return (
<div className="python-editor" ref={containerRef}>
<section className="editor-opening-state" aria-live="polite" style={{ display: "block" }}>
<div className="editor-opening-state__icon">
<span className="button-spinner button-spinner--blue" />
</div>
<strong> Python </strong>
<p></p>
</section>
</div>
);
}
if (loadError) {
return (
<div className="python-editor" ref={containerRef}>
<section className="editor-opening-state has-error" aria-live="polite" style={{ display: "block" }}>
<div className="editor-opening-state__icon">
<Icon name="info" size={28} />
</div>
<strong>Python </strong>
<p>{loadError}</p>
</section>
</div>
);
}
return (
<div className="python-editor" ref={containerRef}>
{dirty && <div className="editor-dirty-banner"></div>}
<div className="python-editor__status">
<span>
<i /> Workspace Python Editor
</span>
<span>{script.relative_path}</span>
</div>
<div className="python-editor__canvas">
<Editor
height="100%"
language="python"
theme="vs"
value={value}
onChange={(v) => {
setValue(v ?? "");
onChange(scriptId, v ?? "");
}}
options={{
minimap: { enabled: initialContent.length > 5000 },
wordWrap: "on",
fontSize: 13,
automaticLayout: true,
renderLineHighlight: "gutter",
contextmenu: false,
dragAndDrop: false,
scrollBeyondLastLine: false,
}}
/>
</div>
</div>
);
}
@@ -4,15 +4,16 @@ import type {
LatestVersion, LatestVersion,
ScriptItem, ScriptItem,
ScriptType, ScriptType,
} from "../../services/api"; } from "~/services/api";
import { scriptIcon } from "./WorkspaceTree"; import { scriptIcon } from "./WorkspaceTree";
import type { MouseEvent as ReactMouseEvent } from "react"; import type { MouseEvent as ReactMouseEvent } from "react";
import Editor from "@monaco-editor/react"; import Editor from "@monaco-editor/react";
import { useRef, useLayoutEffect, useState, useEffect } from "react"; import { useRef, useLayoutEffect, useState, useEffect } from "react";
import { useScriptWorkspaceStore } from "./state/scriptWorkspaceStore"; import { useScriptWorkspaceStore, type PythonEditorBuffer } from "./state/scriptWorkspaceStore";
import { useAuth } from "../../context/AuthContext"; import { PythonEditor } from "./PythonEditor";
import { getScriptContent } from "../../services/api"; import { useAuth } from "~/context/AuthContext";
import { getScriptContent } from "~/services/api";
type ToastState = { type ToastState = {
tone: "success" | "error" | "info"; tone: "success" | "error" | "info";
@@ -28,6 +29,7 @@ interface CachedSession {
type ScriptWorkspaceProps = { type ScriptWorkspaceProps = {
script: ScriptItem; script: ScriptItem;
scripts: ScriptItem[];
sessionCache: Map<string, CachedSession>; sessionCache: Map<string, CachedSession>;
editSession: ActiveEditSession | null; editSession: ActiveEditSession | null;
jupyterUrl: string | null; jupyterUrl: string | null;
@@ -43,6 +45,13 @@ type ScriptWorkspaceProps = {
onNewTab: () => void; onNewTab: () => void;
onPublish: () => void; onPublish: () => void;
onInfo: (toast: ToastState) => void; onInfo: (toast: ToastState) => void;
pythonEditorBuffers: Record<string, PythonEditorBuffer>;
onOpenPythonEditor: () => void;
onSetPythonEditorContent: (scriptId: string, v: string) => void;
onSavePythonEditor: (scriptId: string) => void;
onExitPythonEditor: (scriptId: string) => void;
onClosePythonTab: (scriptId: string) => void;
}; };
function formatTime(value: string) { function formatTime(value: string) {
@@ -104,6 +113,7 @@ function confineJupyterFrame(frame: HTMLIFrameElement): void {
export function ScriptWorkspace({ export function ScriptWorkspace({
script, script,
scripts,
sessionCache, sessionCache,
editSession, editSession,
jupyterUrl, jupyterUrl,
@@ -119,13 +129,30 @@ export function ScriptWorkspace({
onNewTab, onNewTab,
onPublish, onPublish,
onInfo, onInfo,
pythonEditorBuffers,
onOpenPythonEditor,
onSetPythonEditorContent,
onSavePythonEditor,
onExitPythonEditor,
onClosePythonTab,
}: ScriptWorkspaceProps) { }: ScriptWorkspaceProps) {
const { user } = useAuth(); const { user } = useAuth();
const tabbarRef = useRef<HTMLDivElement | null>(null); const tabbarRef = useRef<HTMLDivElement | null>(null);
const isNotebook = script.script_type === "notebook"; const isNotebook = script.script_type === "notebook";
const isPython = script.script_type === "python";
// 判断当前选中的文件是否正在编辑(需要同时检查 session_status 和 script_id // 判断当前选中的文件是否正在编辑(需要同时检查 session_status 和 script_id
const isEditing = editSession?.session_status === "active" const isEditing = editSession?.session_status === "active"
&& editSession?.script_id === script.script_id; && editSession?.script_id === script.script_id;
const activePythonBuf = isPython ? pythonEditorBuffers[script.script_id] : null;
const isPythonEditing = isPython && !!activePythonBuf;
const showSaveButton = isPythonEditing && activePythonBuf &&
(activePythonBuf.dirty || activePythonBuf.saving || activePythonBuf.initial);
const saveDisabled = !activePythonBuf?.dirty || activePythonBuf?.saving;
const pythonTabBuffers = openTabs.filter(
(t) => t.scriptType === "python" && pythonEditorBuffers[t.scriptId],
);
const hasEmbeddedContent = sessionCache.size > 0 || pythonTabBuffers.length > 0;
// 只读模式状态 // 只读模式状态
const [readOnlyContent, setReadOnlyContent] = useState<string | null>(null); const [readOnlyContent, setReadOnlyContent] = useState<string | null>(null);
@@ -249,7 +276,23 @@ export function ScriptWorkspace({
<strong>{script.script_name}</strong> <strong>{script.script_name}</strong>
</div> </div>
<div className="editor-toolbar__actions"> <div className="editor-toolbar__actions">
{isEditing ? ( {isPythonEditing ? (
<>
{showSaveButton && (
<button
type="button"
className={`editor-save-button${activePythonBuf?.saving ? " is-saving" : ""}`}
disabled={saveDisabled}
onClick={() => onSavePythonEditor(script.script_id)}
>
{activePythonBuf?.saving
? <><span className="button-spinner button-spinner--blue" /> </>
: "保存"}
</button>
)}
<button type="button" className="end-edit-button" onClick={() => onExitPythonEditor(script.script_id)}></button>
</>
) : isEditing ? (
<button <button
className="end-edit-button" className="end-edit-button"
type="button" type="button"
@@ -327,6 +370,40 @@ export function ScriptWorkspace({
) : ( ) : (
// 正常编辑模式:原有的 iframe + Monaco 预览逻辑 // 正常编辑模式:原有的 iframe + Monaco 预览逻辑
<div className={`editor-canvas ${sessionCache.size > 0 ? 'is-embedded' : ''}`} style={sessionCache.size > 0 ? { position: 'relative', minHeight: '0', height: '100%' } : undefined}> <div className={`editor-canvas ${sessionCache.size > 0 ? 'is-embedded' : ''}`} style={sessionCache.size > 0 ? { position: 'relative', minHeight: '0', height: '100%' } : undefined}>
{/* 多 PythonEditor 实例:每个有 buffer 的 python tab 都挂载,仅 active 可见 */}
{pythonTabBuffers.map((tab) => {
const tabScript = scripts.find((s) => s.script_id === tab.scriptId);
if (!tabScript) return null;
const buf = pythonEditorBuffers[tab.scriptId];
const isActive = tab.scriptId === script.script_id;
return (
<section
key={tab.scriptId}
className="python-editor-mount"
style={{
position: isActive ? "relative" : "absolute",
visibility: isActive ? "visible" : "hidden",
pointerEvents: isActive ? "auto" : "none",
width: "100%",
height: "100%",
}}
>
<PythonEditor
scriptId={tab.scriptId}
script={tabScript}
initialContent={buf.initialContent ?? ""}
loading={buf.initialContent === null && !buf.loadError}
loadError={buf.loadError}
saving={buf.saving}
dirty={buf.dirty}
onChange={onSetPythonEditorContent}
onSave={onSavePythonEditor}
onEndEditing={onExitPythonEditor}
onCloseTab={onClosePythonTab}
/>
</section>
);
})}
{/* 渲染所有缓存的 iframe */} {/* 渲染所有缓存的 iframe */}
{Array.from(sessionCache.entries()).map(([scriptId, cached]) => { {Array.from(sessionCache.entries()).map(([scriptId, cached]) => {
const isActive = scriptId === script.script_id; const isActive = scriptId === script.script_id;
@@ -366,67 +443,67 @@ export function ScriptWorkspace({
); );
})} })}
{/* 当前脚本没有缓存时的状态显示 */} {/* 当前脚本没有缓存时的状态显示 */}
{!sessionCache.has(script.script_id) ? ( {!sessionCache.has(script.script_id) ? (
isNotebook ? ( isNotebook ? (
<section <section
className={`editor-opening-state${openError ? " has-error" : ""}`} className={`editor-opening-state${openError ? " has-error" : ""}`}
aria-live="polite" aria-live="polite"
style={{ display: 'block' }} style={{ display: 'block' }}
>
<div className="editor-opening-state__icon">
{openError
? <Icon name="info" size={28} />
: <span className="button-spinner button-spinner--blue" />}
</div>
<strong>
{openError ? "Notebook 打开失败" : "正在打开 Notebook"}
</strong>
<p>
{openError
? openError
: "正在获取编辑锁并连接 Workspace Jupyter Server…"}
</p>
{openError && (
<button
className="open-editor-button"
type="button"
disabled={editBusy}
onClick={onOpenEditor}
>
{editBusy
? <span className="button-spinner button-spinner--blue" />
: <Icon name="refresh" size={16} />}
{editBusy ? "正在重试…" : "重试打开"}
</button>
)}
</section>
) : isPython && !activePythonBuf ? (
<section className="script-overview">
<div className="script-overview__header">
<div>
<span className="section-kicker">PYTHON SCRIPT</span>
<h2>{script.script_name}</h2>
<p>{script.relative_path}</p>
</div>
<button
className={`open-editor-button${isEditing ? " is-editing" : ""}`}
type="button"
disabled={editBusy}
onClick={onOpenEditor}
> >
<div className="editor-opening-state__icon"> {editBusy
{openError ? <span className="button-spinner button-spinner--blue" />
? <Icon name="info" size={28} /> : <Icon name="external" size={17} />}
: <span className="button-spinner button-spinner--blue" />} {editBusy
</div> ? "正在准备编辑器…"
<strong> : isEditing
{openError ? "Notebook 打开失败" : "正在打开 Notebook"} ? "继续编辑"
</strong> : "打开编辑器"}
<p> </button>
{openError </div>
? openError
: "正在获取编辑锁并连接 Workspace Jupyter Server…"}
</p>
{openError && (
<button
className="open-editor-button"
type="button"
disabled={editBusy}
onClick={onOpenEditor}
>
{editBusy
? <span className="button-spinner button-spinner--blue" />
: <Icon name="refresh" size={16} />}
{editBusy ? "正在重试…" : "重试打开"}
</button>
)}
</section>
) : (
<section className="script-overview">
<div className="script-overview__header">
<div>
<span className="section-kicker">PYTHON SCRIPT</span>
<h2>{script.script_name}</h2>
<p>{script.relative_path}</p>
</div>
<button
className={`open-editor-button${isEditing ? " is-editing" : ""}`}
type="button"
disabled={editBusy}
onClick={onOpenEditor}
>
{editBusy
? <span className="button-spinner button-spinner--blue" />
: <Icon name="external" size={17} />}
{editBusy
? "正在准备编辑器…"
: isEditing
? "继续编辑"
: "打开编辑器"}
</button>
</div>
<div className="metadata-grid"> <div className="metadata-grid">
<div> <div>
<span></span> <span></span>
<strong>Python</strong> <strong>Python</strong>
@@ -448,8 +525,7 @@ export function ScriptWorkspace({
<strong>{formatTime(script.updated_at)}</strong> <strong>{formatTime(script.updated_at)}</strong>
</div> </div>
</div> </div>
<div className="preview-card">
<div className="preview-card">
<div className="preview-card__bar"> <div className="preview-card__bar">
<div> <div>
<span className="window-dot window-dot--red" /> <span className="window-dot window-dot--red" />
@@ -465,23 +541,23 @@ export function ScriptWorkspace({
/> />
</div> </div>
<div className="integrity-row"> <div className="integrity-row">
<span> <span>
<Icon name="check" size={15} /> <Icon name="check" size={15} />
</span> {/*{isEditing ? "Demo 无锁编辑会话已连接" : "工作副本已同步"}*/}
<span>SHA-256&nbsp; {shortHash(script.content_hash)}</span> </span>
<span> <span>SHA-256&nbsp; {shortHash(script.content_hash)}</span>
&nbsp; <span>
{versionsLoading &nbsp;
? "加载中" {versionsLoading
: latestVersion ? "加载中"
? `${latestVersion.version_label} · ${latestVersion.versions_id}` : latestVersion
: "尚未发布"} ? `${latestVersion.version_label} · ${latestVersion.versions_id}`
</span> : "尚未发布"}
</div> </span>
</section> </div>
) </section>
) : null} ) : null):null}
</div> </div>
)} )}
</> </>
+41 -2
View File
@@ -32,6 +32,8 @@ export default function ScriptsPage() {
const latestVersion = useScriptWorkspaceStore((s) => s.latestVersion); const latestVersion = useScriptWorkspaceStore((s) => s.latestVersion);
const latestVersionLoading = useScriptWorkspaceStore((s) => s.latestVersionLoading); const latestVersionLoading = useScriptWorkspaceStore((s) => s.latestVersionLoading);
const pythonEditorBuffers = useScriptWorkspaceStore((s) => s.pythonEditorBuffers);
// store actions // store actions
const setKeyword = useScriptWorkspaceStore((s) => s.setKeyword); const setKeyword = useScriptWorkspaceStore((s) => s.setKeyword);
const load = useScriptWorkspaceStore((s) => s.load); const load = useScriptWorkspaceStore((s) => s.load);
@@ -42,6 +44,10 @@ export default function ScriptsPage() {
const switchTab = useScriptWorkspaceStore((s) => s.switchTab); const switchTab = useScriptWorkspaceStore((s) => s.switchTab);
const openScriptEditor = useScriptWorkspaceStore((s) => s.openScriptEditor); const openScriptEditor = useScriptWorkspaceStore((s) => s.openScriptEditor);
const endEditing = useScriptWorkspaceStore((s) => s.endEditing); const endEditing = useScriptWorkspaceStore((s) => s.endEditing);
const openPythonEditor = useScriptWorkspaceStore((s) => s.openPythonEditor);
const setPythonEditorContent = useScriptWorkspaceStore((s) => s.setPythonEditorContent);
const savePythonEditor = useScriptWorkspaceStore((s) => s.savePythonEditor);
const exitPythonEditor = useScriptWorkspaceStore((s) => s.exitPythonEditor);
const loadLatestVersion = useScriptWorkspaceStore((s) => s.loadLatestVersion); const loadLatestVersion = useScriptWorkspaceStore((s) => s.loadLatestVersion);
const createScript = useScriptWorkspaceStore((s) => s.createScript); const createScript = useScriptWorkspaceStore((s) => s.createScript);
const uploadScripts = useScriptWorkspaceStore((s) => s.uploadScripts); const uploadScripts = useScriptWorkspaceStore((s) => s.uploadScripts);
@@ -166,6 +172,20 @@ export default function ScriptsPage() {
}; };
}, [contextMenu, closeContextMenu]); }, [contextMenu, closeContextMenu]);
// python editor handlers with scriptId forwarding
const handleSetPythonEditorContent = (scriptId: string, value: string) => {
setPythonEditorContent(scriptId, value);
};
const handleSavePythonEditor = (scriptId: string) => {
void savePythonEditor(scriptId);
};
const handleExitPythonEditor = (scriptId: string) => {
exitPythonEditor(scriptId);
};
const handleClosePythonTab = (scriptId: string) => {
void closeTab(scriptId);
};
// 5) handlers // 5) handlers
const handleUpload = (event: React.ChangeEvent<HTMLInputElement>) => { const handleUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files ?? []); const files = Array.from(event.target.files ?? []);
@@ -242,12 +262,31 @@ export default function ScriptsPage() {
scriptType: s?.script_type ?? "notebook", scriptType: s?.script_type ?? "notebook",
}; };
})} })}
onOpenEditor={() => void openScriptEditor(selected)} onOpenEditor={() => {
onEndEditing={() => void endEditing()} if (selected.script_type === "python") {
void openPythonEditor(selected);
} else {
void openScriptEditor(selected);
}
}}
onEndEditing={() => {
if (selected && pythonEditorBuffers[selected.script_id]) {
exitPythonEditor(selected.script_id);
} else {
void endEditing();
}
}}
onClose={(scriptId, event) => void closeTab(scriptId, event)} onClose={(scriptId, event) => void closeTab(scriptId, event)}
onSwitchTab={switchTab} onSwitchTab={switchTab}
onNewTab={() => openCreateDialog("")} onNewTab={() => openCreateDialog("")}
onPublish={() => openPublishDialog(selected)} onPublish={() => openPublishDialog(selected)}
scripts={scripts}
pythonEditorBuffers={pythonEditorBuffers}
onOpenPythonEditor={() => void openPythonEditor(selected)}
onSetPythonEditorContent={handleSetPythonEditorContent}
onSavePythonEditor={handleSavePythonEditor}
onExitPythonEditor={handleExitPythonEditor}
onClosePythonTab={handleClosePythonTab}
onInfo={(t) => pushToast(t)} onInfo={(t) => pushToast(t)}
/> />
) : ( ) : (
@@ -1,4 +1,4 @@
import { type MouseEvent as ReactMouseEvent, useState } from "react"; import { type MouseEvent as ReactMouseEvent, useEffect } from "react";
import Icon from "../../components/common/Icon"; import Icon from "../../components/common/Icon";
import type { import type {
@@ -23,9 +23,14 @@ type WorkspaceTreeProps = {
target: WorkspaceTreeTarget, target: WorkspaceTreeTarget,
) => void; ) => void;
readOnly?: boolean; readOnly?: boolean;
// 唯一标识此 group(通常 `__group__<owner_user_id>`),让多个 owner 的 group 各自独立展开。
groupKey: string;
expandedPaths: Set<string>;
onToggle: (path: string) => void;
loadingChildrenPaths: Set<string>;
}; };
type WorkspaceTreeItemsProps = Omit<WorkspaceTreeProps, "title" | "readOnly"> & { type WorkspaceTreeItemsProps = Omit<WorkspaceTreeProps, "title" | "readOnly" | "groupKey"> & {
path: string; path: string;
depth: number; depth: number;
}; };
@@ -62,15 +67,27 @@ export function WorkspaceTreeGroup({
onSelect, onSelect,
onContextMenu, onContextMenu,
readOnly = false, readOnly = false,
groupKey,
expandedPaths,
onToggle,
loadingChildrenPaths,
}: WorkspaceTreeProps) { }: WorkspaceTreeProps) {
const [open, setOpen] = useState(true); const open = expandedPaths.has(groupKey);
// 首次挂载自动展开(保留原本 useState(true) 的默认展开行为)。
// toggleExpanded 内识别 `__group__` 前缀,不会触发 loadChildren。
useEffect(() => {
if (!expandedPaths.has(groupKey)) {
void onToggle(groupKey);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [groupKey]);
return ( return (
<div className="tree-group"> <div className="tree-group">
<button <button
className={`tree-group__title${open ? " is-open" : ""}`} className={`tree-group__title${open ? " is-open" : ""}`}
type="button" type="button"
aria-expanded={open} aria-expanded={open}
onClick={() => setOpen((current) => !current)} onClick={() => onToggle(groupKey)}
onContextMenu={onContextMenu onContextMenu={onContextMenu
? (event) => onContextMenu(event, { kind: "root", path: "" }) ? (event) => onContextMenu(event, { kind: "root", path: "" })
: undefined} : undefined}
@@ -90,6 +107,9 @@ export function WorkspaceTreeGroup({
selectedId={selectedId} selectedId={selectedId}
onSelect={onSelect} onSelect={onSelect}
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
expandedPaths={expandedPaths}
onToggle={onToggle}
loadingChildrenPaths={loadingChildrenPaths}
/> />
{scripts.length === 0 && directories.length === 0 && ( {scripts.length === 0 && directories.length === 0 && (
<p className="tree-group__empty"> <p className="tree-group__empty">
@@ -110,6 +130,9 @@ function WorkspaceTreeItems({
selectedId, selectedId,
onSelect, onSelect,
onContextMenu, onContextMenu,
expandedPaths,
onToggle,
loadingChildrenPaths,
}: WorkspaceTreeItemsProps) { }: WorkspaceTreeItemsProps) {
const childDirectories = directories.filter( const childDirectories = directories.filter(
(item) => item.parent_path === path, (item) => item.parent_path === path,
@@ -129,6 +152,9 @@ function WorkspaceTreeItems({
selectedId={selectedId} selectedId={selectedId}
onSelect={onSelect} onSelect={onSelect}
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
expandedPaths={expandedPaths}
onToggle={onToggle}
loadingChildrenPaths={loadingChildrenPaths}
/> />
))} ))}
{childScripts.map((item) => ( {childScripts.map((item) => (
@@ -177,17 +203,21 @@ function DirectoryBranch({
selectedId, selectedId,
onSelect, onSelect,
onContextMenu, onContextMenu,
expandedPaths,
onToggle,
loadingChildrenPaths,
}: Omit<WorkspaceTreeItemsProps, "path"> & { }: Omit<WorkspaceTreeItemsProps, "path"> & {
directory: WorkspaceDirectory; directory: WorkspaceDirectory;
}) { }) {
const [open, setOpen] = useState(true); const open = expandedPaths.has(directory.path);
const childrenLoading = loadingChildrenPaths.has(directory.path);
return ( return (
<div className="directory-branch"> <div className="directory-branch">
<button <button
className="directory-row" className="directory-row"
style={{ paddingLeft: 10 + depth * 16 }} style={{ paddingLeft: 10 + depth * 16 }}
type="button" type="button"
onClick={() => setOpen((current) => !current)} onClick={() => onToggle(directory.path)}
onContextMenu={onContextMenu onContextMenu={onContextMenu
? (event) => onContextMenu(event, { ? (event) => onContextMenu(event, {
kind: "directory", kind: "directory",
@@ -200,6 +230,7 @@ function DirectoryBranch({
</span> </span>
<Icon name="folder" size={17} /> <Icon name="folder" size={17} />
<strong title={directory.path}>{directory.name}</strong> <strong title={directory.path}>{directory.name}</strong>
{childrenLoading && <span className="loading-spinner" />}
</button> </button>
{open && ( {open && (
<WorkspaceTreeItems <WorkspaceTreeItems
@@ -210,6 +241,9 @@ function DirectoryBranch({
selectedId={selectedId} selectedId={selectedId}
onSelect={onSelect} onSelect={onSelect}
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
expandedPaths={expandedPaths}
onToggle={onToggle}
loadingChildrenPaths={loadingChildrenPaths}
/> />
)} )}
</div> </div>
@@ -20,6 +20,15 @@ type CachedSession = {
lastActiveTime: number; lastActiveTime: number;
}; };
export type PythonEditorBuffer = {
initialContent: string | null;
content: string | null;
dirty: boolean;
saving: boolean;
initial: boolean;
loadError: string | null;
};
// 模块级可变 holder(非响应式,避免 React 重渲) // 模块级可变 holder(非响应式,避免 React 重渲)
const sessionCache = new Map<string, CachedSession>(); const sessionCache = new Map<string, CachedSession>();
let _selectedId: string | null = null; let _selectedId: string | null = null;
@@ -29,6 +38,7 @@ let _editorOpenRequest = 0;
let _api: WorkspaceBoundApi | null = null; let _api: WorkspaceBoundApi | null = null;
let _previewController: AbortController | null = null; let _previewController: AbortController | null = null;
let _previewRequest = 0; let _previewRequest = 0;
let _pythonEditorOpeningIds = new Set<string>();
export const bindScriptWorkspaceApi = (api: WorkspaceBoundApi | null) => { export const bindScriptWorkspaceApi = (api: WorkspaceBoundApi | null) => {
_api = api; _api = api;
@@ -68,6 +78,11 @@ type State = {
previewLoading: boolean; previewLoading: boolean;
previewError: string | null; previewError: string | null;
pythonEditorBuffers: Record<string, PythonEditorBuffer>;
expandedPaths: Set<string>;
loadingChildrenPaths: Set<string>;
loadedChildPaths: Set<string>;
// actions // actions
setApiOnline: (online: boolean) => void; setApiOnline: (online: boolean) => void;
setKeyword: (keyword: string) => void; setKeyword: (keyword: string) => void;
@@ -79,6 +94,11 @@ type State = {
switchTab: (id: string) => void; switchTab: (id: string) => void;
openScriptEditor: (script: ScriptItem, showToast?: boolean) => Promise<void>; openScriptEditor: (script: ScriptItem, showToast?: boolean) => Promise<void>;
endEditing: (closeTabFlag?: boolean, showToast?: boolean) => Promise<void>; endEditing: (closeTabFlag?: boolean, showToast?: boolean) => Promise<void>;
openPythonEditor: (script: ScriptItem, showToast?: boolean) => Promise<void>;
setPythonEditorContent: (scriptId: string, value: string) => void;
savePythonEditor: (scriptId: string) => Promise<void>;
exitPythonEditor: (scriptId: string) => void;
exitAllPythonEditors: () => void;
loadLatestVersion: (scriptId: string) => Promise<void>; loadLatestVersion: (scriptId: string) => Promise<void>;
loadPreview: (workspaceId: string, filePath: string) => Promise<void>; loadPreview: (workspaceId: string, filePath: string) => Promise<void>;
createScript: (form: NewScriptForm) => Promise<ScriptItem | null>; createScript: (form: NewScriptForm) => Promise<ScriptItem | null>;
@@ -86,6 +106,8 @@ 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>;
toggleExpanded: (path: string) => Promise<void>;
loadChildren: (parentPath: string) => Promise<void>;
toggleScriptLock: (script: ScriptItem) => 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>;
@@ -148,6 +170,11 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
previewLoading: false, previewLoading: false,
previewError: null, previewError: null,
pythonEditorBuffers: {},
expandedPaths: new Set<string>(),
loadingChildrenPaths: new Set<string>(),
loadedChildPaths: new Set<string>(),
setApiOnline: (online) => set({ apiOnline: online }), setApiOnline: (online) => set({ apiOnline: online }),
setKeyword: (keyword) => set({ keyword }), setKeyword: (keyword) => set({ keyword }),
@@ -161,6 +188,7 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
_editSession = null; _editSession = null;
editSessionHandle.current = null; editSessionHandle.current = null;
sessionCache.clear(); sessionCache.clear();
_pythonEditorOpeningIds.clear();
set({ set({
scripts: [], scripts: [],
directories: [], directories: [],
@@ -176,6 +204,10 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
previewCodeSize: null, previewCodeSize: null,
previewLoading: false, previewLoading: false,
previewError: null, previewError: null,
pythonEditorBuffers: {},
expandedPaths: new Set<string>(),
loadingChildrenPaths: new Set<string>(),
loadedChildPaths: new Set<string>(),
}); });
}, },
@@ -186,12 +218,15 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
try { try {
const [items, folderItems] = await Promise.all([ const [items, folderItems] = await Promise.all([
api.listScripts(), api.listScripts(),
api.listWorkspaceDirectories(), api.listWorkspaceDirectories(""),
]); ]);
const nextLoaded = new Set(get().loadedChildPaths);
nextLoaded.add("");
set({ set({
scripts: items, scripts: items,
directories: folderItems, directories: folderItems,
apiOnline: true, apiOnline: true,
loadedChildPaths: nextLoaded,
}); });
const validIds = new Set(items.map((item) => item.script_id)); const validIds = new Set(items.map((item) => item.script_id));
const currentSelected = get().selectedId; const currentSelected = get().selectedId;
@@ -215,6 +250,57 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
} }
}, },
loadChildren: async (parentPath) => {
const api = requireApi();
if (get().loadedChildPaths.has(parentPath)) return;
const next = new Set(get().loadingChildrenPaths);
next.add(parentPath);
set({ loadingChildrenPaths: next });
try {
const children = await api.listWorkspaceDirectories(parentPath);
set((state) => {
const nextLoaded = new Set(state.loadedChildPaths);
nextLoaded.add(parentPath);
const trimmed = state.directories.filter(
(d) => d.parent_path !== parentPath,
);
return {
directories: [...trimmed, ...children],
loadedChildPaths: nextLoaded,
loadingChildrenPaths: new Set(
[...state.loadingChildrenPaths].filter((p) => p !== parentPath),
),
};
});
} catch (error) {
set((state) => ({
loadingChildrenPaths: new Set(
[...state.loadingChildrenPaths].filter((p) => p !== parentPath),
),
}));
pushToast(
"error",
error instanceof Error ? error.message : "目录加载失败",
);
}
},
toggleExpanded: async (path) => {
const state = get();
const isOpen = state.expandedPaths.has(path);
const next = new Set(state.expandedPaths);
if (isOpen) {
next.delete(path);
} else {
next.add(path);
// group key (`__group__<owner_id>`) 不是 workspace 路径,不调 loadChildren
if (!path.startsWith("__group__") && !state.loadedChildPaths.has(path)) {
void get().loadChildren(path);
}
}
set({ expandedPaths: next });
},
selectScript: (id) => { selectScript: (id) => {
_selectedId = id; _selectedId = id;
set({ selectedId: id }); set({ selectedId: id });
@@ -236,6 +322,15 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
closeTab: async (id, event) => { closeTab: async (id, event) => {
event?.stopPropagation(); event?.stopPropagation();
const buffer = get().pythonEditorBuffers[id];
if (buffer?.dirty && !buffer.saving) {
const name = get().scripts.find((s) => s.script_id === id)?.script_name ?? "该脚本";
const ok = window.confirm(`当前脚本有未保存修改,确定关闭 "${name}" 吗?`);
if (!ok) return;
}
if (buffer) {
get().exitPythonEditor(id);
}
if (_editSession?.script_id === id) { if (_editSession?.script_id === id) {
await get().endEditing(false, false); await get().endEditing(false, false);
} }
@@ -255,6 +350,13 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
}, },
switchTab: (id) => { switchTab: (id) => {
const current = get().selectedId;
if (current && current !== id) {
const curBuffer = get().pythonEditorBuffers[current];
if (curBuffer?.dirty && !curBuffer.saving) {
void get().savePythonEditor(current);
}
}
if (_selectedId !== id) { if (_selectedId !== id) {
_editorOpenRequest += 1; _editorOpenRequest += 1;
set({ editorOpenError: null }); set({ editorOpenError: null });
@@ -268,6 +370,140 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
} }
}, },
openPythonEditor: async (script, showToast = true) => {
if (!script) return;
if (_pythonEditorOpeningIds.has(script.script_id)) return;
_pythonEditorOpeningIds.add(script.script_id);
try {
const workspaceId = script.workspace_id;
const url =
`/jupyter/${workspaceId}/api/contents/${script.jupyter_path}` +
`?type=file&content=1&hash=1&format=text`;
const response = await fetch(url, {
credentials: "include",
cache: "no-store",
});
if (!response.ok) {
throw new Error(`加载文件失败: ${response.status}`);
}
const data = await response.json();
const content = typeof data.content === "string" ? data.content : "";
set((state) => ({
pythonEditorBuffers: {
...state.pythonEditorBuffers,
[script.script_id]: {
initialContent: content,
content,
dirty: false,
saving: false,
initial: true,
loadError: null,
},
},
}));
} catch (error) {
const message = error instanceof Error ? error.message : "打开 Python 编辑器失败";
set((state) => ({
pythonEditorBuffers: {
...state.pythonEditorBuffers,
[script.script_id]: {
initialContent: null,
content: null,
dirty: false,
saving: false,
initial: false,
loadError: message,
},
},
}));
pushToast("error", message);
} finally {
_pythonEditorOpeningIds.delete(script.script_id);
}
},
setPythonEditorContent: (scriptId, value) => {
set((state) => {
const buffer = state.pythonEditorBuffers[scriptId];
if (!buffer) return state;
return {
pythonEditorBuffers: {
...state.pythonEditorBuffers,
[scriptId]: {
...buffer,
content: value,
dirty: value !== buffer.initialContent,
},
},
};
});
},
savePythonEditor: async (scriptId) => {
const buffer = get().pythonEditorBuffers[scriptId];
if (!buffer || buffer.content === null || !buffer.dirty || buffer.saving) {
return;
}
const script = get().scripts.find((s) => s.script_id === scriptId);
if (!script) return;
const savedContent = buffer.content;
set((state) => ({
pythonEditorBuffers: {
...state.pythonEditorBuffers,
[scriptId]: { ...buffer, saving: true },
},
}));
try {
const api = requireApi();
await api.updateScript(scriptId, { content: savedContent });
set((state) => {
const current = state.pythonEditorBuffers[scriptId];
if (!current) return state;
return {
pythonEditorBuffers: {
...state.pythonEditorBuffers,
[scriptId]: {
...current,
initialContent: savedContent,
dirty: current.content !== savedContent,
saving: false,
initial: false,
},
},
};
});
pushToast("success", "已保存");
} catch (error) {
set((state) => {
const current = state.pythonEditorBuffers[scriptId];
if (!current) return state;
return {
pythonEditorBuffers: {
...state.pythonEditorBuffers,
[scriptId]: { ...current, saving: false },
},
};
});
pushToast(
"error",
error instanceof Error ? error.message : "保存失败",
);
}
},
exitPythonEditor: (scriptId) => {
set((state) => {
const next = { ...state.pythonEditorBuffers };
delete next[scriptId];
return { pythonEditorBuffers: next };
});
},
exitAllPythonEditors: () => {
_pythonEditorOpeningIds.clear();
set({ pythonEditorBuffers: {} });
},
openScriptEditor: async (script, showToast = true) => { openScriptEditor: async (script, showToast = true) => {
if (!script) return; if (!script) return;
if (_editorOpening) return; if (_editorOpening) return;
@@ -541,7 +777,25 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
ui.setFolderBusy(true); ui.setFolderBusy(true);
try { try {
await api.createWorkspaceDirectory(trimmed, parentPath); await api.createWorkspaceDirectory(trimmed, parentPath);
await get().load(true); if (parentPath === "") {
await get().load(true);
} else {
set((state) => {
const nextLoaded = new Set(state.loadedChildPaths);
for (const p of state.loadedChildPaths) {
if (p.startsWith(`${parentPath}/`)) nextLoaded.delete(p);
}
nextLoaded.delete(parentPath);
return {
loadedChildPaths: nextLoaded,
directories: state.directories.filter(
(d) => !d.parent_path.startsWith(`${parentPath}/`),
),
expandedPaths: new Set(state.expandedPaths),
};
});
await get().loadChildren(parentPath);
}
ui.closeFolderDialog(); ui.closeFolderDialog();
pushToast("success", `${trimmed} 文件夹已创建`); pushToast("success", `${trimmed} 文件夹已创建`);
} catch (error) { } catch (error) {
@@ -608,6 +862,9 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
await get().endEditing(false, false); await get().endEditing(false, false);
if (_editSession?.script_id === activeScript.script_id) return; if (_editSession?.script_id === activeScript.script_id) return;
} }
const parentPath = path.includes("/")
? path.split("/").slice(0, -1).join("/")
: "";
try { try {
const result = await api.deleteWorkspaceDirectory(path); const result = await api.deleteWorkspaceDirectory(path);
const selectedScript = get().scripts.find( const selectedScript = get().scripts.find(
@@ -619,7 +876,28 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
) { ) {
get().selectScript(null); get().selectScript(null);
} }
await get().load(true); set((state) => {
const nextLoaded = new Set(state.loadedChildPaths);
const nextExpanded = new Set(state.expandedPaths);
for (const p of state.loadedChildPaths) {
if (p === path || p.startsWith(`${path}/`)) nextLoaded.delete(p);
}
for (const p of state.expandedPaths) {
if (p === path || p.startsWith(`${path}/`)) nextExpanded.delete(p);
}
return {
loadedChildPaths: nextLoaded,
expandedPaths: nextExpanded,
directories: state.directories.filter(
(d) => d.parent_path !== path && !d.parent_path.startsWith(`${path}/`),
),
};
});
if (parentPath === "") {
await get().load(true);
} else {
await get().loadChildren(parentPath);
}
pushToast( pushToast(
"success", "success",
`${path} 已删除(含 ${result.deleted_scripts} 个脚本)`, `${path} 已删除(含 ${result.deleted_scripts} 个脚本)`,
+10 -4
View File
@@ -173,6 +173,7 @@ export type ScriptItem = {
workspace_id: string; workspace_id: string;
current_object_id: string; current_object_id: string;
owner_user_id: string; owner_user_id: string;
owner_display_name: string | null;
script_name: string; script_name: string;
script_type: ScriptType; script_type: ScriptType;
visibility: Visibility; visibility: Visibility;
@@ -190,6 +191,7 @@ export type WorkspaceDirectory = {
path: string; path: string;
name: string; name: string;
parent_path: string; parent_path: string;
has_children?: boolean;
}; };
type ApiEnvelope<T> = { type ApiEnvelope<T> = {
@@ -442,9 +444,13 @@ export async function deleteScript(
export async function listWorkspaceDirectories( export async function listWorkspaceDirectories(
workspaceId: string, workspaceId: string,
parentPath: string = "",
): Promise<WorkspaceDirectory[]> { ): Promise<WorkspaceDirectory[]> {
const query = parentPath
? `?parent_path=${encodeURIComponent(parentPath)}`
: "";
const data = await apiRequest<{ directories: WorkspaceDirectory[] }>( const data = await apiRequest<{ directories: WorkspaceDirectory[] }>(
"/api/v1/workspace-tree", `/api/v1/workspace-directories${query}`,
{}, {},
workspaceId, workspaceId,
); );
@@ -1324,9 +1330,9 @@ export type WorkspaceBoundApi = {
setScriptLock: ( setScriptLock: (
scriptId: string, scriptId: string,
isLocked: boolean, isLocked: boolean,
) => Promise<ScriptItem>; ) => Promise<ScriptItem>;
listWorkspaceDirectories: () => Promise<WorkspaceDirectory[]>; listWorkspaceDirectories: (parentPath?: string) => Promise<WorkspaceDirectory[]>;
createWorkspaceDirectory: ( createWorkspaceDirectory: (
directoryName: string, directoryName: string,
parentPath?: string, parentPath?: string,
) => Promise<WorkspaceDirectory>; ) => Promise<WorkspaceDirectory>;
+76
View File
@@ -1048,6 +1048,7 @@ button {
} }
.editor-canvas { .editor-canvas {
position: relative;
min-height: 0; min-height: 0;
flex: 1; flex: 1;
overflow: auto; overflow: auto;
@@ -1990,6 +1991,81 @@ button {
} }
} }
/* Save button — green primary tone for python editor */
.editor-toolbar__actions .editor-save-button {
color: #1f6d3a;
background: #f1faf3;
border-color: #b8dec1;
font-weight: 600;
}
.editor-toolbar__actions .editor-save-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.editor-toolbar__actions .editor-save-button.is-saving {
color: #1f6d3a;
opacity: 0.85;
}
.editor-toolbar__actions .editor-save-button .button-spinner {
width: 12px;
height: 12px;
}
/* Dirty banner — top of editor canvas */
.editor-dirty-banner {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
background: #fff7e6;
border-bottom: 1px solid #f0d8a8;
color: #9b6a18;
font-size: 12px;
font-weight: 500;
}
.editor-dirty-banner::before {
content: "●";
color: #d49b00;
font-size: 10px;
}
/* Python editor wrapper */
.python-editor {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
background: #fff;
}
.python-editor__status {
display: flex;
align-items: center;
gap: 16px;
padding: 4px 14px;
background: #f5f7fa;
border-bottom: 1px solid #e6e8ec;
font-size: 11px;
color: #6b7280;
}
.python-editor__canvas {
flex: 1;
min-height: 0;
position: relative;
overflow: hidden;
}
/* Python editor mount — multiple instances, only active visible */
.python-editor-mount {
display: flex;
width: 100%;
height: 100%;
min-height: 0;
flex-direction: column;
background: #fff;
}
/* ============ 只读编辑器样式 ============ */ /* ============ 只读编辑器样式 ============ */
.readonly-editor-container { .readonly-editor-container {
display: flex; display: flex;
@@ -0,0 +1,38 @@
"""Add workspace tree relative_path index.
The ``list_workspace_directories`` endpoint filters by
``relative_path`` prefixes inside a workspace. A composite index on
``(workspace_id, relative_path(255))`` avoids scanning all rows for a
workspace when listing a subdirectory.
Revision ID: 3ba4d8489f36
Revises: f6a7b8c9d0e1
Create Date: 2026-08-12
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "3ba4d8489f36"
down_revision: str | Sequence[str] | None = "f6a7b8c9d0e1"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_index(
"idx_storage_workspace_relative_path",
"storage_objects",
[sa.text("`workspace_id`"), sa.text("`relative_path`(255)")],
unique=False,
)
def downgrade() -> None:
op.drop_index(
"idx_storage_workspace_relative_path",
table_name="storage_objects",
)