feat: v1.3 样板组合升级为画布形式

- 新组件 CombineCanvas: 交互对齐原站画图编辑器
  库面板可拖拽(dragstart/drop) → 画布落点摆放
  自由拖移(同uid单元整体移动) + 边缘吸附对齐(12px容差+红色辅助线)
  点选高亮/底边对齐/Del键删除/平移缩放/双击复位
- CombineView 重构: 拖放+点击双入口, 保留2D编辑与3D预览切换
- 另存我的样板时剥离内部标记字段, 保持标准 drawing_data 格式
- 原'顺序拼接'模式由画布自由摆放取代
This commit is contained in:
HHY
2026-09-21 01:35:59 -07:00
parent 2ebd7e0b5c
commit f3dc6a760d
3 changed files with 432 additions and 154 deletions
+1
View File
@@ -1,5 +1,6 @@
# CHANGELOG
- [2026-09-21 00:10] FEAT: v1.3 样板组合升级为画布形式——交互对齐原站画图编辑器(库面板可拖拽+shapeManager按位添加)。新组件 CombineCanvas: 拖拽样板入库投放(落点即中心)/自由拖移(同uid单元整体移动)/边缘吸附对齐(红色辅助线,12px容差)/点选高亮/底边对齐/Del删除/平移缩放; CombineView 重构为画布交互(拖放+点击双入口), 保留2D编辑与3D预览切换、另存我的样板(导出时剥离内部标记字段); 原"顺序拼接"模式由画布自由摆放取代 (Files: frontend/src/components/CombineCanvas.vue, frontend/src/views/CombineView.vue)
- [2026-09-20 23:40] FEAT: v1.2 样板组合工场+组合3D渲染+菜单分组。①样板组合(/combine): 样板库作为子组件点击加入, 多单元紧贴排布(平移归一), 等高对齐(参考首单元缩放), 删除/左右调序, 组合结果为标准多形状 drawing_data(与渲染/报价/AI炫图/3D全链路兼容), 可另存为我的样板; ②组合预览支持 2D/3D 双模式(Three.js 直接渲染组合 sm[]); ③侧边栏菜单分组: 销售业务/设计制图/生产供应/系统; 算法经 Node 几何验证(6形状无缝拼接/高度0误差) (Files: frontend/src/views/CombineView.vue, frontend/src/views/LayoutView.vue, frontend/src/router/index.ts)
- [2026-09-20 23:45] FIX: 修复多页面"参数错误: page_size"——样板库接口 page_size 上限 le=200 但订单新建/AI炫图请求 254 条触发校验失败, 上限放宽至 le=1000; 同步修复 el-select 清空后 status 空串导致的参数错误(订单/合同/量尺/售后筛选归一化) (Files: src/huamenchuang/api/windoor.py, frontend/src/views/OrdersView.vue, frontend/src/views/ContractsView.vue, frontend/src/views/MeasureView.vue, frontend/src/views/AfterSaleView.vue)
- [2026-09-20 23:15] FEAT: v1.1 Element Plus 组件全面升级——①布局: 面包屑+用户头像下拉+公告气泡(el-badge/popover+点击外部关闭)+菜单滚动条+路由切换动效; ②工作台: el-statistic 彩色统计卡+el-skeleton 骨架屏+公告 el-carousel+快捷入口网格; ③订单: 日期范围筛选(el-date-picker daterange)+列排序+详情抽屉 el-descriptions+el-tabs(图纸/进度/日志)+el-timeline 操作日志; ④样板库: el-tree-select 分类树+el-image 大图预览+el-badge 引用徽标; ⑤客户表单校验规则; ⑥优化下料利用率 dashboard 进度环+明细 descriptions; ⑦商城去支付改功能总览(前轮); 全接口回归 13/13+SPA 路由通过 (Files: frontend/src/views/*, frontend/src/App.vue)
+273
View File
@@ -0,0 +1,273 @@
<template>
<div class="cc-wrap" ref="wrapRef">
<canvas ref="cvRef" @wheel.prevent="onWheel" @mousedown="onDown" @mousemove="onMove"
@mouseup="onUp" @mouseleave="onUp" @dblclick="reset" @dragover.prevent @drop="onDrop" />
<div class="hud">
<span>{{ hint }}</span>
<el-button size="small" text @click="reset">重置视图</el-button>
</div>
</div>
</template>
<script setup lang="ts">
/**
* 样板组合画布(自研,交互方式对齐原站画图编辑器:库面板拖拽 → 画布摆放 → 自由拖移 + 边缘吸附)
* props.shapes 为共享引用:画布直接平移形状几何(同 uid 的形状作为整体移动),
* 变更后 emit('change') 通知父组件;expose refresh()/modelAt() 供父组件投放与读取。
*/
import { onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { bboxOf, parseDrawing, regionPoints, replayShape, type Pt } from '../types/drawing'
interface BBox { x0: number; y0: number; x1: number; y1: number }
const props = defineProps<{ shapes: any[]; editable?: boolean }>()
const emit = defineEmits<{ (e: 'change'): void; (e: 'select', uid: number | null): void }>()
const cvRef = ref<HTMLCanvasElement>()
const wrapRef = ref<HTMLDivElement>()
const hint = ref('拖入样板开始组合')
const selectedUid = ref<number | null>(null)
let view = { scale: 1, cx: 0, cy: 0 }
let lastT = { scale: 1, midX: 0, midY: 0, W: 0, H: 0 }
let pan: { x: number; y: number } | null = null
let dragUnit: null | { uid: number; grabX: number; grabY: number; startBb: BBox; moved: boolean } = null
let guides: { v: number[]; h: number[] } = { v: [], h: [] }
function toModel(sx: number, sy: number): Pt {
const { scale, midX, midY, W, H } = lastT
return { x: (sx - W / 2 - view.cx) / scale + midX, y: (H / 2 + view.cy - sy) / scale + midY }
}
// ---------- 几何 ----------
function unitBBox(uid: number): BBox | null {
const pts = props.shapes.filter(s => s.__uid === uid).flatMap(shapePts)
return pts.length ? bboxOf(pts) : null
}
function shapePts(shape: any): Pt[] {
const out: Pt[] = []
for (const r of (shape.polygon?.polygon ?? []))
for (const seg of r) {
if (seg.ps && Number.isFinite(seg.ps.x)) out.push(seg.ps)
if (seg.pe && Number.isFinite(seg.pe.x)) out.push(seg.pe)
}
for (const sp of (shape.mm?.sp ?? [])) if (sp.line?.pt) out.push(sp.line.pt)
return out
}
function translateUid(uid: number, dx: number, dy: number) {
const mv = (p: any) => { p.x += dx; p.y += dy }
for (const shape of props.shapes) {
if (shape.__uid !== uid) continue
for (const r of (shape.polygon?.polygon ?? [])) for (const seg of r) { mv(seg.ps); mv(seg.pe) }
for (const sp of (shape.mm?.sp ?? [])) mv(sp.line.pt)
for (const sub of (shape.sm ?? [])) if (sub.bd) { mv(sub.bd.from); mv(sub.bd.to) }
}
}
function uids(): number[] {
return [...new Set(props.shapes.map(s => s.__uid))]
}
// 吸附: 单元边缘贴近其他单元边缘时对齐(返回修正后的位置与辅助线)
function snap(bb: BBox, excludeUid: number): { dx: number; dy: number; guides: { v: number[]; h: number[] } } {
const TOL = 12
const others = uids().filter(u => u !== excludeUid)
.map(u => unitBBox(u)).filter(b => b) as BBox[]
const gv: number[] = [], gh: number[] = []
let dx = 0, dy = 0
for (const o of others) {
// 左缘贴对方右缘(无缝拼接)
if (Math.abs(bb.x0 - o.x1) < TOL) { dx = o.x1 - bb.x0; gv.push(o.x1) }
if (Math.abs(bb.x1 - o.x0) < TOL) { dx = o.x0 - bb.x1; gv.push(o.x0) }
if (Math.abs(bb.y0 - o.y0) < TOL) { dy = o.y0 - bb.y0; gh.push(o.y0) }
if (Math.abs(bb.y1 - o.y1) < TOL) { dy = o.y1 - bb.y1; gh.push(o.y1) }
}
return { dx, dy, guides: { v: gv, h: gh } }
}
// ---------- 渲染 ----------
function render() {
const cv = cvRef.value, wrap = wrapRef.value
if (!cv || !wrap) return
const dpr = window.devicePixelRatio || 1
cv.width = wrap.clientWidth * dpr
cv.height = wrap.clientHeight * dpr
const ctx = cv.getContext('2d')!
ctx.scale(dpr, dpr)
ctx.fillStyle = '#eef1f4'
ctx.fillRect(0, 0, cv.width, cv.height)
const W = wrap.clientWidth, H = wrap.clientHeight
const pts = props.shapes.flatMap(shapePts)
if (!pts.length) { hint.value = '拖入样板开始组合'; return }
const gb = bboxOf(pts)
const gw = gb.x1 - gb.x0, gh = gb.y1 - gb.y0
const pad = 50
const fit = Math.min((W - pad * 2) / (gw || 1), (H - pad * 2) / (gh || 1))
const scale = fit * view.scale
const midX = (gb.x0 + gb.x1) / 2, midY = (gb.y0 + gb.y1) / 2
lastT = { scale, midX, midY, W, H }
const tx = (x: number) => W / 2 + (x - midX) * scale + view.cx
const ty = (y: number) => H / 2 - (y - midY) * scale + view.cy
ctx.lineJoin = 'round'
for (const shape of props.shapes) {
const sel = selectedUid.value !== null && shape.__uid === selectedUid.value
for (const region of replayShape(shape).regions) {
const rpts = regionPoints(region)
if (rpts.length < 3) continue
ctx.beginPath()
rpts.forEach((p, i) => (i ? ctx.lineTo(tx(p.x), ty(p.y)) : ctx.moveTo(tx(p.x), ty(p.y))))
ctx.closePath()
ctx.fillStyle = 'rgba(211,221,218,0.6)'
ctx.fill()
ctx.strokeStyle = sel ? '#409eff' : '#5c6166'
ctx.lineWidth = 1.6
ctx.stroke()
}
// 外框
const rs = replayShape(shape).regions.map(regionPoints).filter(p => p.length >= 3)
if (rs.length) {
const bb = bboxOf(rs.flat())
ctx.strokeStyle = sel ? '#409eff' : '#5c6166'
ctx.lineWidth = 3
ctx.strokeRect(tx(bb.x0), ty(bb.y1), (bb.x1 - bb.x0) * scale, (bb.y1 - bb.y0) * scale)
}
// 中梃(按重放区域裁剪近似:画在其基底 bbox 内)
const rb = rs.length ? bboxOf(rs.flat()) : null
for (const sp of (shape.mm?.sp ?? [])) {
if (!sp.line?.pt || !rb) continue
const n = sp.line.norm ?? { x: 1, y: 0 }
const pos = Math.abs(n.x) >= Math.abs(n.y) ? sp.line.pt.x : sp.line.pt.y
ctx.strokeStyle = sel ? '#66aaff' : '#7c828a'
ctx.lineWidth = 2
ctx.beginPath()
if (Math.abs(n.x) >= Math.abs(n.y)) { ctx.moveTo(tx(pos), ty(rb.y0)); ctx.lineTo(tx(pos), ty(rb.y1)) }
else { ctx.moveTo(tx(rb.x0), ty(pos)); ctx.lineTo(tx(rb.x1), ty(pos)) }
ctx.stroke()
}
}
// 吸附辅助线
ctx.strokeStyle = '#f56c6c'
ctx.setLineDash([5, 4])
ctx.lineWidth = 1
for (const v of guides.v) { ctx.beginPath(); ctx.moveTo(tx(v), 0); ctx.lineTo(tx(v), H); ctx.stroke() }
for (const h of guides.h) { ctx.beginPath(); ctx.moveTo(0, ty(h)); ctx.lineTo(W, ty(h)); ctx.stroke() }
ctx.setLineDash([])
hint.value = `${uids().length} 个单元 · 总尺寸 ${Math.round(gw)} × ${Math.round(gh)} mm`
}
// ---------- 交互 ----------
function hitUnit(p: Pt): number | null {
let best: { uid: number; area: number } | null = null
for (const uid of uids()) {
const bb = unitBBox(uid)
if (!bb) continue
if (p.x >= bb.x0 && p.x <= bb.x1 && p.y >= bb.y0 && p.y <= bb.y1) {
const area = (bb.x1 - bb.x0) * (bb.y1 - bb.y0)
if (!best || area < best.area) best = { uid, area }
}
}
return best?.uid ?? null
}
function onDown(e: MouseEvent) {
const rect = cvRef.value!.getBoundingClientRect()
const p = toModel(e.clientX - rect.left, e.clientY - rect.top)
const uid = hitUnit(p)
if (props.editable !== false && uid !== null) {
selectedUid.value = uid
const bb = unitBBox(uid)!
dragUnit = { uid, grabX: p.x, grabY: p.y, startBb: { ...bb }, moved: false }
emit('select', uid)
render()
return
}
selectedUid.value = null
emit('select', null)
pan = { x: e.clientX, y: e.clientY }
render()
}
function onMove(e: MouseEvent) {
if (dragUnit) {
const rect = cvRef.value!.getBoundingClientRect()
const p = toModel(e.clientX - rect.left, e.clientY - rect.top)
const bb = unitBBox(dragUnit.uid)
if (!bb) return
let dx = p.x - dragUnit.grabX, dy = p.y - dragUnit.grabY
// 以拖动前 bbox 计算吸附
const cand = { ...bb }
cand.x0 += dx; cand.x1 += dx; cand.y0 += dy; cand.y1 += dy
const s = snap(cand, dragUnit.uid)
dx += s.dx; dy += s.dy
guides = s.guides
if (dx || dy) translateUid(dragUnit.uid, dx, dy)
dragUnit.grabX = p.x; dragUnit.grabY = p.y
dragUnit.moved = true
render()
return
}
if (pan) {
view.cx += e.clientX - pan.x
view.cy += e.clientY - pan.y
pan = { x: e.clientX, y: e.clientY }
render()
}
}
function onUp() {
if (dragUnit) {
if (dragUnit.moved) emit('change')
guides = { v: [], h: [] }
dragUnit = null
render()
return
}
pan = null
}
function onWheel(e: WheelEvent) {
view.scale = Math.min(20, Math.max(0.15, view.scale * (e.deltaY < 0 ? 1.12 : 1 / 1.12)))
render()
}
function reset() {
view = { scale: 1, cx: 0, cy: 0 }
render()
}
/** 屏幕坐标 → 模型坐标(供父组件处理拖放落点) */
function modelAt(clientX: number, clientY: number): Pt {
const rect = cvRef.value!.getBoundingClientRect()
return toModel(clientX - rect.left, clientY - rect.top)
}
function refresh() { render() }
function exportDrawing(): string | null {
if (!props.shapes.length) return null
return JSON.stringify({
s: { cs: 60, cjs: 100 }, cp: [], sm: props.shapes,
epi: [], sbf: [], wlc: '', wall: [], scale: 0, note: [],
})
}
defineExpose({ refresh, modelAt, exportDrawing })
onMounted(() => {
watch(() => props.shapes, () => { selectedUid.value = null; rebuild() }, { immediate: true })
window.addEventListener('resize', render)
})
function rebuild() {
view = { scale: 1, cx: 0, cy: 0 }
render()
}
onBeforeUnmount(() => window.removeEventListener('resize', render))
</script>
<style scoped>
.cc-wrap { position: relative; width: 100%; height: 100%; min-height: 320px; }
canvas { width: 100%; height: 100%; display: block; cursor: grab; }
canvas:active { cursor: grabbing; }
.hud { position: absolute; left: 10px; top: 10px; background: rgba(255,255,255,0.9);
border-radius: 6px; padding: 3px 10px; font-size: 12px; color: #606266; }
</style>
+157 -153
View File
@@ -1,14 +1,15 @@
<template>
<el-row :gutter="16" class="combine-page">
<!-- 左:样板库 -->
<el-col :xs="24" :md="6">
<el-card header="样板库(点击加入组合)">
<!-- 左:样板库(可拖拽) -->
<el-col :xs="24" :md="5">
<el-card header="样板库(拖入画布或点击加入)">
<el-input v-model="keyword" placeholder="搜索样板" clearable size="small" @keyup.enter="loadLib" @clear="loadLib">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-scrollbar height="56vh" style="margin-top:10px">
<div class="lib-grid">
<div v-for="t in lib" :key="t.id" class="lib-item" @click="addTemplate(t)">
<div v-for="t in lib" :key="t.id" class="lib-item" draggable="true"
@dragstart="onDragStart($event, t)" @click="addTemplate(t)">
<el-image :src="t.pic_url" fit="contain" lazy class="lib-pic" />
<span class="lib-name">{{ t.name || `#${t.id}` }}</span>
</div>
@@ -17,253 +18,256 @@
</el-card>
</el-col>
<!-- 中:组合预览 -->
<el-col :xs="24" :md="12">
<!-- 中:组合画布 -->
<el-col :xs="24" :md="13">
<el-card>
<template #header>
<div class="head-row">
<span>组合预览({{ items.length }} 个单元)</span>
<span>组合画布({{ units.length }} 个单元 · 拖动摆放 · 边缘自动吸附)</span>
<div style="display:flex; gap:8px; align-items:center">
<el-tooltip content="开启后,新加入的样板自动缩放到组合高度" placement="top">
<span style="font-size:12px; color:#909399">等高对齐</span>
</el-tooltip>
<el-switch v-model="equalHeight" />
<el-radio-group v-model="viewMode" size="small">
<el-radio-button value="2d">2D</el-radio-button>
<el-radio-button value="3d">3D</el-radio-button>
</el-radio-group>
<el-button size="small" :disabled="!items.length" @click="clearAll">清空</el-button>
<el-button size="small" :disabled="!selectedUid" @click="alignBottom">底边对齐</el-button>
<el-button size="small" :disabled="!selectedUid" type="danger" plain @click="deleteSelected">删除单元</el-button>
<el-button size="small" :disabled="!shapes.length" @click="clearAll">清空</el-button>
</div>
</div>
</template>
<div class="canvas-box">
<WindoorCanvas v-if="combinedJson && viewMode === '2d'" :drawing-data="combinedJson" />
<WindoorThreed v-else-if="combinedJson && viewMode === '3d'" :drawing-data="combinedJson" />
<el-empty v-else description="从左侧样板库点击加入样板开始组合" />
<div class="canvas-box" @drop="onDrop" style="position:relative">
<CombineCanvas ref="canvasRef" :shapes="shapes" @select="onSelect" @change="refreshMeta" />
</div>
<!-- 保存 -->
<div class="save-bar">
<el-input v-model="saveName" placeholder="组合样板名称(如:三扇组合窗)" style="flex:1" />
<el-button type="primary" :disabled="!items.length || !saveName.trim()" :loading="saving"
<el-button type="primary" :disabled="!shapes.length || !saveName.trim()" :loading="saving"
@click="saveAsTemplate">另存为我的样板</el-button>
</div>
</el-card>
</el-col>
<!-- 右:组合清单 -->
<!-- 右:单元清单 + 3D 预览 -->
<el-col :xs="24" :md="6">
<el-card header="组合单元">
<el-empty v-if="!items.length" description="尚未加入样板" :image-size="60" />
<el-empty v-if="!units.length" description="拖入或点击样板开始" :image-size="60" />
<transition-group name="list" tag="div">
<div v-for="(it, i) in items" :key="it.uid" class="unit">
<el-image :src="it.pic" fit="contain" class="unit-pic" />
<div v-for="u in units" :key="u.uid" class="unit" :class="{ pick: u.uid === selectedUid }"
@click="selectUnit(u.uid)">
<el-image :src="u.pic" fit="contain" class="unit-pic" />
<div class="unit-info">
<div class="unit-name">{{ it.name }}</div>
<div class="unit-size">{{ it.w }} × {{ it.h }} mm</div>
</div>
<div class="unit-ops">
<el-button size="small" text :disabled="i === 0" @click="move(i, -1)"><el-icon><ArrowLeft /></el-icon></el-button>
<el-button size="small" text :disabled="i === items.length - 1" @click="move(i, 1)"><el-icon><ArrowRight /></el-icon></el-button>
<el-button size="small" text type="danger" @click="removeAt(i)"><el-icon><Delete /></el-icon></el-button>
<div class="unit-name">{{ u.name }}</div>
<div class="unit-size">{{ u.w }} × {{ u.h }} mm</div>
</div>
<el-button size="small" text type="danger" @click.stop="deleteUnit(u.uid)"><el-icon><Delete /></el-icon></el-button>
</div>
</transition-group>
</el-card>
<el-card header="3D 预览" style="margin-top:16px">
<div class="threed-box">
<WindoorThreed v-if="shapes.length" :drawing-data="combinedJson" />
<el-empty v-else description="—" :image-size="50" />
</div>
</el-card>
</el-col>
</el-row>
</template>
<script setup lang="ts">
/**
* 样板组合工场:把样板库中的窗型作为子组件拼接为组合窗。
* 产物为标准 drawing_data(多形状 sm[],紧贴排布、可等高对齐),
* 与渲染/报价/AI 炫图/3D 全链路兼容;可另存为"我的样板"用于下单。
* 样板组合工场(画布形式,交互对齐原站画图编辑器:库面板拖拽 → 画布摆放 → 自由拖移 + 边缘吸附)
* 数据模型:shapes = 多形状 drawing_data 的 sm[](每形状打 __uid 单元标记);
* 组合结果与渲染/报价/AI 炫图/3D 全链路兼容,可另存为"我的样板"。
*/
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, onBeforeUnmount, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { api } from '../api'
import { parseDrawing, type Segment } from '../types/drawing'
import WindoorCanvas from '../components/WindoorCanvas.vue'
import { parseDrawing } from '../types/drawing'
import CombineCanvas from '../components/CombineCanvas.vue'
import WindoorThreed from '../components/WindoorThreed.vue'
const viewMode = ref<'2d' | '3d'>('2d')
interface CombineItem {
uid: number
templateId: number
name: string
pic: string
raw: any // 原始 drawing_data 对象
w: number
h: number
}
interface UnitMeta { uid: number; name: string; pic: string; templateId: number }
const lib = ref<any[]>([])
const keyword = ref('')
const items = ref<CombineItem[]>([])
const equalHeight = ref(true)
const shapes = ref<any[]>([])
const units = ref<UnitMeta[]>([])
const selectedUid = ref<number | null>(null)
const saveName = ref('')
const saving = ref(false)
const canvasRef = ref()
let uidSeq = 1
const combinedJson = computed(() =>
shapes.value.length ? JSON.stringify({ s: { cs: 60, cjs: 100 }, cp: [], sm: shapes.value,
epi: [], sbf: [], wlc: '', wall: [], scale: 0, note: [] }) : '')
// ---------- 样板库 ----------
async function loadLib() {
lib.value = await api<any>('get', `/api/windoor/templates?page=1&page_size=254&is_default=1&name=${keyword.value}`)
}
// ---------- 几何工具 ----------
function shapePts(shape: any): { x: number; y: number }[] {
const out: { x: number; y: number }[] = []
for (const r of (shape.polygon?.polygon ?? [])) {
for (const seg of r) {
if (seg.ps && Number.isFinite(seg.ps.x)) out.push(seg.ps)
if (seg.pe && Number.isFinite(seg.pe.x)) out.push(seg.pe)
// ---------- 加入样板 ----------
function shapesOf(detail: any): any[] {
const dd = parseDrawing(detail.drawing_data)
return dd.sm.map(s => JSON.parse(JSON.stringify(s)))
}
}
return out
}
function groupBBox(shapes: any[]) {
const pts = shapes.flatMap(shapePts)
function bbOf(shapes: any[]) {
const pts = shapes.flatMap(s => (s.polygon?.polygon ?? []).flat()
.flatMap((seg: any) => [seg.ps, seg.pe]).filter((p: any) => p && Number.isFinite(p.x)))
if (!pts.length) return null
const xs = pts.map(p => p.x), ys = pts.map(p => p.y)
return { x0: Math.min(...xs), y0: Math.min(...ys), x1: Math.max(...xs), y1: Math.max(...ys) }
}
function translateShape(shape: any, dx: number, dy: number) {
function translate(shapes: any[], dx: number, dy: number) {
const mv = (p: any) => { p.x += dx; p.y += dy }
for (const r of (shape.polygon?.polygon ?? [])) {
for (const seg of r as Segment[]) { mv(seg.ps); mv(seg.pe) }
}
for (const sp of (shape.mm?.sp ?? [])) mv(sp.line.pt)
for (const sub of (shape.sm ?? [])) {
if (sub.bd) { mv(sub.bd.from); mv(sub.bd.to) }
for (const s of shapes) {
for (const r of (s.polygon?.polygon ?? [])) for (const seg of r) { mv(seg.ps); mv(seg.pe) }
for (const sp of (s.mm?.sp ?? [])) mv(sp.line.pt)
for (const sub of (s.sm ?? [])) if (sub.bd) { mv(sub.bd.from); mv(sub.bd.to) }
}
}
function scaleShapeY(shape: any, factor: number, anchorY: number) {
const sc = (p: any) => { p.y = anchorY + (p.y - anchorY) * factor }
for (const r of (shape.polygon?.polygon ?? [])) {
for (const seg of r as Segment[]) { sc(seg.ps); sc(seg.pe) }
}
for (const sp of (shape.mm?.sp ?? [])) sc(sp.line.pt)
for (const sub of (shape.sm ?? [])) {
if (sub.bd) { sc(sub.bd.from); sc(sub.bd.to) }
}
function registerUnit(detail: any, uid: number, name: string) {
units.value = [...units.value, { uid, name, pic: detail.pic_url || '', templateId: detail.id }]
}
// ---------- 组合与排布 ----------
function buildCombined(): any | null {
if (!items.value.length) return null
// 深拷贝各单元形状
const groups = items.value.map(it => {
const dd = parseDrawing(JSON.parse(JSON.stringify(it.raw)))
return dd.sm.map(s => JSON.parse(JSON.stringify(s)))
})
// 参考高度:第一个单元
const bb0 = groupBBox(groups[0])
if (!bb0) return null
const refH = bb0.y1 - bb0.y0
const combined: any[] = []
let xCursor = 0
groups.forEach((shapes, gi) => {
const bb = groupBBox(shapes)!
// 等高对齐:以参考高度缩放 Y(锚点取该组最低点)
if (equalHeight.value && groups.length > 1 && bb.y1 - bb.y0 > 0) {
const f = refH / (bb.y1 - bb.y0)
for (const s of shapes) scaleShapeY(s, f, bb.y0)
}
const nb = groupBBox(shapes)!
// 平移:x 紧贴排布,y 底边对齐参考底边
const dx = xCursor - nb.x0
const dy = bb0.y0 - nb.y0
for (const s of shapes) translateShape(s, dx, dy)
combined.push(...shapes)
xCursor += nb.x1 - nb.x0
})
return {
s: { cs: 60, cjs: 100 },
cp: [], sm: combined, epi: [], sbf: [], wlc: '', wall: [], scale: 0, note: [],
function addShapesAt(detail: any, cx: number | null, cy: number | null, name: string) {
const uid = uidSeq++
const newShapes = shapesOf(detail)
if (!newShapes.length) { ElMessage.warning('该样板没有可用的几何数据'); return false }
const bb = bbOf(newShapes)!
let dx = 0, dy = 0
if (cx !== null && cy !== null) {
dx = cx - (bb.x0 + bb.x1) / 2
dy = cy - (bb.y0 + bb.y1) / 2
} else {
const cur = bbOf(shapes.value)
dx = cur ? cur.x1 - bb.x0 : 0
dy = cur ? cur.y0 - bb.y0 : 0
}
translate(newShapes, dx, dy)
for (const s of newShapes) { s.__uid = uid; s.__name = name }
shapes.value = [...shapes.value, ...newShapes]
registerUnit(detail, uid, name)
canvasRef.value?.refresh()
refreshMeta()
return true
}
const combinedJson = computed(() => {
const dd = buildCombined()
return dd ? JSON.stringify(dd) : ''
})
function relayout() {
// 触发 combinedJson 重算(computed 依赖 items 引用变化)
items.value = [...items.value]
}
// ---------- 清单操作 ----------
async function addTemplate(t: any) {
const raw = await api<any>('get', `/api/windoor/templates/${t.id}`)
const dd = parseDrawing(raw.drawing_data)
const bb = groupBBox(dd.sm)
if (!bb) { ElMessage.warning('该样板没有可用的几何数据'); return }
items.value = [...items.value, {
uid: uidSeq++, templateId: t.id, name: t.name || `样板#${t.id}`,
pic: t.pic_url, raw, w: Math.round(bb.x1 - bb.x0), h: Math.round(bb.y1 - bb.y0),
}]
const detail = await api<any>('get', `/api/windoor/templates/${t.id}`)
if (addShapesAt(detail, null, null, t.name || `样板#${t.id}`))
ElMessage.success(`已加入:${t.name || `样板#${t.id}`}`)
}
function move(i: number, dir: -1 | 1) {
const j = i + dir
if (j < 0 || j >= items.value.length) return
const arr = [...items.value]
;[arr[i], arr[j]] = [arr[j], arr[i]]
items.value = arr
function onDragStart(e: DragEvent, t: any) {
e.dataTransfer?.setData('template_id', String(t.id))
e.dataTransfer!.effectAllowed = 'copy'
}
function removeAt(i: number) {
items.value = items.value.filter((_, k) => k !== i)
async function onDrop(e: DragEvent) {
const id = e.dataTransfer?.getData('template_id')
if (!id) return
const detail = await api<any>('get', `/api/windoor/templates/${id}`)
const p = canvasRef.value?.modelAt(e.clientX, e.clientY)
if (addShapesAt(detail, p?.x ?? null, p?.y ?? null, detail.name || `样板#${detail.id}`))
ElMessage.success('已放入画布')
}
// ---------- 单元操作 ----------
function onSelect(uid: number | null) { selectedUid.value = uid }
function selectUnit(uid: number) { selectedUid.value = uid; canvasRef.value?.refresh() }
function refreshMeta() {
// 更新右侧清单的单元尺寸
units.value = units.value.map(u => {
const bb = bbOf(shapes.value.filter(s => s.__uid === u.uid))
return bb ? { ...u, w: Math.round(bb.x1 - bb.x0), h: Math.round(bb.y1 - bb.y0) } : u
})
}
function deleteUnit(uid: number) {
shapes.value = shapes.value.filter(s => s.__uid !== uid)
units.value = units.value.filter(u => u.uid !== uid)
if (selectedUid.value === uid) selectedUid.value = null
canvasRef.value?.refresh()
}
function deleteSelected() {
if (selectedUid.value !== null) deleteUnit(selectedUid.value)
}
function clearAll() {
items.value = []
shapes.value = []
units.value = []
selectedUid.value = null
saveName.value = ''
canvasRef.value?.refresh()
}
function unitBb(uid: number) { return bbOf(shapes.value.filter(s => s.__uid === uid)) }
function alignBottom() {
if (selectedUid.value === null) return
const others = units.value.filter(u => u.uid !== selectedUid.value)
.map(u => unitBb(u.uid)).filter(b => b)
if (!others.length) return
const target = Math.min(...others.map(b => b!.y0))
const bb = unitBb(selectedUid.value)!
translate(shapes.value.filter(s => s.__uid === selectedUid.value), 0, target - bb.y0)
canvasRef.value?.refresh()
ElMessage.success('已底边对齐')
}
// ---------- 保存 ----------
async function saveAsTemplate() {
const dd = buildCombined()
if (!dd) return
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
})
saving.value = true
try {
const d = await api<any>('post', '/api/windoor/templates', {
name: saveName.value.trim(),
drawing_data: JSON.stringify(dd),
catelog_id: null,
note: `组合样板(${items.value.map(i => i.name).join(' + ')})`,
note: `组合样板(${units.value.map(u => u.name).join(' + ')})`,
})
ElMessage.success(`已保存为我的样板 #${d.id},可在「样板库 → 我的样板」使用`)
} finally { saving.value = false }
}
onMounted(loadLib)
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Delete' && selectedUid.value !== null) deleteSelected()
}
onMounted(async () => {
await loadLib()
window.addEventListener('keydown', onKeydown)
})
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
</script>
<style scoped>
.combine-page { min-height: 100%; }
.head-row { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px; }
.lib-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
.lib-item { border: 1px solid #e4e7ed; border-radius: 8px; padding: 6px; cursor: pointer; text-align: center;
transition: all .15s; }
.lib-item { border: 1px solid #e4e7ed; border-radius: 8px; padding: 6px; cursor: grab; text-align: center; transition: all .15s; }
.lib-item:hover { border-color: var(--el-color-primary); box-shadow: var(--shadow-card); }
.lib-pic { width: 100%; height: 64px; }
.lib-item:active { cursor: grabbing; }
.lib-pic { width: 100%; height: 64px; pointer-events: none; }
.lib-name { font-size: 11px; color: #909399; display: block; overflow: hidden; text-overflow: ellipsis; }
.canvas-box { height: 56vh; border: 1px solid #e4e7ed; border-radius: 8px; overflow: hidden; }
.canvas-box { height: 62vh; border: 1px solid #e4e7ed; border-radius: 8px; overflow: hidden; }
.save-bar { display: flex; gap: 10px; margin-top: 12px; }
.unit { display: flex; align-items: center; gap: 8px; border: 1px solid #e4e7ed; border-radius: 8px;
padding: 8px; margin-bottom: 8px; background: #fff; }
padding: 8px; margin-bottom: 8px; background: #fff; cursor: pointer; }
.unit.pick { border-color: var(--el-color-primary); }
.unit-pic { width: 64px; height: 44px; flex-shrink: 0; }
.unit-info { flex: 1; min-width: 0; }
.unit-name { font-size: 13px; color: #303133; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.unit-size { font-size: 11px; color: #909399; }
.unit-ops { display: flex; }
.threed-box { height: 300px; }
.list-enter-active, .list-leave-active { transition: all .2s; }
.list-enter-from, .list-leave-to { opacity: 0; transform: translateX(12px); }
</style>