imetting/frontend/src/pages/admin/TerminalManagement.jsx

472 lines
15 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,
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;