Files
xk-admin/apps/web-antd/src/components/form/components/express-company-select.vue

275 lines
6.5 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
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({
name: 'ExpressCompanySelect',
inheritAttrs: false,
});
const props = defineProps<{
disabled?: boolean;
placeholder?: string;
/** 运单号:变化时按前缀自动匹配快递公司(用户手动改选后不再覆盖) */
trackingNo?: string;
value?: string;
}>();
const emits = defineEmits<{
'update:value': [value: string | undefined];
}>();
const mValue = useVModel(props, 'value', emits, { passive: true });
type ExpressCompanyOption = {
code: string;
id: number;
name: string;
};
const open = ref(false);
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();
if (!q) return list;
return list.filter((item) => {
const name = (item.name || '').toLowerCase();
const code = (item.code || '').toLowerCase();
return name.includes(q) || code.includes(q);
});
}
const filteredOptions = computed(() =>
filterExpressCompanyOptions(searchKeyword.value, options.value),
);
const selectedOption = computed(() =>
options.value.find((item) => item.code === mValue.value),
);
const displayText = computed(() => {
const item = selectedOption.value;
if (!item) return '';
return item.code ? `${item.name || '-'}(${item.code})` : item.name || '-';
});
/**
* 根据 trackingNo 自动选中快递公司
* 规则:尚未手动改选,或当前选中仍与规则结果一致时才覆盖
*/
function tryAutoMatchByTrackingNo() {
if (options.value.length === 0) 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 = '';
}
function clearSelection(e: Event) {
e.stopPropagation();
userPicked.value = true;
mValue.value = undefined;
}
function onOpenChange(next: boolean) {
if (props.disabled) return;
open.value = next;
if (next) {
searchKeyword.value = '';
if (options.value.length === 0) {
loadOptions();
}
nextTick(() => searchInputRef.value?.focus?.());
}
}
watch(
() => props.disabled,
(disabled) => {
if (disabled) open.value = false;
},
);
watch(
() => props.trackingNo,
() => {
// 单号变化时允许再自动匹配(除非用户已改选为不一致公司)
tryAutoMatchByTrackingNo();
},
);
onMounted(() => {
loadOptions();
});
</script>
<template>
<Popover
:open="open"
overlay-class-name="express-company-select-popover"
placement="bottomLeft"
trigger="click"
@open-change="onOpenChange"
>
<template #content>
<div class="express-panel">
<Input
ref="searchInputRef"
v-model:value="searchKeyword"
allow-clear
class="express-search"
placeholder="搜索公司名称或编码"
/>
<Spin :spinning="loading">
<div v-if="filteredOptions.length > 0" class="express-list">
<button
v-for="item in filteredOptions"
:key="item.id"
:class="{ active: item.code === mValue }"
class="express-item"
type="button"
@click="selectOption(item)"
>
<div class="express-meta">
<div class="express-name">{{ item.name || '-' }}</div>
<div class="express-sub">{{ item.code }}</div>
</div>
</button>
</div>
<Empty v-else class="express-empty" description="无匹配快递公司" />
</Spin>
</div>
</template>
<div
:class="{ disabled, placeholder: !displayText }"
class="express-trigger"
>
<Input
:disabled="disabled"
:placeholder="placeholder || '请选择快递公司'"
:value="displayText"
class="express-trigger-input"
readonly
>
<template v-if="displayText && !disabled" #suffix>
<span class="clear-btn" @click="clearSelection">×</span>
</template>
</Input>
</div>
</Popover>
</template>
<style scoped>
.express-trigger {
width: 100%;
/* min-width: fit-content; */
min-width: 250px;
cursor: pointer;
}
.express-trigger.disabled {
cursor: not-allowed;
}
.express-trigger-input {
pointer-events: none;
}
.express-trigger:not(.disabled) :deep(.ant-input) {
cursor: pointer;
}
.clear-btn {
pointer-events: auto;
cursor: pointer;
color: hsl(var(--muted-foreground));
font-size: 16px;
line-height: 1;
padding: 0 4px;
}
.express-panel {
width: 360px;
max-width: 80vw;
}
.express-search {
margin-bottom: 8px;
}
.express-list {
max-height: 280px;
overflow-y: auto;
}
.express-item {
display: flex;
align-items: center;
width: 100%;
padding: 8px 10px;
border: none;
background: transparent;
text-align: left;
cursor: pointer;
border-radius: 6px;
color: hsl(var(--foreground));
}
.express-item:hover,
.express-item.active {
background: hsl(var(--accent));
}
.express-meta {
min-width: 0;
flex: 1;
}
.express-name {
font-size: 14px;
color: hsl(var(--foreground));
}
.express-sub {
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.express-empty {
margin: 12px 0;
}
</style>
<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>