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])
|
||||
@@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 { PoolAccountController } from './controllers/pool-account.controller';
|
||||
import { CapabilityController } from './controllers/capability.controller';
|
||||
import { AdminNotificationController, MobileNotificationController } from './controllers/notification.controller';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -44,6 +45,8 @@ import { CapabilityController } from './controllers/capability.controller';
|
|||
MobileVersionController,
|
||||
PoolAccountController,
|
||||
CapabilityController,
|
||||
AdminNotificationController,
|
||||
MobileNotificationController,
|
||||
],
|
||||
})
|
||||
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 { VersionService } from './services/version.service';
|
||||
import { CapabilityAdminService } from './services/capability-admin.service';
|
||||
import { NotificationService } from './services/notification.service';
|
||||
|
||||
@Module({
|
||||
imports: [InfrastructureModule],
|
||||
|
|
@ -26,6 +27,7 @@ import { CapabilityAdminService } from './services/capability-admin.service';
|
|||
BatchMiningService,
|
||||
VersionService,
|
||||
CapabilityAdminService,
|
||||
NotificationService,
|
||||
],
|
||||
exports: [
|
||||
AuthService,
|
||||
|
|
@ -39,6 +41,7 @@ import { CapabilityAdminService } from './services/capability-admin.service';
|
|||
BatchMiningService,
|
||||
VersionService,
|
||||
CapabilityAdminService,
|
||||
NotificationService,
|
||||
],
|
||||
})
|
||||
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,
|
||||
HardDrive,
|
||||
Repeat,
|
||||
Bell,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
|
|
@ -34,6 +35,7 @@ const menuItems = [
|
|||
{ name: 'C2C Bot', href: '/c2c-bot', icon: Zap },
|
||||
{ name: '手工补发', href: '/manual-mining', icon: HandCoins },
|
||||
{ name: '批量补发', href: '/batch-mining', icon: FileSpreadsheet },
|
||||
{ name: '通知管理', href: '/notifications', icon: Bell },
|
||||
{ name: '配置管理', href: '/configs', icon: Settings },
|
||||
{ name: '系统账户', href: '/system-accounts', icon: Building2 },
|
||||
{ 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/profile/help_center_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/providers/user_providers.dart';
|
||||
import 'routes.dart';
|
||||
|
|
@ -179,6 +180,10 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
|||
filterDirection: state.uri.queryParameters['filter'],
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.notifications,
|
||||
builder: (context, state) => const NotificationInboxPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.helpCenter,
|
||||
builder: (context, state) => const HelpCenterPage(),
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ class Routes {
|
|||
static const String transferRecords = '/transfer-records';
|
||||
// P2P转账记录
|
||||
static const String p2pTransferRecords = '/p2p-transfer-records';
|
||||
// 通知
|
||||
static const String notifications = '/notifications';
|
||||
// 其他设置
|
||||
static const String helpCenter = '/help-center';
|
||||
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/settings_providers.dart';
|
||||
import '../../providers/mining_providers.dart';
|
||||
import '../../providers/notification_providers.dart';
|
||||
import '../../widgets/shimmer_loading.dart';
|
||||
|
||||
class ProfilePage extends ConsumerWidget {
|
||||
|
|
@ -228,7 +229,8 @@ class ProfilePage extends ConsumerWidget {
|
|||
),
|
||||
),
|
||||
|
||||
// 编辑按钮
|
||||
// 通知图标(含未读角标)+ 编辑按钮
|
||||
_buildNotificationIcon(context, ref),
|
||||
IconButton(
|
||||
onPressed: () => context.push(Routes.editProfile),
|
||||
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) {
|
||||
return Container(
|
||||
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 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../core/router/routes.dart';
|
||||
import '../../core/constants/app_colors.dart';
|
||||
import '../../core/updater/update_service.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;
|
||||
|
||||
const MainShell({super.key, required this.child});
|
||||
|
||||
@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 DateTime? _nextCheckAllowedTime;
|
||||
|
||||
/// 强制阅读弹窗互斥锁:防止同时弹出多个弹窗
|
||||
static bool _isShowingForceReadDialog = false;
|
||||
|
||||
/// 上次展示强制阅读弹窗的时间(60秒冷却,防止快速切后台重弹)
|
||||
static DateTime? _lastForceReadDialogShownAt;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
|
@ -27,6 +43,10 @@ class _MainShellState extends State<MainShell> with WidgetsBindingObserver {
|
|||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
debugPrint('[MainShell] postFrameCallback → 触发首次更新检查');
|
||||
_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');
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
_checkForUpdateIfNeeded();
|
||||
// 恢复前台时也检查强制阅读通知
|
||||
_checkAndShowForceReadDialog();
|
||||
}
|
||||
}
|
||||
|
||||
/// ==================== 版本更新检查(原有逻辑) ====================
|
||||
|
||||
Future<void> _checkForUpdateIfNeeded() async {
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = AppColors.isDark(context);
|
||||
|
|
|
|||
Loading…
Reference in New Issue