Files
klp-oa/klp-ui/src/views/wms/warehouse/components/Warehouse3D.vue
2026-06-02 09:27:47 +08:00

886 lines
31 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>
<div class="wh3d-wrapper" :class="'wh3d-' + themeMode" :style="cssVars">
<div class="wh3d-header">
<h1>钢卷库 3D 可视化系统</h1>
<span class="tag">地面存放 · 最大叠放 2 · 无货架</span>
<span class="fps">FPS: <b>{{ fps }}</b></span>
</div>
<div class="wh3d-main">
<div class="wh3d-view" ref="viewEl">
<div class="wh3d-vbt">
<button v-for="v in viewBtns" :key="v.k" :class="{ act: viewMode === v.k }" @click="setView(v.k)">{{ v.l }}</button>
<button class="theme-toggle" @click="toggleTheme" :title="'切换为' + (themeMode === 'dark' ? '浅色' : '深色') + '主题'">
{{ themeMode === 'dark' ? '浅色' : '深色' }}
</button>
</div>
<div class="wh3d-help">
<div class="hh">操作说明</div>
<div class="hr"><kbd>左键</kbd> 旋转视角</div>
<div class="hr"><kbd>右键</kbd> 平移视角</div>
<div class="hr"><kbd>滚轮</kbd> 缩放</div>
<div class="hr"><kbd>点击</kbd> 查看钢卷详情</div>
<div class="hr"><kbd>R</kbd> 复位视角 · <kbd>Esc</kbd> 关闭</div>
</div>
<div class="wh3d-axis">
<span>X · (C)</span>
<span class="vt">Z · (R)</span>
</div>
<div class="wh3d-dlg" v-show="detail">
<button class="x" @click="closeDetail">×</button>
<h3>钢卷 {{ detail && detail.id }}</h3>
<div class="rw"><span class="k">库位</span><span class="v">{{ detail && detail.posKey }} · {{ detail && detail.layer }} </span></div>
<div class="rw"><span class="k">钢卷号</span><span class="v">{{ detail && detail.id }}</span></div>
<div class="rw"><span class="k">钢种</span><span class="v">{{ detail && detail.grade }}</span></div>
<div class="rw"><span class="k">宽度×外径</span><span class="v">{{ detail && (detail.width + ' × Ø' + detail.od) }}</span></div>
<div class="rw"><span class="k">重量(t)</span><span class="v">{{ detail && detail.weight }}</span></div>
<div class="rw"><span class="k">扫码状态</span><span class="v">{{ detail && scanText(detail.scan) }}</span></div>
</div>
</div>
<div class="wh3d-info">
<h2>库区统计</h2>
<div class="ss">
<div class="sg">
<div class="sc"><div class="l">总库位</div><div class="v">{{ stats.total }}</div></div>
<div class="sc"><div class="l">已占用</div><div class="v">{{ stats.used }}</div></div>
<div class="sc"><div class="l">空闲位</div><div class="v">{{ stats.free }}</div></div>
<div class="sc"><div class="l">占用率</div><div class="v primary">{{ stats.pct }}</div></div>
<div class="sc"><div class="l">钢卷总数</div><div class="v">{{ stats.coils }}</div></div>
<div class="sc"><div class="l">双层叠放</div><div class="v">{{ stats.doubled }}</div></div>
</div>
</div>
<h2>钢卷明细</h2>
<div class="ctb">
<table>
<thead><tr><th>库位</th><th>钢卷号</th><th>规格</th><th>状态</th></tr></thead>
<tbody>
<tr v-for="c in tableRows" :key="c.id"
:class="{ sel: detail && detail.id === c.id }"
@click="showCoilDetail(c.id)">
<td>{{ c.posKey }} L{{ c.layer }}</td>
<td>{{ c.id }}</td>
<td>{{ c.width }}ר{{ c.od }}</td>
<td :class="scanClass(c.scan)">{{ scanShort(c.scan) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</template>
<script>
import * as THREE from 'three';
const GRADE_LIST = ['SPHC', 'SPHD', 'Q195', 'Q235', 'DC01', 'DC03', 'SUS304'];
const CELL_W = 3.2, CELL_D = 3.2, GAP = 0.5;
const COIL_OD = 1.6, COIL_W = 1.1;
function pad2(n) { return n < 10 ? '0' + n : '' + n; }
function parseCode(code) {
if (!code) return null;
const reg = /^([A-Za-z0-9]{3})([^-]+)-X?(\d{2})-(\d+)$/;
const m = code.match(reg);
if (!m) return null;
return { column: Number(m[2]), row: Number(m[3]), layer: Number(m[4]) };
}
function hexToInt(hex) {
if (!hex) return 0x409eff;
return parseInt(hex.replace('#', ''), 16);
}
export default {
name: 'Warehouse3D',
props: {
warehouseList: { type: Array, default: () => [] },
},
data() {
return {
fps: '--',
viewMode: 'iso',
detail: null,
stats: { total: 0, used: 0, free: 0, pct: '0%', coils: 0, doubled: 0 },
coilList: [],
cols: 14,
rows: 6,
themeMode: localStorage.getItem('wh3d-theme') || 'dark',
viewBtns: [
{ k: 'iso', l: '等轴' },
{ k: 'top', l: '俯视' },
{ k: 'front', l: '正视' },
{ k: 'side', l: '侧视' },
],
};
},
computed: {
themeColor() {
return (this.$store && this.$store.state.settings && this.$store.state.settings.theme) || '#409EFF';
},
palette() {
// 整套色板根据 themeMode 切换3D 场景与 DOM 都用同一份
if (this.themeMode === 'light') {
return {
sceneBg: 0xf5f7fa,
ground: 0xe4eaf2,
gridMajor: 0x9aa5b1,
gridMinor: 0xdcdfe6,
slot: 0xeef2f7,
slotOpacity: 0.55,
coilBody: 0x303133,
coilEnd: 0x9aa5b1,
coilHole: 0x1a1a1a,
ambient: 0.95, dirMain: 1.0, dirFill: 0.4, hemiSky: 0xffffff, hemiGround: 0xcccccc,
highlightEmissive: 0xf39c12,
highlightColor: hexToInt(this.themeColor),
};
}
return {
sceneBg: 0x080c14,
ground: 0x111a26,
gridMajor: 0x1e3a5f,
gridMinor: 0x0f1923,
slot: 0x1a3a1a,
slotOpacity: 0.55,
coilBody: 0x1a1a1a,
coilEnd: 0x9aa5b1,
coilHole: 0x000000,
ambient: 0.6, dirMain: 1.1, dirFill: 0.35, hemiSky: 0x445566, hemiGround: 0x111820,
highlightEmissive: 0xf39c12,
highlightColor: hexToInt(this.themeColor),
};
},
cssVars() {
return { '--wh3d-primary': this.themeColor };
},
tableRows() {
return this.coilList.slice().sort((a, b) => {
if (a.posKey !== b.posKey) return a.posKey.localeCompare(b.posKey);
return a.layer - b.layer;
});
},
},
watch: {
warehouseList() { if (this._ready) this.rebuild(); },
themeColor() { if (this._ready) this.rebuild(); },
themeMode() {
if (!this._ready) return;
this.scene.background = new THREE.Color(this.palette.sceneBg);
this.rebuild();
},
},
mounted() {
this.$nextTick(() => {
this.initScene();
this._ready = true;
});
},
beforeDestroy() {
this._destroyed = true;
if (this._raf) cancelAnimationFrame(this._raf);
window.removeEventListener('resize', this._onResize);
document.removeEventListener('keydown', this._onKey);
if (this.renderer) {
this.renderer.dispose && this.renderer.dispose();
if (this.renderer.domElement && this.renderer.domElement.parentNode) {
this.renderer.domElement.parentNode.removeChild(this.renderer.domElement);
}
}
},
methods: {
scanText(s) { return s === 'OK' ? '正常' : (s === 'PENDING' ? '待扫码' : '异常'); },
scanShort(s) { return s === 'OK' ? '正常' : (s === 'PENDING' ? '待扫' : '异常'); },
scanClass(s) { return s === 'OK' ? 'ok' : (s === 'PENDING' ? 'pn' : 'ng'); },
deriveGrid() {
let maxC = 0, maxR = 0;
(this.warehouseList || []).forEach((w) => {
const info = parseCode(w.actualWarehouseCode);
if (info) {
if (info.column > maxC) maxC = info.column;
if (info.row > maxR) maxR = info.row;
}
});
this.cols = Math.max(maxC, 8);
this.rows = Math.max(maxR, 4);
},
initScene() {
this.deriveGrid();
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(this.palette.sceneBg);
const el = this.$refs.viewEl;
const w = Math.max(el.clientWidth - 310, 100);
const h = Math.max(el.clientHeight, 100);
this.camera = new THREE.PerspectiveCamera(50, w / h, 0.1, 200);
this.camTarget = { x: 0, y: 1.5, z: 0 };
this.camR = 36; this.camTheta = 0.6; this.camPhi = 0.55;
this.updateCamera();
this.renderer = new THREE.WebGLRenderer({ antialias: true });
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
this.renderer.setSize(w, h);
el.appendChild(this.renderer.domElement);
const P = this.palette;
this.scene.add(new THREE.AmbientLight(0xffffff, P.ambient));
const dl = new THREE.DirectionalLight(0xffffff, P.dirMain);
dl.position.set(20, 30, 18);
dl.castShadow = true;
dl.shadow.mapSize.set(1024, 1024);
this.scene.add(dl);
const fill = new THREE.DirectionalLight(0xa0c0ff, P.dirFill);
fill.position.set(-15, 20, -10);
this.scene.add(fill);
this.scene.add(new THREE.HemisphereLight(P.hemiSky, P.hemiGround, 0.5));
this.clock = new THREE.Clock();
this.raycaster = new THREE.Raycaster();
this.mouseVec = new THREE.Vector2();
this.buildGround();
this.buildAllCoils();
this.updateStats();
const dom = this.renderer.domElement;
dom.addEventListener('click', this.onCanvasClick);
dom.addEventListener('mousedown', this.onMouseDown);
dom.addEventListener('mousemove', this.onMouseMove);
dom.addEventListener('mouseup', this.onMouseUp);
dom.addEventListener('wheel', this.onMouseWheel, { passive: false });
dom.addEventListener('contextmenu', (e) => e.preventDefault());
this._onResize = () => this.resizeRenderer();
this._onKey = (e) => {
if (e.key === 'r' || e.key === 'R') this.setView('iso');
if (e.key === 'Escape') this.closeDetail();
};
window.addEventListener('resize', this._onResize);
document.addEventListener('keydown', this._onKey);
this.fpsCount = 0; this.fpsTime = 0;
this.animate();
},
clearScene() {
const dispose = (obj) => {
if (obj.geometry) obj.geometry.dispose();
if (obj.material) {
if (Array.isArray(obj.material)) obj.material.forEach((m) => m.dispose());
else obj.material.dispose();
}
};
const children = this.scene.children.slice();
children.forEach((o) => {
if (o.isLight) return;
o.traverse && o.traverse(dispose);
this.scene.remove(o);
});
},
rebuild() {
if (!this.scene) return;
this.detail = null;
this.clearScene();
// 重新加灯clearScene 保留灯,但主题变化时需要刷新强度,所以也清掉并重建)
const lights = this.scene.children.filter((o) => o.isLight);
lights.forEach((l) => this.scene.remove(l));
const P = this.palette;
this.scene.background = new THREE.Color(P.sceneBg);
this.scene.add(new THREE.AmbientLight(0xffffff, P.ambient));
const dl = new THREE.DirectionalLight(0xffffff, P.dirMain);
dl.position.set(20, 30, 18);
dl.castShadow = true;
this.scene.add(dl);
const fill = new THREE.DirectionalLight(0xa0c0ff, P.dirFill);
fill.position.set(-15, 20, -10);
this.scene.add(fill);
this.scene.add(new THREE.HemisphereLight(P.hemiSky, P.hemiGround, 0.5));
this.buildGround();
this.buildAllCoils();
this.updateStats();
},
toggleTheme() {
this.themeMode = this.themeMode === 'dark' ? 'light' : 'dark';
localStorage.setItem('wh3d-theme', this.themeMode);
},
resizeRenderer() {
if (!this.renderer) return;
const el = this.$refs.viewEl;
const w = Math.max(el.clientWidth - 310, 100);
const h = Math.max(el.clientHeight, 100);
this.renderer.setSize(w, h);
this.camera.aspect = w / h;
this.camera.updateProjectionMatrix();
},
updateCamera() {
const c = this.camera;
c.position.x = this.camTarget.x + this.camR * Math.sin(this.camPhi) * Math.cos(this.camTheta);
c.position.y = this.camTarget.y + this.camR * Math.cos(this.camPhi);
c.position.z = this.camTarget.z + this.camR * Math.sin(this.camPhi) * Math.sin(this.camTheta);
c.lookAt(this.camTarget.x, this.camTarget.y, this.camTarget.z);
},
createTextSprite(text, size, color) {
const canvas = document.createElement('canvas');
const px = Math.floor(size * 256);
canvas.width = px * 3;
canvas.height = px;
const ctx = canvas.getContext('2d');
ctx.fillStyle = color || '#5dade2';
ctx.font = 'bold ' + Math.floor(size * 64) + 'px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, canvas.width / 2, canvas.height / 2);
const tex = new THREE.CanvasTexture(canvas);
tex.minFilter = THREE.LinearFilter;
const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false });
const sp = new THREE.Sprite(mat);
sp.renderOrder = 10;
return sp;
},
buildGround() {
const tw = this.cols * CELL_W + GAP * (this.cols - 1);
const td = this.rows * CELL_D + GAP * (this.rows - 1);
const P = this.palette;
const gnd = new THREE.Mesh(
new THREE.PlaneGeometry(tw + 8, td + 8),
new THREE.MeshStandardMaterial({ color: P.ground, roughness: 0.95 })
);
gnd.rotation.x = -Math.PI / 2;
gnd.receiveShadow = true;
this.scene.add(gnd);
const grid = new THREE.GridHelper(
Math.max(tw, td) + 6,
Math.max(this.cols, this.rows) * 2,
P.gridMajor,
P.gridMinor
);
grid.position.y = 0.01;
this.scene.add(grid);
for (let c = 0; c < this.cols; c++) {
for (let r = 0; r < this.rows; r++) {
const cx = -tw / 2 + c * (CELL_W + GAP) + CELL_W / 2;
const cz = -td / 2 + r * (CELL_D + GAP) + CELL_D / 2;
const key = pad2(c + 1) + pad2(r + 1);
const b = new THREE.Mesh(
new THREE.PlaneGeometry(CELL_W - 0.1, CELL_D - 0.1),
new THREE.MeshStandardMaterial({ color: P.slot, roughness: 0.8, transparent: true, opacity: 0.4 })
);
b.rotation.x = -Math.PI / 2;
b.position.set(cx, 0.02, cz);
b.userData = { isSlot: true, key };
this.scene.add(b);
}
}
const labelColor = this.themeMode === 'light' ? '#3A71A8' : '#5dade2';
const subColor = this.themeMode === 'light' ? '#909399' : '#7f8c8d';
for (let c = 0; c < this.cols; c++) {
const cx = -tw / 2 + c * (CELL_W + GAP) + CELL_W / 2;
const sp = this.createTextSprite('C' + pad2(c + 1), 0.5, labelColor);
sp.position.set(cx, 0.1, -td / 2 - 2.2);
sp.scale.set(2.6, 0.9, 1);
this.scene.add(sp);
}
for (let r = 0; r < this.rows; r++) {
const cz = -td / 2 + r * (CELL_D + GAP) + CELL_D / 2;
const sp = this.createTextSprite('R' + (r + 1), 0.5, labelColor);
sp.position.set(-tw / 2 - 2.5, 0.1, cz);
sp.scale.set(2.2, 0.9, 1);
this.scene.add(sp);
}
const origin = this.createTextSprite('原点 (0,0)', 0.42, subColor);
origin.position.set(-tw / 2 - 2.5, 0.1, -td / 2 - 2.2);
origin.scale.set(3, 0.8, 1);
this.scene.add(origin);
},
buildAllCoils() {
this.coilList = [];
const tw = this.cols * CELL_W + GAP * (this.cols - 1);
const td = this.rows * CELL_D + GAP * (this.rows - 1);
const occupiedMap = {};
(this.warehouseList || []).forEach((w) => {
const info = parseCode(w.actualWarehouseCode);
if (!info) return;
if (w.isEnabled !== 0) return;
const c = info.column - 1, r = info.row - 1, l = info.layer;
if (c < 0 || c >= this.cols || r < 0 || r >= this.rows) return;
const key = pad2(c + 1) + pad2(r + 1);
if (!occupiedMap[key]) occupiedMap[key] = {};
occupiedMap[key][l] = w;
});
if (Object.keys(occupiedMap).length === 0) {
const total = this.cols * this.rows;
const nUsed = Math.floor(total * 0.65);
const indices = [];
for (let i = 0; i < total; i++) indices.push(i);
for (let i = total - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[indices[i], indices[j]] = [indices[j], indices[i]];
}
for (let i = 0; i < nUsed; i++) {
const idx = indices[i];
const c = Math.floor(idx / this.rows), r = idx % this.rows;
const key = pad2(c + 1) + pad2(r + 1);
occupiedMap[key] = { 1: {} };
if (Math.random() < 0.3) occupiedMap[key][2] = {};
}
}
let posSeed = 0;
for (let c = 0; c < this.cols; c++) {
for (let r = 0; r < this.rows; r++) {
const key = pad2(c + 1) + pad2(r + 1);
if (!occupiedMap[key]) continue;
const cx = -tw / 2 + c * (CELL_W + GAP) + CELL_W / 2;
const cz = -td / 2 + r * (CELL_D + GAP) + CELL_D / 2;
[1, 2].forEach((layer) => {
if (!occupiedMap[key][layer]) return;
const w = occupiedMap[key][layer];
posSeed++;
// 仅极少数标记为非正常状态,避免整体闪烁
let scan = 'OK';
const rnd = (posSeed * 9301 + 49297) % 233280 / 233280;
if (rnd < 0.04) scan = 'NG';
else if (rnd < 0.12) scan = 'PENDING';
const grade = GRADE_LIST[(posSeed) % GRADE_LIST.length];
const width = 800 + ((posSeed * 37) % 550);
const od = 1100 + ((posSeed * 53) % 900);
const weight = (4 + ((posSeed * 7) % 220) / 10).toFixed(1);
const g = this.createCoilGroup();
const y = layer === 1 ? COIL_OD / 2 : (COIL_OD / 2 + COIL_OD + 0.08);
g.position.set(cx, y, cz);
const cid = 'C' + key + pad2(layer);
g.userData = { coilId: cid };
this.scene.add(g);
this.coilList.push({
id: cid, posKey: key, grade, width, od, weight, scan, mesh: g, layer,
warehouseId: w.actualWarehouseId, warehouseCode: w.actualWarehouseCode,
});
});
this.updateSlotColor(key);
}
}
},
updateSlotColor(key) {
const P = this.palette;
this.scene.traverse((obj) => {
if (obj.userData && obj.userData.isSlot && obj.userData.key === key) {
obj.material.color.setHex(P.slot);
obj.material.opacity = P.slotOpacity;
}
});
},
createCoilGroup() {
const P = this.palette;
const g = new THREE.Group();
const cyl = new THREE.Mesh(
new THREE.CylinderGeometry(COIL_OD / 2, COIL_OD / 2, COIL_W, 28),
new THREE.MeshStandardMaterial({
color: P.coilBody, roughness: 0.55, metalness: 0.85,
emissive: 0x000000, emissiveIntensity: 1,
})
);
cyl.rotation.x = Math.PI / 2;
cyl.castShadow = true;
cyl.userData.isCoilBody = true;
g.add(cyl);
const rGeo = new THREE.TorusGeometry(COIL_OD / 2, 0.06, 8, 24);
const rMat = new THREE.MeshStandardMaterial({ color: P.coilEnd, roughness: 0.3, metalness: 0.95 });
const rn1 = new THREE.Mesh(rGeo, rMat); rn1.position.z = COIL_W / 2 - 0.03; g.add(rn1);
const rn2 = new THREE.Mesh(rGeo, rMat); rn2.position.z = -(COIL_W / 2 - 0.03); g.add(rn2);
const hole = new THREE.Mesh(
new THREE.CylinderGeometry(0.22, 0.22, COIL_W + 0.1, 12),
new THREE.MeshStandardMaterial({ color: P.coilHole })
);
hole.rotation.x = Math.PI / 2;
g.add(hole);
return g;
},
onCanvasClick(e) {
if (this._didDrag) { this._didDrag = false; return; }
const rect = this.renderer.domElement.getBoundingClientRect();
this.mouseVec.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
this.mouseVec.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
this.raycaster.setFromCamera(this.mouseVec, this.camera);
const targets = [];
this.scene.traverse((o) => { if (o.userData && o.userData.coilId) targets.push(o); });
const hits = this.raycaster.intersectObjects(targets, true);
if (hits.length > 0) {
let obj = hits[0].object;
while (obj.parent && !(obj.userData && obj.userData.coilId)) obj = obj.parent;
if (obj.userData && obj.userData.coilId) {
this.showCoilDetail(obj.userData.coilId);
return;
}
}
this.closeDetail();
},
showCoilDetail(cid) {
const coil = this.coilList.find((c) => c.id === cid);
if (!coil) return;
this.detail = coil;
this.applyHighlight();
},
closeDetail() {
this.detail = null;
this.applyHighlight();
},
applyHighlight() {
const P = this.palette;
this.coilList.forEach((c) => {
if (!c.mesh) return;
c.mesh.traverse((ch) => {
if (ch.userData && ch.userData.isCoilBody) {
ch.material.color.setHex(P.coilBody);
ch.material.emissive.setHex(0x000000);
ch.material.emissiveIntensity = 1;
}
});
});
if (this.detail) {
const c = this.coilList.find((x) => x.id === this.detail.id);
if (c && c.mesh) {
c.mesh.traverse((ch) => {
if (ch.userData && ch.userData.isCoilBody) {
ch.material.color.setHex(P.highlightColor);
ch.material.emissive.setHex(P.highlightEmissive);
ch.material.emissiveIntensity = 0.9;
}
});
}
}
},
onMouseDown(e) {
e.preventDefault();
this._didDrag = false;
this._dragStartX = e.clientX; this._dragStartY = e.clientY;
if (e.button === 0) this.isRotating = true;
if (e.button === 2) this.isPanning = true;
this.prevMX = e.clientX; this.prevMY = e.clientY;
},
onMouseUp() { this.isRotating = false; this.isPanning = false; },
onMouseMove(e) {
if (this.isRotating || this.isPanning) {
if (Math.abs(e.clientX - this._dragStartX) + Math.abs(e.clientY - this._dragStartY) > 4) {
this._didDrag = true;
}
}
if (this.isRotating) {
const dx = e.clientX - this.prevMX, dy = e.clientY - this.prevMY;
this.camTheta -= dx * 0.005;
this.camPhi = Math.max(0.15, Math.min(1.45, this.camPhi + dy * 0.005));
this.updateCamera();
}
if (this.isPanning) {
const dx = e.clientX - this.prevMX, dy = e.clientY - this.prevMY;
this.camTarget.x += (-dx * Math.cos(this.camTheta) + dy * Math.sin(this.camTheta)) * 0.04;
this.camTarget.z += (-dx * Math.sin(this.camTheta) - dy * Math.cos(this.camTheta)) * 0.04;
this.updateCamera();
}
this.prevMX = e.clientX; this.prevMY = e.clientY;
},
onMouseWheel(e) {
this.camR = Math.max(8, Math.min(80, this.camR + e.deltaY * 0.03));
this.updateCamera();
e.preventDefault();
},
setView(type) {
this.viewMode = type;
if (type === 'iso') { this.camR = 36; this.camTheta = 0.6; this.camPhi = 0.55; this.camTarget = { x: 0, y: 1.5, z: 0 }; }
if (type === 'top') { this.camR = 34; this.camTheta = 0; this.camPhi = 0.01; this.camTarget = { x: 0, y: 0, z: 0 }; }
if (type === 'front') { this.camR = 32; this.camTheta = 0; this.camPhi = 1.5; this.camTarget = { x: 0, y: 2, z: 0 }; }
if (type === 'side') { this.camR = 32; this.camTheta = Math.PI / 2; this.camPhi = 1.5; this.camTarget = { x: 0, y: 2, z: 0 }; }
this.updateCamera();
},
updateStats() {
const total = this.cols * this.rows;
const usedKeys = {};
let doubled = 0;
this.coilList.forEach((c) => {
usedKeys[c.posKey] = true;
if (c.layer === 2) doubled++;
});
const used = Object.keys(usedKeys).length;
this.stats = {
total, used, free: total - used,
pct: total > 0 ? Math.round(used / total * 100) + '%' : '0%',
coils: this.coilList.length, doubled,
};
},
animate() {
if (this._destroyed) return;
this._raf = requestAnimationFrame(this.animate);
const t = Date.now() * 0.001;
// 仅给选中钢卷一个柔和呼吸高亮,其它一律保持静态
if (this.detail) {
const c = this.coilList.find((x) => x.id === this.detail.id);
if (c && c.mesh) {
const pulse = 0.6 + 0.4 * Math.abs(Math.sin(t * 2.5));
c.mesh.traverse((ch) => {
if (ch.userData && ch.userData.isCoilBody) ch.material.emissiveIntensity = pulse;
});
}
}
this.fpsCount++;
this.fpsTime += this.clock.getDelta();
if (this.fpsTime >= 1) { this.fps = this.fpsCount; this.fpsCount = 0; this.fpsTime = 0; }
this.renderer.render(this.scene, this.camera);
},
},
};
</script>
<style scoped lang="scss">
.wh3d-wrapper {
background: #0a0e17;
color: #c8d6e5;
font-family: 'Segoe UI', 'Microsoft YaHei', sans-serif;
height: 100%;
display: flex;
flex-direction: column;
border-radius: 8px;
overflow: hidden;
border: 1px solid #1e3a5f;
}
.wh3d-header {
background: linear-gradient(90deg, #0d1320, #111827, #0d1320);
border-bottom: 1px solid #1e3a5f;
padding: 10px 20px;
display: flex;
align-items: center;
gap: 16px;
h1 { color: #5dade2; font-size: 15px; font-weight: 600; letter-spacing: 1px; margin: 0; }
.tag { font-size: 10px; color: #7f8c8d; background: #1a2233; padding: 3px 8px; border-radius: 3px; }
.fps { margin-left: auto; font-size: 12px; color: #2ecc71; }
}
.wh3d-main { display: flex; flex: 1; overflow: hidden; }
.wh3d-view {
position: relative;
flex: 1;
background: #080c14;
overflow: hidden;
}
.wh3d-info {
width: 310px;
background: #0f1923;
border-left: 1px solid #1e3a5f;
display: flex;
flex-direction: column;
overflow: hidden;
h2 {
font-size: 13px; color: #5dade2;
padding: 10px 14px 8px;
border-bottom: 1px solid #1a2a3a;
background: #0d1320; margin: 0;
font-weight: 600;
}
.ss { padding: 10px 14px; border-bottom: 1px solid #1a2a3a; }
.sg { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
.sc {
background: #151f2e; border: 1px solid #1e3a5f; border-radius: 4px; padding: 7px 10px;
.l { font-size: 10px; color: #576574; margin-bottom: 2px; }
.v {
font-size: 15px; font-weight: 700; color: #2ecc71;
&.primary { color: var(--wh3d-primary); }
}
}
.ctb {
flex: 1; overflow-y: auto;
table { width: 100%; font-size: 11px; border-collapse: collapse; }
th {
position: sticky; top: 0;
background: #0d1320; color: #5dade2;
padding: 5px 6px; text-align: left;
font-weight: 600; font-size: 10px;
border-bottom: 1px solid #1e3a5f;
}
td { padding: 5px 6px; border-bottom: 1px solid #0f1923; color: #8395a7; }
tr { cursor: pointer; }
tr:hover { background: #151f2e; }
tr.sel { background: #1a3a5f; td { color: #5dade2; } }
.ok { color: #2ecc71; }
.pn { color: #e67e22; }
.ng { color: #e74c3c; }
}
}
.wh3d-vbt {
position: absolute; top: 10px; right: 10px; z-index: 5;
display: flex; gap: 4px;
button {
background: #1a2a3a; border: 1px solid #2a4a6f; color: #8395a7;
padding: 5px 12px; font-size: 11px; border-radius: 3px; cursor: pointer;
transition: all 0.15s;
&:hover { background: #1a3a5f; color: #5dade2; border-color: #5dade2; }
&.act { background: #1a3a5f; color: #5dade2; border-color: #5dade2; font-weight: 600; }
&.theme-toggle {
margin-left: 8px;
border-color: var(--wh3d-primary);
color: var(--wh3d-primary);
}
}
}
/* ====== 浅色主题覆盖 ====== */
.wh3d-light {
background: #fff !important;
color: #303133 !important;
border-color: #ebeef5 !important;
.wh3d-header {
background: #fafbfc;
border-bottom-color: #ebeef5;
h1 { color: var(--wh3d-primary); }
.tag { color: #909399; background: #f4f4f5; }
.fps { color: #67C23A; }
}
.wh3d-view { background: #f5f7fa; }
.wh3d-info {
background: #fff;
border-left-color: #ebeef5;
h2 { color: #303133; background: #fafbfc; border-bottom-color: #ebeef5; }
.ss { border-bottom-color: #ebeef5; }
.sc {
background: #fafbfc; border-color: #ebeef5;
.l { color: #909399; }
.v { color: #67C23A; &.primary { color: var(--wh3d-primary); } }
}
.ctb {
th { background: #fafbfc; color: #606266; border-bottom-color: #ebeef5; }
td { color: #606266; border-bottom-color: #f2f3f5; }
tr:hover { background: #ecf5ff; }
tr.sel { background: var(--wh3d-primary); td { color: #fff; } }
}
}
.wh3d-vbt button {
background: #fff; border-color: #dcdfe6; color: #606266;
&:hover { background: #ecf5ff; color: var(--wh3d-primary); border-color: var(--wh3d-primary); }
&.act { background: var(--wh3d-primary); color: #fff; border-color: var(--wh3d-primary); }
&.theme-toggle { border-color: var(--wh3d-primary); color: var(--wh3d-primary); background: #fff; }
}
.wh3d-help {
background: rgba(255, 255, 255, 0.95);
border-color: #ebeef5;
color: #606266;
.hh { color: var(--wh3d-primary); border-bottom-color: #ebeef5; }
.hr kbd {
background: #f4f4f5; border-color: #dcdfe6;
color: var(--wh3d-primary);
}
}
.wh3d-axis {
background: rgba(255, 255, 255, 0.92);
border-color: #ebeef5;
color: var(--wh3d-primary);
.vt { color: #E6A23C; }
}
.wh3d-dlg {
background: #fff; border-color: var(--wh3d-primary);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
h3 { color: var(--wh3d-primary); border-bottom-color: #ebeef5; }
.k { color: #909399; }
.v { color: #303133; }
.x { color: #909399; &:hover { color: #303133; } }
}
}
.wh3d-help {
position: absolute; top: 10px; left: 10px;
background: rgba(15, 25, 35, 0.92);
border: 1px solid #1e3a5f; border-radius: 6px;
padding: 10px 14px; z-index: 5;
font-size: 11px; color: #8395a7;
min-width: 180px;
.hh {
color: #5dade2; font-weight: 600; font-size: 12px;
margin-bottom: 8px; padding-bottom: 6px;
border-bottom: 1px solid #1e3a5f;
}
.hr {
line-height: 1.9;
kbd {
display: inline-block;
background: #1a2a3a;
border: 1px solid #2a4a6f;
border-bottom-width: 2px;
border-radius: 3px;
padding: 1px 6px;
margin-right: 6px;
color: #5dade2;
font-family: monospace;
font-size: 10px;
min-width: 32px;
text-align: center;
}
}
}
.wh3d-axis {
position: absolute; bottom: 10px; left: 10px;
background: rgba(15, 25, 35, 0.85);
border: 1px solid #1e3a5f; border-radius: 4px;
padding: 5px 10px;
z-index: 5;
font-size: 11px; color: #5dade2;
display: flex; gap: 14px;
.vt { color: #f39c12; }
}
.wh3d-dlg {
position: absolute; top: 56px; right: 20px;
width: 280px; background: #0f1923;
border: 1px solid var(--wh3d-primary); border-radius: 6px;
padding: 14px; z-index: 20;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.7);
h3 {
color: var(--wh3d-primary);
font-size: 13px; margin: 0 0 10px;
padding-bottom: 6px;
border-bottom: 1px solid #1e3a5f;
}
.rw { display: flex; justify-content: space-between; margin-bottom: 6px; font-size: 12px; }
.k { color: #576574; }
.v { color: #c8d6e5; font-weight: 600; }
.x {
position: absolute; top: 8px; right: 10px;
background: none; border: none;
color: #576574; cursor: pointer; font-size: 18px;
&:hover { color: #c8d6e5; }
}
}
</style>