diff --git a/backend/services/admin-service/prisma/schema.prisma b/backend/services/admin-service/prisma/schema.prisma index e98908d5..d31cdf31 100644 --- a/backend/services/admin-service/prisma/schema.prisma +++ b/backend/services/admin-service/prisma/schema.prisma @@ -516,6 +516,28 @@ model SystemConfig { @@map("system_configs") } +// ============================================================================= +// System Maintenance (系统维护公告) +// ============================================================================= + +/// 系统维护公告 - 用于系统升级/维护期间阻断用户操作 +model SystemMaintenance { + id String @id @default(uuid()) + title String @db.VarChar(100) // 标题:如"系统升级中" + message String @db.Text // 说明:如"预计10:00恢复,请稍候" + startTime DateTime @map("start_time") // 维护开始时间 + endTime DateTime @map("end_time") // 维护结束时间 + isActive Boolean @default(false) @map("is_active") // 是否激活 + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + createdBy String @map("created_by") // 创建人ID + updatedBy String? @map("updated_by") // 更新人ID + + @@index([isActive]) + @@index([startTime, endTime]) + @@map("system_maintenances") +} + // ============================================================================= // Co-Managed Wallet System (共管钱包系统) // ============================================================================= diff --git a/backend/services/admin-service/src/api/controllers/system-maintenance.controller.ts b/backend/services/admin-service/src/api/controllers/system-maintenance.controller.ts new file mode 100644 index 00000000..c8c2a879 --- /dev/null +++ b/backend/services/admin-service/src/api/controllers/system-maintenance.controller.ts @@ -0,0 +1,223 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Query, + HttpCode, + HttpStatus, + Inject, + NotFoundException, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { v4 as uuidv4 } from 'uuid'; +import { + SYSTEM_MAINTENANCE_REPOSITORY, + ISystemMaintenanceRepository, +} from '../../domain/repositories/system-maintenance.repository'; +import { SystemMaintenanceEntity } from '../../domain/entities/system-maintenance.entity'; +import { + CreateMaintenanceDto, + UpdateMaintenanceDto, + ToggleMaintenanceDto, +} from '../dto/request/system-maintenance.dto'; +import { + MaintenanceResponseDto, + MaintenanceListResponseDto, + MaintenanceStatusResponseDto, +} from '../dto/response/system-maintenance.dto'; + +/** + * 管理端系统维护控制器 + */ +@ApiTags('系统维护管理') +@Controller('admin/maintenance') +export class AdminMaintenanceController { + constructor( + @Inject(SYSTEM_MAINTENANCE_REPOSITORY) + private readonly maintenanceRepo: ISystemMaintenanceRepository, + ) {} + + /** + * 创建维护公告 + */ + @Post() + @ApiOperation({ summary: '创建维护公告' }) + @ApiResponse({ status: 201, type: MaintenanceResponseDto }) + async create(@Body() dto: CreateMaintenanceDto): Promise { + const entity = SystemMaintenanceEntity.create({ + id: uuidv4(), + title: dto.title, + message: dto.message, + startTime: new Date(dto.startTime), + endTime: new Date(dto.endTime), + createdBy: 'admin', // TODO: 从认证信息获取 + }); + + const saved = await this.maintenanceRepo.create(entity); + return MaintenanceResponseDto.fromEntity(saved); + } + + /** + * 获取维护公告列表 + */ + @Get() + @ApiOperation({ summary: '获取维护公告列表' }) + @ApiResponse({ status: 200, type: MaintenanceListResponseDto }) + async list( + @Query('limit') limit = 20, + @Query('offset') offset = 0, + ): Promise { + const result = await this.maintenanceRepo.findAll({ + limit: Number(limit), + offset: Number(offset), + }); + + return { + items: result.items.map((item) => MaintenanceResponseDto.fromEntity(item)), + total: result.total, + }; + } + + /** + * 获取维护公告详情 + */ + @Get(':id') + @ApiOperation({ summary: '获取维护公告详情' }) + @ApiResponse({ status: 200, type: MaintenanceResponseDto }) + async findById(@Param('id') id: string): Promise { + const entity = await this.maintenanceRepo.findById(id); + if (!entity) { + throw new NotFoundException('维护公告不存在'); + } + return MaintenanceResponseDto.fromEntity(entity); + } + + /** + * 更新维护公告 + */ + @Put(':id') + @ApiOperation({ summary: '更新维护公告' }) + @ApiResponse({ status: 200, type: MaintenanceResponseDto }) + async update( + @Param('id') id: string, + @Body() dto: UpdateMaintenanceDto, + ): Promise { + const entity = await this.maintenanceRepo.findById(id); + if (!entity) { + throw new NotFoundException('维护公告不存在'); + } + + const updated = entity.update({ + title: dto.title, + message: dto.message, + startTime: dto.startTime ? new Date(dto.startTime) : undefined, + endTime: dto.endTime ? new Date(dto.endTime) : undefined, + updatedBy: 'admin', // TODO: 从认证信息获取 + }); + + const saved = await this.maintenanceRepo.update(updated); + return MaintenanceResponseDto.fromEntity(saved); + } + + /** + * 激活/停用维护公告 + */ + @Put(':id/toggle') + @ApiOperation({ summary: '激活/停用维护公告' }) + @ApiResponse({ status: 200, type: MaintenanceResponseDto }) + async toggle( + @Param('id') id: string, + @Body() dto: ToggleMaintenanceDto, + ): Promise { + const entity = await this.maintenanceRepo.findById(id); + if (!entity) { + throw new NotFoundException('维护公告不存在'); + } + + // 如果是激活操作,先停用其他所有激活的公告 + if (dto.isActive) { + await this.maintenanceRepo.deactivateAll(id); + } + + const updated = dto.isActive + ? entity.activate('admin') // TODO: 从认证信息获取 + : entity.deactivate('admin'); + + const saved = await this.maintenanceRepo.update(updated); + return MaintenanceResponseDto.fromEntity(saved); + } + + /** + * 删除维护公告 + */ + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: '删除维护公告' }) + @ApiResponse({ status: 204 }) + async delete(@Param('id') id: string): Promise { + const entity = await this.maintenanceRepo.findById(id); + if (!entity) { + throw new NotFoundException('维护公告不存在'); + } + await this.maintenanceRepo.delete(id); + } + + /** + * 获取当前维护状态(供内部使用) + */ + @Get('status/current') + @ApiOperation({ summary: '获取当前维护状态' }) + @ApiResponse({ status: 200, type: MaintenanceStatusResponseDto }) + async getCurrentStatus(): Promise { + const active = await this.maintenanceRepo.findActiveMaintenance(); + + if (!active) { + return { inMaintenance: false }; + } + + return { + inMaintenance: true, + title: active.title, + message: active.message, + endTime: active.endTime, + }; + } +} + +/** + * 移动端系统维护控制器(无需认证) + */ +@ApiTags('移动端-系统维护') +@Controller('mobile/system') +export class MobileMaintenanceController { + constructor( + @Inject(SYSTEM_MAINTENANCE_REPOSITORY) + private readonly maintenanceRepo: ISystemMaintenanceRepository, + ) {} + + /** + * 检查系统维护状态 + * 注意:此接口无需认证,任何客户端都可以调用 + */ + @Get('maintenance-status') + @ApiOperation({ summary: '检查系统维护状态' }) + @ApiResponse({ status: 200, type: MaintenanceStatusResponseDto }) + async checkMaintenanceStatus(): Promise { + const active = await this.maintenanceRepo.findActiveMaintenance(); + + if (!active) { + return { inMaintenance: false }; + } + + return { + inMaintenance: true, + title: active.title, + message: active.message, + endTime: active.endTime, + }; + } +} diff --git a/backend/services/admin-service/src/api/dto/request/system-maintenance.dto.ts b/backend/services/admin-service/src/api/dto/request/system-maintenance.dto.ts new file mode 100644 index 00000000..9534fc88 --- /dev/null +++ b/backend/services/admin-service/src/api/dto/request/system-maintenance.dto.ts @@ -0,0 +1,59 @@ +import { IsString, IsDateString, IsBoolean, IsOptional, MaxLength } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +/** + * 创建系统维护公告请求 + */ +export class CreateMaintenanceDto { + @ApiProperty({ description: '维护标题', example: '系统升级中' }) + @IsString() + @MaxLength(100) + title: string; + + @ApiProperty({ description: '维护说明', example: '系统正在升级,预计10:00恢复服务,请稍候...' }) + @IsString() + message: string; + + @ApiProperty({ description: '维护开始时间', example: '2025-01-01T10:00:00Z' }) + @IsDateString() + startTime: string; + + @ApiProperty({ description: '维护结束时间', example: '2025-01-01T12:00:00Z' }) + @IsDateString() + endTime: string; +} + +/** + * 更新系统维护公告请求 + */ +export class UpdateMaintenanceDto { + @ApiPropertyOptional({ description: '维护标题' }) + @IsOptional() + @IsString() + @MaxLength(100) + title?: string; + + @ApiPropertyOptional({ description: '维护说明' }) + @IsOptional() + @IsString() + message?: string; + + @ApiPropertyOptional({ description: '维护开始时间' }) + @IsOptional() + @IsDateString() + startTime?: string; + + @ApiPropertyOptional({ description: '维护结束时间' }) + @IsOptional() + @IsDateString() + endTime?: string; +} + +/** + * 激活/停用维护公告请求 + */ +export class ToggleMaintenanceDto { + @ApiProperty({ description: '是否激活' }) + @IsBoolean() + isActive: boolean; +} diff --git a/backend/services/admin-service/src/api/dto/response/system-maintenance.dto.ts b/backend/services/admin-service/src/api/dto/response/system-maintenance.dto.ts new file mode 100644 index 00000000..4e6629a2 --- /dev/null +++ b/backend/services/admin-service/src/api/dto/response/system-maintenance.dto.ts @@ -0,0 +1,84 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { SystemMaintenanceEntity } from '../../../domain/entities/system-maintenance.entity'; + +/** + * 系统维护公告响应 + */ +export class MaintenanceResponseDto { + @ApiProperty({ description: 'ID' }) + id: string; + + @ApiProperty({ description: '维护标题' }) + title: string; + + @ApiProperty({ description: '维护说明' }) + message: string; + + @ApiProperty({ description: '维护开始时间' }) + startTime: Date; + + @ApiProperty({ description: '维护结束时间' }) + endTime: Date; + + @ApiProperty({ description: '是否激活' }) + isActive: boolean; + + @ApiProperty({ description: '是否在维护时间窗口内' }) + isInMaintenanceWindow: boolean; + + @ApiProperty({ description: '创建时间' }) + createdAt: Date; + + @ApiProperty({ description: '更新时间' }) + updatedAt: Date; + + @ApiProperty({ description: '创建人' }) + createdBy: string; + + @ApiPropertyOptional({ description: '更新人' }) + updatedBy?: string; + + static fromEntity(entity: SystemMaintenanceEntity): MaintenanceResponseDto { + return { + id: entity.id, + title: entity.title, + message: entity.message, + startTime: entity.startTime, + endTime: entity.endTime, + isActive: entity.isActive, + isInMaintenanceWindow: entity.isInMaintenanceWindow(), + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + createdBy: entity.createdBy, + updatedBy: entity.updatedBy ?? undefined, + }; + } +} + +/** + * 维护公告列表响应 + */ +export class MaintenanceListResponseDto { + @ApiProperty({ description: '维护公告列表', type: [MaintenanceResponseDto] }) + items: MaintenanceResponseDto[]; + + @ApiProperty({ description: '总数' }) + total: number; +} + +/** + * 维护状态响应(给移动端,无需登录) + */ +export class MaintenanceStatusResponseDto { + @ApiProperty({ description: '是否在维护中' }) + inMaintenance: boolean; + + @ApiPropertyOptional({ description: '维护标题' }) + title?: string; + + @ApiPropertyOptional({ description: '维护说明' }) + message?: string; + + @ApiPropertyOptional({ description: '预计结束时间' }) + endTime?: Date; +} diff --git a/backend/services/admin-service/src/api/interceptors/maintenance.interceptor.ts b/backend/services/admin-service/src/api/interceptors/maintenance.interceptor.ts new file mode 100644 index 00000000..342e87ee --- /dev/null +++ b/backend/services/admin-service/src/api/interceptors/maintenance.interceptor.ts @@ -0,0 +1,73 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, + HttpException, + HttpStatus, + Inject, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { + SYSTEM_MAINTENANCE_REPOSITORY, + ISystemMaintenanceRepository, +} from '../../domain/repositories/system-maintenance.repository'; + +/** + * 系统维护拦截器 + * 在维护期间,拦截移动端的业务 API 请求,返回 503 状态码 + */ +@Injectable() +export class MaintenanceInterceptor implements NestInterceptor { + constructor( + @Inject(SYSTEM_MAINTENANCE_REPOSITORY) + private readonly maintenanceRepo: ISystemMaintenanceRepository, + ) {} + + async intercept( + context: ExecutionContext, + next: CallHandler, + ): Promise> { + const request = context.switchToHttp().getRequest(); + const path = request.path; + + // 白名单路径 - 这些路径在维护期间也可以访问 + const whitelist = [ + '/mobile/system/maintenance-status', // 维护状态检查 + '/health', // 健康检查 + '/api/health', // 健康检查(带前缀) + ]; + + // 只拦截移动端 API(以 /mobile 开头的路径) + if (!path.startsWith('/mobile')) { + return next.handle(); + } + + // 检查是否在白名单中 + if (whitelist.some((p) => path.startsWith(p))) { + return next.handle(); + } + + // 检查是否在维护中 + const activeMaintenance = await this.maintenanceRepo.findActiveMaintenance(); + + if (activeMaintenance) { + throw new HttpException( + { + statusCode: HttpStatus.SERVICE_UNAVAILABLE, + error: 'Service Unavailable', + message: '系统维护中', + maintenance: { + inMaintenance: true, + title: activeMaintenance.title, + message: activeMaintenance.message, + endTime: activeMaintenance.endTime, + }, + }, + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + + return next.handle(); + } +} diff --git a/backend/services/admin-service/src/app.module.ts b/backend/services/admin-service/src/app.module.ts index 24aa8629..710e8ceb 100644 --- a/backend/services/admin-service/src/app.module.ts +++ b/backend/services/admin-service/src/app.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { APP_INTERCEPTOR } from '@nestjs/core'; import { ConfigModule } from '@nestjs/config'; import { ServeStaticModule } from '@nestjs/serve-static'; import { ScheduleModule } from '@nestjs/schedule'; @@ -60,6 +61,11 @@ import { CO_MANAGED_WALLET_SESSION_REPOSITORY, CO_MANAGED_WALLET_REPOSITORY, } from './domain/repositories/co-managed-wallet.repository'; +// System Maintenance imports +import { SYSTEM_MAINTENANCE_REPOSITORY } from './domain/repositories/system-maintenance.repository'; +import { SystemMaintenanceRepositoryImpl } from './infrastructure/persistence/repositories/system-maintenance.repository.impl'; +import { AdminMaintenanceController, MobileMaintenanceController } from './api/controllers/system-maintenance.controller'; +import { MaintenanceInterceptor } from './api/interceptors/maintenance.interceptor'; @Module({ imports: [ @@ -91,6 +97,9 @@ import { AudienceSegmentController, // Co-Managed Wallet Controller CoManagedWalletController, + // System Maintenance Controllers + AdminMaintenanceController, + MobileMaintenanceController, ], providers: [ PrismaService, @@ -157,6 +166,16 @@ import { provide: CO_MANAGED_WALLET_REPOSITORY, useClass: CoManagedWalletRepositoryImpl, }, + // System Maintenance + { + provide: SYSTEM_MAINTENANCE_REPOSITORY, + useClass: SystemMaintenanceRepositoryImpl, + }, + // Global Interceptors + { + provide: APP_INTERCEPTOR, + useClass: MaintenanceInterceptor, + }, ], }) export class AppModule {} diff --git a/backend/services/admin-service/src/domain/entities/system-maintenance.entity.ts b/backend/services/admin-service/src/domain/entities/system-maintenance.entity.ts new file mode 100644 index 00000000..c717bda7 --- /dev/null +++ b/backend/services/admin-service/src/domain/entities/system-maintenance.entity.ts @@ -0,0 +1,133 @@ +/** + * 系统维护公告实体 + * 用于系统升级/维护期间阻断用户操作 + */ +export class SystemMaintenanceEntity { + constructor( + public readonly id: string, + public readonly title: string, + public readonly message: string, + public readonly startTime: Date, + public readonly endTime: Date, + public readonly isActive: boolean, + public readonly createdAt: Date, + public readonly updatedAt: Date, + public readonly createdBy: string, + public readonly updatedBy: string | null, + ) {} + + /** + * 检查当前是否在维护期间 + */ + isInMaintenanceWindow(): boolean { + if (!this.isActive) return false; + const now = new Date(); + return now >= this.startTime && now <= this.endTime; + } + + /** + * 检查维护是否已结束 + */ + isExpired(): boolean { + return new Date() > this.endTime; + } + + /** + * 创建新的维护公告 + */ + static create(params: { + id: string; + title: string; + message: string; + startTime: Date; + endTime: Date; + createdBy: string; + }): SystemMaintenanceEntity { + return new SystemMaintenanceEntity( + params.id, + params.title, + params.message, + params.startTime, + params.endTime, + false, // 默认不激活,需手动激活 + new Date(), + new Date(), + params.createdBy, + null, + ); + } + + /** + * 激活维护公告 + */ + activate(updatedBy: string): SystemMaintenanceEntity { + return new SystemMaintenanceEntity( + this.id, + this.title, + this.message, + this.startTime, + this.endTime, + true, + this.createdAt, + new Date(), + this.createdBy, + updatedBy, + ); + } + + /** + * 停用维护公告 + */ + deactivate(updatedBy: string): SystemMaintenanceEntity { + return new SystemMaintenanceEntity( + this.id, + this.title, + this.message, + this.startTime, + this.endTime, + false, + this.createdAt, + new Date(), + this.createdBy, + updatedBy, + ); + } + + /** + * 更新维护公告 + */ + update(params: { + title?: string; + message?: string; + startTime?: Date; + endTime?: Date; + updatedBy: string; + }): SystemMaintenanceEntity { + return new SystemMaintenanceEntity( + this.id, + params.title ?? this.title, + params.message ?? this.message, + params.startTime ?? this.startTime, + params.endTime ?? this.endTime, + this.isActive, + this.createdAt, + new Date(), + this.createdBy, + params.updatedBy, + ); + } +} + +/** + * 维护状态响应(给移动端) + */ +export interface MaintenanceStatusResponse { + /** 是否在维护中 */ + inMaintenance: boolean; + /** 维护标题 */ + title?: string; + /** 维护说明 */ + message?: string; + /** 预计结束时间 */ + endTime?: Date; +} diff --git a/backend/services/admin-service/src/domain/repositories/system-maintenance.repository.ts b/backend/services/admin-service/src/domain/repositories/system-maintenance.repository.ts new file mode 100644 index 00000000..693d621e --- /dev/null +++ b/backend/services/admin-service/src/domain/repositories/system-maintenance.repository.ts @@ -0,0 +1,46 @@ +import { SystemMaintenanceEntity } from '../entities/system-maintenance.entity'; + +/** + * 系统维护公告仓储接口 + */ +export interface ISystemMaintenanceRepository { + /** + * 创建维护公告 + */ + create(entity: SystemMaintenanceEntity): Promise; + + /** + * 根据ID查找 + */ + findById(id: string): Promise; + + /** + * 查找当前激活的维护公告 + */ + findActiveMaintenance(): Promise; + + /** + * 查找所有维护公告(分页) + */ + findAll(params: { + limit: number; + offset: number; + }): Promise<{ items: SystemMaintenanceEntity[]; total: number }>; + + /** + * 更新维护公告 + */ + update(entity: SystemMaintenanceEntity): Promise; + + /** + * 删除维护公告 + */ + delete(id: string): Promise; + + /** + * 停用所有其他激活的维护公告(确保同时只有一个激活) + */ + deactivateAll(excludeId?: string): Promise; +} + +export const SYSTEM_MAINTENANCE_REPOSITORY = Symbol('ISystemMaintenanceRepository'); diff --git a/backend/services/admin-service/src/infrastructure/persistence/repositories/system-maintenance.repository.impl.ts b/backend/services/admin-service/src/infrastructure/persistence/repositories/system-maintenance.repository.impl.ts new file mode 100644 index 00000000..287ac324 --- /dev/null +++ b/backend/services/admin-service/src/infrastructure/persistence/repositories/system-maintenance.repository.impl.ts @@ -0,0 +1,108 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { SystemMaintenanceEntity } from '../../../domain/entities/system-maintenance.entity'; +import { ISystemMaintenanceRepository } from '../../../domain/repositories/system-maintenance.repository'; + +@Injectable() +export class SystemMaintenanceRepositoryImpl implements ISystemMaintenanceRepository { + constructor(private readonly prisma: PrismaService) {} + + private toEntity(data: any): SystemMaintenanceEntity { + return new SystemMaintenanceEntity( + data.id, + data.title, + data.message, + data.startTime, + data.endTime, + data.isActive, + data.createdAt, + data.updatedAt, + data.createdBy, + data.updatedBy, + ); + } + + async create(entity: SystemMaintenanceEntity): Promise { + const data = await this.prisma.systemMaintenance.create({ + data: { + id: entity.id, + title: entity.title, + message: entity.message, + startTime: entity.startTime, + endTime: entity.endTime, + isActive: entity.isActive, + createdBy: entity.createdBy, + }, + }); + return this.toEntity(data); + } + + async findById(id: string): Promise { + const data = await this.prisma.systemMaintenance.findUnique({ + where: { id }, + }); + return data ? this.toEntity(data) : null; + } + + async findActiveMaintenance(): Promise { + const now = new Date(); + const data = await this.prisma.systemMaintenance.findFirst({ + where: { + isActive: true, + startTime: { lte: now }, + endTime: { gte: now }, + }, + orderBy: { createdAt: 'desc' }, + }); + return data ? this.toEntity(data) : null; + } + + async findAll(params: { + limit: number; + offset: number; + }): Promise<{ items: SystemMaintenanceEntity[]; total: number }> { + const [items, total] = await Promise.all([ + this.prisma.systemMaintenance.findMany({ + take: params.limit, + skip: params.offset, + orderBy: { createdAt: 'desc' }, + }), + this.prisma.systemMaintenance.count(), + ]); + + return { + items: items.map((item) => this.toEntity(item)), + total, + }; + } + + async update(entity: SystemMaintenanceEntity): Promise { + const data = await this.prisma.systemMaintenance.update({ + where: { id: entity.id }, + data: { + title: entity.title, + message: entity.message, + startTime: entity.startTime, + endTime: entity.endTime, + isActive: entity.isActive, + updatedBy: entity.updatedBy, + }, + }); + return this.toEntity(data); + } + + async delete(id: string): Promise { + await this.prisma.systemMaintenance.delete({ + where: { id }, + }); + } + + async deactivateAll(excludeId?: string): Promise { + await this.prisma.systemMaintenance.updateMany({ + where: excludeId + ? { id: { not: excludeId }, isActive: true } + : { isActive: true }, + data: { isActive: false }, + }); + } +} diff --git a/frontend/admin-web/src/app/(dashboard)/maintenance/maintenance.module.scss b/frontend/admin-web/src/app/(dashboard)/maintenance/maintenance.module.scss new file mode 100644 index 00000000..9792ae42 --- /dev/null +++ b/frontend/admin-web/src/app/(dashboard)/maintenance/maintenance.module.scss @@ -0,0 +1,376 @@ +@use '@/styles/variables' as *; + +.maintenance { + display: flex; + flex-direction: column; + gap: 24px; + + &__header { + display: flex; + justify-content: space-between; + align-items: center; + } + + &__title { + font-size: 24px; + font-weight: 600; + color: $text-primary; + } + + &__card { + background: $card-background; + border-radius: 12px; + padding: 24px; + box-shadow: $shadow-base; + } + + // 当前状态卡片 + &__statusCard { + background: linear-gradient(135deg, #fff9e6 0%, #fff3cc 100%); + border: 1px solid #ffd666; + border-radius: 12px; + padding: 20px 24px; + margin-bottom: 24px; + + &--active { + background: linear-gradient(135deg, #fff1f0 0%, #ffccc7 100%); + border-color: #ff7875; + } + } + + &__statusHeader { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 8px; + } + + &__statusIcon { + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + background: rgba(255, 255, 255, 0.8); + } + + &__statusTitle { + font-size: 16px; + font-weight: 600; + color: $text-primary; + } + + &__statusBadge { + display: inline-block; + padding: 2px 10px; + border-radius: 12px; + font-size: 12px; + font-weight: 500; + margin-left: 8px; + + &--active { + background: #ff4d4f; + color: white; + } + + &--inactive { + background: #52c41a; + color: white; + } + } + + &__statusContent { + font-size: 14px; + color: $text-secondary; + line-height: 1.6; + } + + &__statusTime { + font-size: 13px; + color: $text-disabled; + margin-top: 8px; + } + + // 工具栏 + &__toolbar { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + } + + &__actions { + display: flex; + gap: 12px; + } + + // 列表 + &__list { + display: flex; + flex-direction: column; + gap: 16px; + } + + &__loading, + &__empty, + &__error { + display: flex; + justify-content: center; + align-items: center; + gap: 12px; + padding: 60px 20px; + color: $text-secondary; + font-size: 14px; + } + + &__error { + color: $error-color; + } + + // 列表项 + &__item { + background: white; + border: 1px solid $border-color; + border-radius: 10px; + padding: 16px 20px; + transition: all 0.2s; + + &:hover { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + } + + &--active { + border-color: #ff7875; + background: #fff8f8; + } + + &--expired { + opacity: 0.6; + background: #fafafa; + } + } + + &__itemHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + } + + &__itemMeta { + display: flex; + gap: 8px; + align-items: center; + } + + &__tag { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + + &--active { + background: #fff1f0; + color: #ff4d4f; + } + + &--scheduled { + background: #e6f7ff; + color: #1890ff; + } + + &--expired { + background: #f5f5f5; + color: #8c8c8c; + } + } + + &__itemActions { + display: flex; + gap: 8px; + } + + &__actionBtn { + padding: 4px 12px; + font-size: 13px; + color: $text-secondary; + background: transparent; + border: 1px solid $border-color; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s; + + &:hover { + color: $primary-color; + border-color: $primary-color; + } + + &--danger { + &:hover { + color: $error-color; + border-color: $error-color; + } + } + + &--primary { + background: $primary-color; + color: white; + border-color: $primary-color; + + &:hover { + background: darken($primary-color, 10%); + } + } + } + + &__itemTitle { + font-size: 16px; + font-weight: 600; + color: $text-primary; + margin-bottom: 8px; + } + + &__itemContent { + font-size: 14px; + color: $text-secondary; + line-height: 1.6; + margin-bottom: 12px; + white-space: pre-wrap; + } + + &__itemFooter { + display: flex; + gap: 20px; + font-size: 12px; + color: $text-disabled; + } + + &__timeRange { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: $text-secondary; + + svg { + width: 16px; + height: 16px; + color: $text-disabled; + } + } + + // 表单 + &__form { + display: flex; + flex-direction: column; + gap: 16px; + } + + &__formRow { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; + } + + &__formGroup { + display: flex; + flex-direction: column; + gap: 6px; + + label { + font-size: 14px; + font-weight: 500; + color: $text-primary; + } + + input, + select, + textarea { + padding: 10px 12px; + border: 1px solid $border-color; + border-radius: 8px; + font-size: 14px; + transition: border-color 0.2s; + + &:focus { + outline: none; + border-color: $primary-color; + } + + &::placeholder { + color: $text-disabled; + } + } + + textarea { + resize: vertical; + min-height: 100px; + } + } + + &__formHint { + font-size: 12px; + color: $text-disabled; + } + + &__modalFooter { + display: flex; + justify-content: flex-end; + gap: 12px; + } + + // 快捷时间选择 + &__quickTimeSelector { + display: flex; + gap: 8px; + margin-bottom: 8px; + } + + &__quickTimeBtn { + padding: 4px 12px; + font-size: 12px; + color: $text-secondary; + background: #f5f5f5; + border: 1px solid transparent; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: #e8e8e8; + } + + &--active { + background: $primary-color; + color: white; + } + } + + // 警告提示 + &__warning { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 12px 16px; + background: #fffbe6; + border: 1px solid #ffe58f; + border-radius: 8px; + margin-top: 16px; + + svg { + flex-shrink: 0; + width: 20px; + height: 20px; + color: #faad14; + } + } + + &__warningText { + font-size: 13px; + color: $text-secondary; + line-height: 1.5; + } +} diff --git a/frontend/admin-web/src/app/(dashboard)/maintenance/page.tsx b/frontend/admin-web/src/app/(dashboard)/maintenance/page.tsx new file mode 100644 index 00000000..df802de5 --- /dev/null +++ b/frontend/admin-web/src/app/(dashboard)/maintenance/page.tsx @@ -0,0 +1,487 @@ +'use client'; + +import { useState, useCallback, useEffect } from 'react'; +import { Modal, toast, Button } from '@/components/common'; +import { PageContainer } from '@/components/layout'; +import { cn } from '@/utils/helpers'; +import { formatDateTime } from '@/utils/formatters'; +import { + maintenanceService, + MaintenanceItem, + MaintenanceStatusResponse, +} from '@/services/maintenanceService'; +import styles from './maintenance.module.scss'; + +// 快捷时间选项(分钟) +const QUICK_DURATIONS = [ + { label: '5分钟', minutes: 5 }, + { label: '15分钟', minutes: 15 }, + { label: '30分钟', minutes: 30 }, + { label: '1小时', minutes: 60 }, + { label: '2小时', minutes: 120 }, +]; + +// 获取维护状态标签 +const getStatusTag = (item: MaintenanceItem) => { + const now = new Date(); + const startTime = new Date(item.startTime); + const endTime = new Date(item.endTime); + + if (item.isActive && now >= startTime && now <= endTime) { + return { label: '维护中', style: 'active' }; + } + if (now > endTime) { + return { label: '已过期', style: 'expired' }; + } + if (now < startTime) { + return { label: '已计划', style: 'scheduled' }; + } + return { label: '未激活', style: 'expired' }; +}; + +// 获取项目样式类名 +const getItemClassName = (item: MaintenanceItem) => { + const now = new Date(); + const endTime = new Date(item.endTime); + + if (item.isActive) { + return styles['maintenance__item--active']; + } + if (now > endTime) { + return styles['maintenance__item--expired']; + } + return ''; +}; + +/** + * 系统维护管理页面 + */ +export default function MaintenancePage() { + // 数据状态 + const [maintenanceList, setMaintenanceList] = useState([]); + const [currentStatus, setCurrentStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // 弹窗状态 + const [showModal, setShowModal] = useState(false); + const [editingItem, setEditingItem] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState(null); + + // 表单状态 + const [formData, setFormData] = useState({ + title: '', + message: '', + startTime: '', + endTime: '', + }); + + // 加载数据 + const loadData = useCallback(async () => { + try { + setLoading(true); + setError(null); + + const [listResponse, statusResponse] = await Promise.all([ + maintenanceService.getMaintenanceList(), + maintenanceService.getCurrentStatus(), + ]); + + setMaintenanceList(listResponse.items); + setCurrentStatus(statusResponse); + } catch (err) { + setError((err as Error).message || '加载失败'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadData(); + }, [loadData]); + + // 打开创建弹窗 + const handleCreate = () => { + setEditingItem(null); + // 默认从现在开始,持续30分钟 + const now = new Date(); + const end = new Date(now.getTime() + 30 * 60 * 1000); + + setFormData({ + title: '系统升级维护', + message: '尊敬的用户,系统正在进行升级维护,请稍后再试。给您带来的不便,敬请谅解。', + startTime: formatLocalDateTime(now), + endTime: formatLocalDateTime(end), + }); + setShowModal(true); + }; + + // 打开编辑弹窗 + const handleEdit = (item: MaintenanceItem) => { + setEditingItem(item); + setFormData({ + title: item.title, + message: item.message, + startTime: formatLocalDateTime(new Date(item.startTime)), + endTime: formatLocalDateTime(new Date(item.endTime)), + }); + setShowModal(true); + }; + + // 格式化为本地日期时间字符串(用于 datetime-local input) + const formatLocalDateTime = (date: Date) => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + return `${year}-${month}-${day}T${hours}:${minutes}`; + }; + + // 快捷设置时间 + const handleQuickDuration = (minutes: number) => { + const now = new Date(); + const end = new Date(now.getTime() + minutes * 60 * 1000); + setFormData(prev => ({ + ...prev, + startTime: formatLocalDateTime(now), + endTime: formatLocalDateTime(end), + })); + }; + + // 关闭弹窗 + const handleCloseModal = () => { + setShowModal(false); + setEditingItem(null); + }; + + // 提交表单 + const handleSubmit = async () => { + if (!formData.title.trim()) { + toast.error('请输入维护标题'); + return; + } + if (!formData.message.trim()) { + toast.error('请输入维护公告内容'); + return; + } + if (!formData.startTime) { + toast.error('请选择开始时间'); + return; + } + if (!formData.endTime) { + toast.error('请选择结束时间'); + return; + } + + const startTime = new Date(formData.startTime); + const endTime = new Date(formData.endTime); + + if (endTime <= startTime) { + toast.error('结束时间必须晚于开始时间'); + return; + } + + try { + const payload = { + title: formData.title.trim(), + message: formData.message.trim(), + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + }; + + if (editingItem) { + await maintenanceService.updateMaintenance(editingItem.id, payload); + toast.success('维护配置已更新'); + } else { + await maintenanceService.createMaintenance(payload); + toast.success('维护配置已创建'); + } + + handleCloseModal(); + loadData(); + } catch (err) { + toast.error((err as Error).message || '操作失败'); + } + }; + + // 切换激活状态 + const handleToggle = async (item: MaintenanceItem) => { + try { + await maintenanceService.toggleMaintenance(item.id, !item.isActive); + toast.success(item.isActive ? '已停止维护' : '已激活维护'); + loadData(); + } catch (err) { + toast.error((err as Error).message || '操作失败'); + } + }; + + // 删除 + const handleDelete = async (id: string) => { + try { + await maintenanceService.deleteMaintenance(id); + toast.success('维护配置已删除'); + setDeleteConfirm(null); + loadData(); + } catch (err) { + toast.error((err as Error).message || '删除失败'); + } + }; + + return ( + +
+ {/* 页面标题 */} +
+

