AI_Painting_V2.0/src/components/dialogBox/index.vue
WangLeo 4f7357eefc 回退动态参数控件为独立组件,模型配置对齐 API 文档,修复多处缺陷
- 删除 params/ 动态控件,恢复 paintingProportion/Quantity 独立组件
- 模型参数 UI 双向绑定:proportion/resolution/quantity/customSize 同步到 paramValues
- 模型选择器适配 API tags 数组和 display_name,新增 displayNameMap 映射
- 模型配置对齐 RunningHub 文档,精简即梦/通义万相多余参数
- 模型列表缓存改为 30s TTL + pendingRequests 并发去重
- sessionId 改为从登录态获取,禁止随机生成
- Select 下拉菜单增加 max-height 防止溢出
- 更新 CLAUDE.md 架构文档
2026-06-04 18:30:50 +08:00

671 lines
19 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" />
<paintingProportion
v-if="showProportion"
v-model="proportion"
v-model:resolution="resolution"
v-model:width="customWidth"
v-model:height="customHight"
:proportion-options="paintingProportionOpts"
:resolution-options="paintingResolutionOpts"
:allow-custom="hasCustomSize"
/>
<Quantity v-if="showQuantity" v-model="quantity" :max="quantityMax" />
</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 paintingProportion from './proportion/painting.vue'
import Quantity from './quantity/index.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({})
const showImageUploader = computed(() => {
if (props.type === 'Video') return modelType.value !== 'text'
return modelType.value !== 'text' || modelConfig.value?.inputType === 'image' || modelConfig.value?.inputType === 'both'
})
// 模型是否有数量参数imageNum或生成类模型显示张数选择
const showQuantity = computed(() => {
if (props.type !== 'Painting') return false
const qtyParam = modelConfig.value?.params?.find(p => p.ui === 'quantity')
if (qtyParam) return true
return modelType.value === 'text' && !modelConfig.value?.params?.find(p => p.name === 'forceSingle')
})
// 模型是否使用 proportion 组件aspectRatio 参数)
const showProportion = computed(() => {
return !!modelConfig.value?.params?.find(p => p.ui === 'proportion')
})
// 模型是否支持自定义尺寸aspectRatio 选项含 'custom'
const hasCustomSize = computed(() => {
const ratioParam = modelConfig.value?.params?.find(p => p.ui === 'proportion')
return ratioParam?.options?.includes('custom') || false
})
// 从模型配置派生比例选项,回退到默认值
const paintingProportionOpts = computed(() => {
const ratioParam = modelConfig.value?.params?.find(p => p.ui === 'proportion')
if (ratioParam?.options) {
return ratioParam.options
.filter(o => o !== 'custom')
.map(o => ({ value: o, label: o }))
}
return proportionOptions.value
})
// 从模型配置派生分辨率选项(仅模型有 resolution 参数时显示)
const paintingResolutionOpts = computed(() => {
const resParam = modelConfig.value?.params?.find(p => p.ui === 'resolution')
if (resParam?.options) {
return resParam.options.map(o => ({ value: o, label: o.toUpperCase() }))
}
return []
})
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 customWidth = ref(1024) // 自定义宽度
const customHight = ref(1024) // 自定义高度
const quantityMax = computed(() => {
const qtyParam = modelConfig.value?.params?.find(p => p.ui === 'quantity')
if (qtyParam?.options?.length) return Math.max(...qtyParam.options)
return 4
})
// 同步模型默认值到 paramValues 和 UI refs
watch(modelConfig, (config) => {
if (!config) return
config.params.forEach(p => {
if (!(p.name in paramValues)) {
if (p.name === 'outputFormat') {
paramValues[p.name] = 'png'
} else {
paramValues[p.name] = p.default ?? ''
}
}
})
// 同步默认值到 UI 控件
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
}, { immediate: true })
// 反向同步UI refs → paramValues
watch(proportion, (val) => {
const p = modelConfig.value?.params?.find(param => param.ui === 'proportion')
if (p) paramValues[p.name] = val
})
watch(resolution, (val) => {
const p = modelConfig.value?.params?.find(param => param.ui === 'resolution')
if (p) paramValues[p.name] = val
})
watch(quantity, (val) => {
const p = modelConfig.value?.params?.find(param => param.ui === 'quantity')
if (p) paramValues[p.name] = val
})
watch(customWidth, (val) => {
if (modelConfig.value?.params?.find(p => p.name === 'customWidth')) {
paramValues.customWidth = val
}
})
watch(customHight, (val) => {
if (modelConfig.value?.params?.find(p => p.name === 'customHight')) {
paramValues.customHight = val
}
})
// 同步参考图片到 paramValues
watch(referenceImages, (imgs) => {
const imageParam = modelConfig.value?.params?.find(p => p.ui === 'imageUpload')
if (imageParam) {
paramValues[imageParam.name] = imgs.map(img => img.url)
}
}, { deep: true })
// 视频
const duration = ref(5) // 时间
const videoPattern = ref('文生视频') // 视频模式下,默认值为'文生视频'
const resolutionOptions = ref([
{ value: '1k', label: '标清 1K' },
{ value: '2k', label: '高清 2K' },
{ value: '4k', label: '超清 4K' },
])
const proportionOptions = ref([
{ 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' },
])
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)
// 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,
customWidth: customWidth.value,
customHight: customHight.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.customWidth !== undefined) customWidth.value = resultData.customWidth
if (resultData.customHight !== undefined) customHight.value = resultData.customHight
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') {
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 })
</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>