feat(admin): Add activity logs page and fix AI chat markdown rendering

- Add paginated activity logs API with filters (date, module, action, keyword)

- Add ActivityLogsPage with table, filters, and detail modal

- Add markdown rendering support for AI chat messages

- Remove prototype placeholder content from chat sidebar

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-01 21:04:21 +08:00
parent aaa29ea9d3
commit 4c2c9b437b
9 changed files with 795 additions and 9 deletions

View File

@@ -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() {
{/* 系统知识库 */}
<Route path="system-kb" element={<SystemKbListPage />} />
<Route path="system-kb/:id" element={<SystemKbDetailPage />} />
{/* 运营日志 */}
<Route path="activity-logs" element={<ActivityLogsPage />} />
{/* 系统配置 */}
<Route path="system" element={<div className="text-center py-20">🚧 ...</div>} />
</Route>

View File

@@ -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: <TeamOutlined />,
label: '租户管理',
},
{
key: '/admin/activity-logs',
icon: <FileTextOutlined />,
label: '运营日志',
},
{
key: '/admin/users',
icon: <UserOutlined />,

View File

@@ -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<ChatWorkspaceProps> = ({
</div>
</div>
{/* 用户信息 */}
<div className="sidebar-user">
<div className="user-avatar">U</div>
<div className="user-info">
<div className="user-name">Dr. Wang</div>
<div className="user-plan"></div>
</div>
</div>
</aside>
{/* 主对话区 */}
@@ -750,7 +743,11 @@ export const ChatWorkspace: React.FC<ChatWorkspaceProps> = ({
{/* 消息内容(有内容时才显示气泡) */}
{(msg.content || (msg.role === 'assistant' && isStreaming && index === messages.length - 1)) && (
<div className={`message-bubble ${msg.role}`}>
{msg.content}
{msg.role === 'assistant' ? (
<MarkdownContent content={msg.content || ''} />
) : (
msg.content
)}
{msg.role === 'assistant' && isStreaming && index === messages.length - 1 && (
<span className="typing-cursor"></span>
)}

View File

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

View File

@@ -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<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 />,
'logout': <LoginOutlined />,
'chat': <MessageOutlined />,
'upload': <CloudUploadOutlined />,
'delete': <DeleteOutlined />,
'export': <FileTextOutlined />,
'create': <FileTextOutlined />,
}
// ==================== 组件 ====================
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<string>('')
const [actionFilter, setActionFilter] = useState<string>('')
const [keyword, setKeyword] = useState<string>('')
const [searchKeyword, setSearchKeyword] = useState<string>('')
// 详情弹窗
const [detailVisible, setDetailVisible] = useState(false)
const [selectedLog, setSelectedLog] = useState<ActivityLog | null>(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<ActivityLog> = [
{
title: '时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 170,
render: (val: string) => (
<Text style={{ fontSize: 13 }}>
{dayjs(val).format('YYYY-MM-DD HH:mm:ss')}
</Text>
),
},
{
title: '租户',
dataIndex: 'tenantName',
key: 'tenantName',
width: 120,
ellipsis: true,
render: (val: string | null) => (
<Tooltip title={val}>
<Text style={{ fontSize: 13 }}>{val || '-'}</Text>
</Tooltip>
),
},
{
title: '用户',
dataIndex: 'userName',
key: 'userName',
width: 100,
ellipsis: true,
render: (val: string | null) => (
<Tooltip title={val}>
<Text style={{ fontSize: 13 }}>{val || '-'}</Text>
</Tooltip>
),
},
{
title: '模块',
dataIndex: 'module',
key: 'module',
width: 100,
render: (val: string) => (
<Tag
icon={MODULE_ICONS[val]}
color={MODULE_COLORS[val] || 'default'}
>
{val}
</Tag>
),
},
{
title: '功能',
dataIndex: 'feature',
key: 'feature',
width: 120,
ellipsis: true,
render: (val: string) => (
<Tooltip title={val}>
<Text style={{ fontSize: 13 }}>{val}</Text>
</Tooltip>
),
},
{
title: '动作',
dataIndex: 'action',
key: 'action',
width: 80,
render: (val: string) => (
<Space size={4}>
{ACTION_ICONS[val]}
<span style={{ fontSize: 13 }}>{val}</span>
</Space>
),
},
{
title: '详情',
dataIndex: 'info',
key: 'info',
ellipsis: true,
render: (val: string | null) => (
<Tooltip title={val} placement="topLeft">
<Text
style={{ fontSize: 13, color: '#666' }}
ellipsis
>
{val || '-'}
</Text>
</Tooltip>
),
},
{
title: '操作',
key: 'actions',
width: 70,
fixed: 'right',
render: (_, record) => (
<Button
type="link"
size="small"
icon={<EyeOutlined />}
onClick={() => handleShowDetail(record)}
>
</Button>
),
},
]
return (
<div className="p-6">
{/* 页面标题 */}
<div className="mb-4">
<h2 className="text-xl font-semibold text-gray-800"></h2>
<p className="text-gray-500 text-sm mt-1"></p>
</div>
{/* 筛选区域 */}
<Card className="mb-4" size="small">
<div className="flex flex-wrap gap-3 items-center">
<Space>
<FilterOutlined />
<span className="text-gray-600"></span>
</Space>
<RangePicker
value={dateRange}
onChange={(dates) => {
setDateRange(dates as [Dayjs | null, Dayjs | null])
setPage(1)
}}
placeholder={['开始日期', '结束日期']}
style={{ width: 240 }}
/>
<Select
value={moduleFilter}
onChange={(val) => {
setModuleFilter(val)
setPage(1)
}}
options={MODULE_OPTIONS}
style={{ width: 130 }}
placeholder="选择模块"
/>
<Select
value={actionFilter}
onChange={(val) => {
setActionFilter(val)
setPage(1)
}}
options={ACTION_OPTIONS}
style={{ width: 120 }}
placeholder="选择动作"
/>
<Input.Search
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onSearch={handleSearch}
placeholder="搜索用户/租户名称"
style={{ width: 200 }}
enterButton={<SearchOutlined />}
/>
<Button
icon={<ClearOutlined />}
onClick={handleClearFilters}
>
</Button>
<Button
icon={<ReloadOutlined />}
onClick={() => refetch()}
>
</Button>
</div>
</Card>
{/* 数据表格 */}
<Card>
<Table
columns={columns}
dataSource={data?.data || []}
rowKey="id"
loading={isLoading}
scroll={{ x: 1100 }}
pagination={{
current: page,
pageSize: pageSize,
total: data?.pagination.total || 0,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total} 条记录`,
pageSizeOptions: ['10', '20', '50', '100'],
}}
onChange={handleTableChange}
size="middle"
/>
</Card>
{/* 详情弹窗 */}
<Modal
title="日志详情"
open={detailVisible}
onCancel={() => setDetailVisible(false)}
footer={[
<Button key="close" onClick={() => setDetailVisible(false)}>
</Button>
]}
width={600}
>
{selectedLog && (
<div className="space-y-3">
<div className="flex">
<span className="w-20 text-gray-500"></span>
<span>{dayjs(selectedLog.createdAt).format('YYYY-MM-DD HH:mm:ss')}</span>
</div>
<div className="flex">
<span className="w-20 text-gray-500"></span>
<span>{selectedLog.tenantName || '-'}</span>
</div>
<div className="flex">
<span className="w-20 text-gray-500"></span>
<span>{selectedLog.userName || '-'}</span>
</div>
<div className="flex">
<span className="w-20 text-gray-500"></span>
<Tag
icon={MODULE_ICONS[selectedLog.module]}
color={MODULE_COLORS[selectedLog.module] || 'default'}
>
{selectedLog.module}
</Tag>
</div>
<div className="flex">
<span className="w-20 text-gray-500"></span>
<span>{selectedLog.feature}</span>
</div>
<div className="flex">
<span className="w-20 text-gray-500"></span>
<span>{selectedLog.action}</span>
</div>
<div className="flex">
<span className="w-20 text-gray-500"></span>
<Paragraph
style={{ margin: 0, flex: 1 }}
ellipsis={{ rows: 5, expandable: true }}
>
{selectedLog.info || '-'}
</Paragraph>
</div>
</div>
)}
</Modal>
</div>
)
}

View File

@@ -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<ActivityLogsResponse>(url)
return {
data: res.data.data,
pagination: res.data.pagination,
}
}