feat: 病历模块优化

This commit is contained in:
李琦
2026-08-05 16:22:46 +08:00
parent 029b45d0bb
commit bd5b0b2de1
17 changed files with 971 additions and 140 deletions

View File

@@ -65,6 +65,11 @@ export const usePrescriptionStore = defineStore('prescription', () => {
{ label: '医疗器械', value: 7, icon: '', icon_text: '' },
]);
const prescriptionTypeDefault = ref(2);
/** 开方种类权限校验中(进入开方页先 loading */
const prescriptionTypeLoading = ref(false);
/** 当前门店是否有可开方种类 */
const prescriptionTypeAllowed = ref(true);
const prescriptionTypeDenyMessage = ref('');
const diagnosis = ref('');
const medicalAdvice = ref('');
const treatmentPrice = ref(0);
@@ -204,9 +209,10 @@ export const usePrescriptionStore = defineStore('prescription', () => {
};
/**
* 拉取后端处方类型列表与默认选中
* 拉取后端处方类型列表与默认选中;无绑定时清空 list 并返回 false
*/
const loadPrescriptionTypeOptions = async (registerId?: number | string) => {
prescriptionTypeLoading.value = true;
try {
const params: { register_id?: number; store_id?: number } = {};
const rid = Number(registerId || currentRegisterId.value);
@@ -214,19 +220,34 @@ export const usePrescriptionStore = defineStore('prescription', () => {
if (myStoreId.value) params.store_id = myStoreId.value;
const res = await getPrescriptionTypeOptionsApi(params);
const list = Array.isArray(res?.list) ? res.list : [];
if (list.length) {
categories.value = list.map((item) => ({
value: Number(item.value),
label: item.label || '',
icon: item.icon || '',
icon_text: item.icon_text || '',
}));
categories.value = list.map((item) => ({
value: Number(item.value),
label: item.label || '',
icon: item.icon || '',
icon_text: item.icon_text || '',
}));
const def = Number(res?.default) || 0;
prescriptionTypeDefault.value = def;
const allowed =
res?.allowed !== false && list.length > 0;
prescriptionTypeAllowed.value = allowed;
prescriptionTypeDenyMessage.value = allowed
? ''
: (res?.message || '当前门店未开通开方权限');
// 当前 Tab 不在 allowed 内时强制切到 default
if (allowed && def && !categories.value.some((c) => c.value === activeCategory.value)) {
activeCategory.value = def;
}
const def = Number(res?.default);
if (def) prescriptionTypeDefault.value = def;
return allowed;
} catch (error) {
console.error('加载处方类型失败:', error);
categories.value = [];
prescriptionTypeAllowed.value = false;
prescriptionTypeDenyMessage.value = '处方类型加载失败,请稍后重试';
message.warning('处方类型加载失败,请稍后重试');
return false;
} finally {
prescriptionTypeLoading.value = false;
}
};
@@ -1249,6 +1270,9 @@ export const usePrescriptionStore = defineStore('prescription', () => {
activeCategory,
categories,
prescriptionTypeDefault,
prescriptionTypeLoading,
prescriptionTypeAllowed,
prescriptionTypeDenyMessage,
diagnosis,
medicalAdvice,
treatmentPrice,

View File

@@ -29,6 +29,7 @@ import {
Row,
Select,
SelectOption,
Spin,
Tag,
Tabs,
TabPane,
@@ -180,6 +181,8 @@ watch(allowInsuranceCategory, (allow) => {
}, { immediate: true });
const submittingType = ref(false);
/** 弹窗内容区 loading先校验开方种类权限 */
const contentLoading = ref(false);
const [Modal, modalApi] = useVbenModal({
draggable: true,
confirmText: '发送处方',
@@ -200,22 +203,44 @@ const [Modal, modalApi] = useVbenModal({
if (modalData.value?.registerId) {
// 获取存储前缀,默认为在线复诊-药店
const storagePrefix = modalData.value?.storagePrefix || 'onlineConsultation-';
// 使用完整初始化,包含患者信息(传递 true 以获取患者信息)
await prescriptionStore.initializePrescription(
modalData.value.registerId,
true,
storagePrefix,
);
prescriptionStore.loadFromLocalStorage();
// 开方弹窗打开时再拉一次类型,确保 Network 可见且数据最新
await prescriptionStore.loadPrescriptionTypeOptions(modalData.value.registerId);
// 获取挂号诊所信息
await prescriptionStore.fetchRegisterStoreInfo();
// 如果恢复的处方类型是中药,检测并显示转诊提示
if (prescriptionStore.activeCategory === 1) {
await checkAndShowTransferTip();
contentLoading.value = true;
try {
// 先校验开方种类权限,再加载完整开方数据
const allowed = await prescriptionStore.loadPrescriptionTypeOptions(
modalData.value.registerId,
);
if (!allowed) {
message.error(
prescriptionStore.prescriptionTypeDenyMessage ||
'当前门店未开通开方权限',
);
modalApi.close();
return;
}
await prescriptionStore.initializePrescription(
modalData.value.registerId,
true,
storagePrefix,
);
prescriptionStore.loadFromLocalStorage();
// 再拉一次类型,确保与门店一致并校正 Tab
await prescriptionStore.loadPrescriptionTypeOptions(
modalData.value.registerId,
);
if (!prescriptionStore.prescriptionTypeAllowed) {
message.error(
prescriptionStore.prescriptionTypeDenyMessage ||
'当前门店未开通开方权限',
);
modalApi.close();
return;
}
await prescriptionStore.fetchRegisterStoreInfo();
if (prescriptionStore.activeCategory === 1) {
await checkAndShowTransferTip();
}
} finally {
contentLoading.value = false;
}
} else {
console.warn('未提供registerId');
@@ -934,6 +959,7 @@ const cancelSaveCommonPrescription = () => {
<template>
<Modal class="w-[90%]" title="电子处方">
<Spin :spinning="contentLoading || prescriptionStore.prescriptionTypeLoading">
<!-- 主容器左右布局 -->
<div class="prescription-module flex h-[calc(80vh-60px)] gap-4 overflow-hidden">
<!-- 说明书预览 (保持隐藏逻辑) -->
@@ -1794,6 +1820,7 @@ const cancelSaveCommonPrescription = () => {
</p>
</AntModal>
</div>
</Spin>
<!-- 子模态框组件 - 按照VbenAdmin的方式声明 -->
<WesternDrugModal />

View File

@@ -119,6 +119,8 @@ export async function getPrescriptionTypeOptionsApi(params?: {
icon?: string;
icon_text?: string;
}>;
allowed?: boolean;
message?: string;
}>(`${prefix}prescription-type-options`, {
params,
});

View File

@@ -34,6 +34,7 @@ import {
Textarea,
Timeline,
TimelineItem, notification,
Spin,
} from 'ant-design-vue';
import {debounce} from 'lodash-es'; // 或者使用自定义防抖函数
@@ -206,27 +207,47 @@ function getMyStoreList() {
getMyStoreList();
/** 拉取处方类型列表与默认选中 */
/** 开方种类权限校验中 */
const prescriptionTypeLoading = ref(false);
/** 当前门店是否有可开方种类 */
const prescriptionTypeAllowed = ref(true);
const prescriptionTypeDenyMessage = ref('');
/** 拉取处方类型列表与默认选中;无权限时清空并返回 false */
async function loadPrescriptionTypeOptions(registerId?: number) {
prescriptionTypeLoading.value = true;
try {
const params: { register_id?: number; store_id?: number } = {};
if (registerId) params.register_id = registerId;
if (myStoreId.value) params.store_id = myStoreId.value;
const res = await getPrescriptionTypeOptionsApi(params);
const list = Array.isArray(res?.list) ? res.list : [];
if (list.length) {
categories.value = list.map((item) => ({
value: Number(item.value),
label: item.label || '',
icon: item.icon || '',
icon_text: item.icon_text || '',
}));
categories.value = list.map((item) => ({
value: Number(item.value),
label: item.label || '',
icon: item.icon || '',
icon_text: item.icon_text || '',
}));
const def = Number(res?.default) || 0;
prescriptionTypeDefault.value = def;
const allowed = res?.allowed !== false && list.length > 0;
prescriptionTypeAllowed.value = allowed;
prescriptionTypeDenyMessage.value = allowed
? ''
: (res?.message || '当前门店未开通开方权限');
if (allowed && def && !categories.value.some((c) => c.value === activeCategory.value)) {
activeCategory.value = def;
}
const def = Number(res?.default);
if (def) prescriptionTypeDefault.value = def;
return allowed;
} catch (e) {
console.error('加载处方类型失败', e);
categories.value = [];
prescriptionTypeAllowed.value = false;
prescriptionTypeDenyMessage.value = '处方类型加载失败,请稍后重试';
message.warning('处方类型加载失败,请稍后重试');
return false;
} finally {
prescriptionTypeLoading.value = false;
}
}
@@ -669,7 +690,10 @@ const selectPatient = async (patient: Patient, isUpdateTabType = true) => {
const registerId = patient.id;
// 先拉类型,再恢复草稿/默认 Tab
await loadPrescriptionTypeOptions(registerId);
const allowed = await loadPrescriptionTypeOptions(registerId);
if (!allowed) {
message.error(prescriptionTypeDenyMessage.value || '当前门店未开通开方权限');
}
// 优先恢复完整草稿(诊断、医嘱、诊疗费、中药配置等)
const hasDraft = await restoreReceptionDraft(registerId);
if (!hasDraft) {
@@ -689,6 +713,14 @@ const selectPatient = async (patient: Patient, isUpdateTabType = true) => {
}
}
}
// 恢复的 Tab 若不在权限内,强制切到默认
if (
allowed &&
categories.value.length &&
!categories.value.some((c) => c.value === activeCategory.value)
) {
activeCategory.value = prescriptionTypeDefault.value || categories.value[0].value;
}
// 如果恢复的处方类型是中药,检测并显示转诊提示
if (activeCategory.value === 1) {
@@ -2535,46 +2567,110 @@ function updateLocalStorageStoreInfo() {
tabType.value = 0;
getPatientListByReception();
}
const leftShow = ref(true)
// 获取doctorReceptionLeftShow
function getDoctorReceptionLeftShow() {
const localStorageLeftShow = localStorage.getItem(`doctorReceptionLeftShow`);
leftShow.value = localStorageLeftShow !== 'false';
/**
* 患者列表:默认收起;鼠标悬停展开,移出自动收起;可点击钉住保持展开
* leftShow = 钉住 || 悬停
*/
const leftPinned = ref(false);
const leftHover = ref(false);
let leftLeaveTimer: ReturnType<typeof setTimeout> | null = null;
/** 门店下拉打开时暂不因 mouseleave 收起(下拉挂到 body */
const storeSelectOpen = ref(false);
const leftShow = computed(() => leftPinned.value || leftHover.value);
function getDoctorReceptionLeftPinned() {
const v = localStorage.getItem('doctorReceptionLeftPinned');
leftPinned.value = v === 'true';
}
getDoctorReceptionLeftShow();
// 监听leftShow存入缓存
getDoctorReceptionLeftPinned();
watch(
() => leftShow.value,
async (enable) => {
localStorage.setItem(`doctorReceptionLeftShow`, enable);
},
{
immediate: true,
() => leftPinned.value,
(enable) => {
localStorage.setItem('doctorReceptionLeftPinned', String(!!enable));
},
);
/** 侧栏进入:取消待收起并展开 */
function onPatientSidebarEnter() {
if (leftLeaveTimer) {
clearTimeout(leftLeaveTimer);
leftLeaveTimer = null;
}
leftHover.value = true;
}
/** 侧栏离开:短暂延迟后收起,避免点到下拉/滚动条误收 */
function onPatientSidebarLeave() {
if (leftLeaveTimer) clearTimeout(leftLeaveTimer);
leftLeaveTimer = setTimeout(() => {
if (!storeSelectOpen.value) {
leftHover.value = false;
}
}, 280);
}
/** 点击钉住/取消钉住(展开时显示) */
function toggleLeftPinned() {
leftPinned.value = !leftPinned.value;
if (leftPinned.value) {
leftHover.value = true;
}
}
/** 门店下拉显隐:打开时保持展开;关闭后若未钉住则按离开逻辑收起 */
function onStoreSelectOpenChange(open: boolean) {
storeSelectOpen.value = open;
if (open) {
onPatientSidebarEnter();
} else if (!leftPinned.value) {
onPatientSidebarLeave();
}
}
</script>
<template>
<div class="reception-layout" :class="{ 'is-left-collapsed': !leftShow }">
<RefusalOfTreatmentModals />
<InfoModalComponent />
<!-- 左侧患者列表宽度过渡动画收起后保留窄条展开按钮 -->
<aside class="patient-sidebar">
<div class="patient-sidebar__toggle">
<!-- 左侧患者列表悬停展开移出收起收起时左上角显示当前患者名 -->
<aside
class="patient-sidebar"
@mouseenter="onPatientSidebarEnter"
@mouseleave="onPatientSidebarLeave"
>
<!-- 收起态竖排患者名提示可悬停展开 -->
<div v-show="!leftShow" class="patient-sidebar__collapsed">
<div
class="patient-sidebar__collapsed-name"
:title="(activePatient && activePatient.name) || '悬停展开患者列表'"
>
{{ (activePatient && activePatient.name) || '患者' }}
</div>
</div>
<div v-show="leftShow" class="patient-sidebar__toggle">
<Button
type="primary"
size="small"
class="patient-sidebar__toggle-btn"
:title="leftShow ? '收起患者列表' : '展开患者列表'"
@click="leftShow = !leftShow"
:title="leftPinned ? '取消固定,移出后收起' : '固定展开'"
@click="toggleLeftPinned"
>
<MenuFoldOutlined v-if="leftShow" />
<MenuFoldOutlined v-if="leftPinned" />
<MenuUnfoldOutlined v-else />
<span v-if="leftShow" class="patient-sidebar__toggle-text">收起</span>
<span class="patient-sidebar__toggle-text">{{
leftPinned ? '取消固定' : '固定'
}}</span>
</Button>
</div>
<div class="patient-panel">
<Select v-model:value="myStoreId" class="w-full" @change="switchStore">
<Select
v-model:value="myStoreId"
class="w-full"
@change="switchStore"
@dropdown-visible-change="onStoreSelectOpenChange"
>
<SelectOption v-for="item in myStoreList" :key="item.id" :value="item.id">
{{ item.name }}
</SelectOption>
@@ -2877,10 +2973,18 @@ watch(
</Page>
<Page v-if="tabType === 2" class="prescription-panel prescription-panel--rx">
<Spin :spinning="prescriptionTypeLoading">
<div class="prescription-header">
<h2>{{ activePatient.name }} 的处方</h2>
</div>
<Empty
v-if="!prescriptionTypeLoading && !prescriptionTypeAllowed"
:description="prescriptionTypeDenyMessage || '当前门店未开通开方权限'"
class="py-20"
/>
<template v-else>
<Tabs
v-if="canUseMedicalRecord"
v-model:active-key="rxMrTab"
@@ -3635,6 +3739,8 @@ watch(
:user-patient-id="Number(activePatient?.id || 0)"
/>
</div>
</template>
</Spin>
</Page>
<div v-else-if="tabType === 0" class="prescription-panel">
<Empty/>
@@ -3737,10 +3843,34 @@ watch(
display: flex;
flex-direction: column;
background: hsl(var(--background));
z-index: 20;
}
.reception-layout.is-left-collapsed .patient-sidebar {
width: 44px;
}
.patient-sidebar__collapsed {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 0 16px;
min-height: 0;
cursor: default;
user-select: none;
}
.patient-sidebar__collapsed-name {
writing-mode: vertical-rl;
text-orientation: mixed;
font-size: 14px;
font-weight: 600;
color: hsl(var(--foreground));
letter-spacing: 0.12em;
line-height: 1.2;
max-height: calc(90vh - 48px);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.patient-sidebar__toggle {
flex-shrink: 0;
padding: 8px;

View File

@@ -358,37 +358,82 @@ function appendCommaText(base: string, add: string) {
return parts.join('');
}
/** 从中文逗号列表移除一项 */
function removeCommaText(base: string, remove: string) {
if (!base || !remove) return base || '';
return (base || '')
.split('')
.map((s) => s.trim())
.filter((s) => s && s !== remove)
.join('');
}
/** 从换行文本移除一行(优先整行匹配,否则去掉首次出现) */
function removeAppendedText(base: string, remove: string) {
if (!base || !remove) return base || '';
const lines = base.split('\n');
const idx = lines.findIndex((l) => l.trim() === remove.trim());
if (idx >= 0) {
lines.splice(idx, 1);
return lines.join('\n');
}
const i = base.indexOf(remove);
if (i < 0) return base;
return `${base.slice(0, i)}${base.slice(i + remove.length)}`
.replace(/\n{3,}/g, '\n\n')
.replace(/^\n+|\n+$/g, '');
}
/** 字段正文是否已包含该文案 */
function fieldContainsText(base: string, text: string) {
const t = String(text || '').trim();
if (!t) return false;
const v = String(base || '');
if (!v) return false;
if (v.split('').map((s) => s.trim()).includes(t)) return true;
if (v.split('\n').map((s) => s.trim()).includes(t)) return true;
return v.includes(t);
}
/**
* 选用词条:舌象/脉象覆盖;诊断/医嘱/中医三典逗号拼接;其余换行追加
* 选用词条(切换):正文已有则删除对应文案,否则追加
* 舌象/脉象:相同则清空,不同则覆盖
*/
function onEntryPick(fieldCode: string, item: MedicalRecordEntryItem) {
const text = item.content || item.title || '';
if (!text) return;
if (fieldCode === 'tongue' || fieldCode === 'pulse') {
(form as any)[fieldCode] = text;
const cur = String((form as any)[fieldCode] || '');
(form as any)[fieldCode] = cur.trim() === text.trim() ? '' : text;
return;
}
if (fieldCode === 'diagnosis') {
diagnosisModel.value = appendCommaText(diagnosisModel.value, text);
diagnosisModel.value = fieldContainsText(diagnosisModel.value, text)
? removeCommaText(diagnosisModel.value, text)
: appendCommaText(diagnosisModel.value, text);
return;
}
if (fieldCode === 'doctor_order') {
medicalAdviceModel.value = appendCommaText(medicalAdviceModel.value, text);
medicalAdviceModel.value = fieldContainsText(medicalAdviceModel.value, text)
? removeCommaText(medicalAdviceModel.value, text)
: appendCommaText(medicalAdviceModel.value, text);
return;
}
// 中医疾病/证候/治法与诊断类似,多项用中文逗号拼接
if (
fieldCode === 'tcm_disease' ||
fieldCode === 'tcm_syndrome' ||
fieldCode === 'tcm_method'
) {
(form as any)[fieldCode] = appendCommaText(
String((form as any)[fieldCode] || ''),
text,
);
const cur = String((form as any)[fieldCode] || '');
(form as any)[fieldCode] = fieldContainsText(cur, text)
? removeCommaText(cur, text)
: appendCommaText(cur, text);
return;
}
(form as any)[fieldCode] = appendText((form as any)[fieldCode] || '', text);
const cur = String((form as any)[fieldCode] || '');
(form as any)[fieldCode] = fieldContainsText(cur, text)
? removeAppendedText(cur, text)
: appendText(cur, text);
}
/**
@@ -485,7 +530,9 @@ function openFieldExpand(fieldCode: string, label: string) {
{
source: tcmSource || 'entry',
fieldCode: tcmSource ? undefined : fieldCode,
showChips: false,
// 词条与诊断/医嘱一样,跟随 mr_common_display_mode中医三典无常用词条表
showChips: !tcmSource && showCommonTags.value,
showCommonInBubble: !tcmSource && showCommonInBubble.value,
joinMode,
},
);
@@ -669,6 +716,7 @@ const rightFields = [
v-if="showCommonTags"
:key="`dx_chips_${commonChipsTick}`"
source="diagnosis"
:field-value="diagnosisModel"
:disabled="diagnosisReadonly"
@select="(text) => onEntryPick('diagnosis', { id: text, title: text, content: text, remark: '' })"
/>
@@ -686,7 +734,9 @@ const rightFields = [
field-code="tongue"
:store-id="storeId"
placeholder="舌象词条"
:show-common-in-bubble="showCommonInBubble"
@select="(item) => onEntryPick('tongue', item)"
@common-changed="commonChipsTick += 1"
/>
</div>
<div data-mr-module="tongue" data-mr-role="textarea">
@@ -697,6 +747,22 @@ const rightFields = [
@dblclick="openFieldExpand('tongue', '舌象')"
/>
</div>
<CommonDxOrderChips
v-if="showCommonTags"
:key="`entry_chips_tongue_${commonChipsTick}`"
source="entry"
field-code="tongue"
:field-value="form.tongue"
@select="
(text) =>
onEntryPick('tongue', {
id: text,
title: text,
content: text,
remark: '',
})
"
/>
</div>
<div class="min-w-0 flex-1">
<div class="mb-1 text-sm text-muted-foreground">脉象</div>
@@ -706,7 +772,9 @@ const rightFields = [
field-code="pulse"
:store-id="storeId"
placeholder="脉象词条"
:show-common-in-bubble="showCommonInBubble"
@select="(item) => onEntryPick('pulse', item)"
@common-changed="commonChipsTick += 1"
/>
</div>
<div data-mr-module="pulse" data-mr-role="textarea">
@@ -717,6 +785,22 @@ const rightFields = [
@dblclick="openFieldExpand('pulse', '脉象')"
/>
</div>
<CommonDxOrderChips
v-if="showCommonTags"
:key="`entry_chips_pulse_${commonChipsTick}`"
source="entry"
field-code="pulse"
:field-value="form.pulse"
@select="
(text) =>
onEntryPick('pulse', {
id: text,
title: text,
content: text,
remark: '',
})
"
/>
</div>
</div>
<!-- 2) 辨证 / 病案 -->
@@ -726,7 +810,9 @@ const rightFields = [
:ref="(el) => setSearchRef('tcm_case', el)"
field-code="tcm_case"
:store-id="storeId"
:show-common-in-bubble="showCommonInBubble"
@select="(item) => onEntryPick('tcm_case', item)"
@common-changed="commonChipsTick += 1"
/>
</div>
<div class="mb-3" data-mr-module="tcm_case" data-mr-role="textarea">
@@ -738,6 +824,23 @@ const rightFields = [
@dblclick="openFieldExpand('tcm_case', '辨证/病案')"
/>
</div>
<CommonDxOrderChips
v-if="showCommonTags"
:key="`entry_chips_tcm_case_${commonChipsTick}`"
class="mb-3"
source="entry"
field-code="tcm_case"
:field-value="form.tcm_case"
@select="
(text) =>
onEntryPick('tcm_case', {
id: text,
title: text,
content: text,
remark: '',
})
"
/>
<!-- 3) 证候|疾病治法|与舌脉同为一行两列 -->
<div
v-for="(row, rowIdx) in tcmCaseSubRows"
@@ -800,7 +903,9 @@ const rightFields = [
:ref="(el) => setSearchRef(f.fieldCode, el)"
:field-code="f.fieldCode"
:store-id="storeId"
:show-common-in-bubble="showCommonInBubble"
@select="(item) => onEntryPick(f.fieldCode, item)"
@common-changed="commonChipsTick += 1"
/>
</div>
<template v-if="f.fieldCode === 'doctor_order'">
@@ -817,6 +922,7 @@ const rightFields = [
v-if="showCommonTags"
:key="`order_chips_${commonChipsTick}`"
source="doctor_order"
:field-value="medicalAdviceModel"
@select="(text) => onEntryPick('doctor_order', { id: text, title: text, content: text, remark: '' })"
/>
</template>
@@ -833,6 +939,22 @@ const rightFields = [
@dblclick="openFieldExpand(f.textKey, f.label)"
/>
</div>
<CommonDxOrderChips
v-if="showCommonTags && f.fieldCode !== 'tcm_case' && f.fieldCode !== 'doctor_order'"
:key="`entry_chips_${f.fieldCode}_${commonChipsTick}`"
source="entry"
:field-code="f.fieldCode"
:field-value="String((form as any)[f.textKey] || '')"
@select="
(text) =>
onEntryPick(f.fieldCode, {
id: text,
title: text,
content: text,
remark: '',
})
"
/>
</div>
</Col>
<Col :span="12">
@@ -847,77 +969,78 @@ const rightFields = [
:ref="(el) => setSearchRef(f.fieldCode, el)"
:field-code="f.fieldCode"
:store-id="storeId"
:show-common-in-bubble="showCommonInBubble"
@select="(item) => onEntryPick(f.fieldCode, item)"
@common-changed="commonChipsTick += 1"
/>
</div>
<!-- 体征一段话内联书写标签含单位 + 下划线输入 -->
<div
v-if="f.fieldCode === 'physical_exam'"
class="vitals-underline mb-2 flex flex-wrap items-end gap-x-3 gap-y-2 text-sm"
class="vitals-para mb-2"
>
<span class="vital-item">
<span class="vital-label">体温</span>
<span class="vital-chunk">
<span class="vital-label">体温(°C)</span>
<InputNumber
v-model:value="form.temperature"
:min="0"
:step="0.1"
:bordered="false"
class="vital-input"
placeholder=""
placeholder=" "
/>
<span class="vital-unit"></span>
</span>
<span class="vital-item">
<span class="vital-label">身高</span>
<span class="vital-chunk">
<span class="vital-label">身高(cm)</span>
<InputNumber
v-model:value="form.height"
:min="0"
:bordered="false"
class="vital-input"
placeholder=""
placeholder=" "
/>
<span class="vital-unit">cm</span>
</span>
<span class="vital-item">
<span class="vital-label">体重</span>
<span class="vital-chunk">
<span class="vital-label">体重(KG)</span>
<InputNumber
v-model:value="form.weight"
:min="0"
:step="0.1"
:bordered="false"
class="vital-input"
placeholder=""
placeholder=" "
/>
<span class="vital-unit">KG</span>
</span>
<span class="vital-item">
<span class="vital-label">呼吸</span>
<span class="vital-chunk">
<span class="vital-label">呼吸(/)</span>
<InputNumber
v-model:value="form.respiratory_rate"
:min="0"
:bordered="false"
class="vital-input vital-input--sm"
placeholder=""
placeholder=" "
/>
<span class="vital-unit">/</span>
</span>
<span class="vital-item">
<span class="vital-label">血压</span>
<span class="vital-chunk">
<span class="vital-label">高压 mmHg</span>
<InputNumber
v-model:value="form.bp_systolic"
:min="0"
:bordered="false"
class="vital-input vital-input--sm"
placeholder=""
placeholder=" "
/>
<span class="vital-sep">/</span>
</span>
<span class="vital-sep">/</span>
<span class="vital-chunk">
<span class="vital-label">低压 mmHg</span>
<InputNumber
v-model:value="form.bp_diastolic"
:min="0"
:bordered="false"
class="vital-input vital-input--sm"
placeholder=""
placeholder=" "
/>
<span class="vital-unit">mmHg</span>
</span>
</div>
<div :data-mr-module="f.fieldCode" data-mr-role="textarea">
@@ -929,6 +1052,22 @@ const rightFields = [
@dblclick="openFieldExpand(f.textKey, f.label)"
/>
</div>
<CommonDxOrderChips
v-if="showCommonTags"
:key="`entry_chips_r_${f.fieldCode}_${commonChipsTick}`"
source="entry"
:field-code="f.fieldCode"
:field-value="String((form as any)[f.textKey] || '')"
@select="
(text) =>
onEntryPick(f.fieldCode, {
id: text,
title: text,
content: text,
remark: '',
})
"
/>
</div>
</Col>
</Row>
@@ -940,36 +1079,55 @@ const rightFields = [
.medical-record-panel {
padding: 8px 0 24px;
}
.vitals-underline .vital-item {
display: inline-flex;
align-items: flex-end;
gap: 4px;
}
.vitals-underline .vital-label,
.vitals-underline .vital-unit,
.vitals-underline .vital-sep {
/* 体征一段话:标签 + 下划线输入横向流式排布 */
.vitals-para {
display: flex;
flex-wrap: wrap;
align-items: baseline;
column-gap: 14px;
row-gap: 10px;
line-height: 2;
font-size: 13px;
color: hsl(var(--muted-foreground));
}
.vitals-para .vital-chunk {
display: inline-flex;
align-items: baseline;
gap: 6px;
white-space: nowrap;
}
.vitals-underline :deep(.vital-input.ant-input-number),
.vitals-underline :deep(.vital-input .ant-input-number) {
width: 88px;
.vitals-para .vital-label {
color: hsl(var(--muted-foreground));
font-size: 13px;
}
.vitals-para .vital-sep {
color: hsl(var(--muted-foreground));
margin: 0 2px;
}
.vitals-para :deep(.vital-input.ant-input-number),
.vitals-para :deep(.vital-input .ant-input-number) {
width: 72px;
border: none !important;
border-bottom: 1px solid hsl(var(--border)) !important;
border-radius: 0 !important;
box-shadow: none !important;
background: transparent;
}
.vitals-underline :deep(.vital-input--sm.ant-input-number),
.vitals-underline :deep(.vital-input--sm .ant-input-number) {
width: 72px;
.vitals-para :deep(.vital-input--sm.ant-input-number),
.vitals-para :deep(.vital-input--sm .ant-input-number) {
width: 64px;
}
.vitals-underline :deep(.ant-input-number-focused),
.vitals-underline :deep(.ant-input-number:hover) {
.vitals-para :deep(.ant-input-number-input) {
text-align: center;
padding: 0 4px !important;
height: 28px !important;
}
.vitals-para :deep(.ant-input-number-focused),
.vitals-para :deep(.ant-input-number:hover) {
border-bottom-color: hsl(var(--primary)) !important;
box-shadow: none !important;
}
.vitals-underline :deep(.ant-input-number-handler-wrap) {
opacity: 0.65;
.vitals-para :deep(.ant-input-number-handler-wrap) {
display: none;
}
</style>

View File

@@ -31,6 +31,24 @@ export async function searchMedicalRecordEntry(data: {
return requestClient.get<any>(`${prefix}search-entry`, { params: data });
}
/** 我的常用词条(医生维度) */
export async function getMyMedicalRecordEntryList(data: {
field_code?: string;
limit?: number;
}) {
return requestClient.get<any>(`${prefix}my-entry-list`, { params: data });
}
/** 加入常用词条 */
export async function addMyMedicalRecordEntry(entry_id: number) {
return requestClient.post<any>(`${prefix}add-my-entry`, { entry_id });
}
/** 删除常用词条(常用记录 id */
export async function deleteMyMedicalRecordEntry(id: number) {
return requestClient.post<any>(`${prefix}delete-my-entry`, { id });
}
export async function getMedicalRecord(data: {
register_id: number;
store_id?: number;

View File

@@ -1,24 +1,33 @@
<script lang="ts" setup>
/**
* 常用诊断 / 常用医嘱快捷标签
* 展示在输入框下方,点击即追加到对应字段(与 EntryKeywordBubble 同源数据
* 常用快捷标签(诊断 / 医嘱 / 病历词条)
* - 正文已包含该文案时高亮is-active
* - 点击:已在正文则移除,否则追加(由父级 onEntryPick 统一 toggle
*/
import { onMounted, ref, watch } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import { Tag } from 'ant-design-vue';
import {
getMyMedicalRecordEntryList,
} from '#/views/doctor/medical-record/api';
import { getDoctorOrderList } from '#/views/doctor/doctor-reception/api';
import { getDoctorMyDiseaseListApi } from '#/views/doctor/settings/api';
const props = withDefaults(
defineProps<{
/** diagnosis=我的常用诊断doctor_order=我的常用医嘱 */
source: 'diagnosis' | 'doctor_order';
/** 最多展示条数 */
/** diagnosis=常用诊断doctor_order=常用医嘱entry=常用词条 */
source: 'diagnosis' | 'doctor_order' | 'entry';
/** entry 时必填 */
fieldCode?: string;
/** 当前字段正文,用于高亮已选 */
fieldValue?: string;
limit?: number;
disabled?: boolean;
}>(),
{
fieldCode: '',
fieldValue: '',
limit: 12,
disabled: false,
},
@@ -33,8 +42,28 @@ type ChipItem = { id: string | number; text: string };
const chips = ref<ChipItem[]>([]);
const loading = ref(false);
/** 文案是否已出现在字段值中(逗号分段或整行/包含) */
function textInField(fieldValue: string, text: string) {
const t = String(text || '').trim();
if (!t) return false;
const v = String(fieldValue || '');
if (!v) return false;
if (v.split('').map((s) => s.trim()).includes(t)) return true;
if (v.split('\n').map((s) => s.trim()).includes(t)) return true;
return v.includes(t);
}
const activeSet = computed(() => {
const set = new Set<string>();
const v = props.fieldValue || '';
for (const c of chips.value) {
if (textInField(v, c.text)) set.add(c.text);
}
return set;
});
/**
* 拉取常用项(诊断取医生常用病;医嘱取我的医嘱,无则带公共前几条)
* 拉取常用项
*/
async function loadChips() {
loading.value = true;
@@ -51,6 +80,26 @@ async function loadChips() {
.slice(0, props.limit) as ChipItem[];
return;
}
if (props.source === 'entry') {
if (!props.fieldCode) {
chips.value = [];
return;
}
const list = await getMyMedicalRecordEntryList({
field_code: props.fieldCode,
limit: props.limit,
}).catch(() => []);
chips.value = (list || [])
.map((item: any) => {
const text = String(item?.content || item?.title || '').trim();
return text
? { id: item?.id ?? item?.entry_id ?? text, text }
: null;
})
.filter(Boolean)
.slice(0, props.limit) as ChipItem[];
return;
}
const res = await getDoctorOrderList().catch(() => ({ my: [], common: [] }));
const my = (res?.my || [])
.map((item: any) => {
@@ -79,8 +128,14 @@ function onPick(item: ChipItem) {
emit('select', item.text);
}
const labelText = computed(() => {
if (props.source === 'diagnosis') return '常用诊断';
if (props.source === 'doctor_order') return '常用医嘱';
return '常用词条';
});
watch(
() => props.source,
() => [props.source, props.fieldCode] as const,
() => {
loadChips();
},
@@ -94,16 +149,17 @@ defineExpose({ reload: loadChips });
</script>
<template>
<div v-if="chips.length > 0" class="dx-order-chips">
<span class="dx-order-chips__label">
{{ source === 'diagnosis' ? '常用诊断' : '常用医嘱' }}
</span>
<span class="dx-order-chips__label">{{ labelText }}</span>
<div class="dx-order-chips__list">
<Tag
v-for="item in chips"
:key="`${item.id}_${item.text}`"
class="dx-order-chips__tag"
:class="{ 'is-disabled': disabled }"
color="processing"
:class="{
'is-disabled': disabled,
'is-active': activeSet.has(item.text),
}"
:color="activeSet.has(item.text) ? 'success' : 'processing'"
@click="onPick(item)"
>
{{ item.text }}
@@ -124,16 +180,17 @@ defineExpose({ reload: loadChips });
.dx-order-chips__list {
display: flex;
flex-wrap: wrap;
gap: 6px;
gap: 8px;
}
.dx-order-chips__tag {
cursor: pointer;
user-select: none;
max-width: 100%;
white-space: normal;
height: auto;
line-height: 1.4;
padding: 2px 8px;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.dx-order-chips__tag.is-active {
font-weight: 600;
}
.dx-order-chips__tag.is-disabled {
cursor: not-allowed;

View File

@@ -30,7 +30,7 @@ import {
} from '#/views/doctor/settings/api';
import { getSystemConfigByKeys } from '#/views/system/system-config/api';
import { createMedicalRecordEntry, searchMedicalRecordEntry } from '../api';
import { createMedicalRecordEntry, searchMedicalRecordEntry, addMyMedicalRecordEntry } from '../api';
import { MEDICAL_RECORD_FIELD_OPTIONS } from '../config/constants';
import CommonDxOrderChips from './CommonDxOrderChips.vue';
@@ -101,7 +101,9 @@ const bubbleChipsTick = ref(0);
const showBubbleChips = computed(
() =>
props.showCommonInBubble === true &&
(props.source === 'diagnosis' || props.source === 'doctor_order'),
(props.source === 'diagnosis' ||
props.source === 'doctor_order' ||
(props.source === 'entry' && !!props.fieldCode)),
);
const ROW_HEIGHT = 52;
@@ -132,7 +134,10 @@ const createForm = reactive({
remark: '',
});
const showActionCol = computed(
() => props.source === 'diagnosis' || props.source === 'doctor_order',
() =>
props.source === 'diagnosis' ||
props.source === 'doctor_order' ||
(props.source === 'entry' && !!props.fieldCode),
);
/** 病历词条 / 我的医嘱 均可新增 */
const showCreateEntry = computed(
@@ -617,6 +622,17 @@ async function handleAddCommon(item: MedicalRecordEntryItem, e?: Event) {
message.success('已加入常用医嘱');
bubbleChipsTick.value += 1;
emit('common-changed');
} else if (props.source === 'entry' || (!props.source && props.fieldCode)) {
const entryId = Number(item.id);
if (!entryId) {
message.warning('无法识别词条ID');
return;
}
await addMyMedicalRecordEntry(entryId);
item.is_common = true;
message.success('已加入常用词条');
bubbleChipsTick.value += 1;
emit('common-changed');
}
} catch (err: any) {
message.error(err?.message || '添加失败');
@@ -893,9 +909,16 @@ defineExpose({
<Spin :spinning="loading">
<CommonDxOrderChips
v-if="showBubbleChips"
:key="`bubble_chips_${source}_${bubbleChipsTick}`"
:key="`bubble_chips_${source}_${fieldCode}_${bubbleChipsTick}`"
class="entry-keyword-bubble__chips"
:source="source === 'diagnosis' ? 'diagnosis' : 'doctor_order'"
:source="
source === 'diagnosis'
? 'diagnosis'
: source === 'doctor_order'
? 'doctor_order'
: 'entry'
"
:field-code="fieldCode"
:disabled="disabled"
@select="
(text) =>

View File

@@ -99,25 +99,63 @@ function appendCommaText(base: string, add: string) {
return parts.join('');
}
function removeCommaText(base: string, remove: string) {
if (!base || !remove) return base || '';
return (base || '')
.split('')
.map((s) => s.trim())
.filter((s) => s && s !== remove)
.join('');
}
function appendNewline(base: string, add: string) {
if (!add) return base || '';
if (!base) return add;
return `${base}${base.endsWith('\n') ? '' : '\n'}${add}`;
}
/** 气泡/快捷写入草稿 */
function removeNewline(base: string, remove: string) {
if (!base || !remove) return base || '';
const lines = base.split('\n');
const idx = lines.findIndex((l) => l.trim() === remove.trim());
if (idx >= 0) {
lines.splice(idx, 1);
return lines.join('\n');
}
const i = base.indexOf(remove);
if (i < 0) return base;
return `${base.slice(0, i)}${base.slice(i + remove.length)}`
.replace(/\n{3,}/g, '\n\n')
.replace(/^\n+|\n+$/g, '');
}
function containsText(base: string, text: string) {
const t = String(text || '').trim();
if (!t) return false;
const v = String(base || '');
if (!v) return false;
if (v.split('').map((s) => s.trim()).includes(t)) return true;
if (v.split('\n').map((s) => s.trim()).includes(t)) return true;
return v.includes(t);
}
/** 气泡/快捷写入草稿(已有则删除,实现选择/取消) */
function applyPick(text: string) {
const t = String(text || '').trim();
if (!t || readonly.value) return;
if (joinMode.value === 'replace') {
draft.value = t;
draft.value = String(draft.value || '').trim() === t ? '' : t;
return;
}
if (joinMode.value === 'comma') {
draft.value = appendCommaText(draft.value, t);
draft.value = containsText(draft.value, t)
? removeCommaText(draft.value, t)
: appendCommaText(draft.value, t);
return;
}
draft.value = appendNewline(draft.value, t);
draft.value = containsText(draft.value, t)
? removeNewline(draft.value, t)
: appendNewline(draft.value, t);
}
function onBubbleSelect(item: MedicalRecordEntryItem) {
@@ -163,8 +201,21 @@ function confirmAndClose() {
@dblclick="confirmAndClose"
/>
<CommonDxOrderChips
v-if="showChips && (source === 'diagnosis' || source === 'doctor_order')"
:source="source"
v-if="
showChips &&
(source === 'diagnosis' ||
source === 'doctor_order' ||
source === 'entry')
"
:source="
source === 'diagnosis'
? 'diagnosis'
: source === 'doctor_order'
? 'doctor_order'
: 'entry'
"
:field-code="fieldCode"
:field-value="draft"
:disabled="readonly"
@select="applyPick"
/>

View File

@@ -45,6 +45,11 @@ import {
saveServicePriceApi,
} from '#/views/doctor/settings/api';
import type { CommonPrescriptionResponse } from '#/views/doctor/settings/api';
import {
deleteMyMedicalRecordEntry,
getMyMedicalRecordEntryList,
} from '#/views/doctor/medical-record/api';
import { MEDICAL_RECORD_FIELD_OPTIONS } from '#/views/doctor/medical-record/config/constants';
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
const userInfoStore = useUserStore();
@@ -63,6 +68,7 @@ const activeTabBar = ref(savedTab ? Number(savedTab) : 1);
* - 3: 常用方(新增)
* - 4: 常用医嘱
* - 5: 常用诊断
* - 6: 常用词条
*/
const tabBar = [
{
@@ -81,6 +87,10 @@ const tabBar = [
value: 5,
label: '常用诊断',
},
{
value: 6,
label: '常用词条',
},
];
// 格式化日期
@@ -127,6 +137,28 @@ const getDoctorMyDiseaseList = () => {
});
};
/** 常用词条(医生维度) */
const myEntryCommonList = ref<any[]>([]);
const getMyEntryCommonList = () => {
getMyMedicalRecordEntryList({ limit: 200 })
.then((res) => {
myEntryCommonList.value = Array.isArray(res) ? res : [];
})
.catch(() => {
myEntryCommonList.value = [];
});
};
const fieldCodeLabel = (code: string) => {
const hit = MEDICAL_RECORD_FIELD_OPTIONS.find((o) => o.value === code);
return hit?.label || code || '-';
};
const removeMyEntryCommon = (id: number) => {
deleteMyMedicalRecordEntry(id).then(() => {
notification.success({ message: '已移除常用词条' });
getMyEntryCommonList();
});
};
// ==================== 常用方相关 ====================
/**
@@ -282,6 +314,11 @@ const tabBarChange = () => {
getDoctorMyDiseaseList();
break;
}
case 6: {
// 常用词条
getMyEntryCommonList();
break;
}
}
};
// 初始化时加载基础数据和当前tab对应的数据
@@ -807,6 +844,29 @@ const handleViewSignature = () => {
{{ item.disease.name }}
</Button>
</Card>
<Card v-else-if="data && activeTabBar === 6" title="常用词条">
<div class="mb-3 text-sm text-gray-500">
在病历接诊气泡中点「加常用」可收藏;此处可移除。点击标签本身无操作。
</div>
<Empty v-if="!myEntryCommonList.length" description="暂无常用词条" />
<div v-else class="flex flex-wrap gap-2">
<Tag
v-for="item in myEntryCommonList"
:key="item.id"
color="processing"
class="mb-2"
>
<span class="mr-1 text-xs opacity-70">{{ fieldCodeLabel(item.field_code) }}</span>
{{ item.title || item.content }}
<Popconfirm
title="确定从常用词条中移除吗?"
@confirm="removeMyEntryCommon(item.id)"
>
<a class="ml-2 text-red-500" @click.stop>移除</a>
</Popconfirm>
</Tag>
</div>
</Card>
<!-- 常用方编辑弹窗 -->
<EditCommonPrescriptionModals />

View File

@@ -37,6 +37,13 @@ export const gridOptions: VxeGridProps<RowType> = {
width: 160,
slots: { default: 'online_consultation_config' },
},
{
field: 'prescription_types_config',
align: 'left',
title: '开方种类',
width: 200,
slots: { default: 'prescription_types_config' },
},
{
field: 'store_config_1',
align: 'left',

View File

@@ -35,6 +35,7 @@ import StoreBasicInfoCell from '#/views/system/store/components/cells/StoreBasic
import StoreConfigTogglesCell from '#/views/system/store/components/cells/StoreConfigTogglesCell.vue';
// 导入绑定在线复诊配置弹窗使用store的组件
import BindConsultationModal from '#/views/system/store/components/BindConsultationModal.vue';
import BindPrescriptionTypesModal from '#/views/system/store/components/BindPrescriptionTypesModal.vue';
import DrugPriceModal from '#/views/system/store/components/DrugPriceModal.vue';
import StoreExternalFieldModal from '#/views/system/store/components/StoreExternalFieldModal.vue';
import StoreCardModal from '#/components/store-card/StoreCardModal.vue';
@@ -46,6 +47,7 @@ import { getStoreBankCardReportDetail } from '#/views/system/store-bank-card/api
import { formOptions } from './config/search';
// 导入表格配置
import { gridOptions } from './config/table';
import { prescriptionTypeLabel } from '#/views/system/store/config/prescription-type-constants';
import { canManageStoreBankCard } from '#/views/system/admin/_shared/platform-admin-role';
const userStore = useUserStore();
@@ -128,6 +130,10 @@ const [BindConsultationModalComponent, bindConsultationModalApi] = useVbenModal(
connectedComponent: BindConsultationModal,
});
const [BindPrescriptionTypesModalComponent, bindPrescriptionTypesModalApi] = useVbenModal({
connectedComponent: BindPrescriptionTypesModal,
});
const [StoreExternalFieldModalComponent, storeExternalFieldModalApi] = useVbenModal({
connectedComponent: StoreExternalFieldModal,
});
@@ -247,6 +253,26 @@ const showBindConsultationModal = (row: any) => {
bindConsultationModalApi.open();
};
/** 打开绑定开方种类弹窗 */
const showBindPrescriptionTypesModal = (row: any) => {
bindPrescriptionTypesModalApi.setData({
values: row,
gridApi,
});
bindPrescriptionTypesModalApi.open();
};
/** 列表摘要:已绑种类数 + 默认种类名 */
function prescriptionTypesSummary(row: any) {
const allowed = Array.isArray(row?.allowed_prescription_types)
? row.allowed_prescription_types
: [];
if (!allowed.length) return '未绑定';
const def = Number(row?.default_prescription_type) || 0;
const defLabel = def ? prescriptionTypeLabel(def) : '';
return defLabel ? `${allowed.length}种 · 默认${defLabel}` : `${allowed.length}`;
}
// 显示表单弹窗(新增或编辑)
const showModal = (data = {}, isUpdate = false) => {
formModalApi.setData({
@@ -391,6 +417,7 @@ const batchSyncDrugPrice = () => {
<QrCodePreviewModal />
<DrugPriceModalComponent />
<BindConsultationModalComponent />
<BindPrescriptionTypesModalComponent />
<StoreExternalFieldModalComponent />
<Grid>
<template #toolbar-buttons>
@@ -532,6 +559,23 @@ const batchSyncDrugPrice = () => {
绑定
</Button>
</template>
<template #prescription_types_config="{ row }">
<div
class="cursor-pointer hover:text-blue-500"
@click="showBindPrescriptionTypesModal(row)"
>
<Tag
:color="
Array.isArray(row.allowed_prescription_types) &&
row.allowed_prescription_types.length
? 'success'
: 'default'
"
>
{{ prescriptionTypesSummary(row) }}
</Tag>
</div>
</template>
<template #start-time="{ row }">
<Tag color="success">{{ row.start_time }}</Tag>
<br />

View File

@@ -32,6 +32,17 @@ export async function bindOnlineConsultationApi(data: {
return requestClient.post<any>(`${prefix}bind-online-consultation`, data);
}
/**
* 绑定门店可开方药品种类与默认种类
*/
export async function bindPrescriptionTypesApi(data: {
id: number;
allowed_prescription_types: number[];
default_prescription_type: number;
}) {
return requestClient.post<any>(`${prefix}bind-prescription-types`, data);
}
/**
* 绑定特色方默认挂号医生
*/

View File

@@ -0,0 +1,151 @@
<script lang="ts" setup>
/**
* 绑定门店可开方药品种类与默认种类
* 为什么独立弹窗:诊所/药店列表共用,整表替换中间表绑定
*/
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Checkbox, CheckboxGroup, Form, FormItem, message, Radio, RadioGroup } from 'ant-design-vue';
import { bindPrescriptionTypesApi } from '../api';
import {
STORE_PRESCRIPTION_TYPE_OPTIONS,
} from '../config/prescription-type-constants';
const formState = ref({
id: undefined as number | undefined,
allowed_prescription_types: [] as number[],
default_prescription_type: undefined as number | undefined,
});
const gridApiRef = ref<any>(null);
const loading = ref(false);
/** 默认种类只能从已勾选里选 */
const defaultOptions = computed(() =>
STORE_PRESCRIPTION_TYPE_OPTIONS.filter((o) =>
formState.value.allowed_prescription_types.includes(o.value),
),
);
function onAllowedChange(values: number[]) {
const next = (values || []) as number[];
formState.value.allowed_prescription_types = next;
// 默认种类若不在已选中,自动落到第一项
if (
formState.value.default_prescription_type &&
!next.includes(formState.value.default_prescription_type)
) {
formState.value.default_prescription_type = next[0];
}
if (!formState.value.default_prescription_type && next.length) {
formState.value.default_prescription_type = next[0];
}
}
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
if (!formState.value.id) {
message.error('门店ID无效');
return;
}
if (!formState.value.allowed_prescription_types.length) {
message.error('请至少选择一种可开方种类');
return;
}
if (
!formState.value.default_prescription_type ||
!formState.value.allowed_prescription_types.includes(
formState.value.default_prescription_type,
)
) {
message.error('请选择默认开方种类');
return;
}
loading.value = true;
modalApi.setState({ confirmLoading: true });
try {
await bindPrescriptionTypesApi({
id: formState.value.id,
allowed_prescription_types: formState.value.allowed_prescription_types,
default_prescription_type: formState.value.default_prescription_type,
});
message.success('绑定成功');
gridApiRef.value?.reload();
modalApi.close();
} catch (error) {
console.error(error);
message.error('绑定失败');
} finally {
loading.value = false;
modalApi.setState({ confirmLoading: false });
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) return;
const { values, gridApi } = modalApi.getData<Record<string, any>>();
gridApiRef.value = gridApi;
const allowed = Array.isArray(values?.allowed_prescription_types)
? values.allowed_prescription_types.map((n: any) => Number(n))
: [];
let def = Number(values?.default_prescription_type) || 0;
if (def && !allowed.includes(def)) {
def = allowed[0] || 0;
}
formState.value = {
id: values?.id,
allowed_prescription_types: allowed,
default_prescription_type: def || undefined,
};
},
});
</script>
<template>
<Modal title="绑定开方种类" class="w-[520px]">
<Form :model="formState" layout="vertical">
<FormItem label="可开方种类" required>
<CheckboxGroup
v-model:value="formState.allowed_prescription_types"
@change="onAllowedChange"
>
<div class="flex flex-wrap gap-2">
<Checkbox
v-for="opt in STORE_PRESCRIPTION_TYPE_OPTIONS"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</Checkbox>
</div>
</CheckboxGroup>
</FormItem>
<FormItem label="进入开方页默认种类" required>
<RadioGroup
v-model:value="formState.default_prescription_type"
:disabled="!defaultOptions.length"
>
<div class="flex flex-wrap gap-2">
<Radio
v-for="opt in defaultOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</Radio>
</div>
</RadioGroup>
<div v-if="!defaultOptions.length" class="mt-1 text-gray-400 text-sm">
请先勾选可开方种类
</div>
</FormItem>
</Form>
</Modal>
</template>

View File

@@ -0,0 +1,17 @@
/**
* 门店开方种类选项(与 ProductTypeEnum 开方 Tab 对齐,不含特色方)
*/
export const STORE_PRESCRIPTION_TYPE_OPTIONS = [
{ label: '中药', value: 1 },
{ label: '西(中成)药', value: 2 },
{ label: '保健食品', value: 3 },
{ label: '产品服务包', value: 5 },
{ label: '非药品', value: 6 },
{ label: '医疗器械', value: 7 },
];
/** 按 value 取中文名 */
export function prescriptionTypeLabel(value: number): string {
const hit = STORE_PRESCRIPTION_TYPE_OPTIONS.find((o) => o.value === Number(value));
return hit?.label || `类型${value}`;
}

View File

@@ -51,6 +51,13 @@ export const gridOptions: VxeGridProps<RowType> = {
width: 180,
slots: { default: 'special_prescription_config' },
},
{
field: 'prescription_types_config',
align: 'left',
title: '开方种类',
width: 200,
slots: { default: 'prescription_types_config' },
},
{
field: 'store_config_1',
align: 'left',

View File

@@ -37,6 +37,7 @@ import StoreConfigTogglesCell from './components/cells/StoreConfigTogglesCell.vu
import DrugPriceModal from './components/DrugPriceModal.vue';
import BindConsultationModal from './components/BindConsultationModal.vue';
import BindSpecialPrescriptionDoctorModal from './components/BindSpecialPrescriptionDoctorModal.vue';
import BindPrescriptionTypesModal from './components/BindPrescriptionTypesModal.vue';
import StoreExternalFieldModal from './components/StoreExternalFieldModal.vue';
import SalespersonCommissionDrawer from './components/SalespersonCommissionDrawer.vue';
import AddDoctorFromStoreModal from './components/AddDoctorFromStoreModal.vue';
@@ -49,6 +50,7 @@ import BankCardStoreEditModal from '#/views/system/store-bank-card/components/Ba
import { getStoreBankCardReportDetail } from '#/views/system/store-bank-card/api';
import { formOptions } from './config/search';
import { gridOptions } from './config/table';
import { prescriptionTypeLabel } from './config/prescription-type-constants';
import {
canAddStoreDoctor,
canManageStoreBankCard,
@@ -174,6 +176,10 @@ const [BindSpecialPrescriptionDoctorModalComponent, bindSpecialPrescriptionDocto
connectedComponent: BindSpecialPrescriptionDoctorModal,
});
const [BindPrescriptionTypesModalComponent, bindPrescriptionTypesModalApi] = useVbenModal({
connectedComponent: BindPrescriptionTypesModal,
});
const [StoreExternalFieldModalComponent, storeExternalFieldModalApi] = useVbenModal({
connectedComponent: StoreExternalFieldModal,
});
@@ -310,6 +316,26 @@ const showBindSpecialPrescriptionDoctorModal = (row: any) => {
bindSpecialPrescriptionDoctorModalApi.open();
};
/** 打开绑定开方种类弹窗 */
const showBindPrescriptionTypesModal = (row: any) => {
bindPrescriptionTypesModalApi.setData({
values: row,
gridApi,
});
bindPrescriptionTypesModalApi.open();
};
/** 列表摘要:已绑种类数 + 默认种类名 */
function prescriptionTypesSummary(row: any) {
const allowed = Array.isArray(row?.allowed_prescription_types)
? row.allowed_prescription_types
: [];
if (!allowed.length) return '未绑定';
const def = Number(row?.default_prescription_type) || 0;
const defLabel = def ? prescriptionTypeLabel(def) : '';
return defLabel ? `${allowed.length}种 · 默认${defLabel}` : `${allowed.length}`;
}
const deleteApi = (row: any) => {
let ids = [];
if (row) {
@@ -468,6 +494,7 @@ const handleSwitchClinicType = (row: any) => {
<DrugPriceModalComponent />
<BindConsultationModalComponent />
<BindSpecialPrescriptionDoctorModalComponent />
<BindPrescriptionTypesModalComponent />
<StoreExternalFieldModalComponent />
<SalespersonCommissionDrawer ref="salespersonDrawerRef" />
<Grid>
@@ -637,6 +664,23 @@ const handleSwitchClinicType = (row: any) => {
绑定
</Button>
</template>
<template #prescription_types_config="{ row }">
<div
class="cursor-pointer hover:text-blue-500"
@click="showBindPrescriptionTypesModal(row)"
>
<Tag
:color="
Array.isArray(row.allowed_prescription_types) &&
row.allowed_prescription_types.length
? 'success'
: 'default'
"
>
{{ prescriptionTypesSummary(row) }}
</Tag>
</div>
</template>
<template #start-time="{ row }">
<Tag color="success">{{ row.start_time }}</Tag>
<br />