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 AlertDashboard = lazy(() => import('./pages/health/AlertDashboard'));
|
||||
const AlertRuleList = lazy(() => import('./pages/health/AlertRuleList'));
|
||||
const DeviceManage = lazy(() => import('./pages/health/DeviceManage'));
|
||||
|
||||
// 内容管理
|
||||
const ArticleManageList = lazy(() => import('./pages/health/ArticleManageList'));
|
||||
@@ -252,6 +253,7 @@ export default function App() {
|
||||
<Route path="/health/alerts" element={<AlertList />} />
|
||||
<Route path="/health/alert-dashboard" element={<AlertDashboard />} />
|
||||
<Route path="/health/alert-rules" element={<AlertRuleList />} />
|
||||
<Route path="/health/devices" element={<DeviceManage />} />
|
||||
{/* 内容管理 */}
|
||||
<Route path="/health/articles" element={<ArticleManageList />} />
|
||||
<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/alert-dashboard': '告警仪表盘',
|
||||
'/health/alert-rules': '告警规则',
|
||||
'/health/devices': '设备管理',
|
||||
};
|
||||
|
||||
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 { HealthRecordsTab } from './components/HealthRecordsTab';
|
||||
import { FollowUpTab } from './components/FollowUpTab';
|
||||
import { DeviceReadingsTab } from './components/DeviceReadingsTab';
|
||||
import { GENDER_OPTIONS, BLOOD_TYPE_OPTIONS } from '../../constants/health';
|
||||
import { useThemeMode } from '../../hooks/useThemeMode';
|
||||
|
||||
@@ -276,6 +277,7 @@ export default function PatientDetail() {
|
||||
size="small"
|
||||
items={[
|
||||
{ key: 'vital', label: '体征数据', children: <VitalSignsTab patientId={id} /> },
|
||||
{ key: 'device', label: '设备数据', children: <DeviceReadingsTab patientId={id} /> },
|
||||
{ key: 'lab', label: '化验报告', children: <LabReportsTab 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user