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

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

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

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

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

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

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

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

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

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

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

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

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

## 验收

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

447 lines
17 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- 全站统一 favicon:与 web 管理端同源(logo/favicon.svg) -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>艾动智能 — 智能充电柜矩阵系统</title>
<!-- 社交分享(og / Twitter):此前完全缺失,链接被分享时没有预览图 -->
<meta property="og:type" content="website">
<meta property="og:site_name" content="艾动智能">
<meta property="og:title" content="艾动智能 — 为换电场景打造的下一代智能充电">
<meta property="og:description" content="36 仓独立直流快充,4G 远程实时管控,多重安全防护体系。从硬件到云端,全栈自研的智能充电解决方案。">
<meta property="og:image" content="https://app.anzhizhichong.com/og-image.jpg">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:url" content="https://app.anzhizhichong.com/">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="艾动智能 — 为换电场景打造的下一代智能充电">
<meta name="twitter:description" content="36 仓独立直流快充,4G 远程实时管控,多重安全防护体系。从硬件到云端,全栈自研。">
<meta name="twitter:image" content="https://app.anzhizhichong.com/og-image.jpg">
<style>
/* ==========================================================================
艾动智能 · 产品介绍页(单屏不滚动 / 明亮浅色)
—— 设计口径见 AGENTS.md「操作规则」:偏好明亮浅色、单屏不滚动,⛔ 不做暗色科技风
—— 品牌色直接取自 logo:logo/纯色横向.svg 的 .cls-1 = #0366e6
========================================================================== */
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--brand: #0366e6; /* 取自 logo 的蓝,全站强调色统一用它 */
--brand-dark: #0354c4; /* hover 加深 */
--brand-soft: #eaf2fe; /* 极浅蓝底,用于标签/弱化背景 */
--ink: #0F1419; /* 主标题 */
--body: #454B58; /* 正文 */
--muted: #8B92A5; /* 辅助文字 */
--line: rgba(15,20,25,0.08);
}
html, body {
height: 100%;
overflow: hidden; /* 单屏不滚动(AGENTS.md 口径) */
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", sans-serif;
color: var(--body);
-webkit-font-smoothing: antialiased;
}
a { color: inherit; text-decoration: none; }
/* ===== 整体:上 / 中 / 下 三块,中块自适应 ===== */
.layout { display: flex; flex-direction: column; height: 100vh; }
/* ===== 背景:浅色渐变 + 右侧 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;
}
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.60) 0%,
rgba(255,255,255,0.30) 34%,
rgba(255,255,255,0.04) 54%,
transparent 66%);
}
/* ===== TOP: 顶部导航 =====
⚠️ pointer-events 的坑:.top/.mid/.bot 都是**通栏**容器,子元素只占一侧,
但容器自身会横跨整个宽度并把鼠标事件吃掉 —— 右侧 3D canvas 就收不到
拖拽/滚轮了(OrbitControls 装在 canvas 上)。所以通栏容器一律
pointer-events:none,只让真正可交互的子元素恢复 auto。 */
.top {
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; 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; pointer-events: auto; }
.top-link {
font-size: 13px; font-weight: 500; color: var(--body);
padding: 8px 16px; border-radius: 8px; transition: all .2s;
}
.top-link:hover { background: var(--brand-soft); color: var(--brand); }
.top-link--cta { background: var(--ink); color: #fff; }
.top-link--cta:hover { background: #23272F; color: #fff; }
/* ===== MID: 浅色渐变上叠一块毛玻璃文案面板 =====
⚠️ 容器通栏但 pointer-events:none(否则挡住右侧 3D 的鼠标事件),
只让 .copy 面板自身可交互。 */
.mid {
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, 44vw);
padding: 40px 44px;
display: flex; flex-direction: column; gap: 20px;
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);
border-radius: 22px;
box-shadow:
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; /* 面板自身要能选中文字/点按钮 */
}
/* 小标签:品牌蓝 + 呼吸圆点 */
.tag {
display: inline-flex; align-items: center; gap: 8px;
padding: 6px 14px 6px 12px;
background: var(--brand-soft);
border: 1px solid rgba(3,102,230,0.16);
border-radius: 100px;
font-size: 13px; font-weight: 600; color: var(--brand);
width: fit-content;
}
.tag .dot {
width: 7px; height: 7px; border-radius: 50%;
background: var(--brand);
box-shadow: 0 0 0 0 rgba(3,102,230,0.45);
animation: pulse 2.4s ease-out infinite;
}
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(3,102,230,0.40); }
70% { box-shadow: 0 0 0 7px rgba(3,102,230,0); }
100% { box-shadow: 0 0 0 0 rgba(3,102,230,0); }
}
h1 {
font-size: clamp(38px, 4.2vw, 58px);
font-weight: 800;
line-height: 1.18;
letter-spacing: -0.02em;
color: var(--ink);
}
h1 .accent { color: var(--brand); }
.subhead {
font-size: clamp(15px, 1.25vw, 17px);
font-weight: 400;
line-height: 1.8;
color: var(--body);
max-width: 500px;
}
/* 能力标签:只放 stats 里没有的,避免信息重复 */
.pills { display: flex; flex-wrap: wrap; gap: 8px; }
.pill {
padding: 5px 13px;
background: #fff;
border: 1px solid var(--line);
border-radius: 100px;
font-size: 13px; font-weight: 500; color: var(--body);
box-shadow: 0 1px 2px rgba(15,20,25,0.03);
}
/* 行动按钮(此前只有 CSS、HTML 里从未渲染过)*/
.ctas { display: flex; align-items: center; gap: 12px; margin-top: 2px; }
.btn {
display: inline-flex; align-items: center; gap: 8px;
padding: 13px 26px;
font-size: 15px; font-weight: 600;
border-radius: 10px; transition: all .2s;
white-space: nowrap;
}
.btn-primary {
background: var(--brand); color: #fff;
box-shadow: 0 6px 20px rgba(3,102,230,0.24);
}
.btn-primary:hover {
background: var(--brand-dark);
transform: translateY(-2px);
box-shadow: 0 10px 26px rgba(3,102,230,0.30);
}
.btn-ghost { color: var(--body); border: 1.5px solid rgba(15,20,25,0.13); background: #fff; }
.btn-ghost:hover { border-color: var(--brand); color: var(--brand); }
.btn .arrow { transition: transform .2s; }
.btn-primary:hover .arrow { transform: translateX(3px); }
/* 数据条 */
.stats {
display: flex; gap: 40px;
padding-top: 18px; margin-top: 4px;
border-top: 1px solid var(--line);
}
.stat-num {
font-size: 30px; font-weight: 800; color: var(--ink);
line-height: 1.1; letter-spacing: -0.01em;
font-variant-numeric: tabular-nums; /* 数字等宽,四个数据对齐 */
}
.stat-num .unit { font-size: 15px; font-weight: 700; color: var(--muted); margin-left: 2px; }
.stat-label { font-size: 12px; font-weight: 500; color: var(--muted); margin-top: 5px; }
/* ===== BOT: 底部备案 ===== */
.bot {
position: relative; z-index: 2;
flex-shrink: 0;
text-align: center; padding: 14px;
font-size: 12px; color: var(--muted);
}
.bot a { color: var(--brand); }
.bot a:hover { text-decoration: underline; }
/* ===== 自适应:窄屏时收紧,保证仍然单屏不滚动 ===== */
@media (max-width: 1180px) {
.mid { padding: 0 32px; }
.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; }
.stage { width: 100%; opacity: 0.55; }
.scrim {
background: linear-gradient(to right,
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">
<!-- 上:顶部导航 -->
<div class="top">
<div class="top-brand">
<!-- 横版锁标(图标 + "艾动智能"字样,文字已转为路径、无字体依赖)-->
<img class="logo" src="logo.svg" alt="艾动智能">
</div>
<div class="top-links">
<a href="/doc/" class="top-link">开发文档</a>
<a href="/pms/" class="top-link top-link--cta">管理后台</a>
</div>
</div>
<!-- 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>
<h1>为换电场景打造的<br><span class="accent">下一代智能充电</span></h1>
<p class="subhead">
36 仓独立直流快充,4G 远程实时管控,多重安全防护体系。<br>
从硬件到云端,全栈自研的智能充电解决方案。
</p>
<!-- 只放 stats 未覆盖的能力点,"36仓独立"已由下方数据条表达,不再重复 -->
<div class="pills">
<span class="pill">多重防护</span>
<span class="pill">4G 在线</span>
<span class="pill">BMS 适配</span>
</div>
<!-- 首屏行动按钮 -->
<div class="ctas">
<a href="/pms/" class="btn btn-primary">
进入管理后台
<svg class="arrow" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14M13 6l6 6-6 6"/>
</svg>
</a>
<a href="/doc/" class="btn btn-ghost">开发文档</a>
</div>
<div class="stats">
<div><div class="stat-num">36</div><div class="stat-label">独立仓体</div></div>
<div><div class="stat-num">&lt;0.5<span class="unit">s</span></div><div class="stat-label">指令响应</div></div>
<div><div class="stat-num">8000<span class="unit">h</span></div><div class="stat-label">MTBF</div></div>
<div><div class="stat-num">99<span class="unit">%</span></div><div class="stat-label">可用率</div></div>
</div>
</div>
</div>
<!-- 下:备案 -->
<div class="bot">
© 2026 艾动智能 · <a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">苏ICP备2025198376号-1</a>
</div>
</div>
</body>
</html>