mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-09-06 08:07:22 +08:00
feat: Refactor storage adapter and mount handling; migrate mounts to storage adapters; enhance SideNav; implement database migration scripts
This commit is contained in:
+1
-1
@@ -5,5 +5,5 @@ __pycache__/
|
|||||||
.venv/
|
.venv/
|
||||||
.vscode/
|
.vscode/
|
||||||
data/
|
data/
|
||||||
|
migrate/
|
||||||
.env
|
.env
|
||||||
+3
-1
@@ -13,11 +13,13 @@ FROM python:3.13-slim
|
|||||||
|
|
||||||
WORKDIR /app
|
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 .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt && pip install gunicorn
|
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 --from=frontend-builder /app/web/dist /app/web/dist
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
+1
-2
@@ -1,13 +1,12 @@
|
|||||||
from fastapi import FastAPI
|
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):
|
def include_routers(app: FastAPI):
|
||||||
app.include_router(adapters.router)
|
app.include_router(adapters.router)
|
||||||
app.include_router(virtual_fs.router)
|
app.include_router(virtual_fs.router)
|
||||||
app.include_router(search.router)
|
app.include_router(search.router)
|
||||||
app.include_router(mounts.router)
|
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
app.include_router(config.router)
|
app.include_router(config.router)
|
||||||
app.include_router(processors.router)
|
app.include_router(processors.router)
|
||||||
|
|||||||
+22
-52
@@ -2,7 +2,7 @@ from fastapi import APIRouter, HTTPException, Depends
|
|||||||
from tortoise.transactions import in_transaction
|
from tortoise.transactions import in_transaction
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from models import StorageAdapter, Mount
|
from models import StorageAdapter
|
||||||
from schemas import AdapterCreate, AdapterOut
|
from schemas import AdapterCreate, AdapterOut
|
||||||
from services.auth import get_current_active_user, User
|
from services.auth import get_current_active_user, User
|
||||||
from services.adapters.registry import runtime_registry, get_config_schemas
|
from services.adapters.registry import runtime_registry, get_config_schemas
|
||||||
@@ -39,26 +39,21 @@ async def create_adapter(
|
|||||||
data: AdapterCreate,
|
data: AdapterCreate,
|
||||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
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 = {
|
adapter_fields = {
|
||||||
"name": data.name,
|
"name": data.name,
|
||||||
"type": data.type,
|
"type": data.type,
|
||||||
"config": validate_and_normalize_config(data.type, data.config or {}),
|
"config": validate_and_normalize_config(data.type, data.config or {}),
|
||||||
"enabled": data.enabled,
|
"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)
|
rec = await StorageAdapter.create(**adapter_fields)
|
||||||
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
|
|
||||||
await runtime_registry.refresh()
|
await runtime_registry.refresh()
|
||||||
await LogService.action(
|
await LogService.action(
|
||||||
"route:adapters",
|
"route:adapters",
|
||||||
@@ -73,20 +68,8 @@ async def create_adapter(
|
|||||||
async def list_adapters(
|
async def list_adapters(
|
||||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||||
):
|
):
|
||||||
adapters = await StorageAdapter.all().prefetch_related("mounts")
|
adapters = await StorageAdapter.all()
|
||||||
out = []
|
out = [AdapterOut.model_validate(a) for a in adapters]
|
||||||
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)
|
|
||||||
return success(out)
|
return success(out)
|
||||||
|
|
||||||
|
|
||||||
@@ -109,13 +92,10 @@ async def get_adapter(
|
|||||||
adapter_id: int,
|
adapter_id: int,
|
||||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
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:
|
if not rec:
|
||||||
raise HTTPException(404, detail="Not found")
|
raise HTTPException(404, detail="Not found")
|
||||||
mount = rec.mounts[0] if rec.mounts else None
|
return success(AdapterOut.model_validate(rec))
|
||||||
rec.mount_path = mount.path if mount else None
|
|
||||||
rec.sub_path = mount.sub_path if mount else None
|
|
||||||
return success(rec)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{adapter_id}")
|
@router.put("/{adapter_id}")
|
||||||
@@ -124,33 +104,23 @@ async def update_adapter(
|
|||||||
data: AdapterCreate,
|
data: AdapterCreate,
|
||||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||||
):
|
):
|
||||||
|
rec = await StorageAdapter.get_or_none(id=adapter_id)
|
||||||
rec = await StorageAdapter.get_or_none(id=adapter_id).prefetch_related("mounts")
|
|
||||||
if not rec:
|
if not rec:
|
||||||
raise HTTPException(404, detail="Not found")
|
raise HTTPException(404, detail="Not found")
|
||||||
|
|
||||||
norm_path = AdapterCreate.normalize_mount_path(data.mount_path)
|
norm_path = AdapterCreate.normalize_mount_path(data.mount_path)
|
||||||
existing = await Mount.get_or_none(path=norm_path)
|
existing = await StorageAdapter.get_or_none(path=norm_path)
|
||||||
mount = rec.mounts[0] if rec.mounts else None
|
if existing and existing.id != adapter_id:
|
||||||
if existing and (not mount or existing.id != mount.id):
|
|
||||||
raise HTTPException(400, detail="Mount path already exists")
|
raise HTTPException(400, detail="Mount path already exists")
|
||||||
|
|
||||||
rec.name = data.name
|
rec.name = data.name
|
||||||
rec.type = data.type
|
rec.type = data.type
|
||||||
rec.config = validate_and_normalize_config(data.type, data.config or {})
|
rec.config = validate_and_normalize_config(data.type, data.config or {})
|
||||||
rec.enabled = data.enabled
|
rec.enabled = data.enabled
|
||||||
|
rec.path = norm_path
|
||||||
|
rec.sub_path = data.sub_path
|
||||||
await rec.save()
|
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 runtime_registry.refresh()
|
||||||
await LogService.action(
|
await LogService.action(
|
||||||
"route:adapters",
|
"route:adapters",
|
||||||
|
|||||||
@@ -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})
|
|
||||||
@@ -116,7 +116,7 @@ async def get_thumb(
|
|||||||
if not is_image_filename(rel):
|
if not is_image_filename(rel):
|
||||||
raise HTTPException(404, detail="Not an image")
|
raise HTTPException(404, detail="Not an image")
|
||||||
# type: ignore
|
# 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 = {
|
headers = {
|
||||||
'Cache-Control': 'public, max-age=3600',
|
'Cache-Control': 'public, max-age=3600',
|
||||||
'ETag': key,
|
'ETag': key,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
|
python migrate/run.py
|
||||||
nginx -g 'daemon off;' &
|
nginx -g 'daemon off;' &
|
||||||
exec gunicorn -k uvicorn.workers.UvicornWorker -w 4 -b 0.0.0.0:8000 main:app
|
exec gunicorn -k uvicorn.workers.UvicornWorker -w 4 -b 0.0.0.0:8000 main:app
|
||||||
@@ -4,13 +4,13 @@ load_dotenv()
|
|||||||
|
|
||||||
from services.middleware.exception_handler import global_exception_handler
|
from services.middleware.exception_handler import global_exception_handler
|
||||||
from services.middleware.logging_middleware import LoggingMiddleware
|
from services.middleware.logging_middleware import LoggingMiddleware
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI
|
||||||
from api.routers import include_routers
|
from api.routers import include_routers
|
||||||
from db.session import close_db, init_db
|
from db.session import close_db, init_db
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from services.adapters.registry import runtime_registry
|
from services.adapters.registry import runtime_registry
|
||||||
|
from services.config import VERSION
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
|
|||||||
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
from .database import StorageAdapter, Mount
|
from .database import StorageAdapter
|
||||||
|
|
||||||
__all__ = ["StorageAdapter", "Mount"]
|
__all__ = ["StorageAdapter"]
|
||||||
|
|||||||
+2
-14
@@ -8,25 +8,13 @@ class StorageAdapter(Model):
|
|||||||
type = fields.CharField(max_length=30)
|
type = fields.CharField(max_length=30)
|
||||||
config = fields.JSONField()
|
config = fields.JSONField()
|
||||||
enabled = fields.BooleanField(default=True)
|
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:
|
class Meta:
|
||||||
table = "storage_adapters"
|
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):
|
class UserAccount(Model):
|
||||||
id = fields.IntField(pk=True)
|
id = fields.IntField(pk=True)
|
||||||
username = fields.CharField(max_length=50, unique=True)
|
username = fields.CharField(max_length=50, unique=True)
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
from .adapters import AdapterCreate, AdapterOut
|
from .adapters import AdapterCreate, AdapterOut
|
||||||
from .mounts import MountCreate, MountOut
|
|
||||||
from .fs import MkdirRequest, MoveRequest
|
from .fs import MkdirRequest, MoveRequest
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AdapterCreate",
|
"AdapterCreate",
|
||||||
"AdapterOut",
|
"AdapterOut",
|
||||||
"MountCreate",
|
|
||||||
"MountOut",
|
|
||||||
"MkdirRequest",
|
"MkdirRequest",
|
||||||
"MoveRequest",
|
"MoveRequest",
|
||||||
]
|
]
|
||||||
|
|||||||
+8
-4
@@ -2,13 +2,16 @@ from typing import Dict, Optional
|
|||||||
from pydantic import BaseModel, Field, validator
|
from pydantic import BaseModel, Field, validator
|
||||||
|
|
||||||
|
|
||||||
class AdapterCreate(BaseModel):
|
class AdapterBase(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
type: str = Field(pattern=r"^[a-zA-Z0-9_]+$")
|
type: str = Field(pattern=r"^[a-zA-Z0-9_]+$")
|
||||||
config: Dict = Field(default_factory=dict)
|
config: Dict = Field(default_factory=dict)
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
mount_path: str
|
sub_path: Optional[str] = None
|
||||||
sub_path: Optional[str] = None
|
|
||||||
|
|
||||||
|
class AdapterCreate(AdapterBase):
|
||||||
|
mount_path: str
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def normalize_mount_path(p: str) -> str:
|
def normalize_mount_path(p: str) -> str:
|
||||||
@@ -25,8 +28,9 @@ class AdapterCreate(BaseModel):
|
|||||||
return cls.normalize_mount_path(v)
|
return cls.normalize_mount_path(v)
|
||||||
|
|
||||||
|
|
||||||
class AdapterOut(AdapterCreate):
|
class AdapterOut(AdapterBase):
|
||||||
id: int
|
id: int
|
||||||
|
mount_path: str = Field(alias='path')
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
from tortoise.transactions import in_transaction
|
from tortoise.transactions import in_transaction
|
||||||
from models.database import (
|
from models.database import (
|
||||||
StorageAdapter,
|
StorageAdapter,
|
||||||
Mount,
|
|
||||||
UserAccount,
|
UserAccount,
|
||||||
AutomationTask,
|
AutomationTask,
|
||||||
ShareLink,
|
ShareLink,
|
||||||
@@ -18,7 +17,6 @@ class BackupService:
|
|||||||
"""
|
"""
|
||||||
async with in_transaction() as conn:
|
async with in_transaction() as conn:
|
||||||
adapters = await StorageAdapter.all().values()
|
adapters = await StorageAdapter.all().values()
|
||||||
mounts = await Mount.all().values()
|
|
||||||
users = await UserAccount.all().values()
|
users = await UserAccount.all().values()
|
||||||
tasks = await AutomationTask.all().values()
|
tasks = await AutomationTask.all().values()
|
||||||
shares = await ShareLink.all().values()
|
shares = await ShareLink.all().values()
|
||||||
@@ -33,7 +31,6 @@ class BackupService:
|
|||||||
return {
|
return {
|
||||||
"version": VERSION,
|
"version": VERSION,
|
||||||
"storage_adapters": list(adapters),
|
"storage_adapters": list(adapters),
|
||||||
"mounts": list(mounts),
|
|
||||||
"user_accounts": list(users),
|
"user_accounts": list(users),
|
||||||
"automation_tasks": list(tasks),
|
"automation_tasks": list(tasks),
|
||||||
"share_links": list(shares),
|
"share_links": list(shares),
|
||||||
@@ -48,7 +45,6 @@ class BackupService:
|
|||||||
async with in_transaction() as conn:
|
async with in_transaction() as conn:
|
||||||
await ShareLink.all().using_db(conn).delete()
|
await ShareLink.all().using_db(conn).delete()
|
||||||
await AutomationTask.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 StorageAdapter.all().using_db(conn).delete()
|
||||||
await UserAccount.all().using_db(conn).delete()
|
await UserAccount.all().using_db(conn).delete()
|
||||||
await Configuration.all().using_db(conn).delete()
|
await Configuration.all().using_db(conn).delete()
|
||||||
@@ -71,12 +67,6 @@ class BackupService:
|
|||||||
using_db=conn
|
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"):
|
if data.get("automation_tasks"):
|
||||||
await AutomationTask.bulk_create(
|
await AutomationTask.bulk_create(
|
||||||
[AutomationTask(**t) for t in data["automation_tasks"]],
|
[AutomationTask(**t) for t in data["automation_tasks"]],
|
||||||
|
|||||||
+55
-56
@@ -1,4 +1,3 @@
|
|||||||
from pathlib import Path
|
|
||||||
from typing import Dict, Tuple, Any, Union, AsyncIterator
|
from typing import Dict, Tuple, Any, Union, AsyncIterator
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
import mimetypes
|
import mimetypes
|
||||||
@@ -8,7 +7,7 @@ import hmac
|
|||||||
import hashlib
|
import hashlib
|
||||||
import base64
|
import base64
|
||||||
|
|
||||||
from models import Mount
|
from models import StorageAdapter
|
||||||
from .adapters.registry import runtime_registry
|
from .adapters.registry import runtime_registry
|
||||||
from api.response import page
|
from api.response import page
|
||||||
from .thumbnail import is_image_filename, is_raw_filename
|
from .thumbnail import is_image_filename, is_raw_filename
|
||||||
@@ -18,16 +17,16 @@ from services.logging import LogService
|
|||||||
from services.config import ConfigCenter
|
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
|
norm = path if path.startswith('/') else '/' + path
|
||||||
mounts = await Mount.filter(enabled=True)
|
adapters = await StorageAdapter.filter(enabled=True)
|
||||||
best = None
|
best = None
|
||||||
for m in mounts:
|
for a in adapters:
|
||||||
if norm == m.path or norm.startswith(m.path.rstrip('/') + '/'):
|
if norm == a.path or norm.startswith(a.path.rstrip('/') + '/'):
|
||||||
if (best is None) or len(m.path) > len(best.path):
|
if (best is None) or len(a.path) > len(best.path):
|
||||||
best = m
|
best = a
|
||||||
if not best:
|
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('/')
|
rel = norm[len(best.path):].lstrip('/')
|
||||||
return best, rel
|
return best, rel
|
||||||
|
|
||||||
@@ -35,16 +34,17 @@ async def resolve_mount(path: str) -> Tuple[Mount, str]:
|
|||||||
|
|
||||||
|
|
||||||
async def resolve_adapter_and_rel(path: 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
|
norm = path if path.startswith('/') else '/' + path
|
||||||
try:
|
try:
|
||||||
mount, rel = await resolve_mount(norm)
|
adapter_model, rel = await resolve_adapter_by_path(norm)
|
||||||
except HTTPException as e:
|
except HTTPException as e:
|
||||||
raise e
|
raise e
|
||||||
await mount.fetch_related("adapter")
|
adapter_instance = runtime_registry.get(adapter_model.id)
|
||||||
adapter_instance = runtime_registry.get(mount.adapter_id)
|
if not adapter_instance:
|
||||||
effective_root = adapter_instance.get_effective_root(mount.sub_path)
|
raise HTTPException(404, detail=f"Adapter instance not found for ID {adapter_model.id}")
|
||||||
return adapter_instance, mount, effective_root, rel
|
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):
|
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:
|
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 '/'
|
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 = []
|
child_mount_entries = []
|
||||||
norm_prefix = norm.rstrip('/')
|
norm_prefix = norm.rstrip('/')
|
||||||
for m in mounts:
|
for a in adapters:
|
||||||
if m.path == norm:
|
if a.path == norm:
|
||||||
continue
|
continue
|
||||||
if m.path.startswith(norm_prefix + '/'):
|
if a.path.startswith(norm_prefix + '/'):
|
||||||
tail = m.path[len(norm_prefix):].lstrip('/')
|
tail = a.path[len(norm_prefix):].lstrip('/')
|
||||||
if '/' not in tail:
|
if '/' not in tail:
|
||||||
child_mount_entries.append(tail)
|
child_mount_entries.append(tail)
|
||||||
child_mount_entries = sorted(set(child_mount_entries))
|
child_mount_entries = sorted(set(child_mount_entries))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
mount, rel = await resolve_mount(norm)
|
adapter_model, rel = await resolve_adapter_by_path(norm)
|
||||||
await mount.fetch_related("adapter")
|
adapter_instance = runtime_registry.get(adapter_model.id)
|
||||||
adapter = runtime_registry.get(mount.adapter_id)
|
effective_root = adapter_instance.get_effective_root(adapter_model.sub_path)
|
||||||
effective_root = adapter.get_effective_root(mount.sub_path)
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
mount = None
|
adapter_model = None
|
||||||
adapter = None
|
adapter_instance = None
|
||||||
effective_root = ''
|
effective_root = ''
|
||||||
rel = ''
|
rel = ''
|
||||||
|
|
||||||
@@ -84,8 +83,8 @@ async def list_virtual_dir(path: str, page_num: int = 1, page_size: int = 50) ->
|
|||||||
adapter_total = 0
|
adapter_total = 0
|
||||||
covered = set()
|
covered = set()
|
||||||
|
|
||||||
if mount and adapter:
|
if adapter_model and adapter_instance:
|
||||||
list_dir = await _ensure_method(adapter, "list_dir")
|
list_dir = await _ensure_method(adapter_instance, "list_dir")
|
||||||
try:
|
try:
|
||||||
adapter_entries, adapter_total = await list_dir(effective_root, rel, page_num, page_size)
|
adapter_entries, adapter_total = await list_dir(effective_root, rel, page_num, page_size)
|
||||||
except NotADirectoryError:
|
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]:
|
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 == '':
|
if rel.endswith('/') or rel == '':
|
||||||
raise HTTPException(400, detail="Path is a directory")
|
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)
|
return await read_func(root, rel)
|
||||||
|
|
||||||
|
|
||||||
async def write_file(path: str, data: bytes):
|
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('/'):
|
if rel.endswith('/'):
|
||||||
raise HTTPException(400, detail="Invalid file path")
|
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 write_func(root, rel, data)
|
||||||
await task_service.trigger_tasks("file_written", path)
|
await task_service.trigger_tasks("file_written", path)
|
||||||
await LogService.action(
|
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):
|
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('/'):
|
if rel.endswith('/'):
|
||||||
raise HTTPException(400, detail="Invalid file path")
|
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):
|
if not overwrite and callable(exists_func):
|
||||||
try:
|
try:
|
||||||
if await exists_func(root, rel):
|
if await exists_func(root, rel):
|
||||||
@@ -153,7 +152,7 @@ async def write_file_stream(path: str, data_iter: AsyncIterator[bytes], overwrit
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
size = 0
|
size = 0
|
||||||
stream_func = getattr(adapter, "write_file_stream", None)
|
stream_func = getattr(adapter_instance, "write_file_stream", None)
|
||||||
if callable(stream_func):
|
if callable(stream_func):
|
||||||
size = await stream_func(root, rel, data_iter)
|
size = await stream_func(root, rel, data_iter)
|
||||||
else:
|
else:
|
||||||
@@ -161,7 +160,7 @@ async def write_file_stream(path: str, data_iter: AsyncIterator[bytes], overwrit
|
|||||||
async for chunk in data_iter:
|
async for chunk in data_iter:
|
||||||
if chunk:
|
if chunk:
|
||||||
buf.extend(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))
|
await write_func(root, rel, bytes(buf))
|
||||||
size = len(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):
|
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:
|
if not rel:
|
||||||
raise HTTPException(400, detail="Cannot create root")
|
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 mkdir_func(root, rel)
|
||||||
await LogService.action("virtual_fs", f"Created directory {path}", details={"path": path})
|
await LogService.action("virtual_fs", f"Created directory {path}", details={"path": path})
|
||||||
|
|
||||||
|
|
||||||
async def delete_path(path: str):
|
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:
|
if not rel:
|
||||||
raise HTTPException(400, detail="Cannot delete root")
|
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 delete_func(root, rel)
|
||||||
await task_service.trigger_tasks("file_deleted", path)
|
await task_service.trigger_tasks("file_deleted", path)
|
||||||
await LogService.action("virtual_fs", f"Deleted {path}", details={"path": 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):
|
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_s, adapter_model_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_d, adapter_model_d, root_d, rel_d = await resolve_adapter_and_rel(dst)
|
||||||
debug_info = {
|
debug_info = {
|
||||||
"src": src, "dst": dst,
|
"src": src, "dst": dst,
|
||||||
"rel_s": rel_s, "rel_d": rel_d,
|
"rel_s": rel_s, "rel_d": rel_d,
|
||||||
"root_s": root_s, "root_d": root_d,
|
"root_s": root_s, "root_d": root_d,
|
||||||
"overwrite": overwrite
|
"overwrite": overwrite
|
||||||
}
|
}
|
||||||
if mount_s.id != mount_d.id:
|
if adapter_model_s.id != adapter_model_d.id:
|
||||||
raise HTTPException(400, detail="Cross-mount move not supported")
|
raise HTTPException(400, detail="Cross-adapter move not supported")
|
||||||
if not rel_s:
|
if not rel_s:
|
||||||
raise HTTPException(400, detail="Cannot move or rename mount root")
|
raise HTTPException(400, detail="Cannot move or rename mount root")
|
||||||
if not rel_d:
|
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):
|
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_s, adapter_model_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_d, adapter_model_d, root_d, rel_d = await resolve_adapter_and_rel(dst)
|
||||||
debug_info = {
|
debug_info = {
|
||||||
"src": src, "dst": dst,
|
"src": src, "dst": dst,
|
||||||
"rel_s": rel_s, "rel_d": rel_d,
|
"rel_s": rel_s, "rel_d": rel_d,
|
||||||
"root_s": root_s, "root_d": root_d,
|
"root_s": root_s, "root_d": root_d,
|
||||||
"overwrite": overwrite
|
"overwrite": overwrite
|
||||||
}
|
}
|
||||||
if mount_s.id != mount_d.id:
|
if adapter_model_s.id != adapter_model_d.id:
|
||||||
raise HTTPException(400, detail="Cross-mount rename not supported")
|
raise HTTPException(400, detail="Cross-adapter rename not supported")
|
||||||
if not rel_s:
|
if not rel_s:
|
||||||
raise HTTPException(400, detail="Cannot rename mount root")
|
raise HTTPException(400, detail="Cannot rename mount root")
|
||||||
if not rel_d:
|
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):
|
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('/'):
|
if not rel or rel.endswith('/'):
|
||||||
raise HTTPException(400, detail="Path is a directory")
|
raise HTTPException(400, detail="Path is a directory")
|
||||||
if is_raw_filename(rel):
|
if is_raw_filename(rel):
|
||||||
@@ -371,7 +370,7 @@ async def stream_file(path: str, range_header: str | None):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(500, detail=f"RAW file processing failed: {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):
|
if callable(stream_impl):
|
||||||
return await stream_impl(root, rel, range_header)
|
return await stream_impl(root, rel, range_header)
|
||||||
data = await read_file(path)
|
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):
|
async def stat_file(path: str):
|
||||||
adapter, _mount, root, rel = await resolve_adapter_and_rel(path)
|
adapter_instance, _, root, rel = await resolve_adapter_and_rel(path)
|
||||||
stat_func = getattr(adapter, "stat_file", None)
|
stat_func = getattr(adapter_instance, "stat_file", None)
|
||||||
if not callable(stat_func):
|
if not callable(stat_func):
|
||||||
raise HTTPException(501, detail="Adapter does not implement stat_file")
|
raise HTTPException(501, detail="Adapter does not implement stat_file")
|
||||||
return await stat_func(root, rel)
|
return await stat_func(root, rel)
|
||||||
|
|
||||||
|
|
||||||
async def copy_path(src: str, dst: str, overwrite: bool = False, return_debug: bool = True):
|
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_s, adapter_model_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_d, adapter_model_d, root_d, rel_d = await resolve_adapter_and_rel(dst)
|
||||||
debug_info = {
|
debug_info = {
|
||||||
"src": src, "dst": dst,
|
"src": src, "dst": dst,
|
||||||
"rel_s": rel_s, "rel_d": rel_d,
|
"rel_s": rel_s, "rel_d": rel_d,
|
||||||
"root_s": root_s, "root_d": root_d,
|
"root_s": root_s, "root_d": root_d,
|
||||||
"overwrite": overwrite
|
"overwrite": overwrite
|
||||||
}
|
}
|
||||||
if mount_s.id != mount_d.id:
|
if adapter_model_s.id != adapter_model_d.id:
|
||||||
raise HTTPException(400, detail="Cross-mount copy not supported")
|
raise HTTPException(400, detail="Cross-adapter copy not supported")
|
||||||
if not rel_s:
|
if not rel_s:
|
||||||
raise HTTPException(400, detail="Cannot copy mount root")
|
raise HTTPException(400, detail="Cannot copy mount root")
|
||||||
if not rel_d:
|
if not rel_d:
|
||||||
|
|||||||
@@ -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 { navGroups } from './nav.ts';
|
||||||
import type { NavItem, NavGroup } from './nav.ts';
|
import type { NavItem, NavGroup } from './nav.ts';
|
||||||
import { memo, useEffect, useState } from 'react';
|
import { memo, useEffect, useState } from 'react';
|
||||||
@@ -225,13 +225,71 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
|
|||||||
onCancel={() => setIsVersionModalOpen(false)}
|
onCancel={() => setIsVersionModalOpen(false)}
|
||||||
title="版本信息"
|
title="版本信息"
|
||||||
footer={null}
|
footer={null}
|
||||||
|
width={600}
|
||||||
>
|
>
|
||||||
<div>
|
<div style={{ paddingTop: 12 }}>
|
||||||
<p>当前版本: {status?.version}</p>
|
{latestVersion ? (
|
||||||
{latestVersion && (
|
<>
|
||||||
<div>
|
<Descriptions bordered column={1} size="small">
|
||||||
<p>最新版本: {latestVersion.version}</p>
|
<Descriptions.Item label="当前版本">
|
||||||
<ReactMarkdown>{latestVersion.body}</ReactMarkdown>
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user