feat:优化了订单、诊所搜索组件、药品这些交互

This commit is contained in:
李琦
2026-07-23 09:24:06 +08:00
parent ee4490a91c
commit 700db76f16
73 changed files with 1321 additions and 433 deletions

View File

@@ -13,11 +13,11 @@ defineOptions({
});
const props = defineProps<{
value?: string;
disabled?: boolean;
placeholder?: string;
/** 运单号:变化时按前缀自动匹配快递公司(用户手动改选后不再覆盖) */
trackingNo?: string;
value?: string;
}>();
const emits = defineEmits<{
@@ -27,9 +27,9 @@ const emits = defineEmits<{
const mValue = useVModel(props, 'value', emits, { passive: true });
type ExpressCompanyOption = {
code: string;
id: number;
name: string;
code: string;
};
const open = ref(false);
@@ -40,7 +40,10 @@ const searchInputRef = ref<InstanceType<typeof Input> | null>(null);
/** 用户是否已手动选择/清空;为 true 时不再按单号自动覆盖 */
const userPicked = ref(false);
function filterExpressCompanyOptions(keyword: string, list: ExpressCompanyOption[]) {
function filterExpressCompanyOptions(
keyword: string,
list: ExpressCompanyOption[],
) {
const q = keyword.trim().toLowerCase();
if (!q) return list;
return list.filter((item) => {
@@ -61,7 +64,7 @@ const selectedOption = computed(() =>
const displayText = computed(() => {
const item = selectedOption.value;
if (!item) return '';
return item.code ? `${item.name || '-'}${item.code}` : (item.name || '-');
return item.code ? `${item.name || '-'}${item.code}` : item.name || '-';
});
/**
@@ -69,7 +72,7 @@ const displayText = computed(() => {
* 规则:尚未手动改选,或当前选中仍与规则结果一致时才覆盖
*/
function tryAutoMatchByTrackingNo() {
if (!options.value.length) return;
if (options.value.length === 0) return;
const matched = matchExpressByTrackingNo(props.trackingNo, options.value);
if (!matched?.code) return;
const matchedCode = matched.code;
@@ -110,7 +113,7 @@ function onOpenChange(next: boolean) {
open.value = next;
if (next) {
searchKeyword.value = '';
if (!options.value.length) {
if (options.value.length === 0) {
loadOptions();
}
nextTick(() => searchInputRef.value?.focus?.());
@@ -140,9 +143,9 @@ onMounted(() => {
<template>
<Popover
:open="open"
trigger="click"
placement="bottomLeft"
overlay-class-name="express-company-select-popover"
placement="bottomLeft"
trigger="click"
@open-change="onOpenChange"
>
<template #content>
@@ -151,17 +154,17 @@ onMounted(() => {
ref="searchInputRef"
v-model:value="searchKeyword"
allow-clear
placeholder="搜索公司名称或编码"
class="express-search"
placeholder="搜索公司名称或编码"
/>
<Spin :spinning="loading">
<div v-if="filteredOptions.length" class="express-list">
<div v-if="filteredOptions.length > 0" class="express-list">
<button
v-for="item in filteredOptions"
:key="item.id"
type="button"
class="express-item"
:class="{ active: item.code === mValue }"
class="express-item"
type="button"
@click="selectOption(item)"
>
<div class="express-meta">
@@ -170,20 +173,20 @@ onMounted(() => {
</div>
</button>
</div>
<Empty v-else description="无匹配快递公司" class="express-empty" />
<Empty v-else class="express-empty" description="无匹配快递公司" />
</Spin>
</div>
</template>
<div
:class="{ disabled, placeholder: !displayText }"
class="express-trigger"
:class="{ disabled: disabled, placeholder: !displayText }"
>
<Input
:value="displayText"
readonly
:disabled="disabled"
:placeholder="placeholder || '请选择快递公司'"
:value="displayText"
class="express-trigger-input"
readonly
>
<template v-if="displayText && !disabled" #suffix>
<span class="clear-btn" @click="clearSelection">×</span>
@@ -196,6 +199,8 @@ onMounted(() => {
<style scoped>
.express-trigger {
width: 100%;
/* min-width: fit-content; */
min-width: 250px;
cursor: pointer;
}
.express-trigger.disabled {

View File

@@ -0,0 +1,346 @@
<script setup lang="ts">
/**
* 门店多选搜索组件
* - 普通输入框输入,下方气泡卡片展示匹配结果(名称 / 拼音首拼)
* - 选中后在输入框下方用可关闭 Tag 展示「名称【id】」
* - 搜索结果默认高亮第一项Enter 可直接选中
* - 样式使用主题 CSS 变量,自动适配暗色
*/
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { LoadingOutlined, SearchOutlined } from '@ant-design/icons-vue';
import { useDebounceFn, useVModel } from '@vueuse/core';
import { Empty, Input, Spin, Tag } from 'ant-design-vue';
import { searchStoreOption } from '#/views/system/store/api';
defineOptions({
name: 'StoreMultiSearch',
inheritAttrs: false,
});
export type StoreSearchItem = {
id: number;
name: string;
type?: number;
shouzimu?: string;
};
const props = withDefaults(
defineProps<{
/** 已选门店 ID 列表 */
value?: number[];
/** 限定类型0诊所 1药店不传则诊所+药店都可搜 */
storeType?: 0 | 1;
placeholder?: string;
disabled?: boolean;
/**
* 外部已选门店详情(打开绑定弹窗时回填名称用)
* 仅用于 Tag 展示,不改变 value
*/
initialItems?: StoreSearchItem[];
}>(),
{
value: () => [],
storeType: undefined,
placeholder: '输入名称或拼音首拼搜索',
disabled: false,
initialItems: () => [],
},
);
const emits = defineEmits<{
'update:value': [value: number[]];
}>();
const mValue = useVModel(props, 'value', emits, {
passive: true,
defaultValue: [],
});
const containerRef = ref<HTMLElement | null>(null);
const searchKeyword = ref('');
const loading = ref(false);
const showDropdown = ref(false);
const highlightIndex = ref(-1);
const options = ref<StoreSearchItem[]>([]);
/** 已选门店详情(用于 Tag 展示名称) */
const selectedMap = ref<Record<number, StoreSearchItem>>({});
const selectedIds = computed(() =>
Array.isArray(mValue.value) ? mValue.value.map(Number) : [],
);
const selectedItems = computed(() =>
selectedIds.value.map((id) => {
const cached = selectedMap.value[id];
return cached || { id, name: `门店${id}` };
}),
);
/** 下拉中过滤掉已选 */
const visibleOptions = computed(() =>
options.value.filter((item) => !selectedIds.value.includes(item.id)),
);
/**
* 将初始/回填门店写入 selectedMap便于 Tag 显示真实名称
*/
function mergeInitialItems(items: StoreSearchItem[]) {
if (!items?.length) return;
const next = { ...selectedMap.value };
for (const item of items) {
if (item?.id) {
next[item.id] = item;
}
}
selectedMap.value = next;
}
/**
* 请求后端门店搜索;有结果时默认高亮第一项
*/
async function fetchOptions() {
const keyword = searchKeyword.value.trim();
if (!keyword) {
options.value = [];
showDropdown.value = false;
highlightIndex.value = -1;
return;
}
loading.value = true;
showDropdown.value = true;
try {
const res = await searchStoreOption({
keyword,
...(props.storeType === 0 || props.storeType === 1
? { type: props.storeType }
: {}),
limit: 20,
});
options.value = res?.items ?? [];
// 默认高亮第一项Enter 可直接选中
highlightIndex.value = options.value.length > 0 ? 0 : -1;
} catch {
options.value = [];
highlightIndex.value = -1;
} finally {
loading.value = false;
}
}
const debouncedFetch = useDebounceFn(fetchOptions, 300);
function handleInput() {
debouncedFetch();
}
function handleFocus() {
if (visibleOptions.value.length > 0) {
showDropdown.value = true;
if (highlightIndex.value < 0) {
highlightIndex.value = 0;
}
}
}
/**
* 选中一项:追加到 value缓存名称清空输入继续搜
*/
function selectOption(item: StoreSearchItem) {
if (selectedIds.value.includes(item.id)) return;
selectedMap.value = { ...selectedMap.value, [item.id]: item };
mValue.value = [...selectedIds.value, item.id];
searchKeyword.value = '';
options.value = [];
showDropdown.value = false;
highlightIndex.value = -1;
}
/**
* 取消某个已选门店
*/
function removeSelected(id: number) {
mValue.value = selectedIds.value.filter((x) => x !== id);
const next = { ...selectedMap.value };
delete next[id];
selectedMap.value = next;
}
function handleKeydown(e: KeyboardEvent) {
if (!showDropdown.value || visibleOptions.value.length === 0) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
highlightIndex.value = Math.min(
Math.max(highlightIndex.value, 0) + 1,
visibleOptions.value.length - 1,
);
break;
case 'ArrowUp':
e.preventDefault();
highlightIndex.value = Math.max(highlightIndex.value - 1, 0);
break;
case 'Enter':
e.preventDefault();
{
const idx = highlightIndex.value >= 0 ? highlightIndex.value : 0;
const item = visibleOptions.value[idx];
if (item) selectOption(item);
}
break;
case 'Escape':
showDropdown.value = false;
highlightIndex.value = -1;
break;
}
}
function handleClickOutside(e: MouseEvent) {
if (containerRef.value && !containerRef.value.contains(e.target as Node)) {
showDropdown.value = false;
highlightIndex.value = -1;
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside);
mergeInitialItems(props.initialItems || []);
});
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
});
watch(
() => props.storeType,
() => {
options.value = [];
showDropdown.value = false;
searchKeyword.value = '';
highlightIndex.value = -1;
},
);
watch(
() => props.initialItems,
(items) => mergeInitialItems(items || []),
{ deep: true },
);
</script>
<template>
<div ref="containerRef" class="store-multi-search">
<Input
v-model:value="searchKeyword"
:placeholder="placeholder"
:disabled="disabled"
allow-clear
@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>
<!-- 气泡建议列表 -->
<div v-if="showDropdown" class="store-multi-search__dropdown">
<Spin :spinning="loading">
<Empty
v-if="!loading && visibleOptions.length === 0"
:image="Empty.PRESENTED_IMAGE_SIMPLE"
description="暂无匹配门店"
class="py-3"
/>
<div
v-for="(item, index) in visibleOptions"
:key="item.id"
class="store-multi-search__option"
:class="{ 'is-active': index === highlightIndex }"
@mousedown.prevent="selectOption(item)"
>
<div class="store-multi-search__option-name">
{{ item.name }}{{ item.id }}
</div>
<div v-if="item.shouzimu" class="store-multi-search__option-sub">
首拼{{ item.shouzimu }}
</div>
</div>
</Spin>
</div>
<!-- 已选 Tag -->
<div v-if="selectedItems.length > 0" class="store-multi-search__tags">
<Tag
v-for="item in selectedItems"
:key="item.id"
closable
color="blue"
@close="() => removeSelected(item.id)"
>
{{ item.name }}{{ item.id }}
</Tag>
</div>
</div>
</template>
<style scoped lang="scss">
/* 只用框架主题变量,随亮暗/主题色自动适配 */
.store-multi-search {
position: relative;
width: 100%;
}
.store-multi-search__dropdown {
position: absolute;
z-index: 1050;
top: calc(100% + 4px);
left: 0;
right: 0;
max-height: 280px;
overflow-y: auto;
padding: 6px;
border-radius: 8px;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
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);
}
.store-multi-search__option {
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s;
&:hover,
&.is-active {
background: hsl(var(--accent-hover));
}
}
.store-multi-search__option-name {
font-size: 13px;
font-weight: 500;
color: hsl(var(--foreground));
line-height: 1.4;
}
.store-multi-search__option-sub {
margin-top: 2px;
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.store-multi-search__tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
</style>

View File

@@ -5,4 +5,5 @@ export type CustomComponentType =
| 'ApiSelect'
| 'ApiTreeSelect'
| 'IconPicker'
| 'StoreMultiSearch'
| 'WarehouseAdminDrugSearch';