feat: P1批次1 设计域(AI推荐/分享体系/阳光房绘图/量尺标注/逐格指定编辑)
This commit is contained in:
@@ -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
@@ -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 拖线标注)。
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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') },
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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="推荐完成")
|
||||
@@ -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)
|
||||
|
||||
@@ -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]})
|
||||
@@ -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="已删除")
|
||||
@@ -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",
|
||||
|
||||
@@ -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 通道)。"""
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user