feat: Refactor storage adapter and mount handling; migrate mounts to storage adapters; enhance SideNav; implement database migration scripts

This commit is contained in:
shiyu
2025-08-28 12:59:24 +08:00
parent bfa8898931
commit 62a1c5810d
16 changed files with 163 additions and 262 deletions

2
.gitignore vendored
View File

@@ -5,5 +5,5 @@ __pycache__/
.venv/
.vscode/
data/
migrate/
.env

View File

@@ -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 . .

View File

@@ -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)

View File

@@ -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",

View File

@@ -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})

View File

@@ -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,

View File

@@ -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

View File

@@ -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):

View File

@@ -1,3 +1,3 @@
from .database import StorageAdapter, Mount
from .database import StorageAdapter
__all__ = ["StorageAdapter", "Mount"]
__all__ = ["StorageAdapter"]

View File

@@ -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)

View File

@@ -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",
]

View File

@@ -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

View File

@@ -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

View File

@@ -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"]],

View File

@@ -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:

View File

@@ -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}
>
<div>
<p>: {status?.version}</p>
{latestVersion && (
<div>
<p>: {latestVersion.version}</p>
<ReactMarkdown>{latestVersion.body}</ReactMarkdown>
<div style={{ paddingTop: 12 }}>
{latestVersion ? (
<>
<Descriptions bordered column={1} size="small">
<Descriptions.Item label="当前版本">
<Tag>{status?.version}</Tag>
</Descriptions.Item>
<Descriptions.Item label="最新版本">
<Tag color={hasUpdate ? 'orange' : 'green'}>{latestVersion.version}</Tag>
</Descriptions.Item>
</Descriptions>
{hasUpdate && (
<Alert
message={`发现新版本: ${latestVersion.version}`}
description="建议尽快更新到最新版本,以获得新功能和安全修复。"
type="info"
showIcon
style={{ marginTop: 24, marginBottom: 24 }}
action={
<Button
size="small"
type="primary"
href="https://github.com/DrizzleTime/Foxel/releases"
target="_blank"
icon={<GithubOutlined />}
>
</Button>
}
/>
)}
<Divider orientation="left" plain></Divider>
<div style={{
maxHeight: '40vh',
overflowY: 'auto',
padding: '8px 16px',
background: token.colorFillAlter,
borderRadius: token.borderRadiusLG,
border: `1px solid ${token.colorBorderSecondary}`
}}>
<ReactMarkdown
components={{
h3: ({ ...props }) => <h3 style={{
fontSize: 16,
borderBottom: `1px solid ${token.colorBorderSecondary}`,
paddingBottom: 8,
marginTop: 24,
marginBottom: 16
}} {...props} />,
ul: ({ ...props }) => <ul style={{ paddingLeft: 20 }} {...props} />,
li: ({ ...props }) => <li style={{ marginBottom: 8 }} {...props} />,
p: ({ ...props }) => <p style={{ marginBottom: 8 }} {...props} />,
a: ({ ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" />
}}
>{latestVersion.body}</ReactMarkdown>
</div>
</>
) : (
<div style={{ textAlign: 'center', padding: '40px 0', color: token.colorTextSecondary }}>
<Spin size="large" />
<p style={{ marginTop: 16 }}>...</p>
</div>
)}
</div>