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,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/customer-buildings": "customer:building",
"/api/customer-rooms": "customer:room",
"/api/customer-equipments": "device:list",
"/api/smart-devices": "device:model",
"/api/survey-orders": "project:survey",
"/api/project-plans": "project:plan",
"/api/contracts": "project:contract",
@@ -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 };
}
}