Lufaneng 4 months ago
commit e530b822a7

65
.gitignore vendored

@ -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,27 @@
DATABASE_URL="mysql://app:app123456@localhost:3306/app_db"
# 仅当本机执行 prisma migrate dev 且数据库用户无法自动创建影子库时,再在 schema 中配置 shadowDatabaseUrl(见 server-deployment 文档)
REDIS_HOST="localhost"
REDIS_PORT="6379"
JWT_SECRET="change-me-in-production"
JWT_EXPIRES_IN="2h"
REFRESH_TOKEN_EXPIRES_IN="7d"
PORT="3000"
# 多个智慧照明平台根地址:写在「同一变量」里用英文或中文逗号分隔,会依次登录;每个地址都会同步 ILC 单灯 + RTU 监控终端(见 EXTERNAL_LIGHTING_SYNC_RTU)。
# 不要另起 EXTERNAL_LIGHTING_BASE_URL_2 等变量——后端只读这一项。
EXTERNAL_LIGHTING_BASE_URL="http://36.139.220.121:8090"
# 双平台示例(与上二选一):EXTERNAL_LIGHTING_BASE_URL="http://36.139.220.121:8090,http://171.208.94.46:7070"
EXTERNAL_LIGHTING_PLATFORM_CODE="lighting-main"
# 用户名、密码、租户名:可只写一段(所有平台共用);多平台且各平台凭证不同时,用逗号分段,段数须与 BASE_URL 一致
EXTERNAL_LIGHTING_USERNAME="admin"
EXTERNAL_LIGHTING_PASSWORD="123456"
# 多平台时可为每段一个租户名,逗号对齐;留空表示不传 tenant_name
EXTERNAL_LIGHTING_TENANT_NAME=""
EXTERNAL_LIGHTING_ILC_LIST_METHOD="/lightm/ilc/list"
EXTERNAL_LIGHTING_RTU_LIST_METHOD="/lightm/rtu/list"
EXTERNAL_LIGHTING_SYNC_RTU="true"
# 目前只有 7070 平台提供故障列表,同步时只拉近 N 天并按 RTU commUid 匹配监控终端
EXTERNAL_LIGHTING_FAULT_BASE_URL="http://171.208.94.46:7070"
EXTERNAL_LIGHTING_FAULT_LIST_METHOD="/m9z/fault/list"
EXTERNAL_LIGHTING_SYNC_FAULTS="true"
EXTERNAL_LIGHTING_FAULT_SYNC_DAYS="7"
EXTERNAL_LIGHTING_DEFAULT_REGION_CODE="321084"

@ -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,55 @@
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AlarmIngestionModule } from './modules/alarm-ingestion/alarm-ingestion.module';
import { LampIngestionModule } from './modules/lamp-ingestion/lamp-ingestion.module';
import { AlarmsModule } from './modules/alarms/alarms.module';
import { AuthModule } from './modules/auth/auth.module';
import { CommonModule } from './common/common.module';
import { DashboardModule } from './modules/dashboard/dashboard.module';
import { LampsModule } from './modules/lamps/lamps.module';
import { MapModule } from './modules/map/map.module';
import { PrismaModule } from './modules/prisma/prisma.module';
import { RedisModule } from './modules/redis/redis.module';
import { RegionsModule } from './modules/regions/regions.module';
import { RolesModule } from './modules/roles/roles.module';
import { TenantsModule } from './modules/tenants/tenants.module';
import { UsersModule } from './modules/users/users.module';
/** 与启动时 cwd 无关:先根据入口文件位置找 backend/.env,再回退到 cwd 下常见路径 */
function resolveEnvFilePath(): string | undefined {
const candidates = [
join(__dirname, '..', '.env'),
join(__dirname, '..', '..', '.env'),
join(process.cwd(), 'backend', '.env'),
join(process.cwd(), '.env'),
];
return candidates.find((p) => existsSync(p));
}
const resolvedEnvFile = resolveEnvFilePath();
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: resolvedEnvFile ? [resolvedEnvFile] : ['.env'],
}),
PrismaModule,
RedisModule,
AuthModule,
CommonModule,
TenantsModule,
UsersModule,
RolesModule,
RegionsModule,
LampsModule,
AlarmsModule,
DashboardModule,
MapModule,
AlarmIngestionModule,
LampIngestionModule,
],
})
export class AppModule {}

@ -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,198 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../modules/prisma/prisma.service';
import { AuthUser } from './current-user.decorator';
const inferredCoordinateRegions = [
{
// 第二个平台大量终端坐标位于四川雅安汉源一带,外部平台未提供行政区时按坐标归属到这里。
code: '511823',
name: '汉源县',
level: 3,
longitude: 102.650417,
latitude: 29.344275,
bounds: { minLng: 97.0, maxLng: 108.6, minLat: 26.0, maxLat: 34.6 },
parents: [
{ code: '510000', name: '四川省', level: 1, longitude: 104.075809, latitude: 30.651239 },
{ code: '511800', name: '雅安市', level: 2, longitude: 103.042375, latitude: 30.010602 },
],
},
] as const;
@Injectable()
export class RegionScopeService {
constructor(private readonly prisma: PrismaService) {}
async expandRegionIds(regionIds: string[]) {
if (!regionIds.length) {
return [];
}
const result = new Set(regionIds);
let frontier = regionIds;
while (frontier.length) {
const children = await this.prisma.region.findMany({
where: { parentId: { in: frontier } },
select: { id: true },
});
frontier = children.map((child) => child.id).filter((id) => !result.has(id));
frontier.forEach((id) => result.add(id));
}
return [...result];
}
async scopedRegionIds(user: AuthUser, requestedRegionId?: string) {
const allowedRegionIds = await this.expandRegionIds(user.regionIds ?? []);
if (!requestedRegionId) {
return allowedRegionIds;
}
const requestedRegionIds = await this.expandRegionIds([requestedRegionId]);
if (!allowedRegionIds.length) {
return requestedRegionIds;
}
return requestedRegionIds.filter((regionId) => allowedRegionIds.includes(regionId));
}
async findNearestRegionId(longitude: number, latitude: number, maxDistanceSquared = 1) {
const regions = await this.prisma.region.findMany({
where: { longitude: { not: null }, latitude: { not: null }, level: 3 },
select: { id: true, longitude: true, latitude: true },
});
if (!regions.length) {
return undefined;
}
const nearest = regions
.map((region) => {
const lng = Number(region.longitude);
const lat = Number(region.latitude);
return {
id: region.id,
distance: Math.abs(lng - longitude) ** 2 + Math.abs(lat - latitude) ** 2,
};
})
.sort((a, b) => a.distance - b.distance)[0];
if (nearest && nearest.distance <= maxDistanceSquared) {
return nearest.id;
}
return this.inferRegionByCoordinate(longitude, latitude);
}
private async inferRegionByCoordinate(longitude: number, latitude: number) {
const inferred = inferredCoordinateRegions.find(
(region) =>
longitude >= region.bounds.minLng &&
longitude <= region.bounds.maxLng &&
latitude >= region.bounds.minLat &&
latitude <= region.bounds.maxLat,
);
if (!inferred) {
return undefined;
}
const china = await this.prisma.region.upsert({
where: { code: '100000' },
update: {},
create: { code: '100000', name: '全国', level: 0, longitude: 104.195397, latitude: 35.86166 },
});
let parentId = china.id;
for (const parent of inferred.parents) {
const region = await this.prisma.region.upsert({
where: { code: parent.code },
update: {
name: parent.name,
level: parent.level,
parentId,
longitude: parent.longitude,
latitude: parent.latitude,
},
create: {
code: parent.code,
name: parent.name,
level: parent.level,
parentId,
longitude: parent.longitude,
latitude: parent.latitude,
},
});
parentId = region.id;
}
const region = await this.prisma.region.upsert({
where: { code: inferred.code },
update: {
name: inferred.name,
level: inferred.level,
parentId,
longitude: inferred.longitude,
latitude: inferred.latitude,
},
create: {
code: inferred.code,
name: inferred.name,
level: inferred.level,
parentId,
longitude: inferred.longitude,
latitude: inferred.latitude,
},
});
return region.id;
}
async lampWhere(user: AuthUser, extra: Prisma.StreetLampWhereInput = {}, requestedRegionId?: string) {
const regionIds = await this.scopedRegionIds(user, requestedRegionId);
return {
...extra,
...(regionIds.length ? { regionId: { in: regionIds } } : {}),
...(user.tenantId ? { tenantId: user.tenantId } : {}),
};
}
async alarmWhere(user: AuthUser, extra: Prisma.AlarmWhereInput = {}, requestedRegionId?: string) {
const regionIds = await this.scopedRegionIds(user, requestedRegionId);
return {
...extra,
...(regionIds.length ? { regionId: { in: regionIds } } : {}),
...(user.tenantId ? { tenantId: user.tenantId } : {}),
};
}
/**
* 外部推送场景:租户登录仅能操作本租户路灯;若配置了区域管辖则路灯须在允许区域内。
* 平台登录(无 tenantId)不校验。
*/
async assertLampRowWritableByUser(
user: AuthUser,
lamp: { tenantId: string | null; regionId: string },
): Promise<void> {
if (!user.tenantId) {
return;
}
if (lamp.tenantId !== user.tenantId) {
throw new NotFoundException('路灯设备不存在');
}
const allowed = await this.scopedRegionIds(user);
if (allowed.length && !allowed.includes(lamp.regionId)) {
throw new NotFoundException('路灯设备不存在');
}
}
/** 租户用户在指定 region 下创建或迁入路灯时的区域权限(平台用户跳过)。 */
async assertRegionWritableByTenantUser(user: AuthUser, regionId: string): Promise<void> {
if (!user.tenantId) {
return;
}
const allowed = await this.scopedRegionIds(user);
if (allowed.length && !allowed.includes(regionId)) {
throw new BadRequestException('无权在该区域创建或迁移路灯');
}
}
}

