12451 0441d91d1f feat(users+cabinets): 用户管理加角色过滤、数据范围仅操作员可配、柜子详情透出电表读数
## 1. 用户管理:角色过滤(页面总共只有三种角色,默认罗列全部太吵)

前端加角色下拉 + 后端 `GET /users` 新增 `role` 参数(0/1/2,越界当作不过滤)。

后端把 `build_user_list_where` 从**二元穷举 match 重构为条件累加** ——
加第三个过滤器时穷举会涨到 8 分支且极易漏。同时修一个潜在坑:
**OR 条件必须整体加括号**,否则 `phone LIKE ? OR name LIKE ?` 与
`organization_id = ?` 用 AND 混排时会变成
`(A AND phone LIKE ?) OR name LIKE ?`,把组织/角色过滤整个击穿
(搜个姓名就跨组织串数据)。

新增 6 个守门单测(含"占位符个数必须与 bind 次数一致"的反证),
已自证能失败:去掉 OR 括号 → 2 个用例 FAILED,还原 → 6 passed。

## 2. 数据范围:仅操作员(role=0)可配

按用户口径:
- **总管理员**:豁免范围限制,不需要配;
- **企业管理员**:天然管理本组织全部设备,配项目级范围反而有歧义;
- **操作员**:才需要设置可管理的项目。

前端对该按钮按 `record.role !== 0` 置灰并加 title 说明。
**创建/编辑时操作员必须选组织**(没有企业归属就看不到任何设备),校验文案也分开提示。

## 3. 柜子详情:透出电表读数

接口早已返回 `meter_wh`/`meter_at`,但**前端从没接过**(用户看不到)。
现加到详情页基本信息行(IMEI/ICCID/名称 之后),Wh → kWh 展示并附收到时刻;
`null` = 从未上报过,显示 `-` 而**不是 0 度**。

## ⛔ 表号:系统里没有,做不了

全仓搜不到 `meter_no`/`meter_id`/`meter_sn`/`表号`,协议里 `cabinet.meter`
只是读数(Wh),**没有电表编号字段**。这需要硬件侧在协议里加字段,
平台无法单方面补。

## ⚠️ 我这次犯的流程错误(已修正)

改了 Rust 代码后**只跑了 cargo check/test,没重新 `zigbuild` release 产物**,
`deploy -SkipBuild` 于是把几小时前的旧二进制发上去了 —— 第一次实测角色过滤
三种角色全返回、监控词。重建后才通过。
**教训:改 Rust 后必须重建 release 产物再发。**

## 验收

- `cargo check --all-targets` 两服务:**0 error / 0 warning**
- `cargo test --bin pms-api`:**210 passed**(204 + 6 新)
- web `npm run typecheck`:exit 0
- **角色过滤实测**(测试环境,临时超管+自签 JWT,用完即删):
  ```
  role=0 → 2 人,role 集合 [0]        OK
  role=1 → 1 人,role 集合 [1]        OK
  role=2 → 2 人,role 集合 [2]        OK
  不带 role → [0,1,2]                 OK
  role=0 + keyword → 1 人             OK(keyword 单独查命中 0,证明不是靠关键字通过)
  role=9 越界 → 5 人                  OK(当作不过滤)
  ```
- 已发布测试 + 生产(四服务 active、0 ERROR、消费链探测通过、二进制时间戳已核对)
2026-09-21 05:02:09 +08:00

431 lines
15 KiB
JavaScript
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.

