imetting_frontend/src/pages/ClientManagement.jsx

637 lines
21 KiB
React
Raw Normal View History

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,
HardDrive
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-10-21 09:30:30 +00:00
const [formData, setFormData] = useState({
platform_type: 'mobile',
platform_name: 'ios',
version: '',
version_code: '',
download_url: '',
file_size: '',
release_notes: '',
is_active: true,
is_latest: false,
min_system_version: ''
});
const platformOptions = {
mobile: [
{ value: 'ios', label: 'iOS', icon: <Apple size={16} /> },
{ value: 'android', label: 'Android', icon: <Smartphone size={16} /> }
],
desktop: [
{ value: 'windows', label: 'Windows', icon: <Monitor size={16} /> },
{ value: 'mac_intel', label: 'Mac (Intel)', icon: <Apple size={16} /> },
{ value: 'mac_m', label: 'Mac (M系列)', icon: <Apple size={16} /> },
{ value: 'linux', label: 'Linux', icon: <Monitor size={16} /> }
]
};
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(() => {
fetchClients();
}, []);
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.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 = {
...formData,
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 {
// 验证必填字段
if (!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 = {
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({
platform_type: client.platform_type,
platform_name: client.platform_name,
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);
setFormData({
platform_type: 'mobile',
platform_name: 'ios',
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-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,
platform_name: getPlatformLabel(client.platform_name),
version: client.version
});
2025-10-21 09:30:30 +00:00
};
const resetForm = () => {
setFormData({
platform_type: 'mobile',
platform_name: 'ios',
version: '',
version_code: '',
download_url: '',
file_size: '',
release_notes: '',
is_active: true,
is_latest: false,
min_system_version: ''
});
setSelectedClient(null);
};
const getPlatformLabel = (platformName) => {
const allOptions = [...platformOptions.mobile, ...platformOptions.desktop];
const option = allOptions.find(opt => opt.value === platformName);
return option ? option.label : platformName;
};
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_name).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')
};
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>
</div>
</div>
<div className="clients-sections">
{['mobile', 'desktop'].map(type => {
const typeClients = groupedClients[type];
if (typeClients.length === 0 && filterPlatformType && filterPlatformType !== type) {
return null;
}
return (
<div key={type} className="client-section">
<h2 className="section-title">
{type === 'mobile' ? <Smartphone size={20} /> : <Monitor size={20} />}
<span>{type === 'mobile' ? '移动端' : '桌面端'}</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_name)}</h3>
{client.is_latest && <span className="badge-latest">最新</span>}
{!client.is_active && <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.min_system_version && (
<div className="info-row">
<span className="label">系统要求:</span>
<span className="value">{client.min_system_version}</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">
<div className="form-group">
<label><Monitor size={16} /> 平台类型 *</label>
<select
value={formData.platform_type}
onChange={(e) => {
const newType = e.target.value;
handleInputChange('platform_type', newType);
handleInputChange('platform_name', platformOptions[newType][0].value);
}}
disabled={isEditing}
>
<option value="mobile">移动端</option>
<option value="desktop">桌面端</option>
</select>
2025-10-21 09:30:30 +00:00
</div>
2025-11-12 07:29:05 +00:00
<div className="form-group">
<label><Smartphone size={16} /> 具体平台 *</label>
<select
value={formData.platform_name}
onChange={(e) => handleInputChange('platform_name', e.target.value)}
disabled={isEditing}
>
{platformOptions[formData.platform_type].map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</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;