472 lines
15 KiB
JavaScript
472 lines
15 KiB
JavaScript
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||
import {
|
||
Table,
|
||
Button,
|
||
Input,
|
||
Space,
|
||
Drawer,
|
||
Form,
|
||
Select,
|
||
App,
|
||
Tooltip,
|
||
Switch,
|
||
Badge,
|
||
Typography,
|
||
Tag,
|
||
Row,
|
||
Col,
|
||
} from 'antd';
|
||
import {
|
||
PlusOutlined,
|
||
SaveOutlined,
|
||
DeleteOutlined,
|
||
EditOutlined,
|
||
SearchOutlined,
|
||
SyncOutlined,
|
||
MonitorOutlined,
|
||
ReloadOutlined,
|
||
LinkOutlined,
|
||
WifiOutlined,
|
||
CheckCircleOutlined,
|
||
SafetyCertificateOutlined,
|
||
} 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 ONLINE_MINUTES = 10;
|
||
|
||
const TerminalManagement = () => {
|
||
const { message, modal } = App.useApp();
|
||
const [form] = Form.useForm();
|
||
const pageSize = useSystemPageSize(10);
|
||
|
||
const [terminals, setTerminals] = useState([]);
|
||
const [terminalTypes, setTerminalTypes] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
|
||
const [keyword, setKeyword] = useState('');
|
||
const [filterType, setFilterType] = useState('all');
|
||
const [filterStatus, setFilterStatus] = useState('all');
|
||
const [filterActivation, setFilterActivation] = useState('all');
|
||
|
||
const [showDrawer, setShowDrawer] = useState(false);
|
||
const [isEditing, setIsEditing] = useState(false);
|
||
const [selectedTerminal, setSelectedTerminal] = useState(null);
|
||
|
||
const fetchTerminalTypes = useCallback(async () => {
|
||
try {
|
||
const response = await httpService.get(buildApiUrl(API_ENDPOINTS.DICT_DATA.BY_TYPE('client_platform')), {
|
||
params: { parent_code: 'TERMINAL' },
|
||
});
|
||
if (response.code === '200') {
|
||
setTerminalTypes(response.data.items || []);
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to fetch terminal types:', error);
|
||
}
|
||
}, []);
|
||
|
||
const fetchTerminals = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const response = await httpService.get(buildApiUrl(API_ENDPOINTS.TERMINALS.LIST), {
|
||
params: { page: 1, size: 10000 },
|
||
});
|
||
if (response.code === '200') {
|
||
setTerminals(response.data.items || []);
|
||
}
|
||
} catch {
|
||
message.error('获取终端列表失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [message]);
|
||
|
||
useEffect(() => {
|
||
fetchTerminalTypes();
|
||
fetchTerminals();
|
||
}, [fetchTerminalTypes, fetchTerminals]);
|
||
|
||
const handleOpenModal = (terminal = null) => {
|
||
if (terminal) {
|
||
setIsEditing(true);
|
||
setSelectedTerminal(terminal);
|
||
form.setFieldsValue({
|
||
...terminal,
|
||
status: terminal.status === 1,
|
||
});
|
||
} else {
|
||
setIsEditing(false);
|
||
setSelectedTerminal(null);
|
||
form.resetFields();
|
||
form.setFieldsValue({
|
||
terminal_type: terminalTypes[0]?.dict_code,
|
||
status: true,
|
||
});
|
||
}
|
||
setShowDrawer(true);
|
||
};
|
||
|
||
const handleSave = async () => {
|
||
try {
|
||
const values = await form.validateFields();
|
||
const payload = {
|
||
...values,
|
||
status: values.status ? 1 : 0,
|
||
};
|
||
|
||
if (isEditing) {
|
||
await httpService.put(buildApiUrl(API_ENDPOINTS.TERMINALS.UPDATE(selectedTerminal.id)), payload);
|
||
message.success('终端更新成功');
|
||
} else {
|
||
await httpService.post(buildApiUrl(API_ENDPOINTS.TERMINALS.CREATE), payload);
|
||
message.success('终端创建成功');
|
||
}
|
||
|
||
setShowDrawer(false);
|
||
fetchTerminals();
|
||
} catch (error) {
|
||
if (!error.errorFields) {
|
||
message.error('保存失败');
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleDelete = (item) => {
|
||
modal.confirm({
|
||
title: '删除终端',
|
||
content: `确定要删除 IMEI 为 ${item.imei} 的终端吗?`,
|
||
okText: '确定',
|
||
okType: 'danger',
|
||
onOk: async () => {
|
||
try {
|
||
await httpService.delete(buildApiUrl(API_ENDPOINTS.TERMINALS.DELETE(item.id)));
|
||
message.success('删除成功');
|
||
fetchTerminals();
|
||
} catch {
|
||
message.error('删除失败');
|
||
}
|
||
},
|
||
});
|
||
};
|
||
|
||
const handleToggleStatus = async (item, checked) => {
|
||
try {
|
||
const newStatus = checked ? 1 : 0;
|
||
await httpService.post(buildApiUrl(API_ENDPOINTS.TERMINALS.STATUS(item.id)), null, {
|
||
params: { status: newStatus },
|
||
});
|
||
setTerminals((prev) => prev.map((terminal) => (
|
||
terminal.id === item.id ? { ...terminal, status: newStatus } : terminal
|
||
)));
|
||
message.success(`已${newStatus === 1 ? '启用' : '停用'}终端`);
|
||
} catch {
|
||
message.error('状态更新失败');
|
||
}
|
||
};
|
||
|
||
const getTerminalTypeLabel = (code) => (
|
||
terminalTypes.find((terminalType) => terminalType.dict_code === code)?.label_cn || code
|
||
);
|
||
|
||
const isOnline = (lastOnlineAt) => {
|
||
if (!lastOnlineAt) return false;
|
||
const diffMs = Date.now() - new Date(lastOnlineAt).getTime();
|
||
return diffMs >= 0 && diffMs <= ONLINE_MINUTES * 60 * 1000;
|
||
};
|
||
|
||
const filteredTerminals = useMemo(() => terminals.filter((terminal) => {
|
||
if (filterType !== 'all' && terminal.terminal_type !== filterType) {
|
||
return false;
|
||
}
|
||
|
||
if (filterStatus !== 'all' && terminal.status !== Number.parseInt(filterStatus, 10)) {
|
||
return false;
|
||
}
|
||
|
||
if (filterActivation === 'activated' && terminal.is_activated !== 1) {
|
||
return false;
|
||
}
|
||
|
||
if (filterActivation === 'pending' && terminal.is_activated === 1) {
|
||
return false;
|
||
}
|
||
|
||
if (!keyword) {
|
||
return true;
|
||
}
|
||
|
||
const query = keyword.toLowerCase();
|
||
return [
|
||
terminal.imei,
|
||
terminal.terminal_name,
|
||
terminal.current_username,
|
||
terminal.current_user_caption,
|
||
terminal.mac_address,
|
||
terminal.ip_address,
|
||
].some((field) => String(field || '').toLowerCase().includes(query));
|
||
}), [terminals, filterType, filterStatus, filterActivation, keyword]);
|
||
|
||
const activeCount = useMemo(() => terminals.filter((terminal) => terminal.status === 1).length, [terminals]);
|
||
const activatedCount = useMemo(() => terminals.filter((terminal) => terminal.is_activated === 1).length, [terminals]);
|
||
const onlineCount = useMemo(() => terminals.filter((terminal) => isOnline(terminal.last_online_at)).length, [terminals]);
|
||
|
||
const columns = [
|
||
{
|
||
title: 'IMEI',
|
||
dataIndex: 'imei',
|
||
key: 'imei',
|
||
width: 160,
|
||
render: (text) => <Text code>{text}</Text>,
|
||
},
|
||
{
|
||
title: '终端信息',
|
||
key: 'terminal',
|
||
width: 220,
|
||
render: (_, record) => (
|
||
<Space direction="vertical" size={0}>
|
||
<Text strong>{record.terminal_name || '未命名终端'}</Text>
|
||
<Space size={8}>
|
||
<Tag color="orange">{getTerminalTypeLabel(record.terminal_type)}</Tag>
|
||
{isOnline(record.last_online_at) ? <Tag color="green" icon={<WifiOutlined />}>在线</Tag> : <Tag>离线</Tag>}
|
||
</Space>
|
||
</Space>
|
||
),
|
||
},
|
||
{
|
||
title: '绑定账号',
|
||
key: 'user',
|
||
width: 170,
|
||
render: (_, record) => (
|
||
record.current_user_caption ? (
|
||
<Space direction="vertical" size={0}>
|
||
<Text>{record.current_user_caption}</Text>
|
||
<Text type="secondary" style={{ fontSize: 11 }}>({record.current_username})</Text>
|
||
</Space>
|
||
) : (
|
||
<Text type="secondary">未绑定</Text>
|
||
)
|
||
),
|
||
},
|
||
{
|
||
title: '网络与固件',
|
||
key: 'network',
|
||
width: 220,
|
||
render: (_, record) => (
|
||
<Space direction="vertical" size={0}>
|
||
<Text type="secondary">IP:{record.ip_address || '-'}</Text>
|
||
<Text type="secondary">MAC:{record.mac_address || '-'}</Text>
|
||
<Text type="secondary">固件:{record.firmware_version || '-'}</Text>
|
||
</Space>
|
||
),
|
||
},
|
||
{
|
||
title: '状态',
|
||
key: 'status',
|
||
width: 110,
|
||
render: (_, record) => (
|
||
<Switch
|
||
size="small"
|
||
checked={record.status === 1}
|
||
onChange={(checked) => handleToggleStatus(record, checked)}
|
||
/>
|
||
),
|
||
},
|
||
{
|
||
title: '激活情况',
|
||
dataIndex: 'is_activated',
|
||
key: 'is_activated',
|
||
width: 120,
|
||
render: (value) => (
|
||
value === 1
|
||
? <Badge status="success" text="已激活" />
|
||
: <Badge status="default" text="未激活" />
|
||
),
|
||
},
|
||
{
|
||
title: '最后在线',
|
||
dataIndex: 'last_online_at',
|
||
key: 'last_online_at',
|
||
width: 180,
|
||
render: (time) => (time ? new Date(time).toLocaleString() : '-'),
|
||
},
|
||
{
|
||
title: '操作',
|
||
key: 'action',
|
||
fixed: 'right',
|
||
width: 100,
|
||
render: (_, record) => (
|
||
<Space size={6}>
|
||
<ActionButton tone="edit" variant="iconSm" tooltip="编辑" icon={<EditOutlined />} onClick={() => handleOpenModal(record)} />
|
||
<ActionButton tone="delete" variant="iconSm" tooltip="删除" icon={<DeleteOutlined />} onClick={() => handleDelete(record)} />
|
||
</Space>
|
||
),
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div className="terminal-management">
|
||
<AdminModuleShell
|
||
icon={<MonitorOutlined style={{ fontSize: 18, color: '#1d4ed8' }} />}
|
||
title="终端管理"
|
||
subtitle="维护终端设备全生命周期:注册、绑定、激活、启停与在线状态监控。"
|
||
rightActions={(
|
||
<Space>
|
||
<Button icon={<ReloadOutlined />} className="btn-soft-blue" onClick={fetchTerminals} loading={loading}>刷新</Button>
|
||
<Button type="primary" icon={<PlusOutlined />} onClick={() => handleOpenModal()}>添加终端</Button>
|
||
</Space>
|
||
)}
|
||
stats={[
|
||
{
|
||
label: '设备总数',
|
||
value: terminals.length,
|
||
icon: <MonitorOutlined />,
|
||
tone: 'blue',
|
||
desc: '已纳入系统管理的终端设备总量',
|
||
},
|
||
{
|
||
label: '启用设备',
|
||
value: activeCount,
|
||
icon: <CheckCircleOutlined />,
|
||
tone: 'green',
|
||
desc: '允许接入平台并正常提供服务的设备',
|
||
},
|
||
{
|
||
label: '已激活设备',
|
||
value: activatedCount,
|
||
icon: <SafetyCertificateOutlined />,
|
||
tone: 'violet',
|
||
desc: '已完成激活或完成初始化绑定的设备',
|
||
},
|
||
{
|
||
label: '在线设备',
|
||
value: onlineCount,
|
||
icon: <WifiOutlined />,
|
||
tone: 'cyan',
|
||
desc: `最近 ${ONLINE_MINUTES} 分钟内有心跳上报的设备`,
|
||
},
|
||
]}
|
||
toolbar={(
|
||
<Space wrap>
|
||
<Input
|
||
placeholder="搜索 IMEI、名称、账号或网络信息..."
|
||
prefix={<SearchOutlined />}
|
||
value={keyword}
|
||
onChange={(e) => setKeyword(e.target.value)}
|
||
style={{ width: 280 }}
|
||
allowClear
|
||
/>
|
||
<Select value={filterType} onChange={setFilterType} style={{ width: 160 }}>
|
||
<Select.Option value="all">所有类型</Select.Option>
|
||
{terminalTypes.map((terminalType) => (
|
||
<Select.Option key={terminalType.dict_code} value={terminalType.dict_code}>
|
||
{terminalType.label_cn}
|
||
</Select.Option>
|
||
))}
|
||
</Select>
|
||
<Select value={filterStatus} onChange={setFilterStatus} style={{ width: 120 }}>
|
||
<Select.Option value="all">所有状态</Select.Option>
|
||
<Select.Option value="1">启用</Select.Option>
|
||
<Select.Option value="0">停用</Select.Option>
|
||
</Select>
|
||
<Select value={filterActivation} onChange={setFilterActivation} style={{ width: 130 }}>
|
||
<Select.Option value="all">全部激活状态</Select.Option>
|
||
<Select.Option value="activated">已激活</Select.Option>
|
||
<Select.Option value="pending">未激活</Select.Option>
|
||
</Select>
|
||
<Tooltip title={`最近 ${ONLINE_MINUTES} 分钟内有心跳即视为在线`}>
|
||
<Button icon={<SyncOutlined />} className="btn-soft-blue" onClick={fetchTerminals} loading={loading} />
|
||
</Tooltip>
|
||
</Space>
|
||
)}
|
||
>
|
||
<div className="console-table">
|
||
<Table
|
||
columns={columns}
|
||
dataSource={filteredTerminals}
|
||
loading={loading}
|
||
rowKey="id"
|
||
scroll={{ x: 1220 }}
|
||
pagination={{ pageSize, showTotal: (count) => `共 ${count} 条记录` }}
|
||
/>
|
||
</div>
|
||
</AdminModuleShell>
|
||
|
||
<Drawer
|
||
open={showDrawer}
|
||
title={isEditing ? '编辑终端' : '添加终端'}
|
||
placement="right"
|
||
width={720}
|
||
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="imei" label="IMEI 号" rules={[{ required: true, message: '请输入 IMEI 号' }]}>
|
||
<Input placeholder="设备唯一识别码" disabled={isEditing} />
|
||
</Form.Item>
|
||
</Col>
|
||
<Col span={12}>
|
||
<Form.Item name="terminal_name" label="终端名称">
|
||
<Input placeholder="例如:会议室终端 A" />
|
||
</Form.Item>
|
||
</Col>
|
||
</Row>
|
||
|
||
<Row gutter={16}>
|
||
<Col span={12}>
|
||
<Form.Item name="terminal_type" label="终端类型" rules={[{ required: true, message: '请选择终端类型' }]}>
|
||
<Select placeholder="选择终端硬件类型">
|
||
{terminalTypes.map((terminalType) => (
|
||
<Select.Option key={terminalType.dict_code} value={terminalType.dict_code}>
|
||
{terminalType.label_cn}
|
||
</Select.Option>
|
||
))}
|
||
</Select>
|
||
</Form.Item>
|
||
</Col>
|
||
<Col span={12}>
|
||
<Form.Item name="firmware_version" label="固件版本">
|
||
<Input placeholder="例如:v1.0.3" prefix={<LinkOutlined />} />
|
||
</Form.Item>
|
||
</Col>
|
||
</Row>
|
||
|
||
<Row gutter={16}>
|
||
<Col span={12}>
|
||
<Form.Item name="mac_address" label="MAC 地址">
|
||
<Input placeholder="例如:00:11:22:33:44:55" />
|
||
</Form.Item>
|
||
</Col>
|
||
<Col span={12}>
|
||
<Form.Item name="status" label="启用状态" valuePropName="checked">
|
||
<Switch />
|
||
</Form.Item>
|
||
</Col>
|
||
</Row>
|
||
|
||
<Form.Item name="description" label="备注说明">
|
||
<TextArea rows={3} placeholder="记录设备部署位置、资产编号或特殊说明" />
|
||
</Form.Item>
|
||
</Form>
|
||
</Drawer>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default TerminalManagement;
|