Files
cmgd-mini-app/components/inlineSelect/inlineSelect.vue
2026-01-05 12:47:14 +08:00

342 lines
9.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.
<template>
<view
class="inline-select"
:class="{ 'is-disabled': disabled, 'is-error': hasError }"
:tabindex="disabled ? -1 : 0"
@keydown.enter="handleTriggerClick"
@keydown.space.prevent="handleTriggerClick"
@keydown.esc="onCancel"
>
<view class="inline-select__trigger" :style="customStyle" @click="handleTriggerClick">
<view class="inline-select__content">
<view class="inline-select__value" :class="{ 'is-placeholder': !hasValue }">{{ displayValue }}</view>
<slot name="suffix">
<view class="inline-select__indicator">
<text v-if="loading" class="inline-select__loading"></text>
<text v-else class="inline-select__arrow" :class="{ 'is-active': show_popup }"></text>
</view>
</slot>
</view>
</view>
</view>
<nut-popup v-model:visible="show_popup" position="bottom" safe-area-inset-bottom :close-on-click-overlay="closeOnClickOverlay">
<nut-picker
:columns="columns"
:field-names="fieldNames"
v-bind="$attrs"
v-model="currentPickerValue"
@confirm="onConfirm"
@change="onChange"
@cancel="onCancel"
>
<view v-if="columns.length === 0" class="inline-select__empty">{{ emptyText }}</view>
<template v-else-if="$slots.option" #default="{ option }">
<slot name="option" :option="option"></slot>
</template>
</nut-picker>
</nut-popup>
</template>
<script setup>
/**
* @component InlineSelect
* @description 行内选择组件 - 基于 NutUI Picker 的移动端下拉选择器,提供了丰富的自定义选项和事件处理能力。
* 该组件适用于在表单中进行单项选择的场景,支持自定义样式、异步加载、键盘操作等特性。
*
* @property {String|Number} modelValue - 选择器的当前值支持v-model双向绑定
* @property {String} [title='请选择'] - 选择器标题,显示在弹出层顶部
* @property {String} [placeholder='请选择'] - 选择框占位文本,未选择时显示
* @property {Object} [fieldNames={ text: 'name', value: 'value' }] - 自定义字段名映射,用于适配不同数据结构
* @property {String} [fieldNames.text='name'] - 选项显示文本的字段名
* @property {String} [fieldNames.value='value'] - 选项值的字段名
* @property {Array<{[key: string]: any}>} columns - 选项数据列表必填每个选项对象必须包含text和value字段支持自定义字段名
* @property {String|Number} [defaultValue=''] - 默认选中值当modelValue未设置时生效
* @property {Boolean} [disabled=false] - 是否禁用选择器,禁用时无法打开选择弹窗
* @property {Boolean} [closeOnClickOverlay=true] - 是否在点击遮罩层时关闭弹窗
* @property {Object} [customStyle={}] - 自定义样式对象,用于定制选择器外观
* @property {Boolean} [loading=false] - 是否显示加载状态加载时显示loading图标
* @property {String} [emptyText='暂无可选项'] - 无选项时显示的文本
* @property {String} [theme='default'] - 主题样式,可选值:'default'|'primary'|'success'|'warning'|'danger'
*
* @event {Function} update:modelValue - 值更新时触发,参数: (value: string|number)
* @event {Function} change - 选项改变时触发,参数: { columnIndex: number, selectedValue: Array, selectedOptions: Array }
* @event {Function} confirm - 点击确定按钮时触发,参数: { value: string|number, option: Object }
* @event {Function} cancel - 点击取消按钮时触发
* @event {Function} error - 选中值无效时触发,参数: { value: string|number, message: string }
*
* @slot suffix - 选择框后置内容,默认显示箭头图标
* @slot option - 自定义选项渲染,参数: { option: Object }
*
* @see 更多信息请参考 NutUI Picker 组件文档
*/
import { ref, computed, watch, shallowRef, onMounted } from 'vue';
const props = defineProps({
modelValue: {
type: [String, Number],
default: '',
},
placeholder: {
type: String,
default: '未选择',
},
fieldNames: {
type: Object,
default() {
return {
text: 'name',
value: 'value',
};
},
},
columns: {
type: Array,
default() {
return [];
},
required: true,
},
defaultValue: {
type: [String, Number],
default: '',
},
disabled: {
type: Boolean,
default: false,
},
closeOnClickOverlay: {
type: Boolean,
default: true,
},
customStyle: {
type: Object,
default() {
return {};
},
},
loading: {
type: Boolean,
default: false,
},
emptyText: {
type: String,
default: '暂无可选项',
},
});
const emit = defineEmits(['update:modelValue', 'change', 'confirm', 'cancel', 'error']);
// 状态管理
const show_popup = ref(false);
const currentValue = shallowRef('');
const hasError = ref(false);
const optionsCache = new Map();
// 初始化currentValue
const initCurrentValue = () => {
if (props.modelValue !== undefined && props.modelValue !== null) {
currentValue.value = props.modelValue;
} else if (props.defaultValue !== undefined && props.defaultValue !== null) {
currentValue.value = props.defaultValue;
emit('update:modelValue', props.defaultValue);
}
};
onMounted(initCurrentValue);
// 监听columns变化重新初始化currentValue
watch(
() => props.columns,
() => {
initCurrentValue();
},
{ immediate: true }
);
const currentPickerValue = ref([]);
// 监听columns变化重新初始化currentValue
watch(
() => currentValue.value,
() => {
currentPickerValue.value = [currentValue.value];
},
{ immediate: true }
);
// 计算属性
const hasValue = computed(() => currentValue.value !== '' && currentValue.value !== undefined && currentValue.value !== null);
const displayValue = computed(() => {
if (!hasValue.value || !props.columns.length) return props.placeholder;
const cachedOption = optionsCache.get(currentValue.value);
if (cachedOption) {
hasError.value = false;
return cachedOption[props.fieldNames.text];
}
const selectedItem = props.columns.find(item => item[props.fieldNames.value] === currentValue.value);
if (selectedItem) {
optionsCache.set(currentValue.value, selectedItem);
hasError.value = false;
return selectedItem[props.fieldNames.text];
}
hasError.value = true;
emit('error', { value: currentValue.value, message: '选项不存在' });
return props.placeholder;
});
// 事件处理
const handleTriggerClick = () => {
if (props.disabled) return;
show_popup.value = true;
};
const onChange = ({ columnIndex, selectedValue, selectedOptions }) => {
emit('change', { columnIndex, selectedValue, selectedOptions });
};
const onConfirm = ({ selectedValue, selectedOptions }) => {
if (!Array.isArray(selectedValue) || !Array.isArray(selectedOptions) || !selectedValue.length || !selectedOptions.length) return;
const value = selectedValue[0];
const option = selectedOptions[0];
if (value === undefined || option === undefined) return;
currentValue.value = value;
emit('update:modelValue', value);
emit('confirm', { value, option });
show_popup.value = false;
};
const onCancel = () => {
show_popup.value = false;
emit('cancel');
};
// 监听modelValue的变化
watch(
() => props.modelValue,
newValue => {
if (newValue !== undefined && newValue !== null && newValue !== currentValue.value) {
currentValue.value = newValue;
}
}
);
</script>
<style lang="scss">
.inline-select {
width: 100%;
outline: none;
&:focus:not(.is-disabled) .inline-select__trigger {
outline: none;
}
&.is-disabled {
opacity: 0.6;
cursor: not-allowed;
.inline-select__trigger {
pointer-events: none;
}
}
&.is-error .inline-select__trigger {
border-color: var(--theme-error-color, #f56c6c);
}
&__trigger {
position: relative;
display: flex;
align-items: center;
background-color: var(--theme-bg-color, #fff);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&:active:not(.is-disabled) {
background-color: var(--theme-hover-bg-color, #f5f5f5);
}
}
&__content {
display: flex;
align-items: center;
width: 100%;
gap: 16rpx;
}
&__value {
flex: 1;
line-height: 1.5;
color: var(--theme-text-color, #333);
&.is-placeholder {
color: var(--theme-placeholder-color, #999);
}
}
&__indicator {
display: flex;
align-items: center;
justify-content: center;
width: 32rpx;
height: 32rpx;
}
&__arrow {
width: 0;
height: 0;
border: 12rpx solid transparent;
border-top-color: var(--theme-arrow-color, #999);
transform: translateY(25%) rotate(0deg);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&.is-active {
transform: translateY(25%) rotate(180deg);
}
}
&__loading {
display: inline-block;
width: 24rpx;
height: 24rpx;
border: 3rpx solid var(--theme-loading-color, #2d8cf0);
border-radius: 50%;
border-top-color: transparent;
animation: inline-select-loading 0.8s infinite linear;
}
&__empty {
padding: 64rpx;
text-align: center;
color: var(--theme-empty-color, #999);
}
}
@keyframes inline-select-loading {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
:root {
--theme-color-rgb: 45, 140, 240;
--theme-bg-color: #fff;
--theme-text-color: #333;
--theme-border-color: #dcdfe6;
--theme-placeholder-color: #999;
--theme-hover-bg-color: #f5f5f5;
--theme-arrow-color: #999;
--theme-loading-color: #2d8cf0;
--theme-empty-color: #999;
--theme-error-color: #f56c6c;
}
</style>