342 lines
9.5 KiB
Vue
342 lines
9.5 KiB
Vue
<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>
|