This commit is contained in:
oneao committed 2026-03-19 22:15:36 +08:00
1 parent 4aaa93df7d
commit c330e52fd3
66 files changed
+7386 -4

No files matched your search

@@ -0,0 +1,368 @@
<script lang="js" setup>
import { loadPageDataApi } from "@/request/api";
import { ClickOutside as vClickOutside } from "element-plus";
import { useThrottle } from "@/utils/throttle";
// ===============================
// Props
// ===============================
const props = defineProps({
modelValue: { type: null, default: "" },
label: { type: String, default: "" },
labelKey: { type: String, default: "b_name" },
valueKey: { type: String, default: "b_id" },
fitInputWidth: { type: Boolean, default: true },
viewName: { type: String, default: "" },
searchCondition: { type: String, default: "" },
searchColumns: { type: Array, default: () => [] },
orderField: { type: String, default: "" },
sql: { type: String, default: "" },
});
// ===============================
// Emits
// ===============================
const emits = defineEmits(["update:modelValue", "update:label", "change"]);
// ===============================
// Refs & Reactive
// ===============================
const innerInput = ref(props.label); // 输入框内部值
const inputRef = ref(); // el-input ref
const popoverRef = ref(); // el-popover ref
const scrollbarRef = ref(); // 滚动条 ref
const popoverVisible = ref(false); // 弹窗显示状态
const popoverWidth = ref(""); // 弹窗宽度
const options = ref([]); // 下拉选项
const loading = ref(false); // 加载状态
const inputFocus = ref(false); // 输入框焦点状态
const highlightIndex = ref(0); // 高亮索引
const inputChange = ref(false); // 输入变化标记
const pagination = reactive({ page: 1, size: 10, total: 0 }); // 分页
const popperPaneRef = computed(() => popoverRef.value?.popperRef?.contentRef); // Popover 内容 DOM
const searchColumns = computed(() => [
props.labelKey,
props.valueKey,
...props.searchColumns,
]); // 搜索列
// ===============================
// Watchers
// ===============================
watch(
() => props.label,
(newVal) => {
innerInput.value = newVal;
},
);
// ===============================
// Popover 生命周期
// ===============================
function onPopoverBeforeEnter() {
if (props.fitInputWidth)
popoverWidth.value = `${inputRef.value.$el.offsetWidth}px`;
}
function onPopoverAfterLeave() {
pagination.page = 1;
pagination.total = 0;
options.value = [];
highlightIndex.value = 0;
inputChange.value = false;
}
function onPopoverBeforeLeave() {
// 弹窗关闭前恢复或清空状态
if (props.label) {
if (innerInput.value === "") {
emits("update:label", "");
emits("update:modelValue", "");
emits("change", null);
} else {
innerInput.value = props.label;
}
}
}
// ===============================
// 输入框事件
// ===============================
function onFocus() {
inputFocus.value = true;
}
function onBlur() {
inputFocus.value = false;
}
function onInput(value) {
pagination.page = 1;
pagination.total = 0;
highlightIndex.value = 0;
inputChange.value = true;
fetchOptions(value);
}
function onClick(event) {
event.stopPropagation();
if (!popoverVisible.value) {
inputChange.value = false;
fetchOptions("");
}
}
function closePopover() {
popoverVisible.value = false;
}
// ===============================
// 键盘操作
// ===============================
function onKeydown(e) {
if (e.key === "ArrowDown") {
e.preventDefault();
moveHighlight(1);
} else if (e.key === "ArrowUp") {
e.preventDefault();
moveHighlight(-1);
} else if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
if (!popoverVisible.value) {
inputChange.value = false;
fetchOptions("");
} else {
let index = highlightIndex.value;
if (index < 0) index = 0;
handleSelectOption(options.value[index]);
}
} else if (e.key === "Escape") {
popoverVisible.value = false;
}
}
// ===============================
// 高亮与滚动
// ===============================
function moveHighlight(direction) {
const total = options.value.length;
if (!total) return;
let newIndex = highlightIndex.value + direction;
if (newIndex < 0) newIndex = total - 1;
if (newIndex >= total) newIndex = 0;
highlightIndex.value = newIndex;
scrollToOption(newIndex);
}
function scrollToOption(index) {
nextTick(() => {
const wrap = scrollbarRef.value?.wrapRef;
if (!wrap) return;
const opts = wrap.querySelectorAll(".option");
const el = opts[index];
if (el) el.scrollIntoView({ block: "nearest" });
});
}
// ===============================
// 数据请求
// ===============================
function _fetchOptions(searchTitle) {
loading.value = true;
let searchCondition = props.searchCondition || "";
if (searchTitle) {
const condition = `${props.labelKey} like '%${searchTitle}%'`;
searchCondition = searchCondition
? `${searchCondition} AND ${condition}`
: condition;
}
if (!inputChange.value) searchCondition = "";
loadPageDataApi({
page: pagination.page,
size: pagination.size,
viewName: props.viewName,
searchColumns: searchColumns.value,
searchCondition,
orderField: props.orderField,
})
.then((res) => {
if (res.success) {
options.value =
pagination.page === 1
? res.data.rows
: [...options.value, ...res.data.rows];
pagination.total = res.data.total;
popoverVisible.value = true
}
})
.finally(() => {
loading.value = false;
});
}
const fetchOptions = useThrottle(_fetchOptions, 300);
// ===============================
// 滚动加载
// ===============================
function onScrollbarReachEnd() {
if (loading.value) return;
if (pagination.page * pagination.size >= pagination.total) return;
pagination.page += 1;
fetchOptions(innerInput.value);
}
// ===============================
// 选中操作
// ===============================
function handleSelectOption(option) {
if (!option) return;
emits("update:label", option[props.labelKey]);
emits("update:modelValue", option[props.valueKey]);
emits("change", option);
popoverVisible.value = false;
}
</script>
<template>
<el-popover
ref="popoverRef"
trigger="manual"
:visible="popoverVisible"
placement="bottom"
popper-style="padding:0"
transition="el-zoom-in-top"
:width="popoverWidth"
@before-enter="onPopoverBeforeEnter"
@before-leave="onPopoverBeforeLeave"
@after-leave="onPopoverAfterLeave"
>
<template #reference>
<el-input
v-model="innerInput"
ref="inputRef"
@click.stop="onClick"
@focus="onFocus"
@blur="onBlur"
@input="onInput"
v-click-outside:[popperPaneRef]="closePopover"
@keydown="onKeydown"
v-bind="$attrs"
>
<template #suffix>
<el-icon
class="el-input__icon arrow-icon"
:class="{
rotate: inputFocus && !loading,
spinning: loading,
}"
>
<G3Icon :icon="loading ? 'lucide:loader' : 'ep:arrow-down'" />
</el-icon>
</template>
</el-input>
</template>
<div class="option-wrapper" @mousedown.prevent>
<div v-if="!loading && options.length === 0" class="empty">
<span>暂无数据</span>
</div>
<template v-else>
<el-scrollbar
ref="scrollbarRef"
max-height="260px"
:distance="20"
@end-reached="onScrollbarReachEnd"
>
<div
v-for="(item, index) in options"
:key="item[props.valueKey]"
class="option"
:class="{
selected: props.modelValue === item[valueKey],
highlighted: highlightIndex === index,
}"
@click="handleSelectOption(item)"
>
{{ item[labelKey] }}
</div>
</el-scrollbar>
</template>
</div>
</el-popover>
</template>
<style lang="scss" scoped>
.arrow-icon {
transition: transform 0.25s ease;
}
.arrow-icon.rotate {
transform: rotate(180deg);
}
.arrow-icon.spinning {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.option-wrapper {
.loading {
height: 60px;
}
.empty {
height: 60px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--el-text-color-placeholder);
font-size: var(--el-font-size-small);
}
.option {
font-size: var(--el-font-size-base);
padding: 0 32px 0 20px;
position: relative;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--el-text-color-regular);
height: 34px;
line-height: 34px;
box-sizing: border-box;
cursor: pointer;
&:hover {
background-color: var(--el-fill-color-light);
}
&.selected {
color: var(--el-color-primary);
font-weight: 700;
}
&.highlighted {
background-color: var(--el-fill-color-light);
}
}
}
</style>