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:
wk committed 2026-10-07 08:58:34 +08:00
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] 完成后统一接入
+146
View File
@@ -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}`),
};
+23 -5
View File
@@ -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",
+921
View File
@@ -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 &amp; 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 &amp; 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
View File
@@ -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); }
}
+34
View File
@@ -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;
}
+26
View File
@@ -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;
}
+32
View File
@@ -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;
}
+2
View File
@@ -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 };
}
}