imetting_frontend/src/pages/ClientManagement.jsx

744 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import React, { useState, useEffect } from 'react';
import {
Download,
Plus,
Edit,
Trash2,
Smartphone,
Monitor,
Apple,
Search,
X,
ChevronDown,
ChevronUp,
Package,
Hash,
Link,
FileText,
HardDrive,
Cpu,
Upload
} from 'lucide-react';
import apiClient from '../utils/apiClient';
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
import ConfirmDialog from '../components/ConfirmDialog';
import FormModal from '../components/FormModal';
import Toast from '../components/Toast';
import './ClientManagement.css';
const ClientManagement = ({ user }) => {
const [clients, setClients] = useState([]);
const [loading, setLoading] = useState(true);
const [showClientModal, setShowClientModal] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [deleteConfirmInfo, setDeleteConfirmInfo] = useState(null);
const [selectedClient, setSelectedClient] = useState(null);
const [filterPlatformType, setFilterPlatformType] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [expandedNotes, setExpandedNotes] = useState({});
const [toasts, setToasts] = useState([]);
const [uploadingFile, setUploadingFile] = useState(false);
// 码表数据
const [platforms, setPlatforms] = useState({ tree: [], items: [] });
const [platformsMap, setPlatformsMap] = useState({});
const [formData, setFormData] = useState({
platform_code: '',
version: '',
version_code: '',
download_url: '',
file_size: '',
release_notes: '',
is_active: true,
is_latest: false,
min_system_version: ''
});
// 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));
};
useEffect(() => {
fetchPlatforms();
fetchClients();
}, []);
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');
}
};
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 {
// 验证必填字段
if (!formData.platform_code || !formData.version_code || !formData.version || !formData.download_url) {
showToast('请填写所有必填字段', 'warning');
return;
}
// 根据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();
const payload = {
...formData,
platform_type,
platform_name,
version_code: parseInt(formData.version_code, 10),
file_size: formData.file_size ? parseInt(formData.file_size, 10) : null
};
// 验证转换后的数字
if (isNaN(payload.version_code)) {
showToast('版本代码必须是有效的数字', 'warning');
return;
}
if (formData.file_size && isNaN(payload.file_size)) {
showToast('文件大小必须是有效的数字', 'warning');
return;
}
await apiClient.post(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.CREATE), payload);
handleCloseModal();
fetchClients();
showToast('客户端创建成功', 'success');
} catch (error) {
console.error('创建客户端失败:', error);
showToast(error.response?.data?.message || '创建失败,请重试', 'error');
}
};
const handleUpdate = async () => {
try {
// 验证必填字段
if (!formData.platform_code || !formData.version_code || !formData.version || !formData.download_url) {
showToast('请填写所有必填字段', 'warning');
return;
}
const payload = {
platform_code: formData.platform_code,
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)) {
showToast('版本代码必须是有效的数字', 'warning');
return;
}
if (formData.file_size && isNaN(payload.file_size)) {
showToast('文件大小必须是有效的数字', 'warning');
return;
}
await apiClient.put(
buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.UPDATE(selectedClient.id)),
payload
);
handleCloseModal();
fetchClients();
showToast('客户端更新成功', 'success');
} catch (error) {
console.error('更新客户端失败:', error);
showToast(error.response?.data?.message || '更新失败,请重试', 'error');
}
};
const handleDelete = async () => {
try {
await apiClient.delete(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.DELETE(deleteConfirmInfo.id)));
setDeleteConfirmInfo(null);
setSelectedClient(null);
showToast('删除成功', 'success');
fetchClients();
} catch (error) {
console.error('删除客户端失败:', error);
showToast('删除失败,请重试', 'error');
setDeleteConfirmInfo(null);
}
};
const handleOpenModal = (client = null) => {
if (client) {
setIsEditing(true);
setSelectedClient(client);
setFormData({
platform_code: client.platform_code || '',
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);
// 默认选择第一个可用的平台
const defaultPlatformCode = platforms.items.length > 0 && platforms.items[0].parent_code !== 'ROOT'
? platforms.items[0].dict_code
: '';
setFormData({
platform_code: defaultPlatformCode,
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 }));
};
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 = '';
}
};
const openEditModal = (client) => {
handleOpenModal(client);
};
const openDeleteModal = (client) => {
setDeleteConfirmInfo({
id: client.id,
platform_name: getPlatformLabel(client.platform_code),
version: client.version
});
};
const resetForm = () => {
const defaultPlatformCode = platforms.items.length > 0 && platforms.items[0].parent_code !== 'ROOT'
? platforms.items[0].dict_code
: '';
setFormData({
platform_code: defaultPlatformCode,
version: '',
version_code: '',
download_url: '',
file_size: '',
release_notes: '',
is_active: true,
is_latest: false,
min_system_version: ''
});
setSelectedClient(null);
};
const getPlatformLabel = (platformCode) => {
const platform = platformsMap[platformCode];
return platform ? platform.label_cn : platformCode;
};
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) ||
getPlatformLabel(client.platform_code).toLowerCase().includes(query) ||
(client.release_notes && client.release_notes.toLowerCase().includes(query))
);
}
return true;
});
const groupedClients = {
mobile: filteredClients.filter(c => c.platform_type === 'mobile'),
desktop: filteredClients.filter(c => c.platform_type === 'desktop'),
terminal: filteredClients.filter(c => c.platform_type === 'terminal')
};
if (loading) {
return <div className="client-management-loading">加载中...</div>;
}
return (
<div className="client-management-page">
<div className="client-management-header">
<h1>客户端下载管理</h1>
<button className="btn-create" onClick={() => handleOpenModal()}>
<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>
<button
className={`filter-btn ${filterPlatformType === 'terminal' ? 'active' : ''}`}
onClick={() => setFilterPlatformType('terminal')}
>
<Cpu size={16} />
<span>专用终端</span>
</button>
</div>
</div>
<div className="clients-sections">
{['mobile', 'desktop', 'terminal'].map(type => {
const typeClients = groupedClients[type];
if (typeClients.length === 0 && filterPlatformType && filterPlatformType !== type) {
return null;
}
const typeConfig = {
mobile: { icon: <Smartphone size={20} />, label: '移动端' },
desktop: { icon: <Monitor size={20} />, label: '桌面端' },
terminal: { icon: <Cpu size={20} />, label: '专用终端' }
};
return (
<div key={type} className="client-section">
<h2 className="section-title">
{typeConfig[type].icon}
<span>{typeConfig[type].label}</span>
<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">
<h3>{getPlatformLabel(client.platform_code)}</h3>
{client.is_latest === true && <span className="badge-latest">最新</span>}
{client.is_active === false && <span className="badge-inactive">未启用</span>}
</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>
{/* 客户端表单模态框 */}
<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">
<div className="form-group" style={{ flex: 1 }}>
<label><Monitor size={16} /> 选择平台 *</label>
<select
value={formData.platform_code}
onChange={(e) => handleInputChange('platform_code', e.target.value)}
disabled={isEditing}
>
<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>
))}
</select>
</div>
</div>
{/* 文件上传区域 */}
<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' : ''}`}
>
<Upload size={20} />
<span>{uploadingFile ? '上传中...' : '选择文件'}</span>
</label>
<p className="upload-hint">
{!formData.platform_code
? '请先选择平台'
: 'APK文件将自动读取版本信息其他文件只读取文件大小'}
</p>
</div>
</div>
<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)}
/>
</div>
<div className="form-group">
<label><Hash size={16} /> 版本代码 *</label>
<input
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"
/>
</div>
</div>
<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>
<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"
/>
</div>
<div className="form-group">
<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)}
/>
</div>
</div>
<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>
<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>
</div>
<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>
</div>
</>
)}
</FormModal>
{/* 删除确认对话框 */}
<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)}
/>
))}
</div>
);
};
export default ClientManagement;