feat: v1.2 样板组合工场+组合3D渲染+菜单功能分组
- 样板组合(/combine): 样板库作为子组件, 点击加入组合 - 多单元紧贴排布(平移归一), 等高对齐(以首单元为参考Y向缩放) - 组合单元管理: 删除/左右调序/清空, 列表动画 - 组合结果为标准多形状 drawing_data, 渲染/报价/AI炫图/3D全链路兼容 - 组合预览 2D/3D 双模式(Three.js 直接渲染组合 sm[]) - 侧边栏菜单按业务分组: 销售业务/设计制图/生产供应/系统 - 组合算法经 Node 几何验证: 6形状无缝拼接, 高度0误差对齐
This commit is contained in:
@@ -21,6 +21,7 @@ const router = createRouter({
|
||||
{ path: 'measure', component: () => import('../views/MeasureView.vue'), meta: { title: '量尺管理' } },
|
||||
{ path: 'aftersale', component: () => import('../views/AfterSaleView.vue'), meta: { title: '售后管理' } },
|
||||
{ path: 'templates', component: () => import('../views/TemplatesView.vue'), meta: { title: '样板库' } },
|
||||
{ path: 'combine', component: () => import('../views/CombineView.vue'), meta: { title: '样板组合' } },
|
||||
{ path: 'products', component: () => import('../views/ProductsView.vue'), meta: { title: '产品管理' } },
|
||||
{ path: 'warehouse', component: () => import('../views/WarehouseView.vue'), meta: { title: '仓库库存' } },
|
||||
{ path: 'optimize', component: () => import('../views/OptimizeView.vue'), meta: { title: '优化下料' } },
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
<template>
|
||||
<el-row :gutter="16" class="combine-page">
|
||||
<!-- 左:样板库 -->
|
||||
<el-col :xs="24" :md="6">
|
||||
<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)">
|
||||
<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="12">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="head-row">
|
||||
<span>组合预览({{ items.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>
|
||||
</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>
|
||||
<!-- 保存 -->
|
||||
<div class="save-bar">
|
||||
<el-input v-model="saveName" placeholder="组合样板名称(如:三扇组合窗)" style="flex:1" />
|
||||
<el-button type="primary" :disabled="!items.length || !saveName.trim()" :loading="saving"
|
||||
@click="saveAsTemplate">另存为我的样板</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- 右:组合清单 -->
|
||||
<el-col :xs="24" :md="6">
|
||||
<el-card header="组合单元">
|
||||
<el-empty v-if="!items.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 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>
|
||||
</div>
|
||||
</transition-group>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 样板组合工场:把样板库中的窗型作为子组件拼接为组合窗。
|
||||
* 产物为标准 drawing_data(多形状 sm[],紧贴排布、可等高对齐),
|
||||
* 与渲染/报价/AI 炫图/3D 全链路兼容;可另存为"我的样板"用于下单。
|
||||
*/
|
||||
import { computed, onMounted, 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 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
|
||||
}
|
||||
|
||||
const lib = ref<any[]>([])
|
||||
const keyword = ref('')
|
||||
const items = ref<CombineItem[]>([])
|
||||
const equalHeight = ref(true)
|
||||
const saveName = ref('')
|
||||
const saving = ref(false)
|
||||
let uidSeq = 1
|
||||
|
||||
// ---------- 样板库 ----------
|
||||
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 groupBBox(shapes: any[]) {
|
||||
const pts = shapes.flatMap(shapePts)
|
||||
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) {
|
||||
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) }
|
||||
}
|
||||
}
|
||||
|
||||
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 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: [],
|
||||
}
|
||||
}
|
||||
|
||||
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}`}`)
|
||||
}
|
||||
|
||||
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 removeAt(i: number) {
|
||||
items.value = items.value.filter((_, k) => k !== i)
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
items.value = []
|
||||
saveName.value = ''
|
||||
}
|
||||
|
||||
async function saveAsTemplate() {
|
||||
const dd = buildCombined()
|
||||
if (!dd) return
|
||||
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(' + ')})`,
|
||||
})
|
||||
ElMessage.success(`已保存为我的样板 #${d.id},可在「样板库 → 我的样板」使用`)
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
|
||||
onMounted(loadLib)
|
||||
</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:hover { border-color: var(--el-color-primary); box-shadow: var(--shadow-card); }
|
||||
.lib-pic { width: 100%; height: 64px; }
|
||||
.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; }
|
||||
.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; }
|
||||
.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; }
|
||||
.list-enter-active, .list-leave-active { transition: all .2s; }
|
||||
.list-enter-from, .list-leave-to { opacity: 0; transform: translateX(12px); }
|
||||
</style>
|
||||
@@ -7,25 +7,38 @@
|
||||
text-color="#bfc9d4" active-text-color="#ffffff" :collapse-transition="false">
|
||||
<el-menu-item index="/dashboard"><el-icon><Odometer /></el-icon>工作台</el-menu-item>
|
||||
<el-menu-item index="/chart"><el-icon><TrendCharts /></el-icon>报表统计</el-menu-item>
|
||||
<el-menu-item index="/orders"><el-icon><Tickets /></el-icon>订单管理</el-menu-item>
|
||||
<el-menu-item index="/sunny"><el-icon><OfficeBuilding /></el-icon>阳光房</el-menu-item>
|
||||
<el-menu-item index="/quotations"><el-icon><PriceTag /></el-icon>报价单</el-menu-item>
|
||||
<el-menu-item index="/contracts"><el-icon><Document /></el-icon>合同管理</el-menu-item>
|
||||
<el-menu-item index="/customers"><el-icon><User /></el-icon>客户管理</el-menu-item>
|
||||
<el-menu-item index="/measure"><el-icon><ScaleToOriginal /></el-icon>量尺管理</el-menu-item>
|
||||
<el-menu-item index="/aftersale"><el-icon><Service /></el-icon>售后管理</el-menu-item>
|
||||
<el-menu-item index="/templates"><el-icon><Grid /></el-icon>样板库</el-menu-item>
|
||||
<el-menu-item index="/products"><el-icon><Goods /></el-icon>产品管理</el-menu-item>
|
||||
<el-menu-item index="/warehouse"><el-icon><Box /></el-icon>仓库库存</el-menu-item>
|
||||
<el-menu-item index="/optimize"><el-icon><Scissor /></el-icon>优化下料</el-menu-item>
|
||||
<el-menu-item index="/cut_solution"><el-icon><SetUp /></el-icon>下料方案</el-menu-item>
|
||||
<el-menu-item index="/rendering"><el-icon><Picture /></el-icon>效果图渲染</el-menu-item>
|
||||
<el-menu-item index="/ai_render"><el-icon><MagicStick /></el-icon>AI 炫图</el-menu-item>
|
||||
<el-menu-item index="/threed"><el-icon><View /></el-icon>3D 展示</el-menu-item>
|
||||
<el-menu-item index="/printer"><el-icon><Printer /></el-icon>打印中心</el-menu-item>
|
||||
<el-menu-item index="/devices"><el-icon><Cpu /></el-icon>设备管理</el-menu-item>
|
||||
<el-menu-item index="/market"><el-icon><ShoppingCart /></el-icon>商城</el-menu-item>
|
||||
<el-menu-item index="/guide"><el-icon><QuestionFilled /></el-icon>使用指南</el-menu-item>
|
||||
|
||||
<el-menu-item-group title="销售业务">
|
||||
<el-menu-item index="/orders"><el-icon><Tickets /></el-icon>订单管理</el-menu-item>
|
||||
<el-menu-item index="/sunny"><el-icon><OfficeBuilding /></el-icon>阳光房</el-menu-item>
|
||||
<el-menu-item index="/quotations"><el-icon><PriceTag /></el-icon>报价单</el-menu-item>
|
||||
<el-menu-item index="/contracts"><el-icon><Document /></el-icon>合同管理</el-menu-item>
|
||||
<el-menu-item index="/customers"><el-icon><User /></el-icon>客户管理</el-menu-item>
|
||||
<el-menu-item index="/aftersale"><el-icon><Service /></el-icon>售后管理</el-menu-item>
|
||||
</el-menu-item-group>
|
||||
|
||||
<el-menu-item-group title="设计制图">
|
||||
<el-menu-item index="/templates"><el-icon><Grid /></el-icon>样板库</el-menu-item>
|
||||
<el-menu-item index="/combine"><el-icon><Connection /></el-icon>样板组合</el-menu-item>
|
||||
<el-menu-item index="/rendering"><el-icon><Picture /></el-icon>效果图渲染</el-menu-item>
|
||||
<el-menu-item index="/ai_render"><el-icon><MagicStick /></el-icon>AI 炫图</el-menu-item>
|
||||
<el-menu-item index="/threed"><el-icon><View /></el-icon>3D 展示</el-menu-item>
|
||||
</el-menu-item-group>
|
||||
|
||||
<el-menu-item-group title="生产供应">
|
||||
<el-menu-item index="/measure"><el-icon><ScaleToOriginal /></el-icon>量尺管理</el-menu-item>
|
||||
<el-menu-item index="/optimize"><el-icon><Scissor /></el-icon>优化下料</el-menu-item>
|
||||
<el-menu-item index="/cut_solution"><el-icon><SetUp /></el-icon>下料方案</el-menu-item>
|
||||
<el-menu-item index="/printer"><el-icon><Printer /></el-icon>打印中心</el-menu-item>
|
||||
<el-menu-item index="/warehouse"><el-icon><Box /></el-icon>仓库库存</el-menu-item>
|
||||
<el-menu-item index="/products"><el-icon><Goods /></el-icon>产品管理</el-menu-item>
|
||||
<el-menu-item index="/devices"><el-icon><Cpu /></el-icon>设备管理</el-menu-item>
|
||||
</el-menu-item-group>
|
||||
|
||||
<el-menu-item-group title="系统">
|
||||
<el-menu-item index="/market"><el-icon><ShoppingCart /></el-icon>商城</el-menu-item>
|
||||
<el-menu-item index="/guide"><el-icon><QuestionFilled /></el-icon>使用指南</el-menu-item>
|
||||
</el-menu-item-group>
|
||||
</el-menu>
|
||||
</el-scrollbar>
|
||||
</el-aside>
|
||||
|
||||
Reference in New Issue
Block a user