diff --git a/ui/api-templates/http-client.ejs b/ui/api-templates/http-client.ejs index b7046a9..6df6482 100644 --- a/ui/api-templates/http-client.ejs +++ b/ui/api-templates/http-client.ejs @@ -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 { }, (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') { diff --git a/ui/src/api/Admin.ts b/ui/src/api/Admin.ts index 4eaed3c..fbb2765 100644 --- a/ui/src/api/Admin.ts +++ b/ui/src/api/Admin.ts @@ -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({ + 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 获取系统设置 * diff --git a/ui/src/api/OpenAiv1.ts b/ui/src/api/OpenAiv1.ts index 52ad1e6..f3409af 100644 --- a/ui/src/api/OpenAiv1.ts +++ b/ui/src/api/OpenAiv1.ts @@ -125,7 +125,7 @@ export const getModelList = (params: RequestParams = {}) => }); /** - * @description 报告 + * @description 报告,支持多种操作:accept(接受补全)、suggest(建议)、reject(拒绝补全并记录用户输入)、file_written(文件写入) * * @tags OpenAIV1 * @name PostReport diff --git a/ui/src/api/User.ts b/ui/src/api/User.ts index 7ebbd1a..f099503 100644 --- a/ui/src/api/User.ts +++ b/ui/src/api/User.ts @@ -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({ + path: `/api/v1/user/logout`, + method: "POST", + type: ContentType.Json, + format: "json", + ...params, + }); + /** * @description 用户 OAuth 回调 * diff --git a/ui/src/api/httpClient.ts b/ui/src/api/httpClient.ts index 8042895..26497ca 100644 --- a/ui/src/api/httpClient.ts +++ b/ui/src/api/httpClient.ts @@ -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 { }, (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") { diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index 20df103..5d287a7 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -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"; } diff --git a/ui/src/components/header/index.tsx b/ui/src/components/header/index.tsx index 38f7177..678f3ae 100644 --- a/ui/src/components/header/index.tsx +++ b/ui/src/components/header/index.tsx @@ -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 ( { > 下载客户端 - + { - message.success('退出登录成功'); - navigate(pathname.startsWith('/user/') ? '/user/login' : '/login', { - replace: true, - }); - }} - > - - + '&:hover': { + color: 'primary.main', + }, + }} + onClick={onLogout} + > + + + ); diff --git a/ui/src/context/index.tsx b/ui/src/context/index.tsx new file mode 100644 index 0000000..c650625 --- /dev/null +++ b/ui/src/context/index.tsx @@ -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>; +}>({ contactModalOpen: false, setContactModalOpen: () => {} }); diff --git a/ui/src/hooks/context.ts b/ui/src/hooks/context.ts new file mode 100644 index 0000000..c0903bd --- /dev/null +++ b/ui/src/hooks/context.ts @@ -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); +}; diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 3cb1003..06c0be9 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -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(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 ( + + + + + + ); +}; + const root = ReactDOM.createRoot( document.getElementById('root') as HTMLElement ); -root.render( - - - -); +root.render(); diff --git a/ui/src/pages/auth/index.tsx b/ui/src/pages/auth/index.tsx index ab1ab7b..63088b2 100644 --- a/ui/src/pages/auth/index.tsx +++ b/ui/src/pages/auth/index.tsx @@ -147,6 +147,7 @@ const AuthPage = () => { const loginResult = await postLogin({ ...data, session_id: sessionId, + source: ConstsLoginSource.LoginSourcePlugin, }); if (!loginResult.redirect_url) { diff --git a/ui/src/pages/invite/index.tsx b/ui/src/pages/invite/index.tsx index 9dc76b7..1428fc6 100644 --- a/ui/src/pages/invite/index.tsx +++ b/ui/src/pages/invite/index.tsx @@ -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; diff --git a/ui/src/pages/login/index.tsx b/ui/src/pages/login/index.tsx index 6a2263c..cce67dd 100644 --- a/ui/src/pages/login/index.tsx +++ b/ui/src/pages/login/index.tsx @@ -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, }); }; diff --git a/ui/src/pages/user/setting/index.tsx b/ui/src/pages/user/setting/index.tsx new file mode 100644 index 0000000..5adccc0 --- /dev/null +++ b/ui/src/pages/user/setting/index.tsx @@ -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(null); + const [previewUrl, setPreviewUrl] = useState(''); + + // 用户信息表单 + const { + control: profileControl, + handleSubmit: handleProfileSubmit, + setValue: setProfileValue, + watch: watchProfile, + } = useForm({ + defaultValues: { + username: '', + avatar: '', + }, + }); + + // 密码修改表单 + const { + control: passwordControl, + handleSubmit: handlePasswordSubmit, + formState: { errors: passwordErrors }, + reset: resetPasswordForm, + watch: watchPassword, + } = useForm({ + 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) => { + 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 ( + + {/* 页面标题 */} + + 账户设置 + + + {/* 头像设置 */} + + + 头像 + + + + + + + + + + + 更改头像 + + + 点击头像或相机图标来选择本地图片文件 + + + {/* 隐藏的文件输入控件 */} + + + + + {/* 基本信息 */} + + + 基本信息 + +
+ + + ( + + )} + /> + + + + + + + + + + +
+
+ + {/* 密码设置 */} + + + 密码设置 + + + + + 修改密码 + + + 为了账户安全,建议定期更换密码 + + + + + + + {/* 修改密码弹窗 */} + setPasswordDialogOpen(false)} + width={600} + onOk={handlePasswordSubmit(onPasswordSubmit)} + > +
+ + + ( + + )} + /> + + + + ( + + )} + /> + + + + { + return value === newPassword || '两次输入的密码不一致'; + }, + }} + render={({ field, fieldState }) => ( + + )} + /> + + +
+
+ + {/* 头像设置弹窗 */} + + + + + {selectedFile && ( + + 已选择文件:{selectedFile.name} + + )} + + + + + + + {!selectedFile && ( + setAvatarUrl(e.target.value)} + placeholder='请输入头像图片链接' + helperText='支持 http:// 或 https:// 开头的图片链接' + /> + )} + + +
+ ); +}; + +export default UserSetting; diff --git a/ui/src/router.tsx b/ui/src/router.tsx index 7c29808..240d2f0 100644 --- a/ui/src/router.tsx +++ b/ui/src/router.tsx @@ -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: , }, + { + path: 'setting', + element: , + }, ], }, {