feat: 新增 Painting 平台包(descriptor + 控件迁移)
This commit is contained in:
parent
d2a04613d5
commit
705a7a7ebf
247
src/platforms/painting/controls/dimension.vue
Normal file
247
src/platforms/painting/controls/dimension.vue
Normal 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>
|
||||
465
src/platforms/painting/controls/proportion.vue
Normal file
465
src/platforms/painting/controls/proportion.vue
Normal 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>
|
||||
46
src/platforms/painting/controls/quality.vue
Normal file
46
src/platforms/painting/controls/quality.vue
Normal 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>
|
||||
67
src/platforms/painting/controls/quantity.vue
Normal file
67
src/platforms/painting/controls/quantity.vue
Normal 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>
|
||||
312
src/platforms/painting/imageUploader.vue
Normal file
312
src/platforms/painting/imageUploader.vue
Normal 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>
|
||||
254
src/platforms/painting/index.js
Normal file
254
src/platforms/painting/index.js
Normal 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)
|
||||
168
src/platforms/painting/modelSelector.vue
Normal file
168
src/platforms/painting/modelSelector.vue
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user