feat(health): Web 管理端设备数据集成补全 — Phase 2
- 新增告警三页面(仪表盘/列表/规则)+ 设备管理菜单种子数据
- 新增设备管理后端 API(GET /devices + DELETE /devices/{id})
- 新增设备数据查看组件 DeviceReadingsTab(原始数据 + 小时聚合)
- 新增设备管理页面 DeviceManage(列表/筛选/解绑)
- 患者详情页新增设备数据 Tab
This commit is contained in:
@@ -47,6 +47,7 @@ const AiUsageDashboard = lazy(() => import('./pages/health/AiUsageDashboard'));
|
|||||||
const AlertList = lazy(() => import('./pages/health/AlertList'));
|
const AlertList = lazy(() => import('./pages/health/AlertList'));
|
||||||
const AlertDashboard = lazy(() => import('./pages/health/AlertDashboard'));
|
const AlertDashboard = lazy(() => import('./pages/health/AlertDashboard'));
|
||||||
const AlertRuleList = lazy(() => import('./pages/health/AlertRuleList'));
|
const AlertRuleList = lazy(() => import('./pages/health/AlertRuleList'));
|
||||||
|
const DeviceManage = lazy(() => import('./pages/health/DeviceManage'));
|
||||||
|
|
||||||
// 内容管理
|
// 内容管理
|
||||||
const ArticleManageList = lazy(() => import('./pages/health/ArticleManageList'));
|
const ArticleManageList = lazy(() => import('./pages/health/ArticleManageList'));
|
||||||
@@ -252,6 +253,7 @@ export default function App() {
|
|||||||
<Route path="/health/alerts" element={<AlertList />} />
|
<Route path="/health/alerts" element={<AlertList />} />
|
||||||
<Route path="/health/alert-dashboard" element={<AlertDashboard />} />
|
<Route path="/health/alert-dashboard" element={<AlertDashboard />} />
|
||||||
<Route path="/health/alert-rules" element={<AlertRuleList />} />
|
<Route path="/health/alert-rules" element={<AlertRuleList />} />
|
||||||
|
<Route path="/health/devices" element={<DeviceManage />} />
|
||||||
{/* 内容管理 */}
|
{/* 内容管理 */}
|
||||||
<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 />} />
|
||||||
|
|||||||
32
apps/web/src/api/health/devices.ts
Normal file
32
apps/web/src/api/health/devices.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import client from '../client';
|
||||||
|
import type { PaginatedResponse } from '../types';
|
||||||
|
|
||||||
|
// --- Types ---
|
||||||
|
export interface DeviceItem {
|
||||||
|
id: string;
|
||||||
|
patient_id: string;
|
||||||
|
device_id: string;
|
||||||
|
device_model: string;
|
||||||
|
device_type: string;
|
||||||
|
bound_at: string;
|
||||||
|
last_sync_at: string;
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- API ---
|
||||||
|
export const deviceApi = {
|
||||||
|
listDevices: (params?: {
|
||||||
|
patient_id?: string;
|
||||||
|
device_type?: string;
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
}) =>
|
||||||
|
client
|
||||||
|
.get('/health/devices', { params })
|
||||||
|
.then((r) => r.data.data as PaginatedResponse<DeviceItem>),
|
||||||
|
|
||||||
|
unbindDevice: (id: string, version: number) =>
|
||||||
|
client
|
||||||
|
.delete(`/health/devices/${id}`, { data: { version } })
|
||||||
|
.then((r) => r.data.data as DeviceItem),
|
||||||
|
};
|
||||||
@@ -94,6 +94,7 @@ const routeTitleFallback: Record<string, string> = {
|
|||||||
'/health/alerts': '告警列表',
|
'/health/alerts': '告警列表',
|
||||||
'/health/alert-dashboard': '告警仪表盘',
|
'/health/alert-dashboard': '告警仪表盘',
|
||||||
'/health/alert-rules': '告警规则',
|
'/health/alert-rules': '告警规则',
|
||||||
|
'/health/devices': '设备管理',
|
||||||
};
|
};
|
||||||
|
|
||||||
function getTitleFromMenus(path: string, menus: MenuInfo[]): string | undefined {
|
function getTitleFromMenus(path: string, menus: MenuInfo[]): string | undefined {
|
||||||
|
|||||||
164
apps/web/src/pages/health/DeviceManage.tsx
Normal file
164
apps/web/src/pages/health/DeviceManage.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Button, Input, message, Popconfirm, Select, Space, Table, Tag } from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
import { deviceApi, type DeviceItem } from '../../api/health/devices';
|
||||||
|
|
||||||
|
const DEVICE_TYPE_OPTIONS = [
|
||||||
|
{ label: '血压', value: 'blood_pressure' },
|
||||||
|
{ label: '血糖', value: 'blood_glucose' },
|
||||||
|
{ label: '心率', value: 'heart_rate' },
|
||||||
|
{ label: '血氧', value: 'blood_oxygen' },
|
||||||
|
{ label: '步数', value: 'steps' },
|
||||||
|
{ label: '体温', value: 'temperature' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEVICE_TYPE_COLOR: Record<string, string> = {
|
||||||
|
blood_pressure: 'red',
|
||||||
|
blood_glucose: 'purple',
|
||||||
|
heart_rate: 'volcano',
|
||||||
|
blood_oxygen: 'blue',
|
||||||
|
steps: 'green',
|
||||||
|
temperature: 'orange',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatTime(val?: string | null): string {
|
||||||
|
if (!val) return '-';
|
||||||
|
return dayjs(val).format('YYYY-MM-DD HH:mm');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeviceManage() {
|
||||||
|
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 fetchDevices = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await deviceApi.listDevices({
|
||||||
|
page,
|
||||||
|
page_size: 20,
|
||||||
|
...(filterPatientId ? { patient_id: filterPatientId } : {}),
|
||||||
|
...(filterDeviceType ? { device_type: filterDeviceType } : {}),
|
||||||
|
});
|
||||||
|
setData(res.data);
|
||||||
|
setTotal(res.total);
|
||||||
|
} catch {
|
||||||
|
message.error('加载设备列表失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [page, filterPatientId, filterDeviceType]);
|
||||||
|
|
||||||
|
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: '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>
|
||||||
|
<Input
|
||||||
|
placeholder="患者 ID"
|
||||||
|
value={filterPatientId}
|
||||||
|
onChange={(e) => setFilterPatientId(e.target.value)}
|
||||||
|
style={{ width: 200 }}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="设备类型"
|
||||||
|
value={filterDeviceType}
|
||||||
|
onChange={setFilterDeviceType}
|
||||||
|
options={DEVICE_TYPE_OPTIONS}
|
||||||
|
style={{ width: 140 }}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ import { VitalSignsTab } from './components/VitalSignsTab';
|
|||||||
import { LabReportsTab } from './components/LabReportsTab';
|
import { LabReportsTab } from './components/LabReportsTab';
|
||||||
import { HealthRecordsTab } from './components/HealthRecordsTab';
|
import { HealthRecordsTab } from './components/HealthRecordsTab';
|
||||||
import { FollowUpTab } from './components/FollowUpTab';
|
import { FollowUpTab } from './components/FollowUpTab';
|
||||||
|
import { DeviceReadingsTab } from './components/DeviceReadingsTab';
|
||||||
import { GENDER_OPTIONS, BLOOD_TYPE_OPTIONS } from '../../constants/health';
|
import { GENDER_OPTIONS, BLOOD_TYPE_OPTIONS } from '../../constants/health';
|
||||||
import { useThemeMode } from '../../hooks/useThemeMode';
|
import { useThemeMode } from '../../hooks/useThemeMode';
|
||||||
|
|
||||||
@@ -276,6 +277,7 @@ export default function PatientDetail() {
|
|||||||
size="small"
|
size="small"
|
||||||
items={[
|
items={[
|
||||||
{ key: 'vital', label: '体征数据', children: <VitalSignsTab patientId={id} /> },
|
{ key: 'vital', label: '体征数据', children: <VitalSignsTab patientId={id} /> },
|
||||||
|
{ key: 'device', label: '设备数据', children: <DeviceReadingsTab patientId={id} /> },
|
||||||
{ key: 'lab', label: '化验报告', children: <LabReportsTab patientId={id} /> },
|
{ key: 'lab', label: '化验报告', children: <LabReportsTab patientId={id} /> },
|
||||||
{ key: 'records', label: '健康档案', children: <HealthRecordsTab patientId={id} /> },
|
{ key: 'records', label: '健康档案', children: <HealthRecordsTab patientId={id} /> },
|
||||||
]}
|
]}
|
||||||
|
|||||||
312
apps/web/src/pages/health/components/DeviceReadingsTab.tsx
Normal file
312
apps/web/src/pages/health/components/DeviceReadingsTab.tsx
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { Table, Select, Tabs, Card, Typography } from 'antd';
|
||||||
|
import { deviceReadingApi } from '../../../api/health/deviceReadings';
|
||||||
|
import type { DeviceReading, HourlyReading } from '../../../api/health/deviceReadings';
|
||||||
|
import { usePaginatedData } from '../../../hooks/usePaginatedData';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
/* ---------- 常量 ---------- */
|
||||||
|
|
||||||
|
const DEVICE_TYPE_OPTIONS = [
|
||||||
|
{ value: 'heart_rate', label: '心率' },
|
||||||
|
{ value: 'blood_oxygen', label: '血氧' },
|
||||||
|
{ value: 'blood_pressure', label: '血压' },
|
||||||
|
{ value: 'blood_glucose', label: '血糖' },
|
||||||
|
{ value: 'steps', label: '步数' },
|
||||||
|
{ value: 'temperature', label: '体温' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const TIME_RANGE_OPTIONS = [
|
||||||
|
{ value: 1, label: '最近 1 小时' },
|
||||||
|
{ value: 6, label: '最近 6 小时' },
|
||||||
|
{ value: 24, label: '最近 24 小时' },
|
||||||
|
{ value: 168, label: '最近 7 天' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const DAYS_RANGE_OPTIONS = [
|
||||||
|
{ value: 1, label: '1 天' },
|
||||||
|
{ value: 3, label: '3 天' },
|
||||||
|
{ value: 7, label: '7 天' },
|
||||||
|
{ value: 30, label: '30 天' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const DEVICE_TYPE_MAP: Record<string, string> = Object.fromEntries(
|
||||||
|
DEVICE_TYPE_OPTIONS.map((o) => [o.value, o.label]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
/* ---------- Props ---------- */
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
patientId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 原始数据 Tab ---------- */
|
||||||
|
|
||||||
|
interface RawFilters {
|
||||||
|
deviceType: string | undefined;
|
||||||
|
hours: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RawDataTab({ patientId }: Props) {
|
||||||
|
const [deviceType, setDeviceType] = useState<string | undefined>(undefined);
|
||||||
|
const [hours, setHours] = useState<number>(24);
|
||||||
|
|
||||||
|
const fetcher = useCallback(
|
||||||
|
async (page: number, pageSize: number) => {
|
||||||
|
return deviceReadingApi.query({
|
||||||
|
patient_id: patientId,
|
||||||
|
device_type: deviceType,
|
||||||
|
hours,
|
||||||
|
page,
|
||||||
|
page_size: pageSize,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[patientId, deviceType, hours],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, total, page, loading, refresh } = usePaginatedData<DeviceReading>(
|
||||||
|
fetcher,
|
||||||
|
PAGE_SIZE,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
title: '测量时间',
|
||||||
|
dataIndex: 'measured_at',
|
||||||
|
key: 'measured_at',
|
||||||
|
width: 180,
|
||||||
|
render: (v: string) => v ?? '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '设备类型',
|
||||||
|
dataIndex: 'device_type',
|
||||||
|
key: 'device_type',
|
||||||
|
width: 100,
|
||||||
|
render: (v: string) => DEVICE_TYPE_MAP[v] ?? v,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '指标',
|
||||||
|
dataIndex: 'raw_value',
|
||||||
|
key: 'metric',
|
||||||
|
width: 120,
|
||||||
|
render: (v: Record<string, unknown>) => {
|
||||||
|
const keys = Object.keys(v);
|
||||||
|
return keys.length > 0 ? keys.join(', ') : '-';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '原始值',
|
||||||
|
dataIndex: 'raw_value',
|
||||||
|
key: 'raw_value',
|
||||||
|
render: (v: Record<string, unknown>) => JSON.stringify(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '设备型号',
|
||||||
|
dataIndex: 'device_model',
|
||||||
|
key: 'device_model',
|
||||||
|
width: 120,
|
||||||
|
render: (v?: string) => v ?? '-',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', gap: 12, marginBottom: 12 }}>
|
||||||
|
<Select
|
||||||
|
placeholder="设备类型"
|
||||||
|
allowClear
|
||||||
|
style={{ width: 140 }}
|
||||||
|
options={[...DEVICE_TYPE_OPTIONS]}
|
||||||
|
value={deviceType}
|
||||||
|
onChange={(v) => {
|
||||||
|
setDeviceType(v);
|
||||||
|
refresh(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
style={{ width: 140 }}
|
||||||
|
options={[...TIME_RANGE_OPTIONS]}
|
||||||
|
value={hours}
|
||||||
|
onChange={(v) => {
|
||||||
|
setHours(v);
|
||||||
|
refresh(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
size="small"
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
total,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
onChange: (p) => refresh(p),
|
||||||
|
showTotal: (t) => `共 ${t} 条`,
|
||||||
|
size: 'small',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 小时聚合 Tab ---------- */
|
||||||
|
|
||||||
|
interface HourlyFilters {
|
||||||
|
deviceType: string | undefined;
|
||||||
|
days: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function HourlyAggTab({ patientId }: Props) {
|
||||||
|
const [deviceType, setDeviceType] = useState<string | undefined>(undefined);
|
||||||
|
const [days, setDays] = useState<number>(7);
|
||||||
|
|
||||||
|
const fetcher = useCallback(
|
||||||
|
async (page: number, pageSize: number) => {
|
||||||
|
if (!deviceType) {
|
||||||
|
return { data: [] as HourlyReading[], total: 0 };
|
||||||
|
}
|
||||||
|
return deviceReadingApi.queryHourly({
|
||||||
|
patient_id: patientId,
|
||||||
|
device_type: deviceType,
|
||||||
|
days,
|
||||||
|
page,
|
||||||
|
page_size: pageSize,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[patientId, deviceType, days],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, total, page, loading, refresh } = usePaginatedData<HourlyReading>(
|
||||||
|
fetcher,
|
||||||
|
PAGE_SIZE,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
title: '小时起始时间',
|
||||||
|
dataIndex: 'hour_start',
|
||||||
|
key: 'hour_start',
|
||||||
|
width: 180,
|
||||||
|
render: (v: string) => v ?? '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '设备类型',
|
||||||
|
dataIndex: 'device_type',
|
||||||
|
key: 'device_type',
|
||||||
|
width: 100,
|
||||||
|
render: (v: string) => DEVICE_TYPE_MAP[v] ?? v,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '最小值',
|
||||||
|
dataIndex: 'min_val',
|
||||||
|
key: 'min_val',
|
||||||
|
width: 100,
|
||||||
|
render: (v?: number) => (v != null ? v.toFixed(2) : '-'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '最大值',
|
||||||
|
dataIndex: 'max_val',
|
||||||
|
key: 'max_val',
|
||||||
|
width: 100,
|
||||||
|
render: (v?: number) => (v != null ? v.toFixed(2) : '-'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '平均值',
|
||||||
|
dataIndex: 'avg_val',
|
||||||
|
key: 'avg_val',
|
||||||
|
width: 100,
|
||||||
|
render: (v: number) => v.toFixed(2),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '采样数',
|
||||||
|
dataIndex: 'sample_count',
|
||||||
|
key: 'sample_count',
|
||||||
|
width: 80,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', gap: 12, marginBottom: 12 }}>
|
||||||
|
<Select
|
||||||
|
placeholder="设备类型"
|
||||||
|
allowClear
|
||||||
|
style={{ width: 140 }}
|
||||||
|
options={[...DEVICE_TYPE_OPTIONS]}
|
||||||
|
value={deviceType}
|
||||||
|
onChange={(v) => {
|
||||||
|
setDeviceType(v);
|
||||||
|
refresh(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
style={{ width: 140 }}
|
||||||
|
options={[...DAYS_RANGE_OPTIONS]}
|
||||||
|
value={days}
|
||||||
|
onChange={(v) => {
|
||||||
|
setDays(v);
|
||||||
|
refresh(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!deviceType ? (
|
||||||
|
<Text type="secondary">请先选择设备类型</Text>
|
||||||
|
) : (
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
size="small"
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
total,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
onChange: (p) => refresh(p),
|
||||||
|
showTotal: (t) => `共 ${t} 条`,
|
||||||
|
size: 'small',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 主组件 ---------- */
|
||||||
|
|
||||||
|
export function DeviceReadingsTab({ patientId }: Props) {
|
||||||
|
return (
|
||||||
|
<Card size="small">
|
||||||
|
<Tabs
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'raw',
|
||||||
|
label: '原始数据',
|
||||||
|
children: <RawDataTab patientId={patientId} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'hourly',
|
||||||
|
label: '小时聚合',
|
||||||
|
children: <HourlyAggTab patientId={patientId} />,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -68,6 +68,9 @@ pub enum HealthError {
|
|||||||
#[error("知情同意记录不存在")]
|
#[error("知情同意记录不存在")]
|
||||||
ConsentNotFound,
|
ConsentNotFound,
|
||||||
|
|
||||||
|
#[error("设备绑定不存在")]
|
||||||
|
DeviceNotFound,
|
||||||
|
|
||||||
#[error("告警规则不存在")]
|
#[error("告警规则不存在")]
|
||||||
AlertRuleNotFound,
|
AlertRuleNotFound,
|
||||||
|
|
||||||
@@ -118,6 +121,7 @@ impl From<HealthError> for AppError {
|
|||||||
| HealthError::ThresholdNotFound
|
| HealthError::ThresholdNotFound
|
||||||
| HealthError::ConsentNotFound
|
| HealthError::ConsentNotFound
|
||||||
| HealthError::AlertRuleNotFound
|
| HealthError::AlertRuleNotFound
|
||||||
|
| HealthError::DeviceNotFound
|
||||||
| HealthError::AlertNotFound
|
| HealthError::AlertNotFound
|
||||||
| HealthError::DialysisPrescriptionNotFound
|
| HealthError::DialysisPrescriptionNotFound
|
||||||
| HealthError::FollowUpTemplateNotFound
|
| HealthError::FollowUpTemplateNotFound
|
||||||
|
|||||||
85
crates/erp-health/src/handler/device_handler.rs
Normal file
85
crates/erp-health/src/handler/device_handler.rs
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
//! 设备管理 API — 设备列表查询与解绑
|
||||||
|
|
||||||
|
use axum::extract::{FromRef, Path, Query, State};
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use axum::Extension;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use utoipa::IntoParams;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use erp_core::error::AppError;
|
||||||
|
use erp_core::rbac::require_permission;
|
||||||
|
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
||||||
|
|
||||||
|
use crate::dto::DeleteWithVersion;
|
||||||
|
use crate::service::device_service;
|
||||||
|
use crate::state::HealthState;
|
||||||
|
|
||||||
|
/// 设备列表查询参数
|
||||||
|
#[derive(Debug, Deserialize, IntoParams)]
|
||||||
|
pub struct DeviceListQuery {
|
||||||
|
/// 按患者 ID 筛选
|
||||||
|
pub patient_id: Option<Uuid>,
|
||||||
|
/// 按设备类型筛选
|
||||||
|
pub device_type: Option<String>,
|
||||||
|
pub page: Option<u64>,
|
||||||
|
pub page_size: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/v1/health/devices — 设备绑定列表
|
||||||
|
pub async fn list_devices<S>(
|
||||||
|
State(state): State<HealthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Query(query): Query<DeviceListQuery>,
|
||||||
|
) -> Result<impl IntoResponse, AppError>
|
||||||
|
where
|
||||||
|
HealthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "health.devices.list")?;
|
||||||
|
let page = query.page.unwrap_or(1);
|
||||||
|
let page_size = query.page_size.unwrap_or(20);
|
||||||
|
|
||||||
|
let (items, total) = device_service::list_devices(
|
||||||
|
&state,
|
||||||
|
ctx.tenant_id,
|
||||||
|
query.patient_id,
|
||||||
|
query.device_type.as_deref(),
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(axum::Json(ApiResponse::ok(PaginatedResponse {
|
||||||
|
data: items,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
total_pages: total.div_ceil(page_size.max(1)),
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DELETE /api/v1/health/devices/{id} — 解绑设备(软删除)
|
||||||
|
pub async fn unbind_device<S>(
|
||||||
|
State(state): State<HealthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
axum::Json(body): axum::Json<DeleteWithVersion>,
|
||||||
|
) -> Result<impl IntoResponse, AppError>
|
||||||
|
where
|
||||||
|
HealthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "health.devices.manage")?;
|
||||||
|
|
||||||
|
let device = device_service::unbind_device(
|
||||||
|
&state,
|
||||||
|
ctx.tenant_id,
|
||||||
|
id,
|
||||||
|
ctx.user_id,
|
||||||
|
body.version,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(axum::Json(ApiResponse::ok(device)))
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ pub mod consent_handler;
|
|||||||
pub mod critical_alert_handler;
|
pub mod critical_alert_handler;
|
||||||
pub mod critical_value_threshold_handler;
|
pub mod critical_value_threshold_handler;
|
||||||
pub mod daily_monitoring_handler;
|
pub mod daily_monitoring_handler;
|
||||||
|
pub mod device_handler;
|
||||||
pub mod device_reading_handler;
|
pub mod device_reading_handler;
|
||||||
pub mod diagnosis_handler;
|
pub mod diagnosis_handler;
|
||||||
pub mod medication_record_handler;
|
pub mod medication_record_handler;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use erp_core::module::{ErpModule, PermissionDescriptor};
|
|||||||
|
|
||||||
use crate::handler::{
|
use crate::handler::{
|
||||||
alert_handler, alert_rule_handler,
|
alert_handler, alert_rule_handler,
|
||||||
appointment_handler, article_category_handler, article_handler, article_tag_handler, consultation_handler, consent_handler, critical_alert_handler, critical_value_threshold_handler, daily_monitoring_handler, device_reading_handler, diagnosis_handler, doctor_handler, follow_up_handler, follow_up_template_handler,
|
appointment_handler, article_category_handler, article_handler, article_tag_handler, consultation_handler, consent_handler, critical_alert_handler, critical_value_threshold_handler, daily_monitoring_handler, device_handler, device_reading_handler, diagnosis_handler, doctor_handler, follow_up_handler, follow_up_template_handler,
|
||||||
health_data_handler, medication_record_handler, patient_handler, points_handler, stats_handler,
|
health_data_handler, medication_record_handler, patient_handler, points_handler, stats_handler,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -653,6 +653,15 @@ impl HealthModule {
|
|||||||
"/health/alert-rules/{id}/deactivate",
|
"/health/alert-rules/{id}/deactivate",
|
||||||
axum::routing::put(alert_rule_handler::deactivate),
|
axum::routing::put(alert_rule_handler::deactivate),
|
||||||
)
|
)
|
||||||
|
// 设备管理
|
||||||
|
.route(
|
||||||
|
"/health/devices",
|
||||||
|
axum::routing::get(device_handler::list_devices),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/health/devices/{id}",
|
||||||
|
axum::routing::delete(device_handler::unbind_device),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -884,6 +893,18 @@ impl ErpModule for HealthModule {
|
|||||||
description: "提交设备采集数据".into(),
|
description: "提交设备采集数据".into(),
|
||||||
module: "health".into(),
|
module: "health".into(),
|
||||||
},
|
},
|
||||||
|
PermissionDescriptor {
|
||||||
|
code: "health.devices.list".into(),
|
||||||
|
name: "查看设备绑定".into(),
|
||||||
|
description: "查看设备绑定记录列表".into(),
|
||||||
|
module: "health".into(),
|
||||||
|
},
|
||||||
|
PermissionDescriptor {
|
||||||
|
code: "health.devices.manage".into(),
|
||||||
|
name: "管理设备绑定".into(),
|
||||||
|
description: "解绑设备".into(),
|
||||||
|
module: "health".into(),
|
||||||
|
},
|
||||||
PermissionDescriptor {
|
PermissionDescriptor {
|
||||||
code: "health.alerts.list".into(),
|
code: "health.alerts.list".into(),
|
||||||
name: "查看告警".into(),
|
name: "查看告警".into(),
|
||||||
|
|||||||
72
crates/erp-health/src/service/device_service.rs
Normal file
72
crates/erp-health/src/service/device_service.rs
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
//! 设备管理服务 — 设备绑定记录的查询与解绑
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use sea_orm::ActiveValue::Set;
|
||||||
|
use sea_orm::{QueryOrder, QuerySelect};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::entity::patient_devices;
|
||||||
|
use crate::error::{HealthError, HealthResult};
|
||||||
|
use crate::state::HealthState;
|
||||||
|
|
||||||
|
/// 查询设备绑定记录(分页),支持按 patient_id / device_type 筛选
|
||||||
|
pub async fn list_devices(
|
||||||
|
state: &HealthState,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
patient_id: Option<Uuid>,
|
||||||
|
device_type: Option<&str>,
|
||||||
|
page: u64,
|
||||||
|
page_size: u64,
|
||||||
|
) -> HealthResult<(Vec<patient_devices::Model>, u64)> {
|
||||||
|
let limit = page_size.min(100);
|
||||||
|
let offset = page.saturating_sub(1) * limit;
|
||||||
|
|
||||||
|
let mut query = patient_devices::Entity::find()
|
||||||
|
.filter(patient_devices::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(patient_devices::Column::DeletedAt.is_null());
|
||||||
|
|
||||||
|
if let Some(pid) = patient_id {
|
||||||
|
query = query.filter(patient_devices::Column::PatientId.eq(pid));
|
||||||
|
}
|
||||||
|
if let Some(dt) = device_type {
|
||||||
|
query = query.filter(patient_devices::Column::DeviceType.eq(dt));
|
||||||
|
}
|
||||||
|
|
||||||
|
let total = query.clone().count(&state.db).await?;
|
||||||
|
let items = query
|
||||||
|
.order_by_desc(patient_devices::Column::CreatedAt)
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
.all(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok((items, total))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解绑设备 — 设置 deleted_at 实现软删除,递增 version
|
||||||
|
pub async fn unbind_device(
|
||||||
|
state: &HealthState,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
device_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
version: i32,
|
||||||
|
) -> HealthResult<patient_devices::Model> {
|
||||||
|
let device = patient_devices::Entity::find_by_id(device_id)
|
||||||
|
.filter(patient_devices::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(patient_devices::Column::DeletedAt.is_null())
|
||||||
|
.one(&state.db)
|
||||||
|
.await?
|
||||||
|
.ok_or(HealthError::DeviceNotFound)?;
|
||||||
|
|
||||||
|
// 乐观锁校验
|
||||||
|
erp_core::error::check_version(device.version, version)?;
|
||||||
|
|
||||||
|
let mut active: patient_devices::ActiveModel = device.into();
|
||||||
|
active.deleted_at = Set(Some(Utc::now()));
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.updated_by = Set(Some(user_id));
|
||||||
|
active.version = Set(version + 1);
|
||||||
|
|
||||||
|
Ok(active.update(&state.db).await?)
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ pub mod critical_alert_service;
|
|||||||
pub mod critical_value_threshold_service;
|
pub mod critical_value_threshold_service;
|
||||||
pub mod daily_monitoring_service;
|
pub mod daily_monitoring_service;
|
||||||
pub mod device_reading_service;
|
pub mod device_reading_service;
|
||||||
|
pub mod device_service;
|
||||||
pub mod diagnosis_service;
|
pub mod diagnosis_service;
|
||||||
pub mod medication_record_service;
|
pub mod medication_record_service;
|
||||||
pub mod doctor_service;
|
pub mod doctor_service;
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ mod m20260428_000091_dead_letter_events;
|
|||||||
mod m20260429_000092_device_readings_metric;
|
mod m20260429_000092_device_readings_metric;
|
||||||
mod m20260429_000093_trend_analysis_prompt_v2;
|
mod m20260429_000093_trend_analysis_prompt_v2;
|
||||||
mod m20260429_000094_device_readings_unique_constraint;
|
mod m20260429_000094_device_readings_unique_constraint;
|
||||||
|
mod m20260429_000095_seed_alert_device_menus;
|
||||||
|
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
@@ -195,6 +196,7 @@ impl MigratorTrait for Migrator {
|
|||||||
Box::new(m20260429_000092_device_readings_metric::Migration),
|
Box::new(m20260429_000092_device_readings_metric::Migration),
|
||||||
Box::new(m20260429_000093_trend_analysis_prompt_v2::Migration),
|
Box::new(m20260429_000093_trend_analysis_prompt_v2::Migration),
|
||||||
Box::new(m20260429_000094_device_readings_unique_constraint::Migration),
|
Box::new(m20260429_000094_device_readings_unique_constraint::Migration),
|
||||||
|
Box::new(m20260429_000095_seed_alert_device_menus::Migration),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
//! 补充告警和设备管理菜单种子数据
|
||||||
|
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
let db = manager.get_connection();
|
||||||
|
|
||||||
|
// 获取默认租户 ID
|
||||||
|
let result = db.query_one(sea_orm::Statement::from_string(
|
||||||
|
sea_orm::DatabaseBackend::Postgres,
|
||||||
|
"SELECT id::text FROM tenant LIMIT 1".to_string(),
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let tid = match result {
|
||||||
|
Some(row) => row.try_get_by_index::<String>(0).unwrap_or_default(),
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let sys = "00000000-0000-0000-0000-000000000000";
|
||||||
|
let d3 = "a0000000-0000-0000-0000-000000000003"; // 健康管理目录
|
||||||
|
|
||||||
|
// 告警相关菜单(排在 AI 用量统计之后)
|
||||||
|
insert_menu(db, &tid, d3, "b0000003-0000-0000-0000-000000000016", "告警仪表盘", "/health/alert-dashboard", "AlertOutlined", 15, sys).await?;
|
||||||
|
insert_menu(db, &tid, d3, "b0000003-0000-0000-0000-000000000017", "告警列表", "/health/alerts", "BellOutlined", 16, sys).await?;
|
||||||
|
insert_menu(db, &tid, d3, "b0000003-0000-0000-0000-000000000018", "告警规则", "/health/alert-rules", "ControlOutlined", 17, sys).await?;
|
||||||
|
|
||||||
|
// 设备管理菜单
|
||||||
|
insert_menu(db, &tid, d3, "b0000003-0000-0000-0000-000000000019", "设备管理", "/health/devices", "ApiOutlined", 18, sys).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
let db = manager.get_connection();
|
||||||
|
|
||||||
|
let ids = [
|
||||||
|
"b0000003-0000-0000-0000-000000000016",
|
||||||
|
"b0000003-0000-0000-0000-000000000017",
|
||||||
|
"b0000003-0000-0000-0000-000000000018",
|
||||||
|
"b0000003-0000-0000-0000-000000000019",
|
||||||
|
];
|
||||||
|
|
||||||
|
for id in &ids {
|
||||||
|
db.execute(sea_orm::Statement::from_string(
|
||||||
|
sea_orm::DatabaseBackend::Postgres,
|
||||||
|
format!("DELETE FROM menus WHERE id = '{id}'"),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn insert_menu(
|
||||||
|
db: &sea_orm_migration::SchemaManagerConnection<'_>,
|
||||||
|
tenant_id: &str,
|
||||||
|
parent_id: &str,
|
||||||
|
id: &str,
|
||||||
|
title: &str,
|
||||||
|
path: &str,
|
||||||
|
icon: &str,
|
||||||
|
sort: i32,
|
||||||
|
sys: &str,
|
||||||
|
) -> Result<(), DbErr> {
|
||||||
|
let esc_title = title.replace('\'', "''");
|
||||||
|
db.execute(sea_orm::Statement::from_string(
|
||||||
|
sea_orm::DatabaseBackend::Postgres,
|
||||||
|
format!(
|
||||||
|
"INSERT INTO menus (id, tenant_id, parent_id, title, path, icon, sort_order, visible, menu_type, permission, created_at, updated_at, created_by, updated_by, deleted_at, version) \
|
||||||
|
VALUES ('{id}', '{tenant_id}', '{parent_id}', '{esc_title}', '{path}', '{icon}', {sort}, true, 'menu', NULL, NOW(), NOW(), '{sys}', '{sys}', NULL, 1) \
|
||||||
|
ON CONFLICT (id) DO NOTHING"
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user