imetting/frontend/src/pages/ClientManagement.jsx

557 lines
18 KiB
React
Raw Normal View History

2026-03-26 06:55:12 +00:00
import React, { useEffect, useMemo, useState } from 'react';
import {
2026-03-26 06:55:12 +00:00
Table,
Button,
Input,
Space,
2026-03-26 09:32:31 +00:00
Drawer,
2026-03-26 06:55:12 +00:00
Form,
Select,
App,
Tooltip,
Switch,
InputNumber,
Badge,
Typography,
Upload,
Tabs,
Tag,
Row,
Col,
} from 'antd';
import {
PlusOutlined,
DownloadOutlined,
DeleteOutlined,
EditOutlined,
SearchOutlined,
MobileOutlined,
DesktopOutlined,
PartitionOutlined,
UploadOutlined,
InfoCircleOutlined,
ReloadOutlined,
CloudUploadOutlined,
CheckCircleOutlined,
RocketOutlined,
2026-03-26 09:32:31 +00:00
SaveOutlined,
2026-03-26 06:55:12 +00:00
} from '@ant-design/icons';
import apiClient from '../utils/apiClient';
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
2026-03-26 06:55:12 +00:00
import AdminModuleShell from '../components/AdminModuleShell';
2026-04-03 16:25:53 +00:00
import ActionButton from '../components/ActionButton';
2026-03-26 06:55:12 +00:00
const { Text } = Typography;
const { TextArea } = Input;
const CLIENT_TABS = [
{ key: 'all', label: '全部' },
{ key: 'mobile', label: <Space><MobileOutlined />移动端</Space> },
{ key: 'desktop', label: <Space><DesktopOutlined />桌面端</Space> },
{ key: 'terminal', label: <Space><PartitionOutlined />专用终端</Space> },
];
const STATUS_OPTIONS = [
{ label: '全部状态', value: 'all' },
{ label: '已启用', value: 'active' },
{ label: '已停用', value: 'inactive' },
{ label: '最新版本', value: 'latest' },
];
const isTruthy = (value) => value === true || value === 1 || value === '1';
const ClientManagement = () => {
const { message, modal } = App.useApp();
const [form] = Form.useForm();
const [clients, setClients] = useState([]);
const [loading, setLoading] = useState(true);
2026-03-26 09:32:31 +00:00
const [showDrawer, setShowDrawer] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [selectedClient, setSelectedClient] = useState(null);
const [searchQuery, setSearchQuery] = useState('');
2026-03-26 06:55:12 +00:00
const [statusFilter, setStatusFilter] = useState('all');
const [activeTab, setActiveTab] = useState('all');
const [uploadingFile, setUploadingFile] = useState(false);
2026-03-26 06:55:12 +00:00
const [updatingStatusId, setUpdatingStatusId] = useState(null);
const [platforms, setPlatforms] = useState({ tree: [], items: [] });
const [platformsMap, setPlatformsMap] = useState({});
useEffect(() => {
fetchPlatforms();
fetchClients();
}, []);
const fetchPlatforms = async () => {
try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.DICT_DATA.BY_TYPE('client_platform')));
2026-03-26 06:55:12 +00:00
if (response.code === '200') {
const { tree = [], items = [] } = response.data || {};
setPlatforms({ tree, items });
const nextMap = {};
items.forEach((item) => {
nextMap[item.dict_code] = item;
});
setPlatformsMap(nextMap);
}
} catch {
message.error('获取平台列表失败');
}
};
const fetchClients = async () => {
setLoading(true);
try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.LIST));
2026-03-26 06:55:12 +00:00
if (response.code === '200') {
setClients(response.data.clients || []);
}
} catch {
message.error('获取客户端列表失败');
} finally {
setLoading(false);
}
};
2026-03-26 06:55:12 +00:00
const getPlatformLabel = (platformCode) => platformsMap[platformCode]?.label_cn || platformCode || '-';
2026-03-26 06:55:12 +00:00
const openModal = (client = null) => {
if (client) {
setIsEditing(true);
setSelectedClient(client);
form.setFieldsValue({
...client,
version_code: client.version_code,
file_size: client.file_size,
is_active: isTruthy(client.is_active),
is_latest: isTruthy(client.is_latest),
});
} else {
setIsEditing(false);
setSelectedClient(null);
form.resetFields();
form.setFieldsValue({
is_active: true,
is_latest: false,
});
}
2026-03-26 09:32:31 +00:00
setShowDrawer(true);
};
2026-03-26 06:55:12 +00:00
const handleSave = async () => {
try {
2026-03-26 06:55:12 +00:00
const values = await form.validateFields();
const platformInfo = platformsMap[values.platform_code];
const parentCode = platformInfo?.parent_code;
let platformType = 'desktop';
if (parentCode === 'MOBILE') {
platformType = 'mobile';
} else if (parentCode === 'TERMINAL') {
platformType = 'terminal';
}
const payload = {
2026-03-26 06:55:12 +00:00
...values,
platform_type: platformType,
platform_name: String(values.platform_code || '').toLowerCase(),
};
2026-03-26 06:55:12 +00:00
if (isEditing) {
await apiClient.put(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.UPDATE(selectedClient.id)), payload);
message.success('版本更新成功');
} else {
await apiClient.post(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.CREATE), payload);
message.success('版本创建成功');
}
2026-03-26 09:32:31 +00:00
setShowDrawer(false);
fetchClients();
} catch (error) {
2026-03-26 06:55:12 +00:00
if (!error?.errorFields) {
message.error('保存失败');
}
}
};
2026-03-26 06:55:12 +00:00
const handleDelete = (item) => {
modal.confirm({
title: '删除客户端版本',
content: `确定要删除 ${getPlatformLabel(item.platform_code)} ${item.version} 吗?`,
okText: '确定',
okType: 'danger',
onOk: async () => {
try {
await apiClient.delete(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.DELETE(item.id)));
message.success('删除成功');
fetchClients();
} catch {
message.error('删除失败');
}
},
});
};
2026-03-26 06:55:12 +00:00
const handleToggleActive = async (item, checked) => {
setUpdatingStatusId(item.id);
try {
await apiClient.put(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.UPDATE(item.id)), {
is_active: checked,
});
2026-03-26 06:55:12 +00:00
setClients((prev) => prev.map((client) => (
client.id === item.id ? { ...client, is_active: checked } : client
)));
message.success(`${checked ? '启用' : '停用'}版本`);
} catch {
message.error('状态更新失败');
} finally {
setUpdatingStatusId(null);
}
};
2026-03-26 06:55:12 +00:00
const handleFileUpload = async (options) => {
const { file, onSuccess, onError } = options;
const platformCode = form.getFieldValue('platform_code');
2026-03-26 06:55:12 +00:00
if (!platformCode) {
message.warning('请先选择发布平台');
onError(new Error('No platform selected'));
return;
}
setUploadingFile(true);
2026-03-26 06:55:12 +00:00
const uploadFormData = new FormData();
uploadFormData.append('file', file);
uploadFormData.append('platform_code', platformCode);
try {
const response = await apiClient.post(
buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.UPLOAD),
uploadFormData,
2026-03-26 06:55:12 +00:00
{ headers: { 'Content-Type': 'multipart/form-data' } },
);
2026-03-26 06:55:12 +00:00
if (response.code === '200') {
const {
file_size: fileSize,
download_url: downloadUrl,
version_code: versionCode,
version_name: versionName,
} = response.data;
form.setFieldsValue({
file_size: fileSize,
download_url: downloadUrl,
version_code: versionCode,
version: versionName,
});
message.success('安装包上传成功,已自动填充版本信息');
onSuccess(response.data);
}
} catch (error) {
2026-03-26 06:55:12 +00:00
message.error('文件上传失败');
onError(error);
} finally {
setUploadingFile(false);
}
};
const formatFileSize = (bytes) => {
if (!bytes) return '-';
2026-03-26 06:55:12 +00:00
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
};
2026-03-26 06:55:12 +00:00
const filteredClients = useMemo(() => clients.filter((client) => {
if (activeTab !== 'all' && client.platform_type !== activeTab) {
return false;
}
2026-03-26 06:55:12 +00:00
const enabled = isTruthy(client.is_active);
const latest = isTruthy(client.is_latest);
if (statusFilter === 'active' && !enabled) {
return false;
}
2026-03-26 06:55:12 +00:00
if (statusFilter === 'inactive' && enabled) {
return false;
}
if (statusFilter === 'latest' && !latest) {
return false;
}
2026-03-26 06:55:12 +00:00
if (!searchQuery) {
return true;
}
2026-03-26 06:55:12 +00:00
const query = searchQuery.toLowerCase();
return [
client.version,
client.platform_code,
getPlatformLabel(client.platform_code),
client.min_system_version,
client.download_url,
client.release_notes,
].some((field) => String(field || '').toLowerCase().includes(query));
}), [activeTab, clients, searchQuery, statusFilter, platformsMap]);
const publishedCount = clients.length;
const activeCount = clients.filter((item) => isTruthy(item.is_active)).length;
const latestCount = clients.filter((item) => isTruthy(item.is_latest)).length;
const terminalCount = clients.filter((item) => item.platform_type === 'terminal').length;
const columns = [
{
title: '平台',
dataIndex: 'platform_code',
key: 'platform_code',
width: 140,
render: (code) => <Tag color="blue">{getPlatformLabel(code)}</Tag>,
},
{
title: '版本信息',
key: 'version',
width: 200,
render: (_, record) => (
<Space direction="vertical" size={0}>
<Text strong>{record.version}</Text>
<Text type="secondary">版本码{record.version_code}</Text>
</Space>
),
},
{
title: '安装包信息',
key: 'package',
width: 220,
render: (_, record) => (
<Space direction="vertical" size={0}>
<Text type="secondary">大小{formatFileSize(record.file_size)}</Text>
<Text type="secondary" ellipsis style={{ maxWidth: 200 }}>
系统要求{record.min_system_version || '-'}
</Text>
</Space>
),
},
{
title: '发布状态',
key: 'status',
2026-03-26 09:32:31 +00:00
width: 140,
2026-03-26 06:55:12 +00:00
render: (_, record) => (
<Space direction="vertical" size={4}>
<Switch
2026-03-26 09:32:31 +00:00
size="small"
2026-03-26 06:55:12 +00:00
checked={isTruthy(record.is_active)}
loading={updatingStatusId === record.id}
onChange={(checked) => handleToggleActive(record, checked)}
/>
2026-03-26 09:32:31 +00:00
{isTruthy(record.is_latest) ? <Badge status="success" text="最新版本" /> : <Badge status="default" text="历史版本" />}
2026-03-26 06:55:12 +00:00
</Space>
),
},
{
title: '更新时间',
dataIndex: 'updated_at',
key: 'updated_at',
width: 180,
render: (value) => (value ? new Date(value).toLocaleString() : '-'),
},
{
title: '操作',
key: 'action',
fixed: 'right',
width: 140,
render: (_, record) => (
2026-04-03 16:25:53 +00:00
<Space size={6}>
<ActionButton tone="edit" variant="iconSm" tooltip="编辑" icon={<EditOutlined />} onClick={() => openModal(record)} />
<ActionButton tone="view" variant="iconSm" tooltip="下载" icon={<DownloadOutlined />} href={record.download_url} target="_blank" />
<ActionButton tone="delete" variant="iconSm" tooltip="删除" icon={<DeleteOutlined />} onClick={() => handleDelete(record)} />
2026-03-26 06:55:12 +00:00
</Space>
),
},
];
return (
2026-03-26 06:55:12 +00:00
<div className="client-management">
<AdminModuleShell
icon={<DesktopOutlined style={{ fontSize: 18, color: '#1d4ed8' }} />}
title="客户端管理"
subtitle="统一维护移动端、桌面端与专用终端的版本发布、安装包上传、启停控制与最新版本标记。"
rightActions={(
<Space>
2026-03-26 09:32:31 +00:00
<Button icon={<ReloadOutlined />} className="btn-soft-blue" onClick={fetchClients} loading={loading}>刷新</Button>
2026-03-26 06:55:12 +00:00
<Button type="primary" icon={<PlusOutlined />} onClick={() => openModal()}>新增发布</Button>
</Space>
)}
stats={[
{
label: '发布总数',
value: publishedCount,
icon: <CloudUploadOutlined />,
tone: 'blue',
desc: '当前系统登记的全部客户端版本',
},
{
label: '启用版本',
value: activeCount,
icon: <CheckCircleOutlined />,
tone: 'green',
desc: '对外可见、允许分发的版本数量',
},
{
label: '最新版本',
value: latestCount,
icon: <RocketOutlined />,
tone: 'violet',
desc: '被标记为最新的各平台主推版本',
},
{
label: '终端发布',
value: terminalCount,
icon: <PartitionOutlined />,
tone: 'amber',
desc: '面向专用终端设备的独立发布包数量',
},
]}
toolbar={(
<Space wrap>
<Input
placeholder="搜索平台、版本、系统要求或下载地址..."
prefix={<SearchOutlined />}
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
style={{ width: 300 }}
allowClear
/>
<Select
value={statusFilter}
onChange={setStatusFilter}
options={STATUS_OPTIONS}
style={{ width: 150 }}
/>
</Space>
)}
>
<Tabs
className="console-tabs"
activeKey={activeTab}
onChange={setActiveTab}
items={CLIENT_TABS.map((tab) => ({ ...tab, children: null }))}
/>
2026-03-26 06:55:12 +00:00
<div className="console-table">
<Table
columns={columns}
dataSource={filteredClients}
loading={loading}
rowKey="id"
size="small"
scroll={{ x: 1040 }}
pagination={{ pageSize: 10, showTotal: (total) => `${total} 条记录` }}
/>
</div>
2026-03-26 06:55:12 +00:00
</AdminModuleShell>
2026-03-26 09:32:31 +00:00
<Drawer
open={showDrawer}
2026-03-26 06:55:12 +00:00
title={isEditing ? '编辑客户端版本' : '发布新版本'}
2026-03-26 09:32:31 +00:00
placement="right"
width={760}
onClose={() => setShowDrawer(false)}
destroyOnClose
extra={(
<Space>
<Button type="primary" icon={<SaveOutlined />} onClick={handleSave}>
{isEditing ? '保存修改' : '发布版本'}
</Button>
</Space>
)}
>
2026-03-26 06:55:12 +00:00
<Form form={form} layout="vertical" style={{ marginTop: 20 }}>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="platform_code" label="发布平台" rules={[{ required: true, message: '请选择发布平台' }]}>
<Select placeholder="选择平台" disabled={isEditing}>
{platforms.tree.map((parentNode) => (
<Select.OptGroup key={parentNode.dict_code} label={parentNode.label_cn}>
{parentNode.children?.map((childNode) => (
<Select.Option key={childNode.dict_code} value={childNode.dict_code}>
{childNode.label_cn}
2026-03-26 06:55:12 +00:00
</Select.Option>
))}
2026-03-26 06:55:12 +00:00
</Select.OptGroup>
))}
2026-03-26 06:55:12 +00:00
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="安装包上传">
<Upload customRequest={handleFileUpload} showUploadList={false} disabled={uploadingFile}>
<Button icon={<UploadOutlined />} loading={uploadingFile} style={{ width: '100%' }}>
{uploadingFile ? '上传中...' : '点击上传安装包'}
</Button>
</Upload>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="version" label="版本号" rules={[{ required: true, message: '请输入版本号' }]}>
<Input placeholder="例如 1.0.0" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="version_code" label="版本代码" rules={[{ required: true, message: '请输入版本代码' }]}>
<InputNumber style={{ width: '100%' }} min={0} placeholder="例如 100" />
</Form.Item>
</Col>
</Row>
<Form.Item name="download_url" label="下载链接" rules={[{ required: true, message: '请输入下载链接' }]}>
<Input placeholder="https://..." />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="file_size" label="文件大小Bytes">
<InputNumber style={{ width: '100%' }} min={0} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="min_system_version" label="最低系统要求">
<Input placeholder="例如 Android 8.0 / macOS 11" />
</Form.Item>
</Col>
</Row>
<Form.Item name="release_notes" label="更新说明">
<TextArea rows={4} placeholder="请输入本次版本更新内容..." />
</Form.Item>
<Space size="large">
<Form.Item name="is_active" label="立即启用" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="is_latest" label="设为最新版本" valuePropName="checked">
<Switch />
</Form.Item>
</Space>
<div style={{ backgroundColor: '#fffbe6', padding: '8px 12px', borderRadius: 8, marginTop: 8 }}>
<Space align="start">
<InfoCircleOutlined style={{ color: '#faad14', marginTop: 2 }} />
<Text type="secondary">
上传 APK 会自动提取版本信息设为最新版本同平台其他版本会自动取消最新标记
</Text>
</Space>
</div>
</Form>
2026-03-26 09:32:31 +00:00
</Drawer>
</div>
);
};
export default ClientManagement;