feat(device): 新增设备管理模块全栈实现并拆分菜单 - 客户设备台账(customer_equipment)与智电设备型号目录(smart_device)CRUD,JWT+RBAC鉴权、参数绑定防SQL注入;设备监控改目录挂台账(/device/equipment),新建基础数据目录挂型号(/base/smart-device);前端防XSS样式对齐客户模块;远程库菜单迁移20261007_device_menu_split_fix.sql(id 58-60)
This commit is contained in:
1 parent
91aa3c06f0
commit
09f7345e8a
18 files changed
+2482
-7
No files matched your search
@@ -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 并返回列表数据)
|
||||||
@@ -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`。
|
||||||
@@ -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] 完成后统一接入
|
||||||
@@ -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<CustomerEquipmentListResult>('/customer-equipments', { params }),
|
||||||
|
|
||||||
|
getDetail: (id: number) =>
|
||||||
|
request.get<CustomerEquipment>(`/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<SmartDeviceListResult>('/smart-devices', { params }),
|
||||||
|
|
||||||
|
getDetail: (id: number) =>
|
||||||
|
request.get<SmartDevice>(`/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}`),
|
||||||
|
};
|
||||||
@@ -188,11 +188,29 @@ const routes: RouteRecordRaw[] = [
|
|||||||
meta: { requiresAuth: true, title: "电工登记管理", permission: "dispatch:worker" }
|
meta: { requiresAuth: true, title: "电工登记管理", permission: "dispatch:worker" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "device",
|
path: "device",
|
||||||
name: "Device",
|
name: "Device",
|
||||||
component: () => import("@/views/Placeholder.vue"),
|
redirect: "/device/equipment",
|
||||||
meta: { requiresAuth: true, title: "设备监控", permission: "device:list" }
|
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",
|
path: "report",
|
||||||
name: "Report",
|
name: "Report",
|
||||||
|
|||||||
@@ -0,0 +1,921 @@
|
|||||||
|
<template>
|
||||||
|
<div class="device-equipment">
|
||||||
|
<!-- 顶部标题 -->
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="breadcrumb">
|
||||||
|
<span>运营管控体系</span>
|
||||||
|
<span class="sep">/</span>
|
||||||
|
<span>设备监控</span>
|
||||||
|
<span class="sep">/</span>
|
||||||
|
<span class="cur">设备管理台账</span>
|
||||||
|
</div>
|
||||||
|
<div class="header-main">
|
||||||
|
<div class="header-left">
|
||||||
|
<div class="header-row">
|
||||||
|
<span class="title-bar"></span>
|
||||||
|
<span class="main-title">设备管理台账</span>
|
||||||
|
<span class="sub-title">EQUIPMENT LEDGER</span>
|
||||||
|
</div>
|
||||||
|
<p class="desc">统一维护客户侧智电改造设备(配电间/井/箱)档案,支持按客户、建筑、类型快速检索。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 主体卡片 -->
|
||||||
|
<div class="content-card">
|
||||||
|
<!-- 筛选区 -->
|
||||||
|
<div class="filter-card">
|
||||||
|
<div class="filter-grid">
|
||||||
|
<div class="filter-item">
|
||||||
|
<label class="filter-label">所属客户</label>
|
||||||
|
<a-select
|
||||||
|
v-model:value="searchForm.customerId"
|
||||||
|
placeholder="全部客户"
|
||||||
|
allow-clear
|
||||||
|
show-search
|
||||||
|
option-filter-prop="label"
|
||||||
|
:options="customerOptions"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="filter-item">
|
||||||
|
<label class="filter-label">所属建筑</label>
|
||||||
|
<a-input
|
||||||
|
v-model:value="searchForm.building"
|
||||||
|
placeholder="建筑名称"
|
||||||
|
allow-clear
|
||||||
|
@pressEnter="handleSearch"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="filter-item">
|
||||||
|
<label class="filter-label">设备类型</label>
|
||||||
|
<a-select v-model:value="searchForm.equipType" placeholder="全部类型" allow-clear>
|
||||||
|
<a-select-option v-for="t in equipTypeOptions" :key="t" :value="t">{{ t }}</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-item">
|
||||||
|
<label class="filter-label">关键词检索</label>
|
||||||
|
<a-input
|
||||||
|
v-model:value="searchForm.keyword"
|
||||||
|
placeholder="设备编码 / 名称 / 位置"
|
||||||
|
allow-clear
|
||||||
|
@pressEnter="handleSearch"
|
||||||
|
>
|
||||||
|
<template #prefix><SearchOutlined /></template>
|
||||||
|
</a-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="filter-bottom">
|
||||||
|
<div class="filter-actions">
|
||||||
|
<a-button type="primary" @click="handleSearch">
|
||||||
|
<template #icon><SearchOutlined /></template>
|
||||||
|
查询
|
||||||
|
</a-button>
|
||||||
|
<a-button @click="handleReset">
|
||||||
|
<template #icon><ReloadOutlined /></template>
|
||||||
|
重置
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 列表区 -->
|
||||||
|
<div class="list-section">
|
||||||
|
<div class="list-header">
|
||||||
|
<div class="list-title">
|
||||||
|
<span class="dot"></span>
|
||||||
|
<span class="t">设备台账清单</span>
|
||||||
|
<span class="count">{{ total }} 台</span>
|
||||||
|
</div>
|
||||||
|
<div class="list-actions">
|
||||||
|
<a-button type="primary" @click="handleAdd">
|
||||||
|
<template #icon><PlusOutlined /></template>
|
||||||
|
新增设备
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrapper" ref="wrapperRef">
|
||||||
|
<a-table
|
||||||
|
:columns="columns"
|
||||||
|
:data-source="dataSource"
|
||||||
|
:loading="loading"
|
||||||
|
:pagination="pagination"
|
||||||
|
row-key="id"
|
||||||
|
:scroll="{ x: 'max-content', y: tableHeight }"
|
||||||
|
@change="handleTableChange"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'equipmentNo'">
|
||||||
|
<span class="mono">{{ record.equipmentNo || '-' }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'equipmentName'">
|
||||||
|
<span class="equip-name">{{ record.equipmentName }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'equipType'">
|
||||||
|
<span v-if="record.equipType" class="type-chip">{{ record.equipType }}</span>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'customerId'">
|
||||||
|
{{ customerNameMap[record.customerId] || '-' }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'photo'">
|
||||||
|
<a-image
|
||||||
|
v-if="record.photo"
|
||||||
|
:src="record.photo"
|
||||||
|
:width="44"
|
||||||
|
:height="44"
|
||||||
|
style="border-radius: 6px"
|
||||||
|
:preview="{ mask: false }"
|
||||||
|
/>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'action'">
|
||||||
|
<a-space>
|
||||||
|
<a-button type="link" size="small" @click="handleEdit(record)">
|
||||||
|
<template #icon><EditOutlined /></template>
|
||||||
|
编辑
|
||||||
|
</a-button>
|
||||||
|
<a-popconfirm
|
||||||
|
title="确定要删除该设备吗?"
|
||||||
|
ok-text="确定"
|
||||||
|
cancel-text="取消"
|
||||||
|
ok-button-props="{ danger: true }"
|
||||||
|
@confirm="handleDelete(record.id)"
|
||||||
|
>
|
||||||
|
<a-button type="link" size="small" danger>
|
||||||
|
<template #icon><DeleteOutlined /></template>
|
||||||
|
删除
|
||||||
|
</a-button>
|
||||||
|
</a-popconfirm>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #emptyText>
|
||||||
|
<a-empty description="暂无设备数据" />
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 新增/编辑弹窗 -->
|
||||||
|
<a-modal
|
||||||
|
v-model:open="modalVisible"
|
||||||
|
:title="modalTitle"
|
||||||
|
:confirm-loading="modalLoading"
|
||||||
|
width="760px"
|
||||||
|
@ok="handleSubmit"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
>
|
||||||
|
<!-- 设备编号信息条(编辑态) -->
|
||||||
|
<div v-if="isEdit && editId" class="no-bar">
|
||||||
|
<div class="no-item">
|
||||||
|
<span class="no-label">设备编码</span>
|
||||||
|
<span class="no-value mono">{{ editNo || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="formData"
|
||||||
|
:rules="formRules"
|
||||||
|
:label-col="{ span: 6 }"
|
||||||
|
:wrapper-col="{ span: 17 }"
|
||||||
|
>
|
||||||
|
<!-- SECTION 01 设备主体信息 -->
|
||||||
|
<div class="form-section">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="section-no">01</span>
|
||||||
|
<div class="section-text">
|
||||||
|
<span class="section-name">设备主体信息</span>
|
||||||
|
<span class="section-sub">Equipment Identity</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a-form-item label="设备名称" name="equipmentName">
|
||||||
|
<a-input v-model:value="formData.equipmentName" placeholder="请输入设备名称" maxlength="128" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="设备类型" name="equipType">
|
||||||
|
<a-select
|
||||||
|
v-model:value="formData.equipType"
|
||||||
|
placeholder="请选择设备类型"
|
||||||
|
allow-clear
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<a-select-option v-for="t in equipTypeOptions" :key="t" :value="t">{{ t }}</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="所属客户" name="customerId">
|
||||||
|
<a-select
|
||||||
|
v-model:value="formData.customerId"
|
||||||
|
placeholder="请选择所属客户"
|
||||||
|
:options="customerOptions"
|
||||||
|
show-search
|
||||||
|
option-filter-prop="label"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SECTION 02 位置信息 -->
|
||||||
|
<div class="form-section">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="section-no">02</span>
|
||||||
|
<div class="section-text">
|
||||||
|
<span class="section-name">位置信息</span>
|
||||||
|
<span class="section-sub">Location</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a-form-item label="所属建筑" name="building">
|
||||||
|
<a-input v-model:value="formData.building" placeholder="请输入所属建筑" maxlength="128" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="所在楼层" name="floor">
|
||||||
|
<a-input v-model:value="formData.floor" placeholder="请输入所在楼层" maxlength="64" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="具体位置" name="location">
|
||||||
|
<a-input v-model:value="formData.location" placeholder="请输入具体位置" maxlength="128" />
|
||||||
|
</a-form-item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SECTION 03 照片与备注 -->
|
||||||
|
<div class="form-section">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="section-no">03</span>
|
||||||
|
<div class="section-text">
|
||||||
|
<span class="section-name">照片与备注</span>
|
||||||
|
<span class="section-sub">Photo & Remark</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a-form-item label="设备照片" name="photo">
|
||||||
|
<div class="photo-field">
|
||||||
|
<div class="photo-preview">
|
||||||
|
<img v-if="photoPreview" :src="photoPreview" class="photo-thumb" alt="设备照片" />
|
||||||
|
<span v-else class="photo-empty">未上传</span>
|
||||||
|
</div>
|
||||||
|
<a-upload
|
||||||
|
:show-upload-list="false"
|
||||||
|
:custom-request="handlePhotoUpload"
|
||||||
|
:before-upload="beforePhotoUpload"
|
||||||
|
accept="image/*"
|
||||||
|
:disabled="photoUploading"
|
||||||
|
>
|
||||||
|
<a-button :loading="photoUploading">
|
||||||
|
{{ formData.photo ? '替换' : '上传' }}
|
||||||
|
</a-button>
|
||||||
|
</a-upload>
|
||||||
|
</div>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="备注" name="remark">
|
||||||
|
<a-textarea v-model:value="formData.remark" placeholder="请输入备注" :rows="3" maxlength="256" />
|
||||||
|
</a-form-item>
|
||||||
|
</div>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted, computed } from 'vue';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
import {
|
||||||
|
SearchOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
import {
|
||||||
|
customerEquipmentApi,
|
||||||
|
type CustomerEquipment,
|
||||||
|
type CustomerEquipmentQuery,
|
||||||
|
} from '@/api/device';
|
||||||
|
import { customerApi, type CustomerInfo } from '@/api/customer';
|
||||||
|
import { uploadApi } from '@/api/upload';
|
||||||
|
import { useTableScroll } from '@/composables/useTableScroll';
|
||||||
|
|
||||||
|
const { wrapperRef, tableHeight } = useTableScroll();
|
||||||
|
|
||||||
|
// 设备类型字典(配电间/配电井/配电箱)
|
||||||
|
const equipTypeOptions = ['配电间', '配电井', '配电箱'];
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const dataSource = ref<CustomerEquipment[]>([]);
|
||||||
|
const total = ref(0);
|
||||||
|
const editNo = ref<string>('');
|
||||||
|
|
||||||
|
const searchForm = reactive<CustomerEquipmentQuery>({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
keyword: '',
|
||||||
|
customerId: undefined as any,
|
||||||
|
building: '',
|
||||||
|
equipType: undefined as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
const modalVisible = ref(false);
|
||||||
|
const modalTitle = ref('');
|
||||||
|
const modalLoading = ref(false);
|
||||||
|
const formRef = ref();
|
||||||
|
const isEdit = ref(false);
|
||||||
|
const editId = ref<number | null>(null);
|
||||||
|
|
||||||
|
const customerOptions = ref<{ label: string; value: number }[]>([]);
|
||||||
|
const customerNameMap = reactive<Record<number, string>>({});
|
||||||
|
|
||||||
|
const photoUploading = ref(false);
|
||||||
|
const photoPreview = ref<string>('');
|
||||||
|
|
||||||
|
const formData = reactive<Partial<CustomerEquipment>>({
|
||||||
|
equipmentName: '',
|
||||||
|
equipType: undefined,
|
||||||
|
customerId: undefined,
|
||||||
|
building: '',
|
||||||
|
floor: '',
|
||||||
|
location: '',
|
||||||
|
photo: '',
|
||||||
|
remark: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const formRules = {
|
||||||
|
equipmentName: [{ required: true, message: '请输入设备名称', trigger: 'blur' }],
|
||||||
|
equipType: [{ required: true, message: '请选择设备类型', trigger: 'change' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: '设备编码', dataIndex: 'equipmentNo', key: 'equipmentNo', ellipsis: true, width: 150 },
|
||||||
|
{ title: '设备名称', dataIndex: 'equipmentName', key: 'equipmentName', ellipsis: true },
|
||||||
|
{ title: '设备类型', dataIndex: 'equipType', key: 'equipType', ellipsis: true, width: 100 },
|
||||||
|
{ title: '所属客户', dataIndex: 'customerId', key: 'customerId', ellipsis: true, width: 180 },
|
||||||
|
{ title: '所属建筑', dataIndex: 'building', key: 'building', ellipsis: true, width: 140 },
|
||||||
|
{ title: '所在楼层', dataIndex: 'floor', key: 'floor', ellipsis: true, width: 100 },
|
||||||
|
{ title: '具体位置', dataIndex: 'location', key: 'location', ellipsis: true },
|
||||||
|
{ title: '照片', dataIndex: 'photo', key: 'photo', align: 'center' as const, width: 70 },
|
||||||
|
{ title: '操作', key: 'action', width: 140, fixed: 'right' as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
const pagination = computed(() => ({
|
||||||
|
current: searchForm.page,
|
||||||
|
pageSize: searchForm.pageSize,
|
||||||
|
total: total.value,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showQuickJumper: true,
|
||||||
|
showTotal: (t: number) => `共 ${t} 条数据`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchList();
|
||||||
|
fetchCustomerOptions();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fetchList() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res: any = await customerEquipmentApi.getList(searchForm);
|
||||||
|
if (res.code === 200) {
|
||||||
|
dataSource.value = res.data.list;
|
||||||
|
total.value = res.data.total;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('获取设备列表失败');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCustomerOptions() {
|
||||||
|
try {
|
||||||
|
const res: any = await customerApi.getCustomerOptions();
|
||||||
|
if (res.code === 200) {
|
||||||
|
const list: CustomerInfo[] = res.data;
|
||||||
|
customerOptions.value = list.map((item) => ({
|
||||||
|
label: `${item.customerNo} ${item.customerName}`,
|
||||||
|
value: item.id,
|
||||||
|
}));
|
||||||
|
list.forEach((item) => {
|
||||||
|
customerNameMap[item.id] = `${item.customerNo} ${item.customerName}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取客户选项失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
searchForm.page = 1;
|
||||||
|
fetchList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
searchForm.keyword = '';
|
||||||
|
searchForm.customerId = undefined as any;
|
||||||
|
searchForm.building = '';
|
||||||
|
searchForm.equipType = undefined as any;
|
||||||
|
searchForm.page = 1;
|
||||||
|
fetchList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTableChange(pag: any) {
|
||||||
|
searchForm.page = pag.current;
|
||||||
|
searchForm.pageSize = pag.pageSize;
|
||||||
|
fetchList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 照片上传 ====================
|
||||||
|
function beforePhotoUpload(file: File) {
|
||||||
|
const isImage = file.type.startsWith('image/');
|
||||||
|
if (!isImage) {
|
||||||
|
message.error('请上传图片文件');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
message.error('图片大小不能超过 10MB');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePhotoUpload(options: any) {
|
||||||
|
const { file, onSuccess, onError } = options;
|
||||||
|
const f = file as File;
|
||||||
|
if (!beforePhotoUpload(f)) {
|
||||||
|
onError?.(new Error('文件校验失败'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
photoUploading.value = true;
|
||||||
|
try {
|
||||||
|
const res: any = await uploadApi.uploadImage(f);
|
||||||
|
if (res.code === 200 && res.data) {
|
||||||
|
formData.photo = res.data.key;
|
||||||
|
photoPreview.value = res.data.url;
|
||||||
|
onSuccess?.(res.data);
|
||||||
|
message.success('上传成功');
|
||||||
|
} else {
|
||||||
|
throw new Error(res.message || '上传失败');
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.message || '图片上传失败');
|
||||||
|
onError?.(e);
|
||||||
|
} finally {
|
||||||
|
photoUploading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 新增/编辑 ====================
|
||||||
|
function handleAdd() {
|
||||||
|
isEdit.value = false;
|
||||||
|
editId.value = null;
|
||||||
|
editNo.value = '';
|
||||||
|
modalTitle.value = '新增设备';
|
||||||
|
resetForm();
|
||||||
|
modalVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEdit(record: CustomerEquipment) {
|
||||||
|
isEdit.value = true;
|
||||||
|
editId.value = record.id;
|
||||||
|
editNo.value = record.equipmentNo;
|
||||||
|
modalTitle.value = '编辑设备';
|
||||||
|
resetForm();
|
||||||
|
|
||||||
|
formData.equipmentName = record.equipmentName;
|
||||||
|
formData.equipType = record.equipType || undefined;
|
||||||
|
formData.customerId = record.customerId;
|
||||||
|
formData.building = record.building || '';
|
||||||
|
formData.floor = record.floor || '';
|
||||||
|
formData.location = record.location || '';
|
||||||
|
formData.photo = record.photo || '';
|
||||||
|
formData.remark = record.remark || '';
|
||||||
|
photoPreview.value = record.photo || '';
|
||||||
|
|
||||||
|
modalVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
formData.equipmentName = '';
|
||||||
|
formData.equipType = undefined;
|
||||||
|
formData.customerId = undefined;
|
||||||
|
formData.building = '';
|
||||||
|
formData.floor = '';
|
||||||
|
formData.location = '';
|
||||||
|
formData.photo = '';
|
||||||
|
formData.remark = '';
|
||||||
|
photoPreview.value = '';
|
||||||
|
|
||||||
|
if (formRef.value) {
|
||||||
|
formRef.value.resetFields();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
try {
|
||||||
|
await formRef.value.validate();
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
modalLoading.value = true;
|
||||||
|
try {
|
||||||
|
const submitData: any = {
|
||||||
|
equipmentName: formData.equipmentName,
|
||||||
|
equipType: formData.equipType,
|
||||||
|
customerId: formData.customerId,
|
||||||
|
building: formData.building,
|
||||||
|
floor: formData.floor,
|
||||||
|
location: formData.location,
|
||||||
|
photo: formData.photo,
|
||||||
|
remark: formData.remark,
|
||||||
|
};
|
||||||
|
|
||||||
|
let res: any;
|
||||||
|
if (isEdit.value && editId.value) {
|
||||||
|
res = await customerEquipmentApi.update(editId.value, submitData);
|
||||||
|
} else {
|
||||||
|
res = await customerEquipmentApi.create(submitData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.code === 200) {
|
||||||
|
message.success(isEdit.value ? '更新成功' : '创建成功');
|
||||||
|
modalVisible.value = false;
|
||||||
|
searchForm.page = 1;
|
||||||
|
fetchList();
|
||||||
|
} else {
|
||||||
|
message.error(res.message || '操作失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('操作失败');
|
||||||
|
} finally {
|
||||||
|
modalLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
modalVisible.value = false;
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: number) {
|
||||||
|
try {
|
||||||
|
const res: any = await customerEquipmentApi.delete(id);
|
||||||
|
if (res.code === 200) {
|
||||||
|
message.success('删除成功');
|
||||||
|
searchForm.page = 1;
|
||||||
|
fetchList();
|
||||||
|
} else {
|
||||||
|
message.error(res.message || '删除失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('删除失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.device-equipment {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: linear-gradient(180deg, #eef3f8 0%, #e8eefb 100%);
|
||||||
|
padding: 20px 24px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #5b6b7e;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb .sep {
|
||||||
|
color: #c2c6d8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb .cur {
|
||||||
|
color: #0054cd;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-bar {
|
||||||
|
width: 5px;
|
||||||
|
height: 22px;
|
||||||
|
background: #0054cd;
|
||||||
|
border-radius: 3px;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-title {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0d1c2c;
|
||||||
|
line-height: 1.4;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0054cd;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #5b6b7e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-card {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #dbe4ee;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.07);
|
||||||
|
padding: 20px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-card {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #f8faff;
|
||||||
|
border: 1px solid #dbe4ee;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.filter-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.filter-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #5b6b7e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bottom {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid #dbe4ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-section {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 2px 4px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-title .dot {
|
||||||
|
width: 4px;
|
||||||
|
height: 16px;
|
||||||
|
background: #0054cd;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-title .t {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0d1c2c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-title .count {
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #eef4ff;
|
||||||
|
color: #0054cd;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: Consolas, Menlo, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: Consolas, Menlo, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #0d1c2c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.equip-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0d1c2c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-chip {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #eef4ff;
|
||||||
|
color: #0054cd;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 弹窗 */
|
||||||
|
.no-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f4f7ff;
|
||||||
|
border: 1px solid #dbe4ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #5b6b7e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-value {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0054cd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-preview {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #dbe4ee;
|
||||||
|
background: #f8faff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-thumb {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-empty {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #a4b0bf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 4px 0 10px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid #eef1f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-no {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #0054cd;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: Consolas, Menlo, monospace;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0d1c2c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #8a97a8;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-table) {
|
||||||
|
font-size: 14px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-table-thead > tr > th) {
|
||||||
|
background: #f4f7ff;
|
||||||
|
color: #5b6b7e;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-btn-link) {
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-btn-primary) {
|
||||||
|
background: #0054cd;
|
||||||
|
border-color: #0054cd;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-btn-primary:not(:disabled):hover),
|
||||||
|
:deep(.ant-btn-primary:not(:disabled):focus) {
|
||||||
|
background: #136bfb;
|
||||||
|
border-color: #136bfb;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,699 @@
|
|||||||
|
<template>
|
||||||
|
<div class="smart-device">
|
||||||
|
<!-- 顶部标题 -->
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="breadcrumb">
|
||||||
|
<span>运营管控体系</span>
|
||||||
|
<span class="sep">/</span>
|
||||||
|
<span>基础数据</span>
|
||||||
|
<span class="sep">/</span>
|
||||||
|
<span class="cur">智电设备型号</span>
|
||||||
|
</div>
|
||||||
|
<div class="header-main">
|
||||||
|
<div class="header-left">
|
||||||
|
<div class="header-row">
|
||||||
|
<span class="title-bar"></span>
|
||||||
|
<span class="main-title">智电设备型号</span>
|
||||||
|
<span class="sub-title">SMART DEVICE MODEL</span>
|
||||||
|
</div>
|
||||||
|
<p class="desc">维护鑫潼智电设备产品目录(型号/规格/类型/供应商),作为基础数据供业务模块引用。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 主体卡片 -->
|
||||||
|
<div class="content-card">
|
||||||
|
<!-- 筛选区 -->
|
||||||
|
<div class="filter-card">
|
||||||
|
<div class="filter-grid">
|
||||||
|
<div class="filter-item">
|
||||||
|
<label class="filter-label">设备类型</label>
|
||||||
|
<a-select v-model:value="searchForm.deviceType" placeholder="全部类型" allow-clear>
|
||||||
|
<a-select-option v-for="t in deviceTypeOptions" :key="t" :value="t">{{ t }}</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-item">
|
||||||
|
<label class="filter-label">关键词检索</label>
|
||||||
|
<a-input
|
||||||
|
v-model:value="searchForm.keyword"
|
||||||
|
placeholder="型号 / 名称 / 规格 / 供应商"
|
||||||
|
allow-clear
|
||||||
|
@pressEnter="handleSearch"
|
||||||
|
>
|
||||||
|
<template #prefix><SearchOutlined /></template>
|
||||||
|
</a-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="filter-bottom">
|
||||||
|
<div class="filter-actions">
|
||||||
|
<a-button type="primary" @click="handleSearch">
|
||||||
|
<template #icon><SearchOutlined /></template>
|
||||||
|
查询
|
||||||
|
</a-button>
|
||||||
|
<a-button @click="handleReset">
|
||||||
|
<template #icon><ReloadOutlined /></template>
|
||||||
|
重置
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 列表区 -->
|
||||||
|
<div class="list-section">
|
||||||
|
<div class="list-header">
|
||||||
|
<div class="list-title">
|
||||||
|
<span class="dot"></span>
|
||||||
|
<span class="t">设备型号目录</span>
|
||||||
|
<span class="count">{{ total }} 条</span>
|
||||||
|
</div>
|
||||||
|
<div class="list-actions">
|
||||||
|
<a-button type="primary" @click="handleAdd">
|
||||||
|
<template #icon><PlusOutlined /></template>
|
||||||
|
新增型号
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrapper" ref="wrapperRef">
|
||||||
|
<a-table
|
||||||
|
:columns="columns"
|
||||||
|
:data-source="dataSource"
|
||||||
|
:loading="loading"
|
||||||
|
:pagination="pagination"
|
||||||
|
row-key="id"
|
||||||
|
:scroll="{ x: 'max-content', y: tableHeight }"
|
||||||
|
@change="handleTableChange"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'modelNo'">
|
||||||
|
<span class="mono">{{ record.modelNo || '-' }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'modelName'">
|
||||||
|
<span class="model-name">{{ record.modelName }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'deviceType'">
|
||||||
|
<span v-if="record.deviceType" class="type-chip">{{ record.deviceType }}</span>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'action'">
|
||||||
|
<a-space>
|
||||||
|
<a-button type="link" size="small" @click="handleEdit(record)">
|
||||||
|
<template #icon><EditOutlined /></template>
|
||||||
|
编辑
|
||||||
|
</a-button>
|
||||||
|
<a-popconfirm
|
||||||
|
title="确定要删除该设备型号吗?"
|
||||||
|
ok-text="确定"
|
||||||
|
cancel-text="取消"
|
||||||
|
ok-button-props="{ danger: true }"
|
||||||
|
@confirm="handleDelete(record.id)"
|
||||||
|
>
|
||||||
|
<a-button type="link" size="small" danger>
|
||||||
|
<template #icon><DeleteOutlined /></template>
|
||||||
|
删除
|
||||||
|
</a-button>
|
||||||
|
</a-popconfirm>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #emptyText>
|
||||||
|
<a-empty description="暂无型号数据" />
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 新增/编辑弹窗 -->
|
||||||
|
<a-modal
|
||||||
|
v-model:open="modalVisible"
|
||||||
|
:title="modalTitle"
|
||||||
|
:confirm-loading="modalLoading"
|
||||||
|
width="720px"
|
||||||
|
@ok="handleSubmit"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
>
|
||||||
|
<a-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="formData"
|
||||||
|
:rules="formRules"
|
||||||
|
:label-col="{ span: 6 }"
|
||||||
|
:wrapper-col="{ span: 16 }"
|
||||||
|
>
|
||||||
|
<div class="form-section">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="section-no">01</span>
|
||||||
|
<div class="section-text">
|
||||||
|
<span class="section-name">基本信息</span>
|
||||||
|
<span class="section-sub">Basic Information</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a-form-item label="型号" name="modelNo">
|
||||||
|
<a-input v-model:value="formData.modelNo" placeholder="请输入设备型号" maxlength="64" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="名称" name="modelName">
|
||||||
|
<a-input v-model:value="formData.modelName" placeholder="请输入设备名称" maxlength="128" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="类型" name="deviceType">
|
||||||
|
<a-select
|
||||||
|
v-model:value="formData.deviceType"
|
||||||
|
placeholder="请选择设备类型"
|
||||||
|
allow-clear
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<a-select-option v-for="t in deviceTypeOptions" :key="t" :value="t">{{ t }}</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="规格" name="spec">
|
||||||
|
<a-input v-model:value="formData.spec" placeholder="请输入规格参数" maxlength="128" />
|
||||||
|
</a-form-item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-section">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="section-no">02</span>
|
||||||
|
<div class="section-text">
|
||||||
|
<span class="section-name">功能与供应商</span>
|
||||||
|
<span class="section-sub">Function & Supplier</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a-form-item label="功能说明" name="functionDesc">
|
||||||
|
<a-textarea v-model:value="formData.functionDesc" placeholder="请输入功能说明" :rows="3" maxlength="256" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="供应商" name="supplier">
|
||||||
|
<a-input v-model:value="formData.supplier" placeholder="请输入供应商" maxlength="64" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="备注" name="remark">
|
||||||
|
<a-textarea v-model:value="formData.remark" placeholder="请输入备注" :rows="2" maxlength="256" />
|
||||||
|
</a-form-item>
|
||||||
|
</div>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted, computed } from 'vue';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
import {
|
||||||
|
SearchOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
import {
|
||||||
|
smartDeviceApi,
|
||||||
|
type SmartDevice,
|
||||||
|
type SmartDeviceQuery,
|
||||||
|
} from '@/api/device';
|
||||||
|
import { useTableScroll } from '@/composables/useTableScroll';
|
||||||
|
|
||||||
|
const { wrapperRef, tableHeight } = useTableScroll();
|
||||||
|
|
||||||
|
// 设备类型字典(网关/探测器/采集器)
|
||||||
|
const deviceTypeOptions = ['网关', '探测器', '采集器'];
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const dataSource = ref<SmartDevice[]>([]);
|
||||||
|
const total = ref(0);
|
||||||
|
|
||||||
|
const searchForm = reactive<SmartDeviceQuery>({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
keyword: '',
|
||||||
|
deviceType: undefined as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
const modalVisible = ref(false);
|
||||||
|
const modalTitle = ref('');
|
||||||
|
const modalLoading = ref(false);
|
||||||
|
const formRef = ref();
|
||||||
|
const isEdit = ref(false);
|
||||||
|
const editId = ref<number | null>(null);
|
||||||
|
|
||||||
|
const formData = reactive<Partial<SmartDevice>>({
|
||||||
|
modelNo: '',
|
||||||
|
modelName: '',
|
||||||
|
spec: '',
|
||||||
|
deviceType: undefined,
|
||||||
|
functionDesc: '',
|
||||||
|
supplier: '',
|
||||||
|
remark: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const formRules = {
|
||||||
|
modelNo: [{ required: true, message: '请输入设备型号', trigger: 'blur' }],
|
||||||
|
modelName: [{ required: true, message: '请输入设备名称', trigger: 'blur' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: '型号', dataIndex: 'modelNo', key: 'modelNo', ellipsis: true, width: 150 },
|
||||||
|
{ title: '名称', dataIndex: 'modelName', key: 'modelName', ellipsis: true },
|
||||||
|
{ title: '类型', dataIndex: 'deviceType', key: 'deviceType', ellipsis: true, width: 110 },
|
||||||
|
{ title: '规格', dataIndex: 'spec', key: 'spec', ellipsis: true },
|
||||||
|
{ title: '功能说明', dataIndex: 'functionDesc', key: 'functionDesc', ellipsis: true },
|
||||||
|
{ title: '供应商', dataIndex: 'supplier', key: 'supplier', ellipsis: true, width: 140 },
|
||||||
|
{ title: '操作', key: 'action', width: 140, fixed: 'right' as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
const pagination = computed(() => ({
|
||||||
|
current: searchForm.page,
|
||||||
|
pageSize: searchForm.pageSize,
|
||||||
|
total: total.value,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showQuickJumper: true,
|
||||||
|
showTotal: (t: number) => `共 ${t} 条数据`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchList();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fetchList() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res: any = await smartDeviceApi.getList(searchForm);
|
||||||
|
if (res.code === 200) {
|
||||||
|
dataSource.value = res.data.list;
|
||||||
|
total.value = res.data.total;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('获取型号列表失败');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
searchForm.page = 1;
|
||||||
|
fetchList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
searchForm.keyword = '';
|
||||||
|
searchForm.deviceType = undefined as any;
|
||||||
|
searchForm.page = 1;
|
||||||
|
fetchList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTableChange(pag: any) {
|
||||||
|
searchForm.page = pag.current;
|
||||||
|
searchForm.pageSize = pag.pageSize;
|
||||||
|
fetchList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 新增/编辑 ====================
|
||||||
|
function handleAdd() {
|
||||||
|
isEdit.value = false;
|
||||||
|
editId.value = null;
|
||||||
|
modalTitle.value = '新增型号';
|
||||||
|
resetForm();
|
||||||
|
modalVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEdit(record: SmartDevice) {
|
||||||
|
isEdit.value = true;
|
||||||
|
editId.value = record.id;
|
||||||
|
modalTitle.value = '编辑型号';
|
||||||
|
resetForm();
|
||||||
|
|
||||||
|
formData.modelNo = record.modelNo;
|
||||||
|
formData.modelName = record.modelName;
|
||||||
|
formData.spec = record.spec || '';
|
||||||
|
formData.deviceType = record.deviceType || undefined;
|
||||||
|
formData.functionDesc = record.functionDesc || '';
|
||||||
|
formData.supplier = record.supplier || '';
|
||||||
|
formData.remark = record.remark || '';
|
||||||
|
|
||||||
|
modalVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
formData.modelNo = '';
|
||||||
|
formData.modelName = '';
|
||||||
|
formData.spec = '';
|
||||||
|
formData.deviceType = undefined;
|
||||||
|
formData.functionDesc = '';
|
||||||
|
formData.supplier = '';
|
||||||
|
formData.remark = '';
|
||||||
|
|
||||||
|
if (formRef.value) {
|
||||||
|
formRef.value.resetFields();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
try {
|
||||||
|
await formRef.value.validate();
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
modalLoading.value = true;
|
||||||
|
try {
|
||||||
|
const submitData: any = {
|
||||||
|
modelNo: formData.modelNo,
|
||||||
|
modelName: formData.modelName,
|
||||||
|
spec: formData.spec,
|
||||||
|
deviceType: formData.deviceType,
|
||||||
|
functionDesc: formData.functionDesc,
|
||||||
|
supplier: formData.supplier,
|
||||||
|
remark: formData.remark,
|
||||||
|
};
|
||||||
|
|
||||||
|
let res: any;
|
||||||
|
if (isEdit.value && editId.value) {
|
||||||
|
res = await smartDeviceApi.update(editId.value, submitData);
|
||||||
|
} else {
|
||||||
|
res = await smartDeviceApi.create(submitData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.code === 200) {
|
||||||
|
message.success(isEdit.value ? '更新成功' : '创建成功');
|
||||||
|
modalVisible.value = false;
|
||||||
|
searchForm.page = 1;
|
||||||
|
fetchList();
|
||||||
|
} else {
|
||||||
|
message.error(res.message || '操作失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('操作失败');
|
||||||
|
} finally {
|
||||||
|
modalLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
modalVisible.value = false;
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: number) {
|
||||||
|
try {
|
||||||
|
const res: any = await smartDeviceApi.delete(id);
|
||||||
|
if (res.code === 200) {
|
||||||
|
message.success('删除成功');
|
||||||
|
searchForm.page = 1;
|
||||||
|
fetchList();
|
||||||
|
} else {
|
||||||
|
message.error(res.message || '删除失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('删除失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.smart-device {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: linear-gradient(180deg, #eef3f8 0%, #e8eefb 100%);
|
||||||
|
padding: 20px 24px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #5b6b7e;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb .sep {
|
||||||
|
color: #c2c6d8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb .cur {
|
||||||
|
color: #0054cd;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-bar {
|
||||||
|
width: 5px;
|
||||||
|
height: 22px;
|
||||||
|
background: #0054cd;
|
||||||
|
border-radius: 3px;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-title {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0d1c2c;
|
||||||
|
line-height: 1.4;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0054cd;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #5b6b7e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-card {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #dbe4ee;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.07);
|
||||||
|
padding: 20px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-card {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #f8faff;
|
||||||
|
border: 1px solid #dbe4ee;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.filter-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.filter-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #5b6b7e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bottom {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid #dbe4ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-section {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 2px 4px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-title .dot {
|
||||||
|
width: 4px;
|
||||||
|
height: 16px;
|
||||||
|
background: #0054cd;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-title .t {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0d1c2c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-title .count {
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #eef4ff;
|
||||||
|
color: #0054cd;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: Consolas, Menlo, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: Consolas, Menlo, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #0d1c2c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0d1c2c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-chip {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #eef4ff;
|
||||||
|
color: #0054cd;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 4px 0 6px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid #eef1f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-no {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #0054cd;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: Consolas, Menlo, monospace;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0d1c2c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #8a97a8;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-table) {
|
||||||
|
font-size: 14px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-table-thead > tr > th) {
|
||||||
|
background: #f4f7ff;
|
||||||
|
color: #5b6b7e;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-btn-link) {
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-btn-primary) {
|
||||||
|
background: #0054cd;
|
||||||
|
border-color: #0054cd;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-btn-primary:not(:disabled):hover),
|
||||||
|
:deep(.ant-btn-primary:not(:disabled):focus) {
|
||||||
|
background: #136bfb;
|
||||||
|
border-color: #136bfb;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+31
-2
@@ -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),
|
(1, NULL, '客户管理', '/customer', NULL, 'TeamOutlined', 1, 1, 'customer:list', 1),
|
||||||
(2, NULL, '项目管理', '/project', NULL, 'ProjectOutlined', 2, 1, 'project:list', 1),
|
(2, NULL, '项目管理', '/project', NULL, 'ProjectOutlined', 2, 1, 'project:list', 1),
|
||||||
(3, NULL, '运营派单', '/dispatch', NULL, 'ScheduleOutlined', 3, 1, 'dispatch: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),
|
(5, NULL, '统计报表', '/report', NULL, 'BarChartOutlined', 5, 2, 'report:list', 1),
|
||||||
(6, NULL, '系统管理', '/system', NULL, 'SettingOutlined', 6, 1, 'system:list', 1)
|
(6, NULL, '系统管理', '/system', NULL, 'SettingOutlined', 6, 1, 'system:list', 1)
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
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;
|
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
|
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,
|
('demo_heartbeat', '示例心跳', '*/5 * * * *', '{"message":"heartbeat"}'::jsonb, 0, 60, 1,
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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); }
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ const PERMISSION_MAP: Record<string, string> = {
|
|||||||
"/api/upload": "customer:visit",
|
"/api/upload": "customer:visit",
|
||||||
"/api/customer-buildings": "customer:building",
|
"/api/customer-buildings": "customer:building",
|
||||||
"/api/customer-rooms": "customer:room",
|
"/api/customer-rooms": "customer:room",
|
||||||
|
"/api/customer-equipments": "device:list",
|
||||||
|
"/api/smart-devices": "device:model",
|
||||||
"/api/survey-orders": "project:survey",
|
"/api/survey-orders": "project:survey",
|
||||||
"/api/project-plans": "project:plan",
|
"/api/project-plans": "project:plan",
|
||||||
"/api/contracts": "project:contract",
|
"/api/contracts": "project:contract",
|
||||||
|
|||||||
@@ -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<CustomerEquipment>;
|
||||||
|
|
||||||
|
@InjectRepository(Customer)
|
||||||
|
customerModel!: Repository<Customer>;
|
||||||
|
|
||||||
|
@InjectRepository(CustomerBuilding)
|
||||||
|
buildingModel!: Repository<CustomerBuilding>;
|
||||||
|
|
||||||
|
@InjectRepository(SysUserRole)
|
||||||
|
userRoleModel!: Repository<SysUserRole>;
|
||||||
|
|
||||||
|
@InjectRepository(SysRole)
|
||||||
|
roleModel!: Repository<SysRole>;
|
||||||
|
|
||||||
|
private async isAdmin(userId: number): Promise<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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<string> {
|
||||||
|
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<any> {
|
||||||
|
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<any> {
|
||||||
|
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<any> {
|
||||||
|
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<any> {
|
||||||
|
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<any> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SmartDevice>;
|
||||||
|
|
||||||
|
async list(query: any, ctx: Context): Promise<any> {
|
||||||
|
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<any> {
|
||||||
|
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<any> {
|
||||||
|
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<any> {
|
||||||
|
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<any> {
|
||||||
|
const record = await this.smartDeviceModel.findByPk(id);
|
||||||
|
if (!record) return { code: 404, message: "智电设备型号不存在", data: null };
|
||||||
|
await record.destroy();
|
||||||
|
return { code: 200, message: "删除智电设备型号成功", data: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user