Merge pull request #127 from guanweiwang/main

添加退出登录,修复闪跳问题
This commit is contained in:
Yoko
2025-07-22 18:27:30 +08:00
committed by GitHub
15 changed files with 714 additions and 35 deletions

View File

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

View File

@@ -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 获取系统设置
*

View File

@@ -125,7 +125,7 @@ export const getModelList = (params: RequestParams = {}) =>
});
/**
* @description 报告
* @description 报告支持多种操作accept接受补全、suggest建议、reject拒绝补全并记录用户输入、file_written文件写入
*
* @tags OpenAIV1
* @name PostReport

View File

@@ -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 回调
*

View File

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

View File

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

View File

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

View File

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

View File

@@ -147,6 +147,7 @@ const AuthPage = () => {
const loginResult = await postLogin({
...data,
session_id: sessionId,
source: ConstsLoginSource.LoginSourcePlugin,
});
if (!loginResult.redirect_url) {

View File

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

View File

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

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

View File

@@ -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 />,
},
],
},
{