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:
2026-01-25 22:16:16 +08:00
parent 303dd78c54
commit 01a17f1e6f
36 changed files with 2962 additions and 95 deletions

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

View File

@@ -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 />} />

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

View File

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

View File

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

View File

@@ -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秒超时
});