Files
Yi.Admin/Yi.Ai.Vue3/src/pages/chat/image/components/ImageGenerator.vue
2026-01-03 15:16:18 +08:00

567 lines
17 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.
<script setup lang="ts">
import type { UploadFile, UploadUserFile } from 'element-plus';
import type { ImageModel, TaskStatusResponse } from '@/api/aiImage/types';
import {
CircleCloseFilled,
Delete,
Download,
MagicStick,
Picture as PictureIcon,
Plus,
Refresh,
ZoomIn,
} from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { getSelectableTokenInfo } from '@/api';
import { generateImage, getImageModels, getTaskStatus } from '@/api/aiImage';
const emit = defineEmits(['task-created']);
// State
const tokenOptions = ref<any[]>([]);
const selectedTokenId = ref('');
const tokenLoading = ref(false);
const modelOptions = ref<ImageModel[]>([]);
const selectedModelId = ref('');
const modelLoading = ref(false);
const prompt = ref('');
const fileList = ref<UploadUserFile[]>([]);
const compressImage = ref(true);
const generating = ref(false);
const currentTaskId = ref('');
const currentTask = ref<TaskStatusResponse | null>(null);
const showViewer = ref(false);
let pollTimer: any = null;
const canGenerate = computed(() => {
return selectedModelId.value && prompt.value && !generating.value;
});
// Methods
async function fetchTokens() {
tokenLoading.value = true;
try {
const res = await getSelectableTokenInfo();
// Handle potential wrapper
const data = Array.isArray(res) ? res : (res as any).data || [];
// Add Default Option
tokenOptions.value = [
{ tokenId: '', name: '默认 (Default)', isDisabled: false },
...data,
];
// Default select "Default" if available, otherwise first available
if (!selectedTokenId.value) {
selectedTokenId.value = '';
}
}
catch (e) {
console.error(e);
}
finally {
tokenLoading.value = false;
}
}
async function fetchModels() {
modelLoading.value = true;
try {
const res = await getImageModels();
// Handle potential wrapper
const data = Array.isArray(res) ? res : (res as any).data || [];
modelOptions.value = data;
// Default select first
if (modelOptions.value.length > 0 && !selectedModelId.value) {
selectedModelId.value = modelOptions.value[0].modelId;
}
}
catch (e) {
console.error(e);
}
finally {
modelLoading.value = false;
}
}
function handleFileChange(uploadFile: UploadFile) {
const isLt5M = uploadFile.size! / 1024 / 1024 < 5;
if (!isLt5M) {
ElMessage.error('图片大小不能超过 5MB!');
const index = fileList.value.indexOf(uploadFile);
if (index !== -1)
fileList.value.splice(index, 1);
}
}
function handleRemove(file: UploadFile) {
const index = fileList.value.indexOf(file);
if (index !== -1)
fileList.value.splice(index, 1);
}
function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result as string);
reader.onerror = error => reject(error);
});
}
async function handleGenerate() {
if (!canGenerate.value)
return;
generating.value = true;
// Reset current task display immediately
currentTask.value = {
id: '',
prompt: prompt.value,
taskStatus: 'Processing',
publishStatus: 'Unpublished',
categories: [],
creationTime: new Date().toISOString(),
};
try {
const base64Images: string[] = [];
for (const fileItem of fileList.value) {
if (fileItem.raw) {
const base64 = await fileToBase64(fileItem.raw);
base64Images.push(base64);
}
}
const res = await generateImage({
tokenId: selectedTokenId.value || undefined, // Send undefined if empty
prompt: prompt.value,
modelId: selectedModelId.value,
referenceImagesPrefixBase64: base64Images,
});
// Robust ID extraction: Handle string, object wrapper, or direct object
let taskId = '';
if (typeof res === 'string') {
taskId = res;
}
else if (typeof res === 'object' && res !== null) {
// Check for data property which might contain the ID string or object
const data = (res as any).data;
if (typeof data === 'string') {
taskId = data;
}
else if (typeof data === 'object' && data !== null) {
taskId = data.id || data.taskId;
}
else {
// Fallback to direct properties
taskId = (res as any).id || (res as any).taskId || (res as any).result;
}
}
if (!taskId) {
console.error('Task ID not found in response:', res);
throw new Error('Invalid Task ID');
}
currentTaskId.value = taskId;
startPolling(taskId);
emit('task-created');
}
catch (e) {
console.error(e);
ElMessage.error('生成任务创建失败');
currentTask.value = null;
}
finally {
generating.value = false; // Allow new tasks immediately
}
}
function startPolling(taskId: string) {
if (pollTimer)
clearInterval(pollTimer);
// Initial fetch
pollStatus(taskId);
pollTimer = setInterval(() => {
pollStatus(taskId);
}, 3000);
}
async function pollStatus(taskId: string) {
try {
const res = await getTaskStatus(taskId);
// Handle response structure if needed
const taskData = (res as any).data || res;
// Only update if it matches the current task we are watching
// This prevents race conditions if user starts a new task
if (currentTaskId.value === taskId) {
currentTask.value = taskData;
// Case-insensitive check just in case
const status = taskData.taskStatus;
if (status === 'Success' || status === 'Fail') {
stopPolling();
if (status === 'Success') {
ElMessage.success('图片生成成功');
}
else {
ElMessage.error(taskData.errorInfo || '图片生成失败');
}
}
}
else {
// If task ID changed, stop polling this old task
// Actually, we should probably just let the new task's poller handle it
// But since we use a single pollTimer variable, starting a new task clears the old timer.
// So this check is mostly for the initial async call returning after new task started.
}
}
catch (e) {
console.error(e);
// Don't stop polling on transient network errors, but maybe log it
}
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
function clearPrompt() {
prompt.value = '';
}
async function downloadImage() {
if (currentTask.value?.storeUrl) {
try {
const response = await fetch(currentTask.value.storeUrl);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `generated-image-${currentTask.value.id}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (e) {
console.error('Download failed', e);
// Fallback
window.open(currentTask.value.storeUrl, '_blank');
}
}
}
// Exposed methods for external control
function setPrompt(text: string) {
prompt.value = text;
}
// Helper to load image from URL and convert to File object
async function addReferenceImage(url: string) {
if (fileList.value.length >= 2) {
ElMessage.warning('最多只能上传2张参考图');
return;
}
try {
const response = await fetch(url);
const blob = await response.blob();
const filename = url.split('/').pop() || 'reference.png';
const file = new File([blob], filename, { type: blob.type });
const uploadFile: UploadUserFile = {
name: filename,
url,
raw: file,
uid: Date.now(),
status: 'ready',
};
fileList.value.push(uploadFile);
}
catch (e) {
console.error('Failed to load reference image', e);
ElMessage.error('无法加载参考图');
}
}
defineExpose({
setPrompt,
addReferenceImage,
});
onMounted(() => {
fetchTokens();
fetchModels();
});
onUnmounted(() => {
stopPolling();
});
</script>
<template>
<div class="flex flex-col h-full md:flex-row gap-6 p-4 bg-white rounded-lg shadow-sm">
<!-- Left Config Panel -->
<div class="w-full md:w-[400px] flex flex-col gap-6 overflow-y-auto pr-2 custom-scrollbar">
<div class="space-y-4">
<h2 class="text-lg font-bold text-gray-800 flex items-center gap-2">
<el-icon><MagicStick /></el-icon>
配置
</h2>
<!-- Token & Model -->
<div class="bg-gray-50 p-4 rounded-lg space-y-4">
<el-form-item label="API密钥" class="mb-0">
<el-select
v-model="selectedTokenId"
placeholder="请选择API密钥"
class="w-full"
:loading="tokenLoading"
>
<el-option
v-for="token in tokenOptions"
:key="token.tokenId"
:label="token.name"
:value="token.tokenId"
:disabled="token.isDisabled"
/>
</el-select>
</el-form-item>
<el-form-item label="模型" class="mb-0">
<el-select
v-model="selectedModelId"
placeholder="请选择模型"
class="w-full"
:loading="modelLoading"
>
<el-option
v-for="model in modelOptions"
:key="model.modelId"
:label="model.modelName"
:value="model.modelId"
>
<div class="flex flex-col py-1">
<span class="font-medium">{{ model.modelName }}</span>
<span class="text-xs text-gray-400 truncate">{{ model.modelDescribe }}</span>
</div>
</el-option>
</el-select>
</el-form-item>
</div>
<!-- Prompt -->
<div class="space-y-2">
<div class="flex justify-between items-center">
<label class="text-sm font-medium text-gray-700">提示词</label>
<el-button link type="primary" size="small" @click="clearPrompt">
<el-icon class="mr-1">
<Delete />
</el-icon>清空
</el-button>
</div>
<el-input
v-model="prompt"
type="textarea"
:autosize="{ minRows: 8, maxRows: 15 }"
placeholder="描述你想要生成的画面,例如:一只在太空中飞行的赛博朋克风格的猫..."
maxlength="2000"
show-word-limit
class="custom-textarea"
/>
</div>
<!-- Reference Image -->
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">参考图 (可选)</label>
<div class="bg-gray-50 p-4 rounded-lg border border-dashed border-gray-300 hover:border-blue-400 transition-colors">
<el-upload
v-model:file-list="fileList"
action="#"
list-type="picture-card"
:auto-upload="false"
:limit="2"
:on-change="handleFileChange"
:on-remove="handleRemove"
accept=".jpg,.jpeg,.png,.bmp,.webp"
:class="{ 'hide-upload-btn': fileList.length >= 2 }"
>
<div class="flex flex-col items-center justify-center text-gray-400">
<el-icon class="text-2xl mb-2">
<Plus />
</el-icon>
<span class="text-xs">点击上传</span>
</div>
</el-upload>
<div class="text-xs text-gray-400 mt-2 flex justify-between items-center flex-wrap gap-2">
<span>最多2张< 5MB (支持 JPG/PNG/WEBP)</span>
<el-checkbox v-model="compressImage" label="压缩图片" size="small" />
</div>
</div>
</div>
</div>
<div class="mt-auto pt-4">
<el-button
type="primary"
class="w-full h-12 text-lg shadow-lg shadow-blue-500/30 transition-all hover:shadow-blue-500/50"
:loading="generating"
:disabled="!canGenerate"
round
@click="handleGenerate"
>
{{ generating ? '生成中...' : '开始生成' }}
</el-button>
</div>
</div>
<!-- Right Result Panel -->
<div class="flex-1 bg-gray-100 rounded-xl overflow-hidden relative flex flex-col h-full">
<div v-if="currentTask" class="flex-1 flex flex-col relative h-full overflow-hidden">
<!-- Image Display -->
<div class="flex-1 flex items-center justify-center p-4 bg-checkboard overflow-hidden relative min-h-0">
<div v-if="currentTask.taskStatus === 'Success' && currentTask.storeUrl" class="relative group w-full h-full flex items-center justify-center">
<el-image
ref="previewImageRef"
:src="currentTask.storeUrl"
fit="contain"
class="w-full h-full"
:style="{ maxHeight: '100%', maxWidth: '100%' }"
:preview-src-list="[currentTask.storeUrl]"
:initial-index="0"
:preview-teleported="true"
/>
<!-- Hover Actions -->
<div class="absolute bottom-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity flex gap-2 z-10">
<el-button circle type="primary" :icon="ZoomIn" @click="showViewer = true" />
<el-button circle type="primary" :icon="Download" @click="downloadImage" />
<el-button circle type="info" :icon="Refresh" title="新任务" @click="currentTask = null" />
</div>
<el-image-viewer
v-if="showViewer"
:url-list="[currentTask.storeUrl]"
@close="showViewer = false"
/>
</div>
<!-- Processing State -->
<div v-else-if="currentTask.taskStatus === 'Processing'" class="text-center">
<div class="loader mb-6" />
<p class="text-gray-600 font-medium text-lg animate-pulse">
正在绘制您的想象...
</p>
<p class="text-gray-400 text-sm mt-2">
请稍候这可能需要几秒钟
</p>
</div>
<!-- Fail State -->
<div v-else-if="currentTask.taskStatus === 'Fail'" class="text-center text-red-500">
<el-icon class="text-6xl mb-4">
<CircleCloseFilled />
</el-icon>
<p class="text-lg font-medium">
生成失败
</p>
<p class="text-sm opacity-80 mt-1">
{{ currentTask.errorInfo || '请检查提示词或稍后重试' }}
</p>
<el-button class="mt-4" icon="Refresh" @click="handleGenerate">
重试
</el-button>
</div>
</div>
<!-- Prompt Display (Bottom) -->
<div class="bg-white p-4 border-t border-gray-200 shrink-0 max-h-40 overflow-y-auto">
<p class="text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">
当前提示词
</p>
<p class="text-gray-800 text-sm leading-relaxed whitespace-pre-wrap">
{{ currentTask.prompt }}
</p>
</div>
</div>
<!-- Empty State -->
<div v-else class="flex-1 flex flex-col items-center justify-center text-gray-400 bg-gray-50/50 h-full">
<div class="w-32 h-32 bg-gray-200 rounded-full flex items-center justify-center mb-6">
<el-icon class="text-5xl text-gray-400">
<PictureIcon />
</el-icon>
</div>
<h3 class="text-xl font-semibold text-gray-600 mb-2">
准备好开始了吗
</h3>
<p class="text-gray-500 max-w-md text-center">
在左侧配置您的创意参数点击生成按钮见证AI的奇迹
</p>
</div>
</div>
</div>
</template>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: #e5e7eb;
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background-color: transparent;
}
.bg-checkboard {
background-image:
linear-gradient(45deg, #f0f0f0 25%, transparent 25%),
linear-gradient(-45deg, #f0f0f0 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #f0f0f0 75%),
linear-gradient(-45deg, transparent 75%, #f0f0f0 75%);
background-size: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
}
/* Loader Animation */
.loader {
width: 48px;
height: 48px;
border: 5px solid #e5e7eb;
border-bottom-color: #3b82f6;
border-radius: 50%;
display: inline-block;
box-sizing: border-box;
animation: rotation 1s linear infinite;
}
@keyframes rotation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
:deep(.hide-upload-btn .el-upload--picture-card) {
display: none;
}
</style>