feat(notifications): 2.0系统通知弹窗功能(后端+管理端+APP端)

复制1.0通知系统架构到2.0系统,实现完整的通知推送功能:

后端 (mining-admin-service):
- Prisma Schema: 添加 Notification/NotificationRead/NotificationUserTarget 表
- NotificationService: 完整 CRUD + 移动端通知查询/已读标记
- AdminNotificationController: 管理端通知 CRUD API
- MobileNotificationController: 移动端通知列表/未读数/标记已读 API

管理端 (mining-admin-web):
- 通知管理页面: 列表/筛选/新建/编辑/删除 Dialog
- 支持类型/优先级/目标用户/强制弹窗/发布时间等完整配置
- 侧边栏添加"通知管理"入口

APP端 (mining-app):
- NotificationService: 通知API服务(经Kong网关路由)
- NotificationBadgeProvider: 30秒轮询未读数量+生命周期监听
- ForceReadNotificationDialog: 强制阅读弹窗(橙色主题,逐条查看+确认)
- NotificationInboxPage: 通知收件箱(支持dark/light主题)
- MainShell: 添加强制弹窗检查(启动+前台恢复,60秒冷却)
- ProfilePage: 用户头部添加通知图标+未读角标

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-02 08:35:16 -08:00
parent 59f7bdc137
commit 7c781c7d62
17 changed files with 2861 additions and 4 deletions

View File

@ -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")
}

View File

@ -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 {}

View File

@ -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 };
}
}

View File

