Initial commit: 节能建筑云管理平台 SaaS 项目Demo

This commit is contained in:
zzh 2025-12-09 13:12:11 +08:00
commit c8c5266550
80 changed files with 12211 additions and 0 deletions

33
.gitignore vendored Normal file
View File

@ -0,0 +1,33 @@
# Dependencies
node_modules/
# Build output
dist/
build/
# Environment files
.env
.env.local
.env.*.local
# Logs
logs/
*.log
npm-debug.log*
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Lock files (optional - uncomment if you want to ignore)
# package-lock.json
# Cache
.cache/
.npm/

16
backend/Dockerfile Normal file
View File

@ -0,0 +1,16 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* yarn.lock* pnpm-lock.yaml* ./
RUN npm i --production=false || yarn || true
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json* yarn.lock* pnpm-lock.yaml* ./
RUN npm i --production
COPY --from=build /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/main.js"]

5571
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

43
backend/package.json Normal file
View File

@ -0,0 +1,43 @@
{
"name": "energy-smart-saas-backend",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "tsc",
"start": "node dist/main.js",
"dev": "ts-node-dev --respawn --transpile-only src/main.ts",
"migrate": "node dist/modules/migrations/migrate.js"
},
"dependencies": {
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.0",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/platform-socket.io": "^10.4.20",
"@nestjs/schedule": "^3.0.0",
"@nestjs/typeorm": "^10.0.0",
"@nestjs/websockets": "^10.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"exceljs": "^4.4.0",
"ioredis": "^5.4.1",
"mqtt": "^5.10.0",
"mysql2": "^3.9.0",
"node-cron": "^3.0.3",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pdfmake": "^0.2.10",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.0",
"socket.io": "^4.7.5",
"typeorm": "^0.3.20"
},
"devDependencies": {
"@types/express": "^5.0.5",
"@types/pdfmake": "^0.2.12",
"ts-node": "^10.9.2",
"ts-node-dev": "^2.0.0",
"typescript": "^5.4.0"
}
}

16
backend/src/main.ts Normal file
View File

@ -0,0 +1,16 @@
import 'reflect-metadata'
import { NestFactory } from '@nestjs/core'
import { AppModule } from './modules/app.module'
import { ValidationPipe } from '@nestjs/common'
async function bootstrap() {
const app = await NestFactory.create(AppModule, { logger: ['error', 'warn', 'log'] })
app.enableCors({ origin: true, credentials: true })
app.useGlobalPipes(new ValidationPipe({ whitelist: true }))
const port = process.env.PORT ? Number(process.env.PORT) : 3000
await app.listen(port)
console.log(`Backend listening on ${port}`)
}
bootstrap()

View File

@ -0,0 +1,18 @@
import { Controller, Get, Post, Param } from '@nestjs/common'
@Controller('alarm')
export class AlarmController {
@Get('unread')
unread() {
return [
{ id: 'a1', level: 'high', message: '设备离线', time: Date.now() - 5000 },
{ id: 'a2', level: 'medium', message: '能耗异常', time: Date.now() - 10000 }
]
}
@Post('ack/:id')
ack(@Param('id') id: string) {
return { id, status: 'acked' }
}
}

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common'
import { AlarmController } from './alarm.controller'
@Module({
controllers: [AlarmController]
})
export class AlarmModule {}

View File

@ -0,0 +1,48 @@
import { Module } from '@nestjs/common'
import { TypeOrmModule } from '@nestjs/typeorm'
import { AuthModule } from '../modules/auth/auth.module'
import { TenantModule } from '../modules/tenant/tenant.module'
import { ProjectModule } from '../modules/project/project.module'
import { DeviceModule } from '../modules/device/device.module'
import { EnergyModule } from '../modules/energy/energy.module'
import { BatchModule } from '../modules/batch/batch.module'
import { AlarmModule } from '../modules/alarm/alarm.module'
import { ReportModule } from '../modules/report/report.module'
import { OtaModule } from '../modules/ota/ota.module'
import { WebsocketModule } from '../modules/websocket/websocket.module'
import { CommonModule } from '../modules/common/common.module'
import { EntitiesModule } from '../modules/entities/entities.module'
import { MockModule } from '../modules/mock/mock.module'
@Module({
imports: [
TypeOrmModule.forRootAsync({
useFactory: () => ({
type: 'mysql',
host: process.env.DB_HOST || 'localhost',
port: Number(process.env.DB_PORT || 3306),
username: process.env.DB_USER || 'root',
password: process.env.DB_PASS || 'rootpass',
database: process.env.DB_NAME || 'energy_saas',
synchronize: false,
autoLoadEntities: true,
entities: [__dirname + '/../**/*.entity.{ts,js}'],
timezone: 'Z'
})
}),
EntitiesModule,
CommonModule,
AuthModule,
TenantModule,
ProjectModule,
DeviceModule,
EnergyModule,
BatchModule,
AlarmModule,
ReportModule,
OtaModule,
WebsocketModule,
MockModule
]
})
export class AppModule {}

View File

@ -0,0 +1,19 @@
import { Body, Controller, Post } from '@nestjs/common'
import { AuthService } from './auth.service'
class LoginDto {
email!: string
password!: string
tenantId?: string
}
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('login')
async login(@Body() dto: LoginDto) {
return this.authService.login(dto.email, dto.password, dto.tenantId)
}
}

View File

@ -0,0 +1,17 @@
import { Module } from '@nestjs/common'
import { JwtModule } from '@nestjs/jwt'
import { AuthService } from './auth.service'
import { AuthController } from './auth.controller'
@Module({
imports: [
JwtModule.register({
secret: process.env.JWT_SECRET || 'secret',
signOptions: { expiresIn: '7d' }
})
],
providers: [AuthService],
controllers: [AuthController]
})
export class AuthModule {}

View File

@ -0,0 +1,19 @@
import { Injectable } from '@nestjs/common'
import { JwtService } from '@nestjs/jwt'
@Injectable()
export class AuthService {
constructor(private readonly jwt: JwtService) {}
async login(email: string, password: string, tenantId?: string) {
if (email === 'admin@energy.com' && password === '123456') {
const payload = { sub: 'admin-id', email, tenantId: tenantId || 'tenant-1', role: 'admin' }
return {
token: await this.jwt.signAsync(payload),
user: { id: 'admin-id', email, name: 'Admin', tenantId: payload.tenantId },
whiteLabel: { title: 'EnergySmart SaaS', logoUrl: '/logo.svg' }
}
}
return { error: 'Invalid credentials' }
}
}

View File

@ -0,0 +1,15 @@
import { Body, Controller, Post } from '@nestjs/common'
@Controller('batch')
export class BatchController {
@Post('command')
command(@Body() body: any) {
return { taskId: 'task-' + Date.now(), status: 'queued', total: (body?.rooms?.length ?? 0) }
}
@Post('ota')
ota(@Body() body: any) {
return { taskId: 'ota-' + Date.now(), status: 'queued', devices: (body?.devices?.length ?? 0) }
}
}

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common'
import { BatchController } from './batch.controller'
@Module({
controllers: [BatchController]
})
export class BatchModule {}

View File

@ -0,0 +1,15 @@
import { Module } from '@nestjs/common'
import { TenantInterceptor } from './tenant.interceptor'
import { APP_INTERCEPTOR } from '@nestjs/core'
import { OperationLogInterceptor } from './operation-log.interceptor'
import { TypeOrmModule } from '@nestjs/typeorm'
import { OperationLog } from '../entities/operation-log.entity'
@Module({
imports: [TypeOrmModule.forFeature([OperationLog])],
providers: [
{ provide: APP_INTERCEPTOR, useClass: TenantInterceptor },
{ provide: APP_INTERCEPTOR, useClass: OperationLogInterceptor }
]
})
export class CommonModule {}

View File

@ -0,0 +1,19 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'
import { InjectRepository } from '@nestjs/typeorm'
import { Repository } from 'typeorm'
import { OperationLog } from '../entities/operation-log.entity'
import { Observable } from 'rxjs'
@Injectable()
export class OperationLogInterceptor implements NestInterceptor {
constructor(@InjectRepository(OperationLog) private repo: Repository<OperationLog>) {}
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
const req = context.switchToHttp().getRequest()
const userId = req.user?.sub || 'anonymous'
const action = `${req.method} ${req.url}`
const detail = JSON.stringify(req.body || {})
await this.repo.save(this.repo.create({ userId, action, detail }))
return next.handle()
}
}

View File

@ -0,0 +1,12 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'
import { Observable } from 'rxjs'
@Injectable()
export class TenantInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const req = context.switchToHttp().getRequest()
req.tenantId = req.headers['x-tenant-id'] || 'mock-tenant'
return next.handle()
}
}

View File

@ -0,0 +1,20 @@
import { Controller, Get, Query, Param } from '@nestjs/common'
@Controller('rooms')
export class DeviceController {
@Get()
list(@Query('page') page = 1, @Query('pageSize') pageSize = 20) {
const total = 3600
const start = (Number(page) - 1) * Number(pageSize)
const items = Array.from({ length: Number(pageSize) }).map((_, i) => {
const id = start + i + 1
return { id, name: `Room ${id}`, building: Math.ceil(id / 300), floor: Math.ceil((id % 300) / 20) }
})
return { total, items }
}
@Get(':id/realtime')
realtime(@Param('id') id: string) {
return { channel: `rooms/${id}/realtime`, protocol: 'websocket' }
}
}

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common'
import { DeviceController } from './device.controller'
@Module({
controllers: [DeviceController]
})
export class DeviceModule {}

View File

@ -0,0 +1,60 @@
import { Controller, Get, Query } from '@nestjs/common'
@Controller('energy')
export class EnergyController {
@Get('realtime')
realtime() {
const ts = Date.now()
return {
ts,
totalPower: Math.round(500 + Math.random() * 200),
subitems: [
{ name: '空调', kw: Math.round(200 + Math.random() * 80) },
{ name: '照明', kw: Math.round(120 + Math.random() * 50) },
{ name: '电梯', kw: Math.round(80 + Math.random() * 30) },
{ name: '其他', kw: Math.round(60 + Math.random() * 20) }
]
}
}
@Get('history')
history(@Query('range') range = 'day') {
const now = Date.now()
let points: { t: number; v: number }[] = []
if (range === 'day') {
// 今日24小时每小时一个点
points = Array.from({ length: 24 }).map((_, i) => {
const hour = new Date(now)
hour.setHours(i, 0, 0, 0)
// 模拟白天用电高峰
const base = i >= 8 && i <= 18 ? 400 : 250
return { t: hour.getTime(), v: Math.round(base + Math.random() * 100) }
})
} else if (range === 'week') {
// 本周7天每天一个点
points = Array.from({ length: 7 }).map((_, i) => {
const day = new Date(now)
day.setDate(day.getDate() - 6 + i)
day.setHours(12, 0, 0, 0)
// 模拟工作日用电高,周末低
const dayOfWeek = day.getDay()
const base = dayOfWeek >= 1 && dayOfWeek <= 5 ? 8000 : 5000
return { t: day.getTime(), v: Math.round(base + Math.random() * 2000) }
})
} else if (range === 'month') {
// 本月30天每天一个点
points = Array.from({ length: 30 }).map((_, i) => {
const day = new Date(now)
day.setDate(day.getDate() - 29 + i)
day.setHours(12, 0, 0, 0)
const dayOfWeek = day.getDay()
const base = dayOfWeek >= 1 && dayOfWeek <= 5 ? 8500 : 5500
return { t: day.getTime(), v: Math.round(base + Math.random() * 2500) }
})
}
return { range, points }
}
}

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common'
import { EnergyController } from './energy.controller'
@Module({
controllers: [EnergyController]
})
export class EnergyModule {}

View File

@ -0,0 +1,9 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'
@Entity('alarm_rules')
export class AlarmRule {
@PrimaryGeneratedColumn('uuid') id!: string
@Column() name!: string
@Column({ nullable: true }) condition?: string
}

View File

@ -0,0 +1,10 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'
@Entity('alarms')
export class Alarm {
@PrimaryGeneratedColumn('uuid') id!: string
@Column() level!: string
@Column() message!: string
@Column({ type: 'bigint', default: () => 'UNIX_TIMESTAMP() * 1000' }) time!: number
@Column({ default: false }) acked!: boolean
}

View File

@ -0,0 +1,11 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'
@Entity('batch_task_records')
export class BatchTaskRecord {
@PrimaryGeneratedColumn('uuid') id!: string
@Column() taskId!: string
@Column() targetId!: string
@Column({ default: 'pending' }) status!: string
@Column({ nullable: true }) message?: string
}

View File

@ -0,0 +1,11 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'
@Entity('batch_tasks')
export class BatchTask {
@PrimaryGeneratedColumn('uuid') id!: string
@Column() type!: string
@Column({ default: 'queued' }) status!: string
@Column({ type: 'int', default: 0 }) total!: number
@Column({ type: 'int', default: 0 }) done!: number
}

View File

@ -0,0 +1,9 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'
@Entity('buildings')
export class Building {
@PrimaryGeneratedColumn('uuid') id!: string
@Column() name!: string
@Column({ nullable: true }) projectId?: string
}

View File

