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