Initial commit: 节能建筑云管理平台 SaaS 项目Demo
This commit is contained in:
commit
c8c5266550
33
.gitignore
vendored
Normal file
33
.gitignore
vendored
Normal 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
16
backend/Dockerfile
Normal 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
5571
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
43
backend/package.json
Normal file
43
backend/package.json
Normal 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
16
backend/src/main.ts
Normal 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()
|
||||
|
||||
18
backend/src/modules/alarm/alarm.controller.ts
Normal file
18
backend/src/modules/alarm/alarm.controller.ts
Normal 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' }
|
||||
}
|
||||
}
|
||||
|
||||
8
backend/src/modules/alarm/alarm.module.ts
Normal file
8
backend/src/modules/alarm/alarm.module.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { AlarmController } from './alarm.controller'
|
||||
|
||||
@Module({
|
||||
controllers: [AlarmController]
|
||||
})
|
||||
export class AlarmModule {}
|
||||
|
||||
48
backend/src/modules/app.module.ts
Normal file
48
backend/src/modules/app.module.ts
Normal 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 {}
|
||||
19
backend/src/modules/auth/auth.controller.ts
Normal file
19
backend/src/modules/auth/auth.controller.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
17
backend/src/modules/auth/auth.module.ts
Normal file
17
backend/src/modules/auth/auth.module.ts
Normal 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 {}
|
||||
|
||||
19
backend/src/modules/auth/auth.service.ts
Normal file
19
backend/src/modules/auth/auth.service.ts
Normal 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' }
|
||||
}
|
||||
}
|
||||
15
backend/src/modules/batch/batch.controller.ts
Normal file
15
backend/src/modules/batch/batch.controller.ts
Normal 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) }
|
||||
}
|
||||
}
|
||||
|
||||
8
backend/src/modules/batch/batch.module.ts
Normal file
8
backend/src/modules/batch/batch.module.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { BatchController } from './batch.controller'
|
||||
|
||||
@Module({
|
||||
controllers: [BatchController]
|
||||
})
|
||||
export class BatchModule {}
|
||||
|
||||
15
backend/src/modules/common/common.module.ts
Normal file
15
backend/src/modules/common/common.module.ts
Normal 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 {}
|
||||
19
backend/src/modules/common/operation-log.interceptor.ts
Normal file
19
backend/src/modules/common/operation-log.interceptor.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
|
||||
12
backend/src/modules/common/tenant.interceptor.ts
Normal file
12
backend/src/modules/common/tenant.interceptor.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
|
||||
20
backend/src/modules/device/device.controller.ts
Normal file
20
backend/src/modules/device/device.controller.ts
Normal 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' }
|
||||
}
|
||||
}
|
||||
8
backend/src/modules/device/device.module.ts
Normal file
8
backend/src/modules/device/device.module.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { DeviceController } from './device.controller'
|
||||
|
||||
@Module({
|
||||
controllers: [DeviceController]
|
||||
})
|
||||
export class DeviceModule {}
|
||||
|
||||
60
backend/src/modules/energy/energy.controller.ts
Normal file
60
backend/src/modules/energy/energy.controller.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
|
||||
8
backend/src/modules/energy/energy.module.ts
Normal file
8
backend/src/modules/energy/energy.module.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { EnergyController } from './energy.controller'
|
||||
|
||||
@Module({
|
||||
controllers: [EnergyController]
|
||||
})
|
||||
export class EnergyModule {}
|
||||
|
||||
9
backend/src/modules/entities/alarm-rule.entity.ts
Normal file
9
backend/src/modules/entities/alarm-rule.entity.ts
Normal 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
|
||||
}
|
||||
|
||||
10
backend/src/modules/entities/alarm.entity.ts
Normal file
10
backend/src/modules/entities/alarm.entity.ts
Normal 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
|
||||
}
|
||||
11
backend/src/modules/entities/batch-task-record.entity.ts
Normal file
11
backend/src/modules/entities/batch-task-record.entity.ts
Normal 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
|
||||
}
|
||||
|
||||
11
backend/src/modules/entities/batch-task.entity.ts
Normal file
11
backend/src/modules/entities/batch-task.entity.ts
Normal 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
|
||||
}
|
||||
|
||||
9
backend/src/modules/entities/building.entity.ts
Normal file
9
backend/src/modules/entities/building.entity.ts
Normal 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
|
||||
}
|
||||
|
||||
11
backend/src/modules/entities/device.entity.ts
Normal file
11
backend/src/modules/entities/device.entity.ts
Normal 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
|
||||
}
|
||||
46
backend/src/modules/entities/entities.module.ts
Normal file
46
backend/src/modules/entities/entities.module.ts
Normal 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 {}
|
||||
|
||||
9
backend/src/modules/entities/floor.entity.ts
Normal file
9
backend/src/modules/entities/floor.entity.ts
Normal 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
|
||||
}
|
||||
|
||||
10
backend/src/modules/entities/operation-log.entity.ts
Normal file
10
backend/src/modules/entities/operation-log.entity.ts
Normal 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
|
||||
}
|
||||
9
backend/src/modules/entities/permission.entity.ts
Normal file
9
backend/src/modules/entities/permission.entity.ts
Normal 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
|
||||
}
|
||||
|
||||
9
backend/src/modules/entities/project.entity.ts
Normal file
9
backend/src/modules/entities/project.entity.ts
Normal 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
|
||||
}
|
||||
|
||||
9
backend/src/modules/entities/report-template.entity.ts
Normal file
9
backend/src/modules/entities/report-template.entity.ts
Normal 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
|
||||
}
|
||||
|
||||
10
backend/src/modules/entities/report.entity.ts
Normal file
10
backend/src/modules/entities/report.entity.ts
Normal 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
|
||||
}
|
||||
9
backend/src/modules/entities/role.entity.ts
Normal file
9
backend/src/modules/entities/role.entity.ts
Normal 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
|
||||
}
|
||||
|
||||
9
backend/src/modules/entities/room.entity.ts
Normal file
9
backend/src/modules/entities/room.entity.ts
Normal 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
|
||||
}
|
||||
|
||||
10
backend/src/modules/entities/tenant.entity.ts
Normal file
10
backend/src/modules/entities/tenant.entity.ts
Normal 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
|
||||
}
|
||||
12
backend/src/modules/entities/user.entity.ts
Normal file
12
backend/src/modules/entities/user.entity.ts
Normal 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
|
||||
}
|
||||
103
backend/src/modules/migrations/0001-init.sql
Normal file
103
backend/src/modules/migrations/0001-init.sql
Normal 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
|
||||
);
|
||||
|
||||
24
backend/src/modules/migrations/migrate.ts
Normal file
24
backend/src/modules/migrations/migrate.ts
Normal 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) })
|
||||
17
backend/src/modules/mock/mock.module.ts
Normal file
17
backend/src/modules/mock/mock.module.ts
Normal 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 {}
|
||||
|
||||
62
backend/src/modules/mock/mock.service.ts
Normal file
62
backend/src/modules/mock/mock.service.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
10
backend/src/modules/ota/ota.controller.ts
Normal file
10
backend/src/modules/ota/ota.controller.ts
Normal 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' }
|
||||
}
|
||||
}
|
||||
|
||||
8
backend/src/modules/ota/ota.module.ts
Normal file
8
backend/src/modules/ota/ota.module.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { OtaController } from './ota.controller'
|
||||
|
||||
@Module({
|
||||
controllers: [OtaController]
|
||||
})
|
||||
export class OtaModule {}
|
||||
|
||||
19
backend/src/modules/project/project.controller.ts
Normal file
19
backend/src/modules/project/project.controller.ts
Normal 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 }]
|
||||
}
|
||||
}
|
||||
|
||||
8
backend/src/modules/project/project.module.ts
Normal file
8
backend/src/modules/project/project.module.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { ProjectController } from './project.controller'
|
||||
|
||||
@Module({
|
||||
controllers: [ProjectController]
|
||||
})
|
||||
export class ProjectModule {}
|
||||
|
||||
23
backend/src/modules/report/report.controller.ts
Normal file
23
backend/src/modules/report/report.controller.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
|
||||
8
backend/src/modules/report/report.module.ts
Normal file
8
backend/src/modules/report/report.module.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { ReportController } from './report.controller'
|
||||
|
||||
@Module({
|
||||
controllers: [ReportController]
|
||||
})
|
||||
export class ReportModule {}
|
||||
|
||||
10
backend/src/modules/tenant/tenant.controller.ts
Normal file
10
backend/src/modules/tenant/tenant.controller.ts
Normal 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' } }
|
||||
}
|
||||
}
|
||||
|
||||
8
backend/src/modules/tenant/tenant.module.ts
Normal file
8
backend/src/modules/tenant/tenant.module.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { TenantController } from './tenant.controller'
|
||||
|
||||
@Module({
|
||||
controllers: [TenantController]
|
||||
})
|
||||
export class TenantModule {}
|
||||
|
||||
26
backend/src/modules/websocket/mqtt-bridge.service.ts
Normal file
26
backend/src/modules/websocket/mqtt-bridge.service.ts
Normal 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() })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
12
backend/src/modules/websocket/websocket.gateway.ts
Normal file
12
backend/src/modules/websocket/websocket.gateway.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
8
backend/src/modules/websocket/websocket.module.ts
Normal file
8
backend/src/modules/websocket/websocket.module.ts
Normal 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
18
backend/tsconfig.json
Normal 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
78
docker-compose.yml
Normal 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
13
frontend/Dockerfile
Normal 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
13
frontend/index.html
Normal 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
10
frontend/nginx.conf
Normal 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
2427
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
frontend/package.json
Normal file
25
frontend/package.json
Normal 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
11
frontend/public/logo.svg
Normal 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
83
frontend/src/App.vue
Normal 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>
|
||||
|
||||
121
frontend/src/components/EnergyChart/index.vue
Normal file
121
frontend/src/components/EnergyChart/index.vue
Normal 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>
|
||||
|
||||
797
frontend/src/components/ProjectTree/index.vue
Normal file
797
frontend/src/components/ProjectTree/index.vue
Normal 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>
|
||||
|
||||
40
frontend/src/components/RoomTable/index.vue
Normal file
40
frontend/src/components/RoomTable/index.vue
Normal 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
13
frontend/src/main.ts
Normal 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')
|
||||
|
||||
24
frontend/src/router/index.ts
Normal file
24
frontend/src/router/index.ts
Normal 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
|
||||
14
frontend/src/stores/user.ts
Normal file
14
frontend/src/stores/user.ts
Normal 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 }
|
||||
}
|
||||
})
|
||||
|
||||
15
frontend/src/utils/mqtt.ts
Normal file
15
frontend/src/utils/mqtt.ts
Normal 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
|
||||
}
|
||||
|
||||
26
frontend/src/views/AlarmCenter.vue
Normal file
26
frontend/src/views/AlarmCenter.vue
Normal 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>
|
||||
|
||||
23
frontend/src/views/BatchOperation.vue
Normal file
23
frontend/src/views/BatchOperation.vue
Normal 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>
|
||||
|
||||
921
frontend/src/views/Dashboard.vue
Normal file
921
frontend/src/views/Dashboard.vue
Normal 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>
|
||||
52
frontend/src/views/EnergyOverview.vue
Normal file
52
frontend/src/views/EnergyOverview.vue
Normal 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>
|
||||
|
||||
50
frontend/src/views/Login.vue
Normal file
50
frontend/src/views/Login.vue
Normal 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>
|
||||
806
frontend/src/views/ProjectManage.vue
Normal file
806
frontend/src/views/ProjectManage.vue
Normal 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>
|
||||
22
frontend/src/views/ReportCenter.vue
Normal file
22
frontend/src/views/ReportCenter.vue
Normal 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>
|
||||
|
||||
26
frontend/src/views/RoomList.vue
Normal file
26
frontend/src/views/RoomList.vue
Normal 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
14
frontend/tsconfig.json
Normal 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
14
frontend/vite.config.ts
Normal 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
2
mock/README.md
Normal file
@ -0,0 +1,2 @@
|
||||
Mock 数据在后端接口中直接返回,满足首次启动无需真实数据库即可展示的需求。后续可逐步替换为真实 TypeORM + TDengine 查询。
|
||||
|
||||
31
start.sh
Normal file
31
start.sh
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user