feat: 新增 Painting 平台包(descriptor + 控件迁移)

This commit is contained in:
王佑琳 2026-06-09 11:34:23 +08:00
parent d2a04613d5
commit 705a7a7ebf
7 changed files with 1559 additions and 0 deletions

View File

@ -0,0 +1,247 @@
<template>
<Popover placement="top">
<div class="dimension-container">
<div class="section">
<h3>尺寸 (px)</h3>
<div class="size-inputs">
<div class="input-group">
<label>W</label>
<input
type="number"
v-model.number="localWidth"
:min="minW"
:max="maxW"
@input="onWidthChange"
>
</div>
<div class="lock-icon" :class="{ locked: isLocked }" @click="toggleLock">
<img :src="isLocked ? lockIcon : lockNoIcon" alt="约束比例">
<span class="tooltip">{{ isLocked ? '解绑比例' : '约束比例' }}</span>
</div>
<div class="input-group">
<label>H</label>
<input
type="number"
v-model.number="localHeight"
:min="minH"
:max="maxH"
@input="onHeightChange"
>
</div>
</div>
</div>
</div>
<template #reference>
<div class="choice-btn">
<img src="@/assets/dialog/proportion.svg" alt="" style="width: 16px;">
<span>{{ displayText }}</span>
</div>
</template>
</Popover>
</template>
<script setup>
import Popover from '@/components/Popover/index.vue'
import lockIcon from '@/assets/dialog/lock.svg'
import lockNoIcon from '@/assets/dialog/lockNo.svg'
const props = defineProps({
width: { type: Number, default: 1024 },
height: { type: Number, default: 1024 },
minW: { type: Number, default: 256 },
maxW: { type: Number, default: 6197 },
minH: { type: Number, default: 256 },
maxH: { type: Number, default: 4096 },
})
const emit = defineEmits(['update:width', 'update:height'])
const localWidth = ref(props.width)
const localHeight = ref(props.height)
const isLocked = ref(true)
const lastRatio = ref(props.width / props.height)
const displayText = computed(() => `${localWidth.value} × ${localHeight.value}`)
watch(() => props.width, (val) => { localWidth.value = val })
watch(() => props.height, (val) => { localHeight.value = val })
const toggleLock = () => {
isLocked.value = !isLocked.value
if (isLocked.value) {
lastRatio.value = localWidth.value / localHeight.value
}
}
const clamp = (val, min, max) => Math.max(min, Math.min(max, Math.round(val)))
const onWidthChange = () => {
localWidth.value = clamp(localWidth.value, props.minW, props.maxW)
if (isLocked.value) {
localHeight.value = clamp(Math.round(localWidth.value / lastRatio.value), props.minH, props.maxH)
}
emit('update:width', localWidth.value)
emit('update:height', localHeight.value)
}
const onHeightChange = () => {
localHeight.value = clamp(localHeight.value, props.minH, props.maxH)
if (isLocked.value) {
localWidth.value = clamp(Math.round(localHeight.value * lastRatio.value), props.minW, props.maxW)
}
emit('update:width', localWidth.value)
emit('update:height', localHeight.value)
}
watch([localWidth, localHeight], () => {
if (!isLocked.value) {
lastRatio.value = localWidth.value / localHeight.value
}
})
</script>
<style lang="less" scoped>
.choice-btn {
display: flex;
height: 40px;
padding: 0 15px;
justify-content: center;
align-items: center;
gap: 5px;
border-radius: 10px;
border: 1px solid #E8E9EB;
background: #f5f6f7;
cursor: pointer;
position: relative;
span {
font-family: "Microsoft YaHei";
font-size: 14px;
color: #333;
}
}
.choice-btn:hover {
background: #e9eaeb;
}
.dimension-container {
padding: 20px;
min-width: 280px;
}
.section {
border-radius: 20px;
h3 {
font-family: "Microsoft YaHei";
font-size: 12px;
font-weight: 400;
margin-bottom: 12px;
color: #999;
}
}
.size-inputs {
display: flex;
align-items: center;
gap: 10px;
}
.input-group {
flex: 1;
min-width: 0;
position: relative;
label {
position: absolute;
top: 50%;
left: 12px;
transform: translateY(-50%);
font-size: 14px;
color: #666;
pointer-events: none;
}
input {
box-sizing: border-box;
width: 100%;
height: 36px;
padding: 12px 12px 12px 30px;
border: none;
border-radius: 8px;
font-size: 14px;
background: #f5f6f7;
text-align: right;
-moz-appearance: textfield;
&::-webkit-inner-spin-button,
&::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
&:focus {
outline: none;
}
}
}
.lock-icon {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
cursor: pointer;
border-radius: 10px;
position: relative;
transition: background 0.2s ease;
img {
width: 36px;
height: 36px;
}
.tooltip {
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background: #333;
color: #fff;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
margin-bottom: 5px;
pointer-events: none;
}
.tooltip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: #333;
}
&:hover {
opacity: 0.8;
.tooltip {
opacity: 1;
visibility: visible;
}
}
&.locked {
background: #f5f6f7;
}
}
</style>

View File

@ -0,0 +1,465 @@
<template>
<Popover placement="top">
<div class="proportion-container">
<div class="section">
<h3>选择比例</h3>
<div class="proportion-options">
<div
v-for="item in proportionOptions"
:key="item.value"
class="proportion-item"
:class="{ active: proportion === item.value }"
:style="getProportionStyle(item.value)"
@click="selectProportion(item.value)"
>
{{ item.label }}
</div>
</div>
</div>
<div v-if="resolutionOptions.length > 0" class="section">
<h3>选择分辨率</h3>
<div class="resolution-options">
<div
v-for="item in resolutionOptions"
:key="item.value"
class="resolution-item"
:class="{ active: resolution === item.value }"
@click="selectResolution(item.value)"
>
{{ item.label }}
</div>
</div>
</div>
<div v-if="allowCustom" class="section">
<h3>尺寸(px)</h3>
<div class="size-inputs">
<div class="input-group">
<label>W</label>
<input type="number" v-model.number="width" @input="updateWidth" :disabled="isLocked">
</div>
<div class="lock-icon" :class="{ locked: isLocked }" @click="toggleLock">
<img :src="isLocked ? lockIcon : lockNoIcon" alt="约束比例">
<span class="tooltip">{{ isLocked ? '解绑比例' : '约束比例' }}</span>
</div>
<div class="input-group">
<label>H</label>
<input type="number" v-model.number="height" @input="updateHeight" :disabled="isLocked">
</div>
</div>
</div>
</div>
<template #reference>
<div class="choice-btn">
<img src="@/assets/dialog/proportion.svg" alt="" style="width: 16px;">
<span>{{ proportion }}</span>
</div>
</template>
</Popover>
</template>
<script setup>
import Popover from '@/components/Popover/index.vue'
import lockIcon from '@/assets/dialog/lock.svg'
import lockNoIcon from '@/assets/dialog/lockNo.svg'
const props = defineProps({
modelValue: {
type: String,
default: '1:1'
},
resolution: {
type: String,
default: '2k'
},
width: {
type: Number,
default: 2048
},
height: {
type: Number,
default: 2048
},
proportionOptions: {
type: Array,
default: () => [
{ value: '智能', label: '智能' },
{ value: '21:9', label: '21:9' },
{ value: '16:9', label: '16:9' },
{ value: '4:3', label: '4:3' },
{ value: '1:1', label: '1:1' },
{ value: '3:4', label: '3:4' },
{ value: '9:16', label: '9:16' }
]
},
allowCustom: {
type: Boolean,
default: true,
},
resolutionOptions: {
type: Array,
default: () => [
{ value: '1k', label: '标清 1K' },
{ value: '2k', label: '高清 2K' },
{ value: '4k', label: '超清 4K' }
]
}
})
const emit = defineEmits(['update:modelValue', 'update:resolution', 'update:width', 'update:height'])
const proportion = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const resolution = computed({
get: () => props.resolution,
set: (value) => emit('update:resolution', value)
})
const width = ref(props.width)
const height = ref(props.height)
const isLocked = ref(true)
const toggleLock = () => {
isLocked.value = !isLocked.value
}
const selectProportion = (value) => {
proportion.value = value
updateDimensionsByProportion(value)
}
const selectResolution = (value) => {
resolution.value = value
updateDimensionsByResolution(value)
}
const updateDimensionsByProportion = (proportionValue) => {
if (proportionValue === '智能') {
return
}
const [w, h] = proportionValue.split(':').map(Number)
const aspectRatio = w / h
if (width.value > height.value) {
height.value = Math.round(width.value / aspectRatio)
} else {
width.value = Math.round(height.value * aspectRatio)
}
emitUpdateDimensions()
}
const updateDimensionsByResolution = (resolutionValue) => {
let baseSize
switch (resolutionValue) {
case '1k':
baseSize = 1024
break
case '2k':
baseSize = 2048
break
case '4k':
baseSize = 4096
break
default:
baseSize = 2048
}
if (proportion.value === '智能') {
width.value = baseSize
height.value = baseSize
} else {
const [w, h] = proportion.value.split(':').map(Number)
const aspectRatio = w / h
if (aspectRatio > 1) {
width.value = baseSize
height.value = Math.round(baseSize / aspectRatio)
} else {
height.value = baseSize
width.value = Math.round(baseSize * aspectRatio)
}
}
emitUpdateDimensions()
}
const updateWidth = () => {
if (isLocked.value && proportion.value !== '智能') {
const [w, h] = proportion.value.split(':').map(Number)
const aspectRatio = w / h
height.value = Math.round(width.value / aspectRatio)
}
emitUpdateDimensions()
}
const updateHeight = () => {
if (isLocked.value && proportion.value !== '智能') {
const [w, h] = proportion.value.split(':').map(Number)
const aspectRatio = w / h
width.value = Math.round(height.value * aspectRatio)
}
emitUpdateDimensions()
}
const emitUpdateDimensions = () => {
emit('update:width', width.value)
emit('update:height', height.value)
}
const getProportionStyle = (value) => {
if (value === '智能') {
return {
'--width': '20px',
'--height': '20px'
}
}
const [w, h] = value.split(':').map(Number)
const aspectRatio = w / h
const baseSize = 20
if (aspectRatio > 1) {
return {
'--width': `${baseSize}px`,
'--height': `${Math.round(baseSize / aspectRatio)}px`
}
} else {
return {
'--width': `${Math.round(baseSize * aspectRatio)}px`,
'--height': `${baseSize}px`
}
}
}
watch(() => [props.modelValue, props.resolution], () => {
updateDimensionsByResolution(resolution.value)
}, { immediate: true })
</script>
<style lang="less" scoped>
.choice-btn{
display: flex;
height: 40px;
padding: 0 15px;
justify-content: center;
align-items: center;
gap: 5px;
border-radius: 10px;
border: 1px solid #E8E9EB;
background: #f5f6f7;
cursor: pointer;
position: relative;
}
.choice-btn:hover{
background: #e9eaeb;
}
.proportion-container{
padding: 20px;
min-width: 300px;
}
.section{
margin-bottom: 20px;
border-radius: 20px;
&:last-child{
margin-bottom: 0;
}
h3{
font-family: "Microsoft YaHei";
font-size: 12px;
font-weight: 400;
margin-bottom: 12px;
color: #999;
}
}
.proportion-options{
display: flex;
flex-wrap: nowrap;
gap: 5px;
margin-bottom: 16px;
background-color: #F8F9FA;
padding: 5px;
border-radius: 10px;
}
.proportion-item{
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: flex-end;
gap: 4px;
padding: 5px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s ease;
border-radius: 5px;
text-align: bottom;
color: #999;
&::before{
content: '';
width: var(--width, 20px);
height: var(--height, 20px);
background: #F5F6F7;
border-radius: 4px;
transition: all 0.2s ease;
border: 2px solid #999;
}
&:hover{
background: #e0e0e0;
}
&.active{
color: #000F33;
background: #ffffff;
}
&.active::before{
border-color: #000F33;
}
}
.resolution-options{
display: flex;
padding: 5px;
align-items: center;
align-self: stretch;
border-radius: 10px;
background: #F8F9FA;
gap: 10px;
}
.resolution-item{
flex: 1;
padding: 10px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
text-align: center;
transition: all 0.2s ease;
color: #666;
&:hover{
background: #e0e0e0;
}
&.active{
background: #ffffff;
color: #000000;
font-weight: 500;
}
}
.size-inputs{
display: flex;
align-items: center;
gap: 10px;
}
.input-group{
flex: 1;
min-width: 0;
position: relative;
label{
position: absolute;
top: 50%;
left: 12px;
transform: translateY(-50%);
font-size: 14px;
color: #666;
pointer-events: none;
}
input{
box-sizing: border-box;
width: 100%;
height: 36px;
padding: 12px 12px 12px 30px;
border: none;
border-radius: 8px;
font-size: 14px;
background: #f5f6f7;
text-align: right;
-moz-appearance: textfield;
&::-webkit-inner-spin-button,
&::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
&:focus{
outline: none;
}
&:disabled{
color: #999;
cursor: not-allowed;
}
}
}
.lock-icon{
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
cursor: pointer;
border-radius: 10px;
position: relative;
transition: background 0.2s ease;
img{
width: 36px;
height: 36px;
}
.tooltip{
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background: #333;
color: #fff;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
margin-bottom: 5px;
pointer-events: none;
}
.tooltip::after{
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: #333;
}
&:hover{
opacity: 0.8;
.tooltip{
opacity: 1;
visibility: visible;
}
}
&.locked{
background: #f5f6f7;
}
}
</style>

