feat: Add Personal Center module and UI improvements

- Add ProfilePage with avatar upload, password change, and module status display

- Update logo and favicon for login page and browser tab

- Redirect Data Cleaning module default route to Tool C

- Hide Settings button from top navigation for MVP

- Add avatar display in top navigation bar with refresh sync

- Add Prompt knowledge base integration development plan docs
This commit is contained in:
2026-01-28 18:18:09 +08:00
parent 5d5a174dd7
commit 3a4aa9123c
17 changed files with 1309 additions and 22 deletions

View File

@@ -21,6 +21,8 @@ import { MODULES } from './framework/modules/moduleRegistry'
import UserListPage from './modules/admin/pages/UserListPage'
import UserFormPage from './modules/admin/pages/UserFormPage'
import UserDetailPage from './modules/admin/pages/UserDetailPage'
// 个人中心页面
import ProfilePage from './pages/user/ProfilePage'
/**
* 应用根组件
@@ -86,6 +88,10 @@ function App() {
}
/>
))}
{/* 个人中心路由 - 2026-01-28 新增 */}
<Route path="/user/profile" element={<ProfilePage />} />
<Route path="/user/settings" element={<ProfilePage />} />
</Route>
{/* 运营管理端 /admin/* */}

View File

@@ -188,6 +188,19 @@ export function AuthProvider({ children }: AuthProviderProps) {
return user.modules?.includes(moduleCode) || false;
}, [user]);
/**
* 刷新用户信息(头像更新等场景使用)
*/
const refreshUser = useCallback(async () => {
try {
const freshUser = await authApi.getCurrentUser();
setUser(freshUser);
authApi.saveUser(freshUser);
} catch (err) {
console.error('刷新用户信息失败:', err);
}
}, []);
const value: AuthContextType = {
user,
isAuthenticated: !!user,
@@ -201,7 +214,8 @@ export function AuthProvider({ children }: AuthProviderProps) {
refreshToken,
hasPermission,
hasRole,
hasModule, // 新增
hasModule,
refreshUser, // 2026-01-28: 头像更新等场景使用
};
return (

View File

@@ -198,6 +198,29 @@ export async function changePassword(request: ChangePasswordRequest): Promise<vo
}
}
/**
* 更新头像
*/
export async function updateAvatar(avatarUrl: string): Promise<{ avatarUrl: string }> {
const response = await authFetch<{ avatarUrl: string }>(`${API_BASE}/me/avatar`, {
method: 'PUT',
body: JSON.stringify({ avatarUrl }),
});
if (!response.success || !response.data) {
throw new Error(response.message || '更新头像失败');
}
// 更新本地存储的用户信息
const user = getSavedUser();
if (user) {
user.avatarUrl = avatarUrl;
saveUser(user);
}
return response.data;
}
/**
* 刷新Token
*/

View File

@@ -29,6 +29,13 @@ export interface AuthUser {
isDefaultPassword: boolean;
permissions: string[];
modules: string[]; // 用户可访问的模块代码列表(如 ['AIA', 'PKB', 'RVW']
// 2026-01-28: 个人中心扩展字段
avatarUrl?: string | null;
status?: string;
kbQuota?: number;
kbUsed?: number;
isTrial?: boolean;
trialEndsAt?: string | null; // ISO date string
}
/** Token信息 */
@@ -100,6 +107,8 @@ export interface AuthContextType extends AuthState {
hasRole: (...roles: UserRole[]) => boolean;
/** 检查模块权限 */
hasModule: (moduleCode: string) => boolean;
/** 刷新用户信息(头像更新等场景使用) */
refreshUser: () => Promise<void>;
}

View File

