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:
hailin 2025-12-27 23:26:01 -08:00
parent fea01642e7
commit c328d8b59b
26 changed files with 2504 additions and 41 deletions

View File

@ -516,6 +516,28 @@ model SystemConfig {
@@map("system_configs")
}
// =============================================================================
// System Maintenance (系统维护公告)
// =============================================================================
/// 系统维护公告 - 用于系统升级/维护期间阻断用户操作
model SystemMaintenance {
id String @id @default(uuid())
title String @db.VarChar(100) // 标题:如"系统升级中"
message String @db.Text // 说明:如"预计10:00恢复请稍候"
startTime DateTime @map("start_time") // 维护开始时间
endTime DateTime @map("end_time") // 维护结束时间
isActive Boolean @default(false) @map("is_active") // 是否激活
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
createdBy String @map("created_by") // 创建人ID
updatedBy String? @map("updated_by") // 更新人ID
@@index([isActive])
@@index([startTime, endTime])
@@map("system_maintenances")
}
// =============================================================================
// Co-Managed Wallet System (共管钱包系统)
// =============================================================================

View File

@ -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,
};
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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();
}
}

View File

@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { ConfigModule } from '@nestjs/config';
import { ServeStaticModule } from '@nestjs/serve-static';
import { ScheduleModule } from '@nestjs/schedule';
@ -60,6 +61,11 @@ import {
CO_MANAGED_WALLET_SESSION_REPOSITORY,
CO_MANAGED_WALLET_REPOSITORY,
} from './domain/repositories/co-managed-wallet.repository';
// System Maintenance imports
import { SYSTEM_MAINTENANCE_REPOSITORY } from './domain/repositories/system-maintenance.repository';
import { SystemMaintenanceRepositoryImpl } from './infrastructure/persistence/repositories/system-maintenance.repository.impl';
import { AdminMaintenanceController, MobileMaintenanceController } from './api/controllers/system-maintenance.controller';
import { MaintenanceInterceptor } from './api/interceptors/maintenance.interceptor';
@Module({
imports: [
@ -91,6 +97,9 @@ import {
AudienceSegmentController,
// Co-Managed Wallet Controller
CoManagedWalletController,
// System Maintenance Controllers
AdminMaintenanceController,
MobileMaintenanceController,
],
providers: [
PrismaService,
@ -157,6 +166,16 @@ import {
provide: CO_MANAGED_WALLET_REPOSITORY,
useClass: CoManagedWalletRepositoryImpl,
},
// System Maintenance
{
provide: SYSTEM_MAINTENANCE_REPOSITORY,
useClass: SystemMaintenanceRepositoryImpl,
},
// Global Interceptors
{
provide: APP_INTERCEPTOR,
useClass: MaintenanceInterceptor,
},
],
})
export class AppModule {}

View File

@ -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;
}

View File

@ -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');

View File

@ -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 },
});
}
}

View File

@ -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;
}
}

View File

@ -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>
);
}

View File

@ -29,6 +29,7 @@ const topMenuItems: MenuItem[] = [
{ key: 'authorization', icon: '/images/Container4.svg', label: '授权管理', path: '/authorization' },
{ key: 'notifications', icon: '/images/Container3.svg', label: '通知管理', path: '/notifications' },
{ key: 'statistics', icon: '/images/Container5.svg', label: '数据统计', path: '/statistics' },
{ key: 'maintenance', icon: '/images/Container6.svg', label: '系统维护', path: '/maintenance' },
{ key: 'settings', icon: '/images/Container6.svg', label: '系统设置', path: '/settings' },
];

View File

