feat(web): 家庭健康代理 + 知情同意 Web UI — Phase 2c
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

家庭代理:关联患者列表 + 健康摘要查看 + 授权/撤销访问
知情同意:患者范围 CRUD 列表页(类型/范围/签署/撤销)
This commit is contained in:
iven
2026-05-05 00:02:39 +08:00
parent 0774dd75ad
commit 888fa108ef
6 changed files with 667 additions and 0 deletions

View File

@@ -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() {
<Route path="/health/ble-gateways/:id" element={<BleGatewayDetail />} />
<Route path="/health/critical-value-thresholds" element={<CriticalValueThresholdList />} />
<Route path="/health/diagnoses" element={<DiagnosisList />} />
<Route path="/health/family-proxy" element={<FamilyProxyPage />} />
<Route path="/health/consents" element={<ConsentList />} />
{/* 内容管理 */}
<Route path="/health/articles" element={<ArticleManageList />} />
<Route path="/health/articles/new" element={<ArticleEditor />} />

View File

@@ -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<string, string> = {
active: 'green',
revoked: 'red',
expired: 'default',
};
export const CONSENT_STATUS_LABEL: Record<string, string> = {
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<Consent>;
}>(`/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;
},
};

View File

@@ -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<string, unknown>;
active_care_plan?: Record<string, unknown>;
recent_alerts_count: number;
next_appointment?: Record<string, unknown>;
}
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<string, string> = {
granted: 'green',
pending: 'orange',
revoked: 'red',
expired: 'default',
};
export const ACCESS_LEVEL_LABEL: Record<string, string> = Object.fromEntries(
ACCESS_LEVEL_OPTIONS.map((o) => [o.value, o.label]),
);
export const CONSENT_STATUS_LABEL: Record<string, string> = 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;
},
};

View File

@@ -120,6 +120,8 @@ const routeTitleFallback: Record<string, string> = {
'/health/ble-gateways/:id': '网关详情',
'/health/critical-value-thresholds': '危急值阈值',
'/health/diagnoses': '诊断记录',
'/health/family-proxy': '家庭健康代理',
'/health/consents': '知情同意管理',
};
function getTitleFromMenus(path: string, menus: MenuInfo[]): string | undefined {

View File

@@ -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<Consent[]>([]);
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<Consent> = 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) => (
<Tag color={CONSENT_STATUS_COLOR[v] ?? 'default'}>{CONSENT_STATUS_LABEL[v] ?? v}</Tag>
),
},
{
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' ? (
<Popconfirm title="确定撤销此知情同意?" onConfirm={() => handleRevoke(record)}>
<Button size="small" danger></Button>
</Popconfirm>
) : null
),
},
], [patientId, page]);
if (!hasPermission) {
return <Result status="403" title="权限不足" subTitle="您没有查看知情同意记录的权限" />;
}
return (
<PageContainer
title="知情同意管理"
actions={patientId ? <Button type="primary" onClick={handleCreate}></Button> : undefined}
>
<Space style={{ marginBottom: 16 }} wrap>
<Input.Search
placeholder="输入患者 ID 搜索"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onSearch={handleSearch}
style={{ width: 360 }}
enterButton="查询"
/>
{patientId && (
<Tag color="blue">: {patientId}</Tag>
)}
</Space>
{!patientId ? (
<Result title="请输入患者 ID 查询知情同意记录" />
) : (
<Table<Consent>
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
pagination={{
current: page,
pageSize,
total,
showTotal: (t) => `${t}`,
onChange: (p) => fetchData(patientId, p),
}}
/>
)}
<Modal
title="签署知情同意"
open={createModalOpen}
onOk={handleSubmit}
onCancel={() => setCreateModalOpen(false)}
confirmLoading={submitting}
width={560}
>
<Form form={form} layout="vertical">
<Form.Item name="patient_id" label="患者 ID" rules={[{ required: true }]}>
<Input disabled />
</Form.Item>
<Space style={{ width: '100%' }} size="middle">
<Form.Item name="consent_type" label="同意类型" rules={[{ required: true, message: '请选择类型' }]} style={{ width: 240 }}>
<Select options={CONSENT_TYPE_OPTIONS} placeholder="选择类型" />
</Form.Item>
<Form.Item name="consent_scope" label="同意范围" rules={[{ required: true, message: '请选择范围' }]} style={{ width: 240 }}>
<Select options={CONSENT_SCOPE_OPTIONS} placeholder="选择范围" />
</Form.Item>
</Space>
<Space style={{ width: '100%' }} size="middle">
<Form.Item name="consent_method" label="签署方式" style={{ width: 240 }}>
<Select
placeholder="选择方式"
allowClear
options={[
{ label: '纸质签署', value: 'paper' },
{ label: '电子签署', value: 'electronic' },
{ label: '口头同意', value: 'verbal' },
]}
/>
</Form.Item>
<Form.Item name="expiry_date" label="到期日期" style={{ width: 240 }}>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
</Space>
<Form.Item name="witness_name" label="见证人">
<Input placeholder="见证人姓名(可选)" />
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
</PageContainer>
);
}

View File

@@ -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<FamilyPatientSummary[]>([]);
const [loading, setLoading] = useState(false);
const [loaded, setLoaded] = useState(false);
const [selectedPatientId, setSelectedPatientId] = useState<string | null>(null);
const [healthSummary, setHealthSummary] = useState<FamilyHealthSummary | null>(null);
const [summaryLoading, setSummaryLoading] = useState(false);
const [grantModalOpen, setGrantModalOpen] = useState(false);
const [grantTarget, setGrantTarget] = useState<FamilyPatientSummary | null>(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<FamilyPatientSummary> = [
{
title: '患者姓名',
dataIndex: 'patient_name',
width: 120,
},
{
title: '关系',
dataIndex: 'relationship',
width: 80,
},
{
title: '同意状态',
dataIndex: 'consent_status',
width: 100,
render: (v: string) => (
<Tag color={CONSENT_STATUS_COLOR[v] ?? 'default'}>{CONSENT_STATUS_LABEL[v] ?? v}</Tag>
),
},
{
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) => (
<Space>
<Button size="small" onClick={() => handleViewSummary(record.patient_id)}>
</Button>
{record.consent_status !== 'granted' && (
<Button size="small" type="primary" onClick={() => handleGrantAccess(record)}>
访
</Button>
)}
{record.consent_status === 'granted' && (
<Popconfirm title="确定撤销访问权限?" onConfirm={() => handleRevokeAccess(record)}>
<Button size="small" danger></Button>
</Popconfirm>
)}
</Space>
),
},
];
return (
<PageContainer
title="家庭健康代理"
actions={<Button type="primary" onClick={fetchPatients} loading={loading}>{loaded ? '刷新' : '加载关联患者'}</Button>}
>
{loaded && patients.length === 0 ? (
<Result title="暂无关联患者" subTitle="您尚未被添加为任何患者的家庭成员" />
) : (
<>
<Table<FamilyPatientSummary>
rowKey="family_member_id"
columns={patientColumns}
dataSource={patients}
loading={loading}
pagination={false}
/>
{selectedPatientId && (
<Card title="健康摘要" style={{ marginTop: 24 }} loading={summaryLoading}>
{healthSummary && (
<Descriptions bordered column={2}>
<Descriptions.Item label="患者">{healthSummary.patient_name}</Descriptions.Item>
<Descriptions.Item label="近期告警数">{healthSummary.recent_alerts_count}</Descriptions.Item>
<Descriptions.Item label="最新体征" span={2}>
{healthSummary.latest_vital_signs
? JSON.stringify(healthSummary.latest_vital_signs, null, 2)
: '暂无数据'}
</Descriptions.Item>
<Descriptions.Item label="活跃护理计划" span={2}>
{healthSummary.active_care_plan
? JSON.stringify(healthSummary.active_care_plan, null, 2)
: '暂无'}
</Descriptions.Item>
<Descriptions.Item label="下次预约" span={2}>
{healthSummary.next_appointment
? JSON.stringify(healthSummary.next_appointment, null, 2)
: '暂无'}
</Descriptions.Item>
</Descriptions>
)}
</Card>
)}
</>
)}
<Modal
title={`授权访问 — ${grantTarget?.patient_name ?? ''}`}
open={grantModalOpen}
onOk={handleSubmitGrant}
onCancel={() => setGrantModalOpen(false)}
confirmLoading={grantSubmitting}
width={400}
>
<Form form={grantForm} layout="vertical">
<Form.Item name="access_level" label="访问级别" rules={[{ required: true }]}>
<Select options={ACCESS_LEVEL_OPTIONS} />
</Form.Item>
</Form>
</Modal>
</PageContainer>
);
}