From 74d1488d3b12bfc74ae288d5d39d74f6b656bfde Mon Sep 17 00:00:00 2001 From: fengpu <18691763612@163.com> Date: Fri, 3 Jul 2026 14:53:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=AE=BE=E5=A4=87=E4=B8=AD=E5=BF=83?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E5=85=A8=E9=87=8F=E8=90=BD=E5=9C=B0=20+=20?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E9=A1=B5=E5=90=8C=E7=BA=A7=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E8=A7=84=E8=8C=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 设备管理(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 --- AGENTS.md | 418 ++++++++ CLAUDE.md | 27 +- src/axios/handlers/admin/dict.ts | 34 + src/axios/handlers/admin/system.ts | 175 ++++ .../handlers/device-center/asset/category.ts | 307 ++++++ .../handlers/device-center/asset/inbound.ts | 167 ++++ .../handlers/device-center/asset/instock.ts | 118 +++ .../handlers/device-center/asset/manage.ts | 342 +++++++ .../handlers/device-center/asset/outbound.ts | 155 +++ .../device-center/asset/statistics.ts | 246 +++++ .../handlers/device-center/asset/supplier.ts | 123 +++ .../handlers/device-center/asset/workOrder.ts | 102 ++ .../device-center/distribute/config.ts | 325 +++++++ .../device-center/distribute/deadletter.ts | 254 +++++ .../device-center/distribute/record.ts | 211 ++++ src/axios/handlers/device-center/ops/alarm.ts | 228 +++++ .../handlers/device-center/ops/config.ts | 203 ++++ src/axios/handlers/device-center/ops/init.ts | 287 ++++++ .../handlers/device-center/ops/monitor.ts | 228 +++++ .../handlers/device-center/ops/version.ts | 191 ++++ .../handlers/device-center/tenantSite/site.ts | 210 ++++ .../device-center/workbench/overview.ts | 66 ++ src/axios/handlers/index.ts | 40 + src/composables/usePageHistory.ts | 10 +- src/composables/useVisibility.ts | 8 + src/docs/device/设备管理服务器功能现状.md | 902 ++++++++++++++++++ src/docs/list-page-spec.md | 55 ++ src/docs/tabs-page-spec.md | 12 + src/layouts/MiniprogramLayout.vue | 78 +- src/layouts/components/MenuTreeNode.vue | 57 ++ src/layouts/components/Sidebar.vue | 103 +- src/meta/nav.ts | 456 ++++++++- .../admin-portal/device-center/Overview.vue | 12 - .../device-center/asset/category/api/index.ts | 19 + .../device-center/asset/category/category.vue | 157 +++ .../component/modal/addOrEdit/addOrEdit.vue | 209 ++++ .../component/modal/addOrEdit/api/index.ts | 21 + .../component/modal/addOrEdit/init/usePage.ts | 246 +++++ .../component/modal/addOrEdit/types/index.ts | 60 ++ .../asset/category/init/usePage.ts | 225 +++++ .../asset/category/init/useSearch.ts | 6 + .../asset/category/init/useTable.ts | 72 ++ .../asset/category/types/index.ts | 59 ++ .../device-center/asset/manage/api/index.ts | 15 + .../manage/component/drawer/info/api/index.ts | 11 + .../manage/component/drawer/info/info.vue | 218 +++++ .../component/drawer/info/init/usePage.ts | 85 ++ .../component/drawer/info/types/index.ts | 70 ++ .../component/modal/addOrEdit/addOrEdit.vue | 313 ++++++ .../component/modal/addOrEdit/api/index.ts | 21 + .../component/modal/addOrEdit/init/usePage.ts | 302 ++++++ .../component/modal/addOrEdit/types/index.ts | 124 +++ .../asset/manage/init/usePage.ts | 219 +++++ .../asset/manage/init/useSearch.ts | 24 + .../asset/manage/init/useTable.ts | 136 +++ .../device-center/asset/manage/manage.vue | 126 +++ .../device-center/asset/manage/types/index.ts | 64 ++ .../asset/statistics/api/index.ts | 15 + .../asset/statistics/init/usePage.ts | 154 +++ .../asset/statistics/init/useSearch.ts | 49 + .../asset/statistics/init/useTable.ts | 92 ++ .../asset/statistics/statistics.vue | 198 ++++ .../asset/statistics/types/index.ts | 69 ++ .../component/tabs/downstream/api/index.ts | 32 + .../component/drawer/info/api/index.ts | 8 + .../downstream/component/drawer/info/info.vue | 53 + .../component/drawer/info/init/usePage.ts | 44 + .../component/drawer/info/types/index.ts | 15 + .../component/modal/addOrEdit/addOrEdit.vue | 108 +++ .../component/modal/addOrEdit/api/index.ts | 11 + .../component/modal/addOrEdit/init/usePage.ts | 85 ++ .../component/modal/addOrEdit/types/index.ts | 12 + .../component/tabs/downstream/downstream.vue | 78 ++ .../component/tabs/downstream/init/usePage.ts | 168 ++++ .../tabs/downstream/init/useSearch.ts | 13 + .../tabs/downstream/init/useTable.ts | 59 ++ .../component/tabs/downstream/types/index.ts | 110 +++ .../component/tabs/subscribe/api/index.ts | 29 + .../component/modal/addOrEdit/addOrEdit.vue | 52 + .../component/modal/addOrEdit/api/index.ts | 11 + .../component/modal/addOrEdit/init/usePage.ts | 74 ++ .../component/modal/addOrEdit/types/index.ts | 15 + .../component/tabs/subscribe/init/usePage.ts | 161 ++++ .../tabs/subscribe/init/useSearch.ts | 12 + .../component/tabs/subscribe/init/useTable.ts | 31 + .../component/tabs/subscribe/subscribe.vue | 76 ++ .../component/tabs/subscribe/types/index.ts | 43 + .../distribute/config/config.vue | 44 + .../distribute/deadletter/api/index.ts | 34 + .../component/drawer/info/api/index.ts | 9 + .../deadletter/component/drawer/info/info.vue | 99 ++ .../component/drawer/info/init/usePage.ts | 43 + .../component/drawer/info/types/index.ts | 18 + .../distribute/deadletter/deadletter.vue | 134 +++ .../distribute/deadletter/init/usePage.ts | 252 +++++ .../distribute/deadletter/init/useSearch.ts | 13 + .../distribute/deadletter/init/useTable.ts | 46 + .../distribute/deadletter/types/index.ts | 86 ++ .../distribute/record/api/index.ts | 31 + .../record/component/drawer/info/api/index.ts | 11 + .../record/component/drawer/info/info.vue | 76 ++ .../component/drawer/info/init/usePage.ts | 48 + .../component/drawer/info/types/index.ts | 13 + .../distribute/record/init/usePage.ts | 197 ++++ .../distribute/record/init/useSearch.ts | 9 + .../distribute/record/init/useTable.ts | 61 ++ .../distribute/record/record.vue | 104 ++ .../distribute/record/types/index.ts | 101 ++ .../device-center/ops/alarm/alarm.vue | 150 +++ .../device-center/ops/alarm/api/index.ts | 33 + .../device-center/ops/alarm/init/usePage.ts | 245 +++++ .../device-center/ops/alarm/init/useSearch.ts | 16 + .../device-center/ops/alarm/init/useTable.ts | 69 ++ .../device-center/ops/alarm/types/index.ts | 153 +++ .../device-center/ops/config/api/index.ts | 31 + .../component/drawer/history/api/index.ts | 11 + .../component/drawer/history/history.vue | 112 +++ .../component/drawer/history/init/usePage.ts | 55 ++ .../component/drawer/history/types/index.ts | 18 + .../config/component/modal/edit/api/index.ts | 19 + .../ops/config/component/modal/edit/edit.vue | 63 ++ .../component/modal/edit/init/usePage.ts | 122 +++ .../component/modal/edit/types/index.ts | 27 + .../device-center/ops/config/config.vue | 82 ++ .../device-center/ops/config/init/usePage.ts | 95 ++ .../ops/config/init/useSearch.ts | 8 + .../device-center/ops/config/init/useTable.ts | 31 + .../device-center/ops/config/types/index.ts | 81 ++ .../device-center/ops/init/api/index.ts | 31 + .../init/component/drawer/info/api/index.ts | 10 + .../ops/init/component/drawer/info/info.vue | 139 +++ .../component/drawer/info/init/usePage.ts | 52 + .../init/component/drawer/info/types/index.ts | 29 + .../component/modal/addOrEdit/addOrEdit.vue | 171 ++++ .../component/modal/addOrEdit/api/index.ts | 18 + .../component/modal/addOrEdit/init/usePage.ts | 162 ++++ .../component/modal/addOrEdit/types/index.ts | 50 + .../device-center/ops/init/init.vue | 141 +++ .../device-center/ops/init/init/usePage.ts | 173 ++++ .../device-center/ops/init/init/useSearch.ts | 10 + .../device-center/ops/init/init/useTable.ts | 36 + .../device-center/ops/init/types/index.ts | 113 +++ .../device-center/ops/monitor/api/index.ts | 25 + .../component/drawer/info/api/index.ts | 9 + .../monitor/component/drawer/info/info.vue | 292 ++++++ .../component/drawer/info/init/usePage.ts | 67 ++ .../component/drawer/info/types/index.ts | 19 + .../device-center/ops/monitor/init/usePage.ts | 132 +++ .../ops/monitor/init/useSearch.ts | 12 + .../ops/monitor/init/useTable.ts | 58 ++ .../device-center/ops/monitor/monitor.vue | 112 +++ .../device-center/ops/monitor/types/index.ts | 179 ++++ .../device-center/ops/version/api/index.ts | 27 + .../component/drawer/info/api/index.ts | 6 + .../version/component/drawer/info/info.vue | 82 ++ .../component/drawer/info/init/usePage.ts | 49 + .../component/drawer/info/types/index.ts | 18 + .../component/modal/addOrEdit/addOrEdit.vue | 84 ++ .../component/modal/addOrEdit/api/index.ts | 17 + .../component/modal/addOrEdit/init/usePage.ts | 143 +++ .../component/modal/addOrEdit/types/index.ts | 37 + .../device-center/ops/version/init/usePage.ts | 161 ++++ .../ops/version/init/useSearch.ts | 9 + .../ops/version/init/useTable.ts | 55 ++ .../device-center/ops/version/types/index.ts | 98 ++ .../device-center/ops/version/version.vue | 80 ++ .../device-center/ops/workOrder/api/index.ts | 13 + .../ops/workOrder/init/usePage.ts | 60 ++ .../ops/workOrder/init/useSearch.ts | 7 + .../ops/workOrder/init/useTable.ts | 72 ++ .../ops/workOrder/types/index.ts | 68 ++ .../device-center/ops/workOrder/workOrder.vue | 81 ++ .../device-center/stock/inbound/api/index.ts | 27 + .../component/modal/addOrEdit/addOrEdit.vue | 173 ++++ .../component/modal/addOrEdit/api/index.ts | 19 + .../component/modal/addOrEdit/init/usePage.ts | 150 +++ .../component/modal/addOrEdit/types/index.ts | 57 ++ .../device-center/stock/inbound/inbound.vue | 79 ++ .../stock/inbound/init/usePage.ts | 137 +++ .../stock/inbound/init/useSearch.ts | 7 + .../stock/inbound/init/useTable.ts | 47 + .../stock/inbound/types/index.ts | 92 ++ .../device-center/stock/instock/api/index.ts | 17 + .../component/drawer/info/api/index.ts | 10 + .../instock/component/drawer/info/info.vue | 61 ++ .../component/drawer/info/init/usePage.ts | 52 + .../component/drawer/info/types/index.ts | 19 + .../component/modal/returnGoods/api/index.ts | 10 + .../modal/returnGoods/init/usePage.ts | 92 ++ .../modal/returnGoods/returnGoods.vue | 81 ++ .../modal/returnGoods/types/index.ts | 35 + .../stock/instock/init/usePage.ts | 120 +++ .../stock/instock/init/useSearch.ts | 7 + .../stock/instock/init/useTable.ts | 34 + .../device-center/stock/instock/instock.vue | 78 ++ .../stock/instock/types/index.ts | 83 ++ .../device-center/stock/outbound/api/index.ts | 17 + .../component/modal/addOrEdit/addOrEdit.vue | 163 ++++ .../component/modal/addOrEdit/api/index.ts | 19 + .../component/modal/addOrEdit/init/usePage.ts | 148 +++ .../component/modal/addOrEdit/types/index.ts | 53 + .../stock/outbound/init/usePage.ts | 165 ++++ .../stock/outbound/init/useSearch.ts | 7 + .../stock/outbound/init/useTable.ts | 47 + .../device-center/stock/outbound/outbound.vue | 81 ++ .../stock/outbound/types/index.ts | 85 ++ .../device-center/stock/supplier/api/index.ts | 9 + .../component/modal/addOrEdit/addOrEdit.vue | 167 ++++ .../component/modal/addOrEdit/api/index.ts | 21 + .../component/modal/addOrEdit/init/usePage.ts | 170 ++++ .../component/modal/addOrEdit/types/index.ts | 50 + .../stock/supplier/init/usePage.ts | 130 +++ .../stock/supplier/init/useSearch.ts | 6 + .../stock/supplier/init/useTable.ts | 34 + .../device-center/stock/supplier/supplier.vue | 65 ++ .../stock/supplier/types/index.ts | 46 + .../tenantSite/site/api/index.ts | 27 + .../site/component/drawer/info/api/index.ts | 11 + .../site/component/drawer/info/info.vue | 50 + .../component/drawer/info/init/usePage.ts | 48 + .../site/component/drawer/info/types/index.ts | 48 + .../component/modal/addOrEdit/addOrEdit.vue | 172 ++++ .../component/modal/addOrEdit/api/index.ts | 19 + .../component/modal/addOrEdit/init/usePage.ts | 144 +++ .../component/modal/addOrEdit/types/index.ts | 50 + .../tenantSite/site/init/usePage.ts | 170 ++++ .../tenantSite/site/init/useSearch.ts | 9 + .../tenantSite/site/init/useTable.ts | 65 ++ .../device-center/tenantSite/site/site.vue | 107 +++ .../tenantSite/site/types/index.ts | 61 ++ .../tenantSite/tenant/api/index.ts | 7 + .../tenantSite/tenant/init/usePage.ts | 73 ++ .../tenantSite/tenant/init/useSearch.ts | 7 + .../tenantSite/tenant/init/useTable.ts | 24 + .../tenantSite/tenant/tenant.vue | 54 ++ .../tenantSite/tenant/types/index.ts | 31 + .../workbench/overview/api/index.ts | 7 + .../component/DonutChart/DonutChart.vue | 126 +++ .../workbench/overview/init/usePage.ts | 49 + .../workbench/overview/init/useSearch.ts | 5 + .../workbench/overview/init/useTable.ts | 20 + .../workbench/overview/overview.vue | 476 +++++++++ .../workbench/overview/types/index.ts | 73 ++ .../operation-center/Overview.vue | 12 - .../tenantManagement/tenantList/api/index.ts | 15 + .../tenantList/init/usePage.ts | 19 + .../tenantList/init/useSearch.ts | 5 + .../tenantList/init/useTable.ts | 14 + .../tenantList/tenantList.vue | 64 ++ .../tenantList/types/index.ts | 15 + .../admin-portal/shared/SimpleActionModal.vue | 57 ++ src/pages/admin-portal/shared/types.ts | 58 ++ src/pages/admin-portal/shared/useAdminPage.ts | 209 ++++ .../admin-portal/shared/useAdminSearch.ts | 19 + .../admin-portal/shared/useAdminTable.ts | 44 + .../admin-portal/system-settings/Overview.vue | 12 - .../applicationManagement/api/index.ts | 12 + .../applicationManagement.vue | 68 ++ .../applicationManagement/init/usePage.ts | 18 + .../applicationManagement/init/useSearch.ts | 5 + .../applicationManagement/init/useTable.ts | 29 + .../applicationManagement/types/index.ts | 16 + .../appService/menuManagement/api/index.ts | 12 + .../appService/menuManagement/init/usePage.ts | 18 + .../menuManagement/init/useSearch.ts | 5 + .../menuManagement/init/useTable.ts | 16 + .../menuManagement/menuManagement.vue | 68 ++ .../appService/menuManagement/types/index.ts | 19 + .../dataDictionary/api/index.ts | 9 + .../dataDictionary/dataDictionary.vue | 64 ++ .../dataDictionary/init/usePage.ts | 17 + .../dataDictionary/init/useSearch.ts | 5 + .../dataDictionary/init/useTable.ts | 11 + .../dataDictionary/types/index.ts | 14 + .../employeeManagement/api/index.ts | 15 + .../employeeManagement/employeeManagement.vue | 63 ++ .../employeeManagement/init/usePage.ts | 19 + .../employeeManagement/init/useSearch.ts | 5 + .../employeeManagement/init/useTable.ts | 13 + .../employeeManagement/types/index.ts | 16 + .../permission/roleManagement/api/index.ts | 12 + .../permission/roleManagement/init/usePage.ts | 18 + .../roleManagement/init/useSearch.ts | 5 + .../roleManagement/init/useTable.ts | 11 + .../roleManagement/roleManagement.vue | 68 ++ .../permission/roleManagement/types/index.ts | 14 + .../user/miniProgramUser/api/index.ts | 12 + .../user/miniProgramUser/init/usePage.ts | 17 + .../user/miniProgramUser/init/useSearch.ts | 5 + .../user/miniProgramUser/init/useTable.ts | 20 + .../user/miniProgramUser/miniProgramUser.vue | 53 + .../user/miniProgramUser/types/index.ts | 16 + src/pages/console/VisibilityConsole.vue | 10 +- src/pages/home/Home.vue | 82 +- .../miniprogram/{ => customer}/ask/Home.vue | 0 .../miniprogram/{ => customer}/eat/Home.vue | 0 .../miniprogram/{ => customer}/make/Home.vue | 0 .../miniprogram/{ => customer}/me/Home.vue | 0 src/pages/miniprogram/ops/inspect/Home.vue | 12 + src/pages/miniprogram/ops/me/Home.vue | 12 + src/pages/miniprogram/ops/scan/Home.vue | 12 + src/pages/miniprogram/ops/workOrder/Home.vue | 12 + src/pages/miniprogram/ops/workbench/Home.vue | 12 + src/pages/overview/DesignOverview.vue | 32 +- src/router/routes.ts | 58 +- 305 files changed, 23157 insertions(+), 205 deletions(-) create mode 100644 AGENTS.md create mode 100644 src/axios/handlers/admin/dict.ts create mode 100644 src/axios/handlers/admin/system.ts create mode 100644 src/axios/handlers/device-center/asset/category.ts create mode 100644 src/axios/handlers/device-center/asset/inbound.ts create mode 100644 src/axios/handlers/device-center/asset/instock.ts create mode 100644 src/axios/handlers/device-center/asset/manage.ts create mode 100644 src/axios/handlers/device-center/asset/outbound.ts create mode 100644 src/axios/handlers/device-center/asset/statistics.ts create mode 100644 src/axios/handlers/device-center/asset/supplier.ts create mode 100644 src/axios/handlers/device-center/asset/workOrder.ts create mode 100644 src/axios/handlers/device-center/distribute/config.ts create mode 100644 src/axios/handlers/device-center/distribute/deadletter.ts create mode 100644 src/axios/handlers/device-center/distribute/record.ts create mode 100644 src/axios/handlers/device-center/ops/alarm.ts create mode 100644 src/axios/handlers/device-center/ops/config.ts create mode 100644 src/axios/handlers/device-center/ops/init.ts create mode 100644 src/axios/handlers/device-center/ops/monitor.ts create mode 100644 src/axios/handlers/device-center/ops/version.ts create mode 100644 src/axios/handlers/device-center/tenantSite/site.ts create mode 100644 src/axios/handlers/device-center/workbench/overview.ts create mode 100644 src/docs/device/设备管理服务器功能现状.md create mode 100644 src/layouts/components/MenuTreeNode.vue delete mode 100644 src/pages/admin-portal/device-center/Overview.vue create mode 100644 src/pages/admin-portal/device-center/asset/category/api/index.ts create mode 100644 src/pages/admin-portal/device-center/asset/category/category.vue create mode 100644 src/pages/admin-portal/device-center/asset/category/component/modal/addOrEdit/addOrEdit.vue create mode 100644 src/pages/admin-portal/device-center/asset/category/component/modal/addOrEdit/api/index.ts create mode 100644 src/pages/admin-portal/device-center/asset/category/component/modal/addOrEdit/init/usePage.ts create mode 100644 src/pages/admin-portal/device-center/asset/category/component/modal/addOrEdit/types/index.ts create mode 100644 src/pages/admin-portal/device-center/asset/category/init/usePage.ts create mode 100644 src/pages/admin-portal/device-center/asset/category/init/useSearch.ts create mode 100644 src/pages/admin-portal/device-center/asset/category/init/useTable.ts create mode 100644 src/pages/admin-portal/device-center/asset/category/types/index.ts create mode 100644 src/pages/admin-portal/device-center/asset/manage/api/index.ts create mode 100644 src/pages/admin-portal/device-center/asset/manage/component/drawer/info/api/index.ts create mode 100644 src/pages/admin-portal/device-center/asset/manage/component/drawer/info/info.vue create mode 100644 src/pages/admin-portal/device-center/asset/manage/component/drawer/info/init/usePage.ts create mode 100644 src/pages/admin-portal/device-center/asset/manage/component/drawer/info/types/index.ts create mode 100644 src/pages/admin-portal/device-center/asset/manage/component/modal/addOrEdit/addOrEdit.vue create mode 100644 src/pages/admin-portal/device-center/asset/manage/component/modal/addOrEdit/api/index.ts create mode 100644 src/pages/admin-portal/device-center/asset/manage/component/modal/addOrEdit/init/usePage.ts create mode 100644 src/pages/admin-portal/device-center/asset/manage/component/modal/addOrEdit/types/index.ts create mode 100644 src/pages/admin-portal/device-center/asset/manage/init/usePage.ts create mode 100644 src/pages/admin-portal/device-center/asset/manage/init/useSearch.ts create mode 100644 src/pages/admin-portal/device-center/asset/manage/init/useTable.ts create mode 100644 src/pages/admin-portal/device-center/asset/manage/manage.vue create mode 100644 src/pages/admin-portal/device-center/asset/manage/types/index.ts create mode 100644 src/pages/admin-portal/device-center/asset/statistics/api/index.ts create mode 100644 src/pages/admin-portal/device-center/asset/statistics/init/usePage.ts create mode 100644 src/pages/admin-portal/device-center/asset/statistics/init/useSearch.ts create mode 100644 src/pages/admin-portal/device-center/asset/statistics/init/useTable.ts create mode 100644 src/pages/admin-portal/device-center/asset/statistics/statistics.vue create mode 100644 src/pages/admin-portal/device-center/asset/statistics/types/index.ts create mode 100644 src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/api/index.ts create mode 100644 src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/component/drawer/info/api/index.ts create mode 100644 src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/component/drawer/info/info.vue create mode 100644 src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/component/drawer/info/init/usePage.ts create mode 100644 src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/component/drawer/info/types/index.ts create mode 100644 src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/component/modal/addOrEdit/addOrEdit.vue create mode 100644 src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/component/modal/addOrEdit/api/index.ts create mode 100644 src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/component/modal/addOrEdit/init/usePage.ts create mode 100644 src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/component/modal/addOrEdit/types/index.ts create mode 100644 src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/downstream.vue create mode 100644 src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/init/usePage.ts create mode 100644 src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/init/useSearch.ts create mode 100644 src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/init/useTable.ts create mode 100644 src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/types/index.ts create mode 100644 src/pages/admin-portal/device-center/distribute/config/component/tabs/subscribe/api/index.ts create mode 100644 src/pages/admin-portal/device-center/distribute/config/component/tabs/subscribe/component/modal/addOrEdit/addOrEdit.vue create mode 100644 src/pages/admin-portal/device-center/distribute/config/component/tabs/subscribe/component/modal/addOrEdit/api/index.ts create mode 100644 src/pages/admin-portal/device-center/distribute/config/component/tabs/subscribe/component/modal/addOrEdit/init/usePage.ts create mode 100644 src/pages/admin-portal/device-center/distribute/config/component/tabs/subscribe/component/modal/addOrEdit/types/index.ts create mode 100644 src/pages/admin-portal/device-center/distribute/config/component/tabs/subscribe/init/usePage.ts create mode 100644 src/pages/admin-portal/device-center/distribute/config/component/tabs/subscribe/init/useSearch.ts create mode 100644 src/pages/admin-portal/device-center/distribute/config/component/tabs/subscribe/init/useTable.ts create mode 100644 src/pages/admin-portal/device-center/distribute/config/component/tabs/subscribe/subscribe.vue create mode 100644 src/pages/admin-portal/device-center/distribute/config/component/tabs/subscribe/types/index.ts create mode 100644 src/pages/admin-portal/device-center/distribute/config/config.vue create mode 100644 src/pages/admin-portal/device-center/distribute/deadletter/api/index.ts create mode 100644 src/pages/admin-portal/device-center/distribute/deadletter/component/drawer/info/api/index.ts create mode 100644 src/pages/admin-portal/device-center/distribute/deadletter/component/drawer/info/info.vue create mode 100644 src/pages/admin-portal/device-center/distribute/deadletter/component/drawer/info/init/usePage.ts create mode 100644 src/pages/admin-portal/device-center/distribute/deadletter/component/drawer/info/types/index.ts create mode 100644 src/pages/admin-portal/device-center/distribute/deadletter/deadletter.vue create mode 100644 src/pages/admin-portal/device-center/distribute/deadletter/init/usePage.ts create mode 100644 src/pages/admin-portal/device-center/distribute/deadletter/init/useSearch.ts create mode 100644 src/pages/admin-portal/device-center/distribute/deadletter/init/useTable.ts create mode 100644 src/pages/admin-portal/device-center/distribute/deadletter/types/index.ts create mode 100644 src/pages/admin-portal/device-center/distribute/record/api/index.ts create mode 100644 src/pages/admin-portal/device-center/distribute/record/component/drawer/info/api/index.ts create mode 100644 src/pages/admin-portal/device-center/distribute/record/component/drawer/info/info.vue create mode 100644 src/pages/admin-portal/device-center/distribute/record/component/drawer/info/init/usePage.ts create mode 100644 src/pages/admin-portal/device-center/distribute/record/component/drawer/info/types/index.ts create mode 100644 src/pages/admin-portal/device-center/distribute/record/init/usePage.ts create mode 100644 src/pages/admin-portal/device-center/distribute/record/init/useSearch.ts create mode 100644 src/pages/admin-portal/device-center/distribute/record/init/useTable.ts create mode 100644 src/pages/admin-portal/device-center/distribute/record/record.vue create mode 100644 src/pages/admin-portal/device-center/distribute/record/types/index.ts create mode 100644 src/pages/admin-portal/device-center/ops/alarm/alarm.vue create mode 100644 src/pages/admin-portal/device-center/ops/alarm/api/index.ts create mode 100644 src/pages/admin-portal/device-center/ops/alarm/init/usePage.ts create mode 100644 src/pages/admin-portal/device-center/ops/alarm/init/useSearch.ts create mode 100644 src/pages/admin-portal/device-center/ops/alarm/init/useTable.ts create mode 100644 src/pages/admin-portal/device-center/ops/alarm/types/index.ts create mode 100644 src/pages/admin-portal/device-center/ops/config/api/index.ts create mode 100644 src/pages/admin-portal/device-center/ops/config/component/drawer/history/api/index.ts create mode 100644 src/pages/admin-portal/device-center/ops/config/component/drawer/history/history.vue create mode 100644 src/pages/admin-portal/device-center/ops/config/component/drawer/history/init/usePage.ts create mode 100644 src/pages/admin-portal/device-center/ops/config/component/drawer/history/types/index.ts create mode 100644 src/pages/admin-portal/device-center/ops/config/component/modal/edit/api/index.ts create mode 100644 src/pages/admin-portal/device-center/ops/config/component/modal/edit/edit.vue create mode 100644 src/pages/admin-portal/device-center/ops/config/component/modal/edit/init/usePage.ts create mode 100644 src/pages/admin-portal/device-center/ops/config/component/modal/edit/types/index.ts create mode 100644 src/pages/admin-portal/device-center/ops/config/config.vue create mode 100644 src/pages/admin-portal/device-center/ops/config/init/usePage.ts create mode 100644 src/pages/admin-portal/device-center/ops/config/init/useSearch.ts create mode 100644 src/pages/admin-portal/device-center/ops/config/init/useTable.ts create mode 100644 src/pages/admin-portal/device-center/ops/config/types/index.ts create mode 100644 src/pages/admin-portal/device-center/ops/init/api/index.ts create mode 100644 src/pages/admin-portal/device-center/ops/init/component/drawer/info/api/index.ts create mode 100644 src/pages/admin-portal/device-center/ops/init/component/drawer/info/info.vue create mode 100644 src/pages/admin-portal/device-center/ops/init/component/drawer/info/init/usePage.ts create mode 100644 src/pages/admin-portal/device-center/ops/init/component/drawer/info/types/index.ts create mode 100644 src/pages/admin-portal/device-center/ops/init/component/modal/addOrEdit/addOrEdit.vue create mode 100644 src/pages/admin-portal/device-center/ops/init/component/modal/addOrEdit/api/index.ts create mode 100644 src/pages/admin-portal/device-center/ops/init/component/modal/addOrEdit/init/usePage.ts create mode 100644 src/pages/admin-portal/device-center/ops/init/component/modal/addOrEdit/types/index.ts create mode 100644 src/pages/admin-portal/device-center/ops/init/init.vue create mode 100644 src/pages/admin-portal/device-center/ops/init/init/usePage.ts create mode 100644 src/pages/admin-portal/device-center/ops/init/init/useSearch.ts create mode 100644 src/pages/admin-portal/device-center/ops/init/init/useTable.ts create mode 100644 src/pages/admin-portal/device-center/ops/init/types/index.ts create mode 100644 src/pages/admin-portal/device-center/ops/monitor/api/index.ts create mode 100644 src/pages/admin-portal/device-center/ops/monitor/component/drawer/info/api/index.ts create mode 100644 src/pages/admin-portal/device-center/ops/monitor/component/drawer/info/info.vue create mode 100644 src/pages/admin-portal/device-center/ops/monitor/component/drawer/info/init/usePage.ts create mode 100644 src/pages/admin-portal/device-center/ops/monitor/component/drawer/info/types/index.ts create mode 100644 src/pages/admin-portal/device-center/ops/monitor/init/usePage.ts create mode 100644 src/pages/admin-portal/device-center/ops/monitor/init/useSearch.ts create mode 100644 src/pages/admin-portal/device-center/ops/monitor/init/useTable.ts create mode 100644 src/pages/admin-portal/device-center/ops/monitor/monitor.vue create mode 100644 src/pages/admin-portal/device-center/ops/monitor/types/index.ts create mode 100644 src/pages/admin-portal/device-center/ops/version/api/index.ts create mode 100644 src/pages/admin-portal/device-center/ops/version/component/drawer/info/api/index.ts create mode 100644 src/pages/admin-portal/device-center/ops/version/component/drawer/info/info.vue create mode 100644 src/pages/admin-portal/device-center/ops/version/component/drawer/info/init/usePage.ts create mode 100644 src/pages/admin-portal/device-center/ops/version/component/drawer/info/types/index.ts create mode 100644 src/pages/admin-portal/device-center/ops/version/component/modal/addOrEdit/addOrEdit.vue create mode 100644 src/pages/admin-portal/device-center/ops/version/component/modal/addOrEdit/api/index.ts create mode 100644 src/pages/admin-portal/device-center/ops/version/component/modal/addOrEdit/init/usePage.ts create mode 100644 src/pages/admin-portal/device-center/ops/version/component/modal/addOrEdit/types/index.ts create mode 100644 src/pages/admin-portal/device-center/ops/version/init/usePage.ts create mode 100644 src/pages/admin-portal/device-center/ops/version/init/useSearch.ts create mode 100644 src/pages/admin-portal/device-center/ops/version/init/useTable.ts create mode 100644 src/pages/admin-portal/device-center/ops/version/types/index.ts create mode 100644 src/pages/admin-portal/device-center/ops/version/version.vue create mode 100644 src/pages/admin-portal/device-center/ops/workOrder/api/index.ts create mode 100644 src/pages/admin-portal/device-center/ops/workOrder/init/usePage.ts create mode 100644 src/pages/admin-portal/device-center/ops/workOrder/init/useSearch.ts create mode 100644 src/pages/admin-portal/device-center/ops/workOrder/init/useTable.ts create mode 100644 src/pages/admin-portal/device-center/ops/workOrder/types/index.ts create mode 100644 src/pages/admin-portal/device-center/ops/workOrder/workOrder.vue create mode 100644 src/pages/admin-portal/device-center/stock/inbound/api/index.ts create mode 100644 src/pages/admin-portal/device-center/stock/inbound/component/modal/addOrEdit/addOrEdit.vue create mode 100644 src/pages/admin-portal/device-center/stock/inbound/component/modal/addOrEdit/api/index.ts create mode 100644 src/pages/admin-portal/device-center/stock/inbound/component/modal/addOrEdit/init/usePage.ts create mode 100644 src/pages/admin-portal/device-center/stock/inbound/component/modal/addOrEdit/types/index.ts create mode 100644 src/pages/admin-portal/device-center/stock/inbound/inbound.vue create mode 100644 src/pages/admin-portal/device-center/stock/inbound/init/usePage.ts create mode 100644 src/pages/admin-portal/device-center/stock/inbound/init/useSearch.ts create mode 100644 src/pages/admin-portal/device-center/stock/inbound/init/useTable.ts create mode 100644 src/pages/admin-portal/device-center/stock/inbound/types/index.ts create mode 100644 src/pages/admin-portal/device-center/stock/instock/api/index.ts create mode 100644 src/pages/admin-portal/device-center/stock/instock/component/drawer/info/api/index.ts create mode 100644 src/pages/admin-portal/device-center/stock/instock/component/drawer/info/info.vue create mode 100644 src/pages/admin-portal/device-center/stock/instock/component/drawer/info/init/usePage.ts create mode 100644 src/pages/admin-portal/device-center/stock/instock/component/drawer/info/types/index.ts create mode 100644 src/pages/admin-portal/device-center/stock/instock/component/modal/returnGoods/api/index.ts create mode 100644 src/pages/admin-portal/device-center/stock/instock/component/modal/returnGoods/init/usePage.ts create mode 100644 src/pages/admin-portal/device-center/stock/instock/component/modal/returnGoods/returnGoods.vue create mode 100644 src/pages/admin-portal/device-center/stock/instock/component/modal/returnGoods/types/index.ts create mode 100644 src/pages/admin-portal/device-center/stock/instock/init/usePage.ts create mode 100644 src/pages/admin-portal/device-center/stock/instock/init/useSearch.ts create mode 100644 src/pages/admin-portal/device-center/stock/instock/init/useTable.ts create mode 100644 src/pages/admin-portal/device-center/stock/instock/instock.vue create mode 100644 src/pages/admin-portal/device-center/stock/instock/types/index.ts create mode 100644 src/pages/admin-portal/device-center/stock/outbound/api/index.ts create mode 100644 src/pages/admin-portal/device-center/stock/outbound/component/modal/addOrEdit/addOrEdit.vue create mode 100644 src/pages/admin-portal/device-center/stock/outbound/component/modal/addOrEdit/api/index.ts create mode 100644 src/pages/admin-portal/device-center/stock/outbound/component/modal/addOrEdit/init/usePage.ts create mode 100644 src/pages/admin-portal/device-center/stock/outbound/component/modal/addOrEdit/types/index.ts create mode 100644 src/pages/admin-portal/device-center/stock/outbound/init/usePage.ts create mode 100644 src/pages/admin-portal/device-center/stock/outbound/init/useSearch.ts create mode 100644 src/pages/admin-portal/device-center/stock/outbound/init/useTable.ts create mode 100644 src/pages/admin-portal/device-center/stock/outbound/outbound.vue create mode 100644 src/pages/admin-portal/device-center/stock/outbound/types/index.ts create mode 100644 src/pages/admin-portal/device-center/stock/supplier/api/index.ts create mode 100644 src/pages/admin-portal/device-center/stock/supplier/component/modal/addOrEdit/addOrEdit.vue create mode 100644 src/pages/admin-portal/device-center/stock/supplier/component/modal/addOrEdit/api/index.ts create mode 100644 src/pages/admin-portal/device-center/stock/supplier/component/modal/addOrEdit/init/usePage.ts create mode 100644 src/pages/admin-portal/device-center/stock/supplier/component/modal/addOrEdit/types/index.ts create mode 100644 src/pages/admin-portal/device-center/stock/supplier/init/usePage.ts create mode 100644 src/pages/admin-portal/device-center/stock/supplier/init/useSearch.ts create mode 100644 src/pages/admin-portal/device-center/stock/supplier/init/useTable.ts create mode 100644 src/pages/admin-portal/device-center/stock/supplier/supplier.vue create mode 100644 src/pages/admin-portal/device-center/stock/supplier/types/index.ts create mode 100644 src/pages/admin-portal/device-center/tenantSite/site/api/index.ts create mode 100644 src/pages/admin-portal/device-center/tenantSite/site/component/drawer/info/api/index.ts create mode 100644 src/pages/admin-portal/device-center/tenantSite/site/component/drawer/info/info.vue create mode 100644 src/pages/admin-portal/device-center/tenantSite/site/component/drawer/info/init/usePage.ts create mode 100644 src/pages/admin-portal/device-center/tenantSite/site/component/drawer/info/types/index.ts create mode 100644 src/pages/admin-portal/device-center/tenantSite/site/component/modal/addOrEdit/addOrEdit.vue create mode 100644 src/pages/admin-portal/device-center/tenantSite/site/component/modal/addOrEdit/api/index.ts create mode 100644 src/pages/admin-portal/device-center/tenantSite/site/component/modal/addOrEdit/init/usePage.ts create mode 100644 src/pages/admin-portal/device-center/tenantSite/site/component/modal/addOrEdit/types/index.ts create mode 100644 src/pages/admin-portal/device-center/tenantSite/site/init/usePage.ts create mode 100644 src/pages/admin-portal/device-center/tenantSite/site/init/useSearch.ts create mode 100644 src/pages/admin-portal/device-center/tenantSite/site/init/useTable.ts create mode 100644 src/pages/admin-portal/device-center/tenantSite/site/site.vue create mode 100644 src/pages/admin-portal/device-center/tenantSite/site/types/index.ts create mode 100644 src/pages/admin-portal/device-center/tenantSite/tenant/api/index.ts create mode 100644 src/pages/admin-portal/device-center/tenantSite/tenant/init/usePage.ts create mode 100644 src/pages/admin-portal/device-center/tenantSite/tenant/init/useSearch.ts create mode 100644 src/pages/admin-portal/device-center/tenantSite/tenant/init/useTable.ts create mode 100644 src/pages/admin-portal/device-center/tenantSite/tenant/tenant.vue create mode 100644 src/pages/admin-portal/device-center/tenantSite/tenant/types/index.ts create mode 100644 src/pages/admin-portal/device-center/workbench/overview/api/index.ts create mode 100644 src/pages/admin-portal/device-center/workbench/overview/component/DonutChart/DonutChart.vue create mode 100644 src/pages/admin-portal/device-center/workbench/overview/init/usePage.ts create mode 100644 src/pages/admin-portal/device-center/workbench/overview/init/useSearch.ts create mode 100644 src/pages/admin-portal/device-center/workbench/overview/init/useTable.ts create mode 100644 src/pages/admin-portal/device-center/workbench/overview/overview.vue create mode 100644 src/pages/admin-portal/device-center/workbench/overview/types/index.ts delete mode 100644 src/pages/admin-portal/operation-center/Overview.vue create mode 100644 src/pages/admin-portal/operation-center/tenantManagement/tenantList/api/index.ts create mode 100644 src/pages/admin-portal/operation-center/tenantManagement/tenantList/init/usePage.ts create mode 100644 src/pages/admin-portal/operation-center/tenantManagement/tenantList/init/useSearch.ts create mode 100644 src/pages/admin-portal/operation-center/tenantManagement/tenantList/init/useTable.ts create mode 100644 src/pages/admin-portal/operation-center/tenantManagement/tenantList/tenantList.vue create mode 100644 src/pages/admin-portal/operation-center/tenantManagement/tenantList/types/index.ts create mode 100644 src/pages/admin-portal/shared/SimpleActionModal.vue create mode 100644 src/pages/admin-portal/shared/types.ts create mode 100644 src/pages/admin-portal/shared/useAdminPage.ts create mode 100644 src/pages/admin-portal/shared/useAdminSearch.ts create mode 100644 src/pages/admin-portal/shared/useAdminTable.ts delete mode 100644 src/pages/admin-portal/system-settings/Overview.vue create mode 100644 src/pages/admin-portal/system-settings/appService/applicationManagement/api/index.ts create mode 100644 src/pages/admin-portal/system-settings/appService/applicationManagement/applicationManagement.vue create mode 100644 src/pages/admin-portal/system-settings/appService/applicationManagement/init/usePage.ts create mode 100644 src/pages/admin-portal/system-settings/appService/applicationManagement/init/useSearch.ts create mode 100644 src/pages/admin-portal/system-settings/appService/applicationManagement/init/useTable.ts create mode 100644 src/pages/admin-portal/system-settings/appService/applicationManagement/types/index.ts create mode 100644 src/pages/admin-portal/system-settings/appService/menuManagement/api/index.ts create mode 100644 src/pages/admin-portal/system-settings/appService/menuManagement/init/usePage.ts create mode 100644 src/pages/admin-portal/system-settings/appService/menuManagement/init/useSearch.ts create mode 100644 src/pages/admin-portal/system-settings/appService/menuManagement/init/useTable.ts create mode 100644 src/pages/admin-portal/system-settings/appService/menuManagement/menuManagement.vue create mode 100644 src/pages/admin-portal/system-settings/appService/menuManagement/types/index.ts create mode 100644 src/pages/admin-portal/system-settings/dataDictionary/api/index.ts create mode 100644 src/pages/admin-portal/system-settings/dataDictionary/dataDictionary.vue create mode 100644 src/pages/admin-portal/system-settings/dataDictionary/init/usePage.ts create mode 100644 src/pages/admin-portal/system-settings/dataDictionary/init/useSearch.ts create mode 100644 src/pages/admin-portal/system-settings/dataDictionary/init/useTable.ts create mode 100644 src/pages/admin-portal/system-settings/dataDictionary/types/index.ts create mode 100644 src/pages/admin-portal/system-settings/permission/employeeManagement/api/index.ts create mode 100644 src/pages/admin-portal/system-settings/permission/employeeManagement/employeeManagement.vue create mode 100644 src/pages/admin-portal/system-settings/permission/employeeManagement/init/usePage.ts create mode 100644 src/pages/admin-portal/system-settings/permission/employeeManagement/init/useSearch.ts create mode 100644 src/pages/admin-portal/system-settings/permission/employeeManagement/init/useTable.ts create mode 100644 src/pages/admin-portal/system-settings/permission/employeeManagement/types/index.ts create mode 100644 src/pages/admin-portal/system-settings/permission/roleManagement/api/index.ts create mode 100644 src/pages/admin-portal/system-settings/permission/roleManagement/init/usePage.ts create mode 100644 src/pages/admin-portal/system-settings/permission/roleManagement/init/useSearch.ts create mode 100644 src/pages/admin-portal/system-settings/permission/roleManagement/init/useTable.ts create mode 100644 src/pages/admin-portal/system-settings/permission/roleManagement/roleManagement.vue create mode 100644 src/pages/admin-portal/system-settings/permission/roleManagement/types/index.ts create mode 100644 src/pages/admin-portal/system-settings/user/miniProgramUser/api/index.ts create mode 100644 src/pages/admin-portal/system-settings/user/miniProgramUser/init/usePage.ts create mode 100644 src/pages/admin-portal/system-settings/user/miniProgramUser/init/useSearch.ts create mode 100644 src/pages/admin-portal/system-settings/user/miniProgramUser/init/useTable.ts create mode 100644 src/pages/admin-portal/system-settings/user/miniProgramUser/miniProgramUser.vue create mode 100644 src/pages/admin-portal/system-settings/user/miniProgramUser/types/index.ts rename src/pages/miniprogram/{ => customer}/ask/Home.vue (100%) rename src/pages/miniprogram/{ => customer}/eat/Home.vue (100%) rename src/pages/miniprogram/{ => customer}/make/Home.vue (100%) rename src/pages/miniprogram/{ => customer}/me/Home.vue (100%) create mode 100644 src/pages/miniprogram/ops/inspect/Home.vue create mode 100644 src/pages/miniprogram/ops/me/Home.vue create mode 100644 src/pages/miniprogram/ops/scan/Home.vue create mode 100644 src/pages/miniprogram/ops/workOrder/Home.vue create mode 100644 src/pages/miniprogram/ops/workbench/Home.vue diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..77b50ac --- /dev/null +++ b/AGENTS.md @@ -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 4(375×812) | 用户自助查询、上报、咨询 | +| 硬件终端屏 | `hardware` | 自定义(动态 viewport) | 净菜柜屏 / 自助查询屏 / 大屏 | + +### URL 规范 + +- 综合后台:`/admin-portal/
/`(无应用层) +- 租户后台:`/tenant-portal//
/`(例:`/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 4(Hash 模式) | +| 状态管理 | **不引入 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 必须用 包裹 +├── 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 / dispatchMock(200~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/
/ +│ ├── tenant-portal//
// +│ ├── miniprogram/
/ +│ └── hardware/
/ +├── 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>({ + dataSource: [], + loading: false, + pagination: createPaginationConfig(), + columns: [...], +}) + +// ❌ 禁止单独 dataList ref +const dataList = ref([]) +``` + +### 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>() +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 +批量删除 +``` + +### 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 +- **操作列 `` 一律不加图标**;多个按钮用 `` 分隔 +- **次要操作**放 `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 作菜单或按钮图标 +- ❌ 操作列 `` 加图标 +- ❌ AI 脚手架默认生成 CRUD 按钮 +- ❌ 操作列按钮之间不加 `` +- ❌ `customRender` 写 JSX(统一用 `h()`) +- ❌ `a-input-number` 不设 `style="width: 100%"` +- ❌ 顶栏高度偏离 64px +- ❌ `App.vue` 漏写 `` + +--- + +## 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. 把 `` 拆为 `` + `` +5. 把 `` 拆为 `` + `` +6. **业务逻辑 100% 不改** diff --git a/CLAUDE.md b/CLAUDE.md index f761a0e..6dc4dca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,17 +13,22 @@ |------|-----|-------|---------|---------| | 综合管理后台 | `admin-portal` | Ant Design Vue | 平台管理员 | 平台级管理(系统设置/运营中心/设备中心 为顶部一级菜单,**无子应用、无应用切换、无应用网格**) | | 租户运营后台 | `tenant-portal` | Ant Design Vue | 租户运营人员 | **17 个独立可部署应用的统一入口**(营养管理/健康监测/...),有应用网格首页 | -| C 端微信小程序 | `miniprogram` | Vant 4(375×812) | 终端用户 | 用户自助查询、上报、咨询 | +| 微信小程序 | `miniprogram` | Vant 4(375×812) | 多种 | **支持多个小程序实例并存**,每个实例一组底部 Tab;URL `/miniprogram//` | | 硬件终端屏 | `hardware` | 自定义(动态 viewport) | 现场操作员 | 净菜柜屏 / 自助查询屏 / 大屏 等 | -> **重要架构说明**(admin / tenant 形态差异): +> **重要架构说明**(admin / tenant / miniprogram 形态差异): > - **综合管理后台为单应用形态**:3 个一级菜单(系统设置 / 运营中心 / 设备中心)直接挂在 portal 下,URL = `/admin-portal/
/`,无应用切换概念。 > - **租户运营后台为多应用形态**(对齐生产 vue 项目 `D:\Work\platform-vue-tenant` 的 qiankun 微前端):下挂多个**独立可部署的应用(NavApp)**,每个应用对应生产 vue 项目中的一个 `subXxx` 子应用。 > - 进入某个应用后,顶部显示**该应用内的一级菜单 tabs**(NavSection),左侧显示**当前一级菜单下的二/三级菜单**(NavGroup → NavLeaf) > - URL:`/tenant-portal//
/`,例:`/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//`,例:`/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 规范**:`///
/`,例:`/tenant-portal/nutrition/nutrition-farm/monitor/crop` 4. **列表页 UI 规范(重要)**: - **不渲染 PageHeader**(菜单 + 顶部历史栏已经表达所在位置) diff --git a/src/axios/handlers/admin/dict.ts b/src/axios/handlers/admin/dict.ts new file mode 100644 index 0000000..3dc3075 --- /dev/null +++ b/src/axios/handlers/admin/dict.ts @@ -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> = { + /** 设备厂商 */ + device_manufacturer: [ + { label: '华为', value: 1 }, + { label: '蚁熊', value: 2 }, + { label: '罗杰夫', value: 3 }, + { label: '鱼跃', value: 4 }, + { label: '上禾电子', value: 5 }, + ], +} + +const handleItems = (ctx: MockContext): ApiResponse> => { + 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 }, +]) diff --git a/src/axios/handlers/admin/system.ts b/src/axios/handlers/admin/system.ts new file mode 100644 index 0000000..6876c95 --- /dev/null +++ b/src/axios/handlers/admin/system.ts @@ -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 = (list: T[], params: PageParams): ApiResponse => { + 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 = (msg: string, data: T = null as T): ApiResponse => ({ + 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 => { + 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 => { + 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 => { + 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 => { + const params = getParams(ctx.params) + return paginate(roleList.filter((item) => includes(item.name, params.name)), params) +} + +const listMiniProgramUser = (ctx: MockContext): ApiResponse => { + 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 => { + const params = getParams(ctx.params) + return paginate(applicationList.filter((item) => includes(item.name, params.name)), params) +} + +const listMenu = (ctx: MockContext): ApiResponse => { + const params = getParams(ctx.params) + return paginate(menuList.filter((item) => includes(item.name, params.name)), params) +} + +const listDictionary = (ctx: MockContext): ApiResponse => { + 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 => { + 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('授权应用保存成功') }, +]) diff --git a/src/axios/handlers/device-center/asset/category.ts b/src/axios/handlers/device-center/asset/category.ts new file mode 100644 index 0000000..9f1ca6c --- /dev/null +++ b/src/axios/handlers/device-center/asset/category.ts @@ -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 = (msg: string, data: T = null as T): ApiResponse => ({ + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 }, +]) diff --git a/src/axios/handlers/device-center/asset/inbound.ts b/src/axios/handlers/device-center/asset/inbound.ts new file mode 100644 index 0000000..5fe5058 --- /dev/null +++ b/src/axios/handlers/device-center/asset/inbound.ts @@ -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 = (msg: string, data: T = null as T): ApiResponse => ({ + code: '00000', + msg, + data, +}) + +const paginate = (list: T[], params: PageParams): ApiResponse => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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('导出任务已提交') }, +]) diff --git a/src/axios/handlers/device-center/asset/instock.ts b/src/axios/handlers/device-center/asset/instock.ts new file mode 100644 index 0000000..72367a5 --- /dev/null +++ b/src/axios/handlers/device-center/asset/instock.ts @@ -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 = (msg: string, data: T = null as T): ApiResponse => ({ code: '00000', msg, data }) +const paginate = (list: T[], params: PageParams): ApiResponse => { + 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 => { + 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)[params.column as string] + const bv = (b as unknown as Record)[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 => { + const id = String(params.id ?? '') + const record = stockList.find((item) => item.id === id) + return ok('查询成功', record ?? null) +} + +/** 回库登记:状态变更 + 数量调整 */ +const returnRecord = (params: PageParams): ApiResponse => { + 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('导出任务已提交') }, +]) diff --git a/src/axios/handlers/device-center/asset/manage.ts b/src/axios/handlers/device-center/asset/manage.ts new file mode 100644 index 0000000..56883d9 --- /dev/null +++ b/src/axios/handlers/device-center/asset/manage.ts @@ -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 = (msg: string, data: T = null as T): ApiResponse => ({ + 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 = { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => 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 }, +]) diff --git a/src/axios/handlers/device-center/asset/outbound.ts b/src/axios/handlers/device-center/asset/outbound.ts new file mode 100644 index 0000000..0b7e18e --- /dev/null +++ b/src/axios/handlers/device-center/asset/outbound.ts @@ -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 = (msg: string, data: T = null as T): ApiResponse => ({ code: '00000', msg, data }) +const paginate = (list: T[], params: PageParams): ApiResponse => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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('导出任务已提交') }, +]) diff --git a/src/axios/handlers/device-center/asset/statistics.ts b/src/axios/handlers/device-center/asset/statistics.ts new file mode 100644 index 0000000..12afc8d --- /dev/null +++ b/src/axios/handlers/device-center/asset/statistics.ts @@ -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 = (msg: string, data: T = null as T): ApiResponse => ({ + code: '00000', + msg, + data, +}) + +/** 顶级分类中文标签 */ +const CATEGORY_LABEL: Record = { + 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 => { + 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 => 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 }, +]) diff --git a/src/axios/handlers/device-center/asset/supplier.ts b/src/axios/handlers/device-center/asset/supplier.ts new file mode 100644 index 0000000..3c2d125 --- /dev/null +++ b/src/axios/handlers/device-center/asset/supplier.ts @@ -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 = (msg: string, data: T = null as T): ApiResponse => ({ code: '00000', msg, data }) +const paginate = (list: T[], params: PageParams): ApiResponse => { + 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 => { + 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 => { + 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 => { + const id = String(params.id ?? '') + const record = supplierList.find((item) => item.id === id) + return ok('查询成功', record ?? null) +} + +/** 新增 */ +const addRecord = (params: PageParams): ApiResponse => { + 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 => { + 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)) }, +]) diff --git a/src/axios/handlers/device-center/asset/workOrder.ts b/src/axios/handlers/device-center/asset/workOrder.ts new file mode 100644 index 0000000..63b5d27 --- /dev/null +++ b/src/axios/handlers/device-center/asset/workOrder.ts @@ -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 = (msg: string, data: T = null as T): ApiResponse => ({ code: '00000', msg, data }) +const paginate = (list: T[], params: PageParams): ApiResponse => { + 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 => { + 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 => { + 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 => { + 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)) }, +]) diff --git a/src/axios/handlers/device-center/distribute/config.ts b/src/axios/handlers/device-center/distribute/config.ts new file mode 100644 index 0000000..3c3d89f --- /dev/null +++ b/src/axios/handlers/device-center/distribute/config.ts @@ -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 = (msg: string, data: T = null as T): ApiResponse => ({ 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 = { + '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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 }, +]) diff --git a/src/axios/handlers/device-center/distribute/deadletter.ts b/src/axios/handlers/device-center/distribute/deadletter.ts new file mode 100644 index 0000000..253677d --- /dev/null +++ b/src/axios/handlers/device-center/distribute/deadletter.ts @@ -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 = (msg: string, data: T = null as T): ApiResponse => ({ 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 }, +]) diff --git a/src/axios/handlers/device-center/distribute/record.ts b/src/axios/handlers/device-center/distribute/record.ts new file mode 100644 index 0000000..acee172 --- /dev/null +++ b/src/axios/handlers/device-center/distribute/record.ts @@ -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 = (msg: string, data: T = null as T): ApiResponse => ({ 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 => { + 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 => { + 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 => { + 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 => { + 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 }, +]) diff --git a/src/axios/handlers/device-center/ops/alarm.ts b/src/axios/handlers/device-center/ops/alarm.ts new file mode 100644 index 0000000..4857492 --- /dev/null +++ b/src/axios/handlers/device-center/ops/alarm.ts @@ -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 = (msg: string, data: T = null as T): ApiResponse => ({ 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 }, +]) diff --git a/src/axios/handlers/device-center/ops/config.ts b/src/axios/handlers/device-center/ops/config.ts new file mode 100644 index 0000000..6b69879 --- /dev/null +++ b/src/axios/handlers/device-center/ops/config.ts @@ -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 = (msg: string, data: T = null as T): ApiResponse => ({ 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 => { + 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 => { + const params = getParams(ctx.params) + return ok('查询成功', configList.find((i) => i.id === params.id) ?? null) +} + +const updateConfig = (ctx: MockContext): ApiResponse => { + 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 => { + 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 }, +]) diff --git a/src/axios/handlers/device-center/ops/init.ts b/src/axios/handlers/device-center/ops/init.ts new file mode 100644 index 0000000..28cc324 --- /dev/null +++ b/src/axios/handlers/device-center/ops/init.ts @@ -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 = (msg: string, data: T = null as T): ApiResponse => ({ 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 => { + 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 => { + 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 | 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 => { + 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 => { + 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 => { + 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 => { + 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 }, +]) diff --git a/src/axios/handlers/device-center/ops/monitor.ts b/src/axios/handlers/device-center/ops/monitor.ts new file mode 100644 index 0000000..2e7dcfc --- /dev/null +++ b/src/axios/handlers/device-center/ops/monitor.ts @@ -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 = (msg: string, data: T = null as T): ApiResponse => ({ 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 => { + 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 => { + const params = getParams(ctx.params) + const target = monitorList.find((item) => item.id === params.id) + return ok('查询成功', target ?? null) +} + +const remoteMonitor = (ctx: MockContext): ApiResponse => { + 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 }, +]) diff --git a/src/axios/handlers/device-center/ops/version.ts b/src/axios/handlers/device-center/ops/version.ts new file mode 100644 index 0000000..2b10dc1 --- /dev/null +++ b/src/axios/handlers/device-center/ops/version.ts @@ -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 = (msg: string, data: T = null as T): ApiResponse => ({ 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 => { + 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 => { + const params = getParams(ctx.params) + return ok('查询成功', versionList.find((i) => i.id === params.id) ?? null) +} + +/** 编辑回填版 */ +const infoForEdit = (ctx: MockContext): ApiResponse | 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 => { + 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 => { + 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 => { + 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 => { + 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 }, +]) diff --git a/src/axios/handlers/device-center/tenantSite/site.ts b/src/axios/handlers/device-center/tenantSite/site.ts new file mode 100644 index 0000000..679548e --- /dev/null +++ b/src/axios/handlers/device-center/tenantSite/site.ts @@ -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 = (list: T[], params: SiteParams): ApiResponse => { + 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 = (msg: string, data: T = null as T): ApiResponse => ({ 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 => { + 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 = { + 'tenant-001': '长庆能源总部食堂', + 'tenant-002': '第一采油厂', + 'tenant-003': '炼化分公司', + 'tenant-004': '健康示范园区', +} + +const addSite = (ctx: MockContext): ApiResponse => { + 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 => { + 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 => { + 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 => { + 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 }, +]) diff --git a/src/axios/handlers/device-center/workbench/overview.ts b/src/axios/handlers/device-center/workbench/overview.ts new file mode 100644 index 0000000..0c1bc1f --- /dev/null +++ b/src/axios/handlers/device-center/workbench/overview.ts @@ -0,0 +1,66 @@ +import { registerMocks } from '@axios/mockBus' +import type { ApiResponse, MockContext } from '@axios' + +const ok = (msg: string, data: T = null as T): ApiResponse => ({ + code: '00000', + msg, + data, +}) + +/** 数据概览 mock 数据(一次性聚合返回,多维度) */ +const handleOverview = (_ctx: MockContext): ApiResponse => { + /** 设备总数按比例分布(仅原型展示) */ + 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 }, +]) diff --git a/src/axios/handlers/index.ts b/src/axios/handlers/index.ts index c4cf676..c71267b 100644 --- a/src/axios/handlers/index.ts +++ b/src/axios/handlers/index.ts @@ -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' diff --git a/src/composables/usePageHistory.ts b/src/composables/usePageHistory.ts index a5a867f..4002e4a 100644 --- a/src/composables/usePageHistory.ts +++ b/src/composables/usePageHistory.ts @@ -97,9 +97,12 @@ export const usePageHistory = (scopeKey: string) => { * 计算路由对应的历史 scope key * - admin 路由 meta.app 固定为 'admin' * - tenant 路由 meta.app 为具体应用 key(nutrition / monitor / ...) + * - miniprogram 路由 meta.mp 为小程序实例 key(customer / 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, diff --git a/src/composables/useVisibility.ts b/src/composables/useVisibility.ts index abd4212..b19be18 100644 --- a/src/composables/useVisibility.ts +++ b/src/composables/useVisibility.ts @@ -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 diff --git a/src/docs/device/设备管理服务器功能现状.md b/src/docs/device/设备管理服务器功能现状.md new file mode 100644 index 0000000..d502bfb --- /dev/null +++ b/src/docs/device/设备管理服务器功能现状.md @@ -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. **权限隔离**:仅实施运维工程师角色可登录小程序,管理员 / 客户经理 / 销售等角色无小程序权限 diff --git a/src/docs/list-page-spec.md b/src/docs/list-page-spec.md index 904634b..56b8790 100644 --- a/src/docs/list-page-spec.md +++ b/src/docs/list-page-spec.md @@ -482,6 +482,61 @@ const { **禁止**:脚手架生成时**默认带上新增/编辑/删除**。必须由用户明确指定页面功能。 +### 7.4 同级数据切换(radio-button 样式,强制) + +> 参考 vue 研发项目 `subHealthAssessment/archiveModule/unitReportData` 的"二级单位/三级单位"切换实现。 + +**适用场景**:同一张表格按某个维度(如设备类别、单位级别、订单状态等)过滤出多类数据展示,且**各切换项共用同一份列定义与业务逻辑**(只是 `category` / `orgType` 等过滤参数不同)。 + +**强制样式**:必须用 `` + ``,**禁止用 ``**。 + +**与 tabs-page-spec 的边界**: +- `` 仅用于"每个 Tab 各自独立 6 文件结构 / 各自独立业务"的场景(参见 `tabs-page-spec.md`) +- 同表格按维度过滤一律用 `a-radio-group button-style="solid"` + +**模板示例**: + +```vue + +``` + +**强制要求**: +- 切换行使用 `.listPageRow` 全局类(与 listPage.less 对齐),`margin-bottom: 12px` +- `button-style="solid"` 必填(默认 outline 风格不符合规范) +- 切换项数据源在 `usePage.ts` 顶部以常量数组声明:`{ key, label }[]` +- 切换时重置搜索条件 + 回到第一页 + 重新拉列表(参考 `switchTab` 实现) +- `switchTab` 入参兼容 `string` 与 `RadioChangeEvent` 两种形态(`@change` 传 event,编程式调用传 string) + +**禁止行为**: +- ❌ 同表格按维度过滤场景使用 `` / `` +- ❌ 省略 `button-style="solid"`(默认 outline 风格不符合规范) +- ❌ 把 radio-group 塞进 FilterBar 的 inline 表单内(视觉错位,应单独成行) + ## 八、数据展示规范 | 数据类型 | 展示格式 | diff --git a/src/docs/tabs-page-spec.md b/src/docs/tabs-page-spec.md index 8c5c2c4..7420344 100644 --- a/src/docs/tabs-page-spec.md +++ b/src/docs/tabs-page-spec.md @@ -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` 过滤参数不同)—— 此类场景必须用 ``,参见 `list-page-spec.md` §7.4「同级数据切换」。 + +| 场景 | 选型 | 规范 | +|------|------|------| +| 各 Tab 独立业务 / 独立 6 文件 | `` | 本规范 | +| 同表格按维度过滤 | `` | 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()` +- ❌ **禁止同表格按维度过滤场景套用本规范**(须用 ``,参见 `list-page-spec.md` §7.4) diff --git a/src/layouts/MiniprogramLayout.vue b/src/layouts/MiniprogramLayout.vue index f3df912..540ae38 100644 --- a/src/layouts/MiniprogramLayout.vue +++ b/src/layouts/MiniprogramLayout.vue @@ -5,7 +5,7 @@
9:41 - 小程序 + {{ currentMp?.label ?? '小程序' }} 100%
@@ -21,8 +21,26 @@
- + ← 返回原型首页 + + + + + 🔀 切换小程序 + + + @@ -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(() => { const effectiveRole = role.value ?? 'designer' - return filterMiniprogramTabs(miniprogramTabs, effectiveRole, overrides.value) + return filterMiniprograms(miniprograms, effectiveRole, overrides.value) +}) + +/** 当前 route.meta.mp 对应的小程序实例(找不到时回退到第一个可见 mp) */ +const currentMp = computed(() => { + const mpKey = route.meta?.mp as string | undefined + return filteredMps.value.find(m => m.key === mpKey) ?? filteredMps.value[0] +}) + +/** 当前 mp 经角色过滤后的 tabs */ +const visibleTabs = computed(() => { + const effectiveRole = role.value ?? 'designer' + if (!currentMp.value) return [] + return filterMiniprogramTabs(currentMp.value.tabs, effectiveRole, overrides.value) }) /** 当前激活 tab key */ const activeTabKey = computed(() => (route.meta.tab as string) ?? visibleTabs.value[0]?.key ?? '') +/** 顶部"切换小程序"下拉里展示的 mp 列表(与可见列表一致) */ +const switchableMps = computed(() => 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}`) + } } @@ -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; diff --git a/src/layouts/components/MenuTreeNode.vue b/src/layouts/components/MenuTreeNode.vue new file mode 100644 index 0000000..4b368fe --- /dev/null +++ b/src/layouts/components/MenuTreeNode.vue @@ -0,0 +1,57 @@ + + + diff --git a/src/layouts/components/Sidebar.vue b/src/layouts/components/Sidebar.vue index e7c8b73..49742af 100644 --- a/src/layouts/components/Sidebar.vue +++ b/src/layouts/components/Sidebar.vue @@ -2,43 +2,34 @@ - + @@ -102,5 +115,3 @@ const onMenuClick: MenuProps['onClick'] = (info) => { height: 100%; } - - diff --git a/src/meta/nav.ts b/src/meta/nav.ts index 9655b9d..6ec46b6 100644 --- a/src/meta/nav.ts +++ b/src/meta/nav.ts @@ -6,7 +6,9 @@ * 四个终端: * - admin-portal 综合管理后台(AntDV 后台 UI;单应用形态,3 个一级菜单:系统设置 / 运营中心 / 设备中心) * - tenant-portal 租户运营后台(AntDV 后台 UI;下挂 17 个独立应用) - * - miniprogram C 端微信小程序(Vant,375×812;吃 / 问 / 做 / 我) + * - miniprogram 微信小程序(Vant,375×812;支持多个小程序实例并存,每个实例一组底部 Tab) + * · customer:C 端用户小程序(吃 / 问 / 做 / 我) + * · ops:实施运维小程序(工作台 / 工单 / 扫码 / 巡检 / 我的,仅运维工程师) * - hardware 硬件终端屏(按设备类型动态 viewport;10 类设备) * * 后台菜单层级(对齐生产 vue 项目 D:\Work\platform-vue-tenant): @@ -25,6 +27,9 @@ * - 渲染:layouts 用 `` 动态渲染 */ +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// + */ +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[] = [ +/** + * 微信小程序实例列表(支持多个并存) + * - customer:C 端用户小程序(原 4 Tab:吃 / 问 / 做 / 我) + * - ops:实施运维小程序(5 Tab:工作台 / 工单 / 扫码 / 巡检 / 我的),仅运维工程师使用 + * + * URL:/miniprogram// + * 目录:src/pages/miniprogram///... + */ +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', + }, + }, + ], }, ] diff --git a/src/pages/admin-portal/device-center/Overview.vue b/src/pages/admin-portal/device-center/Overview.vue deleted file mode 100644 index 2253b84..0000000 --- a/src/pages/admin-portal/device-center/Overview.vue +++ /dev/null @@ -1,12 +0,0 @@ - - - diff --git a/src/pages/admin-portal/device-center/asset/category/api/index.ts b/src/pages/admin-portal/device-center/asset/category/api/index.ts new file mode 100644 index 0000000..1b21f67 --- /dev/null +++ b/src/pages/admin-portal/device-center/asset/category/api/index.ts @@ -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> => + postRequest('axiosRequest', '/admin/device/category/tree', params) + +/** 新增分类节点 */ +export const add = (payload: CategoryPayload): Promise> => + postRequest('axiosRequest', '/admin/device/category/add', payload) + +/** 更新分类节点 */ +export const update = (payload: CategoryPayload): Promise> => + postRequest('axiosRequest', '/admin/device/category/update', payload) + +/** 删除分类节点(含子节点级联删除) */ +export const remove = (id: string): Promise> => + postRequest('axiosRequest', '/admin/device/category/delete', { id }) diff --git a/src/pages/admin-portal/device-center/asset/category/category.vue b/src/pages/admin-portal/device-center/asset/category/category.vue new file mode 100644 index 0000000..5d525d0 --- /dev/null +++ b/src/pages/admin-portal/device-center/asset/category/category.vue @@ -0,0 +1,157 @@ + + + + + diff --git a/src/pages/admin-portal/device-center/asset/category/component/modal/addOrEdit/addOrEdit.vue b/src/pages/admin-portal/device-center/asset/category/component/modal/addOrEdit/addOrEdit.vue new file mode 100644 index 0000000..08502a2 --- /dev/null +++ b/src/pages/admin-portal/device-center/asset/category/component/modal/addOrEdit/addOrEdit.vue @@ -0,0 +1,209 @@ + + + + + diff --git a/src/pages/admin-portal/device-center/asset/category/component/modal/addOrEdit/api/index.ts b/src/pages/admin-portal/device-center/asset/category/component/modal/addOrEdit/api/index.ts new file mode 100644 index 0000000..cb8b77c --- /dev/null +++ b/src/pages/admin-portal/device-center/asset/category/component/modal/addOrEdit/api/index.ts @@ -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> => + postRequest('axiosRequest', '/admin/device/category/info', { id }) + +/** 新增 */ +export const add = (params: Record): Promise> => + postRequest('axiosRequest', '/admin/device/category/add', params) + +/** 编辑 */ +export const edit = (params: Record): Promise> => + postRequest('axiosRequest', '/admin/device/category/update', params) diff --git a/src/pages/admin-portal/device-center/asset/category/component/modal/addOrEdit/init/usePage.ts b/src/pages/admin-portal/device-center/asset/category/component/modal/addOrEdit/init/usePage.ts new file mode 100644 index 0000000..0c42e55 --- /dev/null +++ b/src/pages/admin-portal/device-center/asset/category/component/modal/addOrEdit/init/usePage.ts @@ -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({ + visible: false, + type: 'add', + title: '', + width: 640, + spin: false, + }) + + const formRef = ref() + const form = ref
(createInitForm()) + + /** 厂商字典选项(首次打开弹框时懒加载,后续复用) */ + const manufacturerOptions = ref>([]) + + 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 = { + name: [{ required: true, message: '请输入节点名称', trigger: 'blur' }], + sortOrder: [{ required: true, type: 'number', message: '请输入排序号', trigger: 'blur' }], + } + + /** 是否展示业务字段(IOT / 数据分发 / 数据上报):仅 level >= 2 */ + const showBusinessFields = computed(() => form.value.level >= 2) + + /** 是否为叶子节点:新增视为叶子;编辑看是否有子 */ + const isLeaf = computed(() => { + if (pageInfo.type === 'add') return true + return !form.value.hasChildren + }) + + /** 是否展示叶子专属字段(厂商 / 型号):业务字段可见 且 当前为叶子 */ + const showLeafFields = computed(() => 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[] = [] + 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 => { + const params = JSON.parse(JSON.stringify(raw)) as Record + /** 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): 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): 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, + } +} diff --git a/src/pages/admin-portal/device-center/asset/category/component/modal/addOrEdit/types/index.ts b/src/pages/admin-portal/device-center/asset/category/component/modal/addOrEdit/types/index.ts new file mode 100644 index 0000000..337b7fa --- /dev/null +++ b/src/pages/admin-portal/device-center/asset/category/component/modal/addOrEdit/types/index.ts @@ -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 用 id,add sub 用 parentId/parentName/level) */ +export interface OpenContext { + id?: string + parentId?: string + parentName?: string + level?: number +} diff --git a/src/pages/admin-portal/device-center/asset/category/init/usePage.ts b/src/pages/admin-portal/device-center/asset/category/init/usePage.ts new file mode 100644 index 0000000..f178673 --- /dev/null +++ b/src/pages/admin-portal/device-center/asset/category/init/usePage.ts @@ -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 展示选中"类别详情" + 操作按钮 + * - 新增/编辑走子组件 AddOrEdit(modal-spec 4 文件结构) + * - 顶级节点:仅名称/编码/排序/备注 + * - 二级及以下节点:携带 IOT/数据分发/数据上报 + * - 叶子节点:额外携带厂商/型号 + * - code 由系统按 D01A01 规则自动生成 + */ +export const usePage = () => { + const { message, Modal } = useAntdStaticMethods() + const { + treeData, + selectedKeys, + expandedKeys, + pageLoading, + fieldNames, + collectExpandableKeys, + resetTree, + } = useTable() + + const search = reactive(createSearchKey()) + + /** 当前选中的节点对象(基于 selectedKeys 计算) */ + const selectedNode = ref(null) + + /** 新增/编辑子组件 ref(必须在 usePage.ts 中定义) */ + const addOrEditRef = ref | null>(null) + + /** 厂商字典选项(用于详情面板 value→label 映射) */ + const manufacturerOptions = ref>([]) + /** 厂商 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, + } +} diff --git a/src/pages/admin-portal/device-center/asset/category/init/useSearch.ts b/src/pages/admin-portal/device-center/asset/category/init/useSearch.ts new file mode 100644 index 0000000..4ab6fac --- /dev/null +++ b/src/pages/admin-portal/device-center/asset/category/init/useSearch.ts @@ -0,0 +1,6 @@ +import type { SearchForm } from '../types' + +/** 搜索表单工厂:统一重置入口 */ +export const createSearchKey = (): SearchForm => ({ + keyword: undefined, +}) diff --git a/src/pages/admin-portal/device-center/asset/category/init/useTable.ts b/src/pages/admin-portal/device-center/asset/category/init/useTable.ts new file mode 100644 index 0000000..26cc5cd --- /dev/null +++ b/src/pages/admin-portal/device-center/asset/category/init/useTable.ts @@ -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([]) + const selectedKeys = ref([]) + const expandedKeys = ref([]) + const pageLoading = ref(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, + } +} diff --git a/src/pages/admin-portal/device-center/asset/category/types/index.ts b/src/pages/admin-portal/device-center/asset/category/types/index.ts new file mode 100644 index 0000000..7e2de7e --- /dev/null +++ b/src/pages/admin-portal/device-center/asset/category/types/index.ts @@ -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 +} diff --git a/src/pages/admin-portal/device-center/asset/manage/api/index.ts b/src/pages/admin-portal/device-center/asset/manage/api/index.ts new file mode 100644 index 0000000..93bdb37 --- /dev/null +++ b/src/pages/admin-portal/device-center/asset/manage/api/index.ts @@ -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> => + postRequest('axiosRequest', '/admin/device/manage/list', params) + +/** 删除设备(归档,不物理删除) */ +export const remove = (id: string): Promise> => + postRequest('axiosRequest', '/admin/device/manage/delete', { id }) + +/** 异步导出设备列表(按当前搜索条件) */ +export const exportList = (params: SearchForm & { category: DeviceCategoryTab }): Promise> => + postRequest('axiosRequest', '/admin/device/manage/export', params) diff --git a/src/pages/admin-portal/device-center/asset/manage/component/drawer/info/api/index.ts b/src/pages/admin-portal/device-center/asset/manage/component/drawer/info/api/index.ts new file mode 100644 index 0000000..1a17d18 --- /dev/null +++ b/src/pages/admin-portal/device-center/asset/manage/component/drawer/info/api/index.ts @@ -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> => + postRequest('axiosRequest', '/admin/device/manage/info', { id }) diff --git a/src/pages/admin-portal/device-center/asset/manage/component/drawer/info/info.vue b/src/pages/admin-portal/device-center/asset/manage/component/drawer/info/info.vue new file mode 100644 index 0000000..5bb5b79 --- /dev/null +++ b/src/pages/admin-portal/device-center/asset/manage/component/drawer/info/info.vue @@ -0,0 +1,218 @@ + + + + + diff --git a/src/pages/admin-portal/device-center/asset/manage/component/drawer/info/init/usePage.ts b/src/pages/admin-portal/device-center/asset/manage/component/drawer/info/init/usePage.ts new file mode 100644 index 0000000..7ebf868 --- /dev/null +++ b/src/pages/admin-portal/device-center/asset/manage/component/drawer/info/init/usePage.ts @@ -0,0 +1,85 @@ +/** + * 设备管理 - 详情抽屉 - 主逻辑 + * + * 严格遵循 drawer-spec.md(B 类型:详情抽屉) + * - 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 = { + catering: '智养餐饮设备', + body: '体重测量设备', + wearable: '智能穿戴设备', + fitness: '健身器材设备', + monitor: '健康监测设备', +} + +export const usePage = () => { + const pageInfo = reactive({ + visible: false, + title: '设备详情', + spin: false, + width: 900, + }) + + const detail = ref(null) + const activeTab = ref('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, + } +} diff --git a/src/pages/admin-portal/device-center/asset/manage/component/drawer/info/types/index.ts b/src/pages/admin-portal/device-center/asset/manage/component/drawer/info/types/index.ts new file mode 100644 index 0000000..6a4be3f --- /dev/null +++ b/src/pages/admin-portal/device-center/asset/manage/component/drawer/info/types/index.ts @@ -0,0 +1,70 @@ +/** + * 设备管理 - 详情抽屉 - 类型定义 + * + * 严格遵循 drawer-spec.md(B 类型:详情抽屉) + * - 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[] +} diff --git a/src/pages/admin-portal/device-center/asset/manage/component/modal/addOrEdit/addOrEdit.vue b/src/pages/admin-portal/device-center/asset/manage/component/modal/addOrEdit/addOrEdit.vue new file mode 100644 index 0000000..2e5a132 --- /dev/null +++ b/src/pages/admin-portal/device-center/asset/manage/component/modal/addOrEdit/addOrEdit.vue @@ -0,0 +1,313 @@ + + + + + diff --git a/src/pages/admin-portal/device-center/asset/manage/component/modal/addOrEdit/api/index.ts b/src/pages/admin-portal/device-center/asset/manage/component/modal/addOrEdit/api/index.ts new file mode 100644 index 0000000..1cef793 --- /dev/null +++ b/src/pages/admin-portal/device-center/asset/manage/component/modal/addOrEdit/api/index.ts @@ -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> => + postRequest('axiosRequest', '/admin/device/manage/info', { id }) + +/** 新增 */ +export const add = (params: Record): Promise> => + postRequest('axiosRequest', '/admin/device/manage/add', params) + +/** 编辑 */ +export const edit = (params: Record): Promise> => + postRequest('axiosRequest', '/admin/device/manage/update', params) diff --git a/src/pages/admin-portal/device-center/asset/manage/component/modal/addOrEdit/init/usePage.ts b/src/pages/admin-portal/device-center/asset/manage/component/modal/addOrEdit/init/usePage.ts new file mode 100644 index 0000000..828725f --- /dev/null +++ b/src/pages/admin-portal/device-center/asset/manage/component/modal/addOrEdit/init/usePage.ts @@ -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 = { + 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({ + visible: false, + type: 'add', + title: '', + width: 820, + spin: false, + }) + + const formRef = ref() + const form = ref(createInitForm()) + const formOptions = ref(createInitOptions()) + + /** 当前 Tab(控制差异字段区块显隐) */ + const activeTab = ref('catering') + + /** 投用状态实时联动(只读展示) */ + const deployStatusText = computed(() => (form.value.deployStatus === 'deployed' ? '投用' : '在库')) + + /** 租户或场所变化时重新计算投用状态 */ + const onDeployFieldChange = (): void => { + form.value.deployStatus = computeDeployStatus(form.value.tenantName, form.value.siteName) + } + + const rules: Record = { + 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 = { + catering: '智养餐饮设备', + body: '体重测量设备', + wearable: '智能穿戴设备', + fitness: '健身器材设备', + monitor: '健康监测设备', + } + pageInfo.title = type === 'add' + ? `新增设备(${tabLabelMap[tab]})` + : '编辑设备' + + const requests: Promise[] = [] + 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): 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): 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 + 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, + } +} diff --git a/src/pages/admin-portal/device-center/asset/manage/component/modal/addOrEdit/types/index.ts b/src/pages/admin-portal/device-center/asset/manage/component/modal/addOrEdit/types/index.ts new file mode 100644 index 0000000..5046928 --- /dev/null +++ b/src/pages/admin-portal/device-center/asset/manage/component/modal/addOrEdit/types/index.ts @@ -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 diff --git a/src/pages/admin-portal/device-center/asset/manage/init/usePage.ts b/src/pages/admin-portal/device-center/asset/manage/init/usePage.ts new file mode 100644 index 0000000..0bdf3c4 --- /dev/null +++ b/src/pages/admin-portal/device-center/asset/manage/init/usePage.ts @@ -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('catering') + + const search = reactive(createSearchKey()) + + const table = reactive>({ + columns: [], + dataSource: [], + sort: { field: '', order: null }, + pagination: createPaginationConfig() as TablePaginationConfig, + }) + + /** 根据 activeTab 动态生成列定义 */ + const tableColumns = computed(() => buildColumns(activeTab.value)) + + const pageLoading = ref(false) + const selectedRowKeys = ref<(string | number)[]>([]) + + /** 新增/编辑子组件 ref(必须在 usePage.ts 中定义) */ + const addOrEditRef = ref | null>(null) + /** 详情抽屉子组件 ref */ + const infoDrawerRef = ref | 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 } diff --git a/src/pages/admin-portal/device-center/asset/manage/init/useSearch.ts b/src/pages/admin-portal/device-center/asset/manage/init/useSearch.ts new file mode 100644 index 0000000..f36cc60 --- /dev/null +++ b/src/pages/admin-portal/device-center/asset/manage/init/useSearch.ts @@ -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, +}) diff --git a/src/pages/admin-portal/device-center/asset/manage/init/useTable.ts b/src/pages/admin-portal/device-center/asset/manage/init/useTable.ts new file mode 100644 index 0000000..5edf8af --- /dev/null +++ b/src/pages/admin-portal/device-center/asset/manage/init/useTable.ts @@ -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 = { + online: 'success', + offline: 'default', + repairing: 'warning', + scrapped: 'error', +} + +/** 运行状态 → 中文 */ +const runStatusText: Record = { + online: '在用', + offline: '离线', + repairing: '维修中', + scrapped: '报废', +} + +/** 投用状态 → Tag 颜色映射 */ +const deployStatusColor: Record = { + in_stock: 'default', + deployed: 'success', +} + +/** 投用状态 → 中文 */ +const deployStatusText: Record = { + 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 = { + 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 } diff --git a/src/pages/admin-portal/device-center/asset/manage/manage.vue b/src/pages/admin-portal/device-center/asset/manage/manage.vue new file mode 100644 index 0000000..84f4ea8 --- /dev/null +++ b/src/pages/admin-portal/device-center/asset/manage/manage.vue @@ -0,0 +1,126 @@ + + + + + diff --git a/src/pages/admin-portal/device-center/asset/manage/types/index.ts b/src/pages/admin-portal/device-center/asset/manage/types/index.ts new file mode 100644 index 0000000..b8d2116 --- /dev/null +++ b/src/pages/admin-portal/device-center/asset/manage/types/index.ts @@ -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 +} diff --git a/src/pages/admin-portal/device-center/asset/statistics/api/index.ts b/src/pages/admin-portal/device-center/asset/statistics/api/index.ts new file mode 100644 index 0000000..3493b53 --- /dev/null +++ b/src/pages/admin-portal/device-center/asset/statistics/api/index.ts @@ -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> => + postRequest('axiosRequest', '/admin/device/statistics/overview', params) + +/** 查询设备统计列表(按多维条件过滤后的扁平记录) */ +export const list = (params: SearchForm): Promise> => + postRequest('axiosRequest', '/admin/device/statistics/list', params) + +/** 异步导出统计列表数据 */ +export const exportList = (params: SearchForm): Promise> => + postRequest('axiosRequest', '/admin/device/statistics/export', params) diff --git a/src/pages/admin-portal/device-center/asset/statistics/init/usePage.ts b/src/pages/admin-portal/device-center/asset/statistics/init/usePage.ts new file mode 100644 index 0000000..acdd4e6 --- /dev/null +++ b/src/pages/admin-portal/device-center/asset/statistics/init/usePage.ts @@ -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(createSearchKey()) + const pageLoading = ref(false) + + /** 顶部统计卡数据 */ + const overview = ref({ overview: [] }) + + /** 入库时间范围(绑 a-range-picker) */ + const onlineRange = ref<[string, string] | undefined>(undefined) + /** 投用时间范围 */ + const deployRange = ref<[string, string] | undefined>(undefined) + + const table = reactive>({ + 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, + } +} diff --git a/src/pages/admin-portal/device-center/asset/statistics/init/useSearch.ts b/src/pages/admin-portal/device-center/asset/statistics/init/useSearch.ts new file mode 100644 index 0000000..c51b43c --- /dev/null +++ b/src/pages/admin-portal/device-center/asset/statistics/init/useSearch.ts @@ -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, +}) diff --git a/src/pages/admin-portal/device-center/asset/statistics/init/useTable.ts b/src/pages/admin-portal/device-center/asset/statistics/init/useTable.ts new file mode 100644 index 0000000..d1088db --- /dev/null +++ b/src/pages/admin-portal/device-center/asset/statistics/init/useTable.ts @@ -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 = { + online: 'success', + offline: 'default', + repairing: 'warning', + scrapped: 'error', + } + const textMap: Record = { + 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 => { + 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}`, + } +} diff --git a/src/pages/admin-portal/device-center/asset/statistics/statistics.vue b/src/pages/admin-portal/device-center/asset/statistics/statistics.vue new file mode 100644 index 0000000..2398abe --- /dev/null +++ b/src/pages/admin-portal/device-center/asset/statistics/statistics.vue @@ -0,0 +1,198 @@ + + + + + diff --git a/src/pages/admin-portal/device-center/asset/statistics/types/index.ts b/src/pages/admin-portal/device-center/asset/statistics/types/index.ts new file mode 100644 index 0000000..befae4d --- /dev/null +++ b/src/pages/admin-portal/device-center/asset/statistics/types/index.ts @@ -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 +} diff --git a/src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/api/index.ts b/src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/api/index.ts new file mode 100644 index 0000000..b1b7c88 --- /dev/null +++ b/src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/api/index.ts @@ -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> => + postRequest('axiosRequest', BASE + '/list', params) + +/** 详情 */ +export const info = (id: string): Promise> => + postRequest('axiosRequest', BASE + '/info', { id }) + +/** 新增 */ +export const add = (params: DownstreamForm): Promise> => + postRequest('axiosRequest', BASE + '/add', params) + +/** 编辑 */ +export const update = (params: DownstreamForm): Promise> => + postRequest('axiosRequest', BASE + '/update', params) + +/** 删除 */ +export const remove = (id: string): Promise> => + postRequest('axiosRequest', BASE + '/delete', { id }) + +/** 启用/禁用 */ +export const toggleStatus = (id: string): Promise> => + postRequest('axiosRequest', BASE + '/toggleStatus', { id }) diff --git a/src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/component/drawer/info/api/index.ts b/src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/component/drawer/info/api/index.ts new file mode 100644 index 0000000..a747993 --- /dev/null +++ b/src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/component/drawer/info/api/index.ts @@ -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> => + postRequest('axiosRequest', BASE + '/info', { id }) diff --git a/src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/component/drawer/info/info.vue b/src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/component/drawer/info/info.vue new file mode 100644 index 0000000..a27d432 --- /dev/null +++ b/src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/component/drawer/info/info.vue @@ -0,0 +1,53 @@ + + + diff --git a/src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/component/drawer/info/init/usePage.ts b/src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/component/drawer/info/init/usePage.ts new file mode 100644 index 0000000..58f7bbc --- /dev/null +++ b/src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/component/drawer/info/init/usePage.ts @@ -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({ + visible: false, + title: '下游系统详情', + width: 720, + spin: false, + }) + + const detail = ref(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 } +} diff --git a/src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/component/drawer/info/types/index.ts b/src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/component/drawer/info/types/index.ts new file mode 100644 index 0000000..a2959b4 --- /dev/null +++ b/src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/component/drawer/info/types/index.ts @@ -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 diff --git a/src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/component/modal/addOrEdit/addOrEdit.vue b/src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/component/modal/addOrEdit/addOrEdit.vue new file mode 100644 index 0000000..41bdbcd --- /dev/null +++ b/src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/component/modal/addOrEdit/addOrEdit.vue @@ -0,0 +1,108 @@ + + + diff --git a/src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/component/modal/addOrEdit/api/index.ts b/src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/component/modal/addOrEdit/api/index.ts new file mode 100644 index 0000000..9da7297 --- /dev/null +++ b/src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/component/modal/addOrEdit/api/index.ts @@ -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> => + postRequest('axiosRequest', BASE + '/add', params) + +export const update = (params: FormModel): Promise> => + postRequest('axiosRequest', BASE + '/update', params) diff --git a/src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/component/modal/addOrEdit/init/usePage.ts b/src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/component/modal/addOrEdit/init/usePage.ts new file mode 100644 index 0000000..ee9579e --- /dev/null +++ b/src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/component/modal/addOrEdit/init/usePage.ts @@ -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({ + visible: false, + title: '新增下游系统', + width: 720, + spin: false, + }) + + const formModel = reactive(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 } +} diff --git a/src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/component/modal/addOrEdit/types/index.ts b/src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/component/modal/addOrEdit/types/index.ts new file mode 100644 index 0000000..c21aee3 --- /dev/null +++ b/src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/component/modal/addOrEdit/types/index.ts @@ -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 diff --git a/src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/downstream.vue b/src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/downstream.vue new file mode 100644 index 0000000..f646b30 --- /dev/null +++ b/src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/downstream.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/init/usePage.ts b/src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/init/usePage.ts new file mode 100644 index 0000000..82d5475 --- /dev/null +++ b/src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/init/usePage.ts @@ -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(createSearchKey()) + + const table = reactive>({ + columns: tableColumns, + dataSource: [], + sort: { field: '', order: null }, + pagination: createPaginationConfig() as TablePaginationConfig, + }) + + const pageLoading = ref(false) + const selectedRowKeys = ref<(string | number)[]>([]) + + const addOrEditRef = ref | null>(null) + const infoDrawerRef = ref | 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, + } +} diff --git a/src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/init/useSearch.ts b/src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/init/useSearch.ts new file mode 100644 index 0000000..3578eed --- /dev/null +++ b/src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/init/useSearch.ts @@ -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 } +} diff --git a/src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/init/useTable.ts b/src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/init/useTable.ts new file mode 100644 index 0000000..1380806 --- /dev/null +++ b/src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/init/useTable.ts @@ -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 }, +] diff --git a/src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/types/index.ts b/src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/types/index.ts new file mode 100644 index 0000000..9902d5f --- /dev/null +++ b/src/pages/admin-portal/device-center/distribute/config/component/tabs/downstream/types/index.ts @@ -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 = { + none: 'default', + apikey: 'blue', + hmac: 'geekblue', + token: 'cyan', +} + +/** 重投策略 → Tag 颜色 */ +export const retryStrategyColor: Record = { + exponential: 'processing', + fixed: 'warning', + none: 'default', +} + +/** 启用状态 → Tag 颜色 */ +export const statusColor: Record = { + 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' diff --git a/src/pages/admin-portal/device-center/distribute/config/component/tabs/subscribe/api/index.ts b/src/pages/admin-portal/device-center/distribute/config/component/tabs/subscribe/api/index.ts new file mode 100644 index 0000000..9c3cac2 --- /dev/null +++ b/src/pages/admin-portal/device-center/distribute/config/component/tabs/subscribe/api/index.ts @@ -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> => + postRequest('axiosRequest', BASE + '/list', params) + +/** 新增 */ +export const add = (params: SubscribeForm): Promise> => + postRequest('axiosRequest', BASE + '/add', params) + +/** 编辑 */ +export const update = (params: SubscribeForm): Promise> => + postRequest('axiosRequest', BASE + '/update', params) + +/** 删除 */ +export const remove = (id: string): Promise> => + postRequest('axiosRequest', BASE + '/delete', { id }) + +/** 启用/禁用 */ +export const toggleStatus = (id: string): Promise> => + postRequest('axiosRequest', BASE + '/toggleStatus', { id }) + +/** 下游下拉选项 */ +export const options = (): Promise> => + postRequest('axiosRequest', BASE + '/options', {}) diff --git a/src/pages/admin-portal/device-center/distribute/config/component/tabs/subscribe/component/modal/addOrEdit/addOrEdit.vue b/src/pages/admin-portal/device-center/distribute/config/component/tabs/subscribe/component/modal/addOrEdit/addOrEdit.vue new file mode 100644 index 0000000..e41e721 --- /dev/null +++ b/src/pages/admin-portal/device-center/distribute/config/component/tabs/subscribe/component/modal/addOrEdit/addOrEdit.vue @@ -0,0 +1,52 @@ + + + diff --git a/src/pages/admin-portal/device-center/distribute/config/component/tabs/subscribe/component/modal/addOrEdit/api/index.ts b/src/pages/admin-portal/device-center/distribute/config/component/tabs/subscribe/component/modal/addOrEdit/api/index.ts new file mode 100644 index 0000000..04a06b0 --- /dev/null +++ b/src/pages/admin-portal/device-center/distribute/config/component/tabs/subscribe/component/modal/addOrEdit/api/index.ts @@ -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> => + postRequest('axiosRequest', BASE + '/add', params) + +export const update = (params: FormModel): Promise> => + postRequest('axiosRequest', BASE + '/update', params) diff --git a/src/pages/admin-portal/device-center/distribute/config/component/tabs/subscribe/component/modal/addOrEdit/init/usePage.ts b/src/pages/admin-portal/device-center/distribute/config/component/tabs/subscribe/component/modal/addOrEdit/init/usePage.ts new file mode 100644 index 0000000..8599a1c --- /dev/null +++ b/src/pages/admin-portal/device-center/distribute/config/component/tabs/subscribe/component/modal/addOrEdit/init/usePage.ts @@ -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({ + visible: false, + title: '新增订阅映射', + width: 600, + spin: false, + }) + + const formModel = reactive(createFormModel()) + const downstreamOptions = ref([]) + + 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 } +} diff --git a/src/pages/admin-portal/device-center/distribute/config/component/tabs/subscribe/component/modal/addOrEdit/types/index.ts b/src/pages/admin-portal/device-center/distribute/config/component/tabs/subscribe/component/modal/addOrEdit/types/index.ts new file mode 100644 index 0000000..8db5f6d --- /dev/null +++ b/src/pages/admin-portal/device-center/distribute/config/component/tabs/subscribe/component/modal/addOrEdit/types/index.ts @@ -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[] diff --git a/src/pages/admin-portal/device-center/distribute/config/component/tabs/subscribe/init/usePage.ts b/src/pages/admin-portal/device-center/distribute/config/component/tabs/subscribe/init/usePage.ts new file mode 100644 index 0000000..84a0a23 --- /dev/null +++ b/src/pages/admin-portal/device-center/distribute/config/component/tabs/subscribe/init/usePage.ts @@ -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(createSearchKey()) + + const table = reactive>({ + columns: tableColumns, + dataSource: [], + sort: { field: '', order: null }, + pagination: createPaginationConfig() as TablePaginationConfig, + }) + + const pageLoading = ref(false) + const downstreamOptions = ref([]) + + const addOrEditRef = ref | 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, + } +} diff --git a/src/pages/admin-portal/device-center/distribute/config/component/tabs/subscribe/init/useSearch.ts b/src/pages/admin-portal/device-center/distribute/config/component/tabs/subscribe/init/useSearch.ts new file mode 100644 index 0000000..628b330 --- /dev/null +++ b/src/pages/admin-portal/device-center/distribute/config/component/tabs/subscribe/init/useSearch.ts @@ -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 } +} diff --git a/src/pages/admin-portal/device-center/distribute/config/component/tabs/subscribe/init/useTable.ts b/src/pages/admin-portal/device-center/distribute/config/component/tabs/subscribe/init/useTable.ts new file mode 100644 index 0000000..3df5d97 --- /dev/null +++ b/src/pages/admin-portal/device-center/distribute/config/component/tabs/subscribe/init/useTable.ts @@ -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 }, +] diff --git a/src/pages/admin-portal/device-center/distribute/config/component/tabs/subscribe/subscribe.vue b/src/pages/admin-portal/device-center/distribute/config/component/tabs/subscribe/subscribe.vue new file mode 100644 index 0000000..395c260 --- /dev/null +++ b/src/pages/admin-portal/device-center/distribute/config/component/tabs/subscribe/subscribe.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/src/pages/admin-portal/device-center/distribute/config/component/tabs/subscribe/types/index.ts b/src/pages/admin-portal/device-center/distribute/config/component/tabs/subscribe/types/index.ts new file mode 100644 index 0000000..ff1b0f1 --- /dev/null +++ b/src/pages/admin-portal/device-center/distribute/config/component/tabs/subscribe/types/index.ts @@ -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' diff --git a/src/pages/admin-portal/device-center/distribute/config/config.vue b/src/pages/admin-portal/device-center/distribute/config/config.vue new file mode 100644 index 0000000..6afe451 --- /dev/null +++ b/src/pages/admin-portal/device-center/distribute/config/config.vue @@ -0,0 +1,44 @@ + + + + + diff --git a/src/pages/admin-portal/device-center/distribute/deadletter/api/index.ts b/src/pages/admin-portal/device-center/distribute/deadletter/api/index.ts new file mode 100644 index 0000000..e263e75 --- /dev/null +++ b/src/pages/admin-portal/device-center/distribute/deadletter/api/index.ts @@ -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> => + postRequest('axiosRequest', '/admin/device/distribute/deadletter/list', params) + +/** 详情查询 */ +export const info = (id: string): Promise> => + postRequest('axiosRequest', '/admin/device/distribute/deadletter/info', { id }) + +/** 顶部统计卡 */ +export const stat = (): Promise> => + postRequest('axiosRequest', '/admin/device/distribute/deadletter/stat', {}) + +/** 手动重投(单条) */ +export const retry = (id: string): Promise> => + postRequest('axiosRequest', '/admin/device/distribute/deadletter/retry', { id }) + +/** 批量重投 */ +export const batchRetry = (ids: string[]): Promise> => + postRequest('axiosRequest', '/admin/device/distribute/deadletter/batchRetry', { ids }) + +/** 永久丢弃(单条) */ +export const discard = (id: string): Promise> => + postRequest('axiosRequest', '/admin/device/distribute/deadletter/discard', { id }) + +/** 批量丢弃 */ +export const batchDiscard = (ids: string[]): Promise> => + postRequest('axiosRequest', '/admin/device/distribute/deadletter/batchDiscard', { ids }) diff --git a/src/pages/admin-portal/device-center/distribute/deadletter/component/drawer/info/api/index.ts b/src/pages/admin-portal/device-center/distribute/deadletter/component/drawer/info/api/index.ts new file mode 100644 index 0000000..5770f4e --- /dev/null +++ b/src/pages/admin-portal/device-center/distribute/deadletter/component/drawer/info/api/index.ts @@ -0,0 +1,9 @@ +/** + * 死信详情抽屉 - 接口 + */ +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { DetailInfo } from '../types' + +export const getInfo = (id: string): Promise> => + postRequest('axiosRequest', '/admin/device/distribute/deadletter/info', { id }) diff --git a/src/pages/admin-portal/device-center/distribute/deadletter/component/drawer/info/info.vue b/src/pages/admin-portal/device-center/distribute/deadletter/component/drawer/info/info.vue new file mode 100644 index 0000000..72e5089 --- /dev/null +++ b/src/pages/admin-portal/device-center/distribute/deadletter/component/drawer/info/info.vue @@ -0,0 +1,99 @@ + + + + + diff --git a/src/pages/admin-portal/device-center/distribute/deadletter/component/drawer/info/init/usePage.ts b/src/pages/admin-portal/device-center/distribute/deadletter/component/drawer/info/init/usePage.ts new file mode 100644 index 0000000..9e9cc70 --- /dev/null +++ b/src/pages/admin-portal/device-center/distribute/deadletter/component/drawer/info/init/usePage.ts @@ -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({ + visible: false, + title: '死信详情', + width: 720, + spin: false, + }) + + const detail = ref(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 } +} diff --git a/src/pages/admin-portal/device-center/distribute/deadletter/component/drawer/info/types/index.ts b/src/pages/admin-portal/device-center/distribute/deadletter/component/drawer/info/types/index.ts new file mode 100644 index 0000000..90d2c18 --- /dev/null +++ b/src/pages/admin-portal/device-center/distribute/deadletter/component/drawer/info/types/index.ts @@ -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 diff --git a/src/pages/admin-portal/device-center/distribute/deadletter/deadletter.vue b/src/pages/admin-portal/device-center/distribute/deadletter/deadletter.vue new file mode 100644 index 0000000..e0ba582 --- /dev/null +++ b/src/pages/admin-portal/device-center/distribute/deadletter/deadletter.vue @@ -0,0 +1,134 @@ + + + + + diff --git a/src/pages/admin-portal/device-center/distribute/deadletter/init/usePage.ts b/src/pages/admin-portal/device-center/distribute/deadletter/init/usePage.ts new file mode 100644 index 0000000..eda7044 --- /dev/null +++ b/src/pages/admin-portal/device-center/distribute/deadletter/init/usePage.ts @@ -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(createSearchKey()) + + const table = reactive>({ + columns: tableColumns, + dataSource: [], + sort: { field: '', order: null }, + pagination: createPaginationConfig() as TablePaginationConfig, + }) + + const pageLoading = ref(false) + const selectedRowKeys = ref<(string | number)[]>([]) + + /** 顶部统计卡数据 */ + const statCards = ref({ + pendingCount: 0, + retryingCount: 0, + recoveredCount: 0, + discardedCount: 0, + }) + + /** 详情抽屉 ref */ + const infoDrawerRef = ref | 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, + } +} diff --git a/src/pages/admin-portal/device-center/distribute/deadletter/init/useSearch.ts b/src/pages/admin-portal/device-center/distribute/deadletter/init/useSearch.ts new file mode 100644 index 0000000..0033ef8 --- /dev/null +++ b/src/pages/admin-portal/device-center/distribute/deadletter/init/useSearch.ts @@ -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 } +} diff --git a/src/pages/admin-portal/device-center/distribute/deadletter/init/useTable.ts b/src/pages/admin-portal/device-center/distribute/deadletter/init/useTable.ts new file mode 100644 index 0000000..26a82d2 --- /dev/null +++ b/src/pages/admin-portal/device-center/distribute/deadletter/init/useTable.ts @@ -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 }, +] diff --git a/src/pages/admin-portal/device-center/distribute/deadletter/types/index.ts b/src/pages/admin-portal/device-center/distribute/deadletter/types/index.ts new file mode 100644 index 0000000..317d58c --- /dev/null +++ b/src/pages/admin-portal/device-center/distribute/deadletter/types/index.ts @@ -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 = { + pending: 'warning', + retrying: 'processing', + recovered: 'success', + discarded: 'default', +} + +/** 死信状态 → 中文文本 */ +export const deadletterStatusText = (status: DeadletterStatus): string => { + const map: Record = { + pending: '待重投', + retrying: '重投中', + recovered: '已恢复', + discarded: '已丢弃', + } + return map[status] ?? '—' +} + +/** 指数退避策略说明(展示用) */ +export const RETRY_STRATEGY_TEXT = '指数退避:1min → 5min → 30min → 2h → 12h,最多 5 次后转人工' diff --git a/src/pages/admin-portal/device-center/distribute/record/api/index.ts b/src/pages/admin-portal/device-center/distribute/record/api/index.ts new file mode 100644 index 0000000..211b197 --- /dev/null +++ b/src/pages/admin-portal/device-center/distribute/record/api/index.ts @@ -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> => + postRequest('axiosRequest', '/admin/device/distribute/record/list', params) + +/** 详情查询 */ +export const info = (id: string): Promise> => + postRequest('axiosRequest', '/admin/device/distribute/record/info', { id }) + +/** 顶部统计卡 */ +export const stat = (): Promise> => + postRequest('axiosRequest', '/admin/device/distribute/record/stat', {}) + +/** 手动重投(单条) */ +export const retry = (id: string): Promise> => + postRequest('axiosRequest', '/admin/device/distribute/record/retry', { id }) + +/** 批量重投 */ +export const batchRetry = (ids: string[]): Promise> => + postRequest('axiosRequest', '/admin/device/distribute/record/batchRetry', { ids }) + +/** 导出 */ +export const exportList = (params: SearchForm): Promise> => + postRequest('axiosRequest', '/admin/device/distribute/record/export', params) diff --git a/src/pages/admin-portal/device-center/distribute/record/component/drawer/info/api/index.ts b/src/pages/admin-portal/device-center/distribute/record/component/drawer/info/api/index.ts new file mode 100644 index 0000000..5980b07 --- /dev/null +++ b/src/pages/admin-portal/device-center/distribute/record/component/drawer/info/api/index.ts @@ -0,0 +1,11 @@ +/** + * 分发记录 - 详情抽屉 - 接口层 + */ + +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { DetailInfo } from '../../../../types' + +/** 详情查询(含请求体/响应体) */ +export const getInfo = (id: string): Promise> => + postRequest('axiosRequest', '/admin/device/distribute/record/info', { id }) diff --git a/src/pages/admin-portal/device-center/distribute/record/component/drawer/info/info.vue b/src/pages/admin-portal/device-center/distribute/record/component/drawer/info/info.vue new file mode 100644 index 0000000..184ad87 --- /dev/null +++ b/src/pages/admin-portal/device-center/distribute/record/component/drawer/info/info.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/src/pages/admin-portal/device-center/distribute/record/component/drawer/info/init/usePage.ts b/src/pages/admin-portal/device-center/distribute/record/component/drawer/info/init/usePage.ts new file mode 100644 index 0000000..a6a79a7 --- /dev/null +++ b/src/pages/admin-portal/device-center/distribute/record/component/drawer/info/init/usePage.ts @@ -0,0 +1,48 @@ +/** + * 分发记录 - 详情抽屉 - 主逻辑 + */ + +import { getInfo } from '../api' +import type { DetailInfo, OpenContext, PageInfo } from '../types' + +export const usePage = () => { + const pageInfo = reactive({ + visible: false, + title: '分发详情', + width: 760, + spin: false, + }) + + const detail = ref(null) + + const closeDrawer = (): void => { + pageInfo.visible = false + detail.value = null + } + + const openDrawer = (ctx: OpenContext): void => { + pageInfo.visible = true + pageInfo.spin = true + detail.value = null + + getInfo(ctx.id) + .then((res) => { + if (res.code === '00000' && res.data) { + detail.value = res.data + } + }) + .catch((err: unknown) => { + console.error('分发详情查询失败:', err) + }) + .finally(() => { + pageInfo.spin = false + }) + } + + return { + pageInfo, + detail, + openDrawer, + closeDrawer, + } +} diff --git a/src/pages/admin-portal/device-center/distribute/record/component/drawer/info/types/index.ts b/src/pages/admin-portal/device-center/distribute/record/component/drawer/info/types/index.ts new file mode 100644 index 0000000..7ef9b3d --- /dev/null +++ b/src/pages/admin-portal/device-center/distribute/record/component/drawer/info/types/index.ts @@ -0,0 +1,13 @@ +/** 抽屉页面状态 */ +export interface PageInfo { + visible: boolean + title: string + width: number + spin: boolean +} + +/** 打开抽屉上下文 */ +export interface OpenContext { + /** 必传 */ + id: string +} diff --git a/src/pages/admin-portal/device-center/distribute/record/init/usePage.ts b/src/pages/admin-portal/device-center/distribute/record/init/usePage.ts new file mode 100644 index 0000000..43bfe6c --- /dev/null +++ b/src/pages/admin-portal/device-center/distribute/record/init/usePage.ts @@ -0,0 +1,197 @@ +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 { batchRetry, exportList, list, retry, stat } from '../api' +import type { RecordItem, SearchForm, StatCards } from '../types' +import { createSearchKey } from './useSearch' +import { tableColumns } from './useTable' + +/** + * 分发记录页主编排: + * - 顶部统计卡(今日分发 / 成功率 / 平均响应耗时 / 失败待重投) + * - 标准列表 + 详情抽屉(请求体/响应体) + * - 手动重投(单条/批量)+ 导出 + */ +export const usePage = () => { + const { message, Modal } = useAntdStaticMethods() + + const search = reactive(createSearchKey()) + + const table = reactive>({ + columns: tableColumns, + dataSource: [], + sort: { field: '', order: null }, + pagination: createPaginationConfig() as TablePaginationConfig, + }) + + const pageLoading = ref(false) + const selectedRowKeys = ref<(string | number)[]>([]) + + /** 顶部统计卡数据 */ + const statCards = ref({ + todayTotal: 0, + successRate: 0, + avgResponseTime: 0, + failedPending: 0, + }) + + /** 详情抽屉 ref */ + const infoDrawerRef = ref | 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: RecordItem): void => { + infoDrawerRef.value?.openDrawer({ id: record.id }) + } + + /** 手动重投(单条) */ + const retryRecord = (record: RecordItem): void => { + Modal.confirm({ + title: '确认重投?', + content: `分发记录 [${record.id}] 将重新回调下游 [${record.downstreamName}],确认继续?`, + okText: '确认重投', + 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 batchRetryRecord = (): 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: '确认重投', + cancelText: '取消', + onOk: () => { + batchRetry(ids) + .then((res) => { + if (res.code === '00000') { + void message.success(res.msg) + selectedRowKeys.value = [] + listRequest() + statRequest() + } + }) + .catch((err: unknown) => { + console.error('批量重投失败:', err) + }) + }, + }) + } + + /** 导出 */ + const exportRecord = (): void => { + exportList(search) + .then((res) => { + if (res.code === '00000') { + void message.success('导出成功,下载链接已生成') + } + }) + .catch((err: unknown) => { + console.error('导出失败:', err) + }) + } + + const handleAction = (type: string, record?: RecordItem): void => { + if (type === 'detail' && record) { + openInfoDrawer(record) + return + } + if (type === 'retry' && record) { + retryRecord(record) + } + } + + onMounted(() => { + listRequest() + statRequest() + }) + + return { + pageLoading, + search, + table, + selectedRowKeys, + rowSelection, + statCards, + infoDrawerRef, + searchQuery, + resetQuery, + dataSourceChange, + handleAction, + listRequest, + batchRetryRecord, + exportRecord, + } +} diff --git a/src/pages/admin-portal/device-center/distribute/record/init/useSearch.ts b/src/pages/admin-portal/device-center/distribute/record/init/useSearch.ts new file mode 100644 index 0000000..69a4a88 --- /dev/null +++ b/src/pages/admin-portal/device-center/distribute/record/init/useSearch.ts @@ -0,0 +1,9 @@ +import type { SearchForm } from '../types' + +/** 搜索表单工厂:统一重置入口 */ +export const createSearchKey = (): SearchForm => ({ + deviceCode: undefined, + downstreamId: undefined, + dataType: undefined, + status: undefined, +}) diff --git a/src/pages/admin-portal/device-center/distribute/record/init/useTable.ts b/src/pages/admin-portal/device-center/distribute/record/init/useTable.ts new file mode 100644 index 0000000..bd505ed --- /dev/null +++ b/src/pages/admin-portal/device-center/distribute/record/init/useTable.ts @@ -0,0 +1,61 @@ +import type { TableColumnsType } from 'ant-design-vue' +import { h } from 'vue' +import { Tag } from 'ant-design-vue' +import type { DataType, DispatchStatus } from '../types' +import { dataTypeText, dispatchStatusColor, dispatchStatusText } from '../types' + +/** 数据类型 → Tag 颜色 */ +const dataTypeColor: Record = { + weight: 'blue', + bodyFat: 'cyan', + heartRate: 'geekblue', + sleep: 'purple', + stepCount: 'green', + training: 'orange', + healthCheck: 'magenta', +} + +/** 分发记录列定义 */ +export const tableColumns: TableColumnsType = [ + { title: '序号', dataIndex: 'index', align: 'center', fixed: 'left', width: 70, customRender: ({ index }: { index: number }) => index + 1 }, + { 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: dataTypeColor[value] ?? 'default' }, () => dataTypeText(value)), + }, + { title: '下游系统', dataIndex: 'downstreamName', align: 'left', width: 160, resizable: true, ellipsis: true }, + { title: '分发时间', dataIndex: 'dispatchTime', align: 'center', width: 170, sorter: true, resizable: true }, + { + title: 'HTTP', + dataIndex: 'httpStatus', + align: 'center', + width: 90, + customRender: ({ value }: { value: number }) => { + const color = value >= 200 && value < 300 ? 'success' : value >= 500 ? 'error' : 'warning' + return h(Tag, { color }, () => String(value)) + }, + }, + { + title: '响应耗时', + dataIndex: 'responseTime', + align: 'center', + width: 100, + customRender: ({ value }: { value: number }) => `${value} ms`, + }, + { title: '重试', dataIndex: 'retryCount', align: 'center', width: 70 }, + { + title: '状态', + dataIndex: 'status', + align: 'center', + width: 100, + customRender: ({ value }: { value: DispatchStatus }) => + h(Tag, { color: dispatchStatusColor[value] }, () => dispatchStatusText(value)), + }, + { title: '失败原因', dataIndex: 'failureReason', align: 'left', width: 180, resizable: true, ellipsis: true }, + { title: '操作', dataIndex: 'action', align: 'center', fixed: 'right', width: 180 }, +] diff --git a/src/pages/admin-portal/device-center/distribute/record/record.vue b/src/pages/admin-portal/device-center/distribute/record/record.vue new file mode 100644 index 0000000..581e462 --- /dev/null +++ b/src/pages/admin-portal/device-center/distribute/record/record.vue @@ -0,0 +1,104 @@ + + + + + diff --git a/src/pages/admin-portal/device-center/distribute/record/types/index.ts b/src/pages/admin-portal/device-center/distribute/record/types/index.ts new file mode 100644 index 0000000..4f12119 --- /dev/null +++ b/src/pages/admin-portal/device-center/distribute/record/types/index.ts @@ -0,0 +1,101 @@ +import type { BaseSearchForm } from '@pages/admin-portal/shared/types' + +/** 分发状态 */ +export type DispatchStatus = 'success' | 'failed' | 'retrying' + +/** 数据类型 */ +export type DataType = 'weight' | 'bodyFat' | 'heartRate' | 'sleep' | 'stepCount' | 'training' | 'healthCheck' + +/** 分发记录列表项 */ +export interface RecordItem { + id: string + /** 设备编码 */ + deviceCode: string + /** 设备名称 */ + deviceName: string + /** 数据类型 */ + dataType: DataType + /** 下游系统 ID */ + downstreamId: string + /** 下游系统名称 */ + downstreamName: string + /** 分发时间 */ + dispatchTime: string + /** HTTP 状态码 */ + httpStatus: number + /** 响应耗时(毫秒) */ + responseTime: number + /** 重试次数 */ + retryCount: number + /** 分发状态 */ + status: DispatchStatus + /** 失败原因(失败/重投时填) */ + failureReason?: string +} + +/** 详情数据(含请求体/响应体) */ +export interface DetailInfo extends RecordItem { + /** 请求体(JSON 字符串) */ + requestBody: string + /** 响应体(JSON 字符串) */ + responseBody: string + /** 鉴权密钥(脱敏展示) */ + authKey: string +} + +/** 顶部统计卡 */ +export interface StatCards { + /** 今日分发总数 */ + todayTotal: number + /** 成功率(百分比) */ + successRate: number + /** 平均响应耗时(毫秒) */ + avgResponseTime: number + /** 失败待重投数 */ + failedPending: number +} + +/** 搜索表单 */ +export interface SearchForm extends BaseSearchForm { + /** 设备编码 */ + deviceCode?: string + /** 下游系统 ID */ + downstreamId?: string + /** 数据类型 */ + dataType?: DataType + /** 分发状态 */ + status?: DispatchStatus +} + +/** 数据类型下拉选项 */ +export const DATA_TYPE_OPTIONS: { label: string; value: DataType }[] = [ + { label: '体重', value: 'weight' }, + { label: '体脂', value: 'bodyFat' }, + { label: '心率', value: 'heartRate' }, + { label: '睡眠', value: 'sleep' }, + { label: '计步', value: 'stepCount' }, + { label: '训练数据', value: 'training' }, + { label: '体检数据', value: 'healthCheck' }, +] + +/** 数据类型 → 中文 */ +export const dataTypeText = (value: DataType | string | undefined): string => + DATA_TYPE_OPTIONS.find((item) => item.value === value)?.label ?? value ?? '—' + +/** 分发状态下拉选项 */ +export const DISPATCH_STATUS_OPTIONS: { label: string; value: DispatchStatus }[] = [ + { label: '成功', value: 'success' }, + { label: '失败', value: 'failed' }, + { label: '重投中', value: 'retrying' }, +] + +/** 分发状态 → Tag 颜色 */ +export const dispatchStatusColor: Record = { + success: 'success', + failed: 'error', + retrying: 'warning', +} + +/** 分发状态 → 中文 */ +export const dispatchStatusText = (value: DispatchStatus | string | undefined): string => + DISPATCH_STATUS_OPTIONS.find((item) => item.value === value)?.label ?? value ?? '—' diff --git a/src/pages/admin-portal/device-center/ops/alarm/alarm.vue b/src/pages/admin-portal/device-center/ops/alarm/alarm.vue new file mode 100644 index 0000000..66d7569 --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/alarm/alarm.vue @@ -0,0 +1,150 @@ + + + + + diff --git a/src/pages/admin-portal/device-center/ops/alarm/api/index.ts b/src/pages/admin-portal/device-center/ops/alarm/api/index.ts new file mode 100644 index 0000000..48b6055 --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/alarm/api/index.ts @@ -0,0 +1,33 @@ +/** + * 告警管理 - 接口层 + * - list 告警列表 / batchHandle 批量处理 / ignore 忽略 / toWorkOrder 转工单 + * - logList 业务日志列表 + * - stats 顶部统计 + */ +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { AlarmItem, AlarmStats, BusinessLogItem, LogSearchForm, SearchForm } from '../types' + +const BASE = '/admin/device/ops/alarm' + +export const list = (params: SearchForm): Promise> => + postRequest('axiosRequest', BASE + '/list', params) + +export const stats = (): Promise> => + postRequest('axiosRequest', BASE + '/stats', {}) + +/** 转工单 */ +export const toWorkOrder = (id: string): Promise> => + postRequest('axiosRequest', BASE + '/to-work-order', { id }) + +/** 忽略 */ +export const ignore = (id: string): Promise> => + postRequest('axiosRequest', BASE + '/ignore', { id }) + +/** 批量处理:type = ignore / workorder */ +export const batchHandle = (ids: string[], type: 'ignore' | 'workorder'): Promise> => + postRequest('axiosRequest', BASE + '/batch', { ids, type }) + +/** 业务日志列表 */ +export const logList = (params: LogSearchForm): Promise> => + postRequest('axiosRequest', BASE + '/log-list', params) diff --git a/src/pages/admin-portal/device-center/ops/alarm/init/usePage.ts b/src/pages/admin-portal/device-center/ops/alarm/init/usePage.ts new file mode 100644 index 0000000..793c4c6 --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/alarm/init/usePage.ts @@ -0,0 +1,245 @@ +/** + * 告警管理 - 页面主编排 + * - 双 Tab:告警处置 + 业务日志 + * - 转工单 / 忽略 / 批量处理 + */ +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 * as api from '../api' +import type { AlarmItem, AlarmStats, BusinessLogItem, LogSearchForm, SearchForm } from '../types' +import { createLogSearchKey, createSearchKey } from './useSearch' +import { logTableColumns, tableColumns } from './useTable' + +export const usePage = () => { + const { message, Modal } = useAntdStaticMethods() + + const activeTab = ref<'alarm' | 'log'>('alarm') + + // ===== 告警 Tab ===== + const search = reactive(createSearchKey()) + + const table = reactive>({ + columns: tableColumns, + dataSource: [], + sort: { field: '', order: null }, + pagination: createPaginationConfig() as TablePaginationConfig, + }) + + const statsData = reactive({ + todayNew: 0, pending: 0, critical: 0, weekTrend: 0, + }) + + const pageLoading = ref(false) + const selectedRowKeys = ref<(string | number)[]>([]) + + // ===== 业务日志 Tab ===== + const logSearch = reactive(createLogSearchKey()) + + const logTable = reactive>({ + columns: logTableColumns, + dataSource: [], + sort: { field: '', order: null }, + pagination: createPaginationConfig() as TablePaginationConfig, + }) + + const logLoading = ref(false) + + 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 statsRequest = (): void => { + api.stats() + .then((res) => { + if (res.code === '00000' && res.data) { + Object.assign(statsData, res.data) + } + }) + .catch((err: unknown) => { + console.error('告警统计请求失败:', err) + }) + } + + const logListRequest = (): void => { + logLoading.value = true + api.logList(logSearch) + .then((res) => { + if (res.code === '00000') { + logTable.dataSource = res.data ?? [] + logTable.pagination.total = logTable.dataSource.length + } + }) + .catch((err: unknown) => { + console.error('业务日志列表请求失败:', err) + }) + .finally(() => { + logLoading.value = false + }) + } + + const searchQuery = (): void => { + table.pagination.current = 1 + listRequest() + } + + const resetQuery = (): void => { + Object.assign(search, createSearchKey()) + table.pagination.current = 1 + listRequest() + } + + const logSearchQuery = (): void => { + logTable.pagination.current = 1 + logListRequest() + } + + const logResetQuery = (): void => { + Object.assign(logSearch, createLogSearchKey()) + logTable.pagination.current = 1 + logListRequest() + } + + const dataSourceChange = (pagination: TablePaginationConfig): void => { + table.pagination.current = pagination.current ?? 1 + table.pagination.pageSize = pagination.pageSize ?? 30 + } + + const logDataSourceChange = (pagination: TablePaginationConfig): void => { + logTable.pagination.current = pagination.current ?? 1 + logTable.pagination.pageSize = pagination.pageSize ?? 30 + } + + /** 切换 Tab */ + const switchTab = (key: string): void => { + activeTab.value = key as 'alarm' | 'log' + if (key === 'log' && !logTable.dataSource.length) logListRequest() + } + + /** 转工单 */ + const toWorkOrder = (record: AlarmItem): void => { + Modal.confirm({ + title: '确认转工单?', + content: `将基于告警 [${record.alarmCode}] 自动生成工单,并携带告警上下文。`, + okText: '确认转单', + cancelText: '取消', + onOk: () => { + api.toWorkOrder(record.id) + .then((res) => { + if (res.code === '00000') { + void message.success(res.msg) + listRequest() + } + }) + .catch((err: unknown) => { + console.error('转工单失败:', err) + }) + }, + }) + } + + /** 忽略 */ + const ignoreRecord = (record: AlarmItem): void => { + Modal.confirm({ + title: '确认忽略此告警?', + content: `告警 [${record.alarmCode}] 将被标记为"已忽略",可在工单管理中追溯。`, + okText: '确认忽略', + cancelText: '取消', + onOk: () => { + api.ignore(record.id) + .then((res) => { + if (res.code === '00000') { + void message.success(res.msg) + listRequest() + } + }) + .catch((err: unknown) => { + console.error('忽略失败:', err) + }) + }, + }) + } + + /** 批量处理 */ + const batchHandle = (type: 'ignore' | 'workorder'): void => { + if (!selectedRowKeys.value.length) return + const text = type === 'ignore' ? '忽略' : '转工单' + Modal.confirm({ + title: `确认批量${text}?`, + content: `已选择 ${selectedRowKeys.value.length} 条告警,将批量执行"${text}"操作。`, + okText: `确认${text}`, + okType: type === 'ignore' ? 'primary' : 'danger', + cancelText: '取消', + onOk: () => { + api.batchHandle(selectedRowKeys.value.map(String), type) + .then((res) => { + if (res.code === '00000') { + void message.success(res.msg) + selectedRowKeys.value = [] + listRequest() + } + }) + .catch((err: unknown) => { + console.error('批量处理失败:', err) + }) + }, + }) + } + + const handleAction = (type: string, record?: AlarmItem): void => { + if (!record) return + if (type === 'workorder') toWorkOrder(record) + if (type === 'ignore') ignoreRecord(record) + } + + const rowSelection = { + selectedRowKeys, + onChange: (keys: (string | number)[]): void => { + selectedRowKeys.value = keys + }, + } + + onMounted(() => { + listRequest() + statsRequest() + }) + + return { + activeTab, + pageLoading, + search, + table, + statsData, + selectedRowKeys, + rowSelection, + logLoading, + logSearch, + logTable, + searchQuery, + resetQuery, + logSearchQuery, + logResetQuery, + dataSourceChange, + logDataSourceChange, + switchTab, + handleAction, + batchHandle, + listRequest, + logListRequest, + } +} diff --git a/src/pages/admin-portal/device-center/ops/alarm/init/useSearch.ts b/src/pages/admin-portal/device-center/ops/alarm/init/useSearch.ts new file mode 100644 index 0000000..82f3754 --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/alarm/init/useSearch.ts @@ -0,0 +1,16 @@ +import type { LogSearchForm, SearchForm } from '../types' + +export const createSearchKey = (): SearchForm => ({ + keyword: undefined, + source: undefined, + level: undefined, + status: undefined, +}) + +export const createLogSearchKey = (): LogSearchForm => ({ + keyword: undefined, + logType: undefined, + siteName: undefined, +}) + +export const useSearch = () => ({ createSearchKey, createLogSearchKey }) diff --git a/src/pages/admin-portal/device-center/ops/alarm/init/useTable.ts b/src/pages/admin-portal/device-center/ops/alarm/init/useTable.ts new file mode 100644 index 0000000..21a4bf5 --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/alarm/init/useTable.ts @@ -0,0 +1,69 @@ +import type { TableColumnsType } from 'ant-design-vue' +import { h } from 'vue' +import { Tag } from 'ant-design-vue' +import type { AlarmItem, AlarmLevel, AlarmSource, AlarmStatus } from '../types' +import { + alarmLevelColor, + alarmLevelText, + alarmSourceText, + alarmStatusColor, + alarmStatusText, +} from '../types' + +export const tableColumns: TableColumnsType = [ + { title: '告警编码', dataIndex: 'alarmCode', align: 'left', fixed: 'left', width: 170, resizable: true, ellipsis: true }, + { + title: '来源', + dataIndex: 'source', + align: 'center', + width: 130, + customRender: ({ value }: { value: AlarmSource }) => alarmSourceText(value), + }, + { + title: '级别', + dataIndex: 'level', + align: 'center', + width: 90, + customRender: ({ value }: { value: AlarmLevel }) => + h(Tag, { color: alarmLevelColor[value] }, () => alarmLevelText(value)), + }, + { title: '设备编码', dataIndex: 'deviceCode', align: 'left', width: 150, resizable: true, ellipsis: true }, + { title: '设备名称', dataIndex: 'deviceName', align: 'left', width: 160, resizable: true, ellipsis: true }, + { title: '告警内容', dataIndex: 'content', align: 'left', width: 280, resizable: true, ellipsis: true }, + { title: '触发时间', dataIndex: 'triggerTime', align: 'center', width: 170, sorter: true, resizable: true }, + { + title: '状态', + dataIndex: 'status', + align: 'center', + width: 110, + customRender: ({ value }: { value: AlarmStatus }) => + h(Tag, { color: alarmStatusColor[value] }, () => alarmStatusText(value)), + }, + { title: '处理人', dataIndex: 'handler', align: 'center', width: 100, customRender: ({ value }: { value: string | null }) => value ?? '—' }, + { title: '操作', dataIndex: 'action', align: 'center', fixed: 'right', width: 200 }, +] + +export const logTableColumns: TableColumnsType = [ + { title: '日志类型', dataIndex: 'logType', align: 'center', width: 110, customRender: ({ value }: { value: string }) => logTypeBadge(value) }, + { title: '所属场所', dataIndex: 'siteName', align: 'left', width: 160, resizable: true, ellipsis: true }, + { title: '操作模块', dataIndex: 'module', align: 'left', width: 140, resizable: true, ellipsis: true }, + { title: '操作内容', dataIndex: 'content', align: 'left', width: 320, resizable: true, ellipsis: true }, + { title: '操作人', dataIndex: 'operator', align: 'center', width: 100 }, + { title: '操作时间', dataIndex: 'operateTime', align: 'center', width: 170, sorter: true }, +] + +const logTypeBadge = (type: string): ReturnType => { + const map: Record = { + operate: 'blue', + system: 'green', + audit: 'orange', + data: 'purple', + } + const textMap: Record = { + operate: '操作日志', + system: '系统日志', + audit: '审计日志', + data: '数据日志', + } + return h(Tag, { color: map[type] ?? 'default' }, () => textMap[type] ?? type) +} diff --git a/src/pages/admin-portal/device-center/ops/alarm/types/index.ts b/src/pages/admin-portal/device-center/ops/alarm/types/index.ts new file mode 100644 index 0000000..4185806 --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/alarm/types/index.ts @@ -0,0 +1,153 @@ +/** + * 告警管理 - 类型 + * - 告警处置(Tab 1):运行/自检/巡检/SLA/OTA/数据分发失败 等告警 + * - 业务日志(Tab 2):全量操作流水视图 + */ +import type { BaseSearchForm } from '@pages/admin-portal/shared/types' + +/** 告警来源 */ +export type AlarmSource = 'runLog' | 'selfCheck' | 'inspection' | 'sla' | 'ota' | 'dataDistribute' + +/** 告警级别 */ +export type AlarmLevel = 'info' | 'warn' | 'critical' + +/** 告警状态 */ +export type AlarmStatus = 'pending' | 'processing' | 'ignored' | 'workorder' + +/** 业务日志类型 */ +export type BusinessLogType = 'operate' | 'system' | 'audit' | 'data' + +/** 告警列表项 */ +export interface AlarmItem { + id: string + /** 告警编码 */ + alarmCode: string + /** 告警来源 */ + source: AlarmSource + /** 级别 */ + level: AlarmLevel + /** 设备编码 */ + deviceCode: string + /** 设备名称 */ + deviceName: string + /** 告警内容 */ + content: string + /** 触发时间 */ + triggerTime: string + /** 状态 */ + status: AlarmStatus + /** 处理人 */ + handler?: string +} + +/** 业务日志项 */ +export interface BusinessLogItem { + id: string + /** 日志类型 */ + logType: BusinessLogType + /** 场所 */ + siteName: string + /** 操作模块 */ + module: string + /** 操作内容 */ + content: string + /** 操作人 */ + operator: string + /** 操作时间 */ + operateTime: string + /** 日志数据(JSON) */ + data?: string +} + +/** 查询表单 */ +export interface SearchForm extends BaseSearchForm { + source?: AlarmSource + level?: AlarmLevel + status?: AlarmStatus +} + +/** 业务日志查询 */ +export interface LogSearchForm extends BaseSearchForm { + logType?: BusinessLogType + siteName?: string +} + +/** 告警来源下拉 */ +export const ALARM_SOURCE_OPTIONS: { label: string; value: AlarmSource }[] = [ + { label: '运行日志', value: 'runLog' }, + { label: '自检异常', value: 'selfCheck' }, + { label: '巡检异常', value: 'inspection' }, + { label: 'SLA 超时', value: 'sla' }, + { label: 'OTA 失败', value: 'ota' }, + { label: '数据分发失败', value: 'dataDistribute' }, +] + +/** 告警级别下拉 */ +export const ALARM_LEVEL_OPTIONS: { label: string; value: AlarmLevel }[] = [ + { label: '提示', value: 'info' }, + { label: '警告', value: 'warn' }, + { label: '严重', value: 'critical' }, +] + +/** 告警状态下拉 */ +export const ALARM_STATUS_OPTIONS: { label: string; value: AlarmStatus }[] = [ + { label: '未处理', value: 'pending' }, + { label: '处理中', value: 'processing' }, + { label: '已忽略', value: 'ignored' }, + { label: '已转工单', value: 'workorder' }, +] + +/** 业务日志类型下拉 */ +export const LOG_TYPE_OPTIONS: { label: string; value: BusinessLogType }[] = [ + { label: '操作日志', value: 'operate' }, + { label: '系统日志', value: 'system' }, + { label: '审计日志', value: 'audit' }, + { label: '数据日志', value: 'data' }, +] + +/** 来源 → 中文 */ +export const alarmSourceText = (source: AlarmSource | string | undefined): string => { + if (!source) return '—' + return ALARM_SOURCE_OPTIONS.find((i) => i.value === source)?.label ?? source +} + +/** 级别 → Tag 颜色 */ +export const alarmLevelColor: Record = { + info: 'default', + warn: 'warning', + critical: 'error', +} + +/** 状态 → Tag 颜色 */ +export const alarmStatusColor: Record = { + pending: 'error', + processing: 'processing', + ignored: 'default', + workorder: 'success', +} + +/** 状态 → 中文 */ +export const alarmStatusText = (status: AlarmStatus | string | undefined): string => { + if (!status) return '—' + return ALARM_STATUS_OPTIONS.find((i) => i.value === status)?.label ?? status +} + +/** 级别 → 中文 */ +export const alarmLevelText = (level: AlarmLevel | string | undefined): string => { + if (!level) return '—' + return ALARM_LEVEL_OPTIONS.find((i) => i.value === level)?.label ?? level +} + +/** 日志类型 → 中文 */ +export const logTypeText = (type: BusinessLogType | string | undefined): string => { + if (!type) return '—' + return LOG_TYPE_OPTIONS.find((i) => i.value === type)?.label ?? type +} + +/** 顶部统计卡 */ +export interface AlarmStats { + todayNew: number + pending: number + critical: number + weekTrend: number +} diff --git a/src/pages/admin-portal/device-center/ops/config/api/index.ts b/src/pages/admin-portal/device-center/ops/config/api/index.ts new file mode 100644 index 0000000..a56d383 --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/config/api/index.ts @@ -0,0 +1,31 @@ +/** + * 系统配置 - 接口层 + * - list 配置列表 / update 编辑 / info 回填 + * - history 变更历史列表 + */ +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { ConfigGroup, ConfigHistoryItem, ConfigItem, SearchForm } from '../types' + +const BASE = '/admin/device/ops/config' + +export const list = (params: SearchForm): Promise> => + postRequest('axiosRequest', BASE + '/list', params) + +export const info = (id: string): Promise> => + postRequest('axiosRequest', BASE + '/info', { id }) + +export const update = (params: { + id: string + configValue: string + reason?: string +}): Promise> => + postRequest('axiosRequest', BASE + '/update', params) + +/** 按 group 查询某分组的所有配置 */ +export const listByGroup = (group: ConfigGroup): Promise> => + postRequest('axiosRequest', BASE + '/list', { group }) + +/** 变更历史 */ +export const history = (configId: string): Promise> => + postRequest('axiosRequest', BASE + '/history', { configId }) diff --git a/src/pages/admin-portal/device-center/ops/config/component/drawer/history/api/index.ts b/src/pages/admin-portal/device-center/ops/config/component/drawer/history/api/index.ts new file mode 100644 index 0000000..52fbe03 --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/config/component/drawer/history/api/index.ts @@ -0,0 +1,11 @@ +/** + * 系统配置 - 变更历史抽屉 - 接口 + */ +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { ConfigHistoryItem } from '../../../../types' + +const BASE = '/admin/device/ops/config' + +export const history = (configId: string): Promise> => + postRequest('axiosRequest', BASE + '/history', { configId }) diff --git a/src/pages/admin-portal/device-center/ops/config/component/drawer/history/history.vue b/src/pages/admin-portal/device-center/ops/config/component/drawer/history/history.vue new file mode 100644 index 0000000..0ff5b89 --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/config/component/drawer/history/history.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/src/pages/admin-portal/device-center/ops/config/component/drawer/history/init/usePage.ts b/src/pages/admin-portal/device-center/ops/config/component/drawer/history/init/usePage.ts new file mode 100644 index 0000000..4894d58 --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/config/component/drawer/history/init/usePage.ts @@ -0,0 +1,55 @@ +/** + * 系统配置 - 变更历史抽屉 - 主逻辑 + * - 按 configId 拉取历史列表(按时间倒序) + * - 用 a-timeline 展示,便于追溯 + */ +import { reactive, ref } from 'vue' +import { history as historyApi } from '../api' +import type { HistoryList, OpenContext, PageInfo } from '../types' + +export const usePage = () => { + const pageInfo = reactive({ + visible: false, + title: '变更历史', + width: 720, + spin: false, + }) + + const list = ref([]) + const loading = ref(false) + + const close = (): void => { + pageInfo.visible = false + list.value = [] + } + + const openDrawer = (ctx: OpenContext): void => { + pageInfo.visible = true + pageInfo.title = ctx.title ?? '变更历史' + pageInfo.spin = true + loading.value = true + list.value = [] + + historyApi(ctx.id) + .then((res) => { + if (res.code === '00000') { + list.value = (res.data ?? []).slice().sort((a, b) => b.operateTime.localeCompare(a.operateTime)) + } + }) + .catch((err: unknown) => { + console.error('变更历史加载失败:', err) + }) + .finally(() => { + pageInfo.spin = false + loading.value = false + }) + } + + return { + pageInfo, + list, + loading, + openDrawer, + close, + } +} diff --git a/src/pages/admin-portal/device-center/ops/config/component/drawer/history/types/index.ts b/src/pages/admin-portal/device-center/ops/config/component/drawer/history/types/index.ts new file mode 100644 index 0000000..ed11d08 --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/config/component/drawer/history/types/index.ts @@ -0,0 +1,18 @@ +/** + * 系统配置 - 变更历史抽屉 - 类型 + */ +import type { ConfigHistoryItem } from '../../../../types' + +export interface PageInfo { + visible: boolean + title: string + width: number + spin: boolean +} + +export interface OpenContext { + id: string + title?: string +} + +export type HistoryList = ConfigHistoryItem[] diff --git a/src/pages/admin-portal/device-center/ops/config/component/modal/edit/api/index.ts b/src/pages/admin-portal/device-center/ops/config/component/modal/edit/api/index.ts new file mode 100644 index 0000000..949cf46 --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/config/component/modal/edit/api/index.ts @@ -0,0 +1,19 @@ +/** + * 系统配置 - 编辑弹框 - 接口 + */ +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { Form } from '../types' + +const BASE = '/admin/device/ops/config' + +/** 编辑回填:返回包含 valueType / 描述 / 单位等完整字段 */ +export const info = (id: string): Promise> => + postRequest('axiosRequest', BASE + '/info', { id }) + +export const update = (params: { + id: string + configValue: string + reason: string +}): Promise> => + postRequest('axiosRequest', BASE + '/update', params) diff --git a/src/pages/admin-portal/device-center/ops/config/component/modal/edit/edit.vue b/src/pages/admin-portal/device-center/ops/config/component/modal/edit/edit.vue new file mode 100644 index 0000000..870fc1d --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/config/component/modal/edit/edit.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/src/pages/admin-portal/device-center/ops/config/component/modal/edit/init/usePage.ts b/src/pages/admin-portal/device-center/ops/config/component/modal/edit/init/usePage.ts new file mode 100644 index 0000000..ffa49f5 --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/config/component/modal/edit/init/usePage.ts @@ -0,0 +1,122 @@ +/** + * 系统配置 - 编辑弹框 - 主逻辑 + * - 仅允许修改 configValue + reason(其余字段只读展示) + */ +import type { FormInstance, Rule } from 'ant-design-vue/es/form' +import { reactive, ref } from 'vue' +import { useAntdStaticMethods } from '@utils/antDesign/popUp' +import { info as infoApi, update } from '../api' +import type { Form, OpenContext, PageInfo } from '../types' + +const createInitForm = (): Form => ({ + id: undefined, + configKey: '', + configName: '', + configValue: '', + valueType: 'string', + unit: '', + description: '', + reason: '', +}) + +export const usePage = (emit: (e: 'load') => void) => { + const { message } = useAntdStaticMethods() + + const pageInfo = reactive({ + visible: false, + title: '编辑配置', + width: 640, + spin: false, + }) + + const formRef = ref() + const form = ref(createInitForm()) + + const rules: Record = { + configValue: [{ required: true, message: '请输入配置值', trigger: 'blur' }], + reason: [{ required: true, message: '请填写变更原因', trigger: 'blur' }], + } + + const resetForm = (): void => { + formRef.value?.clearValidate() + form.value = createInitForm() + } + + const closeModal = (): void => { + resetForm() + pageInfo.visible = false + } + + const openModal = (ctx: OpenContext): void => { + pageInfo.visible = true + pageInfo.spin = true + pageInfo.title = ctx.title ?? '编辑配置' + resetForm() + + infoApi(ctx.id) + .then((res) => { + if (res.code === '00000' && res.data) { + form.value = { + id: res.data.id, + configKey: res.data.configKey, + configName: res.data.configName, + configValue: res.data.configValue, + valueType: res.data.valueType, + unit: res.data.unit, + description: res.data.description, + reason: '', + } + } + }) + .catch((err: unknown) => { + console.error('加载配置详情失败:', err) + }) + .finally(() => { + pageInfo.spin = false + }) + } + + const submit = (): void => { + pageInfo.spin = true + formRef.value + ?.validate() + .then(() => { + if (!form.value.id) { + pageInfo.spin = false + return + } + update({ + id: form.value.id, + configValue: form.value.configValue, + reason: form.value.reason, + }) + .then((res) => { + if (res.code === '00000') { + void message.success(res.msg) + emit('load') + closeModal() + } + }) + .catch((err: unknown) => { + console.error('编辑配置失败:', err) + }) + .finally(() => { + pageInfo.spin = false + }) + }) + .catch((err: unknown) => { + pageInfo.spin = false + console.error('表单验证失败:', err) + }) + } + + return { + pageInfo, + formRef, + form, + rules, + openModal, + closeModal, + submit, + } +} diff --git a/src/pages/admin-portal/device-center/ops/config/component/modal/edit/types/index.ts b/src/pages/admin-portal/device-center/ops/config/component/modal/edit/types/index.ts new file mode 100644 index 0000000..ebc029b --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/config/component/modal/edit/types/index.ts @@ -0,0 +1,27 @@ +/** + * 系统配置 - 编辑弹框 - 类型 + */ +export interface PageInfo { + visible: boolean + title: string + width: number + spin: boolean +} + +export interface Form { + id?: string + configKey: string + configName: string + configValue: string + valueType: string + unit?: string + description: string + reason: string +} + +export interface OpenContext { + id: string + title?: string +} + +export const LABEL_COL = { style: { width: '120px', minWidth: '120px' } } as const diff --git a/src/pages/admin-portal/device-center/ops/config/config.vue b/src/pages/admin-portal/device-center/ops/config/config.vue new file mode 100644 index 0000000..9315265 --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/config/config.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/src/pages/admin-portal/device-center/ops/config/init/usePage.ts b/src/pages/admin-portal/device-center/ops/config/init/usePage.ts new file mode 100644 index 0000000..a97f006 --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/config/init/usePage.ts @@ -0,0 +1,95 @@ +/** + * 系统配置 - 页面主编排 + * - 列表(按分组过滤) + * - 编辑配置项(带变更原因) + * - 变更历史抽屉 + */ +import { onMounted, reactive, ref } from 'vue' +import type { TablePaginationConfig } from 'ant-design-vue' +import { createPaginationConfig } from '@utils/antDesign/table' +import type { TableState } from '@utils/antDesign/table' +import EditModal from '../component/modal/edit/edit.vue' +import HistoryDrawer from '../component/drawer/history/history.vue' +import * as api from '../api' +import type { ConfigItem, SearchForm } from '../types' +import { createSearchKey } from './useSearch' +import { tableColumns } from './useTable' + +export const usePage = () => { + const search = reactive(createSearchKey()) + + const table = reactive>({ + columns: tableColumns, + dataSource: [], + sort: { field: '', order: null }, + pagination: createPaginationConfig() as TablePaginationConfig, + }) + + const pageLoading = ref(false) + const editModalRef = ref | null>(null) + const historyDrawerRef = ref | 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 openEdit = (record: ConfigItem): void => { + editModalRef.value?.openModal({ id: record.id, title: `编辑配置 · ${record.configName}` }) + } + + const openHistory = (record: ConfigItem): void => { + historyDrawerRef.value?.openDrawer({ id: record.id, title: `变更历史 · ${record.configName}` }) + } + + const handleAction = (type: string, record?: ConfigItem): void => { + if (!record) return + if (type === 'edit') openEdit(record) + if (type === 'history') openHistory(record) + } + + onMounted(() => { + listRequest() + }) + + return { + pageLoading, + search, + table, + editModalRef, + historyDrawerRef, + searchQuery, + resetQuery, + dataSourceChange, + handleAction, + listRequest, + } +} diff --git a/src/pages/admin-portal/device-center/ops/config/init/useSearch.ts b/src/pages/admin-portal/device-center/ops/config/init/useSearch.ts new file mode 100644 index 0000000..2625444 --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/config/init/useSearch.ts @@ -0,0 +1,8 @@ +import type { SearchForm } from '../types' + +export const createSearchKey = (): SearchForm => ({ + keyword: undefined, + group: undefined, +}) + +export const useSearch = () => ({ createSearchKey }) diff --git a/src/pages/admin-portal/device-center/ops/config/init/useTable.ts b/src/pages/admin-portal/device-center/ops/config/init/useTable.ts new file mode 100644 index 0000000..f31c14d --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/config/init/useTable.ts @@ -0,0 +1,31 @@ +import type { TableColumnsType } from 'ant-design-vue' +import { h } from 'vue' +import { Tag } from 'ant-design-vue' +import type { ConfigGroup, ConfigItem } from '../types' +import { configGroupColor, configGroupText } from '../types' + +export const tableColumns: TableColumnsType = [ + { + title: '分组', + dataIndex: 'group', + align: 'center', + width: 120, + fixed: 'left', + customRender: ({ value }: { value: ConfigGroup }) => + h(Tag, { color: configGroupColor[value] }, () => configGroupText(value)), + }, + { title: '配置名称', dataIndex: 'configName', align: 'left', width: 180, resizable: true, ellipsis: true, fixed: 'left' }, + { title: '配置 Key', dataIndex: 'configKey', align: 'left', width: 220, resizable: true, ellipsis: true }, + { title: '当前值', dataIndex: 'configValue', align: 'left', width: 160, resizable: true, ellipsis: true }, + { title: '单位', dataIndex: 'unit', align: 'center', width: 90, customRender: ({ value }: { value?: string }) => value ?? '—' }, + { title: '描述', dataIndex: 'description', align: 'left', width: 320, resizable: true, ellipsis: true }, + { title: '最近修改人', dataIndex: 'updater', align: 'center', width: 110 }, + { title: '最近修改时间', dataIndex: 'updateTime', align: 'center', width: 170, sorter: true }, + { title: '操作', dataIndex: 'action', align: 'center', fixed: 'right', width: 200 }, +] + +/** 渲染当前值的展示格式(含单位拼接) */ +export const formatConfigValue = (record: ConfigItem): string => { + if (!record.unit) return record.configValue + return `${record.configValue} ${record.unit}` +} diff --git a/src/pages/admin-portal/device-center/ops/config/types/index.ts b/src/pages/admin-portal/device-center/ops/config/types/index.ts new file mode 100644 index 0000000..816ee4e --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/config/types/index.ts @@ -0,0 +1,81 @@ +/** + * 系统配置 - 类型定义 + * - 按分组展示全局参数(推送通道 / 限流 / 巡检自检 / SLA / OTA) + * - 支持:编辑配置项 + 查看变更历史 + */ +import type { BaseSearchForm } from '@pages/admin-portal/shared/types' + +/** 配置分组 */ +export type ConfigGroup = 'push' | 'rateLimit' | 'inspect' | 'sla' | 'ota' + +/** 值类型 */ +export type ConfigValueType = 'number' | 'string' | 'boolean' | 'json' + +/** 配置项记录 */ +export interface ConfigItem { + id: string + /** 所属分组 */ + group: ConfigGroup + /** 配置 Key */ + configKey: string + /** 配置名称 */ + configName: string + /** 当前值(按 valueType 解析) */ + configValue: string + /** 值类型 */ + valueType: ConfigValueType + /** 单位(如 次 / 秒 / %) */ + unit?: string + /** 描述 */ + description: string + /** 最近修改人 */ + updater: string + /** 最近修改时间 */ + updateTime: string +} + +/** 变更历史记录 */ +export interface ConfigHistoryItem { + id: string + /** 配置 ID */ + configId: string + /** 旧值 */ + oldValue: string + /** 新值 */ + newValue: string + /** 变更人 */ + operator: string + /** 变更时间 */ + operateTime: string + /** 变更原因 */ + reason: string +} + +/** 查询表单 */ +export interface SearchForm extends BaseSearchForm { + group?: ConfigGroup +} + +/** 配置分组下拉 */ +export const CONFIG_GROUP_OPTIONS: { label: string; value: ConfigGroup }[] = [ + { label: '推送通道', value: 'push' }, + { label: '限流策略', value: 'rateLimit' }, + { label: '巡检 / 自检', value: 'inspect' }, + { label: 'SLA 策略', value: 'sla' }, + { label: 'OTA 策略', value: 'ota' }, +] + +/** 分组 → 中文 */ +export const configGroupText = (group: ConfigGroup | string | undefined): string => { + if (!group) return '—' + return CONFIG_GROUP_OPTIONS.find((i) => i.value === group)?.label ?? group +} + +/** 分组 → Tag 颜色 */ +export const configGroupColor: Record = { + push: 'blue', + rateLimit: 'orange', + inspect: 'green', + sla: 'purple', + ota: 'cyan', +} diff --git a/src/pages/admin-portal/device-center/ops/init/api/index.ts b/src/pages/admin-portal/device-center/ops/init/api/index.ts new file mode 100644 index 0000000..5110fb4 --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/init/api/index.ts @@ -0,0 +1,31 @@ +/** + * 设备初始化 - 接口层 + * - list 列表查询 + * - info 详情查询 + * - add 新增 / update 编辑 / remove 删除 + * - retry 重新初始化 + */ +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { InitDetail, InitItem, SearchForm } from '../types' + +const BASE = '/admin/device/ops/init' + +export const list = (params: SearchForm): Promise> => + postRequest('axiosRequest', BASE + '/list', params) + +export const info = (id: string): Promise> => + postRequest('axiosRequest', BASE + '/info', { id }) + +export const add = (params: Record): Promise> => + postRequest('axiosRequest', BASE + '/add', params) + +export const update = (params: Record): Promise> => + postRequest('axiosRequest', BASE + '/update', params) + +export const remove = (id: string): Promise> => + postRequest('axiosRequest', BASE + '/delete', { id }) + +/** 触发重新初始化(仅 failed 状态可用) */ +export const retry = (id: string): Promise> => + postRequest('axiosRequest', BASE + '/retry', { id }) diff --git a/src/pages/admin-portal/device-center/ops/init/component/drawer/info/api/index.ts b/src/pages/admin-portal/device-center/ops/init/component/drawer/info/api/index.ts new file mode 100644 index 0000000..88b8153 --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/init/component/drawer/info/api/index.ts @@ -0,0 +1,10 @@ +/** + * 设备初始化 - 详情抽屉 - 接口层 + * - 复用主页 info 接口 + */ +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { InitDetail } from '../../../../types' + +export const getInfo = (id: string): Promise> => + postRequest('axiosRequest', '/admin/device/ops/init/info', { id }) diff --git a/src/pages/admin-portal/device-center/ops/init/component/drawer/info/info.vue b/src/pages/admin-portal/device-center/ops/init/component/drawer/info/info.vue new file mode 100644 index 0000000..236df56 --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/init/component/drawer/info/info.vue @@ -0,0 +1,139 @@ + + + + + diff --git a/src/pages/admin-portal/device-center/ops/init/component/drawer/info/init/usePage.ts b/src/pages/admin-portal/device-center/ops/init/component/drawer/info/init/usePage.ts new file mode 100644 index 0000000..d99333d --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/init/component/drawer/info/init/usePage.ts @@ -0,0 +1,52 @@ +/** + * 设备初始化 - 详情抽屉 - 主逻辑 + */ +import { reactive, ref } from 'vue' +import { getInfo } from '../api' +import type { InitDetail } from '../../../../types' +import type { OpenContext, PageInfo } from '../types' + +export const usePage = () => { + const pageInfo = reactive({ + visible: false, + title: '初始化任务详情', + spin: false, + width: 880, + }) + + const detail = ref(null) + + 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 = (ctx: OpenContext): void => { + detail.value = null + pageInfo.visible = true + pageInfo.title = ctx.title ?? '初始化任务详情' + loadData(ctx.id) + } + + const closeDrawer = (): void => { + pageInfo.visible = false + } + + return { + pageInfo, + detail, + openDrawer, + closeDrawer, + } +} diff --git a/src/pages/admin-portal/device-center/ops/init/component/drawer/info/types/index.ts b/src/pages/admin-portal/device-center/ops/init/component/drawer/info/types/index.ts new file mode 100644 index 0000000..dbb8413 --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/init/component/drawer/info/types/index.ts @@ -0,0 +1,29 @@ +/** + * 设备初始化 - 详情抽屉 - 类型 + * - 严格遵循 drawer-spec.md(B 类型:详情抽屉) + */ +import type { InitDetail, InitItem, InitStatus } from '../../../../types' + +export interface PageInfo { + visible: boolean + title: string + spin: boolean + width: number +} + +/** 抽屉打开上下文 */ +export interface OpenContext { + id: string + title?: string +} + +/** 步骤明细行 */ +export interface StepRow extends InitItem { + stepName: string + status: InitStatus + costSeconds: number + finishTime: string | null + remark?: string +} + +export type { InitDetail } diff --git a/src/pages/admin-portal/device-center/ops/init/component/modal/addOrEdit/addOrEdit.vue b/src/pages/admin-portal/device-center/ops/init/component/modal/addOrEdit/addOrEdit.vue new file mode 100644 index 0000000..70dc87e --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/init/component/modal/addOrEdit/addOrEdit.vue @@ -0,0 +1,171 @@ + + + + + diff --git a/src/pages/admin-portal/device-center/ops/init/component/modal/addOrEdit/api/index.ts b/src/pages/admin-portal/device-center/ops/init/component/modal/addOrEdit/api/index.ts new file mode 100644 index 0000000..6375d0a --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/init/component/modal/addOrEdit/api/index.ts @@ -0,0 +1,18 @@ +/** + * 设备初始化 - 新增/编辑弹框 - 接口层 + * - 复用主页 add/update/info 接口 + */ +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { Form } from '../types' + +const BASE = '/admin/device/ops/init' + +export const info = (id: string): Promise> => + postRequest('axiosRequest', BASE + '/info-for-edit', { id }) + +export const add = (params: Record): Promise> => + postRequest('axiosRequest', BASE + '/add', params) + +export const edit = (params: Record): Promise> => + postRequest('axiosRequest', BASE + '/update', params) diff --git a/src/pages/admin-portal/device-center/ops/init/component/modal/addOrEdit/init/usePage.ts b/src/pages/admin-portal/device-center/ops/init/component/modal/addOrEdit/init/usePage.ts new file mode 100644 index 0000000..4c80a59 --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/init/component/modal/addOrEdit/init/usePage.ts @@ -0,0 +1,162 @@ +/** + * 设备初始化 - 新增/编辑弹框 - 主逻辑 + */ +import type { FormInstance, Rule } from 'ant-design-vue/es/form' +import { useAntdStaticMethods } from '@utils/antDesign/popUp' +import { add, edit, info } from '../api' +import type { DeviceCategory, InitStatus } from '../../../../types' +import { DEVICE_CATEGORY_OPTIONS, INIT_STATUS_OPTIONS } from '../../../../types' +import type { Form, FormOptions, ModalType, OpenContext, PageInfo } from '../types' + +const createInitForm = (): Form => ({ + id: undefined, + deviceSn: '', + deviceName: '', + deviceCategory: 'weightScale', + targetSite: '', + outboundOrderNo: undefined, + baselineFw: 'V1.0.0', + baselineApp: 'V1.0.0', + activateCode: '', + status: 'pending', +}) + +const createInitOptions = (): FormOptions => ({ + deviceCategory: DEVICE_CATEGORY_OPTIONS, + status: INIT_STATUS_OPTIONS, +}) + +export const usePage = (emit: (e: 'load') => void) => { + const { message } = useAntdStaticMethods() + + const pageInfo = reactive({ + visible: false, + type: 'add', + title: '', + width: 720, + spin: false, + }) + + const formRef = ref() + const form = ref(createInitForm()) + const formOptions = ref(createInitOptions()) + + const rules: Record = { + deviceSn: [{ required: true, message: '请输入设备 SN', trigger: 'blur' }], + deviceName: [{ required: true, message: '请输入设备名称', trigger: 'blur' }], + deviceCategory: [{ required: true, message: '请选择设备类型', trigger: 'change' }], + targetSite: [{ required: true, message: '请输入目标场所', trigger: 'blur' }], + baselineFw: [{ required: true, message: '请输入固件基线版本', trigger: 'blur' }], + baselineApp: [{ required: true, message: '请输入 APP 基线版本', trigger: 'blur' }], + activateCode: [{ required: true, message: '请输入或生成激活码', trigger: 'blur' }], + } + + const resetForm = (): void => { + formRef.value?.clearValidate() + form.value = createInitForm() + } + + const closeModal = (): void => { + resetForm() + pageInfo.visible = false + } + + /** 生成激活码 */ + const genActivateCode = (): void => { + const random = Math.random().toString(36).slice(2, 10).toUpperCase() + const ts = Date.now().toString(36).toUpperCase().slice(-6) + form.value.activateCode = `ACT-${ts}-${random}` + } + + const infoRequest = (id: string): Promise<{ code: string; data: Form }> => info(id) + + const openModal = (type: ModalType, ctx?: OpenContext): void => { + pageInfo.visible = true + pageInfo.spin = true + pageInfo.type = type + resetForm() + pageInfo.title = type === 'add' ? '新增初始化任务' : '编辑初始化任务' + + const requests: Promise[] = [] + 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') { + form.value = { ...createInitForm(), ...infoRes.data } + } + } else if (type === 'add') { + /** 新增时预生成激活码 */ + genActivateCode() + } + }) + .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): 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): 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 = JSON.parse(JSON.stringify(form.value)) as Record + if (params.id) editRequest(params) + else addRequest(params) + }) + .catch((err: unknown) => { + pageInfo.spin = false + console.error('表单验证失败:', err) + }) + } + + return { + pageInfo, + formRef, + form, + formOptions, + rules, + openModal, + closeModal, + submit, + genActivateCode, + } +} diff --git a/src/pages/admin-portal/device-center/ops/init/component/modal/addOrEdit/types/index.ts b/src/pages/admin-portal/device-center/ops/init/component/modal/addOrEdit/types/index.ts new file mode 100644 index 0000000..6526bd1 --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/init/component/modal/addOrEdit/types/index.ts @@ -0,0 +1,50 @@ +/** + * 设备初始化 - 新增/编辑弹框 - 类型定义 + * 严格遵循 modal-spec.md + */ +import type { SelectProps } from 'ant-design-vue' +import type { DeviceCategory, InitStatus } from '../../../../types' + +export type ModalType = 'add' | 'edit' + +export interface PageInfo { + visible: boolean + type: ModalType + title: string + width: number + spin: boolean +} + +/** 表单数据 */ +export interface Form { + id?: string + /** 设备 SN */ + deviceSn: string + /** 设备名称 */ + deviceName: string + /** 设备类型 */ + deviceCategory: DeviceCategory + /** 目标场所 */ + targetSite: string + /** 关联出库单号 */ + outboundOrderNo?: string + /** 固件基线版本 */ + baselineFw: string + /** APP 基线版本 */ + baselineApp: string + /** 激活码 */ + activateCode: string + /** 状态(默认 pending) */ + status: InitStatus +} + +export interface FormOptions { + deviceCategory: SelectProps['options'] + status: SelectProps['options'] +} + +export interface OpenContext { + id?: string +} + +export const LABEL_COL = { style: { width: '120px', minWidth: '120px' } } as const diff --git a/src/pages/admin-portal/device-center/ops/init/init.vue b/src/pages/admin-portal/device-center/ops/init/init.vue new file mode 100644 index 0000000..b570d6c --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/init/init.vue @@ -0,0 +1,141 @@ + + + + + diff --git a/src/pages/admin-portal/device-center/ops/init/init/usePage.ts b/src/pages/admin-portal/device-center/ops/init/init/usePage.ts new file mode 100644 index 0000000..3f2b9f7 --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/init/init/usePage.ts @@ -0,0 +1,173 @@ +/** + * 设备初始化 - 页面主编排 + * - 列表查询 / 新增 / 编辑 / 详情 / 删除 / 重新初始化 + * - 重新初始化仅失败状态可用 + */ +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 { InitItem, SearchForm } from '../types' +import { createSearchKey } from './useSearch' +import { tableColumns } from './useTable' + +export const usePage = () => { + const { message, Modal } = useAntdStaticMethods() + + const search = reactive(createSearchKey()) + + const table = reactive>({ + columns: tableColumns, + dataSource: [], + sort: { field: '', order: null }, + pagination: createPaginationConfig() as TablePaginationConfig, + }) + + const pageLoading = ref(false) + const selectedRowKeys = ref<(string | number)[]>([]) + + /** 子组件 ref(强制在 usePage.ts 中定义) */ + const addOrEditRef = ref | null>(null) + const infoDrawerRef = ref | 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 openAdd = (): void => { + addOrEditRef.value?.openModal('add') + } + + /** 打开编辑 */ + const openEdit = (record: InitItem): void => { + addOrEditRef.value?.openModal('edit', { id: record.id }) + } + + /** 打开详情 */ + const openDetail = (record: InitItem): void => { + infoDrawerRef.value?.openDrawer({ id: record.id, title: `初始化任务 · ${record.taskCode}` }) + } + + /** 重新初始化(仅 failed 可用) */ + const retryInit = (record: InitItem): void => { + Modal.confirm({ + title: '确认重新初始化?', + content: `将对设备 [${record.deviceSn}] 重新触发初始化任务,确认继续?`, + okText: '确认重试', + okType: 'danger', + cancelText: '取消', + onOk: () => { + api.retry(record.id) + .then((res) => { + if (res.code === '00000') { + void message.success(res.msg) + listRequest() + } + }) + .catch((err: unknown) => { + console.error('重试失败:', err) + }) + }, + }) + } + + /** 删除(归档) */ + const deleteRecord = (record: InitItem): void => { + Modal.confirm({ + title: '确认删除?', + content: `任务 [${record.taskCode}] 删除后将归档,不可恢复。`, + okType: 'danger', + okText: '确认删除', + cancelText: '取消', + onOk: () => { + api.remove(record.id) + .then((res) => { + if (res.code === '00000') { + void message.success(res.msg) + listRequest() + } + }) + .catch((err: unknown) => { + console.error('删除失败:', err) + }) + }, + }) + } + + /** 是否允许重新初始化(仅 failed) */ + const canRetry = (status: string): boolean => status === 'failed' + + /** 统一操作入口 */ + const handleAction = (type: string, record?: InitItem): void => { + if (type === 'add') { + openAdd() + return + } + if (!record) return + if (type === 'detail') openDetail(record) + if (type === 'edit') openEdit(record) + if (type === 'retry') retryInit(record) + if (type === 'delete') deleteRecord(record) + } + + const rowSelection = { + selectedRowKeys, + onChange: (keys: (string | number)[]): void => { + selectedRowKeys.value = keys + }, + } + + onMounted(() => { + listRequest() + }) + + return { + pageLoading, + search, + table, + selectedRowKeys, + rowSelection, + addOrEditRef, + infoDrawerRef, + searchQuery, + resetQuery, + dataSourceChange, + handleAction, + canRetry, + listRequest, + } +} diff --git a/src/pages/admin-portal/device-center/ops/init/init/useSearch.ts b/src/pages/admin-portal/device-center/ops/init/init/useSearch.ts new file mode 100644 index 0000000..d12eb6e --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/init/init/useSearch.ts @@ -0,0 +1,10 @@ +import type { SearchForm } from '../types' + +export const createSearchKey = (): SearchForm => ({ + keyword: undefined, + deviceSn: undefined, + deviceCategory: undefined, + status: undefined, +}) + +export const useSearch = () => ({ createSearchKey }) diff --git a/src/pages/admin-portal/device-center/ops/init/init/useTable.ts b/src/pages/admin-portal/device-center/ops/init/init/useTable.ts new file mode 100644 index 0000000..1de68d3 --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/init/init/useTable.ts @@ -0,0 +1,36 @@ +import type { TableColumnsType } from 'ant-design-vue' +import { h } from 'vue' +import { Tag } from 'ant-design-vue' +import type { DeviceCategory, InitItem, InitStatus } from '../types' +import { + deviceCategoryText, + initStatusColor, + initStatusText, +} from '../types' + +export const tableColumns: TableColumnsType = [ + { title: '任务号', dataIndex: 'taskCode', align: 'left', fixed: 'left', width: 160, resizable: true, ellipsis: true }, + { title: '设备SN', dataIndex: 'deviceSn', align: 'left', width: 150, resizable: true, ellipsis: true }, + { title: '设备名称', dataIndex: 'deviceName', align: 'left', width: 160, resizable: true, ellipsis: true }, + { + title: '设备类型', + dataIndex: 'deviceCategory', + align: 'center', + width: 110, + customRender: ({ value }: { value: DeviceCategory }) => h(Tag, { color: 'blue' }, () => deviceCategoryText(value)), + }, + { title: '目标场所', dataIndex: 'targetSite', align: 'left', width: 160, resizable: true, ellipsis: true }, + { title: '固件基线', dataIndex: 'baselineFw', align: 'left', width: 120 }, + { title: 'APP 基线', dataIndex: 'baselineApp', align: 'left', width: 120 }, + { title: '关联出库单', dataIndex: 'outboundOrderNo', align: 'left', width: 150, ellipsis: true, customRender: ({ value }: { value: string | null }) => value ?? '—' }, + { title: '操作人', dataIndex: 'operator', align: 'center', width: 100 }, + { title: '初始化时间', dataIndex: 'initTime', align: 'center', width: 170, customRender: ({ value }: { value: string | null }) => value ?? '—' }, + { + title: '状态', + dataIndex: 'status', + align: 'center', + width: 110, + customRender: ({ value }: { value: InitStatus }) => h(Tag, { color: initStatusColor[value] }, () => initStatusText(value)), + }, + { title: '操作', dataIndex: 'action', align: 'center', fixed: 'right', width: 200 }, +] diff --git a/src/pages/admin-portal/device-center/ops/init/types/index.ts b/src/pages/admin-portal/device-center/ops/init/types/index.ts new file mode 100644 index 0000000..d10b74c --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/init/types/index.ts @@ -0,0 +1,113 @@ +/** + * 设备初始化 - 类型 + * - 出库装机前激活 / 烧录证书 / 预置场所配置 / 烧录固件基线版本 + * - 任务型列表,运维工程师视角 + */ +import type { BaseSearchForm } from '@pages/admin-portal/shared/types' + +/** 初始化任务状态 */ +export type InitStatus = 'pending' | 'processing' | 'success' | 'failed' + +/** 设备类型(与设备资产 5 顶级分类对齐) */ +export type DeviceCategory = 'weightScale' | 'bodyFat' | 'watch' | 'healthCheck' | 'cabinet' | 'other' + +/** 列表项 */ +export interface InitItem { + id: string + /** 任务号 */ + taskCode: string + /** 设备 SN */ + deviceSn: string + /** 设备名称 */ + deviceName: string + /** 设备类型 */ + deviceCategory: DeviceCategory + /** 目标场所 */ + targetSite: string + /** 固件基线版本 */ + baselineFw: string + /** APP 基线版本 */ + baselineApp: string + /** 激活码 */ + activateCode: string + /** 状态 */ + status: InitStatus + /** 操作人 */ + operator: string + /** 初始化时间 */ + initTime: string | null + /** 失败原因(status=failed 时填充) */ + failReason?: string + /** 关联出库单号 */ + outboundOrderNo?: string + /** 创建时间 */ + createTime: string +} + +/** 详情:在列表项基础上扩展 */ +export interface InitDetail extends InitItem { + /** 证书载荷(脱敏展示) */ + certPayload: string + /** 场所配置快照(JSON) */ + siteConfig: string + /** 初始化策略 */ + strategy: string + /** 步骤明细 */ + steps: InitStepItem[] +} + +/** 初始化步骤明细 */ +export interface InitStepItem { + id: string + /** 步骤名称 */ + stepName: string + /** 步骤状态 */ + status: InitStatus + /** 耗时(秒) */ + costSeconds: number + /** 完成时间 */ + finishTime: string | null + /** 备注 */ + remark?: string +} + +/** 查询表单 */ +export interface SearchForm extends BaseSearchForm { + deviceSn?: string + deviceCategory?: DeviceCategory + status?: InitStatus +} + +/** 设备类型下拉 */ +export const DEVICE_CATEGORY_OPTIONS: { label: string; value: DeviceCategory }[] = [ + { label: '体重秤', value: 'weightScale' }, + { label: '体脂仪', value: 'bodyFat' }, + { label: '智能手表', value: 'watch' }, + { label: '体检一体机', value: 'healthCheck' }, + { label: '净菜柜', value: 'cabinet' }, + { label: '其他', value: 'other' }, +] + +/** 初始化状态下拉 */ +export const INIT_STATUS_OPTIONS: { label: string; value: InitStatus }[] = [ + { label: '待初始化', value: 'pending' }, + { label: '初始化中', value: 'processing' }, + { label: '已完成', value: 'success' }, + { label: '失败', value: 'failed' }, +] + +/** 状态 → Tag 颜色 */ +export const initStatusColor: Record = { + pending: 'default', + processing: 'processing', + success: 'success', + failed: 'error', +} + +/** 设备类型 → 中文 */ +export const deviceCategoryText = (value: DeviceCategory | string | undefined): string => + DEVICE_CATEGORY_OPTIONS.find((i) => i.value === value)?.label ?? value ?? '—' + +/** 状态 → 中文 */ +export const initStatusText = (value: InitStatus | string | undefined): string => + INIT_STATUS_OPTIONS.find((i) => i.value === value)?.label ?? value ?? '—' diff --git a/src/pages/admin-portal/device-center/ops/monitor/api/index.ts b/src/pages/admin-portal/device-center/ops/monitor/api/index.ts new file mode 100644 index 0000000..4dd5288 --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/monitor/api/index.ts @@ -0,0 +1,25 @@ +/** + * 设备监测 - 接口层 + * - list 主列表 + * - stats 顶部统计卡 + * - info 详情(含 6 Tab 聚合数据) + * - remoteActions 远程下发:自检 / 重启 / OTA 升级 + */ +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { MonitorDetail, MonitorItem, MonitorStats, SearchForm } from '../types' + +const BASE = '/admin/device/ops/monitor' + +export const list = (params: SearchForm): Promise> => + postRequest('axiosRequest', BASE + '/list', params) + +export const stats = (): Promise> => + postRequest('axiosRequest', BASE + '/stats', {}) + +export const info = (id: string): Promise> => + postRequest('axiosRequest', BASE + '/info', { id }) + +/** 远程下发:type = self-check / restart / ota */ +export const remoteAction = (id: string, type: 'self-check' | 'restart' | 'ota', version?: string): Promise> => + postRequest('axiosRequest', BASE + '/remote', { id, type, version }) diff --git a/src/pages/admin-portal/device-center/ops/monitor/component/drawer/info/api/index.ts b/src/pages/admin-portal/device-center/ops/monitor/component/drawer/info/api/index.ts new file mode 100644 index 0000000..ed8517d --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/monitor/component/drawer/info/api/index.ts @@ -0,0 +1,9 @@ +/** + * 设备监测 - 详情抽屉 - 接口层 + */ +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { MonitorDetail } from '../../../../types' + +export const getInfo = (id: string): Promise> => + postRequest('axiosRequest', '/admin/device/ops/monitor/info', { id }) diff --git a/src/pages/admin-portal/device-center/ops/monitor/component/drawer/info/info.vue b/src/pages/admin-portal/device-center/ops/monitor/component/drawer/info/info.vue new file mode 100644 index 0000000..026a4df --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/monitor/component/drawer/info/info.vue @@ -0,0 +1,292 @@ + + + + + diff --git a/src/pages/admin-portal/device-center/ops/monitor/component/drawer/info/init/usePage.ts b/src/pages/admin-portal/device-center/ops/monitor/component/drawer/info/init/usePage.ts new file mode 100644 index 0000000..58f24b3 --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/monitor/component/drawer/info/init/usePage.ts @@ -0,0 +1,67 @@ +/** + * 设备监测 - 详情抽屉 - 主逻辑 + * - 详情聚合 6 Tab:实施/自检/巡检/运行日志/更新记录/维修记录 + */ +import { reactive, ref } from 'vue' +import { getInfo } from '../api' +import type { MonitorDetail } from '../../../../types' +import type { OpenContext, PageInfo } from '../types' + +export const TAB_LIST = [ + { key: 'archive', label: '基础档案' }, + { key: 'implement', label: '实施记录' }, + { key: 'selfCheck', label: '自检记录' }, + { key: 'inspection', label: '巡检记录' }, + { key: 'runLog', label: '运行日志' }, + { key: 'update', label: '更新记录' }, + { key: 'repair', label: '维修记录' }, +] as const + +export const usePage = () => { + const pageInfo = reactive({ + visible: false, + title: '设备监测详情', + spin: false, + width: 1080, + }) + + const detail = ref(null) + const activeTab = ref('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 = (ctx: OpenContext): void => { + detail.value = null + activeTab.value = 'archive' + pageInfo.visible = true + pageInfo.title = ctx.title ?? '设备监测详情' + loadData(ctx.id) + } + + const closeDrawer = (): void => { + pageInfo.visible = false + } + + return { + pageInfo, + detail, + activeTab, + tabList: TAB_LIST, + openDrawer, + closeDrawer, + } +} diff --git a/src/pages/admin-portal/device-center/ops/monitor/component/drawer/info/types/index.ts b/src/pages/admin-portal/device-center/ops/monitor/component/drawer/info/types/index.ts new file mode 100644 index 0000000..90f676a --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/monitor/component/drawer/info/types/index.ts @@ -0,0 +1,19 @@ +/** + * 设备监测 - 详情抽屉 - 类型 + * - 严格遵循 drawer-spec.md(B 类型:详情抽屉) + */ +import type { MonitorDetail } from '../../../../types' + +export interface PageInfo { + visible: boolean + title: string + spin: boolean + width: number +} + +export interface OpenContext { + id: string + title?: string +} + +export type { MonitorDetail } diff --git a/src/pages/admin-portal/device-center/ops/monitor/init/usePage.ts b/src/pages/admin-portal/device-center/ops/monitor/init/usePage.ts new file mode 100644 index 0000000..3406079 --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/monitor/init/usePage.ts @@ -0,0 +1,132 @@ +/** + * 设备监测 - 页面主编排 + * - 顶部统计卡 + 列表 + * - 详情抽屉聚合 6 Tab + * - 远程下发:自检 / 重启 / OTA + */ +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 * as api from '../api' +import type { MonitorItem, MonitorStats, SearchForm } from '../types' +import { createSearchKey } from './useSearch' +import { tableColumns } from './useTable' + +export const usePage = () => { + const { message, Modal } = useAntdStaticMethods() + + const search = reactive(createSearchKey()) + + const table = reactive>({ + columns: tableColumns, + dataSource: [], + sort: { field: '', order: null }, + pagination: createPaginationConfig() as TablePaginationConfig, + }) + + const statsData = reactive({ + total: 0, online: 0, offline: 0, alerting: 0, repairing: 0, + }) + + const pageLoading = ref(false) + const infoDrawerRef = ref | 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 statsRequest = (): void => { + api.stats() + .then((res) => { + if (res.code === '00000' && res.data) { + Object.assign(statsData, 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 openDetail = (record: MonitorItem): void => { + infoDrawerRef.value?.openDrawer({ id: record.id, title: `设备监测 · ${record.deviceName}` }) + } + + /** 远程下发 */ + const remoteAction = (record: MonitorItem, type: 'self-check' | 'restart' | 'ota'): void => { + const actionText = type === 'self-check' ? '远程自检' : type === 'restart' ? '远程重启' : '远程 OTA 升级' + Modal.confirm({ + title: `确认${actionText}?`, + content: `目标设备:${record.deviceName}(${record.deviceCode})`, + okText: '确认下发', + cancelText: '取消', + onOk: () => { + api.remoteAction(record.id, type) + .then((res) => { + if (res.code === '00000') { + void message.success(`${actionText}指令已下发`) + listRequest() + } + }) + .catch((err: unknown) => { + console.error(`${actionText}失败:`, err) + }) + }, + }) + } + + const handleAction = (type: string, record?: MonitorItem): void => { + if (!record) return + if (type === 'detail') openDetail(record) + if (type === 'self-check' || type === 'restart' || type === 'ota') remoteAction(record, type) + } + + onMounted(() => { + listRequest() + statsRequest() + }) + + return { + pageLoading, + search, + table, + statsData, + infoDrawerRef, + searchQuery, + resetQuery, + dataSourceChange, + handleAction, + listRequest, + } +} diff --git a/src/pages/admin-portal/device-center/ops/monitor/init/useSearch.ts b/src/pages/admin-portal/device-center/ops/monitor/init/useSearch.ts new file mode 100644 index 0000000..c3816a7 --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/monitor/init/useSearch.ts @@ -0,0 +1,12 @@ +import type { SearchForm } from '../types' + +export const createSearchKey = (): SearchForm => ({ + keyword: undefined, + deviceCategory: undefined, + runStatus: undefined, + tenantName: undefined, + siteName: undefined, + hasAlert: undefined, +}) + +export const useSearch = () => ({ createSearchKey }) diff --git a/src/pages/admin-portal/device-center/ops/monitor/init/useTable.ts b/src/pages/admin-portal/device-center/ops/monitor/init/useTable.ts new file mode 100644 index 0000000..9316ee6 --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/monitor/init/useTable.ts @@ -0,0 +1,58 @@ +import type { TableColumnsType } from 'ant-design-vue' +import { h } from 'vue' +import { Tag } from 'ant-design-vue' +import type { MonitorItem, RunStatus } from '../types' +import { + deviceCategoryText, + runStatusColor, + runStatusText, +} from '../types' + +export const tableColumns: TableColumnsType = [ + { title: '设备编码', dataIndex: 'deviceCode', align: 'left', fixed: 'left', width: 150, resizable: true, ellipsis: true }, + { title: '设备名称', dataIndex: 'deviceName', align: 'left', width: 160, resizable: true, ellipsis: true }, + { + title: '设备类型', + dataIndex: 'deviceCategory', + align: 'center', + width: 130, + customRender: ({ value }: { value: MonitorItem['deviceCategory'] }) => + h(Tag, { color: 'blue' }, () => deviceCategoryText(value)), + }, + { title: '所属租户', dataIndex: 'tenantName', align: 'left', width: 150, resizable: true, ellipsis: true }, + { title: '所属场所', dataIndex: 'siteName', align: 'left', width: 150, resizable: true, ellipsis: true }, + { + title: '运行状态', + dataIndex: 'runStatus', + align: 'center', + width: 100, + customRender: ({ value }: { value: RunStatus }) => + h(Tag, { color: runStatusColor[value] }, () => runStatusText(value)), + }, + { title: '固件版本', dataIndex: 'firmwareVersion', align: 'center', width: 110 }, + { title: '最近心跳', dataIndex: 'lastHeartbeat', align: 'center', width: 170, sorter: true, resizable: true }, + { + title: '在线率', + dataIndex: 'onlineRate', + align: 'center', + width: 100, + customRender: ({ value }: { value: number }) => `${value ?? 0}%`, + }, + { + title: '告警', + dataIndex: 'alertCount', + align: 'center', + width: 80, + customRender: ({ value }: { value: number }) => + value > 0 ? h(Tag, { color: 'error' }, () => `${value}`) : h('span', '0'), + }, + { + title: '数据分发', + dataIndex: 'dataTransit', + align: 'center', + width: 100, + customRender: ({ value }: { value: boolean }) => + h(Tag, { color: value ? 'green' : 'default' }, () => (value ? '已开启' : '未开启')), + }, + { title: '操作', dataIndex: 'action', align: 'center', fixed: 'right', width: 240 }, +] diff --git a/src/pages/admin-portal/device-center/ops/monitor/monitor.vue b/src/pages/admin-portal/device-center/ops/monitor/monitor.vue new file mode 100644 index 0000000..b9873ec --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/monitor/monitor.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/src/pages/admin-portal/device-center/ops/monitor/types/index.ts b/src/pages/admin-portal/device-center/ops/monitor/types/index.ts new file mode 100644 index 0000000..eaed6e0 --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/monitor/types/index.ts @@ -0,0 +1,179 @@ +/** + * 设备监测 - 类型 + * - 主列表:设备运行状态总览 + * - 详情:6 Tab 聚合(实施/自检/巡检/运行日志/更新记录/维修记录) + */ +import type { BaseSearchForm } from '@pages/admin-portal/shared/types' + +/** 运行状态 */ +export type RunStatus = 'online' | 'offline' | 'repairing' | 'scrapped' + +/** 设备类型 */ +export type DeviceCategory = 'catering' | 'body' | 'wearable' | 'fitness' | 'monitor' + +/** 列表项 */ +export interface MonitorItem { + id: string + /** 设备编码 */ + deviceCode: string + /** 设备名称 */ + deviceName: string + /** 设备类型 */ + deviceCategory: DeviceCategory + /** 所属租户 */ + tenantName: string + /** 所属场所 */ + siteName: string + /** 运行状态 */ + runStatus: RunStatus + /** 固件版本 */ + firmwareVersion: string + /** 最近心跳时间 */ + lastHeartbeat: string + /** 在线率(0-100) */ + onlineRate: number + /** 当前告警数 */ + alertCount: number + /** 是否启用数据分发 */ + dataTransit: boolean +} + +/** 查询表单 */ +export interface SearchForm extends BaseSearchForm { + deviceCategory?: DeviceCategory + runStatus?: RunStatus + tenantName?: string + siteName?: string + hasAlert?: boolean +} + +/** 运行状态下拉 */ +export const RUN_STATUS_OPTIONS: { label: string; value: RunStatus }[] = [ + { label: '在用', value: 'online' }, + { label: '离线', value: 'offline' }, + { label: '维修中', value: 'repairing' }, + { label: '报废', value: 'scrapped' }, +] + +/** 设备类型下拉(与 5 顶级分类对齐) */ +export const DEVICE_CATEGORY_OPTIONS: { label: string; value: DeviceCategory }[] = [ + { label: '智养餐饮设备', value: 'catering' }, + { label: '体重测量设备', value: 'body' }, + { label: '智能穿戴设备', value: 'wearable' }, + { label: '健身器材设备', value: 'fitness' }, + { label: '健康监测设备', value: 'monitor' }, +] + +/** 运行状态 → Tag 颜色 */ +export const runStatusColor: Record = { + online: 'success', + offline: 'default', + repairing: 'warning', + scrapped: 'error', +} + +/** 运行状态 → 中文 */ +export const runStatusText = (status: RunStatus | string | undefined): string => { + if (!status) return '—' + const map: Record = { + online: '在用', + offline: '离线', + repairing: '维修中', + scrapped: '报废', + } + return map[status as RunStatus] ?? '—' +} + +/** 设备类型 → 中文 */ +export const deviceCategoryText = (cat: DeviceCategory | string | undefined): string => { + if (!cat) return '—' + return DEVICE_CATEGORY_OPTIONS.find((i) => i.value === cat)?.label ?? cat +} + +// ===== 顶部统计卡 ===== +export interface MonitorStats { + total: number + online: number + offline: number + alerting: number + repairing: number +} + +// ===== 详情 6 Tab 数据结构 ===== + +/** Tab 1 实施记录 */ +export interface ImplementRecord { + id: string + implementType: 'newInstall' | 'migration' | 'removal' | 'reset' + implementer: string + implementTime: string + signedBy: string + remark: string +} + +/** Tab 2 自检记录 */ +export interface SelfCheckRecord { + 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 +} + +/** Tab 3 巡检记录 */ +export interface InspectionRecord { + id: string + planName: string + inspector: string + inspectTime: string + result: 'pass' | 'fail' + itemsCount: number + exceptionCount: number + handleType?: 'close' | 'workorder' + workOrderNo?: string +} + +/** Tab 4 运行日志 */ +export interface RunLogRecord { + id: string + eventType: string + content: string + occurTime: string + level: 'info' | 'warn' | 'error' +} + +/** Tab 5 更新记录 */ +export interface UpdateRecord { + id: string + targetVersion: string + beforeVersion: string + pushTask: string + result: 'success' | 'fail' | 'rollback' + retryCount: number + finishTime: string +} + +/** Tab 6 维修记录 */ +export interface RepairRecord { + id: string + workOrderNo: string + faultType: string + repairMethod: string + repairer: string + workHours: number + partsConsumed?: string + finishTime: string +} + +/** 详情聚合 */ +export interface MonitorDetail extends MonitorItem { + onlineTime: string + isIot: boolean + implements: ImplementRecord[] + selfChecks: SelfCheckRecord[] + inspections: InspectionRecord[] + runLogs: RunLogRecord[] + updates: UpdateRecord[] + repairs: RepairRecord[] +} diff --git a/src/pages/admin-portal/device-center/ops/version/api/index.ts b/src/pages/admin-portal/device-center/ops/version/api/index.ts new file mode 100644 index 0000000..98b9695 --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/version/api/index.ts @@ -0,0 +1,27 @@ +/** + * 版本管理 - 接口层 + */ +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { SearchForm, VersionItem, VersionStatus } from '../types' + +const BASE = '/admin/device/ops/version' + +export const list = (params: SearchForm): Promise> => + postRequest('axiosRequest', BASE + '/list', params) + +export const info = (id: string): Promise> => + postRequest('axiosRequest', BASE + '/info', { id }) + +export const add = (params: Record): Promise> => + postRequest('axiosRequest', BASE + '/add', params) + +export const update = (params: Record): Promise> => + postRequest('axiosRequest', BASE + '/update', params) + +export const remove = (id: string): Promise> => + postRequest('axiosRequest', BASE + '/delete', { id }) + +/** 流转:草稿 → 灰度 → 全量 → 停用 */ +export const transition = (id: string, toStatus: VersionStatus): Promise> => + postRequest('axiosRequest', BASE + '/transition', { id, toStatus }) diff --git a/src/pages/admin-portal/device-center/ops/version/component/drawer/info/api/index.ts b/src/pages/admin-portal/device-center/ops/version/component/drawer/info/api/index.ts new file mode 100644 index 0000000..6ba50cb --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/version/component/drawer/info/api/index.ts @@ -0,0 +1,6 @@ +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { VersionItem } from '../../../../types' + +export const getInfo = (id: string): Promise> => + postRequest('axiosRequest', '/admin/device/ops/version/info', { id }) diff --git a/src/pages/admin-portal/device-center/ops/version/component/drawer/info/info.vue b/src/pages/admin-portal/device-center/ops/version/component/drawer/info/info.vue new file mode 100644 index 0000000..c42b291 --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/version/component/drawer/info/info.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/src/pages/admin-portal/device-center/ops/version/component/drawer/info/init/usePage.ts b/src/pages/admin-portal/device-center/ops/version/component/drawer/info/init/usePage.ts new file mode 100644 index 0000000..6f6c4fc --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/version/component/drawer/info/init/usePage.ts @@ -0,0 +1,49 @@ +import { reactive, ref } from 'vue' +import { getInfo } from '../api' +import type { VersionItem } from '../../../../types' +import type { OpenContext, PageInfo } from '../types' + +export const usePage = () => { + const pageInfo = reactive({ + visible: false, + title: '版本详情', + spin: false, + width: 720, + }) + + const detail = ref(null) + + 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 = (ctx: OpenContext): void => { + detail.value = null + pageInfo.visible = true + pageInfo.title = ctx.title ?? '版本详情' + loadData(ctx.id) + } + + const closeDrawer = (): void => { + pageInfo.visible = false + } + + return { + pageInfo, + detail, + openDrawer, + closeDrawer, + } +} diff --git a/src/pages/admin-portal/device-center/ops/version/component/drawer/info/types/index.ts b/src/pages/admin-portal/device-center/ops/version/component/drawer/info/types/index.ts new file mode 100644 index 0000000..1324910 --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/version/component/drawer/info/types/index.ts @@ -0,0 +1,18 @@ +/** + * 版本管理 - 详情抽屉 - 类型 + */ +import type { VersionItem } from '../../../../types' + +export interface PageInfo { + visible: boolean + title: string + spin: boolean + width: number +} + +export interface OpenContext { + id: string + title?: string +} + +export type { VersionItem } diff --git a/src/pages/admin-portal/device-center/ops/version/component/modal/addOrEdit/addOrEdit.vue b/src/pages/admin-portal/device-center/ops/version/component/modal/addOrEdit/addOrEdit.vue new file mode 100644 index 0000000..ea2bdf7 --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/version/component/modal/addOrEdit/addOrEdit.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/src/pages/admin-portal/device-center/ops/version/component/modal/addOrEdit/api/index.ts b/src/pages/admin-portal/device-center/ops/version/component/modal/addOrEdit/api/index.ts new file mode 100644 index 0000000..4c607d8 --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/version/component/modal/addOrEdit/api/index.ts @@ -0,0 +1,17 @@ +/** + * 版本管理 - 新增/编辑弹框 - 接口 + */ +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { Form } from '../types' + +const BASE = '/admin/device/ops/version' + +export const info = (id: string): Promise> => + postRequest('axiosRequest', BASE + '/info-for-edit', { id }) + +export const add = (params: Record): Promise> => + postRequest('axiosRequest', BASE + '/add', params) + +export const edit = (params: Record): Promise> => + postRequest('axiosRequest', BASE + '/update', params) diff --git a/src/pages/admin-portal/device-center/ops/version/component/modal/addOrEdit/init/usePage.ts b/src/pages/admin-portal/device-center/ops/version/component/modal/addOrEdit/init/usePage.ts new file mode 100644 index 0000000..36dc846 --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/version/component/modal/addOrEdit/init/usePage.ts @@ -0,0 +1,143 @@ +/** + * 版本管理 - 新增/编辑弹框 - 主逻辑 + */ +import type { FormInstance, Rule } from 'ant-design-vue/es/form' +import { useAntdStaticMethods } from '@utils/antDesign/popUp' +import { add, edit, info } from '../api' +import { VERSION_STATUS_OPTIONS, VERSION_TRACK_OPTIONS } from '../../../../types' +import type { Form, FormOptions, ModalType, OpenContext, PageInfo } from '../types' + +const createInitForm = (): Form => ({ + id: undefined, + version: '', + track: 'firmware', + packageUrl: '', + upgradePoints: '', + grayPercent: 10, + rollbackFlag: true, + status: 'draft', +}) + +const createInitOptions = (): FormOptions => ({ + track: VERSION_TRACK_OPTIONS, + status: VERSION_STATUS_OPTIONS, +}) + +export const usePage = (emit: (e: 'load') => void) => { + const { message } = useAntdStaticMethods() + + const pageInfo = reactive({ + visible: false, + type: 'add', + title: '', + width: 720, + spin: false, + }) + + const formRef = ref() + const form = ref(createInitForm()) + const formOptions = ref(createInitOptions()) + + const rules: Record = { + version: [{ required: true, message: '请输入版本号', trigger: 'blur' }], + track: [{ required: true, message: '请选择轨道', trigger: 'change' }], + packageUrl: [{ required: true, message: '请输入升级包地址', trigger: 'blur' }], + upgradePoints: [{ required: true, message: '请输入升级点说明', trigger: 'blur' }], + } + + const resetForm = (): void => { + formRef.value?.clearValidate() + form.value = createInitForm() + } + + const closeModal = (): void => { + resetForm() + pageInfo.visible = false + } + + const openModal = (type: ModalType, ctx?: OpenContext): void => { + pageInfo.visible = true + pageInfo.spin = true + pageInfo.type = type + resetForm() + pageInfo.title = type === 'add' ? '新增版本' : '编辑版本' + + const requests: Promise[] = [] + if (type === 'edit' && ctx?.id) requests.push(info(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') { + form.value = { ...createInitForm(), ...infoRes.data } + } + } + }) + .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): 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): 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 = JSON.parse(JSON.stringify(form.value)) as Record + if (params.id) editRequest(params) + else addRequest(params) + }) + .catch((err: unknown) => { + pageInfo.spin = false + console.error('表单验证失败:', err) + }) + } + + return { + pageInfo, + formRef, + form, + formOptions, + rules, + openModal, + closeModal, + submit, + } +} diff --git a/src/pages/admin-portal/device-center/ops/version/component/modal/addOrEdit/types/index.ts b/src/pages/admin-portal/device-center/ops/version/component/modal/addOrEdit/types/index.ts new file mode 100644 index 0000000..4cfd870 --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/version/component/modal/addOrEdit/types/index.ts @@ -0,0 +1,37 @@ +/** + * 版本管理 - 新增/编辑弹框 - 类型 + */ +import type { SelectProps } from 'ant-design-vue' +import type { VersionStatus, VersionTrack } from '../../../../types' + +export type ModalType = 'add' | 'edit' + +export interface PageInfo { + visible: boolean + type: ModalType + title: string + width: number + spin: boolean +} + +export interface Form { + id?: string + version: string + track: VersionTrack + packageUrl: string + upgradePoints: string + grayPercent: number + rollbackFlag: boolean + status: VersionStatus +} + +export interface FormOptions { + track: SelectProps['options'] + status: SelectProps['options'] +} + +export interface OpenContext { + id?: string +} + +export const LABEL_COL = { style: { width: '120px', minWidth: '120px' } } as const diff --git a/src/pages/admin-portal/device-center/ops/version/init/usePage.ts b/src/pages/admin-portal/device-center/ops/version/init/usePage.ts new file mode 100644 index 0000000..cbe4411 --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/version/init/usePage.ts @@ -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 InfoDrawer from '../component/drawer/info/info.vue' +import * as api from '../api' +import type { SearchForm, VersionItem, VersionStatus } from '../types' +import { NEXT_STATUS_MAP, transitionText } from '../types' +import { createSearchKey } from './useSearch' +import { tableColumns } from './useTable' + +export const usePage = () => { + const { message, Modal } = useAntdStaticMethods() + + const search = reactive(createSearchKey()) + + const table = reactive>({ + columns: tableColumns, + dataSource: [], + sort: { field: '', order: null }, + pagination: createPaginationConfig() as TablePaginationConfig, + }) + + const pageLoading = ref(false) + const addOrEditRef = ref | null>(null) + const infoDrawerRef = ref | 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 openAdd = (): void => { + addOrEditRef.value?.openModal('add') + } + + const openEdit = (record: VersionItem): void => { + addOrEditRef.value?.openModal('edit', { id: record.id }) + } + + const openDetail = (record: VersionItem): void => { + infoDrawerRef.value?.openDrawer({ id: record.id, title: `版本详情 · ${record.version}` }) + } + + /** 状态流转 */ + const transition = (record: VersionItem, to: VersionStatus): void => { + const text = transitionText(to) + Modal.confirm({ + title: `确认${text}?`, + content: `版本号:${record.version}`, + okType: to === 'deprecated' ? 'danger' : 'primary', + okText: `确认${text}`, + cancelText: '取消', + onOk: () => { + api.transition(record.id, to) + .then((res) => { + if (res.code === '00000') { + void message.success(res.msg) + listRequest() + } + }) + .catch((err: unknown) => { + console.error('版本状态流转失败:', err) + }) + }, + }) + } + + const deleteRecord = (record: VersionItem): void => { + Modal.confirm({ + title: '确认删除?', + content: `版本 [${record.version}] 删除后不可恢复。`, + okType: 'danger', + okText: '确认删除', + 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 nextStatus = (status: VersionStatus): VersionStatus | null => NEXT_STATUS_MAP[status] + + const handleAction = (type: string, record?: VersionItem): void => { + if (type === 'add') { + openAdd() + return + } + if (!record) return + if (type === 'detail') openDetail(record) + if (type === 'edit') openEdit(record) + if (type === 'delete') deleteRecord(record) + if (type === 'transition') { + const next = nextStatus(record.status) + if (next) transition(record, next) + } + } + + onMounted(() => { + listRequest() + }) + + return { + pageLoading, + search, + table, + addOrEditRef, + infoDrawerRef, + searchQuery, + resetQuery, + dataSourceChange, + handleAction, + nextStatus, + listRequest, + } +} diff --git a/src/pages/admin-portal/device-center/ops/version/init/useSearch.ts b/src/pages/admin-portal/device-center/ops/version/init/useSearch.ts new file mode 100644 index 0000000..3f7fb11 --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/version/init/useSearch.ts @@ -0,0 +1,9 @@ +import type { SearchForm } from '../types' + +export const createSearchKey = (): SearchForm => ({ + keyword: undefined, + track: undefined, + status: undefined, +}) + +export const useSearch = () => ({ createSearchKey }) diff --git a/src/pages/admin-portal/device-center/ops/version/init/useTable.ts b/src/pages/admin-portal/device-center/ops/version/init/useTable.ts new file mode 100644 index 0000000..7368d85 --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/version/init/useTable.ts @@ -0,0 +1,55 @@ +import type { TableColumnsType } from 'ant-design-vue' +import { h } from 'vue' +import { Progress, Tag } from 'ant-design-vue' +import type { VersionItem, VersionStatus, VersionTrack } from '../types' +import { versionStatusColor, versionStatusText, versionTrackText } from '../types' + +export const tableColumns: TableColumnsType = [ + { title: '版本号', dataIndex: 'version', align: 'left', fixed: 'left', width: 130, resizable: true, ellipsis: true }, + { + title: '轨道', + dataIndex: 'track', + align: 'center', + width: 110, + customRender: ({ value }: { value: VersionTrack }) => + h(Tag, { color: value === 'firmware' ? 'geekblue' : 'purple' }, () => versionTrackText(value)), + }, + { title: '升级包地址', dataIndex: 'packageUrl', align: 'left', width: 220, resizable: true, ellipsis: true }, + { title: '升级点', dataIndex: 'upgradePoints', align: 'left', width: 240, resizable: true, ellipsis: true }, + { + title: '灰度', + dataIndex: 'grayPercent', + align: 'center', + width: 120, + customRender: ({ value, record }: { value: number; record: VersionItem }) => { + if (record.status === 'full') return h(Tag, { color: 'success' }, () => '已全量') + if (record.status === 'draft') return '—' + return h(Progress, { percent: value ?? 0, size: 'small' }) + }, + }, + { + title: '可回滚', + dataIndex: 'rollbackFlag', + align: 'center', + width: 90, + customRender: ({ value }: { value: boolean }) => + h(Tag, { color: value ? 'green' : 'default' }, () => (value ? '是' : '否')), + }, + { + title: '已升级', + dataIndex: 'upgradedCount', + align: 'center', + width: 100, + customRender: ({ value }: { value: number }) => `${value ?? 0} 台`, + }, + { title: '发布时间', dataIndex: 'publishTime', align: 'center', width: 170, customRender: ({ value }: { value: string | null }) => value ?? '—' }, + { + title: '状态', + dataIndex: 'status', + align: 'center', + width: 100, + customRender: ({ value }: { value: VersionStatus }) => + h(Tag, { color: versionStatusColor[value] }, () => versionStatusText(value)), + }, + { title: '操作', dataIndex: 'action', align: 'center', fixed: 'right', width: 220 }, +] diff --git a/src/pages/admin-portal/device-center/ops/version/types/index.ts b/src/pages/admin-portal/device-center/ops/version/types/index.ts new file mode 100644 index 0000000..c813b41 --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/version/types/index.ts @@ -0,0 +1,98 @@ +/** + * 版本管理 - 类型 + * - 固件版本(硬件 OTA)+ APP 版本(客户端 APP)双轨 + * - 状态流转:草稿 → 灰度 → 全量 → 已停用 + */ +import type { BaseSearchForm } from '@pages/admin-portal/shared/types' + +/** 版本轨道 */ +export type VersionTrack = 'firmware' | 'app' + +/** 版本状态 */ +export type VersionStatus = 'draft' | 'gray' | 'full' | 'deprecated' + +/** 列表项 */ +export interface VersionItem { + id: string + /** 版本号 */ + version: string + /** 轨道 */ + track: VersionTrack + /** 升级包地址 */ + packageUrl: string + /** 升级点说明 */ + upgradePoints: string + /** 灰度策略(百分比) */ + grayPercent: number + /** 是否可回滚 */ + rollbackFlag: boolean + /** 发布时间 */ + publishTime: string | null + /** 状态 */ + status: VersionStatus + /** 已升级设备数 */ + upgradedCount: number + /** 创建人 */ + creator: string + /** 创建时间 */ + createTime: string +} + +/** 查询表单 */ +export interface SearchForm extends BaseSearchForm { + track?: VersionTrack + status?: VersionStatus +} + +/** 轨道下拉 */ +export const VERSION_TRACK_OPTIONS: { label: string; value: VersionTrack }[] = [ + { label: '固件版本', value: 'firmware' }, + { label: 'APP 版本', value: 'app' }, +] + +/** 状态下拉 */ +export const VERSION_STATUS_OPTIONS: { label: string; value: VersionStatus }[] = [ + { label: '草稿', value: 'draft' }, + { label: '灰度', value: 'gray' }, + { label: '全量', value: 'full' }, + { label: '已停用', value: 'deprecated' }, +] + +/** 状态 → Tag 颜色 */ +export const versionStatusColor: Record = { + draft: 'default', + gray: 'warning', + full: 'success', + deprecated: 'error', +} + +/** 状态 → 中文 */ +export const versionStatusText = (status: VersionStatus | string | undefined): string => { + if (!status) return '—' + return VERSION_STATUS_OPTIONS.find((i) => i.value === status)?.label ?? status +} + +/** 轨道 → 中文 */ +export const versionTrackText = (track: VersionTrack | string | undefined): string => { + if (!track) return '—' + return VERSION_TRACK_OPTIONS.find((i) => i.value === track)?.label ?? track +} + +/** 可流转的下一个状态 */ +export const NEXT_STATUS_MAP: Record = { + draft: 'gray', + gray: 'full', + full: 'deprecated', + deprecated: null, +} + +/** 流转动作名 */ +export const transitionText = (to: VersionStatus): string => { + const map: Record = { + draft: '设为草稿', + gray: '发布灰度', + full: '发布全量', + deprecated: '停用', + } + return map[to] +} diff --git a/src/pages/admin-portal/device-center/ops/version/version.vue b/src/pages/admin-portal/device-center/ops/version/version.vue new file mode 100644 index 0000000..0c0bfb5 --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/version/version.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/src/pages/admin-portal/device-center/ops/workOrder/api/index.ts b/src/pages/admin-portal/device-center/ops/workOrder/api/index.ts new file mode 100644 index 0000000..eaeced5 --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/workOrder/api/index.ts @@ -0,0 +1,13 @@ +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { ListItem, ListParams, WorkOrderStatus } from '../types' + +export const list = (params: ListParams): Promise> => + postRequest('axiosRequest', '/admin/device/repair/work-order/page', params) + +/** 工单流转:接单 / 派单 / 关单 */ +export const transition = (id: string, toStatus: WorkOrderStatus, assignee?: string): Promise> => + postRequest('axiosRequest', '/admin/device/repair/work-order/transition', { id, toStatus, assignee }) + +export const remove = (id: string): Promise> => + postRequest('axiosRequest', '/admin/device/repair/work-order/delete', { id }) diff --git a/src/pages/admin-portal/device-center/ops/workOrder/init/usePage.ts b/src/pages/admin-portal/device-center/ops/workOrder/init/usePage.ts new file mode 100644 index 0000000..b08a93c --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/workOrder/init/usePage.ts @@ -0,0 +1,60 @@ +import { useAntdStaticMethods } from '@utils/antDesign/popUp' +import { useAdminPage } from '@pages/admin-portal/shared/useAdminPage' +import { list, remove, transition } from '../api' +import { tableColumns } from './useTable' +import { createSearchKey } from './useSearch' +import type { ListItem, SearchForm, WorkOrderStatus } from '../types' + +/** 工单管理页:搜索 + 新增 + 工单流转(接单/关单) + 删除 */ +export const usePage = () => { + const { message, Modal } = useAntdStaticMethods() + + const adminPage = useAdminPage( + { + pageName: '工单管理', + addButtonText: '新增工单', + columns: tableColumns, + actions: ['edit', 'delete'], + listApi: list, + deleteApi: remove, + }, + createSearchKey, + ) + + /** 工单流转:接单 / 关单 */ + const handleTransition = (record: ListItem, toStatus: WorkOrderStatus): void => { + const actionText = toStatus === 'assigned' ? '接单' : toStatus === 'processing' ? '开始处理' : '关单' + Modal.confirm({ + title: `确认${actionText}?`, + content: `工单号:${record.orderCode}`, + okType: toStatus === 'closed' ? 'danger' : 'primary', + okText: `确认${actionText}`, + cancelText: '取消', + onOk: () => { + transition(record.id, toStatus, toStatus === 'closed' ? undefined : '当前用户') + .then((res) => { + if (res.code === '00000') { + void message.success(res.msg) + adminPage.listRequest() + } + }) + .catch((err: unknown) => { + console.error('工单流转失败:', err) + }) + }, + }) + } + + /** 是否允许接单(仅 pending 状态) */ + const canAccept = (status: WorkOrderStatus): boolean => status === 'pending' + + /** 是否允许关单(仅 processing 状态) */ + const canClose = (status: WorkOrderStatus): boolean => status === 'processing' || status === 'assigned' + + return { + ...adminPage, + handleTransition, + canAccept, + canClose, + } +} diff --git a/src/pages/admin-portal/device-center/ops/workOrder/init/useSearch.ts b/src/pages/admin-portal/device-center/ops/workOrder/init/useSearch.ts new file mode 100644 index 0000000..4f7f9af --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/workOrder/init/useSearch.ts @@ -0,0 +1,7 @@ +import type { SearchForm } from '../types' + +export const createSearchKey = (): SearchForm => ({ + keyword: undefined, + status: undefined, + faultLevel: undefined, +}) diff --git a/src/pages/admin-portal/device-center/ops/workOrder/init/useTable.ts b/src/pages/admin-portal/device-center/ops/workOrder/init/useTable.ts new file mode 100644 index 0000000..627bac3 --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/workOrder/init/useTable.ts @@ -0,0 +1,72 @@ +import type { TableColumnsType } from 'ant-design-vue' +import { h } from 'vue' +import { Badge, Tag } from 'ant-design-vue' +import type { ListItem } from '../types' +import { FAULT_LEVEL_OPTIONS, ORDER_SOURCE_OPTIONS, WORK_ORDER_STATUS_OPTIONS } from '../types' + +const statusColor: Record = { + pending: 'warning', + assigned: 'processing', + processing: 'blue', + closed: 'default', +} + +const statusText = (status: string): string => + WORK_ORDER_STATUS_OPTIONS.find((item) => item.value === status)?.label ?? '—' + +const faultColor: Record = { + low: 'default', + medium: 'warning', + high: 'orange', + critical: 'error', +} + +const faultText = (level: string): string => + FAULT_LEVEL_OPTIONS.find((item) => item.value === level)?.label ?? '—' + +const sourceText = (source: string): string => + ORDER_SOURCE_OPTIONS.find((item) => item.value === source)?.label ?? '—' + +export const tableColumns: TableColumnsType = [ + { title: '序号', dataIndex: 'index', align: 'center', fixed: 'left', width: 70, customRender: ({ index }: { index: number }) => index + 1 }, + { + title: '工单号', + dataIndex: 'orderCode', + align: 'left', + width: 170, + resizable: true, + ellipsis: true, + customRender: ({ record }: { record: ListItem }) => + record.slaOverdue && record.status !== 'closed' + ? h('span', [ + h(Badge, { status: 'error', text: record.orderCode }), + ]) + : record.orderCode, + }, + { title: '设备编码', dataIndex: 'deviceCode', align: 'left', width: 150, resizable: true, ellipsis: true }, + { title: '设备名称', dataIndex: 'deviceName', align: 'left', width: 150, resizable: true, ellipsis: true }, + { title: '所属客户', dataIndex: 'customerName', align: 'left', width: 140, resizable: true, ellipsis: true }, + { title: '所属场所', dataIndex: 'siteName', align: 'left', width: 140, resizable: true, ellipsis: true }, + { title: '来源', dataIndex: 'source', align: 'center', width: 100, customRender: ({ value }: { value: string }) => sourceText(value) }, + { + title: '故障等级', + dataIndex: 'faultLevel', + align: 'center', + width: 90, + customRender: ({ value }: { value: string }) => + h(Tag, { color: faultColor[value] ?? 'default' }, () => faultText(value)), + }, + { title: '故障描述', dataIndex: 'faultDesc', align: 'left', width: 200, resizable: true, ellipsis: true }, + { + title: '工单状态', + dataIndex: 'status', + align: 'center', + width: 110, + customRender: ({ value }: { value: string }) => + h(Tag, { color: statusColor[value] ?? 'default' }, () => statusText(value)), + }, + { title: '责任人', dataIndex: 'assignee', align: 'center', width: 100, resizable: true, ellipsis: true }, + { title: '报障时间', dataIndex: 'reportTime', align: 'center', width: 170, sorter: true, resizable: true }, + { title: 'SLA 截止', dataIndex: 'slaDeadline', align: 'center', width: 170, resizable: true }, + { title: '操作', dataIndex: 'action', align: 'center', fixed: 'right', width: 200 }, +] diff --git a/src/pages/admin-portal/device-center/ops/workOrder/types/index.ts b/src/pages/admin-portal/device-center/ops/workOrder/types/index.ts new file mode 100644 index 0000000..42a6095 --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/workOrder/types/index.ts @@ -0,0 +1,68 @@ +import type { BaseListParams, BaseRecord, BaseSearchForm } from '@pages/admin-portal/shared/types' + +/** 工单状态:接单 → 派单 → 处理中 → 关单 */ +export type WorkOrderStatus = 'pending' | 'assigned' | 'processing' | 'closed' + +/** 工单来源 */ +export type OrderSource = 'customer' | 'inspection' | 'self-check' | 'after-sale' + +/** 故障等级 */ +export type FaultLevel = 'low' | 'medium' | 'high' | 'critical' + +export interface SearchForm extends BaseSearchForm { + keyword?: string + status?: string + faultLevel?: string +} + +export interface ListItem extends BaseRecord { + /** 工单号 */ + orderCode: string + /** 设备编码 */ + deviceCode: string + /** 设备名称 */ + deviceName: string + /** 所属场所 */ + siteName: string + /** 所属客户 */ + customerName: string + /** 工单来源 */ + source: OrderSource + /** 故障等级 */ + faultLevel: FaultLevel + /** 故障描述 */ + faultDesc: string + /** 工单状态 */ + status: WorkOrderStatus + /** 责任人 */ + assignee?: string + /** 客户报障时间 */ + reportTime: string + /** SLA 截止时间 */ + slaDeadline: string + /** 是否超 SLA */ + slaOverdue: boolean +} + +export interface ListParams extends BaseListParams, SearchForm {} + +export const WORK_ORDER_STATUS_OPTIONS = [ + { value: 'pending', label: '待接单' }, + { value: 'assigned', label: '已派单' }, + { value: 'processing', label: '处理中' }, + { value: 'closed', label: '已关单' }, +] + +export const ORDER_SOURCE_OPTIONS = [ + { value: 'customer', label: '客户报障' }, + { value: 'inspection', label: '巡检异常' }, + { value: 'self-check', label: '自检异常' }, + { value: 'after-sale', label: '售后转单' }, +] + +export const FAULT_LEVEL_OPTIONS = [ + { value: 'low', label: '低' }, + { value: 'medium', label: '中' }, + { value: 'high', label: '高' }, + { value: 'critical', label: '紧急' }, +] diff --git a/src/pages/admin-portal/device-center/ops/workOrder/workOrder.vue b/src/pages/admin-portal/device-center/ops/workOrder/workOrder.vue new file mode 100644 index 0000000..f3daeef --- /dev/null +++ b/src/pages/admin-portal/device-center/ops/workOrder/workOrder.vue @@ -0,0 +1,81 @@ + + + + + diff --git a/src/pages/admin-portal/device-center/stock/inbound/api/index.ts b/src/pages/admin-portal/device-center/stock/inbound/api/index.ts new file mode 100644 index 0000000..ee87b4a --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/inbound/api/index.ts @@ -0,0 +1,27 @@ +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { Form, ListItem, ListParams } from '../types' + +/** 分页查询入库单 */ +export const list = (params: ListParams): Promise> => + postRequest('axiosRequest', '/admin/device/stock/inbound/page', params) + +/** 详情查询(编辑回填) */ +export const info = (id: string): Promise> => + postRequest('axiosRequest', '/admin/device/stock/inbound/info', { id }) + +/** 新增入库单 */ +export const add = (params: Record): Promise> => + postRequest('axiosRequest', '/admin/device/stock/inbound/add', params) + +/** 编辑入库单 */ +export const edit = (params: Record): Promise> => + postRequest('axiosRequest', '/admin/device/stock/inbound/update', params) + +/** 删除入库单 */ +export const remove = (id: string): Promise> => + postRequest('axiosRequest', '/admin/device/stock/inbound/delete', { id }) + +/** 导出 */ +export const exportList = (params: ListParams): Promise> => + postRequest('axiosRequest', '/admin/device/stock/inbound/export', params) diff --git a/src/pages/admin-portal/device-center/stock/inbound/component/modal/addOrEdit/addOrEdit.vue b/src/pages/admin-portal/device-center/stock/inbound/component/modal/addOrEdit/addOrEdit.vue new file mode 100644 index 0000000..93419d0 --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/inbound/component/modal/addOrEdit/addOrEdit.vue @@ -0,0 +1,173 @@ + + + diff --git a/src/pages/admin-portal/device-center/stock/inbound/component/modal/addOrEdit/api/index.ts b/src/pages/admin-portal/device-center/stock/inbound/component/modal/addOrEdit/api/index.ts new file mode 100644 index 0000000..20b06d6 --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/inbound/component/modal/addOrEdit/api/index.ts @@ -0,0 +1,19 @@ +/** + * 入库管理 - 新增/编辑 弹框 - 接口层 + */ + +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { Form } from '../types' + +/** 详情查询(编辑回填) */ +export const info = (id: string): Promise> => + postRequest('axiosRequest', '/admin/device/stock/inbound/info', { id }) + +/** 新增 */ +export const add = (params: Record): Promise> => + postRequest('axiosRequest', '/admin/device/stock/inbound/add', params) + +/** 编辑 */ +export const edit = (params: Record): Promise> => + postRequest('axiosRequest', '/admin/device/stock/inbound/update', params) diff --git a/src/pages/admin-portal/device-center/stock/inbound/component/modal/addOrEdit/init/usePage.ts b/src/pages/admin-portal/device-center/stock/inbound/component/modal/addOrEdit/init/usePage.ts new file mode 100644 index 0000000..6d48ecf --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/inbound/component/modal/addOrEdit/init/usePage.ts @@ -0,0 +1,150 @@ +/** + * 入库管理 - 新增/编辑 弹框 - 主逻辑 + */ + +import type { FormInstance, Rule } from 'ant-design-vue/es/form' +import { useAntdStaticMethods } from '@utils/antDesign/popUp' +import { add, edit, info } from '../api' +import type { Form, ModalType, OpenContext, PageInfo } from '../types' + +const createInitForm = (): Form => ({ + id: undefined, + inboundCode: '', + deviceCode: '', + deviceName: '', + modelName: '', + batchNo: '', + supplierId: '', + supplierName: '', + quantity: 1, + purchasePrice: 0, + inboundType: 'purchase', + status: 'completed', + storageLocation: '', + remark: '', +}) + +export const usePage = (emit: (e: 'load') => void) => { + const { message } = useAntdStaticMethods() + + const pageInfo = reactive({ + visible: false, + type: 'add', + title: '', + width: 760, + spin: false, + }) + + const formRef = ref() + const form = ref(createInitForm()) + + const rules: Record = { + deviceCode: [{ required: true, message: '请输入设备编码', trigger: 'blur' }], + deviceName: [{ required: true, message: '请输入设备名称', trigger: 'blur' }], + modelName: [{ required: true, message: '请输入二级型号', trigger: 'blur' }], + batchNo: [{ required: true, message: '请输入批次号', trigger: 'blur' }], + supplierName: [{ required: true, message: '请输入供应商', trigger: 'blur' }], + quantity: [{ required: true, type: 'number', message: '请输入数量', trigger: 'blur' }], + purchasePrice: [{ required: true, type: 'number', message: '请输入采购价', trigger: 'blur' }], + inboundType: [{ required: true, message: '请选择入库类型', trigger: 'change' }], + } + + const resetForm = (): void => { + formRef.value?.clearValidate() + form.value = createInitForm() + } + + const closeModal = (): void => { + resetForm() + pageInfo.visible = false + } + + const infoRequest = (id: string) => info(id) + + const openModal = (type: ModalType, ctx?: OpenContext): void => { + pageInfo.visible = true + pageInfo.spin = true + pageInfo.type = type + resetForm() + + pageInfo.title = type === 'add' ? '新增入库单' : '编辑入库单' + + const requests: Promise[] = [] + 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' && infoRes.data) { + form.value = { ...createInitForm(), ...infoRes.data } + } + } + }) + .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): void => { + delete params.id + delete params.inboundCode + 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): 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 = JSON.parse(JSON.stringify(form.value)) as Record + if (params.id) editRequest(params) + else addRequest(params) + }) + .catch((err: unknown) => { + pageInfo.spin = false + console.error('表单验证失败:', err) + }) + } + + return { + pageInfo, + formRef, + form, + rules, + openModal, + closeModal, + submit, + } +} diff --git a/src/pages/admin-portal/device-center/stock/inbound/component/modal/addOrEdit/types/index.ts b/src/pages/admin-portal/device-center/stock/inbound/component/modal/addOrEdit/types/index.ts new file mode 100644 index 0000000..2641bfd --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/inbound/component/modal/addOrEdit/types/index.ts @@ -0,0 +1,57 @@ +import type { FormInstance } from 'ant-design-vue/es/form' +import type { InboundStatus, InboundType } from '../../../types' + +/** 弹框类型 */ +export type ModalType = 'add' | 'edit' + +/** 弹框页面状态 */ +export interface PageInfo { + visible: boolean + type: ModalType + title: string + width: number + spin: boolean +} + +/** 打开弹框上下文 */ +export interface OpenContext { + /** 编辑模式必传 */ + id?: string +} + +/** 表单字段 */ +export interface Form { + id: string | undefined + /** 入库单号(编辑时只读) */ + inboundCode: string + /** 设备编码 */ + deviceCode: string + /** 设备名称 */ + deviceName: string + /** 二级型号 */ + modelName: string + /** 批次号 */ + batchNo: string + /** 供应商 ID */ + supplierId: string + /** 供应商名称 */ + supplierName: string + /** 数量 */ + quantity: number + /** 采购价(单价) */ + purchasePrice: number + /** 入库类型 */ + inboundType: InboundType + /** 状态 */ + status: InboundStatus + /** 仓储位置 */ + storageLocation: string + /** 备注 */ + remark: string +} + +/** 暴露给父组件的 ref 类型 */ +export type FormRef = ReturnType + +/** 仅用于类型推导的占位函数 */ +declare function usePageRef(): FormInstance | undefined diff --git a/src/pages/admin-portal/device-center/stock/inbound/inbound.vue b/src/pages/admin-portal/device-center/stock/inbound/inbound.vue new file mode 100644 index 0000000..fff37e3 --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/inbound/inbound.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/src/pages/admin-portal/device-center/stock/inbound/init/usePage.ts b/src/pages/admin-portal/device-center/stock/inbound/init/usePage.ts new file mode 100644 index 0000000..a76be71 --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/inbound/init/usePage.ts @@ -0,0 +1,137 @@ +import { useAntdStaticMethods } from '@utils/antDesign/popUp' +import { useAdminTable } from '@pages/admin-portal/shared/useAdminTable' +import { useAdminSearch } from '@pages/admin-portal/shared/useAdminSearch' +import { exportList, list, remove } from '../api' +import { tableColumns } from './useTable' +import { createSearchKey } from './useSearch' +import type { ListItem, SearchForm } from '../types' +import type AddOrEdit from '../component/modal/addOrEdit/addOrEdit.vue' + +/** + * 入库管理页主编排: + * - 列表 + 新增 + 编辑 + 删除 + 导出 + * - 完整表单弹框(含入库类型 / 批次号 / 采购价等设计文档字段) + */ +export const usePage = () => { + const { message, Modal } = useAntdStaticMethods() + const { search, initOptions, resetSearch } = useAdminSearch(createSearchKey) + + const pageLoading = ref(false) + const addOrEditRef = ref | null>(null) + + const buildParams = () => ({ + ...search, + pageNum: table.pagination.current ?? 1, + pageSize: table.pagination.pageSize ?? 30, + order: table.sort.order, + column: table.sort.field, + }) + + const listRequest = (): void => { + pageLoading.value = true + list(buildParams()) + .then((res) => { + if (res.code === '00000') { + table.dataSource = res.data ?? [] + table.pagination.total = res.total ?? 0 + } + }) + .catch((err: unknown) => { + console.error('入库单列表请求失败:', err) + }) + .finally(() => { + pageLoading.value = false + }) + } + + const { + table, + dataSourceChange, + resetTable, + selectedRowKeys, + rowSelection, + clearSelection, + } = useAdminTable(tableColumns, listRequest, false) + + const searchQuery = (): void => { + table.pagination.current = 1 + listRequest() + } + + const resetQuery = (): void => { + resetSearch() + resetTable() + clearSelection?.() + listRequest() + } + + const openAddModal = (): void => { + addOrEditRef.value?.openModal('add') + } + + const openEditModal = (record: ListItem): void => { + addOrEditRef.value?.openModal('edit', { id: record.id }) + } + + const deleteRecord = (record: ListItem): void => { + Modal.confirm({ + title: '确认删除?', + content: `入库单「${record.inboundCode}」删除后不可恢复。`, + 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) + }) + }, + }) + } + + const exportRecord = (): void => { + exportList(buildParams()) + .then((res) => { + if (res.code === '00000') void message.success(res.msg || '导出任务已提交') + }) + .catch((err: unknown) => { + console.error('入库单导出失败:', err) + }) + } + + const handleAction = (type: string, record?: ListItem): void => { + if (type === 'add') openAddModal() + else if (type === 'edit' && record) openEditModal(record) + else if (type === 'delete' && record) deleteRecord(record) + } + + const loadData = (): void => { + initOptions() + listRequest() + } + + onMounted(() => { + loadData() + }) + + return { + pageLoading, + search, + table, + selectedRowKeys, + rowSelection, + addOrEditRef, + dataSourceChange, + searchQuery, + resetQuery, + handleAction, + listRequest, + exportRecord, + } +} diff --git a/src/pages/admin-portal/device-center/stock/inbound/init/useSearch.ts b/src/pages/admin-portal/device-center/stock/inbound/init/useSearch.ts new file mode 100644 index 0000000..6e9d6ba --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/inbound/init/useSearch.ts @@ -0,0 +1,7 @@ +import type { SearchForm } from '../types' + +export const createSearchKey = (): SearchForm => ({ + keyword: undefined, + supplierId: undefined, + status: undefined, +}) diff --git a/src/pages/admin-portal/device-center/stock/inbound/init/useTable.ts b/src/pages/admin-portal/device-center/stock/inbound/init/useTable.ts new file mode 100644 index 0000000..62da602 --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/inbound/init/useTable.ts @@ -0,0 +1,47 @@ +import type { TableColumnsType } from 'ant-design-vue' +import { h } from 'vue' +import { Tag } from 'ant-design-vue' +import type { InboundStatus, InboundType, ListItem } from '../types' +import { + INBOUND_STATUS_COLOR, + INBOUND_STATUS_OPTIONS, + INBOUND_TYPE_COLOR, + INBOUND_TYPE_OPTIONS, +} from '../types' + +const statusText = (status: string): string => + INBOUND_STATUS_OPTIONS.find((item) => item.value === status)?.label ?? '—' + +const typeText = (type: string): string => + INBOUND_TYPE_OPTIONS.find((item) => item.value === type)?.label ?? '—' + +export const tableColumns: TableColumnsType = [ + { title: '序号', dataIndex: 'index', align: 'center', fixed: 'left', width: 70, customRender: ({ index }: { index: number }) => index + 1 }, + { title: '入库单号', dataIndex: 'inboundCode', align: 'left', width: 170, fixed: 'left', resizable: true, ellipsis: true }, + { title: '设备编码', dataIndex: 'deviceCode', align: 'left', width: 150, resizable: true, ellipsis: true }, + { title: '设备名称', dataIndex: 'deviceName', align: 'left', width: 140, resizable: true, ellipsis: true }, + { title: '二级型号', dataIndex: 'modelName', align: 'left', width: 130, resizable: true, ellipsis: true }, + { title: '批次号', dataIndex: 'batchNo', align: 'left', width: 130, resizable: true, ellipsis: true }, + { title: '供应商', dataIndex: 'supplierName', align: 'left', width: 180, resizable: true, ellipsis: true }, + { title: '数量', dataIndex: 'quantity', align: 'center', width: 80 }, + { title: '采购价(元)', dataIndex: 'purchasePrice', align: 'right', width: 110, customRender: ({ value }: { value: number }) => value?.toFixed(2) ?? '—' }, + { + title: '入库类型', + dataIndex: 'inboundType', + align: 'center', + width: 110, + customRender: ({ value }: { value: InboundType }) => + h(Tag, { color: INBOUND_TYPE_COLOR[value] ?? 'default' }, () => typeText(value)), + }, + { + title: '状态', + dataIndex: 'status', + align: 'center', + width: 100, + customRender: ({ value }: { value: InboundStatus }) => + h(Tag, { color: INBOUND_STATUS_COLOR[value] ?? 'default' }, () => statusText(value)), + }, + { title: '入库时间', dataIndex: 'inboundTime', align: 'center', width: 170, sorter: true, resizable: true }, + { title: '入库人', dataIndex: 'operator', align: 'center', width: 110, resizable: true, ellipsis: true }, + { title: '操作', dataIndex: 'action', align: 'center', fixed: 'right', width: 200 }, +] diff --git a/src/pages/admin-portal/device-center/stock/inbound/types/index.ts b/src/pages/admin-portal/device-center/stock/inbound/types/index.ts new file mode 100644 index 0000000..4b0c211 --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/inbound/types/index.ts @@ -0,0 +1,92 @@ +import type { BaseListParams, BaseRecord, BaseSearchForm } from '@pages/admin-portal/shared/types' + +/** 入库单状态 */ +export type InboundStatus = 'pending' | 'completed' | 'cancelled' + +/** 入库类型 */ +export type InboundType = 'purchase' | 'return' | 'transfer' + +export interface SearchForm extends BaseSearchForm { + /** 入库单号 / 设备编码 */ + keyword?: string + /** 供应商 ID */ + supplierId?: string + /** 入库状态 */ + status?: string + /** 入库类型 */ + inboundType?: string +} + +export interface ListItem extends BaseRecord { + /** 入库单号 */ + inboundCode: string + /** 设备编码 */ + deviceCode: string + /** 设备名称 */ + deviceName: string + /** 二级型号 */ + modelName: string + /** 批次号 */ + batchNo: string + /** 供应商 ID */ + supplierId: string + /** 供应商名称 */ + supplierName: string + /** 数量 */ + quantity: number + /** 采购价(单价,元) */ + purchasePrice: number + /** 入库时间 */ + inboundTime: string + /** 入库人 */ + operator: string + /** 入库类型 */ + inboundType: InboundType + /** 状态 */ + status: InboundStatus + /** 仓储位置 */ + storageLocation?: string + /** 备注 */ + remark?: string +} + +export interface ListParams extends BaseListParams, SearchForm {} + +export interface InboundPayload { + id?: string + deviceCode: string + deviceName: string + modelName: string + batchNo: string + supplierId: string + supplierName: string + quantity: number + purchasePrice: number + inboundType: InboundType + storageLocation?: string + remark?: string +} + +export const INBOUND_STATUS_OPTIONS = [ + { value: 'pending', label: '待入库' }, + { value: 'completed', label: '已入库' }, + { value: 'cancelled', label: '已取消' }, +] + +export const INBOUND_TYPE_OPTIONS = [ + { value: 'purchase', label: '采购入库' }, + { value: 'return', label: '回库入库' }, + { value: 'transfer', label: '调拨入库' }, +] + +export const INBOUND_TYPE_COLOR: Record = { + purchase: 'blue', + return: 'green', + transfer: 'orange', +} + +export const INBOUND_STATUS_COLOR: Record = { + pending: 'warning', + completed: 'success', + cancelled: 'default', +} diff --git a/src/pages/admin-portal/device-center/stock/instock/api/index.ts b/src/pages/admin-portal/device-center/stock/instock/api/index.ts new file mode 100644 index 0000000..f5dc108 --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/instock/api/index.ts @@ -0,0 +1,17 @@ +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { ListItem, ListParams, ReturnPayload } from '../types' + +export const list = (params: ListParams): Promise> => + postRequest('axiosRequest', '/admin/device/stock/instock/page', params) + +/** 详情查询(详情抽屉回填) */ +export const info = (id: string): Promise> => + postRequest('axiosRequest', '/admin/device/stock/instock/info', { id }) + +/** 回库登记 */ +export const returnGoods = (payload: ReturnPayload): Promise> => + postRequest('axiosRequest', '/admin/device/stock/instock/return', payload) + +export const exportList = (params: ListParams): Promise> => + postRequest('axiosRequest', '/admin/device/stock/instock/export', params) diff --git a/src/pages/admin-portal/device-center/stock/instock/component/drawer/info/api/index.ts b/src/pages/admin-portal/device-center/stock/instock/component/drawer/info/api/index.ts new file mode 100644 index 0000000..1e91c3b --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/instock/component/drawer/info/api/index.ts @@ -0,0 +1,10 @@ +/** + * 在库库存 - 详情抽屉 - 接口层 + */ + +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { DetailInfo } from '../types' + +export const getInfo = (id: string): Promise> => + postRequest('axiosRequest', '/admin/device/stock/instock/info', { id }) diff --git a/src/pages/admin-portal/device-center/stock/instock/component/drawer/info/info.vue b/src/pages/admin-portal/device-center/stock/instock/component/drawer/info/info.vue new file mode 100644 index 0000000..77a5678 --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/instock/component/drawer/info/info.vue @@ -0,0 +1,61 @@ + + + diff --git a/src/pages/admin-portal/device-center/stock/instock/component/drawer/info/init/usePage.ts b/src/pages/admin-portal/device-center/stock/instock/component/drawer/info/init/usePage.ts new file mode 100644 index 0000000..8a19f36 --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/instock/component/drawer/info/init/usePage.ts @@ -0,0 +1,52 @@ +/** + * 在库库存 - 详情抽屉 - 主逻辑(只读) + */ + +import { ref } from 'vue' +import { getInfo } from '../api' +import type { DetailInfo, OpenContext, PageInfo } from '../types' + +export const usePage = () => { + const pageInfo = reactive({ + visible: false, + title: '库存详情', + spin: false, + width: 720, + }) + + const detail = ref(null) + + 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 = (ctx: OpenContext): void => { + detail.value = null + pageInfo.visible = true + pageInfo.title = ctx.title ? `库存详情 · ${ctx.title}` : '库存详情' + loadData(ctx.id) + } + + const closeDrawer = (): void => { + pageInfo.visible = false + } + + return { + pageInfo, + detail, + openDrawer, + closeDrawer, + } +} diff --git a/src/pages/admin-portal/device-center/stock/instock/component/drawer/info/types/index.ts b/src/pages/admin-portal/device-center/stock/instock/component/drawer/info/types/index.ts new file mode 100644 index 0000000..3826a96 --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/instock/component/drawer/info/types/index.ts @@ -0,0 +1,19 @@ +import type { ListItem } from '../../../types' + +/** 抽屉状态 */ +export interface PageInfo { + visible: boolean + title: string + spin: boolean + width: number +} + +/** 详情数据(复用列表项结构) */ +export type DetailInfo = ListItem + +/** 打开抽屉上下文 */ +export interface OpenContext { + id: string + /** 标题后缀,如"智能货柜A1" */ + title?: string +} diff --git a/src/pages/admin-portal/device-center/stock/instock/component/modal/returnGoods/api/index.ts b/src/pages/admin-portal/device-center/stock/instock/component/modal/returnGoods/api/index.ts new file mode 100644 index 0000000..472920a --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/instock/component/modal/returnGoods/api/index.ts @@ -0,0 +1,10 @@ +/** + * 在库库存 - 回库登记 弹框 - 接口层 + */ + +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' + +/** 回库登记 */ +export const returnGoods = (params: Record): Promise> => + postRequest('axiosRequest', '/admin/device/stock/instock/return', params) diff --git a/src/pages/admin-portal/device-center/stock/instock/component/modal/returnGoods/init/usePage.ts b/src/pages/admin-portal/device-center/stock/instock/component/modal/returnGoods/init/usePage.ts new file mode 100644 index 0000000..a230e14 --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/instock/component/modal/returnGoods/init/usePage.ts @@ -0,0 +1,92 @@ +/** + * 在库库存 - 回库登记 弹框 - 主逻辑 + */ + +import type { FormInstance, Rule } from 'ant-design-vue/es/form' +import { useAntdStaticMethods } from '@utils/antDesign/popUp' +import { returnGoods } from '../api' +import type { Form, OpenContext, PageInfo } from '../types' + +const createInitForm = (): Form => ({ + id: undefined, + returnQty: 1, + storageLocation: '', + remark: '', +}) + +export const usePage = (emit: (e: 'load') => void) => { + const { message } = useAntdStaticMethods() + + const pageInfo = reactive({ + visible: false, + title: '回库登记', + width: 520, + spin: false, + }) + + const formRef = ref() + const form = ref(createInitForm()) + + const rules: Record = { + returnQty: [{ required: true, type: 'number', message: '请输入回库数量', trigger: 'blur' }], + storageLocation: [{ required: true, message: '请输入仓储位置', trigger: 'blur' }], + } + + const resetForm = (): void => { + formRef.value?.clearValidate() + form.value = createInitForm() + } + + const closeModal = (): void => { + resetForm() + pageInfo.visible = false + } + + const openModal = (ctx: OpenContext): void => { + pageInfo.visible = true + pageInfo.spin = false + resetForm() + form.value.id = ctx.id + form.value.storageLocation = ctx.storageLocation ?? '' + pageInfo.title = ctx.modelName ? `回库登记 · ${ctx.modelName}` : '回库登记' + } + + const successRequest = (msg: string): void => { + void message.success(msg) + emit('load') + closeModal() + } + + const submit = (): void => { + pageInfo.spin = true + formRef.value + ?.validate() + .then(() => { + const params = JSON.parse(JSON.stringify(form.value)) as Record + returnGoods(params) + .then((res) => { + if (res.code === '00000') successRequest(res.msg) + }) + .catch((err: unknown) => { + console.error('回库登记失败:', err) + }) + .finally(() => { + pageInfo.spin = false + }) + }) + .catch((err: unknown) => { + pageInfo.spin = false + console.error('表单验证失败:', err) + }) + } + + return { + pageInfo, + formRef, + form, + rules, + openModal, + closeModal, + submit, + } +} diff --git a/src/pages/admin-portal/device-center/stock/instock/component/modal/returnGoods/returnGoods.vue b/src/pages/admin-portal/device-center/stock/instock/component/modal/returnGoods/returnGoods.vue new file mode 100644 index 0000000..60db4da --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/instock/component/modal/returnGoods/returnGoods.vue @@ -0,0 +1,81 @@ + + + diff --git a/src/pages/admin-portal/device-center/stock/instock/component/modal/returnGoods/types/index.ts b/src/pages/admin-portal/device-center/stock/instock/component/modal/returnGoods/types/index.ts new file mode 100644 index 0000000..560f316 --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/instock/component/modal/returnGoods/types/index.ts @@ -0,0 +1,35 @@ +import type { FormInstance } from 'ant-design-vue/es/form' + +/** 弹框页面状态 */ +export interface PageInfo { + visible: boolean + title: string + width: number + spin: boolean +} + +/** 打开弹框上下文 */ +export interface OpenContext { + id: string + /** 库存型号名(用于标题) */ + modelName?: string + /** 当前仓储位置(默认值) */ + storageLocation?: string +} + +/** 表单字段 */ +export interface Form { + id: string | undefined + /** 回库数量 */ + returnQty: number + /** 入库后仓储位置 */ + storageLocation: string + /** 备注 */ + remark: string +} + +/** 暴露给父组件的 ref 类型 */ +export type FormRef = ReturnType + +/** 仅用于类型推导的占位函数 */ +declare function usePageRef(): FormInstance | undefined diff --git a/src/pages/admin-portal/device-center/stock/instock/init/usePage.ts b/src/pages/admin-portal/device-center/stock/instock/init/usePage.ts new file mode 100644 index 0000000..6192d5c --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/instock/init/usePage.ts @@ -0,0 +1,120 @@ +import { useAntdStaticMethods } from '@utils/antDesign/popUp' +import { useAdminTable } from '@pages/admin-portal/shared/useAdminTable' +import { useAdminSearch } from '@pages/admin-portal/shared/useAdminSearch' +import { exportList, list } from '../api' +import { tableColumns } from './useTable' +import { createSearchKey } from './useSearch' +import type { ListItem, SearchForm } from '../types' +import type InfoDrawer from '../component/drawer/info/info.vue' +import type ReturnGoodsModal from '../component/modal/returnGoods/returnGoods.vue' + +/** + * 在库库存页主编排: + * - 列表(只读查询) + 详情抽屉 + 回库登记 + 导出 + */ +export const usePage = () => { + const { message } = useAntdStaticMethods() + const { search, initOptions, resetSearch } = useAdminSearch(createSearchKey) + + const pageLoading = ref(false) + const infoDrawerRef = ref | null>(null) + const returnGoodsRef = ref | null>(null) + + const buildParams = () => ({ + ...search, + pageNum: table.pagination.current ?? 1, + pageSize: table.pagination.pageSize ?? 30, + order: table.sort.order, + column: table.sort.field, + }) + + const listRequest = (): void => { + pageLoading.value = true + list(buildParams()) + .then((res) => { + if (res.code === '00000') { + table.dataSource = res.data ?? [] + table.pagination.total = res.total ?? 0 + } + }) + .catch((err: unknown) => { + console.error('在库库存列表请求失败:', err) + }) + .finally(() => { + pageLoading.value = false + }) + } + + const { + table, + dataSourceChange, + resetTable, + selectedRowKeys, + rowSelection, + clearSelection, + } = useAdminTable(tableColumns, listRequest, false) + + const searchQuery = (): void => { + table.pagination.current = 1 + listRequest() + } + + const resetQuery = (): void => { + resetSearch() + resetTable() + clearSelection?.() + listRequest() + } + + const openInfoDrawer = (record: ListItem): void => { + infoDrawerRef.value?.openDrawer({ id: record.id, title: record.modelName }) + } + + const openReturnModal = (record: ListItem): void => { + returnGoodsRef.value?.openModal({ + id: record.id, + modelName: record.modelName, + storageLocation: record.storageLocation, + }) + } + + const exportRecord = (): void => { + exportList(buildParams()) + .then((res) => { + if (res.code === '00000') void message.success(res.msg || '导出任务已提交') + }) + .catch((err: unknown) => { + console.error('在库库存导出失败:', err) + }) + } + + const handleAction = (type: string, record?: ListItem): void => { + if (type === 'info' && record) openInfoDrawer(record) + else if (type === 'return' && record) openReturnModal(record) + } + + const loadData = (): void => { + initOptions() + listRequest() + } + + onMounted(() => { + loadData() + }) + + return { + pageLoading, + search, + table, + selectedRowKeys, + rowSelection, + infoDrawerRef, + returnGoodsRef, + dataSourceChange, + searchQuery, + resetQuery, + handleAction, + listRequest, + exportRecord, + } +} diff --git a/src/pages/admin-portal/device-center/stock/instock/init/useSearch.ts b/src/pages/admin-portal/device-center/stock/instock/init/useSearch.ts new file mode 100644 index 0000000..fc8332b --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/instock/init/useSearch.ts @@ -0,0 +1,7 @@ +import type { SearchForm } from '../types' + +export const createSearchKey = (): SearchForm => ({ + keyword: undefined, + deviceType: undefined, + stockStatus: undefined, +}) diff --git a/src/pages/admin-portal/device-center/stock/instock/init/useTable.ts b/src/pages/admin-portal/device-center/stock/instock/init/useTable.ts new file mode 100644 index 0000000..7fdf369 --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/instock/init/useTable.ts @@ -0,0 +1,34 @@ +import type { TableColumnsType } from 'ant-design-vue' +import { h } from 'vue' +import { Tag } from 'ant-design-vue' +import type { ListItem, StockStatus } from '../types' +import { STOCK_STATUS_COLOR, STOCK_STATUS_OPTIONS } from '../types' + +const statusText = (status: string): string => + STOCK_STATUS_OPTIONS.find((item) => item.value === status)?.label ?? '—' + +export const tableColumns: TableColumnsType = [ + { title: '序号', dataIndex: 'index', align: 'center', fixed: 'left', width: 70, customRender: ({ index }: { index: number }) => index + 1 }, + { title: '型号编码', dataIndex: 'modelCode', align: 'left', width: 150, fixed: 'left', resizable: true, ellipsis: true }, + { title: '二级型号', dataIndex: 'modelName', align: 'left', width: 150, resizable: true, ellipsis: true }, + { title: '设备类型', dataIndex: 'typeName', align: 'center', width: 110, resizable: true, ellipsis: true }, + { title: '批次号', dataIndex: 'batchNo', align: 'left', width: 130, resizable: true, ellipsis: true }, + { title: '供应商', dataIndex: 'supplierName', align: 'left', width: 160, resizable: true, ellipsis: true }, + { title: '采购价(元)', dataIndex: 'purchasePrice', align: 'right', width: 110, sorter: true }, + { title: '入库时间', dataIndex: 'inboundTime', align: 'center', width: 170, sorter: true, resizable: true }, + { title: '仓储位置', dataIndex: 'storageLocation', align: 'left', width: 130, resizable: true, ellipsis: true }, + { title: '库存数量', dataIndex: 'stockQty', align: 'center', width: 100, sorter: true }, + { title: '在途数量', dataIndex: 'inTransitQty', align: 'center', width: 100 }, + { title: '已出库数量', dataIndex: 'shippedQty', align: 'center', width: 110 }, + { title: '可用数量', dataIndex: 'availableQty', align: 'center', width: 100, sorter: true }, + { + title: '库存状态', + dataIndex: 'stockStatus', + align: 'center', + width: 100, + customRender: ({ value }: { value: StockStatus }) => + h(Tag, { color: STOCK_STATUS_COLOR[value] ?? 'default' }, () => statusText(value)), + }, + { title: '更新时间', dataIndex: 'updateTime', align: 'center', width: 170, resizable: true }, + { title: '操作', dataIndex: 'action', align: 'center', fixed: 'right', width: 200 }, +] diff --git a/src/pages/admin-portal/device-center/stock/instock/instock.vue b/src/pages/admin-portal/device-center/stock/instock/instock.vue new file mode 100644 index 0000000..407838b --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/instock/instock.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/src/pages/admin-portal/device-center/stock/instock/types/index.ts b/src/pages/admin-portal/device-center/stock/instock/types/index.ts new file mode 100644 index 0000000..011849a --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/instock/types/index.ts @@ -0,0 +1,83 @@ +import type { BaseListParams, BaseRecord, BaseSearchForm } from '@pages/admin-portal/shared/types' + +/** 库存状态:在库 / 在途 / 已出库 / 已回库 */ +export type StockStatus = 'instock' | 'inbound' | 'shipped' | 'returned' + +export interface SearchForm extends BaseSearchForm { + keyword?: string + deviceType?: string + stockStatus?: string +} + +export interface ListItem extends BaseRecord { + /** 型号编码 */ + 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 +} + +export interface ListParams extends BaseListParams, SearchForm {} + +/** 回库登记 payload */ +export interface ReturnPayload { + id: string + /** 回库数量 */ + returnQty: number + /** 入库后仓储位置 */ + storageLocation: string + /** 备注 */ + remark?: string +} + +export const DEVICE_TYPE_OPTIONS = [ + { value: 'screen', label: '大屏' }, + { value: 'cabinet', label: '餐盘柜' }, + { value: 'shelf', label: '货架' }, + { value: 'scale-inbound', label: '入库秤' }, + { value: 'scale-ratio', label: '配比秤' }, + { value: 'scale-weight', label: '体重秤' }, + { value: 'scale-fat', label: '体脂秤' }, + { value: 'watch', label: '手表' }, + { value: 'fitness', label: '健身器材' }, + { value: 'checkup', label: '体检机' }, +] + +export const STOCK_STATUS_OPTIONS = [ + { value: 'instock', label: '在库' }, + { value: 'inbound', label: '在途' }, + { value: 'shipped', label: '已出库' }, + { value: 'returned', label: '已回库' }, +] + +export const STOCK_STATUS_COLOR: Record = { + instock: 'success', + inbound: 'processing', + shipped: 'default', + returned: 'warning', +} diff --git a/src/pages/admin-portal/device-center/stock/outbound/api/index.ts b/src/pages/admin-portal/device-center/stock/outbound/api/index.ts new file mode 100644 index 0000000..c29e0c2 --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/outbound/api/index.ts @@ -0,0 +1,17 @@ +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { ListItem, ListParams } from '../types' + +export const list = (params: ListParams): Promise> => + postRequest('axiosRequest', '/admin/device/stock/outbound/page', params) + +export const remove = (id: string): Promise> => + postRequest('axiosRequest', '/admin/device/stock/outbound/delete', { id }) + +/** 撤回(已签收的出库单不可撤回) */ +export const withdraw = (id: string): Promise> => + postRequest('axiosRequest', '/admin/device/stock/outbound/withdraw', { id }) + +/** 导出 */ +export const exportList = (params: ListParams): Promise> => + postRequest('axiosRequest', '/admin/device/stock/outbound/export', params) diff --git a/src/pages/admin-portal/device-center/stock/outbound/component/modal/addOrEdit/addOrEdit.vue b/src/pages/admin-portal/device-center/stock/outbound/component/modal/addOrEdit/addOrEdit.vue new file mode 100644 index 0000000..47876a8 --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/outbound/component/modal/addOrEdit/addOrEdit.vue @@ -0,0 +1,163 @@ + + + diff --git a/src/pages/admin-portal/device-center/stock/outbound/component/modal/addOrEdit/api/index.ts b/src/pages/admin-portal/device-center/stock/outbound/component/modal/addOrEdit/api/index.ts new file mode 100644 index 0000000..9ed7154 --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/outbound/component/modal/addOrEdit/api/index.ts @@ -0,0 +1,19 @@ +/** + * 出库管理 - 新增/编辑 弹框 - 接口层 + */ + +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { Form } from '../types' + +/** 详情查询(编辑回填) */ +export const info = (id: string): Promise> => + postRequest('axiosRequest', '/admin/device/stock/outbound/info', { id }) + +/** 新增 */ +export const add = (params: Record): Promise> => + postRequest('axiosRequest', '/admin/device/stock/outbound/add', params) + +/** 编辑 */ +export const edit = (params: Record): Promise> => + postRequest('axiosRequest', '/admin/device/stock/outbound/update', params) diff --git a/src/pages/admin-portal/device-center/stock/outbound/component/modal/addOrEdit/init/usePage.ts b/src/pages/admin-portal/device-center/stock/outbound/component/modal/addOrEdit/init/usePage.ts new file mode 100644 index 0000000..0946053 --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/outbound/component/modal/addOrEdit/init/usePage.ts @@ -0,0 +1,148 @@ +/** + * 出库管理 - 新增/编辑 弹框 - 主逻辑 + */ + +import type { FormInstance, Rule } from 'ant-design-vue/es/form' +import { useAntdStaticMethods } from '@utils/antDesign/popUp' +import { add, edit, info } from '../api' +import type { Form, ModalType, OpenContext, PageInfo } from '../types' + +const createInitForm = (): Form => ({ + id: undefined, + outboundCode: '', + deviceCode: '', + deviceName: '', + modelName: '', + quantity: 1, + recipient: '', + targetSite: '', + targetCustomer: '', + outboundType: 'install', + status: 'completed', + remark: '', +}) + +export const usePage = (emit: (e: 'load') => void) => { + const { message } = useAntdStaticMethods() + + const pageInfo = reactive({ + visible: false, + type: 'add', + title: '', + width: 760, + spin: false, + }) + + const formRef = ref() + const form = ref(createInitForm()) + + const rules: Record = { + deviceCode: [{ required: true, message: '请输入设备编码', trigger: 'blur' }], + deviceName: [{ required: true, message: '请输入设备名称', trigger: 'blur' }], + modelName: [{ required: true, message: '请输入二级型号', trigger: 'blur' }], + quantity: [{ required: true, type: 'number', message: '请输入数量', trigger: 'blur' }], + recipient: [{ required: true, message: '请输入领用人', trigger: 'blur' }], + targetSite: [{ required: true, message: '请输入目的场所', trigger: 'blur' }], + targetCustomer: [{ required: true, message: '请输入所属租户', trigger: 'blur' }], + outboundType: [{ required: true, message: '请选择出库类型', trigger: 'change' }], + } + + const resetForm = (): void => { + formRef.value?.clearValidate() + form.value = createInitForm() + } + + const closeModal = (): void => { + resetForm() + pageInfo.visible = false + } + + const infoRequest = (id: string) => info(id) + + const openModal = (type: ModalType, ctx?: OpenContext): void => { + pageInfo.visible = true + pageInfo.spin = true + pageInfo.type = type + resetForm() + + pageInfo.title = type === 'add' ? '新增出库单' : '编辑出库单' + + const requests: Promise[] = [] + 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' && infoRes.data) { + form.value = { ...createInitForm(), ...infoRes.data } + } + } + }) + .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): void => { + delete params.id + delete params.outboundCode + 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): 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 = JSON.parse(JSON.stringify(form.value)) as Record + if (params.id) editRequest(params) + else addRequest(params) + }) + .catch((err: unknown) => { + pageInfo.spin = false + console.error('表单验证失败:', err) + }) + } + + return { + pageInfo, + formRef, + form, + rules, + openModal, + closeModal, + submit, + } +} diff --git a/src/pages/admin-portal/device-center/stock/outbound/component/modal/addOrEdit/types/index.ts b/src/pages/admin-portal/device-center/stock/outbound/component/modal/addOrEdit/types/index.ts new file mode 100644 index 0000000..b7b1609 --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/outbound/component/modal/addOrEdit/types/index.ts @@ -0,0 +1,53 @@ +import type { FormInstance } from 'ant-design-vue/es/form' +import type { OutboundStatus, OutboundType } from '../../../types' + +/** 弹框类型 */ +export type ModalType = 'add' | 'edit' + +/** 弹框页面状态 */ +export interface PageInfo { + visible: boolean + type: ModalType + title: string + width: number + spin: boolean +} + +/** 打开弹框上下文 */ +export interface OpenContext { + /** 编辑模式必传 */ + id?: string +} + +/** 表单字段 */ +export interface Form { + id: string | undefined + /** 出库单号(编辑时只读) */ + outboundCode: string + /** 设备编码 */ + deviceCode: string + /** 设备名称 */ + deviceName: string + /** 二级型号 */ + modelName: string + /** 数量 */ + quantity: number + /** 领用人 */ + recipient: string + /** 目的场所 */ + targetSite: string + /** 所属租户 */ + targetCustomer: string + /** 出库类型 */ + outboundType: OutboundType + /** 状态 */ + status: OutboundStatus + /** 备注 */ + remark: string +} + +/** 暴露给父组件的 ref 类型 */ +export type FormRef = ReturnType + +/** 仅用于类型推导的占位函数 */ +declare function usePageRef(): FormInstance | undefined diff --git a/src/pages/admin-portal/device-center/stock/outbound/init/usePage.ts b/src/pages/admin-portal/device-center/stock/outbound/init/usePage.ts new file mode 100644 index 0000000..55425a9 --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/outbound/init/usePage.ts @@ -0,0 +1,165 @@ +import { useAntdStaticMethods } from '@utils/antDesign/popUp' +import { useAdminTable } from '@pages/admin-portal/shared/useAdminTable' +import { useAdminSearch } from '@pages/admin-portal/shared/useAdminSearch' +import { exportList, list, remove, withdraw } from '../api' +import { tableColumns } from './useTable' +import { createSearchKey } from './useSearch' +import type { ListItem, SearchForm } from '../types' +import type AddOrEdit from '../component/modal/addOrEdit/addOrEdit.vue' + +/** + * 出库管理页主编排: + * - 列表 + 新增 + 编辑 + 删除 + 撤回 + 导出 + * - 完整表单弹框(含出库类型 / 领用人 / 目的场所 / 所属租户) + */ +export const usePage = () => { + const { message, Modal } = useAntdStaticMethods() + const { search, initOptions, resetSearch } = useAdminSearch(createSearchKey) + + const pageLoading = ref(false) + const addOrEditRef = ref | null>(null) + + const buildParams = () => ({ + ...search, + pageNum: table.pagination.current ?? 1, + pageSize: table.pagination.pageSize ?? 30, + order: table.sort.order, + column: table.sort.field, + }) + + const listRequest = (): void => { + pageLoading.value = true + list(buildParams()) + .then((res) => { + if (res.code === '00000') { + table.dataSource = res.data ?? [] + table.pagination.total = res.total ?? 0 + } + }) + .catch((err: unknown) => { + console.error('出库单列表请求失败:', err) + }) + .finally(() => { + pageLoading.value = false + }) + } + + const { + table, + dataSourceChange, + resetTable, + selectedRowKeys, + rowSelection, + clearSelection, + } = useAdminTable(tableColumns, listRequest, false) + + const searchQuery = (): void => { + table.pagination.current = 1 + listRequest() + } + + const resetQuery = (): void => { + resetSearch() + resetTable() + clearSelection?.() + listRequest() + } + + const openAddModal = (): void => { + addOrEditRef.value?.openModal('add') + } + + const openEditModal = (record: ListItem): void => { + addOrEditRef.value?.openModal('edit', { id: record.id }) + } + + const deleteRecord = (record: ListItem): void => { + Modal.confirm({ + title: '确认删除?', + content: `出库单「${record.outboundCode}」删除后不可恢复。`, + 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) + }) + }, + }) + } + + /** 撤回:已签收的出库单禁止撤回 */ + const withdrawRecord = (record: ListItem): void => { + if (record.status === 'signed') { + void message.warning('已签收的出库单不可撤回') + return + } + Modal.confirm({ + title: '确认撤回?', + content: `出库单「${record.outboundCode}」撤回后将标记为已撤回。`, + okType: 'danger', + okText: '确认撤回', + cancelText: '取消', + onOk: () => { + withdraw(record.id) + .then((res) => { + if (res.code === '00000') { + void message.success(res.msg) + listRequest() + } + }) + .catch((err: unknown) => { + console.error('出库单撤回失败:', err) + }) + }, + }) + } + + const exportRecord = (): void => { + exportList(buildParams()) + .then((res) => { + if (res.code === '00000') void message.success(res.msg || '导出任务已提交') + }) + .catch((err: unknown) => { + console.error('出库单导出失败:', err) + }) + } + + const handleAction = (type: string, record?: ListItem): void => { + if (type === 'add') openAddModal() + else if (type === 'edit' && record) openEditModal(record) + else if (type === 'delete' && record) deleteRecord(record) + else if (type === 'withdraw' && record) withdrawRecord(record) + } + + const loadData = (): void => { + initOptions() + listRequest() + } + + onMounted(() => { + loadData() + }) + + return { + pageLoading, + search, + table, + selectedRowKeys, + rowSelection, + addOrEditRef, + dataSourceChange, + searchQuery, + resetQuery, + handleAction, + listRequest, + exportRecord, + } +} diff --git a/src/pages/admin-portal/device-center/stock/outbound/init/useSearch.ts b/src/pages/admin-portal/device-center/stock/outbound/init/useSearch.ts new file mode 100644 index 0000000..68ba8e8 --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/outbound/init/useSearch.ts @@ -0,0 +1,7 @@ +import type { SearchForm } from '../types' + +export const createSearchKey = (): SearchForm => ({ + keyword: undefined, + status: undefined, + outboundType: undefined, +}) diff --git a/src/pages/admin-portal/device-center/stock/outbound/init/useTable.ts b/src/pages/admin-portal/device-center/stock/outbound/init/useTable.ts new file mode 100644 index 0000000..8f79de4 --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/outbound/init/useTable.ts @@ -0,0 +1,47 @@ +import type { TableColumnsType } from 'ant-design-vue' +import { h } from 'vue' +import { Tag } from 'ant-design-vue' +import type { OutboundStatus, OutboundType, ListItem } from '../types' +import { + OUTBOUND_STATUS_COLOR, + OUTBOUND_STATUS_OPTIONS, + OUTBOUND_TYPE_COLOR, + OUTBOUND_TYPE_OPTIONS, +} from '../types' + +const statusText = (status: string): string => + OUTBOUND_STATUS_OPTIONS.find((item) => item.value === status)?.label ?? '—' + +const typeText = (type: string): string => + OUTBOUND_TYPE_OPTIONS.find((item) => item.value === type)?.label ?? '—' + +export const tableColumns: TableColumnsType = [ + { title: '序号', dataIndex: 'index', align: 'center', fixed: 'left', width: 70, customRender: ({ index }: { index: number }) => index + 1 }, + { title: '出库单号', dataIndex: 'outboundCode', align: 'left', width: 170, fixed: 'left', resizable: true, ellipsis: true }, + { title: '设备编码', dataIndex: 'deviceCode', align: 'left', width: 150, resizable: true, ellipsis: true }, + { title: '设备名称', dataIndex: 'deviceName', align: 'left', width: 140, resizable: true, ellipsis: true }, + { title: '二级型号', dataIndex: 'modelName', align: 'left', width: 130, resizable: true, ellipsis: true }, + { title: '所属租户', dataIndex: 'targetCustomer', align: 'left', width: 150, resizable: true, ellipsis: true }, + { title: '目的场所', dataIndex: 'targetSite', align: 'left', width: 150, resizable: true, ellipsis: true }, + { title: '数量', dataIndex: 'quantity', align: 'center', width: 80 }, + { title: '领用人', dataIndex: 'recipient', align: 'center', width: 100, resizable: true, ellipsis: true }, + { + title: '出库类型', + dataIndex: 'outboundType', + align: 'center', + width: 110, + customRender: ({ value }: { value: OutboundType }) => + h(Tag, { color: OUTBOUND_TYPE_COLOR[value] ?? 'default' }, () => typeText(value)), + }, + { + title: '状态', + dataIndex: 'status', + align: 'center', + width: 100, + customRender: ({ value }: { value: OutboundStatus }) => + h(Tag, { color: OUTBOUND_STATUS_COLOR[value] ?? 'default' }, () => statusText(value)), + }, + { title: '出库时间', dataIndex: 'outboundTime', align: 'center', width: 170, sorter: true, resizable: true }, + { title: '经办人', dataIndex: 'operator', align: 'center', width: 110, resizable: true, ellipsis: true }, + { title: '操作', dataIndex: 'action', align: 'center', fixed: 'right', width: 200 }, +] diff --git a/src/pages/admin-portal/device-center/stock/outbound/outbound.vue b/src/pages/admin-portal/device-center/stock/outbound/outbound.vue new file mode 100644 index 0000000..7b1d9cc --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/outbound/outbound.vue @@ -0,0 +1,81 @@ + + + + + diff --git a/src/pages/admin-portal/device-center/stock/outbound/types/index.ts b/src/pages/admin-portal/device-center/stock/outbound/types/index.ts new file mode 100644 index 0000000..63c3b07 --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/outbound/types/index.ts @@ -0,0 +1,85 @@ +import type { BaseListParams, BaseRecord, BaseSearchForm } from '@pages/admin-portal/shared/types' + +/** 出库单状态 */ +export type OutboundStatus = 'pending' | 'completed' | 'signed' | 'cancelled' + +/** 出库类型 */ +export type OutboundType = 'install' | 'transfer' | 'scrap' + +export interface SearchForm extends BaseSearchForm { + keyword?: string + status?: string + outboundType?: string +} + +export interface ListItem extends BaseRecord { + /** 出库单号 */ + outboundCode: string + /** 设备编码 */ + deviceCode: string + /** 设备名称 */ + deviceName: string + /** 二级型号 */ + modelName: string + /** 数量 */ + quantity: number + /** 领用人 */ + recipient: string + /** 目的场所 */ + targetSite: string + /** 所属租户 */ + targetCustomer: string + /** 出库类型 */ + outboundType: OutboundType + /** 经办人 */ + operator: string + /** 出库时间 */ + outboundTime: string + /** 签收时间 */ + signedTime?: string + /** 状态 */ + status: OutboundStatus + /** 备注 */ + remark?: string +} + +export interface ListParams extends BaseListParams, SearchForm {} + +export interface OutboundPayload { + id?: string + deviceCode: string + deviceName: string + modelName: string + quantity: number + recipient: string + targetSite: string + targetCustomer: string + outboundType: OutboundType + remark?: string +} + +export const OUTBOUND_STATUS_OPTIONS = [ + { value: 'pending', label: '待出库' }, + { value: 'completed', label: '已出库' }, + { value: 'signed', label: '已签收' }, + { value: 'cancelled', label: '已撤回' }, +] + +export const OUTBOUND_TYPE_OPTIONS = [ + { value: 'install', label: '装机出库' }, + { value: 'transfer', label: '调拨出库' }, + { value: 'scrap', label: '报废出库' }, +] + +export const OUTBOUND_STATUS_COLOR: Record = { + pending: 'warning', + completed: 'processing', + signed: 'success', + cancelled: 'default', +} + +export const OUTBOUND_TYPE_COLOR: Record = { + install: 'blue', + transfer: 'orange', + scrap: 'red', +} diff --git a/src/pages/admin-portal/device-center/stock/supplier/api/index.ts b/src/pages/admin-portal/device-center/stock/supplier/api/index.ts new file mode 100644 index 0000000..c813b08 --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/supplier/api/index.ts @@ -0,0 +1,9 @@ +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { ListItem, ListParams } from '../types' + +export const list = (params: ListParams): Promise> => + postRequest('axiosRequest', '/admin/device/stock/supplier/page', params) + +export const remove = (id: string): Promise> => + postRequest('axiosRequest', '/admin/device/stock/supplier/delete', { id }) diff --git a/src/pages/admin-portal/device-center/stock/supplier/component/modal/addOrEdit/addOrEdit.vue b/src/pages/admin-portal/device-center/stock/supplier/component/modal/addOrEdit/addOrEdit.vue new file mode 100644 index 0000000..df60841 --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/supplier/component/modal/addOrEdit/addOrEdit.vue @@ -0,0 +1,167 @@ + + + diff --git a/src/pages/admin-portal/device-center/stock/supplier/component/modal/addOrEdit/api/index.ts b/src/pages/admin-portal/device-center/stock/supplier/component/modal/addOrEdit/api/index.ts new file mode 100644 index 0000000..cbfe3aa --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/supplier/component/modal/addOrEdit/api/index.ts @@ -0,0 +1,21 @@ +/** + * 供应商管理 - 新增/编辑 弹框 - 接口层 + * - 复用主页 page 接口 + * - 新增 info / add / update 接口 + */ + +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { Form } from '../types' + +/** 详情查询(编辑回填) */ +export const info = (id: string): Promise> => + postRequest('axiosRequest', '/admin/device/stock/supplier/info', { id }) + +/** 新增 */ +export const add = (params: Record): Promise> => + postRequest('axiosRequest', '/admin/device/stock/supplier/add', params) + +/** 编辑 */ +export const edit = (params: Record): Promise> => + postRequest('axiosRequest', '/admin/device/stock/supplier/update', params) diff --git a/src/pages/admin-portal/device-center/stock/supplier/component/modal/addOrEdit/init/usePage.ts b/src/pages/admin-portal/device-center/stock/supplier/component/modal/addOrEdit/init/usePage.ts new file mode 100644 index 0000000..d5ee082 --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/supplier/component/modal/addOrEdit/init/usePage.ts @@ -0,0 +1,170 @@ +/** + * 供应商管理 - 新增/编辑 弹框 - 主逻辑 + * + * 严格遵循 modal-spec.md: + * - createInitForm 工厂函数 + * - openModal 用 Promise.all 拉取详情 + * - addRequest 必须 delete params.id + * - submit 开头立即 pageInfo.spin = true + * - 用 params.id 判断新增/编辑 + */ + +import type { FormInstance, Rule } from 'ant-design-vue/es/form' +import { useAntdStaticMethods } from '@utils/antDesign/popUp' +import { add, edit, info } from '../api' +import type { Form, ModalType, OpenContext, PageInfo } from '../types' + +/** 初始表单工厂 */ +const createInitForm = (): Form => ({ + id: undefined, + supplierCode: '', + name: '', + contactName: '', + contactPhone: '', + email: '', + address: '', + mainCategory: '', + cooperationStart: '', + cooperationStatus: 'active', + remark: '', +}) + +export const usePage = (emit: (e: 'load') => void) => { + const { message } = useAntdStaticMethods() + + const pageInfo = reactive({ + visible: false, + type: 'add', + title: '', + width: 720, + spin: false, + }) + + const formRef = ref() + const form = ref(createInitForm()) + + /** 联系电话校验:允许座机 / 手机 */ + const phoneValidator = (_rule: unknown, value: string): Promise => { + if (!value) return Promise.reject(new Error('请输入联系电话')) + const ok = /^1[3-9]\d{9}$|^\d{3,4}-?\d{7,8}$/.test(value) + return ok ? Promise.resolve() : Promise.reject(new Error('请输入正确的手机或座机号')) + } + + /** 邮箱校验(可选,填写时必须合法) */ + const emailValidator = (_rule: unknown, value: string): Promise => { + if (!value) return Promise.resolve() + return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value) + ? Promise.resolve() + : Promise.reject(new Error('邮箱格式不正确')) + } + + const rules: Record = { + supplierCode: [{ required: true, message: '请输入供应商编码', trigger: 'blur' }], + name: [{ required: true, message: '请输入供应商名称', trigger: 'blur' }], + contactName: [{ required: true, message: '请输入联系人', trigger: 'blur' }], + contactPhone: [{ required: true, validator: phoneValidator, trigger: 'blur' }], + email: [{ validator: emailValidator, trigger: 'blur' }], + address: [{ required: true, message: '请输入地址', trigger: 'blur' }], + mainCategory: [{ required: true, message: '请输入主营品类', trigger: 'blur' }], + cooperationStart: [{ required: true, message: '请选择合作起始日期', trigger: 'change' }], + } + + const resetForm = (): void => { + formRef.value?.clearValidate() + form.value = createInitForm() + } + + const closeModal = (): void => { + resetForm() + pageInfo.visible = false + } + + const infoRequest = (id: string) => info(id) + + /** 打开弹框 */ + const openModal = (type: ModalType, ctx?: OpenContext): void => { + pageInfo.visible = true + pageInfo.spin = true + pageInfo.type = type + resetForm() + + pageInfo.title = type === 'add' ? '新增供应商' : '编辑供应商' + + const requests: Promise[] = [] + 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' && infoRes.data) { + form.value = { ...createInitForm(), ...infoRes.data } + } + } + }) + .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): 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): 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 = JSON.parse(JSON.stringify(form.value)) as Record + if (params.id) editRequest(params) + else addRequest(params) + }) + .catch((err: unknown) => { + pageInfo.spin = false + console.error('表单验证失败:', err) + }) + } + + return { + pageInfo, + formRef, + form, + rules, + openModal, + closeModal, + submit, + } +} diff --git a/src/pages/admin-portal/device-center/stock/supplier/component/modal/addOrEdit/types/index.ts b/src/pages/admin-portal/device-center/stock/supplier/component/modal/addOrEdit/types/index.ts new file mode 100644 index 0000000..48b54f1 --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/supplier/component/modal/addOrEdit/types/index.ts @@ -0,0 +1,50 @@ +import type { FormInstance } from 'ant-design-vue/es/form' + +/** 弹框类型 */ +export type ModalType = 'add' | 'edit' + +/** 弹框页面状态 */ +export interface PageInfo { + visible: boolean + type: ModalType + title: string + width: number + spin: boolean +} + +/** 打开弹框上下文 */ +export interface OpenContext { + /** 编辑模式必传 */ + id?: string +} + +/** 表单字段 */ +export interface Form { + id: string | undefined + /** 供应商编码 */ + supplierCode: string + /** 供应商名称 */ + name: string + /** 联系人 */ + contactName: string + /** 联系电话 */ + contactPhone: string + /** 邮箱 */ + email: string + /** 地址 */ + address: string + /** 主营品类 */ + mainCategory: string + /** 合作起始日期 */ + cooperationStart: string + /** 合作状态 */ + cooperationStatus: 'active' | 'suspended' | 'terminated' + /** 备注 */ + remark: string +} + +/** 暴露给父组件的 ref 类型 */ +export type FormRef = ReturnType + +/** 仅用于类型推导的占位函数 */ +declare function usePageRef(): FormInstance | undefined diff --git a/src/pages/admin-portal/device-center/stock/supplier/init/usePage.ts b/src/pages/admin-portal/device-center/stock/supplier/init/usePage.ts new file mode 100644 index 0000000..ba07754 --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/supplier/init/usePage.ts @@ -0,0 +1,130 @@ +import { useAntdStaticMethods } from '@utils/antDesign/popUp' +import { useAdminTable } from '@pages/admin-portal/shared/useAdminTable' +import { useAdminSearch } from '@pages/admin-portal/shared/useAdminSearch' +import { list, remove } from '../api' +import { tableColumns } from './useTable' +import { createSearchKey } from './useSearch' +import type { ListItem, SearchForm } from '../types' +import type AddOrEdit from '../component/modal/addOrEdit/addOrEdit.vue' + +/** + * 供应商管理页主编排: + * - 标准 CRUD + 完整表单弹框 + * - 复用 useAdminTable / useAdminSearch 公共钩子保持与列表页规范一致 + */ +export const usePage = () => { + const { message, Modal } = useAntdStaticMethods() + const { search, initOptions, resetSearch } = useAdminSearch(createSearchKey) + + const pageLoading = ref(false) + const addOrEditRef = ref | null>(null) + + const buildParams = () => ({ + ...search, + pageNum: table.pagination.current ?? 1, + pageSize: table.pagination.pageSize ?? 30, + order: table.sort.order, + column: table.sort.field, + }) + + const listRequest = (): void => { + pageLoading.value = true + list(buildParams()) + .then((res) => { + if (res.code === '00000') { + table.dataSource = res.data ?? [] + table.pagination.total = res.total ?? 0 + } + }) + .catch((err: unknown) => { + console.error('供应商列表请求失败:', err) + }) + .finally(() => { + pageLoading.value = false + }) + } + + const { + table, + dataSourceChange, + resetTable, + selectedRowKeys, + rowSelection, + clearSelection, + } = useAdminTable(tableColumns, listRequest, false) + + const searchQuery = (): void => { + table.pagination.current = 1 + listRequest() + } + + const resetQuery = (): void => { + resetSearch() + resetTable() + clearSelection?.() + listRequest() + } + + /** 新增 */ + const openAddModal = (): void => { + addOrEditRef.value?.openModal('add') + } + + /** 编辑 */ + const openEditModal = (record: ListItem): void => { + addOrEditRef.value?.openModal('edit', { id: record.id }) + } + + /** 删除二次确认 */ + const deleteRecord = (record: ListItem): void => { + Modal.confirm({ + title: '确认删除?', + content: `删除供应商「${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) + }) + }, + }) + } + + /** 操作列分发 */ + const handleAction = (type: string, record?: ListItem): void => { + if (type === 'add') openAddModal() + else if (type === 'edit' && record) openEditModal(record) + else if (type === 'delete' && record) deleteRecord(record) + } + + const loadData = (): void => { + initOptions() + listRequest() + } + + onMounted(() => { + loadData() + }) + + return { + pageLoading, + search, + table, + selectedRowKeys, + rowSelection, + addOrEditRef, + dataSourceChange, + searchQuery, + resetQuery, + handleAction, + listRequest, + } +} diff --git a/src/pages/admin-portal/device-center/stock/supplier/init/useSearch.ts b/src/pages/admin-portal/device-center/stock/supplier/init/useSearch.ts new file mode 100644 index 0000000..178b902 --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/supplier/init/useSearch.ts @@ -0,0 +1,6 @@ +import type { SearchForm } from '../types' + +export const createSearchKey = (): SearchForm => ({ + keyword: undefined, + cooperationStatus: undefined, +}) diff --git a/src/pages/admin-portal/device-center/stock/supplier/init/useTable.ts b/src/pages/admin-portal/device-center/stock/supplier/init/useTable.ts new file mode 100644 index 0000000..bd4ee58 --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/supplier/init/useTable.ts @@ -0,0 +1,34 @@ +import type { TableColumnsType } from 'ant-design-vue' +import { h } from 'vue' +import { Tag } from 'ant-design-vue' +import type { ListItem } from '../types' +import { COOPERATION_STATUS_OPTIONS } from '../types' + +const statusColor: Record = { + active: 'success', + suspended: 'warning', + terminated: 'default', +} + +const statusText = (status: string): string => + COOPERATION_STATUS_OPTIONS.find((item) => item.value === status)?.label ?? '—' + +export const tableColumns: TableColumnsType = [ + { title: '序号', dataIndex: 'index', align: 'center', fixed: 'left', width: 70, customRender: ({ index }: { index: number }) => index + 1 }, + { title: '供应商编码', dataIndex: 'supplierCode', align: 'left', width: 140, resizable: true, ellipsis: true }, + { title: '供应商名称', dataIndex: 'name', align: 'left', width: 180, resizable: true, ellipsis: true }, + { title: '联系人', dataIndex: 'contactName', align: 'center', width: 100, resizable: true, ellipsis: true }, + { title: '联系电话', dataIndex: 'contactPhone', align: 'center', width: 140, resizable: true, ellipsis: true }, + { title: '主营品类', dataIndex: 'mainCategory', align: 'left', width: 160, resizable: true, ellipsis: true }, + { title: '地址', dataIndex: 'address', align: 'left', width: 220, resizable: true, ellipsis: true }, + { + title: '合作状态', + dataIndex: 'cooperationStatus', + align: 'center', + width: 110, + customRender: ({ value }: { value: string }) => + h(Tag, { color: statusColor[value] ?? 'default' }, () => statusText(value)), + }, + { title: '合作起始', dataIndex: 'cooperationStart', align: 'center', width: 120, sorter: true, resizable: true }, + { title: '操作', dataIndex: 'action', align: 'center', fixed: 'right', width: 180 }, +] diff --git a/src/pages/admin-portal/device-center/stock/supplier/supplier.vue b/src/pages/admin-portal/device-center/stock/supplier/supplier.vue new file mode 100644 index 0000000..d9cb329 --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/supplier/supplier.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/src/pages/admin-portal/device-center/stock/supplier/types/index.ts b/src/pages/admin-portal/device-center/stock/supplier/types/index.ts new file mode 100644 index 0000000..ed5703b --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/supplier/types/index.ts @@ -0,0 +1,46 @@ +import type { BaseListParams, BaseRecord, BaseSearchForm } from '@pages/admin-portal/shared/types' + +export interface SearchForm extends BaseSearchForm { + keyword?: string + cooperationStatus?: string +} + +export interface ListItem extends BaseRecord { + /** 供应商编码 */ + supplierCode: string + /** 供应商名称 */ + name: string + /** 联系人 */ + contactName: string + /** 联系电话 */ + contactPhone: string + /** 邮箱 */ + email?: string + /** 地址 */ + address: string + /** 合作起始日期 */ + cooperationStart: string + /** 合作状态 */ + cooperationStatus: 'active' | 'suspended' | 'terminated' + /** 主营品类 */ + mainCategory: string +} + +export interface ListParams extends BaseListParams, SearchForm {} + +export interface SupplierPayload { + id?: string + supplierCode: string + name: string + contactName: string + contactPhone: string + email?: string + address: string + mainCategory: string +} + +export const COOPERATION_STATUS_OPTIONS = [ + { value: 'active', label: '合作中' }, + { value: 'suspended', label: '已暂停' }, + { value: 'terminated', label: '已终止' }, +] diff --git a/src/pages/admin-portal/device-center/tenantSite/site/api/index.ts b/src/pages/admin-portal/device-center/tenantSite/site/api/index.ts new file mode 100644 index 0000000..6eaaeb6 --- /dev/null +++ b/src/pages/admin-portal/device-center/tenantSite/site/api/index.ts @@ -0,0 +1,27 @@ +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { SearchForm, SiteItem, SitePayload } from '../types' + +/** 查询布点场所列表 */ +export const list = (params: SearchForm): Promise> => + postRequest('axiosRequest', '/admin/device/site/list', params) + +/** 查询布点场所详情 */ +export const info = (id: string): Promise> => + postRequest('axiosRequest', '/admin/device/site/info', { id }) + +/** 新增布点场所 */ +export const add = (payload: SitePayload): Promise> => + postRequest('axiosRequest', '/admin/device/site/add', payload) + +/** 更新布点场所 */ +export const update = (payload: SitePayload): Promise> => + postRequest('axiosRequest', '/admin/device/site/update', payload) + +/** 删除布点场所 */ +export const remove = (id: string): Promise> => + postRequest('axiosRequest', '/admin/device/site/delete', { id }) + +/** 查询租户下拉(只读消费,与 tenantList 同源) */ +export const tenantOptions = (): Promise> => + postRequest('axiosRequest', '/admin/tenant/page', { pageNum: 1, pageSize: 200 }) diff --git a/src/pages/admin-portal/device-center/tenantSite/site/component/drawer/info/api/index.ts b/src/pages/admin-portal/device-center/tenantSite/site/component/drawer/info/api/index.ts new file mode 100644 index 0000000..685504c --- /dev/null +++ b/src/pages/admin-portal/device-center/tenantSite/site/component/drawer/info/api/index.ts @@ -0,0 +1,11 @@ +/** + * 布点场所 - 详情抽屉 - 接口层 + */ + +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { DetailInfo } from '../types' + +/** 详情查询 */ +export const info = (id: string): Promise> => + postRequest('axiosRequest', '/admin/device/site/info', { id }) diff --git a/src/pages/admin-portal/device-center/tenantSite/site/component/drawer/info/info.vue b/src/pages/admin-portal/device-center/tenantSite/site/component/drawer/info/info.vue new file mode 100644 index 0000000..8d9e978 --- /dev/null +++ b/src/pages/admin-portal/device-center/tenantSite/site/component/drawer/info/info.vue @@ -0,0 +1,50 @@ + + + diff --git a/src/pages/admin-portal/device-center/tenantSite/site/component/drawer/info/init/usePage.ts b/src/pages/admin-portal/device-center/tenantSite/site/component/drawer/info/init/usePage.ts new file mode 100644 index 0000000..4310cc4 --- /dev/null +++ b/src/pages/admin-portal/device-center/tenantSite/site/component/drawer/info/init/usePage.ts @@ -0,0 +1,48 @@ +/** + * 布点场所 - 详情抽屉 - 主逻辑 + */ + +import { info } from '../api' +import type { DetailInfo, OpenContext, PageInfo } from '../types' + +export const usePage = () => { + const pageInfo = reactive({ + visible: false, + title: '场所详情', + width: 720, + spin: false, + }) + + const detail = ref(null) + + const closeDrawer = (): void => { + pageInfo.visible = false + detail.value = null + } + + const openDrawer = (ctx: OpenContext): void => { + pageInfo.visible = true + pageInfo.spin = true + detail.value = null + + info(ctx.id) + .then((res) => { + if (res.code === '00000' && res.data) { + detail.value = res.data + } + }) + .catch((err: unknown) => { + console.error('布点场所详情查询失败:', err) + }) + .finally(() => { + pageInfo.spin = false + }) + } + + return { + pageInfo, + detail, + openDrawer, + closeDrawer, + } +} diff --git a/src/pages/admin-portal/device-center/tenantSite/site/component/drawer/info/types/index.ts b/src/pages/admin-portal/device-center/tenantSite/site/component/drawer/info/types/index.ts new file mode 100644 index 0000000..b254098 --- /dev/null +++ b/src/pages/admin-portal/device-center/tenantSite/site/component/drawer/info/types/index.ts @@ -0,0 +1,48 @@ +/** 抽屉页面状态 */ +export interface PageInfo { + visible: boolean + title: string + width: number + spin: boolean +} + +/** 打开抽屉上下文 */ +export interface OpenContext { + /** 必传 */ + id: string +} + +/** 详情数据(与列表项 SiteItem 同构) */ +export interface DetailInfo { + id: string + /** 场所名称 */ + name: string + /** 场所编号 */ + code: string + /** 所属租户 ID */ + 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 +} diff --git a/src/pages/admin-portal/device-center/tenantSite/site/component/modal/addOrEdit/addOrEdit.vue b/src/pages/admin-portal/device-center/tenantSite/site/component/modal/addOrEdit/addOrEdit.vue new file mode 100644 index 0000000..5ef519c --- /dev/null +++ b/src/pages/admin-portal/device-center/tenantSite/site/component/modal/addOrEdit/addOrEdit.vue @@ -0,0 +1,172 @@ + + + diff --git a/src/pages/admin-portal/device-center/tenantSite/site/component/modal/addOrEdit/api/index.ts b/src/pages/admin-portal/device-center/tenantSite/site/component/modal/addOrEdit/api/index.ts new file mode 100644 index 0000000..f3db938 --- /dev/null +++ b/src/pages/admin-portal/device-center/tenantSite/site/component/modal/addOrEdit/api/index.ts @@ -0,0 +1,19 @@ +/** + * 布点场所 - 新增/编辑 弹框 - 接口层 + */ + +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { Form } from '../types' + +/** 详情查询(编辑回填) */ +export const info = (id: string): Promise> => + postRequest('axiosRequest', '/admin/device/site/info', { id }) + +/** 新增 */ +export const add = (params: Record): Promise> => + postRequest('axiosRequest', '/admin/device/site/add', params) + +/** 编辑 */ +export const edit = (params: Record): Promise> => + postRequest('axiosRequest', '/admin/device/site/update', params) diff --git a/src/pages/admin-portal/device-center/tenantSite/site/component/modal/addOrEdit/init/usePage.ts b/src/pages/admin-portal/device-center/tenantSite/site/component/modal/addOrEdit/init/usePage.ts new file mode 100644 index 0000000..18bf158 --- /dev/null +++ b/src/pages/admin-portal/device-center/tenantSite/site/component/modal/addOrEdit/init/usePage.ts @@ -0,0 +1,144 @@ +/** + * 布点场所 - 新增/编辑 弹框 - 主逻辑 + * - 自定义标识 customFlag 必填校验 + */ + +import type { FormInstance, Rule } from 'ant-design-vue/es/form' +import { useAntdStaticMethods } from '@utils/antDesign/popUp' +import { add, edit, info } from '../api' +import type { Form, ModalType, OpenContext, PageInfo } from '../types' + +const createInitForm = (): Form => ({ + id: undefined, + name: '', + code: '', + tenantId: undefined, + department: '', + address: '', + contactName: '', + contactPhone: '', + siteType: undefined, + customFlag: '', + status: true, +}) + +export const usePage = (emit: (e: 'load') => void) => { + const { message } = useAntdStaticMethods() + + const pageInfo = reactive({ + visible: false, + type: 'add', + title: '', + width: 760, + spin: false, + }) + + const formRef = ref() + const form = ref(createInitForm()) + + const rules: Record = { + name: [{ required: true, message: '请输入场所名称', trigger: 'blur' }], + code: [{ required: true, message: '请输入场所编号', trigger: 'blur' }], + tenantId: [{ required: true, message: '请选择所属租户', trigger: 'change' }], + siteType: [{ required: true, message: '请选择场所类型', trigger: 'change' }], + customFlag: [{ required: true, message: '请输入自定义标识(用于数据分发寻址、同名场所区分)', trigger: 'blur' }], + } + + const resetForm = (): void => { + formRef.value?.clearValidate() + form.value = createInitForm() + } + + const closeModal = (): void => { + resetForm() + pageInfo.visible = false + } + + const infoRequest = (id: string) => info(id) + + const openModal = (type: ModalType, ctx?: OpenContext): void => { + pageInfo.visible = true + pageInfo.spin = true + pageInfo.type = type + resetForm() + + pageInfo.title = type === 'add' ? '新增布点场所' : '编辑布点场所' + + const requests: Promise[] = [] + 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' && infoRes.data) { + form.value = { ...createInitForm(), ...infoRes.data } + } + } + }) + .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): 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): 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 = JSON.parse(JSON.stringify(form.value)) as Record + if (params.id) editRequest(params) + else addRequest(params) + }) + .catch((err: unknown) => { + pageInfo.spin = false + console.error('表单验证失败:', err) + }) + } + + return { + pageInfo, + formRef, + form, + rules, + openModal, + closeModal, + submit, + } +} diff --git a/src/pages/admin-portal/device-center/tenantSite/site/component/modal/addOrEdit/types/index.ts b/src/pages/admin-portal/device-center/tenantSite/site/component/modal/addOrEdit/types/index.ts new file mode 100644 index 0000000..7245deb --- /dev/null +++ b/src/pages/admin-portal/device-center/tenantSite/site/component/modal/addOrEdit/types/index.ts @@ -0,0 +1,50 @@ +import type { FormInstance } from 'ant-design-vue/es/form' + +/** 弹框类型 */ +export type ModalType = 'add' | 'edit' + +/** 弹框页面状态 */ +export interface PageInfo { + visible: boolean + type: ModalType + title: string + width: number + spin: boolean +} + +/** 打开弹框上下文 */ +export interface OpenContext { + /** 编辑模式必传 */ + id?: string +} + +/** 表单字段(对齐 SitePayload + status) */ +export interface Form { + id: string | undefined + /** 场所名称 */ + name: string + /** 场所编号 */ + code: string + /** 所属租户 ID */ + tenantId: string | undefined + /** 部门 */ + department: string + /** 地址 */ + address: string + /** 联系人 */ + contactName: string + /** 联系电话 */ + contactPhone: string + /** 场所类型 */ + siteType: string | undefined + /** 自定义标识(必填) */ + customFlag: string + /** 启用状态 */ + status: boolean +} + +/** 暴露给父组件的 ref 类型 */ +export type FormRef = ReturnType + +/** 仅用于类型推导的占位函数 */ +declare function usePageRef(): FormInstance | undefined diff --git a/src/pages/admin-portal/device-center/tenantSite/site/init/usePage.ts b/src/pages/admin-portal/device-center/tenantSite/site/init/usePage.ts new file mode 100644 index 0000000..84b488a --- /dev/null +++ b/src/pages/admin-portal/device-center/tenantSite/site/init/usePage.ts @@ -0,0 +1,170 @@ +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 { list, remove, tenantOptions } from '../api' +import type { SearchForm, SiteItem } from '../types' +import { createSearchKey } from './useSearch' +import { tableColumns } from './useTable' + +/** + * 布点场所页主编排: + * - 标准 CRUD 列表,与租户 N:1 + * - 必填"自定义标识"字段 + * - 客户端/MQ 心跳只读展示 + */ +export const usePage = () => { + const { message, Modal } = useAntdStaticMethods() + + const search = reactive(createSearchKey()) + + const table = reactive>({ + columns: tableColumns, + dataSource: [], + sort: { field: '', order: null }, + pagination: createPaginationConfig() as TablePaginationConfig, + }) + + const pageLoading = ref(false) + const selectedRowKeys = ref<(string | number)[]>([]) + + /** 租户下拉选项(用于搜索/弹框) */ + const tenantOptionList = ref<{ id: string; name: string }[]>([]) + + /** 新增/编辑弹框 ref */ + const addOrEditRef = ref | null>(null) + /** 详情抽屉 ref */ + const infoDrawerRef = ref | 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 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 initTenantOptions = (): void => { + tenantOptions() + .then((res) => { + if (res.code === '00000') { + tenantOptionList.value = (res.data ?? []).map((item) => ({ id: item.id, name: item.name })) + } + }) + .catch((err: unknown) => { + console.error('租户下拉请求失败:', err) + }) + } + + /** 打开新增弹框 */ + const openAddModal = (): void => { + addOrEditRef.value?.openModal('add') + } + + /** 打开编辑弹框 */ + const openEditModal = (record: SiteItem): void => { + addOrEditRef.value?.openModal('edit', { id: record.id }) + } + + /** 打开详情抽屉 */ + const openInfoDrawer = (record: SiteItem): void => { + infoDrawerRef.value?.openDrawer({ id: record.id }) + } + + const deleteRecord = (record: SiteItem): void => { + Modal.confirm({ + title: '确认删除?', + content: `布点场所 [${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) + }) + }, + }) + } + + const handleAction = (type: string, record?: SiteItem): void => { + if (type === 'add') { + openAddModal() + return + } + if (type === 'edit' && record) { + openEditModal(record) + return + } + if (type === 'detail' && record) { + openInfoDrawer(record) + return + } + if (type === 'delete' && record) { + deleteRecord(record) + } + } + + onMounted(() => { + initTenantOptions() + listRequest() + }) + + return { + pageLoading, + search, + table, + selectedRowKeys, + rowSelection, + tenantOptionList, + addOrEditRef, + infoDrawerRef, + searchQuery, + resetQuery, + dataSourceChange, + handleAction, + listRequest, + } +} diff --git a/src/pages/admin-portal/device-center/tenantSite/site/init/useSearch.ts b/src/pages/admin-portal/device-center/tenantSite/site/init/useSearch.ts new file mode 100644 index 0000000..6b8a717 --- /dev/null +++ b/src/pages/admin-portal/device-center/tenantSite/site/init/useSearch.ts @@ -0,0 +1,9 @@ +import type { SearchForm } from '../types' + +/** 搜索表单工厂:统一重置入口 */ +export const createSearchKey = (): SearchForm => ({ + keyword: undefined, + tenantId: undefined, + siteType: undefined, + status: undefined, +}) diff --git a/src/pages/admin-portal/device-center/tenantSite/site/init/useTable.ts b/src/pages/admin-portal/device-center/tenantSite/site/init/useTable.ts new file mode 100644 index 0000000..8439ae9 --- /dev/null +++ b/src/pages/admin-portal/device-center/tenantSite/site/init/useTable.ts @@ -0,0 +1,65 @@ +import type { TableColumnsType } from 'ant-design-vue' +import { h } from 'vue' +import { Tag } from 'ant-design-vue' +import type { HeartbeatStatus } from '../types' + +/** 心跳状态 → Tag 颜色 */ +const heartbeatColor: Record = { + online: 'success', + offline: 'default', +} + +const heartbeatText = (status: HeartbeatStatus): string => + status === 'online' ? '在线' : '离线' + +/** 布点场所列定义 */ +export const tableColumns: TableColumnsType = [ + { title: '序号', dataIndex: 'index', align: 'center', fixed: 'left', width: 70, customRender: ({ index }: { index: number }) => index + 1 }, + { title: '场所名称', dataIndex: 'name', align: 'left', width: 160, resizable: true, ellipsis: true }, + { title: '场所编号', dataIndex: 'code', align: 'left', width: 140, resizable: true, ellipsis: true }, + { title: '所属租户', dataIndex: 'tenantName', align: 'left', width: 160, resizable: true, ellipsis: true }, + { title: '场所类型', dataIndex: 'siteType', align: 'center', width: 110, resizable: true, ellipsis: true }, + { title: '自定义标识', dataIndex: 'customFlag', align: 'center', width: 140, resizable: true, ellipsis: true }, + { title: '联系人', dataIndex: 'contactName', align: 'left', width: 110, resizable: true, ellipsis: true }, + { + title: '客户端心跳', + dataIndex: 'clientHeartbeat', + align: 'center', + width: 110, + customRender: ({ value }: { value: HeartbeatStatus }) => + h(Tag, { color: heartbeatColor[value] }, () => heartbeatText(value)), + }, + { + title: 'MQ 心跳', + dataIndex: 'mqHeartbeat', + align: 'center', + width: 100, + customRender: ({ value }: { value: HeartbeatStatus }) => + h(Tag, { color: heartbeatColor[value] }, () => heartbeatText(value)), + }, + { title: '挂载设备数', dataIndex: 'deviceCount', align: 'center', width: 100 }, + { + title: '状态', + dataIndex: 'status', + align: 'center', + width: 90, + customRender: ({ value }: { value: boolean }) => + h(Tag, { color: value ? 'green' : 'default' }, () => (value ? '启用' : '停用')), + }, + { title: '创建时间', dataIndex: 'createTime', align: 'center', width: 170, sorter: true, resizable: true }, + { title: '操作', dataIndex: 'action', align: 'center', fixed: 'right', width: 160 }, +] + +/** 场所类型下拉选项 */ +export const SITE_TYPE_OPTIONS = [ + { label: '食堂', value: 'canteen' }, + { label: '餐厅', value: 'restaurant' }, + { label: '体检站', value: 'checkup-station' }, + { label: '体重测量点', value: 'measure-point' }, + { label: '健身房', value: 'gym' }, + { label: '社区站', value: 'community-station' }, +] + +/** 场所类型 → 中文 */ +export const siteTypeText = (value: string): string => + SITE_TYPE_OPTIONS.find((item) => item.value === value)?.label ?? value ?? '—' diff --git a/src/pages/admin-portal/device-center/tenantSite/site/site.vue b/src/pages/admin-portal/device-center/tenantSite/site/site.vue new file mode 100644 index 0000000..fedb4a3 --- /dev/null +++ b/src/pages/admin-portal/device-center/tenantSite/site/site.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/src/pages/admin-portal/device-center/tenantSite/site/types/index.ts b/src/pages/admin-portal/device-center/tenantSite/site/types/index.ts new file mode 100644 index 0000000..548e35f --- /dev/null +++ b/src/pages/admin-portal/device-center/tenantSite/site/types/index.ts @@ -0,0 +1,61 @@ +import type { BaseRecord, BaseSearchForm } from '@pages/admin-portal/shared/types' + +/** 心跳状态 */ +export type HeartbeatStatus = 'online' | 'offline' + +/** 布点场所档案 */ +export interface SiteItem extends BaseRecord { + /** 场所名称 */ + name: string + /** 场所编号 */ + code: string + /** 所属租户 ID */ + tenantId: string + /** 所属租户名称(联表回填) */ + tenantName: string + /** 部门 */ + department?: string + /** 地址 */ + address?: string + /** 联系人 */ + contactName?: string + /** 联系电话 */ + contactPhone?: string + /** 场所类型(食堂/餐厅/体检站/测量点/...) */ + siteType: string + /** 自定义标识(必填,运营自定义,用于数据中转寻址、同名场所区分、下游业务系统附加编码等) */ + customFlag: string + /** 客户端心跳状态 */ + clientHeartbeat: HeartbeatStatus + /** 消息队列心跳状态 */ + mqHeartbeat: HeartbeatStatus + /** 已挂载设备数 */ + deviceCount: number + /** 启用状态 */ + status: boolean + /** 创建时间 */ + createTime: string +} + +/** 搜索表单 */ +export interface SearchForm extends BaseSearchForm { + keyword?: string + tenantId?: string + siteType?: string + status?: boolean +} + +/** 新增/编辑提交载荷 */ +export interface SitePayload { + id?: string + name: string + code: string + tenantId: string + department?: string + address?: string + contactName?: string + contactPhone?: string + siteType: string + customFlag: string + status?: boolean +} diff --git a/src/pages/admin-portal/device-center/tenantSite/tenant/api/index.ts b/src/pages/admin-portal/device-center/tenantSite/tenant/api/index.ts new file mode 100644 index 0000000..60b475f --- /dev/null +++ b/src/pages/admin-portal/device-center/tenantSite/tenant/api/index.ts @@ -0,0 +1,7 @@ +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { SearchForm, TenantItem } from '../types' + +/** 查询租户列表(与运营中心 tenantList 共用后端接口,设备中心只读消费) */ +export const list = (params: SearchForm): Promise> => + postRequest('axiosRequest', '/admin/tenant/page', params) diff --git a/src/pages/admin-portal/device-center/tenantSite/tenant/init/usePage.ts b/src/pages/admin-portal/device-center/tenantSite/tenant/init/usePage.ts new file mode 100644 index 0000000..0c4607b --- /dev/null +++ b/src/pages/admin-portal/device-center/tenantSite/tenant/init/usePage.ts @@ -0,0 +1,73 @@ +import { onMounted, reactive, ref } from 'vue' +import type { TablePaginationConfig } from 'ant-design-vue' +import { createPaginationConfig } from '@utils/antDesign/table' +import type { TableState } from '@utils/antDesign/table' +import { list } from '../api' +import type { SearchForm, TenantItem } from '../types' +import { createSearchKey } from './useSearch' +import { tableColumns } from './useTable' + +/** + * 租户管理页主编排: + * - 只读复用运营中心 tenantList 数据源 + * - 不提供新增/编辑/删除等操作按钮,仅展示 + * - 操作列省略 + */ +export const usePage = () => { + const search = reactive(createSearchKey()) + + const table = reactive>({ + columns: tableColumns, + dataSource: [], + sort: { field: '', order: null }, + pagination: createPaginationConfig() as TablePaginationConfig, + }) + + const pageLoading = ref(false) + + 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 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 + } + + onMounted(() => { + listRequest() + }) + + return { + pageLoading, + search, + table, + searchQuery, + resetQuery, + dataSourceChange, + } +} diff --git a/src/pages/admin-portal/device-center/tenantSite/tenant/init/useSearch.ts b/src/pages/admin-portal/device-center/tenantSite/tenant/init/useSearch.ts new file mode 100644 index 0000000..ecb36e5 --- /dev/null +++ b/src/pages/admin-portal/device-center/tenantSite/tenant/init/useSearch.ts @@ -0,0 +1,7 @@ +import type { SearchForm } from '../types' + +/** 搜索表单工厂:统一重置入口 */ +export const createSearchKey = (): SearchForm => ({ + name: undefined, + status: undefined, +}) diff --git a/src/pages/admin-portal/device-center/tenantSite/tenant/init/useTable.ts b/src/pages/admin-portal/device-center/tenantSite/tenant/init/useTable.ts new file mode 100644 index 0000000..7607f92 --- /dev/null +++ b/src/pages/admin-portal/device-center/tenantSite/tenant/init/useTable.ts @@ -0,0 +1,24 @@ +import type { TableColumnsType } from 'ant-design-vue' +import { h } from 'vue' +import { Tag } from 'ant-design-vue' + +/** 租户只读列表列定义(无操作列) */ +export const tableColumns: TableColumnsType = [ + { title: '序号', dataIndex: 'index', align: 'center', fixed: 'left', width: 70, customRender: ({ index }: { index: number }) => index + 1 }, + { title: '租户名称', dataIndex: 'name', align: 'left', width: 180, resizable: true, ellipsis: true }, + { title: '租户编码', dataIndex: 'code', align: 'left', width: 140, resizable: true, ellipsis: true }, + { title: '联系人', dataIndex: 'contactName', align: 'left', width: 120, resizable: true, ellipsis: true }, + { title: '联系电话', dataIndex: 'contactPhone', align: 'center', width: 130, resizable: true, ellipsis: true }, + { title: '关联场所数', dataIndex: 'siteCount', align: 'center', width: 110 }, + { title: '授权应用数', dataIndex: 'appCount', align: 'center', width: 110 }, + { + title: '状态', + dataIndex: 'status', + align: 'center', + width: 100, + customRender: ({ value }: { value: boolean }) => + h(Tag, { color: value ? 'green' : 'default' }, () => (value ? '启用' : '停用')), + }, + { title: '到期时间', dataIndex: 'expireTime', align: 'center', width: 130, resizable: true }, + { title: '创建时间', dataIndex: 'createTime', align: 'center', width: 170, sorter: true, resizable: true }, +] diff --git a/src/pages/admin-portal/device-center/tenantSite/tenant/tenant.vue b/src/pages/admin-portal/device-center/tenantSite/tenant/tenant.vue new file mode 100644 index 0000000..b677eac --- /dev/null +++ b/src/pages/admin-portal/device-center/tenantSite/tenant/tenant.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/src/pages/admin-portal/device-center/tenantSite/tenant/types/index.ts b/src/pages/admin-portal/device-center/tenantSite/tenant/types/index.ts new file mode 100644 index 0000000..9091169 --- /dev/null +++ b/src/pages/admin-portal/device-center/tenantSite/tenant/types/index.ts @@ -0,0 +1,31 @@ +import type { BaseRecord, BaseSearchForm } from '@pages/admin-portal/shared/types' + +/** 租户档案(只读视图,与运营中心 tenantList 数据同源) */ +export interface TenantItem extends BaseRecord { + /** 租户名称 */ + name: string + /** 租户编码 */ + code: string + /** 联系人 */ + contactName?: string + /** 联系电话 */ + contactPhone?: string + /** 地址 */ + address?: string + /** 授权应用数 */ + appCount?: number + /** 关联布点场所数 */ + siteCount?: number + /** 到期时间 */ + expireTime: string + /** 启用状态 */ + status: boolean + /** 创建时间 */ + createTime: string +} + +/** 搜索表单 */ +export interface SearchForm extends BaseSearchForm { + name?: string + status?: boolean +} diff --git a/src/pages/admin-portal/device-center/workbench/overview/api/index.ts b/src/pages/admin-portal/device-center/workbench/overview/api/index.ts new file mode 100644 index 0000000..c852fb3 --- /dev/null +++ b/src/pages/admin-portal/device-center/workbench/overview/api/index.ts @@ -0,0 +1,7 @@ +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { OverviewData } from '../types' + +/** 查询数据概览聚合数据(4 KPI + 资产结构 + 分发概览 + 7 日趋势) */ +export const fetchOverview = (): Promise> => + postRequest('axiosRequest', '/admin/device/workbench/overview', {}) diff --git a/src/pages/admin-portal/device-center/workbench/overview/component/DonutChart/DonutChart.vue b/src/pages/admin-portal/device-center/workbench/overview/component/DonutChart/DonutChart.vue new file mode 100644 index 0000000..912e4ba --- /dev/null +++ b/src/pages/admin-portal/device-center/workbench/overview/component/DonutChart/DonutChart.vue @@ -0,0 +1,126 @@ + + + + + diff --git a/src/pages/admin-portal/device-center/workbench/overview/init/usePage.ts b/src/pages/admin-portal/device-center/workbench/overview/init/usePage.ts new file mode 100644 index 0000000..2d2ce67 --- /dev/null +++ b/src/pages/admin-portal/device-center/workbench/overview/init/usePage.ts @@ -0,0 +1,49 @@ +import { onMounted, ref } from 'vue' +import type { OverviewData } from '../types' +import { fetchOverview } from '../api' +import { tenantTopColumns } from './useTable' + +/** + * 数据概览主编排: + * - 顶部 8 KPI 大卡(资产维度 + 业务维度) + * - 中部第一行:资产结构(环形图)+ 运行状态(环形图) + * - 中部第二行:实时业务(窄)+ 租户设备 Top 5(宽) + * - 原型阶段一次拉取所有聚合数据 + */ +export const usePage = () => { + const pageLoading = ref(false) + const data = ref({ + kpi: [], + assetStruct: [], + deviceStatus: [], + miniStats: [], + tenantTop: [], + }) + + /** 拉取概览聚合数据 */ + const fetch = (): void => { + pageLoading.value = true + fetchOverview() + .then((res) => { + if (res.code === '00000' && res.data) { + data.value = res.data + } + }) + .catch((err: unknown) => { + console.error('数据概览请求失败:', err) + }) + .finally(() => { + pageLoading.value = false + }) + } + + onMounted(() => { + fetch() + }) + + return { + pageLoading, + data, + tenantTopColumns, + } +} diff --git a/src/pages/admin-portal/device-center/workbench/overview/init/useSearch.ts b/src/pages/admin-portal/device-center/workbench/overview/init/useSearch.ts new file mode 100644 index 0000000..ab9b425 --- /dev/null +++ b/src/pages/admin-portal/device-center/workbench/overview/init/useSearch.ts @@ -0,0 +1,5 @@ +/** + * 数据概览无搜索表单(一次性聚合查询) + * 工厂保留以满足 6 文件结构 + 未来扩展(如时间范围筛选) + */ +export const createSearchKey = (): Record => ({}) diff --git a/src/pages/admin-portal/device-center/workbench/overview/init/useTable.ts b/src/pages/admin-portal/device-center/workbench/overview/init/useTable.ts new file mode 100644 index 0000000..b4467ae --- /dev/null +++ b/src/pages/admin-portal/device-center/workbench/overview/init/useTable.ts @@ -0,0 +1,20 @@ +import type { TableColumnsType } from 'ant-design-vue' + +/** + * 数据概览页表格列定义 + */ + +/** 租户设备 Top 5 表格列 */ +export const tenantTopColumns: TableColumnsType = [ + { title: '排名', dataIndex: 'rank', align: 'center', width: 70 }, + { title: '租户名称', dataIndex: 'tenantName', align: 'left', width: 180 }, + { title: '设备数', dataIndex: 'total', align: 'center', width: 100 }, + { title: '在用数', dataIndex: 'online', align: 'center', width: 100 }, + { + title: '故障率', + dataIndex: 'faultRate', + align: 'center', + width: 120, + customRender: ({ value }: { value: number }) => `${value}%`, + }, +] diff --git a/src/pages/admin-portal/device-center/workbench/overview/overview.vue b/src/pages/admin-portal/device-center/workbench/overview/overview.vue new file mode 100644 index 0000000..ca1cbd6 --- /dev/null +++ b/src/pages/admin-portal/device-center/workbench/overview/overview.vue @@ -0,0 +1,476 @@ + + + + + diff --git a/src/pages/admin-portal/device-center/workbench/overview/types/index.ts b/src/pages/admin-portal/device-center/workbench/overview/types/index.ts new file mode 100644 index 0000000..a355050 --- /dev/null +++ b/src/pages/admin-portal/device-center/workbench/overview/types/index.ts @@ -0,0 +1,73 @@ +/** 顶部 KPI 卡(资产维度 + 业务维度,共 8 张) */ +export interface OverviewKpi { + /** 唯一标识,决定卡片图标与配色 */ + key: + | 'total' // 设备总数 + | 'collect' // 采集设备数 + | 'onlineRate' // 在用率 + | 'monthAdd' // 本月新增 + | 'todayData' // 今日数据量 + | 'todayDist' // 今日分发量 + | 'tenant' // 下游租户数 + | 'successRate' // 分发成功率 + label: string + value: number + unit: string + /** 较上期变化提示 */ + hint?: string + /** 上升趋势 / 下降趋势(hint 文字前的箭头) */ + trend?: 'up' | 'down' | 'flat' + /** 图标符号(emoji 风格,仅 KPI 卡使用) */ + icon: string + color: 'blue' | 'green' | 'orange' | 'red' | 'cyan' | 'purple' +} + +/** 设备资产结构项(按顶级分类) */ +export interface AssetStructItem { + category: string + total: number + percent: number +} + +/** 设备运行状态项(在用 / 离线 / 维修 / 报废) */ +export interface DeviceStatusItem { + status: string + total: number + percent: number + color: 'success' | 'default' | 'warning' | 'error' +} + +/** 实时业务状态迷你卡 */ +export interface MiniStat { + key: 'online' | 'offline' | 'pendingAlarm' | 'pendingRetry' + label: string + value: number + unit: string + /** 卡片底部说明 */ + hint: string + color: 'green' | 'gray' | 'red' | 'orange' +} + +/** 租户设备 Top N 项 */ +export interface TenantTopItem { + rank: number + tenantName: string + total: number + online: number + /** 故障率(百分比) */ + faultRate: number +} + +/** 数据概览聚合数据(一次性返回) */ +export interface OverviewData { + /** 顶部 8 KPI 卡 */ + kpi: OverviewKpi[] + /** 设备资产结构 */ + assetStruct: AssetStructItem[] + /** 设备运行状态分布 */ + deviceStatus: DeviceStatusItem[] + /** 实时业务状态迷你卡 */ + miniStats: MiniStat[] + /** 租户设备 Top 5 */ + tenantTop: TenantTopItem[] +} diff --git a/src/pages/admin-portal/operation-center/Overview.vue b/src/pages/admin-portal/operation-center/Overview.vue deleted file mode 100644 index c3cebdf..0000000 --- a/src/pages/admin-portal/operation-center/Overview.vue +++ /dev/null @@ -1,12 +0,0 @@ - - - diff --git a/src/pages/admin-portal/operation-center/tenantManagement/tenantList/api/index.ts b/src/pages/admin-portal/operation-center/tenantManagement/tenantList/api/index.ts new file mode 100644 index 0000000..4065e47 --- /dev/null +++ b/src/pages/admin-portal/operation-center/tenantManagement/tenantList/api/index.ts @@ -0,0 +1,15 @@ +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { ListItem, ListParams } from '../types' + +export const list = (params: ListParams): Promise> => + postRequest('axiosRequest', '/admin/tenant/page', params) + +export const remove = (id: string): Promise> => + postRequest('axiosRequest', '/admin/tenant/delete', { id }) + +export const updateStatus = (id: string, status: boolean): Promise> => + postRequest('axiosRequest', '/admin/tenant/status', { id, status }) + +export const resetPassword = (id: string): Promise> => + postRequest('axiosRequest', '/admin/tenant/reset-password', { id }) diff --git a/src/pages/admin-portal/operation-center/tenantManagement/tenantList/init/usePage.ts b/src/pages/admin-portal/operation-center/tenantManagement/tenantList/init/usePage.ts new file mode 100644 index 0000000..2643399 --- /dev/null +++ b/src/pages/admin-portal/operation-center/tenantManagement/tenantList/init/usePage.ts @@ -0,0 +1,19 @@ +import { list, remove, resetPassword, updateStatus } from '../api' +import { tableColumns } from './useTable' +import { createSearchKey } from './useSearch' +import { useAdminPage } from '@pages/admin-portal/shared/useAdminPage' +import type { ListItem, SearchForm } from '../types' + +export const usePage = () => { + return useAdminPage({ + pageName: '租户', + addButtonText: '新增租户', + searchItems: [{ label: '租户名称', field: 'name', placeholder: '请输入租户名称' }], + columns: tableColumns, + actions: ['edit', 'delete', 'authorize', 'resetPassword'], + listApi: list, + deleteApi: remove, + statusApi: updateStatus, + resetPasswordApi: resetPassword, + }, createSearchKey) +} diff --git a/src/pages/admin-portal/operation-center/tenantManagement/tenantList/init/useSearch.ts b/src/pages/admin-portal/operation-center/tenantManagement/tenantList/init/useSearch.ts new file mode 100644 index 0000000..82f352c --- /dev/null +++ b/src/pages/admin-portal/operation-center/tenantManagement/tenantList/init/useSearch.ts @@ -0,0 +1,5 @@ +import type { SearchForm } from '../types' + +export const createSearchKey = (): SearchForm => ({ + name: undefined, +}) diff --git a/src/pages/admin-portal/operation-center/tenantManagement/tenantList/init/useTable.ts b/src/pages/admin-portal/operation-center/tenantManagement/tenantList/init/useTable.ts new file mode 100644 index 0000000..5c33251 --- /dev/null +++ b/src/pages/admin-portal/operation-center/tenantManagement/tenantList/init/useTable.ts @@ -0,0 +1,14 @@ +import { h } from 'vue' +import type { TableColumnsType } from 'ant-design-vue' +import type { ListItem } from '../types' + +export const tableColumns: TableColumnsType = [ + { title: '序号', dataIndex: 'index', align: 'center', fixed: 'left', width: 70, customRender: ({ index }: { index: number }) => index + 1 }, + { title: '租户名称', dataIndex: 'name', align: 'left', width: 200, resizable: true, ellipsis: true }, + { title: '超管账号', dataIndex: 'account', align: 'left', width: 180, customRender: ({ record }: { record: ListItem }) => h('span', `admin-${record.code}`) }, + { title: '租户编码', dataIndex: 'code', align: 'center', width: 160, resizable: true, ellipsis: true }, + { title: '到期时间', dataIndex: 'expireTime', align: 'center', width: 160, resizable: true, ellipsis: true }, + { title: '状态', dataIndex: 'status', align: 'center', width: 120 }, + { title: '创建时间', dataIndex: 'createTime', align: 'center', width: 180, sorter: true, resizable: true }, + { title: '操作', dataIndex: 'action', align: 'center', fixed: 'right', width: 280 }, +] diff --git a/src/pages/admin-portal/operation-center/tenantManagement/tenantList/tenantList.vue b/src/pages/admin-portal/operation-center/tenantManagement/tenantList/tenantList.vue new file mode 100644 index 0000000..72e8319 --- /dev/null +++ b/src/pages/admin-portal/operation-center/tenantManagement/tenantList/tenantList.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/src/pages/admin-portal/operation-center/tenantManagement/tenantList/types/index.ts b/src/pages/admin-portal/operation-center/tenantManagement/tenantList/types/index.ts new file mode 100644 index 0000000..8b8ade0 --- /dev/null +++ b/src/pages/admin-portal/operation-center/tenantManagement/tenantList/types/index.ts @@ -0,0 +1,15 @@ +import type { BaseListParams, BaseRecord, BaseSearchForm } from '@pages/admin-portal/shared/types' + +export interface SearchForm extends BaseSearchForm { + name?: string +} + +export interface ListItem extends BaseRecord { + name: string + code: string + expireTime: string + status: boolean + createTime: string +} + +export interface ListParams extends BaseListParams, SearchForm {} diff --git a/src/pages/admin-portal/shared/SimpleActionModal.vue b/src/pages/admin-portal/shared/SimpleActionModal.vue new file mode 100644 index 0000000..d5b9db4 --- /dev/null +++ b/src/pages/admin-portal/shared/SimpleActionModal.vue @@ -0,0 +1,57 @@ + + + diff --git a/src/pages/admin-portal/shared/types.ts b/src/pages/admin-portal/shared/types.ts new file mode 100644 index 0000000..296dd5e --- /dev/null +++ b/src/pages/admin-portal/shared/types.ts @@ -0,0 +1,58 @@ +import type { TableColumnsType } from 'ant-design-vue' + +export type RowActionType = + | 'edit' + | 'delete' + | 'auth' + | 'resetPassword' + | 'authorize' + | 'menu' + | 'addSub' + | 'setting' + +export interface BaseRecord { + id: string + status?: boolean + hidden?: boolean + createTime?: string +} + +export interface BaseSearchForm { + name?: string + keyword?: string + dictName?: string +} + +export interface BaseListParams extends BaseSearchForm { + pageNum: number + pageSize: number + order?: 'ascend' | 'descend' | null + column?: string +} + +export interface AdminPageConfig { + pageName: string + addButtonText?: string + searchItems: AdminSearchItem[] + columns: TableColumnsType + actions: RowActionType[] + listApi: (params: BaseListParams & S) => Promise<{ code: string; data: T[]; total?: number }> + deleteApi?: (id: string) => Promise<{ code: string; msg: string; data: unknown }> + statusApi?: (id: string, status: boolean) => Promise<{ code: string; msg: string; data: unknown }> + hiddenApi?: (id: string, hidden: boolean) => Promise<{ code: string; msg: string; data: unknown }> + resetPasswordApi?: (id: string) => Promise<{ code: string; msg: string; data: unknown }> +} + +export interface AdminSearchItem { + label: string + field: keyof S + placeholder: string +} + +export interface SimpleModalInfo { + visible: boolean + title: string + spin: boolean + width: number + record: BaseRecord | null +} diff --git a/src/pages/admin-portal/shared/useAdminPage.ts b/src/pages/admin-portal/shared/useAdminPage.ts new file mode 100644 index 0000000..feb415c --- /dev/null +++ b/src/pages/admin-portal/shared/useAdminPage.ts @@ -0,0 +1,209 @@ +import { useAntdStaticMethods } from '@utils/antDesign/popUp' +import { useAdminSearch } from './useAdminSearch' +import { useAdminTable } from './useAdminTable' +import type { AdminPageConfig, BaseListParams, BaseRecord, BaseSearchForm, SimpleModalInfo } from './types' + +export const useAdminPage = ( + config: AdminPageConfig, + createSearchKey: () => S, +) => { + const { message, Modal } = useAntdStaticMethods() + const { search, initOptions, resetSearch } = useAdminSearch(createSearchKey) + + const pageLoading = ref(false) + const simpleModalInfo = reactive({ + visible: false, + title: '', + spin: false, + width: 560, + record: null, + }) + + const buildParams = (): BaseListParams & S => ({ + ...search, + pageNum: table.pagination.current ?? 1, + pageSize: table.pagination.pageSize ?? 30, + order: table.sort.order, + column: table.sort.field, + }) + + const listRequest = (): void => { + pageLoading.value = true + config.listApi(buildParams()) + .then((res) => { + if (res.code === '00000') { + table.dataSource = res.data ?? [] + table.pagination.total = res.total ?? 0 + } + }) + .catch((err: unknown) => { + console.error(`${config.pageName}列表请求失败:`, err) + }) + .finally(() => { + pageLoading.value = false + }) + } + + const { + table, + dataSourceChange, + resetTable, + selectedRowKeys, + rowSelection, + clearSelection, + } = useAdminTable(config.columns, listRequest, true) + + const searchQuery = (): void => { + table.pagination.current = 1 + listRequest() + } + + const resetQuery = (): void => { + resetSearch() + resetTable() + clearSelection?.() + listRequest() + } + + const closeSimpleModal = (): void => { + simpleModalInfo.visible = false + simpleModalInfo.record = null + } + + const openSimpleModal = (title: string, record?: T): void => { + simpleModalInfo.visible = true + simpleModalInfo.title = title + simpleModalInfo.record = record ?? null + } + + const deleteRecord = (record: T): void => { + if (!config.deleteApi) return + Modal.confirm({ + title: '确认删除?', + content: '删除后数据不可恢复,请谨慎操作。', + okType: 'danger', + okText: '确认删除', + cancelText: '取消', + onOk: () => { + config.deleteApi?.(record.id) + .then((res) => { + if (res.code === '00000') { + void message.success(res.msg) + listRequest() + } + }) + .catch((err: unknown) => { + console.error(`${config.pageName}删除失败:`, err) + }) + }, + }) + } + + const statusChange = (checked: boolean, record: T): void => { + if (!config.statusApi) return + config.statusApi(record.id, checked) + .then((res) => { + if (res.code === '00000') void message.success(res.msg) + }) + .catch((err: unknown) => { + console.error(`${config.pageName}状态更新失败:`, err) + record.status = !checked + }) + } + + const hiddenChange = (checked: boolean, record: T): void => { + if (!config.hiddenApi) return + config.hiddenApi(record.id, checked) + .then((res) => { + if (res.code === '00000') void message.success(res.msg) + }) + .catch((err: unknown) => { + console.error(`${config.pageName}菜单状态更新失败:`, err) + record.hidden = !checked + }) + } + + const resetPassword = (record: T): void => { + if (!config.resetPasswordApi) return + Modal.confirm({ + title: '确认重置密码?', + content: '重置后将恢复为系统初始密码。', + okType: 'danger', + okText: '确认重置', + cancelText: '取消', + onOk: () => { + config.resetPasswordApi?.(record.id) + .then((res) => { + if (res.code === '00000') void message.success(res.msg) + }) + .catch((err: unknown) => { + console.error(`${config.pageName}重置密码失败:`, err) + }) + }, + }) + } + + const handleAction = (type: string, record?: T): void => { + if (type === 'delete' && record) { + deleteRecord(record) + return + } + if (type === 'resetPassword' && record) { + resetPassword(record) + return + } + const titleMap: Record = { + add: config.addButtonText ?? `新增${config.pageName}`, + edit: `编辑${config.pageName}`, + auth: '角色授权', + authorize: '授权应用', + menu: '应用菜单', + addSub: '添加下级菜单', + setting: '字典配置', + } + openSimpleModal(titleMap[type] ?? config.pageName, record) + } + + const submitSimpleModal = (): void => { + simpleModalInfo.spin = true + Promise.resolve() + .then(() => { + void message.success('原型操作成功') + closeSimpleModal() + listRequest() + }) + .catch((err: unknown) => { + console.error(`${config.pageName}原型操作失败:`, err) + }) + .finally(() => { + simpleModalInfo.spin = false + }) + } + + const loadData = (): void => { + initOptions() + listRequest() + } + + onMounted(() => { + loadData() + }) + + return { + pageLoading, + search, + table, + selectedRowKeys, + rowSelection, + simpleModalInfo, + dataSourceChange, + searchQuery, + resetQuery, + handleAction, + statusChange, + hiddenChange, + closeSimpleModal, + submitSimpleModal, + listRequest, + } +} diff --git a/src/pages/admin-portal/shared/useAdminSearch.ts b/src/pages/admin-portal/shared/useAdminSearch.ts new file mode 100644 index 0000000..c0f4505 --- /dev/null +++ b/src/pages/admin-portal/shared/useAdminSearch.ts @@ -0,0 +1,19 @@ +import type { BaseSearchForm } from './types' + +export const useAdminSearch = (createSearchKey: () => S) => { + const search = reactive(createSearchKey()) + + const initOptions = (): void => { + Promise.all([Promise.resolve(true)]) + .then(() => {}) + .catch((err: unknown) => { + console.error('初始化搜索选项失败:', err) + }) + } + + const resetSearch = (): void => { + Object.assign(search, createSearchKey()) + } + + return { search, initOptions, resetSearch } +} diff --git a/src/pages/admin-portal/shared/useAdminTable.ts b/src/pages/admin-portal/shared/useAdminTable.ts new file mode 100644 index 0000000..ad567a1 --- /dev/null +++ b/src/pages/admin-portal/shared/useAdminTable.ts @@ -0,0 +1,44 @@ +import type { TableColumnsType } from 'ant-design-vue' +import { + type TableSort, + type TableState, + createDataSourceChange, + createPaginationConfig, + createResetTable, + createRowSelection, + resizeColumn, +} from '@utils/antDesign/table' +import type { BaseRecord } from './types' + +const INIT_SORT: TableSort = { + field: 'createTime', + order: 'descend', +} + +export const useAdminTable = ( + columns: TableColumnsType, + listRequest: () => void, + rowSelectable = false, +) => { + const createInitState = (): TableState => ({ + columns, + dataSource: [], + sort: { ...INIT_SORT }, + pagination: createPaginationConfig(), + }) + + const table = reactive(createInitState()) as TableState + const resetTable = createResetTable(table, createInitState) + const dataSourceChange = createDataSourceChange(table, INIT_SORT, listRequest) + const selection = rowSelectable ? createRowSelection() : undefined + + return { + table, + dataSourceChange, + resetTable, + resizeColumn, + selectedRowKeys: selection?.selectedRowKeys, + rowSelection: selection?.rowSelection, + clearSelection: selection?.clearSelection, + } +} diff --git a/src/pages/admin-portal/system-settings/Overview.vue b/src/pages/admin-portal/system-settings/Overview.vue deleted file mode 100644 index 1d00e2d..0000000 --- a/src/pages/admin-portal/system-settings/Overview.vue +++ /dev/null @@ -1,12 +0,0 @@ - - - diff --git a/src/pages/admin-portal/system-settings/appService/applicationManagement/api/index.ts b/src/pages/admin-portal/system-settings/appService/applicationManagement/api/index.ts new file mode 100644 index 0000000..d5d677b --- /dev/null +++ b/src/pages/admin-portal/system-settings/appService/applicationManagement/api/index.ts @@ -0,0 +1,12 @@ +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { ListItem, ListParams } from '../types' + +export const list = (params: ListParams): Promise> => + postRequest('axiosRequest', '/admin/application/page', params) + +export const remove = (id: string): Promise> => + postRequest('axiosRequest', '/admin/application/delete', { id }) + +export const updateStatus = (id: string, status: boolean): Promise> => + postRequest('axiosRequest', '/admin/application/status', { id, status }) diff --git a/src/pages/admin-portal/system-settings/appService/applicationManagement/applicationManagement.vue b/src/pages/admin-portal/system-settings/appService/applicationManagement/applicationManagement.vue new file mode 100644 index 0000000..c8646f9 --- /dev/null +++ b/src/pages/admin-portal/system-settings/appService/applicationManagement/applicationManagement.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/src/pages/admin-portal/system-settings/appService/applicationManagement/init/usePage.ts b/src/pages/admin-portal/system-settings/appService/applicationManagement/init/usePage.ts new file mode 100644 index 0000000..a72649d --- /dev/null +++ b/src/pages/admin-portal/system-settings/appService/applicationManagement/init/usePage.ts @@ -0,0 +1,18 @@ +import { list, remove, updateStatus } from '../api' +import { tableColumns } from './useTable' +import { createSearchKey } from './useSearch' +import { useAdminPage } from '@pages/admin-portal/shared/useAdminPage' +import type { ListItem, SearchForm } from '../types' + +export const usePage = () => { + return useAdminPage({ + pageName: '应用', + addButtonText: '新增应用', + searchItems: [{ label: '应用名称', field: 'name', placeholder: '请输入应用名称' }], + columns: tableColumns, + actions: ['edit', 'menu', 'delete'], + listApi: list, + deleteApi: remove, + statusApi: updateStatus, + }, createSearchKey) +} diff --git a/src/pages/admin-portal/system-settings/appService/applicationManagement/init/useSearch.ts b/src/pages/admin-portal/system-settings/appService/applicationManagement/init/useSearch.ts new file mode 100644 index 0000000..82f352c --- /dev/null +++ b/src/pages/admin-portal/system-settings/appService/applicationManagement/init/useSearch.ts @@ -0,0 +1,5 @@ +import type { SearchForm } from '../types' + +export const createSearchKey = (): SearchForm => ({ + name: undefined, +}) diff --git a/src/pages/admin-portal/system-settings/appService/applicationManagement/init/useTable.ts b/src/pages/admin-portal/system-settings/appService/applicationManagement/init/useTable.ts new file mode 100644 index 0000000..df161ff --- /dev/null +++ b/src/pages/admin-portal/system-settings/appService/applicationManagement/init/useTable.ts @@ -0,0 +1,29 @@ +import { h } from 'vue' +import type { TableColumnsType } from 'ant-design-vue' +import type { ListItem } from '../types' + +const categoryMap: Record = { + 1: '管理系统', + 2: '餐厅系统', + 3: '食堂系统', + 4: '蚁熊健康系统', +} + +const appTypeMap: Record = { + 1: 'Web', + 2: 'APP', + 3: '小程序', + 4: '终端设备', +} + +export const tableColumns: TableColumnsType = [ + { title: '序号', dataIndex: 'index', align: 'center', fixed: 'left', width: 70, customRender: ({ index }: { index: number }) => index + 1 }, + { title: '平台名称', dataIndex: 'name', align: 'left', width: 180, resizable: true, ellipsis: true }, + { title: 'APPID', dataIndex: 'id', align: 'center', width: 160, resizable: true, ellipsis: true }, + { title: '应用分类', dataIndex: 'category', align: 'center', width: 140, customRender: ({ text }: { text: number }) => h('span', categoryMap[text] ?? '—') }, + { title: '应用类型', dataIndex: 'appType', align: 'center', width: 120, customRender: ({ text }: { text: number }) => h('span', appTypeMap[text] ?? '—') }, + { title: '应用编码', dataIndex: 'code', align: 'center', width: 160, resizable: true, ellipsis: true }, + { title: '状态', dataIndex: 'status', align: 'center', width: 120 }, + { title: '创建时间', dataIndex: 'createTime', align: 'center', width: 180, sorter: true, resizable: true }, + { title: '操作', dataIndex: 'action', align: 'center', fixed: 'right', width: 190 }, +] diff --git a/src/pages/admin-portal/system-settings/appService/applicationManagement/types/index.ts b/src/pages/admin-portal/system-settings/appService/applicationManagement/types/index.ts new file mode 100644 index 0000000..eb4d4a7 --- /dev/null +++ b/src/pages/admin-portal/system-settings/appService/applicationManagement/types/index.ts @@ -0,0 +1,16 @@ +import type { BaseListParams, BaseRecord, BaseSearchForm } from '@pages/admin-portal/shared/types' + +export interface SearchForm extends BaseSearchForm { + name?: string +} + +export interface ListItem extends BaseRecord { + name: string + category: 1 | 2 | 3 | 4 + appType: 1 | 2 | 3 | 4 + code: string + status: boolean + createTime: string +} + +export interface ListParams extends BaseListParams, SearchForm {} diff --git a/src/pages/admin-portal/system-settings/appService/menuManagement/api/index.ts b/src/pages/admin-portal/system-settings/appService/menuManagement/api/index.ts new file mode 100644 index 0000000..28ad6ff --- /dev/null +++ b/src/pages/admin-portal/system-settings/appService/menuManagement/api/index.ts @@ -0,0 +1,12 @@ +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { ListItem, ListParams } from '../types' + +export const list = (params: ListParams): Promise> => + postRequest('axiosRequest', '/admin/menu/page', params) + +export const remove = (id: string): Promise> => + postRequest('axiosRequest', '/admin/menu/delete', { id }) + +export const updateHidden = (id: string, hidden: boolean): Promise> => + postRequest('axiosRequest', '/admin/menu/hidden', { id, hidden }) diff --git a/src/pages/admin-portal/system-settings/appService/menuManagement/init/usePage.ts b/src/pages/admin-portal/system-settings/appService/menuManagement/init/usePage.ts new file mode 100644 index 0000000..2030441 --- /dev/null +++ b/src/pages/admin-portal/system-settings/appService/menuManagement/init/usePage.ts @@ -0,0 +1,18 @@ +import { list, remove, updateHidden } from '../api' +import { tableColumns } from './useTable' +import { createSearchKey } from './useSearch' +import { useAdminPage } from '@pages/admin-portal/shared/useAdminPage' +import type { ListItem, SearchForm } from '../types' + +export const usePage = () => { + return useAdminPage({ + pageName: '菜单', + addButtonText: '新增菜单', + searchItems: [{ label: '菜单名称', field: 'name', placeholder: '请输入菜单名称' }], + columns: tableColumns, + actions: ['edit', 'addSub', 'delete'], + listApi: list, + deleteApi: remove, + hiddenApi: updateHidden, + }, createSearchKey) +} diff --git a/src/pages/admin-portal/system-settings/appService/menuManagement/init/useSearch.ts b/src/pages/admin-portal/system-settings/appService/menuManagement/init/useSearch.ts new file mode 100644 index 0000000..82f352c --- /dev/null +++ b/src/pages/admin-portal/system-settings/appService/menuManagement/init/useSearch.ts @@ -0,0 +1,5 @@ +import type { SearchForm } from '../types' + +export const createSearchKey = (): SearchForm => ({ + name: undefined, +}) diff --git a/src/pages/admin-portal/system-settings/appService/menuManagement/init/useTable.ts b/src/pages/admin-portal/system-settings/appService/menuManagement/init/useTable.ts new file mode 100644 index 0000000..6027261 --- /dev/null +++ b/src/pages/admin-portal/system-settings/appService/menuManagement/init/useTable.ts @@ -0,0 +1,16 @@ +import { h } from 'vue' +import type { TableColumnsType } from 'ant-design-vue' +import type { ListItem } from '../types' + +export const tableColumns: TableColumnsType = [ + { title: '排序', dataIndex: 'sortNo', align: 'center', fixed: 'left', width: 80 }, + { title: '类型', dataIndex: 'elementType', align: 'center', width: 90, customRender: ({ text }: { text: 0 | 1 }) => h('span', text === 0 ? '菜单' : '按钮') }, + { title: '图标', dataIndex: 'iconUrl', align: 'center', width: 150, resizable: true, ellipsis: true }, + { title: '名称', dataIndex: 'name', align: 'left', width: 180, resizable: true, ellipsis: true }, + { title: '文件地址', dataIndex: 'component', align: 'left', width: 260, resizable: true, ellipsis: true }, + { title: '跳转地址', dataIndex: 'routePath', align: 'left', width: 240, resizable: true, ellipsis: true }, + { title: '权限', dataIndex: 'accessControl', align: 'center', width: 90, customRender: ({ text }: { text: 0 | 1 }) => h('span', text === 1 ? '是' : '否') }, + { title: '状态', dataIndex: 'hidden', align: 'center', width: 120 }, + { title: '创建时间', dataIndex: 'createTime', align: 'center', width: 180, sorter: true, resizable: true }, + { title: '操作', dataIndex: 'action', align: 'center', fixed: 'right', width: 210 }, +] diff --git a/src/pages/admin-portal/system-settings/appService/menuManagement/menuManagement.vue b/src/pages/admin-portal/system-settings/appService/menuManagement/menuManagement.vue new file mode 100644 index 0000000..6f87432 --- /dev/null +++ b/src/pages/admin-portal/system-settings/appService/menuManagement/menuManagement.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/src/pages/admin-portal/system-settings/appService/menuManagement/types/index.ts b/src/pages/admin-portal/system-settings/appService/menuManagement/types/index.ts new file mode 100644 index 0000000..ea01a9a --- /dev/null +++ b/src/pages/admin-portal/system-settings/appService/menuManagement/types/index.ts @@ -0,0 +1,19 @@ +import type { BaseListParams, BaseRecord, BaseSearchForm } from '@pages/admin-portal/shared/types' + +export interface SearchForm extends BaseSearchForm { + name?: string +} + +export interface ListItem extends BaseRecord { + sortNo: string + elementType: 0 | 1 + iconUrl: string + name: string + component: string + routePath: string + accessControl: 0 | 1 + hidden: boolean + createTime: string +} + +export interface ListParams extends BaseListParams, SearchForm {} diff --git a/src/pages/admin-portal/system-settings/dataDictionary/api/index.ts b/src/pages/admin-portal/system-settings/dataDictionary/api/index.ts new file mode 100644 index 0000000..a4364a9 --- /dev/null +++ b/src/pages/admin-portal/system-settings/dataDictionary/api/index.ts @@ -0,0 +1,9 @@ +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { ListItem, ListParams } from '../types' + +export const list = (params: ListParams): Promise> => + postRequest('axiosRequest', '/admin/dictionary/page', params) + +export const remove = (id: string): Promise> => + postRequest('axiosRequest', '/admin/dictionary/delete', { id }) diff --git a/src/pages/admin-portal/system-settings/dataDictionary/dataDictionary.vue b/src/pages/admin-portal/system-settings/dataDictionary/dataDictionary.vue new file mode 100644 index 0000000..8d88944 --- /dev/null +++ b/src/pages/admin-portal/system-settings/dataDictionary/dataDictionary.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/src/pages/admin-portal/system-settings/dataDictionary/init/usePage.ts b/src/pages/admin-portal/system-settings/dataDictionary/init/usePage.ts new file mode 100644 index 0000000..fff749b --- /dev/null +++ b/src/pages/admin-portal/system-settings/dataDictionary/init/usePage.ts @@ -0,0 +1,17 @@ +import { list, remove } from '../api' +import { tableColumns } from './useTable' +import { createSearchKey } from './useSearch' +import { useAdminPage } from '@pages/admin-portal/shared/useAdminPage' +import type { ListItem, SearchForm } from '../types' + +export const usePage = () => { + return useAdminPage({ + pageName: '字典', + addButtonText: '新增字典', + searchItems: [{ label: '字典名称', field: 'dictName', placeholder: '请输入字典名称/编号' }], + columns: tableColumns, + actions: ['edit', 'setting', 'delete'], + listApi: list, + deleteApi: remove, + }, createSearchKey) +} diff --git a/src/pages/admin-portal/system-settings/dataDictionary/init/useSearch.ts b/src/pages/admin-portal/system-settings/dataDictionary/init/useSearch.ts new file mode 100644 index 0000000..6ea2ed4 --- /dev/null +++ b/src/pages/admin-portal/system-settings/dataDictionary/init/useSearch.ts @@ -0,0 +1,5 @@ +import type { SearchForm } from '../types' + +export const createSearchKey = (): SearchForm => ({ + dictName: undefined, +}) diff --git a/src/pages/admin-portal/system-settings/dataDictionary/init/useTable.ts b/src/pages/admin-portal/system-settings/dataDictionary/init/useTable.ts new file mode 100644 index 0000000..3a18e7f --- /dev/null +++ b/src/pages/admin-portal/system-settings/dataDictionary/init/useTable.ts @@ -0,0 +1,11 @@ +import type { TableColumnsType } from 'ant-design-vue' +import type { ListItem } from '../types' + +export const tableColumns: TableColumnsType = [ + { title: '序号', dataIndex: 'index', align: 'center', fixed: 'left', width: 70, customRender: ({ index }: { index: number }) => index + 1 }, + { title: '字典名称', dataIndex: 'dictName', align: 'center', width: 180, resizable: true, ellipsis: true }, + { title: '字典编号', dataIndex: 'dictCode', align: 'center', width: 180, resizable: true, ellipsis: true }, + { title: '描述', dataIndex: 'description', align: 'left', width: 360, resizable: true, ellipsis: true }, + { title: '创建时间', dataIndex: 'createTime', align: 'center', width: 180, sorter: true, resizable: true }, + { title: '操作', dataIndex: 'action', align: 'center', fixed: 'right', width: 200 }, +] diff --git a/src/pages/admin-portal/system-settings/dataDictionary/types/index.ts b/src/pages/admin-portal/system-settings/dataDictionary/types/index.ts new file mode 100644 index 0000000..4568022 --- /dev/null +++ b/src/pages/admin-portal/system-settings/dataDictionary/types/index.ts @@ -0,0 +1,14 @@ +import type { BaseListParams, BaseRecord, BaseSearchForm } from '@pages/admin-portal/shared/types' + +export interface SearchForm extends BaseSearchForm { + dictName?: string +} + +export interface ListItem extends BaseRecord { + dictName: string + dictCode: string + description: string + createTime: string +} + +export interface ListParams extends BaseListParams, SearchForm {} diff --git a/src/pages/admin-portal/system-settings/permission/employeeManagement/api/index.ts b/src/pages/admin-portal/system-settings/permission/employeeManagement/api/index.ts new file mode 100644 index 0000000..e5da641 --- /dev/null +++ b/src/pages/admin-portal/system-settings/permission/employeeManagement/api/index.ts @@ -0,0 +1,15 @@ +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { ListItem, ListParams } from '../types' + +export const list = (params: ListParams): Promise> => + postRequest('axiosRequest', '/admin/employee/page', params) + +export const remove = (id: string): Promise> => + postRequest('axiosRequest', '/admin/employee/delete', { id }) + +export const updateStatus = (id: string, status: boolean): Promise> => + postRequest('axiosRequest', '/admin/employee/status', { id, status }) + +export const resetPassword = (id: string): Promise> => + postRequest('axiosRequest', '/admin/employee/reset-password', { id }) diff --git a/src/pages/admin-portal/system-settings/permission/employeeManagement/employeeManagement.vue b/src/pages/admin-portal/system-settings/permission/employeeManagement/employeeManagement.vue new file mode 100644 index 0000000..899c409 --- /dev/null +++ b/src/pages/admin-portal/system-settings/permission/employeeManagement/employeeManagement.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/src/pages/admin-portal/system-settings/permission/employeeManagement/init/usePage.ts b/src/pages/admin-portal/system-settings/permission/employeeManagement/init/usePage.ts new file mode 100644 index 0000000..37cec25 --- /dev/null +++ b/src/pages/admin-portal/system-settings/permission/employeeManagement/init/usePage.ts @@ -0,0 +1,19 @@ +import { list, remove, resetPassword, updateStatus } from '../api' +import { tableColumns } from './useTable' +import { createSearchKey } from './useSearch' +import { useAdminPage } from '@pages/admin-portal/shared/useAdminPage' +import type { ListItem, SearchForm } from '../types' + +export const usePage = () => { + return useAdminPage({ + pageName: '员工', + addButtonText: '新增员工', + searchItems: [{ label: '员工名称', field: 'name', placeholder: '请输入员工名称/账号' }], + columns: tableColumns, + actions: ['edit', 'delete', 'resetPassword'], + listApi: list, + deleteApi: remove, + statusApi: updateStatus, + resetPasswordApi: resetPassword, + }, createSearchKey) +} diff --git a/src/pages/admin-portal/system-settings/permission/employeeManagement/init/useSearch.ts b/src/pages/admin-portal/system-settings/permission/employeeManagement/init/useSearch.ts new file mode 100644 index 0000000..82f352c --- /dev/null +++ b/src/pages/admin-portal/system-settings/permission/employeeManagement/init/useSearch.ts @@ -0,0 +1,5 @@ +import type { SearchForm } from '../types' + +export const createSearchKey = (): SearchForm => ({ + name: undefined, +}) diff --git a/src/pages/admin-portal/system-settings/permission/employeeManagement/init/useTable.ts b/src/pages/admin-portal/system-settings/permission/employeeManagement/init/useTable.ts new file mode 100644 index 0000000..59bc35b --- /dev/null +++ b/src/pages/admin-portal/system-settings/permission/employeeManagement/init/useTable.ts @@ -0,0 +1,13 @@ +import type { TableColumnsType } from 'ant-design-vue' +import type { ListItem } from '../types' + +export const tableColumns: TableColumnsType = [ + { title: '序号', dataIndex: 'index', align: 'center', fixed: 'left', width: 70, customRender: ({ index }: { index: number }) => index + 1 }, + { title: '账号', dataIndex: 'account', align: 'left', width: 160, resizable: true, ellipsis: true }, + { title: '员工姓名', dataIndex: 'name', align: 'center', width: 140, resizable: true, ellipsis: true }, + { title: '手机号', dataIndex: 'mobile', align: 'center', width: 160, resizable: true, ellipsis: true }, + { title: '角色', dataIndex: 'roleName', align: 'center', width: 150, resizable: true, ellipsis: true }, + { title: '状态', dataIndex: 'status', align: 'center', width: 120 }, + { title: '创建时间', dataIndex: 'createTime', align: 'center', width: 180, sorter: true, resizable: true }, + { title: '操作', dataIndex: 'action', align: 'center', fixed: 'right', width: 220 }, +] diff --git a/src/pages/admin-portal/system-settings/permission/employeeManagement/types/index.ts b/src/pages/admin-portal/system-settings/permission/employeeManagement/types/index.ts new file mode 100644 index 0000000..2e195be --- /dev/null +++ b/src/pages/admin-portal/system-settings/permission/employeeManagement/types/index.ts @@ -0,0 +1,16 @@ +import type { BaseListParams, BaseRecord, BaseSearchForm } from '@pages/admin-portal/shared/types' + +export interface SearchForm extends BaseSearchForm { + name?: string +} + +export interface ListItem extends BaseRecord { + account: string + name: string + mobile: string + roleName: string + status: boolean + createTime: string +} + +export interface ListParams extends BaseListParams, SearchForm {} diff --git a/src/pages/admin-portal/system-settings/permission/roleManagement/api/index.ts b/src/pages/admin-portal/system-settings/permission/roleManagement/api/index.ts new file mode 100644 index 0000000..21daac8 --- /dev/null +++ b/src/pages/admin-portal/system-settings/permission/roleManagement/api/index.ts @@ -0,0 +1,12 @@ +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { ListItem, ListParams } from '../types' + +export const list = (params: ListParams): Promise> => + postRequest('axiosRequest', '/admin/role/page', params) + +export const remove = (id: string): Promise> => + postRequest('axiosRequest', '/admin/role/delete', { id }) + +export const updateStatus = (id: string, status: boolean): Promise> => + postRequest('axiosRequest', '/admin/role/status', { id, status }) diff --git a/src/pages/admin-portal/system-settings/permission/roleManagement/init/usePage.ts b/src/pages/admin-portal/system-settings/permission/roleManagement/init/usePage.ts new file mode 100644 index 0000000..ab142fb --- /dev/null +++ b/src/pages/admin-portal/system-settings/permission/roleManagement/init/usePage.ts @@ -0,0 +1,18 @@ +import { list, remove, updateStatus } from '../api' +import { tableColumns } from './useTable' +import { createSearchKey } from './useSearch' +import { useAdminPage } from '@pages/admin-portal/shared/useAdminPage' +import type { ListItem, SearchForm } from '../types' + +export const usePage = () => { + return useAdminPage({ + pageName: '角色', + addButtonText: '新增角色', + searchItems: [{ label: '角色名称', field: 'name', placeholder: '请输入角色名称' }], + columns: tableColumns, + actions: ['auth', 'edit', 'delete'], + listApi: list, + deleteApi: remove, + statusApi: updateStatus, + }, createSearchKey) +} diff --git a/src/pages/admin-portal/system-settings/permission/roleManagement/init/useSearch.ts b/src/pages/admin-portal/system-settings/permission/roleManagement/init/useSearch.ts new file mode 100644 index 0000000..82f352c --- /dev/null +++ b/src/pages/admin-portal/system-settings/permission/roleManagement/init/useSearch.ts @@ -0,0 +1,5 @@ +import type { SearchForm } from '../types' + +export const createSearchKey = (): SearchForm => ({ + name: undefined, +}) diff --git a/src/pages/admin-portal/system-settings/permission/roleManagement/init/useTable.ts b/src/pages/admin-portal/system-settings/permission/roleManagement/init/useTable.ts new file mode 100644 index 0000000..b2a7b4a --- /dev/null +++ b/src/pages/admin-portal/system-settings/permission/roleManagement/init/useTable.ts @@ -0,0 +1,11 @@ +import type { TableColumnsType } from 'ant-design-vue' +import type { ListItem } from '../types' + +export const tableColumns: TableColumnsType = [ + { title: '序号', dataIndex: 'index', align: 'center', fixed: 'left', width: 70, customRender: ({ index }: { index: number }) => index + 1 }, + { title: '角色名称', dataIndex: 'name', align: 'center', width: 180, resizable: true, ellipsis: true }, + { title: '角色编码', dataIndex: 'code', align: 'center', width: 180, resizable: true, ellipsis: true }, + { title: '状态', dataIndex: 'status', align: 'center', width: 120 }, + { title: '创建时间', dataIndex: 'createTime', align: 'center', width: 180, sorter: true, resizable: true }, + { title: '操作', dataIndex: 'action', align: 'center', fixed: 'right', width: 200 }, +] diff --git a/src/pages/admin-portal/system-settings/permission/roleManagement/roleManagement.vue b/src/pages/admin-portal/system-settings/permission/roleManagement/roleManagement.vue new file mode 100644 index 0000000..2333552 --- /dev/null +++ b/src/pages/admin-portal/system-settings/permission/roleManagement/roleManagement.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/src/pages/admin-portal/system-settings/permission/roleManagement/types/index.ts b/src/pages/admin-portal/system-settings/permission/roleManagement/types/index.ts new file mode 100644 index 0000000..1317762 --- /dev/null +++ b/src/pages/admin-portal/system-settings/permission/roleManagement/types/index.ts @@ -0,0 +1,14 @@ +import type { BaseListParams, BaseRecord, BaseSearchForm } from '@pages/admin-portal/shared/types' + +export interface SearchForm extends BaseSearchForm { + name?: string +} + +export interface ListItem extends BaseRecord { + name: string + code: string + status: boolean + createTime: string +} + +export interface ListParams extends BaseListParams, SearchForm {} diff --git a/src/pages/admin-portal/system-settings/user/miniProgramUser/api/index.ts b/src/pages/admin-portal/system-settings/user/miniProgramUser/api/index.ts new file mode 100644 index 0000000..9de2371 --- /dev/null +++ b/src/pages/admin-portal/system-settings/user/miniProgramUser/api/index.ts @@ -0,0 +1,12 @@ +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { ListItem, ListParams } from '../types' + +export const list = (params: ListParams): Promise> => + postRequest('axiosRequest', '/admin/mini-program-user/page', params) + +export const remove = (id: string): Promise> => + postRequest('axiosRequest', '/admin/mini-program-user/delete', { id }) + +export const updateStatus = (id: string, status: boolean): Promise> => + postRequest('axiosRequest', '/admin/mini-program-user/status', { id, status }) diff --git a/src/pages/admin-portal/system-settings/user/miniProgramUser/init/usePage.ts b/src/pages/admin-portal/system-settings/user/miniProgramUser/init/usePage.ts new file mode 100644 index 0000000..8ca86e5 --- /dev/null +++ b/src/pages/admin-portal/system-settings/user/miniProgramUser/init/usePage.ts @@ -0,0 +1,17 @@ +import { list, remove, updateStatus } from '../api' +import { tableColumns } from './useTable' +import { createSearchKey } from './useSearch' +import { useAdminPage } from '@pages/admin-portal/shared/useAdminPage' +import type { ListItem, SearchForm } from '../types' + +export const usePage = () => { + return useAdminPage({ + pageName: '小程序用户', + searchItems: [{ label: '用户关键词', field: 'keyword', placeholder: '请输入昵称/手机号' }], + columns: tableColumns, + actions: ['edit', 'delete'], + listApi: list, + deleteApi: remove, + statusApi: updateStatus, + }, createSearchKey) +} diff --git a/src/pages/admin-portal/system-settings/user/miniProgramUser/init/useSearch.ts b/src/pages/admin-portal/system-settings/user/miniProgramUser/init/useSearch.ts new file mode 100644 index 0000000..a64550c --- /dev/null +++ b/src/pages/admin-portal/system-settings/user/miniProgramUser/init/useSearch.ts @@ -0,0 +1,5 @@ +import type { SearchForm } from '../types' + +export const createSearchKey = (): SearchForm => ({ + keyword: undefined, +}) diff --git a/src/pages/admin-portal/system-settings/user/miniProgramUser/init/useTable.ts b/src/pages/admin-portal/system-settings/user/miniProgramUser/init/useTable.ts new file mode 100644 index 0000000..d0b9ba6 --- /dev/null +++ b/src/pages/admin-portal/system-settings/user/miniProgramUser/init/useTable.ts @@ -0,0 +1,20 @@ +import { h } from 'vue' +import type { TableColumnsType } from 'ant-design-vue' +import type { ListItem } from '../types' + +export const tableColumns: TableColumnsType = [ + { title: '序号', dataIndex: 'index', align: 'center', fixed: 'left', width: 70, customRender: ({ index }: { index: number }) => index + 1 }, + { title: '昵称', dataIndex: 'faceName', align: 'center', width: 160, resizable: true, ellipsis: true }, + { title: '手机号', dataIndex: 'phone', align: 'center', width: 160, resizable: true, ellipsis: true }, + { title: '姓名', dataIndex: 'name', align: 'center', width: 140, resizable: true, ellipsis: true }, + { + title: '性别', + dataIndex: 'sex', + align: 'center', + width: 90, + customRender: ({ text }: { text: 1 | 2 | 0 }) => h('span', text === 1 ? '男' : text === 2 ? '女' : '—'), + }, + { title: '状态', dataIndex: 'status', align: 'center', width: 120 }, + { title: '创建时间', dataIndex: 'createTime', align: 'center', width: 180, sorter: true, resizable: true }, + { title: '操作', dataIndex: 'action', align: 'center', fixed: 'right', width: 150 }, +] diff --git a/src/pages/admin-portal/system-settings/user/miniProgramUser/miniProgramUser.vue b/src/pages/admin-portal/system-settings/user/miniProgramUser/miniProgramUser.vue new file mode 100644 index 0000000..34b18ee --- /dev/null +++ b/src/pages/admin-portal/system-settings/user/miniProgramUser/miniProgramUser.vue @@ -0,0 +1,53 @@ + + + + + diff --git a/src/pages/admin-portal/system-settings/user/miniProgramUser/types/index.ts b/src/pages/admin-portal/system-settings/user/miniProgramUser/types/index.ts new file mode 100644 index 0000000..bf10bd0 --- /dev/null +++ b/src/pages/admin-portal/system-settings/user/miniProgramUser/types/index.ts @@ -0,0 +1,16 @@ +import type { BaseListParams, BaseRecord, BaseSearchForm } from '@pages/admin-portal/shared/types' + +export interface SearchForm extends BaseSearchForm { + keyword?: string +} + +export interface ListItem extends BaseRecord { + faceName: string + phone: string + name: string + sex: 1 | 2 | 0 + status: boolean + createTime: string +} + +export interface ListParams extends BaseListParams, SearchForm {} diff --git a/src/pages/console/VisibilityConsole.vue b/src/pages/console/VisibilityConsole.vue index 9aaafe2..76d0ecb 100644 --- a/src/pages/console/VisibilityConsole.vue +++ b/src/pages/console/VisibilityConsole.vue @@ -95,7 +95,7 @@ import type { TableColumnsType } from 'ant-design-vue' import { getPortalApps, tenantApps, - miniprogramTabs, + miniprograms, hardwareDevices, isNavGroup, type NavApp, @@ -159,9 +159,11 @@ const allLeaves = computed(() => { pushApps(getPortalApps('admin-portal'), 'admin-portal') pushApps(tenantApps, 'tenant-portal') - miniprogramTabs.forEach((tab) => { - push(tab.page, 'miniprogram', tab.label) - tab.subPages?.forEach(l => push(l, 'miniprogram', `${tab.label} / 子页面`)) + miniprograms.forEach((mp) => { + mp.tabs.forEach((tab) => { + push(tab.page, 'miniprogram', `${mp.label} / ${tab.label}`) + tab.subPages?.forEach(l => push(l, 'miniprogram', `${mp.label} / ${tab.label} / 子页面`)) + }) }) hardwareDevices.forEach((dev) => { dev.children.forEach(l => push(l, 'hardware', dev.label)) diff --git a/src/pages/home/Home.vue b/src/pages/home/Home.vue index ad9b481..39f0bf6 100644 --- a/src/pages/home/Home.vue +++ b/src/pages/home/Home.vue @@ -155,24 +155,25 @@ - +
- {{ emojiOf(tab.icon) }} - {{ tab.label }} + {{ mp.icon ?? '📱' }} + {{ mp.label }}
设计中
+
{{ mp.description }}
- 📄 PRD - {{ 1 + (tab.subPages?.length ?? 0) }} 个页面 + 📄 PRD + {{ mp.tabCount }} 个页面
@@ -267,21 +268,21 @@ import { useRouter } from 'vue-router' import { adminSections, tenantApps, - miniprogramTabs, + miniprograms, hardwareDevices, isNavGroup, type NavApp, type NavSection, type NavGroup, type NavLeaf, - type NavMiniprogramTab, + type NavMiniprogram, type NavHardwareDevice, type PortalKey, } from '@meta/nav' import { useVisibility, filterApps, - filterMiniprogramTabs, + filterMiniprograms, filterHardwareDevices, } from '@composables/useVisibility' @@ -291,7 +292,7 @@ const effectiveRole = computed(() => role.value ?? 'designer') /** 过滤四端数据 */ const filteredTenant = computed(() => filterApps(tenantApps, effectiveRole.value, overrides.value)) -const filteredMpTabs = computed(() => filterMiniprogramTabs(miniprogramTabs, effectiveRole.value, overrides.value)) +const filteredMps = computed(() => filterMiniprograms(miniprograms, effectiveRole.value, overrides.value)) const filteredHardware = computed(() => filterHardwareDevices(hardwareDevices, effectiveRole.value, overrides.value)) /** 综合管理后台一级菜单(admin 无子应用,直接展示 sections) */ @@ -328,7 +329,7 @@ const tabs = computed(() => [ { key: 'admin-portal', label: '综合管理后台', icon: '🛠️', count: countSectionPages(adminAllSections.value) }, { key: 'tenant-portal', label: '租户运营后台', icon: '💼', count: countAppPages(filteredTenant.value) }, { key: 'miniprogram', label: '微信小程序', icon: '📱', - count: filteredMpTabs.value.reduce((s, t) => s + 1 + (t.subPages?.length ?? 0), 0) }, + count: filteredMps.value.reduce((s, mp) => s + mp.tabs.reduce((ts, t) => ts + 1 + (t.subPages?.length ?? 0), 0), 0) }, { key: 'hardware', label: '硬件终端屏', icon: '🖥️', count: filteredHardware.value.reduce((s, d) => s + d.children.length, 0) }, { key: 'prd', label: '产品需求文档', icon: '📄', count: 0 }, @@ -443,8 +444,35 @@ const enterLeaf = (portalKey: string, appKey: string, leaf: NavLeaf) => { } } -const enterMiniprogram = (tab: NavMiniprogramTab) => { - router.push(`/miniprogram/${tab.page.path}`) +/** 小程序实例卡片数据 */ +interface MpCard { + key: string + label: string + icon?: string + description?: string + tabCount: number + /** 第一个 tab 用于点击卡片跳转 */ + firstTabPath?: string +} + +const miniprogramCards = computed(() => { + return filteredMps.value.map((mp) => { + const tabCount = mp.tabs.reduce((s, t) => s + 1 + (t.subPages?.length ?? 0), 0) + const firstTab = mp.tabs[0] + return { + key: mp.key, + label: mp.label, + icon: mp.icon, + description: mp.description, + tabCount, + firstTabPath: firstTab ? `${mp.key}/${firstTab.page.path}` : undefined, + } + }) +}) + +const enterMiniprogram = (mp: MpCard) => { + if (!mp.firstTabPath) return + router.push(`/miniprogram/${mp.firstTabPath}`) } const enterHardware = (dev: NavHardwareDevice) => { @@ -464,7 +492,10 @@ const openPrd = (modKey: string) => { // ============================== PRD Tab:分类网格 ============================== /** 收集 src/prd 下全部 md(用于判断模块 PRD 是否存在) */ -const prdGlobMap = import.meta.glob('/src/prd/**/*.md') as Record +const prdGlobMap = import.meta.glob('/src/prd/**/*.md', { + query: '?raw', + import: 'default', +}) as Record const existingPrdPaths = computed(() => Object.keys(prdGlobMap).map(p => p.replace(/^\/src\/prd\//, '')), ) @@ -545,10 +576,10 @@ const prdSections = computed(() => { buildPortalSection('tenant-portal', '租户运营后台', '💼', filteredTenant.value), ] - // 微信小程序:底部 Tab 即模块 - const mpItems: PrdItem[] = filteredMpTabs.value.map((t) => { - const path = resolveModulePrd('miniprogram', t.key) - return { key: t.key, name: t.label, path, ready: !!path } + // 微信小程序:以小程序实例(NavMiniprogram)为粒度 + const mpItems: PrdItem[] = filteredMps.value.map((mp) => { + const path = resolveModulePrd('miniprogram', mp.key) + return { key: mp.key, name: mp.label, path, ready: !!path } }) sections.push({ key: 'miniprogram', @@ -578,17 +609,6 @@ const prdSections = computed(() => { const openMd = (mdPath: string, title: string) => { void router.push({ path: '/__prd', query: { path: mdPath, title } }) } - -const emojiOf = (icon: string): string => { - const map: Record = { - 'home-o': '🏠', - 'shop-o': '🍽️', - 'chat-o': '💬', - 'apps-o': '🛠️', - 'user-o': '👤', - } - return map[icon] ?? '📌' -}