feat: 新增客户用电回路全链路功能
新增客户用电回路表及相关数据库迁移、后端服务、控制器、DTO、实体类和权限配置;新增前端API封装,同时调整路由名称以匹配新业务模块,复用现有菜单权限避免403错误。
This commit is contained in:
1 parent
3596462eca
commit
f72644c24f
12 files changed
+1975
-411
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. 回归:确认原有建筑/房间增删改仍可用(房间功能未被破坏)。
|
||||
|
||||
@@ -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
@@ -676,7 +676,7 @@ function clearError(key: string) {
|
||||
}
|
||||
|
||||
function goLedger() {
|
||||
router.push({ name: 'ProjectSurvey' });
|
||||
router.push({ name: 'DispatchSurvey' });
|
||||
}
|
||||
|
||||
function handleCustomerChange() {
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
|
||||
// ==================== 展示辅助 ====================
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user