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