diff --git a/backend/services/admin-service/prisma/migrations/20250102200000_add_notifications/migration.sql b/backend/services/admin-service/prisma/migrations/20250102200000_add_notifications/migration.sql new file mode 100644 index 00000000..d2f31405 --- /dev/null +++ b/backend/services/admin-service/prisma/migrations/20250102200000_add_notifications/migration.sql @@ -0,0 +1,53 @@ +-- CreateEnum +CREATE TYPE "NotificationType" AS ENUM ('SYSTEM', 'ACTIVITY', 'REWARD', 'UPGRADE', 'ANNOUNCEMENT'); + +-- CreateEnum +CREATE TYPE "NotificationPriority" AS ENUM ('LOW', 'NORMAL', 'HIGH', 'URGENT'); + +-- CreateEnum +CREATE TYPE "TargetType" AS ENUM ('ALL', 'NEW_USER', 'VIP'); + +-- CreateTable +CREATE TABLE "notifications" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "content" TEXT NOT NULL, + "type" "NotificationType" NOT NULL, + "priority" "NotificationPriority" NOT NULL DEFAULT 'NORMAL', + "targetType" "TargetType" NOT NULL DEFAULT 'ALL', + "imageUrl" TEXT, + "linkUrl" TEXT, + "isEnabled" BOOLEAN NOT NULL DEFAULT true, + "publishedAt" TIMESTAMP(3), + "expiresAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "createdBy" TEXT NOT NULL, + + CONSTRAINT "notifications_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "notification_reads" ( + "id" TEXT NOT NULL, + "notificationId" TEXT NOT NULL, + "userSerialNum" TEXT NOT NULL, + "readAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "notification_reads_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "notifications_isEnabled_publishedAt_idx" ON "notifications"("isEnabled", "publishedAt"); + +-- CreateIndex +CREATE INDEX "notifications_type_idx" ON "notifications"("type"); + +-- CreateIndex +CREATE INDEX "notification_reads_userSerialNum_idx" ON "notification_reads"("userSerialNum"); + +-- CreateIndex +CREATE UNIQUE INDEX "notification_reads_notificationId_userSerialNum_key" ON "notification_reads"("notificationId", "userSerialNum"); + +-- AddForeignKey +ALTER TABLE "notification_reads" ADD CONSTRAINT "notification_reads_notificationId_fkey" FOREIGN KEY ("notificationId") REFERENCES "notifications"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/services/admin-service/prisma/schema.prisma b/backend/services/admin-service/prisma/schema.prisma index e1473b89..fd593263 100644 --- a/backend/services/admin-service/prisma/schema.prisma +++ b/backend/services/admin-service/prisma/schema.prisma @@ -43,3 +43,70 @@ enum Platform { ANDROID IOS } + +// ============================================================================= +// Notification System (通知系统) +// ============================================================================= + +/// 系统通知 - 管理员发布的公告/通知 +model Notification { + id String @id @default(uuid()) + title String // 通知标题 + content String // 通知内容 + type NotificationType // 通知类型 + priority NotificationPriority @default(NORMAL) // 优先级 + targetType TargetType @default(ALL) // 目标用户类型 + imageUrl String? // 可选的图片URL + linkUrl String? // 可选的跳转链接 + isEnabled Boolean @default(true) // 是否启用 + publishedAt DateTime? // 发布时间(null表示草稿) + expiresAt DateTime? // 过期时间(null表示永不过期) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + createdBy String // 创建人ID + + // 用户已读记录 + readRecords NotificationRead[] + + @@index([isEnabled, publishedAt]) + @@index([type]) + @@map("notifications") +} + +/// 用户已读记录 +model NotificationRead { + id String @id @default(uuid()) + notificationId String + userSerialNum String // 用户序列号 + readAt DateTime @default(now()) + + notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade) + + @@unique([notificationId, userSerialNum]) + @@index([userSerialNum]) + @@map("notification_reads") +} + +/// 通知类型 +enum NotificationType { + SYSTEM // 系统通知 + ACTIVITY // 活动通知 + REWARD // 收益通知 + UPGRADE // 升级通知 + ANNOUNCEMENT // 公告 +} + +/// 通知优先级 +enum NotificationPriority { + LOW + NORMAL + HIGH + URGENT +} + +/// 目标用户类型 +enum TargetType { + ALL // 所有用户 + NEW_USER // 新用户 + VIP // VIP用户 +} diff --git a/backend/services/admin-service/src/api/controllers/notification.controller.ts b/backend/services/admin-service/src/api/controllers/notification.controller.ts new file mode 100644 index 00000000..27ec2633 --- /dev/null +++ b/backend/services/admin-service/src/api/controllers/notification.controller.ts @@ -0,0 +1,202 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Query, + HttpCode, + HttpStatus, + Inject, +} from '@nestjs/common'; +import { v4 as uuidv4 } from 'uuid'; +import { + NOTIFICATION_REPOSITORY, + NotificationRepository, +} from '../../domain/repositories/notification.repository'; +import { NotificationEntity } from '../../domain/entities/notification.entity'; +import { + CreateNotificationDto, + UpdateNotificationDto, + ListNotificationsDto, + UserNotificationsDto, + MarkReadDto, +} from '../dto/request/notification.dto'; +import { + NotificationResponseDto, + UserNotificationResponseDto, + UnreadCountResponseDto, + NotificationListResponseDto, +} from '../dto/response/notification.dto'; + +/** + * 管理端通知控制器 + */ +@Controller('admin/notifications') +export class AdminNotificationController { + constructor( + @Inject(NOTIFICATION_REPOSITORY) + private readonly notificationRepo: NotificationRepository, + ) {} + + /** + * 创建通知 + */ + @Post() + async create(@Body() dto: CreateNotificationDto): Promise { + const notification = NotificationEntity.create({ + id: uuidv4(), + title: dto.title, + content: dto.content, + type: dto.type, + priority: dto.priority, + targetType: dto.targetType, + imageUrl: dto.imageUrl, + linkUrl: dto.linkUrl, + publishedAt: dto.publishedAt ? new Date(dto.publishedAt) : null, + expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : null, + createdBy: 'admin', // TODO: 从认证信息获取 + }); + + const saved = await this.notificationRepo.save(notification); + return NotificationResponseDto.fromEntity(saved); + } + + /** + * 获取通知详情 + */ + @Get(':id') + async findOne(@Param('id') id: string): Promise { + const notification = await this.notificationRepo.findById(id); + if (!notification) { + throw new Error('Notification not found'); + } + return NotificationResponseDto.fromEntity(notification); + } + + /** + * 获取通知列表(管理端) + */ + @Get() + async findAll(@Query() dto: ListNotificationsDto): Promise { + const notifications = await this.notificationRepo.findAll({ + type: dto.type, + limit: dto.limit, + offset: dto.offset, + }); + return notifications.map(NotificationResponseDto.fromEntity); + } + + /** + * 更新通知 + */ + @Put(':id') + async update( + @Param('id') id: string, + @Body() dto: UpdateNotificationDto, + ): Promise { + const existing = await this.notificationRepo.findById(id); + if (!existing) { + throw new Error('Notification not found'); + } + + const updated = new NotificationEntity( + existing.id, + dto.title ?? existing.title, + dto.content ?? existing.content, + dto.type ?? existing.type, + dto.priority ?? existing.priority, + dto.targetType ?? existing.targetType, + dto.imageUrl !== undefined ? dto.imageUrl : existing.imageUrl, + dto.linkUrl !== undefined ? dto.linkUrl : existing.linkUrl, + dto.isEnabled ?? existing.isEnabled, + dto.publishedAt !== undefined + ? dto.publishedAt + ? new Date(dto.publishedAt) + : null + : existing.publishedAt, + dto.expiresAt !== undefined + ? dto.expiresAt + ? new Date(dto.expiresAt) + : null + : existing.expiresAt, + existing.createdAt, + new Date(), + existing.createdBy, + ); + + const saved = await this.notificationRepo.save(updated); + return NotificationResponseDto.fromEntity(saved); + } + + /** + * 删除通知 + */ + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + async delete(@Param('id') id: string): Promise { + await this.notificationRepo.delete(id); + } +} + +/** + * 移动端通知控制器 + */ +@Controller('mobile/notifications') +export class MobileNotificationController { + constructor( + @Inject(NOTIFICATION_REPOSITORY) + private readonly notificationRepo: NotificationRepository, + ) {} + + /** + * 获取用户的通知列表 + */ + @Get() + async getNotifications( + @Query() dto: UserNotificationsDto, + ): Promise { + const [notifications, unreadCount] = await Promise.all([ + this.notificationRepo.findNotificationsForUser({ + userSerialNum: dto.userSerialNum, + type: dto.type, + limit: dto.limit ?? 50, + offset: dto.offset ?? 0, + }), + this.notificationRepo.countUnreadForUser(dto.userSerialNum), + ]); + + return { + notifications: notifications.map(UserNotificationResponseDto.fromEntity), + total: notifications.length, + unreadCount, + }; + } + + /** + * 获取未读通知数量 + */ + @Get('unread-count') + async getUnreadCount( + @Query('userSerialNum') userSerialNum: string, + ): Promise { + const unreadCount = await this.notificationRepo.countUnreadForUser(userSerialNum); + return { unreadCount }; + } + + /** + * 标记通知为已读 + */ + @Post('mark-read') + @HttpCode(HttpStatus.OK) + async markRead(@Body() dto: MarkReadDto): Promise<{ success: boolean }> { + if (dto.notificationId) { + await this.notificationRepo.markAsRead(dto.notificationId, dto.userSerialNum); + } else { + await this.notificationRepo.markAllAsRead(dto.userSerialNum); + } + return { success: true }; + } +} diff --git a/backend/services/admin-service/src/api/dto/request/notification.dto.ts b/backend/services/admin-service/src/api/dto/request/notification.dto.ts new file mode 100644 index 00000000..e68fadfd --- /dev/null +++ b/backend/services/admin-service/src/api/dto/request/notification.dto.ts @@ -0,0 +1,140 @@ +import { IsString, IsOptional, IsEnum, IsBoolean, IsDateString, IsInt, Min, Max } from 'class-validator'; +import { NotificationType, NotificationPriority, TargetType } from '../../../domain/entities/notification.entity'; + +/** + * 创建通知请求 + */ +export class CreateNotificationDto { + @IsString() + title: string; + + @IsString() + content: string; + + @IsEnum(NotificationType) + type: NotificationType; + + @IsOptional() + @IsEnum(NotificationPriority) + priority?: NotificationPriority; + + @IsOptional() + @IsEnum(TargetType) + targetType?: TargetType; + + @IsOptional() + @IsString() + imageUrl?: string; + + @IsOptional() + @IsString() + linkUrl?: string; + + @IsOptional() + @IsDateString() + publishedAt?: string; + + @IsOptional() + @IsDateString() + expiresAt?: string; +} + +/** + * 更新通知请求 + */ +export class UpdateNotificationDto { + @IsOptional() + @IsString() + title?: string; + + @IsOptional() + @IsString() + content?: string; + + @IsOptional() + @IsEnum(NotificationType) + type?: NotificationType; + + @IsOptional() + @IsEnum(NotificationPriority) + priority?: NotificationPriority; + + @IsOptional() + @IsEnum(TargetType) + targetType?: TargetType; + + @IsOptional() + @IsString() + imageUrl?: string; + + @IsOptional() + @IsString() + linkUrl?: string; + + @IsOptional() + @IsBoolean() + isEnabled?: boolean; + + @IsOptional() + @IsDateString() + publishedAt?: string; + + @IsOptional() + @IsDateString() + expiresAt?: string; +} + +/** + * 查询通知列表请求 + */ +export class ListNotificationsDto { + @IsOptional() + @IsEnum(NotificationType) + type?: NotificationType; + + @IsOptional() + @IsInt() + @Min(1) + @Max(100) + limit?: number; + + @IsOptional() + @IsInt() + @Min(0) + offset?: number; +} + +/** + * 用户查询通知请求(需要用户序列号) + */ +export class UserNotificationsDto { + @IsString() + userSerialNum: string; + + @IsOptional() + @IsEnum(NotificationType) + type?: NotificationType; + + @IsOptional() + @IsInt() + @Min(1) + @Max(100) + limit?: number; + + @IsOptional() + @IsInt() + @Min(0) + offset?: number; +} + +/** + * 标记已读请求 + */ +export class MarkReadDto { + @IsString() + userSerialNum: string; + + @IsOptional() + @IsString() + notificationId?: string; // 如果不传,则标记所有为已读 +} diff --git a/backend/services/admin-service/src/api/dto/response/notification.dto.ts b/backend/services/admin-service/src/api/dto/response/notification.dto.ts new file mode 100644 index 00000000..d2a62439 --- /dev/null +++ b/backend/services/admin-service/src/api/dto/response/notification.dto.ts @@ -0,0 +1,84 @@ +import { NotificationEntity, NotificationType, NotificationPriority, TargetType } from '../../../domain/entities/notification.entity'; +import { NotificationWithReadStatus } from '../../../domain/repositories/notification.repository'; + +/** + * 通知响应DTO + */ +export class NotificationResponseDto { + id: string; + title: string; + content: string; + type: NotificationType; + priority: NotificationPriority; + targetType: TargetType; + imageUrl: string | null; + linkUrl: string | null; + isEnabled: boolean; + publishedAt: string | null; + expiresAt: string | null; + createdAt: string; + + static fromEntity(entity: NotificationEntity): NotificationResponseDto { + return { + id: entity.id, + title: entity.title, + content: entity.content, + type: entity.type, + priority: entity.priority, + targetType: entity.targetType, + imageUrl: entity.imageUrl, + linkUrl: entity.linkUrl, + isEnabled: entity.isEnabled, + publishedAt: entity.publishedAt?.toISOString() ?? null, + expiresAt: entity.expiresAt?.toISOString() ?? null, + createdAt: entity.createdAt.toISOString(), + }; + } +} + +/** + * 带已读状态的通知响应DTO(用于用户端) + */ +export class UserNotificationResponseDto { + id: string; + title: string; + content: string; + type: NotificationType; + priority: NotificationPriority; + imageUrl: string | null; + linkUrl: string | null; + publishedAt: string | null; + isRead: boolean; + readAt: string | null; + + static fromEntity(item: NotificationWithReadStatus): UserNotificationResponseDto { + return { + id: item.notification.id, + title: item.notification.title, + content: item.notification.content, + type: item.notification.type, + priority: item.notification.priority, + imageUrl: item.notification.imageUrl, + linkUrl: item.notification.linkUrl, + publishedAt: item.notification.publishedAt?.toISOString() ?? null, + isRead: item.isRead, + readAt: item.readAt?.toISOString() ?? null, + }; + } +} + +/** + * 未读数量响应 + */ +export class UnreadCountResponseDto { + unreadCount: number; +} + +/** + * 通知列表响应 + */ +export class NotificationListResponseDto { + notifications: UserNotificationResponseDto[]; + total: number; + unreadCount: number; +} diff --git a/backend/services/admin-service/src/app.module.ts b/backend/services/admin-service/src/app.module.ts index eab51700..f5b194f9 100644 --- a/backend/services/admin-service/src/app.module.ts +++ b/backend/services/admin-service/src/app.module.ts @@ -21,6 +21,11 @@ import { VersionController } from './api/controllers/version.controller'; import { MobileVersionController } from './api/controllers/mobile-version.controller'; import { HealthController } from './api/controllers/health.controller'; import { DownloadController } from './api/controllers/download.controller'; +// Notification imports +import { NotificationMapper } from './infrastructure/persistence/mappers/notification.mapper'; +import { NotificationRepositoryImpl } from './infrastructure/persistence/repositories/notification.repository.impl'; +import { NOTIFICATION_REPOSITORY } from './domain/repositories/notification.repository'; +import { AdminNotificationController, MobileNotificationController } from './api/controllers/notification.controller'; @Module({ imports: [ @@ -34,7 +39,14 @@ import { DownloadController } from './api/controllers/download.controller'; serveRoot: '/uploads', }), ], - controllers: [VersionController, MobileVersionController, HealthController, DownloadController], + controllers: [ + VersionController, + MobileVersionController, + HealthController, + DownloadController, + AdminNotificationController, + MobileNotificationController, + ], providers: [ PrismaService, AppVersionMapper, @@ -54,6 +66,12 @@ import { DownloadController } from './api/controllers/download.controller'; DeleteVersionHandler, ToggleVersionHandler, UploadVersionHandler, + // Notification + NotificationMapper, + { + provide: NOTIFICATION_REPOSITORY, + useClass: NotificationRepositoryImpl, + }, ], }) export class AppModule {} diff --git a/backend/services/admin-service/src/domain/entities/notification.entity.ts b/backend/services/admin-service/src/domain/entities/notification.entity.ts new file mode 100644 index 00000000..da6152f0 --- /dev/null +++ b/backend/services/admin-service/src/domain/entities/notification.entity.ts @@ -0,0 +1,113 @@ +/** + * 通知实体 + */ +export class NotificationEntity { + constructor( + public readonly id: string, + public readonly title: string, + public readonly content: string, + public readonly type: NotificationType, + public readonly priority: NotificationPriority, + public readonly targetType: TargetType, + public readonly imageUrl: string | null, + public readonly linkUrl: string | null, + public readonly isEnabled: boolean, + public readonly publishedAt: Date | null, + public readonly expiresAt: Date | null, + public readonly createdAt: Date, + public readonly updatedAt: Date, + public readonly createdBy: string, + ) {} + + /** + * 检查通知是否已发布且有效 + */ + isActive(): boolean { + if (!this.isEnabled || !this.publishedAt) { + return false; + } + const now = new Date(); + if (this.publishedAt > now) { + return false; + } + if (this.expiresAt && this.expiresAt < now) { + return false; + } + return true; + } + + /** + * 检查是否已过期 + */ + isExpired(): boolean { + if (!this.expiresAt) { + return false; + } + return this.expiresAt < new Date(); + } + + /** + * 创建新通知 + */ + static create(params: { + id: string; + title: string; + content: string; + type: NotificationType; + priority?: NotificationPriority; + targetType?: TargetType; + imageUrl?: string | null; + linkUrl?: string | null; + publishedAt?: Date | null; + expiresAt?: Date | null; + createdBy: string; + }): NotificationEntity { + const now = new Date(); + return new NotificationEntity( + params.id, + params.title, + params.content, + params.type, + params.priority ?? NotificationPriority.NORMAL, + params.targetType ?? TargetType.ALL, + params.imageUrl ?? null, + params.linkUrl ?? null, + true, + params.publishedAt ?? null, + params.expiresAt ?? null, + now, + now, + params.createdBy, + ); + } +} + +/** + * 通知类型 + */ +export enum NotificationType { + SYSTEM = 'SYSTEM', + ACTIVITY = 'ACTIVITY', + REWARD = 'REWARD', + UPGRADE = 'UPGRADE', + ANNOUNCEMENT = 'ANNOUNCEMENT', +} + +/** + * 通知优先级 + */ +export enum NotificationPriority { + LOW = 'LOW', + NORMAL = 'NORMAL', + HIGH = 'HIGH', + URGENT = 'URGENT', +} + +/** + * 目标用户类型 + */ +export enum TargetType { + ALL = 'ALL', + NEW_USER = 'NEW_USER', + VIP = 'VIP', +} diff --git a/backend/services/admin-service/src/domain/repositories/notification.repository.ts b/backend/services/admin-service/src/domain/repositories/notification.repository.ts new file mode 100644 index 00000000..efab28a2 --- /dev/null +++ b/backend/services/admin-service/src/domain/repositories/notification.repository.ts @@ -0,0 +1,84 @@ +import { NotificationEntity, NotificationType } from '../entities/notification.entity'; + +export const NOTIFICATION_REPOSITORY = Symbol('NOTIFICATION_REPOSITORY'); + +/** + * 通知仓储接口 + */ +export interface NotificationRepository { + /** + * 保存通知 + */ + save(notification: NotificationEntity): Promise; + + /** + * 根据ID查找通知 + */ + findById(id: string): Promise; + + /** + * 查找所有已发布且有效的通知 + */ + findActiveNotifications(params?: { + type?: NotificationType; + limit?: number; + offset?: number; + }): Promise; + + /** + * 获取用户的通知列表(带已读状态) + */ + findNotificationsForUser(params: { + userSerialNum: string; + type?: NotificationType; + limit?: number; + offset?: number; + }): Promise; + + /** + * 获取用户未读通知数量 + */ + countUnreadForUser(userSerialNum: string): Promise; + + /** + * 标记通知为已读 + */ + markAsRead(notificationId: string, userSerialNum: string): Promise; + + /** + * 标记所有通知为已读 + */ + markAllAsRead(userSerialNum: string): Promise; + + /** + * 删除通知 + */ + delete(id: string): Promise; + + /** + * 查找所有通知(管理后台用) + */ + findAll(params?: { + type?: NotificationType; + isEnabled?: boolean; + limit?: number; + offset?: number; + }): Promise; + + /** + * 统计通知数量 + */ + count(params?: { + type?: NotificationType; + isEnabled?: boolean; + }): Promise; +} + +/** + * 带已读状态的通知 + */ +export interface NotificationWithReadStatus { + notification: NotificationEntity; + isRead: boolean; + readAt: Date | null; +} diff --git a/backend/services/admin-service/src/infrastructure/persistence/mappers/notification.mapper.ts b/backend/services/admin-service/src/infrastructure/persistence/mappers/notification.mapper.ts new file mode 100644 index 00000000..6e0d934e --- /dev/null +++ b/backend/services/admin-service/src/infrastructure/persistence/mappers/notification.mapper.ts @@ -0,0 +1,54 @@ +import { Injectable } from '@nestjs/common'; +import { + Notification as PrismaNotification, + NotificationType as PrismaNotificationType, + NotificationPriority as PrismaPriority, + TargetType as PrismaTargetType, +} from '@prisma/client'; +import { + NotificationEntity, + NotificationType, + NotificationPriority, + TargetType, +} from '../../../domain/entities/notification.entity'; + +@Injectable() +export class NotificationMapper { + toDomain(prisma: PrismaNotification): NotificationEntity { + return new NotificationEntity( + prisma.id, + prisma.title, + prisma.content, + prisma.type as NotificationType, + prisma.priority as NotificationPriority, + prisma.targetType as TargetType, + prisma.imageUrl, + prisma.linkUrl, + prisma.isEnabled, + prisma.publishedAt, + prisma.expiresAt, + prisma.createdAt, + prisma.updatedAt, + prisma.createdBy, + ); + } + + toPersistence(entity: NotificationEntity): Omit & { id: string } { + return { + id: entity.id, + title: entity.title, + content: entity.content, + type: entity.type as PrismaNotificationType, + priority: entity.priority as PrismaPriority, + targetType: entity.targetType as PrismaTargetType, + imageUrl: entity.imageUrl, + linkUrl: entity.linkUrl, + isEnabled: entity.isEnabled, + publishedAt: entity.publishedAt, + expiresAt: entity.expiresAt, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + createdBy: entity.createdBy, + }; + } +} diff --git a/backend/services/admin-service/src/infrastructure/persistence/repositories/notification.repository.impl.ts b/backend/services/admin-service/src/infrastructure/persistence/repositories/notification.repository.impl.ts new file mode 100644 index 00000000..f6780a4a --- /dev/null +++ b/backend/services/admin-service/src/infrastructure/persistence/repositories/notification.repository.impl.ts @@ -0,0 +1,182 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { + NotificationRepository, + NotificationWithReadStatus, +} from '../../../domain/repositories/notification.repository'; +import { + NotificationEntity, + NotificationType, +} from '../../../domain/entities/notification.entity'; +import { NotificationMapper } from '../mappers/notification.mapper'; + +@Injectable() +export class NotificationRepositoryImpl implements NotificationRepository { + constructor( + private readonly prisma: PrismaService, + private readonly mapper: NotificationMapper, + ) {} + + async save(notification: NotificationEntity): Promise { + const data = this.mapper.toPersistence(notification); + const saved = await this.prisma.notification.upsert({ + where: { id: notification.id }, + create: data, + update: data, + }); + return this.mapper.toDomain(saved); + } + + async findById(id: string): Promise { + const notification = await this.prisma.notification.findUnique({ + where: { id }, + }); + return notification ? this.mapper.toDomain(notification) : null; + } + + async findActiveNotifications(params?: { + type?: NotificationType; + limit?: number; + offset?: number; + }): Promise { + const now = new Date(); + const notifications = await this.prisma.notification.findMany({ + where: { + isEnabled: true, + publishedAt: { lte: now }, + OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], + ...(params?.type && { type: params.type }), + }, + orderBy: [{ priority: 'desc' }, { publishedAt: 'desc' }], + take: params?.limit ?? 50, + skip: params?.offset ?? 0, + }); + return notifications.map((n) => this.mapper.toDomain(n)); + } + + async findNotificationsForUser(params: { + userSerialNum: string; + type?: NotificationType; + limit?: number; + offset?: number; + }): Promise { + const now = new Date(); + const notifications = await this.prisma.notification.findMany({ + where: { + isEnabled: true, + publishedAt: { lte: now }, + OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], + ...(params.type && { type: params.type }), + }, + include: { + readRecords: { + where: { userSerialNum: params.userSerialNum }, + take: 1, + }, + }, + orderBy: [{ priority: 'desc' }, { publishedAt: 'desc' }], + take: params.limit ?? 50, + skip: params.offset ?? 0, + }); + + return notifications.map((n) => ({ + notification: this.mapper.toDomain(n), + isRead: n.readRecords.length > 0, + readAt: n.readRecords[0]?.readAt ?? null, + })); + } + + async countUnreadForUser(userSerialNum: string): Promise { + const now = new Date(); + const count = await this.prisma.notification.count({ + where: { + isEnabled: true, + publishedAt: { lte: now }, + OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], + readRecords: { + none: { userSerialNum }, + }, + }, + }); + return count; + } + + async markAsRead(notificationId: string, userSerialNum: string): Promise { + await this.prisma.notificationRead.upsert({ + where: { + notificationId_userSerialNum: { + notificationId, + userSerialNum, + }, + }, + create: { + notificationId, + userSerialNum, + }, + update: {}, + }); + } + + async markAllAsRead(userSerialNum: string): Promise { + const now = new Date(); + // 获取所有未读的有效通知 + const unreadNotifications = await this.prisma.notification.findMany({ + where: { + isEnabled: true, + publishedAt: { lte: now }, + OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], + readRecords: { + none: { userSerialNum }, + }, + }, + select: { id: true }, + }); + + // 批量创建已读记录 + if (unreadNotifications.length > 0) { + await this.prisma.notificationRead.createMany({ + data: unreadNotifications.map((n) => ({ + notificationId: n.id, + userSerialNum, + })), + skipDuplicates: true, + }); + } + } + + async delete(id: string): Promise { + await this.prisma.notification.delete({ + where: { id }, + }); + } + + async findAll(params?: { + type?: NotificationType; + isEnabled?: boolean; + limit?: number; + offset?: number; + }): Promise { + const notifications = await this.prisma.notification.findMany({ + where: { + ...(params?.type && { type: params.type }), + ...(params?.isEnabled !== undefined && { isEnabled: params.isEnabled }), + }, + orderBy: { createdAt: 'desc' }, + take: params?.limit ?? 50, + skip: params?.offset ?? 0, + }); + return notifications.map((n) => this.mapper.toDomain(n)); + } + + async count(params?: { + type?: NotificationType; + isEnabled?: boolean; + }): Promise { + return this.prisma.notification.count({ + where: { + ...(params?.type && { type: params.type }), + ...(params?.isEnabled !== undefined && { isEnabled: params.isEnabled }), + }, + }); + } +} diff --git a/frontend/mobile-app/lib/core/di/injection_container.dart b/frontend/mobile-app/lib/core/di/injection_container.dart index 111961e0..ca4d6528 100644 --- a/frontend/mobile-app/lib/core/di/injection_container.dart +++ b/frontend/mobile-app/lib/core/di/injection_container.dart @@ -9,6 +9,7 @@ import '../services/deposit_service.dart'; import '../services/wallet_service.dart'; import '../services/planting_service.dart'; import '../services/reward_service.dart'; +import '../services/notification_service.dart'; // Storage Providers final secureStorageProvider = Provider((ref) { @@ -72,6 +73,12 @@ final rewardServiceProvider = Provider((ref) { return RewardService(apiClient: apiClient); }); +// Notification Service Provider (调用 admin-service) +final notificationServiceProvider = Provider((ref) { + final apiClient = ref.watch(apiClientProvider); + return NotificationService(apiClient: apiClient); +}); + // Override provider with initialized instance ProviderContainer createProviderContainer(LocalStorage localStorage) { return ProviderContainer( diff --git a/frontend/mobile-app/lib/core/services/notification_service.dart b/frontend/mobile-app/lib/core/services/notification_service.dart new file mode 100644 index 00000000..fb4dfb11 --- /dev/null +++ b/frontend/mobile-app/lib/core/services/notification_service.dart @@ -0,0 +1,231 @@ +import 'package:flutter/foundation.dart'; +import '../network/api_client.dart'; + +/// 通知类型 +enum NotificationType { + system, + activity, + reward, + upgrade, + announcement, +} + +/// 通知优先级 +enum NotificationPriority { + low, + normal, + high, + urgent, +} + +/// 通知项 +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; + + 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, + }); + + factory NotificationItem.fromJson(Map 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, + ); + } + + 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 '公告'; + } + } + + /// 获取通知类型的图标 + 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 '📢'; + } + } +} + +/// 通知列表响应 +class NotificationListResponse { + final List notifications; + final int total; + final int unreadCount; + + NotificationListResponse({ + required this.notifications, + required this.total, + required this.unreadCount, + }); + + factory NotificationListResponse.fromJson(Map json) { + final list = (json['notifications'] as List?) + ?.map((e) => NotificationItem.fromJson(e as Map)) + .toList() ?? + []; + return NotificationListResponse( + notifications: list, + total: json['total'] ?? list.length, + unreadCount: json['unreadCount'] ?? 0, + ); + } +} + +/// 通知服务 +class NotificationService { + final ApiClient _apiClient; + + NotificationService({required ApiClient apiClient}) : _apiClient = apiClient; + + /// 获取用户的通知列表 + Future 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( + '/admin-service/mobile/notifications', + queryParameters: queryParams, + ); + + return NotificationListResponse.fromJson(response.data); + } catch (e) { + debugPrint('[NotificationService] 获取通知列表失败: $e'); + rethrow; + } + } + + /// 获取未读通知数量 + Future getUnreadCount({required String userSerialNum}) async { + try { + final response = await _apiClient.get( + '/admin-service/mobile/notifications/unread-count', + queryParameters: {'userSerialNum': userSerialNum}, + ); + + return response.data['unreadCount'] ?? 0; + } catch (e) { + debugPrint('[NotificationService] 获取未读数量失败: $e'); + return 0; + } + } + + /// 标记通知为已读 + Future markAsRead({ + required String userSerialNum, + String? notificationId, + }) async { + try { + final body = { + 'userSerialNum': userSerialNum, + if (notificationId != null) 'notificationId': notificationId, + }; + + final response = await _apiClient.post( + '/admin-service/mobile/notifications/mark-read', + data: body, + ); + + return response.data['success'] ?? false; + } catch (e) { + debugPrint('[NotificationService] 标记已读失败: $e'); + return false; + } + } + + /// 标记所有通知为已读 + Future markAllAsRead({required String userSerialNum}) async { + return markAsRead(userSerialNum: userSerialNum); + } +} diff --git a/frontend/mobile-app/lib/features/notification/presentation/pages/notification_inbox_page.dart b/frontend/mobile-app/lib/features/notification/presentation/pages/notification_inbox_page.dart new file mode 100644 index 00000000..2e242dd4 --- /dev/null +++ b/frontend/mobile-app/lib/features/notification/presentation/pages/notification_inbox_page.dart @@ -0,0 +1,549 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../../../core/di/injection_container.dart'; +import '../../../../core/services/notification_service.dart'; +import '../../../../features/auth/presentation/providers/auth_provider.dart'; + +/// 通知箱页面 +class NotificationInboxPage extends ConsumerStatefulWidget { + const NotificationInboxPage({super.key}); + + @override + ConsumerState createState() => + _NotificationInboxPageState(); +} + +class _NotificationInboxPageState extends ConsumerState { + /// 通知列表 + List _notifications = []; + + /// 未读数量 + int _unreadCount = 0; + + /// 是否正在加载 + bool _isLoading = true; + + /// 是否有错误 + String? _error; + + @override + void initState() { + super.initState(); + _loadNotifications(); + } + + /// 加载通知列表 + Future _loadNotifications() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final authState = ref.read(authProvider); + final userSerialNum = authState.userSerialNum; + + if (userSerialNum == null) { + setState(() { + _error = '用户未登录'; + _isLoading = false; + }); + return; + } + + final notificationService = ref.read(notificationServiceProvider); + final response = await notificationService.getNotifications( + userSerialNum: userSerialNum, + ); + + if (mounted) { + setState(() { + _notifications = response.notifications; + _unreadCount = response.unreadCount; + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _error = '加载通知失败'; + _isLoading = false; + }); + } + } + } + + /// 标记单条通知为已读 + Future _markAsRead(NotificationItem notification) async { + if (notification.isRead) return; + + final authState = ref.read(authProvider); + final userSerialNum = authState.userSerialNum; + if (userSerialNum == null) return; + + final notificationService = ref.read(notificationServiceProvider); + final success = await notificationService.markAsRead( + userSerialNum: userSerialNum, + 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(), + ); + _unreadCount = (_unreadCount - 1).clamp(0, _unreadCount); + } + }); + } + } + + /// 标记所有为已读 + Future _markAllAsRead() async { + if (_unreadCount == 0) return; + + final authState = ref.read(authProvider); + final userSerialNum = authState.userSerialNum; + if (userSerialNum == null) return; + + final notificationService = ref.read(notificationServiceProvider); + final success = await notificationService.markAllAsRead( + userSerialNum: userSerialNum, + ); + + 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(), + ); + } + return n; + }).toList(); + _unreadCount = 0; + }); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('已全部标记为已读'), + backgroundColor: Color(0xFFD4AF37), + ), + ); + } + } + } + + /// 显示通知详情 + void _showNotificationDetail(NotificationItem notification) { + // 先标记为已读 + _markAsRead(notification); + + // 显示详情对话框 + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Row( + children: [ + Text( + notification.typeIcon, + style: const TextStyle(fontSize: 20), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + notification.title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Color(0xFF5D4037), + ), + ), + ), + ], + ), + content: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + notification.content, + style: const TextStyle( + fontSize: 14, + color: Color(0xFF8B7355), + height: 1.5, + ), + ), + const SizedBox(height: 16), + Text( + _formatTime(notification.publishedAt), + style: const TextStyle( + fontSize: 12, + color: Color(0xFFBDBDBD), + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text( + '关闭', + style: TextStyle(color: Color(0xFFD4AF37)), + ), + ), + ], + ), + ); + } + + /// 格式化时间 + 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}日'; + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFFFF8E1), + body: SafeArea( + child: Column( + children: [ + _buildAppBar(), + Expanded( + child: _isLoading + ? const Center( + child: CircularProgressIndicator( + valueColor: + AlwaysStoppedAnimation(Color(0xFFD4AF37)), + ), + ) + : _error != null + ? _buildErrorView() + : _notifications.isEmpty + ? _buildEmptyView() + : _buildNotificationList(), + ), + ], + ), + ), + ); + } + + /// 构建 AppBar + Widget _buildAppBar() { + return Container( + height: 56, + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + // 返回按钮 + GestureDetector( + onTap: () => context.pop(), + child: Container( + width: 40, + height: 40, + alignment: Alignment.center, + child: const Icon( + Icons.arrow_back, + size: 24, + color: Color(0xFF5D4037), + ), + ), + ), + const SizedBox(width: 8), + // 标题 + const Expanded( + child: Text( + '通知中心', + style: TextStyle( + fontSize: 18, + fontFamily: 'Inter', + fontWeight: FontWeight.w600, + color: Color(0xFF5D4037), + ), + ), + ), + // 未读数量和全部已读按钮 + if (_unreadCount > 0) + GestureDetector( + onTap: _markAllAsRead, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFFD4AF37).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(16), + ), + child: const Text( + '全部已读', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Color(0xFFD4AF37), + ), + ), + ), + ), + ], + ), + ); + } + + /// 构建空视图 + Widget _buildEmptyView() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.notifications_none, + size: 64, + color: Colors.grey.shade300, + ), + const SizedBox(height: 16), + Text( + '暂无通知', + style: TextStyle( + fontSize: 16, + color: Colors.grey.shade500, + ), + ), + ], + ), + ); + } + + /// 构建错误视图 + Widget _buildErrorView() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 64, + color: Colors.grey.shade300, + ), + const SizedBox(height: 16), + Text( + _error ?? '加载失败', + style: TextStyle( + fontSize: 16, + color: Colors.grey.shade500, + ), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadNotifications, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFD4AF37), + ), + child: const Text('重试'), + ), + ], + ), + ); + } + + /// 构建通知列表 + Widget _buildNotificationList() { + return RefreshIndicator( + onRefresh: _loadNotifications, + color: const Color(0xFFD4AF37), + 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); + }, + ), + ); + } + + /// 构建通知卡片 + Widget _buildNotificationCard(NotificationItem notification) { + return GestureDetector( + onTap: () => _showNotificationDetail(notification), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: notification.isRead + ? Colors.white + : const Color(0xFFFFF8E1), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: notification.isRead + ? const Color(0xFFE0E0E0) + : const Color(0xFFD4AF37).withValues(alpha: 0.3), + width: 1, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 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: Color(0xFFD4AF37), + shape: BoxShape.circle, + ), + ), + // 标题 + Expanded( + child: Text( + notification.title, + style: TextStyle( + fontSize: 15, + fontWeight: notification.isRead + ? FontWeight.w500 + : FontWeight.w600, + color: const Color(0xFF5D4037), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 4), + // 内容预览 + Text( + notification.content, + style: TextStyle( + fontSize: 13, + color: notification.isRead + ? const Color(0xFFBDBDBD) + : const Color(0xFF8B7355), + 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: const TextStyle( + fontSize: 11, + color: Color(0xFFBDBDBD), + ), + ), + ], + ), + ], + ), + ), + // 箭头 + const Icon( + Icons.chevron_right, + size: 20, + color: Color(0xFFBDBDBD), + ), + ], + ), + ), + ); + } + + /// 获取类型颜色 + Color _getTypeColor(NotificationType type) { + switch (type) { + case NotificationType.system: + return const Color(0xFF5D4037); + case NotificationType.activity: + return const Color(0xFFFF9800); + case NotificationType.reward: + return const Color(0xFFD4AF37); + case NotificationType.upgrade: + return const Color(0xFF4CAF50); + case NotificationType.announcement: + return const Color(0xFF2196F3); + } + } +} diff --git a/frontend/mobile-app/lib/features/profile/presentation/pages/profile_page.dart b/frontend/mobile-app/lib/features/profile/presentation/pages/profile_page.dart index 3784d4c7..297ea328 100644 --- a/frontend/mobile-app/lib/features/profile/presentation/pages/profile_page.dart +++ b/frontend/mobile-app/lib/features/profile/presentation/pages/profile_page.dart @@ -10,8 +10,10 @@ import 'package:device_info_plus/device_info_plus.dart'; import '../../../../core/di/injection_container.dart'; import '../../../../core/services/referral_service.dart'; import '../../../../core/services/reward_service.dart'; +import '../../../../core/services/notification_service.dart'; import '../../../../routes/route_paths.dart'; import '../../../../routes/app_router.dart'; +import '../../../auth/presentation/providers/auth_provider.dart'; import '../widgets/team_tree_widget.dart'; /// 个人中心页面 - 显示用户信息、社区数据、收益和设置 @@ -119,6 +121,9 @@ class _ProfilePageState extends ConsumerState { String _osVersion = '--'; String _platform = '--'; + // 通知未读数量 + int _unreadNotificationCount = 0; + @override void initState() { super.initState(); @@ -131,6 +136,8 @@ class _ProfilePageState extends ConsumerState { _loadAuthorizationData(); // 加载钱包和收益数据 _loadWalletData(); + // 加载通知未读数量 + _loadUnreadNotificationCount(); } /// 加载应用信息 @@ -435,6 +442,36 @@ class _ProfilePageState extends ConsumerState { } } + /// 加载通知未读数量 + Future _loadUnreadNotificationCount() async { + try { + final authState = ref.read(authProvider); + final userSerialNum = authState.userSerialNum; + if (userSerialNum == null) return; + + final notificationService = ref.read(notificationServiceProvider); + final count = await notificationService.getUnreadCount( + userSerialNum: userSerialNum, + ); + + if (mounted) { + setState(() { + _unreadNotificationCount = count; + }); + } + } catch (e) { + debugPrint('[ProfilePage] 加载通知未读数量失败: $e'); + } + } + + /// 跳转到通知中心 + void _goToNotifications() { + context.push(RoutePaths.notifications).then((_) { + // 从通知页面返回后刷新未读数量 + _loadUnreadNotificationCount(); + }); + } + /// 加载收益数据 (直接从 reward-service 获取) Future _loadWalletData() async { try { @@ -733,6 +770,8 @@ class _ProfilePageState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // 页面标题行(带通知图标) + _buildPageHeader(), const SizedBox(height: 16), // 用户头像和基本信息 _buildUserHeader(), @@ -758,6 +797,79 @@ class _ProfilePageState extends ConsumerState { ); } + /// 构建页面标题行(含通知图标) + Widget _buildPageHeader() { + return Container( + height: 48, + padding: const EdgeInsets.only(top: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // 页面标题 + const Text( + '我的', + style: TextStyle( + fontSize: 20, + fontFamily: 'Inter', + fontWeight: FontWeight.w700, + color: Color(0xFF5D4037), + ), + ), + // 通知图标(带未读角标) + GestureDetector( + onTap: _goToNotifications, + child: Container( + width: 40, + height: 40, + alignment: Alignment.center, + child: Stack( + clipBehavior: Clip.none, + children: [ + const Icon( + Icons.notifications_outlined, + size: 26, + color: Color(0xFF5D4037), + ), + // 未读角标 + if (_unreadNotificationCount > 0) + Positioned( + top: -4, + right: -4, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 5, + vertical: 2, + ), + decoration: BoxDecoration( + color: const Color(0xFFD4AF37), + borderRadius: BorderRadius.circular(10), + ), + constraints: const BoxConstraints( + minWidth: 18, + minHeight: 18, + ), + child: Text( + _unreadNotificationCount > 99 + ? '99+' + : _unreadNotificationCount.toString(), + style: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + textAlign: TextAlign.center, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + /// 构建用户头像和基本信息 Widget _buildUserHeader() { return Row( diff --git a/frontend/mobile-app/lib/routes/app_router.dart b/frontend/mobile-app/lib/routes/app_router.dart index 38e7dbee..ac5df7db 100644 --- a/frontend/mobile-app/lib/routes/app_router.dart +++ b/frontend/mobile-app/lib/routes/app_router.dart @@ -24,6 +24,7 @@ import '../features/security/presentation/pages/change_password_page.dart'; import '../features/security/presentation/pages/bind_email_page.dart'; import '../features/withdraw/presentation/pages/withdraw_usdt_page.dart'; import '../features/withdraw/presentation/pages/withdraw_confirm_page.dart'; +import '../features/notification/presentation/pages/notification_inbox_page.dart'; import 'route_paths.dart'; import 'route_names.dart'; @@ -175,6 +176,13 @@ final appRouterProvider = Provider((ref) { builder: (context, state) => const EditProfilePage(), ), + // Notification Inbox (通知中心) + GoRoute( + path: RoutePaths.notifications, + name: RouteNames.notifications, + builder: (context, state) => const NotificationInboxPage(), + ), + // Share Page (分享页面) GoRoute( path: RoutePaths.share, diff --git a/frontend/mobile-app/lib/routes/route_names.dart b/frontend/mobile-app/lib/routes/route_names.dart index fde86abb..e9bd4d02 100644 --- a/frontend/mobile-app/lib/routes/route_names.dart +++ b/frontend/mobile-app/lib/routes/route_names.dart @@ -22,6 +22,7 @@ class RouteNames { static const editProfile = 'edit-profile'; static const referralList = 'referral-list'; static const earningsDetail = 'earnings-detail'; + static const notifications = 'notifications'; static const deposit = 'deposit'; static const depositUsdt = 'deposit-usdt'; static const plantingQuantity = 'planting-quantity'; diff --git a/frontend/mobile-app/lib/routes/route_paths.dart b/frontend/mobile-app/lib/routes/route_paths.dart index a81b827c..55b11e56 100644 --- a/frontend/mobile-app/lib/routes/route_paths.dart +++ b/frontend/mobile-app/lib/routes/route_paths.dart @@ -22,6 +22,7 @@ class RoutePaths { static const editProfile = '/profile/edit'; static const referralList = '/profile/referrals'; static const earningsDetail = '/profile/earnings'; + static const notifications = '/notifications'; static const deposit = '/deposit'; static const depositUsdt = '/deposit/usdt'; static const plantingQuantity = '/planting/quantity';