660 lines
16 KiB
Vue
660 lines
16 KiB
Vue
<template>
|
||
<div class="img-img-panel">
|
||
<div v-if="!selectedModel" class="model-loading">
|
||
{{ modelLoading ? '模型加载中...' : '暂无配置模型,请等待管理员配置' }}
|
||
</div>
|
||
|
||
<template v-else>
|
||
<el-card class="prompt-card">
|
||
<div class="upload-area">
|
||
<input
|
||
ref="imageInputRef"
|
||
class="hidden-upload-input"
|
||
type="file"
|
||
accept="image/*"
|
||
@change="handleImageSelect"
|
||
/>
|
||
|
||
<div v-if="imagePreviewUrl" class="image-preview">
|
||
<img :src="imagePreviewUrl" alt="参考图片" />
|
||
<button type="button" class="remove-image" @click="removeImage">删除</button>
|
||
</div>
|
||
|
||
<button v-else type="button" class="upload-card" @click="triggerImageUpload">
|
||
<span class="upload-plus">+</span>
|
||
<span>上传参考图片</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="prompt-editor">
|
||
<el-input
|
||
v-model="prompt"
|
||
class="prompt-input"
|
||
:autosize="{ minRows: 8, maxRows: 8 }"
|
||
type="textarea"
|
||
maxlength="3000"
|
||
show-word-limit
|
||
:placeholder="promptPlaceholder"
|
||
/>
|
||
</div>
|
||
</el-card>
|
||
|
||
<div v-if="configPanelVisible" class="config-panel">
|
||
<div
|
||
v-for="field in configFields"
|
||
:key="field.name"
|
||
class="config-section"
|
||
>
|
||
<div class="config-title">
|
||
{{ field.displayLabel }}
|
||
<span v-if="field.required" class="required-star">*</span>
|
||
</div>
|
||
|
||
<div class="config-content">
|
||
<div v-if="field.uitype === 'code'" class="option-grid">
|
||
<button
|
||
v-for="option in field.data"
|
||
:key="option.value"
|
||
type="button"
|
||
class="option-btn"
|
||
:class="{ active: configData[field.name] === option.value }"
|
||
@click="configData[field.name] = option.value"
|
||
>
|
||
{{ option.text || option.value }}
|
||
</button>
|
||
</div>
|
||
|
||
<div v-else-if="field.isQuantity" class="option-grid">
|
||
<button
|
||
v-for="num in field.quantityOptions"
|
||
:key="num"
|
||
type="button"
|
||
class="option-btn"
|
||
:class="{ active: Number(configData[field.name]) === num }"
|
||
@click="configData[field.name] = num"
|
||
>
|
||
{{ num }}个
|
||
</button>
|
||
</div>
|
||
|
||
<el-input-number
|
||
v-else-if="field.uitype === 'int'"
|
||
v-model="configData[field.name]"
|
||
:min="1"
|
||
:max="field.max || 99"
|
||
:step="1"
|
||
controls-position="right"
|
||
style="width: 100%"
|
||
/>
|
||
|
||
<el-input
|
||
v-else
|
||
v-model="configData[field.name]"
|
||
:placeholder="field.placeholder || `请输入${field.displayLabel}`"
|
||
clearable
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="control-row">
|
||
<el-select
|
||
v-model="currentModelId"
|
||
class="model-select"
|
||
placeholder="请选择模型"
|
||
popper-class="custom-select-popup"
|
||
@change="onModelChange"
|
||
>
|
||
<el-option
|
||
v-for="item in modelOptions"
|
||
:key="item.value"
|
||
:label="item.label"
|
||
:value="item.value"
|
||
/>
|
||
</el-select>
|
||
|
||
<button
|
||
type="button"
|
||
class="config-trigger"
|
||
:class="{ active: configPanelVisible }"
|
||
@click="configPanelVisible = !configPanelVisible"
|
||
>
|
||
<span
|
||
v-for="item in summaryItems"
|
||
:key="item.name"
|
||
class="summary-item"
|
||
>
|
||
{{ item.text }}
|
||
</span>
|
||
<span class="trigger-arrow">
|
||
<i :class="['iconfont', configPanelVisible ? 'icon-shang' : 'icon-xia']"></i>
|
||
</span>
|
||
</button>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { getAiModels, queryModelPrice, texttoimage } from '@/apis/Model';
|
||
import type {
|
||
InputField,
|
||
Model,
|
||
PriceQueryParams,
|
||
PriceQueryResponse,
|
||
TextToImageParams,
|
||
TextToImageResponse,
|
||
} from '@/types/model';
|
||
import { ElMessage } from 'element-plus';
|
||
import { computed, onMounted, ref, watch } from 'vue';
|
||
|
||
type ConfigValue = string | number | boolean;
|
||
|
||
const emit = defineEmits<{
|
||
(e: 'price-update', price: number): void;
|
||
(e: 'prompt-change', isEmpty: boolean): void;
|
||
(e: 'task-created', taskid: string): void;
|
||
(e: 'image-generated', result: TextToImageResponse): void;
|
||
}>();
|
||
|
||
const prompt = ref('');
|
||
const models = ref<Model[]>([]);
|
||
const currentModelId = ref('');
|
||
const configData = ref<Record<string, ConfigValue>>({});
|
||
const configPanelVisible = ref(false);
|
||
const generating = ref(false);
|
||
const modelLoading = ref(true);
|
||
const imageInputRef = ref<HTMLInputElement | null>(null);
|
||
const imageFileField = ref('image_file');
|
||
const imageData = ref('');
|
||
const imagePreviewUrl = ref('');
|
||
let priceTimer: ReturnType<typeof setTimeout> | null = null;
|
||
|
||
const selectedModel = computed(() => models.value.find((item) => item.id === currentModelId.value));
|
||
|
||
const modelOptions = computed(() => models.value.map((item) => ({
|
||
label: item.model || item.name,
|
||
value: item.id,
|
||
})));
|
||
|
||
const promptPlaceholder = computed(() => {
|
||
const promptField = selectedModel.value?.input_fields?.find((field) => field.name === 'prompt');
|
||
return promptField?.placeholder || promptField?.label || '结合参考图片,描述你想生成的图片内容';
|
||
});
|
||
|
||
const isImageField = (field: InputField) => {
|
||
const name = field.name.toLowerCase();
|
||
return field.uitype === 'image' || name.includes('image') || name.includes('img') || name.includes('file');
|
||
};
|
||
|
||
const configFields = computed(() => {
|
||
return (selectedModel.value?.input_fields || [])
|
||
.filter((field) => field.name !== 'prompt' && !isImageField(field))
|
||
.map((field) => {
|
||
const value = configData.value[field.name];
|
||
const isQuantity = isQuantityName(field.name) && field.uitype === 'int';
|
||
const displayLabel = field.label || field.lable || field.name;
|
||
const selectedOption = field.data?.find((option) => option.value === value);
|
||
|
||
return {
|
||
...field,
|
||
displayLabel,
|
||
isQuantity,
|
||
quantityOptions: isQuantity
|
||
? Array.from({ length: Math.min(field.max || 4, 4) }, (_, index) => index + 1)
|
||
: [],
|
||
summaryText: selectedOption?.text || (isQuantity ? `${value || 1}个` : String(value || displayLabel)),
|
||
};
|
||
});
|
||
});
|
||
|
||
const summaryItems = computed(() => configFields.value.slice(0, 4).map((field) => ({
|
||
name: field.name,
|
||
text: field.summaryText,
|
||
})));
|
||
|
||
const isQuantityName = (name: string) => {
|
||
const lowerName = name.toLowerCase();
|
||
return ['num', 'count', 'quantity', 'batch_size', 'n'].some((key) => lowerName.includes(key));
|
||
};
|
||
|
||
const defaultConfigValue = (field: InputField): ConfigValue => {
|
||
if (field.defaultvalue !== undefined && field.defaultvalue !== null) return field.defaultvalue;
|
||
if (field.uitype === 'code' && field.data?.length) return field.data[0].value;
|
||
if (field.uitype === 'int') return 1;
|
||
return '';
|
||
};
|
||
|
||
const resetConfig = () => {
|
||
const nextConfig: Record<string, ConfigValue> = {};
|
||
const imageField = selectedModel.value?.input_fields?.find(isImageField);
|
||
imageFileField.value = imageField?.name || 'image_file';
|
||
|
||
(selectedModel.value?.input_fields || [])
|
||
.filter((field) => field.name !== 'prompt' && !isImageField(field))
|
||
.forEach((field) => {
|
||
nextConfig[field.name] = defaultConfigValue(field);
|
||
});
|
||
|
||
configData.value = nextConfig;
|
||
};
|
||
|
||
const onModelChange = () => {
|
||
resetConfig();
|
||
configPanelVisible.value = false;
|
||
fetchPrice();
|
||
};
|
||
|
||
const loadImageToImageModels = async () => {
|
||
modelLoading.value = true;
|
||
try {
|
||
// 图生图在新接口中归入 t2i 类型
|
||
const res = await getAiModels('t2i');
|
||
|
||
if (res.status === 'ok' && res.data?.length) {
|
||
models.value = res.data;
|
||
currentModelId.value = res.data[0]?.id || '';
|
||
resetConfig();
|
||
fetchPrice();
|
||
} else {
|
||
models.value = [];
|
||
currentModelId.value = '';
|
||
}
|
||
} finally {
|
||
modelLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const triggerImageUpload = () => {
|
||
imageInputRef.value?.click();
|
||
};
|
||
|
||
const handleImageSelect = (event: Event) => {
|
||
const input = event.target as HTMLInputElement;
|
||
const file = input.files?.[0];
|
||
if (!file) return;
|
||
if (!file.type.startsWith('image/')) {
|
||
ElMessage.warning('请上传图片文件');
|
||
input.value = '';
|
||
return;
|
||
}
|
||
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
imageData.value = String(reader.result || '');
|
||
imagePreviewUrl.value = imageData.value;
|
||
emitPromptState();
|
||
};
|
||
reader.readAsDataURL(file);
|
||
};
|
||
|
||
const removeImage = () => {
|
||
imageData.value = '';
|
||
imagePreviewUrl.value = '';
|
||
if (imageInputRef.value) imageInputRef.value.value = '';
|
||
emitPromptState();
|
||
};
|
||
|
||
const emitPromptState = () => {
|
||
emit('prompt-change', !prompt.value.trim() || !imageData.value);
|
||
};
|
||
|
||
const buildPriceParams = (): PriceQueryParams | null => {
|
||
if (!selectedModel.value) return null;
|
||
|
||
return {
|
||
product_type: 'llm',
|
||
product_id: selectedModel.value.id,
|
||
config_data: {
|
||
action: selectedModel.value.apiname,
|
||
has_image: !!imageData.value,
|
||
...configData.value,
|
||
},
|
||
};
|
||
};
|
||
|
||
const buildGenerateParams = (): TextToImageParams | null => {
|
||
if (!selectedModel.value) return null;
|
||
|
||
return {
|
||
model: selectedModel.value.model,
|
||
catelogid: 'i2i',
|
||
prompt: prompt.value.trim(),
|
||
[imageFileField.value]: imageData.value,
|
||
...configData.value,
|
||
};
|
||
};
|
||
|
||
const fetchPrice = () => {
|
||
if (priceTimer) clearTimeout(priceTimer);
|
||
|
||
priceTimer = setTimeout(async () => {
|
||
const params = buildPriceParams();
|
||
if (!params) {
|
||
emit('price-update', 0);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const res = await queryModelPrice<PriceQueryResponse, PriceQueryParams>(params);
|
||
emit('price-update', res.status === 'ok' && res.data ? res.data.amount : 0);
|
||
} catch (error) {
|
||
console.error('图生图价格查询失败:', error);
|
||
emit('price-update', 0);
|
||
}
|
||
}, 500);
|
||
};
|
||
|
||
watch(
|
||
() => [currentModelId.value, configData.value],
|
||
() => fetchPrice(),
|
||
{ deep: true }
|
||
);
|
||
|
||
watch(
|
||
() => [prompt.value, imageData.value],
|
||
() => emitPromptState(),
|
||
{ immediate: true }
|
||
);
|
||
|
||
const generate = async () => {
|
||
if (!imageData.value) {
|
||
ElMessage.warning('请上传参考图片');
|
||
return false;
|
||
}
|
||
|
||
if (!prompt.value.trim()) {
|
||
ElMessage.warning('请输入提示词');
|
||
return false;
|
||
}
|
||
|
||
const params = buildGenerateParams();
|
||
if (!params) {
|
||
ElMessage.warning('模型未加载完成');
|
||
return false;
|
||
}
|
||
|
||
generating.value = true;
|
||
try {
|
||
const res = await texttoimage<TextToImageResponse, TextToImageParams>(params);
|
||
const taskid = res.data?.taskid || res.taskid;
|
||
const images = res.image || res.data?.image || [];
|
||
|
||
if (String(res.status).toUpperCase() === 'SUCCEEDED' && images.length) {
|
||
ElMessage.success('图生图生成成功');
|
||
emit('image-generated', res);
|
||
resetForm();
|
||
return true;
|
||
}
|
||
|
||
if (['CREATED', 'PENDING', 'RUNNING'].includes(String(res.status).toUpperCase())) {
|
||
ElMessage.success('图生图任务已提交,请稍后在任务列表中查看');
|
||
if (taskid) emit('task-created', taskid);
|
||
resetForm();
|
||
return true;
|
||
}
|
||
|
||
ElMessage.error(res.message || '生成失败,请重试');
|
||
return false;
|
||
} catch (error) {
|
||
console.error('图生图生成失败:', error);
|
||
ElMessage.error(error instanceof Error ? error.message : '生成失败,请重试');
|
||
return false;
|
||
} finally {
|
||
generating.value = false;
|
||
}
|
||
};
|
||
|
||
const resetForm = () => {
|
||
prompt.value = '';
|
||
removeImage();
|
||
resetConfig();
|
||
emit('price-update', 0);
|
||
emit('prompt-change', true);
|
||
};
|
||
|
||
defineExpose({
|
||
generate,
|
||
resetForm,
|
||
});
|
||
|
||
onMounted(() => {
|
||
loadImageToImageModels();
|
||
});
|
||
</script>
|
||
|
||
<style scoped lang="less">
|
||
.img-img-panel {
|
||
position: relative;
|
||
height: 100%;
|
||
min-height: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
padding: 0 16px;
|
||
}
|
||
|
||
.model-loading {
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 12px;
|
||
color: rgba(255, 255, 255, 0.62);
|
||
background: #1a1c20;
|
||
}
|
||
|
||
.prompt-card {
|
||
flex: 1;
|
||
min-height: 0;
|
||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||
border-radius: 12px;
|
||
background: #1a1c20;
|
||
overflow: hidden;
|
||
|
||
:deep(.el-card__body) {
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
padding: 12px;
|
||
}
|
||
}
|
||
|
||
.hidden-upload-input {
|
||
display: none;
|
||
}
|
||
|
||
.upload-area {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.upload-card,
|
||
.image-preview {
|
||
width: 160px;
|
||
height: 160px;
|
||
border-radius: 12px;
|
||
}
|
||
|
||
.upload-card {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
border: 1px dashed rgba(255, 255, 255, 0.24);
|
||
color: rgba(255, 255, 255, 0.72);
|
||
background: rgba(0, 0, 0, 0.22);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.upload-plus {
|
||
font-size: 34px;
|
||
line-height: 1;
|
||
}
|
||
|
||
.image-preview {
|
||
position: relative;
|
||
overflow: hidden;
|
||
background: rgba(0, 0, 0, 0.22);
|
||
|
||
img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
}
|
||
|
||
.remove-image {
|
||
position: absolute;
|
||
right: 8px;
|
||
bottom: 8px;
|
||
height: 28px;
|
||
padding: 0 10px;
|
||
border: none;
|
||
border-radius: 8px;
|
||
color: #fff;
|
||
background: rgba(0, 0, 0, 0.62);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.prompt-editor {
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: flex;
|
||
}
|
||
|
||
.prompt-input {
|
||
flex: 1;
|
||
|
||
:deep(.el-textarea) {
|
||
height: 100%;
|
||
}
|
||
|
||
:deep(.el-textarea__inner) {
|
||
height: 100% !important;
|
||
resize: none;
|
||
border: none !important;
|
||
border-radius: 10px !important;
|
||
color: #fff !important;
|
||
background: rgba(0, 0, 0, 0.32) !important;
|
||
box-shadow: none !important;
|
||
padding: 14px 16px !important;
|
||
line-height: 1.7;
|
||
}
|
||
|
||
:deep(.el-input__count) {
|
||
background: transparent;
|
||
color: rgba(255, 255, 255, 0.48);
|
||
}
|
||
}
|
||
|
||
.config-panel {
|
||
position: absolute;
|
||
left: 16px;
|
||
right: 16px;
|
||
bottom: 64px;
|
||
z-index: 10;
|
||
overflow-y: auto;
|
||
padding: 16px;
|
||
border-radius: 12px;
|
||
background: #2b2d30;
|
||
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.32);
|
||
}
|
||
|
||
.config-section + .config-section {
|
||
margin-top: 18px;
|
||
}
|
||
|
||
.config-title {
|
||
margin-bottom: 10px;
|
||
color: rgba(255, 255, 255, 0.64);
|
||
font-size: 14px;
|
||
}
|
||
|
||
.required-star {
|
||
margin-left: 4px;
|
||
color: #f56c6c;
|
||
}
|
||
|
||
.option-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(88px, 1fr));
|
||
gap: 8px;
|
||
border-radius: 10px;
|
||
background: rgba(0, 0, 0, 0.18);
|
||
padding: 6px;
|
||
}
|
||
|
||
.option-btn {
|
||
height: 34px;
|
||
border: none;
|
||
border-radius: 8px;
|
||
color: rgba(255, 255, 255, 0.76);
|
||
background: transparent;
|
||
cursor: pointer;
|
||
|
||
&.active {
|
||
color: #fff;
|
||
background: rgba(255, 255, 255, 0.14);
|
||
}
|
||
}
|
||
|
||
.control-row {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
gap: 8px;
|
||
min-height: 52px;
|
||
}
|
||
|
||
.model-select {
|
||
flex: 0 0 160px;
|
||
}
|
||
|
||
.config-trigger {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 0 14px;
|
||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||
border-radius: 12px;
|
||
color: #fff;
|
||
background: #252729;
|
||
cursor: pointer;
|
||
|
||
&.active {
|
||
border-color: rgba(69, 238, 120, 0.8);
|
||
}
|
||
}
|
||
|
||
.summary-item {
|
||
position: relative;
|
||
color: rgba(255, 255, 255, 0.82);
|
||
font-size: 14px;
|
||
white-space: nowrap;
|
||
|
||
& + .summary-item {
|
||
padding-left: 12px;
|
||
|
||
&::before {
|
||
content: '';
|
||
position: absolute;
|
||
left: 0;
|
||
top: 50%;
|
||
width: 1px;
|
||
height: 16px;
|
||
background: rgba(255, 255, 255, 0.12);
|
||
transform: translateY(-50%);
|
||
}
|
||
}
|
||
}
|
||
|
||
.trigger-arrow {
|
||
margin-left: auto;
|
||
color: rgba(255, 255, 255, 0.72);
|
||
}
|
||
</style>
|