系统维护管理

+
+ + {/* 当前状态卡片 */} +
+
+ + {currentStatus?.isUnderMaintenance ? '🔧' : '✅'} + + + 当前系统状态 + + {currentStatus?.isUnderMaintenance ? '维护中' : '正常运行'} + + +
+ {currentStatus?.isUnderMaintenance && currentStatus.maintenance && ( + <> +
+ {currentStatus.maintenance.message} +
+
+ 预计剩余时间: {currentStatus.maintenance.remainingMinutes} 分钟 + ({formatDateTime(currentStatus.maintenance.endTime)} 结束) +
+ + )} + {!currentStatus?.isUnderMaintenance && ( +
+ 移动端 APP 用户可正常访问所有功能。 +
+ )} +
+ + {/* 维护配置列表 */} +
+
+
+ +
+ +
+ +
+ {loading ? ( +
加载中...
+ ) : error ? ( +
+ {error} + +
+ ) : maintenanceList.length === 0 ? ( +
+ 暂无维护计划,点击"新建维护计划"创建 +
+ ) : ( + maintenanceList.map((item) => { + const status = getStatusTag(item); + return ( +
+
+
+ + {status.label} + +
+
+ {status.style !== 'expired' && ( + + )} + + +
+
+

{item.title}

+

{item.message}

+
+
+ + + + + + {formatDateTime(item.startTime)} - {formatDateTime(item.endTime)} + +
+ 创建: {formatDateTime(item.createdAt)} +
+
+ ); + }) + )} +
+
+ + {/* 创建/编辑弹窗 */} + + + +
+ } + width={600} + > +
+
+ + setFormData({ ...formData, title: e.target.value })} + placeholder="例如:系统升级维护" + maxLength={100} + /> +
+ +
+ +