feat: v0.8 平台扩展——3D渲染/打印中心/下料方案/设备/分享/验证码登录

- 3D展示: Three.js挤出重建窗型(玻璃半透明+型材盒体), 自研轨道控制
- 效果图渲染: Canvas场景合成(墙面/玻璃渐变高光/投影/三场景), 设置持久化
- 打印中心: A4生产订单模板(图纸+进度+签字栏), 打印CSS隔离
- 下料方案: 合同级汇总订单算料需求→FFD优化, 修正利用率为已排入/已用料
- 设备模块: 序列号+激活码绑定, 防重复绑定
- 订单分享: HMAC可逆token + 免登录公开只读页(customOrder-share)
- 验证码登录: mock短信通道全流程可用(发码/校验/自动注册), 接签名后换provider
- 帮助文章接口; 新增支付单适配层(微信/支付宝凭据就绪即接通)
- 前端新增6页面, 共18页面
This commit is contained in:
HHY
2026-09-20 12:10:26 -07:00
parent b2730af80b
commit 3f871d7eeb
19 changed files with 1192 additions and 3 deletions
+196
View File
@@ -0,0 +1,196 @@
<template>
<div class="threed-wrap" ref="wrapRef">
<canvas ref="cvRef" />
<div class="hud3d">
<span v-if="info">{{ info }}</span>
<span class="hint">左键旋转 · 滚轮缩放 · 右键平移</span>
</div>
</div>
</template>
<script setup lang="ts">
/**
* 3D 窗型查看器(自研 Three.js 实现)
* 从 drawing_data 重建:外框/中梃 → 型材盒体;玻璃区域 → 半透明玻璃板;
* ArcBall 轨道控制(自实现,不依赖 three examples 以控制体积)。
*/
import { onMounted, onBeforeUnmount, ref, watch } from 'vue'
import * as THREE from 'three'
import { bboxOf, parseDrawing, regionPoints, replayShape, type DrawingData, type Pt } from '../types/drawing'
const props = defineProps<{ drawingData: string | object }>()
const cvRef = ref<HTMLCanvasElement>()
const wrapRef = ref<HTMLDivElement>()
const info = ref('')
let renderer: THREE.WebGLRenderer | null = null
let scene: THREE.Scene | null = null
let camera: THREE.PerspectiveCamera | null = null
let raf = 0
let orbit = { theta: -0.7, phi: 1.1, dist: 1.6, target: new THREE.Vector3(0, 0, 0) }
let dragging: { x: number; y: number; button: number } | null = null
function dispose() {
if (scene) {
scene.traverse(obj => {
const m = obj as THREE.Mesh
if (m.geometry) m.geometry.dispose()
const mat = m.material as THREE.Material | THREE.Material[] | undefined
if (Array.isArray(mat)) mat.forEach(x => x.dispose())
else mat?.dispose()
})
scene.clear()
}
}
function build(drawing: DrawingData) {
dispose()
scene = new THREE.Scene()
scene.background = new THREE.Color(0xf2f4f7)
scene.add(new THREE.AmbientLight(0xffffff, 0.75))
const dir = new THREE.DirectionalLight(0xffffff, 0.9)
dir.position.set(1, 2, 3)
scene.add(dir)
// 全局包围盒(取全部基底区域)
const pts: Pt[] = []
for (const s of drawing.sm) {
for (const r of replayShape(s).regions) {
for (const seg of r) { pts.push(seg.ps); pts.push(seg.pe) }
}
}
if (!pts.length) { info.value = '无几何数据'; return }
const gb = bboxOf(pts)
const gw = gb.x1 - gb.x0, gh = gb.y1 - gb.y0
const maxDim = Math.max(gw, gh)
const S = 1 / maxDim // 归一化到 1 单位
const cx = (gb.x0 + gb.x1) / 2, cy = (gb.y0 + gb.y1) / 2
const DEPTH = Math.max(0.04, (drawing.s?.cs ?? 60) * S * 1.2) // 型材厚度
const GLASS_T = 0.008
const frameMat = new THREE.MeshStandardMaterial({ color: 0x636568, roughness: 0.55, metalness: 0.35 })
const glassMat = new THREE.MeshPhysicalMaterial({
color: 0xdfeef5, transparent: true, opacity: 0.42, roughness: 0.08,
metalness: 0.05, side: THREE.DoubleSide,
})
for (const shape of drawing.sm) {
const { regions } = replayShape(shape)
if (!regions.length) continue
const cm = shape.cm ?? {}
const gm = cm.g
? new THREE.MeshPhysicalMaterial({
color: new THREE.Color(cm.g.replace(/rgba?\(([^)]+)\)/, (_, v: string) => {
const parts = v.split(',').map(x => parseFloat(x))
return `rgb(${parts[0]},${parts[1]},${parts[2]})`
})),
transparent: true, opacity: 0.45, roughness: 0.1, side: THREE.DoubleSide,
})
: glassMat
for (const region of regions) {
const rpts = regionPoints(region)
const bb = bboxOf(rpts)
const w = (bb.x1 - bb.x0) * S, h = (bb.y1 - bb.y0) * S
const mx = ((bb.x0 + bb.x1) / 2 - cx) * S, my = ((bb.y0 + bb.y1) / 2 - cy) * S
// 玻璃板
const glass = new THREE.Mesh(new THREE.BoxGeometry(w, h, GLASS_T), gm)
glass.position.set(mx, my, 0)
scene!.add(glass)
// 四周型材框(盒体,宽度=料宽 8% 视觉比例)
const fw = Math.max(0.02, (shape.fm?.ew?.[0] ?? 60) * S)
const mk = (bw: number, bh: number, ox: number, oy: number) => {
const bar = new THREE.Mesh(new THREE.BoxGeometry(bw, bh, DEPTH), frameMat)
bar.position.set(mx + ox, my + oy, 0)
scene!.add(bar)
}
mk(w + fw * 2, fw, 0, h / 2 + fw / 2) // 下(屏幕y翻转后为底)
mk(w + fw * 2, fw, 0, -h / 2 - fw / 2) // 上
mk(fw, h, -w / 2 - fw / 2, 0) // 左
mk(fw, h, w / 2 + fw / 2, 0) // 右
}
}
// 地面参考网格(模型已归一化到 1 单位,网格取 2 单位)
const grid = new THREE.GridHelper(2, 10, 0xc8cdd2, 0xdfe3e8)
grid.position.y = -gh * S / 2 - DEPTH
scene.add(grid)
camera!.position.set(
orbit.target.x + orbit.dist * Math.sin(orbit.phi) * Math.sin(orbit.theta),
orbit.target.y + orbit.dist * Math.cos(orbit.phi),
orbit.target.z + orbit.dist * Math.sin(orbit.phi) * Math.cos(orbit.theta))
camera!.lookAt(orbit.target)
info.value = `${Math.round(gw)} × ${Math.round(gh)} mm`
}
function loop() {
raf = requestAnimationFrame(loop)
if (!renderer || !scene || !camera) return
camera.position.set(
orbit.target.x + orbit.dist * Math.sin(orbit.phi) * Math.sin(orbit.theta),
orbit.target.y + orbit.dist * Math.cos(orbit.phi),
orbit.target.z + orbit.dist * Math.sin(orbit.phi) * Math.cos(orbit.theta))
camera.lookAt(orbit.target)
renderer.render(scene, camera)
}
function onResize() {
const wrap = wrapRef.value, cv = cvRef.value
if (!wrap || !cv || !renderer || !camera) return
renderer.setSize(wrap.clientWidth, wrap.clientHeight)
camera.aspect = wrap.clientWidth / wrap.clientHeight
camera.updateProjectionMatrix()
}
function onDown(e: MouseEvent) { dragging = { x: e.clientX, y: e.clientY, button: e.button } }
function onMove(e: MouseEvent) {
if (!dragging) return
const dx = e.clientX - dragging.x, dy = e.clientY - dragging.y
dragging = { x: e.clientX, y: e.clientY, button: dragging.button }
if (dragging.button === 2) {
// 平移
const pan = new THREE.Vector3().crossVectors(camera!.up, camera!.getWorldDirection(new THREE.Vector3()))
orbit.target.addScaledVector(pan, -dx * orbit.dist * 0.001)
orbit.target.y += dy * orbit.dist * 0.001
} else {
orbit.theta -= dx * 0.008
orbit.phi = Math.min(Math.PI - 0.1, Math.max(0.15, orbit.phi + dy * 0.008))
}
}
function onUp() { dragging = null }
function onWheel(e: WheelEvent) {
orbit.dist = Math.min(6, Math.max(0.3, orbit.dist * (e.deltaY > 0 ? 1.1 : 0.9)))
}
onMounted(() => {
const wrap = wrapRef.value!, cv = cvRef.value!
renderer = new THREE.WebGLRenderer({ canvas: cv, antialias: true })
renderer.setSize(wrap.clientWidth, wrap.clientHeight)
camera = new THREE.PerspectiveCamera(50, wrap.clientWidth / wrap.clientHeight, 0.01, 100)
cv.addEventListener('mousedown', onDown)
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
cv.addEventListener('wheel', e => { e.preventDefault(); onWheel(e) }, { passive: false })
cv.addEventListener('contextmenu', e => e.preventDefault())
window.addEventListener('resize', onResize)
watch(() => props.drawingData, () => {
try { if (props.drawingData) build(parseDrawing(props.drawingData)) } catch { info.value = '解析失败' }
}, { immediate: true })
loop()
})
onBeforeUnmount(() => {
cancelAnimationFrame(raf)
dispose()
window.removeEventListener('resize', onResize)
renderer?.dispose()
})
</script>
<style scoped>
.threed-wrap { position: relative; width: 100%; height: 100%; min-height: 320px; }
canvas { width: 100%; height: 100%; display: block; }
.hud3d { position: absolute; right: 10px; top: 10px; background: rgba(255,255,255,0.9);
border-radius: 6px; padding: 4px 10px; font-size: 12px; color: #606266; display: flex; gap: 12px; }
.hint { color: #b0b5bc; }
</style>