@ -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,965 @@
import { BadGatewayException, BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Prisma } from '@prisma/client';
import { AuthUser } from '../../common/current-user.decorator';
import { RegionScopeService } from '../../common/region-scope.service';
import { PrismaService } from '../prisma/prisma.service';
interface JsonRpcResponse<T> {
jsonrpc: string;
result: T | null;
error?: {
message: string;
code: number;
};
}
interface LightingLoginResult {
token: string;
}
interface ExternalIlcItem {
id: number;
ilcName?: string;
commUid?: string;
rtuId?: number;
rtuCommUid?: string;
workState?: number;
branchCount?: number;
description?: string;
lampPost?: string;
positionX?: number;
positionY?: number;
firm?: string;
setPower?: string;
set_power?: string;
isPowerOn?: boolean;
isOnline?: boolean;
lamp_post?: string;
lastOnlineTime?: string;
}
interface GetIlcListResult {
rows: ExternalIlcItem[];
count: number;
}
interface ExternalFaultItem {
id: number;
comm_uid?: string;
commUid?: string;
loop_idx?: number;
loopIdx?: number;
voltage?: number;
fault_msg?: string;
faultMsg?: string;
fault_code?: string;
faultCode?: string;
fault_time?: string;
faultTime?: string;
operate_status?: number;
operateStatus?: number;
}
interface GetFaultListResult {
rows: ExternalFaultItem[];
count: number;
}
/** 监控终端 RTU,平台实际 JSON-RPC 方法为 /lightm/rtu/list */
interface ExternalRtuItem {
id: number;
rtuName?: string;
commUid?: string;
groupId?: number;
description?: string;
workState?: number;
uimNumber?: string;
lampPost?: string;
lamp_post?: string;
positionX?: number;
positionY?: number;
isOnline?: boolean;
csq?: number;
lastOnlineTime?: string;
lastSyncTime?: string;
}
export interface SyncPageStats {
total: number;
created: number;
updated: number;
skipped: number;
}
@Injectable()
export class LampsService {
constructor(
private readonly prisma: PrismaService,
private readonly regionScope: RegionScopeService,
private readonly configService: ConfigService,
) {}
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 onlyTerminal =
query.onlyTerminal === '1' ||
query.onlyTerminal === 'true' ||
query.onlyTerminal === true ||
query.onlyTerminal === 1;
const excludeTerminal =
query.excludeTerminal === '1' ||
query.excludeTerminal === 'true' ||
query.excludeTerminal === true ||
query.excludeTerminal === 1;
const where = await this.regionScope.lampWhere(user, {
...(onlyTerminal ? { deviceType: '监控终端' } : excludeTerminal ? { deviceType: { not: '监控终端' } } : {}),
...(query.keyword ? { OR: [{ code: { contains: query.keyword } }, { name: { contains: query.keyword } }] } : {}),
...(query.status ? { status: query.status } : {}),
...(query.onlineStatus ? { onlineStatus: query.onlineStatus } : {}),
}, query.regionId);
const [items, total, onlineTotal, abnormalTotal] = await Promise.all([
this.prisma.streetLamp.findMany({
where,
include: { region: true, alarms: { where: { status: { in: ['OPEN', 'PROCESSING'] } } } },
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.streetLamp.count({ where }),
this.prisma.streetLamp.count({
where: { AND: [where, { onlineStatus: 'ONLINE' }] } as Prisma.StreetLampWhereInput,
}),
this.prisma.streetLamp.count({
where: { AND: [where, { status: { not: 'NORMAL' } }] } as Prisma.StreetLampWhereInput,
}),
]);
return { items, total, page, pageSize, onlineTotal, abnormalTotal };
}
async create(user: AuthUser, dto: any) {
const longitude = Number(dto.longitude);
const latitude = Number(dto.latitude);
const regionId = dto.regionId ?? (await this.regionScope.findNearestRegionId(longitude, latitude));
if (!regionId) {
throw new BadRequestException('无法根据经纬度匹配所属区域');
}
return this.prisma.streetLamp.create({
data: {
code: dto.code,
name: dto.name,
tenantId: user.tenantId,
regionId,
address: dto.address,
longitude,
latitude,
deviceType: this.normalizeDeviceType(dto.deviceType) ?? '单灯',
status: dto.status,
onlineStatus: dto.onlineStatus,
isPowerOn: dto.isPowerOn,
lastReportedAt: dto.lastReportedAt ? new Date(dto.lastReportedAt) : undefined,
},
});
}
async update(user: AuthUser, id: string, dto: any) {
const existing = await this.prisma.streetLamp.findFirst({ where: await this.regionScope.lampWhere(user, { id }) });
if (!existing) {
throw new NotFoundException('路灯不存在或无权限访问');
}
const longitude = dto.longitude === undefined ? undefined : Number(dto.longitude);
const latitude = dto.latitude === undefined ? undefined : Number(dto.latitude);
const regionId =
dto.regionId ??
(longitude !== undefined && latitude !== undefined
? await this.regionScope.findNearestRegionId(longitude, latitude)
: undefined);
return this.prisma.streetLamp.update({
where: { id },
data: {
code: dto.code,
name: dto.name,
regionId,
address: dto.address,
longitude,
latitude,
deviceType: this.normalizeDeviceType(dto.deviceType),
status: dto.status,
onlineStatus: dto.onlineStatus,
isPowerOn: dto.isPowerOn,
lastReportedAt: dto.lastReportedAt ? new Date(dto.lastReportedAt) : undefined,
},
});
}
async remove(user: AuthUser, id: string) {
const existing = await this.prisma.streetLamp.findFirst({ where: await this.regionScope.lampWhere(user, { id }) });
if (!existing) {
throw new NotFoundException('路灯不存在或无权限访问');
}
return this.prisma.streetLamp.delete({ where: { id } });
}
async syncExternalIlcList(user: AuthUser, options: any = {}) {
const baseUrlRaw = this.configService.get<string>('EXTERNAL_LIGHTING_BASE_URL');
const usernameRaw = this.configService.get<string>('EXTERNAL_LIGHTING_USERNAME');
const passwordRaw = this.configService.get<string>('EXTERNAL_LIGHTING_PASSWORD');
const tenantNameRaw = this.configService.get<string>('EXTERNAL_LIGHTING_TENANT_NAME');
const ilcListMethod = this.configService.get<string>('EXTERNAL_LIGHTING_ILC_LIST_METHOD') ?? '/device/getIlcList';
const rtuListMethod = this.configService.get<string>('EXTERNAL_LIGHTING_RTU_LIST_METHOD') ?? '/lightm/rtu/list';
const faultListMethod = this.configService.get<string>('EXTERNAL_LIGHTING_FAULT_LIST_METHOD') ?? '/m9z/fault/list';
const faultSyncBaseUrl = (this.configService.get<string>('EXTERNAL_LIGHTING_FAULT_BASE_URL') ?? 'http://171.208.94.46:7070')
.trim()
.replace(/\/$/, '');
const faultSyncDays = Math.max(
Number(options.faultDays ?? this.configService.get<string>('EXTERNAL_LIGHTING_FAULT_SYNC_DAYS') ?? 7),
1,
);
const syncAssets =
options.syncAssets === undefined ||
options.syncAssets === true ||
options.syncAssets === 'true' ||
options.syncAssets === 1 ||
options.syncAssets === '1';
const syncRtu =
this.configService.get<string>('EXTERNAL_LIGHTING_SYNC_RTU') !== '0' &&
this.configService.get<string>('EXTERNAL_LIGHTING_SYNC_RTU') !== 'false';
const syncFaults =
options.syncFaults === undefined
? this.configService.get<string>('EXTERNAL_LIGHTING_SYNC_FAULTS') !== '0' &&
this.configService.get<string>('EXTERNAL_LIGHTING_SYNC_FAULTS') !== 'false'
: options.syncFaults === true ||
options.syncFaults === 'true' ||
options.syncFaults === 1 ||
options.syncFaults === '1';
const platformCode = this.configService.get<string>('EXTERNAL_LIGHTING_PLATFORM_CODE') ?? 'lighting-main';
const defaultRegionCode = this.configService.get<string>('EXTERNAL_LIGHTING_DEFAULT_REGION_CODE') ?? '110101';
const baseUrls = this.parseExternalLightingBaseUrls(baseUrlRaw);
if (!baseUrls.length) {
throw new BadRequestException('未配置智慧照明平台地址');
}
if (!syncAssets && !syncFaults) {
throw new BadRequestException('请至少选择资产同步或报警同步');
}
if (!usernameRaw?.trim() || !passwordRaw?.trim()) {
throw new BadRequestException('未配置智慧照明平台账号或密码');
}
const userParts = this.splitEnvCredentialList(usernameRaw);
const passParts = this.splitEnvCredentialList(passwordRaw);
const users = this.expandAlignedSegments(userParts, baseUrls.length, 'EXTERNAL_LIGHTING_USERNAME');
const passwords = this.expandAlignedSegments(passParts, baseUrls.length, 'EXTERNAL_LIGHTING_PASSWORD');
const tenantParts = tenantNameRaw?.trim() ? this.splitEnvCredentialList(tenantNameRaw) : [];
const tenantNames = tenantParts.length
? this.expandAlignedSegments(tenantParts, baseUrls.length, 'EXTERNAL_LIGHTING_TENANT_NAME')
: Array.from({ length: baseUrls.length }, () => undefined as string | undefined);
const requestedBaseUrls = this.parseExternalLightingBaseUrls(
Array.isArray(options.baseUrls) ? options.baseUrls.join(',') : options.baseUrls,
);
const requestedSet = new Set(requestedBaseUrls.map((baseUrl) => baseUrl.toLowerCase()));
const platformConfigs = baseUrls
.map((baseUrl, index) => ({
baseUrl,
user: users[index],
password: passwords[index],
tenantName: tenantNames[index],
}))
.filter((item) => !requestedSet.size || requestedSet.has(item.baseUrl.toLowerCase()));
if (!platformConfigs.length) {
throw new BadRequestException('未选择有效的智慧照明平台地址');
}
for (let i = 0; i < baseUrls.length; i++) {
if (!users[i] || !passwords[i]) {
throw new BadRequestException(`第 ${i + 1} 个智慧照明平台的用户名或密码为空`);
}
}
const region = await this.ensureSyncRegion(defaultRegionCode);
const multiBase = baseUrls.length > 1;
const targets: Array<{
baseUrl: string;
platformCode: string;
ilc?: SyncPageStats;
rtu?: SyncPageStats;
faults?: SyncPageStats & { days: number };
total?: number;
created?: number;
updated?: number;
skipped?: number;
error?: string;
}> = [];
let created = 0;
let updated = 0;
let skipped = 0;
let total = 0;
for (const platform of platformConfigs) {
const baseUrl = platform.baseUrl;
const effectivePlatform = this.disambiguatedPlatformCode(platformCode, baseUrl, multiBase);
const tenantName = platform.tenantName?.trim() || undefined;
try {
const token = await this.loginExternalPlatform(baseUrl, platform.user, platform.password, tenantName);
const ilcStats = syncAssets
? await this.pullIlcPagesAndUpsert(
baseUrl,
ilcListMethod,
token,
user.tenantId,
region.id,
effectivePlatform,
)
: ({ total: 0, created: 0, updated: 0, skipped: 0 } satisfies SyncPageStats);
const rtuStats =
syncAssets && syncRtu
? await this.pullRtuPagesAndUpsert(
baseUrl,
rtuListMethod,
token,
user.tenantId,
region.id,
effectivePlatform,
)
: ({ total: 0, created: 0, updated: 0, skipped: 0 } satisfies SyncPageStats);
const faultStats =
syncFaults && this.sameExternalBaseUrl(baseUrl, faultSyncBaseUrl)
? await this.pullRecentFaultPagesAndUpsert(
baseUrl,
rtuListMethod,
faultListMethod,
token,
user.tenantId,
effectivePlatform,
faultSyncDays,
)
: undefined;
total += ilcStats.total + rtuStats.total;
created += ilcStats.created + rtuStats.created;
updated += ilcStats.updated + rtuStats.updated;
skipped += ilcStats.skipped + rtuStats.skipped;
targets.push({
baseUrl,
platformCode: effectivePlatform,
ilc: ilcStats,
rtu: rtuStats,
...(faultStats ? { faults: { ...faultStats, days: faultSyncDays } } : {}),
total: ilcStats.total + rtuStats.total,
created: ilcStats.created + rtuStats.created,
updated: ilcStats.updated + rtuStats.updated,
skipped: ilcStats.skipped + rtuStats.skipped,
});
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
targets.push({ baseUrl, platformCode: effectivePlatform, error: message });
}
}
if (targets.length && targets.every((t) => t.error)) {
throw new BadGatewayException(
`所有智慧照明平台地址同步失败:${targets.map((t) => `${t.baseUrl}: ${t.error}`).join('; ')}`,
);
}
return {
baseUrlCount: platformConfigs.length,
configuredBaseUrlCount: baseUrls.length,
targets,
total,
created,
updated,
skipped,
platformCode,
regionCode: defaultRegionCode,
};
}
/** 按逗号拆分账号、密码、租户名等配置段(不去除空段,便于与地址个数严格对齐)。 */
private splitEnvCredentialList(raw: string): string[] {
return raw.split(/[,,]/).map((s) => s.trim());
}
/**
* 与 `EXTERNAL_LIGHTING_BASE_URL` 段数对齐:要么只配 1 段(全部地址共用),要么段数与地址个数相同。
*/
private expandAlignedSegments(parts: string[], targetCount: number, envLabel: string): string[] {
if (parts.length === targetCount) {
return parts;
}
if (parts.length === 1) {
return Array.from({ length: targetCount }, () => parts[0]);
}
throw new BadRequestException(
`${envLabel} 须为 1 段(所有平台共用)或与 EXTERNAL_LIGHTING_BASE_URL 相同段数(当前 ${parts.length} 段,地址 ${targetCount} 个)`,
);
}
/** 支持英文逗号或中文逗号分隔多个根地址,依次请求。 */
private parseExternalLightingBaseUrls(raw?: string): string[] {
if (!raw?.trim()) {
return [];
}
return raw
.split(/[,,]/)
.map((s) => s.trim().replace(/\/$/, ''))
.filter(Boolean);
}
/** 多平台时把主机名拼进业务前缀,避免不同平台上 ILC id 重复导致路灯 code 冲突。单地址时与旧行为一致。 */
private disambiguatedPlatformCode(platformCode: string, baseUrl: string, multi: boolean): string {
if (!multi) {
return platformCode;
}
let host = 'node';
try {
host =
new URL(baseUrl).hostname.replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'node';
} catch {
/* 非法 URL 时退回占位 */
}
return `${platformCode}-${host}`;
}
private sameExternalBaseUrl(a: string, b: string) {
const normalize = (value: string) => value.trim().replace(/\/$/, '').toLowerCase();
return normalize(a) === normalize(b);
}
private async pullIlcPagesAndUpsert(
baseUrl: string,
ilcListMethod: string,
token: string,
tenantId: string | null | undefined,
regionId: string,
effectivePlatformCode: string,
): Promise<{ total: number; created: number; updated: number; skipped: number }> {
const pageSize = 100;
let page = 1;
let total = 0;
let created = 0;
let updated = 0;
let skipped = 0;
while (true) {
const result = await this.callJsonRpc<GetIlcListResult>(baseUrl, ilcListMethod, {
params: { page, limit: pageSize },
session: token,
});
const rows = result.rows ?? [];
total = result.count ?? total;
if (!rows.length) {
break;
}
for (const item of rows) {
const syncResult = await this.upsertExternalIlc(item, tenantId, regionId, effectivePlatformCode);
if (syncResult === 'created') {
created += 1;
} else if (syncResult === 'updated') {
updated += 1;
} else {
skipped += 1;
}
}
if (page * pageSize >= total || rows.length < pageSize) {
break;
}
page += 1;
}
return { total, created, updated, skipped } satisfies SyncPageStats;
}
private async pullRtuPagesAndUpsert(
baseUrl: string,
rtuListMethod: string,
token: string,
tenantId: string | null | undefined,
regionId: string,
effectivePlatformCode: string,
): Promise<SyncPageStats> {
const pageSize = 100;
let page = 1;
let total = 0;
let created = 0;
let updated = 0;
let skipped = 0;
while (true) {
const result = await this.callJsonRpc<GetIlcListResult>(baseUrl, rtuListMethod, {
params: { page, limit: pageSize },
session: token,
});
const rows = (result.rows ?? []) as unknown as ExternalRtuItem[];
total = result.count ?? total;
if (!rows.length) {
break;
}
for (const item of rows) {
const syncResult = await this.upsertExternalRtu(item, tenantId, regionId, effectivePlatformCode);
if (syncResult === 'created') {
created += 1;
} else if (syncResult === 'updated') {
updated += 1;
} else {
skipped += 1;
}
}
if (page * pageSize >= total || rows.length < pageSize) {
break;
}
page += 1;
}
return { total, created, updated, skipped } satisfies SyncPageStats;
}
private async pullRecentFaultPagesAndUpsert(
baseUrl: string,
rtuListMethod: string,
faultListMethod: string,
token: string,
tenantId: string | null | undefined,
effectivePlatformCode: string,
days: number,
): Promise<SyncPageStats> {
const commUidToLamp = await this.buildRtuCommUidLampMap(baseUrl, rtuListMethod, token, effectivePlatformCode);
const pageSize = 200;
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
const first = await this.callJsonRpc<GetFaultListResult>(baseUrl, faultListMethod, {
params: { page: 1, limit: 1 },
session: token,
});
const pageCount = Math.max(Math.ceil((first.count ?? 0) / pageSize), 1);
let total = 0;
let created = 0;
let updated = 0;
let skipped = 0;
for (let page = pageCount; page >= 1; page -= 1) {
const result = await this.callJsonRpc<GetFaultListResult>(baseUrl, faultListMethod, {
params: { page, limit: pageSize },
session: token,
});
const rows = result.rows ?? [];
if (!rows.length) {
continue;
}
let pageHasRecent = false;
for (const item of rows) {
const occurredAt = this.parseExternalDate(item.fault_time ?? item.faultTime);
if (!occurredAt || occurredAt < cutoff) {
continue;
}
pageHasRecent = true;
total += 1;
const syncResult = await this.upsertExternalFault(item, tenantId, effectivePlatformCode, commUidToLamp);
if (syncResult === 'created') {
created += 1;
} else if (syncResult === 'updated') {
updated += 1;
} else {
skipped += 1;
}
}
if (!pageHasRecent && rows.every((item) => {
const occurredAt = this.parseExternalDate(item.fault_time ?? item.faultTime);
return occurredAt && occurredAt < cutoff;
})) {
break;
}
}
return { total, created, updated, skipped } satisfies SyncPageStats;
}
private async buildRtuCommUidLampMap(
baseUrl: string,
rtuListMethod: string,
token: string,
effectivePlatformCode: string,
) {
const pageSize = 500;
let page = 1;
const commUidToCode = new Map<string, string>();
while (true) {
const result = await this.callJsonRpc<GetIlcListResult>(baseUrl, rtuListMethod, {
params: { page, limit: pageSize },
session: token,
});
const rows = (result.rows ?? []) as unknown as ExternalRtuItem[];
for (const item of rows) {
if (item.commUid) {
commUidToCode.set(String(item.commUid), `${effectivePlatformCode}-RTU-${item.id}`);
}
}
if (!rows.length || page * pageSize >= (result.count ?? 0) || rows.length < pageSize) {
break;
}
page += 1;
}
const lamps = await this.prisma.streetLamp.findMany({
where: { code: { in: [...commUidToCode.values()] } },
select: { id: true, code: true, regionId: true, tenantId: true },
});
const codeToLamp = new Map(lamps.map((lamp) => [lamp.code, lamp]));
const commUidToLamp = new Map<string, (typeof lamps)[number]>();
for (const [commUid, code] of commUidToCode.entries()) {
const lamp = codeToLamp.get(code);
if (lamp) {
commUidToLamp.set(commUid, lamp);
}
}
return commUidToLamp;
}
private async upsertExternalFault(
item: ExternalFaultItem,
tenantId: string | null | undefined,
platformCode: string,
commUidToLamp: Map<string, { id: string; regionId: string; tenantId: string | null }>,
) {
const commUid = String(item.comm_uid ?? item.commUid ?? '').trim();
const occurredAt = this.parseExternalDate(item.fault_time ?? item.faultTime);
const lamp = commUid ? commUidToLamp.get(commUid) : undefined;
if (!lamp || !occurredAt) {
return 'skipped' as const;
}
const sourceAlarmId = `${platformCode}-FAULT-${item.id}`;
const existing = await this.prisma.alarm.findUnique({ where: { sourceAlarmId } });
const status = (item.operate_status ?? item.operateStatus) === 1 ? 'RESOLVED' : 'OPEN';
const faultMsg = item.fault_msg ?? item.faultMsg ?? '设备故障';
const faultCode = item.fault_code ?? item.faultCode ?? 'FAULT';
const description = [
`设备CUID:${commUid}`,
`回路:${item.loop_idx ?? item.loopIdx ?? '-'}`,
`电压:${item.voltage ?? '-'}`,
`故障码:${faultCode}`,
].join(';');
await this.prisma.alarm.upsert({
where: { sourceAlarmId },
update: {
status,
title: faultMsg,
description,
occurredAt,
recoveredAt: status === 'RESOLVED' ? new Date() : null,
},
create: {
sourceAlarmId,
tenantId: tenantId ?? lamp.tenantId,
lampId: lamp.id,
regionId: lamp.regionId,
type: String(faultCode),
title: faultMsg,
description,
level: this.mapFaultLevel(faultMsg),
status,
occurredAt,
recoveredAt: status === 'RESOLVED' ? new Date() : undefined,
events: { create: { eventType: 'SYNCED_FROM_LIGHTING_FAULT', payload: item as unknown as Prisma.InputJsonValue } },
},
});
return existing ? ('updated' as const) : ('created' as const);
}
private async loginExternalPlatform(baseUrl: string, username: string, password: string, tenantName?: string) {
const result = await this.callJsonRpc<LightingLoginResult>(baseUrl, '/account/login', {
params: {
...(tenantName ? { tenant_name: tenantName } : {}),
username,
password,
},
});
if (!result.token) {
throw new BadGatewayException('智慧照明平台登录成功但未返回 token');
}
return result.token;
}
private async ensureSyncRegion(regionCode: string) {
const existing = await this.prisma.region.findUnique({ where: { code: regionCode } });
if (existing) {
return existing;
}
const china = await this.prisma.region.upsert({
where: { code: '100000' },
update: {},
create: { code: '100000', name: '全国', level: 0, longitude: 104.195397, latitude: 35.86166 },
});
if (regionCode === '321084') {
const jiangsu = await this.prisma.region.upsert({
where: { code: '320000' },
update: {},
create: { code: '320000', name: '江苏省', level: 1, parentId: china.id, longitude: 118.763232, latitude: 32.061707 },
});
const yangzhou = await this.prisma.region.upsert({
where: { code: '321000' },
update: {},
create: { code: '321000', name: '扬州市', level: 2, parentId: jiangsu.id, longitude: 119.412966, latitude: 32.39421 },
});
return this.prisma.region.upsert({
where: { code: '321084' },
update: {},
create: { code: '321084', name: '高邮市', level: 3, parentId: yangzhou.id, longitude: 119.459161, latitude: 32.781659 },
});
}
return this.prisma.region.create({
data: {
code: regionCode,
name: `同步区域 ${regionCode}`,
level: 3,
parentId: china.id,
},
});
}
private async callJsonRpc<T>(
baseUrl: string,
method: string,
options: { params?: Record<string, unknown>; session?: string },
) {
const url = `${baseUrl.replace(/\/$/, '')}/jsonrpc`;
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
method,
params: options.params ?? {},
...(options.session ? { session: options.session } : {}),
}),
});
if (!response.ok) {
throw new BadGatewayException(`智慧照明平台请求失败:HTTP ${response.status}`);
}
const payload = (await response.json()) as JsonRpcResponse<T>;
if (payload.error && payload.error.code !== 200) {
throw new BadGatewayException(`智慧照明平台返回错误:${payload.error.message}`);
}
if (!payload.result) {
throw new BadGatewayException(`智慧照明平台 ${method} 未返回数据`);
}
return payload.result;
}
private async upsertExternalRtu(
item: ExternalRtuItem,
tenantId: string | null | undefined,
regionId: string,
platformCode: string,
) {
const code = `${platformCode}-RTU-${item.id}`;
const legacyCode = `RTU-${item.id}`;
const longitude = Number(item.positionX);
const latitude = Number(item.positionY);
if (!code || !Number.isFinite(longitude) || !Number.isFinite(latitude)) {
return 'skipped' as const;
}
const existing = await this.prisma.streetLamp.findFirst({ where: { code: { in: [code, legacyCode] } } });
const status = this.mapWorkState(item.workState);
const onlineStatus = (item.isOnline ?? Boolean(item.lastOnlineTime)) ? 'ONLINE' : 'OFFLINE';
const lastReportedAt = this.parseExternalDate(item.lastOnlineTime);
const address = item.description || item.lampPost || item.lamp_post || undefined;
const deviceType = '监控终端';
const matchedRegionId = (await this.regionScope.findNearestRegionId(longitude, latitude)) ?? regionId;
if (existing && existing.code !== code) {
await this.prisma.streetLamp.update({
where: { id: existing.id },
data: { code },
});
}
if (item.commUid && item.commUid !== code) {
await this.prisma.streetLamp.deleteMany({
where: {
code: { in: [item.commUid, legacyCode] },
id: existing ? { not: existing.id } : undefined,
deviceType: { startsWith: '监控终端' },
alarms: { none: {} },
},
});
}
await this.prisma.streetLamp.upsert({
where: { code },
update: {
name: item.rtuName || item.commUid || code,
regionId: matchedRegionId,
address,
longitude,
latitude,
deviceType,
status,
onlineStatus,
isPowerOn: null,
lastReportedAt,
},
create: {
code,
name: item.rtuName || item.commUid || code,
tenantId,
regionId: matchedRegionId,
address,
longitude,
latitude,
deviceType,
status,
onlineStatus,
isPowerOn: null,
lastReportedAt,
},
});
return existing ? ('updated' as const) : ('created' as const);
}
private async upsertExternalIlc(
item: ExternalIlcItem,
tenantId: string | null | undefined,
regionId: string,
platformCode: string,
) {
const code = `${platformCode}-ILC-${item.id}`;
const legacyCode = `ILC-${item.id}`;
const longitude = Number(item.positionX);
const latitude = Number(item.positionY);
if (!code || !Number.isFinite(longitude) || !Number.isFinite(latitude)) {
return 'skipped' as const;
}
const existing = await this.prisma.streetLamp.findFirst({ where: { code: { in: [code, legacyCode] } } });
const status = this.mapWorkState(item.workState);
const onlineStatus = (item.isOnline ?? Boolean(item.lastOnlineTime)) ? 'ONLINE' : 'OFFLINE';
const lastReportedAt = this.parseExternalDate(item.lastOnlineTime);
const address = item.description || item.lampPost || item.lamp_post || undefined;
const deviceType = '单灯';
const matchedRegionId = (await this.regionScope.findNearestRegionId(longitude, latitude)) ?? regionId;
if (existing && existing.code !== code) {
await this.prisma.streetLamp.update({
where: { id: existing.id },
data: { code },
});
}
if (item.commUid && item.commUid !== code) {
await this.prisma.streetLamp.deleteMany({
where: {
code: { in: [item.commUid, legacyCode] },
id: existing ? { not: existing.id } : undefined,
deviceType: { not: '监控终端' },
alarms: { none: {} },
},
});
}
await this.prisma.streetLamp.upsert({
where: { code },
update: {
name: item.ilcName || item.commUid || code,
regionId: matchedRegionId,
address,
longitude,
latitude,
deviceType,
status,
onlineStatus,
isPowerOn: item.isPowerOn,
lastReportedAt,
},
create: {
code,
name: item.ilcName || item.commUid || code,
tenantId,
regionId: matchedRegionId,
address,
longitude,
latitude,
deviceType,
status,
onlineStatus,
isPowerOn: item.isPowerOn,
lastReportedAt,
},
});
return existing ? ('updated' as const) : ('created' as const);
}
private mapWorkState(workState?: number) {
if (workState === 0) {
return 'NORMAL' as const;
}
if (workState === 2) {
return 'MAINTENANCE' as const;
}
if (workState === 1) {
return 'OFFLINE' as const;
}
return 'FAULT' as const;
}
private mapFaultLevel(message?: string) {
if (message && /电源|欠压|过压|断电|离线|短路|漏电/.test(message)) {
return 'HIGH' as const;
}
return 'MEDIUM' as const;
}
private normalizeDeviceType(value: unknown) {
if (value === undefined) {
return undefined;
}
return value === '监控终端' ? '监控终端' : '单灯';
}
private parseExternalDate(value?: string) {
if (!value) {
return undefined;
}
const date = new Date(value.replace(' ', 'T'));
return Number.isNaN(date.getTime()) ? undefined : date;
}
}

