2025-10-21 09:30:30 +00:00
|
|
|
|
import React, { useState, useEffect } from 'react';
|
|
|
|
|
|
import {
|
|
|
|
|
|
Download,
|
|
|
|
|
|
Plus,
|
|
|
|
|
|
Edit,
|
|
|
|
|
|
Trash2,
|
|
|
|
|
|
Smartphone,
|
|
|
|
|
|
Monitor,
|
|
|
|
|
|
Apple,
|
|
|
|
|
|
Search,
|
|
|
|
|
|
X,
|
|
|
|
|
|
ChevronDown,
|
2025-11-12 07:29:05 +00:00
|
|
|
|
ChevronUp,
|
|
|
|
|
|
Package,
|
|
|
|
|
|
Hash,
|
|
|
|
|
|
Link,
|
|
|
|
|
|
FileText,
|
2025-12-11 08:47:46 +00:00
|
|
|
|
HardDrive,
|
2025-12-18 11:57:56 +00:00
|
|
|
|
Cpu,
|
|
|
|
|
|
Upload
|
2025-10-21 09:30:30 +00:00
|
|
|
|
} from 'lucide-react';
|
|
|
|
|
|
import apiClient from '../utils/apiClient';
|
|
|
|
|
|
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
|
2025-10-31 06:55:19 +00:00
|
|
|
|
import ConfirmDialog from '../components/ConfirmDialog';
|
2025-11-12 07:29:05 +00:00
|
|
|
|
import FormModal from '../components/FormModal';
|
2025-10-31 06:55:19 +00:00
|
|
|
|
import Toast from '../components/Toast';
|
2025-10-21 09:30:30 +00:00
|
|
|
|
import './ClientManagement.css';
|
|
|
|
|
|
|
|
|
|
|
|
const ClientManagement = ({ user }) => {
|
|
|
|
|
|
const [clients, setClients] = useState([]);
|
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
2025-11-12 07:29:05 +00:00
|
|
|
|
const [showClientModal, setShowClientModal] = useState(false);
|
|
|
|
|
|
const [isEditing, setIsEditing] = useState(false);
|
2025-10-31 06:55:19 +00:00
|
|
|
|
const [deleteConfirmInfo, setDeleteConfirmInfo] = useState(null);
|
2025-10-21 09:30:30 +00:00
|
|
|
|
const [selectedClient, setSelectedClient] = useState(null);
|
|
|
|
|
|
const [filterPlatformType, setFilterPlatformType] = useState('');
|
|
|
|
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
|
|
|
|
const [expandedNotes, setExpandedNotes] = useState({});
|
2025-10-31 06:55:19 +00:00
|
|
|
|
const [toasts, setToasts] = useState([]);
|
2025-12-18 11:57:56 +00:00
|
|
|
|
const [uploadingFile, setUploadingFile] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
// 码表数据
|
|
|
|
|
|
const [platforms, setPlatforms] = useState({ tree: [], items: [] });
|
|
|
|
|
|
const [platformsMap, setPlatformsMap] = useState({});
|
2025-10-21 09:30:30 +00:00
|
|
|
|
|
|
|
|
|
|
const [formData, setFormData] = useState({
|
2025-12-18 11:57:56 +00:00
|
|
|
|
platform_code: '',
|
2025-10-21 09:30:30 +00:00
|
|
|
|
version: '',
|
|
|
|
|
|
version_code: '',
|
|
|
|
|
|
download_url: '',
|
|
|
|
|
|
file_size: '',
|
|
|
|
|
|
release_notes: '',
|
|
|
|
|
|
is_active: true,
|
|
|
|
|
|
is_latest: false,
|
|
|
|
|
|
min_system_version: ''
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-10-31 06:55:19 +00:00
|
|
|
|
// Toast helper functions
|
|
|
|
|
|
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));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-10-21 09:30:30 +00:00
|
|
|
|
useEffect(() => {
|
2025-12-18 11:57:56 +00:00
|
|
|
|
fetchPlatforms();
|
2025-10-21 09:30:30 +00:00
|
|
|
|
fetchClients();
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2025-12-18 11:57:56 +00:00
|
|
|
|
const fetchPlatforms = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.DICT_DATA.BY_TYPE('client_platform')));
|
|
|
|
|
|
const { tree, items } = response.data;
|
|
|
|
|
|
setPlatforms({ tree, items });
|
|
|
|
|
|
|
|
|
|
|
|
// 构建快速查找map
|
|
|
|
|
|
const map = {};
|
|
|
|
|
|
items.forEach(item => {
|
|
|
|
|
|
map[item.dict_code] = item;
|
|
|
|
|
|
});
|
|
|
|
|
|
setPlatformsMap(map);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('获取平台列表失败:', error);
|
|
|
|
|
|
showToast('获取平台列表失败', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-10-21 09:30:30 +00:00
|
|
|
|
const fetchClients = async () => {
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.LIST));
|
|
|
|
|
|
console.log('Client downloads response:', response);
|
|
|
|
|
|
setClients(response.data.clients || []);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('获取客户端列表失败:', error);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleCreate = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 验证必填字段
|
2025-12-18 11:57:56 +00:00
|
|
|
|
if (!formData.platform_code || !formData.version_code || !formData.version || !formData.download_url) {
|
2025-10-31 06:55:19 +00:00
|
|
|
|
showToast('请填写所有必填字段', 'warning');
|
2025-10-21 09:30:30 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 11:57:56 +00:00
|
|
|
|
// 根据platform_code映射到旧字段platform_type和platform_name以保持向后兼容
|
|
|
|
|
|
const platformInfo = platformsMap[formData.platform_code];
|
|
|
|
|
|
const parentCode = platformInfo?.parent_code;
|
|
|
|
|
|
const parentInfo = parentCode && parentCode !== 'ROOT' ? platformsMap[parentCode] : null;
|
|
|
|
|
|
|
|
|
|
|
|
// 映射platform_type
|
|
|
|
|
|
let platform_type = 'desktop'; // 默认
|
|
|
|
|
|
if (parentInfo) {
|
|
|
|
|
|
const parentCodeUpper = parentCode.toUpperCase();
|
|
|
|
|
|
if (parentCodeUpper === 'MOBILE') platform_type = 'mobile';
|
|
|
|
|
|
else if (parentCodeUpper === 'DESKTOP') platform_type = 'desktop';
|
|
|
|
|
|
else if (parentCodeUpper === 'TERMINAL') platform_type = 'terminal';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 映射platform_name (简化映射,用小写的dict_code)
|
|
|
|
|
|
const platform_name = formData.platform_code.toLowerCase();
|
|
|
|
|
|
|
2025-10-21 09:30:30 +00:00
|
|
|
|
const payload = {
|
|
|
|
|
|
...formData,
|
2025-12-18 11:57:56 +00:00
|
|
|
|
platform_type,
|
|
|
|
|
|
platform_name,
|
2025-10-21 09:30:30 +00:00
|
|
|
|
version_code: parseInt(formData.version_code, 10),
|
|
|
|
|
|
file_size: formData.file_size ? parseInt(formData.file_size, 10) : null
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 验证转换后的数字
|
|
|
|
|
|
if (isNaN(payload.version_code)) {
|
2025-10-31 06:55:19 +00:00
|
|
|
|
showToast('版本代码必须是有效的数字', 'warning');
|
2025-10-21 09:30:30 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (formData.file_size && isNaN(payload.file_size)) {
|
2025-10-31 06:55:19 +00:00
|
|
|
|
showToast('文件大小必须是有效的数字', 'warning');
|
2025-10-21 09:30:30 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await apiClient.post(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.CREATE), payload);
|
2025-11-12 07:29:05 +00:00
|
|
|
|
handleCloseModal();
|
2025-10-21 09:30:30 +00:00
|
|
|
|
fetchClients();
|
2025-11-12 07:29:05 +00:00
|
|
|
|
showToast('客户端创建成功', 'success');
|
2025-10-21 09:30:30 +00:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('创建客户端失败:', error);
|
2025-10-31 06:55:19 +00:00
|
|
|
|
showToast(error.response?.data?.message || '创建失败,请重试', 'error');
|
2025-10-21 09:30:30 +00:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleUpdate = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 验证必填字段
|
2025-12-18 11:57:56 +00:00
|
|
|
|
if (!formData.platform_code || !formData.version_code || !formData.version || !formData.download_url) {
|
2025-10-31 06:55:19 +00:00
|
|
|
|
showToast('请填写所有必填字段', 'warning');
|
2025-10-21 09:30:30 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const payload = {
|
2025-12-18 11:57:56 +00:00
|
|
|
|
platform_code: formData.platform_code,
|
2025-10-21 09:30:30 +00:00
|
|
|
|
version: formData.version,
|
|
|
|
|
|
version_code: parseInt(formData.version_code, 10),
|
|
|
|
|
|
download_url: formData.download_url,
|
|
|
|
|
|
file_size: formData.file_size ? parseInt(formData.file_size, 10) : null,
|
|
|
|
|
|
release_notes: formData.release_notes,
|
|
|
|
|
|
is_active: formData.is_active,
|
|
|
|
|
|
is_latest: formData.is_latest,
|
|
|
|
|
|
min_system_version: formData.min_system_version
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 验证转换后的数字
|
|
|
|
|
|
if (isNaN(payload.version_code)) {
|
2025-10-31 06:55:19 +00:00
|
|
|
|
showToast('版本代码必须是有效的数字', 'warning');
|
2025-10-21 09:30:30 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (formData.file_size && isNaN(payload.file_size)) {
|
2025-10-31 06:55:19 +00:00
|
|
|
|
showToast('文件大小必须是有效的数字', 'warning');
|
2025-10-21 09:30:30 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await apiClient.put(
|
|
|
|
|
|
buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.UPDATE(selectedClient.id)),
|
|
|
|
|
|
payload
|
|
|
|
|
|
);
|
2025-11-12 07:29:05 +00:00
|
|
|
|
handleCloseModal();
|
2025-10-21 09:30:30 +00:00
|
|
|
|
fetchClients();
|
2025-11-12 07:29:05 +00:00
|
|
|
|
showToast('客户端更新成功', 'success');
|
2025-10-21 09:30:30 +00:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('更新客户端失败:', error);
|
2025-10-31 06:55:19 +00:00
|
|
|
|
showToast(error.response?.data?.message || '更新失败,请重试', 'error');
|
2025-10-21 09:30:30 +00:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleDelete = async () => {
|
|
|
|
|
|
try {
|
2025-10-31 06:55:19 +00:00
|
|
|
|
await apiClient.delete(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.DELETE(deleteConfirmInfo.id)));
|
|
|
|
|
|
setDeleteConfirmInfo(null);
|
2025-10-21 09:30:30 +00:00
|
|
|
|
setSelectedClient(null);
|
2025-10-31 06:55:19 +00:00
|
|
|
|
showToast('删除成功', 'success');
|
2025-10-21 09:30:30 +00:00
|
|
|
|
fetchClients();
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('删除客户端失败:', error);
|
2025-10-31 06:55:19 +00:00
|
|
|
|
showToast('删除失败,请重试', 'error');
|
|
|
|
|
|
setDeleteConfirmInfo(null);
|
2025-10-21 09:30:30 +00:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-12 07:29:05 +00:00
|
|
|
|
const handleOpenModal = (client = null) => {
|
|
|
|
|
|
if (client) {
|
|
|
|
|
|
setIsEditing(true);
|
|
|
|
|
|
setSelectedClient(client);
|
|
|
|
|
|
setFormData({
|
2025-12-18 11:57:56 +00:00
|
|
|
|
platform_code: client.platform_code || '',
|
2025-11-12 07:29:05 +00:00
|
|
|
|
version: client.version,
|
|
|
|
|
|
version_code: String(client.version_code),
|
|
|
|
|
|
download_url: client.download_url,
|
|
|
|
|
|
file_size: client.file_size ? String(client.file_size) : '',
|
|
|
|
|
|
release_notes: client.release_notes || '',
|
|
|
|
|
|
is_active: client.is_active,
|
|
|
|
|
|
is_latest: client.is_latest,
|
|
|
|
|
|
min_system_version: client.min_system_version || ''
|
|
|
|
|
|
});
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setIsEditing(false);
|
|
|
|
|
|
setSelectedClient(null);
|
2025-12-18 11:57:56 +00:00
|
|
|
|
// 默认选择第一个可用的平台
|
|
|
|
|
|
const defaultPlatformCode = platforms.items.length > 0 && platforms.items[0].parent_code !== 'ROOT'
|
|
|
|
|
|
? platforms.items[0].dict_code
|
|
|
|
|
|
: '';
|
2025-11-12 07:29:05 +00:00
|
|
|
|
setFormData({
|
2025-12-18 11:57:56 +00:00
|
|
|
|
platform_code: defaultPlatformCode,
|
2025-11-12 07:29:05 +00:00
|
|
|
|
version: '',
|
|
|
|
|
|
version_code: '',
|
|
|
|
|
|
download_url: '',
|
|
|
|
|
|
file_size: '',
|
|
|
|
|
|
release_notes: '',
|
|
|
|
|
|
is_active: true,
|
|
|
|
|
|
is_latest: false,
|
|
|
|
|
|
min_system_version: ''
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
setShowClientModal(true);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleCloseModal = () => {
|
|
|
|
|
|
setShowClientModal(false);
|
|
|
|
|
|
setIsEditing(false);
|
|
|
|
|
|
setSelectedClient(null);
|
|
|
|
|
|
resetForm();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleSave = async () => {
|
|
|
|
|
|
if (isEditing) {
|
|
|
|
|
|
await handleUpdate();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
await handleCreate();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleInputChange = (field, value) => {
|
|
|
|
|
|
setFormData(prev => ({ ...prev, [field]: value }));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-18 11:57:56 +00:00
|
|
|
|
const handleFileUpload = async (event) => {
|
|
|
|
|
|
const file = event.target.files[0];
|
|
|
|
|
|
if (!file) return;
|
|
|
|
|
|
|
|
|
|
|
|
if (!formData.platform_code) {
|
|
|
|
|
|
showToast('请先选择平台', 'warning');
|
|
|
|
|
|
event.target.value = '';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setUploadingFile(true);
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const uploadFormData = new FormData();
|
|
|
|
|
|
uploadFormData.append('file', file);
|
|
|
|
|
|
uploadFormData.append('platform_code', formData.platform_code);
|
|
|
|
|
|
|
|
|
|
|
|
const response = await apiClient.post(
|
|
|
|
|
|
buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.UPLOAD),
|
|
|
|
|
|
uploadFormData,
|
|
|
|
|
|
{
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Content-Type': 'multipart/form-data'
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const { file_size, download_url, version_code, version_name } = response.data;
|
|
|
|
|
|
|
|
|
|
|
|
// 自动填充表单
|
|
|
|
|
|
setFormData(prev => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
file_size: file_size ? String(file_size) : prev.file_size,
|
|
|
|
|
|
download_url: download_url || prev.download_url,
|
|
|
|
|
|
version_code: version_code ? String(version_code) : prev.version_code,
|
|
|
|
|
|
version: version_name || prev.version
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
showToast('文件上传成功,已自动填充相关字段', 'success');
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('文件上传失败:', error);
|
|
|
|
|
|
showToast(error.response?.data?.message || '文件上传失败', 'error');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setUploadingFile(false);
|
|
|
|
|
|
event.target.value = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-10-21 09:30:30 +00:00
|
|
|
|
const openEditModal = (client) => {
|
2025-11-12 07:29:05 +00:00
|
|
|
|
handleOpenModal(client);
|
2025-10-21 09:30:30 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const openDeleteModal = (client) => {
|
2025-10-31 06:55:19 +00:00
|
|
|
|
setDeleteConfirmInfo({
|
|
|
|
|
|
id: client.id,
|
2025-12-18 11:57:56 +00:00
|
|
|
|
platform_name: getPlatformLabel(client.platform_code),
|
2025-10-31 06:55:19 +00:00
|
|
|
|
version: client.version
|
|
|
|
|
|
});
|
2025-10-21 09:30:30 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const resetForm = () => {
|
2025-12-18 11:57:56 +00:00
|
|
|
|
const defaultPlatformCode = platforms.items.length > 0 && platforms.items[0].parent_code !== 'ROOT'
|
|
|
|
|
|
? platforms.items[0].dict_code
|
|
|
|
|
|
: '';
|
2025-10-21 09:30:30 +00:00
|
|
|
|
setFormData({
|
2025-12-18 11:57:56 +00:00
|
|
|
|
platform_code: defaultPlatformCode,
|
2025-10-21 09:30:30 +00:00
|
|
|
|
version: '',
|
|
|
|
|
|
version_code: '',
|
|
|
|
|
|
download_url: '',
|
|
|
|
|
|
file_size: '',
|
|
|
|
|
|
release_notes: '',
|
|
|
|
|
|
is_active: true,
|
|
|
|
|
|
is_latest: false,
|
|
|
|
|
|
min_system_version: ''
|
|
|
|
|
|
});
|
|
|
|
|
|
setSelectedClient(null);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-18 11:57:56 +00:00
|
|
|
|
const getPlatformLabel = (platformCode) => {
|
|
|
|
|
|
const platform = platformsMap[platformCode];
|
|
|
|
|
|
return platform ? platform.label_cn : platformCode;
|
2025-10-21 09:30:30 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const formatFileSize = (bytes) => {
|
|
|
|
|
|
if (!bytes) return '-';
|
|
|
|
|
|
const mb = bytes / (1024 * 1024);
|
|
|
|
|
|
return `${mb.toFixed(2)} MB`;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const toggleNotes = (clientId) => {
|
|
|
|
|
|
setExpandedNotes(prev => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
[clientId]: !prev[clientId]
|
|
|
|
|
|
}));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const filteredClients = clients.filter(client => {
|
|
|
|
|
|
if (filterPlatformType && client.platform_type !== filterPlatformType) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (searchQuery) {
|
|
|
|
|
|
const query = searchQuery.toLowerCase();
|
|
|
|
|
|
return (
|
|
|
|
|
|
client.version.toLowerCase().includes(query) ||
|
2025-12-18 11:57:56 +00:00
|
|
|
|
getPlatformLabel(client.platform_code).toLowerCase().includes(query) ||
|
2025-10-21 09:30:30 +00:00
|
|
|
|
(client.release_notes && client.release_notes.toLowerCase().includes(query))
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
return true;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const groupedClients = {
|
|
|
|
|
|
mobile: filteredClients.filter(c => c.platform_type === 'mobile'),
|
2025-12-11 08:47:46 +00:00
|
|
|
|
desktop: filteredClients.filter(c => c.platform_type === 'desktop'),
|
|
|
|
|
|
terminal: filteredClients.filter(c => c.platform_type === 'terminal')
|
2025-10-21 09:30:30 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (loading) {
|
|
|
|
|
|
return <div className="client-management-loading">加载中...</div>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="client-management-page">
|
|
|
|
|
|
<div className="client-management-header">
|
|
|
|
|
|
<h1>客户端下载管理</h1>
|
2025-11-12 07:29:05 +00:00
|
|
|
|
<button className="btn-create" onClick={() => handleOpenModal()}>
|
2025-10-21 09:30:30 +00:00
|
|
|
|
<Plus size={18} />
|
|
|
|
|
|
<span>新增客户端</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="client-filters">
|
|
|
|
|
|
<div className="search-box">
|
|
|
|
|
|
<Search size={18} />
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
placeholder="搜索版本号、平台或更新说明..."
|
|
|
|
|
|
value={searchQuery}
|
|
|
|
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
{searchQuery && (
|
|
|
|
|
|
<button className="clear-search" onClick={() => setSearchQuery('')}>
|
|
|
|
|
|
<X size={16} />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="platform-filters">
|
|
|
|
|
|
<button
|
|
|
|
|
|
className={`filter-btn ${filterPlatformType === '' ? 'active' : ''}`}
|
|
|
|
|
|
onClick={() => setFilterPlatformType('')}
|
|
|
|
|
|
>
|
|
|
|
|
|
全部
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className={`filter-btn ${filterPlatformType === 'mobile' ? 'active' : ''}`}
|
|
|
|
|
|
onClick={() => setFilterPlatformType('mobile')}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Smartphone size={16} />
|
|
|
|
|
|
<span>移动端</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className={`filter-btn ${filterPlatformType === 'desktop' ? 'active' : ''}`}
|
|
|
|
|
|
onClick={() => setFilterPlatformType('desktop')}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Monitor size={16} />
|
|
|
|
|
|
<span>桌面端</span>
|
|
|
|
|
|
</button>
|
2025-12-11 08:47:46 +00:00
|
|
|
|
<button
|
|
|
|
|
|
className={`filter-btn ${filterPlatformType === 'terminal' ? 'active' : ''}`}
|
|
|
|
|
|
onClick={() => setFilterPlatformType('terminal')}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Cpu size={16} />
|
|
|
|
|
|
<span>专用终端</span>
|
|
|
|
|
|
</button>
|
2025-10-21 09:30:30 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="clients-sections">
|
2025-12-11 08:47:46 +00:00
|
|
|
|
{['mobile', 'desktop', 'terminal'].map(type => {
|
2025-10-21 09:30:30 +00:00
|
|
|
|
const typeClients = groupedClients[type];
|
|
|
|
|
|
if (typeClients.length === 0 && filterPlatformType && filterPlatformType !== type) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-11 08:47:46 +00:00
|
|
|
|
const typeConfig = {
|
|
|
|
|
|
mobile: { icon: <Smartphone size={20} />, label: '移动端' },
|
|
|
|
|
|
desktop: { icon: <Monitor size={20} />, label: '桌面端' },
|
|
|
|
|
|
terminal: { icon: <Cpu size={20} />, label: '专用终端' }
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-10-21 09:30:30 +00:00
|
|
|
|
return (
|
|
|
|
|
|
<div key={type} className="client-section">
|
|
|
|
|
|
<h2 className="section-title">
|
2025-12-11 08:47:46 +00:00
|
|
|
|
{typeConfig[type].icon}
|
|
|
|
|
|
<span>{typeConfig[type].label}</span>
|
2025-10-21 09:30:30 +00:00
|
|
|
|
<span className="count">({typeClients.length})</span>
|
|
|
|
|
|
</h2>
|
|
|
|
|
|
|
|
|
|
|
|
{typeClients.length === 0 ? (
|
|
|
|
|
|
<div className="empty-state">暂无客户端</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="clients-grid">
|
|
|
|
|
|
{typeClients.map(client => (
|
|
|
|
|
|
<div key={client.id} className={`client-card ${!client.is_active ? 'inactive' : ''}`}>
|
|
|
|
|
|
<div className="card-header">
|
|
|
|
|
|
<div className="platform-info">
|
2025-12-18 11:57:56 +00:00
|
|
|
|
<h3>{getPlatformLabel(client.platform_code)}</h3>
|
|
|
|
|
|
{client.is_latest === true && <span className="badge-latest">最新</span>}
|
|
|
|
|
|
{client.is_active === false && <span className="badge-inactive">未启用</span>}
|
2025-10-21 09:30:30 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="card-actions">
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="btn-icon btn-edit"
|
|
|
|
|
|
onClick={() => openEditModal(client)}
|
|
|
|
|
|
title="编辑"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Edit size={16} />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="btn-icon btn-delete"
|
|
|
|
|
|
onClick={() => openDeleteModal(client)}
|
|
|
|
|
|
title="删除"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Trash2 size={16} />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="card-body">
|
|
|
|
|
|
<div className="info-row">
|
|
|
|
|
|
<span className="label">版本:</span>
|
|
|
|
|
|
<span className="value">{client.version}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="info-row">
|
|
|
|
|
|
<span className="label">版本代码:</span>
|
|
|
|
|
|
<span className="value">{client.version_code}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="info-row">
|
|
|
|
|
|
<span className="label">文件大小:</span>
|
|
|
|
|
|
<span className="value">{formatFileSize(client.file_size)}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{client.release_notes && (
|
|
|
|
|
|
<div className="release-notes">
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="notes-header"
|
|
|
|
|
|
onClick={() => toggleNotes(client.id)}
|
|
|
|
|
|
style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="label">更新说明</span>
|
|
|
|
|
|
{expandedNotes[client.id] ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{expandedNotes[client.id] && (
|
|
|
|
|
|
<p>{client.release_notes}</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<div className="download-link">
|
|
|
|
|
|
<a href={client.download_url} target="_blank" rel="noopener noreferrer">
|
|
|
|
|
|
<Download size={14} />
|
|
|
|
|
|
<span>查看下载链接</span>
|
|
|
|
|
|
</a>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-11-12 07:29:05 +00:00
|
|
|
|
{/* 客户端表单模态框 */}
|
|
|
|
|
|
<FormModal
|
|
|
|
|
|
isOpen={showClientModal}
|
|
|
|
|
|
onClose={handleCloseModal}
|
|
|
|
|
|
title={isEditing ? '编辑客户端' : '新增客户端'}
|
|
|
|
|
|
size="large"
|
|
|
|
|
|
actions={
|
|
|
|
|
|
<>
|
|
|
|
|
|
<button type="button" className="btn btn-secondary" onClick={handleCloseModal}>
|
|
|
|
|
|
取消
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button type="button" className="btn btn-primary" onClick={handleSave}>
|
|
|
|
|
|
{isEditing ? '保存' : '创建'}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</>
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
{formData && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div className="form-row">
|
2025-12-18 11:57:56 +00:00
|
|
|
|
<div className="form-group" style={{ flex: 1 }}>
|
|
|
|
|
|
<label><Monitor size={16} /> 选择平台 *</label>
|
2025-11-12 07:29:05 +00:00
|
|
|
|
<select
|
2025-12-18 11:57:56 +00:00
|
|
|
|
value={formData.platform_code}
|
|
|
|
|
|
onChange={(e) => handleInputChange('platform_code', e.target.value)}
|
2025-11-12 07:29:05 +00:00
|
|
|
|
disabled={isEditing}
|
|
|
|
|
|
>
|
2025-12-18 11:57:56 +00:00
|
|
|
|
<option value="">请选择平台</option>
|
|
|
|
|
|
{platforms.tree.map(parentNode => (
|
|
|
|
|
|
<optgroup key={parentNode.dict_code} label={parentNode.label_cn}>
|
|
|
|
|
|
{parentNode.children && parentNode.children.map(childNode => (
|
|
|
|
|
|
<option key={childNode.dict_code} value={childNode.dict_code}>
|
|
|
|
|
|
{childNode.label_cn}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</optgroup>
|
|
|
|
|
|
))}
|
2025-11-12 07:29:05 +00:00
|
|
|
|
</select>
|
2025-10-21 09:30:30 +00:00
|
|
|
|
</div>
|
2025-12-18 11:57:56 +00:00
|
|
|
|
</div>
|
2025-10-21 09:30:30 +00:00
|
|
|
|
|
2025-12-18 11:57:56 +00:00
|
|
|
|
{/* 文件上传区域 */}
|
|
|
|
|
|
<div className="form-group">
|
|
|
|
|
|
<label><Upload size={16} /> 上传安装包</label>
|
|
|
|
|
|
<div className="upload-area">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="file"
|
|
|
|
|
|
id="client-file-upload"
|
|
|
|
|
|
accept=".apk,.exe,.dmg,.deb,.rpm,.pkg,.msi,.zip,.tar.gz"
|
|
|
|
|
|
onChange={handleFileUpload}
|
|
|
|
|
|
disabled={uploadingFile || !formData.platform_code}
|
|
|
|
|
|
style={{ display: 'none' }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<label
|
|
|
|
|
|
htmlFor="client-file-upload"
|
|
|
|
|
|
className={`upload-label ${uploadingFile || !formData.platform_code ? 'disabled' : ''}`}
|
2025-11-12 07:29:05 +00:00
|
|
|
|
>
|
2025-12-18 11:57:56 +00:00
|
|
|
|
<Upload size={20} />
|
|
|
|
|
|
<span>{uploadingFile ? '上传中...' : '选择文件'}</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<p className="upload-hint">
|
|
|
|
|
|
{!formData.platform_code
|
|
|
|
|
|
? '请先选择平台'
|
|
|
|
|
|
: 'APK文件将自动读取版本信息,其他文件只读取文件大小'}
|
|
|
|
|
|
</p>
|
2025-11-12 07:29:05 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-10-21 09:30:30 +00:00
|
|
|
|
|
2025-11-12 07:29:05 +00:00
|
|
|
|
<div className="form-row">
|
|
|
|
|
|
<div className="form-group">
|
|
|
|
|
|
<label><Package size={16} /> 版本号 *</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
placeholder="例如: 1.0.0"
|
|
|
|
|
|
value={formData.version}
|
|
|
|
|
|
onChange={(e) => handleInputChange('version', e.target.value)}
|
|
|
|
|
|
/>
|
2025-10-21 09:30:30 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="form-group">
|
2025-11-12 07:29:05 +00:00
|
|
|
|
<label><Hash size={16} /> 版本代码 *</label>
|
2025-10-21 09:30:30 +00:00
|
|
|
|
<input
|
2025-11-12 07:29:05 +00:00
|
|
|
|
type="number"
|
|
|
|
|
|
placeholder="例如: 1000"
|
|
|
|
|
|
value={formData.version_code}
|
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
|
const value = e.target.value;
|
|
|
|
|
|
if (value === '' || /^\d+$/.test(value)) {
|
|
|
|
|
|
handleInputChange('version_code', value);
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
min="0"
|
|
|
|
|
|
step="1"
|
2025-10-21 09:30:30 +00:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2025-11-12 07:29:05 +00:00
|
|
|
|
</div>
|
2025-10-21 09:30:30 +00:00
|
|
|
|
|
2025-11-12 07:29:05 +00:00
|
|
|
|
<div className="form-group">
|
|
|
|
|
|
<label><Link size={16} /> 下载链接 *</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="url"
|
|
|
|
|
|
placeholder="https://..."
|
|
|
|
|
|
value={formData.download_url}
|
|
|
|
|
|
onChange={(e) => handleInputChange('download_url', e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2025-10-21 09:30:30 +00:00
|
|
|
|
|
2025-11-12 07:29:05 +00:00
|
|
|
|
<div className="form-row">
|
|
|
|
|
|
<div className="form-group">
|
|
|
|
|
|
<label><HardDrive size={16} /> 文件大小 (字节)</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
placeholder="例如: 52428800"
|
|
|
|
|
|
value={formData.file_size}
|
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
|
const value = e.target.value;
|
|
|
|
|
|
if (value === '' || /^\d+$/.test(value)) {
|
|
|
|
|
|
handleInputChange('file_size', value);
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
min="0"
|
|
|
|
|
|
step="1"
|
|
|
|
|
|
/>
|
2025-10-21 09:30:30 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="form-group">
|
2025-11-12 07:29:05 +00:00
|
|
|
|
<label><Monitor size={16} /> 最低系统版本</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
placeholder="例如: iOS 13.0"
|
|
|
|
|
|
value={formData.min_system_version}
|
|
|
|
|
|
onChange={(e) => handleInputChange('min_system_version', e.target.value)}
|
2025-10-21 09:30:30 +00:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2025-11-12 07:29:05 +00:00
|
|
|
|
</div>
|
2025-10-21 09:30:30 +00:00
|
|
|
|
|
2025-11-12 07:29:05 +00:00
|
|
|
|
<div className="form-group">
|
|
|
|
|
|
<label><FileText size={16} /> 更新说明</label>
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
rows={6}
|
|
|
|
|
|
placeholder="请输入更新说明..."
|
|
|
|
|
|
value={formData.release_notes}
|
|
|
|
|
|
onChange={(e) => handleInputChange('release_notes', e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2025-10-21 09:30:30 +00:00
|
|
|
|
|
2025-11-12 07:29:05 +00:00
|
|
|
|
<div className="form-row">
|
|
|
|
|
|
<div className="form-group checkbox-group">
|
|
|
|
|
|
<label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="checkbox"
|
|
|
|
|
|
checked={formData.is_active}
|
|
|
|
|
|
onChange={(e) => handleInputChange('is_active', e.target.checked)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span>启用</span>
|
|
|
|
|
|
</label>
|
2025-10-21 09:30:30 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-11-12 07:29:05 +00:00
|
|
|
|
<div className="form-group checkbox-group">
|
|
|
|
|
|
<label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="checkbox"
|
|
|
|
|
|
checked={formData.is_latest}
|
|
|
|
|
|
onChange={(e) => handleInputChange('is_latest', e.target.checked)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span>设为最新版本</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
2025-10-21 09:30:30 +00:00
|
|
|
|
</div>
|
2025-11-12 07:29:05 +00:00
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</FormModal>
|
2025-10-21 09:30:30 +00:00
|
|
|
|
|
2025-10-31 06:55:19 +00:00
|
|
|
|
{/* 删除确认对话框 */}
|
|
|
|
|
|
<ConfirmDialog
|
|
|
|
|
|
isOpen={!!deleteConfirmInfo}
|
|
|
|
|
|
onClose={() => setDeleteConfirmInfo(null)}
|
|
|
|
|
|
onConfirm={handleDelete}
|
|
|
|
|
|
title="删除客户端"
|
|
|
|
|
|
message={`确定要删除 ${deleteConfirmInfo?.platform_name} 版本 ${deleteConfirmInfo?.version} 吗?此操作无法撤销。`}
|
|
|
|
|
|
confirmText="确定删除"
|
|
|
|
|
|
cancelText="取消"
|
|
|
|
|
|
type="danger"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Toast notifications */}
|
|
|
|
|
|
{toasts.map(toast => (
|
|
|
|
|
|
<Toast
|
|
|
|
|
|
key={toast.id}
|
|
|
|
|
|
message={toast.message}
|
|
|
|
|
|
type={toast.type}
|
|
|
|
|
|
onClose={() => removeToast(toast.id)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
))}
|
2025-10-21 09:30:30 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export default ClientManagement;
|