@ -153,4 +153,15 @@ export const API_ENDPOINTS = {
SESSION_DETAIL: (sessionId: string) => `/v1/admin/co-managed-wallets/sessions/${sessionId}`,
WALLET_DETAIL: (walletId: string) => `/v1/admin/co-managed-wallets/${walletId}`,
},
// 系统维护 (admin-service)
MAINTENANCE: {
LIST: '/v1/admin/maintenance',
CREATE: '/v1/admin/maintenance',
DETAIL: (id: string) => `/v1/admin/maintenance/${id}`,
UPDATE: (id: string) => `/v1/admin/maintenance/${id}`,
TOGGLE: (id: string) => `/v1/admin/maintenance/${id}/toggle`,
DELETE: (id: string) => `/v1/admin/maintenance/${id}`,
CURRENT_STATUS: '/v1/admin/maintenance/status/current',
},
} as const;

View File

@ -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;

View File

@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart';
import 'core/di/injection_container.dart';
import 'core/theme/app_theme.dart';
import 'core/services/auth_event_service.dart';
import 'core/providers/maintenance_provider.dart';
import 'routes/app_router.dart';
import 'routes/route_paths.dart';
@ -24,6 +25,13 @@ class _AppState extends ConsumerState<App> {
void initState() {
super.initState();
_listenToAuthEvents();
_initializeMaintenanceProvider();
}
///
void _initializeMaintenanceProvider() {
// Key便 App
ref.read(maintenanceProvider.notifier).setNavigatorKey(rootNavigatorKey);
}
@override

View File

@ -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),
),
),
],
),
),
);
}
}

View File

@ -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;
});

View File

@ -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;
}
}
}

View File