@ -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"]
}

@ -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>

2492
front/package-lock.json generated

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>,
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

13
front/src/env.d.ts vendored

@ -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,64 @@
let loadingPromise: Promise<any> | null = null;
export const BEIJING_CENTER: [number, number] = [116.407526, 39.90403];
export function getMapConfig() {
return {
key: import.meta.env.VITE_AMAP_KEY,
securityCode: import.meta.env.VITE_AMAP_SECURITY_CODE,
center: [
Number(import.meta.env.VITE_MAP_CENTER_LNG || 104.195397),
Number(import.meta.env.VITE_MAP_CENTER_LAT || 35.86166),
] as [number, number],
zoom: Number(import.meta.env.VITE_MAP_DEFAULT_ZOOM || 4),
};
}
export function getBrowserCoordinates() {
return new Promise<[number, number]>((resolve, reject) => {
if (!navigator.geolocation) {
reject(new Error('浏览器定位不可用'));
return;
}
navigator.geolocation.getCurrentPosition(
(position) => {
resolve([position.coords.longitude, position.coords.latitude]);
},
() => reject(new Error('浏览器定位失败')),
{
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 5 * 60 * 1000,
},
);
});
}
export function loadAmap() {
if (window.AMap) {
return Promise.resolve(window.AMap);
}
if (loadingPromise) {
return loadingPromise;
}
const { key, securityCode } = getMapConfig();
if (!key) {
return Promise.reject(new Error('未配置 VITE_AMAP_KEY,地图将使用列表降级展示'));
}
if (securityCode) {
window._AMapSecurityConfig = { securityJsCode: securityCode };
}
loadingPromise = new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = `https://webapi.amap.com/maps?v=2.0&key=${key}`;
script.async = true;
script.onload = () => resolve(window.AMap);
script.onerror = () => reject(new Error('高德地图脚本加载失败'));
document.head.appendChild(script);
});
return loadingPromise;
}

