feat: P1批次1 设计域(AI推荐/分享体系/阳光房绘图/量尺标注/逐格指定编辑)

This commit is contained in:
HHY
2026-09-25 06:17:29 -07:00
parent 6726a9fa3a
commit 76fd0ced14
21 changed files with 1154 additions and 20 deletions
+2
View File
@@ -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)
+13
View File
@@ -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 拖线标注)。
+142
View File
@@ -0,0 +1,142 @@
<template>
<canvas ref="cvRef" class="sunroom-cv" />
</template>
<script setup lang="ts">
/**
* 阳光房 2D 参数化绘图(自研):
* 上=正视图(flat 平顶横梁 / gable 人字顶山墙),下=俯视图(周长立柱 + 顶面玻璃分格)
*/
import { onMounted, ref, watch } from 'vue'
const props = withDefaults(defineProps<{
roof?: 'flat' | 'gable'
widthMm?: number
depthMm?: number
heightMm?: number
bayMm?: number
}>(), { roof: 'flat', widthMm: 4000, depthMm: 3000, heightMm: 2800, bayMm: 1000 })
const cvRef = ref<HTMLCanvasElement>()
function render() {
const cv = cvRef.value
if (!cv) return
const wrap = cv.parentElement
const W = Math.max(320, wrap?.clientWidth ?? 600)
const H = Math.max(300, wrap?.clientHeight ?? 420)
const dpr = window.devicePixelRatio || 1
cv.width = W * dpr
cv.height = H * dpr
cv.style.height = `${H}px`
const ctx = cv.getContext('2d')!
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, W, H)
ctx.lineJoin = 'round'
const w = props.widthMm, d = props.depthMm, h = props.heightMm, bay = props.bayMm
const posts = Math.max(2, Math.ceil(w / bay) + 1)
const bays = posts - 1
// ---- 正视图(上 55%)----
const fx0 = W * 0.12, fx1 = W * 0.88
const scale = (fx1 - fx0) / w
const fy0 = H * 0.34
const wallH = h * scale
ctx.strokeStyle = '#4d5257'
ctx.lineWidth = 4
if (props.roof === 'gable') {
// 人字顶: 两侧斜梁 + 屋脊
const ridgeY = fy0 - Math.min(H * 0.16, (d / 2) * scale)
ctx.beginPath()
ctx.moveTo(fx0 - 8, fy0)
ctx.lineTo((fx0 + fx1) / 2, ridgeY)
ctx.lineTo(fx1 + 8, fy0)
ctx.stroke()
// 山墙玻璃分格(沿斜梁)
ctx.lineWidth = 1.4
ctx.strokeStyle = '#8a9098'
for (let i = 1; i < bays; i++) {
const x = fx0 + (fx1 - fx0) * i / bays
ctx.beginPath()
ctx.moveTo(x, fy0)
ctx.lineTo((fx0 + fx1) / 2, ridgeY)
ctx.stroke()
}
} else {
// 平顶: 前檐横梁 + 悬挑
ctx.beginPath()
ctx.moveTo(fx0 - 12, fy0 - 6)
ctx.lineTo(fx1 + 12, fy0 - 6)
ctx.stroke()
}
// 立柱 + 竖玻璃分格
ctx.lineWidth = 6
ctx.strokeStyle = '#565b62'
for (let i = 0; i <= bays; i++) {
const x = fx0 + (fx1 - fx0) * i / bays
ctx.beginPath()
ctx.moveTo(x, fy0)
ctx.lineTo(x, fy0 + wallH)
ctx.stroke()
}
// 玻璃
ctx.fillStyle = 'rgba(158,201,216,0.35)'
ctx.fillRect(fx0 + 3, fy0 + 3, fx1 - fx0 - 6, wallH - 6)
// 地面
ctx.strokeStyle = '#c0b9a8'
ctx.lineWidth = 5
ctx.beginPath()
ctx.moveTo(fx0 - 26, fy0 + wallH + 4)
ctx.lineTo(fx1 + 26, fy0 + wallH + 4)
ctx.stroke()
// ---- 俯视图(下 40%)----
const px0 = W * 0.12, px1 = W * 0.88
const pscale = (px1 - px0) / w
const py0 = H * 0.62
const depPx = d * pscale
ctx.strokeStyle = '#4d5257'
ctx.lineWidth = 4
ctx.strokeRect(px0, py0, px1 - px0, depPx)
// 顶面玻璃分格(横向 bays × 纵向按进深 1m 分)
const rowsD = Math.max(1, Math.ceil(d / bay))
ctx.lineWidth = 1.4
ctx.strokeStyle = '#8a9098'
for (let i = 1; i < bays; i++) {
const x = px0 + (px1 - px0) * i / bays
ctx.beginPath(); ctx.moveTo(x, py0); ctx.lineTo(x, py0 + depPx); ctx.stroke()
}
for (let j = 1; j < rowsD; j++) {
const y = py0 + depPx * j / rowsD
ctx.beginPath(); ctx.moveTo(px0, y); ctx.lineTo(px1, y); ctx.stroke()
}
ctx.fillStyle = 'rgba(158,201,216,0.22)'
ctx.fillRect(px0 + 2, py0 + 2, px1 - px0 - 4, depPx - 4)
if (props.roof === 'gable') {
// 屋脊线(俯视)
ctx.strokeStyle = '#4d5257'
ctx.lineWidth = 3
ctx.beginPath()
ctx.moveTo(px0, py0 + depPx / 2)
ctx.lineTo(px1, py0 + depPx / 2)
ctx.stroke()
}
// 尺寸标注
ctx.fillStyle = '#2b6cb0'
ctx.font = '11px sans-serif'
ctx.fillText(`${w} mm`, (fx0 + fx1) / 2 - 24, fy0 + wallH + 22)
ctx.fillText(`${d} mm`, px1 + 8, py0 + depPx / 2)
ctx.fillText(`${h} mm`, fx0 - 44, fy0 + wallH / 2)
}
onMounted(() => {
watch(() => [props.roof, props.widthMm, props.depthMm, props.heightMm, props.bayMm], render, { immediate: true })
window.addEventListener('resize', render)
})
</script>
<style scoped>
.sunroom-cv { width: 100%; display: block; }
</style>
+139 -1
View File
@@ -15,6 +15,7 @@
<span class="dim-input">格高 <el-input-number v-model="regH" :min="100" :step="10" size="small"
controls-position="right" style="width:92px" @change="onRegionSize" /></span>
<el-button size="small" type="warning" plain @click="openDec">格条</el-button>
<el-button size="small" type="success" plain @click="openAssign">扇/玻璃</el-button>
</template>
<template v-else-if="selected">
<span class="dim-input">梃位 <el-input-number v-model="mulPos" :min="0" :step="10" size="small"
@@ -55,6 +56,51 @@
<el-button type="primary" @click="applyDec">应用</el-button>
</template>
</el-dialog>
<!-- 扇/玻璃逐格指定对话框 -->
<el-dialog v-model="assignVisible" title="扇 / 玻璃逐格指定" width="460px" append-to-body>
<el-form label-width="90px" size="small">
<el-form-item label="开启类型">
<el-radio-group v-model="assignForm.sash.kind">
<el-radio-button value="fixed">固定玻璃</el-radio-button>
<el-radio-button value="casement">平开扇</el-radio-button>
<el-radio-button value="awning">上悬</el-radio-button>
<el-radio-button value="sliding">推拉</el-radio-button>
<el-radio-button value="kfc">内开内倒</el-radio-button>
</el-radio-group>
</el-form-item>
<template v-if="assignForm.sash.kind !== 'fixed'">
<el-form-item label="开启方向">
<el-radio-group v-model="assignForm.sash.openToward">
<el-radio-button value="inward">内开</el-radio-button>
<el-radio-button value="outward">外开</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="铰链侧">
<el-radio-group v-model="assignForm.sash.openDirection" :disabled="assignForm.sash.kind === 'awning'">
<el-radio-button value="left">左侧</el-radio-button>
<el-radio-button value="right">右侧</el-radio-button>
</el-radio-group>
</el-form-item>
</template>
<el-form-item label="副梃">
<el-switch v-model="assignForm.subBar" />
<span class="assign-tip">扇中梃(按长度计价)</span>
</el-form-item>
<el-form-item label="玻璃配置">
<el-select v-model="assignForm.glass" style="width:200px" clearable>
<el-option value="单玻 5mm" /><el-option value="中空 5+12A+5" />
<el-option value="中空 5+18A+5" /><el-option value="三玻两腔 5+9A+5+9A+5" />
<el-option value="夹胶 5+0.76PVB+5" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button v-if="hasAssign" type="warning" plain @click="removeAssign">清除该格指定</el-button>
<el-button @click="assignVisible = false">取消</el-button>
<el-button type="primary" @click="applyAssign">应用</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -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<SashAssign>({
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; }
</style>
+1
View File
@@ -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') },
],
})
+9
View File
@@ -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
}
+12
View File
@@ -92,6 +92,7 @@
<img :src="aiImage" class="ai-img" />
<div class="ai-ops">
<el-button type="primary" size="small" plain @click="downloadAi">下载 PNG</el-button>
<el-button size="small" type="success" plain @click="shareAi">分享此图</el-button>
</div>
<el-collapse class="prompt-box">
<el-collapse-item title="查看优化后的提示词(由窗型数据结构化生成)">
@@ -281,6 +282,17 @@ function downloadAi() {
a.click()
}
async function shareAi() {
if (!aiImage.value) return
const d = await api<any>('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
+20
View File
@@ -61,6 +61,7 @@
<el-input v-model="saveName" placeholder="组合样板名称(如:三扇组合窗)" style="flex:1" />
<el-button type="primary" :disabled="!shapes.length || !saveName.trim()" :loading="saving"
@click="saveAsTemplate">另存为我的样板</el-button>
<el-button type="success" plain :disabled="!shapes.length" @click="shareCombine">分享</el-button>
</div>
</el-card>
</el-col>
@@ -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<any>('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)
+156 -2
View File
@@ -24,9 +24,15 @@
<el-table-column label="洞口数据" width="90">
<template #default="{ row }">{{ (row.data || []).length }} 项</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<el-table-column label="标注" width="80">
<template #default="{ row }">
<el-tag v-if="(row.data || []).some((d: any) => d.photo)" size="small" type="success" effect="plain">已标注</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="openEdit(row)">编辑</el-button>
<el-button size="small" type="warning" plain @click="annotateRow(row)">标注</el-button>
</template>
</el-table-column>
</el-table>
@@ -53,6 +59,8 @@
<el-input-number v-model="d.width" :min="0" placeholder="宽" controls-position="right" style="width:110px" />
<el-input-number v-model="d.height" :min="0" placeholder="高" controls-position="right" style="width:110px" />
<el-input-number v-model="d.qty" :min="1" placeholder="数量" controls-position="right" style="width:90px" />
<el-tag v-if="d.photo" size="small" type="success" effect="plain">已标注</el-tag>
<el-button size="small" text type="warning" @click="openAnno(Number(i))">标注</el-button>
<el-button text type="danger" @click="form.data.splice(i, 1)">删</el-button>
</div>
<el-button text type="primary" @click="form.data.push({ name: '', width: 0, height: 0, qty: 1 })">+ 添加洞口</el-button>
@@ -70,11 +78,32 @@
<el-button type="primary" @click="save">保存</el-button>
</template>
</el-dialog>
<!-- 拍照标注(对齐源站 measure_annotation:照片上画尺寸线) -->
<el-dialog v-model="annoVisible" :title="`拍照标注 — ${form.data[annoIdx]?.name || '洞口'}`" width="760px" top="4vh">
<div class="anno-bar">
<el-upload :show-file-list="false" accept="image/*" :http-request="onAnnoUpload">
<el-button size="small" type="primary" plain>{{ annoPhoto ? '更换照片' : '上传/拍照' }}</el-button>
</el-upload>
<el-input v-model="annoLabel" size="small" placeholder="标注文字(画线时使用)" style="width:180px" />
<el-color-picker v-model="annoColor" size="small" />
<el-button size="small" @click="undoAnno" :disabled="!annoLines.length">撤销一线</el-button>
<el-button size="small" type="danger" plain @click="annoLines = []">清空</el-button>
<span class="anno-tip">在照片上拖拽画尺寸线,文字取自左侧输入框</span>
</div>
<div class="anno-stage">
<canvas ref="annoCv" @mousedown="annoDown" @mousemove="annoMove" @mouseup="annoUp" @mouseleave="annoUp" />
</div>
<template #footer>
<el-button @click="annoVisible = false">取消</el-button>
<el-button type="primary" @click="saveAnno">完成标注</el-button>
</template>
</el-dialog>
</el-card>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { nextTick, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { api } from '../api'
@@ -126,9 +155,134 @@ async function save() {
}
onMounted(load)
// ---- 拍照标注(照片 + 拖拽画线, 存洞口行 photo 字段) ----
const annoVisible = ref(false)
const annoIdx = ref(0)
const annoCv = ref<HTMLCanvasElement>()
const annoLabel = ref('')
const annoColor = ref('#e6483d')
const annoPhoto = ref<HTMLImageElement | null>(null)
let annoLines: { x1: number; y1: number; x2: number; y2: number; label: string; color: string }[] = []
let annoDrag: { x1: number; y1: number; x2: number; y2: number } | null = null
function annotateRow(row: any) {
openEdit(row)
nextTick(() => openAnno(0))
}
function openAnno(i: number) {
if (!form.data?.length) return ElMessage.warning('请先添加洞口')
annoIdx.value = i
const existed = form.data[i]?.photo
annoPhoto.value = null
annoLines = []
if (existed) {
// 载入已有标注照片
const img = new Image()
img.onload = () => { annoPhoto.value = img; annoLines = existed_lines(img, existed) ; drawAnno() }
img.src = existed
}
annoVisible.value = true
nextTick(drawAnno)
}
function existed_lines(_img: HTMLImageElement, _data: string) {
return [] // 已标注图作为底图整体载入, 不再解析历史线段
}
async function onAnnoUpload(opt: any) {
const url = URL.createObjectURL(opt.file as File)
const img = new Image()
img.onload = () => { annoPhoto.value = img; annoLines = []; drawAnno(); URL.revokeObjectURL(url) }
img.src = url
}
function annoCtx() { return (annoCv.value as HTMLCanvasElement)?.getContext('2d') }
function drawAnno() {
const cv = annoCv.value as HTMLCanvasElement | undefined
if (!cv) return
const maxW = Math.min(700, cv.parentElement?.clientWidth ?? 700)
if (annoPhoto.value) {
const scale = Math.min(1, maxW / annoPhoto.value.naturalWidth)
cv.width = Math.round(annoPhoto.value.naturalWidth * scale)
cv.height = Math.round(annoPhoto.value.naturalHeight * scale)
} else {
cv.width = maxW; cv.height = 380
}
const ctx = cv.getContext('2d')!
ctx.clearRect(0, 0, cv.width, cv.height)
if (annoPhoto.value) ctx.drawImage(annoPhoto.value, 0, 0, cv.width, cv.height)
else {
ctx.fillStyle = '#f0f2f5'; ctx.fillRect(0, 0, cv.width, cv.height)
ctx.fillStyle = '#909399'; ctx.font = '14px sans-serif'; ctx.textAlign = 'center'
ctx.fillText('上传量尺照片后拖拽画标注线', cv.width / 2, cv.height / 2)
ctx.textAlign = 'left'
}
const all = [...annoLines, ...(annoDrag ? [{ ...annoDrag, label: '', color: annoColor.value }] : [])]
for (const l of all) {
ctx.strokeStyle = l.color
ctx.fillStyle = l.color
ctx.lineWidth = 2.5
ctx.beginPath(); ctx.moveTo(l.x1, l.y1); ctx.lineTo(l.x2, l.y2); ctx.stroke()
// 端点箭头
for (const [ex, ey] of [[l.x1, l.y1], [l.x2, l.y2]] as [number, number][]) {
ctx.beginPath(); ctx.arc(ex, ey, 3.5, 0, Math.PI * 2); ctx.fill()
}
if (l.label) {
ctx.font = 'bold 13px sans-serif'
const mx = (l.x1 + l.x2) / 2, my = (l.y1 + l.y2) / 2 - 6
const tw = ctx.measureText(l.label).width
ctx.fillStyle = 'rgba(255,255,255,0.85)'
ctx.fillRect(mx - tw / 2 - 3, my - 13, tw + 6, 17)
ctx.fillStyle = l.color
ctx.fillText(l.label, mx - tw / 2, my)
}
}
}
function annoPos(e: MouseEvent) {
const cv = annoCv.value!
const b = cv.getBoundingClientRect()
return { x: (e.clientX - b.left) * (cv.width / b.width), y: (e.clientY - b.top) * (cv.height / b.height) }
}
function annoDown(e: MouseEvent) {
const p = annoPos(e)
annoDrag = { x1: p.x, y1: p.y, x2: p.x, y2: p.y }
}
function annoMove(e: MouseEvent) {
if (!annoDrag) return
const p = annoPos(e)
annoDrag.x2 = p.x; annoDrag.y2 = p.y
drawAnno()
}
function annoUp() {
if (!annoDrag) return
const { x1, y1, x2, y2 } = annoDrag
annoDrag = null
if (Math.hypot(x2 - x1, y2 - y1) > 12) {
annoLines.push({ x1, y1, x2, y2, label: annoLabel.value, color: annoColor.value })
}
drawAnno()
}
function undoAnno() { annoLines.pop(); drawAnno() }
function saveAnno() {
const cv = annoCv.value as HTMLCanvasElement | undefined
if (!cv || !annoPhoto.value) return ElMessage.warning('请先上传照片')
if (form.data[annoIdx.value]) form.data[annoIdx.value].photo = cv.toDataURL('image/jpeg', 0.85)
annoVisible.value = false
ElMessage.success('标注完成, 记得保存量尺单')
}
</script>
<style scoped>
.toolbar { display: flex; gap: 10px; margin-bottom: 12px; }
.row { display: flex; gap: 8px; margin-bottom: 6px; align-items: center; }
.anno-bar { display: flex; gap: 8px; align-items: center; margin-bottom: 10px; flex-wrap: wrap; }
.anno-tip { color: #909399; font-size: 12px; }
.anno-stage { display: flex; justify-content: center; }
.anno-stage canvas { max-width: 100%; border: 1px solid #e4e7ed; border-radius: 6px; cursor: crosshair; background: #fff; }
</style>
+20 -1
View File
@@ -21,10 +21,11 @@
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="170" />
<el-table-column label="操作" width="170" fixed="right">
<el-table-column label="操作" width="230" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="detail(row)">详情</el-button>
<el-button size="small" type="success" plain @click="toOrder(row)">转订单</el-button>
<el-button size="small" type="warning" plain @click="shareQuote(row)">分享</el-button>
<el-button size="small" type="danger" plain @click="remove(row)">删</el-button>
</template>
</el-table-column>
@@ -174,6 +175,24 @@ async function detail(row: any) {
detailVisible.value = true
}
/** 报价分享链接(quote_link 语义:公开只读报价页,客户免登录查看) */
async function shareQuote(row: any) {
const q = await api<any>('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<any>('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(
`报价分享链接已复制:<br/><a href="${url}" target="_blank">${url}</a><br/><br/>客户打开后免登录查看报价明细。`,
'报价分享', { dangerouslyUseHTMLString: true })
}
async function toOrder(row: any) {
await ElMessageBox.confirm(`将报价单 ${row.quote_no} 转为订单?`, '提示')
const q = await api<any>('get', `/api/quotations/${row.id}`)
+80
View File
@@ -0,0 +1,80 @@
<template>
<div class="share-page">
<header class="share-head">
<span class="logo">华门窗</span>
<span class="title">{{ data?.title || '分享详情' }}</span>
</header>
<div v-if="loading" class="center"><el-icon class="is-loading"><Loading /></el-icon></div>
<el-empty v-else-if="!data" description="分享不存在或已失效" />
<template v-else>
<!-- 渲染图/阳光房效果: 大图展示 -->
<div v-if="data.type === 'render' || data.type === 'sunny'" class="img-wrap">
<img v-if="data.payload.image" :src="data.payload.image" />
<div v-if="data.payload.desc" class="desc">{{ data.payload.desc }}</div>
</div>
<!-- 报价单: 明细表 -->
<el-card v-else-if="data.type === 'quote'" class="quote-card">
<h2 class="q-title">报价单 — {{ data.payload.customer || '客户' }}</h2>
<el-table :data="data.payload.items || []" border size="small">
<el-table-column prop="note" label="项目" min-width="140" />
<el-table-column prop="length" label="规格(mm)" width="110" />
<el-table-column prop="qty" label="数量" width="80" />
<el-table-column prop="unit_price" label="单价" width="90" />
<el-table-column prop="amount" label="金额" width="110" />
</el-table>
<div class="q-total">合计:<b>¥{{ data.payload.total }}</b></div>
<div class="q-note" v-if="data.payload.note">{{ data.payload.note }}</div>
</el-card>
<!-- 样板组合: 只读画布列表 -->
<div v-else-if="data.type === 'combine'" class="combine-list">
<div v-for="(sh, i) in data.payload.shapes || []" :key="i" class="combine-item">
<WindoorCanvas :drawing-data="sh" />
<div class="combine-name">{{ sh.titl || `窗体 ${Number(i) + 1}` }}</div>
</div>
</div>
</template>
<footer class="share-foot">本页面由华门窗云平台生成 · {{ data?.created_at?.slice(0, 10) }}</footer>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { api } from '../api'
import WindoorCanvas from '../components/WindoorCanvas.vue'
const route = useRoute()
const data = ref<any>(null)
const loading = ref(true)
onMounted(async () => {
try {
data.value = await api<any>('get', `/api/shares/${route.params.token}`)
} catch { /* 无效分享 */ } finally { loading.value = false }
})
</script>
<style scoped>
.share-page { min-height: 100vh; background: #f2f4f7; }
.share-head { display: flex; align-items: baseline; gap: 14px; padding: 16px 22px; background: #fff;
border-bottom: 1px solid #e4e7ed; }
.logo { font-size: 20px; font-weight: 700; color: #2b3a4d; letter-spacing: 3px; }
.title { color: #606266; font-size: 14px; }
.center { display: flex; justify-content: center; padding: 80px 0; }
.img-wrap { max-width: 920px; margin: 24px auto; text-align: center; }
.img-wrap img { max-width: 100%; border-radius: 10px; box-shadow: 0 2px 14px rgba(0,0,0,.12); }
.desc { margin-top: 10px; color: #606266; font-size: 13px; }
.quote-card { max-width: 760px; margin: 24px auto; }
.q-title { margin: 0 0 12px; }
.q-total { text-align: right; margin-top: 12px; font-size: 16px; }
.q-total b { color: #e6483d; font-size: 20px; }
.q-note { margin-top: 6px; color: #909399; font-size: 12px; }
.combine-list { max-width: 920px; margin: 20px auto; display: flex; flex-direction: column; gap: 16px; }
.combine-item { background: #fff; border-radius: 10px; padding: 12px; }
.combine-item :deep(.canvas-wrap) { height: 320px; }
.combine-name { text-align: center; color: #606266; font-size: 13px; padding-top: 6px; }
.share-foot { text-align: center; color: #b0b6bf; font-size: 12px; padding: 30px 0 20px; }
</style>
+134 -1
View File
@@ -2,6 +2,8 @@
<el-card>
<div class="toolbar">
<el-button type="primary" plain @click="openCreate">新建阳光房订单</el-button>
<div style="flex:1" />
<el-button type="warning" plain @click="openDesign">阳光房设计绘图</el-button>
</div>
<el-table :data="rows" v-loading="loading" stripe>
<el-table-column prop="order_no" label="订单号" width="160" />
@@ -34,13 +36,73 @@
<el-button type="primary" @click="save">创建</el-button>
</template>
</el-dialog>
<!-- 阳光房设计绘图(平顶/人字顶参数化 + AI 效果图) -->
<el-dialog v-model="designVisible" title="阳光房设计绘图" width="860px" top="4vh">
<el-row :gutter="16">
<el-col :span="10">
<el-form label-width="90px" size="small">
<el-form-item label="顶型">
<el-radio-group v-model="dform.roof">
<el-radio-button value="flat">平顶</el-radio-button>
<el-radio-button value="gable">人字顶</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="名称"><el-input v-model="dform.name" placeholder="如 北院阳光房" /></el-form-item>
<el-form-item label="宽度 mm"><el-input-number v-model="dform.width_mm" :min="1500" :max="20000" :step="100" /></el-form-item>
<el-form-item label="进深 mm"><el-input-number v-model="dform.depth_mm" :min="1500" :max="20000" :step="100" /></el-form-item>
<el-form-item label="檐高 mm"><el-input-number v-model="dform.height_mm" :min="2000" :max="6000" :step="50" /></el-form-item>
<el-form-item label="立柱间距"><el-input-number v-model="dform.bay_mm" :min="400" :max="3000" :step="50" /></el-form-item>
<el-form-item label="玻璃"><el-input v-model="dform.glass" /></el-form-item>
<el-form-item label="型材色">
<el-select v-model="dform.frame_color" style="width:140px">
<el-option value="深灰" /><el-option value="咖啡木纹" /><el-option value="白色" /><el-option value="黑色" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveDesign">保存设计</el-button>
<el-button type="success" plain :loading="rendering" :disabled="!lastSaved" @click="aiRender">AI 效果图</el-button>
<el-button plain :disabled="!lastSaved" @click="shareDesign">分享</el-button>
</el-form-item>
</el-form>
</el-col>
<el-col :span="14">
<div class="sun-stage">
<SunroomCanvas :roof="dform.roof" :width-mm="dform.width_mm" :depth-mm="dform.depth_mm"
:height-mm="dform.height_mm" :bay-mm="dform.bay_mm" />
</div>
<div v-if="aiImage" class="ai-wrap">
<img :src="aiImage" />
<el-button size="small" text type="primary" @click="downloadAi">下载效果图</el-button>
</div>
</el-col>
</el-row>
<el-divider content-position="left">已保存设计</el-divider>
<el-table :data="designs" size="small" border max-height="200">
<el-table-column prop="name" label="名称" min-width="110" />
<el-table-column label="顶型" width="80">
<template #default="{ row }">{{ row.roof === 'gable' ? '人字顶' : '平顶' }}</template>
</el-table-column>
<el-table-column label="尺寸" min-width="150">
<template #default="{ row }">{{ row.width_mm }} × {{ row.depth_mm }} × {{ row.height_mm }} mm</template>
</el-table-column>
<el-table-column label="操作" width="130">
<template #default="{ row }">
<el-button size="small" text type="primary" @click="loadDesign(row)">载入</el-button>
<el-button size="small" text type="danger" @click="removeDesign(row)">删</el-button>
</template>
</el-table-column>
</el-table>
</el-dialog>
</el-card>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { api } from '../api'
import { api, apiDownload } from '../api'
import SunroomCanvas from '../components/SunroomCanvas.vue'
const rows = ref<any[]>([])
const total = ref(0)
@@ -51,6 +113,13 @@ const createVisible = ref(false)
const customerOptions = ref<any[]>([])
const form = reactive<any>({ customer_id: undefined, money: 0, area: 0, note: '' })
const designVisible = ref(false)
const designs = ref<any[]>([])
const lastSaved = ref<any>(null)
const rendering = ref(false)
const aiImage = ref('')
const dform = reactive<any>({ roof: 'flat', name: '', width_mm: 4000, depth_mm: 3000, height_mm: 2800, bay_mm: 1000, glass: '双层中空钢化玻璃 5+12A+5', frame_color: '深灰' })
async function load() {
loading.value = true
try {
@@ -77,9 +146,73 @@ async function save() {
load()
}
// ---- 阳光房设计 ----
async function openDesign() {
designVisible.value = true
designs.value = (await api<any[]>('get', '/api/sunroom/designs')) ?? []
}
function loadDesign(row: any) {
Object.assign(dform, {
roof: row.roof, name: row.name, width_mm: row.width_mm, depth_mm: row.depth_mm,
height_mm: row.height_mm, bay_mm: row.bay_mm,
glass: row.params?.glass ?? dform.glass, frame_color: row.params?.frame_color ?? '深灰',
})
lastSaved.value = row
aiImage.value = ''
}
async function saveDesign() {
lastSaved.value = await api<any>('post', '/api/sunroom/designs', dform)
ElMessage.success('设计已保存')
designs.value = (await api<any[]>('get', '/api/sunroom/designs')) ?? []
}
async function aiRender() {
rendering.value = true
aiImage.value = ''
try {
const d = await api<any>('post', '/api/ai-render/generate', {
template_id: null, mode: 'quick',
style: 'modern', time: 'day',
scene: dform.roof === 'gable' ? 'sunroom-gable' : 'sunroom-flat',
drawing_data: { sm: [], _sunroom: { width_mm: dform.width_mm, depth_mm: dform.depth_mm, height_mm: dform.height_mm, bay_mm: dform.bay_mm } },
}, { timeout: 200000 })
aiImage.value = `data:image/png;base64,${d.image_b64}`
ElMessage.success('AI 效果图已生成')
} catch { /* 拦截器已提示 */ } finally { rendering.value = false }
}
function downloadAi() {
if (!aiImage.value) return
const a = document.createElement('a')
a.href = aiImage.value
a.download = `阳光房效果图_${dform.name || '设计'}.png`
a.click()
}
async function shareDesign() {
const d = await api<any>('post', '/api/shares', {
type: 'sunny', title: dform.name || '阳光房设计',
payload: { image: aiImage.value || '', desc: `${dform.roof === 'gable' ? '人字顶' : '平顶'} · ${dform.width_mm}×${dform.depth_mm}×${dform.height_mm}mm · ${dform.glass}` },
})
const url = `${location.origin}${d.url}`
await navigator.clipboard?.writeText(url).catch(() => undefined)
ElMessage.success(`分享链接已复制: ${url}`)
}
async function removeDesign(row: any) {
await api('delete', `/api/sunroom/designs/${row.id}`)
ElMessage.success('已删除')
designs.value = (await api<any[]>('get', '/api/sunroom/designs')) ?? []
}
onMounted(load)
</script>
<style scoped>
.toolbar { display: flex; gap: 10px; margin-bottom: 12px; }
.sun-stage { border: 1px solid #e4e7ed; border-radius: 8px; padding: 8px; height: 360px; overflow: hidden; }
.ai-wrap { margin-top: 10px; text-align: center; }
.ai-wrap img { max-width: 100%; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,.12); }
</style>
+68 -3
View File
@@ -2,10 +2,12 @@
<div>
<el-card>
<div class="toolbar">
<el-radio-group v-model="tab" size="small" @change="load">
<el-radio-group v-model="tab" size="small" @change="tabChange">
<el-radio-button value="official">官方样板</el-radio-button>
<el-radio-button value="mine">我的样板</el-radio-button>
<el-radio-button value="ai">AI 推荐</el-radio-button>
</el-radio-group>
<template v-if="tab !== 'ai'">
<el-tree-select v-model="catelogId" :data="catelogs" node-key="id"
:props="{ label: 'name', children: 'children' }" check-strictly default-expand-all
clearable style="width:170px" placeholder="全部分类" @change="load" />
@@ -13,7 +15,43 @@
<el-button type="primary" @click="load">查询</el-button>
<div style="flex:1" />
<span class="count">共 {{ total }} 套{{ tab === 'mine' ? '我的' : '官方' }}样板</span>
</template>
</div>
<!-- AI 智能推荐(ai_recommend 语义:洞口+场景+需求 → 可解释推荐) -->
<div v-if="tab === 'ai'" v-loading="recLoading" class="rec-panel">
<div class="rec-form">
<span>洞口</span>
<el-input-number v-model="rec.width_mm" :min="300" :max="20000" :step="100" controls-position="right" style="width:120px" />
<span>×</span>
<el-input-number v-model="rec.height_mm" :min="300" :max="20000" :step="100" controls-position="right" style="width:120px" />
<span>mm</span>
<el-select v-model="rec.room" style="width:110px">
<el-option value="living" label="客厅" /><el-option value="bedroom" label="卧室" />
<el-option value="balcony" label="阳台" /><el-option value="kitchen" label="厨房" />
<el-option value="bath" label="卫浴" /><el-option value="shop" label="商铺" />
</el-select>
<el-select v-model="rec.needs" multiple collapse-tags style="min-width:200px" placeholder="需求标签">
<el-option value="ventilation" label="通风" /><el-option value="light" label="采光" />
<el-option value="sound" label="隔音" /><el-option value="safe" label="安全防蚊" />
<el-option value="view" label="景观" /><el-option value="door" label="通行" />
</el-select>
<el-button type="primary" :loading="recLoading" @click="doRecommend">✦ 智能推荐</el-button>
</div>
<el-empty v-if="!recItems.length && !recLoading" description="输入洞口尺寸与需求,AI 从 254 套样板中推荐最匹配窗型" />
<div v-for="it in recItems" :key="it.id" class="rec-item" @click="preview(it)">
<el-image :src="it.pic_url" fit="contain" class="rec-pic" />
<div class="rec-info">
<b>{{ it.name || `样板#${it.id}` }}</b>
<el-tag size="small" type="success" effect="plain">匹配 {{ it.score }}</el-tag>
<div class="rec-reasons">
<el-tag v-for="r in it.reasons" :key="r" size="small" effect="plain">{{ r }}</el-tag>
</div>
</div>
</div>
</div>
<template v-else>
<div v-loading="loading" class="grid">
<div v-for="t in rows" :key="t.id" class="item" @click="preview(t)">
<el-badge :value="t.times ? `${t.times}次` : ''" :hidden="!t.times" class="bdg">
@@ -26,6 +64,7 @@
</div>
<el-pagination style="margin-top:12px; justify-content:center" background layout="total, prev, pager, next"
:total="total" :page-size="page_size" v-model:current-page="page" @current-change="load" />
</template>
</el-card>
<el-dialog v-model="previewVisible" :title="current?.name || `样板#${current?.id}`" width="72%" top="4vh">
@@ -58,7 +97,7 @@
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { api } from '../api'
@@ -81,9 +120,27 @@ const editing = ref(false)
const dirty = ref(false)
const editRef = ref()
const liveTotal = ref<number | null>(null)
const tab = ref<'official' | 'mine'>('official')
const tab = ref<'official' | 'mine' | 'ai'>('official')
let quoteTimer: number | undefined
// ---- AI 智能推荐 ----
const recLoading = ref(false)
const recItems = ref<any[]>([])
const rec = reactive({ width_mm: 2400, height_mm: 1500, room: 'living', needs: [] as string[] })
function tabChange() {
if (tab.value !== 'ai') load()
}
async function doRecommend() {
recLoading.value = true
try {
const d = await api<any>('post', '/api/ai-recommend', rec)
recItems.value = d.items
if (!d.items?.length) ElMessage.info('未找到匹配样板')
} finally { recLoading.value = false }
}
// 画布变化(改尺寸/加分格)→ 防抖自动重算报价
function onCanvasChange() {
dirty.value = true
@@ -162,4 +219,12 @@ onMounted(async () => {
.preview .meta { display: flex; gap: 20px; margin-top: 10px; color: #606266; font-size: 13px; align-items: center; flex-wrap: wrap; }
.tip { color: #909399; }
.live-price { color: #e6483d; font-size: 16px; font-weight: 700; }
.rec-panel { min-height: 300px; }
.rec-form { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 16px; }
.rec-item { display: flex; gap: 14px; align-items: center; border: 1px solid #e4e7ed; border-radius: 10px;
padding: 10px 14px; margin-bottom: 10px; cursor: pointer; background: #fff; transition: box-shadow .2s; }
.rec-item:hover { box-shadow: 0 2px 12px rgba(0,0,0,.12); }
.rec-pic { width: 130px; height: 90px; flex: none; }
.rec-info { display: flex; flex-direction: column; gap: 6px; align-items: flex-start; }
.rec-reasons { display: flex; gap: 6px; flex-wrap: wrap; }
</style>
+159
View File
@@ -0,0 +1,159 @@
"""AI 推荐窗型路由(对齐目标站 ai_recommend:按需求推荐样板,规则引擎确定性打分)。"""
import json
from fastapi import APIRouter
from pydantic import BaseModel, Field
from sqlalchemy import select
from huamenchuang.core.deps import CurrentUser, DbSession
from huamenchuang.core.response import fail, ok
from huamenchuang.core.drawing import parse_drawing, replay_shape
from huamenchuang.models.windoor_template import WindoorTemplate
router = APIRouter(prefix="/ai-recommend", tags=["AI 推荐"])
class RecommendBody(BaseModel):
width_mm: int = Field(ge=300, le=20000, description="洞口宽度 mm")
height_mm: int = Field(ge=300, le=20000, description="洞口高度 mm")
room: str = Field(default="living", pattern="^(living|bedroom|balcony|kitchen|bath|shop)$",
description="房间: 客厅/卧室/阳台/厨房/卫浴/商铺")
needs: list[str] = Field(default_factory=list,
description="需求标签: ventilation通风/light采光/sound隔音/safe安全/view景观/door通行")
# 需求标签 → 加分特征
_TAG_RULES = {
"ventilation": ("开扇", 3.0, "需要通风,优先带开启扇"),
"light": ("玻璃面积", 2.0, "需要采光,优先大玻璃分格"),
"sound": ("隔音", 2.0, "需要隔音,优先少开扇+固定大格"),
"safe": ("安全", 1.5, "需要安全,优先内开内倒/纱扇"),
"view": ("景观", 1.5, "需要景观面,优先大板玻璃"),
"door": ("通行", 3.0, "需要通行,优先门/推拉类"),
}
_ROOM_HINT = {
"living": ("客厅", ["view", "light", "ventilation"]),
"bedroom": ("卧室", ["ventilation", "sound", "safe"]),
"balcony": ("阳台", ["door", "ventilation", "light"]),
"kitchen": ("厨房", ["ventilation", "light"]),
"bath": ("卫浴", ["ventilation", "safe"]),
"shop": ("商铺", ["door", "view", "light"]),
}
def _template_features(dd: dict) -> dict:
"""提取样板特征: 宽高比/玻璃面积比/开扇数/是否门类/是否有纱扇。"""
try:
parsed = parse_drawing(dd)
except Exception:
return {}
xs, ys, glass_area = [], [], 0.0
sashes = 0
is_door = False
has_screen = False
for shape in parsed.get("sm", []):
if not isinstance(shape, dict):
continue
regions = replay_shape(shape)
pts = [[(seg["ps"]["x"], seg["ps"]["y"]) for seg in r] for r in regions]
if not pts:
continue
flat = [p for r in pts for p in r]
xs += [min(p[0] for p in flat), max(p[0] for p in flat)]
ys += [min(p[1] for p in flat), max(p[1] for p in flat)]
for r in pts:
a = 0.0
for i in range(len(r)):
x1, y1 = r[i]
x2, y2 = r[(i + 1) % len(r)]
a += x1 * y2 - x2 * y1
glass_area += abs(a) / 2
for sub in (shape.get("sm") or []):
if not isinstance(sub, dict):
continue
t = str(sub.get("type", ""))
if "Screen" in t:
has_screen = True
continue
if "Slide" in t:
sashes += 2
elif sub.get("bd") and not (sub.get("bd") or {}).get("hidden"):
sashes += 1
if sub.get("isDoor"):
is_door = True
if not xs:
return {}
w, h = max(xs) - min(xs), max(ys) - min(ys)
frame_area = w * h
return {"ratio": w / h if h else 1, "glass_ratio": glass_area / frame_area if frame_area else 0,
"sashes": sashes, "is_door": is_door, "has_screen": has_screen}
@router.post("")
async def recommend(body: RecommendBody, user: CurrentUser, db: DbSession):
"""按洞口尺寸+房间+需求标签推荐样板 TOP5(规则打分,可解释理由)。"""
stmt = select(WindoorTemplate).where(WindoorTemplate.is_default == 1)
templates = list((await db.execute(stmt)).scalars())
if not templates:
return fail("样板库为空")
room_name, default_tags = _ROOM_HINT.get(body.room, ("客厅", ["light"]))
tags = list(dict.fromkeys((body.needs or []) + default_tags))
target_ratio = body.width_mm / body.height_mm
scored = []
for t in templates[:80]: # 打分上限控制计算量(前 80 套按 id 序,已覆盖六大类)
if not t.drawing_data:
continue
try:
dd = t.drawing_data if isinstance(t.drawing_data, dict) else json.loads(t.drawing_data)
except Exception:
continue
f = _template_features(dd)
if not f:
continue
score, reasons = 0.0, []
# 尺寸适配: 宽高比接近度(0~4 分) + 洞口在模板可调范围
r_diff = abs(f["ratio"] - target_ratio) / max(target_ratio, 0.01)
score += max(0.0, 4.0 - r_diff * 4.0)
if r_diff < 0.15:
reasons.append("宽高比例与洞口匹配")
# 标签规则
if "ventilation" in tags:
v = min(f["sashes"], 3) * 1.2
score += v
if v >= 1.2:
reasons.append(f"带 {f['sashes']} 个开启扇,通风好")
if "light" in tags:
v = f["glass_ratio"] * 4.0
score += v
if f["glass_ratio"] > 0.6:
reasons.append("大玻璃面积,采光佳")
if "sound" in tags:
if f["sashes"] <= 1:
score += 2.0
reasons.append("固定大格为主,隔音好")
if "safe" in tags:
if f["has_screen"]:
score += 1.5
reasons.append("带纱扇防蚊安全")
if "view" in tags:
if f["glass_ratio"] > 0.65 and f["sashes"] <= 2:
score += 2.0
reasons.append("大板玻璃,景观视野好")
if "door" in tags:
if f["is_door"] or f["ratio"] >= 1.2:
score += 3.0
if f["is_door"]:
reasons.append("门类窗型,可通行")
if not reasons:
reasons.append("综合分格均衡")
scored.append((score, t, reasons[:3]))
scored.sort(key=lambda x: -x[0])
items = [{
"id": t.id, "name": t.name, "pic_url": t.pic_url, "catelog_id": t.catelog_id,
"score": round(s, 2), "reasons": rs,
} for s, t, rs in scored[:5]]
return ok(data={"items": items, "room": room_name, "tags": tags,
"summary": f"按{room_name}场景({'、'.join(tags)})从 {len(scored)} 套样板中推荐"},
message="推荐完成")
+6
View File
@@ -13,8 +13,11 @@ from huamenchuang.api import (
orders,
platform,
quote,
recommend,
reports,
script,
shares,
sunroom,
warehouse,
windoor,
)
@@ -31,6 +34,9 @@ api_router.include_router(script.router)
api_router.include_router(order_format.router)
api_router.include_router(finance.router)
api_router.include_router(ai_render.router)
api_router.include_router(shares.router)
api_router.include_router(recommend.router)
api_router.include_router(sunroom.router)
api_router.include_router(warehouse.router)
api_router.include_router(business.router)
api_router.include_router(platform.router)
+44
View File
@@ -0,0 +1,44 @@
"""通用分享路由:渲染图/样板组合/报价单/阳光房设计分享(公开访问,HMAC token)。"""
import json
import secrets
from fastapi import APIRouter
from pydantic import BaseModel, Field
from sqlalchemy import select
from huamenchuang.core.deps import CurrentUser, DbSession
from huamenchuang.core.response import fail, ok
from huamenchuang.models.extend import ShareItem
router = APIRouter(tags=["分享"])
SHARE_TYPES = ("render", "combine", "quote", "sunny")
class ShareBody(BaseModel):
type: str = Field(pattern="^(render|combine|quote|sunny)$")
title: str = Field(default="", max_length=128)
payload: dict = Field(default_factory=dict, description="render:{image}, combine:{shapes[]}, quote:{items[],total,customer}, sunny:{image,params}")
@router.post("/shares")
async def create_share(body: ShareBody, user: CurrentUser, db: DbSession):
"""生成分享链接(对齐 contract_share / share-render 语义)。"""
item = ShareItem(
company_id=user.company_id, token=secrets.token_urlsafe(24),
type=body.type, title=body.title,
payload=json.dumps(body.payload, ensure_ascii=False), user_id=user.id)
db.add(item)
await db.flush()
return ok(data={"token": item.token, "url": f"/share/s/{item.token}"}, message="分享链接已生成")
@router.get("/shares/{token}")
async def get_share(token: str, db: DbSession):
"""公开访问分享内容(无需登录,对齐 group-detail-share/:id/:user_id)。"""
item = (await db.execute(select(ShareItem).where(ShareItem.token == token))).scalar_one_or_none()
if item is None:
return fail("分享不存在或已失效")
return ok(data={"type": item.type, "title": item.title,
"payload": json.loads(item.payload or "{}"),
"created_at": str(item.created_at)[:19]})
+60
View File
@@ -0,0 +1,60 @@
"""阳光房设计路由(对齐目标站 sunnyhouse:平顶/人字顶参数化设计,出 2D 图 + AI 效果图)。"""
import json
from fastapi import APIRouter
from pydantic import BaseModel, Field
from sqlalchemy import select
from huamenchuang.core.deps import CurrentUser, DbSession
from huamenchuang.core.response import fail, ok
from huamenchuang.models.extend import SunroomDesign
router = APIRouter(prefix="/sunroom", tags=["阳光房"])
class SunroomBody(BaseModel):
order_id: int | None = None
name: str = Field(default="", max_length=64)
roof: str = Field(default="flat", pattern="^(flat|gable)$")
width_mm: int = Field(default=4000, ge=1500, le=20000)
depth_mm: int = Field(default=3000, ge=1500, le=20000)
height_mm: int = Field(default=2800, ge=2000, le=6000)
bay_mm: int = Field(default=1000, ge=400, le=3000)
glass: str = Field(default="双层中空钢化玻璃 5+12A+5", max_length=64)
frame_color: str = Field(default="深灰", max_length=16)
def _to_out(d: SunroomDesign) -> dict:
return {"id": d.id, "order_id": d.order_id, "name": d.name, "roof": d.roof,
"width_mm": d.width_mm, "depth_mm": d.depth_mm, "height_mm": d.height_mm,
"bay_mm": d.bay_mm, "params": json.loads(d.params or "{}"),
"created_at": str(d.created_at)[:19]}
@router.get("/designs")
async def list_designs(user: CurrentUser, db: DbSession):
rows = list((await db.execute(select(SunroomDesign).where(
SunroomDesign.company_id == user.company_id).order_by(SunroomDesign.id.desc()))).scalars())
return ok(data=[_to_out(r) for r in rows])
@router.post("/designs")
async def save_design(body: SunroomBody, user: CurrentUser, db: DbSession):
d = SunroomDesign(company_id=user.company_id, order_id=body.order_id, name=body.name,
roof=body.roof, width_mm=body.width_mm, depth_mm=body.depth_mm,
height_mm=body.height_mm, bay_mm=body.bay_mm,
params=json.dumps({"glass": body.glass, "frame_color": body.frame_color},
ensure_ascii=False),
user_id=user.id)
db.add(d)
await db.flush()
return ok(data=_to_out(d), message="设计已保存")
@router.delete("/designs/{design_id}")
async def delete_design(design_id: int, user: CurrentUser, db: DbSession):
d = await db.get(SunroomDesign, design_id)
if d is None or d.company_id != user.company_id:
return fail("设计不存在")
await db.delete(d)
return ok(message="已删除")
+4
View File
@@ -22,7 +22,9 @@ from huamenchuang.models.extend import (
Device,
OrderFormat,
PayOrder,
ShareItem,
SmsCode,
SunroomDesign,
)
from huamenchuang.models.order import DEFAULT_PROCESSES, Order, OrderLog, OrderProcess
from huamenchuang.models.user import User
@@ -49,7 +51,9 @@ __all__ = [
"ProductCatelog",
"Quotation",
"ReceiptAccount",
"ShareItem",
"SmsCode",
"SunroomDesign",
"StockMove",
"User",
"WarehouseItem",
+35
View File
@@ -84,6 +84,41 @@ class OrderFormat(Base):
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
class ShareItem(Base):
"""通用分享项(对齐目标站 share-render/group-detail-share/报价链接:HMAC token 公开访问)。"""
__tablename__ = "share_items"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
company_id: Mapped[int] = mapped_column(default=1, index=True)
token: Mapped[str] = mapped_column(String(48), unique=True, index=True)
# render=渲染图 combine=样板组合 quote=报价单 sunny=阳光房设计
type: Mapped[str] = mapped_column(String(16), index=True)
title: Mapped[str] = mapped_column(String(128), default="")
payload: Mapped[str] = mapped_column(Text, default="{}") # 类型化 JSON(图片 dataURL / 组合图纸 / 报价明细)
user_id: Mapped[int] = mapped_column(default=0)
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
class SunroomDesign(Base):
"""阳光房设计(对齐目标站 sunnyhouse 模块:平顶/人字顶参数化 + 效果图)。"""
__tablename__ = "sunroom_designs"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
company_id: Mapped[int] = mapped_column(default=1, index=True)
order_id: Mapped[int | None] = mapped_column(default=None, index=True) # 关联阳光房订单
name: Mapped[str] = mapped_column(String(64), default="")
roof: Mapped[str] = mapped_column(String(8), default="flat") # flat平顶 / gable人字顶
width_mm: Mapped[int] = mapped_column(default=4000) # 宽(X向)
depth_mm: Mapped[int] = mapped_column(default=3000) # 进深(Y向)
height_mm: Mapped[int] = mapped_column(default=2800) # 柱高/檐高
bay_mm: Mapped[int] = mapped_column(default=1000) # 立柱间距
params: Mapped[str] = mapped_column(Text, default="{}") # 扩展(玻璃规格/立柱色等)
user_id: Mapped[int] = mapped_column(default=0)
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
class SmsCode(Base):
"""短信验证码(provider 适配层:未配置短信签名时走 mock 通道)。"""
+20 -1
View File
@@ -41,6 +41,12 @@ QUICK_SCENE = {
"studio": "studio product shot on a seamless neutral background, softbox lighting, slight floor reflection",
}
# 阳光房场景(对齐目标站 sunnyhouse 平顶/人字顶)
SUNROOM_DESC = {
"sunroom-flat": "a flat-roof glass sunroom attached to the house, slim {frame_word} aluminum posts and glass beams, clear tempered glass roof panels, furnished with rattan chairs and plants, garden view",
"sunroom-gable": "a gable (A-frame pitched) glass sunroom attached to the house, ridge beam with fan-shaped front glass, {frame_word} aluminum structure, clear glass roof, garden view",
}
def _sash_kind(sub: dict) -> str | None:
t = str(sub.get("type", ""))
@@ -134,11 +140,24 @@ def build_prompt(drawing: str | dict, style: str = "modern", time: str = "day",
ratio: portrait/landscape/square → 依据窗型宽高比的构图引导。
"""
info = describe_window(drawing)
dd = parse_drawing(drawing)
finish, style_word = STYLE_FINISH.get(style, STYLE_FINISH["modern"])
light = TIME_LIGHT.get(time, TIME_LIGHT["day"])
# 阳光房场景: 窗型描述让位于结构描述
frame_word = "anthracite" if style == "modern" else ("bronze" if style == "chinese" else "white")
if scene in SUNROOM_DESC:
scene_text = SUNROOM_DESC[scene].replace("{frame_word}", frame_word)
sr = dd.get("_sunroom") or {}
fw = sr.get("width_mm") or info["width_mm"] or 4000
fd = sr.get("depth_mm") or 3000
fh = sr.get("height_mm") or 2800
bays = max(2, round(fw / (sr.get("bay_mm") or 1000)))
window_desc = (f"Structure: {scene_text}; footprint {fw}×{fd}mm, eave height {fh}mm, "
f"{bays} bays with slim aluminum posts")
else:
scene_tpl = SCENE_DESC.get(scene) or QUICK_SCENE.get(scene) or SCENE_DESC["render"]
scene_text = scene_tpl.replace("{style_word}", style_word)
window_desc = info["summary"] or "a modern aluminum window"
composition = {
"portrait": "vertical composition, window fills most of the frame height",
+21 -2
View File
@@ -27,6 +27,7 @@ class TakeoffResult:
width_mm: int = 0
height_mm: int = 0
sash_count: int = 0
sash_assign_count: int = 0
meters: dict = field(default_factory=dict) # 各型材类别总米数 {frame, mullion, sash}
@@ -106,6 +107,23 @@ def takeoff(dd) -> TakeoffResult:
if cols:
result.bars.append(BarItem("dec_bar", round(bb[3] - bb[1]), cols, "格条-竖"))
# 逐格指定(自研 assigns 扩展): 开扇五金+扇框料 / 副梃
for assign in (shape.get("assigns") or []):
if not isinstance(assign, dict):
continue
ri = int(assign.get("ri", -1))
if not (0 <= ri < len(region_bbs)):
continue
bb = region_bbs[ri]
w, h = round(bb[2] - bb[0]), round(bb[3] - bb[1])
sash = assign.get("sash") or {}
if sash.get("kind") and sash["kind"] != "fixed":
result.bars.append(BarItem("sash", w, 2, "指定扇-宽"))
result.bars.append(BarItem("sash", h, 2, "指定扇-高"))
result.sash_assign_count += 1
if assign.get("subBar"):
result.bars.append(BarItem("subbar", w, 1, "副梃"))
# 扇框
for sub in (shape.get("sm") or []):
bd = sub.get("bd") if isinstance(sub, dict) else None
@@ -127,7 +145,7 @@ def takeoff(dd) -> TakeoffResult:
result.sash_count = sum(
1 for s in dd.get("sm", []) for sub in (s.get("sm") or [])
if isinstance(sub, dict) and sub.get("bd") and not sub.get("bd").get("hidden")
)
) + result.sash_assign_count
return result
@@ -167,7 +185,8 @@ def quote(drawing: str | dict, prices: dict | None = None, scripts: dict | None
"""
from huamenchuang.services.formula import FormulaError, eval_formula
p = {"frame": 45, "mullion": 40, "sash": 50, "glass": 120, "hardware": 180, "dec_bar": 12}
p = {"frame": 45, "mullion": 40, "sash": 50, "glass": 120, "hardware": 180, "dec_bar": 12,
"subbar": 25}
if prices:
p.update(prices)