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:
parent
8e7435005e
commit
0441d91d1f
430
deploy/landing/cabinet3d.js
Normal file
430
deploy/landing/cabinet3d.js
Normal 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 };
|
||||
@ -50,35 +50,69 @@ a { color: inherit; text-decoration: none; }
|
||||
/* ===== 整体:上 / 中 / 下 三块,中块自适应 ===== */
|
||||
.layout { display: flex; flex-direction: column; height: 100vh; }
|
||||
|
||||
/* ===== 背景:一张全屏产品主视觉图(cover 填满,永不变形)=====
|
||||
图是专门为这个布局生成的:柜子在画面右侧约四成,左侧是纯净负空间。
|
||||
::after 再叠一层从左到右渐淡的白色轻纱,保证左侧文字对比度。 */
|
||||
/* ===== 背景:浅色渐变 + 右侧 3D 舞台 =====
|
||||
柜子由 Three.js 实时渲染在透明 canvas 上,"浮"在这层浅色渐变上 ——
|
||||
⛔ 不存在图片硬边界(这正是之前"贴图感"的根因)。
|
||||
3D 不可用(WebGL 缺失/脚本异常)时,JS 会给 <body> 加 .no3d 类,
|
||||
由 .stage-fallback 展示静态主视觉图,页面照常可用。 */
|
||||
.bg {
|
||||
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-size: cover;
|
||||
display: none;
|
||||
}
|
||||
.bg::after {
|
||||
content: ''; position: absolute; inset: 0;
|
||||
body.no3d .stage-fallback { display: block; }
|
||||
body.no3d .stage canvas { display: none; }
|
||||
/* 左侧轻纱:保证毛玻璃上的文字对比度(3D 模式下也要) */
|
||||
.scrim {
|
||||
position: fixed; inset: 0; z-index: 1; pointer-events: none;
|
||||
background: linear-gradient(to right,
|
||||
rgba(255,255,255,0.62) 0%,
|
||||
rgba(255,255,255,0.34) 38%,
|
||||
rgba(255,255,255,0.06) 58%,
|
||||
transparent 72%);
|
||||
rgba(255,255,255,0.60) 0%,
|
||||
rgba(255,255,255,0.30) 34%,
|
||||
rgba(255,255,255,0.04) 54%,
|
||||
transparent 66%);
|
||||
}
|
||||
|
||||
/* ===== TOP: 顶部导航 ===== */
|
||||
/* ===== TOP: 顶部导航 =====
|
||||
⚠️ pointer-events 的坑:.top/.mid/.bot 都是**通栏**容器,子元素只占一侧,
|
||||
但容器自身会横跨整个宽度并把鼠标事件吃掉 —— 右侧 3D canvas 就收不到
|
||||
拖拽/滚轮了(OrbitControls 装在 canvas 上)。所以通栏容器一律
|
||||
pointer-events:none,只让真正可交互的子元素恢复 auto。 */
|
||||
.top {
|
||||
position: relative; z-index: 2;
|
||||
position: relative; z-index: 4;
|
||||
flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
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 宽 */
|
||||
.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 {
|
||||
font-size: 13px; font-weight: 500; color: var(--body);
|
||||
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:hover { background: #23272F; color: #fff; }
|
||||
|
||||
/* ===== MID: 全屏图上叠一块毛玻璃文案面板 ===== */
|
||||
/* ===== MID: 浅色渐变上叠一块毛玻璃文案面板 =====
|
||||
⚠️ 容器通栏但 pointer-events:none(否则挡住右侧 3D 的鼠标事件),
|
||||
只让 .copy 面板自身可交互。 */
|
||||
.mid {
|
||||
position: relative; z-index: 1;
|
||||
position: relative; z-index: 3;
|
||||
flex: 1; min-height: 0;
|
||||
display: flex; align-items: center;
|
||||
padding: 0 56px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ---- 毛玻璃面板 ----
|
||||
半透明白底 + 背景模糊:背后的柜子与渐变会隐约透出来,
|
||||
边界从"贴图的硬边"变成"玻璃的质感"——这是消除突兀感的关键。 */
|
||||
.copy {
|
||||
width: min(720px, 46vw);
|
||||
width: min(720px, 44vw);
|
||||
padding: 40px 44px;
|
||||
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);
|
||||
backdrop-filter: blur(26px) saturate(1.5);
|
||||
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 2px 8px rgba(15,20,25,0.04),
|
||||
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) {
|
||||
.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; }
|
||||
.stat-num { font-size: 26px; }
|
||||
}
|
||||
/* 更窄/更矮时:面板进一步收紧,并减弱背后的白纱让图更多露出来 */
|
||||
/* 更窄/更矮时:面板占满,3D 舞台退到背后并减弱轻纱 */
|
||||
@media (max-width: 820px), (max-height: 600px) {
|
||||
.copy { width: 100%; padding: 26px 26px; }
|
||||
.bg::after {
|
||||
.stage { width: 100%; opacity: 0.55; }
|
||||
.scrim {
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="bg"></div>
|
||||
<div class="scrim" aria-hidden="true"></div>
|
||||
|
||||
<div class="layout">
|
||||
|
||||
@ -245,8 +355,49 @@ h1 .accent { color: var(--brand); }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中:左文右图 -->
|
||||
<div class="mid">
|
||||
<!-- 3D 舞台:桌面端 fixed 到右侧;移动端变为顶部 hero 块(见媒体查询)。
|
||||
柜子由 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="tag"><span class="dot"></span>智能充电矩阵系统</div>
|
||||
|
||||
@ -289,7 +440,6 @@ h1 .accent { color: var(--brand); }
|
||||
<div class="bot">
|
||||
© 2026 艾动智能 · <a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">苏ICP备2025198376号-1</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
|
||||
1523
deploy/landing/vendor/addons/controls/OrbitControls.js
vendored
Normal file
1523
deploy/landing/vendor/addons/controls/OrbitControls.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
6
deploy/landing/vendor/three.module.min.js
vendored
Normal file
6
deploy/landing/vendor/three.module.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -24,6 +24,10 @@ pub(crate) struct UserListQuery {
|
||||
page: Option<u32>,
|
||||
page_size: Option<u32>,
|
||||
keyword: Option<String>,
|
||||
/// 角色过滤:`0`=操作员 `1`=企业管理员 `2`=总管理员;不给 = 全部
|
||||
///
|
||||
/// 用户管理页只有这三种角色,默认罗列全部太吵,故由前端下拉过滤。
|
||||
role: Option<i32>,
|
||||
}
|
||||
|
||||
/// 用户列表数据库行映射(替代7元素元组,sqlx 不支持超过6元素的元组)
|
||||
@ -218,40 +222,69 @@ async fn log_operation(
|
||||
///
|
||||
/// 返回 (where_clause, bind_org):where_clause 用于 COUNT 与列表查询,
|
||||
/// 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)) => (
|
||||
"WHERE (phone LIKE ? OR name LIKE ?) AND organization_id = ?".to_string(),
|
||||
Some(org_id),
|
||||
),
|
||||
(true, None) => (
|
||||
"WHERE phone LIKE ? OR name LIKE ?".to_string(),
|
||||
None,
|
||||
),
|
||||
(false, Some(org_id)) => (
|
||||
"WHERE organization_id = ?".to_string(),
|
||||
Some(org_id),
|
||||
),
|
||||
(false, None) => (String::new(), None),
|
||||
/// 用户列表过滤条件。
|
||||
///
|
||||
/// ⚠️ 三个过滤器(关键字 / 组织 / 角色)都可选,用**条件累加**而不是穷举 match ——
|
||||
/// 穷举会随过滤器数量指数膨胀(2→3 个就是 8 分支,且极易漏)。
|
||||
///
|
||||
/// ⛔ **绑定顺序固定为:keyword、keyword、org、role**,与 [`Self::clause`] 里
|
||||
/// 占位符的出现顺序严格一致。调用方必须按此顺序 `bind`。
|
||||
struct UserListFilter {
|
||||
/// WHERE 子句(不含 `WHERE` 关键字本身;无条件时为空串)
|
||||
clause: String,
|
||||
has_keyword: bool,
|
||||
bind_org: Option<i64>,
|
||||
bind_role: Option<i32>,
|
||||
}
|
||||
|
||||
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(
|
||||
pool: &sqlx::MySqlPool,
|
||||
where_clause: &str,
|
||||
has_keyword: bool,
|
||||
filter: &UserListFilter,
|
||||
like_pattern: &str,
|
||||
bind_org: Option<i64>,
|
||||
) -> 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);
|
||||
if has_keyword {
|
||||
// 绑定顺序必须与 clause 中占位符顺序一致:keyword ×2 → org → role
|
||||
if filter.has_keyword {
|
||||
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);
|
||||
}
|
||||
if let Some(role) = filter.bind_role {
|
||||
q = q.bind(role);
|
||||
}
|
||||
let total: (i64,) = q.fetch_one(pool).await?;
|
||||
Ok(total.0)
|
||||
}
|
||||
@ -259,10 +292,8 @@ async fn count_users(
|
||||
/// 查询当前页的用户行(分页 + 排序)
|
||||
async fn fetch_user_rows(
|
||||
pool: &sqlx::MySqlPool,
|
||||
where_clause: &str,
|
||||
has_keyword: bool,
|
||||
filter: &UserListFilter,
|
||||
like_pattern: &str,
|
||||
bind_org: Option<i64>,
|
||||
page_size: u32,
|
||||
offset: u32,
|
||||
) -> Result<Vec<UserRow>, AppError> {
|
||||
@ -271,15 +302,24 @@ async fn fetch_user_rows(
|
||||
FROM users u {}
|
||||
ORDER BY u.id DESC
|
||||
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);
|
||||
if has_keyword {
|
||||
if filter.has_keyword {
|
||||
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);
|
||||
}
|
||||
if let Some(role) = filter.bind_role {
|
||||
q = q.bind(role);
|
||||
}
|
||||
Ok(q.bind(page_size).bind(offset).fetch_all(pool).await?)
|
||||
}
|
||||
|
||||
@ -371,23 +411,17 @@ pub async fn list_users(
|
||||
})));
|
||||
}
|
||||
|
||||
// 构建通用 WHERE 子句(避免 count/list 重复维护3个分支)
|
||||
let (where_clause, bind_org) = build_user_list_where(has_keyword, org_filter);
|
||||
// 角色过滤:只接受 0/1/2,越界值当作"不过滤"(比静默查空更安全)
|
||||
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(
|
||||
pool,
|
||||
&where_clause,
|
||||
has_keyword,
|
||||
&like_pattern,
|
||||
bind_org,
|
||||
page_size,
|
||||
offset,
|
||||
)
|
||||
.await?;
|
||||
let rows = fetch_user_rows(pool, &filter, &like_pattern, page_size, offset).await?;
|
||||
|
||||
// 收集组织ID并批量查询组织名称
|
||||
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": "删除成功" })))
|
||||
}
|
||||
|
||||
#[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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -52,6 +52,17 @@ export interface Cabinet {
|
||||
pow_fail_ac?: boolean | null;
|
||||
/** 充电工作模式:`"0"`=平台指令模式(默认) `"1"`=自动充电工作模式;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;
|
||||
/** 所属组织是否被禁用 */
|
||||
org_disabled?: boolean;
|
||||
|
||||
@ -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>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>
|
||||
{/* 电表读数(整柜累计电能,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>
|
||||
<Space>
|
||||
{/* 开门(柜级解锁):权限码 charge:open_door(与后端 check_permission 一致)。
|
||||
|
||||
@ -62,6 +62,8 @@ export default function UsersPage() {
|
||||
const [pageSize] = useState(20);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
/** 角色过滤:'' = 全部;0/1/2 = 操作员/企业管理员/总管理员 */
|
||||
const [roleFilter, setRoleFilter] = useState<number | ''>('');
|
||||
|
||||
// 弹窗状态
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
@ -178,8 +180,9 @@ export default function UsersPage() {
|
||||
const buildQuery = useCallback(() => {
|
||||
const params: Record<string, string | number> = { page, page_size: pageSize };
|
||||
if (keyword.trim()) params.keyword = keyword.trim();
|
||||
if (roleFilter !== '') params.role = roleFilter;
|
||||
return params;
|
||||
}, [page, pageSize, keyword]);
|
||||
}, [page, pageSize, keyword, roleFilter]);
|
||||
|
||||
/** 加载用户列表 */
|
||||
const fetchData = useCallback(async () => {
|
||||
@ -205,6 +208,12 @@ export default function UsersPage() {
|
||||
fetchData();
|
||||
};
|
||||
|
||||
/** 角色过滤变化:回到第一页并重新查 */
|
||||
const handleRoleFilterChange = (v: number | '') => {
|
||||
setRoleFilter(v);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
/** 打开创建弹窗 */
|
||||
const handleCreate = () => {
|
||||
setEditingUser(null);
|
||||
@ -261,9 +270,9 @@ export default function UsersPage() {
|
||||
Message.warning('请输入密码');
|
||||
return;
|
||||
}
|
||||
// 操作员/企业管理员必须选组织
|
||||
// 操作员/企业管理员必须选组织(⛔ 操作员没有企业归属就看不到任何设备)
|
||||
if (formValues.role !== 2 && !formValues.organization_id) {
|
||||
Message.warning('请选择所属组织');
|
||||
Message.warning(formValues.role === 0 ? '操作员必须选择所属组织' : '请选择所属组织');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
@ -375,9 +384,18 @@ export default function UsersPage() {
|
||||
<Permission code="user:edit">
|
||||
<Button type="text" size="small" icon={<IconEdit />} onClick={() => handleEdit(record)} />
|
||||
</Permission>
|
||||
{/* 数据范围:与「编辑用户」同一权限码(后端 PUT /users/:id/scopes 校验 user:edit) */}
|
||||
{/* 数据范围:**仅操作员(role=0)可配**。
|
||||
总管理员(role>=2)豁免范围限制;企业管理员(role=1)天然管本组织全部设备,
|
||||
配项目级范围反而会有歧义("管自己企业"已隐含全部设备)。
|
||||
与「编辑用户」同一权限码(后端 PUT /users/:id/scopes 校验 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>
|
||||
</Permission>
|
||||
@ -409,8 +427,8 @@ export default function UsersPage() {
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
{/* 搜索 */}
|
||||
<Space>
|
||||
{/* 搜索 + 角色过滤 */}
|
||||
<Space wrap>
|
||||
<Input.Search
|
||||
placeholder="搜索手机号/姓名"
|
||||
value={keyword}
|
||||
@ -419,10 +437,22 @@ export default function UsersPage() {
|
||||
style={{ width: 260 }}
|
||||
allowClear
|
||||
/>
|
||||
{/* 角色过滤:只有三种角色,默认"全部"太吵 */}
|
||||
<Select
|
||||
value={roleFilter}
|
||||
onChange={handleRoleFilterChange}
|
||||
style={{ width: 150 }}
|
||||
placeholder="全部角色"
|
||||
allowClear
|
||||
options={ROLE_OPTIONS}
|
||||
/>
|
||||
<Button icon={<IconSearch />} onClick={handleSearch}>
|
||||
查询
|
||||
</Button>
|
||||
<Button icon={<IconRefresh />} onClick={() => { setKeyword(''); setPage(1); }}>
|
||||
<Button
|
||||
icon={<IconRefresh />}
|
||||
onClick={() => { setKeyword(''); setRoleFilter(''); setPage(1); }}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user