import React, { useState, useEffect, useMemo, useRef } from 'react'; import { Card, Table, Row, Col, Statistic, Progress, Space, Button, Tag, Select, App, Modal, Descriptions, Badge, Tooltip, Typography, Divider, } from 'antd'; import { UsergroupAddOutlined, VideoCameraAddOutlined, DatabaseOutlined, SyncOutlined, UserDeleteOutlined, SearchOutlined, DeploymentUnitOutlined, HddOutlined, ApartmentOutlined, FileTextOutlined, ReloadOutlined, PauseCircleOutlined, PlayCircleOutlined, ClockCircleOutlined, CloseOutlined, } from '@ant-design/icons'; import apiClient from '../utils/apiClient'; import { buildApiUrl, API_ENDPOINTS } from '../config/api'; import './AdminDashboard.css'; const { Text } = Typography; const AUTO_REFRESH_INTERVAL = 30; const TASK_TYPE_MAP = { transcription: { text: '转录', color: 'blue' }, summary: { text: '总结', color: 'green' }, knowledge_base: { text: '知识库', color: 'purple' }, }; const STATUS_MAP = { pending: { text: '待处理', color: 'default' }, processing: { text: '处理中', color: 'processing' }, completed: { text: '已完成', color: 'success' }, failed: { text: '失败', color: 'error' }, }; const formatResourcePercent = (value) => `${Number(value || 0).toFixed(1)}%`; const AdminDashboard = () => { const { message, modal } = App.useApp(); const inFlightRef = useRef(false); const mountedRef = useRef(true); const [stats, setStats] = useState(null); const [onlineUsers, setOnlineUsers] = useState([]); const [usersList, setUsersList] = useState([]); const [tasks, setTasks] = useState([]); const [resources, setResources] = useState(null); const [loading, setLoading] = useState(true); const [taskLoading, setTaskLoading] = useState(false); const [lastUpdatedAt, setLastUpdatedAt] = useState(null); const [taskType, setTaskType] = useState('all'); const [taskStatus, setTaskStatus] = useState('all'); const [autoRefresh, setAutoRefresh] = useState(true); const [countdown, setCountdown] = useState(AUTO_REFRESH_INTERVAL); const [showMeetingModal, setShowMeetingModal] = useState(false); const [meetingDetails, setMeetingDetails] = useState(null); const [meetingLoading, setMeetingLoading] = useState(false); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); useEffect(() => { fetchAllData(); }, []); useEffect(() => { if (!autoRefresh || showMeetingModal) return; const timer = setInterval(() => { setCountdown((prev) => { if (prev <= 1) { fetchAllData({ silent: true }); return AUTO_REFRESH_INTERVAL; } return prev - 1; }); }, 1000); return () => clearInterval(timer); }, [autoRefresh, showMeetingModal]); useEffect(() => { fetchTasks(); }, [taskType, taskStatus]); const fetchAllData = async ({ silent = false } = {}) => { if (inFlightRef.current) return; inFlightRef.current = true; try { if (!silent && mountedRef.current) { setLoading(true); } await Promise.all([fetchStats(), fetchOnlineUsers(), fetchUsersList(), fetchTasks(), fetchResources()]); if (mountedRef.current) { setLastUpdatedAt(new Date()); setCountdown(AUTO_REFRESH_INTERVAL); } } catch (err) { console.error('获取数据失败:', err); if (mountedRef.current && !silent) { message.error('加载数据失败,请稍后重试'); } } finally { inFlightRef.current = false; if (mountedRef.current && !silent) { setLoading(false); } } }; const fetchStats = async () => { const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.DASHBOARD_STATS)); if (response.code === '200') setStats(response.data); }; const fetchOnlineUsers = async () => { const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.ONLINE_USERS)); if (response.code === '200') setOnlineUsers(response.data.users || []); }; const fetchUsersList = async () => { const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.USER_STATS)); if (response.code === '200') setUsersList(response.data.users || []); }; const fetchTasks = async () => { try { setTaskLoading(true); const params = new URLSearchParams(); if (taskType !== 'all') params.append('task_type', taskType); if (taskStatus !== 'all') params.append('status', taskStatus); params.append('limit', '20'); const response = await apiClient.get(buildApiUrl(`${API_ENDPOINTS.ADMIN.TASKS_MONITOR}?${params.toString()}`)); if (response.code === '200') setTasks(response.data.tasks || []); } finally { setTaskLoading(false); } }; const fetchResources = async () => { const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.SYSTEM_RESOURCES)); if (response.code === '200') setResources(response.data); }; const handleKickUser = (u) => { modal.confirm({ title: '踢出用户', content: `确定要踢出用户"${u.caption}"吗?该用户将被强制下线。`, okText: '确定', okType: 'danger', cancelText: '取消', onOk: async () => { try { const response = await apiClient.post(buildApiUrl(API_ENDPOINTS.ADMIN.KICK_USER(u.user_id))); if (response.code === '200') { message.success('用户已被踢出'); fetchOnlineUsers(); } } catch { message.error('踢出用户失败'); } }, }); }; const handleViewMeeting = async (meetingId) => { setMeetingLoading(true); setShowMeetingModal(true); try { const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meetingId))); if (response.code === '200') setMeetingDetails(response.data); } catch { message.error('获取会议详情失败'); } finally { setMeetingLoading(false); } }; const handleDownloadTranscript = async (meetingId) => { try { const response = await apiClient.get(buildApiUrl(`/api/meetings/${meetingId}/transcript`)); if (response.code === '200') { const dataStr = JSON.stringify(response.data, null, 2); const blob = new Blob([dataStr], { type: 'application/json' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `transcript_${meetingId}.json`; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); } } catch { message.error('下载失败'); } }; const taskCompletionRate = useMemo(() => { const all = tasks.length || 1; const completed = tasks.filter((item) => item.status === 'completed').length; return Math.round((completed / all) * 100); }, [tasks]); const userColumns = [ { title: 'ID', dataIndex: 'user_id', key: 'user_id', width: 80 }, { title: '用户名', dataIndex: 'username', key: 'username' }, { title: '姓名', dataIndex: 'caption', key: 'caption' }, { title: '注册时间', dataIndex: 'created_at', key: 'created_at', render: (text) => (text ? new Date(text).toLocaleString() : '-'), }, { title: '最新登录', dataIndex: 'last_login_time', key: 'last_login_time', render: (text) => (text ? new Date(text).toLocaleString() : '-'), }, { title: '会议数', dataIndex: 'meeting_count', key: 'meeting_count', align: 'right' }, { title: '总时长', dataIndex: 'total_duration_formatted', key: 'total_duration_formatted' }, ]; const onlineUserColumns = [ { title: '姓名', dataIndex: 'caption', key: 'caption' }, { title: '会话数', dataIndex: 'token_count', key: 'token_count', align: 'center' }, { title: '剩余(h)', dataIndex: 'ttl_hours', key: 'ttl_hours', align: 'center' }, { title: '操作', key: 'action', align: 'center', render: (_, record) => (
用户统计
{stats?.users?.total || 0}
今日新增:{stats?.users?.today_new || 0} 在线:{stats?.users?.online || 0}
会议统计
{stats?.meetings?.total || 0}
今日新增:{stats?.meetings?.today_new || 0} 任务总量:{(stats?.tasks?.transcription?.total || 0) + (stats?.tasks?.summary?.total || 0) + (stats?.tasks?.knowledge_base?.total || 0)}
存储统计
{Number(stats?.storage?.audio_total_size_gb || 0).toFixed(2)} GB
音频文件:{stats?.storage?.audio_file_count || 0} 个 音频目录占用汇总
服务器资源
{formatResourcePercent(resources?.cpu?.percent)}
{formatResourcePercent(resources?.memory?.percent)}
{formatResourcePercent(resources?.disk?.percent)}
{['transcription', 'summary', 'knowledge_base'].map((type) => (
{TASK_TYPE_MAP[type].text}
))}
当前完成率 {taskCompletionRate}%
`${record.task_type}-${record.task_id}`} pagination={{ pageSize: 6 }} loading={taskLoading} scroll={{ x: 760 }} />
在线用户会话可在此直接踢出,立即失效其 Token。 { setShowMeetingModal(false); setMeetingDetails(null); }} footer={[]} width={620} > {meetingLoading ? (
) : meetingDetails ? ( {meetingDetails.title} {meetingDetails.meeting_time ? new Date(meetingDetails.meeting_time).toLocaleString() : '-'} {meetingDetails.prompt_name || '默认模版'} {meetingDetails.audio_duration ? `${Math.floor(meetingDetails.audio_duration / 60)}分${Math.floor(meetingDetails.audio_duration % 60)}秒` : '无时长信息'} ) : (
无法加载数据
)}
); }; export default AdminDashboard;