/**
* 艾动智能 · 36 仓充电柜 参数化 3D 模型(Three.js)
*
* 设计约束(AGENTS.md「操作规则」):
* - 明亮 / 浅色基调,⛔ 不做暗色科技风;
* - 柜子规格按真实产品:6 列 × 6 行 = 36 个独立仓体,每仓带状态指示灯。
*
* 工程约束:
* - canvas 透明背景(alpha),柜子"浮"在页面浅色渐变上 ⇒ 不存在图片硬边界;
* - 纯参数化建模,不依赖任何外部模型文件(我们没有、也生成不出高精度模型资产);
* - 性能:DPR 上限 2、页面隐藏/滚出视口时暂停渲染;
* - 降级:WebGL 不可用或脚本异常 ⇒ 页面回落到静态主视觉图(见 index.html)。
*/
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
/* ============================ 规格常量 ============================ */
const COLS = 6;
const ROWS = 6;
const CELL_W = 0.62; // 单仓横向步进
const CELL_H = 0.72; // 单仓纵向步进
const DOOR_W = 0.54;
const DOOR_H = 0.64;
const BODY_H = ROWS * CELL_H; // 4.32
const BASE_H = 0.20;
const CAP_H = 0.24;
/* 深度(Z 轴)。
⚠️ 原先柜体只有 0.62 深,而宽 3.72 ⇒ 宽:深 ≈ 6:1,薄得像一块板,
从侧面看尤其难看。真实充电柜深得多,这里加到 1.18 ⇒ 宽:深 ≈ 3.2:1。 */
const BODY_D = 1.18;
const CAP_D = 1.24; // 顶盖略外挑
const PLINTH_D = 1.22; // 底座略外挑
const DOOR_Z = BODY_D / 2 + 0.07; // 仓门铰链平面(柜体前面之外一点)
const CAVITY_D = 0.85; // 仓内深度
const BRAND = 0x0366e6; // 取自 logo 的品牌蓝
const LIGHT_CYAN = 0x17e4ff; // 状态灯
/** 上面 3 排用深色玻璃仓门、下面 3 排用浅色仓门 —— 与主视觉图的层次一致 */
const DARK_ROWS = 3;
/* ============================ 渲染器 ============================ */
const canvas = document.getElementById('cabinet-canvas');
/* 移动端降质:手机 GPU 弱,36 仓 + 阴影 + 环境反射很容易发烫掉帧。
判据用「窄屏 或 粗指针(触摸) 或 低 DPR」三者之一。 */
const IS_MOBILE = (window.innerWidth <= 860)
|| (window.matchMedia && window.matchMedia('(pointer: coarse)').matches)
|| (window.devicePixelRatio || 1) < 2;
const renderer = new THREE.WebGLRenderer({
canvas,
alpha: true, // 透明背景,让柜子浮在页面上
antialias: !IS_MOBILE, // 移动端关 MSAA,靠 DPR 与后处理兜不住就算了
powerPreference: 'high-performance',
});
renderer.setClearColor(0x000000, 0);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, IS_MOBILE ? 1.5 : 2));
renderer.shadowMap.enabled = !IS_MOBILE; // 移动端用假接触阴影(地面那张),不开实时阴影
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
const scene = new THREE.Scene();
/* 相机:略低于人眼、微微仰视,产品感更强。
⚠️ 距离是算出来的,不是凭感觉:柜体总高约 4.76 单位(底座0.20+主体4.32+顶盖0.24),
摄像机看向 target(0, 2.36, 0)。38° 垂直视角下
可见高度 = 2·d·tan19° = 0.689·d。
- 原 d≈8.4 ⇒ 柜子占 82%,顶天顶地;
- 现取 CAM_DIST=12.2 ⇒ 实际 d≈11.4 ⇒ 柜子占约 61%,上下各留约 20% 呼吸空间。 */
const CAM_DIST = 13.0;
const camera = new THREE.PerspectiveCamera(38, 1, 0.1, 100);
camera.position.set(
CAM_DIST * 0.52, // x
CAM_DIST * 0.35, // y
CAM_DIST * 0.76, // z
);
/* ============================ 环境反射 ============================ */
/** 程序化生成"影棚渐变"环境贴图 —— 让深色玻璃仓门有细腻反射,无需外部 HDR 文件。 */
function buildEnvironment() {
const pmrem = new THREE.PMREMGenerator(renderer);
const envScene = new THREE.Scene();
// 一个内部着色的渐变球:上白、中浅灰、下稍暗,模拟影棚柔光箱
const geo = new THREE.SphereGeometry(10, 32, 16);
const mat = new THREE.ShaderMaterial({
side: THREE.BackSide,
uniforms: {
top: { value: new THREE.Color(0xffffff) },
mid: { value: new THREE.Color(0xdfe4ec) },
bot: { value: new THREE.Color(0x9aa3b2) },
},
vertexShader: `
varying float vY;
void main() {
vY = normalize(position).y;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}`,
fragmentShader: `
uniform vec3 top; uniform vec3 mid; uniform vec3 bot;
varying float vY;
void main() {
float t = clamp(vY * 0.5 + 0.5, 0.0, 1.0);
vec3 c = t < 0.5 ? mix(bot, mid, t * 2.0) : mix(mid, top, (t - 0.5) * 2.0);
gl_FragColor = vec4(c, 1.0);
}`,
});
envScene.add(new THREE.Mesh(geo, mat));
const env = pmrem.fromScene(envScene, 0.04).texture;
pmrem.dispose();
geo.dispose();
mat.dispose();
return env;
}
scene.environment = buildEnvironment();
/* ============================ 灯光 ============================ */
function buildLights() {
scene.add(new THREE.HemisphereLight(0xffffff, 0xc8d0dc, 0.55));
// 主光:右上前侧,冷白,投影(移动端为省电只留光照、不投影)
const key = new THREE.DirectionalLight(0xffffff, 2.1);
key.position.set(5, 8, 6);
if (!IS_MOBILE) {
key.castShadow = true;
key.shadow.mapSize.set(2048, 2048);
key.shadow.camera.near = 1;
key.shadow.camera.far = 30;
key.shadow.camera.left = -6;
key.shadow.camera.right = 6;
key.shadow.camera.top = 8;
key.shadow.camera.bottom = -2;
key.shadow.bias = -0.0008;
key.shadow.normalBias = 0.02;
}
scene.add(key);
// 补光:左侧,柔
const fill = new THREE.DirectionalLight(0xdfe8ff, 0.7);
fill.position.set(-6, 4, 4);
scene.add(fill);
// 轮廓光:品牌蓝,从左后打,勾出柜体边缘
const rim = new THREE.DirectionalLight(BRAND, 1.5);
rim.position.set(-5, 5, -6);
scene.add(rim);
// 顶部灯带的自发光点光,让蓝光落在柜顶
const stripGlow = new THREE.PointLight(BRAND, 6, 6, 2);
stripGlow.position.set(0, BODY_H + CAP_H + 0.12, 1.2);
scene.add(stripGlow);
}
buildLights();
/* ============================ 柜体模型 ============================ */
const cabinet = new THREE.Group();
const doors = []; // { pivot, mesh, row, col, open, target, light }
const doorMaterialCache = new Map();
/** 仓门材质(按行分深浅,缓存复用) */
function doorMaterial(dark) {
const key = dark ? 'dark' : 'light';
if (doorMaterialCache.has(key)) return doorMaterialCache.get(key);
const m = new THREE.MeshStandardMaterial(
dark
? { color: 0x1b2432, roughness: 0.18, metalness: 0.35 } // 深色玻璃
: { color: 0xd9dde4, roughness: 0.42, metalness: 0.12 }, // 浅色门板
);
doorMaterialCache.set(key, m);
return m;
}
function buildBody() {
const w = COLS * CELL_W;
// 主体
const body = new THREE.Mesh(
new THREE.BoxGeometry(w, BODY_H, BODY_D),
new THREE.MeshStandardMaterial({ color: 0xe9ebef, roughness: 0.55, metalness: 0.08 }),
);
body.position.y = BASE_H + BODY_H / 2;
body.castShadow = true;
body.receiveShadow = true;
cabinet.add(body);
// 顶盖(略宽略深,深色,中间嵌品牌蓝灯带)
const cap = new THREE.Mesh(
new THREE.BoxGeometry(w + 0.08, CAP_H, CAP_D),
new THREE.MeshStandardMaterial({ color: 0x2b3038, roughness: 0.4, metalness: 0.3 }),
);
cap.position.y = BASE_H + BODY_H + CAP_H / 2;
cap.castShadow = true;
cabinet.add(cap);
const strip = new THREE.Mesh(
new THREE.BoxGeometry(w - 0.5, 0.045, 0.05),
new THREE.MeshStandardMaterial({
color: 0x0a2a5e, emissive: BRAND, emissiveIntensity: 2.4, roughness: 0.3,
}),
);
strip.position.set(0, BASE_H + BODY_H + CAP_H - 0.07, CAP_D / 2 + 0.012);
cabinet.add(strip);
// 底座 + 支脚
const plinth = new THREE.Mesh(
new THREE.BoxGeometry(w + 0.04, BASE_H, PLINTH_D),
new THREE.MeshStandardMaterial({ color: 0x3a4048, roughness: 0.5, metalness: 0.25 }),
);
plinth.position.y = BASE_H / 2;
plinth.castShadow = true;
plinth.receiveShadow = true;
cabinet.add(plinth);
for (const sx of [-1, 1]) {
for (const sz of [-1, 1]) {
const foot = new THREE.Mesh(
new THREE.CylinderGeometry(0.055, 0.05, 0.06, 16),
new THREE.MeshStandardMaterial({ color: 0x22262c, roughness: 0.6 }),
);
foot.position.set(sx * (w / 2 - 0.14), 0.03, sz * (PLINTH_D / 2 - 0.14));
cabinet.add(foot);
}
}
}
/** 单个仓体:门框 + 门 + 把手 + 状态灯;门挂在 pivot 上以便绕铰链开合 */
function buildCompartment(row, col) {
const dark = row < ROWS / 2;
const x = (col - (COLS - 1) / 2) * CELL_W;
const y = BASE_H + (row + 0.5) * CELL_H;
// 门框(深色,凸出在柜体前面)
const frame = new THREE.Mesh(
new THREE.BoxGeometry(CELL_W - 0.02, CELL_H - 0.02, 0.06),
new THREE.MeshStandardMaterial({ color: 0x353b45, roughness: 0.45, metalness: 0.3 }),
);
frame.position.set(x, y, BODY_D / 2 + 0.03);
cabinet.add(frame);
// 铰链在左侧:pivot 放在门左缘
const pivot = new THREE.Group();
pivot.position.set(x - DOOR_W / 2, y, DOOR_Z);
cabinet.add(pivot);
const door = new THREE.Mesh(
new THREE.BoxGeometry(DOOR_W, DOOR_H, 0.05),
doorMaterial(dark),
);
door.position.set(DOOR_W / 2, 0, 0.025);
door.castShadow = true;
pivot.add(door);
// 把手
const handle = new THREE.Mesh(
new THREE.BoxGeometry(0.035, 0.16, 0.035),
new THREE.MeshStandardMaterial({ color: 0xb9c0cb, roughness: 0.25, metalness: 0.85 }),
);
handle.position.set(DOOR_W - 0.07, 0, 0.055);
pivot.add(handle);
// 状态灯(门框右上角,自发光)
const light = new THREE.Mesh(
new THREE.BoxGeometry(0.1, 0.022, 0.02),
new THREE.MeshStandardMaterial({
color: 0x08243f, emissive: LIGHT_CYAN, emissiveIntensity: 2.2, roughness: 0.4,
}),
);
light.position.set(x + CELL_W / 2 - 0.1, y + CELL_H / 2 - 0.07, BODY_D / 2 + 0.065);
cabinet.add(light);
// 仓内(开门后可见的暗腔 + 弱光)
const cavity = new THREE.Mesh(
new THREE.BoxGeometry(CELL_W - 0.08, CELL_H - 0.08, CAVITY_D),
new THREE.MeshStandardMaterial({ color: 0x111720, roughness: 0.9 }),
);
cavity.position.set(x, y, BODY_D / 2 - CAVITY_D / 2 + 0.02);
cabinet.add(cavity);
doors.push({
pivot, door, light, row, col,
open: false, target: 0, hover: false,
baseEmissive: 2.2, phase: (row * COLS + col) * 0.37,
});
}
function buildCabinet() {
buildBody();
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) buildCompartment(r, c);
}
cabinet.position.y = 0;
scene.add(cabinet);
}
buildCabinet();
/* ============================ 地面 + 软阴影 ============================ */
/** 用一张径向渐变贴图做假接触阴影,比实时阴影便宜且边缘更柔。 */
function buildFloor() {
const size = 256;
const cv = document.createElement('canvas');
cv.width = cv.height = size;
const ctx = cv.getContext('2d');
const g = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
g.addColorStop(0, 'rgba(20,30,45,0.34)');
g.addColorStop(0.55, 'rgba(20,30,45,0.12)');
g.addColorStop(1, 'rgba(20,30,45,0)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, size, size);
const tex = new THREE.CanvasTexture(cv);
const shadow = new THREE.Mesh(
new THREE.PlaneGeometry(7.5, 4.5),
new THREE.MeshBasicMaterial({ map: tex, transparent: true, depthWrite: false }),
);
shadow.rotation.x = -Math.PI / 2;
shadow.position.y = 0.005;
shadow.renderOrder = -1;
scene.add(shadow);
}
buildFloor();
/* ============================ 交互 ============================ */
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, BASE_H + BODY_H / 2, 0);
controls.enableDamping = true;
controls.dampingFactor = 0.06;
controls.enablePan = false;
// 距离上下限跟着取景走:默认约 12.1,允许 9.5–17
controls.minDistance = 9.5;
controls.maxDistance = 17;
controls.minPolarAngle = 0.35;
controls.maxPolarAngle = Math.PI / 2 - 0.03; // 不钻到地面以下
controls.autoRotate = true;
controls.autoRotateSpeed = 0.55;
// 用户一交互就停自动旋转,避免"拖着还转"
controls.addEventListener('start', () => { controls.autoRotate = false; });
// 触屏:单指旋转、双指缩放;页面滚动交给舞台外的区域(canvas 已设 touch-action:none)
controls.touches = { ONE: THREE.TOUCH.ROTATE, TWO: THREE.TOUCH.DOLLY_ROTATE };
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2(-10, -10);
let hovered = null;
renderer.domElement.addEventListener('pointermove', (e) => {
const r = renderer.domElement.getBoundingClientRect();
pointer.x = ((e.clientX - r.left) / r.width) * 2 - 1;
pointer.y = -((e.clientY - r.top) / r.height) * 2 + 1;
});
renderer.domElement.addEventListener('pointerleave', () => {
pointer.set(-10, -10);
});
renderer.domElement.addEventListener('click', () => {
if (!hovered) return;
const d = doors.find((x) => x.door === hovered);
if (!d) return;
d.open = !d.open;
d.target = d.open ? -Math.PI * 0.62 : 0;
});
/* ============================ 尺寸自适应 ============================ */
function resize() {
const stage = renderer.domElement.parentElement;
const w = Math.max(1, stage.clientWidth);
const h = Math.max(1, stage.clientHeight);
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
window.addEventListener('resize', resize);
resize();
/* ============================ 主循环 ============================ */
const clock = new THREE.Clock();
let visible = true;
// 页面隐藏 / 舞台滚出视口时暂停,省电省 CPU
document.addEventListener('visibilitychange', () => {
visible = !document.hidden;
if (visible) clock.getDelta();
});
if ('IntersectionObserver' in window) {
new IntersectionObserver((entries) => {
visible = entries[0].isIntersecting && !document.hidden;
if (visible) clock.getDelta();
}, { threshold: 0.01 }).observe(renderer.domElement);
}
function tick() {
requestAnimationFrame(tick);
if (!visible) return;
const dt = Math.min(clock.getDelta(), 0.05);
const t = clock.elapsedTime;
// 仓门开合(缓动)
for (const d of doors) {
const cur = d.pivot.rotation.y;
const next = cur + (d.target - cur) * Math.min(1, dt * 7);
d.pivot.rotation.y = next;
// 悬停高亮
const wantEmissive = d.door === hovered ? 0.55 : 0;
d.light.material.emissiveIntensity +=
(d.baseEmissive + wantEmissive - d.light.material.emissiveIntensity) * Math.min(1, dt * 8);
// 状态灯轻微呼吸(有在充的仓才亮;这里取一部分做"使用中"的观感)
if ((d.row + d.col) % 3 === 0) {
const pulse = 0.75 + 0.25 * Math.sin(t * 2.2 + d.phase);
d.light.material.emissiveIntensity *= pulse;
}
}
controls.update();
renderer.render(scene, camera);
}
tick();
/* ============================ 悬停射线检测 ============================ */
setInterval(() => {
if (!visible) return;
raycaster.setFromCamera(pointer, camera);
const hits = raycaster.intersectObjects(doors.map((d) => d.door), false);
const first = hits.length ? hits[0].object : null;
if (first !== hovered) {
hovered = first;
renderer.domElement.style.cursor = hovered ? 'pointer' : 'grab';
}
}, 60);
// 供页面判断 3D 是否就绪(失败则回落静态图)
window.__cabinet3d = { ok: true, doors: doors.length };