1. 修复了接诊页面的剂量、天数输入框过窄

This commit is contained in:
李琦
2026-07-15 08:46:31 +08:00
parent 316bbabcd1
commit 8eca0bd84d
66 changed files with 5242 additions and 393 deletions

View File

@@ -6,19 +6,20 @@
* - 支持中药/西药类型切换
* - 下拉选项展示:药品图片、名称、供应商、规格、价格
* - 选择时返回完整药品数据(包含默认用法字段)
* - isCard单选确认场景如仓绑药选中后以内嵌卡片展示已选药品
* @author 系统
* @date 2024
*/
import { ref, watch, onMounted, onUnmounted } from 'vue';
import { SearchOutlined, LoadingOutlined } from '@ant-design/icons-vue';
import { Input, Spin } from 'ant-design-vue';
import { Button, Input, Spin } from 'ant-design-vue';
import { debounce } from 'lodash-es';
import { getProductListDoctorReception } from '#/views/doctor/doctor-reception/api';
/** 中药无图时与仓库列表一致的占位图 */
const TCM_PLACEHOLDER_IMAGE =
/** 无图时占位(中药/西药等统一兜底,避免仓绑药下拉只剩文字) */
const DRUG_PLACEHOLDER_IMAGE =
'https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/xk_upload_20250510/cd470ebf97e31c4048ba502ba71b66a3.jpg';
// ==================== Props 定义 ====================
@@ -29,12 +30,17 @@ interface Props {
* 1-中药2-西药
*/
type?: number;
/**
* 多药品类型(优先于 type有值时支持空关键词拉全量
* 例如 [2,4] 表示西药+中成药
*/
types?: number[];
/**
* 占位提示文本
*/
placeholder?: string;
/**
* 诊所ID
* 诊所ID(接诊/开方必传;仓药绑定等平台场景可省略,默认 0 走主库 types 检索)
*/
storeId?: number;
/**
@@ -45,14 +51,21 @@ interface Props {
* 是否禁用
*/
disabled?: boolean;
/**
* 是否以卡片展示已选药品(仓绑药等单选确认场景;开方默认 false
*/
isCard?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
type: 1,
types: () => [],
placeholder: '输入药品名称搜索',
storeId: 2,
// 默认 0平台无门店诊所场景由调用方传 :store-id
storeId: 0,
registerId: undefined,
disabled: false,
isCard: false,
});
// ==================== Emits 定义 ====================
@@ -97,10 +110,21 @@ const containerRef = ref<HTMLElement | null>(null);
*/
const highlightIndex = ref(-1);
/**
* isCard 模式下已选药品(用于输入框下方卡片)
*/
const selectedDrug = ref<any | null>(null);
/**
* 搜索框引用,便于「重新选择」时聚焦
*/
const inputRef = ref<{ focus?: () => void } | null>(null);
/**
* 解析下拉展示图:优先真图,无图用统一占位
*/
function resolveDropdownImage(item: any): string {
if (item._image) return item._image;
if (props.type === 1) return TCM_PLACEHOLDER_IMAGE;
return '';
return item._image || DRUG_PLACEHOLDER_IMAGE;
}
// ==================== 方法定义 ====================
@@ -108,10 +132,12 @@ function resolveDropdownImage(item: any): string {
/**
* 搜索药品
* @param keyword 搜索关键词
* @description 调用后端API搜索药品支持按名称和拼音搜索
* @description 调用后端API搜索药品支持按名称和拼音搜索
* 传入 types 时允许空关键词拉取该类型全量列表(用于下拉展开)
*/
const searchDrugs = debounce(async (keyword: string) => {
if (!keyword || keyword.length < 1) {
// 单类型模式:空关键词清空;多类型模式:空关键词仍请求后端拉全量
if ((!keyword || keyword.length < 1) && props.types.length === 0) {
searchResults.value = [];
showDropdown.value = false;
return;
@@ -121,37 +147,41 @@ const searchDrugs = debounce(async (keyword: string) => {
showDropdown.value = true;
try {
// types 优先:有多类型时只传 types不传 type避免后端被单 type 覆盖
const typeParams =
props.types.length > 0
? { types: props.types }
: { type: props.type };
const res = await getProductListDoctorReception({
name: keyword,
type: props.type,
name: keyword || '',
...typeParams,
store_id: props.storeId,
...(props.registerId ? { register_id: props.registerId } : {}),
});
// 处理返回数据
// 处理返回数据(兼容门店 relation 与平台主库打平结构)
if (Array.isArray(res)) {
searchResults.value = res.map((item: any) => ({
// 保留原始数据
...item,
// 提取常用字段便于访问
_id: item.id,
_drugId: item.drug_id || item.drug?.id,
_drugName: item.drug?.drug_name || item.drug_name || '',
_image: item.drug?.image || '',
_specification: item.drug?.specification || '',
_supplier: item.drug?.supplier?.name || '',
_price: item.price || 0,
// 默认用法字段
_timeId: item.drug?.time_id || 0,
_typeId: item.drug?.type_id || 0,
_frequencyId: item.drug?.frequency_id || 0,
_unitId: item.drug?.unit_id || 0,
_number: item.drug?.number || 1,
// 用法名称
_useNum: item.drug?.useNum,
_useType: item.drug?.useType,
_useFrequency: item.drug?.useFrequency,
}));
searchResults.value = res.map((item: any) => {
const drug = item.drug || {};
return {
...item,
_id: item.id,
_drugId: item.drug_id || drug.id || item.id,
_drugName: drug.drug_name || item.drug_name || '',
_image: drug.image || item.image || '',
_specification: drug.specification || item.specification || '',
_supplier: drug.supplier?.name || item.supplier?.name || '',
_price: Number(item.price ?? 0),
_timeId: drug.time_id || 0,
_typeId: drug.type_id || 0,
_frequencyId: drug.frequency_id || 0,
_unitId: drug.unit_id || 0,
_number: drug.number || 1,
_useNum: drug.useNum,
_useType: drug.useType,
_useFrequency: drug.useFrequency,
};
});
} else {
searchResults.value = [];
}
@@ -176,8 +206,14 @@ function handleInput(e: Event) {
/**
* 处理输入框获得焦点
* @description 多类型模式聚焦即拉取列表并展开;单类型模式仅在已有结果时展示下拉
*/
function handleFocus() {
if (props.types.length > 0) {
searchDrugs('');
showDropdown.value = true;
return;
}
if (searchResults.value.length > 0) {
showDropdown.value = true;
}
@@ -186,15 +222,36 @@ function handleFocus() {
/**
* 处理选中药品
* @param drug 选中的药品数据
* @description isCard 时保留 selectedDrug 用于卡片展示;否则清空选中态
*/
function handleSelectDrug(drug: any) {
emit('select', drug);
// 清空搜索状态
searchKeyword.value = '';
searchResults.value = [];
showDropdown.value = false;
highlightIndex.value = -1;
if (props.isCard) {
selectedDrug.value = drug;
} else {
selectedDrug.value = null;
}
}
/**
* 清空已选卡片并重新搜索(仅 isCard
* 同步 emit null便于父级清空 drug_id
*/
function handleReselect() {
selectedDrug.value = null;
searchKeyword.value = '';
searchResults.value = [];
showDropdown.value = false;
highlightIndex.value = -1;
emit('select', null);
// 下一帧聚焦搜索框,方便重新选药
setTimeout(() => {
inputRef.value?.focus?.();
}, 0);
}
/**
@@ -261,6 +318,7 @@ watch(
searchResults.value = [];
showDropdown.value = false;
searchKeyword.value = '';
selectedDrug.value = null;
},
);
</script>
@@ -269,6 +327,7 @@ watch(
<div ref="containerRef" class="drug-search-select">
<!-- 搜索输入框 -->
<Input
ref="inputRef"
v-model:value="searchKeyword"
:placeholder="placeholder"
:disabled="disabled"
@@ -283,6 +342,49 @@ watch(
</template>
</Input>
<!-- isCard已选药品卡片与下拉项同结构带边框区分 -->
<div
v-if="isCard && selectedDrug"
class="drug-item drug-item--selected-card"
>
<div class="drug-item__image">
<img
:src="resolveDropdownImage(selectedDrug)"
alt=""
class="drug-item__img"
@error="
(e) => ((e.target as HTMLImageElement).src = DRUG_PLACEHOLDER_IMAGE)
"
/>
</div>
<div class="drug-item__info">
<div class="drug-item__name">{{ selectedDrug._drugName }}</div>
<div class="drug-item__meta">
<span class="drug-item__id">ID{{ selectedDrug._drugId }}</span>
<span v-if="selectedDrug._specification" class="drug-item__spec">
规格{{ selectedDrug._specification }}
</span>
<span v-if="selectedDrug._supplier" class="drug-item__supplier">
供应商{{ selectedDrug._supplier }}
</span>
</div>
</div>
<div class="drug-item__price">
<template v-if="Number(selectedDrug._price) > 0">
¥{{ Number(selectedDrug._price).toFixed(2) }}
</template>
<span v-else class="drug-item__price--empty">暂无</span>
</div>
<Button
type="link"
size="small"
class="drug-item__reselect"
@click="handleReselect"
>
重新选择
</Button>
</div>
<!-- 下拉列表 -->
<div v-if="showDropdown" class="drug-dropdown">
<!-- 加载中 -->
@@ -321,12 +423,11 @@ watch(
<div v-else class="drug-item__img-placeholder">无图</div>
</div>
<!-- 药品信息 -->
<!-- 药品信息药名 / ID / 规格 / 供应商 -->
<div class="drug-item__info">
<!-- 药品名称 -->
<div class="drug-item__name">{{ item._drugName }}</div>
<!-- 规格和供应商 -->
<div class="drug-item__meta">
<span class="drug-item__id">ID{{ item._drugId }}</span>
<span v-if="item._specification" class="drug-item__spec">
规格{{ item._specification }}
</span>
@@ -336,9 +437,12 @@ watch(
</div>
</div>
<!-- 价格 -->
<!-- 价格无价平台主库常见展示暂无 -->
<div class="drug-item__price">
¥{{ Number(item._price).toFixed(2) }}
<template v-if="Number(item._price) > 0">
¥{{ Number(item._price).toFixed(2) }}
</template>
<span v-else class="drug-item__price--empty">暂无</span>
</div>
</div>
</div>
@@ -347,6 +451,7 @@ watch(
</template>
<style lang="scss" scoped>
/* 只用框架主题变量,随亮暗/主题色自动适配,不写 .dark 硬编码覆盖 */
.drug-search-select {
position: relative;
width: 100%;
@@ -359,8 +464,8 @@ watch(
right: 0;
z-index: 1050;
margin-top: 4px;
background: #fff;
border: 1px solid #e5e7eb;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 8px;
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 9px 28px 8px rgba(0, 0, 0, 0.05);
max-height: 400px;
@@ -373,7 +478,7 @@ watch(
justify-content: center;
gap: 8px;
padding: 24px;
color: #999;
color: hsl(var(--muted-foreground));
font-size: 14px;
}
@@ -387,7 +492,7 @@ watch(
}
&::-webkit-scrollbar-thumb {
background: #d9d9d9;
background: hsl(var(--border));
border-radius: 3px;
}
@@ -407,7 +512,25 @@ watch(
&:hover,
&--active {
background-color: #f5f7fa;
background-color: hsl(var(--accent-hover));
}
/* 已选确认卡片:固定展示,非下拉浮层 */
&--selected-card {
margin-top: 8px;
cursor: default;
background: hsl(var(--muted) / 0.3);
border: 1px solid hsl(var(--border));
border-radius: 8px;
&:hover {
background: hsl(var(--muted) / 0.3);
}
}
&__reselect {
flex-shrink: 0;
padding: 0 4px;
}
&__image {
@@ -421,7 +544,7 @@ watch(
height: 48px;
object-fit: cover;
border-radius: 6px;
border: 1px solid #f0f0f0;
border: 1px solid hsl(var(--border));
}
&__img-placeholder {
@@ -430,10 +553,10 @@ watch(
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
background: hsl(var(--accent));
border-radius: 6px;
font-size: 12px;
color: #bbb;
color: hsl(var(--muted-foreground));
}
&__info {
@@ -445,7 +568,7 @@ watch(
&__name {
font-size: 14px;
font-weight: 500;
color: #333;
color: hsl(var(--foreground));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -457,57 +580,31 @@ watch(
flex-wrap: wrap;
gap: 12px;
font-size: 12px;
color: #999;
color: hsl(var(--muted-foreground));
}
&__id {
color: hsl(var(--muted-foreground));
}
&__spec {
color: #666;
color: hsl(var(--muted-foreground));
}
&__supplier {
color: #1890ff;
color: hsl(var(--primary));
}
&__price {
flex-shrink: 0;
font-size: 16px;
font-weight: 600;
color: #ff4d4f;
}
}
color: hsl(var(--destructive));
/* 暗色模式适配 */
.dark {
.drug-dropdown {
background: #1f2937;
border-color: #374151;
}
.drug-item {
&:hover,
&--active {
background-color: #374151;
}
&__img-placeholder {
background: #374151;
color: #6b7280;
}
&__name {
color: #f3f4f6;
}
&__meta {
color: #9ca3af;
}
&__spec {
color: #d1d5db;
}
&__supplier {
color: #60a5fa;
&--empty {
font-size: 13px;
font-weight: 400;
color: hsl(var(--muted-foreground));
}
}
}

View File

@@ -4,6 +4,7 @@ import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { useVModel } from '@vueuse/core';
import { Empty, Input, Popover, Spin } from 'ant-design-vue';
import { matchExpressByTrackingNo } from '#/utils/matchExpressByTrackingNo';
import { getExpressCompaniesOption } from '#/views/business/express/express-company/api';
defineOptions({
@@ -15,6 +16,8 @@ const props = defineProps<{
value?: string;
disabled?: boolean;
placeholder?: string;
/** 运单号:变化时按前缀自动匹配快递公司(用户手动改选后不再覆盖) */
trackingNo?: string;
}>();
const emits = defineEmits<{
@@ -34,6 +37,8 @@ const loading = ref(false);
const searchKeyword = ref('');
const options = ref<ExpressCompanyOption[]>([]);
const searchInputRef = ref<InstanceType<typeof Input> | null>(null);
/** 用户是否已手动选择/清空;为 true 时不再按单号自动覆盖 */
const userPicked = ref(false);
function filterExpressCompanyOptions(keyword: string, list: ExpressCompanyOption[]) {
const q = keyword.trim().toLowerCase();
@@ -59,17 +64,36 @@ const displayText = computed(() => {
return item.code ? `${item.name || '-'}${item.code}` : (item.name || '-');
});
/**
* 根据 trackingNo 自动选中快递公司
* 规则:尚未手动改选,或当前选中仍与规则结果一致时才覆盖
*/
function tryAutoMatchByTrackingNo() {
if (!options.value.length) return;
const matched = matchExpressByTrackingNo(props.trackingNo, options.value);
if (!matched?.code) return;
const matchedCode = matched.code;
if (userPicked.value && mValue.value && mValue.value !== matchedCode) {
// 用户已选其他公司,不覆盖
return;
}
if (mValue.value === matchedCode) return;
mValue.value = matchedCode;
}
async function loadOptions() {
loading.value = true;
try {
const res = await getExpressCompaniesOption({});
options.value = Array.isArray(res) ? res : [];
tryAutoMatchByTrackingNo();
} finally {
loading.value = false;
}
}
function selectOption(item: ExpressCompanyOption) {
userPicked.value = true;
mValue.value = item.code;
open.value = false;
searchKeyword.value = '';
@@ -77,6 +101,7 @@ function selectOption(item: ExpressCompanyOption) {
function clearSelection(e: Event) {
e.stopPropagation();
userPicked.value = true;
mValue.value = undefined;
}
@@ -99,6 +124,14 @@ watch(
},
);
watch(
() => props.trackingNo,
() => {
// 单号变化时允许再自动匹配(除非用户已改选为不一致公司)
tryAutoMatchByTrackingNo();
},
);
onMounted(() => {
loadOptions();
});
@@ -177,7 +210,7 @@ onMounted(() => {
.clear-btn {
pointer-events: auto;
cursor: pointer;
color: #86909c;
color: hsl(var(--muted-foreground));
font-size: 16px;
line-height: 1;
padding: 0 4px;
@@ -203,10 +236,11 @@ onMounted(() => {
text-align: left;
cursor: pointer;
border-radius: 6px;
color: hsl(var(--foreground));
}
.express-item:hover,
.express-item.active {
background: #f2f3f5;
background: hsl(var(--accent));
}
.express-meta {
min-width: 0;
@@ -214,11 +248,11 @@ onMounted(() => {
}
.express-name {
font-size: 14px;
color: #1d2129;
color: hsl(var(--foreground));
}
.express-sub {
font-size: 12px;
color: #86909c;
color: hsl(var(--muted-foreground));
}
.express-empty {
margin: 12px 0;
@@ -228,5 +262,8 @@ onMounted(() => {
<style>
.express-company-select-popover .ant-popover-inner {
padding: 12px;
background: hsl(var(--card));
color: hsl(var(--foreground));
border: 1px solid hsl(var(--border));
}
</style>