Merge branch 'develop' of http://8.153.151.51:8888/team_group/model-develop into develop
This commit is contained in:
@@ -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
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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} />
|
||||||
|
|||||||
@@ -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 {shortHash(script.content_hash)}</span>
|
</span>
|
||||||
<span>
|
<span>SHA-256 {shortHash(script.content_hash)}</span>
|
||||||
稳定版本
|
<span>
|
||||||
{versionsLoading
|
稳定版本
|
||||||
? "加载中"
|
{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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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} 个脚本)`,
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user