feat(admin): Implement operational monitoring MVP and login optimization
Summary: - Add SimpleLog table for activity tracking (admin_schema) - Implement ActivityService with fire-and-forget pattern - Add stats API endpoints (overview/live-feed/user-overview/cleanup) - Complete activity logging for 7 modules (SYSTEM/AIA/PKB/ASL/DC/RVW/IIT) - Update Admin Dashboard with DAU/DAT metrics and live feed - Fix user module permission display logic - Fix login redirect to /ai-qa instead of homepage - Replace top navigation LOGO with brand image - Fix PKB workspace layout CSS conflict (rename to .pa-chat-container) New files: - backend/src/common/services/activity.service.ts - backend/src/modules/admin/controllers/statsController.ts - backend/src/modules/admin/routes/statsRoutes.ts - frontend-v2/src/modules/admin/api/statsApi.ts - docs/03-.../04-operational-monitoring-mvp-plan.md - docs/03-.../04-operational-monitoring-mvp-implementation.md Tested: All features verified locally
This commit is contained in:
@@ -39,13 +39,22 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
await authApi.refreshAccessToken();
|
||||
const freshUser = await authApi.getCurrentUser();
|
||||
setUser(freshUser);
|
||||
authApi.saveUser(freshUser); // 保存最新用户信息
|
||||
} catch {
|
||||
// 刷新失败,清除状态
|
||||
authApi.clearTokens();
|
||||
setUser(null);
|
||||
}
|
||||
} else {
|
||||
setUser(savedUser);
|
||||
// Token 有效,但仍需获取最新用户信息(确保 modules 等字段是最新的)
|
||||
try {
|
||||
const freshUser = await authApi.getCurrentUser();
|
||||
setUser(freshUser);
|
||||
authApi.saveUser(freshUser); // 更新本地存储
|
||||
} catch {
|
||||
// 获取失败,使用本地缓存
|
||||
setUser(savedUser);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -102,7 +102,11 @@ const TopNavigation = () => {
|
||||
className="flex items-center gap-3 cursor-pointer"
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
<div className="text-2xl">🏥</div>
|
||||
<img
|
||||
src="/logo.jpg"
|
||||
alt="AI临床研究平台"
|
||||
className="h-[52px] w-auto"
|
||||
/>
|
||||
<span className="text-xl font-bold text-blue-600">AI临床研究平台</span>
|
||||
</div>
|
||||
|
||||
|
||||
82
frontend-v2/src/modules/admin/api/statsApi.ts
Normal file
82
frontend-v2/src/modules/admin/api/statsApi.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 运营统计 API
|
||||
*/
|
||||
|
||||
import { authRequest } from '@/framework/request';
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
|
||||
export interface OverviewData {
|
||||
dau: number;
|
||||
dat: number;
|
||||
exportCount: number;
|
||||
moduleStats: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface ActivityLog {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
tenantName: string | null;
|
||||
userName: string | null;
|
||||
module: string;
|
||||
feature: string;
|
||||
action: string;
|
||||
info: string | null;
|
||||
}
|
||||
|
||||
export interface UserAssets {
|
||||
aia: { conversationCount: number };
|
||||
pkb: { kbCount: number; docCount: number };
|
||||
dc: { taskCount: number };
|
||||
rvw: { reviewTaskCount: number; completedCount: number };
|
||||
}
|
||||
|
||||
export interface UserOverview {
|
||||
profile: {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
tenantName: string | null;
|
||||
} | null;
|
||||
assets: UserAssets;
|
||||
activities: Array<{
|
||||
createdAt: string;
|
||||
module: string;
|
||||
feature: string;
|
||||
action: string;
|
||||
info: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
// ==================== API 函数 ====================
|
||||
|
||||
/**
|
||||
* 获取今日大盘数据
|
||||
*/
|
||||
export async function getOverview(): Promise<OverviewData> {
|
||||
const res = await authRequest.get<{ success: boolean; data: OverviewData }>(
|
||||
'/api/admin/stats/overview'
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取实时流水账
|
||||
*/
|
||||
export async function getLiveFeed(limit = 100): Promise<ActivityLog[]> {
|
||||
const res = await authRequest.get<{ success: boolean; data: ActivityLog[] }>(
|
||||
`/api/admin/stats/live-feed?limit=${limit}`
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户360画像
|
||||
*/
|
||||
export async function getUserOverview(userId: string): Promise<UserOverview> {
|
||||
const res = await authRequest.get<{ success: boolean; data: UserOverview }>(
|
||||
`/api/admin/users/${userId}/overview`
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* ADMIN(运营管理端)模块入口
|
||||
*
|
||||
* 功能:
|
||||
* - 运营监控看板
|
||||
* - 用户管理
|
||||
* - 租户管理(已有)
|
||||
* - Prompt管理(已有)
|
||||
@@ -12,11 +13,15 @@ import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import UserListPage from './pages/UserListPage';
|
||||
import UserFormPage from './pages/UserFormPage';
|
||||
import UserDetailPage from './pages/UserDetailPage';
|
||||
import StatsDashboardPage from './pages/StatsDashboardPage';
|
||||
|
||||
const AdminModule: React.FC = () => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="users" replace />} />
|
||||
<Route path="/" element={<Navigate to="stats" replace />} />
|
||||
|
||||
{/* 运营监控看板 */}
|
||||
<Route path="stats" element={<StatsDashboardPage />} />
|
||||
|
||||
{/* 用户管理 */}
|
||||
<Route path="users" element={<UserListPage />} />
|
||||
|
||||
279
frontend-v2/src/modules/admin/pages/StatsDashboardPage.tsx
Normal file
279
frontend-v2/src/modules/admin/pages/StatsDashboardPage.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* 运营统计看板
|
||||
*
|
||||
* 展示 DAU/DAT、模块使用统计、实时流水账
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, Statistic, Row, Col, Table, Tag, Spin, Empty, Tooltip } from 'antd';
|
||||
import {
|
||||
UserOutlined,
|
||||
BankOutlined,
|
||||
ExportOutlined,
|
||||
MessageOutlined,
|
||||
BookOutlined,
|
||||
SearchOutlined,
|
||||
FileTextOutlined,
|
||||
SyncOutlined,
|
||||
LoginOutlined,
|
||||
CloudUploadOutlined,
|
||||
DeleteOutlined,
|
||||
WarningOutlined,
|
||||
ReloadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { getOverview, getLiveFeed } from '../api/statsApi';
|
||||
import type { OverviewData, ActivityLog } from '../api/statsApi';
|
||||
|
||||
// ==================== 模块图标映射 ====================
|
||||
|
||||
const MODULE_ICONS: Record<string, React.ReactNode> = {
|
||||
'SYSTEM': <LoginOutlined />,
|
||||
'AIA': <MessageOutlined />,
|
||||
'PKB': <BookOutlined />,
|
||||
'ASL': <SearchOutlined />,
|
||||
'DC': <FileTextOutlined />,
|
||||
'RVW': <FileTextOutlined />,
|
||||
'IIT': <SyncOutlined />,
|
||||
};
|
||||
|
||||
const MODULE_COLORS: Record<string, string> = {
|
||||
'SYSTEM': '#8c8c8c',
|
||||
'AIA': '#1890ff',
|
||||
'PKB': '#52c41a',
|
||||
'ASL': '#722ed1',
|
||||
'DC': '#fa8c16',
|
||||
'RVW': '#eb2f96',
|
||||
'IIT': '#13c2c2',
|
||||
};
|
||||
|
||||
const ACTION_ICONS: Record<string, React.ReactNode> = {
|
||||
'LOGIN': <LoginOutlined style={{ color: '#52c41a' }} />,
|
||||
'USE': <MessageOutlined style={{ color: '#1890ff' }} />,
|
||||
'EXPORT': <CloudUploadOutlined style={{ color: '#722ed1' }} />,
|
||||
'CREATE': <ExportOutlined style={{ color: '#fa8c16' }} />,
|
||||
'DELETE': <DeleteOutlined style={{ color: '#ff4d4f' }} />,
|
||||
'ERROR': <WarningOutlined style={{ color: '#ff4d4f' }} />,
|
||||
};
|
||||
|
||||
// ==================== 组件 ====================
|
||||
|
||||
export default function StatsDashboardPage() {
|
||||
const [liveFeedLimit] = useState(50);
|
||||
|
||||
// 获取大盘数据
|
||||
const { data: overview, isLoading: overviewLoading, refetch: refetchOverview } = useQuery({
|
||||
queryKey: ['admin-stats-overview'],
|
||||
queryFn: getOverview,
|
||||
refetchInterval: 30000, // 30秒自动刷新
|
||||
});
|
||||
|
||||
// 获取实时流水账
|
||||
const { data: liveFeed, isLoading: liveFeedLoading, refetch: refetchLiveFeed } = useQuery({
|
||||
queryKey: ['admin-stats-live-feed', liveFeedLimit],
|
||||
queryFn: () => getLiveFeed(liveFeedLimit),
|
||||
refetchInterval: 10000, // 10秒自动刷新
|
||||
});
|
||||
|
||||
// 刷新所有数据
|
||||
const handleRefresh = () => {
|
||||
refetchOverview();
|
||||
refetchLiveFeed();
|
||||
};
|
||||
|
||||
// 流水账表格列
|
||||
const columns = [
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 90,
|
||||
render: (time: string) => {
|
||||
const date = new Date(time);
|
||||
return (
|
||||
<Tooltip title={date.toLocaleString()}>
|
||||
<span className="text-gray-500 text-xs">
|
||||
{date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '动作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
width: 80,
|
||||
render: (action: string) => (
|
||||
<span className="flex items-center gap-1">
|
||||
{ACTION_ICONS[action] || null}
|
||||
<span className="text-xs">{action}</span>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '模块',
|
||||
dataIndex: 'module',
|
||||
key: 'module',
|
||||
width: 80,
|
||||
render: (module: string) => (
|
||||
<Tag
|
||||
icon={MODULE_ICONS[module]}
|
||||
color={MODULE_COLORS[module]}
|
||||
className="text-xs"
|
||||
>
|
||||
{module}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '功能',
|
||||
dataIndex: 'feature',
|
||||
key: 'feature',
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '用户',
|
||||
dataIndex: 'userName',
|
||||
key: 'userName',
|
||||
width: 100,
|
||||
render: (name: string | null, record: ActivityLog) => (
|
||||
<Tooltip title={record.tenantName || '未知租户'}>
|
||||
<span className="text-gray-600">{name || '-'}</span>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '详情',
|
||||
dataIndex: 'info',
|
||||
key: 'info',
|
||||
ellipsis: true,
|
||||
render: (info: string | null) => (
|
||||
<span className="text-gray-400 text-xs">{info || '-'}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-gray-50 min-h-screen">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-800">运营监控看板</h1>
|
||||
<p className="text-gray-500 text-sm mt-1">实时监控系统使用情况</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<ReloadOutlined className={overviewLoading || liveFeedLoading ? 'animate-spin' : ''} />
|
||||
刷新数据
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 核心指标卡片 */}
|
||||
<Row gutter={[16, 16]} className="mb-6">
|
||||
<Col xs={24} sm={8}>
|
||||
<Card className="shadow-sm hover:shadow-md transition-shadow">
|
||||
<Statistic
|
||||
title={<span className="text-gray-600">今日活跃医生 (DAU)</span>}
|
||||
value={overview?.dau ?? 0}
|
||||
prefix={<UserOutlined className="text-blue-500" />}
|
||||
loading={overviewLoading}
|
||||
valueStyle={{ color: '#1890ff', fontWeight: 'bold' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card className="shadow-sm hover:shadow-md transition-shadow">
|
||||
<Statistic
|
||||
title={<span className="text-gray-600">今日活跃租户 (DAT)</span>}
|
||||
value={overview?.dat ?? 0}
|
||||
prefix={<BankOutlined className="text-green-500" />}
|
||||
loading={overviewLoading}
|
||||
valueStyle={{ color: '#52c41a', fontWeight: 'bold' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card className="shadow-sm hover:shadow-md transition-shadow">
|
||||
<Statistic
|
||||
title={<span className="text-gray-600">今日导出次数</span>}
|
||||
value={overview?.exportCount ?? 0}
|
||||
prefix={<ExportOutlined className="text-purple-500" />}
|
||||
loading={overviewLoading}
|
||||
valueStyle={{ color: '#722ed1', fontWeight: 'bold' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 模块使用统计 */}
|
||||
{overview?.moduleStats && Object.keys(overview.moduleStats).length > 0 && (
|
||||
<Card
|
||||
title="模块使用统计"
|
||||
className="mb-6 shadow-sm"
|
||||
extra={<span className="text-gray-400 text-xs">今日</span>}
|
||||
>
|
||||
<Row gutter={[16, 16]}>
|
||||
{Object.entries(overview.moduleStats).map(([module, count]) => (
|
||||
<Col key={module} xs={12} sm={8} md={6} lg={4}>
|
||||
<div className="text-center p-4 bg-gray-50 rounded-lg">
|
||||
<div className="text-2xl mb-2">
|
||||
{MODULE_ICONS[module] || <MessageOutlined />}
|
||||
</div>
|
||||
<div className="text-2xl font-bold" style={{ color: MODULE_COLORS[module] }}>
|
||||
{count}
|
||||
</div>
|
||||
<div className="text-gray-500 text-sm">{module}</div>
|
||||
</div>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 实时流水账 */}
|
||||
<Card
|
||||
title="实时操作流水"
|
||||
className="shadow-sm"
|
||||
extra={
|
||||
<span className="text-gray-400 text-xs">
|
||||
最近 {liveFeedLimit} 条 · 10秒自动刷新
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{liveFeedLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Spin tip="加载中..." />
|
||||
</div>
|
||||
) : liveFeed && liveFeed.length > 0 ? (
|
||||
<Table
|
||||
dataSource={liveFeed}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
scroll={{ y: 400 }}
|
||||
className="activity-table"
|
||||
/>
|
||||
) : (
|
||||
<Empty description="暂无操作记录" />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 自定义样式 */}
|
||||
<style>{`
|
||||
.activity-table .ant-table-tbody > tr:hover > td {
|
||||
background-color: #f0f5ff !important;
|
||||
}
|
||||
.activity-table .ant-table-thead > tr > th {
|
||||
background-color: #fafafa;
|
||||
font-weight: 600;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -397,7 +397,7 @@ export const ChatArea: React.FC<ChatAreaProps> = ({
|
||||
return (
|
||||
<section className="chat-area">
|
||||
{/* 聊天历史 */}
|
||||
<div className="chat-container" ref={chatContainerRef}>
|
||||
<div className="pa-chat-container" ref={chatContainerRef}>
|
||||
{/* 加载历史消息时显示加载状态 */}
|
||||
{isLoadingHistory && (
|
||||
<div className="loading-history">
|
||||
|
||||
@@ -411,8 +411,8 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 聊天容器 */
|
||||
.chat-container {
|
||||
/* 聊天容器 - 使用 pa- 前缀避免与共享组件 ChatContainer 冲突 */
|
||||
.pa-chat-container {
|
||||
flex: 1;
|
||||
min-height: 0; /* 关键:允许 flex 子元素收缩 */
|
||||
overflow-y: auto;
|
||||
@@ -1838,7 +1838,7 @@
|
||||
/* ============================================ */
|
||||
/* 滚动条美化 - 始终可见 */
|
||||
/* ============================================ */
|
||||
.chat-container,
|
||||
.pa-chat-container,
|
||||
.panel-body,
|
||||
.conversations-list,
|
||||
.document-scroll-area {
|
||||
@@ -1846,14 +1846,14 @@
|
||||
scrollbar-color: #94A3B8 #F1F5F9;
|
||||
}
|
||||
|
||||
.chat-container::-webkit-scrollbar,
|
||||
.pa-chat-container::-webkit-scrollbar,
|
||||
.panel-body::-webkit-scrollbar,
|
||||
.conversations-list::-webkit-scrollbar,
|
||||
.document-scroll-area::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.chat-container::-webkit-scrollbar-track,
|
||||
.pa-chat-container::-webkit-scrollbar-track,
|
||||
.panel-body::-webkit-scrollbar-track,
|
||||
.conversations-list::-webkit-scrollbar-track,
|
||||
.document-scroll-area::-webkit-scrollbar-track {
|
||||
@@ -1861,7 +1861,7 @@
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.chat-container::-webkit-scrollbar-thumb,
|
||||
.pa-chat-container::-webkit-scrollbar-thumb,
|
||||
.panel-body::-webkit-scrollbar-thumb,
|
||||
.conversations-list::-webkit-scrollbar-thumb,
|
||||
.document-scroll-area::-webkit-scrollbar-thumb {
|
||||
@@ -1870,7 +1870,7 @@
|
||||
border: 2px solid #F1F5F9;
|
||||
}
|
||||
|
||||
.chat-container::-webkit-scrollbar-thumb:hover,
|
||||
.pa-chat-container::-webkit-scrollbar-thumb:hover,
|
||||
.panel-body::-webkit-scrollbar-thumb:hover,
|
||||
.conversations-list::-webkit-scrollbar-thumb:hover,
|
||||
.document-scroll-area::-webkit-scrollbar-thumb:hover {
|
||||
|
||||
@@ -110,13 +110,15 @@ export interface ChatHistoryResponse {
|
||||
|
||||
/**
|
||||
* 上传CSV/Excel文件
|
||||
*
|
||||
* 注意:文件上传不设置 Content-Type,浏览器会自动设置正确的 multipart/form-data 和 boundary
|
||||
*/
|
||||
export const uploadFile = async (file: File): Promise<UploadResponse> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
// ✅ 不设置 Content-Type,让浏览器自动处理 FormData 的 boundary
|
||||
const response = await apiClient.post(`${BASE_URL}/sessions/upload`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 30000, // 30秒超时
|
||||
});
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate, useParams, useLocation } from 'react-router-dom';
|
||||
import { Form, Input, Button, Tabs, message, Card, Space, Typography, Alert, Modal } from 'antd';
|
||||
import { PhoneOutlined, LockOutlined, SafetyOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import { PhoneOutlined, LockOutlined, SafetyOutlined, UserOutlined, FileTextOutlined } from '@ant-design/icons';
|
||||
import { useAuth } from '../framework/auth';
|
||||
import type { ChangePasswordRequest } from '../framework/auth';
|
||||
|
||||
@@ -26,6 +26,8 @@ interface TenantConfig {
|
||||
logo?: string;
|
||||
primaryColor: string;
|
||||
systemName: string;
|
||||
modules?: string[];
|
||||
isReviewOnly?: boolean;
|
||||
}
|
||||
|
||||
// 默认配置
|
||||
@@ -33,6 +35,15 @@ const DEFAULT_CONFIG: TenantConfig = {
|
||||
name: 'AI临床研究平台',
|
||||
primaryColor: '#1890ff',
|
||||
systemName: 'AI临床研究平台',
|
||||
isReviewOnly: false,
|
||||
};
|
||||
|
||||
// 审稿专用配置
|
||||
const REVIEW_CONFIG: TenantConfig = {
|
||||
name: '智能审稿系统',
|
||||
primaryColor: '#6366f1',
|
||||
systemName: '智能审稿系统',
|
||||
isReviewOnly: true,
|
||||
};
|
||||
|
||||
export default function LoginPage() {
|
||||
@@ -60,12 +71,19 @@ export default function LoginPage() {
|
||||
// 获取租户配置
|
||||
useEffect(() => {
|
||||
if (tenantCode) {
|
||||
// TODO: 从API获取租户配置
|
||||
fetch(`/api/v1/public/tenant-config/${tenantCode}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.success && data.data) {
|
||||
setTenantConfig(data.data);
|
||||
// 如果是审稿专用租户,合并审稿配置
|
||||
if (data.data.isReviewOnly) {
|
||||
setTenantConfig({
|
||||
...REVIEW_CONFIG,
|
||||
...data.data,
|
||||
});
|
||||
} else {
|
||||
setTenantConfig(data.data);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -82,34 +100,87 @@ export default function LoginPage() {
|
||||
}
|
||||
}, [countdown]);
|
||||
|
||||
// 智能跳转:根据用户角色判断目标页面
|
||||
// 智能跳转:根据用户角色和模块权限判断目标页面
|
||||
const getRedirectPath = useCallback(() => {
|
||||
const from = (location.state as any)?.from?.pathname || '/';
|
||||
const from = (location.state as any)?.from?.pathname;
|
||||
const userRole = user?.role;
|
||||
const userModules = user?.modules || [];
|
||||
|
||||
// 如果目标是运营管理端,检查权限
|
||||
if (from.startsWith('/admin')) {
|
||||
const canAccessAdmin = ['SUPER_ADMIN', 'PROMPT_ENGINEER'].includes(userRole || '');
|
||||
return canAccessAdmin ? from : '/';
|
||||
// 如果有明确的来源页面,优先处理
|
||||
if (from && from !== '/') {
|
||||
// 如果目标是运营管理端,检查权限
|
||||
if (from.startsWith('/admin')) {
|
||||
const canAccessAdmin = ['SUPER_ADMIN', 'PROMPT_ENGINEER'].includes(userRole || '');
|
||||
return canAccessAdmin ? from : getDefaultModule(userModules);
|
||||
}
|
||||
|
||||
// 如果目标是机构管理端,检查权限
|
||||
if (from.startsWith('/org')) {
|
||||
const canAccessOrg = ['HOSPITAL_ADMIN', 'PHARMA_ADMIN', 'DEPARTMENT_ADMIN', 'SUPER_ADMIN'].includes(userRole || '');
|
||||
return canAccessOrg ? from : getDefaultModule(userModules);
|
||||
}
|
||||
|
||||
// 其他页面直接跳转
|
||||
return from;
|
||||
}
|
||||
|
||||
// 如果目标是机构管理端,检查权限
|
||||
if (from.startsWith('/org')) {
|
||||
const canAccessOrg = ['HOSPITAL_ADMIN', 'PHARMA_ADMIN', 'DEPARTMENT_ADMIN', 'SUPER_ADMIN'].includes(userRole || '');
|
||||
return canAccessOrg ? from : '/';
|
||||
}
|
||||
|
||||
// 其他页面直接跳转
|
||||
return from;
|
||||
// 没有来源页面,智能判断默认目标
|
||||
return getDefaultModule(userModules);
|
||||
}, [location, user]);
|
||||
|
||||
// 根据用户模块权限获取默认跳转页面
|
||||
// 路径需要与 moduleRegistry.ts 保持一致!
|
||||
const getDefaultModule = (modules: string[]): string => {
|
||||
// 模块代码到路径的映射(必须与 moduleRegistry.ts 保持一致)
|
||||
const modulePathMap: Record<string, string> = {
|
||||
'AIA': '/ai-qa', // AI问答
|
||||
'PKB': '/knowledge-base', // 知识库
|
||||
'ASL': '/literature', // AI智能文献
|
||||
'DC': '/data-cleaning', // 智能数据清洗
|
||||
'RVW': '/rvw', // 预审稿
|
||||
'SSA': '/intelligent-analysis', // 智能统计分析
|
||||
'ST': '/statistical-tools', // 统计分析工具
|
||||
'IIT': '/iit', // IIT管理(如果有)
|
||||
};
|
||||
|
||||
// 如果用户只有 RVW 模块权限,直接进入审稿系统
|
||||
if (modules.length === 1 && modules[0] === 'RVW') {
|
||||
return '/rvw';
|
||||
}
|
||||
|
||||
// 如果有 AIA 模块权限,默认进入 AI 问答
|
||||
if (modules.includes('AIA')) {
|
||||
return '/ai-qa';
|
||||
}
|
||||
|
||||
// 否则进入第一个有权限的模块
|
||||
if (modules.length > 0 && modulePathMap[modules[0]]) {
|
||||
return modulePathMap[modules[0]];
|
||||
}
|
||||
|
||||
// 兜底:返回首页
|
||||
return '/';
|
||||
};
|
||||
|
||||
// 登录成功后检查是否需要修改密码
|
||||
useEffect(() => {
|
||||
if (user && user.isDefaultPassword) {
|
||||
setShowPasswordModal(true);
|
||||
} else if (user) {
|
||||
// 调试日志
|
||||
console.log('[LoginPage] 用户登录成功,准备跳转:', {
|
||||
userId: user.id,
|
||||
name: user.name,
|
||||
modules: user.modules,
|
||||
modulesLength: user.modules?.length,
|
||||
});
|
||||
|
||||
// 计算跳转路径
|
||||
const targetPath = getRedirectPath();
|
||||
console.log('[LoginPage] 跳转目标路径:', targetPath);
|
||||
|
||||
// 登录成功,智能跳转
|
||||
navigate(getRedirectPath(), { replace: true });
|
||||
navigate(targetPath, { replace: true });
|
||||
}
|
||||
}, [user, navigate, getRedirectPath]);
|
||||
|
||||
@@ -197,21 +268,33 @@ export default function LoginPage() {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 16,
|
||||
background: `linear-gradient(135deg, ${tenantConfig.primaryColor} 0%, ${tenantConfig.primaryColor}dd 100%)`,
|
||||
background: tenantConfig.isReviewOnly
|
||||
? 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)'
|
||||
: `linear-gradient(135deg, ${tenantConfig.primaryColor} 0%, ${tenantConfig.primaryColor}dd 100%)`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
margin: '0 auto 16px',
|
||||
boxShadow: tenantConfig.isReviewOnly ? '0 8px 24px rgba(99, 102, 241, 0.3)' : undefined,
|
||||
}}>
|
||||
<UserOutlined style={{ fontSize: 32, color: '#fff' }} />
|
||||
{tenantConfig.isReviewOnly ? (
|
||||
<FileTextOutlined style={{ fontSize: 32, color: '#fff' }} />
|
||||
) : (
|
||||
<UserOutlined style={{ fontSize: 32, color: '#fff' }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Title level={3} style={{ margin: 0, marginBottom: 8 }}>
|
||||
{tenantConfig.systemName}
|
||||
</Title>
|
||||
{tenantCode && (
|
||||
{tenantCode && !tenantConfig.isReviewOnly && (
|
||||
<Text type="secondary">{tenantConfig.name}</Text>
|
||||
)}
|
||||
{tenantConfig.isReviewOnly && (
|
||||
<Text type="secondary" style={{ display: 'block', marginTop: 4 }}>
|
||||
期刊智能审稿 · AI驱动
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
|
||||
@@ -1,66 +1,270 @@
|
||||
import { Card, Row, Col, Statistic, Table, Tag } from 'antd'
|
||||
import { Card, Row, Col, Statistic, Table, Tag, Spin, Empty, Tooltip } from 'antd'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
TeamOutlined,
|
||||
UserOutlined,
|
||||
BankOutlined,
|
||||
ExportOutlined,
|
||||
MessageOutlined,
|
||||
BookOutlined,
|
||||
SearchOutlined,
|
||||
FileTextOutlined,
|
||||
SyncOutlined,
|
||||
LoginOutlined,
|
||||
CloudUploadOutlined,
|
||||
DeleteOutlined,
|
||||
WarningOutlined,
|
||||
ReloadOutlined,
|
||||
CloudServerOutlined,
|
||||
ApiOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import apiClient from '../../common/api/axios'
|
||||
|
||||
// 运营管理端主色
|
||||
const PRIMARY_COLOR = '#10b981'
|
||||
|
||||
// ==================== API 函数 ====================
|
||||
|
||||
interface OverviewData {
|
||||
dau: number
|
||||
dat: number
|
||||
exportCount: number
|
||||
moduleStats: Record<string, number>
|
||||
}
|
||||
|
||||
interface ActivityLog {
|
||||
id: string
|
||||
createdAt: string
|
||||
tenantName: string | null
|
||||
userName: string | null
|
||||
module: string
|
||||
feature: string
|
||||
action: string
|
||||
info: string | null
|
||||
}
|
||||
|
||||
async function getOverview(): Promise<OverviewData> {
|
||||
const res = await apiClient.get<{ success: boolean; data: OverviewData }>(
|
||||
'/api/admin/stats/overview'
|
||||
)
|
||||
return res.data.data
|
||||
}
|
||||
|
||||
async function getLiveFeed(limit = 20): Promise<ActivityLog[]> {
|
||||
const res = await apiClient.get<{ success: boolean; data: ActivityLog[] }>(
|
||||
`/api/admin/stats/live-feed?limit=${limit}`
|
||||
)
|
||||
return res.data.data
|
||||
}
|
||||
|
||||
// ==================== 常量映射 ====================
|
||||
|
||||
const MODULE_ICONS: Record<string, React.ReactNode> = {
|
||||
'SYSTEM': <LoginOutlined />,
|
||||
'AIA': <MessageOutlined />,
|
||||
'PKB': <BookOutlined />,
|
||||
'ASL': <SearchOutlined />,
|
||||
'DC': <FileTextOutlined />,
|
||||
'RVW': <FileTextOutlined />,
|
||||
'IIT': <SyncOutlined />,
|
||||
}
|
||||
|
||||
const MODULE_COLORS: Record<string, string> = {
|
||||
'SYSTEM': '#8c8c8c',
|
||||
'AIA': '#1890ff',
|
||||
'PKB': '#52c41a',
|
||||
'ASL': '#722ed1',
|
||||
'DC': '#fa8c16',
|
||||
'RVW': '#eb2f96',
|
||||
'IIT': '#13c2c2',
|
||||
}
|
||||
|
||||
const ACTION_ICONS: Record<string, React.ReactNode> = {
|
||||
'LOGIN': <LoginOutlined style={{ color: '#52c41a' }} />,
|
||||
'USE': <MessageOutlined style={{ color: '#1890ff' }} />,
|
||||
'EXPORT': <CloudUploadOutlined style={{ color: '#722ed1' }} />,
|
||||
'CREATE': <ExportOutlined style={{ color: '#fa8c16' }} />,
|
||||
'DELETE': <DeleteOutlined style={{ color: '#ff4d4f' }} />,
|
||||
'ERROR': <WarningOutlined style={{ color: '#ff4d4f' }} />,
|
||||
}
|
||||
|
||||
/**
|
||||
* 运营管理端 - 概览页(浅色主题)
|
||||
* 运营管理端 - 概览页(集成实时运营数据)
|
||||
*/
|
||||
const AdminDashboard = () => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
// 模拟数据
|
||||
const stats = [
|
||||
{ title: '活跃租户', value: 12, icon: <TeamOutlined />, color: PRIMARY_COLOR },
|
||||
{ title: 'Prompt模板', value: 8, icon: <FileTextOutlined />, color: '#3b82f6' },
|
||||
{ title: 'API调用/今日', value: 1234, icon: <ApiOutlined />, color: '#f59e0b' },
|
||||
{ title: '系统状态', value: '正常', icon: <CloudServerOutlined />, color: PRIMARY_COLOR },
|
||||
]
|
||||
|
||||
const recentActivities = [
|
||||
{ key: 1, time: '10分钟前', action: '发布Prompt', target: 'RVW_EDITORIAL', user: '张工程师' },
|
||||
{ key: 2, time: '1小时前', action: '新增租户', target: '北京协和医院', user: '李运营' },
|
||||
{ key: 3, time: '2小时前', action: '用户开通', target: '王医生', user: '系统' },
|
||||
{ key: 4, time: '3小时前', action: '配额调整', target: '武田制药', user: '李运营' },
|
||||
]
|
||||
// 获取大盘数据
|
||||
const { data: overview, isLoading: overviewLoading, refetch: refetchOverview } = useQuery({
|
||||
queryKey: ['admin-stats-overview'],
|
||||
queryFn: getOverview,
|
||||
refetchInterval: 30000, // 30秒自动刷新
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ title: '时间', dataIndex: 'time', key: 'time', width: 100 },
|
||||
{ title: '操作', dataIndex: 'action', key: 'action' },
|
||||
{ title: '对象', dataIndex: 'target', key: 'target' },
|
||||
{ title: '操作人', dataIndex: 'user', key: 'user' },
|
||||
// 获取实时流水账
|
||||
const { data: liveFeed, isLoading: liveFeedLoading, refetch: refetchLiveFeed } = useQuery({
|
||||
queryKey: ['admin-stats-live-feed'],
|
||||
queryFn: () => getLiveFeed(10),
|
||||
refetchInterval: 10000, // 10秒自动刷新
|
||||
})
|
||||
|
||||
// 刷新所有数据
|
||||
const handleRefresh = () => {
|
||||
refetchOverview()
|
||||
refetchLiveFeed()
|
||||
}
|
||||
|
||||
// 流水账表格列
|
||||
const activityColumns = [
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 90,
|
||||
render: (time: string) => {
|
||||
const date = new Date(time)
|
||||
return (
|
||||
<Tooltip title={date.toLocaleString()}>
|
||||
<span className="text-gray-500 text-xs">
|
||||
{date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '动作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
width: 80,
|
||||
render: (action: string) => (
|
||||
<span className="flex items-center gap-1">
|
||||
{ACTION_ICONS[action] || null}
|
||||
<span className="text-xs">{action}</span>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '模块',
|
||||
dataIndex: 'module',
|
||||
key: 'module',
|
||||
width: 80,
|
||||
render: (module: string) => (
|
||||
<Tag
|
||||
icon={MODULE_ICONS[module]}
|
||||
color={MODULE_COLORS[module]}
|
||||
className="text-xs"
|
||||
>
|
||||
{module}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '功能',
|
||||
dataIndex: 'feature',
|
||||
key: 'feature',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '用户',
|
||||
dataIndex: 'userName',
|
||||
key: 'userName',
|
||||
width: 100,
|
||||
render: (name: string | null, record: ActivityLog) => (
|
||||
<Tooltip title={record.tenantName || '未知租户'}>
|
||||
<span className="text-gray-600">{name || '-'}</span>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 页面标题 */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-2">运营概览</h1>
|
||||
<p className="text-gray-500">壹证循科技 · AI临床研究平台运营管理中心</p>
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-2">运营概览</h1>
|
||||
<p className="text-gray-500">壹证循科技 · AI临床研究平台运营管理中心</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<ReloadOutlined className={overviewLoading || liveFeedLoading ? 'animate-spin' : ''} />
|
||||
刷新数据
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
{/* 核心运营指标 */}
|
||||
<Row gutter={16}>
|
||||
{stats.map((stat, index) => (
|
||||
<Col span={6} key={index}>
|
||||
<Card className="hover:shadow-lg transition-shadow">
|
||||
<Statistic
|
||||
title={<span className="text-gray-500">{stat.title}</span>}
|
||||
value={stat.value}
|
||||
prefix={<span style={{ color: stat.color }}>{stat.icon}</span>}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
<Col span={6}>
|
||||
<Card className="hover:shadow-lg transition-shadow">
|
||||
<Statistic
|
||||
title={<span className="text-gray-500">今日活跃医生 (DAU)</span>}
|
||||
value={overview?.dau ?? 0}
|
||||
prefix={<UserOutlined style={{ color: '#1890ff' }} />}
|
||||
loading={overviewLoading}
|
||||
valueStyle={{ color: '#1890ff', fontWeight: 'bold' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card className="hover:shadow-lg transition-shadow">
|
||||
<Statistic
|
||||
title={<span className="text-gray-500">今日活跃租户 (DAT)</span>}
|
||||
value={overview?.dat ?? 0}
|
||||
prefix={<BankOutlined style={{ color: PRIMARY_COLOR }} />}
|
||||
loading={overviewLoading}
|
||||
valueStyle={{ color: PRIMARY_COLOR, fontWeight: 'bold' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card className="hover:shadow-lg transition-shadow">
|
||||
<Statistic
|
||||
title={<span className="text-gray-500">今日导出次数</span>}
|
||||
value={overview?.exportCount ?? 0}
|
||||
prefix={<ExportOutlined style={{ color: '#722ed1' }} />}
|
||||
loading={overviewLoading}
|
||||
valueStyle={{ color: '#722ed1', fontWeight: 'bold' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card className="hover:shadow-lg transition-shadow">
|
||||
<Statistic
|
||||
title={<span className="text-gray-500">系统状态</span>}
|
||||
value="正常"
|
||||
prefix={<CloudServerOutlined style={{ color: PRIMARY_COLOR }} />}
|
||||
valueStyle={{ color: PRIMARY_COLOR, fontWeight: 'bold' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 模块使用统计 */}
|
||||
{overview?.moduleStats && Object.keys(overview.moduleStats).length > 0 && (
|
||||
<Card
|
||||
title="今日模块使用统计"
|
||||
extra={<span className="text-gray-400 text-xs">实时</span>}
|
||||
>
|
||||
<Row gutter={[16, 16]}>
|
||||
{Object.entries(overview.moduleStats).map(([module, count]) => (
|
||||
<Col key={module} xs={12} sm={8} md={6} lg={4}>
|
||||
<div className="text-center p-4 bg-gray-50 rounded-lg">
|
||||
<div className="text-2xl mb-2" style={{ color: MODULE_COLORS[module] }}>
|
||||
{MODULE_ICONS[module] || <MessageOutlined />}
|
||||
</div>
|
||||
<div className="text-2xl font-bold" style={{ color: MODULE_COLORS[module] }}>
|
||||
{count}
|
||||
</div>
|
||||
<div className="text-gray-500 text-sm">{module}</div>
|
||||
</div>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 快捷操作 */}
|
||||
<Card title="快捷操作">
|
||||
<div className="flex gap-4">
|
||||
@@ -86,14 +290,26 @@ const AdminDashboard = () => {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 最近活动 */}
|
||||
<Card title="最近活动">
|
||||
<Table
|
||||
dataSource={recentActivities}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
{/* 实时操作流水 */}
|
||||
<Card
|
||||
title="实时操作流水"
|
||||
extra={<span className="text-gray-400 text-xs">最近10条 · 10秒自动刷新</span>}
|
||||
>
|
||||
{liveFeedLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Spin tip="加载中..." />
|
||||
</div>
|
||||
) : liveFeed && liveFeed.length > 0 ? (
|
||||
<Table
|
||||
dataSource={liveFeed}
|
||||
columns={activityColumns}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
) : (
|
||||
<Empty description="暂无操作记录" />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 系统状态 */}
|
||||
|
||||
Reference in New Issue
Block a user