534 lines
20 KiB
JavaScript
534 lines
20 KiB
JavaScript
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;
|