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、消费链探测通过、二进制时间戳已核对)
This commit is contained in:
12451 2026-09-21 05:02:09 +08:00
parent 8e7435005e
commit 0441d91d1f
8 changed files with 2348 additions and 72 deletions

430
deploy/landing/cabinet3d.js Normal file
View File

@ -0,0 +1,430 @@
/**
* 艾动智能 · 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 };

View File

@ -50,35 +50,69 @@ a { color: inherit; text-decoration: none; }
/* ===== 整体:上 / 中 / 下 三块,中块自适应 ===== */ /* ===== 整体:上 / 中 / 下 三块,中块自适应 ===== */
.layout { display: flex; flex-direction: column; height: 100vh; } .layout { display: flex; flex-direction: column; height: 100vh; }
/* ===== 背景:一张全屏产品主视觉图(cover 填满,永不变形)===== /* ===== 背景:浅色渐变 + 右侧 3D 舞台 =====
图是专门为这个布局生成的:柜子在画面右侧约四成,左侧是纯净负空间。 柜子由 Three.js 实时渲染在透明 canvas 上,"浮"在这层浅色渐变上 ——
::after 再叠一层从左到右渐淡的白色轻纱,保证左侧文字对比度。 */ ⛔ 不存在图片硬边界(这正是之前"贴图感"的根因)。
3D 不可用(WebGL 缺失/脚本异常)时,JS 会给 <body> 加 .no3d 类,
由 .stage-fallback 展示静态主视觉图,页面照常可用。 */
.bg { .bg {
position: fixed; inset: 0; z-index: 0; pointer-events: none; position: fixed; inset: 0; z-index: 0; pointer-events: none;
background:
radial-gradient(1100px 760px at 74% 42%, rgba(3,102,230,0.070), transparent 66%),
radial-gradient(900px 640px at 92% 86%, rgba(3,102,230,0.045), transparent 70%),
linear-gradient(to right, #ffffff 0%, #f7f9fc 46%, #eef3fa 100%);
}
/* 3D 舞台:右侧,柜子在其中居中。
⚠️ 上下留 84px 内边距 —— 顶天顶地会显得拥挤、压迫,留白才有"产品感"。 */
.stage {
position: fixed; z-index: 1;
top: 84px; right: 0; bottom: 84px;
width: min(52vw, 940px);
pointer-events: auto;
}
.stage canvas {
display: block; width: 100%; height: 100%;
/* 兜底:WebGL 的 alpha 合成在部分环境会失败(实测 antialias:false + 软件渲染下
canvas 未绘制区域变成黑块)。给画布自身铺一层浅色背景 ——
alpha 正常时透出下面的页面渐变;失败时也只是一片浅色,绝不会露黑。 */
background: linear-gradient(155deg, #ffffff 0%, #f5f8fd 52%, #e8f0fa 100%);
}
/* 静态回落图:默认隐藏,仅 .no3d 时显示 */
.stage-fallback {
position: absolute; inset: 0;
background: url("hero-full.jpg") no-repeat center center; background: url("hero-full.jpg") no-repeat center center;
background-size: cover; background-size: cover;
display: none;
} }
.bg::after { body.no3d .stage-fallback { display: block; }
content: ''; position: absolute; inset: 0; body.no3d .stage canvas { display: none; }
/* 左侧轻纱:保证毛玻璃上的文字对比度(3D 模式下也要) */
.scrim {
position: fixed; inset: 0; z-index: 1; pointer-events: none;
background: linear-gradient(to right, background: linear-gradient(to right,
rgba(255,255,255,0.62) 0%, rgba(255,255,255,0.60) 0%,
rgba(255,255,255,0.34) 38%, rgba(255,255,255,0.30) 34%,
rgba(255,255,255,0.06) 58%, rgba(255,255,255,0.04) 54%,
transparent 72%); transparent 66%);
} }
/* ===== TOP: 顶部导航 ===== */ /* ===== TOP: 顶部导航 =====
⚠️ pointer-events 的坑:.top/.mid/.bot 都是**通栏**容器,子元素只占一侧,
但容器自身会横跨整个宽度并把鼠标事件吃掉 —— 右侧 3D canvas 就收不到
拖拽/滚轮了(OrbitControls 装在 canvas 上)。所以通栏容器一律
pointer-events:none,只让真正可交互的子元素恢复 auto。 */
.top { .top {
position: relative; z-index: 2; position: relative; z-index: 4;
flex-shrink: 0; flex-shrink: 0;
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
padding: 14px 48px; padding: 14px 48px;
pointer-events: none;
} }
.top-brand { display: flex; align-items: center; } .top-brand { display: flex; align-items: center; pointer-events: auto; }
/* 横版锁标 1511.75×413.59(≈3.65:1);48px 高 ⇒ 约 175px 宽 */ /* 横版锁标 1511.75×413.59(≈3.65:1);48px 高 ⇒ 约 175px 宽 */
.logo { height: 46px; width: auto; display: block; } .logo { height: 46px; width: auto; display: block; }
.top-links { display: flex; align-items: center; gap: 6px; } .top-links { display: flex; align-items: center; gap: 6px; pointer-events: auto; }
.top-link { .top-link {
font-size: 13px; font-weight: 500; color: var(--body); font-size: 13px; font-weight: 500; color: var(--body);
padding: 8px 16px; border-radius: 8px; transition: all .2s; padding: 8px 16px; border-radius: 8px; transition: all .2s;
@ -87,22 +121,25 @@ a { color: inherit; text-decoration: none; }
.top-link--cta { background: var(--ink); color: #fff; } .top-link--cta { background: var(--ink); color: #fff; }
.top-link--cta:hover { background: #23272F; color: #fff; } .top-link--cta:hover { background: #23272F; color: #fff; }
/* ===== MID: 全屏图上叠一块毛玻璃文案面板 ===== */ /* ===== MID: 浅色渐变上叠一块毛玻璃文案面板 =====
⚠️ 容器通栏但 pointer-events:none(否则挡住右侧 3D 的鼠标事件),
只让 .copy 面板自身可交互。 */
.mid { .mid {
position: relative; z-index: 1; position: relative; z-index: 3;
flex: 1; min-height: 0; flex: 1; min-height: 0;
display: flex; align-items: center; display: flex; align-items: center;
padding: 0 56px; padding: 0 56px;
pointer-events: none;
} }
/* ---- 毛玻璃面板 ---- /* ---- 毛玻璃面板 ----
半透明白底 + 背景模糊:背后的柜子与渐变会隐约透出来, 半透明白底 + 背景模糊:背后的柜子与渐变会隐约透出来,
边界从"贴图的硬边"变成"玻璃的质感"——这是消除突兀感的关键。 */ 边界从"贴图的硬边"变成"玻璃的质感"——这是消除突兀感的关键。 */
.copy { .copy {
width: min(720px, 46vw); width: min(720px, 44vw);
padding: 40px 44px; padding: 40px 44px;
display: flex; flex-direction: column; gap: 20px; display: flex; flex-direction: column; gap: 20px;
background: rgba(255,255,255,0.58); background: rgba(255,255,255,0.56);
-webkit-backdrop-filter: blur(26px) saturate(1.5); -webkit-backdrop-filter: blur(26px) saturate(1.5);
backdrop-filter: blur(26px) saturate(1.5); backdrop-filter: blur(26px) saturate(1.5);
border: 1px solid rgba(255,255,255,0.72); border: 1px solid rgba(255,255,255,0.72);
@ -111,6 +148,7 @@ a { color: inherit; text-decoration: none; }
0 24px 60px rgba(15,20,25,0.10), 0 24px 60px rgba(15,20,25,0.10),
0 2px 8px rgba(15,20,25,0.04), 0 2px 8px rgba(15,20,25,0.04),
inset 0 1px 0 rgba(255,255,255,0.85); inset 0 1px 0 rgba(255,255,255,0.85);
pointer-events: auto; /* 面板自身要能选中文字/点按钮 */
} }
/* 小标签:品牌蓝 + 呼吸圆点 */ /* 小标签:品牌蓝 + 呼吸圆点 */
@ -213,23 +251,95 @@ h1 .accent { color: var(--brand); }
/* ===== 自适应:窄屏时收紧,保证仍然单屏不滚动 ===== */ /* ===== 自适应:窄屏时收紧,保证仍然单屏不滚动 ===== */
@media (max-width: 1180px) { @media (max-width: 1180px) {
.mid { padding: 0 32px; } .mid { padding: 0 32px; }
.copy { width: min(640px, 62vw); padding: 32px 34px; } .copy { width: min(640px, 60vw); padding: 32px 34px; }
.stage { width: min(64vw, 760px); }
.stats { gap: 26px; } .stats { gap: 26px; }
.stat-num { font-size: 26px; } .stat-num { font-size: 26px; }
} }
/* 更窄/更矮时:面板进一步收紧,并减弱背后的白纱让图更多露出来 */ /* 更窄/更矮时:面板占满,3D 舞台退到背后并减弱轻纱 */
@media (max-width: 820px), (max-height: 600px) { @media (max-width: 820px), (max-height: 600px) {
.copy { width: 100%; padding: 26px 26px; } .copy { width: 100%; padding: 26px 26px; }
.bg::after { .stage { width: 100%; opacity: 0.55; }
.scrim {
background: linear-gradient(to right, background: linear-gradient(to right,
rgba(255,255,255,0.72) 0%, rgba(255,255,255,0.42) 55%, transparent 85%); rgba(255,255,255,0.74) 0%, rgba(255,255,255,0.44) 55%, transparent 88%);
} }
} }
/* 用户偏好减少动效时,关掉自动旋转(JS 里也会读) */
@media (prefers-reduced-motion: reduce) {
.tag .dot { animation: none; }
}
/* ==========================================================================
移动端适配(竖屏 / 窄屏)
桌面的「fixed 右侧舞台 + 通栏轻纱 + 单屏不滚动」在竖屏上会挤爆,
这里改成:3D 变成顶部 hero 块,文案在下面正常流式排列,页面可滚动。
========================================================================== */
@media (max-width: 860px) {
/* 桌面是单屏不滚动;移动端内容多,必须允许纵向滚动 */
html, body { height: auto; overflow-x: hidden; overflow-y: auto; }
.layout { height: auto; min-height: 100vh; }
body { -webkit-text-size-adjust: 100%; }
/* 顶部导航收紧 */
.top { padding: 12px 18px; }
.logo { height: 34px; }
.top-link { padding: 6px 12px; font-size: 12px; }
.top-link--cta { padding: 7px 14px; }
/* 3D 变成顶部 hero 块:有界、不固定、给足高度 */
.stage {
position: relative;
top: auto; right: auto; bottom: auto;
width: 100%;
height: 46vh;
min-height: 280px;
max-height: 460px;
}
/* 单指拖拽旋转模型,不要拿去滚页面(舞台是有界块,周围仍可滚动) */
.stage canvas { touch-action: none; }
/* 通栏轻纱在移动端会糊掉 hero,去掉;背景改简洁浅色渐变 */
.scrim { display: none; }
.bg {
background: linear-gradient(to bottom, #ffffff 0%, #f4f7fc 60%, #eaf0f9 100%);
}
/* 文案面板:通栏、贴住 hero 底部 */
.mid { padding: 0 16px; margin-top: 6px; align-items: flex-start; }
.copy {
width: 100%;
padding: 26px 22px;
border-radius: 18px;
/* 移动端毛玻璃成本高(backdrop-filter + 3D 重绘),改用更实的白底保证可读 */
background: rgba(255,255,255,0.90);
-webkit-backdrop-filter: none;
backdrop-filter: none;
}
h1 { font-size: clamp(28px, 7.6vw, 40px); line-height: 1.22; }
.subhead { font-size: 14px; line-height: 1.75; }
.pill { font-size: 12px; padding: 4px 11px; }
.btn { padding: 12px 20px; font-size: 14px; }
.stats { gap: 18px 22px; flex-wrap: wrap; }
.stat-num { font-size: 22px; }
.stat-num .unit { font-size: 13px; }
.stat-label { font-size: 11px; }
.bot { padding: 16px 12px 20px; font-size: 11px; }
}
/* 极窄屏(老手机 / 分屏)*/
@media (max-width: 380px) {
.stage { height: 38vh; min-height: 220px; }
.copy { padding: 20px 16px; }
.stats { gap: 14px 18px; }
.stat-num { font-size: 19px; }
}
</style> </style>
</head> </head>
<body> <body>
<div class="bg"></div> <div class="bg"></div>
<div class="scrim" aria-hidden="true"></div>
<div class="layout"> <div class="layout">
@ -245,8 +355,49 @@ h1 .accent { color: var(--brand); }
</div> </div>
</div> </div>
<!-- 中:左文右图 --> <!-- 3D 舞台:桌面端 fixed 到右侧;移动端变为顶部 hero 块(见媒体查询)。
<div class="mid"> 柜子由 Three.js 实时渲染;不可用时回落静态图。 -->
<div class="stage" id="stage">
<div class="stage-fallback" aria-hidden="true"></div>
<canvas id="cabinet-canvas"></canvas>
</div>
<script type="importmap">
{
"imports": {
"three": "./vendor/three.module.min.js",
"three/addons/": "./vendor/addons/"
}
}
</script>
<script type="module">
// 3D 就绪/失败都要有明确信号:失败则 <body> 加 .no3d,展示静态主视觉图
const fail = (why) => {
document.body.classList.add('no3d');
console.warn('[cabinet3d] 回落到静态图:', why);
};
try {
// 无 WebGL 直接回落,不必加载 700KB 的 three
const probe = document.createElement('canvas');
if (!(probe.getContext('webgl2') || probe.getContext('webgl'))) {
fail('浏览器不支持 WebGL');
} else {
import('./cabinet3d.js')
.then(() => {
if (window.__cabinet3d && window.__cabinet3d.ok) {
document.body.classList.add('ready3d');
} else {
fail('场景未就绪');
}
})
.catch((e) => fail(e));
}
} catch (e) {
fail(e);
}
</script>
<div class="mid">
<div class="copy"> <div class="copy">
<div class="tag"><span class="dot"></span>智能充电矩阵系统</div> <div class="tag"><span class="dot"></span>智能充电矩阵系统</div>
@ -289,7 +440,6 @@ h1 .accent { color: var(--brand); }
<div class="bot"> <div class="bot">
© 2026 艾动智能 · <a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">苏ICP备2025198376号-1</a> © 2026 艾动智能 · <a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">苏ICP备2025198376号-1</a>
</div> </div>
</div> </div>
</body> </body>

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -24,6 +24,10 @@ pub(crate) struct UserListQuery {
page: Option<u32>, page: Option<u32>,
page_size: Option<u32>, page_size: Option<u32>,
keyword: Option<String>, keyword: Option<String>,
/// 角色过滤:`0`=操作员 `1`=企业管理员 `2`=总管理员;不给 = 全部
///
/// 用户管理页只有这三种角色,默认罗列全部太吵,故由前端下拉过滤。
role: Option<i32>,
} }
/// 用户列表数据库行映射(替代7元素元组,sqlx 不支持超过6元素的元组) /// 用户列表数据库行映射(替代7元素元组,sqlx 不支持超过6元素的元组)
@ -218,40 +222,69 @@ async fn log_operation(
/// ///
/// 返回 (where_clause, bind_org):where_clause 用于 COUNT 与列表查询, /// 返回 (where_clause, bind_org):where_clause 用于 COUNT 与列表查询,
/// bind_org 为需要追加绑定的组织过滤值(无过滤则为 None)。 /// bind_org 为需要追加绑定的组织过滤值(无过滤则为 None)。
fn build_user_list_where(has_keyword: bool, org_filter: Option<i64>) -> (String, Option<i64>) { /// 用户列表过滤条件。
match (has_keyword, org_filter) { ///
(true, Some(org_id)) => ( /// ⚠️ 三个过滤器(关键字 / 组织 / 角色)都可选,用**条件累加**而不是穷举 match ——
"WHERE (phone LIKE ? OR name LIKE ?) AND organization_id = ?".to_string(), /// 穷举会随过滤器数量指数膨胀(2→3 个就是 8 分支,且极易漏)。
Some(org_id), ///
), /// ⛔ **绑定顺序固定为:keyword、keyword、org、role**,与 [`Self::clause`] 里
(true, None) => ( /// 占位符的出现顺序严格一致。调用方必须按此顺序 `bind`。
"WHERE phone LIKE ? OR name LIKE ?".to_string(), struct UserListFilter {
None, /// WHERE 子句(不含 `WHERE` 关键字本身;无条件时为空串)
), clause: String,
(false, Some(org_id)) => ( has_keyword: bool,
"WHERE organization_id = ?".to_string(), bind_org: Option<i64>,
Some(org_id), bind_role: Option<i32>,
), }
(false, None) => (String::new(), None),
fn build_user_list_where(
has_keyword: bool,
org_filter: Option<i64>,
role_filter: Option<i32>,
) -> UserListFilter {
let mut conds: Vec<&str> = Vec::new();
if has_keyword {
// ⛔ 必须加括号:`phone LIKE ? OR name LIKE ?` 与其它条件用 AND 混排时,
// 不加括号会变成 `(A AND phone LIKE ?) OR name LIKE ?`,
// 把组织/角色过滤整个击穿(搜个姓名就跨组织串数据)。
conds.push("(phone LIKE ? OR name LIKE ?)");
}
if org_filter.is_some() {
conds.push("organization_id = ?");
}
if role_filter.is_some() {
conds.push("role = ?");
}
UserListFilter {
clause: if conds.is_empty() {
String::new()
} else {
format!("WHERE {}", conds.join(" AND "))
},
has_keyword,
bind_org: org_filter,
bind_role: role_filter,
} }
} }
/// 统计符合条件的用户总数 /// 统计符合条件的用户总数
async fn count_users( async fn count_users(
pool: &sqlx::MySqlPool, pool: &sqlx::MySqlPool,
where_clause: &str, filter: &UserListFilter,
has_keyword: bool,
like_pattern: &str, like_pattern: &str,
bind_org: Option<i64>,
) -> Result<i64, AppError> { ) -> Result<i64, AppError> {
let sql = format!("SELECT COUNT(*) FROM users {}", where_clause); let sql = format!("SELECT COUNT(*) FROM users {}", filter.clause);
let mut q = sqlx::query_as::<_, (i64,)>(&sql); let mut q = sqlx::query_as::<_, (i64,)>(&sql);
if has_keyword { // 绑定顺序必须与 clause 中占位符顺序一致:keyword ×2 → org → role
if filter.has_keyword {
q = q.bind(like_pattern).bind(like_pattern); q = q.bind(like_pattern).bind(like_pattern);
} }
if let Some(org_id) = bind_org { if let Some(org_id) = filter.bind_org {
q = q.bind(org_id); q = q.bind(org_id);
} }
if let Some(role) = filter.bind_role {
q = q.bind(role);
}
let total: (i64,) = q.fetch_one(pool).await?; let total: (i64,) = q.fetch_one(pool).await?;
Ok(total.0) Ok(total.0)
} }
@ -259,10 +292,8 @@ async fn count_users(
/// 查询当前页的用户行(分页 + 排序) /// 查询当前页的用户行(分页 + 排序)
async fn fetch_user_rows( async fn fetch_user_rows(
pool: &sqlx::MySqlPool, pool: &sqlx::MySqlPool,
where_clause: &str, filter: &UserListFilter,
has_keyword: bool,
like_pattern: &str, like_pattern: &str,
bind_org: Option<i64>,
page_size: u32, page_size: u32,
offset: u32, offset: u32,
) -> Result<Vec<UserRow>, AppError> { ) -> Result<Vec<UserRow>, AppError> {
@ -271,15 +302,24 @@ async fn fetch_user_rows(
FROM users u {} FROM users u {}
ORDER BY u.id DESC ORDER BY u.id DESC
LIMIT ? OFFSET ?"#, LIMIT ? OFFSET ?"#,
where_clause.replace("phone LIKE", "u.phone LIKE").replace("name LIKE", "u.name LIKE") // `FROM users u` 起了别名,列名要限定到 u(无 JOIN,不限定也能跑,但限定更稳)
filter
.clause
.replace("phone LIKE", "u.phone LIKE")
.replace("name LIKE", "u.name LIKE")
.replace("organization_id =", "u.organization_id =")
.replace("role =", "u.role =")
); );
let mut q = sqlx::query_as::<_, UserRow>(&sql); let mut q = sqlx::query_as::<_, UserRow>(&sql);
if has_keyword { if filter.has_keyword {
q = q.bind(like_pattern).bind(like_pattern); q = q.bind(like_pattern).bind(like_pattern);
} }
if let Some(org_id) = bind_org { if let Some(org_id) = filter.bind_org {
q = q.bind(org_id); q = q.bind(org_id);
} }
if let Some(role) = filter.bind_role {
q = q.bind(role);
}
Ok(q.bind(page_size).bind(offset).fetch_all(pool).await?) Ok(q.bind(page_size).bind(offset).fetch_all(pool).await?)
} }
@ -371,23 +411,17 @@ pub async fn list_users(
}))); })));
} }
// 构建通用 WHERE 子句(避免 count/list 重复维护3个分支) // 角色过滤:只接受 0/1/2,越界值当作"不过滤"(比静默查空更安全)
let (where_clause, bind_org) = build_user_list_where(has_keyword, org_filter); let role_filter = query.role.filter(|r| (0..=2).contains(r));
// 构建通用 WHERE 子句(条件累加,避免 count/list 重复维护分支)
let filter = build_user_list_where(has_keyword, org_filter, role_filter);
// 查询总数 // 查询总数
let total = count_users(pool, &where_clause, has_keyword, &like_pattern, bind_org).await?; let total = count_users(pool, &filter, &like_pattern).await?;
// 查询用户列表 // 查询用户列表
let rows = fetch_user_rows( let rows = fetch_user_rows(pool, &filter, &like_pattern, page_size, offset).await?;
pool,
&where_clause,
has_keyword,
&like_pattern,
bind_org,
page_size,
offset,
)
.await?;
// 收集组织ID并批量查询组织名称 // 收集组织ID并批量查询组织名称
let org_map = load_org_name_map(pool, &rows).await?; let org_map = load_org_name_map(pool, &rows).await?;
@ -842,3 +876,82 @@ pub async fn delete_user(
Ok(Json(json!({ "code": 0, "data": null, "message": "删除成功" }))) Ok(Json(json!({ "code": 0, "data": null, "message": "删除成功" })))
} }
#[cfg(test)]
mod tests {
use super::*;
/// 无任何过滤 ⇒ 空子句
#[test]
fn test_user_list_filter_none() {
let f = build_user_list_where(false, None, None);
assert_eq!(f.clause, "");
assert!(!f.has_keyword);
assert!(f.bind_org.is_none());
assert!(f.bind_role.is_none());
}
/// 仅关键字:OR 必须被括号包住(否则与其它条件 AND 混排会被击穿)
#[test]
fn test_user_list_filter_keyword_only_is_parenthesized() {
let f = build_user_list_where(true, None, None);
assert_eq!(f.clause, "WHERE (phone LIKE ? OR name LIKE ?)");
assert!(f.has_keyword);
}
/// 仅角色
#[test]
fn test_user_list_filter_role_only() {
let f = build_user_list_where(false, None, Some(0));
assert_eq!(f.clause, "WHERE role = ?");
assert_eq!(f.bind_role, Some(0));
}
/// 关键字 + 组织 + 角色三者叠加:顺序必须固定,bind 顺序才不会错位
#[test]
fn test_user_list_filter_all_three_conditions_order() {
let f = build_user_list_where(true, Some(7), Some(1));
assert_eq!(
f.clause,
"WHERE (phone LIKE ? OR name LIKE ?) AND organization_id = ? AND role = ?"
);
assert!(f.has_keyword);
assert_eq!(f.bind_org, Some(7));
assert_eq!(f.bind_role, Some(1));
}
/// 反证:OR 忘加括号会红 —— 防止"搜姓名跨组织串数据"
#[test]
fn test_user_list_filter_keyword_or_is_guarded() {
let f = build_user_list_where(true, Some(7), None);
assert!(
f.clause.contains("(phone LIKE ? OR name LIKE ?)"),
"OR 条件必须整体括起来,否则组织过滤会被击穿。实际: {}",
f.clause
);
assert!(!f.clause.contains("WHERE phone LIKE"), "OR 未被括号包裹");
}
/// 占位符个数必须与"会执行的 bind 次数"一致
#[test]
fn test_user_list_filter_placeholder_count_matches_binds() {
for (kw, org, role) in [
(false, None, None),
(true, None, None),
(false, Some(1), None),
(false, None, Some(2)),
(true, Some(1), Some(0)),
] {
let f = build_user_list_where(kw, org, role);
let placeholders = f.clause.matches('?').count();
let binds = if f.has_keyword { 2 } else { 0 }
+ f.bind_org.is_some() as usize
+ f.bind_role.is_some() as usize;
assert_eq!(
placeholders, binds,
"占位符数与 bind 数不一致(kw={:?} org={:?} role={:?})clause={}",
kw, org, role, f.clause
);
}
}
}

