feat(web): BLE 网关管理 UI — Phase 2b-1
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

网关 CRUD 列表页(状态筛选/密钥刷新/API Key 创建展示)
+ 网关详情页(信息面板/设备绑定管理 Tab)
This commit is contained in:
iven
2026-05-04 23:47:21 +08:00
parent 438f9ca3f4
commit b6838c1bc1
5 changed files with 716 additions and 0 deletions

View File

@@ -58,6 +58,8 @@ const CarePlanDetail = lazy(() => import('./pages/health/CarePlanDetail'));
const ShiftList = lazy(() => import('./pages/health/ShiftList'));
const ShiftDetail = lazy(() => import('./pages/health/ShiftDetail'));
const MedicationRecordList = lazy(() => import('./pages/health/MedicationRecordList'));
const BleGatewayList = lazy(() => import('./pages/health/BleGatewayList'));
const BleGatewayDetail = lazy(() => import('./pages/health/BleGatewayDetail'));
// 内容管理
const ArticleManageList = lazy(() => import('./pages/health/ArticleManageList'));
@@ -281,6 +283,8 @@ export default function App() {
<Route path="/health/shifts" element={<ShiftList />} />
<Route path="/health/shifts/:id" element={<ShiftDetail />} />
<Route path="/health/medications" element={<MedicationRecordList />} />
<Route path="/health/ble-gateways" element={<BleGatewayList />} />
<Route path="/health/ble-gateways/:id" element={<BleGatewayDetail />} />
{/* 内容管理 */}
<Route path="/health/articles" element={<ArticleManageList />} />
<Route path="/health/articles/new" element={<ArticleEditor />} />

View File

@@ -0,0 +1,170 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface BleGateway {
id: string;
tenant_id: string;
gateway_id: string;
name: string;
status: string;
firmware_version?: string;
ip_address?: string;
last_heartbeat_at?: string;
metadata?: Record<string, unknown>;
created_at: string;
updated_at: string;
version: number;
api_key?: string;
patient_count?: number;
}
export interface GatewayBinding {
id: string;
tenant_id: string;
gateway_id: string;
patient_id: string;
peripheral_mac?: string;
device_type?: string;
status: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateBleGatewayReq {
gateway_id: string;
name: string;
firmware_version?: string;
metadata?: Record<string, unknown>;
}
export interface UpdateBleGatewayReq {
name?: string;
status?: string;
firmware_version?: string;
metadata?: Record<string, unknown>;
}
export interface ListBleGatewaysParams {
page?: number;
page_size?: number;
status?: string;
}
export interface CreateBindingReq {
patient_id: string;
peripheral_mac?: string;
device_type?: string;
}
export interface BatchBindReq {
bindings: CreateBindingReq[];
}
// --- Constants ---
export const GATEWAY_STATUS_OPTIONS = [
{ label: '在线', value: 'online' },
{ label: '离线', value: 'offline' },
{ label: '未激活', value: 'inactive' },
{ label: '已禁用', value: 'disabled' },
];
export const GATEWAY_STATUS_COLOR: Record<string, string> = {
online: 'green',
offline: 'red',
inactive: 'default',
disabled: 'error',
};
export const GATEWAY_STATUS_LABEL: Record<string, string> = Object.fromEntries(
GATEWAY_STATUS_OPTIONS.map((o) => [o.value, o.label]),
);
export const BINDING_STATUS_COLOR: Record<string, string> = {
active: 'green',
inactive: 'default',
unbound: 'error',
};
// --- API ---
export const bleGatewayApi = {
// --- Gateways ---
list: async (params?: ListBleGatewaysParams) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<BleGateway>;
}>('/health/ble-gateways', { params });
return data.data;
},
get: async (gatewayId: string) => {
const { data } = await client.get<{
success: boolean;
data: BleGateway;
}>(`/health/ble-gateways/${gatewayId}`);
return data.data;
},
create: async (req: CreateBleGatewayReq) => {
const { data } = await client.post<{
success: boolean;
data: BleGateway;
}>('/health/ble-gateways', req);
return data.data;
},
update: async (gatewayId: string, req: UpdateBleGatewayReq & { version: number }) => {
const { data } = await client.put<{
success: boolean;
data: BleGateway;
}>(`/health/ble-gateways/${gatewayId}`, req);
return data.data;
},
delete: async (gatewayId: string, version: number) => {
await client.delete(`/health/ble-gateways/${gatewayId}`, { data: { version } });
},
regenerateKey: async (gatewayId: string) => {
const { data } = await client.post<{
success: boolean;
data: BleGateway;
}>(`/health/ble-gateways/${gatewayId}/regenerate-key`);
return data.data;
},
// --- Bindings ---
listBindings: async (gatewayId: string, params?: { page?: number; page_size?: number }) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<GatewayBinding>;
}>(`/health/ble-gateways/${gatewayId}/bindings`, { params });
return data.data;
},
bindPatient: async (gatewayId: string, req: CreateBindingReq) => {
const { data } = await client.post<{
success: boolean;
data: GatewayBinding;
}>(`/health/ble-gateways/${gatewayId}/bindings`, req);
return data.data;
},
batchBind: async (gatewayId: string, req: BatchBindReq) => {
const { data } = await client.post<{
success: boolean;
data: GatewayBinding[];
}>(`/health/ble-gateways/${gatewayId}/bindings/batch`, req);
return data.data;
},
unbindPatient: async (gatewayId: string, bindingId: string, version: number) => {
await client.delete(`/health/ble-gateways/${gatewayId}/bindings/${bindingId}`, { data: { version } });
},
};

