431 lines
15 KiB
JavaScript
Raw Permalink Normal View History

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
/**
* 艾动智能 · 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 };