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 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 />} />
|
||||
|
||||
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/:id': '班次详情',
|
||||
'/health/medications': '药物记录',
|
||||
'/health/ble-gateways': 'BLE 网关管理',
|
||||
'/health/ble-gateways/:id': '网关详情',
|
||||
};
|
||||
|
||||
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