feat(canvas): 下拉统一换成 SelectMenu 组件并支持向上展开

This commit is contained in:
2026-10-12 14:18:50 +08:00
parent af5bf84582
commit d934dbe419
2 changed files with 74 additions and 64 deletions
+4 -2
View File
@@ -13,6 +13,7 @@ const props = defineProps({
placeholder: { type: String, default: '请选择' }, placeholder: { type: String, default: '请选择' },
mono: { type: Boolean, default: false }, mono: { type: Boolean, default: false },
disabled: { type: Boolean, default: false }, disabled: { type: Boolean, default: false },
up: { type: Boolean, default: false }, // 面板向上展开(底部居中的输入框用)
}) })
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
@@ -90,8 +91,9 @@ onUnmounted(() => document.removeEventListener('mousedown', onDocClick))
leave-from-class="opacity-100 translate-y-0" leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-1"> leave-to-class="opacity-0 -translate-y-1">
<div v-if="open" ref="panel" <div v-if="open" ref="panel"
class="absolute z-30 mt-2 w-full max-h-64 overflow-auto rounded-xl border border-[color:var(--glass-border)] class="absolute z-30 w-full max-h-64 overflow-auto rounded-xl border border-[color:var(--glass-border)]
bg-[var(--menu-bg)] backdrop-blur-xl p-1.5 shadow-[var(--glass-shadow-lg)]"> bg-[var(--menu-bg)] backdrop-blur-xl p-1.5 shadow-[var(--glass-shadow-lg)]"
:class="up ? 'bottom-full mb-2' : 'mt-2'">
<button v-for="(o, i) in options" :key="o.value" type="button" <button v-for="(o, i) in options" :key="o.value" type="button"
:data-idx="i" @click="pick(o)" @mouseenter="active = i" :data-idx="i" @click="pick(o)" @mouseenter="active = i"
class="w-full flex items-center justify-between gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium transition-colors" class="w-full flex items-center justify-between gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium transition-colors"
+70 -62
View File
@@ -7,6 +7,7 @@ import { api, generatedUrl, jsonBody, thumbUrl } from '../api'
import { auth } from '../auth' import { auth } from '../auth'
import { normalizeDoc, newId, ratioToSize, serializeDoc, screenToWorld } from '../utils/canvas' import { normalizeDoc, newId, ratioToSize, serializeDoc, screenToWorld } from '../utils/canvas'
import CanvasStage from '../components/canvas/CanvasStage.vue' import CanvasStage from '../components/canvas/CanvasStage.vue'
import SelectMenu from '../components/SelectMenu.vue'
const stage = ref(null) const stage = ref(null)
@@ -39,6 +40,21 @@ const resolutions = computed(() => model.value?.resolutions?.length
? model.value.resolutions ? model.value.resolutions
: Object.keys(model.value?.prices || {})) : Object.keys(model.value?.prices || {}))
const durations = computed(() => model.value?.durations?.length ? model.value.durations : ['5s']) const durations = computed(() => model.value?.durations?.length ? model.value.durations : ['5s'])
// SelectMenu options(统一全站自定义下拉样式)
const kindOptions = [
{ value: 'image', label: '生图' },
{ value: 'video', label: '生视频' },
]
const modelOptions = computed(() =>
kindModels.value.map((m) => ({ value: m.id, label: m.alias || m.name || m.id })),
)
const ratioOptions = computed(() => ratios.value.map((r) => ({ value: r, label: r })))
const resolutionOptions = computed(() => resolutions.value.map((r) => ({ value: r, label: r })))
const durationOptions = computed(() => durations.value.map((d) => ({ value: d, label: d })))
const countOptions = [1, 2, 3, 4].map((n) => ({ value: n, label: `${n} 张` }))
const canvasOptions = computed(() =>
canvases.value.map((c) => ({ value: c.id, label: c.title || '未命名画布' })),
)
const maxRefs = computed(() => Math.min(8, Math.max( const maxRefs = computed(() => Math.min(8, Math.max(
Number(model.value?.max_reference_images || 0), model.value?.image_to_image ? 1 : 0, Number(model.value?.max_reference_images || 0), model.value?.image_to_image ? 1 : 0,
))) )))
@@ -794,13 +810,13 @@ const saveLabel = computed(() => ({
<div class="cv-top"> <div class="cv-top">
<div class="cv-top__left"> <div class="cv-top__left">
<button class="cv-btn" title="返回" @click="$router.push('/user')">←</button> <button class="cv-btn" title="返回" @click="$router.push('/user')">←</button>
<select <SelectMenu
class="cv-select" class="cv-dd cv-dd--canvas"
:value="currentId" :model-value="currentId"
@change="switchCanvas($event.target.value)" :options="canvasOptions"
> placeholder="选择画布"
<option v-for="c in canvases" :key="c.id" :value="c.id">{{ c.title }}</option> @update:model-value="switchCanvas"
</select> />
<button class="cv-btn" title="重命名" @click="renameCanvas">✎</button> <button class="cv-btn" title="重命名" @click="renameCanvas">✎</button>
<button class="cv-btn" title="新建画布" @click="newCanvas">+</button> <button class="cv-btn" title="新建画布" @click="newCanvas">+</button>
<button class="cv-btn cv-btn--danger" title="删除画布" @click="deleteCanvas">🗑</button> <button class="cv-btn cv-btn--danger" title="删除画布" @click="deleteCanvas">🗑</button>
@@ -881,52 +897,43 @@ const saveLabel = computed(() => ({
placeholder="描述你要生成的画面…(留空则使用选中的笔记)" placeholder="描述你要生成的画面…(留空则使用选中的笔记)"
/> />
<div class="cv-composer__bar"> <div class="cv-composer__bar">
<select v-model="gen.kind" class="cv-composer__sel" title="生成类型"> <SelectMenu v-model="gen.kind" class="cv-dd" :options="kindOptions" up />
<option value="image">生图</option> <SelectMenu
<option value="video">生视频</option>
</select>
<select
v-if="kindModels.length" v-if="kindModels.length"
v-model="gen.modelId" v-model="gen.modelId"
class="cv-composer__sel cv-composer__sel--model" class="cv-dd cv-dd--model"
title="生成模型" :options="modelOptions"
> placeholder="模型"
<option v-for="m in kindModels" :key="m.id" :value="m.id"> up
{{ m.alias || m.name || m.id }} />
</option> <SelectMenu
</select>
<select
v-if="model && ratios.length" v-if="model && ratios.length"
v-model="gen.ratio" v-model="gen.ratio"
class="cv-composer__sel" class="cv-dd"
title="比例" :options="ratioOptions"
> up
<option v-for="r in ratios" :key="r" :value="r">{{ r }}</option> />
</select> <SelectMenu
<select
v-if="gen.kind === 'image' && model && resolutions.length" v-if="gen.kind === 'image' && model && resolutions.length"
v-model="gen.resolution" v-model="gen.resolution"
class="cv-composer__sel" class="cv-dd"
title="画质" :options="resolutionOptions"
> up
<option v-for="r in resolutions" :key="r" :value="r">{{ r }}</option> />
</select> <SelectMenu
<select
v-if="gen.kind === 'video' && model && durations.length" v-if="gen.kind === 'video' && model && durations.length"
v-model="gen.duration" v-model="gen.duration"
class="cv-composer__sel" class="cv-dd"
title="时长" :options="durationOptions"
> up
<option v-for="d in durations" :key="d" :value="d">{{ d }}</option> />
</select> <SelectMenu
<select
v-if="gen.kind === 'image'" v-if="gen.kind === 'image'"
v-model.number="gen.count" v-model.number="gen.count"
class="cv-composer__sel" class="cv-dd"
title="生成张数" :options="countOptions"
> up
<option v-for="n in [1, 2, 3, 4]" :key="n" :value="n">{{ n }}张</option> />
</select>
<div v-if="!kindModels.length" class="cv-nomodels"> <div v-if="!kindModels.length" class="cv-nomodels">
⚠ 还没有可用的{{ gen.kind === 'video' ? '视频' : '图像' }}模型 ⚠ 还没有可用的{{ gen.kind === 'video' ? '视频' : '图像' }}模型
</div> </div>
@@ -1056,8 +1063,21 @@ const saveLabel = computed(() => ({
gap: 7px; gap: 7px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.cv-select { /* 统一自定义下拉的宽度/紧凑度(SelectMenu 内部是 .field 按钮) */
max-width: 220px; .cv-dd {
min-width: 96px;
}
.cv-dd--canvas {
width: 200px;
min-width: 0;
}
.cv-dd :deep(.field) {
height: 34px;
padding: 0 10px;
font-size: 12.5px;
}
.cv-dd--canvas :deep(.field) {
height: 30px;
} }
.cv-sep { .cv-sep {
width: 2.5px; width: 2.5px;
@@ -1185,7 +1205,7 @@ const saveLabel = computed(() => ({
backdrop-filter: blur(18px); backdrop-filter: blur(18px);
box-shadow: var(--glass-shadow-lg); box-shadow: var(--glass-shadow-lg);
transition: border-color 0.15s ease, box-shadow 0.15s ease; transition: border-color 0.15s ease, box-shadow 0.15s ease;
overflow: hidden; overflow: visible; /* 下拉面板要从输入框上方弹出,不能被裁切 */
} }
.cv-composer:focus-within { .cv-composer:focus-within {
border-color: rgb(124 92 252 / 0.6); border-color: rgb(124 92 252 / 0.6);
@@ -1285,24 +1305,12 @@ const saveLabel = computed(() => ({
flex-wrap: wrap; flex-wrap: wrap;
padding: 8px 12px; padding: 8px 12px;
border-top: 1px solid var(--ink-soft); border-top: 1px solid var(--ink-soft);
border-radius: 0 0 19px 19px; /* composer 不再裁切,圆角由最底层行补齐 */
} }
.cv-composer__sel { .cv-composer__bar .cv-dd--model {
padding: 5px 9px;
border-radius: 9px;
border: 1px solid var(--ink-soft);
background: transparent;
color: var(--fg);
font-size: 12px;
font-weight: 600;
outline: none;
max-width: 200px;
}
.cv-composer__sel:focus {
border-color: rgb(124 92 252 / 0.6);
}
.cv-composer__sel--model {
flex: 1; flex: 1;
min-width: 130px; min-width: 140px;
max-width: 260px;
} }
.cv-composer__bar .cv-gen { .cv-composer__bar .cv-gen {
margin-left: auto; margin-left: auto;