mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-05-08 09:13:23 +08:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17236e601f | ||
|
|
71e5f84eb7 | ||
|
|
4e724b9c4a | ||
|
|
ba62bd0d4a | ||
|
|
138296e5a6 | ||
|
|
51326dea08 | ||
|
|
ac6d8ff7ad | ||
|
|
029aa2574d | ||
|
|
eeb0e6aa70 | ||
|
|
d1ceb7ddba | ||
|
|
63b54458e9 | ||
|
|
f7e6815265 | ||
|
|
4d6e0b86ad | ||
|
|
77a4749fec | ||
|
|
8eaa025f7e | ||
|
|
11799cd97c | ||
|
|
c14224827d |
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
custom: https://foxel.cc/sponsor.html
|
||||
75
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
75
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,75 @@
|
||||
name: Bug Report / 缺陷报告
|
||||
description: Report reproducible defects with clear context / 请提供可复现的缺陷信息
|
||||
title: "[Bug] "
|
||||
labels:
|
||||
- bug
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for helping us improve Foxel! / 感谢你帮助改进 Foxel!
|
||||
Please confirm the checklist below before filing. / 在提交前请确认以下事项。
|
||||
- type: checkboxes
|
||||
id: validations
|
||||
attributes:
|
||||
label: Pre-flight Check / 提交前检查
|
||||
options:
|
||||
- label: I searched existing issues and docs / 我已搜索现有 Issue 与文档
|
||||
required: true
|
||||
- label: This is not a question or feature request / 这不是问题咨询或功能需求
|
||||
required: true
|
||||
- type: textarea
|
||||
id: summary
|
||||
attributes:
|
||||
label: Bug Summary / 缺陷摘要
|
||||
description: Briefly describe what is wrong / 简要说明出现了什么问题
|
||||
placeholder: e.g. Upload fails with 500 error / 例如:上传时报 500 错误
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to Reproduce / 复现步骤
|
||||
description: List numbered steps to trigger the bug / 列出触发问题的步骤
|
||||
placeholder: |
|
||||
1. ...
|
||||
2. ...
|
||||
3. ...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected Behavior / 预期行为
|
||||
description: What should happen instead? / 期望看到什么结果?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual Behavior / 实际行为
|
||||
description: What actually happens? Include messages or screenshots / 实际发生了什么?可附报错或截图
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version / 版本信息
|
||||
description: Git commit, tag, or build number / 提供 Git 提交、标签或构建号
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment / 运行环境
|
||||
description: OS, browser, API server config, etc. / 操作系统、浏览器、服务端配置等
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs & Attachments / 日志与附件
|
||||
description: Paste relevant logs, stack traces, screenshots / 粘贴相关日志、堆栈或截图
|
||||
render: shell
|
||||
validations:
|
||||
required: false
|
||||
56
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
56
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
name: Feature Request / 功能需求
|
||||
description: Suggest enhancements or new capabilities / 提出改进或新增能力
|
||||
title: "[Feature] "
|
||||
labels:
|
||||
- enhancement
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Tell us about your idea! / 欢迎分享你的想法!
|
||||
Please complete the sections below so we can evaluate it quickly. / 请完整填写以下信息,便于快速评估。
|
||||
- type: checkboxes
|
||||
id: prechecks
|
||||
attributes:
|
||||
label: Pre-flight Check / 提交前检查
|
||||
options:
|
||||
- label: I searched existing issues and roadmap / 我已搜索现有 Issue 与路线图
|
||||
required: true
|
||||
- label: This is not a bug report or question / 这不是缺陷或问题咨询
|
||||
required: true
|
||||
- type: textarea
|
||||
id: summary
|
||||
attributes:
|
||||
label: Feature Summary / 功能概述
|
||||
description: What do you want to build? / 希望新增什么能力?
|
||||
placeholder: e.g. Support sharing download links / 例如:支持分享下载链接
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: motivation
|
||||
attributes:
|
||||
label: Motivation / 背景与价值
|
||||
description: Why is this feature important? Who benefits? / 为什么重要?受益者是谁?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: scope
|
||||
attributes:
|
||||
label: Proposed Solution / 建议方案
|
||||
description: Outline how the feature might work, including API or UI hints / 描述可能的实现方式,包含 API 或 UI 提示
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives / 可选方案
|
||||
description: List any alternatives considered / 如有考虑过其他方案请列出
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: Additional Context / 补充信息
|
||||
description: Diagrams, sketches, links, constraints, etc. / 可附上草图、链接或约束
|
||||
validations:
|
||||
required: false
|
||||
42
.github/ISSUE_TEMPLATE/question.yml
vendored
Normal file
42
.github/ISSUE_TEMPLATE/question.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
name: Question / 问题咨询
|
||||
description: Ask about usage, configuration, or clarification / 用于使用、配置或澄清问题
|
||||
title: "[Question] "
|
||||
labels:
|
||||
- question
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Need help? You're in the right place. / 需要帮助?请按以下提示填写。
|
||||
Check the docs before filing. / 提交前请先查阅文档。
|
||||
- type: checkboxes
|
||||
id: prechecks
|
||||
attributes:
|
||||
label: Pre-flight Check / 提交前检查
|
||||
options:
|
||||
- label: I searched existing issues and discussions / 我已搜索现有 Issue 和讨论
|
||||
required: true
|
||||
- label: I read the relevant documentation / 我已阅读相关文档
|
||||
required: true
|
||||
- type: textarea
|
||||
id: question
|
||||
attributes:
|
||||
label: Question Details / 问题详情
|
||||
description: What do you need help with? Be specific. / 具体说明需要帮助的内容
|
||||
placeholder: Describe the scenario, expectation, and blockers / 说明场景、期望结果与阻碍
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: tried
|
||||
attributes:
|
||||
label: What You Tried / 已尝试方案
|
||||
description: List commands, configs, or steps attempted / 列出尝试过的命令、配置或步骤
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional Context / 补充信息
|
||||
description: Environment details, logs, screenshots / 可补充运行环境、日志或截图
|
||||
validations:
|
||||
required: false
|
||||
4
.github/workflows/docker.yml
vendored
4
.github/workflows/docker.yml
vendored
@@ -2,6 +2,8 @@ name: Build and Push Docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
workflow_dispatch:
|
||||
@@ -48,4 +50,4 @@ jobs:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ env.DOCKER_TAGS }}
|
||||
tags: ${{ env.DOCKER_TAGS }}
|
||||
|
||||
@@ -13,7 +13,9 @@ FROM python:3.13-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y nginx git && rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends nginx git ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN pip install uv
|
||||
COPY pyproject.toml uv.lock ./
|
||||
@@ -35,4 +37,4 @@ EXPOSE 80
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
CMD ["/entrypoint.sh"]
|
||||
CMD ["/entrypoint.sh"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import FastAPI
|
||||
|
||||
from .routes import adapters, virtual_fs, auth, config, processors, tasks, logs, share, backup, search, vector_db, offline_downloads, ai_providers
|
||||
from .routes import adapters, virtual_fs, auth, config, processors, tasks, logs, share, backup, search, vector_db, offline_downloads, ai_providers, email
|
||||
from .routes import webdav
|
||||
from .routes import plugins
|
||||
|
||||
@@ -22,3 +22,4 @@ def include_routers(app: FastAPI):
|
||||
app.include_router(plugins.router)
|
||||
app.include_router(webdav.router)
|
||||
app.include_router(offline_downloads.router)
|
||||
app.include_router(email.router)
|
||||
|
||||
@@ -10,6 +10,9 @@ from services.auth import (
|
||||
Token,
|
||||
get_current_active_user,
|
||||
User,
|
||||
request_password_reset,
|
||||
verify_password_reset_token,
|
||||
reset_password_with_token,
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
from datetime import timedelta
|
||||
@@ -66,7 +69,7 @@ async def get_me(current_user: Annotated[User, Depends(get_current_active_user)]
|
||||
"""
|
||||
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"
|
||||
gravatar_url = f"https://cn.cravatar.com/avatar/{md5_hash}?s=64&d=identicon"
|
||||
return success({
|
||||
"id": current_user.id,
|
||||
"username": current_user.username,
|
||||
@@ -83,6 +86,15 @@ class UpdateMeRequest(BaseModel):
|
||||
new_password: str | None = None
|
||||
|
||||
|
||||
class PasswordResetRequest(BaseModel):
|
||||
email: str
|
||||
|
||||
|
||||
class PasswordResetConfirm(BaseModel):
|
||||
token: str
|
||||
password: str
|
||||
|
||||
|
||||
@router.put("/me", summary="更新当前登录用户信息")
|
||||
async def update_me(
|
||||
payload: UpdateMeRequest,
|
||||
@@ -120,3 +132,24 @@ async def update_me(
|
||||
"full_name": db_user.full_name,
|
||||
"gravatar_url": gravatar_url,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/password-reset/request", summary="请求密码重置邮件")
|
||||
async def password_reset_request_endpoint(payload: PasswordResetRequest):
|
||||
await request_password_reset(payload.email)
|
||||
return success(msg="如果邮箱存在,将发送重置邮件")
|
||||
|
||||
|
||||
@router.get("/password-reset/verify", summary="校验密码重置令牌")
|
||||
async def password_reset_verify(token: str):
|
||||
user = await verify_password_reset_token(token)
|
||||
return success({
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/password-reset/confirm", summary="使用令牌重置密码")
|
||||
async def password_reset_confirm(payload: PasswordResetConfirm):
|
||||
await reset_password_with_token(payload.token, payload.password)
|
||||
return success(msg="密码已重置")
|
||||
|
||||
@@ -37,10 +37,13 @@ async def get_all_config(
|
||||
|
||||
@router.get("/status")
|
||||
async def get_system_status():
|
||||
logo = await ConfigCenter.get("APP_LOGO", "/logo.svg")
|
||||
favicon = await ConfigCenter.get("APP_FAVICON", logo)
|
||||
system_info = {
|
||||
"version": VERSION,
|
||||
"title": await ConfigCenter.get("APP_NAME", "Foxel"),
|
||||
"logo": await ConfigCenter.get("APP_LOGO", "/logo.svg"),
|
||||
"logo": logo,
|
||||
"favicon": favicon,
|
||||
"is_initialized": await has_users(),
|
||||
"app_domain": await ConfigCenter.get("APP_DOMAIN"),
|
||||
"file_domain": await ConfigCenter.get("FILE_DOMAIN"),
|
||||
|
||||
92
api/routes/email.py
Normal file
92
api/routes/email.py
Normal file
@@ -0,0 +1,92 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from services.auth import User, get_current_active_user
|
||||
from services.email import EmailService, EmailTemplateRenderer
|
||||
from schemas.email import EmailTestRequest, EmailTemplateUpdate, EmailTemplatePreviewPayload
|
||||
from api.response import success
|
||||
from services.logging import LogService
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/email",
|
||||
tags=["email"],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/test")
|
||||
async def trigger_test_email(
|
||||
payload: EmailTestRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
try:
|
||||
task = await EmailService.enqueue_email(
|
||||
recipients=[str(payload.to)],
|
||||
subject=payload.subject,
|
||||
template=payload.template,
|
||||
context=payload.context,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
await LogService.action(
|
||||
"route:email",
|
||||
"Triggered email test",
|
||||
details={"task_id": task.id, "template": payload.template, "to": str(payload.to)},
|
||||
user_id=getattr(current_user, "id", None),
|
||||
)
|
||||
return success({"task_id": task.id})
|
||||
|
||||
|
||||
@router.get("/templates")
|
||||
async def list_email_templates(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
templates = await EmailTemplateRenderer.list_templates()
|
||||
return success({"templates": templates})
|
||||
|
||||
|
||||
@router.get("/templates/{name}")
|
||||
async def get_email_template(
|
||||
name: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
try:
|
||||
content = await EmailTemplateRenderer.load(name)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="模板不存在")
|
||||
return success({"name": name, "content": content})
|
||||
|
||||
|
||||
@router.post("/templates/{name}")
|
||||
async def update_email_template(
|
||||
name: str,
|
||||
payload: EmailTemplateUpdate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
try:
|
||||
await EmailTemplateRenderer.save(name, payload.content)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
await LogService.action(
|
||||
"route:email",
|
||||
"Updated email template",
|
||||
details={"template": name},
|
||||
user_id=getattr(current_user, "id", None),
|
||||
)
|
||||
return success({"name": name})
|
||||
|
||||
|
||||
@router.post("/templates/{name}/preview")
|
||||
async def preview_email_template(
|
||||
name: str,
|
||||
payload: EmailTemplatePreviewPayload,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
try:
|
||||
html = await EmailTemplateRenderer.render(name, payload.context)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="模板不存在")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return success({"html": html})
|
||||
@@ -17,7 +17,7 @@ from services.virtual_fs import (
|
||||
verify_temp_link_token,
|
||||
maybe_redirect_download,
|
||||
)
|
||||
from services.thumbnail import is_image_filename, get_or_create_thumb, is_raw_filename
|
||||
from services.thumbnail import is_image_filename, get_or_create_thumb, is_raw_filename, is_video_filename
|
||||
from schemas import MkdirRequest, MoveRequest
|
||||
from api.response import success
|
||||
from services.config import ConfigCenter
|
||||
@@ -121,8 +121,8 @@ async def get_thumb(
|
||||
adapter, mount, root, rel = await resolve_adapter_and_rel(full_path)
|
||||
if not rel or rel.endswith('/'):
|
||||
raise HTTPException(400, detail="Not a file")
|
||||
if not is_image_filename(rel):
|
||||
raise HTTPException(404, detail="Not an image")
|
||||
if not (is_image_filename(rel) or is_video_filename(rel)):
|
||||
raise HTTPException(404, detail="Not an image or video")
|
||||
# type: ignore
|
||||
data, mime, key = await get_or_create_thumb(adapter, mount.id, root, rel, w, h, fit)
|
||||
headers = {
|
||||
|
||||
@@ -22,4 +22,5 @@ dependencies = [
|
||||
"uvicorn>=0.37.0",
|
||||
"pymilvus[milvus-lite]>=2.6.2",
|
||||
"paramiko>=4.0.0",
|
||||
"pydantic[email]>=2.11.7",
|
||||
]
|
||||
|
||||
18
schemas/email.py
Normal file
18
schemas/email.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from typing import Any, Dict
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
|
||||
class EmailTestRequest(BaseModel):
|
||||
to: EmailStr
|
||||
subject: str = Field(..., min_length=1)
|
||||
template: str = Field(default="test", min_length=1)
|
||||
context: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class EmailTemplateUpdate(BaseModel):
|
||||
content: str
|
||||
|
||||
|
||||
class EmailTemplatePreviewPayload(BaseModel):
|
||||
context: Dict[str, Any] = Field(default_factory=dict)
|
||||
@@ -8,7 +8,7 @@ class VfsEntry(BaseModel):
|
||||
size: int
|
||||
mtime: int
|
||||
type: Optional[str] = None
|
||||
is_image: Optional[bool] = None
|
||||
has_thumbnail: Optional[bool] = None
|
||||
|
||||
|
||||
class DirListing(BaseModel):
|
||||
|
||||
160
services/auth.py
160
services/auth.py
@@ -1,5 +1,8 @@
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Annotated
|
||||
import secrets
|
||||
|
||||
import jwt
|
||||
from fastapi import Depends, HTTPException, status
|
||||
@@ -10,9 +13,78 @@ from pydantic import BaseModel
|
||||
|
||||
from models.database import UserAccount
|
||||
from services.config import ConfigCenter
|
||||
from services.logging import LogService
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 365
|
||||
PASSWORD_RESET_TOKEN_EXPIRE_MINUTES = 10
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PasswordResetEntry:
|
||||
user_id: int
|
||||
email: str
|
||||
username: str
|
||||
expires_at: datetime
|
||||
used: bool = False
|
||||
|
||||
|
||||
class PasswordResetStore:
|
||||
_tokens: dict[str, PasswordResetEntry] = {}
|
||||
_lock = asyncio.Lock()
|
||||
|
||||
@classmethod
|
||||
def _cleanup(cls):
|
||||
now = _now()
|
||||
for token, record in list(cls._tokens.items()):
|
||||
if record.used or record.expires_at < now:
|
||||
cls._tokens.pop(token, None)
|
||||
|
||||
@classmethod
|
||||
async def create(cls, user: UserAccount) -> str:
|
||||
async with cls._lock:
|
||||
cls._cleanup()
|
||||
for key, record in list(cls._tokens.items()):
|
||||
if record.user_id == user.id:
|
||||
cls._tokens.pop(key, None)
|
||||
token = secrets.token_urlsafe(32)
|
||||
expires_at = _now() + timedelta(minutes=PASSWORD_RESET_TOKEN_EXPIRE_MINUTES)
|
||||
cls._tokens[token] = PasswordResetEntry(
|
||||
user_id=user.id,
|
||||
email=user.email or "",
|
||||
username=user.username,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
return token
|
||||
|
||||
@classmethod
|
||||
async def get(cls, token: str) -> PasswordResetEntry | None:
|
||||
async with cls._lock:
|
||||
cls._cleanup()
|
||||
record = cls._tokens.get(token)
|
||||
if not record or record.used:
|
||||
return None
|
||||
return record
|
||||
|
||||
@classmethod
|
||||
async def mark_used(cls, token: str) -> None:
|
||||
async with cls._lock:
|
||||
record = cls._tokens.get(token)
|
||||
if record:
|
||||
record.used = True
|
||||
cls._cleanup()
|
||||
|
||||
@classmethod
|
||||
async def invalidate_user(cls, user_id: int, except_token: str | None = None) -> None:
|
||||
async with cls._lock:
|
||||
for key, record in list(cls._tokens.items()):
|
||||
if record.user_id == user_id and key != except_token:
|
||||
cls._tokens.pop(key, None)
|
||||
cls._cleanup()
|
||||
|
||||
|
||||
async def get_secret_key():
|
||||
@@ -132,6 +204,94 @@ async def create_access_token(data: dict, expires_delta: timedelta | None = None
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def _normalize_email(email: str | None) -> str:
|
||||
return (email or "").strip().lower()
|
||||
|
||||
|
||||
async def _send_password_reset_email(user: UserAccount, token: str) -> None:
|
||||
from services.email import EmailService
|
||||
|
||||
app_domain = await ConfigCenter.get("APP_DOMAIN", None)
|
||||
base_url = (app_domain or "http://localhost:5173").rstrip("/")
|
||||
reset_link = f"{base_url}/reset-password?token={token}"
|
||||
await EmailService.enqueue_email(
|
||||
recipients=[user.email],
|
||||
subject="Foxel 密码重置",
|
||||
template="password_reset",
|
||||
context={
|
||||
"username": user.username,
|
||||
"reset_link": reset_link,
|
||||
"expire_minutes": PASSWORD_RESET_TOKEN_EXPIRE_MINUTES,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def request_password_reset(email: str) -> bool:
|
||||
normalized = _normalize_email(email)
|
||||
if not normalized:
|
||||
return False
|
||||
user = await UserAccount.get_or_none(email=normalized)
|
||||
if not user or not user.email:
|
||||
return False
|
||||
|
||||
token = await PasswordResetStore.create(user)
|
||||
try:
|
||||
await _send_password_reset_email(user, token)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
await PasswordResetStore.mark_used(token)
|
||||
await PasswordResetStore.invalidate_user(user.id)
|
||||
await LogService.error(
|
||||
"auth",
|
||||
f"Failed to enqueue password reset email: {exc}",
|
||||
details={"user_id": user.id},
|
||||
user_id=user.id,
|
||||
)
|
||||
raise HTTPException(status_code=500, detail="邮件发送失败") from exc
|
||||
await LogService.action(
|
||||
"auth",
|
||||
"Password reset requested",
|
||||
details={"user_id": user.id},
|
||||
user_id=user.id,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def verify_password_reset_token(token: str) -> UserAccount:
|
||||
record = await PasswordResetStore.get(token)
|
||||
if not record:
|
||||
raise HTTPException(status_code=400, detail="重置链接无效")
|
||||
user = await UserAccount.get_or_none(id=record.user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="重置链接无效")
|
||||
if record.expires_at < _now():
|
||||
await PasswordResetStore.mark_used(token)
|
||||
raise HTTPException(status_code=400, detail="重置链接已过期")
|
||||
return user
|
||||
|
||||
|
||||
async def reset_password_with_token(token: str, new_password: str) -> None:
|
||||
record = await PasswordResetStore.get(token)
|
||||
if not record:
|
||||
raise HTTPException(status_code=400, detail="重置链接无效")
|
||||
if record.expires_at < _now():
|
||||
await PasswordResetStore.mark_used(token)
|
||||
raise HTTPException(status_code=400, detail="重置链接已过期")
|
||||
|
||||
user = await UserAccount.get_or_none(id=record.user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="重置链接无效")
|
||||
user.hashed_password = get_password_hash(new_password)
|
||||
await user.save(update_fields=["hashed_password"])
|
||||
await PasswordResetStore.mark_used(token)
|
||||
await PasswordResetStore.invalidate_user(user.id)
|
||||
await LogService.action(
|
||||
"auth",
|
||||
"Password reset via email",
|
||||
details={"user_id": user.id},
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
|
||||
async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
|
||||
@@ -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.3.2"
|
||||
VERSION = "v1.3.5"
|
||||
|
||||
class ConfigCenter:
|
||||
_cache: Dict[str, Any] = {}
|
||||
|
||||
201
services/email.py
Normal file
201
services/email.py
Normal file
@@ -0,0 +1,201 @@
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import smtplib
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formataddr
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from string import Template
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field, ValidationError
|
||||
|
||||
from services.config import ConfigCenter
|
||||
from services.logging import LogService
|
||||
|
||||
|
||||
class EmailSecurity(str, Enum):
|
||||
NONE = "none"
|
||||
SSL = "ssl"
|
||||
STARTTLS = "starttls"
|
||||
|
||||
|
||||
class EmailConfig(BaseModel):
|
||||
host: str
|
||||
port: int = Field(..., gt=0)
|
||||
username: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
sender_email: EmailStr
|
||||
sender_name: Optional[str] = None
|
||||
security: EmailSecurity = EmailSecurity.NONE
|
||||
timeout: float = Field(default=30.0, gt=0.0)
|
||||
|
||||
|
||||
class EmailSendPayload(BaseModel):
|
||||
recipients: List[EmailStr] = Field(..., min_length=1)
|
||||
subject: str = Field(..., min_length=1)
|
||||
template: str = Field(..., min_length=1)
|
||||
context: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class EmailTemplateRenderer:
|
||||
ROOT = Path("templates/email")
|
||||
|
||||
@classmethod
|
||||
def _resolve_path(cls, template_name: str) -> Path:
|
||||
if not re.fullmatch(r"[A-Za-z0-9_\-]+", template_name):
|
||||
raise ValueError("Invalid template name")
|
||||
return cls.ROOT / f"{template_name}.html"
|
||||
|
||||
@classmethod
|
||||
async def list_templates(cls) -> list[str]:
|
||||
cls.ROOT.mkdir(parents=True, exist_ok=True)
|
||||
return sorted(
|
||||
path.stem
|
||||
for path in cls.ROOT.glob("*.html")
|
||||
if path.is_file()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def load(cls, template_name: str) -> str:
|
||||
path = cls._resolve_path(template_name)
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"Email template '{template_name}' not found")
|
||||
return await asyncio.to_thread(path.read_text, encoding="utf-8")
|
||||
|
||||
@classmethod
|
||||
async def save(cls, template_name: str, content: str) -> None:
|
||||
path = cls._resolve_path(template_name)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
await asyncio.to_thread(path.write_text, content, encoding="utf-8")
|
||||
|
||||
@classmethod
|
||||
async def render(cls, template_name: str, context: Dict[str, Any]) -> str:
|
||||
raw = await cls.load(template_name)
|
||||
context = {k: str(v) for k, v in (context or {}).items()}
|
||||
return Template(raw).safe_substitute(context)
|
||||
|
||||
|
||||
class EmailService:
|
||||
CONFIG_KEY = "EMAIL_CONFIG"
|
||||
|
||||
@classmethod
|
||||
async def _load_config(cls) -> EmailConfig:
|
||||
raw_config = await ConfigCenter.get(cls.CONFIG_KEY)
|
||||
if raw_config is None:
|
||||
raise ValueError("Email configuration not found")
|
||||
|
||||
if isinstance(raw_config, str):
|
||||
raw_config = raw_config.strip()
|
||||
data: Any = json.loads(raw_config) if raw_config else {}
|
||||
elif isinstance(raw_config, dict):
|
||||
data = raw_config
|
||||
else:
|
||||
raise ValueError("Invalid email configuration format")
|
||||
|
||||
try:
|
||||
return EmailConfig(**data)
|
||||
except ValidationError as exc:
|
||||
raise ValueError(f"Invalid email configuration: {exc}") from exc
|
||||
|
||||
@staticmethod
|
||||
def _html_to_text(html: str) -> str:
|
||||
stripped = re.sub(r"<[^>]+>", " ", html)
|
||||
return " ".join(stripped.split())
|
||||
|
||||
@classmethod
|
||||
async def _deliver(cls, config: EmailConfig, payload: EmailSendPayload, html_body: str):
|
||||
message = EmailMessage()
|
||||
message["Subject"] = payload.subject
|
||||
message["From"] = formataddr((config.sender_name or str(config.sender_email), str(config.sender_email)))
|
||||
message["To"] = ", ".join([str(addr) for addr in payload.recipients])
|
||||
|
||||
plain_body = cls._html_to_text(html_body)
|
||||
message.set_content(plain_body or html_body)
|
||||
message.add_alternative(html_body, subtype="html")
|
||||
|
||||
await asyncio.to_thread(cls._deliver_sync, config, message)
|
||||
|
||||
@staticmethod
|
||||
def _deliver_sync(config: EmailConfig, message: EmailMessage):
|
||||
if config.security == EmailSecurity.SSL:
|
||||
smtp: smtplib.SMTP = smtplib.SMTP_SSL(config.host, config.port, timeout=config.timeout)
|
||||
else:
|
||||
smtp = smtplib.SMTP(config.host, config.port, timeout=config.timeout)
|
||||
|
||||
try:
|
||||
if config.security == EmailSecurity.STARTTLS:
|
||||
smtp.starttls()
|
||||
if config.username and config.password:
|
||||
smtp.login(config.username, config.password)
|
||||
smtp.send_message(message)
|
||||
finally:
|
||||
try:
|
||||
smtp.quit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
async def enqueue_email(
|
||||
cls,
|
||||
recipients: List[str],
|
||||
subject: str,
|
||||
template: str,
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
from services.task_queue import TaskProgress, task_queue_service
|
||||
|
||||
payload = EmailSendPayload(
|
||||
recipients=recipients,
|
||||
subject=subject,
|
||||
template=template,
|
||||
context=context or {},
|
||||
)
|
||||
|
||||
task = await task_queue_service.add_task(
|
||||
"send_email",
|
||||
payload.model_dump(mode="json"),
|
||||
)
|
||||
|
||||
await task_queue_service.update_progress(
|
||||
task.id,
|
||||
TaskProgress(stage="queued", percent=0.0, detail="Waiting to send"),
|
||||
)
|
||||
await LogService.action(
|
||||
"email_service",
|
||||
"Email task enqueued",
|
||||
details={"task_id": task.id, "subject": subject, "template": template},
|
||||
)
|
||||
return task
|
||||
|
||||
@classmethod
|
||||
async def send_from_task(cls, task_id: str, data: Dict[str, Any]):
|
||||
from services.task_queue import TaskProgress, task_queue_service
|
||||
|
||||
payload = EmailSendPayload(**data)
|
||||
|
||||
await task_queue_service.update_progress(
|
||||
task_id,
|
||||
TaskProgress(stage="preparing", percent=10.0, detail="Rendering template"),
|
||||
)
|
||||
|
||||
config = await cls._load_config()
|
||||
html_body = await EmailTemplateRenderer.render(payload.template, payload.context)
|
||||
|
||||
await task_queue_service.update_progress(
|
||||
task_id,
|
||||
TaskProgress(stage="sending", percent=60.0, detail="Sending message"),
|
||||
)
|
||||
|
||||
await cls._deliver(config, payload, html_body)
|
||||
|
||||
await task_queue_service.update_progress(
|
||||
task_id,
|
||||
TaskProgress(stage="completed", percent=100.0, detail="Email sent"),
|
||||
)
|
||||
await LogService.info(
|
||||
"email_service",
|
||||
"Email sent",
|
||||
details={"task_id": task_id, "subject": payload.subject},
|
||||
)
|
||||
@@ -130,6 +130,10 @@ class TaskQueueService:
|
||||
|
||||
result = await run_cross_mount_transfer_task(task)
|
||||
task.result = result
|
||||
elif task.name == "send_email":
|
||||
from services.email import EmailService
|
||||
await EmailService.send_from_task(task.id, task.task_info)
|
||||
task.result = "Email sent"
|
||||
else:
|
||||
raise ValueError(f"Unknown task name: {task.name}")
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import inspect
|
||||
import io
|
||||
import hashlib
|
||||
import tempfile
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from typing import Tuple
|
||||
from fastapi import HTTPException
|
||||
@@ -8,7 +12,10 @@ from fastapi import HTTPException
|
||||
ALLOWED_EXT = {"jpg", "jpeg", "png", "webp", "gif", "bmp",
|
||||
"tiff", "arw", "cr2", "cr3", "nef", "rw2", "orf", "pef", "dng"}
|
||||
RAW_EXT = {"arw", "cr2", "cr3", "nef", "rw2", "orf", "pef", "dng"}
|
||||
MAX_SOURCE_SIZE = 200 * 1024 * 1024
|
||||
VIDEO_EXT = {"mp4", "mov", "m4v", "avi", "mkv", "wmv", "flv", "webm", "mpg", "mpeg", "3gp"}
|
||||
MAX_IMAGE_SOURCE_SIZE = 200 * 1024 * 1024
|
||||
VIDEO_RANGE_LIMIT = 16 * 1024 * 1024 # 16MB
|
||||
VIDEO_INITIAL_CHUNK = 4 * 1024 * 1024
|
||||
CACHE_ROOT = Path('data/.thumb_cache')
|
||||
|
||||
|
||||
@@ -26,6 +33,13 @@ def is_raw_filename(name: str) -> bool:
|
||||
return parts[1].lower() in RAW_EXT
|
||||
|
||||
|
||||
def is_video_filename(name: str) -> bool:
|
||||
parts = name.rsplit('.', 1)
|
||||
if len(parts) < 2:
|
||||
return False
|
||||
return parts[1].lower() in VIDEO_EXT
|
||||
|
||||
|
||||
def _cache_key(adapter_id: int, rel: str, size: int, mtime: int, w: int, h: int, fit: str) -> str:
|
||||
raw = f"{adapter_id}|{rel}|{size}|{mtime}|{w}x{h}|{fit}".encode()
|
||||
return hashlib.sha1(raw).hexdigest()
|
||||
@@ -40,6 +54,30 @@ def _ensure_cache_dir(p: Path):
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _image_to_webp(im, w: int, h: int, fit: str) -> Tuple[bytes, str]:
|
||||
from PIL import Image
|
||||
if im.mode not in ("RGB", "RGBA"):
|
||||
im = im.convert("RGBA" if im.mode in ("P", "LA") else "RGB")
|
||||
if fit == 'cover':
|
||||
im_ratio = im.width / im.height
|
||||
target_ratio = w / h
|
||||
if im_ratio > target_ratio:
|
||||
new_h = h
|
||||
new_w = int(h * im_ratio)
|
||||
else:
|
||||
new_w = w
|
||||
new_h = int(w / im_ratio)
|
||||
im = im.resize((new_w, new_h))
|
||||
left = max(0, (im.width - w)//2)
|
||||
top = max(0, (im.height - h)//2)
|
||||
im = im.crop((left, top, left + w, top + h))
|
||||
else:
|
||||
im.thumbnail((w, h))
|
||||
buf = io.BytesIO()
|
||||
im.save(buf, 'WEBP', quality=80)
|
||||
return buf.getvalue(), 'image/webp'
|
||||
|
||||
|
||||
def generate_thumb(data: bytes, w: int, h: int, fit: str, is_raw: bool = False) -> Tuple[bytes, str]:
|
||||
from PIL import Image
|
||||
if is_raw:
|
||||
@@ -64,35 +102,172 @@ def generate_thumb(data: bytes, w: int, h: int, fit: str, is_raw: bool = False)
|
||||
else:
|
||||
im = Image.open(io.BytesIO(data))
|
||||
|
||||
if im.mode not in ("RGB", "RGBA"):
|
||||
im = im.convert("RGBA" if im.mode in ("P", "LA") else "RGB")
|
||||
if fit == 'cover':
|
||||
im_ratio = im.width / im.height
|
||||
target_ratio = w / h
|
||||
if im_ratio > target_ratio:
|
||||
new_h = h
|
||||
new_w = int(h * im_ratio)
|
||||
else:
|
||||
new_w = w
|
||||
new_h = int(w / im_ratio)
|
||||
im = im.resize((new_w, new_h))
|
||||
left = max(0, (im.width - w)//2)
|
||||
top = max(0, (im.height - h)//2)
|
||||
im = im.crop((left, top, left + w, top + h))
|
||||
else:
|
||||
im.thumbnail((w, h))
|
||||
buf = io.BytesIO()
|
||||
im.save(buf, 'WEBP', quality=80)
|
||||
return buf.getvalue(), 'image/webp'
|
||||
return _image_to_webp(im, w, h, fit)
|
||||
|
||||
|
||||
async def _collect_response_bytes(response, limit: int) -> bytes:
|
||||
if response is None:
|
||||
return b""
|
||||
|
||||
try:
|
||||
if isinstance(response, (bytes, bytearray)):
|
||||
return bytes(response[:limit])
|
||||
|
||||
body = getattr(response, "body", None)
|
||||
if body is not None:
|
||||
return bytes(body[:limit])
|
||||
|
||||
iterator = getattr(response, "body_iterator", None)
|
||||
if iterator is not None:
|
||||
data = bytearray()
|
||||
async for chunk in iterator:
|
||||
if not chunk:
|
||||
continue
|
||||
need = limit - len(data)
|
||||
if need <= 0:
|
||||
break
|
||||
data.extend(chunk[:need])
|
||||
if len(data) >= limit:
|
||||
break
|
||||
return bytes(data)
|
||||
|
||||
if hasattr(response, "__aiter__"):
|
||||
data = bytearray()
|
||||
async for chunk in response:
|
||||
if not chunk:
|
||||
continue
|
||||
need = limit - len(data)
|
||||
if need <= 0:
|
||||
break
|
||||
data.extend(chunk[:need])
|
||||
if len(data) >= limit:
|
||||
break
|
||||
return bytes(data)
|
||||
finally:
|
||||
close_func = getattr(response, "close", None)
|
||||
if callable(close_func):
|
||||
result = close_func()
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
|
||||
return b""
|
||||
|
||||
|
||||
async def _read_range_slice(adapter, root: str, rel: str, start: int, end: int) -> bytes:
|
||||
read_range = getattr(adapter, "read_file_range", None)
|
||||
if callable(read_range):
|
||||
try:
|
||||
return await read_range(root, rel, start, end)
|
||||
except TypeError:
|
||||
return await read_range(root, rel, start, end=end)
|
||||
|
||||
stream_impl = getattr(adapter, "stream_file", None)
|
||||
if callable(stream_impl):
|
||||
range_header = f"bytes={start}-{end}"
|
||||
response = await stream_impl(root, rel, range_header)
|
||||
expected = end - start + 1
|
||||
return await _collect_response_bytes(response, expected)
|
||||
|
||||
read_file = getattr(adapter, "read_file", None)
|
||||
if callable(read_file) and start == 0:
|
||||
data = await read_file(root, rel)
|
||||
slice_end = end + 1
|
||||
return data[:slice_end]
|
||||
|
||||
return b""
|
||||
|
||||
|
||||
async def _read_video_prefix(adapter, root: str, rel: str, size: int, limit: int = VIDEO_RANGE_LIMIT) -> bytes:
|
||||
chunk_size = min(VIDEO_INITIAL_CHUNK, limit)
|
||||
offset = 0
|
||||
collected = bytearray()
|
||||
|
||||
while len(collected) < limit:
|
||||
end = offset + chunk_size - 1
|
||||
data = await _read_range_slice(adapter, root, rel, offset, end)
|
||||
if not data:
|
||||
break
|
||||
collected.extend(data)
|
||||
if len(data) < chunk_size:
|
||||
break
|
||||
offset += len(data)
|
||||
remaining = limit - len(collected)
|
||||
if remaining <= 0:
|
||||
break
|
||||
chunk_size = min(chunk_size * 2, remaining)
|
||||
|
||||
if not collected and size <= limit:
|
||||
read_file = getattr(adapter, "read_file", None)
|
||||
if callable(read_file):
|
||||
blob = await read_file(root, rel)
|
||||
if blob:
|
||||
return bytes(blob[:limit])
|
||||
|
||||
return bytes(collected[:limit])
|
||||
|
||||
|
||||
async def _run_ffmpeg_extract_frame(src_path: str, dst_path: str):
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-hide_banner",
|
||||
"-loglevel", "error",
|
||||
"-i", src_path,
|
||||
"-frames:v", "1",
|
||||
dst_path,
|
||||
]
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise RuntimeError("未找到 ffmpeg,可执行文件需要在 PATH 中") from e
|
||||
|
||||
stdout, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
message = stderr.decode().strip() or stdout.decode().strip() or "ffmpeg 执行失败"
|
||||
raise RuntimeError(message)
|
||||
|
||||
|
||||
async def _generate_video_thumb(video_bytes: bytes, rel: str, w: int, h: int, fit: str) -> Tuple[bytes, str]:
|
||||
from PIL import Image
|
||||
|
||||
suffix = Path(rel).suffix or ".mp4"
|
||||
src_tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
|
||||
src_path = src_tmp.name
|
||||
try:
|
||||
src_tmp.write(video_bytes)
|
||||
src_tmp.flush()
|
||||
finally:
|
||||
src_tmp.close()
|
||||
|
||||
dst_tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
|
||||
dst_path = dst_tmp.name
|
||||
dst_tmp.close()
|
||||
|
||||
try:
|
||||
await _run_ffmpeg_extract_frame(src_path, dst_path)
|
||||
with Image.open(dst_path) as im:
|
||||
im.load()
|
||||
return _image_to_webp(im, w, h, fit)
|
||||
finally:
|
||||
with suppress(FileNotFoundError):
|
||||
Path(src_path).unlink()
|
||||
with suppress(FileNotFoundError):
|
||||
Path(dst_path).unlink()
|
||||
|
||||
|
||||
async def get_or_create_thumb(adapter, adapter_id: int, root: str, rel: str, w: int, h: int, fit: str = 'cover'):
|
||||
stat = await adapter.stat_file(root, rel)
|
||||
if stat['size'] > MAX_SOURCE_SIZE:
|
||||
size = int(stat.get('size') or 0)
|
||||
is_video = is_video_filename(rel)
|
||||
if not is_video and size > MAX_IMAGE_SOURCE_SIZE:
|
||||
raise HTTPException(400, detail="Image too large for thumbnail")
|
||||
|
||||
key = _cache_key(adapter_id, rel, stat['size'], int(
|
||||
stat['mtime']), w, h, fit)
|
||||
key = _cache_key(adapter_id, rel, size, int(
|
||||
stat.get('mtime', 0)), w, h, fit)
|
||||
path = _cache_path(key)
|
||||
if path.exists():
|
||||
return path.read_bytes(), 'image/webp', key
|
||||
@@ -119,14 +294,33 @@ async def get_or_create_thumb(adapter, adapter_id: int, root: str, rel: str, w:
|
||||
thumb_bytes, mime = None, None
|
||||
|
||||
if not thumb_bytes:
|
||||
read_data = await adapter.read_file(root, rel)
|
||||
try:
|
||||
thumb_bytes, mime = generate_thumb(
|
||||
read_data, w, h, fit, is_raw=is_raw_filename(rel))
|
||||
except Exception as e:
|
||||
print(e)
|
||||
raise HTTPException(
|
||||
500, detail=f"Thumbnail generation failed: {e}")
|
||||
if is_video:
|
||||
try:
|
||||
video_bytes = await _read_video_prefix(adapter, root, rel, size)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"Video prefix read failed: {e}")
|
||||
raise HTTPException(500, detail=f"Video read failed: {e}")
|
||||
|
||||
if not video_bytes:
|
||||
raise HTTPException(500, detail="Unable to read video data for thumbnail")
|
||||
|
||||
try:
|
||||
thumb_bytes, mime = await _generate_video_thumb(video_bytes, rel, w, h, fit)
|
||||
except Exception as e:
|
||||
print(f"Video thumbnail generation failed: {e}")
|
||||
raise HTTPException(
|
||||
500, detail=f"Video thumbnail generation failed: {e}")
|
||||
else:
|
||||
read_data = await adapter.read_file(root, rel)
|
||||
try:
|
||||
thumb_bytes, mime = generate_thumb(
|
||||
read_data, w, h, fit, is_raw=is_raw_filename(rel))
|
||||
except Exception as e:
|
||||
print(e)
|
||||
raise HTTPException(
|
||||
500, detail=f"Thumbnail generation failed: {e}")
|
||||
|
||||
if thumb_bytes:
|
||||
path.write_bytes(thumb_bytes)
|
||||
|
||||
@@ -15,7 +15,7 @@ import aiofiles
|
||||
from models import StorageAdapter
|
||||
from .adapters.registry import runtime_registry
|
||||
from api.response import page
|
||||
from .thumbnail import is_image_filename, is_raw_filename
|
||||
from .thumbnail import is_image_filename, is_raw_filename, is_video_filename
|
||||
from services.processors.registry import get as get_processor
|
||||
from services.tasks import task_service
|
||||
from services.logging import LogService
|
||||
@@ -143,7 +143,7 @@ async def list_virtual_dir(path: str, page_num: int = 1, page_size: int = 50, so
|
||||
norm = (path if path.startswith('/') else '/' + path).rstrip('/') or '/'
|
||||
adapters = await StorageAdapter.filter(enabled=True)
|
||||
|
||||
child_mount_entries = []
|
||||
child_mount_entries: List[str] = []
|
||||
norm_prefix = norm.rstrip('/')
|
||||
for a in adapters:
|
||||
if a.path == norm:
|
||||
@@ -154,6 +154,28 @@ async def list_virtual_dir(path: str, page_num: int = 1, page_size: int = 50, so
|
||||
child_mount_entries.append(tail)
|
||||
child_mount_entries = sorted(set(child_mount_entries))
|
||||
|
||||
sort_field = sort_by.lower()
|
||||
reverse = sort_order.lower() == "desc"
|
||||
|
||||
def build_sort_key(item: Dict) -> Tuple:
|
||||
key = (not bool(item.get("is_dir")),)
|
||||
if sort_field == "name":
|
||||
key += (str(item.get("name", "")).lower(),)
|
||||
elif sort_field == "size":
|
||||
key += (int(item.get("size", 0)),)
|
||||
elif sort_field == "mtime":
|
||||
key += (int(item.get("mtime", 0)),)
|
||||
else:
|
||||
key += (str(item.get("name", "")).lower(),)
|
||||
return key
|
||||
|
||||
def annotate_entry(entry: Dict) -> None:
|
||||
if not entry.get("is_dir"):
|
||||
name = entry.get("name", "")
|
||||
entry["has_thumbnail"] = bool(is_image_filename(name) or is_video_filename(name))
|
||||
else:
|
||||
entry["has_thumbnail"] = False
|
||||
|
||||
try:
|
||||
adapter_model, rel = await resolve_adapter_by_path(norm)
|
||||
adapter_instance = runtime_registry.get(adapter_model.id)
|
||||
@@ -173,57 +195,57 @@ async def list_virtual_dir(path: str, page_num: int = 1, page_size: int = 50, so
|
||||
effective_root = ''
|
||||
rel = ''
|
||||
|
||||
adapter_entries = []
|
||||
adapter_entries_page: List[Dict] = []
|
||||
adapter_entries_for_merge: List[Dict] = []
|
||||
adapter_total = 0
|
||||
covered = set()
|
||||
|
||||
if adapter_model and adapter_instance:
|
||||
list_dir = await _ensure_method(adapter_instance, "list_dir")
|
||||
try:
|
||||
adapter_entries, adapter_total = await list_dir(effective_root, rel, page_num, page_size, sort_by, sort_order)
|
||||
adapter_entries_page, adapter_total = await list_dir(effective_root, rel, page_num, page_size, sort_by, sort_order)
|
||||
except NotADirectoryError:
|
||||
raise HTTPException(400, detail="Not a directory")
|
||||
|
||||
for item in adapter_entries:
|
||||
adapter_entries_for_merge = adapter_entries_page
|
||||
|
||||
# 存在挂载节点且适配器结果被分页时,补齐完整列表以便合并排序
|
||||
if child_mount_entries and adapter_total > len(adapter_entries_page):
|
||||
full_page_size = adapter_total
|
||||
if full_page_size > 0:
|
||||
adapter_entries_for_merge, adapter_total = await list_dir(
|
||||
effective_root, rel, 1, full_page_size, sort_by, sort_order
|
||||
)
|
||||
else:
|
||||
adapter_entries_for_merge = adapter_entries_page
|
||||
|
||||
for item in adapter_entries_for_merge:
|
||||
covered.add(item["name"])
|
||||
|
||||
mount_entries = []
|
||||
for name in child_mount_entries:
|
||||
if name not in covered:
|
||||
mount_entries.append({"name": name, "is_dir": True,
|
||||
"size": 0, "mtime": 0, "type": "mount", "is_image": False})
|
||||
"size": 0, "mtime": 0, "type": "mount", "has_thumbnail": False})
|
||||
|
||||
for ent in adapter_entries:
|
||||
if not ent.get('is_dir'):
|
||||
ent['is_image'] = is_image_filename(ent['name'])
|
||||
else:
|
||||
ent['is_image'] = False
|
||||
|
||||
all_entries = adapter_entries + mount_entries
|
||||
|
||||
if mount_entries:
|
||||
reverse = sort_order.lower() == "desc"
|
||||
def get_sort_key(item):
|
||||
key = (not item.get("is_dir"),)
|
||||
sort_field = sort_by.lower()
|
||||
if sort_field == "name":
|
||||
key += (item["name"].lower(),)
|
||||
elif sort_field == "size":
|
||||
key += (item.get("size", 0),)
|
||||
elif sort_field == "mtime":
|
||||
key += (item.get("mtime", 0),)
|
||||
else:
|
||||
key += (item["name"].lower(),)
|
||||
return key
|
||||
all_entries.sort(key=get_sort_key, reverse=reverse)
|
||||
|
||||
total_entries = adapter_total + len(mount_entries)
|
||||
for ent in adapter_entries_for_merge:
|
||||
annotate_entry(ent)
|
||||
combined_entries = adapter_entries_for_merge + [
|
||||
{**ent, "has_thumbnail": False} for ent in mount_entries
|
||||
]
|
||||
combined_entries.sort(key=build_sort_key, reverse=reverse)
|
||||
|
||||
total_entries = len(combined_entries)
|
||||
start_idx = (page_num - 1) * page_size
|
||||
end_idx = start_idx + page_size
|
||||
page_entries = all_entries[start_idx:end_idx]
|
||||
page_entries = combined_entries[start_idx:end_idx]
|
||||
return page(page_entries, total_entries, page_num, page_size)
|
||||
|
||||
return page(adapter_entries, adapter_total, page_num, page_size)
|
||||
|
||||
annotate_entry_list = adapter_entries_page or []
|
||||
for ent in annotate_entry_list:
|
||||
annotate_entry(ent)
|
||||
return page(adapter_entries_page, adapter_total, page_num, page_size)
|
||||
|
||||
|
||||
async def read_file(path: str) -> Union[bytes, Any]:
|
||||
@@ -285,7 +307,12 @@ async def write_file_stream(path: str, data_iter: AsyncIterator[bytes], overwrit
|
||||
async def make_dir(path: str):
|
||||
adapter_instance, _, root, rel = await resolve_adapter_and_rel(path)
|
||||
if not rel:
|
||||
raise HTTPException(400, detail="Cannot create root")
|
||||
await LogService.info(
|
||||
"virtual_fs",
|
||||
f"Ignored create-root request for {path}",
|
||||
details={"path": path, "reason": "root directory already exists"},
|
||||
)
|
||||
return
|
||||
mkdir_func = await _ensure_method(adapter_instance, "mkdir")
|
||||
await mkdir_func(root, rel)
|
||||
await LogService.action("virtual_fs", f"Created directory {path}", details={"path": path})
|
||||
@@ -575,6 +602,9 @@ async def stat_file(path: str):
|
||||
is_dir = bool(info.get("is_dir"))
|
||||
except Exception:
|
||||
is_dir = False
|
||||
rel_name = rel.rstrip('/').split('/')[-1] if rel else path.rstrip('/').split('/')[-1]
|
||||
name_hint = str(info.get("name") or rel_name or "")
|
||||
info["has_thumbnail"] = bool(not is_dir and (is_image_filename(name_hint) or is_video_filename(name_hint)))
|
||||
if not is_dir:
|
||||
vector_index = await _gather_vector_index(path)
|
||||
if vector_index is not None:
|
||||
|
||||
102
templates/email/password_reset.html
Normal file
102
templates/email/password_reset.html
Normal file
@@ -0,0 +1,102 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Foxel 密码重置</title>
|
||||
<style>
|
||||
body {
|
||||
background: #f4f7fb;
|
||||
font-family: 'Helvetica Neue', Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 32px 0;
|
||||
color: #1f2937;
|
||||
}
|
||||
.wrapper {
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 12px 40px rgba(15, 23, 42, 0.12);
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(99, 102, 241, 0.12);
|
||||
}
|
||||
.header {
|
||||
background: linear-gradient(120deg, #4f46e5, #7c3aed);
|
||||
padding: 32px;
|
||||
color: #ffffff;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.content {
|
||||
padding: 32px;
|
||||
}
|
||||
.content p {
|
||||
margin: 16px 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.cta {
|
||||
display: block;
|
||||
margin: 32px 0;
|
||||
text-align: center;
|
||||
}
|
||||
.cta a {
|
||||
display: inline-block;
|
||||
background: linear-gradient(120deg, #6366f1, #8b5cf6);
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
padding: 14px 32px;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 8px 24px rgba(79, 70, 229, 0.32);
|
||||
}
|
||||
.info-box {
|
||||
background: #f5f3ff;
|
||||
border: 1px solid rgba(107, 114, 128, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 18px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.footer {
|
||||
padding: 24px 32px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
line-height: 1.6;
|
||||
background: #fafafa;
|
||||
border-top: 1px solid rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
.footer a {
|
||||
color: #6366f1;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrapper">
|
||||
<div class="card">
|
||||
<div class="header">
|
||||
<h1>重置你的 Foxel 密码</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>你好,${username}。</p>
|
||||
<p>我们收到了重置你 Foxel 帐号密码的请求。请点击下方按钮完成密码重置操作:</p>
|
||||
<div class="cta">
|
||||
<a href="${reset_link}" target="_blank" rel="noopener">重置密码</a>
|
||||
</div>
|
||||
<p>如果按钮无法点击,你也可以复制下面的链接到浏览器打开:</p>
|
||||
<div class="info-box">
|
||||
<div style="word-break: break-all;">${reset_link}</div>
|
||||
</div>
|
||||
<p>该链接在 ${expire_minutes} 分钟内有效。若你未发起此请求,请忽略本邮件,你的密码不会发生变化。</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div>此邮件由 Foxel 系统自动发送,请勿直接回复。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
97
templates/email/test.html
Normal file
97
templates/email/test.html
Normal file
@@ -0,0 +1,97 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Foxel 邮件配置测试</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 32px 0;
|
||||
background: linear-gradient(135deg, #eef2ff, #e0f2fe);
|
||||
font-family: 'Helvetica Neue', Arial, sans-serif;
|
||||
color: #0f172a;
|
||||
}
|
||||
.wrapper {
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.18);
|
||||
border: 1px solid rgba(99, 102, 241, 0.08);
|
||||
}
|
||||
.banner {
|
||||
background: linear-gradient(120deg, #1d4ed8, #6366f1);
|
||||
padding: 36px;
|
||||
color: #ffffff;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.banner h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
.content {
|
||||
padding: 32px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 6px 14px;
|
||||
border-radius: 999px;
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
color: #1d4ed8;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.cta-box {
|
||||
margin-top: 32px;
|
||||
padding: 20px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.08), rgba(14, 165, 233, 0.08));
|
||||
border: 1px solid rgba(99, 102, 241, 0.12);
|
||||
}
|
||||
.cta-box strong {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.footer {
|
||||
padding: 24px 32px;
|
||||
background: #f8fafc;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.18);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrapper">
|
||||
<div class="card">
|
||||
<div class="banner">
|
||||
<h1>Foxel 邮件服务已连通</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="badge">Mail Delivery Test</div>
|
||||
<p>你好,${username}!</p>
|
||||
<p>
|
||||
这是一封来自 <strong>Foxel</strong> 的测试邮件。如果你能够正常阅读到这段内容,说明系统已经成功与配置的邮箱服务建立连接。
|
||||
</p>
|
||||
<div class="cta-box">
|
||||
<strong>接下来可以做什么?</strong>
|
||||
<ul style="margin: 0; padding-left: 18px; line-height: 1.7;">
|
||||
<li>继续完善系统通知、密码重置等业务功能</li>
|
||||
<li>在后台页面中自定义更精美的邮件模板</li>
|
||||
<li>保持发送凭据安全,避免泄露</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
本邮件由 Foxel 系统自动发送,请勿直接回复。如非本人操作,请忽略此邮件。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
29
uv.lock
generated
29
uv.lock
generated
@@ -373,6 +373,28 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/7c/15ad426257615f9be8caf7f97990cf3dcbb5b8dd7ed7e0db581a1c4759dd/cryptography-46.0.2-cp38-abi3-win_arm64.whl", hash = "sha256:91447f2b17e83c9e0c89f133119d83f94ce6e0fb55dd47da0a959316e6e9cfa1", size = 2918153, upload-time = "2025-10-01T00:28:51.003Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dnspython"
|
||||
version = "2.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email-validator"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "dnspython" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.116.1"
|
||||
@@ -399,6 +421,7 @@ dependencies = [
|
||||
{ name = "paramiko" },
|
||||
{ name = "passlib", extra = ["bcrypt"] },
|
||||
{ name = "pillow" },
|
||||
{ name = "pydantic", extra = ["email"] },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "pymilvus", extra = ["milvus-lite"] },
|
||||
{ name = "pysocks" },
|
||||
@@ -420,6 +443,7 @@ requires-dist = [
|
||||
{ name = "paramiko", specifier = ">=4.0.0" },
|
||||
{ name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" },
|
||||
{ name = "pillow", specifier = ">=11.3.0" },
|
||||
{ name = "pydantic", extras = ["email"], specifier = ">=2.11.7" },
|
||||
{ name = "pyjwt", specifier = ">=2.10.1" },
|
||||
{ name = "pymilvus", extras = ["milvus-lite"], specifier = ">=2.6.2" },
|
||||
{ name = "pysocks", specifier = ">=1.7.1" },
|
||||
@@ -1050,6 +1074,11 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
email = [
|
||||
{ name = "email-validator" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.33.2"
|
||||
|
||||
@@ -18,9 +18,14 @@ function AppInner() {
|
||||
const status = await getStatus();
|
||||
setStatus(status);
|
||||
document.title = status.title;
|
||||
const favicon = document.querySelector("link[rel*='icon']") as HTMLLinkElement;
|
||||
let favicon = document.querySelector("link[rel*='icon']") as HTMLLinkElement | null;
|
||||
if (!favicon) {
|
||||
favicon = document.createElement('link');
|
||||
favicon.rel = 'icon';
|
||||
document.head.appendChild(favicon);
|
||||
}
|
||||
if (favicon) {
|
||||
favicon.href = status.logo;
|
||||
favicon.href = status.favicon || status.logo;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check initialization status:", error);
|
||||
|
||||
@@ -32,6 +32,15 @@ export interface UpdateMePayload {
|
||||
new_password?: string;
|
||||
}
|
||||
|
||||
export interface PasswordResetRequestPayload {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface PasswordResetConfirmPayload {
|
||||
token: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
register: async (username: string, password: string, email?: string, full_name?: string): Promise<any> => {
|
||||
return request('/auth/register', {
|
||||
@@ -68,4 +77,19 @@ export const authApi = {
|
||||
json: payload,
|
||||
});
|
||||
},
|
||||
requestPasswordReset: async (payload: PasswordResetRequestPayload) => {
|
||||
return await request('/auth/password-reset/request', {
|
||||
method: 'POST',
|
||||
json: payload,
|
||||
});
|
||||
},
|
||||
verifyPasswordResetToken: async (token: string) => {
|
||||
return await request<{ username: string; email: string }>('/auth/password-reset/verify?token=' + encodeURIComponent(token));
|
||||
},
|
||||
confirmPasswordReset: async (payload: PasswordResetConfirmPayload) => {
|
||||
return await request('/auth/password-reset/confirm', {
|
||||
method: 'POST',
|
||||
json: payload,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface SystemStatus {
|
||||
version: string;
|
||||
title: string;
|
||||
logo: string;
|
||||
favicon: string;
|
||||
is_initialized: boolean;
|
||||
app_domain?: string;
|
||||
file_domain?: string;
|
||||
|
||||
41
web/src/api/email.ts
Normal file
41
web/src/api/email.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import request from './client';
|
||||
|
||||
export interface EmailTestPayload {
|
||||
to: string;
|
||||
subject: string;
|
||||
template?: string;
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function sendTestEmail(payload: EmailTestPayload) {
|
||||
return request<{ task_id: string }>('/email/test', {
|
||||
method: 'POST',
|
||||
json: {
|
||||
template: 'test',
|
||||
context: {},
|
||||
...payload,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function listEmailTemplates() {
|
||||
return request<{ templates: string[] }>('/email/templates');
|
||||
}
|
||||
|
||||
export async function getEmailTemplate(name: string) {
|
||||
return request<{ name: string; content: string }>(`/email/templates/${encodeURIComponent(name)}`);
|
||||
}
|
||||
|
||||
export async function updateEmailTemplate(name: string, content: string) {
|
||||
return request(`/email/templates/${encodeURIComponent(name)}`, {
|
||||
method: 'POST',
|
||||
json: { content },
|
||||
});
|
||||
}
|
||||
|
||||
export async function previewEmailTemplate(name: string, context: Record<string, unknown>) {
|
||||
return request<{ html: string }>(`/email/templates/${encodeURIComponent(name)}/preview`, {
|
||||
method: 'POST',
|
||||
json: { context },
|
||||
});
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export interface VfsEntry {
|
||||
size: number;
|
||||
mtime: number;
|
||||
type?: string;
|
||||
is_image?: boolean;
|
||||
has_thumbnail?: boolean;
|
||||
}
|
||||
|
||||
export interface DirListing {
|
||||
|
||||
@@ -54,8 +54,8 @@ const DEFAULT_TONE: RgbColor = { r: 28, g: 32, b: 46 };
|
||||
|
||||
const isImageEntry = (ent: VfsEntry) => {
|
||||
if (ent.is_dir) return false;
|
||||
const maybe = ent as VfsEntry & { is_image?: boolean };
|
||||
if (typeof maybe.is_image === 'boolean' && maybe.is_image) return true;
|
||||
const maybe = ent as VfsEntry & { has_thumbnail?: boolean };
|
||||
if (typeof maybe.has_thumbnail === 'boolean' && maybe.has_thumbnail) return true;
|
||||
const ext = ent.name.split('.').pop()?.toLowerCase();
|
||||
if (!ext) return false;
|
||||
return ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'avif', 'ico', 'tif', 'tiff', 'svg', 'heic', 'heif', 'arw', 'cr2', 'cr3', 'nef', 'rw2', 'orf', 'pef', 'dng'].includes(ext);
|
||||
|
||||
@@ -127,6 +127,8 @@ export const en = {
|
||||
|
||||
// Context menu
|
||||
'Upload File': 'Upload File',
|
||||
'Upload Files': 'Upload Files',
|
||||
'Upload Folder': 'Upload Folder',
|
||||
'Open': 'Open',
|
||||
'Open With': 'Open With',
|
||||
'Default': 'Default',
|
||||
@@ -137,6 +139,31 @@ export const en = {
|
||||
'Details': 'Details',
|
||||
'Get Direct Link': 'Get Direct Link',
|
||||
|
||||
// Upload modal
|
||||
'Total progress': 'Total progress',
|
||||
'Upload bytes summary': '{uploaded} / {total}',
|
||||
'Upload task summary': 'Tasks: {completed} / {total} completed, {pending} pending, {failures} failed',
|
||||
'Overwrite confirmation required': 'Overwrite confirmation required',
|
||||
'Target already exists: {path}': 'Target already exists: {path}',
|
||||
'Overwrite': 'Overwrite',
|
||||
'Skip': 'Skip',
|
||||
'Overwrite All': 'Overwrite All',
|
||||
'Skip All': 'Skip All',
|
||||
'Directory': 'Directory',
|
||||
'Creating directory...': 'Creating directory...',
|
||||
'Directory ready': 'Directory ready',
|
||||
'Create directory failed': 'Create directory failed',
|
||||
'Waiting to create': 'Waiting to create',
|
||||
'Waiting for overwrite decision': 'Waiting for overwrite decision',
|
||||
'Waiting to upload': 'Waiting to upload',
|
||||
'Skipped': 'Skipped',
|
||||
'Upload succeeded': 'Upload succeeded',
|
||||
'Upload failed': 'Upload failed',
|
||||
'No items selected for upload': 'No items selected for upload',
|
||||
'No uploadable files or directories found': 'No uploadable files or directories found',
|
||||
'Missing file content': 'Missing file content',
|
||||
'Directory conflicts with existing file': 'A file with the same name already exists at the target location',
|
||||
|
||||
// Side nav modals
|
||||
'Join Community': 'Join Community',
|
||||
'Scan to join WeChat group': 'Scan to join WeChat group',
|
||||
@@ -257,6 +284,7 @@ export const en = {
|
||||
'Custom CSS': 'Custom CSS',
|
||||
'Save': 'Save',
|
||||
'App Settings': 'App Settings',
|
||||
'Email Settings': 'Email Settings',
|
||||
'AI Settings': 'AI Settings',
|
||||
'Vision Model': 'Vision Model',
|
||||
'Embedding Model': 'Embedding Model',
|
||||
@@ -301,8 +329,65 @@ export const en = {
|
||||
'Clear Vector DB': 'Clear Vector DB',
|
||||
'App Name': 'App Name',
|
||||
'Logo URL': 'Logo URL',
|
||||
'Favicon URL': 'Favicon URL',
|
||||
'App Domain': 'App Domain',
|
||||
'File Domain': 'File Domain',
|
||||
'SMTP Settings': 'SMTP Settings',
|
||||
'SMTP Host': 'SMTP Host',
|
||||
'Please input SMTP host': 'Please input SMTP host',
|
||||
'SMTP Port': 'SMTP Port',
|
||||
'Please input SMTP port': 'Please input SMTP port',
|
||||
'Security': 'Security',
|
||||
'None': 'None',
|
||||
'SSL': 'SSL',
|
||||
'STARTTLS': 'STARTTLS',
|
||||
'Timeout (seconds)': 'Timeout (seconds)',
|
||||
'Sender': 'Sender',
|
||||
'Sender Name': 'Sender Name',
|
||||
'Sender Email': 'Sender Email',
|
||||
'Please input sender email': 'Please input sender email',
|
||||
'Authentication': 'Authentication',
|
||||
'SMTP Username': 'SMTP Username',
|
||||
'SMTP Password': 'SMTP Password',
|
||||
'Test Email': 'Test Email',
|
||||
'Current Configuration': 'Current Configuration',
|
||||
'Available variables': 'Available variables',
|
||||
'Not set': 'Not set',
|
||||
'Password Reset Template': 'Password Reset Template',
|
||||
'Live Preview': 'Live Preview',
|
||||
'Foxel Mail Test': 'Foxel Mail Test',
|
||||
'Recipient Address': 'Recipient Address',
|
||||
'Please input recipient email': 'Please input recipient email',
|
||||
'Test Subject': 'Test Subject',
|
||||
'Test User Name': 'Test User Name',
|
||||
'Optional': 'Optional',
|
||||
'Send Test Email': 'Send Test Email',
|
||||
'Please complete all required fields': 'Please complete all required fields',
|
||||
'SMTP port must be a positive number': 'SMTP port must be a positive number',
|
||||
'Test email queued (task {{taskId}})': 'Test email queued (task {{taskId}})',
|
||||
'Test email failed': 'Test email failed',
|
||||
|
||||
// Auth reset
|
||||
'Forgot Password?': 'Forgot password?',
|
||||
'Reset Your Password': 'Reset Your Password',
|
||||
'Enter the email linked to your account and we will send a reset link.': 'Enter the email linked to your account and we will send a reset link.',
|
||||
'If the email exists, a reset link has been sent.': 'If the email exists, a reset link has been sent.',
|
||||
'Send Reset Link': 'Send Reset Link',
|
||||
'Resend Link': 'Resend Link',
|
||||
'Back to login': 'Back to login',
|
||||
'Request failed': 'Request failed',
|
||||
'Reset link is invalid': 'Reset link is invalid',
|
||||
'Reset link is invalid or expired': 'Reset link is invalid or expired',
|
||||
'Reset failed': 'Reset failed',
|
||||
'Try again': 'Try again',
|
||||
'Set a new password': 'Set a new password',
|
||||
'Please enter new password': 'Please enter new password',
|
||||
'Confirm Password': 'Confirm Password',
|
||||
'Please confirm new password': 'Please confirm new password',
|
||||
'Update Password': 'Update Password',
|
||||
'Passwords do not match': 'Passwords do not match',
|
||||
'Password updated, please login again.': 'Password updated, please login again.',
|
||||
'Failed to reset password': 'Failed to reset password',
|
||||
'Vision API URL': 'Vision API URL',
|
||||
'Vision API Key': 'Vision API Key',
|
||||
'Embedding API URL': 'Embedding API URL',
|
||||
@@ -571,7 +656,6 @@ export const en = {
|
||||
'This is the first account with full permissions': 'This is the first account with full permissions',
|
||||
'Username': 'Username',
|
||||
'Please input a valid email!': 'Please input a valid email!',
|
||||
'Confirm Password': 'Confirm Password',
|
||||
'Please confirm your password!': 'Please confirm your password!',
|
||||
'Passwords do not match!': 'Passwords do not match!',
|
||||
'System Initialization': 'System Initialization',
|
||||
|
||||
@@ -63,12 +63,32 @@ export const zh = {
|
||||
'Sign In': '登录',
|
||||
'Please enter username and password': '请输入用户名与密码',
|
||||
'Login failed': '登录失败',
|
||||
'Forgot Password?': '忘记密码?',
|
||||
'Your next-generation file manager': '您的下一代文件管理系统',
|
||||
'Cross-platform sync, access anywhere': '跨平台同步,随时随地访问',
|
||||
'AI-powered search for quick find': 'AI 驱动的智能搜索,快速定位文件',
|
||||
'Flexible sharing and collaboration': '灵活的分享与协作,提升团队效率',
|
||||
'Powerful automation to simplify tasks': '强大的自动化工作流,简化繁琐任务',
|
||||
'Join our community:': '加入我们的社区:',
|
||||
'Reset Your Password': '重置你的密码',
|
||||
'Enter the email linked to your account and we will send a reset link.': '请输入你账户绑定的邮箱,我们会发送重置链接。',
|
||||
'If the email exists, a reset link has been sent.': '如果邮箱存在,我们已发送重置链接。',
|
||||
'Send Reset Link': '发送重置链接',
|
||||
'Resend Link': '重新发送链接',
|
||||
'Back to login': '返回登录',
|
||||
'Request failed': '请求失败',
|
||||
'Reset link is invalid': '重置链接无效',
|
||||
'Reset link is invalid or expired': '重置链接无效或已过期',
|
||||
'Reset failed': '重置失败',
|
||||
'Try again': '重试',
|
||||
'Set a new password': '设置新密码',
|
||||
'Please enter new password': '请输入新密码',
|
||||
'Confirm Password': '确认新密码',
|
||||
'Please confirm new password': '请确认新密码',
|
||||
'Update Password': '更新密码',
|
||||
'Passwords do not match': '两次输入的密码不一致',
|
||||
'Password updated, please login again.': '密码已更新,请重新登录。',
|
||||
'Failed to reset password': '密码重置失败',
|
||||
|
||||
// Share page
|
||||
'Refresh': '刷新',
|
||||
@@ -129,6 +149,8 @@ export const zh = {
|
||||
|
||||
// Context menu
|
||||
'Upload File': '上传文件',
|
||||
'Upload Files': '上传文件',
|
||||
'Upload Folder': '上传文件夹',
|
||||
'Open': '打开',
|
||||
'Open With': '打开方式',
|
||||
'Default': '默认',
|
||||
@@ -139,6 +161,31 @@ export const zh = {
|
||||
'Details': '详情',
|
||||
'Get Direct Link': '获取直链',
|
||||
|
||||
// Upload modal
|
||||
'Total progress': '总体进度',
|
||||
'Upload bytes summary': '{uploaded} / {total}',
|
||||
'Upload task summary': '任务:已完成 {completed} / {total},待处理 {pending},失败 {failures}',
|
||||
'Overwrite confirmation required': '需要确认是否覆盖',
|
||||
'Target already exists: {path}': '目标已存在:{path}',
|
||||
'Overwrite': '覆盖',
|
||||
'Skip': '跳过',
|
||||
'Overwrite All': '全部覆盖',
|
||||
'Skip All': '全部跳过',
|
||||
'Directory': '目录',
|
||||
'Creating directory...': '正在创建目录...',
|
||||
'Directory ready': '目录已就绪',
|
||||
'Create directory failed': '创建目录失败',
|
||||
'Waiting to create': '等待创建',
|
||||
'Waiting for overwrite decision': '等待覆盖处理',
|
||||
'Waiting to upload': '等待上传',
|
||||
'Skipped': '已跳过',
|
||||
'Upload succeeded': '上传成功',
|
||||
'Upload failed': '上传失败',
|
||||
'No items selected for upload': '未选择任何可上传项',
|
||||
'No uploadable files or directories found': '未找到可上传的文件或目录',
|
||||
'Missing file content': '缺少文件内容',
|
||||
'Directory conflicts with existing file': '目标存在同名文件,无法创建目录',
|
||||
|
||||
// Side nav modals
|
||||
'Join Community': '加入社区',
|
||||
'Scan to join WeChat group': '微信扫码加入交流群',
|
||||
@@ -258,6 +305,7 @@ export const zh = {
|
||||
'Custom CSS': '自定义 CSS',
|
||||
'Save': '保存',
|
||||
'App Settings': '应用设置',
|
||||
'Email Settings': '邮箱设置',
|
||||
'AI Settings': 'AI设置',
|
||||
'Choose Template': '选择模板',
|
||||
'Configure Provider': '配置提供商',
|
||||
@@ -306,8 +354,47 @@ export const zh = {
|
||||
'Clear Vector DB': '清空向量库',
|
||||
'App Name': '应用名称',
|
||||
'Logo URL': 'LOGO地址',
|
||||
'Favicon URL': 'Favicon 地址',
|
||||
'App Domain': '应用域名',
|
||||
'File Domain': '文件域名',
|
||||
'SMTP Settings': 'SMTP 配置',
|
||||
'SMTP Host': 'SMTP 服务器',
|
||||
'Please input SMTP host': '请输入 SMTP 服务器',
|
||||
'SMTP Port': 'SMTP 端口',
|
||||
'Please input SMTP port': '请输入 SMTP 端口',
|
||||
'Security': '安全协议',
|
||||
'None': '无',
|
||||
'SSL': 'SSL',
|
||||
'STARTTLS': 'STARTTLS',
|
||||
'Timeout (seconds)': '超时时间(秒)',
|
||||
'Sender': '发件人',
|
||||
'Sender Name': '发件人名称',
|
||||
'Sender Email': '发件人邮箱',
|
||||
'Please input sender email': '请输入发件人邮箱',
|
||||
'Authentication': '身份认证',
|
||||
'SMTP Username': 'SMTP 用户名',
|
||||
'SMTP Password': 'SMTP 密码',
|
||||
'Test Email': '测试发信',
|
||||
'Current Configuration': '当前配置摘要',
|
||||
'Available variables': '可用变量',
|
||||
'Not set': '未设置',
|
||||
'Password Reset Template': '密码重置模板',
|
||||
'Live Preview': '实时预览',
|
||||
'Template saved': '模板已保存',
|
||||
'Failed to save template': '模板保存失败',
|
||||
'Failed to load template': '模板加载失败',
|
||||
'Preview failed': '预览失败',
|
||||
'Foxel Mail Test': 'Foxel 邮件测试',
|
||||
'Recipient Address': '收件人地址',
|
||||
'Please input recipient email': '请输入收件人邮箱',
|
||||
'Test Subject': '测试邮件标题',
|
||||
'Test User Name': '测试用户名',
|
||||
'Optional': '可选',
|
||||
'Send Test Email': '发送测试邮件',
|
||||
'Please complete all required fields': '请填写所有必填项',
|
||||
'SMTP port must be a positive number': 'SMTP 端口必须为正数',
|
||||
'Test email queued (task {{taskId}})': '测试邮件已入队(任务 {{taskId}})',
|
||||
'Test email failed': '测试邮件发送失败',
|
||||
'Vision API URL': '视觉模型 API 地址',
|
||||
'Vision API Key': '视觉模型 API Key',
|
||||
'Embedding API URL': '嵌入模型 API 地址',
|
||||
@@ -584,7 +671,6 @@ export const zh = {
|
||||
'This is the first account with full permissions': '这是系统的第一个账户,将拥有最高权限。',
|
||||
'Username': '用户名',
|
||||
'Please input a valid email!': '请输入有效的邮箱地址!',
|
||||
'Confirm Password': '确认密码',
|
||||
'Please confirm your password!': '请确认您的密码!',
|
||||
'Passwords do not match!': '两次输入的密码不一致!',
|
||||
'System Initialization': '系统初始化',
|
||||
|
||||
@@ -221,7 +221,7 @@ const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
|
||||
size: Number((stat as any)?.size ?? 0),
|
||||
mtime: Number((stat as any)?.mtime ?? (stat as any)?.mtime_ms ?? 0),
|
||||
type: (stat as any)?.type,
|
||||
is_image: Boolean((stat as any)?.is_image),
|
||||
has_thumbnail: Boolean((stat as any)?.has_thumbnail),
|
||||
};
|
||||
statCacheRef.current.set(fullPath, entry);
|
||||
return entry;
|
||||
@@ -522,7 +522,8 @@ const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
|
||||
processorHook.setSelectedProcessor(type);
|
||||
processorHook.openProcessorModal(entry);
|
||||
}}
|
||||
onUpload={noop}
|
||||
onUploadFile={noop}
|
||||
onUploadDirectory={noop}
|
||||
onCreateDir={noop}
|
||||
onShare={doShare}
|
||||
onGetDirectLink={doGetDirectLink}
|
||||
|
||||
@@ -25,13 +25,16 @@ import { FileDetailModal } from './components/FileDetailModal';
|
||||
import { MoveCopyModal } from './components/Modals/MoveCopyModal';
|
||||
import type { ViewMode } from './types';
|
||||
import { vfsApi, type VfsEntry } from '../../api/client';
|
||||
import { LoadingSkeleton } from './components/LoadingSkeleton';
|
||||
|
||||
const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
const { navKey = 'files', '*': restPath = '' } = useParams();
|
||||
const { token } = theme.useToken();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('grid');
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [showSkeleton, setShowSkeleton] = useState(false);
|
||||
const dragCounter = useRef(0);
|
||||
const skeletonTimerRef = useRef<number | null>(null);
|
||||
|
||||
// --- Hooks ---
|
||||
const { path, entries, loading, pagination, processorTypes, sortBy, sortOrder, load, navigateTo, goUp, handlePaginationChange, refresh, handleSortChange } = useFileExplorer(navKey);
|
||||
@@ -40,7 +43,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
const { openFileWithDefaultApp, confirmOpenWithApp } = useAppWindows();
|
||||
const { ctxMenu, blankCtxMenu, openContextMenu, openBlankContextMenu, closeContextMenus } = useContextMenu();
|
||||
const uploader = useUploader(path, refresh);
|
||||
const { handleFileDrop } = uploader;
|
||||
const { handleFileDrop, openFilePicker, openDirectoryPicker, handleFileInputChange, handleDirectoryInputChange } = uploader;
|
||||
const processorHook = useProcessor({ path, processorTypes, refresh });
|
||||
const { thumbs } = useThumbnails(entries, path);
|
||||
|
||||
@@ -50,16 +53,40 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
const [sharingEntries, setSharingEntries] = useState<VfsEntry[]>([]);
|
||||
const [detailEntry, setDetailEntry] = useState<VfsEntry | null>(null);
|
||||
const [directLinkEntry, setDirectLinkEntry] = useState<VfsEntry | null>(null);
|
||||
const [detailData, setDetailData] = useState<any>(null);
|
||||
const [detailData, setDetailData] = useState<Record<string, unknown> | { error: string } | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [movingEntries, setMovingEntries] = useState<VfsEntry[]>([]);
|
||||
const [copyingEntries, setCopyingEntries] = useState<VfsEntry[]>([]);
|
||||
|
||||
// --- Effects ---
|
||||
const routePath = '/' + (restPath || '').replace(/^\/+/, '');
|
||||
|
||||
useEffect(() => {
|
||||
const routeP = '/' + (restPath || '').replace(/^\/+/, '');
|
||||
load(routeP, 1, pagination.pageSize, sortBy, sortOrder);
|
||||
}, [restPath, navKey, load, pagination.pageSize, sortBy, sortOrder]);
|
||||
load(routePath, 1, pagination.pageSize, sortBy, sortOrder);
|
||||
}, [routePath, navKey, load, pagination.pageSize, sortBy, sortOrder]);
|
||||
|
||||
useEffect(() => {
|
||||
if (skeletonTimerRef.current !== null) {
|
||||
clearTimeout(skeletonTimerRef.current);
|
||||
skeletonTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
skeletonTimerRef.current = window.setTimeout(() => {
|
||||
setShowSkeleton(true);
|
||||
skeletonTimerRef.current = null;
|
||||
}, 200);
|
||||
} else {
|
||||
setShowSkeleton(false);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (skeletonTimerRef.current !== null) {
|
||||
clearTimeout(skeletonTimerRef.current);
|
||||
skeletonTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [loading]);
|
||||
|
||||
// --- Handlers ---
|
||||
const handleOpenEntry = (entry: VfsEntry) => {
|
||||
@@ -77,9 +104,10 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
try {
|
||||
const fullPath = (path === '/' ? '' : path) + '/' + entry.name;
|
||||
const stat = await vfsApi.stat(fullPath);
|
||||
setDetailData(stat);
|
||||
} catch (e: any) {
|
||||
setDetailData({ error: e.message });
|
||||
setDetailData(stat as Record<string, unknown>);
|
||||
} catch (error) {
|
||||
const messageText = error instanceof Error ? error.message : String(error);
|
||||
setDetailData({ error: messageText });
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
@@ -128,7 +156,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
dragCounter.current = 0;
|
||||
handleFileDrop(e.dataTransfer.files);
|
||||
void handleFileDrop(e.dataTransfer);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -159,22 +187,37 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
onNavigate={navigateTo}
|
||||
onRefresh={refresh}
|
||||
onCreateDir={() => setCreatingDir(true)}
|
||||
onUpload={uploader.openModal}
|
||||
onUploadFile={openFilePicker}
|
||||
onUploadDirectory={openDirectoryPicker}
|
||||
onSetViewMode={setViewMode}
|
||||
onSortChange={handleSortChange}
|
||||
/>
|
||||
|
||||
<input ref={uploader.fileInputRef} type="file" style={{ display: 'none' }} multiple onChange={uploader.handleFileChange} />
|
||||
<input
|
||||
ref={uploader.fileInputRef}
|
||||
type="file"
|
||||
style={{ display: 'none' }}
|
||||
multiple
|
||||
onChange={handleFileInputChange}
|
||||
/>
|
||||
<input
|
||||
ref={uploader.directoryInputRef}
|
||||
type="file"
|
||||
style={{ display: 'none' }}
|
||||
multiple
|
||||
onChange={handleDirectoryInputChange}
|
||||
/>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto', paddingBottom: pagination.total > 0 ? '80px' : '0' }} onContextMenu={openBlankContextMenu}>
|
||||
{loading && entries.length === 0 ? (
|
||||
{showSkeleton && loading && (entries.length === 0 || path !== routePath) ? (
|
||||
<LoadingSkeleton mode={viewMode} />
|
||||
) : !loading && entries.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: 40 }}><EmptyState isRoot={path === '/'} /></div>
|
||||
) : viewMode === 'grid' ? (
|
||||
<GridView
|
||||
entries={entries}
|
||||
thumbs={thumbs}
|
||||
selectedEntries={selectedEntries}
|
||||
loading={loading}
|
||||
path={path}
|
||||
onSelect={handleSelect}
|
||||
onSelectRange={handleSelectRange}
|
||||
@@ -184,7 +227,6 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
) : (
|
||||
<FileListView
|
||||
entries={entries}
|
||||
loading={loading}
|
||||
selectedEntries={selectedEntries}
|
||||
onRowClick={(r, e) => handleSelect(r, e.ctrlKey || e.metaKey)}
|
||||
onSelectionChange={setSelectedEntries}
|
||||
@@ -282,7 +324,8 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
processorHook.setSelectedProcessor(type);
|
||||
processorHook.openProcessorModal(entry);
|
||||
}}
|
||||
onUpload={uploader.openModal}
|
||||
onUploadFile={openFilePicker}
|
||||
onUploadDirectory={openDirectoryPicker}
|
||||
onCreateDir={() => setCreatingDir(true)}
|
||||
onShare={doShare}
|
||||
onGetDirectLink={doGetDirectLink}
|
||||
@@ -293,8 +336,14 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
<UploadModal
|
||||
visible={uploader.isModalVisible}
|
||||
files={uploader.files}
|
||||
isUploading={uploader.isUploading}
|
||||
totalProgress={uploader.totalProgress}
|
||||
totalFileBytes={uploader.totalFileBytes}
|
||||
uploadedFileBytes={uploader.uploadedFileBytes}
|
||||
conflict={uploader.conflict}
|
||||
onClose={uploader.closeModal}
|
||||
onStartUpload={uploader.startUpload}
|
||||
onResolveConflict={uploader.confirmConflict}
|
||||
/>
|
||||
<DropzoneOverlay visible={isDragging} />
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { useLayoutEffect, useRef, useState } from 'react';
|
||||
import { Menu, theme } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import type { VfsEntry } from '../../../api/client';
|
||||
import type { ProcessorTypeMeta } from '../../../api/processors';
|
||||
import { getAppsForEntry, getDefaultAppForEntry } from '../../../apps/registry';
|
||||
import { useI18n } from '../../../i18n';
|
||||
import {
|
||||
@@ -15,7 +17,7 @@ interface ContextMenuProps {
|
||||
entry?: VfsEntry;
|
||||
entries: VfsEntry[];
|
||||
selectedEntries: string[];
|
||||
processorTypes: any[];
|
||||
processorTypes: ProcessorTypeMeta[];
|
||||
onClose: () => void;
|
||||
onOpen: (entry: VfsEntry) => void;
|
||||
onOpenWith: (entry: VfsEntry, appKey: string) => void;
|
||||
@@ -24,7 +26,8 @@ interface ContextMenuProps {
|
||||
onDelete: (entries: VfsEntry[]) => void;
|
||||
onDetail: (entry: VfsEntry) => void;
|
||||
onProcess: (entry: VfsEntry, processorType: string) => void;
|
||||
onUpload: () => void;
|
||||
onUploadFile: () => void;
|
||||
onUploadDirectory: () => void;
|
||||
onCreateDir: () => void;
|
||||
onShare: (entries: VfsEntry[]) => void;
|
||||
onGetDirectLink: (entry: VfsEntry) => void;
|
||||
@@ -32,6 +35,18 @@ interface ContextMenuProps {
|
||||
onCopy: (entries: VfsEntry[]) => void;
|
||||
}
|
||||
|
||||
type MenuItem = Required<MenuProps>['items'][number];
|
||||
|
||||
interface ActionMenuItem {
|
||||
key: string;
|
||||
label: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
danger?: boolean;
|
||||
onClick?: () => void;
|
||||
children?: ActionMenuItem[];
|
||||
}
|
||||
|
||||
export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
||||
const { token } = theme.useToken();
|
||||
const { t } = useI18n();
|
||||
@@ -43,10 +58,18 @@ export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
||||
setPosition({ left: x, top: y });
|
||||
}, [x, y]);
|
||||
|
||||
const getContextMenuItems = () => {
|
||||
const getContextMenuItems = (): ActionMenuItem[] => {
|
||||
if (!entry) { // Blank context menu
|
||||
return [
|
||||
{ key: 'upload', label: t('Upload File'), icon: <UploadOutlined />, onClick: actions.onUpload },
|
||||
{
|
||||
key: 'upload',
|
||||
label: t('Upload'),
|
||||
icon: <UploadOutlined />,
|
||||
children: [
|
||||
{ key: 'upload-file', label: t('Upload Files'), onClick: actions.onUploadFile },
|
||||
{ key: 'upload-folder', label: t('Upload Folder'), onClick: actions.onUploadDirectory },
|
||||
],
|
||||
},
|
||||
{ key: 'mkdir', label: t('New Folder'), icon: <PlusOutlined />, onClick: actions.onCreateDir },
|
||||
];
|
||||
}
|
||||
@@ -57,7 +80,7 @@ export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
||||
const targetNames = selectedEntries.includes(entry.name) ? selectedEntries : [entry.name];
|
||||
const targetEntries = entries.filter(e => targetNames.includes(e.name));
|
||||
|
||||
let processorSubMenu: any[] = [];
|
||||
let processorSubMenu: ActionMenuItem[] = [];
|
||||
if (!entry.is_dir && processorTypes.length > 0) {
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
processorSubMenu = processorTypes
|
||||
@@ -73,7 +96,7 @@ export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
||||
}));
|
||||
}
|
||||
|
||||
return [
|
||||
const menuItems: (ActionMenuItem | null)[] = [
|
||||
(entry.is_dir || apps.length > 0) ? {
|
||||
key: 'open',
|
||||
label: defaultApp ? `${t('Open')} (${defaultApp.name})` : t('Open'),
|
||||
@@ -151,18 +174,32 @@ export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
||||
icon: <InfoCircleOutlined />,
|
||||
onClick: () => actions.onDetail(entry),
|
||||
},
|
||||
].filter(Boolean);
|
||||
];
|
||||
|
||||
return menuItems.filter((item): item is ActionMenuItem => item !== null);
|
||||
};
|
||||
|
||||
const items = getContextMenuItems()
|
||||
.filter(item => item !== null) // Ensure no null items
|
||||
.map(item => ({
|
||||
...item,
|
||||
onClick: () => {
|
||||
if (item.onClick) item.onClick();
|
||||
onClose();
|
||||
}
|
||||
}));
|
||||
const actionItems = getContextMenuItems();
|
||||
|
||||
const handlerMap = new Map<string, () => void>();
|
||||
|
||||
const mapItems = (source: ActionMenuItem[]): MenuItem[] =>
|
||||
source.map<MenuItem>((item) => {
|
||||
if (item.onClick) handlerMap.set(item.key, item.onClick);
|
||||
const mappedChildren = item.children && item.children.length > 0 ? mapItems(item.children) : undefined;
|
||||
|
||||
const transformed = {
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
icon: item.icon,
|
||||
disabled: item.disabled,
|
||||
danger: item.danger,
|
||||
...(mappedChildren ? { children: mappedChildren } : {}),
|
||||
} as MenuItem;
|
||||
return transformed;
|
||||
});
|
||||
|
||||
const items = mapItems(actionItems);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
@@ -203,8 +240,13 @@ export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
||||
onClick={onClose} // Close on any click inside the menu area
|
||||
>
|
||||
<Menu
|
||||
items={items as any[]}
|
||||
items={items}
|
||||
selectable={false}
|
||||
onClick={({ key }) => {
|
||||
const handler = handlerMap.get(String(key));
|
||||
if (handler) handler();
|
||||
onClose();
|
||||
}}
|
||||
style={{ width: 160, borderRadius: token.borderRadius, background: 'transparent' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,6 @@ import { useI18n } from '../../../i18n';
|
||||
|
||||
interface FileListViewProps {
|
||||
entries: VfsEntry[];
|
||||
loading: boolean;
|
||||
selectedEntries: string[];
|
||||
onRowClick: (entry: VfsEntry, e: React.MouseEvent) => void;
|
||||
onSelectionChange: (selectedKeys: string[]) => void;
|
||||
@@ -22,7 +21,6 @@ interface FileListViewProps {
|
||||
|
||||
export const FileListView: React.FC<FileListViewProps> = ({
|
||||
entries,
|
||||
loading,
|
||||
selectedEntries,
|
||||
onRowClick,
|
||||
onSelectionChange,
|
||||
@@ -107,7 +105,6 @@ export const FileListView: React.FC<FileListViewProps> = ({
|
||||
rowKey={r => r.name}
|
||||
dataSource={entries}
|
||||
columns={columns as any}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
onRow={(r) => ({
|
||||
onClick: (e: any) => onRowClick(r, e),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { Tooltip, Spin, theme } from 'antd';
|
||||
import { Tooltip, theme } from 'antd';
|
||||
import { FolderFilled, PictureOutlined } from '@ant-design/icons';
|
||||
import type { VfsEntry } from '../../../api/client';
|
||||
import { getFileIcon } from './FileIcons';
|
||||
@@ -10,7 +10,6 @@ interface Props {
|
||||
entries: VfsEntry[];
|
||||
thumbs: Record<string, string>;
|
||||
selectedEntries: string[];
|
||||
loading: boolean;
|
||||
path: string;
|
||||
onSelect: (e: VfsEntry, additive?: boolean) => void;
|
||||
onSelectRange: (names: string[]) => void;
|
||||
@@ -25,7 +24,7 @@ const formatSize = (size: number) => {
|
||||
return (size / 1024 / 1024 / 1024).toFixed(1) + ' GB';
|
||||
};
|
||||
|
||||
export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, loading, path, onSelect, onSelectRange, onOpen, onContextMenu }) => {
|
||||
export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, path, onSelect, onSelectRange, onOpen, onContextMenu }) => {
|
||||
const { token } = theme.useToken();
|
||||
const { resolvedMode } = useTheme();
|
||||
const lightenColor = (hex: string, amount: number) => {
|
||||
@@ -185,8 +184,7 @@ export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, lo
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{loading && <div style={{ width: '100%', textAlign: 'center', padding: 40 }}><Spin /></div>}
|
||||
{!loading && entries.length === 0 && <EmptyState isRoot={path === '/'} />}
|
||||
{entries.length === 0 && <EmptyState isRoot={path === '/'} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Flex, Typography, Divider, Button, Space, Tooltip, Segmented, Breadcrumb, Input, theme } from 'antd';
|
||||
import { Flex, Typography, Divider, Button, Space, Tooltip, Segmented, Breadcrumb, Input, theme, Dropdown } from 'antd';
|
||||
import { ArrowUpOutlined, ArrowDownOutlined, ReloadOutlined, PlusOutlined, UploadOutlined, AppstoreOutlined, UnorderedListOutlined } from '@ant-design/icons';
|
||||
import { Select } from 'antd';
|
||||
import { useI18n } from '../../../i18n';
|
||||
@@ -16,7 +16,8 @@ interface HeaderProps {
|
||||
onNavigate: (path: string) => void;
|
||||
onRefresh: () => void;
|
||||
onCreateDir: () => void;
|
||||
onUpload: () => void;
|
||||
onUploadFile: () => void;
|
||||
onUploadDirectory: () => void;
|
||||
onSetViewMode: (mode: ViewMode) => void;
|
||||
onSortChange: (sortBy: string, sortOrder: string) => void;
|
||||
}
|
||||
@@ -31,7 +32,8 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
onNavigate,
|
||||
onRefresh,
|
||||
onCreateDir,
|
||||
onUpload,
|
||||
onUploadFile,
|
||||
onUploadDirectory,
|
||||
onSetViewMode,
|
||||
onSortChange,
|
||||
}) => {
|
||||
@@ -108,7 +110,26 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
<Space size={8} wrap>
|
||||
<Button size="small" icon={<ReloadOutlined />} onClick={onRefresh} loading={loading}>{t('Refresh')}</Button>
|
||||
<Button size="small" icon={<PlusOutlined />} onClick={onCreateDir}>{t('New Folder')}</Button>
|
||||
<Button size="small" icon={<UploadOutlined />} onClick={onUpload}>{t('Upload')}</Button>
|
||||
<Dropdown.Button
|
||||
size="small"
|
||||
icon={<UploadOutlined />}
|
||||
onClick={onUploadFile}
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'file', label: t('Upload Files') },
|
||||
{ key: 'folder', label: t('Upload Folder') },
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'folder') {
|
||||
onUploadDirectory();
|
||||
} else {
|
||||
onUploadFile();
|
||||
}
|
||||
},
|
||||
}}
|
||||
>
|
||||
{t('Upload')}
|
||||
</Dropdown.Button>
|
||||
<Select
|
||||
size="small"
|
||||
value={sortBy}
|
||||
@@ -128,7 +149,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
<Segmented
|
||||
size="small"
|
||||
value={viewMode}
|
||||
onChange={v => onSetViewMode(v as any)}
|
||||
onChange={value => onSetViewMode(value as ViewMode)}
|
||||
options={[
|
||||
{ label: <Tooltip title={t('Grid')}><AppstoreOutlined /></Tooltip>, value: 'grid' },
|
||||
{ label: <Tooltip title={t('List')}><UnorderedListOutlined /></Tooltip>, value: 'list' }
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { FC } from 'react';
|
||||
import { Skeleton, theme } from 'antd';
|
||||
|
||||
type LoadingMode = 'grid' | 'list';
|
||||
|
||||
interface LoadingSkeletonProps {
|
||||
mode: LoadingMode;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
const createArray = (length: number) => Array.from({ length }, (_, index) => index);
|
||||
|
||||
export const LoadingSkeleton: FC<LoadingSkeletonProps> = ({ mode, count }) => {
|
||||
const { token } = theme.useToken();
|
||||
const fallbackCount = mode === 'grid' ? 50 : 30;
|
||||
const items = createArray(count ?? fallbackCount);
|
||||
|
||||
if (mode === 'grid') {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))',
|
||||
gap: 16,
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
{items.map((key) => (
|
||||
<div
|
||||
key={key}
|
||||
style={{
|
||||
background: token.colorBgElevated,
|
||||
borderRadius: token.borderRadius,
|
||||
padding: 16,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<Skeleton.Button active block style={{ height: 96, borderRadius: token.borderRadiusLG }} />
|
||||
<Skeleton active title={false} paragraph={{ rows: 2, width: ['80%', '60%'] }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '0 16px' }}>
|
||||
{items.map((key) => (
|
||||
<div
|
||||
key={key}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '48px 1fr',
|
||||
alignItems: 'center',
|
||||
padding: '12px 16px',
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
<Skeleton.Avatar active shape="square" size={32} />
|
||||
<div style={{ paddingLeft: 16 }}>
|
||||
<Skeleton active title={false} paragraph={{ rows: 1, width: '60%' }} />
|
||||
<Skeleton active title={false} paragraph={{ rows: 1, width: '40%' }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,24 +1,57 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Modal, Button, List, Progress, Typography, message, Flex } from 'antd';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { Modal, Button, List, Progress, Typography, message, Flex, Tag, Space } from 'antd';
|
||||
import { CopyOutlined, CheckCircleFilled, CloseCircleFilled } from '@ant-design/icons';
|
||||
import type { UploadFile } from '../../hooks/useUploader';
|
||||
import type { ConflictDecision, UploadConflict, UploadFile } from '../../hooks/useUploader';
|
||||
import { useI18n } from '../../../../i18n';
|
||||
|
||||
interface UploadModalProps {
|
||||
visible: boolean;
|
||||
files: UploadFile[];
|
||||
isUploading: boolean;
|
||||
totalProgress: number;
|
||||
totalFileBytes: number;
|
||||
uploadedFileBytes: number;
|
||||
conflict: UploadConflict | null;
|
||||
onClose: () => void;
|
||||
onStartUpload: () => void;
|
||||
onResolveConflict: (decision: ConflictDecision) => void;
|
||||
}
|
||||
|
||||
const UploadModal: React.FC<UploadModalProps> = ({ visible, files, onClose, onStartUpload }) => {
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes <= 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const index = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
|
||||
const value = bytes / (1024 ** index);
|
||||
return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`;
|
||||
};
|
||||
|
||||
const UploadModal: React.FC<UploadModalProps> = ({
|
||||
visible,
|
||||
files,
|
||||
isUploading,
|
||||
totalProgress,
|
||||
totalFileBytes,
|
||||
uploadedFileBytes,
|
||||
conflict,
|
||||
onClose,
|
||||
onStartUpload,
|
||||
onResolveConflict,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
const allSuccess = files.every(f => f.status === 'success');
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const total = files.length;
|
||||
const completed = files.filter(f => ['success', 'skipped'].includes(f.status)).length;
|
||||
const failures = files.filter(f => f.status === 'error').length;
|
||||
const pending = files.filter(f => ['pending', 'waiting', 'uploading'].includes(f.status)).length;
|
||||
return { total, completed, failures, pending };
|
||||
}, [files]);
|
||||
|
||||
const allFinished = files.length > 0 && files.every(f => ['success', 'error', 'skipped'].includes(f.status));
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && files.length > 0 && files.every(f => f.status === 'pending')) {
|
||||
onStartUpload();
|
||||
if (visible && files.length > 0 && files.some(f => f.status === 'pending')) {
|
||||
onStartUpload();
|
||||
}
|
||||
}, [visible, files, onStartUpload]);
|
||||
|
||||
@@ -28,6 +61,29 @@ const UploadModal: React.FC<UploadModalProps> = ({ visible, files, onClose, onSt
|
||||
};
|
||||
|
||||
const renderStatus = (file: UploadFile) => {
|
||||
if (file.type === 'directory') {
|
||||
if (file.status === 'uploading') {
|
||||
return <Typography.Text type="secondary">{t('Creating directory...')}</Typography.Text>;
|
||||
}
|
||||
if (file.status === 'success') {
|
||||
return (
|
||||
<Flex align="center" gap={8}>
|
||||
<CheckCircleFilled style={{ color: 'var(--ant-color-success, #52c41a)' }} />
|
||||
<Typography.Text type="secondary">{t('Directory ready')}</Typography.Text>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
if (file.status === 'error') {
|
||||
return (
|
||||
<Flex align="center" gap={8}>
|
||||
<CloseCircleFilled style={{ color: 'var(--ant-color-error, #ff4d4f)' }} />
|
||||
<Typography.Text type="danger" title={file.error}>{t('Create directory failed')}</Typography.Text>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
return <Typography.Text type="secondary">{t('Waiting to create')}</Typography.Text>;
|
||||
}
|
||||
|
||||
switch (file.status) {
|
||||
case 'uploading':
|
||||
return <Progress percent={Math.round(file.progress)} size="small" />;
|
||||
@@ -39,6 +95,10 @@ const UploadModal: React.FC<UploadModalProps> = ({ visible, files, onClose, onSt
|
||||
<Button icon={<CopyOutlined />} size="small" onClick={() => handleCopy(file.permanentLink!)} type="text" />
|
||||
</Flex>
|
||||
);
|
||||
case 'waiting':
|
||||
return <Typography.Text type="warning">{t('Waiting for overwrite decision')}</Typography.Text>;
|
||||
case 'skipped':
|
||||
return <Typography.Text type="secondary">{t('Skipped')}</Typography.Text>;
|
||||
case 'error':
|
||||
return (
|
||||
<Flex align="center" gap={8}>
|
||||
@@ -56,13 +116,72 @@ const UploadModal: React.FC<UploadModalProps> = ({ visible, files, onClose, onSt
|
||||
open={visible}
|
||||
title={t('Upload File')}
|
||||
width={600}
|
||||
closable={!isUploading}
|
||||
maskClosable={!isUploading}
|
||||
onCancel={onClose}
|
||||
footer={[
|
||||
<Button key="close" onClick={onClose} disabled={!allSuccess && files.some(f => f.status === 'uploading')}>
|
||||
{allSuccess ? t('Close') : t('Done')}
|
||||
<Button key="close" onClick={onClose} disabled={!allFinished || isUploading}>
|
||||
{allFinished ? t('Close') : t('Done')}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={16}>
|
||||
<div>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Typography.Text strong>
|
||||
{t('Total progress')}:
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
{t('Upload bytes summary', {
|
||||
uploaded: formatBytes(uploadedFileBytes),
|
||||
total: formatBytes(totalFileBytes),
|
||||
})}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
<Progress percent={Math.round(totalProgress)} showInfo />
|
||||
<Typography.Text type="secondary">
|
||||
{t('Upload task summary', {
|
||||
completed: summary.completed,
|
||||
total: summary.total,
|
||||
pending: summary.pending,
|
||||
failures: summary.failures,
|
||||
})}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
{conflict && (
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--ant-color-warning-border, #faad14)',
|
||||
borderRadius: 8,
|
||||
padding: '12px 16px',
|
||||
background: 'var(--ant-color-warning-bg, rgba(250,173,20,0.1))',
|
||||
}}
|
||||
>
|
||||
<Typography.Text strong>
|
||||
{t('Overwrite confirmation required')}
|
||||
</Typography.Text>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||
{t('Target already exists: {path}', { path: conflict.relativePath })}
|
||||
</Typography.Paragraph>
|
||||
<Flex gap={8} wrap="wrap">
|
||||
<Button size="small" type="primary" onClick={() => onResolveConflict('overwrite')}>
|
||||
{t('Overwrite')}
|
||||
</Button>
|
||||
<Button size="small" onClick={() => onResolveConflict('skip')}>
|
||||
{t('Skip')}
|
||||
</Button>
|
||||
<Button size="small" type="primary" onClick={() => onResolveConflict('overwriteAll')}>
|
||||
{t('Overwrite All')}
|
||||
</Button>
|
||||
<Button size="small" onClick={() => onResolveConflict('skipAll')}>
|
||||
{t('Skip All')}
|
||||
</Button>
|
||||
</Flex>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
<List
|
||||
dataSource={files}
|
||||
itemLayout="horizontal"
|
||||
@@ -77,9 +196,16 @@ const UploadModal: React.FC<UploadModalProps> = ({ visible, files, onClose, onSt
|
||||
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; }}
|
||||
>
|
||||
<Flex justify="space-between" align="center" style={{ width: '100%' }}>
|
||||
<Typography.Text ellipsis={{ tooltip: file.file.name }} style={{ maxWidth: '60%' }}>
|
||||
{file.file.name}
|
||||
</Typography.Text>
|
||||
<Flex align="center" gap={8} style={{ maxWidth: '60%', overflow: 'hidden' }}>
|
||||
<Typography.Text ellipsis={{ tooltip: file.relativePath }} style={{ maxWidth: '100%' }}>
|
||||
{file.relativePath}
|
||||
</Typography.Text>
|
||||
{file.type === 'directory' ? (
|
||||
<Tag color="blue">{t('Directory')}</Tag>
|
||||
) : (
|
||||
<Tag color="geekblue">{formatBytes(file.size)}</Tag>
|
||||
)}
|
||||
</Flex>
|
||||
<div style={{ minWidth: 180, textAlign: 'right', flexShrink: 0 }}>
|
||||
{renderStatus(file)}
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@ export function useThumbnails(entries: VfsEntry[], path: string) {
|
||||
|
||||
useEffect(() => {
|
||||
const newThumbs: Record<string, string> = {};
|
||||
const targets = entries.filter(e => !e.is_dir && (e as any).is_image && !thumbs[e.name]);
|
||||
const targets = entries.filter(e => !e.is_dir && (e as any).has_thumbnail && !thumbs[e.name]);
|
||||
|
||||
if (targets.length > 0) {
|
||||
targets.forEach(ent => {
|
||||
@@ -37,4 +37,4 @@ export function useThumbnails(entries: VfsEntry[], path: string) {
|
||||
}, [entries, path, thumbs]);
|
||||
|
||||
return { thumbs };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,103 +1,592 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import type { ChangeEvent, RefObject } from 'react';
|
||||
import { useState, useCallback, useRef, useMemo, useEffect } from 'react';
|
||||
import { message } from 'antd';
|
||||
import { vfsApi } from '../../../api/client';
|
||||
import { message }
|
||||
from 'antd';
|
||||
import { useI18n } from '../../../i18n';
|
||||
|
||||
type UploadStatus = 'pending' | 'waiting' | 'uploading' | 'success' | 'error' | 'skipped';
|
||||
|
||||
export interface UploadFile {
|
||||
id: string;
|
||||
file: File;
|
||||
status: 'pending' | 'uploading' | 'success' | 'error';
|
||||
name: string;
|
||||
relativePath: string;
|
||||
targetPath: string;
|
||||
type: 'file' | 'directory';
|
||||
size: number;
|
||||
loadedBytes: number;
|
||||
status: UploadStatus;
|
||||
progress: number;
|
||||
error?: string;
|
||||
permanentLink?: string;
|
||||
file?: File;
|
||||
}
|
||||
|
||||
export type ConflictDecision = 'overwrite' | 'skip' | 'overwriteAll' | 'skipAll';
|
||||
|
||||
export interface UploadConflict {
|
||||
taskId: string;
|
||||
relativePath: string;
|
||||
targetPath: string;
|
||||
type: 'file' | 'directory';
|
||||
}
|
||||
|
||||
interface RawUploadFile {
|
||||
kind: 'file';
|
||||
relativePath: string;
|
||||
file: File;
|
||||
}
|
||||
|
||||
interface RawUploadDirectory {
|
||||
kind: 'directory';
|
||||
relativePath: string;
|
||||
}
|
||||
|
||||
type RawUploadItem = RawUploadFile | RawUploadDirectory;
|
||||
|
||||
const generateId = (() => {
|
||||
const cryptoApi = typeof crypto !== 'undefined' ? crypto : undefined;
|
||||
return () => {
|
||||
if (cryptoApi?.randomUUID) return cryptoApi.randomUUID();
|
||||
return `upload-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
|
||||
};
|
||||
})();
|
||||
|
||||
const normalizeRelativePath = (path: string) => path.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, '');
|
||||
|
||||
const joinWithBasePath = (base: string, relative: string) => {
|
||||
const cleanedBase = base === '/' ? '' : base.replace(/\/+$/, '');
|
||||
const cleanedRelative = normalizeRelativePath(relative);
|
||||
const parts = [cleanedBase, cleanedRelative].filter(Boolean);
|
||||
const joined = parts.join('/');
|
||||
return joined.startsWith('/') ? joined : `/${joined}`;
|
||||
};
|
||||
|
||||
const collectParentDirectories = (relativePath: string) => {
|
||||
const normalized = normalizeRelativePath(relativePath);
|
||||
if (!normalized) return [];
|
||||
const segments = normalized.split('/').slice(0, -1);
|
||||
const dirs: string[] = [];
|
||||
for (let i = 1; i <= segments.length; i += 1) {
|
||||
const dir = segments.slice(0, i).join('/');
|
||||
if (dir) dirs.push(dir);
|
||||
}
|
||||
return dirs;
|
||||
};
|
||||
|
||||
const collectAllDirectories = (items: RawUploadItem[]) => {
|
||||
const directories = new Set<string>();
|
||||
items.forEach((item) => {
|
||||
if (item.kind === 'directory') {
|
||||
const normalized = normalizeRelativePath(item.relativePath);
|
||||
if (normalized) directories.add(normalized);
|
||||
} else {
|
||||
collectParentDirectories(item.relativePath).forEach((dir) => directories.add(dir));
|
||||
}
|
||||
});
|
||||
return Array.from(directories).sort((a, b) => a.localeCompare(b));
|
||||
};
|
||||
|
||||
interface WebkitFileSystemFileEntry {
|
||||
isFile: true;
|
||||
isDirectory: false;
|
||||
name: string;
|
||||
fullPath: string;
|
||||
file: (
|
||||
successCallback: (file: File) => void,
|
||||
errorCallback?: (err: DOMException) => void,
|
||||
) => void;
|
||||
}
|
||||
|
||||
interface WebkitFileSystemDirectoryReader {
|
||||
readEntries: (
|
||||
successCallback: (entries: WebkitFileSystemEntry[]) => void,
|
||||
errorCallback?: (err: DOMException) => void,
|
||||
) => void;
|
||||
}
|
||||
|
||||
interface WebkitFileSystemDirectoryEntry {
|
||||
isFile: false;
|
||||
isDirectory: true;
|
||||
name: string;
|
||||
fullPath: string;
|
||||
createReader: () => WebkitFileSystemDirectoryReader;
|
||||
}
|
||||
|
||||
type WebkitFileSystemEntry = WebkitFileSystemFileEntry | WebkitFileSystemDirectoryEntry;
|
||||
|
||||
const safeStat = async (fullPath: string): Promise<{ is_dir?: boolean } | null> => {
|
||||
try {
|
||||
return await vfsApi.stat(fullPath) as { is_dir?: boolean };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const readAllDirectoryEntries = (directoryEntry: WebkitFileSystemDirectoryEntry): Promise<WebkitFileSystemEntry[]> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = directoryEntry.createReader();
|
||||
const entries: WebkitFileSystemEntry[] = [];
|
||||
const readBatch = () => {
|
||||
reader.readEntries(
|
||||
(batch: WebkitFileSystemEntry[]) => {
|
||||
if (batch.length === 0) {
|
||||
resolve(entries);
|
||||
} else {
|
||||
entries.push(...batch);
|
||||
readBatch();
|
||||
}
|
||||
},
|
||||
(err: DOMException) => reject(err),
|
||||
);
|
||||
};
|
||||
readBatch();
|
||||
});
|
||||
|
||||
const traverseEntry = async (
|
||||
entry: WebkitFileSystemEntry,
|
||||
parentPath: string,
|
||||
bucket: RawUploadItem[],
|
||||
) => {
|
||||
if (!entry) return;
|
||||
const currentPath = parentPath ? `${parentPath}/${entry.name}` : entry.name;
|
||||
if (entry.isFile) {
|
||||
const file: File = await new Promise((resolve, reject) => {
|
||||
entry.file(
|
||||
(f: File) => resolve(f),
|
||||
(err: DOMException) => reject(err),
|
||||
);
|
||||
});
|
||||
bucket.push({
|
||||
kind: 'file',
|
||||
relativePath: currentPath,
|
||||
file,
|
||||
});
|
||||
} else if (entry.isDirectory) {
|
||||
bucket.push({
|
||||
kind: 'directory',
|
||||
relativePath: currentPath,
|
||||
});
|
||||
const entries = await readAllDirectoryEntries(entry);
|
||||
for (const child of entries) {
|
||||
await traverseEntry(child, currentPath, bucket);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const collectFromFileList = async (list: FileList): Promise<RawUploadItem[]> => {
|
||||
const items: RawUploadItem[] = [];
|
||||
for (const file of Array.from(list)) {
|
||||
const fileWithPath = file as File & { webkitRelativePath?: string };
|
||||
const relativePath = fileWithPath.webkitRelativePath || file.name;
|
||||
items.push({
|
||||
kind: 'file',
|
||||
relativePath,
|
||||
file,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
};
|
||||
|
||||
const collectFromDataTransfer = async (dataTransfer: DataTransfer): Promise<RawUploadItem[]> => {
|
||||
const items: RawUploadItem[] = [];
|
||||
if (dataTransfer.items && dataTransfer.items.length > 0) {
|
||||
for (const item of Array.from(dataTransfer.items)) {
|
||||
const itemWithEntry = item as DataTransferItem & {
|
||||
webkitGetAsEntry?: () => FileSystemEntry | null;
|
||||
};
|
||||
const entry = itemWithEntry.webkitGetAsEntry ? (itemWithEntry.webkitGetAsEntry() as unknown as WebkitFileSystemEntry) : null;
|
||||
if (entry) {
|
||||
await traverseEntry(entry, '', items);
|
||||
} else if (item.kind === 'file') {
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
items.push({
|
||||
kind: 'file',
|
||||
relativePath: file.name,
|
||||
file,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (dataTransfer.files && dataTransfer.files.length > 0) {
|
||||
return collectFromFileList(dataTransfer.files);
|
||||
}
|
||||
return items;
|
||||
};
|
||||
|
||||
const createUploadTasks = (basePath: string, items: RawUploadItem[]): UploadFile[] => {
|
||||
const idGenerator = generateId;
|
||||
const directories = collectAllDirectories(items);
|
||||
const directoryTasks: UploadFile[] = directories.map((relativePath) => {
|
||||
const targetPath = joinWithBasePath(basePath, relativePath);
|
||||
const segments = normalizeRelativePath(relativePath).split('/');
|
||||
const name = segments[segments.length - 1] || targetPath;
|
||||
return {
|
||||
id: idGenerator(),
|
||||
name,
|
||||
relativePath,
|
||||
targetPath,
|
||||
type: 'directory',
|
||||
size: 0,
|
||||
loadedBytes: 0,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
};
|
||||
});
|
||||
|
||||
const fileTasks: UploadFile[] = items
|
||||
.filter((item): item is RawUploadFile => item.kind === 'file')
|
||||
.map((item) => {
|
||||
const relativePath = normalizeRelativePath(item.relativePath) || item.file.name;
|
||||
const targetPath = joinWithBasePath(basePath, relativePath);
|
||||
return {
|
||||
id: idGenerator(),
|
||||
name: item.file.name,
|
||||
relativePath,
|
||||
targetPath,
|
||||
type: 'file',
|
||||
size: item.file.size,
|
||||
loadedBytes: 0,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
file: item.file,
|
||||
};
|
||||
});
|
||||
|
||||
return [...directoryTasks, ...fileTasks];
|
||||
};
|
||||
|
||||
export function useUploader(path: string, onUploadComplete: () => void) {
|
||||
const { t } = useI18n();
|
||||
const [files, setFiles] = useState<UploadFile[]>([]);
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [conflict, setConflict] = useState<UploadConflict | null>(null);
|
||||
const conflictResolverRef = useRef<((decision: ConflictDecision) => void) | null>(null);
|
||||
const overwriteAllRef = useRef(false);
|
||||
const skipAllRef = useRef(false);
|
||||
const createdDirsRef = useRef<Set<string>>(new Set());
|
||||
const filesRef = useRef<UploadFile[]>(files);
|
||||
const isUploadingRef = useRef(false);
|
||||
|
||||
const openModal = useCallback(() => {
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const directoryInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const node = directoryInputRef.current;
|
||||
if (!node) return;
|
||||
node.setAttribute('webkitdirectory', '');
|
||||
node.setAttribute('directory', '');
|
||||
}, []);
|
||||
|
||||
const mutateFiles = useCallback((updater: (prev: UploadFile[]) => UploadFile[]) => {
|
||||
setFiles((prev) => {
|
||||
const next = updater(prev);
|
||||
filesRef.current = next;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const replaceFiles = useCallback((next: UploadFile[]) => {
|
||||
filesRef.current = next;
|
||||
setFiles(next);
|
||||
}, []);
|
||||
|
||||
const updateFile = useCallback((id: string, patch: Partial<UploadFile>) => {
|
||||
mutateFiles((prev) => prev.map((f) => (f.id === id ? { ...f, ...patch } : f)));
|
||||
}, [mutateFiles]);
|
||||
|
||||
const resetOverwriteDecisions = useCallback(() => {
|
||||
overwriteAllRef.current = false;
|
||||
skipAllRef.current = false;
|
||||
}, []);
|
||||
|
||||
const openFilePicker = useCallback(() => {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setIsModalVisible(false);
|
||||
setFiles([]);
|
||||
const openDirectoryPicker = useCallback(() => {
|
||||
if (directoryInputRef.current) {
|
||||
directoryInputRef.current.click();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const closeModal = useCallback(() => {
|
||||
if (isUploadingRef.current) {
|
||||
return;
|
||||
}
|
||||
setIsModalVisible(false);
|
||||
replaceFiles([]);
|
||||
resetOverwriteDecisions();
|
||||
setConflict(null);
|
||||
conflictResolverRef.current = null;
|
||||
createdDirsRef.current = new Set();
|
||||
}, [replaceFiles, resetOverwriteDecisions]);
|
||||
|
||||
const prepareQueue = useCallback((items: RawUploadItem[]) => {
|
||||
if (!items.length) {
|
||||
message.info(t('No items selected for upload'));
|
||||
return;
|
||||
}
|
||||
const tasks = createUploadTasks(path, items);
|
||||
if (!tasks.length) {
|
||||
message.info(t('No uploadable files or directories found'));
|
||||
return;
|
||||
}
|
||||
replaceFiles(tasks);
|
||||
resetOverwriteDecisions();
|
||||
createdDirsRef.current = new Set();
|
||||
setIsModalVisible(true);
|
||||
}, [path, replaceFiles, resetOverwriteDecisions, t]);
|
||||
|
||||
const handleInputChange = useCallback(async (event: ChangeEvent<HTMLInputElement>, ref: RefObject<HTMLInputElement | null>) => {
|
||||
const selectedFiles = event.target.files;
|
||||
if (selectedFiles && selectedFiles.length > 0) {
|
||||
const newFiles: UploadFile[] = Array.from(selectedFiles).map(file => ({
|
||||
id: `${file.name}-${Date.now()}`,
|
||||
file,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
}));
|
||||
setFiles(newFiles);
|
||||
setIsModalVisible(true);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
if (!selectedFiles || selectedFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
const items = await collectFromFileList(selectedFiles);
|
||||
prepareQueue(items);
|
||||
if (ref.current) {
|
||||
ref.current.value = '';
|
||||
}
|
||||
}, [prepareQueue]);
|
||||
|
||||
const handleFileInputChange = useCallback(async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
await handleInputChange(event, fileInputRef);
|
||||
}, [handleInputChange]);
|
||||
|
||||
const handleDirectoryInputChange = useCallback(async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
await handleInputChange(event, directoryInputRef);
|
||||
}, [handleInputChange]);
|
||||
|
||||
const handleFileDrop = useCallback(async (data: DataTransfer) => {
|
||||
const items = await collectFromDataTransfer(data);
|
||||
prepareQueue(items);
|
||||
}, [prepareQueue]);
|
||||
|
||||
const awaitConflictDecision = useCallback(async (task: UploadFile): Promise<'overwrite' | 'skip'> => {
|
||||
if (overwriteAllRef.current) {
|
||||
return 'overwrite';
|
||||
}
|
||||
if (skipAllRef.current) {
|
||||
return 'skip';
|
||||
}
|
||||
return new Promise<'overwrite' | 'skip'>((resolve) => {
|
||||
updateFile(task.id, { status: 'waiting' });
|
||||
setConflict({
|
||||
taskId: task.id,
|
||||
relativePath: task.relativePath,
|
||||
targetPath: task.targetPath,
|
||||
type: task.type,
|
||||
});
|
||||
conflictResolverRef.current = (decision: ConflictDecision) => {
|
||||
if (decision === 'overwriteAll') {
|
||||
overwriteAllRef.current = true;
|
||||
resolve('overwrite');
|
||||
} else if (decision === 'skipAll') {
|
||||
skipAllRef.current = true;
|
||||
resolve('skip');
|
||||
} else if (decision === 'overwrite') {
|
||||
resolve('overwrite');
|
||||
} else {
|
||||
resolve('skip');
|
||||
}
|
||||
};
|
||||
});
|
||||
}, [updateFile]);
|
||||
|
||||
const confirmConflict = useCallback((decision: ConflictDecision) => {
|
||||
if (!conflictResolverRef.current) {
|
||||
return;
|
||||
}
|
||||
const resolver = conflictResolverRef.current;
|
||||
conflictResolverRef.current = null;
|
||||
setConflict(null);
|
||||
resolver(decision);
|
||||
}, []);
|
||||
|
||||
const ensureDirectory = useCallback(async (fullPath: string) => {
|
||||
const normalized = fullPath.replace(/\/+/g, '/');
|
||||
if (!normalized || normalized === '/') {
|
||||
return;
|
||||
}
|
||||
if (createdDirsRef.current.has(normalized)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await vfsApi.mkdir(normalized);
|
||||
} catch (err: unknown) {
|
||||
const messageText = err instanceof Error ? err.message : String(err);
|
||||
if (!/exist/i.test(messageText)) {
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
createdDirsRef.current.add(normalized);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleFileDrop = (droppedFiles: FileList) => {
|
||||
if (droppedFiles && droppedFiles.length > 0) {
|
||||
const newFiles: UploadFile[] = Array.from(droppedFiles).map(file => ({
|
||||
id: `${file.name}-${Date.now()}`,
|
||||
file,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
}));
|
||||
setFiles(newFiles);
|
||||
setIsModalVisible(true);
|
||||
const ensureDirectoryTree = useCallback(async (targetDir: string) => {
|
||||
if (!targetDir || targetDir === '/') return;
|
||||
const normalized = targetDir.replace(/\/+/g, '/');
|
||||
const segments = normalized.replace(/^\/+/, '').split('/').filter(Boolean);
|
||||
let current = '';
|
||||
for (const segment of segments) {
|
||||
current = `${current}/${segment}`;
|
||||
await ensureDirectory(current.startsWith('/') ? current : `/${current}`);
|
||||
}
|
||||
};
|
||||
}, [ensureDirectory]);
|
||||
|
||||
const startUpload = useCallback(async () => {
|
||||
if (files.length === 0) {
|
||||
const processDirectoryTask = useCallback(async (task: UploadFile) => {
|
||||
updateFile(task.id, { status: 'uploading', progress: 10 });
|
||||
const stat = await safeStat(task.targetPath);
|
||||
if (stat && !stat.is_dir) {
|
||||
const error = t('Directory conflicts with existing file');
|
||||
updateFile(task.id, { status: 'error', progress: 0, error });
|
||||
message.error(`${task.relativePath}: ${error}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await ensureDirectory(task.targetPath);
|
||||
updateFile(task.id, { status: 'success', progress: 100 });
|
||||
} catch (err: unknown) {
|
||||
const error = err instanceof Error ? err.message : t('Create directory failed');
|
||||
updateFile(task.id, { status: 'error', progress: 0, error });
|
||||
message.error(`${task.relativePath}: ${error}`);
|
||||
}
|
||||
}, [ensureDirectory, updateFile, t]);
|
||||
|
||||
const processFileTask = useCallback(async (task: UploadFile) => {
|
||||
if (!task.file) {
|
||||
updateFile(task.id, { status: 'error', error: t('Missing file content') });
|
||||
return;
|
||||
}
|
||||
|
||||
const dir = path === '/' ? '' : path;
|
||||
if (skipAllRef.current) {
|
||||
updateFile(task.id, { status: 'skipped', progress: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
for (const uploadFile of files) {
|
||||
if (uploadFile.status !== 'pending') continue;
|
||||
|
||||
setFiles(prev => prev.map(f => f.id === uploadFile.id ? { ...f, status: 'uploading' } : f));
|
||||
|
||||
const dest = (dir + '/' + uploadFile.file.name).replace(/\/+/g, '/');
|
||||
|
||||
try {
|
||||
await vfsApi.uploadStream(dest, uploadFile.file, true, (loaded, total) => {
|
||||
const progress = total > 0 ? (loaded / total) * 100 : 0;
|
||||
setFiles(prev => prev.map(f => f.id === uploadFile.id ? { ...f, progress } : f));
|
||||
});
|
||||
|
||||
const link = await vfsApi.getTempLinkToken(dest, 60 * 60 * 24 * 365 * 10);
|
||||
const permanentLink = vfsApi.getTempPublicUrl(link.token);
|
||||
|
||||
setFiles(prev => prev.map(f => f.id === uploadFile.id ? { ...f, status: 'success', progress: 100, permanentLink } : f));
|
||||
} catch (e: any) {
|
||||
setFiles(prev => prev.map(f => f.id === uploadFile.id ? { ...f, status: 'error', error: e.message } : f));
|
||||
message.error(`Upload failed: ${uploadFile.file.name} - ${e.message}`);
|
||||
let shouldOverwrite = overwriteAllRef.current;
|
||||
if (!shouldOverwrite) {
|
||||
const stat = await safeStat(task.targetPath);
|
||||
if (stat) {
|
||||
const decision = await awaitConflictDecision(task);
|
||||
if (decision === 'skip') {
|
||||
updateFile(task.id, { status: 'skipped', progress: 0 });
|
||||
return;
|
||||
}
|
||||
shouldOverwrite = true;
|
||||
}
|
||||
}
|
||||
|
||||
onUploadComplete();
|
||||
}, [files, path, onUploadComplete]);
|
||||
|
||||
setConflict(null);
|
||||
updateFile(task.id, { status: 'uploading', progress: 0, loadedBytes: 0 });
|
||||
|
||||
const parentDir = task.targetPath.replace(/\/[^/]+$/, '') || '/';
|
||||
try {
|
||||
await ensureDirectoryTree(parentDir);
|
||||
await vfsApi.uploadStream(task.targetPath, task.file, shouldOverwrite, (loaded, total) => {
|
||||
mutateFiles((prev) => prev.map((f) => {
|
||||
if (f.id !== task.id) return f;
|
||||
const effectiveTotal = total > 0 ? total : f.size;
|
||||
const size = Math.max(f.size, effectiveTotal, loaded);
|
||||
const percent = size > 0 ? Math.min(100, Math.round((loaded / size) * 100)) : 0;
|
||||
return {
|
||||
...f,
|
||||
size,
|
||||
loadedBytes: loaded,
|
||||
progress: percent,
|
||||
};
|
||||
}));
|
||||
});
|
||||
|
||||
const link = await vfsApi.getTempLinkToken(task.targetPath, 60 * 60 * 24 * 365 * 10);
|
||||
const permanentLink = vfsApi.getTempPublicUrl(link.token);
|
||||
updateFile(task.id, { status: 'success', progress: 100, loadedBytes: task.size, permanentLink });
|
||||
} catch (err: unknown) {
|
||||
const error = err instanceof Error ? err.message : t('Upload failed');
|
||||
updateFile(task.id, { status: 'error', error, progress: 0 });
|
||||
message.error(`${task.relativePath}: ${error}`);
|
||||
}
|
||||
}, [ensureDirectoryTree, awaitConflictDecision, mutateFiles, updateFile, t]);
|
||||
|
||||
const startUpload = useCallback(async () => {
|
||||
if (isUploadingRef.current) return;
|
||||
if (!filesRef.current.length) return;
|
||||
|
||||
isUploadingRef.current = true;
|
||||
setIsUploading(true);
|
||||
try {
|
||||
for (const task of filesRef.current) {
|
||||
if (task.status !== 'pending' && task.status !== 'waiting') {
|
||||
continue;
|
||||
}
|
||||
if (task.type === 'directory') {
|
||||
await processDirectoryTask(task);
|
||||
} else {
|
||||
await processFileTask(task);
|
||||
}
|
||||
}
|
||||
onUploadComplete();
|
||||
} finally {
|
||||
isUploadingRef.current = false;
|
||||
setIsUploading(false);
|
||||
}
|
||||
}, [onUploadComplete, processDirectoryTask, processFileTask]);
|
||||
|
||||
const totalFileBytes = useMemo(
|
||||
() => files.reduce((acc, f) => acc + (f.type === 'file' ? f.size : 0), 0),
|
||||
[files],
|
||||
);
|
||||
|
||||
const uploadedFileBytes = useMemo(
|
||||
() => files.reduce((acc, f) => {
|
||||
if (f.type !== 'file') return acc;
|
||||
const loaded = Math.min(f.loadedBytes, f.size);
|
||||
if (f.status === 'success') {
|
||||
return acc + (f.size || loaded);
|
||||
}
|
||||
if (f.status === 'uploading' || f.status === 'waiting') {
|
||||
return acc + loaded;
|
||||
}
|
||||
return acc;
|
||||
}, 0),
|
||||
[files],
|
||||
);
|
||||
|
||||
const directoryCounts = useMemo(() => {
|
||||
const directories = files.filter((f) => f.type === 'directory');
|
||||
const completed = directories.filter((f) => f.status === 'success').length;
|
||||
return {
|
||||
total: directories.length,
|
||||
completed,
|
||||
};
|
||||
}, [files]);
|
||||
|
||||
const totalWeight = totalFileBytes + directoryCounts.total;
|
||||
const totalProgress = totalWeight === 0
|
||||
? 0
|
||||
: ((uploadedFileBytes + directoryCounts.completed) / totalWeight) * 100;
|
||||
|
||||
return {
|
||||
files,
|
||||
isModalVisible,
|
||||
isUploading,
|
||||
totalProgress: Math.min(100, Math.max(0, totalProgress)),
|
||||
totalFileBytes,
|
||||
uploadedFileBytes,
|
||||
conflict,
|
||||
confirmConflict,
|
||||
resetOverwriteDecisions,
|
||||
fileInputRef,
|
||||
openModal,
|
||||
directoryInputRef,
|
||||
openFilePicker,
|
||||
openDirectoryPicker,
|
||||
closeModal,
|
||||
handleFileChange,
|
||||
handleFileInputChange,
|
||||
handleDirectoryInputChange,
|
||||
handleFileDrop,
|
||||
startUpload,
|
||||
};
|
||||
|
||||
104
web/src/pages/ForgotPasswordPage.tsx
Normal file
104
web/src/pages/ForgotPasswordPage.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useState } from 'react';
|
||||
import { Card, Form, Input, Button, Typography, message } from 'antd';
|
||||
import { MailOutlined, ArrowLeftOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { authApi } from '../api/auth';
|
||||
import { useI18n } from '../i18n';
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const { t } = useI18n();
|
||||
const navigate = useNavigate();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
const handleSubmit = async (values: { email: string }) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await authApi.requestPasswordReset({ email: values.email });
|
||||
message.success(t('If the email exists, a reset link has been sent.'));
|
||||
setSent(true);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || t('Request failed'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(to right, var(--ant-color-bg-layout, #f0f2f5), var(--ant-color-fill-secondary, #d7d7d7))',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '48px 16px',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{ position: 'absolute', top: 16, right: 16 }}>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
<Card
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 460,
|
||||
borderRadius: 20,
|
||||
boxShadow: '0 24px 60px rgba(15,23,42,0.12)',
|
||||
border: '1px solid rgba(99,102,241,0.12)',
|
||||
}}
|
||||
styles={{ body: { padding: '40px 36px' } }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||
<div style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: '50%',
|
||||
margin: '0 auto 16px',
|
||||
background: 'linear-gradient(135deg,#6366f1,#8b5cf6)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#fff',
|
||||
fontSize: 28,
|
||||
}}>
|
||||
<MailOutlined />
|
||||
</div>
|
||||
<Title level={3} style={{ marginBottom: 8 }}>{t('Reset Your Password')}</Title>
|
||||
<Text type="secondary">
|
||||
{t('Enter the email linked to your account and we will send a reset link.')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Form layout="vertical" size="large" onFinish={handleSubmit}>
|
||||
<Form.Item
|
||||
name="email"
|
||||
label={t('Email')}
|
||||
rules={[
|
||||
{ required: true, message: t('Please input recipient email') },
|
||||
{ type: 'email', message: t('Please input a valid email!') },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="me@example.com" autoComplete="email" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item style={{ marginTop: 32 }}>
|
||||
<Button type="primary" htmlType="submit" loading={submitting} block>
|
||||
{sent ? t('Resend Link') : t('Send Reset Link')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Button
|
||||
type="link"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/login')}
|
||||
style={{ padding: 0 }}
|
||||
>
|
||||
{t('Back to login')}
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -107,6 +107,12 @@ export default function LoginPage() {
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item style={{ marginBottom: 8, textAlign: 'right' }}>
|
||||
<Button type="link" onClick={() => navigate('/forgot-password')} style={{ padding: 0 }}>
|
||||
{t('Forgot Password?')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
|
||||
146
web/src/pages/ResetPasswordPage.tsx
Normal file
146
web/src/pages/ResetPasswordPage.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Card, Form, Input, Button, Typography, message, Result } from 'antd';
|
||||
import { LockOutlined, CheckCircleTwoTone } from '@ant-design/icons';
|
||||
import { useLocation, useNavigate } from 'react-router';
|
||||
import { authApi } from '../api/auth';
|
||||
import { useI18n } from '../i18n';
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const { t } = useI18n();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const token = useMemo(() => new URLSearchParams(location.search).get('token') || '', [location.search]);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [userInfo, setUserInfo] = useState<{ username: string; email: string } | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setError(t('Reset link is invalid'));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
authApi.verifyPasswordResetToken(token)
|
||||
.then(setUserInfo)
|
||||
.catch((err) => {
|
||||
setError(err?.message || t('Reset link is invalid or expired'));
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [token, t]);
|
||||
|
||||
const handleSubmit = async (values: { password: string; confirm: string }) => {
|
||||
if (values.password !== values.confirm) {
|
||||
message.error(t('Passwords do not match'));
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await authApi.confirmPasswordReset({ token, password: values.password });
|
||||
setSuccess(true);
|
||||
message.success(t('Password updated, please login again.'));
|
||||
setTimeout(() => navigate('/login'), 1500);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || t('Failed to reset password'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Result
|
||||
status="error"
|
||||
title={t('Reset failed')}
|
||||
subTitle={error}
|
||||
extra={[
|
||||
<Button type="primary" key="back" onClick={() => navigate('/forgot-password')}>
|
||||
{t('Try again')}
|
||||
</Button>,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(to right, var(--ant-color-bg-layout, #f0f2f5), var(--ant-color-fill-secondary, #d7d7d7))',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '48px 16px',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{ position: 'absolute', top: 16, right: 16 }}>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
<Card
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 480,
|
||||
borderRadius: 20,
|
||||
border: '1px solid rgba(99,102,241,0.14)',
|
||||
boxShadow: '0 24px 60px rgba(79,70,229,0.18)',
|
||||
}}
|
||||
bodyStyle={{ padding: '40px 36px' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||
<div style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: '50%',
|
||||
margin: '0 auto 16px',
|
||||
background: success ? '#ecfdf5' : 'linear-gradient(135deg,#6366f1,#8b5cf6)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: success ? '#047857' : '#fff',
|
||||
fontSize: success ? 32 : 28,
|
||||
}}>
|
||||
{success ? <CheckCircleTwoTone twoToneColor="#22c55e" /> : <LockOutlined />}
|
||||
</div>
|
||||
<Title level={3} style={{ marginBottom: 8 }}>{t('Set a new password')}</Title>
|
||||
{userInfo && <Text type="secondary">{userInfo.email}</Text>}
|
||||
</div>
|
||||
|
||||
<Form layout="vertical" size="large" onFinish={handleSubmit}>
|
||||
<Form.Item
|
||||
name="password"
|
||||
label={t('New Password')}
|
||||
rules={[{ required: true, message: t('Please enter new password') }]}
|
||||
>
|
||||
<Input.Password autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="confirm"
|
||||
label={t('Confirm Password')}
|
||||
rules={[{ required: true, message: t('Please confirm new password') }]}
|
||||
>
|
||||
<Input.Password autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={submitting}
|
||||
block
|
||||
size="large"
|
||||
>
|
||||
{t('Update Password')}
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { message, Tabs, Space } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import PageCard from '../../components/PageCard';
|
||||
import { getAllConfig, setConfig } from '../../api/config';
|
||||
import { AppstoreOutlined, RobotOutlined, DatabaseOutlined, SkinOutlined } from '@ant-design/icons';
|
||||
import { AppstoreOutlined, RobotOutlined, DatabaseOutlined, SkinOutlined, MailOutlined } from '@ant-design/icons';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import '../../styles/settings-tabs.css';
|
||||
import { useI18n } from '../../i18n';
|
||||
@@ -10,10 +10,11 @@ import AppearanceSettingsTab from './components/AppearanceSettingsTab';
|
||||
import AppSettingsTab from './components/AppSettingsTab';
|
||||
import AiSettingsTab from './components/AiSettingsTab';
|
||||
import VectorDbSettingsTab from './components/VectorDbSettingsTab';
|
||||
import EmailSettingsTab from './components/EmailSettingsTab';
|
||||
|
||||
type TabKey = 'appearance' | 'app' | 'ai' | 'vector-db';
|
||||
type TabKey = 'appearance' | 'app' | 'email' | 'ai' | 'vector-db';
|
||||
|
||||
const TAB_KEYS: TabKey[] = ['appearance', 'app', 'ai', 'vector-db'];
|
||||
const TAB_KEYS: TabKey[] = ['appearance', 'app', 'email', 'ai', 'vector-db'];
|
||||
const DEFAULT_TAB: TabKey = 'appearance';
|
||||
|
||||
const isValidTab = (key?: string): key is TabKey => !!key && (TAB_KEYS as string[]).includes(key);
|
||||
@@ -26,6 +27,7 @@ interface SystemSettingsPageProps {
|
||||
const APP_CONFIG_KEYS: { key: string, label: string, default?: string }[] = [
|
||||
{ key: 'APP_NAME', label: 'App Name' },
|
||||
{ key: 'APP_LOGO', label: 'Logo URL' },
|
||||
{ key: 'APP_FAVICON', label: 'Favicon URL', default: '/logo.svg' },
|
||||
{ key: 'APP_DOMAIN', label: 'App Domain' },
|
||||
{ key: 'FILE_DOMAIN', label: 'File Domain' },
|
||||
];
|
||||
@@ -107,13 +109,12 @@ export default function SystemSettingsPage({ tabKey, onTabNavigate }: SystemSett
|
||||
<PageCard
|
||||
title={t('System Settings')}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={32}>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={16}>
|
||||
<Tabs
|
||||
className="fx-settings-tabs"
|
||||
activeKey={activeTab}
|
||||
onChange={handleTabChange}
|
||||
centered
|
||||
tabPosition="left"
|
||||
items={[
|
||||
{
|
||||
key: 'appearance',
|
||||
@@ -149,6 +150,22 @@ export default function SystemSettingsPage({ tabKey, onTabNavigate }: SystemSett
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
label: (
|
||||
<span>
|
||||
<MailOutlined style={{ marginRight: 8 }} />
|
||||
{t('Email Settings')}
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<EmailSettingsTab
|
||||
config={config}
|
||||
loading={loading}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'ai',
|
||||
label: (
|
||||
|
||||
440
web/src/pages/SystemSettingsPage/components/EmailSettingsTab.tsx
Normal file
440
web/src/pages/SystemSettingsPage/components/EmailSettingsTab.tsx
Normal file
@@ -0,0 +1,440 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Descriptions,
|
||||
Divider,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Typography,
|
||||
message,
|
||||
Tag,
|
||||
Skeleton,
|
||||
} from 'antd';
|
||||
import { HighlightOutlined, EyeOutlined, SaveOutlined, SendOutlined, InfoCircleOutlined } from '@ant-design/icons';
|
||||
import { useI18n } from '../../../i18n';
|
||||
import {
|
||||
sendTestEmail,
|
||||
getEmailTemplate,
|
||||
updateEmailTemplate,
|
||||
previewEmailTemplate,
|
||||
} from '../../../api/email';
|
||||
|
||||
interface EmailSettingsTabProps {
|
||||
config: Record<string, string>;
|
||||
loading: boolean;
|
||||
onSave: (values: Record<string, unknown>) => Promise<void>;
|
||||
}
|
||||
|
||||
interface EmailFormValues {
|
||||
host: string;
|
||||
port: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
sender_name?: string;
|
||||
sender_email: string;
|
||||
security: 'none' | 'ssl' | 'starttls';
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
interface TestFormValues {
|
||||
to: string;
|
||||
subject: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
interface PreviewContext extends Record<string, unknown> {
|
||||
username: string;
|
||||
reset_link: string;
|
||||
expire_minutes: number;
|
||||
}
|
||||
|
||||
const DEFAULT_FORM: EmailFormValues = {
|
||||
host: '',
|
||||
port: 465,
|
||||
username: '',
|
||||
password: '',
|
||||
sender_name: '',
|
||||
sender_email: '',
|
||||
security: 'ssl',
|
||||
timeout: 30,
|
||||
};
|
||||
|
||||
const TEMPLATE_NAME = 'password_reset';
|
||||
|
||||
function parseEmailConfig(raw?: string | null): EmailFormValues {
|
||||
if (!raw) return { ...DEFAULT_FORM };
|
||||
try {
|
||||
const data = JSON.parse(raw) as Partial<EmailFormValues>;
|
||||
return {
|
||||
...DEFAULT_FORM,
|
||||
...data,
|
||||
port: Number(data?.port ?? DEFAULT_FORM.port),
|
||||
timeout: data?.timeout !== undefined ? Number(data.timeout) : DEFAULT_FORM.timeout,
|
||||
security: (data?.security ?? DEFAULT_FORM.security) as EmailFormValues['security'],
|
||||
};
|
||||
} catch (_err) {
|
||||
return { ...DEFAULT_FORM };
|
||||
}
|
||||
}
|
||||
|
||||
export default function EmailSettingsTab({ config, loading, onSave }: EmailSettingsTabProps) {
|
||||
const { t } = useI18n();
|
||||
const [testForm] = Form.useForm<TestFormValues>();
|
||||
const [previewForm] = Form.useForm<PreviewContext>();
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [template, setTemplate] = useState<string>('');
|
||||
const [templateLoading, setTemplateLoading] = useState(true);
|
||||
const [templateSaving, setTemplateSaving] = useState(false);
|
||||
const [previewing, setPreviewing] = useState(false);
|
||||
const [previewHtml, setPreviewHtml] = useState<string>('');
|
||||
|
||||
const initialValues = useMemo(() => parseEmailConfig(config?.EMAIL_CONFIG), [config]);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const parsed = parseEmailConfig(config?.EMAIL_CONFIG);
|
||||
return [
|
||||
{ label: t('SMTP Host'), value: parsed.host || '-' },
|
||||
{ label: t('SMTP Port'), value: parsed.port || '-' },
|
||||
{ label: t('Security'), value: parsed.security.toUpperCase() },
|
||||
{ label: t('Sender Email'), value: parsed.sender_email || '-' },
|
||||
{ label: t('Sender Name'), value: parsed.sender_name || t('Not set') },
|
||||
{ label: t('Timeout (seconds)'), value: parsed.timeout || '-' },
|
||||
];
|
||||
}, [config, t]);
|
||||
|
||||
useEffect(() => {
|
||||
setTemplateLoading(true);
|
||||
getEmailTemplate(TEMPLATE_NAME)
|
||||
.then((res) => setTemplate(res.content))
|
||||
.catch((err) => {
|
||||
message.error(err?.message || t('Failed to load template'));
|
||||
})
|
||||
.finally(() => setTemplateLoading(false));
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
previewForm.setFieldsValue({
|
||||
username: 'Foxel 用户',
|
||||
reset_link: 'https://foxel.cc/reset-password?token=demo',
|
||||
expire_minutes: 10,
|
||||
});
|
||||
}, [previewForm]);
|
||||
|
||||
const handleSaveConfig = async (values: EmailFormValues) => {
|
||||
if (!values.host || !values.port || !values.sender_email) {
|
||||
message.error(t('Please complete all required fields'));
|
||||
return;
|
||||
}
|
||||
const payload: Record<string, unknown> = {
|
||||
host: values.host.trim(),
|
||||
port: Number(values.port),
|
||||
sender_email: values.sender_email.trim(),
|
||||
security: values.security,
|
||||
};
|
||||
if (!Number.isFinite(payload.port as number) || (payload.port as number) <= 0) {
|
||||
message.error(t('SMTP port must be a positive number'));
|
||||
return;
|
||||
}
|
||||
if (values.username?.trim()) {
|
||||
payload.username = values.username.trim();
|
||||
}
|
||||
if (values.password?.length) {
|
||||
payload.password = values.password;
|
||||
}
|
||||
if (values.sender_name?.trim()) {
|
||||
payload.sender_name = values.sender_name.trim();
|
||||
}
|
||||
if (values.timeout !== undefined && values.timeout !== null) {
|
||||
const timeoutNumber = Number(values.timeout);
|
||||
if (Number.isFinite(timeoutNumber) && timeoutNumber > 0) {
|
||||
payload.timeout = timeoutNumber;
|
||||
}
|
||||
}
|
||||
await onSave({ EMAIL_CONFIG: JSON.stringify(payload) });
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
try {
|
||||
const values = await testForm.validateFields();
|
||||
setTesting(true);
|
||||
const response = await sendTestEmail({
|
||||
to: values.to,
|
||||
subject: values.subject,
|
||||
template: 'test',
|
||||
context: { username: values.username || values.to },
|
||||
});
|
||||
message.success(t('Test email queued (task {{taskId}})', { taskId: response.task_id }));
|
||||
} catch (err: any) {
|
||||
if (err?.errorFields) {
|
||||
return;
|
||||
}
|
||||
message.error(err?.message || t('Test email failed'));
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviewTemplate = async () => {
|
||||
try {
|
||||
const values = await previewForm.validateFields();
|
||||
setPreviewing(true);
|
||||
const res = await previewEmailTemplate(TEMPLATE_NAME, values);
|
||||
setPreviewHtml(res.html);
|
||||
} catch (err: any) {
|
||||
if (err?.errorFields) return;
|
||||
message.error(err?.message || t('Preview failed'));
|
||||
} finally {
|
||||
setPreviewing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveTemplate = async () => {
|
||||
setTemplateSaving(true);
|
||||
try {
|
||||
await updateEmailTemplate(TEMPLATE_NAME, template);
|
||||
message.success(t('Template saved'));
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || t('Failed to save template'));
|
||||
} finally {
|
||||
setTemplateSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={32} style={{ width: '100%', marginTop: 24 }}>
|
||||
<Row gutter={24}>
|
||||
<Col xs={24} lg={15}>
|
||||
<Card
|
||||
title={t('SMTP Settings')}
|
||||
extra={<InfoCircleOutlined style={{ color: 'var(--ant-color-primary)' }} />}
|
||||
bodyStyle={{ paddingBottom: 12 }}
|
||||
>
|
||||
<Form<EmailFormValues>
|
||||
layout="vertical"
|
||||
initialValues={initialValues}
|
||||
onFinish={handleSaveConfig}
|
||||
key={'email-settings-' + (config?.EMAIL_CONFIG ?? '')}
|
||||
>
|
||||
<Row gutter={16}>
|
||||
<Col span={14}>
|
||||
<Form.Item
|
||||
name="host"
|
||||
label={t('SMTP Host')}
|
||||
rules={[{ required: true, message: t('Please input SMTP host') }]}
|
||||
>
|
||||
<Input size="large" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={10}>
|
||||
<Form.Item
|
||||
name="port"
|
||||
label={t('SMTP Port')}
|
||||
rules={[{ required: true, message: t('Please input SMTP port') }]}
|
||||
>
|
||||
<InputNumber min={1} style={{ width: '100%' }} size="large" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="security" label={t('Security')}>
|
||||
<Select
|
||||
size="large"
|
||||
options={[
|
||||
{ value: 'none', label: t('None') },
|
||||
{ value: 'ssl', label: 'SSL' },
|
||||
{ value: 'starttls', label: 'STARTTLS' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="timeout" label={t('Timeout (seconds)')}>
|
||||
<InputNumber min={1} style={{ width: '100%' }} size="large" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Divider />
|
||||
<Form.Item name="sender_name" label={t('Sender Name')}>
|
||||
<Input size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="sender_email"
|
||||
label={t('Sender Email')}
|
||||
rules={[
|
||||
{ required: true, message: t('Please input sender email') },
|
||||
{ type: 'email', message: t('Please input a valid email!') },
|
||||
]}
|
||||
>
|
||||
<Input size="large" />
|
||||
</Form.Item>
|
||||
<Divider />
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="username" label={t('SMTP Username')}>
|
||||
<Input size="large" autoComplete="username" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="password" label={t('SMTP Password')}>
|
||||
<Input.Password size="large" autoComplete="current-password" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item style={{ marginTop: 24 }}>
|
||||
<Button type="primary" htmlType="submit" loading={loading} icon={<SaveOutlined />} block>
|
||||
{t('Save')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={9}>
|
||||
<Space direction="vertical" size={24} style={{ width: '100%' }}>
|
||||
<Card title={t('Current Configuration')} bodyStyle={{ paddingBottom: 12 }}>
|
||||
<Descriptions column={1} size="small" colon={false}>
|
||||
{summary.map(item => (
|
||||
<Descriptions.Item key={item.label} label={<TextLabel text={item.label} />}>
|
||||
<Typography.Text strong>{item.value}</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
</Card>
|
||||
<Card title={t('Test Email')} extra={<SendOutlined style={{ color: 'var(--ant-color-primary)' }} />}>
|
||||
<Form<TestFormValues>
|
||||
form={testForm}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
subject: t('Foxel Mail Test'),
|
||||
username: '',
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name="to"
|
||||
label={t('Recipient Address')}
|
||||
rules={[
|
||||
{ required: true, message: t('Please input recipient email') },
|
||||
{ type: 'email', message: t('Please input a valid email!') },
|
||||
]}
|
||||
>
|
||||
<Input size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="subject" label={t('Test Subject')}>
|
||||
<Input size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="username" label={t('Test User Name')}>
|
||||
<Input size="large" placeholder={t('Optional')} />
|
||||
</Form.Item>
|
||||
<Button type="primary" onClick={handleTest} loading={testing} block icon={<SendOutlined />}>
|
||||
{t('Send Test Email')}
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<HighlightOutlined />
|
||||
{t('Password Reset Template')}
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Space>
|
||||
<Button icon={<EyeOutlined />} onClick={handlePreviewTemplate} loading={previewing}>
|
||||
{t('Preview')}
|
||||
</Button>
|
||||
<Button type="primary" icon={<SaveOutlined />} onClick={handleSaveTemplate} loading={templateSaving}>
|
||||
{t('Save')}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Row gutter={24}>
|
||||
<Col xs={24} lg={14}>
|
||||
{templateLoading ? (
|
||||
<Skeleton active paragraph={{ rows: 8 }} />
|
||||
) : (
|
||||
<Input.TextArea
|
||||
value={template}
|
||||
onChange={(e) => setTemplate(e.target.value)}
|
||||
autoSize={{ minRows: 20 }}
|
||||
style={{ fontFamily: 'monospace', background: '#0f172a', color: '#e2e8f0' }}
|
||||
/>
|
||||
)}
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Typography.Text type="secondary">{t('Available variables')}:</Typography.Text>
|
||||
<Space wrap style={{ marginTop: 8 }}>
|
||||
<Tag color="blue">${'{username}'}</Tag>
|
||||
<Tag color="blue">${'{reset_link}'}</Tag>
|
||||
<Tag color="blue">${'{expire_minutes}'}</Tag>
|
||||
</Space>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} lg={10}>
|
||||
<Card title={t('Preview Context')} size="small" style={{ marginBottom: 16 }}>
|
||||
<Form<PreviewContext> layout="vertical" form={previewForm}>
|
||||
<Form.Item
|
||||
name="username"
|
||||
label="username"
|
||||
rules={[{ required: true, message: t('Please complete all required fields') }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="reset_link"
|
||||
label="reset_link"
|
||||
rules={[{ required: true, message: t('Please complete all required fields') }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="expire_minutes"
|
||||
label="expire_minutes"
|
||||
rules={[{ required: true, message: t('Please complete all required fields') }]}
|
||||
>
|
||||
<InputNumber min={1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
<Card title={t('Live Preview')} size="small" className="email-template-preview">
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid rgba(148,163,184,0.2)',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
height: 360,
|
||||
background: '#f8fafc',
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
title="email-preview"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
backgroundColor: '#f8fafc',
|
||||
}}
|
||||
srcDoc={previewHtml || template}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
function TextLabel({ text }: { text: string }) {
|
||||
return <Typography.Text type="secondary">{text}</Typography.Text>;
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import LayoutShell from './LayoutShell.tsx';
|
||||
import LoginPage from '../pages/LoginPage.tsx';
|
||||
import SetupPage from '../pages/SetupPage.tsx';
|
||||
import PublicSharePage from '../pages/PublicSharePage';
|
||||
import ForgotPasswordPage from '../pages/ForgotPasswordPage';
|
||||
import ResetPasswordPage from '../pages/ResetPasswordPage';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import type { JSX } from 'react';
|
||||
|
||||
@@ -13,12 +15,16 @@ export const routes: RouteObject[] = [
|
||||
{ path: '/login', element: <LoginPage /> },
|
||||
{ path: '/share/:token', element: <PublicSharePage /> },
|
||||
{ path: '/setup', element: <SetupPage /> },
|
||||
{ path: '/forgot-password', element: <ForgotPasswordPage /> },
|
||||
{ path: '/reset-password', element: <ResetPasswordPage /> },
|
||||
];
|
||||
|
||||
function RequireAuth({ children }: { children: JSX.Element }) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const location = useLocation();
|
||||
if (!isAuthenticated && !location.pathname.startsWith('/share/') && location.pathname !== '/login' && location.pathname !== '/register') {
|
||||
const publicPaths = ['/login', '/register', '/forgot-password', '/reset-password'];
|
||||
const isPublic = publicPaths.some((p) => location.pathname.startsWith(p));
|
||||
if (!isAuthenticated && !location.pathname.startsWith('/share/') && !isPublic) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
return children;
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
.fx-settings-tabs .ant-tabs-nav {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.fx-settings-tabs .ant-tabs-nav-list {
|
||||
padding: 8px 4px;
|
||||
padding: 4px 8px;
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.fx-settings-tabs .ant-tabs-tab {
|
||||
margin: 4px 0 !important;
|
||||
margin: 0 !important;
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px !important;
|
||||
padding: 8px 12px !important;
|
||||
flex: 1 1 auto;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.fx-settings-tabs .ant-tabs-tab .ant-tabs-tab-btn {
|
||||
|
||||
Reference in New Issue
Block a user