|
|
@@ -0,0 +1,418 @@
|
|
|
+<template>
|
|
|
+ <el-select
|
|
|
+ ref="selectRef"
|
|
|
+ popper-class="deviceSelectorPopper"
|
|
|
+ class="deviceSelector"
|
|
|
+ v-model="data"
|
|
|
+ placeholder="请选择设备"
|
|
|
+ @visible-change="visibleChange"
|
|
|
+ clearable
|
|
|
+ filterable
|
|
|
+ remote
|
|
|
+ :remote-method="remoteMethod"
|
|
|
+ :loading="loading"
|
|
|
+ @change="onSelectChange"
|
|
|
+ >
|
|
|
+ <el-option
|
|
|
+ v-for="item in options"
|
|
|
+ :key="item[tableConfig.value]"
|
|
|
+ :label="item[tableConfig.label]"
|
|
|
+ :value="item[tableConfig.value]"
|
|
|
+ >
|
|
|
+ <div class="option-item">{{ item[tableConfig.label] }}
|
|
|
+ <!-- <span class="option-label"></span>
|
|
|
+ <span v-if="item.code" class="option-code">{{ item.code }}</span> -->
|
|
|
+ </div>
|
|
|
+ </el-option>
|
|
|
+ <template #empty>
|
|
|
+ <div v-if="!loading" class="empty-content">
|
|
|
+ <div v-if="options.length === 0" class="no-data">暂无数据</div>
|
|
|
+ <div v-else-if="hasMore" class="load-more-wrapper">
|
|
|
+ <el-button
|
|
|
+ link
|
|
|
+ type="primary"
|
|
|
+ @click="loadMore"
|
|
|
+ :loading="loadingMore"
|
|
|
+ >
|
|
|
+ 加载更多
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+ <div v-else class="no-more-data">没有更多数据了</div>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </el-select>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+import { defineProps, reactive, ref, watch, useAttrs, computed, nextTick, onUnmounted } from 'vue';
|
|
|
+import { request } from '/@/utils/service';
|
|
|
+import { useUi } from '@fast-crud/fast-crud';
|
|
|
+
|
|
|
+const props = defineProps({
|
|
|
+ modelValue: {},
|
|
|
+ tableConfig: {
|
|
|
+ url: null,
|
|
|
+ label: null, //显示值
|
|
|
+ value: null, //数据值
|
|
|
+ columns: [], //每一项对应的列表项
|
|
|
+ },
|
|
|
+ displayLabel: {},
|
|
|
+} as any);
|
|
|
+
|
|
|
+const attrs = useAttrs();
|
|
|
+
|
|
|
+const emit = defineEmits(['update:modelValue', 'change']);
|
|
|
+
|
|
|
+// 获取表单校验上下文
|
|
|
+const { ui } = useUi();
|
|
|
+const formValidator = ui.formItem.injectFormItemContext();
|
|
|
+
|
|
|
+// select组件引用
|
|
|
+const selectRef = ref();
|
|
|
+// template上使用data
|
|
|
+const data = ref();
|
|
|
+// 选项列表
|
|
|
+const options = ref<any[]>([]);
|
|
|
+// 加载状态
|
|
|
+const loading = ref(false);
|
|
|
+const loadingMore = ref(false);
|
|
|
+// 搜索关键词
|
|
|
+const searchKeyword = ref('');
|
|
|
+// 分页配置
|
|
|
+const pageConfig = reactive({
|
|
|
+ page: 1,
|
|
|
+ limit: 20,
|
|
|
+ total: 0,
|
|
|
+});
|
|
|
+
|
|
|
+// 计算tableConfig
|
|
|
+const tableConfig = computed(() => props.tableConfig);
|
|
|
+
|
|
|
+// 是否有更多数据
|
|
|
+const hasMore = computed(() => {
|
|
|
+ return options.value.length < pageConfig.total;
|
|
|
+});
|
|
|
+
|
|
|
+// 滚动事件处理
|
|
|
+let scrollHandler: ((e: Event) => void) | null = null;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 远程搜索方法
|
|
|
+ */
|
|
|
+const remoteMethod = async (query: string) => {
|
|
|
+ searchKeyword.value = query;
|
|
|
+ pageConfig.page = 1;
|
|
|
+
|
|
|
+ // 保存当前选中的项,避免清空后无法显示
|
|
|
+ const currentSelected = props.modelValue ? options.value.find(
|
|
|
+ (item: any) => item[tableConfig.value.value] === props.modelValue
|
|
|
+ ) : null;
|
|
|
+
|
|
|
+ options.value = [];
|
|
|
+
|
|
|
+ // 如果有选中的项,先添加回去
|
|
|
+ if (currentSelected) {
|
|
|
+ options.value = [currentSelected];
|
|
|
+ }
|
|
|
+
|
|
|
+ await fetchData(false);
|
|
|
+
|
|
|
+ // 搜索后,确保选中的项仍在选项中
|
|
|
+ if (currentSelected && props.modelValue) {
|
|
|
+ const exists = options.value.find(
|
|
|
+ (item: any) => item[tableConfig.value.value] === props.modelValue
|
|
|
+ );
|
|
|
+ if (!exists) {
|
|
|
+ options.value = [currentSelected, ...options.value];
|
|
|
+ }
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+/**
|
|
|
+ * 加载更多数据
|
|
|
+ */
|
|
|
+const loadMore = async () => {
|
|
|
+ if (loadingMore.value || !hasMore.value) return;
|
|
|
+ pageConfig.page += 1;
|
|
|
+ await fetchData(true);
|
|
|
+};
|
|
|
+
|
|
|
+/**
|
|
|
+ * 获取数据
|
|
|
+ */
|
|
|
+const fetchData = async (isLoadMore: boolean = false) => {
|
|
|
+ const url = tableConfig.value.url;
|
|
|
+ const params: any = {
|
|
|
+ page: pageConfig.page,
|
|
|
+ limit: pageConfig.limit,
|
|
|
+ has_inventory: 1,
|
|
|
+ };
|
|
|
+
|
|
|
+ // 如果有搜索关键词,添加到参数中
|
|
|
+ if (searchKeyword.value) {
|
|
|
+ params.search = searchKeyword.value;
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ if (isLoadMore) {
|
|
|
+ loadingMore.value = true;
|
|
|
+ } else {
|
|
|
+ loading.value = true;
|
|
|
+ }
|
|
|
+
|
|
|
+ const response = await request({
|
|
|
+ url: url,
|
|
|
+ params: params,
|
|
|
+ });
|
|
|
+
|
|
|
+ if (response.code === 2000) {
|
|
|
+ const newData = response.data || [];
|
|
|
+ pageConfig.total = response.total || 0;
|
|
|
+
|
|
|
+ if (isLoadMore) {
|
|
|
+ // 加载更多时追加数据,但要避免重复
|
|
|
+ const existingIds = new Set(options.value.map((item: any) => item[tableConfig.value.value]));
|
|
|
+ const newItems = newData.filter((item: any) => !existingIds.has(item[tableConfig.value.value]));
|
|
|
+ options.value = [...options.value, ...newItems];
|
|
|
+ } else {
|
|
|
+ // 首次加载或搜索时替换数据,但要保留当前选中的项
|
|
|
+ const currentSelected = props.modelValue ? options.value.find(
|
|
|
+ (item: any) => item[tableConfig.value.value] === props.modelValue
|
|
|
+ ) : null;
|
|
|
+
|
|
|
+ options.value = newData;
|
|
|
+
|
|
|
+ // 如果当前有选中的值且不在新数据中,添加回去
|
|
|
+ if (currentSelected && props.modelValue) {
|
|
|
+ const exists = options.value.find(
|
|
|
+ (item: any) => item[tableConfig.value.value] === props.modelValue
|
|
|
+ );
|
|
|
+ if (!exists) {
|
|
|
+ options.value = [currentSelected, ...options.value];
|
|
|
+ }
|
|
|
+ } else if (props.modelValue && !currentSelected) {
|
|
|
+ // 如果选项中没有选中的项,尝试加载
|
|
|
+ const selectedItem = options.value.find(
|
|
|
+ (item: any) => item[tableConfig.value.value] === props.modelValue
|
|
|
+ );
|
|
|
+ if (!selectedItem) {
|
|
|
+ await loadSelectedItem();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('获取设备列表失败:', error);
|
|
|
+ if (!isLoadMore) {
|
|
|
+ options.value = [];
|
|
|
+ }
|
|
|
+ } finally {
|
|
|
+ loading.value = false;
|
|
|
+ loadingMore.value = false;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+/**
|
|
|
+ * 加载已选中的项(用于编辑时显示)
|
|
|
+ */
|
|
|
+const loadSelectedItem = async (deviceId?: any) => {
|
|
|
+ const targetId = deviceId || props.modelValue;
|
|
|
+ if (!targetId) return;
|
|
|
+
|
|
|
+ try {
|
|
|
+ const response = await request({
|
|
|
+ url: `${tableConfig.value.url}${targetId}/`,
|
|
|
+ method: 'get',
|
|
|
+ });
|
|
|
+
|
|
|
+ if (response.code === 2000 && response.data) {
|
|
|
+ const selectedItem = response.data;
|
|
|
+ // 检查是否已经在选项中,如果不在则添加
|
|
|
+ const exists = options.value.find(
|
|
|
+ (item: any) => item[tableConfig.value.value] === selectedItem[tableConfig.value.value]
|
|
|
+ );
|
|
|
+ if (!exists) {
|
|
|
+ options.value = [selectedItem, ...options.value];
|
|
|
+ }
|
|
|
+ return selectedItem;
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('加载选中项失败:', error);
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+};
|
|
|
+
|
|
|
+/**
|
|
|
+ * 下拉框展开/关闭
|
|
|
+ */
|
|
|
+const visibleChange = async (bool: any) => {
|
|
|
+ if (bool) {
|
|
|
+ // 重置分页和搜索
|
|
|
+ if (options.value.length === 0) {
|
|
|
+ pageConfig.page = 1;
|
|
|
+ searchKeyword.value = '';
|
|
|
+ await fetchData(false);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 添加滚动监听
|
|
|
+ await nextTick();
|
|
|
+ setupScrollListener();
|
|
|
+ } else {
|
|
|
+ // 移除滚动监听
|
|
|
+ removeScrollListener();
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+/**
|
|
|
+ * 设置滚动监听
|
|
|
+ */
|
|
|
+const setupScrollListener = () => {
|
|
|
+ if (!selectRef.value) return;
|
|
|
+
|
|
|
+ const popper = document.querySelector('.deviceSelectorPopper .el-select-dropdown__wrap');
|
|
|
+ if (!popper) return;
|
|
|
+
|
|
|
+ scrollHandler = (e: Event) => {
|
|
|
+ const target = e.target as HTMLElement;
|
|
|
+ const scrollTop = target.scrollTop;
|
|
|
+ const scrollHeight = target.scrollHeight;
|
|
|
+ const clientHeight = target.clientHeight;
|
|
|
+
|
|
|
+ // 当滚动到底部附近时(距离底部20px),加载更多
|
|
|
+ if (scrollHeight - scrollTop - clientHeight < 20 && hasMore.value && !loadingMore.value && !loading.value) {
|
|
|
+ loadMore();
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ popper.addEventListener('scroll', scrollHandler);
|
|
|
+};
|
|
|
+
|
|
|
+/**
|
|
|
+ * 移除滚动监听
|
|
|
+ */
|
|
|
+const removeScrollListener = () => {
|
|
|
+ if (scrollHandler) {
|
|
|
+ const popper = document.querySelector('.deviceSelectorPopper .el-select-dropdown__wrap');
|
|
|
+ if (popper && scrollHandler) {
|
|
|
+ popper.removeEventListener('scroll', scrollHandler);
|
|
|
+ }
|
|
|
+ scrollHandler = null;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+onUnmounted(() => {
|
|
|
+ removeScrollListener();
|
|
|
+});
|
|
|
+
|
|
|
+/**
|
|
|
+ * select change事件
|
|
|
+ */
|
|
|
+const onSelectChange = async (value: any) => {
|
|
|
+ // 如果选择了值,确保该值在选项中(这样el-select才能正确显示)
|
|
|
+ if (value) {
|
|
|
+ const selectedItem = options.value.find(
|
|
|
+ (item: any) => item[tableConfig.value.value] === value
|
|
|
+ );
|
|
|
+
|
|
|
+ // 如果选项中没有选中的项,尝试加载该项
|
|
|
+ if (!selectedItem) {
|
|
|
+ await loadSelectedItem(value);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // data已经是value了,不需要再更新
|
|
|
+ // data.value 已经在watch中同步了,这里直接emit即可
|
|
|
+ emit('update:modelValue', value);
|
|
|
+ // 触发校验
|
|
|
+ formValidator?.onChange();
|
|
|
+ formValidator?.onBlur();
|
|
|
+ // 触发change事件
|
|
|
+ emit('change', value);
|
|
|
+ // 如果通过attrs传入了onChange回调,调用它
|
|
|
+ if (attrs.onChange && typeof attrs.onChange === 'function') {
|
|
|
+ attrs.onChange(value);
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 监听modelValue的变化,同步到data(el-select需要value,不是label)
|
|
|
+watch(
|
|
|
+ () => props.modelValue,
|
|
|
+ async (value) => {
|
|
|
+ // el-select的v-model应该绑定value(设备ID),不是label(设备名称)
|
|
|
+ data.value = value;
|
|
|
+
|
|
|
+ // 如果选项中没有该项,尝试加载,确保能正确显示
|
|
|
+ if (value) {
|
|
|
+ const item = options.value.find(
|
|
|
+ (item: any) => item[tableConfig.value.value] === value
|
|
|
+ );
|
|
|
+ if (!item) {
|
|
|
+ // 如果选项中没有,尝试加载该项
|
|
|
+ await loadSelectedItem(value);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ { immediate: true }
|
|
|
+);
|
|
|
+
|
|
|
+// 监听displayLabel的变化(用于编辑时回显)
|
|
|
+watch(
|
|
|
+ () => props.displayLabel,
|
|
|
+ (value) => {
|
|
|
+ // displayLabel仅用于参考,实际显示还是依赖于modelValue和options
|
|
|
+ // 如果modelValue存在但options中没有,可能需要触发加载
|
|
|
+ if (props.modelValue && value && options.value.length === 0) {
|
|
|
+ loadSelectedItem();
|
|
|
+ }
|
|
|
+ },
|
|
|
+ { immediate: true }
|
|
|
+);
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped lang="scss">
|
|
|
+.option-item {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+
|
|
|
+ .option-label {
|
|
|
+ flex: 1;
|
|
|
+ }
|
|
|
+
|
|
|
+ .option-code {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #909399;
|
|
|
+ margin-left: 10px;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.empty-content {
|
|
|
+ padding: 10px;
|
|
|
+ text-align: center;
|
|
|
+
|
|
|
+ .no-data {
|
|
|
+ color: #909399;
|
|
|
+ font-size: 12px;
|
|
|
+ padding: 10px 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ .load-more-wrapper {
|
|
|
+ padding: 10px 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ .no-more-data {
|
|
|
+ color: #909399;
|
|
|
+ font-size: 12px;
|
|
|
+ padding: 10px 0;
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|
|
|
+
|
|
|
+<style lang="scss">
|
|
|
+.deviceSelectorPopper {
|
|
|
+ .el-select-dropdown__wrap {
|
|
|
+ max-height: 300px;
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|
|
|
+
|