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
+158 -154
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)
}
}
return out
// ---------- 加入样板 ----------
function shapesOf(detail: any): any[] {
const dd = parseDrawing(detail.drawing_data)
return dd.sm.map(s => JSON.parse(JSON.stringify(s)))
}
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),
}]
ElMessage.success(`已加入:${t.name || `样板#${t.id}`}`)
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>