feat(models): add models page and refactor profile form

This commit is contained in:
2025-11-14 16:48:42 +08:00
parent 97402674cd
commit ef8076d87f
10 changed files with 267 additions and 94 deletions

View File

@@ -18,17 +18,17 @@ import {
Person as PersonIcon,
} from '@mui/icons-material';
import LanguageSwitcher from '../LanguageSwitcher';
import ProfileFormDialog from '@/components/ProfileFormDialog';
import { useAuth } from '@/hooks/login-hooks';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
const Header = () => {
const { userInfo, logout } = useAuth();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [profileDialogOpen, setProfileDialogOpen] = useState(false);
const open = Boolean(anchorEl);
const navigate = useNavigate();
const {t} = useTranslation();
const { t } = useTranslation();
const handleAvatarClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
@@ -39,7 +39,7 @@ const Header = () => {
};
const handleProfileClick = () => {
navigate('/setting/profile');
setProfileDialogOpen(true);
setAnchorEl(null);
};
@@ -198,14 +198,6 @@ const Header = () => {
</ListItemIcon>
<ListItemText>{t('setting.personalProfile')}</ListItemText>
</MenuItem>
{/* 模型配置 */}
<MenuItem onClick={() => navigate('/setting/models')} sx={{ py: 1 }}>
<ListItemIcon>
<SettingsIcon fontSize="small" />
</ListItemIcon>
<ListItemText>{t('setting.modelSettings')}</ListItemText>
</MenuItem>
<Divider />
<MenuItem onClick={handleLogout} sx={{ py: 1, color: '#d32f2f' }}>
@@ -216,6 +208,10 @@ const Header = () => {
</MenuItem>
</Menu>
</Box>
<ProfileFormDialog
open={profileDialogOpen}
onClose={() => setProfileDialogOpen(false)}
/>
</Box>
);
};

View File

@@ -1,19 +1,66 @@
import { Box, List, ListItemButton, ListItemText, Typography } from '@mui/material';
import { Link, useLocation } from 'react-router-dom';
import { Box, List, ListItemButton, ListItemText, Typography, Collapse, ListItemIcon } from '@mui/material';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import {
LibraryBooksOutlined as KnowledgeBasesIcon,
AccountTreeOutlined as AgentIcon,
SettingsOutlined as SettingIcon,
Person as PersonIcon,
Group as GroupIcon,
ExpandMore as ExpandMoreIcon,
ExpandLess as ExpandLessIcon,
} from '@mui/icons-material';
import { useTranslation } from 'react-i18next';
import { useMemo, useState } from 'react';
type MenuChild = {
text: string;
path: string;
icon?: React.ComponentType<any>;
};
type MenuItem = {
text: string;
path: string;
icon: React.ComponentType<any>;
children?: MenuChild[];
};
const Sidebar = () => {
const location = useLocation();
const navigate = useNavigate();
const { t } = useTranslation();
const navItems = [
const navItems: MenuItem[] = useMemo(() => ([
{ text: t('header.knowledgeBase'), path: '/knowledge', icon: KnowledgeBasesIcon },
{ text: t('header.agent'), path: '/agents', icon: AgentIcon },
];
{ text: t('header.agents'), path: '/agents', icon: AgentIcon },
{ text: t('header.models'), path: '/models', icon: AgentIcon },
{ text: t('header.mcp'), path: '/mcp', icon: AgentIcon },
{
text: t('header.setting'),
path: '/setting',
icon: SettingIcon,
children: [
{ text: t('setting.profile'), path: '/setting/profile', icon: PersonIcon },
{ text: t('setting.team'), path: '/setting/teams', icon: GroupIcon },
],
},
]), [t]);
const [openGroup, setOpenGroup] = useState<Record<string, boolean>>({
'/setting': location.pathname.startsWith('/setting'),
});
const toggleGroup = (path: string) => {
setOpenGroup((prev) => ({ ...prev, [path]: !prev[path] }));
};
const isActivePath = (itemPath: string) => {
if (itemPath === '/knowledge' && (location.pathname === '/' || location.pathname.startsWith('/knowledge'))) {
return true;
}
return location.pathname === itemPath || location.pathname.startsWith(itemPath + '/');
};
return (
<Box
@@ -43,42 +90,99 @@ const Sidebar = () => {
<List>
{navItems.map((item) => {
const IconComponent = item.icon;
let isActive = location.pathname === item.path;
if (item.path === '/knowledge' && location.pathname === '/') {
isActive = true;
}
const hasChildren = Array.isArray(item.children) && item.children.length > 0;
const isActive = isActivePath(item.path);
return (
<Link
key={item.path}
to={item.path}
style={{ textDecoration: 'none', color: 'inherit' }}
>
<ListItemButton
sx={{
color: isActive ? '#FFF' : '#B9B9C2',
backgroundColor: isActive ? 'rgba(226,0,116,0.12)' : 'transparent',
borderLeft: isActive ? '4px solid' : '4px solid transparent',
borderLeftColor: isActive ? 'primary.main' : 'transparent',
fontWeight: isActive ? 600 : 'normal',
'&:hover': {
backgroundColor: 'rgba(255,255,255,0.05)',
color: '#FFF',
},
'& .MuiListItemText-primary': {
fontSize: '0.9rem',
},
}}
>
<IconComponent
<Box key={item.path}>
{hasChildren ? (
<ListItemButton
onClick={() => toggleGroup(item.path)}
sx={{
color: 'primary.main',
marginRight: '12px',
fontSize: '1.2rem'
color: isActive ? '#FFF' : '#B9B9C2',
backgroundColor: isActive ? 'rgba(226,0,116,0.12)' : 'transparent',
borderLeft: isActive ? '4px solid' : '4px solid transparent',
borderLeftColor: isActive ? 'primary.main' : 'transparent',
fontWeight: isActive ? 600 : 'normal',
'&:hover': {
backgroundColor: 'rgba(255,255,255,0.05)',
color: '#FFF',
},
'& .MuiListItemText-primary': {
fontSize: '0.9rem',
},
}}
/>
<ListItemText primary={item.text} />
</ListItemButton>
</Link>
>
<IconComponent sx={{ color: 'primary.main', marginRight: '12px', fontSize: '1.2rem' }} />
<ListItemText primary={item.text} />
{openGroup[item.path] ? (
<ExpandLessIcon sx={{ marginLeft: 'auto', color: '#B9B9C2' }} />
) : (
<ExpandMoreIcon sx={{ marginLeft: 'auto', color: '#B9B9C2' }} />
)}
</ListItemButton>
) : (
<Link to={item.path} style={{ textDecoration: 'none', color: 'inherit' }}>
<ListItemButton
sx={{
color: isActive ? '#FFF' : '#B9B9C2',
backgroundColor: isActive ? 'rgba(226,0,116,0.12)' : 'transparent',
borderLeft: isActive ? '4px solid' : '4px solid transparent',
borderLeftColor: isActive ? 'primary.main' : 'transparent',
fontWeight: isActive ? 600 : 'normal',
'&:hover': {
backgroundColor: 'rgba(255,255,255,0.05)',
color: '#FFF',
},
'& .MuiListItemText-primary': {
fontSize: '0.9rem',
},
}}
>
<IconComponent sx={{ color: 'primary.main', marginRight: '12px', fontSize: '1.2rem' }} />
<ListItemText primary={item.text} />
</ListItemButton>
</Link>
)}
{hasChildren && (
<Collapse in={!!openGroup[item.path]} timeout="auto" unmountOnExit>
<List component="div" disablePadding>
{item.children!.map((child) => {
const ChildIcon = child.icon || IconComponent;
const childActive = location.pathname === child.path;
return (
<Link key={child.path} to={child.path} style={{ textDecoration: 'none', color: 'inherit' }}>
<ListItemButton
sx={{
pl: 6,
color: childActive ? '#FFF' : '#B9B9C2',
backgroundColor: childActive ? 'rgba(226,0,116,0.08)' : 'transparent',
borderLeft: childActive ? '4px solid' : '4px solid transparent',
borderLeftColor: childActive ? 'primary.main' : 'transparent',
fontWeight: childActive ? 600 : 'normal',
'&:hover': {
backgroundColor: 'rgba(255,255,255,0.04)',
color: '#FFF',
},
'& .MuiListItemText-primary': {
fontSize: '0.9rem',
},
}}
onClick={() => navigate(child.path)}
>
<ListItemIcon sx={{ minWidth: 32 }}>
<ChildIcon sx={{ color: 'primary.main', fontSize: '1.1rem' }} />
</ListItemIcon>
<ListItemText primary={child.text} />
</ListItemButton>
</Link>
);
})}
</List>
</Collapse>
)}
</Box>
);
})}
</List>

View File

@@ -0,0 +1,43 @@
import React, { useCallback, useRef } from 'react';
import { Dialog, DialogTitle, DialogContent, DialogActions, Button } from '@mui/material';
import { useTranslation } from 'react-i18next';
import ProfileForm, { type ProfileFormHandle } from '@/pages/setting/components/ProfileForm';
import { useProfileSetting } from '@/hooks/setting-hooks';
import type { IUserInfo } from '@/interfaces/database/user-setting';
interface ProfileFormDialogProps {
open: boolean;
onClose: () => void;
}
/**
* 个人资料编辑对话框
* 复用已有 ProfileForm并使用 useProfileSetting 提供的 userInfo 与更新方法
*/
const ProfileFormDialog: React.FC<ProfileFormDialogProps> = ({ open, onClose }) => {
const { t } = useTranslation();
const { userInfo, updateUserInfo } = useProfileSetting();
const formRef = useRef<ProfileFormHandle>(null);
const handleSubmit = useCallback(async (data: Partial<IUserInfo>) => {
await updateUserInfo(data);
onClose();
}, [updateUserInfo, onClose]);
return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>{t('setting.personalProfile')}</DialogTitle>
<DialogContent>
<ProfileForm ref={formRef} userInfo={userInfo} onSubmit={handleSubmit} showSaveButton={false} />
</DialogContent>
<DialogActions>
<Button onClick={onClose}>{t('setting.cancel')}</Button>
<Button variant="contained" onClick={() => formRef.current?.submit()}>{t('setting.save')}</Button>
</DialogActions>
</Dialog>
);
};
ProfileFormDialog.displayName = 'ProfileFormDialog';
export default ProfileFormDialog;

View File

@@ -278,10 +278,12 @@ export default {
register: 'Register',
signin: 'Sign in',
home: 'Home',
setting: 'User settings',
setting: 'Settings',
logout: 'Log out',
fileManager: 'File Management',
agent: 'Agent',
agents: 'Agents',
models: 'Models',
mcp: 'MCP',
search: 'Search',
welcome: 'Welcome to',
},
@@ -1477,6 +1479,7 @@ This auto-tagging feature enhances retrieval by adding another layer of domain-s
error: 'Error',
success: 'Success',
info: 'Info',
processing: 'Processing',
confirmDelete: 'Confirm Delete',
confirmDeleteMessage: 'This operation cannot be undone. Are you sure you want to delete?',
operationSuccess: 'Operation successful',

View File

@@ -260,10 +260,12 @@ export default {
register: '注册',
signin: '登录',
home: '首页',
setting: '用户设置',
setting: '设置',
logout: '登出',
fileManager: '文件管理',
agent: '智能体',
agents: '智能体',
models: '模型',
mcp: 'MCP',
search: '搜索',
welcome: '欢迎来到',
},
@@ -1441,6 +1443,7 @@ General实体和关系提取提示来自 GitHub - microsoft/graphrag基于
error: '错误',
success: '成功',
info: '信息',
processing: '处理中',
confirmDelete: '确认删除',
confirmDeleteMessage: '此操作不可撤销,确定要删除吗?',
operationSuccess: '操作成功',

View File

@@ -69,6 +69,7 @@ function CreateKnowledgeDialog({ open, onClose, onSuccess }: CreateKnowledgeDial
// 解析相关字段:后端已支持 parser_id / pipeline_id
parser_id: data.parser_id,
pipeline_id: data.pipeline_id,
parse_type: Number.parseInt(parseType) ?? 1,
};
await createKnowledge(requestData);

View File

@@ -25,10 +25,10 @@ import {
} from '@mui/icons-material';
import { useTranslation } from 'react-i18next';
import { useLlmModelSetting } from '@/hooks/setting-hooks';
import { useModelDialogs } from './hooks/useModelDialogs';
import { useModelDialogs } from '../setting/hooks/useModelDialogs';
import type { IFactory, IMyLlmModel, ILlmItem } from '@/interfaces/database/llm';
import LLMFactoryCard, { MODEL_TYPE_COLORS } from './components/LLMFactoryCard';
import { ModelDialogs } from './components/ModelDialogs';
import LLMFactoryCard, { MODEL_TYPE_COLORS } from '../setting/components/LLMFactoryCard';
import { ModelDialogs } from '../setting/components/ModelDialogs';
import { useDialog } from '@/hooks/useDialog';
import logger from '@/utils/logger';
import { LLM_FACTORY_LIST, LocalLlmFactories, type LLMFactory } from '@/constants/llm';

View File

@@ -1,4 +1,4 @@
import React, { useState, useRef } from 'react';
import React, { useState, useRef, forwardRef, useImperativeHandle } from 'react';
import {
Box,
TextField,
@@ -36,20 +36,29 @@ const languageOptions = [
// 时区选项
const timezoneOptions = TimezoneList.map(x => ({ value: x, label: x }));
interface ProfileFormProps {
export interface ProfileFormProps {
userInfo: IUserInfo | null;
onSubmit: (data: Partial<IUserInfo>) => Promise<void>;
showSaveButton?: boolean;
onSave?: () => void | Promise<void>;
}
export type ProfileFormHandle = {
submit: () => Promise<void>;
};
/**
* 个人信息表单
*/
function ProfileForm({ userInfo, onSubmit }: ProfileFormProps) {
const ProfileFormInner = (
{ userInfo, onSubmit, showSaveButton = true, onSave }: ProfileFormProps,
ref: React.ForwardedRef<ProfileFormHandle>
) => {
const { t } = useTranslation();
const showMessage = useMessage();
const fileInputRef = useRef<HTMLInputElement>(null);
const [formData, setFormData] = useState<Partial<IUserInfo>>({
nickname: userInfo?.nickname || '',
avatar: userInfo?.avatar || null,
@@ -96,7 +105,7 @@ function ProfileForm({ userInfo, onSubmit }: ProfileFormProps) {
showMessage.error(t('setting.pleaseSelectImageFile'));
return;
}
// 检查文件大小 (限制为2MB)
if (file.size > 2 * 1024 * 1024) {
showMessage.error(t('setting.imageSizeLimit'));
@@ -145,20 +154,25 @@ function ProfileForm({ userInfo, onSubmit }: ProfileFormProps) {
}
};
// 向父级暴露提交方法
useImperativeHandle(ref, () => ({
submit: handleSave,
}));
return (
<Paper elevation={0} sx={{ p: 3, backgroundColor: 'transparent' }}>
<Typography variant="h6" gutterBottom sx={{ mb: 3, fontWeight: 600 }}>
{t('setting.personalProfile')}
</Typography>
<Grid container spacing={3}>
{/* 头像部分 */}
<Grid size={12}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Avatar
src={formData.avatar}
sx={{
width: 80,
sx={{
width: 80,
height: 80,
border: '2px solid',
borderColor: 'divider'
@@ -174,7 +188,7 @@ function ProfileForm({ userInfo, onSubmit }: ProfileFormProps) {
<IconButton
color="primary"
onClick={triggerFileSelect}
sx={{
sx={{
border: '1px solid',
borderColor: 'primary.main',
'&:hover': {
@@ -260,21 +274,33 @@ function ProfileForm({ userInfo, onSubmit }: ProfileFormProps) {
</FormControl>
</Grid>
{/* 保存按钮 */}
<Grid size={12}>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 2 }}>
<Button
variant="contained"
onClick={handleSave}
sx={{ minWidth: 120 }}
>
{t('setting.save')}
</Button>
</Box>
</Grid>
{/* 保存按钮(可选) */}
{showSaveButton && (
<Grid size={12}>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 2 }}>
<Button
variant="contained"
onClick={async () => {
if (onSave) {
await onSave();
} else {
await handleSave();
}
}}
sx={{ minWidth: 120 }}
>
{t('setting.save')}
</Button>
</Box>
</Grid>
)}
</Grid>
</Paper>
);
}
};
const ProfileForm = forwardRef<ProfileFormHandle, ProfileFormProps>(ProfileFormInner);
ProfileForm.displayName = 'ProfileForm';
export default ProfileForm;

View File

@@ -1,4 +1,3 @@
export { default as ModelsSetting } from './models';
export { default as SystemSetting } from './system';
export { default as TeamsSetting } from './teams';
export { default as ProfileSetting } from './profile';

View File

@@ -2,9 +2,6 @@ import { Routes, Route, Navigate } from 'react-router-dom';
import MainLayout from '@/components/Layout/MainLayout';
import SettingLayout from '@/components/Layout/SettingLayout';
import Login from '@/pages/login/Login';
import PipelineConfig from '@/pages/PipelineConfig';
import Dashboard from '@/pages/Dashboard';
import ModelsResources from '@/pages/ModelsResources';
import AgentList from '@/pages/agent-mui/list';
import {
KnowledgeBaseList,
@@ -15,13 +12,12 @@ import {
KnowledgeLogsPage
} from '@/pages/knowledge';
import {
ModelsSetting,
SystemSetting,
TeamsSetting,
ProfileSetting,
MCPSetting,
} from '@/pages/setting';
import MCP from '@/pages/MCP';
import ModelsSetting from '@/pages/models/models';
import FormFieldTest from '@/pages/FormFieldTest';
import ChunkParsedResult from '@/pages/chunk/parsed-result';
import DocumentPreview from '@/pages/chunk/document-preview';
@@ -30,6 +26,7 @@ import AgentDetailPage from '@/pages/agent-mui/detail';
import RagflowLayout from '@/pages/ragflow/layout';
import RagflowIframePage from '@/pages/ragflow/iframe';
import RagflowAgentPage from '@/pages/ragflow/agent';
import McpSettingPage from '@/pages/setting/mcp';
const AppRoutes = () => {
return (
@@ -53,6 +50,16 @@ const AppRoutes = () => {
<Route path="agents">
<Route index element={<AgentList />} />
</Route>
<Route path='models'>
<Route index element={<ModelsSetting />} />
</Route>
<Route path='mcp'>
<Route index element={<McpSettingPage />} />
</Route>
<Route path='setting'>
<Route path="teams" element={<TeamsSetting />} />
<Route path="profile" element={<ProfileSetting />} />
</Route>
</Route>
<Route path="agent">
<Route path=":id" element={<AgentDetailPage />} />
@@ -63,15 +70,6 @@ const AppRoutes = () => {
{/* 文档预览页面路由 */}
<Route path="document-preview/:kb_id/:doc_id" element={<DocumentPreview />} />
</Route>
{/* setting 相关路由 */}
<Route path="setting" element={<SettingLayout />}>
<Route index element={<ModelsSetting />} />
<Route path="models" element={<ModelsSetting />} />
<Route path="system" element={<SystemSetting />} />
<Route path="teams" element={<TeamsSetting />} />
<Route path="profile" element={<ProfileSetting />} />
<Route path="mcp" element={<MCPSetting />} />
</Route>
{/* 通过 iframe 承载 ragflow 应用,避免路由冲突并保持同源 */}
{/* ragflow 路由分组layout 承载agent 为特定页面,其余走通用 iframe */}