|
| 1 | +<template> |
| 2 | + <NSelect |
| 3 | + v-bind="forwardedAttrs" |
| 4 | + :value="normalizedValue" |
| 5 | + :options="mappedOptions" |
| 6 | + :render-label="renderOptionLabel" |
| 7 | + :render-tag="multiple ? renderSelectedTag : undefined" |
| 8 | + @update:value="onUpdateValue" |
| 9 | + > |
| 10 | + <template #empty> |
| 11 | + <slot name="empty"> |
| 12 | + <NSpace vertical align="center" style="padding: 12px 0;"> |
| 13 | + <NText depth="3">{{ emptyText || t('model.select.noAvailableModels') }}</NText> |
| 14 | + <NButton |
| 15 | + v-if="shouldShowEmptyConfigCTA" |
| 16 | + type="tertiary" |
| 17 | + size="small" |
| 18 | + ghost |
| 19 | + @click="emitConfig()" |
| 20 | + > |
| 21 | + <template #icon> |
| 22 | + <span>⚙️</span> |
| 23 | + </template> |
| 24 | + {{ configText || t('model.select.configure') }} |
| 25 | + </NButton> |
| 26 | + </NSpace> |
| 27 | + </slot> |
| 28 | + </template> |
| 29 | + |
| 30 | + <template #action> |
| 31 | + <slot name="action"> |
| 32 | + <div v-if="shouldShowConfigAction" style="padding: 8px 12px;"> |
| 33 | + <NButton quaternary size="small" @click="emitConfig()"> |
| 34 | + <template #icon> |
| 35 | + <span>⚙️</span> |
| 36 | + </template> |
| 37 | + {{ configText || t('model.select.configure') }} |
| 38 | + </NButton> |
| 39 | + </div> |
| 40 | + </slot> |
| 41 | + </template> |
| 42 | + </NSelect> |
| 43 | +</template> |
| 44 | + |
| 45 | +<script setup lang="ts"> |
| 46 | +import { computed, h, useAttrs } from 'vue' |
| 47 | +import { useI18n } from 'vue-i18n' |
| 48 | +import { NSelect, NSpace, NButton, NText } from 'naive-ui' |
| 49 | +
|
| 50 | +interface Props { |
| 51 | + modelValue: any |
| 52 | + options: any[] |
| 53 | + getPrimary: (opt: any) => string |
| 54 | + getSecondary?: (opt: any) => string |
| 55 | + getValue: (opt: any) => string | number |
| 56 | + selectedTooltip?: boolean |
| 57 | + showConfigAction?: boolean |
| 58 | + showEmptyConfigCTA?: boolean |
| 59 | + configText?: string |
| 60 | + emptyText?: string |
| 61 | + multiple?: boolean |
| 62 | +} |
| 63 | +
|
| 64 | +const props = withDefaults(defineProps<Props>(), { |
| 65 | + modelValue: null, |
| 66 | + options: () => [], |
| 67 | + getSecondary: undefined, |
| 68 | + selectedTooltip: true, |
| 69 | + showConfigAction: false, |
| 70 | + showEmptyConfigCTA: false, |
| 71 | + configText: undefined, |
| 72 | + emptyText: undefined, |
| 73 | + multiple: false |
| 74 | +}) |
| 75 | +
|
| 76 | +const emit = defineEmits<{ |
| 77 | + 'update:modelValue': [value: any] |
| 78 | + 'config': [payload?: any] |
| 79 | +}>() |
| 80 | +
|
| 81 | +const attrs = useAttrs() as Record<string, any> |
| 82 | +const { t } = useI18n() |
| 83 | +
|
| 84 | +// 检测是否有 config 事件处理器 - 始终显示配置按钮确保功能可用 |
| 85 | +// 动态显示配置相关功能(由父级显式开启) |
| 86 | +const shouldShowConfigAction = computed(() => !!props.showConfigAction) |
| 87 | +const shouldShowEmptyConfigCTA = computed(() => !!props.showEmptyConfigCTA) |
| 88 | +
|
| 89 | +// 将外部原始 options 转换为 NSelect 可识别的选项,label 为两行结构 |
| 90 | +const mappedOptions = computed(() => { |
| 91 | + return (props.options || []).map((opt: any) => { |
| 92 | + const primary = props.getPrimary(opt) || '' |
| 93 | + const secondary = props.getSecondary ? (props.getSecondary(opt) || '') : '' |
| 94 | + const value = props.getValue(opt) |
| 95 | + return { |
| 96 | + label: primary, |
| 97 | + value, |
| 98 | + raw: opt, |
| 99 | + primary, |
| 100 | + secondary |
| 101 | + } |
| 102 | + }) |
| 103 | +}) |
| 104 | +
|
| 105 | +// 使用 Naive UI 官方的 render-label 自定义选项渲染 |
| 106 | +const renderOptionLabel = (option: any) => { |
| 107 | + const primary = option?.primary || '' |
| 108 | + const secondary = option?.secondary || '' |
| 109 | + const title = props.selectedTooltip && secondary ? `${primary} · ${secondary}` : undefined |
| 110 | + return h('div', { class: 'swc-opt', title }, [ |
| 111 | + h('div', { class: 'swc-primary' }, primary), |
| 112 | + secondary ? h('div', { class: 'swc-secondary' }, secondary) : null |
| 113 | + ]) |
| 114 | +} |
| 115 | +
|
| 116 | +// 多选 tag 渲染 |
| 117 | +const renderSelectedTag = ({ option }: { option: any }) => { |
| 118 | + const title = props.selectedTooltip && option?.secondary ? `${option.primary} · ${option.secondary}` : undefined |
| 119 | + return h('span', { title }, option?.primary || '') |
| 120 | +} |
| 121 | +
|
| 122 | +// 透传属性,若无自定义 filter,则提供默认过滤(匹配主/副文本) |
| 123 | +const forwardedAttrs = computed(() => { |
| 124 | + const hasCustomFilter = Object.prototype.hasOwnProperty.call(attrs, 'filter') |
| 125 | + const internalFilter = (pattern: string, option: any) => { |
| 126 | + const p = (pattern || '').toLowerCase() |
| 127 | + return ( |
| 128 | + (option?.primary || '').toLowerCase().includes(p) || |
| 129 | + (option?.secondary || '').toLowerCase().includes(p) |
| 130 | + ) |
| 131 | + } |
| 132 | + const { ['onUpdate:value']: _omitUpdate, multiple: attrsMultiple, class: rootClass, ['menu-props']: menuPropsKebab, menuProps, ...rest } = attrs as any |
| 133 | +
|
| 134 | + // 规范:通过 class & menu-props.class 注入样式作用域,避免使用 :deep |
| 135 | + const mergedRootClass = [rootClass, 'swc-select'].filter(Boolean).join(' ') |
| 136 | + const mp = (menuPropsKebab || menuProps || {}) as Record<string, any> |
| 137 | + const mergedMenuClass = [mp.class, 'swc-select-menu'].filter(Boolean).join(' ') |
| 138 | + const normalizedMenuProps = { ...mp, class: mergedMenuClass } |
| 139 | +
|
| 140 | + return { |
| 141 | + filterable: true, |
| 142 | + multiple: attrsMultiple ?? props.multiple, |
| 143 | + class: mergedRootClass, |
| 144 | + menuProps: normalizedMenuProps, |
| 145 | + ...rest, |
| 146 | + filter: hasCustomFilter ? (attrs as any).filter : internalFilter |
| 147 | + } |
| 148 | +}) |
| 149 | +
|
| 150 | +const normalizedValue = computed(() => props.modelValue) |
| 151 | +
|
| 152 | +const onUpdateValue = (val: any) => { |
| 153 | + emit('update:modelValue', val) |
| 154 | + const cb = (attrs as any)['onUpdate:value'] |
| 155 | + if (typeof cb === 'function') cb(val) |
| 156 | +} |
| 157 | +
|
| 158 | +const emitConfig = () => emit('config') |
| 159 | +</script> |
| 160 | + |
| 161 | +<style scoped> |
| 162 | +.swc-opt { |
| 163 | + display: flex; |
| 164 | + flex-direction: column; |
| 165 | +} |
| 166 | +.swc-primary { |
| 167 | + font-weight: 500; |
| 168 | + line-height: 1.35; |
| 169 | +} |
| 170 | +.swc-secondary { |
| 171 | + font-size: 12px; |
| 172 | + opacity: 0.72; |
| 173 | + line-height: 1.3; |
| 174 | + white-space: normal; |
| 175 | +} |
| 176 | +</style> |
| 177 | + |
| 178 | +<style> |
| 179 | +/* 使用类作用域(通过 class & menu-props 注入),避免 :deep */ |
| 180 | +.swc-select-menu .n-base-select-option__content { |
| 181 | + white-space: normal; |
| 182 | + line-height: 1.35; |
| 183 | + display: block; |
| 184 | +} |
| 185 | +.swc-select-menu .n-base-select-option { |
| 186 | + align-items: flex-start; |
| 187 | + padding-top: 6px; |
| 188 | + padding-bottom: 6px; |
| 189 | +} |
| 190 | +.swc-select-menu .swc-opt { |
| 191 | + display: flex; |
| 192 | + flex-direction: column; |
| 193 | + align-items: flex-start; |
| 194 | + width: 100%; |
| 195 | +} |
| 196 | +.swc-select-menu .swc-primary { |
| 197 | + font-weight: 600; |
| 198 | + line-height: 1.35; |
| 199 | + margin-bottom: 2px; |
| 200 | +} |
| 201 | +.swc-select-menu .swc-secondary { |
| 202 | + font-size: 12px; |
| 203 | + opacity: 0.65; |
| 204 | + line-height: 1.25; |
| 205 | + white-space: normal; |
| 206 | + word-break: break-word; |
| 207 | +} |
| 208 | +/* 选中区仅显示主行 */ |
| 209 | +.swc-select .n-base-selection .swc-secondary, |
| 210 | +.swc-select .n-base-selection-label .swc-secondary { |
| 211 | + display: none; |
| 212 | +} |
| 213 | +</style> |
0 commit comments