Files
menchuang/frontend/src/views/CombineView.vue
T
HHY 4b52767654 fix: 修复 3D NaN 崩溃/样品图不刷新/画布点选失效
- replayShape 段过滤补 Y 轴有限值校验(此前仅校验 X)
- inferSashGlyphs bdRect 增加有限值守卫
- WindoorThreed 逐网格有限值防护 + maxDim 守卫, 杜绝 NaN 网格
- CombineCanvas shapePts 过滤非有限点, 修复包围盒污染导致的点选失效
- 组合画布引入 rev 版本号: 原地变异几何后强制 3D 预览重建
  (修复尺寸编辑后样品图不变化)
2026-09-21 02:26:08 -07:00

315 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<el-row :gutter="16" class="combine-page">
<!-- 左:样板库(可拖拽) -->
<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" 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>
</div>
</el-scrollbar>
</el-card>
</el-col>
<!-- 中:组合画布 -->
<el-col :xs="24" :md="13">
<el-card>
<template #header>
<div class="head-row">
<span>组合画布({{ units.length }} 个单元 · 拖动摆放 · 边缘自动吸附)</span>
<div style="display:flex; gap:8px; align-items:center">
<el-tooltip content="选中单元逆时针旋转90°" placement="top">
<el-button size="small" :disabled="!selectedUid" @click="canvasRef?.rotateUnit(selectedUid!, -90)">⟲ 90°</el-button>
</el-tooltip>
<el-tooltip content="选中单元顺时针旋转90°" placement="top">
<el-button size="small" :disabled="!selectedUid" @click="canvasRef?.rotateUnit(selectedUid!, 90)">⟳ 90°</el-button>
</el-tooltip>
<el-tooltip content="选中单元水平镜像" placement="top">
<el-button size="small" :disabled="!selectedUid" @click="canvasRef?.mirrorUnit(selectedUid!)">⇋ 镜像</el-button>
</el-tooltip>
<template v-if="selectedUid">
<span style="font-size:12px; color:#909399">尺寸</span>
<el-input-number v-model="selSize.w" :min="300" :step="10" size="small" controls-position="right"
style="width:96px" @change="onSelResize" />
<el-input-number v-model="selSize.h" :min="300" :step="10" size="small" controls-position="right"
style="width:96px" @change="onSelResize" />
</template>
<el-divider direction="vertical" />
<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" @drop="onDrop" style="position:relative">
<CombineCanvas ref="canvasRef" :shapes="shapes" @select="onSelect" @change="onCanvasChange" />
</div>
<div class="save-bar">
<el-input v-model="saveName" placeholder="组合样板名称(如:三扇组合窗)" style="flex:1" />
<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="!units.length" description="拖入或点击样板开始" :image-size="60" />
<transition-group name="list" tag="div">
<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">{{ 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">
/**
* 样板组合工场(画布形式,交互对齐原站画图编辑器:库面板拖拽 → 画布摆放 → 自由拖移 + 边缘吸附)
* 数据模型:shapes = 多形状 drawing_data 的 sm[](每形状打 __uid 单元标记);
* 组合结果与渲染/报价/AI 炫图/3D 全链路兼容,可另存为"我的样板"。
*/
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { api } from '../api'
import { parseDrawing } from '../types/drawing'
import CombineCanvas from '../components/CombineCanvas.vue'
import WindoorThreed from '../components/WindoorThreed.vue'
interface UnitMeta { uid: number; name: string; pic: string; templateId: number }
const lib = ref<any[]>([])
const keyword = ref('')
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 rev = ref(0)
const combinedJson = computed(() => {
void rev.value // 画布原地变异几何后通过版本号强制重算(驱动 3D 预览重建)
if (!shapes.value.length) return ''
return JSON.stringify({ s: { cs: 60, cjs: 100 }, cp: [], sm: shapes.value,
epi: [], sbf: [], wlc: '', wall: [], scale: 0, note: [], rev: rev.value })
})
// ---------- 样板库 ----------
async function loadLib() {
lib.value = await api<any>('get', `/api/windoor/templates?page=1&page_size=254&is_default=1&name=${keyword.value}`)
}
// ---------- 加入样板 ----------
function shapesOf(detail: any): any[] {
const dd = parseDrawing(detail.drawing_data)
return dd.sm.map(s => JSON.parse(JSON.stringify(s)))
}
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 translate(shapes: any[], dx: number, dy: number) {
const mv = (p: any) => { p.x += dx; p.y += dy }
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 registerUnit(detail: any, uid: number, name: string) {
units.value = [...units.value, { uid, name, pic: detail.pic_url || '', templateId: detail.id }]
}
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
}
async function addTemplate(t: any) {
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 onDragStart(e: DragEvent, t: any) {
e.dataTransfer?.setData('template_id', String(t.id))
e.dataTransfer!.effectAllowed = 'copy'
}
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; syncSelSize() }
const selSize = reactive({ w: 0, h: 0 })
function syncSelSize() {
if (selectedUid.value === null) { selSize.w = selSize.h = 0; return }
const bb = bbOf(shapes.value.filter(s => s.__uid === selectedUid.value))
if (bb) { selSize.w = Math.round(bb.x1 - bb.x0); selSize.h = Math.round(bb.y1 - bb.y0) }
}
function onCanvasChange() { refreshMeta(); syncSelSize(); rev.value++ }
function onSelResize() {
if (selSize.w >= 300 && selSize.h >= 300) canvasRef.value?.resizeSelected(selSize.w, selSize.h)
}
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() {
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 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: `组合样板(${units.value.map(u => u.name).join(' + ')})`,
})
ElMessage.success(`已保存为我的样板 #${d.id},可在「样板库 → 我的样板」使用`)
} finally { saving.value = false }
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Delete' && selectedUid.value !== null) { deleteSelected(); return }
// 方向键微调选中单元(1mm,Shift=10mm)
if (selectedUid.value !== null && e.key.startsWith('Arrow')) {
const step = e.shiftKey ? 10 : 1
const d = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, step], ArrowDown: [0, -step] }[e.key] as [number, number] | undefined
if (d) { e.preventDefault(); canvasRef.value?.nudgeSelected(d[0], d[1]) }
}
}
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: grab; text-align: center; transition: all .15s; }
.lib-item:hover { border-color: var(--el-color-primary); box-shadow: var(--shadow-card); }
.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: 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; 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; }
.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>