u
This commit is contained in:
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>
|
||||
Reference in new issue
Block a user