Compare commits

...

17 Commits

Author SHA1 Message Date
shiyu
17236e601f chore: update version to v1.3.5 2025-11-06 15:54:20 +08:00
shiyu
71e5f84eb7 style: adjust tab spacing and padding in System Settings page 2025-11-06 15:46:40 +08:00
shiyu
4e724b9c4a feat: add password reset functionality with email templates 2025-11-06 15:31:13 +08:00
时雨
ba62bd0d4a chore: add custom sponsorship URL to FUNDING.yml
Updated the funding model to include a custom sponsorship URL.
2025-10-28 17:43:54 +08:00
ShiYu
138296e5a6 feat: add favicon configuration 2025-10-28 11:01:46 +08:00
ShiYu
51326dea08 chore: update version to v1.3.4 2025-10-22 13:36:27 +08:00
ShiYu
ac6d8ff7ad fix: handle create-root request gracefully in write_file_stream 2025-10-22 13:10:19 +08:00
ShiYu
029aa2574d feat: optimize skeleton screen animations 2025-10-22 10:30:19 +08:00
ShiYu
eeb0e6aa70 feat: add issue templates for bug reports, feature requests, and questions 2025-10-21 14:01:54 +08:00
ShiYu
d1ceb7ddba fix: update gravatar URL to use cn.cravatar.com 2025-10-21 12:20:53 +08:00
ShiYu
63b54458e9 chore: update version to v1.3.3 2025-10-20 17:48:34 +08:00
ShiYu
f7e6815265 feat: enhance file upload functionality 2025-10-20 17:46:37 +08:00
ShiYu
4d6e0b86ad feat: add LoadingSkeleton component 2025-10-19 17:17:03 +08:00
ShiYu
77a4749fec fix: ensure main branch is specified in Docker workflow triggers 2025-10-18 16:56:38 +08:00
ShiYu
8eaa025f7e feat: add video thumbnail support and update 2025-10-18 16:34:48 +08:00
ShiYu
11799cd97c feat: rename is_image to has_thumbnail in VfsEntry and update related logic 2025-10-18 16:12:55 +08:00
ShiYu
c14224827d feat: enhance list_virtual_dir to support sorting and improved entry annotation 2025-10-18 15:18:16 +08:00
48 changed files with 3122 additions and 217 deletions

1
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1 @@
custom: https://foxel.cc/sponsor.html

75
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View 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

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

View File

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

View File

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

View File

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

View File

@@ -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="密码已重置")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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
View 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
View File

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

View File

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

View File

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

View File

@@ -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
View 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 },
});
}

View File

@@ -6,7 +6,7 @@ export interface VfsEntry {
size: number;
mtime: number;
type?: string;
is_image?: boolean;
has_thumbnail?: boolean;
}
export interface DirListing {

View File

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

View File

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

View File

@@ -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': '系统初始化',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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>
);
}

View File

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

View 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>
);
}

View File

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

View 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>;
}

View File

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

View File

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