feat(web): BLE 网关管理 UI — Phase 2b-1
网关 CRUD 列表页(状态筛选/密钥刷新/API Key 创建展示) + 网关详情页(信息面板/设备绑定管理 Tab)
This commit is contained in:
@@ -58,6 +58,8 @@ const CarePlanDetail = lazy(() => import('./pages/health/CarePlanDetail'));
|
|||||||
const ShiftList = lazy(() => import('./pages/health/ShiftList'));
|
const ShiftList = lazy(() => import('./pages/health/ShiftList'));
|
||||||
const ShiftDetail = lazy(() => import('./pages/health/ShiftDetail'));
|
const ShiftDetail = lazy(() => import('./pages/health/ShiftDetail'));
|
||||||
const MedicationRecordList = lazy(() => import('./pages/health/MedicationRecordList'));
|
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'));
|
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" element={<ShiftList />} />
|
||||||
<Route path="/health/shifts/:id" element={<ShiftDetail />} />
|
<Route path="/health/shifts/:id" element={<ShiftDetail />} />
|
||||||
<Route path="/health/medications" element={<MedicationRecordList />} />
|
<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" element={<ArticleManageList />} />
|
||||||
<Route path="/health/articles/new" element={<ArticleEditor />} />
|
<Route path="/health/articles/new" element={<ArticleEditor />} />
|
||||||
|
|||||||
170
apps/web/src/api/health/bleGateways.ts
Normal file
170
apps/web/src/api/health/bleGateways.ts
Normal 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 } });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -116,6 +116,8 @@ const routeTitleFallback: Record<string, string> = {
|
|||||||
'/health/shifts': '班次管理',
|
'/health/shifts': '班次管理',
|
||||||
'/health/shifts/:id': '班次详情',
|
'/health/shifts/:id': '班次详情',
|
||||||
'/health/medications': '药物记录',
|
'/health/medications': '药物记录',
|
||||||
|
'/health/ble-gateways': 'BLE 网关管理',
|
||||||
|
'/health/ble-gateways/:id': '网关详情',
|
||||||
};
|
};
|
||||||
|
|
||||||
function getTitleFromMenus(path: string, menus: MenuInfo[]): string | undefined {
|
function getTitleFromMenus(path: string, menus: MenuInfo[]): string | undefined {
|
||||||
|
|||||||
254
apps/web/src/pages/health/BleGatewayDetail.tsx
Normal file
254
apps/web/src/pages/health/BleGatewayDetail.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
286
apps/web/src/pages/health/BleGatewayList.tsx
Normal file
286
apps/web/src/pages/health/BleGatewayList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user