fix(mp): 接入 vue-tsc 真类型检查并修掉它抓出的两个真实缺陷

小程序端此前**没有任何类型门禁**:tsc 不检查 .vue,而 9 个页面全是 .vue,
npm run build:mp-weixin 只验语法不验类型。接入 vue-tsc 后立刻抓到 17 个错误,
其中两个是真 BUG:

1. me/index.vue:写 const user = authStore.user 之后又写 user.value ——
   Pinia setup store 对外已把 ref 解包,user.value 恒为 undefined
   ⇒ **头像上传成功后写回 store 的那行是空操作**(要等冷启动才显示新头像);
   且直接取值是快照、丢掉响应式。改为 computed(() => authStore.user),模板无需改动。
2. api/index.ts:CompartmentDetail 漏了后端确实会返回的 temp
   (routes/h5.rs 的 COMPARTMENT_REALTIME_FIELDS 第 6 项,有单测守门)
   ⇒ compartment-detail 页读 data.temp 报 TS2339。已按后端契约补上。

其余 12 处是宿主全局 wx 未声明(@dcloudio/types 只声明跨端 uni),
在 env.d.ts 里**只声明实际用到的两个 API**(刻意不写 [k:string]:any,
否则等于把这一层检查关掉)。

顺带清理:
- 删除死页面 pages/devices/index.vue(不在 pages.json、无任何跳转引用,grep 确认);
- 删除仓控板「在线/离线」徽标:cabin_boards.status **全后端没有任何写入方**
  (生产库实测取值只有 0),该徽标恒定显示"离线",属对用户的谎报;
  协议层面也没有"仓控板在线"这个概念(TCP 长连接的是柜控板,按 IMEI 标识)。
  按「柜的就显示到柜子」+「不显示推不出来的状态」去掉,并在类型上加禁止说明。

已按仓库铁律自证门禁能失败:植入 export const probe: number = 'x' → exit 2;撤销 → exit 0。
This commit is contained in:
12451 2026-09-20 04:31:42 +08:00
parent c3a3ea7c3a
commit aa02cd4e58
7 changed files with 145 additions and 15 deletions

View File

@ -3,7 +3,8 @@
"version": "1.0.0",
"private": true,
"scripts": {
"build:mp-weixin": "uni build --platform mp-weixin"
"build:mp-weixin": "uni build --platform mp-weixin",
"typecheck": "vue-tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@dcloudio/uni-app": "3.0.0-4060620250520001",
@ -21,6 +22,7 @@
"@dcloudio/uni-stacktracey": "3.0.0-4060620250520001",
"@dcloudio/vite-plugin-uni": "3.0.0-4060620250520001",
"typescript": "^5.4.5",
"vite": "^5.2.8"
"vite": "^5.2.8",
"vue-tsc": "^2.2.12"
}
}

View File

