2026-01-19 11:03:08 +00:00
|
|
|
import React, { useState, useEffect } from 'react';
|
2026-01-22 07:23:28 +00:00
|
|
|
import { LogOut, User, Users, Activity, Server, HardDrive, Cpu, MemoryStick, RefreshCw, UserX, ChevronDown, KeyRound, Shield, BookText, Waves, UserCog, Search } from 'lucide-react';
|
2026-01-19 11:03:08 +00:00
|
|
|
import apiClient from '../utils/apiClient';
|
|
|
|
|
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
|
|
|
|
|
import Dropdown from '../components/Dropdown';
|
|
|
|
|
import menuService from '../services/menuService';
|
|
|
|
|
import ConfirmDialog from '../components/ConfirmDialog';
|
|
|
|
|
import Toast from '../components/Toast';
|
|
|
|
|
import PageLoading from '../components/PageLoading';
|
2026-01-22 07:23:28 +00:00
|
|
|
import FormModal from '../components/FormModal';
|
2026-01-19 11:03:08 +00:00
|
|
|
import './AdminDashboard.css';
|
|
|
|
|
|
|
|
|
|
// 常量定义
|
|
|
|
|
const AUTO_REFRESH_INTERVAL = 30; // 自动刷新间隔(秒)
|
|
|
|
|
const STATUS_BADGE_MAP = {
|
|
|
|
|
'pending': 'status-badge status-pending',
|
|
|
|
|
'processing': 'status-badge status-processing',
|
|
|
|
|
'completed': 'status-badge status-completed',
|
|
|
|
|
'failed': 'status-badge status-failed'
|
|
|
|
|
};
|
|
|
|
|
const STATUS_TEXT_MAP = {
|
|
|
|
|
'pending': '待处理',
|
|
|
|
|
'processing': '处理中',
|
|
|
|
|
'completed': '已完成',
|
|
|
|
|
'failed': '失败'
|
|
|
|
|
};
|
|
|
|
|
const TASK_TYPE_TEXT_MAP = {
|
|
|
|
|
'transcription': '转录',
|
|
|
|
|
'summary': '总结',
|
|
|
|
|
'knowledge_base': '知识库'
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 辅助函数
|
|
|
|
|
const getStatusBadgeClass = (status) => STATUS_BADGE_MAP[status] || 'status-badge';
|
|
|
|
|
const getStatusText = (status) => STATUS_TEXT_MAP[status] || status;
|
|
|
|
|
const getTaskTypeText = (type) => TASK_TYPE_TEXT_MAP[type] || type;
|
|
|
|
|
|
|
|
|
|
// 默认管理员菜单
|
|
|
|
|
const getDefaultMenus = () => [
|
|
|
|
|
{ menu_code: 'account_settings', menu_name: '账户设置', menu_type: 'link', menu_url: '/account-settings' },
|
|
|
|
|
{ menu_code: 'prompt_management', menu_name: '提示词仓库', menu_type: 'link', menu_url: '/prompts' },
|
|
|
|
|
{ menu_code: 'platform_admin', menu_name: '平台管理', menu_type: 'link', menu_url: '/admin' },
|
|
|
|
|
{ menu_code: 'logout', menu_name: '退出登录', menu_type: 'action' }
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const AdminDashboard = ({ user, onLogout }) => {
|
|
|
|
|
// 统计数据
|
|
|
|
|
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 [error, setError] = useState('');
|
|
|
|
|
|
|
|
|
|
// 菜单权限相关状态
|
|
|
|
|
const [userMenus, setUserMenus] = useState([]);
|
|
|
|
|
|
|
|
|
|
// 任务筛选
|
|
|
|
|
const [taskType, setTaskType] = useState('all');
|
|
|
|
|
const [taskStatus, setTaskStatus] = useState('all');
|
|
|
|
|
|
|
|
|
|
// 自动刷新定时器
|
|
|
|
|
const [autoRefresh, setAutoRefresh] = useState(true);
|
|
|
|
|
const [countdown, setCountdown] = useState(AUTO_REFRESH_INTERVAL);
|
|
|
|
|
|
|
|
|
|
// Toast和确认对话框
|
|
|
|
|
const [toasts, setToasts] = useState([]);
|
|
|
|
|
const [kickConfirmInfo, setKickConfirmInfo] = useState(null);
|
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
|
|
|
|
|
|
|
|
// Toast辅助函数
|
|
|
|
|
const showToast = (message, type = 'info') => {
|
|
|
|
|
const id = Date.now();
|
|
|
|
|
setToasts(prev => [...prev, { id, message, type }]);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const removeToast = (id) => {
|
|
|
|
|
setToasts(prev => prev.filter(toast => toast.id !== id));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 获取菜单配置
|
|
|
|
|
const getMenuItemConfig = (menu) => {
|
|
|
|
|
const iconMap = {
|
|
|
|
|
'change_password': <KeyRound size={16} />,
|
|
|
|
|
'account_settings': <UserCog size={16} />,
|
|
|
|
|
'prompt_management': <BookText size={16} />,
|
|
|
|
|
'platform_admin': <Shield size={16} />,
|
|
|
|
|
'logout': <LogOut size={16} />
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const actionMap = {
|
|
|
|
|
'logout': onLogout
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
icon: iconMap[menu.menu_code],
|
|
|
|
|
label: menu.menu_name,
|
|
|
|
|
onClick: menu.menu_type === 'link' && menu.menu_url
|
|
|
|
|
? () => window.location.href = menu.menu_url
|
|
|
|
|
: actionMap[menu.menu_code]
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 获取用户菜单权限
|
|
|
|
|
const fetchUserMenus = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const response = await menuService.getUserMenus();
|
|
|
|
|
if (response.code === '200') {
|
|
|
|
|
setUserMenus(response.data.menus || []);
|
|
|
|
|
} else {
|
|
|
|
|
setUserMenus(getDefaultMenus());
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Error fetching user menus:', error);
|
|
|
|
|
setUserMenus(getDefaultMenus());
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 初始化和自动刷新
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
fetchAllData();
|
|
|
|
|
fetchUserMenus();
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-01-22 07:23:28 +00:00
|
|
|
if (autoRefresh && !showMeetingModal) {
|
2026-01-19 11:03:08 +00:00
|
|
|
const timer = setInterval(() => {
|
|
|
|
|
setCountdown(prev => {
|
|
|
|
|
if (prev <= 1) {
|
|
|
|
|
fetchAllData();
|
|
|
|
|
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]);
|
|
|
|
|
|
|
|
|
|
const fetchAllData = async () => {
|
|
|
|
|
try {
|
|
|
|
|
setLoading(true);
|
|
|
|
|
await Promise.all([
|
|
|
|
|
fetchStats(),
|
|
|
|
|
fetchOnlineUsers(),
|
|
|
|
|
fetchUsersList(),
|
|
|
|
|
fetchTasks(),
|
|
|
|
|
fetchResources()
|
|
|
|
|
]);
|
|
|
|
|
setError('');
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('获取数据失败:', err);
|
|
|
|
|
setError('加载数据失败,请稍后重试');
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const fetchStats = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.DASHBOARD_STATS));
|
|
|
|
|
if (response.code === '200') {
|
|
|
|
|
setStats(response.data);
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('获取统计数据失败:', err);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const fetchOnlineUsers = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.ONLINE_USERS));
|
|
|
|
|
if (response.code === '200') {
|
|
|
|
|
setOnlineUsers(response.data.users || []);
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('获取在线用户失败:', err);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const fetchUsersList = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.USER_STATS));
|
|
|
|
|
if (response.code === '200') {
|
|
|
|
|
setUsersList(response.data.users || []);
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('获取用户列表失败:', err);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const fetchTasks = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const params = new URLSearchParams();
|
|
|
|
|
if (taskType !== 'all') params.append('task_type', taskType);
|
|
|
|
|
if (taskStatus !== 'all') params.append('status', taskStatus);
|
|
|
|
|
params.append('limit', '20');
|
|
|
|
|
|
|
|
|
|
const url = `${API_ENDPOINTS.ADMIN.TASKS_MONITOR}?${params.toString()}`;
|
|
|
|
|
const response = await apiClient.get(buildApiUrl(url));
|
|
|
|
|
if (response.code === '200') {
|
|
|
|
|
setTasks(response.data.tasks || []);
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('获取任务列表失败:', err);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const fetchResources = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.SYSTEM_RESOURCES));
|
|
|
|
|
if (response.code === '200') {
|
|
|
|
|
setResources(response.data);
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('获取系统资源失败:', err);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleKickUser = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const response = await apiClient.post(buildApiUrl(API_ENDPOINTS.ADMIN.KICK_USER(kickConfirmInfo.user_id)));
|
|
|
|
|
if (response.code === '200') {
|
|
|
|
|
showToast('用户已被踢出', 'success');
|
|
|
|
|
fetchOnlineUsers();
|
|
|
|
|
} else {
|
|
|
|
|
showToast(`踢出失败: ${response.message}`, 'error');
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('踢出用户失败:', err);
|
|
|
|
|
showToast('踢出用户失败', 'error');
|
|
|
|
|
} finally {
|
|
|
|
|
setKickConfirmInfo(null);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-22 07:23:28 +00:00
|
|
|
const handleViewMeeting = async (meetingId) => {
|
|
|
|
|
if (!meetingId) return;
|
|
|
|
|
setMeetingLoading(true);
|
|
|
|
|
setShowMeetingModal(true);
|
|
|
|
|
setMeetingDetails(null); // Clear previous
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meetingId)));
|
|
|
|
|
if (response.code === '200') {
|
|
|
|
|
setMeetingDetails(response.data);
|
|
|
|
|
} else {
|
|
|
|
|
showToast('获取会议详情失败', 'error');
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Fetch meeting details error:', err);
|
|
|
|
|
showToast('获取会议详情失败', 'error');
|
|
|
|
|
} finally {
|
|
|
|
|
setMeetingLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-19 11:03:08 +00:00
|
|
|
if (loading && !stats) {
|
|
|
|
|
return <PageLoading message="加载中..." />;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="admin-dashboard">
|
|
|
|
|
{/* 顶部导航栏 - 使用与普通Dashboard一致的样式 */}
|
|
|
|
|
<div className="dashboard-header">
|
|
|
|
|
<div className="header-content">
|
|
|
|
|
<div className="logo">
|
|
|
|
|
<Waves size={32} className="logo-icon" />
|
|
|
|
|
<span>管理面板</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="user-actions">
|
|
|
|
|
<button
|
|
|
|
|
className={`auto-refresh-toggle ${autoRefresh ? 'active' : ''}`}
|
|
|
|
|
onClick={() => setAutoRefresh(!autoRefresh)}
|
|
|
|
|
title={autoRefresh ? '关闭自动刷新' : '开启自动刷新'}
|
|
|
|
|
>
|
|
|
|
|
<RefreshCw size={16} />
|
|
|
|
|
{autoRefresh ? '自动刷新' : '手动刷新'}
|
|
|
|
|
</button>
|
|
|
|
|
<Dropdown
|
|
|
|
|
trigger={
|
|
|
|
|
<div className="user-menu-trigger">
|
|
|
|
|
{user.avatar_url ? (
|
|
|
|
|
<img
|
|
|
|
|
src={user.avatar_url.startsWith('http') ? user.avatar_url : `${apiClient.defaults.baseURL || ''}${user.avatar_url}`}
|
|
|
|
|
alt={user.caption}
|
|
|
|
|
style={{
|
|
|
|
|
width: '24px',
|
|
|
|
|
height: '24px',
|
|
|
|
|
borderRadius: '50%',
|
|
|
|
|
objectFit: 'cover',
|
|
|
|
|
border: '1px solid #e2e8f0'
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<User size={20} />
|
|
|
|
|
)}
|
|
|
|
|
<span>{user.caption}</span>
|
|
|
|
|
<ChevronDown size={16} />
|
|
|
|
|
</div>
|
|
|
|
|
}
|
|
|
|
|
items={userMenus.map(menu => getMenuItemConfig(menu))}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Dashboard Content */}
|
|
|
|
|
<div className="dashboard-content">
|
|
|
|
|
{error && <div className="error-message">{error}</div>}
|
|
|
|
|
|
|
|
|
|
{/* 统计卡片 */}
|
|
|
|
|
{stats && (
|
|
|
|
|
<div className="stats-grid">
|
|
|
|
|
{/* 用户统计 */}
|
|
|
|
|
<div className="stat-card">
|
|
|
|
|
<div className="stat-icon" style={{ background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' }}>
|
|
|
|
|
<Users size={24} />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="stat-content">
|
|
|
|
|
<h3>用户统计</h3>
|
|
|
|
|
<div className="stat-number">{stats.users.total}</div>
|
|
|
|
|
<div className="stat-details">
|
|
|
|
|
<span>今日新增: {stats.users.today_new}</span>
|
|
|
|
|
<span>在线: {stats.users.online}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 会议统计 */}
|
|
|
|
|
<div className="stat-card">
|
|
|
|
|
<div className="stat-icon" style={{ background: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)' }}>
|
|
|
|
|
<Activity size={24} />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="stat-content">
|
|
|
|
|
<h3>会议统计</h3>
|
|
|
|
|
<div className="stat-number">{stats.meetings.total}</div>
|
|
|
|
|
<div className="stat-details">
|
|
|
|
|
<span>今日新增: {stats.meetings.today_new}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 存储统计 */}
|
|
|
|
|
<div className="stat-card">
|
|
|
|
|
<div className="stat-icon" style={{ background: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)' }}>
|
|
|
|
|
<HardDrive size={24} />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="stat-content">
|
|
|
|
|
<h3>存储统计</h3>
|
|
|
|
|
<div className="stat-number">{stats.storage.audio_total_size_gb} GB</div>
|
|
|
|
|
<div className="stat-details">
|
|
|
|
|
<span>音频文件: {stats.storage.audio_files_count} 个</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 服务器资源 */}
|
|
|
|
|
{resources && (
|
|
|
|
|
<div className="stat-card">
|
|
|
|
|
<div className="stat-icon" style={{ background: 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)' }}>
|
|
|
|
|
<Server size={24} />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="stat-content">
|
|
|
|
|
<h3>服务器资源</h3>
|
|
|
|
|
<div className="resource-bars">
|
|
|
|
|
<div className="resource-item">
|
|
|
|
|
<Cpu size={14} />
|
|
|
|
|
<div className="resource-bar">
|
|
|
|
|
<div className="resource-fill" style={{ width: `${resources.cpu.percent}%` }}></div>
|
|
|
|
|
</div>
|
|
|
|
|
<span>{resources.cpu.percent}%</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="resource-item">
|
|
|
|
|
<MemoryStick size={14} />
|
|
|
|
|
<div className="resource-bar">
|
|
|
|
|
<div className="resource-fill" style={{ width: `${resources.memory.percent}%` }}></div>
|
|
|
|
|
</div>
|
|
|
|
|
<span>{resources.memory.percent}%</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="resource-item">
|
|
|
|
|
<HardDrive size={14} />
|
|
|
|
|
<div className="resource-bar">
|
|
|
|
|
<div className="resource-fill" style={{ width: `${resources.disk.percent}%` }}></div>
|
|
|
|
|
</div>
|
|
|
|
|
<span>{resources.disk.percent}%</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 任务统计 */}
|
|
|
|
|
{stats && (
|
|
|
|
|
<div className="tasks-overview">
|
|
|
|
|
<h2>任务概览</h2>
|
|
|
|
|
<div className="task-stats-grid">
|
|
|
|
|
<div className="task-stat-card">
|
|
|
|
|
<h3>转录任务</h3>
|
|
|
|
|
<div className="task-stat-numbers">
|
|
|
|
|
<div className="task-stat-item">
|
|
|
|
|
<span className="task-stat-label">进行中</span>
|
|
|
|
|
<span className="task-stat-value running">{stats.tasks.transcription.running}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="task-stat-item">
|
|
|
|
|
<span className="task-stat-label">已完成</span>
|
|
|
|
|
<span className="task-stat-value completed">{stats.tasks.transcription.completed}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="task-stat-item">
|
|
|
|
|
<span className="task-stat-label">失败</span>
|
|
|
|
|
<span className="task-stat-value failed">{stats.tasks.transcription.failed}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="task-stat-card">
|
|
|
|
|
<h3>总结任务</h3>
|
|
|
|
|
<div className="task-stat-numbers">
|
|
|
|
|
<div className="task-stat-item">
|
|
|
|
|
<span className="task-stat-label">进行中</span>
|
|
|
|
|
<span className="task-stat-value running">{stats.tasks.summary.running}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="task-stat-item">
|
|
|
|
|
<span className="task-stat-label">已完成</span>
|
|
|
|
|
<span className="task-stat-value completed">{stats.tasks.summary.completed}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="task-stat-item">
|
|
|
|
|
<span className="task-stat-label">失败</span>
|
|
|
|
|
<span className="task-stat-value failed">{stats.tasks.summary.failed}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="task-stat-card">
|
|
|
|
|
<h3>知识库任务</h3>
|
|
|
|
|
<div className="task-stat-numbers">
|
|
|
|
|
<div className="task-stat-item">
|
|
|
|
|
<span className="task-stat-label">进行中</span>
|
|
|
|
|
<span className="task-stat-value running">{stats.tasks.knowledge_base.running}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="task-stat-item">
|
|
|
|
|
<span className="task-stat-label">已完成</span>
|
|
|
|
|
<span className="task-stat-value completed">{stats.tasks.knowledge_base.completed}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="task-stat-item">
|
|
|
|
|
<span className="task-stat-label">失败</span>
|
|
|
|
|
<span className="task-stat-value failed">{stats.tasks.knowledge_base.failed}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 用户列表 */}
|
|
|
|
|
<div className="admin-panel full-width">
|
|
|
|
|
<div className="panel-header">
|
|
|
|
|
<h2>用户列表 ({usersList.length})</h2>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="panel-content">
|
|
|
|
|
{usersList.length === 0 ? (
|
|
|
|
|
<div className="empty-state">暂无用户数据</div>
|
|
|
|
|
) : (
|
|
|
|
|
<table className="users-table">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr>
|
|
|
|
|
<th>ID</th>
|
|
|
|
|
<th>用户名</th>
|
|
|
|
|
<th>姓名</th>
|
|
|
|
|
<th>注册时间</th>
|
|
|
|
|
<th>最新登录时间</th>
|
|
|
|
|
<th>会议数量</th>
|
|
|
|
|
<th>会议时长</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
{usersList.map(u => (
|
|
|
|
|
<tr key={u.user_id}>
|
|
|
|
|
<td>{u.user_id}</td>
|
|
|
|
|
<td>{u.username}</td>
|
|
|
|
|
<td>{u.caption}</td>
|
|
|
|
|
<td>{u.created_at ? new Date(u.created_at).toLocaleString('zh-CN') : '-'}</td>
|
|
|
|
|
<td>{u.last_login_time ? new Date(u.last_login_time).toLocaleString('zh-CN') : '-'}</td>
|
|
|
|
|
<td>{u.meeting_count || 0}</td>
|
|
|
|
|
<td>{u.total_duration_formatted || '-'}</td>
|
|
|
|
|
</tr>
|
|
|
|
|
))}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="admin-content-grid">
|
|
|
|
|
{/* 在线用户列表 */}
|
|
|
|
|
<div className="admin-panel">
|
|
|
|
|
<div className="panel-header">
|
|
|
|
|
<h2>在线用户 ({onlineUsers.length})</h2>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="panel-content">
|
|
|
|
|
{onlineUsers.length === 0 ? (
|
|
|
|
|
<div className="empty-state">暂无在线用户</div>
|
|
|
|
|
) : (
|
|
|
|
|
<table className="online-users-table">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr>
|
|
|
|
|
<th>ID</th>
|
|
|
|
|
<th>用户名</th>
|
|
|
|
|
<th>姓名</th>
|
|
|
|
|
<th>会话数</th>
|
|
|
|
|
<th>剩余时间</th>
|
|
|
|
|
<th>操作</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
{onlineUsers.map(u => (
|
|
|
|
|
<tr key={u.user_id}>
|
|
|
|
|
<td>{u.user_id}</td>
|
|
|
|
|
<td>{u.username}</td>
|
|
|
|
|
<td>{u.caption}</td>
|
|
|
|
|
<td>{u.token_count}</td>
|
|
|
|
|
<td>{u.ttl_hours}h</td>
|
|
|
|
|
<td>
|
|
|
|
|
<button
|
|
|
|
|
className="kick-btn"
|
|
|
|
|
onClick={() => setKickConfirmInfo(u)}
|
|
|
|
|
title="踢出用户"
|
|
|
|
|
>
|
|
|
|
|
<UserX size={16} />
|
|
|
|
|
</button>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
))}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 任务监控 */}
|
|
|
|
|
<div className="admin-panel">
|
|
|
|
|
<div className="panel-header">
|
|
|
|
|
<h2>任务监控</h2>
|
|
|
|
|
<div className="panel-filters">
|
|
|
|
|
<select value={taskType} onChange={(e) => setTaskType(e.target.value)}>
|
|
|
|
|
<option value="all">全部类型</option>
|
|
|
|
|
<option value="transcription">转录</option>
|
|
|
|
|
<option value="summary">总结</option>
|
|
|
|
|
<option value="knowledge_base">知识库</option>
|
|
|
|
|
</select>
|
|
|
|
|
<select value={taskStatus} onChange={(e) => setTaskStatus(e.target.value)}>
|
|
|
|
|
<option value="all">全部状态</option>
|
|
|
|
|
<option value="running">进行中</option>
|
|
|
|
|
<option value="completed">已完成</option>
|
|
|
|
|
<option value="failed">失败</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="panel-content">
|
|
|
|
|
{tasks.length === 0 ? (
|
|
|
|
|
<div className="empty-state">暂无任务</div>
|
|
|
|
|
) : (
|
|
|
|
|
<table className="tasks-table">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr>
|
|
|
|
|
<th>任务ID</th>
|
|
|
|
|
<th>类型</th>
|
|
|
|
|
<th>关联对象</th>
|
|
|
|
|
<th>创建者</th>
|
|
|
|
|
<th>状态</th>
|
|
|
|
|
<th>进度</th>
|
|
|
|
|
<th>创建时间</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
{tasks.map(task => (
|
|
|
|
|
<tr key={`${task.task_type}-${task.task_id}`}>
|
|
|
|
|
<td>{task.task_id}</td>
|
|
|
|
|
<td>{getTaskTypeText(task.task_type)}</td>
|
2026-01-22 07:23:28 +00:00
|
|
|
<td>
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
|
|
|
{task.meeting_id && task.task_type === 'transcription' && (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="btn-icon"
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
handleViewMeeting(task.meeting_id);
|
|
|
|
|
}}
|
|
|
|
|
title="查看详情"
|
|
|
|
|
style={{ padding: '4px', height: 'auto', width: 'auto', border: 'none', background: 'transparent', cursor: 'pointer', color: '#667eea' }}
|
|
|
|
|
>
|
|
|
|
|
<Search size={16} />
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
<span>{task.meeting_title || '-'}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</td>
|
2026-01-19 11:03:08 +00:00
|
|
|
<td>{task.creator_name || '-'}</td>
|
|
|
|
|
<td>
|
|
|
|
|
<span className={getStatusBadgeClass(task.status)}>
|
|
|
|
|
{getStatusText(task.status)}
|
|
|
|
|
</span>
|
|
|
|
|
</td>
|
|
|
|
|
<td>
|
|
|
|
|
{task.progress !== null ? `${task.progress}%` : '-'}
|
|
|
|
|
</td>
|
|
|
|
|
<td>{new Date(task.created_at).toLocaleString()}</td>
|
|
|
|
|
</tr>
|
|
|
|
|
))}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 踢出用户确认对话框 */}
|
|
|
|
|
<ConfirmDialog
|
|
|
|
|
isOpen={!!kickConfirmInfo}
|
|
|
|
|
onClose={() => setKickConfirmInfo(null)}
|
|
|
|
|
onConfirm={handleKickUser}
|
|
|
|
|
title="踢出用户"
|
|
|
|
|
message={`确定要踢出用户"${kickConfirmInfo?.caption}"吗?该用户将被强制下线。`}
|
|
|
|
|
confirmText="确定踢出"
|
|
|
|
|
cancelText="取消"
|
|
|
|
|
type="warning"
|
|
|
|
|
/>
|
|
|
|
|
|
2026-01-22 07:23:28 +00:00
|
|
|
{/* 会议数据模态框 (使用标准 FormModal) */}
|
|
|
|
|
<FormModal
|
|
|
|
|
isOpen={showMeetingModal}
|
|
|
|
|
onClose={() => setShowMeetingModal(false)}
|
|
|
|
|
title="会议数据"
|
|
|
|
|
size="medium"
|
|
|
|
|
actions={
|
|
|
|
|
<button className="btn btn-secondary" onClick={() => setShowMeetingModal(false)}>关闭</button>
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
{meetingLoading ? (
|
|
|
|
|
<div className="modal-body-loading">
|
|
|
|
|
<div className="loading-spinner"></div>
|
|
|
|
|
<p>正在获取会议数据...</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : meetingDetails ? (
|
|
|
|
|
<div className="meeting-details-info">
|
|
|
|
|
<div className="detail-item">
|
|
|
|
|
<span className="detail-label">会议名称:</span>
|
|
|
|
|
<span className="detail-value">{meetingDetails.title}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="detail-item">
|
|
|
|
|
<span className="detail-label">开始时间:</span>
|
|
|
|
|
<span className="detail-value">
|
|
|
|
|
{meetingDetails.meeting_time ? new Date(meetingDetails.meeting_time).toLocaleString() : '-'}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="detail-item">
|
|
|
|
|
<span className="detail-label">使用模版:</span>
|
|
|
|
|
<span className="detail-value">{meetingDetails.prompt_name || '默认模版'}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="detail-item">
|
|
|
|
|
<span className="detail-label">音频信息:</span>
|
|
|
|
|
<div className="detail-value" style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
|
|
|
|
{meetingDetails.audio_file_path && meetingDetails.audio_file_path.length > 5 ? (
|
|
|
|
|
<>
|
|
|
|
|
<span>{meetingDetails.audio_duration ? `${Math.floor(meetingDetails.audio_duration / 60)}分${Math.floor(meetingDetails.audio_duration % 60)}秒` : '未知时长'}</span>
|
|
|
|
|
<a
|
|
|
|
|
href={meetingDetails.audio_file_path.startsWith('http') ? meetingDetails.audio_file_path : `${apiClient.defaults.baseURL || ''}/${meetingDetails.audio_file_path.replace(/^\//, '')}`}
|
|
|
|
|
target="_blank"
|
|
|
|
|
rel="noopener noreferrer"
|
|
|
|
|
style={{ color: '#3b82f6', textDecoration: 'underline', cursor: 'pointer' }}
|
|
|
|
|
>
|
|
|
|
|
下载
|
|
|
|
|
</a>
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<span style={{ color: '#94a3b8' }}>无音频</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="empty-state">无法加载数据</div>
|
|
|
|
|
)}
|
|
|
|
|
</FormModal>
|
|
|
|
|
|
2026-01-19 11:03:08 +00:00
|
|
|
{/* Toast notifications */}
|
|
|
|
|
{toasts.map(toast => (
|
|
|
|
|
<Toast
|
|
|
|
|
key={toast.id}
|
|
|
|
|
message={toast.message}
|
|
|
|
|
type={toast.type}
|
|
|
|
|
onClose={() => removeToast(toast.id)}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default AdminDashboard;
|