feat: 病历模块优化、AI辅助问诊
This commit is contained in:
@@ -428,3 +428,41 @@ export async function saveGranularCommonPrescriptionApi(data: {
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
/** AI 给处方 */
|
||||
export async function aiGeneratePrescriptionApi(data: {
|
||||
register_id: number;
|
||||
store_id?: number;
|
||||
prescription_type: number;
|
||||
/** 前端确认过的主诉(可编辑) */
|
||||
chief_complaint?: string;
|
||||
/** 当前病历表单快照(可未保存),供 AI 综合参考 */
|
||||
medical_record?: Record<string, any>;
|
||||
}) {
|
||||
return requestClient.post<any>(`${prefix}ai-generate-prescription`, data);
|
||||
}
|
||||
|
||||
/** 本挂号 AI 生成历史(轻量列表) */
|
||||
export async function aiListGenerationsApi(data: {
|
||||
register_id: number;
|
||||
scene?: string;
|
||||
limit?: number;
|
||||
}) {
|
||||
return requestClient.get<any>(`${prefix}ai-list-generations`, { params: data });
|
||||
}
|
||||
|
||||
/** AI 生成详情(点选历史) */
|
||||
export async function aiGenerationDetailApi(data: { id: number }) {
|
||||
return requestClient.get<any>(`${prefix}ai-generation-detail`, { params: data });
|
||||
}
|
||||
|
||||
/** AI 处方药名模糊对照 */
|
||||
export async function aiMatchPrescriptionDrugsApi(data: {
|
||||
generation_id?: number;
|
||||
register_id?: number;
|
||||
store_id?: number;
|
||||
prescription_type?: number;
|
||||
items?: Array<Record<string, any>>;
|
||||
}) {
|
||||
return requestClient.post<any>(`${prefix}ai-match-prescription-drugs`, data);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,710 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* PC 接诊:AI 辅助出方
|
||||
* 主抽屉:历史列表 + 详情对照 + 确认导入
|
||||
* 二级弹窗:紧凑可编辑病历预览 → 生成处方(编辑同步回病历面板)
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer, useVbenModal } from '@vben/common-ui';
|
||||
import { Button, Descriptions, Input, Switch, Tag, message } from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
aiGeneratePrescriptionApi,
|
||||
aiListGenerationsApi,
|
||||
aiMatchPrescriptionDrugsApi,
|
||||
} from '../api';
|
||||
import PrescriptionLoading from '#/components/loading/PrescriptionLoading.vue';
|
||||
|
||||
/** 二级弹窗通栏字段(Descriptions span=2) */
|
||||
const FULL_SPAN_KEYS = new Set([
|
||||
'chief_complaint',
|
||||
'present_illness',
|
||||
'tcm_case',
|
||||
'treatment_advice',
|
||||
'doctor_order',
|
||||
'auxiliary_exam',
|
||||
'physical_exam',
|
||||
]);
|
||||
|
||||
/** 二级弹窗可编辑的病历字段(主诉单独置顶) */
|
||||
const MR_EDIT_FIELDS: Array<{ key: string; label: string }> = [
|
||||
{ key: 'present_illness', label: '现病史' },
|
||||
{ key: 'tongue', label: '舌象' },
|
||||
{ key: 'pulse', label: '脉象' },
|
||||
{ key: 'tcm_case', label: '中医病案' },
|
||||
{ key: 'tcm_disease', label: '中医疾病' },
|
||||
{ key: 'tcm_syndrome', label: '中医证候' },
|
||||
{ key: 'tcm_method', label: '中医治法' },
|
||||
{ key: 'diagnosis', label: '临床诊断' },
|
||||
{ key: 'treatment_advice', label: '治疗意见' },
|
||||
{ key: 'doctor_order', label: '医嘱' },
|
||||
{ key: 'allergy_history', label: '过敏史' },
|
||||
{ key: 'past_history', label: '既往史' },
|
||||
{ key: 'family_history', label: '家族史' },
|
||||
{ key: 'epidemic_history', label: '流行病学史' },
|
||||
{ key: 'personal_history', label: '个人史' },
|
||||
{ key: 'menstrual_history', label: '月经史' },
|
||||
{ key: 'marital_history', label: '婚育史' },
|
||||
{ key: 'physical_exam', label: '体征检查' },
|
||||
{ key: 'auxiliary_exam', label: '辅助检查' },
|
||||
];
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
e: 'import',
|
||||
payload: {
|
||||
rows: Array<Record<string, any>>;
|
||||
dosage?: number;
|
||||
day_dosage?: number;
|
||||
},
|
||||
): void;
|
||||
(e: 'search-drug', keyword: string): void;
|
||||
/** 预览里改病历字段时回写父页病历面板 */
|
||||
(e: 'sync-medical-record', payload: Record<string, any>): void;
|
||||
}>();
|
||||
|
||||
const registerId = ref(0);
|
||||
const storeId = ref(0);
|
||||
const prescriptionType = ref(0);
|
||||
const patientName = ref('');
|
||||
const patientSex = ref(0);
|
||||
const patientAge = ref(0);
|
||||
const chiefComplaint = ref('');
|
||||
const medicalRecord = ref<Record<string, any>>({});
|
||||
const histLoading = ref(false);
|
||||
const generating = ref(false);
|
||||
const matchLoading = ref(false);
|
||||
const historyList = ref<any[]>([]);
|
||||
const activeId = ref(0);
|
||||
const matchedList = ref<any[]>([]);
|
||||
const unmatchedList = ref<any[]>([]);
|
||||
const conflictMessages = ref<string[]>([]);
|
||||
const rxDosage = ref(0);
|
||||
const rxDayDosage = ref(0);
|
||||
const rxReason = ref('');
|
||||
const rxBasis = ref('');
|
||||
const rxPrescriptionName = ref('');
|
||||
/** 二级弹窗:当前正在编辑的字段 key,空表示浏览态 */
|
||||
const editingKey = ref('');
|
||||
|
||||
const sexLabel = computed(() => {
|
||||
const s = Number(patientSex.value);
|
||||
if (s === 1) return '男';
|
||||
if (s === 2) return '女';
|
||||
return '未知';
|
||||
});
|
||||
|
||||
const typeLabel = computed(() => {
|
||||
const t = Number(prescriptionType.value);
|
||||
if (t === 1) return '中药';
|
||||
if (t === 2) return '西(中成)药';
|
||||
return `类型 ${t || '—'}`;
|
||||
});
|
||||
|
||||
const isTcmRx = computed(() => Number(prescriptionType.value) === 1);
|
||||
|
||||
/** 二级弹窗紧凑展示:有内容的字段 + 主诉 */
|
||||
const genPreviewRows = computed(() => {
|
||||
const mr = medicalRecord.value || {};
|
||||
const rows: Array<{ key: string; label: string; value: string }> = [
|
||||
{
|
||||
key: 'chief_complaint',
|
||||
label: '主诉',
|
||||
value: String(chiefComplaint.value || mr.chief_complaint || '').trim(),
|
||||
},
|
||||
];
|
||||
for (const f of MR_EDIT_FIELDS) {
|
||||
const val = String(mr[f.key] ?? '').trim();
|
||||
if (val) rows.push({ key: f.key, label: f.label, value: val });
|
||||
}
|
||||
return rows;
|
||||
});
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
title: 'AI辅助出方',
|
||||
class: 'w-[920px]',
|
||||
// 按钮放内容区,关掉默认 footer,避免底部留白占高
|
||||
footer: false,
|
||||
showCancelButton: false,
|
||||
showConfirmButton: false,
|
||||
// 内容区改 flex 铺满,由内部滚动,避免双重裁切
|
||||
contentClass: '!flex !flex-col !overflow-hidden',
|
||||
onOpenChange(isOpen) {
|
||||
if (!isOpen) {
|
||||
resetState();
|
||||
return;
|
||||
}
|
||||
const data = drawerApi.getData<{
|
||||
registerId: number;
|
||||
storeId?: number;
|
||||
prescriptionType: number;
|
||||
patientName?: string;
|
||||
patientSex?: number;
|
||||
patientAge?: number;
|
||||
chiefComplaint?: string;
|
||||
medicalRecord?: Record<string, any>;
|
||||
}>();
|
||||
registerId.value = Number(data?.registerId || 0);
|
||||
storeId.value = Number(data?.storeId || 0);
|
||||
prescriptionType.value = Number(data?.prescriptionType || 0);
|
||||
patientName.value = String(data?.patientName || '');
|
||||
patientSex.value = Number(data?.patientSex || 0);
|
||||
patientAge.value = Number(data?.patientAge || 0);
|
||||
chiefComplaint.value = String(data?.chiefComplaint || '');
|
||||
medicalRecord.value =
|
||||
data?.medicalRecord && typeof data.medicalRecord === 'object'
|
||||
? { ...data.medicalRecord }
|
||||
: {};
|
||||
resetPreview();
|
||||
// 打开即进历史,并尽量选中第一条
|
||||
void bootstrapHistory();
|
||||
},
|
||||
});
|
||||
|
||||
const [GenModal, genModalApi] = useVbenModal({
|
||||
title: '生成处方',
|
||||
class: 'w-[920px]',
|
||||
confirmText: '开始生成',
|
||||
cancelText: '取消',
|
||||
// 校验失败不关;通过后立刻关弹窗,生成在抽屉内进行
|
||||
onConfirm: async () => confirmGenerateAndClose(),
|
||||
});
|
||||
|
||||
function resetPreview() {
|
||||
matchedList.value = [];
|
||||
unmatchedList.value = [];
|
||||
conflictMessages.value = [];
|
||||
rxDosage.value = 0;
|
||||
rxDayDosage.value = 0;
|
||||
rxReason.value = '';
|
||||
rxBasis.value = '';
|
||||
rxPrescriptionName.value = '';
|
||||
activeId.value = 0;
|
||||
editingKey.value = '';
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
generating.value = false;
|
||||
matchLoading.value = false;
|
||||
histLoading.value = false;
|
||||
historyList.value = [];
|
||||
medicalRecord.value = {};
|
||||
chiefComplaint.value = '';
|
||||
resetPreview();
|
||||
genModalApi.close();
|
||||
}
|
||||
|
||||
function formatTime(ts: number) {
|
||||
const n = Number(ts || 0);
|
||||
if (!n) return '—';
|
||||
const d = new Date(n * 1000);
|
||||
const pad = (x: number) => (x < 10 ? `0${x}` : `${x}`);
|
||||
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function typeLabelOf(t: number | string) {
|
||||
const n = Number(t || 0);
|
||||
if (n === 1) return '中药';
|
||||
if (n === 2) return '西药(中成药)';
|
||||
return n ? `类型 ${n}` : '—';
|
||||
}
|
||||
|
||||
function applyRxMeta(data: any) {
|
||||
rxDosage.value = Number(data?.dosage || 0);
|
||||
rxDayDosage.value = Number(data?.day_dosage || 0);
|
||||
rxReason.value = String(data?.reason || '').trim();
|
||||
rxBasis.value = String(data?.basis || '').trim();
|
||||
rxPrescriptionName.value = String(data?.prescription_name || '').trim();
|
||||
}
|
||||
|
||||
function applyConflict(conflict: any) {
|
||||
const msgs = Array.isArray(conflict?.messages) ? conflict.messages : [];
|
||||
conflictMessages.value = msgs
|
||||
.map((m: any) => String(m || '').trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function applyMatch(match: any) {
|
||||
const matched = Array.isArray(match?.matched) ? match.matched : [];
|
||||
matchedList.value = matched.map((m: any) => ({
|
||||
...m,
|
||||
_import: true,
|
||||
}));
|
||||
unmatchedList.value = Array.isArray(match?.unmatched) ? match.unmatched : [];
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
histLoading.value = true;
|
||||
try {
|
||||
const list = await aiListGenerationsApi({
|
||||
register_id: registerId.value,
|
||||
scene: 'prescription',
|
||||
limit: 30,
|
||||
});
|
||||
historyList.value = Array.isArray(list) ? list : [];
|
||||
} catch (e: any) {
|
||||
historyList.value = [];
|
||||
message.error(e?.message || e?.msg || '加载历史失败');
|
||||
} finally {
|
||||
histLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开抽屉后加载历史;有则选最新,无则自动打开生成框 */
|
||||
async function bootstrapHistory() {
|
||||
if (!registerId.value) return;
|
||||
await loadHistory();
|
||||
if (historyList.value.length) {
|
||||
await onSelectHistory(historyList.value[0]);
|
||||
} else {
|
||||
openGenerateModal();
|
||||
}
|
||||
}
|
||||
|
||||
function openGenerateModal() {
|
||||
if (!prescriptionType.value) {
|
||||
message.warning('请先选择处方类型');
|
||||
return;
|
||||
}
|
||||
editingKey.value = '';
|
||||
genModalApi.open();
|
||||
}
|
||||
|
||||
/**
|
||||
* 点字段进入编辑;改完 blur 后同步病历
|
||||
*/
|
||||
function startEdit(key: string) {
|
||||
editingKey.value = key;
|
||||
}
|
||||
|
||||
function fieldValue(key: string) {
|
||||
if (key === 'chief_complaint') return chiefComplaint.value;
|
||||
return String(medicalRecord.value?.[key] ?? '');
|
||||
}
|
||||
|
||||
function onFieldInput(key: string, val: string) {
|
||||
if (key === 'chief_complaint') {
|
||||
chiefComplaint.value = val;
|
||||
medicalRecord.value = {
|
||||
...(medicalRecord.value || {}),
|
||||
chief_complaint: val,
|
||||
};
|
||||
} else {
|
||||
medicalRecord.value = {
|
||||
...(medicalRecord.value || {}),
|
||||
[key]: val,
|
||||
};
|
||||
}
|
||||
emit('sync-medical-record', { [key]: val });
|
||||
}
|
||||
|
||||
function endEdit() {
|
||||
editingKey.value = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 二级弹窗点「开始生成」:先关弹窗,回抽屉等待 AI
|
||||
*/
|
||||
async function confirmGenerateAndClose(): Promise<boolean> {
|
||||
if (generating.value) return false;
|
||||
const chief = String(chiefComplaint.value || '').trim();
|
||||
if (!chief) {
|
||||
message.warning('请先填写主诉后再生成');
|
||||
return false;
|
||||
}
|
||||
if (!prescriptionType.value) {
|
||||
message.warning('请先选择处方类型');
|
||||
return false;
|
||||
}
|
||||
if (!registerId.value) {
|
||||
message.warning('挂号无效');
|
||||
return false;
|
||||
}
|
||||
// 先关模态,在抽屉内展示「AI 处理中」
|
||||
genModalApi.close();
|
||||
void runGenerateInDrawer(chief);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function runGenerateInDrawer(chief: string) {
|
||||
generating.value = true;
|
||||
try {
|
||||
const data = await aiGeneratePrescriptionApi({
|
||||
register_id: registerId.value,
|
||||
store_id: storeId.value || undefined,
|
||||
prescription_type: prescriptionType.value,
|
||||
chief_complaint: chief,
|
||||
medical_record: {
|
||||
...(medicalRecord.value || {}),
|
||||
chief_complaint: chief,
|
||||
},
|
||||
});
|
||||
await loadHistory();
|
||||
activeId.value = Number(data?.generation_id || 0);
|
||||
applyMatch(data?.match || null);
|
||||
applyConflict(data?.conflict || null);
|
||||
applyRxMeta(data);
|
||||
message.success('已生成,请确认导入');
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || e?.msg || '生成失败');
|
||||
} finally {
|
||||
generating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onSelectHistory(row: any) {
|
||||
if (!row?.id) return;
|
||||
activeId.value = Number(row.id);
|
||||
matchLoading.value = true;
|
||||
try {
|
||||
const data = await aiMatchPrescriptionDrugsApi({
|
||||
generation_id: Number(row.id),
|
||||
register_id: registerId.value,
|
||||
store_id: storeId.value || undefined,
|
||||
prescription_type: Number(
|
||||
prescriptionType.value || row.prescription_type || 0,
|
||||
),
|
||||
});
|
||||
applyMatch(data);
|
||||
applyConflict(data?.conflict || null);
|
||||
applyRxMeta({
|
||||
...data,
|
||||
prescription_name:
|
||||
data?.prescription_name || row?.name || row?.prescription_name || '',
|
||||
});
|
||||
} catch (e: any) {
|
||||
matchedList.value = [];
|
||||
unmatchedList.value = [];
|
||||
conflictMessages.value = [];
|
||||
message.error(e?.message || e?.msg || '对照失败');
|
||||
} finally {
|
||||
matchLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onConfirmImport() {
|
||||
const drugs: any[] = [];
|
||||
for (const m of matchedList.value) {
|
||||
if (!m || m._import === false) continue;
|
||||
const sid = Number(m.selected_drug_id || 0);
|
||||
if (!sid) continue;
|
||||
const picked = (m.candidates || []).find(
|
||||
(c: any) => Number(c.drug_id) === sid,
|
||||
);
|
||||
if (!picked) continue;
|
||||
drugs.push({
|
||||
candidate: picked,
|
||||
dose: m.dose || '',
|
||||
unit: m.unit || '',
|
||||
usage: m.usage || '',
|
||||
ai_name: m.ai_name || '',
|
||||
});
|
||||
}
|
||||
if (!drugs.length) {
|
||||
message.warning('请先勾选已对照药品');
|
||||
return;
|
||||
}
|
||||
emit('import', {
|
||||
rows: drugs,
|
||||
dosage: rxDosage.value || undefined,
|
||||
day_dosage: rxDayDosage.value || undefined,
|
||||
});
|
||||
drawerApi.close();
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open(payload: {
|
||||
registerId: number;
|
||||
storeId?: number;
|
||||
prescriptionType: number;
|
||||
patientName?: string;
|
||||
patientSex?: number;
|
||||
patientAge?: number;
|
||||
chiefComplaint?: string;
|
||||
medicalRecord?: Record<string, any>;
|
||||
}) {
|
||||
drawerApi.setData(payload);
|
||||
drawerApi.open();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer>
|
||||
<!-- 铺满抽屉内容区:头部/禁忌/按钮固定,中间历史+对照可滚动 -->
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden text-foreground">
|
||||
<div class="shrink-0 text-sm text-muted-foreground">
|
||||
{{ patientName || '—' }} · {{ sexLabel }} · {{ patientAge || '—' }}岁 ·
|
||||
{{ typeLabel }}
|
||||
</div>
|
||||
<div
|
||||
v-if="conflictMessages.length"
|
||||
class="shrink-0 rounded-lg border border-orange-500/35 bg-orange-500/10 px-3 py-2 text-xs"
|
||||
>
|
||||
<div class="mb-1 font-medium">配伍禁忌提示</div>
|
||||
<div v-for="(line, i) in conflictMessages" :key="i" class="text-muted-foreground">
|
||||
· {{ line }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex min-h-0 flex-1 gap-3 overflow-hidden">
|
||||
<div class="w-44 shrink-0 overflow-y-auto border-r border-border pr-2">
|
||||
<div class="mb-2 text-sm font-medium">历史记录</div>
|
||||
<div v-if="histLoading" class="text-xs text-muted-foreground">加载中…</div>
|
||||
<div v-else-if="!historyList.length" class="text-xs text-muted-foreground">
|
||||
暂无记录,可点击下方「生成处方」
|
||||
</div>
|
||||
<div
|
||||
v-for="row in historyList"
|
||||
:key="row.id"
|
||||
class="mb-1 cursor-pointer rounded-md px-2 py-1.5 text-xs transition-colors hover:bg-accent"
|
||||
:class="{
|
||||
'bg-primary/10 ring-1 ring-primary/20': activeId === Number(row.id),
|
||||
}"
|
||||
@click="onSelectHistory(row)"
|
||||
>
|
||||
<div class="truncate font-medium">
|
||||
{{ row.name || row.prescription_name || `方案 #${row.id}` }}
|
||||
</div>
|
||||
<div class="mt-0.5 text-muted-foreground">
|
||||
{{ row.prescription_type_label || typeLabelOf(row.prescription_type) }}
|
||||
· {{ row.item_count || 0 }}味 · {{ formatTime(row.created_at) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-h-0 min-w-0 flex-1 overflow-y-auto pl-1 pb-2">
|
||||
<div
|
||||
v-if="generating || matchLoading"
|
||||
class="flex h-full min-h-[260px] items-center justify-center"
|
||||
>
|
||||
<PrescriptionLoading
|
||||
type="prescription"
|
||||
:text="
|
||||
generating ? 'AI正在生成处方' : 'AI正在对照药品'
|
||||
"
|
||||
class="h-[280px] w-full max-w-[420px]"
|
||||
/>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="!matchedList.length && !unmatchedList.length"
|
||||
class="py-8 text-center text-muted-foreground"
|
||||
>
|
||||
请选择左侧历史,或点击「生成处方」
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="mb-1.5 text-xs font-medium text-muted-foreground">
|
||||
已对照({{ matchedList.length }})
|
||||
</div>
|
||||
<!-- 中药:一行多味网格;西药:仍用较完整卡片 -->
|
||||
<div
|
||||
v-if="isTcmRx"
|
||||
class="grid grid-cols-2 gap-1.5 xl:grid-cols-3"
|
||||
>
|
||||
<div
|
||||
v-for="(m, mi) in matchedList"
|
||||
:key="mi"
|
||||
class="rounded border border-border bg-card px-1.5 py-1"
|
||||
>
|
||||
<div class="flex items-start gap-1">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-xs font-medium">{{ m.ai_name }}</div>
|
||||
<div class="text-[11px] text-muted-foreground">
|
||||
{{ m.dose }}{{ m.unit }}{{ m.usage ? `·${m.usage}` : '' }}
|
||||
</div>
|
||||
</div>
|
||||
<Switch v-model:checked="m._import" size="small" />
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap gap-0.5">
|
||||
<Tag
|
||||
v-for="c in m.candidates || []"
|
||||
:key="c.drug_id"
|
||||
class="cursor-pointer !m-0 !px-1 !text-[10px] !leading-4"
|
||||
:color="
|
||||
Number(m.selected_drug_id) === Number(c.drug_id)
|
||||
? 'success'
|
||||
: undefined
|
||||
"
|
||||
@click="m.selected_drug_id = Number(c.drug_id)"
|
||||
>
|
||||
{{ c.drug_name }}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="(m, mi) in matchedList"
|
||||
:key="mi"
|
||||
class="rounded-lg border border-border bg-card p-2.5"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="min-w-0 flex-1 truncate font-medium">{{ m.ai_name }}</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ m.dose }}{{ m.unit }}{{ m.usage ? `·${m.usage}` : '' }}
|
||||
</span>
|
||||
<Switch v-model:checked="m._import" size="small" />
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap gap-1">
|
||||
<Tag
|
||||
v-for="c in m.candidates || []"
|
||||
:key="c.drug_id"
|
||||
class="cursor-pointer !m-0 !text-[11px]"
|
||||
:color="
|
||||
Number(m.selected_drug_id) === Number(c.drug_id)
|
||||
? 'success'
|
||||
: undefined
|
||||
"
|
||||
@click="m.selected_drug_id = Number(c.drug_id)"
|
||||
>
|
||||
{{ c.drug_name }} ¥{{ Number(c.price || 0).toFixed(2) }}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="unmatchedList.length"
|
||||
class="mb-1.5 mt-3 text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
未对照({{ unmatchedList.length }})
|
||||
</div>
|
||||
<div
|
||||
v-for="(u, ui) in unmatchedList"
|
||||
:key="ui"
|
||||
class="mb-1.5 rounded border border-orange-500/30 bg-orange-500/10 px-2 py-1.5 text-sm"
|
||||
>
|
||||
<div class="font-medium">{{ u.ai_name }}</div>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
class="!h-auto !px-0"
|
||||
@click="
|
||||
emit('search-drug', u.ai_name || '');
|
||||
drawerApi.close();
|
||||
"
|
||||
>
|
||||
去手动搜索
|
||||
</Button>
|
||||
</div>
|
||||
<!-- 药方摘要:Descriptions 双列展示名称/剂数/出方理由 -->
|
||||
<Descriptions
|
||||
v-if="
|
||||
rxPrescriptionName ||
|
||||
rxReason ||
|
||||
rxBasis ||
|
||||
rxDosage ||
|
||||
rxDayDosage
|
||||
"
|
||||
:column="2"
|
||||
bordered
|
||||
size="small"
|
||||
class="ai-rx-meta-desc mt-3"
|
||||
title="出方说明"
|
||||
>
|
||||
<Descriptions.Item label="药方名" :span="2">
|
||||
{{ rxPrescriptionName || '未命名药方' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="推荐剂数">
|
||||
{{ rxDosage || '—' }}剂
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="每天几次">
|
||||
{{ rxDayDosage || '—' }}次
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item
|
||||
v-if="rxReason"
|
||||
label="为什么出这个方"
|
||||
:span="2"
|
||||
>
|
||||
<div class="max-h-28 overflow-y-auto whitespace-pre-wrap leading-relaxed">
|
||||
{{ rxReason }}
|
||||
</div>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item
|
||||
v-if="rxBasis"
|
||||
label="根据哪些"
|
||||
:span="2"
|
||||
>
|
||||
<div class="max-h-28 overflow-y-auto whitespace-pre-wrap leading-relaxed">
|
||||
{{ rxBasis }}
|
||||
</div>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 justify-end gap-2 border-t border-border pt-3">
|
||||
<Button @click="drawerApi.close()">关闭</Button>
|
||||
<Button type="default" @click="openGenerateModal">生成处方</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
:disabled="generating || matchLoading || !matchedList.length"
|
||||
@click="onConfirmImport"
|
||||
>
|
||||
确认导入
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
|
||||
<!-- 二级:病历预览(可点改)+ 生成;Descriptions 双列 -->
|
||||
<GenModal>
|
||||
<div class="space-y-2 text-sm text-foreground">
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{{ patientName || '—' }} · {{ sexLabel }} · {{ patientAge || '—' }}岁 ·
|
||||
{{ typeLabel }} · 点击文字可编辑,将同步到病历
|
||||
</div>
|
||||
<div class="max-h-[58vh] overflow-y-auto">
|
||||
<Descriptions
|
||||
v-if="genPreviewRows.length"
|
||||
:column="2"
|
||||
bordered
|
||||
size="small"
|
||||
class="ai-rx-gen-desc"
|
||||
>
|
||||
<Descriptions.Item
|
||||
v-for="row in genPreviewRows"
|
||||
:key="row.key"
|
||||
:label="row.label"
|
||||
:span="FULL_SPAN_KEYS.has(row.key) ? 2 : 1"
|
||||
>
|
||||
<Input.TextArea
|
||||
v-if="editingKey === row.key"
|
||||
:value="fieldValue(row.key)"
|
||||
:rows="row.key === 'chief_complaint' ? 3 : 2"
|
||||
autofocus
|
||||
@update:value="(v) => onFieldInput(row.key, String(v ?? ''))"
|
||||
@blur="endEdit"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="max-h-24 cursor-text overflow-y-auto whitespace-pre-wrap leading-relaxed"
|
||||
title="点击编辑"
|
||||
@click="startEdit(row.key)"
|
||||
>
|
||||
{{ fieldValue(row.key) || '(空,点击填写)' }}
|
||||
</div>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<div
|
||||
v-if="genPreviewRows.length <= 1"
|
||||
class="px-2 py-3 text-center text-xs text-muted-foreground"
|
||||
>
|
||||
仅有主诉。建议先在病历区补充现病史、舌脉等,再生成。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GenModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ai-rx-gen-desc :deep(.ant-descriptions-item-label),
|
||||
.ai-rx-meta-desc :deep(.ant-descriptions-item-label) {
|
||||
width: 110px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ai-rx-gen-desc :deep(.ant-descriptions-item-content),
|
||||
.ai-rx-meta-desc :deep(.ant-descriptions-item-content) {
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
PlusOutlined,
|
||||
RobotOutlined,
|
||||
SaveOutlined,
|
||||
StopOutlined,
|
||||
SwapOutlined,
|
||||
@@ -85,6 +86,7 @@ import RefusalOfTreatmentModal
|
||||
from "#/views/doctor/doctor-reception/components/RefusalOfTreatmentModal.vue";
|
||||
// 常用方选择弹窗组件
|
||||
import CommonPrescriptionModal from './components/CommonPrescriptionModal.vue';
|
||||
import AiPrescriptionDrawer from './components/AiPrescriptionDrawer.vue';
|
||||
// 确认模态框组件
|
||||
import ConfirmModal from './components/ConfirmModal.vue';
|
||||
// 信息提示模态框组件
|
||||
@@ -1264,6 +1266,177 @@ function openCommonPrescriptionModal() {
|
||||
CommonPrescriptionModalApi.open();
|
||||
}
|
||||
|
||||
const aiPrescriptionDrawerRef = ref<InstanceType<typeof AiPrescriptionDrawer> | null>(null);
|
||||
|
||||
/** 打开 AI 给处方抽屉:先确认患者与主诉,再生成预览 */
|
||||
function openAiPrescriptionDrawer() {
|
||||
if (guardSpecialPrescriptionCartEdit()) return;
|
||||
const registerId = Number.parseInt(
|
||||
localStorage.getItem(`doctorReception-id`) || '0',
|
||||
);
|
||||
if (!registerId) {
|
||||
message.warning('请先选择患者');
|
||||
return;
|
||||
}
|
||||
if (!activeCategory.value) {
|
||||
message.warning('请先选择处方类型');
|
||||
return;
|
||||
}
|
||||
const payload = medicalRecordPanelRef.value?.getPayload?.() || {};
|
||||
aiPrescriptionDrawerRef.value?.open({
|
||||
registerId,
|
||||
storeId: Number(myStoreId.value || 0) || undefined,
|
||||
prescriptionType: Number(activeCategory.value),
|
||||
patientName: String(activePatient.value?.name || ''),
|
||||
patientSex: Number(activePatient.value?.sex || 0),
|
||||
patientAge: Number(activePatient.value?.age || 0),
|
||||
chiefComplaint: String(payload.chief_complaint || ''),
|
||||
// 带入当前病历表单(含未保存内容),出方时综合参考而非只看主诉
|
||||
medicalRecord: { ...payload },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 出方预览里改病历字段时,回写到当前病历面板(不强制保存)
|
||||
*/
|
||||
function handleSyncAiMedicalRecord(partial: Record<string, any>) {
|
||||
medicalRecordPanelRef.value?.patchFields?.(partial || {});
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 AI 对照结果覆盖导入当前处方(非追加)
|
||||
* 中药会同步剂数/每日次数,并按 usage 匹配先煎后下 way_id
|
||||
*/
|
||||
function handleImportAiPrescription(payload: {
|
||||
rows?: any[];
|
||||
dosage?: number;
|
||||
day_dosage?: number;
|
||||
}) {
|
||||
if (guardSpecialPrescriptionCartEdit()) return;
|
||||
const list = Array.isArray(payload?.rows) ? payload.rows : [];
|
||||
if (!list.length) {
|
||||
message.warning('没有可导入的药品');
|
||||
return;
|
||||
}
|
||||
const resolveWayId = (usage: string) => {
|
||||
const u = String(usage || '').trim();
|
||||
if (!u) return 0;
|
||||
const found = (drugUseWay.value || []).find(
|
||||
(w: any) =>
|
||||
String(w.name || '') === u || String(w.name || '').includes(u),
|
||||
);
|
||||
return found ? Number(found.id || 0) : 0;
|
||||
};
|
||||
const doOverwrite = () => {
|
||||
clearAppliedSpecialPrescription();
|
||||
const next: any[] = [];
|
||||
for (const row of list) {
|
||||
const c = row?.candidate || {};
|
||||
const drugId = Number(c.drug_id || 0);
|
||||
if (!drugId) continue;
|
||||
if (next.find((item: any) => Number(item.id) === drugId)) continue;
|
||||
const doseNum = parseFloat(row.dose);
|
||||
const qty = !Number.isNaN(doseNum) && doseNum > 0 ? doseNum : 1;
|
||||
const drug = c.drug || {};
|
||||
if (activeCategory.value === 1) {
|
||||
const wayId = resolveWayId(row.usage || '');
|
||||
next.push({
|
||||
index_id: c.id || drugId,
|
||||
id: drugId,
|
||||
drug_name: c.drug_name || row.ai_name,
|
||||
number: qty,
|
||||
price: parseFloat(c.price) || 0,
|
||||
way_id: wayId,
|
||||
use_ways: drugUseWay.value.find((item: any) => item.id === wayId),
|
||||
select_number: 1,
|
||||
unit: c.unit || { id: 0, name: row.unit || 'g' },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (activeCategory.value === 2) {
|
||||
next.push({
|
||||
index_id: c.id || drugId,
|
||||
id: drugId,
|
||||
drug_name: c.drug_name || drug.drug_name || row.ai_name,
|
||||
number: qty,
|
||||
select_number: qty,
|
||||
price: parseFloat(c.price) || 0,
|
||||
image: c.image || drug.image || '',
|
||||
instruction: drug.instruction || '',
|
||||
time_id: c.time_id || drug.time_id || 0,
|
||||
type_id: c.type_id || drug.type_id || 0,
|
||||
frequency_id: c.frequency_id || drug.frequency_id || 0,
|
||||
unit_id: c.unit_id || drug.unit_id || 0,
|
||||
use_num: drugTime.value.find(
|
||||
(item) => item.id === (c.time_id || drug.time_id || 0),
|
||||
),
|
||||
use_type: drugUseType.value.find(
|
||||
(item) => item.id === (c.type_id || drug.type_id || 0),
|
||||
),
|
||||
use_frequency: drugUseFrequency.value.find(
|
||||
(item) => item.id === (c.frequency_id || drug.frequency_id || 0),
|
||||
),
|
||||
unit:
|
||||
c.unit ||
|
||||
drugUnit.value.find(
|
||||
(item) => item.id === (c.unit_id || drug.unit_id || 0),
|
||||
),
|
||||
type: 2,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
next.push({
|
||||
index_id: c.id || drugId,
|
||||
id: drugId,
|
||||
drug_name: c.drug_name || row.ai_name,
|
||||
number: 1,
|
||||
select_number: 1,
|
||||
price: parseFloat(c.price) || 0,
|
||||
image: c.image || '',
|
||||
type: activeCategory.value,
|
||||
});
|
||||
}
|
||||
if (!next.length) {
|
||||
message.warning('没有可导入的药品');
|
||||
return;
|
||||
}
|
||||
currentDrugs.value = next;
|
||||
if (activeCategory.value === 1) {
|
||||
if (payload.dosage != null && Number(payload.dosage) > 0) {
|
||||
dosage.value = Number(payload.dosage);
|
||||
}
|
||||
if (payload.day_dosage != null && Number(payload.day_dosage) > 0) {
|
||||
dayDosage.value = Number(payload.day_dosage);
|
||||
}
|
||||
}
|
||||
updateLocalStorage();
|
||||
message.success(`已覆盖导入 ${next.length} 个药品`);
|
||||
};
|
||||
if (currentDrugs.value.length > 0) {
|
||||
AntModal.confirm({
|
||||
title: '覆盖当前处方',
|
||||
content: '导入将清空并覆盖现有处方药品,是否继续?',
|
||||
okText: '继续覆盖',
|
||||
cancelText: '取消',
|
||||
onOk: () => doOverwrite(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
doOverwrite();
|
||||
}
|
||||
|
||||
/** 未对照药名:提示手动搜索 */
|
||||
function handleAiSearchUnmatchedDrug(keyword: string) {
|
||||
const key = String(keyword || '');
|
||||
if (activeCategory.value === 2) {
|
||||
openWesternModal();
|
||||
return;
|
||||
}
|
||||
if (key) {
|
||||
message.info(`请手动搜索:${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理选择常用方
|
||||
* @param data 常用方数据,包含prescription和recipes
|
||||
@@ -2947,6 +3120,15 @@ function onStoreSelectOpenChange(open: boolean) {
|
||||
<BookOutlined />
|
||||
选择常用方
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!isSpecialPrescriptionCartLocked"
|
||||
type="link"
|
||||
size="small"
|
||||
@click="openAiPrescriptionDrawer"
|
||||
>
|
||||
<RobotOutlined />
|
||||
AI辅助出方
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canUseCommonPrescription"
|
||||
type="link"
|
||||
@@ -3007,6 +3189,14 @@ function onStoreSelectOpenChange(open: boolean) {
|
||||
<HistoryOutlined />
|
||||
引用历史病历
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
@click="medicalRecordPanelRef?.aiGenerate?.()"
|
||||
>
|
||||
<RobotOutlined />
|
||||
AI写病历
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
@@ -3745,6 +3935,7 @@ function onStoreSelectOpenChange(open: boolean) {
|
||||
:user-patient-id="Number(activePatient?.id || 0)"
|
||||
:patient-sex="Number(activePatient?.sex || 0)"
|
||||
:patient-age="Number(activePatient?.age || 0)"
|
||||
:patient-name="String(activePatient?.name || '')"
|
||||
:show-toolbar="false"
|
||||
/>
|
||||
</div>
|
||||
@@ -3767,6 +3958,12 @@ function onStoreSelectOpenChange(open: boolean) {
|
||||
<PrescriptionDetailModal/>
|
||||
<!-- 常用方选择弹窗 -->
|
||||
<CommonPrescriptionModals/>
|
||||
<AiPrescriptionDrawer
|
||||
ref="aiPrescriptionDrawerRef"
|
||||
@import="handleImportAiPrescription"
|
||||
@search-drug="handleAiSearchUnmatchedDrug"
|
||||
@sync-medical-record="handleSyncAiMedicalRecord"
|
||||
/>
|
||||
<!-- 确认添加药品弹窗 -->
|
||||
<ConfirmModalComponent />
|
||||
<PriceAdjustDrawer />
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import {
|
||||
ClearOutlined,
|
||||
HistoryOutlined,
|
||||
RobotOutlined,
|
||||
SaveOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
@@ -35,6 +36,7 @@ import EntryKeywordBubble, {
|
||||
import CommonDxOrderChips from './components/CommonDxOrderChips.vue';
|
||||
import PatientMedicalRecordDrawer from './components/PatientMedicalRecordDrawer.vue';
|
||||
import TextareaExpandModal from './components/TextareaExpandModal.vue';
|
||||
import AiMedicalRecordModal from './components/AiMedicalRecordModal.vue';
|
||||
import { emptyMedicalRecord, isHistoryFieldVisible } from './config/constants';
|
||||
import {
|
||||
clearMedicalRecordDraft,
|
||||
@@ -62,6 +64,8 @@ const props = withDefaults(
|
||||
patientSex?: number;
|
||||
/** 就诊人年龄,用于婚育史显隐 */
|
||||
patientAge?: number;
|
||||
/** 就诊人姓名(AI 写病历确认用) */
|
||||
patientName?: string;
|
||||
/**
|
||||
* 是否在面板内展示操作工具栏
|
||||
* 接诊台/弹窗已把按钮提到一级 Tab 下吸顶工具栏时传 false
|
||||
@@ -75,6 +79,7 @@ const props = withDefaults(
|
||||
storagePrefix: '',
|
||||
patientSex: 0,
|
||||
patientAge: 0,
|
||||
patientName: '',
|
||||
showToolbar: true,
|
||||
},
|
||||
);
|
||||
@@ -579,6 +584,54 @@ async function handleSave() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开 AI 写病历抽屉:先进历史;无历史自动打开生成框
|
||||
*/
|
||||
function handleAiGenerate() {
|
||||
if (!props.registerId) {
|
||||
message.warning('无挂号信息');
|
||||
return;
|
||||
}
|
||||
aiMedicalRecordModalRef.value?.open({
|
||||
registerId: props.registerId,
|
||||
storeId: props.storeId || undefined,
|
||||
patientName: props.patientName || '',
|
||||
patientSex: Number(props.patientSex || 0),
|
||||
patientAge: Number(props.patientAge || 0),
|
||||
chiefComplaint: String(form.chief_complaint || ''),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 AI 预览结果导入病历表单(不自动保存)
|
||||
*/
|
||||
function handleImportAiMedicalRecord(payload: {
|
||||
chief_complaint: string;
|
||||
fields: Record<string, string>;
|
||||
}) {
|
||||
const fields = payload?.fields || {};
|
||||
Object.keys(fields).forEach((k) => {
|
||||
if (k === 'chief_complaint' || k === 'name') return;
|
||||
// 诊断与处方同源,走双向绑定而不是仅写 form
|
||||
if (k === 'diagnosis') {
|
||||
const dx = fields[k] == null ? '' : String(fields[k]);
|
||||
form.diagnosis = dx;
|
||||
emit('update:diagnosis', dx);
|
||||
return;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(form, k)) {
|
||||
(form as any)[k] = fields[k] == null ? '' : String(fields[k]);
|
||||
}
|
||||
});
|
||||
if (payload?.chief_complaint) {
|
||||
form.chief_complaint = payload.chief_complaint;
|
||||
}
|
||||
persistLocalDraft();
|
||||
message.success('已导入,请检查后保存');
|
||||
}
|
||||
|
||||
const aiMedicalRecordModalRef = ref<InstanceType<typeof AiMedicalRecordModal> | null>(null);
|
||||
|
||||
async function handleClear() {
|
||||
if (!props.registerId) return;
|
||||
await clearMedicalRecord({
|
||||
@@ -637,6 +690,7 @@ defineExpose({
|
||||
loadRecord,
|
||||
applyCitedRecord,
|
||||
openCiteHistory: handleCiteHistory,
|
||||
aiGenerate: handleAiGenerate,
|
||||
loading,
|
||||
getPayload: () => ({
|
||||
...form,
|
||||
@@ -644,6 +698,30 @@ defineExpose({
|
||||
diagnosis: diagnosisModel.value,
|
||||
doctor_order: medicalAdviceModel.value,
|
||||
}),
|
||||
/**
|
||||
* 外部局部回写病历字段(如 AI 出方预览里点改),同步表单与本地草稿,不自动落库
|
||||
*/
|
||||
patchFields: (partial: Record<string, any>) => {
|
||||
if (!partial || typeof partial !== 'object') return;
|
||||
Object.keys(partial).forEach((k) => {
|
||||
const raw = partial[k];
|
||||
const val = raw == null ? '' : String(raw);
|
||||
if (k === 'diagnosis') {
|
||||
form.diagnosis = val;
|
||||
emit('update:diagnosis', val);
|
||||
return;
|
||||
}
|
||||
if (k === 'doctor_order' || k === 'medicalAdvice') {
|
||||
form.doctor_order = val;
|
||||
emit('update:medicalAdvice', val);
|
||||
return;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(form, k)) {
|
||||
(form as any)[k] = val;
|
||||
}
|
||||
});
|
||||
persistLocalDraft();
|
||||
},
|
||||
});
|
||||
|
||||
/** source:走中医三典气泡;默认 entry 走病历词条 */
|
||||
@@ -692,6 +770,8 @@ const allNormalFields = [
|
||||
fieldBlock('personal_history', '个人史'),
|
||||
fieldBlock('allergy_history', '过敏史'),
|
||||
fieldBlock('treatment_advice', '治疗意见'),
|
||||
// 诊断/医嘱与处方 Tab 同源双向同步(此前网格漏渲染导致病历侧「诊断」消失)
|
||||
fieldBlock('diagnosis', '临床诊断'),
|
||||
fieldBlock('doctor_order', '医嘱'),
|
||||
fieldBlock('physical_exam', '体征检查'),
|
||||
fieldBlock('auxiliary_exam', '辅助检查'),
|
||||
@@ -724,6 +804,10 @@ const visibleNormalFields = computed(() =>
|
||||
<HistoryOutlined />
|
||||
引用历史病历
|
||||
</Button>
|
||||
<Button type="link" size="small" :loading="loading" @click="handleAiGenerate">
|
||||
<RobotOutlined />
|
||||
AI写病历
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
@@ -747,8 +831,25 @@ const visibleNormalFields = computed(() =>
|
||||
>
|
||||
<div class="mr-field-head mb-1">
|
||||
<span class="mr-field-label">{{ f.label }}</span>
|
||||
<!-- 诊断/医嘱走处方同源气泡;其余走病历词条 -->
|
||||
<div
|
||||
v-if="f.fieldCode === 'doctor_order'"
|
||||
v-if="f.fieldCode === 'diagnosis'"
|
||||
class="mr-field-search"
|
||||
data-mr-module="diagnosis"
|
||||
data-mr-role="search"
|
||||
>
|
||||
<EntryKeywordBubble
|
||||
:ref="(el) => setSearchRef('diagnosis', el)"
|
||||
source="diagnosis"
|
||||
placeholder="诊断关键字"
|
||||
compact
|
||||
:show-common-in-bubble="showCommonInBubble"
|
||||
@select="(item) => onEntryPick('diagnosis', item)"
|
||||
@common-changed="commonChipsTick += 1"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="f.fieldCode === 'doctor_order'"
|
||||
class="mr-field-search"
|
||||
data-mr-module="doctor_order"
|
||||
data-mr-role="search"
|
||||
@@ -848,7 +949,34 @@ const visibleNormalFields = computed(() =>
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<template v-if="f.fieldCode === 'doctor_order'">
|
||||
<template v-if="f.fieldCode === 'diagnosis'">
|
||||
<div data-mr-module="diagnosis" data-mr-role="textarea">
|
||||
<Textarea
|
||||
:ref="(el) => setTextareaRef('diagnosis', el)"
|
||||
v-model:value="diagnosisModel"
|
||||
:rows="2"
|
||||
:readonly="diagnosisReadonly"
|
||||
placeholder="临床诊断(与处方同步,双击放大)"
|
||||
@dblclick="openDiagnosisExpand"
|
||||
/>
|
||||
</div>
|
||||
<CommonDxOrderChips
|
||||
v-if="showCommonTags && !diagnosisReadonly"
|
||||
:key="`dx_chips_${commonChipsTick}`"
|
||||
source="diagnosis"
|
||||
:field-value="diagnosisModel"
|
||||
@select="
|
||||
(text) =>
|
||||
onEntryPick('diagnosis', {
|
||||
id: text,
|
||||
title: text,
|
||||
content: text,
|
||||
remark: '',
|
||||
})
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="f.fieldCode === 'doctor_order'">
|
||||
<div data-mr-module="doctor_order" data-mr-role="textarea">
|
||||
<Textarea
|
||||
:ref="(el) => setTextareaRef('doctor_order', el)"
|
||||
@@ -1075,6 +1203,10 @@ const visibleNormalFields = computed(() =>
|
||||
|
||||
<ExpandModal />
|
||||
<CiteHistoryDrawer />
|
||||
<AiMedicalRecordModal
|
||||
ref="aiMedicalRecordModalRef"
|
||||
@import="handleImportAiMedicalRecord"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
|
||||
@@ -84,6 +84,33 @@ export async function getMedicalRecordListByPatient(data: {
|
||||
return requestClient.get<any>(`${prefix}list-by-patient`, { params: data });
|
||||
}
|
||||
|
||||
/** AI 写病历(需先填主诉;返回草稿字段) */
|
||||
export async function aiGenerateMedicalRecord(data: {
|
||||
register_id: number;
|
||||
store_id?: number;
|
||||
chief_complaint?: string;
|
||||
}) {
|
||||
return requestClient.post<any>(`${prefix}ai-generate-medical-record`, data);
|
||||
}
|
||||
|
||||
/** 本挂号 AI 生成历史(与接诊共用 doctor-reception 接口) */
|
||||
export async function aiListGenerations(data: {
|
||||
register_id: number;
|
||||
scene?: string;
|
||||
limit?: number;
|
||||
}) {
|
||||
return requestClient.get<any>('doctor-reception/ai-list-generations', {
|
||||
params: data,
|
||||
});
|
||||
}
|
||||
|
||||
/** AI 生成详情(点选历史) */
|
||||
export async function aiGenerationDetail(data: { id: number }) {
|
||||
return requestClient.get<any>('doctor-reception/ai-generation-detail', {
|
||||
params: data,
|
||||
});
|
||||
}
|
||||
|
||||
/** Excel 解析后批量导入词条 */
|
||||
export async function importMedicalRecordEntry(data: {
|
||||
scope: 'public' | 'store';
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* PC:AI 写病历
|
||||
* 主抽屉:生成历史 + 预览 + 确认导入
|
||||
* 二级弹窗:确认主诉后点生成 → 关弹窗,回抽屉等待结果
|
||||
* 无历史时打开抽屉后自动弹出生成框
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer, useVbenModal } from '@vben/common-ui';
|
||||
import { Button, Descriptions, Input, message } from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
aiGenerateMedicalRecord,
|
||||
aiGenerationDetail,
|
||||
aiListGenerations,
|
||||
} from '../api';
|
||||
import { MEDICAL_RECORD_FIELD_OPTIONS } from '../config/constants';
|
||||
import PrescriptionLoading from '#/components/loading/PrescriptionLoading.vue';
|
||||
|
||||
/** Descriptions 通栏字段 */
|
||||
const FULL_SPAN_KEYS = new Set([
|
||||
'present_illness',
|
||||
'tcm_case',
|
||||
'treatment_advice',
|
||||
'auxiliary_exam',
|
||||
'physical_exam',
|
||||
'chief_complaint',
|
||||
]);
|
||||
/** 预览字段顺序(不含主诉) */
|
||||
const PREVIEW_FIELDS = [
|
||||
{ label: '现病史', value: 'present_illness' },
|
||||
{ label: '舌象', value: 'tongue' },
|
||||
{ label: '脉象', value: 'pulse' },
|
||||
{ label: '中医病案', value: 'tcm_case' },
|
||||
{ label: '中医证候', value: 'tcm_syndrome' },
|
||||
{ label: '中医疾病', value: 'tcm_disease' },
|
||||
{ label: '中医治法', value: 'tcm_method' },
|
||||
{ label: '临床诊断', value: 'diagnosis' },
|
||||
{ label: '治疗意见', value: 'treatment_advice' },
|
||||
...MEDICAL_RECORD_FIELD_OPTIONS.filter((f) =>
|
||||
[
|
||||
'family_history',
|
||||
'epidemic_history',
|
||||
'past_history',
|
||||
'menstrual_history',
|
||||
'marital_history',
|
||||
'personal_history',
|
||||
'allergy_history',
|
||||
'physical_exam',
|
||||
'auxiliary_exam',
|
||||
].includes(f.value),
|
||||
),
|
||||
];
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
e: 'import',
|
||||
payload: { chief_complaint: string; fields: Record<string, string> },
|
||||
): void;
|
||||
}>();
|
||||
|
||||
const registerId = ref(0);
|
||||
const storeId = ref(0);
|
||||
const patientName = ref('');
|
||||
const patientSex = ref(0);
|
||||
const patientAge = ref(0);
|
||||
const chiefComplaint = ref('');
|
||||
const histLoading = ref(false);
|
||||
const generating = ref(false);
|
||||
const detailLoading = ref(false);
|
||||
const historyList = ref<any[]>([]);
|
||||
const activeId = ref(0);
|
||||
const previewFields = ref<Record<string, string>>({});
|
||||
const previewChief = ref('');
|
||||
|
||||
const sexLabel = computed(() => {
|
||||
const s = Number(patientSex.value);
|
||||
if (s === 1) return '男';
|
||||
if (s === 2) return '女';
|
||||
return '未知';
|
||||
});
|
||||
|
||||
const previewRows = computed(() =>
|
||||
PREVIEW_FIELDS.map((f) => ({
|
||||
label: f.label,
|
||||
value: String(previewFields.value[f.value] || '').trim(),
|
||||
code: f.value,
|
||||
})).filter((r) => r.value !== ''),
|
||||
);
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
title: 'AI写病历',
|
||||
class: 'w-[860px]',
|
||||
footer: false,
|
||||
showCancelButton: false,
|
||||
showConfirmButton: false,
|
||||
contentClass: '!flex !flex-col !overflow-hidden',
|
||||
onOpenChange(isOpen) {
|
||||
if (!isOpen) {
|
||||
resetState();
|
||||
return;
|
||||
}
|
||||
const data = drawerApi.getData<{
|
||||
registerId: number;
|
||||
storeId?: number;
|
||||
patientName?: string;
|
||||
patientSex?: number;
|
||||
patientAge?: number;
|
||||
chiefComplaint?: string;
|
||||
}>();
|
||||
registerId.value = Number(data?.registerId || 0);
|
||||
storeId.value = Number(data?.storeId || 0);
|
||||
patientName.value = String(data?.patientName || '');
|
||||
patientSex.value = Number(data?.patientSex || 0);
|
||||
patientAge.value = Number(data?.patientAge || 0);
|
||||
chiefComplaint.value = String(data?.chiefComplaint || '');
|
||||
previewFields.value = {};
|
||||
previewChief.value = '';
|
||||
activeId.value = 0;
|
||||
void bootstrapHistory();
|
||||
},
|
||||
});
|
||||
|
||||
const [GenModal, genModalApi] = useVbenModal({
|
||||
title: '生成病历',
|
||||
class: 'w-[560px]',
|
||||
confirmText: '开始生成',
|
||||
cancelText: '取消',
|
||||
onConfirm: async () => confirmGenerateAndClose(),
|
||||
});
|
||||
|
||||
function resetState() {
|
||||
histLoading.value = false;
|
||||
generating.value = false;
|
||||
detailLoading.value = false;
|
||||
historyList.value = [];
|
||||
activeId.value = 0;
|
||||
previewFields.value = {};
|
||||
previewChief.value = '';
|
||||
chiefComplaint.value = '';
|
||||
genModalApi.close();
|
||||
}
|
||||
|
||||
function formatTime(ts: number) {
|
||||
const n = Number(ts || 0);
|
||||
if (!n) return '—';
|
||||
const d = new Date(n * 1000);
|
||||
const pad = (x: number) => (x < 10 ? `0${x}` : `${x}`);
|
||||
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
histLoading.value = true;
|
||||
try {
|
||||
const list = await aiListGenerations({
|
||||
register_id: registerId.value,
|
||||
scene: 'medical_record',
|
||||
limit: 30,
|
||||
});
|
||||
historyList.value = Array.isArray(list) ? list : [];
|
||||
} catch (e: any) {
|
||||
historyList.value = [];
|
||||
message.error(e?.message || e?.msg || '加载历史失败');
|
||||
} finally {
|
||||
histLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 有历史选最新;无历史自动打开生成框 */
|
||||
async function bootstrapHistory() {
|
||||
if (!registerId.value) return;
|
||||
await loadHistory();
|
||||
if (historyList.value.length) {
|
||||
await onSelectHistory(historyList.value[0]);
|
||||
} else {
|
||||
openGenerateModal();
|
||||
}
|
||||
}
|
||||
|
||||
function openGenerateModal() {
|
||||
genModalApi.open();
|
||||
}
|
||||
|
||||
async function onSelectHistory(row: any) {
|
||||
if (!row?.id) return;
|
||||
activeId.value = Number(row.id);
|
||||
detailLoading.value = true;
|
||||
try {
|
||||
const data = await aiGenerationDetail({ id: Number(row.id) });
|
||||
const fields = (data?.fields || {}) as Record<string, string>;
|
||||
previewFields.value = { ...fields };
|
||||
const snap = data?.input_snapshot || {};
|
||||
previewChief.value = String(
|
||||
snap.chief_complaint || chiefComplaint.value || '',
|
||||
).trim();
|
||||
} catch (e: any) {
|
||||
previewFields.value = {};
|
||||
previewChief.value = '';
|
||||
message.error(e?.message || e?.msg || '加载详情失败');
|
||||
} finally {
|
||||
detailLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 先关弹窗,回抽屉等待生成 */
|
||||
async function confirmGenerateAndClose(): Promise<boolean> {
|
||||
if (generating.value) return false;
|
||||
const chief = chiefComplaint.value.trim();
|
||||
if (!chief) {
|
||||
message.warning('请先填写主诉后再生成');
|
||||
return false;
|
||||
}
|
||||
if (!registerId.value) {
|
||||
message.warning('挂号无效');
|
||||
return false;
|
||||
}
|
||||
genModalApi.close();
|
||||
void runGenerateInDrawer(chief);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function runGenerateInDrawer(chief: string) {
|
||||
generating.value = true;
|
||||
previewFields.value = {};
|
||||
previewChief.value = chief;
|
||||
try {
|
||||
const data = await aiGenerateMedicalRecord({
|
||||
register_id: registerId.value,
|
||||
store_id: storeId.value || undefined,
|
||||
chief_complaint: chief,
|
||||
});
|
||||
previewFields.value = (data?.fields || {}) as Record<string, string>;
|
||||
await loadHistory();
|
||||
activeId.value = Number(data?.generation_id || 0);
|
||||
message.success('已生成,请预览后确认导入');
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || e?.msg || 'AI写病历失败');
|
||||
} finally {
|
||||
generating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onConfirmImport() {
|
||||
if (!Object.keys(previewFields.value || {}).length && !previewChief.value) {
|
||||
message.warning('暂无可导入内容');
|
||||
return;
|
||||
}
|
||||
emit('import', {
|
||||
chief_complaint: String(previewChief.value || chiefComplaint.value || '').trim(),
|
||||
fields: { ...previewFields.value },
|
||||
});
|
||||
drawerApi.close();
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open(payload: {
|
||||
registerId: number;
|
||||
storeId?: number;
|
||||
patientName?: string;
|
||||
patientSex?: number;
|
||||
patientAge?: number;
|
||||
chiefComplaint?: string;
|
||||
}) {
|
||||
drawerApi.setData(payload);
|
||||
drawerApi.open();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer>
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden text-foreground">
|
||||
<div class="shrink-0 text-sm text-muted-foreground">
|
||||
{{ patientName || '—' }} · {{ sexLabel }} · {{ patientAge || '—' }}岁
|
||||
</div>
|
||||
<div class="flex min-h-0 flex-1 gap-3 overflow-hidden">
|
||||
<div class="w-44 shrink-0 overflow-y-auto border-r border-border pr-2">
|
||||
<div class="mb-2 text-sm font-medium">历史记录</div>
|
||||
<div v-if="histLoading" class="text-xs text-muted-foreground">加载中…</div>
|
||||
<div v-else-if="!historyList.length" class="text-xs text-muted-foreground">
|
||||
暂无记录,可点击下方「生成病历」
|
||||
</div>
|
||||
<div
|
||||
v-for="row in historyList"
|
||||
:key="row.id"
|
||||
class="mb-1 cursor-pointer rounded-md px-2 py-1.5 text-xs transition-colors hover:bg-accent"
|
||||
:class="{
|
||||
'bg-primary/10 ring-1 ring-primary/20': activeId === Number(row.id),
|
||||
}"
|
||||
@click="onSelectHistory(row)"
|
||||
>
|
||||
<div class="truncate font-medium">
|
||||
{{ row.name || `病历 #${row.id}` }}
|
||||
</div>
|
||||
<div class="mt-0.5 text-muted-foreground">
|
||||
{{ formatTime(row.created_at) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-h-0 min-w-0 flex-1 overflow-y-auto pl-1 pb-2">
|
||||
<div
|
||||
v-if="generating || detailLoading"
|
||||
class="flex h-full min-h-[260px] items-center justify-center"
|
||||
>
|
||||
<PrescriptionLoading
|
||||
type="medical_record"
|
||||
:text="
|
||||
generating ? 'AI正在生成病历' : 'AI正在加载病历'
|
||||
"
|
||||
class="h-[280px] w-full max-w-[420px]"
|
||||
/>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="!previewRows.length && !previewChief"
|
||||
class="py-8 text-center text-muted-foreground"
|
||||
>
|
||||
请选择左侧历史,或点击「生成病历」
|
||||
</div>
|
||||
<template v-else>
|
||||
<Descriptions
|
||||
v-if="previewChief || previewRows.length"
|
||||
:column="2"
|
||||
bordered
|
||||
size="small"
|
||||
class="ai-mr-preview-desc"
|
||||
>
|
||||
<Descriptions.Item
|
||||
v-if="previewChief"
|
||||
label="主诉"
|
||||
:span="2"
|
||||
>
|
||||
<div class="max-h-20 overflow-y-auto whitespace-pre-wrap leading-relaxed">
|
||||
{{ previewChief }}
|
||||
</div>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item
|
||||
v-for="row in previewRows"
|
||||
:key="row.code"
|
||||
:label="row.label"
|
||||
:span="FULL_SPAN_KEYS.has(row.code) ? 2 : 1"
|
||||
>
|
||||
<div class="max-h-20 overflow-y-auto whitespace-pre-wrap leading-relaxed">
|
||||
{{ row.value }}
|
||||
</div>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<div
|
||||
v-else
|
||||
class="py-4 text-center text-xs text-muted-foreground"
|
||||
>
|
||||
暂无生成字段
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 justify-end gap-2 border-t border-border pt-3">
|
||||
<Button @click="drawerApi.close()">关闭</Button>
|
||||
<Button type="default" @click="openGenerateModal">生成病历</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
:disabled="generating || detailLoading || (!previewRows.length && !previewChief)"
|
||||
@click="onConfirmImport"
|
||||
>
|
||||
确认导入
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
|
||||
<GenModal>
|
||||
<div class="space-y-3 text-sm text-foreground">
|
||||
<div class="rounded-lg border border-border bg-muted/40 p-3">
|
||||
<div class="mb-1 font-medium">患者信息确认</div>
|
||||
<div class="text-muted-foreground">
|
||||
姓名:{{ patientName || '—' }} 性别:{{ sexLabel }} 年龄:{{
|
||||
patientAge || '—'
|
||||
}}岁
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-2 font-medium">主诉(可编辑)</div>
|
||||
<Input.TextArea
|
||||
v-model:value="chiefComplaint"
|
||||
:rows="4"
|
||||
placeholder="请确认或补充主诉后再生成"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
点击「开始生成」后将关闭本弹窗,请在抽屉中等待结果。
|
||||
</div>
|
||||
</div>
|
||||
</GenModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ai-mr-preview-desc :deep(.ant-descriptions-item-label) {
|
||||
width: 88px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ai-mr-preview-desc :deep(.ant-descriptions-item-content) {
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -175,7 +175,10 @@ async function handleImportFile(file: File) {
|
||||
importing.value = true;
|
||||
try {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const parsed = await parseMedicalRecordEntryExcelBuffer(buffer);
|
||||
// 按字段拆分页传入 defaultFieldCode:允许 Excel 字段编码留空 / 填中文名
|
||||
const parsed = await parseMedicalRecordEntryExcelBuffer(buffer, {
|
||||
defaultFieldCode: props.fixedFieldCode || '',
|
||||
});
|
||||
let rows = parsed.items;
|
||||
if (props.fixedFieldCode) {
|
||||
rows = rows
|
||||
@@ -183,10 +186,13 @@ async function handleImportFile(file: File) {
|
||||
.filter((r) => r.title);
|
||||
}
|
||||
if (!rows.length) {
|
||||
const detail = parsed.firstInvalidReason
|
||||
? `:${parsed.firstInvalidReason}`
|
||||
: '';
|
||||
message.warning(
|
||||
parsed.invalidCount
|
||||
? `没有可导入的有效行(无效 ${parsed.invalidCount} 行)`
|
||||
: '文件无有效数据',
|
||||
? `没有可导入的有效行(无效 ${parsed.invalidCount} 行)${detail}`
|
||||
: '文件无有效数据,请确认填写的是「词条导入」工作表',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
* 患者历史病历抽屉
|
||||
* - view:只读查看
|
||||
* - cite:选择一条后点「引用此病历」回填到当前接诊病历
|
||||
* (修复:原 latest 接口会排除当前挂号,仅有本次病历时会误报「暂无」)
|
||||
* 右侧预览用 Descriptions 双列,跳过空字段
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer } from '@vben/common-ui';
|
||||
import { Empty, Spin, Tag, message } from 'ant-design-vue';
|
||||
import { Descriptions, Empty, Spin, Tag, message } from 'ant-design-vue';
|
||||
|
||||
import { getMedicalRecordListByPatient } from '../api';
|
||||
import { MEDICAL_RECORD_VIEW_FIELDS } from '../config/constants';
|
||||
@@ -19,14 +19,22 @@ type DrawerData = {
|
||||
userPatientId?: number;
|
||||
storeId?: number;
|
||||
patientName?: string;
|
||||
/** view=查看;cite=引用到当前病历 */
|
||||
mode?: 'view' | 'cite';
|
||||
/** 引用模式下排除当前挂号(仍展示,但默认选中其他挂号) */
|
||||
excludeRegisterId?: number;
|
||||
/** 引用确认回调 */
|
||||
onCite?: (record: Record<string, any>) => void;
|
||||
};
|
||||
|
||||
/** Descriptions 通栏字段 */
|
||||
const FULL_SPAN_KEYS = new Set([
|
||||
'chief_complaint',
|
||||
'present_illness',
|
||||
'tcm_case',
|
||||
'treatment_advice',
|
||||
'doctor_order',
|
||||
'auxiliary_exam',
|
||||
'physical_exam',
|
||||
]);
|
||||
|
||||
const loading = ref(false);
|
||||
const list = ref<Record<string, any>[]>([]);
|
||||
const activeId = ref(0);
|
||||
@@ -38,7 +46,6 @@ const activeRecord = computed(() => {
|
||||
return list.value.find((r) => Number(r.id) === activeId.value) || null;
|
||||
});
|
||||
|
||||
/** 体征摘要文案 */
|
||||
function vitalsText(row: Record<string, any>) {
|
||||
const parts: string[] = [];
|
||||
if (row.temperature) parts.push(`体温 ${row.temperature}℃`);
|
||||
@@ -56,13 +63,25 @@ function fieldDisplay(row: Record<string, any>, key: string) {
|
||||
const body = String(row.physical_exam || '').trim();
|
||||
const vitals = vitalsText(row);
|
||||
if (body && vitals) return `${vitals}\n${body}`;
|
||||
return body || vitals || '—';
|
||||
return body || vitals || '';
|
||||
}
|
||||
const v = row[key];
|
||||
if (v === null || v === undefined || v === '' || v === 0) return '—';
|
||||
return String(v);
|
||||
if (v === null || v === undefined || v === '' || v === 0) return '';
|
||||
return String(v).trim();
|
||||
}
|
||||
|
||||
/** 仅有内容的字段,供 Descriptions 渲染 */
|
||||
const filledViewFields = computed(() => {
|
||||
const row = activeRecord.value;
|
||||
if (!row) return [];
|
||||
return MEDICAL_RECORD_VIEW_FIELDS.map((f) => ({
|
||||
label: f.label,
|
||||
value: f.value,
|
||||
text: fieldDisplay(row, f.value),
|
||||
span: FULL_SPAN_KEYS.has(f.value) ? 2 : 1,
|
||||
})).filter((f) => !!f.text);
|
||||
});
|
||||
|
||||
function listTitle(row: Record<string, any>) {
|
||||
const dx = String(row.diagnosis || '').trim();
|
||||
if (dx) return dx.length > 28 ? `${dx.slice(0, 28)}…` : dx;
|
||||
@@ -74,9 +93,6 @@ function isCurrentRegister(row: Record<string, any>) {
|
||||
return exclude > 0 && Number(row.register_id) === exclude;
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认选中:优先「非当前挂号」的最近一条,否则选第一条
|
||||
*/
|
||||
function pickDefaultActiveId(rows: Record<string, any>[]) {
|
||||
if (!rows.length) return 0;
|
||||
const exclude = Number(meta.value.excludeRegisterId || 0);
|
||||
@@ -113,7 +129,7 @@ async function loadList() {
|
||||
}
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
class: 'w-[860px]',
|
||||
class: 'w-[900px]',
|
||||
title: '查看患者病历',
|
||||
cancelText: '关闭',
|
||||
confirmText: '引用此病历',
|
||||
@@ -177,27 +193,50 @@ const [Drawer, drawerApi] = useVbenDrawer({
|
||||
</div>
|
||||
<div class="mr-view-detail">
|
||||
<template v-if="activeRecord">
|
||||
<div class="mr-view-detail__head">
|
||||
<span>挂号 ID:{{ activeRecord.register_id || '-' }}</span>
|
||||
<span>创建:{{ activeRecord.created_at || '—' }}</span>
|
||||
<span>更新:{{ activeRecord.updated_at || '—' }}</span>
|
||||
</div>
|
||||
<Descriptions
|
||||
:column="2"
|
||||
bordered
|
||||
size="small"
|
||||
class="mb-3 mr-view-meta-desc"
|
||||
>
|
||||
<Descriptions.Item label="挂号 ID">
|
||||
{{ activeRecord.register_id || '-' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="创建">
|
||||
{{ activeRecord.created_at || '—' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="更新" :span="2">
|
||||
{{ activeRecord.updated_at || '—' }}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<div
|
||||
v-if="isCiteMode && isCurrentRegister(activeRecord)"
|
||||
class="mr-view-tip"
|
||||
>
|
||||
当前选中的是本次挂号病历,引用后会覆盖编辑区内容(仍可继续改)。
|
||||
</div>
|
||||
<div
|
||||
v-for="f in MEDICAL_RECORD_VIEW_FIELDS"
|
||||
:key="f.value"
|
||||
class="mr-view-field"
|
||||
<Empty
|
||||
v-if="!filledViewFields.length"
|
||||
description="该病历暂无填写内容"
|
||||
class="py-6"
|
||||
/>
|
||||
<Descriptions
|
||||
v-else
|
||||
:column="2"
|
||||
bordered
|
||||
size="small"
|
||||
title="病历内容"
|
||||
class="mr-view-content-desc"
|
||||
>
|
||||
<div class="mr-view-field__label">{{ f.label }}</div>
|
||||
<div class="mr-view-field__value">
|
||||
{{ fieldDisplay(activeRecord, f.value) }}
|
||||
</div>
|
||||
</div>
|
||||
<Descriptions.Item
|
||||
v-for="f in filledViewFields"
|
||||
:key="f.value"
|
||||
:label="f.label"
|
||||
:span="f.span"
|
||||
>
|
||||
<div class="mr-view-content-text">{{ f.text }}</div>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</template>
|
||||
<Empty v-else description="请选择左侧病历" />
|
||||
</div>
|
||||
@@ -208,22 +247,22 @@ const [Drawer, drawerApi] = useVbenDrawer({
|
||||
<style scoped>
|
||||
.mr-view-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 240px 1fr;
|
||||
gap: 16px;
|
||||
min-height: 520px;
|
||||
grid-template-columns: 220px 1fr;
|
||||
gap: 12px;
|
||||
min-height: 480px;
|
||||
}
|
||||
.mr-view-list {
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
padding-right: 12px;
|
||||
padding-right: 10px;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.mr-view-item {
|
||||
padding: 10px 12px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 6px;
|
||||
background: hsl(var(--muted) / 0.35);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
@@ -237,56 +276,44 @@ const [Drawer, drawerApi] = useVbenDrawer({
|
||||
.mr-view-item__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
line-height: 1.35;
|
||||
word-break: break-all;
|
||||
}
|
||||
.mr-view-item__meta {
|
||||
margin-top: 6px;
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
.mr-view-detail {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
.mr-view-detail__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
padding-right: 2px;
|
||||
}
|
||||
.mr-view-tip {
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 10px;
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
color: #ad6800;
|
||||
background: #fff7e6;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.mr-view-field {
|
||||
margin-bottom: 12px;
|
||||
.mr-view-meta-desc :deep(.ant-descriptions-item-label),
|
||||
.mr-view-content-desc :deep(.ant-descriptions-item-label) {
|
||||
width: 88px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mr-view-field__label {
|
||||
.mr-view-content-desc :deep(.ant-descriptions-item-content) {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.mr-view-field__value {
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
.mr-view-content-text {
|
||||
max-height: 88px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
background: hsl(var(--muted) / 0.4);
|
||||
min-height: 36px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -78,8 +78,28 @@ export async function exportMedicalRecordEntryTemplate(): Promise<ArrayBuffer> {
|
||||
});
|
||||
});
|
||||
tip.addRow([]);
|
||||
tip.addRow([
|
||||
'填写说明',
|
||||
'「字段编码」可填右侧英文编码,也可直接填左侧中文名(如:主诉)',
|
||||
]);
|
||||
tip.addRow(['状态说明', '1=启用,0=禁用;空默认启用']);
|
||||
tip.addRow(['拼音首拼', '由系统根据词条名称自动生成,无需填写']);
|
||||
tip.addRow([
|
||||
'按字段菜单导入',
|
||||
'在「主诉/既往史」等拆分页导入时,字段编码列可留空,系统自动归属当前字段',
|
||||
]);
|
||||
|
||||
// 给「字段编码」列加下拉,减少填错中文/编码混用导致整表无效
|
||||
const codeList = MEDICAL_RECORD_FIELD_OPTIONS.map((o) => o.value).join(',');
|
||||
// Excel 数据验证公式长度有限;字段不多时可直接列枚举
|
||||
sheet.dataValidations.add('A2:A2001', {
|
||||
type: 'list',
|
||||
allowBlank: true,
|
||||
formulae: [`"${codeList}"`],
|
||||
showErrorMessage: true,
|
||||
errorTitle: '字段编码',
|
||||
error: '请从下拉选择英文编码,或手动填写中文名(如:主诉)',
|
||||
});
|
||||
|
||||
return workbook.xlsx.writeBuffer() as Promise<ArrayBuffer>;
|
||||
}
|
||||
|
||||
@@ -17,18 +17,45 @@ export type ParseMedicalRecordEntryResult = {
|
||||
items: ParsedMedicalRecordEntryRow[];
|
||||
invalidCount: number;
|
||||
rowCount: number;
|
||||
/** 首条无效原因,便于前端提示(如填了中文名未识别等) */
|
||||
firstInvalidReason?: string;
|
||||
};
|
||||
|
||||
export type ParseMedicalRecordEntryOptions = {
|
||||
/**
|
||||
* 按字段拆分菜单导入时传入:字段编码列可留空,自动落到当前字段
|
||||
* 为什么需要:运营在「主诉词条」页导入时常不填字段编码,旧逻辑会整行判无效
|
||||
*/
|
||||
defaultFieldCode?: string;
|
||||
};
|
||||
|
||||
function normalizeCell(value: ExcelJS.CellValue): string {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'object' && 'text' in value) {
|
||||
return String((value as any).text ?? '').trim();
|
||||
if (typeof value === 'object') {
|
||||
// 富文本:Excel/WPS 局部加粗后常见 { richText: [...] }
|
||||
if ('richText' in value && Array.isArray((value as any).richText)) {
|
||||
return (value as any).richText
|
||||
.map((p: { text?: string }) => p?.text ?? '')
|
||||
.join('')
|
||||
.trim();
|
||||
}
|
||||
// 超链接 / 带 text 的复合值
|
||||
if ('text' in value) {
|
||||
return String((value as any).text ?? '').trim();
|
||||
}
|
||||
// 公式结果
|
||||
if ('result' in value) {
|
||||
return normalizeCell((value as any).result as ExcelJS.CellValue);
|
||||
}
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return Number.isInteger(value) ? String(value) : String(value).trim();
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? '1' : '0';
|
||||
}
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
@@ -45,6 +72,23 @@ const STATUS_ALIASES = ['状态', 'status'];
|
||||
|
||||
const VALID_FIELDS = new Set(MEDICAL_RECORD_FIELD_OPTIONS.map((o) => o.value));
|
||||
|
||||
/** 中文名 / 英文编码 → 标准 field_code(运营常按「字段编码说明」里的中文名填写) */
|
||||
const FIELD_LABEL_TO_CODE = new Map<string, string>(
|
||||
MEDICAL_RECORD_FIELD_OPTIONS.flatMap((o) => [
|
||||
[normalizeHeader(o.value), o.value],
|
||||
[normalizeHeader(o.label), o.value],
|
||||
]),
|
||||
);
|
||||
|
||||
/**
|
||||
* 把单元格里的字段编码或中文名解析成标准 code;无法识别则返回空串
|
||||
*/
|
||||
function resolveFieldCode(raw: string): string {
|
||||
const key = normalizeHeader(raw);
|
||||
if (!key) return '';
|
||||
return FIELD_LABEL_TO_CODE.get(key) || (VALID_FIELDS.has(raw) ? raw : '');
|
||||
}
|
||||
|
||||
function buildHeaderIndexMap(headerRow: ExcelJS.Row): Record<string, number> {
|
||||
const map: Record<string, number> = {};
|
||||
headerRow.eachCell({ includeEmpty: true }, (cell, colNumber) => {
|
||||
@@ -81,25 +125,38 @@ function getCellInt(row: ExcelJS.Row, col?: number, fallback = 0): number {
|
||||
}
|
||||
|
||||
function rowIsEmpty(row: ExcelJS.Row): boolean {
|
||||
let empty = true;
|
||||
row.eachCell({ includeEmpty: false }, () => {
|
||||
empty = false;
|
||||
let hasValue = false;
|
||||
row.eachCell({ includeEmpty: false }, (cell) => {
|
||||
if (normalizeCell(cell.value) !== '') {
|
||||
hasValue = true;
|
||||
}
|
||||
});
|
||||
return empty;
|
||||
return !hasValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 优先取「词条导入」sheet;避免用户改完后激活了「字段编码说明」导致读错表
|
||||
*/
|
||||
function pickImportSheet(workbook: ExcelJS.Workbook): ExcelJS.Worksheet | undefined {
|
||||
const byName = workbook.worksheets.find((s) => s.name === '词条导入');
|
||||
return byName || workbook.worksheets[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析词条导入 Excel(首行为表头)
|
||||
* 字段编码列支持英文 code 或中文名;按字段页可传 defaultFieldCode 允许留空
|
||||
*/
|
||||
export async function parseMedicalRecordEntryExcelBuffer(
|
||||
buffer: ArrayBuffer,
|
||||
options: ParseMedicalRecordEntryOptions = {},
|
||||
): Promise<ParseMedicalRecordEntryResult> {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(buffer);
|
||||
const sheet = workbook.worksheets[0];
|
||||
const sheet = pickImportSheet(workbook);
|
||||
if (!sheet) {
|
||||
return { items: [], invalidCount: 0, rowCount: 0 };
|
||||
}
|
||||
const defaultFieldCode = resolveFieldCode(options.defaultFieldCode || '');
|
||||
const headerRow = sheet.getRow(1);
|
||||
const headerMap = buildHeaderIndexMap(headerRow);
|
||||
const fieldCol = pickColumn(headerMap, FIELD_ALIASES);
|
||||
@@ -108,21 +165,44 @@ export async function parseMedicalRecordEntryExcelBuffer(
|
||||
const remarkCol = pickColumn(headerMap, REMARK_ALIASES);
|
||||
const sortCol = pickColumn(headerMap, SORT_ALIASES);
|
||||
const statusCol = pickColumn(headerMap, STATUS_ALIASES);
|
||||
if (!fieldCol || !titleCol) {
|
||||
throw new Error('模板表头缺少「字段编码」或「词条名称」列');
|
||||
// 无默认字段时必须有「字段编码」列;有默认字段时仅要求「词条名称」
|
||||
if (!titleCol) {
|
||||
throw new Error('模板表头缺少「词条名称」列,请重新下载模板');
|
||||
}
|
||||
if (!fieldCol && !defaultFieldCode) {
|
||||
throw new Error('模板表头缺少「字段编码」列,请重新下载模板');
|
||||
}
|
||||
const items: ParsedMedicalRecordEntryRow[] = [];
|
||||
let invalidCount = 0;
|
||||
let rowCount = 0;
|
||||
sheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
|
||||
if (rowNumber === 1 || rowIsEmpty(row)) return;
|
||||
let firstInvalidReason: string | undefined;
|
||||
const maxRow = Math.min(sheet.rowCount || 0, MAX_ROWS + 1);
|
||||
for (let rowNumber = 2; rowNumber <= maxRow; rowNumber++) {
|
||||
const row = sheet.getRow(rowNumber);
|
||||
if (rowIsEmpty(row)) continue;
|
||||
rowCount++;
|
||||
if (items.length >= MAX_ROWS) return;
|
||||
const field_code = getCellText(row, fieldCol);
|
||||
if (items.length >= MAX_ROWS) break;
|
||||
const rawField = getCellText(row, fieldCol);
|
||||
const title = getCellText(row, titleCol);
|
||||
if (!field_code || !title || !VALID_FIELDS.has(field_code)) {
|
||||
let field_code = resolveFieldCode(rawField);
|
||||
if (!field_code && defaultFieldCode) {
|
||||
field_code = defaultFieldCode;
|
||||
}
|
||||
if (!title) {
|
||||
invalidCount++;
|
||||
return;
|
||||
if (!firstInvalidReason) {
|
||||
firstInvalidReason = `第${rowNumber}行:词条名称为空`;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!field_code) {
|
||||
invalidCount++;
|
||||
if (!firstInvalidReason) {
|
||||
firstInvalidReason = rawField
|
||||
? `第${rowNumber}行:字段「${rawField}」无法识别(请填英文编码如 chief_complaint,或中文名如「主诉」)`
|
||||
: `第${rowNumber}行:字段编码为空(请对照「字段编码说明」填写,或从对应字段菜单页导入)`;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const statusRaw = getCellInt(row, statusCol, 1);
|
||||
items.push({
|
||||
@@ -133,6 +213,6 @@ export async function parseMedicalRecordEntryExcelBuffer(
|
||||
sort: getCellInt(row, sortCol, 0),
|
||||
status: statusRaw === 0 ? 0 : 1,
|
||||
});
|
||||
});
|
||||
return { items, invalidCount, rowCount };
|
||||
}
|
||||
return { items, invalidCount, rowCount, firstInvalidReason };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user