commit
e530b822a7
@ -0,0 +1,65 @@
|
||||
# Keep the repository focused on source and project metadata.
|
||||
/*
|
||||
!/.gitignore
|
||||
!/backend/
|
||||
!/front/
|
||||
!/docker-compose.yml
|
||||
!/deliverables/
|
||||
|
||||
# Local generators and temporary browser profiles are not delivery artifacts.
|
||||
/deliverables/_work/
|
||||
|
||||
# Current delivery requirement is Word only; screenshots remain embedded in DOCX.
|
||||
/deliverables/screenshots/
|
||||
/deliverables/*.html
|
||||
/deliverables/*.pdf
|
||||
/deliverables/*.zip
|
||||
/deliverables/*.json
|
||||
/deliverables/*.txt
|
||||
/deliverables/*.csv
|
||||
|
||||
# Dependencies
|
||||
**/node_modules/
|
||||
|
||||
# Build output
|
||||
**/dist/
|
||||
**/build/
|
||||
**/.vite/
|
||||
**/.turbo/
|
||||
**/coverage/
|
||||
|
||||
# Local environment and secrets
|
||||
**/.env
|
||||
**/.env.local
|
||||
**/.env.*.local
|
||||
!**/.env.example
|
||||
|
||||
# Local database/cache volumes
|
||||
/mysql-data/
|
||||
/redis-data/
|
||||
**/*.db
|
||||
**/*.sqlite
|
||||
**/*.sqlite3
|
||||
|
||||
# Logs and runtime files
|
||||
**/*.log
|
||||
**/logs/
|
||||
**/tmp/
|
||||
**/temp/
|
||||
**/uploads/
|
||||
|
||||
# Editor and OS files
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Package manager caches
|
||||
**/.npm/
|
||||
**/.pnpm-store/
|
||||
**/.yarn/cache/
|
||||
**/.yarn/unplugged/
|
||||
**/.yarn/build-state.yml
|
||||
**/.yarn/install-state.gz
|
||||
@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.0.0",
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "node dist/src/main.js",
|
||||
"start:dev": "nest start --watch",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:seed": "ts-node prisma/seed.ts",
|
||||
"test": "jest"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "Street light alarm SaaS backend",
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.1.19",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
"@nestjs/core": "^11.1.19",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.1.19",
|
||||
"@nestjs/swagger": "^11.4.2",
|
||||
"@prisma/client": "^6.19.3",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"ioredis": "^5.10.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"rxjs": "^7.8.2",
|
||||
"swagger-ui-express": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.21",
|
||||
"@nestjs/schematics": "^11.1.0",
|
||||
"@nestjs/testing": "^11.1.19",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^25.7.0",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"jest": "^30.4.2",
|
||||
"prisma": "^6.19.3",
|
||||
"supertest": "^7.2.2",
|
||||
"ts-jest": "^29.4.9",
|
||||
"ts-node": "^10.9.2",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "ts-node prisma/seed.ts"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,246 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE `Tenant` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`name` VARCHAR(191) NOT NULL,
|
||||
`code` VARCHAR(191) NOT NULL,
|
||||
`enabled` BOOLEAN NOT NULL DEFAULT true,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `Tenant_code_key`(`code`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `User` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`username` VARCHAR(191) NOT NULL,
|
||||
`passwordHash` VARCHAR(191) NOT NULL,
|
||||
`name` VARCHAR(191) NOT NULL,
|
||||
`phone` VARCHAR(191) NULL,
|
||||
`email` VARCHAR(191) NULL,
|
||||
`status` ENUM('ACTIVE', 'DISABLED') NOT NULL DEFAULT 'ACTIVE',
|
||||
`tenantId` VARCHAR(191) NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `User_username_key`(`username`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `Role` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`name` VARCHAR(191) NOT NULL,
|
||||
`code` VARCHAR(191) NOT NULL,
|
||||
`description` VARCHAR(191) NULL,
|
||||
`tenantId` VARCHAR(191) NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `Role_code_key`(`code`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `Permission` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`name` VARCHAR(191) NOT NULL,
|
||||
`code` VARCHAR(191) NOT NULL,
|
||||
`type` ENUM('MENU', 'BUTTON', 'API') NOT NULL,
|
||||
`parentId` VARCHAR(191) NULL,
|
||||
`path` VARCHAR(191) NULL,
|
||||
`sort` INTEGER NOT NULL DEFAULT 0,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `Permission_code_key`(`code`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `UserRole` (
|
||||
`userId` VARCHAR(191) NOT NULL,
|
||||
`roleId` VARCHAR(191) NOT NULL,
|
||||
|
||||
PRIMARY KEY (`userId`, `roleId`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `RolePermission` (
|
||||
`roleId` VARCHAR(191) NOT NULL,
|
||||
`permissionId` VARCHAR(191) NOT NULL,
|
||||
|
||||
PRIMARY KEY (`roleId`, `permissionId`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `Region` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`code` VARCHAR(191) NOT NULL,
|
||||
`name` VARCHAR(191) NOT NULL,
|
||||
`level` INTEGER NOT NULL,
|
||||
`parentId` VARCHAR(191) NULL,
|
||||
`longitude` DECIMAL(10, 6) NULL,
|
||||
`latitude` DECIMAL(10, 6) NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `Region_code_key`(`code`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `UserRegionScope` (
|
||||
`userId` VARCHAR(191) NOT NULL,
|
||||
`regionId` VARCHAR(191) NOT NULL,
|
||||
|
||||
PRIMARY KEY (`userId`, `regionId`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `RoleRegionScope` (
|
||||
`roleId` VARCHAR(191) NOT NULL,
|
||||
`regionId` VARCHAR(191) NOT NULL,
|
||||
|
||||
PRIMARY KEY (`roleId`, `regionId`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `StreetLamp` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`code` VARCHAR(191) NOT NULL,
|
||||
`name` VARCHAR(191) NOT NULL,
|
||||
`tenantId` VARCHAR(191) NULL,
|
||||
`regionId` VARCHAR(191) NOT NULL,
|
||||
`address` VARCHAR(191) NULL,
|
||||
`longitude` DECIMAL(10, 6) NOT NULL,
|
||||
`latitude` DECIMAL(10, 6) NOT NULL,
|
||||
`deviceType` VARCHAR(191) NULL,
|
||||
`status` ENUM('NORMAL', 'FAULT', 'MAINTENANCE', 'OFFLINE') NOT NULL DEFAULT 'NORMAL',
|
||||
`onlineStatus` ENUM('ONLINE', 'OFFLINE') NOT NULL DEFAULT 'ONLINE',
|
||||
`lastReportedAt` DATETIME(3) NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `StreetLamp_code_key`(`code`),
|
||||
INDEX `StreetLamp_regionId_idx`(`regionId`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `Alarm` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`sourceAlarmId` VARCHAR(191) NULL,
|
||||
`tenantId` VARCHAR(191) NULL,
|
||||
`lampId` VARCHAR(191) NOT NULL,
|
||||
`regionId` VARCHAR(191) NOT NULL,
|
||||
`type` VARCHAR(191) NOT NULL,
|
||||
`title` VARCHAR(191) NOT NULL,
|
||||
`description` VARCHAR(191) NULL,
|
||||
`level` ENUM('LOW', 'MEDIUM', 'HIGH', 'CRITICAL') NOT NULL DEFAULT 'MEDIUM',
|
||||
`status` ENUM('OPEN', 'PROCESSING', 'RESOLVED', 'IGNORED') NOT NULL DEFAULT 'OPEN',
|
||||
`occurredAt` DATETIME(3) NOT NULL,
|
||||
`recoveredAt` DATETIME(3) NULL,
|
||||
`handledAt` DATETIME(3) NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `Alarm_sourceAlarmId_key`(`sourceAlarmId`),
|
||||
INDEX `Alarm_regionId_status_idx`(`regionId`, `status`),
|
||||
INDEX `Alarm_lampId_idx`(`lampId`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `AlarmEvent` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`alarmId` VARCHAR(191) NOT NULL,
|
||||
`eventType` VARCHAR(191) NOT NULL,
|
||||
`payload` JSON NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `AlarmHandleRecord` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`alarmId` VARCHAR(191) NOT NULL,
|
||||
`userId` VARCHAR(191) NULL,
|
||||
`action` VARCHAR(191) NOT NULL,
|
||||
`remark` VARCHAR(191) NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `OperationLog` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`userId` VARCHAR(191) NULL,
|
||||
`action` VARCHAR(191) NOT NULL,
|
||||
`resource` VARCHAR(191) NOT NULL,
|
||||
`detail` JSON NULL,
|
||||
`ip` VARCHAR(191) NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `User` ADD CONSTRAINT `User_tenantId_fkey` FOREIGN KEY (`tenantId`) REFERENCES `Tenant`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `Role` ADD CONSTRAINT `Role_tenantId_fkey` FOREIGN KEY (`tenantId`) REFERENCES `Tenant`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `UserRole` ADD CONSTRAINT `UserRole_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `UserRole` ADD CONSTRAINT `UserRole_roleId_fkey` FOREIGN KEY (`roleId`) REFERENCES `Role`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `RolePermission` ADD CONSTRAINT `RolePermission_roleId_fkey` FOREIGN KEY (`roleId`) REFERENCES `Role`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `RolePermission` ADD CONSTRAINT `RolePermission_permissionId_fkey` FOREIGN KEY (`permissionId`) REFERENCES `Permission`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `Region` ADD CONSTRAINT `Region_parentId_fkey` FOREIGN KEY (`parentId`) REFERENCES `Region`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `UserRegionScope` ADD CONSTRAINT `UserRegionScope_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `UserRegionScope` ADD CONSTRAINT `UserRegionScope_regionId_fkey` FOREIGN KEY (`regionId`) REFERENCES `Region`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `RoleRegionScope` ADD CONSTRAINT `RoleRegionScope_roleId_fkey` FOREIGN KEY (`roleId`) REFERENCES `Role`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `RoleRegionScope` ADD CONSTRAINT `RoleRegionScope_regionId_fkey` FOREIGN KEY (`regionId`) REFERENCES `Region`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `StreetLamp` ADD CONSTRAINT `StreetLamp_tenantId_fkey` FOREIGN KEY (`tenantId`) REFERENCES `Tenant`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `StreetLamp` ADD CONSTRAINT `StreetLamp_regionId_fkey` FOREIGN KEY (`regionId`) REFERENCES `Region`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `Alarm` ADD CONSTRAINT `Alarm_tenantId_fkey` FOREIGN KEY (`tenantId`) REFERENCES `Tenant`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `Alarm` ADD CONSTRAINT `Alarm_lampId_fkey` FOREIGN KEY (`lampId`) REFERENCES `StreetLamp`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `Alarm` ADD CONSTRAINT `Alarm_regionId_fkey` FOREIGN KEY (`regionId`) REFERENCES `Region`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `AlarmEvent` ADD CONSTRAINT `AlarmEvent_alarmId_fkey` FOREIGN KEY (`alarmId`) REFERENCES `Alarm`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `AlarmHandleRecord` ADD CONSTRAINT `AlarmHandleRecord_alarmId_fkey` FOREIGN KEY (`alarmId`) REFERENCES `Alarm`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `AlarmHandleRecord` ADD CONSTRAINT `AlarmHandleRecord_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `StreetLamp` ADD COLUMN `isPowerOn` BOOLEAN NULL;
|
||||
@ -0,0 +1,6 @@
|
||||
-- Allow each tenant to reuse the same login username and role code.
|
||||
DROP INDEX `User_username_key` ON `User`;
|
||||
DROP INDEX `Role_code_key` ON `Role`;
|
||||
|
||||
CREATE UNIQUE INDEX `User_tenantId_username_key` ON `User`(`tenantId`, `username`);
|
||||
CREATE UNIQUE INDEX `Role_tenantId_code_key` ON `Role`(`tenantId`, `code`);
|
||||
@ -0,0 +1,8 @@
|
||||
-- Rename tenant-scoped administrator roles so tenant pages do not expose
|
||||
-- platform-super-admin terminology.
|
||||
UPDATE `Role`
|
||||
SET `code` = 'tenant_admin',
|
||||
`name` = '租户管理员',
|
||||
`description` = '租户初始化管理员角色'
|
||||
WHERE `tenantId` IS NOT NULL
|
||||
AND `code` = 'super_admin';
|
||||
@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "mysql"
|
||||
@ -0,0 +1,239 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "mysql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
enum UserStatus {
|
||||
ACTIVE
|
||||
DISABLED
|
||||
}
|
||||
|
||||
enum PermissionType {
|
||||
MENU
|
||||
BUTTON
|
||||
API
|
||||
}
|
||||
|
||||
enum LampStatus {
|
||||
NORMAL
|
||||
FAULT
|
||||
MAINTENANCE
|
||||
OFFLINE
|
||||
}
|
||||
|
||||
enum OnlineStatus {
|
||||
ONLINE
|
||||
OFFLINE
|
||||
}
|
||||
|
||||
enum AlarmLevel {
|
||||
LOW
|
||||
MEDIUM
|
||||
HIGH
|
||||
CRITICAL
|
||||
}
|
||||
|
||||
enum AlarmStatus {
|
||||
OPEN
|
||||
PROCESSING
|
||||
RESOLVED
|
||||
IGNORED
|
||||
}
|
||||
|
||||
model Tenant {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
code String @unique
|
||||
enabled Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
users User[]
|
||||
roles Role[]
|
||||
lamps StreetLamp[]
|
||||
alarms Alarm[]
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
username String
|
||||
passwordHash String
|
||||
name String
|
||||
phone String?
|
||||
email String?
|
||||
status UserStatus @default(ACTIVE)
|
||||
tenantId String?
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id])
|
||||
roles UserRole[]
|
||||
regionScopes UserRegionScope[]
|
||||
alarmHandles AlarmHandleRecord[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([tenantId, username])
|
||||
}
|
||||
|
||||
model Role {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
code String
|
||||
description String?
|
||||
tenantId String?
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id])
|
||||
users UserRole[]
|
||||
permissions RolePermission[]
|
||||
regionScopes RoleRegionScope[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([tenantId, code])
|
||||
}
|
||||
|
||||
model Permission {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
code String @unique
|
||||
type PermissionType
|
||||
parentId String?
|
||||
path String?
|
||||
sort Int @default(0)
|
||||
roles RolePermission[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model UserRole {
|
||||
userId String
|
||||
roleId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([userId, roleId])
|
||||
}
|
||||
|
||||
model RolePermission {
|
||||
roleId String
|
||||
permissionId String
|
||||
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||
permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([roleId, permissionId])
|
||||
}
|
||||
|
||||
model Region {
|
||||
id String @id @default(cuid())
|
||||
code String @unique
|
||||
name String
|
||||
level Int
|
||||
parentId String?
|
||||
parent Region? @relation("RegionTree", fields: [parentId], references: [id])
|
||||
children Region[] @relation("RegionTree")
|
||||
longitude Decimal? @db.Decimal(10, 6)
|
||||
latitude Decimal? @db.Decimal(10, 6)
|
||||
userScopes UserRegionScope[]
|
||||
roleScopes RoleRegionScope[]
|
||||
lamps StreetLamp[]
|
||||
alarms Alarm[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model UserRegionScope {
|
||||
userId String
|
||||
regionId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
region Region @relation(fields: [regionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([userId, regionId])
|
||||
}
|
||||
|
||||
model RoleRegionScope {
|
||||
roleId String
|
||||
regionId String
|
||||
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||
region Region @relation(fields: [regionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([roleId, regionId])
|
||||
}
|
||||
|
||||
model StreetLamp {
|
||||
id String @id @default(cuid())
|
||||
code String @unique
|
||||
name String
|
||||
tenantId String?
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id])
|
||||
regionId String
|
||||
region Region @relation(fields: [regionId], references: [id])
|
||||
address String?
|
||||
longitude Decimal @db.Decimal(10, 6)
|
||||
latitude Decimal @db.Decimal(10, 6)
|
||||
deviceType String?
|
||||
status LampStatus @default(NORMAL)
|
||||
onlineStatus OnlineStatus @default(ONLINE)
|
||||
isPowerOn Boolean?
|
||||
lastReportedAt DateTime?
|
||||
alarms Alarm[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([regionId])
|
||||
}
|
||||
|
||||
model Alarm {
|
||||
id String @id @default(cuid())
|
||||
sourceAlarmId String? @unique
|
||||
tenantId String?
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id])
|
||||
lampId String
|
||||
lamp StreetLamp @relation(fields: [lampId], references: [id])
|
||||
regionId String
|
||||
region Region @relation(fields: [regionId], references: [id])
|
||||
type String
|
||||
title String
|
||||
description String?
|
||||
level AlarmLevel @default(MEDIUM)
|
||||
status AlarmStatus @default(OPEN)
|
||||
occurredAt DateTime
|
||||
recoveredAt DateTime?
|
||||
handledAt DateTime?
|
||||
handleRecords AlarmHandleRecord[]
|
||||
events AlarmEvent[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([regionId, status])
|
||||
@@index([lampId])
|
||||
}
|
||||
|
||||
model AlarmEvent {
|
||||
id String @id @default(cuid())
|
||||
alarmId String
|
||||
alarm Alarm @relation(fields: [alarmId], references: [id], onDelete: Cascade)
|
||||
eventType String
|
||||
payload Json?
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model AlarmHandleRecord {
|
||||
id String @id @default(cuid())
|
||||
alarmId String
|
||||
alarm Alarm @relation(fields: [alarmId], references: [id], onDelete: Cascade)
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
action String
|
||||
remark String?
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model OperationLog {
|
||||
id String @id @default(cuid())
|
||||
userId String?
|
||||
action String
|
||||
resource String
|
||||
detail Json?
|
||||
ip String?
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
@ -0,0 +1,246 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.upsert({
|
||||
where: { code: 'default' },
|
||||
update: {},
|
||||
create: { name: '默认租户', code: 'default' },
|
||||
});
|
||||
|
||||
const china = await prisma.region.upsert({
|
||||
where: { code: '100000' },
|
||||
update: {},
|
||||
create: { code: '100000', name: '全国', level: 0, longitude: 104.195397, latitude: 35.86166 },
|
||||
});
|
||||
|
||||
const province = await prisma.region.upsert({
|
||||
where: { code: '110000' },
|
||||
update: {},
|
||||
create: { code: '110000', name: '北京市', level: 1, parentId: china.id, longitude: 116.407526, latitude: 39.90403 },
|
||||
});
|
||||
|
||||
const city = await prisma.region.upsert({
|
||||
where: { code: '110100' },
|
||||
update: {},
|
||||
create: { code: '110100', name: '北京市', level: 2, parentId: province.id, longitude: 116.407526, latitude: 39.90403 },
|
||||
});
|
||||
|
||||
const district = await prisma.region.upsert({
|
||||
where: { code: '110101' },
|
||||
update: {},
|
||||
create: { code: '110101', name: '东城区', level: 3, parentId: city.id, longitude: 116.416357, latitude: 39.928353 },
|
||||
});
|
||||
|
||||
const shanghai = await prisma.region.upsert({
|
||||
where: { code: '310000' },
|
||||
update: {},
|
||||
create: { code: '310000', name: '上海市', level: 1, parentId: china.id, longitude: 121.473701, latitude: 31.230416 },
|
||||
});
|
||||
const shanghaiCity = await prisma.region.upsert({
|
||||
where: { code: '310100' },
|
||||
update: {},
|
||||
create: { code: '310100', name: '上海市', level: 2, parentId: shanghai.id, longitude: 121.473701, latitude: 31.230416 },
|
||||
});
|
||||
const pudong = await prisma.region.upsert({
|
||||
where: { code: '310115' },
|
||||
update: {},
|
||||
create: { code: '310115', name: '浦东新区', level: 3, parentId: shanghaiCity.id, longitude: 121.544346, latitude: 31.221461 },
|
||||
});
|
||||
const guangdong = await prisma.region.upsert({
|
||||
where: { code: '440000' },
|
||||
update: {},
|
||||
create: { code: '440000', name: '广东省', level: 1, parentId: china.id, longitude: 113.266887, latitude: 23.133306 },
|
||||
});
|
||||
const guangzhou = await prisma.region.upsert({
|
||||
where: { code: '440100' },
|
||||
update: {},
|
||||
create: { code: '440100', name: '广州市', level: 2, parentId: guangdong.id, longitude: 113.264385, latitude: 23.129112 },
|
||||
});
|
||||
const tianhe = await prisma.region.upsert({
|
||||
where: { code: '440106' },
|
||||
update: {},
|
||||
create: { code: '440106', name: '天河区', level: 3, parentId: guangzhou.id, longitude: 113.361597, latitude: 23.124817 },
|
||||
});
|
||||
const zhejiang = await prisma.region.upsert({
|
||||
where: { code: '330000' },
|
||||
update: {},
|
||||
create: { code: '330000', name: '浙江省', level: 1, parentId: china.id, longitude: 120.152575, latitude: 30.266619 },
|
||||
});
|
||||
const hangzhou = await prisma.region.upsert({
|
||||
where: { code: '330100' },
|
||||
update: {},
|
||||
create: { code: '330100', name: '杭州市', level: 2, parentId: zhejiang.id, longitude: 120.15507, latitude: 30.274085 },
|
||||
});
|
||||
const xihu = await prisma.region.upsert({
|
||||
where: { code: '330106' },
|
||||
update: {},
|
||||
create: { code: '330106', name: '西湖区', level: 3, parentId: hangzhou.id, longitude: 120.130396, latitude: 30.259242 },
|
||||
});
|
||||
|
||||
const permissions = [
|
||||
['tenants:manage', '租户管理', 'MENU', '/system/tenants'],
|
||||
['dashboard:view', '数据看板', 'MENU', '/dashboard'],
|
||||
['map:view', '地图监控', 'MENU', '/map'],
|
||||
['lamps:manage', '路灯管理', 'MENU', '/lamps'],
|
||||
['alarms:manage', '报警管理', 'MENU', '/alarms'],
|
||||
['system:manage', '系统管理', 'MENU', '/system/users'],
|
||||
] as const;
|
||||
|
||||
for (const [code, name, type, path] of permissions) {
|
||||
await prisma.permission.upsert({
|
||||
where: { code },
|
||||
update: {},
|
||||
create: { code, name, type, path },
|
||||
});
|
||||
}
|
||||
|
||||
let platformRole = await prisma.role.findFirst({ where: { tenantId: null, code: 'platform_super_admin' } });
|
||||
if (!platformRole) {
|
||||
platformRole = await prisma.role.create({
|
||||
data: {
|
||||
name: '平台超级管理员',
|
||||
code: 'platform_super_admin',
|
||||
description: '仅用于平台租户管理,不在页面中配置',
|
||||
},
|
||||
});
|
||||
}
|
||||
const tenantManagePermission = await prisma.permission.findUnique({ where: { code: 'tenants:manage' } });
|
||||
if (tenantManagePermission) {
|
||||
await prisma.rolePermission.createMany({
|
||||
data: [{ roleId: platformRole.id, permissionId: tenantManagePermission.id }],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
let platformAdmin = await prisma.user.findFirst({ where: { tenantId: null, username: 'admin' } });
|
||||
if (!platformAdmin) {
|
||||
platformAdmin = await prisma.user.create({
|
||||
data: {
|
||||
username: 'admin',
|
||||
passwordHash: await bcrypt.hash('admin123456', 10),
|
||||
name: '平台超级管理员',
|
||||
roles: { create: [{ roleId: platformRole.id }] },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let role = await prisma.role.findFirst({ where: { tenantId: tenant.id, code: { in: ['tenant_admin', 'super_admin'] } } });
|
||||
if (role) {
|
||||
role = await prisma.role.update({
|
||||
where: { id: role.id },
|
||||
data: { name: '租户管理员', code: 'tenant_admin', description: '租户初始化管理员角色' },
|
||||
});
|
||||
} else {
|
||||
role = await prisma.role.create({
|
||||
data: { name: '租户管理员', code: 'tenant_admin', description: '租户初始化管理员角色', tenantId: tenant.id },
|
||||
});
|
||||
}
|
||||
|
||||
const allPermissions = await prisma.permission.findMany({ where: { code: { not: 'tenants:manage' } } });
|
||||
await prisma.rolePermission.createMany({
|
||||
data: allPermissions.map((permission) => ({ roleId: role.id, permissionId: permission.id })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
await prisma.roleRegionScope.createMany({
|
||||
data: [{ roleId: role.id, regionId: china.id }],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
|
||||
let admin = await prisma.user.findFirst({ where: { tenantId: tenant.id, username: 'admin' } });
|
||||
if (admin) {
|
||||
admin = await prisma.user.update({
|
||||
where: { id: admin.id },
|
||||
data: { name: '系统管理员' },
|
||||
});
|
||||
} else {
|
||||
admin = await prisma.user.create({
|
||||
data: {
|
||||
username: 'admin',
|
||||
passwordHash: await bcrypt.hash('admin123456', 10),
|
||||
name: '系统管理员',
|
||||
tenantId: tenant.id,
|
||||
roles: { create: [{ roleId: role.id }] },
|
||||
regionScopes: { create: [{ regionId: china.id }] },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const sampleLamps = [
|
||||
['BJ-DC-0001', '东城区智慧路灯 A01', district.id, '北京市东城区东长安街', 116.416357, 39.928353, '单灯', 'NORMAL', 'ONLINE'],
|
||||
['BJ-DC-0002', '东城区智慧路灯 A02', district.id, '北京市东城区王府井大街', 116.411568, 39.908823, '单灯', 'FAULT', 'ONLINE'],
|
||||
['BJ-DC-0003', '东城区智慧路灯 A03', district.id, '北京市东城区朝阳门内大街', 116.433235, 39.924463, '单灯', 'MAINTENANCE', 'OFFLINE'],
|
||||
['SH-PD-0001', '浦东新区智慧路灯 P01', pudong.id, '上海市浦东新区世纪大道', 121.544346, 31.221461, '单灯', 'NORMAL', 'ONLINE'],
|
||||
['SH-PD-0002', '浦东新区智慧路灯 P02', pudong.id, '上海市浦东新区陆家嘴环路', 121.502833, 31.239101, '单灯', 'FAULT', 'ONLINE'],
|
||||
['GZ-TH-0001', '天河区智慧路灯 T01', tianhe.id, '广州市天河区体育西路', 113.321478, 23.132191, '单灯', 'NORMAL', 'ONLINE'],
|
||||
['GZ-TH-0002', '天河区智慧路灯 T02', tianhe.id, '广州市天河区珠江新城', 113.32459, 23.119962, '单灯', 'OFFLINE', 'OFFLINE'],
|
||||
['HZ-XH-0001', '西湖区智慧路灯 X01', xihu.id, '杭州市西湖区文三路', 120.130396, 30.279242, '单灯', 'NORMAL', 'ONLINE'],
|
||||
['HZ-XH-0002', '西湖区智慧路灯 X02', xihu.id, '杭州市西湖区曙光路', 120.14321, 30.26311, '单灯', 'FAULT', 'ONLINE'],
|
||||
] as const;
|
||||
|
||||
const lampMap = new Map<string, { id: string; regionId: string }>();
|
||||
for (const [code, name, regionId, address, longitude, latitude, deviceType, status, onlineStatus] of sampleLamps) {
|
||||
const lamp = await prisma.streetLamp.upsert({
|
||||
where: { code },
|
||||
update: { name, regionId, address, longitude, latitude, deviceType, status, onlineStatus },
|
||||
create: {
|
||||
code,
|
||||
name,
|
||||
tenantId: tenant.id,
|
||||
regionId,
|
||||
address,
|
||||
longitude,
|
||||
latitude,
|
||||
deviceType,
|
||||
status,
|
||||
onlineStatus,
|
||||
lastReportedAt: onlineStatus === 'ONLINE' ? new Date(Date.now() - Math.random() * 1000 * 60 * 45) : undefined,
|
||||
},
|
||||
});
|
||||
lampMap.set(code, { id: lamp.id, regionId: lamp.regionId });
|
||||
}
|
||||
|
||||
const sampleAlarms = [
|
||||
['sample-alarm-001', 'BJ-DC-0001', 'POWER_FAILURE', '路灯断电报警', '设备供电异常,请尽快排查配电箱。', 'HIGH', 'OPEN', 18],
|
||||
['sample-alarm-002', 'BJ-DC-0002', 'LAMP_FAULT', '灯具故障报警', '灯具电流异常,疑似光源损坏。', 'MEDIUM', 'PROCESSING', 65],
|
||||
['sample-alarm-003', 'SH-PD-0002', 'DOOR_OPEN', '灯杆箱门开启', '箱门异常开启,存在安全风险。', 'CRITICAL', 'OPEN', 12],
|
||||
['sample-alarm-004', 'GZ-TH-0002', 'OFFLINE', '设备离线报警', '设备超过 30 分钟未上报数据。', 'HIGH', 'OPEN', 42],
|
||||
['sample-alarm-005', 'HZ-XH-0002', 'VOLTAGE_ABNORMAL', '电压异常报警', '输入电压波动超过阈值。', 'MEDIUM', 'PROCESSING', 95],
|
||||
['sample-alarm-006', 'SH-PD-0001', 'BRIGHTNESS_LOW', '亮度不足报警', '夜间亮度低于策略要求。', 'LOW', 'RESOLVED', 240],
|
||||
] as const;
|
||||
|
||||
for (const [sourceAlarmId, lampCode, type, title, description, level, status, minutesAgo] of sampleAlarms) {
|
||||
const lamp = lampMap.get(lampCode);
|
||||
if (!lamp) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await prisma.alarm.upsert({
|
||||
where: { sourceAlarmId },
|
||||
update: { status },
|
||||
create: {
|
||||
sourceAlarmId,
|
||||
tenantId: tenant.id,
|
||||
lampId: lamp.id,
|
||||
regionId: lamp.regionId,
|
||||
type,
|
||||
title,
|
||||
description,
|
||||
level,
|
||||
status,
|
||||
occurredAt: new Date(Date.now() - minutesAgo * 60 * 1000),
|
||||
recoveredAt: status === 'RESOLVED' ? new Date(Date.now() - 60 * 60 * 1000) : undefined,
|
||||
events: { create: [{ eventType: 'CREATED', payload: { seededBy: platformAdmin.username, tenantAdmin: admin.username } }] },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.then(async () => prisma.$disconnect())
|
||||
.catch(async (error) => {
|
||||
console.error(error);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
@ -0,0 +1,17 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { PermissionsGuard } from './permissions.guard';
|
||||
import { RegionScopeService } from './region-scope.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
RegionScopeService,
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: PermissionsGuard,
|
||||
},
|
||||
],
|
||||
exports: [RegionScopeService],
|
||||
})
|
||||
export class CommonModule {}
|
||||
@ -0,0 +1,17 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
|
||||
export interface AuthUser {
|
||||
sub: string;
|
||||
username: string;
|
||||
tenantId?: string | null;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
regionIds: string[];
|
||||
}
|
||||
|
||||
export const CurrentUser = createParamDecorator((data: keyof AuthUser | undefined, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest<Request & { user?: AuthUser }>();
|
||||
const user = request.user;
|
||||
return data && user ? user[data] : user;
|
||||
});
|
||||
@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const PERMISSIONS_KEY = 'permissions';
|
||||
export const RequirePermissions = (...permissions: string[]) => SetMetadata(PERMISSIONS_KEY, permissions);
|
||||
@ -0,0 +1,24 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Request } from 'express';
|
||||
import { AuthUser } from './current-user.decorator';
|
||||
import { PERMISSIONS_KEY } from './permissions.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class PermissionsGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const required = this.reflector.getAllAndOverride<string[]>(PERMISSIONS_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (!required?.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request & { user?: AuthUser }>();
|
||||
const permissions = request.user?.permissions ?? [];
|
||||
return required.every((permission) => permissions.includes(permission));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
@ -0,0 +1,62 @@
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
const configService = app.get(ConfigService);
|
||||
|
||||
app.enableCors({
|
||||
origin: true,
|
||||
credentials: true,
|
||||
});
|
||||
app.setGlobalPrefix('api');
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const swaggerConfig = new DocumentBuilder()
|
||||
.setTitle('城市照明综合网管平台 API')
|
||||
.setDescription('权限、区域、路灯、报警和地图接口')
|
||||
.setVersion('1.0.0')
|
||||
.addBearerAuth()
|
||||
.build();
|
||||
const document = SwaggerModule.createDocument(app, swaggerConfig);
|
||||
SwaggerModule.setup('api/docs', app, document);
|
||||
|
||||
const frontendDistPath = resolveFrontendDistPath();
|
||||
if (frontendDistPath) {
|
||||
app.useStaticAssets(frontendDistPath);
|
||||
const indexPath = join(frontendDistPath, 'index.html');
|
||||
app.use((req: any, res: any, next: () => void) => {
|
||||
if (req.method !== 'GET' || req.path.startsWith('/api') || /\.[^/]+$/.test(req.path)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
res.sendFile(indexPath);
|
||||
});
|
||||
}
|
||||
|
||||
await app.listen(Number(configService.get('PORT') ?? 3000));
|
||||
}
|
||||
|
||||
function resolveFrontendDistPath() {
|
||||
const candidates = [
|
||||
join(process.cwd(), 'front', 'dist'),
|
||||
join(process.cwd(), '..', 'front', 'dist'),
|
||||
join(__dirname, '..', '..', 'front', 'dist'),
|
||||
join(__dirname, '..', '..', '..', 'front', 'dist'),
|
||||
];
|
||||
|
||||
return candidates.find((path) => existsSync(join(path, 'index.html')));
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
@ -0,0 +1,16 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { AuthUser, CurrentUser } from '../../common/current-user.decorator';
|
||||
import { AlarmIngestionService } from './alarm-ingestion.service';
|
||||
|
||||
@ApiTags('报警接入')
|
||||
@ApiBearerAuth()
|
||||
@Controller('alarm-ingestion')
|
||||
export class AlarmIngestionController {
|
||||
constructor(private readonly alarmIngestionService: AlarmIngestionService) {}
|
||||
|
||||
@Post('receive')
|
||||
receive(@CurrentUser() user: AuthUser, @Body() dto: any) {
|
||||
return this.alarmIngestionService.receive(user, dto);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AlarmsModule } from '../alarms/alarms.module';
|
||||
import { AlarmIngestionController } from './alarm-ingestion.controller';
|
||||
import { AlarmIngestionService } from './alarm-ingestion.service';
|
||||
|
||||
@Module({
|
||||
imports: [AlarmsModule],
|
||||
controllers: [AlarmIngestionController],
|
||||
providers: [AlarmIngestionService],
|
||||
})
|
||||
export class AlarmIngestionModule {}
|
||||
@ -0,0 +1,29 @@
|
||||
import { ConflictException, Injectable } from '@nestjs/common';
|
||||
import { AuthUser } from '../../common/current-user.decorator';
|
||||
import { RedisService } from '../redis/redis.service';
|
||||
import { AlarmsService } from '../alarms/alarms.service';
|
||||
|
||||
@Injectable()
|
||||
export class AlarmIngestionService {
|
||||
constructor(
|
||||
private readonly redisService: RedisService,
|
||||
private readonly alarmsService: AlarmsService,
|
||||
) {}
|
||||
|
||||
async receive(user: AuthUser, dto: any) {
|
||||
const idempotentKey = `alarm:ingestion:${user.sub}:${dto.sourceAlarmId}`;
|
||||
const redis = this.redisService.client;
|
||||
await redis.connect().catch(() => undefined);
|
||||
const created = await redis.set(idempotentKey, '1', 'EX', 3600, 'NX');
|
||||
if (!created) {
|
||||
throw new ConflictException('重复报警已忽略');
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.alarmsService.createFromIngestion(user, dto);
|
||||
} catch (e) {
|
||||
await redis.del(idempotentKey).catch(() => undefined);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
import { Body, Controller, Get, Param, Patch, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { AuthUser, CurrentUser } from '../../common/current-user.decorator';
|
||||
import { RequirePermissions } from '../../common/permissions.decorator';
|
||||
import { AlarmsService } from './alarms.service';
|
||||
|
||||
@ApiBearerAuth()
|
||||
@ApiTags('报警管理')
|
||||
@RequirePermissions('alarms:manage')
|
||||
@Controller('alarms')
|
||||
export class AlarmsController {
|
||||
constructor(private readonly alarmsService: AlarmsService) {}
|
||||
|
||||
@Get()
|
||||
findAll(@CurrentUser() user: AuthUser, @Query() query: any) {
|
||||
return this.alarmsService.findAll(user, query);
|
||||
}
|
||||
|
||||
@Patch(':id/handle')
|
||||
handle(@CurrentUser() user: AuthUser, @Param('id') id: string, @Body() dto: any) {
|
||||
return this.alarmsService.handle(user, id, dto);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AlarmsController } from './alarms.controller';
|
||||
import { AlarmsService } from './alarms.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AlarmsController],
|
||||
providers: [AlarmsService],
|
||||
exports: [AlarmsService],
|
||||
})
|
||||
export class AlarmsModule {}
|
||||
@ -0,0 +1,112 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { AuthUser } from '../../common/current-user.decorator';
|
||||
import { RegionScopeService } from '../../common/region-scope.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class AlarmsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly regionScope: RegionScopeService,
|
||||
) {}
|
||||
|
||||
async findAll(user: AuthUser, query: any) {
|
||||
const page = Math.max(Number(query.page ?? 1), 1);
|
||||
const pageSize = Math.min(Math.max(Number(query.pageSize ?? query.limit ?? 20), 1), 100);
|
||||
const where = await this.regionScope.alarmWhere(user, {
|
||||
...(query.status ? { status: query.status } : {}),
|
||||
...(query.level ? { level: query.level } : {}),
|
||||
...(query.type ? { type: query.type } : {}),
|
||||
}, query.regionId);
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.alarm.findMany({
|
||||
where,
|
||||
include: { lamp: true, region: true, handleRecords: { include: { user: true }, orderBy: { createdAt: 'desc' } } },
|
||||
orderBy: { occurredAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.alarm.count({ where }),
|
||||
]);
|
||||
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
async createFromIngestion(user: AuthUser, dto: any) {
|
||||
const code = typeof dto.code === 'string' ? dto.code.trim() : '';
|
||||
const name = typeof dto.name === 'string' ? dto.name.trim() : '';
|
||||
if (!code || !name) {
|
||||
throw new BadRequestException('code 与 name 均为必填,且须与资产库中路灯一致');
|
||||
}
|
||||
|
||||
const lamp = await this.prisma.streetLamp.findUnique({ where: { code } });
|
||||
if (!lamp) {
|
||||
throw new NotFoundException('路灯设备不存在');
|
||||
}
|
||||
if (lamp.name.trim() !== name) {
|
||||
throw new BadRequestException('路灯名称与编号不一致');
|
||||
}
|
||||
|
||||
await this.regionScope.assertLampRowWritableByUser(user, lamp);
|
||||
|
||||
const existing = await this.prisma.alarm.findUnique({ where: { sourceAlarmId: dto.sourceAlarmId } });
|
||||
if (existing) {
|
||||
if (user.tenantId != null && existing.tenantId !== user.tenantId) {
|
||||
throw new NotFoundException('报警不存在');
|
||||
}
|
||||
if (existing.lampId !== lamp.id) {
|
||||
throw new BadRequestException('路灯与既有报警不匹配');
|
||||
}
|
||||
}
|
||||
|
||||
const tenantId =
|
||||
user.tenantId != null ? user.tenantId : dto.tenantId !== undefined ? dto.tenantId : lamp.tenantId;
|
||||
|
||||
return this.prisma.alarm.upsert({
|
||||
where: { sourceAlarmId: dto.sourceAlarmId },
|
||||
update: {
|
||||
status: dto.status,
|
||||
recoveredAt: dto.recoveredAt ? new Date(dto.recoveredAt) : undefined,
|
||||
events: { create: { eventType: 'UPDATED_FROM_API', payload: dto } },
|
||||
},
|
||||
create: {
|
||||
sourceAlarmId: dto.sourceAlarmId,
|
||||
tenantId: tenantId ?? lamp.tenantId,
|
||||
lampId: lamp.id,
|
||||
regionId: lamp.regionId,
|
||||
type: dto.type,
|
||||
title: dto.title,
|
||||
description: dto.description,
|
||||
level: dto.level ?? 'MEDIUM',
|
||||
status: dto.status ?? 'OPEN',
|
||||
occurredAt: dto.occurredAt ? new Date(dto.occurredAt) : new Date(),
|
||||
events: { create: { eventType: 'CREATED_FROM_API', payload: dto } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async handle(user: AuthUser, id: string, dto: any) {
|
||||
const alarm = await this.prisma.alarm.findFirst({ where: await this.regionScope.alarmWhere(user, { id }) });
|
||||
if (!alarm) {
|
||||
throw new NotFoundException('报警不存在或无权限访问');
|
||||
}
|
||||
|
||||
return this.prisma.alarm.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: dto.status ?? 'PROCESSING',
|
||||
handledAt: new Date(),
|
||||
recoveredAt: dto.status === 'RESOLVED' ? new Date() : undefined,
|
||||
handleRecords: {
|
||||
create: {
|
||||
userId: user.sub,
|
||||
action: dto.action ?? 'HANDLE',
|
||||
remark: dto.remark,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { handleRecords: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
import { Body, Controller, Get, Post } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { AuthUser, CurrentUser } from '../../common/current-user.decorator';
|
||||
import { Public } from '../../common/public.decorator';
|
||||
import { AuthService } from './auth.service';
|
||||
import { ChangePasswordDto, LoginDto, RefreshTokenDto } from './auth.dto';
|
||||
|
||||
@ApiTags('认证')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Public()
|
||||
@Post('login')
|
||||
login(@Body() dto: LoginDto) {
|
||||
return this.authService.login(dto);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('refresh')
|
||||
refresh(@Body() dto: RefreshTokenDto) {
|
||||
return this.authService.refresh(dto.refreshToken);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@Get('me')
|
||||
me(@CurrentUser() user: unknown) {
|
||||
return user;
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@Post('change-password')
|
||||
changePassword(@CurrentUser() user: AuthUser, @Body() dto: ChangePasswordDto) {
|
||||
return this.authService.changePassword(user, dto);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsString()
|
||||
tenantCode!: string;
|
||||
|
||||
@IsString()
|
||||
username!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(6)
|
||||
password!: string;
|
||||
}
|
||||
|
||||
export class RefreshTokenDto {
|
||||
@IsString()
|
||||
refreshToken!: string;
|
||||
}
|
||||
|
||||
export class ChangePasswordDto {
|
||||
@IsString()
|
||||
oldPassword!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(6)
|
||||
newPassword!: string;
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
secret: configService.get('JWT_SECRET'),
|
||||
signOptions: { expiresIn: configService.get('JWT_EXPIRES_IN') ?? '2h' },
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
AuthService,
|
||||
JwtStrategy,
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: JwtAuthGuard,
|
||||
},
|
||||
],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@ -0,0 +1,112 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { LoginDto } from './auth.dto';
|
||||
import { AuthUser } from '../../common/current-user.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async login(dto: LoginDto) {
|
||||
const isPlatformLogin = dto.tenantCode === 'platform';
|
||||
const tenant = isPlatformLogin
|
||||
? null
|
||||
: await this.prisma.tenant.findUnique({
|
||||
where: { code: dto.tenantCode },
|
||||
});
|
||||
if (!isPlatformLogin && !tenant?.enabled) {
|
||||
throw new UnauthorizedException('租户不存在或已停用');
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { username: dto.username, tenantId: tenant?.id ?? null },
|
||||
include: {
|
||||
tenant: true,
|
||||
roles: { include: { role: { include: { permissions: { include: { permission: true } }, regionScopes: true } } } },
|
||||
regionScopes: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user || user.status !== 'ACTIVE') {
|
||||
throw new UnauthorizedException('用户名或密码错误');
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(dto.password, user.passwordHash);
|
||||
if (!valid) {
|
||||
throw new UnauthorizedException('用户名或密码错误');
|
||||
}
|
||||
|
||||
const payload = {
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
tenantId: tenant?.id ?? null,
|
||||
tenantCode: tenant?.code ?? 'platform',
|
||||
roles: user.roles.map((item) => item.role.code),
|
||||
permissions: [...new Set(user.roles.flatMap((item) => item.role.permissions.map((rp) => rp.permission.code)))],
|
||||
regionIds: [
|
||||
...new Set([
|
||||
...user.regionScopes.map((scope) => scope.regionId),
|
||||
...user.roles.flatMap((item) => item.role.regionScopes.map((scope) => scope.regionId)),
|
||||
]),
|
||||
],
|
||||
};
|
||||
|
||||
return {
|
||||
accessToken: await this.jwtService.signAsync(payload),
|
||||
refreshToken: await this.jwtService.signAsync(payload, {
|
||||
expiresIn: this.configService.get('REFRESH_TOKEN_EXPIRES_IN') ?? '7d',
|
||||
}),
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
tenant: {
|
||||
id: tenant?.id ?? null,
|
||||
code: tenant?.code ?? 'platform',
|
||||
name: tenant?.name ?? '平台管理',
|
||||
},
|
||||
roles: payload.roles,
|
||||
permissions: payload.permissions,
|
||||
regionIds: payload.regionIds,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async refresh(refreshToken: string) {
|
||||
try {
|
||||
const payload = await this.jwtService.verifyAsync(refreshToken);
|
||||
const { exp, iat, ...rest } = payload;
|
||||
return {
|
||||
accessToken: await this.jwtService.signAsync(rest),
|
||||
};
|
||||
} catch {
|
||||
throw new UnauthorizedException('刷新令牌无效');
|
||||
}
|
||||
}
|
||||
|
||||
async changePassword(user: AuthUser, dto: { oldPassword: string; newPassword: string }) {
|
||||
const account = await this.prisma.user.findUnique({ where: { id: user.sub } });
|
||||
if (!account) {
|
||||
throw new UnauthorizedException('用户不存在');
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(dto.oldPassword, account.passwordHash);
|
||||
if (!valid) {
|
||||
throw new UnauthorizedException('原密码错误');
|
||||
}
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.sub },
|
||||
data: { passwordHash: await bcrypt.hash(dto.newPassword, 10) },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
import { ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { IS_PUBLIC_KEY } from '../../common/public.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
constructor(private readonly reflector: Reflector) {
|
||||
super();
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext) {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return super.canActivate(context);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { AuthUser } from '../../common/current-user.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(configService: ConfigService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.get('JWT_SECRET') ?? 'change-me-in-production',
|
||||
});
|
||||
}
|
||||
|
||||
validate(payload: AuthUser) {
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { AuthUser, CurrentUser } from '../../common/current-user.decorator';
|
||||
import { RequirePermissions } from '../../common/permissions.decorator';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
|
||||
@ApiBearerAuth()
|
||||
@ApiTags('数据看板')
|
||||
@RequirePermissions('dashboard:view')
|
||||
@Controller('dashboard')
|
||||
export class DashboardController {
|
||||
constructor(private readonly dashboardService: DashboardService) {}
|
||||
|
||||
@Get('summary')
|
||||
summary(@CurrentUser() user: AuthUser, @Query() query: any) {
|
||||
return this.dashboardService.summary(user, query);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DashboardController } from './dashboard.controller';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
|
||||
@Module({
|
||||
controllers: [DashboardController],
|
||||
providers: [DashboardService],
|
||||
})
|
||||
export class DashboardModule {}
|
||||
@ -0,0 +1,244 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthUser } from '../../common/current-user.decorator';
|
||||
import { RegionScopeService } from '../../common/region-scope.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class DashboardService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly regionScope: RegionScopeService,
|
||||
) {}
|
||||
|
||||
async summary(user: AuthUser, query: any = {}) {
|
||||
const recentPage = Math.max(Number(query.recentPage ?? 1), 1);
|
||||
const recentPageSize = Math.min(Math.max(Number(query.recentPageSize ?? 6), 1), 100);
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const trendStart = new Date(now.getFullYear(), now.getMonth() - 11, 1);
|
||||
const lampWhere = await this.regionScope.lampWhere(user, {}, query.regionId);
|
||||
const alarmWhere = await this.regionScope.alarmWhere(user, {}, query.regionId);
|
||||
const singleLampWhere = { AND: [lampWhere, { deviceType: '单灯' }] };
|
||||
const terminalWhere = { AND: [lampWhere, { deviceType: '监控终端' }] };
|
||||
const [
|
||||
assetTotal,
|
||||
singleLampTotal,
|
||||
terminalTotal,
|
||||
onlineTotal,
|
||||
singleLampOnlineTotal,
|
||||
terminalOnlineTotal,
|
||||
openAlarms,
|
||||
criticalAlarms,
|
||||
alarmsByStatus,
|
||||
alarmsByLevel,
|
||||
lampsByStatus,
|
||||
terminalsByStatus,
|
||||
regionRanking,
|
||||
regionDeviceRanking,
|
||||
regionOnlineRanking,
|
||||
regionOpenAlarmRanking,
|
||||
deviceTypes,
|
||||
todayAlarms,
|
||||
lampCreatedRows,
|
||||
recentAlarms,
|
||||
recentAlarmsTotal,
|
||||
] = await Promise.all([
|
||||
this.prisma.streetLamp.count({ where: lampWhere }),
|
||||
this.prisma.streetLamp.count({ where: singleLampWhere }),
|
||||
this.prisma.streetLamp.count({ where: terminalWhere }),
|
||||
this.prisma.streetLamp.count({ where: { ...lampWhere, onlineStatus: 'ONLINE' } }),
|
||||
this.prisma.streetLamp.count({ where: { AND: [singleLampWhere, { onlineStatus: 'ONLINE' }] } }),
|
||||
this.prisma.streetLamp.count({ where: { AND: [terminalWhere, { onlineStatus: 'ONLINE' }] } }),
|
||||
this.prisma.alarm.count({ where: { ...alarmWhere, status: { in: ['OPEN', 'PROCESSING'] } } }),
|
||||
this.prisma.alarm.count({ where: { ...alarmWhere, level: 'CRITICAL', status: { in: ['OPEN', 'PROCESSING'] } } }),
|
||||
this.prisma.alarm.groupBy({ by: ['status'], where: alarmWhere, _count: true }),
|
||||
this.prisma.alarm.groupBy({ by: ['level'], where: alarmWhere, _count: true }),
|
||||
this.prisma.streetLamp.groupBy({ by: ['status'], where: singleLampWhere, _count: true }),
|
||||
this.prisma.streetLamp.groupBy({ by: ['status'], where: terminalWhere, _count: true }),
|
||||
this.prisma.alarm.groupBy({
|
||||
by: ['regionId'],
|
||||
where: alarmWhere,
|
||||
_count: true,
|
||||
orderBy: { _count: { regionId: 'desc' } },
|
||||
take: 8,
|
||||
}),
|
||||
this.prisma.streetLamp.groupBy({
|
||||
by: ['regionId'],
|
||||
where: lampWhere,
|
||||
_count: true,
|
||||
orderBy: { _count: { regionId: 'desc' } },
|
||||
take: 8,
|
||||
}),
|
||||
this.prisma.streetLamp.groupBy({
|
||||
by: ['regionId'],
|
||||
where: { ...lampWhere, onlineStatus: 'ONLINE' },
|
||||
_count: true,
|
||||
}),
|
||||
this.prisma.alarm.groupBy({
|
||||
by: ['regionId'],
|
||||
where: { ...alarmWhere, status: { in: ['OPEN', 'PROCESSING'] } },
|
||||
_count: true,
|
||||
}),
|
||||
this.prisma.streetLamp.groupBy({
|
||||
by: ['deviceType'],
|
||||
where: lampWhere,
|
||||
_count: true,
|
||||
orderBy: { _count: { deviceType: 'desc' } },
|
||||
}),
|
||||
this.prisma.alarm.count({ where: { ...alarmWhere, occurredAt: { gte: todayStart } } }),
|
||||
this.prisma.streetLamp.findMany({
|
||||
where: lampWhere,
|
||||
select: { regionId: true, createdAt: true },
|
||||
}),
|
||||
this.prisma.alarm.findMany({
|
||||
where: alarmWhere,
|
||||
include: { lamp: true, region: true },
|
||||
orderBy: { occurredAt: 'desc' },
|
||||
skip: (recentPage - 1) * recentPageSize,
|
||||
take: recentPageSize,
|
||||
}),
|
||||
this.prisma.alarm.count({ where: alarmWhere }),
|
||||
]);
|
||||
|
||||
const countOf = (item: any) =>
|
||||
typeof item?._count === 'number' ? item._count : Number(item?._count?._all ?? Object.values(item?._count ?? {})[0] ?? 0);
|
||||
const alarmCountByRegion = new Map(regionOpenAlarmRanking.map((item) => [item.regionId, countOf(item)]));
|
||||
const onlineCountByRegion = new Map(regionOnlineRanking.map((item) => [item.regionId, countOf(item)]));
|
||||
const rankedRegionIds = [...new Set([
|
||||
...regionRanking.map((item) => item.regionId),
|
||||
...regionDeviceRanking.map((item) => item.regionId),
|
||||
...lampCreatedRows.map((item) => item.regionId),
|
||||
])];
|
||||
const regions = await this.prisma.region.findMany({
|
||||
where: rankedRegionIds.length ? { id: { in: rankedRegionIds } } : undefined,
|
||||
select: { id: true, name: true, level: true, parentId: true, longitude: true, latitude: true },
|
||||
});
|
||||
const allRegions = await this.prisma.region.findMany({
|
||||
select: { id: true, name: true, level: true, parentId: true, longitude: true, latitude: true },
|
||||
});
|
||||
const regionLookup = new Map(allRegions.map((region) => [region.id, region]));
|
||||
const displayRegion = (regionId: string) => regions.find((region) => region.id === regionId) ?? regionLookup.get(regionId);
|
||||
const cityRegionId = (regionId: string) => {
|
||||
let region = regionLookup.get(regionId);
|
||||
let fallback = region;
|
||||
while (region?.parentId) {
|
||||
if (region.level === 2) {
|
||||
return region.id;
|
||||
}
|
||||
fallback = region;
|
||||
region = regionLookup.get(region.parentId);
|
||||
}
|
||||
return region?.level === 2 ? region.id : fallback?.id ?? regionId;
|
||||
};
|
||||
const accessCityCount = new Set(lampCreatedRows.map((item) => cityRegionId(item.regionId))).size;
|
||||
const accessRegionCount = new Set(lampCreatedRows.map((item) => item.regionId)).size;
|
||||
const yesterdayLampRows = lampCreatedRows.filter((item) => item.createdAt < todayStart);
|
||||
const todayLampRows = lampCreatedRows.filter((item) => item.createdAt >= todayStart);
|
||||
const yesterdayCityCount = new Set(yesterdayLampRows.map((item) => cityRegionId(item.regionId))).size;
|
||||
const yesterdayRegionCount = new Set(yesterdayLampRows.map((item) => item.regionId)).size;
|
||||
const accessTrend = this.buildAccessTrend(lampCreatedRows, trendStart, now, cityRegionId);
|
||||
const deviceTypeStats = deviceTypes.map((item) => ({
|
||||
name: item.deviceType ?? '未分类',
|
||||
count: countOf(item),
|
||||
}));
|
||||
const deviceTypeCount = (keyword: string) =>
|
||||
deviceTypeStats
|
||||
.filter((item) => item.name.includes(keyword))
|
||||
.reduce((sum, item) => sum + item.count, 0);
|
||||
const regionDeviceOverview = regionDeviceRanking.map((item) => {
|
||||
const region = displayRegion(item.regionId);
|
||||
const count = countOf(item);
|
||||
const online = onlineCountByRegion.get(item.regionId) ?? 0;
|
||||
return {
|
||||
regionId: item.regionId,
|
||||
regionName: region?.name ?? item.regionId,
|
||||
total: count,
|
||||
online,
|
||||
onlineRate: count ? Number(((online / count) * 100).toFixed(2)) : 0,
|
||||
alarms: alarmCountByRegion.get(item.regionId) ?? 0,
|
||||
longitude: region?.longitude === null || region?.longitude === undefined ? null : Number(region.longitude),
|
||||
latitude: region?.latitude === null || region?.latitude === undefined ? null : Number(region.latitude),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
assetTotal,
|
||||
accessCityCount,
|
||||
accessRegionCount,
|
||||
metricChanges: {
|
||||
accessCityCount: accessCityCount - yesterdayCityCount,
|
||||
accessRegionCount: accessRegionCount - yesterdayRegionCount,
|
||||
assetTotal: todayLampRows.length,
|
||||
},
|
||||
projectTotal: null,
|
||||
hasProjectTable: false,
|
||||
hasEnergyTable: false,
|
||||
lampTotal: singleLampTotal,
|
||||
terminalTotal,
|
||||
onlineTotal,
|
||||
lampOnlineTotal: singleLampOnlineTotal,
|
||||
terminalOnlineTotal,
|
||||
onlineRate: assetTotal ? Number(((onlineTotal / assetTotal) * 100).toFixed(2)) : 0,
|
||||
lampOnlineRate: singleLampTotal ? Number(((singleLampOnlineTotal / singleLampTotal) * 100).toFixed(2)) : 0,
|
||||
terminalOnlineRate: terminalTotal ? Number(((terminalOnlineTotal / terminalTotal) * 100).toFixed(2)) : 0,
|
||||
openAlarms,
|
||||
todayAlarms,
|
||||
criticalAlarms,
|
||||
alarmsByStatus,
|
||||
alarmsByLevel,
|
||||
lampsByStatus,
|
||||
terminalsByStatus,
|
||||
deviceTypeStats,
|
||||
deviceBaseStats: {
|
||||
lamp: singleLampTotal,
|
||||
terminal: terminalTotal,
|
||||
distributionBox: deviceTypeCount('配电箱'),
|
||||
camera: deviceTypeCount('摄像'),
|
||||
sensor: deviceTypeCount('传感'),
|
||||
energyCollector: deviceTypeCount('能耗'),
|
||||
},
|
||||
accessTrend,
|
||||
regionDeviceRanking: regionDeviceOverview,
|
||||
mapRegions: regionDeviceOverview.filter((item) => item.longitude !== null && item.latitude !== null),
|
||||
energy: null,
|
||||
recentAlarms: recentAlarms.map((alarm) => ({
|
||||
id: alarm.id,
|
||||
title: alarm.title,
|
||||
type: alarm.type,
|
||||
level: alarm.level,
|
||||
status: alarm.status,
|
||||
occurredAt: alarm.occurredAt,
|
||||
lampName: alarm.lamp.name,
|
||||
lampCode: alarm.lamp.code,
|
||||
deviceType: alarm.lamp.deviceType,
|
||||
regionName: alarm.region.name,
|
||||
})),
|
||||
recentAlarmsTotal,
|
||||
recentAlarmsPage: recentPage,
|
||||
recentAlarmsPageSize: recentPageSize,
|
||||
regionRanking: regionRanking.map((item) => ({
|
||||
regionId: item.regionId,
|
||||
regionName: displayRegion(item.regionId)?.name ?? item.regionId,
|
||||
count: countOf(item),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private buildAccessTrend(
|
||||
lampRows: { regionId: string; createdAt: Date }[],
|
||||
trendStart: Date,
|
||||
now: Date,
|
||||
cityRegionId: (regionId: string) => string,
|
||||
) {
|
||||
return Array.from({ length: 12 }, (_, index) => {
|
||||
const monthStart = new Date(trendStart.getFullYear(), trendStart.getMonth() + index, 1);
|
||||
const monthEnd = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1);
|
||||
const rows = lampRows.filter((row) => row.createdAt < monthEnd && row.createdAt <= now);
|
||||
return {
|
||||
month: `${monthStart.getFullYear()}-${String(monthStart.getMonth() + 1).padStart(2, '0')}`,
|
||||
accessCityCount: new Set(rows.map((row) => cityRegionId(row.regionId))).size,
|
||||
deviceCount: rows.length,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { AuthUser, CurrentUser } from '../../common/current-user.decorator';
|
||||
import { LampIngestionService } from './lamp-ingestion.service';
|
||||
|
||||
@ApiTags('路灯接入')
|
||||
@ApiBearerAuth()
|
||||
@Controller('lamp-ingestion')
|
||||
export class LampIngestionController {
|
||||
constructor(private readonly lampIngestionService: LampIngestionService) {}
|
||||
|
||||
@Post('receive')
|
||||
receive(@CurrentUser() user: AuthUser, @Body() dto: Record<string, unknown>) {
|
||||
return this.lampIngestionService.receive(user, dto);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { LampIngestionController } from './lamp-ingestion.controller';
|
||||
import { LampIngestionService } from './lamp-ingestion.service';
|
||||
|
||||
@Module({
|
||||
controllers: [LampIngestionController],
|
||||
providers: [LampIngestionService],
|
||||
})
|
||||
export class LampIngestionModule {}
|
||||
@ -0,0 +1,158 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { LampStatus, OnlineStatus, Prisma } from '@prisma/client';
|
||||
import { AuthUser } from '../../common/current-user.decorator';
|
||||
import { RegionScopeService } from '../../common/region-scope.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const LAMP_STATUSES: LampStatus[] = ['NORMAL', 'FAULT', 'MAINTENANCE', 'OFFLINE'];
|
||||
const ONLINE_STATUSES: OnlineStatus[] = ['ONLINE', 'OFFLINE'];
|
||||
|
||||
@Injectable()
|
||||
export class LampIngestionService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly regionScope: RegionScopeService,
|
||||
) {}
|
||||
|
||||
async receive(user: AuthUser, dto: Record<string, unknown>) {
|
||||
const code = typeof dto.code === 'string' ? dto.code.trim() : '';
|
||||
if (!code) {
|
||||
throw new BadRequestException('code 不能为空');
|
||||
}
|
||||
|
||||
const existing = await this.prisma.streetLamp.findUnique({ where: { code } });
|
||||
if (existing) {
|
||||
await this.regionScope.assertLampRowWritableByUser(user, existing);
|
||||
}
|
||||
|
||||
let regionId: string | undefined =
|
||||
typeof dto.regionId === 'string' && dto.regionId.trim() ? dto.regionId.trim() : undefined;
|
||||
if (!regionId && typeof dto.regionCode === 'string' && dto.regionCode.trim()) {
|
||||
const region = await this.prisma.region.findUnique({ where: { code: dto.regionCode.trim() } });
|
||||
regionId = region?.id;
|
||||
}
|
||||
|
||||
const lng = dto.longitude !== undefined && dto.longitude !== null ? Number(dto.longitude) : undefined;
|
||||
const lat = dto.latitude !== undefined && dto.latitude !== null ? Number(dto.latitude) : undefined;
|
||||
if (!regionId && lng !== undefined && lat !== undefined && Number.isFinite(lng) && Number.isFinite(lat)) {
|
||||
regionId = (await this.regionScope.findNearestRegionId(lng, lat)) ?? undefined;
|
||||
}
|
||||
|
||||
if (!existing && !regionId) {
|
||||
throw new BadRequestException('新建路灯时需提供 regionId、regionCode 或有效经纬度以匹配区域');
|
||||
}
|
||||
|
||||
if (!existing && (lng === undefined || lat === undefined || !Number.isFinite(lng) || !Number.isFinite(lat))) {
|
||||
throw new BadRequestException('新建路灯时必须提供有效的 longitude 与 latitude');
|
||||
}
|
||||
|
||||
const name =
|
||||
typeof dto.name === 'string' && dto.name.trim()
|
||||
? dto.name.trim()
|
||||
: existing?.name ?? code;
|
||||
|
||||
const address =
|
||||
dto.address === undefined ? undefined : dto.address === null ? null : String(dto.address);
|
||||
const deviceType = this.normalizeDeviceType(dto.deviceType);
|
||||
|
||||
const status = this.parseLampStatus(dto.status, existing?.status);
|
||||
const onlineStatus = this.parseOnlineStatus(dto.onlineStatus, existing?.onlineStatus);
|
||||
|
||||
const isPowerOn =
|
||||
dto.isPowerOn === undefined ? undefined : dto.isPowerOn === null ? null : Boolean(dto.isPowerOn);
|
||||
|
||||
let lastReportedAt: Date | null | undefined;
|
||||
if (dto.lastReportedAt === null) {
|
||||
lastReportedAt = null;
|
||||
} else if (typeof dto.lastReportedAt === 'string' && dto.lastReportedAt) {
|
||||
const d = new Date(dto.lastReportedAt);
|
||||
lastReportedAt = Number.isNaN(d.getTime()) ? undefined : d;
|
||||
}
|
||||
|
||||
let tenantIdFromBody: string | null | undefined;
|
||||
if (user.tenantId == null) {
|
||||
if (dto.tenantId === null) {
|
||||
tenantIdFromBody = null;
|
||||
} else if (typeof dto.tenantId === 'string' && dto.tenantId.trim()) {
|
||||
tenantIdFromBody = dto.tenantId.trim();
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveTenantId = user.tenantId != null ? user.tenantId : tenantIdFromBody;
|
||||
|
||||
if (existing) {
|
||||
if (regionId) {
|
||||
await this.regionScope.assertRegionWritableByTenantUser(user, regionId);
|
||||
}
|
||||
const data: Prisma.StreetLampUpdateInput = {
|
||||
name,
|
||||
...(address !== undefined ? { address } : {}),
|
||||
...(deviceType !== undefined ? { deviceType } : {}),
|
||||
...(lng !== undefined && Number.isFinite(lng) ? { longitude: lng } : {}),
|
||||
...(lat !== undefined && Number.isFinite(lat) ? { latitude: lat } : {}),
|
||||
...(status !== undefined ? { status } : {}),
|
||||
...(onlineStatus !== undefined ? { onlineStatus } : {}),
|
||||
...(isPowerOn !== undefined ? { isPowerOn } : {}),
|
||||
...(lastReportedAt !== undefined ? { lastReportedAt } : {}),
|
||||
...(regionId ? { region: { connect: { id: regionId } } } : {}),
|
||||
};
|
||||
if (user.tenantId == null && tenantIdFromBody !== undefined) {
|
||||
data.tenant = tenantIdFromBody ? { connect: { id: tenantIdFromBody } } : { disconnect: true };
|
||||
}
|
||||
return this.prisma.streetLamp.update({
|
||||
where: { id: existing.id },
|
||||
data,
|
||||
include: { region: true },
|
||||
});
|
||||
}
|
||||
|
||||
await this.regionScope.assertRegionWritableByTenantUser(user, regionId!);
|
||||
|
||||
return this.prisma.streetLamp.create({
|
||||
data: {
|
||||
code,
|
||||
name,
|
||||
tenantId: effectiveTenantId ?? null,
|
||||
regionId: regionId!,
|
||||
address: address ?? null,
|
||||
longitude: lng!,
|
||||
latitude: lat!,
|
||||
deviceType: deviceType ?? '单灯',
|
||||
status: status ?? 'NORMAL',
|
||||
onlineStatus: onlineStatus ?? 'ONLINE',
|
||||
isPowerOn: isPowerOn ?? null,
|
||||
lastReportedAt: lastReportedAt ?? undefined,
|
||||
},
|
||||
include: { region: true },
|
||||
});
|
||||
}
|
||||
|
||||
private parseLampStatus(value: unknown, fallback?: LampStatus): LampStatus | undefined {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return fallback;
|
||||
}
|
||||
const v = String(value).toUpperCase() as LampStatus;
|
||||
if (!LAMP_STATUSES.includes(v)) {
|
||||
throw new BadRequestException(`status 枚举值不合法:${String(value)}`);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
private normalizeDeviceType(value: unknown) {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return value === '监控终端' ? '监控终端' : '单灯';
|
||||
}
|
||||
|
||||
private parseOnlineStatus(value: unknown, fallback?: OnlineStatus): OnlineStatus | undefined {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return fallback;
|
||||
}
|
||||
const v = String(value).toUpperCase() as OnlineStatus;
|
||||
if (!ONLINE_STATUSES.includes(v)) {
|
||||
throw new BadRequestException(`onlineStatus 枚举值不合法:${String(value)}`);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { AuthUser, CurrentUser } from '../../common/current-user.decorator';
|
||||
import { RequirePermissions } from '../../common/permissions.decorator';
|
||||
import { LampsService } from './lamps.service';
|
||||
|
||||
@ApiBearerAuth()
|
||||
@ApiTags('路灯资产')
|
||||
@RequirePermissions('lamps:manage')
|
||||
@Controller('lamps')
|
||||
export class LampsController {
|
||||
constructor(private readonly lampsService: LampsService) {}
|
||||
|
||||
@Get()
|
||||
findAll(@CurrentUser() user: AuthUser, @Query() query: any) {
|
||||
return this.lampsService.findAll(user, query);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: AuthUser, @Body() dto: any) {
|
||||
return this.lampsService.create(user, dto);
|
||||
}
|
||||
|
||||
@Post('sync-ilc')
|
||||
syncIlc(@CurrentUser() user: AuthUser) {
|
||||
return this.lampsService.syncExternalIlcList(user);
|
||||
}
|
||||
|
||||
@Post('sync-platform')
|
||||
syncPlatform(@CurrentUser() user: AuthUser, @Body() dto: any) {
|
||||
return this.lampsService.syncExternalIlcList(user, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@CurrentUser() user: AuthUser, @Param('id') id: string, @Body() dto: any) {
|
||||
return this.lampsService.update(user, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.lampsService.remove(user, id);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { LampsController } from './lamps.controller';
|
||||
import { LampsService } from './lamps.service';
|
||||
|
||||
@Module({
|
||||
controllers: [LampsController],
|
||||
providers: [LampsService],
|
||||
})
|
||||
export class LampsModule {}
|
||||
@ -0,0 +1,18 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { AuthUser, CurrentUser } from '../../common/current-user.decorator';
|
||||
import { RequirePermissions } from '../../common/permissions.decorator';
|
||||
import { MapService } from './map.service';
|
||||
|
||||
@ApiBearerAuth()
|
||||
@ApiTags('地图监控')
|
||||
@RequirePermissions('map:view')
|
||||
@Controller('map')
|
||||
export class MapController {
|
||||
constructor(private readonly mapService: MapService) {}
|
||||
|
||||
@Get('points')
|
||||
points(@CurrentUser() user: AuthUser, @Query() query: any) {
|
||||
return this.mapService.points(user, query);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MapController } from './map.controller';
|
||||
import { MapService } from './map.service';
|
||||
|
||||
@Module({
|
||||
controllers: [MapController],
|
||||
providers: [MapService],
|
||||
})
|
||||
export class MapModule {}
|
||||
@ -0,0 +1,78 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AlarmStatus, Prisma } from '@prisma/client';
|
||||
import { AuthUser } from '../../common/current-user.decorator';
|
||||
import { RegionScopeService } from '../../common/region-scope.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const mapPointInclude = {
|
||||
region: true,
|
||||
alarms: { where: { status: { in: [AlarmStatus.OPEN, AlarmStatus.PROCESSING] } } },
|
||||
} satisfies Prisma.StreetLampInclude;
|
||||
|
||||
type MapPointLamp = Prisma.StreetLampGetPayload<{ include: typeof mapPointInclude }>;
|
||||
|
||||
@Injectable()
|
||||
export class MapService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly regionScope: RegionScopeService,
|
||||
) {}
|
||||
|
||||
async points(user: AuthUser, query: any = {}) {
|
||||
const deviceType = query.deviceType === '单灯' || query.deviceType === '监控终端' ? query.deviceType : undefined;
|
||||
const where = await this.regionScope.lampWhere(user, {
|
||||
...(deviceType ? { deviceType } : {}),
|
||||
}, query.regionId);
|
||||
const page = Math.max(Number(query.page ?? 1), 1);
|
||||
const pageSize = Math.min(Math.max(Number(query.pageSize ?? query.limit ?? 20), 1), 100);
|
||||
|
||||
if (query.all === '1' || query.all === 'true' || query.all === true) {
|
||||
const lamps = await this.prisma.streetLamp.findMany({ where, include: mapPointInclude });
|
||||
return lamps.map((lamp) => this.toPoint(lamp));
|
||||
}
|
||||
|
||||
const [lamps, total] = await Promise.all([
|
||||
this.prisma.streetLamp.findMany({
|
||||
where,
|
||||
include: mapPointInclude,
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.streetLamp.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: lamps.map((lamp) => this.toPoint(lamp)),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
private toPoint(lamp: MapPointLamp) {
|
||||
return {
|
||||
id: lamp.id,
|
||||
code: lamp.code,
|
||||
name: lamp.name,
|
||||
address: lamp.address,
|
||||
deviceType: lamp.deviceType,
|
||||
longitude: Number(lamp.longitude),
|
||||
latitude: Number(lamp.latitude),
|
||||
regionName: lamp.region.name,
|
||||
status: lamp.status,
|
||||
onlineStatus: lamp.onlineStatus,
|
||||
isPowerOn: lamp.isPowerOn,
|
||||
lastReportedAt: lamp.lastReportedAt,
|
||||
activeAlarmCount: lamp.alarms.length,
|
||||
highestAlarmLevel: lamp.alarms[0]?.level ?? null,
|
||||
activeAlarms: lamp.alarms.map((alarm) => ({
|
||||
id: alarm.id,
|
||||
title: alarm.title,
|
||||
type: alarm.type,
|
||||
level: alarm.level,
|
||||
status: alarm.status,
|
||||
occurredAt: alarm.occurredAt,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
@ -0,0 +1,13 @@
|
||||
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { RedisService } from './redis.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [RedisService],
|
||||
exports: [RedisService],
|
||||
})
|
||||
export class RedisModule {}
|
||||
@ -0,0 +1,21 @@
|
||||
import { Injectable, OnModuleDestroy } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
@Injectable()
|
||||
export class RedisService implements OnModuleDestroy {
|
||||
readonly client: Redis;
|
||||
|
||||
constructor(configService: ConfigService) {
|
||||
this.client = new Redis({
|
||||
host: configService.get('REDIS_HOST') ?? 'localhost',
|
||||
port: Number(configService.get('REDIS_PORT') ?? 6379),
|
||||
lazyConnect: true,
|
||||
maxRetriesPerRequest: 1,
|
||||
});
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.client.quit();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { RequirePermissions } from '../../common/permissions.decorator';
|
||||
import { RegionsService } from './regions.service';
|
||||
|
||||
@ApiBearerAuth()
|
||||
@ApiTags('区域管理')
|
||||
@Controller('regions')
|
||||
export class RegionsController {
|
||||
constructor(private readonly regionsService: RegionsService) {}
|
||||
|
||||
@Get('tree')
|
||||
tree() {
|
||||
return this.regionsService.tree();
|
||||
}
|
||||
|
||||
@RequirePermissions('system:manage')
|
||||
@Post()
|
||||
create(@Body() dto: any) {
|
||||
return this.regionsService.create(dto);
|
||||
}
|
||||
|
||||
@RequirePermissions('system:manage')
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() dto: any) {
|
||||
return this.regionsService.update(id, dto);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { RegionsController } from './regions.controller';
|
||||
import { RegionsService } from './regions.service';
|
||||
|
||||
@Module({
|
||||
controllers: [RegionsController],
|
||||
providers: [RegionsService],
|
||||
})
|
||||
export class RegionsModule {}
|
||||
@ -0,0 +1,51 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class RegionsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async tree() {
|
||||
const regions = await this.prisma.region.findMany({ orderBy: [{ level: 'asc' }, { code: 'asc' }] });
|
||||
const map = new Map<string, any>();
|
||||
const roots: any[] = [];
|
||||
|
||||
regions.forEach((region) => map.set(region.id, { ...region, children: [] }));
|
||||
map.forEach((region) => {
|
||||
if (region.parentId && map.has(region.parentId)) {
|
||||
map.get(region.parentId).children.push(region);
|
||||
} else {
|
||||
roots.push(region);
|
||||
}
|
||||
});
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
create(dto: any) {
|
||||
return this.prisma.region.create({
|
||||
data: {
|
||||
code: dto.code,
|
||||
name: dto.name,
|
||||
level: Number(dto.level),
|
||||
parentId: dto.parentId,
|
||||
longitude: dto.longitude,
|
||||
latitude: dto.latitude,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
update(id: string, dto: any) {
|
||||
return this.prisma.region.update({
|
||||
where: { id },
|
||||
data: {
|
||||
code: dto.code,
|
||||
name: dto.name,
|
||||
level: dto.level === undefined ? undefined : Number(dto.level),
|
||||
parentId: dto.parentId,
|
||||
longitude: dto.longitude,
|
||||
latitude: dto.latitude,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { AuthUser, CurrentUser } from '../../common/current-user.decorator';
|
||||
import { RequirePermissions } from '../../common/permissions.decorator';
|
||||
import { RolesService } from './roles.service';
|
||||
|
||||
@ApiBearerAuth()
|
||||
@ApiTags('角色权限')
|
||||
@RequirePermissions('system:manage')
|
||||
@Controller('roles')
|
||||
export class RolesController {
|
||||
constructor(private readonly rolesService: RolesService) {}
|
||||
|
||||
@Get()
|
||||
findAll(@CurrentUser() user: AuthUser, @Query() query: any) {
|
||||
return this.rolesService.findAll(user, query);
|
||||
}
|
||||
|
||||
@Get('permissions')
|
||||
permissions(@CurrentUser() user: AuthUser) {
|
||||
return this.rolesService.permissions(user);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: AuthUser, @Body() dto: any) {
|
||||
return this.rolesService.create(user, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@CurrentUser() user: AuthUser, @Param('id') id: string, @Body() dto: any) {
|
||||
return this.rolesService.update(user, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.rolesService.remove(user, id);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { RolesController } from './roles.controller';
|
||||
import { RolesService } from './roles.service';
|
||||
|
||||
@Module({
|
||||
controllers: [RolesController],
|
||||
providers: [RolesService],
|
||||
})
|
||||
export class RolesModule {}
|
||||
@ -0,0 +1,88 @@
|
||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { AuthUser } from '../../common/current-user.decorator';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class RolesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findAll(user: AuthUser, query: any = {}) {
|
||||
const page = Math.max(Number(query.page ?? 1), 1);
|
||||
const pageSize = Math.min(Math.max(Number(query.pageSize ?? query.limit ?? 20), 1), 100);
|
||||
const where = { tenantId: user.tenantId ?? '__platform-roles-hidden__' };
|
||||
const include = {
|
||||
permissions: { include: { permission: true } },
|
||||
regionScopes: { include: { region: true } },
|
||||
};
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.role.findMany({
|
||||
where,
|
||||
include,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.role.count({ where }),
|
||||
]);
|
||||
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
permissions(user: AuthUser) {
|
||||
return this.prisma.permission.findMany({
|
||||
where: user.tenantId ? { code: { not: 'tenants:manage' } } : { code: 'tenants:manage' },
|
||||
orderBy: [{ sort: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
}
|
||||
|
||||
async create(user: AuthUser, dto: any) {
|
||||
if (!user.tenantId) {
|
||||
throw new ForbiddenException('平台超级管理员不能在角色页面配置角色');
|
||||
}
|
||||
|
||||
return this.prisma.role.create({
|
||||
data: {
|
||||
name: dto.name,
|
||||
code: dto.code,
|
||||
description: dto.description,
|
||||
tenantId: user.tenantId,
|
||||
permissions: { create: dto.permissionIds?.map((permissionId: string) => ({ permissionId })) ?? [] },
|
||||
regionScopes: { create: dto.regionIds?.map((regionId: string) => ({ regionId })) ?? [] },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(user: AuthUser, id: string, dto: any) {
|
||||
await this.ensureTenantRole(user, id);
|
||||
await this.prisma.rolePermission.deleteMany({ where: { roleId: id } });
|
||||
await this.prisma.roleRegionScope.deleteMany({ where: { roleId: id } });
|
||||
|
||||
return this.prisma.role.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: dto.name,
|
||||
code: dto.code,
|
||||
description: dto.description,
|
||||
permissions: { create: dto.permissionIds?.map((permissionId: string) => ({ permissionId })) ?? [] },
|
||||
regionScopes: { create: dto.regionIds?.map((regionId: string) => ({ regionId })) ?? [] },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async remove(user: AuthUser, id: string) {
|
||||
await this.ensureTenantRole(user, id);
|
||||
return this.prisma.role.delete({ where: { id } });
|
||||
}
|
||||
|
||||
private async ensureTenantRole(user: AuthUser, id: string) {
|
||||
if (!user.tenantId) {
|
||||
throw new ForbiddenException('平台超级管理员不能在角色页面配置角色');
|
||||
}
|
||||
const role = await this.prisma.role.findFirst({ where: { id, tenantId: user.tenantId } });
|
||||
if (!role) {
|
||||
throw new NotFoundException('角色不存在或无权限访问');
|
||||
}
|
||||
return role;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
import { Body, Controller, Get, Patch, Param, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { AuthUser, CurrentUser } from '../../common/current-user.decorator';
|
||||
import { RequirePermissions } from '../../common/permissions.decorator';
|
||||
import { TenantsService } from './tenants.service';
|
||||
|
||||
@ApiBearerAuth()
|
||||
@ApiTags('租户管理')
|
||||
@RequirePermissions('tenants:manage')
|
||||
@Controller('tenants')
|
||||
export class TenantsController {
|
||||
constructor(private readonly tenantsService: TenantsService) {}
|
||||
|
||||
@Get()
|
||||
findAll(@CurrentUser() user: AuthUser, @Query() query: any) {
|
||||
return this.tenantsService.findAll(user, query);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: AuthUser, @Body() dto: any) {
|
||||
return this.tenantsService.create(user, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@CurrentUser() user: AuthUser, @Param('id') id: string, @Body() dto: any) {
|
||||
return this.tenantsService.update(user, id, dto);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TenantsController } from './tenants.controller';
|
||||
import { TenantsService } from './tenants.service';
|
||||
|
||||
@Module({
|
||||
controllers: [TenantsController],
|
||||
providers: [TenantsService],
|
||||
})
|
||||
export class TenantsModule {}
|
||||
@ -0,0 +1,99 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { AuthUser } from '../../common/current-user.decorator';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class TenantsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findAll(user: AuthUser, query: any = {}) {
|
||||
this.ensurePlatformAdmin(user);
|
||||
const page = Math.max(Number(query.page ?? 1), 1);
|
||||
const pageSize = Math.min(Math.max(Number(query.pageSize ?? query.limit ?? 20), 1), 100);
|
||||
const where = query.keyword
|
||||
? { OR: [{ code: { contains: query.keyword } }, { name: { contains: query.keyword } }] }
|
||||
: undefined;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.tenant.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: { users: { where: { username: query.adminUsername ?? 'admin' }, select: { id: true, username: true, name: true } } },
|
||||
}),
|
||||
this.prisma.tenant.count({ where }),
|
||||
]);
|
||||
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
async create(user: AuthUser, dto: any) {
|
||||
this.ensurePlatformAdmin(user);
|
||||
const adminUsername = dto.adminUsername || 'admin';
|
||||
const adminPassword = dto.adminPassword || 'admin123456';
|
||||
const adminName = dto.adminName || '租户管理员';
|
||||
const region = await this.prisma.region.findFirst({ where: { level: 0 }, orderBy: { code: 'asc' } });
|
||||
if (!region) {
|
||||
throw new BadRequestException('请先初始化全国区域数据');
|
||||
}
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const tenant = await tx.tenant.create({
|
||||
data: {
|
||||
name: dto.name,
|
||||
code: dto.code,
|
||||
enabled: dto.enabled ?? true,
|
||||
},
|
||||
});
|
||||
|
||||
const permissions = await tx.permission.findMany({ where: { code: { not: 'tenants:manage' } } });
|
||||
const role = await tx.role.create({
|
||||
data: {
|
||||
name: '租户管理员',
|
||||
code: 'tenant_admin',
|
||||
description: '租户初始化管理员角色',
|
||||
tenantId: tenant.id,
|
||||
permissions: { create: permissions.map((permission) => ({ permissionId: permission.id })) },
|
||||
regionScopes: { create: [{ regionId: region.id }] },
|
||||
},
|
||||
});
|
||||
|
||||
await tx.user.create({
|
||||
data: {
|
||||
username: adminUsername,
|
||||
passwordHash: await bcrypt.hash(adminPassword, 10),
|
||||
name: adminName,
|
||||
tenantId: tenant.id,
|
||||
roles: { create: [{ roleId: role.id }] },
|
||||
regionScopes: { create: [{ regionId: region.id }] },
|
||||
},
|
||||
});
|
||||
|
||||
return { tenant, admin: { username: adminUsername, name: adminName } };
|
||||
});
|
||||
}
|
||||
|
||||
async update(user: AuthUser, id: string, dto: any) {
|
||||
this.ensurePlatformAdmin(user);
|
||||
const tenant = await this.prisma.tenant.findUnique({ where: { id } });
|
||||
if (!tenant) {
|
||||
throw new NotFoundException('租户不存在');
|
||||
}
|
||||
|
||||
return this.prisma.tenant.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: dto.name,
|
||||
enabled: dto.enabled,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private ensurePlatformAdmin(user: AuthUser) {
|
||||
if (user.tenantId || !user.permissions.includes('tenants:manage')) {
|
||||
throw new ForbiddenException('仅平台超级管理员可访问租户管理');
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { AuthUser, CurrentUser } from '../../common/current-user.decorator';
|
||||
import { RequirePermissions } from '../../common/permissions.decorator';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
@ApiBearerAuth()
|
||||
@ApiTags('用户管理')
|
||||
@RequirePermissions('system:manage')
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
constructor(private readonly usersService: UsersService) {}
|
||||
|
||||
@Get()
|
||||
findAll(@CurrentUser() user: AuthUser, @Query() query: any) {
|
||||
return this.usersService.findAll(user, query);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: AuthUser, @Body() dto: any) {
|
||||
return this.usersService.create(user, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@CurrentUser() user: AuthUser, @Param('id') id: string, @Body() dto: any) {
|
||||
return this.usersService.update(user, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.usersService.remove(user, id);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UsersController } from './users.controller';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
@Module({
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
@ -0,0 +1,105 @@
|
||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { AuthUser } from '../../common/current-user.decorator';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
interface CreateUserDto {
|
||||
username: string;
|
||||
password?: string;
|
||||
name: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
roleIds?: string[];
|
||||
regionIds?: string[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findAll(user: AuthUser, query: any = {}) {
|
||||
const page = Math.max(Number(query.page ?? 1), 1);
|
||||
const pageSize = Math.min(Math.max(Number(query.pageSize ?? query.limit ?? 20), 1), 100);
|
||||
const where = {
|
||||
tenantId: user.tenantId ?? '__platform-users-hidden__',
|
||||
...(query.keyword ? { OR: [{ username: { contains: query.keyword } }, { name: { contains: query.keyword } }] } : {}),
|
||||
};
|
||||
const select = {
|
||||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
email: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
roles: { include: { role: true } },
|
||||
regionScopes: { include: { region: true } },
|
||||
};
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.user.findMany({
|
||||
where,
|
||||
select,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.user.count({ where }),
|
||||
]);
|
||||
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
async create(user: AuthUser, dto: CreateUserDto) {
|
||||
if (!user.tenantId) {
|
||||
throw new ForbiddenException('平台超级管理员不能在用户管理中创建账号');
|
||||
}
|
||||
|
||||
return this.prisma.user.create({
|
||||
data: {
|
||||
username: dto.username,
|
||||
passwordHash: await bcrypt.hash(dto.password ?? '123456', 10),
|
||||
name: dto.name,
|
||||
phone: dto.phone,
|
||||
email: dto.email,
|
||||
tenantId: user.tenantId,
|
||||
roles: { create: dto.roleIds?.map((roleId) => ({ roleId })) ?? [] },
|
||||
regionScopes: { create: dto.regionIds?.map((regionId) => ({ regionId })) ?? [] },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(user: AuthUser, id: string, dto: Partial<CreateUserDto> & { status?: 'ACTIVE' | 'DISABLED' }) {
|
||||
await this.ensureTenantUser(user, id);
|
||||
await this.prisma.userRole.deleteMany({ where: { userId: id } });
|
||||
await this.prisma.userRegionScope.deleteMany({ where: { userId: id } });
|
||||
|
||||
return this.prisma.user.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: dto.name,
|
||||
phone: dto.phone,
|
||||
email: dto.email,
|
||||
status: dto.status,
|
||||
roles: { create: dto.roleIds?.map((roleId) => ({ roleId })) ?? [] },
|
||||
regionScopes: { create: dto.regionIds?.map((regionId) => ({ regionId })) ?? [] },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async remove(user: AuthUser, id: string) {
|
||||
await this.ensureTenantUser(user, id);
|
||||
return this.prisma.user.delete({ where: { id } });
|
||||
}
|
||||
|
||||
private async ensureTenantUser(user: AuthUser, id: string) {
|
||||
if (!user.tenantId) {
|
||||
throw new ForbiddenException('平台超级管理员不能在用户管理中配置账号');
|
||||
}
|
||||
const target = await this.prisma.user.findFirst({ where: { id, tenantId: user.tenantId } });
|
||||
if (!target) {
|
||||
throw new NotFoundException('用户不存在或无权限访问');
|
||||
}
|
||||
return target;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"incremental": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"strictPropertyInitialization": false,
|
||||
"esModuleInterop": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "prisma/**/*.ts"]
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@ -0,0 +1,27 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
container_name: local-mysql
|
||||
restart: always
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: 123456
|
||||
MYSQL_DATABASE: app_db
|
||||
MYSQL_USER: app
|
||||
MYSQL_PASSWORD: app123456
|
||||
ports:
|
||||
- '3306:3306'
|
||||
volumes:
|
||||
- ./mysql-data:/var/lib/mysql
|
||||
command: --default-authentication-plugin=mysql_native_password
|
||||
|
||||
redis:
|
||||
image: redis:7
|
||||
container_name: local-redis
|
||||
restart: always
|
||||
ports:
|
||||
- '6379:6379'
|
||||
volumes:
|
||||
- ./redis-data:/data
|
||||
command: redis-server --appendonly yes
|
||||
@ -0,0 +1,5 @@
|
||||
VITE_AMAP_KEY=""
|
||||
VITE_AMAP_SECURITY_CODE=""
|
||||
VITE_MAP_CENTER_LNG="104.195397"
|
||||
VITE_MAP_CENTER_LAT="35.861660"
|
||||
VITE_MAP_DEFAULT_ZOOM="4"
|
||||
@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>城市照明综合网管平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "front",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "Street light alarm SaaS frontend",
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"axios": "^1.16.0",
|
||||
"echarts": "^6.0.0",
|
||||
"element-plus": "^2.14.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.34",
|
||||
"vue-router": "^5.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^6.4.2",
|
||||
"vue-tsc": "^3.2.8"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
@ -0,0 +1,27 @@
|
||||
import axios from 'axios';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
export const http = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
http.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
http.interceptors.response.use(
|
||||
(response) => response.data,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
useAuthStore().logout();
|
||||
}
|
||||
ElMessage.error(error.code === 'ECONNABORTED' ? '请求超时,请稍后查看同步结果' : (error.response?.data?.message ?? '请求失败'));
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
@ -0,0 +1,48 @@
|
||||
import { http } from './client';
|
||||
|
||||
export const authApi = {
|
||||
login: (data: { tenantCode: string; username: string; password: string }) =>
|
||||
http.post('/auth/login', data) as Promise<any>,
|
||||
me: () => http.get('/auth/me') as Promise<any>,
|
||||
changePassword: (data: { oldPassword: string; newPassword: string }) =>
|
||||
http.post('/auth/change-password', data) as Promise<any>,
|
||||
};
|
||||
|
||||
export const dashboardApi = {
|
||||
summary: (params?: any) => http.get('/dashboard/summary', { params }) as Promise<any>,
|
||||
};
|
||||
|
||||
export const regionApi = {
|
||||
tree: () => http.get('/regions/tree') as Promise<any>,
|
||||
create: (data: any) => http.post('/regions', data) as Promise<any>,
|
||||
};
|
||||
|
||||
export const lampApi = {
|
||||
list: (params?: any) => http.get('/lamps', { params }) as Promise<any>,
|
||||
create: (data: any) => http.post('/lamps', data) as Promise<any>,
|
||||
syncIlc: () => http.post('/lamps/sync-ilc', undefined, { timeout: 180000 }) as Promise<any>,
|
||||
syncPlatform: (data: any) => http.post('/lamps/sync-platform', data, { timeout: 180000 }) as Promise<any>,
|
||||
update: (id: string, data: any) => http.patch(`/lamps/${id}`, data) as Promise<any>,
|
||||
remove: (id: string) => http.delete(`/lamps/${id}`) as Promise<any>,
|
||||
};
|
||||
|
||||
export const alarmApi = {
|
||||
list: (params?: any) => http.get('/alarms', { params }) as Promise<any>,
|
||||
handle: (id: string, data: any) => http.patch(`/alarms/${id}/handle`, data) as Promise<any>,
|
||||
};
|
||||
|
||||
export const mapApi = {
|
||||
points: (params?: any) => http.get('/map/points', { params }) as Promise<any>,
|
||||
};
|
||||
|
||||
export const systemApi = {
|
||||
tenants: (params?: any) => http.get('/tenants', { params }) as Promise<any>,
|
||||
createTenant: (data: any) => http.post('/tenants', data) as Promise<any>,
|
||||
updateTenant: (id: string, data: any) => http.patch(`/tenants/${id}`, data) as Promise<any>,
|
||||
users: (params?: any) => http.get('/users', { params }) as Promise<any>,
|
||||
createUser: (data: any) => http.post('/users', data) as Promise<any>,
|
||||
roles: (params?: any) => http.get('/roles', { params }) as Promise<any>,
|
||||
permissions: () => http.get('/roles/permissions') as Promise<any>,
|
||||
createRole: (data: any) => http.post('/roles', data) as Promise<any>,
|
||||
updateRole: (id: string, data: any) => http.patch(`/roles/${id}`, data) as Promise<any>,
|
||||
};
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
@ -0,0 +1,13 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.png' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
AMap?: any;
|
||||
_AMapSecurityConfig?: {
|
||||
securityJsCode: string;
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,9 @@
|
||||
import { createApp } from 'vue';
|
||||
import ElementPlus from 'element-plus';
|
||||
import 'element-plus/dist/index.css';
|
||||
import './styles.css';
|
||||
import App from './App.vue';
|
||||
import { router } from './router';
|
||||
import { pinia } from './stores';
|
||||
|
||||
createApp(App).use(pinia).use(router).use(ElementPlus).mount('#app');
|
||||
@ -0,0 +1,62 @@
|
||||
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import LoginView from '../views/LoginView.vue';
|
||||
import AppLayout from '../layouts/AppLayout.vue';
|
||||
import DashboardView from '../views/dashboard/DashboardView.vue';
|
||||
import MapView from '../views/map/MapView.vue';
|
||||
import MapFullscreenView from '../views/map/MapFullscreenView.vue';
|
||||
import LampsView from '../views/lamps/LampsView.vue';
|
||||
import TerminalsView from '../views/terminals/TerminalsView.vue';
|
||||
import SyncView from '../views/sync/SyncView.vue';
|
||||
import AlarmsView from '../views/alarms/AlarmsView.vue';
|
||||
import UsersView from '../views/system/UsersView.vue';
|
||||
import RolesView from '../views/system/RolesView.vue';
|
||||
import TenantsView from '../views/system/TenantsView.vue';
|
||||
|
||||
export const menuRoutes: RouteRecordRaw[] = [
|
||||
{ path: '/system/tenants', component: TenantsView, meta: { title: '租户管理', permission: 'tenants:manage' } },
|
||||
{ path: '/dashboard', component: DashboardView, meta: { title: '总览', permission: 'dashboard:view' } },
|
||||
{ path: '/map', component: MapView, meta: { title: '设备监控', permission: 'map:view' } },
|
||||
{ path: '/lamps', component: LampsView, meta: { title: '路灯资产', permission: 'lamps:manage' } },
|
||||
{ path: '/terminals', component: TerminalsView, meta: { title: '监控终端', permission: 'lamps:manage' } },
|
||||
{ path: '/sync', component: SyncView, meta: { title: '运维管理', permission: 'lamps:manage' } },
|
||||
{ path: '/alarms', component: AlarmsView, meta: { title: '告警管理', permission: 'alarms:manage' } },
|
||||
{ path: '/system/users', component: UsersView, meta: { title: '用户管理', permission: 'system:manage' } },
|
||||
{ path: '/system/roles', component: RolesView, meta: { title: '角色权限', permission: 'system:manage' } },
|
||||
];
|
||||
|
||||
export function firstPermittedPath(permissions: string[]) {
|
||||
return menuRoutes.find((route) => {
|
||||
const permission = route.meta?.permission as string | undefined;
|
||||
return !permission || permissions.includes(permission);
|
||||
})?.path ?? '/login';
|
||||
}
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/login', component: LoginView },
|
||||
{ path: '/map/fullscreen', component: MapFullscreenView, meta: { title: '全屏地图', permission: 'map:view' } },
|
||||
{
|
||||
path: '/',
|
||||
component: AppLayout,
|
||||
redirect: '/dashboard',
|
||||
children: menuRoutes,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
if (to.path !== '/login' && !token) {
|
||||
return '/login';
|
||||
}
|
||||
|
||||
const permission = to.meta.permission as string | undefined;
|
||||
const authStore = useAuthStore();
|
||||
if (permission && authStore.permissions.length && !authStore.permissions.includes(permission)) {
|
||||
return firstPermittedPath(authStore.permissions);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
@ -0,0 +1,4 @@
|
||||
declare module '*.png' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { authApi } from '../api/modules';
|
||||
import { router } from '../router';
|
||||
|
||||
interface LoginResult {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
name: string;
|
||||
tenant: {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
};
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
regionIds: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
user: JSON.parse(localStorage.getItem('user') || 'null') as LoginResult['user'] | null,
|
||||
}),
|
||||
getters: {
|
||||
permissions: (state) => state.user?.permissions ?? [],
|
||||
},
|
||||
actions: {
|
||||
async login(tenantCode: string, username: string, password: string) {
|
||||
const result = (await authApi.login({ tenantCode, username, password })) as unknown as LoginResult;
|
||||
localStorage.setItem('accessToken', result.accessToken);
|
||||
localStorage.setItem('refreshToken', result.refreshToken);
|
||||
localStorage.setItem('user', JSON.stringify(result.user));
|
||||
this.user = result.user;
|
||||
},
|
||||
logout() {
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
localStorage.removeItem('user');
|
||||
this.user = null;
|
||||
if (router.currentRoute.value.path !== '/login') {
|
||||
void router.push('/login');
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -0,0 +1,3 @@
|
||||
import { createPinia } from 'pinia';
|
||||
|
||||
export const pinia = createPinia();
|
||||
@ -0,0 +1,180 @@
|
||||
:root {
|
||||
color: #d8e8fb;
|
||||
background: #020916;
|
||||
font-family: Inter, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
min-width: 320px;
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
background:
|
||||
linear-gradient(rgba(19, 99, 190, 0.08) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(19, 99, 190, 0.08) 1px, transparent 1px),
|
||||
radial-gradient(circle at 16% 14%, rgba(0, 136, 255, 0.2), transparent 28%),
|
||||
radial-gradient(circle at 82% 10%, rgba(0, 211, 255, 0.11), transparent 24%),
|
||||
#020916;
|
||||
background-size: 42px 42px, 42px 42px, auto, auto, auto;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.page-hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 22px 24px;
|
||||
border: 1px solid rgba(35, 150, 255, 0.28);
|
||||
border-radius: 6px;
|
||||
color: #f2f8ff;
|
||||
background: rgba(3, 25, 58, 0.78);
|
||||
box-shadow: inset 0 0 24px rgba(26, 126, 255, 0.14), 0 18px 50px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.page-hero h1 {
|
||||
margin: 0;
|
||||
color: #f2f8ff;
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.page-hero p {
|
||||
margin: 8px 0 0;
|
||||
color: #91acd0;
|
||||
}
|
||||
|
||||
.panel-card,
|
||||
.modern-card,
|
||||
.metric-card {
|
||||
border: 1px solid rgba(40, 145, 255, 0.34) !important;
|
||||
border-radius: 6px !important;
|
||||
color: #d8e8fb !important;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(8, 48, 99, 0.9), rgba(3, 24, 55, 0.84)) !important;
|
||||
box-shadow: inset 0 0 24px rgba(20, 126, 255, 0.13), 0 12px 32px rgba(0, 0, 0, 0.24) !important;
|
||||
}
|
||||
|
||||
.modern-table {
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.metric-card .el-card__body {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.metric-title {
|
||||
color: #9ab5d8;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
margin-top: 10px;
|
||||
color: #f5fbff;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.el-card {
|
||||
--el-card-padding: 16px;
|
||||
--el-card-border-color: rgba(40, 145, 255, 0.34);
|
||||
color: #d8e8fb;
|
||||
background-color: rgba(3, 24, 55, 0.84);
|
||||
}
|
||||
|
||||
.el-card__header {
|
||||
border-bottom-color: rgba(67, 154, 255, 0.22) !important;
|
||||
}
|
||||
|
||||
.el-table {
|
||||
--el-table-bg-color: transparent;
|
||||
--el-table-tr-bg-color: transparent;
|
||||
--el-table-header-bg-color: rgba(13, 58, 113, 0.84);
|
||||
--el-table-header-text-color: #bddaff;
|
||||
--el-table-row-hover-bg-color: rgba(33, 128, 255, 0.16);
|
||||
--el-table-border-color: rgba(71, 151, 255, 0.2);
|
||||
--el-table-text-color: #d9e8fb;
|
||||
color: #d9e8fb;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.el-table th.el-table__cell,
|
||||
.el-table tr,
|
||||
.el-table td.el-table__cell {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.el-pagination {
|
||||
--el-pagination-bg-color: rgba(7, 35, 73, 0.9);
|
||||
--el-pagination-button-color: #d8e8fb;
|
||||
--el-pagination-button-disabled-bg-color: rgba(8, 29, 59, 0.8);
|
||||
--el-pagination-hover-color: #35d8ff;
|
||||
}
|
||||
|
||||
.el-input__wrapper,
|
||||
.el-select__wrapper,
|
||||
.el-tree-select .el-select__wrapper {
|
||||
background: rgba(3, 22, 50, 0.9) !important;
|
||||
border: 1px solid rgba(55, 148, 255, 0.32);
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.el-input__inner,
|
||||
.el-select__placeholder,
|
||||
.el-select__selected-item {
|
||||
color: #d8e8fb !important;
|
||||
}
|
||||
|
||||
.el-dialog {
|
||||
--el-dialog-bg-color: #071b38;
|
||||
--el-text-color-primary: #eef7ff;
|
||||
--el-text-color-regular: #c7dcf5;
|
||||
border: 1px solid rgba(55, 148, 255, 0.35);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.card-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
const onlineStatusLabels: Record<string, string> = {
|
||||
ONLINE: '在线',
|
||||
OFFLINE: '离线',
|
||||
};
|
||||
|
||||
const lampStatusLabels: Record<string, string> = {
|
||||
NORMAL: '正常',
|
||||
FAULT: '故障',
|
||||
MAINTENANCE: '维护中',
|
||||
OFFLINE: '离线',
|
||||
};
|
||||
|
||||
const alarmStatusLabels: Record<string, string> = {
|
||||
OPEN: '待处理',
|
||||
PROCESSING: '处理中',
|
||||
RESOLVED: '已恢复',
|
||||
IGNORED: '已忽略',
|
||||
};
|
||||
|
||||
const alarmLevelLabels: Record<string, string> = {
|
||||
LOW: '低',
|
||||
MEDIUM: '中',
|
||||
HIGH: '高',
|
||||
CRITICAL: '严重',
|
||||
};
|
||||
|
||||
const userStatusLabels: Record<string, string> = {
|
||||
ACTIVE: '启用',
|
||||
DISABLED: '停用',
|
||||
};
|
||||
|
||||
export function onlineStatusText(value?: string) {
|
||||
return value ? onlineStatusLabels[value] ?? value : '-';
|
||||
}
|
||||
|
||||
export function lampStatusText(value?: string) {
|
||||
return value ? lampStatusLabels[value] ?? value : '-';
|
||||
}
|
||||
|
||||
export function alarmStatusText(value?: string) {
|
||||
return value ? alarmStatusLabels[value] ?? value : '-';
|
||||
}
|
||||
|
||||
export function alarmLevelText(value?: string) {
|
||||
return value ? alarmLevelLabels[value] ?? value : '-';
|
||||
}
|
||||
|
||||
export function userStatusText(value?: string) {
|
||||
return value ? userStatusLabels[value] ?? value : '-';
|
||||
}
|
||||
@ -0,0 +1,232 @@
|
||||
<template>
|
||||
<div class="login">
|
||||
<div class="glow glow-one"></div>
|
||||
<div class="glow glow-two"></div>
|
||||
<section class="hero">
|
||||
<div class="badge">Urban Lighting Management Platform</div>
|
||||
<h1>城市照明综合网管平台<br />多租户运营中心</h1>
|
||||
<p>面向全国、省、市、区县的路灯资产监控、报警处理与区域权限管控。</p>
|
||||
<div class="stats">
|
||||
<div>
|
||||
<strong>4</strong>
|
||||
<span>级区域权限</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>24h</strong>
|
||||
<span>报警监控</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>API</strong>
|
||||
<span>外部接入</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<el-card class="login-card" shadow="never">
|
||||
<div class="card-title">
|
||||
<span>租户登录</span>
|
||||
<small>Tenant Console</small>
|
||||
</div>
|
||||
<el-form :model="form" label-position="top" @keyup.enter="submit">
|
||||
<el-form-item label="租户编码">
|
||||
<el-input v-model="form.tenantCode" size="large" placeholder="请输入租户编码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="账号">
|
||||
<el-input v-model="form.username" size="large" placeholder="请输入账号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码">
|
||||
<el-input v-model="form.password" size="large" type="password" show-password placeholder="请输入密码" />
|
||||
</el-form-item>
|
||||
<el-button type="primary" :loading="loading" class="login-button" size="large" @click="submit">进入运营中心</el-button>
|
||||
</el-form>
|
||||
<div class="hint">请输入租户编码、账号和密码登录。</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { firstPermittedPath } from '../router';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const loading = ref(false);
|
||||
// const form = reactive({ tenantCode: 'default', username: 'admin', password: 'admin123456' });
|
||||
const form = reactive({ tenantCode: '', username: '', password: '' });
|
||||
|
||||
async function submit() {
|
||||
loading.value = true;
|
||||
try {
|
||||
await authStore.login(form.tenantCode, form.username, form.password);
|
||||
await router.push(firstPermittedPath(authStore.permissions));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login {
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 460px;
|
||||
gap: 72px;
|
||||
align-items: center;
|
||||
padding: 0 9vw;
|
||||
background:
|
||||
linear-gradient(rgba(10, 18, 36, 0.82) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(10, 18, 36, 0.82) 1px, transparent 1px),
|
||||
radial-gradient(circle at 18% 20%, rgba(56, 189, 248, 0.35), transparent 30%),
|
||||
radial-gradient(circle at 80% 10%, rgba(99, 102, 241, 0.35), transparent 28%),
|
||||
#030712;
|
||||
background-size: 48px 48px, 48px 48px, auto, auto, auto;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.glow {
|
||||
position: absolute;
|
||||
width: 420px;
|
||||
height: 420px;
|
||||
border-radius: 999px;
|
||||
filter: blur(40px);
|
||||
opacity: 0.34;
|
||||
}
|
||||
|
||||
.glow-one {
|
||||
right: 8%;
|
||||
top: 12%;
|
||||
background: #0ea5e9;
|
||||
}
|
||||
|
||||
.glow-two {
|
||||
left: 12%;
|
||||
bottom: 8%;
|
||||
background: #6366f1;
|
||||
}
|
||||
|
||||
.hero,
|
||||
.login-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
padding: 8px 14px;
|
||||
border: 1px solid rgba(125, 211, 252, 0.45);
|
||||
border-radius: 999px;
|
||||
color: #bae6fd;
|
||||
background: rgba(14, 165, 233, 0.12);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 24px 0 18px;
|
||||
font-size: clamp(42px, 6vw, 72px);
|
||||
line-height: 1.06;
|
||||
letter-spacing: -0.05em;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
max-width: 640px;
|
||||
margin: 0;
|
||||
color: #cbd5e1;
|
||||
font-size: 18px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 140px));
|
||||
gap: 16px;
|
||||
margin-top: 44px;
|
||||
}
|
||||
|
||||
.stats div {
|
||||
padding: 18px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
border-radius: 18px;
|
||||
background: rgba(15, 23, 42, 0.58);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.stats strong {
|
||||
display: block;
|
||||
font-size: 28px;
|
||||
color: #67e8f9;
|
||||
}
|
||||
|
||||
.stats span {
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||
border-radius: 28px;
|
||||
background: rgba(15, 23, 42, 0.78);
|
||||
box-shadow: 0 24px 90px rgba(0, 0, 0, 0.35);
|
||||
backdrop-filter: blur(24px);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 26px;
|
||||
color: #f8fafc;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.card-title small {
|
||||
color: #38bdf8;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
width: 100%;
|
||||
margin-top: 6px;
|
||||
background: linear-gradient(135deg, #0ea5e9, #6366f1);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: 16px;
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
:deep(.el-form-item__label) {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
:deep(.el-input__wrapper) {
|
||||
background: rgba(2, 6, 23, 0.68);
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:deep(.el-input__inner) {
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.login {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 32px;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.stats {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<section class="page-hero">
|
||||
<div>
|
||||
<h1>路灯资产</h1>
|
||||
<p>统一管理路灯设备、在线状态、设备状态和未处理报警。</p>
|
||||
</div>
|
||||
<div class="asset-stats">
|
||||
<span>总数 {{ total }}</span>
|
||||
<span>在线 {{ onlineTotal }}</span>
|
||||
<span>异常 {{ abnormalTotal }}</span>
|
||||
</div>
|
||||
</section>
|
||||
<div class="toolbar">
|
||||
<div class="filter-group">
|
||||
<el-input v-model="keyword" placeholder="搜索编号/名称" style="width: 240px" clearable @change="handleFilterChange" />
|
||||
<el-tree-select
|
||||
v-model="selectedRegionId"
|
||||
:data="regions"
|
||||
:props="{ label: 'name', children: 'children' }"
|
||||
node-key="id"
|
||||
check-strictly
|
||||
clearable
|
||||
placeholder="按区域筛选"
|
||||
style="width: 240px"
|
||||
@change="handleFilterChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<el-card class="modern-card" shadow="never">
|
||||
<el-table :data="lamps" v-loading="loading" class="modern-table">
|
||||
<el-table-column prop="code" label="编号" min-width="140" />
|
||||
<el-table-column prop="name" label="名称" min-width="160" />
|
||||
<el-table-column prop="region.name" label="区域" />
|
||||
<el-table-column label="在线状态">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.onlineStatus === 'ONLINE' ? 'success' : 'info'" effect="light">
|
||||
{{ onlineStatusText(row.onlineStatus) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="设备状态">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'NORMAL' ? 'success' : row.status === 'MAINTENANCE' ? 'warning' : 'danger'">
|
||||
{{ lampStatusText(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="alarms.length" label="未处理报警" width="130" />
|
||||
</el-table>
|
||||
<el-pagination
|
||||
class="pagination"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
:total="total"
|
||||
:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
@current-change="handlePageChange"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</el-card>
|
||||
<el-dialog v-model="dialogVisible" title="新增路灯" width="520px">
|
||||
<el-form :model="form" label-width="90px">
|
||||
<el-form-item label="编号"><el-input v-model="form.code" /></el-form-item>
|
||||
<el-form-item label="名称"><el-input v-model="form.name" /></el-form-item>
|
||||
<el-form-item label="区域ID"><el-input v-model="form.regionId" placeholder="可在区域管理复制区县 ID" /></el-form-item>
|
||||
<el-form-item label="经度"><el-input v-model="form.longitude" /></el-form-item>
|
||||
<el-form-item label="纬度"><el-input v-model="form.latitude" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="create">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { lampApi, regionApi } from '../../api/modules';
|
||||
import { lampStatusText, onlineStatusText } from '../../utils/labels';
|
||||
|
||||
const lamps = ref<any[]>([]);
|
||||
const regions = ref<any[]>([]);
|
||||
const keyword = ref('');
|
||||
const selectedRegionId = ref('');
|
||||
const loading = ref(false);
|
||||
const dialogVisible = ref(false);
|
||||
const form = reactive({ code: '', name: '', regionId: '', longitude: '', latitude: '' });
|
||||
const page = ref(1);
|
||||
const pageSize = ref(20);
|
||||
const total = ref(0);
|
||||
const onlineTotal = ref(0);
|
||||
const abnormalTotal = ref(0);
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await lampApi.list({
|
||||
keyword: keyword.value,
|
||||
regionId: selectedRegionId.value,
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
excludeTerminal: '1',
|
||||
});
|
||||
lamps.value = result.items ?? [];
|
||||
total.value = result.total ?? 0;
|
||||
onlineTotal.value = result.onlineTotal ?? 0;
|
||||
abnormalTotal.value = result.abnormalTotal ?? 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageChange(value: number) {
|
||||
page.value = value;
|
||||
void load();
|
||||
}
|
||||
|
||||
function handleFilterChange() {
|
||||
page.value = 1;
|
||||
void load();
|
||||
}
|
||||
|
||||
function handleSizeChange(value: number) {
|
||||
pageSize.value = value;
|
||||
page.value = 1;
|
||||
void load();
|
||||
}
|
||||
|
||||
async function create() {
|
||||
await lampApi.create({ ...form, longitude: Number(form.longitude), latitude: Number(form.latitude) });
|
||||
dialogVisible.value = false;
|
||||
await load();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
regions.value = (await regionApi.tree()) as any[];
|
||||
await load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.asset-stats {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.asset-stats span {
|
||||
padding: 10px 14px;
|
||||
border-radius: 999px;
|
||||
color: #1d4ed8;
|
||||
background: #eff6ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<section class="page-hero">
|
||||
<div>
|
||||
<h1>用户管理</h1>
|
||||
<p>维护租户下的用户、角色和区域数据权限范围。</p>
|
||||
</div>
|
||||
<el-tag type="primary" effect="light">用户 {{ users.length }}</el-tag>
|
||||
</section>
|
||||
<div class="toolbar">
|
||||
<el-input v-model="keyword" placeholder="搜索用户" style="width: 280px" clearable @change="handleFilterChange" />
|
||||
<el-button type="primary" round @click="dialogVisible = true">新增用户</el-button>
|
||||
</div>
|
||||
<el-card class="modern-card" shadow="never">
|
||||
<el-table :data="users" v-loading="loading" class="modern-table">
|
||||
<el-table-column prop="username" label="账号" />
|
||||
<el-table-column prop="name" label="姓名" />
|
||||
<el-table-column prop="phone" label="手机号" />
|
||||
<el-table-column label="状态">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'">{{ userStatusText(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="角色">
|
||||
<template #default="{ row }">{{ roleNames(row) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination
|
||||
class="pagination"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
:total="total"
|
||||
:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
@current-change="handlePageChange"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</el-card>
|
||||
<el-dialog v-model="dialogVisible" title="新增用户" width="520px">
|
||||
<el-form :model="form" label-width="90px">
|
||||
<el-form-item label="账号"><el-input v-model="form.username" /></el-form-item>
|
||||
<el-form-item label="姓名"><el-input v-model="form.name" /></el-form-item>
|
||||
<el-form-item label="密码"><el-input v-model="form.password" /></el-form-item>
|
||||
<el-form-item label="角色ID"><el-input v-model="roleIdsText" placeholder="多个用逗号分隔" /></el-form-item>
|
||||
<el-form-item label="区域ID"><el-input v-model="regionIdsText" placeholder="多个用逗号分隔" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="create">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { systemApi } from '../../api/modules';
|
||||
import { userStatusText } from '../../utils/labels';
|
||||
|
||||
const users = ref<any[]>([]);
|
||||
const keyword = ref('');
|
||||
const loading = ref(false);
|
||||
const dialogVisible = ref(false);
|
||||
const roleIdsText = ref('');
|
||||
const regionIdsText = ref('');
|
||||
const form = reactive({ username: '', name: '', password: '' });
|
||||
const page = ref(1);
|
||||
const pageSize = ref(20);
|
||||
const total = ref(0);
|
||||
|
||||
function roleNames(row: any) {
|
||||
return row.roles?.map((item: any) => item.role.name).join('、') || '-';
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await systemApi.users({
|
||||
keyword: keyword.value,
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
});
|
||||
users.value = result.items ?? [];
|
||||
total.value = result.total ?? 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleFilterChange() {
|
||||
page.value = 1;
|
||||
void load();
|
||||
}
|
||||
|
||||
function handlePageChange(value: number) {
|
||||
page.value = value;
|
||||
void load();
|
||||
}
|
||||
|
||||
function handleSizeChange(value: number) {
|
||||
pageSize.value = value;
|
||||
page.value = 1;
|
||||
void load();
|
||||
}
|
||||
|
||||
async function create() {
|
||||
await systemApi.createUser({
|
||||
...form,
|
||||
roleIds: roleIdsText.value.split(',').map((item) => item.trim()).filter(Boolean),
|
||||
regionIds: regionIdsText.value.split(',').map((item) => item.trim()).filter(Boolean),
|
||||
});
|
||||
dialogVisible.value = false;
|
||||
await load();
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["ES2021", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": []
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in new issue