@ -0,0 +1,11 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'
@Entity('devices')
export class Device {
@PrimaryGeneratedColumn('uuid') id!: string
@Column() room_id!: string
@Column() type!: string
@Column() mac!: string
@Column({ default: 'offline' }) online_status!: 'online' | 'offline'
@Column({ type: 'bigint', default: () => 'UNIX_TIMESTAMP() * 1000' }) last_seen!: number
}

View File

@ -0,0 +1,46 @@
import { Module } from '@nestjs/common'
import { TypeOrmModule } from '@nestjs/typeorm'
import { Tenant } from './tenant.entity'
import { User } from './user.entity'
import { Role } from './role.entity'
import { Permission } from './permission.entity'
import { Project } from './project.entity'
import { Building } from './building.entity'
import { Floor } from './floor.entity'
import { Room } from './room.entity'
import { Device } from './device.entity'
import { Alarm } from './alarm.entity'
import { AlarmRule } from './alarm-rule.entity'
import { BatchTask } from './batch-task.entity'
import { BatchTaskRecord } from './batch-task-record.entity'
import { Report } from './report.entity'
import { ReportTemplate } from './report-template.entity'
import { OperationLog } from './operation-log.entity'
@Module({
imports: [
TypeOrmModule.forFeature([
Tenant,
User,
Role,
Permission,
Project,
Building,
Floor,
Room,
Device,
Alarm,
AlarmRule,
BatchTask,
BatchTaskRecord,
Report,
ReportTemplate,
OperationLog
])
],
exports: [
TypeOrmModule
]
})
export class EntitiesModule {}

View File

@ -0,0 +1,9 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'
@Entity('floors')
export class Floor {
@PrimaryGeneratedColumn('uuid') id!: string
@Column() name!: string
@Column({ nullable: true }) buildingId?: string
}

View File

@ -0,0 +1,10 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'
@Entity('operation_logs')
export class OperationLog {
@PrimaryGeneratedColumn('uuid') id!: string
@Column() userId!: string
@Column() action!: string
@Column({ type: 'text', nullable: true }) detail?: string
@Column({ type: 'bigint', default: () => 'UNIX_TIMESTAMP() * 1000' }) time!: number
}

View File

@ -0,0 +1,9 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'
@Entity('permissions')
export class Permission {
@PrimaryGeneratedColumn('uuid') id!: string
@Column() code!: string
@Column({ nullable: true }) description?: string
}

View File

@ -0,0 +1,9 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'
@Entity('projects')
export class Project {
@PrimaryGeneratedColumn('uuid') id!: string
@Column() name!: string
@Column({ nullable: true }) tenantId?: string
}

View File

@ -0,0 +1,9 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'
@Entity('report_templates')
export class ReportTemplate {
@PrimaryGeneratedColumn('uuid') id!: string
@Column() name!: string
@Column({ type: 'text', nullable: true }) schema?: string
}

View File

@ -0,0 +1,10 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'
@Entity('reports')
export class Report {
@PrimaryGeneratedColumn('uuid') id!: string
@Column() type!: string
@Column() period!: string
@Column({ type: 'bigint', default: () => 'UNIX_TIMESTAMP() * 1000' }) createdAt!: number
@Column({ nullable: true }) filePath?: string
}

View File

@ -0,0 +1,9 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'
@Entity('roles')
export class Role {
@PrimaryGeneratedColumn('uuid') id!: string
@Column() name!: string
@Column({ nullable: true }) description?: string
}

View File

@ -0,0 +1,9 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'
@Entity('rooms')
export class Room {
@PrimaryGeneratedColumn('uuid') id!: string
@Column() name!: string
@Column({ nullable: true }) floorId?: string
}

View File

@ -0,0 +1,10 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'
@Entity('tenants')
export class Tenant {
@PrimaryGeneratedColumn('uuid') id!: string
@Column() name!: string
@Column({ nullable: true }) logoUrl?: string
@Column({ default: true }) enabled!: boolean
@Column({ type: 'bigint' }) createdAt!: number
}

View File

@ -0,0 +1,12 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'
@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid') id!: string
@Column() email!: string
@Column() passwordHash!: string
@Column() name!: string
@Column({ nullable: true }) tenantId?: string
@Column({ default: 'user' }) role!: string
@Column({ type: 'bigint', nullable: true }) createdAt!: number
}

View File

@ -0,0 +1,103 @@
CREATE TABLE IF NOT EXISTS tenants (
id varchar(36) primary key,
name varchar(255) not null,
logoUrl varchar(255),
enabled tinyint(1) default 1,
createdAt bigint
);
CREATE TABLE IF NOT EXISTS users (
id varchar(36) primary key,
email varchar(255) not null,
passwordHash varchar(255) not null,
name varchar(255) not null,
tenantId varchar(36),
role varchar(64) default 'user',
createdAt bigint
);
CREATE TABLE IF NOT EXISTS projects (
id varchar(36) primary key,
name varchar(255) not null,
tenantId varchar(36)
);
CREATE TABLE IF NOT EXISTS buildings (
id varchar(36) primary key,
name varchar(255) not null,
projectId varchar(36)
);
CREATE TABLE IF NOT EXISTS floors (
id varchar(36) primary key,
name varchar(255) not null,
buildingId varchar(36)
);
CREATE TABLE IF NOT EXISTS rooms (
id varchar(36) primary key,
name varchar(255) not null,
floorId varchar(36)
);
CREATE TABLE IF NOT EXISTS devices (
id varchar(36) primary key,
room_id varchar(36) not null,
type varchar(64) not null,
mac varchar(64) not null,
online_status varchar(16) default 'offline',
last_seen bigint
);
CREATE TABLE IF NOT EXISTS alarms (
id varchar(36) primary key,
level varchar(64) not null,
message varchar(255) not null,
time bigint,
acked tinyint(1) default 0
);
CREATE TABLE IF NOT EXISTS alarm_rules (
id varchar(36) primary key,
name varchar(255) not null,
`condition` text
);
CREATE TABLE IF NOT EXISTS batch_tasks (
id varchar(36) primary key,
type varchar(64) not null,
status varchar(32) default 'queued',
total int default 0,
done int default 0
);
CREATE TABLE IF NOT EXISTS batch_task_records (
id varchar(36) primary key,
taskId varchar(36) not null,
targetId varchar(36) not null,
status varchar(32) default 'pending',
message text
);
CREATE TABLE IF NOT EXISTS reports (
id varchar(36) primary key,
type varchar(64) not null,
period varchar(64) not null,
createdAt bigint,
filePath varchar(255)
);
CREATE TABLE IF NOT EXISTS report_templates (
id varchar(36) primary key,
name varchar(255) not null,
`schema` text
);
CREATE TABLE IF NOT EXISTS operation_logs (
id varchar(36) primary key,
userId varchar(36) not null,
action varchar(255) not null,
detail text,
time bigint
);

View File

@ -0,0 +1,24 @@
import { createPool } from 'mysql2/promise'
import fs from 'fs'
import path from 'path'
async function run() {
const pool = createPool({
host: process.env.DB_HOST || 'localhost',
port: Number(process.env.DB_PORT || 3306),
user: process.env.DB_USER || 'root',
password: process.env.DB_PASS || 'rootpass',
database: process.env.DB_NAME || 'energy_saas',
multipleStatements: true
})
// 优先从 src 目录读取 SQL 文件(开发和生产环境都适用)
const srcPath = path.join(process.cwd(), 'src/modules/migrations/0001-init.sql')
const distPath = path.join(__dirname, '0001-init.sql')
const sqlPath = fs.existsSync(srcPath) ? srcPath : distPath
const sql = fs.readFileSync(sqlPath, 'utf-8')
await pool.query(sql)
await pool.end()
console.log('Migration applied from', sqlPath)
}
run().catch(err => { console.error(err); process.exit(1) })

View File

@ -0,0 +1,17 @@
import { Module } from '@nestjs/common'
import { TypeOrmModule } from '@nestjs/typeorm'
import { Tenant } from '../entities/tenant.entity'
import { Project } from '../entities/project.entity'
import { Building } from '../entities/building.entity'
import { Floor } from '../entities/floor.entity'
import { Room } from '../entities/room.entity'
import { Device } from '../entities/device.entity'
import { MockService } from './mock.service'
import { WebsocketGateway } from '../websocket/websocket.gateway'
@Module({
imports: [TypeOrmModule.forFeature([Tenant, Project, Building, Floor, Room, Device])],
providers: [MockService, WebsocketGateway]
})
export class MockModule {}

View File

@ -0,0 +1,62 @@
import { Injectable, OnModuleInit } from '@nestjs/common'
import { InjectRepository } from '@nestjs/typeorm'
import { Repository } from 'typeorm'
import { Tenant } from '../entities/tenant.entity'
import { Project } from '../entities/project.entity'
import { Building } from '../entities/building.entity'
import { Floor } from '../entities/floor.entity'
import { Room } from '../entities/room.entity'
import { Device } from '../entities/device.entity'
import { WebsocketGateway } from '../websocket/websocket.gateway'
@Injectable()
export class MockService implements OnModuleInit {
constructor(
@InjectRepository(Tenant) private tenants: Repository<Tenant>,
@InjectRepository(Project) private projects: Repository<Project>,
@InjectRepository(Building) private buildings: Repository<Building>,
@InjectRepository(Floor) private floors: Repository<Floor>,
@InjectRepository(Room) private rooms: Repository<Room>,
@InjectRepository(Device) private devices: Repository<Device>,
private ws: WebsocketGateway
) {}
async onModuleInit() {
const count = await this.tenants.count()
if (count === 0) await this.seed()
this.startRealtimeMock()
}
async seed() {
const tenantNames = ['万达酒店', '绿地地产', '华为上海研究所']
const tenants = await this.tenants.save(tenantNames.map(name => this.tenants.create({ name, createdAt: Date.now() })))
for (const t of tenants) {
for (let p = 1; p <= 2; p++) {
const project = await this.projects.save(this.projects.create({ name: `${t.name}项目${p}`, tenantId: t.id }))
const bs: Building[] = []
for (let b = 1; b <= 2; b++) bs.push(await this.buildings.save(this.buildings.create({ name: `楼栋${b}`, projectId: project.id })))
for (const bld of bs) {
for (let f = 1; f <= 15; f++) {
const floor = await this.floors.save(this.floors.create({ name: `F${f}`, buildingId: bld.id }))
for (let r = 1; r <= 20; r++) {
const room = await this.rooms.save(this.rooms.create({ name: `R${r}`, floorId: floor.id }))
await this.devices.save(this.devices.create({ room_id: room.id, type: 'AC', mac: `00:11:22:${(r%100).toString().padStart(2,'0')}:AA:${(f%100).toString().padStart(2,'0')}`, online_status: 'online', last_seen: Date.now() }))
}
}
}
}
}
}
startRealtimeMock() {
setInterval(async () => {
const some = await this.devices.find({ take: 50 })
for (const d of some) {
d.online_status = Math.random() > 0.1 ? 'online' : 'offline'
d.last_seen = Date.now()
await this.devices.save(d)
this.ws.pushRoomRealtime(d.room_id, { temp: Math.round(20 + Math.random() * 10), power: Math.round(100 + Math.random() * 50), online: d.online_status === 'online' })
}
}, 5000)
}
}

View File

@ -0,0 +1,10 @@
import { Body, Controller, Post } from '@nestjs/common'
@Controller('ota')
export class OtaController {
@Post()
upgrade(@Body() body: any) {
return { taskId: 'ota-' + Date.now(), status: 'queued', version: body?.version || '1.0.0' }
}
}

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common'
import { OtaController } from './ota.controller'
@Module({
controllers: [OtaController]
})
export class OtaModule {}

View File

@ -0,0 +1,19 @@
import { Controller, Get } from '@nestjs/common'
@Controller('project')
export class ProjectController {
@Get('tree')
tree() {
const buildings = Array.from({ length: 2 }).map((_, b) => ({
id: `b${b+1}`,
name: `楼栋${b+1}`,
floors: Array.from({ length: 15 }).map((_, f) => ({
id: `b${b+1}-f${f+1}`,
name: `F${f+1}`,
rooms: Array.from({ length: 20 }).map((_, r) => ({ id: `b${b+1}-f${f+1}-r${r+1}`, name: `R${r+1}` }))
}))
}))
return [{ id: 'p1', name: '项目1', buildings }, { id: 'p2', name: '项目2', buildings }]
}
}

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common'
import { ProjectController } from './project.controller'
@Module({
controllers: [ProjectController]
})
export class ProjectModule {}

View File

@ -0,0 +1,23 @@
import { Controller, Get, Param, Res } from '@nestjs/common'
import { Response } from 'express'
import PdfPrinter from 'pdfmake'
@Controller('report')
export class ReportController {
@Get('month/:year/:month')
async month(@Param('year') year: string, @Param('month') month: string, @Res() res: Response) {
const fonts = { Roboto: { normal: 'node_modules/pdfmake/build/vfs_fonts.js' } } as any
const printer = new PdfPrinter({})
const docDefinition = {
content: [
{ text: `能耗月报 ${year}-${month}`, style: 'header' },
{ text: '本报告为模拟数据,用于演示。' }
]
} as any
const pdfDoc = (printer as any).createPdfKitDocument(docDefinition)
res.setHeader('Content-Type', 'application/pdf')
pdfDoc.pipe(res)
pdfDoc.end()
}
}

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common'
import { ReportController } from './report.controller'
@Module({
controllers: [ReportController]
})
export class ReportModule {}

