feature:新增邮箱注册登录功能
This commit is contained in:
+674
-120
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user