From 888fa108efbe16e0f9c02d8e74d41fe2920718db Mon Sep 17 00:00:00 2001 From: iven Date: Tue, 5 May 2026 00:02:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E5=AE=B6=E5=BA=AD=E5=81=A5?= =?UTF-8?q?=E5=BA=B7=E4=BB=A3=E7=90=86=20+=20=E7=9F=A5=E6=83=85=E5=90=8C?= =?UTF-8?q?=E6=84=8F=20Web=20UI=20=E2=80=94=20Phase=202c?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 家庭代理:关联患者列表 + 健康摘要查看 + 授权/撤销访问 知情同意:患者范围 CRUD 列表页(类型/范围/签署/撤销) --- apps/web/src/App.tsx | 4 + apps/web/src/api/health/consents.ts | 92 +++++++ apps/web/src/api/health/familyProxy.ts | 109 ++++++++ apps/web/src/layouts/MainLayout.tsx | 2 + apps/web/src/pages/health/ConsentList.tsx | 249 ++++++++++++++++++ apps/web/src/pages/health/FamilyProxyPage.tsx | 211 +++++++++++++++ 6 files changed, 667 insertions(+) create mode 100644 apps/web/src/api/health/consents.ts create mode 100644 apps/web/src/api/health/familyProxy.ts create mode 100644 apps/web/src/pages/health/ConsentList.tsx create mode 100644 apps/web/src/pages/health/FamilyProxyPage.tsx diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index b52547e..715a4f3 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -62,6 +62,8 @@ const BleGatewayList = lazy(() => import('./pages/health/BleGatewayList')); const BleGatewayDetail = lazy(() => import('./pages/health/BleGatewayDetail')); const CriticalValueThresholdList = lazy(() => import('./pages/health/CriticalValueThresholdList')); const DiagnosisList = lazy(() => import('./pages/health/DiagnosisList')); +const FamilyProxyPage = lazy(() => import('./pages/health/FamilyProxyPage')); +const ConsentList = lazy(() => import('./pages/health/ConsentList')); // 内容管理 const ArticleManageList = lazy(() => import('./pages/health/ArticleManageList')); @@ -289,6 +291,8 @@ export default function App() { } /> } /> } /> + } /> + } /> {/* 内容管理 */} } /> } /> diff --git a/apps/web/src/api/health/consents.ts b/apps/web/src/api/health/consents.ts new file mode 100644 index 0000000..94ae960 --- /dev/null +++ b/apps/web/src/api/health/consents.ts @@ -0,0 +1,92 @@ +import client from '../client'; +import type { PaginatedResponse } from '../types'; + +// --- Types --- + +export interface Consent { + id: string; + patient_id: string; + consent_type: string; + consent_scope: string; + status: string; + granted_at?: string; + revoked_at?: string; + expiry_date?: string; + consent_method?: string; + witness_name?: string; + notes?: string; + created_at: string; + updated_at: string; + version: number; +} + +export interface CreateConsentReq { + patient_id: string; + consent_type: string; + consent_scope: string; + expiry_date?: string; + consent_method?: string; + witness_name?: string; + notes?: string; +} + +export interface RevokeConsentReq { + notes?: string; + version: number; +} + +// --- Constants --- + +export const CONSENT_TYPE_OPTIONS = [ + { label: '治疗同意', value: 'treatment' }, + { label: '数据共享', value: 'data_sharing' }, + { label: '隐私政策', value: 'privacy' }, + { label: '研究参与', value: 'research' }, +]; + +export const CONSENT_SCOPE_OPTIONS = [ + { label: '全部', value: 'all' }, + { label: '健康数据', value: 'health_data' }, + { label: '基本信息', value: 'basic_info' }, + { label: '体检报告', value: 'examination' }, +]; + +export const CONSENT_STATUS_COLOR: Record = { + active: 'green', + revoked: 'red', + expired: 'default', +}; + +export const CONSENT_STATUS_LABEL: Record = { + active: '生效中', + revoked: '已撤销', + expired: '已过期', +}; + +// --- API --- + +export const consentApi = { + list: async (patientId: string, params?: { page?: number; page_size?: number }) => { + const { data } = await client.get<{ + success: boolean; + data: PaginatedResponse; + }>(`/health/patients/${patientId}/consents`, { params }); + return data.data; + }, + + grant: async (req: CreateConsentReq) => { + const { data } = await client.post<{ + success: boolean; + data: Consent; + }>('/health/consents', req); + return data.data; + }, + + revoke: async (consentId: string, req: RevokeConsentReq) => { + const { data } = await client.put<{ + success: boolean; + data: Consent; + }>(`/health/consents/${consentId}/revoke`, req); + return data.data; + }, +}; diff --git a/apps/web/src/api/health/familyProxy.ts b/apps/web/src/api/health/familyProxy.ts new file mode 100644 index 0000000..b9e3a39 --- /dev/null +++ b/apps/web/src/api/health/familyProxy.ts @@ -0,0 +1,109 @@ +import client from '../client'; + +// --- Types --- + +export interface FamilyMember { + id: string; + patient_id: string; + name: string; + relationship: string; + phone?: string; + birth_date?: string; + notes?: string; + user_id?: string; + consent_status: string; + access_level: string; + consented_at?: string; + created_at: string; + updated_at: string; + version: number; +} + +export interface FamilyPatientSummary { + family_member_id: string; + patient_id: string; + patient_name: string; + relationship: string; + consent_status: string; + access_level: string; + consented_at?: string; +} + +export interface FamilyHealthSummary { + patient_id: string; + patient_name: string; + latest_vital_signs?: Record; + active_care_plan?: Record; + recent_alerts_count: number; + next_appointment?: Record; +} + +export interface GrantAccessReq { + access_level: string; +} + +// --- Constants --- + +export const CONSENT_STATUS_OPTIONS = [ + { label: '已同意', value: 'granted' }, + { label: '待确认', value: 'pending' }, + { label: '已撤销', value: 'revoked' }, + { label: '已过期', value: 'expired' }, +]; + +export const ACCESS_LEVEL_OPTIONS = [ + { label: '完全访问', value: 'full' }, + { label: '只读', value: 'read_only' }, + { label: '摘要', value: 'summary' }, +]; + +export const CONSENT_STATUS_COLOR: Record = { + granted: 'green', + pending: 'orange', + revoked: 'red', + expired: 'default', +}; + +export const ACCESS_LEVEL_LABEL: Record = Object.fromEntries( + ACCESS_LEVEL_OPTIONS.map((o) => [o.value, o.label]), +); + +export const CONSENT_STATUS_LABEL: Record = Object.fromEntries( + CONSENT_STATUS_OPTIONS.map((o) => [o.value, o.label]), +); + +// --- API --- + +export const familyProxyApi = { + grantAccess: async (patientId: string, familyMemberId: string, req: GrantAccessReq, version: number) => { + const { data } = await client.post<{ + success: boolean; + data: FamilyMember; + }>(`/health/patients/${patientId}/family-members/${familyMemberId}/grant-access?version=${version}`, req); + return data.data; + }, + + revokeAccess: async (patientId: string, familyMemberId: string, version: number) => { + const { data } = await client.put<{ + success: boolean; + data: FamilyMember; + }>(`/health/patients/${patientId}/family-members/${familyMemberId}/revoke-access?version=${version}`); + return data.data; + }, + + listMyPatients: async () => { + const { data } = await client.get<{ + success: boolean; + data: FamilyPatientSummary[]; + }>('/health/family/my-patients'); + return data.data; + }, + + getHealthSummary: async (patientId: string) => { + const { data } = await client.get<{ + success: boolean; + data: FamilyHealthSummary; + }>(`/health/family/patients/${patientId}/health-summary`); + return data.data; + }, +}; diff --git a/apps/web/src/layouts/MainLayout.tsx b/apps/web/src/layouts/MainLayout.tsx index ab5b7f2..29362c7 100644 --- a/apps/web/src/layouts/MainLayout.tsx +++ b/apps/web/src/layouts/MainLayout.tsx @@ -120,6 +120,8 @@ const routeTitleFallback: Record = { '/health/ble-gateways/:id': '网关详情', '/health/critical-value-thresholds': '危急值阈值', '/health/diagnoses': '诊断记录', + '/health/family-proxy': '家庭健康代理', + '/health/consents': '知情同意管理', }; function getTitleFromMenus(path: string, menus: MenuInfo[]): string | undefined { diff --git a/apps/web/src/pages/health/ConsentList.tsx b/apps/web/src/pages/health/ConsentList.tsx new file mode 100644 index 0000000..69eea93 --- /dev/null +++ b/apps/web/src/pages/health/ConsentList.tsx @@ -0,0 +1,249 @@ +import { useState, useCallback, useMemo } from 'react'; +import { + Button, DatePicker, Form, Input, message, Modal, Popconfirm, + Result, Select, Space, Table, Tag, +} from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import dayjs from 'dayjs'; + +import { + consentApi, + type Consent, + type CreateConsentReq, + CONSENT_TYPE_OPTIONS, + CONSENT_SCOPE_OPTIONS, + CONSENT_STATUS_COLOR, + CONSENT_STATUS_LABEL, +} from '../../api/health/consents'; +import { PageContainer } from '../../components/PageContainer'; +import { usePermission } from '../../hooks/usePermission'; + +export default function ConsentList() { + const { hasPermission } = usePermission('health.consent.list'); + + const [data, setData] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(false); + + const [patientId, setPatientId] = useState(''); + const [searchInput, setSearchInput] = useState(''); + + const [createModalOpen, setCreateModalOpen] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [form] = Form.useForm(); + + const pageSize = 20; + + const fetchData = useCallback(async (pid: string, p: number) => { + if (!pid) return; + setLoading(true); + try { + const resp = await consentApi.list(pid, { page: p, page_size: pageSize }); + setData(resp.data); + setTotal(resp.total); + setPage(p); + } catch { + message.error('加载知情同意记录失败'); + } finally { + setLoading(false); + } + }, []); + + const handleSearch = () => { + const pid = searchInput.trim(); + if (!pid) { + message.warning('请输入患者 ID'); + return; + } + setPatientId(pid); + fetchData(pid, 1); + }; + + const handleCreate = () => { + form.resetFields(); + form.setFieldsValue({ patient_id: patientId }); + setCreateModalOpen(true); + }; + + const handleSubmit = async () => { + try { + const values = await form.validateFields(); + setSubmitting(true); + const req: CreateConsentReq = { + ...values, + expiry_date: values.expiry_date?.format('YYYY-MM-DD'), + }; + await consentApi.grant(req); + message.success('知情同意已签署'); + setCreateModalOpen(false); + fetchData(patientId, page); + } catch { + // validation + } finally { + setSubmitting(false); + } + }; + + const handleRevoke = async (record: Consent) => { + try { + await consentApi.revoke(record.id, { version: record.version }); + message.success('知情同意已撤销'); + fetchData(patientId, page); + } catch { + message.error('撤销失败'); + } + }; + + const columns: ColumnsType = useMemo(() => [ + { + title: '同意类型', + dataIndex: 'consent_type', + width: 110, + render: (v: string) => { + const label = CONSENT_TYPE_OPTIONS.find((o) => o.value === v)?.label; + return label ?? v; + }, + }, + { + title: '同意范围', + dataIndex: 'consent_scope', + width: 110, + render: (v: string) => { + const label = CONSENT_SCOPE_OPTIONS.find((o) => o.value === v)?.label; + return label ?? v; + }, + }, + { + title: '状态', + dataIndex: 'status', + width: 90, + render: (v: string) => ( + {CONSENT_STATUS_LABEL[v] ?? v} + ), + }, + { + title: '签署方式', + dataIndex: 'consent_method', + width: 100, + render: (v: string) => v ?? '-', + }, + { + title: '见证人', + dataIndex: 'witness_name', + width: 100, + render: (v: string) => v ?? '-', + }, + { + title: '签署时间', + dataIndex: 'granted_at', + width: 170, + render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-', + }, + { + title: '到期日', + dataIndex: 'expiry_date', + width: 110, + render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD') : '无限期', + }, + { + title: '操作', + width: 100, + render: (_, record) => ( + record.status === 'active' ? ( + handleRevoke(record)}> + + + ) : null + ), + }, + ], [patientId, page]); + + if (!hasPermission) { + return ; + } + + return ( + 签署同意 : undefined} + > + + setSearchInput(e.target.value)} + onSearch={handleSearch} + style={{ width: 360 }} + enterButton="查询" + /> + {patientId && ( + 当前患者: {patientId} + )} + + + {!patientId ? ( + + ) : ( + + rowKey="id" + columns={columns} + dataSource={data} + loading={loading} + pagination={{ + current: page, + pageSize, + total, + showTotal: (t) => `共 ${t} 条`, + onChange: (p) => fetchData(patientId, p), + }} + /> + )} + + setCreateModalOpen(false)} + confirmLoading={submitting} + width={560} + > +
+ + + + + + + + + + + + + + + + +
+
+ ); +} diff --git a/apps/web/src/pages/health/FamilyProxyPage.tsx b/apps/web/src/pages/health/FamilyProxyPage.tsx new file mode 100644 index 0000000..02732e9 --- /dev/null +++ b/apps/web/src/pages/health/FamilyProxyPage.tsx @@ -0,0 +1,211 @@ +import { useState, useCallback } from 'react'; +import { + Button, Card, Descriptions, Form, Input, message, Modal, Popconfirm, Result, Select, Space, Table, Tag, +} from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import dayjs from 'dayjs'; + +import { + familyProxyApi, + type FamilyPatientSummary, + type FamilyHealthSummary, + ACCESS_LEVEL_OPTIONS, + ACCESS_LEVEL_LABEL, + CONSENT_STATUS_LABEL, + CONSENT_STATUS_COLOR, +} from '../../api/health/familyProxy'; +import { PageContainer } from '../../components/PageContainer'; + +export default function FamilyProxyPage() { + const [patients, setPatients] = useState([]); + const [loading, setLoading] = useState(false); + const [loaded, setLoaded] = useState(false); + + const [selectedPatientId, setSelectedPatientId] = useState(null); + const [healthSummary, setHealthSummary] = useState(null); + const [summaryLoading, setSummaryLoading] = useState(false); + + const [grantModalOpen, setGrantModalOpen] = useState(false); + const [grantTarget, setGrantTarget] = useState(null); + const [grantSubmitting, setGrantSubmitting] = useState(false); + const [grantForm] = Form.useForm(); + + const fetchPatients = useCallback(async () => { + setLoading(true); + try { + const list = await familyProxyApi.listMyPatients(); + setPatients(list); + setLoaded(true); + } catch { + message.error('加载关联患者失败'); + } finally { + setLoading(false); + } + }, []); + + const handleViewSummary = async (patientId: string) => { + setSelectedPatientId(patientId); + setSummaryLoading(true); + try { + const summary = await familyProxyApi.getHealthSummary(patientId); + setHealthSummary(summary); + } catch { + message.error('加载健康摘要失败'); + } finally { + setSummaryLoading(false); + } + }; + + const handleGrantAccess = (record: FamilyPatientSummary) => { + setGrantTarget(record); + grantForm.resetFields(); + grantForm.setFieldsValue({ access_level: 'read_only' }); + setGrantModalOpen(true); + }; + + const handleRevokeAccess = async (record: FamilyPatientSummary) => { + try { + await familyProxyApi.revokeAccess(record.patient_id, record.family_member_id, 0); + message.success('已撤销访问权限'); + fetchPatients(); + } catch { + message.error('撤销失败'); + } + }; + + const handleSubmitGrant = async () => { + try { + const values = await grantForm.validateFields(); + setGrantSubmitting(true); + await familyProxyApi.grantAccess( + grantTarget!.patient_id, + grantTarget!.family_member_id, + values, + 0, + ); + message.success('访问权限已授权'); + setGrantModalOpen(false); + fetchPatients(); + } catch { + // validation + } finally { + setGrantSubmitting(false); + } + }; + + const patientColumns: ColumnsType = [ + { + title: '患者姓名', + dataIndex: 'patient_name', + width: 120, + }, + { + title: '关系', + dataIndex: 'relationship', + width: 80, + }, + { + title: '同意状态', + dataIndex: 'consent_status', + width: 100, + render: (v: string) => ( + {CONSENT_STATUS_LABEL[v] ?? v} + ), + }, + { + title: '访问级别', + dataIndex: 'access_level', + width: 100, + render: (v: string) => ACCESS_LEVEL_LABEL[v] ?? v ?? '-', + }, + { + title: '授权时间', + dataIndex: 'consented_at', + width: 170, + render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-', + }, + { + title: '操作', + width: 240, + render: (_, record) => ( + + + {record.consent_status !== 'granted' && ( + + )} + {record.consent_status === 'granted' && ( + handleRevokeAccess(record)}> + + + )} + + ), + }, + ]; + + return ( + {loaded ? '刷新' : '加载关联患者'}} + > + {loaded && patients.length === 0 ? ( + + ) : ( + <> + + rowKey="family_member_id" + columns={patientColumns} + dataSource={patients} + loading={loading} + pagination={false} + /> + + {selectedPatientId && ( + + {healthSummary && ( + + {healthSummary.patient_name} + {healthSummary.recent_alerts_count} + + {healthSummary.latest_vital_signs + ? JSON.stringify(healthSummary.latest_vital_signs, null, 2) + : '暂无数据'} + + + {healthSummary.active_care_plan + ? JSON.stringify(healthSummary.active_care_plan, null, 2) + : '暂无'} + + + {healthSummary.next_appointment + ? JSON.stringify(healthSummary.next_appointment, null, 2) + : '暂无'} + + + )} + + )} + + )} + + setGrantModalOpen(false)} + confirmLoading={grantSubmitting} + width={400} + > +
+ +