feat: 病历模块优化、AI辅助问诊

This commit is contained in:
李琦
2026-08-06 08:14:33 +08:00
parent 0331325929
commit dc9f9c5c13
11 changed files with 2440 additions and 83 deletions

View File

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

View File

@@ -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>

View File

@@ -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 />

View File

@@ -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>

View File

@@ -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';

View File

@@ -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>

View File

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

View File

@@ -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>

View File

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

View File

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