@@ -1,17 +1,15 @@
import { useState, useEffect } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { Dropdown, Avatar } from 'antd'
import {
UserOutlined,
LogoutOutlined,
SettingOutlined,
// SettingOutlined, // MVP阶段暂时隐藏设置按钮
ControlOutlined,
BankOutlined,
} from '@ant-design/icons'
import type { MenuProps } from 'antd'
import { MODULES } from '../modules/moduleRegistry'
import { useAuth } from '../auth'
import type { ModuleDefinition } from '../modules/types'
/**
* 顶部导航栏组件
@@ -53,11 +51,12 @@ const TopNavigation = () => {
icon: <UserOutlined />,
label: '个人中心',
},
{
key: 'settings',
icon: <SettingOutlined />,
label: '设置',
},
// MVP阶段暂时隐藏设置按钮
// {
// key: 'settings',
// icon: <SettingOutlined />,
// label: '设置',
// },
// 切换入口 - 根据权限显示
...(canAccessOrg || canAccessAdmin ? [{ type: 'divider' as const }] : []),
...(canAccessOrg ? [{
@@ -103,9 +102,9 @@ const TopNavigation = () => {
onClick={() => navigate('/')}
>
<img
src="/logo.jpg"
src="/logo-new.png"
alt="AI临床研究平台"
className="h-[52px] w-auto"
className="h-[48px] w-auto"
/>
<span className="text-xl font-bold text-blue-600">AI临床研究平台</span>
</div>
@@ -133,14 +132,15 @@ const TopNavigation = () => {
})}
</div>
{/* 用户菜单 - 显示真实用户信息 */}
{/* 用户菜单 - 显示真实用户信息和头像 */}
<Dropdown
menu={{ items: userMenuItems, onClick: handleUserMenuClick }}
placement="bottomRight"
>
<div className="flex items-center gap-2 cursor-pointer px-3 py-2 rounded-md hover:bg-gray-50">
<Avatar
icon={<UserOutlined />}
src={user?.avatarUrl}
icon={!user?.avatarUrl && <UserOutlined />}
size="small"
/>
<div className="flex flex-col">

View File

