This commit is contained in:
2026-01-05 12:47:14 +08:00
commit 1fc846fae3
1614 changed files with 162035 additions and 0 deletions

View File

@@ -0,0 +1,341 @@
<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>