View File

@@ -116,6 +116,8 @@ const routeTitleFallback: Record<string, string> = {
'/health/shifts': '班次管理',
'/health/shifts/:id': '班次详情',
'/health/medications': '药物记录',
'/health/ble-gateways': 'BLE 网关管理',
'/health/ble-gateways/:id': '网关详情',
};
function getTitleFromMenus(path: string, menus: MenuInfo[]): string | undefined {

View File

@@ -0,0 +1,254 @@
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>
);
}

View File

@@ -0,0 +1,286 @@
import { useState, useCallback, useMemo } from 'react';
import {
Button, Form, Input, message, Modal, Popconfirm, Select, Space, Table, Tag,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import {
bleGatewayApi,
type BleGateway,
type CreateBleGatewayReq,
type UpdateBleGatewayReq,
GATEWAY_STATUS_OPTIONS,
GATEWAY_STATUS_COLOR,
GATEWAY_STATUS_LABEL,
} from '../../api/health/bleGateways';
import { PageContainer } from '../../components/PageContainer';
import { usePermission } from '../../hooks/usePermission';
import { useNavigate } from 'react-router-dom';
export default function BleGatewayList() {
const { hasPermission } = usePermission('health.ble-gateways.manage');
const navigate = useNavigate();
const [data, setData] = useState<BleGateway[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [statusFilter, setStatusFilter] = useState<string | undefined>();
const [modalOpen, setModalOpen] = useState(false);
const [editRecord, setEditRecord] = useState<BleGateway | null>(null);
const [submitting, setSubmitting] = useState(false);
const [form] = Form.useForm();
const pageSize = 20;
const fetchData = useCallback(async (p: number, status?: string) => {
setLoading(true);
try {
const resp = await bleGatewayApi.list({ page: p, page_size: pageSize, status });
setData(resp.data);
setTotal(resp.total);
setPage(p);
} catch {
message.error('加载网关列表失败');
} finally {
setLoading(false);
}
}, []);
const handleSearch = () => {
fetchData(1, statusFilter);
};
const handleCreate = () => {
setEditRecord(null);
form.resetFields();
setModalOpen(true);
};
const handleEdit = (record: BleGateway) => {
setEditRecord(record);
form.setFieldsValue({
name: record.name,
status: record.status,
firmware_version: record.firmware_version,
});
setModalOpen(true);
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
setSubmitting(true);
if (editRecord) {
const req: UpdateBleGatewayReq & { version: number } = {
...values,
version: editRecord.version,
};
await bleGatewayApi.update(editRecord.gateway_id, req);
message.success('网关已更新');
} else {
const req: CreateBleGatewayReq = values;
const created = await bleGatewayApi.create(req);
Modal.success({
title: '网关创建成功',
content: (
<div>
<p><strong> ID</strong>{created.gateway_id}</p>
<p><strong>API Key</strong></p>
<Input.TextArea
readOnly
autoSize
value={created.api_key ?? ''}
style={{ fontFamily: 'monospace', fontSize: 12 }}
/>
<p style={{ color: '#999', marginTop: 8 }}> API Key</p>
</div>
),
width: 520,
});
}
setModalOpen(false);
fetchData(page, statusFilter);
} catch {
// validation
} finally {
setSubmitting(false);
}
};
const handleDelete = async (record: BleGateway) => {
try {
await bleGatewayApi.delete(record.gateway_id, record.version);
message.success('网关已删除');
fetchData(page, statusFilter);
} catch {
message.error('删除失败');
}
};
const handleRegenerateKey = async (record: BleGateway) => {
try {
const updated = await bleGatewayApi.regenerateKey(record.gateway_id);
Modal.success({
title: 'API Key 已重新生成',
content: (
<div>
<p><strong> API Key</strong></p>
<Input.TextArea
readOnly
autoSize
value={updated.api_key ?? ''}
style={{ fontFamily: 'monospace', fontSize: 12 }}
/>
<p style={{ color: '#999', marginTop: 8 }}> Key </p>
</div>
),
width: 520,
});
fetchData(page, statusFilter);
} catch {
message.error('重新生成密钥失败');
}
};
const columns: ColumnsType<BleGateway> = useMemo(() => [
{
title: '网关名称',
dataIndex: 'name',
width: 180,
render: (v: string, record) => (
<a onClick={() => navigate(`/health/ble-gateways/${record.gateway_id}`)}>{v}</a>
),
},
{
title: '网关 ID',
dataIndex: 'gateway_id',
width: 160,
ellipsis: true,
},
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (v: string) => (
<Tag color={GATEWAY_STATUS_COLOR[v] ?? 'default'}>
{GATEWAY_STATUS_LABEL[v] ?? v}
</Tag>
),
},
{
title: '固件版本',
dataIndex: 'firmware_version',
width: 110,
render: (v: string) => v ?? '-',
},
{
title: 'IP 地址',
dataIndex: 'ip_address',
width: 130,
render: (v: string) => v ?? '-',
},
{
title: '绑定患者',
dataIndex: 'patient_count',
width: 90,
render: (v: number) => v ?? 0,
},
{
title: '最后心跳',
dataIndex: 'last_heartbeat_at',
width: 170,
render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '-',
},
{
title: '操作',
width: 220,
render: (_, record) => (
<Space>
<Button size="small" onClick={() => navigate(`/health/ble-gateways/${record.gateway_id}`)}></Button>
<Button size="small" onClick={() => handleEdit(record)}></Button>
<Popconfirm title="确定重新生成 API Key旧 Key 将立即失效。" onConfirm={() => handleRegenerateKey(record)}>
<Button size="small"></Button>
</Popconfirm>
<Popconfirm title="确定删除此网关?" onConfirm={() => handleDelete(record)}>
<Button size="small" danger></Button>
</Popconfirm>
</Space>
),
},
], [page, statusFilter, navigate]);
if (!hasPermission) {
return (
<PageContainer title="BLE 网关管理">
<div style={{ textAlign: 'center', padding: 80, color: '#999' }}></div>
</PageContainer>
);
}
return (
<PageContainer
title="BLE 网关管理"
actions={<Button type="primary" onClick={handleCreate}></Button>}
>
<Space style={{ marginBottom: 16 }} wrap>
<Select
placeholder="状态筛选"
options={GATEWAY_STATUS_OPTIONS}
value={statusFilter}
onChange={setStatusFilter}
allowClear
style={{ width: 150 }}
/>
<Button type="primary" onClick={handleSearch}></Button>
</Space>
<Table<BleGateway>
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
pagination={{
current: page,
pageSize,
total,
showTotal: (t) => `${t}`,
onChange: (p) => fetchData(p, statusFilter),
}}
/>
<Modal
title={editRecord ? '编辑网关' : '添加网关'}
open={modalOpen}
onOk={handleSubmit}
onCancel={() => setModalOpen(false)}
confirmLoading={submitting}
width={520}
>
<Form form={form} layout="vertical">
{!editRecord && (
<Form.Item name="gateway_id" label="网关 ID" rules={[{ required: true, message: '请输入网关 ID' }]}>
<Input placeholder="如GW-001" />
</Form.Item>
)}
<Form.Item name="name" label="网关名称" rules={[{ required: true, message: '请输入网关名称' }]}>
<Input placeholder="如:透析室 1 号网关" />
</Form.Item>
{editRecord && (
<Form.Item name="status" label="状态">
<Select options={GATEWAY_STATUS_OPTIONS} placeholder="选择状态" />
</Form.Item>
)}
<Form.Item name="firmware_version" label="固件版本">
<Input placeholder="如v2.1.0" />
</Form.Item>
</Form>
</Modal>
</PageContainer>
);
}