feat(notifications): 2.0系统通知弹窗功能(后端+管理端+APP端)
复制1.0通知系统架构到2.0系统,实现完整的通知推送功能: 后端 (mining-admin-service): - Prisma Schema: 添加 Notification/NotificationRead/NotificationUserTarget 表 - NotificationService: 完整 CRUD + 移动端通知查询/已读标记 - AdminNotificationController: 管理端通知 CRUD API - MobileNotificationController: 移动端通知列表/未读数/标记已读 API 管理端 (mining-admin-web): - 通知管理页面: 列表/筛选/新建/编辑/删除 Dialog - 支持类型/优先级/目标用户/强制弹窗/发布时间等完整配置 - 侧边栏添加"通知管理"入口 APP端 (mining-app): - NotificationService: 通知API服务(经Kong网关路由) - NotificationBadgeProvider: 30秒轮询未读数量+生命周期监听 - ForceReadNotificationDialog: 强制阅读弹窗(橙色主题,逐条查看+确认) - NotificationInboxPage: 通知收件箱(支持dark/light主题) - MainShell: 添加强制弹窗检查(启动+前台恢复,60秒冷却) - ProfilePage: 用户头部添加通知图标+未读角标 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
59f7bdc137
commit
7c781c7d62
|
|
@ -926,3 +926,76 @@ model AppVersion {
|
||||||
@@index([platform, versionCode])
|
@@index([platform, versionCode])
|
||||||
@@map("app_versions")
|
@@map("app_versions")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 通知模块
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
enum NotificationType {
|
||||||
|
SYSTEM
|
||||||
|
ACTIVITY
|
||||||
|
REWARD
|
||||||
|
UPGRADE
|
||||||
|
ANNOUNCEMENT
|
||||||
|
}
|
||||||
|
|
||||||
|
enum NotificationPriority {
|
||||||
|
LOW
|
||||||
|
NORMAL
|
||||||
|
HIGH
|
||||||
|
URGENT
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TargetType {
|
||||||
|
ALL
|
||||||
|
SPECIFIC
|
||||||
|
}
|
||||||
|
|
||||||
|
model Notification {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
title String
|
||||||
|
content String @db.Text
|
||||||
|
type NotificationType @default(SYSTEM)
|
||||||
|
priority NotificationPriority @default(NORMAL)
|
||||||
|
targetType TargetType @default(ALL)
|
||||||
|
imageUrl String? @map("image_url")
|
||||||
|
linkUrl String? @map("link_url")
|
||||||
|
isEnabled Boolean @default(true) @map("is_enabled")
|
||||||
|
requiresForceRead Boolean @default(false) @map("requires_force_read")
|
||||||
|
publishedAt DateTime? @map("published_at")
|
||||||
|
expiresAt DateTime? @map("expires_at")
|
||||||
|
createdBy String @default("admin") @map("created_by")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
reads NotificationRead[]
|
||||||
|
userTargets NotificationUserTarget[]
|
||||||
|
|
||||||
|
@@index([isEnabled, publishedAt])
|
||||||
|
@@index([type])
|
||||||
|
@@map("notifications")
|
||||||
|
}
|
||||||
|
|
||||||
|
model NotificationRead {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
notificationId String @map("notification_id")
|
||||||
|
userSerialNum String @map("user_serial_num")
|
||||||
|
readAt DateTime @default(now()) @map("read_at")
|
||||||
|
|
||||||
|
notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([notificationId, userSerialNum])
|
||||||
|
@@index([userSerialNum])
|
||||||
|
@@map("notification_reads")
|
||||||
|
}
|
||||||
|
|
||||||
|
model NotificationUserTarget {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
notificationId String @map("notification_id")
|
||||||
|
accountSequence String @map("account_sequence")
|
||||||
|
|
||||||
|
notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([notificationId, accountSequence])
|
||||||
|
@@map("notification_user_targets")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { UpgradeVersionController } from './controllers/upgrade-version.controll
|
||||||
import { MobileVersionController } from './controllers/mobile-version.controller';
|
import { MobileVersionController } from './controllers/mobile-version.controller';
|
||||||
import { PoolAccountController } from './controllers/pool-account.controller';
|
import { PoolAccountController } from './controllers/pool-account.controller';
|
||||||
import { CapabilityController } from './controllers/capability.controller';
|
import { CapabilityController } from './controllers/capability.controller';
|
||||||
|
import { AdminNotificationController, MobileNotificationController } from './controllers/notification.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|
@ -44,6 +45,8 @@ import { CapabilityController } from './controllers/capability.controller';
|
||||||
MobileVersionController,
|
MobileVersionController,
|
||||||
PoolAccountController,
|
PoolAccountController,
|
||||||
CapabilityController,
|
CapabilityController,
|
||||||
|
AdminNotificationController,
|
||||||
|
MobileNotificationController,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class ApiModule {}
|
export class ApiModule {}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiQuery } from '@nestjs/swagger';
|
||||||
|
import { NotificationService } from '../../application/services/notification.service';
|
||||||
|
import type { CreateNotificationDto, UpdateNotificationDto } from '../../application/services/notification.service';
|
||||||
|
import { Public } from '../../shared/guards/admin-auth.guard';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理端通知控制器
|
||||||
|
*/
|
||||||
|
@ApiTags('Notifications')
|
||||||
|
@Controller('notifications')
|
||||||
|
export class AdminNotificationController {
|
||||||
|
constructor(private readonly notificationService: NotificationService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: '创建通知' })
|
||||||
|
async create(@Body() dto: CreateNotificationDto) {
|
||||||
|
return this.notificationService.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: '获取通知列表' })
|
||||||
|
@ApiQuery({ name: 'type', required: false })
|
||||||
|
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||||
|
@ApiQuery({ name: 'offset', required: false, type: Number })
|
||||||
|
async findAll(
|
||||||
|
@Query('type') type?: string,
|
||||||
|
@Query('limit') limit?: number,
|
||||||
|
@Query('offset') offset?: number,
|
||||||
|
) {
|
||||||
|
return this.notificationService.findAll({
|
||||||
|
type: type as any,
|
||||||
|
limit: limit ? Number(limit) : undefined,
|
||||||
|
offset: offset ? Number(offset) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: '获取通知详情' })
|
||||||
|
async findOne(@Param('id') id: string) {
|
||||||
|
const notification = await this.notificationService.findById(id);
|
||||||
|
if (!notification) {
|
||||||
|
throw new NotFoundException('通知不存在');
|
||||||
|
}
|
||||||
|
return notification;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
@ApiOperation({ summary: '更新通知' })
|
||||||
|
async update(@Param('id') id: string, @Body() dto: UpdateNotificationDto) {
|
||||||
|
const existing = await this.notificationService.findById(id);
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException('通知不存在');
|
||||||
|
}
|
||||||
|
return this.notificationService.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@ApiOperation({ summary: '删除通知' })
|
||||||
|
async delete(@Param('id') id: string) {
|
||||||
|
await this.notificationService.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移动端通知控制器
|
||||||
|
*/
|
||||||
|
@ApiTags('Mobile Notifications')
|
||||||
|
@Controller('mobile/notifications')
|
||||||
|
export class MobileNotificationController {
|
||||||
|
constructor(private readonly notificationService: NotificationService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({ summary: '获取用户通知列表' })
|
||||||
|
@ApiQuery({ name: 'userSerialNum', required: true })
|
||||||
|
@ApiQuery({ name: 'type', required: false })
|
||||||
|
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||||
|
@ApiQuery({ name: 'offset', required: false, type: Number })
|
||||||
|
async getNotifications(
|
||||||
|
@Query('userSerialNum') userSerialNum: string,
|
||||||
|
@Query('type') type?: string,
|
||||||
|
@Query('limit') limit?: number,
|
||||||
|
@Query('offset') offset?: number,
|
||||||
|
) {
|
||||||
|
const notifications = await this.notificationService.findNotificationsForUser({
|
||||||
|
userSerialNum,
|
||||||
|
type: type as any,
|
||||||
|
limit: limit ? Number(limit) : undefined,
|
||||||
|
offset: offset ? Number(offset) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const unreadCount = await this.notificationService.countUnreadForUser(userSerialNum);
|
||||||
|
|
||||||
|
return {
|
||||||
|
notifications,
|
||||||
|
total: notifications.length,
|
||||||
|
unreadCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('unread-count')
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({ summary: '获取未读通知数量' })
|
||||||
|
@ApiQuery({ name: 'userSerialNum', required: true })
|
||||||
|
async getUnreadCount(@Query('userSerialNum') userSerialNum: string) {
|
||||||
|
const unreadCount = await this.notificationService.countUnreadForUser(userSerialNum);
|
||||||
|
return { unreadCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('mark-read')
|
||||||
|
@Public()
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: '标记通知为已读' })
|
||||||
|
async markRead(@Body() dto: { userSerialNum: string; notificationId?: string }) {
|
||||||
|
if (dto.notificationId) {
|
||||||
|
await this.notificationService.markAsRead(dto.notificationId, dto.userSerialNum);
|
||||||
|
} else {
|
||||||
|
await this.notificationService.markAllAsRead(dto.userSerialNum);
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ import { PendingContributionsService } from './services/pending-contributions.se
|
||||||
import { BatchMiningService } from './services/batch-mining.service';
|
import { BatchMiningService } from './services/batch-mining.service';
|
||||||
import { VersionService } from './services/version.service';
|
import { VersionService } from './services/version.service';
|
||||||
import { CapabilityAdminService } from './services/capability-admin.service';
|
import { CapabilityAdminService } from './services/capability-admin.service';
|
||||||
|
import { NotificationService } from './services/notification.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [InfrastructureModule],
|
imports: [InfrastructureModule],
|
||||||
|
|
@ -26,6 +27,7 @@ import { CapabilityAdminService } from './services/capability-admin.service';
|
||||||
BatchMiningService,
|
BatchMiningService,
|
||||||
VersionService,
|
VersionService,
|
||||||
CapabilityAdminService,
|
CapabilityAdminService,
|
||||||
|
NotificationService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
AuthService,
|
AuthService,
|
||||||
|
|
@ -39,6 +41,7 @@ import { CapabilityAdminService } from './services/capability-admin.service';
|
||||||
BatchMiningService,
|
BatchMiningService,
|
||||||
VersionService,
|
VersionService,
|
||||||
CapabilityAdminService,
|
CapabilityAdminService,
|
||||||
|
NotificationService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class ApplicationModule implements OnModuleInit {
|
export class ApplicationModule implements OnModuleInit {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,357 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
|
||||||
|
import { NotificationType, TargetType, Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
export interface CreateNotificationDto {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
type?: NotificationType;
|
||||||
|
priority?: string;
|
||||||
|
targetType?: TargetType;
|
||||||
|
targetConfig?: {
|
||||||
|
accountSequences?: string[];
|
||||||
|
};
|
||||||
|
imageUrl?: string;
|
||||||
|
linkUrl?: string;
|
||||||
|
requiresForceRead?: boolean;
|
||||||
|
publishedAt?: string;
|
||||||
|
expiresAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateNotificationDto {
|
||||||
|
title?: string;
|
||||||
|
content?: string;
|
||||||
|
type?: NotificationType;
|
||||||
|
priority?: string;
|
||||||
|
targetType?: TargetType;
|
||||||
|
targetConfig?: {
|
||||||
|
accountSequences?: string[];
|
||||||
|
};
|
||||||
|
imageUrl?: string | null;
|
||||||
|
linkUrl?: string | null;
|
||||||
|
isEnabled?: boolean;
|
||||||
|
requiresForceRead?: boolean;
|
||||||
|
publishedAt?: string | null;
|
||||||
|
expiresAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class NotificationService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建通知
|
||||||
|
*/
|
||||||
|
async create(dto: CreateNotificationDto) {
|
||||||
|
const targetType = dto.targetType || 'ALL';
|
||||||
|
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const notification = await tx.notification.create({
|
||||||
|
data: {
|
||||||
|
title: dto.title,
|
||||||
|
content: dto.content,
|
||||||
|
type: dto.type || 'SYSTEM',
|
||||||
|
priority: dto.priority || 'NORMAL',
|
||||||
|
targetType,
|
||||||
|
imageUrl: dto.imageUrl || null,
|
||||||
|
linkUrl: dto.linkUrl || null,
|
||||||
|
requiresForceRead: dto.requiresForceRead ?? false,
|
||||||
|
publishedAt: dto.publishedAt ? new Date(dto.publishedAt) : null,
|
||||||
|
expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// SPECIFIC 目标:创建用户关联
|
||||||
|
if (
|
||||||
|
targetType === 'SPECIFIC' &&
|
||||||
|
dto.targetConfig?.accountSequences?.length
|
||||||
|
) {
|
||||||
|
await tx.notificationUserTarget.createMany({
|
||||||
|
data: dto.targetConfig.accountSequences.map((accountSequence) => ({
|
||||||
|
notificationId: notification.id,
|
||||||
|
accountSequence,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.findById(notification.id, tx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询通知详情
|
||||||
|
*/
|
||||||
|
async findById(id: string, tx?: any) {
|
||||||
|
const db = tx || this.prisma;
|
||||||
|
const notification = await db.notification.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
userTargets: { select: { accountSequence: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!notification) return null;
|
||||||
|
return this.formatNotification(notification);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理端通知列表
|
||||||
|
*/
|
||||||
|
async findAll(params?: {
|
||||||
|
type?: NotificationType;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}) {
|
||||||
|
const where: Prisma.NotificationWhereInput = {};
|
||||||
|
if (params?.type) where.type = params.type;
|
||||||
|
|
||||||
|
const [notifications, total] = await Promise.all([
|
||||||
|
this.prisma.notification.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
userTargets: { select: { accountSequence: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: params?.limit ?? 50,
|
||||||
|
skip: params?.offset ?? 0,
|
||||||
|
}),
|
||||||
|
this.prisma.notification.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
notifications: notifications.map((n) => this.formatNotification(n)),
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新通知
|
||||||
|
*/
|
||||||
|
async update(id: string, dto: UpdateNotificationDto) {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const data: any = {};
|
||||||
|
if (dto.title !== undefined) data.title = dto.title;
|
||||||
|
if (dto.content !== undefined) data.content = dto.content;
|
||||||
|
if (dto.type !== undefined) data.type = dto.type;
|
||||||
|
if (dto.priority !== undefined) data.priority = dto.priority;
|
||||||
|
if (dto.targetType !== undefined) data.targetType = dto.targetType;
|
||||||
|
if (dto.imageUrl !== undefined) data.imageUrl = dto.imageUrl;
|
||||||
|
if (dto.linkUrl !== undefined) data.linkUrl = dto.linkUrl;
|
||||||
|
if (dto.isEnabled !== undefined) data.isEnabled = dto.isEnabled;
|
||||||
|
if (dto.requiresForceRead !== undefined)
|
||||||
|
data.requiresForceRead = dto.requiresForceRead;
|
||||||
|
if (dto.publishedAt !== undefined)
|
||||||
|
data.publishedAt = dto.publishedAt ? new Date(dto.publishedAt) : null;
|
||||||
|
if (dto.expiresAt !== undefined)
|
||||||
|
data.expiresAt = dto.expiresAt ? new Date(dto.expiresAt) : null;
|
||||||
|
|
||||||
|
await tx.notification.update({ where: { id }, data });
|
||||||
|
|
||||||
|
// 如果更新了目标类型或目标配置,重建用户关联
|
||||||
|
if (dto.targetType !== undefined || dto.targetConfig !== undefined) {
|
||||||
|
await tx.notificationUserTarget.deleteMany({
|
||||||
|
where: { notificationId: id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const targetType = dto.targetType || (await tx.notification.findUnique({ where: { id }, select: { targetType: true } }))?.targetType;
|
||||||
|
|
||||||
|
if (
|
||||||
|
targetType === 'SPECIFIC' &&
|
||||||
|
dto.targetConfig?.accountSequences?.length
|
||||||
|
) {
|
||||||
|
await tx.notificationUserTarget.createMany({
|
||||||
|
data: dto.targetConfig.accountSequences.map((accountSequence) => ({
|
||||||
|
notificationId: id,
|
||||||
|
accountSequence,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.findById(id, tx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除通知
|
||||||
|
*/
|
||||||
|
async delete(id: string) {
|
||||||
|
await this.prisma.notification.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移动端:获取用户的通知列表(带已读状态)
|
||||||
|
*/
|
||||||
|
async findNotificationsForUser(params: {
|
||||||
|
userSerialNum: string;
|
||||||
|
type?: NotificationType;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}) {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const notifications = await this.prisma.notification.findMany({
|
||||||
|
where: {
|
||||||
|
isEnabled: true,
|
||||||
|
...(params.type && { type: params.type }),
|
||||||
|
AND: [
|
||||||
|
{ OR: [{ publishedAt: null }, { publishedAt: { lte: now } }] },
|
||||||
|
{ OR: [{ expiresAt: null }, { expiresAt: { gt: now } }] },
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{ targetType: 'ALL' },
|
||||||
|
{
|
||||||
|
targetType: 'SPECIFIC',
|
||||||
|
userTargets: {
|
||||||
|
some: { accountSequence: params.userSerialNum },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
reads: {
|
||||||
|
where: { userSerialNum: params.userSerialNum },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [{ priority: 'desc' }, { publishedAt: 'desc' }],
|
||||||
|
take: params.limit ?? 50,
|
||||||
|
skip: params.offset ?? 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
return notifications.map((n) => ({
|
||||||
|
id: n.id,
|
||||||
|
title: n.title,
|
||||||
|
content: n.content,
|
||||||
|
type: n.type,
|
||||||
|
priority: n.priority,
|
||||||
|
imageUrl: n.imageUrl,
|
||||||
|
linkUrl: n.linkUrl,
|
||||||
|
publishedAt: n.publishedAt,
|
||||||
|
isRead: n.reads.length > 0,
|
||||||
|
readAt: n.reads[0]?.readAt ?? null,
|
||||||
|
requiresForceRead: n.requiresForceRead,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移动端:获取未读通知数量
|
||||||
|
*/
|
||||||
|
async countUnreadForUser(userSerialNum: string): Promise<number> {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
return this.prisma.notification.count({
|
||||||
|
where: {
|
||||||
|
isEnabled: true,
|
||||||
|
reads: {
|
||||||
|
none: { userSerialNum },
|
||||||
|
},
|
||||||
|
AND: [
|
||||||
|
{ OR: [{ publishedAt: null }, { publishedAt: { lte: now } }] },
|
||||||
|
{ OR: [{ expiresAt: null }, { expiresAt: { gt: now } }] },
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{ targetType: 'ALL' },
|
||||||
|
{
|
||||||
|
targetType: 'SPECIFIC',
|
||||||
|
userTargets: {
|
||||||
|
some: { accountSequence: userSerialNum },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移动端:标记通知为已读
|
||||||
|
*/
|
||||||
|
async markAsRead(notificationId: string, userSerialNum: string) {
|
||||||
|
await this.prisma.notificationRead.upsert({
|
||||||
|
where: {
|
||||||
|
notificationId_userSerialNum: {
|
||||||
|
notificationId,
|
||||||
|
userSerialNum,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: { notificationId, userSerialNum },
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移动端:标记全部已读
|
||||||
|
*/
|
||||||
|
async markAllAsRead(userSerialNum: string) {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const unreadNotifications = await this.prisma.notification.findMany({
|
||||||
|
where: {
|
||||||
|
isEnabled: true,
|
||||||
|
reads: {
|
||||||
|
none: { userSerialNum },
|
||||||
|
},
|
||||||
|
AND: [
|
||||||
|
{ OR: [{ publishedAt: null }, { publishedAt: { lte: now } }] },
|
||||||
|
{ OR: [{ expiresAt: null }, { expiresAt: { gt: now } }] },
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{ targetType: 'ALL' },
|
||||||
|
{
|
||||||
|
targetType: 'SPECIFIC',
|
||||||
|
userTargets: {
|
||||||
|
some: { accountSequence: userSerialNum },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (unreadNotifications.length > 0) {
|
||||||
|
await this.prisma.notificationRead.createMany({
|
||||||
|
data: unreadNotifications.map((n) => ({
|
||||||
|
notificationId: n.id,
|
||||||
|
userSerialNum,
|
||||||
|
})),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化通知(包含目标配置)
|
||||||
|
*/
|
||||||
|
private formatNotification(notification: any) {
|
||||||
|
const targetConfig =
|
||||||
|
notification.targetType === 'SPECIFIC' && notification.userTargets?.length
|
||||||
|
? {
|
||||||
|
accountSequences: notification.userTargets.map(
|
||||||
|
(t: any) => t.accountSequence,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: notification.id,
|
||||||
|
title: notification.title,
|
||||||
|
content: notification.content,
|
||||||
|
type: notification.type,
|
||||||
|
priority: notification.priority,
|
||||||
|
targetType: notification.targetType,
|
||||||
|
targetConfig,
|
||||||
|
imageUrl: notification.imageUrl,
|
||||||
|
linkUrl: notification.linkUrl,
|
||||||
|
isEnabled: notification.isEnabled,
|
||||||
|
requiresForceRead: notification.requiresForceRead,
|
||||||
|
publishedAt: notification.publishedAt,
|
||||||
|
expiresAt: notification.expiresAt,
|
||||||
|
createdAt: notification.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,593 @@
|
||||||
|
/**
|
||||||
|
* 通知管理页面
|
||||||
|
*
|
||||||
|
* 功能:
|
||||||
|
* - 通知列表:展示所有通知,支持按类型筛选 + 分页
|
||||||
|
* - 新建/编辑通知:Dialog 表单,支持标题、内容、类型、优先级、
|
||||||
|
* 目标类型(全部/指定用户)、图片URL、链接URL、强制弹窗、发布/过期时间
|
||||||
|
* - 删除通知:确认对话框
|
||||||
|
*
|
||||||
|
* 数据流:
|
||||||
|
* notificationsApi (axios) → useNotifications (react-query) → 本页面渲染
|
||||||
|
*/
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
|
import {
|
||||||
|
useNotifications,
|
||||||
|
useCreateNotification,
|
||||||
|
useUpdateNotification,
|
||||||
|
useDeleteNotification,
|
||||||
|
} from '@/features/notifications/hooks/use-notifications';
|
||||||
|
import type {
|
||||||
|
NotificationItem,
|
||||||
|
NotificationType,
|
||||||
|
NotificationPriority,
|
||||||
|
TargetType,
|
||||||
|
CreateNotificationDto,
|
||||||
|
} from '@/features/notifications/api/notifications.api';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import {
|
||||||
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { ChevronLeft, ChevronRight, Plus, Pencil, Trash2 } from 'lucide-react';
|
||||||
|
import { formatDateTime } from '@/lib/utils/date';
|
||||||
|
|
||||||
|
// ==================== 常量映射 ====================
|
||||||
|
|
||||||
|
/** 通知类型标签与样式 */
|
||||||
|
const typeLabels: Record<NotificationType, { label: string; className: string }> = {
|
||||||
|
SYSTEM: { label: '系统', className: 'bg-blue-100 text-blue-700' },
|
||||||
|
ACTIVITY: { label: '活动', className: 'bg-green-100 text-green-700' },
|
||||||
|
REWARD: { label: '收益', className: 'bg-yellow-100 text-yellow-700' },
|
||||||
|
UPGRADE: { label: '升级', className: 'bg-purple-100 text-purple-700' },
|
||||||
|
ANNOUNCEMENT: { label: '公告', className: 'bg-orange-100 text-orange-700' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 优先级标签与样式 */
|
||||||
|
const priorityLabels: Record<NotificationPriority, { label: string; className: string }> = {
|
||||||
|
LOW: { label: '低', className: 'bg-gray-100 text-gray-600' },
|
||||||
|
NORMAL: { label: '普通', className: 'bg-blue-100 text-blue-600' },
|
||||||
|
HIGH: { label: '高', className: 'bg-orange-100 text-orange-700' },
|
||||||
|
URGENT: { label: '紧急', className: 'bg-red-100 text-red-700' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 目标类型标签 */
|
||||||
|
const targetLabels: Record<TargetType, string> = {
|
||||||
|
ALL: '全部用户',
|
||||||
|
SPECIFIC: '指定用户',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 表单初始值 ====================
|
||||||
|
|
||||||
|
const emptyForm: CreateNotificationDto = {
|
||||||
|
title: '',
|
||||||
|
content: '',
|
||||||
|
type: 'SYSTEM',
|
||||||
|
priority: 'NORMAL',
|
||||||
|
targetType: 'ALL',
|
||||||
|
targetAccountSequences: [],
|
||||||
|
imageUrl: '',
|
||||||
|
linkUrl: '',
|
||||||
|
isEnabled: true,
|
||||||
|
requiresForceRead: false,
|
||||||
|
publishedAt: '',
|
||||||
|
expiresAt: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 主页面组件 ====================
|
||||||
|
|
||||||
|
export default function NotificationsPage() {
|
||||||
|
// ---- 列表状态 ----
|
||||||
|
const [filterType, setFilterType] = useState<string>('ALL');
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const pageSize = 20;
|
||||||
|
|
||||||
|
// ---- 查询通知列表 ----
|
||||||
|
const { data, isLoading } = useNotifications({
|
||||||
|
type: filterType === 'ALL' ? undefined : (filterType as NotificationType),
|
||||||
|
limit: pageSize,
|
||||||
|
offset: page * pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- CRUD mutations ----
|
||||||
|
const createMutation = useCreateNotification();
|
||||||
|
const updateMutation = useUpdateNotification();
|
||||||
|
const deleteMutation = useDeleteNotification();
|
||||||
|
|
||||||
|
// ---- 编辑 Dialog 状态 ----
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [form, setForm] = useState<CreateNotificationDto>({ ...emptyForm });
|
||||||
|
/** 指定用户的文本输入(逗号/换行分隔) */
|
||||||
|
const [targetUsersText, setTargetUsersText] = useState('');
|
||||||
|
|
||||||
|
// ---- 删除确认 Dialog 状态 ----
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// ---- 打开新建 Dialog ----
|
||||||
|
const handleCreate = () => {
|
||||||
|
setEditingId(null);
|
||||||
|
setForm({ ...emptyForm });
|
||||||
|
setTargetUsersText('');
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- 打开编辑 Dialog ----
|
||||||
|
const handleEdit = (item: NotificationItem) => {
|
||||||
|
setEditingId(item.id);
|
||||||
|
setForm({
|
||||||
|
title: item.title,
|
||||||
|
content: item.content,
|
||||||
|
type: item.type,
|
||||||
|
priority: item.priority,
|
||||||
|
targetType: item.targetType,
|
||||||
|
targetAccountSequences: item.targetConfig?.accountSequences || [],
|
||||||
|
imageUrl: item.imageUrl || '',
|
||||||
|
linkUrl: item.linkUrl || '',
|
||||||
|
isEnabled: item.isEnabled,
|
||||||
|
requiresForceRead: item.requiresForceRead,
|
||||||
|
publishedAt: item.publishedAt ? item.publishedAt.slice(0, 16) : '', // datetime-local 格式
|
||||||
|
expiresAt: item.expiresAt ? item.expiresAt.slice(0, 16) : '',
|
||||||
|
});
|
||||||
|
setTargetUsersText(
|
||||||
|
item.targetConfig?.accountSequences?.join('\n') || ''
|
||||||
|
);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- 提交表单(创建 or 更新) ----
|
||||||
|
const handleSubmit = () => {
|
||||||
|
// 解析指定用户文本 → 账号数组(去空行去空格)
|
||||||
|
const targetAccountSequences =
|
||||||
|
form.targetType === 'SPECIFIC'
|
||||||
|
? targetUsersText
|
||||||
|
.split(/[,\n]/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const payload: CreateNotificationDto = {
|
||||||
|
...form,
|
||||||
|
targetAccountSequences,
|
||||||
|
// 空字符串转 undefined,避免后端报错
|
||||||
|
imageUrl: form.imageUrl || undefined,
|
||||||
|
linkUrl: form.linkUrl || undefined,
|
||||||
|
publishedAt: form.publishedAt ? new Date(form.publishedAt).toISOString() : undefined,
|
||||||
|
expiresAt: form.expiresAt ? new Date(form.expiresAt).toISOString() : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingId) {
|
||||||
|
updateMutation.mutate(
|
||||||
|
{ id: editingId, dto: payload },
|
||||||
|
{ onSuccess: () => setDialogOpen(false) }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(payload, {
|
||||||
|
onSuccess: () => setDialogOpen(false),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- 删除确认 ----
|
||||||
|
const handleDelete = (id: string) => {
|
||||||
|
setDeletingId(id);
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
if (deletingId) {
|
||||||
|
deleteMutation.mutate(deletingId, {
|
||||||
|
onSuccess: () => setDeleteDialogOpen(false),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- 渲染 Badge ----
|
||||||
|
const renderBadge = (text: string, className: string) => (
|
||||||
|
<span className={`inline-flex items-center px-2 py-1 rounded text-xs font-medium ${className}`}>
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- 骨架行 ----
|
||||||
|
const renderSkeletonRows = () =>
|
||||||
|
[...Array(5)].map((_, i) => (
|
||||||
|
<TableRow key={i}>
|
||||||
|
{[...Array(8)].map((_, j) => (
|
||||||
|
<TableCell key={j}><Skeleton className="h-4 w-full" /></TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
));
|
||||||
|
|
||||||
|
// ---- 分页计算 ----
|
||||||
|
const total = data?.total || 0;
|
||||||
|
const totalPages = Math.ceil(total / pageSize);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 页头 + 新建按钮 */}
|
||||||
|
<PageHeader
|
||||||
|
title="通知管理"
|
||||||
|
description="管理系统通知,支持全部推送和指定用户推送"
|
||||||
|
actions={
|
||||||
|
<Button onClick={handleCreate}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
新建通知
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 类型筛选 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Select value={filterType} onValueChange={(v) => { setFilterType(v); setPage(0); }}>
|
||||||
|
<SelectTrigger className="w-[150px]">
|
||||||
|
<SelectValue placeholder="通知类型" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="ALL">全部类型</SelectItem>
|
||||||
|
<SelectItem value="SYSTEM">系统</SelectItem>
|
||||||
|
<SelectItem value="ACTIVITY">活动</SelectItem>
|
||||||
|
<SelectItem value="REWARD">收益</SelectItem>
|
||||||
|
<SelectItem value="UPGRADE">升级</SelectItem>
|
||||||
|
<SelectItem value="ANNOUNCEMENT">公告</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 通知列表表格 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>标题</TableHead>
|
||||||
|
<TableHead>类型</TableHead>
|
||||||
|
<TableHead>优先级</TableHead>
|
||||||
|
<TableHead>目标</TableHead>
|
||||||
|
<TableHead>强制弹窗</TableHead>
|
||||||
|
<TableHead>启用</TableHead>
|
||||||
|
<TableHead>发布时间</TableHead>
|
||||||
|
<TableHead className="text-right">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading ? (
|
||||||
|
renderSkeletonRows()
|
||||||
|
) : !data?.items?.length ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
|
||||||
|
暂无通知
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
data.items.map((item) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell className="max-w-[200px] truncate font-medium">
|
||||||
|
{item.title}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{renderBadge(
|
||||||
|
typeLabels[item.type]?.label || item.type,
|
||||||
|
typeLabels[item.type]?.className || ''
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{renderBadge(
|
||||||
|
priorityLabels[item.priority]?.label || item.priority,
|
||||||
|
priorityLabels[item.priority]?.className || ''
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{renderBadge(
|
||||||
|
targetLabels[item.targetType] || item.targetType,
|
||||||
|
item.targetType === 'ALL'
|
||||||
|
? 'bg-green-100 text-green-700'
|
||||||
|
: 'bg-yellow-100 text-yellow-700'
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{item.requiresForceRead ? (
|
||||||
|
<span className="text-red-600 font-medium">是</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">否</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{item.isEnabled ? (
|
||||||
|
<span className="text-green-600 font-medium">启用</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">禁用</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatDateTime(item.publishedAt)}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => handleEdit(item)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-destructive"
|
||||||
|
onClick={() => handleDelete(item.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
{/* 分页 */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between p-4 border-t">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
共 {total} 条,第 {page + 1} / {totalPages} 页
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage(page - 1)}
|
||||||
|
disabled={page <= 0}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage(page + 1)}
|
||||||
|
disabled={page + 1 >= totalPages}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ==================== 新建/编辑 Dialog ==================== */}
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingId ? '编辑通知' : '新建通知'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{editingId ? '修改通知内容,保存后立即生效' : '创建新的系统通知,可推送给全部或指定用户'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
{/* 标题 */}
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="title">标题 *</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
value={form.title}
|
||||||
|
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
||||||
|
placeholder="输入通知标题"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 内容 */}
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="content">内容 *</Label>
|
||||||
|
<Textarea
|
||||||
|
id="content"
|
||||||
|
value={form.content}
|
||||||
|
onChange={(e) => setForm({ ...form, content: e.target.value })}
|
||||||
|
placeholder="输入通知内容"
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 类型 + 优先级(同行) */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>类型</Label>
|
||||||
|
<Select
|
||||||
|
value={form.type}
|
||||||
|
onValueChange={(v) => setForm({ ...form, type: v as NotificationType })}
|
||||||
|
>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="SYSTEM">系统</SelectItem>
|
||||||
|
<SelectItem value="ACTIVITY">活动</SelectItem>
|
||||||
|
<SelectItem value="REWARD">收益</SelectItem>
|
||||||
|
<SelectItem value="UPGRADE">升级</SelectItem>
|
||||||
|
<SelectItem value="ANNOUNCEMENT">公告</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>优先级</Label>
|
||||||
|
<Select
|
||||||
|
value={form.priority}
|
||||||
|
onValueChange={(v) => setForm({ ...form, priority: v as NotificationPriority })}
|
||||||
|
>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="LOW">低</SelectItem>
|
||||||
|
<SelectItem value="NORMAL">普通</SelectItem>
|
||||||
|
<SelectItem value="HIGH">高</SelectItem>
|
||||||
|
<SelectItem value="URGENT">紧急</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 目标类型 */}
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>推送目标</Label>
|
||||||
|
<Select
|
||||||
|
value={form.targetType}
|
||||||
|
onValueChange={(v) => setForm({ ...form, targetType: v as TargetType })}
|
||||||
|
>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="ALL">全部用户</SelectItem>
|
||||||
|
<SelectItem value="SPECIFIC">指定用户</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 指定用户输入(targetType=SPECIFIC 时显示) */}
|
||||||
|
{form.targetType === 'SPECIFIC' && (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="targetUsers">指定用户账号(每行一个或逗号分隔)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="targetUsers"
|
||||||
|
value={targetUsersText}
|
||||||
|
onChange={(e) => setTargetUsersText(e.target.value)}
|
||||||
|
placeholder="D25121400001 D25121400002"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 图片URL + 链接URL */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="imageUrl">图片 URL(可选)</Label>
|
||||||
|
<Input
|
||||||
|
id="imageUrl"
|
||||||
|
value={form.imageUrl || ''}
|
||||||
|
onChange={(e) => setForm({ ...form, imageUrl: e.target.value })}
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="linkUrl">链接 URL(可选)</Label>
|
||||||
|
<Input
|
||||||
|
id="linkUrl"
|
||||||
|
value={form.linkUrl || ''}
|
||||||
|
onChange={(e) => setForm({ ...form, linkUrl: e.target.value })}
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 发布时间 + 过期时间 */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="publishedAt">发布时间(可选,不填则立即发布)</Label>
|
||||||
|
<Input
|
||||||
|
id="publishedAt"
|
||||||
|
type="datetime-local"
|
||||||
|
value={form.publishedAt || ''}
|
||||||
|
onChange={(e) => setForm({ ...form, publishedAt: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="expiresAt">过期时间(可选)</Label>
|
||||||
|
<Input
|
||||||
|
id="expiresAt"
|
||||||
|
type="datetime-local"
|
||||||
|
value={form.expiresAt || ''}
|
||||||
|
onChange={(e) => setForm({ ...form, expiresAt: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 复选框:强制弹窗 + 启用 */}
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="requiresForceRead"
|
||||||
|
checked={form.requiresForceRead}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({ ...form, requiresForceRead: e.target.checked })
|
||||||
|
}
|
||||||
|
className="h-4 w-4 rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<Label htmlFor="requiresForceRead" className="text-sm">
|
||||||
|
强制弹窗阅读(用户打开APP时必须查看)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="isEnabled"
|
||||||
|
checked={form.isEnabled}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({ ...form, isEnabled: e.target.checked })
|
||||||
|
}
|
||||||
|
className="h-4 w-4 rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<Label htmlFor="isEnabled" className="text-sm">启用</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={
|
||||||
|
!form.title?.trim() ||
|
||||||
|
!form.content?.trim() ||
|
||||||
|
createMutation.isPending ||
|
||||||
|
updateMutation.isPending
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{createMutation.isPending || updateMutation.isPending ? '提交中...' : '保存'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* ==================== 删除确认 Dialog ==================== */}
|
||||||
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>确认删除</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
删除后无法恢复,确定要删除这条通知吗?
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={confirmDelete}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
{deleteMutation.isPending ? '删除中...' : '确认删除'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
SendHorizontal,
|
SendHorizontal,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Repeat,
|
Repeat,
|
||||||
|
Bell,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
|
@ -34,6 +35,7 @@ const menuItems = [
|
||||||
{ name: 'C2C Bot', href: '/c2c-bot', icon: Zap },
|
{ name: 'C2C Bot', href: '/c2c-bot', icon: Zap },
|
||||||
{ name: '手工补发', href: '/manual-mining', icon: HandCoins },
|
{ name: '手工补发', href: '/manual-mining', icon: HandCoins },
|
||||||
{ name: '批量补发', href: '/batch-mining', icon: FileSpreadsheet },
|
{ name: '批量补发', href: '/batch-mining', icon: FileSpreadsheet },
|
||||||
|
{ name: '通知管理', href: '/notifications', icon: Bell },
|
||||||
{ name: '配置管理', href: '/configs', icon: Settings },
|
{ name: '配置管理', href: '/configs', icon: Settings },
|
||||||
{ name: '系统账户', href: '/system-accounts', icon: Building2 },
|
{ name: '系统账户', href: '/system-accounts', icon: Building2 },
|
||||||
{ name: '报表统计', href: '/reports', icon: FileBarChart },
|
{ name: '报表统计', href: '/reports', icon: FileBarChart },
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
/**
|
||||||
|
* 通知管理 API 模块
|
||||||
|
*
|
||||||
|
* 提供后台通知的 CRUD 操作接口,使用 apiClient 通过 Next.js rewrite
|
||||||
|
* 代理到 mining-admin-service 的 /api/v2/notifications 端点。
|
||||||
|
*
|
||||||
|
* 后端响应格式: { success: boolean, data: T, timestamp: string }
|
||||||
|
* 前端统一通过 response.data.data 提取实际数据。
|
||||||
|
*/
|
||||||
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
|
||||||
|
// ==================== 枚举类型(与后端 Prisma 枚举对应) ====================
|
||||||
|
|
||||||
|
/** 通知类型 */
|
||||||
|
export type NotificationType = 'SYSTEM' | 'ACTIVITY' | 'REWARD' | 'UPGRADE' | 'ANNOUNCEMENT';
|
||||||
|
|
||||||
|
/** 通知优先级 */
|
||||||
|
export type NotificationPriority = 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT';
|
||||||
|
|
||||||
|
/** 目标用户类型: ALL=全部用户, SPECIFIC=指定用户 */
|
||||||
|
export type TargetType = 'ALL' | 'SPECIFIC';
|
||||||
|
|
||||||
|
// ==================== 数据类型 ====================
|
||||||
|
|
||||||
|
/** 通知列表项(后端 formatNotification 返回格式) */
|
||||||
|
export interface NotificationItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
type: NotificationType;
|
||||||
|
priority: NotificationPriority;
|
||||||
|
targetType: TargetType;
|
||||||
|
imageUrl: string | null;
|
||||||
|
linkUrl: string | null;
|
||||||
|
isEnabled: boolean;
|
||||||
|
requiresForceRead: boolean;
|
||||||
|
publishedAt: string | null;
|
||||||
|
expiresAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
/** 指定目标配置(后端格式,仅 SPECIFIC 类型时有值) */
|
||||||
|
targetConfig?: { accountSequences: string[] } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建通知请求体 */
|
||||||
|
export interface CreateNotificationDto {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
type?: NotificationType;
|
||||||
|
priority?: NotificationPriority;
|
||||||
|
targetType?: TargetType;
|
||||||
|
/** 指定用户账号序列号列表(targetType=SPECIFIC 时必填) */
|
||||||
|
targetAccountSequences?: string[];
|
||||||
|
imageUrl?: string;
|
||||||
|
linkUrl?: string;
|
||||||
|
isEnabled?: boolean;
|
||||||
|
requiresForceRead?: boolean;
|
||||||
|
publishedAt?: string;
|
||||||
|
expiresAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新通知请求体(所有字段可选) */
|
||||||
|
export type UpdateNotificationDto = Partial<CreateNotificationDto>;
|
||||||
|
|
||||||
|
/** 通知列表查询参数 */
|
||||||
|
export interface NotificationListParams {
|
||||||
|
type?: NotificationType;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通知列表响应 */
|
||||||
|
export interface NotificationListResponse {
|
||||||
|
items: NotificationItem[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== API 方法 ====================
|
||||||
|
|
||||||
|
export const notificationsApi = {
|
||||||
|
/**
|
||||||
|
* 获取通知列表(管理端)
|
||||||
|
* GET /notifications?type=&limit=&offset=
|
||||||
|
*/
|
||||||
|
getList: async (params: NotificationListParams = {}): Promise<NotificationListResponse> => {
|
||||||
|
const response = await apiClient.get('/notifications', { params });
|
||||||
|
const data = response.data.data || response.data;
|
||||||
|
// 后端 findAll 返回 { notifications: [...], total: number }
|
||||||
|
return {
|
||||||
|
items: data.notifications || data.items || [],
|
||||||
|
total: data.total || 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取通知详情
|
||||||
|
* GET /notifications/:id
|
||||||
|
*/
|
||||||
|
getById: async (id: string): Promise<NotificationItem> => {
|
||||||
|
const response = await apiClient.get(`/notifications/${id}`);
|
||||||
|
return response.data.data || response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建通知
|
||||||
|
* POST /notifications
|
||||||
|
* 前端 DTO 使用 targetAccountSequences,需转换为后端格式 targetConfig.accountSequences
|
||||||
|
*/
|
||||||
|
create: async (dto: CreateNotificationDto): Promise<NotificationItem> => {
|
||||||
|
const { targetAccountSequences, ...rest } = dto;
|
||||||
|
const payload = {
|
||||||
|
...rest,
|
||||||
|
...(targetAccountSequences?.length && {
|
||||||
|
targetConfig: { accountSequences: targetAccountSequences },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const response = await apiClient.post('/notifications', payload);
|
||||||
|
return response.data.data || response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新通知
|
||||||
|
* PUT /notifications/:id
|
||||||
|
*/
|
||||||
|
update: async (id: string, dto: UpdateNotificationDto): Promise<NotificationItem> => {
|
||||||
|
const { targetAccountSequences, ...rest } = dto;
|
||||||
|
const payload = {
|
||||||
|
...rest,
|
||||||
|
...(targetAccountSequences !== undefined && {
|
||||||
|
targetConfig: { accountSequences: targetAccountSequences || [] },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const response = await apiClient.put(`/notifications/${id}`, payload);
|
||||||
|
return response.data.data || response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除通知
|
||||||
|
* DELETE /notifications/:id
|
||||||
|
*/
|
||||||
|
delete: async (id: string): Promise<void> => {
|
||||||
|
await apiClient.delete(`/notifications/${id}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
/**
|
||||||
|
* 通知管理 React Query Hooks
|
||||||
|
*
|
||||||
|
* 封装通知 CRUD 的数据获取与变更操作。
|
||||||
|
* - useNotifications: 获取通知列表(支持类型筛选 + 分页)
|
||||||
|
* - useCreateNotification: 创建通知(成功后自动刷新列表)
|
||||||
|
* - useUpdateNotification: 更新通知(成功后自动刷新列表)
|
||||||
|
* - useDeleteNotification: 删除通知(成功后自动刷新列表)
|
||||||
|
*/
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { notificationsApi } from '../api/notifications.api';
|
||||||
|
import type { NotificationListParams, CreateNotificationDto, UpdateNotificationDto } from '../api/notifications.api';
|
||||||
|
import { useToast } from '@/lib/hooks/use-toast';
|
||||||
|
|
||||||
|
/** 查询通知列表 */
|
||||||
|
export function useNotifications(params: NotificationListParams = {}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['notifications', params],
|
||||||
|
queryFn: () => notificationsApi.getList(params),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建通知,成功后 invalidate 列表缓存并提示 */
|
||||||
|
export function useCreateNotification() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (dto: CreateNotificationDto) => notificationsApi.create(dto),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||||
|
toast({ title: '通知创建成功', variant: 'success' as any });
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast({
|
||||||
|
title: '创建失败',
|
||||||
|
description: error?.response?.data?.message || '请稍后重试',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新通知,成功后 invalidate 列表缓存并提示 */
|
||||||
|
export function useUpdateNotification() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, dto }: { id: string; dto: UpdateNotificationDto }) =>
|
||||||
|
notificationsApi.update(id, dto),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||||
|
toast({ title: '通知更新成功', variant: 'success' as any });
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast({
|
||||||
|
title: '更新失败',
|
||||||
|
description: error?.response?.data?.message || '请稍后重试',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除通知,成功后 invalidate 列表缓存并提示 */
|
||||||
|
export function useDeleteNotification() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => notificationsApi.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||||
|
toast({ title: '通知已删除', variant: 'success' as any });
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast({
|
||||||
|
title: '删除失败',
|
||||||
|
description: error?.response?.data?.message || '请稍后重试',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -27,6 +27,7 @@ import '../../presentation/pages/trading/transfer_records_page.dart';
|
||||||
import '../../presentation/pages/asset/p2p_transfer_records_page.dart';
|
import '../../presentation/pages/asset/p2p_transfer_records_page.dart';
|
||||||
import '../../presentation/pages/profile/help_center_page.dart';
|
import '../../presentation/pages/profile/help_center_page.dart';
|
||||||
import '../../presentation/pages/profile/about_page.dart';
|
import '../../presentation/pages/profile/about_page.dart';
|
||||||
|
import '../../presentation/pages/profile/notification_inbox_page.dart';
|
||||||
import '../../presentation/widgets/main_shell.dart';
|
import '../../presentation/widgets/main_shell.dart';
|
||||||
import '../../presentation/providers/user_providers.dart';
|
import '../../presentation/providers/user_providers.dart';
|
||||||
import 'routes.dart';
|
import 'routes.dart';
|
||||||
|
|
@ -179,6 +180,10 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||||
filterDirection: state.uri.queryParameters['filter'],
|
filterDirection: state.uri.queryParameters['filter'],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: Routes.notifications,
|
||||||
|
builder: (context, state) => const NotificationInboxPage(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: Routes.helpCenter,
|
path: Routes.helpCenter,
|
||||||
builder: (context, state) => const HelpCenterPage(),
|
builder: (context, state) => const HelpCenterPage(),
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ class Routes {
|
||||||
static const String transferRecords = '/transfer-records';
|
static const String transferRecords = '/transfer-records';
|
||||||
// P2P转账记录
|
// P2P转账记录
|
||||||
static const String p2pTransferRecords = '/p2p-transfer-records';
|
static const String p2pTransferRecords = '/p2p-transfer-records';
|
||||||
|
// 通知
|
||||||
|
static const String notifications = '/notifications';
|
||||||
// 其他设置
|
// 其他设置
|
||||||
static const String helpCenter = '/help-center';
|
static const String helpCenter = '/help-center';
|
||||||
static const String about = '/about';
|
static const String about = '/about';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,270 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import '../network/api_client.dart';
|
||||||
|
|
||||||
|
/// ==================== 通知枚举类型 ====================
|
||||||
|
|
||||||
|
/// 通知类型(与后端 NotificationType 枚举对应)
|
||||||
|
enum NotificationType {
|
||||||
|
system,
|
||||||
|
activity,
|
||||||
|
reward,
|
||||||
|
upgrade,
|
||||||
|
announcement,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 通知优先级(与后端 NotificationPriority 枚举对应)
|
||||||
|
enum NotificationPriority {
|
||||||
|
low,
|
||||||
|
normal,
|
||||||
|
high,
|
||||||
|
urgent,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ==================== 通知数据模型 ====================
|
||||||
|
|
||||||
|
/// 单条通知项
|
||||||
|
///
|
||||||
|
/// 对应后端 MobileNotificationController GET /mobile/notifications 返回的列表项。
|
||||||
|
/// [requiresForceRead] 标记的通知在 APP 启动/恢复前台时强制弹窗展示。
|
||||||
|
class NotificationItem {
|
||||||
|
final String id;
|
||||||
|
final String title;
|
||||||
|
final String content;
|
||||||
|
final NotificationType type;
|
||||||
|
final NotificationPriority priority;
|
||||||
|
final String? imageUrl;
|
||||||
|
final String? linkUrl;
|
||||||
|
final DateTime? publishedAt;
|
||||||
|
final bool isRead;
|
||||||
|
final DateTime? readAt;
|
||||||
|
|
||||||
|
/// 是否需要强制弹窗阅读(由管理员创建通知时配置)
|
||||||
|
final bool requiresForceRead;
|
||||||
|
|
||||||
|
NotificationItem({
|
||||||
|
required this.id,
|
||||||
|
required this.title,
|
||||||
|
required this.content,
|
||||||
|
required this.type,
|
||||||
|
required this.priority,
|
||||||
|
this.imageUrl,
|
||||||
|
this.linkUrl,
|
||||||
|
this.publishedAt,
|
||||||
|
required this.isRead,
|
||||||
|
this.readAt,
|
||||||
|
this.requiresForceRead = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory NotificationItem.fromJson(Map<String, dynamic> json) {
|
||||||
|
return NotificationItem(
|
||||||
|
id: json['id'] ?? '',
|
||||||
|
title: json['title'] ?? '',
|
||||||
|
content: json['content'] ?? '',
|
||||||
|
type: _parseNotificationType(json['type']),
|
||||||
|
priority: _parseNotificationPriority(json['priority']),
|
||||||
|
imageUrl: json['imageUrl'],
|
||||||
|
linkUrl: json['linkUrl'],
|
||||||
|
publishedAt: json['publishedAt'] != null
|
||||||
|
? DateTime.tryParse(json['publishedAt'])
|
||||||
|
: null,
|
||||||
|
isRead: json['isRead'] ?? false,
|
||||||
|
readAt: json['readAt'] != null ? DateTime.tryParse(json['readAt']) : null,
|
||||||
|
requiresForceRead: json['requiresForceRead'] ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static NotificationType _parseNotificationType(String? type) {
|
||||||
|
switch (type) {
|
||||||
|
case 'SYSTEM':
|
||||||
|
return NotificationType.system;
|
||||||
|
case 'ACTIVITY':
|
||||||
|
return NotificationType.activity;
|
||||||
|
case 'REWARD':
|
||||||
|
return NotificationType.reward;
|
||||||
|
case 'UPGRADE':
|
||||||
|
return NotificationType.upgrade;
|
||||||
|
case 'ANNOUNCEMENT':
|
||||||
|
return NotificationType.announcement;
|
||||||
|
default:
|
||||||
|
return NotificationType.system;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static NotificationPriority _parseNotificationPriority(String? priority) {
|
||||||
|
switch (priority) {
|
||||||
|
case 'LOW':
|
||||||
|
return NotificationPriority.low;
|
||||||
|
case 'NORMAL':
|
||||||
|
return NotificationPriority.normal;
|
||||||
|
case 'HIGH':
|
||||||
|
return NotificationPriority.high;
|
||||||
|
case 'URGENT':
|
||||||
|
return NotificationPriority.urgent;
|
||||||
|
default:
|
||||||
|
return NotificationPriority.normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取通知类型的中文名称
|
||||||
|
String get typeName {
|
||||||
|
switch (type) {
|
||||||
|
case NotificationType.system:
|
||||||
|
return '系统通知';
|
||||||
|
case NotificationType.activity:
|
||||||
|
return '活动通知';
|
||||||
|
case NotificationType.reward:
|
||||||
|
return '收益通知';
|
||||||
|
case NotificationType.upgrade:
|
||||||
|
return '升级通知';
|
||||||
|
case NotificationType.announcement:
|
||||||
|
return '公告';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取通知类型的图标 emoji
|
||||||
|
String get typeIcon {
|
||||||
|
switch (type) {
|
||||||
|
case NotificationType.system:
|
||||||
|
return '🔔';
|
||||||
|
case NotificationType.activity:
|
||||||
|
return '🎉';
|
||||||
|
case NotificationType.reward:
|
||||||
|
return '💰';
|
||||||
|
case NotificationType.upgrade:
|
||||||
|
return '⬆️';
|
||||||
|
case NotificationType.announcement:
|
||||||
|
return '📢';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 通知列表 API 响应
|
||||||
|
class NotificationListResponse {
|
||||||
|
final List<NotificationItem> notifications;
|
||||||
|
final int total;
|
||||||
|
final int unreadCount;
|
||||||
|
|
||||||
|
NotificationListResponse({
|
||||||
|
required this.notifications,
|
||||||
|
required this.total,
|
||||||
|
required this.unreadCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory NotificationListResponse.fromJson(Map<String, dynamic> json) {
|
||||||
|
final list = (json['notifications'] as List?)
|
||||||
|
?.map((e) => NotificationItem.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList() ??
|
||||||
|
[];
|
||||||
|
return NotificationListResponse(
|
||||||
|
notifications: list,
|
||||||
|
total: json['total'] ?? list.length,
|
||||||
|
unreadCount: json['unreadCount'] ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ==================== 通知服务 ====================
|
||||||
|
|
||||||
|
/// 通知 API 服务
|
||||||
|
///
|
||||||
|
/// 通过 Kong 网关访问 mining-admin-service 的移动端通知接口:
|
||||||
|
/// - GET /api/v2/mining-admin/mobile/notifications 获取通知列表
|
||||||
|
/// - GET /api/v2/mining-admin/mobile/notifications/unread-count 获取未读数量
|
||||||
|
/// - POST /api/v2/mining-admin/mobile/notifications/mark-read 标记已读
|
||||||
|
class NotificationService {
|
||||||
|
final ApiClient _apiClient;
|
||||||
|
|
||||||
|
/// API 路径前缀(经 Kong 网关路由到 mining-admin-service)
|
||||||
|
static const String _basePath = '/api/v2/mining-admin/mobile/notifications';
|
||||||
|
|
||||||
|
NotificationService({required ApiClient apiClient}) : _apiClient = apiClient;
|
||||||
|
|
||||||
|
/// 获取用户的通知列表
|
||||||
|
///
|
||||||
|
/// [userSerialNum] 用户账号序列号(accountSequence)
|
||||||
|
/// [type] 可选,按通知类型筛选
|
||||||
|
/// [limit] 每页数量,默认 50
|
||||||
|
/// [offset] 偏移量,默认 0
|
||||||
|
Future<NotificationListResponse> getNotifications({
|
||||||
|
required String userSerialNum,
|
||||||
|
NotificationType? type,
|
||||||
|
int limit = 50,
|
||||||
|
int offset = 0,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final queryParams = {
|
||||||
|
'userSerialNum': userSerialNum,
|
||||||
|
'limit': limit.toString(),
|
||||||
|
'offset': offset.toString(),
|
||||||
|
};
|
||||||
|
if (type != null) {
|
||||||
|
queryParams['type'] = type.name.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
final response = await _apiClient.get(
|
||||||
|
_basePath,
|
||||||
|
queryParameters: queryParams,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 后端通过 TransformInterceptor 包装:{ success, data: { notifications, total, unreadCount }, timestamp }
|
||||||
|
final data = response.data is Map && response.data['data'] != null
|
||||||
|
? response.data['data']
|
||||||
|
: response.data;
|
||||||
|
return NotificationListResponse.fromJson(data);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[NotificationService] 获取通知列表失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取未读通知数量
|
||||||
|
Future<int> getUnreadCount({required String userSerialNum}) async {
|
||||||
|
try {
|
||||||
|
final response = await _apiClient.get(
|
||||||
|
'$_basePath/unread-count',
|
||||||
|
queryParameters: {'userSerialNum': userSerialNum},
|
||||||
|
);
|
||||||
|
|
||||||
|
final data = response.data is Map && response.data['data'] != null
|
||||||
|
? response.data['data']
|
||||||
|
: response.data;
|
||||||
|
return data['unreadCount'] ?? 0;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[NotificationService] 获取未读数量失败: $e');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 标记通知为已读
|
||||||
|
///
|
||||||
|
/// [notificationId] 不传则标记全部为已读
|
||||||
|
Future<bool> markAsRead({
|
||||||
|
required String userSerialNum,
|
||||||
|
String? notificationId,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final body = {
|
||||||
|
'userSerialNum': userSerialNum,
|
||||||
|
if (notificationId != null) 'notificationId': notificationId,
|
||||||
|
};
|
||||||
|
|
||||||
|
final response = await _apiClient.post(
|
||||||
|
'$_basePath/mark-read',
|
||||||
|
data: body,
|
||||||
|
);
|
||||||
|
|
||||||
|
final data = response.data is Map && response.data['data'] != null
|
||||||
|
? response.data['data']
|
||||||
|
: response.data;
|
||||||
|
return data['success'] ?? false;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[NotificationService] 标记已读失败: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 标记所有通知为已读(快捷方法)
|
||||||
|
Future<bool> markAllAsRead({required String userSerialNum}) async {
|
||||||
|
return markAsRead(userSerialNum: userSerialNum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,563 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import '../../../core/services/notification_service.dart';
|
||||||
|
import '../../providers/notification_providers.dart';
|
||||||
|
import '../../providers/user_providers.dart';
|
||||||
|
|
||||||
|
/// 通知收件箱页面
|
||||||
|
///
|
||||||
|
/// 展示当前用户的所有通知列表,支持:
|
||||||
|
/// - 下拉刷新
|
||||||
|
/// - 点击查看详情并自动标记已读
|
||||||
|
/// - 右上角"全部已读"按钮
|
||||||
|
/// - 空状态 / 错误状态 / 加载状态
|
||||||
|
///
|
||||||
|
/// 适配 2.0 mining-app 的 dark/light 主题。
|
||||||
|
class NotificationInboxPage extends ConsumerStatefulWidget {
|
||||||
|
const NotificationInboxPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<NotificationInboxPage> createState() =>
|
||||||
|
_NotificationInboxPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NotificationInboxPageState extends ConsumerState<NotificationInboxPage> {
|
||||||
|
/// 通知列表
|
||||||
|
List<NotificationItem> _notifications = [];
|
||||||
|
|
||||||
|
/// 未读数量
|
||||||
|
int _unreadCount = 0;
|
||||||
|
|
||||||
|
/// 是否正在加载
|
||||||
|
bool _isLoading = true;
|
||||||
|
|
||||||
|
/// 错误信息
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
// ── 品牌色 ──
|
||||||
|
static const Color _orange = Color(0xFFFF6B00);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadNotifications();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从后端加载通知列表
|
||||||
|
Future<void> _loadNotifications() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final accountSequence = ref.read(currentAccountSequenceProvider);
|
||||||
|
if (accountSequence == null || accountSequence.isEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_error = '用户未登录';
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final notificationService = ref.read(notificationServiceProvider);
|
||||||
|
final response = await notificationService.getNotifications(
|
||||||
|
userSerialNum: accountSequence,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_notifications = response.notifications;
|
||||||
|
_unreadCount = response.unreadCount;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_error = '加载通知失败';
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 标记单条通知为已读
|
||||||
|
Future<void> _markAsRead(NotificationItem notification) async {
|
||||||
|
if (notification.isRead) return;
|
||||||
|
|
||||||
|
final accountSequence = ref.read(currentAccountSequenceProvider);
|
||||||
|
if (accountSequence == null || accountSequence.isEmpty) return;
|
||||||
|
|
||||||
|
final notificationService = ref.read(notificationServiceProvider);
|
||||||
|
final success = await notificationService.markAsRead(
|
||||||
|
userSerialNum: accountSequence,
|
||||||
|
notificationId: notification.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (success && mounted) {
|
||||||
|
setState(() {
|
||||||
|
final index = _notifications.indexWhere((n) => n.id == notification.id);
|
||||||
|
if (index != -1) {
|
||||||
|
_notifications[index] = NotificationItem(
|
||||||
|
id: notification.id,
|
||||||
|
title: notification.title,
|
||||||
|
content: notification.content,
|
||||||
|
type: notification.type,
|
||||||
|
priority: notification.priority,
|
||||||
|
imageUrl: notification.imageUrl,
|
||||||
|
linkUrl: notification.linkUrl,
|
||||||
|
publishedAt: notification.publishedAt,
|
||||||
|
isRead: true,
|
||||||
|
readAt: DateTime.now(),
|
||||||
|
requiresForceRead: notification.requiresForceRead,
|
||||||
|
);
|
||||||
|
_unreadCount = (_unreadCount - 1).clamp(0, _unreadCount);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// 同步更新全局未读角标
|
||||||
|
ref.read(notificationBadgeProvider.notifier).decrementCount();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 标记所有通知为已读
|
||||||
|
Future<void> _markAllAsRead() async {
|
||||||
|
if (_unreadCount == 0) return;
|
||||||
|
|
||||||
|
final accountSequence = ref.read(currentAccountSequenceProvider);
|
||||||
|
if (accountSequence == null || accountSequence.isEmpty) return;
|
||||||
|
|
||||||
|
final notificationService = ref.read(notificationServiceProvider);
|
||||||
|
final success = await notificationService.markAllAsRead(
|
||||||
|
userSerialNum: accountSequence,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (success && mounted) {
|
||||||
|
setState(() {
|
||||||
|
_notifications = _notifications.map((n) {
|
||||||
|
if (!n.isRead) {
|
||||||
|
return NotificationItem(
|
||||||
|
id: n.id,
|
||||||
|
title: n.title,
|
||||||
|
content: n.content,
|
||||||
|
type: n.type,
|
||||||
|
priority: n.priority,
|
||||||
|
imageUrl: n.imageUrl,
|
||||||
|
linkUrl: n.linkUrl,
|
||||||
|
publishedAt: n.publishedAt,
|
||||||
|
isRead: true,
|
||||||
|
readAt: DateTime.now(),
|
||||||
|
requiresForceRead: n.requiresForceRead,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}).toList();
|
||||||
|
_unreadCount = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 同步清空全局未读角标
|
||||||
|
ref.read(notificationBadgeProvider.notifier).clearCount();
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('已全部标记为已读'),
|
||||||
|
backgroundColor: _orange,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 显示通知详情弹窗
|
||||||
|
void _showNotificationDetail(NotificationItem notification) {
|
||||||
|
// 先标记为已读
|
||||||
|
_markAsRead(notification);
|
||||||
|
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
backgroundColor: isDark ? const Color(0xFF1F2937) : Colors.white,
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
notification.typeIcon,
|
||||||
|
style: const TextStyle(fontSize: 20),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
notification.title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: isDark ? const Color(0xFFE5E7EB) : const Color(0xFF1F2937),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
notification.content,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: isDark ? const Color(0xFF9CA3AF) : const Color(0xFF6B7280),
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
_formatTime(notification.publishedAt),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: isDark ? const Color(0xFF6B7280) : const Color(0xFFBDBDBD),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text(
|
||||||
|
'关闭',
|
||||||
|
style: TextStyle(color: _orange),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 格式化相对时间
|
||||||
|
String _formatTime(DateTime? dateTime) {
|
||||||
|
if (dateTime == null) return '';
|
||||||
|
final now = DateTime.now();
|
||||||
|
final diff = now.difference(dateTime);
|
||||||
|
|
||||||
|
if (diff.inMinutes < 1) {
|
||||||
|
return '刚刚';
|
||||||
|
} else if (diff.inHours < 1) {
|
||||||
|
return '${diff.inMinutes}分钟前';
|
||||||
|
} else if (diff.inDays < 1) {
|
||||||
|
return '${diff.inHours}小时前';
|
||||||
|
} else if (diff.inDays < 7) {
|
||||||
|
return '${diff.inDays}天前';
|
||||||
|
} else {
|
||||||
|
return '${dateTime.month}月${dateTime.day}日';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取类型颜色
|
||||||
|
Color _getTypeColor(NotificationType type) {
|
||||||
|
switch (type) {
|
||||||
|
case NotificationType.system:
|
||||||
|
return const Color(0xFF6B7280);
|
||||||
|
case NotificationType.activity:
|
||||||
|
return _orange;
|
||||||
|
case NotificationType.reward:
|
||||||
|
return const Color(0xFFF59E0B);
|
||||||
|
case NotificationType.upgrade:
|
||||||
|
return const Color(0xFF10B981);
|
||||||
|
case NotificationType.announcement:
|
||||||
|
return const Color(0xFF3B82F6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: isDark ? const Color(0xFF111827) : const Color(0xFFF3F4F6),
|
||||||
|
body: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_buildAppBar(isDark),
|
||||||
|
Expanded(
|
||||||
|
child: _isLoading
|
||||||
|
? const Center(
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(_orange),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: _error != null
|
||||||
|
? _buildErrorView(isDark)
|
||||||
|
: _notifications.isEmpty
|
||||||
|
? _buildEmptyView(isDark)
|
||||||
|
: _buildNotificationList(isDark),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 自定义 AppBar
|
||||||
|
Widget _buildAppBar(bool isDark) {
|
||||||
|
return Container(
|
||||||
|
height: 56,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
color: isDark ? const Color(0xFF1F2937) : Colors.white,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => context.pop(),
|
||||||
|
child: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Icon(
|
||||||
|
Icons.arrow_back,
|
||||||
|
size: 24,
|
||||||
|
color: isDark ? const Color(0xFFE5E7EB) : const Color(0xFF1F2937),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'通知中心',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: isDark ? const Color(0xFFE5E7EB) : const Color(0xFF1F2937),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 全部已读按钮
|
||||||
|
if (_unreadCount > 0)
|
||||||
|
GestureDetector(
|
||||||
|
onTap: _markAllAsRead,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _orange.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'全部已读',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: _orange,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 空状态视图
|
||||||
|
Widget _buildEmptyView(bool isDark) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.notifications_none,
|
||||||
|
size: 64,
|
||||||
|
color: isDark ? Colors.grey.shade700 : Colors.grey.shade300,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'暂无通知',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
color: isDark ? Colors.grey.shade600 : Colors.grey.shade500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 错误状态视图
|
||||||
|
Widget _buildErrorView(bool isDark) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.error_outline,
|
||||||
|
size: 64,
|
||||||
|
color: isDark ? Colors.grey.shade700 : Colors.grey.shade300,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
_error ?? '加载失败',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
color: isDark ? Colors.grey.shade600 : Colors.grey.shade500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _loadNotifications,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: _orange,
|
||||||
|
),
|
||||||
|
child: const Text('重试'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 通知列表(支持下拉刷新)
|
||||||
|
Widget _buildNotificationList(bool isDark) {
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: _loadNotifications,
|
||||||
|
color: _orange,
|
||||||
|
child: ListView.separated(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: _notifications.length,
|
||||||
|
separatorBuilder: (context, index) => const SizedBox(height: 12),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final notification = _notifications[index];
|
||||||
|
return _buildNotificationCard(notification, isDark);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单条通知卡片
|
||||||
|
Widget _buildNotificationCard(NotificationItem notification, bool isDark) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => _showNotificationDetail(notification),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: notification.isRead
|
||||||
|
? (isDark ? const Color(0xFF1F2937) : Colors.white)
|
||||||
|
: (isDark ? const Color(0xFF374151) : const Color(0xFFFFF3E0)),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: notification.isRead
|
||||||
|
? (isDark ? const Color(0xFF374151) : const Color(0xFFE0E0E0))
|
||||||
|
: _orange.withValues(alpha: 0.3),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: isDark ? 0.15 : 0.05),
|
||||||
|
blurRadius: 4,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// 类型图标圆圈
|
||||||
|
Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _getTypeColor(notification.type).withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
notification.typeIcon,
|
||||||
|
style: const TextStyle(fontSize: 20),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
// 内容区域
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
// 未读红点
|
||||||
|
if (!notification.isRead)
|
||||||
|
Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
margin: const EdgeInsets.only(right: 8),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: _orange,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 标题
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
notification.title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: notification.isRead
|
||||||
|
? FontWeight.w500
|
||||||
|
: FontWeight.w600,
|
||||||
|
color: isDark ? const Color(0xFFE5E7EB) : const Color(0xFF1F2937),
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
// 内容预览
|
||||||
|
Text(
|
||||||
|
notification.content,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: notification.isRead
|
||||||
|
? (isDark ? const Color(0xFF6B7280) : const Color(0xFFBDBDBD))
|
||||||
|
: (isDark ? const Color(0xFF9CA3AF) : const Color(0xFF6B7280)),
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
// 类型标签 + 时间
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
notification.typeName,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: _getTypeColor(notification.type),
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
_formatTime(notification.publishedAt),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: isDark ? const Color(0xFF6B7280) : const Color(0xFFBDBDBD),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 右箭头
|
||||||
|
Icon(
|
||||||
|
Icons.chevron_right,
|
||||||
|
size: 20,
|
||||||
|
color: isDark ? const Color(0xFF6B7280) : const Color(0xFFBDBDBD),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import '../../providers/user_providers.dart';
|
||||||
import '../../providers/profile_providers.dart';
|
import '../../providers/profile_providers.dart';
|
||||||
import '../../providers/settings_providers.dart';
|
import '../../providers/settings_providers.dart';
|
||||||
import '../../providers/mining_providers.dart';
|
import '../../providers/mining_providers.dart';
|
||||||
|
import '../../providers/notification_providers.dart';
|
||||||
import '../../widgets/shimmer_loading.dart';
|
import '../../widgets/shimmer_loading.dart';
|
||||||
|
|
||||||
class ProfilePage extends ConsumerWidget {
|
class ProfilePage extends ConsumerWidget {
|
||||||
|
|
@ -228,7 +229,8 @@ class ProfilePage extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// 编辑按钮
|
// 通知图标(含未读角标)+ 编辑按钮
|
||||||
|
_buildNotificationIcon(context, ref),
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () => context.push(Routes.editProfile),
|
onPressed: () => context.push(Routes.editProfile),
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
|
|
@ -241,6 +243,48 @@ class ProfilePage extends ConsumerWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 构建通知图标(含未读数量红色角标)
|
||||||
|
Widget _buildNotificationIcon(BuildContext context, WidgetRef ref) {
|
||||||
|
final unreadCount = ref.watch(unreadNotificationCountProvider);
|
||||||
|
|
||||||
|
return IconButton(
|
||||||
|
onPressed: () => context.push(Routes.notifications),
|
||||||
|
icon: Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.notifications_outlined,
|
||||||
|
color: _grayText(context),
|
||||||
|
),
|
||||||
|
// 未读角标(红色圆点/数字)
|
||||||
|
if (unreadCount > 0)
|
||||||
|
Positioned(
|
||||||
|
right: -4,
|
||||||
|
top: -4,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(2),
|
||||||
|
constraints: const BoxConstraints(minWidth: 16, minHeight: 16),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: _red,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
unreadCount > 99 ? '99+' : '$unreadCount',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildStatsRow(BuildContext context, UserStats? stats, bool isLoading) {
|
Widget _buildStatsRow(BuildContext context, UserStats? stats, bool isLoading) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../../core/di/injection.dart';
|
||||||
|
import '../../core/services/notification_service.dart';
|
||||||
|
import 'user_providers.dart';
|
||||||
|
|
||||||
|
/// ==================== 通知服务 Provider ====================
|
||||||
|
|
||||||
|
/// NotificationService 单例 Provider(从 GetIt 获取 ApiClient 构建)
|
||||||
|
final notificationServiceProvider = Provider<NotificationService>((ref) {
|
||||||
|
return NotificationService(apiClient: getIt());
|
||||||
|
});
|
||||||
|
|
||||||
|
/// ==================== 未读通知角标 ====================
|
||||||
|
|
||||||
|
/// 未读通知数量状态
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 未读通知数量管理器
|
||||||
|
///
|
||||||
|
/// 功能:
|
||||||
|
/// - 初始化时立即加载未读数量
|
||||||
|
/// - 每 30 秒自动轮询刷新
|
||||||
|
/// - 监听 App 生命周期,前台恢复时立即刷新
|
||||||
|
/// - 提供手动刷新、递减、清零方法供其他组件调用
|
||||||
|
///
|
||||||
|
/// 数据源:通过 [NotificationService.getUnreadCount] 调用后端 API
|
||||||
|
/// 用户标识:从 [currentAccountSequenceProvider] 获取当前登录用户的 accountSequence
|
||||||
|
class NotificationBadgeNotifier extends StateNotifier<NotificationBadgeState>
|
||||||
|
with WidgetsBindingObserver {
|
||||||
|
final NotificationService _notificationService;
|
||||||
|
final Ref _ref;
|
||||||
|
Timer? _refreshTimer;
|
||||||
|
|
||||||
|
/// 定时刷新间隔(30秒,与 1.0 一致)
|
||||||
|
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) {
|
||||||
|
debugPrint('[NotificationBadge] App 恢复前台,刷新未读数量');
|
||||||
|
_loadUnreadCount();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 启动 30 秒自动轮询
|
||||||
|
void _startAutoRefresh() {
|
||||||
|
_refreshTimer?.cancel();
|
||||||
|
_refreshTimer = Timer.periodic(
|
||||||
|
const Duration(seconds: _refreshIntervalSeconds),
|
||||||
|
(_) => _loadUnreadCount(),
|
||||||
|
);
|
||||||
|
debugPrint('[NotificationBadge] 自动刷新已启动 (间隔: ${_refreshIntervalSeconds}s)');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 停止自动刷新(账号切换时调用,避免混账号请求)
|
||||||
|
void stopAutoRefresh() {
|
||||||
|
_refreshTimer?.cancel();
|
||||||
|
_refreshTimer = null;
|
||||||
|
debugPrint('[NotificationBadge] 自动刷新已停止(切换账号)');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从后端加载未读通知数量
|
||||||
|
Future<void> _loadUnreadCount() async {
|
||||||
|
try {
|
||||||
|
// 使用 2.0 的 currentAccountSequenceProvider 获取用户账号
|
||||||
|
final accountSequence = _ref.read(currentAccountSequenceProvider);
|
||||||
|
if (accountSequence == null || accountSequence.isEmpty) {
|
||||||
|
state = state.copyWith(unreadCount: 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state = state.copyWith(isLoading: true);
|
||||||
|
|
||||||
|
final count = await _notificationService.getUnreadCount(
|
||||||
|
userSerialNum: accountSequence,
|
||||||
|
);
|
||||||
|
|
||||||
|
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: 只获取未读数量(供 UI 直接使用)
|
||||||
|
final unreadNotificationCountProvider = Provider<int>((ref) {
|
||||||
|
return ref.watch(notificationBadgeProvider).unreadCount;
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,317 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../core/services/notification_service.dart';
|
||||||
|
|
||||||
|
/// 强制阅读通知弹窗
|
||||||
|
///
|
||||||
|
/// 用于在用户打开 APP 或从后台恢复时,强制展示标记了 [requiresForceRead] 的未读通知。
|
||||||
|
/// 用户必须逐条查看,并在最后一条勾选「我已经阅读并知晓」后才能关闭。
|
||||||
|
///
|
||||||
|
/// 使用方式:
|
||||||
|
/// ```dart
|
||||||
|
/// await ForceReadNotificationDialog.show(
|
||||||
|
/// context: context,
|
||||||
|
/// notification: item,
|
||||||
|
/// currentIndex: 1,
|
||||||
|
/// totalCount: 3,
|
||||||
|
/// );
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// 品牌色:橙色 #FF6B00(与 2.0 mining-app 整体风格一致)
|
||||||
|
class ForceReadNotificationDialog extends StatefulWidget {
|
||||||
|
final NotificationItem notification;
|
||||||
|
final int currentIndex;
|
||||||
|
final int totalCount;
|
||||||
|
|
||||||
|
const ForceReadNotificationDialog._({
|
||||||
|
super.key,
|
||||||
|
required this.notification,
|
||||||
|
required this.currentIndex,
|
||||||
|
required this.totalCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 显示单条强制阅读弹窗
|
||||||
|
///
|
||||||
|
/// [currentIndex] 和 [totalCount] 用于显示进度(如 "2/5")。
|
||||||
|
/// 当 [currentIndex] == [totalCount] 时为最后一条,显示 checkbox + 确定按钮。
|
||||||
|
static Future<void> show({
|
||||||
|
required BuildContext context,
|
||||||
|
required NotificationItem notification,
|
||||||
|
required int currentIndex,
|
||||||
|
required int totalCount,
|
||||||
|
}) {
|
||||||
|
return showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
barrierColor: const Color(0x99000000),
|
||||||
|
builder: (context) => ForceReadNotificationDialog._(
|
||||||
|
notification: notification,
|
||||||
|
currentIndex: currentIndex,
|
||||||
|
totalCount: totalCount,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ForceReadNotificationDialog> createState() =>
|
||||||
|
_ForceReadNotificationDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ForceReadNotificationDialogState
|
||||||
|
extends State<ForceReadNotificationDialog> {
|
||||||
|
bool _isAcknowledged = false;
|
||||||
|
|
||||||
|
/// 品牌主色(橙色,与 2.0 APP 一致)
|
||||||
|
static const Color _brandColor = Color(0xFFFF6B00);
|
||||||
|
|
||||||
|
/// 品牌浅色背景
|
||||||
|
static const Color _brandLightBg = Color(0xFFFFF3E0);
|
||||||
|
|
||||||
|
bool get _isLast => widget.currentIndex == widget.totalCount;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
return PopScope(
|
||||||
|
canPop: false,
|
||||||
|
child: Dialog(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 32),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 420, maxHeight: 580),
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isDark ? const Color(0xFF1F2937) : Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.18),
|
||||||
|
blurRadius: 24,
|
||||||
|
offset: const Offset(0, 8),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// ── 顶部标题栏 ──
|
||||||
|
_buildHeader(isDark),
|
||||||
|
Divider(
|
||||||
|
height: 1,
|
||||||
|
color: isDark ? const Color(0xFF374151) : const Color(0xFFEEEEEE),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── 通知标题 ──
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 14, 20, 6),
|
||||||
|
child: Text(
|
||||||
|
widget.notification.title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: isDark ? const Color(0xFFE5E7EB) : const Color(0xFF1F2937),
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── 可滚动内容区 ──
|
||||||
|
Flexible(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 4, 20, 16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.notification.content,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: isDark ? const Color(0xFF9CA3AF) : const Color(0xFF6B7280),
|
||||||
|
height: 1.7,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (widget.notification.publishedAt != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
_formatTime(widget.notification.publishedAt!),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: isDark ? const Color(0xFF6B7280) : const Color(0xFFBDBDBD),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
Divider(
|
||||||
|
height: 1,
|
||||||
|
color: isDark ? const Color(0xFF374151) : const Color(0xFFEEEEEE),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── 底部操作区 ──
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 14, 20, 20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// 最后一条才显示 checkbox
|
||||||
|
if (_isLast) ...[
|
||||||
|
_buildAcknowledgeCheckbox(isDark),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
_buildActionButton(context),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 顶部标题栏:类型图标 + 类型名称 + 进度指示器
|
||||||
|
Widget _buildHeader(bool isDark) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 16, 16, 14),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// 类型图标圆圈
|
||||||
|
Container(
|
||||||
|
width: 38,
|
||||||
|
height: 38,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _brandLightBg,
|
||||||
|
borderRadius: BorderRadius.circular(19),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
widget.notification.typeIcon,
|
||||||
|
style: const TextStyle(fontSize: 18),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
|
||||||
|
// 类型名称
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
widget.notification.typeName,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: isDark ? const Color(0xFFE5E7EB) : const Color(0xFF1F2937),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 进度指示器(多条时才显示)
|
||||||
|
if (widget.totalCount > 1)
|
||||||
|
Container(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _brandLightBg,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: _brandColor.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'${widget.currentIndex}/${widget.totalCount}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: _brandColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 已读确认复选框(仅最后一条显示)
|
||||||
|
Widget _buildAcknowledgeCheckbox(bool isDark) {
|
||||||
|
return InkWell(
|
||||||
|
onTap: () => setState(() => _isAcknowledged = !_isAcknowledged),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
child: Checkbox(
|
||||||
|
value: _isAcknowledged,
|
||||||
|
onChanged: (v) =>
|
||||||
|
setState(() => _isAcknowledged = v ?? false),
|
||||||
|
activeColor: _brandColor,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'我已经阅读并知晓',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: isDark ? const Color(0xFFD1D5DB) : const Color(0xFF374151),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 操作按钮:非最后一条 → "下一条 ▶",最后一条 → "确定"(需勾选后启用)
|
||||||
|
Widget _buildActionButton(BuildContext context) {
|
||||||
|
final isEnabled = !_isLast || _isAcknowledged;
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 48,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: isEnabled ? () => Navigator.of(context).pop() : null,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: _brandColor,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
disabledBackgroundColor:
|
||||||
|
_brandColor.withValues(alpha: 0.35),
|
||||||
|
disabledForegroundColor: Colors.white.withValues(alpha: 0.6),
|
||||||
|
elevation: isEnabled ? 2 : 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_isLast ? '确定' : '下一条 ▶',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 格式化发布时间
|
||||||
|
String _formatTime(DateTime time) {
|
||||||
|
return '${time.year}-'
|
||||||
|
'${time.month.toString().padLeft(2, '0')}-'
|
||||||
|
'${time.day.toString().padLeft(2, '0')} '
|
||||||
|
'${time.hour.toString().padLeft(2, '0')}:'
|
||||||
|
'${time.minute.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,24 +1,40 @@
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../core/router/routes.dart';
|
import '../../core/router/routes.dart';
|
||||||
import '../../core/constants/app_colors.dart';
|
import '../../core/constants/app_colors.dart';
|
||||||
import '../../core/updater/update_service.dart';
|
import '../../core/updater/update_service.dart';
|
||||||
import '../../core/updater/channels/self_hosted_updater.dart';
|
import '../../core/updater/channels/self_hosted_updater.dart';
|
||||||
|
import '../../core/services/notification_service.dart';
|
||||||
|
import '../providers/notification_providers.dart';
|
||||||
|
import '../providers/user_providers.dart';
|
||||||
|
import 'force_read_notification_dialog.dart';
|
||||||
|
|
||||||
class MainShell extends StatefulWidget {
|
/// 主壳 Widget,包含底部导航栏和强制通知弹窗检查
|
||||||
|
///
|
||||||
|
/// 在 APP 启动和从后台恢复前台时:
|
||||||
|
/// 1. 检查版本更新(已有逻辑)
|
||||||
|
/// 2. 检查并展示强制阅读通知弹窗(新增逻辑)
|
||||||
|
class MainShell extends ConsumerStatefulWidget {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
|
||||||
const MainShell({super.key, required this.child});
|
const MainShell({super.key, required this.child});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<MainShell> createState() => _MainShellState();
|
ConsumerState<MainShell> createState() => _MainShellState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MainShellState extends State<MainShell> with WidgetsBindingObserver {
|
class _MainShellState extends ConsumerState<MainShell> with WidgetsBindingObserver {
|
||||||
/// 下次允许检查更新的时间(static 防止 widget rebuild 重置)
|
/// 下次允许检查更新的时间(static 防止 widget rebuild 重置)
|
||||||
static DateTime? _nextCheckAllowedTime;
|
static DateTime? _nextCheckAllowedTime;
|
||||||
|
|
||||||
|
/// 强制阅读弹窗互斥锁:防止同时弹出多个弹窗
|
||||||
|
static bool _isShowingForceReadDialog = false;
|
||||||
|
|
||||||
|
/// 上次展示强制阅读弹窗的时间(60秒冷却,防止快速切后台重弹)
|
||||||
|
static DateTime? _lastForceReadDialogShownAt;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
@ -27,6 +43,10 @@ class _MainShellState extends State<MainShell> with WidgetsBindingObserver {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
debugPrint('[MainShell] postFrameCallback → 触发首次更新检查');
|
debugPrint('[MainShell] postFrameCallback → 触发首次更新检查');
|
||||||
_checkForUpdateIfNeeded();
|
_checkForUpdateIfNeeded();
|
||||||
|
// 延迟 5 秒后检查强制阅读通知,避免和更新弹窗冲突
|
||||||
|
Future.delayed(const Duration(seconds: 5), () {
|
||||||
|
if (mounted) _checkAndShowForceReadDialog();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,9 +62,13 @@ class _MainShellState extends State<MainShell> with WidgetsBindingObserver {
|
||||||
debugPrint('[MainShell] didChangeAppLifecycleState: $state');
|
debugPrint('[MainShell] didChangeAppLifecycleState: $state');
|
||||||
if (state == AppLifecycleState.resumed) {
|
if (state == AppLifecycleState.resumed) {
|
||||||
_checkForUpdateIfNeeded();
|
_checkForUpdateIfNeeded();
|
||||||
|
// 恢复前台时也检查强制阅读通知
|
||||||
|
_checkAndShowForceReadDialog();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ==================== 版本更新检查(原有逻辑) ====================
|
||||||
|
|
||||||
Future<void> _checkForUpdateIfNeeded() async {
|
Future<void> _checkForUpdateIfNeeded() async {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
|
|
||||||
|
|
@ -87,6 +111,81 @@ class _MainShellState extends State<MainShell> with WidgetsBindingObserver {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ==================== 强制阅读通知弹窗(新增逻辑) ====================
|
||||||
|
///
|
||||||
|
/// 仅展示标记了 [requiresForceRead] 且尚未读的通知。
|
||||||
|
/// 用户必须逐条查看,最后一条勾选"我已经阅读并知晓"后方可关闭。
|
||||||
|
/// 全部确认后逐条调用 markAsRead 并刷新未读角标。
|
||||||
|
Future<void> _checkAndShowForceReadDialog() async {
|
||||||
|
// 防护 1:已有强制阅读弹窗正在展示
|
||||||
|
if (_isShowingForceReadDialog) return;
|
||||||
|
|
||||||
|
// 防护 2:60 秒内不重复触发(防止快速切后台后立刻重弹)
|
||||||
|
if (_lastForceReadDialogShownAt != null &&
|
||||||
|
DateTime.now().difference(_lastForceReadDialogShownAt!) <
|
||||||
|
const Duration(seconds: 60)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 防护 3:用户未登录
|
||||||
|
final accountSequence = ref.read(currentAccountSequenceProvider);
|
||||||
|
if (accountSequence == null || accountSequence.isEmpty) return;
|
||||||
|
|
||||||
|
// 获取未读且需要强制阅读的通知
|
||||||
|
List<NotificationItem> forceReadList;
|
||||||
|
try {
|
||||||
|
final notifService = ref.read(notificationServiceProvider);
|
||||||
|
final response = await notifService.getNotifications(
|
||||||
|
userSerialNum: accountSequence,
|
||||||
|
limit: 20,
|
||||||
|
);
|
||||||
|
forceReadList = response.notifications
|
||||||
|
.where((n) => !n.isRead && n.requiresForceRead)
|
||||||
|
.toList();
|
||||||
|
} catch (_) {
|
||||||
|
// API 失败时静默处理,不阻断用户使用
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (forceReadList.isEmpty || !mounted) return;
|
||||||
|
|
||||||
|
_isShowingForceReadDialog = true;
|
||||||
|
_lastForceReadDialogShownAt = DateTime.now();
|
||||||
|
|
||||||
|
// 逐条展示强制阅读弹窗
|
||||||
|
for (int i = 0; i < forceReadList.length; i++) {
|
||||||
|
if (!mounted) break;
|
||||||
|
await ForceReadNotificationDialog.show(
|
||||||
|
context: context,
|
||||||
|
notification: forceReadList[i],
|
||||||
|
currentIndex: i + 1,
|
||||||
|
totalCount: forceReadList.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_isShowingForceReadDialog = false;
|
||||||
|
|
||||||
|
// 全部通知看完后,逐条标记已读(不影响其他未读普通通知)
|
||||||
|
if (mounted) {
|
||||||
|
try {
|
||||||
|
final notifService = ref.read(notificationServiceProvider);
|
||||||
|
final currentAccount = ref.read(currentAccountSequenceProvider);
|
||||||
|
if (currentAccount != null && currentAccount.isNotEmpty) {
|
||||||
|
for (final n in forceReadList) {
|
||||||
|
await notifService.markAsRead(
|
||||||
|
userSerialNum: currentAccount,
|
||||||
|
notificationId: n.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 刷新真实未读数量(非直接清零,避免误清其他未读通知的 badge)
|
||||||
|
ref.read(notificationBadgeProvider.notifier).refresh();
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// 标记失败时静默处理;下次打开 App 仍会重新检查
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isDark = AppColors.isDark(context);
|
final isDark = AppColors.isDark(context);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue