feat: VIP功能绑定、VIP病历模块、字典模块、传方按钮移动到顶部导航栏
This commit is contained in:
@@ -9,7 +9,6 @@ import { App, ConfigProvider, theme } from 'ant-design-vue';
|
||||
|
||||
import { antdLocale } from '#/locales';
|
||||
import { GlobalContextMenu } from '#/components/context-menu';
|
||||
import DoctorTransferFloat from '#/components/doctor-transfer-float/DoctorTransferFloat.vue';
|
||||
import { useWebSocket } from '#/views/business/chat/composables/useWebSocket';
|
||||
import { useChatStore } from '#/views/business/chat/stores/chat';
|
||||
import { useUserStore } from '#/views/business/chat/stores/user';
|
||||
@@ -76,13 +75,6 @@ watch(userInfoLoaded, (loaded) => {
|
||||
initWebsocket();
|
||||
}
|
||||
});
|
||||
|
||||
/** 诊所医生账号显示传方悬浮窗(有 doctor_id 且绑定门店) */
|
||||
const showDoctorTransferFloat = computed(() => {
|
||||
const info = vbenUserStore.userInfo as Record<string, any> | null;
|
||||
if (!info) return false;
|
||||
return !!(info.doctor_id && info.store_id);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -90,7 +82,6 @@ const showDoctorTransferFloat = computed(() => {
|
||||
<App>
|
||||
<RouterView />
|
||||
<GlobalContextMenu />
|
||||
<DoctorTransferFloat v-if="showDoctorTransferFloat" />
|
||||
</App>
|
||||
</ConfigProvider>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 医生端 PC 传方悬浮窗:悬浮按钮 + 子窗口列表 + 状态操作
|
||||
* 医生端 PC 传方入口:顶栏按钮 + 子窗口列表 + 状态操作
|
||||
* 权限:有 doctor_id 且绑定 store_id 时由外层 v-if 控制,本组件只负责展示与操作
|
||||
*/
|
||||
import {
|
||||
computed,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
} from 'vue';
|
||||
|
||||
import {
|
||||
FileTextOutlined,
|
||||
ReloadOutlined,
|
||||
ZoomInOutlined,
|
||||
ZoomOutOutlined,
|
||||
@@ -19,6 +21,7 @@ import { usePreferences } from '@vben/preferences';
|
||||
import { useUserStore } from '@vben/stores';
|
||||
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Dropdown,
|
||||
Image,
|
||||
@@ -29,10 +32,10 @@ import {
|
||||
Spin,
|
||||
Tag,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import FloatFab from '#/components/float-fab/FloatFab.vue';
|
||||
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
|
||||
import {
|
||||
DEFAULT_SUB_WINDOW_Z_INDEX,
|
||||
@@ -355,12 +358,20 @@ defineExpose({ loadList });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FloatFab
|
||||
storage-key="pc_doctor_transfer_fab"
|
||||
label="传方"
|
||||
:badge="pendingCount"
|
||||
@click="openWindow"
|
||||
/>
|
||||
<div class="doctor-transfer-header-entry">
|
||||
<Tooltip title="门店传方">
|
||||
<Badge :count="pendingCount" :overflow-count="99" size="small">
|
||||
<Button
|
||||
type="text"
|
||||
class="transfer-header-btn"
|
||||
@click="openWindow"
|
||||
>
|
||||
<FileTextOutlined />
|
||||
<span class="transfer-header-btn__text">传方</span>
|
||||
</Button>
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<SubWindow
|
||||
v-model:open="windowOpen"
|
||||
title="门店传方"
|
||||
@@ -848,4 +859,25 @@ defineExpose({ loadList });
|
||||
margin: 0 auto;
|
||||
user-select: none;
|
||||
}
|
||||
.doctor-transfer-header-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.transfer-header-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
.transfer-header-btn__text {
|
||||
font-size: 13px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.transfer-header-btn__text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from 'lucide-vue-next';
|
||||
|
||||
// import { $t } from '#/locales';
|
||||
import DoctorTransferFloat from '#/components/doctor-transfer-float/DoctorTransferFloat.vue';
|
||||
import HeaderVipBadge from '#/components/vip/HeaderVipBadge.vue';
|
||||
import { useAuthStore } from '#/store';
|
||||
import LoginForm from '#/views/_core/authentication/login.vue';
|
||||
@@ -180,6 +181,13 @@ const showDot = computed(() =>
|
||||
notifications.value.some((item) => !item.isRead),
|
||||
);
|
||||
|
||||
/** 与原先悬浮窗一致:有 doctor_id 且绑定门店才显示传方入口 */
|
||||
const showDoctorTransfer = computed(() => {
|
||||
const info = userStore.userInfo as Record<string, any> | null | undefined;
|
||||
if (!info) return false;
|
||||
return !!(info.doctor_id && info.store_id);
|
||||
});
|
||||
|
||||
const menus = computed(() => [
|
||||
{
|
||||
handler: () => {
|
||||
@@ -234,6 +242,10 @@ watch(
|
||||
|
||||
<template>
|
||||
<BasicLayout @clear-preferences-and-logout="handleLogout">
|
||||
<!-- index=55:紧挨全局搜索(50)右侧 -->
|
||||
<template #header-right-55>
|
||||
<DoctorTransferFloat v-if="showDoctorTransfer" />
|
||||
</template>
|
||||
<template #user-dropdown>
|
||||
<div class="flex items-center">
|
||||
<HeaderVipBadge />
|
||||
|
||||
137
apps/web-antd/src/utils/dictSearchRank.ts
Normal file
137
apps/web-antd/src/utils/dictSearchRank.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* 字典/词条搜索:匹配度打分、排序、关键字高亮拆分
|
||||
* 与后端 DictSearchRankService 规则对齐;mode=frontend 时由本工具排序
|
||||
*/
|
||||
|
||||
export type DictSearchRankMode = 'backend' | 'frontend';
|
||||
|
||||
export type HighlightPart = { text: string; hit: boolean };
|
||||
|
||||
/** 与 PHP DictSearchRankService::score 对齐 */
|
||||
export function scoreDictMatch(
|
||||
text: string,
|
||||
keyword: string,
|
||||
extra = '',
|
||||
): number {
|
||||
const kw = String(keyword || '').trim();
|
||||
if (!kw) return 0;
|
||||
const main = String(text || '').trim();
|
||||
if (!main) return 0;
|
||||
let s = scoreOne(main, kw);
|
||||
if (extra) {
|
||||
s = Math.max(s, Math.floor(scoreOne(String(extra), kw) * 0.9));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function scoreOne(haystack: string, needle: string): number {
|
||||
if (haystack === needle) return 100;
|
||||
const lowerH = haystack.toLowerCase();
|
||||
const lowerN = needle.toLowerCase();
|
||||
const pos = lowerH.indexOf(lowerN);
|
||||
if (pos < 0) return 0;
|
||||
if (pos === 0) {
|
||||
const lenBonus = Math.max(0, 10 - Math.abs(haystack.length - needle.length));
|
||||
return 80 + Math.min(15, lenBonus);
|
||||
}
|
||||
return Math.max(20, 60 - Math.min(40, pos));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按匹配度降序;无关键字时保持原序
|
||||
*/
|
||||
export function rankByMatchScore<T>(
|
||||
items: T[],
|
||||
keyword: string,
|
||||
getText: (item: T) => string,
|
||||
getExtra?: (item: T) => string,
|
||||
): Array<T & { match_score: number }> {
|
||||
const kw = String(keyword || '').trim();
|
||||
const scored = items.map((item) => {
|
||||
const score = kw
|
||||
? scoreDictMatch(getText(item), kw, getExtra?.(item) || '')
|
||||
: 0;
|
||||
return Object.assign({}, item, { match_score: score }) as T & {
|
||||
match_score: number;
|
||||
};
|
||||
});
|
||||
if (!kw) return scored;
|
||||
return scored.sort((a, b) => {
|
||||
if (b.match_score !== a.match_score) return b.match_score - a.match_score;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 将文本按关键字拆成高亮片段(不做 HTML,便于 PC/小程序安全渲染)
|
||||
*/
|
||||
export function splitHighlight(
|
||||
text: string,
|
||||
keyword: string,
|
||||
): HighlightPart[] {
|
||||
const raw = String(text ?? '');
|
||||
const kw = String(keyword || '').trim();
|
||||
if (!raw || !kw) return [{ text: raw, hit: false }];
|
||||
const lower = raw.toLowerCase();
|
||||
const needle = kw.toLowerCase();
|
||||
const parts: HighlightPart[] = [];
|
||||
let start = 0;
|
||||
let idx = lower.indexOf(needle, start);
|
||||
while (idx >= 0) {
|
||||
if (idx > start) {
|
||||
parts.push({ text: raw.slice(start, idx), hit: false });
|
||||
}
|
||||
parts.push({ text: raw.slice(idx, idx + kw.length), hit: true });
|
||||
start = idx + kw.length;
|
||||
idx = lower.indexOf(needle, start);
|
||||
}
|
||||
if (start < raw.length) {
|
||||
parts.push({ text: raw.slice(start), hit: false });
|
||||
}
|
||||
return parts.length ? parts : [{ text: raw, hit: false }];
|
||||
}
|
||||
|
||||
let cachedRankMode: DictSearchRankMode | null = null;
|
||||
|
||||
/** 写入缓存(从系统配置或接口响应读取后调用) */
|
||||
export function setDictSearchRankMode(mode: string | undefined | null) {
|
||||
cachedRankMode = mode === 'frontend' ? 'frontend' : 'backend';
|
||||
}
|
||||
|
||||
export function getDictSearchRankMode(): DictSearchRankMode {
|
||||
return cachedRankMode || 'backend';
|
||||
}
|
||||
|
||||
/**
|
||||
* 按当前模式决定是否前端重排;有关键字时保证按匹配度降序,并尽量带上可展示的 match_score
|
||||
*/
|
||||
export function applyDictRankIfNeeded<T>(
|
||||
items: T[],
|
||||
keyword: string,
|
||||
getText: (item: T) => string,
|
||||
getExtra?: (item: T) => string,
|
||||
modeFromApi?: string,
|
||||
): Array<T & { match_score: number }> {
|
||||
if (modeFromApi === 'frontend' || modeFromApi === 'backend') {
|
||||
setDictSearchRankMode(modeFromApi);
|
||||
}
|
||||
const mode = getDictSearchRankMode();
|
||||
const kw = String(keyword || '').trim();
|
||||
if (mode === 'frontend') {
|
||||
return rankByMatchScore(items, keyword, getText, getExtra);
|
||||
}
|
||||
// backend:保留接口分数;若有关键字却全是 0(漏打分/合并未打分),前端补算
|
||||
const mapped = items.map((item: any) => ({
|
||||
...item,
|
||||
match_score: Number(item?.match_score || 0),
|
||||
})) as Array<T & { match_score: number }>;
|
||||
if (!kw) return mapped;
|
||||
const hasPositive = mapped.some((i) => Number(i.match_score) > 0);
|
||||
if (!hasPositive) {
|
||||
return rankByMatchScore(items, keyword, getText, getExtra);
|
||||
}
|
||||
return mapped.sort((a, b) => {
|
||||
if (b.match_score !== a.match_score) return b.match_score - a.match_score;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
Select,
|
||||
SelectOption,
|
||||
Tag,
|
||||
Tabs,
|
||||
TabPane,
|
||||
Textarea,
|
||||
Timeline,
|
||||
TimelineItem,
|
||||
@@ -41,10 +43,17 @@ import {
|
||||
saveWestCommonPrescriptionApi,
|
||||
} from '#/views/business/chat/api';
|
||||
import { usePrescriptionStore } from '#/store/prescription';
|
||||
import { hasVipPermission } from '#/utils/vip';
|
||||
import MedicalRecordPanel from '#/views/doctor/medical-record/MedicalRecordPanel.vue';
|
||||
import EntryKeywordBubble from '#/views/doctor/medical-record/components/EntryKeywordBubble.vue';
|
||||
import CommonDxOrderChips from '#/views/doctor/medical-record/components/CommonDxOrderChips.vue';
|
||||
import { saveMedicalRecord } from '#/views/doctor/medical-record/api';
|
||||
import {
|
||||
loadRxMrTab,
|
||||
saveRxMrTab,
|
||||
} from '#/views/doctor/medical-record/utils/localDraft';
|
||||
// import { getTraditionalChineseMedicineAllApi } from '#/views/doctor/doctor-reception/api';
|
||||
// 导入子组件
|
||||
import DiagnosisModal from '#/views/doctor/doctor-reception/components/DiagnosisModal.vue';
|
||||
import DoctorOrderModal from '#/views/doctor/doctor-reception/components/DoctorOrderModal.vue';
|
||||
import PrescriptionDetail from '#/views/doctor/doctor-reception/components/PrescriptionDetail.vue';
|
||||
import WesternModal from '#/views/doctor/doctor-reception/components/WesternModal.vue';
|
||||
import SimpleProductModal from '#/views/doctor/doctor-reception/components/SimpleProductModal.vue';
|
||||
@@ -68,6 +77,80 @@ const visible = ref(false);
|
||||
const previewImage = ref([]);
|
||||
const showNewDrugModal = ref(false);
|
||||
const doctorSecondSignModal = ref(false);
|
||||
/** 处方|病历 Tab(按挂号 + storagePrefix 落本地) */
|
||||
const rxMrTab = ref<'rx' | 'mr'>('rx');
|
||||
const medicalRecordPanelRef = ref<InstanceType<typeof MedicalRecordPanel> | null>(null);
|
||||
|
||||
// 使用 Pinia store(须在依赖它的 computed/watch 之前初始化)
|
||||
const prescriptionStore = usePrescriptionStore();
|
||||
|
||||
/**
|
||||
* 仅 chat-shop(非诊所前缀)且门店 VIP 含 medical_record 时显示病历 Tab
|
||||
*/
|
||||
const canUseMedicalRecord = computed(() => {
|
||||
if (!hasVipPermission('medical_record')) return false;
|
||||
if (modalData.value?.enableMedicalRecord === false) return false;
|
||||
if (modalData.value?.enableMedicalRecord === true) return true;
|
||||
const prefix = modalData.value?.storagePrefix || 'onlineConsultation-';
|
||||
return prefix !== 'onlineConsultationClinic-';
|
||||
});
|
||||
|
||||
const medicalRecordStoragePrefix = computed(
|
||||
() => modalData.value?.storagePrefix || 'onlineConsultation-',
|
||||
);
|
||||
|
||||
const diagnosisModel = computed({
|
||||
get: () => prescriptionStore.diagnosis,
|
||||
set: (v: string) => {
|
||||
prescriptionStore.diagnosis = v;
|
||||
},
|
||||
});
|
||||
const medicalAdviceModel = computed({
|
||||
get: () => prescriptionStore.medicalAdvice,
|
||||
set: (v: string) => {
|
||||
prescriptionStore.medicalAdvice = v;
|
||||
},
|
||||
});
|
||||
|
||||
const medicalRecordRegisterId = computed(
|
||||
() =>
|
||||
Number(
|
||||
modalData.value?.registerId ||
|
||||
prescriptionStore.currentRegisterId ||
|
||||
0,
|
||||
),
|
||||
);
|
||||
const medicalRecordStoreId = computed(
|
||||
() =>
|
||||
Number(
|
||||
prescriptionStore.registerStoreInfo?.store_id ||
|
||||
prescriptionStore.selectedStoreId ||
|
||||
prescriptionStore.myStoreId ||
|
||||
0,
|
||||
),
|
||||
);
|
||||
/** activePatient 即为 user_patient,主键用 id */
|
||||
const medicalRecordPatientId = computed(
|
||||
() => Number(prescriptionStore.activePatient?.id || 0),
|
||||
);
|
||||
|
||||
watch(rxMrTab, (tab) => {
|
||||
const rid = medicalRecordRegisterId.value;
|
||||
if (rid) saveRxMrTab(rid, tab, medicalRecordStoragePrefix.value);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [medicalRecordRegisterId.value, canUseMedicalRecord.value] as const,
|
||||
([rid, can]) => {
|
||||
if (!can) {
|
||||
rxMrTab.value = 'rx';
|
||||
return;
|
||||
}
|
||||
if (!rid) return;
|
||||
rxMrTab.value = loadRxMrTab(rid, medicalRecordStoragePrefix.value) || 'rx';
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// ==================== 保存常用方相关状态 ====================
|
||||
/** 保存常用方弹窗是否显示 */
|
||||
@@ -77,9 +160,6 @@ const commonPrescriptionName = ref('');
|
||||
/** 是否正在保存常用方 */
|
||||
const isSavingCommonPrescription = ref(false);
|
||||
|
||||
// 使用 Pinia store
|
||||
const prescriptionStore = usePrescriptionStore();
|
||||
|
||||
/** 处方类型来自后端下发(store.categories) */
|
||||
const categories = computed(() => prescriptionStore.categories);
|
||||
|
||||
@@ -161,18 +241,38 @@ const [SimpleProductModalComponent, SimpleProductModalApi] = useVbenModal({
|
||||
connectedComponent: SimpleProductModal,
|
||||
});
|
||||
|
||||
const [DiagnosisModals, DiagnosisModalApi] = useVbenModal({
|
||||
connectedComponent: DiagnosisModal,
|
||||
});
|
||||
|
||||
const [DoctorOrderModals, DoctorOrderModalApi] = useVbenModal({
|
||||
connectedComponent: DoctorOrderModal,
|
||||
});
|
||||
|
||||
const [PrescriptionDetailModal, PrescriptionDetailModalApi] = useVbenModal({
|
||||
connectedComponent: PrescriptionDetail,
|
||||
});
|
||||
|
||||
/**
|
||||
* 诊断/医嘱追加(中文逗号拼接,与病历面板一致)
|
||||
*/
|
||||
function appendCommaText(base: string, add: string) {
|
||||
if (!add) return base || '';
|
||||
const parts = (base || '').split(',').map((s) => s.trim()).filter(Boolean);
|
||||
if (parts.includes(add)) return parts.join(',');
|
||||
parts.push(add);
|
||||
return parts.join(',');
|
||||
}
|
||||
|
||||
/** 气泡/常用标签选中诊断(在线复诊诊断框只读,只能通过选择写入) */
|
||||
function onPickDiagnosis(text: string) {
|
||||
const t = String(text || '').trim();
|
||||
if (!t) return;
|
||||
prescriptionStore.diagnosis = appendCommaText(prescriptionStore.diagnosis, t);
|
||||
}
|
||||
|
||||
/** 气泡/常用标签选中医嘱 */
|
||||
function onPickDoctorOrder(text: string) {
|
||||
const t = String(text || '').trim();
|
||||
if (!t) return;
|
||||
prescriptionStore.medicalAdvice = appendCommaText(
|
||||
prescriptionStore.medicalAdvice,
|
||||
t,
|
||||
);
|
||||
}
|
||||
|
||||
// 信息提示模态框
|
||||
const [InfoModalComponent, infoModalApi] = useVbenModal({
|
||||
connectedComponent: InfoModal,
|
||||
@@ -382,6 +482,23 @@ const executeSendPrescription = async (
|
||||
const prescriptionResult = typeof result === 'object' ? result : null;
|
||||
|
||||
if (success) {
|
||||
// VIP 病历:发送成功后同步保存(诊断/医嘱与处方同源)
|
||||
if (canUseMedicalRecord.value) {
|
||||
try {
|
||||
const payload = medicalRecordPanelRef.value?.getPayload?.() || {};
|
||||
await saveMedicalRecord({
|
||||
...payload,
|
||||
register_id: medicalRecordRegisterId.value,
|
||||
store_id: medicalRecordStoreId.value,
|
||||
user_patient_id: medicalRecordPatientId.value,
|
||||
diagnosis: prescriptionStore.diagnosis,
|
||||
doctor_order: prescriptionStore.medicalAdvice,
|
||||
medicalAdvice: prescriptionStore.medicalAdvice,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('同步保存病历失败', e);
|
||||
}
|
||||
}
|
||||
// 检查是否需要转诊(西医诊所开中药处方)
|
||||
if (prescriptionResult?.need_transfer || prescriptionResult?.result?.need_transfer) {
|
||||
const transferId = prescriptionResult?.transfer_prescription_id || prescriptionResult?.result?.transfer_prescription_id;
|
||||
@@ -513,28 +630,6 @@ const openWesternModal = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 打开诊断模态框
|
||||
const openDiagnosisModal = () => {
|
||||
DiagnosisModalApi.setData({
|
||||
values: prescriptionStore.diagnosis,
|
||||
updateDiagnosis: (values: string) => {
|
||||
prescriptionStore.diagnosis = values;
|
||||
},
|
||||
});
|
||||
DiagnosisModalApi.open();
|
||||
};
|
||||
|
||||
// 打开医嘱模态框
|
||||
const openDoctorOrderModal = () => {
|
||||
DoctorOrderModalApi.setData({
|
||||
values: prescriptionStore.medicalAdvice,
|
||||
updateDoctorOrder: (values: string) => {
|
||||
prescriptionStore.medicalAdvice = values;
|
||||
},
|
||||
});
|
||||
DoctorOrderModalApi.open();
|
||||
};
|
||||
|
||||
// 打开处方详情模态框
|
||||
const openPrescriptionDetail = (prescription: any) => {
|
||||
PrescriptionDetailModalApi.setData({
|
||||
@@ -1014,6 +1109,16 @@ const cancelSaveCommonPrescription = () => {
|
||||
|
||||
<!-- 右侧:处方开具区域 (占据剩余空间) -->
|
||||
<div class="right-panel flex-1 h-full overflow-y-auto px-2">
|
||||
<Tabs
|
||||
v-if="canUseMedicalRecord"
|
||||
v-model:active-key="rxMrTab"
|
||||
class="mb-2"
|
||||
>
|
||||
<TabPane key="rx" tab="处方" />
|
||||
<TabPane key="mr" tab="病历" />
|
||||
</Tabs>
|
||||
|
||||
<div v-show="!canUseMedicalRecord || rxMrTab === 'rx'">
|
||||
<!-- 药品分类导航 -->
|
||||
<div class="drug-categories sticky top-0 bg-white dark:bg-[#151515] z-10 py-2 border-b mb-4">
|
||||
<button
|
||||
@@ -1510,33 +1615,49 @@ const cancelSaveCommonPrescription = () => {
|
||||
show-search
|
||||
/>
|
||||
</div>
|
||||
<!-- 诊断输入 -->
|
||||
<!-- 诊断输入:搜索气泡 + 常用标签,与病历同源 -->
|
||||
<div class="mb-3">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<label class="font-medium">常用诊断</label>
|
||||
<Button type="primary" size="small" @click="openDiagnosisModal">选择常用诊断</Button>
|
||||
<div class="mb-2">
|
||||
<label class="font-medium">诊断</label>
|
||||
</div>
|
||||
<EntryKeywordBubble
|
||||
class="mb-2"
|
||||
source="diagnosis"
|
||||
placeholder="诊断关键字搜索"
|
||||
@select="(item) => onPickDiagnosis(item.content || item.title)"
|
||||
/>
|
||||
<Textarea
|
||||
v-model:value="prescriptionStore.diagnosis"
|
||||
disabled
|
||||
placeholder="输入常用诊断..."
|
||||
placeholder="请通过上方搜索或下方常用诊断选择..."
|
||||
:rows="2"
|
||||
/>
|
||||
<CommonDxOrderChips
|
||||
source="diagnosis"
|
||||
@select="onPickDiagnosis"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 医嘱输入 -->
|
||||
<div class="mb-3">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<div class="mb-2">
|
||||
<label class="font-medium">医嘱</label>
|
||||
<Button type="primary" size="small" @click="openDoctorOrderModal">
|
||||
选择常用医嘱
|
||||
</Button>
|
||||
</div>
|
||||
<EntryKeywordBubble
|
||||
class="mb-2"
|
||||
source="doctor_order"
|
||||
placeholder="医嘱关键字搜索"
|
||||
@select="(item) => onPickDoctorOrder(item.content || item.title)"
|
||||
/>
|
||||
<Textarea
|
||||
v-model:value="prescriptionStore.medicalAdvice"
|
||||
placeholder="输入医嘱..."
|
||||
:rows="2"
|
||||
/>
|
||||
<CommonDxOrderChips
|
||||
source="doctor_order"
|
||||
@select="onPickDoctorOrder"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ChineseMedicineConfig
|
||||
@@ -1590,6 +1711,22 @@ const cancelSaveCommonPrescription = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-show="canUseMedicalRecord && rxMrTab === 'mr'"
|
||||
class="medical-record-wrap py-2"
|
||||
>
|
||||
<MedicalRecordPanel
|
||||
ref="medicalRecordPanelRef"
|
||||
v-model:diagnosis="diagnosisModel"
|
||||
v-model:medical-advice="medicalAdviceModel"
|
||||
:register-id="medicalRecordRegisterId"
|
||||
:store-id="medicalRecordStoreId"
|
||||
:user-patient-id="medicalRecordPatientId"
|
||||
:storage-prefix="medicalRecordStoragePrefix"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 以下是各种弹窗组件,位置不动 -->
|
||||
@@ -1662,8 +1799,6 @@ const cancelSaveCommonPrescription = () => {
|
||||
<WesternDrugModal />
|
||||
<SimpleProductModalComponent />
|
||||
<InfoModalComponent />
|
||||
<DiagnosisModals />
|
||||
<DoctorOrderModals />
|
||||
<PrescriptionDetailModal />
|
||||
<!-- 常用方选择弹窗 -->
|
||||
<CommonPrescriptionModals />
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 病历门店词条-按字段拆分页(从路由 path 末段解析 field_code)
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import EntryListPage from '#/views/doctor/medical-record/components/EntryListPage.vue';
|
||||
import { MEDICAL_RECORD_FIELD_OPTIONS } from '#/views/doctor/medical-record/config/constants';
|
||||
|
||||
defineOptions({ name: 'MedicalRecordEntryStoreField' });
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const fieldCode = computed(() => {
|
||||
const seg = (route.path.split('/').filter(Boolean).pop() || '').trim();
|
||||
return seg.replace(/-/g, '_');
|
||||
});
|
||||
|
||||
const fieldLabel = computed(() => {
|
||||
const hit = MEDICAL_RECORD_FIELD_OPTIONS.find((o) => o.value === fieldCode.value);
|
||||
return hit?.label || fieldCode.value;
|
||||
});
|
||||
|
||||
const pageTitle = computed(() => `门店词条·${fieldLabel.value}`);
|
||||
</script>
|
||||
<template>
|
||||
<EntryListPage
|
||||
:key="fieldCode"
|
||||
scope="store"
|
||||
:fixed-field-code="fieldCode"
|
||||
:page-title="pageTitle"
|
||||
:page-name="String(route.name || 'MedicalRecordEntryStoreField')"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 病历门店词条管理(当前登录门店)
|
||||
*/
|
||||
import EntryListPage from '#/views/doctor/medical-record/components/EntryListPage.vue';
|
||||
|
||||
defineOptions({ name: 'MedicalRecordEntryStore' });
|
||||
</script>
|
||||
<template>
|
||||
<EntryListPage
|
||||
scope="store"
|
||||
page-title="病历门店词条"
|
||||
page-name="MedicalRecordEntryStore"
|
||||
/>
|
||||
</template>
|
||||
@@ -23,6 +23,10 @@ import {
|
||||
addDoctorMyDiseaseApi,
|
||||
deleteDoctorMyDiseaseApi,
|
||||
} from "#/views/doctor/settings/api";
|
||||
import {
|
||||
applyDictRankIfNeeded,
|
||||
splitHighlight,
|
||||
} from '#/utils/dictSearchRank';
|
||||
|
||||
const searchKey = ref('');
|
||||
// 存储接口返回的所有诊断数据
|
||||
@@ -90,32 +94,37 @@ const [Modal, modalApi] = useVbenModal({
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取诊断列表
|
||||
* 获取诊断列表(按配置匹配度排序 + 关键字高亮片段)
|
||||
* @param searchKey
|
||||
*/
|
||||
function getDiagnosisList(searchKey = '') {
|
||||
getDiseaseList(searchKey).then((res) => {
|
||||
// 处理所有数据
|
||||
const processedList = res.map((item) => {
|
||||
const kw = String(searchKey || '').trim();
|
||||
getDiseaseList(kw).then((res) => {
|
||||
const rows = Array.isArray(res) ? res : [];
|
||||
const modeFromApi = rows[0]?.rank_mode;
|
||||
const ranked = applyDictRankIfNeeded(
|
||||
rows,
|
||||
kw,
|
||||
(item: any) => String(item?.name || ''),
|
||||
(item: any) => String(item?.pinyin || ''),
|
||||
modeFromApi,
|
||||
);
|
||||
const processedList = ranked.map((item: any) => {
|
||||
item.isSelect = 0;
|
||||
// 检查是否已在常用诊断中
|
||||
item.isInMyList = doctorMyDiseaseList.value.some(
|
||||
(myItem) => myItem.disease.id === item.id
|
||||
(myItem: any) => myItem.disease.id === item.id,
|
||||
);
|
||||
if (selectDiagnosisList.value) {
|
||||
// 把values的字符串转为数组根据,分解
|
||||
const valuesArr = selectDiagnosisList.value.split(',');
|
||||
// 判断valuesArr数组中是否有item.name
|
||||
valuesArr.forEach((value) => {
|
||||
if (value === item.name) {
|
||||
item.isSelect = 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
item._hlParts = splitHighlight(item.name || '', kw);
|
||||
return item;
|
||||
});
|
||||
|
||||
// 更新总数据源并重置页码
|
||||
allDiagnosisList.value = processedList;
|
||||
currentPage.value = 1;
|
||||
});
|
||||
@@ -123,7 +132,7 @@ function getDiagnosisList(searchKey = '') {
|
||||
|
||||
const getDiagnosisListChage = debounce(async (searchText = '') => {
|
||||
getDiagnosisList(searchText);
|
||||
}, 300)
|
||||
}, 500)
|
||||
|
||||
/**
|
||||
* 选择诊断
|
||||
@@ -255,7 +264,12 @@ function handlePageChange(page: number, size: number) {
|
||||
:class="{ 'is-selected': item.isSelect === 1 }"
|
||||
@click="selectDiagnosis(item)"
|
||||
>
|
||||
<span class="tag-text">{{ item.name }}</span>
|
||||
<span class="tag-text">
|
||||
<template v-for="(p, pi) in (item._hlParts || [{ text: item.name, hit: false }])" :key="pi">
|
||||
<em v-if="p.hit" class="hit">{{ p.text }}</em>
|
||||
<template v-else>{{ p.text }}</template>
|
||||
</template>
|
||||
</span>
|
||||
|
||||
<div class="tag-action-wrapper" @click.stop>
|
||||
<Tooltip v-if="!item.isInMyList" title="加入常用">
|
||||
@@ -500,4 +514,12 @@ function handlePageChange(page: number, size: number) {
|
||||
.dark .pagination-wrapper {
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
.hit {
|
||||
font-style: normal;
|
||||
color: #cf1322;
|
||||
background: rgba(255, 214, 102, 0.55);
|
||||
border-radius: 2px;
|
||||
padding: 0 1px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,6 +6,9 @@ import {useUserStore} from '@vben/stores';
|
||||
|
||||
import {
|
||||
DeleteTwoTone,
|
||||
LeftOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
SaveOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import {
|
||||
@@ -26,6 +29,8 @@ import {
|
||||
SelectOption,
|
||||
Modal as AntModal,
|
||||
Tag,
|
||||
Tabs,
|
||||
TabPane,
|
||||
Textarea,
|
||||
Timeline,
|
||||
TimelineItem, notification,
|
||||
@@ -33,6 +38,16 @@ import {
|
||||
import {debounce} from 'lodash-es'; // 或者使用自定义防抖函数
|
||||
|
||||
import {getRegisterStatus} from '#/util/tool';
|
||||
import { hasVipPermission } from '#/utils/vip';
|
||||
import MedicalRecordPanel from '#/views/doctor/medical-record/MedicalRecordPanel.vue';
|
||||
import EntryKeywordBubble from '#/views/doctor/medical-record/components/EntryKeywordBubble.vue';
|
||||
import CommonDxOrderChips from '#/views/doctor/medical-record/components/CommonDxOrderChips.vue';
|
||||
import { saveMedicalRecord } from '#/views/doctor/medical-record/api';
|
||||
import {
|
||||
clearMedicalRecordDraft,
|
||||
loadRxMrTab,
|
||||
saveRxMrTab,
|
||||
} from '#/views/doctor/medical-record/utils/localDraft';
|
||||
import {
|
||||
addWestPrescription, checkChineseMedicineConflictApi,
|
||||
endOfDiagnosisApi,
|
||||
@@ -52,8 +67,6 @@ import {
|
||||
} from '#/views/doctor/doctor-reception/api';
|
||||
|
||||
import SpecialPrescriptionImportCard from './components/SpecialPrescriptionImportCard.vue';
|
||||
import DiagnosisModal from './components/DiagnosisModal.vue';
|
||||
import DoctorOrderModal from './components/DoctorOrderModal.vue';
|
||||
import PrescriptionDetail from './components/PrescriptionDetail.vue';
|
||||
import WesternModal from './components/WesternModal.vue';
|
||||
import SimpleProductModal from './components/SimpleProductModal.vue';
|
||||
@@ -147,6 +160,25 @@ interface UserPatientHealthInquiry {
|
||||
}
|
||||
|
||||
const tabType = ref(0);
|
||||
/** 处方|病历 Tab(v-show 切换,保持两侧状态;按挂号落本地) */
|
||||
const rxMrTab = ref<'rx' | 'mr'>('rx');
|
||||
const medicalRecordPanelRef = ref<InstanceType<typeof MedicalRecordPanel> | null>(null);
|
||||
const canUseMedicalRecord = computed(() => hasVipPermission('medical_record'));
|
||||
|
||||
watch(rxMrTab, (tab) => {
|
||||
const rid = getRegisterId();
|
||||
if (rid) saveRxMrTab(rid, tab);
|
||||
});
|
||||
|
||||
/** 按挂号恢复处方|病历 Tab */
|
||||
function restoreRxMrTab(registerId: number) {
|
||||
if (!canUseMedicalRecord.value) {
|
||||
rxMrTab.value = 'rx';
|
||||
return;
|
||||
}
|
||||
const saved = loadRxMrTab(registerId);
|
||||
rxMrTab.value = saved || 'rx';
|
||||
}
|
||||
const category = ref(1);
|
||||
const listType = ref(1);
|
||||
const receptionStatus = ref(0);
|
||||
@@ -219,6 +251,7 @@ const { config: priceAdjustConfig, loadByStoreId, applyRatioToDrugs: applyRatioD
|
||||
const [PriceAdjustDrawer, priceAdjustDrawerApi] = useVbenDrawer({
|
||||
connectedComponent: OrderPricePercentAdjustDrawer,
|
||||
});
|
||||
|
||||
/** 推广员传方导入关联ID */
|
||||
const salespersonTransferPrescriptionId = ref(0);
|
||||
const salespersonTransferDrawerOpen = ref(false);
|
||||
@@ -662,6 +695,8 @@ const selectPatient = async (patient: Patient, isUpdateTabType = true) => {
|
||||
checkAndShowTransferTip();
|
||||
checkSalespersonTransfer();
|
||||
}
|
||||
// 恢复处方|病历 Tab
|
||||
restoreRxMrTab(registerId);
|
||||
|
||||
getPatientItem(patient.id).then((value) => {
|
||||
patientInfo.value = value;
|
||||
@@ -797,6 +832,7 @@ function clearReceptionDraft(registerId?: number) {
|
||||
const id = registerId || getRegisterId();
|
||||
if (id) {
|
||||
localStorage.removeItem(getDraftKey(id));
|
||||
clearMedicalRecordDraft(String(id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1007,8 +1043,33 @@ const sendPrescription = () => {
|
||||
salesperson_transfer_prescription_id: salespersonTransferPrescriptionId.value || undefined,
|
||||
price_discount: priceDiscount.value,
|
||||
special_prescription_id: appliedSpecialPrescriptionId.value || 0,
|
||||
}).then((res) => {
|
||||
}).then(async (res) => {
|
||||
message.success('处方已发送');
|
||||
// 有 VIP 时同步保存病历(诊断/医嘱已与处方同源)
|
||||
if (canUseMedicalRecord.value) {
|
||||
try {
|
||||
const registerId = Number(localStorage.getItem('doctorReception-id') || 0);
|
||||
const payload = medicalRecordPanelRef.value?.getPayload?.() || {
|
||||
diagnosis: diagnosis.value,
|
||||
doctor_order: medicalAdvice.value,
|
||||
};
|
||||
await saveMedicalRecord({
|
||||
...payload,
|
||||
register_id: registerId,
|
||||
store_id: Number(myStoreId.value || 0),
|
||||
user_patient_id: Number(
|
||||
activePatient.value?.user_patient?.id ||
|
||||
activePatient.value?.user_patient_id ||
|
||||
0,
|
||||
),
|
||||
diagnosis: diagnosis.value,
|
||||
doctor_order: medicalAdvice.value,
|
||||
medicalAdvice: medicalAdvice.value,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('同步保存病历失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否需要转诊
|
||||
const needTransfer = res?.result?.need_transfer || res?.data?.need_transfer || res?.need_transfer;
|
||||
@@ -1083,45 +1144,29 @@ const [SimpleProductModalComponent, SimpleProductModalApi] = useVbenModal({
|
||||
connectedComponent: SimpleProductModal,
|
||||
});
|
||||
|
||||
const [DiagnosisModals, DiagnosisModalApi] = useVbenModal({
|
||||
connectedComponent: DiagnosisModal,
|
||||
});
|
||||
|
||||
const openDiagnosisModal = () => {
|
||||
// 打开常用诊断模态框逻辑
|
||||
DiagnosisModalApi.setData({
|
||||
values: diagnosis.value,
|
||||
updateDiagnosis,
|
||||
});
|
||||
DiagnosisModalApi.open();
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新诊断结果
|
||||
* @param values
|
||||
* 诊断/医嘱追加(中文逗号拼接,与病历面板一致)
|
||||
*/
|
||||
function updateDiagnosis(values) {
|
||||
diagnosis.value = values;
|
||||
function appendCommaText(base: string, add: string) {
|
||||
if (!add) return base || '';
|
||||
const parts = (base || '').split(',').map((s) => s.trim()).filter(Boolean);
|
||||
if (parts.includes(add)) return parts.join(',');
|
||||
parts.push(add);
|
||||
return parts.join(',');
|
||||
}
|
||||
|
||||
/**
|
||||
* 常用医嘱
|
||||
*/
|
||||
const [DoctorOrderModals, DoctorOrderModalApi] = useVbenModal({
|
||||
connectedComponent: DoctorOrderModal,
|
||||
});
|
||||
/** 气泡/常用标签选中诊断 */
|
||||
function onPickDiagnosis(text: string) {
|
||||
const t = String(text || '').trim();
|
||||
if (!t) return;
|
||||
diagnosis.value = appendCommaText(diagnosis.value, t);
|
||||
}
|
||||
|
||||
const openDoctorOrderModal = () => {
|
||||
// 打开常用医嘱模态框逻辑
|
||||
DoctorOrderModalApi.setData({
|
||||
values: medicalAdvice.value,
|
||||
updateDoctorOrder,
|
||||
});
|
||||
DoctorOrderModalApi.open();
|
||||
};
|
||||
|
||||
function updateDoctorOrder(values) {
|
||||
medicalAdvice.value = values;
|
||||
/** 气泡/常用标签选中医嘱 */
|
||||
function onPickDoctorOrder(text: string) {
|
||||
const t = String(text || '').trim();
|
||||
if (!t) return;
|
||||
medicalAdvice.value = appendCommaText(medicalAdvice.value, t);
|
||||
}
|
||||
|
||||
const [PrescriptionDetailModal, PrescriptionDetailModalApi] = useVbenModal({
|
||||
@@ -2510,16 +2555,27 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="leftShow === true? 'container-box-grid':'container-box'">
|
||||
<div class="reception-layout" :class="{ 'is-left-collapsed': !leftShow }">
|
||||
<RefusalOfTreatmentModals />
|
||||
<InfoModalComponent />
|
||||
<!-- 如果leftShow === true展示收起,反之则显示展开-->
|
||||
<div>
|
||||
<Button :class="`${leftShow === true? 'w-full': 'sticky top-5 left-5'}`" type="primary" @click="leftShow = !leftShow">{{ leftShow === true ? '收起患者列表' : '展开患者列表' }}</Button>
|
||||
<!-- 左侧患者列表 -->
|
||||
<div v-if="leftShow" class="patient-panel">
|
||||
<!-- 左侧患者列表:宽度过渡动画,收起后保留窄条展开按钮 -->
|
||||
<aside class="patient-sidebar">
|
||||
<div class="patient-sidebar__toggle">
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
class="patient-sidebar__toggle-btn"
|
||||
:title="leftShow ? '收起患者列表' : '展开患者列表'"
|
||||
@click="leftShow = !leftShow"
|
||||
>
|
||||
<MenuFoldOutlined v-if="leftShow" />
|
||||
<MenuUnfoldOutlined v-else />
|
||||
<span v-if="leftShow" class="patient-sidebar__toggle-text">收起</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="patient-panel">
|
||||
<Select v-model:value="myStoreId" class="w-full" @change="switchStore">
|
||||
<SelectOption v-for="item in myStoreList" :value="item.id">
|
||||
<SelectOption v-for="item in myStoreList" :key="item.id" :value="item.id">
|
||||
{{ item.name }}
|
||||
</SelectOption>
|
||||
</Select>
|
||||
@@ -2536,7 +2592,6 @@ watch(
|
||||
v-for="(patient, index) in patients"
|
||||
:key="index"
|
||||
>
|
||||
|
||||
<div
|
||||
v-if="patient.user_patient"
|
||||
:class="{ active: selectPatientId === patient.id }"
|
||||
@@ -2571,8 +2626,21 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 右侧主区域 -->
|
||||
<div class="reception-main">
|
||||
<!-- 开方页返回:右上角悬浮,不占布局高度 -->
|
||||
<Button
|
||||
v-if="tabType === 2"
|
||||
class="rx-back-fab"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="tabType = 1"
|
||||
>
|
||||
<LeftOutlined />
|
||||
返回
|
||||
</Button>
|
||||
<!-- 右侧处方区域 -->
|
||||
<Page v-if="tabType === 1" class="prescription-panel">
|
||||
<div class="prescription-header">
|
||||
@@ -2808,15 +2876,21 @@ watch(
|
||||
</div>
|
||||
</Page>
|
||||
|
||||
<Page v-if="tabType === 2" class="prescription-panel">
|
||||
<Button class="mb-10" type="primary" @click="tabType = 1">
|
||||
返回上一页
|
||||
</Button>
|
||||
|
||||
<Page v-if="tabType === 2" class="prescription-panel prescription-panel--rx">
|
||||
<div class="prescription-header">
|
||||
<h2>{{ activePatient.name }} 的处方</h2>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
v-if="canUseMedicalRecord"
|
||||
v-model:active-key="rxMrTab"
|
||||
class="mb-4"
|
||||
>
|
||||
<TabPane key="rx" tab="处方" />
|
||||
<TabPane key="mr" tab="病历" />
|
||||
</Tabs>
|
||||
|
||||
<div v-show="!canUseMedicalRecord || rxMrTab === 'rx'">
|
||||
<!-- 药品分类导航 -->
|
||||
<div class="drug-categories">
|
||||
<button
|
||||
@@ -3447,20 +3521,38 @@ watch(
|
||||
class="mb-5"
|
||||
@change="handleChineseConfigChange"
|
||||
/>
|
||||
<Button type="primary" @click="openDiagnosisModal">
|
||||
常用诊断
|
||||
</Button>
|
||||
<Textarea
|
||||
v-model:value="diagnosis"
|
||||
placeholder="输入诊断结果..."
|
||||
/>
|
||||
<Button type="primary" @click="openDoctorOrderModal">
|
||||
常用医嘱
|
||||
</Button>
|
||||
<Textarea
|
||||
v-model:value="medicalAdvice"
|
||||
placeholder="输入医嘱..."
|
||||
/>
|
||||
<div class="mb-2">
|
||||
<div class="mb-1 font-medium">诊断</div>
|
||||
<EntryKeywordBubble
|
||||
source="diagnosis"
|
||||
placeholder="诊断关键字搜索"
|
||||
@select="(item) => onPickDiagnosis(item.content || item.title)"
|
||||
/>
|
||||
<Textarea
|
||||
v-model:value="diagnosis"
|
||||
placeholder="输入诊断结果..."
|
||||
/>
|
||||
<CommonDxOrderChips
|
||||
source="diagnosis"
|
||||
@select="onPickDiagnosis"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<div class="mb-1 font-medium">医嘱</div>
|
||||
<EntryKeywordBubble
|
||||
source="doctor_order"
|
||||
placeholder="医嘱关键字搜索"
|
||||
@select="(item) => onPickDoctorOrder(item.content || item.title)"
|
||||
/>
|
||||
<Textarea
|
||||
v-model:value="medicalAdvice"
|
||||
placeholder="输入医嘱..."
|
||||
/>
|
||||
<CommonDxOrderChips
|
||||
source="doctor_order"
|
||||
@select="onPickDoctorOrder"
|
||||
/>
|
||||
</div>
|
||||
诊疗费用:
|
||||
<InputNumber
|
||||
v-model:value="treatmentPrice"
|
||||
@@ -3528,12 +3620,27 @@ watch(
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-show="canUseMedicalRecord && rxMrTab === 'mr'"
|
||||
class="medical-record-wrap"
|
||||
>
|
||||
<MedicalRecordPanel
|
||||
ref="medicalRecordPanelRef"
|
||||
v-model:diagnosis="diagnosis"
|
||||
v-model:medical-advice="medicalAdvice"
|
||||
:register-id="doctorReceptionRegisterId"
|
||||
:store-id="Number(myStoreId || 0)"
|
||||
:user-patient-id="Number(activePatient?.id || 0)"
|
||||
/>
|
||||
</div>
|
||||
</Page>
|
||||
<div v-else-if="tabType === 0" class="prescription-panel">
|
||||
<Empty/>
|
||||
</div>
|
||||
<DiagnosisModals/>
|
||||
<DoctorOrderModals/>
|
||||
</div>
|
||||
<!-- /reception-main -->
|
||||
<WesternDrugModal/>
|
||||
<SimpleProductModalComponent/>
|
||||
<PrescriptionDetailModal/>
|
||||
@@ -3615,6 +3722,81 @@ watch(
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.reception-layout {
|
||||
display: flex;
|
||||
min-height: 90vh;
|
||||
width: 100%;
|
||||
align-items: stretch;
|
||||
}
|
||||
.patient-sidebar {
|
||||
flex-shrink: 0;
|
||||
width: 300px;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid #e4e7ed;
|
||||
transition: width 0.28s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: hsl(var(--background));
|
||||
}
|
||||
.reception-layout.is-left-collapsed .patient-sidebar {
|
||||
width: 44px;
|
||||
}
|
||||
.patient-sidebar__toggle {
|
||||
flex-shrink: 0;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.reception-layout.is-left-collapsed .patient-sidebar__toggle {
|
||||
justify-content: center;
|
||||
padding: 8px 4px;
|
||||
}
|
||||
.patient-sidebar__toggle-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.patient-sidebar__toggle-text {
|
||||
margin-left: 2px;
|
||||
}
|
||||
.patient-panel {
|
||||
width: 300px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
opacity: 1;
|
||||
transition: opacity 0.2s ease;
|
||||
padding: 0 8px 12px;
|
||||
}
|
||||
.reception-layout.is-left-collapsed .patient-panel {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.dark .patient-sidebar {
|
||||
border-right: 1px solid #333;
|
||||
}
|
||||
.reception-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
.rx-back-fab {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 16px;
|
||||
z-index: 30;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.prescription-panel--rx .prescription-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.container-box {
|
||||
min-height: 90vh;
|
||||
}
|
||||
@@ -3624,13 +3806,8 @@ watch(
|
||||
grid-template-columns: 300px 1fr;
|
||||
}
|
||||
|
||||
.patient-panel {
|
||||
border-right: 1px solid #e4e7ed;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.dark .patient-panel {
|
||||
border-right: 1px solid #333;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.patient-card {
|
||||
|
||||
@@ -0,0 +1,975 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 接诊病历面板(双栏)
|
||||
* diagnosis / medicalAdvice 与处方 Tab 双向绑定
|
||||
* 诊断/医嘱气泡走处方同源接口;其余字段走病历词条
|
||||
* 文本域双击弹出放大编辑 Modal
|
||||
*/
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
|
||||
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
import { useVbenDrawer, useVbenModal } from '@vben/common-ui';
|
||||
import {
|
||||
Button,
|
||||
Col,
|
||||
Input,
|
||||
InputNumber,
|
||||
Row,
|
||||
Textarea,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import { getSystemConfigByKeys } from '#/views/system/system-config/api';
|
||||
|
||||
import {
|
||||
clearMedicalRecord,
|
||||
getMedicalRecord,
|
||||
saveMedicalRecord,
|
||||
} from './api';
|
||||
import EntryKeywordBubble, {
|
||||
type MedicalRecordEntryItem,
|
||||
} from './components/EntryKeywordBubble.vue';
|
||||
import CommonDxOrderChips from './components/CommonDxOrderChips.vue';
|
||||
import PatientMedicalRecordDrawer from './components/PatientMedicalRecordDrawer.vue';
|
||||
import TextareaExpandModal from './components/TextareaExpandModal.vue';
|
||||
import { emptyMedicalRecord } from './config/constants';
|
||||
import {
|
||||
clearMedicalRecordDraft,
|
||||
loadMedicalRecordDraft,
|
||||
saveMedicalRecordDraft,
|
||||
} from './utils/localDraft';
|
||||
|
||||
/** 诊断/医嘱常用展示:tags标签 / bubble气泡 / both两边 */
|
||||
type MrCommonDisplayMode = 'tags' | 'bubble' | 'both';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
registerId: number;
|
||||
storeId?: number;
|
||||
userPatientId?: number;
|
||||
/** 与处方同步:临床诊断 */
|
||||
diagnosis: string;
|
||||
/** 与处方同步:医嘱 */
|
||||
medicalAdvice: string;
|
||||
/** 在线复诊时诊断只读 */
|
||||
diagnosisReadonly?: boolean;
|
||||
/** localStorage 键前缀(chat-shop 用 storagePrefix 隔离) */
|
||||
storagePrefix?: string;
|
||||
}>(),
|
||||
{
|
||||
storeId: 0,
|
||||
userPatientId: 0,
|
||||
diagnosisReadonly: false,
|
||||
storagePrefix: '',
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:diagnosis': [string];
|
||||
'update:medicalAdvice': [string];
|
||||
}>();
|
||||
|
||||
const form = reactive(emptyMedicalRecord());
|
||||
const loading = ref(false);
|
||||
/** 加载中不写本地,避免接口回填触发草稿覆盖 */
|
||||
const hydrating = ref(false);
|
||||
/** 系统配置:常用|我的展示位置,默认标签 */
|
||||
const commonDisplayMode = ref<MrCommonDisplayMode>('tags');
|
||||
const showCommonTags = computed(
|
||||
() => commonDisplayMode.value === 'tags' || commonDisplayMode.value === 'both',
|
||||
);
|
||||
const showCommonInBubble = computed(
|
||||
() =>
|
||||
commonDisplayMode.value === 'bubble' || commonDisplayMode.value === 'both',
|
||||
);
|
||||
/** 标签区刷新(气泡内新增/加常用后) */
|
||||
const commonChipsTick = ref(0);
|
||||
|
||||
/**
|
||||
* 病历模块焦点顺序:双栏同行先左后右,再换下一行
|
||||
* 「中医病案」内:舌象→脉象→辨证/病案→证候→疾病→治法,再进入右栏过敏史
|
||||
*/
|
||||
const MODULE_ORDER = [
|
||||
'diagnosis',
|
||||
'family_history',
|
||||
'chief_complaint',
|
||||
'epidemic_history',
|
||||
'present_illness',
|
||||
'past_history',
|
||||
'tongue',
|
||||
'pulse',
|
||||
'tcm_case',
|
||||
'tcm_syndrome',
|
||||
'tcm_disease',
|
||||
'tcm_method',
|
||||
'allergy_history',
|
||||
'treatment_advice',
|
||||
'physical_exam',
|
||||
'doctor_order',
|
||||
'auxiliary_exam',
|
||||
] as const;
|
||||
|
||||
type MrModuleKey = (typeof MODULE_ORDER)[number];
|
||||
|
||||
const panelRootRef = ref<HTMLElement | null>(null);
|
||||
const searchRefs = reactive<Record<string, { focus?: () => void; close?: () => void } | null>>(
|
||||
{},
|
||||
);
|
||||
const textareaRefs = reactive<Record<string, any>>({});
|
||||
|
||||
function setSearchRef(key: string, el: unknown) {
|
||||
searchRefs[key] = (el as any) || null;
|
||||
}
|
||||
|
||||
function setTextareaRef(key: string, el: unknown) {
|
||||
textareaRefs[key] = el || null;
|
||||
}
|
||||
|
||||
/** 聚焦某模块搜索气泡 */
|
||||
function focusModuleSearch(key: MrModuleKey) {
|
||||
Object.values(searchRefs).forEach((r) => r?.close?.());
|
||||
nextTick(() => searchRefs[key]?.focus?.());
|
||||
}
|
||||
|
||||
/**
|
||||
* 聚焦某模块文本域/输入框
|
||||
* 舌象/脉象是 Input,其余多为 Textarea,兼容两种 DOM
|
||||
*/
|
||||
function focusModuleTextarea(key: MrModuleKey) {
|
||||
Object.values(searchRefs).forEach((r) => r?.close?.());
|
||||
nextTick(() => {
|
||||
const comp = textareaRefs[key];
|
||||
if (!comp) return;
|
||||
// Ant Design Vue 组件常自带 focus()
|
||||
if (typeof comp.focus === 'function') {
|
||||
comp.focus();
|
||||
return;
|
||||
}
|
||||
const el =
|
||||
comp?.resizableTextArea?.textArea ||
|
||||
comp?.$el?.querySelector?.('textarea') ||
|
||||
comp?.$el?.querySelector?.('input') ||
|
||||
(comp instanceof HTMLTextAreaElement || comp instanceof HTMLInputElement
|
||||
? comp
|
||||
: null);
|
||||
el?.focus?.();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ctrl+Enter:搜索→同模块文本域;文本域→下一模块搜索(首尾循环)
|
||||
* Ctrl+Shift+Enter:文本域→同模块搜索;搜索→上一模块文本域(首尾循环)
|
||||
*/
|
||||
function onPanelKeydownCapture(e: KeyboardEvent) {
|
||||
if (!e.ctrlKey || e.key !== 'Enter') return;
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (!target || !panelRootRef.value?.contains(target)) return;
|
||||
const host = target.closest('[data-mr-module]') as HTMLElement | null;
|
||||
if (!host || !panelRootRef.value.contains(host)) return;
|
||||
const moduleKey = host.getAttribute('data-mr-module') as MrModuleKey | null;
|
||||
const role = host.getAttribute('data-mr-role') as 'search' | 'textarea' | null;
|
||||
if (!moduleKey || !role) return;
|
||||
const idx = MODULE_ORDER.indexOf(moduleKey);
|
||||
if (idx < 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const n = MODULE_ORDER.length;
|
||||
if (e.shiftKey) {
|
||||
if (role === 'textarea') {
|
||||
focusModuleSearch(moduleKey);
|
||||
return;
|
||||
}
|
||||
const prev = MODULE_ORDER[(idx - 1 + n) % n];
|
||||
focusModuleTextarea(prev);
|
||||
return;
|
||||
}
|
||||
if (role === 'search') {
|
||||
focusModuleTextarea(moduleKey);
|
||||
return;
|
||||
}
|
||||
const next = MODULE_ORDER[(idx + 1) % n];
|
||||
focusModuleSearch(next);
|
||||
}
|
||||
|
||||
/** 读取常用展示位置配置 */
|
||||
async function loadCommonDisplayMode() {
|
||||
try {
|
||||
const res = await getSystemConfigByKeys(['mr_common_display_mode']);
|
||||
const raw = String(
|
||||
res?.mr_common_display_mode ?? res?.data?.mr_common_display_mode ?? 'tags',
|
||||
);
|
||||
commonDisplayMode.value =
|
||||
raw === 'bubble' || raw === 'both' ? raw : 'tags';
|
||||
} catch {
|
||||
commonDisplayMode.value = 'tags';
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCommonDisplayMode();
|
||||
});
|
||||
|
||||
/** 优先 props,其次病历/挂号回填的 form.user_patient_id */
|
||||
const effectivePatientId = computed(
|
||||
() => Number(props.userPatientId || form.user_patient_id || 0),
|
||||
);
|
||||
|
||||
const diagnosisModel = computed({
|
||||
get: () => props.diagnosis,
|
||||
set: (v: string) => emit('update:diagnosis', v),
|
||||
});
|
||||
const medicalAdviceModel = computed({
|
||||
get: () => props.medicalAdvice,
|
||||
set: (v: string) => emit('update:medicalAdvice', v),
|
||||
});
|
||||
|
||||
const [ExpandModal, expandModalApi] = useVbenModal({
|
||||
connectedComponent: TextareaExpandModal,
|
||||
});
|
||||
|
||||
/** 引用历史:用列表抽屉选择,避免 latest 排除当前挂号导致「列表有、引用无」 */
|
||||
const [CiteHistoryDrawer, citeHistoryDrawerApi] = useVbenDrawer({
|
||||
connectedComponent: PatientMedicalRecordDrawer,
|
||||
});
|
||||
|
||||
/** 组装当前可落盘的病历草稿(含诊断/医嘱) */
|
||||
function buildDraftPayload() {
|
||||
return {
|
||||
...form,
|
||||
register_id: props.registerId,
|
||||
store_id: props.storeId || form.store_id,
|
||||
user_patient_id: effectivePatientId.value,
|
||||
diagnosis: diagnosisModel.value,
|
||||
doctor_order: medicalAdviceModel.value,
|
||||
};
|
||||
}
|
||||
|
||||
/** 写入本地草稿(带前缀隔离) */
|
||||
function persistLocalDraft() {
|
||||
if (!props.registerId || hydrating.value) return;
|
||||
const keyId = `${props.storagePrefix || ''}${props.registerId}`;
|
||||
saveMedicalRecordDraft(keyId, buildDraftPayload());
|
||||
}
|
||||
|
||||
const debouncedPersistLocal = useDebounceFn(persistLocalDraft, 300);
|
||||
|
||||
/**
|
||||
* 拉取病历:接口 + 本地草稿合并(草稿覆盖内容字段,避免刷新丢失未保存编辑)
|
||||
*/
|
||||
async function loadRecord() {
|
||||
if (!props.registerId) return;
|
||||
loading.value = true;
|
||||
hydrating.value = true;
|
||||
try {
|
||||
const data = await getMedicalRecord({
|
||||
register_id: props.registerId,
|
||||
store_id: props.storeId || undefined,
|
||||
});
|
||||
Object.assign(form, emptyMedicalRecord(props.registerId, props.storeId), data || {});
|
||||
const draftKey = `${props.storagePrefix || ''}${props.registerId}`;
|
||||
const draft = loadMedicalRecordDraft(draftKey);
|
||||
if (draft) {
|
||||
const keepMeta = {
|
||||
id: form.id || draft.id || 0,
|
||||
register_id: props.registerId,
|
||||
store_id: props.storeId || form.store_id || draft.store_id || 0,
|
||||
user_patient_id:
|
||||
props.userPatientId ||
|
||||
form.user_patient_id ||
|
||||
draft.user_patient_id ||
|
||||
0,
|
||||
};
|
||||
Object.assign(form, draft, keepMeta);
|
||||
if (draft.diagnosis) emit('update:diagnosis', draft.diagnosis);
|
||||
if (draft.doctor_order) emit('update:medicalAdvice', draft.doctor_order);
|
||||
} else {
|
||||
if (!props.diagnosis && form.diagnosis) {
|
||||
emit('update:diagnosis', form.diagnosis);
|
||||
}
|
||||
if (!props.medicalAdvice && form.doctor_order) {
|
||||
emit('update:medicalAdvice', form.doctor_order);
|
||||
}
|
||||
}
|
||||
// 处方侧已有值优先(接诊草稿可能先恢复了诊断/医嘱)
|
||||
if (props.diagnosis) form.diagnosis = props.diagnosis;
|
||||
if (props.medicalAdvice) form.doctor_order = props.medicalAdvice;
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
const draft = loadMedicalRecordDraft(
|
||||
`${props.storagePrefix || ''}${props.registerId}`,
|
||||
);
|
||||
if (draft) {
|
||||
Object.assign(form, emptyMedicalRecord(props.registerId, props.storeId), draft);
|
||||
if (draft.diagnosis) emit('update:diagnosis', draft.diagnosis);
|
||||
if (draft.doctor_order) emit('update:medicalAdvice', draft.doctor_order);
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
hydrating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.registerId,
|
||||
(id) => {
|
||||
if (id) loadRecord();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.diagnosis,
|
||||
(v) => {
|
||||
form.diagnosis = v || '';
|
||||
debouncedPersistLocal();
|
||||
},
|
||||
);
|
||||
watch(
|
||||
() => props.medicalAdvice,
|
||||
(v) => {
|
||||
form.doctor_order = v || '';
|
||||
debouncedPersistLocal();
|
||||
},
|
||||
);
|
||||
|
||||
/** 表单任意字段变更都落本地,防止刷新丢失 */
|
||||
watch(
|
||||
form,
|
||||
() => {
|
||||
debouncedPersistLocal();
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
function appendText(base: string, add: string) {
|
||||
if (!add) return base || '';
|
||||
if (!base) return add;
|
||||
return `${base}${base.endsWith('\n') ? '' : '\n'}${add}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 诊断/医嘱与处方一致:用中文逗号拼接,避免重复
|
||||
*/
|
||||
function appendCommaText(base: string, add: string) {
|
||||
if (!add) return base || '';
|
||||
const parts = (base || '').split(',').map((s) => s.trim()).filter(Boolean);
|
||||
if (parts.includes(add)) return parts.join(',');
|
||||
parts.push(add);
|
||||
return parts.join(',');
|
||||
}
|
||||
|
||||
/**
|
||||
* 选用词条:舌象/脉象覆盖;诊断/医嘱/中医三典逗号拼接;其余换行追加
|
||||
*/
|
||||
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;
|
||||
return;
|
||||
}
|
||||
if (fieldCode === 'diagnosis') {
|
||||
diagnosisModel.value = appendCommaText(diagnosisModel.value, text);
|
||||
return;
|
||||
}
|
||||
if (fieldCode === 'doctor_order') {
|
||||
medicalAdviceModel.value = 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,
|
||||
);
|
||||
return;
|
||||
}
|
||||
(form as any)[fieldCode] = appendText((form as any)[fieldCode] || '', text);
|
||||
}
|
||||
|
||||
/**
|
||||
* 双击文本域:弹出放大编辑框(可带词条搜索与常用快捷)
|
||||
*/
|
||||
function openExpandEditor(
|
||||
title: string,
|
||||
getValue: () => string,
|
||||
setValue: (v: string) => void,
|
||||
readonly = false,
|
||||
extra?: {
|
||||
source?:
|
||||
| 'entry'
|
||||
| 'diagnosis'
|
||||
| 'doctor_order'
|
||||
| 'tcm_disease'
|
||||
| 'tcm_syndrome'
|
||||
| 'tcm_method';
|
||||
fieldCode?: string;
|
||||
showChips?: boolean;
|
||||
showCommonInBubble?: boolean;
|
||||
joinMode?: 'comma' | 'newline' | 'replace';
|
||||
},
|
||||
) {
|
||||
if (readonly) return;
|
||||
expandModalApi.setData({
|
||||
title,
|
||||
value: getValue() || '',
|
||||
onConfirm: (v: string) => setValue(v),
|
||||
storeId: props.storeId,
|
||||
readonly,
|
||||
...extra,
|
||||
});
|
||||
expandModalApi.open();
|
||||
}
|
||||
|
||||
function openDiagnosisExpand() {
|
||||
openExpandEditor(
|
||||
'临床诊断',
|
||||
() => diagnosisModel.value,
|
||||
(v) => {
|
||||
diagnosisModel.value = v;
|
||||
},
|
||||
props.diagnosisReadonly,
|
||||
{
|
||||
source: 'diagnosis',
|
||||
showChips: showCommonTags.value,
|
||||
showCommonInBubble: showCommonInBubble.value,
|
||||
joinMode: 'comma',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function openAdviceExpand() {
|
||||
openExpandEditor(
|
||||
'医嘱',
|
||||
() => medicalAdviceModel.value,
|
||||
(v) => {
|
||||
medicalAdviceModel.value = v;
|
||||
},
|
||||
false,
|
||||
{
|
||||
source: 'doctor_order',
|
||||
showChips: showCommonTags.value,
|
||||
showCommonInBubble: showCommonInBubble.value,
|
||||
joinMode: 'comma',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function openFieldExpand(fieldCode: string, label: string) {
|
||||
const tcmSourceMap: Record<
|
||||
string,
|
||||
'tcm_disease' | 'tcm_syndrome' | 'tcm_method'
|
||||
> = {
|
||||
tcm_disease: 'tcm_disease',
|
||||
tcm_syndrome: 'tcm_syndrome',
|
||||
tcm_method: 'tcm_method',
|
||||
};
|
||||
const tcmSource = tcmSourceMap[fieldCode];
|
||||
const joinMode =
|
||||
fieldCode === 'tongue' || fieldCode === 'pulse'
|
||||
? 'replace'
|
||||
: tcmSource
|
||||
? 'comma'
|
||||
: 'newline';
|
||||
openExpandEditor(
|
||||
label,
|
||||
() => String((form as any)[fieldCode] || ''),
|
||||
(v) => {
|
||||
(form as any)[fieldCode] = v;
|
||||
},
|
||||
false,
|
||||
{
|
||||
source: tcmSource || 'entry',
|
||||
fieldCode: tcmSource ? undefined : fieldCode,
|
||||
showChips: false,
|
||||
joinMode,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 保存病历(同步诊断/医嘱) */
|
||||
async function handleSave() {
|
||||
if (!props.registerId) {
|
||||
message.warning('无挂号信息');
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
await saveMedicalRecord({
|
||||
...form,
|
||||
register_id: props.registerId,
|
||||
store_id: props.storeId || form.store_id,
|
||||
user_patient_id: effectivePatientId.value,
|
||||
diagnosis: diagnosisModel.value,
|
||||
doctor_order: medicalAdviceModel.value,
|
||||
medicalAdvice: medicalAdviceModel.value,
|
||||
});
|
||||
persistLocalDraft();
|
||||
message.success('病历已保存');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClear() {
|
||||
if (!props.registerId) return;
|
||||
await clearMedicalRecord({
|
||||
register_id: props.registerId,
|
||||
store_id: props.storeId || undefined,
|
||||
});
|
||||
const keepPatient = effectivePatientId.value;
|
||||
Object.assign(form, emptyMedicalRecord(props.registerId, props.storeId));
|
||||
form.user_patient_id = keepPatient;
|
||||
emit('update:diagnosis', '');
|
||||
emit('update:medicalAdvice', '');
|
||||
clearMedicalRecordDraft(`${props.storagePrefix || ''}${props.registerId}`);
|
||||
message.success('已清空病历');
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开引用历史病历抽屉(从列表选择,可引用填充)
|
||||
*/
|
||||
function handleCiteHistory() {
|
||||
const patientId = effectivePatientId.value;
|
||||
if (!patientId) {
|
||||
message.warning('无就诊人信息');
|
||||
return;
|
||||
}
|
||||
citeHistoryDrawerApi.setData({
|
||||
mode: 'cite',
|
||||
userPatientId: patientId,
|
||||
storeId: props.storeId || undefined,
|
||||
excludeRegisterId: props.registerId || 0,
|
||||
onCite: applyCitedRecord,
|
||||
});
|
||||
citeHistoryDrawerApi.open();
|
||||
}
|
||||
|
||||
/**
|
||||
* 将选中的历史病历回填到当前编辑区(保留当前挂号关联 id)
|
||||
*/
|
||||
function applyCitedRecord(data: Record<string, any>) {
|
||||
const patientId = effectivePatientId.value;
|
||||
const keepIds = {
|
||||
id: form.id,
|
||||
register_id: props.registerId,
|
||||
store_id: props.storeId || form.store_id,
|
||||
user_patient_id: patientId,
|
||||
};
|
||||
Object.assign(form, data, keepIds);
|
||||
if (data.diagnosis) emit('update:diagnosis', data.diagnosis);
|
||||
if (data.doctor_order) emit('update:medicalAdvice', data.doctor_order);
|
||||
persistLocalDraft();
|
||||
message.success('已引用历史病历');
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
save: handleSave,
|
||||
loadRecord,
|
||||
getPayload: () => ({
|
||||
...form,
|
||||
user_patient_id: effectivePatientId.value,
|
||||
diagnosis: diagnosisModel.value,
|
||||
doctor_order: medicalAdviceModel.value,
|
||||
}),
|
||||
});
|
||||
|
||||
/** source:走中医三典气泡;默认 entry 走病历词条 */
|
||||
function fieldBlock(
|
||||
fieldCode: string,
|
||||
label: string,
|
||||
textKey?: string,
|
||||
source?: 'entry' | 'tcm_disease' | 'tcm_syndrome' | 'tcm_method',
|
||||
) {
|
||||
return {
|
||||
fieldCode,
|
||||
label,
|
||||
textKey: textKey || fieldCode,
|
||||
source: source || 'entry',
|
||||
};
|
||||
}
|
||||
|
||||
/** 中医病案内嵌:证候→疾病→治法(一行两列排布) */
|
||||
const tcmCaseSubFields = [
|
||||
fieldBlock('tcm_syndrome', '中医证候', undefined, 'tcm_syndrome'),
|
||||
fieldBlock('tcm_disease', '中医疾病', undefined, 'tcm_disease'),
|
||||
fieldBlock('tcm_method', '中医治法', undefined, 'tcm_method'),
|
||||
];
|
||||
|
||||
/**
|
||||
* 两列切块:证候|疾病、治法|空
|
||||
* 与舌象/脉象同一行两列布局一致
|
||||
*/
|
||||
const tcmCaseSubRows = [
|
||||
[tcmCaseSubFields[0], tcmCaseSubFields[1]],
|
||||
[tcmCaseSubFields[2], null],
|
||||
] as const;
|
||||
|
||||
const leftFields = [
|
||||
fieldBlock('chief_complaint', '主诉'),
|
||||
fieldBlock('present_illness', '现病史'),
|
||||
fieldBlock('tcm_case', '中医病案'),
|
||||
fieldBlock('treatment_advice', '治疗意见'),
|
||||
fieldBlock('doctor_order', '医嘱'),
|
||||
];
|
||||
const rightFields = [
|
||||
fieldBlock('family_history', '家族史'),
|
||||
fieldBlock('epidemic_history', '流行病学史'),
|
||||
fieldBlock('past_history', '既往史'),
|
||||
fieldBlock('allergy_history', '过敏史'),
|
||||
fieldBlock('physical_exam', '体征检查'),
|
||||
fieldBlock('auxiliary_exam', '辅助检查'),
|
||||
];
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
ref="panelRootRef"
|
||||
class="medical-record-panel"
|
||||
:class="{ 'opacity-60': loading }"
|
||||
@keydown.capture="onPanelKeydownCapture"
|
||||
>
|
||||
<div class="mb-3 flex flex-wrap gap-2">
|
||||
<Button danger @click="handleClear">清空病历</Button>
|
||||
<Button @click="handleCiteHistory">引用历史病历</Button>
|
||||
<Button type="primary" :loading="loading" @click="handleSave">保存病历</Button>
|
||||
</div>
|
||||
<div class="mb-2 text-xs text-muted-foreground">
|
||||
提示:双击文本域可放大编辑;Ctrl+Enter 下一项,Ctrl+Shift+Enter 上一项
|
||||
</div>
|
||||
<Row :gutter="16">
|
||||
<Col :span="12">
|
||||
<div class="mb-4">
|
||||
<div class="mb-1 font-medium">诊断(与处方同步)</div>
|
||||
<div class="mb-1" data-mr-module="diagnosis" data-mr-role="search">
|
||||
<EntryKeywordBubble
|
||||
:ref="(el) => setSearchRef('diagnosis', el)"
|
||||
source="diagnosis"
|
||||
placeholder="诊断关键字"
|
||||
:disabled="diagnosisReadonly"
|
||||
:show-common-in-bubble="showCommonInBubble"
|
||||
@select="(item) => onEntryPick('diagnosis', item)"
|
||||
@common-changed="commonChipsTick += 1"
|
||||
/>
|
||||
</div>
|
||||
<div data-mr-module="diagnosis" data-mr-role="textarea">
|
||||
<Textarea
|
||||
:ref="(el) => setTextareaRef('diagnosis', el)"
|
||||
v-model:value="diagnosisModel"
|
||||
:rows="2"
|
||||
:disabled="diagnosisReadonly"
|
||||
:placeholder="diagnosisReadonly ? '请通过上方关键字选择诊断' : '临床诊断(双击放大)'"
|
||||
@dblclick="openDiagnosisExpand"
|
||||
/>
|
||||
</div>
|
||||
<CommonDxOrderChips
|
||||
v-if="showCommonTags"
|
||||
:key="`dx_chips_${commonChipsTick}`"
|
||||
source="diagnosis"
|
||||
:disabled="diagnosisReadonly"
|
||||
@select="(text) => onEntryPick('diagnosis', { id: text, title: text, content: text, remark: '' })"
|
||||
/>
|
||||
</div>
|
||||
<div v-for="f in leftFields" :key="f.fieldCode" class="mb-4">
|
||||
<div class="mb-1 font-medium">{{ f.label }}</div>
|
||||
<template v-if="f.fieldCode === 'tcm_case'">
|
||||
<!-- 1) 舌象 | 脉象 -->
|
||||
<div class="mb-2 flex gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="mb-1 text-sm text-muted-foreground">舌象</div>
|
||||
<div class="mb-1" data-mr-module="tongue" data-mr-role="search">
|
||||
<EntryKeywordBubble
|
||||
:ref="(el) => setSearchRef('tongue', el)"
|
||||
field-code="tongue"
|
||||
:store-id="storeId"
|
||||
placeholder="舌象词条"
|
||||
@select="(item) => onEntryPick('tongue', item)"
|
||||
/>
|
||||
</div>
|
||||
<div data-mr-module="tongue" data-mr-role="textarea">
|
||||
<Input
|
||||
:ref="(el) => setTextareaRef('tongue', el)"
|
||||
v-model:value="form.tongue"
|
||||
placeholder="舌象"
|
||||
@dblclick="openFieldExpand('tongue', '舌象')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="mb-1 text-sm text-muted-foreground">脉象</div>
|
||||
<div class="mb-1" data-mr-module="pulse" data-mr-role="search">
|
||||
<EntryKeywordBubble
|
||||
:ref="(el) => setSearchRef('pulse', el)"
|
||||
field-code="pulse"
|
||||
:store-id="storeId"
|
||||
placeholder="脉象词条"
|
||||
@select="(item) => onEntryPick('pulse', item)"
|
||||
/>
|
||||
</div>
|
||||
<div data-mr-module="pulse" data-mr-role="textarea">
|
||||
<Input
|
||||
:ref="(el) => setTextareaRef('pulse', el)"
|
||||
v-model:value="form.pulse"
|
||||
placeholder="脉象"
|
||||
@dblclick="openFieldExpand('pulse', '脉象')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 2) 辨证 / 病案 -->
|
||||
<div class="mb-1 text-sm text-muted-foreground">辨证 / 病案</div>
|
||||
<div class="mb-1" data-mr-module="tcm_case" data-mr-role="search">
|
||||
<EntryKeywordBubble
|
||||
:ref="(el) => setSearchRef('tcm_case', el)"
|
||||
field-code="tcm_case"
|
||||
:store-id="storeId"
|
||||
@select="(item) => onEntryPick('tcm_case', item)"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-3" data-mr-module="tcm_case" data-mr-role="textarea">
|
||||
<Textarea
|
||||
:ref="(el) => setTextareaRef('tcm_case', el)"
|
||||
v-model:value="form.tcm_case"
|
||||
:rows="2"
|
||||
placeholder="[选择] 辨证/病案(双击放大)"
|
||||
@dblclick="openFieldExpand('tcm_case', '辨证/病案')"
|
||||
/>
|
||||
</div>
|
||||
<!-- 3) 证候|疾病、治法|空:与舌脉同为一行两列 -->
|
||||
<div
|
||||
v-for="(row, rowIdx) in tcmCaseSubRows"
|
||||
:key="`tcm_row_${rowIdx}`"
|
||||
class="mb-2 flex gap-2"
|
||||
>
|
||||
<div
|
||||
v-for="(sub, colIdx) in row"
|
||||
:key="sub ? sub.fieldCode : `empty_${rowIdx}_${colIdx}`"
|
||||
class="min-w-0 flex-1"
|
||||
>
|
||||
<template v-if="sub">
|
||||
<div class="mb-1 text-sm text-muted-foreground">{{ sub.label }}</div>
|
||||
<div
|
||||
class="mb-1"
|
||||
:data-mr-module="sub.fieldCode"
|
||||
data-mr-role="search"
|
||||
>
|
||||
<EntryKeywordBubble
|
||||
:ref="(el) => setSearchRef(sub.fieldCode, el)"
|
||||
:source="sub.source"
|
||||
:placeholder="`${sub.label}关键字/首拼/全拼`"
|
||||
@select="(item) => onEntryPick(sub.fieldCode, item)"
|
||||
/>
|
||||
</div>
|
||||
<div :data-mr-module="sub.fieldCode" data-mr-role="textarea">
|
||||
<Input
|
||||
:ref="(el) => setTextareaRef(sub.fieldCode, el)"
|
||||
v-model:value="(form as any)[sub.textKey]"
|
||||
:placeholder="sub.label"
|
||||
@dblclick="openFieldExpand(sub.textKey, sub.label)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-else-if="f.fieldCode === 'doctor_order'"
|
||||
class="mb-1"
|
||||
data-mr-module="doctor_order"
|
||||
data-mr-role="search"
|
||||
>
|
||||
<EntryKeywordBubble
|
||||
:ref="(el) => setSearchRef('doctor_order', el)"
|
||||
source="doctor_order"
|
||||
placeholder="医嘱关键字"
|
||||
:show-common-in-bubble="showCommonInBubble"
|
||||
@select="(item) => onEntryPick('doctor_order', item)"
|
||||
@common-changed="commonChipsTick += 1"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="mb-1"
|
||||
:data-mr-module="f.fieldCode"
|
||||
data-mr-role="search"
|
||||
>
|
||||
<EntryKeywordBubble
|
||||
:ref="(el) => setSearchRef(f.fieldCode, el)"
|
||||
:field-code="f.fieldCode"
|
||||
:store-id="storeId"
|
||||
@select="(item) => onEntryPick(f.fieldCode, item)"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="f.fieldCode === 'doctor_order'">
|
||||
<div data-mr-module="doctor_order" data-mr-role="textarea">
|
||||
<Textarea
|
||||
:ref="(el) => setTextareaRef('doctor_order', el)"
|
||||
v-model:value="medicalAdviceModel"
|
||||
:rows="2"
|
||||
placeholder="医嘱(与处方同步,双击放大)"
|
||||
@dblclick="openAdviceExpand"
|
||||
/>
|
||||
</div>
|
||||
<CommonDxOrderChips
|
||||
v-if="showCommonTags"
|
||||
:key="`order_chips_${commonChipsTick}`"
|
||||
source="doctor_order"
|
||||
@select="(text) => onEntryPick('doctor_order', { id: text, title: text, content: text, remark: '' })"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
v-else-if="f.fieldCode !== 'tcm_case'"
|
||||
:data-mr-module="f.fieldCode"
|
||||
data-mr-role="textarea"
|
||||
>
|
||||
<Textarea
|
||||
:ref="(el) => setTextareaRef(f.fieldCode, el)"
|
||||
v-model:value="(form as any)[f.textKey]"
|
||||
:rows="2"
|
||||
:placeholder="`[选择] ${f.label}(双击放大)`"
|
||||
@dblclick="openFieldExpand(f.textKey, f.label)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col :span="12">
|
||||
<div v-for="f in rightFields" :key="f.fieldCode" class="mb-4">
|
||||
<div class="mb-1 font-medium">{{ f.label }}</div>
|
||||
<div
|
||||
class="mb-1"
|
||||
:data-mr-module="f.fieldCode"
|
||||
data-mr-role="search"
|
||||
>
|
||||
<EntryKeywordBubble
|
||||
:ref="(el) => setSearchRef(f.fieldCode, el)"
|
||||
:field-code="f.fieldCode"
|
||||
:store-id="storeId"
|
||||
@select="(item) => onEntryPick(f.fieldCode, item)"
|
||||
/>
|
||||
</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"
|
||||
>
|
||||
<span class="vital-item">
|
||||
<span class="vital-label">体温</span>
|
||||
<InputNumber
|
||||
v-model:value="form.temperature"
|
||||
:min="0"
|
||||
:step="0.1"
|
||||
:bordered="false"
|
||||
class="vital-input"
|
||||
placeholder="—"
|
||||
/>
|
||||
<span class="vital-unit">℃</span>
|
||||
</span>
|
||||
<span class="vital-item">
|
||||
<span class="vital-label">身高</span>
|
||||
<InputNumber
|
||||
v-model:value="form.height"
|
||||
:min="0"
|
||||
:bordered="false"
|
||||
class="vital-input"
|
||||
placeholder="—"
|
||||
/>
|
||||
<span class="vital-unit">cm</span>
|
||||
</span>
|
||||
<span class="vital-item">
|
||||
<span class="vital-label">体重</span>
|
||||
<InputNumber
|
||||
v-model:value="form.weight"
|
||||
:min="0"
|
||||
:step="0.1"
|
||||
:bordered="false"
|
||||
class="vital-input"
|
||||
placeholder="—"
|
||||
/>
|
||||
<span class="vital-unit">KG</span>
|
||||
</span>
|
||||
<span class="vital-item">
|
||||
<span class="vital-label">呼吸</span>
|
||||
<InputNumber
|
||||
v-model:value="form.respiratory_rate"
|
||||
:min="0"
|
||||
:bordered="false"
|
||||
class="vital-input vital-input--sm"
|
||||
placeholder="—"
|
||||
/>
|
||||
<span class="vital-unit">次/分</span>
|
||||
</span>
|
||||
<span class="vital-item">
|
||||
<span class="vital-label">血压</span>
|
||||
<InputNumber
|
||||
v-model:value="form.bp_systolic"
|
||||
:min="0"
|
||||
:bordered="false"
|
||||
class="vital-input vital-input--sm"
|
||||
placeholder="—"
|
||||
/>
|
||||
<span class="vital-sep">/</span>
|
||||
<InputNumber
|
||||
v-model:value="form.bp_diastolic"
|
||||
:min="0"
|
||||
:bordered="false"
|
||||
class="vital-input vital-input--sm"
|
||||
placeholder="—"
|
||||
/>
|
||||
<span class="vital-unit">mmHg</span>
|
||||
</span>
|
||||
</div>
|
||||
<div :data-mr-module="f.fieldCode" data-mr-role="textarea">
|
||||
<Textarea
|
||||
:ref="(el) => setTextareaRef(f.fieldCode, el)"
|
||||
v-model:value="(form as any)[f.textKey]"
|
||||
:rows="2"
|
||||
:placeholder="`[选择] ${f.label}(双击放大)`"
|
||||
@dblclick="openFieldExpand(f.textKey, f.label)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
<ExpandModal />
|
||||
<CiteHistoryDrawer />
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.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 {
|
||||
color: hsl(var(--muted-foreground));
|
||||
white-space: nowrap;
|
||||
}
|
||||
.vitals-underline :deep(.vital-input.ant-input-number),
|
||||
.vitals-underline :deep(.vital-input .ant-input-number) {
|
||||
width: 88px;
|
||||
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-underline :deep(.ant-input-number-focused),
|
||||
.vitals-underline :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;
|
||||
}
|
||||
</style>
|
||||
82
apps/web-antd/src/views/doctor/medical-record/api/index.ts
Normal file
82
apps/web-antd/src/views/doctor/medical-record/api/index.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
/** 病历与词条 API(admin medical-record/) */
|
||||
const prefix = 'medical-record/';
|
||||
|
||||
export async function getMedicalRecordEntryList(data: any) {
|
||||
return requestClient.get<any>(`${prefix}list`, { params: data });
|
||||
}
|
||||
|
||||
export async function createMedicalRecordEntry(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}create`, data);
|
||||
}
|
||||
|
||||
export async function updateMedicalRecordEntry(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}update`, data);
|
||||
}
|
||||
|
||||
export async function deleteMedicalRecordEntry(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}delete`, data);
|
||||
}
|
||||
|
||||
export async function getMedicalRecordFieldOptions() {
|
||||
return requestClient.get<any>(`${prefix}field-options`);
|
||||
}
|
||||
|
||||
export async function searchMedicalRecordEntry(data: {
|
||||
field_code: string;
|
||||
keyword?: string;
|
||||
store_id?: number;
|
||||
}) {
|
||||
return requestClient.get<any>(`${prefix}search-entry`, { params: data });
|
||||
}
|
||||
|
||||
export async function getMedicalRecord(data: {
|
||||
register_id: number;
|
||||
store_id?: number;
|
||||
}) {
|
||||
return requestClient.get<any>(`${prefix}get-record`, { params: data });
|
||||
}
|
||||
|
||||
export async function saveMedicalRecord(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}save-record`, data);
|
||||
}
|
||||
|
||||
export async function clearMedicalRecord(data: {
|
||||
register_id: number;
|
||||
store_id?: number;
|
||||
}) {
|
||||
return requestClient.post<any>(`${prefix}clear-record`, data);
|
||||
}
|
||||
|
||||
export async function getLatestMedicalRecord(data: {
|
||||
user_patient_id: number;
|
||||
register_id?: number;
|
||||
store_id?: number;
|
||||
}) {
|
||||
return requestClient.get<any>(`${prefix}latest-by-patient`, { params: data });
|
||||
}
|
||||
|
||||
/** 按就诊人拉取病历列表(查看历史) */
|
||||
export async function getMedicalRecordListByPatient(data: {
|
||||
user_patient_id: number;
|
||||
store_id?: number;
|
||||
limit?: number;
|
||||
}) {
|
||||
return requestClient.get<any>(`${prefix}list-by-patient`, { params: data });
|
||||
}
|
||||
|
||||
/** Excel 解析后批量导入词条 */
|
||||
export async function importMedicalRecordEntry(data: {
|
||||
scope: 'public' | 'store';
|
||||
rows: Array<Record<string, any>>;
|
||||
}) {
|
||||
return requestClient.post<any>(`${prefix}import-entry`, data);
|
||||
}
|
||||
|
||||
/** 手动同步空拼音首拼 */
|
||||
export async function syncMedicalRecordEntryPinyin(data?: {
|
||||
scope?: 'public' | 'store' | '';
|
||||
}) {
|
||||
return requestClient.post<any>(`${prefix}sync-pinyin`, data || {});
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 常用诊断 / 常用医嘱快捷标签
|
||||
* 展示在输入框下方,点击即追加到对应字段(与 EntryKeywordBubble 同源数据)
|
||||
*/
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { Tag } from 'ant-design-vue';
|
||||
|
||||
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';
|
||||
/** 最多展示条数 */
|
||||
limit?: number;
|
||||
disabled?: boolean;
|
||||
}>(),
|
||||
{
|
||||
limit: 12,
|
||||
disabled: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [text: string];
|
||||
}>();
|
||||
|
||||
type ChipItem = { id: string | number; text: string };
|
||||
|
||||
const chips = ref<ChipItem[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
/**
|
||||
* 拉取常用项(诊断取医生常用病;医嘱取我的医嘱,无则带公共前几条)
|
||||
*/
|
||||
async function loadChips() {
|
||||
loading.value = true;
|
||||
try {
|
||||
if (props.source === 'diagnosis') {
|
||||
const list = await getDoctorMyDiseaseListApi().catch(() => []);
|
||||
chips.value = (list || [])
|
||||
.map((item: any) => {
|
||||
const d = item?.disease || item;
|
||||
const text = String(d?.name || '').trim();
|
||||
return text ? { id: d?.id ?? item?.id, 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) => {
|
||||
const text = String(item?.content || '').trim();
|
||||
return text ? { id: item?.id ?? text, text } : null;
|
||||
})
|
||||
.filter(Boolean) as ChipItem[];
|
||||
if (my.length > 0) {
|
||||
chips.value = my.slice(0, props.limit);
|
||||
return;
|
||||
}
|
||||
chips.value = (res?.common || [])
|
||||
.map((item: any) => {
|
||||
const text = String(item?.name || item?.content || '').trim();
|
||||
return text ? { id: item?.id ?? text, text } : null;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.slice(0, props.limit) as ChipItem[];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onPick(item: ChipItem) {
|
||||
if (props.disabled || !item?.text) return;
|
||||
emit('select', item.text);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.source,
|
||||
() => {
|
||||
loadChips();
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
loadChips();
|
||||
});
|
||||
|
||||
defineExpose({ reload: loadChips });
|
||||
</script>
|
||||
<template>
|
||||
<div v-if="chips.length > 0" class="dx-order-chips">
|
||||
<span class="dx-order-chips__label">
|
||||
{{ source === 'diagnosis' ? '常用诊断' : '常用医嘱' }}
|
||||
</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"
|
||||
@click="onPick(item)"
|
||||
>
|
||||
{{ item.text }}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.dx-order-chips {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.dx-order-chips__label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
.dx-order-chips__list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.dx-order-chips__tag {
|
||||
cursor: pointer;
|
||||
max-width: 100%;
|
||||
white-space: normal;
|
||||
height: auto;
|
||||
line-height: 1.4;
|
||||
padding: 2px 8px;
|
||||
margin: 0;
|
||||
}
|
||||
.dx-order-chips__tag.is-disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,304 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 病历词条列表(公共/门店共用)
|
||||
* - scope=public → store_id=0;scope=store → 当前登录门店
|
||||
* - fixedFieldCode:按字段拆分菜单时锁定字段,隐藏字段筛选
|
||||
* - 支持 Excel 导入 + 手动同步空拼音首拼
|
||||
*/
|
||||
import type { VxeGridListeners } from '#/adapter/vxe-table';
|
||||
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button, message, Modal as AntModal, Tag, Upload } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
import { downloadByData } from '#/util/tool';
|
||||
|
||||
import {
|
||||
deleteMedicalRecordEntry,
|
||||
getMedicalRecordEntryList,
|
||||
importMedicalRecordEntry,
|
||||
syncMedicalRecordEntryPinyin,
|
||||
} from '../api';
|
||||
import { MEDICAL_RECORD_FIELD_OPTIONS, STATUS_OPTIONS } from '../config/constants';
|
||||
import { exportMedicalRecordEntryTemplate } from '../utils/exportMedicalRecordEntryExcel';
|
||||
import { parseMedicalRecordEntryExcelBuffer } from '../utils/parseMedicalRecordEntryExcel';
|
||||
import EntryModal from './EntryModal.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
scope: 'public' | 'store';
|
||||
pageTitle: string;
|
||||
pageName: string;
|
||||
/** 锁定字段编码(按类型拆菜单时传入) */
|
||||
fixedFieldCode?: string;
|
||||
}>();
|
||||
|
||||
defineOptions({ name: 'MedicalRecordEntryList' });
|
||||
|
||||
const hasTopTableDropDownActions = ref(false);
|
||||
const importing = ref(false);
|
||||
const syncing = ref(false);
|
||||
|
||||
const formOptions = computed(() => {
|
||||
const schema: any[] = [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'title',
|
||||
label: '词条名称',
|
||||
componentProps: { placeholder: '名称/首拼模糊搜索' },
|
||||
},
|
||||
];
|
||||
if (!props.fixedFieldCode) {
|
||||
schema.push({
|
||||
component: 'VbenSelect',
|
||||
fieldName: 'field_code',
|
||||
label: '所属字段',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
options: MEDICAL_RECORD_FIELD_OPTIONS,
|
||||
},
|
||||
});
|
||||
}
|
||||
schema.push({
|
||||
component: 'VbenSelect',
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
componentProps: { allowClear: true, options: STATUS_OPTIONS },
|
||||
});
|
||||
return { schema };
|
||||
});
|
||||
|
||||
const gridOptions = computed(() => ({
|
||||
checkboxConfig: { highlight: true, labelField: '' },
|
||||
columns: [
|
||||
{ type: 'checkbox', width: 60 },
|
||||
{ field: 'id', title: 'ID', width: 80 },
|
||||
...(props.fixedFieldCode
|
||||
? []
|
||||
: [{ field: 'field_code_txt', title: '字段', width: 120 }]),
|
||||
{ field: 'title', title: '词条名称', minWidth: 140 },
|
||||
{ field: 'pinyin_initials', title: '首拼(全拼)', minWidth: 160 },
|
||||
{ field: 'content', title: '正文', minWidth: 200 },
|
||||
{ field: 'remark', title: '临床意义', width: 140 },
|
||||
{ field: 'status', title: '状态', width: 90, slots: { default: 'status' } },
|
||||
{ field: 'sort', title: '排序', width: 80 },
|
||||
{ field: 'created_at', title: '创建时间', width: 170 },
|
||||
{ title: '操作', slots: { default: 'action' }, width: 160, fixed: 'right' },
|
||||
],
|
||||
keepSource: true,
|
||||
pagerConfig: {},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }: any, formValues: any) => {
|
||||
return await getMedicalRecordEntryList({
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
scope: props.scope,
|
||||
field_code: props.fixedFieldCode || undefined,
|
||||
...formValues,
|
||||
...(props.fixedFieldCode ? { field_code: props.fixedFieldCode } : {}),
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
height: 'auto',
|
||||
toolbarConfig: {
|
||||
search: true,
|
||||
refresh: true,
|
||||
slots: { buttons: 'toolbar-buttons' },
|
||||
},
|
||||
}));
|
||||
|
||||
const gridEvents: VxeGridListeners<any> = {
|
||||
checkboxChange() {
|
||||
hasTopTableDropDownActions.value =
|
||||
gridApi.grid.getCheckboxRecords().length > 0;
|
||||
},
|
||||
checkboxAll() {
|
||||
hasTopTableDropDownActions.value =
|
||||
gridApi.grid.getCheckboxRecords().length > 0;
|
||||
},
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: formOptions.value as any,
|
||||
gridOptions: gridOptions.value as any,
|
||||
gridEvents,
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.fixedFieldCode,
|
||||
() => {
|
||||
gridApi.reload?.();
|
||||
},
|
||||
);
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
connectedComponent: EntryModal,
|
||||
});
|
||||
|
||||
const showModal = (data: any = {}, isUpdate = false) => {
|
||||
formModalApi.setData({
|
||||
values: {
|
||||
...data,
|
||||
...(props.fixedFieldCode && !isUpdate
|
||||
? { field_code: props.fixedFieldCode }
|
||||
: {}),
|
||||
},
|
||||
update: isUpdate,
|
||||
gridApi,
|
||||
scope: props.scope,
|
||||
fixedFieldCode: props.fixedFieldCode || '',
|
||||
});
|
||||
formModalApi.open();
|
||||
};
|
||||
|
||||
const handleDelete = async (ids: number[]) => {
|
||||
await deleteMedicalRecordEntry({ ids });
|
||||
message.success('删除成功');
|
||||
gridApi.query();
|
||||
};
|
||||
|
||||
async function handleDownloadTemplate() {
|
||||
const buffer = await exportMedicalRecordEntryTemplate();
|
||||
downloadByData(
|
||||
buffer,
|
||||
'病历词条导入模板.xlsx',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
);
|
||||
}
|
||||
|
||||
async function handleImportFile(file: File) {
|
||||
importing.value = true;
|
||||
try {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const parsed = await parseMedicalRecordEntryExcelBuffer(buffer);
|
||||
let rows = parsed.items;
|
||||
if (props.fixedFieldCode) {
|
||||
rows = rows
|
||||
.map((r) => ({ ...r, field_code: props.fixedFieldCode as string }))
|
||||
.filter((r) => r.title);
|
||||
}
|
||||
if (!rows.length) {
|
||||
message.warning(
|
||||
parsed.invalidCount
|
||||
? `没有可导入的有效行(无效 ${parsed.invalidCount} 行)`
|
||||
: '文件无有效数据',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const res = await importMedicalRecordEntry({
|
||||
scope: props.scope,
|
||||
rows,
|
||||
});
|
||||
const success = Number(res?.success ?? 0);
|
||||
const fail = Number(res?.fail ?? 0);
|
||||
const errors = Array.isArray(res?.errors) ? res.errors : [];
|
||||
if (fail > 0) {
|
||||
message.warning(
|
||||
`导入完成:成功 ${success},失败 ${fail}${
|
||||
errors.length ? `;${errors.slice(0, 3).join(';')}` : ''
|
||||
}`,
|
||||
);
|
||||
} else {
|
||||
message.success(`成功导入 ${success} 条`);
|
||||
}
|
||||
gridApi.reload();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '导入失败');
|
||||
} finally {
|
||||
importing.value = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 手动同步当前 scope 下空拼音首拼 */
|
||||
function handleSyncPinyin() {
|
||||
AntModal.confirm({
|
||||
title: '同步拼音首拼',
|
||||
content: '将扫描拼音为空的词条并自动生成,已有值不会覆盖。确认执行?',
|
||||
onOk: async () => {
|
||||
syncing.value = true;
|
||||
try {
|
||||
const res = await syncMedicalRecordEntryPinyin({ scope: props.scope });
|
||||
message.success(
|
||||
`同步完成:更新 ${Number(res?.updated || 0)} 条,跳过 ${Number(res?.skipped || 0)} 条`,
|
||||
);
|
||||
gridApi.query();
|
||||
} finally {
|
||||
syncing.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<Page auto-content-height :title="pageTitle">
|
||||
<FormModal />
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '新增词条',
|
||||
type: 'primary',
|
||||
onClick: () => showModal({}, false),
|
||||
},
|
||||
{
|
||||
label: '下载模板',
|
||||
onClick: () => handleDownloadTemplate(),
|
||||
},
|
||||
{
|
||||
label: '批量删除',
|
||||
danger: true,
|
||||
ifShow: () => hasTopTableDropDownActions,
|
||||
popConfirm: {
|
||||
title: '确认删除所选词条?',
|
||||
confirm: () => {
|
||||
const ids = gridApi.grid
|
||||
.getCheckboxRecords()
|
||||
.map((r: any) => r.id);
|
||||
handleDelete(ids);
|
||||
},
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<Upload
|
||||
:show-upload-list="false"
|
||||
accept=".xlsx,.xls"
|
||||
:before-upload="handleImportFile"
|
||||
class="ml-2 inline-block"
|
||||
>
|
||||
<Button :loading="importing">导入 Excel</Button>
|
||||
</Upload>
|
||||
<Button class="ml-2" :loading="syncing" @click="handleSyncPinyin">
|
||||
同步拼音首拼
|
||||
</Button>
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<Tag :color="row.status === 1 ? 'success' : 'default'">
|
||||
{{ row.status_txt || (row.status === 1 ? '启用' : '禁用') }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{ label: '编辑', onClick: () => showModal(row, true) },
|
||||
{
|
||||
label: '删除',
|
||||
danger: true,
|
||||
popConfirm: {
|
||||
title: '确认删除?',
|
||||
confirm: () => handleDelete([row.id]),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
@@ -0,0 +1,95 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 病历词条新增/编辑弹窗
|
||||
* scope 从 modalApi.getData() 读取:public | store
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
|
||||
import {
|
||||
createMedicalRecordEntry,
|
||||
updateMedicalRecordEntry,
|
||||
} from '../api';
|
||||
import { buildEntryFormProps } from '../config/entry-form';
|
||||
|
||||
const isUpdate = ref(false);
|
||||
const gridApi = ref();
|
||||
const entryScope = ref<'public' | 'store'>('public');
|
||||
const fixedFieldCode = ref('');
|
||||
const [Form, formApi] = useVbenForm(buildEntryFormProps());
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
draggable: true,
|
||||
onCancel() {
|
||||
modalApi.close();
|
||||
},
|
||||
onConfirm: async () => {
|
||||
const e = await formApi.validate();
|
||||
if (!e.valid) return;
|
||||
const values = await formApi.getValues();
|
||||
modalApi.setState({ confirmLoading: true });
|
||||
try {
|
||||
const payload = {
|
||||
...values,
|
||||
scope: entryScope.value,
|
||||
...(fixedFieldCode.value ? { field_code: fixedFieldCode.value } : {}),
|
||||
};
|
||||
if (isUpdate.value) {
|
||||
await updateMedicalRecordEntry(payload);
|
||||
} else {
|
||||
await createMedicalRecordEntry(payload);
|
||||
}
|
||||
message.success('保存成功');
|
||||
gridApi.value?.query();
|
||||
modalApi.close();
|
||||
} finally {
|
||||
modalApi.setState({ confirmLoading: false });
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
const data = isOpen ? modalApi.getData<Record<string, any>>() : null;
|
||||
gridApi.value = data?.gridApi || null;
|
||||
entryScope.value = (data?.scope as 'public' | 'store') || 'public';
|
||||
fixedFieldCode.value = String(data?.fixedFieldCode || '');
|
||||
if (!isOpen) {
|
||||
formApi.resetForm();
|
||||
return;
|
||||
}
|
||||
// 按字段拆页时隐藏「所属字段」,并去掉必填校验(提交时由 fixedFieldCode 注入)
|
||||
formApi.updateSchema([
|
||||
{
|
||||
fieldName: 'field_code',
|
||||
rules: fixedFieldCode.value ? '' : 'selectRequired',
|
||||
dependencies: {
|
||||
show: () => !fixedFieldCode.value,
|
||||
triggerFields: ['title'],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const { values, update } = data || {};
|
||||
isUpdate.value = !!update;
|
||||
if (values && update) {
|
||||
formApi.setValues({ ...values });
|
||||
} else {
|
||||
formApi.resetForm();
|
||||
formApi.setValues({
|
||||
sort: 0,
|
||||
status: 1,
|
||||
content: '',
|
||||
remark: '',
|
||||
field_code: fixedFieldCode.value || undefined,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Modal :title="`${isUpdate ? '编辑' : '新增'}词条`" class="w-[640px]">
|
||||
<Form />
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,292 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 患者历史病历抽屉
|
||||
* - view:只读查看
|
||||
* - cite:选择一条后点「引用此病历」回填到当前接诊病历
|
||||
* (修复:原 latest 接口会排除当前挂号,仅有本次病历时会误报「暂无」)
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer } from '@vben/common-ui';
|
||||
import { Empty, Spin, Tag, message } from 'ant-design-vue';
|
||||
|
||||
import { getMedicalRecordListByPatient } from '../api';
|
||||
import { MEDICAL_RECORD_VIEW_FIELDS } from '../config/constants';
|
||||
|
||||
defineOptions({ name: 'PatientMedicalRecordDrawer' });
|
||||
|
||||
type DrawerData = {
|
||||
userPatientId?: number;
|
||||
storeId?: number;
|
||||
patientName?: string;
|
||||
/** view=查看;cite=引用到当前病历 */
|
||||
mode?: 'view' | 'cite';
|
||||
/** 引用模式下排除当前挂号(仍展示,但默认选中其他挂号) */
|
||||
excludeRegisterId?: number;
|
||||
/** 引用确认回调 */
|
||||
onCite?: (record: Record<string, any>) => void;
|
||||
};
|
||||
|
||||
const loading = ref(false);
|
||||
const list = ref<Record<string, any>[]>([]);
|
||||
const activeId = ref(0);
|
||||
const meta = ref<DrawerData>({});
|
||||
|
||||
const isCiteMode = computed(() => meta.value.mode === 'cite');
|
||||
|
||||
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}℃`);
|
||||
if (row.height) parts.push(`身高 ${row.height}cm`);
|
||||
if (row.weight) parts.push(`体重 ${row.weight}KG`);
|
||||
if (row.respiratory_rate) parts.push(`呼吸 ${row.respiratory_rate}次/分`);
|
||||
if (row.bp_systolic || row.bp_diastolic) {
|
||||
parts.push(`血压 ${row.bp_systolic || '-'}/${row.bp_diastolic || '-'}mmHg`);
|
||||
}
|
||||
return parts.join(';') || '';
|
||||
}
|
||||
|
||||
function fieldDisplay(row: Record<string, any>, key: string) {
|
||||
if (key === 'physical_exam') {
|
||||
const body = String(row.physical_exam || '').trim();
|
||||
const vitals = vitalsText(row);
|
||||
if (body && vitals) return `${vitals}\n${body}`;
|
||||
return body || vitals || '—';
|
||||
}
|
||||
const v = row[key];
|
||||
if (v === null || v === undefined || v === '' || v === 0) return '—';
|
||||
return String(v);
|
||||
}
|
||||
|
||||
function listTitle(row: Record<string, any>) {
|
||||
const dx = String(row.diagnosis || '').trim();
|
||||
if (dx) return dx.length > 28 ? `${dx.slice(0, 28)}…` : dx;
|
||||
return `挂号 #${row.register_id || '-'}`;
|
||||
}
|
||||
|
||||
function isCurrentRegister(row: Record<string, any>) {
|
||||
const exclude = Number(meta.value.excludeRegisterId || 0);
|
||||
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);
|
||||
if (exclude > 0) {
|
||||
const other = rows.find((r) => Number(r.register_id) !== exclude);
|
||||
if (other) return Number(other.id);
|
||||
}
|
||||
return Number(rows[0].id);
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
const uid = Number(meta.value.userPatientId || 0);
|
||||
if (!uid) {
|
||||
list.value = [];
|
||||
activeId.value = 0;
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getMedicalRecordListByPatient({
|
||||
user_patient_id: uid,
|
||||
store_id: meta.value.storeId || undefined,
|
||||
limit: 50,
|
||||
});
|
||||
list.value = Array.isArray(res) ? res : res?.items || res?.list || [];
|
||||
activeId.value = pickDefaultActiveId(list.value);
|
||||
} catch (e: any) {
|
||||
list.value = [];
|
||||
activeId.value = 0;
|
||||
message.error(e?.message || '加载病历失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
class: 'w-[860px]',
|
||||
title: '查看患者病历',
|
||||
cancelText: '关闭',
|
||||
confirmText: '引用此病历',
|
||||
showConfirmButton: false,
|
||||
onConfirm() {
|
||||
if (!isCiteMode.value) {
|
||||
drawerApi.close();
|
||||
return;
|
||||
}
|
||||
const row = activeRecord.value;
|
||||
if (!row) {
|
||||
message.warning('请先选择要引用的病历');
|
||||
return;
|
||||
}
|
||||
meta.value.onCite?.(row);
|
||||
drawerApi.close();
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
list.value = [];
|
||||
activeId.value = 0;
|
||||
meta.value = {};
|
||||
return;
|
||||
}
|
||||
const data = drawerApi.getData<DrawerData>() || {};
|
||||
meta.value = data;
|
||||
const cite = data.mode === 'cite';
|
||||
const name = data.patientName ? ` - ${data.patientName}` : '';
|
||||
drawerApi.setState({
|
||||
title: cite ? `引用历史病历${name}` : `查看患者病历${name}`,
|
||||
showConfirmButton: cite,
|
||||
confirmText: '引用此病历',
|
||||
cancelText: cite ? '取消' : '关闭',
|
||||
});
|
||||
loadList();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Drawer>
|
||||
<Spin :spinning="loading">
|
||||
<div v-if="list.length === 0 && !loading" class="py-10">
|
||||
<Empty description="暂无病历记录" />
|
||||
</div>
|
||||
<div v-else class="mr-view-layout">
|
||||
<div class="mr-view-list">
|
||||
<div
|
||||
v-for="row in list"
|
||||
:key="row.id"
|
||||
class="mr-view-item"
|
||||
:class="{ 'is-active': Number(row.id) === activeId }"
|
||||
@click="activeId = Number(row.id)"
|
||||
>
|
||||
<div class="mr-view-item__title">{{ listTitle(row) }}</div>
|
||||
<div class="mr-view-item__meta">
|
||||
<Tag>挂号 {{ row.register_id || '-' }}</Tag>
|
||||
<Tag v-if="isCurrentRegister(row)" color="blue">本次</Tag>
|
||||
<span>{{ row.created_at || row.updated_at || '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
<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"
|
||||
>
|
||||
<div class="mr-view-field__label">{{ f.label }}</div>
|
||||
<div class="mr-view-field__value">
|
||||
{{ fieldDisplay(activeRecord, f.value) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<Empty v-else description="请选择左侧病历" />
|
||||
</div>
|
||||
</div>
|
||||
</Spin>
|
||||
</Drawer>
|
||||
</template>
|
||||
<style scoped>
|
||||
.mr-view-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 240px 1fr;
|
||||
gap: 16px;
|
||||
min-height: 520px;
|
||||
}
|
||||
.mr-view-list {
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
padding-right: 12px;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.mr-view-item {
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
margin-bottom: 8px;
|
||||
background: hsl(var(--muted) / 0.35);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.mr-view-item:hover {
|
||||
border-color: hsl(var(--primary) / 0.35);
|
||||
}
|
||||
.mr-view-item.is-active {
|
||||
border-color: hsl(var(--primary));
|
||||
background: hsl(var(--primary) / 0.08);
|
||||
}
|
||||
.mr-view-item__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
word-break: break-all;
|
||||
}
|
||||
.mr-view-item__meta {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
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));
|
||||
}
|
||||
.mr-view-tip {
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
color: #ad6800;
|
||||
background: #fff7e6;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.mr-view-field {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.mr-view-field__label {
|
||||
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;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
background: hsl(var(--muted) / 0.4);
|
||||
min-height: 36px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,176 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 病历文本域双击放大编辑弹窗
|
||||
* - 内嵌关键字搜索气泡 + 常用快捷(诊断/医嘱)
|
||||
* - 确认或弹窗内文本域再双击:写回并关闭
|
||||
* - 禁止点遮罩关闭,避免误丢编辑内容
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { Textarea } from 'ant-design-vue';
|
||||
|
||||
import CommonDxOrderChips from './CommonDxOrderChips.vue';
|
||||
import EntryKeywordBubble, {
|
||||
type MedicalRecordEntryItem,
|
||||
} from './EntryKeywordBubble.vue';
|
||||
|
||||
const draft = ref('');
|
||||
const fieldLabel = ref('编辑内容');
|
||||
const onConfirmCb = ref<((v: string) => void) | null>(null);
|
||||
type ExpandBubbleSource =
|
||||
| 'entry'
|
||||
| 'diagnosis'
|
||||
| 'doctor_order'
|
||||
| 'tcm_disease'
|
||||
| 'tcm_syndrome'
|
||||
| 'tcm_method';
|
||||
const source = ref<ExpandBubbleSource>('entry');
|
||||
const fieldCode = ref('');
|
||||
const storeId = ref(0);
|
||||
const showChips = ref(false);
|
||||
/** 气泡内展示常用|我的(与系统配置 mr_common_display_mode 对齐) */
|
||||
const showCommonInBubble = ref(false);
|
||||
const joinMode = ref<'comma' | 'newline' | 'replace'>('newline');
|
||||
const readonly = ref(false);
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
title: '编辑',
|
||||
class: 'w-[780px]',
|
||||
draggable: true,
|
||||
closeOnClickModal: false,
|
||||
onConfirm() {
|
||||
confirmAndClose();
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
draft.value = '';
|
||||
onConfirmCb.value = null;
|
||||
source.value = 'entry';
|
||||
fieldCode.value = '';
|
||||
showChips.value = false;
|
||||
showCommonInBubble.value = false;
|
||||
return;
|
||||
}
|
||||
const data = modalApi.getData<{
|
||||
title?: string;
|
||||
value?: string;
|
||||
onConfirm?: (v: string) => void;
|
||||
source?: ExpandBubbleSource;
|
||||
fieldCode?: string;
|
||||
storeId?: number;
|
||||
showChips?: boolean;
|
||||
showCommonInBubble?: boolean;
|
||||
joinMode?: 'comma' | 'newline' | 'replace';
|
||||
readonly?: boolean;
|
||||
}>();
|
||||
fieldLabel.value = data?.title || '编辑内容';
|
||||
draft.value = data?.value || '';
|
||||
onConfirmCb.value = data?.onConfirm || null;
|
||||
source.value = data?.source || 'entry';
|
||||
fieldCode.value = data?.fieldCode || '';
|
||||
storeId.value = Number(data?.storeId || 0);
|
||||
showChips.value = !!data?.showChips;
|
||||
showCommonInBubble.value = !!data?.showCommonInBubble;
|
||||
joinMode.value = data?.joinMode || 'newline';
|
||||
readonly.value = !!data?.readonly;
|
||||
modalApi.setState({ title: fieldLabel.value });
|
||||
},
|
||||
});
|
||||
|
||||
const showBubble = computed(() => {
|
||||
if (readonly.value) return false;
|
||||
if (source.value === 'entry') return !!fieldCode.value;
|
||||
return (
|
||||
source.value === 'diagnosis' ||
|
||||
source.value === 'doctor_order' ||
|
||||
source.value === 'tcm_disease' ||
|
||||
source.value === 'tcm_syndrome' ||
|
||||
source.value === 'tcm_method'
|
||||
);
|
||||
});
|
||||
|
||||
/** 中文逗号拼接,去重 */
|
||||
function appendCommaText(base: string, add: string) {
|
||||
if (!add) return base || '';
|
||||
const parts = (base || '').split(',').map((s) => s.trim()).filter(Boolean);
|
||||
if (parts.includes(add)) return parts.join(',');
|
||||
parts.push(add);
|
||||
return parts.join(',');
|
||||
}
|
||||
|
||||
function appendNewline(base: string, add: string) {
|
||||
if (!add) return base || '';
|
||||
if (!base) return add;
|
||||
return `${base}${base.endsWith('\n') ? '' : '\n'}${add}`;
|
||||
}
|
||||
|
||||
/** 气泡/快捷写入草稿 */
|
||||
function applyPick(text: string) {
|
||||
const t = String(text || '').trim();
|
||||
if (!t || readonly.value) return;
|
||||
if (joinMode.value === 'replace') {
|
||||
draft.value = t;
|
||||
return;
|
||||
}
|
||||
if (joinMode.value === 'comma') {
|
||||
draft.value = appendCommaText(draft.value, t);
|
||||
return;
|
||||
}
|
||||
draft.value = appendNewline(draft.value, t);
|
||||
}
|
||||
|
||||
function onBubbleSelect(item: MedicalRecordEntryItem) {
|
||||
applyPick(item.content || item.title || '');
|
||||
}
|
||||
|
||||
function confirmAndClose() {
|
||||
onConfirmCb.value?.(draft.value);
|
||||
modalApi.close();
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<Modal>
|
||||
<div class="py-2">
|
||||
<div v-if="showBubble" class="mb-2">
|
||||
<EntryKeywordBubble
|
||||
:source="source"
|
||||
:field-code="fieldCode"
|
||||
:store-id="storeId"
|
||||
:placeholder="
|
||||
source === 'diagnosis'
|
||||
? '诊断关键字/首拼'
|
||||
: source === 'doctor_order'
|
||||
? '医嘱关键字/首拼'
|
||||
: source === 'tcm_disease' ||
|
||||
source === 'tcm_syndrome' ||
|
||||
source === 'tcm_method'
|
||||
? `${fieldLabel}关键字/首拼/全拼`
|
||||
: '词条关键字/首拼'
|
||||
"
|
||||
:disabled="readonly"
|
||||
:show-common-in-bubble="showCommonInBubble"
|
||||
@select="onBubbleSelect"
|
||||
/>
|
||||
</div>
|
||||
<Textarea
|
||||
v-model:value="draft"
|
||||
:rows="12"
|
||||
:placeholder="fieldLabel"
|
||||
:disabled="readonly"
|
||||
class="w-full"
|
||||
allow-clear
|
||||
@dblclick="confirmAndClose"
|
||||
/>
|
||||
<CommonDxOrderChips
|
||||
v-if="showChips && (source === 'diagnosis' || source === 'doctor_order')"
|
||||
:source="source"
|
||||
:disabled="readonly"
|
||||
@select="applyPick"
|
||||
/>
|
||||
<div class="mt-2 text-xs text-muted-foreground">
|
||||
可先搜索/点常用写入;双击文本域或点确定写回关闭;Ctrl+Enter 在主面板切换模块
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,76 @@
|
||||
/** 病历词条字段选项(与后端 MedicalRecordFieldCodeEnum 对齐) */
|
||||
export const MEDICAL_RECORD_FIELD_OPTIONS = [
|
||||
{ label: '主诉', value: 'chief_complaint' },
|
||||
{ label: '现病史', value: 'present_illness' },
|
||||
{ label: '舌象', value: 'tongue' },
|
||||
{ label: '脉象', value: 'pulse' },
|
||||
{ label: '中医病案', value: 'tcm_case' },
|
||||
{ label: '治疗意见', value: 'treatment_advice' },
|
||||
// 诊断/医嘱与处方「常用诊断」「常用医嘱」同源,不在此表维护
|
||||
{ label: '家族史', value: 'family_history' },
|
||||
{ label: '流行病学史', value: 'epidemic_history' },
|
||||
{ label: '既往史', value: 'past_history' },
|
||||
{ label: '过敏史', value: 'allergy_history' },
|
||||
{ label: '体征检查', value: 'physical_exam' },
|
||||
{ label: '辅助检查', value: 'auxiliary_exam' },
|
||||
];
|
||||
|
||||
/** 病历只读展示字段(含诊断/医嘱正文) */
|
||||
export const MEDICAL_RECORD_VIEW_FIELDS = [
|
||||
{ label: '临床诊断', value: 'diagnosis' },
|
||||
{ label: '主诉', value: 'chief_complaint' },
|
||||
{ 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: 'treatment_advice' },
|
||||
{ label: '医嘱', value: 'doctor_order' },
|
||||
{ label: '家族史', value: 'family_history' },
|
||||
{ label: '流行病学史', value: 'epidemic_history' },
|
||||
{ label: '既往史', value: 'past_history' },
|
||||
{ label: '过敏史', value: 'allergy_history' },
|
||||
{ label: '体征检查', value: 'physical_exam' },
|
||||
{ label: '辅助检查', value: 'auxiliary_exam' },
|
||||
];
|
||||
|
||||
export const STATUS_OPTIONS = [
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '禁用', value: 0 },
|
||||
];
|
||||
|
||||
/** 空病历结构 */
|
||||
export function emptyMedicalRecord(registerId = 0, storeId = 0) {
|
||||
return {
|
||||
id: 0,
|
||||
store_id: storeId,
|
||||
register_id: registerId,
|
||||
user_patient_id: 0,
|
||||
doctor_id: 0,
|
||||
chief_complaint: '',
|
||||
present_illness: '',
|
||||
tcm_disease: '',
|
||||
tcm_syndrome: '',
|
||||
tcm_method: '',
|
||||
tongue: '',
|
||||
pulse: '',
|
||||
tcm_case: '',
|
||||
treatment_advice: '',
|
||||
doctor_order: '',
|
||||
diagnosis: '',
|
||||
family_history: '',
|
||||
epidemic_history: '',
|
||||
past_history: '',
|
||||
allergy_history: '',
|
||||
physical_exam: '',
|
||||
auxiliary_exam: '',
|
||||
temperature: 0,
|
||||
height: 0,
|
||||
weight: 0,
|
||||
respiratory_rate: 0,
|
||||
bp_systolic: 0,
|
||||
bp_diastolic: 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { VbenFormProps } from '#/adapter/form';
|
||||
|
||||
import { MEDICAL_RECORD_FIELD_OPTIONS, STATUS_OPTIONS } from './constants';
|
||||
|
||||
/** 词条表单 */
|
||||
export function buildEntryFormProps(): VbenFormProps {
|
||||
return {
|
||||
wrapperClass: 'grid-cols-12',
|
||||
commonConfig: {
|
||||
formItemClass: 'col-span-12',
|
||||
componentProps: { class: 'w-full' },
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'id',
|
||||
label: 'ID',
|
||||
dependencies: { show: false, triggerFields: ['id'] },
|
||||
},
|
||||
{
|
||||
component: 'VbenSelect',
|
||||
fieldName: 'field_code',
|
||||
label: '所属字段',
|
||||
rules: 'selectRequired',
|
||||
componentProps: { options: MEDICAL_RECORD_FIELD_OPTIONS },
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'title',
|
||||
label: '词条名称',
|
||||
rules: 'required',
|
||||
componentProps: { placeholder: '列表展示名称' },
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'content',
|
||||
label: '词条正文',
|
||||
componentProps: { type: 'textarea', rows: 4, placeholder: '选中后写入文本区' },
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'remark',
|
||||
label: '临床意义',
|
||||
componentProps: { placeholder: '可选' },
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
fieldName: 'sort',
|
||||
label: '排序',
|
||||
formItemClass: 'col-span-6',
|
||||
componentProps: { min: 0 },
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
component: 'VbenSelect',
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
formItemClass: 'col-span-6',
|
||||
componentProps: { options: STATUS_OPTIONS },
|
||||
defaultValue: 1,
|
||||
},
|
||||
],
|
||||
showDefaultActions: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import ExcelJS from 'exceljs';
|
||||
|
||||
import { MEDICAL_RECORD_FIELD_OPTIONS } from '../config/constants';
|
||||
|
||||
const THIN_BORDER: Partial<ExcelJS.Borders> = {
|
||||
top: { style: 'thin', color: { argb: 'FF000000' } },
|
||||
left: { style: 'thin', color: { argb: 'FF000000' } },
|
||||
bottom: { style: 'thin', color: { argb: 'FF000000' } },
|
||||
right: { style: 'thin', color: { argb: 'FF000000' } },
|
||||
};
|
||||
|
||||
const HEADERS = [
|
||||
'字段编码',
|
||||
'词条名称',
|
||||
'正文',
|
||||
'临床意义',
|
||||
'排序',
|
||||
'状态',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 下载病历词条导入模板(含字段编码说明 sheet)
|
||||
*/
|
||||
export async function exportMedicalRecordEntryTemplate(): Promise<ArrayBuffer> {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet('词条导入');
|
||||
sheet.columns = [
|
||||
{ width: 18 },
|
||||
{ width: 20 },
|
||||
{ width: 36 },
|
||||
{ width: 24 },
|
||||
{ width: 10 },
|
||||
{ width: 10 },
|
||||
];
|
||||
const headerRow = sheet.addRow([...HEADERS]);
|
||||
headerRow.height = 28;
|
||||
headerRow.eachCell((cell) => {
|
||||
cell.font = { bold: true };
|
||||
cell.fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FFFFFF00' },
|
||||
};
|
||||
cell.alignment = { horizontal: 'center', vertical: 'middle' };
|
||||
cell.border = THIN_BORDER;
|
||||
});
|
||||
// 示例行:方便运营对照填写
|
||||
const sample = sheet.addRow([
|
||||
'chief_complaint',
|
||||
'头痛三天',
|
||||
'头痛三天,加重一天',
|
||||
'常见主诉示例',
|
||||
100,
|
||||
1,
|
||||
]);
|
||||
sample.eachCell((cell) => {
|
||||
cell.border = THIN_BORDER;
|
||||
cell.alignment = { vertical: 'middle' };
|
||||
});
|
||||
sheet.views = [{ state: 'frozen', ySplit: 1 }];
|
||||
|
||||
const tip = workbook.addWorksheet('字段编码说明');
|
||||
tip.columns = [{ width: 20 }, { width: 16 }];
|
||||
const tipHead = tip.addRow(['字段名称', '字段编码']);
|
||||
tipHead.eachCell((cell) => {
|
||||
cell.font = { bold: true };
|
||||
cell.fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FFD9EAD3' },
|
||||
};
|
||||
cell.border = THIN_BORDER;
|
||||
});
|
||||
MEDICAL_RECORD_FIELD_OPTIONS.forEach((opt) => {
|
||||
const row = tip.addRow([opt.label, opt.value]);
|
||||
row.eachCell((cell) => {
|
||||
cell.border = THIN_BORDER;
|
||||
});
|
||||
});
|
||||
tip.addRow([]);
|
||||
tip.addRow(['状态说明', '1=启用,0=禁用;空默认启用']);
|
||||
tip.addRow(['拼音首拼', '由系统根据词条名称自动生成,无需填写']);
|
||||
|
||||
return workbook.xlsx.writeBuffer() as Promise<ArrayBuffer>;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 病历本地草稿(按挂号 ID)
|
||||
* 刷新页面后优先用草稿覆盖接口空/旧内容,避免未点「保存病历」就丢失
|
||||
*/
|
||||
const PREFIX = 'medicalRecordDraft_';
|
||||
const TAB_PREFIX = 'rxMrTab_';
|
||||
|
||||
export function medicalRecordDraftKey(registerId: number | string) {
|
||||
return `${PREFIX}${registerId}`;
|
||||
}
|
||||
|
||||
/** 处方|病历 Tab 本地键;prefix 用于 chat-shop 等场景隔离 */
|
||||
export function rxMrTabKey(registerId: number | string, prefix = '') {
|
||||
return `${prefix}${TAB_PREFIX}${registerId}`;
|
||||
}
|
||||
|
||||
export function saveMedicalRecordDraft(
|
||||
registerId: number | string,
|
||||
data: Record<string, any>,
|
||||
) {
|
||||
if (!registerId) return;
|
||||
try {
|
||||
localStorage.setItem(
|
||||
medicalRecordDraftKey(registerId),
|
||||
JSON.stringify({ ...data, _savedAt: Date.now() }),
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('保存病历草稿失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
export function loadMedicalRecordDraft(
|
||||
registerId: number | string,
|
||||
): Record<string, any> | null {
|
||||
if (!registerId) return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(medicalRecordDraftKey(registerId));
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearMedicalRecordDraft(registerId?: number | string) {
|
||||
if (!registerId) return;
|
||||
localStorage.removeItem(medicalRecordDraftKey(registerId));
|
||||
}
|
||||
|
||||
export function saveRxMrTab(
|
||||
registerId: number | string,
|
||||
tab: 'rx' | 'mr',
|
||||
prefix = '',
|
||||
) {
|
||||
if (!registerId) return;
|
||||
localStorage.setItem(rxMrTabKey(registerId, prefix), tab);
|
||||
}
|
||||
|
||||
export function loadRxMrTab(
|
||||
registerId: number | string,
|
||||
prefix = '',
|
||||
): 'rx' | 'mr' | null {
|
||||
if (!registerId) return null;
|
||||
const v = localStorage.getItem(rxMrTabKey(registerId, prefix));
|
||||
if (v === 'rx' || v === 'mr') return v;
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import ExcelJS from 'exceljs';
|
||||
|
||||
import { MEDICAL_RECORD_FIELD_OPTIONS } from '../config/constants';
|
||||
|
||||
const MAX_ROWS = 2000;
|
||||
|
||||
export type ParsedMedicalRecordEntryRow = {
|
||||
field_code: string;
|
||||
title: string;
|
||||
content: string;
|
||||
remark: string;
|
||||
sort: number;
|
||||
status: number;
|
||||
};
|
||||
|
||||
export type ParseMedicalRecordEntryResult = {
|
||||
items: ParsedMedicalRecordEntryRow[];
|
||||
invalidCount: number;
|
||||
rowCount: number;
|
||||
};
|
||||
|
||||
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 === 'number') {
|
||||
return Number.isInteger(value) ? String(value) : String(value).trim();
|
||||
}
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
function normalizeHeader(value: string): string {
|
||||
return value.replace(/\s+/g, '').replace(/\u3000/g, '');
|
||||
}
|
||||
|
||||
const FIELD_ALIASES = ['字段编码', 'field_code', '字段'];
|
||||
const TITLE_ALIASES = ['词条名称', '名称', 'title'];
|
||||
const CONTENT_ALIASES = ['正文', '内容', 'content'];
|
||||
const REMARK_ALIASES = ['临床意义', '备注', 'remark'];
|
||||
const SORT_ALIASES = ['排序', 'sort'];
|
||||
const STATUS_ALIASES = ['状态', 'status'];
|
||||
|
||||
const VALID_FIELDS = new Set(MEDICAL_RECORD_FIELD_OPTIONS.map((o) => o.value));
|
||||
|
||||
function buildHeaderIndexMap(headerRow: ExcelJS.Row): Record<string, number> {
|
||||
const map: Record<string, number> = {};
|
||||
headerRow.eachCell({ includeEmpty: true }, (cell, colNumber) => {
|
||||
const key = normalizeHeader(normalizeCell(cell.value));
|
||||
if (key) {
|
||||
map[key] = colNumber;
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
function pickColumn(
|
||||
headerMap: Record<string, number>,
|
||||
aliases: string[],
|
||||
): number | undefined {
|
||||
for (const alias of aliases) {
|
||||
const col = headerMap[normalizeHeader(alias)];
|
||||
if (col) return col;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getCellText(row: ExcelJS.Row, col?: number): string {
|
||||
if (!col) return '';
|
||||
return normalizeCell(row.getCell(col).value);
|
||||
}
|
||||
|
||||
function getCellInt(row: ExcelJS.Row, col?: number, fallback = 0): number {
|
||||
if (!col) return fallback;
|
||||
const raw = normalizeCell(row.getCell(col).value);
|
||||
if (raw === '') return fallback;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) ? Math.trunc(n) : fallback;
|
||||
}
|
||||
|
||||
function rowIsEmpty(row: ExcelJS.Row): boolean {
|
||||
let empty = true;
|
||||
row.eachCell({ includeEmpty: false }, () => {
|
||||
empty = false;
|
||||
});
|
||||
return empty;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析词条导入 Excel(首行为表头)
|
||||
*/
|
||||
export async function parseMedicalRecordEntryExcelBuffer(
|
||||
buffer: ArrayBuffer,
|
||||
): Promise<ParseMedicalRecordEntryResult> {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(buffer);
|
||||
const sheet = workbook.worksheets[0];
|
||||
if (!sheet) {
|
||||
return { items: [], invalidCount: 0, rowCount: 0 };
|
||||
}
|
||||
const headerRow = sheet.getRow(1);
|
||||
const headerMap = buildHeaderIndexMap(headerRow);
|
||||
const fieldCol = pickColumn(headerMap, FIELD_ALIASES);
|
||||
const titleCol = pickColumn(headerMap, TITLE_ALIASES);
|
||||
const contentCol = pickColumn(headerMap, CONTENT_ALIASES);
|
||||
const remarkCol = pickColumn(headerMap, REMARK_ALIASES);
|
||||
const sortCol = pickColumn(headerMap, SORT_ALIASES);
|
||||
const statusCol = pickColumn(headerMap, STATUS_ALIASES);
|
||||
if (!fieldCol || !titleCol) {
|
||||
throw new Error('模板表头缺少「字段编码」或「词条名称」列');
|
||||
}
|
||||
const items: ParsedMedicalRecordEntryRow[] = [];
|
||||
let invalidCount = 0;
|
||||
let rowCount = 0;
|
||||
sheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
|
||||
if (rowNumber === 1 || rowIsEmpty(row)) return;
|
||||
rowCount++;
|
||||
if (items.length >= MAX_ROWS) return;
|
||||
const field_code = getCellText(row, fieldCol);
|
||||
const title = getCellText(row, titleCol);
|
||||
if (!field_code || !title || !VALID_FIELDS.has(field_code)) {
|
||||
invalidCount++;
|
||||
return;
|
||||
}
|
||||
const statusRaw = getCellInt(row, statusCol, 1);
|
||||
items.push({
|
||||
field_code,
|
||||
title,
|
||||
content: getCellText(row, contentCol) || title,
|
||||
remark: getCellText(row, remarkCol),
|
||||
sort: getCellInt(row, sortCol, 0),
|
||||
status: statusRaw === 0 ? 0 : 1,
|
||||
});
|
||||
});
|
||||
return { items, invalidCount, rowCount };
|
||||
}
|
||||
@@ -96,6 +96,8 @@ const openPrescriptionModule = (id) => {
|
||||
prescriptionModalApi.setData({
|
||||
registerId: targetRegisterId,
|
||||
onPrescriptionSent: handlePrescriptionSent,
|
||||
storagePrefix: 'onlineConsultation-',
|
||||
enableMedicalRecord: true,
|
||||
});
|
||||
prescriptionModalApi.open();
|
||||
};
|
||||
@@ -335,6 +337,8 @@ const handleAddPatientProductsToPrescription = async () => {
|
||||
prescriptionModalApi.setData({
|
||||
registerId: prescriptionStore.currentRegisterId,
|
||||
onPrescriptionSent: handlePrescriptionSent,
|
||||
storagePrefix: 'onlineConsultation-',
|
||||
enableMedicalRecord: true,
|
||||
});
|
||||
prescriptionModalApi.open();
|
||||
} else {
|
||||
|
||||
27
apps/web-antd/src/views/system/dict/disease/api/index.ts
Normal file
27
apps/web-antd/src/views/system/dict/disease/api/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 疾病诊断字典 API(yii_disease)
|
||||
*/
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
const prefix = 'disease-dict/';
|
||||
|
||||
export async function getDiseaseDictList(data: any) {
|
||||
return requestClient.get<any>(`${prefix}list`, { params: data });
|
||||
}
|
||||
|
||||
export async function createDiseaseDict(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}create`, data);
|
||||
}
|
||||
|
||||
export async function updateDiseaseDict(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}update`, data);
|
||||
}
|
||||
|
||||
export async function deleteDiseaseDict(data: { ids: number[] }) {
|
||||
return requestClient.post<any>(`${prefix}delete`, data);
|
||||
}
|
||||
|
||||
/** 同步空拼音首拼/全拼 */
|
||||
export async function syncDiseaseDictPinyin() {
|
||||
return requestClient.post<any>(`${prefix}sync-pinyin`, {});
|
||||
}
|
||||
269
apps/web-antd/src/views/system/dict/disease/index.vue
Normal file
269
apps/web-antd/src/views/system/dict/disease/index.vue
Normal file
@@ -0,0 +1,269 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 疾病诊断字典(yii_disease)
|
||||
* 挂在字典管理下,维护接诊诊断库
|
||||
*/
|
||||
import type { VxeGridListeners } from '#/adapter/vxe-table';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button, message, Modal as AntModal } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
|
||||
import {
|
||||
createDiseaseDict,
|
||||
deleteDiseaseDict,
|
||||
getDiseaseDictList,
|
||||
syncDiseaseDictPinyin,
|
||||
updateDiseaseDict,
|
||||
} from './api';
|
||||
|
||||
defineOptions({ name: 'DiseaseDict' });
|
||||
|
||||
const hasTopTableDropDownActions = ref(false);
|
||||
const syncing = ref(false);
|
||||
const isUpdate = ref(false);
|
||||
|
||||
const formOptions = {
|
||||
schema: [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'keyword',
|
||||
label: '关键字',
|
||||
componentProps: { placeholder: '名称/代码/首拼/全拼' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const gridOptions = {
|
||||
checkboxConfig: { highlight: true, labelField: '' },
|
||||
columns: [
|
||||
{ type: 'checkbox', width: 60 },
|
||||
{ field: 'id', title: 'ID', width: 80 },
|
||||
{ field: 'diagnose_code', title: '诊断代码', width: 120 },
|
||||
{ field: 'name', title: '诊断名称', minWidth: 180 },
|
||||
{ field: 'pinyin', title: '首拼', width: 120 },
|
||||
{ field: 'pinyin_full', title: '全拼', minWidth: 160 },
|
||||
{ field: 'major_number', title: '主编号', width: 120 },
|
||||
{ field: 'ref_number', title: '次编号', width: 120 },
|
||||
{ field: 'created_at', title: '创建时间', width: 170 },
|
||||
{ title: '操作', slots: { default: 'action' }, width: 140, fixed: 'right' },
|
||||
],
|
||||
keepSource: true,
|
||||
pagerConfig: {},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }: any, formValues: any) => {
|
||||
return await getDiseaseDictList({
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
height: 'auto',
|
||||
toolbarConfig: {
|
||||
search: true,
|
||||
refresh: true,
|
||||
slots: { buttons: 'toolbar-buttons' },
|
||||
},
|
||||
};
|
||||
|
||||
const gridEvents: VxeGridListeners<any> = {
|
||||
checkboxChange() {
|
||||
hasTopTableDropDownActions.value =
|
||||
gridApi.grid.getCheckboxRecords().length > 0;
|
||||
},
|
||||
checkboxAll() {
|
||||
hasTopTableDropDownActions.value =
|
||||
gridApi.grid.getCheckboxRecords().length > 0;
|
||||
},
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions,
|
||||
gridOptions: gridOptions as any,
|
||||
gridEvents,
|
||||
});
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
wrapperClass: 'grid-cols-12',
|
||||
commonConfig: {
|
||||
formItemClass: 'col-span-12',
|
||||
componentProps: { class: 'w-full' },
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'id',
|
||||
label: 'ID',
|
||||
dependencies: { show: false, triggerFields: ['id'] },
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'name',
|
||||
label: '诊断名称',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'diagnose_code',
|
||||
label: '诊断代码',
|
||||
formItemClass: 'col-span-6',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'major_number',
|
||||
label: '主编号',
|
||||
formItemClass: 'col-span-6',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'ref_number',
|
||||
label: '次编号',
|
||||
formItemClass: 'col-span-6',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'pinyin',
|
||||
label: '拼音首拼',
|
||||
formItemClass: 'col-span-6',
|
||||
componentProps: { placeholder: '可留空,保存时按名称自动生成' },
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'pinyin_full',
|
||||
label: '拼音全拼',
|
||||
componentProps: { placeholder: '可留空,保存时按名称自动生成' },
|
||||
},
|
||||
],
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
class: 'w-[640px]',
|
||||
draggable: true,
|
||||
onConfirm: async () => {
|
||||
const e = await formApi.validate();
|
||||
if (!e.valid) return;
|
||||
const values = await formApi.getValues();
|
||||
formModalApi.setState({ confirmLoading: true });
|
||||
try {
|
||||
if (isUpdate.value) {
|
||||
await updateDiseaseDict(values);
|
||||
} else {
|
||||
await createDiseaseDict(values);
|
||||
}
|
||||
message.success('保存成功');
|
||||
gridApi.query();
|
||||
formModalApi.close();
|
||||
} finally {
|
||||
formModalApi.setState({ confirmLoading: false });
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
formApi.resetForm();
|
||||
return;
|
||||
}
|
||||
const data = formModalApi.getData<{ values?: any; update?: boolean }>();
|
||||
isUpdate.value = !!data?.update;
|
||||
if (data?.values && data.update) {
|
||||
formApi.setValues({ ...data.values });
|
||||
} else {
|
||||
formApi.resetForm();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function showModal(row: any = {}, update = false) {
|
||||
formModalApi.setData({ values: row, update });
|
||||
formModalApi.open();
|
||||
}
|
||||
|
||||
async function handleDelete(ids: number[]) {
|
||||
await deleteDiseaseDict({ ids });
|
||||
message.success('删除成功');
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
/** 手动同步空拼音(疾病库量大,可能稍慢) */
|
||||
function handleSyncPinyin() {
|
||||
AntModal.confirm({
|
||||
title: '同步拼音',
|
||||
content:
|
||||
'将扫描疾病诊断中首拼或全拼为空的记录并自动生成,已有值不会覆盖。确认执行?',
|
||||
onOk: async () => {
|
||||
syncing.value = true;
|
||||
try {
|
||||
const res = await syncDiseaseDictPinyin();
|
||||
message.success(
|
||||
`同步完成:更新 ${Number(res?.updated || 0)} 条,跳过 ${Number(res?.skipped || 0)} 条`,
|
||||
);
|
||||
gridApi.query();
|
||||
} finally {
|
||||
syncing.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<Page auto-content-height title="疾病诊断">
|
||||
<FormModal :title="`${isUpdate ? '编辑' : '新增'}疾病诊断`">
|
||||
<Form />
|
||||
</FormModal>
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '新增',
|
||||
type: 'primary',
|
||||
onClick: () => showModal({}, false),
|
||||
},
|
||||
{
|
||||
label: '批量删除',
|
||||
danger: true,
|
||||
ifShow: () => hasTopTableDropDownActions,
|
||||
popConfirm: {
|
||||
title: '确认删除所选?',
|
||||
confirm: () => {
|
||||
const ids = gridApi.grid
|
||||
.getCheckboxRecords()
|
||||
.map((r: any) => r.id);
|
||||
handleDelete(ids);
|
||||
},
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<Button class="ml-2" :loading="syncing" @click="handleSyncPinyin">
|
||||
同步拼音
|
||||
</Button>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{ label: '编辑', onClick: () => showModal(row, true) },
|
||||
{
|
||||
label: '删除',
|
||||
danger: true,
|
||||
popConfirm: {
|
||||
title: '确认删除?',
|
||||
confirm: () => handleDelete([row.id]),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 公共医嘱字典 API(xk_doctor_order)
|
||||
*/
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
const prefix = 'doctor-order/';
|
||||
|
||||
export async function getDoctorOrderDictList(data: any) {
|
||||
return requestClient.get<any>(`${prefix}list`, { params: data });
|
||||
}
|
||||
|
||||
export async function createDoctorOrderDict(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}create`, data);
|
||||
}
|
||||
|
||||
export async function updateDoctorOrderDict(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}update`, data);
|
||||
}
|
||||
|
||||
export async function deleteDoctorOrderDict(data: { ids?: number[]; id?: number }) {
|
||||
return requestClient.post<any>(`${prefix}delete`, data);
|
||||
}
|
||||
|
||||
/** 同步空拼音首拼/全拼 */
|
||||
export async function syncDoctorOrderDictPinyin() {
|
||||
return requestClient.post<any>(`${prefix}sync-pinyin`, {});
|
||||
}
|
||||
235
apps/web-antd/src/views/system/dict/doctor-order/index.vue
Normal file
235
apps/web-antd/src/views/system/dict/doctor-order/index.vue
Normal file
@@ -0,0 +1,235 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 公共医嘱字典(xk_doctor_order)
|
||||
* 挂在字典管理下,维护接诊公共医嘱
|
||||
*/
|
||||
import type { VxeGridListeners } from '#/adapter/vxe-table';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button, message, Modal as AntModal } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
|
||||
import {
|
||||
createDoctorOrderDict,
|
||||
deleteDoctorOrderDict,
|
||||
getDoctorOrderDictList,
|
||||
syncDoctorOrderDictPinyin,
|
||||
updateDoctorOrderDict,
|
||||
} from './api';
|
||||
|
||||
defineOptions({ name: 'DoctorOrderDict' });
|
||||
|
||||
const hasTopTableDropDownActions = ref(false);
|
||||
const syncing = ref(false);
|
||||
const isUpdate = ref(false);
|
||||
|
||||
const formOptions = {
|
||||
schema: [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'keyword',
|
||||
label: '关键字',
|
||||
componentProps: { placeholder: '内容/首拼/全拼' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const gridOptions = {
|
||||
checkboxConfig: { highlight: true, labelField: '' },
|
||||
columns: [
|
||||
{ type: 'checkbox', width: 60 },
|
||||
{ field: 'id', title: 'ID', width: 80 },
|
||||
{ field: 'name', title: '医嘱内容', minWidth: 220 },
|
||||
{ field: 'pinyin_initials', title: '首拼', width: 120 },
|
||||
{ field: 'pinyin_full', title: '全拼', minWidth: 160 },
|
||||
{ field: 'created_at', title: '创建时间', width: 170 },
|
||||
{ title: '操作', slots: { default: 'action' }, width: 140, fixed: 'right' },
|
||||
],
|
||||
keepSource: true,
|
||||
pagerConfig: {},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }: any, formValues: any) => {
|
||||
return await getDoctorOrderDictList({
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
height: 'auto',
|
||||
toolbarConfig: {
|
||||
search: true,
|
||||
refresh: true,
|
||||
slots: { buttons: 'toolbar-buttons' },
|
||||
},
|
||||
};
|
||||
|
||||
const gridEvents: VxeGridListeners<any> = {
|
||||
checkboxChange() {
|
||||
hasTopTableDropDownActions.value =
|
||||
gridApi.grid.getCheckboxRecords().length > 0;
|
||||
},
|
||||
checkboxAll() {
|
||||
hasTopTableDropDownActions.value =
|
||||
gridApi.grid.getCheckboxRecords().length > 0;
|
||||
},
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions,
|
||||
gridOptions: gridOptions as any,
|
||||
gridEvents,
|
||||
});
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
wrapperClass: 'grid-cols-12',
|
||||
commonConfig: {
|
||||
formItemClass: 'col-span-12',
|
||||
componentProps: { class: 'w-full' },
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'id',
|
||||
label: 'ID',
|
||||
dependencies: { show: false, triggerFields: ['id'] },
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'name',
|
||||
label: '医嘱内容',
|
||||
rules: 'required',
|
||||
componentProps: { placeholder: '请输入医嘱内容' },
|
||||
},
|
||||
],
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
class: 'w-[560px]',
|
||||
draggable: true,
|
||||
onConfirm: async () => {
|
||||
const e = await formApi.validate();
|
||||
if (!e.valid) return;
|
||||
const values = await formApi.getValues();
|
||||
formModalApi.setState({ confirmLoading: true });
|
||||
try {
|
||||
if (isUpdate.value) {
|
||||
await updateDoctorOrderDict(values);
|
||||
} else {
|
||||
await createDoctorOrderDict(values);
|
||||
}
|
||||
message.success('保存成功');
|
||||
gridApi.query();
|
||||
formModalApi.close();
|
||||
} finally {
|
||||
formModalApi.setState({ confirmLoading: false });
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
formApi.resetForm();
|
||||
return;
|
||||
}
|
||||
const data = formModalApi.getData<{ values?: any; update?: boolean }>();
|
||||
isUpdate.value = !!data?.update;
|
||||
if (data?.values && data.update) {
|
||||
formApi.setValues({ ...data.values });
|
||||
} else {
|
||||
formApi.resetForm();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function showModal(row: any = {}, update = false) {
|
||||
formModalApi.setData({ values: row, update });
|
||||
formModalApi.open();
|
||||
}
|
||||
|
||||
async function handleDelete(ids: number[]) {
|
||||
await deleteDoctorOrderDict({ ids });
|
||||
message.success('删除成功');
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
function handleSyncPinyin() {
|
||||
AntModal.confirm({
|
||||
title: '同步拼音',
|
||||
content:
|
||||
'将扫描公共医嘱中首拼或全拼为空的记录并自动生成,已有值不会覆盖。确认执行?',
|
||||
onOk: async () => {
|
||||
syncing.value = true;
|
||||
try {
|
||||
const res = await syncDoctorOrderDictPinyin();
|
||||
message.success(
|
||||
`同步完成:更新 ${Number(res?.updated || 0)} 条,跳过 ${Number(res?.skipped || 0)} 条`,
|
||||
);
|
||||
gridApi.query();
|
||||
} finally {
|
||||
syncing.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<Page auto-content-height title="公共医嘱">
|
||||
<FormModal :title="`${isUpdate ? '编辑' : '新增'}公共医嘱`">
|
||||
<Form />
|
||||
</FormModal>
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '新增',
|
||||
type: 'primary',
|
||||
onClick: () => showModal({}, false),
|
||||
},
|
||||
{
|
||||
label: '批量删除',
|
||||
danger: true,
|
||||
ifShow: () => hasTopTableDropDownActions,
|
||||
popConfirm: {
|
||||
title: '确认删除所选?',
|
||||
confirm: () => {
|
||||
const ids = gridApi.grid
|
||||
.getCheckboxRecords()
|
||||
.map((r: any) => r.id);
|
||||
handleDelete(ids);
|
||||
},
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<Button class="ml-2" :loading="syncing" @click="handleSyncPinyin">
|
||||
同步拼音
|
||||
</Button>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{ label: '编辑', onClick: () => showModal(row, true) },
|
||||
{
|
||||
label: '删除',
|
||||
danger: true,
|
||||
popConfirm: {
|
||||
title: '确认删除?',
|
||||
confirm: () => handleDelete([row.id]),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
34
apps/web-antd/src/views/system/dict/tcm/api/index.ts
Normal file
34
apps/web-antd/src/views/system/dict/tcm/api/index.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 中医字典 API(疾病 / 治法 / 证候)
|
||||
*/
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
const prefix = 'tcm-dict/';
|
||||
|
||||
export type TcmDictType = 'diseases' | 'method' | 'syndrome';
|
||||
|
||||
export async function getTcmDictList(data: {
|
||||
type: TcmDictType;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
}) {
|
||||
return requestClient.get<any>(`${prefix}list`, { params: data });
|
||||
}
|
||||
|
||||
export async function createTcmDict(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}create`, data);
|
||||
}
|
||||
|
||||
export async function updateTcmDict(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}update`, data);
|
||||
}
|
||||
|
||||
export async function deleteTcmDict(data: { type: TcmDictType; ids: number[] }) {
|
||||
return requestClient.post<any>(`${prefix}delete`, data);
|
||||
}
|
||||
|
||||
/** 手动同步空拼音首拼 */
|
||||
export async function syncTcmDictPinyin(type: TcmDictType) {
|
||||
return requestClient.post<any>(`${prefix}sync-pinyin`, { type });
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 中医字典列表(疾病 / 治法 / 证候共用)
|
||||
* 支持 CRUD + 手动「同步拼音首拼」(仅补空值)
|
||||
*/
|
||||
import type { VxeGridListeners } from '#/adapter/vxe-table';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button, message, Modal as AntModal } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
|
||||
import {
|
||||
createTcmDict,
|
||||
deleteTcmDict,
|
||||
getTcmDictList,
|
||||
syncTcmDictPinyin,
|
||||
updateTcmDict,
|
||||
type TcmDictType,
|
||||
} from '../api';
|
||||
|
||||
const props = defineProps<{
|
||||
dictType: TcmDictType;
|
||||
pageTitle: string;
|
||||
pageName: string;
|
||||
}>();
|
||||
|
||||
defineOptions({ name: 'TcmDictListPage' });
|
||||
|
||||
const hasTopTableDropDownActions = ref(false);
|
||||
const syncing = ref(false);
|
||||
|
||||
const formOptions = {
|
||||
schema: [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'keyword',
|
||||
label: '关键字',
|
||||
componentProps: { placeholder: '名称/编码/首拼' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const gridOptions = computed(() => ({
|
||||
checkboxConfig: { highlight: true, labelField: '' },
|
||||
columns: [
|
||||
{ type: 'checkbox', width: 60 },
|
||||
{ field: 'id', title: 'ID', width: 80 },
|
||||
{ field: 'code', title: '代码', width: 120 },
|
||||
{ field: 'number', title: '编号', width: 120 },
|
||||
{ field: 'name', title: '名称', minWidth: 160 },
|
||||
{ field: 'alias', title: '别名', minWidth: 120 },
|
||||
{ field: 'initials_of_pinyin', title: '拼音首拼', width: 120 },
|
||||
{ field: 'pinyin_full', title: '拼音全拼', minWidth: 160 },
|
||||
{ field: 'created_at', title: '创建时间', width: 170 },
|
||||
{ title: '操作', slots: { default: 'action' }, width: 140, fixed: 'right' },
|
||||
],
|
||||
keepSource: true,
|
||||
pagerConfig: {},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }: any, formValues: any) => {
|
||||
return await getTcmDictList({
|
||||
type: props.dictType,
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
height: 'auto',
|
||||
toolbarConfig: {
|
||||
search: true,
|
||||
refresh: true,
|
||||
slots: { buttons: 'toolbar-buttons' },
|
||||
},
|
||||
}));
|
||||
|
||||
const gridEvents: VxeGridListeners<any> = {
|
||||
checkboxChange() {
|
||||
hasTopTableDropDownActions.value =
|
||||
gridApi.grid.getCheckboxRecords().length > 0;
|
||||
},
|
||||
checkboxAll() {
|
||||
hasTopTableDropDownActions.value =
|
||||
gridApi.grid.getCheckboxRecords().length > 0;
|
||||
},
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions,
|
||||
gridOptions: gridOptions.value as any,
|
||||
gridEvents,
|
||||
});
|
||||
|
||||
const isUpdate = ref(false);
|
||||
const [Form, formApi] = useVbenForm({
|
||||
wrapperClass: 'grid-cols-12',
|
||||
commonConfig: {
|
||||
formItemClass: 'col-span-12',
|
||||
componentProps: { class: 'w-full' },
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'id',
|
||||
label: 'ID',
|
||||
dependencies: { show: false, triggerFields: ['id'] },
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'name',
|
||||
label: '名称',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'code',
|
||||
label: '代码',
|
||||
formItemClass: 'col-span-6',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'number',
|
||||
label: '编号',
|
||||
formItemClass: 'col-span-6',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'alias',
|
||||
label: '别名',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'initials_of_pinyin',
|
||||
label: '拼音首拼',
|
||||
componentProps: { placeholder: '保存时按名称自动生成,可留空' },
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'pinyin_full',
|
||||
label: '拼音全拼',
|
||||
componentProps: { placeholder: '保存时按名称自动生成,可留空' },
|
||||
},
|
||||
],
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
class: 'w-[640px]',
|
||||
draggable: true,
|
||||
onConfirm: async () => {
|
||||
const e = await formApi.validate();
|
||||
if (!e.valid) return;
|
||||
const values = await formApi.getValues();
|
||||
formModalApi.setState({ confirmLoading: true });
|
||||
try {
|
||||
const payload = { ...values, type: props.dictType };
|
||||
if (isUpdate.value) {
|
||||
await updateTcmDict(payload);
|
||||
} else {
|
||||
await createTcmDict(payload);
|
||||
}
|
||||
message.success('保存成功');
|
||||
gridApi.query();
|
||||
formModalApi.close();
|
||||
} finally {
|
||||
formModalApi.setState({ confirmLoading: false });
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
formApi.resetFields();
|
||||
return;
|
||||
}
|
||||
const data = formModalApi.getData<{ values?: any; update?: boolean }>();
|
||||
isUpdate.value = !!data?.update;
|
||||
if (data?.values && data.update) {
|
||||
formApi.setValues({ ...data.values });
|
||||
} else {
|
||||
formApi.resetFields();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function showModal(row: any = {}, update = false) {
|
||||
formModalApi.setData({ values: row, update });
|
||||
formModalApi.open();
|
||||
}
|
||||
|
||||
async function handleDelete(ids: number[]) {
|
||||
await deleteTcmDict({ type: props.dictType, ids });
|
||||
message.success('删除成功');
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
/** 手动同步:补全空首拼/全拼,并纠正名称含数字但旧拼音丢数字的脏数据 */
|
||||
function handleSyncPinyin() {
|
||||
AntModal.confirm({
|
||||
title: '同步拼音',
|
||||
content: `将扫描「${props.pageTitle}」中首拼/全拼为空(或含数字名称需纠正)的记录并自动生成。确认执行?`,
|
||||
onOk: async () => {
|
||||
syncing.value = true;
|
||||
try {
|
||||
const res = await syncTcmDictPinyin(props.dictType);
|
||||
message.success(
|
||||
`同步完成:更新 ${Number(res?.updated || 0)} 条,跳过 ${Number(res?.skipped || 0)} 条`,
|
||||
);
|
||||
gridApi.query();
|
||||
} finally {
|
||||
syncing.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<Page auto-content-height :title="pageTitle">
|
||||
<FormModal :title="`${isUpdate ? '编辑' : '新增'}${pageTitle}`">
|
||||
<Form />
|
||||
</FormModal>
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '新增',
|
||||
type: 'primary',
|
||||
onClick: () => showModal({}, false),
|
||||
},
|
||||
{
|
||||
label: '批量删除',
|
||||
danger: true,
|
||||
ifShow: () => hasTopTableDropDownActions,
|
||||
popConfirm: {
|
||||
title: '确认删除所选?',
|
||||
confirm: () => {
|
||||
const ids = gridApi.grid
|
||||
.getCheckboxRecords()
|
||||
.map((r: any) => r.id);
|
||||
handleDelete(ids);
|
||||
},
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<Button class="ml-2" :loading="syncing" @click="handleSyncPinyin">
|
||||
同步拼音
|
||||
</Button>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{ label: '编辑', onClick: () => showModal(row, true) },
|
||||
{
|
||||
label: '删除',
|
||||
danger: true,
|
||||
popConfirm: {
|
||||
title: '确认删除?',
|
||||
confirm: () => handleDelete([row.id]),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
15
apps/web-antd/src/views/system/dict/tcm/diseases/index.vue
Normal file
15
apps/web-antd/src/views/system/dict/tcm/diseases/index.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 中医疾病字典(xk_traditional_chinese_medicine_diseases)
|
||||
*/
|
||||
import TcmDictListPage from '../components/TcmDictListPage.vue';
|
||||
|
||||
defineOptions({ name: 'TcmDiseasesDict' });
|
||||
</script>
|
||||
<template>
|
||||
<TcmDictListPage
|
||||
dict-type="diseases"
|
||||
page-title="中医疾病"
|
||||
page-name="TcmDiseasesDict"
|
||||
/>
|
||||
</template>
|
||||
15
apps/web-antd/src/views/system/dict/tcm/method/index.vue
Normal file
15
apps/web-antd/src/views/system/dict/tcm/method/index.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 中医治法字典(xk_traditional_chinese_medicine_method)
|
||||
*/
|
||||
import TcmDictListPage from '../components/TcmDictListPage.vue';
|
||||
|
||||
defineOptions({ name: 'TcmMethodDict' });
|
||||
</script>
|
||||
<template>
|
||||
<TcmDictListPage
|
||||
dict-type="method"
|
||||
page-title="中医治法"
|
||||
page-name="TcmMethodDict"
|
||||
/>
|
||||
</template>
|
||||
15
apps/web-antd/src/views/system/dict/tcm/syndrome/index.vue
Normal file
15
apps/web-antd/src/views/system/dict/tcm/syndrome/index.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 中医证候字典(xk_traditional_chinese_medicine_syndrome)
|
||||
*/
|
||||
import TcmDictListPage from '../components/TcmDictListPage.vue';
|
||||
|
||||
defineOptions({ name: 'TcmSyndromeDict' });
|
||||
</script>
|
||||
<template>
|
||||
<TcmDictListPage
|
||||
dict-type="syndrome"
|
||||
page-title="中医证候"
|
||||
page-name="TcmSyndromeDict"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 病历公共词条-按字段拆分页(从路由 path 末段解析 field_code)
|
||||
* 例:/system/medical-record/entry-public/chief-complaint → chief_complaint
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import EntryListPage from '#/views/doctor/medical-record/components/EntryListPage.vue';
|
||||
import { MEDICAL_RECORD_FIELD_OPTIONS } from '#/views/doctor/medical-record/config/constants';
|
||||
|
||||
defineOptions({ name: 'MedicalRecordEntryPublicField' });
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const fieldCode = computed(() => {
|
||||
const seg = (route.path.split('/').filter(Boolean).pop() || '').trim();
|
||||
return seg.replace(/-/g, '_');
|
||||
});
|
||||
|
||||
const fieldLabel = computed(() => {
|
||||
const hit = MEDICAL_RECORD_FIELD_OPTIONS.find((o) => o.value === fieldCode.value);
|
||||
return hit?.label || fieldCode.value;
|
||||
});
|
||||
|
||||
const pageTitle = computed(() => `公共词条·${fieldLabel.value}`);
|
||||
</script>
|
||||
<template>
|
||||
<EntryListPage
|
||||
:key="fieldCode"
|
||||
scope="public"
|
||||
:fixed-field-code="fieldCode"
|
||||
:page-title="pageTitle"
|
||||
:page-name="String(route.name || 'MedicalRecordEntryPublicField')"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 病历公共词条管理(平台)
|
||||
*/
|
||||
import EntryListPage from '#/views/doctor/medical-record/components/EntryListPage.vue';
|
||||
|
||||
defineOptions({ name: 'MedicalRecordEntryPublic' });
|
||||
</script>
|
||||
<template>
|
||||
<EntryListPage
|
||||
scope="public"
|
||||
page-title="病历公共词条"
|
||||
page-name="MedicalRecordEntryPublic"
|
||||
/>
|
||||
</template>
|
||||
@@ -15,3 +15,10 @@ export async function getPriceAdjustConfig(storeId?: number) {
|
||||
params: storeId ? { store_id: storeId } : {},
|
||||
});
|
||||
}
|
||||
|
||||
/** 按 key 批量读取系统配置(如 dict_search_rank_mode) */
|
||||
export async function getSystemConfigByKeys(keys: string[]) {
|
||||
return requestClient.get<any>(`${prefix}get-by-keys`, {
|
||||
params: { keys: keys.join(',') },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -40,6 +40,13 @@ const logisticsShowWhNameClinic = ref(true);
|
||||
const salespersonStoreEditEnabled = ref(false);
|
||||
/** 业务端单机登录:医生/药师/诊所管理员等同一账号仅 1 手机 + 1 网页(默认否) */
|
||||
const doctorSingleDeviceLogin = ref(false);
|
||||
/** 字典搜索匹配度:backend 服务端 / frontend 前端 */
|
||||
const dictSearchRankMode = ref<'backend' | 'frontend'>('backend');
|
||||
/**
|
||||
* 病历诊断/医嘱「常用|我的」展示位置
|
||||
* tags=文本域下标签(默认) bubble=气泡内展示 both=两边
|
||||
*/
|
||||
const mrCommonDisplayMode = ref<'tags' | 'bubble' | 'both'>('tags');
|
||||
/** 开票通知渠道:subscribe / sms,默认仅订阅消息 */
|
||||
const invoiceNoticeChannels = ref<string[]>(['subscribe']);
|
||||
|
||||
@@ -53,6 +60,8 @@ function readStoredTab() {
|
||||
stored === 'salesperson' ||
|
||||
stored === 'invoice_notice' ||
|
||||
stored === 'doctor_login' ||
|
||||
stored === 'dict_search' ||
|
||||
stored === 'medical_record' ||
|
||||
stored === 'oa_notify'
|
||||
) {
|
||||
activeKey.value = stored;
|
||||
@@ -121,6 +130,15 @@ async function load() {
|
||||
if (row.config_key === 'doctor_single_device_login') {
|
||||
doctorSingleDeviceLogin.value = parseBoolConfig(row.config_value, false);
|
||||
}
|
||||
if (row.config_key === 'dict_search_rank_mode') {
|
||||
dictSearchRankMode.value =
|
||||
String(row.config_value || '') === 'frontend' ? 'frontend' : 'backend';
|
||||
}
|
||||
if (row.config_key === 'mr_common_display_mode') {
|
||||
const mode = String(row.config_value || 'tags');
|
||||
mrCommonDisplayMode.value =
|
||||
mode === 'bubble' || mode === 'both' ? mode : 'tags';
|
||||
}
|
||||
if (row.config_key === 'invoice_notice_channels') {
|
||||
let channels: unknown = row.config_value;
|
||||
if (typeof channels === 'string') {
|
||||
@@ -202,6 +220,22 @@ async function handleSave() {
|
||||
description: '业务端单机登录(医生/药师/诊所管理员等:1手机+1网页)',
|
||||
sort: 310,
|
||||
},
|
||||
{
|
||||
config_key: 'dict_search_rank_mode',
|
||||
config_value: dictSearchRankMode.value,
|
||||
value_type: 'string',
|
||||
config_group: 'search',
|
||||
description: '字典搜索匹配度:backend服务端 / frontend前端',
|
||||
sort: 320,
|
||||
},
|
||||
{
|
||||
config_key: 'mr_common_display_mode',
|
||||
config_value: mrCommonDisplayMode.value,
|
||||
value_type: 'string',
|
||||
config_group: 'medical_record',
|
||||
description: '病历诊断医嘱常用展示:tags标签 / bubble气泡 / both两边',
|
||||
sort: 330,
|
||||
},
|
||||
{
|
||||
config_key: 'invoice_notice_channels',
|
||||
config_value: JSON.stringify(
|
||||
@@ -331,6 +365,33 @@ onMounted(() => {
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<Tabs.TabPane key="dict_search" tab="字典搜索">
|
||||
<div class="py-4">
|
||||
<div class="mb-2 font-medium">诊断 / 医嘱 / 病历词条匹配度处理端</div>
|
||||
<div class="mb-2 text-sm text-gray-500">
|
||||
后端:服务端计算匹配度并排序(适合疾病库等大数据量);前端:接口只返回候选,由端上打分排序(适合调试或减轻服务端 CPU)。关键字高亮始终在端上渲染。
|
||||
</div>
|
||||
<Radio.Group v-model:value="dictSearchRankMode">
|
||||
<Radio value="backend">后端处理(推荐)</Radio>
|
||||
<Radio value="frontend">前端处理</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<Tabs.TabPane key="medical_record" tab="词条">
|
||||
<div class="py-4">
|
||||
<div class="mb-2 font-medium">诊断 / 医嘱「常用|我的」展示位置</div>
|
||||
<div class="mb-2 text-sm text-gray-500">
|
||||
控制接诊病历里诊断、医嘱的常用快捷出现在哪里。默认「标签」即文本域下方的快捷标签;「气泡卡片」则在关键字搜索气泡内默认展示常用;「两边」同时展示。
|
||||
</div>
|
||||
<Radio.Group v-model:value="mrCommonDisplayMode">
|
||||
<Radio value="tags">标签(默认)</Radio>
|
||||
<Radio value="bubble">气泡卡片默认展示</Radio>
|
||||
<Radio value="both">两边都展示</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<Tabs.TabPane key="invoice_notice" tab="开票通知">
|
||||
<div class="py-4">
|
||||
<div class="mb-2 font-medium">患者开票通知渠道</div>
|
||||
|
||||
24
apps/web-antd/src/views/system/vip/feature/api/index.ts
Normal file
24
apps/web-antd/src/views/system/vip/feature/api/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
/** VIP 功能字典 API */
|
||||
const prefix = 'vip-feature/';
|
||||
|
||||
export async function getVipFeatureList(data: any) {
|
||||
return requestClient.get<any>(`${prefix}list`, { params: data });
|
||||
}
|
||||
|
||||
export async function getVipFeatureOption(data?: any) {
|
||||
return requestClient.get<any>(`${prefix}option`, { params: data });
|
||||
}
|
||||
|
||||
export async function createVipFeature(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}create`, data);
|
||||
}
|
||||
|
||||
export async function updateVipFeature(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}update`, data);
|
||||
}
|
||||
|
||||
export async function deleteVipFeature(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}delete`, data);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* VIP 功能字典新增/编辑弹窗
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
|
||||
import { createVipFeature, updateVipFeature } from '../api';
|
||||
import { modalFormProps } from '../config/form';
|
||||
|
||||
const isUpdate = ref(false);
|
||||
const gridApi = ref();
|
||||
const [Form, formApi] = useVbenForm(modalFormProps);
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
draggable: true,
|
||||
onCancel() {
|
||||
modalApi.close();
|
||||
},
|
||||
onConfirm: async () => {
|
||||
const e = await formApi.validate();
|
||||
if (!e.valid) return;
|
||||
const values = await formApi.getValues();
|
||||
modalApi.setState({ confirmLoading: true });
|
||||
try {
|
||||
const api = isUpdate.value ? updateVipFeature : createVipFeature;
|
||||
await api(values);
|
||||
message.success('保存成功');
|
||||
gridApi.value?.query();
|
||||
modalApi.close();
|
||||
} finally {
|
||||
modalApi.setState({ confirmLoading: false });
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
|
||||
if (!isOpen) {
|
||||
formApi.resetFields();
|
||||
return;
|
||||
}
|
||||
const { values, update } = modalApi.getData<Record<string, any>>() || {};
|
||||
isUpdate.value = !!update;
|
||||
if (values && update) {
|
||||
formApi.setValues({ ...values });
|
||||
} else {
|
||||
formApi.resetFields();
|
||||
formApi.setValues({ sort: 0, status: 1, description: '' });
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Modal :title="`${isUpdate ? '编辑' : '新增'}VIP功能`" class="w-[560px]">
|
||||
<Form />
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
/** VIP 功能字典常量 */
|
||||
export const STATUS_OPTIONS = [
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '禁用', value: 0 },
|
||||
];
|
||||
59
apps/web-antd/src/views/system/vip/feature/config/form.ts
Normal file
59
apps/web-antd/src/views/system/vip/feature/config/form.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { VbenFormProps } from '#/adapter/form';
|
||||
|
||||
import { STATUS_OPTIONS } from './constants';
|
||||
|
||||
/** VIP 功能字典表单 */
|
||||
export const modalFormProps: VbenFormProps = {
|
||||
wrapperClass: 'grid-cols-12',
|
||||
commonConfig: {
|
||||
formItemClass: 'col-span-12',
|
||||
componentProps: { class: 'w-full' },
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'id',
|
||||
label: 'ID',
|
||||
dependencies: { show: false, triggerFields: ['id'] },
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'code',
|
||||
label: '功能编码',
|
||||
rules: 'required',
|
||||
componentProps: { placeholder: 'snake_case,如 medical_record' },
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'name',
|
||||
label: '功能名称',
|
||||
rules: 'required',
|
||||
componentProps: { placeholder: '如:病历' },
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'description',
|
||||
label: '说明',
|
||||
componentProps: { type: 'textarea', rows: 2 },
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
fieldName: 'sort',
|
||||
label: '排序',
|
||||
formItemClass: 'col-span-6',
|
||||
componentProps: { min: 0 },
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
component: 'VbenSelect',
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
formItemClass: 'col-span-6',
|
||||
componentProps: { options: STATUS_OPTIONS },
|
||||
defaultValue: 1,
|
||||
},
|
||||
],
|
||||
showDefaultActions: false,
|
||||
};
|
||||
169
apps/web-antd/src/views/system/vip/feature/index.vue
Normal file
169
apps/web-antd/src/views/system/vip/feature/index.vue
Normal file
@@ -0,0 +1,169 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* VIP 功能字典列表:供等级 permissions 勾选的功能码维护
|
||||
*/
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Empty,
|
||||
Input,
|
||||
Pagination,
|
||||
Select,
|
||||
Tag,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import { TableAction } from '#/components/table-action';
|
||||
|
||||
import { deleteVipFeature, getVipFeatureList } from './api';
|
||||
import FormModal from './components/modal.vue';
|
||||
import { STATUS_OPTIONS } from './config/constants';
|
||||
|
||||
defineOptions({ name: 'VipFeature' });
|
||||
|
||||
const loading = ref(false);
|
||||
const list = ref<any[]>([]);
|
||||
const total = ref(0);
|
||||
const page = reactive({ current: 1, pageSize: 12 });
|
||||
const filters = reactive({
|
||||
code: '',
|
||||
name: '',
|
||||
status: undefined as number | undefined,
|
||||
});
|
||||
|
||||
const [FormModalComp, formModalApi] = useVbenModal({
|
||||
connectedComponent: FormModal,
|
||||
});
|
||||
|
||||
async function loadList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getVipFeatureList({
|
||||
page: page.current,
|
||||
pageSize: page.pageSize,
|
||||
code: filters.code || undefined,
|
||||
name: filters.name || undefined,
|
||||
status: filters.status,
|
||||
});
|
||||
list.value = res?.items || [];
|
||||
total.value = Number(res?.total || 0);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
page.current = 1;
|
||||
loadList();
|
||||
}
|
||||
|
||||
function onReset() {
|
||||
filters.code = '';
|
||||
filters.name = '';
|
||||
filters.status = undefined;
|
||||
onSearch();
|
||||
}
|
||||
|
||||
const showModal = (data: any = {}, isUpdate = false) => {
|
||||
formModalApi.setData({
|
||||
values: data,
|
||||
update: isUpdate,
|
||||
gridApi: { query: loadList, reload: loadList },
|
||||
});
|
||||
formModalApi.open();
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
deleteVipFeature({ ids: [id] }).then(() => {
|
||||
message.success('删除成功');
|
||||
loadList();
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(loadList);
|
||||
</script>
|
||||
<template>
|
||||
<Page auto-content-height title="VIP功能字典">
|
||||
<FormModalComp />
|
||||
<div class="p-4">
|
||||
<div class="mb-4 flex flex-wrap items-center gap-3">
|
||||
<Input
|
||||
v-model:value="filters.code"
|
||||
allow-clear
|
||||
placeholder="功能编码"
|
||||
class="w-40"
|
||||
@press-enter="onSearch"
|
||||
/>
|
||||
<Input
|
||||
v-model:value="filters.name"
|
||||
allow-clear
|
||||
placeholder="功能名称"
|
||||
class="w-40"
|
||||
@press-enter="onSearch"
|
||||
/>
|
||||
<Select
|
||||
v-model:value="filters.status"
|
||||
allow-clear
|
||||
placeholder="状态"
|
||||
class="w-28"
|
||||
:options="STATUS_OPTIONS"
|
||||
/>
|
||||
<Button type="primary" @click="onSearch">查询</Button>
|
||||
<Button @click="onReset">重置</Button>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '新增功能',
|
||||
type: 'primary',
|
||||
onClick: () => showModal({}, false),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="list.length" class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<Card v-for="item in list" :key="item.id" size="small" :loading="loading">
|
||||
<template #title>
|
||||
<span class="font-medium">{{ item.name }}</span>
|
||||
<Tag class="ml-2" :color="item.status === 1 ? 'success' : 'default'">
|
||||
{{ item.status_txt }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template #extra>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{ label: '编辑', onClick: () => showModal(item, true) },
|
||||
{
|
||||
label: '删除',
|
||||
danger: true,
|
||||
popConfirm: {
|
||||
title: '确认删除该功能?',
|
||||
confirm: () => handleDelete(item.id),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
<div class="text-sm text-gray-600">
|
||||
<div>编码:{{ item.code }}</div>
|
||||
<div class="mt-1">说明:{{ item.description || '-' }}</div>
|
||||
<div class="mt-1">排序:{{ item.sort }}</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<Empty v-else description="暂无功能,请先新增或执行 SQL 种子" />
|
||||
<div class="mt-4 flex justify-end">
|
||||
<Pagination
|
||||
v-model:current="page.current"
|
||||
v-model:page-size="page.pageSize"
|
||||
:total="total"
|
||||
show-size-changer
|
||||
@change="loadList"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* VIP 等级新增/编辑弹窗
|
||||
* permissions 从 VIP 功能字典 option 多选
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
|
||||
@@ -9,6 +10,7 @@ import { useVbenModal } from '@vben/common-ui';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { getVipFeatureOption } from '#/views/system/vip/feature/api';
|
||||
|
||||
import { createVipLevel, updateVipLevel } from '../api';
|
||||
import { modalFormProps } from '../config/form';
|
||||
@@ -37,12 +39,30 @@ const [Modal, modalApi] = useVbenModal({
|
||||
modalApi.setState({ confirmLoading: false });
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
|
||||
if (!isOpen) {
|
||||
formApi.resetFields();
|
||||
return;
|
||||
}
|
||||
// 打开时拉取功能字典,注入 permissions 下拉
|
||||
try {
|
||||
const opts = await getVipFeatureOption();
|
||||
formApi.updateSchema([
|
||||
{
|
||||
fieldName: 'permissions',
|
||||
componentProps: {
|
||||
mode: 'multiple',
|
||||
allowClear: true,
|
||||
optionFilterProp: 'label',
|
||||
placeholder: '从功能字典多选',
|
||||
options: Array.isArray(opts) ? opts : [],
|
||||
},
|
||||
},
|
||||
]);
|
||||
} catch (e) {
|
||||
console.error('加载 VIP 功能字典失败', e);
|
||||
}
|
||||
const { values, update } = modalApi.getData<Record<string, any>>() || {};
|
||||
isUpdate.value = !!update;
|
||||
if (values && update) {
|
||||
|
||||
@@ -72,11 +72,14 @@ export const modalFormProps: VbenFormProps = {
|
||||
{
|
||||
component: 'Select',
|
||||
fieldName: 'permissions',
|
||||
label: '权限码',
|
||||
label: 'VIP功能',
|
||||
help: '低等级勾选后,更高权重等级会自动补齐缺失功能',
|
||||
componentProps: {
|
||||
mode: 'tags',
|
||||
placeholder: '输入权限码后回车,预留业务权益',
|
||||
tokenSeparators: [','],
|
||||
mode: 'multiple',
|
||||
allowClear: true,
|
||||
optionFilterProp: 'label',
|
||||
placeholder: '从功能字典多选(需先在 VIP功能字典 维护)',
|
||||
options: [],
|
||||
},
|
||||
defaultValue: [],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user