AI_Painting_V2.0/src/components/dialogBox/index.vue
WangLeo 239b32fb95 重构 Painting 模型参数架构:每模型独立配置、动态参数表单、移除 workflow 适配
- 新增 src/config/models/ 每模型独立参数 schema(8 个模型)
- 新增 src/components/dialogBox/params/ 动态参数控件
- 模型选择器改为从 API 获取并按 tag 分组
- dialogBox 参数区改为根据模型 config 动态渲染控件
- createTask.js Painting 直接返回扁平 modelParams,Video 保留旧 workflow
- 删除旧的 proportion/painting.vue 和 quantity 组件
- 更新 CLAUDE.md 架构文档
2026-06-03 19:00:49 +08:00

593 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<Transition name="slide-up">
<div class="input-container" :class="{ generate : !props.isGenerate }" @click="handleContainerClick">
<div v-if="!props.isGenerate && props.type === 'Painting'" class="title">AI绘画2026</div>
<div v-if="!props.isGenerate && props.type === 'Video'" class="title">AI视频2026</div>
<div class="sender-top">
<div v-if="useDisplay.Sender_variant === 'default'" class="scroll-to-bottom-text" @click.stop="handleScrollToBottom">回到底部<img src="@/assets/dialog/ArrowDown.svg"></div>
<div v-show="showImageUploader" class="upload-img-container">
<div class="reference-diagram">
<ImageUploader
v-if="props.type === 'Painting'"
ref="referenceDiagramRef"
v-model="referenceImages"
:limit="imageUploadLimit"
@open-canvas="handleOpenCanvas"
/>
<VideoImageUploader
v-else-if="props.type === 'Video'"
ref="referenceDiagramRef"
v-model="referenceImages"
:model-type="modelType"
:images-count="modelDisplayConfig?.display?.images || 1"
/>
</div>
</div>
</div>
<Sender :key="useDisplay.Sender_variant" v-model="prompt" :variant="useDisplay.Sender_variant" :placeholder="promptPlaceholder" :submit-btn-disabled="isgerenate.value" :auto-size="autoSizeConfig">
<template #prefix>
<div v-if="useDisplay.Sender_variant !== 'default' && props.type === 'Painting'" class="prefix-self-wrap">
<paintingModel v-model="model" v-model:typeValue="modelType" />
<template v-for="param in visibleParams" :key="param.name">
<ProportionSelect v-if="param.ui === 'proportion'" v-model="paramValues[param.name]" :param="param" />
<ResolutionSelect v-if="param.ui === 'resolution'" v-model="paramValues[param.name]" :param="param" />
<SelectInput v-if="param.ui === 'select'" v-model="paramValues[param.name]" :param="param" />
<NumberInput v-if="param.ui === 'number'" v-model="paramValues[param.name]" :param="param" />
<SwitchInput v-if="param.ui === 'switch'" v-model="paramValues[param.name]" :param="param" />
</template>
</div>
<div v-if="useDisplay.Sender_variant !== 'default' && props.type === 'Video'" class="prefix-self-wrap">
<Pattern v-model="videoPattern" />
<videoModel v-model="model" v-model:typeValue="modelType" :video-pattern="videoPattern" />
<videoProportion
v-model="proportion"
v-model:resolution="resolution"
:proportion-options="proportionOptions"
:resolution-options="resolutionOptions"
/>
<Time v-model="duration" :options="durationOptions" />
</div>
</template>
<template #action-list>
<div style="display: flex; align-items: center; gap: 8px; height: 100%;">
<el-button v-if="isgerenate" round color="#626aef">
<i-ep-loading style="animation: spin 1s linear infinite;" />
</el-button>
<div v-else class="gerenate" :class="{ isprompt: prompt }" @click="handleStart">
<img v-if="!prompt" src="@/assets/dialog/darkArrow.svg" alt="" />
<img v-else src="@/assets/dialog/writerArrow.svg" alt="" />
<div v-show="useDisplay.Sender_variant !== 'default'">发送</div>
</div>
</div>
</template>
</Sender>
</div>
</Transition>
</template>
<script setup>
import videoProportion from './proportion/video.vue'
import paintingModel from './model/painting.vue'
import videoModel from './model/video.vue'
import Pattern from './pattern/index.vue'
import ImageUploader from './imageUploader/index.vue'
import VideoImageUploader from './videoImageUploader/index.vue'
import Time from './Time/index.vue'
import ProportionSelect from './params/ProportionSelect.vue'
import ResolutionSelect from './params/ResolutionSelect.vue'
import SelectInput from './params/SelectInput.vue'
import NumberInput from './params/NumberInput.vue'
import SwitchInput from './params/SwitchInput.vue'
import { Sender } from 'vue-element-plus-x'
import { useDisplayStore } from '@/stores'
import { generate } from '@/utils/websocket'
import { useRouter } from 'vue-router'
import { getModelId, fetchPlatformModels, getPlatformCode } from '@/utils/modelApi'
import { fetchModelConfig } from '@/utils/modelConfig'
import { getModelConfig } from '@/config/models/index.js'
const props = defineProps({
isGenerate: {
type: Boolean,
default: false
},
generate: {
type: Boolean,
default: false
},
type: {
type: String,
default: 'Painting'
}
})
const router = useRouter()
const useDisplay = useDisplayStore()
const isgerenate = ref(false)
const model = ref() // 模型
const modelType = ref('text')
// 当前模型配置
const modelConfig = computed(() => {
return props.type === 'Painting' ? getModelConfig(model.value) : null
})
// 模型参数值
const paramValues = reactive({})
// 同步模型默认值到 paramValues
watch(modelConfig, (config) => {
if (!config) return
config.params.forEach(p => {
if (!(p.name in paramValues)) {
paramValues[p.name] = p.default ?? ''
}
})
}, { immediate: true })
// 需要显示的参数控件(排除 textarea 和 imageUpload 类型,由 Sender 和独立上传组件处理)
const visibleParams = computed(() => {
if (!modelConfig.value) return []
return modelConfig.value.params.filter(p => {
// 条件显示
if (p.showWhen) {
for (const [key, val] of Object.entries(p.showWhen)) {
if (paramValues[key] !== val) return false
}
}
// textarea 由 Sender 承载imageUpload 由独立上传组件处理
return p.ui !== 'textarea' && p.ui !== 'imageUpload'
})
})
const showImageUploader = computed(() => {
if (props.type === 'Video') return modelType.value !== 'text'
return modelConfig.value?.inputType === 'image' || modelConfig.value?.inputType === 'both'
})
const imageUploadLimit = computed(() => {
if (!modelConfig.value) return 4
const imageParam = modelConfig.value.params.find(p => p.ui === 'imageUpload')
return imageParam?.maxCount || modelConfig.value.maxImages || 4
})
const promptPlaceholder = ref('描述你想生成的画面和动作。') // 提示词占位符
const prompt = ref('') // 提示词
const proportion = ref('16:9') // 比例Video 用)
const resolution = ref('1k') // 分辨率Video 用)
const referenceImages = ref([])
// 绘画
const quantity = ref(1) // 生成数量
// 视频
const duration = ref(5) // 时间
const videoPattern = ref('文生视频') // 视频模式下,默认值为'文生视频'
const resolutionOptions = ref([])
const proportionOptions = ref([])
const durationOptions = ref([])
const isInitialized = ref(false)
const autoSizeConfig = computed(() => {
if (useDisplay.Sender_variant !== 'default') {
return { minRows: 5, maxRows: 9 }
} else {
return { minRows: 1, maxRows: 1 }
}
})
const modelDisplayConfig = ref(null)
// Painting: 从本地配置加载模型参数 schema
const loadPaintingModelConfig = (modelName) => {
const config = getModelConfig(modelName)
if (config?.params) {
config.params.forEach(p => {
if (!(p.name in paramValues)) {
paramValues[p.name] = p.default ?? ''
}
})
}
}
// Video: 从远程加载 workflow 配置(保留旧逻辑)
const loadVideoModelConfig = async (modelName, currentModelType) => {
try {
const config = await fetchModelConfig(props.type, modelName, currentModelType)
modelDisplayConfig.value = config
if (config.display) {
const display = config.display
if (display.promptPlaceholder) {
promptPlaceholder.value = display.promptPlaceholder.default || '描述你想生成的画面和动作。'
}
if (display.prompt && !isInitialized.value) {
prompt.value = display.prompt.default || ''
}
if (display.resolution) {
resolution.value = display.resolution.default || '1k'
resolutionOptions.value = display.resolution.options || []
}
if (display.proportion) {
proportion.value = display.proportion.default || '16:9'
proportionOptions.value = display.proportion.options || []
}
if (display.duration) {
duration.value = display.duration.default || 5
durationOptions.value = display.duration.options || []
}
}
isInitialized.value = true
} catch (error) {
console.error('加载视频模型配置失败:', error)
}
}
const handleStart = async () => {
const currentType = props.type
let currentModelType = modelType.value
if(model.value === 'Seedance 2.0') {
ElMessage.primary('敬请期待 Seedance 2.0')
return
}
if (!props.isGenerate) {
router.push({ name: 'home', query: { loading: false, Generate: true, type: currentType } })
}
if (!prompt.value) {
// eslint-disable-next-line no-undef
ElMessage.error('请输入提示词')
return
}
if (showImageUploader.value && !referenceImages.value.length){
ElMessage.warning('请上传图片')
return
}
isgerenate.value = true
console.log('生成开始', isgerenate.value)
const imgs = []
referenceImages.value.forEach((img, index) => {
imgs.push({ name: `image_${index + 1}`, url: img.url })
})
// 构建模型参数Painting 用)
const modelParams = { ...paramValues }
if (prompt.value) modelParams.prompt = prompt.value
const generateData = {
model: model.value,
modelType: currentModelType,
prompt: prompt.value,
proportion: proportion.value,
referenceImages: referenceImages.value,
quantity: quantity.value,
resolution: resolution.value,
duration: duration.value,
videoPattern: videoPattern.value,
modelParams,
}
const modelId = await getModelId(currentType, model.value)
// Painting 用新架构扁平参数Video 保留旧 params 数组
const isPainting = currentType === 'Painting'
const data = {
type: currentType,
modelType: currentModelType,
AIGC: currentType,
platform: 'runninghub',
modelName: model.value,
modelId: modelId || '',
modelParams: isPainting ? modelParams : {},
params: isPainting ? [] : [
{ name: 'prompt', data: prompt.value },
{ name: 'quantity', data: quantity.value },
{ name: 'proportion', data: proportion.value },
{ name: 'resolution', data: resolution.value },
{ name: 'duration', data: duration.value },
],
imgs,
request: JSON.stringify(generateData)
}
await generate(data, generateData)
console.log('生成中', isgerenate.value)
}
const fillParamsFromResult = (resultData) => {
if (!resultData) return
if (resultData.model !== undefined) model.value = resultData.model
if (resultData.modelType !== undefined) modelType.value = resultData.modelType
if (resultData.prompt !== undefined) prompt.value = resultData.prompt
if (resultData.proportion !== undefined) proportion.value = resultData.proportion
if (resultData.referenceImages !== undefined) referenceImages.value = resultData.referenceImages
if (resultData.quantity !== undefined) quantity.value = resultData.quantity
if (resultData.resolution !== undefined) resolution.value = resultData.resolution
if (resultData.duration !== undefined) duration.value = resultData.duration
if (resultData.videoPattern !== undefined) videoPattern.value = resultData.videoPattern
if (resultData.modelParams !== undefined) Object.assign(paramValues, resultData.modelParams)
}
defineExpose({
fillParamsFromResult,
handleStart
})
const handleContainerClick = () => {
if (useDisplay.Sender_variant === 'default') {
useDisplay.Sender_variant = 'updown'
}
}
const handleScrollToBottom = () => {
console.log('点击回到底部按钮')
useDisplay.scrollToBottom()
}
const handleOpenCanvas = (data) => {
useDisplay.openCanvas(data)
}
watch(() => useDisplay.isSubGerenate, (newValue) => {
console.log('生成状态', newValue)
isgerenate.value = newValue
}, { immediate: true })
watch([() => model.value, () => modelType.value], async ([newModel, newModelType]) => {
console.log('模型或类型改变:', newModel, newModelType)
if (!newModel) return
if (props.type === 'Painting') {
loadPaintingModelConfig(newModel)
} else {
await loadVideoModelConfig(newModel, newModelType)
}
})
// 预加载平台模型列表,避免首次点击"发送"时才请求接口
const prefetchModels = () => {
const code = getPlatformCode(props.type)
fetchPlatformModels(code)
}
watch(() => props.type, (newType) => {
if (newType === 'Video') {
model.value = 'LTX2.0'
} else {
model.value = 'flux'
}
prefetchModels()
}, { immediate: true })
onMounted(() => {
prefetchModels()
})
</script>
<style lang="less" scoped>
/* 输入区域 */
.input-container {
width: 50%;
max-width: 880px;
position: absolute;
bottom: 30px;
z-index: 100;
left: 50%;
transform: translateX(-50%);
border-radius: 10px;
transition: all 0.3s ease;
}
.sender-top {
width: 100%;
display: flex;
justify-content: flex-end;
cursor: pointer;
transition: all 0.3s ease;
z-index: 101;
margin-bottom: 10px;
position: relative;
.scroll-to-bottom-text {
position: absolute;
bottom: 0;
right: 0;
z-index: 2;
padding: 10px;
border-radius: 10px;
background-color: #F8F9FA;
color: #666;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
&:active {
transform: scale(0.95);
}
&:hover {
background-color: #F0F1F2;
}
}
// 上传图片
.upload-img-container{
position: absolute;
bottom: 0;
left: 0;
width: 80%;
width: 100%;
display: flex;
justify-content: start;
align-items: center;
z-index: 1;
gap: 16px;
padding-left: 20px;
.reference-diagram {
display: flex;
align-items: center;
}
}
}
.generate{
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
// gap: 40px;
position: relative;
border: none;
box-shadow: none;
:deep(.el-sender){
border: none;
box-shadow: none;
}
}
.prefix-self-wrap{
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
img{
height: 50px;
border-radius: 4px;
}
}
.title{
background-color: #FFF;
color: #333;
text-align: center;
font-family: "Alibaba PuHuiTi";
font-size: 24px;
font-style: normal;
font-weight: 500;
line-height: normal;
margin-bottom: 106px;
}
:deep(.el-sender){
background-color: #F5F6F7;
border: none;
border-radius: 20px;
}
:deep(.el-sender:focus-within){
box-shadow: 0 0 15px 0 rgba(0, 0, 0, 0.1);
}
:deep(.el-popover.el-popper){
border-radius: 20px;
}
// 时间选择器
.select{
background: #ffffff;
border-radius: 10px;
border: 1px solid rgba(0, 0, 0, 0.10);
}
// 视频效果选择器
.upload-btn{
display: flex;
height: 40px;
padding: 0 15px;
justify-content: center;
align-items: center;
gap: 5px;
border-radius: 10px;
border: 1px solid rgba(0, 0, 0, 0.10);
background: #ffffff;
cursor: pointer;
position: relative;
}
.upload-btn:hover{
background: #E5E7EB;
}
/* 圆形按钮 */
.circle-btn {
position: absolute;
right: 0px;
top: 0px;
width: 18px;
height: 18px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 90;
transition: all 0.3s ease;
color: rgb(0, 0, 0);
font-size: 20px;
&:hover {
transform: scale(1.1);
// box-shadow: 0 6px 16px rgba(98, 106, 239, 0.6);
}
&:active {
transform: scale(0.95);
}
}
/* 过渡动画 */
.slide-up-enter-active,
.slide-up-leave-active {
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.slide-up-enter-from {
opacity: 0;
transform: translate(-50%, 100%);
}
.slide-up-leave-to {
opacity: 0;
transform: translate(-50%, 100%);
}
.gerenate{
display: inline-flex;
height: 40px;
padding: 0 20px;
justify-content: center;
align-items: center;
gap: 5px;
border-radius: 10px;
background: rgba(0, 15, 51, 0.10);
cursor: pointer;
color: #000F33;
text-align: center;
font-family: "Microsoft YaHei";
font-size: 14px;
font-style: normal;
font-weight: 700;
line-height: normal;
}
.isprompt{
color: #ffffff;
background-color: #000F33;
}
// .gerenate:hover{
// background: rgba(0, 15, 51, 0.20);
// }
</style>