imetting/frontend/src/pages/AdminDashboard.jsx

534 lines
20 KiB
React
Raw Normal View History

2026-03-26 06:55:12 +00:00
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';
2026-03-26 06:55:12 +00:00
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' },
};
2026-03-26 06:55:12 +00:00
const STATUS_MAP = {
pending: { text: '待处理', color: 'default' },
processing: { text: '处理中', color: 'processing' },
completed: { text: '已完成', color: 'success' },
failed: { text: '失败', color: 'error' },
};
2026-03-26 06:55:12 +00:00
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);
2026-03-26 06:55:12 +00:00
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);
2026-01-22 07:23:28 +00:00
const [showMeetingModal, setShowMeetingModal] = useState(false);
const [meetingDetails, setMeetingDetails] = useState(null);
const [meetingLoading, setMeetingLoading] = useState(false);
2026-03-26 06:55:12 +00:00
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
2026-03-26 06:55:12 +00:00
}, []);
useEffect(() => {
fetchAllData();
}, []);
useEffect(() => {
2026-03-26 06:55:12 +00:00
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);
2026-01-22 07:23:28 +00:00
}, [autoRefresh, showMeetingModal]);
useEffect(() => {
fetchTasks();
}, [taskType, taskStatus]);
2026-03-26 06:55:12 +00:00
const fetchAllData = async ({ silent = false } = {}) => {
if (inFlightRef.current) return;
inFlightRef.current = true;
try {
2026-03-26 06:55:12 +00:00
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);
2026-03-26 06:55:12 +00:00
if (mountedRef.current && !silent) {
message.error('加载数据失败,请稍后重试');
}
} finally {
2026-03-26 06:55:12 +00:00
inFlightRef.current = false;
if (mountedRef.current && !silent) {
setLoading(false);
}
}
};
const fetchStats = async () => {
2026-03-26 06:55:12 +00:00
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.DASHBOARD_STATS));
if (response.code === '200') setStats(response.data);
};
const fetchOnlineUsers = async () => {
2026-03-26 06:55:12 +00:00
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.ONLINE_USERS));
if (response.code === '200') setOnlineUsers(response.data.users || []);
};
const fetchUsersList = async () => {
2026-03-26 06:55:12 +00:00
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.USER_STATS));
if (response.code === '200') setUsersList(response.data.users || []);
};
const fetchTasks = async () => {
try {
2026-03-26 06:55:12 +00:00
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');
2026-03-26 06:55:12 +00:00
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 () => {
2026-03-26 06:55:12 +00:00
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.SYSTEM_RESOURCES));
if (response.code === '200') setResources(response.data);
};
2026-03-26 06:55:12 +00:00
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('踢出用户失败');
}
},
});
};
2026-01-22 07:23:28 +00:00
const handleViewMeeting = async (meetingId) => {
setMeetingLoading(true);
setShowMeetingModal(true);
try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meetingId)));
2026-03-26 06:55:12 +00:00
if (response.code === '200') setMeetingDetails(response.data);
} catch {
message.error('获取会议详情失败');
2026-01-22 07:23:28 +00:00
} finally {
setMeetingLoading(false);
}
};
2026-02-06 09:14:37 +00:00
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);
2026-03-26 06:55:12 +00:00
const blob = new Blob([dataStr], { type: 'application/json' });
2026-02-06 09:14:37 +00:00
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);
2026-03-26 06:55:12 +00:00
URL.revokeObjectURL(url);
2026-02-06 09:14:37 +00:00
}
2026-03-26 06:55:12 +00:00
} catch {
message.error('下载失败');
2026-02-06 09:14:37 +00:00
}
};
2026-03-26 06:55:12 +00:00
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 (
2026-03-26 06:55:12 +00:00
<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>
2026-03-26 06:55:12 +00:00
<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)}
>
2026-03-26 06:55:12 +00:00
{autoRefresh ? `自动刷新 ${countdown}s` : '已暂停'}
</Button>
</Space>
</div>
2026-03-26 06:55:12 +00:00
</Card>
<Row gutter={[16, 16]} className="admin-overview-grid">
<Col xs={24} sm={12} lg={6}>
<Card className="admin-overview-card" variant="borderless">
2026-03-26 06:55:12 +00:00
<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>
2026-03-26 06:55:12 +00:00
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card className="admin-overview-card" variant="borderless">
2026-03-26 06:55:12 +00:00
<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>
2026-03-26 06:55:12 +00:00
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card className="admin-overview-card" variant="borderless">
2026-03-26 06:55:12 +00:00
<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>
2026-03-26 06:55:12 +00:00
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card className="admin-overview-card" variant="borderless">
2026-03-26 06:55:12 +00:00
<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>
2026-03-26 06:55:12 +00:00
<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>
2026-03-26 06:55:12 +00:00
<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>
2026-03-26 06:55:12 +00:00
</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>
2026-03-26 06:55:12 +00:00
<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>
2026-03-26 06:55:12 +00:00
</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>
2026-03-26 06:55:12 +00:00
</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}
2026-01-22 07:23:28 +00:00
>
{meetingLoading ? (
2026-03-26 06:55:12 +00:00
<div style={{ textAlign: 'center', padding: '44px 0' }}>
<SyncOutlined spin style={{ fontSize: 24 }} />
2026-01-22 07:23:28 +00:00
</div>
) : meetingDetails ? (
2026-03-26 06:55:12 +00:00
<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>
2026-01-22 07:23:28 +00:00
) : (
2026-03-26 06:55:12 +00:00
<div style={{ textAlign: 'center', padding: 20 }}>无法加载数据</div>
2026-01-22 07:23:28 +00:00
)}
2026-03-26 06:55:12 +00:00
</Modal>
</div>
);
};
export default AdminDashboard;