@ -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 {

View File

@ -0,0 +1,357 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
import { NotificationType, TargetType, Prisma } from '@prisma/client';
export interface CreateNotificationDto {
title: string;
content: string;
type?: NotificationType;
priority?: string;
targetType?: TargetType;
targetConfig?: {
accountSequences?: string[];
};
imageUrl?: string;
linkUrl?: string;
requiresForceRead?: boolean;
publishedAt?: string;
expiresAt?: string;
}
export interface UpdateNotificationDto {
title?: string;
content?: string;
type?: NotificationType;
priority?: string;
targetType?: TargetType;
targetConfig?: {
accountSequences?: string[];
};
imageUrl?: string | null;
linkUrl?: string | null;
isEnabled?: boolean;
requiresForceRead?: boolean;
publishedAt?: string | null;
expiresAt?: string | null;
}
@Injectable()
export class NotificationService {
constructor(private readonly prisma: PrismaService) {}
/**
*
*/
async create(dto: CreateNotificationDto) {
const targetType = dto.targetType || 'ALL';
return this.prisma.$transaction(async (tx) => {
const notification = await tx.notification.create({
data: {
title: dto.title,
content: dto.content,
type: dto.type || 'SYSTEM',
priority: dto.priority || 'NORMAL',
targetType,
imageUrl: dto.imageUrl || null,
linkUrl: dto.linkUrl || null,
requiresForceRead: dto.requiresForceRead ?? false,
publishedAt: dto.publishedAt ? new Date(dto.publishedAt) : null,
expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : null,
},
});
// SPECIFIC 目标:创建用户关联
if (
targetType === 'SPECIFIC' &&
dto.targetConfig?.accountSequences?.length
) {
await tx.notificationUserTarget.createMany({
data: dto.targetConfig.accountSequences.map((accountSequence) => ({
notificationId: notification.id,
accountSequence,
})),
});
}
return this.findById(notification.id, tx);
});
}
/**
*
*/
async findById(id: string, tx?: any) {
const db = tx || this.prisma;
const notification = await db.notification.findUnique({
where: { id },
include: {
userTargets: { select: { accountSequence: true } },
},
});
if (!notification) return null;
return this.formatNotification(notification);
}
/**
*
*/
async findAll(params?: {
type?: NotificationType;
limit?: number;
offset?: number;
}) {
const where: Prisma.NotificationWhereInput = {};
if (params?.type) where.type = params.type;
const [notifications, total] = await Promise.all([
this.prisma.notification.findMany({
where,
include: {
userTargets: { select: { accountSequence: true } },
},
orderBy: { createdAt: 'desc' },
take: params?.limit ?? 50,
skip: params?.offset ?? 0,
}),
this.prisma.notification.count({ where }),
]);
return {
notifications: notifications.map((n) => this.formatNotification(n)),
total,
};
}
/**
*
*/
async update(id: string, dto: UpdateNotificationDto) {
return this.prisma.$transaction(async (tx) => {
const data: any = {};
if (dto.title !== undefined) data.title = dto.title;
if (dto.content !== undefined) data.content = dto.content;
if (dto.type !== undefined) data.type = dto.type;
if (dto.priority !== undefined) data.priority = dto.priority;
if (dto.targetType !== undefined) data.targetType = dto.targetType;
if (dto.imageUrl !== undefined) data.imageUrl = dto.imageUrl;
if (dto.linkUrl !== undefined) data.linkUrl = dto.linkUrl;
if (dto.isEnabled !== undefined) data.isEnabled = dto.isEnabled;
if (dto.requiresForceRead !== undefined)
data.requiresForceRead = dto.requiresForceRead;
if (dto.publishedAt !== undefined)
data.publishedAt = dto.publishedAt ? new Date(dto.publishedAt) : null;
if (dto.expiresAt !== undefined)
data.expiresAt = dto.expiresAt ? new Date(dto.expiresAt) : null;
await tx.notification.update({ where: { id }, data });
// 如果更新了目标类型或目标配置,重建用户关联
if (dto.targetType !== undefined || dto.targetConfig !== undefined) {
await tx.notificationUserTarget.deleteMany({
where: { notificationId: id },
});
const targetType = dto.targetType || (await tx.notification.findUnique({ where: { id }, select: { targetType: true } }))?.targetType;
if (
targetType === 'SPECIFIC' &&
dto.targetConfig?.accountSequences?.length
) {
await tx.notificationUserTarget.createMany({
data: dto.targetConfig.accountSequences.map((accountSequence) => ({
notificationId: id,
accountSequence,
})),
});
}
}
return this.findById(id, tx);
});
}
/**
*
*/
async delete(id: string) {
await this.prisma.notification.delete({ where: { id } });
}
/**
*
*/
async findNotificationsForUser(params: {
userSerialNum: string;
type?: NotificationType;
limit?: number;
offset?: number;
}) {
const now = new Date();
const notifications = await this.prisma.notification.findMany({
where: {
isEnabled: true,
...(params.type && { type: params.type }),
AND: [
{ OR: [{ publishedAt: null }, { publishedAt: { lte: now } }] },
{ OR: [{ expiresAt: null }, { expiresAt: { gt: now } }] },
{
OR: [
{ targetType: 'ALL' },
{
targetType: 'SPECIFIC',
userTargets: {
some: { accountSequence: params.userSerialNum },
},
},
],
},
],
},
include: {
reads: {
where: { userSerialNum: params.userSerialNum },
take: 1,
},
},
orderBy: [{ priority: 'desc' }, { publishedAt: 'desc' }],
take: params.limit ?? 50,
skip: params.offset ?? 0,
});
return notifications.map((n) => ({
id: n.id,
title: n.title,
content: n.content,
type: n.type,
priority: n.priority,
imageUrl: n.imageUrl,
linkUrl: n.linkUrl,
publishedAt: n.publishedAt,
isRead: n.reads.length > 0,
readAt: n.reads[0]?.readAt ?? null,
requiresForceRead: n.requiresForceRead,
}));
}
/**
*
*/
async countUnreadForUser(userSerialNum: string): Promise<number> {
const now = new Date();
return this.prisma.notification.count({
where: {
isEnabled: true,
reads: {
none: { userSerialNum },
},
AND: [
{ OR: [{ publishedAt: null }, { publishedAt: { lte: now } }] },
{ OR: [{ expiresAt: null }, { expiresAt: { gt: now } }] },
{
OR: [
{ targetType: 'ALL' },
{
targetType: 'SPECIFIC',
userTargets: {
some: { accountSequence: userSerialNum },
},
},
],
},
],
},
});
}
/**
*
*/
async markAsRead(notificationId: string, userSerialNum: string) {
await this.prisma.notificationRead.upsert({
where: {
notificationId_userSerialNum: {
notificationId,
userSerialNum,
},
},
create: { notificationId, userSerialNum },
update: {},
});
}
/**
*
*/
async markAllAsRead(userSerialNum: string) {
const now = new Date();
const unreadNotifications = await this.prisma.notification.findMany({
where: {
isEnabled: true,
reads: {
none: { userSerialNum },
},
AND: [
{ OR: [{ publishedAt: null }, { publishedAt: { lte: now } }] },
{ OR: [{ expiresAt: null }, { expiresAt: { gt: now } }] },
{
OR: [
{ targetType: 'ALL' },
{
targetType: 'SPECIFIC',
userTargets: {
some: { accountSequence: userSerialNum },
},
},
],
},
],
},
select: { id: true },
});
if (unreadNotifications.length > 0) {
await this.prisma.notificationRead.createMany({
data: unreadNotifications.map((n) => ({
notificationId: n.id,
userSerialNum,
})),
skipDuplicates: true,
});
}
}
/**
*
*/
private formatNotification(notification: any) {
const targetConfig =
notification.targetType === 'SPECIFIC' && notification.userTargets?.length
? {
accountSequences: notification.userTargets.map(
(t: any) => t.accountSequence,
),
}
: null;
return {
id: notification.id,
title: notification.title,
content: notification.content,
type: notification.type,
priority: notification.priority,
targetType: notification.targetType,
targetConfig,
imageUrl: notification.imageUrl,
linkUrl: notification.linkUrl,
isEnabled: notification.isEnabled,
requiresForceRead: notification.requiresForceRead,
publishedAt: notification.publishedAt,
expiresAt: notification.expiresAt,
createdAt: notification.createdAt,
};
}
}

View File

@ -0,0 +1,593 @@
/**
*
*
*
* - +
* - /Dialog
* /URLURL/
* -
*
*
* notificationsApi (axios) useNotifications (react-query)
*/
'use client';
import { useState } from 'react';
import { PageHeader } from '@/components/layout/page-header';
import {
useNotifications,
useCreateNotification,
useUpdateNotification,
useDeleteNotification,
} from '@/features/notifications/hooks/use-notifications';
import type {
NotificationItem,
NotificationType,
NotificationPriority,
TargetType,
CreateNotificationDto,
} from '@/features/notifications/api/notifications.api';
import { Card, CardContent } from '@/components/ui/card';
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Skeleton } from '@/components/ui/skeleton';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { ChevronLeft, ChevronRight, Plus, Pencil, Trash2 } from 'lucide-react';
import { formatDateTime } from '@/lib/utils/date';
// ==================== 常量映射 ====================
/** 通知类型标签与样式 */
const typeLabels: Record<NotificationType, { label: string; className: string }> = {
SYSTEM: { label: '系统', className: 'bg-blue-100 text-blue-700' },
ACTIVITY: { label: '活动', className: 'bg-green-100 text-green-700' },
REWARD: { label: '收益', className: 'bg-yellow-100 text-yellow-700' },
UPGRADE: { label: '升级', className: 'bg-purple-100 text-purple-700' },
ANNOUNCEMENT: { label: '公告', className: 'bg-orange-100 text-orange-700' },
};
/** 优先级标签与样式 */
const priorityLabels: Record<NotificationPriority, { label: string; className: string }> = {
LOW: { label: '低', className: 'bg-gray-100 text-gray-600' },
NORMAL: { label: '普通', className: 'bg-blue-100 text-blue-600' },
HIGH: { label: '高', className: 'bg-orange-100 text-orange-700' },
URGENT: { label: '紧急', className: 'bg-red-100 text-red-700' },
};
/** 目标类型标签 */
const targetLabels: Record<TargetType, string> = {
ALL: '全部用户',
SPECIFIC: '指定用户',
};
// ==================== 表单初始值 ====================
const emptyForm: CreateNotificationDto = {
title: '',
content: '',
type: 'SYSTEM',
priority: 'NORMAL',
targetType: 'ALL',
targetAccountSequences: [],
imageUrl: '',
linkUrl: '',
isEnabled: true,
requiresForceRead: false,
publishedAt: '',
expiresAt: '',
};
// ==================== 主页面组件 ====================
export default function NotificationsPage() {
// ---- 列表状态 ----
const [filterType, setFilterType] = useState<string>('ALL');
const [page, setPage] = useState(0);
const pageSize = 20;
// ---- 查询通知列表 ----
const { data, isLoading } = useNotifications({
type: filterType === 'ALL' ? undefined : (filterType as NotificationType),
limit: pageSize,
offset: page * pageSize,
});
// ---- CRUD mutations ----
const createMutation = useCreateNotification();
const updateMutation = useUpdateNotification();
const deleteMutation = useDeleteNotification();
// ---- 编辑 Dialog 状态 ----
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<CreateNotificationDto>({ ...emptyForm });
/** 指定用户的文本输入(逗号/换行分隔) */
const [targetUsersText, setTargetUsersText] = useState('');
// ---- 删除确认 Dialog 状态 ----
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
// ---- 打开新建 Dialog ----
const handleCreate = () => {
setEditingId(null);
setForm({ ...emptyForm });
setTargetUsersText('');
setDialogOpen(true);
};
// ---- 打开编辑 Dialog ----
const handleEdit = (item: NotificationItem) => {
setEditingId(item.id);
setForm({
title: item.title,
content: item.content,
type: item.type,
priority: item.priority,
targetType: item.targetType,
targetAccountSequences: item.targetConfig?.accountSequences || [],
imageUrl: item.imageUrl || '',
linkUrl: item.linkUrl || '',
isEnabled: item.isEnabled,
requiresForceRead: item.requiresForceRead,
publishedAt: item.publishedAt ? item.publishedAt.slice(0, 16) : '', // datetime-local 格式
expiresAt: item.expiresAt ? item.expiresAt.slice(0, 16) : '',
});
setTargetUsersText(
item.targetConfig?.accountSequences?.join('\n') || ''
);
setDialogOpen(true);
};
// ---- 提交表单(创建 or 更新) ----
const handleSubmit = () => {
// 解析指定用户文本 → 账号数组(去空行去空格)
const targetAccountSequences =
form.targetType === 'SPECIFIC'
? targetUsersText
.split(/[,\n]/)
.map((s) => s.trim())
.filter(Boolean)
: [];
const payload: CreateNotificationDto = {
...form,
targetAccountSequences,
// 空字符串转 undefined避免后端报错
imageUrl: form.imageUrl || undefined,
linkUrl: form.linkUrl || undefined,
publishedAt: form.publishedAt ? new Date(form.publishedAt).toISOString() : undefined,
expiresAt: form.expiresAt ? new Date(form.expiresAt).toISOString() : undefined,
};
if (editingId) {
updateMutation.mutate(
{ id: editingId, dto: payload },
{ onSuccess: () => setDialogOpen(false) }
);
} else {
createMutation.mutate(payload, {
onSuccess: () => setDialogOpen(false),
});
}
};
// ---- 删除确认 ----
const handleDelete = (id: string) => {
setDeletingId(id);
setDeleteDialogOpen(true);
};
const confirmDelete = () => {
if (deletingId) {
deleteMutation.mutate(deletingId, {
onSuccess: () => setDeleteDialogOpen(false),
});
}
};
// ---- 渲染 Badge ----
const renderBadge = (text: string, className: string) => (
<span className={`inline-flex items-center px-2 py-1 rounded text-xs font-medium ${className}`}>
{text}
</span>
);
// ---- 骨架行 ----
const renderSkeletonRows = () =>
[...Array(5)].map((_, i) => (
<TableRow key={i}>
{[...Array(8)].map((_, j) => (
<TableCell key={j}><Skeleton className="h-4 w-full" /></TableCell>
))}
</TableRow>
));
// ---- 分页计算 ----
const total = data?.total || 0;
const totalPages = Math.ceil(total / pageSize);
return (
<div className="space-y-6">
{/* 页头 + 新建按钮 */}
<PageHeader
title="通知管理"
description="管理系统通知,支持全部推送和指定用户推送"
actions={
<Button onClick={handleCreate}>
<Plus className="h-4 w-4 mr-2" />
</Button>
}
/>
{/* 类型筛选 */}
<Card>
<CardContent className="p-4">
<div className="flex gap-3">
<Select value={filterType} onValueChange={(v) => { setFilterType(v); setPage(0); }}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="通知类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL"></SelectItem>
<SelectItem value="SYSTEM"></SelectItem>
<SelectItem value="ACTIVITY"></SelectItem>
<SelectItem value="REWARD"></SelectItem>
<SelectItem value="UPGRADE"></SelectItem>
<SelectItem value="ANNOUNCEMENT"></SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* 通知列表表格 */}
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
renderSkeletonRows()
) : !data?.items?.length ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
</TableCell>
</TableRow>
) : (
data.items.map((item) => (
<TableRow key={item.id}>
<TableCell className="max-w-[200px] truncate font-medium">
{item.title}
</TableCell>
<TableCell>
{renderBadge(
typeLabels[item.type]?.label || item.type,
typeLabels[item.type]?.className || ''
)}
</TableCell>
<TableCell>
{renderBadge(
priorityLabels[item.priority]?.label || item.priority,
priorityLabels[item.priority]?.className || ''
)}
</TableCell>
<TableCell>
{renderBadge(
targetLabels[item.targetType] || item.targetType,
item.targetType === 'ALL'
? 'bg-green-100 text-green-700'
: 'bg-yellow-100 text-yellow-700'
)}
</TableCell>
<TableCell>
{item.requiresForceRead ? (
<span className="text-red-600 font-medium"></span>
) : (
<span className="text-muted-foreground"></span>
)}
</TableCell>
<TableCell>
{item.isEnabled ? (
<span className="text-green-600 font-medium"></span>
) : (
<span className="text-muted-foreground"></span>
)}
</TableCell>
<TableCell>{formatDateTime(item.publishedAt)}</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleEdit(item)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => handleDelete(item.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
{/* 分页 */}
{totalPages > 1 && (
<div className="flex items-center justify-between p-4 border-t">
<p className="text-sm text-muted-foreground">
{total} {page + 1} / {totalPages}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage(page - 1)}
disabled={page <= 0}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage(page + 1)}
disabled={page + 1 >= totalPages}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</CardContent>
</Card>
{/* ==================== 新建/编辑 Dialog ==================== */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingId ? '编辑通知' : '新建通知'}</DialogTitle>
<DialogDescription>
{editingId ? '修改通知内容,保存后立即生效' : '创建新的系统通知,可推送给全部或指定用户'}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
{/* 标题 */}
<div className="grid gap-2">
<Label htmlFor="title"> *</Label>
<Input
id="title"
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
placeholder="输入通知标题"
/>
</div>
{/* 内容 */}
<div className="grid gap-2">
<Label htmlFor="content"> *</Label>
<Textarea
id="content"
value={form.content}
onChange={(e) => setForm({ ...form, content: e.target.value })}
placeholder="输入通知内容"
rows={4}
/>
</div>
{/* 类型 + 优先级(同行) */}
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label></Label>
<Select
value={form.type}
onValueChange={(v) => setForm({ ...form, type: v as NotificationType })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="SYSTEM"></SelectItem>
<SelectItem value="ACTIVITY"></SelectItem>
<SelectItem value="REWARD"></SelectItem>
<SelectItem value="UPGRADE"></SelectItem>
<SelectItem value="ANNOUNCEMENT"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label></Label>
<Select
value={form.priority}
onValueChange={(v) => setForm({ ...form, priority: v as NotificationPriority })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="LOW"></SelectItem>
<SelectItem value="NORMAL"></SelectItem>
<SelectItem value="HIGH"></SelectItem>
<SelectItem value="URGENT"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 目标类型 */}
<div className="grid gap-2">
<Label></Label>
<Select
value={form.targetType}
onValueChange={(v) => setForm({ ...form, targetType: v as TargetType })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="ALL"></SelectItem>
<SelectItem value="SPECIFIC"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 指定用户输入targetType=SPECIFIC 时显示) */}
{form.targetType === 'SPECIFIC' && (
<div className="grid gap-2">
<Label htmlFor="targetUsers"></Label>
<Textarea
id="targetUsers"
value={targetUsersText}
onChange={(e) => setTargetUsersText(e.target.value)}
placeholder="D25121400001&#10;D25121400002"
rows={3}
/>
</div>
)}
{/* 图片URL + 链接URL */}
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="imageUrl"> URL</Label>
<Input
id="imageUrl"
value={form.imageUrl || ''}
onChange={(e) => setForm({ ...form, imageUrl: e.target.value })}
placeholder="https://..."
/>
</div>
<div className="grid gap-2">
<Label htmlFor="linkUrl"> URL</Label>
<Input
id="linkUrl"
value={form.linkUrl || ''}
onChange={(e) => setForm({ ...form, linkUrl: e.target.value })}
placeholder="https://..."
/>
</div>
</div>
{/* 发布时间 + 过期时间 */}
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="publishedAt"></Label>
<Input
id="publishedAt"
type="datetime-local"
value={form.publishedAt || ''}
onChange={(e) => setForm({ ...form, publishedAt: e.target.value })}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="expiresAt"></Label>
<Input
id="expiresAt"
type="datetime-local"
value={form.expiresAt || ''}
onChange={(e) => setForm({ ...form, expiresAt: e.target.value })}
/>
</div>
</div>
{/* 复选框:强制弹窗 + 启用 */}
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<input
type="checkbox"
id="requiresForceRead"
checked={form.requiresForceRead}
onChange={(e) =>
setForm({ ...form, requiresForceRead: e.target.checked })
}
className="h-4 w-4 rounded border-gray-300"
/>
<Label htmlFor="requiresForceRead" className="text-sm">
APP时必须查看
</Label>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="isEnabled"
checked={form.isEnabled}
onChange={(e) =>
setForm({ ...form, isEnabled: e.target.checked })
}
className="h-4 w-4 rounded border-gray-300"
/>
<Label htmlFor="isEnabled" className="text-sm"></Label>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
</Button>
<Button
onClick={handleSubmit}
disabled={
!form.title?.trim() ||
!form.content?.trim() ||
createMutation.isPending ||
updateMutation.isPending
}
>
{createMutation.isPending || updateMutation.isPending ? '提交中...' : '保存'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ==================== 删除确认 Dialog ==================== */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
</Button>
<Button
variant="destructive"
onClick={confirmDelete}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? '删除中...' : '确认删除'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -21,6 +21,7 @@ import {
SendHorizontal,
HardDrive,
Repeat,
Bell,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
@ -34,6 +35,7 @@ const menuItems = [
{ name: 'C2C Bot', href: '/c2c-bot', icon: Zap },
{ name: '手工补发', href: '/manual-mining', icon: HandCoins },
{ name: '批量补发', href: '/batch-mining', icon: FileSpreadsheet },
{ name: '通知管理', href: '/notifications', icon: Bell },
{ name: '配置管理', href: '/configs', icon: Settings },
{ name: '系统账户', href: '/system-accounts', icon: Building2 },
{ name: '报表统计', href: '/reports', icon: FileBarChart },

View File

@ -0,0 +1,143 @@
/**
* API
*
* CRUD 使 apiClient Next.js rewrite
* mining-admin-service /api/v2/notifications
*
* : { success: boolean, data: T, timestamp: string }
* response.data.data
*/
import { apiClient } from '@/lib/api/client';
// ==================== 枚举类型(与后端 Prisma 枚举对应) ====================
/** 通知类型 */
export type NotificationType = 'SYSTEM' | 'ACTIVITY' | 'REWARD' | 'UPGRADE' | 'ANNOUNCEMENT';
/** 通知优先级 */
export type NotificationPriority = 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT';
/** 目标用户类型: ALL=全部用户, SPECIFIC=指定用户 */
export type TargetType = 'ALL' | 'SPECIFIC';
// ==================== 数据类型 ====================
/** 通知列表项(后端 formatNotification 返回格式) */
export interface NotificationItem {
id: string;
title: string;
content: string;
type: NotificationType;
priority: NotificationPriority;
targetType: TargetType;
imageUrl: string | null;
linkUrl: string | null;
isEnabled: boolean;
requiresForceRead: boolean;
publishedAt: string | null;
expiresAt: string | null;
createdAt: string;
/** 指定目标配置(后端格式,仅 SPECIFIC 类型时有值) */
targetConfig?: { accountSequences: string[] } | null;
}
/** 创建通知请求体 */
export interface CreateNotificationDto {
title: string;
content: string;
type?: NotificationType;
priority?: NotificationPriority;
targetType?: TargetType;
/** 指定用户账号序列号列表targetType=SPECIFIC 时必填) */
targetAccountSequences?: string[];
imageUrl?: string;
linkUrl?: string;
isEnabled?: boolean;
requiresForceRead?: boolean;
publishedAt?: string;
expiresAt?: string;
}
/** 更新通知请求体(所有字段可选) */
export type UpdateNotificationDto = Partial<CreateNotificationDto>;
/** 通知列表查询参数 */
export interface NotificationListParams {
type?: NotificationType;
limit?: number;
offset?: number;
}
/** 通知列表响应 */
export interface NotificationListResponse {
items: NotificationItem[];
total: number;
}
// ==================== API 方法 ====================
export const notificationsApi = {
/**
*
* GET /notifications?type=&limit=&offset=
*/
getList: async (params: NotificationListParams = {}): Promise<NotificationListResponse> => {
const response = await apiClient.get('/notifications', { params });
const data = response.data.data || response.data;
// 后端 findAll 返回 { notifications: [...], total: number }
return {
items: data.notifications || data.items || [],
total: data.total || 0,
};
},
/**
*
* GET /notifications/:id
*/
getById: async (id: string): Promise<NotificationItem> => {
const response = await apiClient.get(`/notifications/${id}`);
return response.data.data || response.data;
},
/**
*
* POST /notifications
* DTO 使 targetAccountSequences targetConfig.accountSequences
*/
create: async (dto: CreateNotificationDto): Promise<NotificationItem> => {
const { targetAccountSequences, ...rest } = dto;
const payload = {
...rest,
...(targetAccountSequences?.length && {
targetConfig: { accountSequences: targetAccountSequences },
}),
};
const response = await apiClient.post('/notifications', payload);
return response.data.data || response.data;
},
/**
*
* PUT /notifications/:id
*/
update: async (id: string, dto: UpdateNotificationDto): Promise<NotificationItem> => {
const { targetAccountSequences, ...rest } = dto;
const payload = {
...rest,
...(targetAccountSequences !== undefined && {
targetConfig: { accountSequences: targetAccountSequences || [] },
}),
};
const response = await apiClient.put(`/notifications/${id}`, payload);
return response.data.data || response.data;
},
/**
*
* DELETE /notifications/:id
*/
delete: async (id: string): Promise<void> => {
await apiClient.delete(`/notifications/${id}`);
},
};

View File

@ -0,0 +1,85 @@
/**
* React Query Hooks
*
* CRUD
* - useNotifications: 获取通知列表 +
* - useCreateNotification: 创建通知
* - useUpdateNotification: 更新通知
* - useDeleteNotification: 删除通知
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { notificationsApi } from '../api/notifications.api';
import type { NotificationListParams, CreateNotificationDto, UpdateNotificationDto } from '../api/notifications.api';
import { useToast } from '@/lib/hooks/use-toast';
/** 查询通知列表 */
export function useNotifications(params: NotificationListParams = {}) {
return useQuery({
queryKey: ['notifications', params],
queryFn: () => notificationsApi.getList(params),
});
}
/** 创建通知,成功后 invalidate 列表缓存并提示 */
export function useCreateNotification() {
const queryClient = useQueryClient();
const { toast } = useToast();
return useMutation({
mutationFn: (dto: CreateNotificationDto) => notificationsApi.create(dto),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
toast({ title: '通知创建成功', variant: 'success' as any });
},
onError: (error: any) => {
toast({
title: '创建失败',
description: error?.response?.data?.message || '请稍后重试',
variant: 'destructive',
});
},
});
}
/** 更新通知,成功后 invalidate 列表缓存并提示 */
export function useUpdateNotification() {
const queryClient = useQueryClient();
const { toast } = useToast();
return useMutation({
mutationFn: ({ id, dto }: { id: string; dto: UpdateNotificationDto }) =>
notificationsApi.update(id, dto),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
toast({ title: '通知更新成功', variant: 'success' as any });
},
onError: (error: any) => {
toast({
title: '更新失败',
description: error?.response?.data?.message || '请稍后重试',
variant: 'destructive',
});
},
});
}
/** 删除通知,成功后 invalidate 列表缓存并提示 */
export function useDeleteNotification() {
const queryClient = useQueryClient();
const { toast } = useToast();
return useMutation({
mutationFn: (id: string) => notificationsApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
toast({ title: '通知已删除', variant: 'success' as any });
},
onError: (error: any) => {
toast({
title: '删除失败',
description: error?.response?.data?.message || '请稍后重试',
variant: 'destructive',
});
},
});
}

View File

@ -27,6 +27,7 @@ import '../../presentation/pages/trading/transfer_records_page.dart';
import '../../presentation/pages/asset/p2p_transfer_records_page.dart';
import '../../presentation/pages/profile/help_center_page.dart';
import '../../presentation/pages/profile/about_page.dart';
import '../../presentation/pages/profile/notification_inbox_page.dart';
import '../../presentation/widgets/main_shell.dart';
import '../../presentation/providers/user_providers.dart';
import 'routes.dart';
@ -179,6 +180,10 @@ final appRouterProvider = Provider<GoRouter>((ref) {
filterDirection: state.uri.queryParameters['filter'],
),
),
GoRoute(
path: Routes.notifications,
builder: (context, state) => const NotificationInboxPage(),
),
GoRoute(
path: Routes.helpCenter,
builder: (context, state) => const HelpCenterPage(),

View File

@ -29,6 +29,8 @@ class Routes {
static const String transferRecords = '/transfer-records';
// P2P转账记录
static const String p2pTransferRecords = '/p2p-transfer-records';
//
static const String notifications = '/notifications';
//
static const String helpCenter = '/help-center';
static const String about = '/about';

View File

@ -0,0 +1,270 @@
import 'package:flutter/foundation.dart';
import '../network/api_client.dart';
/// ==================== ====================
/// NotificationType
enum NotificationType {
system,
activity,
reward,
upgrade,
announcement,
}
/// NotificationPriority
enum NotificationPriority {
low,
normal,
high,
urgent,
}
/// ==================== ====================
///
///
/// MobileNotificationController GET /mobile/notifications
/// [requiresForceRead] APP /
class NotificationItem {
final String id;
final String title;
final String content;
final NotificationType type;
final NotificationPriority priority;
final String? imageUrl;
final String? linkUrl;
final DateTime? publishedAt;
final bool isRead;
final DateTime? readAt;
///
final bool requiresForceRead;
NotificationItem({
required this.id,
required this.title,
required this.content,
required this.type,
required this.priority,
this.imageUrl,
this.linkUrl,
this.publishedAt,
required this.isRead,
this.readAt,
this.requiresForceRead = false,
});
factory NotificationItem.fromJson(Map<String, dynamic> json) {
return NotificationItem(
id: json['id'] ?? '',
title: json['title'] ?? '',
content: json['content'] ?? '',
type: _parseNotificationType(json['type']),
priority: _parseNotificationPriority(json['priority']),
imageUrl: json['imageUrl'],
linkUrl: json['linkUrl'],
publishedAt: json['publishedAt'] != null
? DateTime.tryParse(json['publishedAt'])
: null,
isRead: json['isRead'] ?? false,
readAt: json['readAt'] != null ? DateTime.tryParse(json['readAt']) : null,
requiresForceRead: json['requiresForceRead'] ?? false,
);
}
static NotificationType _parseNotificationType(String? type) {
switch (type) {
case 'SYSTEM':
return NotificationType.system;
case 'ACTIVITY':
return NotificationType.activity;
case 'REWARD':
return NotificationType.reward;
case 'UPGRADE':
return NotificationType.upgrade;
case 'ANNOUNCEMENT':
return NotificationType.announcement;
default:
return NotificationType.system;
}
}
static NotificationPriority _parseNotificationPriority(String? priority) {
switch (priority) {
case 'LOW':
return NotificationPriority.low;
case 'NORMAL':
return NotificationPriority.normal;
case 'HIGH':
return NotificationPriority.high;
case 'URGENT':
return NotificationPriority.urgent;
default:
return NotificationPriority.normal;
}
}
///
String get typeName {
switch (type) {
case NotificationType.system:
return '系统通知';
case NotificationType.activity:
return '活动通知';
case NotificationType.reward:
return '收益通知';
case NotificationType.upgrade:
return '升级通知';
case NotificationType.announcement:
return '公告';
}
}
/// emoji
String get typeIcon {
switch (type) {
case NotificationType.system:
return '🔔';
case NotificationType.activity:
return '🎉';
case NotificationType.reward:
return '💰';
case NotificationType.upgrade:
return '⬆️';
case NotificationType.announcement:
return '📢';
}
}
}
/// API
class NotificationListResponse {
final List<NotificationItem> notifications;
final int total;
final int unreadCount;
NotificationListResponse({
required this.notifications,
required this.total,
required this.unreadCount,
});
factory NotificationListResponse.fromJson(Map<String, dynamic> json) {
final list = (json['notifications'] as List?)
?.map((e) => NotificationItem.fromJson(e as Map<String, dynamic>))
.toList() ??
[];
return NotificationListResponse(
notifications: list,
total: json['total'] ?? list.length,
unreadCount: json['unreadCount'] ?? 0,
);
}
}
/// ==================== ====================
/// API
///
/// Kong 访 mining-admin-service
/// - GET /api/v2/mining-admin/mobile/notifications
/// - GET /api/v2/mining-admin/mobile/notifications/unread-count
/// - POST /api/v2/mining-admin/mobile/notifications/mark-read
class NotificationService {
final ApiClient _apiClient;
/// API Kong mining-admin-service
static const String _basePath = '/api/v2/mining-admin/mobile/notifications';
NotificationService({required ApiClient apiClient}) : _apiClient = apiClient;
///
///
/// [userSerialNum] accountSequence
/// [type]
/// [limit] 50
/// [offset] 0
Future<NotificationListResponse> getNotifications({
required String userSerialNum,
NotificationType? type,
int limit = 50,
int offset = 0,
}) async {
try {
final queryParams = {
'userSerialNum': userSerialNum,
'limit': limit.toString(),
'offset': offset.toString(),
};
if (type != null) {
queryParams['type'] = type.name.toUpperCase();
}
final response = await _apiClient.get(
_basePath,
queryParameters: queryParams,
);
// TransformInterceptor { success, data: { notifications, total, unreadCount }, timestamp }
final data = response.data is Map && response.data['data'] != null
? response.data['data']
: response.data;
return NotificationListResponse.fromJson(data);
} catch (e) {
debugPrint('[NotificationService] 获取通知列表失败: $e');
rethrow;
}
}
///
Future<int> getUnreadCount({required String userSerialNum}) async {
try {
final response = await _apiClient.get(
'$_basePath/unread-count',
queryParameters: {'userSerialNum': userSerialNum},
);
final data = response.data is Map && response.data['data'] != null
? response.data['data']
: response.data;
return data['unreadCount'] ?? 0;
} catch (e) {
debugPrint('[NotificationService] 获取未读数量失败: $e');
return 0;
}
}
///
///
/// [notificationId]
Future<bool> markAsRead({
required String userSerialNum,
String? notificationId,
}) async {
try {
final body = {
'userSerialNum': userSerialNum,
if (notificationId != null) 'notificationId': notificationId,
};
final response = await _apiClient.post(
'$_basePath/mark-read',
data: body,
);
final data = response.data is Map && response.data['data'] != null
? response.data['data']
: response.data;
return data['success'] ?? false;
} catch (e) {
debugPrint('[NotificationService] 标记已读失败: $e');
return false;
}
}
///
Future<bool> markAllAsRead({required String userSerialNum}) async {
return markAsRead(userSerialNum: userSerialNum);
}
}

View File

@ -0,0 +1,563 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/services/notification_service.dart';
import '../../providers/notification_providers.dart';
import '../../providers/user_providers.dart';
///
///
///
/// -
/// -
/// - "全部已读"
/// - / /
///
/// 2.0 mining-app dark/light
class NotificationInboxPage extends ConsumerStatefulWidget {
const NotificationInboxPage({super.key});
@override
ConsumerState<NotificationInboxPage> createState() =>
_NotificationInboxPageState();
}
class _NotificationInboxPageState extends ConsumerState<NotificationInboxPage> {
///
List<NotificationItem> _notifications = [];
///
int _unreadCount = 0;
///
bool _isLoading = true;
///
String? _error;
//
static const Color _orange = Color(0xFFFF6B00);
@override
void initState() {
super.initState();
_loadNotifications();
}
///
Future<void> _loadNotifications() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final accountSequence = ref.read(currentAccountSequenceProvider);
if (accountSequence == null || accountSequence.isEmpty) {
setState(() {
_error = '用户未登录';
_isLoading = false;
});
return;
}
final notificationService = ref.read(notificationServiceProvider);
final response = await notificationService.getNotifications(
userSerialNum: accountSequence,
);
if (mounted) {
setState(() {
_notifications = response.notifications;
_unreadCount = response.unreadCount;
_isLoading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_error = '加载通知失败';
_isLoading = false;
});
}
}
}
///
Future<void> _markAsRead(NotificationItem notification) async {
if (notification.isRead) return;
final accountSequence = ref.read(currentAccountSequenceProvider);
if (accountSequence == null || accountSequence.isEmpty) return;
final notificationService = ref.read(notificationServiceProvider);
final success = await notificationService.markAsRead(
userSerialNum: accountSequence,
notificationId: notification.id,
);
if (success && mounted) {
setState(() {
final index = _notifications.indexWhere((n) => n.id == notification.id);
if (index != -1) {
_notifications[index] = NotificationItem(
id: notification.id,
title: notification.title,
content: notification.content,
type: notification.type,
priority: notification.priority,
imageUrl: notification.imageUrl,
linkUrl: notification.linkUrl,
publishedAt: notification.publishedAt,
isRead: true,
readAt: DateTime.now(),
requiresForceRead: notification.requiresForceRead,
);
_unreadCount = (_unreadCount - 1).clamp(0, _unreadCount);
}
});
//
ref.read(notificationBadgeProvider.notifier).decrementCount();
}
}
///
Future<void> _markAllAsRead() async {
if (_unreadCount == 0) return;
final accountSequence = ref.read(currentAccountSequenceProvider);
if (accountSequence == null || accountSequence.isEmpty) return;
final notificationService = ref.read(notificationServiceProvider);
final success = await notificationService.markAllAsRead(
userSerialNum: accountSequence,
);
if (success && mounted) {
setState(() {
_notifications = _notifications.map((n) {
if (!n.isRead) {
return NotificationItem(
id: n.id,
title: n.title,
content: n.content,
type: n.type,
priority: n.priority,
imageUrl: n.imageUrl,
linkUrl: n.linkUrl,
publishedAt: n.publishedAt,
isRead: true,
readAt: DateTime.now(),
requiresForceRead: n.requiresForceRead,
);
}
return n;
}).toList();
_unreadCount = 0;
});
//
ref.read(notificationBadgeProvider.notifier).clearCount();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('已全部标记为已读'),
backgroundColor: _orange,
),
);
}
}
}
///
void _showNotificationDetail(NotificationItem notification) {
//
_markAsRead(notification);
final isDark = Theme.of(context).brightness == Brightness.dark;
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: isDark ? const Color(0xFF1F2937) : Colors.white,
title: Row(
children: [
Text(
notification.typeIcon,
style: const TextStyle(fontSize: 20),
),
const SizedBox(width: 8),
Expanded(
child: Text(
notification.title,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: isDark ? const Color(0xFFE5E7EB) : const Color(0xFF1F2937),
),
),
),
],
),
content: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
notification.content,
style: TextStyle(
fontSize: 14,
color: isDark ? const Color(0xFF9CA3AF) : const Color(0xFF6B7280),
height: 1.5,
),
),
const SizedBox(height: 16),
Text(
_formatTime(notification.publishedAt),
style: TextStyle(
fontSize: 12,
color: isDark ? const Color(0xFF6B7280) : const Color(0xFFBDBDBD),
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text(
'关闭',
style: TextStyle(color: _orange),
),
),
],
),
);
}
///
String _formatTime(DateTime? dateTime) {
if (dateTime == null) return '';
final now = DateTime.now();
final diff = now.difference(dateTime);
if (diff.inMinutes < 1) {
return '刚刚';
} else if (diff.inHours < 1) {
return '${diff.inMinutes}分钟前';
} else if (diff.inDays < 1) {
return '${diff.inHours}小时前';
} else if (diff.inDays < 7) {
return '${diff.inDays}天前';
} else {
return '${dateTime.month}${dateTime.day}';
}
}
///
Color _getTypeColor(NotificationType type) {
switch (type) {
case NotificationType.system:
return const Color(0xFF6B7280);
case NotificationType.activity:
return _orange;
case NotificationType.reward:
return const Color(0xFFF59E0B);
case NotificationType.upgrade:
return const Color(0xFF10B981);
case NotificationType.announcement:
return const Color(0xFF3B82F6);
}
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
backgroundColor: isDark ? const Color(0xFF111827) : const Color(0xFFF3F4F6),
body: SafeArea(
child: Column(
children: [
_buildAppBar(isDark),
Expanded(
child: _isLoading
? const Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(_orange),
),
)
: _error != null
? _buildErrorView(isDark)
: _notifications.isEmpty
? _buildEmptyView(isDark)
: _buildNotificationList(isDark),
),
],
),
),
);
}
/// AppBar
Widget _buildAppBar(bool isDark) {
return Container(
height: 56,
padding: const EdgeInsets.symmetric(horizontal: 16),
color: isDark ? const Color(0xFF1F2937) : Colors.white,
child: Row(
children: [
GestureDetector(
onTap: () => context.pop(),
child: Container(
width: 40,
height: 40,
alignment: Alignment.center,
child: Icon(
Icons.arrow_back,
size: 24,
color: isDark ? const Color(0xFFE5E7EB) : const Color(0xFF1F2937),
),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
'通知中心',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: isDark ? const Color(0xFFE5E7EB) : const Color(0xFF1F2937),
),
),
),
//
if (_unreadCount > 0)
GestureDetector(
onTap: _markAllAsRead,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: _orange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
),
child: const Text(
'全部已读',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: _orange,
),
),
),
),
],
),
);
}
///
Widget _buildEmptyView(bool isDark) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.notifications_none,
size: 64,
color: isDark ? Colors.grey.shade700 : Colors.grey.shade300,
),
const SizedBox(height: 16),
Text(
'暂无通知',
style: TextStyle(
fontSize: 16,
color: isDark ? Colors.grey.shade600 : Colors.grey.shade500,
),
),
],
),
);
}
///
Widget _buildErrorView(bool isDark) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: isDark ? Colors.grey.shade700 : Colors.grey.shade300,
),
const SizedBox(height: 16),
Text(
_error ?? '加载失败',
style: TextStyle(
fontSize: 16,
color: isDark ? Colors.grey.shade600 : Colors.grey.shade500,
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadNotifications,
style: ElevatedButton.styleFrom(
backgroundColor: _orange,
),
child: const Text('重试'),
),
],
),
);
}
///
Widget _buildNotificationList(bool isDark) {
return RefreshIndicator(
onRefresh: _loadNotifications,
color: _orange,
child: ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: _notifications.length,
separatorBuilder: (context, index) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final notification = _notifications[index];
return _buildNotificationCard(notification, isDark);
},
),
);
}
///
Widget _buildNotificationCard(NotificationItem notification, bool isDark) {
return GestureDetector(
onTap: () => _showNotificationDetail(notification),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: notification.isRead
? (isDark ? const Color(0xFF1F2937) : Colors.white)
: (isDark ? const Color(0xFF374151) : const Color(0xFFFFF3E0)),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: notification.isRead
? (isDark ? const Color(0xFF374151) : const Color(0xFFE0E0E0))
: _orange.withValues(alpha: 0.3),
width: 1,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: isDark ? 0.15 : 0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
//
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: _getTypeColor(notification.type).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
),
child: Center(
child: Text(
notification.typeIcon,
style: const TextStyle(fontSize: 20),
),
),
),
const SizedBox(width: 12),
//
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
//
if (!notification.isRead)
Container(
width: 8,
height: 8,
margin: const EdgeInsets.only(right: 8),
decoration: const BoxDecoration(
color: _orange,
shape: BoxShape.circle,
),
),
//
Expanded(
child: Text(
notification.title,
style: TextStyle(
fontSize: 15,
fontWeight: notification.isRead
? FontWeight.w500
: FontWeight.w600,
color: isDark ? const Color(0xFFE5E7EB) : const Color(0xFF1F2937),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 4),
//
Text(
notification.content,
style: TextStyle(
fontSize: 13,
color: notification.isRead
? (isDark ? const Color(0xFF6B7280) : const Color(0xFFBDBDBD))
: (isDark ? const Color(0xFF9CA3AF) : const Color(0xFF6B7280)),
height: 1.4,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
// +
Row(
children: [
Text(
notification.typeName,
style: TextStyle(
fontSize: 11,
color: _getTypeColor(notification.type),
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: 8),
Text(
_formatTime(notification.publishedAt),
style: TextStyle(
fontSize: 11,
color: isDark ? const Color(0xFF6B7280) : const Color(0xFFBDBDBD),
),
),
],
),
],
),
),
//
Icon(
Icons.chevron_right,
size: 20,
color: isDark ? const Color(0xFF6B7280) : const Color(0xFFBDBDBD),
),
],
),
),
);
}
}