View File

@ -0,0 +1,46 @@
<template>
<Select
v-model="model"
:options="options"
width="auto"
class="quality-select"
>
<template #prefix>
<span class="quality-label">画质</span>
</template>
</Select>
</template>
<script setup>
import Select from '@/components/Select/index.vue'
const props = defineProps({
modelValue: { type: String, default: 'medium' },
options: { type: Array, default: () => [] },
})
const emit = defineEmits(['update:modelValue'])
const model = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
</script>
<style lang="less" scoped>
.quality-select {
:deep(.select-header) {
height: 40px;
padding: 0 15px;
border-radius: 10px;
border: 1px solid #E8E9EB;
background: #f5f6f7;
&:hover { background: #e9eaeb; }
}
:deep(.select-text) { font-size: 14px; }
}
.quality-label {
font-family: "Microsoft YaHei";
font-size: 12px;
color: #999;
}
</style>

View File

@ -0,0 +1,67 @@
<template>
<Select
v-model="quantity"
:options="quantityOptions"
class="quantity-select"
position="top"
>
<template #prefix>
<img src="@/assets/dialog/quantity.svg" alt="" style="width: 16px;">
</template>
</Select>
</template>
<script setup>
import Select from '@/components/Select/index.vue'
const props = defineProps({
modelValue: {
type: Number,
default: 1
},
max: {
type: Number,
default: 4
}
})
const emit = defineEmits(['update:modelValue'])
const quantity = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const quantityOptions = computed(() =>
Array.from({ length: props.max }, (_, i) => ({ value: i + 1, label: `${i + 1}` }))
)
</script>
<style lang="less" scoped>
.quantity-select {
:deep(.select-header) {
height: 40px;
padding: 0 15px;
border-radius: 10px;
border: 1px solid #E8E9EB;
background: #f5f6f7;
&:hover {
background: #e9eaeb;
}
}
:deep(.select-text) {
font-size: 14px;
}
:deep(.dropdown-menu) {
min-width: 80px;
}
:deep(.dropdown-item) {
min-width: 80px;
justify-content: center;
}
}
</style>

View File

@ -0,0 +1,312 @@
<template>
<div class="image-uploader">
<div class="image-list">
<div
v-for="(item, index) in localPreviewList"
:key="item.uid"
class="image-item"
@click.stop="handleImageClick(index)"
>
<img :src="item.url" class="uploaded-image" alt="上传的图片" />
<div class="image-index">{{ index + 1 }}</div>
<div class="delete-icon" @click.stop="handleDelete(index)">
<i-ep-close />
</div>
</div>
<div v-if="localPreviewList.length < limit" class="upload-trigger" @click="triggerUpload">
<i-ep-plus color="#333333" />
<div class="upload-text">参考内容</div>
</div>
</div>
<el-upload
ref="uploadRef"
v-show="false"
:action="uploadurl"
multiple
:limit="limit"
:before-upload="beforeUpload"
:on-success="handleSuccess"
:on-error="handleError"
:on-exceed="handleExceed"
/>
</div>
</template>
<script setup>
import { genFileId } from 'element-plus'
const props = defineProps({
limit: {
type: Number,
default: 1
},
/**
* 图片列表每个元素包含 url uid 属性
*/
modelValue: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['update:modelValue', 'open-canvas'])
const uploadurl = import.meta.env.VITE_API_WORKFLOW_UPLOAD
const uploadRef = ref(null)
const imageList = ref([...props.modelValue])
const localPreviewList = ref([...props.modelValue])
const isUploading = ref(false)
watch(() => props.modelValue, async (newVal) => {
if (isUploading.value) {
return
}
imageList.value = [...newVal]
const newPreviewList = []
for (const img of newVal) {
let previewImg = { ...img }
if (img.url && !img.url.startsWith('blob:')) {
try {
const response = await fetch(img.url)
const blob = await response.blob()
previewImg = {
...img,
url: URL.createObjectURL(blob),
serverUrl: img.url
}
} catch (error) {
console.error('Failed to create blob URL:', error)
previewImg = {
...img,
serverUrl: img.url
}
}
}
newPreviewList.push(previewImg)
}
localPreviewList.value = newPreviewList
}, { deep: true, immediate: true })
const triggerUpload = () => {
uploadRef.value.$el.querySelector('input').click()
}
const beforeUpload = (rawFile) => {
const allowedTypes = ['image/jpeg', 'image/png']
if (!allowedTypes.includes(rawFile.type)) {
ElMessage.error('不支持的文件类型,请上传 JPG、PNG 格式的图片')
return false
}
if (rawFile.size / 1024 / 1024 > 10) {
ElMessage.error('图片大小不能超过 10MB')
return false
}
}
const handleSuccess = (response, uploadFile) => {
ElMessage.success('上传成功')
isUploading.value = true
const localUrl = URL.createObjectURL(uploadFile.raw)
const newImage = {
uid: uploadFile.uid,
url: response.url
}
imageList.value.push(newImage)
emit('update:modelValue', [...imageList.value])
const newPreview = {
uid: uploadFile.uid,
url: localUrl,
serverUrl: response.url
}
localPreviewList.value.push(newPreview)
nextTick(() => {
isUploading.value = false
})
}
const handleError = () => {
ElMessage.error('上传失败,请重新选择图片')
}
const handleExceed = () => {
ElMessage.warning(`最多只能上传 ${props.limit} 张图片`)
}
const handleDelete = (index) => {
const previewItem = localPreviewList.value[index]
if (previewItem && previewItem.url && previewItem.url.startsWith('blob:')) {
URL.revokeObjectURL(previewItem.url)
}
localPreviewList.value.splice(index, 1)
imageList.value.splice(index, 1)
emit('update:modelValue', [...imageList.value])
}
const handleImageClick = (clickedIndex) => {
const clickedImage = localPreviewList.value[clickedIndex]
if (!clickedImage) return
const otherImages = localPreviewList.value
.filter((_, index) => index !== clickedIndex)
.map((img, index) => ({
...img,
displayIndex: index + 2
}))
emit('open-canvas', {
mainImage: clickedImage,
referenceImages: otherImages
})
}
const handleSelect = async (url) => {
const initialFile = await fetch(url)
const blob = await initialFile.blob()
const file = new File([blob], `selected_image_${Date.now()}.jpg`, { type: blob.type })
file.uid = genFileId()
if (props.limit === 1 && imageList.value.length === 1) {
imageList.value = []
}
uploadRef.value.handleStart(file)
uploadRef.value.submit()
}
defineExpose({
handleSelect,
triggerUpload
})
</script>
<style lang="less" scoped>
.image-uploader {
display: flex;
align-items: center;
}
.image-list {
display: flex;
gap: 8px;
align-items: center;
}
.image-item {
position: relative;
width: 56px;
height: 56px;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
transform: scale(1.1);
z-index: 10;
.delete-icon {
opacity: 1;
}
}
.uploaded-image {
width: 100%;
height: 100%;
object-fit: cover;
transition: all 0.3s ease;
border-radius: 8px;
}
.image-index {
position: absolute;
bottom: 4px;
right: 4px;
width: 20px;
height: 20px;
background: rgba(0, 15, 51, 0.8);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 12px;
font-weight: bold;
z-index: 5;
transition: all 0.3s ease;
}
.delete-icon {
position: absolute;
top: -6px;
right: -6px;
width: 20px;
height: 20px;
background: #ef4444;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 12px;
cursor: pointer;
opacity: 0;
transition: all 0.3s ease;
z-index: 20;
&:hover {
background: #dc2626;
transform: scale(1.1);
}
}
}
.upload-trigger {
width: 56px;
height: 56px;
// border: 2px dashed #d1d5db;
background: rgba(0, 15, 51, 0.04);
backdrop-filter: blur(5px);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
cursor: pointer;
color: #9ca3af;
font-size: 15px;
transition: all 0.3s ease;
span {
color: #333;
font-family: "Microsoft YaHei";
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: normal;
}
&:hover {
background-color: #F0F1F2;
border-color: #626aef;
// color: #626aef;
transform: scale(1.05);
}
}
.upload-text {
color: #666;
font-family: "Microsoft YaHei";
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: normal;
}
</style>

View File

@ -0,0 +1,254 @@
import { ref, reactive, computed, markRaw } from 'vue'
import { fetchPlatformModels, getPlatformCode } from '@/utils/modelApi'
import { getModelConfig } from '@/config/models/index.js'
import PaintingModelSelector from './modelSelector.vue'
import PaintingProportion from './controls/proportion.vue'
import DimensionInput from './controls/dimension.vue'
import QualitySelect from './controls/quality.vue'
import Quantity from './controls/quantity.vue'
import ImageUploader from './imageUploader.vue'
import { registerPlatform } from '../registry.js'
function getDimConfig(config) {
if (!config) return null
const dimParam = config.params.find(p => p.ui === 'dimension')
if (dimParam) return { type: 'combined', config: dimParam.dimension, paramName: dimParam.name }
const wParam = config.params.find(p => p.ui === 'dimensionWidth')
const hParam = config.params.find(p => p.ui === 'dimensionHeight')
if (wParam && hParam) return { type: 'split', wParam, hParam }
return null
}
export function definePaintingPlatform() {
const model = ref('Flux 2')
const modelType = ref('text')
const proportion = ref('1:1')
const resolution = ref('2k')
const customWidth = ref(1024)
const customHight = ref(1024)
const dimWidth = ref(1024)
const dimHeight = ref(1024)
const quantity = ref(1)
const quality = ref('medium')
const modelConfig = ref(null)
const promptPlaceholder = ref('描述你想生成的画面和动作。')
const paramValues = reactive({})
const state = {
model, modelType,
proportion, resolution,
customWidth, customHight,
dimWidth, dimHeight,
quantity, quality,
paramValues, modelConfig,
}
function syncDefaults(config) {
modelConfig.value = config
if (!config) return
config.params.forEach(p => {
if (!(p.name in paramValues)) {
paramValues[p.name] = p.default ?? (p.name === 'outputFormat' ? 'png' : '')
}
})
const ratioParam = config.params.find(p => p.ui === 'proportion')
if (ratioParam) proportion.value = ratioParam.default || '1:1'
const resParam = config.params.find(p => p.ui === 'resolution')
if (resParam) resolution.value = resParam.default || '2k'
const qtyParam = config.params.find(p => p.ui === 'quantity')
if (qtyParam) quantity.value = qtyParam.default || 1
const cwParam = config.params.find(p => p.name === 'customWidth')
if (cwParam) customWidth.value = cwParam.default || 1024
const chParam = config.params.find(p => p.name === 'customHight')
if (chParam) customHight.value = chParam.default || 1024
const qualityParam = config.params.find(p => p.name === 'quality')
if (qualityParam) quality.value = qualityParam.default || 'medium'
const dc = getDimConfig(config)
if (dc?.type === 'split') {
dimWidth.value = dc.wParam.default || 1024
dimHeight.value = dc.hParam.default || 1024
} else if (dc?.type === 'combined') {
const dimParam = config.params.find(p => p.name === dc.paramName)
const raw = dimParam?.default || ''
const parsed = dc.config.parse(raw)
dimWidth.value = parsed.width
dimHeight.value = parsed.height
}
}
function syncParamValues() {
const ratioParam = modelConfig.value?.params?.find(p => p.ui === 'proportion')
if (ratioParam) paramValues[ratioParam.name] = proportion.value
const resParam = modelConfig.value?.params?.find(p => p.ui === 'resolution')
if (resParam) paramValues[resParam.name] = resolution.value
const qtyParam = modelConfig.value?.params?.find(p => p.ui === 'quantity')
if (qtyParam) paramValues[qtyParam.name] = quantity.value
if (modelConfig.value?.params?.find(p => p.name === 'customWidth')) {
paramValues.customWidth = customWidth.value
}
if (modelConfig.value?.params?.find(p => p.name === 'customHight')) {
paramValues.customHight = customHight.value
}
if (modelConfig.value?.params?.find(p => p.name === 'quality')) {
paramValues.quality = quality.value
}
const dc = getDimConfig(modelConfig.value)
if (dc?.type === 'split') {
paramValues[dc.wParam.name] = dimWidth.value
paramValues[dc.hParam.name] = dimHeight.value
} else if (dc?.type === 'combined') {
paramValues[dc.paramName] = dc.config.format(dimWidth.value, dimHeight.value)
}
}
const controls = [
{
name: 'proportion',
component: markRaw(PaintingProportion),
show: (config) => !!config?.params?.find(p => p.ui === 'proportion'),
props: (config) => {
const ratioParam = config?.params?.find(p => p.ui === 'proportion')
const resParam = config?.params?.find(p => p.ui === 'resolution')
return {
modelValue: proportion.value,
'onUpdate:modelValue': (v) => { proportion.value = v },
resolution: resolution.value,
'onUpdate:resolution': (v) => { resolution.value = v },
width: customWidth.value,
'onUpdate:width': (v) => { customWidth.value = v },
height: customHight.value,
'onUpdate:height': (v) => { customHight.value = v },
proportionOptions: ratioParam?.options
?.filter(o => o !== 'custom')
.map(o => ({ value: o, label: o })) || [],
resolutionOptions: resParam?.options
?.map(o => ({ value: o, label: o.toUpperCase() })) || [],
allowCustom: ratioParam?.options?.includes('custom') || false,
}
},
},
{
name: 'dimension',
component: markRaw(DimensionInput),
show: (config) => !!config?.params?.find(p => p.ui === 'dimension' || p.ui === 'dimensionWidth'),
props: (config) => {
const dc = getDimConfig(config)
return {
width: dimWidth.value,
'onUpdate:width': (v) => { dimWidth.value = v },
height: dimHeight.value,
'onUpdate:height': (v) => { dimHeight.value = v },
minW: dc?.config?.width?.min || dc?.wParam?.min || 256,
maxW: dc?.config?.width?.max || dc?.wParam?.max || 6197,
minH: dc?.config?.height?.min || dc?.hParam?.min || 256,
maxH: dc?.config?.height?.max || dc?.hParam?.max || 4096,
}
},
},
{
name: 'quality',
component: markRaw(QualitySelect),
show: (config) => !!config?.params?.find(p => p.name === 'quality'),
props: (config) => {
const q = config?.params?.find(p => p.name === 'quality')
return {
modelValue: quality.value,
'onUpdate:modelValue': (v) => { quality.value = v },
options: q?.options?.map(o => ({ value: o, label: o })) || [],
}
},
},
{
name: 'quantity',
component: markRaw(Quantity),
show: (config) => !!config?.params?.find(p => p.ui === 'quantity'),
props: (config) => {
const qtyParam = config?.params?.find(p => p.ui === 'quantity')
return {
modelValue: quantity.value,
'onUpdate:modelValue': (v) => { quantity.value = v },
max: qtyParam?.options?.length ? Math.max(...qtyParam.options) : 4,
}
},
},
]
const platform = {
id: 'painting',
label: 'AI绘画2026',
ModelSelector: markRaw(PaintingModelSelector),
modelSelectorProps: null,
controls,
ImageUploader: markRaw(ImageUploader),
state,
model,
modelType,
modelConfig,
promptPlaceholder,
async loadModels() {
const code = getPlatformCode('Painting')
return fetchPlatformModels(code)
},
async loadConfig(modelName) {
const config = getModelConfig(modelName)
syncDefaults(config)
return config
},
getDefaultModel() {
return 'Flux 2'
},
showImageUploader() {
if (modelType.value !== 'text') return true
return modelConfig.value?.inputType === 'image' || modelConfig.value?.inputType === 'both'
},
imageUploadLimit() {
if (!modelConfig.value) return 4
const imageParam = modelConfig.value.params.find(p => p.ui === 'imageUpload')
return imageParam?.maxCount || modelConfig.value.maxImages || 4
},
isImageRequired() {
return !!(modelConfig.value?.params?.find(p => p.ui === 'imageUpload'))
},
buildTaskBody(shared) {
syncParamValues()
const modelParams = { ...paramValues }
if (shared.prompt.value) modelParams.prompt = shared.prompt.value
return modelParams
},
fillFromResult(resultData) {
if (resultData.model !== undefined) model.value = resultData.model
if (resultData.modelType !== undefined) modelType.value = resultData.modelType
if (resultData.proportion !== undefined) proportion.value = resultData.proportion
if (resultData.resolution !== undefined) resolution.value = resultData.resolution
if (resultData.customWidth !== undefined) customWidth.value = resultData.customWidth
if (resultData.customHight !== undefined) customHight.value = resultData.customHight
if (resultData.quantity !== undefined) quantity.value = resultData.quantity
if (resultData.modelParams !== undefined) Object.assign(paramValues, resultData.modelParams)
const dc = getDimConfig(modelConfig.value)
if (dc?.type === 'split') {
if (paramValues[dc.wParam.name] !== undefined) dimWidth.value = paramValues[dc.wParam.name]
if (paramValues[dc.hParam.name] !== undefined) dimHeight.value = paramValues[dc.hParam.name]
} else if (dc?.type === 'combined') {
if (paramValues[dc.paramName]) {
const parsed = dc.config.parse(paramValues[dc.paramName])
dimWidth.value = parsed.width
dimHeight.value = parsed.height
}
}
if (paramValues.quality !== undefined) quality.value = paramValues.quality
},
}
return platform
}
// 自注册
registerPlatform('Painting', definePaintingPlatform)

View File

@ -0,0 +1,168 @@
<template>
<Select
v-model="selectValue"
:grouped-options="modelGroups"
class="model-select"
position="top"
>
<template #prefix>
<img src="@/assets/dialog/model.svg" alt="" style="width: 16px;">
</template>
</Select>
</template>
<script setup>
import Select from '@/components/Select/index.vue'
import { fetchPlatformModels, getPlatformCode } from '@/utils/modelApi'
import { getModelConfig } from '@/config/models/index.js'
const props = defineProps({
modelValue: { type: String, default: 'Flux 2' },
typeValue: { type: String, default: 'text' },
})
const emit = defineEmits(['update:modelValue', 'update:typeValue'])
const platformModels = ref([])
const categoryMap = [
{ tag: 'text', label: '生成模型', inputType: 'text' },
{ tag: 'edit', label: '编辑模型', inputType: 'image' },
{ tag: 'vision', label: '视觉理解模型', inputType: 'vision' },
]
function parseValue(encoded) {
if (!encoded) return null
const idx = encoded.indexOf('::')
if (idx === -1) return null
return { tag: encoded.substring(0, idx), modelName: encoded.substring(idx + 2) }
}
function encodeValue(tag, modelName) {
return `${tag}::${modelName}`
}
function findTagForModel(modelName) {
for (const cat of categoryMap) {
const model = platformModels.value.find(m => (m.display_name || m.name) === modelName && m.tags?.includes(cat.tag))
if (model) return cat.tag
}
return 'text'
}
function tagToInputType(tag) {
const cat = categoryMap.find(c => c.tag === tag)
return cat?.inputType || 'text'
}
// Select
const selectValue = computed({
get: () => {
if (!props.modelValue) return ''
const tag = findTagForModel(props.modelValue)
return encodeValue(tag, props.modelValue)
},
set: (encoded) => {
const parsed = parseValue(encoded)
if (!parsed) return
emit('update:modelValue', parsed.modelName)
emit('update:typeValue', tagToInputType(parsed.tag))
},
})
// API
const loadModels = async () => {
try {
const code = getPlatformCode('Painting')
const models = await fetchPlatformModels(code)
platformModels.value = models || []
} catch (error) {
console.error('加载平台模型列表失败:', error)
}
}
loadModels()
// value tag::displayName
const modelGroups = computed(() => {
const models = platformModels.value
if (models.length === 0) return []
return categoryMap
.filter(cat => models.some(m => m.tags?.includes(cat.tag)))
.map(cat => ({
label: cat.label,
options: models
.filter(m => m.tags?.includes(cat.tag))
.map(m => ({
value: `${cat.tag}::${m.display_name || m.name}`,
label: m.display_name || m.name,
disabled: m.disabled || false,
}))
.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })),
}))
})
//
watch(platformModels, (models) => {
if (models.length === 0) return
const currentModel = models.find(m => (m.display_name || m.name) === props.modelValue || m.id === props.modelValue)
if (!currentModel || currentModel.disabled) {
const firstEnabled = models.find(m => !m.disabled)
if (firstEnabled) {
emit('update:modelValue', firstEnabled.display_name || firstEnabled.name)
emit('update:typeValue', tagToInputType(findTagForModel(firstEnabled.display_name || firstEnabled.name)))
}
}
}, { immediate: true })
// modelValue
watch(() => props.modelValue, (newValue) => {
if (!newValue) return
const models = platformModels.value
if (models.length === 0) return
const currentModel = models.find(m => (m.display_name || m.name) === newValue)
if (currentModel && currentModel.disabled) {
const firstEnabled = models.find(m => !m.disabled)
if (firstEnabled) {
emit('update:modelValue', firstEnabled.display_name || firstEnabled.name)
emit('update:typeValue', tagToInputType(findTagForModel(firstEnabled.display_name || firstEnabled.name)))
}
}
})
</script>
<style lang="less" scoped>
.model-select {
:deep(.select-header) {
height: 40px;
padding: 0 15px;
border-radius: 10px;
border: 1px solid #E8E9EB;
background: #f5f6f7;
&:hover {
background: #e9eaeb;
}
}
:deep(.select-text) {
font-size: 14px;
}
:deep(.dropdown-menu) {
max-height: 510px;
overflow-y: auto;
}
:deep(.dropdown-item) {
min-width: 120px;
&.active {
background: rgba(0, 15, 51, 0.10);
color: #000F33;
font-weight: 600;
}
}
}
</style>