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

This commit is contained in:
李琦
2026-08-06 10:08:02 +08:00
parent dc9f9c5c13
commit c1bc234509
6 changed files with 189 additions and 26 deletions

View File

@@ -92,6 +92,8 @@ export function splitHighlight(
}
let cachedRankMode: DictSearchRankMode | null = null;
/** 多气泡实例共享同一请求,避免进页 N 次 get-by-keys */
let rankModeFetchPromise: Promise<DictSearchRankMode> | null = null;
/** 写入缓存(从系统配置或接口响应读取后调用) */
export function setDictSearchRankMode(mode: string | undefined | null) {
@@ -102,6 +104,32 @@ export function getDictSearchRankMode(): DictSearchRankMode {
return cachedRankMode || 'backend';
}
export function isDictSearchRankModeLoaded(): boolean {
return cachedRankMode != null;
}
/**
* 确保已加载匹配度模式:已缓存则跳过;并发调用共享同一 Promise
* @param loader 真正打网取配置的函数,返回 frontend|backend
*/
export async function ensureDictSearchRankMode(
loader: () => Promise<string | undefined | null>,
): Promise<DictSearchRankMode> {
if (cachedRankMode != null) return cachedRankMode;
if (!rankModeFetchPromise) {
rankModeFetchPromise = (async () => {
try {
const mode = await loader();
setDictSearchRankMode(mode);
} catch {
setDictSearchRankMode('backend');
}
return cachedRankMode || 'backend';
})();
}
return rankModeFetchPromise;
}
/**
* 按当前模式决定是否前端重排;有关键字时保证按匹配度降序,并尽量带上可展示的 match_score
*/

View File

@@ -177,6 +177,10 @@ const tabType = ref(0);
const rxMrTab = ref<'rx' | 'mr'>('rx');
const medicalRecordPanelRef = ref<InstanceType<typeof MedicalRecordPanel> | null>(null);
const canUseMedicalRecord = computed(() => hasVipPermission('medical_record'));
/** AI 辅助出方 VIP */
const canUseAiPrescription = computed(() => hasVipPermission('ai_prescription'));
/** AI 写病历 VIP */
const canUseAiMedicalRecord = computed(() => hasVipPermission('ai_medical_record'));
watch(rxMrTab, (tab) => {
const rid = getRegisterId();
@@ -225,9 +229,20 @@ const prescriptionTypeLoading = ref(false);
const prescriptionTypeAllowed = ref(true);
const prescriptionTypeDenyMessage = ref('');
/** 同参处方类型请求去重setup + selectPatient 易双打) */
let prescriptionTypeInflight: {
key: string;
promise: Promise<boolean>;
} | null = null;
/** 拉取处方类型列表与默认选中;无权限时清空并返回 false */
async function loadPrescriptionTypeOptions(registerId?: number) {
const key = `${Number(registerId || 0)}_${Number(myStoreId.value || 0)}`;
if (prescriptionTypeInflight?.key === key) {
return prescriptionTypeInflight.promise;
}
prescriptionTypeLoading.value = true;
const run = (async () => {
try {
const params: { register_id?: number; store_id?: number } = {};
if (registerId) params.register_id = registerId;
@@ -260,7 +275,13 @@ async function loadPrescriptionTypeOptions(registerId?: number) {
return false;
} finally {
prescriptionTypeLoading.value = false;
if (prescriptionTypeInflight?.key === key) {
prescriptionTypeInflight = null;
}
}
})();
prescriptionTypeInflight = { key, promise: run };
return run;
}
/** 将当前分类 Tab 滚入可视区 */
@@ -307,6 +328,28 @@ const doctorReceptionRegisterId = computed(() => {
);
});
/** 门店类型接口短时缓存,供毛利率 / 转诊提示共用,避免进页双打 */
let storeTypeCache: { key: string; data: any; at: number } | null = null;
const STORE_TYPE_CACHE_TTL = 30_000;
async function getCurrentStoreTypeCached(params: {
register_id?: number;
store_id?: number;
}) {
const key = `${Number(params.store_id || 0)}_${Number(params.register_id || 0)}`;
const now = Date.now();
if (
storeTypeCache &&
storeTypeCache.key === key &&
now - storeTypeCache.at < STORE_TYPE_CACHE_TTL
) {
return storeTypeCache.data;
}
const data = await getCurrentStoreTypeApi(params);
storeTypeCache = { key, data, at: now };
return data;
}
async function fetchStoreSeeRate() {
try {
const registerId = Number.parseInt(
@@ -319,7 +362,7 @@ async function fetchStoreSeeRate() {
if (registerId > 0) {
params.register_id = registerId;
}
const res = await getCurrentStoreTypeApi(params);
const res = await getCurrentStoreTypeCached(params);
seeRate.value = Number(res?.see_rate ?? 0);
allowInsuranceCategory.value = Number(res?.allow_insurance_category ?? 0);
priceAdjustEnabled.value = Number(res?.enable_order_price_percent_adjust ?? 0) === 1;
@@ -1270,6 +1313,10 @@ const aiPrescriptionDrawerRef = ref<InstanceType<typeof AiPrescriptionDrawer> |
/** 打开 AI 给处方抽屉:先确认患者与主诉,再生成预览 */
function openAiPrescriptionDrawer() {
if (!canUseAiPrescription.value) {
message.warning('当前门店未开通AI辅助出方VIP功能');
return;
}
if (guardSpecialPrescriptionCartEdit()) return;
const registerId = Number.parseInt(
localStorage.getItem(`doctorReception-id`) || '0',
@@ -1781,16 +1828,14 @@ async function checkAndShowTransferTip() {
localStorage.getItem('doctorReception-id') || '0',
10,
);
const storeInfo = await getCurrentStoreTypeApi({
const storeInfo = await getCurrentStoreTypeCached({
store_id: myStoreId.value,
...(registerId > 0 ? { register_id: registerId } : {}),
});
// const storeInfo = res;
if (!storeInfo) {
return;
}
console.log(storeInfo, 'aaaaaaaaaaaaaaaaa')
// 检测是否为西医诊所clinic_type === 1
if (storeInfo.clinic_type === 1 && storeInfo.delegate_store) {
@@ -3121,7 +3166,7 @@ function onStoreSelectOpenChange(open: boolean) {
选择常用方
</Button>
<Button
v-if="!isSpecialPrescriptionCartLocked"
v-if="!isSpecialPrescriptionCartLocked && canUseAiPrescription"
type="link"
size="small"
@click="openAiPrescriptionDrawer"
@@ -3190,6 +3235,7 @@ function onStoreSelectOpenChange(open: boolean) {
引用历史病历
</Button>
<Button
v-if="canUseAiMedicalRecord"
type="link"
size="small"
@click="medicalRecordPanelRef?.aiGenerate?.()"
@@ -3923,7 +3969,7 @@ function onStoreSelectOpenChange(open: boolean) {
</div>
<div
v-show="canUseMedicalRecord && rxMrTab === 'mr'"
v-if="canUseMedicalRecord && rxMrTab === 'mr'"
class="medical-record-wrap"
>
<MedicalRecordPanel

View File

@@ -24,6 +24,7 @@ import {
} from '@ant-design/icons-vue';
import { getSystemConfigByKeys } from '#/views/system/system-config/api';
import { hasVipPermission } from '#/utils/vip';
import {
clearMedicalRecord,
@@ -102,6 +103,10 @@ const showCommonInBubble = computed(
() =>
commonDisplayMode.value === 'bubble' || commonDisplayMode.value === 'both',
);
/** AI写病历入口门店 VIP 需开通 ai_medical_record */
const canUseAiMedicalRecord = computed(() =>
hasVipPermission('ai_medical_record'),
);
/** 标签区刷新(气泡内新增/加常用后) */
const commonChipsTick = ref(0);
@@ -280,6 +285,8 @@ const debouncedPersistLocal = useDebounceFn(persistLocalDraft, 300);
*/
async function loadRecord() {
if (!props.registerId) return;
// 无病历 VIP 不请求避免弹出「未开通病历VIP」普通诊所只看病历 Tab 隐藏即可
if (!hasVipPermission('medical_record')) return;
loading.value = true;
hydrating.value = true;
try {
@@ -588,6 +595,10 @@ async function handleSave() {
* 打开 AI 写病历抽屉:先进历史;无历史自动打开生成框
*/
function handleAiGenerate() {
if (!canUseAiMedicalRecord.value) {
message.warning('当前门店未开通AI写病历VIP功能');
return;
}
if (!props.registerId) {
message.warning('无挂号信息');
return;
@@ -804,7 +815,13 @@ const visibleNormalFields = computed(() =>
<HistoryOutlined />
引用历史病历
</Button>
<Button type="link" size="small" :loading="loading" @click="handleAiGenerate">
<Button
v-if="canUseAiMedicalRecord"
type="link"
size="small"
:loading="loading"
@click="handleAiGenerate"
>
<RobotOutlined />
AI写病历
</Button>

View File

@@ -9,10 +9,11 @@ import { computed, onMounted, ref, watch } from 'vue';
import { Tag } from 'ant-design-vue';
import {
getMyMedicalRecordEntryList,
} from '#/views/doctor/medical-record/api';
import { getDoctorOrderList } from '#/views/doctor/doctor-reception/api';
import { getDoctorMyDiseaseListApi } from '#/views/doctor/settings/api';
fetchDoctorOrderListCached,
fetchMyDiseaseListCached,
fetchMyEntriesByField,
invalidateCommonChipsCache,
} from '../utils/commonChipsCache';
const props = withDefaults(
defineProps<{
@@ -63,13 +64,13 @@ const activeSet = computed(() => {
});
/**
* 拉取常用项
* 拉取常用项(诊断/医嘱/词条均走模块缓存,进页多实例只打一次网)
*/
async function loadChips() {
loading.value = true;
try {
if (props.source === 'diagnosis') {
const list = await getDoctorMyDiseaseListApi().catch(() => []);
const list = await fetchMyDiseaseListCached();
chips.value = (list || [])
.map((item: any) => {
const d = item?.disease || item;
@@ -85,10 +86,8 @@ async function loadChips() {
chips.value = [];
return;
}
const list = await getMyMedicalRecordEntryList({
field_code: props.fieldCode,
limit: props.limit,
}).catch(() => []);
// 全量 my-entry-list 一次,再按 field_code 本地切片
const list = await fetchMyEntriesByField(props.fieldCode, props.limit);
chips.value = (list || [])
.map((item: any) => {
const text = String(item?.content || item?.title || '').trim();
@@ -100,7 +99,7 @@ async function loadChips() {
.slice(0, props.limit) as ChipItem[];
return;
}
const res = await getDoctorOrderList().catch(() => ({ my: [], common: [] }));
const res = await fetchDoctorOrderListCached();
const my = (res?.my || [])
.map((item: any) => {
const text = String(item?.content || '').trim();
@@ -123,6 +122,12 @@ async function loadChips() {
}
}
/** 强制刷新:清缓存后重拉(新增/删除常用后用) */
async function reloadChips() {
invalidateCommonChipsCache(props.source);
await loadChips();
}
function onPick(item: ChipItem) {
if (props.disabled || !item?.text) return;
emit('select', item.text);
@@ -145,7 +150,7 @@ onMounted(() => {
loadChips();
});
defineExpose({ reload: loadChips });
defineExpose({ reload: reloadChips });
</script>
<template>
<div v-if="chips.length > 0" class="dx-order-chips">

View File

@@ -13,8 +13,8 @@ import { Button, Empty, Input, Modal, Spin, message } from 'ant-design-vue';
import {
applyDictRankIfNeeded,
ensureDictSearchRankMode,
rankByMatchScore,
setDictSearchRankMode,
splitHighlight,
type HighlightPart,
} from '#/utils/dictSearchRank';
@@ -252,14 +252,14 @@ function matchTextOrPinyin(text: string, pinyin: string, kw: string): boolean {
return !!py && py.includes(k);
}
/**
* 匹配度模式:多气泡共用 ensureDictSearchRankMode进页只打一次 get-by-keys
*/
async function loadRankModeConfig() {
try {
await ensureDictSearchRankMode(async () => {
const res = await getSystemConfigByKeys(['dict_search_rank_mode']);
const mode = res?.dict_search_rank_mode ?? res?.data?.dict_search_rank_mode;
if (mode) setDictSearchRankMode(String(mode));
} catch {
// 默认 backend
}
return res?.dict_search_rank_mode ?? res?.data?.dict_search_rank_mode;
});
}
/**

View File

@@ -0,0 +1,67 @@
/**
* 接诊/病历常用标签请求缓存
* 进页时多个 CommonDxOrderChips 会同时挂载,合并为单次请求再本地按字段切片
*/
import { getMyMedicalRecordEntryList } from '../api';
import { getDoctorOrderList } from '#/views/doctor/doctor-reception/api';
import { getDoctorMyDiseaseListApi } from '#/views/doctor/settings/api';
type OrderListResult = { my?: any[]; common?: any[] };
let entryAllPromise: Promise<any[]> | null = null;
let diseasePromise: Promise<any[]> | null = null;
let orderPromise: Promise<OrderListResult> | null = null;
/** 使缓存失效(新增/删除常用后调用) */
export function invalidateCommonChipsCache(
kind?: 'entry' | 'diagnosis' | 'doctor_order' | 'all',
) {
const k = kind || 'all';
if (k === 'all' || k === 'entry') entryAllPromise = null;
if (k === 'all' || k === 'diagnosis') diseasePromise = null;
if (k === 'all' || k === 'doctor_order') orderPromise = null;
}
/**
* 一次拉全量我的常用词条(不传 field_code
* 后端空 field 时上限 500覆盖多字段常用标签
*/
export function fetchMyEntryListAll(limit = 500): Promise<any[]> {
if (!entryAllPromise) {
entryAllPromise = getMyMedicalRecordEntryList({ limit })
.then((list) => (Array.isArray(list) ? list : []))
.catch(() => []);
}
return entryAllPromise;
}
/** 按 field_code 从全量缓存中切出该字段常用(最多 limit 条) */
export async function fetchMyEntriesByField(
fieldCode: string,
limit = 12,
): Promise<any[]> {
const all = await fetchMyEntryListAll();
const code = String(fieldCode || '').trim();
if (!code) return [];
return all
.filter((item) => String(item?.field_code || '') === code)
.slice(0, limit);
}
export function fetchMyDiseaseListCached(): Promise<any[]> {
if (!diseasePromise) {
diseasePromise = getDoctorMyDiseaseListApi()
.then((list) => (Array.isArray(list) ? list : []))
.catch(() => []);
}
return diseasePromise;
}
export function fetchDoctorOrderListCached(): Promise<OrderListResult> {
if (!orderPromise) {
orderPromise = getDoctorOrderList()
.then((res) => res || { my: [], common: [] })
.catch(() => ({ my: [], common: [] }));
}
return orderPromise;
}