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")
|
@@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 (共管钱包系统)
|
// 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 { Module } from '@nestjs/common';
|
||||||
|
import { APP_INTERCEPTOR } from '@nestjs/core';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
|
|
@ -60,6 +61,11 @@ import {
|
||||||
CO_MANAGED_WALLET_SESSION_REPOSITORY,
|
CO_MANAGED_WALLET_SESSION_REPOSITORY,
|
||||||
CO_MANAGED_WALLET_REPOSITORY,
|
CO_MANAGED_WALLET_REPOSITORY,
|
||||||
} from './domain/repositories/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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|
@ -91,6 +97,9 @@ import {
|
||||||
AudienceSegmentController,
|
AudienceSegmentController,
|
||||||
// Co-Managed Wallet Controller
|
// Co-Managed Wallet Controller
|
||||||
CoManagedWalletController,
|
CoManagedWalletController,
|
||||||
|
// System Maintenance Controllers
|
||||||
|
AdminMaintenanceController,
|
||||||
|
MobileMaintenanceController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
PrismaService,
|
PrismaService,
|
||||||
|
|
@ -157,6 +166,16 @@ import {
|
||||||
provide: CO_MANAGED_WALLET_REPOSITORY,
|
provide: CO_MANAGED_WALLET_REPOSITORY,
|
||||||
useClass: CoManagedWalletRepositoryImpl,
|
useClass: CoManagedWalletRepositoryImpl,
|
||||||
},
|
},
|
||||||
|
// System Maintenance
|
||||||
|
{
|
||||||
|
provide: SYSTEM_MAINTENANCE_REPOSITORY,
|
||||||
|
useClass: SystemMaintenanceRepositoryImpl,
|
||||||
|
},
|
||||||
|
// Global Interceptors
|
||||||
|
{
|
||||||
|
provide: APP_INTERCEPTOR,
|
||||||
|
useClass: MaintenanceInterceptor,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
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: 'authorization', icon: '/images/Container4.svg', label: '授权管理', path: '/authorization' },
|
||||||
{ key: 'notifications', icon: '/images/Container3.svg', label: '通知管理', path: '/notifications' },
|
{ key: 'notifications', icon: '/images/Container3.svg', label: '通知管理', path: '/notifications' },
|
||||||
{ key: 'statistics', icon: '/images/Container5.svg', label: '数据统计', path: '/statistics' },
|
{ 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' },
|
{ 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}`,
|
SESSION_DETAIL: (sessionId: string) => `/v1/admin/co-managed-wallets/sessions/${sessionId}`,
|
||||||
WALLET_DETAIL: (walletId: string) => `/v1/admin/co-managed-wallets/${walletId}`,
|
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;
|
} 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/di/injection_container.dart';
|
||||||
import 'core/theme/app_theme.dart';
|
import 'core/theme/app_theme.dart';
|
||||||
import 'core/services/auth_event_service.dart';
|
import 'core/services/auth_event_service.dart';
|
||||||
|
import 'core/providers/maintenance_provider.dart';
|
||||||
import 'routes/app_router.dart';
|
import 'routes/app_router.dart';
|
||||||
import 'routes/route_paths.dart';
|
import 'routes/route_paths.dart';
|
||||||
|
|
||||||
|
|
@ -24,6 +25,13 @@ class _AppState extends ConsumerState<App> {
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_listenToAuthEvents();
|
_listenToAuthEvents();
|
||||||
|
_initializeMaintenanceProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 初始化维护状态提供者
|
||||||
|
void _initializeMaintenanceProvider() {
|
||||||
|
// 设置全局导航 Key,以便在 App 生命周期事件中显示维护弹窗
|
||||||
|
ref.read(maintenanceProvider.notifier).setNavigatorKey(rootNavigatorKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@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 'package:go_router/go_router.dart';
|
||||||
import '../../../../core/di/injection_container.dart';
|
import '../../../../core/di/injection_container.dart';
|
||||||
import '../../../../core/services/multi_account_service.dart';
|
import '../../../../core/services/multi_account_service.dart';
|
||||||
|
import '../../../../core/providers/notification_badge_provider.dart';
|
||||||
import '../../../../routes/route_paths.dart';
|
import '../../../../routes/route_paths.dart';
|
||||||
|
|
||||||
/// 账号切换页面
|
/// 账号切换页面
|
||||||
|
|
@ -67,6 +68,8 @@ class _AccountSwitchPageState extends ConsumerState<AccountSwitchPage> {
|
||||||
final success = await multiAccountService.switchToAccount(account.userSerialNum);
|
final success = await multiAccountService.switchToAccount(account.userSerialNum);
|
||||||
|
|
||||||
if (success && mounted) {
|
if (success && mounted) {
|
||||||
|
// 刷新新账号的未读通知数量
|
||||||
|
ref.read(notificationBadgeProvider.notifier).refresh();
|
||||||
// 切换成功,跳转到主页刷新状态
|
// 切换成功,跳转到主页刷新状态
|
||||||
context.go(RoutePaths.ranking);
|
context.go(RoutePaths.ranking);
|
||||||
} else if (mounted) {
|
} else if (mounted) {
|
||||||
|
|
@ -102,6 +105,9 @@ class _AccountSwitchPageState extends ConsumerState<AccountSwitchPage> {
|
||||||
// 退出当前账号但保留数据
|
// 退出当前账号但保留数据
|
||||||
await multiAccountService.logoutCurrentAccount();
|
await multiAccountService.logoutCurrentAccount();
|
||||||
|
|
||||||
|
// 清空未读通知数量
|
||||||
|
ref.read(notificationBadgeProvider.notifier).clearCount();
|
||||||
|
|
||||||
// 跳转到向导页创建新账号
|
// 跳转到向导页创建新账号
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
context.go(RoutePaths.guide);
|
context.go(RoutePaths.guide);
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||||
import '../../../../core/di/injection_container.dart';
|
import '../../../../core/di/injection_container.dart';
|
||||||
import '../../../../core/storage/storage_keys.dart';
|
import '../../../../core/storage/storage_keys.dart';
|
||||||
|
import '../../../../core/providers/maintenance_provider.dart';
|
||||||
import '../../../../routes/route_paths.dart';
|
import '../../../../routes/route_paths.dart';
|
||||||
import '../providers/auth_provider.dart';
|
import '../providers/auth_provider.dart';
|
||||||
import 'phone_register_page.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 {
|
void _goToOnboarding() async {
|
||||||
// 标记已查看向导页
|
// 标记已查看向导页
|
||||||
await ref.read(authProvider.notifier).markGuideAsSeen();
|
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 - 跳转到导入助记词页面');
|
debugPrint('[GuidePage] _importMnemonic - 跳转到导入助记词页面');
|
||||||
|
if (!mounted) return;
|
||||||
context.push(RoutePaths.importMnemonic);
|
context.push(RoutePaths.importMnemonic);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -343,6 +345,15 @@ class _WelcomePageContentState extends ConsumerState<_WelcomePageContent> {
|
||||||
Future<void> _saveReferralCodeAndProceed() async {
|
Future<void> _saveReferralCodeAndProceed() async {
|
||||||
if (!_canProceed) return;
|
if (!_canProceed) return;
|
||||||
|
|
||||||
|
// 检查系统维护状态(在用户注册前再次确认)
|
||||||
|
final isUnderMaintenance = await ref
|
||||||
|
.read(maintenanceProvider.notifier)
|
||||||
|
.showMaintenanceDialogIfNeeded(context);
|
||||||
|
if (isUnderMaintenance) {
|
||||||
|
debugPrint('[GuidePage] 系统维护中,阻止注册流程');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final inviterCode = _referralCodeController.text.trim();
|
final inviterCode = _referralCodeController.text.trim();
|
||||||
|
|
||||||
// 调用API验证推荐码是否有效
|
// 调用API验证推荐码是否有效
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../../../routes/route_paths.dart';
|
import '../../../../routes/route_paths.dart';
|
||||||
import '../../../../routes/app_router.dart';
|
|
||||||
import '../../../../bootstrap.dart';
|
import '../../../../bootstrap.dart';
|
||||||
|
import '../../../../core/providers/maintenance_provider.dart';
|
||||||
import '../providers/auth_provider.dart';
|
import '../providers/auth_provider.dart';
|
||||||
|
|
||||||
/// 开屏页面 - 应用启动时显示的第一个页面
|
/// 开屏页面 - 应用启动时显示的第一个页面
|
||||||
|
|
@ -122,6 +122,20 @@ class _SplashPageState extends ConsumerState<SplashPage> {
|
||||||
// 初始化遥测服务(需要 BuildContext)
|
// 初始化遥测服务(需要 BuildContext)
|
||||||
await initializeTelemetry(context);
|
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();
|
await ref.read(authProvider.notifier).checkAuthStatus();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import '../../../../core/storage/storage_keys.dart';
|
||||||
import '../../../../core/di/injection_container.dart';
|
import '../../../../core/di/injection_container.dart';
|
||||||
import '../../../../core/telemetry/telemetry_service.dart';
|
import '../../../../core/telemetry/telemetry_service.dart';
|
||||||
import '../../../../core/sentry/sentry_service.dart';
|
import '../../../../core/sentry/sentry_service.dart';
|
||||||
|
import '../../../../core/providers/notification_badge_provider.dart';
|
||||||
|
|
||||||
enum AuthStatus {
|
enum AuthStatus {
|
||||||
initial,
|
initial,
|
||||||
|
|
@ -70,8 +71,9 @@ class AuthState {
|
||||||
|
|
||||||
class AuthNotifier extends StateNotifier<AuthState> {
|
class AuthNotifier extends StateNotifier<AuthState> {
|
||||||
final SecureStorage _secureStorage;
|
final SecureStorage _secureStorage;
|
||||||
|
final Ref _ref;
|
||||||
|
|
||||||
AuthNotifier(this._secureStorage) : super(const AuthState());
|
AuthNotifier(this._secureStorage, this._ref) : super(const AuthState());
|
||||||
|
|
||||||
Future<void> checkAuthStatus() async {
|
Future<void> checkAuthStatus() async {
|
||||||
state = state.copyWith(status: AuthStatus.checking);
|
state = state.copyWith(status: AuthStatus.checking);
|
||||||
|
|
@ -191,6 +193,8 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||||
Future<void> logout() async {
|
Future<void> logout() async {
|
||||||
await _secureStorage.deleteAll();
|
await _secureStorage.deleteAll();
|
||||||
state = const AuthState(status: AuthStatus.unauthenticated);
|
state = const AuthState(status: AuthStatus.unauthenticated);
|
||||||
|
// 清空未读通知数量
|
||||||
|
_ref.read(notificationBadgeProvider.notifier).clearCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 重新加载认证状态(checkAuthStatus 的别名)
|
/// 重新加载认证状态(checkAuthStatus 的别名)
|
||||||
|
|
@ -202,5 +206,5 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||||
|
|
||||||
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
|
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
|
||||||
final secureStorage = ref.watch(secureStorageProvider);
|
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/route_paths.dart';
|
||||||
import '../../../../routes/app_router.dart';
|
import '../../../../routes/app_router.dart';
|
||||||
import '../../../../bootstrap.dart';
|
import '../../../../bootstrap.dart';
|
||||||
|
import '../../../../core/providers/notification_badge_provider.dart';
|
||||||
import '../widgets/bottom_nav_bar.dart';
|
import '../widgets/bottom_nav_bar.dart';
|
||||||
|
|
||||||
class HomeShellPage extends ConsumerStatefulWidget {
|
class HomeShellPage extends ConsumerStatefulWidget {
|
||||||
|
|
@ -375,11 +376,14 @@ class _HomeShellPageState extends ConsumerState<HomeShellPage>
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final unreadCount = ref.watch(unreadNotificationCountProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: widget.child,
|
body: widget.child,
|
||||||
bottomNavigationBar: BottomNavBar(
|
bottomNavigationBar: BottomNavBar(
|
||||||
currentIndex: _getCurrentIndex(context),
|
currentIndex: _getCurrentIndex(context),
|
||||||
onTap: (index) => _onTabTapped(context, index),
|
onTap: (index) => _onTabTapped(context, index),
|
||||||
|
profileBadgeCount: unreadCount,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,13 @@ import 'package:flutter/material.dart';
|
||||||
class BottomNavBar extends StatelessWidget {
|
class BottomNavBar extends StatelessWidget {
|
||||||
final int currentIndex;
|
final int currentIndex;
|
||||||
final Function(int) onTap;
|
final Function(int) onTap;
|
||||||
|
final int profileBadgeCount;
|
||||||
|
|
||||||
const BottomNavBar({
|
const BottomNavBar({
|
||||||
super.key,
|
super.key,
|
||||||
required this.currentIndex,
|
required this.currentIndex,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
|
this.profileBadgeCount = 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -58,6 +60,7 @@ class BottomNavBar extends StatelessWidget {
|
||||||
icon: Icons.person_outline,
|
icon: Icons.person_outline,
|
||||||
activeIcon: Icons.person,
|
activeIcon: Icons.person,
|
||||||
label: '我',
|
label: '我',
|
||||||
|
badgeCount: profileBadgeCount,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -71,6 +74,7 @@ class BottomNavBar extends StatelessWidget {
|
||||||
required IconData icon,
|
required IconData icon,
|
||||||
required IconData activeIcon,
|
required IconData activeIcon,
|
||||||
required String label,
|
required String label,
|
||||||
|
int badgeCount = 0,
|
||||||
}) {
|
}) {
|
||||||
final isSelected = currentIndex == index;
|
final isSelected = currentIndex == index;
|
||||||
return Expanded(
|
return Expanded(
|
||||||
|
|
@ -82,10 +86,46 @@ class BottomNavBar extends StatelessWidget {
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Stack(
|
||||||
isSelected ? activeIcon : icon,
|
clipBehavior: Clip.none,
|
||||||
size: 24,
|
children: [
|
||||||
color: isSelected ? const Color(0xFFD4AF37) : const Color(0xFF8B5A2B),
|
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),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../../../core/di/injection_container.dart';
|
import '../../../../core/di/injection_container.dart';
|
||||||
import '../../../../core/services/notification_service.dart';
|
import '../../../../core/services/notification_service.dart';
|
||||||
|
import '../../../../core/providers/notification_badge_provider.dart';
|
||||||
import '../../../../features/auth/presentation/providers/auth_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);
|
_unreadCount = (_unreadCount - 1).clamp(0, _unreadCount);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// 同步更新全局未读数量
|
||||||
|
ref.read(notificationBadgeProvider.notifier).decrementCount();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -145,6 +148,9 @@ class _NotificationInboxPageState extends ConsumerState<NotificationInboxPage> {
|
||||||
_unreadCount = 0;
|
_unreadCount = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 同步更新全局未读数量
|
||||||
|
ref.read(notificationBadgeProvider.notifier).clearCount();
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import '../../../../core/storage/storage_keys.dart';
|
||||||
import '../../../../core/services/referral_service.dart';
|
import '../../../../core/services/referral_service.dart';
|
||||||
import '../../../../core/services/reward_service.dart';
|
import '../../../../core/services/reward_service.dart';
|
||||||
import '../../../../core/services/notification_service.dart';
|
import '../../../../core/services/notification_service.dart';
|
||||||
|
import '../../../../core/providers/notification_badge_provider.dart';
|
||||||
import '../../../../core/utils/date_utils.dart';
|
import '../../../../core/utils/date_utils.dart';
|
||||||
import '../../../../routes/route_paths.dart';
|
import '../../../../routes/route_paths.dart';
|
||||||
import '../../../../routes/app_router.dart';
|
import '../../../../routes/app_router.dart';
|
||||||
|
|
@ -149,8 +150,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||||
String _osVersion = '--';
|
String _osVersion = '--';
|
||||||
String _platform = '--';
|
String _platform = '--';
|
||||||
|
|
||||||
// 通知未读数量
|
|
||||||
int _unreadNotificationCount = 0;
|
|
||||||
|
|
||||||
// ========== 懒加载 + 重试机制相关状态 ==========
|
// ========== 懒加载 + 重试机制相关状态 ==========
|
||||||
// 各区域加载状态(懒加载区域初始为 false,等待可见时触发)
|
// 各区域加载状态(懒加载区域初始为 false,等待可见时触发)
|
||||||
|
|
@ -678,26 +677,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 加载通知未读数量
|
/// 加载通知未读数量(现在通过全局 Provider 管理)
|
||||||
Future<void> _loadUnreadNotificationCount() async {
|
Future<void> _loadUnreadNotificationCount() async {
|
||||||
try {
|
// 触发全局 Provider 刷新
|
||||||
final authState = ref.read(authProvider);
|
await ref.read(notificationBadgeProvider.notifier).refresh();
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 跳转到通知中心
|
/// 跳转到通知中心
|
||||||
|
|
@ -1378,6 +1361,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||||
|
|
||||||
/// 构建页面标题行(含通知图标)
|
/// 构建页面标题行(含通知图标)
|
||||||
Widget _buildPageHeader() {
|
Widget _buildPageHeader() {
|
||||||
|
// 使用 watch 确保 UI 响应式更新
|
||||||
|
final unreadCount = ref.watch(unreadNotificationCountProvider);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
height: 48,
|
height: 48,
|
||||||
padding: const EdgeInsets.only(top: 8),
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
|
@ -1414,7 +1400,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||||
color: Color(0xFF5D4037),
|
color: Color(0xFF5D4037),
|
||||||
),
|
),
|
||||||
// 未读角标
|
// 未读角标
|
||||||
if (_unreadNotificationCount > 0)
|
if (unreadCount > 0)
|
||||||
Positioned(
|
Positioned(
|
||||||
top: -4,
|
top: -4,
|
||||||
right: -4,
|
right: -4,
|
||||||
|
|
@ -1432,9 +1418,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||||
minHeight: 18,
|
minHeight: 18,
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
_unreadNotificationCount > 99
|
unreadCount > 99
|
||||||
? '99+'
|
? '99+'
|
||||||
: _unreadNotificationCount.toString(),
|
: unreadCount.toString(),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue