imetting/frontend/src/pages/ClientManagement.jsx

562 lines
18 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, { 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;