feat: AI生成图持久化记录(AiRenderRecord模型+图片落盘+记录列表/图片/删除端点+前端生成记录抽屉, 三模式统一)
This commit is contained in:
@@ -25,3 +25,6 @@ data/dxf-study/
|
||||
.tmp_token
|
||||
.tmp_*/
|
||||
*.drawing-bundle.mjs
|
||||
|
||||
# AI 生成图(运行时产物)
|
||||
runtime/
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 分类:**渲图 / 安装合成 / 门窗擦除 / 室内装修 / 别墅 / 去围栏 / 变毛坯**。
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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="记录已删除")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user