feat(health): Web 管理端设备数据集成补全 — Phase 2
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

- 新增告警三页面(仪表盘/列表/规则)+ 设备管理菜单种子数据
- 新增设备管理后端 API(GET /devices + DELETE /devices/{id})
- 新增设备数据查看组件 DeviceReadingsTab(原始数据 + 小时聚合)
- 新增设备管理页面 DeviceManage(列表/筛选/解绑)
- 患者详情页新增设备数据 Tab
This commit is contained in:
iven
2026-04-29 06:28:30 +08:00
parent f6ccb8a35c
commit cac61637ce
14 changed files with 784 additions and 1 deletions

View File

@@ -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 />} />

View 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),
};

View File

@@ -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 {

View 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>
);
}

View File

@@ -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} /> },
]}

View 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>
);
}