562 lines
18 KiB
JavaScript
562 lines
18 KiB
JavaScript
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||
import {
|
||
Table,
|
||
Button,
|
||
Input,
|
||
Space,
|
||
Drawer,
|
||
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,
|
||
SaveOutlined,
|
||
} from '@ant-design/icons';
|
||
import httpService from '../services/httpService';
|
||
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
|
||
import AdminModuleShell from '../components/AdminModuleShell';
|
||
import ActionButton from '../components/ActionButton';
|
||
import useSystemPageSize from '../hooks/useSystemPageSize';
|
||
|
||
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);
|
||
const [showDrawer, setShowDrawer] = useState(false);
|
||
const [isEditing, setIsEditing] = useState(false);
|
||
const [selectedClient, setSelectedClient] = useState(null);
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [statusFilter, setStatusFilter] = useState('all');
|
||
const [activeTab, setActiveTab] = useState('all');
|
||
const [uploadingFile, setUploadingFile] = useState(false);
|
||
const [updatingStatusId, setUpdatingStatusId] = useState(null);
|
||
const pageSize = useSystemPageSize(10);
|
||
|
||
const [platforms, setPlatforms] = useState({ tree: [], items: [] });
|
||
const [platformsMap, setPlatformsMap] = useState({});
|
||
|
||
const fetchPlatforms = useCallback(async () => {
|
||
try {
|
||
const response = await httpService.get(buildApiUrl(API_ENDPOINTS.DICT_DATA.BY_TYPE('client_platform')));
|
||
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('获取平台列表失败');
|
||
}
|
||
}, [message]);
|
||
|
||
const fetchClients = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const response = await httpService.get(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.LIST));
|
||
if (response.code === '200') {
|
||
setClients(response.data.clients || []);
|
||
}
|
||
} catch {
|
||
message.error('获取客户端列表失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [message]);
|
||
|
||
const getPlatformLabel = useCallback(
|
||
(platformCode) => platformsMap[platformCode]?.label_cn || platformCode || '-',
|
||
[platformsMap],
|
||
);
|
||
|
||
useEffect(() => {
|
||
fetchPlatforms();
|
||
fetchClients();
|
||
}, [fetchClients, fetchPlatforms]);
|
||
|
||
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,
|
||
});
|
||
}
|
||
setShowDrawer(true);
|
||
};
|
||
|
||
const handleSave = async () => {
|
||
try {
|
||
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 = {
|
||
...values,
|
||
platform_type: platformType,
|
||
platform_name: String(values.platform_code || '').toLowerCase(),
|
||
};
|
||
|
||
if (isEditing) {
|
||
await httpService.put(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.UPDATE(selectedClient.id)), payload);
|
||
message.success('版本更新成功');
|
||
} else {
|
||
await httpService.post(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.CREATE), payload);
|
||
message.success('版本创建成功');
|
||
}
|
||
|
||
setShowDrawer(false);
|
||
fetchClients();
|
||
} catch (error) {
|
||
if (!error?.errorFields) {
|
||
message.error('保存失败');
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleDelete = (item) => {
|
||
modal.confirm({
|
||
title: '删除客户端版本',
|
||
content: `确定要删除 ${getPlatformLabel(item.platform_code)} ${item.version} 吗?`,
|
||
okText: '确定',
|
||
okType: 'danger',
|
||
onOk: async () => {
|
||
try {
|
||
await httpService.delete(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.DELETE(item.id)));
|
||
message.success('删除成功');
|
||
fetchClients();
|
||
} catch {
|
||
message.error('删除失败');
|
||
}
|
||
},
|
||
});
|
||
};
|
||
|
||
const handleToggleActive = async (item, checked) => {
|
||
setUpdatingStatusId(item.id);
|
||
try {
|
||
await httpService.put(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.UPDATE(item.id)), {
|
||
is_active: checked,
|
||
});
|
||
setClients((prev) => prev.map((client) => (
|
||
client.id === item.id ? { ...client, is_active: checked } : client
|
||
)));
|
||
message.success(`已${checked ? '启用' : '停用'}版本`);
|
||
} catch {
|
||
message.error('状态更新失败');
|
||
} finally {
|
||
setUpdatingStatusId(null);
|
||
}
|
||
};
|
||
|
||
const handleFileUpload = async (options) => {
|
||
const { file, onSuccess, onError } = options;
|
||
const platformCode = form.getFieldValue('platform_code');
|
||
|
||
if (!platformCode) {
|
||
message.warning('请先选择发布平台');
|
||
onError(new Error('No platform selected'));
|
||
return;
|
||
}
|
||
|
||
setUploadingFile(true);
|
||
const uploadFormData = new FormData();
|
||
uploadFormData.append('file', file);
|
||
uploadFormData.append('platform_code', platformCode);
|
||
|
||
try {
|
||
const response = await httpService.post(
|
||
buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.UPLOAD),
|
||
uploadFormData,
|
||
{ headers: { 'Content-Type': 'multipart/form-data' } },
|
||
);
|
||
|
||
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) {
|
||
message.error('文件上传失败');
|
||
onError(error);
|
||
} finally {
|
||
setUploadingFile(false);
|
||
}
|
||
};
|
||
|
||
const formatFileSize = (bytes) => {
|
||
if (!bytes) return '-';
|
||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||
};
|
||
|
||
const filteredClients = useMemo(() => clients.filter((client) => {
|
||
if (activeTab !== 'all' && client.platform_type !== activeTab) {
|
||
return false;
|
||
}
|
||
|
||
const enabled = isTruthy(client.is_active);
|
||
const latest = isTruthy(client.is_latest);
|
||
|
||
if (statusFilter === 'active' && !enabled) {
|
||
return false;
|
||
}
|
||
if (statusFilter === 'inactive' && enabled) {
|
||
return false;
|
||
}
|
||
if (statusFilter === 'latest' && !latest) {
|
||
return false;
|
||
}
|
||
|
||
if (!searchQuery) {
|
||
return true;
|
||
}
|
||
|
||
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, getPlatformLabel, searchQuery, statusFilter]);
|
||
|
||
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',
|
||
width: 140,
|
||
render: (_, record) => (
|
||
<Space direction="vertical" size={4}>
|
||
<Switch
|
||
size="small"
|
||
checked={isTruthy(record.is_active)}
|
||
loading={updatingStatusId === record.id}
|
||
onChange={(checked) => handleToggleActive(record, checked)}
|
||
/>
|
||
{isTruthy(record.is_latest) ? <Badge status="success" text="最新版本" /> : <Badge status="default" text="历史版本" />}
|
||
</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) => (
|
||
<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)} />
|
||
</Space>
|
||
),
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div className="client-management">
|
||
<AdminModuleShell
|
||
icon={<DesktopOutlined style={{ fontSize: 18, color: '#1d4ed8' }} />}
|
||
title="客户端管理"
|
||
subtitle="统一维护移动端、桌面端与专用终端的版本发布、安装包上传、启停控制与最新版本标记。"
|
||
rightActions={(
|
||
<Space>
|
||
<Button icon={<ReloadOutlined />} className="btn-soft-blue" onClick={fetchClients} loading={loading}>刷新</Button>
|
||
<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 }))}
|
||
/>
|
||
|
||
<div className="console-table">
|
||
<Table
|
||
columns={columns}
|
||
dataSource={filteredClients}
|
||
loading={loading}
|
||
rowKey="id"
|
||
size="small"
|
||
scroll={{ x: 1040 }}
|
||
pagination={{ pageSize, showTotal: (total) => `共 ${total} 条记录` }}
|
||
/>
|
||
</div>
|
||
</AdminModuleShell>
|
||
|
||
<Drawer
|
||
open={showDrawer}
|
||
title={isEditing ? '编辑客户端版本' : '发布新版本'}
|
||
placement="right"
|
||
width={760}
|
||
onClose={() => setShowDrawer(false)}
|
||
destroyOnClose
|
||
extra={(
|
||
<Space>
|
||
<Button type="primary" icon={<SaveOutlined />} onClick={handleSave}>
|
||
{isEditing ? '保存修改' : '发布版本'}
|
||
</Button>
|
||
</Space>
|
||
)}
|
||
>
|
||
<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}
|
||
</Select.Option>
|
||
))}
|
||
</Select.OptGroup>
|
||
))}
|
||
</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>
|
||
</Drawer>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default ClientManagement;
|