diff --git a/CHANGELOG b/CHANGELOG index e573134..dbba4e2 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,7 @@ # CHANGELOG +- [2026-09-25 20:10] FEAT: v2.29 P1 批次1 设计域 5 项。①AI 推荐窗型: api/recommend.py 规则引擎(洞口宽高比/玻璃面积比/开扇数/门类/纱扇打分)+需求标签规则(通风/采光/隔音/安全/景观/通行)+可解释理由 TOP5, 样板库页"AI 推荐"Tab; ②通用分享体系: ShareItem 模型+POST/GET /api/shares(render/combine/quote/sunny 四类)+公开页 /share/s/:token(大图/报价明细/组合只读画布), 组合页/AI 炫图/报价单三处分享按钮; ③阳光房设计: SunroomDesign 模型+CRUD+SunroomCanvas 参数化绘图(flat 平顶/gable 人字顶 正视+俯视+尺寸标注)+AI 效果图(scene=sunroom-*, 提示词含 bays/footprint)+设计分享; ④量尺拍照标注: 照片上传+Canvas 拖拽画尺寸线(颜色/标签/撤销)+合成图存洞口行 photo; ⑤扇/玻璃逐格指定: SashAssign(decs 同型自研扩展)+画布"扇/玻璃"对话框(五类开启类型/内外开/铰链侧/副梃/玻璃配置)+2D 开向三角渲染+takeoff 计指定扇框料五金副梃(subbar 25 元/米); vue-tsc 0 错+构建通过, 推荐/阳光房/分享 API 实测通过 (Files: src/huamenchuang/models/extend.py, src/huamenchuang/api/shares.py, src/huamenchuang/api/recommend.py, src/huamenchuang/api/sunroom.py, src/huamenchuang/services/ai_prompt.py, src/huamenchuang/services/quote_service.py, frontend/src/views/ShareView.vue, frontend/src/views/SunnyHouseView.vue, frontend/src/views/TemplatesView.vue, frontend/src/views/MeasureView.vue, frontend/src/views/CombineView.vue, frontend/src/views/AIRenderView.vue, frontend/src/views/QuotationsView.vue, frontend/src/components/SunroomCanvas.vue, frontend/src/components/WindoorCanvas.vue, frontend/src/types/drawing.ts, frontend/src/router/index.ts, doc/API.md) + - [2026-09-25 18:30] FEAT: v2.28 AI 炫图接入 gpt-image-2.5 云端生图+提示词工程。①services/ai_prompt.py 结构化提示词构建器: 从 drawing_data 提取窗型特征(组合单元/宽高mm/格数/扇类型平开推拉内开内倒对开门/开向/纱扇/圆弧/格条)→专业英文提示词(主体先行/材质风格映射/摄影光照/负面约束"保持分格勿增删扇"/质量词)+按宽高比自动选尺寸(1024/1536x1024/1024x1536); ②api/ai_render.py 代理(Key 仅后端 .env HMC_AI_API_KEY, 已 gitignore): POST /ai-render/prompt 提示词预览 + /ai-render/generate(quick→generations, photo→edits 参考图合成, httpx 180s 超时, 错误分级); ③config .env 锚定项目根(修复 cwd 相对路径失效); ④前端 AIRenderView 双模式主按钮接云端(photo 模式参考图压缩≤1536px JPEG), 结果大图+提示词折叠面板+下载, 未配 Key 降级本地 4 方案; ⑤实测: quick 生图 28s 返回三格平开窗产品级渲染(把手/铰链/投影正确), photo 合成 55s 返回窗型融入照片; 端到端 puppeteer 验证通过(shots/verify-aicloud.png); vue-tsc 0 错+构建通过 (Files: pyproject.toml, src/huamenchuang/services/ai_prompt.py, src/huamenchuang/api/ai_render.py, src/huamenchuang/api/router.py, src/huamenchuang/core/config.py, frontend/src/views/AIRenderView.vue, frontend/src/api/index.ts, frontend/scripts/verify-aicloud.mjs, doc/API.md) - [2026-09-25 16:50] FEAT: v2.27 P0-8 照片式 AI 炫图(7 场景)。①photoCompose.ts 纯前端合成引擎: renderWindowOnly 透明背景纯窗型渲染(分割树重放/玻璃渐变/中梃投影/扇开向虚线三角/装饰格条, 复用 types/drawing 纯函数)+composePhoto 光照匹配(洞口周边亮度→窗户亮度 0.55~1.25)+7 场景后处理(渲图/安装合成投影/门窗擦除内容填充/室内暖色/别墅石基座/去围栏背景虚化/变毛坯去饱和); ②AIRenderView 升级双 Tab(照片合成/风格速览): 上传照片→拖框窗洞→选样板→生成 4 方案画廊+下载; 修复三处: variants 赋值后未等 Vue 挂载渲染空画布(nextFrames)、rect 舞台坐标与照片原始坐标双重缩放(统一原始坐标+CSS↔原始宽换算)、v-if canvas 未挂载 drawStage 静默失败致 photoScale 滞留 1(nextTick+无状态换算); ③端到端验证: 程序化测试照片→拖框→4 方案生成, 窗户准确贴入洞口+投影正确(shots/verify-photorender-*.png); vue-tsc 0 错+构建通过 (Files: frontend/src/components/photoCompose.ts, frontend/src/views/AIRenderView.vue, frontend/scripts/verify-photorender.mjs, doc/API.md) diff --git a/doc/API.md b/doc/API.md index 1688753..b4d5bb3 100644 --- a/doc/API.md +++ b/doc/API.md @@ -479,3 +479,16 @@ uv run uvicorn huamenchuang.main:app --reload # 启动,http://localhost:8000 - 未配置 Key 时接口返回 code=0,前端降级本地程序化渲染("本地 4 方案"按钮) - 前端:AI 炫图页两个模式(照片合成/风格速览)主按钮均走云端生成,结果大图展示 + 提示词折叠面板可查看 + PNG 下载;照片模式参考图自动压缩 ≤1536px JPEG + +### 12.24 P1 设计域扩展(v2.29) + +| 接口名称 | 方式 | URL 路径 | 请求参数 | 说明 | +|---|---|---|---|---| +| **AI 推荐窗型** | POST | `/api/ai-recommend` | `{width_mm, height_mm, room(living/bedroom/balcony/kitchen/bath/shop), needs[]: ventilation/light/sound/safe/view/door}` | 对齐 ai_recommend;规则引擎从样板库打分推荐 TOP5,含匹配分与可解释理由(宽高比/开扇数/玻璃面积比/门类/纱扇) | +| **创建分享** | POST | `/api/shares` | `{type: render\|combine\|quote\|sunny, title, payload}` | 对齐 share-render/group-detail-share/quote_link;返回 `{token, url:/share/s/{token}}` | +| 公开访问分享 | GET | `/api/shares/{token}` | 路径(无需登录) | 返回 `{type, title, payload, created_at}`;前端 `/share/s/:token` 公开页渲染(大图/报价明细/组合只读画布) | +| 阳光房设计列表 | GET | `/api/sunroom/designs` | — | | +| **保存阳光房设计** | POST | `/api/sunroom/designs` | `{name, roof: flat\|gable, width_mm, depth_mm, height_mm, bay_mm, glass, frame_color, order_id?}` | 对齐 sunnyhouse 平顶/人字顶;前端 SunroomCanvas 参数化 2D(正视+俯视),AI 效果图走 `/api/ai-render/generate`(scene=sunroom-flat/gable) | +| 删除阳光房设计 | DELETE | `/api/sunroom/designs/{id}` | 路径 | | + +**逐格扇/玻璃指定**(自研扩展 `drawing_data.sm[i].assigns[] = [{ri, sash:{kind: fixed/casement/awning/sliding/kfc, openToward, openDirection}, subBar, glass}]`):画布选格 → "扇/玻璃"对话框指定开启类型/方向/铰链侧/副梃/玻璃配置;2D 画开向三角与副梃线;算料计入指定扇框料+五金(`sash_count` 含 assigns)与副梃长度(`subbar` 默认 25 元/米,价格本可覆盖)。量尺拍照标注:洞口行 `photo` 字段存标注合成图(前端 Canvas 拖线标注)。 diff --git a/frontend/src/components/SunroomCanvas.vue b/frontend/src/components/SunroomCanvas.vue new file mode 100644 index 0000000..9208e9a --- /dev/null +++ b/frontend/src/components/SunroomCanvas.vue @@ -0,0 +1,142 @@ + + + + + diff --git a/frontend/src/components/WindoorCanvas.vue b/frontend/src/components/WindoorCanvas.vue index 0301154..765d53f 100644 --- a/frontend/src/components/WindoorCanvas.vue +++ b/frontend/src/components/WindoorCanvas.vue @@ -15,6 +15,7 @@ 格高 格条 + 扇/玻璃 + + + + + + + 固定玻璃 + 平开扇 + 上悬 + 推拉 + 内开内倒 + + + + + + 扇中梃(按长度计价) + + + + + + + + + + + @@ -77,7 +123,7 @@ import { onMounted, reactive, ref, watch } from 'vue' import { ElMessage } from 'element-plus' import { bboxOf, DEC_PRESETS, inferSashGlyphs, parseDrawing, regionPoints, replayShape, spAxis, spPos, - type DecBar, type DrawingData, type FrameShape, type Pt, type Segment, type SashShape, type SplitLine, + type DecBar, type DrawingData, type FrameShape, type Pt, type SashAssign, type Segment, type SashShape, type SplitLine, } from '../types/drawing' const props = defineProps<{ drawingData: string | object; editable?: boolean }>() @@ -436,6 +482,49 @@ function drawFrame( ctx.restore() } + // 1.6) 逐格指定(自研 assigns): 副梃竖线 + 开扇符号 + 玻璃配置角标 + for (const a of (shape.assigns ?? []) as SashAssign[]) { + const segs = rp.regions[a.ri] + if (!segs) continue + const apts = regionPoints(segs) + if (apts.length < 3) continue + const abb = bboxOf(apts) + const ax0 = tx(abb.x0), ay0 = ty(abb.y0), ax1 = tx(abb.x1), ay1 = ty(abb.y1) + const aw = ax1 - ax0, ah = ay1 - ay0 + if (aw < 14 || ah < 14) continue + const acx = (ax0 + ax1) / 2, acy = (ay0 + ay1) / 2 + if (a.subBar) { + ctx.strokeStyle = STYLE.profileDark + ctx.lineWidth = 2 + ctx.beginPath(); ctx.moveTo(acx, ay0 + 2); ctx.lineTo(acx, ay1 - 2); ctx.stroke() + } + if (a.sash) { + ctx.save() + ctx.setLineDash([7, 5]) + ctx.strokeStyle = 'rgba(40,60,90,0.75)' + ctx.lineWidth = 1.6 + ctx.beginPath() + if (a.sash.kind === 'sliding') { + ctx.moveTo(ax0 + 4, acy); ctx.lineTo(ax1 - 4, acy) + ctx.moveTo(ax1 - 10, acy - 4); ctx.lineTo(ax1 - 4, acy); ctx.lineTo(ax1 - 10, acy + 4) + } else if (a.sash.kind === 'awning') { + ctx.moveTo(ax0 + 4, ay1 - 4); ctx.lineTo(ax1 - 4, ay1 - 4); ctx.lineTo(acx, ay0 + 6) + } else { + const hingeLeft = a.sash.openDirection !== 'right' + if (hingeLeft) { ctx.moveTo(ax0 + 5, ay0 + 5); ctx.lineTo(ax0 + 5, ay1 - 5); ctx.lineTo(ax1 - 6, acy) } + else { ctx.moveTo(ax1 - 5, ay0 + 5); ctx.lineTo(ax1 - 5, ay1 - 5); ctx.lineTo(ax0 + 6, acy) } + } + ctx.closePath() + ctx.stroke() + ctx.restore() + } + if (a.glass) { + ctx.fillStyle = 'rgba(43,108,176,0.85)' + ctx.font = `${Math.max(9, Math.min(12, aw / 8))}px sans-serif` + ctx.fillText(a.glass, ax0 + 4, ay1 - 5) + } + } + // 2) 中梃(按目标区域裁剪, 型材三段线; 选中高亮) + 与外框交点白块(官方连接件符号) for (let spIdx = 0; spIdx < rp.clips.length; spIdx++) { const { sp, bb } = rp.clips[spIdx] @@ -919,6 +1008,54 @@ function removeDec() { emit('change') ElMessage.success('格条已清除') } + +// ---- 扇/玻璃逐格指定(P1-逐格编辑) ---- +const assignVisible = ref(false) +const hasAssign = ref(false) +const assignForm = reactive({ + ri: 0, sash: { kind: 'fixed', openToward: 'inward', openDirection: 'left' }, subBar: false, glass: '', +}) + +function openAssign() { + if (!selRegion.value || !dd) return + const { shapeIdx, ri } = selRegion.value + const existing = (dd.sm[shapeIdx]?.assigns ?? []).find(a => a.ri === ri) + hasAssign.value = !!existing + const blank: SashAssign = { ri, sash: { kind: 'fixed', openToward: 'inward', openDirection: 'left' }, subBar: false, glass: '' } + Object.assign(assignForm, JSON.parse(JSON.stringify(existing ?? blank)), { ri }) + assignVisible.value = true +} + +function applyAssign() { + if (!selRegion.value || !dd) return + const { shapeIdx } = selRegion.value + const shape = dd.sm[shapeIdx] + if (!shape) return + undoStack.push(JSON.stringify(dd)) + shape.assigns = shape.assigns ?? [] + const found = shape.assigns.find(a => a.ri === assignForm.ri) + const copy = JSON.parse(JSON.stringify(assignForm)) as SashAssign + if (found) Object.assign(found, copy) + else shape.assigns.push(copy) + assignVisible.value = false + render() + emit('change') + ElMessage.success('已指定该格') +} + +function removeAssign() { + if (!selRegion.value || !dd) return + const { shapeIdx, ri } = selRegion.value + const shape = dd.sm[shapeIdx] + if (!shape?.assigns) return + undoStack.push(JSON.stringify(dd)) + shape.assigns = shape.assigns.filter(a => a.ri !== ri) + hasAssign.value = false + assignVisible.value = false + render() + emit('change') + ElMessage.success('已清除该格指定') +} defineExpose({ exportDrawing }) // ============ 视图控制 ============ @@ -1088,4 +1225,5 @@ canvas:active { cursor: grabbing; } .dec-preset.active { border-color: #409eff; background: #ecf5ff; } .dec-thumb { width: 100%; height: 44px; border: 1px solid #dcdfe6; border-radius: 4px; overflow: hidden; } .dec-thumb-bar { width: 100%; height: 100%; } +.assign-tip { color: #909399; font-size: 12px; margin-left: 8px; } diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 5ed0233..ddbb429 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -38,6 +38,7 @@ const router = createRouter({ ], }, { path: '/share/order/:token', component: () => import('../views/ShareOrderView.vue') }, + { path: '/share/s/:token', component: () => import('../views/ShareView.vue') }, ], }) diff --git a/frontend/src/types/drawing.ts b/frontend/src/types/drawing.ts index 3ce770f..86c6206 100644 --- a/frontend/src/types/drawing.ts +++ b/frontend/src/types/drawing.ts @@ -92,6 +92,14 @@ export const DEC_PRESETS: { kind: string; label: string; rows: number; cols: num { kind: 'pastoral', label: '田园密格', rows: 4, cols: 4, bw: 14, color: '#ffffff' }, ] +/** 逐格指定(自研扩展:把某玻璃格指定为开扇/玻璃配置/副梃;ri = 重放区域索引) */ +export interface SashAssign { + ri: number + sash: { kind: 'fixed' | 'casement' | 'awning' | 'sliding' | 'kfc'; openToward?: 'inward' | 'outward'; openDirection?: 'left' | 'right' } + subBar?: boolean // 副梃(扇中梃) + glass?: string // 玻璃配置描述(如 中空 5+12A+5) +} + export interface FrameShape { type: 'Frame' | string polygon?: { @@ -110,6 +118,7 @@ export interface FrameShape { uid?: string titl?: string decs?: DecBar[] // 装饰格条(自研扩展) + assigns?: SashAssign[] // 逐格扇/玻璃指定(自研扩展) [k: string]: unknown } diff --git a/frontend/src/views/AIRenderView.vue b/frontend/src/views/AIRenderView.vue index 01eb9de..04a04c8 100644 --- a/frontend/src/views/AIRenderView.vue +++ b/frontend/src/views/AIRenderView.vue @@ -92,6 +92,7 @@
下载 PNG + 分享此图
@@ -281,6 +282,17 @@ function downloadAi() { a.click() } +async function shareAi() { + if (!aiImage.value) return + const d = await api('post', '/api/shares', { + type: 'render', title: 'AI 炫图', + payload: { image: aiImage.value, desc: `${templateId.value ? `样板#${templateId.value}` : ''} AI 效果图` }, + }) + const url = `${location.origin}${d.url}` + await navigator.clipboard?.writeText(url).catch(() => undefined) + ElMessage.success(`分享链接已复制: ${url}`) +} + // ---- 风格速览(本地程序化 4 方案, 未配置云端 Key 时的降级模式) ---- async function generateQuick() { generating.value = true diff --git a/frontend/src/views/CombineView.vue b/frontend/src/views/CombineView.vue index e93cb2b..eee691d 100644 --- a/frontend/src/views/CombineView.vue +++ b/frontend/src/views/CombineView.vue @@ -61,6 +61,7 @@ 另存为我的样板 + 分享 @@ -384,6 +385,25 @@ async function saveAsTemplate() { } finally { saving.value = false } } +// ---------- 分享组合(group-detail-share 语义:公开只读页) ---------- +async function shareCombine() { + const json = canvasRef.value?.exportDrawing() + if (!json) return + const dd = JSON.parse(json) + dd.sm = dd.sm.map((s: any) => { + const { __uid, __name, __templateId, ...rest } = s + void __uid; void __name; void __templateId + return rest + }) + const d = await api('post', '/api/shares', { + type: 'combine', title: saveName.value.trim() || '样板组合', + payload: { shapes: [dd] }, + }) + const url = `${location.origin}${d.url}` + await navigator.clipboard?.writeText(url).catch(() => undefined) + ElMessage.success(`分享链接已复制: ${url}`) +} + function onKeydown(e: KeyboardEvent) { if (e.key === 'Delete' && selectedUid.value !== null) { deleteSelected(); return } // 方向键微调选中单元(1mm,Shift=10mm) diff --git a/frontend/src/views/MeasureView.vue b/frontend/src/views/MeasureView.vue index 3eaa5b3..862a4a7 100644 --- a/frontend/src/views/MeasureView.vue +++ b/frontend/src/views/MeasureView.vue @@ -24,9 +24,15 @@ - + + + + @@ -53,6 +59,8 @@ + 已标注 + 标注 删 + 添加洞口 @@ -70,11 +78,32 @@ 保存 + + + +
+ + {{ annoPhoto ? '更换照片' : '上传/拍照' }} + + + + 撤销一线 + 清空 + 在照片上拖拽画尺寸线,文字取自左侧输入框 +
+
+ +
+ +
diff --git a/frontend/src/views/QuotationsView.vue b/frontend/src/views/QuotationsView.vue index c750524..08acfcf 100644 --- a/frontend/src/views/QuotationsView.vue +++ b/frontend/src/views/QuotationsView.vue @@ -21,10 +21,11 @@
- + @@ -174,6 +175,24 @@ async function detail(row: any) { detailVisible.value = true } +/** 报价分享链接(quote_link 语义:公开只读报价页,客户免登录查看) */ +async function shareQuote(row: any) { + const q = await api('get', `/api/quotations/${row.id}`) + const items = (q.items || []).map((i: any) => ({ + note: i.name || i.note || '-', length: i.length ? `${i.length}` : '-', qty: i.qty, + unit_price: i.unit_price, amount: i.amount, + })) + const d = await api('post', '/api/shares', { + type: 'quote', title: q.quote_no, + payload: { customer: q.customer_name, total: q.total, note: q.note, items }, + }) + const url = `${location.origin}${d.url}` + await navigator.clipboard?.writeText(url).catch(() => undefined) + ElMessageBox.alert( + `报价分享链接已复制:
${url}

客户打开后免登录查看报价明细。`, + '报价分享', { dangerouslyUseHTMLString: true }) +} + async function toOrder(row: any) { await ElMessageBox.confirm(`将报价单 ${row.quote_no} 转为订单?`, '提示') const q = await api('get', `/api/quotations/${row.id}`) diff --git a/frontend/src/views/ShareView.vue b/frontend/src/views/ShareView.vue new file mode 100644 index 0000000..dc332ee --- /dev/null +++ b/frontend/src/views/ShareView.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/frontend/src/views/SunnyHouseView.vue b/frontend/src/views/SunnyHouseView.vue index 3f28e9c..03aca0d 100644 --- a/frontend/src/views/SunnyHouseView.vue +++ b/frontend/src/views/SunnyHouseView.vue @@ -2,6 +2,8 @@
新建阳光房订单 +
+ 阳光房设计绘图
@@ -34,13 +36,73 @@ 创建 + + + + + + + + + 平顶 + 人字顶 + + + + + + + + + + + + + + + 保存设计 + AI 效果图 + 分享 + + + + +
+ +
+
+ + 下载效果图 +
+
+
+ + 已保存设计 + + + + + + + + + + + + +
diff --git a/frontend/src/views/TemplatesView.vue b/frontend/src/views/TemplatesView.vue index c4ceb79..ee2a8d5 100644 --- a/frontend/src/views/TemplatesView.vue +++ b/frontend/src/views/TemplatesView.vue @@ -2,18 +2,56 @@
- + 官方样板 我的样板 + AI 推荐 - - - 查询 -
- 共 {{ total }} 套{{ tab === 'mine' ? '我的' : '官方' }}样板 +
+ + +
+
+ 洞口 + + × + + mm + + + + + + + + + + + ✦ 智能推荐 +
+ +
+ +
+ {{ it.name || `样板#${it.id}` }} + 匹配 {{ it.score }} +
+ {{ r }} +
+
+
+
+ + @@ -58,7 +97,7 @@