View File

@ -0,0 +1,10 @@
import { Controller, Get } from '@nestjs/common'
@Controller('tenant')
export class TenantController {
@Get('info')
info() {
return { id: 'tenant-1', name: '万达酒店', branding: { title: 'EnergySmart SaaS', logoUrl: '/logo.png' } }
}
}

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common'
import { TenantController } from './tenant.controller'
@Module({
controllers: [TenantController]
})
export class TenantModule {}

View File

@ -0,0 +1,26 @@
import { Injectable, OnModuleInit } from '@nestjs/common'
import { connect, MqttClient } from 'mqtt'
import { WebsocketGateway } from './websocket.gateway'
@Injectable()
export class MqttBridgeService implements OnModuleInit {
private client: MqttClient | null = null
constructor(private readonly ws: WebsocketGateway) {}
onModuleInit() {
const url = process.env.MQTT_URL
if (!url) return
this.client = connect(url, { clientId: `backend-${Date.now()}` })
this.client.on('connect', () => {
this.client?.subscribe('stat/+/+/+/+', { qos: 0 })
})
this.client.on('message', (topic, payload) => {
try {
const data = JSON.parse(payload.toString())
this.ws.server.emit('mqtt', { topic, data })
} catch {
this.ws.server.emit('mqtt', { topic, data: payload.toString() })
}
})
}
}

View File

@ -0,0 +1,12 @@
import { WebSocketGateway, WebSocketServer } from '@nestjs/websockets'
import { Server } from 'socket.io'
@WebSocketGateway({ cors: { origin: '*' } })
export class WebsocketGateway {
@WebSocketServer() server!: Server
pushRoomRealtime(roomId: string, payload: any) {
this.server.emit(`rooms/${roomId}/realtime`, payload)
}
}

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common'
import { WebsocketGateway } from './websocket.gateway'
import { MqttBridgeService } from './mqtt-bridge.service'
@Module({
providers: [WebsocketGateway, MqttBridgeService]
})
export class WebsocketModule {}

18
backend/tsconfig.json Normal file
View File

@ -0,0 +1,18 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "es2019",
"lib": ["es2019", "dom"],
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"strict": true,
"sourceMap": true,
"outDir": "dist",
"baseUrl": ".",
"skipLibCheck": true,
"esModuleInterop": true
},
"include": ["src/**/*", "src/modules/migrations/**/*"],
"exclude": ["node_modules", "dist"]
}

78
docker-compose.yml Normal file
View File

@ -0,0 +1,78 @@
services:
mysql:
image: mysql:8.0
container_name: energy_mysql
environment:
MYSQL_ROOT_PASSWORD: rootpass
MYSQL_DATABASE: energy_saas
MYSQL_USER: energy
MYSQL_PASSWORD: energypass
ports:
- "3306:3306"
command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
volumes:
- mysql_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 10
redis:
image: redis:7-alpine
container_name: energy_redis
volumes:
- redis_data:/data
tdengine:
image: tdengine/tdengine:3.0.2.0
container_name: energy_tdengine
ports:
- "6030:6030"
- "6041:6041"
environment:
TAOS_FQDN: tdengine
volumes:
- tdengine_data:/var/lib/taos
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: energy_backend
environment:
DB_HOST: mysql
DB_PORT: 3306
DB_USER: energy
DB_PASS: energypass
DB_NAME: energy_saas
REDIS_HOST: redis
REDIS_PORT: 6379
JWT_SECRET: energysmartsecret
PORT: 3000
MQTT_URL: wss://mqtt.yourcompany.com:8083/mqtt
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_started
ports:
- "3001:3000"
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: energy_frontend
environment:
VITE_BACKEND_URL: http://backend:3000
VITE_MQTT_URL: wss://mqtt.yourcompany.com:8083/mqtt
depends_on:
- backend
ports:
- "8080:80"
volumes:
mysql_data:
redis_data:
tdengine_data:

13
frontend/Dockerfile Normal file
View File

@ -0,0 +1,13 @@
FROM node:20-alpine as build
WORKDIR /app
COPY package.json package-lock.json* yarn.lock* pnpm-lock.yaml* ./
RUN npm i || yarn || true
COPY . .
ENV VITE_BACKEND_URL=http://backend:3000
ENV VITE_MQTT_URL=wss://mqtt.yourcompany.com:8083/mqtt
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

13
frontend/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EnergySmart SaaS</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

10
frontend/nginx.conf Normal file
View File

@ -0,0 +1,10 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}

2427
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
frontend/package.json Normal file
View File

@ -0,0 +1,25 @@
{
"name": "energy-smart-saas-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview --port 8080"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.3.0",
"pinia": "^2.1.7",
"element-plus": "^2.8.6",
"echarts": "^5.5.0",
"axios": "^1.6.7",
"mqtt": "^5.10.0",
"socket.io-client": "^4.7.5"
},
"devDependencies": {
"typescript": "^5.4.0",
"vite": "^5.0.0",
"@vitejs/plugin-vue": "^5.0.0"
}
}

11
frontend/public/logo.svg Normal file
View File

@ -0,0 +1,11 @@
<svg width="120" height="48" viewBox="0 0 120 48" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop stop-color="#00d4ff" offset="0"/>
<stop stop-color="#0a84ff" offset="1"/>
</linearGradient>
</defs>
<rect rx="8" width="120" height="48" fill="#0b132b"/>
<text x="60" y="30" text-anchor="middle" fill="url(#g)" font-size="18" font-family="Arial, Helvetica">EnergySmart</text>
</svg>

After

Width:  |  Height:  |  Size: 462 B

83
frontend/src/App.vue Normal file
View File

@ -0,0 +1,83 @@
<template>
<router-view />
</template>
<script setup lang="ts"></script>
<style>
html, body, #app { height: 100%; margin: 0; }
/* 自定义滚动条 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
border-radius: 3px;
}
::-webkit-scrollbar-thumb {
background: rgba(100, 116, 139, 0.5);
border-radius: 3px;
transition: background 0.2s;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(100, 116, 139, 0.8);
}
::-webkit-scrollbar-corner {
background: transparent;
}
/* Firefox 滚动条 */
* {
scrollbar-width: thin;
scrollbar-color: rgba(100, 116, 139, 0.5) rgba(255, 255, 255, 0.05);
}
/* Element Plus 下拉框弹出层样式 */
.el-select-dropdown {
background: #1e293b !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
border-radius: 8px !important;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5) !important;
}
.el-select-dropdown__item {
color: #94a3b8 !important;
font-size: 13px !important;
padding: 8px 12px !important;
}
.el-select-dropdown__item:hover {
background: rgba(59, 130, 246, 0.15) !important;
color: #e2e8f0 !important;
}
.el-select-dropdown__item.is-selected {
background: rgba(59, 130, 246, 0.25) !important;
color: #60a5fa !important;
font-weight: 500 !important;
}
.el-select-dropdown__item.is-selected::after {
content: '✓';
float: right;
color: #60a5fa;
}
.el-popper.is-light {
background: #1e293b !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
}
.el-popper.is-light .el-popper__arrow::before {
background: #1e293b !important;
border-color: rgba(255, 255, 255, 0.1) !important;
}
/* 下拉框空状态 */
.el-select-dropdown__empty {
color: #64748b !important;
}
</style>

View File

@ -0,0 +1,121 @@
<template>
<div ref="refEl" style="height:320px"></div>
</template>
<script setup lang="ts">
import * as echarts from 'echarts'
import { onMounted, ref, watch, onUnmounted } from 'vue'
const props = defineProps<{ points: { t: number, v: number }[], range?: string }>()
const refEl = ref<HTMLElement | null>(null)
let chart: echarts.ECharts | null = null
function formatTime(ts: number) {
const d = new Date(ts)
if (props.range === 'week') {
const days = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
return `${d.getMonth() + 1}/${d.getDate()} ${days[d.getDay()]}`
} else if (props.range === 'month') {
return `${d.getMonth() + 1}/${d.getDate()}`
}
return `${d.getHours().toString().padStart(2, '0')}:00`
}
function getUnit() {
return props.range === 'day' ? 'kW' : 'kWh'
}
function render() {
if (!refEl.value) return
if (!chart) {
chart = echarts.init(refEl.value, 'dark')
}
const xData = props.points.map(p => formatTime(p.t))
const yData = props.points.map(p => p.v)
chart.setOption({
backgroundColor: 'transparent',
grid: {
top: 40,
right: 20,
bottom: 40,
left: 60,
containLabel: false
},
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(15, 23, 42, 0.9)',
borderColor: 'rgba(59, 130, 246, 0.5)',
borderWidth: 1,
textStyle: { color: '#e2e8f0' },
formatter: (params: any) => {
const p = params[0]
const unit = getUnit()
return `<div style="font-weight:600">${p.name}</div>
<div style="color:#60a5fa;font-size:18px;margin-top:4px">${p.value} ${unit}</div>`
}
},
xAxis: {
type: 'category',
data: xData,
axisLine: { lineStyle: { color: 'rgba(255,255,255,0.1)' } },
axisLabel: { color: '#64748b', fontSize: 11 },
axisTick: { show: false }
},
yAxis: {
type: 'value',
splitLine: { lineStyle: { color: 'rgba(255,255,255,0.05)' } },
axisLine: { show: false },
axisLabel: { color: '#64748b', fontSize: 11, formatter: (v: number) => `${v} ${getUnit()}` }
},
series: [{
type: 'line',
data: yData,
smooth: true,
symbol: 'circle',
symbolSize: 6,
showSymbol: false,
lineStyle: {
width: 3,
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#3b82f6' },
{ offset: 1, color: '#8b5cf6' }
])
},
itemStyle: {
color: '#3b82f6',
borderColor: '#fff',
borderWidth: 2
},
emphasis: {
showSymbol: true,
symbolSize: 10
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(59, 130, 246, 0.4)' },
{ offset: 1, color: 'rgba(59, 130, 246, 0)' }
])
}
}]
})
}
function handleResize() {
chart?.resize()
}
onMounted(() => {
render()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
chart?.dispose()
})
watch(() => props.points, render, { deep: true })
</script>

View File

