feat: 病历模块优化、AI辅助问诊
This commit is contained in:
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user