P0 阻塞修复:
- 修复 PrivateRoute 权限旁路: p.startsWith('auth.') 匹配不到任何权限码,
改为基于实际权限码的路由级检查 (user.manage/role.manage/organization.manage)
- 修复 deviceReadings API 路径: /patients/{id}/device-readings/daily 改为
/vital-signs/daily?patient_id=, 消除 404
P1 重要修复:
- 补全事件注册表: 新增 auth(11) + config(8) + workflow(4) + plugin(2) = 25 条
- article_article_tag 联表新增 tenant_id + deleted_at + 审计列 (迁移 107)
- vital_signs_hourly 新增 deleted_at 支持软删除过滤 (迁移 108)
- 6 个页面添加权限守卫 (AlertDashboard/AlertRuleList/DeviceManage/
AiAnalysisList/AiUsageDashboard)
- DialysisModule 声明 auth 依赖
192 lines
5.7 KiB
TypeScript
192 lines
5.7 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
|
import { Button, Input, message, Popconfirm, Result, Select, Space, Table, Tag, Badge } from 'antd';
|
|
import type { ColumnsType } from 'antd/es/table';
|
|
import dayjs from 'dayjs';
|
|
|
|
import { deviceApi, type DeviceItem } from '../../api/health/devices';
|
|
import { DEVICE_TYPE_OPTIONS, DEVICE_TYPE_COLOR, DEVICE_STATUS_OPTIONS } from '../../constants/health';
|
|
import { PatientSelect } from './components/PatientSelect';
|
|
import { usePermission } from '../../hooks/usePermission';
|
|
|
|
function formatTime(val?: string | null): string {
|
|
if (!val) return '-';
|
|
return dayjs(val).format('YYYY-MM-DD HH:mm');
|
|
}
|
|
|
|
export default function DeviceManage() {
|
|
const { hasPermission } = usePermission('health.devices.list');
|
|
if (!hasPermission) return <Result status="403" title="权限不足" subTitle="您没有管理设备的权限" />;
|
|
const [data, setData] = useState<DeviceItem[]>([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [loading, setLoading] = useState(false);
|
|
const [page, setPage] = useState(1);
|
|
|
|
const [filterPatientId, setFilterPatientId] = useState('');
|
|
const [filterDeviceType, setFilterDeviceType] = useState<string | undefined>(undefined);
|
|
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
|
|
|
|
const fetchDevices = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await deviceApi.listDevices({
|
|
page,
|
|
page_size: 20,
|
|
...(filterPatientId ? { patient_id: filterPatientId } : {}),
|
|
...(filterDeviceType ? { device_type: filterDeviceType } : {}),
|
|
...(filterStatus ? { status: filterStatus } : {}),
|
|
});
|
|
setData(res.data);
|
|
setTotal(res.total);
|
|
} catch {
|
|
message.error('加载设备列表失败');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [page, filterPatientId, filterDeviceType, filterStatus]);
|
|
|
|
useEffect(() => {
|
|
fetchDevices();
|
|
}, [fetchDevices]);
|
|
|
|
const handleUnbind = async (record: DeviceItem) => {
|
|
try {
|
|
await deviceApi.unbindDevice(record.id, record.version);
|
|
message.success('设备已解绑');
|
|
fetchDevices();
|
|
} catch {
|
|
message.error('解绑失败');
|
|
}
|
|
};
|
|
|
|
const columns: ColumnsType<DeviceItem> = [
|
|
{
|
|
title: '设备 ID',
|
|
dataIndex: 'device_id',
|
|
width: 120,
|
|
render: (v: string) => v.slice(0, 8),
|
|
},
|
|
{
|
|
title: '设备型号',
|
|
dataIndex: 'device_model',
|
|
width: 160,
|
|
},
|
|
{
|
|
title: '设备类型',
|
|
dataIndex: 'device_type',
|
|
width: 100,
|
|
render: (v: string) => {
|
|
const label = DEVICE_TYPE_OPTIONS.find((d) => d.value === v)?.label || v;
|
|
return <Tag color={DEVICE_TYPE_COLOR[v] || 'default'}>{label}</Tag>;
|
|
},
|
|
},
|
|
{
|
|
title: '状态',
|
|
dataIndex: 'status',
|
|
width: 80,
|
|
render: (v: string) => {
|
|
const config: Record<string, { color: 'success' | 'error' | 'default' | 'processing'; label: string }> = {
|
|
online: { color: 'success', label: '在线' },
|
|
offline: { color: 'default', label: '离线' },
|
|
paired: { color: 'processing', label: '已配对' },
|
|
error: { color: 'error', label: '异常' },
|
|
};
|
|
const c = config[v];
|
|
return c ? <Badge status={c.color} text={c.label} /> : <span>{v || '-'}</span>;
|
|
},
|
|
},
|
|
{
|
|
title: '连接方式',
|
|
dataIndex: 'connection_type',
|
|
width: 90,
|
|
render: (v: string) => {
|
|
const map: Record<string, string> = { ble: '蓝牙', gateway: '网关', manual: '手动' };
|
|
return <Tag>{map[v] ?? v ?? '-'}</Tag>;
|
|
},
|
|
},
|
|
{
|
|
title: '固件版本',
|
|
dataIndex: 'firmware_version',
|
|
width: 110,
|
|
render: (v: string) => v ?? '-',
|
|
},
|
|
{
|
|
title: '绑定时间',
|
|
dataIndex: 'bound_at',
|
|
width: 170,
|
|
render: (v: string) => formatTime(v),
|
|
},
|
|
{
|
|
title: '最后同步',
|
|
dataIndex: 'last_sync_at',
|
|
width: 170,
|
|
render: (v: string) => formatTime(v),
|
|
},
|
|
{
|
|
title: '操作',
|
|
width: 80,
|
|
render: (_, record) => (
|
|
<Popconfirm
|
|
title="确认解绑"
|
|
description="解绑后设备将不再关联该患者,确定继续?"
|
|
onConfirm={() => handleUnbind(record)}
|
|
okText="确定"
|
|
cancelText="取消"
|
|
>
|
|
<Button size="small" type="link" danger>
|
|
解绑
|
|
</Button>
|
|
</Popconfirm>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div style={{ padding: 24 }}>
|
|
<h2 style={{ marginBottom: 16 }}>设备管理</h2>
|
|
|
|
<Space style={{ marginBottom: 16 }} wrap>
|
|
<div style={{ width: 240 }}>
|
|
<PatientSelect
|
|
value={filterPatientId || undefined}
|
|
onChange={(val) => setFilterPatientId(val || '')}
|
|
placeholder="搜索患者"
|
|
/>
|
|
</div>
|
|
<Select
|
|
placeholder="设备类型"
|
|
value={filterDeviceType}
|
|
onChange={setFilterDeviceType}
|
|
options={DEVICE_TYPE_OPTIONS}
|
|
style={{ width: 140 }}
|
|
allowClear
|
|
/>
|
|
<Select
|
|
placeholder="设备状态"
|
|
value={filterStatus}
|
|
onChange={setFilterStatus}
|
|
options={DEVICE_STATUS_OPTIONS}
|
|
style={{ width: 120 }}
|
|
allowClear
|
|
/>
|
|
<Button type="primary" onClick={() => setPage(1)}>
|
|
查询
|
|
</Button>
|
|
</Space>
|
|
|
|
<Table<DeviceItem>
|
|
rowKey="id"
|
|
columns={columns}
|
|
dataSource={data}
|
|
loading={loading}
|
|
pagination={{
|
|
current: page,
|
|
total,
|
|
pageSize: 20,
|
|
showTotal: (t) => `共 ${t} 条`,
|
|
onChange: (p) => setPage(p),
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|