feat: AI生成图持久化记录(AiRenderRecord模型+图片落盘+记录列表/图片/删除端点+前端生成记录抽屉, 三模式统一)

This commit is contained in:
HHY
2026-10-07 04:08:27 -07:00
parent f9456b5633
commit ac55932969
7 changed files with 197 additions and 4 deletions
+3
View File
@@ -25,3 +25,6 @@ data/dxf-study/
.tmp_token
.tmp_*/
*.drawing-bundle.mjs
# AI 生成图(运行时产物)
runtime/
+1
View File
@@ -1,5 +1,6 @@
# CHANGELOG
- [2026-10-07 12:07] FEAT: v2.51 AI 生成图持久化记录(用户需求: 生成图要有记录而非一次性缓存)。新模型 AiRenderRecord(ai_render_records: 公司/用户/模式/窗型/场景/提示词/文件路径/尺寸); 三模式(quick/photo/blueprint)统一持久化: generate 成功后图片落盘 runtime/ai_renders/{company}/{id}.png(gitignore)+元数据入库, 响应新增 record_id; 新端点: GET /records(公司隔离分页+mode 筛选)/GET /records/{id}/image(FileResponse PNG, 公司隔离)/DELETE /records/{id}(文件+记录清理); 前端 AI 炫图页头部「生成记录」按钮+抽屉(缩略图 blob 拉取绕过 img 无法带 token+模式标签+窗型+时间+操作人+下载/删除+分页), 生成成功自动刷新; 安全校验: 匿名 code=401 不泄露图片/公司隔离与列表同模式; runtime/ 加入 gitignore; 回归: 冒烟 60/60+24 页巡检 0 异常; UI 抽屉截图验证 (Files: src/huamenchuang/models/extend.py, src/huamenchuang/models/__init__.py, src/huamenchuang/api/ai_render.py, frontend/src/views/AIRenderView.vue, doc/API.md, .gitignore)
- [2026-10-07 11:20] FEAT: v2.50 大样图斜角识别(用户需求: 斜角窗也要识别)。提示词新增「斜角铁律」——手稿中的斜边/切角/梯形轮廓必须原样保留(斜线方向与两端点位置与原图一致), 严禁把斜角窗/梯形窗拉直画成矩形; 斜边尺寸照常誊写/未标注按原图比例; 圆弧边同样保留; PIL 构造斜角手绘测试稿(右上切角窗 1500x1200+左下梯形窗 900x800)实测 47s: 两窗斜边完整保留(切角方向与位置与手稿一致/梯形轮廓未被拉直), 分格/开扇对角线/尺寸线(1500/700/900/800/1200)全对, 标题栏完整; 回归: 冒烟 60/60 (Files: src/huamenchuang/api/ai_render.py)
- [2026-10-07 11:30] FIX: v2.49 大样图窗方向识别修复(用户报告: 窗被旋转竖放)。对照第39页登记表: 修复前左上 2380宽横向窗被生成为 1400x2380 竖版, 多窗宽高对调; 根因: 提示词无方向约束, gpt-image 重绘时自由旋转窗体; 修复: 提示词新增「方向铁律(最重要)」——每个窗横竖方向必须与原图完全一致(横向窗输出宽>高/竖向窗高>宽, 严禁旋转竖立横放)+工程制图标注约定(宽度标窗下水平线/高度标右侧竖直线, 登记表宽/mm 栏对应水平方向 高/mm 栏对应竖直方向)+Avoid 追加被旋转方向的窗; 复测第39页: 全部窗方向与原稿一致(横向窗保持横向/磨砂竖条窗保持竖向 125x1185), 窗5 3530 完全精确, 内左右开文字标注/内开内倒对扇/磨砂玻璃标注等细节保留 (Files: src/huamenchuang/api/ai_render.py)
- [2026-10-07 10:30] FEAT: v2.48 大样图全链路优化(草图预处理工具链+结果对比)。蓝图 Tab 重构: ①旋转转正(↺/↻ 90°, 治理横拍旋转图方向塌陷——离屏 canvas 旋转绘制, ≤1536px) ②框选裁剪拆窗(拖拽框选窗区域, 裁剪后仅转换选中部分——根治一页多窗的窗数幻觉与高密度页塌陷, 窗数天然1:1) ③结果并排对比(原始草图|AI大样图 左右对照, 人工核对尺寸效率提升) ④「再生成一张」快捷按钮(应对轮间方差挑最佳) ⑤大样图模式独立结果区(不再与照片/速览共用); 端到端实测: 上传→旋转90°→框选→生成→并排对比全链路通(34s), 裁剪含两窗局部时 AI 仍正确生成两窗规范大样图(分格/开扇/尺寸全对); 移除测试依赖 pillow; 回归: 冒烟 60/60+24 页巡检 0 异常 (Files: frontend/src/views/AIRenderView.vue)
+4
View File
@@ -459,6 +459,10 @@ uv run uvicorn huamenchuang.main:app --reload # 启动,http://localhost:8000
### 12.22 照片式 AI 炫图(v2.20 升级,P0-照片炫图)
| 生成记录列表 | GET | `/api/ai-render/records?page=&mode=` | query | 本公司生成历史(时间倒序,含 mode/窗型/尺寸/操作人);v2.51 三模式统一持久化 |
| 记录原图 | GET | `/api/ai-render/records/{id}/image` | — | FileResponse PNG(公司隔离,匿名 code=401 不泄露) |
| 删除记录 | DELETE | `/api/ai-render/records/{id}` | — | 删除记录+图片文件 |
> **v2.44 新增 `blueprint` 模式(工程大样图,整合自 autocad 项目草图标准化资产);v2.45 支持手绘草图**:`{mode:"blueprint", sketch_b64(手绘量尺登记表照片或 2D 图纸截图), template_id|drawing_data?(可选, 仅作窗型特征参考; 纯草图模式可不传)}` → AI 重绘为黑白 CAD 工程大样图(正投影立面/细黑线框/玻璃淡灰填充/尺寸线/中文工程标注/标题栏;一页多窗逐窗转换,保留分格/开启扇/手写尺寸并规范誊写,不编造尺寸),可作报价与合同附件。提示词预览 `/api/ai-render/prompt` 同样支持 `mode:"blueprint"`。
对齐目标站 `renderV2` 炫图 7 分类:**渲图 / 安装合成 / 门窗擦除 / 室内装修 / 别墅 / 去围栏 / 变毛坯**。
+89 -2
View File
@@ -8,6 +8,8 @@
<el-radio-button value="quick">风格速览</el-radio-button>
<el-radio-button value="blueprint">工程大样图</el-radio-button>
</el-radio-group>
<div style="flex:1" />
<el-button size="small" plain @click="openRecs">生成记录</el-button>
</div>
</template>
@@ -164,13 +166,40 @@
</el-collapse-item>
</el-collapse>
</div>
<!-- 生成记录抽屉(云端生成持久化) -->
<el-drawer v-model="recDrawer" title="AI 生成记录" size="480px">
<div v-loading="recLoading">
<div v-for="r in recs" :key="r.id" class="rec-item">
<el-image :src="recUrls[r.id]" fit="cover" class="rec-thumb"
:preview-src-list="[recUrls[r.id]]" preview-teleported hide-on-click-modal />
<div class="rec-meta">
<div class="rec-line">
<el-tag size="small" :type="r.mode === 'blueprint' ? 'warning' : (r.mode === 'photo' ? 'success' : 'primary')"
effect="plain">{{ modeLabel(r.mode) }}</el-tag>
<span v-if="r.template_name" class="rec-tpl">{{ r.template_name }}</span>
</div>
<div class="rec-dim">{{ r.created_at }} · {{ r.user_name || '—' }}
<span v-if="r.width_mm">· {{ r.width_mm }}×{{ r.height_mm }}mm</span></div>
<div class="rec-ops">
<el-button size="small" text type="primary" @click="downloadRec(r)">下载</el-button>
<el-button size="small" text type="danger" @click="removeRec(r)">删除</el-button>
</div>
</div>
</div>
<el-empty v-if="!recs.length && !recLoading" description="暂无生成记录" />
<el-pagination v-if="recTotal > 12" small background layout="total, prev, pager, next"
:total="recTotal" :page-size="12" v-model:current-page="recPage" @current-change="loadRecs"
style="margin-top:12px; justify-content:center" />
</div>
</el-drawer>
</el-card>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { api } from '../api'
import { api, http } from '../api'
import AIRenderCanvas from '../components/AIRenderCanvas.vue'
import WindoorCanvas from '../components/WindoorCanvas.vue'
import { PHOTO_SCENES, type PhotoScene } from '../components/photoCompose'
@@ -327,7 +356,8 @@ async function generateCloud(mode: 'quick' | 'photo' | 'blueprint', sketch_b64?:
aiPrompt.value = d.prompt
aiFeatures.value = d.features?.summary ?? ''
progress.value = 100
ElMessage.success(mode === 'blueprint' ? 'AI 大样图已生成' : 'AI 炫图已生成')
ElMessage.success((mode === 'blueprint' ? 'AI 大样图已生成' : 'AI 炫图已生成') + ',已存入生成记录')
if (recDrawer.value) await loadRecs()
} catch { /* 拦截器已提示 */ } finally {
window.clearInterval(timer)
generating.value = false
@@ -483,6 +513,56 @@ async function generateBlueprint(regen = false) {
await generateCloud('blueprint', base.split(',')[1], !!sketchImg.value)
}
// ---- 生成记录(云端生成持久化) ----
const recDrawer = ref(false)
const recLoading = ref(false)
const recs = ref<any[]>([])
const recTotal = ref(0)
const recPage = ref(1)
const recUrls = ref<Record<number, string>>({})
function modeLabel(m: string) {
return ({ quick: '风格速览', photo: '照片合成', blueprint: '工程大样图' } as Record<string, string>)[m] ?? m
}
async function openRecs() {
recDrawer.value = true
await loadRecs()
}
async function loadRecs() {
recLoading.value = true
try {
const d = await api<any>('get', `/api/ai-render/records?page=${recPage.value}&page_size=12`)
recs.value = d.data ?? []
recTotal.value = d.total ?? 0
Object.values(recUrls.value).forEach(u => URL.revokeObjectURL(u))
recUrls.value = {}
await Promise.all(recs.value.map(async r => {
try {
const res = await http.get(r.url, { responseType: 'blob' })
recUrls.value[r.id] = URL.createObjectURL(res.data)
} catch { /* 单图失败不影响列表 */ }
}))
} finally { recLoading.value = false }
}
function downloadRec(r: any) {
const url = recUrls.value[r.id]
if (!url) return
const a = document.createElement('a')
a.href = url
a.download = `ai-${r.mode}-${r.id}.png`
a.click()
}
async function removeRec(r: any) {
await api('delete', `/api/ai-render/records/${r.id}`)
ElMessage.success('记录已删除')
delete recUrls.value[r.id]
await loadRecs()
}
/** 照片模式参考图: 全图压至 ≤1536px JPEG base64 */
function photoB64(): string {
const img = photo.value!
@@ -590,6 +670,13 @@ onMounted(async () => {
.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; }
.rec-item { display: flex; gap: 10px; padding: 10px 4px; border-bottom: 1px solid #f0f1f3; }
.rec-thumb { width: 110px; height: 78px; border-radius: 6px; flex-shrink: 0; cursor: zoom-in; background: #f5f6f8; }
.rec-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.rec-line { display: flex; gap: 6px; align-items: center; }
.rec-tpl { font-size: 13px; font-weight: 600; }
.rec-dim { font-size: 12px; color: #909399; }
.rec-ops { display: flex; gap: 4px; margin-top: auto; }
.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; }
+78 -2
View File
@@ -2,21 +2,29 @@
- quick 模式: /v1/images/generations 纯提示词生成
- photo 模式: /v1/images/edits 以用户照片为参考图,把窗型合成进照片
- blueprint 模式: /v1/images/edits 以手绘草图/2D 图纸为底图重绘工程大样图
- 生成结果持久化: 图片落盘 runtime/ai_renders/{company}/{id}.png + ai_render_records 元数据
"""
import base64
import json
import httpx
from fastapi import APIRouter
from fastapi.responses import FileResponse
from pathlib import Path
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from huamenchuang.core.config import settings
from huamenchuang.core.deps import CurrentUser, DbSession
from huamenchuang.core.response import fail, ok
from huamenchuang.core.response import fail, ok, paginated
from huamenchuang.models.extend import AiRenderRecord
from huamenchuang.services.ai_prompt import build_prompt, describe_window, pick_size
router = APIRouter(prefix="/ai-render", tags=["AI 炫图"])
RENDER_DIR = Path(__file__).resolve().parents[3] / "runtime" / "ai_renders"
class AiRenderBody(BaseModel):
template_id: int | None = None
@@ -182,7 +190,75 @@ async def generate(body: AiRenderBody, user: CurrentUser, db: DbSession):
await asyncio.sleep(stage_wait)
if not b64:
return fail(last_err or "生成失败")
return ok(data={"image_b64": b64, "prompt": prompt, "size": size,
# 持久化: 图片落盘 + 元数据入库(三模式统一记录)
from huamenchuang.models.extend import AiRenderRecord
rec = AiRenderRecord(company_id=user.company_id, user_id=user.id,
user_name=user.nickname, mode=body.mode,
template_id=body.template_id or 0, scene=body.scene,
prompt=prompt, width_mm=int(info["width_mm"] or 0),
height_mm=int(info["height_mm"] or 0))
if body.template_id:
from huamenchuang.models.windoor_template import WindoorTemplate
t = await db.get(WindoorTemplate, body.template_id)
if t:
rec.template_name = t.name or ""
db.add(rec)
await db.flush()
rec_dir = RENDER_DIR / str(user.company_id)
rec_dir.mkdir(parents=True, exist_ok=True)
(rec_dir / f"{rec.id}.png").write_bytes(base64.b64decode(b64))
rec.file_path = f"{user.company_id}/{rec.id}.png"
await db.flush()
return ok(data={"record_id": rec.id, "image_b64": b64, "prompt": prompt, "size": size,
"features": {"width_mm": info["width_mm"], "height_mm": info["height_mm"],
"summary": info["summary"]}},
message="生成成功")
# ---------------- 生成记录 ----------------
@router.get("/records")
async def list_records(user: CurrentUser, db: DbSession, page: int = 1, page_size: int = 12,
mode: str | None = None):
"""AI 生图记录列表(本公司,按时间倒序)。"""
conds = [AiRenderRecord.company_id == user.company_id]
if mode:
conds.append(AiRenderRecord.mode == mode)
where = *conds,
total = (await db.execute(select(func.count()).select_from(AiRenderRecord).where(*where))).scalar_one()
stmt = (select(AiRenderRecord).where(*where).order_by(AiRenderRecord.id.desc())
.offset((page - 1) * page_size).limit(page_size))
items = list((await db.execute(stmt)).scalars())
return ok(data=paginated([{
"id": r.id, "mode": r.mode, "template_id": r.template_id, "template_name": r.template_name,
"scene": r.scene, "width_mm": r.width_mm, "height_mm": r.height_mm,
"user_name": r.user_name, "created_at": r.created_at.strftime("%Y-%m-%d %H:%M") if r.created_at else "",
"url": f"/api/ai-render/records/{r.id}/image",
} for r in items], page, page_size, total))
@router.get("/records/{rid}/image")
async def record_image(rid: int, user: CurrentUser, db: DbSession):
"""记录原图(公司隔离)。"""
rec = await db.get(AiRenderRecord, rid)
if rec is None or rec.company_id != user.company_id:
return fail("记录不存在")
file = RENDER_DIR / rec.file_path
if not file.exists():
return fail("图片文件已丢失")
return FileResponse(file, media_type="image/png",
filename=f"ai-{rec.mode}-{rec.id}.png")
@router.delete("/records/{rid}")
async def delete_record(rid: int, user: CurrentUser, db: DbSession):
rec = await db.get(AiRenderRecord, rid)
if rec is None or rec.company_id != user.company_id:
return fail("记录不存在")
file = RENDER_DIR / rec.file_path
if file.exists():
file.unlink()
await db.delete(rec)
return ok(message="记录已删除")
+2
View File
@@ -21,6 +21,7 @@ from huamenchuang.models.contract import (
WarehouseItem,
)
from huamenchuang.models.extend import (
AiRenderRecord,
Article,
CompanyParam,
Device,
@@ -38,6 +39,7 @@ from huamenchuang.models.windoor_template import WindoorCatelog, WindoorTemplate
__all__ = [
"AfterSale",
"AiRenderRecord",
"Announcement",
"Article",
"CompanyParam",
+20
View File
@@ -162,3 +162,23 @@ class SmsCode(Base):
scene: Mapped[str] = mapped_column(String(16), default="login") # login/reset/bind
used: Mapped[int] = mapped_column(default=0)
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
class AiRenderRecord(Base):
"""AI 生图记录(quick/photo/blueprint 云端生成结果持久化,图片落盘 runtime/ai_renders/)。"""
__tablename__ = "ai_render_records"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
company_id: Mapped[int] = mapped_column(default=1, index=True)
user_id: Mapped[int] = mapped_column(default=0, index=True)
user_name: Mapped[str] = mapped_column(String(64), default="")
mode: Mapped[str] = mapped_column(String(16), default="quick") # quick/photo/blueprint
template_id: Mapped[int] = mapped_column(default=0)
template_name: Mapped[str] = mapped_column(String(64), default="")
scene: Mapped[str] = mapped_column(String(32), default="")
prompt: Mapped[str] = mapped_column(Text, default="")
file_path: Mapped[str] = mapped_column(String(255), default="") # 相对 runtime/ai_renders/
width_mm: Mapped[int] = mapped_column(default=0)
height_mm: Mapped[int] = mapped_column(default=0)
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)