View File

@ -6,6 +6,7 @@ import '../../providers/user_providers.dart';
import '../../providers/profile_providers.dart';
import '../../providers/settings_providers.dart';
import '../../providers/mining_providers.dart';
import '../../providers/notification_providers.dart';
import '../../widgets/shimmer_loading.dart';
class ProfilePage extends ConsumerWidget {
@ -228,7 +229,8 @@ class ProfilePage extends ConsumerWidget {
),
),
//
// +
_buildNotificationIcon(context, ref),
IconButton(
onPressed: () => context.push(Routes.editProfile),
icon: Icon(
@ -241,6 +243,48 @@ class ProfilePage extends ConsumerWidget {
);
}
///
Widget _buildNotificationIcon(BuildContext context, WidgetRef ref) {
final unreadCount = ref.watch(unreadNotificationCountProvider);
return IconButton(
onPressed: () => context.push(Routes.notifications),
icon: Stack(
clipBehavior: Clip.none,
children: [
Icon(
Icons.notifications_outlined,
color: _grayText(context),
),
// /
if (unreadCount > 0)
Positioned(
right: -4,
top: -4,
child: Container(
padding: const EdgeInsets.all(2),
constraints: const BoxConstraints(minWidth: 16, minHeight: 16),
decoration: const BoxDecoration(
color: _red,
shape: BoxShape.circle,
),
child: Center(
child: Text(
unreadCount > 99 ? '99+' : '$unreadCount',
style: const TextStyle(
fontSize: 9,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
),
),
],
),
);
}
Widget _buildStatsRow(BuildContext context, UserStats? stats, bool isLoading) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 16),

View File

@ -0,0 +1,162 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/di/injection.dart';
import '../../core/services/notification_service.dart';
import 'user_providers.dart';
/// ==================== Provider ====================
/// NotificationService Provider GetIt ApiClient
final notificationServiceProvider = Provider<NotificationService>((ref) {
return NotificationService(apiClient: getIt());
});
/// ==================== ====================
///
class NotificationBadgeState {
final int unreadCount;
final bool isLoading;
const NotificationBadgeState({
this.unreadCount = 0,
this.isLoading = false,
});
NotificationBadgeState copyWith({
int? unreadCount,
bool? isLoading,
}) {
return NotificationBadgeState(
unreadCount: unreadCount ?? this.unreadCount,
isLoading: isLoading ?? this.isLoading,
);
}
}
///
///
///
/// -
/// - 30
/// - App
/// -
///
/// [NotificationService.getUnreadCount] API
/// [currentAccountSequenceProvider] accountSequence
class NotificationBadgeNotifier extends StateNotifier<NotificationBadgeState>
with WidgetsBindingObserver {
final NotificationService _notificationService;
final Ref _ref;
Timer? _refreshTimer;
/// 30 1.0
static const _refreshIntervalSeconds = 30;
NotificationBadgeNotifier(this._notificationService, this._ref)
: super(const NotificationBadgeState()) {
// App
WidgetsBinding.instance.addObserver(this);
//
_loadUnreadCount();
//
_startAutoRefresh();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_refreshTimer?.cancel();
super.dispose();
}
/// App
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
debugPrint('[NotificationBadge] App 恢复前台,刷新未读数量');
_loadUnreadCount();
}
}
/// 30
void _startAutoRefresh() {
_refreshTimer?.cancel();
_refreshTimer = Timer.periodic(
const Duration(seconds: _refreshIntervalSeconds),
(_) => _loadUnreadCount(),
);
debugPrint('[NotificationBadge] 自动刷新已启动 (间隔: ${_refreshIntervalSeconds}s)');
}
///
void stopAutoRefresh() {
_refreshTimer?.cancel();
_refreshTimer = null;
debugPrint('[NotificationBadge] 自动刷新已停止(切换账号)');
}
///
Future<void> _loadUnreadCount() async {
try {
// 使 2.0 currentAccountSequenceProvider
final accountSequence = _ref.read(currentAccountSequenceProvider);
if (accountSequence == null || accountSequence.isEmpty) {
state = state.copyWith(unreadCount: 0);
return;
}
state = state.copyWith(isLoading: true);
final count = await _notificationService.getUnreadCount(
userSerialNum: accountSequence,
);
state = state.copyWith(
unreadCount: count,
isLoading: false,
);
debugPrint('[NotificationBadge] 未读通知数量: $count');
} catch (e) {
debugPrint('[NotificationBadge] 加载未读数量失败: $e');
state = state.copyWith(isLoading: false);
}
}
///
Future<void> refresh() async {
await _loadUnreadCount();
}
///
void updateCount(int count) {
state = state.copyWith(unreadCount: count);
}
///
void decrementCount() {
if (state.unreadCount > 0) {
state = state.copyWith(unreadCount: state.unreadCount - 1);
}
}
///
void clearCount() {
state = state.copyWith(unreadCount: 0);
}
}
/// Provider
final notificationBadgeProvider =
StateNotifierProvider<NotificationBadgeNotifier, NotificationBadgeState>(
(ref) {
final notificationService = ref.watch(notificationServiceProvider);
return NotificationBadgeNotifier(notificationService, ref);
});
/// 便 Provider: UI 使
final unreadNotificationCountProvider = Provider<int>((ref) {
return ref.watch(notificationBadgeProvider).unreadCount;
});

View File

@ -0,0 +1,317 @@
import 'package:flutter/material.dart';
import '../../core/services/notification_service.dart';
///
///
/// APP [requiresForceRead]
///
///
/// 使
/// ```dart
/// await ForceReadNotificationDialog.show(
/// context: context,
/// notification: item,
/// currentIndex: 1,
/// totalCount: 3,
/// );
/// ```
///
/// #FF6B00 2.0 mining-app
class ForceReadNotificationDialog extends StatefulWidget {
final NotificationItem notification;
final int currentIndex;
final int totalCount;
const ForceReadNotificationDialog._({
super.key,
required this.notification,
required this.currentIndex,
required this.totalCount,
});
///
///
/// [currentIndex] [totalCount] "2/5"
/// [currentIndex] == [totalCount] checkbox +
static Future<void> show({
required BuildContext context,
required NotificationItem notification,
required int currentIndex,
required int totalCount,
}) {
return showDialog<void>(
context: context,
barrierDismissible: false,
barrierColor: const Color(0x99000000),
builder: (context) => ForceReadNotificationDialog._(
notification: notification,
currentIndex: currentIndex,
totalCount: totalCount,
),
);
}
@override
State<ForceReadNotificationDialog> createState() =>
_ForceReadNotificationDialogState();
}
class _ForceReadNotificationDialogState
extends State<ForceReadNotificationDialog> {
bool _isAcknowledged = false;
/// 2.0 APP
static const Color _brandColor = Color(0xFFFF6B00);
///
static const Color _brandLightBg = Color(0xFFFFF3E0);
bool get _isLast => widget.currentIndex == widget.totalCount;
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return PopScope(
canPop: false,
child: Dialog(
backgroundColor: Colors.transparent,
insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 32),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420, maxHeight: 580),
child: Container(
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1F2937) : Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.18),
blurRadius: 24,
offset: const Offset(0, 8),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
//
_buildHeader(isDark),
Divider(
height: 1,
color: isDark ? const Color(0xFF374151) : const Color(0xFFEEEEEE),
),
//
Padding(
padding: const EdgeInsets.fromLTRB(20, 14, 20, 6),
child: Text(
widget.notification.title,
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: isDark ? const Color(0xFFE5E7EB) : const Color(0xFF1F2937),
height: 1.4,
),
),
),
//
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.notification.content,
style: TextStyle(
fontSize: 14,
color: isDark ? const Color(0xFF9CA3AF) : const Color(0xFF6B7280),
height: 1.7,
),
),
if (widget.notification.publishedAt != null) ...[
const SizedBox(height: 12),
Text(
_formatTime(widget.notification.publishedAt!),
style: TextStyle(
fontSize: 12,
color: isDark ? const Color(0xFF6B7280) : const Color(0xFFBDBDBD),
),
),
],
],
),
),
),
Divider(
height: 1,
color: isDark ? const Color(0xFF374151) : const Color(0xFFEEEEEE),
),
//
Padding(
padding: const EdgeInsets.fromLTRB(20, 14, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// checkbox
if (_isLast) ...[
_buildAcknowledgeCheckbox(isDark),
const SizedBox(height: 12),
],
_buildActionButton(context),
],
),
),
],
),
),
),
),
);
}
/// + +
Widget _buildHeader(bool isDark) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 16, 14),
child: Row(
children: [
//
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: _brandLightBg,
borderRadius: BorderRadius.circular(19),
),
child: Center(
child: Text(
widget.notification.typeIcon,
style: const TextStyle(fontSize: 18),
),
),
),
const SizedBox(width: 10),
//
Expanded(
child: Text(
widget.notification.typeName,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: isDark ? const Color(0xFFE5E7EB) : const Color(0xFF1F2937),
),
),
),
//
if (widget.totalCount > 1)
Container(
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: _brandLightBg,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _brandColor.withValues(alpha: 0.5),
),
),
child: Text(
'${widget.currentIndex}/${widget.totalCount}',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: _brandColor,
),
),
),
],
),
);
}
///
Widget _buildAcknowledgeCheckbox(bool isDark) {
return InkWell(
onTap: () => setState(() => _isAcknowledged = !_isAcknowledged),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
SizedBox(
width: 24,
height: 24,
child: Checkbox(
value: _isAcknowledged,
onChanged: (v) =>
setState(() => _isAcknowledged = v ?? false),
activeColor: _brandColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
'我已经阅读并知晓',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: isDark ? const Color(0xFFD1D5DB) : const Color(0xFF374151),
),
),
),
],
),
),
);
}
/// "下一条 ▶" "确定"
Widget _buildActionButton(BuildContext context) {
final isEnabled = !_isLast || _isAcknowledged;
return SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
onPressed: isEnabled ? () => Navigator.of(context).pop() : null,
style: ElevatedButton.styleFrom(
backgroundColor: _brandColor,
foregroundColor: Colors.white,
disabledBackgroundColor:
_brandColor.withValues(alpha: 0.35),
disabledForegroundColor: Colors.white.withValues(alpha: 0.6),
elevation: isEnabled ? 2 : 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
child: Text(
_isLast ? '确定' : '下一条 ▶',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
);
}
///
String _formatTime(DateTime time) {
return '${time.year}-'
'${time.month.toString().padLeft(2, '0')}-'
'${time.day.toString().padLeft(2, '0')} '
'${time.hour.toString().padLeft(2, '0')}:'
'${time.minute.toString().padLeft(2, '0')}';
}
}

