From 62a1c5810d5c9ae7e3529306831198f080efc928 Mon Sep 17 00:00:00 2001 From: shiyu Date: Thu, 28 Aug 2025 12:59:24 +0800 Subject: [PATCH] feat: Refactor storage adapter and mount handling; migrate mounts to storage adapters; enhance SideNav; implement database migration scripts --- .gitignore | 2 +- Dockerfile | 4 +- api/routers.py | 3 +- api/routes/adapters.py | 74 ++++++++----------------- api/routes/mounts.py | 84 ---------------------------- api/routes/virtual_fs.py | 2 +- entrypoint.sh | 1 + main.py | 4 +- models/__init__.py | 4 +- models/database.py | 16 +----- schemas/__init__.py | 3 - schemas/adapters.py | 12 ++-- schemas/mounts.py | 23 -------- services/backup.py | 10 ---- services/virtual_fs.py | 111 ++++++++++++++++++------------------- web/src/layout/SideNav.tsx | 72 +++++++++++++++++++++--- 16 files changed, 163 insertions(+), 262 deletions(-) delete mode 100644 api/routes/mounts.py delete mode 100644 schemas/mounts.py diff --git a/.gitignore b/.gitignore index 9f807ef..e470381 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,5 @@ __pycache__/ .venv/ .vscode/ data/ - +migrate/ .env \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 9a7216a..1afe5d4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,11 +13,13 @@ FROM python:3.13-slim WORKDIR /app -RUN apt-get update && apt-get install -y nginx && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y nginx git && rm -rf /var/lib/apt/lists/* COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt && pip install gunicorn +RUN git clone https://github.com/DrizzleTime/FoxelUpgrade /app/migrate + COPY --from=frontend-builder /app/web/dist /app/web/dist COPY . . diff --git a/api/routers.py b/api/routers.py index 6c2104f..a85980f 100644 --- a/api/routers.py +++ b/api/routers.py @@ -1,13 +1,12 @@ from fastapi import FastAPI -from .routes import adapters, virtual_fs, mounts, auth, config, processors, tasks, logs, share, backup, search +from .routes import adapters, virtual_fs, auth, config, processors, tasks, logs, share, backup, search def include_routers(app: FastAPI): app.include_router(adapters.router) app.include_router(virtual_fs.router) app.include_router(search.router) - app.include_router(mounts.router) app.include_router(auth.router) app.include_router(config.router) app.include_router(processors.router) diff --git a/api/routes/adapters.py b/api/routes/adapters.py index 483720c..32d4986 100644 --- a/api/routes/adapters.py +++ b/api/routes/adapters.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, HTTPException, Depends from tortoise.transactions import in_transaction from typing import Annotated -from models import StorageAdapter, Mount +from models import StorageAdapter from schemas import AdapterCreate, AdapterOut from services.auth import get_current_active_user, User from services.adapters.registry import runtime_registry, get_config_schemas @@ -39,26 +39,21 @@ async def create_adapter( data: AdapterCreate, current_user: Annotated[User, Depends(get_current_active_user)] ): + norm_path = AdapterCreate.normalize_mount_path(data.mount_path) + exists = await StorageAdapter.get_or_none(path=norm_path) + if exists: + raise HTTPException(400, detail="Mount path already exists") + adapter_fields = { "name": data.name, "type": data.type, "config": validate_and_normalize_config(data.type, data.config or {}), "enabled": data.enabled, + "path": norm_path, + "sub_path": data.sub_path, } - norm_path = AdapterCreate.normalize_mount_path(data.mount_path) - exists = await Mount.get_or_none(path=norm_path) - if exists: - raise HTTPException(400, detail="Mount path already exists") - async with in_transaction(): - rec = await StorageAdapter.create(**adapter_fields) - await Mount.create( - path=norm_path, - sub_path=data.sub_path, - adapter=rec, - enabled=True, - ) - rec.mount_path = norm_path - rec.sub_path = data.sub_path + + rec = await StorageAdapter.create(**adapter_fields) await runtime_registry.refresh() await LogService.action( "route:adapters", @@ -73,20 +68,8 @@ async def create_adapter( async def list_adapters( current_user: Annotated[User, Depends(get_current_active_user)] ): - adapters = await StorageAdapter.all().prefetch_related("mounts") - out = [] - for a in adapters: - mount = a.mounts[0] if a.mounts else None - item = AdapterOut( - name=a.name, - type=a.type, - config=a.config, - enabled=a.enabled, - id=a.id, - mount_path=mount.path if mount else None, - sub_path=mount.sub_path if mount else None - ) - out.append(item) + adapters = await StorageAdapter.all() + out = [AdapterOut.model_validate(a) for a in adapters] return success(out) @@ -109,13 +92,10 @@ async def get_adapter( adapter_id: int, current_user: Annotated[User, Depends(get_current_active_user)] ): - rec = await StorageAdapter.get_or_none(id=adapter_id).prefetch_related("mounts") + rec = await StorageAdapter.get_or_none(id=adapter_id) if not rec: raise HTTPException(404, detail="Not found") - mount = rec.mounts[0] if rec.mounts else None - rec.mount_path = mount.path if mount else None - rec.sub_path = mount.sub_path if mount else None - return success(rec) + return success(AdapterOut.model_validate(rec)) @router.put("/{adapter_id}") @@ -124,33 +104,23 @@ async def update_adapter( data: AdapterCreate, current_user: Annotated[User, Depends(get_current_active_user)] ): - - rec = await StorageAdapter.get_or_none(id=adapter_id).prefetch_related("mounts") + rec = await StorageAdapter.get_or_none(id=adapter_id) if not rec: raise HTTPException(404, detail="Not found") + norm_path = AdapterCreate.normalize_mount_path(data.mount_path) - existing = await Mount.get_or_none(path=norm_path) - mount = rec.mounts[0] if rec.mounts else None - if existing and (not mount or existing.id != mount.id): + existing = await StorageAdapter.get_or_none(path=norm_path) + if existing and existing.id != adapter_id: raise HTTPException(400, detail="Mount path already exists") + rec.name = data.name rec.type = data.type rec.config = validate_and_normalize_config(data.type, data.config or {}) rec.enabled = data.enabled + rec.path = norm_path + rec.sub_path = data.sub_path await rec.save() - if mount: - mount.path = norm_path - mount.sub_path = data.sub_path - await mount.save() - else: - mount = await Mount.create( - path=norm_path, - sub_path=data.sub_path, - adapter=rec, - enabled=True, - ) - rec.mount_path = mount.path - rec.sub_path = mount.sub_path + await runtime_registry.refresh() await LogService.action( "route:adapters", diff --git a/api/routes/mounts.py b/api/routes/mounts.py deleted file mode 100644 index c0c8956..0000000 --- a/api/routes/mounts.py +++ /dev/null @@ -1,84 +0,0 @@ -from fastapi import APIRouter, HTTPException, Depends -from typing import Annotated - -from models import StorageAdapter, Mount -from schemas import MountCreate, MountOut -from api.response import success -from services.auth import get_current_active_user, User -from services.logging import LogService - -router = APIRouter(prefix="/api/mounts", tags=["mounts"]) - - -@router.post("") -async def create_mount( - data: MountCreate, - current_user: Annotated[User, Depends(get_current_active_user)], -): - adapter = await StorageAdapter.get_or_none(id=data.adapter_id) - if not adapter: - raise HTTPException(400, detail="Adapter not found") - rec = await Mount.create( - path=MountCreate.normalize(data.path), - adapter=adapter, - sub_path=data.sub_path, - enabled=data.enabled, - ) - await LogService.action( - "route:mounts", - f"Created mount {rec.path}", - details=data.model_dump(), - user_id=current_user.id if hasattr(current_user, "id") else None, - ) - return success(rec) - - -@router.get("") -async def list_mounts( - current_user: Annotated[User, Depends(get_current_active_user)], -): - recs = await Mount.all() - return success(recs) - - -@router.put("/{mount_id}") -async def update_mount( - mount_id: int, - data: MountCreate, - current_user: Annotated[User, Depends(get_current_active_user)], -): - rec = await Mount.get_or_none(id=mount_id) - if not rec: - raise HTTPException(404, detail="Not found") - adapter = await StorageAdapter.get_or_none(id=data.adapter_id) - if not adapter: - raise HTTPException(400, detail="Adapter not found") - rec.path = MountCreate.normalize(data.path) - rec.adapter = adapter - rec.sub_path = data.sub_path - rec.enabled = data.enabled - await rec.save() - await LogService.action( - "route:mounts", - f"Updated mount {rec.path}", - details=data.model_dump(), - user_id=current_user.id if hasattr(current_user, "id") else None, - ) - return success(rec) - - -@router.delete("/{mount_id}") -async def delete_mount( - mount_id: int, - current_user: Annotated[User, Depends(get_current_active_user)], -): - deleted = await Mount.filter(id=mount_id).delete() - if not deleted: - raise HTTPException(404, detail="Not found") - await LogService.action( - "route:mounts", - f"Deleted mount {mount_id}", - details={"mount_id": mount_id}, - user_id=current_user.id if hasattr(current_user, "id") else None, - ) - return success({"deleted": True}) diff --git a/api/routes/virtual_fs.py b/api/routes/virtual_fs.py index b5ea19f..2bfa8e7 100644 --- a/api/routes/virtual_fs.py +++ b/api/routes/virtual_fs.py @@ -116,7 +116,7 @@ async def get_thumb( if not is_image_filename(rel): raise HTTPException(404, detail="Not an image") # type: ignore - data, mime, key = await get_or_create_thumb(adapter, mount.adapter_id, root, rel, w, h, fit) + data, mime, key = await get_or_create_thumb(adapter, mount.id, root, rel, w, h, fit) headers = { 'Cache-Control': 'public, max-age=3600', 'ETag': key, diff --git a/entrypoint.sh b/entrypoint.sh index 4899b35..a38dbef 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,4 +1,5 @@ #!/bin/bash set -e +python migrate/run.py nginx -g 'daemon off;' & exec gunicorn -k uvicorn.workers.UvicornWorker -w 4 -b 0.0.0.0:8000 main:app \ No newline at end of file diff --git a/main.py b/main.py index 1d2ce91..35a272b 100644 --- a/main.py +++ b/main.py @@ -4,13 +4,13 @@ load_dotenv() from services.middleware.exception_handler import global_exception_handler from services.middleware.logging_middleware import LoggingMiddleware -from fastapi import FastAPI, Request +from fastapi import FastAPI from api.routers import include_routers from db.session import close_db, init_db from contextlib import asynccontextmanager from fastapi.middleware.cors import CORSMiddleware from services.adapters.registry import runtime_registry - +from services.config import VERSION @asynccontextmanager async def lifespan(app: FastAPI): diff --git a/models/__init__.py b/models/__init__.py index 4f3edf3..af4ce68 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -1,3 +1,3 @@ -from .database import StorageAdapter, Mount +from .database import StorageAdapter -__all__ = ["StorageAdapter", "Mount"] +__all__ = ["StorageAdapter"] diff --git a/models/database.py b/models/database.py index f602ef3..a2d825b 100644 --- a/models/database.py +++ b/models/database.py @@ -8,25 +8,13 @@ class StorageAdapter(Model): type = fields.CharField(max_length=30) config = fields.JSONField() enabled = fields.BooleanField(default=True) - mounts: fields.ReverseRelation["Mount"] + path = fields.CharField(max_length=255, unique=True) + sub_path = fields.CharField(max_length=1024, null=True) class Meta: table = "storage_adapters" -class Mount(Model): - id = fields.IntField(pk=True) - path = fields.CharField(max_length=255, unique=True) - sub_path = fields.CharField(max_length=1024, null=True) - adapter: fields.ForeignKeyRelation[StorageAdapter] = fields.ForeignKeyField( - "models.StorageAdapter", related_name="mounts", on_delete=fields.CASCADE - ) - enabled = fields.BooleanField(default=True) - - class Meta: - table = "mounts" - - class UserAccount(Model): id = fields.IntField(pk=True) username = fields.CharField(max_length=50, unique=True) diff --git a/schemas/__init__.py b/schemas/__init__.py index e43a6e4..2cb26bd 100644 --- a/schemas/__init__.py +++ b/schemas/__init__.py @@ -1,12 +1,9 @@ from .adapters import AdapterCreate, AdapterOut -from .mounts import MountCreate, MountOut from .fs import MkdirRequest, MoveRequest __all__ = [ "AdapterCreate", "AdapterOut", - "MountCreate", - "MountOut", "MkdirRequest", "MoveRequest", ] diff --git a/schemas/adapters.py b/schemas/adapters.py index ecbf101..5ba49df 100644 --- a/schemas/adapters.py +++ b/schemas/adapters.py @@ -2,13 +2,16 @@ from typing import Dict, Optional from pydantic import BaseModel, Field, validator -class AdapterCreate(BaseModel): +class AdapterBase(BaseModel): name: str type: str = Field(pattern=r"^[a-zA-Z0-9_]+$") config: Dict = Field(default_factory=dict) enabled: bool = True - mount_path: str - sub_path: Optional[str] = None + sub_path: Optional[str] = None + + +class AdapterCreate(AdapterBase): + mount_path: str @staticmethod def normalize_mount_path(p: str) -> str: @@ -25,8 +28,9 @@ class AdapterCreate(BaseModel): return cls.normalize_mount_path(v) -class AdapterOut(AdapterCreate): +class AdapterOut(AdapterBase): id: int + mount_path: str = Field(alias='path') class Config: from_attributes = True diff --git a/schemas/mounts.py b/schemas/mounts.py deleted file mode 100644 index 8de51bd..0000000 --- a/schemas/mounts.py +++ /dev/null @@ -1,23 +0,0 @@ -from typing import Optional -from pydantic import BaseModel - - -class MountCreate(BaseModel): - path: str - adapter_id: int - sub_path: Optional[str] = None - enabled: bool = True - - @staticmethod - def normalize(path: str) -> str: - return (path if path.startswith('/') else '/' + path).rstrip('/') or '/' - - def model_post_init(self, __context): - self.path = self.normalize(self.path) - - -class MountOut(MountCreate): - id: int - - class Config: - from_attributes = True diff --git a/services/backup.py b/services/backup.py index 42da2c3..e3fcc4c 100644 --- a/services/backup.py +++ b/services/backup.py @@ -1,7 +1,6 @@ from tortoise.transactions import in_transaction from models.database import ( StorageAdapter, - Mount, UserAccount, AutomationTask, ShareLink, @@ -18,7 +17,6 @@ class BackupService: """ async with in_transaction() as conn: adapters = await StorageAdapter.all().values() - mounts = await Mount.all().values() users = await UserAccount.all().values() tasks = await AutomationTask.all().values() shares = await ShareLink.all().values() @@ -33,7 +31,6 @@ class BackupService: return { "version": VERSION, "storage_adapters": list(adapters), - "mounts": list(mounts), "user_accounts": list(users), "automation_tasks": list(tasks), "share_links": list(shares), @@ -48,7 +45,6 @@ class BackupService: async with in_transaction() as conn: await ShareLink.all().using_db(conn).delete() await AutomationTask.all().using_db(conn).delete() - await Mount.all().using_db(conn).delete() await StorageAdapter.all().using_db(conn).delete() await UserAccount.all().using_db(conn).delete() await Configuration.all().using_db(conn).delete() @@ -71,12 +67,6 @@ class BackupService: using_db=conn ) - if data.get("mounts"): - await Mount.bulk_create( - [Mount(**m) for m in data["mounts"]], - using_db=conn - ) - if data.get("automation_tasks"): await AutomationTask.bulk_create( [AutomationTask(**t) for t in data["automation_tasks"]], diff --git a/services/virtual_fs.py b/services/virtual_fs.py index 6416148..76fb13e 100644 --- a/services/virtual_fs.py +++ b/services/virtual_fs.py @@ -1,4 +1,3 @@ -from pathlib import Path from typing import Dict, Tuple, Any, Union, AsyncIterator from fastapi import HTTPException import mimetypes @@ -8,7 +7,7 @@ import hmac import hashlib import base64 -from models import Mount +from models import StorageAdapter from .adapters.registry import runtime_registry from api.response import page from .thumbnail import is_image_filename, is_raw_filename @@ -18,16 +17,16 @@ from services.logging import LogService from services.config import ConfigCenter -async def resolve_mount(path: str) -> Tuple[Mount, str]: +async def resolve_adapter_by_path(path: str) -> Tuple[StorageAdapter, str]: norm = path if path.startswith('/') else '/' + path - mounts = await Mount.filter(enabled=True) + adapters = await StorageAdapter.filter(enabled=True) best = None - for m in mounts: - if norm == m.path or norm.startswith(m.path.rstrip('/') + '/'): - if (best is None) or len(m.path) > len(best.path): - best = m + for a in adapters: + if norm == a.path or norm.startswith(a.path.rstrip('/') + '/'): + if (best is None) or len(a.path) > len(best.path): + best = a if not best: - raise HTTPException(404, detail="No mount for path") + raise HTTPException(404, detail="No storage adapter for path") rel = norm[len(best.path):].lstrip('/') return best, rel @@ -35,16 +34,17 @@ async def resolve_mount(path: str) -> Tuple[Mount, str]: async def resolve_adapter_and_rel(path: str): - """返回 (adapter_instance, mount, effective_root, rel_path).""" + """返回 (adapter_instance, adapter_model, effective_root, rel_path).""" norm = path if path.startswith('/') else '/' + path try: - mount, rel = await resolve_mount(norm) + adapter_model, rel = await resolve_adapter_by_path(norm) except HTTPException as e: raise e - await mount.fetch_related("adapter") - adapter_instance = runtime_registry.get(mount.adapter_id) - effective_root = adapter_instance.get_effective_root(mount.sub_path) - return adapter_instance, mount, effective_root, rel + adapter_instance = runtime_registry.get(adapter_model.id) + if not adapter_instance: + raise HTTPException(404, detail=f"Adapter instance not found for ID {adapter_model.id}") + effective_root = adapter_instance.get_effective_root(adapter_model.sub_path) + return adapter_instance, adapter_model, effective_root, rel async def _ensure_method(adapter: Any, method: str): @@ -56,27 +56,26 @@ async def _ensure_method(adapter: Any, method: str): async def list_virtual_dir(path: str, page_num: int = 1, page_size: int = 50) -> Dict: norm = (path if path.startswith('/') else '/' + path).rstrip('/') or '/' - mounts = await Mount.filter(enabled=True).prefetch_related("adapter") + adapters = await StorageAdapter.filter(enabled=True) child_mount_entries = [] norm_prefix = norm.rstrip('/') - for m in mounts: - if m.path == norm: + for a in adapters: + if a.path == norm: continue - if m.path.startswith(norm_prefix + '/'): - tail = m.path[len(norm_prefix):].lstrip('/') + if a.path.startswith(norm_prefix + '/'): + tail = a.path[len(norm_prefix):].lstrip('/') if '/' not in tail: child_mount_entries.append(tail) child_mount_entries = sorted(set(child_mount_entries)) try: - mount, rel = await resolve_mount(norm) - await mount.fetch_related("adapter") - adapter = runtime_registry.get(mount.adapter_id) - effective_root = adapter.get_effective_root(mount.sub_path) + adapter_model, rel = await resolve_adapter_by_path(norm) + adapter_instance = runtime_registry.get(adapter_model.id) + effective_root = adapter_instance.get_effective_root(adapter_model.sub_path) except HTTPException: - mount = None - adapter = None + adapter_model = None + adapter_instance = None effective_root = '' rel = '' @@ -84,8 +83,8 @@ async def list_virtual_dir(path: str, page_num: int = 1, page_size: int = 50) -> adapter_total = 0 covered = set() - if mount and adapter: - list_dir = await _ensure_method(adapter, "list_dir") + if adapter_model and adapter_instance: + list_dir = await _ensure_method(adapter_instance, "list_dir") try: adapter_entries, adapter_total = await list_dir(effective_root, rel, page_num, page_size) except NotADirectoryError: @@ -119,18 +118,18 @@ async def list_virtual_dir(path: str, page_num: int = 1, page_size: int = 50) -> async def read_file(path: str) -> Union[bytes, Any]: - adapter, _mount, root, rel = await resolve_adapter_and_rel(path) + adapter_instance, _, root, rel = await resolve_adapter_and_rel(path) if rel.endswith('/') or rel == '': raise HTTPException(400, detail="Path is a directory") - read_func = await _ensure_method(adapter, "read_file") + read_func = await _ensure_method(adapter_instance, "read_file") return await read_func(root, rel) async def write_file(path: str, data: bytes): - adapter, _mount, root, rel = await resolve_adapter_and_rel(path) + adapter_instance, _, root, rel = await resolve_adapter_and_rel(path) if rel.endswith('/'): raise HTTPException(400, detail="Invalid file path") - write_func = await _ensure_method(adapter, "write_file") + write_func = await _ensure_method(adapter_instance, "write_file") await write_func(root, rel, data) await task_service.trigger_tasks("file_written", path) await LogService.action( @@ -139,10 +138,10 @@ async def write_file(path: str, data: bytes): async def write_file_stream(path: str, data_iter: AsyncIterator[bytes], overwrite: bool = True): - adapter, _mount, root, rel = await resolve_adapter_and_rel(path) + adapter_instance, _, root, rel = await resolve_adapter_and_rel(path) if rel.endswith('/'): raise HTTPException(400, detail="Invalid file path") - exists_func = getattr(adapter, "exists", None) + exists_func = getattr(adapter_instance, "exists", None) if not overwrite and callable(exists_func): try: if await exists_func(root, rel): @@ -153,7 +152,7 @@ async def write_file_stream(path: str, data_iter: AsyncIterator[bytes], overwrit pass size = 0 - stream_func = getattr(adapter, "write_file_stream", None) + stream_func = getattr(adapter_instance, "write_file_stream", None) if callable(stream_func): size = await stream_func(root, rel, data_iter) else: @@ -161,7 +160,7 @@ async def write_file_stream(path: str, data_iter: AsyncIterator[bytes], overwrit async for chunk in data_iter: if chunk: buf.extend(chunk) - write_func = await _ensure_method(adapter, "write_file") + write_func = await _ensure_method(adapter_instance, "write_file") await write_func(root, rel, bytes(buf)) size = len(buf) @@ -175,35 +174,35 @@ async def write_file_stream(path: str, data_iter: AsyncIterator[bytes], overwrit async def make_dir(path: str): - adapter, _mount, root, rel = await resolve_adapter_and_rel(path) + adapter_instance, _, root, rel = await resolve_adapter_and_rel(path) if not rel: raise HTTPException(400, detail="Cannot create root") - mkdir_func = await _ensure_method(adapter, "mkdir") + mkdir_func = await _ensure_method(adapter_instance, "mkdir") await mkdir_func(root, rel) await LogService.action("virtual_fs", f"Created directory {path}", details={"path": path}) async def delete_path(path: str): - adapter, _mount, root, rel = await resolve_adapter_and_rel(path) + adapter_instance, _, root, rel = await resolve_adapter_and_rel(path) if not rel: raise HTTPException(400, detail="Cannot delete root") - delete_func = await _ensure_method(adapter, "delete") + delete_func = await _ensure_method(adapter_instance, "delete") await delete_func(root, rel) await task_service.trigger_tasks("file_deleted", path) await LogService.action("virtual_fs", f"Deleted {path}", details={"path": path}) async def move_path(src: str, dst: str, overwrite: bool = False, return_debug: bool = True): - adapter_s, mount_s, root_s, rel_s = await resolve_adapter_and_rel(src) - adapter_d, mount_d, root_d, rel_d = await resolve_adapter_and_rel(dst) + adapter_s, adapter_model_s, root_s, rel_s = await resolve_adapter_and_rel(src) + adapter_d, adapter_model_d, root_d, rel_d = await resolve_adapter_and_rel(dst) debug_info = { "src": src, "dst": dst, "rel_s": rel_s, "rel_d": rel_d, "root_s": root_s, "root_d": root_d, "overwrite": overwrite } - if mount_s.id != mount_d.id: - raise HTTPException(400, detail="Cross-mount move not supported") + if adapter_model_s.id != adapter_model_d.id: + raise HTTPException(400, detail="Cross-adapter move not supported") if not rel_s: raise HTTPException(400, detail="Cannot move or rename mount root") if not rel_d: @@ -266,16 +265,16 @@ async def move_path(src: str, dst: str, overwrite: bool = False, return_debug: b async def rename_path(src: str, dst: str, overwrite: bool = False, return_debug: bool = True): - adapter_s, mount_s, root_s, rel_s = await resolve_adapter_and_rel(src) - adapter_d, mount_d, root_d, rel_d = await resolve_adapter_and_rel(dst) + adapter_s, adapter_model_s, root_s, rel_s = await resolve_adapter_and_rel(src) + adapter_d, adapter_model_d, root_d, rel_d = await resolve_adapter_and_rel(dst) debug_info = { "src": src, "dst": dst, "rel_s": rel_s, "rel_d": rel_d, "root_s": root_s, "root_d": root_d, "overwrite": overwrite } - if mount_s.id != mount_d.id: - raise HTTPException(400, detail="Cross-mount rename not supported") + if adapter_model_s.id != adapter_model_d.id: + raise HTTPException(400, detail="Cross-adapter rename not supported") if not rel_s: raise HTTPException(400, detail="Cannot rename mount root") if not rel_d: @@ -338,7 +337,7 @@ async def rename_path(src: str, dst: str, overwrite: bool = False, return_debug: async def stream_file(path: str, range_header: str | None): - adapter, mount, root, rel = await resolve_adapter_and_rel(path) + adapter_instance, _, root, rel = await resolve_adapter_and_rel(path) if not rel or rel.endswith('/'): raise HTTPException(400, detail="Path is a directory") if is_raw_filename(rel): @@ -371,7 +370,7 @@ async def stream_file(path: str, range_header: str | None): except Exception as e: raise HTTPException(500, detail=f"RAW file processing failed: {e}") - stream_impl = getattr(adapter, "stream_file", None) + stream_impl = getattr(adapter_instance, "stream_file", None) if callable(stream_impl): return await stream_impl(root, rel, range_header) data = await read_file(path) @@ -380,24 +379,24 @@ async def stream_file(path: str, range_header: str | None): async def stat_file(path: str): - adapter, _mount, root, rel = await resolve_adapter_and_rel(path) - stat_func = getattr(adapter, "stat_file", None) + adapter_instance, _, root, rel = await resolve_adapter_and_rel(path) + stat_func = getattr(adapter_instance, "stat_file", None) if not callable(stat_func): raise HTTPException(501, detail="Adapter does not implement stat_file") return await stat_func(root, rel) async def copy_path(src: str, dst: str, overwrite: bool = False, return_debug: bool = True): - adapter_s, mount_s, root_s, rel_s = await resolve_adapter_and_rel(src) - adapter_d, mount_d, root_d, rel_d = await resolve_adapter_and_rel(dst) + adapter_s, adapter_model_s, root_s, rel_s = await resolve_adapter_and_rel(src) + adapter_d, adapter_model_d, root_d, rel_d = await resolve_adapter_and_rel(dst) debug_info = { "src": src, "dst": dst, "rel_s": rel_s, "rel_d": rel_d, "root_s": root_s, "root_d": root_d, "overwrite": overwrite } - if mount_s.id != mount_d.id: - raise HTTPException(400, detail="Cross-mount copy not supported") + if adapter_model_s.id != adapter_model_d.id: + raise HTTPException(400, detail="Cross-adapter copy not supported") if not rel_s: raise HTTPException(400, detail="Cannot copy mount root") if not rel_d: diff --git a/web/src/layout/SideNav.tsx b/web/src/layout/SideNav.tsx index 7d1abc8..03338b5 100644 --- a/web/src/layout/SideNav.tsx +++ b/web/src/layout/SideNav.tsx @@ -1,4 +1,4 @@ -import { Layout, Menu, theme, Button, Modal, Tag, Tooltip } from 'antd'; +import { Layout, Menu, theme, Button, Modal, Tag, Tooltip, Descriptions, Alert, Divider, Spin } from 'antd'; import { navGroups } from './nav.ts'; import type { NavItem, NavGroup } from './nav.ts'; import { memo, useEffect, useState } from 'react'; @@ -225,13 +225,71 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle onCancel={() => setIsVersionModalOpen(false)} title="版本信息" footer={null} + width={600} > -
-

当前版本: {status?.version}

- {latestVersion && ( -
-

最新版本: {latestVersion.version}

- {latestVersion.body} +
+ {latestVersion ? ( + <> + + + {status?.version} + + + {latestVersion.version} + + + + {hasUpdate && ( + } + > + 前往发布页面 + + } + /> + )} + + 更新日志 +
+

, + ul: ({ ...props }) =>

+ + ) : ( +
+ +

正在获取最新版本信息...

)}