Files
klp-oa/klp-ui/src/views/wms/warehouse/components/Warehouse3D.vue
砂糖 08dec15614 feat(wms-warehouse): 添加钢卷右键领料功能
新增仓库视图右键菜单触发钢卷选择弹窗,支持平面视图和3D视图右键选中钢卷,实现快速领料流程,包含领料弹窗、工序选择和接口调用逻辑
2026-06-30 10:02:27 +08:00

1089 lines
38 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" v-loading="detailLoading">
<button class="x" @click="closeDetail">×</button>
<h3>钢卷 {{ detail && detail.coilNo }}</h3>
<div class="rw"><span class="k">库位</span><span class="v">{{ detail && (detail.warehouseName || detail.posKey) }} · {{ detail && detail.layer }} </span></div>
<div class="rw"><span class="k">钢卷号</span><span class="v">{{ detail && detail.coilNo }}</span></div>
<div class="rw"><span class="k">入库钢卷号</span><span class="v">{{ detail && (detail.enterCoilNo || '-') }}</span></div>
<div class="rw"><span class="k">材质</span><span class="v">{{ detail && detail.material }}</span></div>
<div class="rw"><span class="k">规格</span><span class="v">{{ detail && detail.specification }}</span></div>
<div class="rw"><span class="k">毛重(kg)</span><span class="v">{{ detail && fmtNum(detail.grossWeight) }}</span></div>
<div class="rw"><span class="k">净重(kg)</span><span class="v">{{ detail && fmtNum(detail.netWeight) }}</span></div>
<div class="rw"><span class="k">质量状态</span><span class="v">{{ detail && detail.qualityStatus }}</span></div>
<div class="sec-title">移库信息</div>
<table class="sec-tbl" v-if="detail && detail.moves && detail.moves.length">
<thead><tr><th>时间</th><th>库位</th><th>操作</th><th>出入库</th></tr></thead>
<tbody>
<tr v-for="(m, i) in detail.moves" :key="i">
<td>{{ m.time }}</td>
<td>{{ m.warehouse }}</td>
<td>{{ m.operationType }}</td>
<td>{{ m.inOutType }}</td>
</tr>
</tbody>
</table>
<div v-else-if="detail && !detailLoading" class="sec-empty">暂无移库记录</div>
<div class="sec-title">生产追溯</div>
<div v-if="detail && detail.trace && detail.trace.steps && detail.trace.steps.length" class="trace">
<div v-for="(step, i) in traceSteps" :key="i" class="trace-step">
<div class="ts-head">
<span class="ts-action">{{ step.action }}</span>
<span class="ts-time">{{ step.time }}</span>
</div>
<div class="ts-op" v-if="step.operation">操作员{{ step.operation }}</div>
<div class="ts-coils" v-if="step.newCoilInfoList && step.newCoilInfoList.length">
<span v-for="(c, idx) in step.newCoilInfoList" :key="idx" class="ts-tag" v-if="c && c.currentCoilNo && c.currentCoilNo !== '-'">
{{ c.currentCoilNo }}
</span>
</div>
</div>
</div>
<div v-else-if="detail && !detailLoading" class="sec-empty">暂无追溯数据</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)"
@contextmenu.prevent="handleRowRightClick(c)">
<td>{{ c.posKey }} L{{ c.layer }}</td>
<td>{{ c.coilNo }}</td>
<td>{{ c.specification }}</td>
<td>{{ fmtNum(c.grossWeight) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</template>
<script>
import * as THREE from 'three';
import { listMaterialCoil, getMaterialCoilTrace } from '@/api/wms/coil';
import { getCoilWarehouseOperationLogByCoilId } from '@/api/wms/coilWarehouseOperationLog';
const OP_TYPE_MAP = { 1: '收货', 2: '加工', 3: '调拨', 4: '发货' };
const IN_OUT_MAP = { 1: '入库', 2: '出库' };
function formatDate(d) {
if (!d) return '-';
const dt = typeof d === 'string' ? new Date(d.replace(' ', 'T')) : new Date(d);
if (isNaN(dt.getTime())) return String(d);
const p = (n) => (n < 10 ? '0' + n : '' + n);
return `${dt.getFullYear()}-${p(dt.getMonth() + 1)}-${p(dt.getDate())} ${p(dt.getHours())}:${p(dt.getMinutes())}`;
}
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: () => [] },
parentId: { type: [String, Number], 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',
materialCoils: [],
detailLoading: false,
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;
});
},
traceSteps() {
if (!this.detail || !this.detail.trace || !this.detail.trace.steps) return [];
return this.detail.trace.steps.slice().reverse();
},
},
watch: {
warehouseList() { if (this._ready) this.fetchAndRebuild(); },
parentId() { if (this._ready) this.fetchAndRebuild(); },
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;
this.fetchAndRebuild();
});
},
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: {
fmtNum(n) { return n == null ? '-' : Number(n).toLocaleString(); },
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('contextmenu', this.onContextMenu);
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);
// 1) actualWarehouseId → 库位坐标和层号(来自 warehouseList 中的库位编码)
const slotIndex = {};
(this.warehouseList || []).forEach((w) => {
const info = parseCode(w.actualWarehouseCode);
if (!info) return;
const c = info.column - 1, r = info.row - 1;
if (c < 0 || c >= this.cols || r < 0 || r >= this.rows) return;
slotIndex[w.actualWarehouseId] = {
c, r,
key: pad2(c + 1) + pad2(r + 1),
layer: info.layer || 1,
code: w.actualWarehouseCode,
};
});
// 2) 按库位 + 层分组真实钢卷
const occupiedMap = {};
(this.materialCoils || []).forEach((mc) => {
const slot = slotIndex[mc.actualWarehouseId];
if (!slot) return;
if (!occupiedMap[slot.key]) occupiedMap[slot.key] = {};
if (!occupiedMap[slot.key][slot.layer]) occupiedMap[slot.key][slot.layer] = mc;
});
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) => {
const mc = occupiedMap[key][layer];
if (!mc) return;
const g = this.createCoilGroup();
const y = layer === 1 ? COIL_OD / 2 : (COIL_OD / 2 + COIL_OD + 0.08);
g.position.set(cx, y, cz);
g.userData = { coilId: mc.coilId };
this.scene.add(g);
this.coilList.push({
id: mc.coilId,
coilNo: mc.currentCoilNo || mc.enterCoilNo || ('#' + mc.coilId),
enterCoilNo: mc.enterCoilNo,
posKey: key,
layer,
specification: mc.specification || '-',
material: mc.material || '-',
grossWeight: mc.grossWeight,
netWeight: mc.netWeight,
qualityStatus: mc.qualityStatus || '-',
warehouseId: mc.actualWarehouseId,
warehouseName: mc.actualWarehouseName,
mesh: g,
moves: null,
trace: null,
});
});
this.updateSlotColor(key);
}
}
},
async fetchAndRebuild() {
await this.fetchMaterialCoils();
this.rebuild();
},
async fetchMaterialCoils() {
if (!this.parentId) { this.materialCoils = []; return; }
try {
const res = await listMaterialCoil({
actualWarehouseId: this.parentId,
pageNum: 1,
pageSize: 9999,
});
this.materialCoils = res.rows || res.data || [];
} catch (err) {
console.error('[Warehouse3D] 查询钢卷失败', err);
this.materialCoils = [];
}
},
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();
},
onContextMenu(e) {
e.preventDefault();
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) {
const coil = this.coilList.find(c => c.id === obj.userData.coilId);
if (coil) {
this.$emit('coil-selected', {
coilId: coil.id,
currentCoilNo: coil.coilNo,
warehouseId: coil.warehouseId,
warehouseName: coil.warehouseName,
});
}
}
}
},
async showCoilDetail(cid) {
const coil = this.coilList.find((c) => c.id === cid);
if (!coil) return;
this.detail = coil;
this.applyHighlight();
if (coil.moves && coil.trace) return;
this.detailLoading = true;
try {
const [mvRes, trRes] = await Promise.all([
getCoilWarehouseOperationLogByCoilId({ coilId: coil.id }),
getMaterialCoilTrace({ coilId: coil.id, currentCoilNo: coil.coilNo }),
]);
coil.moves = (mvRes.data || mvRes.rows || []).map((log) => ({
time: formatDate(log.createTime),
warehouse: (log.warehouse && (log.warehouse.actualWarehouseName || log.warehouse.actualWarehouseCode)) || '-',
operationType: OP_TYPE_MAP[log.operationType] || '-',
inOutType: IN_OUT_MAP[log.inOutType] || '-',
remark: log.remark || '',
}));
coil.trace = trRes.data || null;
} catch (err) {
console.error('[Warehouse3D] 查询追溯失败', err);
coil.moves = coil.moves || [];
coil.trace = coil.trace || null;
} finally {
this.detailLoading = false;
if (this.detail && this.detail.id === cid) this.detail = { ...coil };
}
},
handleRowRightClick(coil) {
this.$emit('coil-selected', {
coilId: coil.id,
currentCoilNo: coil.coilNo,
warehouseId: coil.warehouseId,
warehouseName: coil.warehouseName,
});
},
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: 340px; max-height: calc(100% - 80px);
overflow-y: auto;
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;
&.ok { color: #2ecc71; }
&.pn { color: #e67e22; }
&.ng { color: #e74c3c; }
}
.x {
position: absolute; top: 8px; right: 10px;
background: none; border: none;
color: #576574; cursor: pointer; font-size: 18px;
&:hover { color: #c8d6e5; }
}
.sec-title {
margin: 14px 0 8px;
padding: 4px 8px;
font-size: 12px; font-weight: 600;
color: var(--wh3d-primary);
background: #0a1422;
border-left: 3px solid var(--wh3d-primary);
border-radius: 0 3px 3px 0;
}
.sec-tbl {
width: 100%; font-size: 11px;
border-collapse: collapse;
th {
background: #0d1320; color: #5dade2;
padding: 4px 6px; text-align: left; font-weight: 600;
border-bottom: 1px solid #1e3a5f;
}
td {
padding: 4px 6px;
color: #8395a7;
border-bottom: 1px solid #1a2a3a;
}
}
.trace { display: flex; flex-direction: column; gap: 8px; }
.trace-step {
border-left: 2px solid var(--wh3d-primary);
padding: 4px 8px;
background: rgba(30, 58, 95, 0.25);
border-radius: 0 3px 3px 0;
font-size: 11.5px;
.ts-head { display: flex; justify-content: space-between; margin-bottom: 3px; }
.ts-action { color: var(--wh3d-primary); font-weight: 600; }
.ts-time { color: #576574; }
.ts-op { color: #8395a7; margin-bottom: 3px; }
.ts-coils { display: flex; flex-wrap: wrap; gap: 3px; }
.ts-tag {
display: inline-block;
padding: 1px 6px;
background: #1a2a3a;
border: 1px solid #1e3a5f;
border-radius: 2px;
color: #c8d6e5;
font-size: 10.5px;
}
}
.sec-empty {
padding: 10px;
color: #576574;
font-size: 11.5px;
text-align: center;
font-style: italic;
}
}
.wh3d-light .wh3d-dlg {
.sec-title {
background: #ecf5ff;
color: var(--wh3d-primary);
}
.sec-tbl {
th { background: #fafbfc; color: #606266; border-bottom-color: #ebeef5; }
td { color: #606266; border-bottom-color: #f2f3f5; }
}
.trace-step {
background: #ecf5ff;
.ts-time { color: #909399; }
.ts-op { color: #606266; }
.ts-tag { background: #fff; border-color: #dcdfe6; color: #606266; }
}
.sec-empty { color: #909399; }
}
</style>