@ -0,0 +1,797 @@
<template>
<div class="nav-wrapper">
<!-- 面包屑导航 -->
<div class="breadcrumb">
<span class="crumb" @click="showPanel('project')">
{{ currentProject?.name || '选择项目' }}
<i class="arrow"></i>
</span>
<span class="sep">/</span>
<span class="crumb" @click="showPanel('building')" v-if="selected.project">
{{ currentBuilding?.name || '楼栋' }}
<i class="arrow"></i>
</span>
<span class="sep" v-if="selected.building">/</span>
<span class="crumb" @click="showPanel('floor')" v-if="selected.building">
{{ currentFloor?.name || '楼层' }}
<i class="arrow"></i>
</span>
</div>
<!-- 选择面板 -->
<div class="select-panel" v-if="activePanel">
<div class="panel-header">
<span>{{ panelTitle }}</span>
<span class="close-btn" @click="activePanel = ''"></span>
</div>
<div class="panel-list">
<div
v-for="item in panelItems"
:key="item.id"
class="panel-item"
:class="{ active: isItemActive(item) }"
@click="selectItem(item)"
>
<span class="item-icon">{{ getIcon(activePanel) }}</span>
<span class="item-name">{{ item.name }}</span>
<span class="item-check" v-if="isItemActive(item)"></span>
</div>
</div>
</div>
<!-- 快速搜索 -->
<div class="search-section">
<div id="poda">
<div class="glow"></div>
<div class="darkBorderBg"></div>
<div class="darkBorderBg"></div>
<div class="darkBorderBg"></div>
<div class="white"></div>
<div class="border"></div>
<div id="main">
<input
v-model="searchKey"
placeholder="Search..."
type="text"
class="input"
@focus="searchFocused = true"
@blur="onSearchBlur"
/>
<div id="input-mask"></div>
<div id="pink-mask"></div>
<div class="filterBorder"></div>
<div id="filter-icon">
<svg preserveAspectRatio="none" height="27" width="27" viewBox="4.8 4.56 14.832 15.408" fill="none">
<path d="M8.16 6.65002H15.83C16.47 6.65002 16.99 7.17002 16.99 7.81002V9.09002C16.99 9.56002 16.7 10.14 16.41 10.43L13.91 12.64C13.56 12.93 13.33 13.51 13.33 13.98V16.48C13.33 16.83 13.1 17.29 12.81 17.47L12 17.98C11.24 18.45 10.2 17.92 10.2 16.99V13.91C10.2 13.5 9.97 12.98 9.73 12.69L7.52 10.36C7.23 10.08 7 9.55002 7 9.20002V7.87002C7 7.17002 7.52 6.65002 8.16 6.65002Z" stroke="#d6d6e6" stroke-width="1" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</div>
<div id="search-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="24" viewBox="0 0 24 24" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" height="24" fill="none">
<circle stroke="url(#search)" r="8" cy="11" cx="11"></circle>
<line stroke="url(#searchl)" y2="16.65" y1="22" x2="16.65" x1="22"></line>
<defs>
<linearGradient gradientTransform="rotate(50)" id="search">
<stop stop-color="#f8e7f8" offset="0%"></stop>
<stop stop-color="#b6a9b7" offset="50%"></stop>
</linearGradient>
<linearGradient id="searchl">
<stop stop-color="#b6a9b7" offset="0%"></stop>
<stop stop-color="#837484" offset="50%"></stop>
</linearGradient>
</defs>
</svg>
</div>
</div>
</div>
<div class="search-results" v-if="searchFocused && searchKey">
<div
v-for="r in searchResults"
:key="r.id"
class="search-item"
@mousedown="jumpToRoom(r)"
>
<span class="search-path">{{ r.path }}</span>
<span class="search-name">{{ r.name }}</span>
</div>
</div>
</div>
<!-- 房间网格 -->
<div class="rooms-section" v-if="rooms.length && !activePanel">
<div class="rooms-header">
<span class="rooms-title">🚪 房间</span>
<span class="rooms-count">{{ rooms.length }}</span>
</div>
<div class="rooms-grid">
<div
v-for="r in rooms"
:key="r.id"
class="room-card"
:class="{ active: selected.room === r.id }"
@click="selectRoom(r)"
>
<div class="room-status" :class="r.id.charCodeAt(1) % 3 === 0 ? 'offline' : 'online'"></div>
<div class="room-name">{{ r.name }}</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, reactive } from 'vue'
import axios from 'axios'
const emit = defineEmits(['selectRoom'])
const rawData = ref<any[]>([])
const searchKey = ref('')
const searchFocused = ref(false)
const activePanel = ref('')
const base = (import.meta as any).env.VITE_BACKEND_URL || 'http://localhost:3001'
const selected = reactive({
project: '',
building: '',
floor: '',
room: ''
})
//
const projects = computed(() => rawData.value)
const buildings = computed(() => {
const project = rawData.value.find(p => p.id === selected.project)
return project?.buildings || []
})
const floors = computed(() => {
const building = buildings.value.find((b: any) => b.id === selected.building)
return building?.floors || []
})
const rooms = computed(() => {
const floor = floors.value.find((f: any) => f.id === selected.floor)
return floor?.rooms || []
})
//
const currentProject = computed(() => rawData.value.find(p => p.id === selected.project))
const currentBuilding = computed(() => buildings.value.find((b: any) => b.id === selected.building))
const currentFloor = computed(() => floors.value.find((f: any) => f.id === selected.floor))
//
const panelTitle = computed(() => {
const titles: Record<string, string> = { project: '选择项目', building: '选择楼栋', floor: '选择楼层' }
return titles[activePanel.value] || ''
})
const panelItems = computed(() => {
if (activePanel.value === 'project') return projects.value
if (activePanel.value === 'building') return buildings.value
if (activePanel.value === 'floor') return floors.value
return []
})
function getIcon(type: string) {
const icons: Record<string, string> = { project: '📁', building: '🏢', floor: '🏠' }
return icons[type] || '📄'
}
function isItemActive(item: any) {
if (activePanel.value === 'project') return item.id === selected.project
if (activePanel.value === 'building') return item.id === selected.building
if (activePanel.value === 'floor') return item.id === selected.floor
return false
}
function showPanel(type: string) {
activePanel.value = activePanel.value === type ? '' : type
}
function selectItem(item: any) {
if (activePanel.value === 'project') {
selected.project = item.id
selected.building = ''
selected.floor = ''
selected.room = ''
//
if (buildings.value.length) {
selected.building = buildings.value[0].id
if (floors.value.length) {
selected.floor = floors.value[0].id
}
}
} else if (activePanel.value === 'building') {
selected.building = item.id
selected.floor = ''
selected.room = ''
if (floors.value.length) {
selected.floor = floors.value[0].id
}
} else if (activePanel.value === 'floor') {
selected.floor = item.id
selected.room = ''
}
activePanel.value = ''
}
//
const searchResults = computed(() => {
if (!searchKey.value) return []
const key = searchKey.value.toLowerCase()
const results: any[] = []
rawData.value.forEach(p => {
p.buildings?.forEach((b: any) => {
b.floors?.forEach((f: any) => {
f.rooms?.forEach((r: any) => {
if (r.name?.toLowerCase().includes(key)) {
results.push({
...r,
path: `${p.name} / ${b.name} / ${f.name}`,
projectId: p.id,
buildingId: b.id,
floorId: f.id
})
}
})
})
})
})
return results.slice(0, 10)
})
function selectRoom(room: any) {
selected.room = room.id
emit('selectRoom', room.id)
}
function jumpToRoom(r: any) {
selected.project = r.projectId
selected.building = r.buildingId
selected.floor = r.floorId
selected.room = r.id
searchKey.value = ''
searchFocused.value = false
emit('selectRoom', r.id)
}
function onSearchBlur() {
setTimeout(() => { searchFocused.value = false }, 200)
}
async function load() {
const { data } = await axios.get(`${base}/project/tree`)
rawData.value = data
//
if (data.length) {
selected.project = data[0].id
if (buildings.value.length) {
selected.building = buildings.value[0].id
if (floors.value.length) {
selected.floor = floors.value[0].id
}
}
}
}
onMounted(load)
</script>
<style scoped>
.nav-wrapper {
height: 100%;
display: flex;
flex-direction: column;
padding: 12px;
gap: 12px;
overflow: hidden;
}
/* 面包屑导航 */
.breadcrumb {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
padding: 12px;
background: #171717;
border-radius: 10px;
border: 1px solid #262626;
}
.crumb {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 10px;
background: #262626;
border-radius: 6px;
font-size: 13px;
color: #e5e5e5;
cursor: pointer;
transition: all 0.2s;
border: 1px solid #404040;
}
.crumb:hover {
background: #404040;
border-color: #7c3aed;
}
.crumb .arrow {
font-size: 10px;
color: #737373;
font-style: normal;
}
.sep {
color: #737373;
font-size: 12px;
}
/* 选择面板 */
.select-panel {
background: #171717;
border: 1px solid #262626;
border-radius: 10px;
overflow: hidden;
animation: slideDown 0.2s ease;
}
@keyframes slideDown {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: #262626;
border-bottom: 1px solid #404040;
font-size: 13px;
font-weight: 500;
color: #a78bfa;
}
.close-btn {
cursor: pointer;
color: #737373;
transition: color 0.2s;
}
.close-btn:hover {
color: #e5e5e5;
}
.panel-list {
max-height: 200px;
overflow: auto;
}
.panel-item {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
cursor: pointer;
transition: all 0.2s;
border-bottom: 1px solid #262626;
}
.panel-item:last-child {
border-bottom: none;
}
.panel-item:hover {
background: #262626;
}
.panel-item.active {
background: rgba(124, 58, 237, 0.2);
}
.item-icon {
font-size: 16px;
}
.item-name {
flex: 1;
font-size: 13px;
color: #e5e5e5;
}
.item-check {
color: #4ade80;
font-weight: bold;
}
/* 房间网格 */
.rooms-section {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
overflow: hidden;
}
.rooms-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 4px;
}
.rooms-title {
font-size: 12px;
color: #737373;
}
.rooms-count {
background: linear-gradient(135deg, #7c3aed, #db2777);
color: #fff;
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
font-weight: 500;
}
.rooms-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
overflow: auto;
flex: 1;
padding: 4px;
}
.room-card {
position: relative;
background: #171717;
border: 1px solid #262626;
border-radius: 8px;
padding: 10px 8px;
cursor: pointer;
transition: all 0.2s;
text-align: center;
height: auto;
}
.room-card:hover {
background: #262626;
border-color: #7c3aed;
}
.room-card.active {
background: linear-gradient(135deg, #7c3aed, #db2777);
border-color: transparent;
}
.room-name {
font-size: 11px;
color: #a3a3a3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.room-card.active .room-name {
color: #fff;
font-weight: 500;
}
.room-status {
position: absolute;
top: 4px;
right: 4px;
width: 5px;
height: 5px;
border-radius: 50%;
}
.room-status.online {
background: #4ade80;
}
.room-status.offline {
background: #525252;
}
/* 搜索区域 */
.search-section {
position: relative;
padding: 10px 0;
}
#poda {
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.white, .border, .darkBorderBg, .glow {
max-height: 60px;
max-width: 212px;
height: 60px;
width: 212px;
position: absolute;
overflow: hidden;
border-radius: 12px;
filter: blur(3px);
}
.input {
background-color: #010201;
border: none;
width: 200px;
height: 46px;
border-radius: 10px;
color: white;
padding-left: 42px;
padding-right: 46px;
font-size: 13px;
position: relative;
z-index: 1;
box-sizing: border-box;
}
.input::placeholder {
color: #c0b9c0;
}
.input:focus {
outline: none;
}
#main:focus-within > #input-mask {
display: none;
}
#input-mask {
pointer-events: none;
width: 80px;
height: 16px;
position: absolute;
background: linear-gradient(90deg, transparent, black);
top: 15px;
left: 50px;
z-index: 2;
}
#pink-mask {
pointer-events: none;
width: 24px;
height: 16px;
position: absolute;
background: #cf30aa;
top: 8px;
left: 4px;
filter: blur(16px);
opacity: 0.8;
transition: all 2s;
z-index: 2;
}
#main:hover > #pink-mask {
opacity: 0;
}
.white {
max-height: 53px;
max-width: 206px;
height: 53px;
width: 206px;
border-radius: 10px;
filter: blur(2px);
}
.white::before {
content: "";
z-index: -2;
text-align: center;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(83deg);
position: absolute;
width: 600px;
height: 600px;
background-repeat: no-repeat;
background-position: 0 0;
filter: brightness(1.4);
background-image: conic-gradient(rgba(0,0,0,0) 0%, #a099d8, rgba(0,0,0,0) 8%, rgba(0,0,0,0) 50%, #dfa2da, rgba(0,0,0,0) 58%);
transition: all 2s;
}
.border {
max-height: 49px;
max-width: 202px;
height: 49px;
width: 202px;
border-radius: 11px;
filter: blur(0.5px);
}
.border::before {
content: "";
z-index: -2;
text-align: center;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(70deg);
position: absolute;
width: 600px;
height: 600px;
filter: brightness(1.3);
background-repeat: no-repeat;
background-position: 0 0;
background-image: conic-gradient(#1c191c, #402fb5 5%, #1c191c 14%, #1c191c 50%, #cf30aa 60%, #1c191c 64%);
transition: all 2s;
}
.darkBorderBg {
max-height: 55px;
max-width: 210px;
height: 55px;
width: 210px;
}
.darkBorderBg::before {
content: "";
z-index: -2;
text-align: center;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(82deg);
position: absolute;
width: 600px;
height: 600px;
background-repeat: no-repeat;
background-position: 0 0;
background-image: conic-gradient(rgba(0,0,0,0), #18116a, rgba(0,0,0,0) 10%, rgba(0,0,0,0) 50%, #6e1b60, rgba(0,0,0,0) 60%);
transition: all 2s;
}
#poda:hover > .darkBorderBg::before { transform: translate(-50%, -50%) rotate(-98deg); }
#poda:hover > .glow::before { transform: translate(-50%, -50%) rotate(-120deg); }
#poda:hover > .white::before { transform: translate(-50%, -50%) rotate(-97deg); }
#poda:hover > .border::before { transform: translate(-50%, -50%) rotate(-110deg); }
#poda:focus-within > .darkBorderBg::before { transform: translate(-50%, -50%) rotate(442deg); transition: all 4s; }
#poda:focus-within > .glow::before { transform: translate(-50%, -50%) rotate(420deg); transition: all 4s; }
#poda:focus-within > .white::before { transform: translate(-50%, -50%) rotate(443deg); transition: all 4s; }
#poda:focus-within > .border::before { transform: translate(-50%, -50%) rotate(430deg); transition: all 4s; }
.glow {
overflow: hidden;
filter: blur(30px);
opacity: 0.4;
max-height: 100px;
max-width: 240px;
height: 100px;
width: 240px;
}
.glow::before {
content: "";
z-index: -2;
text-align: center;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(60deg);
position: absolute;
width: 999px;
height: 999px;
background-repeat: no-repeat;
background-position: 0 0;
background-image: conic-gradient(#000, #402fb5 5%, #000 38%, #000 50%, #cf30aa 60%, #000 87%);
transition: all 2s;
}
#filter-icon {
position: absolute;
top: 5px;
right: 5px;
display: flex;
align-items: center;
justify-content: center;
z-index: 3;
height: 36px;
width: 36px;
isolation: isolate;
overflow: hidden;
border-radius: 8px;
background: linear-gradient(180deg, #161329, black, #1d1b4b);
border: 1px solid transparent;
}
.filterBorder {
height: 38px;
width: 38px;
position: absolute;
overflow: hidden;
top: 4px;
right: 4px;
border-radius: 8px;
z-index: 2;
}
.filterBorder::before {
content: "";
text-align: center;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(90deg);
position: absolute;
width: 600px;
height: 600px;
background-repeat: no-repeat;
background-position: 0 0;
filter: brightness(1.35);
background-image: conic-gradient(rgba(0,0,0,0), #3d3a4f, rgba(0,0,0,0) 50%, rgba(0,0,0,0) 50%, #3d3a4f, rgba(0,0,0,0) 100%);
animation: rotate 4s linear infinite;
}
#main {
position: relative;
z-index: 1;
}
#search-icon {
position: absolute;
left: 12px;
top: 11px;
z-index: 2;
}
@keyframes rotate {
100% {
transform: translate(-50%, -50%) rotate(450deg);
}
}
.search-results {
position: absolute;
bottom: 100%;
left: 0;
right: 0;
background: #171717;
border: 1px solid #262626;
border-radius: 8px;
margin-bottom: 8px;
max-height: 200px;
overflow: auto;
}
.search-item {
padding: 10px 12px;
cursor: pointer;
border-bottom: 1px solid #262626;
transition: background 0.2s;
}
.search-item:last-child {
border-bottom: none;
}
.search-item:hover {
background: #262626;
}
.search-path {
display: block;
font-size: 10px;
color: #737373;
margin-bottom: 2px;
}
.search-name {
font-size: 13px;
color: #e5e5e5;
}
</style>

View File

@ -0,0 +1,40 @@
<template>
<div class="room-table">
<el-table :data="items" @row-contextmenu="onContext" @selection-change="onSel" border>
<el-table-column type="selection" width="48" />
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="房间" />
<el-table-column prop="building" label="楼栋" width="80" />
<el-table-column prop="floor" label="楼层" width="80" />
</el-table>
<div v-if="menu.show" class="menu" :style="{ left: menu.x+'px', top: menu.y+'px' }">
<div class="item" @click="bulkSetTemp">批量设置温度</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import axios from 'axios'
const props = defineProps<{ items: any[] }>()
const emit = defineEmits(['selected'])
const selected = ref<any[]>([])
const menu = ref({ show: false, x: 0, y: 0 })
function onSel(val: any[]) { selected.value = val; emit('selected', val) }
function onContext(row: any, col: any, evt: MouseEvent) { evt.preventDefault(); menu.value = { show: true, x: evt.clientX, y: evt.clientY } }
async function bulkSetTemp() {
menu.value.show = false
const base = (import.meta as any).env.VITE_BACKEND_URL || 'http://localhost:3001'
const roomIds = selected.value.map(r => r.id)
const { data } = await axios.post(`${base}/batch/command`, { cmd: 'set_temp', value: 24, rooms: roomIds })
ElMessage.success(`任务 ${data.taskId} 已下发,共 ${data.total}`)
}
</script>
<style scoped>
.room-table { position: relative; }
.menu { position: fixed; background: #fff; border: 1px solid #ddd; border-radius: 8px; box-shadow: 0 6px 20px rgba(0,0,0,.15); }
.item { padding: 8px 12px; cursor: pointer; }
.item:hover { background: #f5f5f5; }
</style>

13
frontend/src/main.ts Normal file
View File

@ -0,0 +1,13 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(ElementPlus)
app.use(router)
app.mount('#app')

View File

@ -0,0 +1,24 @@
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
import Login from '@/views/Login.vue'
import Dashboard from '@/views/Dashboard.vue'
import RoomList from '@/views/RoomList.vue'
import EnergyOverview from '@/views/EnergyOverview.vue'
import ReportCenter from '@/views/ReportCenter.vue'
import AlarmCenter from '@/views/AlarmCenter.vue'
import BatchOperation from '@/views/BatchOperation.vue'
import ProjectManage from '@/views/ProjectManage.vue'
const routes: RouteRecordRaw[] = [
{ path: '/', redirect: '/login' },
{ path: '/login', component: Login },
{ path: '/dashboard', component: Dashboard },
{ path: '/rooms', component: RoomList },
{ path: '/energy', component: EnergyOverview },
{ path: '/report', component: ReportCenter },
{ path: '/alarm', component: AlarmCenter },
{ path: '/batch', component: BatchOperation },
{ path: '/project', component: ProjectManage }
]
const router = createRouter({ history: createWebHistory(), routes })
export default router

View File

@ -0,0 +1,14 @@
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => ({ token: '', user: null as any, whiteLabel: { title: 'EnergySmart SaaS', logoUrl: '' } }),
actions: {
setLogin(payload: any) {
this.token = payload.token
this.user = payload.user
this.whiteLabel = payload.whiteLabel
},
logout() { this.token = ''; this.user = null }
}
})

View File

@ -0,0 +1,15 @@
import { connect, MqttClient } from 'mqtt'
let client: MqttClient | null = null
export function getMqttClient(tenantId: string, userId: string) {
if (client) return client
const url = (import.meta as any).env.VITE_MQTT_URL || 'wss://mqtt.yourcompany.com:8083/mqtt'
client = connect(url, {
clientId: `${tenantId}-${userId}-${Date.now()}`,
clean: true,
reconnectPeriod: 3000
})
return client
}

View File

@ -0,0 +1,26 @@
<template>
<div class="wrap">
<el-table :data="alarms" border>
<el-table-column prop="id" label="ID" width="120" />
<el-table-column prop="level" label="级别" width="120" />
<el-table-column prop="message" label="消息" />
<el-table-column prop="time" label="时间" />
<el-table-column label="操作" width="140">
<template #default="{ row }">
<el-button size="small" @click="ack(row.id)">确认</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import axios from 'axios'
const alarms = ref<any[]>([])
const base = (import.meta as any).env.VITE_BACKEND_URL || 'http://localhost:3001'
async function load() { const { data } = await axios.get(`${base}/alarm/unread`); alarms.value = data }
async function ack(id: string) { await axios.post(`${base}/alarm/ack/${id}`); load() }
onMounted(load)
</script>

View File

@ -0,0 +1,23 @@
<template>
<div class="wrap">
<el-input-number v-model="temp" :min="16" :max="30" />
<el-button type="primary" @click="send">批量设置空调温度</el-button>
<el-progress :percentage="progress" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import axios from 'axios'
const temp = ref(24)
const progress = ref(0)
const base = (import.meta as any).env.VITE_BACKEND_URL || 'http://localhost:3001'
async function send() {
progress.value = 0
const rooms = Array.from({ length: 50 }).map((_, i) => `R${i+1}`)
const { data } = await axios.post(`${base}/batch/command`, { cmd: 'set_temp', value: temp.value, rooms })
let done = 0
const timer = setInterval(() => { done++; progress.value = Math.min(100, Math.round(done / rooms.length * 100)); if (progress.value >= 100) clearInterval(timer) }, 200)
}
</script>

View File

@ -0,0 +1,921 @@
<template>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-header">
<div class="sp">
<div class="sparkle-logo">
<span class="spark"></span>
<span class="backdrop"></span>
<svg class="sparkle" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.187 8.096L15 5.25L15.813 8.096C16.0231 8.83114 16.4171 9.50062 16.9577 10.0413C17.4984 10.5819 18.1679 10.9759 18.903 11.186L21.75 12L18.904 12.813C18.1689 13.0231 17.4994 13.4171 16.9587 13.9577C16.4181 14.4984 16.0241 15.1679 15.814 15.903L15 18.75L14.187 15.904C13.9769 15.1689 13.5829 14.4994 13.0423 13.9587C12.5016 13.4181 11.8321 13.0241 11.097 12.814L8.25 12L11.096 11.187C11.8311 10.9769 12.5006 10.5829 13.0413 10.0423C13.5819 9.50162 13.9759 8.83214 14.186 8.097L14.187 8.096Z" fill="white" stroke="white" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M6 14.25L5.741 15.285C5.59267 15.8785 5.28579 16.4206 4.85319 16.8532C4.42059 17.2858 3.87853 17.5927 3.285 17.741L2.25 18L3.285 18.259C3.87853 18.4073 4.42059 18.7142 4.85319 19.1468C5.28579 19.5794 5.59267 20.1215 5.741 20.715L6 21.75L6.259 20.715C6.40725 20.1216 6.71398 19.5796 7.14639 19.147C7.5788 18.7144 8.12065 18.4075 8.714 18.259L9.75 18L8.714 17.741C8.12065 17.5925 7.5788 17.2856 7.14639 16.853C6.71398 16.4204 6.40725 15.8784 6.259 15.285L6 14.25Z" fill="white" stroke="white" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M6.5 4L6.303 4.5915C6.24777 4.75718 6.15472 4.90774 6.03123 5.03123C5.90774 5.15472 5.75718 5.24777 5.5915 5.303L5 5.5L5.5915 5.697C5.75718 5.75223 5.90774 5.84528 6.03123 5.96877C6.15472 6.09226 6.24777 6.24282 6.303 6.4085L6.5 7L6.697 6.4085C6.75223 6.24282 6.84528 6.09226 6.96877 5.96877C7.09226 5.84528 7.24282 5.75223 7.4085 5.697L8 5.5L7.4085 5.303C7.24282 5.24777 7.09226 5.15472 6.96877 5.03123C6.84528 4.90774 6.75223 4.75718 6.697 4.5915L6.5 4Z" fill="white" stroke="white" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</div>
</div>
<h2>能源管理</h2>
</div>
<ProjectTree @selectRoom="onSelectRoom" />
</aside>
<main class="main">
<header class="page-header">
<div class="page-title">
<h1>{{ selectedRoom ? `房间 ${selectedRoom}` : '实时监控大屏' }}</h1>
<span class="back-btn" v-if="selectedRoom" @click="backToOverview"> 返回总览</span>
</div>
<div class="header-right">
<router-link to="/project" class="btn-manage">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 3h7a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7"/>
<path d="M16 3v4h4"/>
<line x1="9" y1="9" x2="15" y2="9"/>
<line x1="9" y1="13" x2="15" y2="13"/>
<line x1="9" y1="17" x2="13" y2="17"/>
</svg>
项目管理
</router-link>
<div class="time">{{ currentTime }}</div>
</div>
</header>
<div class="metrics-grid">
<div class="metric-card primary">
<div class="metric-icon"></div>
<div class="metric-content">
<div class="metric-label">实时总功率</div>
<div class="metric-value">
<span class="number">{{ realtime.totalPower }}</span>
<span class="unit">kW</span>
</div>
</div>
<div class="metric-trend up"> 12%</div>
</div>
<div class="metric-card" v-for="(s, i) in realtime.subitems" :key="s.name">
<div class="metric-icon">{{ getIcon(s.name) }}</div>
<div class="metric-content">
<div class="metric-label">{{ s.name }}</div>
<div class="metric-value">
<span class="number">{{ s.kw }}</span>
<span class="unit">kW</span>
</div>
</div>
<div class="metric-percent">{{ Math.round(s.kw / realtime.totalPower * 100) }}%</div>
</div>
</div>
<div class="charts-section">
<div class="chart-card">
<div class="chart-header">
<h3>功率趋势</h3>
<div class="chart-tabs">
<span class="tab" :class="{ active: timeRange === 'day' }" @click="changeRange('day')">今日</span>
<span class="tab" :class="{ active: timeRange === 'week' }" @click="changeRange('week')">本周</span>
<span class="tab" :class="{ active: timeRange === 'month' }" @click="changeRange('month')">本月</span>
</div>
</div>
<EnergyChart :points="history.points" :range="timeRange" />
</div>
<div class="stats-card">
<h3>能耗分布</h3>
<div class="stats-list">
<div class="stat-item" v-for="s in realtime.subitems" :key="s.name">
<div class="stat-info">
<span class="stat-name">{{ s.name }}</span>
<span class="stat-value">{{ s.kw }} kW</span>
</div>
<div class="stat-bar">
<div class="stat-fill" :style="{ width: (s.kw / realtime.totalPower * 100) + '%' }"></div>
</div>
</div>
</div>
</div>
</div>
<!-- 第二行卡片 -->
<div class="cards-row">
<!-- 今日能耗统计 -->
<div class="info-card">
<div class="card-header">
<h3>📊 今日能耗统计</h3>
</div>
<div class="energy-stats">
<div class="energy-item">
<span class="energy-label">今日用电</span>
<span class="energy-value">{{ stats.todayEnergy }}<small>kWh</small></span>
</div>
<div class="energy-item">
<span class="energy-label">昨日用电</span>
<span class="energy-value dim">{{ stats.yesterdayEnergy }}<small>kWh</small></span>
</div>
<div class="energy-item">
<span class="energy-label">环比变化</span>
<span class="energy-value" :class="stats.changePercent >= 0 ? 'up' : 'down'">
{{ stats.changePercent >= 0 ? '+' : '' }}{{ stats.changePercent }}%
</span>
</div>
<div class="energy-item">
<span class="energy-label">本月累计</span>
<span class="energy-value">{{ stats.monthEnergy }}<small>kWh</small></span>
</div>
</div>
</div>
<!-- 设备状态 -->
<div class="info-card">
<div class="card-header">
<h3>🔌 设备状态</h3>
</div>
<div class="device-grid">
<div class="device-item">
<div class="device-icon online">🟢</div>
<div class="device-info">
<span class="device-count">{{ deviceStatus.online }}</span>
<span class="device-label">在线设备</span>
</div>
</div>
<div class="device-item">
<div class="device-icon offline">🔴</div>
<div class="device-info">
<span class="device-count">{{ deviceStatus.offline }}</span>
<span class="device-label">离线设备</span>
</div>
</div>
<div class="device-item">
<div class="device-icon warning">🟡</div>
<div class="device-info">
<span class="device-count">{{ deviceStatus.warning }}</span>
<span class="device-label">告警设备</span>
</div>
</div>
<div class="device-item">
<div class="device-icon total">📱</div>
<div class="device-info">
<span class="device-count">{{ deviceStatus.total }}</span>
<span class="device-label">设备总数</span>
</div>
</div>
</div>
</div>
<!-- 告警信息 -->
<div class="info-card">
<div class="card-header">
<h3>🔔 最新告警</h3>
<span class="badge" v-if="alarms.length">{{ alarms.length }}</span>
</div>
<div class="alarm-list">
<div class="alarm-item" v-for="(alarm, i) in alarms.slice(0, 4)" :key="i">
<span class="alarm-level" :class="alarm.level">{{ alarm.level === 'high' ? '🔴' : alarm.level === 'medium' ? '🟡' : '🟢' }}</span>
<span class="alarm-msg">{{ alarm.message }}</span>
<span class="alarm-time">{{ alarm.time }}</span>
</div>
<div class="no-alarm" v-if="!alarms.length">暂无告警 </div>
</div>
</div>
</div>
<!-- 第三行卡片 -->
<div class="cards-row">
<!-- 节能建议 -->
<div class="info-card wide">
<div class="card-header">
<h3>💡 节能建议</h3>
</div>
<div class="tips-list">
<div class="tip-item" v-for="(tip, i) in energyTips" :key="i">
<span class="tip-icon">{{ tip.icon }}</span>
<div class="tip-content">
<span class="tip-title">{{ tip.title }}</span>
<span class="tip-desc">{{ tip.desc }}</span>
</div>
<span class="tip-save">预计节省 {{ tip.save }}</span>
</div>
</div>
</div>
<!-- 环境数据 -->
<div class="info-card">
<div class="card-header">
<h3>🌡 环境数据</h3>
</div>
<div class="env-grid">
<div class="env-item">
<span class="env-icon">🌡</span>
<span class="env-value">{{ envData.temperature }}°C</span>
<span class="env-label">室内温度</span>
</div>
<div class="env-item">
<span class="env-icon">💧</span>
<span class="env-value">{{ envData.humidity }}%</span>
<span class="env-label">室内湿度</span>
</div>
<div class="env-item">
<span class="env-icon">🌬</span>
<span class="env-value">{{ envData.pm25 }}</span>
<span class="env-label">PM2.5</span>
</div>
<div class="env-item">
<span class="env-icon">💨</span>
<span class="env-value">{{ envData.co2 }}</span>
<span class="env-label">CO₂ ppm</span>
</div>
</div>
</div>
</div>
</main>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import axios from 'axios'
import { io } from 'socket.io-client'
import ProjectTree from '@/components/ProjectTree/index.vue'
import EnergyChart from '@/components/EnergyChart/index.vue'
const realtime = ref({ totalPower: 0, subitems: [] as any[] })
const history = ref({ points: [] as any[] })
const timeRange = ref<'day' | 'week' | 'month'>('day')
const base = (import.meta as any).env.VITE_BACKEND_URL || 'http://localhost:3001'
let socket: ReturnType<typeof io> | null = null
//
const stats = ref({
todayEnergy: 3842,
yesterdayEnergy: 3567,
changePercent: 7.7,
monthEnergy: 98420
})
const deviceStatus = ref({
online: 156,
offline: 12,
warning: 3,
total: 171
})
const alarms = ref([
{ level: 'high', message: '5楼空调机组功率异常', time: '10:23' },
{ level: 'medium', message: '3楼照明能耗超标', time: '09:45' },
{ level: 'low', message: '电梯运行频率偏高', time: '08:30' },
])
const energyTips = ref([
{ icon: '❄️', title: '空调温度优化', desc: '建议将空调温度调高2°C', save: '15%' },
{ icon: '💡', title: '照明智能控制', desc: '非工作时段自动关闭公共区域照明', save: '8%' },
{ icon: '⏰', title: '错峰用电', desc: '将部分负载转移至谷电时段', save: '12%' },
])
const envData = ref({
temperature: 24.5,
humidity: 58,
pm25: 35,
co2: 680
})
const currentTime = ref('')
function updateTime() {
currentTime.value = new Date().toLocaleString('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit'
})
}
setInterval(updateTime, 1000)
updateTime()
function getIcon(name: string) {
const icons: Record<string, string> = {
'空调': '❄️', '照明': '💡', '电梯': '🛗', '其他': '🔌',
'暖通': '🌡️', '动力': '⚙️', '特殊': '🔋'
}
return icons[name] || '📊'
}
async function fetchRealtime() {
const { data } = await axios.get(`${base}/energy/realtime`)
realtime.value = data
}
async function fetchHistory() {
const { data } = await axios.get(`${base}/energy/history?range=${timeRange.value}`)
history.value = data
}
function changeRange(range: 'day' | 'week' | 'month') {
timeRange.value = range
fetchHistory()
}
function backToOverview() {
selectedRoom.value = ''
fetchRealtime()
fetchHistory()
}
function loop() {
if (!selectedRoom.value) {
fetchRealtime()
fetchHistory()
}
setTimeout(loop, 5000)
}
const selectedRoom = ref('')
async function onSelectRoom(roomId: string) {
selectedRoom.value = roomId
//
try {
const { data } = await axios.get(`${base}/rooms/${roomId}/realtime`)
//
realtime.value = {
totalPower: data.power || Math.round(50 + Math.random() * 100),
subitems: [
{ name: '空调', kw: Math.round(20 + Math.random() * 30) },
{ name: '照明', kw: Math.round(10 + Math.random() * 20) },
{ name: '插座', kw: Math.round(5 + Math.random() * 15) },
{ name: '其他', kw: Math.round(3 + Math.random() * 10) }
]
}
//
realtime.value.totalPower = realtime.value.subitems.reduce((sum, s) => sum + s.kw, 0)
//
fetchHistory()
// WebSocket
if (!socket) socket = io(base)
socket.on(data.channel || `room:${roomId}`, (payload: any) => {
realtime.value.totalPower = payload.power
history.value.points.push({ t: Date.now(), v: payload.power })
if (history.value.points.length > 50) history.value.points.shift()
})
} catch (e) {
console.log('Room data:', roomId)
}
}
onMounted(loop)
</script>
<style scoped>
.layout { display: grid; grid-template-columns: 260px 1fr; height: 100%; }
/* 侧边栏 */
.sidebar {
background: #0a0a0a;
color: #e5e5e5;
padding: 0;
overflow: auto;
border-right: 1px solid #262626;
}
.sidebar-header {
display: flex;
align-items: center;
gap: 12px;
padding: 20px 16px;
border-bottom: 1px solid #262626;
}
/* Sparkle Logo */
.sp {
--cut: 0.1em;
--active: 0;
--bg: radial-gradient(40% 50% at center 100%, hsl(270 calc(var(--active) * 97%) 72% / var(--active)), transparent),
radial-gradient(80% 100% at center 120%, hsl(260 calc(var(--active) * 97%) 70% / var(--active)), transparent),
hsl(260 calc(var(--active) * 97%) calc((var(--active) * 44%) + 12%));
--spark: 1.8s;
--transition: 0.25s;
position: relative;
}
.sp:hover {
--active: 1;
}
.sparkle-logo {
width: 42px;
height: 42px;
background: var(--bg);
border: 0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12px;
position: relative;
box-shadow: 0 0 calc(var(--active) * 1.5em) calc(var(--active) * 0.5em) hsl(260 97% 61% / 0.75),
0 0em 0 0 hsl(260 calc(var(--active) * 97%) calc((var(--active) * 50%) + 30%)) inset,
0 -0.05em 0 0 hsl(260 calc(var(--active) * 97%) calc(var(--active) * 60%)) inset;
transition: box-shadow var(--transition), scale var(--transition), background var(--transition);
scale: calc(1 + (var(--active) * 0.05));
}
.sparkle-logo .sparkle {
width: 22px;
height: 22px;
position: relative;
z-index: 1;
}
.sparkle-logo .sparkle path {
fill: hsl(0 0% calc((var(--active) * 30%) + 70%));
stroke: hsl(0 0% calc((var(--active) * 30%) + 70%));
transition: fill var(--transition), stroke var(--transition);
}
.sparkle-logo .spark {
position: absolute;
inset: 0;
border-radius: 12px;
rotate: 0deg;
overflow: hidden;
mask: linear-gradient(white, transparent 50%);
animation: flip calc(var(--spark) * 2) infinite steps(2, end);
}
.sparkle-logo .spark:before {
content: "";
position: absolute;
width: 200%;
aspect-ratio: 1;
top: 0%;
left: 50%;
z-index: -1;
translate: -50% -15%;
rotate: 0;
transform: rotate(-90deg);
opacity: calc((var(--active)) + 0.4);
background: conic-gradient(from 0deg, transparent 0 340deg, white 360deg);
transition: opacity var(--transition);
animation: rotate var(--spark) linear infinite both;
}
.sparkle-logo .backdrop {
position: absolute;
inset: var(--cut);
background: var(--bg);
border-radius: 12px;
transition: background var(--transition);
}
@keyframes flip {
to { rotate: 360deg; }
}
@keyframes rotate {
to { transform: rotate(90deg); }
}
.sidebar-header h2 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #e5e5e5;
}
/* 主内容区 */
.main {
padding: 24px;
background: #0f0f0f;
color: #e5e5e5;
overflow: auto;
}
/* 页面头部 */
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.page-title {
display: flex;
align-items: center;
gap: 16px;
}
.back-btn {
font-size: 13px;
color: #a78bfa;
cursor: pointer;
padding: 6px 12px;
background: rgba(167, 139, 250, 0.1);
border-radius: 6px;
transition: all 0.2s;
}
.back-btn:hover {
background: rgba(167, 139, 250, 0.2);
}
.page-header h1 {
margin: 0;
font-size: 24px;
font-weight: 600;
color: #e5e5e5;
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
.btn-manage {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
background: #171717;
border: 1px solid #262626;
border-radius: 8px;
color: #e5e5e5;
font-size: 13px;
text-decoration: none;
transition: all 0.2s;
}
.btn-manage:hover {
background: #262626;
border-color: #404040;
}
.page-header .time {
font-size: 14px;
color: #a3a3a3;
background: #171717;
padding: 8px 16px;
border-radius: 20px;
border: 1px solid #262626;
}
/* 指标卡片网格 */
.metrics-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.metric-card {
position: relative;
background: #171717;
border-radius: 12px;
padding: 16px;
border: 1px solid #262626;
transition: all 0.2s ease;
}
.metric-card:hover {
border-color: #404040;
}
.metric-card.primary {
background: linear-gradient(135deg, #7c3aed, #db2777);
border-color: transparent;
color: #fff;
}
.metric-icon {
font-size: 20px;
margin-bottom: 8px;
}
.metric-label {
font-size: 12px;
color: #a3a3a3;
margin-bottom: 4px;
}
.metric-card.primary .metric-label { color: rgba(255,255,255,0.85); }
.metric-value {
display: flex;
align-items: baseline;
gap: 4px;
}
.metric-value .number {
font-size: 28px;
font-weight: 600;
color: #e5e5e5;
}
.metric-value .unit {
font-size: 12px;
color: #a3a3a3;
}
.metric-card.primary .metric-value .number { color: #fff; }
.metric-card.primary .metric-value .unit { color: rgba(255,255,255,0.7); }
.metric-trend {
position: absolute;
top: 12px;
right: 12px;
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
background: rgba(34, 197, 94, 0.15);
color: #4ade80;
}
.metric-percent {
position: absolute;
bottom: 12px;
right: 12px;
font-size: 16px;
font-weight: 600;
color: #a78bfa;
opacity: 0.7;
}
/* 图表区域 */
.charts-section {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 16px;
}
.chart-card, .stats-card {
background: #171717;
border-radius: 12px;
padding: 20px;
border: 1px solid #262626;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.chart-header h3, .stats-card h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #e5e5e5;
}
.chart-tabs {
display: flex;
gap: 4px;
background: #262626;
padding: 4px;
border-radius: 8px;
}
.chart-tabs .tab {
padding: 6px 12px;
font-size: 12px;
color: #a3a3a3;
cursor: pointer;
border-radius: 6px;
transition: all 0.2s;
}
.chart-tabs .tab.active {
background: #404040;
color: #e5e5e5;
}
.chart-tabs .tab:hover:not(.active) {
color: #e5e5e5;
}
/* 统计列表 */
.stats-card h3 { margin-bottom: 20px; }
.stats-list { display: flex; flex-direction: column; gap: 16px; }
.stat-item {}
.stat-info {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
}
.stat-name { font-size: 14px; color: #a3a3a3; }
.stat-value { font-size: 14px; font-weight: 600; color: #e5e5e5; }
.stat-bar {
height: 8px;
background: #262626;
border-radius: 4px;
overflow: hidden;
}
.stat-fill {
height: 100%;
background: linear-gradient(90deg, #7c3aed, #db2777);
border-radius: 4px;
transition: width 0.3s ease;
}
/* 新增卡片行 */
.cards-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-top: 20px;
}
.info-card {
background: #171717;
border-radius: 12px;
padding: 20px;
border: 1px solid #262626;
}
.info-card.wide {
grid-column: span 2;
}
.info-card .card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.info-card .card-header h3 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: #e5e5e5;
}
.badge {
background: linear-gradient(135deg, #ef4444, #dc2626);
color: white;
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
font-weight: 600;
}
/* 今日能耗统计 */
.energy-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.energy-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.energy-label {
font-size: 12px;
color: #737373;
}
.energy-value {
font-size: 24px;
font-weight: 600;
color: #e5e5e5;
}
.energy-value small {
font-size: 12px;
color: #737373;
margin-left: 4px;
}
.energy-value.dim {
color: #737373;
font-size: 20px;
}
.energy-value.up {
color: #ef4444;
}
.energy-value.down {
color: #22c55e;
}
/* 设备状态 */
.device-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.device-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: #0f0f0f;
border-radius: 8px;
}
.device-icon {
font-size: 20px;
}
.device-info {
display: flex;
flex-direction: column;
}
.device-count {
font-size: 20px;
font-weight: 600;
color: #e5e5e5;
}
.device-label {
font-size: 11px;
color: #737373;
}
/* 告警列表 */
.alarm-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.alarm-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: #0f0f0f;
border-radius: 8px;
}
.alarm-level {
font-size: 12px;
}
.alarm-msg {
flex: 1;
font-size: 13px;
color: #e5e5e5;
}
.alarm-time {
font-size: 11px;
color: #525252;
}
.no-alarm {
text-align: center;
padding: 30px;
color: #525252;
font-size: 14px;
}
/* 节能建议 */
.tips-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.tip-item {
display: flex;
align-items: center;
gap: 14px;
padding: 14px;
background: #0f0f0f;
border-radius: 8px;
}
.tip-icon {
font-size: 24px;
}
.tip-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.tip-title {
font-size: 14px;
font-weight: 500;
color: #e5e5e5;
}
.tip-desc {
font-size: 12px;
color: #737373;
}
.tip-save {
font-size: 13px;
font-weight: 600;
color: #22c55e;
background: rgba(34, 197, 94, 0.1);
padding: 4px 10px;
border-radius: 4px;
}
/* 环境数据 */
.env-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.env-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 16px 12px;
background: #0f0f0f;
border-radius: 8px;
}
.env-icon {
font-size: 24px;
}
.env-value {
font-size: 20px;
font-weight: 600;
color: #e5e5e5;
}
.env-label {
font-size: 11px;
color: #737373;
}
</style>

View File

@ -0,0 +1,52 @@
<template>
<div class="overview">
<div class="hero">
<div class="number">{{ totalPower }}</div>
<div class="label">实时总功率 kW</div>
</div>
<div class="grid">
<div class="card"><EnergyChart :points="points" /></div>
<div class="card">
<h3>能耗排行榜</h3>
<ul>
<li v-for="(item,i) in ranking" :key="i">{{ item.name }} <b>{{ item.kw }}</b> kW</li>
</ul>
</div>
<div class="card">
<h3>地图</h3>
<div class="map">项目分布Mock</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import axios from 'axios'
import EnergyChart from '@/components/EnergyChart/index.vue'
const base = (import.meta as any).env.VITE_BACKEND_URL || 'http://localhost:3001'
const totalPower = ref(0)
const points = ref<{ t:number, v:number }[]>([])
const ranking = ref<{ name:string, kw:number }[]>([])
async function refresh() {
const { data } = await axios.get(`${base}/energy/realtime`)
totalPower.value = data.totalPower
points.value.push({ t: Date.now(), v: data.totalPower }); if (points.value.length > 50) points.value.shift()
ranking.value = data.subitems.map((s:any) => ({ name: s.name, kw: s.kw })).sort((a,b) => b.kw - a.kw)
}
function loop() { refresh(); setTimeout(loop, 3000) }
onMounted(loop)
</script>
<style scoped>
.overview { padding: 16px; background: #0b132b; color: #fff; min-height: 100%; }
.hero { text-align: center; margin-bottom: 16px; }
.number { font-size: 56px; font-weight: 800; letter-spacing: 2px; animation: roll 1s ease-in-out; }
@keyframes roll { from { transform: translateY(10px); opacity: 0 } to { transform: translateY(0); opacity: 1 } }
.grid { display: grid; grid-template-columns: 2fr 1fr; grid-template-rows: 320px 320px; gap: 16px; }
.card { background: #1c2541; border-radius: 12px; padding: 12px; }
.map { height: 280px; border-radius: 8px; background: linear-gradient(135deg,#162447,#1f4068); display: flex; align-items: center; justify-content: center; opacity: .85 }
ul { list-style: none; padding: 0; margin: 0; }
li { display: flex; justify-content: space-between; padding: 8px 12px; background: #243b55; margin-bottom: 8px; border-radius: 8px; }
</style>

View File

@ -0,0 +1,50 @@
<template>
<div class="login-wrap">
<div class="card">
<img v-if="whiteLabel.logoUrl" :src="whiteLabel.logoUrl" class="logo" />
<h2>{{ whiteLabel.title }}</h2>
<el-form :model="form" @submit.prevent>
<el-form-item>
<el-input v-model="form.email" placeholder="邮箱" />
</el-form-item>
<el-form-item>
<el-input v-model="form.password" type="password" placeholder="密码" />
</el-form-item>
<el-button type="primary" class="full" @click="onLogin">登录</el-button>
</el-form>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, computed, onMounted } from 'vue'
import axios from 'axios'
import { useUserStore } from '@/stores/user'
import { useRouter } from 'vue-router'
const router = useRouter()
const user = useUserStore()
const form = reactive({ email: 'admin@energy.com', password: '123456' })
const whiteLabel = computed(() => user.whiteLabel)
const base = (import.meta as any).env.VITE_BACKEND_URL || 'http://localhost:3001'
async function onLogin() {
const { data } = await axios.post(`${base}/auth/login`, form)
user.setLogin(data)
router.push('/dashboard')
}
onMounted(async () => {
try {
const { data } = await axios.get(`${base}/tenant/info`)
user.whiteLabel = data.branding
} catch {}
})
</script>
<style scoped>
.login-wrap { display: flex; align-items: center; justify-content: center; height: 100%; background: linear-gradient(120deg,#0f2027,#203a43,#2c5364); }
.card { width: 360px; background: #fff; padding: 24px; border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,.2); text-align: center; }
.logo { height: 48px; margin-bottom: 12px; }
.full { width: 100%; }
</style>

View File

@ -0,0 +1,806 @@
<template>
<div class="page-container">
<header class="page-header">
<div class="header-left">
<h1>项目管理</h1>
<span class="subtitle">管理项目楼栋和房间</span>
</div>
<div class="header-right">
<button class="btn-back" @click="$router.push('/dashboard')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
返回监控
</button>
</div>
</header>
<div class="content-grid">
<!-- 项目列表 -->
<div class="panel">
<div class="panel-header">
<h3>📁 项目</h3>
<button class="btn-add" @click="addProject">+ 新增</button>
</div>
<div class="panel-body">
<div
v-for="p in projects"
:key="p.id"
class="list-item"
:class="{ active: selectedProject?.id === p.id }"
@click="selectProject(p)"
>
<span class="item-name">{{ p.name }}</span>
<div class="item-actions">
<button class="btn-icon" @click.stop="editProject(p)"></button>
<button class="btn-icon" @click.stop="deleteProject(p)">🗑</button>
</div>
</div>
<div v-if="!projects.length" class="empty">暂无项目</div>
</div>
</div>
<!-- 楼栋列表 -->
<div class="panel">
<div class="panel-header">
<h3>🏢 楼栋</h3>
<button class="btn-add" @click="addBuilding" :disabled="!selectedProject">+ 新增</button>
</div>
<div class="panel-body">
<div
v-for="b in buildings"
:key="b.id"
class="list-item"
:class="{ active: selectedBuilding?.id === b.id }"
@click="selectBuilding(b)"
>
<span class="item-name">{{ b.name }}</span>
<div class="item-actions">
<button class="btn-icon" @click.stop="editBuilding(b)"></button>
<button class="btn-icon" @click.stop="deleteBuilding(b)">🗑</button>
</div>
</div>
<div v-if="selectedProject && !buildings.length" class="empty">暂无楼栋</div>
<div v-if="!selectedProject" class="empty hint">请先选择项目</div>
</div>
</div>
<!-- 房间列表 -->
<div class="panel">
<div class="panel-header">
<h3>🚪 房间</h3>
<div class="header-btns">
<button class="btn-batch" @click="openBatchModal" :disabled="!selectedBuilding">批量添加</button>
<button class="btn-add" @click="addRoom" :disabled="!selectedBuilding">+ 新增</button>
</div>
</div>
<div class="panel-body">
<div
v-for="r in rooms"
:key="r.id"
class="list-item"
>
<span class="item-name">{{ r.name }}</span>
<div class="item-actions">
<button class="btn-icon" @click.stop="editRoom(r)"></button>
<button class="btn-icon" @click.stop="deleteRoom(r)">🗑</button>
</div>
</div>
<div v-if="selectedBuilding && !rooms.length" class="empty">暂无房间</div>
<div v-if="!selectedBuilding" class="empty hint">请先选择楼栋</div>
</div>
</div>
</div>
<!-- 弹窗 -->
<div class="modal-overlay" v-if="showModal" @click="closeModal">
<div class="modal" @click.stop>
<div class="modal-header">
<h3>{{ modalTitle }}</h3>
<button class="btn-close" @click="closeModal"></button>
</div>
<div class="modal-body">
<div class="form-group">
<label>名称</label>
<input v-model="formData.name" placeholder="请输入名称" />
</div>
<div class="form-group" v-if="modalType === 'room'">
<label>楼层</label>
<input v-model="formData.floor" placeholder="请输入楼层,如 F1" />
</div>
</div>
<div class="modal-footer">
<button class="btn-cancel" @click="closeModal">取消</button>
<button class="btn-confirm" @click="saveForm">保存</button>
</div>
</div>
</div>
<!-- 批量添加弹窗 -->
<div class="modal-overlay" v-if="showBatchModal" @click="closeBatchModal">
<div class="modal modal-batch" @click.stop>
<div class="modal-header">
<h3> 批量添加房间</h3>
<button class="btn-close" @click="closeBatchModal"></button>
</div>
<div class="modal-body">
<div class="form-group">
<label>房间号范围</label>
<div class="range-input">
<input v-model="batchData.start" placeholder="起始号,如 501" />
<span class="range-sep"></span>
<input v-model="batchData.end" placeholder="结束号,如 520" />
</div>
<div class="form-hint">将创建从 {{ batchData.start || '?' }} {{ batchData.end || '?' }} 的房间</div>
</div>
<div class="form-group">
<label>前缀可选</label>
<input v-model="batchData.prefix" placeholder="如A栋" />
</div>
<div class="form-group">
<label>后缀可选</label>
<input v-model="batchData.suffix" placeholder="如:室" />
</div>
<div class="form-group">
<label>楼层可选</label>
<input v-model="batchData.floor" placeholder="如 F5" />
</div>
<div class="preview-section" v-if="batchPreview.length">
<label>预览 {{ batchPreview.length }} 个房间</label>
<div class="preview-list">
<span v-for="name in batchPreview.slice(0, 10)" :key="name" class="preview-tag">{{ name }}</span>
<span v-if="batchPreview.length > 10" class="preview-more">...还有 {{ batchPreview.length - 10 }} </span>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn-cancel" @click="closeBatchModal">取消</button>
<button class="btn-confirm" @click="saveBatch" :disabled="!batchPreview.length || batchLoading">
{{ batchLoading ? '创建中...' : `创建 ${batchPreview.length} 个房间` }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import axios from 'axios'
const base = (import.meta as any).env.VITE_BACKEND_URL || 'http://localhost:3001'
interface Project { id: string; name: string }
interface Building { id: string; name: string; projectId: string }
interface Room { id: string; name: string; buildingId: string; floor?: string }
const projects = ref<Project[]>([])
const allBuildings = ref<Building[]>([])
const allRooms = ref<Room[]>([])
const selectedProject = ref<Project | null>(null)
const selectedBuilding = ref<Building | null>(null)
const buildings = computed(() =>
selectedProject.value
? allBuildings.value.filter(b => b.projectId === selectedProject.value!.id)
: []
)
const rooms = computed(() =>
selectedBuilding.value
? allRooms.value.filter(r => r.buildingId === selectedBuilding.value!.id)
: []
)
// Modal
const showModal = ref(false)
const modalType = ref<'project' | 'building' | 'room'>('project')
const modalMode = ref<'add' | 'edit'>('add')
const modalTitle = computed(() => {
const types = { project: '项目', building: '楼栋', room: '房间' }
return `${modalMode.value === 'add' ? '新增' : '编辑'}${types[modalType.value]}`
})
const formData = ref({ id: '', name: '', floor: '' })
const editingItem = ref<any>(null)
// Batch Modal
const showBatchModal = ref(false)
const batchLoading = ref(false)
const batchData = ref({ start: '', end: '', prefix: '', suffix: '', floor: '' })
const batchPreview = computed(() => {
const start = parseInt(batchData.value.start)
const end = parseInt(batchData.value.end)
if (isNaN(start) || isNaN(end) || start > end || end - start > 200) return []
const names: string[] = []
for (let i = start; i <= end; i++) {
names.push(`${batchData.value.prefix}${i}${batchData.value.suffix}`)
}
return names
})
function openBatchModal() {
if (!selectedBuilding.value) return
batchData.value = { start: '', end: '', prefix: '', suffix: '', floor: '' }
showBatchModal.value = true
}
function closeBatchModal() {
showBatchModal.value = false
}
async function saveBatch() {
if (!batchPreview.value.length || !selectedBuilding.value) return
batchLoading.value = true
try {
for (const name of batchPreview.value) {
const res = await axios.post(`${base}/api/rooms`, {
name,
buildingId: selectedBuilding.value.id,
floor: batchData.value.floor
})
allRooms.value.push(res.data)
}
closeBatchModal()
} catch (e) {
alert('批量创建失败')
} finally {
batchLoading.value = false
}
}
function selectProject(p: Project) {
selectedProject.value = p
selectedBuilding.value = null
}
function selectBuilding(b: Building) {
selectedBuilding.value = b
}
// Project CRUD
function addProject() {
modalType.value = 'project'
modalMode.value = 'add'
formData.value = { id: '', name: '', floor: '' }
showModal.value = true
}
function editProject(p: Project) {
modalType.value = 'project'
modalMode.value = 'edit'
editingItem.value = p
formData.value = { id: p.id, name: p.name, floor: '' }
showModal.value = true
}
async function deleteProject(p: Project) {
if (!confirm(`确定删除项目 "${p.name}" 吗?`)) return
try {
await axios.delete(`${base}/api/projects/${p.id}`)
projects.value = projects.value.filter(x => x.id !== p.id)
if (selectedProject.value?.id === p.id) {
selectedProject.value = null
selectedBuilding.value = null
}
} catch (e) {
alert('删除失败')
}
}
// Building CRUD
function addBuilding() {
if (!selectedProject.value) return
modalType.value = 'building'
modalMode.value = 'add'
formData.value = { id: '', name: '', floor: '' }
showModal.value = true
}
function editBuilding(b: Building) {
modalType.value = 'building'
modalMode.value = 'edit'
editingItem.value = b
formData.value = { id: b.id, name: b.name, floor: '' }
showModal.value = true
}
async function deleteBuilding(b: Building) {
if (!confirm(`确定删除楼栋 "${b.name}" 吗?`)) return
try {
await axios.delete(`${base}/api/buildings/${b.id}`)
allBuildings.value = allBuildings.value.filter(x => x.id !== b.id)
if (selectedBuilding.value?.id === b.id) {
selectedBuilding.value = null
}
} catch (e) {
alert('删除失败')
}
}
// Room CRUD
function addRoom() {
if (!selectedBuilding.value) return
modalType.value = 'room'
modalMode.value = 'add'
formData.value = { id: '', name: '', floor: '' }
showModal.value = true
}
function editRoom(r: Room) {
modalType.value = 'room'
modalMode.value = 'edit'
editingItem.value = r
formData.value = { id: r.id, name: r.name, floor: r.floor || '' }
showModal.value = true
}
async function deleteRoom(r: Room) {
if (!confirm(`确定删除房间 "${r.name}" 吗?`)) return
try {
await axios.delete(`${base}/api/rooms/${r.id}`)
allRooms.value = allRooms.value.filter(x => x.id !== r.id)
} catch (e) {
alert('删除失败')
}
}
function closeModal() {
showModal.value = false
editingItem.value = null
}
async function saveForm() {
if (!formData.value.name.trim()) {
alert('请输入名称')
return
}
try {
if (modalType.value === 'project') {
if (modalMode.value === 'add') {
const res = await axios.post(`${base}/api/projects`, { name: formData.value.name })
projects.value.push(res.data)
} else {
await axios.put(`${base}/api/projects/${formData.value.id}`, { name: formData.value.name })
const idx = projects.value.findIndex(x => x.id === formData.value.id)
if (idx >= 0) projects.value[idx].name = formData.value.name
}
} else if (modalType.value === 'building') {
if (modalMode.value === 'add') {
const res = await axios.post(`${base}/api/buildings`, {
name: formData.value.name,
projectId: selectedProject.value!.id
})
allBuildings.value.push(res.data)
} else {
await axios.put(`${base}/api/buildings/${formData.value.id}`, { name: formData.value.name })
const idx = allBuildings.value.findIndex(x => x.id === formData.value.id)
if (idx >= 0) allBuildings.value[idx].name = formData.value.name
}
} else if (modalType.value === 'room') {
if (modalMode.value === 'add') {
const res = await axios.post(`${base}/api/rooms`, {
name: formData.value.name,
buildingId: selectedBuilding.value!.id,
floor: formData.value.floor
})
allRooms.value.push(res.data)
} else {
await axios.put(`${base}/api/rooms/${formData.value.id}`, {
name: formData.value.name,
floor: formData.value.floor
})
const idx = allRooms.value.findIndex(x => x.id === formData.value.id)
if (idx >= 0) {
allRooms.value[idx].name = formData.value.name
allRooms.value[idx].floor = formData.value.floor
}
}
}
closeModal()
} catch (e) {
alert('保存失败')
}
}
async function loadData() {
try {
const res = await axios.get(`${base}/api/tree`)
const data = res.data
projects.value = data.map((p: any) => ({ id: p.id, name: p.name }))
allBuildings.value = []
allRooms.value = []
data.forEach((p: any) => {
p.buildings?.forEach((b: any) => {
allBuildings.value.push({ id: b.id, name: b.name, projectId: p.id })
b.floors?.forEach((f: any) => {
f.rooms?.forEach((r: any) => {
allRooms.value.push({ id: r.id, name: r.name, buildingId: b.id, floor: f.name })
})
})
})
})
} catch (e) {
console.error('加载数据失败', e)
}
}
onMounted(loadData)
</script>
<style scoped>
.page-container {
min-height: 100vh;
background: #0f0f0f;
color: #e5e5e5;
padding: 24px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.header-left h1 {
margin: 0;
font-size: 24px;
font-weight: 600;
}
.subtitle {
font-size: 14px;
color: #737373;
margin-left: 12px;
}
.btn-back {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: #171717;
border: 1px solid #262626;
border-radius: 8px;
color: #e5e5e5;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.btn-back:hover {
background: #262626;
border-color: #404040;
}
.content-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
.panel {
background: #171717;
border: 1px solid #262626;
border-radius: 12px;
overflow: hidden;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #262626;
}
.panel-header h3 {
margin: 0;
font-size: 16px;
font-weight: 500;
}
.btn-add {
padding: 6px 12px;
background: linear-gradient(135deg, #7c3aed, #db2777);
border: none;
border-radius: 6px;
color: white;
font-size: 13px;
cursor: pointer;
transition: opacity 0.2s;
}
.btn-add:hover {
opacity: 0.9;
}
.btn-add:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.panel-body {
padding: 8px;
max-height: 500px;
overflow: auto;
}
.list-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.list-item:hover {
background: #262626;
}
.list-item.active {
background: rgba(124, 58, 237, 0.2);
border: 1px solid rgba(124, 58, 237, 0.3);
}
.item-name {
font-size: 14px;
}
.item-actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.2s;
}
.list-item:hover .item-actions {
opacity: 1;
}
.btn-icon {
padding: 4px 8px;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: background 0.2s;
}
.btn-icon:hover {
background: #404040;
}
.empty {
padding: 40px 20px;
text-align: center;
color: #525252;
font-size: 14px;
}
.empty.hint {
color: #404040;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: #171717;
border: 1px solid #262626;
border-radius: 12px;
width: 400px;
max-width: 90vw;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #262626;
}
.modal-header h3 {
margin: 0;
font-size: 16px;
}
.btn-close {
background: transparent;
border: none;
color: #737373;
font-size: 18px;
cursor: pointer;
padding: 4px;
}
.btn-close:hover {
color: #e5e5e5;
}
.modal-body {
padding: 20px;
}
.form-group {
margin-bottom: 16px;
}
.form-group:last-child {
margin-bottom: 0;
}
.form-group label {
display: block;
font-size: 13px;
color: #a3a3a3;
margin-bottom: 8px;
}
.form-group input {
width: 100%;
padding: 10px 12px;
background: #0f0f0f;
border: 1px solid #262626;
border-radius: 8px;
color: #e5e5e5;
font-size: 14px;
box-sizing: border-box;
}
.form-group input:focus {
outline: none;
border-color: #7c3aed;
}
.form-group input::placeholder {
color: #525252;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 20px;
border-top: 1px solid #262626;
}
.btn-cancel {
padding: 8px 16px;
background: #262626;
border: 1px solid #404040;
border-radius: 6px;
color: #e5e5e5;
font-size: 14px;
cursor: pointer;
}
.btn-cancel:hover {
background: #404040;
}
.btn-confirm {
padding: 8px 16px;
background: linear-gradient(135deg, #7c3aed, #db2777);
border: none;
border-radius: 6px;
color: white;
font-size: 14px;
cursor: pointer;
}
.btn-confirm:hover {
opacity: 0.9;
}
.btn-confirm:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Header buttons */
.header-btns {
display: flex;
gap: 8px;
}
.btn-batch {
padding: 6px 12px;
background: #262626;
border: 1px solid #404040;
border-radius: 6px;
color: #e5e5e5;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.btn-batch:hover {
background: #404040;
}
.btn-batch:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Batch Modal */
.modal-batch {
width: 480px;
}
.range-input {
display: flex;
align-items: center;
gap: 12px;
}
.range-input input {
flex: 1;
}
.range-sep {
color: #525252;
font-size: 18px;
}
.form-hint {
margin-top: 8px;
font-size: 12px;
color: #737373;
}
.preview-section {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #262626;
}
.preview-section label {
display: block;
font-size: 13px;
color: #a3a3a3;
margin-bottom: 12px;
}
.preview-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.preview-tag {
padding: 4px 10px;
background: rgba(124, 58, 237, 0.2);
border: 1px solid rgba(124, 58, 237, 0.3);
border-radius: 4px;
font-size: 13px;
color: #a78bfa;
}
.preview-more {
padding: 4px 10px;
color: #737373;
font-size: 13px;
}
</style>

View File

@ -0,0 +1,22 @@
<template>
<div class="wrap">
<el-form inline>
<el-form-item label="选择月份">
<el-date-picker v-model="month" type="month" placeholder="选择月份" />
</el-form-item>
<el-button type="primary" @click="download">生成并下载月报</el-button>
</el-form>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const month = ref('') as any
const base = (import.meta as any).env.VITE_BACKEND_URL || 'http://localhost:3001'
function download() {
const d = new Date(month.value)
const y = d.getFullYear(); const m = String(d.getMonth()+1).padStart(2,'0')
window.open(`${base}/report/month/${y}/${m}`)
}
</script>

View File

@ -0,0 +1,26 @@
<template>
<div class="wrap">
<el-pagination background layout="prev, pager, next" :total="total" :page-size="pageSize" @current-change="onPage" />
<RoomTable :items="items" @selected="setSel" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import axios from 'axios'
import RoomTable from '@/components/RoomTable/index.vue'
const total = ref(0)
const pageSize = ref(20)
const page = ref(1)
const items = ref<any[]>([])
const selected = ref<any[]>([])
const base = (import.meta as any).env.VITE_BACKEND_URL || 'http://localhost:3001'
async function load() {
const { data } = await axios.get(`${base}/rooms?page=${page.value}&pageSize=${pageSize.value}`)
total.value = data.total; items.value = data.items
}
function onPage(p: number) { page.value = p; load() }
function setSel(list: any[]) { selected.value = list }
onMounted(load)
</script>

14
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Node",
"strict": true,
"jsx": "preserve",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"types": ["vite/client"]
}
}

14
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src')
}
},
server: { port: 5173, host: '0.0.0.0' },
preview: { port: 8080 }
})

2
mock/README.md Normal file
View File

@ -0,0 +1,2 @@
Mock 数据在后端接口中直接返回,满足首次启动无需真实数据库即可展示的需求。后续可逐步替换为真实 TypeORM + TDengine 查询。

31
start.sh Normal file
View File

@ -0,0 +1,31 @@
#!/bin/bash
# 设置 Node 环境
export PATH="/root/.trae-server/binaries/node/versions/22.19.0/bin:$PATH"
echo "🚀 启动节能建筑云管理平台..."
# 启动后端
echo "📦 启动后端服务 (端口 3001)..."
cd /home/hyx/work/节能建筑云管理平台/energy-smart-saas/backend
PORT=3001 npm run dev &
BACKEND_PID=$!
# 等待后端启动
sleep 3
# 启动前端
echo "🎨 启动前端服务 (端口 5173)..."
cd /home/hyx/work/节能建筑云管理平台/energy-smart-saas/frontend
npm run dev &
FRONTEND_PID=$!
echo ""
echo "✅ 服务已启动!"
echo " 前端: http://localhost:5173"
echo " 后端: http://localhost:3001"
echo ""
echo "按 Ctrl+C 停止所有服务"
# 等待子进程
wait