@ -116,7 +116,21 @@ export interface CabinetItem {
id: number
abstract_id: string
name?: string
/**
* **配置态**:`1` = 启用(默认)/ `2` = 禁用(后端据此拦截开合闸、开门、充电)。
*
* ⛔ **不表示在线** —— 全仓没有任何写入方会把它写成 `0`(离线)。
* 展示"在线/离线"一律用 [`CabinetItem.is_online`]。
*/
status: number
/**
* **实时在线**(`GET /api/h5/cabinets` 返回,值来自 Redis `device:{imei}.online`)
*
* `true` 在线 / `false` 明确离线 / `null`、缺失 = 无数据(键或字段不存在)。
* 口径与 PC 设备列表 `is_online` **同源**(后端同一纯函数)。
* ⚠️ 与 `status`(启用/禁用配置态)严格分离,不得互相推断。
*/
is_online?: boolean | null
project_id?: number
rssi?: number
imei?: string
@ -164,6 +178,16 @@ export interface CompartmentInfo {
export interface CabinBoard {
id: number
board_id: number
/**
* ⛔ **不要用它推断"仓控板在线"** —— `cabin_boards.status` 在**整个后端没有任何写入方**
* (全仓只有建表默认值 0,`INSERT INTO cabin_boards (cabinet_id, board_id)` 没写这一列;
* 生产库实测取值分布只有 `0`)。协议里也**没有"仓控板在线"的概念**:
* 与平台保持 TCP 长连接的是**柜控板**(按 IMEI 标识),仓控板挂在柜内 485 总线上,
* 没有独立上行通路 ⇒ 这个状态**推不出来**。
* 该列保留仅为兼容后端响应形状;**界面不得据此显示"在线/离线"**
* (此前小程序展示的板级徽标恒定显示"离线",属谎报,已删除)。
* 柜级在线状态请看 `CabinetDetail.is_online`(Redis 实时值)。
*/
status: number
compartments: CompartmentInfo[]
}
@ -174,7 +198,19 @@ export interface CabinetDetail {
name?: string
imei?: string
iccid?: string
/**
* **配置态**:`1` = 启用 / `2` = 禁用(后端据此拦截指令链路)。
*
* ⛔ **不表示在线**;展示"在线/离线"请用 [`CabinetDetail.is_online`]。
*/
status: number
/**
* **实时在线**(`GET /api/cabinets/{id}` 返回,值来自 Redis `device:{imei}.online`)
*
* `true` 在线 / `false` 明确离线 / `null`、缺失 = 无数据。
* 判据与设备列表 `is_online` **同源**(后端同一纯函数)。
*/
is_online?: boolean | null
rssi?: number
/** 直流停电(协议 §5.4);true=停电 / false=正常 / null=无数据 */
pow_fail_dc?: boolean | null
@ -214,6 +250,15 @@ export interface CompartmentDetail {
batt_status?: string | null
/** 电源状态(协议 §3.8,2 位 hex);与电池状态相互独立,null/缺失 = 展示 "-" */
pow_status?: string | null
/**
* **仓体温度**(协议附 A,K×100)
*
* 后端 `GET /api/h5/compartments/{id}` 会返回该字段
* (`routes/h5.rs` 的 `COMPARTMENT_REALTIME_FIELDS` 第 6 项 `"temp"`,有单测守门),
* 展示走 `utils/deviceStatus.ts::formatCelsius`。
* 此前类型里漏了它 ⇒ 页面 `data.temp` 报 TS2339(类型契约与后端不一致,已补)。
*/
temp?: number | null
}
export interface ChargeRecord {

View File

@ -5,3 +5,31 @@ declare module '*.vue' {
const component: DefineComponent<{}, {}, any>
export default component
}
/**
* 微信小程序宿主全局 `wx`。
*
* **为什么需要手工声明**:`@dcloudio/types` 只声明跨端的 `uni`,**不声明宿主特有的 `wx`**;
* 而自定义导航栏的安全区计算必须用宿主 API(胶囊按钮位置没有等价的跨端取法),
* 所以 6 个页面里直接用了 `wx.*`。不声明时 `vue-tsc` 会报 12 处
* `TS2304: Cannot find name 'wx'` —— 噪声会把**真正的类型错误淹掉**
* (与「打印 ❌ 却 exit 0」是同一类问题:判据不再指向真实缺陷)。
*
* ⚠️ **刻意只声明实际用到的两个 API**,不写 `[k: string]: any`:
* 后者会让 `wx.anything()` 全部通过,等于把这一层检查关掉。需要新宿主 API 时,
* 按同样方式在下面补一条(签名照官方文档抄)。
*
* 运行期说明:`wx` 只存在于 `mp-weixin` 产物中,而本工程只构建该目标(`build:mp-weixin`)。
*/
declare const wx: {
/** 取宿主系统信息(本工程只消费 `statusBarHeight`) */
getSystemInfoSync(): {
/** 状态栏高度(px) */
statusBarHeight?: number
}
/** 取右上角胶囊按钮布局,用于按"胶囊高度 + 上下间距"反推自定义导航栏高度 */
getMenuButtonBoundingClientRect(): {
top: number
height: number
}
}

View File

@ -15,7 +15,13 @@
<view class="info-top">
<view><text class="info-id">{{ data.abstract_id }}</text></view>
<view class="info-status-row">
<view class="status-badge" :class="data.status === 1 ? 'online' : 'offline'"><text>{{ data.status === 1 ? '在线' : '离线' }}</text></view>
<!-- 状态徽标:`status === 2` = 禁用(配置态);其余按**接口的实时在线字段** `is_online`
(Redis `device:{imei}.online`,与 PC 设备列表 `is_online` 同源)。
⛔ 不得用 `data.status` 推断在线。 -->
<view
class="status-badge"
:class="data.status === 2 ? 'disabled' : (data.is_online === true ? 'online' : 'offline')"
><text>{{ data.status === 2 ? '禁用' : (data.is_online === true ? '在线' : '离线') }}</text></view>
<view class="signal"><view v-for="(color, i) in rssiBarColors(data.rssi)" :key="i" class="signal-bar" :class="'l' + (i + 1)" :style="{ background: color }"></view></view>
<text class="rssi-val">{{ data.rssi != null ? data.rssi : '—' }}</text>
<view class="pow-tags">
@ -71,7 +77,16 @@
<view v-for="board in data.cabin_boards" :key="board.id" class="board-section">
<view class="board-header">
<text class="board-title">{{ data.imei }}-{{ board.board_id }}</text>
<view class="board-status" :class="board.status === 1 ? 'online' : 'offline'"><text>{{ board.status === 1 ? '在线' : '离线' }}</text></view>
<!-- ⛔ 这里**不再显示"在线/离线"徽标**。
原因(实测 2026-09-20):`cabin_boards.status` 在**整个后端没有任何写入方**
—— 全仓只有建表默认值,`INSERT INTO cabin_boards (cabinet_id, board_id)` 根本没写它;
生产库实测取值分布只有 `0`(2/2 行),接口也只是把该列读出来。
所以此前那句 `board.status === 1 ? '在线' : '离线'` 是**恒定显示"离线"**,
属对用户的**谎报**(比不显示更糟:运维会据此以为板子坏了)。
协议层面也**没有"仓控板在线"这个概念**:与平台保持 TCP 连接的是柜控板(按 IMEI 标识),
仓控板挂在柜内 485 总线上,没有独立的上行通路 ⇒ 该项状态**推不出来**。
按既定口径「柜的就显示到柜子,仓体的就显示到仓上」+「不显示推不出来的状态」,
这里直接去掉;柜级在线状态在同页顶部按 Redis 实时值显示。 -->
</view>
<view class="comp-grid">
<view v-for="comp in board.compartments" :key="comp.id" class="comp" :class="compClass(comp.status)" @click="goCompartment(comp.id)">
@ -247,7 +262,9 @@ function openLocation() {
.status-badge { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; font-size: 11px; font-weight: 500; border-radius: 999px; }
.status-badge.online { background: #f0fdf4; color: #22c55e; }
.status-badge.offline { background: #f1f5f9; color: #94a3b8; }
.status-badge.online::before, .status-badge.offline::before { content: ''; width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
/* 配置态「禁用」(`cabinets.status === 2`),与"离线"区分 */
.status-badge.disabled { background: #fff7ed; color: #f97316; }
.status-badge.online::before, .status-badge.offline::before, .status-badge.disabled::before { content: ''; width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
.signal { display: flex; align-items: flex-end; gap: 1.5px; height: 14px; }
.signal-bar { width: 2.5px; border-radius: 1px; }
.signal-bar.l1 { height: 4px; } .signal-bar.l2 { height: 7px; } .signal-bar.l3 { height: 10px; } .signal-bar.l4 { height: 13px; } .signal-bar.l5 { height: 14px; }
@ -287,8 +304,8 @@ function openLocation() {
.board-section { margin: 12px 16px; background: #fff; border-radius: 12px; padding: 14px; box-shadow: 0 1px 2px rgba(0,0,0,0.04); }
.board-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.board-title { font-size: 14px; font-weight: 600; color: #111827; font-variant-numeric: tabular-nums; }
.board-status { font-size: 11px; color: #94a3b8; }
.board-status.online { color: #22c55e; }
/* `.board-status` 样式已随"板级在线徽标"一并删除:该状态无数据来源(见模板注释),
留着只会诱使后来者再加回那个恒定显示"离线"的徽标。 */
.comp-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
.comp { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 8px; border: 1.5px solid transparent; }
.comp:active { transform: scale(0.97); }

View File

@ -40,7 +40,16 @@
<view class="device-header">
<view class="device-id-row">
<text class="device-id">{{ cab.abstract_id }}</text>
<view class="status-badge" :class="cab.status === 1 ? 'online' : 'offline'"><text>{{ cab.status === 1 ? '在线' : '离线' }}</text></view>
<!-- 状态徽标:**配置态与实时态分开**
- `status === 2` = 禁用(`cabinets.status` 配置态,后端据此拦截指令);
- 否则按**接口返回的实时在线字段** `is_online` 显示「在线/离线」
(值来自 Redis `device:{imei}.online`,与 PC 设备列表 `is_online` 同源)。
⛔ 不得再用 `cab.status` 推断在线 —— 那是启用/禁用配置态,全仓没有写入方会把
它写成 0(离线),拿它判在线会在一台设备都没连时依然显示「在线」。 -->
<view
class="status-badge"
:class="cab.status === 2 ? 'disabled' : (cab.is_online === true ? 'online' : 'offline')"
><text>{{ cab.status === 2 ? '禁用' : (cab.is_online === true ? '在线' : '离线') }}</text></view>
</view>
<view class="header-right">
<text v-if="cab.temp != null" class="temp-badge">{{ formatCelsius(cab.temp) }}</text>
@ -267,7 +276,9 @@ function showScanInput() {
.status-badge { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; font-size: 11px; font-weight: 500; border-radius: 999px; }
.status-badge.online { background: #f0fdf4; color: #22c55e; }
.status-badge.offline { background: #f1f5f9; color: #94a3b8; }
.status-badge.online::before, .status-badge.offline::before { content: ''; width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
/* 配置态「禁用」(`cabinets.status === 2`):与"离线"区分开的橙色徽标 */
.status-badge.disabled { background: #fff7ed; color: #f97316; }
.status-badge.online::before, .status-badge.offline::before, .status-badge.disabled::before { content: ''; width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
.temp-badge { font-size: 12px; color: #475569; font-variant-numeric: tabular-nums; flex-shrink: 0; }
.header-right { display: flex; align-items: center; gap: 8px; }
.signal-inline { display: flex; align-items: center; gap: 3px; }

View File

@ -27,7 +27,7 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { useAuthStore } from '../../store/auth'
import AppIcon from '../../components/AppIcon.vue'
@ -46,7 +46,12 @@ setTimeout(calcNav, 300)
setTimeout(calcNav, 600)
const authStore = useAuthStore()
const user = authStore.user
// ⚠️ 必须用 `computed` 包一层,不能写 `const user = authStore.user`:
// Pinia 的 **setup store** 对外已把 `ref` 解包,`authStore.user` 是**值**而不是 Ref,
// 于是 ① `user.value` 恒为 `undefined`(头像上传成功后写回 store 的那行成了**空操作**,
// 页面要等下次冷启动才显示新头像);② 直接取值是**快照**,store 之后被 `restore()`/
// 重新登录改写时,页面仍指向旧对象(响应式丢失)。两处都是 vue-tsc 报出来的真实缺陷。
const user = computed(() => authStore.user)
function goFeedback() { uni.navigateTo({ url: '/pages/feedback/index' }) }
function confirmLogout() {
uni.showModal({ title: '退出登录', content: '确定要退出当前账号?', success: (res) => { if (res.confirm) { authStore.logout(); uni.reLaunch({ url: '/pages/login/index' }) } } })

View File

@ -54,10 +54,16 @@ const STATUS_MAP: Record<number, { label: string; color: string }> = {
3: { label: '故障', color: 'red' },
};
/** 柜子状态映射 */
/**
* 柜子**配置态**映射(`cabinets.status`):只有「启用 / 禁用」两档。
*
* ⚠️ 这里**不含"在线/离线"**:`cabinets.status` 是启用/禁用配置态,
* 全仓没有任何写入方会把它写成 `0`(离线),拿它判在线会让"一台设备都没连"时
* 依然显示"在线"。实时在线一律看接口的 `is_online`(Redis,见 `onlineTag`)。
* ⛔ 不要再往本表里加"离线"档。
*/
const CABINET_STATUS_MAP: Record<number, { label: string; color: string }> = {
0: { label: '离线', color: 'red' },
1: { label: '在线', color: 'green' },
1: { label: '启用', color: 'green' },
2: { label: '禁用', color: 'orange' },
};
@ -483,7 +489,20 @@ export default function CabinetDetail() {
);
}
const cabStatus = CABINET_STATUS_MAP[detail.status] || { label: '未知', color: 'gray' };
const cabStatus = CABINET_STATUS_MAP[detail.status] || { label: '启用', color: 'green' };
/**
* **实时在线**标签:`is_online` 来自接口(Redis `device:{imei}.online`,
* 与设备列表 `is_online` 同源)。三态 —— `true` 在线 / `false` 离线 / `null`、缺失 = 无数据。
*
* ⚠️ 与 `detail.status`(启用/禁用配置态)**无关**:后者永不表示在线,不得再用来推断。
* 无数据时显示"在线未知"而不是"离线"("没收到上报" 与 "明确离线" 必须区分)。
*/
const onlineTag: { label: string; color: string } =
detail.is_online === true
? { label: '在线', color: 'green' }
: detail.is_online === false
? { label: '离线', color: 'red' }
: { label: '在线未知', color: 'gray' };
/** 充电工作模式:后端未返回时为 null(界面上按默认「平台指令模式」展示并标注) */
const workMode: '0' | '1' | null = detail.work_mode === '1' ? '1' : detail.work_mode === '0' ? '0' : null;
/** 柜级柜门三态(协议 §4.2):`status1` bit4;无数据 → 「未知」(绝不显示成"关闭") */
@ -500,6 +519,9 @@ export default function CabinetDetail() {
<Button type="text" icon={<IconLeft />} onClick={() => navigate('/devices')} style={{ padding: '0 4px' }} />
<IconDesktop style={{ fontSize: 20, color: 'var(--color-text-2)' }} />
<Typography.Text style={{ fontSize: 16, fontWeight: 600 }}>柜子 {detail.abstract_id}</Typography.Text>
{/* 实时在线(Redis `device:{imei}.online`,接口 `is_online`):三态,无数据 → 「在线未知」 */}
<Tag color={onlineTag.color} size="small">{onlineTag.label}</Tag>
{/* 配置态(启用/禁用):与上面"是否在线"是两个维度,同时展示 */}
<Tag color={cabStatus.color} size="small">{cabStatus.label}</Tag>
<div style={{ flex: 1 }} />
<span style={{ fontSize: 12, color: 'var(--color-text-2)', background: 'var(--color-fill-1)', borderRadius: 4, padding: '2px 8px' }}>