feat: AI炫图接入gpt-image-2.5云端生图(结构化提示词工程+照片参考合成)

This commit is contained in:
HHY
2026-09-25 04:15:45 -07:00
parent 710b3521a2
commit 6726a9fa3a
12 changed files with 496 additions and 48 deletions
+3 -3
View File
@@ -36,9 +36,9 @@ http.interceptors.response.use(
},
)
/** 提取 envelope 的 data */
export async function api<T = any>(method: string, url: string, body?: object): Promise<T> {
const res = await http.request({ method, url, data: body })
/** 提取 envelope 的 data(config 可传 timeout 等覆盖) */
export async function api<T = any>(method: string, url: string, body?: object, config?: object): Promise<T> {
const res = await http.request({ method, url, data: body, ...config })
return res.data.data as T
}
+78 -44
View File
@@ -24,8 +24,9 @@
<el-upload :show-file-list="false" accept="image/*" :http-request="onUpload">
<el-button type="primary" plain>{{ photo ? '更换照片' : '上传场景照片' }}</el-button>
</el-upload>
<el-button type="primary" :disabled="!photo || !drawing || !rect" :loading="generating" @click="generate">
{{ generating ? '合成中…' : '✦ 生成炫图' }}</el-button>
<el-button type="primary" :disabled="!photo || !drawing || !rect" :loading="generating"
@click="generateCloud('photo')">
{{ generating ? 'AI 合成中…' : '✦ AI 照片合成' }}</el-button>
</div>
<div v-if="!photo" class="drop-hint" @click="triggerUpload">
<el-icon size="40"><UploadFilled /></el-icon>
@@ -67,8 +68,9 @@
<el-select v-model="form.scene" style="width:110px">
<el-option value="indoor" label="室内" /><el-option value="outdoor" label="室外" /><el-option value="studio" label="影棚" />
</el-select>
<el-button type="primary" :disabled="!drawing" :loading="generating" @click="generateQuick">
{{ generating ? '生成中…' : '✦ 生成 AI 炫图' }}</el-button>
<el-button type="primary" :disabled="!drawing" :loading="generating" @click="generateCloud('quick')">
{{ generating ? 'AI 绘制中…' : '✦ AI 云端生成' }}</el-button>
<el-button :disabled="!drawing" :loading="generating" @click="generateQuick">本地 4 方案</el-button>
</div>
<el-progress v-if="generating" :percentage="progress" :stroke-width="10" style="margin: 10px 0"
@@ -82,7 +84,21 @@
</template>
<div v-if="variants.length" class="gallery-foot">
<span class="tip">点击方案切换大图预览,图标下载 PNG</span>
<span class="tip">本地 4 方案(程序化渲染降级模式)· 点击图标下载</span>
</div>
<!-- 云端 AI 结果(两个模式共用) -->
<div v-if="aiImage" class="ai-result">
<img :src="aiImage" class="ai-img" />
<div class="ai-ops">
<el-button type="primary" size="small" plain @click="downloadAi">下载 PNG</el-button>
</div>
<el-collapse class="prompt-box">
<el-collapse-item title="查看优化后的提示词(由窗型数据结构化生成)">
<div class="prompt-text">{{ aiPrompt }}</div>
<div class="prompt-feat" v-if="aiFeatures">窗型特征:{{ aiFeatures }}</div>
</el-collapse-item>
</el-collapse>
</div>
</el-card>
</template>
@@ -92,7 +108,7 @@ import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { api } from '../api'
import AIRenderCanvas from '../components/AIRenderCanvas.vue'
import { PHOTO_SCENES, composePhoto, type PhotoScene } from '../components/photoCompose'
import { PHOTO_SCENES, type PhotoScene } from '../components/photoCompose'
const tab = ref<'photo' | 'quick'>('photo')
const templates = ref<any[]>([])
@@ -122,8 +138,6 @@ const stageRef = ref<HTMLCanvasElement>()
const rect = ref<{ x: number; y: number; w: number; h: number } | null>(null)
let dragStart: { x: number; y: number } | null = null
const sceneLabel = (s: string) => PHOTO_SCENES.find(x => x.key === s)?.label ?? s
async function loadTemplate(tid: number | undefined) {
drawing.value = ''
variants.value = []
@@ -209,52 +223,65 @@ function drawStage() {
}
}
// ---- 照片合成生成(4 方案: 当前场景 + 3 变化) ----
async function generate() {
if (!photo.value || !drawing.value || !rect.value) return
// ---- 云端 AI 生成(gpt-image-2.5, 提示词由后端从窗型数据结构化构建) ----
const aiImage = ref('')
const aiPrompt = ref('')
const aiFeatures = ref('')
async function generateCloud(mode: 'quick' | 'photo') {
if (!drawing.value) return
if (mode === 'photo' && (!photo.value || !rect.value)) return
generating.value = true
aiImage.value = ''
variants.value = []
const plan: { key: PhotoScene; label: string }[] = [
{ key: scene.value, label: `${sceneLabel(scene.value)}` },
{ key: 'install', label: '安装合成' },
{ key: 'erase', label: '门窗擦除' },
{ key: scene.value === 'render' ? 'indoor' : 'render', label: scene.value === 'render' ? '室内装修' : '渲图' },
]
progress.value = 8
stageText.value = mode === 'photo' ? '上传照片与窗型特征' : '解析窗型结构'
const timer = window.setInterval(() => {
progress.value = Math.min(92, progress.value + 2)
if (progress.value > 40) stageText.value = 'AI 绘制中(约 30~90 秒, 请勿关闭页面)'
}, 1500)
try {
for (let s = 0; s < STAGES.length; s++) {
stageText.value = STAGES[s]
for (let p = 0; p <= 25; p += 5) {
progress.value = Math.min(100, s * 25 + p)
await new Promise(r => setTimeout(r, 18))
}
await new Promise(r => setTimeout(r, 10))
let photo_b64: string | undefined
if (mode === 'photo') {
photo_b64 = photoB64()
}
const d = await api<any>('post', '/api/ai-render/generate', {
template_id: templateId.value, mode,
style: form.ai_style, time: form.ai_time,
scene: mode === 'photo' ? scene.value : form.scene, photo_b64,
}, { timeout: 200000 })
aiImage.value = `data:image/png;base64,${d.image_b64}`
aiPrompt.value = d.prompt
aiFeatures.value = d.features?.summary ?? ''
progress.value = 100
variants.value = plan.map((p, i) => ({
label: p.label, style: String(p.key), time: String(i), seed: 7 + i * 13,
}))
pickIdx.value = 0
// 等 Vue 挂载画廊画布后再合成输出
await nextFrames()
// 渲染画廊: composePhoto 输出到各画布
for (let i = 0; i < plan.length; i++) {
const cv = variantCvs[i]
if (!cv) continue
const out = composePhoto(photo.value, rect.value, drawing.value, plan[i].key, 1280, 7 + i * 13)
cv.width = out.width
cv.height = out.height
cv.getContext('2d')!.drawImage(out, 0, 0)
stageText.value = `合成方案 ${i + 1}/4`
await new Promise(r => setTimeout(r, 30))
}
ElMessage.success('已生成 4 套炫图方案')
} finally {
ElMessage.success('AI 炫图已生成')
} catch { /* 拦截器已提示 */ } finally {
window.clearInterval(timer)
generating.value = false
progress.value = 0
}
}
// ---- 风格速览(原有程序化模式) ----
/** 照片模式参考图: 全图压至 ≤1536px JPEG base64 */
function photoB64(): string {
const img = photo.value!
const scale = Math.min(1, 1536 / img.naturalWidth)
const cv = document.createElement('canvas')
cv.width = Math.round(img.naturalWidth * scale)
cv.height = Math.round(img.naturalHeight * scale)
cv.getContext('2d')!.drawImage(img, 0, 0, cv.width, cv.height)
return cv.toDataURL('image/jpeg', 0.9).split(',')[1]
}
function downloadAi() {
if (!aiImage.value) return
const a = document.createElement('a')
a.href = aiImage.value
a.download = 'AI炫图.png'
a.click()
}
// ---- 风格速览(本地程序化 4 方案, 未配置云端 Key 时的降级模式) ----
async function generateQuick() {
generating.value = true
variants.value = []
@@ -321,6 +348,13 @@ onMounted(async () => {
.preview { height: 58vh; }
.gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; margin-top: 14px; }
.gallery-foot { margin-top: 8px; }
.ai-result { margin-top: 14px; text-align: center; }
.ai-img { max-width: 100%; max-height: 72vh; border-radius: 10px; box-shadow: 0 2px 14px rgba(0,0,0,.14); }
.ai-ops { margin-top: 8px; }
.prompt-box { margin-top: 10px; text-align: left; }
.prompt-text { font-size: 12px; color: #606266; line-height: 1.7; font-family: Consolas, monospace;
background: #f7f8fa; padding: 10px 12px; border-radius: 6px; word-break: break-word; }
.prompt-feat { margin-top: 8px; font-size: 12px; color: #909399; }
.shot { position: relative; border: 2px solid transparent; border-radius: 10px; overflow: hidden; cursor: pointer;
height: 200px; transition: border-color .2s, transform .2s; }
.shot:hover { transform: translateY(-2px); }