feat(web): 家庭健康代理 + 知情同意 Web UI — Phase 2c
家庭代理:关联患者列表 + 健康摘要查看 + 授权/撤销访问 知情同意:患者范围 CRUD 列表页(类型/范围/签署/撤销)
This commit is contained in:
249
apps/web/src/pages/health/ConsentList.tsx
Normal file
249
apps/web/src/pages/health/ConsentList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
211
apps/web/src/pages/health/FamilyProxyPage.tsx
Normal file
211
apps/web/src/pages/health/FamilyProxyPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user