imetting/frontend/src/pages/AdminDashboard.jsx

534 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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) => (
<Button type="text" danger className="btn-text-delete" icon={<UserDeleteOutlined />} onClick={() => handleKickUser(record)} />
),
},
];
const taskColumns = [
{
title: '类型',
dataIndex: 'task_type',
key: 'task_type',
width: 100,
render: (type) => <Tag color={TASK_TYPE_MAP[type]?.color}>{TASK_TYPE_MAP[type]?.text || type}</Tag>,
},
{
title: '关联会议',
dataIndex: 'meeting_title',
key: 'meeting_title',
render: (text, record) => (
<Space>
{record.meeting_id && (
<Tooltip title="查看详情">
<Button type="link" size="small" icon={<SearchOutlined />} onClick={() => handleViewMeeting(record.meeting_id)} />
</Tooltip>
)}
<span>{text || '-'}</span>
</Space>
),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 110,
render: (status) => <Badge status={STATUS_MAP[status]?.color} text={STATUS_MAP[status]?.text || status} />,
},
{
title: '进度',
dataIndex: 'progress',
key: 'progress',
width: 210,
render: (progress) => (progress !== null ? <Progress percent={progress} size="small" /> : '-'),
},
{
title: '时间',
dataIndex: 'created_at',
key: 'created_at',
width: 120,
render: (text) => (text ? new Date(text).toLocaleTimeString() : '-'),
},
];
return (
<div style={{ paddingBottom: 24 }}>
<Card className="console-surface" style={{ marginBottom: 18 }}>
<div className="console-toolbar">
<div>
<h2 className="console-section-title">管理概览</h2>
<p className="console-section-subtitle">实时监控平台用户任务与资源快速处理异常会话与任务状态</p>
</div>
<Space wrap>
<Tag icon={<ClockCircleOutlined />} color="cyan">
{lastUpdatedAt ? `上次更新 ${lastUpdatedAt.toLocaleTimeString()}` : '等待初始化'}
</Tag>
<Button icon={<ReloadOutlined />} onClick={fetchAllData} loading={loading}>
立即刷新
</Button>
<Button
type={autoRefresh ? 'default' : 'primary'}
icon={autoRefresh ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
onClick={() => setAutoRefresh((prev) => !prev)}
>
{autoRefresh ? `自动刷新 ${countdown}s` : '已暂停'}
</Button>
</Space>
</div>
</Card>
<Row gutter={[16, 16]} className="admin-overview-grid">
<Col xs={24} sm={12} lg={6}>
<Card className="admin-overview-card" variant="borderless">
<div className="admin-overview-title">用户统计</div>
<div className="admin-overview-main">
<span className="admin-overview-icon users"><UsergroupAddOutlined /></span>
<div>
<div className="admin-overview-value">{stats?.users?.total || 0}</div>
<div className="admin-overview-meta">
<span>今日新增{stats?.users?.today_new || 0}</span>
<span>在线{stats?.users?.online || 0}</span>
</div>
</div>
</div>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card className="admin-overview-card" variant="borderless">
<div className="admin-overview-title">会议统计</div>
<div className="admin-overview-main">
<span className="admin-overview-icon meetings"><VideoCameraAddOutlined /></span>
<div>
<div className="admin-overview-value">{stats?.meetings?.total || 0}</div>
<div className="admin-overview-meta">
<span>今日新增{stats?.meetings?.today_new || 0}</span>
<span>任务总量{(stats?.tasks?.transcription?.total || 0) + (stats?.tasks?.summary?.total || 0) + (stats?.tasks?.knowledge_base?.total || 0)}</span>
</div>
</div>
</div>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card className="admin-overview-card" variant="borderless">
<div className="admin-overview-title">存储统计</div>
<div className="admin-overview-main">
<span className="admin-overview-icon storage"><DatabaseOutlined /></span>
<div>
<div className="admin-overview-value compact">{Number(stats?.storage?.audio_total_size_gb || 0).toFixed(2)} GB</div>
<div className="admin-overview-meta">
<span>音频文件{stats?.storage?.audio_file_count || 0} </span>
<span>音频目录占用汇总</span>
</div>
</div>
</div>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card className="admin-overview-card" variant="borderless">
<div className="admin-overview-title">服务器资源</div>
<div className="admin-overview-main">
<span className="admin-overview-icon resources"><DeploymentUnitOutlined /></span>
<div className="admin-resource-list">
<div className="admin-resource-row">
<ApartmentOutlined style={{ color: '#334155' }} />
<Progress percent={resources?.cpu?.percent || 0} size="small" showInfo={false} strokeColor={{ from: '#60a5fa', to: '#2563eb' }} />
<span className="admin-resource-value">{formatResourcePercent(resources?.cpu?.percent)}</span>
</div>
<div className="admin-resource-row">
<DatabaseOutlined style={{ color: '#334155' }} />
<Progress percent={resources?.memory?.percent || 0} size="small" showInfo={false} strokeColor={{ from: '#a78bfa', to: '#7c3aed' }} />
<span className="admin-resource-value">{formatResourcePercent(resources?.memory?.percent)}</span>
</div>
<div className="admin-resource-row">
<HddOutlined style={{ color: '#334155' }} />
<Progress percent={resources?.disk?.percent || 0} size="small" showInfo={false} strokeColor={{ from: '#818cf8', to: '#6d28d9' }} />
<span className="admin-resource-value">{formatResourcePercent(resources?.disk?.percent)}</span>
</div>
</div>
</div>
</Card>
</Col>
</Row>
<Card className="console-surface" title="任务监控" style={{ marginBottom: 18 }}>
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
{['transcription', 'summary', 'knowledge_base'].map((type) => (
<Col key={type} xs={24} md={8}>
<Card size="small" style={{ borderRadius: 12, borderColor: '#dbe7f3', background: '#f8fbff' }}>
<div style={{ fontWeight: 600, marginBottom: 10 }}>{TASK_TYPE_MAP[type].text}</div>
<Row gutter={10}>
<Col span={8}>
<Statistic title="处理中" value={stats?.tasks?.[type]?.running || 0} valueStyle={{ color: '#1d4ed8', fontSize: 18 }} />
</Col>
<Col span={8}>
<Statistic title="完成" value={stats?.tasks?.[type]?.completed || 0} valueStyle={{ color: '#0f766e', fontSize: 18 }} />
</Col>
<Col span={8}>
<Statistic title="失败" value={stats?.tasks?.[type]?.failed || 0} valueStyle={{ color: '#c2410c', fontSize: 18 }} />
</Col>
</Row>
</Card>
</Col>
))}
</Row>
<div className="console-toolbar" style={{ marginBottom: 12 }}>
<Space wrap>
<Select value={taskType} onChange={setTaskType} style={{ width: 140 }}>
<Select.Option value="all">全部类型</Select.Option>
<Select.Option value="transcription">转录</Select.Option>
<Select.Option value="summary">总结</Select.Option>
<Select.Option value="knowledge_base">知识库</Select.Option>
</Select>
<Select value={taskStatus} onChange={setTaskStatus} style={{ width: 140 }}>
<Select.Option value="all">全部状态</Select.Option>
<Select.Option value="pending">待处理</Select.Option>
<Select.Option value="processing">处理中</Select.Option>
<Select.Option value="completed">已完成</Select.Option>
<Select.Option value="failed">失败</Select.Option>
</Select>
</Space>
<Tag color="blue">当前完成率 {taskCompletionRate}%</Tag>
</div>
<div className="console-table">
<Table
columns={taskColumns}
dataSource={tasks}
rowKey={(record) => `${record.task_type}-${record.task_id}`}
pagination={{ pageSize: 6 }}
loading={taskLoading}
scroll={{ x: 760 }}
/>
</div>
</Card>
<Row gutter={[16, 16]}>
<Col xs={24} lg={16}>
<Card className="console-surface" title={`用户列表 (${usersList.length})`}>
<div className="console-table">
<Table
columns={userColumns}
dataSource={usersList}
rowKey="user_id"
pagination={{ pageSize: 10 }}
scroll={{ x: 760 }}
/>
</div>
</Card>
</Col>
<Col xs={24} lg={8}>
<Card className="console-surface" title={`在线用户 (${onlineUsers.length})`}>
<div className="console-table">
<Table columns={onlineUserColumns} dataSource={onlineUsers} rowKey="user_id" pagination={false} />
</div>
<Divider style={{ margin: '16px 0 12px' }} />
<Text type="secondary">在线用户会话可在此直接踢出立即失效其 Token</Text>
</Card>
</Col>
</Row>
<Modal
title="会议详情"
open={showMeetingModal}
onCancel={() => {
setShowMeetingModal(false);
setMeetingDetails(null);
}}
footer={[<Button key="close" icon={<CloseOutlined />} onClick={() => setShowMeetingModal(false)}>关闭</Button>]}
width={620}
>
{meetingLoading ? (
<div style={{ textAlign: 'center', padding: '44px 0' }}>
<SyncOutlined spin style={{ fontSize: 24 }} />
</div>
) : meetingDetails ? (
<Descriptions bordered column={1} size="small">
<Descriptions.Item label="会议名称">{meetingDetails.title}</Descriptions.Item>
<Descriptions.Item label="开始时间">
{meetingDetails.meeting_time ? new Date(meetingDetails.meeting_time).toLocaleString() : '-'}
</Descriptions.Item>
<Descriptions.Item label="使用模版">{meetingDetails.prompt_name || '默认模版'}</Descriptions.Item>
<Descriptions.Item label="音频信息">
{meetingDetails.audio_duration
? `${Math.floor(meetingDetails.audio_duration / 60)}${Math.floor(meetingDetails.audio_duration % 60)}`
: '无时长信息'}
</Descriptions.Item>
<Descriptions.Item label="操作">
<Button type="link" icon={<FileTextOutlined />} onClick={() => handleDownloadTranscript(meetingDetails.meeting_id)}>
下载转录结果 (JSON)
</Button>
</Descriptions.Item>
</Descriptions>
) : (
<div style={{ textAlign: 'center', padding: 20 }}>无法加载数据</div>
)}
</Modal>
</div>
);
};
export default AdminDashboard;