@ -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>

@ -0,0 +1,178 @@
<template>
<div class="page">
<section class="page-hero alarm-hero">
<div>
<h1>报警管理</h1>
<p>聚焦待处理、处理中和高等级报警,支持快速闭环处理。</p>
</div>
<div class="alarm-stats">
<span>当前 {{ alarms.length }}</span>
<span>高危 {{ highRiskCount }}</span>
</div>
</section>
<div class="toolbar">
<div class="filter-group">
<el-select v-model="status" placeholder="报警状态" clearable style="width: 180px" @change="handleFilterChange">
<el-option label="待处理" value="OPEN" />
<el-option label="处理中" value="PROCESSING" />
<el-option label="已恢复" value="RESOLVED" />
<el-option label="已忽略" value="IGNORED" />
</el-select>
<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="alarms" v-loading="loading" class="modern-table">
<el-table-column prop="title" label="报警标题" />
<el-table-column prop="lamp.name" label="设备" />
<el-table-column prop="lamp.deviceType" label="设备类型" width="120" />
<el-table-column prop="region.name" label="区域" />
<el-table-column label="等级" width="120">
<template #default="{ row }">
<el-tag :type="levelTag(row.level)" effect="dark">{{ alarmLevelText(row.level) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-tag :type="row.status === 'RESOLVED' ? 'success' : row.status === 'PROCESSING' ? 'warning' : 'danger'">
{{ alarmStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="发生时间" width="180">
<template #default="{ row }">{{ formatTime(row.occurredAt) }}</template>
</el-table-column>
<!-- <el-table-column label="操作" width="160">
<template #default="{ row }">
<el-button size="small" type="primary" @click="handleAlarm(row)">处理</el-button>
</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>
</div>
</template>
<script setup lang="ts">
import { ElMessageBox } from 'element-plus';
import { computed, onMounted, ref } from 'vue';
import { alarmApi, regionApi } from '../../api/modules';
import { alarmLevelText, alarmStatusText } from '../../utils/labels';
const alarms = ref<any[]>([]);
const regions = ref<any[]>([]);
const status = ref('');
const selectedRegionId = ref('');
const loading = ref(false);
const page = ref(1);
const pageSize = ref(20);
const total = ref(0);
const highRiskCount = computed(() => alarms.value.filter((alarm) => ['HIGH', 'CRITICAL'].includes(alarm.level)).length);
async function load() {
loading.value = true;
try {
const result = await alarmApi.list({
status: status.value,
regionId: selectedRegionId.value,
page: page.value,
pageSize: pageSize.value,
});
alarms.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 handleAlarm(row: any) {
const { value } = await ElMessageBox.prompt('请输入处理备注', '处理报警', {
confirmButtonText: '标记已恢复',
cancelButtonText: '取消',
});
await alarmApi.handle(row.id, { status: 'RESOLVED', action: 'RESOLVE', remark: value });
await load();
}
function levelTag(level: string) {
return level === 'CRITICAL' || level === 'HIGH' ? 'danger' : level === 'MEDIUM' ? 'warning' : 'info';
}
function formatTime(value: string) {
return value ? new Date(value).toLocaleString() : '-';
}
onMounted(async () => {
regions.value = (await regionApi.tree()) as any[];
await load();
});
</script>
<style scoped>
.alarm-hero {
background:
radial-gradient(circle at 90% 20%, rgba(255, 91, 76, 0.3), transparent 30%),
radial-gradient(circle at 18% 80%, rgba(35, 145, 255, 0.18), transparent 34%),
linear-gradient(180deg, rgba(8, 47, 98, 0.92), rgba(3, 24, 55, 0.9));
}
.alarm-stats {
display: flex;
gap: 10px;
}
.alarm-stats span {
padding: 10px 14px;
border: 1px solid rgba(255, 105, 77, 0.38);
border-radius: 6px;
color: #ffd9d2;
background: rgba(93, 23, 27, 0.48);
box-shadow: inset 0 0 16px rgba(255, 92, 76, 0.14);
font-weight: 600;
}
.filter-group {
display: flex;
gap: 10px;
}
.pagination {
margin-top: 16px;
justify-content: flex-end;
}
</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,600 @@
<template>
<div class="screen">
<header class="screen-header">
<div>
<div class="eyebrow">城市照明综合网管平台</div>
<h1>全国照明全屏监控</h1>
</div>
<div class="status">
<div class="device-filter">
<button :class="{ active: deviceTypeFilter === '' }" @click="setDeviceTypeFilter('')">全部</button>
<button :class="{ active: deviceTypeFilter === '单灯' }" @click="setDeviceTypeFilter('单灯')">单灯</button>
<button :class="{ active: deviceTypeFilter === '监控终端' }" @click="setDeviceTypeFilter('监控终端')">终端</button>
</div>
<el-tree-select
v-model="selectedRegionId"
:data="regions"
:props="{ label: 'name', children: 'children' }"
node-key="id"
check-strictly
clearable
placeholder="按区域筛选"
style="width: 220px"
@change="loadPoints"
/>
<span>点位 {{ points.length }}(单灯 {{ lampPointCount }} / 终端 {{ terminalPointCount }})</span>
<span>报警 {{ activeAlarmCount }}</span>
<el-button type="primary" plain @click="closePage">退出全屏</el-button>
</div>
</header>
<main class="screen-body">
<section class="map-panel">
<el-alert v-if="mapError" :title="mapError" type="warning" show-icon :closable="false" />
<div ref="mapEl" class="fullscreen-map"></div>
</section>
<aside class="side-panel">
<div class="panel-card">
<span class="label">在线路灯</span>
<strong>{{ onlineCount }}</strong>
</div>
<div class="panel-card danger">
<span class="label">未处理报警</span>
<strong>{{ activeAlarmCount }}</strong>
</div>
<div class="alarm-list">
<div class="list-title">
<h2>{{ selectedPoint ? '路灯状态详情' : '实时报警点位' }}</h2>
<el-button v-if="selectedPoint" link type="primary" @click="selectedPoint = undefined">返回列表</el-button>
</div>
<div v-if="selectedPoint" class="lamp-detail">
<div class="detail-name">{{ selectedPoint.name }}</div>
<div class="detail-code">{{ selectedPoint.code }}</div>
<dl>
<div><dt>所属区域</dt><dd>{{ selectedPoint.regionName }}</dd></div>
<div><dt>安装地址</dt><dd>{{ selectedPoint.address || '-' }}</dd></div>
<div><dt>设备类型</dt><dd>{{ selectedPoint.deviceType || '-' }}</dd></div>
<div><dt>在线状态</dt><dd>{{ onlineStatusText(selectedPoint.onlineStatus) }}</dd></div>
<div><dt>设备状态</dt><dd>{{ lampStatusText(selectedPoint.status) }}</dd></div>
<div><dt>最近上报</dt><dd>{{ formatTime(selectedPoint.lastReportedAt) }}</dd></div>
<div><dt>坐标</dt><dd>{{ selectedPoint.longitude }}, {{ selectedPoint.latitude }}</dd></div>
</dl>
<div class="detail-alarms">
<h3>当前报警 {{ selectedPoint.activeAlarmCount }}</h3>
<div v-for="alarm in selectedPoint.activeAlarms" :key="alarm.id" class="alarm-item">
<span>{{ alarm.title }}</span>
<small>{{ alarm.type }} / {{ alarmLevelText(alarm.level) }} / {{ formatTime(alarm.occurredAt) }}</small>
</div>
<el-empty v-if="!selectedPoint.activeAlarms?.length" description="暂无未处理报警" />
</div>
</div>
<template v-else>
<div v-for="point in alarmPoints" :key="point.id" class="alarm-item" @click="selectPoint(point)">
<span>{{ point.name }}</span>
<small>{{ point.regionName }} / {{ alarmLevelText(point.highestAlarmLevel) }}</small>
</div>
<el-empty v-if="!alarmPoints.length" description="暂无未处理报警" />
</template>
</div>
</aside>
</main>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { mapApi, regionApi } from '../../api/modules';
import { BEIJING_CENTER, getBrowserCoordinates, getMapConfig, loadAmap } from '../../utils/amap';
import { alarmLevelText, lampStatusText, onlineStatusText } from '../../utils/labels';
const route = useRoute();
const mapEl = ref<HTMLDivElement>();
const points = ref<any[]>([]);
const regions = ref<any[]>([]);
const selectedRegionId = ref(typeof route.query.regionId === 'string' ? route.query.regionId : '');
const deviceTypeFilter = ref<'单灯' | '监控终端' | ''>(
route.query.deviceType === '单灯' || route.query.deviceType === '监控终端' ? route.query.deviceType : '',
);
const mapError = ref('');
const selectedPoint = ref<any>();
const mapInstance = ref<any>();
const mapConfig = getMapConfig();
const markerOverlays = ref<any[]>([]);
const normalMarkerZoom = 12;
const onlineCount = computed(() => points.value.filter((point) => point.onlineStatus === 'ONLINE').length);
const terminalPointCount = computed(() => points.value.filter((p) => p.deviceType === '监控终端').length);
const lampPointCount = computed(() => points.value.length - terminalPointCount.value);
const alarmPoints = computed(() => points.value.filter((point) => point.activeAlarmCount > 0));
const activeAlarmCount = computed(() =>
points.value.reduce((total, point) => total + Number(point.activeAlarmCount || 0), 0),
);
function closePage() {
window.close();
}
function setDeviceTypeFilter(value: '单灯' | '监控终端' | '') {
deviceTypeFilter.value = value;
void loadPoints();
}
function formatTime(value?: string) {
return value ? new Date(value).toLocaleString() : '-';
}
function selectPoint(point: any) {
selectedPoint.value = point;
mapInstance.value?.setZoomAndCenter(15, [point.longitude, point.latitude]);
}
function isMonitoringTerminal(point: any) {
return point?.deviceType === '监控终端';
}
function lampState(point: any) {
if (point.activeAlarmCount > 0 || ['FAULT', 'MAINTENANCE'].includes(point.status)) {
return 'abnormal';
}
return point.isPowerOn === false || point.status === 'OFFLINE' || point.onlineStatus === 'OFFLINE' ? 'off' : 'on';
}
function terminalMarkerState(point: any) {
if (
point.activeAlarmCount > 0 ||
['FAULT', 'MAINTENANCE', 'OFFLINE'].includes(point.status) ||
point.onlineStatus === 'OFFLINE'
) {
return 'bad';
}
return 'ok';
}
function markerHtml(point: any) {
if (isMonitoringTerminal(point)) {
return `<div class="terminal-marker is-${terminalMarkerState(point)}"></div>`;
}
return `<div class="lamp-marker is-${lampState(point)}"></div>`;
}
function clusterGridSize(zoom: number) {
if (zoom >= 10) {
return 0.05;
}
if (zoom >= 8) {
return 0.15;
}
if (zoom >= 6) {
return 0.5;
}
return 1.5;
}
function clearMarkerOverlays() {
if (mapInstance.value && markerOverlays.value.length) {
mapInstance.value.remove(markerOverlays.value);
}
markerOverlays.value = [];
}
function groupedPoints(zoom: number, sourcePoints = points.value) {
const gridSize = clusterGridSize(zoom);
const groups = new Map<string, { count: number; longitude: number; latitude: number }>();
sourcePoints.forEach((point) => {
const lng = Number(point.longitude);
const lat = Number(point.latitude);
if (!Number.isFinite(lng) || !Number.isFinite(lat)) {
return;
}
const key = `${Math.floor(lng / gridSize)}:${Math.floor(lat / gridSize)}`;
const existing = groups.get(key);
if (existing) {
existing.count += 1;
existing.longitude += lng;
existing.latitude += lat;
return;
}
groups.set(key, { count: 1, longitude: lng, latitude: lat });
});
return [...groups.values()].map((group) => ({
count: group.count,
longitude: group.longitude / group.count,
latitude: group.latitude / group.count,
}));
}
function clusterHtml(count: number) {
return `<div class="map-cluster-marker">${count}</div>`;
}
function lngLatValue(value: any, key: 'lng' | 'lat') {
const getter = key === 'lng' ? 'getLng' : 'getLat';
return typeof value?.[getter] === 'function' ? Number(value[getter]()) : Number(value?.[key]);
}
function visiblePoints() {
const bounds = mapInstance.value?.getBounds?.();
const southWest = bounds?.getSouthWest?.();
const northEast = bounds?.getNorthEast?.();
if (!southWest || !northEast) {
return points.value;
}
const minLng = lngLatValue(southWest, 'lng');
const maxLng = lngLatValue(northEast, 'lng');
const minLat = lngLatValue(southWest, 'lat');
const maxLat = lngLatValue(northEast, 'lat');
if (![minLng, maxLng, minLat, maxLat].every(Number.isFinite)) {
return points.value;
}
return points.value.filter((point) => {
const lng = Number(point.longitude);
const lat = Number(point.latitude);
return lng >= minLng && lng <= maxLng && lat >= minLat && lat <= maxLat;
});
}
function renderMarkers(AMap: any) {
if (!mapInstance.value) {
return;
}
clearMarkerOverlays();
const zoom = mapInstance.value.getZoom?.() ?? mapConfig.zoom;
const visible = visiblePoints();
if (zoom < normalMarkerZoom || visible.length > 1000) {
markerOverlays.value = groupedPoints(zoom, visible).map((group) => {
const marker = new AMap.Marker({
position: [group.longitude, group.latitude],
content: clusterHtml(group.count),
offset: new AMap.Pixel(-24, -24),
});
marker.on('click', () => {
mapInstance.value?.setZoomAndCenter(Math.min(Math.max(zoom + 2, normalMarkerZoom), 18), [group.longitude, group.latitude]);
});
marker.setMap(mapInstance.value);
return marker;
});
return;
}
markerOverlays.value = visible.map((point) => {
const marker = new AMap.Marker({
position: [point.longitude, point.latitude],
title: point.name,
content: markerHtml(point),
});
marker.on('click', () => selectPoint(point));
marker.setMap(mapInstance.value);
return marker;
});
}
async function loadPoints() {
points.value = (await mapApi.points({
regionId: selectedRegionId.value,
deviceType: deviceTypeFilter.value,
all: 1,
})) as any[];
selectedPoint.value = undefined;
try {
const AMap = await loadAmap();
renderMarkers(AMap);
} catch (error) {
mapError.value = error instanceof Error ? error.message : '地图加载失败';
}
}
onMounted(async () => {
regions.value = (await regionApi.tree()) as any[];
points.value = (await mapApi.points({
regionId: selectedRegionId.value,
deviceType: deviceTypeFilter.value,
all: 1,
})) as any[];
try {
const AMap = await loadAmap();
let center = mapConfig.center;
try {
center = await getBrowserCoordinates();
} catch {
center = BEIJING_CENTER;
mapError.value = '浏览器定位失败,已使用北京作为默认中心';
}
mapInstance.value = new AMap.Map(mapEl.value, {
zoom: mapConfig.zoom,
center,
pitch: 42,
viewMode: '3D',
mapStyle: 'amap://styles/darkblue',
});
mapInstance.value.on('zoomend', () => renderMarkers(AMap));
mapInstance.value.on('moveend', () => renderMarkers(AMap));
renderMarkers(AMap);
} catch (error) {
mapError.value = error instanceof Error ? error.message : '地图加载失败';
}
});
</script>
<style scoped>
.screen {
box-sizing: border-box;
display: flex;
flex-direction: column;
min-height: 100vh;
height: 100vh;
padding: 24px;
overflow: hidden;
color: #e0f2fe;
background:
radial-gradient(circle at top left, rgba(14, 165, 233, 0.28), transparent 34%),
radial-gradient(circle at bottom right, rgba(99, 102, 241, 0.22), transparent 30%),
#020617;
}
.screen-header {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.eyebrow {
color: #38bdf8;
font-size: 12px;
letter-spacing: 0.18em;
text-transform: uppercase;
}
h1,
h2 {
margin: 0;
}
.screen-header h1 {
margin-top: 8px;
font-size: 30px;
}
.status {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 12px;
align-items: center;
}
.status span {
padding: 10px 14px;
border: 1px solid rgba(125, 211, 252, 0.25);
border-radius: 999px;
background: rgba(15, 23, 42, 0.72);
}
.device-filter {
display: inline-flex;
gap: 4px;
padding: 4px;
border: 1px solid rgba(56, 189, 248, 0.25);
border-radius: 999px;
background: rgba(15, 23, 42, 0.78);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.device-filter button {
min-width: 54px;
padding: 7px 13px;
border: 0;
border-radius: 999px;
color: #bae6fd;
background: transparent;
cursor: pointer;
font-weight: 700;
transition: all 0.18s ease;
}
.device-filter button:hover {
color: #fff;
background: rgba(14, 165, 233, 0.18);
}
.device-filter button.active {
color: #fff;
background: linear-gradient(135deg, #0ea5e9, #7c3aed);
box-shadow: 0 8px 22px rgba(14, 165, 233, 0.28);
}
.screen-body {
display: grid;
flex: 1 1 auto;
grid-template-columns: minmax(0, 1fr) 340px;
gap: 18px;
min-height: 0;
}
.map-panel,
.side-panel,
.panel-card,
.alarm-list {
border: 1px solid rgba(125, 211, 252, 0.18);
border-radius: 22px;
background: rgba(15, 23, 42, 0.72);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.28);
backdrop-filter: blur(18px);
}
.map-panel {
min-height: 0;
overflow: hidden;
}
.fullscreen-map {
width: 100%;
height: 100%;
min-height: 0;
}
.side-panel {
display: flex;
flex-direction: column;
gap: 14px;
min-height: 0;
padding: 14px;
}
.panel-card {
padding: 20px;
}
.panel-card strong {
display: block;
margin-top: 10px;
font-size: 38px;
color: #67e8f9;
}
.panel-card.danger strong {
color: #fb7185;
}
.label {
color: #94a3b8;
}
.alarm-list {
flex: 1;
padding: 18px;
overflow: auto;
}
.list-title {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.alarm-list h2 {
margin: 0;
font-size: 18px;
}
.alarm-item {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 0;
border-bottom: 1px solid rgba(148, 163, 184, 0.14);
cursor: pointer;
}
.alarm-item small {
color: #94a3b8;
}
.lamp-detail {
color: #e0f2fe;
}
.detail-name {
font-size: 22px;
font-weight: 700;
}
.detail-code {
margin-top: 6px;
color: #38bdf8;
}
dl {
margin: 18px 0;
}
dl div {
display: grid;
grid-template-columns: 84px minmax(0, 1fr);
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid rgba(148, 163, 184, 0.14);
}
dt {
color: #94a3b8;
}
dd {
margin: 0;
word-break: break-all;
}
.detail-alarms h3 {
margin: 0 0 8px;
font-size: 16px;
}
:global(.lamp-marker) {
width: 30px;
height: 30px;
background-position: center;
background-repeat: no-repeat;
background-size: contain;
cursor: pointer;
transform: translate(-15px, -30px);
}
:global(.lamp-marker.is-on) {
background-image: url("../../assets/images/路灯正常新.png");
}
:global(.lamp-marker.is-off) {
background-image: url("../../assets/images/路灯关新.png");
}
:global(.lamp-marker.is-abnormal) {
background-image: url("../../assets/images/路灯异常新.png");
}
:global(.terminal-marker) {
width: 32px;
height: 32px;
background-position: center;
background-repeat: no-repeat;
background-size: contain;
cursor: pointer;
transform: translate(-16px, -32px);
}
:global(.terminal-marker.is-ok) {
background-image: url("../../assets/images/终端正常.png");
}
:global(.terminal-marker.is-bad) {
background-image: url("../../assets/images/终端异常.png");
}
:global(.map-cluster-marker) {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border: 2px solid rgba(255, 255, 255, 0.88);
border-radius: 999px;
color: #fff;
background: linear-gradient(135deg, rgba(14, 165, 233, 0.92), rgba(124, 58, 237, 0.92));
box-shadow: 0 12px 34px rgba(14, 165, 233, 0.35);
cursor: pointer;
font-size: 15px;
font-weight: 800;
}
@media (max-width: 1100px) {
.screen-body {
grid-template-columns: 1fr;
height: auto;
}
}
</style>

@ -0,0 +1,540 @@
<template>
<div class="page">
<section class="page-hero">
<div>
<h1>地图监控</h1>
<p>在地图上查看单灯与监控终端点位、在线状态及报警;终端使用独立图标。</p>
</div>
<div class="map-actions">
<div class="device-filter">
<button :class="{ active: deviceTypeFilter === '' }" @click="setDeviceTypeFilter('')">全部</button>
<button :class="{ active: deviceTypeFilter === '单灯' }" @click="setDeviceTypeFilter('单灯')">单灯</button>
<button :class="{ active: deviceTypeFilter === '监控终端' }" @click="setDeviceTypeFilter('监控终端')">终端</button>
</div>
<el-tree-select
v-model="selectedRegionId"
:data="regions"
:props="{ label: 'name', children: 'children' }"
node-key="id"
check-strictly
clearable
placeholder="按区域筛选"
style="width: 240px"
@change="handleRegionChange"
/>
<el-button type="primary" round @click="openFullscreen">打开全屏监控</el-button>
</div>
</section>
<el-alert v-if="mapError" :title="mapError" type="warning" show-icon :closable="false" />
<el-card class="modern-card" shadow="never">
<template #header>
<div class="toolbar">
<span>地图监控</span>
<div class="map-legend">
<span><i class="lamp-legend-icon is-abnormal"></i>路灯异常</span>
<span><i class="lamp-legend-icon is-on"></i>开灯</span>
<span><i class="lamp-legend-icon is-off"></i>关灯</span>
<span class="legend-gap"><i class="terminal-legend-icon is-ok"></i>终端正常</span>
<span><i class="terminal-legend-icon is-bad"></i>终端异常</span>
</div>
</div>
</template>
<div ref="mapEl" class="map-container"></div>
</el-card>
<el-card class="modern-card" shadow="never">
<template #header>点位列表</template>
<el-table :data="listPoints" class="modern-table" @row-click="selectPoint">
<el-table-column prop="name" label="名称" min-width="160" />
<el-table-column prop="code" label="编号" min-width="140" />
<el-table-column prop="deviceType" label="类型" width="120" />
<el-table-column prop="regionName" label="区域" />
<el-table-column label="在线状态">
<template #default="{ row }">
<el-tag :type="row.onlineStatus === 'ONLINE' ? 'success' : 'info'">{{ onlineStatusText(row.onlineStatus) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="设备状态">
<template #default="{ row }">
<el-tag :type="row.activeAlarmCount > 0 ? 'danger' : row.status === 'NORMAL' ? 'success' : 'warning'">
{{ lampStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="activeAlarmCount" 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-drawer v-model="detailVisible" :title="detailTitle" size="420px">
<el-descriptions v-if="selectedPoint" :column="1" border>
<el-descriptions-item label="名称">{{ selectedPoint.name }}</el-descriptions-item>
<el-descriptions-item label="编号">{{ selectedPoint.code }}</el-descriptions-item>
<el-descriptions-item label="所属区域">{{ selectedPoint.regionName }}</el-descriptions-item>
<el-descriptions-item label="安装地址">{{ selectedPoint.address || '-' }}</el-descriptions-item>
<el-descriptions-item label="设备类型">{{ selectedPoint.deviceType || '-' }}</el-descriptions-item>
<el-descriptions-item label="在线状态">
<el-tag :type="selectedPoint.onlineStatus === 'ONLINE' ? 'success' : 'info'">
{{ onlineStatusText(selectedPoint.onlineStatus) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="设备状态">
<el-tag :type="selectedPoint.status === 'NORMAL' ? 'success' : 'danger'">
{{ lampStatusText(selectedPoint.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="坐标">{{ selectedPoint.longitude }}, {{ selectedPoint.latitude }}</el-descriptions-item>
<el-descriptions-item label="最近上报">{{ formatTime(selectedPoint.lastReportedAt) }}</el-descriptions-item>
<el-descriptions-item label="未处理报警">{{ selectedPoint.activeAlarmCount }}</el-descriptions-item>
</el-descriptions>
<el-divider>当前报警</el-divider>
<el-timeline v-if="selectedPoint?.activeAlarms?.length">
<el-timeline-item v-for="alarm in selectedPoint.activeAlarms" :key="alarm.id" :timestamp="formatTime(alarm.occurredAt)">
<div class="alarm-title">{{ alarm.title }}</div>
<div class="alarm-meta">{{ alarm.type }} / {{ alarmLevelText(alarm.level) }} / {{ alarmStatusText(alarm.status) }}</div>
</el-timeline-item>
</el-timeline>
<el-empty v-else description="暂无未处理报警" />
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { mapApi, regionApi } from '../../api/modules';
import { BEIJING_CENTER, getBrowserCoordinates, getMapConfig, loadAmap } from '../../utils/amap';
import { alarmLevelText, alarmStatusText, lampStatusText, onlineStatusText } from '../../utils/labels';
const mapEl = ref<HTMLDivElement>();
const points = ref<any[]>([]);
const listPoints = ref<any[]>([]);
const regions = ref<any[]>([]);
const selectedRegionId = ref('');
const deviceTypeFilter = ref<'单灯' | '监控终端' | ''>('');
const page = ref(1);
const pageSize = ref(20);
const total = ref(0);
const mapError = ref('');
const mapConfig = getMapConfig();
const selectedPoint = ref<any>();
const detailVisible = ref(false);
const mapInstance = ref<any>();
const markerOverlays = ref<any[]>([]);
const normalMarkerZoom = 12;
const detailTitle = computed(() =>
selectedPoint.value?.deviceType === '监控终端' ? '监控终端详情' : '路灯详情',
);
function isMonitoringTerminal(point: any) {
return point?.deviceType === '监控终端';
}
function openFullscreen() {
const params = new URLSearchParams();
if (selectedRegionId.value) {
params.set('regionId', selectedRegionId.value);
}
if (deviceTypeFilter.value) {
params.set('deviceType', deviceTypeFilter.value);
}
const query = params.toString() ? `?${params.toString()}` : '';
window.open(`/map/fullscreen${query}`, '_blank');
}
function formatTime(value?: string) {
return value ? new Date(value).toLocaleString() : '-';
}
function selectPoint(point: any) {
selectedPoint.value = point;
detailVisible.value = true;
mapInstance.value?.setZoomAndCenter(15, [point.longitude, point.latitude]);
}
function lampState(point: any) {
if (point.activeAlarmCount > 0 || ['FAULT', 'MAINTENANCE'].includes(point.status)) {
return 'abnormal';
}
return point.isPowerOn === false || point.status === 'OFFLINE' || point.onlineStatus === 'OFFLINE' ? 'off' : 'on';
}
/** 终端:正常 / 异常(对应终端正常、终端异常图标) */
function terminalMarkerState(point: any) {
if (
point.activeAlarmCount > 0 ||
['FAULT', 'MAINTENANCE', 'OFFLINE'].includes(point.status) ||
point.onlineStatus === 'OFFLINE'
) {
return 'bad';
}
return 'ok';
}
function markerHtml(point: any) {
if (isMonitoringTerminal(point)) {
return `<div class="terminal-marker is-${terminalMarkerState(point)}"></div>`;
}
return `<div class="lamp-marker is-${lampState(point)}"></div>`;
}
function clusterGridSize(zoom: number) {
if (zoom >= 10) {
return 0.05;
}
if (zoom >= 8) {
return 0.15;
}
if (zoom >= 6) {
return 0.5;
}
return 1.5;
}
function clearMarkerOverlays() {
if (mapInstance.value && markerOverlays.value.length) {
mapInstance.value.remove(markerOverlays.value);
}
markerOverlays.value = [];
}
function groupedPoints(zoom: number, sourcePoints = points.value) {
const gridSize = clusterGridSize(zoom);
const groups = new Map<string, { count: number; longitude: number; latitude: number }>();
sourcePoints.forEach((point) => {
const lng = Number(point.longitude);
const lat = Number(point.latitude);
if (!Number.isFinite(lng) || !Number.isFinite(lat)) {
return;
}
const key = `${Math.floor(lng / gridSize)}:${Math.floor(lat / gridSize)}`;
const existing = groups.get(key);
if (existing) {
existing.count += 1;
existing.longitude += lng;
existing.latitude += lat;
return;
}
groups.set(key, { count: 1, longitude: lng, latitude: lat });
});
return [...groups.values()].map((group) => ({
count: group.count,
longitude: group.longitude / group.count,
latitude: group.latitude / group.count,
}));
}
function clusterHtml(count: number) {
return `<div class="map-cluster-marker">${count}</div>`;
}
function lngLatValue(value: any, key: 'lng' | 'lat') {
const getter = key === 'lng' ? 'getLng' : 'getLat';
return typeof value?.[getter] === 'function' ? Number(value[getter]()) : Number(value?.[key]);
}
function visiblePoints() {
const bounds = mapInstance.value?.getBounds?.();
const southWest = bounds?.getSouthWest?.();
const northEast = bounds?.getNorthEast?.();
if (!southWest || !northEast) {
return points.value;
}
const minLng = lngLatValue(southWest, 'lng');
const maxLng = lngLatValue(northEast, 'lng');
const minLat = lngLatValue(southWest, 'lat');
const maxLat = lngLatValue(northEast, 'lat');
if (![minLng, maxLng, minLat, maxLat].every(Number.isFinite)) {
return points.value;
}
return points.value.filter((point) => {
const lng = Number(point.longitude);
const lat = Number(point.latitude);
return lng >= minLng && lng <= maxLng && lat >= minLat && lat <= maxLat;
});
}
function renderMarkers(AMap: any) {
if (!mapInstance.value) {
return;
}
clearMarkerOverlays();
const zoom = mapInstance.value.getZoom?.() ?? mapConfig.zoom;
const visible = visiblePoints();
if (zoom < normalMarkerZoom || visible.length > 1000) {
markerOverlays.value = groupedPoints(zoom, visible).map((group) => {
const marker = new AMap.Marker({
position: [group.longitude, group.latitude],
content: clusterHtml(group.count),
offset: new AMap.Pixel(-24, -24),
});
marker.on('click', () => {
mapInstance.value?.setZoomAndCenter(Math.min(Math.max(zoom + 2, normalMarkerZoom), 18), [group.longitude, group.latitude]);
});
marker.setMap(mapInstance.value);
return marker;
});
return;
}
markerOverlays.value = visible.map((point) => {
const marker = new AMap.Marker({
position: [point.longitude, point.latitude],
title: point.name,
content: markerHtml(point),
});
marker.on('click', () => selectPoint(point));
marker.setMap(mapInstance.value);
return marker;
});
}
async function loadPoints() {
points.value = (await mapApi.points({
regionId: selectedRegionId.value,
deviceType: deviceTypeFilter.value,
all: 1,
})) as any[];
await loadList();
try {
const AMap = await loadAmap();
renderMarkers(AMap);
} catch (error) {
mapError.value = error instanceof Error ? error.message : '地图加载失败';
}
}
function handleRegionChange() {
page.value = 1;
void loadPoints();
}
function setDeviceTypeFilter(value: '单灯' | '监控终端' | '') {
deviceTypeFilter.value = value;
selectedPoint.value = undefined;
detailVisible.value = false;
page.value = 1;
void loadPoints();
}
async function loadList() {
const result = await mapApi.points({
regionId: selectedRegionId.value,
deviceType: deviceTypeFilter.value,
page: page.value,
pageSize: pageSize.value,
});
listPoints.value = result.items ?? [];
total.value = result.total ?? 0;
}
function handlePageChange(value: number) {
page.value = value;
void loadList();
}
function handleSizeChange(value: number) {
pageSize.value = value;
page.value = 1;
void loadList();
}
onMounted(async () => {
regions.value = (await regionApi.tree()) as any[];
points.value = (await mapApi.points({ deviceType: deviceTypeFilter.value, all: 1 })) as any[];
await loadList();
try {
const AMap = await loadAmap();
let center = mapConfig.center;
try {
center = await getBrowserCoordinates();
} catch {
center = BEIJING_CENTER;
mapError.value = '浏览器定位失败,已使用北京作为默认中心';
}
mapInstance.value = new AMap.Map(mapEl.value, {
zoom: mapConfig.zoom,
center,
});
mapInstance.value.on('zoomend', () => renderMarkers(AMap));
mapInstance.value.on('moveend', () => renderMarkers(AMap));
renderMarkers(AMap);
} catch (error) {
mapError.value = error instanceof Error ? error.message : '地图加载失败';
}
});
</script>
<style scoped>
.map-container {
height: 520px;
border-radius: 8px;
background: linear-gradient(135deg, #eff6ff, #e0f2fe);
}
.pagination {
margin-top: 16px;
justify-content: flex-end;
}
.map-actions {
display: flex;
gap: 10px;
align-items: center;
}
.device-filter {
display: inline-flex;
gap: 4px;
padding: 4px;
border: 1px solid rgba(37, 99, 235, 0.12);
border-radius: 999px;
background: rgba(248, 250, 252, 0.92);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.82);
}
.device-filter button {
min-width: 54px;
padding: 7px 13px;
border: 0;
border-radius: 999px;
color: #64748b;
background: transparent;
cursor: pointer;
font-weight: 700;
transition: all 0.18s ease;
}
.device-filter button:hover {
color: #1d4ed8;
background: rgba(37, 99, 235, 0.08);
}
.device-filter button.active {
color: #fff;
background: linear-gradient(135deg, #2563eb, #0ea5e9);
box-shadow: 0 8px 18px rgba(37, 99, 235, 0.24);
}
.map-legend {
display: flex;
gap: 12px;
align-items: center;
color: #64748b;
font-size: 13px;
}
.lamp-legend-icon {
display: inline-block;
width: 18px;
height: 18px;
margin-right: 5px;
background-position: center;
background-repeat: no-repeat;
background-size: contain;
vertical-align: middle;
}
:global(.lamp-marker) {
width: 30px;
height: 30px;
background-position: center;
background-repeat: no-repeat;
background-size: contain;
cursor: pointer;
transform: translate(-15px, -30px);
}
.lamp-legend-icon.is-on,
:global(.lamp-marker.is-on) {
background-image: url("../../assets/images/路灯正常新.png");
}
.lamp-legend-icon.is-off,
:global(.lamp-marker.is-off) {
background-image: url("../../assets/images/路灯关新.png");
}
.lamp-legend-icon.is-abnormal,
:global(.lamp-marker.is-abnormal) {
background-image: url("../../assets/images/路灯异常新.png");
}
.legend-gap {
margin-left: 10px;
padding-left: 12px;
border-left: 1px solid #e2e8f0;
}
.terminal-legend-icon {
display: inline-block;
width: 18px;
height: 18px;
margin-right: 5px;
background-position: center;
background-repeat: no-repeat;
background-size: contain;
vertical-align: middle;
}
.terminal-legend-icon.is-ok {
background-image: url("../../assets/images/终端正常.png");
}
.terminal-legend-icon.is-bad {
background-image: url("../../assets/images/终端异常.png");
}
:global(.terminal-marker) {
width: 32px;
height: 32px;
background-position: center;
background-repeat: no-repeat;
background-size: contain;
cursor: pointer;
transform: translate(-16px, -32px);
}
:global(.terminal-marker.is-ok) {
background-image: url("../../assets/images/终端正常.png");
}
:global(.terminal-marker.is-bad) {
background-image: url("../../assets/images/终端异常.png");
}
:global(.map-cluster-marker) {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border: 2px solid rgba(255, 255, 255, 0.9);
border-radius: 999px;
color: #fff;
background: linear-gradient(135deg, rgba(37, 99, 235, 0.92), rgba(14, 165, 233, 0.92));
box-shadow: 0 10px 30px rgba(37, 99, 235, 0.35);
cursor: pointer;
font-size: 15px;
font-weight: 800;
}
.alarm-title {
font-weight: 600;
}
.alarm-meta {
margin-top: 4px;
color: #6b7280;
font-size: 13px;
}
</style>

@ -0,0 +1,305 @@
<template>
<div class="page sync-page">
<section class="page-hero sync-hero">
<div>
<h1>同步管理</h1>
<p>按平台选择同步资产或近期开启报警同步。高邮支持资产同步,汉源支持资产与报警同步。</p>
</div>
<div class="sync-summary">
<span>已选平台 {{ selectedBaseUrls.length }}</span>
<span>报警近 {{ faultDays }} 天</span>
</div>
</section>
<el-card class="modern-card" shadow="never">
<template #header>
<div class="section-title">
<span>选择平台</span>
<small>平台地址</small>
</div>
</template>
<div class="platform-grid">
<button
v-for="platform in platforms"
:key="platform.baseUrl"
class="platform-card"
:class="{ active: selectedBaseUrls.includes(platform.baseUrl) }"
@click="togglePlatform(platform.baseUrl)"
>
<span class="platform-badge">{{ platform.shortName }}</span>
<strong>{{ platform.name }}</strong>
<small>{{ platform.baseUrl }}</small>
<em>{{ platform.alarmSupported ? '支持报警同步' : '仅资产同步' }}</em>
</button>
</div>
</el-card>
<el-card class="modern-card" shadow="never">
<template #header>
<div class="section-title">
<span>同步内容</span>
<small>资产 / 报警</small>
</div>
</template>
<div class="sync-options">
<label class="sync-option" :class="{ active: syncAssets }">
<input v-model="syncAssets" type="checkbox" />
<span class="option-icon asset"></span>
<strong>资产同步</strong>
<small>同步单灯与监控终端</small>
</label>
<label class="sync-option" :class="{ active: syncFaults, disabled: !hasAlarmPlatform }">
<input v-model="syncFaults" type="checkbox" :disabled="!hasAlarmPlatform" />
<span class="option-icon alarm"></span>
<strong>报警同步</strong>
<small>仅汉源平台,按故障时间筛选</small>
</label>
<div class="days-box">
<span>报警时间范围</span>
<el-input-number v-model="faultDays" :min="1" :max="30" :disabled="!syncFaults" />
<small>同步最近 N 天故障</small>
</div>
</div>
<div class="sync-actions">
<el-button type="primary" size="large" :loading="syncing" round @click="runSync">执行同步</el-button>
</div>
</el-card>
<el-card v-if="lastResult" class="modern-card" shadow="never">
<template #header>
<div class="section-title">
<span>同步结果</span>
<small>按平台统计</small>
</div>
</template>
<el-table :data="lastResult.targets ?? []" class="modern-table">
<el-table-column label="平台" min-width="120">
<template #default="{ row }">{{ platformName(row.baseUrl) }}</template>
</el-table-column>
<el-table-column prop="baseUrl" label="地址" min-width="190" />
<el-table-column label="单灯">
<template #default="{ row }">{{ statText(row.ilc) }}</template>
</el-table-column>
<el-table-column label="终端">
<template #default="{ row }">{{ statText(row.rtu) }}</template>
</el-table-column>
<el-table-column label="报警">
<template #default="{ row }">
<span v-if="row.faults">近{{ row.faults.days }}天 {{ row.faults.total }} 条</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="状态" width="160">
<template #default="{ row }">
<el-tag :type="row.error ? 'danger' : 'success'">{{ row.error ? '失败' : '成功' }}</el-tag>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus';
import { computed, ref, watch } from 'vue';
import { lampApi } from '../../api/modules';
const platforms = [
{ name: '高邮智慧照明', shortName: '高邮', baseUrl: 'http://36.139.220.121:8090', alarmSupported: false },
{ name: '汉源智慧照明', shortName: '汉源', baseUrl: 'http://171.208.94.46:7070', alarmSupported: true },
] as const;
const selectedBaseUrls = ref<string[]>(platforms.map((item) => item.baseUrl));
const syncAssets = ref(true);
const syncFaults = ref(false);
const faultDays = ref(7);
const syncing = ref(false);
const lastResult = ref<any>();
const hasAlarmPlatform = computed(() =>
platforms.some((platform) => platform.alarmSupported && selectedBaseUrls.value.includes(platform.baseUrl)),
);
watch(hasAlarmPlatform, (value) => {
if (!value) {
syncFaults.value = false;
}
});
function togglePlatform(baseUrl: string) {
selectedBaseUrls.value = selectedBaseUrls.value.includes(baseUrl)
? selectedBaseUrls.value.filter((item) => item !== baseUrl)
: [...selectedBaseUrls.value, baseUrl];
}
function platformName(baseUrl: string) {
return platforms.find((platform) => platform.baseUrl === baseUrl)?.shortName ?? baseUrl;
}
function statText(stat?: any) {
return stat ? `新增 ${stat.created} / 更新 ${stat.updated}` : '-';
}
async function runSync() {
if (!selectedBaseUrls.value.length) {
ElMessage.warning('请至少选择一个平台');
return;
}
if (!syncAssets.value && !syncFaults.value) {
ElMessage.warning('请至少选择资产同步或报警同步');
return;
}
syncing.value = true;
try {
lastResult.value = await lampApi.syncPlatform({
baseUrls: selectedBaseUrls.value,
syncAssets: syncAssets.value,
syncFaults: syncFaults.value,
faultDays: faultDays.value,
});
const faultTotal = (lastResult.value.targets ?? []).reduce((sum: number, target: any) => sum + Number(target.faults?.total ?? 0), 0);
ElMessage.success(`同步完成:平台 ${lastResult.value.baseUrlCount} 个${faultTotal ? `,报警 ${faultTotal} 条` : ''}`);
} finally {
syncing.value = false;
}
}
</script>
<style scoped>
.sync-page {
gap: 20px;
}
.sync-hero {
background:
radial-gradient(circle at 86% 12%, rgba(34, 211, 238, 0.28), transparent 30%),
radial-gradient(circle at 16% 78%, rgba(37, 99, 235, 0.2), transparent 34%),
linear-gradient(180deg, rgba(8, 47, 98, 0.92), rgba(3, 24, 55, 0.9));
}
.sync-summary {
display: flex;
gap: 10px;
}
.sync-summary span {
padding: 10px 14px;
border: 1px solid rgba(80, 183, 255, 0.32);
border-radius: 6px;
color: #dff4ff;
background: rgba(9, 50, 103, 0.82);
box-shadow: inset 0 0 16px rgba(37, 154, 255, 0.14);
font-weight: 700;
}
.section-title {
display: flex;
flex-direction: column;
gap: 4px;
font-weight: 700;
}
.section-title small {
color: #94a3b8;
font-size: 12px;
}
.platform-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.platform-card {
display: grid;
gap: 8px;
padding: 18px;
border: 1px solid #e2e8f0;
border-radius: 22px;
text-align: left;
background: linear-gradient(135deg, #fff, #f8fafc);
cursor: pointer;
transition: all 0.18s ease;
}
.platform-card.active {
border-color: #38bdf8;
box-shadow: 0 18px 45px rgba(14, 165, 233, 0.16);
transform: translateY(-2px);
}
.platform-card small {
color: #64748b;
}
.platform-card em,
.platform-badge {
width: fit-content;
padding: 5px 10px;
border-radius: 999px;
color: #075985;
background: #e0f2fe;
font-style: normal;
font-size: 12px;
font-weight: 700;
}
.sync-options {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.sync-option,
.days-box {
display: grid;
gap: 8px;
padding: 18px;
border: 1px solid #e2e8f0;
border-radius: 22px;
background: #fff;
}
.sync-option {
cursor: pointer;
}
.sync-option input {
display: none;
}
.sync-option.active {
border-color: #2563eb;
background: #eff6ff;
}
.sync-option.disabled {
opacity: 0.52;
cursor: not-allowed;
}
.sync-option small,
.days-box small {
color: #64748b;
}
.option-icon {
width: 34px;
height: 34px;
border-radius: 12px;
}
.option-icon.asset {
background: linear-gradient(135deg, #2563eb, #22d3ee);
}
.option-icon.alarm {
background: linear-gradient(135deg, #f97316, #ef4444);
}
.sync-actions {
display: flex;
justify-content: flex-end;
margin-top: 18px;
}
</style>

@ -0,0 +1,92 @@
<template>
<div class="page">
<section class="page-hero">
<div>
<h1>区域管理</h1>
<p>区域是路灯、报警、地图和角色数据权限的基础维度。</p>
</div>
<el-tag type="primary" effect="light">区域树</el-tag>
</section>
<div class="usage-grid">
<el-card class="usage-card" shadow="never">
<strong>1. 绑定路灯资产</strong>
<span>每盏路灯归属到一个区县,地图点位、资产列表和报警都继承这个区域。</span>
</el-card>
<el-card class="usage-card" shadow="never">
<strong>2. 控制角色数据范围</strong>
<span>在角色权限页勾选区域后,该角色只能查看对应区域及下级区域的数据。</span>
</el-card>
<el-card class="usage-card" shadow="never">
<strong>3. 支撑地图筛选和统计</strong>
<span>看板区域排行、地图聚合、报警统计都会按区域树汇总。</span>
</el-card>
</div>
<el-card class="modern-card" shadow="never">
<template #header>行政区域树</template>
<el-tree :data="regions" node-key="id" default-expand-all :props="{ label: 'name', children: 'children' }">
<template #default="{ data }">
<span class="tree-node">
<strong>{{ data.name }}</strong>
<small class="muted">ID: {{ data.id }} / code: {{ data.code }}</small>
</span>
</template>
</el-tree>
</el-card>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { regionApi } from '../../api/modules';
const regions = ref<any[]>([]);
onMounted(async () => {
regions.value = (await regionApi.tree()) as any[];
});
</script>
<style scoped>
.muted {
margin-left: 8px;
color: #9ca3af;
}
.tree-node {
display: inline-flex;
align-items: center;
gap: 4px;
}
.usage-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
.usage-card {
border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: 18px;
}
.usage-card strong,
.usage-card span {
display: block;
}
.usage-card strong {
color: #0f172a;
}
.usage-card span {
margin-top: 8px;
color: #64748b;
line-height: 1.7;
}
@media (max-width: 960px) {
.usage-grid {
grid-template-columns: 1fr;
}
}
</style>

@ -0,0 +1,203 @@
<template>
<div class="page">
<section class="page-hero">
<div>
<h1>角色权限</h1>
<p>通过角色聚合菜单、按钮/API 权限和区域数据范围。</p>
</div>
<el-tag type="primary" effect="light">角色 {{ total }}</el-tag>
</section>
<div class="toolbar">
<span></span>
<el-button type="primary" round @click="openCreate">新增角色</el-button>
</div>
<el-card class="modern-card" shadow="never">
<el-table :data="roles" class="modern-table">
<el-table-column prop="name" label="角色名称" />
<el-table-column prop="code" label="角色编码" />
<el-table-column prop="description" label="说明" />
<el-table-column label="权限" min-width="220">
<template #default="{ row }">
<el-tag v-for="item in row.permissions" :key="item.permissionId" class="permission-tag">
{{ item.permission.name }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="区域数据权限" min-width="220">
<template #default="{ row }">
<el-tag v-for="item in row.regionScopes" :key="item.regionId" type="success" class="permission-tag">
{{ item.region.name }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="120">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
</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="form.id ? '编辑角色' : '新增角色'" width="720px">
<el-form :model="form" label-width="90px">
<el-form-item label="名称"><el-input v-model="form.name" /></el-form-item>
<el-form-item label="编码"><el-input v-model="form.code" /></el-form-item>
<el-form-item label="说明"><el-input v-model="form.description" /></el-form-item>
<el-form-item label="功能权限">
<el-checkbox-group v-model="selectedPermissionIds" class="permission-options">
<el-checkbox v-for="permission in permissions" :key="permission.id" :label="permission.id">
{{ permission.name }}
</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="区域权限">
<div class="region-scope-box">
<div class="scope-help">勾选全国/省/市/区县后,该角色只能查看对应区域及其下级区域的路灯和报警数据。</div>
<el-tree
ref="regionTreeRef"
:data="regions"
node-key="id"
show-checkbox
default-expand-all
check-strictly
:props="{ label: 'name', children: 'children' }"
/>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="save">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { nextTick, onMounted, reactive, ref } from 'vue';
import { regionApi, systemApi } from '../../api/modules';
const roles = ref<any[]>([]);
const permissions = ref<any[]>([]);
const regions = ref<any[]>([]);
const dialogVisible = ref(false);
const selectedPermissionIds = ref<string[]>([]);
const regionTreeRef = ref<any>();
const page = ref(1);
const pageSize = ref(20);
const total = ref(0);
const form = reactive<{ id?: string; name: string; code: string; description: string }>({
id: undefined,
name: '',
code: '',
description: '',
});
async function load() {
const [roleData, permissionData, regionData] = await Promise.all([
systemApi.roles({ page: page.value, pageSize: pageSize.value }),
systemApi.permissions(),
regionApi.tree(),
]);
roles.value = roleData.items ?? [];
total.value = roleData.total ?? 0;
permissions.value = permissionData as any[];
regions.value = regionData as any[];
}
function handlePageChange(value: number) {
page.value = value;
void load();
}
function handleSizeChange(value: number) {
pageSize.value = value;
page.value = 1;
void load();
}
function resetForm() {
form.id = undefined;
form.name = '';
form.code = '';
form.description = '';
selectedPermissionIds.value = [];
regionTreeRef.value?.setCheckedKeys([]);
}
function openCreate() {
resetForm();
dialogVisible.value = true;
}
function openEdit(row: any) {
form.id = row.id;
form.name = row.name;
form.code = row.code;
form.description = row.description ?? '';
selectedPermissionIds.value = row.permissions?.map((item: any) => item.permissionId) ?? [];
dialogVisible.value = true;
void nextTick(() => {
regionTreeRef.value?.setCheckedKeys(row.regionScopes?.map((item: any) => item.regionId) ?? []);
});
}
async function save() {
const payload = {
...form,
permissionIds: selectedPermissionIds.value,
regionIds: regionTreeRef.value?.getCheckedKeys() ?? [],
};
if (form.id) {
await systemApi.updateRole(form.id, payload);
} else {
await systemApi.createRole(payload);
}
dialogVisible.value = false;
resetForm();
await load();
}
onMounted(load);
</script>
<style scoped>
.permission-tag {
margin: 2px 4px 2px 0;
}
.pagination {
margin-top: 16px;
justify-content: flex-end;
}
.permission-options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px 12px;
}
.region-scope-box {
width: 100%;
padding: 12px;
border: 1px solid #e5e7eb;
border-radius: 14px;
background: #f8fafc;
}
.scope-help {
margin-bottom: 10px;
color: #64748b;
font-size: 13px;
line-height: 1.6;
}
</style>

@ -0,0 +1,148 @@
<template>
<div class="page">
<section class="page-hero">
<div>
<h1>租户管理</h1>
<p>平台超级管理员用于开通租户,并同步创建该租户的管理员账号。</p>
</div>
<el-button type="primary" round @click="dialogVisible = true">新增租户</el-button>
</section>
<div class="toolbar">
<el-input v-model="keyword" placeholder="搜索租户名称/编码" style="width: 280px" clearable @change="handleFilterChange" />
</div>
<el-card class="modern-card" shadow="never">
<el-table :data="tenants" v-loading="loading" class="modern-table">
<el-table-column prop="name" label="租户名称" />
<el-table-column prop="code" label="租户编码" />
<el-table-column label="管理员账号">
<template #default="{ row }">{{ row.users?.[0]?.username ?? '-' }}</template>
</el-table-column>
<el-table-column label="状态">
<template #default="{ row }">
<el-switch v-model="row.enabled" @change="toggleTenant(row)" />
</template>
</el-table-column>
<el-table-column label="创建时间" min-width="180">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</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="560px">
<el-form :model="form" label-width="110px">
<el-form-item label="租户名称"><el-input v-model="form.name" placeholder="例如:高邮市照明中心" /></el-form-item>
<el-form-item label="租户编码"><el-input v-model="form.code" placeholder="例如:gaoyou" /></el-form-item>
<el-divider>租户管理员</el-divider>
<el-form-item label="管理员账号"><el-input v-model="form.adminUsername" placeholder="默认 admin" /></el-form-item>
<el-form-item label="管理员姓名"><el-input v-model="form.adminName" placeholder="默认 租户管理员" /></el-form-item>
<el-form-item label="初始密码">
<el-input v-model="form.adminPassword" type="password" show-password placeholder="至少 6 位" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="create">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus';
import { onMounted, reactive, ref } from 'vue';
import { systemApi } from '../../api/modules';
const tenants = ref<any[]>([]);
const keyword = ref('');
const loading = ref(false);
const saving = ref(false);
const dialogVisible = ref(false);
const page = ref(1);
const pageSize = ref(20);
const total = ref(0);
const form = reactive({
name: '',
code: '',
adminUsername: 'admin',
adminName: '租户管理员',
adminPassword: 'admin123456',
});
function formatTime(value: string) {
return value ? new Date(value).toLocaleString() : '-';
}
async function load() {
loading.value = true;
try {
const result = await systemApi.tenants({ keyword: keyword.value, page: page.value, pageSize: pageSize.value });
tenants.value = result.items ?? [];
total.value = result.total ?? 0;
} finally {
loading.value = false;
}
}
function resetForm() {
form.name = '';
form.code = '';
form.adminUsername = 'admin';
form.adminName = '租户管理员';
form.adminPassword = 'admin123456';
}
async function create() {
saving.value = true;
try {
await systemApi.createTenant({ ...form });
ElMessage.success('租户创建成功');
dialogVisible.value = false;
resetForm();
await load();
} finally {
saving.value = false;
}
}
async function toggleTenant(row: any) {
await systemApi.updateTenant(row.id, { enabled: row.enabled });
ElMessage.success(row.enabled ? '租户已启用' : '租户已停用');
}
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();
}
onMounted(load);
</script>
<style scoped>
.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,146 @@
<template>
<div class="page">
<section class="page-hero">
<div>
<h1>监控终端</h1>
<p>展示从智慧照明平台同步的 RTU 监控终端,与单灯数据同源;执行同步会同时更新单灯与终端。</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="rows" v-loading="loading" class="modern-table">
<el-table-column prop="code" label="编号" min-width="160" />
<el-table-column prop="name" label="终端名称" min-width="160" />
<el-table-column prop="region.name" label="区域" />
<el-table-column prop="deviceType" label="类型" width="110" />
<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>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { lampApi, regionApi } from '../../api/modules';
import { lampStatusText, onlineStatusText } from '../../utils/labels';
const rows = ref<any[]>([]);
const regions = ref<any[]>([]);
const keyword = ref('');
const selectedRegionId = ref('');
const loading = ref(false);
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,
onlyTerminal: '1',
});
rows.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();
}
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: #0f766e;
background: #ccfbf1;
font-weight: 600;
}
.filter-group {
display: flex;
gap: 10px;
}
.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…
Cancel
Save