View File

@ -52,6 +52,17 @@ export interface Cabinet {
pow_fail_ac?: boolean | null; pow_fail_ac?: boolean | null;
/** 充电工作模式:`"0"`=平台指令模式(默认) `"1"`=自动充电工作模式;null/缺失 = 后端未返回,按默认处理 */ /** 充电工作模式:`"0"`=平台指令模式(默认) `"1"`=自动充电工作模式;null/缺失 = 后端未返回,按默认处理 */
work_mode?: string | null; work_mode?: string | null;
/**
* **最新电表读数**(协议 §5.3:`status_post` 的 `cabinet.meter`,单位 **Wh**,
* 硬件 0.1kWh 读数 ×100 后上报,如 `125500` = 125.5 kWh)
*
* 数据源为 `cabinets.meter_wh`(每次读数变化时更新,见后端 `workers/meter.rs`)。
* ⚠️ `null`/`undefined` = **该柜从未上报过电表读数**,与"读数为 0"是两回事,
* 界面必须展示 "-"(无数据)而不是 0 度。
*/
meter_wh?: number | null;
/** 最新电表读数的收到时刻(ISO 字符串);`null` = 从未上报过 */
meter_at?: string | null;
is_online?: boolean; is_online?: boolean;
/** 所属组织是否被禁用 */ /** 所属组织是否被禁用 */
org_disabled?: boolean; org_disabled?: boolean;

View File

@ -584,6 +584,19 @@ export default function CabinetDetail() {
<span>IMEI: <b style={{ color: 'var(--color-text-1)' }}>{detail.imei}</b> <IconCopy style={{ cursor: 'pointer', color: 'var(--color-text-3)', fontSize: 12 }} onClick={() => copyText(detail.imei, 'IMEI')} /></span> <span>IMEI: <b style={{ color: 'var(--color-text-1)' }}>{detail.imei}</b> <IconCopy style={{ cursor: 'pointer', color: 'var(--color-text-3)', fontSize: 12 }} onClick={() => copyText(detail.imei, 'IMEI')} /></span>
<span>ICCID: <b style={{ color: 'var(--color-text-1)' }}>{detail.iccid || '-'}</b> {detail.iccid && <IconCopy style={{ cursor: 'pointer', color: 'var(--color-text-3)', fontSize: 12 }} onClick={() => copyText(detail.iccid!, 'ICCID')} />}</span> <span>ICCID: <b style={{ color: 'var(--color-text-1)' }}>{detail.iccid || '-'}</b> {detail.iccid && <IconCopy style={{ cursor: 'pointer', color: 'var(--color-text-3)', fontSize: 12 }} onClick={() => copyText(detail.iccid!, 'ICCID')} />}</span>
<span>名称: <b style={{ color: 'var(--color-text-1)' }}>{detail.name || '-'}</b></span> <span>名称: <b style={{ color: 'var(--color-text-1)' }}>{detail.name || '-'}</b></span>
{/* 电表读数(整柜累计电能,Wh → 界面按 kWh 展示)。
⚠️ null = 从未上报过,必须显示 "-"(无数据)而不是 0 度。 */}
<span>
电表读数:{' '}
<b style={{ color: 'var(--color-text-1)' }}>
{detail.meter_wh == null ? '-' : `${(detail.meter_wh / 1000).toFixed(1)} kWh`}
</b>
{detail.meter_at && (
<span style={{ color: 'var(--color-text-4)', fontSize: 11 }}>
{' '}({detail.meter_at.slice(0, 19).replace('T', ' ')})
</span>
)}
</span>
</div> </div>
<Space> <Space>
{/* 开门(柜级解锁):权限码 charge:open_door(与后端 check_permission 一致)。 {/* 开门(柜级解锁):权限码 charge:open_door(与后端 check_permission 一致)。

View File

@ -62,6 +62,8 @@ export default function UsersPage() {
const [pageSize] = useState(20); const [pageSize] = useState(20);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
/** 角色过滤:'' = 全部;0/1/2 = 操作员/企业管理员/总管理员 */
const [roleFilter, setRoleFilter] = useState<number | ''>('');
// 弹窗状态 // 弹窗状态
const [modalVisible, setModalVisible] = useState(false); const [modalVisible, setModalVisible] = useState(false);
@ -178,8 +180,9 @@ export default function UsersPage() {
const buildQuery = useCallback(() => { const buildQuery = useCallback(() => {
const params: Record<string, string | number> = { page, page_size: pageSize }; const params: Record<string, string | number> = { page, page_size: pageSize };
if (keyword.trim()) params.keyword = keyword.trim(); if (keyword.trim()) params.keyword = keyword.trim();
if (roleFilter !== '') params.role = roleFilter;
return params; return params;
}, [page, pageSize, keyword]); }, [page, pageSize, keyword, roleFilter]);
/** 加载用户列表 */ /** 加载用户列表 */
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
@ -205,6 +208,12 @@ export default function UsersPage() {
fetchData(); fetchData();
}; };
/** 角色过滤变化:回到第一页并重新查 */
const handleRoleFilterChange = (v: number | '') => {
setRoleFilter(v);
setPage(1);
};
/** 打开创建弹窗 */ /** 打开创建弹窗 */
const handleCreate = () => { const handleCreate = () => {
setEditingUser(null); setEditingUser(null);
@ -261,9 +270,9 @@ export default function UsersPage() {
Message.warning('请输入密码'); Message.warning('请输入密码');
return; return;
} }
// 操作员/企业管理员必须选组织 // 操作员/企业管理员必须选组织(⛔ 操作员没有企业归属就看不到任何设备)
if (formValues.role !== 2 && !formValues.organization_id) { if (formValues.role !== 2 && !formValues.organization_id) {
Message.warning('请选择所属组织'); Message.warning(formValues.role === 0 ? '操作员必须选择所属组织' : '请选择所属组织');
return; return;
} }
setSaving(true); setSaving(true);
@ -375,9 +384,18 @@ export default function UsersPage() {
<Permission code="user:edit"> <Permission code="user:edit">
<Button type="text" size="small" icon={<IconEdit />} onClick={() => handleEdit(record)} /> <Button type="text" size="small" icon={<IconEdit />} onClick={() => handleEdit(record)} />
</Permission> </Permission>
{/* 数据范围:与「编辑用户」同一权限码(后端 PUT /users/:id/scopes 校验 user:edit) */} {/* 数据范围:**仅操作员(role=0)可配**。
总管理员(role>=2)豁免范围限制;企业管理员(role=1)天然管本组织全部设备,
配项目级范围反而会有歧义("管自己企业"已隐含全部设备)。
与「编辑用户」同一权限码(后端 PUT /users/:id/scopes 校验 user:edit) */}
<Permission code="user:edit"> <Permission code="user:edit">
<Button type="text" size="small" onClick={() => handleOpenScopes(record)}> <Button
type="text"
size="small"
disabled={record.role !== 0}
title={record.role === 0 ? '设置可管理的项目' : '仅操作员需要设置数据范围'}
onClick={() => record.role === 0 && handleOpenScopes(record)}
>
数据范围 数据范围
</Button> </Button>
</Permission> </Permission>
@ -409,8 +427,8 @@ export default function UsersPage() {
</Space> </Space>
</Space> </Space>
{/* 搜索 */} {/* 搜索 + 角色过滤 */}
<Space> <Space wrap>
<Input.Search <Input.Search
placeholder="搜索手机号/姓名" placeholder="搜索手机号/姓名"
value={keyword} value={keyword}
@ -419,10 +437,22 @@ export default function UsersPage() {
style={{ width: 260 }} style={{ width: 260 }}
allowClear allowClear
/> />
{/* 角色过滤:只有三种角色,默认"全部"太吵 */}
<Select
value={roleFilter}
onChange={handleRoleFilterChange}
style={{ width: 150 }}
placeholder="全部角色"
allowClear
options={ROLE_OPTIONS}
/>
<Button icon={<IconSearch />} onClick={handleSearch}> <Button icon={<IconSearch />} onClick={handleSearch}>
查询 查询
</Button> </Button>
<Button icon={<IconRefresh />} onClick={() => { setKeyword(''); setPage(1); }}> <Button
icon={<IconRefresh />}
onClick={() => { setKeyword(''); setRoleFilter(''); setPage(1); }}
>
重置 重置
</Button> </Button>
</Space> </Space>