feature:新增邮箱注册登录功能

This commit is contained in:
xiamuceer
2026-03-20 11:06:25 +08:00
parent 7df6f52a9e
commit d72dd1c555
16 changed files with 2074 additions and 349 deletions
+9 -8
View File
@@ -8,6 +8,7 @@ on:
env:
DOCKER_IMAGE: mumujie/mumuainovel
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
build-and-push:
@@ -15,29 +16,29 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
with:
platforms: linux/amd64,linux/arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
with:
driver-opts: |
image=moby/buildkit:master
network=host
- name: Login to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: ${{ env.DOCKER_IMAGE }}
tags: |
@@ -53,7 +54,7 @@ jobs:
type=sha,prefix=sha-
- name: Build and push Docker image
uses: docker/build-push-action@v5
uses: docker/build-push-action@v7
with:
context: .
file: ./Dockerfile
@@ -68,10 +69,10 @@ jobs:
- name: Update Docker Hub Description
if: startsWith(github.ref, 'refs/tags/v')
uses: peter-evans/dockerhub-description@v4
uses: peter-evans/dockerhub-description@v5
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
repository: ${{ env.DOCKER_IMAGE }}
readme-filepath: ./README.md
continue-on-error: true
continue-on-error: true
+17
View File
@@ -93,6 +93,23 @@ LOCAL_AUTH_DISPLAY_NAME=本地管理员
SESSION_EXPIRE_MINUTES=120
SESSION_REFRESH_THRESHOLD_MINUTES=30
# ==========================================
# SMTP 默认配置(可在系统设置中被管理员覆盖)
# ==========================================
SMTP_PROVIDER=qq
SMTP_HOST=smtp.qq.com
SMTP_PORT=465
SMTP_USERNAME=your-email@qq.com
SMTP_PASSWORD=your-qq-smtp-auth-code
SMTP_USE_TLS=false
SMTP_USE_SSL=true
SMTP_FROM_EMAIL=your-email@qq.com
SMTP_FROM_NAME=MuMuAINovel
EMAIL_AUTH_ENABLED=true
EMAIL_REGISTER_ENABLED=true
EMAIL_VERIFICATION_CODE_TTL_MINUTES=10
EMAIL_VERIFICATION_RESEND_INTERVAL_SECONDS=60
# ==========================================
# 提示词工坊配置
# ==========================================
@@ -0,0 +1,64 @@
"""新增stmp相关配置
Revision ID: 6eb27fce64de
Revises: 8e3ac0236b27
Create Date: 2026-03-20 09:36:42.899296
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '6eb27fce64de'
down_revision: Union[str, None] = '8e3ac0236b27'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('settings', sa.Column('smtp_provider', sa.String(length=50), server_default='qq', nullable=False, comment='SMTP 提供商'))
op.add_column('settings', sa.Column('smtp_host', sa.String(length=255), nullable=True, comment='SMTP 主机'))
op.add_column('settings', sa.Column('smtp_port', sa.Integer(), server_default='465', nullable=False, comment='SMTP 端口'))
op.add_column('settings', sa.Column('smtp_username', sa.String(length=255), nullable=True, comment='SMTP 用户名'))
op.add_column('settings', sa.Column('smtp_password', sa.String(length=500), nullable=True, comment='SMTP 密码或授权码'))
op.add_column('settings', sa.Column('smtp_use_tls', sa.Boolean(), server_default='0', nullable=False, comment='是否启用 TLS'))
op.add_column('settings', sa.Column('smtp_use_ssl', sa.Boolean(), server_default='1', nullable=False, comment='是否启用 SSL'))
op.add_column('settings', sa.Column('smtp_from_email', sa.String(length=255), nullable=True, comment='发件人邮箱'))
op.add_column('settings', sa.Column('smtp_from_name', sa.String(length=255), server_default='MuMuAINovel', nullable=False, comment='发件人名称'))
op.add_column('settings', sa.Column('email_auth_enabled', sa.Boolean(), server_default='1', nullable=False, comment='是否启用邮箱认证'))
op.add_column('settings', sa.Column('email_register_enabled', sa.Boolean(), server_default='1', nullable=False, comment='是否启用邮箱注册'))
op.add_column('settings', sa.Column('verification_code_ttl_minutes', sa.Integer(), server_default='10', nullable=False, comment='验证码有效期(分钟)'))
op.add_column('settings', sa.Column('verification_resend_interval_seconds', sa.Integer(), server_default='60', nullable=False, comment='验证码重发间隔(秒)'))
op.alter_column('settings', 'cover_enabled',
existing_type=sa.BOOLEAN(),
server_default='0',
existing_comment='是否启用封面图片生成',
existing_nullable=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('settings', 'cover_enabled',
existing_type=sa.BOOLEAN(),
server_default=None,
existing_comment='是否启用封面图片生成',
existing_nullable=False)
op.drop_column('settings', 'verification_resend_interval_seconds')
op.drop_column('settings', 'verification_code_ttl_minutes')
op.drop_column('settings', 'email_register_enabled')
op.drop_column('settings', 'email_auth_enabled')
op.drop_column('settings', 'smtp_from_name')
op.drop_column('settings', 'smtp_from_email')
op.drop_column('settings', 'smtp_use_ssl')
op.drop_column('settings', 'smtp_use_tls')
op.drop_column('settings', 'smtp_password')
op.drop_column('settings', 'smtp_username')
op.drop_column('settings', 'smtp_port')
op.drop_column('settings', 'smtp_host')
op.drop_column('settings', 'smtp_provider')
# ### end Alembic commands ###
@@ -0,0 +1,70 @@
"""新增stmp相关配置
Revision ID: 6ff45db05863
Revises: 17ce752ed7cc
Create Date: 2026-03-20 09:39:23.544434
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '6ff45db05863'
down_revision: Union[str, None] = '17ce752ed7cc'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('projects', schema=None) as batch_op:
batch_op.alter_column('cover_status',
existing_type=sa.VARCHAR(length=20),
server_default=None,
existing_nullable=False)
with op.batch_alter_table('settings', schema=None) as batch_op:
batch_op.add_column(sa.Column('smtp_provider', sa.String(length=50), server_default='qq', nullable=False, comment='SMTP 提供商'))
batch_op.add_column(sa.Column('smtp_host', sa.String(length=255), nullable=True, comment='SMTP 主机'))
batch_op.add_column(sa.Column('smtp_port', sa.Integer(), server_default='465', nullable=False, comment='SMTP 端口'))
batch_op.add_column(sa.Column('smtp_username', sa.String(length=255), nullable=True, comment='SMTP 用户名'))
batch_op.add_column(sa.Column('smtp_password', sa.String(length=500), nullable=True, comment='SMTP 密码或授权码'))
batch_op.add_column(sa.Column('smtp_use_tls', sa.Boolean(), server_default='0', nullable=False, comment='是否启用 TLS'))
batch_op.add_column(sa.Column('smtp_use_ssl', sa.Boolean(), server_default='1', nullable=False, comment='是否启用 SSL'))
batch_op.add_column(sa.Column('smtp_from_email', sa.String(length=255), nullable=True, comment='发件人邮箱'))
batch_op.add_column(sa.Column('smtp_from_name', sa.String(length=255), server_default='MuMuAINovel', nullable=False, comment='发件人名称'))
batch_op.add_column(sa.Column('email_auth_enabled', sa.Boolean(), server_default='1', nullable=False, comment='是否启用邮箱认证'))
batch_op.add_column(sa.Column('email_register_enabled', sa.Boolean(), server_default='1', nullable=False, comment='是否启用邮箱注册'))
batch_op.add_column(sa.Column('verification_code_ttl_minutes', sa.Integer(), server_default='10', nullable=False, comment='验证码有效期(分钟)'))
batch_op.add_column(sa.Column('verification_resend_interval_seconds', sa.Integer(), server_default='60', nullable=False, comment='验证码重发间隔(秒)'))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('settings', schema=None) as batch_op:
batch_op.drop_column('verification_resend_interval_seconds')
batch_op.drop_column('verification_code_ttl_minutes')
batch_op.drop_column('email_register_enabled')
batch_op.drop_column('email_auth_enabled')
batch_op.drop_column('smtp_from_name')
batch_op.drop_column('smtp_from_email')
batch_op.drop_column('smtp_use_ssl')
batch_op.drop_column('smtp_use_tls')
batch_op.drop_column('smtp_password')
batch_op.drop_column('smtp_username')
batch_op.drop_column('smtp_port')
batch_op.drop_column('smtp_host')
batch_op.drop_column('smtp_provider')
with op.batch_alter_table('projects', schema=None) as batch_op:
batch_op.alter_column('cover_status',
existing_type=sa.VARCHAR(length=20),
server_default=sa.text("'none'"),
existing_nullable=False)
# ### end Alembic commands ###
+531 -209
View File
File diff suppressed because it is too large Load Diff
+152 -1
View File
@@ -18,12 +18,14 @@ from app.services.cover_generation_service import cover_generation_service
from app.schemas.settings import (
SettingsCreate, SettingsUpdate, SettingsResponse,
APIKeyPreset, APIKeyPresetConfig, PresetCreateRequest,
PresetUpdateRequest, PresetResponse, PresetListResponse
PresetUpdateRequest, PresetResponse, PresetListResponse,
SystemSMTPSettingsResponse, SystemSMTPSettingsUpdate, SMTPTestRequest
)
from app.user_manager import User
from app.logger import get_logger
from app.config import settings as app_settings, PROJECT_ROOT
from app.services.ai_service import AIService, create_user_ai_service, create_user_ai_service_with_mcp, normalize_provider
from app.services.email_service import email_service
logger = get_logger(__name__)
@@ -56,6 +58,46 @@ def require_login(request: Request):
return request.state.user
def require_admin(user: User = Depends(require_login)):
"""依赖:要求管理员权限"""
if not user.is_admin:
raise HTTPException(status_code=403, detail="仅管理员可访问系统设置")
return user
async def get_or_create_admin_settings(db: AsyncSession, user: User) -> Settings:
"""获取或创建管理员设置,系统级 SMTP 配置挂在管理员设置记录上"""
result = await db.execute(
select(Settings).where(Settings.user_id == user.user_id)
)
settings = result.scalar_one_or_none()
if not settings:
env_defaults = read_env_defaults()
settings = Settings(
user_id=user.user_id,
smtp_provider=app_settings.SMTP_PROVIDER,
smtp_host=app_settings.SMTP_HOST,
smtp_port=app_settings.SMTP_PORT,
smtp_username=app_settings.SMTP_USERNAME,
smtp_password=app_settings.SMTP_PASSWORD,
smtp_use_tls=app_settings.SMTP_USE_TLS,
smtp_use_ssl=app_settings.SMTP_USE_SSL,
smtp_from_email=app_settings.SMTP_FROM_EMAIL,
smtp_from_name=app_settings.SMTP_FROM_NAME,
email_auth_enabled=app_settings.EMAIL_AUTH_ENABLED,
email_register_enabled=app_settings.EMAIL_REGISTER_ENABLED,
verification_code_ttl_minutes=app_settings.EMAIL_VERIFICATION_CODE_TTL_MINUTES,
verification_resend_interval_seconds=app_settings.EMAIL_VERIFICATION_RESEND_INTERVAL_SECONDS,
**env_defaults
)
db.add(settings)
await db.commit()
await db.refresh(settings)
return settings
async def get_user_ai_service(
user: User = Depends(require_login),
db: AsyncSession = Depends(get_db)
@@ -169,6 +211,115 @@ async def test_cover_settings(
}
@router.get("/system/smtp", response_model=SystemSMTPSettingsResponse)
async def get_system_smtp_settings(
user: User = Depends(require_admin),
db: AsyncSession = Depends(get_db)
):
"""获取系统 SMTP 设置(仅管理员)"""
settings = await get_or_create_admin_settings(db, user)
return settings
@router.put("/system/smtp", response_model=SystemSMTPSettingsResponse)
async def update_system_smtp_settings(
data: SystemSMTPSettingsUpdate,
user: User = Depends(require_admin),
db: AsyncSession = Depends(get_db)
):
"""更新系统 SMTP 设置(仅管理员)"""
settings = await get_or_create_admin_settings(db, user)
update_data = data.model_dump(exclude_unset=True)
if update_data.get("smtp_provider") == "qq":
update_data.setdefault("smtp_host", "smtp.qq.com")
update_data.setdefault("smtp_port", 465)
update_data.setdefault("smtp_use_ssl", True)
update_data.setdefault("smtp_use_tls", False)
if update_data.get("smtp_use_ssl") and update_data.get("smtp_use_tls"):
raise HTTPException(status_code=400, detail="SSL 和 TLS 不能同时启用")
for key, value in update_data.items():
setattr(settings, key, value)
await db.commit()
await db.refresh(settings)
logger.info(f"管理员 {user.user_id} 更新系统 SMTP 设置")
return settings
@router.post("/system/smtp/test")
async def test_system_smtp_settings(
data: SMTPTestRequest,
user: User = Depends(require_admin),
db: AsyncSession = Depends(get_db)
):
"""测试系统 SMTP 设置(真实发送测试邮件)"""
settings = await get_or_create_admin_settings(db, user)
if not settings.smtp_host or not settings.smtp_username or not settings.smtp_password:
raise HTTPException(status_code=400, detail="请先完善 SMTP 主机、用户名和授权码")
if settings.smtp_provider == "qq" and settings.smtp_host != "smtp.qq.com":
raise HTTPException(status_code=400, detail="QQ 邮箱 SMTP 主机必须为 smtp.qq.com")
if "@" not in data.to_email or "." not in data.to_email.split("@")[-1]:
raise HTTPException(status_code=400, detail="测试收件邮箱格式不正确")
from_email = settings.smtp_from_email or settings.smtp_username
if not from_email:
raise HTTPException(status_code=400, detail="请先配置发件人邮箱或 SMTP 用户名")
subject = "MuMuAINovel SMTP 测试邮件"
text_body = (
"这是一封来自 MuMuAINovel 系统设置页面的 SMTP 测试邮件。\n\n"
f"发送时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
f"SMTP 服务商:{settings.smtp_provider}\n"
f"SMTP 主机:{settings.smtp_host}:{settings.smtp_port}\n"
"如果你收到这封邮件,说明当前 SMTP 配置可正常发送邮件。"
)
html_body = f"""
<div style=\"font-family: Arial, sans-serif; line-height: 1.7; color: #1f1f1f;\">
<h2 style=\"margin-bottom: 12px;\">MuMuAINovel SMTP 测试邮件</h2>
<p>这是一封来自系统设置页面的 SMTP 测试邮件。</p>
<ul>
<li><strong>发送时间:</strong>{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</li>
<li><strong>SMTP 服务商:</strong>{settings.smtp_provider}</li>
<li><strong>SMTP 主机:</strong>{settings.smtp_host}:{settings.smtp_port}</li>
</ul>
<p>如果你收到这封邮件,说明当前 SMTP 配置可正常发送邮件。</p>
</div>
"""
try:
await email_service.send_mail(
host=settings.smtp_host,
port=settings.smtp_port,
username=settings.smtp_username,
password=settings.smtp_password,
use_tls=settings.smtp_use_tls,
use_ssl=settings.smtp_use_ssl,
from_email=from_email,
from_name=settings.smtp_from_name,
to_email=data.to_email,
subject=subject,
text_body=text_body,
html_body=html_body,
)
except Exception as exc:
logger.exception(f"SMTP 测试邮件发送失败: {exc}")
raise HTTPException(status_code=400, detail=f"SMTP 测试邮件发送失败: {str(exc)}") from exc
return {
"success": True,
"message": f"测试邮件已发送至 {data.to_email},请检查收件箱和垃圾箱",
"provider": settings.smtp_provider,
"host": settings.smtp_host,
"port": settings.smtp_port,
}
@router.post("", response_model=SettingsResponse)
async def save_settings(
data: SettingsCreate,
+15
View File
@@ -106,6 +106,21 @@ class Settings(BaseSettings):
# 会话配置
SESSION_EXPIRE_MINUTES: int = 120 # 会话过期时间(分钟),默认2小时
SESSION_REFRESH_THRESHOLD_MINUTES: int = 30 # 会话刷新阈值(分钟),剩余时间少于此值时可刷新
# 系统 SMTP 默认配置(可被管理员系统设置覆盖)
SMTP_PROVIDER: str = "qq"
SMTP_HOST: Optional[str] = "smtp.qq.com"
SMTP_PORT: int = 465
SMTP_USERNAME: Optional[str] = None
SMTP_PASSWORD: Optional[str] = None
SMTP_USE_TLS: bool = False
SMTP_USE_SSL: bool = True
SMTP_FROM_EMAIL: Optional[str] = None
SMTP_FROM_NAME: str = "MuMuAINovel"
EMAIL_AUTH_ENABLED: bool = True
EMAIL_REGISTER_ENABLED: bool = True
EMAIL_VERIFICATION_CODE_TTL_MINUTES: int = 10
EMAIL_VERIFICATION_RESEND_INTERVAL_SECONDS: int = 60
# 提示词工坊配置
WORKSHOP_MODE: str = "client" # client: 本地部署实例, server: 云端中央服务器
+15
View File
@@ -26,6 +26,21 @@ class Settings(Base):
cover_image_model = Column(String(100), comment="封面图片模型名称")
cover_enabled = Column(Boolean, default=False, server_default="0", nullable=False, comment="是否启用封面图片生成")
# 系统级 SMTP 配置(仅管理员维护)
smtp_provider = Column(String(50), default="qq", server_default="qq", nullable=False, comment="SMTP 提供商")
smtp_host = Column(String(255), comment="SMTP 主机")
smtp_port = Column(Integer, default=465, server_default="465", nullable=False, comment="SMTP 端口")
smtp_username = Column(String(255), comment="SMTP 用户名")
smtp_password = Column(String(500), comment="SMTP 密码或授权码")
smtp_use_tls = Column(Boolean, default=False, server_default="0", nullable=False, comment="是否启用 TLS")
smtp_use_ssl = Column(Boolean, default=True, server_default="1", nullable=False, comment="是否启用 SSL")
smtp_from_email = Column(String(255), comment="发件人邮箱")
smtp_from_name = Column(String(255), default="MuMuAINovel", server_default="MuMuAINovel", nullable=False, comment="发件人名称")
email_auth_enabled = Column(Boolean, default=True, server_default="1", nullable=False, comment="是否启用邮箱认证")
email_register_enabled = Column(Boolean, default=True, server_default="1", nullable=False, comment="是否启用邮箱注册")
verification_code_ttl_minutes = Column(Integer, default=10, server_default="10", nullable=False, comment="验证码有效期(分钟)")
verification_resend_interval_seconds = Column(Integer, default=60, server_default="60", nullable=False, comment="验证码重发间隔(秒)")
preferences = Column(Text, comment="其他偏好设置(JSON)")
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
+41
View File
@@ -43,6 +43,47 @@ class SettingsResponse(SettingsBase):
updated_at: datetime
class SystemSMTPSettingsBase(BaseModel):
"""系统 SMTP 设置基础模型"""
model_config = ConfigDict(protected_namespaces=())
smtp_provider: str = Field(default="qq", description="SMTP 提供商")
smtp_host: Optional[str] = Field(default=None, description="SMTP 主机")
smtp_port: int = Field(default=465, ge=1, le=65535, description="SMTP 端口")
smtp_username: Optional[str] = Field(default=None, description="SMTP 用户名")
smtp_password: Optional[str] = Field(default=None, description="SMTP 密码或授权码")
smtp_use_tls: bool = Field(default=False, description="是否启用 TLS")
smtp_use_ssl: bool = Field(default=True, description="是否启用 SSL")
smtp_from_email: Optional[str] = Field(default=None, description="发件人邮箱")
smtp_from_name: str = Field(default="MuMuAINovel", description="发件人名称")
email_auth_enabled: bool = Field(default=True, description="是否启用邮箱认证")
email_register_enabled: bool = Field(default=True, description="是否启用邮箱注册")
verification_code_ttl_minutes: int = Field(default=10, ge=1, le=120, description="验证码有效期(分钟)")
verification_resend_interval_seconds: int = Field(default=60, ge=10, le=3600, description="验证码重发间隔(秒)")
class SystemSMTPSettingsUpdate(SystemSMTPSettingsBase):
"""系统 SMTP 设置更新模型"""
pass
class SystemSMTPSettingsResponse(SystemSMTPSettingsBase):
"""系统 SMTP 设置响应模型"""
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
id: str
user_id: str
created_at: datetime
updated_at: datetime
class SMTPTestRequest(BaseModel):
"""SMTP 测试请求模型"""
model_config = ConfigDict(protected_namespaces=())
to_email: str = Field(..., min_length=3, max_length=255, description="测试收件邮箱")
# ========== API配置预设相关模型 ==========
class APIKeyPresetConfig(BaseModel):
+71
View File
@@ -0,0 +1,71 @@
"""SMTP 邮件发送服务"""
from __future__ import annotations
from email.message import EmailMessage
from typing import Optional
import aiosmtplib
from app.logger import get_logger
logger = get_logger(__name__)
class EmailService:
"""系统 SMTP 邮件发送服务"""
async def send_mail(
self,
*,
host: str,
port: int,
username: str,
password: str,
use_tls: bool,
use_ssl: bool,
from_email: str,
from_name: str,
to_email: str,
subject: str,
text_body: str,
html_body: Optional[str] = None,
) -> None:
if use_tls and use_ssl:
raise ValueError("SMTP 配置错误:TLS 和 SSL 不能同时启用")
message = EmailMessage()
message['From'] = f'{from_name} <{from_email}>' if from_name else from_email
message['To'] = to_email
message['Subject'] = subject
message.set_content(text_body)
if html_body:
message.add_alternative(html_body, subtype='html')
logger.info(f"[SMTP] 准备发送测试邮件到 {self._mask_email(to_email)},服务器: {host}:{port}")
await aiosmtplib.send(
message,
hostname=host,
port=port,
username=username,
password=password,
use_tls=use_ssl,
start_tls=use_tls,
timeout=20,
)
logger.info(f"[SMTP] 测试邮件发送成功: {self._mask_email(to_email)}")
@staticmethod
def _mask_email(email: str) -> str:
if '@' not in email:
return email
name, domain = email.split('@', 1)
if len(name) <= 2:
masked_name = name[0] + '*'
else:
masked_name = name[0] + '*' * (len(name) - 2) + name[-1]
return f"{masked_name}@{domain}"
email_service = EmailService()
+1
View File
@@ -22,6 +22,7 @@ anthropic==0.72.0
httpx==0.28.1
python-dotenv==1.1.0
psutil==6.1.1
aiosmtplib==4.0.2
# MCP官方库(Model Context Protocol Python SDK
mcp==1.22.0
+674 -120
View File
@@ -1,46 +1,151 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Card, Col, Divider, Form, Input, Layout, Row, Space, Spin, Tag, Typography, message, theme } from 'antd';
import { BookOutlined, LockOutlined, RobotOutlined, SafetyCertificateOutlined, TeamOutlined, ThunderboltOutlined, UserOutlined } from '@ant-design/icons';
import { useEffect, useMemo, useState } from 'react';
import {
Alert,
Button,
Card,
Col,
Divider,
Form,
Input,
Layout,
Row,
Space,
Spin,
Tabs,
Tag,
Typography,
message,
theme,
} from 'antd';
import {
BookOutlined,
LockOutlined,
MailOutlined,
RobotOutlined,
SafetyCertificateOutlined,
TeamOutlined,
ThunderboltOutlined,
UserOutlined,
} from '@ant-design/icons';
import { authApi } from '../services/api';
import { useNavigate, useSearchParams } from 'react-router-dom';
import AnnouncementModal from '../components/AnnouncementModal';
import ThemeSwitch from '../components/ThemeSwitch';
const { Title, Paragraph } = Typography;
const { Title, Paragraph, Text } = Typography;
interface AuthConfig {
local_auth_enabled: boolean;
linuxdo_enabled: boolean;
email_auth_enabled: boolean;
email_register_enabled: boolean;
}
interface LocalLoginValues {
username: string;
password: string;
}
interface EmailLoginValues {
email: string;
code: string;
}
interface EmailRegisterValues {
email: string;
code: string;
password: string;
confirmPassword: string;
display_name?: string;
}
interface ResetPasswordValues {
email: string;
code: string;
new_password: string;
confirmNewPassword: string;
}
export default function Login() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [loading, setLoading] = useState(false);
const [checking, setChecking] = useState(true);
const [localAuthEnabled, setLocalAuthEnabled] = useState(false);
const [linuxdoEnabled, setLinuxdoEnabled] = useState(false);
const [form] = Form.useForm();
const [authConfig, setAuthConfig] = useState<AuthConfig>({
local_auth_enabled: false,
linuxdo_enabled: false,
email_auth_enabled: false,
email_register_enabled: false,
});
const [localForm] = Form.useForm<LocalLoginValues>();
const [emailLoginForm] = Form.useForm<EmailLoginValues>();
const [emailRegisterForm] = Form.useForm<EmailRegisterValues>();
const [resetPasswordForm] = Form.useForm<ResetPasswordValues>();
const { token } = theme.useToken();
const alphaColor = (color: string, alpha: number) => `color-mix(in srgb, ${color} ${(alpha * 100).toFixed(0)}%, transparent)`;
const primaryButtonShadow = `0 8px 20px ${alphaColor(token.colorPrimary, 0.28)}`;
const hoverButtonShadow = `0 12px 28px ${alphaColor(token.colorPrimary, 0.36)}`;
const [showAnnouncement, setShowAnnouncement] = useState(false);
const [loginCodeSending, setLoginCodeSending] = useState(false);
const [registerCodeSending, setRegisterCodeSending] = useState(false);
const [resetCodeSending, setResetCodeSending] = useState(false);
const [loginCountdown, setLoginCountdown] = useState(0);
const [registerCountdown, setRegisterCountdown] = useState(0);
const [resetCountdown, setResetCountdown] = useState(0);
const [showResetPassword, setShowResetPassword] = useState(false);
const localAuthEnabled = authConfig.local_auth_enabled;
const linuxdoEnabled = authConfig.linuxdo_enabled;
const emailAuthEnabled = authConfig.email_auth_enabled;
const emailRegisterEnabled = authConfig.email_register_enabled;
useEffect(() => {
const timers = [
{ value: loginCountdown, setter: setLoginCountdown },
{ value: registerCountdown, setter: setRegisterCountdown },
{ value: resetCountdown, setter: setResetCountdown },
].map(({ value, setter }) => {
if (value <= 0) {
return null;
}
return window.setInterval(() => {
setter((prev) => {
if (prev <= 1) {
return 0;
}
return prev - 1;
});
}, 1000);
});
return () => {
timers.forEach((timer) => {
if (timer) {
window.clearInterval(timer);
}
});
};
}, [loginCountdown, registerCountdown, resetCountdown]);
// 检查是否已登录和获取认证配置
useEffect(() => {
const checkAuth = async () => {
try {
await authApi.getCurrentUser();
// 已登录,重定向到首页
const redirect = searchParams.get('redirect') || '/';
navigate(redirect);
} catch {
// 未登录,获取认证配置
try {
const config = await authApi.getAuthConfig();
setLocalAuthEnabled(config.local_auth_enabled);
setLinuxdoEnabled(config.linuxdo_enabled);
setAuthConfig(config);
} catch (error) {
console.error('获取认证配置失败:', error);
// 默认显示LinuxDO登录
setLinuxdoEnabled(true);
setAuthConfig({
local_auth_enabled: false,
linuxdo_enabled: true,
email_auth_enabled: false,
email_register_enabled: false,
});
}
setChecking(false);
}
@@ -48,29 +153,131 @@ export default function Login() {
checkAuth();
}, [navigate, searchParams]);
const handleLocalLogin = async (values: { username: string; password: string }) => {
const handleLoginSuccess = () => {
message.success('登录成功!');
const hideForever = localStorage.getItem('announcement_hide_forever');
const hideToday = localStorage.getItem('announcement_hide_today');
const today = new Date().toDateString();
if (hideForever === 'true' || hideToday === today) {
const redirect = searchParams.get('redirect') || '/';
navigate(redirect);
} else {
setShowAnnouncement(true);
}
};
const handleLocalLogin = async (values: LocalLoginValues) => {
try {
setLoading(true);
const response = await authApi.localLogin(values.username, values.password);
if (response.success) {
message.success('登录成功!');
// 检查是否永久隐藏公告
const hideForever = localStorage.getItem('announcement_hide_forever');
const hideToday = localStorage.getItem('announcement_hide_today');
const today = new Date().toDateString();
// 如果永久隐藏或今日已隐藏,则不显示公告
if (hideForever === 'true' || hideToday === today) {
const redirect = searchParams.get('redirect') || '/';
navigate(redirect);
} else {
setShowAnnouncement(true);
}
handleLoginSuccess();
}
} catch (error) {
console.error('本地登录失败:', error);
} finally {
setLoading(false);
}
};
const handleEmailLogin = async (values: EmailLoginValues) => {
try {
setLoading(true);
const response = await authApi.emailLogin({
email: values.email,
code: values.code,
});
if (response.success) {
handleLoginSuccess();
}
} catch (error) {
console.error('邮箱验证码登录失败:', error);
} finally {
setLoading(false);
}
};
const sendLoginCode = async () => {
try {
const values = await emailLoginForm.validateFields(['email']);
setLoginCodeSending(true);
const result = await authApi.sendEmailCode({ email: values.email, scene: 'login' });
message.success(result.message || '验证码已发送');
setLoginCountdown(result.resend_interval_seconds || 60);
} catch (error) {
console.error('发送 login 验证码失败:', error);
} finally {
setLoginCodeSending(false);
}
};
const sendRegisterCode = async () => {
try {
const values = await emailRegisterForm.validateFields(['email']);
setRegisterCodeSending(true);
const result = await authApi.sendEmailCode({ email: values.email, scene: 'register' });
message.success(result.message || '验证码已发送');
setRegisterCountdown(result.resend_interval_seconds || 60);
} catch (error) {
console.error('发送 register 验证码失败:', error);
} finally {
setRegisterCodeSending(false);
}
};
const sendResetCode = async () => {
try {
const values = await resetPasswordForm.validateFields(['email']);
setResetCodeSending(true);
const result = await authApi.sendEmailCode({ email: values.email, scene: 'reset_password' });
message.success(result.message || '验证码已发送');
setResetCountdown(result.resend_interval_seconds || 60);
} catch (error) {
console.error('发送 reset_password 验证码失败:', error);
} finally {
setResetCodeSending(false);
}
};
const handleEmailRegister = async (values: EmailRegisterValues) => {
try {
setLoading(true);
const response = await authApi.emailRegister({
email: values.email,
code: values.code,
password: values.password,
display_name: values.display_name?.trim() || undefined,
});
if (response.success) {
message.success('注册成功,已自动登录');
emailRegisterForm.resetFields(['code', 'password', 'confirmPassword']);
setRegisterCountdown(0);
handleLoginSuccess();
}
} catch (error) {
console.error('邮箱注册失败:', error);
} finally {
setLoading(false);
}
};
const handleResetPassword = async (values: ResetPasswordValues) => {
try {
setLoading(true);
const result = await authApi.resetEmailPassword({
email: values.email,
code: values.code,
new_password: values.new_password,
});
message.success(result.message || '密码重置成功');
resetPasswordForm.resetFields(['code', 'new_password', 'confirmNewPassword']);
setResetCountdown(0);
setShowResetPassword(false);
} catch (error) {
console.error('重置密码失败:', error);
} finally {
setLoading(false);
}
};
@@ -80,13 +287,11 @@ export default function Login() {
setLoading(true);
const response = await authApi.getLinuxDOAuthUrl();
// 保存重定向地址到 sessionStorage
const redirect = searchParams.get('redirect');
if (redirect) {
sessionStorage.setItem('login_redirect', redirect);
}
// 跳转到 LinuxDO 授权页面
window.location.href = response.auth_url;
} catch (error) {
console.error('获取授权地址失败:', error);
@@ -95,53 +300,397 @@ export default function Login() {
}
};
if (checking) {
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
background: token.colorBgLayout,
}}>
<Spin size="large" style={{ color: token.colorPrimary }} />
</div>
);
}
const handleAnnouncementClose = () => {
setShowAnnouncement(false);
const redirect = searchParams.get('redirect') || '/';
navigate(redirect);
};
const handleDoNotShowToday = () => {
const today = new Date().toDateString();
localStorage.setItem('announcement_hide_today', today);
};
const handleNeverShow = () => {
localStorage.setItem('announcement_hide_forever', 'true');
};
const loginTips = useMemo(() => {
const tips = [
'首次 LinuxDO 登录会自动创建账号。',
];
if (localAuthEnabled) {
tips.unshift('本地登录默认账号:admin / admin123');
}
if (emailAuthEnabled) {
tips.push('邮箱注册用户支持通过邮箱验证码重置密码。');
}
return tips;
}, [emailAuthEnabled, localAuthEnabled]);
const featureItems = [
{
icon: <RobotOutlined />,
title: '多 AI 模型协同',
description: '支持 OpenAI、Gemini、Claude 等主流模型,按场景灵活切换。',
},
{
icon: <ThunderboltOutlined />,
title: '智能向导驱动',
description: '自动生成大纲、角色与世界观,快速搭建完整故事骨架。',
},
{
icon: <TeamOutlined />,
title: '角色组织管理',
description: '人物关系、组织架构可视化管理,复杂设定也能清晰掌控。',
},
{
icon: <BookOutlined />,
title: '章节创作闭环',
description: '支持章节生成、编辑、重写与润色,持续提升内容质量。',
},
];
// 渲染本地登录表单
const renderLocalLogin = () => (
<>
<Form
form={localForm}
layout="vertical"
onFinish={handleLocalLogin}
size="large"
style={{ marginTop: 16 }}
>
<Form.Item
name="username"
label="管理账号"
rules={[{ required: true, message: '请输入管理账号/邮箱' }]}
>
<Input
prefix={<UserOutlined style={{ color: token.colorTextTertiary }} />}
placeholder="请输入管理账号/邮箱"
autoComplete="username"
style={{ height: 46, borderRadius: 12 }}
/>
</Form.Item>
<Form.Item
name="password"
label="访问密钥"
rules={[{ required: true, message: '请输入访问密钥' }]}
>
<Input.Password
prefix={<LockOutlined style={{ color: token.colorTextTertiary }} />}
placeholder="请输入访问密钥"
autoComplete="current-password"
style={{ height: 46, borderRadius: 12 }}
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0, marginTop: 8 }}>
<Button
type="primary"
htmlType="submit"
loading={loading}
block
style={{
height: 46,
fontSize: 16,
fontWeight: 600,
background: `linear-gradient(90deg, ${token.colorPrimary} 0%, ${alphaColor(token.colorPrimary, 0.86)} 100%)`,
border: 'none',
borderRadius: '12px',
boxShadow: primaryButtonShadow,
}}
>
</Button>
</Form.Item>
</Form>
{linuxdoEnabled ? (
<>
<Divider style={{ margin: '18px 0 16px' }}></Divider>
{renderLinuxDOLogin()}
</>
) : null}
</>
);
const renderEmailLogin = () => {
if (showResetPassword) {
return (
<div style={{ marginTop: 16 }}>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Title level={5} style={{ margin: 0 }}> / </Title>
<Button type="link" style={{ paddingInline: 0 }} onClick={() => setShowResetPassword(false)}>
</Button>
</Space>
<Card size="small" bordered={false} style={{ borderRadius: 12, background: token.colorFillAlter }}>
<Form
form={resetPasswordForm}
layout="vertical"
onFinish={handleResetPassword}
size="middle"
>
<Form.Item
name="email"
label="注册邮箱"
rules={[
{ required: true, message: '请输入注册邮箱' },
{ type: 'email', message: '请输入有效的邮箱地址' },
]}
>
<Input prefix={<MailOutlined />} placeholder="请输入注册邮箱" />
</Form.Item>
<Form.Item label="重置验证码" required style={{ marginBottom: 12 }}>
<Space.Compact style={{ width: '100%' }}>
<Form.Item
name="code"
noStyle
rules={[
{ required: true, message: '请输入重置验证码' },
{ len: 6, message: '验证码长度为 6 位' },
]}
>
<Input placeholder="请输入重置验证码" maxLength={6} />
</Form.Item>
<Button
onClick={sendResetCode}
loading={resetCodeSending}
disabled={resetCountdown > 0}
>
{resetCountdown > 0 ? `${resetCountdown}s 后重发` : '发送验证码'}
</Button>
</Space.Compact>
</Form.Item>
<Form.Item
name="new_password"
label="新密码"
rules={[
{ required: true, message: '请输入新密码' },
{ min: 6, message: '密码长度至少为 6 个字符' },
]}
>
<Input.Password prefix={<LockOutlined />} placeholder="请输入新密码" />
</Form.Item>
<Form.Item
name="confirmNewPassword"
label="确认新密码"
dependencies={['new_password']}
rules={[
{ required: true, message: '请再次输入新密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('new_password') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('两次输入的新密码不一致'));
},
}),
]}
>
<Input.Password prefix={<LockOutlined />} placeholder="请再次输入新密码" />
</Form.Item>
<Button type="default" htmlType="submit" loading={loading} block>
</Button>
</Form>
</Card>
</Space>
</div>
);
}
return (
<Form
form={emailLoginForm}
layout="vertical"
onFinish={handleEmailLogin}
size="large"
style={{ marginTop: 16 }}
>
<Form.Item
name="email"
label="邮箱地址"
rules={[
{ required: true, message: '请输入邮箱地址' },
{ type: 'email', message: '请输入有效的邮箱地址' },
]}
>
<Input
prefix={<MailOutlined style={{ color: token.colorTextTertiary }} />}
placeholder="请输入已注册邮箱"
autoComplete="email"
style={{ height: 46, borderRadius: 12 }}
/>
</Form.Item>
<Form.Item label="登录验证码" required style={{ marginBottom: 24 }}>
<Space.Compact style={{ width: '100%' }}>
<Form.Item
name="code"
noStyle
rules={[
{ required: true, message: '请输入登录验证码' },
{ len: 6, message: '验证码长度为 6 位' },
]}
>
<Input
prefix={<SafetyCertificateOutlined style={{ color: token.colorTextTertiary }} />}
placeholder="请输入 6 位登录验证码"
maxLength={6}
style={{ height: 46, borderRadius: '12px 0 0 12px' }}
/>
</Form.Item>
<Button
style={{ height: 46 }}
onClick={sendLoginCode}
loading={loginCodeSending}
disabled={loginCountdown > 0}
>
{loginCountdown > 0 ? `${loginCountdown}s 后重发` : '发送验证码'}
</Button>
</Space.Compact>
</Form.Item>
<Form.Item style={{ marginBottom: 0, marginTop: 8 }}>
<Button
type="primary"
htmlType="submit"
loading={loading}
block
style={{
height: 46,
fontSize: 16,
fontWeight: 600,
background: `linear-gradient(90deg, ${token.colorPrimary} 0%, ${alphaColor(token.colorPrimary, 0.86)} 100%)`,
border: 'none',
borderRadius: '12px',
boxShadow: primaryButtonShadow,
}}
>
</Button>
</Form.Item>
<div style={{ marginTop: 12, textAlign: 'right' }}>
<Button type="link" style={{ paddingInline: 0 }} onClick={() => setShowResetPassword(true)}>
</Button>
</div>
</Form>
);
};
const renderEmailRegister = () => (
<Form
form={form}
form={emailRegisterForm}
layout="vertical"
onFinish={handleLocalLogin}
onFinish={handleEmailRegister}
size="large"
style={{ marginTop: '16px' }}
style={{ marginTop: 16 }}
>
<Form.Item
name="username"
label="管理账号"
rules={[{ required: true, message: '请输入管理账号' }]}
name="email"
label="注册邮箱"
rules={[
{ required: true, message: '请输入注册邮箱' },
{ type: 'email', message: '请输入有效的邮箱地址' },
]}
>
<Input
prefix={<MailOutlined style={{ color: token.colorTextTertiary }} />}
placeholder="请输入注册邮箱"
autoComplete="email"
style={{ height: 46, borderRadius: 12 }}
/>
</Form.Item>
<Form.Item label="邮箱验证码" required style={{ marginBottom: 12 }}>
<Space.Compact style={{ width: '100%' }}>
<Form.Item
name="code"
noStyle
rules={[
{ required: true, message: '请输入邮箱验证码' },
{ len: 6, message: '验证码长度为 6 位' },
]}
>
<Input
prefix={<SafetyCertificateOutlined style={{ color: token.colorTextTertiary }} />}
placeholder="请输入 6 位验证码"
maxLength={6}
style={{ height: 46, borderRadius: '12px 0 0 12px' }}
/>
</Form.Item>
<Button
style={{ height: 46 }}
onClick={sendRegisterCode}
loading={registerCodeSending}
disabled={registerCountdown > 0}
>
{registerCountdown > 0 ? `${registerCountdown}s 后重发` : '发送验证码'}
</Button>
</Space.Compact>
</Form.Item>
<Form.Item
name="display_name"
label="昵称"
rules={[{ max: 50, message: '昵称长度不能超过 50 个字符' }]}
>
<Input
prefix={<UserOutlined style={{ color: token.colorTextTertiary }} />}
placeholder="请输入管理账号"
autoComplete="username"
placeholder="选填,默认使用邮箱前缀"
autoComplete="nickname"
style={{ height: 46, borderRadius: 12 }}
/>
</Form.Item>
<Form.Item
name="password"
label="访问密钥"
rules={[{ required: true, message: '请输入访问密钥' }]}
label="登录密码"
rules={[
{ required: true, message: '请输入登录密码' },
{ min: 6, message: '密码长度至少为 6 个字符' },
]}
>
<Input.Password
prefix={<LockOutlined style={{ color: token.colorTextTertiary }} />}
placeholder="请输入访问密钥"
autoComplete="current-password"
placeholder="请输入登录密码"
autoComplete="new-password"
style={{ height: 46, borderRadius: 12 }}
/>
</Form.Item>
<Form.Item
name="confirmPassword"
label="确认密码"
dependencies={['password']}
rules={[
{ required: true, message: '请再次输入登录密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('password') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('两次输入的密码不一致'));
},
}),
]}
>
<Input.Password
prefix={<LockOutlined style={{ color: token.colorTextTertiary }} />}
placeholder="请再次输入登录密码"
autoComplete="new-password"
style={{ height: 46, borderRadius: 12 }}
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0, marginTop: 8 }}>
<Button
type="primary"
@@ -158,19 +707,22 @@ export default function Login() {
boxShadow: primaryButtonShadow,
}}
>
</Button>
</Form.Item>
<Text type="secondary" style={{ marginTop: 12, display: 'block' }}>
</Text>
</Form>
);
// 渲染LinuxDO登录
const renderLinuxDOLogin = () => (
<div>
<Button
type="primary"
size="large"
icon={
icon={(
<img
src="/favicon.ico"
alt="LinuxDO"
@@ -181,7 +733,7 @@ export default function Login() {
verticalAlign: 'middle',
}}
/>
}
)}
loading={loading}
onClick={handleLinuxDOLogin}
block
@@ -209,51 +761,51 @@ export default function Login() {
</div>
);
const handleAnnouncementClose = () => {
setShowAnnouncement(false);
const redirect = searchParams.get('redirect') || '/';
navigate(redirect);
};
const handleDoNotShowToday = () => {
// 设置今日不再显示
const today = new Date().toDateString();
localStorage.setItem('announcement_hide_today', today);
};
const handleNeverShow = () => {
// 设置永久不再显示
localStorage.setItem('announcement_hide_forever', 'true');
};
const loginTips = [
'本地登录默认账号:admin / admin123',
'首次 LinuxDO 登录会自动创建账号',
'系统采用多用户数据隔离机制,每位用户拥有独立的创作空间与配置。',
const authTabs = [
...(localAuthEnabled
? [
{
key: 'local-login',
label: '本地登录',
children: renderLocalLogin(),
},
]
: []),
...(emailAuthEnabled
? [
{
key: 'email-login',
label: '邮箱登录',
children: renderEmailLogin(),
},
]
: []),
...(emailAuthEnabled && emailRegisterEnabled
? [
{
key: 'email-register',
label: '邮箱注册',
children: renderEmailRegister(),
},
]
: []),
];
const featureItems = [
{
icon: <RobotOutlined />,
title: '多 AI 模型协同',
description: '支持 OpenAI、Gemini、Claude 等主流模型,按场景灵活切换。',
},
{
icon: <ThunderboltOutlined />,
title: '智能向导驱动',
description: '自动生成大纲、角色与世界观,快速搭建完整故事骨架。',
},
{
icon: <TeamOutlined />,
title: '角色组织管理',
description: '人物关系、组织架构可视化管理,复杂设定也能清晰掌控。',
},
{
icon: <BookOutlined />,
title: '章节创作闭环',
description: '支持章节生成、编辑、重写与润色,持续提升内容质量。',
},
];
if (checking) {
return (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
background: token.colorBgLayout,
}}
>
<Spin size="large" style={{ color: token.colorPrimary }} />
</div>
);
}
return (
<>
@@ -313,7 +865,6 @@ export default function Login() {
justifyContent: 'space-between',
gap: 34,
width: '100%',
// flex: 1,
}}
>
<Space align="center" size={14}>
@@ -444,7 +995,7 @@ export default function Login() {
background: token.colorBgLayout,
}}
>
<div style={{ width: '100%', maxWidth: 480 }}>
<div style={{ width: '100%', maxWidth: 520 }}>
<Space direction="vertical" size={4}>
<Title level={2} style={{ marginBottom: 0, fontWeight: 700, color: token.colorText }}>
@@ -455,23 +1006,26 @@ export default function Login() {
</Space>
<div style={{ marginTop: 22 }}>
{localAuthEnabled ? renderLocalLogin() : null}
{linuxdoEnabled && localAuthEnabled ? (
<>
<Divider style={{ margin: '18px 0 16px' }}></Divider>
{renderLinuxDOLogin()}
</>
{authTabs.length > 0 ? (
<Tabs defaultActiveKey={authTabs[0].key} items={authTabs} />
) : null}
{!localAuthEnabled && linuxdoEnabled ? renderLinuxDOLogin() : null}
{!localAuthEnabled && !linuxdoEnabled ? (
{!localAuthEnabled && !linuxdoEnabled && !emailAuthEnabled ? (
<Alert
type="warning"
showIcon
message="当前未启用可用登录方式"
description="请联系管理员在系统配置中启用本地登录或 LinuxDO OAuth 登录。"
description="请联系管理员在系统配置中启用本地登录、邮箱认证或 LinuxDO OAuth 登录。"
/>
) : null}
{emailAuthEnabled && !emailRegisterEnabled ? (
<Alert
type="info"
showIcon
style={{ marginTop: 12, borderRadius: 12 }}
message="邮箱注册暂未开放"
description="当前仅开放邮箱验证码登录与找回密码,如需注册请联系管理员。"
/>
) : null}
@@ -500,4 +1054,4 @@ export default function Login() {
</Layout>
</>
);
}
}
+24 -6
View File
@@ -1,18 +1,19 @@
import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { Card, Button, Modal, message, Spin, Space, Tag, Typography, Upload, Checkbox, Tooltip, Drawer, Menu, theme } from 'antd';
import { EditOutlined, BookOutlined, CalendarOutlined, FileTextOutlined, TrophyOutlined, SettingOutlined, UploadOutlined, ApiOutlined, FileSearchOutlined, MenuUnfoldOutlined, MenuFoldOutlined, BulbOutlined, MoonOutlined, DesktopOutlined } from '@ant-design/icons';
import { projectApi } from '../services/api';
import { EditOutlined, BookOutlined, CalendarOutlined, FileTextOutlined, TrophyOutlined, SettingOutlined, UploadOutlined, ApiOutlined, FileSearchOutlined, MenuUnfoldOutlined, MenuFoldOutlined, BulbOutlined, MoonOutlined, DesktopOutlined, MailOutlined } from '@ant-design/icons';
import { authApi, projectApi } from '../services/api';
import { useStore } from '../store';
import { useProjectSync } from '../store/hooks';
import { eventBus, EventNames } from '../store/eventBus';
import type { ReactNode } from 'react';
import type { Project } from '../types';
import type { Project, User } from '../types';
import UserMenu from '../components/UserMenu';
import ChangelogFloatingButton from '../components/ChangelogFloatingButton';
import ThemeSwitch from '../components/ThemeSwitch';
import { useThemeMode } from '../theme/useThemeMode';
import SettingsPage from './Settings';
import SystemSettingsPage from './SystemSettings';
import MCPPluginsPage from './MCPPlugins';
import PromptTemplates from './PromptTemplates';
import BookImport from './BookImport';
@@ -41,11 +42,11 @@ const formatWordCount = (count: number): string => {
}
};
type ProjectListView = 'projects' | 'settings' | 'mcp' | 'prompts' | 'book-import';
type ProjectListView = 'projects' | 'settings' | 'system-settings' | 'mcp' | 'prompts' | 'book-import';
const parseViewFromSearch = (search: string): ProjectListView => {
const view = new URLSearchParams(search).get('view');
if (view === 'settings' || view === 'mcp' || view === 'prompts' || view === 'book-import' || view === 'projects') {
if (view === 'settings' || view === 'system-settings' || view === 'mcp' || view === 'prompts' || view === 'book-import' || view === 'projects') {
return view;
}
return 'projects';
@@ -58,6 +59,7 @@ export default function ProjectList() {
const [drawerVisible, setDrawerVisible] = useState(false);
const [collapsed, setCollapsed] = useState<boolean>(() => getStoredSidebarCollapsed());
const [modal, contextHolder] = Modal.useModal();
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [showApiTip, setShowApiTip] = useState(true);
const [importModalVisible, setImportModalVisible] = useState(false);
const [exportModalVisible, setExportModalVisible] = useState(false);
@@ -113,6 +115,7 @@ export default function ProjectList() {
useEffect(() => {
refreshProjects();
authApi.getCurrentUser().then(setCurrentUser).catch(() => setCurrentUser(null));
// 监听切换到 MCP 视图的事件
eventBus.on(EventNames.SWITCH_TO_MCP_VIEW, handleSwitchToMcp);
@@ -396,7 +399,11 @@ export default function ProjectList() {
? '拆书导入'
: activeView === 'mcp'
? 'MCP 插件'
: 'API 设置';
: activeView === 'system-settings'
? '系统设置'
: 'API 设置';
const isAdmin = !!currentUser?.is_admin;
const sideMenuItems = [
{
@@ -434,6 +441,11 @@ export default function ProjectList() {
icon: <SettingOutlined />,
label: 'API 设置',
},
...(isAdmin ? [{
key: 'system-settings',
icon: <MailOutlined />,
label: '系统设置',
}] : []),
{
key: 'mumu-api',
icon: <ApiOutlined />,
@@ -469,6 +481,11 @@ export default function ProjectList() {
icon: <SettingOutlined />,
label: 'API 设置',
},
...(isAdmin ? [{
key: 'system-settings',
icon: <MailOutlined />,
label: '系统设置',
}] : []),
{
key: 'mumu-api',
icon: <ApiOutlined />,
@@ -839,6 +856,7 @@ export default function ProjectList() {
}}
>
{activeView === 'settings' && <SettingsPage />}
{activeView === 'system-settings' && <SystemSettingsPage />}
{activeView === 'mcp' && <MCPPluginsPage />}
{activeView === 'prompts' && <PromptTemplates />}
+289
View File
@@ -0,0 +1,289 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Card, Col, Form, Input, InputNumber, Row, Select, Space, Spin, Switch, Tabs, Typography, message, theme } from 'antd';
import { CheckCircleOutlined, MailOutlined, ReloadOutlined, SaveOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons';
import { authApi, settingsApi } from '../services/api';
import type { SystemSMTPSettings, SystemSMTPSettingsUpdate, User } from '../types';
const { Title, Text, Paragraph } = Typography;
const { Option } = Select;
const qqDefaults: Pick<SystemSMTPSettings, 'smtp_provider' | 'smtp_host' | 'smtp_port' | 'smtp_use_ssl' | 'smtp_use_tls'> = {
smtp_provider: 'qq',
smtp_host: 'smtp.qq.com',
smtp_port: 465,
smtp_use_ssl: true,
smtp_use_tls: false,
};
export default function SystemSettingsPage() {
const { token } = theme.useToken();
const [form] = Form.useForm<SystemSMTPSettingsUpdate>();
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [initialLoading, setInitialLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [testTargetEmail, setTestTargetEmail] = useState('');
const pageBackground = `linear-gradient(180deg, ${token.colorBgLayout} 0%, ${token.colorFillSecondary} 100%)`;
const headerBackground = `linear-gradient(135deg, ${token.colorPrimary} 0%, ${token.colorPrimaryHover} 100%)`;
const footerSafeOffset = 88;
const loadData = async () => {
setInitialLoading(true);
try {
const [user, smtpSettings] = await Promise.all([
authApi.getCurrentUser(),
settingsApi.getSystemSMTPSettings(),
]);
setCurrentUser(user);
form.setFieldsValue(smtpSettings);
} catch (error) {
console.error('加载系统设置失败:', error);
message.error('加载系统设置失败');
} finally {
setInitialLoading(false);
}
};
useEffect(() => {
loadData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleProviderChange = (value: string) => {
if (value === 'qq') {
form.setFieldsValue(qqDefaults);
}
};
const handleSave = async (values: SystemSMTPSettingsUpdate) => {
setSaving(true);
try {
const payload = values.smtp_provider === 'qq'
? {
...values,
...qqDefaults,
smtp_username: values.smtp_username,
smtp_password: values.smtp_password,
smtp_from_email: values.smtp_from_email,
smtp_from_name: values.smtp_from_name,
email_auth_enabled: values.email_auth_enabled,
email_register_enabled: values.email_register_enabled,
verification_code_ttl_minutes: values.verification_code_ttl_minutes,
verification_resend_interval_seconds: values.verification_resend_interval_seconds,
}
: values;
const result = await settingsApi.updateSystemSMTPSettings(payload);
form.setFieldsValue(result);
message.success('系统 SMTP 设置已保存');
} catch (error) {
console.error('保存系统设置失败:', error);
message.error('保存系统设置失败');
} finally {
setSaving(false);
}
};
const handleTest = async () => {
const toEmail = testTargetEmail.trim();
if (!toEmail) {
message.warning('请先填写测试目标邮箱');
return;
}
setTesting(true);
try {
const result = await settingsApi.testSystemSMTPSettings({ to_email: toEmail });
if (result.success) {
message.success(result.message);
} else {
message.error(result.message || 'SMTP 测试失败');
}
} catch (error) {
console.error('测试 SMTP 配置失败:', error);
message.error('测试 SMTP 配置失败');
} finally {
setTesting(false);
}
};
if (initialLoading) {
return (
<div style={{ minHeight: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: token.colorBgLayout }}>
<Spin size="large" />
</div>
);
}
if (!currentUser?.is_admin) {
return (
<div style={{ padding: 24 }}>
<Alert type="error" showIcon message="无权限访问" description="只有管理员可以访问系统设置。" />
</div>
);
}
return (
<div
style={{
minHeight: `calc(100vh - ${footerSafeOffset}px)`,
boxSizing: 'border-box',
background: pageBackground,
padding: 24,
paddingBottom: footerSafeOffset,
}}
>
<Card
bordered={false}
style={{
marginBottom: 24,
borderRadius: 20,
overflow: 'hidden',
boxShadow: `0 12px 32px ${token.colorFillSecondary}`,
}}
bodyStyle={{ padding: 0 }}
>
<div style={{ background: headerBackground, padding: '28px 32px', color: '#fff' }}>
<Space direction="vertical" size={6}>
<Space>
<SettingOutlined />
<Title level={3} style={{ color: '#fff', margin: 0 }}></Title>
</Space>
<Paragraph style={{ color: 'rgba(255,255,255,0.88)', margin: 0 }}>
SMTP
</Paragraph>
</Space>
</div>
</Card>
<Tabs
defaultActiveKey="smtp"
items={[
{
key: 'smtp',
label: (
<Space>
<MailOutlined />
SMTP
</Space>
),
children: (
<Form form={form} layout="vertical" onFinish={handleSave}>
<Row gutter={24}>
<Col xs={24} xl={16}>
<Card title="邮件服务配置" bordered={false} style={{ borderRadius: 16 }}>
<Alert
type="info"
showIcon
style={{ marginBottom: 20 }}
message="QQ 邮箱配置说明"
description="如果选择 QQ 邮箱,请使用完整 QQ 邮箱地址作为用户名,密码处填写 SMTP 授权码,而不是 QQ 登录密码。默认推荐 smtp.qq.com + SSL 465。"
/>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item name="smtp_provider" label="邮件服务商" rules={[{ required: true, message: '请选择邮件服务商' }]}>
<Select onChange={handleProviderChange}>
<Option value="qq">QQ </Option>
<Option value="custom"> SMTP</Option>
</Select>
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="smtp_host" label="SMTP 主机" rules={[{ required: true, message: '请输入 SMTP 主机' }]}>
<Input placeholder="例如:smtp.qq.com" />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="smtp_port" label="SMTP 端口" rules={[{ required: true, message: '请输入 SMTP 端口' }]}>
<InputNumber style={{ width: '100%' }} min={1} max={65535} />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="smtp_username" label="SMTP 用户名" rules={[{ required: true, message: '请输入 SMTP 用户名' }]}>
<Input placeholder="完整邮箱地址" />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="smtp_password" label="SMTP 密码 / 授权码" rules={[{ required: true, message: '请输入 SMTP 授权码' }]}>
<Input.Password placeholder="QQ 邮箱请填写授权码" />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="smtp_from_email" label="发件人邮箱">
<Input placeholder="默认可与用户名一致" />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="smtp_from_name" label="发件人名称" rules={[{ required: true, message: '请输入发件人名称' }]}>
<Input placeholder="MuMuAINovel" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item name="smtp_use_ssl" label="启用 SSL" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="smtp_use_tls" label="启用 TLS" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
</Row>
</Card>
</Col>
<Col xs={24} xl={8}>
<Card title="注册与验证码策略" bordered={false} style={{ borderRadius: 16, marginBottom: 24 }}>
<Form.Item name="email_auth_enabled" label="启用邮箱认证" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="email_register_enabled" label="启用邮箱注册" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="verification_code_ttl_minutes" label="验证码有效期(分钟)" rules={[{ required: true, message: '请输入验证码有效期' }]}>
<InputNumber style={{ width: '100%' }} min={1} max={120} />
</Form.Item>
<Form.Item name="verification_resend_interval_seconds" label="验证码重发间隔(秒)" rules={[{ required: true, message: '请输入验证码重发间隔' }]}>
<InputNumber style={{ width: '100%' }} min={10} max={3600} />
</Form.Item>
</Card>
<Card title="操作" bordered={false} style={{ borderRadius: 16 }}>
<Space direction="vertical" style={{ width: '100%' }} size={12}>
<Input
value={testTargetEmail}
onChange={(e) => setTestTargetEmail(e.target.value)}
placeholder="请输入测试目标邮箱,如 123456@qq.com"
/>
<Button icon={<ReloadOutlined />} onClick={loadData} block>
</Button>
<Button icon={<SendOutlined />} loading={testing} onClick={handleTest} block>
</Button>
<Button type="primary" htmlType="submit" icon={<SaveOutlined />} loading={saving} block onClick={() => form.submit()}>
</Button>
<Alert
type="success"
showIcon
icon={<CheckCircleOutlined />}
message="建议使用 QQ 默认配置"
description={<Text type="secondary"> SMTP SMTP </Text>}
/>
</Space>
</Card>
</Col>
</Row>
</Form>
),
},
]}
/>
</div>
);
}
+42 -5
View File
@@ -98,14 +98,25 @@ api.interceptors.response.use(
case 400:
errorMessage = data?.detail || '请求参数错误';
break;
case 401:
errorMessage = '未授权,请先登录';
if (window.location.pathname !== '/login') {
case 401: {
const backendDetail = data?.detail || data?.message;
const unauthenticatedDetails = [
'未登录',
'需要登录',
'未登录或用户ID缺失',
'未登录,无法刷新会话',
];
const isUnauthenticated = unauthenticatedDetails.includes(backendDetail);
errorMessage = backendDetail || '登录状态已失效,请重新登录';
if (isUnauthenticated && window.location.pathname !== '/login') {
window.location.href = '/login';
}
break;
}
case 403:
errorMessage = '没有权限访问';
errorMessage = data?.detail || '没有权限访问';
break;
case 404:
errorMessage = data?.detail || '请求的资源不存在';
@@ -139,7 +150,12 @@ api.interceptors.response.use(
);
export const authApi = {
getAuthConfig: () => api.get<unknown, { local_auth_enabled: boolean; linuxdo_enabled: boolean }>('/auth/config'),
getAuthConfig: () => api.get<unknown, {
local_auth_enabled: boolean;
linuxdo_enabled: boolean;
email_auth_enabled: boolean;
email_register_enabled: boolean;
}>('/auth/config'),
localLogin: (username: string, password: string) =>
api.post<unknown, { success: boolean; message: string; user: User }>('/auth/local/login', { username, password }),
@@ -147,6 +163,18 @@ export const authApi = {
bindAccountLogin: (username: string, password: string) =>
api.post<unknown, { success: boolean; message: string; user: User }>('/auth/bind/login', { username, password }),
emailLogin: (payload: import('../types').EmailLoginPayload) =>
api.post<unknown, { success: boolean; message: string; user: User }>('/auth/email/login', payload),
sendEmailCode: (payload: import('../types').EmailSendCodePayload) =>
api.post<unknown, { success: boolean; message: string; expire_in_seconds: number; resend_interval_seconds: number }>('/auth/email/send-code', payload),
emailRegister: (payload: import('../types').EmailRegisterPayload) =>
api.post<unknown, { success: boolean; message: string; user: User }>('/auth/email/register', payload),
resetEmailPassword: (payload: import('../types').EmailResetPasswordPayload) =>
api.post<unknown, { success: boolean; message: string }>('/auth/email/reset-password', payload),
getLinuxDOAuthUrl: () => api.get<unknown, AuthUrlResponse>('/auth/linuxdo/url'),
getCurrentUser: () => api.get<unknown, User>('/auth/user'),
@@ -290,6 +318,15 @@ export const settingsApi = {
api.post<unknown, APIKeyPreset>('/settings/presets/from-current', null, {
params: { name, description }
}),
getSystemSMTPSettings: () =>
api.get<unknown, import('../types').SystemSMTPSettings>('/settings/system/smtp'),
updateSystemSMTPSettings: (data: import('../types').SystemSMTPSettingsUpdate) =>
api.put<unknown, import('../types').SystemSMTPSettings>('/settings/system/smtp', data),
testSystemSMTPSettings: (data: { to_email: string }) =>
api.post<unknown, { success: boolean; message: string }>('/settings/system/smtp/test', data),
};
export const projectApi = {
+59
View File
@@ -11,6 +11,65 @@ export interface User {
last_login: string;
}
export interface EmailLoginPayload {
email: string;
code: string;
}
export interface EmailRegisterPayload {
email: string;
code: string;
password: string;
display_name?: string;
}
export interface EmailSendCodePayload {
email: string;
scene: 'register' | 'login' | 'reset_password';
}
export interface EmailResetPasswordPayload {
email: string;
code: string;
new_password: string;
}
export interface SystemSMTPSettings {
id: string;
user_id: string;
smtp_provider: string;
smtp_host?: string;
smtp_port: number;
smtp_username?: string;
smtp_password?: string;
smtp_use_tls: boolean;
smtp_use_ssl: boolean;
smtp_from_email?: string;
smtp_from_name: string;
email_auth_enabled: boolean;
email_register_enabled: boolean;
verification_code_ttl_minutes: number;
verification_resend_interval_seconds: number;
created_at: string;
updated_at: string;
}
export interface SystemSMTPSettingsUpdate {
smtp_provider?: string;
smtp_host?: string;
smtp_port?: number;
smtp_username?: string;
smtp_password?: string;
smtp_use_tls?: boolean;
smtp_use_ssl?: boolean;
smtp_from_email?: string;
smtp_from_name?: string;
email_auth_enabled?: boolean;
email_register_enabled?: boolean;
verification_code_ttl_minutes?: number;
verification_resend_interval_seconds?: number;
}
// 设置类型定义
export interface Settings {
id: string;