feat: v1.3 样板组合升级为画布形式
- 新组件 CombineCanvas: 交互对齐原站画图编辑器 库面板可拖拽(dragstart/drop) → 画布落点摆放 自由拖移(同uid单元整体移动) + 边缘吸附对齐(12px容差+红色辅助线) 点选高亮/底边对齐/Del键删除/平移缩放/双击复位 - CombineView 重构: 拖放+点击双入口, 保留2D编辑与3D预览切换 - 另存我的样板时剥离内部标记字段, 保持标准 drawing_data 格式 - 原'顺序拼接'模式由画布自由摆放取代
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
# CHANGELOG
|
# 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: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: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)
|
- [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)
|
||||||
|
|||||||
@@ -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
@@ -1,14 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-row :gutter="16" class="combine-page">
|
<el-row :gutter="16" class="combine-page">
|
||||||
<!-- 左:样板库 -->
|
<!-- 左:样板库(可拖拽) -->
|
||||||
<el-col :xs="24" :md="6">
|
<el-col :xs="24" :md="5">
|
||||||
<el-card header="样板库(点击加入组合)">
|
<el-card header="样板库(拖入画布或点击加入)">
|
||||||
<el-input v-model="keyword" placeholder="搜索样板" clearable size="small" @keyup.enter="loadLib" @clear="loadLib">
|
<el-input v-model="keyword" placeholder="搜索样板" clearable size="small" @keyup.enter="loadLib" @clear="loadLib">
|
||||||
<template #prefix><el-icon><Search /></el-icon></template>
|
<template #prefix><el-icon><Search /></el-icon></template>
|
||||||
</el-input>
|
</el-input>
|
||||||
<el-scrollbar height="56vh" style="margin-top:10px">
|
<el-scrollbar height="56vh" style="margin-top:10px">
|
||||||
<div class="lib-grid">
|
<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" />
|
<el-image :src="t.pic_url" fit="contain" lazy class="lib-pic" />
|
||||||
<span class="lib-name">{{ t.name || `#${t.id}` }}</span>
|
<span class="lib-name">{{ t.name || `#${t.id}` }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -17,253 +18,256 @@
|
|||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
|
|
||||||
<!-- 中:组合预览 -->
|
<!-- 中:组合画布 -->
|
||||||
<el-col :xs="24" :md="12">
|
<el-col :xs="24" :md="13">
|
||||||
<el-card>
|
<el-card>
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="head-row">
|
<div class="head-row">
|
||||||
<span>组合预览({{ items.length }} 个单元)</span>
|
<span>组合画布({{ units.length }} 个单元 · 拖动摆放 · 边缘自动吸附)</span>
|
||||||
<div style="display:flex; gap:8px; align-items:center">
|
<div style="display:flex; gap:8px; align-items:center">
|
||||||
<el-tooltip content="开启后,新加入的样板自动缩放到组合高度" placement="top">
|
<el-button size="small" :disabled="!selectedUid" @click="alignBottom">底边对齐</el-button>
|
||||||
<span style="font-size:12px; color:#909399">等高对齐</span>
|
<el-button size="small" :disabled="!selectedUid" type="danger" plain @click="deleteSelected">删除单元</el-button>
|
||||||
</el-tooltip>
|
<el-button size="small" :disabled="!shapes.length" @click="clearAll">清空</el-button>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div class="canvas-box">
|
<div class="canvas-box" @drop="onDrop" style="position:relative">
|
||||||
<WindoorCanvas v-if="combinedJson && viewMode === '2d'" :drawing-data="combinedJson" />
|
<CombineCanvas ref="canvasRef" :shapes="shapes" @select="onSelect" @change="refreshMeta" />
|
||||||
<WindoorThreed v-else-if="combinedJson && viewMode === '3d'" :drawing-data="combinedJson" />
|
|
||||||
<el-empty v-else description="从左侧样板库点击加入样板开始组合" />
|
|
||||||
</div>
|
</div>
|
||||||
<!-- 保存 -->
|
|
||||||
<div class="save-bar">
|
<div class="save-bar">
|
||||||
<el-input v-model="saveName" placeholder="组合样板名称(如:三扇组合窗)" style="flex:1" />
|
<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>
|
@click="saveAsTemplate">另存为我的样板</el-button>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
|
|
||||||
<!-- 右:组合清单 -->
|
<!-- 右:单元清单 + 3D 预览 -->
|
||||||
<el-col :xs="24" :md="6">
|
<el-col :xs="24" :md="6">
|
||||||
<el-card header="组合单元">
|
<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">
|
<transition-group name="list" tag="div">
|
||||||
<div v-for="(it, i) in items" :key="it.uid" class="unit">
|
<div v-for="u in units" :key="u.uid" class="unit" :class="{ pick: u.uid === selectedUid }"
|
||||||
<el-image :src="it.pic" fit="contain" class="unit-pic" />
|
@click="selectUnit(u.uid)">
|
||||||
|
<el-image :src="u.pic" fit="contain" class="unit-pic" />
|
||||||
<div class="unit-info">
|
<div class="unit-info">
|
||||||
<div class="unit-name">{{ it.name }}</div>
|
<div class="unit-name">{{ u.name }}</div>
|
||||||
<div class="unit-size">{{ it.w }} × {{ it.h }} mm</div>
|
<div class="unit-size">{{ u.w }} × {{ u.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>
|
||||||
|
<el-button size="small" text type="danger" @click.stop="deleteUnit(u.uid)"><el-icon><Delete /></el-icon></el-button>
|
||||||
</div>
|
</div>
|
||||||
</transition-group>
|
</transition-group>
|
||||||
</el-card>
|
</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-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* 样板组合工场:把样板库中的窗型作为子组件拼接为组合窗。
|
* 样板组合工场(画布形式,交互对齐原站画图编辑器:库面板拖拽 → 画布摆放 → 自由拖移 + 边缘吸附)
|
||||||
* 产物为标准 drawing_data(多形状 sm[],紧贴排布、可等高对齐),
|
* 数据模型:shapes = 多形状 drawing_data 的 sm[](每形状打 __uid 单元标记);
|
||||||
* 与渲染/报价/AI 炫图/3D 全链路兼容;可另存为"我的样板"用于下单。
|
* 组合结果与渲染/报价/AI 炫图/3D 全链路兼容,可另存为"我的样板"。
|
||||||
*/
|
*/
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, onBeforeUnmount, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
import { parseDrawing, type Segment } from '../types/drawing'
|
import { parseDrawing } from '../types/drawing'
|
||||||
import WindoorCanvas from '../components/WindoorCanvas.vue'
|
import CombineCanvas from '../components/CombineCanvas.vue'
|
||||||
import WindoorThreed from '../components/WindoorThreed.vue'
|
import WindoorThreed from '../components/WindoorThreed.vue'
|
||||||
|
|
||||||
const viewMode = ref<'2d' | '3d'>('2d')
|
interface UnitMeta { uid: number; name: string; pic: string; templateId: number }
|
||||||
|
|
||||||
interface CombineItem {
|
|
||||||
uid: number
|
|
||||||
templateId: number
|
|
||||||
name: string
|
|
||||||
pic: string
|
|
||||||
raw: any // 原始 drawing_data 对象
|
|
||||||
w: number
|
|
||||||
h: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const lib = ref<any[]>([])
|
const lib = ref<any[]>([])
|
||||||
const keyword = ref('')
|
const keyword = ref('')
|
||||||
const items = ref<CombineItem[]>([])
|
const shapes = ref<any[]>([])
|
||||||
const equalHeight = ref(true)
|
const units = ref<UnitMeta[]>([])
|
||||||
|
const selectedUid = ref<number | null>(null)
|
||||||
const saveName = ref('')
|
const saveName = ref('')
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
const canvasRef = ref()
|
||||||
let uidSeq = 1
|
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() {
|
async function loadLib() {
|
||||||
lib.value = await api<any>('get', `/api/windoor/templates?page=1&page_size=254&is_default=1&name=${keyword.value}`)
|
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 }[] {
|
function shapesOf(detail: any): any[] {
|
||||||
const out: { x: number; y: number }[] = []
|
const dd = parseDrawing(detail.drawing_data)
|
||||||
for (const r of (shape.polygon?.polygon ?? [])) {
|
return dd.sm.map(s => JSON.parse(JSON.stringify(s)))
|
||||||
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 bbOf(shapes: any[]) {
|
||||||
function groupBBox(shapes: any[]) {
|
const pts = shapes.flatMap(s => (s.polygon?.polygon ?? []).flat()
|
||||||
const pts = shapes.flatMap(shapePts)
|
.flatMap((seg: any) => [seg.ps, seg.pe]).filter((p: any) => p && Number.isFinite(p.x)))
|
||||||
if (!pts.length) return null
|
if (!pts.length) return null
|
||||||
const xs = pts.map(p => p.x), ys = pts.map(p => p.y)
|
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) }
|
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) {
|
||||||
function translateShape(shape: any, dx: number, dy: number) {
|
|
||||||
const mv = (p: any) => { p.x += dx; p.y += dy }
|
const mv = (p: any) => { p.x += dx; p.y += dy }
|
||||||
for (const r of (shape.polygon?.polygon ?? [])) {
|
for (const s of shapes) {
|
||||||
for (const seg of r as Segment[]) { mv(seg.ps); mv(seg.pe) }
|
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 sp of (shape.mm?.sp ?? [])) mv(sp.line.pt)
|
for (const sub of (s.sm ?? [])) if (sub.bd) { mv(sub.bd.from); mv(sub.bd.to) }
|
||||||
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) {
|
function registerUnit(detail: any, uid: number, name: string) {
|
||||||
const sc = (p: any) => { p.y = anchorY + (p.y - anchorY) * factor }
|
units.value = [...units.value, { uid, name, pic: detail.pic_url || '', templateId: detail.id }]
|
||||||
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 addShapesAt(detail: any, cx: number | null, cy: number | null, name: string) {
|
||||||
function buildCombined(): any | null {
|
const uid = uidSeq++
|
||||||
if (!items.value.length) return null
|
const newShapes = shapesOf(detail)
|
||||||
// 深拷贝各单元形状
|
if (!newShapes.length) { ElMessage.warning('该样板没有可用的几何数据'); return false }
|
||||||
const groups = items.value.map(it => {
|
const bb = bbOf(newShapes)!
|
||||||
const dd = parseDrawing(JSON.parse(JSON.stringify(it.raw)))
|
let dx = 0, dy = 0
|
||||||
return dd.sm.map(s => JSON.parse(JSON.stringify(s)))
|
if (cx !== null && cy !== null) {
|
||||||
})
|
dx = cx - (bb.x0 + bb.x1) / 2
|
||||||
// 参考高度:第一个单元
|
dy = cy - (bb.y0 + bb.y1) / 2
|
||||||
const bb0 = groupBBox(groups[0])
|
} else {
|
||||||
if (!bb0) return null
|
const cur = bbOf(shapes.value)
|
||||||
const refH = bb0.y1 - bb0.y0
|
dx = cur ? cur.x1 - bb.x0 : 0
|
||||||
|
dy = cur ? cur.y0 - bb.y0 : 0
|
||||||
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: [],
|
|
||||||
}
|
}
|
||||||
|
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) {
|
async function addTemplate(t: any) {
|
||||||
const raw = await api<any>('get', `/api/windoor/templates/${t.id}`)
|
const detail = await api<any>('get', `/api/windoor/templates/${t.id}`)
|
||||||
const dd = parseDrawing(raw.drawing_data)
|
if (addShapesAt(detail, null, null, t.name || `样板#${t.id}`))
|
||||||
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}`}`)
|
ElMessage.success(`已加入:${t.name || `样板#${t.id}`}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function move(i: number, dir: -1 | 1) {
|
function onDragStart(e: DragEvent, t: any) {
|
||||||
const j = i + dir
|
e.dataTransfer?.setData('template_id', String(t.id))
|
||||||
if (j < 0 || j >= items.value.length) return
|
e.dataTransfer!.effectAllowed = 'copy'
|
||||||
const arr = [...items.value]
|
|
||||||
;[arr[i], arr[j]] = [arr[j], arr[i]]
|
|
||||||
items.value = arr
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeAt(i: number) {
|
async function onDrop(e: DragEvent) {
|
||||||
items.value = items.value.filter((_, k) => k !== i)
|
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() {
|
function clearAll() {
|
||||||
items.value = []
|
shapes.value = []
|
||||||
|
units.value = []
|
||||||
|
selectedUid.value = null
|
||||||
saveName.value = ''
|
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() {
|
async function saveAsTemplate() {
|
||||||
const dd = buildCombined()
|
const json = canvasRef.value?.exportDrawing()
|
||||||
if (!dd) return
|
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
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
const d = await api<any>('post', '/api/windoor/templates', {
|
const d = await api<any>('post', '/api/windoor/templates', {
|
||||||
name: saveName.value.trim(),
|
name: saveName.value.trim(),
|
||||||
drawing_data: JSON.stringify(dd),
|
drawing_data: JSON.stringify(dd),
|
||||||
catelog_id: null,
|
catelog_id: null,
|
||||||
note: `组合样板(${items.value.map(i => i.name).join(' + ')})`,
|
note: `组合样板(${units.value.map(u => u.name).join(' + ')})`,
|
||||||
})
|
})
|
||||||
ElMessage.success(`已保存为我的样板 #${d.id},可在「样板库 → 我的样板」使用`)
|
ElMessage.success(`已保存为我的样板 #${d.id},可在「样板库 → 我的样板」使用`)
|
||||||
} finally { saving.value = false }
|
} 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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.combine-page { min-height: 100%; }
|
.combine-page { min-height: 100%; }
|
||||||
.head-row { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px; }
|
.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-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;
|
.lib-item { border: 1px solid #e4e7ed; border-radius: 8px; padding: 6px; cursor: grab; text-align: center; transition: all .15s; }
|
||||||
transition: all .15s; }
|
|
||||||
.lib-item:hover { border-color: var(--el-color-primary); box-shadow: var(--shadow-card); }
|
.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; }
|
.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; }
|
.save-bar { display: flex; gap: 10px; margin-top: 12px; }
|
||||||
.unit { display: flex; align-items: center; gap: 8px; border: 1px solid #e4e7ed; border-radius: 8px;
|
.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-pic { width: 64px; height: 44px; flex-shrink: 0; }
|
||||||
.unit-info { flex: 1; min-width: 0; }
|
.unit-info { flex: 1; min-width: 0; }
|
||||||
.unit-name { font-size: 13px; color: #303133; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.unit-name { font-size: 13px; color: #303133; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.unit-size { font-size: 11px; color: #909399; }
|
.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-active, .list-leave-active { transition: all .2s; }
|
||||||
.list-enter-from, .list-leave-to { opacity: 0; transform: translateX(12px); }
|
.list-enter-from, .list-leave-to { opacity: 0; transform: translateX(12px); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user