- 新增 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 架构文档
593 lines
16 KiB
Vue
593 lines
16 KiB
Vue
<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>
|