@@ -3,14 +3,17 @@
* 数据清洗整理模块
*
* 路由结构:
* - / → Portal工作台主页
* - / → 直接跳转到 Tool C科研数据编辑器
* - /tool-a → Tool A - 超级合并器(暂未开发)
* - /tool-b → Tool B - 病历结构化机器人(✅ 已完成)
* - /tool-c → Tool C - 科研数据编辑器(🚀 Day 4-5开发中
* - /tool-c → Tool C - 科研数据编辑器(🚀 主力工具
* - /portal → Portal工作台保留暂不展示
*
* 2026-01-28 更新:默认直接进入 Tool C不再显示 Portal 页面
*/
import { Suspense, lazy } from 'react';
import { Routes, Route } from 'react-router-dom';
import { Routes, Route, Navigate } from 'react-router-dom';
import { Spin } from 'antd';
import Placeholder from '@/shared/components/Placeholder';
@@ -29,8 +32,11 @@ const DCModule = () => {
}
>
<Routes>
{/* Portal主页 */}
<Route index element={<Portal />} />
{/* 默认直接跳转到 Tool C */}
<Route index element={<Navigate to="tool-c" replace />} />
{/* Portal工作台保留可通过 /data-cleaning/portal 访问) */}
<Route path="portal" element={<Portal />} />
{/* Tool A - 超级合并器(暂未开发) */}
<Route

View File

@@ -33,6 +33,7 @@ interface TenantConfig {
// 默认配置
const DEFAULT_CONFIG: TenantConfig = {
name: 'AI临床研究平台',
logo: '/logo-new.png', // 默认使用新 Logo
primaryColor: '#1890ff',
systemName: 'AI临床研究平台',
isReviewOnly: false,
@@ -261,7 +262,7 @@ export default function LoginPage() {
<img
src={tenantConfig.logo}
alt={tenantConfig.name}
style={{ height: 48, marginBottom: 16 }}
style={{ height: 108, marginBottom: 16, borderRadius: 8 }}
/>
) : (
<div style={{

View File

@@ -0,0 +1,411 @@
/**
* 个人中心页面
*
* 功能:
* - 查看/修改头像(可选)
* - 查看姓名、手机号
* - 修改密码
* - 查看账户状态(有效期)
* - 查看已开通模块(展示所有模块,标记已开通)
*
* @version 2026-01-28
*/
import { useState, useEffect } from 'react';
import {
Card,
Avatar,
Button,
Upload,
Modal,
Form,
Input,
message,
Spin,
Tag,
Divider,
Typography,
Row,
Col
} from 'antd';
import {
UserOutlined,
CameraOutlined,
LockOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
PhoneOutlined,
SafetyCertificateOutlined,
AppstoreOutlined,
} from '@ant-design/icons';
import type { UploadProps } from 'antd';
import { useAuth } from '@/framework/auth';
import { getCurrentUser, changePassword } from '@/framework/auth/api';
import type { AuthUser, ChangePasswordRequest } from '@/framework/auth/types';
import { MODULES } from '@/framework/modules/moduleRegistry';
import type { ModuleDefinition } from '@/framework/modules/types';
const { Title, Text } = Typography;
const ProfilePage = () => {
const { refreshUser } = useAuth();
const [loading, setLoading] = useState(true);
const [userInfo, setUserInfo] = useState<AuthUser | null>(null);
const [passwordModalOpen, setPasswordModalOpen] = useState(false);
const [passwordLoading, setPasswordLoading] = useState(false);
const [uploadLoading, setUploadLoading] = useState(false);
const [form] = Form.useForm();
// 加载用户详细信息
useEffect(() => {
loadUserInfo();
}, []);
const loadUserInfo = async () => {
try {
setLoading(true);
const user = await getCurrentUser();
setUserInfo(user);
} catch (error) {
console.error('加载用户信息失败', error);
message.error('加载用户信息失败');
} finally {
setLoading(false);
}
};
// 处理头像上传
const handleAvatarUpload: UploadProps['customRequest'] = async (options) => {
const { file, onSuccess, onError } = options;
try {
setUploadLoading(true);
// 创建 FormData
const formData = new FormData();
formData.append('file', file as Blob);
// 调用后端头像上传接口(上传到 staticStorage
const response = await fetch('/api/v1/auth/me/avatar/upload', {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
},
body: formData,
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || '上传失败');
}
const result = await response.json();
if (!result.success) {
throw new Error(result.message || '上传失败');
}
// 刷新用户信息(本地页面 + 全局 AuthContext
await loadUserInfo();
await refreshUser(); // 同步更新右上角头像
message.success('头像更新成功');
onSuccess?.(result);
} catch (error) {
console.error('头像上传失败', error);
const errorMessage = error instanceof Error ? error.message : '头像上传失败';
message.error(errorMessage);
onError?.(error as Error);
} finally {
setUploadLoading(false);
}
};
// 处理修改密码
const handleChangePassword = async (values: ChangePasswordRequest) => {
try {
setPasswordLoading(true);
await changePassword(values);
message.success('密码修改成功');
setPasswordModalOpen(false);
form.resetFields();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '修改密码失败';
message.error(errorMessage);
} finally {
setPasswordLoading(false);
}
};
// 格式化手机号隐藏中间4位
const formatPhone = (phone: string) => {
if (!phone || phone.length !== 11) return phone;
return `${phone.slice(0, 3)}****${phone.slice(7)}`;
};
// 计算账户有效期显示
const getAccountStatus = () => {
if (!userInfo) return { text: '未知', color: 'default' };
if (userInfo.status !== 'active') {
return { text: '已禁用', color: 'error' };
}
if (userInfo.isTrial && userInfo.trialEndsAt) {
const endDate = new Date(userInfo.trialEndsAt);
const now = new Date();
const daysLeft = Math.ceil((endDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
if (daysLeft <= 0) {
return { text: '试用已过期', color: 'error' };
} else if (daysLeft <= 7) {
return { text: `试用中(剩余 ${daysLeft} 天)`, color: 'warning' };
} else {
return { text: `试用中(剩余 ${daysLeft} 天)`, color: 'processing' };
}
}
return { text: '正式用户', color: 'success' };
};
// 获取有效期显示文本
const getExpiryText = () => {
if (!userInfo) return '-';
if (userInfo.isTrial && userInfo.trialEndsAt) {
return new Date(userInfo.trialEndsAt).toLocaleDateString('zh-CN');
}
return '永久有效';
};
if (loading) {
return (
<div className="flex-1 flex items-center justify-center h-[calc(100vh-64px)]">
<Spin size="large" />
</div>
);
}
const accountStatus = getAccountStatus();
return (
<div className="flex-1 p-8 bg-gray-50 min-h-[calc(100vh-64px)] overflow-y-auto">
<div className="max-w-4xl mx-auto pb-8">
<Title level={2} className="mb-6"></Title>
{/* 基本信息卡片 */}
<Card className="mb-6">
<div className="flex items-start gap-6">
{/* 头像区域 */}
<div className="flex flex-col items-center">
<Upload
name="avatar"
showUploadList={false}
accept="image/*"
customRequest={handleAvatarUpload}
disabled={uploadLoading}
>
<div className="relative cursor-pointer group">
<Avatar
size={100}
src={userInfo?.avatarUrl}
icon={<UserOutlined />}
className="border-2 border-gray-200"
/>
<div className="absolute inset-0 bg-black bg-opacity-40 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
{uploadLoading ? (
<Spin size="small" />
) : (
<CameraOutlined className="text-white text-2xl" />
)}
</div>
</div>
</Upload>
<Text type="secondary" className="mt-2 text-xs"></Text>
</div>
{/* 用户信息 */}
<div className="flex-1">
<div className="mb-4">
<Title level={4} className="mb-1">{userInfo?.name}</Title>
<Tag color={accountStatus.color as any}>{accountStatus.text}</Tag>
</div>
<Row gutter={[24, 16]}>
<Col span={12}>
<div className="flex items-center gap-2 text-gray-600">
<PhoneOutlined />
<Text>{formatPhone(userInfo?.phone || '')}</Text>
</div>
</Col>
<Col span={12}>
<div className="flex items-center gap-2 text-gray-600">
<SafetyCertificateOutlined />
<Text>{getExpiryText()}</Text>
</div>
</Col>
</Row>
</div>
</div>
</Card>
{/* 账户安全卡片 */}
<Card title="账户安全" className="mb-6">
<div className="flex items-center justify-between py-2">
<div className="flex items-center gap-3">
<LockOutlined className="text-xl text-gray-400" />
<div>
<Text strong></Text>
<br />
<Text type="secondary" className="text-sm">
{userInfo?.isDefaultPassword
? '您正在使用默认密码,建议尽快修改'
: '定期修改密码可以提高账户安全性'}
</Text>
</div>
</div>
<Button
type={userInfo?.isDefaultPassword ? 'primary' : 'default'}
onClick={() => setPasswordModalOpen(true)}
>
{userInfo?.isDefaultPassword ? '立即修改' : '修改密码'}
</Button>
</div>
</Card>
{/* 已开通模块卡片 */}
<Card
title={
<div className="flex items-center gap-2">
<AppstoreOutlined />
<span></span>
</div>
}
>
<Text type="secondary" className="mb-4 block">
绿
</Text>
<Row gutter={[16, 16]}>
{MODULES.map((module: ModuleDefinition) => {
const isEnabled = userInfo?.modules?.includes(module.moduleCode || '');
return (
<Col xs={24} sm={12} md={8} key={module.id}>
<div
className={`
p-4 rounded-lg border-2 transition-all
${isEnabled
? 'border-green-200 bg-green-50'
: 'border-gray-200 bg-gray-50 opacity-60'}
`}
>
<div className="flex items-start gap-3">
<div className={`text-2xl ${isEnabled ? 'text-green-600' : 'text-gray-400'}`}>
{module.icon && <module.icon />}
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<Text strong className={isEnabled ? 'text-green-700' : 'text-gray-500'}>
{module.name}
</Text>
{isEnabled ? (
<CheckCircleOutlined className="text-green-500" />
) : (
<CloseCircleOutlined className="text-gray-400" />
)}
</div>
<Text type="secondary" className="text-xs">
{module.description}
</Text>
</div>
</div>
</div>
</Col>
);
})}
</Row>
{/* 开通更多模块提示 */}
<Divider />
<div className="text-center">
<Text type="secondary">
</Text>
</div>
</Card>
</div>
{/* 修改密码弹窗 */}
<Modal
title="修改密码"
open={passwordModalOpen}
onCancel={() => {
setPasswordModalOpen(false);
form.resetFields();
}}
footer={null}
destroyOnClose
>
<Form
form={form}
layout="vertical"
onFinish={handleChangePassword}
className="mt-4"
>
{!userInfo?.isDefaultPassword && (
<Form.Item
name="oldPassword"
label="原密码"
rules={[{ required: true, message: '请输入原密码' }]}
>
<Input.Password placeholder="请输入原密码" />
</Form.Item>
)}
<Form.Item
name="newPassword"
label="新密码"
rules={[
{ required: true, message: '请输入新密码' },
{ min: 6, message: '密码长度至少6位' },
]}
>
<Input.Password placeholder="请输入新密码至少6位" />
</Form.Item>
<Form.Item
name="confirmPassword"
label="确认新密码"
dependencies={['newPassword']}
rules={[
{ required: true, message: '请确认新密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('newPassword') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('两次输入的密码不一致'));
},
}),
]}
>
<Input.Password placeholder="请再次输入新密码" />
</Form.Item>
<Form.Item className="mb-0 flex justify-end">
<Button onClick={() => setPasswordModalOpen(false)} className="mr-2">
</Button>
<Button type="primary" htmlType="submit" loading={passwordLoading}>
</Button>
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default ProfilePage;