From 7c781c7d624e5700cfe0f263bdf6057522aeebf8 Mon Sep 17 00:00:00 2001 From: hailin Date: Mon, 2 Mar 2026 08:35:16 -0800 Subject: [PATCH] =?UTF-8?q?feat(notifications):=202.0=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E5=BC=B9=E7=AA=97=E5=8A=9F=E8=83=BD=EF=BC=88?= =?UTF-8?q?=E5=90=8E=E7=AB=AF+=E7=AE=A1=E7=90=86=E7=AB=AF+APP=E7=AB=AF?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复制1.0通知系统架构到2.0系统,实现完整的通知推送功能: 后端 (mining-admin-service): - Prisma Schema: 添加 Notification/NotificationRead/NotificationUserTarget 表 - NotificationService: 完整 CRUD + 移动端通知查询/已读标记 - AdminNotificationController: 管理端通知 CRUD API - MobileNotificationController: 移动端通知列表/未读数/标记已读 API 管理端 (mining-admin-web): - 通知管理页面: 列表/筛选/新建/编辑/删除 Dialog - 支持类型/优先级/目标用户/强制弹窗/发布时间等完整配置 - 侧边栏添加"通知管理"入口 APP端 (mining-app): - NotificationService: 通知API服务(经Kong网关路由) - NotificationBadgeProvider: 30秒轮询未读数量+生命周期监听 - ForceReadNotificationDialog: 强制阅读弹窗(橙色主题,逐条查看+确认) - NotificationInboxPage: 通知收件箱(支持dark/light主题) - MainShell: 添加强制弹窗检查(启动+前台恢复,60秒冷却) - ProfilePage: 用户头部添加通知图标+未读角标 Co-Authored-By: Claude Opus 4.6 --- .../mining-admin-service/prisma/schema.prisma | 73 +++ .../src/api/api.module.ts | 3 + .../controllers/notification.controller.ts | 136 ++++ .../src/application/application.module.ts | 3 + .../services/notification.service.ts | 357 +++++++++++ .../app/(dashboard)/notifications/page.tsx | 593 ++++++++++++++++++ .../src/components/layout/sidebar.tsx | 2 + .../notifications/api/notifications.api.ts | 143 +++++ .../notifications/hooks/use-notifications.ts | 85 +++ .../lib/core/router/app_router.dart | 5 + .../mining-app/lib/core/router/routes.dart | 2 + .../core/services/notification_service.dart | 270 ++++++++ .../profile/notification_inbox_page.dart | 563 +++++++++++++++++ .../pages/profile/profile_page.dart | 46 +- .../providers/notification_providers.dart | 162 +++++ .../force_read_notification_dialog.dart | 317 ++++++++++ .../lib/presentation/widgets/main_shell.dart | 105 +++- 17 files changed, 2861 insertions(+), 4 deletions(-) create mode 100644 backend/services/mining-admin-service/src/api/controllers/notification.controller.ts create mode 100644 backend/services/mining-admin-service/src/application/services/notification.service.ts create mode 100644 frontend/mining-admin-web/src/app/(dashboard)/notifications/page.tsx create mode 100644 frontend/mining-admin-web/src/features/notifications/api/notifications.api.ts create mode 100644 frontend/mining-admin-web/src/features/notifications/hooks/use-notifications.ts create mode 100644 frontend/mining-app/lib/core/services/notification_service.dart create mode 100644 frontend/mining-app/lib/presentation/pages/profile/notification_inbox_page.dart create mode 100644 frontend/mining-app/lib/presentation/providers/notification_providers.dart create mode 100644 frontend/mining-app/lib/presentation/widgets/force_read_notification_dialog.dart diff --git a/backend/services/mining-admin-service/prisma/schema.prisma b/backend/services/mining-admin-service/prisma/schema.prisma index 0beb28f0..a0698011 100644 --- a/backend/services/mining-admin-service/prisma/schema.prisma +++ b/backend/services/mining-admin-service/prisma/schema.prisma @@ -926,3 +926,76 @@ model AppVersion { @@index([platform, versionCode]) @@map("app_versions") } + +// ============================================================================= +// 通知模块 +// ============================================================================= + +enum NotificationType { + SYSTEM + ACTIVITY + REWARD + UPGRADE + ANNOUNCEMENT +} + +enum NotificationPriority { + LOW + NORMAL + HIGH + URGENT +} + +enum TargetType { + ALL + SPECIFIC +} + +model Notification { + id String @id @default(uuid()) + title String + content String @db.Text + type NotificationType @default(SYSTEM) + priority NotificationPriority @default(NORMAL) + targetType TargetType @default(ALL) + imageUrl String? @map("image_url") + linkUrl String? @map("link_url") + isEnabled Boolean @default(true) @map("is_enabled") + requiresForceRead Boolean @default(false) @map("requires_force_read") + publishedAt DateTime? @map("published_at") + expiresAt DateTime? @map("expires_at") + createdBy String @default("admin") @map("created_by") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + reads NotificationRead[] + userTargets NotificationUserTarget[] + + @@index([isEnabled, publishedAt]) + @@index([type]) + @@map("notifications") +} + +model NotificationRead { + id String @id @default(uuid()) + notificationId String @map("notification_id") + userSerialNum String @map("user_serial_num") + readAt DateTime @default(now()) @map("read_at") + + notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade) + + @@unique([notificationId, userSerialNum]) + @@index([userSerialNum]) + @@map("notification_reads") +} + +model NotificationUserTarget { + id String @id @default(uuid()) + notificationId String @map("notification_id") + accountSequence String @map("account_sequence") + + notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade) + + @@unique([notificationId, accountSequence]) + @@map("notification_user_targets") +} diff --git a/backend/services/mining-admin-service/src/api/api.module.ts b/backend/services/mining-admin-service/src/api/api.module.ts index b7e654a6..bbe7caa2 100644 --- a/backend/services/mining-admin-service/src/api/api.module.ts +++ b/backend/services/mining-admin-service/src/api/api.module.ts @@ -17,6 +17,7 @@ import { UpgradeVersionController } from './controllers/upgrade-version.controll import { MobileVersionController } from './controllers/mobile-version.controller'; import { PoolAccountController } from './controllers/pool-account.controller'; import { CapabilityController } from './controllers/capability.controller'; +import { AdminNotificationController, MobileNotificationController } from './controllers/notification.controller'; @Module({ imports: [ @@ -44,6 +45,8 @@ import { CapabilityController } from './controllers/capability.controller'; MobileVersionController, PoolAccountController, CapabilityController, + AdminNotificationController, + MobileNotificationController, ], }) export class ApiModule {} diff --git a/backend/services/mining-admin-service/src/api/controllers/notification.controller.ts b/backend/services/mining-admin-service/src/api/controllers/notification.controller.ts new file mode 100644 index 00000000..c83b1702 --- /dev/null +++ b/backend/services/mining-admin-service/src/api/controllers/notification.controller.ts @@ -0,0 +1,136 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Query, + HttpCode, + HttpStatus, + NotFoundException, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiQuery } from '@nestjs/swagger'; +import { NotificationService } from '../../application/services/notification.service'; +import type { CreateNotificationDto, UpdateNotificationDto } from '../../application/services/notification.service'; +import { Public } from '../../shared/guards/admin-auth.guard'; + +/** + * 管理端通知控制器 + */ +@ApiTags('Notifications') +@Controller('notifications') +export class AdminNotificationController { + constructor(private readonly notificationService: NotificationService) {} + + @Post() + @ApiOperation({ summary: '创建通知' }) + async create(@Body() dto: CreateNotificationDto) { + return this.notificationService.create(dto); + } + + @Get() + @ApiOperation({ summary: '获取通知列表' }) + @ApiQuery({ name: 'type', required: false }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + @ApiQuery({ name: 'offset', required: false, type: Number }) + async findAll( + @Query('type') type?: string, + @Query('limit') limit?: number, + @Query('offset') offset?: number, + ) { + return this.notificationService.findAll({ + type: type as any, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + }); + } + + @Get(':id') + @ApiOperation({ summary: '获取通知详情' }) + async findOne(@Param('id') id: string) { + const notification = await this.notificationService.findById(id); + if (!notification) { + throw new NotFoundException('通知不存在'); + } + return notification; + } + + @Put(':id') + @ApiOperation({ summary: '更新通知' }) + async update(@Param('id') id: string, @Body() dto: UpdateNotificationDto) { + const existing = await this.notificationService.findById(id); + if (!existing) { + throw new NotFoundException('通知不存在'); + } + return this.notificationService.update(id, dto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: '删除通知' }) + async delete(@Param('id') id: string) { + await this.notificationService.delete(id); + } +} + +/** + * 移动端通知控制器 + */ +@ApiTags('Mobile Notifications') +@Controller('mobile/notifications') +export class MobileNotificationController { + constructor(private readonly notificationService: NotificationService) {} + + @Get() + @Public() + @ApiOperation({ summary: '获取用户通知列表' }) + @ApiQuery({ name: 'userSerialNum', required: true }) + @ApiQuery({ name: 'type', required: false }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + @ApiQuery({ name: 'offset', required: false, type: Number }) + async getNotifications( + @Query('userSerialNum') userSerialNum: string, + @Query('type') type?: string, + @Query('limit') limit?: number, + @Query('offset') offset?: number, + ) { + const notifications = await this.notificationService.findNotificationsForUser({ + userSerialNum, + type: type as any, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + }); + + const unreadCount = await this.notificationService.countUnreadForUser(userSerialNum); + + return { + notifications, + total: notifications.length, + unreadCount, + }; + } + + @Get('unread-count') + @Public() + @ApiOperation({ summary: '获取未读通知数量' }) + @ApiQuery({ name: 'userSerialNum', required: true }) + async getUnreadCount(@Query('userSerialNum') userSerialNum: string) { + const unreadCount = await this.notificationService.countUnreadForUser(userSerialNum); + return { unreadCount }; + } + + @Post('mark-read') + @Public() + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '标记通知为已读' }) + async markRead(@Body() dto: { userSerialNum: string; notificationId?: string }) { + if (dto.notificationId) { + await this.notificationService.markAsRead(dto.notificationId, dto.userSerialNum); + } else { + await this.notificationService.markAllAsRead(dto.userSerialNum); + } + return { success: true }; + } +} diff --git a/backend/services/mining-admin-service/src/application/application.module.ts b/backend/services/mining-admin-service/src/application/application.module.ts index e3130d4f..c92d3e14 100644 --- a/backend/services/mining-admin-service/src/application/application.module.ts +++ b/backend/services/mining-admin-service/src/application/application.module.ts @@ -11,6 +11,7 @@ import { PendingContributionsService } from './services/pending-contributions.se import { BatchMiningService } from './services/batch-mining.service'; import { VersionService } from './services/version.service'; import { CapabilityAdminService } from './services/capability-admin.service'; +import { NotificationService } from './services/notification.service'; @Module({ imports: [InfrastructureModule], @@ -26,6 +27,7 @@ import { CapabilityAdminService } from './services/capability-admin.service'; BatchMiningService, VersionService, CapabilityAdminService, + NotificationService, ], exports: [ AuthService, @@ -39,6 +41,7 @@ import { CapabilityAdminService } from './services/capability-admin.service'; BatchMiningService, VersionService, CapabilityAdminService, + NotificationService, ], }) export class ApplicationModule implements OnModuleInit { diff --git a/backend/services/mining-admin-service/src/application/services/notification.service.ts b/backend/services/mining-admin-service/src/application/services/notification.service.ts new file mode 100644 index 00000000..2ec095f9 --- /dev/null +++ b/backend/services/mining-admin-service/src/application/services/notification.service.ts @@ -0,0 +1,357 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service'; +import { NotificationType, TargetType, Prisma } from '@prisma/client'; + +export interface CreateNotificationDto { + title: string; + content: string; + type?: NotificationType; + priority?: string; + targetType?: TargetType; + targetConfig?: { + accountSequences?: string[]; + }; + imageUrl?: string; + linkUrl?: string; + requiresForceRead?: boolean; + publishedAt?: string; + expiresAt?: string; +} + +export interface UpdateNotificationDto { + title?: string; + content?: string; + type?: NotificationType; + priority?: string; + targetType?: TargetType; + targetConfig?: { + accountSequences?: string[]; + }; + imageUrl?: string | null; + linkUrl?: string | null; + isEnabled?: boolean; + requiresForceRead?: boolean; + publishedAt?: string | null; + expiresAt?: string | null; +} + +@Injectable() +export class NotificationService { + constructor(private readonly prisma: PrismaService) {} + + /** + * 创建通知 + */ + async create(dto: CreateNotificationDto) { + const targetType = dto.targetType || 'ALL'; + + return this.prisma.$transaction(async (tx) => { + const notification = await tx.notification.create({ + data: { + title: dto.title, + content: dto.content, + type: dto.type || 'SYSTEM', + priority: dto.priority || 'NORMAL', + targetType, + imageUrl: dto.imageUrl || null, + linkUrl: dto.linkUrl || null, + requiresForceRead: dto.requiresForceRead ?? false, + publishedAt: dto.publishedAt ? new Date(dto.publishedAt) : null, + expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : null, + }, + }); + + // SPECIFIC 目标:创建用户关联 + if ( + targetType === 'SPECIFIC' && + dto.targetConfig?.accountSequences?.length + ) { + await tx.notificationUserTarget.createMany({ + data: dto.targetConfig.accountSequences.map((accountSequence) => ({ + notificationId: notification.id, + accountSequence, + })), + }); + } + + return this.findById(notification.id, tx); + }); + } + + /** + * 查询通知详情 + */ + async findById(id: string, tx?: any) { + const db = tx || this.prisma; + const notification = await db.notification.findUnique({ + where: { id }, + include: { + userTargets: { select: { accountSequence: true } }, + }, + }); + if (!notification) return null; + return this.formatNotification(notification); + } + + /** + * 管理端通知列表 + */ + async findAll(params?: { + type?: NotificationType; + limit?: number; + offset?: number; + }) { + const where: Prisma.NotificationWhereInput = {}; + if (params?.type) where.type = params.type; + + const [notifications, total] = await Promise.all([ + this.prisma.notification.findMany({ + where, + include: { + userTargets: { select: { accountSequence: true } }, + }, + orderBy: { createdAt: 'desc' }, + take: params?.limit ?? 50, + skip: params?.offset ?? 0, + }), + this.prisma.notification.count({ where }), + ]); + + return { + notifications: notifications.map((n) => this.formatNotification(n)), + total, + }; + } + + /** + * 更新通知 + */ + async update(id: string, dto: UpdateNotificationDto) { + return this.prisma.$transaction(async (tx) => { + const data: any = {}; + if (dto.title !== undefined) data.title = dto.title; + if (dto.content !== undefined) data.content = dto.content; + if (dto.type !== undefined) data.type = dto.type; + if (dto.priority !== undefined) data.priority = dto.priority; + if (dto.targetType !== undefined) data.targetType = dto.targetType; + if (dto.imageUrl !== undefined) data.imageUrl = dto.imageUrl; + if (dto.linkUrl !== undefined) data.linkUrl = dto.linkUrl; + if (dto.isEnabled !== undefined) data.isEnabled = dto.isEnabled; + if (dto.requiresForceRead !== undefined) + data.requiresForceRead = dto.requiresForceRead; + if (dto.publishedAt !== undefined) + data.publishedAt = dto.publishedAt ? new Date(dto.publishedAt) : null; + if (dto.expiresAt !== undefined) + data.expiresAt = dto.expiresAt ? new Date(dto.expiresAt) : null; + + await tx.notification.update({ where: { id }, data }); + + // 如果更新了目标类型或目标配置,重建用户关联 + if (dto.targetType !== undefined || dto.targetConfig !== undefined) { + await tx.notificationUserTarget.deleteMany({ + where: { notificationId: id }, + }); + + const targetType = dto.targetType || (await tx.notification.findUnique({ where: { id }, select: { targetType: true } }))?.targetType; + + if ( + targetType === 'SPECIFIC' && + dto.targetConfig?.accountSequences?.length + ) { + await tx.notificationUserTarget.createMany({ + data: dto.targetConfig.accountSequences.map((accountSequence) => ({ + notificationId: id, + accountSequence, + })), + }); + } + } + + return this.findById(id, tx); + }); + } + + /** + * 删除通知 + */ + async delete(id: string) { + await this.prisma.notification.delete({ where: { id } }); + } + + /** + * 移动端:获取用户的通知列表(带已读状态) + */ + async findNotificationsForUser(params: { + userSerialNum: string; + type?: NotificationType; + limit?: number; + offset?: number; + }) { + const now = new Date(); + + const notifications = await this.prisma.notification.findMany({ + where: { + isEnabled: true, + ...(params.type && { type: params.type }), + AND: [ + { OR: [{ publishedAt: null }, { publishedAt: { lte: now } }] }, + { OR: [{ expiresAt: null }, { expiresAt: { gt: now } }] }, + { + OR: [ + { targetType: 'ALL' }, + { + targetType: 'SPECIFIC', + userTargets: { + some: { accountSequence: params.userSerialNum }, + }, + }, + ], + }, + ], + }, + include: { + reads: { + where: { userSerialNum: params.userSerialNum }, + take: 1, + }, + }, + orderBy: [{ priority: 'desc' }, { publishedAt: 'desc' }], + take: params.limit ?? 50, + skip: params.offset ?? 0, + }); + + return notifications.map((n) => ({ + id: n.id, + title: n.title, + content: n.content, + type: n.type, + priority: n.priority, + imageUrl: n.imageUrl, + linkUrl: n.linkUrl, + publishedAt: n.publishedAt, + isRead: n.reads.length > 0, + readAt: n.reads[0]?.readAt ?? null, + requiresForceRead: n.requiresForceRead, + })); + } + + /** + * 移动端:获取未读通知数量 + */ + async countUnreadForUser(userSerialNum: string): Promise { + const now = new Date(); + + return this.prisma.notification.count({ + where: { + isEnabled: true, + reads: { + none: { userSerialNum }, + }, + AND: [ + { OR: [{ publishedAt: null }, { publishedAt: { lte: now } }] }, + { OR: [{ expiresAt: null }, { expiresAt: { gt: now } }] }, + { + OR: [ + { targetType: 'ALL' }, + { + targetType: 'SPECIFIC', + userTargets: { + some: { accountSequence: userSerialNum }, + }, + }, + ], + }, + ], + }, + }); + } + + /** + * 移动端:标记通知为已读 + */ + async markAsRead(notificationId: string, userSerialNum: string) { + await this.prisma.notificationRead.upsert({ + where: { + notificationId_userSerialNum: { + notificationId, + userSerialNum, + }, + }, + create: { notificationId, userSerialNum }, + update: {}, + }); + } + + /** + * 移动端:标记全部已读 + */ + async markAllAsRead(userSerialNum: string) { + const now = new Date(); + + const unreadNotifications = await this.prisma.notification.findMany({ + where: { + isEnabled: true, + reads: { + none: { userSerialNum }, + }, + AND: [ + { OR: [{ publishedAt: null }, { publishedAt: { lte: now } }] }, + { OR: [{ expiresAt: null }, { expiresAt: { gt: now } }] }, + { + OR: [ + { targetType: 'ALL' }, + { + targetType: 'SPECIFIC', + userTargets: { + some: { accountSequence: userSerialNum }, + }, + }, + ], + }, + ], + }, + select: { id: true }, + }); + + if (unreadNotifications.length > 0) { + await this.prisma.notificationRead.createMany({ + data: unreadNotifications.map((n) => ({ + notificationId: n.id, + userSerialNum, + })), + skipDuplicates: true, + }); + } + } + + /** + * 格式化通知(包含目标配置) + */ + private formatNotification(notification: any) { + const targetConfig = + notification.targetType === 'SPECIFIC' && notification.userTargets?.length + ? { + accountSequences: notification.userTargets.map( + (t: any) => t.accountSequence, + ), + } + : null; + + return { + id: notification.id, + title: notification.title, + content: notification.content, + type: notification.type, + priority: notification.priority, + targetType: notification.targetType, + targetConfig, + imageUrl: notification.imageUrl, + linkUrl: notification.linkUrl, + isEnabled: notification.isEnabled, + requiresForceRead: notification.requiresForceRead, + publishedAt: notification.publishedAt, + expiresAt: notification.expiresAt, + createdAt: notification.createdAt, + }; + } +} diff --git a/frontend/mining-admin-web/src/app/(dashboard)/notifications/page.tsx b/frontend/mining-admin-web/src/app/(dashboard)/notifications/page.tsx new file mode 100644 index 00000000..0a96857d --- /dev/null +++ b/frontend/mining-admin-web/src/app/(dashboard)/notifications/page.tsx @@ -0,0 +1,593 @@ +/** + * 通知管理页面 + * + * 功能: + * - 通知列表:展示所有通知,支持按类型筛选 + 分页 + * - 新建/编辑通知:Dialog 表单,支持标题、内容、类型、优先级、 + * 目标类型(全部/指定用户)、图片URL、链接URL、强制弹窗、发布/过期时间 + * - 删除通知:确认对话框 + * + * 数据流: + * notificationsApi (axios) → useNotifications (react-query) → 本页面渲染 + */ +'use client'; + +import { useState } from 'react'; +import { PageHeader } from '@/components/layout/page-header'; +import { + useNotifications, + useCreateNotification, + useUpdateNotification, + useDeleteNotification, +} from '@/features/notifications/hooks/use-notifications'; +import type { + NotificationItem, + NotificationType, + NotificationPriority, + TargetType, + CreateNotificationDto, +} from '@/features/notifications/api/notifications.api'; +import { Card, CardContent } from '@/components/ui/card'; +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from '@/components/ui/table'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from '@/components/ui/select'; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, +} from '@/components/ui/dialog'; +import { Label } from '@/components/ui/label'; +import { ChevronLeft, ChevronRight, Plus, Pencil, Trash2 } from 'lucide-react'; +import { formatDateTime } from '@/lib/utils/date'; + +// ==================== 常量映射 ==================== + +/** 通知类型标签与样式 */ +const typeLabels: Record = { + SYSTEM: { label: '系统', className: 'bg-blue-100 text-blue-700' }, + ACTIVITY: { label: '活动', className: 'bg-green-100 text-green-700' }, + REWARD: { label: '收益', className: 'bg-yellow-100 text-yellow-700' }, + UPGRADE: { label: '升级', className: 'bg-purple-100 text-purple-700' }, + ANNOUNCEMENT: { label: '公告', className: 'bg-orange-100 text-orange-700' }, +}; + +/** 优先级标签与样式 */ +const priorityLabels: Record = { + LOW: { label: '低', className: 'bg-gray-100 text-gray-600' }, + NORMAL: { label: '普通', className: 'bg-blue-100 text-blue-600' }, + HIGH: { label: '高', className: 'bg-orange-100 text-orange-700' }, + URGENT: { label: '紧急', className: 'bg-red-100 text-red-700' }, +}; + +/** 目标类型标签 */ +const targetLabels: Record = { + ALL: '全部用户', + SPECIFIC: '指定用户', +}; + +// ==================== 表单初始值 ==================== + +const emptyForm: CreateNotificationDto = { + title: '', + content: '', + type: 'SYSTEM', + priority: 'NORMAL', + targetType: 'ALL', + targetAccountSequences: [], + imageUrl: '', + linkUrl: '', + isEnabled: true, + requiresForceRead: false, + publishedAt: '', + expiresAt: '', +}; + +// ==================== 主页面组件 ==================== + +export default function NotificationsPage() { + // ---- 列表状态 ---- + const [filterType, setFilterType] = useState('ALL'); + const [page, setPage] = useState(0); + const pageSize = 20; + + // ---- 查询通知列表 ---- + const { data, isLoading } = useNotifications({ + type: filterType === 'ALL' ? undefined : (filterType as NotificationType), + limit: pageSize, + offset: page * pageSize, + }); + + // ---- CRUD mutations ---- + const createMutation = useCreateNotification(); + const updateMutation = useUpdateNotification(); + const deleteMutation = useDeleteNotification(); + + // ---- 编辑 Dialog 状态 ---- + const [dialogOpen, setDialogOpen] = useState(false); + const [editingId, setEditingId] = useState(null); + const [form, setForm] = useState({ ...emptyForm }); + /** 指定用户的文本输入(逗号/换行分隔) */ + const [targetUsersText, setTargetUsersText] = useState(''); + + // ---- 删除确认 Dialog 状态 ---- + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [deletingId, setDeletingId] = useState(null); + + // ---- 打开新建 Dialog ---- + const handleCreate = () => { + setEditingId(null); + setForm({ ...emptyForm }); + setTargetUsersText(''); + setDialogOpen(true); + }; + + // ---- 打开编辑 Dialog ---- + const handleEdit = (item: NotificationItem) => { + setEditingId(item.id); + setForm({ + title: item.title, + content: item.content, + type: item.type, + priority: item.priority, + targetType: item.targetType, + targetAccountSequences: item.targetConfig?.accountSequences || [], + imageUrl: item.imageUrl || '', + linkUrl: item.linkUrl || '', + isEnabled: item.isEnabled, + requiresForceRead: item.requiresForceRead, + publishedAt: item.publishedAt ? item.publishedAt.slice(0, 16) : '', // datetime-local 格式 + expiresAt: item.expiresAt ? item.expiresAt.slice(0, 16) : '', + }); + setTargetUsersText( + item.targetConfig?.accountSequences?.join('\n') || '' + ); + setDialogOpen(true); + }; + + // ---- 提交表单(创建 or 更新) ---- + const handleSubmit = () => { + // 解析指定用户文本 → 账号数组(去空行去空格) + const targetAccountSequences = + form.targetType === 'SPECIFIC' + ? targetUsersText + .split(/[,\n]/) + .map((s) => s.trim()) + .filter(Boolean) + : []; + + const payload: CreateNotificationDto = { + ...form, + targetAccountSequences, + // 空字符串转 undefined,避免后端报错 + imageUrl: form.imageUrl || undefined, + linkUrl: form.linkUrl || undefined, + publishedAt: form.publishedAt ? new Date(form.publishedAt).toISOString() : undefined, + expiresAt: form.expiresAt ? new Date(form.expiresAt).toISOString() : undefined, + }; + + if (editingId) { + updateMutation.mutate( + { id: editingId, dto: payload }, + { onSuccess: () => setDialogOpen(false) } + ); + } else { + createMutation.mutate(payload, { + onSuccess: () => setDialogOpen(false), + }); + } + }; + + // ---- 删除确认 ---- + const handleDelete = (id: string) => { + setDeletingId(id); + setDeleteDialogOpen(true); + }; + + const confirmDelete = () => { + if (deletingId) { + deleteMutation.mutate(deletingId, { + onSuccess: () => setDeleteDialogOpen(false), + }); + } + }; + + // ---- 渲染 Badge ---- + const renderBadge = (text: string, className: string) => ( + + {text} + + ); + + // ---- 骨架行 ---- + const renderSkeletonRows = () => + [...Array(5)].map((_, i) => ( + + {[...Array(8)].map((_, j) => ( + + ))} + + )); + + // ---- 分页计算 ---- + const total = data?.total || 0; + const totalPages = Math.ceil(total / pageSize); + + return ( +
+ {/* 页头 + 新建按钮 */} + + + 新建通知 + + } + /> + + {/* 类型筛选 */} + + +
+ +
+
+
+ + {/* 通知列表表格 */} + + + + + + 标题 + 类型 + 优先级 + 目标 + 强制弹窗 + 启用 + 发布时间 + 操作 + + + + {isLoading ? ( + renderSkeletonRows() + ) : !data?.items?.length ? ( + + + 暂无通知 + + + ) : ( + data.items.map((item) => ( + + + {item.title} + + + {renderBadge( + typeLabels[item.type]?.label || item.type, + typeLabels[item.type]?.className || '' + )} + + + {renderBadge( + priorityLabels[item.priority]?.label || item.priority, + priorityLabels[item.priority]?.className || '' + )} + + + {renderBadge( + targetLabels[item.targetType] || item.targetType, + item.targetType === 'ALL' + ? 'bg-green-100 text-green-700' + : 'bg-yellow-100 text-yellow-700' + )} + + + {item.requiresForceRead ? ( + + ) : ( + + )} + + + {item.isEnabled ? ( + 启用 + ) : ( + 禁用 + )} + + {formatDateTime(item.publishedAt)} + +
+ + +
+
+
+ )) + )} +
+
+ + {/* 分页 */} + {totalPages > 1 && ( +
+

+ 共 {total} 条,第 {page + 1} / {totalPages} 页 +

+
+ + +
+
+ )} +
+
+ + {/* ==================== 新建/编辑 Dialog ==================== */} + + + + {editingId ? '编辑通知' : '新建通知'} + + {editingId ? '修改通知内容,保存后立即生效' : '创建新的系统通知,可推送给全部或指定用户'} + + + +
+ {/* 标题 */} +
+ + setForm({ ...form, title: e.target.value })} + placeholder="输入通知标题" + /> +
+ + {/* 内容 */} +
+ +