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

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