yuanjing/src/components/img/ImgImg.vue
2026-10-20 18:07:06 +08:00

660 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>
<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>