Compare commits
3 Commits
82cea6a108
...
13f553590b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13f553590b | ||
|
|
931edc3025 | ||
|
|
d8735eb45c |
@@ -12,8 +12,26 @@ const TYPE_LABELS: Record<string, string> = {
|
||||
report_summary_generation: '报告摘要',
|
||||
};
|
||||
|
||||
/** 移除危险的 HTML 标签和事件属性,防止 XSS */
|
||||
function sanitizeHtml(html: string): string {
|
||||
return html
|
||||
// 移除 <script> 标签及其内容
|
||||
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
||||
// 移除 <iframe>, <object>, <embed>, <form>, <input>, <textarea>, <style> 标签
|
||||
.replace(/<\/?(?:iframe|object|embed|form|input|textarea|style)\b[^>]*>/gi, '')
|
||||
// 移除 <link> 和 <meta> 标签
|
||||
.replace(/<\/?(?:link|meta)\b[^>]*>/gi, '')
|
||||
// 移除所有 on* 事件属性 (onclick, onerror, onload 等)
|
||||
.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '')
|
||||
// 移除 javascript: 和 data: 协议的 href/src 属性
|
||||
.replace(/(href|src)\s*=\s*(?:"javascript:[^"]*"|'javascript:[^']*')/gi, '')
|
||||
.replace(/(href|src)\s*=\s*(?:"data:[^"]*"|'data:[^']*')/gi, '');
|
||||
}
|
||||
|
||||
function markdownToHtml(md: string): string {
|
||||
return md
|
||||
// 先转义 markdown 中可能存在的原始 HTML 标签
|
||||
const escaped = sanitizeHtml(md);
|
||||
return escaped
|
||||
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
||||
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
||||
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
||||
|
||||
@@ -37,8 +37,6 @@ export interface Message {
|
||||
|
||||
export interface CreateMessageReq {
|
||||
session_id: string;
|
||||
sender_id: string;
|
||||
sender_role: string;
|
||||
content_type?: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
380
apps/web/src/pages/health/DialysisManageList.tsx
Normal file
380
apps/web/src/pages/health/DialysisManageList.tsx
Normal file
@@ -0,0 +1,380 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { Table, Tag, Button, Modal, Form, InputNumber, DatePicker, Select, message, Popconfirm, Space, Input } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, AuditOutlined } from '@ant-design/icons';
|
||||
import { dayjs } from '../../utils/dayjs';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { dialysisApi } from '../../api/health/dialysis';
|
||||
import type { DialysisRecord } from '../../api/health/dialysis';
|
||||
import { patientApi } from '../../api/health/patients';
|
||||
import { usePaginatedData } from '../../hooks/usePaginatedData';
|
||||
import { AuthButton } from '../../components/AuthButton';
|
||||
import { handleApiError } from '../../api/client';
|
||||
|
||||
const DIALYSIS_TYPE_MAP: Record<string, { color: string; label: string }> = {
|
||||
HD: { color: 'blue', label: 'HD' },
|
||||
HDF: { color: 'green', label: 'HDF' },
|
||||
HF: { color: 'purple', label: 'HF' },
|
||||
};
|
||||
|
||||
const STATUS_MAP: Record<string, { color: string; label: string }> = {
|
||||
pending: { color: 'orange', label: '待审核' },
|
||||
reviewed: { color: 'green', label: '已审核' },
|
||||
};
|
||||
|
||||
interface PatientOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default function DialysisManageList() {
|
||||
const [selectedPatientId, setSelectedPatientId] = useState<string | null>(null);
|
||||
const [patientOptions, setPatientOptions] = useState<PatientOption[]>([]);
|
||||
const [patientSearch, setPatientSearch] = useState('');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingRecord, setEditingRecord] = useState<DialysisRecord | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [reviewOpen, setReviewOpen] = useState(false);
|
||||
const [reviewRecord, setReviewRecord] = useState<DialysisRecord | null>(null);
|
||||
const [reviewSubmitting, setReviewSubmitting] = useState(false);
|
||||
|
||||
const searchPatients = async (keyword: string) => {
|
||||
if (!keyword.trim()) {
|
||||
setPatientOptions([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await patientApi.list({ search: keyword, page: 1, page_size: 20 });
|
||||
setPatientOptions(
|
||||
(res.data || []).map((p) => ({ id: p.id, name: p.name })),
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const fetcher = useCallback(
|
||||
async (page: number, pageSize: number) => {
|
||||
if (!selectedPatientId) return { data: [], total: 0, page: 1 };
|
||||
return dialysisApi.listRecords(selectedPatientId, { page, page_size: pageSize });
|
||||
},
|
||||
[selectedPatientId],
|
||||
);
|
||||
|
||||
const { data, total, page, loading, refresh } = usePaginatedData<DialysisRecord>(fetcher, 10);
|
||||
|
||||
const openCreateModal = () => {
|
||||
if (!selectedPatientId) {
|
||||
message.warning('请先选择患者');
|
||||
return;
|
||||
}
|
||||
setEditingRecord(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ dialysis_type: 'HD' });
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditModal = (record: DialysisRecord) => {
|
||||
setEditingRecord(record);
|
||||
form.setFieldsValue({
|
||||
dialysis_date: dayjs(record.dialysis_date),
|
||||
start_time: record.start_time,
|
||||
end_time: record.end_time,
|
||||
dialysis_type: record.dialysis_type,
|
||||
pre_weight: record.pre_weight,
|
||||
post_weight: record.post_weight,
|
||||
dry_weight: record.dry_weight,
|
||||
pre_bp_systolic: record.pre_bp_systolic,
|
||||
pre_bp_diastolic: record.pre_bp_diastolic,
|
||||
post_bp_systolic: record.post_bp_systolic,
|
||||
post_bp_diastolic: record.post_bp_diastolic,
|
||||
pre_heart_rate: record.pre_heart_rate,
|
||||
post_heart_rate: record.post_heart_rate,
|
||||
ultrafiltration_volume: record.ultrafiltration_volume,
|
||||
dialysis_duration: record.dialysis_duration,
|
||||
blood_flow_rate: record.blood_flow_rate,
|
||||
complication_notes: record.complication_notes,
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: Record<string, unknown>) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const payload = {
|
||||
...values,
|
||||
patient_id: selectedPatientId!,
|
||||
dialysis_date: (values.dialysis_date as Dayjs).format('YYYY-MM-DD'),
|
||||
};
|
||||
if (editingRecord) {
|
||||
await dialysisApi.updateRecord(editingRecord.id, {
|
||||
...payload,
|
||||
version: editingRecord.version,
|
||||
});
|
||||
message.success('透析记录更新成功');
|
||||
} else {
|
||||
await dialysisApi.createRecord(payload as Parameters<typeof dialysisApi.createRecord>[0]);
|
||||
message.success('透析记录添加成功');
|
||||
}
|
||||
setModalOpen(false);
|
||||
setEditingRecord(null);
|
||||
form.resetFields();
|
||||
refresh();
|
||||
} catch (err) {
|
||||
handleApiError(err, editingRecord ? '更新失败' : '添加失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (record: DialysisRecord) => {
|
||||
try {
|
||||
await dialysisApi.deleteRecord(record.id, record.version);
|
||||
message.success('删除成功');
|
||||
refresh();
|
||||
} catch (err) {
|
||||
handleApiError(err, '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReview = async () => {
|
||||
if (!reviewRecord) return;
|
||||
setReviewSubmitting(true);
|
||||
try {
|
||||
await dialysisApi.reviewRecord(reviewRecord.id, { version: reviewRecord.version });
|
||||
message.success('审核完成');
|
||||
setReviewOpen(false);
|
||||
setReviewRecord(null);
|
||||
refresh();
|
||||
} catch (err) {
|
||||
handleApiError(err, '审核失败');
|
||||
} finally {
|
||||
setReviewSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{ title: '透析日期', dataIndex: 'dialysis_date', key: 'dialysis_date', width: 110 },
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'dialysis_type',
|
||||
key: 'dialysis_type',
|
||||
width: 70,
|
||||
render: (v: string) => {
|
||||
const m = DIALYSIS_TYPE_MAP[v];
|
||||
return m ? <Tag color={m.color}>{m.label}</Tag> : <Tag>{v}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 80,
|
||||
render: (v: string) => {
|
||||
const m = STATUS_MAP[v];
|
||||
return m ? <Tag color={m.color}>{m.label}</Tag> : <Tag>{v}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '透前体重',
|
||||
dataIndex: 'pre_weight',
|
||||
key: 'pre_weight',
|
||||
width: 90,
|
||||
render: (v: number | null) => (v != null ? `${v} kg` : '-'),
|
||||
},
|
||||
{
|
||||
title: '透后体重',
|
||||
dataIndex: 'post_weight',
|
||||
key: 'post_weight',
|
||||
width: 90,
|
||||
render: (v: number | null) => (v != null ? `${v} kg` : '-'),
|
||||
},
|
||||
{
|
||||
title: '超滤量',
|
||||
dataIndex: 'ultrafiltration_volume',
|
||||
key: 'ultrafiltration_volume',
|
||||
width: 80,
|
||||
render: (v: number | null) => (v != null ? `${v} ml` : '-'),
|
||||
},
|
||||
{
|
||||
title: '时长(分)',
|
||||
dataIndex: 'dialysis_duration',
|
||||
key: 'dialysis_duration',
|
||||
width: 80,
|
||||
render: (v: number | null) => v ?? '-',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 200,
|
||||
render: (_: unknown, record: DialysisRecord) => (
|
||||
<AuthButton code="health.dialysis.manage">
|
||||
<Space size={0}>
|
||||
{record.status === 'pending' && (
|
||||
<Button type="link" size="small" icon={<AuditOutlined />} onClick={() => { setReviewRecord(record); setReviewOpen(true); }}>
|
||||
审核
|
||||
</Button>
|
||||
)}
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEditModal(record)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm title="确认删除该记录?" onConfirm={() => handleDelete(record)} okText="确认" cancelText="取消">
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</AuthButton>
|
||||
),
|
||||
},
|
||||
],
|
||||
[openEditModal, handleDelete],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||
<Select
|
||||
showSearch
|
||||
style={{ width: 280 }}
|
||||
placeholder="搜索选择患者"
|
||||
filterOption={false}
|
||||
onSearch={(v) => {
|
||||
setPatientSearch(v);
|
||||
searchPatients(v);
|
||||
}}
|
||||
onChange={(v) => setSelectedPatientId(v)}
|
||||
notFoundContent={patientSearch ? '未找到患者' : '输入姓名搜索'}
|
||||
options={patientOptions.map((p) => ({ value: p.id, label: p.name }))}
|
||||
value={selectedPatientId || undefined}
|
||||
/>
|
||||
<Button icon={<PlusOutlined />} onClick={openCreateModal} disabled={!selectedPatientId}>
|
||||
添加记录
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{selectedPatientId ? (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
size="small"
|
||||
pagination={{
|
||||
current: page,
|
||||
total,
|
||||
pageSize: 10,
|
||||
onChange: (p) => refresh(p),
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
style: { margin: 0 },
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: 60, color: '#999' }}>
|
||||
请先选择患者查看透析记录
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
title={editingRecord ? '编辑透析记录' : '添加透析记录'}
|
||||
open={modalOpen}
|
||||
onCancel={() => {
|
||||
setModalOpen(false);
|
||||
setEditingRecord(null);
|
||||
}}
|
||||
onOk={() => form.submit()}
|
||||
confirmLoading={submitting}
|
||||
destroyOnClose
|
||||
width={640}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item name="dialysis_date" label="透析日期" rules={[{ required: true, message: '请选择日期' }]}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="dialysis_type" label="透析方式" style={{ flex: 1 }}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'HD', label: 'HD (血液透析)' },
|
||||
{ value: 'HDF', label: 'HDF (血液透析滤过)' },
|
||||
{ value: 'HF', label: 'HF (血液滤过)' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="dialysis_duration" label="时长(分钟)" style={{ flex: 1 }}>
|
||||
<InputNumber min={0} max={600} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="pre_weight" label="透前体重(kg)" style={{ flex: 1 }}>
|
||||
<InputNumber min={0} max={300} step={0.1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="post_weight" label="透后体重(kg)" style={{ flex: 1 }}>
|
||||
<InputNumber min={0} max={300} step={0.1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="dry_weight" label="干体重(kg)" style={{ flex: 1 }}>
|
||||
<InputNumber min={0} max={300} step={0.1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="pre_bp_systolic" label="透前收缩压" style={{ flex: 1 }}>
|
||||
<InputNumber min={0} max={300} placeholder="mmHg" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="pre_bp_diastolic" label="透前舒张压" style={{ flex: 1 }}>
|
||||
<InputNumber min={0} max={200} placeholder="mmHg" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="post_bp_systolic" label="透后收缩压" style={{ flex: 1 }}>
|
||||
<InputNumber min={0} max={300} placeholder="mmHg" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="post_bp_diastolic" label="透后舒张压" style={{ flex: 1 }}>
|
||||
<InputNumber min={0} max={200} placeholder="mmHg" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="pre_heart_rate" label="透前心率" style={{ flex: 1 }}>
|
||||
<InputNumber min={0} max={250} placeholder="bpm" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="post_heart_rate" label="透后心率" style={{ flex: 1 }}>
|
||||
<InputNumber min={0} max={250} placeholder="bpm" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="ultrafiltration_volume" label="超滤量(ml)" style={{ flex: 1 }}>
|
||||
<InputNumber min={0} max={10000} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="blood_flow_rate" label="血流量(ml/min)" style={{ flex: 1 }}>
|
||||
<InputNumber min={0} max={600} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name="complication_notes" label="并发症备注">
|
||||
<Input.TextArea rows={2} placeholder="可选" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="审核透析记录"
|
||||
open={reviewOpen}
|
||||
onCancel={() => {
|
||||
setReviewOpen(false);
|
||||
setReviewRecord(null);
|
||||
}}
|
||||
onOk={handleReview}
|
||||
confirmLoading={reviewSubmitting}
|
||||
width={400}
|
||||
>
|
||||
{reviewRecord && (
|
||||
<div>
|
||||
<p><strong>透析日期:</strong>{reviewRecord.dialysis_date}</p>
|
||||
<p><strong>透析方式:</strong>{reviewRecord.dialysis_type}</p>
|
||||
<p><strong>时长:</strong>{reviewRecord.dialysis_duration ?? '-'} 分钟</p>
|
||||
<p>确认审核通过该透析记录?</p>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
248
apps/web/src/pages/health/components/DailyMonitoringTab.tsx
Normal file
248
apps/web/src/pages/health/components/DailyMonitoringTab.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import { useCallback, useState, useMemo } from 'react';
|
||||
import { Table, Button, Modal, Form, Input, InputNumber, DatePicker, message, Popconfirm, Space } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import { dayjs } from '../../../utils/dayjs';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { healthDataApi } from '../../../api/health/healthData';
|
||||
import type { DailyMonitoring } from '../../../api/health/healthData';
|
||||
import { usePaginatedData } from '../../../hooks/usePaginatedData';
|
||||
import { AuthButton } from '../../../components/AuthButton';
|
||||
import { handleApiError } from '../../../api/client';
|
||||
|
||||
interface Props {
|
||||
patientId: string;
|
||||
}
|
||||
|
||||
export function DailyMonitoringTab({ patientId }: Props) {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingRecord, setEditingRecord] = useState<DailyMonitoring | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const fetcher = useCallback(
|
||||
async (page: number, pageSize: number) => {
|
||||
return healthDataApi.listDailyMonitoring(patientId, { page, page_size: pageSize });
|
||||
},
|
||||
[patientId],
|
||||
);
|
||||
|
||||
const { data, total, page, loading, refresh } = usePaginatedData<DailyMonitoring>(fetcher, 10);
|
||||
|
||||
const openCreateModal = () => {
|
||||
setEditingRecord(null);
|
||||
form.resetFields();
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditModal = (record: DailyMonitoring) => {
|
||||
setEditingRecord(record);
|
||||
form.setFieldsValue({
|
||||
record_date: dayjs(record.record_date),
|
||||
morning_bp_systolic: record.morning_bp_systolic,
|
||||
morning_bp_diastolic: record.morning_bp_diastolic,
|
||||
evening_bp_systolic: record.evening_bp_systolic,
|
||||
evening_bp_diastolic: record.evening_bp_diastolic,
|
||||
weight: record.weight,
|
||||
blood_sugar: record.blood_sugar,
|
||||
fluid_intake: record.fluid_intake,
|
||||
urine_output: record.urine_output,
|
||||
notes: record.notes,
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: {
|
||||
record_date: Dayjs;
|
||||
morning_bp_systolic?: number;
|
||||
morning_bp_diastolic?: number;
|
||||
evening_bp_systolic?: number;
|
||||
evening_bp_diastolic?: number;
|
||||
weight?: number;
|
||||
blood_sugar?: number;
|
||||
fluid_intake?: number;
|
||||
urine_output?: number;
|
||||
notes?: string;
|
||||
}) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const payload = {
|
||||
...values,
|
||||
record_date: values.record_date.format('YYYY-MM-DD'),
|
||||
};
|
||||
if (editingRecord) {
|
||||
await healthDataApi.updateDailyMonitoring(editingRecord.id, {
|
||||
...payload,
|
||||
version: editingRecord.version,
|
||||
});
|
||||
message.success('日常监测记录更新成功');
|
||||
} else {
|
||||
await healthDataApi.createDailyMonitoring({ ...payload, patient_id: patientId });
|
||||
message.success('日常监测记录添加成功');
|
||||
}
|
||||
setModalOpen(false);
|
||||
setEditingRecord(null);
|
||||
form.resetFields();
|
||||
refresh();
|
||||
} catch (err) {
|
||||
handleApiError(err, editingRecord ? '更新失败' : '添加失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (record: DailyMonitoring) => {
|
||||
try {
|
||||
await healthDataApi.deleteDailyMonitoring(record.id, record.version);
|
||||
message.success('删除成功');
|
||||
refresh();
|
||||
} catch (err) {
|
||||
handleApiError(err, '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{ title: '记录日期', dataIndex: 'record_date', key: 'record_date', width: 110 },
|
||||
{
|
||||
title: '晨起血压',
|
||||
key: 'morning_bp',
|
||||
width: 120,
|
||||
render: (_: unknown, r: DailyMonitoring) =>
|
||||
r.morning_bp_systolic != null ? `${r.morning_bp_systolic}/${r.morning_bp_diastolic}` : '-',
|
||||
},
|
||||
{
|
||||
title: '晚间血压',
|
||||
key: 'evening_bp',
|
||||
width: 120,
|
||||
render: (_: unknown, r: DailyMonitoring) =>
|
||||
r.evening_bp_systolic != null ? `${r.evening_bp_systolic}/${r.evening_bp_diastolic}` : '-',
|
||||
},
|
||||
{
|
||||
title: '体重(kg)',
|
||||
dataIndex: 'weight',
|
||||
key: 'weight',
|
||||
width: 90,
|
||||
render: (v: number | null) => v ?? '-',
|
||||
},
|
||||
{
|
||||
title: '血糖(mmol/L)',
|
||||
dataIndex: 'blood_sugar',
|
||||
key: 'blood_sugar',
|
||||
width: 110,
|
||||
render: (v: number | null) => v ?? '-',
|
||||
},
|
||||
{
|
||||
title: '入量(ml)',
|
||||
dataIndex: 'fluid_intake',
|
||||
key: 'fluid_intake',
|
||||
width: 90,
|
||||
render: (v: number | null) => v ?? '-',
|
||||
},
|
||||
{
|
||||
title: '出量(ml)',
|
||||
dataIndex: 'urine_output',
|
||||
key: 'urine_output',
|
||||
width: 90,
|
||||
render: (v: number | null) => v ?? '-',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 120,
|
||||
render: (_: unknown, record: DailyMonitoring) => (
|
||||
<AuthButton code="health.health-data.manage">
|
||||
<Space size={0}>
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEditModal(record)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm title="确认删除该记录?" onConfirm={() => handleDelete(record)} okText="确认" cancelText="取消">
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</AuthButton>
|
||||
),
|
||||
},
|
||||
],
|
||||
[openEditModal, handleDelete],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button icon={<PlusOutlined />} onClick={openCreateModal}>
|
||||
添加记录
|
||||
</Button>
|
||||
</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
size="small"
|
||||
pagination={{
|
||||
current: page,
|
||||
total,
|
||||
pageSize: 10,
|
||||
onChange: (p) => refresh(p),
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
style: { margin: 0 },
|
||||
}}
|
||||
/>
|
||||
<Modal
|
||||
title={editingRecord ? '编辑日常监测' : '添加日常监测'}
|
||||
open={modalOpen}
|
||||
onCancel={() => {
|
||||
setModalOpen(false);
|
||||
setEditingRecord(null);
|
||||
}}
|
||||
onOk={() => form.submit()}
|
||||
confirmLoading={submitting}
|
||||
destroyOnClose
|
||||
width={560}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item name="record_date" label="记录日期" rules={[{ required: true, message: '请选择日期' }]}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="morning_bp_systolic" label="晨起收缩压" style={{ flex: 1 }}>
|
||||
<InputNumber min={0} max={300} placeholder="mmHg" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="morning_bp_diastolic" label="晨起舒张压" style={{ flex: 1 }}>
|
||||
<InputNumber min={0} max={200} placeholder="mmHg" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="evening_bp_systolic" label="晚间收缩压" style={{ flex: 1 }}>
|
||||
<InputNumber min={0} max={300} placeholder="mmHg" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="evening_bp_diastolic" label="晚间舒张压" style={{ flex: 1 }}>
|
||||
<InputNumber min={0} max={200} placeholder="mmHg" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="weight" label="体重(kg)" style={{ flex: 1 }}>
|
||||
<InputNumber min={0} max={500} step={0.1} placeholder="kg" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="blood_sugar" label="血糖(mmol/L)" style={{ flex: 1 }}>
|
||||
<InputNumber min={0} max={50} step={0.1} placeholder="mmol/L" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="fluid_intake" label="入量(ml)" style={{ flex: 1 }}>
|
||||
<InputNumber min={0} max={10000} placeholder="ml" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="urine_output" label="出量(ml)" style={{ flex: 1 }}>
|
||||
<InputNumber min={0} max={10000} placeholder="ml" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name="notes" label="备注">
|
||||
<Input.TextArea rows={2} placeholder="可选备注" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -45,11 +45,6 @@ interface Props {
|
||||
|
||||
/* ---------- 原始数据 Tab ---------- */
|
||||
|
||||
interface RawFilters {
|
||||
deviceType: string | undefined;
|
||||
hours: number;
|
||||
}
|
||||
|
||||
function RawDataTab({ patientId }: Props) {
|
||||
const [deviceType, setDeviceType] = useState<string | undefined>(undefined);
|
||||
const [hours, setHours] = useState<number>(24);
|
||||
@@ -162,11 +157,6 @@ function RawDataTab({ patientId }: Props) {
|
||||
|
||||
/* ---------- 小时聚合 Tab ---------- */
|
||||
|
||||
interface HourlyFilters {
|
||||
deviceType: string | undefined;
|
||||
days: number;
|
||||
}
|
||||
|
||||
function HourlyAggTab({ patientId }: Props) {
|
||||
const [deviceType, setDeviceType] = useState<string | undefined>(undefined);
|
||||
const [days, setDays] = useState<number>(7);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! 每个测试在独立事务中执行,测试结束自动回滚,无数据残留。
|
||||
//! 多个测试共享同一个数据库连接池,无连接竞争。
|
||||
|
||||
use sea_orm::{ConnectOptions, DatabaseConnection, DatabaseTransaction};
|
||||
use sea_orm::{ConnectOptions, Database, DatabaseConnection, DatabaseTransaction, TransactionTrait};
|
||||
use std::sync::OnceLock;
|
||||
use tokio::sync::OnceCell;
|
||||
|
||||
@@ -25,7 +25,7 @@ async fn db_pool() -> &'static DatabaseConnection {
|
||||
let opt = ConnectOptions::new(db_url())
|
||||
.max_connections(5)
|
||||
.to_owned();
|
||||
DatabaseConnection::connect(opt)
|
||||
Database::connect(opt)
|
||||
.await
|
||||
.expect("测试数据库连接失败")
|
||||
})
|
||||
|
||||
@@ -16,7 +16,7 @@ where
|
||||
DialysisState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.dialysis.list")?;
|
||||
require_permission(&ctx, "health.dialysis.stats")?;
|
||||
let dialysis_state = DialysisState::from_ref(&state);
|
||||
let stats = dialysis_stats_service::get_dialysis_statistics(&dialysis_state, ctx.tenant_id).await?;
|
||||
Ok(Json(ApiResponse::ok(stats)))
|
||||
|
||||
@@ -106,6 +106,12 @@ impl ErpModule for DialysisModule {
|
||||
description: "创建、编辑、删除透析处方".into(),
|
||||
module: "erp-dialysis".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "health.dialysis.stats".into(),
|
||||
name: "查看透析统计".into(),
|
||||
description: "查看透析统计数据".into(),
|
||||
module: "erp-dialysis".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use erp_core::sanitize::{sanitize_option, sanitize_string};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
@@ -18,6 +19,13 @@ pub struct CreateAlertRuleRequest {
|
||||
pub cooldown_minutes: Option<i32>,
|
||||
}
|
||||
|
||||
impl CreateAlertRuleRequest {
|
||||
pub fn sanitize(&mut self) {
|
||||
self.name = sanitize_string(&self.name);
|
||||
self.description = sanitize_option(self.description.take());
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct UpdateAlertRuleRequest {
|
||||
pub name: Option<String>,
|
||||
@@ -30,6 +38,13 @@ pub struct UpdateAlertRuleRequest {
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
impl UpdateAlertRuleRequest {
|
||||
pub fn sanitize(&mut self) {
|
||||
self.name = sanitize_option(self.name.take());
|
||||
self.description = sanitize_option(self.description.take());
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct AlertRuleResponse {
|
||||
pub id: Uuid,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use chrono::{NaiveDate, Utc};
|
||||
use erp_core::sanitize::{sanitize_option, sanitize_string};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
@@ -32,8 +33,24 @@ pub struct CreateConsentReq {
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
impl CreateConsentReq {
|
||||
pub fn sanitize(&mut self) {
|
||||
self.consent_type = sanitize_string(&self.consent_type);
|
||||
self.consent_scope = sanitize_string(&self.consent_scope);
|
||||
self.consent_method = sanitize_option(self.consent_method.take());
|
||||
self.witness_name = sanitize_option(self.witness_name.take());
|
||||
self.notes = sanitize_option(self.notes.take());
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct RevokeConsentReq {
|
||||
pub notes: Option<String>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
impl RevokeConsentReq {
|
||||
pub fn sanitize(&mut self) {
|
||||
self.notes = sanitize_option(self.notes.take());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,11 +32,10 @@ pub struct MessageResp {
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
/// 发送消息请求体 — 不含 sender_id/sender_role,由服务端从 JWT 注入。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct CreateMessageReq {
|
||||
pub session_id: Uuid,
|
||||
pub sender_id: Uuid,
|
||||
pub sender_role: String,
|
||||
pub content_type: Option<String>,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use erp_core::sanitize::{sanitize_option, sanitize_string};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
@@ -15,6 +16,16 @@ pub struct CreateDiagnosisReq {
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
impl CreateDiagnosisReq {
|
||||
pub fn sanitize(&mut self) {
|
||||
self.icd_code = sanitize_string(&self.icd_code);
|
||||
self.diagnosis_name = sanitize_string(&self.diagnosis_name);
|
||||
self.diagnosis_type = sanitize_string(&self.diagnosis_type);
|
||||
self.status = sanitize_string(&self.status);
|
||||
self.notes = sanitize_option(self.notes.take());
|
||||
}
|
||||
}
|
||||
|
||||
fn default_diagnosis_type() -> String { "primary".to_string() }
|
||||
fn default_status() -> String { "active".to_string() }
|
||||
|
||||
@@ -30,6 +41,16 @@ pub struct UpdateDiagnosisReq {
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
impl UpdateDiagnosisReq {
|
||||
pub fn sanitize(&mut self) {
|
||||
self.icd_code = sanitize_option(self.icd_code.take());
|
||||
self.diagnosis_name = sanitize_option(self.diagnosis_name.take());
|
||||
self.diagnosis_type = sanitize_option(self.diagnosis_type.take());
|
||||
self.status = sanitize_option(self.status.take());
|
||||
self.notes = sanitize_option(self.notes.take());
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct DiagnosisResp {
|
||||
pub id: uuid::Uuid,
|
||||
|
||||
@@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize};
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
use uuid::Uuid;
|
||||
|
||||
use erp_core::sanitize::sanitize_option;
|
||||
use erp_core::sanitize::{sanitize_option, sanitize_string};
|
||||
|
||||
// ── 模板字段 DTO ──
|
||||
|
||||
@@ -22,6 +22,16 @@ pub struct TemplateFieldReq {
|
||||
pub sort_order: i32,
|
||||
}
|
||||
|
||||
impl TemplateFieldReq {
|
||||
pub fn sanitize(&mut self) {
|
||||
self.label = sanitize_string(&self.label);
|
||||
self.field_key = sanitize_string(&self.field_key);
|
||||
self.field_type = sanitize_string(&self.field_type);
|
||||
self.options = sanitize_option(self.options.take());
|
||||
self.placeholder = sanitize_option(self.placeholder.take());
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct TemplateFieldResp {
|
||||
pub id: Uuid,
|
||||
@@ -62,9 +72,12 @@ pub struct CreateFollowUpTemplateReq {
|
||||
|
||||
impl CreateFollowUpTemplateReq {
|
||||
pub fn sanitize(&mut self) {
|
||||
self.name = self.name.trim().to_string();
|
||||
self.name = sanitize_string(&self.name);
|
||||
self.description = sanitize_option(self.description.take());
|
||||
self.applicable_scope = sanitize_option(self.applicable_scope.take());
|
||||
for field in &mut self.fields {
|
||||
field.sanitize();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,11 +94,14 @@ pub struct UpdateFollowUpTemplateReq {
|
||||
|
||||
impl UpdateFollowUpTemplateReq {
|
||||
pub fn sanitize(&mut self) {
|
||||
if let Some(ref mut n) = self.name {
|
||||
*n = n.trim().to_string();
|
||||
}
|
||||
self.name = sanitize_option(self.name.take());
|
||||
self.description = sanitize_option(self.description.take());
|
||||
self.applicable_scope = sanitize_option(self.applicable_scope.take());
|
||||
if let Some(ref mut fields) = self.fields {
|
||||
for field in fields.iter_mut() {
|
||||
field.sanitize();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use erp_core::sanitize::{sanitize_option, sanitize_string};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
@@ -71,55 +72,29 @@ pub struct MedicationRecordResp {
|
||||
|
||||
/// 创建请求的输入清洗
|
||||
impl CreateMedicationRecordReq {
|
||||
/// 清洗用户输入:去除首尾空白,截断超长字段
|
||||
/// 清洗用户输入:去除 HTML 标签和首尾空白
|
||||
pub fn sanitize(&mut self) {
|
||||
self.medication_name = self.medication_name.trim().to_string();
|
||||
if let Some(ref mut v) = self.generic_name {
|
||||
*v = v.trim().to_string();
|
||||
}
|
||||
if let Some(ref mut v) = self.dosage {
|
||||
*v = v.trim().to_string();
|
||||
}
|
||||
if let Some(ref mut v) = self.unit {
|
||||
*v = v.trim().to_string();
|
||||
}
|
||||
if let Some(ref mut v) = self.frequency {
|
||||
*v = v.trim().to_string();
|
||||
}
|
||||
if let Some(ref mut v) = self.route {
|
||||
*v = v.trim().to_string();
|
||||
}
|
||||
if let Some(ref mut v) = self.notes {
|
||||
*v = v.trim().to_string();
|
||||
}
|
||||
self.medication_name = sanitize_string(&self.medication_name);
|
||||
self.generic_name = sanitize_option(self.generic_name.take());
|
||||
self.dosage = sanitize_option(self.dosage.take());
|
||||
self.unit = sanitize_option(self.unit.take());
|
||||
self.frequency = sanitize_option(self.frequency.take());
|
||||
self.route = sanitize_option(self.route.take());
|
||||
self.notes = sanitize_option(self.notes.take());
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新请求的输入清洗
|
||||
impl UpdateMedicationRecordReq {
|
||||
/// 清洗用户输入:去除首尾空白
|
||||
/// 清洗用户输入:去除 HTML 标签和首尾空白
|
||||
pub fn sanitize(&mut self) {
|
||||
if let Some(ref mut v) = self.medication_name {
|
||||
*v = v.trim().to_string();
|
||||
}
|
||||
if let Some(ref mut v) = self.generic_name {
|
||||
*v = v.trim().to_string();
|
||||
}
|
||||
if let Some(ref mut v) = self.dosage {
|
||||
*v = v.trim().to_string();
|
||||
}
|
||||
if let Some(ref mut v) = self.unit {
|
||||
*v = v.trim().to_string();
|
||||
}
|
||||
if let Some(ref mut v) = self.frequency {
|
||||
*v = v.trim().to_string();
|
||||
}
|
||||
if let Some(ref mut v) = self.route {
|
||||
*v = v.trim().to_string();
|
||||
}
|
||||
if let Some(ref mut v) = self.notes {
|
||||
*v = v.trim().to_string();
|
||||
}
|
||||
self.medication_name = sanitize_option(self.medication_name.take());
|
||||
self.generic_name = sanitize_option(self.generic_name.take());
|
||||
self.dosage = sanitize_option(self.dosage.take());
|
||||
self.unit = sanitize_option(self.unit.take());
|
||||
self.frequency = sanitize_option(self.frequency.take());
|
||||
self.route = sanitize_option(self.route.take());
|
||||
self.notes = sanitize_option(self.notes.take());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use erp_core::sanitize::{sanitize_option, sanitize_string};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
@@ -47,19 +48,19 @@ pub struct MedicationReminderResp {
|
||||
|
||||
impl CreateMedicationReminderReq {
|
||||
pub fn sanitize(&mut self) {
|
||||
self.medication_name = self.medication_name.trim().to_string();
|
||||
if let Some(ref mut d) = self.dosage { *d = d.trim().to_string(); }
|
||||
if let Some(ref mut f) = self.frequency { *f = f.trim().to_string(); }
|
||||
if let Some(ref mut n) = self.notes { *n = n.trim().to_string(); }
|
||||
self.medication_name = sanitize_string(&self.medication_name);
|
||||
self.dosage = sanitize_option(self.dosage.take());
|
||||
self.frequency = sanitize_option(self.frequency.take());
|
||||
self.notes = sanitize_option(self.notes.take());
|
||||
}
|
||||
}
|
||||
|
||||
impl UpdateMedicationReminderReq {
|
||||
pub fn sanitize(&mut self) {
|
||||
if let Some(ref mut n) = self.medication_name { *n = n.trim().to_string(); }
|
||||
if let Some(ref mut d) = self.dosage { *d = d.trim().to_string(); }
|
||||
if let Some(ref mut f) = self.frequency { *f = f.trim().to_string(); }
|
||||
if let Some(ref mut n) = self.notes { *n = n.trim().to_string(); }
|
||||
self.medication_name = sanitize_option(self.medication_name.take());
|
||||
self.dosage = sanitize_option(self.dosage.take());
|
||||
self.frequency = sanitize_option(self.frequency.take());
|
||||
self.notes = sanitize_option(self.notes.take());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,13 +54,14 @@ where
|
||||
pub async fn create<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
axum::Json(body): axum::Json<CreateAlertRuleRequest>,
|
||||
axum::Json(mut body): axum::Json<CreateAlertRuleRequest>,
|
||||
) -> Result<impl IntoResponse, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.alert-rules.manage")?;
|
||||
body.sanitize();
|
||||
let rule = alert_rule_service::create_rule(
|
||||
&state, ctx.tenant_id, ctx.user_id, body,
|
||||
).await?;
|
||||
@@ -71,13 +72,14 @@ pub async fn update<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
axum::Json(body): axum::Json<UpdateAlertRuleRequest>,
|
||||
axum::Json(mut body): axum::Json<UpdateAlertRuleRequest>,
|
||||
) -> Result<impl IntoResponse, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.alert-rules.manage")?;
|
||||
body.sanitize();
|
||||
let rule = alert_rule_service::update_rule(
|
||||
&state, ctx.tenant_id, id, ctx.user_id, body,
|
||||
).await?;
|
||||
|
||||
@@ -25,7 +25,7 @@ where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.patient.list")?;
|
||||
require_permission(&ctx, "health.consent.list")?;
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
let result = consent_service::list_consents(
|
||||
@@ -44,7 +44,9 @@ where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.patient.manage")?;
|
||||
require_permission(&ctx, "health.consent.manage")?;
|
||||
let mut req = req;
|
||||
req.sanitize();
|
||||
let result = consent_service::grant_consent(
|
||||
&state, ctx.tenant_id, Some(ctx.user_id), req,
|
||||
)
|
||||
@@ -62,7 +64,9 @@ where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.patient.manage")?;
|
||||
require_permission(&ctx, "health.consent.manage")?;
|
||||
let mut req = req;
|
||||
req.sanitize();
|
||||
let result = consent_service::revoke_consent(
|
||||
&state, ctx.tenant_id, consent_id, Some(ctx.user_id), req,
|
||||
)
|
||||
|
||||
@@ -149,6 +149,7 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.consultation.manage")?;
|
||||
// 从 JWT 身份推导 sender_role,不信任客户端输入
|
||||
let is_doctor = crate::entity::doctor_profile::Entity::find()
|
||||
.filter(crate::entity::doctor_profile::Column::UserId.eq(ctx.user_id))
|
||||
.filter(crate::entity::doctor_profile::Column::TenantId.eq(ctx.tenant_id))
|
||||
@@ -157,16 +158,15 @@ where
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(e.to_string()))?
|
||||
.is_some();
|
||||
let sender_role = if is_doctor { "doctor" } else { "patient" }.to_string();
|
||||
let mut msg_req = CreateMessageReq {
|
||||
session_id: req.session_id,
|
||||
sender_id: ctx.user_id,
|
||||
sender_role: if is_doctor { "doctor" } else { "patient" }.to_string(),
|
||||
content_type: req.content_type,
|
||||
content: req.content,
|
||||
};
|
||||
msg_req.sanitize();
|
||||
let result = consultation_service::create_message(
|
||||
&state, ctx.tenant_id, Some(ctx.user_id), msg_req,
|
||||
&state, ctx.tenant_id, Some(ctx.user_id), ctx.user_id, sender_role, msg_req,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
|
||||
@@ -27,7 +27,7 @@ where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.critical-alert.list")?;
|
||||
require_permission(&ctx, "health.critical-alerts.list")?;
|
||||
let page = query.page.unwrap_or(1);
|
||||
let page_size = query.page_size.unwrap_or(20);
|
||||
|
||||
@@ -54,7 +54,7 @@ where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.critical-alert.list")?;
|
||||
require_permission(&ctx, "health.critical-alerts.list")?;
|
||||
let alert = critical_alert_service::get_alert(&state, ctx.tenant_id, id).await?;
|
||||
Ok(axum::Json(ApiResponse::ok(alert)))
|
||||
}
|
||||
@@ -74,7 +74,7 @@ where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.critical-alert.manage")?;
|
||||
require_permission(&ctx, "health.critical-alerts.manage")?;
|
||||
critical_alert_service::acknowledge_alert(
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
|
||||
@@ -37,7 +37,7 @@ where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.list")?;
|
||||
require_permission(&ctx, "health.critical-value-thresholds.list")?;
|
||||
let list = critical_value_threshold_service::find_thresholds(&state.db, ctx.tenant_id).await?;
|
||||
Ok(Json(ApiResponse::ok(list)))
|
||||
}
|
||||
@@ -51,7 +51,7 @@ where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.manage")?;
|
||||
require_permission(&ctx, "health.critical-value-thresholds.manage")?;
|
||||
let result = critical_value_threshold_service::create_threshold(
|
||||
&state.db,
|
||||
ctx.tenant_id,
|
||||
@@ -78,7 +78,7 @@ where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.manage")?;
|
||||
require_permission(&ctx, "health.critical-value-thresholds.manage")?;
|
||||
let result = critical_value_threshold_service::update_threshold(
|
||||
&state.db,
|
||||
ctx.tenant_id,
|
||||
@@ -104,7 +104,7 @@ where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.manage")?;
|
||||
require_permission(&ctx, "health.critical-value-thresholds.manage")?;
|
||||
critical_value_threshold_service::delete_threshold(&state.db, ctx.tenant_id, id, Some(ctx.user_id))
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
|
||||
@@ -36,7 +36,7 @@ where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.list")?;
|
||||
require_permission(&ctx, "health.daily-monitoring.list")?;
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
let result = daily_monitoring_service::list_daily_monitoring(
|
||||
@@ -55,7 +55,7 @@ where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.list")?;
|
||||
require_permission(&ctx, "health.daily-monitoring.list")?;
|
||||
let result = daily_monitoring_service::get_daily_monitoring(
|
||||
&state, ctx.tenant_id, record_id,
|
||||
)
|
||||
@@ -72,7 +72,7 @@ where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.manage")?;
|
||||
require_permission(&ctx, "health.daily-monitoring.manage")?;
|
||||
let mut req = req;
|
||||
req.sanitize();
|
||||
let result = daily_monitoring_service::create_daily_monitoring(
|
||||
@@ -92,7 +92,7 @@ where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.manage")?;
|
||||
require_permission(&ctx, "health.daily-monitoring.manage")?;
|
||||
let mut data = req.data;
|
||||
data.sanitize();
|
||||
let result = daily_monitoring_service::update_daily_monitoring(
|
||||
@@ -112,7 +112,7 @@ where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.manage")?;
|
||||
require_permission(&ctx, "health.daily-monitoring.manage")?;
|
||||
daily_monitoring_service::delete_daily_monitoring(
|
||||
&state, ctx.tenant_id, record_id, Some(ctx.user_id), req.version,
|
||||
)
|
||||
|
||||
@@ -47,6 +47,8 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.manage")?;
|
||||
let mut req = req;
|
||||
req.sanitize();
|
||||
let result = diagnosis_service::create_diagnosis(
|
||||
&state, ctx.tenant_id, patient_id, Some(ctx.user_id), req,
|
||||
)
|
||||
@@ -65,8 +67,10 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.manage")?;
|
||||
let mut data = req.data;
|
||||
data.sanitize();
|
||||
let result = diagnosis_service::update_diagnosis(
|
||||
&state, ctx.tenant_id, diagnosis_id, Some(ctx.user_id), req.data, req.version,
|
||||
&state, ctx.tenant_id, diagnosis_id, Some(ctx.user_id), data, req.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
|
||||
@@ -37,7 +37,7 @@ where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.follow-up.list")?;
|
||||
require_permission(&ctx, "health.follow-up-templates.list")?;
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
let result = follow_up_template_service::list_templates(
|
||||
@@ -56,7 +56,7 @@ where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.follow-up.list")?;
|
||||
require_permission(&ctx, "health.follow-up-templates.list")?;
|
||||
let result = follow_up_template_service::get_template(&state, ctx.tenant_id, id).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
@@ -70,7 +70,7 @@ where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.follow-up.manage")?;
|
||||
require_permission(&ctx, "health.follow-up-templates.manage")?;
|
||||
let mut req = req;
|
||||
req.sanitize();
|
||||
let result = follow_up_template_service::create_template(
|
||||
@@ -90,7 +90,7 @@ where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.follow-up.manage")?;
|
||||
require_permission(&ctx, "health.follow-up-templates.manage")?;
|
||||
let mut data = req.data;
|
||||
data.sanitize();
|
||||
let result = follow_up_template_service::update_template(
|
||||
@@ -110,7 +110,7 @@ where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.follow-up.manage")?;
|
||||
require_permission(&ctx, "health.follow-up-templates.manage")?;
|
||||
follow_up_template_service::delete_template(
|
||||
&state, ctx.tenant_id, id, Some(ctx.user_id), req.version,
|
||||
)
|
||||
|
||||
@@ -27,7 +27,7 @@ where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.list")?;
|
||||
require_permission(&ctx, "health.medication-records.list")?;
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
let result = medication_record_service::list_medications(
|
||||
@@ -47,7 +47,7 @@ where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.list")?;
|
||||
require_permission(&ctx, "health.medication-records.list")?;
|
||||
let result =
|
||||
medication_record_service::get_medication(&state, ctx.tenant_id, record_id).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
@@ -63,7 +63,9 @@ where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.manage")?;
|
||||
require_permission(&ctx, "health.medication-records.manage")?;
|
||||
let mut req = req;
|
||||
req.sanitize();
|
||||
let result = medication_record_service::create_medication(
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
@@ -85,13 +87,15 @@ where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.manage")?;
|
||||
require_permission(&ctx, "health.medication-records.manage")?;
|
||||
let mut data = req.data;
|
||||
data.sanitize();
|
||||
let result = medication_record_service::update_medication(
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
record_id,
|
||||
Some(ctx.user_id),
|
||||
req.data,
|
||||
data,
|
||||
req.version,
|
||||
)
|
||||
.await?;
|
||||
@@ -109,7 +113,7 @@ where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.manage")?;
|
||||
require_permission(&ctx, "health.medication-records.manage")?;
|
||||
medication_record_service::delete_medication(
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
|
||||
@@ -24,7 +24,7 @@ where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.list")?;
|
||||
require_permission(&ctx, "health.medication-reminders.list")?;
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
let result = medication_reminder_service::list_reminders(
|
||||
@@ -42,7 +42,7 @@ where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.manage")?;
|
||||
require_permission(&ctx, "health.medication-reminders.manage")?;
|
||||
req.sanitize();
|
||||
let result = medication_reminder_service::create_reminder(
|
||||
&state, ctx.tenant_id, Some(ctx.user_id), req.0,
|
||||
@@ -67,7 +67,7 @@ where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.manage")?;
|
||||
require_permission(&ctx, "health.medication-reminders.manage")?;
|
||||
let mut data = req.data;
|
||||
data.sanitize();
|
||||
let result = medication_reminder_service::update_reminder(
|
||||
@@ -91,7 +91,7 @@ where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.manage")?;
|
||||
require_permission(&ctx, "health.medication-reminders.manage")?;
|
||||
medication_reminder_service::delete_reminder(
|
||||
&state, ctx.tenant_id, id, Some(ctx.user_id), req.version,
|
||||
).await?;
|
||||
|
||||
@@ -526,6 +526,15 @@ impl HealthModule {
|
||||
"/health/admin/points/orders",
|
||||
axum::routing::get(points_handler::admin_list_orders),
|
||||
)
|
||||
// 积分账户 — 管理端按患者查询
|
||||
.route(
|
||||
"/health/admin/points/patients/{patient_id}/account",
|
||||
axum::routing::get(points_handler::admin_get_patient_account),
|
||||
)
|
||||
.route(
|
||||
"/health/admin/points/patients/{patient_id}/transactions",
|
||||
axum::routing::get(points_handler::admin_list_patient_transactions),
|
||||
)
|
||||
// 线下活动 — 管理端
|
||||
.route(
|
||||
"/health/admin/offline-events",
|
||||
@@ -954,6 +963,90 @@ impl ErpModule for HealthModule {
|
||||
description: "创建/编辑/启停告警规则".into(),
|
||||
module: "health".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "health.critical-alerts.list".into(),
|
||||
name: "查看危急值告警".into(),
|
||||
description: "查看危急值告警列表和详情".into(),
|
||||
module: "health".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "health.critical-alerts.manage".into(),
|
||||
name: "处理危急值告警".into(),
|
||||
description: "确认危急值告警".into(),
|
||||
module: "health".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "health.critical-value-thresholds.list".into(),
|
||||
name: "查看危急值阈值".into(),
|
||||
description: "查看危急值阈值配置".into(),
|
||||
module: "health".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "health.critical-value-thresholds.manage".into(),
|
||||
name: "管理危急值阈值".into(),
|
||||
description: "创建/编辑/删除危急值阈值配置".into(),
|
||||
module: "health".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "health.follow-up-templates.list".into(),
|
||||
name: "查看随访模板".into(),
|
||||
description: "查看随访模板列表和详情".into(),
|
||||
module: "health".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "health.follow-up-templates.manage".into(),
|
||||
name: "管理随访模板".into(),
|
||||
description: "创建/编辑/删除随访模板".into(),
|
||||
module: "health".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "health.daily-monitoring.list".into(),
|
||||
name: "查看日常监测".into(),
|
||||
description: "查看患者日常监测数据".into(),
|
||||
module: "health".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "health.daily-monitoring.manage".into(),
|
||||
name: "管理日常监测".into(),
|
||||
description: "录入/编辑/删除日常监测数据".into(),
|
||||
module: "health".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "health.consent.list".into(),
|
||||
name: "查看知情同意".into(),
|
||||
description: "查看患者知情同意记录".into(),
|
||||
module: "health".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "health.consent.manage".into(),
|
||||
name: "管理知情同意".into(),
|
||||
description: "签署/撤销知情同意".into(),
|
||||
module: "health".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "health.medication-records.list".into(),
|
||||
name: "查看用药记录".into(),
|
||||
description: "查看患者用药记录列表和详情".into(),
|
||||
module: "health".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "health.medication-records.manage".into(),
|
||||
name: "管理用药记录".into(),
|
||||
description: "创建/编辑/删除用药记录".into(),
|
||||
module: "health".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "health.medication-reminders.list".into(),
|
||||
name: "查看药物提醒".into(),
|
||||
description: "查看患者药物提醒列表".into(),
|
||||
module: "health".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "health.medication-reminders.manage".into(),
|
||||
name: "管理药物提醒".into(),
|
||||
description: "创建/编辑/删除药物提醒".into(),
|
||||
module: "health".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -156,8 +156,8 @@ fn evaluate_trend_in_memory(
|
||||
return false;
|
||||
}
|
||||
|
||||
let first = in_window.first().unwrap().avg_val;
|
||||
let last = in_window.last().unwrap().avg_val;
|
||||
let first = in_window.first().map(|r| r.avg_val).unwrap_or(0.0);
|
||||
let last = in_window.last().map(|r| r.avg_val).unwrap_or(0.0);
|
||||
let actual_delta = last - first;
|
||||
|
||||
match direction {
|
||||
|
||||
@@ -77,9 +77,14 @@ pub async fn list_articles(
|
||||
.await?;
|
||||
|
||||
let total_pages = total.div_ceil(limit.max(1));
|
||||
|
||||
// 批量加载所有文章的标签,避免 N+1 查询
|
||||
let article_ids: Vec<Uuid> = models.iter().map(|m| m.id).collect();
|
||||
let tags_map = batch_load_article_tags(state, &article_ids).await?;
|
||||
|
||||
let mut data = Vec::with_capacity(models.len());
|
||||
for m in models {
|
||||
let tags = load_article_tags(state, m.id).await?;
|
||||
let tags = tags_map.get(&m.id).cloned().unwrap_or_default();
|
||||
data.push(ArticleListItem {
|
||||
id: m.id,
|
||||
title: m.title,
|
||||
@@ -285,7 +290,7 @@ pub async fn increment_view_count(
|
||||
) -> HealthResult<()> {
|
||||
let model = find_article(state, tenant_id, id).await?;
|
||||
let mut active: article::ActiveModel = model.into();
|
||||
active.view_count = Set(active.view_count.unwrap() + 1);
|
||||
active.view_count = Set(active.view_count.take().unwrap_or(0) + 1);
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.update(&state.db).await?;
|
||||
Ok(())
|
||||
@@ -480,6 +485,58 @@ async fn load_article_tags(state: &HealthState, article_id: Uuid) -> HealthResul
|
||||
Ok(tags.into_iter().map(|t| t.name).collect())
|
||||
}
|
||||
|
||||
/// 批量加载多篇文章的标签,避免 N+1 查询。
|
||||
/// 返回 HashMap<article_id, Vec<tag_name>>。
|
||||
async fn batch_load_article_tags(
|
||||
state: &HealthState,
|
||||
article_ids: &[Uuid],
|
||||
) -> HealthResult<std::collections::HashMap<Uuid, Vec<String>>> {
|
||||
use std::collections::HashMap;
|
||||
|
||||
if article_ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
// 1. 一次查询所有文章-标签关联
|
||||
let ids: Vec<Uuid> = article_ids.to_vec();
|
||||
let relations = article_article_tag::Entity::find()
|
||||
.filter(article_article_tag::Column::ArticleId.is_in(ids))
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
|
||||
if relations.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
// 2. 收集所有 tag_id,按 article_id 分组
|
||||
let tag_ids: Vec<Uuid> = relations.iter().map(|r| r.tag_id).collect();
|
||||
let mut article_to_tag_ids: HashMap<Uuid, Vec<Uuid>> = HashMap::new();
|
||||
for r in &relations {
|
||||
article_to_tag_ids.entry(r.article_id).or_default().push(r.tag_id);
|
||||
}
|
||||
|
||||
// 3. 一次查询所有标签实体
|
||||
let tags = article_tag::Entity::find()
|
||||
.filter(article_tag::Column::Id.is_in(tag_ids))
|
||||
.filter(article_tag::Column::DeletedAt.is_null())
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
|
||||
let tag_name_map: HashMap<Uuid, String> = tags.into_iter().map(|t| (t.id, t.name)).collect();
|
||||
|
||||
// 4. 组装结果
|
||||
let mut result = HashMap::new();
|
||||
for (article_id, tids) in article_to_tag_ids {
|
||||
let names: Vec<String> = tids
|
||||
.into_iter()
|
||||
.filter_map(|tid| tag_name_map.get(&tid).cloned())
|
||||
.collect();
|
||||
result.insert(article_id, names);
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn save_article_tags(state: &HealthState, article_id: Uuid, tag_ids: &[Uuid]) -> HealthResult<()> {
|
||||
for tid in tag_ids {
|
||||
let active = article_article_tag::ActiveModel {
|
||||
|
||||
@@ -318,6 +318,8 @@ pub async fn create_message(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Option<Uuid>,
|
||||
sender_id: Uuid,
|
||||
sender_role: String,
|
||||
req: CreateMessageReq,
|
||||
) -> HealthResult<MessageResp> {
|
||||
// 校验会话存在且状态为 active 或 waiting
|
||||
@@ -335,17 +337,12 @@ pub async fn create_message(
|
||||
.ok_or(HealthError::ConsultationNotFound)?;
|
||||
|
||||
let now = Utc::now();
|
||||
validate_sender_role(&req.sender_role)?;
|
||||
validate_sender_role(&sender_role)?;
|
||||
let content_type = req.content_type.unwrap_or_else(|| "text".to_string());
|
||||
validate_content_type(&content_type)?;
|
||||
let is_patient = req.sender_role == "patient";
|
||||
let is_patient = sender_role == "patient";
|
||||
let should_activate = session.status == "waiting";
|
||||
|
||||
// 强制 sender_id 为认证用户,防止冒充
|
||||
let sender_id = operator_id.ok_or_else(|| {
|
||||
HealthError::Validation("sender_id 必须与认证用户匹配".into())
|
||||
})?;
|
||||
|
||||
// 事务包裹:消息 INSERT + 会话 CAS 更新,保证原子性
|
||||
let txn = state.db.begin().await?;
|
||||
|
||||
@@ -355,7 +352,7 @@ pub async fn create_message(
|
||||
tenant_id: Set(tenant_id),
|
||||
session_id: Set(req.session_id),
|
||||
sender_id: Set(sender_id),
|
||||
sender_role: Set(req.sender_role),
|
||||
sender_role: Set(sender_role),
|
||||
content_type: Set(content_type),
|
||||
content: Set(pii::encrypt(state.crypto.kek(), &req.content)?),
|
||||
is_read: Set(false),
|
||||
|
||||
@@ -78,7 +78,7 @@ pub async fn acknowledge_alert(
|
||||
active.acknowledged_at = Set(Some(now));
|
||||
active.updated_at = Set(now);
|
||||
active.updated_by = Set(Some(responder_id));
|
||||
active.version = Set(active.version.unwrap() + 1);
|
||||
active.version = Set(active.version.take().unwrap_or(0) + 1);
|
||||
critical_alert::Entity::update(active)
|
||||
.exec(&state.db)
|
||||
.await?;
|
||||
@@ -133,7 +133,7 @@ pub async fn scan_escalation(
|
||||
active.escalation_level = Set(1);
|
||||
active.status = Set("escalated".to_string());
|
||||
active.updated_at = Set(now);
|
||||
active.version = Set(active.version.unwrap() + 1);
|
||||
active.version = Set(active.version.take().unwrap_or(0) + 1);
|
||||
critical_alert::Entity::update(active)
|
||||
.exec(&state.db)
|
||||
.await?;
|
||||
@@ -158,7 +158,7 @@ pub async fn scan_escalation(
|
||||
let mut active: critical_alert::ActiveModel = alert.clone().into();
|
||||
active.escalation_level = Set(2);
|
||||
active.updated_at = Set(now);
|
||||
active.version = Set(active.version.unwrap() + 1);
|
||||
active.version = Set(active.version.take().unwrap_or(0) + 1);
|
||||
critical_alert::Entity::update(active)
|
||||
.exec(&state.db)
|
||||
.await?;
|
||||
|
||||
@@ -46,13 +46,13 @@ pub async fn list_templates(
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
|
||||
// 批量统计所有模板的字段数,避免 N+1 查询
|
||||
let template_ids: Vec<Uuid> = models.iter().map(|m| m.id).collect();
|
||||
let field_count_map = batch_count_template_fields(state, &template_ids).await?;
|
||||
|
||||
let mut data = Vec::with_capacity(models.len());
|
||||
for m in models {
|
||||
let field_count = follow_up_template_field::Entity::find()
|
||||
.filter(follow_up_template_field::Column::TemplateId.eq(m.id))
|
||||
.filter(follow_up_template_field::Column::DeletedAt.is_null())
|
||||
.count(&state.db)
|
||||
.await?;
|
||||
let field_count = field_count_map.get(&m.id).copied().unwrap_or(0);
|
||||
data.push(FollowUpTemplateListItemResp {
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
@@ -344,3 +344,30 @@ fn validate_field_type(val: &str) -> HealthResult<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 批量统计模板字段数量,避免 N+1 查询。
|
||||
/// 返回 HashMap<template_id, count>。
|
||||
async fn batch_count_template_fields(
|
||||
state: &HealthState,
|
||||
template_ids: &[Uuid],
|
||||
) -> HealthResult<std::collections::HashMap<Uuid, u64>> {
|
||||
use std::collections::HashMap;
|
||||
|
||||
if template_ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let ids: Vec<Uuid> = template_ids.to_vec();
|
||||
let fields = follow_up_template_field::Entity::find()
|
||||
.filter(follow_up_template_field::Column::TemplateId.is_in(ids))
|
||||
.filter(follow_up_template_field::Column::DeletedAt.is_null())
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
|
||||
let mut counts: HashMap<Uuid, u64> = HashMap::new();
|
||||
for f in fields {
|
||||
*counts.entry(f.template_id).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
Ok(counts)
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ pub async fn earn_points(
|
||||
// 3. 检查每日上限(用 account.id 而非 patient_id)
|
||||
if rule.daily_cap > 0 {
|
||||
let today = Utc::now().date_naive();
|
||||
let today_start = today.and_hms_opt(0, 0, 0).unwrap().and_utc();
|
||||
let today_start = today.and_hms_opt(0, 0, 0).expect("00:00:00 is always a valid time").and_utc();
|
||||
let earned_today: i32 = points_transaction::Entity::find()
|
||||
.filter(points_transaction::Column::TenantId.eq(tenant_id))
|
||||
.filter(points_transaction::Column::AccountId.eq(acc.id))
|
||||
@@ -322,7 +322,7 @@ async fn earn_points_in_txn<C: sea_orm::ConnectionTrait>(
|
||||
// 3. 检查每日上限
|
||||
if rule.daily_cap > 0 {
|
||||
let today = Utc::now().date_naive();
|
||||
let today_start = today.and_hms_opt(0, 0, 0).unwrap().and_utc();
|
||||
let today_start = today.and_hms_opt(0, 0, 0).expect("00:00:00 is always a valid time").and_utc();
|
||||
let earned_today: i32 = points_transaction::Entity::find()
|
||||
.filter(points_transaction::Column::TenantId.eq(tenant_id))
|
||||
.filter(points_transaction::Column::AccountId.eq(acc.id))
|
||||
|
||||
@@ -249,7 +249,7 @@ async fn test_alert_list_filter_and_tenant_isolation() {
|
||||
|
||||
// 按患者 A 过滤
|
||||
let (alerts_a, total_a) = alert_service::list_alerts(
|
||||
app.health_state(), app.tenant_id(), Some(patient_a), None, 1, 20,
|
||||
app.health_state(), app.tenant_id(), Some(patient_a), None, None, 1, 20,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -259,7 +259,7 @@ async fn test_alert_list_filter_and_tenant_isolation() {
|
||||
// 租户隔离
|
||||
let other_tenant = uuid::Uuid::new_v4();
|
||||
let (_alerts_other, total_other) = alert_service::list_alerts(
|
||||
app.health_state(), other_tenant, Some(patient_a), None, 1, 20,
|
||||
app.health_state(), other_tenant, Some(patient_a), None, None, 1, 20,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -236,7 +236,7 @@ async fn test_article_soft_delete() {
|
||||
.expect("删除应成功");
|
||||
|
||||
let result = article_service::get_article(
|
||||
app.health_state(), app.tenant_id(), article.id,
|
||||
app.health_state(), app.tenant_id(), article.id, true,
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err(), "软删除后查询应失败");
|
||||
@@ -252,7 +252,7 @@ async fn test_article_tenant_isolation() {
|
||||
|
||||
let other_tenant = uuid::Uuid::new_v4();
|
||||
let result = article_service::get_article(
|
||||
app.health_state(), other_tenant, article.id,
|
||||
app.health_state(), other_tenant, article.id, true,
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err(), "不同租户不应看到此文章");
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
//! 验证体征 CRUD、化验报告 CRUD + 审阅、租户隔离、乐观锁。
|
||||
|
||||
use erp_health::dto::health_data_dto::*;
|
||||
use erp_dialysis::dto::dialysis_dto::ReviewLabReportReq;
|
||||
use erp_health::service::health_data_service;
|
||||
|
||||
use super::test_fixture::TestApp;
|
||||
|
||||
@@ -14,6 +14,7 @@ use super::test_db::TestDb;
|
||||
pub struct TestApp {
|
||||
test_db: TestDb,
|
||||
health_state: HealthState,
|
||||
dialysis_state: DialysisState,
|
||||
tenant_id: uuid::Uuid,
|
||||
operator_id: uuid::Uuid,
|
||||
}
|
||||
@@ -26,9 +27,15 @@ impl TestApp {
|
||||
event_bus: EventBus::new(100),
|
||||
crypto: PiiCrypto::dev_default(),
|
||||
};
|
||||
let dialysis_state = DialysisState {
|
||||
db: test_db.db().clone(),
|
||||
event_bus: health_state.event_bus.clone(),
|
||||
crypto: health_state.crypto.clone(),
|
||||
};
|
||||
Self {
|
||||
test_db,
|
||||
health_state,
|
||||
dialysis_state,
|
||||
tenant_id: uuid::Uuid::new_v4(),
|
||||
operator_id: uuid::Uuid::new_v4(),
|
||||
}
|
||||
@@ -42,12 +49,8 @@ impl TestApp {
|
||||
&self.health_state
|
||||
}
|
||||
|
||||
pub fn dialysis_state(&self) -> DialysisState {
|
||||
DialysisState {
|
||||
db: self.test_db.db().clone(),
|
||||
event_bus: self.health_state.event_bus.clone(),
|
||||
crypto: self.health_state.crypto.clone(),
|
||||
}
|
||||
pub fn dialysis_state(&self) -> &DialysisState {
|
||||
&self.dialysis_state
|
||||
}
|
||||
|
||||
pub fn tenant_id(&self) -> uuid::Uuid {
|
||||
|
||||
Reference in New Issue
Block a user