255 lines
7.8 KiB
TypeScript
255 lines
7.8 KiB
TypeScript
import { useState, useCallback, useEffect } from 'react';
|
||
import {
|
||
Button, Descriptions, Form, Input, message, Modal, Popconfirm, Select, Space, Table, Tag, Tabs,
|
||
} from 'antd';
|
||
import type { ColumnsType } from 'antd/es/table';
|
||
import dayjs from 'dayjs';
|
||
import { useParams, useNavigate } from 'react-router-dom';
|
||
|
||
import {
|
||
bleGatewayApi,
|
||
type BleGateway,
|
||
type GatewayBinding,
|
||
type CreateBindingReq,
|
||
GATEWAY_STATUS_LABEL,
|
||
GATEWAY_STATUS_COLOR,
|
||
BINDING_STATUS_COLOR,
|
||
} from '../../api/health/bleGateways';
|
||
import { PageContainer } from '../../components/PageContainer';
|
||
|
||
export default function BleGatewayDetail() {
|
||
const { id } = useParams<{ id: string }>();
|
||
const navigate = useNavigate();
|
||
|
||
const [gateway, setGateway] = useState<BleGateway | null>(null);
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
// Bindings state
|
||
const [bindings, setBindings] = useState<GatewayBinding[]>([]);
|
||
const [bindingsTotal, setBindingsTotal] = useState(0);
|
||
const [bindingsPage, setBindingsPage] = useState(1);
|
||
const [bindingsLoading, setBindingsLoading] = useState(false);
|
||
|
||
// Binding modal
|
||
const [bindModalOpen, setBindModalOpen] = useState(false);
|
||
const [bindSubmitting, setBindSubmitting] = useState(false);
|
||
const [bindForm] = Form.useForm();
|
||
|
||
const pageSize = 20;
|
||
|
||
const fetchGateway = useCallback(async () => {
|
||
if (!id) return;
|
||
setLoading(true);
|
||
try {
|
||
const resp = await bleGatewayApi.get(id);
|
||
setGateway(resp);
|
||
} catch {
|
||
message.error('加载网关详情失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [id]);
|
||
|
||
const fetchBindings = useCallback(async (p: number) => {
|
||
if (!id) return;
|
||
setBindingsLoading(true);
|
||
try {
|
||
const resp = await bleGatewayApi.listBindings(id, { page: p, page_size: pageSize });
|
||
setBindings(resp.data);
|
||
setBindingsTotal(resp.total);
|
||
setBindingsPage(p);
|
||
} catch {
|
||
message.error('加载绑定列表失败');
|
||
} finally {
|
||
setBindingsLoading(false);
|
||
}
|
||
}, [id]);
|
||
|
||
useEffect(() => {
|
||
fetchGateway();
|
||
fetchBindings(1);
|
||
}, [fetchGateway, fetchBindings]);
|
||
|
||
const handleBindPatient = () => {
|
||
bindForm.resetFields();
|
||
setBindModalOpen(true);
|
||
};
|
||
|
||
const handleSubmitBind = async () => {
|
||
try {
|
||
const values = await bindForm.validateFields();
|
||
setBindSubmitting(true);
|
||
const req: CreateBindingReq = values;
|
||
await bleGatewayApi.bindPatient(id!, req);
|
||
message.success('患者绑定成功');
|
||
setBindModalOpen(false);
|
||
fetchBindings(bindingsPage);
|
||
fetchGateway();
|
||
} catch {
|
||
// validation
|
||
} finally {
|
||
setBindSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const handleUnbind = async (binding: GatewayBinding) => {
|
||
try {
|
||
await bleGatewayApi.unbindPatient(id!, binding.id, binding.version);
|
||
message.success('已解除绑定');
|
||
fetchBindings(bindingsPage);
|
||
fetchGateway();
|
||
} catch {
|
||
message.error('解除绑定失败');
|
||
}
|
||
};
|
||
|
||
const bindingColumns: ColumnsType<GatewayBinding> = [
|
||
{
|
||
title: '患者 ID',
|
||
dataIndex: 'patient_id',
|
||
width: 200,
|
||
ellipsis: true,
|
||
},
|
||
{
|
||
title: '外设 MAC',
|
||
dataIndex: 'peripheral_mac',
|
||
width: 160,
|
||
render: (v: string) => v ?? '-',
|
||
},
|
||
{
|
||
title: '设备类型',
|
||
dataIndex: 'device_type',
|
||
width: 120,
|
||
render: (v: string) => v ?? '-',
|
||
},
|
||
{
|
||
title: '状态',
|
||
dataIndex: 'status',
|
||
width: 100,
|
||
render: (v: string) => (
|
||
<Tag color={BINDING_STATUS_COLOR[v] ?? 'default'}>{v}</Tag>
|
||
),
|
||
},
|
||
{
|
||
title: '绑定时间',
|
||
dataIndex: 'created_at',
|
||
width: 170,
|
||
render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '-',
|
||
},
|
||
{
|
||
title: '操作',
|
||
width: 100,
|
||
render: (_, record) => (
|
||
<Popconfirm title="确定解除此患者绑定?" onConfirm={() => handleUnbind(record)}>
|
||
<Button size="small" danger>解绑</Button>
|
||
</Popconfirm>
|
||
),
|
||
},
|
||
];
|
||
|
||
if (!gateway && !loading) {
|
||
return (
|
||
<PageContainer title="网关详情" onBack={() => navigate('/health/ble-gateways')}>
|
||
<div style={{ textAlign: 'center', padding: 80, color: '#999' }}>网关不存在或加载失败</div>
|
||
</PageContainer>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<PageContainer
|
||
title={gateway?.name ?? '网关详情'}
|
||
onBack={() => navigate('/health/ble-gateways')}
|
||
loading={loading}
|
||
>
|
||
<Descriptions bordered column={2} style={{ marginBottom: 24 }}>
|
||
<Descriptions.Item label="网关 ID">{gateway?.gateway_id}</Descriptions.Item>
|
||
<Descriptions.Item label="网关名称">{gateway?.name}</Descriptions.Item>
|
||
<Descriptions.Item label="状态">
|
||
<Tag color={GATEWAY_STATUS_COLOR[gateway?.status ?? ''] ?? 'default'}>
|
||
{GATEWAY_STATUS_LABEL[gateway?.status ?? ''] ?? gateway?.status}
|
||
</Tag>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="固件版本">{gateway?.firmware_version ?? '-'}</Descriptions.Item>
|
||
<Descriptions.Item label="IP 地址">{gateway?.ip_address ?? '-'}</Descriptions.Item>
|
||
<Descriptions.Item label="绑定患者数">{gateway?.patient_count ?? 0}</Descriptions.Item>
|
||
<Descriptions.Item label="最后心跳">
|
||
{gateway?.last_heartbeat_at
|
||
? dayjs(gateway.last_heartbeat_at).format('YYYY-MM-DD HH:mm:ss')
|
||
: '-'}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="创建时间">
|
||
{gateway?.created_at
|
||
? dayjs(gateway.created_at).format('YYYY-MM-DD HH:mm:ss')
|
||
: '-'}
|
||
</Descriptions.Item>
|
||
</Descriptions>
|
||
|
||
<Tabs
|
||
defaultActiveKey="bindings"
|
||
items={[
|
||
{
|
||
key: 'bindings',
|
||
label: '设备绑定',
|
||
children: (
|
||
<>
|
||
<Button type="primary" style={{ marginBottom: 16 }} onClick={handleBindPatient}>
|
||
绑定患者
|
||
</Button>
|
||
<Table<GatewayBinding>
|
||
rowKey="id"
|
||
columns={bindingColumns}
|
||
dataSource={bindings}
|
||
loading={bindingsLoading}
|
||
pagination={{
|
||
current: bindingsPage,
|
||
pageSize,
|
||
total: bindingsTotal,
|
||
showTotal: (t) => `共 ${t} 条`,
|
||
onChange: (p) => fetchBindings(p),
|
||
}}
|
||
/>
|
||
</>
|
||
),
|
||
},
|
||
]}
|
||
/>
|
||
|
||
<Modal
|
||
title="绑定患者"
|
||
open={bindModalOpen}
|
||
onOk={handleSubmitBind}
|
||
onCancel={() => setBindModalOpen(false)}
|
||
confirmLoading={bindSubmitting}
|
||
width={480}
|
||
>
|
||
<Form form={bindForm} layout="vertical">
|
||
<Form.Item
|
||
name="patient_id"
|
||
label="患者 ID"
|
||
rules={[{ required: true, message: '请输入患者 ID' }]}
|
||
>
|
||
<Input placeholder="输入患者 ID" />
|
||
</Form.Item>
|
||
<Form.Item name="peripheral_mac" label="外设 MAC 地址">
|
||
<Input placeholder="如:AA:BB:CC:DD:EE:FF" />
|
||
</Form.Item>
|
||
<Form.Item name="device_type" label="设备类型">
|
||
<Select
|
||
placeholder="选择设备类型"
|
||
allowClear
|
||
options={[
|
||
{ label: '血压计', value: 'blood_pressure' },
|
||
{ label: '血氧仪', value: 'pulse_oximeter' },
|
||
{ label: '体温计', value: 'thermometer' },
|
||
{ label: '体重秤', value: 'weight_scale' },
|
||
{ label: '血糖仪', value: 'glucometer' },
|
||
{ label: '心电仪', value: 'ecg' },
|
||
]}
|
||
/>
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
</PageContainer>
|
||
);
|
||
}
|