AI_Painting_V2.0/src/components/Select/index.vue
WangLeo b8ff25a8d7 feat: Video 平台控件配置驱动化 + UUID 模型标识 + 首尾帧双图上传
- Video 控件(proportion/time/ParamGroup)改为 config 驱动,根据 API 参数 schema 动态渲染选项
- 模型选择器改用 UUID(m.id)作为内部标识,避免同名 display_name 冲突导致错误模型配置
- getModelId 查找优先级:id → name → display_name,向下兼容
- imageUploadLimit 累加所有 imageUpload 参数 maxCount,支持首尾帧等双图模型
- buildTaskBody 将 referenceImages 按索引映射到 imageUpload 参数名
- 新增 ParamGroup(动态参数容器)+ SwitchControl(纯 CSS 开关)共享组件
- modelConfigHelper 扩展 resolution/duration 同步支持
- Select 组件 dropdown-item 添加 flex-shrink:0 防止 flex 压缩
- dialogBox 支持 beforeModel 控件分组渲染
2026-06-10 15:07:37 +08:00

398 lines
8.6 KiB
Vue

<template>
<div class="custom-select" :class="props.class">
<div
ref="headerRef"
class="select-header"
:class="{ open: isOpen }"
:style="{ width: headerWidth }"
@click.stop="toggleDropdown"
>
<slot name="prefix" />
<span class="select-text">{{ selectedOption || placeholder }}</span>
</div>
<div
v-if="isOpen"
ref="menuRef"
class="dropdown-menu"
:class="position"
>
<slot name="header" />
<template v-if="groupedOptions.length > 0">
<div
v-for="(group, groupIndex) in groupedOptions"
:key="groupIndex"
class="option-group"
>
<div v-if="group.label" class="group-title">{{ group.label }}</div>
<div
v-for="(option, index) in group.options"
:key="option.value || index"
class="dropdown-item"
:class="{ selected: option.value === selectedValue, disabled: option.disabled }"
@click.stop="!option.disabled && selectOption(option)"
>
<slot name="option" :option="option" :selected="option.value === selectedValue">
<div class="option-content">
<span class="option-label">{{ option.label }}</span>
<!-- <span v-if="option.value === selectedValue" class="option-check">✓</span> -->
</div>
</slot>
</div>
</div>
</template>
<template v-else>
<div
v-for="(option, index) in options"
:key="option.value || index"
class="dropdown-item"
:class="{ selected: option.value === selectedValue, disabled: option.disabled }"
@click.stop="!option.disabled && selectOption(option)"
>
<slot name="option" :option="option" :selected="option.value === selectedValue">
<div class="option-content">
<span class="option-label">{{ option.label }}</span>
<!-- <span v-if="option.value === selectedValue" class="option-check"></span> -->
</div>
</slot>
</div>
</template>
</div>
</div>
</template>
<script setup>
const props = defineProps({
modelValue: {
type: [String, Number],
default: ''
},
isArrow: {
type: Boolean,
default: true
},
options: {
type: Array,
default: () => []
},
groupedOptions: {
type: Array,
default: () => []
},
placeholder: {
type: String,
default: '请选择'
},
position: {
type: String,
default: 'bottom'
},
width: {
type: [String, Number],
default: 'auto'
},
background: {
type: String,
default: '#ffffff'
},
class: {
type: [String, Object, Array],
default: ''
}
})
const emit = defineEmits(['update:modelValue'])
const selectId = ref(Math.random().toString(36).substr(2, 9))
const isOpen = ref(false)
const selectedValue = ref(props.modelValue)
const headerRef = ref(null)
const menuRef = ref(null)
const menuWidth = ref('100px')
if (!window.__currentOpenSelectId__) {
window.__currentOpenSelectId__ = null
}
const headerWidth = computed(() => {
if (typeof props.width === 'number') {
return `${props.width}px`
}
return props.width
})
const selectedOption = computed(() => {
let option = props.options.find((opt) => opt.value === selectedValue.value)
if (!option && props.groupedOptions.length > 0) {
for (const group of props.groupedOptions) {
option = group.options.find((opt) => opt.value === selectedValue.value)
if (option) break
}
}
return option ? option.label : ''
})
const toggleDropdown = async () => {
if (isOpen.value) {
isOpen.value = false
window.__currentOpenSelectId__ = null
} else {
if (window.__currentOpenSelectId__ && window.__currentOpenSelectId__ !== selectId.value) {
window.dispatchEvent(new CustomEvent('close-other-selects', { detail: { excludeId: selectId.value } }))
}
if (window.__currentOpenPopoverId__) {
window.dispatchEvent(new CustomEvent('close-other-popovers', { detail: { excludeId: null } }))
}
isOpen.value = true
window.__currentOpenSelectId__ = selectId.value
await nextTick()
if (headerRef.value) {
menuWidth.value = `${headerRef.value.offsetWidth}px`
}
}
}
const selectOption = (option) => {
selectedValue.value = option.value
emit('update:modelValue', option.value)
isOpen.value = false
window.__currentOpenSelectId__ = null
}
watch(() => props.modelValue, (newValue) => {
selectedValue.value = newValue
})
const handleClickOutside = (e) => {
if (menuRef.value && !menuRef.value.contains(e.target) && headerRef.value && !headerRef.value.contains(e.target)) {
isOpen.value = false
window.__currentOpenSelectId__ = null
}
}
const handleCloseOtherSelects = (e) => {
if (e.detail.excludeId !== selectId.value) {
isOpen.value = false
}
}
const handleCloseOtherPopovers = () => {
isOpen.value = false
window.__currentOpenSelectId__ = null
}
document.addEventListener('click', handleClickOutside)
window.addEventListener('close-other-selects', handleCloseOtherSelects)
window.addEventListener('close-other-popovers', handleCloseOtherPopovers)
onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside)
window.removeEventListener('close-other-selects', handleCloseOtherSelects)
window.removeEventListener('close-other-popovers', handleCloseOtherPopovers)
})
</script>
<style scoped>
.custom-select {
position: relative;
display: inline-block;
}
.select-header {
width: v-bind(width);
height: 28px;
background-color: v-bind(background);
border-radius: 10px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 15px;
cursor: pointer;
transition: all 0.3s ease;
gap: 8px;
}
.select-header:hover {
background-color: #E9EBEF;
}
.select-header :deep(.prefix-slot) {
display: flex;
align-items: center;
flex-shrink: 0;
}
.select-text {
font-family: 'Microsoft YaHei';
font-size: 12px;
color: #333333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.arrow-icon {
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.3s ease;
font-size: 12px;
color: #666666;
}
.arrow-icon.rotate {
transform: rotate(180deg);
}
.dropdown-menu {
position: absolute;
background-color: #FFFFFF;
border-radius: 16px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08);
padding: 20px 10px;
z-index: 1000;
display: flex;
flex-direction: column;
left: 50%;
transform: translateX(-50%);
min-width: 50px;
width: max-content;
border: 1px solid #e8e8e8;
animation: fadeIn 0.2s ease;
gap: 10px;
max-height: 360px;
overflow-y: auto;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateX(-50%) translateY(-8px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
.dropdown-menu.bottom {
top: 100%;
left: 50%;
transform: translateX(-50%);
margin-top: 8px;
}
.dropdown-menu.top {
bottom: 100%;
left: 50%;
transform: translateX(-50%);
margin-bottom: 8px;
}
.dropdown-menu.left {
right: 100%;
top: 0;
margin-left: 8px;
}
.dropdown-menu.right {
left: 100%;
top: 0;
margin-right: 8px;
}
.dropdown-item {
cursor: pointer;
transition: all 0.2s ease;
position: relative;
padding: 0px 10px;
min-width: 120px;
white-space: nowrap;
width: 100%;
box-sizing: border-box;
border-radius: 5px;
height: 36px;
flex-shrink: 0;
display: flex;
align-items: center;
color: #666;
font-family: "Microsoft YaHei";
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: normal;
}
.dropdown-item:hover:not(.disabled) {
color: #333333;
background-color: #f5f6f7;
border-radius: 10px;
}
.dropdown-item.disabled {
cursor: not-allowed;
opacity: 0.5;
}
.dropdown-item.selected {
color: #333;
font-weight: 400;
background-color: rgba(0, 15, 51, 0.10);
border-radius: 10px;
.option-label {
color: #000F33;
}
}
.option-content {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.option-label {
flex: 1;
}
/* .option-check {
color: #333333;
font-weight: bold;
font-size: 16px;
margin-left: 12px;
animation: checkIn 0.2s ease;
} */
@keyframes checkIn {
from {
opacity: 0;
transform: scale(0.5);
}
to {
opacity: 1;
transform: scale(1);
}
}
.option-group {
/* margin-bottom: 10px; */
&:last-child {
margin-bottom: 0;
}
}
.group-title {
margin-left: 10px;
margin-bottom: 5px;
color: #999;
font-family: "Microsoft YaHei";
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: normal;
}
</style>