@ -4,6 +4,7 @@ import 'package:flutter_svg/flutter_svg.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/di/injection_container.dart';
import '../../../../core/services/multi_account_service.dart';
import '../../../../core/providers/notification_badge_provider.dart';
import '../../../../routes/route_paths.dart';
///
@ -67,6 +68,8 @@ class _AccountSwitchPageState extends ConsumerState<AccountSwitchPage> {
final success = await multiAccountService.switchToAccount(account.userSerialNum);
if (success && mounted) {
//
ref.read(notificationBadgeProvider.notifier).refresh();
//
context.go(RoutePaths.ranking);
} else if (mounted) {
@ -102,6 +105,9 @@ class _AccountSwitchPageState extends ConsumerState<AccountSwitchPage> {
// 退
await multiAccountService.logoutCurrentAccount();
//
ref.read(notificationBadgeProvider.notifier).clearCount();
//
if (mounted) {
context.go(RoutePaths.guide);

View File

@ -7,6 +7,7 @@ import 'package:image_picker/image_picker.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import '../../../../core/di/injection_container.dart';
import '../../../../core/storage/storage_keys.dart';
import '../../../../core/providers/maintenance_provider.dart';
import '../../../../routes/route_paths.dart';
import '../providers/auth_provider.dart';
import 'phone_register_page.dart';
@ -112,15 +113,6 @@ class _GuidePageState extends ConsumerState<GuidePage> {
});
}
void _goToNextPage() {
if (_currentPage < 4) {
_pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
}
void _goToOnboarding() async {
//
await ref.read(authProvider.notifier).markGuideAsSeen();
@ -333,8 +325,18 @@ class _WelcomePageContentState extends ConsumerState<_WelcomePageContent> {
}
///
void _importMnemonic() {
Future<void> _importMnemonic() async {
//
final isUnderMaintenance = await ref
.read(maintenanceProvider.notifier)
.showMaintenanceDialogIfNeeded(context);
if (isUnderMaintenance) {
debugPrint('[GuidePage] 系统维护中,阻止导入流程');
return;
}
debugPrint('[GuidePage] _importMnemonic - 跳转到导入助记词页面');
if (!mounted) return;
context.push(RoutePaths.importMnemonic);
}
@ -343,6 +345,15 @@ class _WelcomePageContentState extends ConsumerState<_WelcomePageContent> {
Future<void> _saveReferralCodeAndProceed() async {
if (!_canProceed) return;
//
final isUnderMaintenance = await ref
.read(maintenanceProvider.notifier)
.showMaintenanceDialogIfNeeded(context);
if (isUnderMaintenance) {
debugPrint('[GuidePage] 系统维护中,阻止注册流程');
return;
}
final inviterCode = _referralCodeController.text.trim();
// API验证推荐码是否有效

View File

@ -2,8 +2,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../routes/route_paths.dart';
import '../../../../routes/app_router.dart';
import '../../../../bootstrap.dart';
import '../../../../core/providers/maintenance_provider.dart';
import '../providers/auth_provider.dart';
/// -
@ -122,6 +122,20 @@ class _SplashPageState extends ConsumerState<SplashPage> {
// BuildContext
await initializeTelemetry(context);
if (!mounted) return;
//
final isUnderMaintenance = await ref
.read(maintenanceProvider.notifier)
.showMaintenanceDialogIfNeeded(context);
if (isUnderMaintenance) {
//
debugPrint('[SplashPage] 系统维护中 → 显示维护弹窗');
_isNavigating = false; //
return;
}
//
await ref.read(authProvider.notifier).checkAuthStatus();

View File

@ -4,6 +4,7 @@ import '../../../../core/storage/storage_keys.dart';
import '../../../../core/di/injection_container.dart';
import '../../../../core/telemetry/telemetry_service.dart';
import '../../../../core/sentry/sentry_service.dart';
import '../../../../core/providers/notification_badge_provider.dart';
enum AuthStatus {
initial,
@ -70,8 +71,9 @@ class AuthState {
class AuthNotifier extends StateNotifier<AuthState> {
final SecureStorage _secureStorage;
final Ref _ref;
AuthNotifier(this._secureStorage) : super(const AuthState());
AuthNotifier(this._secureStorage, this._ref) : super(const AuthState());
Future<void> checkAuthStatus() async {
state = state.copyWith(status: AuthStatus.checking);
@ -191,6 +193,8 @@ class AuthNotifier extends StateNotifier<AuthState> {
Future<void> logout() async {
await _secureStorage.deleteAll();
state = const AuthState(status: AuthStatus.unauthenticated);
//
_ref.read(notificationBadgeProvider.notifier).clearCount();
}
/// checkAuthStatus
@ -202,5 +206,5 @@ class AuthNotifier extends StateNotifier<AuthState> {
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
final secureStorage = ref.watch(secureStorageProvider);
return AuthNotifier(secureStorage);
return AuthNotifier(secureStorage, ref);
});

View File

@ -10,6 +10,7 @@ import '../../../../core/updater/update_service.dart';
import '../../../../routes/route_paths.dart';
import '../../../../routes/app_router.dart';
import '../../../../bootstrap.dart';
import '../../../../core/providers/notification_badge_provider.dart';
import '../widgets/bottom_nav_bar.dart';
class HomeShellPage extends ConsumerStatefulWidget {
@ -375,11 +376,14 @@ class _HomeShellPageState extends ConsumerState<HomeShellPage>
@override
Widget build(BuildContext context) {
final unreadCount = ref.watch(unreadNotificationCountProvider);
return Scaffold(
body: widget.child,
bottomNavigationBar: BottomNavBar(
currentIndex: _getCurrentIndex(context),
onTap: (index) => _onTabTapped(context, index),
profileBadgeCount: unreadCount,
),
);
}

View File

@ -6,11 +6,13 @@ import 'package:flutter/material.dart';
class BottomNavBar extends StatelessWidget {
final int currentIndex;
final Function(int) onTap;
final int profileBadgeCount;
const BottomNavBar({
super.key,
required this.currentIndex,
required this.onTap,
this.profileBadgeCount = 0,
});
@override
@ -58,6 +60,7 @@ class BottomNavBar extends StatelessWidget {
icon: Icons.person_outline,
activeIcon: Icons.person,
label: '',
badgeCount: profileBadgeCount,
),
],
),
@ -71,6 +74,7 @@ class BottomNavBar extends StatelessWidget {
required IconData icon,
required IconData activeIcon,
required String label,
int badgeCount = 0,
}) {
final isSelected = currentIndex == index;
return Expanded(
@ -82,10 +86,46 @@ class BottomNavBar extends StatelessWidget {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
isSelected ? activeIcon : icon,
size: 24,
color: isSelected ? const Color(0xFFD4AF37) : const Color(0xFF8B5A2B),
Stack(
clipBehavior: Clip.none,
children: [
Icon(
isSelected ? activeIcon : icon,
size: 24,
color: isSelected ? const Color(0xFFD4AF37) : const Color(0xFF8B5A2B),
),
if (badgeCount > 0)
Positioned(
right: -8,
top: -4,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
constraints: const BoxConstraints(
minWidth: 16,
minHeight: 16,
),
decoration: BoxDecoration(
color: const Color(0xFFD4AF37),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: const Color(0xFFFFF5E6),
width: 1.5,
),
),
child: Center(
child: Text(
badgeCount > 99 ? '99+' : badgeCount.toString(),
style: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.white,
height: 1,
),
),
),
),
),
],
),
const SizedBox(height: 2),
Text(

View File

@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/di/injection_container.dart';
import '../../../../core/services/notification_service.dart';
import '../../../../core/providers/notification_badge_provider.dart';
import '../../../../features/auth/presentation/providers/auth_provider.dart';
///
@ -107,6 +108,8 @@ class _NotificationInboxPageState extends ConsumerState<NotificationInboxPage> {
_unreadCount = (_unreadCount - 1).clamp(0, _unreadCount);
}
});
//
ref.read(notificationBadgeProvider.notifier).decrementCount();
}
}
@ -145,6 +148,9 @@ class _NotificationInboxPageState extends ConsumerState<NotificationInboxPage> {
_unreadCount = 0;
});
//
ref.read(notificationBadgeProvider.notifier).clearCount();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(

View File

@ -15,6 +15,7 @@ import '../../../../core/storage/storage_keys.dart';
import '../../../../core/services/referral_service.dart';
import '../../../../core/services/reward_service.dart';
import '../../../../core/services/notification_service.dart';
import '../../../../core/providers/notification_badge_provider.dart';
import '../../../../core/utils/date_utils.dart';
import '../../../../routes/route_paths.dart';
import '../../../../routes/app_router.dart';
@ -149,8 +150,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
String _osVersion = '--';
String _platform = '--';
//
int _unreadNotificationCount = 0;
// ========== + ==========
// false
@ -678,26 +677,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
}
}
///
/// Provider
Future<void> _loadUnreadNotificationCount() async {
try {
final authState = ref.read(authProvider);
final userSerialNum = authState.userSerialNum;
if (userSerialNum == null) return;
final notificationService = ref.read(notificationServiceProvider);
final count = await notificationService.getUnreadCount(
userSerialNum: userSerialNum,
);
if (mounted) {
setState(() {
_unreadNotificationCount = count;
});
}
} catch (e) {
debugPrint('[ProfilePage] 加载通知未读数量失败: $e');
}
// Provider
await ref.read(notificationBadgeProvider.notifier).refresh();
}
///
@ -1378,6 +1361,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
///
Widget _buildPageHeader() {
// 使 watch UI
final unreadCount = ref.watch(unreadNotificationCountProvider);
return Container(
height: 48,
padding: const EdgeInsets.only(top: 8),
@ -1414,7 +1400,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
color: Color(0xFF5D4037),
),
//
if (_unreadNotificationCount > 0)
if (unreadCount > 0)
Positioned(
top: -4,
right: -4,
@ -1432,9 +1418,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
minHeight: 18,
),
child: Text(
_unreadNotificationCount > 99
unreadCount > 99
? '99+'
: _unreadNotificationCount.toString(),
: unreadCount.toString(),
style: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,