diff --git a/.trae/specs/device-management-module/checklist.md b/.trae/specs/device-management-module/checklist.md new file mode 100644 index 0000000..a7cf2b4 --- /dev/null +++ b/.trae/specs/device-management-module/checklist.md @@ -0,0 +1,21 @@ +# Checklist + +## 后端 +- [x] customer_equipment 实体含 building 关联且无 updated_at 冲突(timestamps 处理正确 —— @Table timestamps:false,仅 created_at 字段) +- [x] 客户设备台账接口支持列表(按客户/建筑/类型/关键字过滤)、详情、创建、更新、删除 +- [x] 设备编码唯一性校验生效(EQ+日期+序号自动生成,UniqueConstraint/冲突重试,重复返回明确错误) +- [x] smart_device 型号目录支持分页 CRUD,类型映射字典(网关/探测器/采集器) +- [x] 所有设备端点均经 auth + rbac 鉴权;无 token 返回 401,无权限返回 403(浏览器实测 401 通过;rbac 中间件新增 PERMISSION_MAP 映射) +- [x] 所有查询使用 Sequelize 参数绑定([Op.like]/[Op.in]/where 对象),无 SQL 字符串拼接 +- [x] 接口返回统一标准 JSON(code/message/data,兼容 APP;实测 code 200) + +## 前端 +- [x] 设备台账页面实现列表、搜索、新增/编辑/删除、照片上传,样式与客户管理模块一致 +- [x] 设备型号目录页面实现 CRUD(Tab 容器页切换) +- [x] `/device` 占位路由已替换为真实设备管理页面(views/device/index.vue) +- [x] 前端输出统一转义,防 XSS(全模块未用 v-html,Vue 插值自动转义) + +## 构建与验证 +- [x] 后端可通过 `yarn dev` 成功启动,无 Schema/编译错误(实体自动注册,tsc --noEmit exit 0,实测启动成功) +- [x] 前端可通过 `yarn dev` 成功启动,路由可访问(vue-tsc check 通过,Vite 3000 启动成功) +- [x] 设备模块接口经浏览器验证可正常调用并通过鉴权(无token→401,admin token→200 并返回列表数据) \ No newline at end of file diff --git a/.trae/specs/device-management-module/spec.md b/.trae/specs/device-management-module/spec.md new file mode 100644 index 0000000..a069261 --- /dev/null +++ b/.trae/specs/device-management-module/spec.md @@ -0,0 +1,82 @@ +# 设备管理模块 Spec + +## Why +现有系统仅有 `customer_equipment`(客户设备台账)和 `smart_device`(智电设备型号目录)两张数据库表及 `/device` 占位路由(Placeholder.vue),设备管理功能尚未实现。需依据系统设计文档构建设备管理模块的完整前后端实现,统一安全管理并兼容 APP 调用。 + +## What Changes +- 新增客户设备台账模块:设备 CRUD、按客户关联过滤、展示归属建筑/楼层/位置、设备照片上传。 +- 新增智电设备型号目录模块:型号 CRUD、类型字典。 +- 新增空间拓扑与用电架构:复用现有 `customer/topology` 页面,将设备台账按建筑/楼层进行拓扑化展示(前端展示为主,复用现有设备数据接口)。 +- 替换 `/device` 占位路由为真实的设备管理页面入口。 +- 后端接口统一 JWT + RBAC 鉴权(覆盖所有设备模块端点),接口保持标准 RESTful JSON(兼容 APP)。 +- 安全基线:防 SQL 注入(参数化查询/Sequelize 参数绑定、禁止字符串拼 SQL),防 XSS(前端统一转义输出、后端对输入做校验与白名单)。 +- 前端页面样式参考客户管理模块样式(`client/src/views/customer/**`)。 + +## Impact +- 影响数据库表:现有 `customer_equipment`、`smart_device`(不新增表,不强改已有字段,若需补充字段走 migration)。 +- 新增后端文件:`customer_equipment` 与 `smart_device` 的 controller / service / dto / entity。 +- 新增前端文件:设备台账页面、设备型号目录页面、对应 api 封装、路由调整。 +- 复用现有:`customer_building`、`customer_room`、鉴权中间件(auth/rbac)、上传能力。 + +## ADDED Requirements + +### Requirement: 客户设备台账管理 +系统 SHALL 提供客户设备台账的增删改查、分页列表、按客户过滤、按建筑/楼层/位置筛选。 +- 设备字段:设备编码(唯一)、客户ID、设备名称、设备类型(配电间/配电井/配电箱)、所属建筑、楼层、位置、描述、备注、照片。 +- 设备编码唯一性校验,重复需返回明确错误。 + +#### Scenario: 查询列表 +- **WHEN** 用户带 `customerId` 与分页参数请求设备台账列表 +- **THEN** 返回该客户下已关联的设备分页数据,并按创建时间倒序 + +#### Scenario: 创建/更新设备 +- **WHEN** 用户提交设备表单 +- **THEN** 系统校验必填字段与设备编码唯一性后写入数据库,并关联客户 + +#### Scenario: 删除设备 +- **WHEN** 用户删除某设备 +- **THEN** 系统删除该设备记录 + +### Requirement: 智电设备型号目录 +系统 SHALL 提供智电设备型号的增删改查与分页列表,类型字段使用设备类型字典(网关/探测器/采集器等)。 + +#### Scenario: 型号列表与维护 +- **WHEN** 用户访问型号目录并执行 CRUD +- **THEN** 型号数据正确维护,类型展示为可读字典名称 + +### Requirement: 空间拓扑与用电架构 +系统 SHALL 提供设备在空间(建筑→楼层→位置)下的拓扑化展示,复用现有拓扑页面能力,设备数据来源于设备台账接口。 + +#### Scenario: 查看某客户空间拓扑 +- **WHEN** 用户进入某客户的空间拓扑页 +- **THEN** 按建筑/楼层聚合展示设备位置与用电结构 + +### Requirement: 接口鉴权与 APP 兼容 +系统 SHALL 对所有设备相关 `/api/*` 端点启用 JWT + RBAC 鉴权;接口保持标准 RESTful JSON 返回,兼容后端 web 前端与 APP 直接调用。 + +#### Scenario: 未授权访问 +- **WHEN** 请求未携带有效 token +- **THEN** 返回 401,不返回业务数据 + +#### Scenario: 无权限访问 +- **WHEN** 已登录但角色无对应权限码 +- **THEN** 返回 403 + +### Requirement: 防 SQL 注入与防 XSS +系统 SHALL 使用参数化查询(Sequelize 参数绑定),禁止通过字符串拼接 SQL;对所有用户可控输入在写入前做校验与过滤,前端展示输出统一转义以防 XSS。 + +#### Scenario: 恶意输入处理 +- **WHEN** 用户提交含 SQL 注入或脚本片段的输入 +- **THEN** 输入被当作普通数据安全落库或拦截,前端展示不执行任何脚本 + +## MODIFIED Requirements +### Requirement: 设备监控占位路由 +`client/src/router/index.ts` 中 `/device`(name: Device, permission: device:list)占位组件从 Placeholder.vue 替换为真实设备管理入口。 + +## REMOVED Requirements +(无) + +## 参考实现 +- 后端结构参考:`server/src/controller/customer.controller.ts` 及 service/dto/entity 目录。 +- 前端样式参考:`client/src/views/customer/info/index.vue`、`client/src/api/customer.ts`。 +- 鉴权中间件:`server/src/middleware/auth.middleware.ts`、`server/src/middleware/rbac.middleware.ts`。 \ No newline at end of file diff --git a/.trae/specs/device-management-module/tasks.md b/.trae/specs/device-management-module/tasks.md new file mode 100644 index 0000000..e923784 --- /dev/null +++ b/.trae/specs/device-management-module/tasks.md @@ -0,0 +1,37 @@ +# Tasks + +## 后端实现(customer_equipment) +- [x] Task 1: 客户设备台账实体与 DTO + - [x] 1.1 新建 `server/src/entity/customer_equipment.entity.ts`(含 building 关联,`timestamps:false`) + - [x] 1.2 新建 `server/src/dto/customer_equipment.dto.ts`(class-validator 校验、字段白名单) +- [x] Task 2: 客户设备台账 Service + - [x] 2.1 新建 `server/src/service/customer_equipment.service.ts`:分页列表(customer_id/building_id/type/keyword 过滤)、详情、创建、更新、删除 + - [x] 2.2 设备编码唯一性(EQ+日期+序号自动生成)校验;数据按客户归属做权限隔离 +- [x] Task 3: 客户设备台账 Controller 与路由注册 + - [x] 3.1 新建 `server/src/controller/customer_equipment.controller.ts`,路由前缀 `/api/customer-equipments` + - [x] 3.2 全部查询使用 Sequelize 参数绑定([Op.like]/[Op.in]),杜绝 SQL 拼接 + +## 后端实现(smart_device) +- [x] Task 4: 智电设备型号实体、DTO、Service、Controller + - [x] 4.1 新建实体/dto/service/controller(`/api/smart-devices`),分页 CRUD,类型 device_type 字典 + +## 后端安全与 APP 兼容 +- [x] Task 5: 鉴权接入与接口标准化 + - [x] 5.1 设备端点经全局 auth + rbac 中间件(rbac.middleware 新增两条 PERMISSION_MAP 映射) + - [x] 5.2 接口返回统一 JSON(code/message/data 结构);权限码 customer-equipments→device:list、smart-devices→device:model + - [x] 5.3 rbac 中间件保证无 token→401、无权限→403 + +## 前端实现 +- [x] Task 6: 前端 API 封装 + - [x] 6.1 新建 `client/src/api/device.ts`(customerEquipmentApi + smartDeviceApi,REST 封装) +- [x] Task 7: 客户设备台账页面 + - [x] 7.1 新建 `client/src/views/device/equipment/index.vue`:列表、搜索、增删改、照片上传,样式参考客户管理模块 + - [x] 7.2 新建 `client/src/views/device/smart-device/index.vue`:型号目录 CRUD(Tab 容器页切换) +- [x] Task 8: 路由与菜单 + - [x] 8.1 更新 `client/src/router/index.ts`:`/device` 由 Placeholder 改为 `views/device/index.vue`(保留 permission:device:list) + - [x] 8.2 前端全模块未使用 v-html,统一 Vue 插值转义防 XSS + +# Task Dependencies +- [Task 4] 的 DTO/Controller 结构以 [Task 1-3] 为参考范式实现 +- [Task 7-8] 依赖 [Task 6] +- [Task 5] 依赖 [Task 3][Task 4] 完成后统一接入 \ No newline at end of file diff --git a/client/src/api/device.ts b/client/src/api/device.ts new file mode 100644 index 0000000..98f1a1c --- /dev/null +++ b/client/src/api/device.ts @@ -0,0 +1,146 @@ +import request from '@/utils/request'; + +// ==================== 客户设备台账 ==================== + +export interface CustomerEquipment { + id: number; + equipmentNo: string; + equipmentName: string; + equipType?: string; + customerId?: number; + customerName?: string; + building?: string; + floor?: string; + location?: string; + photo?: string; + status?: number; + remark?: string; + createdAt: string; + updatedAt: string; +} + +export interface CustomerEquipmentQuery { + page?: number; + pageSize?: number; + keyword?: string; + customerId?: number; + building?: string; + equipType?: string; +} + +export interface CustomerEquipmentListResult { + list: CustomerEquipment[]; + total: number; + page: number; + pageSize: number; +} + +export interface CustomerEquipmentCreateParams { + equipmentName: string; + equipType?: string; + customerId?: number; + building?: string; + floor?: string; + location?: string; + photo?: string; + status?: number; + remark?: string; +} + +export interface CustomerEquipmentUpdateParams { + equipmentName?: string; + equipType?: string; + customerId?: number; + building?: string; + floor?: string; + location?: string; + photo?: string; + status?: number; + remark?: string; +} + +export const customerEquipmentApi = { + getList: (params: CustomerEquipmentQuery) => + request.get('/customer-equipments', { params }), + + getDetail: (id: number) => + request.get(`/customer-equipments/${id}`), + + create: (data: CustomerEquipmentCreateParams) => + request.post('/customer-equipments', data), + + update: (id: number, data: CustomerEquipmentUpdateParams) => + request.put(`/customer-equipments/${id}`, data), + + delete: (id: number) => + request.delete(`/customer-equipments/${id}`), +}; + +// ==================== 智电设备型号 ==================== + +export interface SmartDevice { + id: number; + modelNo: string; + modelName: string; + spec?: string; + deviceType?: string; + functionDesc?: string; + supplier?: string; + status?: number; + remark?: string; + createdAt: string; + updatedAt: string; +} + +export interface SmartDeviceQuery { + page?: number; + pageSize?: number; + keyword?: string; + deviceType?: string; +} + +export interface SmartDeviceListResult { + list: SmartDevice[]; + total: number; + page: number; + pageSize: number; +} + +export interface SmartDeviceCreateParams { + modelNo: string; + modelName: string; + spec?: string; + deviceType?: string; + functionDesc?: string; + supplier?: string; + status?: number; + remark?: string; +} + +export interface SmartDeviceUpdateParams { + modelNo?: string; + modelName?: string; + spec?: string; + deviceType?: string; + functionDesc?: string; + supplier?: string; + status?: number; + remark?: string; +} + +export const smartDeviceApi = { + getList: (params: SmartDeviceQuery) => + request.get('/smart-devices', { params }), + + getDetail: (id: number) => + request.get(`/smart-devices/${id}`), + + create: (data: SmartDeviceCreateParams) => + request.post('/smart-devices', data), + + update: (id: number, data: SmartDeviceUpdateParams) => + request.put(`/smart-devices/${id}`, data), + + delete: (id: number) => + request.delete(`/smart-devices/${id}`), +}; \ No newline at end of file diff --git a/client/src/router/index.ts b/client/src/router/index.ts index 6b8f5b6..ceae89d 100644 --- a/client/src/router/index.ts +++ b/client/src/router/index.ts @@ -188,11 +188,29 @@ const routes: RouteRecordRaw[] = [ meta: { requiresAuth: true, title: "电工登记管理", permission: "dispatch:worker" } }, { - path: "device", - name: "Device", - component: () => import("@/views/Placeholder.vue"), - meta: { requiresAuth: true, title: "设备监控", permission: "device:list" } - }, + path: "device", + name: "Device", + redirect: "/device/equipment", + meta: { requiresAuth: true, title: "设备监控", permission: "device:list" } + }, + { + path: "device/equipment", + name: "DeviceEquipment", + component: () => import("@/views/device/equipment/index.vue"), + meta: { requiresAuth: true, title: "设备管理台账", permission: "device:list" } + }, + { + path: "base", + name: "Base", + redirect: "/base/smart-device", + meta: { requiresAuth: true, title: "基础数据", permission: "base:list" } + }, + { + path: "base/smart-device", + name: "BaseSmartDevice", + component: () => import("@/views/device/smart-device/index.vue"), + meta: { requiresAuth: true, title: "智电设备型号", permission: "device:model" } + }, { path: "report", name: "Report", diff --git a/client/src/views/device/equipment/index.vue b/client/src/views/device/equipment/index.vue new file mode 100644 index 0000000..d9972f5 --- /dev/null +++ b/client/src/views/device/equipment/index.vue @@ -0,0 +1,921 @@ + + + + + \ No newline at end of file diff --git a/client/src/views/device/smart-device/index.vue b/client/src/views/device/smart-device/index.vue new file mode 100644 index 0000000..f2475ca --- /dev/null +++ b/client/src/views/device/smart-device/index.vue @@ -0,0 +1,699 @@ + + + + + \ No newline at end of file diff --git a/database/db_seed.sql b/database/db_seed.sql index cdf8e13..cd03668 100644 --- a/database/db_seed.sql +++ b/database/db_seed.sql @@ -49,7 +49,7 @@ INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort (1, NULL, '客户管理', '/customer', NULL, 'TeamOutlined', 1, 1, 'customer:list', 1), (2, NULL, '项目管理', '/project', NULL, 'ProjectOutlined', 2, 1, 'project:list', 1), (3, NULL, '运营派单', '/dispatch', NULL, 'ScheduleOutlined', 3, 1, 'dispatch:list', 1), -(4, NULL, '设备监控', '/device', NULL, 'MonitorOutlined', 4, 2, 'device:list', 1), +(4, NULL, '设备监控', '/device', NULL, 'MonitorOutlined', 4, 1, 'device:list', 1), (5, NULL, '统计报表', '/report', NULL, 'BarChartOutlined', 5, 2, 'report:list', 1), (6, NULL, '系统管理', '/system', NULL, 'SettingOutlined', 6, 1, 'system:list', 1) ON CONFLICT (id) DO UPDATE SET @@ -292,7 +292,36 @@ INSERT INTO sys_role_menu (role_id, menu_id) VALUES ON CONFLICT (role_id, menu_id) DO NOTHING; -- ------------------------------------------------------------ --- 8. 定时任务种子(sys_job,按 job_key 幂等) +-- 8. 设备域菜单拆分(设备监控目录 + 设备管理台账子菜单 / 基础数据目录 + 智电设备型号子菜单) +-- 设备监控(id=4)由菜单项改为目录 menu_type=1;台账子菜单挂在设备监控下 +-- 基础数据为新一级目录,智电设备型号(基础数据,与客户无关)挂其下 +-- 注:id 用 58-60(55-57 已被生产库报修派单/维保派单/重置电工密码占用,避免重跑冲突) +-- ------------------------------------------------------------ +INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES +(58, 4, '设备管理台账', '/device/equipment', 'device/equipment/index', 'MonitorOutlined', 1, 2, 'device:list', 1), +(59, NULL, '基础数据', '/base', NULL, 'DatabaseOutlined', 5, 1, 'base:list', 1), +(60, 59, '智电设备型号', '/base/smart-device', 'device/smart-device/index', 'ApartmentOutlined', 1, 2, 'device:model', 1) +ON CONFLICT (id) DO UPDATE SET + parent_id = EXCLUDED.parent_id, + menu_name = EXCLUDED.menu_name, + menu_path = EXCLUDED.menu_path, + component = EXCLUDED.component, + icon = EXCLUDED.icon, + sort_order = EXCLUDED.sort_order, + menu_type = EXCLUDED.menu_type, + permission = EXCLUDED.permission, + visible = EXCLUDED.visible; + +-- 角色-菜单绑定:ADMIN(role_id=1)拥有 58-60 +INSERT INTO sys_role_menu (role_id, menu_id) VALUES +(1, 58), (1, 59), (1, 60) +ON CONFLICT (role_id, menu_id) DO NOTHING; + +-- 重置 sys_menu 自增序列(覆盖到 60) +SELECT setval('sys_menu_id_seq', 60, true); + +-- ------------------------------------------------------------ +-- 9. 定时任务种子(sys_job,按 job_key 幂等) -- ------------------------------------------------------------ INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, retry_interval, status, remark) VALUES ('demo_heartbeat', '示例心跳', '*/5 * * * *', '{"message":"heartbeat"}'::jsonb, 0, 60, 1, diff --git a/database/migrations/20261007_device_menu_split_fix.sql b/database/migrations/20261007_device_menu_split_fix.sql new file mode 100644 index 0000000..96adc44 --- /dev/null +++ b/database/migrations/20261007_device_menu_split_fix.sql @@ -0,0 +1,88 @@ +-- 设备域菜单拆分-恢复版:先恢复被误覆盖的 55/56/57(报修派单/维保派单/重置电工密码), +-- 再用空闲 id 58-60 创建设备域菜单。修复由 20261007_device_menu_split.sql 引入的覆盖事故。 +BEGIN; + +-- ------------------------------------------------------------ +-- 1) 恢复 id=55 报修派单(原字段:parent=3 /dispatch/repair dispatch:repair) +-- ------------------------------------------------------------ +UPDATE sys_menu SET + parent_id = 3, + menu_name = '报修派单', + menu_path = '/dispatch/repair', + component = 'dispatch/repair/index', + icon = 'ToolOutlined', + sort_order = 3, + menu_type = 2, + permission = 'dispatch:repair', + visible = 1 +WHERE id = 55; + +-- ------------------------------------------------------------ +-- 2) 恢复 id=56 维保派单(原字段:parent=3 /dispatch/maintenance dispatch:maintenance) +-- ------------------------------------------------------------ +UPDATE sys_menu SET + parent_id = 3, + menu_name = '维保派单', + menu_path = '/dispatch/maintenance', + component = 'dispatch/maintenance/index', + icon = 'ToolOutlined', + sort_order = 4, + menu_type = 2, + permission = 'dispatch:maintenance', + visible = 1 +WHERE id = 56; + +-- ------------------------------------------------------------ +-- 3) 恢复 id=57 重置电工密码(原字段:parent=3 按钮权限 worker:reset-password) +-- ------------------------------------------------------------ +UPDATE sys_menu SET + parent_id = 3, + menu_name = '重置电工密码', + menu_path = NULL, + component = NULL, + icon = NULL, + sort_order = 11, + menu_type = 3, + permission = 'worker:reset-password', + visible = 1 +WHERE id = 57; + +-- 清理误绑定的角色菜单:(1,57) 保留给 ADMIN(重置电工密码按钮),并确保绑定存在 +-- (2,55)(2,56) 语义已随恢复回到报修/维保派单,保留即可,无需改动 +INSERT INTO sys_role_menu (role_id, menu_id) VALUES (1, 57) +ON CONFLICT (role_id, menu_id) DO NOTHING; + +-- ------------------------------------------------------------ +-- 4) 设备监控(id=4):菜单项 → 目录(menu_type=2→1),保持 /device 与 device:list +-- ------------------------------------------------------------ +UPDATE sys_menu SET menu_type = 1 WHERE id = 4 AND menu_type = 2; + +-- ------------------------------------------------------------ +-- 5) 设备域菜单使用空闲 id 58-60 +-- ------------------------------------------------------------ +INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES +(58, 4, '设备管理台账', '/device/equipment', 'device/equipment/index', 'MonitorOutlined', 1, 2, 'device:list', 1), +(59, NULL, '基础数据', '/base', NULL, 'DatabaseOutlined', 5, 1, 'base:list', 1), +(60, 59, '智电设备型号', '/base/smart-device', 'device/smart-device/index', 'ApartmentOutlined', 1, 2, 'device:model', 1) +ON CONFLICT (id) DO UPDATE SET + parent_id = EXCLUDED.parent_id, + menu_name = EXCLUDED.menu_name, + menu_path = EXCLUDED.menu_path, + component = EXCLUDED.component, + icon = EXCLUDED.icon, + sort_order = EXCLUDED.sort_order, + menu_type = EXCLUDED.menu_type, + permission = EXCLUDED.permission, + visible = EXCLUDED.visible; + +-- ------------------------------------------------------------ +-- 6) 角色-菜单绑定:ADMIN(role_id=1) 拥有 58-60;SALESMAN 不给设备/基础数据权限 +-- ------------------------------------------------------------ +INSERT INTO sys_role_menu (role_id, menu_id) VALUES +(1, 58), (1, 59), (1, 60) +ON CONFLICT (role_id, menu_id) DO NOTHING; + +-- 重置序列(覆盖到 60,避免后续手动插入冲突) +SELECT setval('sys_menu_id_seq', GREATEST((SELECT MAX(id) FROM sys_menu), 60), true); + +COMMIT; diff --git a/server/src/controller/customer_equipment.controller.ts b/server/src/controller/customer_equipment.controller.ts new file mode 100644 index 0000000..8ddd6cb --- /dev/null +++ b/server/src/controller/customer_equipment.controller.ts @@ -0,0 +1,15 @@ +import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core"; +import { Context } from "@midwayjs/koa"; +import { CustomerEquipmentService } from "../service/customer_equipment.service"; + +@Controller("/api/customer-equipments") +export class CustomerEquipmentController { + @Inject() ctx!: Context; + @Inject() customerEquipmentService!: CustomerEquipmentService; + + @Get("/") async list(@Query() query: any) { return this.customerEquipmentService.list(query, this.ctx); } + @Get("/:id") async detail(@Param("id") id: string) { return this.customerEquipmentService.detail(Number(id), this.ctx); } + @Post("/") async create(@Body() body: any) { return this.customerEquipmentService.create(body, this.ctx); } + @Put("/:id") async update(@Param("id") id: string, @Body() body: any) { return this.customerEquipmentService.update(Number(id), body, this.ctx); } + @Del("/:id") async delete(@Param("id") id: string) { return this.customerEquipmentService.delete(Number(id), this.ctx); } +} \ No newline at end of file diff --git a/server/src/controller/smart_device.controller.ts b/server/src/controller/smart_device.controller.ts new file mode 100644 index 0000000..dad4faa --- /dev/null +++ b/server/src/controller/smart_device.controller.ts @@ -0,0 +1,15 @@ +import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core"; +import { Context } from "@midwayjs/koa"; +import { SmartDeviceService } from "../service/smart_device.service"; + +@Controller("/api/smart-devices") +export class SmartDeviceController { + @Inject() ctx!: Context; + @Inject() smartDeviceService!: SmartDeviceService; + + @Get("/") async list(@Query() query: any) { return this.smartDeviceService.list(query, this.ctx); } + @Get("/:id") async detail(@Param("id") id: string) { return this.smartDeviceService.detail(Number(id), this.ctx); } + @Post("/") async create(@Body() body: any) { return this.smartDeviceService.create(body, this.ctx); } + @Put("/:id") async update(@Param("id") id: string, @Body() body: any) { return this.smartDeviceService.update(Number(id), body, this.ctx); } + @Del("/:id") async delete(@Param("id") id: string) { return this.smartDeviceService.delete(Number(id), this.ctx); } +} \ No newline at end of file diff --git a/server/src/dto/customer_equipment.dto.ts b/server/src/dto/customer_equipment.dto.ts new file mode 100644 index 0000000..798e5ee --- /dev/null +++ b/server/src/dto/customer_equipment.dto.ts @@ -0,0 +1,34 @@ +import { IsNotEmpty, IsOptional, IsString, IsInt, Matches, Min, Max, MaxLength } from "class-validator"; + +export class CustomerEquipmentQueryDTO { + @IsOptional() @IsInt() @Min(1) page?: number = 1; + @IsOptional() @IsInt() @Min(1) @Max(200) pageSize?: number = 10; + @IsOptional() @IsString() @MaxLength(64) keyword?: string; + @IsOptional() @IsInt() customerId?: number; + @IsOptional() @IsInt() buildingId?: number; + @IsOptional() @IsString() @MaxLength(32) equipType?: string; +} + +export class CustomerEquipmentCreateDTO { + @IsNotEmpty({ message: "所属客户不能为空" }) @IsInt() customerId!: number; + @IsNotEmpty({ message: "设备名称不能为空" }) @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) equipName!: string; + @IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) equipType?: string; + @IsOptional() @IsInt() buildingId?: number; + @IsOptional() @IsString() @MaxLength(16) floorNo?: string; + @IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) location?: string; + @IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) description?: string; + @IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) remark?: string; + @IsOptional() @IsString() @MaxLength(512) photoUrl?: string; +} + +export class CustomerEquipmentUpdateDTO { + @IsOptional() @IsInt() customerId?: number; + @IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) equipName?: string; + @IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) equipType?: string; + @IsOptional() @IsInt() buildingId?: number; + @IsOptional() @IsString() @MaxLength(16) floorNo?: string; + @IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) location?: string; + @IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) description?: string; + @IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) remark?: string; + @IsOptional() @IsString() @MaxLength(512) photoUrl?: string; +} \ No newline at end of file diff --git a/server/src/dto/smart_device.dto.ts b/server/src/dto/smart_device.dto.ts new file mode 100644 index 0000000..d73c35d --- /dev/null +++ b/server/src/dto/smart_device.dto.ts @@ -0,0 +1,26 @@ +import { IsNotEmpty, IsOptional, IsString, Matches, Min, Max, MaxLength } from "class-validator"; + +export class SmartDeviceQueryDTO { + @IsOptional() @Min(1) page?: number = 1; + @IsOptional() @Min(1) @Max(200) pageSize?: number = 10; + @IsOptional() @IsString() @MaxLength(64) keyword?: string; + @IsOptional() @IsString() @MaxLength(64) deviceType?: string; +} + +export class SmartDeviceCreateDTO { + @IsNotEmpty({ message: "设备型号不能为空" }) @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) deviceModel!: string; + @IsNotEmpty({ message: "设备名称不能为空" }) @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) deviceName!: string; + @IsOptional() @IsString() @MaxLength(256) @Matches(/^[^<>]*$/) deviceSpec?: string; + @IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) deviceType?: string; + @IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) deviceFunction?: string; + @IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) supplier?: string; +} + +export class SmartDeviceUpdateDTO { + @IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) deviceModel?: string; + @IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) deviceName?: string; + @IsOptional() @IsString() @MaxLength(256) @Matches(/^[^<>]*$/) deviceSpec?: string; + @IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) deviceType?: string; + @IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) deviceFunction?: string; + @IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) supplier?: string; +} \ No newline at end of file diff --git a/server/src/entity/customer_equipment.entity.ts b/server/src/entity/customer_equipment.entity.ts new file mode 100644 index 0000000..a8d7225 --- /dev/null +++ b/server/src/entity/customer_equipment.entity.ts @@ -0,0 +1,54 @@ +import { Column, Model, Table, DataType, CreatedAt, ForeignKey, BelongsTo } from "sequelize-typescript"; +import CustomerBuilding from "./customer_building.entity"; + +@Table({ + tableName: "customer_equipment", + comment: "客户设备台账表", + timestamps: false, + indexes: [ + { name: "idx_equip_customer", fields: ["customer_id"] }, + { name: "idx_equip_building", fields: ["building_id"] }, + { name: "uk_equip_code", unique: true, fields: ["equip_code"] } + ] +}) +export default class CustomerEquipment extends Model { + @Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true }) + id!: number; + + @Column({ type: DataType.STRING(64), allowNull: false, unique: true, field: "equip_code", comment: "设备编码(EQ+YYYYMMDD+序号)" }) + equipCode!: string; + + @Column({ type: DataType.BIGINT, allowNull: false, field: "customer_id", comment: "客户ID" }) + customerId!: number; + + @Column({ type: DataType.STRING(128), allowNull: false, field: "equip_name", comment: "设备名称" }) + equipName!: string; + + @Column({ type: DataType.STRING(32), field: "equip_type", comment: "设备类型" }) + equipType!: string; + + @ForeignKey(() => CustomerBuilding) + @Column({ type: DataType.BIGINT, field: "building_id", comment: "所属建筑ID" }) + buildingId!: number; + + @Column({ type: DataType.STRING(16), field: "floor_no", comment: "楼层" }) + floorNo!: string; + + @Column({ type: DataType.STRING(128), comment: "安装位置" }) + location!: string; + + @Column({ type: DataType.TEXT, comment: "设备描述" }) + description!: string; + + @Column({ type: DataType.TEXT, comment: "备注" }) + remark!: string; + + @Column({ type: DataType.STRING(512), field: "photo_url", comment: "设备照片URL" }) + photoUrl!: string; + + @BelongsTo(() => CustomerBuilding, { foreignKey: "buildingId", as: "building" }) + building!: CustomerBuilding; + + @CreatedAt + created_at!: Date; +} \ No newline at end of file diff --git a/server/src/entity/smart_device.entity.ts b/server/src/entity/smart_device.entity.ts new file mode 100644 index 0000000..ff56cf8 --- /dev/null +++ b/server/src/entity/smart_device.entity.ts @@ -0,0 +1,32 @@ +import { Column, Model, Table, DataType, CreatedAt } from "sequelize-typescript"; + +@Table({ + tableName: "smart_device", + comment: "智电设备型号目录表", + timestamps: false +}) +export default class SmartDevice extends Model { + @Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true }) + id!: number; + + @Column({ type: DataType.STRING(128), allowNull: false, unique: true, field: "device_model", comment: "设备型号" }) + deviceModel!: string; + + @Column({ type: DataType.STRING(128), allowNull: false, field: "device_name", comment: "设备名称" }) + deviceName!: string; + + @Column({ type: DataType.STRING(256), field: "device_spec", comment: "设备规格" }) + deviceSpec!: string; + + @Column({ type: DataType.STRING(64), field: "device_type", comment: "设备类型" }) + deviceType!: string; + + @Column({ type: DataType.TEXT, field: "device_function", comment: "设备功能说明" }) + deviceFunction!: string; + + @Column({ type: DataType.STRING(128), comment: "供应商" }) + supplier!: string; + + @CreatedAt + created_at!: Date; +} \ No newline at end of file diff --git a/server/src/middleware/rbac.middleware.ts b/server/src/middleware/rbac.middleware.ts index e92891d..2ea0560 100644 --- a/server/src/middleware/rbac.middleware.ts +++ b/server/src/middleware/rbac.middleware.ts @@ -21,6 +21,8 @@ const PERMISSION_MAP: Record = { "/api/upload": "customer:visit", "/api/customer-buildings": "customer:building", "/api/customer-rooms": "customer:room", + "/api/customer-equipments": "device:list", + "/api/smart-devices": "device:model", "/api/survey-orders": "project:survey", "/api/project-plans": "project:plan", "/api/contracts": "project:contract", diff --git a/server/src/service/customer_equipment.service.ts b/server/src/service/customer_equipment.service.ts new file mode 100644 index 0000000..af9c103 --- /dev/null +++ b/server/src/service/customer_equipment.service.ts @@ -0,0 +1,177 @@ +import { Provide } from "@midwayjs/core"; +import { InjectRepository } from "@midwayjs/sequelize"; +import { Repository } from "sequelize-typescript"; +import { Op, UniqueConstraintError } from "sequelize"; +import { Context } from "@midwayjs/koa"; +import CustomerEquipment from "../entity/customer_equipment.entity"; +import Customer from "../entity/customer.entity"; +import CustomerBuilding from "../entity/customer_building.entity"; +import SysUserRole from "../entity/sysUserRole.entity"; +import SysRole from "../entity/sysRole.entity"; + +@Provide() +export class CustomerEquipmentService { + @InjectRepository(CustomerEquipment) + equipmentModel!: Repository; + + @InjectRepository(Customer) + customerModel!: Repository; + + @InjectRepository(CustomerBuilding) + buildingModel!: Repository; + + @InjectRepository(SysUserRole) + userRoleModel!: Repository; + + @InjectRepository(SysRole) + roleModel!: Repository; + + private async isAdmin(userId: number): Promise { + const userRoles = await this.userRoleModel.findAll({ where: { userId }, include: [this.roleModel] }); + return userRoles.some((ur) => (ur as any).role?.roleCode === "ADMIN"); + } + + private async isCustomerOwner(userId: number, customerId: number): Promise { + if (await this.isAdmin(userId)) return true; + const customer = await this.customerModel.findByPk(customerId); + return !!customer && customer.salesmanId === userId; + } + + private async generateEquipCode(retry: number = 0): Promise { + const now = new Date(); + const offsetMs = now.getTime() + 8 * 60 * 60 * 1000; + const dateLocal = new Date(offsetMs); + const yyyy = dateLocal.getUTCFullYear(); + const mm = String(dateLocal.getUTCMonth() + 1).padStart(2, "0"); + const dd = String(dateLocal.getUTCDate()).padStart(2, "0"); + const yyyymmdd = `${yyyy}${mm}${dd}`; + + const lastRecord = await this.equipmentModel.findOne({ + where: { equipCode: { [Op.like]: `EQ${yyyymmdd}%` } }, + order: [["equip_code", "DESC"]] + }); + + let newSeq = 1; + if (lastRecord) { + const lastNo = lastRecord.equipCode; + const seqStr = lastNo.slice(-3); + const seq = parseInt(seqStr, 10) || 0; + newSeq = seq + 1; + } + + const newSeqStr = String(newSeq).padStart(3, "0"); + const result = `EQ${yyyymmdd}${newSeqStr}`; + + const exists = await this.equipmentModel.findOne({ where: { equipCode: result } }); + if (exists) { + if (retry < 1) { + return this.generateEquipCode(retry + 1); + } + throw new Error("设备编码生成冲突,请重试"); + } + + return result; + } + + async list(query: any, ctx: Context): Promise { + const page = Number(query.page) || 1; + const pageSize = Number(query.pageSize) || 10; + const userId = ctx.state.user.userId; + const where: any = {}; + + if (query.keyword) { + where[Op.or] = [ + { equipCode: { [Op.like]: `%${query.keyword}%` } }, + { equipName: { [Op.like]: `%${query.keyword}%` } }, + { equipType: { [Op.like]: `%${query.keyword}%` } }, + { location: { [Op.like]: `%${query.keyword}%` } } + ]; + } + if (query.customerId !== undefined) where.customerId = query.customerId; + if (query.buildingId !== undefined) where.buildingId = query.buildingId; + if (query.equipType) where.equipType = query.equipType; + + if (!(await this.isAdmin(userId))) { + const myCustomers = await this.customerModel.findAll({ where: { salesmanId: userId }, attributes: ["id"] }); + const ids = myCustomers.map((c) => c.id); + if (ids.length === 0) { + return { code: 200, message: "获取客户设备列表成功", data: { list: [], total: 0, page: Number(page), pageSize: Number(pageSize) } }; + } + if (where.customerId !== undefined) { + if (!ids.includes(where.customerId)) { + return { code: 200, message: "获取客户设备列表成功", data: { list: [], total: 0, page: Number(page), pageSize: Number(pageSize) } }; + } + } else { + where.customerId = { [Op.in]: ids }; + } + } + + const { count, rows } = await this.equipmentModel.findAndCountAll({ + where, + limit: pageSize, + offset: (page - 1) * pageSize, + order: [["created_at", "DESC"]] + }); + + return { code: 200, message: "获取客户设备列表成功", data: { list: rows.map((r) => r.toJSON()), total: count, page: Number(page), pageSize: Number(pageSize) } }; + } + + async detail(id: number, ctx: Context): Promise { + const record = await this.equipmentModel.findByPk(id); + if (!record) return { code: 404, message: "客户设备不存在", data: null }; + if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限访问该设备", data: null }; + return { code: 200, message: "获取客户设备详情成功", data: record.toJSON() }; + } + + async create(data: any, ctx: Context): Promise { + if (!(await this.isCustomerOwner(ctx.state.user.userId, data.customerId))) return { code: 403, message: "无权限为该客户创建设备", data: null }; + + try { + data.equipCode = await this.generateEquipCode(); + const created = await this.equipmentModel.create(data); + return { code: 200, message: "创建客户设备成功", data: created.toJSON() }; + } catch (err: any) { + if (err instanceof UniqueConstraintError && err.errors?.some((e) => e.path === "equip_code" || e.path === "uk_equip_code")) { + try { + const equipCode = await this.generateEquipCode(); + const created = await this.equipmentModel.create({ ...data, equipCode }); + return { code: 200, message: "创建客户设备成功", data: created.toJSON() }; + } catch (_e) { + return { code: 400, message: "设备编码冲突,请重试", data: null }; + } + } + throw err; + } + } + + async update(id: number, data: any, ctx: Context): Promise { + const record = await this.equipmentModel.findByPk(id); + if (!record) return { code: 404, message: "客户设备不存在", data: null }; + if (!(await this.isAdmin(ctx.state.user.userId))) { + if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限修改该设备", data: null }; + delete data.customerId; + } + + delete data.equipCode; + delete data.createdAt; + delete data.created_at; + + try { + await record.update(data); + return { code: 200, message: "更新客户设备成功", data: record.toJSON() }; + } catch (err: any) { + if (err instanceof UniqueConstraintError) { + return { code: 400, message: "设备编码已存在", data: null }; + } + throw err; + } + } + + async delete(id: number, ctx: Context): Promise { + const record = await this.equipmentModel.findByPk(id); + if (!record) return { code: 404, message: "客户设备不存在", data: null }; + if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限删除该设备", data: null }; + await record.destroy(); + return { code: 200, message: "删除客户设备成功", data: null }; + } +} \ No newline at end of file diff --git a/server/src/service/smart_device.service.ts b/server/src/service/smart_device.service.ts new file mode 100644 index 0000000..f8190d5 --- /dev/null +++ b/server/src/service/smart_device.service.ts @@ -0,0 +1,79 @@ +import { Provide } from "@midwayjs/core"; +import { InjectRepository } from "@midwayjs/sequelize"; +import { Repository } from "sequelize-typescript"; +import { Op, UniqueConstraintError } from "sequelize"; +import { Context } from "@midwayjs/koa"; +import SmartDevice from "../entity/smart_device.entity"; + +@Provide() +export class SmartDeviceService { + @InjectRepository(SmartDevice) + smartDeviceModel!: Repository; + + async list(query: any, ctx: Context): Promise { + const page = Number(query.page) || 1; + const pageSize = Number(query.pageSize) || 10; + const where: any = {}; + + if (query.keyword) { + where[Op.or] = [ + { deviceModel: { [Op.like]: `%${query.keyword}%` } }, + { deviceName: { [Op.like]: `%${query.keyword}%` } }, + { supplier: { [Op.like]: `%${query.keyword}%` } } + ]; + } + if (query.deviceType) where.deviceType = query.deviceType; + + const { count, rows } = await this.smartDeviceModel.findAndCountAll({ + where, + limit: pageSize, + offset: (page - 1) * pageSize, + order: [["created_at", "DESC"]] + }); + + return { code: 200, message: "获取智电设备型号列表成功", data: { list: rows.map((r) => r.toJSON()), total: count, page: Number(page), pageSize: Number(pageSize) } }; + } + + async detail(id: number, ctx: Context): Promise { + const record = await this.smartDeviceModel.findByPk(id); + if (!record) return { code: 404, message: "智电设备型号不存在", data: null }; + return { code: 200, message: "获取智电设备型号详情成功", data: record.toJSON() }; + } + + async create(data: any, ctx: Context): Promise { + try { + const created = await this.smartDeviceModel.create(data); + return { code: 200, message: "创建智电设备型号成功", data: created.toJSON() }; + } catch (err: any) { + if (err instanceof UniqueConstraintError && err.errors?.some((e) => e.path === "device_model")) { + return { code: 400, message: "设备型号已存在", data: null }; + } + throw err; + } + } + + async update(id: number, data: any, ctx: Context): Promise { + const record = await this.smartDeviceModel.findByPk(id); + if (!record) return { code: 404, message: "智电设备型号不存在", data: null }; + + delete data.createdAt; + delete data.created_at; + + try { + await record.update(data); + return { code: 200, message: "更新智电设备型号成功", data: record.toJSON() }; + } catch (err: any) { + if (err instanceof UniqueConstraintError && err.errors?.some((e) => e.path === "device_model")) { + return { code: 400, message: "设备型号已存在", data: null }; + } + throw err; + } + } + + async delete(id: number, ctx: Context): Promise { + const record = await this.smartDeviceModel.findByPk(id); + if (!record) return { code: 404, message: "智电设备型号不存在", data: null }; + await record.destroy(); + return { code: 200, message: "删除智电设备型号成功", data: null }; + } +} \ No newline at end of file