feat: 中药导入模块修复、部门财务的优化、队列的优化管理

This commit is contained in:
李琦
2026-08-07 13:17:07 +08:00
parent 9f37802ba7
commit 32eb96b4ad
19 changed files with 1317 additions and 604 deletions

View File

@@ -0,0 +1,658 @@
<script lang="ts" setup>
/**
* 中药药名气泡选择器
* - 对齐诊断/医嘱 EntryKeywordBubbleInput 聚焦弹出 Teleport 浮层
* - 远程搜索药品getProductListDoctorReception拼音/名称高亮
* - 选中后回传完整商品行,供接诊/复诊/特色方/常用方复用现有落库逻辑
*/
import {
computed,
nextTick,
onMounted,
onUnmounted,
ref,
watch,
} from 'vue';
import { LoadingOutlined, SearchOutlined } from '@ant-design/icons-vue';
import { useDebounceFn } from '@vueuse/core';
import { Empty, Input, Spin } from 'ant-design-vue';
import {
rankByMatchScore,
splitHighlight,
type HighlightPart,
} from '#/utils/dictSearchRank';
import { getProductListDoctorReception } from '#/views/doctor/doctor-reception/api';
export type ChineseDrugBubbleItem = {
id: number;
drug_id: number;
price: number;
buy_price?: number;
drug: Record<string, any>;
[key: string]: any;
};
const props = withDefaults(
defineProps<{
/** 已选药名(展示回填;打开搜索时仍可编辑关键词) */
displayName?: string;
/** 药品类型1 中药,颗粒等由调用方传入 */
type?: number;
storeId?: number;
registerId?: number;
placeholder?: string;
disabled?: boolean;
/** 卡片内紧凑宽度 */
compact?: boolean;
/** 额外 class便于焦点跳转 querySelector */
inputClass?: string;
}>(),
{
displayName: '',
type: 1,
storeId: 0,
registerId: 0,
placeholder: '药名',
disabled: false,
compact: false,
inputClass: '',
},
);
const emit = defineEmits<{
select: [item: ChineseDrugBubbleItem];
}>();
const ROW_HEIGHT = 44;
const HINT_HEIGHT = 34;
const HEAD_HEIGHT = 32;
const PANEL_PAD = 8;
const PREFERRED_PANEL = 320;
const containerRef = ref<HTMLElement | null>(null);
const panelRef = ref<HTMLElement | null>(null);
const listRef = ref<HTMLElement | null>(null);
const inputRef = ref<{ focus?: () => void } | null>(null);
const keyword = ref('');
const loading = ref(false);
const open = ref(false);
/** 是否处于「正在搜」true 时不把 displayName 强写回 keyword */
const searching = ref(false);
const highlightIndex = ref(0);
const options = ref<ChineseDrugBubbleItem[]>([]);
const scrollTop = ref(0);
const listViewportH = ref(200);
const panelStyle = ref<Record<string, string>>({});
const totalHeight = computed(() => options.value.length * ROW_HEIGHT);
const startIndex = computed(() =>
Math.max(0, Math.floor(scrollTop.value / ROW_HEIGHT) - 2),
);
const endIndex = computed(() => {
const visible = Math.ceil(listViewportH.value / ROW_HEIGHT) + 4;
return Math.min(options.value.length - 1, startIndex.value + visible);
});
const visibleRows = computed(() => {
const list = options.value;
const start = startIndex.value;
const end = endIndex.value;
if (list.length === 0 || end < start) return [];
return list.slice(start, end + 1).map((item, i) => ({
item,
index: start + i,
}));
});
const offsetY = computed(() => startIndex.value * ROW_HEIGHT);
/**
* 同步展示名:未打开搜索时用 displayName 回填输入框
*/
watch(
() => props.displayName,
(name) => {
if (!searching.value && !open.value) {
keyword.value = String(name || '');
}
},
{ immediate: true },
);
function partsOf(text: string): HighlightPart[] {
return splitHighlight(text, keyword.value.trim());
}
function drugNameOf(item: ChineseDrugBubbleItem): string {
return String(item?.drug?.drug_name || item?.drug_name || '').trim();
}
function unitLabelOf(item: ChineseDrugBubbleItem): string {
const unit = item?.drug?.unit || item?.unit;
if (typeof unit === 'object' && unit?.name) return String(unit.name);
return String(item?.drug?.specification || item?.specification || '').trim() || '-';
}
function priceLabelOf(item: ChineseDrugBubbleItem): string {
const p = Number(item?.price ?? 0);
return Number.isFinite(p) ? `¥${p.toFixed(2)}` : '-';
}
/**
* 面板 Teleport + fixed避免接诊/Modal overflow 裁切
*/
function updatePanelPlacement() {
const el = containerRef.value;
if (!el) return;
const rect = el.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
const gap = 4;
const spaceBelow = vh - rect.bottom - PANEL_PAD;
const spaceAbove = rect.top - PANEL_PAD;
const placeTop = spaceBelow < 180 && spaceAbove > spaceBelow;
const avail = Math.max(140, placeTop ? spaceAbove : spaceBelow);
const panelMax = Math.min(PREFERRED_PANEL, avail);
listViewportH.value = Math.max(80, panelMax - HINT_HEIGHT - HEAD_HEIGHT);
const PANEL_MIN_WIDTH = 420;
const width = Math.min(
Math.max(rect.width, PANEL_MIN_WIDTH),
vw - PANEL_PAD * 2,
);
let left = rect.left;
if (left + width > vw - PANEL_PAD) {
left = vw - PANEL_PAD - width;
}
if (left < PANEL_PAD) left = PANEL_PAD;
const base: Record<string, string> = {
position: 'fixed',
zIndex: '4000',
width: `${width}px`,
maxHeight: `${panelMax}px`,
left: `${left}px`,
right: 'auto',
};
if (placeTop) {
panelStyle.value = {
...base,
top: 'auto',
bottom: `${Math.max(PANEL_PAD, vh - rect.top + gap)}px`,
};
} else {
panelStyle.value = {
...base,
top: `${rect.bottom + gap}px`,
bottom: 'auto',
};
}
}
/**
* 远程搜药;空关键词不请求(与原中药 Select 一致)
*/
async function fetchDrugs(kw: string) {
const name = String(kw || '').trim();
if (!name) {
options.value = [];
loading.value = false;
return;
}
loading.value = true;
try {
const res = await getProductListDoctorReception({
name,
type: props.type,
store_id: props.storeId || 0,
...(props.registerId > 0 ? { register_id: props.registerId } : {}),
});
const list = Array.isArray(res) ? res : [];
const mapped: ChineseDrugBubbleItem[] = list.map((item: any) => {
const drug = item.drug || {};
return {
...item,
id: Number(item.id || 0),
drug_id: Number(item.drug_id || drug.id || 0),
price: Number(item.price ?? 0),
buy_price: item.buy_price,
drug: {
...drug,
drug_name: drug.drug_name || item.drug_name || '',
},
};
});
options.value = rankByMatchScore(
mapped,
name,
(it) => drugNameOf(it),
(it) =>
[
it.drug?.pinyin,
it.drug?.pinyin_full,
it.drug?.initials_of_pinyin,
it.drug?.specification,
]
.map((v) => String(v || ''))
.join(' '),
);
highlightIndex.value = options.value.length > 0 ? 0 : -1;
scrollTop.value = 0;
if (listRef.value) listRef.value.scrollTop = 0;
} catch (e) {
console.error('中药药名搜索失败:', e);
options.value = [];
} finally {
loading.value = false;
nextTick(() => updatePanelPlacement());
}
}
const debouncedFetch = useDebounceFn((kw: string) => {
void fetchDrugs(kw);
}, 280);
function openPanel() {
if (props.disabled) return;
open.value = true;
searching.value = true;
nextTick(() => {
updatePanelPlacement();
if (keyword.value.trim()) {
debouncedFetch(keyword.value);
} else {
options.value = [];
}
});
}
function closePanel(restoreDisplay = true) {
open.value = false;
searching.value = false;
options.value = [];
highlightIndex.value = 0;
if (restoreDisplay) {
keyword.value = String(props.displayName || '');
}
}
function handleFocus() {
if (props.disabled) return;
// 聚焦时清空为搜索态,便于直接输入新药名
if (props.displayName && keyword.value === props.displayName) {
keyword.value = '';
}
openPanel();
}
function handleInput() {
searching.value = true;
if (!open.value) openPanel();
else updatePanelPlacement();
debouncedFetch(keyword.value);
}
function selectItem(item: ChineseDrugBubbleItem) {
const name = drugNameOf(item);
keyword.value = name;
searching.value = false;
open.value = false;
options.value = [];
emit('select', item);
}
/**
* 键盘高亮循环移动(与诊断 EntryKeywordBubble 一致:第一项再上键 → 最后一项)
*/
function moveHighlight(delta: number) {
if (options.value.length === 0) return;
const len = options.value.length;
let next = highlightIndex.value;
if (next < 0) next = 0;
else next = (next + delta + len) % len;
highlightIndex.value = next;
ensureRowVisible(highlightIndex.value);
}
function handleKeydown(e: KeyboardEvent) {
if (!open.value) {
if (e.key === 'ArrowDown' || e.key === 'Enter') {
e.preventDefault();
openPanel();
}
return;
}
if (e.key === 'Escape') {
e.preventDefault();
closePanel(true);
return;
}
if (options.value.length === 0) return;
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
e.preventDefault();
moveHighlight(1);
} else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
e.preventDefault();
moveHighlight(-1);
} else if (e.key === 'Enter') {
e.preventDefault();
const hit = options.value[highlightIndex.value];
if (hit) selectItem(hit);
}
}
function ensureRowVisible(index: number) {
const el = listRef.value;
if (!el || index < 0) return;
const top = index * ROW_HEIGHT;
const bottom = top + ROW_HEIGHT;
if (top < el.scrollTop) el.scrollTop = top;
else if (bottom > el.scrollTop + el.clientHeight) {
el.scrollTop = bottom - el.clientHeight;
}
// 同步虚拟列表切片(循环跳到首/尾时必须刷新可见区)
scrollTop.value = el.scrollTop;
}
function onListScroll() {
scrollTop.value = listRef.value?.scrollTop || 0;
}
function onDocPointerDown(e: MouseEvent) {
if (!open.value) return;
const t = e.target as Node;
if (containerRef.value?.contains(t)) return;
if (panelRef.value?.contains(t)) return;
closePanel(true);
}
function onWinChange() {
if (open.value) updatePanelPlacement();
}
onMounted(() => {
document.addEventListener('mousedown', onDocPointerDown, true);
window.addEventListener('resize', onWinChange);
window.addEventListener('scroll', onWinChange, true);
});
onUnmounted(() => {
document.removeEventListener('mousedown', onDocPointerDown, true);
window.removeEventListener('resize', onWinChange);
window.removeEventListener('scroll', onWinChange, true);
});
defineExpose({
focus() {
nextTick(() => {
const el = inputRef.value as any;
el?.focus?.();
const native = containerRef.value?.querySelector(
'input',
) as HTMLInputElement | null;
native?.focus();
});
},
close() {
closePanel(true);
},
});
</script>
<template>
<div
ref="containerRef"
class="chinese-drug-name-bubble"
:class="[{ 'chinese-drug-name-bubble--compact': compact }, inputClass]"
>
<Input
ref="inputRef"
v-model:value="keyword"
:placeholder="placeholder"
:disabled="disabled"
allow-clear
size="small"
@input="handleInput"
@focus="handleFocus"
@keydown="handleKeydown"
>
<template #prefix>
<LoadingOutlined v-if="loading" class="text-muted-foreground" />
<SearchOutlined v-else class="text-muted-foreground" />
</template>
</Input>
<Teleport to="body">
<div
v-if="open"
ref="panelRef"
class="chinese-drug-name-bubble__panel"
:style="panelStyle"
>
<div class="chinese-drug-name-bubble__hint">
<span> 循环切换 · Enter 选用 · Esc 关闭</span>
<span class="chinese-drug-name-bubble__count">
<template v-if="keyword.trim()">搜到 {{ options.length }} </template>
<template v-else>输入药名搜索</template>
</span>
</div>
<Spin :spinning="loading">
<Empty
v-if="!loading && options.length === 0"
:image="Empty.PRESENTED_IMAGE_SIMPLE"
:description="
keyword.trim() ? '未找到匹配药材' : '请输入药名/拼音搜索'
"
class="py-4"
/>
<template v-else>
<div
class="chinese-drug-name-bubble__head"
:class="{ 'has-score': !!keyword.trim() }"
>
<span class="col-name">药名</span>
<span class="col-spec">规格/单位</span>
<span class="col-price">价格</span>
<span v-if="keyword.trim()" class="col-score">匹配度</span>
</div>
<div
ref="listRef"
class="chinese-drug-name-bubble__list"
:style="{ height: `${listViewportH}px` }"
@scroll="onListScroll"
>
<div
class="chinese-drug-name-bubble__phantom"
:style="{ height: `${totalHeight}px` }"
/>
<div
class="chinese-drug-name-bubble__virtual"
:style="{ transform: `translateY(${offsetY}px)` }"
>
<div
v-for="{ item, index } in visibleRows"
:key="`${item.drug_id}_${index}`"
class="chinese-drug-name-bubble__row"
:class="{
'is-active': index === highlightIndex,
'has-score': !!keyword.trim(),
}"
:style="{ height: `${ROW_HEIGHT}px` }"
@mousedown.prevent="selectItem(item)"
@mouseenter="highlightIndex = index"
>
<span class="col-name" :title="drugNameOf(item)">
<template
v-for="(p, pi) in partsOf(drugNameOf(item))"
:key="`n_${pi}`"
>
<em v-if="p.hit" class="hit">{{ p.text }}</em>
<template v-else>{{ p.text }}</template>
</template>
</span>
<span class="col-spec" :title="unitLabelOf(item)">
{{ unitLabelOf(item) }}
</span>
<span class="col-price">{{ priceLabelOf(item) }}</span>
<span v-if="keyword.trim()" class="col-score">
{{ Number(item.match_score || 0) }}%
</span>
</div>
</div>
</div>
</template>
</Spin>
</div>
</Teleport>
</div>
</template>
<style scoped>
.chinese-drug-name-bubble {
position: relative;
display: inline-block;
vertical-align: middle;
box-sizing: border-box;
/* 锁死宽度:避免接诊/复诊/特色方/常用方 flex 布局把药名框撑满整行 */
width: 100px !important;
min-width: 88px !important;
max-width: 110px !important;
flex: 0 0 100px !important;
overflow: hidden;
}
.chinese-drug-name-bubble--compact {
width: 100px !important;
min-width: 88px !important;
max-width: 110px !important;
flex: 0 0 100px !important;
}
.chinese-drug-name-bubble :deep(.ant-input-affix-wrapper) {
width: 100% !important;
max-width: 100% !important;
min-width: 0 !important;
padding-inline: 6px;
box-sizing: border-box;
}
.chinese-drug-name-bubble :deep(.ant-input-prefix) {
margin-inline-end: 2px;
}
.chinese-drug-name-bubble :deep(.ant-input) {
width: 100% !important;
min-width: 0 !important;
overflow: hidden;
text-overflow: ellipsis;
}
.chinese-drug-name-bubble__panel {
overflow: hidden;
display: flex;
flex-direction: column;
border-radius: 8px;
border: 1px solid hsl(var(--border));
background: hsl(var(--popover, var(--background)));
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
.chinese-drug-name-bubble__hint {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 6px 12px;
font-size: 12px;
color: hsl(var(--muted-foreground));
border-bottom: 1px solid hsl(var(--border));
background: hsl(var(--muted) / 0.35);
}
.chinese-drug-name-bubble__count {
flex-shrink: 0;
font-weight: 600;
color: hsl(var(--foreground));
}
.chinese-drug-name-bubble__list {
position: relative;
overflow-y: auto;
overflow-x: hidden;
}
.chinese-drug-name-bubble__phantom {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: -1;
pointer-events: none;
}
.chinese-drug-name-bubble__virtual {
position: absolute;
top: 0;
left: 0;
right: 0;
}
.chinese-drug-name-bubble__head,
.chinese-drug-name-bubble__row {
display: grid;
grid-template-columns: 1fr 100px 72px;
gap: 8px;
padding: 6px 12px;
font-size: 13px;
align-items: center;
box-sizing: border-box;
}
.chinese-drug-name-bubble__head.has-score,
.chinese-drug-name-bubble__row.has-score {
grid-template-columns: 1fr 90px 64px 48px;
}
.chinese-drug-name-bubble__head {
flex-shrink: 0;
font-weight: 600;
color: hsl(var(--muted-foreground));
background: hsl(var(--popover, var(--background)));
border-bottom: 1px solid hsl(var(--border));
}
.chinese-drug-name-bubble__row {
cursor: pointer;
border-bottom: 1px solid hsl(var(--border) / 0.6);
}
.chinese-drug-name-bubble__row:hover,
.chinese-drug-name-bubble__row.is-active {
background: hsl(var(--accent));
color: hsl(var(--accent-foreground));
}
.col-name,
.col-spec {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.col-name {
font-weight: 500;
}
.col-spec {
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.col-price {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.col-score {
text-align: right;
font-variant-numeric: tabular-nums;
font-weight: 600;
color: #d4380d;
white-space: nowrap;
}
.chinese-drug-name-bubble__row.is-active .col-spec {
color: inherit;
opacity: 0.85;
}
.chinese-drug-name-bubble__row.is-active .col-score {
color: inherit;
}
.hit {
font-style: normal;
color: #cf1322;
background: rgba(255, 214, 102, 0.55);
border-radius: 2px;
padding: 0 1px;
}
</style>

View File

@@ -0,0 +1,5 @@
/**
* 中药药名气泡选择器
*/
export { default as ChineseDrugNameBubble } from './chinese-drug-name-bubble.vue';
export type { ChineseDrugBubbleItem } from './chinese-drug-name-bubble.vue';

View File

@@ -105,7 +105,9 @@ const downloadQRCode = async (name: string, isQr = false) => {
{{ doctorInfo.doctor.name }} {{ doctorInfo.doctor.title.name }}
</div>
<div class="qrAddress">
<span class="addressTitle">诊所地址</span>{{ previewAddress }}
<span class="addressTitle">{{
Number(doctorInfo?.store?.type) === 1 ? '药店地址:' : '诊所地址:'
}}</span>{{ previewAddress }}
</div>
<Image :preview="false" :src="doctorInfo.qr_code" class="mt-3" height="30" width="30" />
<h3 class="border-l-4 border-blue-500 pl-3 text-lg font-semibold">

View File

@@ -1,5 +1,9 @@
<script lang="ts" setup>
import { ref } from 'vue';
/**
* 诊所/药店二维码预览弹窗
* 按门店 type0 诊所 / 1 药店)切换「诊所|药店」文案,列表页与门店卡片共用
*/
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
@@ -11,6 +15,13 @@ const htmlToImage = ref('');
const url = ref('');
const previewTitle = ref('二维码');
const previewAddress = ref('');
/** 门店类型0=诊所 1=药店,未传时按诊所示 */
const storeType = ref(0);
const isPharmacy = computed(() => Number(storeType.value) === 1);
const kindLabel = computed(() => (isPharmacy.value ? '药店' : '诊所'));
const addressLabel = computed(() => `${kindLabel.value}地址:`);
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
footer: false,
@@ -28,21 +39,28 @@ const [Modal, modalApi] = useVbenModal({
},
onOpenChange(isOpen: boolean) {
if (isOpen) {
const { values, title, address } =
modalApi.getData<Record<string, any>>();
const { values, title, address, type, store_type } =
modalApi.getData<Record<string, any>>() || {};
if (values) {
url.value = values;
qrCodeUrl.value = values;
previewTitle.value = title;
previewAddress.value = address;
// 兼容 type / store_type 两种传参
storeType.value = Number(type ?? store_type ?? 0);
modalApi.setState({
confirmText: `保存${kindLabel.value}卡片图片`,
cancelText: `保存${kindLabel.value}二维码图片`,
});
}
}
},
});
/**
* 下载二维码
* @param name
* @param isQr
* 下载二维码卡片或纯二维码图片
* @param name 文件名后缀
* @param isQr true=只截二维码区域false=截整张卡片
*/
const downloadQRCode = async (name: string, isQr = false) => {
const element = isQr ? qrCodeUrl.value : htmlToImage.value;
@@ -51,12 +69,11 @@ const downloadQRCode = async (name: string, isQr = false) => {
allowTaint: true,
logging: false,
}).then((canvas) => {
// 创建a标签下载
const link = document.createElement('a'); // 创建a标签
link.href = canvas.toDataURL(); // 是canvas对象的一种方法用于将canvas对象转换为base64位编码
link.setAttribute('download', `${previewTitle.value}${name}.png`); // 利用了a标签的download 来下载 canvas图片
link.style.display = 'none'; // 将图片隐藏起来
document.body.append(link); // 插入到其中
const link = document.createElement('a');
link.href = canvas.toDataURL();
link.setAttribute('download', `${previewTitle.value}${name}.png`);
link.style.display = 'none';
document.body.append(link);
link.click();
});
};
@@ -69,7 +86,7 @@ const downloadQRCode = async (name: string, isQr = false) => {
<div class="qrTitle">{{ previewTitle }}</div>
<Image :preview="false" :src="url" height="30" width="30" />
<div class="qrAddress">
<span class="addressTitle">诊所地址</span>{{ previewAddress }}
<span class="addressTitle">{{ addressLabel }}</span>{{ previewAddress }}
</div>
</div>
</div>

View File

@@ -71,10 +71,13 @@ const stats = computed(() => cardData.value?.stats_summary ?? {});
const doctorList = computed(() => cardData.value?.doctor_list ?? []);
const isClinic = computed(() => Number(store.value?.type) === 0);
const isPharmacy = computed(() => Number(store.value?.type) === 1);
/** 按门店类型区分诊所/药店文案,避免药店仍显示「诊所」 */
const storeKindLabel = computed(() => (isPharmacy.value ? '药店' : '诊所'));
const qrTabLabel = computed(() => `${storeKindLabel.value}二维码`);
const modalTitle = computed(() => {
const name = store.value?.name;
if (!name) return '门店详情';
return isPharmacy.value ? `药店详情 · ${name}` : `诊所详情 · ${name}`;
return `${storeKindLabel.value}详情 · ${name}`;
});
const drugTypeOptions = [
@@ -479,7 +482,7 @@ function clinicTypeText(v: number) {
</div>
</Tabs.TabPane>
<Tabs.TabPane key="qr" tab="门店二维码">
<Tabs.TabPane key="qr" :tab="qrTabLabel">
<div class="flex flex-col items-center gap-4 py-6 pl-4">
<template v-if="cardData?.qr_code || store.qr_code">
<Image
@@ -488,13 +491,13 @@ function clinicTypeText(v: number) {
:preview="true"
/>
<div class="text-sm text-muted-foreground">
{{ store.name }} 门店二维码
{{ store.name }} {{ qrTabLabel }}
</div>
</template>
<template v-else>
<Empty description="尚未生成二维码" />
<Empty :description="`尚未生成${storeKindLabel}二维码`" />
<Button type="primary" :loading="generatingQr" @click="ensureQrCode">
生成二维码
生成{{ storeKindLabel }}二维码
</Button>
</template>
</div>