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