View File

@ -1,24 +1,40 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/router/routes.dart';
import '../../core/constants/app_colors.dart';
import '../../core/updater/update_service.dart';
import '../../core/updater/channels/self_hosted_updater.dart';
import '../../core/services/notification_service.dart';
import '../providers/notification_providers.dart';
import '../providers/user_providers.dart';
import 'force_read_notification_dialog.dart';
class MainShell extends StatefulWidget {
/// Widget
///
/// APP
/// 1.
/// 2.
class MainShell extends ConsumerStatefulWidget {
final Widget child;
const MainShell({super.key, required this.child});
@override
State<MainShell> createState() => _MainShellState();
ConsumerState<MainShell> createState() => _MainShellState();
}
class _MainShellState extends State<MainShell> with WidgetsBindingObserver {
class _MainShellState extends ConsumerState<MainShell> with WidgetsBindingObserver {
/// static widget rebuild
static DateTime? _nextCheckAllowedTime;
///
static bool _isShowingForceReadDialog = false;
/// 60
static DateTime? _lastForceReadDialogShownAt;
@override
void initState() {
super.initState();
@ -27,6 +43,10 @@ class _MainShellState extends State<MainShell> with WidgetsBindingObserver {
WidgetsBinding.instance.addPostFrameCallback((_) {
debugPrint('[MainShell] postFrameCallback → 触发首次更新检查');
_checkForUpdateIfNeeded();
// 5
Future.delayed(const Duration(seconds: 5), () {
if (mounted) _checkAndShowForceReadDialog();
});
});
}
@ -42,9 +62,13 @@ class _MainShellState extends State<MainShell> with WidgetsBindingObserver {
debugPrint('[MainShell] didChangeAppLifecycleState: $state');
if (state == AppLifecycleState.resumed) {
_checkForUpdateIfNeeded();
//
_checkAndShowForceReadDialog();
}
}
/// ==================== ====================
Future<void> _checkForUpdateIfNeeded() async {
final now = DateTime.now();
@ -87,6 +111,81 @@ class _MainShellState extends State<MainShell> with WidgetsBindingObserver {
}
}
/// ==================== ====================
///
/// [requiresForceRead]
/// "我已经阅读并知晓"
/// markAsRead
Future<void> _checkAndShowForceReadDialog() async {
// 1
if (_isShowingForceReadDialog) return;
// 260
if (_lastForceReadDialogShownAt != null &&
DateTime.now().difference(_lastForceReadDialogShownAt!) <
const Duration(seconds: 60)) {
return;
}
// 3
final accountSequence = ref.read(currentAccountSequenceProvider);
if (accountSequence == null || accountSequence.isEmpty) return;
//
List<NotificationItem> forceReadList;
try {
final notifService = ref.read(notificationServiceProvider);
final response = await notifService.getNotifications(
userSerialNum: accountSequence,
limit: 20,
);
forceReadList = response.notifications
.where((n) => !n.isRead && n.requiresForceRead)
.toList();
} catch (_) {
// API 使
return;
}
if (forceReadList.isEmpty || !mounted) return;
_isShowingForceReadDialog = true;
_lastForceReadDialogShownAt = DateTime.now();
//
for (int i = 0; i < forceReadList.length; i++) {
if (!mounted) break;
await ForceReadNotificationDialog.show(
context: context,
notification: forceReadList[i],
currentIndex: i + 1,
totalCount: forceReadList.length,
);
}
_isShowingForceReadDialog = false;
//
if (mounted) {
try {
final notifService = ref.read(notificationServiceProvider);
final currentAccount = ref.read(currentAccountSequenceProvider);
if (currentAccount != null && currentAccount.isNotEmpty) {
for (final n in forceReadList) {
await notifService.markAsRead(
userSerialNum: currentAccount,
notificationId: n.id,
);
}
// badge
ref.read(notificationBadgeProvider.notifier).refresh();
}
} catch (_) {
// App
}
}
}
@override
Widget build(BuildContext context) {
final isDark = AppColors.isDark(context);