feat: 前后端全量开发 v0.2
后端: - 客户模块 CRUD+搜索 (api/services/repositories 分层) - 合同模块: FG单号规则/意向单转正式单/订单创建自动快照客户信息 - 优化下料: FFD首递减装箱算法(利用率统计/余料/未排入清单) 前端 (Vue3+TS+Element Plus): - 7页面: 登录/工作台/订单中心/合同/客户/样板库/优化下料 - 自研二维窗型画布渲染器: 解析drawing_data渲染玻璃区域/外框/ 扇开向标记/尺寸标注, 支持缩放平移复位 - 样板库与订单联动(用此样板下单自动带图纸) - axios统一envelope拦截/登录守卫/付费墙提示 工程: - SPA history 回退路由, 构建产物挂载后端 - 全接口回归 6/6 通过
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
<template>
|
||||
<div class="canvas-wrap" ref="wrapRef">
|
||||
<canvas ref="cvRef" @wheel.prevent="onWheel" @mousedown="onDown" @mousemove="onMove"
|
||||
@mouseup="onUp" @mouseleave="onUp" @dblclick="reset" />
|
||||
<div class="hud">
|
||||
<span v-if="info">{{ info }}</span>
|
||||
<el-button size="small" text @click="reset">重置视图</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 二维窗型画布渲染器(自研实现)
|
||||
* 读取 shapeManager 序列化的 drawing_data:Frame→玻璃区域多边形 + 扇开向 + 尺寸标注
|
||||
* 支持滚轮缩放、拖拽平移、双击复位
|
||||
*/
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import {
|
||||
bboxOf, parseDrawing, regionPoints,
|
||||
type DrawingData, type FrameShape, type Pt, type SashShape,
|
||||
} from '../types/drawing'
|
||||
|
||||
const props = defineProps<{ drawingData: string | object }>()
|
||||
const cvRef = ref<HTMLCanvasElement>()
|
||||
const wrapRef = ref<HTMLDivElement>()
|
||||
const info = ref('')
|
||||
|
||||
let view = { scale: 1, cx: 0, cy: 0 } // cx/cy=模型坐标中心
|
||||
let drag: { x: number; y: number } | null = null
|
||||
let dd: DrawingData | null = null
|
||||
let modelBox = { x0: 0, y0: 0, x1: 100, y1: 100 }
|
||||
|
||||
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.clearRect(0, 0, cv.width, cv.height)
|
||||
if (!dd) return
|
||||
|
||||
const W = wrap.clientWidth, H = wrap.clientHeight
|
||||
// 自适应:模型坐标 → 屏幕(Y 轴翻转)
|
||||
const pad = 48
|
||||
const bw = modelBox.x1 - modelBox.x0, bh = modelBox.y1 - modelBox.y0
|
||||
const fit = Math.min((W - pad * 2) / (bw || 1), (H - pad * 2) / (bh || 1))
|
||||
const scale = fit * view.scale
|
||||
const midX = (modelBox.x0 + modelBox.x1) / 2, midY = (modelBox.y0 + modelBox.y1) / 2
|
||||
const tx = (x: number) => W / 2 + (x - midX) * scale + view.cx
|
||||
const ty = (y: number) => H / 2 - (y - midY) * scale + view.cy
|
||||
|
||||
// 全局样式
|
||||
const glass = '#d3ddda', glassAlpha = 'rgba(211,221,218,0.6)'
|
||||
const profile = '#5c6166'
|
||||
ctx.lineJoin = 'round'
|
||||
|
||||
for (const shape of dd.sm) drawFrame(ctx, shape, tx, ty, { glass, glassAlpha, profile })
|
||||
|
||||
info.value = dd.sm.length > 1 ? `窗型 · ${dd.sm.length} 个窗体组合` : '窗型'
|
||||
}
|
||||
|
||||
function drawFrame(
|
||||
ctx: CanvasRenderingContext2D, shape: FrameShape,
|
||||
tx: (x: number) => number, ty: (y: number) => number,
|
||||
color: { glass: string; glassAlpha: string; profile: string },
|
||||
) {
|
||||
const regionsRaw = shape.polygon?.polygon ?? []
|
||||
const regions = regionsRaw.map(regionPoints).filter(pts => pts.length >= 3)
|
||||
const cm = shape.cm ?? {}
|
||||
|
||||
// ---- 玻璃区域 ----
|
||||
for (const pts of regions) {
|
||||
ctx.beginPath()
|
||||
pts.forEach((p, i) => (i ? ctx.lineTo(tx(p.x), ty(p.y)) : ctx.moveTo(tx(p.x), ty(p.y))))
|
||||
ctx.closePath()
|
||||
ctx.fillStyle = cm.g ?? color.glassAlpha
|
||||
ctx.fill()
|
||||
ctx.strokeStyle = cm.bn ?? color.profile
|
||||
ctx.lineWidth = 1.6
|
||||
ctx.stroke()
|
||||
// 内描边示意型材厚度
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.65)'
|
||||
ctx.lineWidth = 0.8
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// ---- 外框(全部区域的联合包围盒,逐段加粗)----
|
||||
if (regions.length) {
|
||||
const all = regions.flat()
|
||||
const bb = bboxOf(all)
|
||||
const x0 = tx(bb.x0), y0 = ty(bb.y1), x1 = tx(bb.x1), y1 = ty(bb.y0)
|
||||
const ew = shape.fm?.ew ?? [60, 60, 60, 60]
|
||||
const avg = ew.reduce((a, b) => a + b, 0) / ew.length
|
||||
ctx.strokeStyle = cm.bn ?? color.profile
|
||||
ctx.lineWidth = 3.2
|
||||
ctx.strokeRect(x0, y0, x1 - x0, y1 - y0)
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.5)'
|
||||
ctx.lineWidth = 1
|
||||
const inset = Math.max(2.5, avg * 0.018)
|
||||
ctx.strokeRect(x0 + inset, y0 + inset, (x1 - x0) - inset * 2, (y1 - y0) - inset * 2)
|
||||
void glass // 保留引用
|
||||
}
|
||||
|
||||
// ---- 扇(开向标记)----
|
||||
for (const sub of (shape.sm ?? []) as SashShape[]) {
|
||||
const bd = sub.bd
|
||||
if (!bd || bd.hidden) continue
|
||||
const bx0 = tx(Math.min(bd.from.x, bd.to.x)), bx1 = tx(Math.max(bd.from.x, bd.to.x))
|
||||
const by0 = ty(Math.max(bd.from.y, bd.to.y)), by1 = ty(Math.min(bd.from.y, bd.to.y))
|
||||
const w = bx1 - bx0, h = by1 - by0
|
||||
if (w < 4 || h < 4) continue
|
||||
ctx.save()
|
||||
// 扇框(虚线内框)
|
||||
ctx.setLineDash([5, 4])
|
||||
ctx.strokeStyle = 'rgba(70,76,82,0.9)'
|
||||
ctx.lineWidth = 1.3
|
||||
ctx.strokeRect(bx0 + 2, by0 + 2, w - 4, h - 4)
|
||||
ctx.setLineDash([])
|
||||
// 开向三角:od 为角度(度),屏幕系 y 翻转
|
||||
const ang = ((sub.od ?? 0) * Math.PI) / 180
|
||||
const cx = (bx0 + bx1) / 2, cy = (by0 + by1) / 2
|
||||
const L = Math.min(w, h) * 0.32
|
||||
const ax = cx + Math.cos(ang) * L, ay = cy - Math.sin(ang) * L
|
||||
// 垂直方向的两翼
|
||||
const wx = Math.cos(ang + Math.PI / 2) * L * 0.38
|
||||
const wy = -Math.sin(ang + Math.PI / 2) * L * 0.38
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(ax, ay)
|
||||
ctx.lineTo(cx - wx, cy - wy)
|
||||
ctx.lineTo(cx + wx, cy + wy)
|
||||
ctx.closePath()
|
||||
ctx.fillStyle = 'rgba(70,76,82,0.25)'
|
||||
ctx.fill()
|
||||
ctx.strokeStyle = 'rgba(70,76,82,0.95)'
|
||||
ctx.lineWidth = 1.4
|
||||
ctx.stroke()
|
||||
if (sub.isDoor) {
|
||||
ctx.fillStyle = 'rgba(70,76,82,0.85)'
|
||||
ctx.font = '10px sans-serif'
|
||||
ctx.fillText('门', cx - 6, by1 - 6)
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
// ---- 总尺寸标注(宽/高)----
|
||||
if (regions.length) {
|
||||
const all = regions.flat()
|
||||
const bb = bboxOf(all)
|
||||
const x0 = tx(bb.x0), x1 = tx(bb.x1), y0 = ty(bb.y1), y1 = ty(bb.y0)
|
||||
const wmm = Math.round(bb.x1 - bb.x0), hmm = Math.round(bb.y1 - bb.y0)
|
||||
ctx.fillStyle = '#7a8087'
|
||||
ctx.font = '11px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(`${wmm} mm`, (x0 + x1) / 2, y1 + 16)
|
||||
ctx.save()
|
||||
ctx.translate(x0 - 14, (y0 + y1) / 2)
|
||||
ctx.rotate(-Math.PI / 2)
|
||||
ctx.fillText(`${hmm} mm`, 0, 0)
|
||||
ctx.restore()
|
||||
ctx.strokeStyle = 'rgba(122,128,135,0.6)'
|
||||
ctx.lineWidth = 1
|
||||
// 尺寸线
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x0, y1 + 6); ctx.lineTo(x1, y1 + 6)
|
||||
ctx.moveTo(x0 - 6, y0); ctx.lineTo(x0 - 6, y1)
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
function computeModelBox() {
|
||||
if (!dd) return
|
||||
const pts: Pt[] = []
|
||||
for (const s of dd.sm) {
|
||||
for (const r of (s.polygon?.polygon ?? [])) {
|
||||
for (const seg of r) { pts.push(seg.ps); pts.push(seg.pe) }
|
||||
}
|
||||
// 扇包围盒也计入
|
||||
for (const sub of (s.sm ?? []) as SashShape[]) {
|
||||
if (sub.bd) { pts.push(sub.bd.from, sub.bd.to) }
|
||||
}
|
||||
}
|
||||
if (!pts.length) { modelBox = { x0: 0, y0: 0, x1: 1000, y1: 1000 }; return }
|
||||
modelBox = bboxOf(pts)
|
||||
}
|
||||
|
||||
function reset() {
|
||||
view = { scale: 1, cx: 0, cy: 0 }
|
||||
render()
|
||||
}
|
||||
|
||||
function onWheel(e: WheelEvent) {
|
||||
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12
|
||||
view.scale = Math.min(40, Math.max(0.2, view.scale * factor))
|
||||
render()
|
||||
}
|
||||
function onDown(e: MouseEvent) { drag = { x: e.clientX, y: e.clientY } }
|
||||
function onMove(e: MouseEvent) {
|
||||
if (!drag) return
|
||||
view.cx += e.clientX - drag.x
|
||||
view.cy += e.clientY - drag.y
|
||||
drag = { x: e.clientX, y: e.clientY }
|
||||
render()
|
||||
}
|
||||
function onUp() { drag = null }
|
||||
|
||||
onMounted(() => {
|
||||
watch(() => props.drawingData, () => {
|
||||
try {
|
||||
dd = props.drawingData ? parseDrawing(props.drawingData) : null
|
||||
computeModelBox()
|
||||
reset()
|
||||
} catch (e) {
|
||||
info.value = '数据解析失败'
|
||||
dd = null
|
||||
render()
|
||||
}
|
||||
}, { immediate: true })
|
||||
window.addEventListener('resize', render)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.canvas-wrap { position: relative; width: 100%; height: 100%; min-height: 320px;
|
||||
background: repeating-conic-gradient(#f7f8f9 0% 25%, #eef0f2 0% 50%) 50%/24px 24px; }
|
||||
canvas { width: 100%; height: 100%; display: block; cursor: grab; }
|
||||
canvas:active { cursor: grabbing; }
|
||||
.hud { position: absolute; right: 10px; top: 10px; display: flex; gap: 8px; align-items: center;
|
||||
background: rgba(255,255,255,0.85); border-radius: 6px; padding: 2px 8px; font-size: 12px; color: #606266; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user