feat(notification): 添加通知中心功能
后端 (admin-service): - 新增 Notification 和 NotificationRead 数据模型 - 支持通知类型: 系统/活动/收益/升级/公告 - 实现管理端 API: 创建/更新/删除/列表 - 实现移动端 API: 获取通知列表/未读数量/标记已读 前端 (mobile-app): - 新增 NotificationService 和 Provider - 新增通知中心页面 (NotificationInboxPage) - 在"我的"页面右上角添加通知图标(带未读角标) - 支持查看通知详情、标记已读、全部已读 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
11d9b57bda
commit
db37fbf860
|
|
@ -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;
|
||||
|
|
@ -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用户
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<NotificationResponseDto> {
|
||||
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<NotificationResponseDto> {
|
||||
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<NotificationResponseDto[]> {
|
||||
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<NotificationResponseDto> {
|
||||
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<void> {
|
||||
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<NotificationListResponseDto> {
|
||||
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<UnreadCountResponseDto> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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; // 如果不传,则标记所有为已读
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}
|
||||
|
|
@ -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<NotificationEntity>;
|
||||
|
||||
/**
|
||||
* 根据ID查找通知
|
||||
*/
|
||||
findById(id: string): Promise<NotificationEntity | null>;
|
||||
|
||||
/**
|
||||
* 查找所有已发布且有效的通知
|
||||
*/
|
||||
findActiveNotifications(params?: {
|
||||
type?: NotificationType;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<NotificationEntity[]>;
|
||||
|
||||
/**
|
||||
* 获取用户的通知列表(带已读状态)
|
||||
*/
|
||||
findNotificationsForUser(params: {
|
||||
userSerialNum: string;
|
||||
type?: NotificationType;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<NotificationWithReadStatus[]>;
|
||||
|
||||
/**
|
||||
* 获取用户未读通知数量
|
||||
*/
|
||||
countUnreadForUser(userSerialNum: string): Promise<number>;
|
||||
|
||||
/**
|
||||
* 标记通知为已读
|
||||
*/
|
||||
markAsRead(notificationId: string, userSerialNum: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* 标记所有通知为已读
|
||||
*/
|
||||
markAllAsRead(userSerialNum: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* 删除通知
|
||||
*/
|
||||
delete(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* 查找所有通知(管理后台用)
|
||||
*/
|
||||
findAll(params?: {
|
||||
type?: NotificationType;
|
||||
isEnabled?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<NotificationEntity[]>;
|
||||
|
||||
/**
|
||||
* 统计通知数量
|
||||
*/
|
||||
count(params?: {
|
||||
type?: NotificationType;
|
||||
isEnabled?: boolean;
|
||||
}): Promise<number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 带已读状态的通知
|
||||
*/
|
||||
export interface NotificationWithReadStatus {
|
||||
notification: NotificationEntity;
|
||||
isRead: boolean;
|
||||
readAt: Date | null;
|
||||
}
|
||||
|
|
@ -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<PrismaNotification, 'id'> & { 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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<NotificationEntity> {
|
||||
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<NotificationEntity | null> {
|
||||
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<NotificationEntity[]> {
|
||||
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<NotificationWithReadStatus[]> {
|
||||
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<number> {
|
||||
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<void> {
|
||||
await this.prisma.notificationRead.upsert({
|
||||
where: {
|
||||
notificationId_userSerialNum: {
|
||||
notificationId,
|
||||
userSerialNum,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
notificationId,
|
||||
userSerialNum,
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
async markAllAsRead(userSerialNum: string): Promise<void> {
|
||||
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<void> {
|
||||
await this.prisma.notification.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
async findAll(params?: {
|
||||
type?: NotificationType;
|
||||
isEnabled?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<NotificationEntity[]> {
|
||||
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<number> {
|
||||
return this.prisma.notification.count({
|
||||
where: {
|
||||
...(params?.type && { type: params.type }),
|
||||
...(params?.isEnabled !== undefined && { isEnabled: params.isEnabled }),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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<SecureStorage>((ref) {
|
||||
|
|
@ -72,6 +73,12 @@ final rewardServiceProvider = Provider<RewardService>((ref) {
|
|||
return RewardService(apiClient: apiClient);
|
||||
});
|
||||
|
||||
// Notification Service Provider (调用 admin-service)
|
||||
final notificationServiceProvider = Provider<NotificationService>((ref) {
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
return NotificationService(apiClient: apiClient);
|
||||
});
|
||||
|
||||
// Override provider with initialized instance
|
||||
ProviderContainer createProviderContainer(LocalStorage localStorage) {
|
||||
return ProviderContainer(
|
||||
|
|
|
|||
|
|
@ -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<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,
|
||||
);
|
||||
}
|
||||
|
||||
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<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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 通知服务
|
||||
class NotificationService {
|
||||
final ApiClient _apiClient;
|
||||
|
||||
NotificationService({required ApiClient apiClient}) : _apiClient = apiClient;
|
||||
|
||||
/// 获取用户的通知列表
|
||||
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(
|
||||
'/admin-service/mobile/notifications',
|
||||
queryParameters: queryParams,
|
||||
);
|
||||
|
||||
return NotificationListResponse.fromJson(response.data);
|
||||
} catch (e) {
|
||||
debugPrint('[NotificationService] 获取通知列表失败: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取未读通知数量
|
||||
Future<int> 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<bool> 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<bool> markAllAsRead({required String userSerialNum}) async {
|
||||
return markAsRead(userSerialNum: userSerialNum);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<NotificationInboxPage> createState() =>
|
||||
_NotificationInboxPageState();
|
||||
}
|
||||
|
||||
class _NotificationInboxPageState extends ConsumerState<NotificationInboxPage> {
|
||||
/// 通知列表
|
||||
List<NotificationItem> _notifications = [];
|
||||
|
||||
/// 未读数量
|
||||
int _unreadCount = 0;
|
||||
|
||||
/// 是否正在加载
|
||||
bool _isLoading = true;
|
||||
|
||||
/// 是否有错误
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadNotifications();
|
||||
}
|
||||
|
||||
/// 加载通知列表
|
||||
Future<void> _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<void> _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<void> _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>(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ProfilePage> {
|
|||
String _osVersion = '--';
|
||||
String _platform = '--';
|
||||
|
||||
// 通知未读数量
|
||||
int _unreadNotificationCount = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
|
@ -131,6 +136,8 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||
_loadAuthorizationData();
|
||||
// 加载钱包和收益数据
|
||||
_loadWalletData();
|
||||
// 加载通知未读数量
|
||||
_loadUnreadNotificationCount();
|
||||
}
|
||||
|
||||
/// 加载应用信息
|
||||
|
|
@ -435,6 +442,36 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||
}
|
||||
}
|
||||
|
||||
/// 加载通知未读数量
|
||||
Future<void> _loadUnreadNotificationCount() async {
|
||||
try {
|
||||
final authState = ref.read(authProvider);
|
||||
final userSerialNum = authState.userSerialNum;
|
||||
if (userSerialNum == null) return;
|
||||
|
||||
final notificationService = ref.read(notificationServiceProvider);
|
||||
final count = await notificationService.getUnreadCount(
|
||||
userSerialNum: userSerialNum,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_unreadNotificationCount = count;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[ProfilePage] 加载通知未读数量失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 跳转到通知中心
|
||||
void _goToNotifications() {
|
||||
context.push(RoutePaths.notifications).then((_) {
|
||||
// 从通知页面返回后刷新未读数量
|
||||
_loadUnreadNotificationCount();
|
||||
});
|
||||
}
|
||||
|
||||
/// 加载收益数据 (直接从 reward-service 获取)
|
||||
Future<void> _loadWalletData() async {
|
||||
try {
|
||||
|
|
@ -733,6 +770,8 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 页面标题行(带通知图标)
|
||||
_buildPageHeader(),
|
||||
const SizedBox(height: 16),
|
||||
// 用户头像和基本信息
|
||||
_buildUserHeader(),
|
||||
|
|
@ -758,6 +797,79 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||
);
|
||||
}
|
||||
|
||||
/// 构建页面标题行(含通知图标)
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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<GoRouter>((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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Reference in New Issue