feat: 新增客户用电回路全链路功能

新增客户用电回路表及相关数据库迁移、后端服务、控制器、DTO、实体类和权限配置;新增前端API封装,同时调整路由名称以匹配新业务模块,复用现有菜单权限避免403错误。
This commit is contained in:
andyesfly committed 2026-10-12 15:20:08 +08:00
1 parent 3596462eca
commit f72644c24f
12 files changed
+1886 -322

No files matched your search

@@ -0,0 +1,121 @@
# 空间拓扑与用电架构 · 实现计划
## Context(为什么做)
现有「空间拓扑与用电架构」页面(`client/src/views/customer/topology/index.vue`)只实现了「客户 → 建筑 → 房间」三层树 + 建筑/房间增删改,**"用电架构"部分完全缺失**:数据库与后端没有任何承载"用电回路"的表或接口,页面上也没有配电拓扑可视化。
设计稿 `系统设计/UI/空间拓扑与用电架构/code.html` 定义了三栏工业级布局(左空间树 / 中配电单线拓扑流 / 右节点属性面板)。本次目标:在新表 + 新后端接口的基础上,把该页面补齐为三栏结构,让"空间"与"用电回路"打通。
## 已确认的边界(用户决策)
| 项 | 结论 |
| ------ | ---------------------------------------- |
| 目标形态 | 按设计稿做完整三栏(左树 / 中单线图 / 右属性面板) |
| 左树层级 | 客户 → 建筑 → **楼层** → 房间 → **回路**(不要"配电箱"层) |
| 楼层 | 由房间/回路按 `floor_no` 分组生成的**虚拟层**,不建表 |
| 中间图上层 | 进线柜 / 变压器:**只读展示**,不落库、不做 CRUD |
| 中间图末端 | **回路卡片**(一个回路一张卡) |
| 实时电气量 | 电压/电流/功率/温度等**一律占位**(显示 `—`),不造假数据、不接采集 |
| ICC 映射 | **不做**(设计稿里的网关层、ICC 设备码、告警阈值联动均不实现) |
| 覆盖端 | 仅 Web 管理端 `client/` |
| 数据来源 | 新建表 + 后端 CRUD 接口 |
## 数据模型(新增 1 张表)
`customer_circuit`(客户用电回路)—— 参照 `customer_room` 的建表风格(`database/db_init.sql#L364-L387`):
| 字段 | 类型 | 说明 |
| -------------- | --------------------- | ----------------------------------- |
| id | BIGSERIAL PK | <br /> |
| customer\_id | BIGINT NOT NULL | → `customer(id)` |
| building\_id | BIGINT NOT NULL | → `customer_building(id)` |
| room\_id | BIGINT NULL | → `customer_room(id)`,允许挂建筑/楼层而不挂房间 |
| floor\_no | VARCHAR(16) | 楼层(冗余,便于按楼层分组) |
| circuit\_code | VARCHAR(64) NOT NULL | 回路编号,全局唯一 |
| circuit\_name | VARCHAR(128) NOT NULL | 回路名称 |
| loop\_spec | VARCHAR(128) | 回路规格(如 `三相380V / 铜芯4×25+1×16`) |
| load\_type | VARCHAR(32) | 负荷类型(照明/动力/空调/插座…) |
| rated\_voltage | VARCHAR(32) | 额定电压(配置值) |
| rated\_current | VARCHAR(32) | 额定电流(配置值) |
| rated\_power | NUMERIC(10,2) | 额定功率 kW(配置值) |
| status | SMALLINT DEFAULT 1 | 1 正常 / 0 停用 |
| remark | TEXT | 备注 |
| sort\_order | INT DEFAULT 0 | 排序 |
| created\_at | TIMESTAMP | <br /> |
索引:`customer_id`、`building_id`、`room_id`、唯一键 `circuit_code`。
> 命名跟随现有习惯:表 `customer_circuit`、entity `CustomerCircuit`、路由 `/api/customer-circuits`、权限码 `customer:circuit`(与 `customer:building` / `customer:room` 同级)。
## 后端改动(Midway.js + sequelize-typescript)
参照样板:`customer_room` 全链路。
1. **迁移**:`database/migrations/20261011_customer_circuit.sql`(幂等 `CREATE TABLE IF NOT EXISTS`),并在 `database/db_init.sql` 同步该表定义。
2. **Entity**:`server/src/entity/customer_circuit.entity.ts`(照抄 `customer_building.entity.ts` 的 `@Table/@Column/@BelongsTo/@CreatedAt` 写法)。
3. **DTO**:`server/src/dto/customer_circuit.dto.ts`(Query/Create/Update,class-validator 风格照抄 `customer_room.dto.ts`)。
4. **Service**:`server/src/service/customer_circuit.service.ts`
* `list` / `detail` / `create` / `update` / `delete`(照抄 `customer_room.service.ts`,统一返回 `{ code, message, data }`)。
* `topology(customerId)`:**一次请求**返回整棵「建筑 → 楼层 → 房间 → 回路」结构,避免前端 N+1(现有页面是逐建筑拉房间)。返回形如:
```json
{ "buildings": [ { "id":1, "buildingName":"…", "floors":[ { "floorNo":"2", "rooms":[ { "id":9, "roomName":"…", "circuits":[…] } ], "circuits":[…] } ] } ] }
```
5. **Controller**:`server/src/controller/customer_circuit.controller.ts`(`@Controller("/api/customer-circuits")`,含 `GET /topology`)。
6. **权限注册**:`server/src/middleware/rbac.middleware.ts` 的 `PERMISSION_MAP` 新增 `"/api/customer-circuits": "customer:circuit"`。
7. **菜单/权限种子**:在 `database/db_seed.sql` 复用现有「空间拓扑与用电架构」菜单(id=32, `/customer/topology`, 权限码 `customer:topology`),并**参照** **`customer:room`** **的登记方式**新增按钮级权限 `customer:circuit`。
## 前端改动(Vue3 + Ant Design Vue)
1. **新增 API 封装** `client/src/api/customerCircuit.ts`:照抄 `customerRoom.ts` 风格(`getList/getDetail/create/update/delete/getTopology` + 类型定义 + 常量:负荷类型选项)。
2. **重写** `client/src/views/customer/topology/index.vue` 为三栏:
* **顶部**:面包屑 + 客户切换器 + 指标条 + 「新增回路」按钮。指标只展示**可由现有数据算出**的项(已接入建筑数、监测用电回路数、空间建模覆盖率 = 有回路的房间数/房间总数);无数据源的项(变电所/配电室、ICC 边缘网关)**不展示**。
* **左栏(≈280px)**:`a-tree` 渲染「客户 → 建筑 → 楼层 → 房间 → 回路」;节点徽章显示回路数;节点悬停操作:建筑/房间沿用现有增删改(`customerBuildingApi` / `customerRoomApi`),回路可新增/编辑/删除。
* **中栏**:单线图。上层为**只读示意**(10kV 进线柜 → 变压器 → 低压母线,按客户/建筑信息填充文案)、下层为**回路卡片网格**(编号/名称/所属房间/规格/额定参数,点击选中;实时电气量显示 `—`)。用 div 网格 + 内联 SVG 母线渲染(与设计稿一致),**不引入新依赖**(echarts 已在依赖中但不必要)。
* **右栏(≈320px)**:选中节点的属性面板(回路基本信息可编辑并保存;房间/建筑节点展示只读属性)。
* 样式沿用页面既有基线:背景 `#eef3f8`、主色 `#0054cd`、卡片圆角 `12px`、等宽字体用 `Consolas/Menlo`。
3. **路由/权限**:沿用现有 `customer/topology` 路由与 `customer:topology` 菜单,**不新增页面路由**。
## 不做(明确排除)
* ICC 网关节点、ICC 设备码映射、告警阈值联动、下发同步
* 实时电气量采集与展示(一律 `—`)
* 「从 ICC 同步拓扑」「CAD/BIM 导入」「导出高清拓扑图」按钮 → 先不实现(可留占位,若留则点击提示"暂未开放")
* 「空间三维映射」视图切换
* 配电箱/配电柜层
* Flutter 管理端
## 假设(如不认可请指出)
1. 中间图的上层(进线柜/变压器)**纯前端静态示意**,文案按所选客户/建筑动态填充,不落库。
2. `circuit_code` **全局唯一**(与设备台账 `equip_code` 一致);若你希望"客户内唯一",需改唯一约束。
3. 回路可以**不挂房间**(只挂建筑+楼层),以适应公共区域回路。
4. 顶部指标只做能算出来的 3 项,其余留白而非显示假数据。
## 验证方式
1. 执行迁移:`database/migrations/20261011_customer_circuit.sql`(以及 db\_seed 的权限登记),确认表与权限数据落库。
2. 后端:启动 `server/`,用带 token 的请求验证 `GET /api/customer-circuits/topology?customerId=1`、以及 create/update/delete;无 token 应 401、无权限应 403。
3. 前端:`client/` 执行 `npm run check`(vue-tsc)通过;`npm run dev` 后进入「客户管理 → 空间拓扑与用电架构」:
* 选客户 → 左树按「建筑 → 楼层 → 房间 → 回路」正确展开;
* 新增/编辑/删除回路后树与中间卡片同步刷新;
* 点击回路卡片 → 右栏属性正确回填、可保存;
* 中间图上层的进线柜/变压器为只读示意。
4. 回归:确认原有建筑/房间增删改仍可用(房间功能未被破坏)。
+132
View File
@@ -0,0 +1,132 @@
import request from '@/utils/request';
export interface CustomerCircuitInfo {
id: number;
customerId: number;
buildingId: number;
roomId?: number | null;
floorNo?: string;
circuitCode: string;
circuitName: string;
loopSpec?: string;
loadType?: string;
ratedVoltage?: string;
ratedCurrent?: string;
ratedPower?: number | string | null;
status: number;
remark?: string;
sortOrder?: number;
createdAt?: string;
}
export interface CustomerCircuitQuery {
page?: number;
pageSize?: number;
keyword?: string;
customerId?: number;
buildingId?: number;
roomId?: number;
floorNo?: string;
}
export interface CustomerCircuitListResult {
list: CustomerCircuitInfo[];
total: number;
page: number;
pageSize: number;
}
export interface CustomerCircuitCreateParams {
circuitCode: string;
customerId: number;
buildingId: number;
circuitName: string;
roomId?: number;
floorNo?: string;
loopSpec?: string;
loadType?: string;
ratedVoltage?: string;
ratedCurrent?: string;
ratedPower?: number;
status?: number;
remark?: string;
sortOrder?: number;
}
export interface CustomerCircuitUpdateParams {
circuitCode?: string;
customerId?: number;
buildingId?: number;
circuitName?: string;
roomId?: number;
floorNo?: string;
loopSpec?: string;
loadType?: string;
ratedVoltage?: string;
ratedCurrent?: string;
ratedPower?: number;
status?: number;
remark?: string;
sortOrder?: number;
}
// ==================== 空间拓扑(一次返回整棵树) ====================
export interface TopologyRoom {
id: number;
roomCode: string;
roomName: string;
buildingId: number;
floorNo?: string;
roomPurpose?: string;
deptId?: number;
circuits: CustomerCircuitInfo[];
}
export interface TopologyFloor {
floorNo: string;
rooms: TopologyRoom[];
circuits: CustomerCircuitInfo[];
}
export interface TopologyBuilding {
id: number;
customerId: number;
buildingName: string;
purpose?: string;
basicInfo?: string;
floorCount?: number;
floorDownCount?: number;
floors: TopologyFloor[];
}
export interface CustomerTopologyResult {
buildings: TopologyBuilding[];
}
// ==================== 常量 ====================
export const CIRCUIT_LOAD_TYPES = ['照明', '动力', '空调', '插座', '电梯', '消防', '其他'];
export const CIRCUIT_STATUS_OPTIONS = [
{ label: '正常', value: 1 },
{ label: '停用', value: 0 },
];
export const customerCircuitApi = {
getList: (params: CustomerCircuitQuery) =>
request.get<CustomerCircuitListResult>('/customer-circuits', { params }),
getTopology: (customerId: number) =>
request.get<CustomerTopologyResult>('/customer-circuits/topology', { params: { customerId } }),
getDetail: (id: number) =>
request.get<CustomerCircuitInfo>(`/customer-circuits/${id}`),
create: (data: CustomerCircuitCreateParams) =>
request.post('/customer-circuits', data),
update: (id: number, data: CustomerCircuitUpdateParams) =>
request.put(`/customer-circuits/${id}`, data),
delete: (id: number) =>
request.delete(`/customer-circuits/${id}`),
};
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -676,7 +676,7 @@ function clearError(key: string) {
}
function goLedger() {
router.push({ name: 'ProjectSurvey' });
router.push({ name: 'DispatchSurvey' });
}
function handleCustomerChange() {
+3 -3
View File
@@ -809,7 +809,7 @@ onMounted(() => {
fetchWorkerOptions();
const preCustomerId = Number(route.query.customerId || 0);
if (preCustomerId) {
router.replace({ name: 'ProjectSurveyCreate', query: { customerId: String(preCustomerId) } });
router.replace({ name: 'DispatchSurveyCreate', query: { customerId: String(preCustomerId) } });
}
});
@@ -848,7 +848,7 @@ function handleExport() {
}
function handleAdd() {
router.push({ name: 'ProjectSurveyCreate' });
router.push({ name: 'DispatchSurveyCreate' });
}
// ==================== 行内操作 ====================
@@ -1136,7 +1136,7 @@ async function openDetail(record: SurveyOrderInfo) {
}
function goDetail(record: SurveyOrderInfo) {
router.push({ name: 'ProjectSurveyEdit', params: { id: record.id } });
router.push({ name: 'DispatchSurveyEdit', params: { id: record.id } });
}
// ==================== 展示辅助 ====================
+41
View File
@@ -386,6 +386,47 @@ COMMENT ON COLUMN customer_room.created_at IS '创建时间';
CREATE INDEX idx_room_customer ON customer_room (customer_id);
CREATE INDEX idx_room_building ON customer_room (building_id);
-- 12.1 客户用电回路表(空间拓扑末端节点:建筑 → 楼层 → 房间 → 回路)
DROP TABLE IF EXISTS customer_circuit CASCADE;
CREATE TABLE customer_circuit (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL REFERENCES customer(id),
building_id BIGINT NOT NULL REFERENCES customer_building(id),
room_id BIGINT REFERENCES customer_room(id),
floor_no VARCHAR(16),
circuit_code VARCHAR(64) NOT NULL UNIQUE,
circuit_name VARCHAR(128) NOT NULL,
loop_spec VARCHAR(128),
load_type VARCHAR(32),
rated_voltage VARCHAR(32),
rated_current VARCHAR(32),
rated_power NUMERIC(10,2),
status SMALLINT NOT NULL DEFAULT 1,
remark TEXT,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE customer_circuit IS '客户用电回路表(空间拓扑末端节点)';
COMMENT ON COLUMN customer_circuit.id IS '自增主键';
COMMENT ON COLUMN customer_circuit.customer_id IS '客户 ID(→ customer)';
COMMENT ON COLUMN customer_circuit.building_id IS '所属建筑 ID(→ customer_building)';
COMMENT ON COLUMN customer_circuit.room_id IS '所属房间 ID(→ customer_room,可空)';
COMMENT ON COLUMN customer_circuit.floor_no IS '所在楼层';
COMMENT ON COLUMN customer_circuit.circuit_code IS '回路编号(全局唯一)';
COMMENT ON COLUMN customer_circuit.circuit_name IS '回路名称';
COMMENT ON COLUMN customer_circuit.loop_spec IS '回路规格';
COMMENT ON COLUMN customer_circuit.load_type IS '负荷类型';
COMMENT ON COLUMN customer_circuit.rated_voltage IS '额定电压(配置值)';
COMMENT ON COLUMN customer_circuit.rated_current IS '额定电流(配置值)';
COMMENT ON COLUMN customer_circuit.rated_power IS '额定功率 kW(配置值)';
COMMENT ON COLUMN customer_circuit.status IS '状态:1 正常 / 0 停用';
COMMENT ON COLUMN customer_circuit.remark IS '备注';
COMMENT ON COLUMN customer_circuit.sort_order IS '排序值';
COMMENT ON COLUMN customer_circuit.created_at IS '创建时间';
CREATE INDEX idx_circuit_customer ON customer_circuit (customer_id);
CREATE INDEX idx_circuit_building ON customer_circuit (building_id);
CREATE INDEX idx_circuit_room ON customer_circuit (room_id);
-- 13. 客户设备信息表(配电间、配电井、配电箱)
DROP TABLE IF EXISTS customer_equipment CASCADE;
CREATE TABLE customer_equipment (
@@ -0,0 +1,44 @@
-- 空间拓扑与用电架构:客户用电回路表 customer_circuit
-- 语义:左树最末端节点(建筑 → 楼层 → 房间 → 回路),其中"楼层"为按 floor_no 聚合的虚拟层,不单独建表。
-- 幂等:CREATE TABLE IF NOT EXISTS / CREATE INDEX IF NOT EXISTS / COMMENT 均可重复执行。
-- 说明:rated_* 为配置额定值;实时电气量(电压/电流/功率/温度)本系统暂无采集数据源,不落库。
CREATE TABLE IF NOT EXISTS customer_circuit (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL REFERENCES customer(id),
building_id BIGINT NOT NULL REFERENCES customer_building(id),
room_id BIGINT REFERENCES customer_room(id),
floor_no VARCHAR(16),
circuit_code VARCHAR(64) NOT NULL UNIQUE,
circuit_name VARCHAR(128) NOT NULL,
loop_spec VARCHAR(128),
load_type VARCHAR(32),
rated_voltage VARCHAR(32),
rated_current VARCHAR(32),
rated_power NUMERIC(10,2),
status SMALLINT NOT NULL DEFAULT 1,
remark TEXT,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE customer_circuit IS '客户用电回路表(空间拓扑末端节点)';
COMMENT ON COLUMN customer_circuit.id IS '自增主键';
COMMENT ON COLUMN customer_circuit.customer_id IS '客户 ID(→ customer)';
COMMENT ON COLUMN customer_circuit.building_id IS '所属建筑 ID(→ customer_building)';
COMMENT ON COLUMN customer_circuit.room_id IS '所属房间 ID(→ customer_room,可空:公共区域回路不挂房间)';
COMMENT ON COLUMN customer_circuit.floor_no IS '所在楼层(冗余,便于按楼层分组)';
COMMENT ON COLUMN customer_circuit.circuit_code IS '回路编号(全局唯一,如 CIR-0219)';
COMMENT ON COLUMN customer_circuit.circuit_name IS '回路名称';
COMMENT ON COLUMN customer_circuit.loop_spec IS '回路规格(如 三相380V / 铜芯4×25+1×16)';
COMMENT ON COLUMN customer_circuit.load_type IS '负荷类型(照明/动力/空调/插座等)';
COMMENT ON COLUMN customer_circuit.rated_voltage IS '额定电压(配置值)';
COMMENT ON COLUMN customer_circuit.rated_current IS '额定电流(配置值)';
COMMENT ON COLUMN customer_circuit.rated_power IS '额定功率 kW(配置值)';
COMMENT ON COLUMN customer_circuit.status IS '状态:1 正常 / 0 停用';
COMMENT ON COLUMN customer_circuit.remark IS '备注';
COMMENT ON COLUMN customer_circuit.sort_order IS '排序值';
COMMENT ON COLUMN customer_circuit.created_at IS '创建时间';
CREATE INDEX IF NOT EXISTS idx_circuit_customer ON customer_circuit (customer_id);
CREATE INDEX IF NOT EXISTS idx_circuit_building ON customer_circuit (building_id);
CREATE INDEX IF NOT EXISTS idx_circuit_room ON customer_circuit (room_id);
@@ -0,0 +1,17 @@
import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core";
import { Context } from "@midwayjs/koa";
import { CustomerCircuitService } from "../service/customer_circuit.service";
@Controller("/api/customer-circuits")
export class CustomerCircuitController {
@Inject() ctx!: Context;
@Inject() customerCircuitService!: CustomerCircuitService;
@Get("/") async list(@Query() query: any) { return this.customerCircuitService.list(query, this.ctx); }
// 注意:静态路径 /topology 必须声明在 /:id 之前,避免被参数路由捕获
@Get("/topology") async topology(@Query() query: any) { return this.customerCircuitService.topology(query, this.ctx); }
@Get("/:id") async detail(@Param("id") id: string) { return this.customerCircuitService.detail(Number(id), this.ctx); }
@Post("/") async create(@Body() body: any) { return this.customerCircuitService.create(body, this.ctx); }
@Put("/:id") async update(@Param("id") id: string, @Body() body: any) { return this.customerCircuitService.update(Number(id), body, this.ctx); }
@Del("/:id") async delete(@Param("id") id: string) { return this.customerCircuitService.delete(Number(id), this.ctx); }
}
+45
View File
@@ -0,0 +1,45 @@
import { IsNotEmpty, IsOptional, IsString, IsInt, IsNumber, Matches, Min, Max, MaxLength } from "class-validator";
export class CustomerCircuitQueryDTO {
@IsOptional() @IsInt() @Min(1) page?: number = 1;
@IsOptional() @IsInt() @Min(1) @Max(500) pageSize?: number = 10;
@IsOptional() @IsString() @MaxLength(64) keyword?: string;
@IsOptional() @IsInt() customerId?: number;
@IsOptional() @IsInt() buildingId?: number;
@IsOptional() @IsInt() roomId?: number;
@IsOptional() @IsString() @MaxLength(16) floorNo?: string;
}
export class CustomerCircuitCreateDTO {
@IsNotEmpty({ message: '回路编号不能为空' }) @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) circuitCode!: string;
@IsNotEmpty({ message: '所属客户不能为空' }) @IsInt() customerId!: number;
@IsNotEmpty({ message: '所属建筑不能为空' }) @IsInt() buildingId!: number;
@IsNotEmpty({ message: '回路名称不能为空' }) @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) circuitName!: string;
@IsOptional() @IsInt() roomId?: number;
@IsOptional() @IsString() @MaxLength(16) @Matches(/^[^<>]*$/) floorNo?: string;
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) loopSpec?: string;
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) loadType?: string;
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) ratedVoltage?: string;
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) ratedCurrent?: string;
@IsOptional() @IsNumber() @Min(0) ratedPower?: number;
@IsOptional() @IsInt() status?: number;
@IsOptional() @IsString() @MaxLength(2000) remark?: string;
@IsOptional() @IsInt() sortOrder?: number;
}
export class CustomerCircuitUpdateDTO {
@IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) circuitCode?: string;
@IsOptional() @IsInt() customerId?: number;
@IsOptional() @IsInt() buildingId?: number;
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) circuitName?: string;
@IsOptional() @IsInt() roomId?: number;
@IsOptional() @IsString() @MaxLength(16) @Matches(/^[^<>]*$/) floorNo?: string;
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) loopSpec?: string;
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) loadType?: string;
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) ratedVoltage?: string;
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) ratedCurrent?: string;
@IsOptional() @IsNumber() @Min(0) ratedPower?: number;
@IsOptional() @IsInt() status?: number;
@IsOptional() @IsString() @MaxLength(2000) remark?: string;
@IsOptional() @IsInt() sortOrder?: number;
}
@@ -0,0 +1,78 @@
import { Column, Model, Table, DataType, CreatedAt, ForeignKey, BelongsTo } from "sequelize-typescript";
import Customer from "./customer.entity";
import CustomerBuilding from "./customer_building.entity";
import CustomerRoom from "./customer_room.entity";
@Table({
tableName: "customer_circuit",
comment: "客户用电回路表",
timestamps: true,
createdAt: "created_at",
updatedAt: false,
indexes: [
{ name: "idx_circuit_customer", fields: ["customer_id"] },
{ name: "idx_circuit_building", fields: ["building_id"] },
{ name: "idx_circuit_room", fields: ["room_id"] }
]
})
export default class CustomerCircuit extends Model {
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
id!: number;
@ForeignKey(() => Customer)
@Column({ type: DataType.BIGINT, allowNull: false, field: "customer_id", comment: "客户ID" })
customerId!: number;
@ForeignKey(() => CustomerBuilding)
@Column({ type: DataType.BIGINT, allowNull: false, field: "building_id", comment: "所属建筑ID" })
buildingId!: number;
@ForeignKey(() => CustomerRoom)
@Column({ type: DataType.BIGINT, field: "room_id", comment: "所属房间ID(可空)" })
roomId!: number;
@Column({ type: DataType.STRING(16), field: "floor_no", comment: "所在楼层" })
floorNo!: string;
@Column({ type: DataType.STRING(64), allowNull: false, unique: true, field: "circuit_code", comment: "回路编号(全局唯一)" })
circuitCode!: string;
@Column({ type: DataType.STRING(128), allowNull: false, field: "circuit_name", comment: "回路名称" })
circuitName!: string;
@Column({ type: DataType.STRING(128), field: "loop_spec", comment: "回路规格" })
loopSpec!: string;
@Column({ type: DataType.STRING(32), field: "load_type", comment: "负荷类型" })
loadType!: string;
@Column({ type: DataType.STRING(32), field: "rated_voltage", comment: "额定电压" })
ratedVoltage!: string;
@Column({ type: DataType.STRING(32), field: "rated_current", comment: "额定电流" })
ratedCurrent!: string;
@Column({ type: DataType.DECIMAL(10, 2), field: "rated_power", comment: "额定功率kW" })
ratedPower!: number;
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 1, comment: "状态:1正常/0停用" })
status!: number;
@Column({ type: DataType.TEXT, comment: "备注" })
remark!: string;
@Column({ type: DataType.INTEGER, allowNull: false, defaultValue: 0, field: "sort_order", comment: "排序值" })
sortOrder!: number;
@BelongsTo(() => Customer)
customer!: Customer;
@BelongsTo(() => CustomerBuilding)
building!: CustomerBuilding;
@BelongsTo(() => CustomerRoom)
room!: CustomerRoom;
@CreatedAt
created_at!: Date;
}
+3
View File
@@ -23,6 +23,9 @@ const PERMISSION_MAP: Record<string, string> = {
"/api/upload": "customer:visit",
"/api/customer-buildings": "customer:building",
"/api/customer-rooms": "customer:room",
// 用电回路接口复用页面级权限码 customer:topology(该菜单已在 sys_menu 登记并分配给角色),
// 因此不再新增按钮级权限码,避免非 ADMIN 角色因权限码未登记而被 403。
"/api/customer-circuits": "customer:topology",
"/api/customer-equipments": "device:list",
"/api/smart-devices": "device:model",
"/api/survey-orders": "project:survey",
@@ -0,0 +1,178 @@
import { Provide } from "@midwayjs/core";
import { InjectRepository } from "@midwayjs/sequelize";
import { Repository } from "sequelize-typescript";
import { Op } from "sequelize";
import { Context } from "@midwayjs/koa";
import CustomerCircuit from "../entity/customer_circuit.entity";
import Customer from "../entity/customer.entity";
import CustomerBuilding from "../entity/customer_building.entity";
import CustomerRoom from "../entity/customer_room.entity";
import SysUserRole from "../entity/sysUserRole.entity";
import SysRole from "../entity/sysRole.entity";
// 楼层排序:数值楼层升序(-1 < 1 < 2 …),"未标注楼层"(空串)排最后,其余按字符串排序
function compareFloor(a: string, b: string): number {
if (a === b) return 0;
if (a === "") return 1;
if (b === "") return -1;
const na = Number(a);
const nb = Number(b);
const aIsNum = !Number.isNaN(na);
const bIsNum = !Number.isNaN(nb);
if (aIsNum && bIsNum) return na - nb;
if (aIsNum) return -1;
if (bIsNum) return 1;
return a.localeCompare(b);
}
@Provide()
export class CustomerCircuitService {
@InjectRepository(CustomerCircuit)
circuitModel!: Repository<CustomerCircuit>;
@InjectRepository(Customer)
customerModel!: Repository<Customer>;
@InjectRepository(CustomerBuilding)
buildingModel!: Repository<CustomerBuilding>;
@InjectRepository(CustomerRoom)
roomModel!: Repository<CustomerRoom>;
@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;
}
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] = [
{ circuitName: { [Op.like]: `%${query.keyword}%` } },
{ circuitCode: { [Op.like]: `%${query.keyword}%` } }
];
}
if (query.customerId !== undefined) where.customerId = query.customerId;
if (query.buildingId !== undefined) where.buildingId = query.buildingId;
if (query.roomId !== undefined) where.roomId = query.roomId;
if (query.floorNo !== undefined) where.floorNo = query.floorNo;
const emptyResult = { code: 200, message: "获取用电回路列表成功", data: { list: [], total: 0, page: Number(page), pageSize: Number(pageSize) } };
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 emptyResult;
if (where.customerId !== undefined) {
if (!ids.includes(where.customerId)) return emptyResult;
} else {
where.customerId = { [Op.in]: ids };
}
}
const { count, rows } = await this.circuitModel.findAndCountAll({
where,
limit: pageSize,
offset: (page - 1) * pageSize,
order: [["sort_order", "ASC"], ["created_at", "DESC"]]
});
const list = rows.map((r) => r.toJSON());
return { code: 200, message: "获取用电回路列表成功", data: { list, total: count, page: Number(page), pageSize: Number(pageSize) } };
}
// 一次返回整棵「建筑 → 楼层 → 房间 → 回路」结构,避免前端逐建筑请求(N+1)
async topology(query: any, ctx: Context): Promise<any> {
const customerId = Number(query.customerId);
if (!customerId) return { code: 400, message: "缺少 customerId 参数", data: null };
if (!(await this.isCustomerOwner(ctx.state.user.userId, customerId))) {
return { code: 403, message: "无权限访问该客户的空间拓扑", data: null };
}
const [buildings, rooms, circuits] = await Promise.all([
this.buildingModel.findAll({ where: { customerId }, order: [["id", "ASC"]] }),
this.roomModel.findAll({ where: { customerId }, order: [["id", "ASC"]] }),
this.circuitModel.findAll({ where: { customerId }, order: [["sort_order", "ASC"], ["id", "ASC"]] })
]);
const circuitJson = circuits.map((c) => c.toJSON()) as any[];
const data = buildings.map((b) => {
const bRooms = rooms.filter((r) => r.buildingId === b.id);
const bCircuits = circuitJson.filter((c) => c.buildingId === b.id);
const floorSet = Array.from(
new Set([...bRooms.map((r) => r.floorNo || ""), ...bCircuits.map((c) => c.floorNo || "")])
).sort(compareFloor);
return {
...b.toJSON(),
floors: floorSet.map((floorNo) => ({
floorNo,
rooms: bRooms
.filter((r) => (r.floorNo || "") === floorNo)
.map((r) => ({
...r.toJSON(),
circuits: bCircuits.filter((c) => c.roomId === r.id)
})),
// 公共区域回路:未挂房间,直接归属楼层
circuits: bCircuits.filter((c) => !c.roomId && (c.floorNo || "") === floorNo)
}))
};
});
return { code: 200, message: "获取空间用电拓扑成功", data: { buildings: data } };
}
async detail(id: number, ctx: Context): Promise<any> {
const record = await this.circuitModel.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 };
const exists = await this.circuitModel.findOne({ where: { circuitCode: data.circuitCode } });
if (exists) return { code: 400, message: "回路编号已存在,请更换", data: null };
const created = await this.circuitModel.create(data);
return { code: 200, message: "创建用电回路成功", data: created.toJSON() };
}
async update(id: number, data: any, ctx: Context): Promise<any> {
const record = await this.circuitModel.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;
}
if (data.circuitCode && data.circuitCode !== record.circuitCode) {
const exists = await this.circuitModel.findOne({ where: { circuitCode: data.circuitCode, id: { [Op.ne]: id } } });
if (exists) return { code: 400, message: "回路编号已存在,请更换", data: null };
}
await record.update(data);
return { code: 200, message: "更新用电回路成功", data: record.toJSON() };
}
async delete(id: number, ctx: Context): Promise<any> {
const record = await this.circuitModel.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 };
}
}