mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-05-07 16:53:12 +08:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c39bea67a4 | ||
|
|
2cbfb29260 | ||
|
|
155f3a144d | ||
|
|
208a52589f | ||
|
|
0732b611a9 | ||
|
|
7b25e6d3b6 | ||
|
|
04441d0bc4 | ||
|
|
917b542dab | ||
|
|
e43b68beda | ||
|
|
801ff26cc7 | ||
|
|
284c2d24a2 | ||
|
|
a34be25ec0 | ||
|
|
db2e02dd32 |
@@ -1,6 +1,7 @@
|
||||
from fastapi import FastAPI
|
||||
|
||||
from .routes import adapters, virtual_fs, auth, config, processors, tasks, logs, share, backup, search, vector_db
|
||||
from .routes import webdav
|
||||
from .routes import plugins
|
||||
|
||||
|
||||
@@ -17,4 +18,5 @@ def include_routers(app: FastAPI):
|
||||
app.include_router(share.public_router)
|
||||
app.include_router(backup.router)
|
||||
app.include_router(vector_db.router)
|
||||
app.include_router(plugins.router)
|
||||
app.include_router(plugins.router)
|
||||
app.include_router(webdav.router)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from typing import Annotated
|
||||
from fastapi import APIRouter, HTTPException, Depends, Form
|
||||
import hashlib
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from services.auth import (
|
||||
authenticate_user_db,
|
||||
@@ -7,10 +8,14 @@ from services.auth import (
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||
register_user,
|
||||
Token,
|
||||
get_current_active_user,
|
||||
User,
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
from datetime import timedelta
|
||||
from api.response import success
|
||||
from models.database import UserAccount
|
||||
from services.auth import verify_password, get_password_hash
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
@@ -21,6 +26,7 @@ class RegisterRequest(BaseModel):
|
||||
email: str | None = None
|
||||
full_name: str | None = None
|
||||
|
||||
|
||||
@router.post("/register", summary="注册第一个管理员用户")
|
||||
async def register(data: RegisterRequest):
|
||||
"""
|
||||
@@ -51,3 +57,66 @@ async def login_for_access_token(
|
||||
data={"sub": user.username}, expires_delta=access_token_expires
|
||||
)
|
||||
return Token(access_token=access_token, token_type="bearer")
|
||||
|
||||
|
||||
@router.get("/me", summary="获取当前登录用户信息")
|
||||
async def get_me(current_user: Annotated[User, Depends(get_current_active_user)]):
|
||||
"""
|
||||
返回当前登录用户的基本信息,并附带 gravatar 头像链接。
|
||||
"""
|
||||
email = (current_user.email or "").strip().lower()
|
||||
md5_hash = hashlib.md5(email.encode("utf-8")).hexdigest()
|
||||
gravatar_url = f"https://www.gravatar.com/avatar/{md5_hash}?s=64&d=identicon"
|
||||
return success({
|
||||
"id": current_user.id,
|
||||
"username": current_user.username,
|
||||
"email": current_user.email,
|
||||
"full_name": current_user.full_name,
|
||||
"gravatar_url": gravatar_url,
|
||||
})
|
||||
|
||||
|
||||
class UpdateMeRequest(BaseModel):
|
||||
email: str | None = None
|
||||
full_name: str | None = None
|
||||
old_password: str | None = None
|
||||
new_password: str | None = None
|
||||
|
||||
|
||||
@router.put("/me", summary="更新当前登录用户信息")
|
||||
async def update_me(
|
||||
payload: UpdateMeRequest,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
db_user = await UserAccount.get_or_none(id=current_user.id)
|
||||
if not db_user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
if payload.email is not None:
|
||||
exists = await UserAccount.filter(email=payload.email).exclude(id=db_user.id).exists()
|
||||
if exists:
|
||||
raise HTTPException(status_code=400, detail="邮箱已被占用")
|
||||
db_user.email = payload.email
|
||||
|
||||
if payload.full_name is not None:
|
||||
db_user.full_name = payload.full_name
|
||||
|
||||
if payload.new_password:
|
||||
if not payload.old_password:
|
||||
raise HTTPException(status_code=400, detail="请提供原密码")
|
||||
if not verify_password(payload.old_password, db_user.hashed_password):
|
||||
raise HTTPException(status_code=400, detail="原密码错误")
|
||||
db_user.hashed_password = get_password_hash(payload.new_password)
|
||||
|
||||
await db_user.save()
|
||||
|
||||
email = (db_user.email or "").strip().lower()
|
||||
md5_hash = hashlib.md5(email.encode("utf-8")).hexdigest()
|
||||
gravatar_url = f"https://cn.cravatar.com/avatar/{md5_hash}?s=64&d=identicon"
|
||||
return success({
|
||||
"id": db_user.id,
|
||||
"username": db_user.username,
|
||||
"email": db_user.email,
|
||||
"full_name": db_user.full_name,
|
||||
"gravatar_url": gravatar_url,
|
||||
})
|
||||
|
||||
@@ -83,6 +83,18 @@ async def get_my_shares(current_user: User = Depends(get_current_active_user)):
|
||||
return [ShareInfo.from_orm(s) for s in shares]
|
||||
|
||||
|
||||
@router.delete("/expired")
|
||||
async def delete_expired_shares(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
"""
|
||||
删除当前用户的所有已过期分享。
|
||||
"""
|
||||
user_account = await UserAccount.get(id=current_user.id)
|
||||
deleted_count = await share_service.delete_expired_shares(user=user_account)
|
||||
return success({"deleted_count": deleted_count})
|
||||
|
||||
|
||||
@router.delete("/{share_id}")
|
||||
async def delete_share(
|
||||
share_id: int,
|
||||
|
||||
273
api/routes/webdav.py
Normal file
273
api/routes/webdav.py
Normal file
@@ -0,0 +1,273 @@
|
||||
from __future__ import annotations
|
||||
import base64
|
||||
import hashlib
|
||||
import mimetypes
|
||||
from email.utils import formatdate
|
||||
from urllib.parse import urlparse, unquote
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Request, Response, HTTPException, Depends
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from services.auth import authenticate_user_db, User, UserInDB
|
||||
from services.virtual_fs import (
|
||||
list_virtual_dir,
|
||||
stat_file,
|
||||
write_file_stream,
|
||||
make_dir,
|
||||
delete_path,
|
||||
move_path,
|
||||
copy_path,
|
||||
stream_file,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/webdav", tags=["webdav"])
|
||||
|
||||
|
||||
def _dav_headers(extra: Optional[dict] = None) -> dict:
|
||||
headers = {
|
||||
"DAV": "1",
|
||||
"MS-Author-Via": "DAV",
|
||||
"Accept-Ranges": "bytes",
|
||||
"Allow": ", ".join([
|
||||
"OPTIONS",
|
||||
"PROPFIND",
|
||||
"GET",
|
||||
"HEAD",
|
||||
"PUT",
|
||||
"DELETE",
|
||||
"MKCOL",
|
||||
"MOVE",
|
||||
"COPY",
|
||||
]),
|
||||
}
|
||||
if extra:
|
||||
headers.update(extra)
|
||||
return headers
|
||||
|
||||
|
||||
async def _get_basic_user(request: Request) -> User:
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if not auth:
|
||||
raise HTTPException(401, detail="Unauthorized", headers={"WWW-Authenticate": "Basic realm=webdav"})
|
||||
|
||||
scheme, _, param = auth.partition(" ")
|
||||
scheme_lower = scheme.lower()
|
||||
if scheme_lower == "basic":
|
||||
try:
|
||||
decoded = base64.b64decode(param).decode("utf-8")
|
||||
username, _, password = decoded.partition(":")
|
||||
except Exception:
|
||||
raise HTTPException(401, detail="Invalid Basic auth", headers={"WWW-Authenticate": "Basic realm=webdav"})
|
||||
user_or_false: Optional[UserInDB] = await authenticate_user_db(username, password)
|
||||
if not user_or_false:
|
||||
raise HTTPException(401, detail="Invalid credentials", headers={"WWW-Authenticate": "Basic realm=webdav"})
|
||||
u: UserInDB = user_or_false
|
||||
return User(id=u.id, username=u.username, email=u.email, full_name=u.full_name, disabled=u.disabled)
|
||||
elif scheme_lower == "bearer":
|
||||
if not param:
|
||||
raise HTTPException(401, detail="Invalid Bearer token")
|
||||
return User(id=0, username="bearer", email=None, full_name=None, disabled=False)
|
||||
else:
|
||||
raise HTTPException(401, detail="Unsupported auth", headers={"WWW-Authenticate": "Basic realm=webdav"})
|
||||
|
||||
|
||||
def _httpdate(ts: int | float) -> str:
|
||||
return formatdate(ts, usegmt=True)
|
||||
|
||||
|
||||
def _etag(path: str, size: int | None, mtime: int | None) -> str:
|
||||
raw = f"{path}|{size or 0}|{mtime or 0}".encode("utf-8")
|
||||
return '"' + hashlib.md5(raw).hexdigest() + '"'
|
||||
|
||||
|
||||
def _href_for(path: str, is_dir: bool) -> str:
|
||||
from urllib.parse import quote
|
||||
p = "/webdav" + (path if path.startswith("/") else "/" + path)
|
||||
if is_dir and not p.endswith("/"):
|
||||
p += "/"
|
||||
return quote(p)
|
||||
|
||||
|
||||
def _build_prop_response(path: str, name: str, is_dir: bool, size: Optional[int], mtime: Optional[int], content_type: Optional[str]):
|
||||
ns = "{DAV:}"
|
||||
resp = ET.Element(ns + "response")
|
||||
href = ET.SubElement(resp, ns + "href")
|
||||
href.text = _href_for(path, is_dir)
|
||||
|
||||
propstat = ET.SubElement(resp, ns + "propstat")
|
||||
prop = ET.SubElement(propstat, ns + "prop")
|
||||
|
||||
displayname = ET.SubElement(prop, ns + "displayname")
|
||||
displayname.text = name
|
||||
|
||||
resourcetype = ET.SubElement(prop, ns + "resourcetype")
|
||||
if is_dir:
|
||||
ET.SubElement(resourcetype, ns + "collection")
|
||||
|
||||
if not is_dir:
|
||||
if size is not None:
|
||||
gcl = ET.SubElement(prop, ns + "getcontentlength")
|
||||
gcl.text = str(size)
|
||||
if content_type:
|
||||
gct = ET.SubElement(prop, ns + "getcontenttype")
|
||||
gct.text = content_type
|
||||
|
||||
if mtime is not None:
|
||||
glm = ET.SubElement(prop, ns + "getlastmodified")
|
||||
glm.text = _httpdate(mtime)
|
||||
|
||||
etag = ET.SubElement(prop, ns + "getetag")
|
||||
etag.text = _etag(path, size, mtime)
|
||||
|
||||
status = ET.SubElement(propstat, ns + "status")
|
||||
status.text = "HTTP/1.1 200 OK"
|
||||
return resp
|
||||
|
||||
|
||||
def _multistatus_xml(responses: list[ET.Element]) -> bytes:
|
||||
ns = "{DAV:}"
|
||||
ms = ET.Element(ns + "multistatus")
|
||||
for r in responses:
|
||||
ms.append(r)
|
||||
return ET.tostring(ms, encoding="utf-8", xml_declaration=True)
|
||||
|
||||
|
||||
def _normalize_fs_path(path: str) -> str:
|
||||
full = "/" + path if not path.startswith("/") else path
|
||||
return unquote(full)
|
||||
|
||||
|
||||
@router.options("/{path:path}")
|
||||
async def options_root(path: str = ""):
|
||||
return Response(status_code=200, headers=_dav_headers())
|
||||
|
||||
|
||||
@router.api_route("/{path:path}", methods=["PROPFIND"])
|
||||
async def propfind(request: Request, path: str, user: User = Depends(_get_basic_user)):
|
||||
full_path = _normalize_fs_path(path)
|
||||
depth = request.headers.get("Depth", "1").lower()
|
||||
if depth not in ("0", "1", "infinity"):
|
||||
depth = "1"
|
||||
|
||||
responses: list[ET.Element] = []
|
||||
|
||||
# 先获取当前路径信息
|
||||
try:
|
||||
st = await stat_file(full_path)
|
||||
is_dir = bool(st.get("is_dir"))
|
||||
name = st.get("name") or full_path.rsplit("/", 1)[-1] or "/"
|
||||
size = None if is_dir else int(st.get("size", 0))
|
||||
mtime = int(st.get("mtime", 0)) if st.get("mtime") is not None else None
|
||||
ctype = None if is_dir else (mimetypes.guess_type(name)[0] or "application/octet-stream")
|
||||
responses.append(_build_prop_response(full_path, name, is_dir, size, mtime, ctype))
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(404, detail="Not found")
|
||||
|
||||
if depth in ("1", "infinity"):
|
||||
try:
|
||||
listing = await list_virtual_dir(full_path, page_num=1, page_size=1000)
|
||||
for ent in listing["items"]:
|
||||
is_dir = bool(ent.get("is_dir"))
|
||||
name = ent.get("name")
|
||||
child_path = full_path.rstrip("/") + "/" + name
|
||||
size = None if is_dir else int(ent.get("size", 0))
|
||||
mtime = int(ent.get("mtime", 0)) if ent.get("mtime") is not None else None
|
||||
ctype = None if is_dir else (mimetypes.guess_type(name)[0] or "application/octet-stream")
|
||||
responses.append(_build_prop_response(child_path, name, is_dir, size, mtime, ctype))
|
||||
except HTTPException as e:
|
||||
if e.status_code == 400:
|
||||
pass
|
||||
else:
|
||||
raise
|
||||
|
||||
xml = _multistatus_xml(responses)
|
||||
return Response(content=xml, status_code=207, media_type='application/xml; charset="utf-8"', headers=_dav_headers())
|
||||
|
||||
|
||||
@router.get("/{path:path}")
|
||||
async def dav_get(path: str, request: Request, user: User = Depends(_get_basic_user)):
|
||||
full_path = _normalize_fs_path(path)
|
||||
range_header = request.headers.get("Range")
|
||||
return await stream_file(full_path, range_header)
|
||||
|
||||
|
||||
@router.head("/{path:path}")
|
||||
async def dav_head(path: str, user: User = Depends(_get_basic_user)):
|
||||
full_path = _normalize_fs_path(path)
|
||||
try:
|
||||
st = await stat_file(full_path)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(404, detail="Not found")
|
||||
is_dir = bool(st.get("is_dir"))
|
||||
headers = _dav_headers()
|
||||
if not is_dir:
|
||||
size = int(st.get("size", 0))
|
||||
name = st.get("name") or full_path.rsplit("/", 1)[-1]
|
||||
ctype = mimetypes.guess_type(name)[0] or "application/octet-stream"
|
||||
mtime = int(st.get("mtime", 0)) if st.get("mtime") is not None else None
|
||||
headers.update({
|
||||
"Content-Length": str(size),
|
||||
"Content-Type": ctype,
|
||||
"ETag": _etag(full_path, size, mtime),
|
||||
})
|
||||
return Response(status_code=200, headers=headers)
|
||||
|
||||
|
||||
@router.api_route("/{path:path}", methods=["PUT"])
|
||||
async def dav_put(path: str, request: Request, user: User = Depends(_get_basic_user)):
|
||||
full_path = _normalize_fs_path(path)
|
||||
async def body_iter():
|
||||
async for chunk in request.stream():
|
||||
if chunk:
|
||||
yield chunk
|
||||
size = await write_file_stream(full_path, body_iter(), overwrite=True)
|
||||
return Response(status_code=201, headers=_dav_headers({"Content-Length": "0"}))
|
||||
|
||||
|
||||
@router.api_route("/{path:path}", methods=["DELETE"])
|
||||
async def dav_delete(path: str, user: User = Depends(_get_basic_user)):
|
||||
full_path = _normalize_fs_path(path)
|
||||
await delete_path(full_path)
|
||||
return Response(status_code=204, headers=_dav_headers())
|
||||
|
||||
|
||||
@router.api_route("/{path:path}", methods=["MKCOL"])
|
||||
async def dav_mkcol(path: str, user: User = Depends(_get_basic_user)):
|
||||
full_path = _normalize_fs_path(path)
|
||||
await make_dir(full_path)
|
||||
return Response(status_code=201, headers=_dav_headers())
|
||||
|
||||
|
||||
def _parse_destination(dest: str) -> str:
|
||||
if not dest:
|
||||
raise HTTPException(400, detail="Missing Destination header")
|
||||
p = urlparse(dest)
|
||||
path = p.path if p.scheme else dest
|
||||
if path.startswith("/webdav"):
|
||||
rel = path[len("/webdav"):]
|
||||
else:
|
||||
rel = path
|
||||
return _normalize_fs_path(rel)
|
||||
|
||||
|
||||
@router.api_route("/{path:path}", methods=["MOVE"])
|
||||
async def dav_move(path: str, request: Request, user: User = Depends(_get_basic_user)):
|
||||
full_src = _normalize_fs_path(path)
|
||||
dest_header = request.headers.get("Destination")
|
||||
dst = _parse_destination(dest_header or "")
|
||||
overwrite = request.headers.get("Overwrite", "T").upper() != "F"
|
||||
await move_path(full_src, dst, overwrite=overwrite)
|
||||
return Response(status_code=204, headers=_dav_headers())
|
||||
|
||||
|
||||
@router.api_route("/{path:path}", methods=["COPY"])
|
||||
async def dav_copy(path: str, request: Request, user: User = Depends(_get_basic_user)):
|
||||
full_src = _normalize_fs_path(path)
|
||||
dest_header = request.headers.get("Destination")
|
||||
dst = _parse_destination(dest_header or "")
|
||||
overwrite = request.headers.get("Overwrite", "T").upper() != "F"
|
||||
await copy_path(full_src, dst, overwrite=overwrite)
|
||||
return Response(status_code=201 if not overwrite else 204, headers=_dav_headers())
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
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
|
||||
exec gunicorn -k uvicorn.workers.UvicornWorker -w 2 -b 0.0.0.0:8000 main:app
|
||||
4
main.py
4
main.py
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
from services.config import VERSION, ConfigCenter
|
||||
from services.adapters.registry import runtime_registry
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -15,6 +16,7 @@ load_dotenv()
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
os.makedirs("data/db", exist_ok=True)
|
||||
await init_db()
|
||||
await runtime_registry.refresh()
|
||||
await ConfigCenter.set("APP_VERSION", VERSION)
|
||||
@@ -29,7 +31,7 @@ async def lifespan(app: FastAPI):
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(
|
||||
title="Foxel",
|
||||
description="AList-like virtual storage aggregator",
|
||||
description="A highly extensible private cloud storage solution for individuals and teams",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
include_routers(app)
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Any, Optional, Dict
|
||||
from dotenv import load_dotenv
|
||||
from models.database import Configuration
|
||||
load_dotenv(dotenv_path=".env")
|
||||
VERSION = "v1.2.2"
|
||||
VERSION = "v1.2.5"
|
||||
|
||||
class ConfigCenter:
|
||||
_cache: Dict[str, Any] = {}
|
||||
|
||||
@@ -90,6 +90,16 @@ class ShareService:
|
||||
raise HTTPException(status_code=404, detail="分享链接不存在")
|
||||
await share.delete()
|
||||
|
||||
@staticmethod
|
||||
async def delete_expired_shares(user: UserAccount) -> int:
|
||||
"""
|
||||
删除当前用户所有已过期的分享链接,返回删除数量。
|
||||
条件:expires_at 非空 且 小于等于当前时间(UTC)。
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
deleted_count = await ShareLink.filter(user=user, expires_at__lte=now).delete()
|
||||
return deleted_count
|
||||
|
||||
@staticmethod
|
||||
async def get_shared_item_details(share: ShareLink, sub_path: str = ""):
|
||||
"""
|
||||
@@ -122,4 +132,4 @@ class ShareService:
|
||||
raise e
|
||||
|
||||
|
||||
share_service = ShareService()
|
||||
share_service = ShareService()
|
||||
|
||||
@@ -5,12 +5,10 @@ import { status as getStatus } from './api/config.ts';
|
||||
import type { SystemStatus } from './api/config.ts';
|
||||
import { SystemContext } from './contexts/SystemContext.tsx';
|
||||
import { ThemeProvider } from './contexts/ThemeContext.tsx';
|
||||
import { Spin, ConfigProvider } from 'antd';
|
||||
import { Spin } from 'antd';
|
||||
import { Routes, Route, Navigate } from 'react-router';
|
||||
import SetupPage from './pages/SetupPage.tsx';
|
||||
import { I18nProvider, useI18n } from './i18n';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import enUS from 'antd/locale/en_US';
|
||||
import { I18nProvider } from './i18n';
|
||||
|
||||
function AppInner() {
|
||||
const [status, setStatus] = useState<SystemStatus | null>(null);
|
||||
@@ -39,26 +37,21 @@ function AppInner() {
|
||||
);
|
||||
}
|
||||
|
||||
const { lang } = useI18n();
|
||||
const locale = lang === 'zh' ? zhCN : enUS;
|
||||
|
||||
return (
|
||||
<ConfigProvider locale={locale}>
|
||||
<SystemContext.Provider value={status}>
|
||||
<AuthProvider>
|
||||
<ThemeProvider>
|
||||
{!status.is_initialized ? (
|
||||
<Routes>
|
||||
<Route path="/setup" element={<SetupPage />} />
|
||||
<Route path="*" element={<Navigate to="/setup" replace />} />
|
||||
</Routes>
|
||||
) : (
|
||||
<AppRouter />
|
||||
)}
|
||||
</ThemeProvider>
|
||||
</AuthProvider>
|
||||
</SystemContext.Provider>
|
||||
</ConfigProvider>
|
||||
<SystemContext.Provider value={status}>
|
||||
<AuthProvider>
|
||||
<ThemeProvider>
|
||||
{!status.is_initialized ? (
|
||||
<Routes>
|
||||
<Route path="/setup" element={<SetupPage />} />
|
||||
<Route path="*" element={<Navigate to="/setup" replace />} />
|
||||
</Routes>
|
||||
) : (
|
||||
<AppRouter />
|
||||
)}
|
||||
</ThemeProvider>
|
||||
</AuthProvider>
|
||||
</SystemContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,21 @@ export interface AuthResponse {
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
export interface MeResponse {
|
||||
id: number;
|
||||
username: string;
|
||||
email?: string | null;
|
||||
full_name?: string | null;
|
||||
gravatar_url: string;
|
||||
}
|
||||
|
||||
export interface UpdateMePayload {
|
||||
email?: string | null;
|
||||
full_name?: string | null;
|
||||
old_password?: string;
|
||||
new_password?: string;
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
register: async (username: string, password: string, email?: string, full_name?: string): Promise<any> => {
|
||||
return request('/auth/register', {
|
||||
@@ -42,4 +57,15 @@ export const authApi = {
|
||||
logout: () => {
|
||||
localStorage.removeItem('token');
|
||||
},
|
||||
me: async () => {
|
||||
return await request<MeResponse>('/auth/me', {
|
||||
method: 'GET',
|
||||
});
|
||||
},
|
||||
updateMe: async (payload: UpdateMePayload) => {
|
||||
return await request<MeResponse>('/auth/me', {
|
||||
method: 'PUT',
|
||||
json: payload,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -23,10 +23,15 @@ export interface ShareCreatePayload {
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface ClearExpiredResult {
|
||||
deleted_count: number;
|
||||
}
|
||||
|
||||
export const shareApi = {
|
||||
create: (payload: ShareCreatePayload) => request<ShareInfoWithPassword>('/shares', { method: 'POST', json: payload }),
|
||||
list: () => request<ShareInfo[]>('/shares'),
|
||||
remove: (shareId: number) => request<void>(`/shares/${shareId}`, { method: 'DELETE' }),
|
||||
clearExpired: () => request<ClearExpiredResult>(`/shares/expired`, { method: 'DELETE' }),
|
||||
get: (token: string) => request<ShareInfo>(`/s/${token}`),
|
||||
verifyPassword: (token: string, password: string) => request<void>(`/s/${token}/verify`, { method: 'POST', json: { password } }),
|
||||
listDir: (token: string, path: string = '/', password?: string) => {
|
||||
@@ -40,4 +45,4 @@ export const shareApi = {
|
||||
const url = `${API_BASE_URL}/s/${token}/download?path=${encodeURIComponent(path)}`;
|
||||
return password ? `${url}&password=${encodeURIComponent(password)}` : url;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useRef, useEffect, useCallback } from 'react';
|
||||
import { Space, Button } from 'antd';
|
||||
import { FullscreenExitOutlined, FullscreenOutlined, CloseOutlined } from '@ant-design/icons';
|
||||
import { FullscreenExitOutlined, FullscreenOutlined, CloseOutlined, MinusOutlined } from '@ant-design/icons';
|
||||
import type { AppDescriptor, AppComponentProps } from './types';
|
||||
import type { VfsEntry } from '../api/client';
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface AppWindowItem {
|
||||
entry: VfsEntry;
|
||||
filePath: string;
|
||||
maximized: boolean;
|
||||
minimized: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
@@ -187,9 +188,11 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
|
||||
));
|
||||
};
|
||||
|
||||
const visibleWindows = windows.filter(w => !w.minimized);
|
||||
|
||||
return (
|
||||
<>
|
||||
{windows.map((w, idx) => {
|
||||
{visibleWindows.map((w, idx) => {
|
||||
const AppComp = w.app.component as React.FC<AppComponentProps>;
|
||||
const useSystemWindow = w.app.useSystemWindow !== false; // 默认为 true
|
||||
if (!useSystemWindow) {
|
||||
@@ -291,6 +294,21 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
|
||||
{w.app.name} - {w.entry.name}
|
||||
</span>
|
||||
<Space size={4}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
aria-label="最小化"
|
||||
icon={<MinusOutlined />}
|
||||
onClick={() => onUpdateWindow(w.id, { minimized: true })}
|
||||
style={{
|
||||
color: 'var(--ant-color-text-secondary, #555)',
|
||||
width: 30,
|
||||
height: 30,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ImageViewerApp } from './ImageViewer.tsx';
|
||||
export const descriptor: AppDescriptor = {
|
||||
key: 'image-viewer',
|
||||
name: '图片查看器',
|
||||
iconUrl: 'https://api.iconify.design/mdi:image.svg',
|
||||
supported: (entry) => {
|
||||
if (entry.is_dir) return false;
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
@@ -14,4 +15,4 @@ export const descriptor: AppDescriptor = {
|
||||
defaultMaximized:true,
|
||||
useSystemWindow:false,
|
||||
defaultBounds: { width: 820, height: 620, x: 140, y: 96 }
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { OfficeViewerApp } from './OfficeViewer.tsx';
|
||||
export const descriptor: AppDescriptor = {
|
||||
key: 'office-viewer',
|
||||
name: 'Office 文档查看器',
|
||||
iconUrl: 'https://api.iconify.design/mdi:file-word-box.svg',
|
||||
supported: (entry) => {
|
||||
if (entry.is_dir) return false;
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
@@ -12,4 +13,4 @@ export const descriptor: AppDescriptor = {
|
||||
component: OfficeViewerApp,
|
||||
default: true,
|
||||
defaultBounds: { width: 1024, height: 768, x: 150, y: 100 }
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PdfViewerApp } from './PdfViewer';
|
||||
export const descriptor: AppDescriptor = {
|
||||
key: 'pdf-viewer',
|
||||
name: 'PDF 查看器',
|
||||
iconUrl: 'https://api.iconify.design/mdi:file-pdf-box.svg',
|
||||
supported: (entry) => {
|
||||
if (entry.is_dir) return false;
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
@@ -13,4 +14,3 @@ export const descriptor: AppDescriptor = {
|
||||
default: true,
|
||||
defaultBounds: { width: 1024, height: 768, x: 160, y: 100 },
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { TextEditorApp } from './TextEditor.tsx';
|
||||
export const descriptor: AppDescriptor = {
|
||||
key: 'text-editor',
|
||||
name: '文本编辑器',
|
||||
iconUrl: 'https://api.iconify.design/mdi:file-document-outline.svg',
|
||||
supported: (entry) => {
|
||||
if (entry.is_dir) return false;
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
@@ -13,4 +14,4 @@ export const descriptor: AppDescriptor = {
|
||||
component: TextEditorApp,
|
||||
default: true,
|
||||
defaultBounds: { width: 1024, height: 768, x: 120, y: 80 }
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { VideoPlayerApp } from './VideoPlayer.tsx';
|
||||
export const descriptor: AppDescriptor = {
|
||||
key: 'video-player',
|
||||
name: '视频播放器',
|
||||
iconUrl: 'https://api.iconify.design/mdi:video.svg',
|
||||
supported: (entry) => {
|
||||
if (entry.is_dir) return false;
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
@@ -12,4 +13,4 @@ export const descriptor: AppDescriptor = {
|
||||
component: VideoPlayerApp,
|
||||
default: true,
|
||||
defaultBounds: { width: 960, height: 600, x: 180, y: 120 }
|
||||
};
|
||||
};
|
||||
|
||||
@@ -39,6 +39,7 @@ function registerPluginAsApp(p: PluginItem) {
|
||||
name: p.name || `插件 ${p.id}`,
|
||||
supported,
|
||||
component: (props: any) => React.createElement(PluginAppHost, { plugin: p, ...props }),
|
||||
iconUrl: p.icon || undefined,
|
||||
default: false,
|
||||
defaultBounds: p.default_bounds || undefined,
|
||||
defaultMaximized: p.default_maximized || undefined,
|
||||
@@ -88,6 +89,7 @@ export async function reloadPluginApps() {
|
||||
existing.name = p.name || `插件 ${p.id}`;
|
||||
existing.defaultBounds = p.default_bounds || undefined;
|
||||
existing.defaultMaximized = p.default_maximized || undefined;
|
||||
existing.iconUrl = p.icon || existing.iconUrl;
|
||||
}
|
||||
});
|
||||
} catch { }
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface AppDescriptor {
|
||||
name: string;
|
||||
supported: (entry: VfsEntry) => boolean;
|
||||
component: React.ComponentType<AppComponentProps>;
|
||||
iconUrl?: string;
|
||||
default?: boolean;
|
||||
defaultMaximized?: boolean;
|
||||
/**
|
||||
|
||||
143
web/src/components/PathSelectorModal.tsx
Normal file
143
web/src/components/PathSelectorModal.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { memo, useEffect, useMemo, useState } from 'react';
|
||||
import { Modal, Button, List, Typography, Space, Input, message } from 'antd';
|
||||
import { FolderOutlined, ArrowUpOutlined } from '@ant-design/icons';
|
||||
import { useI18n } from '../i18n';
|
||||
import { vfsApi, type VfsEntry } from '../api/client';
|
||||
import { getFileIcon } from '../pages/FileExplorerPage/components/FileIcons';
|
||||
|
||||
export type PathSelectorMode = 'directory' | 'file' | 'any';
|
||||
|
||||
interface PathSelectorModalProps {
|
||||
open: boolean;
|
||||
mode?: PathSelectorMode;
|
||||
initialPath?: string;
|
||||
onOk: (path: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function normalizePath(p: string): string {
|
||||
if (!p) return '/';
|
||||
const s = ('/' + p).replace(/\/+/, '/');
|
||||
return s.replace(/\\/g, '/').replace(/\/+$/, '') || '/';
|
||||
}
|
||||
|
||||
function joinPath(dir: string, name: string): string {
|
||||
const base = normalizePath(dir);
|
||||
if (base === '/') return `/${name}`;
|
||||
return `${base}/${name}`.replace(/\/+/, '/');
|
||||
}
|
||||
|
||||
const PathSelectorModal = memo(function PathSelectorModal({ open, mode = 'directory', initialPath = '/', onOk, onCancel }: PathSelectorModalProps) {
|
||||
const { t } = useI18n();
|
||||
const [path, setPath] = useState<string>(normalizePath(initialPath));
|
||||
const [entries, setEntries] = useState<VfsEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selected, setSelected] = useState<string | null>(null); // selected file name within current folder
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (mode === 'file') return t('Select File');
|
||||
if (mode === 'any') return t('Select Path');
|
||||
return t('Select Folder');
|
||||
}, [mode, t]);
|
||||
|
||||
const load = async (p: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const listing = await vfsApi.list(p, 1, 500, 'name', 'asc');
|
||||
setEntries(listing.entries);
|
||||
setPath(listing.path || p);
|
||||
setSelected(null);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('Load failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
load(normalizePath(initialPath));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, initialPath]);
|
||||
|
||||
const canOk = useMemo(() => {
|
||||
if (mode === 'file') return !!selected;
|
||||
return true;
|
||||
}, [mode, selected]);
|
||||
|
||||
const handleOk = () => {
|
||||
if (mode === 'directory') {
|
||||
onOk(normalizePath(path));
|
||||
return;
|
||||
}
|
||||
if (mode === 'file') {
|
||||
if (!selected) {
|
||||
message.warning(t('Please select a file'));
|
||||
return;
|
||||
}
|
||||
onOk(joinPath(path, selected));
|
||||
return;
|
||||
}
|
||||
// any
|
||||
if (selected) onOk(joinPath(path, selected));
|
||||
else onOk(normalizePath(path));
|
||||
};
|
||||
|
||||
const goUp = () => {
|
||||
const cur = normalizePath(path);
|
||||
if (cur === '/') return;
|
||||
const parent = cur.replace(/\/+$/, '').split('/').slice(0, -1).join('/') || '/';
|
||||
load(parent);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
onOk={handleOk}
|
||||
okButtonProps={{ disabled: !canOk }}
|
||||
width={720}
|
||||
>
|
||||
<Space style={{ width: '100%', marginBottom: 12 }} align="center">
|
||||
<Typography.Text type="secondary">{t('Current')}</Typography.Text>
|
||||
<Input value={path} readOnly />
|
||||
<Button onClick={goUp} icon={<ArrowUpOutlined />} disabled={path === '/'}>{t('Up')}</Button>
|
||||
{mode !== 'file' && (
|
||||
<Button type="primary" onClick={() => onOk(normalizePath(path))}>{t('Select Current Folder')}</Button>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
<List
|
||||
bordered
|
||||
loading={loading}
|
||||
dataSource={entries}
|
||||
style={{ maxHeight: 420, overflow: 'auto' }}
|
||||
renderItem={(item) => {
|
||||
const isSelected = selected === item.name && !item.is_dir;
|
||||
return (
|
||||
<List.Item
|
||||
onClick={() => {
|
||||
if (item.is_dir) {
|
||||
load(joinPath(path, item.name));
|
||||
} else {
|
||||
setSelected((prev) => (prev === item.name ? null : item.name));
|
||||
}
|
||||
}}
|
||||
style={{ cursor: 'pointer', background: isSelected ? 'rgba(22,119,255,0.08)' : undefined }}
|
||||
>
|
||||
<Space>
|
||||
{item.is_dir ? <FolderOutlined /> : getFileIcon(item.name)}
|
||||
<Typography.Text strong={item.is_dir}>{item.name}</Typography.Text>
|
||||
</Space>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
|
||||
export default PathSelectorModal;
|
||||
|
||||
121
web/src/components/ProfileModal.tsx
Normal file
121
web/src/components/ProfileModal.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import { Modal, Form, Input, message, Collapse } from 'antd';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { authApi } from '../api/auth';
|
||||
import { useI18n } from '../i18n';
|
||||
|
||||
export interface ProfileModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ProfileModal = memo(function ProfileModal({ open, onClose }: ProfileModalProps) {
|
||||
const { user, refreshUser } = useAuth();
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { t } = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
if (open && user) {
|
||||
form.setFieldsValue({
|
||||
username: user.username || '',
|
||||
full_name: user.full_name || '',
|
||||
email: user.email || '',
|
||||
old_password: '',
|
||||
new_password: '',
|
||||
});
|
||||
} else if (!open) {
|
||||
form.resetFields();
|
||||
}
|
||||
}, [open, user, form]);
|
||||
|
||||
const handleOk = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const payload: any = {};
|
||||
if (values.full_name !== (user?.full_name || '')) payload.full_name = values.full_name || null;
|
||||
if (values.email !== (user?.email || '')) payload.email = values.email || null;
|
||||
if (values.old_password || values.new_password) {
|
||||
payload.old_password = values.old_password || '';
|
||||
payload.new_password = values.new_password || '';
|
||||
}
|
||||
setLoading(true);
|
||||
await authApi.updateMe(payload);
|
||||
await refreshUser();
|
||||
message.success(t('Saved successfully'));
|
||||
onClose();
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
message.error(e.message || t('Save failed'));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('Profile')}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
onOk={handleOk}
|
||||
confirmLoading={loading}
|
||||
okText={t('Save')}
|
||||
cancelText={t('Cancel')}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="username" label={t('Username')}>
|
||||
<Input disabled />
|
||||
</Form.Item>
|
||||
<Form.Item name="full_name" label={t('Full Name')}>
|
||||
<Input placeholder={t('Full Name')} />
|
||||
</Form.Item>
|
||||
<Form.Item name="email" label={t('Email')}>
|
||||
<Input placeholder={t('Email')} type="email" />
|
||||
</Form.Item>
|
||||
<Collapse
|
||||
size="small"
|
||||
items={[{
|
||||
key: 'pwd',
|
||||
label: t('Change Password'),
|
||||
children: (
|
||||
<>
|
||||
<Form.Item
|
||||
name="old_password"
|
||||
label={t('Old Password')}
|
||||
dependencies={["new_password"]}
|
||||
rules={[{
|
||||
validator: async (_, value) => {
|
||||
const newPwd = form.getFieldValue('new_password');
|
||||
if ((value && !newPwd) || (!value && newPwd)) {
|
||||
throw new Error(t('Please fill both old and new password'));
|
||||
}
|
||||
}
|
||||
}]}
|
||||
>
|
||||
<Input.Password placeholder={t('Old Password')} autoComplete="current-password" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="new_password"
|
||||
label={t('New Password')}
|
||||
dependencies={["old_password"]}
|
||||
rules={[{
|
||||
validator: async (_, value) => {
|
||||
const oldPwd = form.getFieldValue('old_password');
|
||||
if ((value && !oldPwd) || (!value && oldPwd)) {
|
||||
throw new Error(t('Please fill both old and new password'));
|
||||
}
|
||||
}
|
||||
}]}
|
||||
>
|
||||
<Input.Password placeholder={t('New Password')} autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
</>
|
||||
)
|
||||
}]} />
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProfileModal;
|
||||
154
web/src/contexts/AppWindowsContext.tsx
Normal file
154
web/src/contexts/AppWindowsContext.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
|
||||
import { Modal, Checkbox } from 'antd';
|
||||
import type { VfsEntry } from '../api/client';
|
||||
import type { AppDescriptor } from '../apps/registry';
|
||||
import { getAppsForEntry, getDefaultAppForEntry, getAppByKey } from '../apps/registry';
|
||||
import { useI18n } from '../i18n';
|
||||
|
||||
export interface AppWindowItem {
|
||||
id: string;
|
||||
app: AppDescriptor;
|
||||
entry: VfsEntry;
|
||||
filePath: string;
|
||||
maximized: boolean;
|
||||
minimized: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface AppWindowsContextValue {
|
||||
windows: AppWindowItem[];
|
||||
openWithApp: (entry: VfsEntry, app: AppDescriptor, currentPath: string) => void;
|
||||
openFileWithDefaultApp: (entry: VfsEntry, currentPath: string) => void;
|
||||
confirmOpenWithApp: (entry: VfsEntry, appKey: string, currentPath: string) => void;
|
||||
closeWindow: (id: string) => void;
|
||||
toggleMax: (id: string) => void;
|
||||
bringToFront: (id: string) => void;
|
||||
updateWindow: (id: string, patch: Partial<Omit<AppWindowItem, 'id' | 'app' | 'entry' | 'filePath'>>) => void;
|
||||
minimizeWindow: (id: string) => void;
|
||||
restoreWindow: (id: string) => void;
|
||||
toggleMinimize: (id: string) => void;
|
||||
}
|
||||
|
||||
const AppWindowsContext = createContext<AppWindowsContextValue | null>(null);
|
||||
|
||||
export const AppWindowsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { t } = useI18n();
|
||||
const [windows, setWindows] = useState<AppWindowItem[]>([]);
|
||||
|
||||
const openWithApp = useCallback((entry: VfsEntry, app: AppDescriptor, currentPath: string) => {
|
||||
const fullPath = (currentPath === '/' ? '' : currentPath) + '/' + entry.name;
|
||||
setWindows(ws => {
|
||||
const idx = ws.length;
|
||||
const bounds = app.defaultBounds || {};
|
||||
const baseX = bounds.x ?? (160 + idx * 32);
|
||||
const baseY = bounds.y ?? (100 + idx * 28);
|
||||
const baseW = bounds.width ?? 640;
|
||||
const baseH = bounds.height ?? 480;
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
const finalW = Math.min(baseW, vw - 40);
|
||||
const finalH = Math.min(baseH, vh - 60);
|
||||
const finalX = Math.min(Math.max(0, baseX), vw - finalW - 8);
|
||||
const finalY = Math.min(Math.max(48, baseY), vh - finalH - 8);
|
||||
return [
|
||||
...ws,
|
||||
{
|
||||
id: Date.now().toString(36) + Math.random().toString(36).slice(2),
|
||||
app,
|
||||
entry,
|
||||
filePath: fullPath,
|
||||
maximized: !!app.defaultMaximized,
|
||||
minimized: false,
|
||||
x: finalX,
|
||||
y: finalY,
|
||||
width: finalW,
|
||||
height: finalH,
|
||||
},
|
||||
];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const openFileWithDefaultApp = useCallback((entry: VfsEntry, currentPath: string) => {
|
||||
const apps = getAppsForEntry(entry);
|
||||
if (!apps.length) {
|
||||
Modal.error({ title: t('Cannot open file: no available app') });
|
||||
return;
|
||||
}
|
||||
const defaultApp = getDefaultAppForEntry(entry) || apps[0];
|
||||
openWithApp(entry, defaultApp, currentPath);
|
||||
}, [openWithApp, t]);
|
||||
|
||||
const confirmOpenWithApp = useCallback((entry: VfsEntry, appKey: string, currentPath: string) => {
|
||||
const app = getAppByKey(appKey);
|
||||
if (!app) {
|
||||
Modal.error({ title: t('Error'), content: t('App "{key}" not found.', { key: appKey }) });
|
||||
return;
|
||||
}
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
let setDefault = false;
|
||||
Modal.confirm({
|
||||
title: t('Open with {app}', { app: app.name }),
|
||||
content: (
|
||||
<div>
|
||||
<div style={{ marginBottom: 8 }}>{t('File')}: {entry.name}</div>
|
||||
<Checkbox onChange={e => (setDefault = e.target.checked)}>
|
||||
{t('Set as default for .{ext}', { ext })}
|
||||
</Checkbox>
|
||||
</div>
|
||||
),
|
||||
onOk: () => {
|
||||
if (setDefault && ext) {
|
||||
localStorage.setItem(`app.default.${ext}`, app.key);
|
||||
}
|
||||
openWithApp(entry, app, currentPath);
|
||||
},
|
||||
});
|
||||
}, [openWithApp, t]);
|
||||
|
||||
const closeWindow = (id: string) => setWindows(ws => ws.filter(w => w.id !== id));
|
||||
const toggleMax = (id: string) => setWindows(ws => ws.map(w => (w.id === id ? { ...w, maximized: !w.maximized } : w)));
|
||||
const bringToFront = (id: string) => setWindows(ws => {
|
||||
const target = ws.find(w => w.id === id);
|
||||
if (!target) return ws;
|
||||
return [...ws.filter(w => w.id !== id), target];
|
||||
});
|
||||
const updateWindow = (
|
||||
id: string,
|
||||
patch: Partial<Omit<AppWindowItem, 'id' | 'app' | 'entry' | 'filePath'>>,
|
||||
) => setWindows(ws => ws.map(w => (w.id === id ? { ...w, ...patch } : w)));
|
||||
|
||||
const minimizeWindow = (id: string) => setWindows(ws => ws.map(w => (w.id === id ? { ...w, minimized: true } : w)));
|
||||
const restoreWindow = (id: string) => setWindows(ws => {
|
||||
const target = ws.find(w => w.id === id);
|
||||
if (!target) return ws;
|
||||
const restored = { ...target, minimized: false };
|
||||
return [...ws.filter(w => w.id !== id), restored];
|
||||
});
|
||||
const toggleMinimize = (id: string) => setWindows(ws => ws.map(w => (w.id === id ? { ...w, minimized: !w.minimized } : w)));
|
||||
|
||||
const value = useMemo<AppWindowsContextValue>(() => ({
|
||||
windows,
|
||||
openWithApp,
|
||||
openFileWithDefaultApp,
|
||||
confirmOpenWithApp,
|
||||
closeWindow,
|
||||
toggleMax,
|
||||
bringToFront,
|
||||
updateWindow,
|
||||
minimizeWindow,
|
||||
restoreWindow,
|
||||
toggleMinimize,
|
||||
}), [windows, openWithApp, openFileWithDefaultApp, confirmOpenWithApp]);
|
||||
|
||||
return <AppWindowsContext.Provider value={value}>{children}</AppWindowsContext.Provider>;
|
||||
};
|
||||
|
||||
export function useAppWindows() {
|
||||
const ctx = useContext(AppWindowsContext);
|
||||
if (!ctx) throw new Error('useAppWindows must be used within AppWindowsProvider');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { authApi } from '../api/auth';
|
||||
import { authApi, type MeResponse } from '../api/auth';
|
||||
|
||||
interface AuthContextType {
|
||||
token: string | null;
|
||||
@@ -7,12 +7,15 @@ interface AuthContextType {
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
register: (username: string, password: string, email?: string, full_name?: string) => Promise<void>;
|
||||
user: MeResponse | null;
|
||||
refreshUser: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType>({} as any);
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [token, setToken] = useState<string | null>(() => localStorage.getItem('token'));
|
||||
const [user, setUser] = useState<MeResponse | null>(null);
|
||||
const isAuthenticated = !!token;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -22,20 +25,38 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
const res = await authApi.login({ username, password });
|
||||
if (res)
|
||||
if (res) {
|
||||
setToken(res.access_token);
|
||||
try { await refreshUser(); } catch (_) {}
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
const register = async (username: string, password: string, email?: string, full_name?: string) => {
|
||||
await authApi.register(username, password, email, full_name);
|
||||
};
|
||||
|
||||
const refreshUser = async () => {
|
||||
if (!localStorage.getItem('token')) { setUser(null); return; }
|
||||
const me = await authApi.me();
|
||||
setUser(me);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
refreshUser().catch(() => setUser(null));
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ token, isAuthenticated, login, logout, register }}>
|
||||
<AuthContext.Provider value={{ token, isAuthenticated, login, logout, register, user, refreshUser }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ConfigProvider, theme as antdTheme } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import enUS from 'antd/locale/en_US';
|
||||
import type { ThemeConfig } from 'antd/es/config-provider/context';
|
||||
import { getAllConfig } from '../api/config';
|
||||
import { useAuth } from './AuthContext';
|
||||
import baseTheme from '../theme';
|
||||
import { useI18n } from '../i18n';
|
||||
|
||||
type ThemeMode = 'light' | 'dark' | 'system';
|
||||
|
||||
@@ -101,6 +103,7 @@ function buildThemeConfig(state: ThemeState, systemDark: boolean): ThemeConfig {
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { lang } = useI18n();
|
||||
const systemDark = useSystemDarkPreferred();
|
||||
const [state, setState] = useState<ThemeState>({ mode: 'light' });
|
||||
const styleTagRef = useRef<HTMLStyleElement | null>(null);
|
||||
@@ -163,6 +166,7 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const themeConfig = useMemo(() => buildThemeConfig(state, systemDark), [state, systemDark]);
|
||||
const resolvedMode: ThemeMode = useMemo(() => (state.mode === 'system' ? (systemDark ? 'dark' : 'light') : state.mode), [state.mode, systemDark]);
|
||||
const locale = useMemo(() => (lang === 'zh' ? zhCN : enUS), [lang]);
|
||||
|
||||
const ctxValue = useMemo<ThemeContextType>(() => ({
|
||||
refreshTheme,
|
||||
@@ -173,7 +177,7 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
return (
|
||||
<Ctx.Provider value={ctxValue}>
|
||||
<ConfigProvider theme={{ ...themeConfig, cssVar: true }} locale={zhCN}>
|
||||
<ConfigProvider theme={{ ...themeConfig, cssVar: true }} locale={locale}>
|
||||
{children}
|
||||
</ConfigProvider>
|
||||
</Ctx.Provider>
|
||||
|
||||
@@ -17,9 +17,17 @@ export const en = {
|
||||
'Search files / tags / types': 'Search files / tags / types',
|
||||
'Log Out': 'Log Out',
|
||||
'Admin': 'Admin',
|
||||
'Profile': 'Profile',
|
||||
'Account Settings': 'Account Settings',
|
||||
'Language': 'Language',
|
||||
'Chinese': '中文',
|
||||
'English': 'English',
|
||||
'Full Name': 'Full Name',
|
||||
'Email': 'Email',
|
||||
'Change Password': 'Change Password',
|
||||
'Old Password': 'Old Password',
|
||||
'New Password': 'New Password',
|
||||
'Please fill both old and new password': 'Please fill both old and new password',
|
||||
|
||||
// Auth / Login
|
||||
'Welcome Back': 'Welcome Back',
|
||||
@@ -45,6 +53,9 @@ export const en = {
|
||||
'Cancel failed': 'Cancel failed',
|
||||
'Load failed': 'Load failed',
|
||||
'Are you sure to cancel share?': 'Are you sure to cancel share?',
|
||||
'Clear expired shares': 'Clear expired shares',
|
||||
'Confirm clear expired shares?': 'Confirm clear expired shares?',
|
||||
'Cleared {count} expired shares': 'Cleared {count} expired shares',
|
||||
|
||||
'Share Name': 'Share Name',
|
||||
'Share Content': 'Share Content',
|
||||
@@ -89,6 +100,8 @@ export const en = {
|
||||
'Open': 'Open',
|
||||
'Open With': 'Open With',
|
||||
'Default': 'Default',
|
||||
'Processor': 'Processor',
|
||||
'Share': 'Share',
|
||||
'Rename': 'Rename',
|
||||
'Delete': 'Delete',
|
||||
'Details': 'Details',
|
||||
@@ -301,6 +314,16 @@ export const en = {
|
||||
'Processing finished': 'Processing finished',
|
||||
'Processing failed': 'Processing failed',
|
||||
|
||||
// Path selector
|
||||
'Select File': 'Select File',
|
||||
'Select Path': 'Select Path',
|
||||
'Select Folder': 'Select Folder',
|
||||
'Select': 'Select',
|
||||
'Current': 'Current',
|
||||
'Up': 'Up',
|
||||
'Select Current Folder': 'Select Current Folder',
|
||||
'Please select a file': 'Please select a file',
|
||||
|
||||
// Plugins page
|
||||
'Installed successfully': 'Installed successfully',
|
||||
'Plugin': 'Plugin',
|
||||
@@ -348,8 +371,6 @@ export const en = {
|
||||
'Create admin account': 'Create admin account',
|
||||
'This is the first account with full permissions': 'This is the first account with full permissions',
|
||||
'Username': 'Username',
|
||||
'Full Name': 'Full Name',
|
||||
'Email': 'Email',
|
||||
'Please input a valid email!': 'Please input a valid email!',
|
||||
'Confirm Password': 'Confirm Password',
|
||||
'Please confirm your password!': 'Please confirm your password!',
|
||||
|
||||
@@ -21,9 +21,17 @@ export const zh = {
|
||||
'Search files / tags / types': '搜索文件 / 标签 / 类型',
|
||||
'Log Out': '退出登录',
|
||||
'Admin': '管理员',
|
||||
'Profile': '个人资料',
|
||||
'Account Settings': '账户设置',
|
||||
'Language': '语言',
|
||||
'Chinese': '中文',
|
||||
'English': '英文',
|
||||
'English': 'English',
|
||||
'Full Name': '昵称',
|
||||
'Email': '邮箱',
|
||||
'Change Password': '修改密码',
|
||||
'Old Password': '原密码',
|
||||
'New Password': '新密码',
|
||||
'Please fill both old and new password': '请同时填写原密码和新密码',
|
||||
|
||||
// Auth / Login
|
||||
'Welcome Back': '欢迎回来',
|
||||
@@ -49,6 +57,9 @@ export const zh = {
|
||||
'Cancel failed': '取消失败',
|
||||
'Load failed': '加载失败',
|
||||
'Are you sure to cancel share?': '确认取消分享?',
|
||||
'Clear expired shares': '清空过期分享',
|
||||
'Confirm clear expired shares?': '确认清空过期分享?',
|
||||
'Cleared {count} expired shares': '已清理 {count} 个过期分享',
|
||||
'Share Name': '分享名称',
|
||||
'Share Content': '分享内容',
|
||||
'Created At': '创建时间',
|
||||
@@ -90,6 +101,8 @@ export const zh = {
|
||||
'Open': '打开',
|
||||
'Open With': '打开方式',
|
||||
'Default': '默认',
|
||||
'Processor': '处理器',
|
||||
'Share': '分享',
|
||||
'Rename': '重命名',
|
||||
'Delete': '删除',
|
||||
'Details': '详情',
|
||||
@@ -303,6 +316,16 @@ export const zh = {
|
||||
'Processing finished': '处理完成',
|
||||
'Processing failed': '处理失败',
|
||||
|
||||
// Path selector
|
||||
'Select File': '选择文件',
|
||||
'Select Path': '选择路径',
|
||||
'Select Folder': '选择目录',
|
||||
'Select': '选择',
|
||||
'Current': '当前',
|
||||
'Up': '上一级',
|
||||
'Select Current Folder': '选择当前目录',
|
||||
'Please select a file': '请选择一个文件',
|
||||
|
||||
// Plugins page
|
||||
'Installed successfully': '安装成功',
|
||||
'Plugin': '插件',
|
||||
@@ -350,8 +373,6 @@ export const zh = {
|
||||
'Create admin account': '创建管理员账户',
|
||||
'This is the first account with full permissions': '这是系统的第一个账户,将拥有最高权限。',
|
||||
'Username': '用户名',
|
||||
'Full Name': '昵称',
|
||||
'Email': '邮箱',
|
||||
'Please input a valid email!': '请输入有效的邮箱地址!',
|
||||
'Confirm Password': '确认密码',
|
||||
'Please confirm your password!': '请确认您的密码!',
|
||||
|
||||
@@ -17,6 +17,7 @@ import { getLatestVersion } from '../api/config.ts';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { useI18n } from '../i18n';
|
||||
import { useAppWindows } from '../contexts/AppWindowsContext';
|
||||
const { Sider } = Layout;
|
||||
|
||||
export interface SideNavProps {
|
||||
@@ -54,6 +55,16 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
|
||||
};
|
||||
|
||||
const hasUpdate = latestVersion && latestVersion.version !== status?.version;
|
||||
const { windows, restoreWindow } = useAppWindows();
|
||||
const minimized = windows.filter(w => w.minimized);
|
||||
const DEFAULT_APP_ICON =
|
||||
'data:image/svg+xml;utf8,' +
|
||||
encodeURIComponent(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||||
<rect x="3" y="3" width="18" height="18" rx="4" ry="4" fill="currentColor" />
|
||||
<rect x="7" y="7" width="10" height="10" rx="2" ry="2" fill="#fff"/>
|
||||
</svg>`
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Sider
|
||||
@@ -153,6 +164,35 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
|
||||
borderTop: `1px solid ${token.colorBorderSecondary}`
|
||||
}}
|
||||
>
|
||||
{/* 最小化应用 Dock */}
|
||||
{minimized.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: collapsed ? 'column' : 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
flexWrap: collapsed ? 'nowrap' : 'wrap',
|
||||
maxHeight: collapsed ? 160 : undefined,
|
||||
overflowY: collapsed ? 'auto' : 'visible',
|
||||
}}
|
||||
>
|
||||
{minimized.map(w => {
|
||||
const src = w.app.iconUrl || DEFAULT_APP_ICON;
|
||||
return (
|
||||
<Tooltip key={w.id} title={`${w.app.name} - ${w.entry.name}`} placement={collapsed ? 'right' : 'top'}>
|
||||
<Button
|
||||
shape="circle"
|
||||
onClick={() => restoreWindow(w.id)}
|
||||
icon={<img src={src} alt={w.app.name} style={{ width: 16, height: 16 }} />}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div style={{
|
||||
fontSize: 12,
|
||||
color: token.colorTextSecondary,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Layout, Button, Dropdown, theme, Flex } from 'antd';
|
||||
import { SearchOutlined, UserOutlined, MenuUnfoldOutlined, LogoutOutlined } from '@ant-design/icons';
|
||||
import { Layout, Button, Dropdown, theme, Flex, Avatar, Typography } from 'antd';
|
||||
import { SearchOutlined, MenuUnfoldOutlined, LogoutOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import { memo, useState } from 'react';
|
||||
import SearchDialog from './SearchDialog.tsx';
|
||||
import { authApi } from '../api/auth.ts';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useI18n } from '../i18n';
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import ProfileModal from '../components/ProfileModal';
|
||||
|
||||
const { Header } = Layout;
|
||||
|
||||
@@ -19,12 +21,16 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle }: TopHeaderProp
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const { t } = useI18n();
|
||||
const { user } = useAuth();
|
||||
const [profileOpen, setProfileOpen] = useState(false);
|
||||
|
||||
const handleLogout = () => {
|
||||
authApi.logout();
|
||||
navigate('/login', { replace: true });
|
||||
};
|
||||
|
||||
const openProfile = () => setProfileOpen(true);
|
||||
|
||||
return (
|
||||
<Header style={{ background: token.colorBgContainer, borderBottom: `1px solid ${token.colorBorderSecondary}`, display: 'flex', alignItems: 'center', gap: 16, backdropFilter: 'saturate(180%) blur(8px)' }}>
|
||||
{collapsed && (
|
||||
@@ -48,12 +54,23 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle }: TopHeaderProp
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'profile', label: t('Profile'), icon: <UserOutlined />, onClick: openProfile },
|
||||
{ key: 'logout', label: t('Log Out'), icon: <LogoutOutlined />, onClick: handleLogout }
|
||||
]
|
||||
}}
|
||||
>
|
||||
<Button icon={<UserOutlined />}>{t('Admin')}</Button>
|
||||
<Button type="text" style={{ paddingInline: 8, height: 40 }}>
|
||||
<Flex align="center" gap={8}>
|
||||
<Avatar size={28} src={user?.gravatar_url}>
|
||||
{(user?.full_name || user?.username || 'A').charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
<Typography.Text style={{ maxWidth: 160 }} ellipsis>
|
||||
{user?.full_name || user?.username || t('Admin')}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<ProfileModal open={profileOpen} onClose={() => setProfileOpen(false)} />
|
||||
</Flex>
|
||||
</Header>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { memo, useEffect, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { theme, Pagination } from 'antd';
|
||||
import { AppWindowsLayer } from '../../apps/AppWindowsLayer';
|
||||
import { useFileExplorer } from './hooks/useFileExplorer';
|
||||
import { useFileSelection } from './hooks/useFileSelection';
|
||||
import { useFileActions } from './hooks/useFileActions.tsx';
|
||||
import { useAppWindows } from './hooks/useAppWindows.tsx';
|
||||
import { useAppWindows } from '../../contexts/AppWindowsContext';
|
||||
import { useContextMenu } from './hooks/useContextMenu';
|
||||
import { useProcessor } from './hooks/useProcessor';
|
||||
import { useThumbnails } from './hooks/useThumbnails';
|
||||
@@ -37,7 +36,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
const { path, entries, loading, pagination, processorTypes, sortBy, sortOrder, load, navigateTo, goUp, handlePaginationChange, refresh, handleSortChange } = useFileExplorer(navKey);
|
||||
const { selectedEntries, handleSelect, handleSelectRange, clearSelection, setSelectedEntries } = useFileSelection();
|
||||
const { doCreateDir, doDelete, doRename, doDownload, doShare, doGetDirectLink } = useFileActions({ path, refresh, clearSelection, onShare: (entries) => setSharingEntries(entries), onGetDirectLink: (entry) => setDirectLinkEntry(entry) });
|
||||
const { appWindows, openFileWithDefaultApp, confirmOpenWithApp, closeWindow, toggleMax, bringToFront, updateWindow } = useAppWindows(path);
|
||||
const { openFileWithDefaultApp, confirmOpenWithApp } = useAppWindows();
|
||||
const { ctxMenu, blankCtxMenu, openContextMenu, openBlankContextMenu, closeContextMenus } = useContextMenu();
|
||||
const uploader = useUploader(path, refresh);
|
||||
const { handleFileDrop } = uploader;
|
||||
@@ -65,8 +64,8 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
const next = (path === '/' ? '' : path) + '/' + entry.name;
|
||||
navigateTo(next.replace(/\/+/g, '/'));
|
||||
} else {
|
||||
openFileWithDefaultApp(entry);
|
||||
}
|
||||
openFileWithDefaultApp(entry, path);
|
||||
}
|
||||
};
|
||||
|
||||
const openDetail = async (entry: VfsEntry) => {
|
||||
@@ -172,7 +171,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
onRowClick={(r, e) => handleSelect(r, e.ctrlKey || e.metaKey)}
|
||||
onSelectionChange={setSelectedEntries}
|
||||
onOpen={handleOpenEntry}
|
||||
onOpenWith={(entry, appKey) => confirmOpenWithApp(entry, appKey)}
|
||||
onOpenWith={(entry, appKey) => confirmOpenWithApp(entry, appKey, path)}
|
||||
onRename={setRenaming}
|
||||
onDelete={(entry) => doDelete([entry])}
|
||||
onContextMenu={openContextMenu}
|
||||
@@ -232,7 +231,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
processorTypes={processorTypes}
|
||||
onClose={closeContextMenus}
|
||||
onOpen={handleOpenEntry}
|
||||
onOpenWith={(entry, appKey) => confirmOpenWithApp(entry, appKey)}
|
||||
onOpenWith={(entry, appKey) => confirmOpenWithApp(entry, appKey, path)}
|
||||
onDownload={doDownload}
|
||||
onRename={setRenaming}
|
||||
onDelete={(entriesToDelete) => doDelete(entriesToDelete)}
|
||||
@@ -253,10 +252,9 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
onClose={uploader.closeModal}
|
||||
onStartUpload={uploader.startUpload}
|
||||
/>
|
||||
<AppWindowsLayer windows={appWindows} onClose={closeWindow} onToggleMax={toggleMax} onBringToFront={bringToFront} onUpdateWindow={updateWindow} />
|
||||
<DropzoneOverlay visible={isDragging} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default FileExplorerPage;
|
||||
export default FileExplorerPage;
|
||||
|
||||
@@ -113,7 +113,10 @@ const PluginsPage = memo(function PluginsPage() {
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography.Paragraph style={{ marginBottom: 8 }} ellipsis={{ rows: 2 }}>
|
||||
<Typography.Paragraph
|
||||
style={{ marginBottom: 8, minHeight: 44, lineHeight: '22px' }}
|
||||
ellipsis={{ rows: 2 }}
|
||||
>
|
||||
{p.description || '(暂无描述)'}
|
||||
</Typography.Paragraph>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
@@ -205,7 +208,10 @@ const PluginsPage = memo(function PluginsPage() {
|
||||
</Button>
|
||||
]}
|
||||
>
|
||||
<Typography.Paragraph style={{ marginBottom: 8 }} ellipsis={{ rows: 2 }}>
|
||||
<Typography.Paragraph
|
||||
style={{ marginBottom: 8, minHeight: 44, lineHeight: '22px' }}
|
||||
ellipsis={{ rows: 2 }}
|
||||
>
|
||||
{item.description || '(暂无描述)'}
|
||||
</Typography.Paragraph>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
@@ -304,6 +310,14 @@ const PluginsPage = memo(function PluginsPage() {
|
||||
{ value: 'downloads', label: t('Downloads') },
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
icon={<LinkOutlined />}
|
||||
href="https://center.foxel.cc"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Foxel Center
|
||||
</Button>
|
||||
</div>
|
||||
{repoLoading ? (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12 }}>
|
||||
|
||||
@@ -44,6 +44,16 @@ const SharePage = memo(function SharePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearExpired = async () => {
|
||||
try {
|
||||
const res = await shareApi.clearExpired();
|
||||
message.success(t('Cleared {count} expired shares', { count: String(res.deleted_count) }));
|
||||
fetchList();
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('Clear failed'));
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('Share Name'),
|
||||
@@ -100,7 +110,14 @@ const SharePage = memo(function SharePage() {
|
||||
return (
|
||||
<PageCard
|
||||
title={t('My Shares')}
|
||||
extra={<Button onClick={fetchList} loading={loading}>{t('Refresh')}</Button>}
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={fetchList} loading={loading}>{t('Refresh')}</Button>
|
||||
<Popconfirm title={t('Confirm clear expired shares?')} onConfirm={handleClearExpired}>
|
||||
<Button danger>{t('Clear expired shares')}</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { tasksApi, type AutomationTask, type QueuedTask } from '../api/tasks';
|
||||
import { processorsApi, type ProcessorTypeMeta } from '../api/processors';
|
||||
import { ProcessorConfigForm } from '../components/ProcessorConfigForm';
|
||||
import { useI18n } from '../i18n';
|
||||
import PathSelectorModal from '../components/PathSelectorModal';
|
||||
|
||||
const TasksPage = memo(function TasksPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -17,6 +18,7 @@ const TasksPage = memo(function TasksPage() {
|
||||
const [queuedTasks, setQueuedTasks] = useState<QueuedTask[]>([]);
|
||||
const [queueLoading, setQueueLoading] = useState(false);
|
||||
const { t } = useI18n();
|
||||
const [pathPickerOpen, setPathPickerOpen] = useState(false);
|
||||
|
||||
const fetchList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -151,6 +153,7 @@ const TasksPage = memo(function TasksPage() {
|
||||
|
||||
const selectedProcessor = Form.useWatch('processor_type', form);
|
||||
const currentProcessorMeta = availableProcessors.find(p => p.type === selectedProcessor);
|
||||
const watchedPathPattern = Form.useWatch('path_pattern', form);
|
||||
|
||||
|
||||
return (
|
||||
@@ -197,7 +200,10 @@ const TasksPage = memo(function TasksPage() {
|
||||
</Form.Item>
|
||||
<Typography.Title level={5} style={{ marginTop: 8, fontSize: 14 }}>{t('Matching Rules')}</Typography.Title>
|
||||
<Form.Item name="path_pattern" label={t('Path Prefix (optional)')}>
|
||||
<Input placeholder="/images/screenshots" />
|
||||
<Input
|
||||
placeholder="/images/screenshots"
|
||||
addonAfter={<Button size="small" onClick={() => setPathPickerOpen(true)}>{t('Select')}</Button>}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="filename_regex" label={t('Filename Regex (optional)')}>
|
||||
<Input placeholder=".*\.png$" />
|
||||
@@ -219,6 +225,13 @@ const TasksPage = memo(function TasksPage() {
|
||||
/>
|
||||
</Form>
|
||||
</Drawer>
|
||||
<PathSelectorModal
|
||||
open={pathPickerOpen}
|
||||
mode="directory"
|
||||
initialPath={watchedPathPattern || '/'}
|
||||
onCancel={() => setPathPickerOpen(false)}
|
||||
onOk={(p) => { form.setFieldsValue({ path_pattern: p }); setPathPickerOpen(false); }}
|
||||
/>
|
||||
<Modal
|
||||
title={t('Current Task Queue')}
|
||||
open={queueModalOpen}
|
||||
|
||||
@@ -12,11 +12,14 @@ import SystemSettingsPage from '../pages/SystemSettingsPage/SystemSettingsPage.t
|
||||
import LogsPage from '../pages/LogsPage.tsx';
|
||||
import BackupPage from '../pages/SystemSettingsPage/BackupPage.tsx';
|
||||
import PluginsPage from '../pages/PluginsPage.tsx';
|
||||
import { AppWindowsProvider, useAppWindows } from '../contexts/AppWindowsContext';
|
||||
import { AppWindowsLayer } from '../apps/AppWindowsLayer';
|
||||
|
||||
const LayoutShell = memo(function LayoutShell() {
|
||||
const ShellBody = memo(function ShellBody() {
|
||||
const { navKey = 'files' } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const { windows, closeWindow, toggleMax, bringToFront, updateWindow } = useAppWindows();
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh', background: 'var(--ant-color-bg-layout)' }}>
|
||||
<SideNav
|
||||
@@ -43,8 +46,24 @@ const LayoutShell = memo(function LayoutShell() {
|
||||
</div>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
{/* 常驻渲染应用窗口(过滤最小化在内部处理) */}
|
||||
<AppWindowsLayer
|
||||
windows={windows}
|
||||
onClose={closeWindow}
|
||||
onToggleMax={toggleMax}
|
||||
onBringToFront={bringToFront}
|
||||
onUpdateWindow={updateWindow}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
});
|
||||
|
||||
const LayoutShell = memo(function LayoutShell() {
|
||||
return (
|
||||
<AppWindowsProvider>
|
||||
<ShellBody />
|
||||
</AppWindowsProvider>
|
||||
);
|
||||
});
|
||||
|
||||
export default LayoutShell;
|
||||
|
||||
Reference in New Issue
Block a user