feat(mobile-app,admin): 添加系统维护功能和通知徽章功能
系统维护功能: - 后端: 添加系统维护配置实体、仓库和控制器 - 后端: 添加维护模式拦截器,返回503状态码 - admin-web: 添加系统维护管理页面,支持创建/编辑/开关维护配置 - mobile-app: 添加维护状态检查服务和阻断弹窗 - mobile-app: 在启动页、向导页集成维护检查 - mobile-app: 支持App从后台恢复时自动检查维护状态 通知徽章功能: - 添加通知徽章Provider,监听登录状态自动刷新 - 底部导航栏"我的"标签显示未读通知红点 - 进入通知页面自动刷新徽章状态 - 切换账号、退出登录自动清除徽章 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
fea01642e7
commit
c328d8b59b
|
|
@ -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 (共管钱包系统)
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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<MaintenanceResponseDto> {
|
||||
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<MaintenanceListResponseDto> {
|
||||
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<MaintenanceResponseDto> {
|
||||
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<MaintenanceResponseDto> {
|
||||
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<MaintenanceResponseDto> {
|
||||
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<void> {
|
||||
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<MaintenanceStatusResponseDto> {
|
||||
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<MaintenanceStatusResponseDto> {
|
||||
const active = await this.maintenanceRepo.findActiveMaintenance();
|
||||
|
||||
if (!active) {
|
||||
return { inMaintenance: false };
|
||||
}
|
||||
|
||||
return {
|
||||
inMaintenance: true,
|
||||
title: active.title,
|
||||
message: active.message,
|
||||
endTime: active.endTime,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<Observable<any>> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import { SystemMaintenanceEntity } from '../entities/system-maintenance.entity';
|
||||
|
||||
/**
|
||||
* 系统维护公告仓储接口
|
||||
*/
|
||||
export interface ISystemMaintenanceRepository {
|
||||
/**
|
||||
* 创建维护公告
|
||||
*/
|
||||
create(entity: SystemMaintenanceEntity): Promise<SystemMaintenanceEntity>;
|
||||
|
||||
/**
|
||||
* 根据ID查找
|
||||
*/
|
||||
findById(id: string): Promise<SystemMaintenanceEntity | null>;
|
||||
|
||||
/**
|
||||
* 查找当前激活的维护公告
|
||||
*/
|
||||
findActiveMaintenance(): Promise<SystemMaintenanceEntity | null>;
|
||||
|
||||
/**
|
||||
* 查找所有维护公告(分页)
|
||||
*/
|
||||
findAll(params: {
|
||||
limit: number;
|
||||
offset: number;
|
||||
}): Promise<{ items: SystemMaintenanceEntity[]; total: number }>;
|
||||
|
||||
/**
|
||||
* 更新维护公告
|
||||
*/
|
||||
update(entity: SystemMaintenanceEntity): Promise<SystemMaintenanceEntity>;
|
||||
|
||||
/**
|
||||
* 删除维护公告
|
||||
*/
|
||||
delete(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* 停用所有其他激活的维护公告(确保同时只有一个激活)
|
||||
*/
|
||||
deactivateAll(excludeId?: string): Promise<void>;
|
||||
}
|
||||
|
||||
export const SYSTEM_MAINTENANCE_REPOSITORY = Symbol('ISystemMaintenanceRepository');
|
||||
|
|
@ -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<SystemMaintenanceEntity> {
|
||||
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<SystemMaintenanceEntity | null> {
|
||||
const data = await this.prisma.systemMaintenance.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
return data ? this.toEntity(data) : null;
|
||||
}
|
||||
|
||||
async findActiveMaintenance(): Promise<SystemMaintenanceEntity | null> {
|
||||
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<SystemMaintenanceEntity> {
|
||||
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<void> {
|
||||
await this.prisma.systemMaintenance.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
async deactivateAll(excludeId?: string): Promise<void> {
|
||||
await this.prisma.systemMaintenance.updateMany({
|
||||
where: excludeId
|
||||
? { id: { not: excludeId }, isActive: true }
|
||||
: { isActive: true },
|
||||
data: { isActive: false },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<MaintenanceItem[]>([]);
|
||||
const [currentStatus, setCurrentStatus] = useState<MaintenanceStatusResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 弹窗状态
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<MaintenanceItem | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(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 (
|
||||
<PageContainer title="系统维护">
|
||||
<div className={styles.maintenance}>
|
||||
{/* 页面标题 */}
|
||||
<div className={styles.maintenance__header}>
|
||||
<h1 className={styles.maintenance__title}>系统维护管理</h1>
|
||||
</div>
|
||||
|
||||
{/* 当前状态卡片 */}
|
||||
<div
|
||||
className={cn(
|
||||
styles.maintenance__statusCard,
|
||||
currentStatus?.isUnderMaintenance && styles['maintenance__statusCard--active']
|
||||
)}
|
||||
>
|
||||
<div className={styles.maintenance__statusHeader}>
|
||||
<span className={styles.maintenance__statusIcon}>
|
||||
{currentStatus?.isUnderMaintenance ? '🔧' : '✅'}
|
||||
</span>
|
||||
<span className={styles.maintenance__statusTitle}>
|
||||
当前系统状态
|
||||
<span
|
||||
className={cn(
|
||||
styles.maintenance__statusBadge,
|
||||
currentStatus?.isUnderMaintenance
|
||||
? styles['maintenance__statusBadge--active']
|
||||
: styles['maintenance__statusBadge--inactive']
|
||||
)}
|
||||
>
|
||||
{currentStatus?.isUnderMaintenance ? '维护中' : '正常运行'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{currentStatus?.isUnderMaintenance && currentStatus.maintenance && (
|
||||
<>
|
||||
<div className={styles.maintenance__statusContent}>
|
||||
{currentStatus.maintenance.message}
|
||||
</div>
|
||||
<div className={styles.maintenance__statusTime}>
|
||||
预计剩余时间: {currentStatus.maintenance.remainingMinutes} 分钟
|
||||
({formatDateTime(currentStatus.maintenance.endTime)} 结束)
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!currentStatus?.isUnderMaintenance && (
|
||||
<div className={styles.maintenance__statusContent}>
|
||||
移动端 APP 用户可正常访问所有功能。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 维护配置列表 */}
|
||||
<div className={styles.maintenance__card}>
|
||||
<div className={styles.maintenance__toolbar}>
|
||||
<div className={styles.maintenance__actions}>
|
||||
<Button variant="outline" size="sm" onClick={loadData}>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="primary" onClick={handleCreate}>
|
||||
+ 新建维护计划
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={styles.maintenance__list}>
|
||||
{loading ? (
|
||||
<div className={styles.maintenance__loading}>加载中...</div>
|
||||
) : error ? (
|
||||
<div className={styles.maintenance__error}>
|
||||
<span>{error}</span>
|
||||
<Button variant="outline" size="sm" onClick={loadData}>
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
) : maintenanceList.length === 0 ? (
|
||||
<div className={styles.maintenance__empty}>
|
||||
暂无维护计划,点击"新建维护计划"创建
|
||||
</div>
|
||||
) : (
|
||||
maintenanceList.map((item) => {
|
||||
const status = getStatusTag(item);
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(styles.maintenance__item, getItemClassName(item))}
|
||||
>
|
||||
<div className={styles.maintenance__itemHeader}>
|
||||
<div className={styles.maintenance__itemMeta}>
|
||||
<span
|
||||
className={cn(
|
||||
styles.maintenance__tag,
|
||||
styles[`maintenance__tag--${status.style}`]
|
||||
)}
|
||||
>
|
||||
{status.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.maintenance__itemActions}>
|
||||
{status.style !== 'expired' && (
|
||||
<button
|
||||
className={cn(
|
||||
styles.maintenance__actionBtn,
|
||||
item.isActive && styles['maintenance__actionBtn--primary']
|
||||
)}
|
||||
onClick={() => handleToggle(item)}
|
||||
>
|
||||
{item.isActive ? '停止维护' : '立即激活'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={styles.maintenance__actionBtn}
|
||||
onClick={() => handleEdit(item)}
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
styles.maintenance__actionBtn,
|
||||
styles['maintenance__actionBtn--danger']
|
||||
)}
|
||||
onClick={() => setDeleteConfirm(item.id)}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className={styles.maintenance__itemTitle}>{item.title}</h3>
|
||||
<p className={styles.maintenance__itemContent}>{item.message}</p>
|
||||
<div className={styles.maintenance__itemFooter}>
|
||||
<div className={styles.maintenance__timeRange}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
<span>
|
||||
{formatDateTime(item.startTime)} - {formatDateTime(item.endTime)}
|
||||
</span>
|
||||
</div>
|
||||
<span>创建: {formatDateTime(item.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 创建/编辑弹窗 */}
|
||||
<Modal
|
||||
visible={showModal}
|
||||
title={editingItem ? '编辑维护计划' : '新建维护计划'}
|
||||
onClose={handleCloseModal}
|
||||
footer={
|
||||
<div className={styles.maintenance__modalFooter}>
|
||||
<Button variant="outline" onClick={handleCloseModal}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleSubmit}>
|
||||
{editingItem ? '保存' : '创建'}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
width={600}
|
||||
>
|
||||
<div className={styles.maintenance__form}>
|
||||
<div className={styles.maintenance__formGroup}>
|
||||
<label>维护标题 *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder="例如:系统升级维护"
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.maintenance__formGroup}>
|
||||
<label>公告内容 *</label>
|
||||
<textarea
|
||||
value={formData.message}
|
||||
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
|
||||
placeholder="显示给用户的维护公告内容"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.maintenance__formGroup}>
|
||||
<label>快捷设置时长</label>
|
||||
<div className={styles.maintenance__quickTimeSelector}>
|
||||
{QUICK_DURATIONS.map((d) => (
|
||||
<button
|
||||
key={d.minutes}
|
||||
type="button"
|
||||
className={styles.maintenance__quickTimeBtn}
|
||||
onClick={() => handleQuickDuration(d.minutes)}
|
||||
>
|
||||
{d.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.maintenance__formRow}>
|
||||
<div className={styles.maintenance__formGroup}>
|
||||
<label>开始时间 *</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={formData.startTime}
|
||||
onChange={(e) => setFormData({ ...formData, startTime: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.maintenance__formGroup}>
|
||||
<label>结束时间 *</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={formData.endTime}
|
||||
onChange={(e) => setFormData({ ...formData, endTime: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.maintenance__warning}>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" />
|
||||
</svg>
|
||||
<div className={styles.maintenance__warningText}>
|
||||
<strong>注意:</strong>激活维护后,移动端 APP 将显示维护公告弹窗,用户将无法进行任何操作。
|
||||
请确保在维护完成后及时停止维护或等待自动过期。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* 删除确认弹窗 */}
|
||||
<Modal
|
||||
visible={!!deleteConfirm}
|
||||
title="确认删除"
|
||||
onClose={() => setDeleteConfirm(null)}
|
||||
footer={
|
||||
<div className={styles.maintenance__modalFooter}>
|
||||
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="danger" onClick={() => deleteConfirm && handleDelete(deleteConfirm)}>
|
||||
确认删除
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
width={400}
|
||||
>
|
||||
<p>确定要删除这个维护计划吗?此操作无法撤销。</p>
|
||||
</Modal>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
|
@ -29,6 +29,7 @@ const topMenuItems: MenuItem[] = [
|
|||
{ key: 'authorization', icon: '/images/Container4.svg', label: '授权管理', path: '/authorization' },
|
||||
{ key: 'notifications', icon: '/images/Container3.svg', label: '通知管理', path: '/notifications' },
|
||||
{ key: 'statistics', icon: '/images/Container5.svg', label: '数据统计', path: '/statistics' },
|
||||
{ key: 'maintenance', icon: '/images/Container6.svg', label: '系统维护', path: '/maintenance' },
|
||||
{ key: 'settings', icon: '/images/Container6.svg', label: '系统设置', path: '/settings' },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -153,4 +153,15 @@ export const API_ENDPOINTS = {
|
|||
SESSION_DETAIL: (sessionId: string) => `/v1/admin/co-managed-wallets/sessions/${sessionId}`,
|
||||
WALLET_DETAIL: (walletId: string) => `/v1/admin/co-managed-wallets/${walletId}`,
|
||||
},
|
||||
|
||||
// 系统维护 (admin-service)
|
||||
MAINTENANCE: {
|
||||
LIST: '/v1/admin/maintenance',
|
||||
CREATE: '/v1/admin/maintenance',
|
||||
DETAIL: (id: string) => `/v1/admin/maintenance/${id}`,
|
||||
UPDATE: (id: string) => `/v1/admin/maintenance/${id}`,
|
||||
TOGGLE: (id: string) => `/v1/admin/maintenance/${id}/toggle`,
|
||||
DELETE: (id: string) => `/v1/admin/maintenance/${id}`,
|
||||
CURRENT_STATUS: '/v1/admin/maintenance/status/current',
|
||||
},
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
/**
|
||||
* 系统维护管理服务
|
||||
* 负责系统维护配置的API调用
|
||||
*/
|
||||
|
||||
import apiClient from '@/infrastructure/api/client';
|
||||
import { API_ENDPOINTS } from '@/infrastructure/api/endpoints';
|
||||
|
||||
/** 维护配置项 */
|
||||
export interface MaintenanceItem {
|
||||
id: string;
|
||||
title: string;
|
||||
message: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
createdBy: string;
|
||||
updatedBy: string | null;
|
||||
}
|
||||
|
||||
/** 创建维护配置请求 */
|
||||
export interface CreateMaintenanceRequest {
|
||||
title: string;
|
||||
message: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
}
|
||||
|
||||
/** 更新维护配置请求 */
|
||||
export interface UpdateMaintenanceRequest {
|
||||
title?: string;
|
||||
message?: string;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
}
|
||||
|
||||
/** 维护列表响应 */
|
||||
export interface MaintenanceListResponse {
|
||||
items: MaintenanceItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** 当前维护状态响应 */
|
||||
export interface MaintenanceStatusResponse {
|
||||
isUnderMaintenance: boolean;
|
||||
maintenance: {
|
||||
title: string;
|
||||
message: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
remainingMinutes: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 系统维护管理服务
|
||||
*/
|
||||
export const maintenanceService = {
|
||||
/**
|
||||
* 获取维护配置列表
|
||||
*/
|
||||
async getMaintenanceList(): Promise<MaintenanceListResponse> {
|
||||
return apiClient.get(API_ENDPOINTS.MAINTENANCE.LIST);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取维护配置详情
|
||||
*/
|
||||
async getMaintenance(id: string): Promise<MaintenanceItem> {
|
||||
return apiClient.get(API_ENDPOINTS.MAINTENANCE.DETAIL(id));
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建维护配置
|
||||
*/
|
||||
async createMaintenance(data: CreateMaintenanceRequest): Promise<MaintenanceItem> {
|
||||
return apiClient.post(API_ENDPOINTS.MAINTENANCE.CREATE, data);
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新维护配置
|
||||
*/
|
||||
async updateMaintenance(id: string, data: UpdateMaintenanceRequest): Promise<MaintenanceItem> {
|
||||
return apiClient.put(API_ENDPOINTS.MAINTENANCE.UPDATE(id), data);
|
||||
},
|
||||
|
||||
/**
|
||||
* 切换维护配置激活状态
|
||||
*/
|
||||
async toggleMaintenance(id: string, isActive: boolean): Promise<MaintenanceItem> {
|
||||
return apiClient.patch(API_ENDPOINTS.MAINTENANCE.TOGGLE(id), { isActive });
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除维护配置
|
||||
*/
|
||||
async deleteMaintenance(id: string): Promise<void> {
|
||||
return apiClient.delete(API_ENDPOINTS.MAINTENANCE.DELETE(id));
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取当前维护状态
|
||||
*/
|
||||
async getCurrentStatus(): Promise<MaintenanceStatusResponse> {
|
||||
return apiClient.get(API_ENDPOINTS.MAINTENANCE.CURRENT_STATUS);
|
||||
},
|
||||
};
|
||||
|
||||
export default maintenanceService;
|
||||
|
|
@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart';
|
|||
import 'core/di/injection_container.dart';
|
||||
import 'core/theme/app_theme.dart';
|
||||
import 'core/services/auth_event_service.dart';
|
||||
import 'core/providers/maintenance_provider.dart';
|
||||
import 'routes/app_router.dart';
|
||||
import 'routes/route_paths.dart';
|
||||
|
||||
|
|
@ -24,6 +25,13 @@ class _AppState extends ConsumerState<App> {
|
|||
void initState() {
|
||||
super.initState();
|
||||
_listenToAuthEvents();
|
||||
_initializeMaintenanceProvider();
|
||||
}
|
||||
|
||||
/// 初始化维护状态提供者
|
||||
void _initializeMaintenanceProvider() {
|
||||
// 设置全局导航 Key,以便在 App 生命周期事件中显示维护弹窗
|
||||
ref.read(maintenanceProvider.notifier).setNavigatorKey(rootNavigatorKey);
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -0,0 +1,379 @@
|
|||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../services/maintenance_service.dart';
|
||||
|
||||
/// 系统维护状态 Provider
|
||||
///
|
||||
/// 负责:
|
||||
/// 1. 检查系统维护状态
|
||||
/// 2. 监听 App 生命周期变化(后台切回时自动检查)
|
||||
/// 3. 定时刷新维护状态
|
||||
/// 4. 提供全局维护弹窗显示控制
|
||||
class MaintenanceNotifier extends StateNotifier<MaintenanceStatus>
|
||||
with WidgetsBindingObserver {
|
||||
static const _checkIntervalSeconds = 60; // 维护期间每 60 秒检查一次是否结束
|
||||
|
||||
Timer? _checkTimer;
|
||||
bool _isDialogShowing = false;
|
||||
BuildContext? _dialogContext;
|
||||
|
||||
/// 全局导航 Key,用于在生命周期事件中显示弹窗
|
||||
GlobalKey<NavigatorState>? _navigatorKey;
|
||||
|
||||
MaintenanceNotifier() : super(MaintenanceStatus.normal) {
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
// 初始化服务
|
||||
MaintenanceService().initialize();
|
||||
}
|
||||
|
||||
/// 设置全局导航 Key
|
||||
void setNavigatorKey(GlobalKey<NavigatorState> key) {
|
||||
_navigatorKey = key;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_checkTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 监听 App 生命周期变化
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState lifecycleState) {
|
||||
if (lifecycleState == AppLifecycleState.resumed) {
|
||||
// App 从后台切回前台,立即检查维护状态
|
||||
debugPrint('[MaintenanceProvider] App 恢复前台,检查维护状态');
|
||||
_checkAndShowDialogIfNeeded();
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查维护状态并在必要时显示弹窗
|
||||
Future<void> _checkAndShowDialogIfNeeded() async {
|
||||
final status = await checkMaintenanceStatus();
|
||||
|
||||
if (status.isUnderMaintenance && status.maintenance != null && !_isDialogShowing) {
|
||||
// 优先使用全局导航 Key
|
||||
final context = _navigatorKey?.currentContext;
|
||||
if (context != null) {
|
||||
_showMaintenanceDialog(context, status.maintenance!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查系统维护状态
|
||||
///
|
||||
/// 如果处于维护状态,会自动启动定时检查
|
||||
Future<MaintenanceStatus> checkMaintenanceStatus() async {
|
||||
try {
|
||||
final status = await MaintenanceService().checkStatus();
|
||||
state = status;
|
||||
|
||||
if (status.isUnderMaintenance) {
|
||||
debugPrint('[MaintenanceProvider] 系统正在维护');
|
||||
_startPeriodicCheck();
|
||||
} else {
|
||||
debugPrint('[MaintenanceProvider] 系统正常运行');
|
||||
_stopPeriodicCheck();
|
||||
// 如果之前在维护,现在恢复了,关闭弹窗
|
||||
if (_isDialogShowing) {
|
||||
_dismissMaintenanceDialog();
|
||||
}
|
||||
}
|
||||
|
||||
return status;
|
||||
} catch (e) {
|
||||
debugPrint('[MaintenanceProvider] 检查维护状态失败: $e');
|
||||
return MaintenanceStatus.normal;
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动定时检查(维护期间持续检查是否已恢复)
|
||||
void _startPeriodicCheck() {
|
||||
_checkTimer?.cancel();
|
||||
_checkTimer = Timer.periodic(
|
||||
const Duration(seconds: _checkIntervalSeconds),
|
||||
(_) => checkMaintenanceStatus(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 停止定时检查
|
||||
void _stopPeriodicCheck() {
|
||||
_checkTimer?.cancel();
|
||||
_checkTimer = null;
|
||||
}
|
||||
|
||||
/// 显示维护弹窗(全局阻断)
|
||||
///
|
||||
/// 返回 true 表示正在维护并已显示弹窗
|
||||
/// 返回 false 表示系统正常,无需阻断
|
||||
Future<bool> showMaintenanceDialogIfNeeded(BuildContext context) async {
|
||||
final status = await checkMaintenanceStatus();
|
||||
|
||||
if (status.isUnderMaintenance && status.maintenance != null) {
|
||||
_showMaintenanceDialog(context, status.maintenance!);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 显示维护弹窗
|
||||
void _showMaintenanceDialog(BuildContext context, MaintenanceInfo info) {
|
||||
if (_isDialogShowing) return;
|
||||
|
||||
_isDialogShowing = true;
|
||||
_dialogContext = context;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false, // 不可点击外部关闭
|
||||
barrierColor: Colors.black87, // 深色遮罩
|
||||
builder: (dialogContext) => PopScope(
|
||||
canPop: false, // 禁用返回键
|
||||
child: MaintenanceDialog(
|
||||
info: info,
|
||||
onRefresh: () async {
|
||||
final status = await checkMaintenanceStatus();
|
||||
if (!status.isUnderMaintenance) {
|
||||
_dismissMaintenanceDialog();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
).then((_) {
|
||||
_isDialogShowing = false;
|
||||
_dialogContext = null;
|
||||
});
|
||||
}
|
||||
|
||||
/// 关闭维护弹窗
|
||||
void _dismissMaintenanceDialog() {
|
||||
if (_isDialogShowing && _dialogContext != null) {
|
||||
Navigator.of(_dialogContext!, rootNavigator: true).pop();
|
||||
_isDialogShowing = false;
|
||||
_dialogContext = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 系统维护 Provider
|
||||
final maintenanceProvider =
|
||||
StateNotifierProvider<MaintenanceNotifier, MaintenanceStatus>((ref) {
|
||||
return MaintenanceNotifier();
|
||||
});
|
||||
|
||||
/// 便捷 Provider: 只获取是否正在维护
|
||||
final isUnderMaintenanceProvider = Provider<bool>((ref) {
|
||||
return ref.watch(maintenanceProvider).isUnderMaintenance;
|
||||
});
|
||||
|
||||
/// 维护弹窗组件
|
||||
class MaintenanceDialog extends StatefulWidget {
|
||||
final MaintenanceInfo info;
|
||||
final VoidCallback? onRefresh;
|
||||
|
||||
const MaintenanceDialog({
|
||||
super.key,
|
||||
required this.info,
|
||||
this.onRefresh,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MaintenanceDialog> createState() => _MaintenanceDialogState();
|
||||
}
|
||||
|
||||
class _MaintenanceDialogState extends State<MaintenanceDialog> {
|
||||
bool _isRefreshing = false;
|
||||
late int _remainingMinutes;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_remainingMinutes = widget.info.remainingMinutes;
|
||||
// 每分钟更新剩余时间
|
||||
_startCountdown();
|
||||
}
|
||||
|
||||
void _startCountdown() {
|
||||
Future.delayed(const Duration(minutes: 1), () {
|
||||
if (mounted && _remainingMinutes > 0) {
|
||||
setState(() {
|
||||
_remainingMinutes--;
|
||||
});
|
||||
_startCountdown();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _handleRefresh() async {
|
||||
if (_isRefreshing) return;
|
||||
|
||||
setState(() {
|
||||
_isRefreshing = true;
|
||||
});
|
||||
|
||||
widget.onRefresh?.call();
|
||||
|
||||
// 延迟一下再恢复按钮状态
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isRefreshing = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
child: Container(
|
||||
width: 320,
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFFBF5),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.2),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 维护图标
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFD4AF37).withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'🔧',
|
||||
style: TextStyle(fontSize: 40),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 标题
|
||||
Text(
|
||||
widget.info.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 消息内容
|
||||
Text(
|
||||
widget.info.message,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w400,
|
||||
color: Color(0xFF8B7355),
|
||||
height: 1.5,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 剩余时间
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFD4AF37).withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.access_time,
|
||||
size: 18,
|
||||
color: Color(0xFFD4AF37),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_remainingMinutes > 0
|
||||
? '预计剩余 $_remainingMinutes 分钟'
|
||||
: '即将恢复...',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFFD4AF37),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 刷新按钮
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _isRefreshing ? null : _handleRefresh,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFD4AF37),
|
||||
foregroundColor: Colors.white,
|
||||
disabledBackgroundColor: const Color(0xFFD4AF37).withValues(alpha: 0.5),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
elevation: 0,
|
||||
),
|
||||
child: _isRefreshing
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor:
|
||||
AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'刷新状态',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 提示文字
|
||||
const Text(
|
||||
'感谢您的耐心等待',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontFamily: 'Inter',
|
||||
color: Color(0xFFBDBDBD),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
import 'dart:async';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../di/injection_container.dart';
|
||||
import '../services/notification_service.dart';
|
||||
import '../../features/auth/presentation/providers/auth_provider.dart';
|
||||
|
||||
/// 未读通知数量状态
|
||||
class NotificationBadgeState {
|
||||
final int unreadCount;
|
||||
final bool isLoading;
|
||||
|
||||
const NotificationBadgeState({
|
||||
this.unreadCount = 0,
|
||||
this.isLoading = false,
|
||||
});
|
||||
|
||||
NotificationBadgeState copyWith({
|
||||
int? unreadCount,
|
||||
bool? isLoading,
|
||||
}) {
|
||||
return NotificationBadgeState(
|
||||
unreadCount: unreadCount ?? this.unreadCount,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 未读通知数量管理器
|
||||
class NotificationBadgeNotifier extends StateNotifier<NotificationBadgeState>
|
||||
with WidgetsBindingObserver {
|
||||
final NotificationService _notificationService;
|
||||
final Ref _ref;
|
||||
Timer? _refreshTimer;
|
||||
|
||||
/// 定时刷新间隔(30秒)
|
||||
static const _refreshIntervalSeconds = 30;
|
||||
|
||||
NotificationBadgeNotifier(this._notificationService, this._ref)
|
||||
: super(const NotificationBadgeState()) {
|
||||
// 监听 App 生命周期
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
// 初始化时立即加载未读数量
|
||||
_loadUnreadCount();
|
||||
// 启动定时刷新
|
||||
_startAutoRefresh();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_refreshTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 监听 App 生命周期变化
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
// App 从后台切回前台,立即刷新
|
||||
debugPrint('[NotificationBadge] App 恢复前台,刷新未读数量');
|
||||
_loadUnreadCount();
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动自动刷新
|
||||
void _startAutoRefresh() {
|
||||
_refreshTimer?.cancel();
|
||||
_refreshTimer = Timer.periodic(
|
||||
const Duration(seconds: _refreshIntervalSeconds),
|
||||
(_) => _loadUnreadCount(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 加载未读通知数量
|
||||
Future<void> _loadUnreadCount() async {
|
||||
try {
|
||||
final authState = _ref.read(authProvider);
|
||||
final userSerialNum = authState.userSerialNum;
|
||||
if (userSerialNum == null) {
|
||||
state = state.copyWith(unreadCount: 0);
|
||||
return;
|
||||
}
|
||||
|
||||
state = state.copyWith(isLoading: true);
|
||||
|
||||
final count = await _notificationService.getUnreadCount(
|
||||
userSerialNum: userSerialNum,
|
||||
);
|
||||
|
||||
state = state.copyWith(
|
||||
unreadCount: count,
|
||||
isLoading: false,
|
||||
);
|
||||
|
||||
debugPrint('[NotificationBadge] 未读通知数量: $count');
|
||||
} catch (e) {
|
||||
debugPrint('[NotificationBadge] 加载未读数量失败: $e');
|
||||
state = state.copyWith(isLoading: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 手动刷新未读数量
|
||||
Future<void> refresh() async {
|
||||
await _loadUnreadCount();
|
||||
}
|
||||
|
||||
/// 更新未读数量(用于本地同步,如标记已读后)
|
||||
void updateCount(int count) {
|
||||
state = state.copyWith(unreadCount: count);
|
||||
}
|
||||
|
||||
/// 减少未读数量(标记单条已读后调用)
|
||||
void decrementCount() {
|
||||
if (state.unreadCount > 0) {
|
||||
state = state.copyWith(unreadCount: state.unreadCount - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// 清空未读数量(全部标记已读后调用)
|
||||
void clearCount() {
|
||||
state = state.copyWith(unreadCount: 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// 未读通知数量 Provider
|
||||
final notificationBadgeProvider =
|
||||
StateNotifierProvider<NotificationBadgeNotifier, NotificationBadgeState>(
|
||||
(ref) {
|
||||
final notificationService = ref.watch(notificationServiceProvider);
|
||||
return NotificationBadgeNotifier(notificationService, ref);
|
||||
});
|
||||
|
||||
/// 便捷 Provider: 只获取未读数量
|
||||
final unreadNotificationCountProvider = Provider<int>((ref) {
|
||||
return ref.watch(notificationBadgeProvider).unreadCount;
|
||||
});
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../config/app_config.dart';
|
||||
|
||||
/// 系统维护状态
|
||||
class MaintenanceStatus {
|
||||
final bool isUnderMaintenance;
|
||||
final MaintenanceInfo? maintenance;
|
||||
|
||||
const MaintenanceStatus({
|
||||
required this.isUnderMaintenance,
|
||||
this.maintenance,
|
||||
});
|
||||
|
||||
factory MaintenanceStatus.fromJson(Map<String, dynamic> json) {
|
||||
return MaintenanceStatus(
|
||||
isUnderMaintenance: json['isUnderMaintenance'] ?? false,
|
||||
maintenance: json['maintenance'] != null
|
||||
? MaintenanceInfo.fromJson(json['maintenance'])
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// 无维护状态
|
||||
static const normal = MaintenanceStatus(isUnderMaintenance: false);
|
||||
}
|
||||
|
||||
/// 维护详情
|
||||
class MaintenanceInfo {
|
||||
final String title;
|
||||
final String message;
|
||||
final DateTime startTime;
|
||||
final DateTime endTime;
|
||||
final int remainingMinutes;
|
||||
|
||||
const MaintenanceInfo({
|
||||
required this.title,
|
||||
required this.message,
|
||||
required this.startTime,
|
||||
required this.endTime,
|
||||
required this.remainingMinutes,
|
||||
});
|
||||
|
||||
factory MaintenanceInfo.fromJson(Map<String, dynamic> json) {
|
||||
return MaintenanceInfo(
|
||||
title: json['title'] ?? '系统维护中',
|
||||
message: json['message'] ?? '系统正在维护,请稍后再试',
|
||||
startTime: DateTime.parse(json['startTime']),
|
||||
endTime: DateTime.parse(json['endTime']),
|
||||
remainingMinutes: json['remainingMinutes'] ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 系统维护检查服务
|
||||
///
|
||||
/// 独立的服务,不依赖 ApiClient,因为:
|
||||
/// 1. 需要在用户登录前检查
|
||||
/// 2. 需要绕过 ApiClient 的 token 逻辑
|
||||
/// 3. 维护接口不需要认证
|
||||
class MaintenanceService {
|
||||
static final MaintenanceService _instance = MaintenanceService._internal();
|
||||
factory MaintenanceService() => _instance;
|
||||
MaintenanceService._internal();
|
||||
|
||||
late final Dio _dio;
|
||||
bool _initialized = false;
|
||||
|
||||
/// 初始化服务
|
||||
void initialize() {
|
||||
if (_initialized) return;
|
||||
|
||||
_dio = Dio(BaseOptions(
|
||||
baseUrl: AppConfig.instance.apiBaseUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 10),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
));
|
||||
|
||||
_initialized = true;
|
||||
debugPrint('[MaintenanceService] 初始化完成');
|
||||
}
|
||||
|
||||
/// 检查系统维护状态
|
||||
///
|
||||
/// 返回维护状态,如果发生网络错误返回正常状态(容错处理)
|
||||
Future<MaintenanceStatus> checkStatus() async {
|
||||
if (!_initialized) {
|
||||
initialize();
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await _dio.get('/mobile/system/maintenance-status');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = response.data;
|
||||
return MaintenanceStatus.fromJson(data);
|
||||
}
|
||||
|
||||
// 非 200 响应,返回正常状态
|
||||
return MaintenanceStatus.normal;
|
||||
} on DioException catch (e) {
|
||||
debugPrint('[MaintenanceService] 检查维护状态失败: ${e.message}');
|
||||
// 网络错误时返回正常状态,避免阻断用户
|
||||
// 只有明确收到维护状态时才阻断
|
||||
return MaintenanceStatus.normal;
|
||||
} catch (e) {
|
||||
debugPrint('[MaintenanceService] 检查维护状态异常: $e');
|
||||
return MaintenanceStatus.normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import 'package:flutter_svg/flutter_svg.dart';
|
|||
import 'package:go_router/go_router.dart';
|
||||
import '../../../../core/di/injection_container.dart';
|
||||
import '../../../../core/services/multi_account_service.dart';
|
||||
import '../../../../core/providers/notification_badge_provider.dart';
|
||||
import '../../../../routes/route_paths.dart';
|
||||
|
||||
/// 账号切换页面
|
||||
|
|
@ -67,6 +68,8 @@ class _AccountSwitchPageState extends ConsumerState<AccountSwitchPage> {
|
|||
final success = await multiAccountService.switchToAccount(account.userSerialNum);
|
||||
|
||||
if (success && mounted) {
|
||||
// 刷新新账号的未读通知数量
|
||||
ref.read(notificationBadgeProvider.notifier).refresh();
|
||||
// 切换成功,跳转到主页刷新状态
|
||||
context.go(RoutePaths.ranking);
|
||||
} else if (mounted) {
|
||||
|
|
@ -102,6 +105,9 @@ class _AccountSwitchPageState extends ConsumerState<AccountSwitchPage> {
|
|||
// 退出当前账号但保留数据
|
||||
await multiAccountService.logoutCurrentAccount();
|
||||
|
||||
// 清空未读通知数量
|
||||
ref.read(notificationBadgeProvider.notifier).clearCount();
|
||||
|
||||
// 跳转到向导页创建新账号
|
||||
if (mounted) {
|
||||
context.go(RoutePaths.guide);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import 'package:image_picker/image_picker.dart';
|
|||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
import '../../../../core/di/injection_container.dart';
|
||||
import '../../../../core/storage/storage_keys.dart';
|
||||
import '../../../../core/providers/maintenance_provider.dart';
|
||||
import '../../../../routes/route_paths.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
import 'phone_register_page.dart';
|
||||
|
|
@ -112,15 +113,6 @@ class _GuidePageState extends ConsumerState<GuidePage> {
|
|||
});
|
||||
}
|
||||
|
||||
void _goToNextPage() {
|
||||
if (_currentPage < 4) {
|
||||
_pageController.nextPage(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _goToOnboarding() async {
|
||||
// 标记已查看向导页
|
||||
await ref.read(authProvider.notifier).markGuideAsSeen();
|
||||
|
|
@ -333,8 +325,18 @@ class _WelcomePageContentState extends ConsumerState<_WelcomePageContent> {
|
|||
}
|
||||
|
||||
/// 导入助记词恢复账号
|
||||
void _importMnemonic() {
|
||||
Future<void> _importMnemonic() async {
|
||||
// 检查系统维护状态
|
||||
final isUnderMaintenance = await ref
|
||||
.read(maintenanceProvider.notifier)
|
||||
.showMaintenanceDialogIfNeeded(context);
|
||||
if (isUnderMaintenance) {
|
||||
debugPrint('[GuidePage] 系统维护中,阻止导入流程');
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('[GuidePage] _importMnemonic - 跳转到导入助记词页面');
|
||||
if (!mounted) return;
|
||||
context.push(RoutePaths.importMnemonic);
|
||||
}
|
||||
|
||||
|
|
@ -343,6 +345,15 @@ class _WelcomePageContentState extends ConsumerState<_WelcomePageContent> {
|
|||
Future<void> _saveReferralCodeAndProceed() async {
|
||||
if (!_canProceed) return;
|
||||
|
||||
// 检查系统维护状态(在用户注册前再次确认)
|
||||
final isUnderMaintenance = await ref
|
||||
.read(maintenanceProvider.notifier)
|
||||
.showMaintenanceDialogIfNeeded(context);
|
||||
if (isUnderMaintenance) {
|
||||
debugPrint('[GuidePage] 系统维护中,阻止注册流程');
|
||||
return;
|
||||
}
|
||||
|
||||
final inviterCode = _referralCodeController.text.trim();
|
||||
|
||||
// 调用API验证推荐码是否有效
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../../routes/route_paths.dart';
|
||||
import '../../../../routes/app_router.dart';
|
||||
import '../../../../bootstrap.dart';
|
||||
import '../../../../core/providers/maintenance_provider.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
|
||||
/// 开屏页面 - 应用启动时显示的第一个页面
|
||||
|
|
@ -122,6 +122,20 @@ class _SplashPageState extends ConsumerState<SplashPage> {
|
|||
// 初始化遥测服务(需要 BuildContext)
|
||||
await initializeTelemetry(context);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// 检查系统维护状态(优先级最高)
|
||||
final isUnderMaintenance = await ref
|
||||
.read(maintenanceProvider.notifier)
|
||||
.showMaintenanceDialogIfNeeded(context);
|
||||
|
||||
if (isUnderMaintenance) {
|
||||
// 系统正在维护,弹窗已显示,不进行跳转
|
||||
debugPrint('[SplashPage] 系统维护中 → 显示维护弹窗');
|
||||
_isNavigating = false; // 允许维护恢复后重新跳转
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查认证状态
|
||||
await ref.read(authProvider.notifier).checkAuthStatus();
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import '../../../../core/storage/storage_keys.dart';
|
|||
import '../../../../core/di/injection_container.dart';
|
||||
import '../../../../core/telemetry/telemetry_service.dart';
|
||||
import '../../../../core/sentry/sentry_service.dart';
|
||||
import '../../../../core/providers/notification_badge_provider.dart';
|
||||
|
||||
enum AuthStatus {
|
||||
initial,
|
||||
|
|
@ -70,8 +71,9 @@ class AuthState {
|
|||
|
||||
class AuthNotifier extends StateNotifier<AuthState> {
|
||||
final SecureStorage _secureStorage;
|
||||
final Ref _ref;
|
||||
|
||||
AuthNotifier(this._secureStorage) : super(const AuthState());
|
||||
AuthNotifier(this._secureStorage, this._ref) : super(const AuthState());
|
||||
|
||||
Future<void> checkAuthStatus() async {
|
||||
state = state.copyWith(status: AuthStatus.checking);
|
||||
|
|
@ -191,6 +193,8 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
|||
Future<void> logout() async {
|
||||
await _secureStorage.deleteAll();
|
||||
state = const AuthState(status: AuthStatus.unauthenticated);
|
||||
// 清空未读通知数量
|
||||
_ref.read(notificationBadgeProvider.notifier).clearCount();
|
||||
}
|
||||
|
||||
/// 重新加载认证状态(checkAuthStatus 的别名)
|
||||
|
|
@ -202,5 +206,5 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
|||
|
||||
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
|
||||
final secureStorage = ref.watch(secureStorageProvider);
|
||||
return AuthNotifier(secureStorage);
|
||||
return AuthNotifier(secureStorage, ref);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import '../../../../core/updater/update_service.dart';
|
|||
import '../../../../routes/route_paths.dart';
|
||||
import '../../../../routes/app_router.dart';
|
||||
import '../../../../bootstrap.dart';
|
||||
import '../../../../core/providers/notification_badge_provider.dart';
|
||||
import '../widgets/bottom_nav_bar.dart';
|
||||
|
||||
class HomeShellPage extends ConsumerStatefulWidget {
|
||||
|
|
@ -375,11 +376,14 @@ class _HomeShellPageState extends ConsumerState<HomeShellPage>
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final unreadCount = ref.watch(unreadNotificationCountProvider);
|
||||
|
||||
return Scaffold(
|
||||
body: widget.child,
|
||||
bottomNavigationBar: BottomNavBar(
|
||||
currentIndex: _getCurrentIndex(context),
|
||||
onTap: (index) => _onTabTapped(context, index),
|
||||
profileBadgeCount: unreadCount,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,11 +6,13 @@ import 'package:flutter/material.dart';
|
|||
class BottomNavBar extends StatelessWidget {
|
||||
final int currentIndex;
|
||||
final Function(int) onTap;
|
||||
final int profileBadgeCount;
|
||||
|
||||
const BottomNavBar({
|
||||
super.key,
|
||||
required this.currentIndex,
|
||||
required this.onTap,
|
||||
this.profileBadgeCount = 0,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -58,6 +60,7 @@ class BottomNavBar extends StatelessWidget {
|
|||
icon: Icons.person_outline,
|
||||
activeIcon: Icons.person,
|
||||
label: '我',
|
||||
badgeCount: profileBadgeCount,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -71,6 +74,7 @@ class BottomNavBar extends StatelessWidget {
|
|||
required IconData icon,
|
||||
required IconData activeIcon,
|
||||
required String label,
|
||||
int badgeCount = 0,
|
||||
}) {
|
||||
final isSelected = currentIndex == index;
|
||||
return Expanded(
|
||||
|
|
@ -82,10 +86,46 @@ class BottomNavBar extends StatelessWidget {
|
|||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
isSelected ? activeIcon : icon,
|
||||
size: 24,
|
||||
color: isSelected ? const Color(0xFFD4AF37) : const Color(0xFF8B5A2B),
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Icon(
|
||||
isSelected ? activeIcon : icon,
|
||||
size: 24,
|
||||
color: isSelected ? const Color(0xFFD4AF37) : const Color(0xFF8B5A2B),
|
||||
),
|
||||
if (badgeCount > 0)
|
||||
Positioned(
|
||||
right: -8,
|
||||
top: -4,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 16,
|
||||
minHeight: 16,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFD4AF37),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: const Color(0xFFFFF5E6),
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
badgeCount > 99 ? '99+' : badgeCount.toString(),
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||
import 'package:go_router/go_router.dart';
|
||||
import '../../../../core/di/injection_container.dart';
|
||||
import '../../../../core/services/notification_service.dart';
|
||||
import '../../../../core/providers/notification_badge_provider.dart';
|
||||
import '../../../../features/auth/presentation/providers/auth_provider.dart';
|
||||
|
||||
/// 通知箱页面
|
||||
|
|
@ -107,6 +108,8 @@ class _NotificationInboxPageState extends ConsumerState<NotificationInboxPage> {
|
|||
_unreadCount = (_unreadCount - 1).clamp(0, _unreadCount);
|
||||
}
|
||||
});
|
||||
// 同步更新全局未读数量
|
||||
ref.read(notificationBadgeProvider.notifier).decrementCount();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -145,6 +148,9 @@ class _NotificationInboxPageState extends ConsumerState<NotificationInboxPage> {
|
|||
_unreadCount = 0;
|
||||
});
|
||||
|
||||
// 同步更新全局未读数量
|
||||
ref.read(notificationBadgeProvider.notifier).clearCount();
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import '../../../../core/storage/storage_keys.dart';
|
|||
import '../../../../core/services/referral_service.dart';
|
||||
import '../../../../core/services/reward_service.dart';
|
||||
import '../../../../core/services/notification_service.dart';
|
||||
import '../../../../core/providers/notification_badge_provider.dart';
|
||||
import '../../../../core/utils/date_utils.dart';
|
||||
import '../../../../routes/route_paths.dart';
|
||||
import '../../../../routes/app_router.dart';
|
||||
|
|
@ -149,8 +150,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||
String _osVersion = '--';
|
||||
String _platform = '--';
|
||||
|
||||
// 通知未读数量
|
||||
int _unreadNotificationCount = 0;
|
||||
|
||||
// ========== 懒加载 + 重试机制相关状态 ==========
|
||||
// 各区域加载状态(懒加载区域初始为 false,等待可见时触发)
|
||||
|
|
@ -678,26 +677,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||
}
|
||||
}
|
||||
|
||||
/// 加载通知未读数量
|
||||
/// 加载通知未读数量(现在通过全局 Provider 管理)
|
||||
Future<void> _loadUnreadNotificationCount() async {
|
||||
try {
|
||||
final authState = ref.read(authProvider);
|
||||
final userSerialNum = authState.userSerialNum;
|
||||
if (userSerialNum == null) return;
|
||||
|
||||
final notificationService = ref.read(notificationServiceProvider);
|
||||
final count = await notificationService.getUnreadCount(
|
||||
userSerialNum: userSerialNum,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_unreadNotificationCount = count;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[ProfilePage] 加载通知未读数量失败: $e');
|
||||
}
|
||||
// 触发全局 Provider 刷新
|
||||
await ref.read(notificationBadgeProvider.notifier).refresh();
|
||||
}
|
||||
|
||||
/// 跳转到通知中心
|
||||
|
|
@ -1378,6 +1361,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||
|
||||
/// 构建页面标题行(含通知图标)
|
||||
Widget _buildPageHeader() {
|
||||
// 使用 watch 确保 UI 响应式更新
|
||||
final unreadCount = ref.watch(unreadNotificationCountProvider);
|
||||
|
||||
return Container(
|
||||
height: 48,
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
|
|
@ -1414,7 +1400,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||
color: Color(0xFF5D4037),
|
||||
),
|
||||
// 未读角标
|
||||
if (_unreadNotificationCount > 0)
|
||||
if (unreadCount > 0)
|
||||
Positioned(
|
||||
top: -4,
|
||||
right: -4,
|
||||
|
|
@ -1432,9 +1418,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||
minHeight: 18,
|
||||
),
|
||||
child: Text(
|
||||
_unreadNotificationCount > 99
|
||||
unreadCount > 99
|
||||
? '99+'
|
||||
: _unreadNotificationCount.toString(),
|
||||
: unreadCount.toString(),
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
|
|
|
|||
Loading…
Reference in New Issue