feat: 设备中心模块全量落地 + 列表页同级数据切换规范

- 设备管理(manage):5 类设备 tab 改为 a-radio-group button-style="solid"(参考 vue 研发项目 unitReportData);新增投用状态字段、固件版本→软件版本、移除数据中转;所属租户/布点场所非必填,留空自动判定在库;导出改异步(导出列表数据 + 查看导出任务)
- 设备统计(statistics):从图标展示重构为列表页,14 列定义 + 12 维搜索条件 + 4 张统计卡 + a-table-summary 统计行 + 异步导出
- 设备工作台(workbench/overview):新增概览页 + DonutChart 组件
- 资产/库存/运维/分发/租户场所等子模块全量落地(含 mock handler、6 文件结构、modal/drawer 子组件)
- 系统设置/运营中心扁平化页面落地(员工/角色/菜单/应用/字典/租户列表/小程序用户)
- 实施运维小程序(miniprogram/ops):工作台/工单/扫码/巡检/我的 5 个 tab 页
- 规范:list-page-spec §7.4 新增「同级数据切换(radio-button 样式,强制)」;tabs-page-spec 文首新增「〇、适用范围」明确与同表过滤场景的边界
- 路由/菜单(nav.ts + routes.ts):同步设备中心菜单层级与 ops 小程序实例

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
fengpu
2026-07-03 14:53:56 +08:00
co-authored by Claude Opus 4.7
parent 2727cfda86
commit 74d1488d3b
305 changed files with 23157 additions and 205 deletions
+418
View File
@@ -0,0 +1,418 @@
# AGENTS.md — Codex 约束文件
> 本文件为 **Codex / Codex CLI** 等 AI 编码助手在本项目工作时的约束规范。
>
> **权威源**:详细规则以同目录 `CLAUDE.md` 为准;本文件为 Codex 友好的精简快查版。
> 当本文件与 `CLAUDE.md` 冲突时,**以 `CLAUDE.md` 为准**。
---
## 0. 语言强制
- **所有对话回复必须使用简体中文**(代码标识符除外)
- **所有代码注释必须使用简体中文**JavaDoc / JSDoc / Docstring 同样要求)
- 变量名、函数名、类名仍使用英文(遵循编程语言规范)
---
## 1. 项目定位
**智养餐饮平台 V3** 的高保真交互原型工程:**Vue 3 + Vite + Ant Design Vue + Vant** 半工程化方案。
**核心目标**:让产品经理画的原型 ≈ 开发要写的代码骨架,研发接手时改动最小。
### 四个终端(互不引用)
| 终端 | key | UI 库 | 业务定位 |
|------|-----|-------|---------|
| 综合管理后台 | `admin-portal` | Ant Design Vue | 平台级管理(单应用形态,3 个一级菜单:系统设置 / 运营中心 / 设备中心) |
| 租户运营后台 | `tenant-portal` | Ant Design Vue | 多应用形态(17 个独立可部署应用,如 nutrition / monitor 等) |
| C 端微信小程序 | `miniprogram` | Vant 4375×812) | 用户自助查询、上报、咨询 |
| 硬件终端屏 | `hardware` | 自定义(动态 viewport) | 净菜柜屏 / 自助查询屏 / 大屏 |
### URL 规范
- 综合后台:`/admin-portal/<section>/<page>`(无应用层)
- 租户后台:`/tenant-portal/<app>/<section>/<page>`(例:`/tenant-portal/nutrition/arc/employee`
- 应用首页:`/tenant-portal`(应用网格卡片)
---
## 2. 技术栈与启动
| 类别 | 选型 |
|------|------|
| 框架 | Vue 3.5 + TypeScript 5.9 |
| 构建 | Vite 8(端口 5180 |
| Web 端 UI | Ant Design Vue 4.2 |
| 移动端 UI | Vant 4 |
| 路由 | Vue Router 4Hash 模式) |
| 状态管理 | **不引入 Pinia** |
| 样式 | LESS + CSS 变量 |
| 自动导入 | unplugin-auto-import + unplugin-vue-components |
```bash
npm install
npm run dev # 端口 5180
npm run build
```
**门禁密码**`.env`):设计 `design2026` / 研发 `dev2026`,登录后 7 天有效。
---
## 3. 路径别名(与 vue 研发项目 1:1 对齐)
| 别名 | 指向 |
|------|------|
| `@` | `src/` |
| `@meta` | `src/meta/`(菜单数据,原型特有) |
| `@layouts` | `src/layouts/` |
| `@components` | `src/components/` |
| `@composables` | `src/composables/`(原型特有) |
| `@pages` | `src/pages/`(研发项目用 `@views` |
| `@router` | `src/router/` |
| `@utils` | `src/utils/` |
| `@axios` | `src/axios/` |
| `@assets` | `src/assets/` |
| `@directives` | `src/directives/` |
| `@api` | `src/api/` |
---
## 4. 目录结构(精简版)
```
src/
├── main.ts 入口:mock handlers / directives / antd 静态方法 初始化
├── App.vue 必须用 <a-config-provider :locale="zhCN"> 包裹
├── router/
│ ├── index.ts 门禁 + designerOnly 守卫
│ └── routes.ts 由 nav.ts 自动派生
├── meta/nav.ts 🌟 菜单数据单一来源(adminApps / tenantApps
├── axios/ 🆕 假 axios 层(签名与研发 1:1)
│ ├── index.ts getRequest / postRequest / putRequest / deleteRequest / restfulRequest / uploadRequest / downloadRequest
│ ├── mockBus.ts registerMock / dispatchMock200~500ms 延迟)
│ └── handlers/<模块>/<页面>.ts 🌟 mock handler 注册(禁止写在 useTable.ts
├── api/index.ts 🆕 跨模块公共接口(字典 / 部门树 / 组织树)
├── utils/antDesign/ table.ts / popUp.ts / select.ts
├── directives/ v-no-space / v-only-number / v-only-alphanumeric ...
├── assets/styles/listPage.less 列表页统一布局
├── composables/useVisibility.ts 可见性核心(原型特有)
├── layouts/
│ ├── PortalLayout.vue admin / tenant 共用
│ ├── MiniprogramLayout.vue
│ ├── HardwareLayout.vue
│ └── components/ TopBar / Sidebar / AppTabBar / RoleBadge
├── components/ StatCard / FilterBar / TableCard / PageHeader / PlaceholderPage / AppPlaceholder
├── pages/ 按 端/应用/一级菜单 分目录
│ ├── home/Home.vue 🏠 全局导航首页
│ ├── gate/Gate.vue 🔒 门禁页
│ ├── console/ 🎛 设计完成度控制台
│ ├── prd/ 📄 PRD 查看
│ ├── overview/ 🖼 设计总览
│ ├── admin-portal/<section>/
│ ├── tenant-portal/<app>/<section>/<page>/
│ ├── miniprogram/<section>/
│ └── hardware/<section>/
├── prd/ markdown 文档 + 截图(≤ 60KB
├── docs/ 🌟 业务架构 + 代码规范(生成代码前必读)
└── types/ 自动生成 + shims
```
---
## 5. 列表页 6 文件结构(强制)
```
arcEmployee/
├── arcEmployee.vue 模板:FilterBar + TableCard,仅引用 hook 暴露的字段/方法
├── types.ts 接口/表单/记录类型
├── api/index.ts listRequest / addRequest / editRequest / deleteRequest / detailRequest
└── init/
├── usePage.ts 组合根:发起接口 / 子组件 ref / 打开方法
├── useSearch.ts reactive(search) + createSearchKey() + 下拉数据
└── useTable.ts TableState 对象 + columns(禁调接口、禁存数据 ref)
```
**子组件**按需追加:
- `component/modal/<名>/`(编辑/新增弹框,emit `load`
- `component/drawer/<名>/`(详情抽屉,emit `ok`
---
## 6. 代码风格强制约束
### 6.1 函数与请求
```ts
// ✅ 正确:箭头函数 + .then 链式
const listRequest = () => {
table.spin = true
getList(searchParams)
.then((res) => {
table.dataSource = res.data
})
.catch(() => {})
.finally(() => {
table.spin = false
})
}
// ❌ 禁止:function 关键字 + async/await
async function listRequest() {
const res = await getList(searchParams)
}
```
### 6.2 HTTP 调用
```ts
// ✅ 走 @axios,签名与 vue 研发项目 1:1
import { postRequest, getRequest } from '@axios'
postRequest('/api/employee/list', params)
// ❌ 禁止直接 import axios
import axios from 'axios'
```
### 6.3 弹窗静态方法
```ts
// ✅ 必须用 useAntdStaticMethods
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
const { message, Modal, notification } = useAntdStaticMethods()
// ❌ 禁止直接 import
import { message } from 'ant-design-vue'
```
### 6.4 表格状态
```ts
// ✅ useTable 返回 TableState 对象,数据存 table.dataSource
const table = reactive<TableState<Employee>>({
dataSource: [],
loading: false,
pagination: createPaginationConfig(),
columns: [...],
})
// ❌ 禁止单独 dataList ref
const dataList = ref<Employee[]>([])
```
### 6.5 搜索状态
```ts
// ✅ reactive + createSearchKey 工厂 + Object.assign 重置
const createSearchKey = () => ({ name: undefined, status: undefined })
const search = reactive(createSearchKey())
const resetSearch = () => {
Object.assign(search, createSearchKey())
searchQuery()
}
// ✅ searchQuery 重置页码但保留排序
const searchQuery = () => {
table.pagination.current = 1
listRequest()
}
// ❌ 禁止 Object.keys 遍历清空
// ❌ 禁止 searchQuery 调 resetTable()
```
### 6.6 子组件 ref
```ts
// ✅ 在父 usePage.ts 定义,命名 open + 组件名
const editModalRef = ref<InstanceType<typeof EditEmployeeModal>>()
const openEditEmployeeModal = (record?: Employee) => {
editModalRef.value?.open(record)
}
// ❌ 禁止在 .vue 中定义子组件 ref
// ❌ 禁止命名为 open / show / visible
```
### 6.7 删除二次确认
```ts
// ✅ Modal.confirm + okType: 'danger'
const onDelete = (record: Employee) => {
Modal.confirm({
title: '确认删除?',
okType: 'danger',
onOk: () => deleteRequest({ id: record.id }),
})
}
// ✅ 批量按钮必须 disabled
<a-button :disabled="!selectedRowKeys.length" @click="onBatchDelete"></a-button>
```
### 6.8 空值 / 时间 / ID
- 空值统一 `{{ x ?? '—' }}`(禁空串/null
- 时间 `YYYY-MM-DD HH:mm:ss`,日期 `YYYY-MM-DD`
- ID 统一 `string | undefined`(禁 number
### 6.9 类型
- **禁用 `any`**TS 严格模式
---
## 7. Mock 数据规则
```ts
// ✅ 位置:src/axios/handlers/<模块>/<页面>.ts
// 文件示例:src/axios/handlers/nutrition/employee.ts
import { registerMock } from '@axios/mockBus'
registerMock('GET', '/api/employee/list', (params) => {
return {
code: '00000',
data: { records: [...], total: 8 }, // ⚠️ 单个 dataset ≤ 8 条
}
})
```
**禁止**
- 写在 `useTable.ts`
- 单个 dataset 超过 **8 条**(原型用于展示而非性能压测)
-`api/index.ts` 写业务判断(如 `if res.code === '00000'`
---
## 8. 列表页 UI 规范
- **不渲染 `PageHeader`**(菜单 + 顶部历史栏已表达所在位置)
- **`TableCard`** 接 `:table` 对象(推荐),不显示 title
- **`FilterBar`** 用 default slot 写裸 `a-form-item`(推荐),或 `:fields` 配置驱动
- **顶部按钮**(导出/新增/导入/批量删除)放 `TableCard #toolbar` 左上方,必须带 antd icon
- **操作列 `<a-button type="link">` 一律不加图标**;多个按钮用 `<a-divider type="vertical" />` 分隔
- **次要操作**放 `TableCard #extra` 右上方
- **统计卡**放 `FilterBar` 上方(可选)
- **顶栏高度** 固定 64px
> ⚠️ **CRUD 不是列表页标配**:是否有"新增/编辑/删除/导出"由业务决定,Codex 不要默认生成。
---
## 9. 图标规范(强制)
- 菜单图标 / 操作按钮图标必须取自 `@ant-design/icons-vue` 4.x
- **禁止 emoji / 自定义 svg**NavApp.icon 仅在应用网格首页 + 应用切换器允许 emoji)
**标准映射**
| 操作 | 图标 |
|------|------|
| 查询 | `SearchOutlined` |
| 重置 | `RedoOutlined` |
| 新增 | `PlusOutlined` |
| 编辑 | `EditOutlined` |
| 删除 | `DeleteOutlined` |
| 导入 | `UploadOutlined` |
| 导出 | `DownloadOutlined` |
| 查看详情 | `EyeOutlined` |
| 查看任务 | `UnorderedListOutlined` |
---
## 10. Modal / Drawer 约束
- a-modal 必须 `:keyboard="false" :mask-closable="false"`(防编辑数据丢失)
- a-drawer(含列表型)必须 `destroy-on-close`
- 弹框/抽屉 `.vue` 内**禁写业务逻辑**,逻辑放 `init/` hooks
-`params.id` 判断新增/编辑(**禁用** `pageInfo.title / type` 文本判断)
- `addRequest` 必须 `delete params.id`
- `finally` 中必须 `pageInfo.spin = false`
- Modal emit `load`Drawer emit `ok`
---
## 11. 禁止事项(速查)
### 跨模块 / 跨端
- ❌ 跨端引用(admin ↛ tenant ↛ miniprogram ↛ hardware
- ❌ 跨应用引用(同 portal 下不同 NavApp 之间)
- ❌ Vant 用在后台 / antd 用在小程序
- ❌ 在 `api/index.ts` 写业务私有接口
### 列表页结构
- ❌ 省略 init 三件套
- ❌ 业务逻辑写在模板
- ❌ 渲染 `PageHeader`
-`TableCard` 显式传 title
- ❌ 主操作按钮放在页面顶部独立位置
### Mock / 接口
- ❌ mock 数据写在 `useTable.ts`
- ❌ 单个 dataset 超过 8 条
- ❌ 业务代码直接 `import axios`
-`api/index.ts` 写业务判断
### 代码风格
-`async/await`(统一 `.then().catch().finally()`
-`function` 关键字(统一箭头函数)
- ❌ 直接 `import { message } from 'ant-design-vue'`
-`any` 类型
### Hook 拆分
-`useTable.ts` 调接口
-`usePage.ts` 维护独立 `dataList` ref
-`usePage.ts` 写表格列或下拉请求
-`useSearch``Object.keys` 清空
-`searchQuery``resetTable()`
- ❌ 父 `.vue` 定义子组件 ref
- ❌ 子组件打开方法命名 `open / show / visible`
### Modal / Drawer
- ❌ 弹框/抽屉 `.vue` 写业务逻辑
- ❌ a-modal 不加 `:keyboard="false" :mask-closable="false"`
- ❌ 省略 `finally``pageInfo.spin = false`
- ❌ 用文本判断新增/编辑
-`addRequest` 保留 id 字段
- ❌ 省略 a-drawer 的 `destroy-on-close`
### 数据展示
- ❌ 删除/批量不经二次确认
- ❌ 批量按钮未选中时可点击
- ❌ 空值显示为空串/null
- ❌ 时间/日期格式不统一
### 图标 / UI
- ❌ emoji / 自定义 svg 作菜单或按钮图标
- ❌ 操作列 `<a-button type="link">` 加图标
- ❌ AI 脚手架默认生成 CRUD 按钮
- ❌ 操作列按钮之间不加 `<a-divider type="vertical" />`
-`customRender` 写 JSX(统一用 `h()`
-`a-input-number` 不设 `style="width: 100%"`
- ❌ 顶栏高度偏离 64px
-`App.vue` 漏写 `<a-config-provider :locale="zhCN">`
---
## 12. 进一步阅读
生成代码前 Codex **必须先读**对应文档:
| 任务 | 必读文档 |
|------|---------|
| 任何业务页面 | `src/docs/business-architecture.md` |
| 列表页 | `src/docs/list-page-spec.md` |
| 弹框 | `src/docs/modal-spec.md` |
| 抽屉 | `src/docs/drawer-spec.md` |
| 详情页 | `src/docs/detail-page-spec.md` |
| Tabs 页 | `src/docs/tabs-page-spec.md` |
| 表格工具 | `src/docs/utils-table.md` |
| 弹窗工具 | `src/docs/utils-popup.md` |
| 对照研发项目 | `src/docs/cross-project-mapping.md` |
| 完整规则 | `CLAUDE.md`(项目根目录,权威源) |
---
## 13. 迁移到 vue 研发项目(设计意图)
研发接手时步骤(Codex 编写代码时应保证可平滑迁移):
1. 替换 `src/axios/` 为真实 axios 封装(**保留方法签名**)
2. 删除 `src/axios/handlers/`
3. 删除原型独有页面(`gate / console / prd / overview`)和 `composables/useVisibility`
4.`<TableCard :table>` 拆为 `<a-row class="listPageTable">` + `<a-table>`
5.`<FilterBar v-model>` 拆为 `<a-row class="listPageForm">` + `<a-form>`
6. **业务逻辑 100% 不改**
+21 -6
View File
@@ -13,17 +13,22 @@
|------|-----|-------|---------|---------|
| 综合管理后台 | `admin-portal` | Ant Design Vue | 平台管理员 | 平台级管理(系统设置/运营中心/设备中心 为顶部一级菜单,**无子应用、无应用切换、无应用网格**) |
| 租户运营后台 | `tenant-portal` | Ant Design Vue | 租户运营人员 | **17 个独立可部署应用的统一入口**(营养管理/健康监测/...),有应用网格首页 |
| C 端微信小程序 | `miniprogram` | Vant 4375×812 | 终端用户 | 用户自助查询、上报、咨询 |
| 微信小程序 | `miniprogram` | Vant 4375×812 | 多种 | **支持多个小程序实例并存**,每个实例一组底部 TabURL `/miniprogram/<mpKey>/<tabPagePath>` |
| 硬件终端屏 | `hardware` | 自定义(动态 viewport) | 现场操作员 | 净菜柜屏 / 自助查询屏 / 大屏 等 |
> **重要架构说明**admin / tenant 形态差异):
> **重要架构说明**admin / tenant / miniprogram 形态差异):
> - **综合管理后台为单应用形态**:3 个一级菜单(系统设置 / 运营中心 / 设备中心)直接挂在 portal 下,URL = `/admin-portal/<section>/<page>`,无应用切换概念。
> - **租户运营后台为多应用形态**(对齐生产 vue 项目 `D:\Work\platform-vue-tenant` 的 qiankun 微前端):下挂多个**独立可部署的应用(NavApp)**,每个应用对应生产 vue 项目中的一个 `subXxx` 子应用。
> - 进入某个应用后,顶部显示**该应用内的一级菜单 tabs**NavSection),左侧显示**当前一级菜单下的二/三级菜单**NavGroup → NavLeaf
> - URL`/tenant-portal/<app>/<section>/<page>`,例:`/tenant-portal/nutrition/nutrition-farm/monitor/crop`
> - 应用首页(`/tenant-portal`):展示应用网格卡片(参考 vue 项目 main 的 `home.vue`
> - 不支持在 TopBar 切换应用;切换应用通过"应用首页"或"返回原型首页"完成
> - **最近打开菜单(PageHistory)按 portal + app 隔离**scope key 形如 `admin-portal__admin` / `tenant-portal__nutrition`;切换应用后只显示当前应用打开过的菜单。
> - **微信小程序为多实例形态**:每个实例 = 一个 `NavMiniprogram`key / label / tabs[]),独立的一组底部 Tab。当前已落地 2 个:
> - `customer`C 端用户小程序(吃 / 问 / 做 / 我),终端用户使用
> - `ops`:实施运维小程序(工作台 / 工单 / 扫码 / 巡检 / 我的),仅实施运维工程师使用,详见 `src/docs/device/设备管理服务器功能现状.md` §8
> - URL`/miniprogram/<mpKey>/<tabPagePath>`,例:`/miniprogram/ops/workbench`
> - 新增小程序只需在 `src/meta/nav.ts` 的 `miniprograms` 数组追加一条
> - **最近打开菜单(PageHistory)按 portal + app 隔离**scope key 形如 `admin-portal__admin` / `tenant-portal__nutrition` / `miniprogram__ops`;切换应用 / 小程序后只显示当前 scope 打开过的菜单。
> 详细业务边界与各应用模块清单见 **`src/docs/business-architecture.md`**。AI 在生成原型前**必须先读这份文档**。
---
@@ -187,9 +192,18 @@ yx-platform-prototype/
│ │ │ └── farm/ 绿色种采(一级菜单)
│ │ │ └── FarmLand.vue
│ │ └── monitor/ 健康监测应用(菜单结构占位,页面待开发)
│ ├── miniprogram/ 小程序业务页面
│ │ ├── home/Home.vue
│ │ └── profile/Profile.vue
│ ├── miniprogram/ 微信小程序业务页面(按实例分目录)
│ │ ├── customer/ C 端用户小程序
│ │ │ ├── eat/Home.vue
│ │ │ ├── ask/Home.vue
│ │ │ ├── make/Home.vue
│ │ │ └── me/Home.vue
│ │ └── ops/ 实施运维小程序(仅运维工程师)
│ │ ├── workbench/Home.vue
│ │ ├── workOrder/Home.vue
│ │ ├── scan/Home.vue
│ │ ├── inspect/Home.vue
│ │ └── me/Home.vue
│ └── hardware/ 硬件业务页面
│ └── cabinet/Home.vue
@@ -251,6 +265,7 @@ yx-platform-prototype/
1. **菜单单一来源**:所有菜单结构集中在 `src/meta/nav.ts`
2. **三层菜单结构**:每个应用(NavApp → 一级菜单(NavSection → 二/三级菜单(NavGroup → NavLeaf
2.1. **侧边栏手风琴(全局约束)**`Sidebar.vue` 必须保持"同一时刻最多展开一个二级分组(section.children 这一层)",展开新的二级分组时自动收起其他二级分组。激活叶子所在的祖先分组链不参与互斥(保证选中项始终可见)。三级及更深嵌套分组随二级收起连带隐藏,不单独互斥。该约束对 admin-portal / tenant-portal 全后台生效,禁止移除 `onOpenChange` 互斥逻辑。
3. **URL 规范**`/<portal>/<app>/<section>/<page>`,例:`/tenant-portal/nutrition/nutrition-farm/monitor/crop`
4. **列表页 UI 规范(重要)**
- **不渲染 PageHeader**(菜单 + 顶部历史栏已经表达所在位置)
+34
View File
@@ -0,0 +1,34 @@
import { registerMocks } from '@axios/mockBus'
import type { ApiResponse, MockContext } from '@axios'
/**
* 字典 mock 数据集
*
* 设计目的:与 vue 研发项目 @api 中 dictItems(code) 对接,
* 业务页面调用 dictItems('xxx') 拉取对应字典枚举。
*
* 新增字典:在 dictMap 中追加 code → items 即可。
*/
const dictMap: Record<string, Array<{ label: string; value: number | string }>> = {
/** 设备厂商 */
device_manufacturer: [
{ label: '华为', value: 1 },
{ label: '蚁熊', value: 2 },
{ label: '罗杰夫', value: 3 },
{ label: '鱼跃', value: 4 },
{ label: '上禾电子', value: 5 },
],
}
const handleItems = (ctx: MockContext): ApiResponse<Array<{ label: string; value: number | string }>> => {
const params = (ctx.params ?? {}) as { code?: string }
if (!params.code) {
return { code: '00000', msg: '字典编码不能为空', data: [] }
}
const data = dictMap[params.code] ?? []
return { code: '00000', msg: '查询成功', data }
}
registerMocks([
{ method: 'POST', url: '/sys/dict/items', handler: handleItems },
])
+175
View File
@@ -0,0 +1,175 @@
import { registerMocks } from '@axios/mockBus'
import type { ApiResponse, MockContext, RequestParameter } from '@axios'
interface PageParams {
pageNum?: number
pageSize?: number
column?: string
order?: 'ascend' | 'descend' | null
[key: string]: unknown
}
interface AdminRecord {
id: string
status?: boolean
hidden?: boolean
createTime?: string
[key: string]: unknown
}
const getParams = (params: RequestParameter): PageParams => (params ?? {}) as PageParams
const includes = (value: unknown, keyword: unknown): boolean => {
if (!keyword) return true
return String(value ?? '').toLowerCase().includes(String(keyword).toLowerCase())
}
const paginate = <T extends AdminRecord>(list: T[], params: PageParams): ApiResponse<T[]> => {
const pageNum = Number(params.pageNum ?? 1)
const pageSize = Number(params.pageSize ?? 30)
const start = (pageNum - 1) * pageSize
return {
code: '00000',
msg: '查询成功',
data: list.slice(start, start + pageSize),
total: list.length,
}
}
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({
code: '00000',
msg,
data,
})
const employeeList: AdminRecord[] = [
{ id: 'emp-001', account: 'zhangsan', name: '张三', mobile: '13800010001', status: true, roleName: '平台管理员', createTime: '2026-05-18 09:12:30' },
{ id: 'emp-002', account: 'lisi', name: '李四', mobile: '13800010002', status: true, roleName: '运营管理员', createTime: '2026-05-19 10:24:18' },
{ id: 'emp-003', account: 'wangwu', name: '王五', mobile: '13800010003', status: false, roleName: '审核员', createTime: '2026-05-20 14:08:02' },
{ id: 'emp-004', account: 'zhaoliu', name: '赵六', mobile: '13800010004', status: true, roleName: '设备管理员', createTime: '2026-05-21 16:45:11' },
{ id: 'emp-005', account: 'sunqi', name: '孙七', mobile: '13800010005', status: true, roleName: '字典维护员', createTime: '2026-05-22 11:33:46' },
]
const roleList: AdminRecord[] = [
{ id: 'role-001', name: '平台管理员', code: 'PLATFORM_ADMIN', status: true, createTime: '2026-05-10 09:20:00' },
{ id: 'role-002', name: '运营管理员', code: 'OPERATION_ADMIN', status: true, createTime: '2026-05-11 10:15:00' },
{ id: 'role-003', name: '审核员', code: 'AUDITOR', status: true, createTime: '2026-05-12 13:40:00' },
{ id: 'role-004', name: '设备管理员', code: 'DEVICE_ADMIN', status: false, createTime: '2026-05-13 15:22:00' },
]
const miniProgramUserList: AdminRecord[] = [
{ id: 'mp-001', faceName: '清风', phone: '13900010001', name: '周敏', sex: 2, status: true, createTime: '2026-05-23 08:12:09' },
{ id: 'mp-002', faceName: '远山', phone: '13900010002', name: '吴磊', sex: 1, status: true, createTime: '2026-05-23 09:36:22' },
{ id: 'mp-003', faceName: '小禾', phone: '13900010003', name: '郑洁', sex: 2, status: true, createTime: '2026-05-24 11:18:40' },
{ id: 'mp-004', faceName: '北辰', phone: '13900010004', name: '陈宇', sex: 1, status: false, createTime: '2026-05-24 14:52:17' },
]
const applicationList: AdminRecord[] = [
{ id: 'app-001', name: '综合管理后台', category: 1, appType: 1, code: 'admin-portal', status: true, createTime: '2026-05-01 09:00:00' },
{ id: 'app-002', name: '租户运营后台', category: 1, appType: 1, code: 'tenant-portal', status: true, createTime: '2026-05-02 09:00:00' },
{ id: 'app-003', name: '智养小程序', category: 4, appType: 3, code: 'miniprogram', status: true, createTime: '2026-05-03 09:00:00' },
{ id: 'app-004', name: '硬件终端屏', category: 4, appType: 4, code: 'hardware-screen', status: false, createTime: '2026-05-04 09:00:00' },
]
const menuList: AdminRecord[] = [
{ id: 'menu-001', sortNo: '1', elementType: 0, iconUrl: 'SettingOutlined', name: '系统设置', component: '@pages/admin-portal/system-settings', routePath: '/admin-portal/system-settings', accessControl: 1, hidden: false, createTime: '2026-05-05 10:00:00' },
{ id: 'menu-002', sortNo: '1-1', elementType: 0, iconUrl: 'SafetyCertificateOutlined', name: '权限管理', component: '@pages/admin-portal/system-settings/permission', routePath: '/admin-portal/system-settings/permission', accessControl: 1, hidden: false, createTime: '2026-05-05 10:10:00' },
{ id: 'menu-003', sortNo: '1-2', elementType: 0, iconUrl: 'AppstoreOutlined', name: '应用与服务', component: '@pages/admin-portal/system-settings/appService', routePath: '/admin-portal/system-settings/app-service', accessControl: 1, hidden: false, createTime: '2026-05-05 10:20:00' },
{ id: 'menu-004', sortNo: '2', elementType: 0, iconUrl: 'FundProjectionScreenOutlined', name: '运营中心', component: '@pages/admin-portal/operation-center', routePath: '/admin-portal/operation-center', accessControl: 1, hidden: false, createTime: '2026-05-05 10:30:00' },
]
const dictionaryList: AdminRecord[] = [
{ id: 'dict-001', dictName: '启停状态', dictCode: 'enable_status', description: '系统通用启用停用状态', createTime: '2026-05-06 09:00:00' },
{ id: 'dict-002', dictName: '应用类型', dictCode: 'app_type', description: 'Web、APP、小程序、终端设备', createTime: '2026-05-06 09:10:00' },
{ id: 'dict-003', dictName: '租户状态', dictCode: 'tenant_status', description: '租户生命周期状态', createTime: '2026-05-06 09:20:00' },
{ id: 'dict-004', dictName: '性别', dictCode: 'gender', description: '小程序用户基础信息', createTime: '2026-05-06 09:30:00' },
]
const tenantList: AdminRecord[] = [
{ id: 'tenant-001', name: '长庆能源总部食堂', code: 'cq-energy', expireTime: '2027-06-30', status: true, createTime: '2026-05-08 09:18:00' },
{ id: 'tenant-002', name: '第一采油厂', code: 'oilfield-01', expireTime: '2027-05-31', status: true, createTime: '2026-05-09 10:22:00' },
{ id: 'tenant-003', name: '炼化分公司', code: 'refining', expireTime: '2026-12-31', status: true, createTime: '2026-05-10 11:35:00' },
{ id: 'tenant-004', name: '健康示范园区', code: 'health-park', expireTime: '2026-10-31', status: false, createTime: '2026-05-11 14:06:00' },
]
const updateBooleanField = (list: AdminRecord[], params: PageParams, field: 'status' | 'hidden'): ApiResponse<null> => {
const id = String(params.id ?? '')
const value = Boolean(params[field])
const target = list.find((item) => item.id === id)
if (target) target[field] = value
return ok('操作成功')
}
const deleteById = (list: AdminRecord[], params: PageParams): ApiResponse<null> => {
const id = String(params.id ?? '')
const index = list.findIndex((item) => item.id === id)
if (index >= 0) list.splice(index, 1)
return ok('删除成功')
}
const listEmployee = (ctx: MockContext): ApiResponse<AdminRecord[]> => {
const params = getParams(ctx.params)
return paginate(employeeList.filter((item) => includes(item.name, params.name) || includes(item.account, params.name)), params)
}
const listRole = (ctx: MockContext): ApiResponse<AdminRecord[]> => {
const params = getParams(ctx.params)
return paginate(roleList.filter((item) => includes(item.name, params.name)), params)
}
const listMiniProgramUser = (ctx: MockContext): ApiResponse<AdminRecord[]> => {
const params = getParams(ctx.params)
return paginate(miniProgramUserList.filter((item) => includes(item.faceName, params.keyword) || includes(item.phone, params.keyword)), params)
}
const listApplication = (ctx: MockContext): ApiResponse<AdminRecord[]> => {
const params = getParams(ctx.params)
return paginate(applicationList.filter((item) => includes(item.name, params.name)), params)
}
const listMenu = (ctx: MockContext): ApiResponse<AdminRecord[]> => {
const params = getParams(ctx.params)
return paginate(menuList.filter((item) => includes(item.name, params.name)), params)
}
const listDictionary = (ctx: MockContext): ApiResponse<AdminRecord[]> => {
const params = getParams(ctx.params)
return paginate(dictionaryList.filter((item) => includes(item.dictName, params.dictName) || includes(item.dictCode, params.dictName)), params)
}
const listTenant = (ctx: MockContext): ApiResponse<AdminRecord[]> => {
const params = getParams(ctx.params)
return paginate(tenantList.filter((item) => includes(item.name, params.name)), params)
}
registerMocks([
{ method: 'POST', url: '/admin/employee/page', handler: listEmployee },
{ method: 'POST', url: '/admin/employee/delete', handler: (ctx) => deleteById(employeeList, getParams(ctx.params)) },
{ method: 'POST', url: '/admin/employee/status', handler: (ctx) => updateBooleanField(employeeList, getParams(ctx.params), 'status') },
{ method: 'POST', url: '/admin/employee/reset-password', handler: () => ok('重置密码成功') },
{ method: 'POST', url: '/admin/role/page', handler: listRole },
{ method: 'POST', url: '/admin/role/delete', handler: (ctx) => deleteById(roleList, getParams(ctx.params)) },
{ method: 'POST', url: '/admin/role/status', handler: (ctx) => updateBooleanField(roleList, getParams(ctx.params), 'status') },
{ method: 'POST', url: '/admin/mini-program-user/page', handler: listMiniProgramUser },
{ method: 'POST', url: '/admin/mini-program-user/delete', handler: (ctx) => deleteById(miniProgramUserList, getParams(ctx.params)) },
{ method: 'POST', url: '/admin/mini-program-user/status', handler: (ctx) => updateBooleanField(miniProgramUserList, getParams(ctx.params), 'status') },
{ method: 'POST', url: '/admin/application/page', handler: listApplication },
{ method: 'POST', url: '/admin/application/delete', handler: (ctx) => deleteById(applicationList, getParams(ctx.params)) },
{ method: 'POST', url: '/admin/application/status', handler: (ctx) => updateBooleanField(applicationList, getParams(ctx.params), 'status') },
{ method: 'POST', url: '/admin/menu/page', handler: listMenu },
{ method: 'POST', url: '/admin/menu/delete', handler: (ctx) => deleteById(menuList, getParams(ctx.params)) },
{ method: 'POST', url: '/admin/menu/hidden', handler: (ctx) => updateBooleanField(menuList, getParams(ctx.params), 'hidden') },
{ method: 'POST', url: '/admin/dictionary/page', handler: listDictionary },
{ method: 'POST', url: '/admin/dictionary/delete', handler: (ctx) => deleteById(dictionaryList, getParams(ctx.params)) },
{ method: 'POST', url: '/admin/tenant/page', handler: listTenant },
{ method: 'POST', url: '/admin/tenant/delete', handler: (ctx) => deleteById(tenantList, getParams(ctx.params)) },
{ method: 'POST', url: '/admin/tenant/status', handler: (ctx) => updateBooleanField(tenantList, getParams(ctx.params), 'status') },
{ method: 'POST', url: '/admin/tenant/reset-password', handler: () => ok('重置密码成功') },
{ method: 'POST', url: '/admin/tenant/auth', handler: () => ok('授权应用保存成功') },
])
@@ -0,0 +1,307 @@
import { registerMocks } from '@axios/mockBus'
import type { ApiResponse, MockContext } from '@axios'
interface CategoryNode {
id: string
name: string
/** 编码:系统按 D01 / D01A01 / D01A01A01 规则自动生成 */
code: string
/** 层级:1=顶级分类,2=二级分类,依此类推 */
level: number
parentId?: string
/** 父级名称(仅 info 回填时注入,树节点本身不存储) */
parentName?: string
sortOrder: number
/** 是否 IOT 设备(仅 level >= 2 节点配置) */
isIot?: boolean
/** 是否启用数据中转(仅 level >= 2 节点配置) */
dataTransit?: boolean
/** 是否启用数据上报(仅 level >= 2 节点配置) */
dataReport?: boolean
/** 厂商(字典 device_manufacturer 的 value;仅叶子节点配置) */
manufacturer?: number
/** 型号(仅叶子节点配置) */
model?: string
remark?: string
children?: CategoryNode[]
createTime?: string
}
interface CategoryPayload {
id?: string
name: string
/** code 由系统自动生成,新增时不传 */
code?: string
level: number
parentId?: string
parentName?: string
sortOrder: number
/** 业务字段仅在 level >= 2 时有效 */
isIot?: boolean
dataTransit?: boolean
dataReport?: boolean
/** 叶子节点专属 */
manufacturer?: number
model?: string
remark?: string
}
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({
code: '00000',
msg,
data,
})
const now = (): string => new Date().toISOString().replace('T', ' ').slice(0, 19)
/** 顶级分类预置 ID 前缀 */
const TOP = {
catering: 'cat-catering',
body: 'cat-body',
wearable: 'cat-wearable',
fitness: 'cat-fitness',
monitor: 'cat-monitor',
} as const
/** 厂商字典 value(与 admin/dict.ts 中 device_manufacturer 保持一致) */
const MANUFACTURER = {
huawei: 1,
yixiong: 2,
luojiefu: 3,
yuyue: 4,
shanghe: 5,
} as const
/**
* 设备分类树 mock 数据
*
* 编码规则:
* - 顶级:D01、D02 …
* - 二级:D01A01、D01A02 …(父编码 + A + 同级序号)
* - 三级:D01A01A01 …
*
* 字段规则:
* - 顶级节点:仅名称/编码/排序/备注,不携带 IOT/数据分发/数据上报/厂商/型号
* - 二级及以下节点:携带 IOT/数据分发/数据上报
* - 叶子节点:额外携带厂商/型号
*/
const categoryTree: CategoryNode[] = [
{
id: TOP.catering, name: '智养餐饮设备', code: 'D01', level: 1, sortOrder: 1,
remark: '食堂类设备,业务数据不过平台', createTime: '2026-05-10 09:20:00',
children: [
{ id: 'cat-catering-plate', name: '吐盘机', code: 'D01A01', level: 2, parentId: TOP.catering, sortOrder: 1, isIot: true, dataTransit: false, dataReport: false, manufacturer: MANUFACTURER.shanghe, model: 'SH-TP-200', createTime: '2026-05-11 10:00:00' },
{ id: 'cat-catering-cabinet', name: '智能货柜', code: 'D01A02', level: 2, parentId: TOP.catering, sortOrder: 2, isIot: true, dataTransit: false, dataReport: false, manufacturer: MANUFACTURER.huawei, model: 'HW-CAB-100L', createTime: '2026-05-11 10:05:00' },
{ id: 'cat-catering-nutri-scale', name: '营养秤', code: 'D01A03', level: 2, parentId: TOP.catering, sortOrder: 3, isIot: true, dataTransit: false, dataReport: false, manufacturer: MANUFACTURER.yuyue, model: 'YY-NS-5KG', createTime: '2026-05-11 10:10:00' },
{ id: 'cat-catering-ratio-scale', name: '配比秤', code: 'D01A04', level: 2, parentId: TOP.catering, sortOrder: 4, isIot: true, dataTransit: false, dataReport: false, manufacturer: MANUFACTURER.yuyue, model: 'YY-RS-15KG', createTime: '2026-05-11 10:15:00' },
{ id: 'cat-catering-checkout', name: '结算台', code: 'D01A05', level: 2, parentId: TOP.catering, sortOrder: 5, isIot: true, dataTransit: false, dataReport: false, manufacturer: MANUFACTURER.huawei, model: 'HW-CK-21IN', createTime: '2026-05-11 10:20:00' },
{ id: 'cat-catering-inbound-scale', name: '入库秤', code: 'D01A06', level: 2, parentId: TOP.catering, sortOrder: 6, isIot: true, dataTransit: false, dataReport: false, manufacturer: MANUFACTURER.yuyue, model: 'YY-IS-30KG', createTime: '2026-05-11 10:25:00' },
{ id: 'cat-catering-band-screen', name: '带鱼屏', code: 'D01A07', level: 2, parentId: TOP.catering, sortOrder: 7, isIot: false, dataTransit: false, dataReport: false, manufacturer: MANUFACTURER.shanghe, model: 'SH-BS-25IN', createTime: '2026-05-11 10:30:00' },
{ id: 'cat-catering-baifei', name: '波菲炉', code: 'D01A08', level: 2, parentId: TOP.catering, sortOrder: 8, isIot: false, dataTransit: false, dataReport: false, manufacturer: MANUFACTURER.shanghe, model: 'SH-BF-4GN', createTime: '2026-05-11 10:35:00' },
],
},
{
id: TOP.body, name: '体重测量设备', code: 'D02', level: 1, sortOrder: 2,
remark: '业务数据过平台中转', createTime: '2026-05-10 09:25:00',
children: [
{ id: 'cat-body-all-in-one', name: '一体机', code: 'D02A01', level: 2, parentId: TOP.body, sortOrder: 1, isIot: true, dataTransit: true, dataReport: true, manufacturer: MANUFACTURER.luojiefu, model: 'LJF-AIO-V3', createTime: '2026-05-11 11:00:00' },
{ id: 'cat-body-bt-scale', name: '蓝牙秤', code: 'D02A02', level: 2, parentId: TOP.body, sortOrder: 2, isIot: true, dataTransit: true, dataReport: true, manufacturer: MANUFACTURER.yuyue, model: 'YY-BT-180KG', createTime: '2026-05-11 11:05:00' },
{ id: 'cat-body-fat', name: '体脂仪', code: 'D02A03', level: 2, parentId: TOP.body, sortOrder: 3, isIot: true, dataTransit: true, dataReport: true, manufacturer: MANUFACTURER.huawei, model: 'HW-FAT-BH', createTime: '2026-05-11 11:10:00' },
],
},
{
id: TOP.wearable, name: '智能穿戴设备', code: 'D03', level: 1, sortOrder: 3,
remark: '业务数据过平台中转', createTime: '2026-05-10 09:30:00',
children: [
{ id: 'cat-wearable-hw5', name: '华为-watch5', code: 'D03A01', level: 2, parentId: TOP.wearable, sortOrder: 1, isIot: true, dataTransit: true, dataReport: true, manufacturer: MANUFACTURER.huawei, model: 'Watch-5', createTime: '2026-05-11 11:30:00' },
{ id: 'cat-wearable-hw4', name: '华为-watch4', code: 'D03A02', level: 2, parentId: TOP.wearable, sortOrder: 2, isIot: true, dataTransit: true, dataReport: true, manufacturer: MANUFACTURER.huawei, model: 'Watch-4', createTime: '2026-05-11 11:35:00' },
{ id: 'cat-wearable-ljf01', name: '罗杰夫-LJF01', code: 'D03A03', level: 2, parentId: TOP.wearable, sortOrder: 3, isIot: true, dataTransit: true, dataReport: true, manufacturer: MANUFACTURER.luojiefu, model: 'LJF-01', createTime: '2026-05-11 11:40:00' },
],
},
{
id: TOP.fitness, name: '健身器材设备', code: 'D04', level: 1, sortOrder: 4,
remark: '业务数据过平台中转', createTime: '2026-05-10 09:35:00',
children: [
{ id: 'cat-fitness-treadmill', name: '跑步机', code: 'D04A01', level: 2, parentId: TOP.fitness, sortOrder: 1, isIot: true, dataTransit: true, dataReport: true, manufacturer: MANUFACTURER.yixiong, model: 'YX-TM-Pro', createTime: '2026-05-11 12:00:00' },
{ id: 'cat-fitness-elliptical', name: '椭圆机', code: 'D04A02', level: 2, parentId: TOP.fitness, sortOrder: 2, isIot: true, dataTransit: true, dataReport: true, manufacturer: MANUFACTURER.yixiong, model: 'YX-EL-X1', createTime: '2026-05-11 12:05:00' },
{ id: 'cat-fitness-bike', name: '健身车', code: 'D04A03', level: 2, parentId: TOP.fitness, sortOrder: 3, isIot: true, dataTransit: true, dataReport: true, manufacturer: MANUFACTURER.yixiong, model: 'YX-BK-M3', createTime: '2026-05-11 12:10:00' },
],
},
{
id: TOP.monitor, name: '健康监测设备', code: 'D05', level: 1, sortOrder: 5,
remark: '业务数据过平台中转', createTime: '2026-05-10 09:40:00',
children: [
{ id: 'cat-monitor-sugar', name: '血糖监测仪', code: 'D05A01', level: 2, parentId: TOP.monitor, sortOrder: 1, isIot: true, dataTransit: true, dataReport: true, manufacturer: MANUFACTURER.yuyue, model: 'YY-BG-530', createTime: '2026-05-11 12:30:00' },
{ id: 'cat-monitor-pressure', name: '高血压治疗仪', code: 'D05A02', level: 2, parentId: TOP.monitor, sortOrder: 2, isIot: true, dataTransit: true, dataReport: true, manufacturer: MANUFACTURER.yuyue, model: 'YY-BP-680', createTime: '2026-05-11 12:35:00' },
{ id: 'cat-monitor-all-in-one', name: '智能健康一体机', code: 'D05A03', level: 2, parentId: TOP.monitor, sortOrder: 3, isIot: true, dataTransit: true, dataReport: true, manufacturer: MANUFACTURER.luojiefu, model: 'LJF-HM-Z1', createTime: '2026-05-11 12:40:00' },
],
},
]
/** 按关键字过滤树(保留命中节点的祖先链) */
const filterTree = (nodes: CategoryNode[], keyword: string): CategoryNode[] => {
const lower = keyword.toLowerCase()
const walk = (list: CategoryNode[]): CategoryNode[] => {
const result: CategoryNode[] = []
list.forEach((node) => {
const matched =
node.name.toLowerCase().includes(lower) ||
node.code.toLowerCase().includes(lower)
const children = node.children ? walk(node.children) : []
if (matched || children.length > 0) {
result.push({ ...node, children: children.length > 0 ? children : undefined })
}
})
return result
}
return walk(nodes)
}
/** 深拷贝树(避免 mock 数据被修改污染) */
const cloneTree = (nodes: CategoryNode[]): CategoryNode[] =>
nodes.map((node) => ({ ...node, children: node.children ? cloneTree(node.children) : undefined }))
/** 递归查找并移除节点 */
const removeNode = (nodes: CategoryNode[], id: string): boolean => {
const idx = nodes.findIndex((n) => n.id === id)
if (idx >= 0) {
nodes.splice(idx, 1)
return true
}
for (const node of nodes) {
if (node.children && removeNode(node.children, id)) return true
}
return false
}
/** 递归查找节点 */
const findNode = (nodes: CategoryNode[], id: string): CategoryNode | null => {
for (const node of nodes) {
if (node.id === id) return node
if (node.children) {
const found = findNode(node.children, id)
if (found) return found
}
}
return null
}
/** 生成新 ID */
const genId = (prefix: string): string =>
`${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
/**
* 按规则生成节点编码
* - 顶级:D + 两位序号(按现有顶级数 +1)
* - 子级:parent.code + 'A' + 两位序号(按父级下现有子节点数 +1)
*/
const genCode = (tree: CategoryNode[], parentId?: string): string => {
const pad = (n: number): string => n.toString().padStart(2, '0')
if (!parentId) {
return `D${pad(tree.length + 1)}`
}
const parent = findNode(tree, parentId)
if (!parent) return `D${pad(tree.length + 1)}A01`
const siblingCount = parent.children?.length ?? 0
return `${parent.code}A${pad(siblingCount + 1)}`
}
const handleTree = (ctx: MockContext): ApiResponse<CategoryNode[]> => {
const params = (ctx.params ?? {}) as { keyword?: string }
const data = cloneTree(categoryTree)
const result = params.keyword ? filterTree(data, params.keyword) : data
return ok('查询成功', result)
}
const handleAdd = (ctx: MockContext): ApiResponse<null> => {
const payload = (ctx.params ?? {}) as CategoryPayload
if (!payload.name) {
return ok('名称不能为空')
}
/** code 由系统按 D01A01 规则生成 */
const newCode = genCode(categoryTree, payload.parentId)
const newNode: CategoryNode = {
id: genId('cat'),
name: payload.name,
code: newCode,
level: payload.level,
parentId: payload.parentId,
sortOrder: payload.sortOrder,
remark: payload.remark,
createTime: now(),
}
/** 二级及以下节点:携带业务字段 */
if (payload.level >= 2) {
newNode.isIot = payload.isIot ?? false
newNode.dataTransit = payload.dataTransit ?? false
newNode.dataReport = payload.dataReport ?? false
/** 叶子节点(新建时必然无子)携带厂商/型号 */
newNode.manufacturer = payload.manufacturer
newNode.model = payload.model
}
if (payload.parentId) {
const parent = findNode(categoryTree, payload.parentId)
if (parent) {
parent.children = parent.children ?? []
parent.children.push(newNode)
}
} else {
categoryTree.push(newNode)
}
return ok(`新增成功,编码:${newCode}`)
}
const handleUpdate = (ctx: MockContext): ApiResponse<null> => {
const payload = (ctx.params ?? {}) as CategoryPayload
if (!payload.id) return ok('ID 不能为空')
const node = findNode(categoryTree, payload.id)
if (!node) return ok('节点不存在')
node.name = payload.name
node.sortOrder = payload.sortOrder
node.remark = payload.remark
/** 二级及以下节点:更新业务字段 */
if (node.level >= 2) {
node.isIot = payload.isIot ?? false
node.dataTransit = payload.dataTransit ?? false
node.dataReport = payload.dataReport ?? false
/** 叶子节点(无子)才更新厂商/型号 */
if (!node.children?.length) {
node.manufacturer = payload.manufacturer
node.model = payload.model
}
}
return ok('更新成功')
}
const handleInfo = (ctx: MockContext): ApiResponse<CategoryNode> => {
const params = (ctx.params ?? {}) as { id?: string }
if (!params.id) return ok('ID 不能为空')
const node = findNode(categoryTree, params.id)
if (!node) return ok('节点不存在')
/** 附带父级名称便于弹框回填展示 */
const result: CategoryNode = { ...node }
if (node.parentId) {
const parent = findNode(categoryTree, node.parentId)
result.parentName = parent?.name
}
return ok('查询成功', result)
}
const handleDelete = (ctx: MockContext): ApiResponse<null> => {
const params = (ctx.params ?? {}) as { id?: string }
if (!params.id) return ok('ID 不能为空')
/** 顶级分类预置 5 类不可删除 */
if (Object.values(TOP).includes(params.id as typeof TOP[keyof typeof TOP])) {
return ok('顶级分类为系统预置,不可删除')
}
const removed = removeNode(categoryTree, params.id)
return removed ? ok('删除成功') : ok('节点不存在')
}
registerMocks([
{ method: 'POST', url: '/admin/device/category/tree', handler: handleTree },
{ method: 'POST', url: '/admin/device/category/info', handler: handleInfo },
{ method: 'POST', url: '/admin/device/category/add', handler: handleAdd },
{ method: 'POST', url: '/admin/device/category/update', handler: handleUpdate },
{ method: 'POST', url: '/admin/device/category/delete', handler: handleDelete },
])
@@ -0,0 +1,167 @@
import { registerMocks } from '@axios/mockBus'
import type { ApiResponse, MockContext, RequestParameter } from '@axios'
interface PageParams {
pageNum?: number
pageSize?: number
column?: string
order?: 'ascend' | 'descend' | null
keyword?: string
status?: string
inboundType?: string
supplierId?: string
[key: string]: unknown
}
interface InboundRecord {
id: string
inboundCode: string
deviceCode: string
deviceName: string
modelName: string
batchNo: string
supplierId: string
supplierName: string
quantity: number
purchasePrice: number
inboundTime: string
operator: string
inboundType: 'purchase' | 'return' | 'transfer'
status: 'pending' | 'completed' | 'cancelled'
storageLocation?: string
remark?: string
}
const getParams = (params: RequestParameter): PageParams => (params ?? {}) as PageParams
const includes = (value: unknown, keyword: unknown): boolean => {
if (!keyword) return true
return String(value ?? '').toLowerCase().includes(String(keyword).toLowerCase())
}
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({
code: '00000',
msg,
data,
})
const paginate = <T extends InboundRecord>(list: T[], params: PageParams): ApiResponse<T[]> => {
const pageNum = Number(params.pageNum ?? 1)
const pageSize = Number(params.pageSize ?? 30)
const start = (pageNum - 1) * pageSize
return {
code: '00000',
msg: '查询成功',
data: list.slice(start, start + pageSize),
total: list.length,
}
}
const nowStr = (): string => {
const d = new Date()
const pad = (n: number): string => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
const inboundList: InboundRecord[] = [
{ id: 'inb-001', inboundCode: 'INB-20260601-001', deviceCode: 'CAB-A1-0003', deviceName: '智能货柜A1', modelName: '智能货柜', batchNo: 'B2026-001', supplierId: 'sup-001', supplierName: '深圳智能设备供应商', quantity: 5, purchasePrice: 4500, inboundTime: '2026-06-01 09:30:00', operator: '张三', inboundType: 'purchase', status: 'completed', storageLocation: 'A区-01', remark: '首次采购' },
{ id: 'inb-002', inboundCode: 'INB-20260605-002', deviceCode: 'WAT-W1-0002', deviceName: '鹏升手表W1', modelName: '华为-watch5', batchNo: 'B2026-002', supplierId: 'sup-002', supplierName: '鹏升电子', quantity: 10, purchasePrice: 880, inboundTime: '2026-06-05 14:20:00', operator: '李四', inboundType: 'purchase', status: 'completed', storageLocation: 'B区-02' },
{ id: 'inb-003', inboundCode: 'INB-20260610-003', deviceCode: 'CHK-M1-0002', deviceName: '华盛体检机', modelName: '智能健康一体机', batchNo: 'B2026-003', supplierId: 'sup-003', supplierName: '华盛医疗', quantity: 2, purchasePrice: 28000, inboundTime: '2026-06-10 11:00:00', operator: '王五', inboundType: 'purchase', status: 'pending', storageLocation: 'C区-03', remark: '等待验收' },
{ id: 'inb-004', inboundCode: 'INB-20260615-004', deviceCode: 'SCL-X1-0002', deviceName: '入库秤X1', modelName: '入库秤', batchNo: 'B2026-004', supplierId: 'sup-001', supplierName: '深圳智能设备供应商', quantity: 3, purchasePrice: 1200, inboundTime: '2026-06-15 16:45:00', operator: '赵六', inboundType: 'transfer', status: 'completed', storageLocation: 'A区-02', remark: '从杭州仓调拨' },
{ id: 'inb-005', inboundCode: 'INB-20260618-005', deviceCode: 'FAT-INBODY-0002', deviceName: 'InBody体脂秤', modelName: '体脂仪', batchNo: 'B2026-005', supplierId: 'sup-004', supplierName: 'InBody中国', quantity: 1, purchasePrice: 6500, inboundTime: '2026-06-18 10:15:00', operator: '孙七', inboundType: 'return', status: 'cancelled', storageLocation: 'D区-01', remark: '质量问题已退回' },
{ id: 'inb-006', inboundCode: 'INB-20260620-006', deviceCode: 'FIT-S1-0002', deviceName: '顿米健身车', modelName: '健身车', batchNo: 'B2026-006', supplierId: 'sup-005', supplierName: '顿米科技', quantity: 4, purchasePrice: 3200, inboundTime: '2026-06-20 09:00:00', operator: '张三', inboundType: 'purchase', status: 'completed', storageLocation: 'E区-01' },
{ id: 'inb-007', inboundCode: 'INB-20260622-007', deviceCode: 'SCR-P1-0002', deviceName: '大屏P1', modelName: '带鱼屏', batchNo: 'B2026-007', supplierId: 'sup-001', supplierName: '深圳智能设备供应商', quantity: 2, purchasePrice: 5800, inboundTime: '2026-06-22 14:30:00', operator: '李四', inboundType: 'purchase', status: 'pending', storageLocation: 'A区-03' },
{ id: 'inb-008', inboundCode: 'INB-20260625-008', deviceCode: 'SCL-W-0002', deviceName: '体重秤', modelName: '蓝牙秤', batchNo: 'B2026-008', supplierId: 'sup-002', supplierName: '鹏升电子', quantity: 6, purchasePrice: 480, inboundTime: '2026-06-25 11:45:00', operator: '王五', inboundType: 'purchase', status: 'completed', storageLocation: 'B区-03' },
]
const listPage = (ctx: MockContext): ApiResponse<InboundRecord[]> => {
const params = getParams(ctx.params)
const filtered = inboundList.filter((item) => {
if (params.status && item.status !== params.status) return false
if (params.inboundType && item.inboundType !== params.inboundType) return false
if (params.supplierId && item.supplierId !== params.supplierId) return false
if (params.keyword && !includes(item.inboundCode, params.keyword) && !includes(item.deviceCode, params.keyword) && !includes(item.deviceName, params.keyword)) return false
return true
})
if (params.column === 'inboundTime' && params.order) {
filtered.sort((a, b) => {
const cmp = a.inboundTime.localeCompare(b.inboundTime)
return params.order === 'ascend' ? cmp : -cmp
})
}
return paginate(filtered, params)
}
const deleteById = (params: PageParams): ApiResponse<null> => {
const id = String(params.id ?? '')
const idx = inboundList.findIndex((item) => item.id === id)
if (idx >= 0) inboundList.splice(idx, 1)
return ok('删除成功')
}
/** 详情查询(编辑回填) */
const infoById = (params: PageParams): ApiResponse<InboundRecord | null> => {
const id = String(params.id ?? '')
const record = inboundList.find((item) => item.id === id)
return ok('查询成功', record ?? null)
}
/** 生成入库单号 */
const genInboundCode = (): string => {
const d = new Date()
const ymd = `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`
const seq = String(inboundList.length + 1).padStart(3, '0')
return `INB-${ymd}-${seq}`
}
/** 新增 */
const addRecord = (params: PageParams): ApiResponse<null> => {
const record: InboundRecord = {
id: `inb-${Date.now().toString(36)}`,
inboundCode: String(params.inboundCode ?? genInboundCode()),
deviceCode: String(params.deviceCode ?? ''),
deviceName: String(params.deviceName ?? ''),
modelName: String(params.modelName ?? ''),
batchNo: String(params.batchNo ?? ''),
supplierId: String(params.supplierId ?? ''),
supplierName: String(params.supplierName ?? ''),
quantity: Number(params.quantity ?? 1),
purchasePrice: Number(params.purchasePrice ?? 0),
inboundTime: nowStr(),
operator: '当前用户',
inboundType: (params.inboundType as InboundRecord['inboundType']) ?? 'purchase',
status: 'completed',
storageLocation: params.storageLocation ? String(params.storageLocation) : undefined,
remark: params.remark ? String(params.remark) : undefined,
}
inboundList.unshift(record)
return ok('新增成功')
}
/** 编辑 */
const updateRecord = (params: PageParams): ApiResponse<null> => {
const id = String(params.id ?? '')
const record = inboundList.find((item) => item.id === id)
if (!record) return ok('入库单不存在')
if (params.deviceCode !== undefined) record.deviceCode = String(params.deviceCode)
if (params.deviceName !== undefined) record.deviceName = String(params.deviceName)
if (params.modelName !== undefined) record.modelName = String(params.modelName)
if (params.batchNo !== undefined) record.batchNo = String(params.batchNo)
if (params.supplierId !== undefined) record.supplierId = String(params.supplierId)
if (params.supplierName !== undefined) record.supplierName = String(params.supplierName)
if (params.quantity !== undefined) record.quantity = Number(params.quantity)
if (params.purchasePrice !== undefined) record.purchasePrice = Number(params.purchasePrice)
if (params.inboundType !== undefined) record.inboundType = params.inboundType as InboundRecord['inboundType']
if (params.storageLocation !== undefined) record.storageLocation = String(params.storageLocation)
if (params.remark !== undefined) record.remark = params.remark ? String(params.remark) : undefined
return ok('更新成功')
}
registerMocks([
{ method: 'POST', url: '/admin/device/stock/inbound/page', handler: listPage },
{ method: 'POST', url: '/admin/device/stock/inbound/info', handler: (ctx) => infoById(getParams(ctx.params)) },
{ method: 'POST', url: '/admin/device/stock/inbound/add', handler: (ctx) => addRecord(getParams(ctx.params)) },
{ method: 'POST', url: '/admin/device/stock/inbound/update', handler: (ctx) => updateRecord(getParams(ctx.params)) },
{ method: 'POST', url: '/admin/device/stock/inbound/delete', handler: (ctx) => deleteById(getParams(ctx.params)) },
{ method: 'POST', url: '/admin/device/stock/inbound/export', handler: () => ok('导出任务已提交') },
])
@@ -0,0 +1,118 @@
import { registerMocks } from '@axios/mockBus'
import type { ApiResponse, MockContext, RequestParameter } from '@axios'
interface PageParams {
pageNum?: number
pageSize?: number
column?: string
order?: 'ascend' | 'descend' | null
keyword?: string
deviceType?: string
stockStatus?: string
id?: string
returnQty?: number
storageLocation?: string
remark?: string
[key: string]: unknown
}
type StockStatus = 'instock' | 'inbound' | 'shipped' | 'returned'
interface StockRecord {
id: string
modelCode: string
modelName: string
deviceType: string
typeName: string
batchNo: string
supplierName: string
purchasePrice: number
inboundTime: string
storageLocation: string
stockStatus: StockStatus
stockQty: number
inTransitQty: number
availableQty: number
shippedQty: number
updateTime: string
}
const getParams = (params: RequestParameter): PageParams => (params ?? {}) as PageParams
const includes = (value: unknown, keyword: unknown): boolean => {
if (!keyword) return true
return String(value ?? '').toLowerCase().includes(String(keyword).toLowerCase())
}
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({ code: '00000', msg, data })
const paginate = <T extends StockRecord>(list: T[], params: PageParams): ApiResponse<T[]> => {
const pageNum = Number(params.pageNum ?? 1)
const pageSize = Number(params.pageSize ?? 30)
const start = (pageNum - 1) * pageSize
return { code: '00000', msg: '查询成功', data: list.slice(start, start + pageSize), total: list.length }
}
const nowStr = (): string => {
const d = new Date()
const pad = (n: number): string => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
const stockList: StockRecord[] = [
{ id: 'stk-001', modelCode: 'CABINET-A1', modelName: '智能货柜A1', deviceType: 'cabinet', typeName: '餐盘柜', batchNo: 'B2026-001', supplierName: '深圳智能设备供应商', purchasePrice: 8500, inboundTime: '2026-06-22 09:30:00', storageLocation: 'A区-01', stockStatus: 'instock', stockQty: 5, inTransitQty: 2, availableQty: 3, shippedQty: 2, updateTime: '2026-06-25 09:30:00' },
{ id: 'stk-002', modelCode: 'SCREEN-P1', modelName: '大屏P1', deviceType: 'screen', typeName: '大屏', batchNo: 'B2026-002', supplierName: '深圳智能设备供应商', purchasePrice: 12000, inboundTime: '2026-06-22 10:00:00', storageLocation: 'A区-02', stockStatus: 'instock', stockQty: 3, inTransitQty: 0, availableQty: 3, shippedQty: 1, updateTime: '2026-06-25 10:00:00' },
{ id: 'stk-003', modelCode: 'WATCH-W1-PEN', modelName: '鹏升手表W1', deviceType: 'watch', typeName: '手表', batchNo: 'B2026-003', supplierName: '鹏升电子', purchasePrice: 980, inboundTime: '2026-06-22 11:15:00', storageLocation: 'B区-01', stockStatus: 'instock', stockQty: 12, inTransitQty: 5, availableQty: 7, shippedQty: 6, updateTime: '2026-06-25 11:15:00' },
{ id: 'stk-004', modelCode: 'SCALE-X1', modelName: '入库秤X1', deviceType: 'scale-inbound', typeName: '入库秤', batchNo: 'B2026-004', supplierName: '深圳智能设备供应商', purchasePrice: 1800, inboundTime: '2026-06-23 13:45:00', storageLocation: 'B区-02', stockStatus: 'instock', stockQty: 2, inTransitQty: 0, availableQty: 2, shippedQty: 1, updateTime: '2026-06-25 13:45:00' },
{ id: 'stk-005', modelCode: 'SCALE-Y2', modelName: '配比秤Y2', deviceType: 'scale-ratio', typeName: '配比秤', batchNo: 'B2026-005', supplierName: '深圳智能设备供应商', purchasePrice: 2200, inboundTime: '2026-06-23 14:30:00', storageLocation: 'B区-03', stockStatus: 'inbound', stockQty: 1, inTransitQty: 1, availableQty: 1, shippedQty: 0, updateTime: '2026-06-25 14:30:00' },
{ id: 'stk-006', modelCode: 'FIT-S1-DUNMI', modelName: '顿米健身车S1', deviceType: 'fitness', typeName: '健身器材', batchNo: 'B2026-006', supplierName: '顿米科技', purchasePrice: 5600, inboundTime: '2026-06-23 15:00:00', storageLocation: 'C区-01', stockStatus: 'instock', stockQty: 4, inTransitQty: 0, availableQty: 4, shippedQty: 2, updateTime: '2026-06-25 15:00:00' },
{ id: 'stk-007', modelCode: 'CHK-M1-HUASHENG', modelName: '华盛体检机M1', deviceType: 'checkup', typeName: '体检机', batchNo: 'B2026-007', supplierName: '华盛医疗', purchasePrice: 32000, inboundTime: '2026-06-24 16:20:00', storageLocation: 'C区-02', stockStatus: 'inbound', stockQty: 0, inTransitQty: 1, availableQty: 0, shippedQty: 0, updateTime: '2026-06-25 16:20:00' },
{ id: 'stk-008', modelCode: 'FAT-INBODY', modelName: 'InBody体脂秤', deviceType: 'scale-fat', typeName: '体脂秤', batchNo: 'B2026-008', supplierName: 'InBody中国', purchasePrice: 6800, inboundTime: '2026-06-24 17:00:00', storageLocation: 'B区-04', stockStatus: 'shipped', stockQty: 0, inTransitQty: 0, availableQty: 0, shippedQty: 2, updateTime: '2026-06-25 17:00:00' },
]
const listPage = (ctx: MockContext): ApiResponse<StockRecord[]> => {
const params = getParams(ctx.params)
const filtered = stockList.filter((item) => {
if (params.deviceType && item.deviceType !== params.deviceType) return false
if (params.stockStatus && item.stockStatus !== params.stockStatus) return false
if (params.keyword && !includes(item.modelCode, params.keyword) && !includes(item.modelName, params.keyword) && !includes(item.batchNo, params.keyword)) return false
return true
})
if (params.column && params.order) {
filtered.sort((a, b) => {
const av = (a as unknown as Record<string, number | string>)[params.column as string]
const bv = (b as unknown as Record<string, number | string>)[params.column as string]
if (typeof av === 'number' && typeof bv === 'number') {
return params.order === 'ascend' ? av - bv : bv - av
}
const cmp = String(av ?? '').localeCompare(String(bv ?? ''))
return params.order === 'ascend' ? cmp : -cmp
})
}
return paginate(filtered, params)
}
const infoById = (params: PageParams): ApiResponse<StockRecord | null> => {
const id = String(params.id ?? '')
const record = stockList.find((item) => item.id === id)
return ok('查询成功', record ?? null)
}
/** 回库登记:状态变更 + 数量调整 */
const returnRecord = (params: PageParams): ApiResponse<null> => {
const id = String(params.id ?? '')
const record = stockList.find((item) => item.id === id)
if (!record) return ok('库存记录不存在')
const returnQty = Number(params.returnQty ?? 0)
record.stockQty += returnQty
record.availableQty += returnQty
record.shippedQty = Math.max(0, record.shippedQty - returnQty)
record.stockStatus = 'returned'
if (params.storageLocation) record.storageLocation = String(params.storageLocation)
record.updateTime = nowStr()
return ok('回库登记成功')
}
registerMocks([
{ method: 'POST', url: '/admin/device/stock/instock/page', handler: listPage },
{ method: 'POST', url: '/admin/device/stock/instock/info', handler: (ctx) => infoById(getParams(ctx.params)) },
{ method: 'POST', url: '/admin/device/stock/instock/return', handler: (ctx) => returnRecord(getParams(ctx.params)) },
{ method: 'POST', url: '/admin/device/stock/instock/export', handler: () => ok('导出任务已提交') },
])
@@ -0,0 +1,342 @@
import { registerMocks } from '@axios/mockBus'
import type { ApiResponse, MockContext } from '@axios'
type CategoryTab = 'catering' | 'body' | 'wearable' | 'fitness' | 'monitor'
type RunStatus = 'online' | 'offline' | 'repairing' | 'scrapped'
type DeployStatus = 'in_stock' | 'deployed'
interface DeviceItem {
id: string
deviceCode: string
name: string
modelName: string
category: CategoryTab
tenantName: string
siteName: string
deployStatus: DeployStatus
runStatus: RunStatus
softwareVersion: string
onlineTime: string
deployTime: string
lastHeartbeat: string
isIot: boolean
[key: string]: string | boolean | undefined
}
interface RunLogItem {
id: string
eventType: string
content: string
occurTime: string
}
interface RepairItem {
id: string
workOrderNo: string
faultType: string
repairMethod: string
repairer: string
workHours: number
finishTime: string
}
interface DetailInfo extends DeviceItem {
runLogs?: RunLogItem[]
repairs?: RepairItem[]
}
interface ListParams {
keyword?: string
runStatus?: RunStatus
deployStatus?: DeployStatus
tenantName?: string
siteName?: string
category: CategoryTab
}
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({
code: '00000',
msg,
data,
})
/** 依据租户 / 场所自动判定投用状态 */
const computeDeployStatus = (tenantName: string, siteName: string): DeployStatus => {
if (!tenantName && !siteName) return 'in_stock'
return 'deployed'
}
/** 按 Tab 维度的 mock 数据集(每类 ≤ 8 条) */
const dataset: Record<CategoryTab, DeviceItem[]> = {
catering: [
{
id: 'dev-c-1', deviceCode: 'CATERING-PM-0001', name: '一号吐盘机', modelName: '吐盘机', category: 'catering',
tenantName: '示范餐饮集团', siteName: '总部食堂', runStatus: 'online', softwareVersion: 'V1.2.0',
deployStatus: 'deployed', onlineTime: '2026-05-12 10:00:00', deployTime: '2026-05-20 09:00:00',
lastHeartbeat: '2026-07-02 09:30:00', isIot: true,
trackNo: 'T-01', placement: '落地', boundScreen: '大屏-A1', stall: '主食档', clientIp: '192.168.10.11',
},
{
id: 'dev-c-2', deviceCode: 'CATERING-CB-0002', name: '智能货柜-B02', modelName: '智能货柜', category: 'catering',
tenantName: '示范餐饮集团', siteName: '东区食堂', runStatus: 'online', softwareVersion: 'V2.0.1',
deployStatus: 'deployed', onlineTime: '2026-05-13 11:00:00', deployTime: '2026-05-22 10:00:00',
lastHeartbeat: '2026-07-02 09:31:00', isIot: true,
trackNo: 'T-02', placement: '嵌入式', boundScreen: '—', stall: '副食档', clientIp: '192.168.10.12',
},
{
id: 'dev-c-3', deviceCode: 'CATERING-NS-0003', name: '营养秤-03', modelName: '营养秤', category: 'catering',
tenantName: '', siteName: '', runStatus: 'offline', softwareVersion: 'V1.0.5',
deployStatus: 'in_stock', onlineTime: '2026-05-14 09:30:00', deployTime: '',
lastHeartbeat: '2026-07-01 18:20:00', isIot: true,
trackNo: '—', placement: '台面', boundScreen: '—', stall: '—', clientIp: '—',
},
{
id: 'dev-c-4', deviceCode: 'CATERING-CO-0004', name: '结算台-04', modelName: '结算台', category: 'catering',
tenantName: '健康机构A', siteName: '体检站1', runStatus: 'repairing', softwareVersion: 'V1.3.0',
deployStatus: 'deployed', onlineTime: '2026-05-15 14:00:00', deployTime: '2026-05-25 14:00:00',
lastHeartbeat: '2026-06-30 12:00:00', isIot: true,
trackNo: '—', placement: '落地', boundScreen: '结算屏-04', stall: '出口档', clientIp: '192.168.10.14',
},
],
body: [
{
id: 'dev-b-1', deviceCode: 'BODY-AIO-0001', name: '体检一体机-01', modelName: '一体机', category: 'body',
tenantName: '健康机构A', siteName: '体检站1', runStatus: 'online', softwareVersion: 'V3.1.0',
deployStatus: 'deployed', onlineTime: '2026-05-10 09:00:00', deployTime: '2026-05-18 09:00:00',
lastHeartbeat: '2026-07-02 09:32:00', isIot: true,
measureIndicators: '体重 / 体脂 / BMI', range: '0-200kg', accuracy: '±0.1kg', unit: 'kg', measureMethod: '站姿',
},
{
id: 'dev-b-2', deviceCode: 'BODY-BT-0002', name: '蓝牙秤-02', modelName: '蓝牙秤', category: 'body',
tenantName: '健康机构A', siteName: '体检站2', runStatus: 'online', softwareVersion: 'V2.4.0',
deployStatus: 'deployed', onlineTime: '2026-05-11 10:00:00', deployTime: '2026-05-19 10:00:00',
lastHeartbeat: '2026-07-02 09:30:00', isIot: true,
measureIndicators: '体重', range: '0-150kg', accuracy: '±0.05kg', unit: 'kg', measureMethod: '站姿',
},
{
id: 'dev-b-3', deviceCode: 'BODY-FAT-0003', name: '体脂仪-03', modelName: '体脂仪', category: 'body',
tenantName: '', siteName: '', runStatus: 'offline', softwareVersion: 'V1.8.0',
deployStatus: 'in_stock', onlineTime: '2026-05-12 11:00:00', deployTime: '',
lastHeartbeat: '2026-07-01 16:00:00', isIot: true,
measureIndicators: '体重 / 体脂 / 水分', range: '0-200kg', accuracy: '±0.1kg', unit: 'kg', measureMethod: '站姿',
},
],
wearable: [
{
id: 'dev-w-1', deviceCode: 'WEAR-HW5-0001', name: '华为手表5-老人甲', modelName: '华为-watch5', category: 'wearable',
tenantName: '健康机构A', siteName: '体检站1', runStatus: 'online', softwareVersion: 'V5.2.0',
deployStatus: 'deployed', onlineTime: '2026-05-09 09:00:00', deployTime: '2026-05-15 09:00:00',
lastHeartbeat: '2026-07-02 09:33:00', isIot: true,
bandNo: 'BN-0001', simCard: '8986011224567890', boundUser: '张三 (UID-001)', battery: '85%', protocol: '4G',
},
{
id: 'dev-w-2', deviceCode: 'WEAR-HW4-0002', name: '华为手表4-老人乙', modelName: '华为-watch4', category: 'wearable',
tenantName: '健康机构A', siteName: '体检站2', runStatus: 'online', softwareVersion: 'V4.1.0',
deployStatus: 'deployed', onlineTime: '2026-05-10 09:00:00', deployTime: '2026-05-16 09:00:00',
lastHeartbeat: '2026-07-02 09:30:00', isIot: true,
bandNo: 'BN-0002', simCard: '8986011224567891', boundUser: '李四 (UID-002)', battery: '62%', protocol: '4G',
},
{
id: 'dev-w-3', deviceCode: 'WEAR-LJF-0003', name: '罗杰夫-丙', modelName: '罗杰夫-LJF01', category: 'wearable',
tenantName: '示范餐饮集团', siteName: '总部食堂', runStatus: 'repairing', softwareVersion: 'V1.0.3',
deployStatus: 'deployed', onlineTime: '2026-05-11 09:00:00', deployTime: '2026-05-17 09:00:00',
lastHeartbeat: '2026-06-28 10:00:00', isIot: true,
bandNo: 'BN-0003', simCard: '8986011224567892', boundUser: '王五 (UID-003)', battery: '20%', protocol: 'BLE',
},
],
fitness: [
{
id: 'dev-f-1', deviceCode: 'FIT-TM-0001', name: '跑步机-01', modelName: '跑步机', category: 'fitness',
tenantName: '健身中心B', siteName: '健身房1', runStatus: 'online', softwareVersion: 'V2.0.0',
deployStatus: 'deployed', onlineTime: '2026-05-08 09:00:00', deployTime: '2026-05-14 09:00:00',
lastHeartbeat: '2026-07-02 09:30:00', isIot: true,
equipmentType: '有氧', resistanceMode: '电磁', trainDataType: '时长 / 卡路里 / 距离', location: '一层东区',
},
{
id: 'dev-f-2', deviceCode: 'FIT-EL-0002', name: '椭圆机-02', modelName: '椭圆机', category: 'fitness',
tenantName: '健身中心B', siteName: '健身房1', runStatus: 'online', softwareVersion: 'V1.5.0',
deployStatus: 'deployed', onlineTime: '2026-05-09 09:00:00', deployTime: '2026-05-15 09:00:00',
lastHeartbeat: '2026-07-02 09:31:00', isIot: true,
equipmentType: '有氧', resistanceMode: '电磁', trainDataType: '时长 / 卡路里', location: '一层东区',
},
{
id: 'dev-f-3', deviceCode: 'FIT-BK-0003', name: '健身车-03', modelName: '健身车', category: 'fitness',
tenantName: '', siteName: '', runStatus: 'offline', softwareVersion: 'V1.2.0',
deployStatus: 'in_stock', onlineTime: '2026-05-10 09:00:00', deployTime: '',
lastHeartbeat: '2026-07-01 17:00:00', isIot: true,
equipmentType: '有氧', resistanceMode: '液压', trainDataType: '时长 / 卡路里', location: '—',
},
],
monitor: [
{
id: 'dev-m-1', deviceCode: 'MON-SG-0001', name: '血糖仪-01', modelName: '血糖监测仪', category: 'monitor',
tenantName: '健康机构A', siteName: '体检站1', runStatus: 'online', softwareVersion: 'V1.4.0',
deployStatus: 'deployed', onlineTime: '2026-05-07 09:00:00', deployTime: '2026-05-13 09:00:00',
lastHeartbeat: '2026-07-02 09:32:00', isIot: true,
monitorIndicators: '血糖', sampleFrequency: '按需', measureMethod: '采血', reportMode: '云端',
},
{
id: 'dev-m-2', deviceCode: 'MON-BP-0002', name: '高血压治疗仪-02', modelName: '高血压治疗仪', category: 'monitor',
tenantName: '健康机构A', siteName: '体检站2', runStatus: 'online', softwareVersion: 'V2.1.0',
deployStatus: 'deployed', onlineTime: '2026-05-08 09:00:00', deployTime: '2026-05-14 09:00:00',
lastHeartbeat: '2026-07-02 09:30:00', isIot: true,
monitorIndicators: '血压 / 心率', sampleFrequency: '每5分钟', measureMethod: '袖带', reportMode: '本地',
},
{
id: 'dev-m-3', deviceCode: 'MON-AIO-0003', name: '健康一体机-03', modelName: '智能健康一体机', category: 'monitor',
tenantName: '', siteName: '', runStatus: 'scrapped', softwareVersion: 'V3.0.0',
deployStatus: 'in_stock', onlineTime: '2026-05-09 09:00:00', deployTime: '',
lastHeartbeat: '2026-06-15 11:00:00', isIot: true,
monitorIndicators: '血压 / 血糖 / 血氧 / 心电', sampleFrequency: '按需', measureMethod: '光电', reportMode: '云端',
},
],
}
const handleList = (ctx: MockContext): ApiResponse<DeviceItem[]> => {
const params = (ctx.params ?? {}) as ListParams
let list = dataset[params.category] ?? []
if (params.keyword) {
const kw = params.keyword.toLowerCase()
list = list.filter(
(item) =>
item.deviceCode.toLowerCase().includes(kw) ||
item.name.toLowerCase().includes(kw),
)
}
if (params.runStatus) {
list = list.filter((item) => item.runStatus === params.runStatus)
}
if (params.deployStatus) {
list = list.filter((item) => item.deployStatus === params.deployStatus)
}
if (params.tenantName) {
list = list.filter((item) => (item.tenantName ?? '').includes(params.tenantName as string))
}
if (params.siteName) {
list = list.filter((item) => (item.siteName ?? '').includes(params.siteName as string))
}
return ok('查询成功', list)
}
const handleAdd = (ctx: MockContext): ApiResponse<null> => {
const payload = (ctx.params ?? {}) as DeviceItem
if (!payload.name) return ok('设备名称不能为空')
const category = payload.category
const now = new Date().toISOString().replace('T', ' ').slice(0, 19)
const deployStatus = computeDeployStatus(payload.tenantName ?? '', payload.siteName ?? '')
const newItem: DeviceItem = {
...payload,
id: `dev-${category.slice(0, 1)}-${Date.now().toString(36)}`,
deviceCode: payload.deviceCode || `DEV-${Date.now()}`,
deployStatus,
onlineTime: payload.onlineTime || now,
deployTime: deployStatus === 'deployed' ? (payload.deployTime || now) : '',
lastHeartbeat: now,
isIot: true,
}
dataset[category].unshift(newItem)
return ok('新增成功')
}
const handleUpdate = (ctx: MockContext): ApiResponse<null> => {
const payload = (ctx.params ?? {}) as DeviceItem
if (!payload.id) return ok('ID 不能为空')
const category = payload.category
const idx = dataset[category].findIndex((item) => item.id === payload.id)
if (idx < 0) return ok('设备不存在')
/** 编辑时按租户 / 场所重新计算投用状态 */
const deployStatus = computeDeployStatus(payload.tenantName ?? '', payload.siteName ?? '')
dataset[category][idx] = {
...dataset[category][idx],
...payload,
deployStatus,
deployTime: deployStatus === 'deployed' ? (payload.deployTime || dataset[category][idx].deployTime) : '',
}
return ok('更新成功')
}
const handleDelete = (ctx: MockContext): ApiResponse<null> => {
const params = (ctx.params ?? {}) as { id?: string }
if (!params.id) return ok('ID 不能为空')
;(Object.keys(dataset) as CategoryTab[]).forEach((cat) => {
const idx = dataset[cat].findIndex((item) => item.id === params.id)
if (idx >= 0) dataset[cat].splice(idx, 1)
})
return ok('删除成功')
}
/** 递归查找设备(跨 Tab) */
const findDevice = (id: string): DeviceItem | null => {
for (const cat of Object.keys(dataset) as CategoryTab[]) {
const found = dataset[cat].find((item) => item.id === id)
if (found) return found
}
return null
}
/** 生成运行日志(基于设备状态模拟) */
const buildRunLogs = (device: DeviceItem): RunLogItem[] => {
const logs: RunLogItem[] = [
{ id: 'log-1', eventType: '心跳', content: '设备心跳上报正常', occurTime: device.lastHeartbeat },
{ id: 'log-2', eventType: '心跳', content: '设备心跳上报正常', occurTime: '2026-07-02 08:30:00' },
{ id: 'log-3', eventType: '关键事件', content: '完成一次测量任务', occurTime: '2026-07-02 08:00:00' },
{ id: 'log-4', eventType: '开机', content: '设备开机上线', occurTime: device.onlineTime },
]
if (device.runStatus === 'offline') {
logs.unshift({ id: 'log-0', eventType: '离线', content: '设备连续 3 次心跳未上报,标记为离线', occurTime: device.lastHeartbeat })
}
if (device.runStatus === 'repairing') {
logs.unshift({ id: 'log-r', eventType: '异常', content: '硬件自检异常,已自动派单维修', occurTime: device.lastHeartbeat })
}
return logs.slice(0, 8)
}
/** 生成维修履历(按需) */
const buildRepairs = (device: DeviceItem): RepairItem[] => {
if (device.runStatus !== 'repairing' && device.runStatus !== 'scrapped') return []
return [
{
id: 'rep-1',
workOrderNo: 'WO-2026-0601-001',
faultType: '传感器异常',
repairMethod: '更换传感器模组',
repairer: '李师傅',
workHours: 2.5,
finishTime: '2026-06-30 18:00:00',
},
{
id: 'rep-2',
workOrderNo: 'WO-2026-0512-008',
faultType: '通讯模块故障',
repairMethod: '更换 4G 模块',
repairer: '王师傅',
workHours: 1.5,
finishTime: '2026-05-12 16:30:00',
},
]
}
const handleInfo = (ctx: MockContext): ApiResponse<DetailInfo> => {
const params = (ctx.params ?? {}) as { id?: string }
if (!params.id) return ok('ID 不能为空')
const device = findDevice(params.id)
if (!device) return ok('设备不存在')
const detail: DetailInfo = {
...device,
runLogs: buildRunLogs(device),
repairs: buildRepairs(device),
}
return ok('查询成功', detail)
}
/** 异步导出:提交后立即返回任务已接收 */
const handleExport = (): ApiResponse<null> => ok('导出任务已提交,可在"查看导出任务"中查看进度')
registerMocks([
{ method: 'POST', url: '/admin/device/manage/list', handler: handleList },
{ method: 'POST', url: '/admin/device/manage/info', handler: handleInfo },
{ method: 'POST', url: '/admin/device/manage/add', handler: handleAdd },
{ method: 'POST', url: '/admin/device/manage/update', handler: handleUpdate },
{ method: 'POST', url: '/admin/device/manage/delete', handler: handleDelete },
{ method: 'POST', url: '/admin/device/manage/export', handler: handleExport },
])
@@ -0,0 +1,155 @@
import { registerMocks } from '@axios/mockBus'
import type { ApiResponse, MockContext, RequestParameter } from '@axios'
interface PageParams {
pageNum?: number
pageSize?: number
column?: string
order?: 'ascend' | 'descend' | null
keyword?: string
status?: string
outboundType?: string
[key: string]: unknown
}
interface OutboundRecord {
id: string
outboundCode: string
deviceCode: string
deviceName: string
modelName: string
quantity: number
recipient: string
targetSite: string
targetCustomer: string
outboundType: 'install' | 'transfer' | 'scrap'
operator: string
outboundTime: string
signedTime?: string
status: 'pending' | 'completed' | 'signed' | 'cancelled'
remark?: string
}
const getParams = (params: RequestParameter): PageParams => (params ?? {}) as PageParams
const includes = (value: unknown, keyword: unknown): boolean => {
if (!keyword) return true
return String(value ?? '').toLowerCase().includes(String(keyword).toLowerCase())
}
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({ code: '00000', msg, data })
const paginate = <T extends OutboundRecord>(list: T[], params: PageParams): ApiResponse<T[]> => {
const pageNum = Number(params.pageNum ?? 1)
const pageSize = Number(params.pageSize ?? 30)
const start = (pageNum - 1) * pageSize
return { code: '00000', msg: '查询成功', data: list.slice(start, start + pageSize), total: list.length }
}
const nowStr = (): string => {
const d = new Date()
const pad = (n: number): string => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
const outboundList: OutboundRecord[] = [
{ id: 'oub-001', outboundCode: 'OUB-20260603-001', deviceCode: 'CAB-A1-0003', deviceName: '智能货柜A1', modelName: '智能货柜', quantity: 2, recipient: '王师傅', targetSite: '总部食堂', targetCustomer: '智养餐饮集团', outboundType: 'install', operator: '张三', outboundTime: '2026-06-03 10:00:00', signedTime: '2026-06-03 14:30:00', status: 'signed' },
{ id: 'oub-002', outboundCode: 'OUB-20260608-002', deviceCode: 'WAT-W1-0002', deviceName: '鹏升手表W1', modelName: '华为-watch5', quantity: 5, recipient: '李主任', targetSite: '健康站01', targetCustomer: '健康管理机构', outboundType: 'install', operator: '李四', outboundTime: '2026-06-08 15:30:00', signedTime: '2026-06-09 09:00:00', status: 'signed' },
{ id: 'oub-003', outboundCode: 'OUB-20260612-003', deviceCode: 'CHK-M1-0002', deviceName: '华盛体检机', modelName: '智能健康一体机', quantity: 1, recipient: '赵工', targetSite: '东区体检站', targetCustomer: '健康管理机构', outboundType: 'install', operator: '王五', outboundTime: '2026-06-12 09:15:00', status: 'completed' },
{ id: 'oub-004', outboundCode: 'OUB-20260616-004', deviceCode: 'SCL-X1-0002', deviceName: '入库秤X1', modelName: '入库秤', quantity: 1, recipient: '孙师傅', targetSite: '总部食堂', targetCustomer: '智养餐饮集团', outboundType: 'install', operator: '赵六', outboundTime: '2026-06-16 14:00:00', signedTime: '2026-06-16 18:00:00', status: 'signed' },
{ id: 'oub-005', outboundCode: 'OUB-20260620-005', deviceCode: 'FIT-S1-0002', deviceName: '顿米健身车', modelName: '健身车', quantity: 2, recipient: '周教练', targetSite: '总部健身房', targetCustomer: '智养餐饮集团', outboundType: 'transfer', operator: '孙七', outboundTime: '2026-06-20 11:45:00', status: 'cancelled', remark: '客户取消订单' },
{ id: 'oub-006', outboundCode: 'OUB-20260623-006', deviceCode: 'SCR-P1-0002', deviceName: '大屏P1', modelName: '带鱼屏', quantity: 1, recipient: '陈师傅', targetSite: '总部食堂', targetCustomer: '智养餐饮集团', outboundType: 'install', operator: '张三', outboundTime: '2026-06-23 16:20:00', status: 'pending' },
{ id: 'oub-007', outboundCode: 'OUB-20260626-007', deviceCode: 'SCL-W-0002', deviceName: '体重秤', modelName: '蓝牙秤', quantity: 3, recipient: '吴主任', targetSite: '健康站01', targetCustomer: '健康管理机构', outboundType: 'install', operator: '李四', outboundTime: '2026-06-26 10:30:00', signedTime: '2026-06-27 09:00:00', status: 'signed' },
{ id: 'oub-008', outboundCode: 'OUB-20260628-008', deviceCode: 'FAT-INBODY-0002', deviceName: 'InBody体脂秤', modelName: '体脂仪', quantity: 1, recipient: '郑医生', targetSite: '东区体检站', targetCustomer: '健康管理机构', outboundType: 'install', operator: '王五', outboundTime: '2026-06-28 13:10:00', status: 'completed' },
]
const listPage = (ctx: MockContext): ApiResponse<OutboundRecord[]> => {
const params = getParams(ctx.params)
const filtered = outboundList.filter((item) => {
if (params.status && item.status !== params.status) return false
if (params.outboundType && item.outboundType !== params.outboundType) return false
if (params.keyword && !includes(item.outboundCode, params.keyword) && !includes(item.deviceCode, params.keyword) && !includes(item.deviceName, params.keyword)) return false
return true
})
if (params.column === 'outboundTime' && params.order) {
filtered.sort((a, b) => {
const cmp = a.outboundTime.localeCompare(b.outboundTime)
return params.order === 'ascend' ? cmp : -cmp
})
}
return paginate(filtered, params)
}
const deleteById = (params: PageParams): ApiResponse<null> => {
const id = String(params.id ?? '')
const idx = outboundList.findIndex((item) => item.id === id)
if (idx >= 0) outboundList.splice(idx, 1)
return ok('删除成功')
}
const infoById = (params: PageParams): ApiResponse<OutboundRecord | null> => {
const id = String(params.id ?? '')
const record = outboundList.find((item) => item.id === id)
return ok('查询成功', record ?? null)
}
const genOutboundCode = (): string => {
const d = new Date()
const ymd = `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`
const seq = String(outboundList.length + 1).padStart(3, '0')
return `OUB-${ymd}-${seq}`
}
const addRecord = (params: PageParams): ApiResponse<null> => {
const record: OutboundRecord = {
id: `oub-${Date.now().toString(36)}`,
outboundCode: String(params.outboundCode ?? genOutboundCode()),
deviceCode: String(params.deviceCode ?? ''),
deviceName: String(params.deviceName ?? ''),
modelName: String(params.modelName ?? ''),
quantity: Number(params.quantity ?? 1),
recipient: String(params.recipient ?? ''),
targetSite: String(params.targetSite ?? ''),
targetCustomer: String(params.targetCustomer ?? ''),
outboundType: (params.outboundType as OutboundRecord['outboundType']) ?? 'install',
operator: '当前用户',
outboundTime: nowStr(),
status: 'completed',
remark: params.remark ? String(params.remark) : undefined,
}
outboundList.unshift(record)
return ok('新增成功')
}
const updateRecord = (params: PageParams): ApiResponse<null> => {
const id = String(params.id ?? '')
const record = outboundList.find((item) => item.id === id)
if (!record) return ok('出库单不存在')
if (params.deviceCode !== undefined) record.deviceCode = String(params.deviceCode)
if (params.deviceName !== undefined) record.deviceName = String(params.deviceName)
if (params.modelName !== undefined) record.modelName = String(params.modelName)
if (params.quantity !== undefined) record.quantity = Number(params.quantity)
if (params.recipient !== undefined) record.recipient = String(params.recipient)
if (params.targetSite !== undefined) record.targetSite = String(params.targetSite)
if (params.targetCustomer !== undefined) record.targetCustomer = String(params.targetCustomer)
if (params.outboundType !== undefined) record.outboundType = params.outboundType as OutboundRecord['outboundType']
if (params.remark !== undefined) record.remark = params.remark ? String(params.remark) : undefined
return ok('更新成功')
}
/** 撤回(已出库 / 待出库 状态可撤回) */
const withdrawRecord = (params: PageParams): ApiResponse<null> => {
const id = String(params.id ?? '')
const record = outboundList.find((item) => item.id === id)
if (!record) return ok('出库单不存在')
if (record.status === 'signed') return ok('已签收的出库单不可撤回')
record.status = 'cancelled'
return ok('撤回成功')
}
registerMocks([
{ method: 'POST', url: '/admin/device/stock/outbound/page', handler: listPage },
{ method: 'POST', url: '/admin/device/stock/outbound/info', handler: (ctx) => infoById(getParams(ctx.params)) },
{ method: 'POST', url: '/admin/device/stock/outbound/add', handler: (ctx) => addRecord(getParams(ctx.params)) },
{ method: 'POST', url: '/admin/device/stock/outbound/update', handler: (ctx) => updateRecord(getParams(ctx.params)) },
{ method: 'POST', url: '/admin/device/stock/outbound/withdraw', handler: (ctx) => withdrawRecord(getParams(ctx.params)) },
{ method: 'POST', url: '/admin/device/stock/outbound/delete', handler: (ctx) => deleteById(getParams(ctx.params)) },
{ method: 'POST', url: '/admin/device/stock/outbound/export', handler: () => ok('导出任务已提交') },
])
@@ -0,0 +1,246 @@
import { registerMocks } from '@axios/mockBus'
import type { ApiResponse, MockContext } from '@axios'
type CategoryTab = 'catering' | 'body' | 'wearable' | 'fitness' | 'monitor'
type RunStatus = 'online' | 'offline' | 'repairing' | 'scrapped'
type DeployStatus = 'in_stock' | 'deployed'
interface DeviceStatItem {
id: string
categoryLabel: string
category: CategoryTab
name: string
deviceCode: string
isIot: boolean
deployStatus: DeployStatus
dataDispatch: boolean
dataReport: boolean
tenantName: string
siteName: string
softwareVersion: string
runStatus: RunStatus
onlineTime: string
deployTime: string
}
interface SearchForm {
keyword?: string
category?: CategoryTab
deployStatus?: DeployStatus
runStatus?: RunStatus
isIot?: boolean
dataDispatch?: boolean
dataReport?: boolean
tenantName?: string
siteName?: string
softwareVersion?: string
onlineStart?: string
onlineEnd?: string
deployStart?: string
deployEnd?: string
}
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({
code: '00000',
msg,
data,
})
/** 顶级分类中文标签 */
const CATEGORY_LABEL: Record<string, string> = {
catering: '智养餐饮设备',
body: '体重测量设备',
wearable: '智能穿戴设备',
fitness: '健身器材设备',
monitor: '健康监测设备',
}
/** 复用 manage 数据集,扁平化为统计列表行(不直接 import manage.ts,避免模块耦合,原型阶段保持独立副本) */
const dataset: DeviceStatItem[] = [
// 智养餐饮设备
{
id: 'dev-c-1', category: 'catering', categoryLabel: CATEGORY_LABEL.catering,
name: '一号吐盘机', deviceCode: 'CATERING-PM-0001', isIot: true,
deployStatus: 'deployed', dataDispatch: true, dataReport: true,
tenantName: '示范餐饮集团', siteName: '总部食堂', softwareVersion: 'V1.2.0',
runStatus: 'online', onlineTime: '2026-05-12 10:00:00', deployTime: '2026-05-20 09:00:00',
},
{
id: 'dev-c-2', category: 'catering', categoryLabel: CATEGORY_LABEL.catering,
name: '智能货柜-B02', deviceCode: 'CATERING-CB-0002', isIot: true,
deployStatus: 'deployed', dataDispatch: true, dataReport: true,
tenantName: '示范餐饮集团', siteName: '东区食堂', softwareVersion: 'V2.0.1',
runStatus: 'online', onlineTime: '2026-05-13 11:00:00', deployTime: '2026-05-22 10:00:00',
},
{
id: 'dev-c-3', category: 'catering', categoryLabel: CATEGORY_LABEL.catering,
name: '营养秤-03', deviceCode: 'CATERING-NS-0003', isIot: true,
deployStatus: 'in_stock', dataDispatch: false, dataReport: false,
tenantName: '', siteName: '', softwareVersion: 'V1.0.5',
runStatus: 'offline', onlineTime: '2026-05-14 09:30:00', deployTime: '',
},
{
id: 'dev-c-4', category: 'catering', categoryLabel: CATEGORY_LABEL.catering,
name: '结算台-04', deviceCode: 'CATERING-CO-0004', isIot: true,
deployStatus: 'deployed', dataDispatch: true, dataReport: false,
tenantName: '健康机构A', siteName: '体检站1', softwareVersion: 'V1.3.0',
runStatus: 'repairing', onlineTime: '2026-05-15 14:00:00', deployTime: '2026-05-25 14:00:00',
},
// 体重测量设备
{
id: 'dev-b-1', category: 'body', categoryLabel: CATEGORY_LABEL.body,
name: '体检一体机-01', deviceCode: 'BODY-AIO-0001', isIot: true,
deployStatus: 'deployed', dataDispatch: true, dataReport: true,
tenantName: '健康机构A', siteName: '体检站1', softwareVersion: 'V3.1.0',
runStatus: 'online', onlineTime: '2026-05-10 09:00:00', deployTime: '2026-05-18 09:00:00',
},
{
id: 'dev-b-2', category: 'body', categoryLabel: CATEGORY_LABEL.body,
name: '蓝牙秤-02', deviceCode: 'BODY-BT-0002', isIot: true,
deployStatus: 'deployed', dataDispatch: true, dataReport: true,
tenantName: '健康机构A', siteName: '体检站2', softwareVersion: 'V2.4.0',
runStatus: 'online', onlineTime: '2026-05-11 10:00:00', deployTime: '2026-05-19 10:00:00',
},
{
id: 'dev-b-3', category: 'body', categoryLabel: CATEGORY_LABEL.body,
name: '体脂仪-03', deviceCode: 'BODY-FAT-0003', isIot: true,
deployStatus: 'in_stock', dataDispatch: false, dataReport: false,
tenantName: '', siteName: '', softwareVersion: 'V1.8.0',
runStatus: 'offline', onlineTime: '2026-05-12 11:00:00', deployTime: '',
},
// 智能穿戴设备
{
id: 'dev-w-1', category: 'wearable', categoryLabel: CATEGORY_LABEL.wearable,
name: '华为手表5-老人甲', deviceCode: 'WEAR-HW5-0001', isIot: true,
deployStatus: 'deployed', dataDispatch: true, dataReport: true,
tenantName: '健康机构A', siteName: '体检站1', softwareVersion: 'V5.2.0',
runStatus: 'online', onlineTime: '2026-05-09 09:00:00', deployTime: '2026-05-15 09:00:00',
},
{
id: 'dev-w-2', category: 'wearable', categoryLabel: CATEGORY_LABEL.wearable,
name: '华为手表4-老人乙', deviceCode: 'WEAR-HW4-0002', isIot: true,
deployStatus: 'deployed', dataDispatch: true, dataReport: true,
tenantName: '健康机构A', siteName: '体检站2', softwareVersion: 'V4.1.0',
runStatus: 'online', onlineTime: '2026-05-10 09:00:00', deployTime: '2026-05-16 09:00:00',
},
{
id: 'dev-w-3', category: 'wearable', categoryLabel: CATEGORY_LABEL.wearable,
name: '罗杰夫-丙', deviceCode: 'WEAR-LJF-0003', isIot: true,
deployStatus: 'deployed', dataDispatch: false, dataReport: true,
tenantName: '示范餐饮集团', siteName: '总部食堂', softwareVersion: 'V1.0.3',
runStatus: 'repairing', onlineTime: '2026-05-11 09:00:00', deployTime: '2026-05-17 09:00:00',
},
// 健身器材设备
{
id: 'dev-f-1', category: 'fitness', categoryLabel: CATEGORY_LABEL.fitness,
name: '跑步机-01', deviceCode: 'FIT-TM-0001', isIot: true,
deployStatus: 'deployed', dataDispatch: true, dataReport: true,
tenantName: '健身中心B', siteName: '健身房1', softwareVersion: 'V2.0.0',
runStatus: 'online', onlineTime: '2026-05-08 09:00:00', deployTime: '2026-05-14 09:00:00',
},
{
id: 'dev-f-2', category: 'fitness', categoryLabel: CATEGORY_LABEL.fitness,
name: '椭圆机-02', deviceCode: 'FIT-EL-0002', isIot: true,
deployStatus: 'deployed', dataDispatch: true, dataReport: false,
tenantName: '健身中心B', siteName: '健身房1', softwareVersion: 'V1.5.0',
runStatus: 'online', onlineTime: '2026-05-09 09:00:00', deployTime: '2026-05-15 09:00:00',
},
{
id: 'dev-f-3', category: 'fitness', categoryLabel: CATEGORY_LABEL.fitness,
name: '健身车-03', deviceCode: 'FIT-BK-0003', isIot: true,
deployStatus: 'in_stock', dataDispatch: false, dataReport: false,
tenantName: '', siteName: '', softwareVersion: 'V1.2.0',
runStatus: 'offline', onlineTime: '2026-05-10 09:00:00', deployTime: '',
},
// 健康监测设备
{
id: 'dev-m-1', category: 'monitor', categoryLabel: CATEGORY_LABEL.monitor,
name: '血糖仪-01', deviceCode: 'MON-SG-0001', isIot: true,
deployStatus: 'deployed', dataDispatch: true, dataReport: true,
tenantName: '健康机构A', siteName: '体检站1', softwareVersion: 'V1.4.0',
runStatus: 'online', onlineTime: '2026-05-07 09:00:00', deployTime: '2026-05-13 09:00:00',
},
{
id: 'dev-m-2', category: 'monitor', categoryLabel: CATEGORY_LABEL.monitor,
name: '高血压治疗仪-02', deviceCode: 'MON-BP-0002', isIot: true,
deployStatus: 'deployed', dataDispatch: true, dataReport: true,
tenantName: '健康机构A', siteName: '体检站2', softwareVersion: 'V2.1.0',
runStatus: 'online', onlineTime: '2026-05-08 09:00:00', deployTime: '2026-05-14 09:00:00',
},
{
id: 'dev-m-3', category: 'monitor', categoryLabel: CATEGORY_LABEL.monitor,
name: '健康一体机-03', deviceCode: 'MON-AIO-0003', isIot: true,
deployStatus: 'in_stock', dataDispatch: false, dataReport: false,
tenantName: '', siteName: '', softwareVersion: 'V3.0.0',
runStatus: 'scrapped', onlineTime: '2026-05-09 09:00:00', deployTime: '',
},
]
/** 字符串包含匹配(空值匹配所有) */
const includes = (src: string, kw: string): boolean => (src ?? '').toLowerCase().includes(kw.toLowerCase())
/** 日期范围匹配(YYYY-MM-DD 字符串比较) */
const inRange = (val: string, start?: string, end?: string): boolean => {
if (!start && !end) return true
if (!val) return false
if (start && val < start) return false
if (end && val > end) return false
return true
}
/** 多维条件过滤 */
const filterData = (params: SearchForm): DeviceStatItem[] => {
return dataset.filter((item) => {
if (params.keyword) {
const kw = params.keyword
if (!includes(item.deviceCode, kw) && !includes(item.name, kw)) return false
}
if (params.category && item.category !== params.category) return false
if (params.deployStatus && item.deployStatus !== params.deployStatus) return false
if (params.runStatus && item.runStatus !== params.runStatus) return false
if (params.isIot !== undefined && item.isIot !== params.isIot) return false
if (params.dataDispatch !== undefined && item.dataDispatch !== params.dataDispatch) return false
if (params.dataReport !== undefined && item.dataReport !== params.dataReport) return false
if (params.tenantName && !includes(item.tenantName, params.tenantName)) return false
if (params.siteName && !includes(item.siteName, params.siteName)) return false
if (params.softwareVersion && !includes(item.softwareVersion, params.softwareVersion)) return false
if (!inRange(item.onlineTime, params.onlineStart, params.onlineEnd)) return false
if (!inRange(item.deployTime, params.deployStart, params.deployEnd)) return false
return true
})
}
/** 列表查询 */
const handleList = (ctx: MockContext): ApiResponse<DeviceStatItem[]> => {
const params = (ctx.params ?? {}) as SearchForm
const rows = filterData(params)
return ok('查询成功', rows)
}
/** 顶部统计卡:基于过滤后的数据集动态计算 */
const handleOverview = (ctx: MockContext): ApiResponse<{ overview: { key: string; label: string; value: number; percent?: number; color: string }[] }> => {
const params = (ctx.params ?? {}) as SearchForm
const rows = filterData(params)
const total = rows.length
const deployed = rows.filter((r) => r.deployStatus === 'deployed').length
const inStock = rows.filter((r) => r.deployStatus === 'in_stock').length
const abnormal = rows.filter((r) => r.runStatus === 'repairing' || r.runStatus === 'scrapped').length
const pct = (n: number): number => (total === 0 ? 0 : Math.round((n / total) * 100))
return ok('查询成功', {
overview: [
{ key: 'total', label: '设备总数', value: total, color: 'blue' },
{ key: 'deployed', label: '投用数', value: deployed, percent: pct(deployed), color: 'green' },
{ key: 'inStock', label: '在库数', value: inStock, percent: pct(inStock), color: 'orange' },
{ key: 'abnormal', label: '异常数', value: abnormal, percent: pct(abnormal), color: 'red' },
],
})
}
/** 异步导出:提交后立即返回任务已接收 */
const handleExport = (): ApiResponse<null> => ok('导出任务已提交,可在"查看导出任务"中查看进度')
registerMocks([
{ method: 'POST', url: '/admin/device/statistics/list', handler: handleList },
{ method: 'POST', url: '/admin/device/statistics/overview', handler: handleOverview },
{ method: 'POST', url: '/admin/device/statistics/export', handler: handleExport },
])
@@ -0,0 +1,123 @@
import { registerMocks } from '@axios/mockBus'
import type { ApiResponse, MockContext, RequestParameter } from '@axios'
interface PageParams {
pageNum?: number
pageSize?: number
column?: string
order?: 'ascend' | 'descend' | null
keyword?: string
cooperationStatus?: string
[key: string]: unknown
}
interface SupplierRecord {
id: string
supplierCode: string
name: string
contactName: string
contactPhone: string
email?: string
address: string
cooperationStart: string
cooperationStatus: 'active' | 'suspended' | 'terminated'
mainCategory: string
}
const getParams = (params: RequestParameter): PageParams => (params ?? {}) as PageParams
const includes = (value: unknown, keyword: unknown): boolean => {
if (!keyword) return true
return String(value ?? '').toLowerCase().includes(String(keyword).toLowerCase())
}
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({ code: '00000', msg, data })
const paginate = <T extends SupplierRecord>(list: T[], params: PageParams): ApiResponse<T[]> => {
const pageNum = Number(params.pageNum ?? 1)
const pageSize = Number(params.pageSize ?? 30)
const start = (pageNum - 1) * pageSize
return { code: '00000', msg: '查询成功', data: list.slice(start, start + pageSize), total: list.length }
}
const supplierList: SupplierRecord[] = [
{ id: 'sup-001', supplierCode: 'SUP-001', name: '深圳智能设备供应商', contactName: '陈工', contactPhone: '13800010001', email: 'chen@sz-supplier.com', address: '深圳市南山区科技园路1号', cooperationStart: '2024-01-15', cooperationStatus: 'active', mainCategory: '货柜/大屏/秤' },
{ id: 'sup-002', supplierCode: 'SUP-002', name: '鹏升电子', contactName: '林总', contactPhone: '13800010002', email: 'lin@pengsheng.com', address: '广州市天河区五山路100号', cooperationStart: '2024-03-20', cooperationStatus: 'active', mainCategory: '手表/体重秤' },
{ id: 'sup-003', supplierCode: 'SUP-003', name: '华盛医疗', contactName: '王经理', contactPhone: '13800010003', email: 'wang@huasheng.com', address: '上海市浦东新区张江路88号', cooperationStart: '2024-05-10', cooperationStatus: 'active', mainCategory: '体检机' },
{ id: 'sup-004', supplierCode: 'SUP-004', name: 'InBody中国', contactName: '李代理', contactPhone: '13800010004', email: 'li@inbody-cn.com', address: '北京市朝阳区国贸中心A座', cooperationStart: '2024-06-01', cooperationStatus: 'active', mainCategory: '体脂秤' },
{ id: 'sup-005', supplierCode: 'SUP-005', name: '顿米科技', contactName: '张总', contactPhone: '13800010005', email: 'zhang@dunmi.com', address: '杭州市余杭区文一西路969号', cooperationStart: '2024-07-12', cooperationStatus: 'suspended', mainCategory: '健身器材' },
{ id: 'sup-006', supplierCode: 'SUP-006', name: '聚伟电子', contactName: '吴工', contactPhone: '13800010006', email: 'wu@juwei.com', address: '深圳市宝安区西乡街道50号', cooperationStart: '2024-09-05', cooperationStatus: 'active', mainCategory: '手表' },
{ id: 'sup-007', supplierCode: 'SUP-007', name: '金亿帝科技', contactName: '周总', contactPhone: '13800010007', email: 'zhou@jinyd.com', address: '东莞市松山湖科技产业园', cooperationStart: '2025-01-20', cooperationStatus: 'active', mainCategory: '手表/体脂秤' },
{ id: 'sup-008', supplierCode: 'SUP-008', name: '乐福健康科技', contactName: '郑工', contactPhone: '13800010008', email: 'zheng@lefu.com', address: '苏州市工业园区星湖街318号', cooperationStart: '2025-03-15', cooperationStatus: 'terminated', mainCategory: '体脂秤' },
]
const listPage = (ctx: MockContext): ApiResponse<SupplierRecord[]> => {
const params = getParams(ctx.params)
const filtered = supplierList.filter((item) => {
if (params.cooperationStatus && item.cooperationStatus !== params.cooperationStatus) return false
if (params.keyword && !includes(item.supplierCode, params.keyword) && !includes(item.name, params.keyword) && !includes(item.contactName, params.keyword)) return false
return true
})
if (params.column === 'cooperationStart' && params.order) {
filtered.sort((a, b) => {
const cmp = a.cooperationStart.localeCompare(b.cooperationStart)
return params.order === 'ascend' ? cmp : -cmp
})
}
return paginate(filtered, params)
}
const deleteById = (params: PageParams): ApiResponse<null> => {
const id = String(params.id ?? '')
const idx = supplierList.findIndex((item) => item.id === id)
if (idx >= 0) supplierList.splice(idx, 1)
return ok('删除成功')
}
/** 详情查询(编辑回填) */
const infoById = (params: PageParams): ApiResponse<SupplierRecord | null> => {
const id = String(params.id ?? '')
const record = supplierList.find((item) => item.id === id)
return ok('查询成功', record ?? null)
}
/** 新增 */
const addRecord = (params: PageParams): ApiResponse<null> => {
const now = new Date()
const date = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
const record: SupplierRecord = {
id: `sup-${Date.now().toString(36)}`,
supplierCode: String(params.supplierCode ?? ''),
name: String(params.name ?? ''),
contactName: String(params.contactName ?? ''),
contactPhone: String(params.contactPhone ?? ''),
email: params.email ? String(params.email) : undefined,
address: String(params.address ?? ''),
mainCategory: String(params.mainCategory ?? ''),
cooperationStart: params.cooperationStart ? String(params.cooperationStart) : date,
cooperationStatus: (params.cooperationStatus as SupplierRecord['cooperationStatus']) ?? 'active',
}
supplierList.unshift(record)
return ok('新增成功')
}
/** 编辑 */
const updateRecord = (params: PageParams): ApiResponse<null> => {
const id = String(params.id ?? '')
const record = supplierList.find((item) => item.id === id)
if (!record) return ok('供应商不存在')
record.supplierCode = String(params.supplierCode ?? record.supplierCode)
record.name = String(params.name ?? record.name)
record.contactName = String(params.contactName ?? record.contactName)
record.contactPhone = String(params.contactPhone ?? record.contactPhone)
record.email = params.email ? String(params.email) : record.email
record.address = String(params.address ?? record.address)
record.mainCategory = String(params.mainCategory ?? record.mainCategory)
if (params.cooperationStatus) record.cooperationStatus = params.cooperationStatus as SupplierRecord['cooperationStatus']
return ok('更新成功')
}
registerMocks([
{ method: 'POST', url: '/admin/device/stock/supplier/page', handler: listPage },
{ method: 'POST', url: '/admin/device/stock/supplier/info', handler: (ctx) => infoById(getParams(ctx.params)) },
{ method: 'POST', url: '/admin/device/stock/supplier/add', handler: (ctx) => addRecord(getParams(ctx.params)) },
{ method: 'POST', url: '/admin/device/stock/supplier/update', handler: (ctx) => updateRecord(getParams(ctx.params)) },
{ method: 'POST', url: '/admin/device/stock/supplier/delete', handler: (ctx) => deleteById(getParams(ctx.params)) },
])
@@ -0,0 +1,102 @@
import { registerMocks } from '@axios/mockBus'
import type { ApiResponse, MockContext, RequestParameter } from '@axios'
interface PageParams {
pageNum?: number
pageSize?: number
column?: string
order?: 'ascend' | 'descend' | null
keyword?: string
status?: string
faultLevel?: string
id?: string
toStatus?: string
assignee?: string
[key: string]: unknown
}
interface WorkOrderRecord {
id: string
orderCode: string
deviceCode: string
deviceName: string
siteName: string
customerName: string
source: 'customer' | 'inspection' | 'self-check' | 'after-sale'
faultLevel: 'low' | 'medium' | 'high' | 'critical'
faultDesc: string
status: 'pending' | 'assigned' | 'processing' | 'closed'
assignee?: string
reportTime: string
slaDeadline: string
slaOverdue: boolean
}
const getParams = (params: RequestParameter): PageParams => (params ?? {}) as PageParams
const includes = (value: unknown, keyword: unknown): boolean => {
if (!keyword) return true
return String(value ?? '').toLowerCase().includes(String(keyword).toLowerCase())
}
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({ code: '00000', msg, data })
const paginate = <T extends WorkOrderRecord>(list: T[], params: PageParams): ApiResponse<T[]> => {
const pageNum = Number(params.pageNum ?? 1)
const pageSize = Number(params.pageSize ?? 30)
const start = (pageNum - 1) * pageSize
return { code: '00000', msg: '查询成功', data: list.slice(start, start + pageSize), total: list.length }
}
const workOrderList: WorkOrderRecord[] = [
{ id: 'wo-001', orderCode: 'WO-20260620-001', deviceCode: 'CAB-A1-0001', deviceName: '总部食堂货柜', siteName: '总部食堂', customerName: '智养餐饮集团', source: 'customer', faultLevel: 'high', faultDesc: '柜门无法开启,疑似锁具故障', status: 'closed', assignee: '李工程师', reportTime: '2026-06-20 09:15:00', slaDeadline: '2026-06-20 18:00:00', slaOverdue: false },
{ id: 'wo-002', orderCode: 'WO-20260622-002', deviceCode: 'WAT-W1-0001', deviceName: '健康站01手表', siteName: '健康站01', customerName: '健康管理机构', source: 'inspection', faultLevel: 'medium', faultDesc: '心率采集异常,间歇性无数据', status: 'processing', assignee: '王工程师', reportTime: '2026-06-22 14:30:00', slaDeadline: '2026-06-23 18:00:00', slaOverdue: false },
{ id: 'wo-003', orderCode: 'WO-20260624-003', deviceCode: 'CHK-M1-0001', deviceName: '体检站一体机', siteName: '东区体检站', customerName: '健康管理机构', source: 'customer', faultLevel: 'critical', faultDesc: '设备完全无响应,无法开机', status: 'pending', reportTime: '2026-06-24 10:00:00', slaDeadline: '2026-06-24 22:00:00', slaOverdue: true },
{ id: 'wo-004', orderCode: 'WO-20260625-004', deviceCode: 'SCL-X1-0001', deviceName: '总部入库秤', siteName: '总部食堂', customerName: '智养餐饮集团', source: 'self-check', faultLevel: 'medium', faultDesc: '称重数据偏差,校准无效', status: 'assigned', assignee: '赵工程师', reportTime: '2026-06-25 16:20:00', slaDeadline: '2026-06-26 18:00:00', slaOverdue: false },
{ id: 'wo-005', orderCode: 'WO-20260626-005', deviceCode: 'FAT-INBODY-0001', deviceName: '体检站体脂秤', siteName: '东区体检站', customerName: '健康管理机构', source: 'after-sale', faultLevel: 'low', faultDesc: '用户反馈界面字体显示异常', status: 'closed', assignee: '孙工程师', reportTime: '2026-06-26 11:45:00', slaDeadline: '2026-06-28 18:00:00', slaOverdue: false },
{ id: 'wo-006', orderCode: 'WO-20260627-006', deviceCode: 'FIT-S1-0001', deviceName: '健身房顿米车', siteName: '总部健身房', customerName: '智养餐饮集团', source: 'customer', faultLevel: 'high', faultDesc: '阻力调节失灵,无法增减阻力', status: 'processing', assignee: '李工程师', reportTime: '2026-06-27 09:00:00', slaDeadline: '2026-06-27 22:00:00', slaOverdue: true },
{ id: 'wo-007', orderCode: 'WO-20260628-007', deviceCode: 'SCR-P1-0001', deviceName: '总部大屏', siteName: '总部食堂', customerName: '智养餐饮集团', source: 'inspection', faultLevel: 'low', faultDesc: '屏幕局部亮点,不影响显示', status: 'pending', reportTime: '2026-06-28 15:30:00', slaDeadline: '2026-07-01 18:00:00', slaOverdue: false },
{ id: 'wo-008', orderCode: 'WO-20260629-008', deviceCode: 'SCL-W-0001', deviceName: '健康站体重秤', siteName: '健康站01', customerName: '健康管理机构', source: 'customer', faultLevel: 'medium', faultDesc: '蓝牙连接频繁断开', status: 'closed', assignee: '王工程师', reportTime: '2026-06-29 13:00:00', slaDeadline: '2026-06-30 18:00:00', slaOverdue: false },
]
const listPage = (ctx: MockContext): ApiResponse<WorkOrderRecord[]> => {
const params = getParams(ctx.params)
const filtered = workOrderList.filter((item) => {
if (params.status && item.status !== params.status) return false
if (params.faultLevel && item.faultLevel !== params.faultLevel) return false
if (params.keyword && !includes(item.orderCode, params.keyword) && !includes(item.deviceCode, params.keyword) && !includes(item.deviceName, params.keyword)) return false
return true
})
if (params.column === 'reportTime' && params.order) {
filtered.sort((a, b) => {
const cmp = a.reportTime.localeCompare(b.reportTime)
return params.order === 'ascend' ? cmp : -cmp
})
}
return paginate(filtered, params)
}
const handleTransition = (params: PageParams): ApiResponse<null> => {
const id = String(params.id ?? '')
const item = workOrderList.find((o) => o.id === id)
if (!item) return ok('工单不存在')
const toStatus = params.toStatus as WorkOrderRecord['status']
if (!toStatus) return ok('目标状态不能为空')
item.status = toStatus
if (toStatus === 'closed') {
item.slaOverdue = false
} else if (params.assignee) {
item.assignee = params.assignee
}
return ok('工单状态已更新')
}
const deleteById = (params: PageParams): ApiResponse<null> => {
const id = String(params.id ?? '')
const idx = workOrderList.findIndex((item) => item.id === id)
if (idx >= 0) workOrderList.splice(idx, 1)
return ok('删除成功')
}
registerMocks([
{ method: 'POST', url: '/admin/device/repair/work-order/page', handler: listPage },
{ method: 'POST', url: '/admin/device/repair/work-order/transition', handler: (ctx) => handleTransition(getParams(ctx.params)) },
{ method: 'POST', url: '/admin/device/repair/work-order/delete', handler: (ctx) => deleteById(getParams(ctx.params)) },
])
@@ -0,0 +1,325 @@
import { registerMocks } from '@axios/mockBus'
import type { ApiResponse, MockContext, RequestParameter } from '@axios'
/**
* 分发配置 mock 数据
* - 下游系统管理(CRUD + 启用/禁用)
* - 订阅映射(CRUD + 启用/禁用 + 下游下拉选项)
*/
interface DownstreamItem {
id: string
name: string
code: string
callbackUrl: string
authType: 'none' | 'apikey' | 'hmac' | 'token'
authKey: string
dataTypes: string[]
retryStrategy: 'exponential' | 'fixed' | 'none'
maxRetry: number
status: 'enabled' | 'disabled'
remark?: string
createdAt: string
}
interface SubscribeItem {
id: string
downstreamId: string
downstreamName: string
deviceCode: string
deviceName: string
dataType: string
filterRule: string
status: 'enabled' | 'disabled'
createdAt: string
}
interface BaseParams {
id?: string
name?: string
authType?: string
retryStrategy?: string
status?: string
downstreamId?: string
deviceCode?: string
dataType?: string
code?: string
callbackUrl?: string
authKey?: string
dataTypes?: string[]
maxRetry?: number
remark?: string
filterRule?: string
column?: string
order?: 'ascend' | 'descend' | null
pageNum?: number
pageSize?: number
[key: string]: unknown
}
const getP = (params: RequestParameter): BaseParams => (params ?? {}) as BaseParams
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({ code: '00000', msg, data })
const downstreamList: DownstreamItem[] = [
{
id: 'DS-001', name: '体重管理系统', code: 'WEIGHT-MGMT',
callbackUrl: 'https://weight.example.com/api/v1/iot/callback',
authType: 'apikey', authKey: 'ak_xxx_weight_dev',
dataTypes: ['weight', 'bodyFat'], retryStrategy: 'exponential', maxRetry: 5,
status: 'enabled', remark: '总部 + 园区体重数据', createdAt: '2026-06-12 10:30:00',
},
{
id: 'DS-002', name: '健康管理系统', code: 'HEALTH-MGMT',
callbackUrl: 'https://health.example.com/open/iot/receive',
authType: 'hmac', authKey: 'hmac_xxx_health_prod',
dataTypes: ['heartRate', 'sleep', 'stepCount', 'healthCheck'],
retryStrategy: 'exponential', maxRetry: 5,
status: 'enabled', remark: '个人健康数据聚合', createdAt: '2026-06-12 11:20:00',
},
{
id: 'DS-003', name: '运动管理系统', code: 'SPORT-MGMT',
callbackUrl: 'https://sport.example.com/api/iot/v2',
authType: 'token', authKey: 'bearer_xxx_sport_dev',
dataTypes: ['training'], retryStrategy: 'fixed', maxRetry: 3,
status: 'enabled', remark: '健身房运动数据', createdAt: '2026-06-15 09:00:00',
},
{
id: 'DS-004', name: '营养分析系统', code: 'NUTRI-ANALYSIS',
callbackUrl: 'https://nutri.example.com/api/iot/weight',
authType: 'apikey', authKey: 'ak_xxx_nutri_test',
dataTypes: ['weight', 'bodyFat', 'heartRate'],
retryStrategy: 'exponential', maxRetry: 5,
status: 'disabled', remark: '营养摄入关联分析(停用中)', createdAt: '2026-06-20 14:00:00',
},
{
id: 'DS-005', name: '应急监测平台', code: 'EMERGENCY',
callbackUrl: 'https://emerg.example.com/api/iot/alert',
authType: 'none', authKey: '',
dataTypes: ['heartRate', 'healthCheck'],
retryStrategy: 'none', maxRetry: 0,
status: 'enabled', remark: '高风险健康事件实时推送', createdAt: '2026-06-25 16:30:00',
},
]
const deviceDict: Record<string, string> = {
'WD-SCALE-0001': '总部体重秤 A',
'WD-SCALE-0002': '分部体重秤 B',
'WD-INBODY-0012': '园区体脂仪',
'WD-WATCH-0042': '李四的华为手表',
'WD-WATCH-0051': '王五的华为手表',
'WD-BIKE-0008': '健身房 08 号健身车',
'WD-HEALTH-0023': '体检一体机 23',
'WD-BP-0007': '血压计 07',
}
const subscribeList: SubscribeItem[] = [
{
id: 'SUB-001', downstreamId: 'DS-001', downstreamName: '体重管理系统',
deviceCode: 'WD-SCALE-0001', deviceName: '总部体重秤 A',
dataType: 'weight', filterRule: '', status: 'enabled', createdAt: '2026-06-12 10:35:00',
},
{
id: 'SUB-002', downstreamId: 'DS-001', downstreamName: '体重管理系统',
deviceCode: 'WD-INBODY-0012', deviceName: '园区体脂仪',
dataType: 'bodyFat', filterRule: 'weight > 40', status: 'enabled', createdAt: '2026-06-12 10:36:00',
},
{
id: 'SUB-003', downstreamId: 'DS-002', downstreamName: '健康管理系统',
deviceCode: 'WD-WATCH-0042', deviceName: '李四的华为手表',
dataType: 'heartRate', filterRule: '', status: 'enabled', createdAt: '2026-06-12 11:25:00',
},
{
id: 'SUB-004', downstreamId: 'DS-002', downstreamName: '健康管理系统',
deviceCode: 'WD-WATCH-0042', deviceName: '李四的华为手表',
dataType: 'sleep', filterRule: '', status: 'enabled', createdAt: '2026-06-12 11:26:00',
},
{
id: 'SUB-005', downstreamId: 'DS-003', downstreamName: '运动管理系统',
deviceCode: 'WD-BIKE-0008', deviceName: '健身房 08 号健身车',
dataType: 'training', filterRule: 'duration > 5', status: 'enabled', createdAt: '2026-06-15 09:05:00',
},
{
id: 'SUB-006', downstreamId: 'DS-002', downstreamName: '健康管理系统',
deviceCode: 'WD-HEALTH-0023', deviceName: '体检一体机 23',
dataType: 'healthCheck', filterRule: '', status: 'enabled', createdAt: '2026-06-15 09:30:00',
},
{
id: 'SUB-007', downstreamId: 'DS-005', downstreamName: '应急监测平台',
deviceCode: 'WD-BP-0007', deviceName: '血压计 07',
dataType: 'heartRate', filterRule: 'heartRate > 120', status: 'enabled', createdAt: '2026-06-25 16:35:00',
},
{
id: 'SUB-008', downstreamId: 'DS-004', downstreamName: '营养分析系统',
deviceCode: 'WD-SCALE-0002', deviceName: '分部体重秤 B',
dataType: 'weight', filterRule: '', status: 'disabled', createdAt: '2026-06-20 14:10:00',
},
]
// ============= 下游系统接口 =============
const downstreamListHandler = (ctx: MockContext): ApiResponse<DownstreamItem[]> => {
const p = getP(ctx.params)
const filtered = downstreamList.filter((item) => {
if (p.name && !item.name.toLowerCase().includes(p.name.toLowerCase())) return false
if (p.authType && item.authType !== p.authType) return false
if (p.retryStrategy && item.retryStrategy !== p.retryStrategy) return false
if (p.status && item.status !== p.status) return false
return true
})
return { code: '00000', msg: '查询成功', data: filtered, total: filtered.length }
}
const downstreamInfo = (ctx: MockContext): ApiResponse<DownstreamItem | null> => {
const p = getP(ctx.params)
const target = downstreamList.find((i) => i.id === p.id)
if (!target) return ok('记录不存在', null)
return ok('查询成功', target)
}
const downstreamAdd = (ctx: MockContext): ApiResponse<null> => {
const p = getP(ctx.params)
const id = 'DS-' + String(downstreamList.length + 1).padStart(3, '0')
downstreamList.push({
id,
name: String(p.name ?? ''),
code: String(p.code ?? ''),
callbackUrl: String(p.callbackUrl ?? ''),
authType: (p.authType as DownstreamItem['authType']) ?? 'none',
authKey: String(p.authKey ?? ''),
dataTypes: p.dataTypes ?? [],
retryStrategy: (p.retryStrategy as DownstreamItem['retryStrategy']) ?? 'exponential',
maxRetry: Number(p.maxRetry ?? 0),
status: (p.status as DownstreamItem['status']) ?? 'enabled',
remark: p.remark ?? '',
createdAt: '2026-07-02 10:00:00',
})
return ok('新增成功')
}
const downstreamUpdate = (ctx: MockContext): ApiResponse<null> => {
const p = getP(ctx.params)
const id = String(p.id ?? '')
const target = downstreamList.find((i) => i.id === id)
if (!target) return ok('记录不存在', null)
if (p.name !== undefined) target.name = String(p.name)
if (p.callbackUrl !== undefined) target.callbackUrl = String(p.callbackUrl)
if (p.authType !== undefined) target.authType = p.authType as DownstreamItem['authType']
if (p.authKey !== undefined) target.authKey = String(p.authKey)
if (p.dataTypes !== undefined) target.dataTypes = p.dataTypes as string[]
if (p.retryStrategy !== undefined) target.retryStrategy = p.retryStrategy as DownstreamItem['retryStrategy']
if (p.maxRetry !== undefined) target.maxRetry = Number(p.maxRetry)
if (p.status !== undefined) target.status = p.status as DownstreamItem['status']
if (p.remark !== undefined) target.remark = p.remark
return ok('更新成功')
}
const downstreamDelete = (ctx: MockContext): ApiResponse<null> => {
const p = getP(ctx.params)
const id = String(p.id ?? '')
const idx = downstreamList.findIndex((i) => i.id === id)
if (idx < 0) return ok('记录不存在', null)
downstreamList.splice(idx, 1)
// 级联删除订阅
for (let i = subscribeList.length - 1; i >= 0; i--) {
if (subscribeList[i].downstreamId === id) subscribeList.splice(i, 1)
}
return ok('删除成功')
}
const downstreamToggle = (ctx: MockContext): ApiResponse<null> => {
const p = getP(ctx.params)
const id = String(p.id ?? '')
const target = downstreamList.find((i) => i.id === id)
if (!target) return ok('记录不存在', null)
target.status = target.status === 'enabled' ? 'disabled' : 'enabled'
return ok(target.status === 'enabled' ? '已启用' : '已禁用')
}
// ============= 订阅映射接口 =============
const subscribeListHandler = (ctx: MockContext): ApiResponse<SubscribeItem[]> => {
const p = getP(ctx.params)
const filtered = subscribeList.filter((item) => {
if (p.downstreamId && item.downstreamId !== p.downstreamId) return false
if (p.deviceCode && !item.deviceCode.toLowerCase().includes(p.deviceCode.toLowerCase())) return false
if (p.dataType && item.dataType !== p.dataType) return false
if (p.status && item.status !== p.status) return false
return true
})
return { code: '00000', msg: '查询成功', data: filtered, total: filtered.length }
}
const subscribeOptions = (): ApiResponse<{ downstream: { label: string; value: string }[] }> => {
return ok('查询成功', {
downstream: downstreamList
.filter((i) => i.status === 'enabled')
.map((i) => ({ label: i.name, value: i.id })),
})
}
const subscribeAdd = (ctx: MockContext): ApiResponse<null> => {
const p = getP(ctx.params)
const ds = downstreamList.find((i) => i.id === p.downstreamId)
if (!ds) return ok('下游系统不存在', null)
const id = 'SUB-' + String(subscribeList.length + 1).padStart(3, '0')
subscribeList.push({
id,
downstreamId: ds.id,
downstreamName: ds.name,
deviceCode: String(p.deviceCode ?? ''),
deviceName: deviceDict[String(p.deviceCode ?? '')] ?? '未知设备',
dataType: String(p.dataType ?? ''),
filterRule: String(p.filterRule ?? ''),
status: (p.status as SubscribeItem['status']) ?? 'enabled',
createdAt: '2026-07-02 10:00:00',
})
return ok('新增成功')
}
const subscribeUpdate = (ctx: MockContext): ApiResponse<null> => {
const p = getP(ctx.params)
const id = String(p.id ?? '')
const target = subscribeList.find((i) => i.id === id)
if (!target) return ok('记录不存在', null)
if (p.deviceCode !== undefined) {
target.deviceCode = String(p.deviceCode)
target.deviceName = deviceDict[target.deviceCode] ?? '未知设备'
}
if (p.dataType !== undefined) target.dataType = String(p.dataType)
if (p.filterRule !== undefined) target.filterRule = String(p.filterRule)
if (p.status !== undefined) target.status = p.status as SubscribeItem['status']
return ok('更新成功')
}
const subscribeDelete = (ctx: MockContext): ApiResponse<null> => {
const p = getP(ctx.params)
const id = String(p.id ?? '')
const idx = subscribeList.findIndex((i) => i.id === id)
if (idx < 0) return ok('记录不存在', null)
subscribeList.splice(idx, 1)
return ok('删除成功')
}
const subscribeToggle = (ctx: MockContext): ApiResponse<null> => {
const p = getP(ctx.params)
const id = String(p.id ?? '')
const target = subscribeList.find((i) => i.id === id)
if (!target) return ok('记录不存在', null)
target.status = target.status === 'enabled' ? 'disabled' : 'enabled'
return ok(target.status === 'enabled' ? '已启用' : '已禁用')
}
registerMocks([
{ method: 'POST', url: '/admin/device/distribute/config/downstream/list', handler: downstreamListHandler },
{ method: 'POST', url: '/admin/device/distribute/config/downstream/info', handler: downstreamInfo },
{ method: 'POST', url: '/admin/device/distribute/config/downstream/add', handler: downstreamAdd },
{ method: 'POST', url: '/admin/device/distribute/config/downstream/update', handler: downstreamUpdate },
{ method: 'POST', url: '/admin/device/distribute/config/downstream/delete', handler: downstreamDelete },
{ method: 'POST', url: '/admin/device/distribute/config/downstream/toggleStatus', handler: downstreamToggle },
{ method: 'POST', url: '/admin/device/distribute/config/subscribe/list', handler: subscribeListHandler },
{ method: 'POST', url: '/admin/device/distribute/config/subscribe/options', handler: subscribeOptions },
{ method: 'POST', url: '/admin/device/distribute/config/subscribe/add', handler: subscribeAdd },
{ method: 'POST', url: '/admin/device/distribute/config/subscribe/update', handler: subscribeUpdate },
{ method: 'POST', url: '/admin/device/distribute/config/subscribe/delete', handler: subscribeDelete },
{ method: 'POST', url: '/admin/device/distribute/config/subscribe/toggleStatus', handler: subscribeToggle },
])
@@ -0,0 +1,254 @@
import { registerMocks } from '@axios/mockBus'
import type { ApiResponse, MockContext, RequestParameter } from '@axios'
/**
* 失败重投(死信队列) mock 数据
* - 记录分发失败进入死信队列的样本
* - 自动重投策略:指数退避(1min / 5min / 30min / 2h / 12h),最多 5 次后转人工
* - 状态:pending 待重投 / retrying 重投中 / recovered 已恢复 / discarded 已丢弃
*/
interface DeadletterItem {
id: string
originalRecordId: string
deviceCode: string
deviceName: string
dataType: 'weight' | 'bodyFat' | 'heartRate' | 'sleep' | 'stepCount' | 'training' | 'healthCheck'
downstreamId: string
downstreamName: string
firstFailTime: string
lastRetryTime: string | null
retryCount: number
nextRetryTime: string | null
failReason: string
status: 'pending' | 'retrying' | 'recovered' | 'discarded'
}
interface DeadletterDetail extends DeadletterItem {
requestBody: string
responseBody: string
authKey: string
strategy: string
thresholdWarn: string
}
interface DeadletterParams {
deviceCode?: string
dataType?: string
downstreamId?: string
status?: string
column?: string
order?: 'ascend' | 'descend' | null
pageNum?: number
pageSize?: number
ids?: string[]
id?: string
[key: string]: unknown
}
const getParams = (params: RequestParameter): DeadletterParams => (params ?? {}) as DeadletterParams
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({ code: '00000', msg, data })
const RETRY_STRATEGY = '指数退避:1min → 5min → 30min → 2h → 12h,最多 5 次后转人工'
const sampleRequestBody = (dataType: string, deviceCode: string): string =>
JSON.stringify({
deviceCode,
dataType,
timestamp: '2026-07-02T10:30:00Z',
payload: {
weight: dataType === 'weight' ? 65.3 : undefined,
bodyFat: dataType === 'bodyFat' ? 18.5 : undefined,
heartRate: dataType === 'heartRate' ? 72 : undefined,
steps: dataType === 'stepCount' ? 8542 : undefined,
},
})
const sampleResponseBody = (status: string): string =>
JSON.stringify({
code: status === 'recovered' ? 200 : 500,
message: status === 'recovered' ? 'OK' : 'Internal Server Error',
receivedAt: '2026-07-02T10:30:01Z',
})
const deadletterList: DeadletterItem[] = [
{
id: 'DL-001', originalRecordId: 'DPR-002', deviceCode: 'WD-INBODY-0012', deviceName: '园区体脂仪',
dataType: 'bodyFat', downstreamId: 'DS-001', downstreamName: '体重管理系统',
firstFailTime: '2026-07-02 09:13:12', lastRetryTime: '2026-07-02 09:33:12',
retryCount: 3, nextRetryTime: '2026-07-02 11:33:12',
failReason: '下游服务 500 内部错误,已重试 3 次仍未恢复', status: 'pending',
},
{
id: 'DL-002', originalRecordId: 'DPR-004', deviceCode: 'WD-WATCH-0042', deviceName: '李四的华为手表',
dataType: 'sleep', downstreamId: 'DS-002', downstreamName: '健康管理系统',
firstFailTime: '2026-07-02 09:15:20', lastRetryTime: '2026-07-02 09:20:20',
retryCount: 1, nextRetryTime: '2026-07-02 09:45:20',
failReason: '请求超时 408,进入指数退避重投队列', status: 'retrying',
},
{
id: 'DL-003', originalRecordId: 'DPR-008', deviceCode: 'WD-WATCH-0042', deviceName: '李四的华为手表',
dataType: 'stepCount', downstreamId: 'DS-002', downstreamName: '健康管理系统',
firstFailTime: '2026-07-02 09:19:18', lastRetryTime: '2026-07-02 10:19:18',
retryCount: 5, nextRetryTime: null,
failReason: '下游服务不可用 503,已耗尽 5 次自动重投,待人工介入', status: 'pending',
},
{
id: 'DL-004', originalRecordId: 'DPR-010', deviceCode: 'WD-SCALE-0002', deviceName: '分部体重秤 B',
dataType: 'weight', downstreamId: 'DS-001', downstreamName: '体重管理系统',
firstFailTime: '2026-07-02 08:42:11', lastRetryTime: '2026-07-02 08:47:11',
retryCount: 2, nextRetryTime: '2026-07-02 09:17:11',
failReason: '鉴权密钥过期,下游返回 401', status: 'pending',
},
{
id: 'DL-005', originalRecordId: 'DPR-012', deviceCode: 'WD-BP-0007', deviceName: '血压计 07',
dataType: 'heartRate', downstreamId: 'DS-002', downstreamName: '健康管理系统',
firstFailTime: '2026-07-01 17:30:55', lastRetryTime: '2026-07-01 18:30:55',
retryCount: 4, nextRetryTime: '2026-07-02 06:30:55',
failReason: '下游数据库连接失败,连续 4 次重试失败', status: 'retrying',
},
{
id: 'DL-006', originalRecordId: 'DPR-015', deviceCode: 'WD-BIKE-0008', deviceName: '健身房 08 号健身车',
dataType: 'training', downstreamId: 'DS-003', downstreamName: '运动管理系统',
firstFailTime: '2026-07-01 14:22:30', lastRetryTime: '2026-07-01 14:27:30',
retryCount: 2, nextRetryTime: null,
failReason: '运维确认下游已升级,重投成功', status: 'recovered',
},
{
id: 'DL-007', originalRecordId: 'DPR-018', deviceCode: 'WD-HEALTH-0023', deviceName: '体检一体机 23',
dataType: 'healthCheck', downstreamId: 'DS-002', downstreamName: '健康管理系统',
firstFailTime: '2026-07-01 10:08:14', lastRetryTime: '2026-07-01 22:08:14',
retryCount: 5, nextRetryTime: null,
failReason: '下游接口字段不兼容,已耗尽自动重投,运维永久丢弃', status: 'discarded',
},
{
id: 'DL-008', originalRecordId: 'DPR-020', deviceCode: 'WD-WATCH-0051', deviceName: '王五的华为手表',
dataType: 'sleep', downstreamId: 'DS-002', downstreamName: '健康管理系统',
firstFailTime: '2026-07-02 07:55:42', lastRetryTime: null,
retryCount: 0, nextRetryTime: '2026-07-02 07:56:42',
failReason: '下游服务瞬时不可用,等待首次重投', status: 'pending',
},
]
/** 列表筛选 + 分页 */
const listDeadletter = (ctx: MockContext): ApiResponse<DeadletterItem[]> => {
const params = getParams(ctx.params)
const filtered = deadletterList.filter((item) => {
if (params.deviceCode && !item.deviceCode.toLowerCase().includes(params.deviceCode.toLowerCase())) return false
if (params.dataType && item.dataType !== params.dataType) return false
if (params.downstreamId && item.downstreamId !== params.downstreamId) return false
if (params.status && item.status !== params.status) return false
return true
})
if (params.column === 'firstFailTime' && params.order) {
filtered.sort((a, b) => {
const cmp = a.firstFailTime.localeCompare(b.firstFailTime)
return params.order === 'ascend' ? cmp : -cmp
})
}
const pageNum = Number(params.pageNum ?? 1)
const pageSize = Number(params.pageSize ?? 30)
const start = (pageNum - 1) * pageSize
return { code: '00000', msg: '查询成功', data: filtered.slice(start, start + pageSize), total: filtered.length }
}
/** 详情(含请求体/响应体 + 策略说明) */
const infoDeadletter = (ctx: MockContext): ApiResponse<DeadletterDetail | null> => {
const params = getParams(ctx.params)
const id = String(params.id ?? '')
const target = deadletterList.find((item) => item.id === id)
if (!target) return ok('记录不存在', null)
return ok('查询成功', {
...target,
requestBody: sampleRequestBody(target.dataType, target.deviceCode),
responseBody: sampleResponseBody(target.status),
authKey: 'ak_xxx_' + target.downstreamId.toLowerCase() + '_dev',
strategy: RETRY_STRATEGY,
thresholdWarn: '死信累积超 100 条将自动告警到「设备运维 → 告警管理」',
})
}
/** 顶部统计卡 */
const statDeadletter = (): ApiResponse<{ pendingCount: number; retryingCount: number; recoveredCount: number; discardedCount: number }> => {
const count = (s: string) => deadletterList.filter((i) => i.status === s).length
return ok('查询成功', {
pendingCount: count('pending'),
retryingCount: count('retrying'),
recoveredCount: count('recovered'),
discardedCount: count('discarded'),
})
}
/** 手动重投(单条) */
const retryDeadletter = (ctx: MockContext): ApiResponse<null> => {
const params = getParams(ctx.params)
const id = String(params.id ?? '')
const target = deadletterList.find((item) => item.id === id)
if (!target) return ok('记录不存在', null)
if (target.status === 'discarded') return ok('该死信已丢弃,无法重投')
target.status = 'retrying'
target.retryCount += 1
target.lastRetryTime = '2026-07-02 10:00:00'
target.failReason = '运维手动触发重投,等待下游响应'
return ok('重投任务已提交')
}
/** 批量重投 */
const batchRetryDeadletter = (ctx: MockContext): ApiResponse<null> => {
const params = getParams(ctx.params)
const ids = (params.ids ?? []) as string[]
let count = 0
ids.forEach((id) => {
const target = deadletterList.find((item) => item.id === id)
if (target && target.status !== 'discarded') {
target.status = 'retrying'
target.retryCount += 1
target.lastRetryTime = '2026-07-02 10:00:00'
target.failReason = '批量手动触发重投,等待下游响应'
count += 1
}
})
return ok(`已提交 ${count} 条重投任务`)
}
/** 永久丢弃(单条) */
const discardDeadletter = (ctx: MockContext): ApiResponse<null> => {
const params = getParams(ctx.params)
const id = String(params.id ?? '')
const target = deadletterList.find((item) => item.id === id)
if (!target) return ok('记录不存在', null)
target.status = 'discarded'
target.nextRetryTime = null
target.failReason = '运维永久丢弃,不再自动重投'
return ok('已永久丢弃')
}
/** 批量丢弃 */
const batchDiscardDeadletter = (ctx: MockContext): ApiResponse<null> => {
const params = getParams(ctx.params)
const ids = (params.ids ?? []) as string[]
let count = 0
ids.forEach((id) => {
const target = deadletterList.find((item) => item.id === id)
if (target) {
target.status = 'discarded'
target.nextRetryTime = null
target.failReason = '批量永久丢弃,不再自动重投'
count += 1
}
})
return ok(`已永久丢弃 ${count} 条记录`)
}
registerMocks([
{ method: 'POST', url: '/admin/device/distribute/deadletter/list', handler: listDeadletter },
{ method: 'POST', url: '/admin/device/distribute/deadletter/info', handler: infoDeadletter },
{ method: 'POST', url: '/admin/device/distribute/deadletter/stat', handler: statDeadletter },
{ method: 'POST', url: '/admin/device/distribute/deadletter/retry', handler: retryDeadletter },
{ method: 'POST', url: '/admin/device/distribute/deadletter/batchRetry', handler: batchRetryDeadletter },
{ method: 'POST', url: '/admin/device/distribute/deadletter/discard', handler: discardDeadletter },
{ method: 'POST', url: '/admin/device/distribute/deadletter/batchDiscard', handler: batchDiscardDeadletter },
])
@@ -0,0 +1,211 @@
import { registerMocks } from '@axios/mockBus'
import type { ApiResponse, MockContext, RequestParameter } from '@axios'
/**
* 分发记录 mock 数据
* - 一条上报数据 × 一个下游 = 一条分发记录
* - 含 HTTP 状态码、响应耗时、重试次数、状态、失败原因
* - 详情包含请求体 / 响应体
*/
interface RecordItem {
id: string
deviceCode: string
deviceName: string
dataType: 'weight' | 'bodyFat' | 'heartRate' | 'sleep' | 'stepCount' | 'training' | 'healthCheck'
downstreamId: string
downstreamName: string
dispatchTime: string
httpStatus: number
responseTime: number
retryCount: number
status: 'success' | 'failed' | 'retrying'
failureReason?: string
}
interface DetailRecord extends RecordItem {
requestBody: string
responseBody: string
authKey: string
}
interface RecordParams {
deviceCode?: string
downstreamId?: string
dataType?: string
status?: string
column?: string
order?: 'ascend' | 'descend' | null
pageNum?: number
pageSize?: number
ids?: string[]
id?: string
[key: string]: unknown
}
const getParams = (params: RequestParameter): RecordParams => (params ?? {}) as RecordParams
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({ code: '00000', msg, data })
const sampleRequestBody = (dataType: string, deviceCode: string): string =>
JSON.stringify({
deviceCode,
dataType,
timestamp: '2026-07-02T10:30:00Z',
payload: {
weight: dataType === 'weight' ? 65.3 : undefined,
bodyFat: dataType === 'bodyFat' ? 18.5 : undefined,
heartRate: dataType === 'heartRate' ? 72 : undefined,
steps: dataType === 'stepCount' ? 8542 : undefined,
},
})
const sampleResponseBody = (httpStatus: number, status: string): string =>
JSON.stringify({
code: httpStatus,
message: status === 'success' ? 'OK' : 'Internal Server Error',
receivedAt: '2026-07-02T10:30:01Z',
})
const recordList: RecordItem[] = [
{
id: 'DPR-001', deviceCode: 'WD-SCALE-0001', deviceName: '总部体重秤 A',
dataType: 'weight', downstreamId: 'DS-001', downstreamName: '体重管理系统',
dispatchTime: '2026-07-02 09:12:30', httpStatus: 200, responseTime: 85, retryCount: 0,
status: 'success',
},
{
id: 'DPR-002', deviceCode: 'WD-INBODY-0012', deviceName: '园区体脂仪',
dataType: 'bodyFat', downstreamId: 'DS-001', downstreamName: '体重管理系统',
dispatchTime: '2026-07-02 09:13:12', httpStatus: 500, responseTime: 320, retryCount: 3,
status: 'failed', failureReason: '下游服务 500 内部错误,已耗尽 3 次重试',
},
{
id: 'DPR-003', deviceCode: 'WD-WATCH-0042', deviceName: '李四的华为手表',
dataType: 'heartRate', downstreamId: 'DS-002', downstreamName: '健康管理系统',
dispatchTime: '2026-07-02 09:14:48', httpStatus: 200, responseTime: 102, retryCount: 0,
status: 'success',
},
{
id: 'DPR-004', deviceCode: 'WD-WATCH-0042', deviceName: '李四的华为手表',
dataType: 'sleep', downstreamId: 'DS-002', downstreamName: '健康管理系统',
dispatchTime: '2026-07-02 09:15:20', httpStatus: 408, responseTime: 5000, retryCount: 1,
status: 'retrying', failureReason: '请求超时,进入重投队列',
},
{
id: 'DPR-005', deviceCode: 'WD-BIKE-0008', deviceName: '健身房 08 号健身车',
dataType: 'training', downstreamId: 'DS-003', downstreamName: '运动管理系统',
dispatchTime: '2026-07-02 09:16:05', httpStatus: 200, responseTime: 145, retryCount: 0,
status: 'success',
},
{
id: 'DPR-006', deviceCode: 'WD-HEALTH-0023', deviceName: '体检一体机 23',
dataType: 'healthCheck', downstreamId: 'DS-002', downstreamName: '健康管理系统',
dispatchTime: '2026-07-02 09:17:42', httpStatus: 200, responseTime: 220, retryCount: 0,
status: 'success',
},
{
id: 'DPR-007', deviceCode: 'WD-SCALE-0001', deviceName: '总部体重秤 A',
dataType: 'weight', downstreamId: 'DS-001', downstreamName: '体重管理系统',
dispatchTime: '2026-07-02 09:18:33', httpStatus: 200, responseTime: 78, retryCount: 0,
status: 'success',
},
{
id: 'DPR-008', deviceCode: 'WD-WATCH-0042', deviceName: '李四的华为手表',
dataType: 'stepCount', downstreamId: 'DS-002', downstreamName: '健康管理系统',
dispatchTime: '2026-07-02 09:19:18', httpStatus: 503, responseTime: 280, retryCount: 2,
status: 'failed', failureReason: '下游服务不可用 503,已耗尽重试',
},
]
/** 列表筛选 + 分页 */
const listRecord = (ctx: MockContext): ApiResponse<RecordItem[]> => {
const params = getParams(ctx.params)
const filtered = recordList.filter((item) => {
if (params.deviceCode && !item.deviceCode.toLowerCase().includes(params.deviceCode.toLowerCase())) return false
if (params.downstreamId && item.downstreamId !== params.downstreamId) return false
if (params.dataType && item.dataType !== params.dataType) return false
if (params.status && item.status !== params.status) return false
return true
})
if (params.column === 'dispatchTime' && params.order) {
filtered.sort((a, b) => {
const cmp = a.dispatchTime.localeCompare(b.dispatchTime)
return params.order === 'ascend' ? cmp : -cmp
})
}
const pageNum = Number(params.pageNum ?? 1)
const pageSize = Number(params.pageSize ?? 30)
const start = (pageNum - 1) * pageSize
return { code: '00000', msg: '查询成功', data: filtered.slice(start, start + pageSize), total: filtered.length }
}
/** 详情(含请求体/响应体) */
const infoRecord = (ctx: MockContext): ApiResponse<DetailRecord | null> => {
const params = getParams(ctx.params)
const id = String(params.id ?? '')
const target = recordList.find((item) => item.id === id)
if (!target) return ok('记录不存在', null)
return ok('查询成功', {
...target,
requestBody: sampleRequestBody(target.dataType, target.deviceCode),
responseBody: sampleResponseBody(target.httpStatus, target.status),
authKey: 'ak_xxx_' + target.downstreamId.toLowerCase() + '_dev',
})
}
/** 顶部统计卡:今日分发总数 / 成功率 / 平均响应耗时 / 失败待重投数 */
const statRecord = (): ApiResponse<{ todayTotal: number; successRate: number; avgResponseTime: number; failedPending: number }> => {
const total = recordList.length
const successCount = recordList.filter((i) => i.status === 'success').length
const failedPending = recordList.filter((i) => i.status !== 'success').length
const avgTime = Math.round(recordList.reduce((sum, i) => sum + i.responseTime, 0) / total)
return ok('查询成功', {
todayTotal: 1280,
successRate: Math.round((successCount / total) * 1000) / 10,
avgResponseTime: avgTime,
failedPending,
})
}
/** 手动重投(单条):状态重置为 retrying,重试次数 +1 */
const retryRecord = (ctx: MockContext): ApiResponse<null> => {
const params = getParams(ctx.params)
const id = String(params.id ?? '')
const target = recordList.find((item) => item.id === id)
if (!target) return ok('记录不存在', null)
target.status = 'retrying'
target.retryCount += 1
target.failureReason = '运维手动触发重投,等待下游响应'
return ok('重投任务已提交')
}
/** 批量重投 */
const batchRetryRecord = (ctx: MockContext): ApiResponse<null> => {
const params = getParams(ctx.params)
const ids = (params.ids ?? []) as string[]
ids.forEach((id) => {
const target = recordList.find((item) => item.id === id)
if (target) {
target.status = 'retrying'
target.retryCount += 1
target.failureReason = '批量手动触发重投,等待下游响应'
}
})
return ok(`已提交 ${ids.length} 条重投任务`)
}
/** 导出(返回虚拟 URL */
const exportRecord = (): ApiResponse<{ url: string }> =>
ok('导出成功', { url: '/mock/distribute-record-export.xlsx' })
registerMocks([
{ method: 'POST', url: '/admin/device/distribute/record/list', handler: listRecord },
{ method: 'POST', url: '/admin/device/distribute/record/info', handler: infoRecord },
{ method: 'POST', url: '/admin/device/distribute/record/stat', handler: statRecord },
{ method: 'POST', url: '/admin/device/distribute/record/retry', handler: retryRecord },
{ method: 'POST', url: '/admin/device/distribute/record/batchRetry', handler: batchRetryRecord },
{ method: 'POST', url: '/admin/device/distribute/record/export', handler: exportRecord },
])
@@ -0,0 +1,228 @@
import { registerMocks } from '@axios/mockBus'
import type { ApiResponse, MockContext, RequestParameter } from '@axios'
/**
* 设备运维 / 告警管理 mock
* - 告警处置:转工单 / 忽略 / 批量处理
* - 业务日志:全量操作流水视图
*/
type AlarmSource = 'runLog' | 'selfCheck' | 'inspection' | 'sla' | 'ota' | 'dataDistribute'
type AlarmLevel = 'info' | 'warn' | 'critical'
type AlarmStatus = 'pending' | 'processing' | 'ignored' | 'workorder'
type BusinessLogType = 'operate' | 'system' | 'audit' | 'data'
interface AlarmRecord {
id: string
alarmCode: string
source: AlarmSource
level: AlarmLevel
deviceCode: string
deviceName: string
content: string
triggerTime: string
status: AlarmStatus
handler: string | null
}
interface BusinessLogRecord {
id: string
logType: BusinessLogType
siteName: string
module: string
content: string
operator: string
operateTime: string
}
interface AlarmParams {
keyword?: string
source?: string
level?: string
status?: string
logType?: string
siteName?: string
id?: string
ids?: string[]
type?: string
[key: string]: unknown
}
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({ code: '00000', msg, data })
const getParams = (params: RequestParameter): AlarmParams => (params ?? {}) as AlarmParams
const includes = (value: unknown, keyword: unknown): boolean => {
if (!keyword) return true
return String(value ?? '').toLowerCase().includes(String(keyword).toLowerCase())
}
const alarmList: AlarmRecord[] = [
{
id: 'alm-001', alarmCode: 'ALM20260703001', source: 'selfCheck', level: 'critical',
deviceCode: 'CAB-SH-001', deviceName: '1号净菜柜(上海浦东)',
content: '自检失败:压缩机停转 3 次/小时,疑似硬件故障',
triggerTime: '2026-07-03 09:24:11', status: 'pending', handler: null,
},
{
id: 'alm-002', alarmCode: 'ALM20260703002', source: 'ota', level: 'warn',
deviceCode: 'WATCH-BJ-014', deviceName: '14号健康手表(北京朝阳)',
content: 'OTA 升级失败:设备位于 v1.0.4,目标 v1.1.0,已回滚',
triggerTime: '2026-07-03 10:12:48', status: 'processing', handler: '李工程师',
},
{
id: 'alm-003', alarmCode: 'ALM20260702018', source: 'inspection', level: 'warn',
deviceCode: 'CAB-GZ-007', deviceName: '7号净菜柜(广州天河)',
content: '巡检异常:货道 5 错位,建议现场复位',
triggerTime: '2026-07-02 16:30:22', status: 'workorder', handler: '王工程师',
},
{
id: 'alm-004', alarmCode: 'ALM20260702015', source: 'sla', level: 'critical',
deviceCode: 'CHECK-SZ-003', deviceName: '3号体检一体机(深圳南山)',
content: 'SLA 超时:连续 3 次心跳未上报,已离线 25 分钟',
triggerTime: '2026-07-02 14:05:09', status: 'ignored', handler: '赵工程师',
},
{
id: 'alm-005', alarmCode: 'ALM20260702009', source: 'dataDistribute', level: 'warn',
deviceCode: 'CAB-SH-002', deviceName: '2号净菜柜(上海闵行)',
content: '数据分发失败:营养系统订阅通道 4 次重试失败',
triggerTime: '2026-07-02 11:48:33', status: 'workorder', handler: '李工程师',
},
{
id: 'alm-006', alarmCode: 'ALM20260701022', source: 'runLog', level: 'info',
deviceCode: 'BODYFAT-HZ-005', deviceName: '5号体脂仪(杭州西湖)',
content: '运行日志:电池电量低于 15%,建议安排更换',
triggerTime: '2026-07-01 17:20:00', status: 'ignored', handler: null,
},
{
id: 'alm-007', alarmCode: 'ALM20260701008', source: 'selfCheck', level: 'warn',
deviceCode: 'CAB-BJ-003', deviceName: '3号净菜柜(北京海淀)',
content: '自检告警:温度传感器读数漂移 2.4℃,建议校准',
triggerTime: '2026-07-01 09:15:42', status: 'workorder', handler: '王工程师',
},
{
id: 'alm-008', alarmCode: 'ALM20260630031', source: 'ota', level: 'critical',
deviceCode: 'CHECK-SH-008', deviceName: '8号体检一体机(上海徐汇)',
content: 'OTA 升级失败:固件签名校验未通过,已拦截',
triggerTime: '2026-06-30 22:08:55', status: 'processing', handler: '赵工程师',
},
]
const logList: BusinessLogRecord[] = [
{
id: 'log-001', logType: 'operate', siteName: '上海浦东 1号净菜柜',
module: '工单管理', content: '创建工单 WO20260703001(基于告警 ALM20260703001',
operator: '李工程师', operateTime: '2026-07-03 09:30:14',
},
{
id: 'log-002', logType: 'system', siteName: '全局',
module: '设备初始化', content: '设备 CAB-SH-001 完成证书烧录,进入待出库状态',
operator: '系统', operateTime: '2026-07-03 08:50:01',
},
{
id: 'log-003', logType: 'audit', siteName: '北京朝阳 14号健康手表',
module: '告警管理', content: '用户 [李工程师] 处理告警 ALM20260703002:标记为处理中',
operator: '李工程师', operateTime: '2026-07-03 10:15:00',
},
{
id: 'log-004', logType: 'data', siteName: '广州天河 7号净菜柜',
module: '数据分发', content: '推送销售数据到营养系统成功(耗时 220ms)',
operator: '系统', operateTime: '2026-07-02 18:00:11',
},
{
id: 'log-005', logType: 'operate', siteName: '深圳南山 3号体检一体机',
module: '版本管理', content: '固件版本 V3.0.0 灰度推送至 30% 设备',
operator: '赵工程师', operateTime: '2026-07-02 15:42:30',
},
{
id: 'log-006', logType: 'audit', siteName: '上海闵行 2号净菜柜',
module: '告警管理', content: '用户 [王工程师] 转工单 ALM20260702009',
operator: '王工程师', operateTime: '2026-07-02 14:00:55',
},
{
id: 'log-007', logType: 'system', siteName: '全局',
module: '设备监测', content: '触发巡检任务 INS20260702001(覆盖上海区域 12 台设备)',
operator: '系统', operateTime: '2026-07-02 09:00:00',
},
{
id: 'log-008', logType: 'operate', siteName: '杭州西湖 5号体脂仪',
module: '告警管理', content: '用户 [孙工程师] 忽略告警 ALM20260701022',
operator: '孙工程师', operateTime: '2026-07-01 17:35:20',
},
]
const listAlarm = (ctx: MockContext): ApiResponse<AlarmRecord[]> => {
const params = getParams(ctx.params)
const filtered = alarmList.filter((item) => {
if (params.keyword) {
const kw = params.keyword
if (!includes(item.alarmCode, kw) && !includes(item.deviceCode, kw) &&
!includes(item.deviceName, kw) && !includes(item.content, kw)) return false
}
if (params.source && item.source !== params.source) return false
if (params.level && item.level !== params.level) return false
if (params.status && item.status !== params.status) return false
return true
})
return ok('查询成功', filtered)
}
const statsAlarm = (): ApiResponse<{ todayNew: number; pending: number; critical: number; weekTrend: number }> => {
return ok('查询成功', { todayNew: 2, pending: 3, critical: 2, weekTrend: 11 })
}
const toWorkOrder = (ctx: MockContext): ApiResponse<null> => {
const params = getParams(ctx.params)
const target = alarmList.find((i) => i.id === String(params.id ?? ''))
if (!target) return ok('告警不存在')
target.status = 'workorder'
target.handler = '当前用户'
return ok('已转工单,可在工单管理中查看')
}
const ignoreAlarm = (ctx: MockContext): ApiResponse<null> => {
const params = getParams(ctx.params)
const target = alarmList.find((i) => i.id === String(params.id ?? ''))
if (!target) return ok('告警不存在')
target.status = 'ignored'
target.handler = '当前用户'
return ok('已忽略')
}
const batchHandle = (ctx: MockContext): ApiResponse<null> => {
const params = getParams(ctx.params)
const ids = Array.isArray(params.ids) ? params.ids.map(String) : []
const type = params.type ?? 'ignore'
alarmList.forEach((item) => {
if (!ids.includes(item.id)) return
if (type === 'workorder') {
item.status = 'workorder'
item.handler = '当前用户'
} else {
item.status = 'ignored'
item.handler = '当前用户'
}
})
return ok(type === 'workorder' ? '批量转工单成功' : '批量忽略成功')
}
const listBusinessLog = (ctx: MockContext): ApiResponse<BusinessLogRecord[]> => {
const params = getParams(ctx.params)
const filtered = logList.filter((item) => {
if (params.keyword) {
const kw = params.keyword
if (!includes(item.module, kw) && !includes(item.content, kw)) return false
}
if (params.logType && item.logType !== params.logType) return false
if (params.siteName && !includes(item.siteName, params.siteName)) return false
return true
})
return ok('查询成功', filtered)
}
registerMocks([
{ method: 'POST', url: '/admin/device/ops/alarm/list', handler: listAlarm },
{ method: 'POST', url: '/admin/device/ops/alarm/stats', handler: statsAlarm },
{ method: 'POST', url: '/admin/device/ops/alarm/to-work-order', handler: toWorkOrder },
{ method: 'POST', url: '/admin/device/ops/alarm/ignore', handler: ignoreAlarm },
{ method: 'POST', url: '/admin/device/ops/alarm/batch', handler: batchHandle },
{ method: 'POST', url: '/admin/device/ops/alarm/log-list', handler: listBusinessLog },
])
@@ -0,0 +1,203 @@
import { registerMocks } from '@axios/mockBus'
import type { ApiResponse, MockContext, RequestParameter } from '@axios'
/**
* 设备运维 / 系统配置 mock
* - 5 大分组的全局参数(推送通道 / 限流 / 巡检自检 / SLA / OTA
* - 编辑配置(写历史)
* - 变更历史列表
*/
type ConfigGroup = 'push' | 'rateLimit' | 'inspect' | 'sla' | 'ota'
type ConfigValueType = 'number' | 'string' | 'boolean' | 'json'
interface ConfigRecord {
id: string
group: ConfigGroup
configKey: string
configName: string
configValue: string
valueType: ConfigValueType
unit?: string
description: string
updater: string
updateTime: string
}
interface HistoryRecord {
id: string
configId: string
oldValue: string
newValue: string
operator: string
operateTime: string
reason: string
}
interface ConfigParams {
keyword?: string
group?: string
id?: string
configId?: string
configValue?: string
reason?: string
[key: string]: unknown
}
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({ code: '00000', msg, data })
const getParams = (params: RequestParameter): ConfigParams => (params ?? {}) as ConfigParams
const includes = (value: unknown, keyword: unknown): boolean => {
if (!keyword) return true
return String(value ?? '').toLowerCase().includes(String(keyword).toLowerCase())
}
const now = (): string => new Date().toISOString().replace('T', ' ').slice(0, 19)
const configList: ConfigRecord[] = [
// ===== 推送通道 =====
{
id: 'cfg-push-01', group: 'push', configKey: 'push.callback.timeout',
configName: '回调超时时间', configValue: '5', valueType: 'number', unit: '秒',
description: 'HTTP 回调等待响应的最大时长,超过则判定为推送失败并重试',
updater: '李工程师', updateTime: '2026-06-22 15:30:00',
},
{
id: 'cfg-push-02', group: 'push', configKey: 'push.callback.retry',
configName: '回调重试次数', configValue: '4', valueType: 'number', unit: '次',
description: '推送失败后最大重试次数(指数退避:5s / 30s / 2min / 10min',
updater: '李工程师', updateTime: '2026-06-22 15:30:00',
},
{
id: 'cfg-push-03', group: 'push', configKey: 'push.callback.concurrency',
configName: '回调并发数', configValue: '20', valueType: 'number', unit: '路',
description: '推送通道全局并发请求数,超过将排队等待',
updater: '李工程师', updateTime: '2026-06-22 15:30:00',
},
// ===== 限流 / 黑白名单 =====
{
id: 'cfg-rl-01', group: 'rateLimit', configKey: 'rateLimit.qps',
configName: '全局限流 QPS', configValue: '500', valueType: 'number', unit: '次/秒',
description: '租户调用设备数据查询接口的全局 QPS 上限',
updater: '赵工程师', updateTime: '2026-06-15 10:20:00',
},
{
id: 'cfg-rl-02', group: 'rateLimit', configKey: 'rateLimit.ip.whitelist',
configName: 'IP 白名单', configValue: '10.0.0.0/8,172.16.0.0/12', valueType: 'string',
description: '设备数据查询接口的 IP 白名单(CIDR 逗号分隔)',
updater: '赵工程师', updateTime: '2026-06-15 10:20:00',
},
// ===== 巡检 / 自检 =====
{
id: 'cfg-ins-01', group: 'inspect', configKey: 'inspection.defaultPeriod',
configName: '巡检默认周期', configValue: '24', valueType: 'number', unit: '小时',
description: '新建设备的默认巡检周期,超过未上报视为巡检异常',
updater: '王工程师', updateTime: '2026-06-10 14:00:00',
},
{
id: 'cfg-ins-02', group: 'inspect', configKey: 'selfCheck.failThreshold',
configName: '自检告警阈值', configValue: '2', valueType: 'number', unit: '次',
description: '设备连续自检失败次数达到阈值即产生告警',
updater: '王工程师', updateTime: '2026-06-10 14:00:00',
},
// ===== SLA =====
{
id: 'cfg-sla-01', group: 'sla', configKey: 'sla.urgent.responseMinutes',
configName: '紧急工单响应时长', configValue: '30', valueType: 'number', unit: '分钟',
description: '紧急级别工单必须在此时长内有人接单',
updater: '孙工程师', updateTime: '2026-05-28 09:00:00',
},
{
id: 'cfg-sla-02', group: 'sla', configKey: 'sla.urgent.resolveHours',
configName: '紧急工单处理时长', configValue: '4', valueType: 'number', unit: '小时',
description: '紧急级别工单从接单到完成的处理时长上限',
updater: '孙工程师', updateTime: '2026-05-28 09:00:00',
},
// ===== OTA =====
{
id: 'cfg-ota-01', group: 'ota', configKey: 'ota.defaultGrayPercent',
configName: '默认灰度比例', configValue: '10', valueType: 'number', unit: '%',
description: '新建版本默认的灰度推送比例,可后续逐级提升',
updater: '李工程师', updateTime: '2026-06-25 17:00:00',
},
{
id: 'cfg-ota-02', group: 'ota', configKey: 'ota.rollback.failRate',
configName: 'OTA 自动回滚阈值', configValue: '5', valueType: 'number', unit: '%',
description: '灰度阶段失败率超过此阈值将自动触发回滚',
updater: '李工程师', updateTime: '2026-06-25 17:00:00',
},
]
const historyList: HistoryRecord[] = [
{
id: 'his-001', configId: 'cfg-push-01', oldValue: '3', newValue: '5',
operator: '李工程师', operateTime: '2026-06-22 15:30:00',
reason: '部分下游系统回调耗时较高,超时时间从 3 秒调整为 5 秒以降低误报',
},
{
id: 'his-002', configId: 'cfg-push-02', oldValue: '3', newValue: '4',
operator: '李工程师', operateTime: '2026-06-22 15:30:00',
reason: '配合超时调整,将重试次数从 3 次提升到 4 次以提高送达率',
},
{
id: 'his-003', configId: 'cfg-ota-01', oldValue: '5', newValue: '10',
operator: '李工程师', operateTime: '2026-06-25 17:00:00',
reason: 'V1.2.0 灰度验证通过,将默认灰度比例从 5% 提升至 10% 加快推广',
},
{
id: 'his-004', configId: 'cfg-sla-01', oldValue: '60', newValue: '30',
operator: '孙工程师', operateTime: '2026-05-28 09:00:00',
reason: '紧急工单响应时长从 60 分钟缩短到 30 分钟,对齐 SLA 协议升级',
},
]
const listConfig = (ctx: MockContext): ApiResponse<ConfigRecord[]> => {
const params = getParams(ctx.params)
const filtered = configList.filter((item) => {
if (params.keyword) {
const kw = params.keyword
if (!includes(item.configKey, kw) && !includes(item.configName, kw) && !includes(item.description, kw)) return false
}
if (params.group && item.group !== params.group) return false
return true
})
return ok('查询成功', filtered)
}
const infoConfig = (ctx: MockContext): ApiResponse<ConfigRecord | null> => {
const params = getParams(ctx.params)
return ok('查询成功', configList.find((i) => i.id === params.id) ?? null)
}
const updateConfig = (ctx: MockContext): ApiResponse<null> => {
const params = getParams(ctx.params)
const target = configList.find((i) => i.id === String(params.id ?? ''))
if (!target) return ok('配置不存在')
const oldValue = target.configValue
const newValue = String(params.configValue ?? '')
if (oldValue === newValue) return ok('配置值未变化')
target.configValue = newValue
target.updater = '当前用户'
target.updateTime = now()
historyList.unshift({
id: `his-${String(historyList.length + 1).padStart(3, '0')}`,
configId: target.id,
oldValue,
newValue,
operator: '当前用户',
operateTime: now(),
reason: String(params.reason ?? ''),
})
return ok('配置已更新')
}
const listHistory = (ctx: MockContext): ApiResponse<HistoryRecord[]> => {
const params = getParams(ctx.params)
const filtered = historyList.filter((i) => i.configId === String(params.configId ?? ''))
return ok('查询成功', filtered)
}
registerMocks([
{ method: 'POST', url: '/admin/device/ops/config/list', handler: listConfig },
{ method: 'POST', url: '/admin/device/ops/config/info', handler: infoConfig },
{ method: 'POST', url: '/admin/device/ops/config/update', handler: updateConfig },
{ method: 'POST', url: '/admin/device/ops/config/history', handler: listHistory },
])
@@ -0,0 +1,287 @@
import { registerMocks } from '@axios/mockBus'
import type { ApiResponse, MockContext, RequestParameter } from '@axios'
/**
* 设备运维 / 设备初始化 mock
* - 出库装机前的激活 / 烧录证书 / 预置场所配置 / 固件基线
*/
interface InitRecord {
id: string
taskCode: string
deviceSn: string
deviceName: string
deviceCategory: 'weightScale' | 'bodyFat' | 'watch' | 'healthCheck' | 'cabinet' | 'other'
targetSite: string
baselineFw: string
baselineApp: string
activateCode: string
status: 'pending' | 'processing' | 'success' | 'failed'
operator: string
initTime: string | null
failReason?: string
outboundOrderNo?: string
createTime: string
certPayload: string
siteConfig: string
strategy: string
steps: { id: string; stepName: string; status: 'pending' | 'processing' | 'success' | 'failed'; costSeconds: number; finishTime: string | null; remark?: string }[]
}
interface InitParams {
keyword?: string
deviceSn?: string
deviceCategory?: string
status?: string
id?: string
[key: string]: unknown
}
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({ code: '00000', msg, data })
const getParams = (params: RequestParameter): InitParams => (params ?? {}) as InitParams
const includes = (value: unknown, keyword: unknown): boolean => {
if (!keyword) return true
return String(value ?? '').toLowerCase().includes(String(keyword).toLowerCase())
}
const initList: InitRecord[] = [
{
id: 'init-001', taskCode: 'INIT-20260620-001', deviceSn: 'CAB-A1-0001', deviceName: '总部食堂货柜A1',
deviceCategory: 'cabinet', targetSite: '长庆总部一食堂', baselineFw: 'V1.2.0', baselineApp: 'V1.0.5',
activateCode: 'ACT-20260620-A1B2C3D4', status: 'success', operator: '李工程师', initTime: '2026-06-20 14:30:00',
outboundOrderNo: 'OUT-20260620-001', createTime: '2026-06-20 13:45:12',
certPayload: '-----BEGIN CERTIFICATE-----\nMIIBxTCCAW8CFA==\n-----END CERTIFICATE-----',
siteConfig: '{"siteCode":"CQ-HQ-CANTEEN-01","mqTopic":"device.cabinet","heartbeat":30}',
strategy: '标准烧录策略:证书 → 场所配置 → 固件 → APP → 自检',
steps: [
{ id: 's1', stepName: '设备激活与证书烧录', status: 'success', costSeconds: 12, finishTime: '2026-06-20 14:25:30' },
{ id: 's2', stepName: '场所配置预置', status: 'success', costSeconds: 4, finishTime: '2026-06-20 14:25:34' },
{ id: 's3', stepName: '固件基线烧录', status: 'success', costSeconds: 180, finishTime: '2026-06-20 14:28:34' },
{ id: 's4', stepName: 'APP 基线烧录', status: 'success', costSeconds: 90, finishTime: '2026-06-20 14:30:04' },
],
},
{
id: 'init-002', taskCode: 'INIT-20260622-002', deviceSn: 'WAT-W1-0001', deviceName: '健康站01手表',
deviceCategory: 'watch', targetSite: '健康园区体检站', baselineFw: 'V2.0.1', baselineApp: 'V1.3.0',
activateCode: 'ACT-20260622-E5F6G7H8', status: 'failed', operator: '王工程师', initTime: null,
failReason: '固件烧录超时,设备未在 180s 内完成 OTA', outboundOrderNo: 'OUT-20260622-002', createTime: '2026-06-22 09:10:00',
certPayload: '-----BEGIN CERTIFICATE-----\nMIIBxTCCAW8CFA==\n-----END CERTIFICATE-----',
siteConfig: '{"siteCode":"HP-CHKUP-01","mqTopic":"device.watch","heartbeat":60}',
strategy: '标准烧录策略:证书 → 场所配置 → 固件 → APP → 自检',
steps: [
{ id: 's1', stepName: '设备激活与证书烧录', status: 'success', costSeconds: 10, finishTime: '2026-06-22 09:15:00' },
{ id: 's2', stepName: '场所配置预置', status: 'success', costSeconds: 3, finishTime: '2026-06-22 09:15:03' },
{ id: 's3', stepName: '固件基线烧录', status: 'failed', costSeconds: 180, finishTime: null, remark: 'OTA 超时' },
],
},
{
id: 'init-003', taskCode: 'INIT-20260624-003', deviceSn: 'CHK-M1-0001', deviceName: '体检站一体机',
deviceCategory: 'healthCheck', targetSite: '东区体检站', baselineFw: 'V3.0.0', baselineApp: 'V2.0.0',
activateCode: 'ACT-20260624-I9J0K1L2', status: 'processing', operator: '赵工程师', initTime: null,
outboundOrderNo: 'OUT-20260624-003', createTime: '2026-06-24 10:00:00',
certPayload: '-----BEGIN CERTIFICATE-----\nMIIBxTCCAW8CFA==\n-----END CERTIFICATE-----',
siteConfig: '{"siteCode":"HP-CHKUP-01","mqTopic":"device.healthcheck","heartbeat":120}',
strategy: '标准烧录策略:证书 → 场所配置 → 固件 → APP → 自检',
steps: [
{ id: 's1', stepName: '设备激活与证书烧录', status: 'success', costSeconds: 11, finishTime: '2026-06-24 10:05:00' },
{ id: 's2', stepName: '场所配置预置', status: 'success', costSeconds: 4, finishTime: '2026-06-24 10:05:04' },
{ id: 's3', stepName: '固件基线烧录', status: 'processing', costSeconds: 90, finishTime: null, remark: '升级中…' },
],
},
{
id: 'init-004', taskCode: 'INIT-20260625-004', deviceSn: 'FAT-INBODY-0001', deviceName: '体检站体脂秤',
deviceCategory: 'bodyFat', targetSite: '东区体检站', baselineFw: 'V1.5.0', baselineApp: 'V1.2.0',
activateCode: 'ACT-20260625-M3N4O5P6', status: 'success', operator: '孙工程师', initTime: '2026-06-25 16:00:00',
createTime: '2026-06-25 15:30:00',
certPayload: '-----BEGIN CERTIFICATE-----\nMIIBxTCCAW8CFA==\n-----END CERTIFICATE-----',
siteConfig: '{"siteCode":"HP-CHKUP-01","mqTopic":"device.bodyfat","heartbeat":60}',
strategy: '标准烧录策略:证书 → 场所配置 → 固件 → APP → 自检',
steps: [
{ id: 's1', stepName: '设备激活与证书烧录', status: 'success', costSeconds: 9, finishTime: '2026-06-25 15:55:00' },
{ id: 's2', stepName: '场所配置预置', status: 'success', costSeconds: 3, finishTime: '2026-06-25 15:55:03' },
{ id: 's3', stepName: '固件基线烧录', status: 'success', costSeconds: 150, finishTime: '2026-06-25 15:57:33' },
{ id: 's4', stepName: 'APP 基线烧录', status: 'success', costSeconds: 80, finishTime: '2026-06-25 15:58:53' },
],
},
{
id: 'init-005', taskCode: 'INIT-20260626-005', deviceSn: 'SCL-W-0001', deviceName: '健康站体重秤',
deviceCategory: 'weightScale', targetSite: '健康园区体检站', baselineFw: 'V1.0.3', baselineApp: 'V1.0.0',
activateCode: 'ACT-20260626-Q7R8S9T0', status: 'pending', operator: '—', initTime: null,
createTime: '2026-06-26 08:30:00',
certPayload: '—', siteConfig: '{}',
strategy: '标准烧录策略:证书 → 场所配置 → 固件 → APP → 自检',
steps: [],
},
{
id: 'init-006', taskCode: 'INIT-20260627-006', deviceSn: 'FIT-S1-0001', deviceName: '健身房顿米车',
deviceCategory: 'other', targetSite: '园区员工健身房', baselineFw: 'V2.1.0', baselineApp: 'V1.0.5',
activateCode: 'ACT-20260627-U1V2W3X4', status: 'success', operator: '李工程师', initTime: '2026-06-27 11:00:00',
createTime: '2026-06-27 10:30:00',
certPayload: '-----BEGIN CERTIFICATE-----\nMIIBxTCCAW8CFA==\n-----END CERTIFICATE-----',
siteConfig: '{"siteCode":"HP-GYM-01","mqTopic":"device.fitness","heartbeat":120}',
strategy: '标准烧录策略:证书 → 场所配置 → 固件 → APP → 自检',
steps: [
{ id: 's1', stepName: '设备激活与证书烧录', status: 'success', costSeconds: 12, finishTime: '2026-06-27 10:50:00' },
{ id: 's2', stepName: '场所配置预置', status: 'success', costSeconds: 4, finishTime: '2026-06-27 10:50:04' },
{ id: 's3', stepName: '固件基线烧录', status: 'success', costSeconds: 180, finishTime: '2026-06-27 10:53:04' },
{ id: 's4', stepName: 'APP 基线烧录', status: 'success', costSeconds: 90, finishTime: '2026-06-27 10:54:34' },
],
},
{
id: 'init-007', taskCode: 'INIT-20260628-007', deviceSn: 'WAT-W1-0002', deviceName: '健康站02手表',
deviceCategory: 'watch', targetSite: '总部体重测量点 A', baselineFw: 'V2.0.1', baselineApp: 'V1.3.0',
activateCode: 'ACT-20260628-Y5Z6A7B8', status: 'failed', operator: '王工程师', initTime: null,
failReason: '证书签名校验失败,请检查激活码', createTime: '2026-06-28 14:00:00',
certPayload: '—',
siteConfig: '{"siteCode":"CQ-HQ-MEASURE-A","mqTopic":"device.watch"}',
strategy: '标准烧录策略:证书 → 场所配置 → 固件 → APP → 自检',
steps: [
{ id: 's1', stepName: '设备激活与证书烧录', status: 'failed', costSeconds: 5, finishTime: null, remark: '签名校验失败' },
],
},
{
id: 'init-008', taskCode: 'INIT-20260629-008', deviceSn: 'CHK-M1-0002', deviceName: '社区站一体机',
deviceCategory: 'healthCheck', targetSite: '采油一厂社区站', baselineFw: 'V3.0.0', baselineApp: 'V2.0.0',
activateCode: 'ACT-20260629-C9D0E1F2', status: 'success', operator: '赵工程师', initTime: '2026-06-29 17:30:00',
createTime: '2026-06-29 16:45:00',
certPayload: '-----BEGIN CERTIFICATE-----\nMIIBxTCCAW8CFA==\n-----END CERTIFICATE-----',
siteConfig: '{"siteCode":"OILF-01-COMMUNITY","mqTopic":"device.healthcheck"}',
strategy: '标准烧录策略:证书 → 场所配置 → 固件 → APP → 自检',
steps: [
{ id: 's1', stepName: '设备激活与证书烧录', status: 'success', costSeconds: 11, finishTime: '2026-06-29 17:20:00' },
{ id: 's2', stepName: '场所配置预置', status: 'success', costSeconds: 4, finishTime: '2026-06-29 17:20:04' },
{ id: 's3', stepName: '固件基线烧录', status: 'success', costSeconds: 160, finishTime: '2026-06-29 17:22:44' },
{ id: 's4', stepName: 'APP 基线烧录', status: 'success', costSeconds: 85, finishTime: '2026-06-29 17:24:09' },
],
},
]
const matchKeyword = (item: InitRecord, keyword?: string): boolean => {
if (!keyword) return true
return includes(item.taskCode, keyword) || includes(item.deviceSn, keyword) || includes(item.deviceName, keyword)
}
const listInit = (ctx: MockContext): ApiResponse<InitRecord[]> => {
const params = getParams(ctx.params)
const filtered = initList.filter((item) => {
if (!matchKeyword(item, params.keyword)) return false
if (params.deviceSn && !includes(item.deviceSn, params.deviceSn)) return false
if (params.deviceCategory && item.deviceCategory !== params.deviceCategory) return false
if (params.status && item.status !== params.status) return false
return true
})
return ok('查询成功', filtered)
}
const infoInit = (ctx: MockContext): ApiResponse<InitRecord | null> => {
const params = getParams(ctx.params)
const target = initList.find((item) => item.id === params.id)
return ok('查询成功', target ?? null)
}
/** 编辑回填版(剔除 certPayload / steps 等大字段) */
const infoForEdit = (ctx: MockContext): ApiResponse<Partial<InitRecord> | null> => {
const params = getParams(ctx.params)
const target = initList.find((item) => item.id === params.id)
if (!target) return ok('查询成功', null)
return ok('查询成功', {
id: target.id,
taskCode: target.taskCode,
deviceSn: target.deviceSn,
deviceName: target.deviceName,
deviceCategory: target.deviceCategory,
targetSite: target.targetSite,
baselineFw: target.baselineFw,
baselineApp: target.baselineApp,
activateCode: target.activateCode,
status: target.status,
outboundOrderNo: target.outboundOrderNo,
})
}
const genTaskCode = (): string => {
const ts = new Date()
const ymd = `${ts.getFullYear()}${String(ts.getMonth() + 1).padStart(2, '0')}${String(ts.getDate()).padStart(2, '0')}`
const seq = String(initList.length + 1).padStart(3, '0')
return `INIT-${ymd}-${seq}`
}
const genActivateCode = (): string => {
const random = Math.random().toString(36).slice(2, 10).toUpperCase()
const ts = Date.now().toString(36).toUpperCase().slice(-6)
return `ACT-${ts}-${random}`
}
const addInit = (ctx: MockContext): ApiResponse<null> => {
const params = getParams(ctx.params)
const id = `init-${String(initList.length + 1).padStart(3, '0')}`
const now = new Date().toISOString().replace('T', ' ').slice(0, 19)
const record: InitRecord = {
id,
taskCode: genTaskCode(),
deviceSn: String(params.deviceSn ?? ''),
deviceName: String(params.deviceName ?? ''),
deviceCategory: params.deviceCategory as InitRecord['deviceCategory'],
targetSite: String(params.targetSite ?? ''),
baselineFw: String(params.baselineFw ?? 'V1.0.0'),
baselineApp: String(params.baselineApp ?? 'V1.0.0'),
activateCode: String(params.activateCode ?? genActivateCode()),
status: (params.status as InitRecord['status']) ?? 'pending',
operator: '当前用户',
initTime: null,
outboundOrderNo: params.outboundOrderNo ? String(params.outboundOrderNo) : undefined,
createTime: now,
certPayload: '—',
siteConfig: '{}',
strategy: '标准烧录策略:证书 → 场所配置 → 固件 → APP → 自检',
steps: [],
}
initList.unshift(record)
return ok('新增成功')
}
const updateInit = (ctx: MockContext): ApiResponse<null> => {
const params = getParams(ctx.params)
const id = String(params.id ?? '')
const target = initList.find((item) => item.id === id)
if (!target) return ok('记录不存在')
if (params.deviceSn !== undefined) target.deviceSn = String(params.deviceSn)
if (params.deviceName !== undefined) target.deviceName = String(params.deviceName)
if (params.deviceCategory !== undefined) target.deviceCategory = params.deviceCategory as InitRecord['deviceCategory']
if (params.targetSite !== undefined) target.targetSite = String(params.targetSite)
if (params.baselineFw !== undefined) target.baselineFw = String(params.baselineFw)
if (params.baselineApp !== undefined) target.baselineApp = String(params.baselineApp)
if (params.activateCode !== undefined) target.activateCode = String(params.activateCode)
if (params.outboundOrderNo !== undefined) target.outboundOrderNo = String(params.outboundOrderNo)
return ok('更新成功')
}
const deleteInit = (ctx: MockContext): ApiResponse<null> => {
const params = getParams(ctx.params)
const id = String(params.id ?? '')
const idx = initList.findIndex((item) => item.id === id)
if (idx >= 0) initList.splice(idx, 1)
return ok('删除成功')
}
/** 重试:把失败步骤重新置为 pending,整条任务进入 processing */
const retryInit = (ctx: MockContext): ApiResponse<null> => {
const params = getParams(ctx.params)
const id = String(params.id ?? '')
const target = initList.find((item) => item.id === id)
if (!target) return ok('记录不存在')
target.status = 'processing'
target.failReason = undefined
target.steps = target.steps.map((s) => s.status === 'failed' ? { ...s, status: 'processing', finishTime: null, remark: '重试中…' } : s)
return ok('已触发重新初始化')
}
registerMocks([
{ method: 'POST', url: '/admin/device/ops/init/list', handler: listInit },
{ method: 'POST', url: '/admin/device/ops/init/info', handler: infoInit },
{ method: 'POST', url: '/admin/device/ops/init/info-for-edit', handler: infoForEdit },
{ method: 'POST', url: '/admin/device/ops/init/add', handler: addInit },
{ method: 'POST', url: '/admin/device/ops/init/update', handler: updateInit },
{ method: 'POST', url: '/admin/device/ops/init/delete', handler: deleteInit },
{ method: 'POST', url: '/admin/device/ops/init/retry', handler: retryInit },
])
@@ -0,0 +1,228 @@
import { registerMocks } from '@axios/mockBus'
import type { ApiResponse, MockContext, RequestParameter } from '@axios'
/**
* 设备运维 / 设备监测 mock
* - 主列表:设备运行状态总览
* - 详情 6 Tab:实施/自检/巡检/运行日志/更新记录/维修记录
*/
interface MonitorRecord {
id: string
deviceCode: string
deviceName: string
deviceCategory: 'catering' | 'body' | 'wearable' | 'fitness' | 'monitor'
tenantName: string
siteName: string
runStatus: 'online' | 'offline' | 'repairing' | 'scrapped'
firmwareVersion: string
lastHeartbeat: string
onlineRate: number
alertCount: number
dataTransit: boolean
onlineTime: string
isIot: boolean
implements: { id: string; implementType: 'newInstall' | 'migration' | 'removal' | 'reset'; implementer: string; implementTime: string; signedBy: string; remark: string }[]
selfChecks: { id: string; checkType: 'powerOn' | 'scheduled' | 'remote'; checkItems: { name: string; category: 'hardware' | 'network' | 'sensor' | 'firmware'; result: 'pass' | 'fail'; detail?: string }[]; result: 'pass' | 'fail'; checkTime: string; triggerWorkOrder?: string }[]
inspections: { id: string; planName: string; inspector: string; inspectTime: string; result: 'pass' | 'fail'; itemsCount: number; exceptionCount: number; handleType?: 'close' | 'workorder'; workOrderNo?: string }[]
runLogs: { id: string; eventType: string; content: string; occurTime: string; level: 'info' | 'warn' | 'error' }[]
updates: { id: string; targetVersion: string; beforeVersion: string; pushTask: string; result: 'success' | 'fail' | 'rollback'; retryCount: number; finishTime: string }[]
repairs: { id: string; workOrderNo: string; faultType: string; repairMethod: string; repairer: string; workHours: number; partsConsumed?: string; finishTime: string }[]
}
interface MonitorParams {
keyword?: string
deviceCategory?: string
runStatus?: string
tenantName?: string
siteName?: string
hasAlert?: boolean
id?: string
type?: string
[key: string]: unknown
}
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({ code: '00000', msg, data })
const getParams = (params: RequestParameter): MonitorParams => (params ?? {}) as MonitorParams
const includes = (value: unknown, keyword: unknown): boolean => {
if (!keyword) return true
return String(value ?? '').toLowerCase().includes(String(keyword).toLowerCase())
}
const monitorList: MonitorRecord[] = [
{
id: 'mon-001', deviceCode: 'CAB-A1-0001', deviceName: '总部食堂货柜A1', deviceCategory: 'catering',
tenantName: '长庆能源总部食堂', siteName: '长庆总部一食堂', runStatus: 'online', firmwareVersion: 'V1.2.0',
lastHeartbeat: '2026-07-02 14:50:12', onlineRate: 99, alertCount: 0, dataTransit: false, onlineTime: '2026-06-20 14:30:00', isIot: true,
implements: [
{ id: 'i1', implementType: 'newInstall', implementer: '李工程师', implementTime: '2026-06-20 14:00:00', signedBy: '王经理', remark: '首次装机,A1 货架第二层' },
],
selfChecks: [
{ id: 'sc1', checkType: 'powerOn', checkItems: [{ name: '网络', category: 'network', result: 'pass' }, { name: '主控', category: 'hardware', result: 'pass' }], result: 'pass', checkTime: '2026-07-02 08:00:00' },
{ id: 'sc2', checkType: 'scheduled', checkItems: [{ name: '温度传感器', category: 'sensor', result: 'fail', detail: '读数异常' }], result: 'fail', checkTime: '2026-06-30 12:00:00', triggerWorkOrder: 'WO-20260701-001' },
],
inspections: [
{ id: 'ins1', planName: '食堂周巡检', inspector: '赵工程师', inspectTime: '2026-06-28 10:00:00', result: 'pass', itemsCount: 12, exceptionCount: 0, handleType: 'close' },
],
runLogs: [
{ id: 'rl1', eventType: '心跳', content: '心跳上报正常', occurTime: '2026-07-02 14:50:12', level: 'info' },
{ id: 'rl2', eventType: '开机', content: '设备开机', occurTime: '2026-07-02 06:00:00', level: 'info' },
],
updates: [
{ id: 'u1', targetVersion: 'V1.2.0', beforeVersion: 'V1.1.5', pushTask: 'OTA-20260625-003', result: 'success', retryCount: 0, finishTime: '2026-06-25 03:15:00' },
],
repairs: [
{ id: 'r1', workOrderNo: 'WO-20260620-001', faultType: '锁具故障', repairMethod: '更换锁芯', repairer: '李工程师', workHours: 1.5, partsConsumed: '锁芯×1', finishTime: '2026-06-20 17:30:00' },
],
},
{
id: 'mon-002', deviceCode: 'WAT-W1-0001', deviceName: '健康站01手表', deviceCategory: 'wearable',
tenantName: '健康管理机构', siteName: '健康园区体检站', runStatus: 'repairing', firmwareVersion: 'V2.0.0',
lastHeartbeat: '2026-07-02 13:20:00', onlineRate: 78, alertCount: 2, dataTransit: true, onlineTime: '2026-06-22 09:30:00', isIot: true,
implements: [
{ id: 'i1', implementType: 'newInstall', implementer: '王工程师', implementTime: '2026-06-22 09:30:00', signedBy: '张医生', remark: '绑定用户 u-001' },
],
selfChecks: [
{ id: 'sc1', checkType: 'scheduled', checkItems: [{ name: '心率传感器', category: 'sensor', result: 'fail', detail: '采样异常' }], result: 'fail', checkTime: '2026-07-01 12:00:00', triggerWorkOrder: 'WO-20260622-002' },
],
inspections: [],
runLogs: [
{ id: 'rl1', eventType: '离线', content: '设备离线 30 分钟', occurTime: '2026-07-02 13:00:00', level: 'warn' },
{ id: 'rl2', eventType: '故障', content: '心率数据采集异常', occurTime: '2026-07-02 12:50:00', level: 'error' },
],
updates: [
{ id: 'u1', targetVersion: 'V2.0.0', beforeVersion: 'V1.9.0', pushTask: 'OTA-20260620-002', result: 'success', retryCount: 0, finishTime: '2026-06-20 22:00:00' },
],
repairs: [],
},
{
id: 'mon-003', deviceCode: 'CHK-M1-0001', deviceName: '体检站一体机', deviceCategory: 'monitor',
tenantName: '健康管理机构', siteName: '东区体检站', runStatus: 'offline', firmwareVersion: 'V3.0.0',
lastHeartbeat: '2026-07-02 02:00:00', onlineRate: 45, alertCount: 3, dataTransit: true, onlineTime: '2026-06-24 10:30:00', isIot: true,
implements: [
{ id: 'i1', implementType: 'newInstall', implementer: '赵工程师', implementTime: '2026-06-24 10:30:00', signedBy: '张医生', remark: '东区体检站主力机' },
],
selfChecks: [],
inspections: [],
runLogs: [
{ id: 'rl1', eventType: '故障', content: '设备完全无响应', occurTime: '2026-07-02 02:00:00', level: 'error' },
],
updates: [],
repairs: [],
},
{
id: 'mon-004', deviceCode: 'FAT-INBODY-0001', deviceName: '体检站体脂秤', deviceCategory: 'body',
tenantName: '健康管理机构', siteName: '东区体检站', runStatus: 'online', firmwareVersion: 'V1.5.0',
lastHeartbeat: '2026-07-02 14:48:00', onlineRate: 96, alertCount: 0, dataTransit: true, onlineTime: '2026-06-25 16:00:00', isIot: true,
implements: [{ id: 'i1', implementType: 'newInstall', implementer: '孙工程师', implementTime: '2026-06-25 16:00:00', signedBy: '张医生', remark: '—' }],
selfChecks: [{ id: 'sc1', checkType: 'powerOn', checkItems: [{ name: '网络', category: 'network', result: 'pass' }], result: 'pass', checkTime: '2026-07-02 08:00:00' }],
inspections: [{ id: 'ins1', planName: '体检站周巡检', inspector: '王工程师', inspectTime: '2026-06-29 14:00:00', result: 'pass', itemsCount: 8, exceptionCount: 0, handleType: 'close' }],
runLogs: [{ id: 'rl1', eventType: '心跳', content: '心跳正常', occurTime: '2026-07-02 14:48:00', level: 'info' }],
updates: [],
repairs: [],
},
{
id: 'mon-005', deviceCode: 'SCL-W-0001', deviceName: '健康站体重秤', deviceCategory: 'body',
tenantName: '健康管理机构', siteName: '健康园区体检站', runStatus: 'online', firmwareVersion: 'V1.0.3',
lastHeartbeat: '2026-07-02 14:45:00', onlineRate: 92, alertCount: 1, dataTransit: true, onlineTime: '2026-06-26 09:00:00', isIot: true,
implements: [{ id: 'i1', implementType: 'newInstall', implementer: '李工程师', implementTime: '2026-06-26 09:00:00', signedBy: '张医生', remark: '—' }],
selfChecks: [],
inspections: [],
runLogs: [{ id: 'rl1', eventType: '告警', content: '蓝牙连接频繁断开', occurTime: '2026-07-01 10:00:00', level: 'warn' }],
updates: [],
repairs: [{ id: 'r1', workOrderNo: 'WO-20260629-008', faultType: '蓝牙异常', repairMethod: '更换蓝牙模块', repairer: '王工程师', workHours: 1.0, partsConsumed: '蓝牙模块×1', finishTime: '2026-06-30 16:00:00' }],
},
{
id: 'mon-006', deviceCode: 'FIT-S1-0001', deviceName: '健身房顿米车', deviceCategory: 'fitness',
tenantName: '智养餐饮集团', siteName: '园区员工健身房', runStatus: 'repairing', firmwareVersion: 'V2.1.0',
lastHeartbeat: '2026-07-02 12:00:00', onlineRate: 65, alertCount: 1, dataTransit: true, onlineTime: '2026-06-27 11:00:00', isIot: true,
implements: [{ id: 'i1', implementType: 'newInstall', implementer: '李工程师', implementTime: '2026-06-27 11:00:00', signedBy: '吴教练', remark: '—' }],
selfChecks: [],
inspections: [],
runLogs: [{ id: 'rl1', eventType: '故障', content: '阻力调节失灵', occurTime: '2026-06-27 09:00:00', level: 'error' }],
updates: [],
repairs: [],
},
{
id: 'mon-007', deviceCode: 'SCR-P1-0001', deviceName: '总部大屏', deviceCategory: 'catering',
tenantName: '智养餐饮集团', siteName: '长庆总部一食堂', runStatus: 'online', firmwareVersion: 'V2.5.0',
lastHeartbeat: '2026-07-02 14:50:00', onlineRate: 99, alertCount: 0, dataTransit: false, onlineTime: '2026-05-01 10:00:00', isIot: false,
implements: [{ id: 'i1', implementType: 'newInstall', implementer: '赵工程师', implementTime: '2026-05-01 10:00:00', signedBy: '王经理', remark: '主餐厅入口' }],
selfChecks: [],
inspections: [{ id: 'ins1', planName: '食堂周巡检', inspector: '赵工程师', inspectTime: '2026-06-28 10:30:00', result: 'pass', itemsCount: 6, exceptionCount: 0, handleType: 'close' }],
runLogs: [{ id: 'rl1', eventType: '心跳', content: '心跳正常', occurTime: '2026-07-02 14:50:00', level: 'info' }],
updates: [],
repairs: [],
},
{
id: 'mon-008', deviceCode: 'SCL-X1-0001', deviceName: '总部入库秤', deviceCategory: 'catering',
tenantName: '智养餐饮集团', siteName: '长庆总部一食堂', runStatus: 'online', firmwareVersion: 'V1.3.0',
lastHeartbeat: '2026-07-02 14:49:30', onlineRate: 98, alertCount: 0, dataTransit: false, onlineTime: '2026-04-15 09:00:00', isIot: true,
implements: [{ id: 'i1', implementType: 'newInstall', implementer: '李工程师', implementTime: '2026-04-15 09:00:00', signedBy: '王经理', remark: '后厨入库区' }],
selfChecks: [{ id: 'sc1', checkType: 'scheduled', checkItems: [{ name: '称重校准', category: 'sensor', result: 'pass' }], result: 'pass', checkTime: '2026-07-02 06:00:00' }],
inspections: [],
runLogs: [],
updates: [{ id: 'u1', targetVersion: 'V1.3.0', beforeVersion: 'V1.2.0', pushTask: 'OTA-20260620-001', result: 'success', retryCount: 0, finishTime: '2026-06-20 22:00:00' }],
repairs: [{ id: 'r1', workOrderNo: 'WO-20260625-004', faultType: '校准异常', repairMethod: '重新校准', repairer: '赵工程师', workHours: 0.5, finishTime: '2026-06-26 10:00:00' }],
},
]
const matchKeyword = (item: MonitorRecord, keyword?: string): boolean => {
if (!keyword) return true
return includes(item.deviceCode, keyword) || includes(item.deviceName, keyword)
}
const listMonitor = (ctx: MockContext): ApiResponse<MonitorRecord[]> => {
const params = getParams(ctx.params)
const filtered = monitorList.filter((item) => {
if (!matchKeyword(item, params.keyword)) return false
if (params.deviceCategory && item.deviceCategory !== params.deviceCategory) return false
if (params.runStatus && item.runStatus !== params.runStatus) return false
if (params.tenantName && !includes(item.tenantName, params.tenantName)) return false
if (params.siteName && !includes(item.siteName, params.siteName)) return false
if (params.hasAlert && item.alertCount === 0) return false
return true
})
return ok('查询成功', filtered)
}
const statsMonitor = (): ApiResponse<{ total: number; online: number; offline: number; alerting: number; repairing: number }> => {
return ok('查询成功', {
total: monitorList.length,
online: monitorList.filter((i) => i.runStatus === 'online').length,
offline: monitorList.filter((i) => i.runStatus === 'offline').length,
alerting: monitorList.filter((i) => i.alertCount > 0).length,
repairing: monitorList.filter((i) => i.runStatus === 'repairing').length,
})
}
const infoMonitor = (ctx: MockContext): ApiResponse<MonitorRecord | null> => {
const params = getParams(ctx.params)
const target = monitorList.find((item) => item.id === params.id)
return ok('查询成功', target ?? null)
}
const remoteMonitor = (ctx: MockContext): ApiResponse<null> => {
const params = getParams(ctx.params)
const id = String(params.id ?? '')
const target = monitorList.find((item) => item.id === id)
if (!target) return ok('设备不存在')
/** 在运行日志追加一条记录 */
const now = new Date().toISOString().replace('T', ' ').slice(0, 19)
const actionText = params.type === 'self-check' ? '远程自检' : params.type === 'restart' ? '远程重启' : 'OTA 升级'
target.runLogs.unshift({
id: `rl-${Date.now()}`,
eventType: actionText,
content: `${actionText}指令已下发`,
occurTime: now,
level: 'info',
})
return ok(`${actionText}指令已下发`)
}
registerMocks([
{ method: 'POST', url: '/admin/device/ops/monitor/list', handler: listMonitor },
{ method: 'POST', url: '/admin/device/ops/monitor/stats', handler: statsMonitor },
{ method: 'POST', url: '/admin/device/ops/monitor/info', handler: infoMonitor },
{ method: 'POST', url: '/admin/device/ops/monitor/remote', handler: remoteMonitor },
])
@@ -0,0 +1,191 @@
import { registerMocks } from '@axios/mockBus'
import type { ApiResponse, MockContext, RequestParameter } from '@axios'
/**
* 设备运维 / 版本管理 mock
* - 固件 + APP 双轨
* - 状态流转:草稿 → 灰度 → 全量 → 停用
*/
interface VersionRecord {
id: string
version: string
track: 'firmware' | 'app'
packageUrl: string
upgradePoints: string
grayPercent: number
rollbackFlag: boolean
publishTime: string | null
status: 'draft' | 'gray' | 'full' | 'deprecated'
upgradedCount: number
creator: string
createTime: string
}
interface VersionParams {
keyword?: string
track?: string
status?: string
id?: string
toStatus?: string
[key: string]: unknown
}
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({ code: '00000', msg, data })
const getParams = (params: RequestParameter): VersionParams => (params ?? {}) as VersionParams
const includes = (value: unknown, keyword: unknown): boolean => {
if (!keyword) return true
return String(value ?? '').toLowerCase().includes(String(keyword).toLowerCase())
}
const versionList: VersionRecord[] = [
{
id: 'ver-001', version: 'V1.2.0', track: 'firmware',
packageUrl: 'https://ota.yx-canteen.com/fw/cabinet/v1.2.0.bin',
upgradePoints: '优化货柜开门响应速度\n修复温度传感器偶发漂移\n新增离线缓存支持',
grayPercent: 100, rollbackFlag: true, publishTime: '2026-06-25 03:00:00',
status: 'full', upgradedCount: 12, creator: '李工程师', createTime: '2026-06-22 15:00:00',
},
{
id: 'ver-002', version: 'V1.1.5', track: 'firmware',
packageUrl: 'https://ota.yx-canteen.com/fw/cabinet/v1.1.5.bin',
upgradePoints: '修复开门指令偶发丢失问题',
grayPercent: 0, rollbackFlag: false, publishTime: '2026-05-10 10:00:00',
status: 'deprecated', upgradedCount: 0, creator: '李工程师', createTime: '2026-05-08 14:00:00',
},
{
id: 'ver-003', version: 'V2.0.1', track: 'firmware',
packageUrl: 'https://ota.yx-canteen.com/fw/watch/v2.0.1.bin',
upgradePoints: '心率监测算法升级\n新增睡眠分析\n优化电池续航',
grayPercent: 30, rollbackFlag: true, publishTime: '2026-06-28 22:00:00',
status: 'gray', upgradedCount: 8, creator: '王工程师', createTime: '2026-06-26 09:30:00',
},
{
id: 'ver-004', version: 'V1.0.0', track: 'app',
packageUrl: 'https://ota.yx-canteen.com/app/cabinet/v1.0.0.apk',
upgradePoints: '货柜客户端首版\n支持扫码开门、菜品展示、订单查询',
grayPercent: 100, rollbackFlag: true, publishTime: '2026-04-15 09:00:00',
status: 'full', upgradedCount: 12, creator: '张产品', createTime: '2026-04-10 14:00:00',
},
{
id: 'ver-005', version: 'V1.1.0', track: 'app',
packageUrl: 'https://ota.yx-canteen.com/app/cabinet/v1.1.0.apk',
upgradePoints: '新增会员登录\n优化 UI 加载速度\n修复部分小屏适配问题',
grayPercent: 50, rollbackFlag: true, publishTime: '2026-06-15 14:00:00',
status: 'gray', upgradedCount: 6, creator: '张产品', createTime: '2026-06-12 10:00:00',
},
{
id: 'ver-006', version: 'V1.2.0-beta', track: 'app',
packageUrl: 'https://ota.yx-canteen.com/app/cabinet/v1.2.0-beta.apk',
upgradePoints: '新增健康菜品推荐(灰度验证)',
grayPercent: 0, rollbackFlag: true, publishTime: null,
status: 'draft', upgradedCount: 0, creator: '张产品', createTime: '2026-07-01 16:00:00',
},
{
id: 'ver-007', version: 'V3.0.0', track: 'firmware',
packageUrl: 'https://ota.yx-canteen.com/fw/healthcheck/v3.0.0.bin',
upgradePoints: '体检一体机 V3 主版本\n新增血尿酸检测\n优化血压采样算法',
grayPercent: 100, rollbackFlag: false, publishTime: '2026-05-20 10:00:00',
status: 'full', upgradedCount: 4, creator: '赵工程师', createTime: '2026-05-15 14:00:00',
},
{
id: 'ver-008', version: 'V1.5.0', track: 'firmware',
packageUrl: 'https://ota.yx-canteen.com/fw/bodyfat/v1.5.0.bin',
upgradePoints: '体脂算法同步 InBody 最新公式\n阻抗采样精度提升',
grayPercent: 100, rollbackFlag: true, publishTime: '2026-06-25 17:00:00',
status: 'full', upgradedCount: 2, creator: '孙工程师', createTime: '2026-06-22 11:00:00',
},
]
const listVersion = (ctx: MockContext): ApiResponse<VersionRecord[]> => {
const params = getParams(ctx.params)
const filtered = versionList.filter((item) => {
if (params.keyword && !includes(item.version, params.keyword) && !includes(item.upgradePoints, params.keyword)) return false
if (params.track && item.track !== params.track) return false
if (params.status && item.status !== params.status) return false
return true
})
return ok('查询成功', filtered)
}
const infoVersion = (ctx: MockContext): ApiResponse<VersionRecord | null> => {
const params = getParams(ctx.params)
return ok('查询成功', versionList.find((i) => i.id === params.id) ?? null)
}
/** 编辑回填版 */
const infoForEdit = (ctx: MockContext): ApiResponse<Partial<VersionRecord> | null> => {
const params = getParams(ctx.params)
const target = versionList.find((i) => i.id === params.id)
if (!target) return ok('查询成功', null)
return ok('查询成功', {
id: target.id, version: target.version, track: target.track,
packageUrl: target.packageUrl, upgradePoints: target.upgradePoints,
grayPercent: target.grayPercent, rollbackFlag: target.rollbackFlag, status: target.status,
})
}
const addVersion = (ctx: MockContext): ApiResponse<null> => {
const params = getParams(ctx.params)
const id = `ver-${String(versionList.length + 1).padStart(3, '0')}`
const now = new Date().toISOString().replace('T', ' ').slice(0, 19)
const record: VersionRecord = {
id,
version: String(params.version ?? ''),
track: params.track as VersionRecord['track'],
packageUrl: String(params.packageUrl ?? ''),
upgradePoints: String(params.upgradePoints ?? ''),
grayPercent: Number(params.grayPercent ?? 0),
rollbackFlag: Boolean(params.rollbackFlag),
publishTime: null,
status: (params.status as VersionRecord['status']) ?? 'draft',
upgradedCount: 0,
creator: '当前用户',
createTime: now,
}
versionList.unshift(record)
return ok('新增成功')
}
const updateVersion = (ctx: MockContext): ApiResponse<null> => {
const params = getParams(ctx.params)
const target = versionList.find((i) => i.id === String(params.id ?? ''))
if (!target) return ok('记录不存在')
if (params.version !== undefined) target.version = String(params.version)
if (params.track !== undefined) target.track = params.track as VersionRecord['track']
if (params.packageUrl !== undefined) target.packageUrl = String(params.packageUrl)
if (params.upgradePoints !== undefined) target.upgradePoints = String(params.upgradePoints)
if (params.grayPercent !== undefined) target.grayPercent = Number(params.grayPercent)
if (params.rollbackFlag !== undefined) target.rollbackFlag = Boolean(params.rollbackFlag)
return ok('更新成功')
}
const deleteVersion = (ctx: MockContext): ApiResponse<null> => {
const params = getParams(ctx.params)
const idx = versionList.findIndex((i) => i.id === String(params.id ?? ''))
if (idx >= 0) versionList.splice(idx, 1)
return ok('删除成功')
}
const transitionVersion = (ctx: MockContext): ApiResponse<null> => {
const params = getParams(ctx.params)
const target = versionList.find((i) => i.id === String(params.id ?? ''))
if (!target) return ok('记录不存在')
const to = params.toStatus as VersionRecord['status']
if (!to) return ok('目标状态不能为空')
target.status = to
if (to === 'gray' || to === 'full') {
target.publishTime = new Date().toISOString().replace('T', ' ').slice(0, 19)
}
return ok('状态已更新')
}
registerMocks([
{ method: 'POST', url: '/admin/device/ops/version/list', handler: listVersion },
{ method: 'POST', url: '/admin/device/ops/version/info', handler: infoVersion },
{ method: 'POST', url: '/admin/device/ops/version/info-for-edit', handler: infoForEdit },
{ method: 'POST', url: '/admin/device/ops/version/add', handler: addVersion },
{ method: 'POST', url: '/admin/device/ops/version/update', handler: updateVersion },
{ method: 'POST', url: '/admin/device/ops/version/delete', handler: deleteVersion },
{ method: 'POST', url: '/admin/device/ops/version/transition', handler: transitionVersion },
])
@@ -0,0 +1,210 @@
import { registerMocks } from '@axios/mockBus'
import type { ApiResponse, MockContext, RequestParameter } from '@axios'
/**
* 布点场所 mock 数据
* - 场所与租户为 N:1 关系(同一租户可拥有多个场所)
* - customFlag 为运营自定义标识,用于数据中转寻址 / 同名场所区分
* - clientHeartbeat / mqHeartbeat 为只读展示,由设备中转上报回填
*/
interface SiteRecord {
id: string
name: string
code: string
tenantId: string
tenantName: string
department?: string
address?: string
contactName?: string
contactPhone?: string
siteType: string
customFlag: string
clientHeartbeat: 'online' | 'offline'
mqHeartbeat: 'online' | 'offline'
deviceCount: number
status: boolean
createTime: string
}
interface SiteParams {
pageNum?: number
pageSize?: number
column?: string
order?: 'ascend' | 'descend' | null
keyword?: string
tenantId?: string
siteType?: string
status?: boolean
[key: string]: unknown
}
const getParams = (params: RequestParameter): SiteParams => (params ?? {}) as SiteParams
const includes = (value: unknown, keyword: unknown): boolean => {
if (!keyword) return true
return String(value ?? '').toLowerCase().includes(String(keyword).toLowerCase())
}
const paginate = <T extends SiteRecord>(list: T[], params: SiteParams): ApiResponse<T[]> => {
const pageNum = Number(params.pageNum ?? 1)
const pageSize = Number(params.pageSize ?? 30)
const start = (pageNum - 1) * pageSize
return { code: '00000', msg: '查询成功', data: list.slice(start, start + pageSize), total: list.length }
}
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({ code: '00000', msg, data })
const siteList: SiteRecord[] = [
{
id: 'site-001', name: '长庆总部一食堂', code: 'CQ-CTN-01', tenantId: 'tenant-001', tenantName: '长庆能源总部食堂',
department: '总部后勤', address: '西安市未央区凤城八路 1 号', contactName: '王经理', contactPhone: '13900010001',
siteType: 'canteen', customFlag: 'CQ-HQ-CANTEEN-01',
clientHeartbeat: 'online', mqHeartbeat: 'online', deviceCount: 12, status: true, createTime: '2026-05-12 09:18:30',
},
{
id: 'site-002', name: '采油一厂食堂', code: 'OILF-01-CTN', tenantId: 'tenant-002', tenantName: '第一采油厂',
department: '采油厂综合办', address: '延安市宝塔区河庄坪', contactName: '刘主管', contactPhone: '13900010002',
siteType: 'canteen', customFlag: 'OILF-01-CANTEEN',
clientHeartbeat: 'online', mqHeartbeat: 'offline', deviceCount: 8, status: true, createTime: '2026-05-14 10:24:48',
},
{
id: 'site-003', name: '炼化员工餐厅', code: 'REF-DIN-01', tenantId: 'tenant-003', tenantName: '炼化分公司',
department: '行政服务中心', address: '兰州市西固区福利路 88 号', contactName: '陈主任', contactPhone: '13900010003',
siteType: 'restaurant', customFlag: 'REF-DINING-01',
clientHeartbeat: 'offline', mqHeartbeat: 'offline', deviceCount: 6, status: true, createTime: '2026-05-16 14:08:11',
},
{
id: 'site-004', name: '健康园区体检站', code: 'HP-CHK-01', tenantId: 'tenant-004', tenantName: '健康示范园区',
department: '园区医务部', address: '成都市高新区天府三街 199 号', contactName: '张医生', contactPhone: '13900010004',
siteType: 'checkup-station', customFlag: 'HP-CHKUP-01',
clientHeartbeat: 'online', mqHeartbeat: 'online', deviceCount: 4, status: true, createTime: '2026-05-18 16:45:09',
},
{
id: 'site-005', name: '总部体重测量点 A', code: 'CQ-MSR-A', tenantId: 'tenant-001', tenantName: '长庆能源总部食堂',
department: '总部后勤', address: '西安市未央区凤城八路 1 号 二楼', contactName: '李护士', contactPhone: '13900010005',
siteType: 'measure-point', customFlag: 'CQ-HQ-MEASURE-A',
clientHeartbeat: 'online', mqHeartbeat: 'online', deviceCount: 2, status: true, createTime: '2026-05-22 11:33:24',
},
{
id: 'site-006', name: '园区员工健身房', code: 'HP-GYM-01', tenantId: 'tenant-004', tenantName: '健康示范园区',
department: '园区工会', address: '成都市高新区天府三街 199 号 B 栋', contactName: '吴教练', contactPhone: '13900010006',
siteType: 'gym', customFlag: 'HP-GYM-01',
clientHeartbeat: 'offline', mqHeartbeat: 'online', deviceCount: 5, status: false, createTime: '2026-05-25 09:12:46',
},
{
id: 'site-007', name: '采油一厂社区站', code: 'OILF-01-COM', tenantId: 'tenant-002', tenantName: '第一采油厂',
department: '矿区社区服务', address: '延安市宝塔区河庄坪社区', contactName: '孙社工', contactPhone: '13900010007',
siteType: 'community-station', customFlag: 'OILF-01-COMMUNITY',
clientHeartbeat: 'online', mqHeartbeat: 'online', deviceCount: 3, status: true, createTime: '2026-05-28 15:47:18',
},
{
id: 'site-008', name: '炼化二食堂', code: 'REF-CTN-02', tenantId: 'tenant-003', tenantName: '炼化分公司',
department: '行政服务中心', address: '兰州市西固区福利路 100 号', contactName: '周班长', contactPhone: '13900010008',
siteType: 'canteen', customFlag: 'REF-CANTEEN-02',
clientHeartbeat: 'online', mqHeartbeat: 'offline', deviceCount: 9, status: true, createTime: '2026-06-02 08:55:32',
},
]
/** 关键词搜索:场所名称 / 编号 / 联系人 */
const matchKeyword = (item: SiteRecord, keyword?: string): boolean => {
if (!keyword) return true
return includes(item.name, keyword) || includes(item.code, keyword) || includes(item.contactName, keyword)
}
const listSite = (ctx: MockContext): ApiResponse<SiteRecord[]> => {
const params = getParams(ctx.params)
const filtered = siteList.filter((item) => {
if (!matchKeyword(item, params.keyword)) return false
if (params.tenantId && item.tenantId !== params.tenantId) return false
if (params.siteType && item.siteType !== params.siteType) return false
if (typeof params.status === 'boolean' && item.status !== params.status) return false
return true
})
if (params.column === 'createTime' && params.order) {
filtered.sort((a, b) => {
const cmp = a.createTime.localeCompare(b.createTime)
return params.order === 'ascend' ? cmp : -cmp
})
}
return paginate(filtered, params)
}
/** 租户字典(与 tenant.ts mock 同源,用于 tenantId → tenantName 反查) */
const tenantDict: Record<string, string> = {
'tenant-001': '长庆能源总部食堂',
'tenant-002': '第一采油厂',
'tenant-003': '炼化分公司',
'tenant-004': '健康示范园区',
}
const addSite = (ctx: MockContext): ApiResponse<null> => {
const params = getParams(ctx.params)
const id = `site-${String(siteList.length + 1).padStart(3, '0')}`
const tenantId = String(params.tenantId ?? '')
const record: SiteRecord = {
id,
name: String(params.name ?? ''),
code: String(params.code ?? ''),
tenantId,
tenantName: params.tenantName ? String(params.tenantName) : (tenantDict[tenantId] ?? ''),
department: params.department ? String(params.department) : undefined,
address: params.address ? String(params.address) : undefined,
contactName: params.contactName ? String(params.contactName) : undefined,
contactPhone: params.contactPhone ? String(params.contactPhone) : undefined,
siteType: String(params.siteType ?? ''),
customFlag: String(params.customFlag ?? ''),
clientHeartbeat: 'offline',
mqHeartbeat: 'offline',
deviceCount: 0,
status: typeof params.status === 'boolean' ? params.status : true,
createTime: new Date().toISOString().replace('T', ' ').slice(0, 19),
}
siteList.unshift(record)
return ok('新增成功')
}
const updateSite = (ctx: MockContext): ApiResponse<null> => {
const params = getParams(ctx.params)
const id = String(params.id ?? '')
const target = siteList.find((item) => item.id === id)
if (!target) return ok('记录不存在', null)
if (params.name !== undefined) target.name = String(params.name)
if (params.code !== undefined) target.code = String(params.code)
if (params.tenantId !== undefined) {
const tid = String(params.tenantId)
target.tenantId = tid
target.tenantName = params.tenantName ? String(params.tenantName) : (tenantDict[tid] ?? target.tenantName)
}
target.department = params.department !== undefined ? String(params.department) : target.department
target.address = params.address !== undefined ? String(params.address) : target.address
target.contactName = params.contactName !== undefined ? String(params.contactName) : target.contactName
target.contactPhone = params.contactPhone !== undefined ? String(params.contactPhone) : target.contactPhone
if (params.siteType !== undefined) target.siteType = String(params.siteType)
if (params.customFlag !== undefined) target.customFlag = String(params.customFlag)
if (typeof params.status === 'boolean') target.status = params.status
return ok('更新成功')
}
const deleteSite = (ctx: MockContext): ApiResponse<null> => {
const params = getParams(ctx.params)
const id = String(params.id ?? '')
const idx = siteList.findIndex((item) => item.id === id)
if (idx >= 0) siteList.splice(idx, 1)
return ok('删除成功')
}
const infoSite = (ctx: MockContext): ApiResponse<SiteRecord | null> => {
const params = getParams(ctx.params)
const id = String(params.id ?? '')
const target = siteList.find((item) => item.id === id)
return ok('查询成功', target ?? null)
}
registerMocks([
{ method: 'POST', url: '/admin/device/site/list', handler: listSite },
{ method: 'POST', url: '/admin/device/site/info', handler: infoSite },
{ method: 'POST', url: '/admin/device/site/add', handler: addSite },
{ method: 'POST', url: '/admin/device/site/update', handler: updateSite },
{ method: 'POST', url: '/admin/device/site/delete', handler: deleteSite },
])
@@ -0,0 +1,66 @@
import { registerMocks } from '@axios/mockBus'
import type { ApiResponse, MockContext } from '@axios'
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({
code: '00000',
msg,
data,
})
/** 数据概览 mock 数据(一次性聚合返回,多维度) */
const handleOverview = (_ctx: MockContext): ApiResponse<unknown> => {
/** 设备总数按比例分布(仅原型展示) */
const total = 126
const online = 98
const offline = 22
const repairing = 4
const scrapped = 2
return ok('查询成功', {
/** 顶部 8 KPI 卡 */
kpi: [
{ key: 'total', label: '设备总数', value: total, unit: '台', hint: '较上月 +8', trend: 'up', icon: '📦', color: 'blue' },
{ key: 'collect', label: '采集设备数', value: 86, unit: '台', hint: '已开启数据分发', trend: 'flat', icon: '📡', color: 'cyan' },
{ key: 'onlineRate', label: '在用率', value: 78, unit: '%', hint: '较昨日 +2.3%', trend: 'up', icon: '⚡', color: 'green' },
{ key: 'monthAdd', label: '本月新增', value: 8, unit: '台', hint: '较上月 +33%', trend: 'up', icon: '🚀', color: 'purple' },
{ key: 'todayData', label: '今日数据量', value: 18642, unit: '条', hint: '较昨日 +12%', trend: 'up', icon: '📈', color: 'blue' },
{ key: 'todayDist', label: '今日分发量', value: 18260, unit: '条', hint: '较昨日 +10%', trend: 'up', icon: '🔄', color: 'green' },
{ key: 'tenant', label: '下游租户数', value: 24, unit: '家', hint: '本月新增 2 家', trend: 'up', icon: '🏢', color: 'orange' },
{ key: 'successRate', label: '分发成功率', value: 98, unit: '%', hint: '较昨日 +0.5%', trend: 'up', icon: '✅', color: 'green' },
],
/** 设备资产结构(按顶级分类) */
assetStruct: [
{ category: '智养餐饮设备', total: 42, percent: 33 },
{ category: '智能穿戴设备', total: 26, percent: 21 },
{ category: '健身器材设备', total: 22, percent: 17 },
{ category: '健康监测设备', total: 18, percent: 15 },
{ category: '体重测量设备', total: 18, percent: 14 },
],
/** 设备运行状态分布 */
deviceStatus: [
{ status: '在用', total: online, percent: Math.round((online / total) * 100), color: 'success' },
{ status: '离线', total: offline, percent: Math.round((offline / total) * 100), color: 'default' },
{ status: '维修中', total: repairing, percent: Math.round((repairing / total) * 100), color: 'warning' },
{ status: '报废', total: scrapped, percent: Math.round((scrapped / total) * 100), color: 'error' },
],
/** 实时业务状态迷你卡 */
miniStats: [
{ key: 'online', label: '在线设备', value: 98, unit: '台', hint: '正常心跳', color: 'green' },
{ key: 'offline', label: '离线设备', value: 22, unit: '台', hint: '需现场查看', color: 'gray' },
{ key: 'pendingAlarm', label: '待处理告警', value: 6, unit: '条', hint: '含 2 条严重', color: 'red' },
{ key: 'pendingRetry', label: '待重投数据', value: 12, unit: '条', hint: '指数退避中', color: 'orange' },
],
/** 租户设备 Top 5 */
tenantTop: [
{ rank: 1, tenantName: '示范餐饮集团', total: 38, online: 32, faultRate: 5 },
{ rank: 2, tenantName: '健康管理机构A', total: 32, online: 26, faultRate: 8 },
{ rank: 3, tenantName: '健身中心B', total: 22, online: 20, faultRate: 3 },
{ rank: 4, tenantName: '社区医院C', total: 18, online: 14, faultRate: 12 },
{ rank: 5, tenantName: '养老中心D', total: 16, online: 6, faultRate: 18 },
],
})
}
registerMocks([
{ method: 'POST', url: '/admin/device/workbench/overview', handler: handleOverview },
])
+40
View File
@@ -24,3 +24,43 @@ import './nutrition/algo'
import './nutrition/anoDirty'
// 营养管理 - 异常事件 / 结果异常(含详情)
import './nutrition/anoResult'
// 综合管理后台 - 系统设置 / 运营中心
import './admin/system'
// 综合管理后台 - 公共字典(设备厂商等)
import './admin/dict'
// 设备中心 - 工作台 / 数据概览(8 KPI + 资产结构 + 分发概览 + 实时状态 + 7 日趋势 + Top 5)
import './device-center/workbench/overview'
// 设备中心 - 设备资产 / 设备分类(树形 CRUD,含 IOT/数据中转标识)
import './device-center/asset/category'
// 设备中心 - 设备资产 / 设备管理(5 Tab 切换)
import './device-center/asset/manage'
// 设备中心 - 设备资产 / 设备统计(聚合多维度报表)
import './device-center/asset/statistics'
// 设备中心 - 库存管理 / 入库管理
import './device-center/asset/inbound'
// 设备中心 - 库存管理 / 出库管理
import './device-center/asset/outbound'
// 设备中心 - 库存管理 / 在库库存(只读)
import './device-center/asset/instock'
// 设备中心 - 库存管理 / 供应商管理
import './device-center/asset/supplier'
// 设备中心 - 设备运维 / 工单管理(含流转)
import './device-center/asset/workOrder'
// 设备中心 - 设备运维 / 设备初始化(出库装机前激活 / 烧录证书 / 固件基线)
import './device-center/ops/init'
// 设备中心 - 设备运维 / 设备监测(主列表 + 详情 6 Tab 聚合)
import './device-center/ops/monitor'
// 设备中心 - 设备运维 / 版本管理(固件 + APP 双轨,含状态流转)
import './device-center/ops/version'
// 设备中心 - 设备运维 / 告警管理(告警处置 + 业务日志双 Tab)
import './device-center/ops/alarm'
// 设备中心 - 设备运维 / 系统配置(5 分组 + 变更历史)
import './device-center/ops/config'
// 设备中心 - 租户与场所 / 布点场所(CRUD,含心跳状态展示)
import './device-center/tenantSite/site'
// 设备中心 - 数据分发 / 分发配置(下游系统 + 订阅映射,双 Tab CRUD)
import './device-center/distribute/config'
// 设备中心 - 数据分发 / 分发记录(含重投、批量重投、导出)
import './device-center/distribute/record'
// 设备中心 - 数据分发 / 失败重投(死信队列,含手动重投、永久丢弃)
import './device-center/distribute/deadletter'
+7 -3
View File
@@ -97,9 +97,12 @@ export const usePageHistory = (scopeKey: string) => {
* 计算路由对应的历史 scope key
* - admin 路由 meta.app 固定为 'admin'
* - tenant 路由 meta.app 为具体应用 keynutrition / monitor / ...
* - miniprogram 路由 meta.mp 为小程序实例 keycustomer / ops / ...
*/
const resolveScopeKey = (portal: string, app: string | undefined): string =>
`${portal}__${app ?? 'default'}`
const resolveScopeKey = (portal: string, app: string | undefined, mp: string | undefined): string => {
if (portal === 'miniprogram') return `${portal}__${mp ?? 'default'}`
return `${portal}__${app ?? 'default'}`
}
/**
* 在 router.afterEach 钩子里自动追踪访问
@@ -113,7 +116,8 @@ export const installPageHistoryTracker = (router: Router): void => {
if (to.meta?.isAppGrid) return
if (!to.name) return
const app = to.meta?.app as string | undefined
const scopeKey = resolveScopeKey(portal, app)
const mp = to.meta?.mp as string | undefined
const scopeKey = resolveScopeKey(portal, app, mp)
const { addPage } = usePageHistory(scopeKey)
addPage({
path: to.path,
+8
View File
@@ -17,6 +17,7 @@ import {
type NavGroup,
type NavSection,
type NavApp,
type NavMiniprogram,
type NavMiniprogramTab,
type NavHardwareDevice,
} from '@meta/nav'
@@ -164,6 +165,13 @@ export const filterMiniprogramTabs = (tabs: NavMiniprogramTab[], role: Role, ove
return tabs.filter(t => isLeafVisible(t.page, role, overrides))
}
/** 过滤小程序实例列表:mp 的所有 tab 都不可见则该 mp 整个丢弃 */
export const filterMiniprograms = (mps: NavMiniprogram[], role: Role, overrides: VisibilityOverrides): NavMiniprogram[] => {
return mps
.map((mp) => ({ ...mp, tabs: filterMiniprogramTabs(mp.tabs, role, overrides) }))
.filter((mp) => mp.tabs.length > 0)
}
/** 过滤硬件设备 */
export const filterHardwareDevices = (devices: NavHardwareDevice[], role: Role, overrides: VisibilityOverrides): NavHardwareDevice[] => {
return devices
@@ -0,0 +1,902 @@
# 设备管理服务器功能现状
> 本文档面向产品经理视角,聚焦业务能力、业务边界与菜单规划,不涉及具体技术实现。
>
> 老系统参考后台地址:https://device.shuziweidao.com/canteen/home,登录账号:admin,登录密码:Q4Zz64285886@629
>
> _Last updated: 2026-07-02_
---
## 一、系统定位与业务边界
### 1.1 系统定位(四大职能)
本系统具有**四大职能**
| 职能 | 服务对象 | 业务范围 |
|------|---------|---------|
| **设备资产中台** | 所有硬件设备(食堂 + 体重管理) | 设备台账、库存、配置——只管资产,不管业务数据 |
| **设备租售履约** | 租售给客户的设备 | 订单、租约、售后——把设备作为商品进行全生命周期履约 |
| **设备运维巡检** | 所有现场在用设备 | 实施装机、自检、巡检、运行监控——保障设备健康度 |
| **体重管理数据分发** | 仅体重管理类设备 | 数据采集、归档、分发到下游业务系统 |
**核心边界澄清**
- 食堂类设备(大屏、餐盘柜、货架、入库秤、配比秤)的业务数据**不过本平台**,只在食堂本地系统跑
- 体重管理类设备(体重秤、体脂秤、手表、健身器材、体检一体机)的业务数据**过本平台**
- 是否过平台由"数据分发开关"控制(详见第五章)
### 1.2 租户与场所两层关系
本系统的下游订阅关系采用**租户 + 布点场所**两层结构(菜单层面命名为"租户与场所"):
- **租户(顶层)**:签约使用平台的机构(如 XX 餐饮集团、XX 健康管理机构),即综合管理后台运营中心已存在的"租户"概念
- **布点场所(下属)**:租户下属的具体经营点,与租户为 N:1 关系
- 餐饮类租户 → 食堂、餐厅
- 健康管理类租户 → 体检站、体重测量点
- 一个租户可挂多个布点场所;设备挂在布点场所下
- 每个布点场所档案必须含一个 **自定义标识** 字段(运营自定义,用于数据分发寻址、同名场所区分、下游业务系统附加编码等场景)
> 设计意图:不把"食堂"作为独立概念硬编码,而是用通用的"布点场所"承载各类场所类型,便于未来扩展(如健身房、社区站等)。"租户管理"复用运营中心已有的租户列表(只读),不在设备中心重复维护租户档案。
### 1.3 历史遗留问题
原系统将"食堂数字化管理 / 智慧餐厅 / 体重管理"作为 3 个子应用平铺,导致:
- 中转站既当基础设施又当业务系统,职责混杂
- 食堂数字化、智慧餐厅本质是下游业务系统,不应内嵌
- 菜单按"子应用"而非"业务能力"组织,功能点分散重复
本次菜单重构按**业务能力域**重新组织。
---
## 二、菜单分类规划
按 6 大业务域重组菜单,替代原"3 子应用平铺"结构。**菜单层级最多 3 级**(一级业务域 → 二级菜单 → 三级叶子),不再出现 4 级嵌套。
### 2.1 菜单总览
```
设备管理服务器
├─ 1. 工作台
│ ├─ 实时监控大屏 ← 设备在线率 / 数据吞吐 / 异常告警
│ └─ 数据概览 ← 关键指标卡片
├─ 2. 设备资产 ← 设备分类与设备实例统一管理
│ ├─ 设备分类 ← 左树 + 右侧"类别详情";分类层级携带"是否 IOT 设备""是否启用数据分发"标识
│ ├─ 设备管理 ← 顶部 5 Tab(按顶级分类切换),每类设备字段差异承载
│ └─ 设备统计 ← 多维度统计报表(按分类 / 场所 / 租户 / 在线状态 / 数据分发)
├─ 3. 库存管理 ← 原"设备库存"更名,独立二级菜单
│ ├─ 在库库存 ← 原"库存明细"更名,当前在库台账
│ ├─ 入库管理 ← 采购到货登记 / 入库单
│ ├─ 出库管理 ← 出库登记 + 出库单融合(不再单列"出库单"菜单)
│ └─ 供应商管理 ← 供应商档案
├─ 4. 租户与场所 ← 改名(原"客户与场所"
│ ├─ 租户管理 ← 只读复用运营中心 tenantList,无新增/编辑/删除等操作按钮
│ └─ 布点场所 ← N:1 租户;档案含"自定义标识"字段
├─ 5. 设备运维 ← 主动运维 + 被动维修 + 系统配置合一,承接原"系统设置"域
│ ├─ 设备初始化 ← 出库装机前的激活 / 预配置 / 入网
│ ├─ 设备监测 ← 主列表:设备运行状态总览;详情页多 Tab 聚合实施/自检/巡检/运行日志/更新/维修记录
│ ├─ 工单管理 ← 工单接派 / 处理 / 关单(原"报修工单"更名)
│ ├─ 版本管理 ← 固件 + APP 双轨版本(原系统设置迁入)
│ ├─ 告警管理 ← 运行/自检/巡检/SLA 超时等告警统一处置 + 业务日志视图
│ └─ 系统配置 ← 全局参数(原系统设置迁入)
└─ 6. 数据分发 ← 原"数据中转"更名,本期仅支持接口回调方式
├─ 分发配置 ← 下游系统注册 + 订阅映射关系融合(原"下游系统 + 订阅关系"合并)
├─ 分发记录 ← 每条分发状态(成功/失败/重投中)+ HTTP 响应码 + 手动重投
└─ 失败重投 ← 死信队列集中重投
```
> **菜单调整说明(2026-07-02 修订)**
> 1. **拍平 4 级嵌套**:原"设备资产 → 设备库存 → 5 个子菜单""设备资产 → 故障与维修 → 2 个子菜单""数据中转 → 下游分发 → 3 个子菜单""系统设置 → 设备版本管理 → 2 个子菜单"均为 4 级,本期全部拍平到 3 级。
> 2. **设备库存更名 + 菜单精简**:原"设备库存"更名为"库存管理",从 5 个子菜单精简为 4 个——"出库单"与"出库管理"融合(出库单作为出库管理内的视图/详情,不再单列菜单);"库存明细"更名为"在库库存"以突出"在库状态"视角。
> 3. **设备运维扩域 + 承接系统设置**:原"系统设置"一级域取消,其 4 个二级菜单全部迁入"设备运维"域——版本管理直接迁入;更新记录迁入"设备监测"详情页 Tab;系统配置直接迁入;业务日志并入"告警管理"作为日志视图。运维域共 6 个二级菜单(设备初始化 / 设备监测 / 工单管理 / 版本管理 / 告警管理 / 系统配置)。
> 4. **设备监测聚合多视角**:原"实施记录 / 自检记录 / 巡检记录 / 运行日志 / 维修记录"5 个独立二级菜单收敛为"设备监测"1 个二级菜单——主列表页统一展示设备运行状态总览,详情页以 Tab 形式聚合 5 个视角 + 更新记录共 6 个 Tab。
> 5. **数据中转更名 + 简化为接口回调**:原"数据中转"更名为"数据分发",本期仅支持"接口回调"模式(平台收到设备上报数据后按规则 HTTP 回调下游)。原 6 个子菜单(采集设备/采集监控/订阅关系/投递记录/死信重投/数据归档)精简为 3 个——"下游系统 + 订阅关系"融合为"分发配置""投递记录"更名为"分发记录""死信重投"更名为"失败重投""采集设备/采集监控"并入设备资产/设备监测(不再重复建表);"数据归档"后置 P1。
> 6. **租户与场所更名**:原"客户与场所"改为"租户与场所""客户管理"改为"租户管理"且只读复用运营中心 tenantList。
> 7. **设备资产瘦身**:仅保留"设备分类 / 设备管理 / 设备统计"3 个二级菜单;原"设备配置 / 大屏内容管理"**暂未归属,后续讨论**其归宿。
> 8. **设备租售本期不做**:原"设备租售"域本期不建设,相关章节标注"本期不做"保留设计思路供未来参考。
### 2.2 与原菜单的映射关系
| 原页面 | 新位置 |
|--------|-------|
| 食堂数字化 home / 智慧餐厅 home / 体重管理 home | 工作台 → 实时监控大屏 / 数据概览 |
| 食堂设备(大屏 / 餐盘柜 / 货架 / 入库秤 / 配比秤) | 设备资产 → 设备管理("智养餐饮设备" Tab |
| 配比秤 / 智能货架 / 自动入库 / 智能餐盘柜 | 设备资产 → 设备管理("智养餐饮设备" Tab |
| 设备新增编辑 | 设备资产 → 设备管理(按 Tab 切换) |
| 设备分类目录 / 大类 / 型号 | 设备资产 → 设备分类(左树 + 右侧"类别详情" |
| 设备维修 | 设备运维 → 工单管理 + 设备监测.详情.维修记录 Tab |
| 入库 | 库存管理 → 入库管理 |
| 出库 + 出库单(融合) | 库存管理 → 出库管理(出库单作为内嵌视图) |
| 库存明细 | 库存管理 → 在库库存 |
| 供应商 | 库存管理 → 供应商管理 |
| 大屏设备列表 | 暂未归属,后续讨论(原"大屏内容管理" |
| 虹软 SDK 授权 | 暂未归属,后续讨论(原"设备配置" |
| 客户列表 | 租户与场所 → 租户管理(只读复用运营中心 tenantList |
| 食堂列表 / 同步 | 租户与场所 → 布点场所 |
| 小型 / 大型体重秤 | 设备资产 → 设备管理("体重测量设备" Tab);数据分发按分类订阅 |
| InBody 体脂秤 | 设备资产 → 设备管理("体重测量设备" Tab);数据分发按分类订阅 |
| 顿米健身器材 | 设备资产 → 设备管理("健身器材设备" Tab);数据分发按分类订阅 |
| 华盛一体机 | 设备资产 → 设备管理("健康监测设备" Tab);数据分发按分类订阅 |
| 鹏升 / 聚伟 / 金亿帝手表 | 设备资产 → 设备管理("智能穿戴设备" Tab);数据分发按分类订阅 |
| APP 版本管理 | 设备运维 → 版本管理 |
| 业务日志列表 | 设备运维 → 告警管理(日志视图) |
| **(新增)设备统计** | 设备资产 → 设备统计 |
| **(新增)装机 / 自检 / 巡检 / 运行日志 / 维修记录** | 设备运维 → 设备监测(详情页 Tab 聚合,不再独立二级菜单) |
| **(新增)设备激活预配置** | 设备运维 → 设备初始化 |
| **(新增)OTA 推送记录** | 设备运维 → 设备监测.详情.更新记录 Tab |
| **(新增)告警统一处置** | 设备运维 → 告警管理 |
| **(新增)下游系统 + 订阅关系(融合)** | 数据分发 → 分发配置 |
| **(新增)分发记录 / 失败重投** | 数据分发 → 分发记录 / 失败重投 |
| ~~订单 / 租约 / 售后~~ | 设备租售 → 本期不做(保留设计思路供未来参考) |
### 2.3 菜单设计原则
1. **按业务能力域组织**,不按子应用组织
2. **菜单层级最多 3 级**:一级业务域 → 二级菜单 → 三级叶子;禁止再出现 4 级嵌套,避免左侧菜单折叠过深影响操作效率
3. **设备资产统一管理**,不再按食堂 / 体重管理分库
4. **设备管理按顶级分类 Tab 切换**:5 个顶级分类作为顶部 Tab 呈现,每类设备字段差异在各自 Tab 内承载,避免一张表硬塞所有字段
5. **数据分发标识上提到分类层**:是否 IOT 设备、是否启用数据分发,统一在"设备分类"层配置;设备管理不再为单台设备单独配置数据分发开关(详见第五章)
6. **数据分发独立成域**,与资产台账解耦;本期仅支持"接口回调"一种分发模式,下游注册回调 URL + 鉴权密钥,平台按"分发配置"规则 HTTP 回调
7. **租户与场所分层**,场所类型可扩展(不绑定"食堂"概念);租户档案只读复用运营中心,不在设备中心重复维护
8. **库存管理独立二级**:库存与资产台账关注角色不同(仓储 vs 资产管理),独立二级菜单后查询不互相污染;出库单与出库管理融合,避免重复
9. **设备运维扩域承接系统设置**:运维域既管主动运维(监测/巡检/自检/运行日志)也管被动维修(工单/维修记录),同时承接原系统设置的版本/告警/系统配置,统一由运维班组负责,避免故障工单与系统设置在两个域间跳转
10. **设备监测多视角聚合**:原"实施记录 / 自检记录 / 巡检记录 / 运行日志 / 维修记录"5 个独立二级菜单收敛为"设备监测"1 个二级菜单——主列表页看设备运行状态总览,详情页 Tab 聚合多视角,避免同一台设备在 5 个菜单间来回跳转
11. **告警统一处置**:运行异常、自检异常、巡检异常、SLA 超时、OTA 失败等告警统一进"告警管理"处置;业务日志作为告警管理下的日志视图,避免业务日志单独建菜单
12. **同事件多视角呈现**:原"客户报障"在"售后服务"和"故障与维修"各有一条记录的思路本期收敛——售后服务随设备租售域一并本期不做,故障与维修的工单 ID 在"工单管理"与"设备监测.详情.维修记录 Tab"间关联即可
13. **设备租售本期不做**:销售/租约/售后履约不在本期建设范围,相关需求暂以线下流程或运营中心既有能力承接
---
## 三、各菜单模块详细说明
### 3.1 工作台
**实时监控大屏**:平台级实时看板
- 设备在线率(基于心跳实时计算)
- 数据吞吐量 / 平均分发延迟 / 异常率
- 下游订阅者健康状态
- 死信告警
**数据概览**:关键指标卡片(设备总数、采集设备数、今日数据量、下游租户数等)
### 3.2 设备资产
设备资产域本期只保留 3 个二级菜单:**设备分类 / 设备管理 / 设备统计**。原"设备配置 / 大屏内容管理"暂未归属,后续讨论。
#### 3.2.1 设备分类
**页面结构**:左树 + 右侧"**类别详情**"(注意:不叫"节点详情")。
**左树(顶级分类固定 5 类,不可删除,可排序)**
| 顶级分类 | 预置二级型号 |
|---------|------------|
| 智养餐饮设备 | 吐盘机、智能货柜、营养秤、配比秤、结算台、入库秤、带鱼屏、波菲炉 |
| 体重测量设备 | 一体机、蓝牙秤、体脂仪 |
| 智能穿戴设备 | 华为-watch5、华为-watch4、罗杰夫-LJF01 |
| 健身器材设备 | 跑步机、椭圆机、健身车 |
| 健康监测设备 | 血糖监测仪、高血压治疗仪、智能健康一体机 |
> 顶级分类由系统预置,运营可在顶级下继续新增二级型号;二级型号可新增、编辑、删除(删除前置校验:该型号下无设备实例)。
**类别详情字段(每个分类节点通用)**
| 字段 | 说明 |
|------|------|
| 层级 | 顶级分类 / 二级型号 |
| 名称 | 分类名称 |
| 编码 | 分类编码(用于数据分发寻址) |
| 父级分类 | 二级型号必填,顶级为空 |
| 排序 | 同级排序 |
| **是否 IOT 设备** | 布尔标识;标记该分类下的设备是否为物联网设备(平台未来会接入非 IOT 设备,如纯本地运行的带鱼屏、波菲炉等) |
| **是否启用数据分发** | 布尔标识;标记该分类下的设备业务数据是否过本平台(详见第五章) |
| 数据分下游 | 启用数据分发时可配置默认下游订阅关系(可选) |
| 备注 | 文本 |
> **关键设计**:是否 IOT 设备 / 是否启用数据分发 这两个标识**在分类层设置**,下属设备实例**继承分类配置**,不在设备管理中单独配置(详见第五章"分类级单级开关")。
**操作**:新增下级型号 / 编辑 / 删除(顶级不可删);左侧支持按名称/编码过滤。
#### 3.2.2 设备管理
**页面结构**:顶部 5 Tab 切换 + 每个 Tab 一张设备实例列表。Tab 即顶级分类,固定顺序:智养餐饮设备 / 体重测量设备 / 智能穿戴设备 / 健身器材设备 / 健康监测设备。
**通用字段(5 个 Tab 都有)**
| 字段 | 说明 |
|------|------|
| 设备编码 | 全局唯一,设备入网时分配 |
| 设备名称 | 自定义名称 |
| 所属分类 | 二级型号(继承自当前 Tab) |
| 所属租户 | 关联租户与场所 → 租户管理 |
| 所属布点场所 | 关联租户与场所 → 布点场所 |
| 运行状态 | 在用 / 离线 / 维修中 / 报废 |
| 固件版本 | 当前固件版本号 |
| 入网时间 | YYYY-MM-DD HH:mm:ss |
| 最近心跳时间 | YYYY-MM-DD HH:mm:ss |
| 是否 IOT 设备 | 从分类继承,只读展示 |
| 是否启用数据分发 | 从分类继承,只读展示 |
**各 Tab 差异字段**
| Tab | 差异字段 |
|-----|---------|
| 智养餐饮设备 | 所属轨道(轨道编号)、摆放方式(落地/壁挂/嵌入式)、绑定大屏(关联设备编码)、所在档口、客户端 IP |
| 体重测量设备 | 测量指标(体重/体脂/BMI 等)、量程、精度等级、计量单位、测量方式(站姿/坐姿/手持) |
| 智能穿戴设备 | 腕带编号、SIM 卡号(ICC-ID)、绑定用户(用户 ID + 姓名)、电池电量、通讯协议(4G/5G/BLE/Wi-Fi |
| 健身器材设备 | 器材类型(有氧/力量)、阻力调节方式(机械/电磁/液压)、训练数据采集类型(时长/卡路里/距离/次数)、设备位置(场所内具体位置) |
| 健康监测设备 | 监测指标列表(血糖/血压/血氧等)、采样频率、测量方式(采血/袖带/光电)、报告生成方式(本地/云端) |
> **设计理由**:5 类设备的业务属性差异较大(穿戴类有 SIM 卡号、餐饮类有轨道、监测类有采样频率),强行合并到一张表会导致字段稀疏且表单冗长。Tab 切换既保留了"设备资产统一管理"的菜单骨架,又让每类设备的字段定义可以独立维护。后续新增顶级分类时,只需新增一个 Tab + 一套字段定义,不影响其他 Tab。
**操作能力**:每个 Tab 内支持新增/编辑/删除(删除即归档,不物理删除)、导入导出、批量操作、按运行状态/租户/场所筛选;详情抽屉展示单台设备的完整档案 + 近期运行日志 + 历史维修履历。
#### 3.2.3 设备统计
从不同维度设计统计报表,支持运营决策。**建议维度**:
| 维度 | 图表类型 | 业务价值 |
|------|---------|---------|
| 按顶级分类分布 | 饼图 | 掌握设备资产结构 |
| 按二级型号分布 | 柱状图 | 掌握具体型号占比,指导采购 |
| 按租户分布 Top N | 横向柱状 | 找出大租户,指导资源倾斜 |
| 按布点场所分布 | 表格 + 地图 | 掌握地域分布,指导运维调度 |
| 按运行状态分布 | 饼图 | 在线率/离线率/维修率/报废率 |
| 按 IOT / 非 IOT 分布 | 饼图 | 掌握 IOT 接入覆盖率 |
| 按数据分发开关分布 | 饼图 | 掌握平台数据分发业务覆盖面 |
| 按入网时间趋势 | 折线 | 掌握设备增长节奏 |
| 按故障率排行 | 表格(按设备/型号/场所) | 找出高频故障设备,指导维护 |
| 数据分发吞吐量趋势 | 折线(按日/小时) | 监控数据分发业务量变化 |
> 本期先实现核心 5 个维度(顶级分类、租户 Top N、运行状态、IOT 占比、故障率排行),其余维度作为 P1 跟进。
### 3.3 库存管理
原"设备库存"更名,独立二级菜单。从原 5 个子菜单精简为 4 个:将"出库单"融合到"出库管理"内作为视图/详情,"库存明细"更名为"在库库存"以突出"在库状态"视角。
**在库库存**(原"库存明细"):
- 当前在库台账(按型号 / 批次 / 供应商聚合)
- 字段:设备编码、二级型号、批次号、供应商、采购价、入库时间、仓储位置、库存状态(在库 / 在途 / 已出库 / 已回库)
- 可用数量、在途数量、已出库数量汇总
- 操作:查看详情、回库登记(设备从场所退回时回到在库)
**入库管理**
- 采购到货登记、入库单生成、初始库存建立
- 字段:入库单号、入库时间、供应商、二级型号、批次号、数量、采购价、入库人、入库类型(采购入库 / 回库入库 / 调拨入库)
- 操作:新增入库单、查看详情、导出
**出库管理**(融合原"出库单"):
- 出库登记 + 出库单管理二合一,避免重复菜单
- 顶部 Tab 切换视图:「出库登记」(按出库动作维度,记录每次出库)⇄「出库单」(按出库单维度,聚合多设备清单)
- 出库登记字段:出库单号、出库时间、领用人、目的场所、设备清单(多台)、出库类型(装机出库 / 调拨出库 / 报废出库)、经办人
- 出库单视图字段:出库单号、出库时间、领用人、目的场所、设备数量、出库类型、状态(待出库 / 已出库 / 已签收 / 已撤回)
- 操作:新增出库单、查看详情(含设备清单)、撤回、导出
- 与"设备运维 → 设备初始化"衔接:出库后设备进入初始化阶段,激活完成后转为设备资产实例
**供应商管理**
- 供应商档案(名称、联系人、合作起止日期、供货品类、资质附件)
- 操作:新增 / 编辑 / 删除 / 查看详情
- 便于采购溯源与品类筛选
> 与"设备资产 → 设备管理"的边界:设备管理管的是**已入网的单台设备实例**(含场所归属、运行状态),库存管理管的是**未入网或已回库的设备**(含批次、仓储位置、采购价)。出库动作会把在库库存的一条记录流转到设备初始化,初始化完成后转为设备资产实例。
### 3.4 租户与场所
**租户管理**
- **只读复用**综合管理后台运营中心的 `operation-center/tenantManagement/tenantList` 页面
- 列表展示租户基本信息(名称、联系人、地址、应用授权、状态)
- **不在设备中心重复维护租户档案**;新增/编辑/删除/授权应用/重置密码等操作按钮**全部隐藏**
- 设备中心只读消费租户数据,用于设备归属、布点场所关联
**布点场所**
- 场所档案(名称、编号、所属租户、部门、地址、联系人、场所类型)
- **自定义标识**(必填字段):运营自定义,用于数据分发寻址、同名场所区分、下游业务系统附加编码等场景
- 客户端心跳监控(监控在线状态)
- 消息队列心跳监控
- 按租户同步场所信息
- 设备挂载关系(场所下挂哪些设备)
- 与租户为 N:1 关系(一个租户可挂多个场所)
### 3.5 设备租售(本期不做)
> **本期不建设**。原"把设备当作商品进行租售全生命周期履约"的设计思路保留在此章节供未来参考,相关需求暂以线下流程或运营中心既有能力承接。详见 §7.5 重构迁移路径。
原设计思路(仅供未来参考):
- 订单中心:销售/租赁/退订订单全生命周期
- 租约管理:在执行租约、续约、到期提醒
- 租赁记录:设备维度的租赁履历
- 售后服务:客户报障受理、SLA 跟进、满意度回访
### 3.6 设备运维
把设备当作**运行体**进行运维巡检 + 维修全流程 + 系统配置管理,关注角色是运维班组、巡检工程师、维修工程师。**本期吸收原"故障与维修"域 + 原"系统设置"域**,统一由运维班组负责,共 6 个二级菜单:设备初始化 / 设备监测 / 工单管理 / 版本管理 / 告警管理 / 系统配置。
**设备初始化**
- 设备出库装机前的激活 / 预配置 / 入网
- 初始化任务(设备绑定密钥、烧录证书、预置场所配置、固件基线版本)
- 初始化结果归档(成功 / 失败、操作人、时间)
- 批量初始化(按批次号 / 出库单)
- 与"库存管理 → 出库管理"衔接:出库后设备进入初始化阶段;初始化完成后转为设备资产实例
**设备监测**(核心重构,原 5 个独立菜单聚合为 1 个):
- **主列表页**:设备运行状态总览
- 字段:设备编码、设备名称、所属分类、所属租户 / 场所、运行状态(在用 / 离线 / 维修中 / 报废)、最近心跳时间、在线率、当前告警数、固件版本
- 顶部统计卡:总设备数 / 在线数 / 离线数 / 告警中数 / 维修中数
- 筛选:按顶级分类 / 租户 / 场所 / 运行状态 / 是否有告警 / 是否启用数据分发
- 操作:查看详情、远程下发自检、远程重启、远程 OTA 升级
- **详情页(多 Tab 聚合)**:以单台设备为维度,Tab 切换查看不同视角
- **Tab 1 实施记录**:现场装机 / 拆迁 / 移机台账(实施类型、实施人、租户签收、实施图片、实施前后状态对比)
- **Tab 2 自检记录**:设备自检任务及结果(自检类型 = 开机自检 / 定时自检 / 远程下发;自检项 = 硬件 / 网络 / 传感器 / 固件;结果 = 通过 / 异常 + 异常项详情);异常自检自动触发工单
- **Tab 3 巡检记录**:该设备历次巡检结果(巡检计划、巡检人、巡检项填报、异常处置 = 直接关单 / 转工单)
- **Tab 4 运行日志**:设备运行埋点(开机关机、心跳、关键事件流水)+ 运行状态时序数据(在线率、负载、温度等);与"工作台 → 实时监控大屏"对接(运行日志是大屏底层数据源);异常运行事件自动告警到"告警管理"
- **Tab 5 更新记录**(原系统设置 → 更新记录迁入):该设备历次 OTA 升级记录(推送任务、目标版本、升级前后版本对比、结果 = 成功 / 失败 / 已回滚、重试记录)
- **Tab 6 维修记录**:该设备历史维修档案(故障类型、维修方式、维修图片、工时、备件消耗、关联工单 ID)
- **设计意图**:原"实施记录 / 自检记录 / 巡检记录 / 运行日志 / 维修记录"5 个独立二级菜单,会让运维人员在 5 个菜单间反复跳转查看同一台设备;聚合到详情页 Tab 后,"看一台设备"= 进一个详情页,所有视角一目了然
**工单管理**(原"报修工单"更名,技术视角):
- 工单生命周期(接单 → 派单 → 处理中 → 关单),含工单状态、责任人、SLA、处理进度
- 工单来源:人工报修、自检异常自动派单、巡检异常自动派单、运行日志异常自动派单、告警管理自动派单
- 与"设备监测.详情.维修记录 Tab"通过工单 ID 关联——工单流转过程中产生的维修信息归档到维修记录 Tab
- 顶部红点:超 SLA 工单
- 关单前必填维修上报(故障类型 / 维修方式 / 工时 / 备件 / 图片)
**版本管理**(原系统设置 → 版本管理迁入):
- 固件版本(硬件 OTA)+ APP 版本(客户端 APP)双轨
- 字段:版本号、升级包地址、升级点、灰度策略、回滚标识、发布时间、状态(草稿 / 灰度 / 全量 / 已停用)
- 操作:新增版本、编辑、停用、查看详情
- 与"设备监测.详情.更新记录 Tab"关系:版本管理管"版本档案",更新记录 Tab 管"单台设备的升级结果"
**告警管理**(新):
- 告警统一处置中心,承接所有来源的异常告警
- 告警来源:运行日志异常(连续离线、温度异常、内存泄漏)、自检异常、巡检异常、SLA 超时、OTA 失败、数据分发失败(死信累积超阈值)
- 字段:告警 ID、告警来源、告警级别(提示 / 警告 / 严重)、设备编码、告警内容、触发时间、状态(未处理 / 处理中 / 已忽略 / 已转工单)、处理人
- 操作:查看详情、转工单(自动带告警上下文)、忽略、批量处理
- 顶部统计卡:今日新增告警 / 待处理告警 / 严重告警数 / 本周告警趋势
- **业务日志视图**(原系统设置 → 业务日志迁入):作为告警管理内的子 Tab,按场所 / 日志类型 / 日志数据归档查询;与告警互补——告警是异常事件视图,业务日志是全量操作流水视图
**系统配置**(原系统设置 → 系统配置迁入):
- 全局参数管理
- 配置项分组:
- 推送通道全局配置(数据分发回调超时、重试次数、并发数)
- 限流 / 黑白名单策略
- 巡检 / 自检全局策略(默认周期、告警阈值)
- SLA 策略(不同级别工单的响应/处理时长)
- OTA 全局策略(默认灰度比例、回滚阈值)
- 操作:编辑配置项、查看变更历史
- 注:原"各设备类型的默认数据分发开关"已上提到"设备资产 → 设备分类"层配置,不再放在系统配置(详见第五章)
> **域内菜单边界**
> - 设备初始化管"出厂首次烧录基线版本",版本管理管"在用设备的持续 OTA 升级"
> - 设备监测是"看设备"的统一入口(主列表 + 详情多 Tab),工单管理是"处置异常"的流转中心,告警管理是"异常事件视图 + 业务日志视图"
> - 主动监测(设备监测.详情.自检/巡检/运行日志 Tab)发现异常 → 自动派单到工单管理 → 处置过程归档到设备监测.详情.维修记录 Tab;三者通过设备 ID + 工单 ID 关联但关注点不同
### 3.7 数据分发
原"数据中转"更名,本期仅支持"接口回调"一种分发模式:平台收到设备上报数据后,按"分发配置"规则,以 HTTP POST 方式回调下游系统注册的 URL,并携带鉴权密钥。不再维护消息队列、Webhook 等多种通道——简化为单一接口回调,本期足够覆盖业务。
**分发配置**(融合原"下游系统 + 订阅关系"):
- **下游系统注册**:注册下游业务系统档案
- 字段:下游系统名称、回调 URL、鉴权密钥(用于签名校验)、联系人、启停状态、备注
- 操作:新增 / 编辑 / 删除 / 启停 / 测试连通性(一键发送测试数据验证回调可达)
- **订阅映射关系**:按"设备分类 / 数据类型"配置分发给哪些下游
- 字段:源(设备顶级分类 + 二级型号 + 数据类型 = 如体重/体脂/心率/训练数据)、目标下游系统(可多选)、过滤条件(可选,如仅工作时段、仅异常值)、启用状态
- 操作:新增规则、编辑、删除、启停
- 一个源可分发给多个下游;一个下游可订阅多个源(多对多)
- **设计意图**:原"下游系统"和"订阅关系"分两个菜单,但二者强耦合(订阅关系本质是"下游系统 + 订阅源"的映射),融合后一个菜单内同时管理"接收方"和"订阅什么",减少跨菜单跳转
**分发记录**
- 每条数据分发的执行记录(一条上报数据 × 一个下游 = 一条分发记录)
- 字段:分发记录 ID、设备编码、数据类型、下游系统、分发时间、HTTP 状态码、响应耗时、重试次数、状态(成功 / 失败 / 重投中)、失败原因
- 筛选:按下游系统 / 设备 / 数据类型 / 状态 / 时间范围
- 操作:查看详情(含请求体 / 响应体)、手动重投、批量重投(按筛选条件)、导出
- 顶部统计卡:今日分发总数 / 成功率 / 平均响应耗时 / 失败待重投数
- 数据保留策略:默认保留 30 天,超期归档(归档查询后置 P1)
**失败重投**
- 死信队列:分发失败且自动重试耗尽(默认 3 次)后进入死信
- 字段:死信记录 ID、原分发记录 ID、设备编码、数据类型、下游系统、首次失败时间、最近重投时间、重投次数、失败原因、状态(待重投 / 重投中 / 已恢复 / 已丢弃)
- 操作:手动重投(单条 / 批量)、查看详情、永久丢弃(需二次确认)
- 自动重投策略:指数退避(1min / 5min / 30min / 2h / 12h),最多 5 次后转人工
- 死信累积超阈值自动告警到"设备运维 → 告警管理"
- 与"分发记录"关系:分发记录是全量流水视图,失败重投是失败子集的集中处置视图
> **本期不做**:消息队列通道、Webhook 多通道、下游自主申请订阅(订阅由我方运营在"分发配置"中维护)、数据归档独立查询页(分发记录可查 30 天,超期归档后置 P1)
### 3.8 系统设置(已合并至设备运维)
> **本期取消"系统设置"一级菜单**,其下 4 个二级菜单全部迁入"设备运维"域:
> - 版本管理 → 设备运维 → 版本管理(直接迁入)
> - 更新记录 → 设备运维 → 设备监测.详情.更新记录 Tab(按设备维度查看 OTA 结果)
> - 业务日志 → 设备运维 → 告警管理(作为告警管理内的日志视图 Tab)
> - 系统配置 → 设备运维 → 系统配置(直接迁入)
>
> 迁移动因:版本管理、系统配置本质服务于设备运维场景(OTA 升级、巡检/自检策略、SLA 策略),与设备运维强耦合;更新记录是"按设备看升级结果",归到设备监测详情页更符合运维人员的查询路径;业务日志是"全量操作流水",与告警(异常事件视图)互补,并入告警管理做日志视图。合并后运维班组在一个域内即可完成所有运维 + 系统配置操作,避免在"系统设置"和"设备运维"两个域间跳转。
> **暂未归属的页面**:原"设备配置"(含人脸 SDK 授权、客户端 IP、所属轨道等)和"大屏内容管理"(大屏 HTML 内容)本期从设备资产移除后暂未归属,**后续讨论**其归宿。候选位置:设备运维 → 系统配置下新增"设备配置"子项,或独立新建"设备配置"域。
---
## 四、设备数据采集域
数据分发的核心价值之一是"多品牌硬件统一采集"。当前覆盖 5 类硬件,每类采集的业务数据如下。
### 4.1 手表(多品牌)
**采集数据**10 类健康指标
- 血氧、血压、血糖、脉搏、呼吸率
- 睡眠(总时长、起止时间、清醒次数)
- 计步、体温
- 定位(经纬度、定位类型)
**覆盖品牌**:鹏升、聚伟、金亿帝等
### 4.2 体脂秤(多品牌)
**InBody**:体成分测量结果
- 用户基础信息(身高、生日、性别、年龄)
- 体重、体水分、蛋白质、矿物质、体脂肪等
**乐福**:体重 + 多频多肢体阻抗
- 体重、心率、阻抗(多频段、多肢体)
- 固件版本管理(支持 OTA 升级)
### 4.3 顿米健身器材
**采集数据**:训练数据
- **有氧训练**:骑行时间 / 卡路里 / 距离,分阶段(速度、时长、坡度、功率)
- **力量训练**:动作、训练时间 / 卡路里、计划 ID、完成次数、训练部位、训练动作
### 4.4 体检一体机
**采集数据**15 类体检指标
- 血脂、血氧、血压、血糖、血尿酸
- 体脂肪、胆固醇、心电图
- 身高体重、体温、中医体质、中医建议、腰臀比
### 4.5 体重秤
**采集数据**:体重测量数据
- 设备绑定、用户绑定、测量时间、体重值
- 用户人脸档案(卡号 + 人脸特征)
- 用户设备绑定关系(身高、体重、生日、性别、BMI)
---
## 五、数据分发开关(可配置项设计)
### 5.1 设计目标
将"设备数据是否过本平台"从硬编码改为可配置,支持运营在后台调整,无需研发改代码。
### 5.2 分类级单级开关(2026-07-02 修订)
> **重要变更**:原"两级开关(设备类型级 + 设备实例级)"收敛为**分类级单级开关**。设备实例**不再单独配置**数据分发开关,统一继承所属分类的配置。
**唯一配置位置:设备资产 → 设备分类**(每个分类节点的"类别详情"中)
- 每个分类节点(顶级分类或二级型号)有一个"是否启用数据分发"开关
- 设备实例继承所属二级型号的配置;二级型号若未设置则继承顶级分类配置
- 当前默认建议值(运营可在分类层调整):
- 智能穿戴设备 / 体重测量设备 / 健身器材设备 / 健康监测设备 = **开启**
- 智养餐饮设备 = **关闭**(食堂数据不过平台,只在食堂本地系统跑)
- 未来若某类餐饮设备也要过平台,运营在"设备分类 → 类别详情"打开开关即可
**为何取消设备实例级覆盖**
- 历史场景"同类设备大部分不过平台,但某几台要过"在实际业务中极少出现,且会让"哪些设备走数据分发"的判定逻辑分散到数千台设备实例上,难以全局审计
- 收敛到分类层后,"哪些分类的设备走数据分发"一眼可见,运营决策更清晰
- 数据分发下游的"分发配置"规则直接基于分类层开关过滤,无需逐台判断
### 5.3 配置项字段
每个分类节点的数据分发开关包含:
- **是否 IOT 设备**(布尔,标记该分类下设备是否为物联网设备,独立于数据分发开关)
- **是否启用数据分发**(布尔)
- 是否启用数据采集(启用数据分发时可细化控制)
- 是否启用数据回调(启用数据分发时可细化控制)
- 默认下游订阅关系(可选,启用数据分发时可预置默认订阅到"数据分发 → 分发配置")
### 5.4 业务价值
- **灵活性**:新增设备分类时无需改代码,运营自主配置
- **可审计**:分类层一眼可见哪些分类走数据分发,全局审计清晰
- **可演进**:未来食堂设备如果要走平台,只需在分类层打开开关
---
## 六、端到端业务流程
### 6.1 数据流转总览
```
┌──────────────────────────────────────────────────────────┐
│ 上游 硬件采集 │
│ ┌─ 过平台:体重秤 / 体脂秤 / 手表 / 健身器材 / 体检机 │
│ └─ 不过平台:食堂设备(大屏 / 货架 / 秤等) │
├──────────────────────────────────────────────────────────┤
│ 分发 本平台(仅针对"分类层开启数据分发"的设备) │
│ ├─ 数据接收(设备上报) │
│ ├─ 分发配置匹配(按分类 + 数据类型 → 下游系统) │
│ └─ 接口回调(HTTP POST + 签名鉴权 → 下游回调 URL) │
├──────────────────────────────────────────────────────────┤
│ 下游 租户业务系统 │
│ 餐饮管理系统 / 健康管理系统 / 体重管理系统 │
│ (本期仅支持接口回调方式接收数据) │
└──────────────────────────────────────────────────────────┘
```
### 6.2 业务实体关系
**资产链**:租户 → 布点场所 → 设备 →(二级型号 → 顶级分类)
**库存链**:入库 → 在库库存 ↔ 出库管理(含出库单视图);维修挂到设备实例(设备监测.详情.维修记录 Tab)
**分发链**:设备上报 → 分发配置匹配 → 下游系统回调 URL(带鉴权密钥)
**用户链**:用户 ↔ 用户设备 ↔ 体重秤数据 / 人脸档案
### 6.3 下游分发模式(本期简化方案)
**本期方案(接口回调)**
- 下游系统在"数据分发 → 分发配置"注册回调 URL + 鉴权密钥
- 运营在"分发配置"内维护订阅映射关系(按设备分类 / 数据类型 → 下游系统)
- 平台收到设备上报数据后,按订阅映射规则,以 HTTP POST 方式回调下游 URL
- 每次回调生成一条"分发记录",可查可重投
- 失败自动重试 3 次(指数退避),仍失败进入"失败重投"死信队列
- 死信累积超阈值自动告警到"设备运维 → 告警管理"
**业务边界**
- 下游不能自主订阅,订阅关系由我方运营维护(本期不做下游自助订阅门户)
- 仅支持接口回调,不支持消息队列、Webhook 多通道
- 分发记录保留 30 天,超期归档(归档查询后置 P1)
**业务影响**
- 简化为单一通道后,运维与对接成本降低,本期可快速落地
- 数据是否送达下游可核查(分发记录 + HTTP 状态码),客诉可定位
- 失败数据不丢(死信兜底 + 手动重投)
- 下游鉴权 + 限流(在系统配置中全局管理),数据串户风险消除
### 6.4 设备全生命周期跨域映射
同一台设备在不同业务域中以不同身份存在,通过 **设备 ID** 关联:
| 生命周期阶段 | 所在业务域 | 在该域的身份/记录 | 关键事件 |
|------------|----------|----------------|---------|
| 采购入库 | 库存管理 → 入库管理 | 入库单一条 | 入库登记 |
| 在库待用 | 库存管理 → 在库库存 | 在库台账一条 | 库存状态 = 在库 |
| 出库装机前预配置 | 设备运维 → 设备初始化 | 初始化任务一条 | 激活 / 烧录证书 |
| 现场装机 | 设备运维 → 设备监测.详情.实施记录 Tab | 实施记录一条 | 装机 / 租户签收 |
| 入网建档 | 设备资产 → 设备管理 | 设备实例一条(按 Tab 落到对应顶级分类) | 设备编码分配、固件基线版本 |
| 在用运行 | 设备运维 → 设备监测.详情.运行日志 Tab | 时序日志持续写入 | 心跳、关键事件 |
| 定期巡检 | 设备运维 → 设备监测.详情.巡检记录 Tab | 巡检记录按周期产生 | 巡检任务完成 |
| 自检任务 | 设备运维 → 设备监测.详情.自检记录 Tab | 自检记录一条 | 自检通过/异常 |
| 异常报修 | 设备运维 → 工单管理 | 工单一条 | 工单接派 |
| 维修处置 | 设备运维 → 设备监测.详情.维修记录 Tab | 维修记录归档 | 工时、备件、关单 |
| 固件升级 | 设备运维 → 设备监测.详情.更新记录 Tab | OTA 升级记录一条 | 升级成功/失败 |
| 回库 | 库存管理 → 在库库存 | 库存回库一条 | 设备状态变更为在库 |
| 报废 | 设备资产 → 设备管理 | 设备状态变更为报废 | 资产核销 |
> **本期不涉及**:销售出租、客户退租、回库再租等"设备作为商品"的生命周期阶段,随设备租售域一并本期不做。
**多视角同事件关联关系**
- **运维发现异常**:设备监测.详情.自检/巡检/运行日志 Tab ⇄ 工单管理(1:0..1,自动派单)
- **报修与维修**:工单管理 ⇄ 设备监测.详情.维修记录 Tab(1:1,工单 ID 关联,前者关注流转过程,后者关注维修细节)
- **设备与场所**:设备管理 ⇄ 布点场所(N:1,设备当前所在场所)
- **固件升级与运维**:版本管理 ⇄ 设备初始化(基线版本 vs 持续升级);版本管理 ⇄ 设备监测.详情.更新记录 Tab(版本档案 vs 单设备升级结果)
- **告警与运维**:告警管理 ⇄ 工单管理(异常告警自动转工单);告警管理 ⇄ 设备监测.详情.运行日志 Tab(告警是异常事件视图,运行日志是全量流水视图)
---
## 七、现状业务短板与重构方向
### 7.1 现状业务短板(产品视角)
| 维度 | 现状 | 业务影响 |
|------|------|---------|
| 菜单组织 | 按 3 子应用平铺,且 4 级嵌套过深 | 功能分散重复,中转站与业务混杂,左侧菜单折叠影响操作效率 |
| 设备数据边界 | 所有设备混在一起,未区分是否过平台 | 资产管理与数据分发职责不清 |
| 下游订阅能力 | 下游被动接收,无法自主订阅 | 租户每新增一个订阅字段都要走定制 |
| 分发可追溯性 | 无分发记录表 | 数据是否送达无法核查,客诉难定位 |
| 失败兜底 | 3 次重试后丢弃,无死信 | 失败数据永久丢失,业务侧无感知 |
| 采集品牌碎片化 | 每接一个品牌改代码 | 新品牌接入周期长,依赖研发排期 |
| 设备模型层级 | 分类 / 大类 / 型号 / 实例 4 层 | 层级混乱,新增型号影响面大 |
| 实时监控 | 首页是静态统计 | 无法实时掌握设备在线率、数据吞吐、异常 |
| 多租户隔离 | 靠租户编码软隔离 | 下游无鉴权,存在数据串户风险 |
| **运维巡检缺位** 🆕 | 无装机实施 / 自检 / 巡检记录 | 现场设备健康度黑盒,故障被动响应而非主动发现 |
| **运维菜单分散** 🆕 | 实施/自检/巡检/运行日志/维修 5 个独立二级菜单 | 同一台设备在 5 个菜单间反复跳转,运维效率低 |
| **告警无统一处置** 🆕 | 异常散落在各业务点,无统一告警中心 | 告警易遗漏,SLA 无保障 |
| **固件升级黑盒** 🆕 | APP 升级有记录,固件 OTA 无统一管理 | 升级失败难定位、难回滚、难审计 |
| ~~租售履约缺位~~ | ~~设备采购后到设备出库之间无订单/租约概念~~ | 本期不做(详见 §3.5),暂以线下流程承接 |
| ~~售后闭环缺位~~ | ~~客户报障无 SLA 跟进,无满意度回访~~ | 本期不做(随设备租售域一并延后) |
### 7.2 重构方向
#### (1)菜单按业务能力域重组(本次重点)
- 6 大业务域:工作台 / 设备资产 / 库存管理 / 租户与场所 / 设备运维 / 数据分发
- 食堂数字化、智慧餐厅、体重管理 3 子应用打散重组
- 设备资产统一管理,数据分发独立成域
- 原系统设置域取消,迁入设备运维域
- **菜单层级最多 3 级**,禁止 4 级嵌套
#### (2)补齐下游分发能力(最关键,本期简化)
- 下游系统注册回调 URL + 鉴权密钥
- 运营在"分发配置"内维护订阅映射关系(按设备分类 / 数据类型 → 下游系统)
- 接口回调方式分发,每条分发记录可查可重投
- 失败入死信,后台手动重投
- 分发状态实时可查
- 下游鉴权 + 限流(系统配置中全局管理)
#### 3)采集能力组件化
- 每个品牌一个采集适配器,新增品牌只加适配器
- 新品牌接入不再依赖核心研发排期
#### (4)数据分发开关分类级单级配置
- 数据分发开关收敛到"设备资产 → 设备分类"层单级配置
- 设备实例继承所属分类配置,不再单独覆盖
- 运营自主配置,无需研发改代码
#### 5)首页改为实时监控大屏
- 接入设备数 / 在线率
- 数据吞吐 / 平均分发延迟 / 异常率
- 下游订阅者健康状态
- 死信告警
#### (6)设备运维多视角聚合 + 告警统一处置
- 原 5 个独立二级菜单(实施/自检/巡检/运行日志/维修)收敛为"设备监测"1 个二级菜单,详情页 Tab 聚合
- 告警统一进"告警管理"处置,业务日志作为告警管理内的日志视图
- 工单管理承接所有来源的工单流转
#### 7)设备模型三层解耦
- 顶级分类(智养餐饮 / 体重测量 / 智能穿戴 / 健身器材 / 健康监测)
- 二级型号(具体 SKU + 采集适配器引用 + IOT 标识 + 数据分发标识)
- 设备实例(单台资产 + 在线状态 + 各 Tab 差异字段)
### 7.3 重构优先级
| 优先级 | 事项 | 业务价值 |
|-------|------|---------|
| P0 | 菜单按业务能力域重组(拍平 3 级,合并系统设置) | 系统骨架清晰,职责解耦,操作效率提升 |
| P0 | 分发记录 + 死信兜底(接口回调模式) | 数据可靠性:失败数据不丢、可追溯、可重投 |
| P0 | 下游系统注册 + 鉴权 + 订阅映射 | 分发链路规范化、数据串户风险消除 |
| P0 | 数据分发开关分类级单级配置 | 设备数据边界可运营自主调整,全局可审计 |
| P0 | 设备运维多视角聚合 + 告警统一处置 🆕 | 设备监测详情 Tab 聚合,告警中心统一处置,主动运维 |
| P1 | 采集能力组件化 | 新品牌接入不再卡研发排期 |
| P1 | 首页实时监控 | 运营可视:在线率 / 吞吐 / 异常实时掌握 |
| P1 | 固件 OTA 版本管理 🆕 | 双轨版本(APP + 固件)+ 升级记录可审计 |
| P1 | 实施运维小程序 🆕 | 现场作业移动化:扫码直达、巡检打卡、维修上报、离线缓存 |
| P2 | 设备模型三层解耦 | 设备台账维护成本下降 |
| ~~P0~~ | ~~设备租售履约补齐~~ | 本期不做(详见 §3.5 |
### 7.4 一个关键判断
**如果只做一件事**:补齐"分发记录 + 失败重投(死信兜底)"。
理由:这是当前最大的业务风险点——失败数据永久丢失且无法追溯,是**数据可靠性**问题。比菜单重组(骨架清晰)影响更深远,比首页改版(可观测性)更紧迫。先补可靠性,再做菜单重组,最后做可观测性。
### 7.5 重构迁移路径(分 4 期)
| 期 | 目标 | 业务价值 |
|----|------|---------|
| 一期 | 菜单重组(拍平 3 级,合并系统设置至设备运维)+ 分发记录 + 死信 + 下游注册 + 数据分发分类级开关 | 骨架清晰 + 数据可靠 + 下游分发规范化 |
| 二期 | 设备运维多视角聚合(设备监测详情 Tab)+ 告警管理 + 工单管理 🆕 | 补齐"设备作为运行体"主线,主动运维 + 被动维修合一,告警统一处置 |
| 三期 | 采集能力组件化 + 首页实时监控 + 固件 OTA 版本管理 🆕 | 新品牌快接入 + 运营可视 + 升级可审计 |
| 四期 | 实施运维小程序 🆕 + 设备模型三层解耦 | 现场作业移动化 + 台账维护成本下降 |
每期可独立交付业务价值,不需要一次性重构。
> **设备租售域本期不做**:原"二期含设备租售履约"的设计本期移除,相关需求暂以线下流程或运营中心既有能力承接。未来若重启,可作为独立期次插入,不影响现有 4 期节奏。
> **小程序与四期解耦**:实施运维小程序强依赖二期(运维监测/告警/工单域)和三期(OTA 版本管理)的功能落地,因此放在四期。若需提前验证一线体验,可在二期结束时做最小可用版本(仅扫码 + 工单 + 巡检,无 OTA/离线缓存),随二期灰度。
---
## 八、实施运维小程序
### 8.1 定位与用户
**定位**:本系统的现场作业移动终端,**仅面向公司内部实施运维工程师**,不开放给客户或终端用户。
**用户**:实施运维工程师(携带手机到现场作业,需扫码、打卡、上报、处置工单)
**核心动作**:扫码识别设备 → 执行任务(初始化 / 装机 / 巡检 / 维修 / 升级)→ 现场上报
**与 PC 端关系**
- 小程序是**现场作业终端**,做"采集 + 处置 + 上报"
- PC 端是**管理控制台**,做"配置 + 调度 + 审计 + 分析"
- 数据流向单向:小程序所有操作回写 PC 端对应模块,PC 端配置下发到小程序任务
- 小程序**不做**管理类操作:不接订单 / 租约 / 客户管理 / 系统配置 / 数据分发订阅
### 8.2 菜单框架(5 Tab + 扫码独立)
```
实施运维小程序
├─ 工作台 今日待办、统计卡、消息入口
├─ 工单 工单列表 + 详情 + 处理 + 关单上报
├─ ⚡ 扫码 中间凸起按钮,扫设备后弹"场景操作菜单"
├─ 巡检 巡检计划 + 任务打卡 + 结果上报
└─ 我的 个人统计、消息中心、离线缓存设置
```
> **设计理由**:现场最高频动作是"扫码识别设备",把扫码独立成 Tab 可直达,扫完按设备状态动态弹出可执行操作,符合一线人员肌肉记忆。其余 4 个 Tab 分别承载任务接收(工作台)、被动处置(工单)、主动作业(巡检)、个人空间(我的)。
### 8.3 扫码后场景菜单(核心交互)
扫一台设备 → 弹出底部操作面板,**按设备状态动态过滤可用操作**:
| 设备状态 | 弹出的可选操作 |
|---------|--------------|
| 未激活(仅入库) | 「设备初始化」 |
| 已激活未装机 | 「实施记录上报」「设备初始化(重做)」 |
| 在用且无待处理工单 | 「巡检打卡」「维修上报」「OTA 升级」「查看档案」 |
| 在用且有待处理工单 | 顶部红点提示「该设备有 N 条待处理工单」+ 上述操作 |
| 已报废 / 已退租 | 仅「查看档案」 |
**操作菜单项**(共 6 项,按状态过滤显示):
1. **设备初始化**:扫码 → 烧录证书 / 预置场所配置 / 固件基线版本 → 上报初始化结果
2. **实施记录上报**:扫码 → 选实施类型(新装 / 迁移 / 拆除 / 复位)→ 拍照 → 客户签收 → 提交
3. **巡检打卡**:扫码确认到点 → 填报巡检项 → 异常项自动转工单
4. **维修上报**:扫码 → 关联工单(如有)→ 填故障类型 / 维修方式 / 工时 / 备件 / 图片 → 提交
5. **OTA 升级**:扫码 → 选目标版本 → 下发升级任务 → 监控进度 → 失败可回滚
6. **查看档案**:扫码 → 设备台账卡片 + 近期运行日志 + 历史维修履历(聚合自设备监测详情页多 Tab)+ 当前租约状态
### 8.4 各 Tab 模块详细说明
#### 8.4.1 工作台
**今日待办**
- 今日待处理工单数(含超 SLA 标红)
- 今日巡检任务数(含已完成 / 未完成)
- 今日 OTA 任务数
- 今日待初始化设备数
**统计卡**
- 本周处理工单数 / 平均处理时长
- 本周巡检完成率 / 异常率
- 本月装机数量
**快捷入口**:扫码、消息中心、离线任务、个人统计
#### 8.4.2 工单
**工单列表**
- 按"待处理 / 处理中 / 已完成"分 Tab
- 按工单类型筛选(报修 / 巡检异常 / 自检异常 / 售后转单)
- 顶部红点:超 SLA 工单
**工单详情**
- 设备基础信息(编码、型号、所在场所、客户)
- 故障描述、客户报障附件(图 / 视频)
- SLA 倒计时
- 操作:接单 → 处理中 → 关单
- 关单前必填:维修上报(故障类型 / 维修方式 / 工时 / 备件 / 图片)
**与 PC 端映射**:PC 端"设备运维 → 工单管理"
#### 8.4.3 ⚡ 扫码
**扫码入口页**
- 全屏取景框 + 底部"从相册选二维码"按钮
- 支持扫描:设备机身的二维码(设备 ID 编码)
- 识别后弹出 8.3 的场景操作菜单
**历史扫码**:最近 20 条扫码记录,方便快速复扫
**异常处理**
- 二维码无法识别 → 手动输入设备编码
- 设备不存在 → 提示"未找到设备,请联系管理员"
- 设备已报废 → 仅显示档案查看
#### 8.4.4 巡检
**巡检计划列表**
- 按场所 / 按设备类型聚合
- 状态:未开始 / 进行中 / 已完成 / 已逾期
- 顶部红点:逾期未巡检
**巡检任务详情**
- 巡检清单(设备列表 + 巡检项)
- 操作:扫码打卡 → 逐项填报 → 异常项标注 → 提交
- 异常项自动生成工单(设备运维 → 工单管理)
**与 PC 端映射**:PC 端"设备运维 → 设备监测.详情.巡检记录 Tab"
#### 8.4.5 我的
**个人统计**
- 本月工单处理数 / 巡检完成数 / 装机数 / OTA 升级数
- 工单平均处理时长、SLA 达成率
**消息中心**
- 工单派单通知
- 巡检任务下发通知
- OTA 任务下发通知
- OTA 失败告警
- 系统公告
**离线缓存设置**
- 开关:启用离线缓存(默认开)
- 缓存空间占用显示
- 手动同步(一键上传待上传任务)
- 清除缓存
**设置**:账号信息、修改密码、退出登录
### 8.5 消息推送设计
**推送通道**:微信小程序订阅消息(按微信能力,需用户授权)+ 应用内消息中心
**推送场景**
| 场景 | 触发条件 | 推送内容 |
|------|---------|---------|
| 工单派单 | PC 端派单到本人 | 工单号、设备编码、故障简述、SLA |
| 巡检任务下发 | 巡检计划生效 | 任务名、场所、巡检截止时间 |
| OTA 任务下发 | OTA 推送任务分配 | 设备编码、目标版本、计划升级时间 |
| 工单超 SLA 告警 | SLA 倒计时归零 | 工单号、超时时长 |
| OTA 失败告警 | 升级任务失败 | 设备编码、失败原因 |
| 巡检逾期告警 | 巡检任务逾期 | 任务名、逾期时长 |
| 自检异常告警 | 设备自检异常 | 设备编码、异常项 |
**用户授权策略**
- 首次登录引导用户一次性授权所有订阅类型
- 用户可在"我的 → 消息设置"中按场景开关
- 应用内消息中心作为兜底(微信推送失败也能看到)
### 8.6 离线缓存策略
**触发场景**:食堂地下室、设备机房、现场网络不稳定
**缓存的数据**
- 待上报的维修记录(含图片,图片压缩后本地存)
- 待上报的巡检结果
- 待上报的实施记录
- 待上报的初始化结果
- 已扫码但未提交的 OTA 升级确认
**不缓存的数据**
- 工单列表(实时拉取,避免误派单)
- 设备档案(实时拉取,避免读到旧数据)
**同步策略**
- 网络恢复后自动批量上传
- 上传失败的重试 3 次,仍失败则保留在缓存并标记"上传失败"
- "我的 → 离线缓存 → 手动同步"可一键重试
- 缓存超过 7 天自动告警(避免长期不联网导致数据丢失)
**冲突处理**
- 离线期间若工单被改派他人,本地缓存提交时返回 409,提示"工单已不在你名下"
- 离线期间若设备状态变化(如已报废),提交时返回业务错误,提示具体原因
### 8.7 与 PC 端功能映射总表
| 小程序模块 | 小程序动作 | PC 端对应位置 | 数据流向 |
|----------|----------|------------|---------|
| 工作台 | 查看今日待办 | 工作台 → 实时监控大屏(个人视角) | PC → 小程序(只读) |
| 工单 | 接单 / 处理 / 关单上报 | 设备运维 → 工单管理 | 小程序 → PC |
| 工单(关单时) | 上报维修记录 | 设备运维 → 设备监测.详情.维修记录 Tab | 小程序 → PC |
| 扫码 → 设备初始化 | 烧录证书 / 预置配置 / 上报 | 设备运维 → 设备初始化 | 小程序 → PC |
| 扫码 → 实施记录上报 | 装机打卡 / 拍照 / 签收 | 设备运维 → 设备监测.详情.实施记录 Tab | 小程序 → PC |
| 扫码 → 维修上报 | 关联工单 + 填写维修信息 | 设备运维 → 设备监测.详情.维修记录 Tab | 小程序 → PC |
| 扫码 → OTA 升级 | 下发 / 监控 / 回滚 | 设备运维 → 设备监测.详情.更新记录 Tab + 版本管理 | 双向 |
| 扫码 → 查看档案 | 只读查看 | 设备资产 → 设备管理 + 设备运维 → 设备监测.详情页(多 Tab 聚合) | PC → 小程序(只读) |
| 巡检 | 打卡 / 填报 / 异常转工单 | 设备运维 → 设备监测.详情.巡检记录 Tab | 小程序 → PC |
| 我的 → 消息中心 | 接收派单 / 告警 | 设备运维 → 告警管理(告警源) | PC → 小程序 |
### 8.8 关键设计原则
1. **现场优先**:所有操作默认假设用户在网络不稳定环境下,离线缓存是默认能力不是开关
2. **扫码优先**:能扫码就不手输,减少现场输入错误
3. **任务驱动**:工作台 / 工单 / 巡检三个 Tab 都围绕"任务"组织,不堆砌功能入口
4. **不做管理类操作**:订单 / 租约 / 客户 / 系统配置等管理类功能不进小程序,保持现场作业专注
5. **PC 端可替代**:所有小程序操作在 PC 端也能完成(用于复核 / 补录 / 数据修正),小程序不是唯一入口
6. **权限隔离**:仅实施运维工程师角色可登录小程序,管理员 / 客户经理 / 销售等角色无小程序权限
+55
View File
@@ -482,6 +482,61 @@ const {
**禁止**:脚手架生成时**默认带上新增/编辑/删除**。必须由用户明确指定页面功能。
### 7.4 同级数据切换(radio-button 样式,强制)
> 参考 vue 研发项目 `subHealthAssessment/archiveModule/unitReportData` 的"二级单位/三级单位"切换实现。
**适用场景**:同一张表格按某个维度(如设备类别、单位级别、订单状态等)过滤出多类数据展示,且**各切换项共用同一份列定义与业务逻辑**(只是 `category` / `orgType` 等过滤参数不同)。
**强制样式**:必须用 `<a-radio-group button-style="solid">` + `<a-radio-button>`**禁止用 `<a-tabs>`**。
**与 tabs-page-spec 的边界**
- `<a-tabs>` 仅用于"每个 Tab 各自独立 6 文件结构 / 各自独立业务"的场景(参见 `tabs-page-spec.md`
- 同表格按维度过滤一律用 `a-radio-group button-style="solid"`
**模板示例**
```vue
<template>
<div class="admin-list-page">
<!-- 同级数据切换行单独成行放在 FilterBar 上方 -->
<div class="listPageRow">
<a-radio-group
v-model:value="activeCategory"
button-style="solid"
@change="switchCategory"
>
<a-radio-button
v-for="tab in categoryList"
:key="tab.key"
:value="tab.key"
>{{ tab.label }}</a-radio-button>
</a-radio-group>
</div>
<FilterBar v-model="search" @search="searchQuery" @reset="resetQuery">
<!-- ... -->
</FilterBar>
<TableCard :table="table" :loading="pageLoading" @change="dataSourceChange">
<!-- ... -->
</TableCard>
</div>
</template>
```
**强制要求**
- 切换行使用 `.listPageRow` 全局类(与 listPage.less 对齐),`margin-bottom: 12px`
- `button-style="solid"` 必填(默认 outline 风格不符合规范)
- 切换项数据源在 `usePage.ts` 顶部以常量数组声明:`{ key, label }[]`
- 切换时重置搜索条件 + 回到第一页 + 重新拉列表(参考 `switchTab` 实现)
- `switchTab` 入参兼容 `string``RadioChangeEvent` 两种形态(`@change` 传 event,编程式调用传 string
**禁止行为**
- ❌ 同表格按维度过滤场景使用 `<a-tabs>` / `<a-tab-pane>`
- ❌ 省略 `button-style="solid"`(默认 outline 风格不符合规范)
- ❌ 把 radio-group 塞进 FilterBar 的 inline 表单内(视觉错位,应单独成行)
## 八、数据展示规范
| 数据类型 | 展示格式 |
+12
View File
@@ -2,6 +2,17 @@
> 对齐 platform-vue-tenant 的 `docs/tabs-page-spec.md`。
## 〇、适用范围(重要)
本规范**仅适用于**:每个 Tab 各自独立业务、各自独立 6 文件结构(独立 `usePage.ts` / `useTable.ts` / `useSearch.ts` / `api` / `types` / `component`)的场景。
**不适用**于"同一张表格按某个维度过滤出多类数据"的场景(如设备管理 5 类设备共用同一表格,仅 `category` 过滤参数不同)—— 此类场景必须用 `<a-radio-group button-style="solid">`,参见 `list-page-spec.md` §7.4「同级数据切换」。
| 场景 | 选型 | 规范 |
|------|------|------|
| 各 Tab 独立业务 / 独立 6 文件 | `<a-tabs class="aTabs">` | 本规范 |
| 同表格按维度过滤 | `<a-radio-group button-style="solid">` | list-page-spec.md §7.4 |
## 一、目录结构
```
@@ -90,3 +101,4 @@ import TabB from './component/tabs/tabB/tabB.vue'
- ❌ 禁止 Tab `useSearch.ts``Object.keys` 清空,必须 `Object.assign(search, createSearchKey())`
- ❌ 禁止 `useTable.ts` 直接传 `pagination` 给工具,必须传完整 `TableState`
- ❌ 禁止 `listRequest` 散写参数转换,统一 `buildParams()`
-**禁止同表格按维度过滤场景套用本规范**(须用 `<a-radio-group button-style="solid">`,参见 `list-page-spec.md` §7.4
+70 -8
View File
@@ -5,7 +5,7 @@
<!-- 状态栏 -->
<div class="phone-shell__status">
<span>9:41</span>
<span class="phone-shell__title">小程序</span>
<span class="phone-shell__title">{{ currentMp?.label ?? '小程序' }}</span>
<span>100%</span>
</div>
@@ -21,8 +21,26 @@
<div class="phone-shell__safe-area" />
</div>
<!-- 上角返回首页 -->
<!-- 上角返回原型首页 -->
<router-link to="/" class="mp-shell__home"> 返回原型首页</router-link>
<!-- 右上角切换小程序多于 1 个可见时显示 -->
<a-dropdown
v-if="switchableMps.length > 1"
placement="bottomRight"
class="mp-shell__switch"
>
<a class="mp-shell__switch-btn" @click.prevent>
🔀 切换小程序
</a>
<template #overlay>
<a-menu @click="onSwitchMp">
<a-menu-item v-for="mp in switchableMps" :key="mp.key">
{{ mp.icon }} {{ mp.label }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</template>
@@ -31,26 +49,53 @@
* 小程序布局
* - 375×812 手机壳(iPhone X 逻辑分辨率,与小程序通用模拟器一致)
* - 底部 Vant Tabbar(按当前角色过滤)
* - 支持多个小程序实例并存:按 route.meta.mp 取当前 mp,渲染该 mp 的 tabs
*/
import { miniprogramTabs, type NavMiniprogramTab } from '@meta/nav'
import { useVisibility, filterMiniprogramTabs } from '@composables/useVisibility'
import { miniprograms, type NavMiniprogram, type NavMiniprogramTab } from '@meta/nav'
import { useVisibility, filterMiniprograms, filterMiniprogramTabs } from '@composables/useVisibility'
const route = useRoute()
const router = useRouter()
const { role, overrides } = useVisibility()
/** 按角色过滤后的 tabs */
const visibleTabs = computed(() => {
/** 按角色过滤后的小程序实例列表 */
const filteredMps = computed<NavMiniprogram[]>(() => {
const effectiveRole = role.value ?? 'designer'
return filterMiniprogramTabs(miniprogramTabs, effectiveRole, overrides.value)
return filterMiniprograms(miniprograms, effectiveRole, overrides.value)
})
/** 当前 route.meta.mp 对应的小程序实例(找不到时回退到第一个可见 mp) */
const currentMp = computed<NavMiniprogram | undefined>(() => {
const mpKey = route.meta?.mp as string | undefined
return filteredMps.value.find(m => m.key === mpKey) ?? filteredMps.value[0]
})
/** 当前 mp 经角色过滤后的 tabs */
const visibleTabs = computed<NavMiniprogramTab[]>(() => {
const effectiveRole = role.value ?? 'designer'
if (!currentMp.value) return []
return filterMiniprogramTabs(currentMp.value.tabs, effectiveRole, overrides.value)
})
/** 当前激活 tab key */
const activeTabKey = computed<string>(() => (route.meta.tab as string) ?? visibleTabs.value[0]?.key ?? '')
/** 顶部"切换小程序"下拉里展示的 mp 列表(与可见列表一致) */
const switchableMps = computed<NavMiniprogram[]>(() => filteredMps.value)
/** Tab 切换 */
const onTabChange = (tab: NavMiniprogramTab) => {
void router.push(`/miniprogram/${tab.page.path}`)
if (!currentMp.value) return
void router.push(`/miniprogram/${currentMp.value.key}/${tab.page.path}`)
}
/** 切换小程序:跳到目标 mp 的第一个可见 tab */
const onSwitchMp = ({ key }: { key: string }): void => {
const target = filteredMps.value.find(m => m.key === key)
const firstTab = target?.tabs[0]
if (target && firstTab) {
void router.push(`/miniprogram/${target.key}/${firstTab.page.path}`)
}
}
</script>
@@ -78,6 +123,23 @@ const onTabChange = (tab: NavMiniprogramTab) => {
}
}
.mp-shell__switch {
position: absolute;
top: 16px;
right: 24px;
}
.mp-shell__switch-btn {
color: #666;
font-size: var(--font-sm);
cursor: pointer;
user-select: none;
&:hover {
color: var(--primary);
}
}
.phone-shell {
/** 小程序使用 iPhone X 逻辑分辨率 */
width: 375px;
+57
View File
@@ -0,0 +1,57 @@
<template>
<!-- NavGroup渲染为可展开的子菜单递归渲染 children -->
<a-sub-menu v-if="isGroup" :key="`group-${item.key}`">
<template #title>
<component :is="iconComp" />
<span>{{ item.label }}</span>
</template>
<MenuTreeNode
v-for="child in item.children"
:key="child.key"
:item="child"
@navigate="onNavigate"
/>
</a-sub-menu>
<!-- NavLeaf渲染为可点击菜单项 -->
<a-menu-item v-else :key="item.key" @click="onLeafClick">
<component :is="iconComp" />
<span>{{ item.label }}</span>
</a-menu-item>
</template>
<script setup lang="ts">
/**
* 菜单树节点(递归组件)
* - NavGroup → a-sub-menu(含图标 + 标题),递归渲染 children
* - NavLeaf → a-menu-item(含图标 + 标题),点击 emit navigate
*/
import { computed } from 'vue'
import { isNavGroup, type NavGroup, type NavLeaf } from '@meta/nav'
defineOptions({ name: 'MenuTreeNode' })
const props = defineProps<{
item: NavLeaf | NavGroup
}>()
const emit = defineEmits<{ (e: 'navigate', leaf: NavLeaf): void }>()
const isGroup = computed(() => isNavGroup(props.item))
/** 图标组件名(NavLeaf.icon 可选;NavGroup.icon 必填) */
const iconComp = computed(() => {
const icon = (props.item as { icon?: string }).icon
return icon ?? 'FileOutlined'
})
const onLeafClick = (): void => {
if (!isGroup.value) {
emit('navigate', props.item as NavLeaf)
}
}
const onNavigate = (leaf: NavLeaf): void => {
emit('navigate', leaf)
}
</script>
+57 -46
View File
@@ -2,43 +2,34 @@
<a-menu
v-if="section"
v-model:selectedKeys="selectedKeys"
v-model:openKeys="openKeys"
:open-keys="openKeys"
theme="dark"
mode="inline"
class="sidebar-menu"
@click="onMenuClick"
@openChange="onOpenChange"
>
<template v-for="item in section.children" :key="item.key">
<!-- 二级分组含三级页面 -->
<a-sub-menu v-if="isNavGroup(item)" :key="`group-${item.key}`">
<template #title>
<component :is="item.icon ?? 'AppstoreOutlined'" />
<span>{{ item.label }}</span>
</template>
<a-menu-item v-for="leaf in item.children" :key="leaf.key">
<component :is="leaf.icon ?? 'MinusOutlined'" />
<span>{{ leaf.label }}</span>
</a-menu-item>
</a-sub-menu>
<!-- 直接挂在 section 下的二级叶子 -->
<a-menu-item v-else :key="item.key">
<component :is="item.icon ?? 'FileOutlined'" />
<span>{{ item.label }}</span>
</a-menu-item>
</template>
<MenuTreeNode
v-for="item in section.children"
:key="item.key"
:item="item"
:active-key="activeKey"
@navigate="onNavigate"
/>
</a-menu>
</template>
<script setup lang="ts">
/**
* 侧边栏:渲染当前激活的一级菜单(NavSection)的 children
* - 通过 MenuTreeNode 递归组件支持任意层 NavGroup 嵌套
* - 图标统一使用 @ant-design/icons-vue(已在 main.ts 全量 app.component 注册)
* - 未显式指定图标时:分组用 AppstoreOutlined / 二级叶子用 FileOutlined / 三级叶子用 MinusOutlined
* - 未显式指定图标时:分组用 AppstoreOutlined / 叶子用 FileOutlined
* - 对齐 vue 项目 D:\Work\platform-vue-tenant\apps\subNutritionManagement\src\layouts\menu
*/
import type { MenuProps } from 'ant-design-vue'
import { isNavGroup, type NavLeaf, type NavSection } from '@meta/nav'
import MenuTreeNode from './MenuTreeNode.vue'
const props = defineProps<{
section: NavSection | undefined
@@ -47,16 +38,24 @@ const props = defineProps<{
const emit = defineEmits<{ (e: 'navigate', leaf: NavLeaf): void }>()
/** 当前包含激活叶子的分组 key(用于受控展开) */
/** 递归查找激活叶子所在的所有祖先分组 key(用于受控展开) */
const activeGroupKeys = computed<string[]>(() => {
if (!props.section) return []
const groups: string[] = []
for (const item of props.section.children) {
if (isNavGroup(item) && item.children.some(l => l.key === props.activeKey)) {
groups.push(`group-${item.key}`)
const result: string[] = []
const walk = (item: NavLeaf | { key: string; children?: any[] }): boolean => {
if (isNavGroup(item as any)) {
const hit = (item as any).children.some((c: any) => walk(c))
if (hit) {
result.push(`group-${item.key}`)
return true
}
} else {
return (item as NavLeaf).key === props.activeKey
}
return false
}
return groups
for (const item of props.section.children) walk(item as any)
return result
})
const openKeys = ref<string[]>([])
@@ -66,33 +65,47 @@ const selectedKeys = computed({
set: () => { /* 受控 */ },
})
/** 二级分组 key 集合(section.children 这一层,用于手风琴互斥) */
const topLevelGroupKeys = computed<Set<string>>(() => {
if (!props.section) return new Set()
return new Set(
props.section.children
.filter((item) => isNavGroup(item as any))
.map((item) => `group-${(item as any).key}`),
)
})
watch(
() => [props.section?.key, props.activeKey] as const,
() => {
if (activeGroupKeys.value.length) {
const merged = new Set([...openKeys.value, ...activeGroupKeys.value])
openKeys.value = Array.from(merged)
}
openKeys.value = [...activeGroupKeys.value]
},
{ immediate: true },
)
const findLeafByKey = (key: string): NavLeaf | undefined => {
if (!props.section) return undefined
for (const item of props.section.children) {
if (isNavGroup(item)) {
const hit = item.children.find(l => l.key === key)
if (hit) return hit
} else if (item.key === key) {
return item
}
/** 手风琴:展开一个二级分组时自动收起其他二级分组;激活路径仅由 watch 维护,不参与互斥补偿 */
const onOpenChange = (keys: string[]): void => {
const oldKeys = openKeys.value
const added = keys.filter((k) => !oldKeys.includes(k))
if (added.length === 0) {
openKeys.value = keys
return
}
return undefined
const firstAdded = added[0]
if (!topLevelGroupKeys.value.has(firstAdded)) {
openKeys.value = keys
return
}
const kept = keys.filter((k) => !topLevelGroupKeys.value.has(k) || k === firstAdded)
openKeys.value = Array.from(new Set(kept))
}
const onMenuClick: MenuProps['onClick'] = (info) => {
const leaf = findLeafByKey(String(info.key))
if (leaf) emit('navigate', leaf)
const onNavigate = (leaf: NavLeaf): void => {
emit('navigate', leaf)
}
const onMenuClick: MenuProps['onClick'] = () => {
/* 点击叶子时由 MenuTreeNode 内部 emit navigate 触发跳转 */
}
</script>
@@ -102,5 +115,3 @@ const onMenuClick: MenuProps['onClick'] = (info) => {
height: 100%;
}
</style>
+413 -43
View File
@@ -6,7 +6,9 @@
* 四个终端:
* - admin-portal 综合管理后台(AntDV 后台 UI;单应用形态,3 个一级菜单:系统设置 / 运营中心 / 设备中心)
* - tenant-portal 租户运营后台(AntDV 后台 UI;下挂 17 个独立应用)
* - miniprogram C 端微信小程序(Vant375×812吃 / 问 / 做 / 我
* - miniprogram 微信小程序(Vant375×812支持多个小程序实例并存,每个实例一组底部 Tab
* · customer:C 端用户小程序(吃 / 问 / 做 / 我)
* · ops:实施运维小程序(工作台 / 工单 / 扫码 / 巡检 / 我的,仅运维工程师)
* - hardware 硬件终端屏(按设备类型动态 viewport;10 类设备)
*
* 后台菜单层级(对齐生产 vue 项目 D:\Work\platform-vue-tenant):
@@ -25,6 +27,9 @@
* - 渲染:layouts 用 `<component :is="icon" />` 动态渲染
*/
import { defineComponent, h } from 'vue'
import PlaceholderPage from '@components/PlaceholderPage.vue'
/** 终端类型 */
export type PortalKey = 'admin-portal' | 'tenant-portal' | 'miniprogram' | 'hardware'
@@ -99,6 +104,25 @@ export interface NavMiniprogramTab {
subPages?: NavLeaf[]
}
/**
* 单个微信小程序实例(一个小程序 = 一组底部 Tab)
* - 项目支持多个微信小程序并存(如 C 端用户小程序、实施运维小程序)
* - 每个小程序有独立的 key / Tabs / 页面目录
* - URL/miniprogram/<mpKey>/<tabPagePath>
*/
export interface NavMiniprogram {
/** 唯一 key(同时作为路由路径片段,如 customer / ops */
key: string
/** 小程序名(首页卡片标题 / 状态栏标题) */
label: string
/** 小程序图标(emoji,首页卡片用) */
icon?: string
/** 小程序描述(首页卡片副标题) */
description?: string
/** 该小程序的底部 Tab 列表 */
tabs: NavMiniprogramTab[]
}
/** 硬件设备的视口规格 */
export interface NavHardwareViewport {
/** 设备宽度(CSS px */
@@ -168,17 +192,34 @@ const makePlaceholderApp = (
/**
* 综合管理后台占位叶子
* - 与 tenant 的 placeholder 工厂分开,避免误读为"占位应用"
* - 通过内联 defineComponent 渲染 PlaceholderPage 并传入 title/name
* 让占位页能正确显示标题与路由信息(无需为每个占位页生成独立 .vue 文件)
*
* @param key 叶子 key(全局唯一,作 vue-router name
* @param label 菜单显示文字
* @param path 相对路径片段(不含 / 前缀)
* @param section 所属一级菜单 key,用于拼接 name 展示
*/
const adminPlaceholderLeaf = (
key: string,
label: string,
path: string,
section = 'admin',
): NavLeaf => ({
key,
label,
path,
status: 'draft',
component: PlaceholderImport,
component: () => Promise.resolve(
defineComponent({
name: key,
render: () =>
h(PlaceholderPage, {
title: label,
name: `admin-portal/${section}/${path}`,
}),
}),
),
})
/**
@@ -192,7 +233,70 @@ export const adminSections: NavSection[] = [
label: '系统设置',
icon: 'SettingOutlined',
children: [
adminPlaceholderLeaf('admin-system-settings-overview', '系统设置首页', 'overview'),
{
key: 'admin-system-permission',
label: '权限管理',
icon: 'SafetyCertificateOutlined',
children: [
{
key: 'admin-system-employee-management',
label: '员工管理',
path: 'permission/employee',
status: 'ready',
component: () => import('@pages/admin-portal/system-settings/permission/employeeManagement/employeeManagement.vue'),
},
{
key: 'admin-system-role-management',
label: '角色管理',
path: 'permission/role',
status: 'ready',
component: () => import('@pages/admin-portal/system-settings/permission/roleManagement/roleManagement.vue'),
},
],
},
{
key: 'admin-system-user',
label: '用户管理',
icon: 'UserOutlined',
children: [
{
key: 'admin-system-mini-program-user',
label: '小程序用户',
path: 'user/miniprogram',
status: 'ready',
component: () => import('@pages/admin-portal/system-settings/user/miniProgramUser/miniProgramUser.vue'),
},
],
},
{
key: 'admin-system-app-service',
label: '应用与服务',
icon: 'AppstoreOutlined',
children: [
{
key: 'admin-system-application-management',
label: '应用管理',
path: 'app-service/application',
status: 'ready',
component: () => import('@pages/admin-portal/system-settings/appService/applicationManagement/applicationManagement.vue'),
},
{
key: 'admin-system-menu-management',
label: '菜单管理',
path: 'app-service/menu',
status: 'ready',
component: () => import('@pages/admin-portal/system-settings/appService/menuManagement/menuManagement.vue'),
},
],
},
{
key: 'admin-system-data-dictionary',
label: '数据字典',
path: 'dictionary',
icon: 'BookOutlined',
status: 'ready',
component: () => import('@pages/admin-portal/system-settings/dataDictionary/dataDictionary.vue'),
},
],
},
{
@@ -200,7 +304,20 @@ export const adminSections: NavSection[] = [
label: '运营中心',
icon: 'FundProjectionScreenOutlined',
children: [
adminPlaceholderLeaf('admin-operation-center-overview', '运营中心首页', 'overview'),
{
key: 'admin-operation-tenant',
label: '租户管理',
icon: 'TeamOutlined',
children: [
{
key: 'admin-operation-tenant-list',
label: '租户列表',
path: 'tenant/list',
status: 'ready',
component: () => import('@pages/admin-portal/operation-center/tenantManagement/tenantList/tenantList.vue'),
},
],
},
],
},
{
@@ -208,7 +325,187 @@ export const adminSections: NavSection[] = [
label: '设备中心',
icon: 'DesktopOutlined',
children: [
adminPlaceholderLeaf('admin-device-center-overview', '设备中心首页', 'overview'),
// 1. 工作台
{
key: 'admin-device-workbench',
label: '工作台',
icon: 'DashboardOutlined',
children: [
{
key: 'admin-device-workbench-overview',
label: '数据概览',
path: 'workbench/overview',
status: 'ready',
component: () => import('@pages/admin-portal/device-center/workbench/overview/overview.vue'),
},
],
},
// 2. 设备资产(瘦身:仅 3 个二级菜单)
{
key: 'admin-device-asset',
label: '设备资产',
icon: 'DatabaseOutlined',
children: [
{
key: 'admin-device-asset-category',
label: '设备分类',
path: 'asset/category',
status: 'ready',
component: () => import('@pages/admin-portal/device-center/asset/category/category.vue'),
},
{
key: 'admin-device-asset-manage',
label: '设备管理',
path: 'asset/manage',
status: 'ready',
component: () => import('@pages/admin-portal/device-center/asset/manage/manage.vue'),
},
{
key: 'admin-device-asset-statistics',
label: '设备统计',
path: 'asset/statistics',
status: 'ready',
component: () => import('@pages/admin-portal/device-center/asset/statistics/statistics.vue'),
},
],
},
// 3. 库存管理(原"设备库存"更名,4 个二级菜单)
{
key: 'admin-device-stock',
label: '库存管理',
icon: 'InboxOutlined',
children: [
{
key: 'admin-device-stock-instock',
label: '在库库存',
path: 'stock/instock',
status: 'ready',
component: () => import('@pages/admin-portal/device-center/stock/instock/instock.vue'),
},
{
key: 'admin-device-stock-inbound',
label: '入库管理',
path: 'stock/inbound',
status: 'ready',
component: () => import('@pages/admin-portal/device-center/stock/inbound/inbound.vue'),
},
{
key: 'admin-device-stock-outbound',
label: '出库管理',
path: 'stock/outbound',
status: 'ready',
component: () => import('@pages/admin-portal/device-center/stock/outbound/outbound.vue'),
},
{
key: 'admin-device-stock-supplier',
label: '供应商管理',
path: 'stock/supplier',
status: 'ready',
component: () => import('@pages/admin-portal/device-center/stock/supplier/supplier.vue'),
},
],
},
// 4. 租户与场所(更名:原"客户与场所")
{
key: 'admin-device-tenant-site',
label: '租户与场所',
icon: 'TeamOutlined',
children: [
{
key: 'admin-device-tenant-site-tenant',
label: '租户管理',
path: 'tenant-site/tenant',
status: 'ready',
component: () => import('@pages/admin-portal/device-center/tenantSite/tenant/tenant.vue'),
},
{
key: 'admin-device-tenant-site-site',
label: '布点场所',
path: 'tenant-site/site',
status: 'ready',
component: () => import('@pages/admin-portal/device-center/tenantSite/site/site.vue'),
},
],
},
// 5. 设备运维(重组:原"故障与维修"+ 原"系统设置"迁入,6 个二级菜单)
{
key: 'admin-device-ops',
label: '设备运维',
icon: 'ToolOutlined',
children: [
{
key: 'admin-device-ops-init',
label: '设备初始化',
path: 'ops/init',
status: 'ready',
component: () => import('@pages/admin-portal/device-center/ops/init/init.vue'),
},
{
key: 'admin-device-ops-monitor',
label: '设备监测',
path: 'ops/monitor',
status: 'ready',
component: () => import('@pages/admin-portal/device-center/ops/monitor/monitor.vue'),
},
{
key: 'admin-device-ops-work-order',
label: '工单管理',
path: 'ops/work-order',
status: 'ready',
component: () => import('@pages/admin-portal/device-center/ops/workOrder/workOrder.vue'),
},
{
key: 'admin-device-ops-version',
label: '版本管理',
path: 'ops/version',
status: 'ready',
component: () => import('@pages/admin-portal/device-center/ops/version/version.vue'),
},
{
key: 'admin-device-ops-alarm',
label: '告警管理',
path: 'ops/alarm',
status: 'ready',
component: () => import('@pages/admin-portal/device-center/ops/alarm/alarm.vue'),
},
{
key: 'admin-device-ops-config',
label: '系统配置',
path: 'ops/config',
status: 'ready',
component: () => import('@pages/admin-portal/device-center/ops/config/config.vue'),
},
],
},
// 6. 数据分发(原"数据中转"更名,本期仅支持接口回调,3 个二级菜单)
{
key: 'admin-device-distribute',
label: '数据分发',
icon: 'SwapOutlined',
children: [
{
key: 'admin-device-distribute-config',
label: '分发配置',
path: 'distribute/config',
status: 'ready',
component: () => import('@pages/admin-portal/device-center/distribute/config/config.vue'),
},
{
key: 'admin-device-distribute-record',
label: '分发记录',
path: 'distribute/record',
status: 'ready',
component: () => import('@pages/admin-portal/device-center/distribute/record/record.vue'),
},
{
key: 'admin-device-distribute-deadletter',
label: '失败重投',
path: 'distribute/deadletter',
status: 'ready',
component: () => import('@pages/admin-portal/device-center/distribute/deadletter/deadletter.vue'),
},
],
},
],
},
]
@@ -582,47 +879,120 @@ export const tenantApps: NavApp[] = [
// =========================== C 端微信小程序 ===========================
/** 小程序四个底部 Tab:吃 / 问 / 做 / 我 */
export const miniprogramTabs: NavMiniprogramTab[] = [
/**
* 微信小程序实例列表(支持多个并存)
* - customerC 端用户小程序(原 4 Tab:吃 / 问 / 做 / 我)
* - ops:实施运维小程序(5 Tab:工作台 / 工单 / 扫码 / 巡检 / 我的),仅运维工程师使用
*
* URL/miniprogram/<mpKey>/<tabPagePath>
* 目录:src/pages/miniprogram/<mpKey>/<tabKey>/...
*/
export const miniprograms: NavMiniprogram[] = [
{
key: 'eat',
label: '',
icon: 'shop-o',
page: {
key: 'mp-eat-home', label: '吃', path: 'eat',
component: () => import('@pages/miniprogram/eat/Home.vue'),
status: 'draft',
},
key: 'customer',
label: 'C 端用户小程序',
icon: '👤',
description: '终端用户自助查询、上报、咨询(吃 / 问 / 做 / 我)',
tabs: [
{
key: 'eat',
label: '吃',
icon: 'shop-o',
page: {
key: 'mp-customer-eat-home', label: '吃', path: 'eat',
component: () => import('@pages/miniprogram/customer/eat/Home.vue'),
status: 'draft',
},
},
{
key: 'ask',
label: '问',
icon: 'chat-o',
page: {
key: 'mp-customer-ask-home', label: '问', path: 'ask',
component: () => import('@pages/miniprogram/customer/ask/Home.vue'),
status: 'draft',
},
},
{
key: 'make',
label: '做',
icon: 'apps-o',
page: {
key: 'mp-customer-make-home', label: '做', path: 'make',
component: () => import('@pages/miniprogram/customer/make/Home.vue'),
status: 'draft',
},
},
{
key: 'me',
label: '我',
icon: 'user-o',
page: {
key: 'mp-customer-me-home', label: '我', path: 'me',
component: () => import('@pages/miniprogram/customer/me/Home.vue'),
status: 'draft',
},
},
],
},
{
key: 'ask',
label: '',
icon: 'chat-o',
page: {
key: 'mp-ask-home', label: '问', path: 'ask',
component: () => import('@pages/miniprogram/ask/Home.vue'),
status: 'draft',
},
},
{
key: 'make',
label: '做',
icon: 'apps-o',
page: {
key: 'mp-make-home', label: '做', path: 'make',
component: () => import('@pages/miniprogram/make/Home.vue'),
status: 'draft',
},
},
{
key: 'me',
label: '我',
icon: 'user-o',
page: {
key: 'mp-me-home', label: '我', path: 'me',
component: () => import('@pages/miniprogram/me/Home.vue'),
status: 'draft',
},
key: 'ops',
label: '实施运维小程序',
icon: '🔧',
description: '现场作业移动终端:扫码、工单、巡检、维修上报(仅运维工程师)',
tabs: [
{
key: 'workbench',
label: '工作台',
icon: 'home-o',
page: {
key: 'mp-ops-workbench-home', label: '工作台', path: 'workbench',
component: () => import('@pages/miniprogram/ops/workbench/Home.vue'),
status: 'draft',
},
},
{
key: 'work-order',
label: '工单',
icon: 'orders-o',
page: {
key: 'mp-ops-work-order-home', label: '工单', path: 'work-order',
component: () => import('@pages/miniprogram/ops/workOrder/Home.vue'),
status: 'draft',
},
},
{
key: 'scan',
label: '扫码',
icon: 'scan-o',
page: {
key: 'mp-ops-scan-home', label: '扫码', path: 'scan',
component: () => import('@pages/miniprogram/ops/scan/Home.vue'),
status: 'draft',
},
},
{
key: 'inspect',
label: '巡检',
icon: 'todo-list-o',
page: {
key: 'mp-ops-inspect-home', label: '巡检', path: 'inspect',
component: () => import('@pages/miniprogram/ops/inspect/Home.vue'),
status: 'draft',
},
},
{
key: 'me',
label: '我的',
icon: 'user-o',
page: {
key: 'mp-ops-me-home', label: '我的', path: 'me',
component: () => import('@pages/miniprogram/ops/me/Home.vue'),
status: 'draft',
},
},
],
},
]
@@ -1,12 +0,0 @@
<template>
<PlaceholderPage
title="设备中心 - 首页"
name="admin-portal/device-center/overview"
>
综合管理后台占位设备中心 的二级菜单待规划后通过 /new-page 生成
</PlaceholderPage>
</template>
<script setup lang="ts">
/** 设备中心 首页占位 */
</script>
@@ -0,0 +1,19 @@
import type { ApiResponse } from '@axios'
import { postRequest } from '@axios'
import type { CategoryNode, CategoryPayload, SearchForm } from '../types'
/** 查询设备分类全树(按关键字过滤) */
export const tree = (params: SearchForm): Promise<ApiResponse<CategoryNode[]>> =>
postRequest('axiosRequest', '/admin/device/category/tree', params)
/** 新增分类节点 */
export const add = (payload: CategoryPayload): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/category/add', payload)
/** 更新分类节点 */
export const update = (payload: CategoryPayload): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/category/update', payload)
/** 删除分类节点(含子节点级联删除) */
export const remove = (id: string): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/category/delete', { id })
@@ -0,0 +1,157 @@
<template>
<div class="admin-list-page category-page">
<a-row :gutter="12" class="category-page__body">
<a-col :span="10">
<a-card class="category-page__tree" :bordered="false">
<div class="category-page__tree-toolbar">
<a-input-search
v-model:value="search.keyword"
v-no-space
allow-clear
placeholder="按名称 / 编码过滤"
style="width: 100%"
@search="searchQuery"
/>
</div>
<a-spin :spinning="pageLoading">
<a-empty v-if="!treeData.length" description="暂无分类数据" />
<a-tree
v-else
:tree-data="treeData"
:field-names="fieldNames"
:selected-keys="selectedKeys"
:expanded-keys="expandedKeys"
show-line
block-node
@select="onSelect"
@expand="onExpand"
>
<template #title="{ name, isIot, level }">
<span>{{ name }}</span>
<a-tag v-if="level === 1" color="blue" class="category-page__tag">顶级分类</a-tag>
<a-tag v-if="level >= 2 && !isIot" class="category-page__tag"> IOT</a-tag>
</template>
</a-tree>
</a-spin>
</a-card>
</a-col>
<a-col :span="14">
<a-card class="category-page__detail" :bordered="false">
<template #title>类别详情</template>
<template #extra>
<a-button type="link" size="small" :disabled="!selectedNode" @click="openAddModal(selectedNode ?? undefined)">新增下级分类</a-button>
<a-divider type="vertical" />
<a-button type="link" size="small" :disabled="!selectedNode" @click="openEditModal">编辑</a-button>
<a-divider type="vertical" />
<a-button type="link" size="small" danger :disabled="!selectedNode" @click="deleteNode">删除</a-button>
</template>
<a-empty v-if="!selectedNode" description="请选择左侧节点查看类别详情" />
<a-descriptions v-else :column="2" bordered size="small">
<a-descriptions-item label="层级">{{ levelLabel(selectedNode.level) }}</a-descriptions-item>
<a-descriptions-item label="名称">{{ selectedNode.name ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="编码">{{ selectedNode.code ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="排序">{{ selectedNode.sortOrder ?? '—' }}</a-descriptions-item>
<!-- 业务字段 level >= 2 节点显示 -->
<template v-if="selectedNode.level >= 2">
<a-descriptions-item label="是否 IOT 设备">
<a-tag :color="selectedNode.isIot ? 'green' : 'default'">
{{ selectedNode.isIot ? '是' : '否' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="是否启用数据分发">
<a-tag :color="selectedNode.dataTransit ? 'green' : 'default'">
{{ selectedNode.dataTransit ? '是' : '否' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="是否启用数据上报">
<a-tag :color="selectedNode.dataReport ? 'green' : 'default'">
{{ selectedNode.dataReport ? '是' : '否' }}
</a-tag>
</a-descriptions-item>
<!-- 叶子节点专属字段仅当无子节点时显示 -->
<template v-if="!selectedNode.children?.length">
<a-descriptions-item label="厂商">
{{ manufacturerLabel(selectedNode.manufacturer) }}
</a-descriptions-item>
<a-descriptions-item label="型号">
{{ selectedNode.model ?? '—' }}
</a-descriptions-item>
</template>
</template>
<a-descriptions-item label="备注" :span="2">{{ selectedNode.remark ?? '—' }}</a-descriptions-item>
</a-descriptions>
</a-card>
</a-col>
</a-row>
<AddOrEdit ref="addOrEditRef" @load="treeRequest" />
</div>
</template>
<script setup lang="ts">
import AddOrEdit from './component/modal/addOrEdit/addOrEdit.vue'
import { usePage } from './init/usePage'
const {
pageLoading,
search,
treeData,
selectedKeys,
expandedKeys,
fieldNames,
selectedNode,
addOrEditRef,
manufacturerLabel,
levelLabel,
searchQuery,
onSelect,
onExpand,
openAddModal,
openEditModal,
deleteNode,
treeRequest,
} = usePage()
</script>
<style scoped lang="less">
@import "@assets/styles/listPage.less";
.admin-list-page {
display: flex;
flex-direction: column;
height: 100%;
}
.category-page {
&__body {
flex: 1;
min-height: 0;
margin: 0 !important;
}
&__tree,
&__detail {
height: 100%;
overflow: auto;
:deep(.ant-card-body) {
padding: 16px 20px;
}
}
&__tree-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
&__tag {
margin-left: 6px;
transform: scale(0.85);
transform-origin: left center;
}
}
</style>
@@ -0,0 +1,209 @@
<template>
<a-modal
v-model:open="pageInfo.visible"
:title="pageInfo.title"
:width="pageInfo.width"
:keyboard="false"
:mask-closable="false"
>
<a-spin :spinning="pageInfo.spin">
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="{ style: { width: '110px', minWidth: '110px' } }"
>
<!-- 层级只读展示 -->
<a-form-item label="层级">
<a-tag :color="form.level === 1 ? 'blue' : 'cyan'">
{{ levelText }}
</a-tag>
</a-form-item>
<!-- 父级分类新增下级时展示顶级分类不展示 -->
<a-form-item v-if="form.level > 1" label="父级分类">
<span>{{ form.parentName ?? '—' }}</span>
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="名称" name="name">
<a-input
v-model:value="form.name"
v-no-space
allow-clear
:maxlength="30"
placeholder="请输入节点名称"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="编码">
<!-- code 由系统按 D01A01 规则自动生成前端只读展示 -->
<span v-if="form.code" class="add-or-edit__readonly">{{ form.code }}</span>
<span v-else class="add-or-edit__hint">保存后由系统按 D01A01 规则自动生成</span>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="排序" name="sortOrder">
<a-input-number
v-model:value="form.sortOrder"
:min="1"
:max="9999"
style="width: 100%"
placeholder="同级排序"
/>
</a-form-item>
<!-- 业务字段 level >= 2 节点显示 -->
<template v-if="showBusinessFields">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="是否 IOT 设备">
<a-switch
v-model:checked="form.isIot"
checked-children=""
un-checked-children=""
/>
<span class="add-or-edit__hint">标记该分类下设备是否为物联网设备</span>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="启用数据分发">
<a-switch
v-model:checked="form.dataTransit"
checked-children=""
un-checked-children=""
/>
<span class="add-or-edit__hint">设备实例继承此配置</span>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="启用数据上报">
<a-switch
v-model:checked="form.dataReport"
checked-children=""
un-checked-children=""
/>
<span class="add-or-edit__hint">仅控制开关状态上报地址由设备实例侧配置</span>
</a-form-item>
</template>
<!-- 叶子节点专属厂商 / 型号 -->
<template v-if="showLeafFields">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="厂商" name="manufacturer">
<a-select
v-model:value="form.manufacturer"
allow-clear
show-search
:options="manufacturerOptions"
:filter-option="filterOption"
placeholder="请选择厂商"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="型号" name="model">
<a-input
v-model:value="form.model"
v-no-space
allow-clear
:maxlength="60"
placeholder="请输入设备型号"
/>
</a-form-item>
</a-col>
</a-row>
</template>
<a-form-item label="备注">
<a-textarea
v-model:value="form.remark"
:rows="3"
:maxlength="200"
show-count
placeholder="可选,分类补充说明"
/>
</a-form-item>
</a-form>
</a-spin>
<template #footer>
<a-button @click="closeModal">取消</a-button>
<a-button
type="primary"
:disabled="pageInfo.spin"
:loading="pageInfo.spin"
@click="submit"
>确定</a-button>
</template>
</a-modal>
</template>
<script setup lang="ts">
/**
* 设备分类 - 新增/编辑 弹框
*
* modal-spec.md 强制项已遵循:
* - a-modal :keyboard="false" :mask-closable="false"
* - a-spin 包裹整个 a-form
* - 确定按钮 :disabled + :loading 都用 pageInfo.spin
* - defineExpose 只暴露 openModal
*
* 业务字段(对齐类别详情):
* - 层级 / 父级分类 / 编码:只读展示
* - 名称 / 排序 / 备注:所有节点通用
* - 是否 IOT / 数据分发 / 数据上报:仅 level >= 2 节点显示
* - 厂商 / 型号:仅叶子节点显示(新增视为叶子;编辑看是否有子)
*/
import { computed } from 'vue'
import { filterOption } from '@utils/antDesign/select'
import { usePage } from './init/usePage'
const emit = defineEmits<{
/** 操作成功后通知父组件刷新树 */
(e: 'load'): void
}>()
const {
pageInfo,
formRef,
form,
rules,
manufacturerOptions,
showBusinessFields,
showLeafFields,
openModal,
closeModal,
submit,
} = usePage(emit)
/** 层级中文文案:1=顶级分类,其余按"第 N 级分类" */
const levelText = computed<string>(() => {
const lv = form.value.level
if (lv === 1) return '顶级分类'
const cn = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
const text = lv <= 10 ? cn[lv] : String(lv)
return `${text}级分类`
})
defineExpose({ openModal })
</script>
<style scoped lang="less">
.add-or-edit__hint {
margin-left: 12px;
color: var(--text-secondary, #8c8c8c);
font-size: 12px;
}
.add-or-edit__readonly {
font-weight: 500;
color: var(--text-primary, #262626);
}
</style>
@@ -0,0 +1,21 @@
/**
* 设备分类 - 新增/编辑 弹框 - 接口层
* - 复用主页面 add/update 接口
* - 新增 info 接口供编辑回填(返回完整 CategoryNode,含 children 数组用于判断叶子状态)
*/
import type { ApiResponse } from '@axios'
import { postRequest } from '@axios'
import type { CategoryNode } from '@pages/admin-portal/device-center/asset/category/types'
/** 详情查询(编辑回填,返回完整 CategoryNode */
export const info = (id: string): Promise<ApiResponse<CategoryNode>> =>
postRequest('axiosRequest', '/admin/device/category/info', { id })
/** 新增 */
export const add = (params: Record<string, unknown>): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/category/add', params)
/** 编辑 */
export const edit = (params: Record<string, unknown>): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/category/update', params)
@@ -0,0 +1,246 @@
/**
* 设备分类 - 新增/编辑 弹框 - 主逻辑
*
* 严格遵循 modal-spec.md
* - createInitForm / createInitOptions 工厂函数
* - openModal 用 Promise.all 并发拉取详情
* - addRequest 必须 delete params.id
* - submit 开头立即 pageInfo.spin = true
* - successRequest 接收 res.msg
* - 用 params.id 判断新增/编辑(禁用 type 文本判断)
*
* 设备分类特殊点:
* - 树形结构,任意叶子均可继续新增下级分类
* - code 由系统按 D01A01 规则自动生成,弹框内只读展示
* - 业务字段(IOT/数据分发/数据上报)仅在 level >= 2 节点显示
* - 厂商/型号仅在叶子节点(新增视为叶子;编辑时无子)显示
*/
import type { FormInstance, Rule } from 'ant-design-vue/es/form'
import { computed, ref } from 'vue'
import { dictItems } from '@api'
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
import { add, edit, info } from '../api'
import type { CategoryNode } from '@pages/admin-portal/device-center/asset/category/types'
import type { Form, ModalType, OpenContext, PageInfo } from '../types'
/** 初始表单工厂 */
const createInitForm = (): Form => ({
id: undefined,
name: '',
code: '',
level: 1,
parentId: undefined,
parentName: undefined,
sortOrder: 99,
isIot: false,
dataTransit: false,
dataReport: false,
manufacturer: undefined,
model: undefined,
hasChildren: false,
remark: '',
})
export const usePage = (emit: (e: 'load') => void) => {
const { message } = useAntdStaticMethods()
const pageInfo = reactive<PageInfo>({
visible: false,
type: 'add',
title: '',
width: 640,
spin: false,
})
const formRef = ref<FormInstance>()
const form = ref<Form>(createInitForm())
/** 厂商字典选项(首次打开弹框时懒加载,后续复用) */
const manufacturerOptions = ref<Array<{ label: string; value: number }>>([])
const loadManufacturerOptions = (): void => {
if (manufacturerOptions.value.length > 0) return
dictItems('device_manufacturer')
.then((res) => {
if (res.code === '00000') {
manufacturerOptions.value = (res.data ?? []) as Array<{ label: string; value: number }>
}
})
.catch((err: unknown) => {
console.error('加载厂商字典失败:', err)
})
}
const rules: Record<string, Rule[]> = {
name: [{ required: true, message: '请输入节点名称', trigger: 'blur' }],
sortOrder: [{ required: true, type: 'number', message: '请输入排序号', trigger: 'blur' }],
}
/** 是否展示业务字段(IOT / 数据分发 / 数据上报):仅 level >= 2 */
const showBusinessFields = computed<boolean>(() => form.value.level >= 2)
/** 是否为叶子节点:新增视为叶子;编辑看是否有子 */
const isLeaf = computed<boolean>(() => {
if (pageInfo.type === 'add') return true
return !form.value.hasChildren
})
/** 是否展示叶子专属字段(厂商 / 型号):业务字段可见 且 当前为叶子 */
const showLeafFields = computed<boolean>(() => showBusinessFields.value && isLeaf.value)
const resetForm = (): void => {
formRef.value?.clearValidate()
form.value = createInitForm()
}
const closeModal = (): void => {
resetForm()
pageInfo.visible = false
}
const infoRequest = (id: string) => info(id)
/**
* 打开弹框
* - ctx.id 存在:编辑模式,拉取详情回填
* - ctx.parentId 存在:新增下级分类,预置父级上下文(level = parent.level + 1
* - ctx 为空:新增顶级分类(level=1)
*/
const openModal = (type: ModalType, ctx?: OpenContext): void => {
pageInfo.visible = true
pageInfo.spin = true
pageInfo.type = type
resetForm()
/** 懒加载厂商字典(仅在首次打开时拉取,后续复用缓存) */
loadManufacturerOptions()
/** 预置父级上下文(新增下级分类时) */
if (ctx?.parentId) {
form.value.parentId = ctx.parentId
form.value.parentName = ctx.parentName
form.value.level = ctx.level ?? 2
/** 新增下级默认关闭三个开关,由用户显式配置 */
form.value.isIot = false
form.value.dataTransit = false
form.value.dataReport = false
} else {
form.value.level = ctx?.level ?? 1
}
pageInfo.title = type === 'add'
? (form.value.level === 1 ? '新增顶级分类' : `新增下级分类${ctx?.parentName ? `(父级:${ctx.parentName}` : ''}`)
: '编辑分类节点'
const requests: Promise<unknown>[] = []
if (type === 'edit' && ctx?.id) requests.push(infoRequest(ctx.id))
Promise.all(requests)
.then((results) => {
if (type === 'edit' && ctx?.id) {
const infoRes = results[0] as { code: string; data: CategoryNode }
if (infoRes?.code === '00000' && infoRes.data) {
/** 剥离 children,转为 hasChildren 标志位 */
const { children, ...rest } = infoRes.data
form.value = {
...createInitForm(),
...rest,
hasChildren: !!(children?.length),
}
}
}
})
.catch((err: unknown) => {
console.error('打开弹框初始化失败:', err)
})
.finally(() => {
pageInfo.spin = false
})
}
const successRequest = (msg: string): void => {
void message.success(msg)
emit('load')
closeModal()
}
/** 提交前清洗:根据 level / leaf 状态剥离不应提交的字段 */
const buildPayload = (raw: Form): Record<string, unknown> => {
const params = JSON.parse(JSON.stringify(raw)) as Record<string, unknown>
/** id 仅编辑保留 */
if (!params.id) delete params.id
/** code 由系统生成,前端不提交 */
delete params.code
/** hasChildren 为前端展示用,不提交 */
delete params.hasChildren
/** 顶级节点不提交业务字段 */
if (raw.level < 2) {
delete params.isIot
delete params.dataTransit
delete params.dataReport
delete params.manufacturer
delete params.model
return params
}
/** 非叶子节点不提交厂商/型号 */
if (!isLeaf.value) {
delete params.manufacturer
delete params.model
}
return params
}
const addRequest = (params: Record<string, unknown>): void => {
add(params)
.then((res) => {
if (res.code === '00000') successRequest(res.msg)
})
.catch((err: unknown) => {
console.error('新增失败:', err)
})
.finally(() => {
pageInfo.spin = false
})
}
const editRequest = (params: Record<string, unknown>): void => {
edit(params)
.then((res) => {
if (res.code === '00000') successRequest(res.msg)
})
.catch((err: unknown) => {
console.error('编辑失败:', err)
})
.finally(() => {
pageInfo.spin = false
})
}
const submit = (): void => {
pageInfo.spin = true
formRef.value
?.validate()
.then(() => {
const params = buildPayload(form.value)
if (params.id) editRequest(params)
else addRequest(params)
})
.catch((err: unknown) => {
pageInfo.spin = false
console.error('表单验证失败:', err)
})
}
return {
pageInfo,
formRef,
form,
rules,
manufacturerOptions,
showBusinessFields,
showLeafFields,
openModal,
closeModal,
submit,
}
}
@@ -0,0 +1,60 @@
/**
* 设备分类 - 新增/编辑 弹框 - 类型定义
*
* 严格遵循 modal-spec.md
* - Form.id 统一 string,新增时 undefined
* - 用 params.id 判断新增/编辑(禁用 type 文本判断)
* - 层级与父级由调用方在 openModal 时传入,弹框内只读展示
* - code 由系统按 D01A01 规则自动生成,弹框内只读展示
*/
export type ModalType = 'add' | 'edit'
/** 弹框状态 */
export interface PageInfo {
visible: boolean
type: ModalType
title: string
width: number
spin: boolean
}
/** 表单数据 */
export interface Form {
/** id 统一 string,新增时 undefined */
id?: string
/** 节点名称 */
name: string
/** 节点编码:新增时为空(保存后由系统生成),编辑时回填 */
code: string
/** 层级:1=顶级分类,2=二级分类,依此类推 */
level: number
/** 父节点 ID(顶级分类为 undefined */
parentId?: string
/** 父节点名称(弹框内只读展示,便于用户辨识) */
parentName?: string
/** 排序号 */
sortOrder: number
/** 是否 IOT 设备(仅 level >= 2 节点有效) */
isIot: boolean
/** 是否启用数据分发(仅 level >= 2 节点有效) */
dataTransit: boolean
/** 是否启用数据上报(仅 level >= 2 节点有效) */
dataReport: boolean
/** 厂商(字典 device_manufacturer 的 value;仅叶子节点有效) */
manufacturer?: number | undefined
/** 型号(仅叶子节点有效) */
model?: string | undefined
/** 当前编辑节点是否有子节点(用于判断是否叶子,决定厂商/型号字段显隐) */
hasChildren?: boolean
/** 备注 */
remark?: string
}
/** 打开弹框时传入的上下文(edit 用 idadd sub 用 parentId/parentName/level */
export interface OpenContext {
id?: string
parentId?: string
parentName?: string
level?: number
}
@@ -0,0 +1,225 @@
import { onMounted, reactive, ref } from 'vue'
import { dictItems } from '@api'
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
import { remove, tree } from '../api'
import AddOrEdit from '../component/modal/addOrEdit/addOrEdit.vue'
import type { CategoryNode, SearchForm } from '../types'
import { createSearchKey } from './useSearch'
import { useTable } from './useTable'
/**
* 设备分类页主编排:
* - 左侧 a-tree 展示分类树(业务上支持任意层级嵌套)
* - 右侧 a-card 展示选中"类别详情" + 操作按钮
* - 新增/编辑走子组件 AddOrEditmodal-spec 4 文件结构)
* - 顶级节点:仅名称/编码/排序/备注
* - 二级及以下节点:携带 IOT/数据分发/数据上报
* - 叶子节点:额外携带厂商/型号
* - code 由系统按 D01A01 规则自动生成
*/
export const usePage = () => {
const { message, Modal } = useAntdStaticMethods()
const {
treeData,
selectedKeys,
expandedKeys,
pageLoading,
fieldNames,
collectExpandableKeys,
resetTree,
} = useTable()
const search = reactive<SearchForm>(createSearchKey())
/** 当前选中的节点对象(基于 selectedKeys 计算) */
const selectedNode = ref<CategoryNode | null>(null)
/** 新增/编辑子组件 ref(必须在 usePage.ts 中定义) */
const addOrEditRef = ref<InstanceType<typeof AddOrEdit> | null>(null)
/** 厂商字典选项(用于详情面板 value→label 映射) */
const manufacturerOptions = ref<Array<{ label: string; value: number }>>([])
/** 厂商 value → label 映射,详情面板展示用 */
const manufacturerLabel = (value?: number): string =>
manufacturerOptions.value.find((m) => m.value === value)?.label ?? '—'
const loadManufacturerOptions = (): void => {
dictItems('device_manufacturer')
.then((res) => {
if (res.code === '00000') {
manufacturerOptions.value = (res.data ?? []) as Array<{ label: string; value: number }>
}
})
.catch((err: unknown) => {
console.error('加载厂商字典失败:', err)
})
}
/** 递归查找节点 */
const findNode = (nodes: CategoryNode[], id: string): CategoryNode | null => {
for (const node of nodes) {
if (node.id === id) return node
if (node.children) {
const found = findNode(node.children, id)
if (found) return found
}
}
return null
}
/** 拉取分类全树 */
const treeRequest = (): void => {
pageLoading.value = true
tree({ keyword: search.keyword })
.then((res) => {
if (res.code === '00000') {
const data = res.data ?? []
treeData.value = data
if (expandedKeys.value.length === 0) {
expandedKeys.value = collectExpandableKeys(data)
}
if (selectedKeys.value.length > 0) {
/** 已有选中:在最新树中回填节点对象 */
const matched = findNode(data, selectedKeys.value[0])
selectedNode.value = matched
if (!matched) {
/** 原选中节点已不存在:回退到首个顶级节点 */
selectedKeys.value = data.length ? [data[0].id] : []
selectedNode.value = data.length ? data[0] : null
}
} else if (data.length > 0) {
/** 默认选中首个顶级节点,便于直接展示详情 */
selectedKeys.value = [data[0].id]
selectedNode.value = data[0]
} else {
selectedNode.value = null
}
}
})
.catch((err: unknown) => {
console.error('设备分类树请求失败:', err)
})
.finally(() => {
pageLoading.value = false
})
}
/** 搜索:过滤树 */
const searchQuery = (): void => {
treeRequest()
}
/** 重置:清空搜索 + 重新拉树 */
const resetQuery = (): void => {
Object.assign(search, createSearchKey())
resetTree()
treeRequest()
}
/** 选中树节点 */
const onSelect = (keys: (string | number)[]): void => {
const id = keys[0]?.toString()
selectedKeys.value = id ? [id] : []
selectedNode.value = id ? findNode(treeData.value, id) : null
}
/** 展开节点 */
const onExpand = (keys: (string | number)[]): void => {
expandedKeys.value = keys.map((k) => k.toString())
}
/** 层级中文标签:1=顶级分类,2=二级分类,依此类推 */
const levelLabel = (level: number): string => {
if (level === 1) return '顶级分类'
const cn = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
const text = level <= 10 ? cn[level] : String(level)
return `${text}级分类`
}
/** 打开新增弹框(任意层级节点的下级分类) */
const openAddModal = (parent?: CategoryNode): void => {
if (parent) {
/** 新增下级分类:level = 父级 + 1,支持任意层级嵌套 */
addOrEditRef.value?.openModal('add', {
parentId: parent.id,
parentName: parent.name,
level: parent.level + 1,
})
} else {
/** 新增顶级分类(保留入口供程序化调用,但页面已移除按钮) */
addOrEditRef.value?.openModal('add', { level: 1 })
}
}
/** 打开编辑弹框 */
const openEditModal = (): void => {
if (!selectedNode.value) {
void message.warning('请先选择一个节点')
return
}
addOrEditRef.value?.openModal('edit', { id: selectedNode.value.id })
}
/** 删除节点(二次确认 + 级联提示) */
const deleteNode = (): void => {
if (!selectedNode.value) {
void message.warning('请先选择一个节点')
return
}
const node = selectedNode.value
const hasChildren = node.children && node.children.length > 0
Modal.confirm({
title: '确认删除?',
content: hasChildren
? `该节点下有 ${node.children?.length} 个子节点,删除后子节点将一并删除,请谨慎操作。`
: '删除后数据不可恢复,请谨慎操作。',
okType: 'danger',
okText: '确认删除',
cancelText: '取消',
onOk: () => {
remove(node.id)
.then((res) => {
if (res.code === '00000') {
void message.success(res.msg)
selectedKeys.value = []
selectedNode.value = null
treeRequest()
}
})
.catch((err: unknown) => {
console.error('设备分类删除失败:', err)
})
},
})
}
const loadData = (): void => {
treeRequest()
loadManufacturerOptions()
}
onMounted(() => {
loadData()
})
return {
pageLoading,
search,
treeData,
selectedKeys,
expandedKeys,
fieldNames,
selectedNode,
addOrEditRef,
manufacturerLabel,
levelLabel,
searchQuery,
resetQuery,
onSelect,
onExpand,
openAddModal,
openEditModal,
deleteNode,
treeRequest,
}
}
@@ -0,0 +1,6 @@
import type { SearchForm } from '../types'
/** 搜索表单工厂:统一重置入口 */
export const createSearchKey = (): SearchForm => ({
keyword: undefined,
})
@@ -0,0 +1,72 @@
import { ref } from 'vue'
import type { CategoryNode } from '../types'
/** a-tree 字段映射(与 CategoryNode 字段对齐) */
export const treeFieldNames = {
title: 'name',
key: 'id',
children: 'children',
} as const
/**
* 树形状态:管理树数据、选中、展开
* - 全部用 ref,模板中自动解包,usePage 中用 .value 访问
*/
export const useTable = () => {
const treeData = ref<CategoryNode[]>([])
const selectedKeys = ref<string[]>([])
const expandedKeys = ref<string[]>([])
const pageLoading = ref<boolean>(false)
/** 按关键字过滤树(保留命中节点的祖先链) */
const filterTree = (nodes: CategoryNode[], keyword: string): CategoryNode[] => {
const lower = keyword.toLowerCase()
const walk = (list: CategoryNode[]): CategoryNode[] => {
const result: CategoryNode[] = []
list.forEach((node) => {
const matched =
node.name.toLowerCase().includes(lower) ||
node.code.toLowerCase().includes(lower)
const children = node.children ? walk(node.children) : []
if (matched || children.length > 0) {
result.push({ ...node, children: children.length > 0 ? children : undefined })
}
})
return result
}
return walk(nodes)
}
/** 收集所有非叶子节点 key(用于默认展开顶级分类) */
const collectExpandableKeys = (nodes: CategoryNode[]): string[] => {
const keys: string[] = []
const walk = (list: CategoryNode[]): void => {
list.forEach((node) => {
if (node.children && node.children.length > 0) {
keys.push(node.id)
walk(node.children)
}
})
}
walk(nodes)
return keys
}
/** 重置树状态 */
const resetTree = (): void => {
treeData.value = []
selectedKeys.value = []
expandedKeys.value = []
}
return {
treeData,
selectedKeys,
expandedKeys,
pageLoading,
fieldNames: treeFieldNames,
filterTree,
collectExpandableKeys,
resetTree,
}
}
@@ -0,0 +1,59 @@
import type { BaseRecord, BaseSearchForm } from '@pages/admin-portal/shared/types'
/** 分类层级:1=顶级分类 / 2=二级分类 / ... 支持 N 级 */
export type CategoryLevel = number
/** 设备分类树节点(支持任意层级嵌套) */
export interface CategoryNode extends BaseRecord {
/** 节点名称 */
name: string
/** 节点编码(系统按 D01 / D01A01 / D01A01A01 规则自动生成) */
code: string
/** 层级:1=顶级分类,2=二级分类,依此类推 */
level: CategoryLevel
/** 父节点 ID(顶级分类为空) */
parentId?: string
/** 父级名称(仅 info 回填时注入) */
parentName?: string
/** 排序号 */
sortOrder: number
/** 是否 IOT 设备(仅 level >= 2 节点配置;标记该分类下设备是否为物联网设备) */
isIot?: boolean
/** 是否启用数据中转(仅 level >= 2 节点配置;设备实例继承此配置) */
dataTransit?: boolean
/** 是否启用数据上报(仅 level >= 2 节点配置;仅控制开关,上报地址由设备实例侧配置) */
dataReport?: boolean
/** 厂商(字典 device_manufacturer 的 value;仅叶子节点配置) */
manufacturer?: number
/** 型号(仅叶子节点配置;与厂商配套) */
model?: string
/** 备注 */
remark?: string
/** 子节点 */
children?: CategoryNode[]
}
/** 搜索表单(按名称/编码过滤树) */
export interface SearchForm extends BaseSearchForm {
keyword?: string
}
/** 新增/编辑提交载荷 */
export interface CategoryPayload {
id?: string
name: string
/** code 由系统自动生成,提交时不传;编辑时仅用于回显 */
code?: string
level: CategoryLevel
parentId?: string
parentName?: string
sortOrder: number
/** 业务字段仅在 level >= 2 时有效 */
isIot?: boolean
dataTransit?: boolean
dataReport?: boolean
/** 叶子节点专属字段 */
manufacturer?: number
model?: string
remark?: string
}
@@ -0,0 +1,15 @@
import type { ApiResponse } from '@axios'
import { postRequest } from '@axios'
import type { DeviceCategoryTab, DeviceItem, SearchForm } from '../types'
/** 查询设备列表(按顶级分类 Tab 过滤) */
export const list = (params: SearchForm & { category: DeviceCategoryTab }): Promise<ApiResponse<DeviceItem[]>> =>
postRequest('axiosRequest', '/admin/device/manage/list', params)
/** 删除设备(归档,不物理删除) */
export const remove = (id: string): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/manage/delete', { id })
/** 异步导出设备列表(按当前搜索条件) */
export const exportList = (params: SearchForm & { category: DeviceCategoryTab }): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/manage/export', params)
@@ -0,0 +1,11 @@
/**
* 设备管理 - 详情抽屉 - 接口层
* - info: 设备完整档案(含运行日志 + 维修履历)
*/
import type { ApiResponse } from '@axios'
import { postRequest } from '@axios'
import type { DetailInfo } from '../types'
export const getInfo = (id: string): Promise<ApiResponse<DetailInfo>> =>
postRequest('axiosRequest', '/admin/device/manage/info', { id })
@@ -0,0 +1,218 @@
<template>
<a-drawer
v-model:open="pageInfo.visible"
:title="pageInfo.title"
:width="pageInfo.width"
:keyboard="false"
:mask-closable="false"
destroy-on-close
@close="closeDrawer"
>
<a-spin :spinning="pageInfo.spin">
<a-tabs v-model:active-key="activeTab" type="line">
<!-- Tab 1 基础档案 -->
<a-tab-pane key="archive" tab="基础档案">
<a-descriptions v-if="detail" :column="2" bordered size="small">
<a-descriptions-item label="设备编码">{{ detail.deviceCode ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="设备名称">{{ detail.name ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="所属型号">{{ detail.modelName ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="顶级分类">{{ categoryLabel(detail.category) }}</a-descriptions-item>
<a-descriptions-item label="投用状态">
<a-tag :color="deployStatusColor(detail.deployStatus)">{{ deployStatusText(detail.deployStatus) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="运行状态">
<a-tag :color="runStatusColor(detail.runStatus)">{{ runStatusText(detail.runStatus) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="软件版本">{{ detail.softwareVersion ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="是否 IOT">
<a-tag :color="detail.isIot ? 'green' : 'default'">{{ detail.isIot ? '是' : '否' }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="所属租户">{{ detail.tenantName ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="布点场所">{{ detail.siteName ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="入库时间">{{ detail.onlineTime ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="投用时间">{{ detail.deployTime ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="最近心跳">{{ detail.lastHeartbeat ?? '—' }}</a-descriptions-item>
<!-- 智养餐饮设备差异字段 -->
<template v-if="detail.category === 'catering'">
<a-descriptions-item label="所属轨道">{{ detail.trackNo ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="摆放方式">{{ detail.placement ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="绑定大屏">{{ detail.boundScreen ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="所在档口">{{ detail.stall ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="客户端 IP" :span="2">{{ detail.clientIp ?? '—' }}</a-descriptions-item>
</template>
<!-- 体重测量设备差异字段 -->
<template v-if="detail.category === 'body'">
<a-descriptions-item label="测量指标" :span="2">{{ detail.measureIndicators ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="量程">{{ detail.range ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="精度等级">{{ detail.accuracy ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="计量单位">{{ detail.unit ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="测量方式">{{ detail.measureMethod ?? '—' }}</a-descriptions-item>
</template>
<!-- 智能穿戴设备差异字段 -->
<template v-if="detail.category === 'wearable'">
<a-descriptions-item label="腕带编号">{{ detail.bandNo ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="SIM 卡号">{{ detail.simCard ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="绑定用户">{{ detail.boundUser ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="电池电量">{{ detail.battery ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="通讯协议" :span="2">{{ detail.protocol ?? '—' }}</a-descriptions-item>
</template>
<!-- 健身器材设备差异字段 -->
<template v-if="detail.category === 'fitness'">
<a-descriptions-item label="器材类型">{{ detail.equipmentType ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="阻力调节">{{ detail.resistanceMode ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="训练数据采集" :span="2">{{ detail.trainDataType ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="设备位置" :span="2">{{ detail.location ?? '—' }}</a-descriptions-item>
</template>
<!-- 健康监测设备差异字段 -->
<template v-if="detail.category === 'monitor'">
<a-descriptions-item label="监测指标" :span="2">{{ detail.monitorIndicators ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="采样频率">{{ detail.sampleFrequency ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="测量方式">{{ detail.measureMethod ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="报告生成" :span="2">{{ detail.reportMode ?? '—' }}</a-descriptions-item>
</template>
</a-descriptions>
<a-empty v-else description="暂无档案数据" />
</a-tab-pane>
<!-- Tab 2 运行日志 -->
<a-tab-pane key="runLog" tab="运行日志">
<a-empty v-if="!detail?.runLogs?.length" description="暂无运行日志" />
<a-timeline v-else>
<a-timeline-item v-for="log in detail.runLogs" :key="log.id" :color="logTimelineColor(log.eventType)">
<p class="info-drawer__log-time">{{ log.occurTime ?? '—' }}</p>
<p class="info-drawer__log-type">
<a-tag :color="logTimelineColor(log.eventType)">{{ log.eventType }}</a-tag>
{{ log.content ?? '—' }}
</p>
</a-timeline-item>
</a-timeline>
</a-tab-pane>
<!-- Tab 3 维修履历 -->
<a-tab-pane key="repair" tab="维修履历">
<a-empty v-if="!detail?.repairs?.length" description="暂无维修履历" />
<a-table
v-else
:columns="repairColumns"
:data-source="detail?.repairs ?? []"
:pagination="false"
size="small"
row-key="id"
/>
</a-tab-pane>
</a-tabs>
</a-spin>
<template #footer>
<a-button @click="closeDrawer">关闭</a-button>
</template>
</a-drawer>
</template>
<script setup lang="ts">
/**
* 设备管理 - 详情抽屉
*
* drawer-spec.md 强制项已遵循:
* - destroy-on-close 避免数据残留
* - 父组件通过 ref.openDrawer(record) 调用
* - emit('ok') 与 Modal 的 emit('load') 区分(本抽屉只读,无需 emit)
*
* 业务字段对齐 §3.6.2 设备监测详情页(本期 P0 仅聚合基础档案 + 运行日志 + 维修履历):
* - 实施记录 / 自检记录 / 巡检记录 / 更新记录 由"设备运维"模块产生,后置二期补齐
* - 已删除"数据中转"字段,新增"投用状态",固件版本→软件版本
*/
import type { TableColumnsType } from 'ant-design-vue'
import type { DeployStatus, RunStatus } from '../../../../types'
import { usePage } from './init/usePage'
const {
pageInfo,
detail,
activeTab,
tabList: _tabList,
categoryLabel,
openDrawer,
closeDrawer,
} = usePage()
defineExpose({ openDrawer })
/** 运行状态 → Tag 颜色 */
const runStatusColor = (status: RunStatus | undefined): string => {
const map: Record<RunStatus, string> = {
online: 'success',
offline: 'default',
repairing: 'warning',
scrapped: 'error',
}
return status ? (map[status] ?? 'default') : 'default'
}
/** 运行状态 → 中文 */
const runStatusText = (status: RunStatus | undefined): string => {
const map: Record<RunStatus, string> = {
online: '在用',
offline: '离线',
repairing: '维修中',
scrapped: '报废',
}
return status ? (map[status] ?? '—') : '—'
}
/** 投用状态 → Tag 颜色 */
const deployStatusColor = (status: DeployStatus | undefined): string => {
const map: Record<DeployStatus, string> = {
in_stock: 'default',
deployed: 'success',
}
return status ? (map[status] ?? 'default') : 'default'
}
/** 投用状态 → 中文 */
const deployStatusText = (status: DeployStatus | undefined): string => {
const map: Record<DeployStatus, string> = {
in_stock: '在库',
deployed: '投用',
}
return status ? (map[status] ?? '—') : '—'
}
/** 日志事件类型 → 时间线颜色 */
const logTimelineColor = (eventType: string | undefined): string => {
if (!eventType) return 'blue'
if (eventType.includes('异常') || eventType.includes('故障')) return 'red'
if (eventType === '关机' || eventType === '离线') return 'gray'
return 'blue'
}
/** 维修履历列定义 */
const repairColumns: TableColumnsType = [
{ title: '关联工单', dataIndex: 'workOrderNo', align: 'center', width: 140, ellipsis: true },
{ title: '故障类型', dataIndex: 'faultType', align: 'left', width: 120, ellipsis: true },
{ title: '维修方式', dataIndex: 'repairMethod', align: 'left', width: 120, ellipsis: true },
{ title: '维修人', dataIndex: 'repairer', align: 'center', width: 100, ellipsis: true },
{ title: '工时(h)', dataIndex: 'workHours', align: 'center', width: 90 },
{ title: '完成时间', dataIndex: 'finishTime', align: 'center', width: 170, ellipsis: true },
]
</script>
<style scoped lang="less">
.info-drawer__log-time {
margin: 0 0 4px;
color: #8c8c8c;
font-size: 12px;
}
.info-drawer__log-type {
margin: 0;
color: #262626;
font-size: 13px;
}
</style>
@@ -0,0 +1,85 @@
/**
* 设备管理 - 详情抽屉 - 主逻辑
*
* 严格遵循 drawer-spec.mdB 类型:详情抽屉)
* - openDrawer 接收 { id },拉取详情后展示
* - 抽屉内 Tab 切换:基础档案 / 运行日志 / 维修履历
*/
import { ref } from 'vue'
import type { DeviceCategoryTab } from '../../../../types'
import { getInfo } from '../api'
import type { DetailInfo, PageInfo } from '../types'
/** Tab 列表 */
export const TAB_LIST: { key: string; label: string }[] = [
{ key: 'archive', label: '基础档案' },
{ key: 'runLog', label: '运行日志' },
{ key: 'repair', label: '维修履历' },
]
/** 顶级分类 → 中文标签 */
const TAB_LABEL_MAP: Record<DeviceCategoryTab, string> = {
catering: '智养餐饮设备',
body: '体重测量设备',
wearable: '智能穿戴设备',
fitness: '健身器材设备',
monitor: '健康监测设备',
}
export const usePage = () => {
const pageInfo = reactive<PageInfo>({
visible: false,
title: '设备详情',
spin: false,
width: 900,
})
const detail = ref<DetailInfo | null>(null)
const activeTab = ref<string>('archive')
const loadData = (id: string): void => {
pageInfo.spin = true
getInfo(id)
.then((res) => {
if (res.code === '00000') {
detail.value = res.data
}
})
.catch((err: unknown) => {
console.error('获取设备详情失败:', err)
})
.finally(() => {
pageInfo.spin = false
})
}
/** 父组件调用入口 */
const openDrawer = (record: { id: string; name?: string }): void => {
detail.value = null
activeTab.value = 'archive'
pageInfo.visible = true
pageInfo.title = record.name ? `设备详情 · ${record.name}` : '设备详情'
loadData(record.id)
}
const closeDrawer = (): void => {
pageInfo.visible = false
}
/** 顶级分类中文标签 */
const categoryLabel = (cat: DeviceCategoryTab | undefined): string => {
if (!cat) return '—'
return TAB_LABEL_MAP[cat] ?? '—'
}
return {
pageInfo,
detail,
activeTab,
tabList: TAB_LIST,
categoryLabel,
openDrawer,
closeDrawer,
}
}
@@ -0,0 +1,70 @@
/**
* 设备管理 - 详情抽屉 - 类型定义
*
* 严格遵循 drawer-spec.mdB 类型:详情抽屉)
* - PageInfo 控制抽屉状态
* - DetailInfo 承载完整档案 + 运行日志 + 维修履历
*/
import type { DeviceCategoryTab, DeployStatus, RunStatus } from '../../../../types'
/** 抽屉状态 */
export interface PageInfo {
visible: boolean
title: string
spin: boolean
width: number
}
/** 运行日志条目 */
export interface RunLogItem {
id: string
/** 事件类型:开机/关机/心跳/关键事件 */
eventType: string
/** 事件内容 */
content: string
/** 发生时间 */
occurTime: string
}
/** 维修记录条目 */
export interface RepairItem {
id: string
/** 关联工单号 */
workOrderNo: string
/** 故障类型 */
faultType: string
/** 维修方式 */
repairMethod: string
/** 维修人 */
repairer: string
/** 工时(小时) */
workHours: number
/** 完成时间 */
finishTime: string
}
/** 设备完整档案(含通用 + 5 Tab 差异字段) */
export interface DetailInfo {
id: string
deviceCode: string
name: string
modelName: string
category: DeviceCategoryTab
tenantName: string
siteName: string
deployStatus: DeployStatus
runStatus: RunStatus
softwareVersion: string
onlineTime: string
deployTime: string
lastHeartbeat: string
isIot: boolean
/** 各 Tab 差异字段(动态) */
[key: string]: string | boolean | undefined
/** 运行日志(近 10 条) */
runLogs?: RunLogItem[]
/** 维修履历(近 5 条) */
repairs?: RepairItem[]
}
@@ -0,0 +1,313 @@
<template>
<a-modal
v-model:open="pageInfo.visible"
:title="pageInfo.title"
:width="pageInfo.width"
:keyboard="false"
:mask-closable="false"
>
<a-spin :spinning="pageInfo.spin">
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="LABEL_COL"
>
<!-- 通用字段区块 -->
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="设备编码" name="deviceCode">
<a-input
v-model:value="form.deviceCode"
v-only-alphanumeric-special
allow-clear
:maxlength="40"
placeholder="全局唯一,入网时分配"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="设备名称" name="name">
<a-input
v-model:value="form.name"
v-no-space
allow-clear
:maxlength="40"
placeholder="请输入设备名称"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="所属型号" name="modelName">
<a-select
v-model:value="form.modelName"
:options="formOptions.modelName"
show-search
allow-clear
placeholder="请选择二级型号"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="运行状态" name="runStatus">
<a-select
v-model:value="form.runStatus"
:options="formOptions.runStatus"
placeholder="请选择运行状态"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<!--
所属租户 / 布点场所 非必填
- 二者均为空 投用状态自动为"在库"
- 任一有值 投用状态自动为"投用"
- 投用状态以只读 Tag 形式展示在右侧
-->
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="所属租户" name="tenantName">
<a-input
v-model:value="form.tenantName"
v-no-space
allow-clear
:maxlength="40"
placeholder="留空代表在库未投用"
@change="onDeployFieldChange"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="布点场所" name="siteName">
<a-input
v-model:value="form.siteName"
v-no-space
allow-clear
:maxlength="40"
placeholder="留空代表在库未投用"
@change="onDeployFieldChange"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="软件版本">
<a-input
v-model:value="form.softwareVersion"
v-no-space
allow-clear
:maxlength="30"
placeholder="如 V1.0.0"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="投用状态">
<a-tag :color="form.deployStatus === 'deployed' ? 'success' : 'default'">
{{ deployStatusText }}
</a-tag>
<span class="deploy-tip">由租户 / 场所自动判定</span>
</a-form-item>
</a-col>
</a-row>
<a-divider v-if="activeTab === 'catering'" orientation="left">智养餐饮设备差异字段</a-divider>
<a-row v-if="activeTab === 'catering'" :gutter="16">
<a-col :span="12">
<a-form-item label="所属轨道">
<a-input v-model:value="form.trackNo" v-no-space allow-clear :maxlength="20" placeholder="如 T-01" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="摆放方式">
<a-select v-model:value="form.placement" :options="formOptions.placement" allow-clear placeholder="请选择" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="绑定大屏">
<a-input v-model:value="form.boundScreen" v-no-space allow-clear :maxlength="30" placeholder="关联设备编码" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="所在档口">
<a-input v-model:value="form.stall" v-no-space allow-clear :maxlength="30" placeholder="如 主食档" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="客户端 IP">
<a-input v-model:value="form.clientIp" v-no-space allow-clear :maxlength="30" placeholder="如 192.168.10.11" />
</a-form-item>
</a-col>
</a-row>
<a-divider v-if="activeTab === 'body'" orientation="left">体重测量设备差异字段</a-divider>
<a-row v-if="activeTab === 'body'" :gutter="16">
<a-col :span="12">
<a-form-item label="测量指标">
<a-input v-model:value="form.measureIndicators" v-no-space allow-clear :maxlength="60" placeholder="如 体重 / 体脂 / BMI" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="量程">
<a-input v-model:value="form.range" v-no-space allow-clear :maxlength="20" placeholder="如 0-200kg" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="精度等级">
<a-input v-model:value="form.accuracy" v-no-space allow-clear :maxlength="20" placeholder="如 ±0.1kg" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="计量单位">
<a-input v-model:value="form.unit" v-no-space allow-clear :maxlength="10" placeholder="如 kg" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="测量方式">
<a-select v-model:value="form.measureMethod" :options="formOptions.measureMethod" allow-clear placeholder="请选择" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<a-divider v-if="activeTab === 'wearable'" orientation="left">智能穿戴设备差异字段</a-divider>
<a-row v-if="activeTab === 'wearable'" :gutter="16">
<a-col :span="12">
<a-form-item label="腕带编号">
<a-input v-model:value="form.bandNo" v-no-space allow-clear :maxlength="30" placeholder="如 BN-0001" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="SIM 卡号">
<a-input v-model:value="form.simCard" v-only-alphanumeric allow-clear :maxlength="30" placeholder="ICC-ID" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="绑定用户">
<a-input v-model:value="form.boundUser" v-no-space allow-clear :maxlength="30" placeholder="用户 ID + 姓名" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="电池电量">
<a-input v-model:value="form.battery" v-no-space allow-clear :maxlength="10" placeholder="如 85%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="通讯协议">
<a-select v-model:value="form.protocol" :options="formOptions.protocol" allow-clear placeholder="请选择" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<a-divider v-if="activeTab === 'fitness'" orientation="left">健身器材设备差异字段</a-divider>
<a-row v-if="activeTab === 'fitness'" :gutter="16">
<a-col :span="12">
<a-form-item label="器材类型">
<a-select v-model:value="form.equipmentType" :options="formOptions.equipmentType" allow-clear placeholder="请选择" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="阻力调节方式">
<a-select v-model:value="form.resistanceMode" :options="formOptions.resistanceMode" allow-clear placeholder="请选择" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="训练数据采集">
<a-input v-model:value="form.trainDataType" v-no-space allow-clear :maxlength="60" placeholder="如 时长 / 卡路里 / 距离" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="设备位置">
<a-input v-model:value="form.location" v-no-space allow-clear :maxlength="40" placeholder="场所内具体位置" />
</a-form-item>
</a-col>
</a-row>
<a-divider v-if="activeTab === 'monitor'" orientation="left">健康监测设备差异字段</a-divider>
<a-row v-if="activeTab === 'monitor'" :gutter="16">
<a-col :span="12">
<a-form-item label="监测指标">
<a-input v-model:value="form.monitorIndicators" v-no-space allow-clear :maxlength="80" placeholder="如 血糖 / 血压 / 血氧" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="采样频率">
<a-input v-model:value="form.sampleFrequency" v-no-space allow-clear :maxlength="20" placeholder="如 每5分钟 / 按需" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="测量方式">
<a-select v-model:value="form.measureMethod" :options="formOptions.measureMethod" allow-clear placeholder="请选择" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="报告生成">
<a-select v-model:value="form.reportMode" :options="formOptions.reportMode" allow-clear placeholder="请选择" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
</a-form>
</a-spin>
<template #footer>
<a-button @click="closeModal">取消</a-button>
<a-button
type="primary"
:disabled="pageInfo.spin"
:loading="pageInfo.spin"
@click="submit"
>确定</a-button>
</template>
</a-modal>
</template>
<script setup lang="ts">
/**
* 设备管理 - 新增/编辑 弹框
*
* modal-spec.md 强制项已遵循。
* 业务字段调整:
* - 通用字段:编码 / 名称 / 型号 / 运行状态 / 软件版本 / 投用状态
* - 所属租户 / 布点场所 非必填:留空 → 在库;任一有值 → 投用
* - 投用状态以只读 Tag 展示,由 onDeployFieldChange 联动计算
* - 按 activeTab 条件渲染 5 类差异字段区块
*/
import { usePage } from './init/usePage'
import { LABEL_COL } from './types'
const emit = defineEmits<{
(e: 'load'): void
}>()
const {
pageInfo,
formRef,
form,
formOptions,
activeTab,
deployStatusText,
rules,
openModal,
closeModal,
submit,
onDeployFieldChange,
} = usePage(emit)
defineExpose({ openModal })
</script>
<style scoped lang="less">
.deploy-tip {
margin-left: 8px;
color: var(--text-secondary, #8c8c8c);
font-size: 12px;
}
</style>
@@ -0,0 +1,21 @@
/**
* 设备管理 - 新增/编辑 弹框 - 接口层
* - 复用主页面 add/update 接口
* - 新增 info 接口供编辑回填
*/
import type { ApiResponse } from '@axios'
import { postRequest } from '@axios'
import type { Form } from '../types'
/** 详情查询(编辑回填) */
export const info = (id: string): Promise<ApiResponse<Form>> =>
postRequest('axiosRequest', '/admin/device/manage/info', { id })
/** 新增 */
export const add = (params: Record<string, unknown>): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/manage/add', params)
/** 编辑 */
export const edit = (params: Record<string, unknown>): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/manage/update', params)
@@ -0,0 +1,302 @@
/**
* 设备管理 - 新增/编辑 弹框 - 主逻辑
*
* 严格遵循 modal-spec.md
* - createInitForm / createInitOptions 工厂函数
* - openModal 用 Promise.all 并发拉取详情 + 下拉
* - addRequest 必须 delete params.id
* - submit 开头立即 pageInfo.spin = true
* - 用 params.id 判断新增/编辑
*
* 设备管理特殊点:
* - 5 个 Tab 共用一张表单,差异字段按 activeTab 条件渲染
* - activeTab 在新增时由 OpenContext 传入;编辑时由详情回填
* - 下拉选项(型号、摆放方式等)按 activeTab 切换数据源
* - 所属租户 / 布点场所 非必填:留空 → 投用状态自动为在库;填值 → 投用状态自动为投用
*/
import type { FormInstance, Rule } from 'ant-design-vue/es/form'
import { computed } from 'vue'
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
import { add, edit, info } from '../api'
import type { DeviceCategoryTab, DeployStatus } from '../../../../types'
import type { Form, FormOptions, ModalType, OpenContext, PageInfo } from '../types'
/** 各 Tab 的二级型号下拉数据(与设备分类预置型号对齐) */
const MODEL_OPTIONS: Record<DeviceCategoryTab, { label: string; value: string }[]> = {
catering: [
{ label: '吐盘机', value: '吐盘机' },
{ label: '智能货柜', value: '智能货柜' },
{ label: '营养秤', value: '营养秤' },
{ label: '配比秤', value: '配比秤' },
{ label: '结算台', value: '结算台' },
{ label: '入库秤', value: '入库秤' },
{ label: '带鱼屏', value: '带鱼屏' },
{ label: '波菲炉', value: '波菲炉' },
],
body: [
{ label: '一体机', value: '一体机' },
{ label: '蓝牙秤', value: '蓝牙秤' },
{ label: '体脂仪', value: '体脂仪' },
],
wearable: [
{ label: '华为-watch5', value: '华为-watch5' },
{ label: '华为-watch4', value: '华为-watch4' },
{ label: '罗杰夫-LJF01', value: '罗杰夫-LJF01' },
],
fitness: [
{ label: '跑步机', value: '跑步机' },
{ label: '椭圆机', value: '椭圆机' },
{ label: '健身车', value: '健身车' },
],
monitor: [
{ label: '血糖监测仪', value: '血糖监测仪' },
{ label: '高血压治疗仪', value: '高血压治疗仪' },
{ label: '智能健康一体机', value: '智能健康一体机' },
],
}
const RUN_STATUS_OPTIONS: { label: string; value: Form['runStatus'] }[] = [
{ label: '在用', value: 'online' },
{ label: '离线', value: 'offline' },
{ label: '维修中', value: 'repairing' },
{ label: '报废', value: 'scrapped' },
]
const PLACEMENT_OPTIONS = [
{ label: '落地', value: '落地' },
{ label: '壁挂', value: '壁挂' },
{ label: '嵌入式', value: '嵌入式' },
{ label: '台面', value: '台面' },
]
const MEASURE_METHOD_OPTIONS = [
{ label: '站姿', value: '站姿' },
{ label: '坐姿', value: '坐姿' },
{ label: '手持', value: '手持' },
{ label: '采血', value: '采血' },
{ label: '袖带', value: '袖带' },
{ label: '光电', value: '光电' },
]
const PROTOCOL_OPTIONS = [
{ label: '4G', value: '4G' },
{ label: '5G', value: '5G' },
{ label: 'BLE', value: 'BLE' },
{ label: 'Wi-Fi', value: 'Wi-Fi' },
]
const EQUIPMENT_TYPE_OPTIONS = [
{ label: '有氧', value: '有氧' },
{ label: '力量', value: '力量' },
]
const RESISTANCE_MODE_OPTIONS = [
{ label: '机械', value: '机械' },
{ label: '电磁', value: '电磁' },
{ label: '液压', value: '液压' },
]
const REPORT_MODE_OPTIONS = [
{ label: '本地', value: '本地' },
{ label: '云端', value: '云端' },
]
/** 初始表单工厂 */
const createInitForm = (): Form => ({
id: undefined,
deviceCode: '',
name: '',
modelName: '',
category: 'catering',
tenantName: '',
siteName: '',
deployStatus: 'in_stock',
runStatus: 'online',
softwareVersion: '',
deployTime: '',
})
/** 初始下拉选项工厂 */
const createInitOptions = (): FormOptions => ({
modelName: [],
runStatus: RUN_STATUS_OPTIONS,
placement: PLACEMENT_OPTIONS,
measureMethod: MEASURE_METHOD_OPTIONS,
protocol: PROTOCOL_OPTIONS,
equipmentType: EQUIPMENT_TYPE_OPTIONS,
resistanceMode: RESISTANCE_MODE_OPTIONS,
reportMode: REPORT_MODE_OPTIONS,
})
/** 依据租户 / 场所判定投用状态 */
const computeDeployStatus = (tenantName: string, siteName: string): DeployStatus => {
if (!tenantName && !siteName) return 'in_stock'
return 'deployed'
}
export const usePage = (emit: (e: 'load') => void) => {
const { message } = useAntdStaticMethods()
const pageInfo = reactive<PageInfo>({
visible: false,
type: 'add',
title: '',
width: 820,
spin: false,
})
const formRef = ref<FormInstance>()
const form = ref<Form>(createInitForm())
const formOptions = ref<FormOptions>(createInitOptions())
/** 当前 Tab(控制差异字段区块显隐) */
const activeTab = ref<DeviceCategoryTab>('catering')
/** 投用状态实时联动(只读展示) */
const deployStatusText = computed(() => (form.value.deployStatus === 'deployed' ? '投用' : '在库'))
/** 租户或场所变化时重新计算投用状态 */
const onDeployFieldChange = (): void => {
form.value.deployStatus = computeDeployStatus(form.value.tenantName, form.value.siteName)
}
const rules: Record<string, Rule[]> = {
deviceCode: [{ required: true, message: '请输入设备编码', trigger: 'blur' }],
name: [{ required: true, message: '请输入设备名称', trigger: 'blur' }],
modelName: [{ required: true, message: '请选择所属型号', trigger: 'change' }],
runStatus: [{ required: true, message: '请选择运行状态', trigger: 'change' }],
// 所属租户 / 布点场所 非必填:留空代表在库未投用
}
const resetForm = (): void => {
formRef.value?.clearValidate()
form.value = createInitForm()
}
const closeModal = (): void => {
resetForm()
pageInfo.visible = false
}
/** 按 activeTab 切换型号下拉 */
const refreshModelOptions = (tab: DeviceCategoryTab): void => {
formOptions.value.modelName = MODEL_OPTIONS[tab] ?? []
}
const infoRequest = (id: string) => info(id)
/** 打开弹框 */
const openModal = (type: ModalType, ctx?: OpenContext): void => {
pageInfo.visible = true
pageInfo.spin = true
pageInfo.type = type
resetForm()
/** 新增时预置 category;编辑时由详情回填 */
const tab = ctx?.activeTab ?? 'catering'
activeTab.value = tab
if (type === 'add') {
form.value.category = tab
}
refreshModelOptions(tab)
const tabLabelMap: Record<DeviceCategoryTab, string> = {
catering: '智养餐饮设备',
body: '体重测量设备',
wearable: '智能穿戴设备',
fitness: '健身器材设备',
monitor: '健康监测设备',
}
pageInfo.title = type === 'add'
? `新增设备(${tabLabelMap[tab]}`
: '编辑设备'
const requests: Promise<unknown>[] = []
if (type === 'edit' && ctx?.id) requests.push(infoRequest(ctx.id))
Promise.all(requests)
.then((results) => {
if (type === 'edit' && ctx?.id) {
const infoRes = results[0] as { code: string; data: Form }
if (infoRes?.code === '00000') {
const data = infoRes.data
form.value = { ...createInitForm(), ...data }
/** 编辑时按详情回填的 category 同步 activeTab */
activeTab.value = data.category
refreshModelOptions(data.category)
}
}
})
.catch((err: unknown) => {
console.error('打开弹框初始化失败:', err)
})
.finally(() => {
pageInfo.spin = false
})
}
const successRequest = (msg: string): void => {
void message.success(msg)
emit('load')
closeModal()
}
const addRequest = (params: Record<string, unknown>): void => {
delete params.id
add(params)
.then((res) => {
if (res.code === '00000') successRequest(res.msg)
})
.catch((err: unknown) => {
console.error('新增失败:', err)
})
.finally(() => {
pageInfo.spin = false
})
}
const editRequest = (params: Record<string, unknown>): void => {
edit(params)
.then((res) => {
if (res.code === '00000') successRequest(res.msg)
})
.catch((err: unknown) => {
console.error('编辑失败:', err)
})
.finally(() => {
pageInfo.spin = false
})
}
const submit = (): void => {
pageInfo.spin = true
formRef.value
?.validate()
.then(() => {
/** 提交前重新计算投用状态,确保与租户 / 场所一致 */
form.value.deployStatus = computeDeployStatus(form.value.tenantName, form.value.siteName)
const params = JSON.parse(JSON.stringify(form.value)) as Record<string, unknown>
if (params.id) editRequest(params)
else addRequest(params)
})
.catch((err: unknown) => {
pageInfo.spin = false
console.error('表单验证失败:', err)
})
}
return {
pageInfo,
formRef,
form,
formOptions,
activeTab,
deployStatusText,
rules,
openModal,
closeModal,
submit,
onDeployFieldChange,
}
}
@@ -0,0 +1,124 @@
/**
* 设备管理 - 新增/编辑 弹框 - 类型定义
*
* 严格遵循 modal-spec.md
* - 5 个 Tab 共用同一表单,差异字段以扩展属性承载
* - activeTab 由 openModal 上下文传入,控制差异字段区块的显隐
* - 所属租户 / 布点场所 非必填:留空代表在库未投用,由 usePage 联动计算 deployStatus
*/
import type { SelectProps } from 'ant-design-vue'
import type { DeviceCategoryTab, DeployStatus, RunStatus } from '../../../../types'
export type ModalType = 'add' | 'edit'
/** 弹框状态 */
export interface PageInfo {
visible: boolean
type: ModalType
title: string
width: number
spin: boolean
}
/** 表单数据(通用字段 + 5 Tab 差异字段,差异字段按 activeTab 条件渲染) */
export interface Form {
/** id 统一 string,新增时 undefined */
id?: string
/** 设备编码 */
deviceCode: string
/** 设备名称 */
name: string
/** 所属二级型号 */
modelName: string
/** 所属顶级分类 */
category: DeviceCategoryTab
/** 所属租户(非必填,留空代表在库未投用) */
tenantName: string
/** 所属布点场所(非必填,留空代表在库未投用) */
siteName: string
/** 投用状态(依据租户 / 场所自动判定,提交时由 usePage 注入) */
deployStatus: DeployStatus
/** 运行状态 */
runStatus: RunStatus
/** 软件版本 */
softwareVersion: string
/** 投用时间(投用后才存在,编辑回填) */
deployTime: string
// ===== 智养餐饮设备差异字段 =====
/** 所属轨道 */
trackNo?: string
/** 摆放方式 */
placement?: string
/** 绑定大屏 */
boundScreen?: string
/** 所在档口 */
stall?: string
/** 客户端 IP */
clientIp?: string
// ===== 体重测量设备差异字段 =====
/** 测量指标 */
measureIndicators?: string
/** 量程 */
range?: string
/** 精度等级 */
accuracy?: string
/** 计量单位 */
unit?: string
/** 测量方式 */
measureMethod?: string
// ===== 智能穿戴设备差异字段 =====
/** 腕带编号 */
bandNo?: string
/** SIM 卡号 */
simCard?: string
/** 绑定用户 */
boundUser?: string
/** 电池电量 */
battery?: string
/** 通讯协议 */
protocol?: string
// ===== 健身器材设备差异字段 =====
/** 器材类型 */
equipmentType?: string
/** 阻力调节方式 */
resistanceMode?: string
/** 训练数据采集类型 */
trainDataType?: string
/** 设备位置 */
location?: string
// ===== 健康监测设备差异字段 =====
/** 监测指标列表 */
monitorIndicators?: string
/** 采样频率 */
sampleFrequency?: string
/** 报告生成方式 */
reportMode?: string
}
/** 下拉选项 */
export interface FormOptions {
modelName: SelectProps['options']
runStatus: SelectProps['options']
placement: SelectProps['options']
measureMethod: SelectProps['options']
protocol: SelectProps['options']
equipmentType: SelectProps['options']
resistanceMode: SelectProps['options']
reportMode: SelectProps['options']
}
/** 打开弹框时传入的上下文 */
export interface OpenContext {
id?: string
/** 当前 Tab,新增时预置 category;编辑时由详情回填 */
activeTab?: DeviceCategoryTab
}
/** 标签宽度统一 */
export const LABEL_COL = { style: { width: '120px', minWidth: '120px' } } as const
@@ -0,0 +1,219 @@
import { computed, onMounted, reactive, ref } from 'vue'
import type { TablePaginationConfig } from 'ant-design-vue'
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
import { createPaginationConfig } from '@utils/antDesign/table'
import type { TableState } from '@utils/antDesign/table'
import { exportList, list, remove } from '../api'
import AddOrEdit from '../component/modal/addOrEdit/addOrEdit.vue'
import InfoDrawer from '../component/drawer/info/info.vue'
import type { DeviceCategoryTab, DeviceItem, RunStatus, SearchForm } from '../types'
import { DEPLOY_STATUS_OPTIONS, RUN_STATUS_OPTIONS, createSearchKey } from './useSearch'
import { useTable } from './useTable'
/** 5 个 Tab 元信息(与设备分类预置值对齐) */
export const TAB_LIST: { key: DeviceCategoryTab; label: string; isIot: boolean }[] = [
{ key: 'catering', label: '智养餐饮设备', isIot: true },
{ key: 'body', label: '体重测量设备', isIot: true },
{ key: 'wearable', label: '智能穿戴设备', isIot: true },
{ key: 'fitness', label: '健身器材设备', isIot: true },
{ key: 'monitor', label: '健康监测设备', isIot: true },
]
/**
* 设备管理页主编排:
* - 顶部 5 Tab 切换顶级分类,每个 Tab 一张设备实例列表
* - 列定义按 Tab 动态拼装(通用列 + 差异列 + 操作列)
* - 新增/编辑/详情 走独立子组件(modal-spec / drawer-spec
* - 删除二次确认
* - 导出为异步任务,配套"查看导出任务"入口
*/
export const usePage = () => {
const { message, Modal } = useAntdStaticMethods()
const { buildColumns } = useTable()
const activeTab = ref<DeviceCategoryTab>('catering')
const search = reactive<SearchForm>(createSearchKey())
const table = reactive<TableState<DeviceItem>>({
columns: [],
dataSource: [],
sort: { field: '', order: null },
pagination: createPaginationConfig() as TablePaginationConfig,
})
/** 根据 activeTab 动态生成列定义 */
const tableColumns = computed(() => buildColumns(activeTab.value))
const pageLoading = ref<boolean>(false)
const selectedRowKeys = ref<(string | number)[]>([])
/** 新增/编辑子组件 ref(必须在 usePage.ts 中定义) */
const addOrEditRef = ref<InstanceType<typeof AddOrEdit> | null>(null)
/** 详情抽屉子组件 ref */
const infoDrawerRef = ref<InstanceType<typeof InfoDrawer> | null>(null)
/** 同步 columns 到 table */
const syncColumns = (): void => {
table.columns = tableColumns.value
}
/** 拉取当前 Tab 的设备列表 */
const listRequest = (): void => {
pageLoading.value = true
syncColumns()
selectedRowKeys.value = []
list({ ...search, category: activeTab.value })
.then((res) => {
if (res.code === '00000') {
table.dataSource = res.data ?? []
table.pagination.total = table.dataSource.length
}
})
.catch((err: unknown) => {
console.error('设备管理列表请求失败:', err)
})
.finally(() => {
pageLoading.value = false
})
}
/** 搜索 */
const searchQuery = (): void => {
table.pagination.current = 1
listRequest()
}
/** 重置 */
const resetQuery = (): void => {
Object.assign(search, createSearchKey())
table.pagination.current = 1
listRequest()
}
/** 切换 Tab(接收 a-radio-group change 事件或字符串 key */
const switchTab = (keyOrEvent: string | { target: { value: string } }): void => {
const key = typeof keyOrEvent === 'string' ? keyOrEvent : keyOrEvent.target.value
activeTab.value = key as DeviceCategoryTab
Object.assign(search, createSearchKey())
table.pagination.current = 1
listRequest()
}
/** 分页/排序变更 */
const dataSourceChange = (pagination: TablePaginationConfig): void => {
table.pagination.current = pagination.current ?? 1
table.pagination.pageSize = pagination.pageSize ?? 30
}
/** 打开新增弹框(携带当前 Tab 上下文) */
const openAddModal = (): void => {
addOrEditRef.value?.openModal('add', { activeTab: activeTab.value })
}
/** 打开编辑弹框 */
const openEditModal = (record: DeviceItem): void => {
addOrEditRef.value?.openModal('edit', { id: record.id })
}
/** 打开详情抽屉 */
const openInfoDrawer = (record: DeviceItem): void => {
infoDrawerRef.value?.openDrawer({ id: record.id, name: record.name })
}
/** 删除(归档,二次确认) */
const deleteRecord = (record: DeviceItem): void => {
Modal.confirm({
title: '确认删除?',
content: `设备 [${record.deviceCode ?? record.name}] 删除后将归档,不可恢复,请谨慎操作。`,
okType: 'danger',
okText: '确认删除',
cancelText: '取消',
onOk: () => {
remove(record.id)
.then((res) => {
if (res.code === '00000') {
void message.success(res.msg)
listRequest()
}
})
.catch((err: unknown) => {
console.error('设备管理删除失败:', err)
})
},
})
}
/** 异步导出列表数据(按当前 Tab + 搜索条件) */
const exportListData = (): void => {
exportList({ ...search, category: activeTab.value })
.then((res) => {
if (res.code === '00000') {
void message.success(res.msg || '导出任务已提交,可在"查看导出任务"中查看进度')
}
})
.catch((err: unknown) => {
console.error('设备管理导出失败:', err)
})
}
/** 查看导出任务(原型阶段占位) */
const viewExportTask = (): void => {
void message.info('当前共有 3 个导出任务正在处理中(原型阶段提示)')
}
/** 统一操作入口 */
const handleAction = (type: string, record?: DeviceItem): void => {
if (type === 'add') {
openAddModal()
return
}
if (type === 'info' && record) {
openInfoDrawer(record)
return
}
if (type === 'edit' && record) {
openEditModal(record)
return
}
if (type === 'delete' && record) {
deleteRecord(record)
}
}
/** 行选择 */
const rowSelection = {
selectedRowKeys,
onChange: (keys: (string | number)[]): void => {
selectedRowKeys.value = keys
},
}
onMounted(() => {
listRequest()
})
return {
pageLoading,
activeTab,
search,
table,
selectedRowKeys,
rowSelection,
addOrEditRef,
infoDrawerRef,
tabList: TAB_LIST,
runStatusOptions: RUN_STATUS_OPTIONS,
deployStatusOptions: DEPLOY_STATUS_OPTIONS,
searchQuery,
resetQuery,
switchTab,
dataSourceChange,
handleAction,
exportListData,
viewExportTask,
listRequest,
}
}
export type { RunStatus }
@@ -0,0 +1,24 @@
import type { DeployStatus, RunStatus, SearchForm } from '../types'
/** 投用状态下拉选项 */
export const DEPLOY_STATUS_OPTIONS: { label: string; value: DeployStatus }[] = [
{ label: '在库', value: 'in_stock' },
{ label: '投用', value: 'deployed' },
]
/** 运行状态下拉选项 */
export const RUN_STATUS_OPTIONS: { label: string; value: RunStatus }[] = [
{ label: '在用', value: 'online' },
{ label: '离线', value: 'offline' },
{ label: '维修中', value: 'repairing' },
{ label: '报废', value: 'scrapped' },
]
/** 搜索表单工厂:统一重置入口 */
export const createSearchKey = (): SearchForm => ({
keyword: undefined,
runStatus: undefined,
deployStatus: undefined,
tenantName: undefined,
siteName: undefined,
})
@@ -0,0 +1,136 @@
import type { TableColumnsType } from 'ant-design-vue'
import { h } from 'vue'
import { Tag } from 'ant-design-vue'
import type { DeviceCategoryTab, DeployStatus, DeviceItem, RunStatus } from '../types'
/** 运行状态 → Tag 颜色映射 */
const runStatusColor: Record<RunStatus, string> = {
online: 'success',
offline: 'default',
repairing: 'warning',
scrapped: 'error',
}
/** 运行状态 → 中文 */
const runStatusText: Record<RunStatus, string> = {
online: '在用',
offline: '离线',
repairing: '维修中',
scrapped: '报废',
}
/** 投用状态 → Tag 颜色映射 */
const deployStatusColor: Record<DeployStatus, string> = {
in_stock: 'default',
deployed: 'success',
}
/** 投用状态 → 中文 */
const deployStatusText: Record<DeployStatus, string> = {
in_stock: '在库',
deployed: '投用',
}
/** 通用列(所有 Tab 共享,操作列在末尾追加) */
const buildCommonColumns = (): TableColumnsType => [
{ title: '序号', dataIndex: 'index', align: 'center', fixed: 'left', width: 70, customRender: ({ index }: { index: number }) => index + 1 },
{ title: '设备编码', dataIndex: 'deviceCode', align: 'left', width: 160, resizable: true, ellipsis: true },
{ title: '设备名称', dataIndex: 'name', align: 'left', width: 140, resizable: true, ellipsis: true },
{ title: '所属型号', dataIndex: 'modelName', align: 'left', width: 140, resizable: true, ellipsis: true },
{
title: '投用状态',
dataIndex: 'deployStatus',
align: 'center',
width: 100,
customRender: ({ value }: { value: DeployStatus }) =>
h(Tag, { color: deployStatusColor[value] ?? 'default' }, () => deployStatusText[value] ?? '—'),
},
{
title: '运行状态',
dataIndex: 'runStatus',
align: 'center',
width: 100,
customRender: ({ value }: { value: RunStatus }) =>
h(Tag, { color: runStatusColor[value] ?? 'default' }, () => runStatusText[value] ?? '—'),
},
{ title: '所属租户', dataIndex: 'tenantName', align: 'left', width: 140, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '所属布点场所', dataIndex: 'siteName', align: 'left', width: 140, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '软件版本', dataIndex: 'softwareVersion', align: 'center', width: 110, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '入库时间', dataIndex: 'onlineTime', align: 'center', width: 170, sorter: true, resizable: true },
{ title: '投用时间', dataIndex: 'deployTime', align: 'center', width: 170, resizable: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '最近心跳', dataIndex: 'lastHeartbeat', align: 'center', width: 170, resizable: true },
]
/** 操作列(固定在最右侧) */
const buildActionColumn = (): TableColumnsType => [
{ title: '操作', dataIndex: 'action', align: 'center', fixed: 'right', width: 160 },
]
/** 智养餐饮设备差异列 */
const cateringExtra: TableColumnsType = [
{ title: '所属轨道', dataIndex: 'trackNo', align: 'center', width: 100, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '摆放方式', dataIndex: 'placement', align: 'center', width: 100, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '绑定大屏', dataIndex: 'boundScreen', align: 'center', width: 120, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '所在档口', dataIndex: 'stall', align: 'left', width: 120, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '客户端 IP', dataIndex: 'clientIp', align: 'center', width: 140, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
]
/** 体重测量设备差异列 */
const bodyExtra: TableColumnsType = [
{ title: '测量指标', dataIndex: 'measureIndicators', align: 'left', width: 160, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '量程', dataIndex: 'range', align: 'center', width: 100, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '精度等级', dataIndex: 'accuracy', align: 'center', width: 100, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '计量单位', dataIndex: 'unit', align: 'center', width: 90, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '测量方式', dataIndex: 'measureMethod', align: 'center', width: 100, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
]
/** 智能穿戴设备差异列 */
const wearableExtra: TableColumnsType = [
{ title: '腕带编号', dataIndex: 'bandNo', align: 'center', width: 140, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: 'SIM 卡号', dataIndex: 'simCard', align: 'center', width: 160, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '绑定用户', dataIndex: 'boundUser', align: 'left', width: 120, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '电池电量', dataIndex: 'battery', align: 'center', width: 100, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '通讯协议', dataIndex: 'protocol', align: 'center', width: 100, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
]
/** 健身器材设备差异列 */
const fitnessExtra: TableColumnsType = [
{ title: '器材类型', dataIndex: 'equipmentType', align: 'center', width: 100, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '阻力调节方式', dataIndex: 'resistanceMode', align: 'center', width: 120, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '训练数据采集', dataIndex: 'trainDataType', align: 'left', width: 160, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '设备位置', dataIndex: 'location', align: 'left', width: 140, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
]
/** 健康监测设备差异列 */
const monitorExtra: TableColumnsType = [
{ title: '监测指标', dataIndex: 'monitorIndicators', align: 'left', width: 160, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '采样频率', dataIndex: 'sampleFrequency', align: 'center', width: 100, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '测量方式', dataIndex: 'measureMethod', align: 'center', width: 100, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '报告生成', dataIndex: 'reportMode', align: 'center', width: 100, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
]
/** 按 Tab 拼装列定义(通用列前段 + 差异列 + 通用列后段 + 操作列) */
const buildColumns = (tab: DeviceCategoryTab): TableColumnsType => {
const common = buildCommonColumns()
/** 入库时间/投用时间/最近心跳 提前在差异列之后展示,操作列固定在末尾 */
const tailColumns = common.slice(-3)
const headColumns = common.slice(0, -3)
const extraMap: Record<DeviceCategoryTab, TableColumnsType> = {
catering: cateringExtra,
body: bodyExtra,
wearable: wearableExtra,
fitness: fitnessExtra,
monitor: monitorExtra,
}
return [...headColumns, ...extraMap[tab], ...tailColumns, ...buildActionColumn()]
}
export const useTable = () => {
return {
buildColumns,
runStatusText,
deployStatusText,
}
}
export type { DeviceItem }
@@ -0,0 +1,126 @@
<template>
<div class="admin-list-page manage-page">
<!-- 同级数据切换5 个设备类别共用同一表格 category 过滤参考 vue 研发项目 unitReportData -->
<div class="listPageRow manage-page__switcher">
<a-radio-group
v-model:value="activeTab"
button-style="solid"
@change="switchTab"
>
<a-radio-button v-for="tab in tabList" :key="tab.key" :value="tab.key">
{{ tab.label }}
</a-radio-button>
</a-radio-group>
</div>
<FilterBar v-model="search" @search="searchQuery" @reset="resetQuery">
<a-form-item label="关键字">
<a-input
v-model:value="search.keyword"
v-no-space
allow-clear
placeholder="设备编码 / 名称"
style="width: 200px"
/>
</a-form-item>
<a-form-item label="投用状态">
<a-select
v-model:value="search.deployStatus"
allow-clear
placeholder="全部"
style="width: 140px"
:options="deployStatusOptions"
/>
</a-form-item>
<a-form-item label="运行状态">
<a-select
v-model:value="search.runStatus"
allow-clear
placeholder="全部"
style="width: 140px"
:options="runStatusOptions"
/>
</a-form-item>
<a-form-item label="所属租户">
<a-input v-model:value="search.tenantName" v-no-space allow-clear placeholder="请输入租户名称" style="width: 180px" />
</a-form-item>
<a-form-item label="所属场所">
<a-input v-model:value="search.siteName" v-no-space allow-clear placeholder="请输入场所名称" style="width: 180px" />
</a-form-item>
</FilterBar>
<TableCard :table="table" :loading="pageLoading" row-key="id" :row-selection="rowSelection" @change="dataSourceChange">
<template #toolbar>
<a-button type="primary" @click="handleAction('add')">
<template #icon><PlusOutlined /></template>
新增设备
</a-button>
<a-button @click="exportListData">
<template #icon><DownloadOutlined /></template>
导出列表数据
</a-button>
<a-button @click="viewExportTask">
<template #icon><UnorderedListOutlined /></template>
查看导出任务
</a-button>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'action'">
<a-button type="link" size="small" @click="handleAction('info', record)">详情</a-button>
<a-divider type="vertical" />
<a-button type="link" size="small" @click="handleAction('edit', record)">编辑</a-button>
<a-divider type="vertical" />
<a-button type="link" size="small" danger @click="handleAction('delete', record)">删除</a-button>
</template>
</template>
</TableCard>
<AddOrEdit ref="addOrEditRef" @load="listRequest" />
<InfoDrawer ref="infoDrawerRef" />
</div>
</template>
<script setup lang="ts">
import { DownloadOutlined, PlusOutlined, UnorderedListOutlined } from '@ant-design/icons-vue'
import FilterBar from '@components/FilterBar.vue'
import TableCard from '@components/TableCard.vue'
import AddOrEdit from './component/modal/addOrEdit/addOrEdit.vue'
import InfoDrawer from './component/drawer/info/info.vue'
import { usePage } from './init/usePage'
const {
pageLoading,
activeTab,
search,
table,
selectedRowKeys,
rowSelection,
addOrEditRef,
infoDrawerRef,
tabList,
runStatusOptions,
deployStatusOptions,
searchQuery,
resetQuery,
switchTab,
dataSourceChange,
handleAction,
exportListData,
viewExportTask,
listRequest,
} = usePage()
</script>
<style scoped lang="less">
@import "@assets/styles/listPage.less";
.admin-list-page {
display: flex;
flex-direction: column;
height: 100%;
}
.manage-page__switcher {
margin-bottom: 12px;
}
</style>
@@ -0,0 +1,64 @@
import type { BaseRecord, BaseSearchForm } from '@pages/admin-portal/shared/types'
/** 顶级分类 Tab key(与设备分类预置 5 类对齐) */
export type DeviceCategoryTab =
| 'catering'
| 'body'
| 'wearable'
| 'fitness'
| 'monitor'
/** 运行状态 */
export type RunStatus = 'online' | 'offline' | 'repairing' | 'scrapped'
/** 投用状态:在库 / 投用 */
export type DeployStatus = 'in_stock' | 'deployed'
/** 设备实例(5 个 Tab 通用结构 + 差异字段以扩展属性承载) */
export interface DeviceItem extends BaseRecord {
/** 设备编码 */
deviceCode: string
/** 设备名称 */
name: string
/** 所属二级型号 */
modelName: string
/** 所属顶级分类 */
category: DeviceCategoryTab
/** 所属租户名称(为空代表在库未投用) */
tenantName: string
/** 所属布点场所(为空代表在库未投用) */
siteName: string
/** 投用状态:在库 / 投用(依据租户与场所自动判定) */
deployStatus: DeployStatus
/** 运行状态 */
runStatus: RunStatus
/** 软件版本 */
softwareVersion: string
/** 入网时间(即入库时间) */
onlineTime: string
/** 投用时间(投用后才存在) */
deployTime: string
/** 最近心跳时间 */
lastHeartbeat: string
/** 是否 IOT 设备(从分类继承,只读) */
isIot: boolean
/** 各 Tab 差异字段(动态) */
[key: string]: string | boolean | undefined
}
/** 搜索表单 */
export interface SearchForm extends BaseSearchForm {
keyword?: string
runStatus?: RunStatus
deployStatus?: DeployStatus
tenantName?: string
siteName?: string
}
/** Tab 元信息 */
export interface TabMeta {
key: DeviceCategoryTab
label: string
/** 是否 IOT 设备(与设备分类预置值对齐) */
isIot: boolean
}
@@ -0,0 +1,15 @@
import type { ApiResponse } from '@axios'
import { postRequest } from '@axios'
import type { DeviceStatItem, SearchForm, StatisticsOverview } from '../types'
/** 查询顶部统计聚合数据(4 个核心指标卡) */
export const fetchOverview = (params: SearchForm): Promise<ApiResponse<StatisticsOverview>> =>
postRequest('axiosRequest', '/admin/device/statistics/overview', params)
/** 查询设备统计列表(按多维条件过滤后的扁平记录) */
export const list = (params: SearchForm): Promise<ApiResponse<DeviceStatItem[]>> =>
postRequest('axiosRequest', '/admin/device/statistics/list', params)
/** 异步导出统计列表数据 */
export const exportList = (params: SearchForm): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/statistics/export', params)
@@ -0,0 +1,154 @@
import { computed, onMounted, reactive, ref } from 'vue'
import type { TablePaginationConfig } from 'ant-design-vue'
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
import { createPaginationConfig } from '@utils/antDesign/table'
import type { TableState } from '@utils/antDesign/table'
import { exportList, fetchOverview, list } from '../api'
import type { DeviceStatItem, SearchForm, StatisticsOverview } from '../types'
import {
CATEGORY_OPTIONS,
DEPLOY_STATUS_OPTIONS,
RUN_STATUS_OPTIONS,
YES_NO_OPTIONS,
createSearchKey,
} from './useSearch'
import { buildSummaryRow, tableColumns } from './useTable'
/**
* 设备统计页主编排:
* - 顶部 4 个核心指标卡(总数 / 投用 / 在库 / 异常)
* - 多维搜索条件(关键字 / 类别 / 投用 / 运行 / IOT / 分发 / 上报 / 租户 / 场所 / 软件版本 / 时间范围)
* - 列表展示扁平设备记录 + 顶部统计行(合计)
* - 异步导出 + 查看导出任务
*/
export const usePage = () => {
const { message } = useAntdStaticMethods()
const search = reactive<SearchForm>(createSearchKey())
const pageLoading = ref<boolean>(false)
/** 顶部统计卡数据 */
const overview = ref<StatisticsOverview>({ overview: [] })
/** 入库时间范围(绑 a-range-picker */
const onlineRange = ref<[string, string] | undefined>(undefined)
/** 投用时间范围 */
const deployRange = ref<[string, string] | undefined>(undefined)
const table = reactive<TableState<DeviceStatItem>>({
columns: tableColumns,
dataSource: [],
sort: { field: '', order: null },
pagination: createPaginationConfig() as TablePaginationConfig,
})
/** 统计行(合计) */
const summaryRow = computed(() => buildSummaryRow(table.dataSource))
/** 构造请求参数(时间范围拍平) */
const buildParams = (): SearchForm => ({
...search,
onlineStart: onlineRange.value?.[0],
onlineEnd: onlineRange.value?.[1],
deployStart: deployRange.value?.[0],
deployEnd: deployRange.value?.[1],
})
/** 拉取列表 */
const listRequest = (): void => {
pageLoading.value = true
list(buildParams())
.then((res) => {
if (res.code === '00000') {
table.dataSource = res.data ?? []
table.pagination.total = table.dataSource.length
}
})
.catch((err: unknown) => {
console.error('设备统计列表请求失败:', err)
})
.finally(() => {
pageLoading.value = false
})
}
/** 拉取顶部统计卡 */
const overviewRequest = (): void => {
fetchOverview(buildParams())
.then((res) => {
if (res.code === '00000') {
overview.value = res.data ?? { overview: [] }
}
})
.catch((err: unknown) => {
console.error('设备统计概览请求失败:', err)
})
}
/** 同时拉取列表 + 概览 */
const fetchAll = (): void => {
listRequest()
overviewRequest()
}
const searchQuery = (): void => {
table.pagination.current = 1
fetchAll()
}
const resetQuery = (): void => {
Object.assign(search, createSearchKey())
onlineRange.value = undefined
deployRange.value = undefined
table.pagination.current = 1
fetchAll()
}
/** 分页/排序变更 */
const dataSourceChange = (pagination: TablePaginationConfig): void => {
table.pagination.current = pagination.current ?? 1
table.pagination.pageSize = pagination.pageSize ?? 30
}
/** 异步导出列表数据 */
const exportListData = (): void => {
exportList(buildParams())
.then((res) => {
if (res.code === '00000') {
void message.success(res.msg || '导出任务已提交,可在"查看导出任务"中查看进度')
}
})
.catch((err: unknown) => {
console.error('设备统计导出失败:', err)
})
}
/** 查看导出任务(原型阶段占位) */
const viewExportTask = (): void => {
void message.info('当前共有 3 个导出任务正在处理中(原型阶段提示)')
}
onMounted(() => {
fetchAll()
})
return {
pageLoading,
search,
onlineRange,
deployRange,
table,
overview,
summaryRow,
categoryOptions: CATEGORY_OPTIONS,
deployStatusOptions: DEPLOY_STATUS_OPTIONS,
runStatusOptions: RUN_STATUS_OPTIONS,
yesNoOptions: YES_NO_OPTIONS,
searchQuery,
resetQuery,
dataSourceChange,
exportListData,
viewExportTask,
listRequest,
}
}
@@ -0,0 +1,49 @@
import type { DeviceCategoryTab, DeployStatus, RunStatus } from '../../manage/types'
import type { SearchForm } from '../types'
/** 设备类别下拉(5 类) */
export const CATEGORY_OPTIONS: { label: string; value: DeviceCategoryTab }[] = [
{ label: '智养餐饮设备', value: 'catering' },
{ label: '体重测量设备', value: 'body' },
{ label: '智能穿戴设备', value: 'wearable' },
{ label: '健身器材设备', value: 'fitness' },
{ label: '健康监测设备', value: 'monitor' },
]
/** 投用状态下拉 */
export const DEPLOY_STATUS_OPTIONS: { label: string; value: DeployStatus }[] = [
{ label: '在库', value: 'in_stock' },
{ label: '投用', value: 'deployed' },
]
/** 运行状态下拉 */
export const RUN_STATUS_OPTIONS: { label: string; value: RunStatus }[] = [
{ label: '在用', value: 'online' },
{ label: '离线', value: 'offline' },
{ label: '维修中', value: 'repairing' },
{ label: '报废', value: 'scrapped' },
]
/** 是/否下拉(IOT 设备 / 启用数据分发 / 启用数据上报 共用) */
export const YES_NO_OPTIONS: { label: string; value: boolean }[] = [
{ label: '是', value: true },
{ label: '否', value: false },
]
/** 搜索表单工厂:统一重置入口 */
export const createSearchKey = (): SearchForm => ({
keyword: undefined,
category: undefined,
deployStatus: undefined,
runStatus: undefined,
isIot: undefined,
dataDispatch: undefined,
dataReport: undefined,
tenantName: undefined,
siteName: undefined,
softwareVersion: undefined,
onlineStart: undefined,
onlineEnd: undefined,
deployStart: undefined,
deployEnd: undefined,
})
@@ -0,0 +1,92 @@
import type { TableColumnsType } from 'ant-design-vue'
import { h } from 'vue'
import { Tag } from 'ant-design-vue'
import type { DeviceStatItem } from '../types'
/** 列定义(按用户给定顺序) */
export const tableColumns: TableColumnsType = [
{ title: '序号', dataIndex: 'index', align: 'center', fixed: 'left', width: 70, customRender: ({ index }: { index: number }) => index + 1 },
{ title: '设备类别', dataIndex: 'categoryLabel', align: 'left', width: 140, resizable: true, ellipsis: true },
{ title: '设备名称', dataIndex: 'name', align: 'left', width: 160, resizable: true, ellipsis: true },
{ title: '设备编码', dataIndex: 'deviceCode', align: 'left', width: 180, resizable: true, ellipsis: true },
{
title: 'IOT 设备',
dataIndex: 'isIot',
align: 'center',
width: 100,
customRender: ({ value }: { value: boolean }) =>
h(Tag, { color: value ? 'green' : 'default' }, () => (value ? '是' : '否')),
},
{
title: '投用状态',
dataIndex: 'deployStatus',
align: 'center',
width: 100,
customRender: ({ value }: { value: DeviceStatItem['deployStatus'] }) =>
h(Tag, { color: value === 'deployed' ? 'success' : 'default' }, () => (value === 'deployed' ? '投用' : '在库')),
},
{
title: '启用数据分发',
dataIndex: 'dataDispatch',
align: 'center',
width: 120,
customRender: ({ value }: { value: boolean }) =>
h(Tag, { color: value ? 'green' : 'default' }, () => (value ? '已启用' : '未启用')),
},
{
title: '启用数据上报',
dataIndex: 'dataReport',
align: 'center',
width: 120,
customRender: ({ value }: { value: boolean }) =>
h(Tag, { color: value ? 'green' : 'default' }, () => (value ? '已启用' : '未启用')),
},
{ title: '所属租户', dataIndex: 'tenantName', align: 'left', width: 140, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '布点场所', dataIndex: 'siteName', align: 'left', width: 140, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '软件版本', dataIndex: 'softwareVersion', align: 'center', width: 110, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{
title: '运行状态',
dataIndex: 'runStatus',
align: 'center',
width: 100,
customRender: ({ value }: { value: DeviceStatItem['runStatus'] }) => {
const colorMap: Record<string, string> = {
online: 'success',
offline: 'default',
repairing: 'warning',
scrapped: 'error',
}
const textMap: Record<string, string> = {
online: '在用',
offline: '离线',
repairing: '维修中',
scrapped: '报废',
}
return h(Tag, { color: colorMap[value] ?? 'default' }, () => textMap[value] ?? '—')
},
},
{ title: '入库时间', dataIndex: 'onlineTime', align: 'center', width: 170, sorter: true, resizable: true },
{ title: '投用时间', dataIndex: 'deployTime', align: 'center', width: 170, resizable: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '操作', dataIndex: 'action', align: 'center', fixed: 'right', width: 100 },
]
/** 计算统计行数据(列表上方统计行) */
export const buildSummaryRow = (rows: DeviceStatItem[]): Record<string, number | string> => {
const total = rows.length
const deployed = rows.filter((r) => r.deployStatus === 'deployed').length
const inStock = rows.filter((r) => r.deployStatus === 'in_stock').length
const iot = rows.filter((r) => r.isIot).length
const dispatch = rows.filter((r) => r.dataDispatch).length
const report = rows.filter((r) => r.dataReport).length
const online = rows.filter((r) => r.runStatus === 'online').length
const offline = rows.filter((r) => r.runStatus === 'offline').length
return {
index: '合计',
name: `${total}`,
iot,
deployStatus: `投用 ${deployed} / 在库 ${inStock}`,
dataDispatch: dispatch,
dataReport: report,
runStatus: `在用 ${online} / 离线 ${offline}`,
}
}
@@ -0,0 +1,198 @@
<template>
<div class="admin-list-page statistics-page">
<!-- 顶部核心指标卡4 -->
<a-row :gutter="12" class="statistics-page__overview">
<a-col v-for="card in overview.overview" :key="card.key" :span="6">
<StatCard
:value="card.value"
:label="card.label"
:color="card.color"
:icon="card.percent !== undefined ? `${card.percent}%` : ''"
/>
</a-col>
</a-row>
<!-- 多维搜索 -->
<FilterBar v-model="search" @search="searchQuery" @reset="resetQuery">
<a-form-item label="关键字">
<a-input
v-model:value="search.keyword"
v-no-space
allow-clear
placeholder="设备编码 / 名称"
style="width: 200px"
/>
</a-form-item>
<a-form-item label="设备类别">
<a-select
v-model:value="search.category"
:options="categoryOptions"
allow-clear
placeholder="全部类别"
style="width: 160px"
/>
</a-form-item>
<a-form-item label="投用状态">
<a-select
v-model:value="search.deployStatus"
:options="deployStatusOptions"
allow-clear
placeholder="全部"
style="width: 140px"
/>
</a-form-item>
<a-form-item label="运行状态">
<a-select
v-model:value="search.runStatus"
:options="runStatusOptions"
allow-clear
placeholder="全部"
style="width: 140px"
/>
</a-form-item>
<a-form-item label="IOT 设备">
<a-select
v-model:value="search.isIot"
:options="yesNoOptions"
allow-clear
placeholder="全部"
style="width: 120px"
/>
</a-form-item>
<a-form-item label="启用数据分发">
<a-select
v-model:value="search.dataDispatch"
:options="yesNoOptions"
allow-clear
placeholder="全部"
style="width: 140px"
/>
</a-form-item>
<a-form-item label="启用数据上报">
<a-select
v-model:value="search.dataReport"
:options="yesNoOptions"
allow-clear
placeholder="全部"
style="width: 140px"
/>
</a-form-item>
<a-form-item label="所属租户">
<a-input v-model:value="search.tenantName" v-no-space allow-clear placeholder="请输入租户名称" style="width: 180px" />
</a-form-item>
<a-form-item label="布点场所">
<a-input v-model:value="search.siteName" v-no-space allow-clear placeholder="请输入场所名称" style="width: 180px" />
</a-form-item>
<a-form-item label="软件版本">
<a-input v-model:value="search.softwareVersion" v-no-space allow-clear placeholder="如 V1.0.0" style="width: 140px" />
</a-form-item>
<a-form-item label="入库时间">
<a-range-picker
v-model:value="onlineRange"
style="width: 240px"
value-format="YYYY-MM-DD"
/>
</a-form-item>
<a-form-item label="投用时间">
<a-range-picker
v-model:value="deployRange"
style="width: 240px"
value-format="YYYY-MM-DD"
/>
</a-form-item>
</FilterBar>
<!-- 表格 + 统计行 + 异步导出 -->
<TableCard :table="table" :loading="pageLoading" row-key="id" @change="dataSourceChange">
<template #toolbar>
<a-button @click="exportListData">
<template #icon><DownloadOutlined /></template>
导出列表数据
</a-button>
<a-button @click="viewExportTask">
<template #icon><UnorderedListOutlined /></template>
查看导出任务
</a-button>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'action'">
<a-button type="link" size="small" @click="viewDetail(record)">查看详情</a-button>
</template>
</template>
<!-- 列表上方统计行cell 顺序与 tableColumns 严格对齐 -->
<template #summary>
<a-table-summary fixed>
<a-table-summary-row>
<a-table-summary-cell :index="0" align="center">{{ summaryRow.index }}</a-table-summary-cell>
<a-table-summary-cell :index="1" align="left" />
<a-table-summary-cell :index="2" align="left">{{ summaryRow.name }}</a-table-summary-cell>
<a-table-summary-cell :index="3" align="left" />
<a-table-summary-cell :index="4" align="center">{{ summaryRow.iot }}</a-table-summary-cell>
<a-table-summary-cell :index="5" align="center">{{ summaryRow.deployStatus }}</a-table-summary-cell>
<a-table-summary-cell :index="6" align="center">{{ summaryRow.dataDispatch }}</a-table-summary-cell>
<a-table-summary-cell :index="7" align="center">{{ summaryRow.dataReport }}</a-table-summary-cell>
<a-table-summary-cell :index="8" align="left" />
<a-table-summary-cell :index="9" align="left" />
<a-table-summary-cell :index="10" align="center" />
<a-table-summary-cell :index="11" align="center">{{ summaryRow.runStatus }}</a-table-summary-cell>
<a-table-summary-cell :index="12" align="center" />
<a-table-summary-cell :index="13" align="center" />
<a-table-summary-cell :index="14" align="center" />
</a-table-summary-row>
</a-table-summary>
</template>
</TableCard>
</div>
</template>
<script setup lang="ts">
import { DownloadOutlined, UnorderedListOutlined } from '@ant-design/icons-vue'
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
import StatCard from '@components/StatCard.vue'
import FilterBar from '@components/FilterBar.vue'
import TableCard from '@components/TableCard.vue'
import type { DeviceStatItem } from './types'
import { usePage } from './init/usePage'
const {
pageLoading,
search,
onlineRange,
deployRange,
table,
overview,
summaryRow,
categoryOptions,
deployStatusOptions,
runStatusOptions,
yesNoOptions,
searchQuery,
resetQuery,
dataSourceChange,
exportListData,
viewExportTask,
} = usePage()
const { message } = useAntdStaticMethods()
/** 查看详情(原型阶段占位) */
const viewDetail = (record: DeviceStatItem): void => {
void message.info(`查看设备详情:${record.name}(原型阶段提示)`)
}
</script>
<style scoped lang="less">
@import "@assets/styles/listPage.less";
.admin-list-page {
display: flex;
flex-direction: column;
height: 100%;
gap: 12px;
}
.statistics-page__overview {
margin: 0 !important;
flex-shrink: 0;
}
</style>
@@ -0,0 +1,69 @@
import type { DeviceCategoryTab, DeployStatus, RunStatus } from '../../manage/types'
/** 顶部统计卡数据 */
export interface StatCardData {
key: string
label: string
value: number
/** 与总数的百分比(辅助显示) */
percent?: number
color: 'blue' | 'green' | 'orange' | 'red'
}
/** 统计页列表行 = 设备扁平记录 */
export interface DeviceStatItem {
id: string
/** 设备类别(顶级分类中文) */
categoryLabel: string
/** 顶级分类 key */
category: DeviceCategoryTab
/** 设备名称 */
name: string
/** 设备编码 */
deviceCode: string
/** 是否 IOT 设备 */
isIot: boolean
/** 投用状态 */
deployStatus: DeployStatus
/** 启用数据分发 */
dataDispatch: boolean
/** 启用数据上报 */
dataReport: boolean
/** 所属租户 */
tenantName: string
/** 布点场所 */
siteName: string
/** 软件版本 */
softwareVersion: string
/** 运行状态 */
runStatus: RunStatus
/** 入库时间 */
onlineTime: string
/** 投用时间 */
deployTime: string
}
/** 顶部统计聚合数据 */
export interface StatisticsOverview {
overview: StatCardData[]
}
/** 搜索表单(多维检测条件) */
export interface SearchForm {
keyword?: string
category?: DeviceCategoryTab
deployStatus?: DeployStatus
runStatus?: RunStatus
isIot?: boolean
dataDispatch?: boolean
dataReport?: boolean
tenantName?: string
siteName?: string
softwareVersion?: string
/** 入库时间范围 */
onlineStart?: string
onlineEnd?: string
/** 投用时间范围 */
deployStart?: string
deployEnd?: string
}
@@ -0,0 +1,32 @@
/**
* 分发配置 - 下游系统 接口
*/
import type { ApiResponse } from '@axios'
import { postRequest } from '@axios'
import type { DownstreamForm, DownstreamItem, SearchForm } from '../types'
const BASE = '/admin/device/distribute/config/downstream'
/** 列表 */
export const list = (params: SearchForm): Promise<ApiResponse<DownstreamItem[]>> =>
postRequest('axiosRequest', BASE + '/list', params)
/** 详情 */
export const info = (id: string): Promise<ApiResponse<DownstreamItem>> =>
postRequest('axiosRequest', BASE + '/info', { id })
/** 新增 */
export const add = (params: DownstreamForm): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', BASE + '/add', params)
/** 编辑 */
export const update = (params: DownstreamForm): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', BASE + '/update', params)
/** 删除 */
export const remove = (id: string): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', BASE + '/delete', { id })
/** 启用/禁用 */
export const toggleStatus = (id: string): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', BASE + '/toggleStatus', { id })
@@ -0,0 +1,8 @@
import type { ApiResponse } from '@axios'
import { postRequest } from '@axios'
import type { DetailInfo } from '../types'
const BASE = '/admin/device/distribute/config/downstream'
export const getInfo = (id: string): Promise<ApiResponse<DetailInfo>> =>
postRequest('axiosRequest', BASE + '/info', { id })
@@ -0,0 +1,53 @@
<template>
<a-drawer
v-model:open="pageInfo.visible"
:title="pageInfo.title"
:width="pageInfo.width"
:destroy-on-close="true"
>
<a-spin :spinning="pageInfo.spin">
<a-descriptions v-if="detail" :column="2" bordered size="small">
<a-descriptions-item label="下游ID">{{ detail.id ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="系统名称">{{ detail.name ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="业务编码">{{ detail.code ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="statusColor[detail.status]">{{ statusText(detail.status) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="回调地址" :span="2">{{ detail.callbackUrl ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="鉴权方式">
<a-tag :color="authTypeColor[detail.authType]">{{ authTypeText(detail.authType) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="鉴权密钥">{{ detail.authKey ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="订阅数据类型" :span="2">
<a-tag v-for="t in detail.dataTypes" :key="t" color="blue" style="margin-bottom: 2px">
{{ dataTypeText(t) }}
</a-tag>
<span v-if="!detail.dataTypes || detail.dataTypes.length === 0"></span>
</a-descriptions-item>
<a-descriptions-item label="重投策略">
<a-tag :color="retryStrategyColor[detail.retryStrategy]">{{ retryStrategyText(detail.retryStrategy) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="最大重试">{{ detail.maxRetry ?? 0 }} </a-descriptions-item>
<a-descriptions-item label="创建时间">{{ detail.createdAt ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="备注">{{ detail.remark ?? '—' }}</a-descriptions-item>
</a-descriptions>
</a-spin>
</a-drawer>
</template>
<script setup lang="ts">
import { usePage } from './init/usePage'
import {
authTypeColor,
authTypeText,
dataTypeText,
retryStrategyColor,
retryStrategyText,
statusColor,
statusText,
} from '../../../types'
const { pageInfo, detail, openDrawer, closeDrawer } = usePage()
defineExpose({ openDrawer, closeDrawer })
</script>
@@ -0,0 +1,44 @@
import { reactive, ref } from 'vue'
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
import * as api from '../api'
import type { DetailInfo, OpenContext, PageInfo } from '../types'
export const usePage = () => {
const { message } = useAntdStaticMethods()
const pageInfo = reactive<PageInfo>({
visible: false,
title: '下游系统详情',
width: 720,
spin: false,
})
const detail = ref<DetailInfo | null>(null)
const openDrawer = (ctx: OpenContext): void => {
pageInfo.title = ctx.title ?? '下游系统详情'
pageInfo.visible = true
pageInfo.spin = true
detail.value = null
api.getInfo(ctx.id)
.then((res) => {
if (res.code === '00000' && res.data) {
detail.value = res.data
} else {
void message.error(res.msg || '查询失败')
}
})
.catch((err: unknown) => {
console.error('查询失败:', err)
})
.finally(() => {
pageInfo.spin = false
})
}
const closeDrawer = (): void => {
pageInfo.visible = false
}
return { pageInfo, detail, openDrawer, closeDrawer }
}
@@ -0,0 +1,15 @@
import type { DownstreamItem } from '../../../../types'
export interface PageInfo {
visible: boolean
title: string
width: number
spin: boolean
}
export interface OpenContext {
id: string
title?: string
}
export type DetailInfo = DownstreamItem
@@ -0,0 +1,108 @@
<template>
<a-modal
v-model:open="pageInfo.visible"
:title="pageInfo.title"
:width="pageInfo.width"
:confirm-loading="pageInfo.spin"
:mask-closable="false"
:keyboard="false"
:destroy-on-close="true"
@ok="submit(() => $emit('load'))"
>
<a-form layout="vertical">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="系统名称" required>
<a-input v-model:value="formModel.name" v-no-space placeholder="如:体重管理系统" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="业务编码" required>
<a-input
v-model:value="formModel.code"
v-only-alphanumeric
:disabled="!!formModel.id"
placeholder="如:WEIGHT-MGMT"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="回调地址" required>
<a-input v-model:value="formModel.callbackUrl" v-no-space placeholder="https://downstream.example.com/api/v1/iot/callback" />
</a-form-item>
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="鉴权方式" required>
<a-select v-model:value="formModel.authType" :options="AUTH_TYPE_OPTIONS" />
</a-form-item>
</a-col>
<a-col :span="16">
<a-form-item label="鉴权密钥">
<a-input
v-model:value="formModel.authKey"
v-no-space
:disabled="formModel.authType === 'none'"
placeholder="按鉴权方式填写 API Key / Secret / Token"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="订阅数据类型" required>
<a-select
v-model:value="formModel.dataTypes"
:options="DATA_TYPE_OPTIONS"
mode="multiple"
placeholder="勾选该系统订阅的数据类型"
/>
</a-form-item>
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="重投策略" required>
<a-select v-model:value="formModel.retryStrategy" :options="RETRY_STRATEGY_OPTIONS" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="最大重试" required>
<a-input-number
v-model:value="formModel.maxRetry"
:min="0"
:max="20"
:disabled="formModel.retryStrategy === 'none'"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="状态" required>
<a-select v-model:value="formModel.status" :options="STATUS_OPTIONS" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="备注">
<a-textarea
v-model:value="formModel.remark"
:rows="2"
:maxlength="200"
show-count
placeholder="可选"
/>
</a-form-item>
</a-form>
</a-modal>
</template>
<script setup lang="ts">
import { usePage } from './init/usePage'
import {
AUTH_TYPE_OPTIONS,
DATA_TYPE_OPTIONS,
RETRY_STRATEGY_OPTIONS,
STATUS_OPTIONS,
} from '../../../types'
defineEmits<{ (event: 'load'): void }>()
const { pageInfo, formModel, openAdd, openEdit, submit } = usePage()
defineExpose({ openAdd, openEdit })
</script>
@@ -0,0 +1,11 @@
import type { ApiResponse } from '@axios'
import { postRequest } from '@axios'
import type { FormModel } from '../types'
const BASE = '/admin/device/distribute/config/downstream'
export const add = (params: FormModel): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', BASE + '/add', params)
export const update = (params: FormModel): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', BASE + '/update', params)
@@ -0,0 +1,85 @@
import { reactive } from 'vue'
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
import * as api from '../api'
import type { DetailRecord, FormModel, PageInfo } from '../types'
/** 新增的默认模型 */
const createFormModel = (): FormModel => ({
id: undefined,
name: '',
code: '',
callbackUrl: '',
authType: 'apikey',
authKey: '',
dataTypes: [],
retryStrategy: 'exponential',
maxRetry: 5,
status: 'enabled',
remark: '',
})
export const usePage = () => {
const { message } = useAntdStaticMethods()
const pageInfo = reactive<PageInfo>({
visible: false,
title: '新增下游系统',
width: 720,
spin: false,
})
const formModel = reactive<FormModel>(createFormModel())
/** 新增 */
const openAdd = (): void => {
Object.assign(formModel, createFormModel())
pageInfo.title = '新增下游系统'
pageInfo.visible = true
}
/** 编辑(携带行数据) */
const openEdit = (record: DetailRecord): void => {
Object.assign(formModel, createFormModel(), {
id: record.id,
name: record.name,
code: record.code,
callbackUrl: record.callbackUrl,
authType: record.authType,
authKey: record.authKey,
dataTypes: [...record.dataTypes],
retryStrategy: record.retryStrategy,
maxRetry: record.maxRetry,
status: record.status,
remark: record.remark ?? '',
})
pageInfo.title = '编辑下游系统'
pageInfo.visible = true
}
const close = (): void => {
pageInfo.visible = false
}
/** 提交 */
const submit = (emit: (event: 'load') => void): void => {
pageInfo.spin = true
const params = { ...formModel }
const request = formModel.id ? api.update(params) : api.add({ ...params, id: undefined })
request
.then((res) => {
if (res.code === '00000') {
void message.success(res.msg)
emit('load')
close()
}
})
.catch((err: unknown) => {
console.error('提交失败:', err)
})
.finally(() => {
pageInfo.spin = false
})
}
return { pageInfo, formModel, openAdd, openEdit, close, submit }
}
@@ -0,0 +1,12 @@
import type { DownstreamForm, DownstreamItem } from '../../../../types'
export interface PageInfo {
visible: boolean
title: string
width: number
spin: boolean
}
export type FormModel = DownstreamForm
export type DetailRecord = DownstreamItem
@@ -0,0 +1,78 @@
<template>
<div class="tab-list-page">
<FilterBar v-model="search" @search="searchQuery" @reset="resetQuery">
<a-form-item label="系统名称">
<a-input v-model:value="search.name" v-no-space allow-clear placeholder="系统名称" style="width: 180px" />
</a-form-item>
<a-form-item label="鉴权方式">
<a-select v-model:value="search.authType" :options="AUTH_TYPE_OPTIONS" allow-clear placeholder="全部" style="width: 140px" />
</a-form-item>
<a-form-item label="重投策略">
<a-select v-model:value="search.retryStrategy" :options="RETRY_STRATEGY_OPTIONS" allow-clear placeholder="全部" style="width: 140px" />
</a-form-item>
<a-form-item label="状态">
<a-select v-model:value="search.status" :options="STATUS_OPTIONS" allow-clear placeholder="全部" style="width: 120px" />
</a-form-item>
</FilterBar>
<TableCard :table="table" :loading="pageLoading" row-key="id" @change="dataSourceChange">
<template #toolbar>
<a-button type="primary" @click="openAdd">
<template #icon><PlusOutlined /></template>
新增下游系统
</a-button>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'action'">
<a-button type="link" size="small" @click="handleAction('detail', record)">查看详情</a-button>
<a-divider type="vertical" />
<a-button type="link" size="small" @click="handleAction('edit', record)">编辑</a-button>
<a-divider type="vertical" />
<a-button type="link" size="small" @click="handleAction('toggle', record)">
{{ record.status === 'enabled' ? '禁用' : '启用' }}
</a-button>
<a-divider type="vertical" />
<a-button type="link" size="small" danger @click="handleAction('delete', record)">删除</a-button>
</template>
</template>
</TableCard>
<AddOrEdit ref="addOrEditRef" @load="reload" />
<InfoDrawer ref="infoDrawerRef" />
</div>
</template>
<script setup lang="ts">
import { PlusOutlined } from '@ant-design/icons-vue'
import AddOrEdit from './component/modal/addOrEdit/addOrEdit.vue'
import InfoDrawer from './component/drawer/info/info.vue'
import {
AUTH_TYPE_OPTIONS,
RETRY_STRATEGY_OPTIONS,
STATUS_OPTIONS,
} from './types'
import { usePage } from './init/usePage'
const {
pageLoading,
search,
table,
addOrEditRef,
infoDrawerRef,
searchQuery,
resetQuery,
dataSourceChange,
handleAction,
openAdd,
reload,
} = usePage()
</script>
<style scoped lang="less">
.tab-list-page {
display: flex;
flex-direction: column;
height: 100%;
gap: 12px;
}
</style>
@@ -0,0 +1,168 @@
import { onMounted, reactive, ref } from 'vue'
import type { TablePaginationConfig } from 'ant-design-vue'
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
import { createPaginationConfig } from '@utils/antDesign/table'
import type { TableState } from '@utils/antDesign/table'
import AddOrEdit from '../component/modal/addOrEdit/addOrEdit.vue'
import InfoDrawer from '../component/drawer/info/info.vue'
import * as api from '../api'
import type { DownstreamItem, SearchForm } from '../types'
import { createSearchKey } from './useSearch'
import { tableColumns } from './useTable'
/**
* 下游系统 Tab 编排
*/
export const usePage = () => {
const { message, Modal } = useAntdStaticMethods()
const search = reactive<SearchForm>(createSearchKey())
const table = reactive<TableState<DownstreamItem>>({
columns: tableColumns,
dataSource: [],
sort: { field: '', order: null },
pagination: createPaginationConfig() as TablePaginationConfig,
})
const pageLoading = ref<boolean>(false)
const selectedRowKeys = ref<(string | number)[]>([])
const addOrEditRef = ref<InstanceType<typeof AddOrEdit> | null>(null)
const infoDrawerRef = ref<InstanceType<typeof InfoDrawer> | null>(null)
const listRequest = (): void => {
pageLoading.value = true
api.list(search)
.then((res) => {
if (res.code === '00000') {
table.dataSource = res.data ?? []
table.pagination.total = table.dataSource.length
}
})
.catch((err: unknown) => {
console.error('下游系统列表请求失败:', err)
})
.finally(() => {
pageLoading.value = false
})
}
const searchQuery = (): void => {
table.pagination.current = 1
listRequest()
}
const resetQuery = (): void => {
Object.assign(search, createSearchKey())
table.pagination.current = 1
listRequest()
}
const dataSourceChange = (pagination: TablePaginationConfig): void => {
table.pagination.current = pagination.current ?? 1
table.pagination.pageSize = pagination.pageSize ?? 30
}
const rowSelection = {
selectedRowKeys,
onChange: (keys: (string | number)[]): void => {
selectedRowKeys.value = keys
},
}
/** 新增 */
const openAdd = (): void => {
addOrEditRef.value?.openAdd()
}
/** 编辑 */
const openEdit = (record: DownstreamItem): void => {
addOrEditRef.value?.openEdit(record)
}
/** 详情 */
const openDetail = (record: DownstreamItem): void => {
infoDrawerRef.value?.openDrawer({ id: record.id, title: '下游系统详情' })
}
/** 删除(单条) */
const removeRecord = (record: DownstreamItem): void => {
Modal.confirm({
title: '确认删除?',
content: `将删除下游系统 [${record.name}],删除后相关订阅映射将失效,确认继续?`,
okText: '确认删除',
okType: 'danger',
cancelText: '取消',
onOk: () => {
api.remove(record.id)
.then((res) => {
if (res.code === '00000') {
void message.success(res.msg)
listRequest()
}
})
.catch((err: unknown) => {
console.error('删除失败:', err)
})
},
})
}
/** 切换启用状态 */
const toggleStatus = (record: DownstreamItem): void => {
const next = record.status === 'enabled' ? '禁用' : '启用'
Modal.confirm({
title: `确认${next}`,
content: `${next}下游系统 [${record.name}],确认继续?`,
okText: `确认${next}`,
okType: 'danger',
cancelText: '取消',
onOk: () => {
api.toggleStatus(record.id)
.then((res) => {
if (res.code === '00000') {
void message.success(res.msg)
listRequest()
}
})
.catch((err: unknown) => {
console.error('状态切换失败:', err)
})
},
})
}
/** 刷新(弹框 ok 后回调) */
const reload = (): void => {
listRequest()
}
const handleAction = (type: string, record?: DownstreamItem): void => {
if (!record) return
if (type === 'detail') openDetail(record)
if (type === 'edit') openEdit(record)
if (type === 'delete') removeRecord(record)
if (type === 'toggle') toggleStatus(record)
}
onMounted(() => {
listRequest()
})
return {
pageLoading,
search,
table,
selectedRowKeys,
rowSelection,
addOrEditRef,
infoDrawerRef,
searchQuery,
resetQuery,
dataSourceChange,
handleAction,
openAdd,
reload,
}
}
@@ -0,0 +1,13 @@
import type { SearchForm } from '../types'
/** 搜索表单工厂:统一重置入口 */
export const createSearchKey = (): SearchForm => ({
name: undefined,
authType: undefined,
retryStrategy: undefined,
status: undefined,
})
export const useSearch = () => {
return { createSearchKey }
}
@@ -0,0 +1,59 @@
import type { TableColumnsType } from 'ant-design-vue'
import { h } from 'vue'
import { Tag } from 'ant-design-vue'
import type { AuthType, DownstreamItem, RetryStrategy, Status } from '../types'
import {
authTypeColor,
authTypeText,
dataTypeText,
retryStrategyColor,
retryStrategyText,
statusColor,
statusText,
} from '../types'
/** 下游系统列定义 */
export const tableColumns: TableColumnsType = [
{ title: '下游ID', dataIndex: 'id', align: 'left', fixed: 'left', width: 100 },
{ title: '系统名称', dataIndex: 'name', align: 'left', width: 160, resizable: true, ellipsis: true },
{ title: '业务编码', dataIndex: 'code', align: 'left', width: 130, ellipsis: true },
{ title: '回调地址', dataIndex: 'callbackUrl', align: 'left', width: 220, resizable: true, ellipsis: true },
{
title: '鉴权方式',
dataIndex: 'authType',
align: 'center',
width: 110,
customRender: ({ value }: { value: AuthType }) =>
h(Tag, { color: authTypeColor[value] }, () => authTypeText(value)),
},
{ title: '鉴权密钥', dataIndex: 'authKey', align: 'left', width: 160, ellipsis: true },
{
title: '订阅数据类型',
dataIndex: 'dataTypes',
align: 'left',
width: 200,
customRender: ({ value }: { value: DownstreamItem['dataTypes'] }) => {
if (!value || value.length === 0) return '—'
return value.map((v) => h(Tag, { color: 'blue', style: 'margin-bottom:2px' }, () => dataTypeText(v)))
},
},
{
title: '重投策略',
dataIndex: 'retryStrategy',
align: 'center',
width: 110,
customRender: ({ value }: { value: RetryStrategy }) =>
h(Tag, { color: retryStrategyColor[value] }, () => retryStrategyText(value)),
},
{ title: '最大重试', dataIndex: 'maxRetry', align: 'center', width: 90 },
{
title: '状态',
dataIndex: 'status',
align: 'center',
width: 90,
customRender: ({ value }: { value: Status }) =>
h(Tag, { color: statusColor[value] }, () => statusText(value)),
},
{ title: '创建时间', dataIndex: 'createdAt', align: 'center', width: 170 },
{ title: '操作', dataIndex: 'action', align: 'center', fixed: 'right', width: 220 },
]
@@ -0,0 +1,110 @@
/**
* 分发配置 - 下游系统 类型
*/
import type { BaseSearchForm } from '@pages/admin-portal/shared/types'
import type { DataType } from '../../../../../record/types'
/** 鉴权方式 */
export type AuthType = 'none' | 'apikey' | 'hmac' | 'token'
/** 重投策略 */
export type RetryStrategy = 'exponential' | 'fixed' | 'none'
/** 启用状态 */
export type Status = 'enabled' | 'disabled'
/** 下游系统列表项 */
export interface DownstreamItem {
id: string
name: string
code: string
callbackUrl: string
authType: AuthType
authKey: string
dataTypes: DataType[]
retryStrategy: RetryStrategy
maxRetry: number
status: Status
remark?: string
createdAt: string
}
/** 表单(新增/编辑) */
export interface DownstreamForm {
id?: string
name: string
code: string
callbackUrl: string
authType: AuthType
authKey: string
dataTypes: DataType[]
retryStrategy: RetryStrategy
maxRetry: number
status: Status
remark?: string
}
/** 搜索表单 */
export interface SearchForm extends BaseSearchForm {
name?: string
authType?: AuthType
retryStrategy?: RetryStrategy
status?: Status
}
/** 鉴权方式选项 */
export const AUTH_TYPE_OPTIONS: { label: string; value: AuthType }[] = [
{ label: '无鉴权', value: 'none' },
{ label: 'API Key', value: 'apikey' },
{ label: 'HMAC 签名', value: 'hmac' },
{ label: 'Bearer Token', value: 'token' },
]
/** 重投策略选项 */
export const RETRY_STRATEGY_OPTIONS: { label: string; value: RetryStrategy }[] = [
{ label: '指数退避', value: 'exponential' },
{ label: '固定间隔', value: 'fixed' },
{ label: '不重投', value: 'none' },
]
/** 启用状态选项 */
export const STATUS_OPTIONS: { label: string; value: Status }[] = [
{ label: '启用', value: 'enabled' },
{ label: '禁用', value: 'disabled' },
]
/** 鉴权方式 → 中文 */
export const authTypeText = (value: AuthType | string | undefined): string =>
AUTH_TYPE_OPTIONS.find((i) => i.value === value)?.label ?? value ?? '—'
/** 重投策略 → 中文 */
export const retryStrategyText = (value: RetryStrategy | string | undefined): string =>
RETRY_STRATEGY_OPTIONS.find((i) => i.value === value)?.label ?? value ?? '—'
/** 鉴权方式 → Tag 颜色 */
export const authTypeColor: Record<AuthType, string> = {
none: 'default',
apikey: 'blue',
hmac: 'geekblue',
token: 'cyan',
}
/** 重投策略 → Tag 颜色 */
export const retryStrategyColor: Record<RetryStrategy, string> = {
exponential: 'processing',
fixed: 'warning',
none: 'default',
}
/** 启用状态 → Tag 颜色 */
export const statusColor: Record<Status, string> = {
enabled: 'success',
disabled: 'default',
}
/** 启用状态 → 中文 */
export const statusText = (value: Status | string | undefined): string =>
STATUS_OPTIONS.find((i) => i.value === value)?.label ?? value ?? '—'
/** 数据类型选项(复用 record */
export { DATA_TYPE_OPTIONS, dataTypeText } from '../../../../../record/types'
@@ -0,0 +1,29 @@
import type { ApiResponse } from '@axios'
import { postRequest } from '@axios'
import type { SearchForm, SubscribeForm, SubscribeItem } from '../types'
const BASE = '/admin/device/distribute/config/subscribe'
/** 列表 */
export const list = (params: SearchForm): Promise<ApiResponse<SubscribeItem[]>> =>
postRequest('axiosRequest', BASE + '/list', params)
/** 新增 */
export const add = (params: SubscribeForm): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', BASE + '/add', params)
/** 编辑 */
export const update = (params: SubscribeForm): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', BASE + '/update', params)
/** 删除 */
export const remove = (id: string): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', BASE + '/delete', { id })
/** 启用/禁用 */
export const toggleStatus = (id: string): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', BASE + '/toggleStatus', { id })
/** 下游下拉选项 */
export const options = (): Promise<ApiResponse<{ downstream: { label: string; value: string }[] }>> =>
postRequest('axiosRequest', BASE + '/options', {})
@@ -0,0 +1,52 @@
<template>
<a-modal
v-model:open="pageInfo.visible"
:title="pageInfo.title"
:width="pageInfo.width"
:confirm-loading="pageInfo.spin"
:mask-closable="false"
:keyboard="false"
:destroy-on-close="true"
@ok="submit(() => $emit('load'))"
>
<a-form layout="vertical">
<a-form-item label="下游系统" required>
<a-select
v-model:value="formModel.downstreamId"
:options="downstreamOptions"
:disabled="!!formModel.id"
placeholder="选择下游系统"
/>
</a-form-item>
<a-form-item label="设备编码" required>
<a-input v-model:value="formModel.deviceCode" v-only-alphanumeric placeholder="如 WD-SCALE-0001" />
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="数据类型" required>
<a-select v-model:value="formModel.dataType" :options="DATA_TYPE_OPTIONS" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="状态" required>
<a-select v-model:value="formModel.status" :options="STATUS_OPTIONS" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="过滤规则(可选)">
<a-input v-model:value="formModel.filterRule" v-no-space placeholder="如 weight > 50,留空表示全量订阅" />
</a-form-item>
</a-form>
</a-modal>
</template>
<script setup lang="ts">
import { usePage } from './init/usePage'
import { DATA_TYPE_OPTIONS, STATUS_OPTIONS } from '../../../types'
defineEmits<{ (event: 'load'): void }>()
const { pageInfo, formModel, downstreamOptions, openAdd, openEdit, submit } = usePage()
defineExpose({ openAdd, openEdit })
</script>
@@ -0,0 +1,11 @@
import type { ApiResponse } from '@axios'
import { postRequest } from '@axios'
import type { FormModel } from '../types'
const BASE = '/admin/device/distribute/config/subscribe'
export const add = (params: FormModel): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', BASE + '/add', params)
export const update = (params: FormModel): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', BASE + '/update', params)
@@ -0,0 +1,74 @@
import { reactive, ref } from 'vue'
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
import * as api from '../api'
import type { DetailRecord, DownstreamOptions, FormModel, PageInfo } from '../types'
const createFormModel = (): FormModel => ({
id: undefined,
downstreamId: '',
deviceCode: '',
dataType: 'weight',
filterRule: '',
status: 'enabled',
})
export const usePage = () => {
const { message } = useAntdStaticMethods()
const pageInfo = reactive<PageInfo>({
visible: false,
title: '新增订阅映射',
width: 600,
spin: false,
})
const formModel = reactive<FormModel>(createFormModel())
const downstreamOptions = ref<DownstreamOptions>([])
const openAdd = (options: DownstreamOptions): void => {
Object.assign(formModel, createFormModel())
downstreamOptions.value = options
pageInfo.title = '新增订阅映射'
pageInfo.visible = true
}
const openEdit = (record: DetailRecord, options: DownstreamOptions): void => {
Object.assign(formModel, createFormModel(), {
id: record.id,
downstreamId: record.downstreamId,
deviceCode: record.deviceCode,
dataType: record.dataType,
filterRule: record.filterRule,
status: record.status,
})
downstreamOptions.value = options
pageInfo.title = '编辑订阅映射'
pageInfo.visible = true
}
const close = (): void => {
pageInfo.visible = false
}
const submit = (emit: (event: 'load') => void): void => {
pageInfo.spin = true
const params = { ...formModel }
const request = formModel.id ? api.update(params) : api.add({ ...params, id: undefined })
request
.then((res) => {
if (res.code === '00000') {
void message.success(res.msg)
emit('load')
close()
}
})
.catch((err: unknown) => {
console.error('提交失败:', err)
})
.finally(() => {
pageInfo.spin = false
})
}
return { pageInfo, formModel, downstreamOptions, openAdd, openEdit, close, submit }
}
@@ -0,0 +1,15 @@
import type { DownstreamOption } from '../../../../types'
import type { SubscribeForm, SubscribeItem } from '../../../../types'
export interface PageInfo {
visible: boolean
title: string
width: number
spin: boolean
}
export type FormModel = SubscribeForm
export type DetailRecord = SubscribeItem
export type DownstreamOptions = DownstreamOption[]
@@ -0,0 +1,161 @@
import { onMounted, reactive, ref } from 'vue'
import type { TablePaginationConfig } from 'ant-design-vue'
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
import { createPaginationConfig } from '@utils/antDesign/table'
import type { TableState } from '@utils/antDesign/table'
import AddOrEdit from '../component/modal/addOrEdit/addOrEdit.vue'
import * as api from '../api'
import type { DownstreamOption, SearchForm, SubscribeItem } from '../types'
import { createSearchKey } from './useSearch'
import { tableColumns } from './useTable'
export const usePage = () => {
const { message, Modal } = useAntdStaticMethods()
const search = reactive<SearchForm>(createSearchKey())
const table = reactive<TableState<SubscribeItem>>({
columns: tableColumns,
dataSource: [],
sort: { field: '', order: null },
pagination: createPaginationConfig() as TablePaginationConfig,
})
const pageLoading = ref<boolean>(false)
const downstreamOptions = ref<DownstreamOption[]>([])
const addOrEditRef = ref<InstanceType<typeof AddOrEdit> | null>(null)
const listRequest = (): void => {
pageLoading.value = true
api.list(search)
.then((res) => {
if (res.code === '00000') {
table.dataSource = res.data ?? []
table.pagination.total = table.dataSource.length
}
})
.catch((err: unknown) => {
console.error('订阅映射列表请求失败:', err)
})
.finally(() => {
pageLoading.value = false
})
}
/** 拉下游选项(首次进入时调用) */
const loadOptions = (): void => {
api.options()
.then((res) => {
if (res.code === '00000' && res.data?.downstream) {
downstreamOptions.value = res.data.downstream
}
})
.catch((err: unknown) => {
console.error('下游选项拉取失败:', err)
})
}
const searchQuery = (): void => {
table.pagination.current = 1
listRequest()
}
const resetQuery = (): void => {
Object.assign(search, createSearchKey())
table.pagination.current = 1
listRequest()
}
const dataSourceChange = (pagination: TablePaginationConfig): void => {
table.pagination.current = pagination.current ?? 1
table.pagination.pageSize = pagination.pageSize ?? 30
}
const openAdd = (): void => {
if (downstreamOptions.value.length === 0) {
void message.warning('请先在「下游系统」Tab 创建至少一条下游系统')
return
}
addOrEditRef.value?.openAdd(downstreamOptions.value)
}
const openEdit = (record: SubscribeItem): void => {
addOrEditRef.value?.openEdit(record, downstreamOptions.value)
}
const removeRecord = (record: SubscribeItem): void => {
Modal.confirm({
title: '确认删除?',
content: `将删除映射 [${record.id}],确认继续?`,
okText: '确认删除',
okType: 'danger',
cancelText: '取消',
onOk: () => {
api.remove(record.id)
.then((res) => {
if (res.code === '00000') {
void message.success(res.msg)
listRequest()
}
})
.catch((err: unknown) => {
console.error('删除失败:', err)
})
},
})
}
const toggleStatus = (record: SubscribeItem): void => {
const next = record.status === 'enabled' ? '禁用' : '启用'
Modal.confirm({
title: `确认${next}`,
content: `${next}映射 [${record.id}],确认继续?`,
okText: `确认${next}`,
okType: 'danger',
cancelText: '取消',
onOk: () => {
api.toggleStatus(record.id)
.then((res) => {
if (res.code === '00000') {
void message.success(res.msg)
listRequest()
}
})
.catch((err: unknown) => {
console.error('状态切换失败:', err)
})
},
})
}
const reload = (): void => {
listRequest()
}
const handleAction = (type: string, record?: SubscribeItem): void => {
if (!record) return
if (type === 'edit') openEdit(record)
if (type === 'delete') removeRecord(record)
if (type === 'toggle') toggleStatus(record)
}
onMounted(() => {
loadOptions()
listRequest()
})
return {
pageLoading,
search,
table,
downstreamOptions,
addOrEditRef,
searchQuery,
resetQuery,
dataSourceChange,
handleAction,
openAdd,
reload,
}
}
@@ -0,0 +1,12 @@
import type { SearchForm } from '../types'
export const createSearchKey = (): SearchForm => ({
downstreamId: undefined,
deviceCode: undefined,
dataType: undefined,
status: undefined,
})
export const useSearch = () => {
return { createSearchKey }
}
@@ -0,0 +1,31 @@
import type { TableColumnsType } from 'ant-design-vue'
import { h } from 'vue'
import { Tag } from 'ant-design-vue'
import type { DataType } from '../../../../../record/types'
import { dataTypeText } from '../../../../../record/types'
import type { Status } from '../../downstream/types'
import { statusColor, statusText } from '../../downstream/types'
export const tableColumns: TableColumnsType = [
{ title: '映射ID', dataIndex: 'id', align: 'left', fixed: 'left', width: 110 },
{ title: '下游系统', dataIndex: 'downstreamName', align: 'left', width: 160, resizable: true, ellipsis: true },
{ title: '设备编码', dataIndex: 'deviceCode', align: 'left', width: 140, resizable: true, ellipsis: true },
{ title: '设备名称', dataIndex: 'deviceName', align: 'left', width: 160, resizable: true, ellipsis: true },
{
title: '数据类型',
dataIndex: 'dataType',
align: 'center',
width: 100,
customRender: ({ value }: { value: DataType }) => h(Tag, { color: 'blue' }, () => dataTypeText(value)),
},
{ title: '过滤规则', dataIndex: 'filterRule', align: 'left', width: 220, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{
title: '状态',
dataIndex: 'status',
align: 'center',
width: 90,
customRender: ({ value }: { value: Status }) => h(Tag, { color: statusColor[value] }, () => statusText(value)),
},
{ title: '创建时间', dataIndex: 'createdAt', align: 'center', width: 170 },
{ title: '操作', dataIndex: 'action', align: 'center', fixed: 'right', width: 200 },
]
@@ -0,0 +1,76 @@
<template>
<div class="tab-list-page">
<FilterBar v-model="search" @search="searchQuery" @reset="resetQuery">
<a-form-item label="下游系统">
<a-select
v-model:value="search.downstreamId"
:options="downstreamOptions"
allow-clear
placeholder="全部下游"
style="width: 180px"
/>
</a-form-item>
<a-form-item label="设备编码">
<a-input v-model:value="search.deviceCode" v-no-space allow-clear placeholder="设备编码" style="width: 160px" />
</a-form-item>
<a-form-item label="数据类型">
<a-select v-model:value="search.dataType" :options="DATA_TYPE_OPTIONS" allow-clear placeholder="全部类型" style="width: 140px" />
</a-form-item>
<a-form-item label="状态">
<a-select v-model:value="search.status" :options="STATUS_OPTIONS" allow-clear placeholder="全部" style="width: 120px" />
</a-form-item>
</FilterBar>
<TableCard :table="table" :loading="pageLoading" row-key="id" @change="dataSourceChange">
<template #toolbar>
<a-button type="primary" @click="openAdd">
<template #icon><PlusOutlined /></template>
新增订阅映射
</a-button>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'action'">
<a-button type="link" size="small" @click="handleAction('edit', record)">编辑</a-button>
<a-divider type="vertical" />
<a-button type="link" size="small" @click="handleAction('toggle', record)">
{{ record.status === 'enabled' ? '禁用' : '启用' }}
</a-button>
<a-divider type="vertical" />
<a-button type="link" size="small" danger @click="handleAction('delete', record)">删除</a-button>
</template>
</template>
</TableCard>
<AddOrEdit ref="addOrEditRef" @load="reload" />
</div>
</template>
<script setup lang="ts">
import { PlusOutlined } from '@ant-design/icons-vue'
import AddOrEdit from './component/modal/addOrEdit/addOrEdit.vue'
import { DATA_TYPE_OPTIONS, STATUS_OPTIONS } from './types'
import { usePage } from './init/usePage'
const {
pageLoading,
search,
table,
downstreamOptions,
addOrEditRef,
searchQuery,
resetQuery,
dataSourceChange,
handleAction,
openAdd,
reload,
} = usePage()
</script>
<style scoped lang="less">
.tab-list-page {
display: flex;
flex-direction: column;
height: 100%;
gap: 12px;
}
</style>
@@ -0,0 +1,43 @@
import type { BaseSearchForm } from '@pages/admin-portal/shared/types'
import type { DataType } from '../../../../../record/types'
import type { Status } from '../../downstream/types'
/** 订阅映射列表项 */
export interface SubscribeItem {
id: string
downstreamId: string
downstreamName: string
deviceCode: string
deviceName: string
dataType: DataType
filterRule: string
status: Status
createdAt: string
}
/** 表单(新增/编辑) */
export interface SubscribeForm {
id?: string
downstreamId: string
deviceCode: string
dataType: DataType
filterRule: string
status: Status
}
/** 搜索表单 */
export interface SearchForm extends BaseSearchForm {
downstreamId?: string
deviceCode?: string
dataType?: DataType
status?: Status
}
/** 下游选项(用于搜索/表单的下拉) */
export interface DownstreamOption {
label: string
value: string
}
export { STATUS_OPTIONS, statusColor, statusText } from '../../downstream/types'
export { DATA_TYPE_OPTIONS, dataTypeText } from '../../../../../record/types'
@@ -0,0 +1,44 @@
<template>
<a-card class="listPageCard" :bordered="false">
<a-tabs class="aTabs" v-model:activeKey="activeKey">
<a-tab-pane key="downstream" tab="下游系统">
<Downstream />
</a-tab-pane>
<a-tab-pane key="subscribe" tab="订阅映射">
<Subscribe />
</a-tab-pane>
</a-tabs>
</a-card>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import Downstream from './component/tabs/downstream/downstream.vue'
import Subscribe from './component/tabs/subscribe/subscribe.vue'
const activeKey = ref<'downstream' | 'subscribe'>('downstream')
</script>
<style scoped lang="less">
@import "@assets/styles/listPage.less";
.aTabs {
width: 100%;
height: 100%;
:deep(.ant-tabs-tab) {
padding-top: 0;
}
:deep(.ant-tabs-content) {
width: 100%;
height: 100%;
}
:deep(.ant-tabs-tabpane) {
height: 100%;
display: flex;
flex-direction: column;
}
}
</style>
@@ -0,0 +1,34 @@
/**
* 失败重投 - 接口层
*/
import type { ApiResponse } from '@axios'
import { postRequest } from '@axios'
import type { DeadletterDetail, DeadletterItem, SearchForm, StatCards } from '../types'
/** 列表查询 */
export const list = (params: SearchForm): Promise<ApiResponse<DeadletterItem[]>> =>
postRequest('axiosRequest', '/admin/device/distribute/deadletter/list', params)
/** 详情查询 */
export const info = (id: string): Promise<ApiResponse<DeadletterDetail>> =>
postRequest('axiosRequest', '/admin/device/distribute/deadletter/info', { id })
/** 顶部统计卡 */
export const stat = (): Promise<ApiResponse<StatCards>> =>
postRequest('axiosRequest', '/admin/device/distribute/deadletter/stat', {})
/** 手动重投(单条) */
export const retry = (id: string): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/distribute/deadletter/retry', { id })
/** 批量重投 */
export const batchRetry = (ids: string[]): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/distribute/deadletter/batchRetry', { ids })
/** 永久丢弃(单条) */
export const discard = (id: string): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/distribute/deadletter/discard', { id })
/** 批量丢弃 */
export const batchDiscard = (ids: string[]): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/distribute/deadletter/batchDiscard', { ids })
@@ -0,0 +1,9 @@
/**
* 死信详情抽屉 - 接口
*/
import type { ApiResponse } from '@axios'
import { postRequest } from '@axios'
import type { DetailInfo } from '../types'
export const getInfo = (id: string): Promise<ApiResponse<DetailInfo>> =>
postRequest('axiosRequest', '/admin/device/distribute/deadletter/info', { id })
@@ -0,0 +1,99 @@
<template>
<a-drawer
v-model:open="pageInfo.visible"
:title="pageInfo.title"
:width="pageInfo.width"
:destroy-on-close="true"
>
<a-spin :spinning="pageInfo.spin">
<a-alert
v-if="detail"
type="info"
show-icon
:message="detail.strategy"
:description="detail.thresholdWarn"
class="detail-alert"
/>
<a-descriptions v-if="detail" :column="2" bordered size="small" class="detail-desc">
<a-descriptions-item label="死信ID">{{ detail.id ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="原分发记录">{{ detail.originalRecordId ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="设备编码">{{ detail.deviceCode ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="设备名称">{{ detail.deviceName ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="数据类型">{{ dataTypeText(detail.dataType) }}</a-descriptions-item>
<a-descriptions-item label="下游系统">{{ detail.downstreamName ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="首次失败时间">{{ detail.firstFailTime ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="最近重投时间">{{ detail.lastRetryTime ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="下次重投时间">{{ detail.nextRetryTime ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="重投次数">
<a-tag :color="detail.retryCount >= 4 ? 'red' : 'default'">{{ detail.retryCount }} / 5</a-tag>
</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="deadletterStatusColor[detail.status]">{{ deadletterStatusText(detail.status) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="鉴权密钥">{{ detail.authKey ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="失败原因" :span="2">{{ detail.failReason ?? '—' }}</a-descriptions-item>
</a-descriptions>
<a-card v-if="detail" title="请求体" size="small" :bordered="false" class="detail-body">
<pre>{{ formatJson(detail.requestBody) }}</pre>
</a-card>
<a-card v-if="detail" title="响应体" size="small" :bordered="false" class="detail-body">
<pre>{{ formatJson(detail.responseBody) }}</pre>
</a-card>
</a-spin>
</a-drawer>
</template>
<script setup lang="ts">
/**
* 死信详情抽屉
* - 字段 + 自动重投策略 + 请求体/响应体 JSON 美化展示
*/
import { usePage } from './init/usePage'
import {
dataTypeText,
deadletterStatusColor,
deadletterStatusText,
} from '../../../types'
const { pageInfo, detail, openDrawer, closeDrawer } = usePage()
/** 尝试美化 JSON 字符串;非 JSON 则原样返回 */
const formatJson = (raw: string | undefined): string => {
if (!raw) return '—'
try {
return JSON.stringify(JSON.parse(raw), null, 2)
} catch {
return raw
}
}
defineExpose({ openDrawer, closeDrawer })
</script>
<style scoped lang="less">
.detail-alert {
margin-bottom: 12px;
}
.detail-desc {
margin-top: 4px;
}
.detail-body {
margin-top: 12px;
pre {
margin: 0;
padding: 8px 12px;
background: var(--bg-secondary, #fafafa);
border-radius: 4px;
font-size: 12px;
max-height: 260px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
}
}
</style>
@@ -0,0 +1,43 @@
/**
* 死信详情抽屉 - hook
*/
import { reactive, ref } from 'vue'
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
import * as api from '../api'
import type { DetailInfo, OpenContext, PageInfo } from '../types'
export const usePage = () => {
const { message } = useAntdStaticMethods()
const pageInfo = reactive<PageInfo>({
visible: false,
title: '死信详情',
width: 720,
spin: false,
})
const detail = ref<DetailInfo | null>(null)
const openDrawer = (ctx: OpenContext) => {
pageInfo.title = ctx.title ?? '死信详情'
pageInfo.visible = true
pageInfo.spin = true
detail.value = null
api.getInfo(ctx.id)
.then((res) => {
if (res.code === '00000') {
detail.value = res.data
} else {
message.error(res.msg || '查询失败')
}
})
.catch(() => { message.error('查询失败') })
.finally(() => { pageInfo.spin = false })
}
const closeDrawer = () => {
pageInfo.visible = false
}
return { pageInfo, detail, openDrawer, closeDrawer }
}
@@ -0,0 +1,18 @@
/**
* 死信详情抽屉 - 类型
*/
import type { DeadletterDetail } from '../../../../types'
export interface PageInfo {
visible: boolean
title: string
width: number
spin: boolean
}
export interface OpenContext {
id: string
title?: string
}
export type DetailInfo = DeadletterDetail
@@ -0,0 +1,134 @@
<template>
<div class="admin-list-page">
<!-- 顶部统计卡 -->
<a-row :gutter="12" class="deadletter-stat">
<a-col :span="6">
<StatCard :value="statCards.pendingCount" label="待重投数" color="orange" />
</a-col>
<a-col :span="6">
<StatCard :value="statCards.retryingCount" label="重投中数" color="blue" />
</a-col>
<a-col :span="6">
<StatCard :value="statCards.recoveredCount" label="已恢复数" color="green" />
</a-col>
<a-col :span="6">
<StatCard :value="statCards.discardedCount" label="已丢弃数" color="gray" />
</a-col>
</a-row>
<!-- 策略说明条 -->
<a-alert
type="info"
show-icon
:message="RETRY_STRATEGY_TEXT"
description="死信累积超过 100 条自动告警到「设备运维 → 告警管理」。"
class="strategy-alert"
/>
<FilterBar v-model="search" @search="searchQuery" @reset="resetQuery">
<a-form-item label="设备编码">
<a-input v-model:value="search.deviceCode" v-no-space allow-clear placeholder="设备编码" style="width: 180px" />
</a-form-item>
<a-form-item label="数据类型">
<a-select
v-model:value="search.dataType"
:options="DATA_TYPE_OPTIONS"
allow-clear
placeholder="全部类型"
style="width: 140px"
/>
</a-form-item>
<a-form-item label="状态">
<a-select
v-model:value="search.status"
:options="DEADLETTER_STATUS_OPTIONS"
allow-clear
placeholder="全部状态"
style="width: 140px"
/>
</a-form-item>
</FilterBar>
<TableCard :table="table" :loading="pageLoading" row-key="id" :row-selection="rowSelection" @change="dataSourceChange">
<template #toolbar>
<a-button type="primary" :disabled="!selectedRowKeys.length" @click="batchRetry">
<template #icon><RedoOutlined /></template>
批量重投
</a-button>
<a-button danger :disabled="!selectedRowKeys.length" @click="batchDiscard">
<template #icon><DeleteOutlined /></template>
批量丢弃
</a-button>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'action'">
<a-button type="link" size="small" @click="handleAction('detail', record)">查看详情</a-button>
<a-divider type="vertical" />
<a-button
type="link"
size="small"
:disabled="record.status === 'recovered' || record.status === 'discarded'"
@click="handleAction('retry', record)"
>手动重投</a-button>
<a-divider type="vertical" />
<a-button
type="link"
size="small"
danger
:disabled="record.status === 'discarded'"
@click="handleAction('discard', record)"
>永久丢弃</a-button>
</template>
</template>
</TableCard>
<InfoDrawer ref="infoDrawerRef" />
</div>
</template>
<script setup lang="ts">
import { DeleteOutlined, RedoOutlined } from '@ant-design/icons-vue'
import StatCard from '@components/StatCard.vue'
import InfoDrawer from './component/drawer/info/info.vue'
import {
DATA_TYPE_OPTIONS,
DEADLETTER_STATUS_OPTIONS,
RETRY_STRATEGY_TEXT,
} from './types'
import { usePage } from './init/usePage'
const {
pageLoading,
search,
table,
selectedRowKeys,
rowSelection,
statCards,
infoDrawerRef,
searchQuery,
resetQuery,
dataSourceChange,
handleAction,
batchRetry,
batchDiscard,
} = usePage()
</script>
<style scoped lang="less">
@import "@assets/styles/listPage.less";
.admin-list-page {
display: flex;
flex-direction: column;
height: 100%;
gap: 12px;
}
.deadletter-stat {
margin: 0 !important;
}
.strategy-alert {
margin: 0 !important;
}
</style>
@@ -0,0 +1,252 @@
/**
* 失败重投 - 主 hook
* - 列表查询 / 分页 / 排序
* - 详情抽屉
* - 手动重投(单条/批量)
* - 永久丢弃(单条/批量,二次确认)
*/
import { onMounted, reactive, ref } from 'vue'
import type { TablePaginationConfig } from 'ant-design-vue'
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
import { createPaginationConfig } from '@utils/antDesign/table'
import type { TableState } from '@utils/antDesign/table'
import InfoDrawer from '../component/drawer/info/info.vue'
import { batchDiscard as batchDiscardApi, batchRetry as batchRetryApi, discard, list, retry, stat } from '../api'
import type { DeadletterItem, SearchForm, StatCards } from '../types'
import { createSearchKey } from './useSearch'
import { tableColumns } from './useTable'
/**
* 失败重投页主编排:
* - 顶部统计卡(待重投 / 重投中 / 已恢复 / 已丢弃)
* - 策略说明条(指数退避)
* - 标准列表 + 详情抽屉(请求体/响应体)
* - 手动重投 / 永久丢弃(单条/批量)
*/
export const usePage = () => {
const { message, Modal } = useAntdStaticMethods()
const search = reactive<SearchForm>(createSearchKey())
const table = reactive<TableState<DeadletterItem>>({
columns: tableColumns,
dataSource: [],
sort: { field: '', order: null },
pagination: createPaginationConfig() as TablePaginationConfig,
})
const pageLoading = ref<boolean>(false)
const selectedRowKeys = ref<(string | number)[]>([])
/** 顶部统计卡数据 */
const statCards = ref<StatCards>({
pendingCount: 0,
retryingCount: 0,
recoveredCount: 0,
discardedCount: 0,
})
/** 详情抽屉 ref */
const infoDrawerRef = ref<InstanceType<typeof InfoDrawer> | null>(null)
const listRequest = (): void => {
pageLoading.value = true
list(search)
.then((res) => {
if (res.code === '00000') {
table.dataSource = res.data ?? []
table.pagination.total = table.dataSource.length
}
})
.catch((err: unknown) => {
console.error('死信队列列表请求失败:', err)
})
.finally(() => {
pageLoading.value = false
})
}
const statRequest = (): void => {
stat()
.then((res) => {
if (res.code === '00000' && res.data) {
statCards.value = res.data
}
})
.catch((err: unknown) => {
console.error('死信统计请求失败:', err)
})
}
const searchQuery = (): void => {
table.pagination.current = 1
listRequest()
}
const resetQuery = (): void => {
Object.assign(search, createSearchKey())
table.pagination.current = 1
listRequest()
}
const dataSourceChange = (pagination: TablePaginationConfig): void => {
table.pagination.current = pagination.current ?? 1
table.pagination.pageSize = pagination.pageSize ?? 30
}
const rowSelection = {
selectedRowKeys,
onChange: (keys: (string | number)[]): void => {
selectedRowKeys.value = keys
},
}
/** 打开详情抽屉 */
const openInfoDrawer = (record: DeadletterItem): void => {
infoDrawerRef.value?.openDrawer({ id: record.id, title: '死信详情' })
}
/** 手动重投(单条) */
const retryRecord = (record: DeadletterItem): void => {
Modal.confirm({
title: '确认重投?',
content: `死信 [${record.id}] 将立即触发一次手动重投,确认继续?`,
okText: '确认重投',
okType: 'danger',
cancelText: '取消',
onOk: () => {
retry(record.id)
.then((res) => {
if (res.code === '00000') {
void message.success(res.msg)
listRequest()
statRequest()
}
})
.catch((err: unknown) => {
console.error('重投失败:', err)
})
},
})
}
/** 永久丢弃(单条) */
const discardRecord = (record: DeadletterItem): void => {
Modal.confirm({
title: '永久丢弃?',
content: `丢弃后死信 [${record.id}] 将不再自动重投,且无法恢复,确认继续?`,
okText: '确认丢弃',
okType: 'danger',
cancelText: '取消',
onOk: () => {
discard(record.id)
.then((res) => {
if (res.code === '00000') {
void message.success(res.msg)
listRequest()
statRequest()
}
})
.catch((err: unknown) => {
console.error('丢弃失败:', err)
})
},
})
}
/** 批量重投 */
const batchRetry = (): void => {
if (selectedRowKeys.value.length === 0) {
void message.warning('请先勾选要重投的死信')
return
}
const ids = selectedRowKeys.value.map((key) => String(key))
Modal.confirm({
title: '确认批量重投?',
content: `将立即重投 ${ids.length} 条死信,确认继续?`,
okText: '确认重投',
okType: 'danger',
cancelText: '取消',
onOk: () => {
batchRetryApi(ids)
.then((res) => {
if (res.code === '00000') {
void message.success(res.msg)
selectedRowKeys.value = []
listRequest()
statRequest()
}
})
.catch((err: unknown) => {
console.error('批量重投失败:', err)
})
},
})
}
/** 批量丢弃 */
const batchDiscard = (): void => {
if (selectedRowKeys.value.length === 0) {
void message.warning('请先勾选要丢弃的死信')
return
}
const ids = selectedRowKeys.value.map((key) => String(key))
Modal.confirm({
title: '确认批量丢弃?',
content: `将永久丢弃 ${ids.length} 条死信,此操作不可恢复,确认继续?`,
okText: '确认丢弃',
okType: 'danger',
cancelText: '取消',
onOk: () => {
batchDiscardApi(ids)
.then((res) => {
if (res.code === '00000') {
void message.success(res.msg)
selectedRowKeys.value = []
listRequest()
statRequest()
}
})
.catch((err: unknown) => {
console.error('批量丢弃失败:', err)
})
},
})
}
const handleAction = (type: string, record?: DeadletterItem): void => {
if (type === 'detail' && record) {
openInfoDrawer(record)
return
}
if (type === 'retry' && record) {
retryRecord(record)
return
}
if (type === 'discard' && record) {
discardRecord(record)
}
}
onMounted(() => {
listRequest()
statRequest()
})
return {
pageLoading,
search,
table,
selectedRowKeys,
rowSelection,
statCards,
infoDrawerRef,
searchQuery,
resetQuery,
dataSourceChange,
handleAction,
listRequest,
batchRetry,
batchDiscard,
}
}
@@ -0,0 +1,13 @@
import type { SearchForm } from '../types'
/** 搜索表单工厂:统一重置入口 */
export const createSearchKey = (): SearchForm => ({
deviceCode: undefined,
downstreamId: undefined,
dataType: undefined,
status: undefined,
})
export const useSearch = () => {
return { createSearchKey }
}
@@ -0,0 +1,46 @@
import type { TableColumnsType } from 'ant-design-vue'
import { h } from 'vue'
import { Tag } from 'ant-design-vue'
import type { DataType } from '../../record/types'
import { dataTypeText } from '../../record/types'
import type { DeadletterStatus } from '../types'
import { deadletterStatusColor, deadletterStatusText } from '../types'
/** 死信队列列定义 */
export const tableColumns: TableColumnsType = [
{ title: '死信ID', dataIndex: 'id', align: 'left', fixed: 'left', width: 110 },
{ title: '原分发记录', dataIndex: 'originalRecordId', align: 'left', width: 120, ellipsis: true },
{ title: '设备编码', dataIndex: 'deviceCode', align: 'left', width: 140, resizable: true, ellipsis: true },
{ title: '设备名称', dataIndex: 'deviceName', align: 'left', width: 150, resizable: true, ellipsis: true },
{
title: '数据类型',
dataIndex: 'dataType',
align: 'center',
width: 100,
customRender: ({ value }: { value: DataType }) => h(Tag, { color: 'blue' }, () => dataTypeText(value)),
},
{ title: '下游系统', dataIndex: 'downstreamName', align: 'left', width: 140, resizable: true, ellipsis: true },
{ title: '首次失败', dataIndex: 'firstFailTime', align: 'center', width: 170, sorter: true },
{ title: '最近重投', dataIndex: 'lastRetryTime', align: 'center', width: 170, customRender: ({ value }: { value: string | null }) => value ?? '—' },
{
title: '重投次数',
dataIndex: 'retryCount',
align: 'center',
width: 90,
customRender: ({ value }: { value: number }) => {
const color = value >= 4 ? 'red' : value >= 2 ? 'orange' : 'default'
return h(Tag, { color }, () => `${value} / 5`)
},
},
{ title: '下次重投', dataIndex: 'nextRetryTime', align: 'center', width: 170, customRender: ({ value }: { value: string | null }) => value ?? '—' },
{ title: '失败原因', dataIndex: 'failReason', align: 'left', width: 200, resizable: true, ellipsis: true },
{
title: '状态',
dataIndex: 'status',
align: 'center',
width: 100,
customRender: ({ value }: { value: DeadletterStatus }) =>
h(Tag, { color: deadletterStatusColor[value] }, () => deadletterStatusText(value)),
},
{ title: '操作', dataIndex: 'action', align: 'center', fixed: 'right', width: 240 },
]
@@ -0,0 +1,86 @@
/**
* 失败重投 - 死信队列类型定义
* - 记录分发失败进入死信队列的样本
* - 自动重投策略:指数退避(1min / 5min / 30min / 2h / 12h),最多 5 次后转人工
* - 状态机:pending(待重投)→ retrying(重投中)→ recovered(已恢复)/ discarded(已丢弃)
*/
import type { BaseSearchForm } from '@pages/admin-portal/shared/types'
import type { DataType } from '../../record/types'
/** 死信状态 */
export type DeadletterStatus = 'pending' | 'retrying' | 'recovered' | 'discarded'
/** 死信记录(列表项) */
export interface DeadletterItem {
id: string
originalRecordId: string
deviceCode: string
deviceName: string
dataType: DataType
downstreamId: string
downstreamName: string
firstFailTime: string
lastRetryTime: string | null
retryCount: number
nextRetryTime: string | null
failReason: string
status: DeadletterStatus
}
/** 死信详情(含请求体/响应体) */
export interface DeadletterDetail extends DeadletterItem {
requestBody: string
responseBody: string
authKey: string
strategy: string
thresholdWarn: string
}
/** 顶部统计卡 */
export interface StatCards {
pendingCount: number
retryingCount: number
recoveredCount: number
discardedCount: number
}
/** 搜索表单 */
export interface SearchForm extends BaseSearchForm {
deviceCode?: string
dataType?: DataType
downstreamId?: string
status?: DeadletterStatus
}
/** 死信状态选项 */
export const DEADLETTER_STATUS_OPTIONS = [
{ label: '待重投', value: 'pending' },
{ label: '重投中', value: 'retrying' },
{ label: '已恢复', value: 'recovered' },
{ label: '已丢弃', value: 'discarded' },
] as const
/** 复用 record 的数据类型选项 + 文本字典 */
export { DATA_TYPE_OPTIONS, dataTypeText } from '../../record/types'
/** 死信状态 → Tag 颜色 */
export const deadletterStatusColor: Record<DeadletterStatus, string> = {
pending: 'warning',
retrying: 'processing',
recovered: 'success',
discarded: 'default',
}
/** 死信状态 → 中文文本 */
export const deadletterStatusText = (status: DeadletterStatus): string => {
const map: Record<DeadletterStatus, string> = {
pending: '待重投',
retrying: '重投中',
recovered: '已恢复',
discarded: '已丢弃',
}
return map[status] ?? '—'
}
/** 指数退避策略说明(展示用) */
export const RETRY_STRATEGY_TEXT = '指数退避:1min → 5min → 30min → 2h → 12h,最多 5 次后转人工'
@@ -0,0 +1,31 @@
/**
* 分发记录 - 接口层
*/
import type { ApiResponse } from '@axios'
import { postRequest } from '@axios'
import type { DetailInfo, RecordItem, SearchForm, StatCards } from '../types'
/** 列表查询 */
export const list = (params: SearchForm): Promise<ApiResponse<RecordItem[]>> =>
postRequest('axiosRequest', '/admin/device/distribute/record/list', params)
/** 详情查询 */
export const info = (id: string): Promise<ApiResponse<DetailInfo>> =>
postRequest('axiosRequest', '/admin/device/distribute/record/info', { id })
/** 顶部统计卡 */
export const stat = (): Promise<ApiResponse<StatCards>> =>
postRequest('axiosRequest', '/admin/device/distribute/record/stat', {})
/** 手动重投(单条) */
export const retry = (id: string): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/distribute/record/retry', { id })
/** 批量重投 */
export const batchRetry = (ids: string[]): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/distribute/record/batchRetry', { ids })
/** 导出 */
export const exportList = (params: SearchForm): Promise<ApiResponse<{ url: string }>> =>
postRequest('axiosRequest', '/admin/device/distribute/record/export', params)
@@ -0,0 +1,11 @@
/**
* 分发记录 - 详情抽屉 - 接口层
*/
import type { ApiResponse } from '@axios'
import { postRequest } from '@axios'
import type { DetailInfo } from '../../../../types'
/** 详情查询(含请求体/响应体) */
export const getInfo = (id: string): Promise<ApiResponse<DetailInfo>> =>
postRequest('axiosRequest', '/admin/device/distribute/record/info', { id })

Some files were not shown because too many files have changed in this diff Show More