mirror of
https://github.com/chaitin/MonkeyCode.git
synced 2026-02-13 20:23:25 +08:00
@@ -37,6 +37,10 @@ export enum ContentType {
|
||||
Text = "text/plain",
|
||||
}
|
||||
|
||||
|
||||
const whitePathnameList = ['/user/login', '/login'];
|
||||
const whiteApiList = ['/api/v1/user/profile', '/api/v1/admin/profile'];
|
||||
|
||||
const redirectToLogin = () => {
|
||||
const redirectAfterLogin = encodeURIComponent(location.href);
|
||||
const search = `redirect=${redirectAfterLogin}`;
|
||||
@@ -72,9 +76,12 @@ export class HttpClient<SecurityDataType = unknown> {
|
||||
},
|
||||
(err) => {
|
||||
if (err?.response?.status === 401) {
|
||||
if (whitePathnameList.includes(location.pathname)) {
|
||||
return Promise.reject('尚未登录');
|
||||
}
|
||||
Message.error('尚未登录')
|
||||
redirectToLogin();
|
||||
return
|
||||
return Promise.reject('尚未登录');
|
||||
}
|
||||
// 手动取消请求
|
||||
if (err.code === 'ERR_CANCELED') {
|
||||
|
||||
@@ -175,6 +175,51 @@ export const getAdminLoginHistory = (
|
||||
...params,
|
||||
});
|
||||
|
||||
/**
|
||||
* @description 管理员登出
|
||||
*
|
||||
* @tags Admin
|
||||
* @name PostAdminLogout
|
||||
* @summary 管理员登出
|
||||
* @request POST:/api/v1/admin/logout
|
||||
* @response `200` `WebResp` OK
|
||||
*/
|
||||
|
||||
export const postAdminLogout = (params: RequestParams = {}) =>
|
||||
request<WebResp>({
|
||||
path: `/api/v1/admin/logout`,
|
||||
method: "POST",
|
||||
type: ContentType.Json,
|
||||
format: "json",
|
||||
...params,
|
||||
});
|
||||
|
||||
/**
|
||||
* @description 管理员信息
|
||||
*
|
||||
* @tags Admin
|
||||
* @name GetAdminProfile
|
||||
* @summary 管理员信息
|
||||
* @request GET:/api/v1/admin/profile
|
||||
* @response `200` `(WebResp & {
|
||||
data?: DomainAdminUser,
|
||||
|
||||
})` OK
|
||||
*/
|
||||
|
||||
export const getAdminProfile = (params: RequestParams = {}) =>
|
||||
request<
|
||||
WebResp & {
|
||||
data?: DomainAdminUser;
|
||||
}
|
||||
>({
|
||||
path: `/api/v1/admin/profile`,
|
||||
method: "GET",
|
||||
type: ContentType.Json,
|
||||
format: "json",
|
||||
...params,
|
||||
});
|
||||
|
||||
/**
|
||||
* @description 获取系统设置
|
||||
*
|
||||
|
||||
@@ -125,7 +125,7 @@ export const getModelList = (params: RequestParams = {}) =>
|
||||
});
|
||||
|
||||
/**
|
||||
* @description 报告
|
||||
* @description 报告,支持多种操作:accept(接受补全)、suggest(建议)、reject(拒绝补全并记录用户输入)、file_written(文件写入)
|
||||
*
|
||||
* @tags OpenAIV1
|
||||
* @name PostReport
|
||||
|
||||
@@ -189,6 +189,25 @@ export const getLoginHistory = (
|
||||
...params,
|
||||
});
|
||||
|
||||
/**
|
||||
* @description 用户登出
|
||||
*
|
||||
* @tags User
|
||||
* @name PostLogout
|
||||
* @summary 用户登出
|
||||
* @request POST:/api/v1/user/logout
|
||||
* @response `200` `WebResp` OK
|
||||
*/
|
||||
|
||||
export const postLogout = (params: RequestParams = {}) =>
|
||||
request<WebResp>({
|
||||
path: `/api/v1/user/logout`,
|
||||
method: "POST",
|
||||
type: ContentType.Json,
|
||||
format: "json",
|
||||
...params,
|
||||
});
|
||||
|
||||
/**
|
||||
* @description 用户 OAuth 回调
|
||||
*
|
||||
|
||||
@@ -58,6 +58,9 @@ export enum ContentType {
|
||||
Text = "text/plain",
|
||||
}
|
||||
|
||||
const whitePathnameList = ["/user/login", "/login"];
|
||||
const whiteApiList = ["/api/v1/user/profile", "/api/v1/admin/profile"];
|
||||
|
||||
const redirectToLogin = () => {
|
||||
const redirectAfterLogin = encodeURIComponent(location.href);
|
||||
const search = `redirect=${redirectAfterLogin}`;
|
||||
@@ -101,9 +104,12 @@ export class HttpClient<SecurityDataType = unknown> {
|
||||
},
|
||||
(err) => {
|
||||
if (err?.response?.status === 401) {
|
||||
if (whitePathnameList.includes(location.pathname)) {
|
||||
return Promise.reject("尚未登录");
|
||||
}
|
||||
Message.error("尚未登录");
|
||||
redirectToLogin();
|
||||
return;
|
||||
return Promise.reject("尚未登录");
|
||||
}
|
||||
// 手动取消请求
|
||||
if (err.code === "ERR_CANCELED") {
|
||||
|
||||
@@ -26,6 +26,7 @@ export enum ConstsReportAction {
|
||||
ReportActionAccept = "accept",
|
||||
ReportActionSuggest = "suggest",
|
||||
ReportActionFileWritten = "file_written",
|
||||
ReportActionReject = "reject",
|
||||
}
|
||||
|
||||
export enum ConstsModelType {
|
||||
@@ -367,8 +368,11 @@ export interface DomainLoginReq {
|
||||
password?: string;
|
||||
/** 会话Id插件登录时必填 */
|
||||
session_id?: string;
|
||||
/** 登录来源 plugin: 插件 browser: 浏览器; 默认为 plugin */
|
||||
source?: ConstsLoginSource;
|
||||
/**
|
||||
* 登录来源 plugin: 插件 browser: 浏览器; 默认为 plugin
|
||||
* @default "plugin"
|
||||
*/
|
||||
source: ConstsLoginSource;
|
||||
/** 用户名 */
|
||||
username?: string;
|
||||
}
|
||||
@@ -511,10 +515,16 @@ export interface DomainReportReq {
|
||||
action?: ConstsReportAction;
|
||||
/** 内容 */
|
||||
content?: string;
|
||||
/** 光标位置(用于reject action) */
|
||||
cursor_position?: number;
|
||||
/** task_id or resp_id */
|
||||
id?: string;
|
||||
/** 当前文件的原文(用于reject action) */
|
||||
source_code?: string;
|
||||
/** 工具 */
|
||||
tool?: string;
|
||||
/** 用户输入的新文本(用于reject action) */
|
||||
user_input?: string;
|
||||
}
|
||||
|
||||
export interface DomainSetting {
|
||||
@@ -1070,6 +1080,9 @@ export interface GetUserOauthSignupOrInParams {
|
||||
redirect_url?: string;
|
||||
/** 会话ID */
|
||||
session_id?: string;
|
||||
/** 登录来源 plugin: 插件 browser: 浏览器; 默认为 plugin */
|
||||
source?: "plugin" | "browser";
|
||||
/**
|
||||
* 登录来源 plugin: 插件 browser: 浏览器; 默认为 plugin
|
||||
* @default "plugin"
|
||||
*/
|
||||
source: "plugin" | "browser";
|
||||
}
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
import { Button, IconButton, Stack } from '@mui/material';
|
||||
import { Icon, message } from '@c-x/ui';
|
||||
import { Button, IconButton, Stack, Tooltip } from '@mui/material';
|
||||
import { message } from '@c-x/ui';
|
||||
import { postLogout } from '@/api/User';
|
||||
import { postAdminLogout } from '@/api/Admin';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import LogoutIcon from '@mui/icons-material/Logout';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import { Box } from '@mui/material';
|
||||
import Bread from './Bread';
|
||||
|
||||
const Header = () => {
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
const onLogout = async () => {
|
||||
if (pathname.startsWith('/user')) {
|
||||
await postLogout();
|
||||
} else {
|
||||
await postAdminLogout();
|
||||
}
|
||||
message.success('退出登录成功');
|
||||
navigate(pathname.startsWith('/user') ? '/user/login' : '/login');
|
||||
};
|
||||
return (
|
||||
<Stack
|
||||
direction={'row'}
|
||||
@@ -33,25 +43,22 @@ const Header = () => {
|
||||
>
|
||||
下载客户端
|
||||
</Button>
|
||||
<IconButton
|
||||
title='退出登录'
|
||||
size='small'
|
||||
sx={{
|
||||
bgcolor: '#fff',
|
||||
<Tooltip title='退出登录' arrow>
|
||||
<IconButton
|
||||
title='退出登录'
|
||||
size='small'
|
||||
sx={{
|
||||
bgcolor: '#fff',
|
||||
|
||||
'&:hover': {
|
||||
color: 'primary.main',
|
||||
},
|
||||
}}
|
||||
onClick={() => {
|
||||
message.success('退出登录成功');
|
||||
navigate(pathname.startsWith('/user/') ? '/user/login' : '/login', {
|
||||
replace: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<LogoutIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
'&:hover': {
|
||||
color: 'primary.main',
|
||||
},
|
||||
}}
|
||||
onClick={onLogout}
|
||||
>
|
||||
<LogoutIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
25
ui/src/context/index.tsx
Normal file
25
ui/src/context/index.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { createContext } from 'react';
|
||||
import { DomainUser, DomainAdminUser } from '@/api/types';
|
||||
|
||||
export const AuthContext = createContext<
|
||||
[
|
||||
DomainUser | DomainAdminUser | null,
|
||||
{
|
||||
loading: boolean;
|
||||
setUser: (user: DomainUser | DomainAdminUser) => void;
|
||||
refreshUser: () => void;
|
||||
}
|
||||
]
|
||||
>([
|
||||
null,
|
||||
{
|
||||
setUser: () => {},
|
||||
loading: true,
|
||||
refreshUser: () => {},
|
||||
},
|
||||
]);
|
||||
|
||||
export const CommonContext = createContext<{
|
||||
contactModalOpen: boolean;
|
||||
setContactModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}>({ contactModalOpen: false, setContactModalOpen: () => {} });
|
||||
10
ui/src/hooks/context.ts
Normal file
10
ui/src/hooks/context.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { use } from 'react';
|
||||
import { AuthContext, CommonContext } from '@/context';
|
||||
|
||||
export const useAuthContext = () => {
|
||||
return use(AuthContext);
|
||||
};
|
||||
|
||||
export const useCommonContext = () => {
|
||||
return use(CommonContext);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import '@/assets/fonts/font.css';
|
||||
@@ -6,22 +7,86 @@ import '@/assets/fonts/iconfont';
|
||||
import './index.css';
|
||||
import '@/assets/styles/markdown.css';
|
||||
import { ThemeProvider } from '@c-x/ui';
|
||||
import { getUserProfile } from '@/api/UserManage';
|
||||
import { getAdminProfile } from '@/api/Admin';
|
||||
import dayjs from 'dayjs';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
|
||||
import { AuthContext } from './context';
|
||||
import { DomainUser, DomainAdminUser } from './api/types';
|
||||
import { lightTheme } from './theme';
|
||||
import router from './router';
|
||||
import { getRedirectUrl } from './utils';
|
||||
import { Loading } from '@c-x/ui';
|
||||
|
||||
dayjs.locale('zh-cn');
|
||||
dayjs.extend(duration);
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
const App = () => {
|
||||
const [user, setUser] = useState<DomainUser | DomainAdminUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const onGotoRedirect = (source: 'user' | 'admin') => {
|
||||
const redirectUrl = getRedirectUrl(source);
|
||||
window.location.href = redirectUrl.href;
|
||||
};
|
||||
|
||||
const getUser = () => {
|
||||
setLoading(true);
|
||||
if (location.pathname.startsWith('/user')) {
|
||||
return getUserProfile()
|
||||
.then((res) => {
|
||||
setUser(res);
|
||||
if (location.pathname.startsWith('/user/login')) {
|
||||
onGotoRedirect('user');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
} else {
|
||||
return getAdminProfile()
|
||||
.then((res) => {
|
||||
console.log(res);
|
||||
setUser(res);
|
||||
if (location.pathname.startsWith('/login')) {
|
||||
onGotoRedirect('admin');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getUser();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<AuthContext.Provider
|
||||
value={[
|
||||
user,
|
||||
{
|
||||
loading,
|
||||
setUser,
|
||||
refreshUser: getUser,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<RouterProvider router={router} />
|
||||
</AuthContext.Provider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
);
|
||||
root.render(
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<RouterProvider router={router} />
|
||||
</ThemeProvider>
|
||||
);
|
||||
root.render(<App />);
|
||||
|
||||
@@ -147,6 +147,7 @@ const AuthPage = () => {
|
||||
const loginResult = await postLogin({
|
||||
...data,
|
||||
session_id: sessionId,
|
||||
source: ConstsLoginSource.LoginSourcePlugin,
|
||||
});
|
||||
|
||||
if (!loginResult.redirect_url) {
|
||||
|
||||
@@ -26,7 +26,7 @@ import { useRequest } from 'ahooks';
|
||||
import { postRegister, getUserOauthSignupOrIn } from '@/api/User';
|
||||
import { getGetSetting } from '@/api/Admin';
|
||||
import { Icon } from '@c-x/ui';
|
||||
import { DomainSetting } from '@/api/types';
|
||||
import { ConstsLoginSource, DomainSetting } from '@/api/types';
|
||||
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import MenuBookIcon from '@mui/icons-material/MenuBook';
|
||||
@@ -140,6 +140,7 @@ const Invite = () => {
|
||||
platform,
|
||||
redirect_url: `${window.location.origin}/invite/${id}/2`,
|
||||
inviate_code: id,
|
||||
source: 'plugin',
|
||||
}).then((res) => {
|
||||
if (res.url) {
|
||||
window.location.href = res.url;
|
||||
|
||||
@@ -16,6 +16,7 @@ import { postAdminLogin } from '@/api/Admin';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import { ConstsLoginSource } from '@/api/types';
|
||||
import { Icon } from '@c-x/ui';
|
||||
import Logo from '@/assets/images/logo.png';
|
||||
import { getRedirectUrl } from '@/utils';
|
||||
@@ -71,6 +72,7 @@ const LoginPage = () => {
|
||||
return postAdminLogin({
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
source: ConstsLoginSource.LoginSourceBrowser,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
473
ui/src/pages/user/setting/index.tsx
Normal file
473
ui/src/pages/user/setting/index.tsx
Normal file
@@ -0,0 +1,473 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Stack,
|
||||
Box,
|
||||
Button,
|
||||
TextField,
|
||||
Typography,
|
||||
IconButton,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import PhotoCameraIcon from '@mui/icons-material/PhotoCamera';
|
||||
import { message, Modal } from '@c-x/ui';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { useRequest } from 'ahooks';
|
||||
import Card from '@/components/card';
|
||||
import { FormItem } from '@/components/form';
|
||||
import Avatar from '@/components/avatar';
|
||||
import { useAuthContext } from '@/hooks/context';
|
||||
import { getUserProfile, putUserUpdateProfile } from '@/api/UserManage';
|
||||
import { DomainProfileUpdateReq, DomainUser } from '@/api/types';
|
||||
|
||||
interface PasswordFormData {
|
||||
oldPassword: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
interface ProfileFormData {
|
||||
username: string;
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
const UserSetting = () => {
|
||||
const [user, { setUser, refreshUser }] = useAuthContext();
|
||||
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
|
||||
const [avatarDialogOpen, setAvatarDialogOpen] = useState(false);
|
||||
const [avatarUrl, setAvatarUrl] = useState('');
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState('');
|
||||
|
||||
// 用户信息表单
|
||||
const {
|
||||
control: profileControl,
|
||||
handleSubmit: handleProfileSubmit,
|
||||
setValue: setProfileValue,
|
||||
watch: watchProfile,
|
||||
} = useForm<ProfileFormData>({
|
||||
defaultValues: {
|
||||
username: '',
|
||||
avatar: '',
|
||||
},
|
||||
});
|
||||
|
||||
// 密码修改表单
|
||||
const {
|
||||
control: passwordControl,
|
||||
handleSubmit: handlePasswordSubmit,
|
||||
formState: { errors: passwordErrors },
|
||||
reset: resetPasswordForm,
|
||||
watch: watchPassword,
|
||||
} = useForm<PasswordFormData>({
|
||||
defaultValues: {
|
||||
oldPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
},
|
||||
});
|
||||
|
||||
// 获取用户信息
|
||||
const { loading: profileLoading } = useRequest(getUserProfile, {
|
||||
onSuccess: (res) => {
|
||||
if (res) {
|
||||
setProfileValue('username', res.username || '');
|
||||
setProfileValue('avatar', res.avatar_url || '');
|
||||
setAvatarUrl(res.avatar_url || '');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// 重置头像弹窗状态
|
||||
const resetAvatarDialog = () => {
|
||||
setAvatarDialogOpen(false);
|
||||
setSelectedFile(null);
|
||||
setPreviewUrl('');
|
||||
setAvatarUrl(currentAvatar);
|
||||
};
|
||||
|
||||
// 更新用户信息
|
||||
const { loading: updateLoading, run: updateProfile } = useRequest(
|
||||
putUserUpdateProfile,
|
||||
{
|
||||
manual: true,
|
||||
onSuccess: (res) => {
|
||||
message.success('更新成功');
|
||||
if (res) {
|
||||
setUser(res);
|
||||
refreshUser();
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// 提交用户信息更新
|
||||
const onProfileSubmit = (data: ProfileFormData) => {
|
||||
const params: DomainProfileUpdateReq = {
|
||||
username: data.username,
|
||||
};
|
||||
|
||||
if (data.avatar !== (user as DomainUser)?.avatar_url) {
|
||||
params.avatar = data.avatar;
|
||||
}
|
||||
|
||||
updateProfile(params);
|
||||
};
|
||||
|
||||
// 提交密码修改
|
||||
const onPasswordSubmit = (data: PasswordFormData) => {
|
||||
const params: DomainProfileUpdateReq = {
|
||||
old_password: data.oldPassword,
|
||||
password: data.newPassword,
|
||||
};
|
||||
|
||||
updateProfile(params);
|
||||
setPasswordDialogOpen(false);
|
||||
resetPasswordForm();
|
||||
};
|
||||
|
||||
// 处理文件选择
|
||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// 检查文件类型
|
||||
if (!file.type.startsWith('image/')) {
|
||||
message.error('请选择图片文件');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查文件大小(限制为2MB)
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
message.error('图片大小不能超过2MB');
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedFile(file);
|
||||
|
||||
// 创建预览URL
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const result = e.target?.result as string;
|
||||
setPreviewUrl(result);
|
||||
setAvatarUrl(result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
setAvatarDialogOpen(true);
|
||||
};
|
||||
|
||||
// 头像提交处理
|
||||
const handleAvatarSubmit = () => {
|
||||
if (previewUrl) {
|
||||
setProfileValue('avatar', previewUrl);
|
||||
} else {
|
||||
setProfileValue('avatar', avatarUrl);
|
||||
}
|
||||
resetAvatarDialog();
|
||||
};
|
||||
|
||||
// 打开文件选择对话框
|
||||
const triggerFileSelect = () => {
|
||||
const fileInput = document.getElementById(
|
||||
'avatar-file-input'
|
||||
) as HTMLInputElement;
|
||||
fileInput?.click();
|
||||
};
|
||||
|
||||
const currentUsername = watchProfile('username');
|
||||
const currentAvatar = watchProfile('avatar');
|
||||
const newPassword = watchPassword('newPassword');
|
||||
|
||||
return (
|
||||
<Stack gap={3} sx={{ maxWidth: 800, mx: 'auto', py: 2 }}>
|
||||
{/* 页面标题 */}
|
||||
<Typography variant='h5' sx={{ fontWeight: 700, mb: 2 }}>
|
||||
账户设置
|
||||
</Typography>
|
||||
|
||||
{/* 头像设置 */}
|
||||
<Card sx={{ p: 3 }}>
|
||||
<Typography variant='h6' sx={{ fontWeight: 600, mb: 3 }}>
|
||||
头像
|
||||
</Typography>
|
||||
<Stack direction='row' alignItems='center' gap={3}>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
opacity: 0.8,
|
||||
},
|
||||
}}
|
||||
onClick={triggerFileSelect}
|
||||
>
|
||||
<Avatar
|
||||
src={currentAvatar}
|
||||
name={currentUsername}
|
||||
sx={{ width: 80, height: 80, fontSize: 24 }}
|
||||
/>
|
||||
<IconButton
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: -4,
|
||||
right: -4,
|
||||
bgcolor: 'primary.main',
|
||||
color: 'white',
|
||||
width: 32,
|
||||
height: 32,
|
||||
'&:hover': {
|
||||
bgcolor: 'primary.dark',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<PhotoCameraIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Stack>
|
||||
<Typography variant='body1' sx={{ fontWeight: 500 }}>
|
||||
更改头像
|
||||
</Typography>
|
||||
<Typography variant='body2' color='text.secondary'>
|
||||
点击头像或相机图标来选择本地图片文件
|
||||
</Typography>
|
||||
</Stack>
|
||||
{/* 隐藏的文件输入控件 */}
|
||||
<input
|
||||
id='avatar-file-input'
|
||||
type='file'
|
||||
accept='image/*'
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* 基本信息 */}
|
||||
<Card sx={{ p: 3 }}>
|
||||
<Typography variant='h6' sx={{ fontWeight: 600, mb: 3 }}>
|
||||
基本信息
|
||||
</Typography>
|
||||
<form onSubmit={handleProfileSubmit(onProfileSubmit)}>
|
||||
<Stack gap={3}>
|
||||
<FormItem label='用户名' required>
|
||||
<Controller
|
||||
name='username'
|
||||
control={profileControl}
|
||||
rules={{
|
||||
required: '用户名不能为空',
|
||||
minLength: {
|
||||
value: 2,
|
||||
message: '用户名至少2个字符',
|
||||
},
|
||||
}}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
fullWidth
|
||||
size='small'
|
||||
placeholder='请输入用户名'
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem label='邮箱'>
|
||||
<TextField
|
||||
fullWidth
|
||||
size='small'
|
||||
value={(user as DomainUser)?.email || ''}
|
||||
disabled
|
||||
helperText='邮箱地址无法修改'
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, pt: 2 }}>
|
||||
<Button
|
||||
type='submit'
|
||||
variant='contained'
|
||||
disabled={updateLoading || profileLoading}
|
||||
sx={{ minWidth: 100 }}
|
||||
>
|
||||
{updateLoading ? '保存中...' : '保存更改'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{/* 密码设置 */}
|
||||
<Card sx={{ p: 3 }}>
|
||||
<Typography variant='h6' sx={{ fontWeight: 600, mb: 3 }}>
|
||||
密码设置
|
||||
</Typography>
|
||||
<Stack
|
||||
direction='row'
|
||||
alignItems='center'
|
||||
justifyContent='space-between'
|
||||
>
|
||||
<Stack>
|
||||
<Typography variant='body1' sx={{ fontWeight: 500 }}>
|
||||
修改密码
|
||||
</Typography>
|
||||
<Typography variant='body2' color='text.secondary'>
|
||||
为了账户安全,建议定期更换密码
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Button
|
||||
variant='outlined'
|
||||
startIcon={<EditIcon />}
|
||||
onClick={() => setPasswordDialogOpen(true)}
|
||||
>
|
||||
修改密码
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* 修改密码弹窗 */}
|
||||
<Modal
|
||||
title='修改密码'
|
||||
open={passwordDialogOpen}
|
||||
onCancel={() => setPasswordDialogOpen(false)}
|
||||
width={600}
|
||||
onOk={handlePasswordSubmit(onPasswordSubmit)}
|
||||
>
|
||||
<form onSubmit={handlePasswordSubmit(onPasswordSubmit)}>
|
||||
<Stack gap={3}>
|
||||
<FormItem label='当前密码' required>
|
||||
<Controller
|
||||
name='oldPassword'
|
||||
control={passwordControl}
|
||||
rules={{ required: '请输入当前密码' }}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type='password'
|
||||
fullWidth
|
||||
size='small'
|
||||
placeholder='请输入当前密码'
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem label='新密码' required>
|
||||
<Controller
|
||||
name='newPassword'
|
||||
control={passwordControl}
|
||||
rules={{
|
||||
required: '请输入新密码',
|
||||
minLength: {
|
||||
value: 6,
|
||||
message: '密码长度至少6位',
|
||||
},
|
||||
}}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type='password'
|
||||
fullWidth
|
||||
size='small'
|
||||
placeholder='请输入新密码'
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem label='确认新密码' required>
|
||||
<Controller
|
||||
name='confirmPassword'
|
||||
control={passwordControl}
|
||||
rules={{
|
||||
required: '请确认新密码',
|
||||
validate: (value) => {
|
||||
return value === newPassword || '两次输入的密码不一致';
|
||||
},
|
||||
}}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type='password'
|
||||
fullWidth
|
||||
size='small'
|
||||
placeholder='请再次输入新密码'
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormItem>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* 头像设置弹窗 */}
|
||||
<Modal
|
||||
title='更改头像'
|
||||
open={avatarDialogOpen}
|
||||
onCancel={resetAvatarDialog}
|
||||
width={800}
|
||||
onOk={handleAvatarSubmit}
|
||||
>
|
||||
<Stack gap={3} alignItems='center'>
|
||||
<Avatar
|
||||
src={previewUrl || avatarUrl}
|
||||
name={currentUsername}
|
||||
sx={{ width: 120, height: 120, fontSize: 36 }}
|
||||
/>
|
||||
|
||||
{selectedFile && (
|
||||
<Typography variant='body2' color='text.secondary'>
|
||||
已选择文件:{selectedFile.name}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Stack direction='row' gap={2} width='100%'>
|
||||
<Button
|
||||
variant='outlined'
|
||||
fullWidth
|
||||
onClick={triggerFileSelect}
|
||||
startIcon={<PhotoCameraIcon />}
|
||||
>
|
||||
选择文件
|
||||
</Button>
|
||||
<Button
|
||||
variant='outlined'
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
setSelectedFile(null);
|
||||
setPreviewUrl('');
|
||||
setAvatarUrl('');
|
||||
}}
|
||||
>
|
||||
输入链接
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{!selectedFile && (
|
||||
<TextField
|
||||
fullWidth
|
||||
label='头像URL'
|
||||
value={avatarUrl}
|
||||
onChange={(e) => setAvatarUrl(e.target.value)}
|
||||
placeholder='请输入头像图片链接'
|
||||
helperText='支持 http:// 或 https:// 开头的图片链接'
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserSetting;
|
||||
@@ -43,6 +43,7 @@ const UserChat = LazyLoadable(lazy(() => import('@/pages/user/chat')));
|
||||
const UserCompletion = LazyLoadable(
|
||||
lazy(() => import('@/pages/user/completion'))
|
||||
);
|
||||
const UserSetting = LazyLoadable(lazy(() => import('@/pages/user/setting')));
|
||||
|
||||
const UserDashboard = LazyLoadable(
|
||||
lazy(() => import('@/pages/user/dashboard'))
|
||||
@@ -108,6 +109,10 @@ const routerConfig = [
|
||||
path: 'completion',
|
||||
element: <UserCompletion />,
|
||||
},
|
||||
{
|
||||
path: 'setting',
|
||||
element: <UserSetting />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user