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';
|
2026-01-19 11:03:08 +00:00
|
|
|
|
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-01-19 11:03:08 +00:00
|
|
|
|
};
|
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-01-19 11:03:08 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
2026-01-19 11:03:08 +00:00
|
|
|
|
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);
|
2026-01-19 11:03:08 +00:00
|
|
|
|
|
|
|
|
|
|
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-01-19 11:03:08 +00:00
|
|
|
|
|
2026-03-26 06:55:12 +00:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
mountedRef.current = true;
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
mountedRef.current = false;
|
2026-01-19 11:03:08 +00:00
|
|
|
|
};
|
2026-03-26 06:55:12 +00:00
|
|
|
|
}, []);
|
2026-01-19 11:03:08 +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]);
|
2026-01-19 11:03:08 +00:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
fetchTasks();
|
|
|
|
|
|
}, [taskType, taskStatus]);
|
|
|
|
|
|
|
2026-03-26 06:55:12 +00:00
|
|
|
|
const fetchAllData = async ({ silent = false } = {}) => {
|
|
|
|
|
|
if (inFlightRef.current) return;
|
|
|
|
|
|
inFlightRef.current = true;
|
|
|
|
|
|
|
2026-01-19 11:03:08 +00:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-01-19 11:03:08 +00:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('获取数据失败:', err);
|
2026-03-26 06:55:12 +00:00
|
|
|
|
if (mountedRef.current && !silent) {
|
|
|
|
|
|
message.error('加载数据失败,请稍后重试');
|
|
|
|
|
|
}
|
2026-01-19 11:03:08 +00:00
|
|
|
|
} finally {
|
2026-03-26 06:55:12 +00:00
|
|
|
|
inFlightRef.current = false;
|
|
|
|
|
|
if (mountedRef.current && !silent) {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
2026-01-19 11:03:08 +00:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
2026-01-19 11:03:08 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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 || []);
|
2026-01-19 11:03:08 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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 || []);
|
2026-01-19 11:03:08 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const fetchTasks = async () => {
|
|
|
|
|
|
try {
|
2026-03-26 06:55:12 +00:00
|
|
|
|
setTaskLoading(true);
|
2026-01-19 11:03:08 +00:00
|
|
|
|
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);
|
2026-01-19 11:03:08 +00:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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-01-19 11:03:08 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
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-19 11:03:08 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
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() : '-'),
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
2026-01-19 11:03:08 +00:00
|
|
|
|
|
|
|
|
|
|
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>
|
2026-01-19 11:03:08 +00:00
|
|
|
|
</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-01-19 11:03:08 +00:00
|
|
|
|
>
|
2026-03-26 06:55:12 +00:00
|
|
|
|
{autoRefresh ? `自动刷新 ${countdown}s` : '已暂停'}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Space>
|
2026-01-19 11:03:08 +00:00
|
|
|
|
</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}>
|
2026-03-26 11:51:00 +00:00
|
|
|
|
<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>
|
2026-01-19 11:03:08 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-03-26 06:55:12 +00:00
|
|
|
|
</Card>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
<Col xs={24} sm={12} lg={6}>
|
2026-03-26 11:51:00 +00:00
|
|
|
|
<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>
|
2026-01-19 11:03:08 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-03-26 06:55:12 +00:00
|
|
|
|
</Card>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
<Col xs={24} sm={12} lg={6}>
|
2026-03-26 11:51:00 +00:00
|
|
|
|
<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>
|
2026-01-19 11:03:08 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-03-26 06:55:12 +00:00
|
|
|
|
</Card>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
<Col xs={24} sm={12} lg={6}>
|
2026-03-26 11:51:00 +00:00
|
|
|
|
<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>
|
2026-01-19 11:03:08 +00:00
|
|
|
|
</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>
|
2026-01-19 11:03:08 +00:00
|
|
|
|
</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>
|
2026-01-19 11:03:08 +00:00
|
|
|
|
</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>
|
2026-01-19 11:03:08 +00:00
|
|
|
|
</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 }}
|
|
|
|
|
|
/>
|
2026-01-19 11:03:08 +00:00
|
|
|
|
</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 }}
|
|
|
|
|
|
/>
|
2026-01-19 11:03:08 +00:00
|
|
|
|
</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>
|
2026-01-19 11:03:08 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export default AdminDashboard;
|