diff --git a/backend/src/common/services/activity.service.ts b/backend/src/common/services/activity.service.ts index e6e38b03..9681ec1e 100644 --- a/backend/src/common/services/activity.service.ts +++ b/backend/src/common/services/activity.service.ts @@ -290,6 +290,113 @@ export const activityService = { }; }, + /** + * 分页查询运营日志(支持筛选) + * + * @param options 查询选项 + */ + async getActivityLogs(options: { + page: number; + pageSize: number; + startDate?: Date; + endDate?: Date; + module?: string; + action?: string; + keyword?: string; + }): Promise<{ + data: Array<{ + id: string; + createdAt: Date; + tenantId: string; + tenantName: string | null; + userId: string; + userName: string | null; + module: string; + feature: string; + action: string; + info: string | null; + }>; + total: number; + page: number; + pageSize: number; + }> { + const { page, pageSize, startDate, endDate, module, action, keyword } = options; + + // 构建 where 条件 + const where: any = {}; + + // 日期范围筛选 + if (startDate || endDate) { + where.created_at = {}; + if (startDate) where.created_at.gte = startDate; + if (endDate) { + // endDate 设置到当天结束 + const endOfDay = new Date(endDate); + endOfDay.setHours(23, 59, 59, 999); + where.created_at.lte = endOfDay; + } + } + + // 模块筛选 + if (module) { + where.module = module; + } + + // 动作筛选 + if (action) { + where.action = action; + } + + // 关键词搜索(用户名或租户名) + if (keyword) { + where.OR = [ + { user_name: { contains: keyword, mode: 'insensitive' } }, + { tenant_name: { contains: keyword, mode: 'insensitive' } }, + ]; + } + + // 并行查询数据和总数 + const [logs, total] = await Promise.all([ + prisma.simple_logs.findMany({ + where, + orderBy: { created_at: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + select: { + id: true, + created_at: true, + tenant_id: true, + tenant_name: true, + user_id: true, + user_name: true, + module: true, + feature: true, + action: true, + info: true, + } + }), + prisma.simple_logs.count({ where }), + ]); + + return { + data: logs.map(log => ({ + id: log.id, + createdAt: log.created_at, + tenantId: log.tenant_id, + tenantName: log.tenant_name, + userId: log.user_id, + userName: log.user_name, + module: log.module, + feature: log.feature, + action: log.action, + info: log.info, + })), + total, + page, + pageSize, + }; + }, + /** * 清理过期日志(180天前的数据) * 用于定时任务调用 diff --git a/backend/src/modules/admin/controllers/statsController.ts b/backend/src/modules/admin/controllers/statsController.ts index 91cd90fd..603ea693 100644 --- a/backend/src/modules/admin/controllers/statsController.ts +++ b/backend/src/modules/admin/controllers/statsController.ts @@ -98,6 +98,81 @@ export async function getUserOverview( } } +/** + * 分页查询运营日志 + * GET /api/admin/stats/logs + * + * 查询参数: + * - page: 页码 (默认1) + * - pageSize: 每页条数 (默认20, 最大100) + * - startDate: 开始日期 (YYYY-MM-DD) + * - endDate: 结束日期 (YYYY-MM-DD) + * - module: 模块筛选 + * - action: 动作筛选 + * - keyword: 关键词搜索 (用户名/租户名) + */ +export async function getActivityLogs( + request: FastifyRequest<{ + Querystring: { + page?: string; + pageSize?: string; + startDate?: string; + endDate?: string; + module?: string; + action?: string; + keyword?: string; + } + }>, + reply: FastifyReply +) { + try { + const { + page: pageStr, + pageSize: pageSizeStr, + startDate: startDateStr, + endDate: endDateStr, + module, + action, + keyword, + } = request.query; + + // 解析分页参数 + const page = Math.max(1, Number(pageStr) || 1); + const pageSize = Math.min(100, Math.max(1, Number(pageSizeStr) || 20)); + + // 解析日期参数 + const startDate = startDateStr ? new Date(startDateStr) : undefined; + const endDate = endDateStr ? new Date(endDateStr) : undefined; + + const result = await activityService.getActivityLogs({ + page, + pageSize, + startDate, + endDate, + module, + action, + keyword, + }); + + return reply.send({ + success: true, + data: result.data, + pagination: { + page: result.page, + pageSize: result.pageSize, + total: result.total, + totalPages: Math.ceil(result.total / result.pageSize), + }, + }); + } catch (error: any) { + logger.error('[StatsController] 获取运营日志失败', { error: error.message }); + return reply.status(500).send({ + success: false, + message: error.message || '获取运营日志失败', + }); + } +} + /** * 清理过期日志(管理接口) * POST /api/admin/stats/cleanup diff --git a/backend/src/modules/admin/routes/statsRoutes.ts b/backend/src/modules/admin/routes/statsRoutes.ts index 300b1c49..e3b0016c 100644 --- a/backend/src/modules/admin/routes/statsRoutes.ts +++ b/backend/src/modules/admin/routes/statsRoutes.ts @@ -36,6 +36,17 @@ export async function statsRoutes(fastify: FastifyInstance) { handler: statsController.getLiveFeed, }); + /** + * 分页查询运营日志 + * GET /api/admin/stats/logs + * + * 权限: SUPER_ADMIN, PROMPT_ENGINEER + */ + fastify.get('/logs', { + preHandler: [authenticate, requirePermission('tenant:view')], + handler: statsController.getActivityLogs, + }); + /** * 清理过期日志 * POST /api/admin/stats/cleanup diff --git a/frontend-v2/src/App.tsx b/frontend-v2/src/App.tsx index f6e52b74..0150cb2a 100644 --- a/frontend-v2/src/App.tsx +++ b/frontend-v2/src/App.tsx @@ -24,6 +24,8 @@ import UserDetailPage from './modules/admin/pages/UserDetailPage' // 系统知识库管理 import SystemKbListPage from './modules/admin/pages/SystemKbListPage' import SystemKbDetailPage from './modules/admin/pages/SystemKbDetailPage' +// 运营日志 +import ActivityLogsPage from './pages/admin/ActivityLogsPage' // 个人中心页面 import ProfilePage from './pages/user/ProfilePage' @@ -115,6 +117,8 @@ function App() { {/* 系统知识库 */} } /> } /> + {/* 运营日志 */} + } /> {/* 系统配置 */} 🚧 系统配置页面开发中...} /> diff --git a/frontend-v2/src/framework/layout/AdminLayout.tsx b/frontend-v2/src/framework/layout/AdminLayout.tsx index 4dab08ff..9b619cd2 100644 --- a/frontend-v2/src/framework/layout/AdminLayout.tsx +++ b/frontend-v2/src/framework/layout/AdminLayout.tsx @@ -13,6 +13,7 @@ import { MenuUnfoldOutlined, BellOutlined, BookOutlined, + FileTextOutlined, } from '@ant-design/icons' import type { MenuProps } from 'antd' import { useAuth } from '../auth' @@ -94,6 +95,11 @@ const AdminLayout = () => { icon: , label: '租户管理', }, + { + key: '/admin/activity-logs', + icon: , + label: '运营日志', + }, { key: '/admin/users', icon: , diff --git a/frontend-v2/src/modules/aia/components/ChatWorkspace.tsx b/frontend-v2/src/modules/aia/components/ChatWorkspace.tsx index 8b4c3e34..50c19f89 100644 --- a/frontend-v2/src/modules/aia/components/ChatWorkspace.tsx +++ b/frontend-v2/src/modules/aia/components/ChatWorkspace.tsx @@ -28,6 +28,7 @@ import { ThinkingBlock } from '@/shared/components/Chat'; import { getAccessToken } from '@/framework/auth/api'; import { AGENTS, AGENT_PROMPTS, BRAND_COLORS } from '../constants'; import type { AgentConfig, Conversation, Message } from '../types'; +import { MarkdownContent } from '../protocol-agent/components/MarkdownContent'; import '../styles/chat-workspace.css'; /** @@ -651,14 +652,6 @@ export const ChatWorkspace: React.FC = ({ - {/* 用户信息 */} -
-
U
-
-
Dr. Wang
-
专业版会员
-
-
{/* 主对话区 */} @@ -750,7 +743,11 @@ export const ChatWorkspace: React.FC = ({ {/* 消息内容(有内容时才显示气泡) */} {(msg.content || (msg.role === 'assistant' && isStreaming && index === messages.length - 1)) && (
- {msg.content} + {msg.role === 'assistant' ? ( + + ) : ( + msg.content + )} {msg.role === 'assistant' && isStreaming && index === messages.length - 1 && ( )} diff --git a/frontend-v2/src/modules/aia/styles/chat-workspace.css b/frontend-v2/src/modules/aia/styles/chat-workspace.css index 1cfb78af..6e50bea2 100644 --- a/frontend-v2/src/modules/aia/styles/chat-workspace.css +++ b/frontend-v2/src/modules/aia/styles/chat-workspace.css @@ -946,3 +946,93 @@ border-radius: 8px; } +/* ============================================ */ +/* MarkdownContent 组件样式 */ +/* ============================================ */ +.message-bubble .markdown-content { + white-space: normal; +} + +.message-bubble .markdown-content p { + margin: 0 0 8px 0; +} + +.message-bubble .markdown-content p:last-child { + margin-bottom: 0; +} + +.message-bubble .markdown-content .md-divider { + border: none; + border-top: 1px solid #E5E7EB; + margin: 12px 0; +} + +.message-bubble .markdown-content h1, +.message-bubble .markdown-content h2, +.message-bubble .markdown-content h3 { + margin: 16px 0 8px 0; + font-weight: 700; + line-height: 1.4; +} + +.message-bubble .markdown-content h1:first-child, +.message-bubble .markdown-content h2:first-child, +.message-bubble .markdown-content h3:first-child { + margin-top: 0; +} + +.message-bubble .markdown-content h1 { + font-size: 1.3em; +} + +.message-bubble .markdown-content h2 { + font-size: 1.2em; +} + +.message-bubble .markdown-content h3 { + font-size: 1.1em; +} + +.message-bubble .markdown-content ul, +.message-bubble .markdown-content ol { + margin: 8px 0 12px 0; + padding-left: 24px; +} + +.message-bubble .markdown-content ul { + list-style-type: disc; +} + +.message-bubble .markdown-content ol { + list-style-type: decimal; +} + +.message-bubble .markdown-content li { + margin: 6px 0; + line-height: 1.6; + display: list-item; +} + +.message-bubble .markdown-content li::marker { + color: #4F6EF2; +} + +.message-bubble .markdown-content strong { + font-weight: 700; + color: #1F2937; +} + +.message-bubble .markdown-content em { + font-style: italic; + color: #4B5563; +} + +.message-bubble .markdown-content code { + background: rgba(79, 110, 242, 0.1); + color: #4F6EF2; + padding: 2px 6px; + border-radius: 4px; + font-family: 'Monaco', 'Consolas', 'Courier New', monospace; + font-size: 0.9em; +} + diff --git a/frontend-v2/src/pages/admin/ActivityLogsPage.tsx b/frontend-v2/src/pages/admin/ActivityLogsPage.tsx new file mode 100644 index 00000000..1c9995a5 --- /dev/null +++ b/frontend-v2/src/pages/admin/ActivityLogsPage.tsx @@ -0,0 +1,423 @@ +/** + * 运营日志页面 + * + * 提供完整的运营日志查看、筛选、搜索功能 + * + * @date 2026-01-28 + */ + +import { useState, useCallback } from 'react' +import { + Card, + Table, + Tag, + Input, + Select, + DatePicker, + Button, + Space, + Tooltip, + Modal, + Typography, +} from 'antd' +import type { ColumnsType, TablePaginationConfig } from 'antd/es/table' +import { + SearchOutlined, + ReloadOutlined, + MessageOutlined, + BookOutlined, + FileTextOutlined, + SyncOutlined, + LoginOutlined, + CloudUploadOutlined, + DeleteOutlined, + EyeOutlined, + FilterOutlined, + ClearOutlined, +} from '@ant-design/icons' +import { useQuery } from '@tanstack/react-query' +import dayjs from 'dayjs' +import type { Dayjs } from 'dayjs' +import { fetchActivityLogs, type ActivityLog } from './api/activityApi' + +const { RangePicker } = DatePicker +const { Text, Paragraph } = Typography + +// ==================== 常量映射 ==================== + +const MODULE_OPTIONS = [ + { label: '全部模块', value: '' }, + { label: '系统', value: 'SYSTEM' }, + { label: 'AI问答', value: 'AIA' }, + { label: '个人知识库', value: 'PKB' }, + { label: '智能文献', value: 'ASL' }, + { label: '数据清洗', value: 'DC' }, + { label: '智能循证', value: 'RVW' }, + { label: '智能IIT', value: 'IIT' }, +] + +const ACTION_OPTIONS = [ + { label: '全部动作', value: '' }, + { label: '登录', value: 'login' }, + { label: '登出', value: 'logout' }, + { label: '对话', value: 'chat' }, + { label: '上传', value: 'upload' }, + { label: '导出', value: 'export' }, + { label: '创建', value: 'create' }, + { label: '删除', value: 'delete' }, + { label: '查看', value: 'view' }, +] + +const MODULE_ICONS: Record = { + 'SYSTEM': , + 'AIA': , + 'PKB': , + 'ASL': , + 'DC': , + 'RVW': , + 'IIT': , +} + +const MODULE_COLORS: Record = { + 'SYSTEM': '#8c8c8c', + 'AIA': '#1890ff', + 'PKB': '#52c41a', + 'ASL': '#722ed1', + 'DC': '#fa8c16', + 'RVW': '#eb2f96', + 'IIT': '#13c2c2', +} + +const ACTION_ICONS: Record = { + 'login': , + 'logout': , + 'chat': , + 'upload': , + 'delete': , + 'export': , + 'create': , +} + +// ==================== 组件 ==================== + +export default function ActivityLogsPage() { + // 筛选状态 + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + const [dateRange, setDateRange] = useState<[Dayjs | null, Dayjs | null] | null>(null) + const [moduleFilter, setModuleFilter] = useState('') + const [actionFilter, setActionFilter] = useState('') + const [keyword, setKeyword] = useState('') + const [searchKeyword, setSearchKeyword] = useState('') + + // 详情弹窗 + const [detailVisible, setDetailVisible] = useState(false) + const [selectedLog, setSelectedLog] = useState(null) + + // 构建查询参数 + const queryParams = { + page, + pageSize, + startDate: dateRange?.[0]?.format('YYYY-MM-DD'), + endDate: dateRange?.[1]?.format('YYYY-MM-DD'), + module: moduleFilter || undefined, + action: actionFilter || undefined, + keyword: searchKeyword || undefined, + } + + // 查询数据 + const { data, isLoading, refetch } = useQuery({ + queryKey: ['activityLogs', queryParams], + queryFn: () => fetchActivityLogs(queryParams), + }) + + // 处理分页变化 + const handleTableChange = useCallback((pagination: TablePaginationConfig) => { + setPage(pagination.current || 1) + setPageSize(pagination.pageSize || 20) + }, []) + + // 处理搜索 + const handleSearch = useCallback(() => { + setSearchKeyword(keyword) + setPage(1) // 重置到第一页 + }, [keyword]) + + // 清空筛选 + const handleClearFilters = useCallback(() => { + setDateRange(null) + setModuleFilter('') + setActionFilter('') + setKeyword('') + setSearchKeyword('') + setPage(1) + }, []) + + // 显示详情 + const handleShowDetail = useCallback((log: ActivityLog) => { + setSelectedLog(log) + setDetailVisible(true) + }, []) + + // 表格列定义 + const columns: ColumnsType = [ + { + title: '时间', + dataIndex: 'createdAt', + key: 'createdAt', + width: 170, + render: (val: string) => ( + + {dayjs(val).format('YYYY-MM-DD HH:mm:ss')} + + ), + }, + { + title: '租户', + dataIndex: 'tenantName', + key: 'tenantName', + width: 120, + ellipsis: true, + render: (val: string | null) => ( + + {val || '-'} + + ), + }, + { + title: '用户', + dataIndex: 'userName', + key: 'userName', + width: 100, + ellipsis: true, + render: (val: string | null) => ( + + {val || '-'} + + ), + }, + { + title: '模块', + dataIndex: 'module', + key: 'module', + width: 100, + render: (val: string) => ( + + {val} + + ), + }, + { + title: '功能', + dataIndex: 'feature', + key: 'feature', + width: 120, + ellipsis: true, + render: (val: string) => ( + + {val} + + ), + }, + { + title: '动作', + dataIndex: 'action', + key: 'action', + width: 80, + render: (val: string) => ( + + {ACTION_ICONS[val]} + {val} + + ), + }, + { + title: '详情', + dataIndex: 'info', + key: 'info', + ellipsis: true, + render: (val: string | null) => ( + + + {val || '-'} + + + ), + }, + { + title: '操作', + key: 'actions', + width: 70, + fixed: 'right', + render: (_, record) => ( + + ), + }, + ] + + return ( +
+ {/* 页面标题 */} +
+

运营日志

+

查看系统所有用户操作记录

+
+ + {/* 筛选区域 */} + +
+ + + 筛选: + + + { + setDateRange(dates as [Dayjs | null, Dayjs | null]) + setPage(1) + }} + placeholder={['开始日期', '结束日期']} + style={{ width: 240 }} + /> + + { + setActionFilter(val) + setPage(1) + }} + options={ACTION_OPTIONS} + style={{ width: 120 }} + placeholder="选择动作" + /> + + setKeyword(e.target.value)} + onSearch={handleSearch} + placeholder="搜索用户/租户名称" + style={{ width: 200 }} + enterButton={} + /> + + + + +
+
+ + {/* 数据表格 */} + + `共 ${total} 条记录`, + pageSizeOptions: ['10', '20', '50', '100'], + }} + onChange={handleTableChange} + size="middle" + /> + + + {/* 详情弹窗 */} + setDetailVisible(false)} + footer={[ + + ]} + width={600} + > + {selectedLog && ( +
+
+ 时间: + {dayjs(selectedLog.createdAt).format('YYYY-MM-DD HH:mm:ss')} +
+
+ 租户: + {selectedLog.tenantName || '-'} +
+
+ 用户: + {selectedLog.userName || '-'} +
+
+ 模块: + + {selectedLog.module} + +
+
+ 功能: + {selectedLog.feature} +
+
+ 动作: + {selectedLog.action} +
+
+ 详情: + + {selectedLog.info || '-'} + +
+
+ )} +
+ + ) +} diff --git a/frontend-v2/src/pages/admin/api/activityApi.ts b/frontend-v2/src/pages/admin/api/activityApi.ts new file mode 100644 index 00000000..40cc6a2e --- /dev/null +++ b/frontend-v2/src/pages/admin/api/activityApi.ts @@ -0,0 +1,73 @@ +/** + * 运营日志 API + * + * @date 2026-01-28 + */ + +import apiClient from '../../../common/api/axios' + +// ==================== 类型定义 ==================== + +export interface ActivityLog { + id: string + createdAt: string + tenantId: string + tenantName: string | null + userId: string + userName: string | null + module: string + feature: string + action: string + info: string | null +} + +export interface Pagination { + page: number + pageSize: number + total: number + totalPages: number +} + +export interface ActivityLogsResponse { + success: boolean + data: ActivityLog[] + pagination: Pagination +} + +export interface ActivityLogsParams { + page?: number + pageSize?: number + startDate?: string + endDate?: string + module?: string + action?: string + keyword?: string +} + +// ==================== API 函数 ==================== + +/** + * 获取运营日志列表(分页) + */ +export async function fetchActivityLogs(params: ActivityLogsParams = {}): Promise<{ + data: ActivityLog[] + pagination: Pagination +}> { + const queryParams = new URLSearchParams() + + if (params.page) queryParams.append('page', String(params.page)) + if (params.pageSize) queryParams.append('pageSize', String(params.pageSize)) + if (params.startDate) queryParams.append('startDate', params.startDate) + if (params.endDate) queryParams.append('endDate', params.endDate) + if (params.module) queryParams.append('module', params.module) + if (params.action) queryParams.append('action', params.action) + if (params.keyword) queryParams.append('keyword', params.keyword) + + const url = `/api/admin/stats/logs${queryParams.toString() ? '?' + queryParams.toString() : ''}` + const res = await apiClient.get(url) + + return { + data: res.data.data, + pagination: res.data.pagination, + } +}