feat: VIP功能绑定、VIP病历模块、字典模块、传方按钮移动到顶部导航栏

This commit is contained in:
李琦
2026-08-05 10:45:58 +08:00
parent 212b16a8ec
commit 029b45d0bb
43 changed files with 5821 additions and 153 deletions

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
/** 处方|病历 Tabv-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 {

View File

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

View File

@@ -0,0 +1,82 @@
import { requestClient } from '#/api/request';
/** 病历与词条 APIadmin 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 || {});
}

View File

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

View File

@@ -0,0 +1,304 @@
<script lang="ts" setup>
/**
* 病历词条列表(公共/门店共用)
* - scope=public → store_id=0scope=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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,27 @@
/**
* 疾病诊断字典 APIyii_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`, {});
}

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

View File

@@ -0,0 +1,27 @@
/**
* 公共医嘱字典 APIxk_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`, {});
}

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

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

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -0,0 +1,5 @@
/** VIP 功能字典常量 */
export const STATUS_OPTIONS = [
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 },
];

View 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,
};

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

View File

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

View File

@@ -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: [],
},