feat: 大样图全链路优化(草图旋转转正+框选裁剪拆窗+结果并排对比+再生成一张)
This commit is contained in:
@@ -84,40 +84,74 @@
|
||||
<el-empty v-if="!drawing" description="请先选择样板" />
|
||||
</template>
|
||||
|
||||
<!-- ============ 工程大样图(2D 图纸 → 黑白 CAD 大样图, 整合自 autocad 项目草图标准化能力) ============ -->
|
||||
<!-- ============ 工程大样图(手绘草图/2D 图纸 → 黑白 CAD 大样图, 整合自 autocad 项目草图标准化能力) ============ -->
|
||||
<template v-else-if="tab === 'blueprint'">
|
||||
<div class="controls">
|
||||
<el-upload :show-file-list="false" accept="image/*" :http-request="onSketchUpload">
|
||||
<el-button type="primary" plain>{{ sketchPhoto ? '更换手绘草图' : '上传手绘草图' }}</el-button>
|
||||
<el-button type="primary" plain>{{ sketchImg ? '更换手绘草图' : '上传手绘草图' }}</el-button>
|
||||
</el-upload>
|
||||
<el-button v-if="sketchPhoto" size="small" text type="danger" @click="sketchPhoto = ''">移除草图</el-button>
|
||||
<span class="tip">{{ sketchPhoto ? '已上传手绘草图,AI 将逐窗转为规范大样图' : '上传现场手绘量尺登记表照片;也可不传,用所选样板的 2D 图纸生成' }}</span>
|
||||
<template v-if="sketchImg">
|
||||
<el-button size="small" @click="rotBp(-90)" title="逆时针转正">↺ 旋转</el-button>
|
||||
<el-button size="small" @click="rotBp(90)" title="顺时针转正">↻ 旋转</el-button>
|
||||
<el-button v-if="bpCrop" size="small" text @click="clearBpCrop">重置裁剪</el-button>
|
||||
<el-button size="small" text type="danger" @click="removeSketch">移除</el-button>
|
||||
</template>
|
||||
<span class="tip">{{ sketchImg
|
||||
? '拖拽框选可裁出单个/少量窗(逐窗生成更精准)'
|
||||
: '上传现场手绘量尺登记表照片(可旋转转正、框选裁剪);也可不传,用所选样板的 2D 图纸生成' }}</span>
|
||||
<el-tooltip content="AI 生成的尺寸与窗数可能有误,输出仅供人工审图参考,请逐窗逐尺寸核对后再用于报价" placement="bottom">
|
||||
<el-icon class="warn-icon"><WarningFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
<div style="flex:1" />
|
||||
<el-button type="primary" :disabled="(!sketchPhoto && !drawing) || generating" :loading="generating"
|
||||
@click="generateBlueprint">
|
||||
<el-button type="primary" :disabled="(!sketchImg && !drawing) || generating" :loading="generating"
|
||||
@click="generateBlueprint()">
|
||||
{{ generating ? 'AI 绘制中…' : '✦ AI 生成大样图' }}</el-button>
|
||||
</div>
|
||||
<el-progress v-if="generating" :percentage="progress" :stroke-width="10" style="margin: 10px 0"
|
||||
:format="() => stageText" />
|
||||
<!-- 底图: 优先上传的手绘草图, 否则 2D 图纸 -->
|
||||
<div v-if="sketchPhoto" class="bp-stage bp-photo">
|
||||
<img :src="sketchPhoto" class="sketch-img" />
|
||||
<!-- 草图模式: 旋转转正 + 框选裁剪 -->
|
||||
<div v-if="sketchImg" class="bp-stage bp-edit">
|
||||
<canvas ref="bpSketchRef" class="bp-cv" @mousedown="onBpDown" @mousemove="onBpMove"
|
||||
@mouseup="onBpUp" @mouseleave="onBpUp" />
|
||||
<div class="stage-tip" v-if="!bpRect">拖拽框选窗区域后点「生成」,仅转换选中部分(推荐每页窗多时使用)</div>
|
||||
</div>
|
||||
<div v-else-if="drawing" class="bp-stage">
|
||||
<WindoorCanvas ref="bpCanvasRef" :drawing-data="drawing" :editable="false" />
|
||||
</div>
|
||||
<el-empty v-else description="上传手绘草图照片,或先选择样板" />
|
||||
|
||||
<!-- 结果: 原稿|大样图 并排对比 -->
|
||||
<div v-if="aiImage && lastBpBase" class="ai-result">
|
||||
<div class="bp-compare">
|
||||
<div class="cmp">
|
||||
<div class="cap">原始草图</div>
|
||||
<img :src="lastBpBase" class="cmp-img" />
|
||||
</div>
|
||||
<div class="cmp">
|
||||
<div class="cap">AI 大样图</div>
|
||||
<img :src="aiImage" class="cmp-img" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="ai-ops">
|
||||
<el-button type="primary" size="small" plain @click="downloadAi">下载大样图</el-button>
|
||||
<el-button size="small" type="warning" plain :disabled="generating"
|
||||
@click="generateBlueprint(true)">不满意?再生成一张</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>
|
||||
</template>
|
||||
|
||||
<div v-if="variants.length" class="gallery-foot">
|
||||
<span class="tip">本地 4 方案(程序化渲染降级模式)· 点击图标下载</span>
|
||||
</div>
|
||||
|
||||
<!-- 云端 AI 结果(两个模式共用) -->
|
||||
<div v-if="aiImage" class="ai-result">
|
||||
<!-- 云端 AI 结果(照片/速览模式共用;大样图模式用上方并排对比区) -->
|
||||
<div v-if="aiImage && tab !== 'blueprint'" 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>
|
||||
@@ -301,43 +335,152 @@ async function generateCloud(mode: 'quick' | 'photo' | 'blueprint', sketch_b64?:
|
||||
}
|
||||
}
|
||||
|
||||
/** 工程大样图: 底图优先上传的手绘草图照片, 否则 2D 图纸画布截图; AI 重绘为黑白 CAD 大样图 */
|
||||
const sketchPhoto = ref('')
|
||||
/** 工程大样图: 草图上传→旋转转正→框选裁剪(拆窗)→生成; 无草图时用 2D 图纸画布截图 */
|
||||
const sketchImg = ref<HTMLImageElement | null>(null)
|
||||
const bpSketchRef = ref<HTMLCanvasElement>()
|
||||
const bpRot = ref(0) // 旋转角 0/90/180/270
|
||||
const bpRect = ref<{ x: number; y: number; w: number; h: number } | null>(null) // 框选(显示坐标)
|
||||
const bpCrop = ref('') // 裁剪结果 dataURL
|
||||
const lastBpBase = ref('') // 本次生成底图(结果对比展示)
|
||||
let bpDragStart: { x: number; y: number } | null = null
|
||||
let bpRotCanvas: HTMLCanvasElement | null = null // 旋转后整图离屏(≤1536)
|
||||
|
||||
function onSketchUpload(e: { file: unknown }) {
|
||||
// el-upload http-request: options.file 即原始 File(部分版本包 raw)
|
||||
const opt = e.file as { raw?: File } | File
|
||||
const file = (opt as { raw?: File }).raw ?? (opt as File)
|
||||
if (!(file instanceof File)) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
// 手机原图过大(5~10MB)会导致生图中转超时/拒绝, 统一压至 ≤1536px JPEG
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
const scale = Math.min(1, 1536 / Math.max(img.naturalWidth, img.naturalHeight))
|
||||
const cv = document.createElement('canvas')
|
||||
cv.width = Math.max(1, Math.round(img.naturalWidth * scale))
|
||||
cv.height = Math.max(1, Math.round(img.naturalHeight * scale))
|
||||
cv.getContext('2d')!.drawImage(img, 0, 0, cv.width, cv.height)
|
||||
sketchPhoto.value = cv.toDataURL('image/jpeg', 0.9)
|
||||
ElMessage.success('草图已上传')
|
||||
}
|
||||
img.onerror = () => ElMessage.error('图片读取失败,请重试')
|
||||
img.src = String(reader.result)
|
||||
const url = URL.createObjectURL(file)
|
||||
const img = new Image()
|
||||
img.onload = async () => {
|
||||
sketchImg.value = img
|
||||
bpRot.value = 0
|
||||
bpRect.value = null
|
||||
bpCrop.value = ''
|
||||
URL.revokeObjectURL(url)
|
||||
await nextTick()
|
||||
buildBpRotCanvas()
|
||||
drawBpStage()
|
||||
ElMessage.success('草图已上传,可旋转转正或框选裁剪后生成')
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
img.onerror = () => ElMessage.error('图片读取失败,请重试')
|
||||
img.src = url
|
||||
}
|
||||
|
||||
async function generateBlueprint() {
|
||||
// 上传的手绘草图优先(脱离样板上下文); 无上传时用 2D 图纸画布截图
|
||||
if (sketchPhoto.value) {
|
||||
await generateCloud('blueprint', sketchPhoto.value.split(',')[1], true)
|
||||
return
|
||||
function removeSketch() {
|
||||
sketchImg.value = null
|
||||
bpRot.value = 0
|
||||
bpRect.value = null
|
||||
bpCrop.value = ''
|
||||
bpRotCanvas = null
|
||||
}
|
||||
|
||||
function rotBp(deg: number) {
|
||||
bpRot.value = (bpRot.value + deg + 360) % 360
|
||||
bpRect.value = null
|
||||
buildBpRotCanvas()
|
||||
drawBpStage()
|
||||
}
|
||||
|
||||
function clearBpCrop() {
|
||||
bpRect.value = null
|
||||
bpCrop.value = ''
|
||||
drawBpStage()
|
||||
}
|
||||
|
||||
/** 旋转后整图离屏 canvas(≤1536px):裁剪源 + 生成底图 */
|
||||
function buildBpRotCanvas() {
|
||||
const img = sketchImg.value
|
||||
if (!img) return
|
||||
const rotated = bpRot.value % 180 !== 0
|
||||
const srcW = rotated ? img.naturalHeight : img.naturalWidth
|
||||
const srcH = rotated ? img.naturalWidth : img.naturalHeight
|
||||
const scale = Math.min(1, 1536 / Math.max(srcW, srcH))
|
||||
const cv = bpRotCanvas ?? document.createElement('canvas')
|
||||
cv.width = Math.max(1, Math.round(srcW * scale))
|
||||
cv.height = Math.max(1, Math.round(srcH * scale))
|
||||
const ctx = cv.getContext('2d')!
|
||||
ctx.save()
|
||||
ctx.translate(cv.width / 2, cv.height / 2)
|
||||
ctx.rotate((bpRot.value * Math.PI) / 180)
|
||||
ctx.drawImage(img, (-img.naturalWidth * scale) / 2, (-img.naturalHeight * scale) / 2,
|
||||
img.naturalWidth * scale, img.naturalHeight * scale)
|
||||
ctx.restore()
|
||||
bpRotCanvas = cv
|
||||
}
|
||||
|
||||
/** 草图舞台: 旋转后整图 + 框选遮罩(bpRect 为显示坐标) */
|
||||
function drawBpStage() {
|
||||
const cv = bpSketchRef.value
|
||||
const base = bpRotCanvas
|
||||
if (!cv || !base) return
|
||||
const fit = Math.min(1, Math.min(920, window.innerWidth - 140) / base.width)
|
||||
cv.width = Math.round(base.width * fit)
|
||||
cv.height = Math.round(base.height * fit)
|
||||
const ctx = cv.getContext('2d')!
|
||||
ctx.drawImage(base, 0, 0, cv.width, cv.height)
|
||||
if (bpRect.value) {
|
||||
const f = base.width / cv.width
|
||||
const { x, y, w, h } = bpRect.value
|
||||
ctx.save()
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.35)'
|
||||
ctx.fillRect(0, 0, cv.width, cv.height)
|
||||
ctx.drawImage(base, x * f, y * f, w * f, h * f, x, y, w, h)
|
||||
ctx.strokeStyle = '#5b3fd4'
|
||||
ctx.lineWidth = 2.5
|
||||
ctx.setLineDash([8, 5])
|
||||
ctx.strokeRect(x, y, w, h)
|
||||
ctx.restore()
|
||||
}
|
||||
await nextTick()
|
||||
const dataUrl = bpCanvasRef.value?.exportImage?.()
|
||||
if (!dataUrl) { ElMessage.warning('图纸截图失败,请稍后重试'); return }
|
||||
await generateCloud('blueprint', dataUrl.split(',')[1])
|
||||
}
|
||||
|
||||
function onBpDown(e: MouseEvent) {
|
||||
const cv = bpSketchRef.value
|
||||
if (!cv) return
|
||||
const b = cv.getBoundingClientRect()
|
||||
bpDragStart = { x: e.clientX - b.left, y: e.clientY - b.top }
|
||||
bpRect.value = null
|
||||
}
|
||||
function onBpMove(e: MouseEvent) {
|
||||
if (!bpDragStart || !bpSketchRef.value) return
|
||||
const b = bpSketchRef.value.getBoundingClientRect()
|
||||
const p = { x: e.clientX - b.left, y: e.clientY - b.top }
|
||||
bpRect.value = { x: Math.min(bpDragStart.x, p.x), y: Math.min(bpDragStart.y, p.y),
|
||||
w: Math.abs(p.x - bpDragStart.x), h: Math.abs(p.y - bpDragStart.y) }
|
||||
drawBpStage()
|
||||
}
|
||||
function onBpUp() {
|
||||
if (bpRect.value && (bpRect.value.w < 25 || bpRect.value.h < 25)) bpRect.value = null
|
||||
bpDragStart = null
|
||||
drawBpStage()
|
||||
}
|
||||
|
||||
async function generateBlueprint(regen = false) {
|
||||
let base = ''
|
||||
if (sketchImg.value) {
|
||||
if (bpRect.value && bpRotCanvas && bpSketchRef.value) {
|
||||
// 框选裁剪: 显示坐标 → 离屏图坐标
|
||||
const f = bpRotCanvas.width / bpSketchRef.value.width
|
||||
const r = bpRect.value
|
||||
const c = document.createElement('canvas')
|
||||
c.width = Math.max(1, Math.round(r.w * f))
|
||||
c.height = Math.max(1, Math.round(r.h * f))
|
||||
c.getContext('2d')!.drawImage(bpRotCanvas, r.x * f, r.y * f, r.w * f, r.h * f, 0, 0, c.width, c.height)
|
||||
base = c.toDataURL('image/jpeg', 0.9)
|
||||
bpCrop.value = base
|
||||
} else {
|
||||
buildBpRotCanvas()
|
||||
base = bpRotCanvas!.toDataURL('image/jpeg', 0.9)
|
||||
}
|
||||
} else {
|
||||
await nextTick()
|
||||
const dataUrl = bpCanvasRef.value?.exportImage?.()
|
||||
if (!dataUrl) { ElMessage.warning('图纸截图失败,请稍后重试'); return }
|
||||
base = dataUrl
|
||||
}
|
||||
lastBpBase.value = base
|
||||
void regen
|
||||
await generateCloud('blueprint', base.split(',')[1], !!sketchImg.value)
|
||||
}
|
||||
|
||||
/** 照片模式参考图: 全图压至 ≤1536px JPEG base64 */
|
||||
@@ -438,8 +581,12 @@ onMounted(async () => {
|
||||
background: rgba(0,0,0,0.5); padding: 4px 10px; border-radius: 10px; pointer-events: none; }
|
||||
.preview { height: 58vh; }
|
||||
.bp-stage { height: 58vh; border: 1px solid #e4e7ed; border-radius: 6px; overflow: hidden; }
|
||||
.bp-photo { display: flex; align-items: center; justify-content: center; background: #f5f6f8; }
|
||||
.sketch-img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
||||
.bp-edit { position: relative; display: flex; justify-content: center; align-items: flex-start; background: #f5f6f8; }
|
||||
.bp-cv { max-width: 100%; max-height: 100%; cursor: crosshair; }
|
||||
.bp-compare { display: flex; gap: 12px; align-items: stretch; }
|
||||
.bp-compare .cmp { flex: 1; min-width: 0; border: 1px solid #e4e7ed; border-radius: 8px; overflow: hidden; background: #fafbfc; }
|
||||
.bp-compare .cap { padding: 6px 10px; font-size: 12px; font-weight: 600; color: #606266; background: #f2f3f5; }
|
||||
.cmp-img { display: block; width: 100%; height: auto; }
|
||||
.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; }
|
||||
|
||||
Reference in New Issue
Block a user