feat(pending-actions): add user pending actions system

Add a fully optional pending actions system that allows admins to configure
specific tasks that users must complete after login.

Backend (identity-service):
- Add UserPendingAction model to Prisma schema
- Add migration for user_pending_actions table
- Add PendingActionService with full CRUD operations
- Add user-facing API (GET list, POST complete)
- Add admin API (CRUD, batch create)

Admin Web:
- Add pending actions management page
- Support single/batch create, edit, cancel, delete
- View action details including completion time
- Filter by userId, actionCode, status

Flutter Mobile App:
- Add PendingActionService and PendingActionCheckService
- Add PendingActionsPage for forced task execution
- Integrate into splash_page login flow
- Users must complete all pending tasks in priority order

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-02 18:22:51 -08:00
parent 04a8c56ad6
commit 28e0396a65
26 changed files with 3064 additions and 5 deletions

View File

@ -0,0 +1,26 @@
-- =============================================================================
-- 创建用户待办操作表
-- 用于后台配置指定用户登录后需要执行的特定操作
-- 完全可选,不影响现有任何功能
-- =============================================================================
-- 创建 user_pending_actions 表
CREATE TABLE IF NOT EXISTS "user_pending_actions" (
"id" BIGSERIAL NOT NULL,
"user_id" BIGINT NOT NULL,
"action_code" VARCHAR(50) NOT NULL,
"action_params" JSONB,
"status" VARCHAR(20) NOT NULL DEFAULT 'PENDING',
"priority" INTEGER NOT NULL DEFAULT 0,
"expires_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"completed_at" TIMESTAMP(3),
"created_by" VARCHAR(50),
CONSTRAINT "user_pending_actions_pkey" PRIMARY KEY ("id")
);
-- 创建索引
CREATE INDEX IF NOT EXISTS "idx_user_pending_action" ON "user_pending_actions"("user_id", "status");
CREATE INDEX IF NOT EXISTS "idx_action_code" ON "user_pending_actions"("action_code");
CREATE INDEX IF NOT EXISTS "idx_pending_action_status" ON "user_pending_actions"("status");

View File

@ -450,3 +450,42 @@ model KycConfig {
@@map("kyc_configs")
}
// ============================================
// 用户待办操作表
// 用于后台配置指定用户登录后需要执行的特定操作
// 完全可选,不影响现有任何功能
// ============================================
model UserPendingAction {
id BigInt @id @default(autoincrement())
userId BigInt @map("user_id")
// 操作代码: ADOPTION_WIZARD, SETTLE_REWARDS, BIND_PHONE, FORCE_KYC 等
// 前端根据此代码决定跳转到哪个页面/流程
actionCode String @map("action_code") @db.VarChar(50)
// 操作参数 (JSON格式完全自定义)
// 例如: { "treeType": "durian", "rewardIds": ["r1", "r2"], "amount": 100 }
actionParams Json? @map("action_params")
// 操作状态: PENDING(待执行), COMPLETED(已完成), CANCELLED(已取消)
status String @default("PENDING") @db.VarChar(20)
// 优先级 (数值越大优先级越高,前端按此排序执行)
priority Int @default(0)
// 可选过期时间 (过期后前端可忽略)
expiresAt DateTime? @map("expires_at")
// 时间戳
createdAt DateTime @default(now()) @map("created_at")
completedAt DateTime? @map("completed_at")
// 创建人 (后台管理员)
createdBy String? @map("created_by") @db.VarChar(50)
@@index([userId, status], name: "idx_user_pending_action")
@@index([actionCode], name: "idx_action_code")
@@index([status], name: "idx_pending_action_status")
@@map("user_pending_actions")
}

View File

@ -0,0 +1,239 @@
import {
Controller,
Post,
Get,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiResponse,
ApiParam,
} from '@nestjs/swagger';
import { PendingActionService } from '@/application/services/pending-action.service';
import {
JwtAuthGuard,
CurrentUser,
CurrentUserData,
} from '@/shared/guards/jwt-auth.guard';
import {
CreatePendingActionDto,
UpdatePendingActionDto,
QueryPendingActionsDto,
BatchCreatePendingActionDto,
CompletePendingActionDto,
} from '@/api/dto/request/pending-action.dto';
// ========== 用户端控制器 ==========
@ApiTags('用户待办操作 - 用户端')
@Controller('user/pending-actions')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
export class PendingActionController {
constructor(private readonly pendingActionService: PendingActionService) {}
/**
*
*
*/
@Get()
@ApiOperation({
summary: '获取我的待办操作列表',
description: '获取当前用户待执行的操作列表,按优先级降序排列。前端根据 actionCode 决定跳转到哪个页面。',
})
@ApiResponse({
status: 200,
description: '成功',
schema: {
example: {
code: 'OK',
message: 'success',
data: [
{
id: '1',
userId: '12345',
actionCode: 'ADOPTION_WIZARD',
actionParams: { treeType: 'durian', location: '马来西亚' },
status: 'PENDING',
priority: 10,
expiresAt: null,
createdAt: '2025-01-01T00:00:00.000Z',
},
],
},
},
})
async getMyPendingActions(@CurrentUser() user: CurrentUserData) {
const result = await this.pendingActionService.getMyPendingActions(user.userId);
return {
code: 'OK',
message: 'success',
data: result,
};
}
/**
*
*/
@Post(':id/complete')
@ApiOperation({
summary: '标记操作完成',
description: '用户完成操作后调用此接口标记完成',
})
@ApiParam({ name: 'id', description: '操作ID' })
async completeAction(
@CurrentUser() user: CurrentUserData,
@Param('id') actionId: string,
@Body() dto: CompletePendingActionDto,
) {
const result = await this.pendingActionService.completeAction(
user.userId,
actionId,
dto.resultData,
);
return {
code: 'OK',
message: '操作已完成',
data: result,
};
}
}
// ========== 后台管理控制器 ==========
@ApiTags('用户待办操作 - 后台管理')
@Controller('admin/pending-actions')
export class AdminPendingActionController {
constructor(private readonly pendingActionService: PendingActionService) {}
/**
*
*/
@Post()
@ApiOperation({
summary: '创建用户待办操作',
description: '为指定用户创建一个待办操作,用户登录后会看到此操作',
})
async createAction(@Body() dto: CreatePendingActionDto) {
const result = await this.pendingActionService.createAction(dto, 'admin');
return {
code: 'OK',
message: '操作已创建',
data: result,
};
}
/**
*
*/
@Post('batch')
@ApiOperation({
summary: '批量创建用户待办操作',
description: '为多个用户批量创建相同的待办操作',
})
async batchCreateActions(@Body() dto: BatchCreatePendingActionDto) {
const result = await this.pendingActionService.batchCreateActions(dto, 'admin');
return {
code: 'OK',
message: `成功创建 ${result.successCount} 个操作`,
data: result,
};
}
/**
*
*/
@Get()
@ApiOperation({
summary: '查询用户待办操作列表',
description: '支持按用户ID、操作代码、状态筛选分页返回',
})
async queryActions(@Query() dto: QueryPendingActionsDto) {
const result = await this.pendingActionService.queryActions(dto);
return {
code: 'OK',
message: 'success',
data: result,
};
}
/**
*
*/
@Get(':id')
@ApiOperation({
summary: '获取操作详情',
})
@ApiParam({ name: 'id', description: '操作ID' })
async getAction(@Param('id') actionId: string) {
const result = await this.pendingActionService.getAction(actionId);
return {
code: 'OK',
message: 'success',
data: result,
};
}
/**
*
*/
@Put(':id')
@ApiOperation({
summary: '更新用户待办操作',
description: '更新操作的参数、状态、优先级、过期时间等',
})
@ApiParam({ name: 'id', description: '操作ID' })
async updateAction(
@Param('id') actionId: string,
@Body() dto: UpdatePendingActionDto,
) {
const result = await this.pendingActionService.updateAction(actionId, dto);
return {
code: 'OK',
message: '操作已更新',
data: result,
};
}
/**
* ()
*/
@Post(':id/cancel')
@ApiOperation({
summary: '取消用户待办操作',
description: '将操作状态改为 CANCELLED用户将不再看到此操作',
})
@ApiParam({ name: 'id', description: '操作ID' })
async cancelAction(@Param('id') actionId: string) {
const result = await this.pendingActionService.cancelAction(actionId);
return {
code: 'OK',
message: '操作已取消',
data: result,
};
}
/**
* ()
*/
@Delete(':id')
@ApiOperation({
summary: '删除用户待办操作',
description: '彻底删除操作记录',
})
@ApiParam({ name: 'id', description: '操作ID' })
async deleteAction(@Param('id') actionId: string) {
await this.pendingActionService.deleteAction(actionId);
return {
code: 'OK',
message: '操作已删除',
};
}
}

View File

@ -19,3 +19,4 @@ export * from './bind-email.dto';
export * from './unbind-email.dto';
export * from './register-without-sms-verify.dto';
export * from './change-phone.dto';
export * from './pending-action.dto';

View File

@ -0,0 +1,167 @@
import {
IsString,
IsOptional,
IsNotEmpty,
IsInt,
IsObject,
IsDateString,
IsEnum,
Min,
MaxLength,
IsArray,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
/**
*
*/
export enum PendingActionStatus {
PENDING = 'PENDING',
COMPLETED = 'COMPLETED',
CANCELLED = 'CANCELLED',
}
/**
* DTO ()
*/
export class CreatePendingActionDto {
@ApiProperty({ example: '12345', description: '目标用户ID' })
@IsString()
@IsNotEmpty({ message: '用户ID不能为空' })
userId: string;
@ApiProperty({
example: 'ADOPTION_WIZARD',
description: '操作代码,如: ADOPTION_WIZARD, SETTLE_REWARDS, BIND_PHONE, FORCE_KYC',
})
@IsString()
@IsNotEmpty({ message: '操作代码不能为空' })
@MaxLength(50)
actionCode: string;
@ApiPropertyOptional({
example: { treeType: 'durian', location: '马来西亚' },
description: '操作参数 (JSON对象)',
})
@IsOptional()
@IsObject()
actionParams?: Record<string, any>;
@ApiPropertyOptional({ example: 10, description: '优先级 (数值越大优先级越高)' })
@IsOptional()
@IsInt()
@Min(0)
priority?: number;
@ApiPropertyOptional({
example: '2025-12-31T23:59:59Z',
description: '过期时间 (ISO 8601格式)',
})
@IsOptional()
@IsDateString()
expiresAt?: string;
}
/**
* DTO
*/
export class BatchCreatePendingActionDto {
@ApiProperty({ type: [String], description: '目标用户ID列表' })
@IsArray()
@IsString({ each: true })
@IsNotEmpty({ each: true })
userIds: string[];
@ApiProperty({ example: 'ADOPTION_WIZARD', description: '操作代码' })
@IsString()
@IsNotEmpty({ message: '操作代码不能为空' })
@MaxLength(50)
actionCode: string;
@ApiPropertyOptional({ description: '操作参数' })
@IsOptional()
@IsObject()
actionParams?: Record<string, any>;
@ApiPropertyOptional({ example: 0, description: '优先级' })
@IsOptional()
@IsInt()
@Min(0)
priority?: number;
@ApiPropertyOptional({ description: '过期时间' })
@IsOptional()
@IsDateString()
expiresAt?: string;
}
/**
* DTO
*/
export class UpdatePendingActionDto {
@ApiPropertyOptional({ description: '操作参数' })
@IsOptional()
@IsObject()
actionParams?: Record<string, any>;
@ApiPropertyOptional({ enum: PendingActionStatus, description: '状态' })
@IsOptional()
@IsEnum(PendingActionStatus)
status?: PendingActionStatus;
@ApiPropertyOptional({ description: '优先级' })
@IsOptional()
@IsInt()
@Min(0)
priority?: number;
@ApiPropertyOptional({ description: '过期时间' })
@IsOptional()
@IsDateString()
expiresAt?: string;
}
/**
* DTO ()
*/
export class QueryPendingActionsDto {
@ApiPropertyOptional({ description: '用户ID' })
@IsOptional()
@IsString()
userId?: string;
@ApiPropertyOptional({ description: '操作代码' })
@IsOptional()
@IsString()
actionCode?: string;
@ApiPropertyOptional({ enum: PendingActionStatus, description: '状态' })
@IsOptional()
@IsEnum(PendingActionStatus)
status?: PendingActionStatus;
@ApiPropertyOptional({ example: 1, description: '页码' })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@ApiPropertyOptional({ example: 20, description: '每页数量' })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
limit?: number;
}
/**
* DTO
*/
export class CompletePendingActionDto {
@ApiPropertyOptional({ description: '完成时附带的数据' })
@IsOptional()
@IsObject()
resultData?: Record<string, any>;
}

View File

@ -1,2 +1,3 @@
export * from './user-profile.dto';
export * from './device.dto';
export * from './pending-action.dto';

View File

@ -0,0 +1,73 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
/**
* DTO
*/
export class PendingActionResponseDto {
@ApiProperty({ example: '1', description: '操作ID' })
id: string;
@ApiProperty({ example: '12345', description: '用户ID' })
userId: string;
@ApiProperty({ example: 'ADOPTION_WIZARD', description: '操作代码' })
actionCode: string;
@ApiPropertyOptional({
example: { treeType: 'durian', location: '马来西亚' },
description: '操作参数',
})
actionParams: Record<string, any> | null;
@ApiProperty({ example: 'PENDING', description: '状态' })
status: string;
@ApiProperty({ example: 10, description: '优先级' })
priority: number;
@ApiPropertyOptional({ description: '过期时间' })
expiresAt: Date | null;
@ApiProperty({ description: '创建时间' })
createdAt: Date;
@ApiPropertyOptional({ description: '完成时间' })
completedAt: Date | null;
@ApiPropertyOptional({ description: '创建人' })
createdBy: string | null;
}
/**
* DTO ()
*/
export class PendingActionListResponseDto {
@ApiProperty({ type: [PendingActionResponseDto], description: '操作列表' })
items: PendingActionResponseDto[];
@ApiProperty({ example: 100, description: '总数' })
total: number;
@ApiProperty({ example: 1, description: '当前页' })
page: number;
@ApiProperty({ example: 20, description: '每页数量' })
limit: number;
}
/**
* DTO
*/
export class BatchCreateResultDto {
@ApiProperty({ example: 10, description: '成功创建数量' })
successCount: number;
@ApiProperty({ example: 2, description: '失败数量' })
failedCount: number;
@ApiPropertyOptional({
type: [String],
description: '失败的用户ID列表',
})
failedUserIds?: string[];
}

View File

@ -24,12 +24,17 @@ import { AuthController } from '@/api/controllers/auth.controller';
import { TotpController } from '@/api/controllers/totp.controller';
import { InternalController } from '@/api/controllers/internal.controller';
import { KycController, AdminKycController } from '@/api/controllers/kyc.controller';
import {
PendingActionController,
AdminPendingActionController,
} from '@/api/controllers/pending-action.controller';
// Application Services
import { UserApplicationService } from '@/application/services/user-application.service';
import { TokenService } from '@/application/services/token.service';
import { TotpService } from '@/application/services/totp.service';
import { KycApplicationService } from '@/application/services/kyc-application.service';
import { PendingActionService } from '@/application/services/pending-action.service';
import { BlockchainWalletHandler } from '@/application/event-handlers/blockchain-wallet.handler';
import { MpcKeygenCompletedHandler } from '@/application/event-handlers/mpc-keygen-completed.handler';
import { WalletRetryTask } from '@/application/tasks/wallet-retry.task';
@ -134,13 +139,14 @@ export class DomainModule {}
TokenService,
TotpService,
KycApplicationService,
PendingActionService,
// Event Handlers - 通过注入到 UserApplicationService 来确保它们被初始化
BlockchainWalletHandler,
MpcKeygenCompletedHandler,
// Tasks - 定时任务
WalletRetryTask,
],
exports: [UserApplicationService, TokenService, TotpService, KycApplicationService],
exports: [UserApplicationService, TokenService, TotpService, KycApplicationService, PendingActionService],
})
export class ApplicationModule {}
@ -156,6 +162,8 @@ export class ApplicationModule {}
InternalController,
KycController,
AdminKycController,
PendingActionController,
AdminPendingActionController,
],
providers: [UserAccountRepositoryImpl],
})

View File

@ -0,0 +1,359 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
import { ApplicationError } from '@/shared/exceptions/domain.exception';
import {
PendingActionStatus,
CreatePendingActionDto,
UpdatePendingActionDto,
QueryPendingActionsDto,
BatchCreatePendingActionDto,
} from '@/api/dto/request/pending-action.dto';
import {
PendingActionResponseDto,
PendingActionListResponseDto,
BatchCreateResultDto,
} from '@/api/dto/response/pending-action.dto';
/**
*
*
*
*
*/
@Injectable()
export class PendingActionService {
private readonly logger = new Logger(PendingActionService.name);
constructor(private readonly prisma: PrismaService) {}
// ==================== 用户端接口 ====================
/**
*
* PENDING
*/
async getMyPendingActions(userId: string): Promise<PendingActionResponseDto[]> {
this.logger.log(`[PendingAction] Getting pending actions for user: ${userId}`);
const now = new Date();
const actions = await this.prisma.userPendingAction.findMany({
where: {
userId: BigInt(userId),
status: PendingActionStatus.PENDING,
OR: [
{ expiresAt: null },
{ expiresAt: { gt: now } },
],
},
orderBy: [
{ priority: 'desc' },
{ createdAt: 'asc' },
],
});
return actions.map((action) => this.toResponseDto(action));
}
/**
*
*/
async completeAction(
userId: string,
actionId: string,
resultData?: Record<string, any>,
): Promise<PendingActionResponseDto> {
this.logger.log(`[PendingAction] Completing action: ${actionId} for user: ${userId}`);
const action = await this.prisma.userPendingAction.findFirst({
where: {
id: BigInt(actionId),
userId: BigInt(userId),
},
});
if (!action) {
throw new ApplicationError('操作不存在');
}
if (action.status !== PendingActionStatus.PENDING) {
throw new ApplicationError('操作已完成或已取消');
}
const updated = await this.prisma.userPendingAction.update({
where: { id: BigInt(actionId) },
data: {
status: PendingActionStatus.COMPLETED,
completedAt: new Date(),
// 如果有结果数据,合并到 actionParams 中
...(resultData && {
actionParams: {
...(action.actionParams as object || {}),
_result: resultData,
},
}),
},
});
this.logger.log(`[PendingAction] Action completed: ${actionId}`);
return this.toResponseDto(updated);
}
// ==================== 后台管理接口 ====================
/**
* ()
*/
async createAction(
dto: CreatePendingActionDto,
createdBy?: string,
): Promise<PendingActionResponseDto> {
this.logger.log(`[PendingAction] Creating action for user: ${dto.userId}, code: ${dto.actionCode}`);
// 验证用户是否存在
const user = await this.prisma.userAccount.findUnique({
where: { userId: BigInt(dto.userId) },
select: { userId: true },
});
if (!user) {
throw new ApplicationError('用户不存在');
}
const action = await this.prisma.userPendingAction.create({
data: {
userId: BigInt(dto.userId),
actionCode: dto.actionCode,
actionParams: dto.actionParams ?? undefined,
priority: dto.priority ?? 0,
expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : null,
createdBy,
},
});
this.logger.log(`[PendingAction] Action created: ${action.id}`);
return this.toResponseDto(action);
}
/**
* ()
*/
async batchCreateActions(
dto: BatchCreatePendingActionDto,
createdBy?: string,
): Promise<BatchCreateResultDto> {
this.logger.log(`[PendingAction] Batch creating actions for ${dto.userIds.length} users, code: ${dto.actionCode}`);
let successCount = 0;
const failedUserIds: string[] = [];
for (const userId of dto.userIds) {
try {
// 验证用户是否存在
const user = await this.prisma.userAccount.findUnique({
where: { userId: BigInt(userId) },
select: { userId: true },
});
if (!user) {
failedUserIds.push(userId);
continue;
}
await this.prisma.userPendingAction.create({
data: {
userId: BigInt(userId),
actionCode: dto.actionCode,
actionParams: dto.actionParams ?? undefined,
priority: dto.priority ?? 0,
expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : null,
createdBy,
},
});
successCount++;
} catch (error) {
this.logger.warn(`[PendingAction] Failed to create action for user: ${userId}, error: ${error.message}`);
failedUserIds.push(userId);
}
}
this.logger.log(`[PendingAction] Batch create result: success=${successCount}, failed=${failedUserIds.length}`);
return {
successCount,
failedCount: failedUserIds.length,
failedUserIds: failedUserIds.length > 0 ? failedUserIds : undefined,
};
}
/**
* ()
*/
async queryActions(dto: QueryPendingActionsDto): Promise<PendingActionListResponseDto> {
const page = dto.page ?? 1;
const limit = dto.limit ?? 20;
const skip = (page - 1) * limit;
const where: any = {};
if (dto.userId) {
where.userId = BigInt(dto.userId);
}
if (dto.actionCode) {
where.actionCode = dto.actionCode;
}
if (dto.status) {
where.status = dto.status;
}
const [items, total] = await Promise.all([
this.prisma.userPendingAction.findMany({
where,
orderBy: [
{ createdAt: 'desc' },
],
skip,
take: limit,
}),
this.prisma.userPendingAction.count({ where }),
]);
return {
items: items.map((action) => this.toResponseDto(action)),
total,
page,
limit,
};
}
/**
* ()
*/
async getAction(actionId: string): Promise<PendingActionResponseDto> {
const action = await this.prisma.userPendingAction.findUnique({
where: { id: BigInt(actionId) },
});
if (!action) {
throw new ApplicationError('操作不存在');
}
return this.toResponseDto(action);
}
/**
* ()
*/
async updateAction(
actionId: string,
dto: UpdatePendingActionDto,
): Promise<PendingActionResponseDto> {
this.logger.log(`[PendingAction] Updating action: ${actionId}`);
const action = await this.prisma.userPendingAction.findUnique({
where: { id: BigInt(actionId) },
});
if (!action) {
throw new ApplicationError('操作不存在');
}
const updateData: any = {};
if (dto.actionParams !== undefined) {
updateData.actionParams = dto.actionParams;
}
if (dto.status !== undefined) {
updateData.status = dto.status;
if (dto.status === PendingActionStatus.COMPLETED) {
updateData.completedAt = new Date();
}
}
if (dto.priority !== undefined) {
updateData.priority = dto.priority;
}
if (dto.expiresAt !== undefined) {
updateData.expiresAt = dto.expiresAt ? new Date(dto.expiresAt) : null;
}
const updated = await this.prisma.userPendingAction.update({
where: { id: BigInt(actionId) },
data: updateData,
});
this.logger.log(`[PendingAction] Action updated: ${actionId}`);
return this.toResponseDto(updated);
}
/**
* ()
*/
async deleteAction(actionId: string): Promise<void> {
this.logger.log(`[PendingAction] Deleting action: ${actionId}`);
const action = await this.prisma.userPendingAction.findUnique({
where: { id: BigInt(actionId) },
});
if (!action) {
throw new ApplicationError('操作不存在');
}
await this.prisma.userPendingAction.delete({
where: { id: BigInt(actionId) },
});
this.logger.log(`[PendingAction] Action deleted: ${actionId}`);
}
/**
* ()
* CANCELLED
*/
async cancelAction(actionId: string): Promise<PendingActionResponseDto> {
this.logger.log(`[PendingAction] Cancelling action: ${actionId}`);
const action = await this.prisma.userPendingAction.findUnique({
where: { id: BigInt(actionId) },
});
if (!action) {
throw new ApplicationError('操作不存在');
}
if (action.status !== PendingActionStatus.PENDING) {
throw new ApplicationError('只能取消待处理的操作');
}
const updated = await this.prisma.userPendingAction.update({
where: { id: BigInt(actionId) },
data: {
status: PendingActionStatus.CANCELLED,
},
});
this.logger.log(`[PendingAction] Action cancelled: ${actionId}`);
return this.toResponseDto(updated);
}
// ==================== Helper Methods ====================
private toResponseDto(action: any): PendingActionResponseDto {
return {
id: action.id.toString(),
userId: action.userId.toString(),
actionCode: action.actionCode,
actionParams: action.actionParams as Record<string, any> | null,
status: action.status,
priority: action.priority,
expiresAt: action.expiresAt,
createdAt: action.createdAt,
completedAt: action.completedAt,
createdBy: action.createdBy,
};
}
}

View File

@ -0,0 +1,688 @@
'use client';
import { useState, useCallback } from 'react';
import { Modal, toast, Button } from '@/components/common';
import { PageContainer } from '@/components/layout';
import { cn } from '@/utils/helpers';
import { formatDateTime } from '@/utils/formatters';
import {
usePendingActions,
useCreatePendingAction,
useBatchCreatePendingActions,
useUpdatePendingAction,
useCancelPendingAction,
useDeletePendingAction,
} from '@/hooks/usePendingActions';
import {
PendingAction,
PendingActionStatus,
ACTION_CODE_OPTIONS,
STATUS_OPTIONS,
getStatusInfo,
getActionCodeLabel,
} from '@/types/pending-action.types';
import styles from './pending-actions.module.scss';
/**
*
*/
export default function PendingActionsPage() {
// 筛选状态
const [filters, setFilters] = useState({
userId: '',
actionCode: '',
status: '' as PendingActionStatus | '',
page: 1,
limit: 20,
});
// 弹窗状态
const [showCreateModal, setShowCreateModal] = useState(false);
const [showBatchModal, setShowBatchModal] = useState(false);
const [editingAction, setEditingAction] = useState<PendingAction | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const [viewingAction, setViewingAction] = useState<PendingAction | null>(null);
// 表单状态
const [formData, setFormData] = useState({
userId: '',
actionCode: 'ADOPTION_WIZARD',
actionParams: '',
priority: 0,
expiresAt: '',
});
// 批量创建表单
const [batchFormData, setBatchFormData] = useState({
userIds: '',
actionCode: 'ADOPTION_WIZARD',
actionParams: '',
priority: 0,
expiresAt: '',
});
// 数据查询
const { data, isLoading, error, refetch } = usePendingActions({
userId: filters.userId || undefined,
actionCode: filters.actionCode || undefined,
status: filters.status || undefined,
page: filters.page,
limit: filters.limit,
});
// Mutations
const createMutation = useCreatePendingAction();
const batchCreateMutation = useBatchCreatePendingActions();
const updateMutation = useUpdatePendingAction();
const cancelMutation = useCancelPendingAction();
const deleteMutation = useDeletePendingAction();
// 打开创建弹窗
const handleCreate = () => {
setEditingAction(null);
setFormData({
userId: '',
actionCode: 'ADOPTION_WIZARD',
actionParams: '',
priority: 0,
expiresAt: '',
});
setShowCreateModal(true);
};
// 打开编辑弹窗
const handleEdit = (action: PendingAction) => {
setEditingAction(action);
setFormData({
userId: action.userId,
actionCode: action.actionCode,
actionParams: action.actionParams ? JSON.stringify(action.actionParams, null, 2) : '',
priority: action.priority,
expiresAt: action.expiresAt ? action.expiresAt.slice(0, 16) : '',
});
setShowCreateModal(true);
};
// 打开批量创建弹窗
const handleBatchCreate = () => {
setBatchFormData({
userIds: '',
actionCode: 'ADOPTION_WIZARD',
actionParams: '',
priority: 0,
expiresAt: '',
});
setShowBatchModal(true);
};
// 提交创建/编辑表单
const handleSubmit = async () => {
if (!editingAction && !formData.userId.trim()) {
toast.error('请输入用户ID');
return;
}
let actionParams: Record<string, unknown> | undefined;
if (formData.actionParams.trim()) {
try {
actionParams = JSON.parse(formData.actionParams);
} catch {
toast.error('操作参数格式错误,请输入有效的 JSON');
return;
}
}
try {
if (editingAction) {
await updateMutation.mutateAsync({
id: editingAction.id,
data: {
actionParams,
priority: formData.priority,
expiresAt: formData.expiresAt ? new Date(formData.expiresAt).toISOString() : undefined,
},
});
toast.success('操作已更新');
} else {
await createMutation.mutateAsync({
userId: formData.userId.trim(),
actionCode: formData.actionCode,
actionParams,
priority: formData.priority,
expiresAt: formData.expiresAt ? new Date(formData.expiresAt).toISOString() : undefined,
});
toast.success('操作已创建');
}
setShowCreateModal(false);
} catch (err) {
toast.error((err as Error).message || '操作失败');
}
};
// 提交批量创建
const handleBatchSubmit = async () => {
const userIds = batchFormData.userIds
.split(/[\n,]/)
.map((id) => id.trim())
.filter(Boolean);
if (userIds.length === 0) {
toast.error('请输入用户ID');
return;
}
let actionParams: Record<string, unknown> | undefined;
if (batchFormData.actionParams.trim()) {
try {
actionParams = JSON.parse(batchFormData.actionParams);
} catch {
toast.error('操作参数格式错误,请输入有效的 JSON');
return;
}
}
try {
const result = await batchCreateMutation.mutateAsync({
userIds,
actionCode: batchFormData.actionCode,
actionParams,
priority: batchFormData.priority,
expiresAt: batchFormData.expiresAt
? new Date(batchFormData.expiresAt).toISOString()
: undefined,
});
toast.success(`成功创建 ${result.successCount} 个操作${result.failedCount > 0 ? `${result.failedCount} 个失败` : ''}`);
setShowBatchModal(false);
} catch (err) {
toast.error((err as Error).message || '操作失败');
}
};
// 取消操作
const handleCancel = async (id: string) => {
try {
await cancelMutation.mutateAsync(id);
toast.success('操作已取消');
} catch (err) {
toast.error((err as Error).message || '取消失败');
}
};
// 删除操作
const handleDelete = async (id: string) => {
try {
await deleteMutation.mutateAsync(id);
toast.success('操作已删除');
setDeleteConfirm(null);
} catch (err) {
toast.error((err as Error).message || '删除失败');
}
};
// 搜索处理
const handleSearch = useCallback(() => {
setFilters((prev) => ({ ...prev, page: 1 }));
refetch();
}, [refetch]);
// 翻页
const handlePageChange = (page: number) => {
setFilters((prev) => ({ ...prev, page }));
};
return (
<PageContainer title="用户待办操作">
<div className={styles.pendingActions}>
{/* 页面标题 */}
<div className={styles.pendingActions__header}>
<h1 className={styles.pendingActions__title}></h1>
<p className={styles.pendingActions__subtitle}>
</p>
</div>
{/* 主内容卡片 */}
<div className={styles.pendingActions__card}>
{/* 筛选区域 */}
<div className={styles.pendingActions__filters}>
<input
type="text"
className={styles.pendingActions__input}
placeholder="用户ID"
value={filters.userId}
onChange={(e) => setFilters({ ...filters, userId: e.target.value })}
/>
<select
className={styles.pendingActions__select}
value={filters.actionCode}
onChange={(e) => setFilters({ ...filters, actionCode: e.target.value })}
>
<option value=""></option>
{ACTION_CODE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<select
className={styles.pendingActions__select}
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value as PendingActionStatus | '' })}
>
<option value=""></option>
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<Button variant="outline" size="sm" onClick={handleSearch}>
</Button>
<Button variant="outline" size="sm" onClick={() => refetch()}>
</Button>
<div style={{ flex: 1 }} />
<Button variant="outline" onClick={handleBatchCreate}>
</Button>
<Button variant="primary" onClick={handleCreate}>
+
</Button>
</div>
{/* 列表 */}
<div className={styles.pendingActions__list}>
{isLoading ? (
<div className={styles.pendingActions__loading}>...</div>
) : error ? (
<div className={styles.pendingActions__error}>
<span>{(error as Error).message || '加载失败'}</span>
<Button variant="outline" size="sm" onClick={() => refetch()}>
</Button>
</div>
) : !data || data.items.length === 0 ? (
<div className={styles.pendingActions__empty}>
"新建操作"
</div>
) : (
<>
{/* 表格 */}
<table className={styles.pendingActions__table}>
<thead>
<tr>
<th>ID</th>
<th>ID</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{data.items.map((action) => {
const statusInfo = getStatusInfo(action.status);
return (
<tr key={action.id}>
<td>{action.id}</td>
<td>{action.userId}</td>
<td>
<span className={styles.pendingActions__codeTag}>
{getActionCodeLabel(action.actionCode)}
</span>
</td>
<td>
<span
className={styles.pendingActions__statusTag}
style={{ backgroundColor: statusInfo.color, color: 'white' }}
>
{statusInfo.label}
</span>
</td>
<td>{action.priority}</td>
<td>{formatDateTime(action.createdAt)}</td>
<td>{action.expiresAt ? formatDateTime(action.expiresAt) : '-'}</td>
<td>
<div className={styles.pendingActions__actions}>
<button
className={styles.pendingActions__actionBtn}
onClick={() => setViewingAction(action)}
>
</button>
{action.status === 'PENDING' && (
<>
<button
className={styles.pendingActions__actionBtn}
onClick={() => handleEdit(action)}
>
</button>
<button
className={cn(styles.pendingActions__actionBtn, styles['pendingActions__actionBtn--warning'])}
onClick={() => handleCancel(action.id)}
>
</button>
</>
)}
<button
className={cn(styles.pendingActions__actionBtn, styles['pendingActions__actionBtn--danger'])}
onClick={() => setDeleteConfirm(action.id)}
>
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
{/* 分页 */}
{data.total > filters.limit && (
<div className={styles.pendingActions__pagination}>
<span>
{data.total} {data.page} / {Math.ceil(data.total / data.limit)}
</span>
<div className={styles.pendingActions__pageButtons}>
<Button
variant="outline"
size="sm"
disabled={data.page <= 1}
onClick={() => handlePageChange(data.page - 1)}
>
</Button>
<Button
variant="outline"
size="sm"
disabled={data.page >= Math.ceil(data.total / data.limit)}
onClick={() => handlePageChange(data.page + 1)}
>
</Button>
</div>
</div>
)}
</>
)}
</div>
</div>
{/* 创建/编辑弹窗 */}
<Modal
visible={showCreateModal}
title={editingAction ? '编辑操作' : '新建操作'}
onClose={() => setShowCreateModal(false)}
footer={
<div className={styles.pendingActions__modalFooter}>
<Button variant="outline" onClick={() => setShowCreateModal(false)}>
</Button>
<Button
variant="primary"
onClick={handleSubmit}
loading={createMutation.isPending || updateMutation.isPending}
>
{editingAction ? '保存' : '创建'}
</Button>
</div>
}
width={600}
>
<div className={styles.pendingActions__form}>
{!editingAction && (
<div className={styles.pendingActions__formGroup}>
<label>ID *</label>
<input
type="text"
value={formData.userId}
onChange={(e) => setFormData({ ...formData, userId: e.target.value })}
placeholder="请输入目标用户ID"
/>
</div>
)}
<div className={styles.pendingActions__formGroup}>
<label></label>
<select
value={formData.actionCode}
onChange={(e) => setFormData({ ...formData, actionCode: e.target.value })}
disabled={!!editingAction}
>
{ACTION_CODE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className={styles.pendingActions__formGroup}>
<label> (JSON格式)</label>
<textarea
value={formData.actionParams}
onChange={(e) => setFormData({ ...formData, actionParams: e.target.value })}
placeholder='{"key": "value"}'
rows={4}
/>
<span className={styles.pendingActions__formHint}>
{`{"treeType": "durian", "rewardIds": ["r1", "r2"]}`}
</span>
</div>
<div className={styles.pendingActions__formRow}>
<div className={styles.pendingActions__formGroup}>
<label></label>
<input
type="number"
value={formData.priority}
onChange={(e) => setFormData({ ...formData, priority: parseInt(e.target.value) || 0 })}
min={0}
/>
<span className={styles.pendingActions__formHint}></span>
</div>
<div className={styles.pendingActions__formGroup}>
<label></label>
<input
type="datetime-local"
value={formData.expiresAt}
onChange={(e) => setFormData({ ...formData, expiresAt: e.target.value })}
/>
<span className={styles.pendingActions__formHint}></span>
</div>
</div>
</div>
</Modal>
{/* 批量创建弹窗 */}
<Modal
visible={showBatchModal}
title="批量创建操作"
onClose={() => setShowBatchModal(false)}
footer={
<div className={styles.pendingActions__modalFooter}>
<Button variant="outline" onClick={() => setShowBatchModal(false)}>
</Button>
<Button
variant="primary"
onClick={handleBatchSubmit}
loading={batchCreateMutation.isPending}
>
</Button>
</div>
}
width={600}
>
<div className={styles.pendingActions__form}>
<div className={styles.pendingActions__formGroup}>
<label>ID列表 *</label>
<textarea
value={batchFormData.userIds}
onChange={(e) => setBatchFormData({ ...batchFormData, userIds: e.target.value })}
placeholder="请输入用户ID每行一个或用逗号分隔"
rows={6}
className={styles.pendingActions__userIdsInput}
/>
<span className={styles.pendingActions__formHint}>
{batchFormData.userIds.split(/[\n,]/).filter((s) => s.trim()).length}
</span>
</div>
<div className={styles.pendingActions__formGroup}>
<label></label>
<select
value={batchFormData.actionCode}
onChange={(e) => setBatchFormData({ ...batchFormData, actionCode: e.target.value })}
>
{ACTION_CODE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className={styles.pendingActions__formGroup}>
<label> (JSON格式)</label>
<textarea
value={batchFormData.actionParams}
onChange={(e) => setBatchFormData({ ...batchFormData, actionParams: e.target.value })}
placeholder='{"key": "value"}'
rows={3}
/>
</div>
<div className={styles.pendingActions__formRow}>
<div className={styles.pendingActions__formGroup}>
<label></label>
<input
type="number"
value={batchFormData.priority}
onChange={(e) => setBatchFormData({ ...batchFormData, priority: parseInt(e.target.value) || 0 })}
min={0}
/>
</div>
<div className={styles.pendingActions__formGroup}>
<label></label>
<input
type="datetime-local"
value={batchFormData.expiresAt}
onChange={(e) => setBatchFormData({ ...batchFormData, expiresAt: e.target.value })}
/>
</div>
</div>
</div>
</Modal>
{/* 查看详情弹窗 */}
<Modal
visible={!!viewingAction}
title="操作详情"
onClose={() => setViewingAction(null)}
footer={
<div className={styles.pendingActions__modalFooter}>
<Button variant="outline" onClick={() => setViewingAction(null)}>
</Button>
</div>
}
width={600}
>
{viewingAction && (
<div className={styles.pendingActions__detail}>
<div className={styles.pendingActions__detailRow}>
<span className={styles.pendingActions__detailLabel}>ID:</span>
<span>{viewingAction.id}</span>
</div>
<div className={styles.pendingActions__detailRow}>
<span className={styles.pendingActions__detailLabel}>ID:</span>
<span>{viewingAction.userId}</span>
</div>
<div className={styles.pendingActions__detailRow}>
<span className={styles.pendingActions__detailLabel}>:</span>
<span>{getActionCodeLabel(viewingAction.actionCode)} ({viewingAction.actionCode})</span>
</div>
<div className={styles.pendingActions__detailRow}>
<span className={styles.pendingActions__detailLabel}>:</span>
<span
className={styles.pendingActions__statusTag}
style={{ backgroundColor: getStatusInfo(viewingAction.status).color, color: 'white' }}
>
{getStatusInfo(viewingAction.status).label}
</span>
</div>
<div className={styles.pendingActions__detailRow}>
<span className={styles.pendingActions__detailLabel}>:</span>
<span>{viewingAction.priority}</span>
</div>
<div className={styles.pendingActions__detailRow}>
<span className={styles.pendingActions__detailLabel}>:</span>
<span>{formatDateTime(viewingAction.createdAt)}</span>
</div>
{viewingAction.completedAt && (
<div className={styles.pendingActions__detailRow}>
<span className={styles.pendingActions__detailLabel}>:</span>
<span>{formatDateTime(viewingAction.completedAt)}</span>
</div>
)}
{viewingAction.expiresAt && (
<div className={styles.pendingActions__detailRow}>
<span className={styles.pendingActions__detailLabel}>:</span>
<span>{formatDateTime(viewingAction.expiresAt)}</span>
</div>
)}
{viewingAction.createdBy && (
<div className={styles.pendingActions__detailRow}>
<span className={styles.pendingActions__detailLabel}>:</span>
<span>{viewingAction.createdBy}</span>
</div>
)}
{viewingAction.actionParams && (
<div className={styles.pendingActions__detailRow}>
<span className={styles.pendingActions__detailLabel}>:</span>
<pre className={styles.pendingActions__json}>
{JSON.stringify(viewingAction.actionParams, null, 2)}
</pre>
</div>
)}
</div>
)}
</Modal>
{/* 删除确认弹窗 */}
<Modal
visible={!!deleteConfirm}
title="确认删除"
onClose={() => setDeleteConfirm(null)}
footer={
<div className={styles.pendingActions__modalFooter}>
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
</Button>
<Button
variant="danger"
onClick={() => deleteConfirm && handleDelete(deleteConfirm)}
loading={deleteMutation.isPending}
>
</Button>
</div>
}
width={400}
>
<p></p>
</Modal>
</div>
</PageContainer>
);
}

View File

@ -0,0 +1,294 @@
@use '@/styles/variables' as *;
.pendingActions {
display: flex;
flex-direction: column;
gap: 24px;
&__header {
display: flex;
flex-direction: column;
gap: 8px;
}
&__title {
font-size: 24px;
font-weight: 600;
color: $text-primary;
}
&__subtitle {
font-size: 14px;
color: $text-secondary;
}
&__card {
background: $card-background;
border-radius: 12px;
padding: 24px;
box-shadow: $shadow-base;
}
&__filters {
display: flex;
gap: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
}
&__input {
padding: 8px 12px;
border: 1px solid $border-color;
border-radius: 8px;
font-size: 14px;
background: white;
min-width: 140px;
&:focus {
outline: none;
border-color: $primary-color;
}
}
&__select {
padding: 8px 12px;
border: 1px solid $border-color;
border-radius: 8px;
font-size: 14px;
background: white;
min-width: 140px;
&:focus {
outline: none;
border-color: $primary-color;
}
}
&__list {
display: flex;
flex-direction: column;
gap: 16px;
}
&__loading,
&__empty,
&__error {
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
padding: 60px 20px;
color: $text-secondary;
font-size: 14px;
}
&__error {
color: $error-color;
}
// 表格样式
&__table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
th,
td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid $border-color;
}
th {
font-weight: 600;
color: $text-secondary;
background: #fafafa;
}
td {
color: $text-primary;
}
tbody tr {
transition: background 0.15s;
&:hover {
background: #f5f7fa;
}
}
}
&__codeTag {
display: inline-block;
padding: 2px 8px;
background: #e6f4ff;
color: #1677ff;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
&__statusTag {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
&__actions {
display: flex;
gap: 8px;
}
&__actionBtn {
padding: 4px 10px;
font-size: 13px;
color: $text-secondary;
background: transparent;
border: 1px solid $border-color;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
&:hover {
color: $primary-color;
border-color: $primary-color;
}
&--warning {
&:hover {
color: #faad14;
border-color: #faad14;
}
}
&--danger {
&:hover {
color: $error-color;
border-color: $error-color;
}
}
}
// 分页
&__pagination {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid $border-color;
font-size: 14px;
color: $text-secondary;
}
&__pageButtons {
display: flex;
gap: 8px;
}
// 表单样式
&__form {
display: flex;
flex-direction: column;
gap: 16px;
}
&__formRow {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
&__formGroup {
display: flex;
flex-direction: column;
gap: 6px;
label {
font-size: 14px;
font-weight: 500;
color: $text-primary;
}
input,
select,
textarea {
padding: 10px 12px;
border: 1px solid $border-color;
border-radius: 8px;
font-size: 14px;
transition: border-color 0.2s;
&:focus {
outline: none;
border-color: $primary-color;
}
&::placeholder {
color: $text-disabled;
}
&:disabled {
background: #f5f5f5;
cursor: not-allowed;
}
}
textarea {
resize: vertical;
min-height: 80px;
font-family: monospace;
}
}
&__formHint {
font-size: 12px;
color: $text-disabled;
}
&__userIdsInput {
font-family: monospace;
font-size: 13px;
line-height: 1.6;
min-height: 100px;
}
&__modalFooter {
display: flex;
justify-content: flex-end;
gap: 12px;
}
// 详情样式
&__detail {
display: flex;
flex-direction: column;
gap: 12px;
}
&__detailRow {
display: flex;
gap: 12px;
align-items: flex-start;
}
&__detailLabel {
flex-shrink: 0;
width: 80px;
font-weight: 500;
color: $text-secondary;
}
&__json {
background: #f5f5f5;
padding: 12px;
border-radius: 8px;
font-size: 13px;
line-height: 1.5;
overflow-x: auto;
margin: 0;
flex: 1;
}
}

View File

@ -28,6 +28,7 @@ const topMenuItems: MenuItem[] = [
{ key: 'leaderboard', icon: '/images/Container3.svg', label: '龙虎榜', path: '/leaderboard' },
{ key: 'authorization', icon: '/images/Container4.svg', label: '授权管理', path: '/authorization' },
{ key: 'notifications', icon: '/images/Container3.svg', label: '通知管理', path: '/notifications' },
{ key: 'pending-actions', icon: '/images/Container3.svg', label: '待办操作', path: '/pending-actions' },
{ key: 'statistics', icon: '/images/Container5.svg', label: '数据统计', path: '/statistics' },
{ key: 'maintenance', icon: '/images/Container6.svg', label: '系统维护', path: '/maintenance' },
{ key: 'settings', icon: '/images/Container6.svg', label: '系统设置', path: '/settings' },

View File

@ -0,0 +1,118 @@
/**
* Hooks
* 使 React Query
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { pendingActionService } from '@/services/pendingActionService';
import type {
QueryPendingActionsParams,
CreatePendingActionRequest,
BatchCreatePendingActionRequest,
UpdatePendingActionRequest,
} from '@/types/pending-action.types';
/** Query Keys */
export const pendingActionKeys = {
all: ['pendingActions'] as const,
list: (params: QueryPendingActionsParams) => [...pendingActionKeys.all, 'list', params] as const,
detail: (id: string) => [...pendingActionKeys.all, 'detail', id] as const,
};
/**
*
*/
export function usePendingActions(params: QueryPendingActionsParams = {}) {
return useQuery({
queryKey: pendingActionKeys.list(params),
queryFn: () => pendingActionService.getList(params),
staleTime: 30 * 1000, // 30秒后标记为过期
gcTime: 5 * 60 * 1000, // 5分钟后垃圾回收
});
}
/**
*
*/
export function usePendingActionDetail(id: string) {
return useQuery({
queryKey: pendingActionKeys.detail(id),
queryFn: () => pendingActionService.getDetail(id),
enabled: !!id,
staleTime: 60 * 1000,
gcTime: 5 * 60 * 1000,
});
}
/**
*
*/
export function useCreatePendingAction() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreatePendingActionRequest) => pendingActionService.create(data),
onSuccess: () => {
// 创建成功后刷新列表
queryClient.invalidateQueries({ queryKey: pendingActionKeys.all });
},
});
}
/**
*
*/
export function useBatchCreatePendingActions() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: BatchCreatePendingActionRequest) => pendingActionService.batchCreate(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: pendingActionKeys.all });
},
});
}
/**
*
*/
export function useUpdatePendingAction() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdatePendingActionRequest }) =>
pendingActionService.update(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: pendingActionKeys.all });
queryClient.invalidateQueries({ queryKey: pendingActionKeys.detail(id) });
},
});
}
/**
*
*/
export function useCancelPendingAction() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => pendingActionService.cancel(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: pendingActionKeys.all });
},
});
}
/**
*
*/
export function useDeletePendingAction() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => pendingActionService.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: pendingActionKeys.all });
},
});
}

View File

@ -164,4 +164,15 @@ export const API_ENDPOINTS = {
DELETE: (id: string) => `/v1/admin/maintenance/${id}`,
CURRENT_STATUS: '/v1/admin/maintenance/status/current',
},
// 用户待办操作 (identity-service)
PENDING_ACTIONS: {
LIST: '/v1/admin/pending-actions',
CREATE: '/v1/admin/pending-actions',
BATCH_CREATE: '/v1/admin/pending-actions/batch',
DETAIL: (id: string) => `/v1/admin/pending-actions/${id}`,
UPDATE: (id: string) => `/v1/admin/pending-actions/${id}`,
CANCEL: (id: string) => `/v1/admin/pending-actions/${id}/cancel`,
DELETE: (id: string) => `/v1/admin/pending-actions/${id}`,
},
} as const;

View File

@ -0,0 +1,79 @@
/**
*
*
*/
import apiClient from '@/infrastructure/api/client';
import { API_ENDPOINTS } from '@/infrastructure/api/endpoints';
import type {
PendingAction,
PendingActionListResponse,
CreatePendingActionRequest,
BatchCreatePendingActionRequest,
UpdatePendingActionRequest,
QueryPendingActionsParams,
BatchCreateResult,
} from '@/types/pending-action.types';
/**
*
* apiClient response.data
*/
export const pendingActionService = {
/**
*
*/
async getList(params: QueryPendingActionsParams = {}): Promise<PendingActionListResponse> {
const response = await apiClient.get(API_ENDPOINTS.PENDING_ACTIONS.LIST, { params });
return response.data;
},
/**
*
*/
async getDetail(id: string): Promise<PendingAction> {
const response = await apiClient.get(API_ENDPOINTS.PENDING_ACTIONS.DETAIL(id));
return response.data;
},
/**
*
*/
async create(data: CreatePendingActionRequest): Promise<PendingAction> {
const response = await apiClient.post(API_ENDPOINTS.PENDING_ACTIONS.CREATE, data);
return response.data;
},
/**
*
*/
async batchCreate(data: BatchCreatePendingActionRequest): Promise<BatchCreateResult> {
const response = await apiClient.post(API_ENDPOINTS.PENDING_ACTIONS.BATCH_CREATE, data);
return response.data;
},
/**
*
*/
async update(id: string, data: UpdatePendingActionRequest): Promise<PendingAction> {
const response = await apiClient.put(API_ENDPOINTS.PENDING_ACTIONS.UPDATE(id), data);
return response.data;
},
/**
*
*/
async cancel(id: string): Promise<PendingAction> {
const response = await apiClient.post(API_ENDPOINTS.PENDING_ACTIONS.CANCEL(id));
return response.data;
},
/**
*
*/
async delete(id: string): Promise<void> {
await apiClient.delete(API_ENDPOINTS.PENDING_ACTIONS.DELETE(id));
},
};
export default pendingActionService;

View File

@ -6,3 +6,4 @@ export * from './company.types';
export * from './statistics.types';
export * from './common.types';
export * from './dashboard.types';
export * from './pending-action.types';

View File

@ -0,0 +1,132 @@
// 用户待办操作类型定义
/**
*
*/
export type PendingActionStatus = 'PENDING' | 'COMPLETED' | 'CANCELLED';
/**
*
*/
export const ACTION_CODES = {
ADOPTION_WIZARD: 'ADOPTION_WIZARD', // 认种向导
SETTLE_REWARDS: 'SETTLE_REWARDS', // 结算奖励
BIND_PHONE: 'BIND_PHONE', // 绑定手机
FORCE_KYC: 'FORCE_KYC', // 强制 KYC
CUSTOM_NOTICE: 'CUSTOM_NOTICE', // 自定义通知
} as const;
export type ActionCode = (typeof ACTION_CODES)[keyof typeof ACTION_CODES] | string;
/**
*
*/
export interface PendingAction {
id: string;
userId: string;
actionCode: string;
actionParams: Record<string, unknown> | null;
status: PendingActionStatus;
priority: number;
expiresAt: string | null;
createdAt: string;
completedAt: string | null;
createdBy: string | null;
}
/**
*
*/
export interface CreatePendingActionRequest {
userId: string;
actionCode: string;
actionParams?: Record<string, unknown>;
priority?: number;
expiresAt?: string;
}
/**
*
*/
export interface BatchCreatePendingActionRequest {
userIds: string[];
actionCode: string;
actionParams?: Record<string, unknown>;
priority?: number;
expiresAt?: string;
}
/**
*
*/
export interface UpdatePendingActionRequest {
actionParams?: Record<string, unknown>;
status?: PendingActionStatus;
priority?: number;
expiresAt?: string | null;
}
/**
*
*/
export interface QueryPendingActionsParams {
userId?: string;
actionCode?: string;
status?: PendingActionStatus;
page?: number;
limit?: number;
}
/**
*
*/
export interface PendingActionListResponse {
items: PendingAction[];
total: number;
page: number;
limit: number;
}
/**
*
*/
export interface BatchCreateResult {
successCount: number;
failedCount: number;
failedUserIds?: string[];
}
/**
*
*/
export const ACTION_CODE_OPTIONS = [
{ value: ACTION_CODES.ADOPTION_WIZARD, label: '认种向导' },
{ value: ACTION_CODES.SETTLE_REWARDS, label: '结算奖励' },
{ value: ACTION_CODES.BIND_PHONE, label: '绑定手机' },
{ value: ACTION_CODES.FORCE_KYC, label: '强制 KYC' },
{ value: ACTION_CODES.CUSTOM_NOTICE, label: '自定义通知' },
] as const;
/**
*
*/
export const STATUS_OPTIONS = [
{ value: 'PENDING', label: '待执行', color: '#faad14' },
{ value: 'COMPLETED', label: '已完成', color: '#52c41a' },
{ value: 'CANCELLED', label: '已取消', color: '#999999' },
] as const;
/**
*
*/
export function getStatusInfo(status: PendingActionStatus) {
return STATUS_OPTIONS.find((s) => s.value === status) || STATUS_OPTIONS[0];
}
/**
*
*/
export function getActionCodeLabel(code: string): string {
const option = ACTION_CODE_OPTIONS.find((o) => o.value === code);
return option?.label || code;
}

View File

@ -92,4 +92,8 @@ class ApiEndpoints {
// System Config (-> Admin Service)
static const String systemConfig = '/system-config';
static const String displaySettings = '$systemConfig/display/settings';
// Pending Actions (-> Identity Service)
static const String pendingActions = '/user/pending-actions';
static const String pendingActionsComplete = '/user/pending-actions'; // POST /:id/complete
}

View File

@ -14,6 +14,8 @@ import '../services/notification_service.dart';
import '../services/system_config_service.dart';
import '../services/contract_signing_service.dart';
import '../services/contract_check_service.dart';
import '../services/pending_action_service.dart';
import '../services/pending_action_check_service.dart';
import '../telemetry/storage/telemetry_storage.dart';
import '../../features/kyc/data/kyc_service.dart';
@ -125,6 +127,18 @@ final kycServiceProvider = Provider<KycService>((ref) {
return KycService(apiClient);
});
// Pending Action Service Provider ( identity-service)
final pendingActionServiceProvider = Provider<PendingActionService>((ref) {
final apiClient = ref.watch(apiClientProvider);
return PendingActionService(apiClient: apiClient);
});
// Pending Action Check Service Provider ()
final pendingActionCheckServiceProvider = Provider<PendingActionCheckService>((ref) {
final pendingActionService = ref.watch(pendingActionServiceProvider);
return PendingActionCheckService(pendingActionService: pendingActionService);
});
// Override provider with initialized instance
ProviderContainer createProviderContainer(LocalStorage localStorage) {
return ProviderContainer(

View File

@ -0,0 +1,77 @@
import 'package:flutter/foundation.dart';
import 'pending_action_service.dart';
///
class PendingActionCheckResult {
///
final bool hasPendingActions;
///
final int pendingCount;
///
final PendingAction? nextAction;
PendingActionCheckResult({
required this.hasPendingActions,
required this.pendingCount,
this.nextAction,
});
}
///
/// App
class PendingActionCheckService {
final PendingActionService _pendingActionService;
PendingActionCheckService({
required PendingActionService pendingActionService,
}) : _pendingActionService = pendingActionService;
///
/// true
Future<bool> hasPendingActions() async {
try {
final response = await _pendingActionService.getMyPendingActions();
return response.hasPendingActions;
} catch (e) {
debugPrint('[PendingActionCheckService] 检查待办操作失败: $e');
// 使 App
return false;
}
}
///
Future<PendingActionCheckResult> check() async {
try {
final response = await _pendingActionService.getMyPendingActions();
final pendingActions = response.pendingActionsSorted;
debugPrint('[PendingActionCheckService] 检测到 ${pendingActions.length} 个待办操作');
return PendingActionCheckResult(
hasPendingActions: pendingActions.isNotEmpty,
pendingCount: pendingActions.length,
nextAction: response.nextAction,
);
} catch (e) {
debugPrint('[PendingActionCheckService] 综合检查失败: $e');
// 使 App
return PendingActionCheckResult(
hasPendingActions: false,
pendingCount: 0,
);
}
}
///
Future<int> getPendingActionCount() async {
try {
final response = await _pendingActionService.getMyPendingActions();
return response.pendingActionsSorted.length;
} catch (e) {
debugPrint('[PendingActionCheckService] 获取待办操作数量失败: $e');
return 0;
}
}
}

View File

@ -0,0 +1,196 @@
import 'package:flutter/foundation.dart';
import '../network/api_client.dart';
import '../constants/api_endpoints.dart';
///
enum PendingActionCode {
adoptionWizard('ADOPTION_WIZARD', '认种向导'),
settleRewards('SETTLE_REWARDS', '结算奖励'),
bindPhone('BIND_PHONE', '绑定手机'),
forceKyc('FORCE_KYC', '强制实名'),
signContract('SIGN_CONTRACT', '签署合同'),
updateProfile('UPDATE_PROFILE', '更新资料');
final String code;
final String label;
const PendingActionCode(this.code, this.label);
static PendingActionCode? fromCode(String code) {
for (final action in PendingActionCode.values) {
if (action.code == code) return action;
}
return null;
}
}
///
enum PendingActionStatus {
pending('PENDING'),
completed('COMPLETED'),
cancelled('CANCELLED');
final String value;
const PendingActionStatus(this.value);
static PendingActionStatus fromValue(String value) {
for (final status in PendingActionStatus.values) {
if (status.value == value) return status;
}
return PendingActionStatus.pending;
}
}
///
class PendingAction {
final int id;
final int userId;
final String actionCode;
final Map<String, dynamic>? actionParams;
final PendingActionStatus status;
final int priority;
final DateTime? expiresAt;
final DateTime createdAt;
final DateTime? completedAt;
PendingAction({
required this.id,
required this.userId,
required this.actionCode,
this.actionParams,
required this.status,
required this.priority,
this.expiresAt,
required this.createdAt,
this.completedAt,
});
factory PendingAction.fromJson(Map<String, dynamic> json) {
return PendingAction(
id: _parseInt(json['id']),
userId: _parseInt(json['userId']),
actionCode: json['actionCode'] as String,
actionParams: json['actionParams'] as Map<String, dynamic>?,
status: PendingActionStatus.fromValue(json['status'] as String),
priority: json['priority'] as int? ?? 0,
expiresAt: json['expiresAt'] != null
? DateTime.parse(json['expiresAt'] as String)
: null,
createdAt: DateTime.parse(json['createdAt'] as String),
completedAt: json['completedAt'] != null
? DateTime.parse(json['completedAt'] as String)
: null,
);
}
static int _parseInt(dynamic value) {
if (value is int) return value;
if (value is String) return int.parse(value);
return 0;
}
///
PendingActionCode? get actionCodeEnum => PendingActionCode.fromCode(actionCode);
///
String get displayLabel => actionCodeEnum?.label ?? actionCode;
///
bool get isExpired {
if (expiresAt == null) return false;
return DateTime.now().isAfter(expiresAt!);
}
///
bool get isPending => status == PendingActionStatus.pending && !isExpired;
@override
String toString() =>
'PendingAction(id: $id, actionCode: $actionCode, status: ${status.value}, priority: $priority)';
}
///
class PendingActionsResponse {
final List<PendingAction> actions;
final int count;
PendingActionsResponse({
required this.actions,
required this.count,
});
factory PendingActionsResponse.fromJson(Map<String, dynamic> json) {
final actionsJson = json['actions'] as List<dynamic>? ?? [];
return PendingActionsResponse(
actions: actionsJson
.map((e) => PendingAction.fromJson(e as Map<String, dynamic>))
.toList(),
count: json['count'] as int? ?? actionsJson.length,
);
}
///
List<PendingAction> get pendingActionsSorted {
final pending = actions.where((a) => a.isPending).toList();
pending.sort((a, b) => b.priority.compareTo(a.priority));
return pending;
}
///
bool get hasPendingActions => pendingActionsSorted.isNotEmpty;
///
PendingAction? get nextAction =>
pendingActionsSorted.isNotEmpty ? pendingActionsSorted.first : null;
}
///
class PendingActionService {
final ApiClient _apiClient;
PendingActionService({required ApiClient apiClient}) : _apiClient = apiClient;
///
Future<PendingActionsResponse> getMyPendingActions() async {
debugPrint('[PendingActionService] 获取待办操作列表');
try {
final response = await _apiClient.get(ApiEndpoints.pendingActions);
debugPrint('[PendingActionService] 响应: $response');
if (response is Map<String, dynamic>) {
return PendingActionsResponse.fromJson(response);
}
//
if (response is List) {
return PendingActionsResponse(
actions: response
.map((e) => PendingAction.fromJson(e as Map<String, dynamic>))
.toList(),
count: response.length,
);
}
return PendingActionsResponse(actions: [], count: 0);
} catch (e) {
debugPrint('[PendingActionService] 获取待办操作失败: $e');
rethrow;
}
}
///
Future<void> completeAction(int actionId, {Map<String, dynamic>? resultData}) async {
debugPrint('[PendingActionService] 完成待办操作: actionId=$actionId');
try {
await _apiClient.post(
'${ApiEndpoints.pendingActions}/$actionId/complete',
data: resultData != null ? {'resultData': resultData} : null,
);
debugPrint('[PendingActionService] 待办操作完成成功');
} catch (e) {
debugPrint('[PendingActionService] 完成待办操作失败: $e');
rethrow;
}
}
}

View File

@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
import '../../../../routes/route_paths.dart';
import '../../../../bootstrap.dart';
import '../../../../core/providers/maintenance_provider.dart';
import '../../../../core/di/injection_container.dart';
import '../providers/auth_provider.dart';
/// -
@ -145,13 +146,33 @@ class _SplashPageState extends ConsumerState<SplashPage> {
//
//
// 1.
// 1.
// 2.
// 3. 退
if (authState.isAccountCreated) {
//
debugPrint('[SplashPage] 账号已创建 → 跳转到龙虎榜');
context.go(RoutePaths.ranking);
//
debugPrint('[SplashPage] 账号已创建 → 检查待办操作');
try {
final pendingActionCheckService = ref.read(pendingActionCheckServiceProvider);
final hasPending = await pendingActionCheckService.hasPendingActions();
if (!mounted) return;
if (hasPending) {
//
debugPrint('[SplashPage] 有待办操作 → 跳转到待办操作页面');
context.go(RoutePaths.pendingActions);
} else {
//
debugPrint('[SplashPage] 无待办操作 → 跳转到龙虎榜');
context.go(RoutePaths.ranking);
}
} catch (e) {
// 使
debugPrint('[SplashPage] 检查待办操作失败: $e → 跳转到龙虎榜');
context.go(RoutePaths.ranking);
}
} else if (authState.isFirstLaunch) {
//
debugPrint('[SplashPage] 首次打开 → 跳转到向导页');

View File

@ -0,0 +1,496 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/di/injection_container.dart';
import '../../../../core/services/pending_action_service.dart';
import '../../../../routes/route_paths.dart';
///
///
class PendingActionsPage extends ConsumerStatefulWidget {
const PendingActionsPage({super.key});
@override
ConsumerState<PendingActionsPage> createState() => _PendingActionsPageState();
}
class _PendingActionsPageState extends ConsumerState<PendingActionsPage> {
List<PendingAction> _pendingActions = [];
int _currentIndex = 0;
bool _isLoading = true;
bool _isExecuting = false;
String? _errorMessage;
@override
void initState() {
super.initState();
_loadPendingActions();
}
///
Future<void> _loadPendingActions() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final service = ref.read(pendingActionServiceProvider);
final response = await service.getMyPendingActions();
setState(() {
_pendingActions = response.pendingActionsSorted;
_isLoading = false;
});
//
if (_pendingActions.isEmpty) {
_completeAllActions();
}
} catch (e) {
setState(() {
_isLoading = false;
_errorMessage = '加载失败: $e';
});
}
}
///
Future<void> _executeCurrentAction() async {
if (_currentIndex >= _pendingActions.length) {
_completeAllActions();
return;
}
final action = _pendingActions[_currentIndex];
setState(() => _isExecuting = true);
try {
//
final result = await _executeAction(action);
if (result) {
//
final service = ref.read(pendingActionServiceProvider);
await service.completeAction(action.id);
//
setState(() {
_currentIndex++;
_isExecuting = false;
});
//
if (_currentIndex >= _pendingActions.length) {
_completeAllActions();
}
} else {
//
setState(() => _isExecuting = false);
}
} catch (e) {
setState(() {
_isExecuting = false;
_errorMessage = '操作失败: $e';
});
}
}
/// true
Future<bool> _executeAction(PendingAction action) async {
switch (action.actionCode) {
case 'ADOPTION_WIZARD':
//
final result = await context.push<bool>(RoutePaths.plantingQuantity);
return result == true;
case 'SETTLE_REWARDS':
//
// actionParams
final result = await context.push<bool>(RoutePaths.trading);
return result == true;
case 'BIND_PHONE':
//
final result = await context.push<bool>(RoutePaths.kycPhone);
return result == true;
case 'FORCE_KYC':
//
final orderNo = action.actionParams?['orderNo'] as String?;
final result = await context.push<bool>(RoutePaths.kycEntry, extra: orderNo);
return result == true;
case 'SIGN_CONTRACT':
//
final orderNo = action.actionParams?['orderNo'] as String?;
if (orderNo != null) {
final result = await context.push<bool>(
'${RoutePaths.contractSigning}/$orderNo',
);
return result == true;
}
//
final result = await context.push<bool>(
RoutePaths.pendingContracts,
extra: true, // forceSign = true
);
return result == true;
case 'UPDATE_PROFILE':
//
final result = await context.push<bool>(RoutePaths.editProfile);
return result == true;
default:
//
debugPrint('[PendingActionsPage] 未知操作类型: ${action.actionCode}');
return true;
}
}
///
void _completeAllActions() {
if (mounted) {
debugPrint('[PendingActionsPage] 所有待办操作完成,跳转到主页');
context.go(RoutePaths.ranking);
}
}
@override
Widget build(BuildContext context) {
return PopScope(
//
canPop: false,
child: Scaffold(
body: Container(
width: double.infinity,
height: double.infinity,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xFFFFF7E6),
Color(0xFFEAE0C8),
],
),
),
child: SafeArea(
child: Column(
children: [
_buildHeader(),
Expanded(child: _buildContent()),
],
),
),
),
),
);
}
Widget _buildHeader() {
return Container(
padding: const EdgeInsets.all(16),
child: Column(
children: [
const Icon(
Icons.assignment_outlined,
color: Color(0xFFD4AF37),
size: 48,
),
const SizedBox(height: 12),
const Text(
'待办事项',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Color(0xFF5D4037),
),
),
const SizedBox(height: 8),
Text(
'请完成以下任务后继续使用',
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
if (_pendingActions.isNotEmpty) ...[
const SizedBox(height: 12),
_buildProgress(),
],
],
),
);
}
Widget _buildProgress() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'进度',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
Text(
'$_currentIndex / ${_pendingActions.length}',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Color(0xFFD4AF37),
),
),
],
),
const SizedBox(height: 8),
LinearProgressIndicator(
value: _pendingActions.isEmpty
? 0
: _currentIndex / _pendingActions.length,
backgroundColor: Colors.grey[300],
valueColor: const AlwaysStoppedAnimation<Color>(Color(0xFFD4AF37)),
borderRadius: BorderRadius.circular(4),
),
],
),
);
}
Widget _buildContent() {
if (_isLoading) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(color: Color(0xFFD4AF37)),
SizedBox(height: 16),
Text(
'正在加载待办事项...',
style: TextStyle(fontSize: 14, color: Color(0xFF666666)),
),
],
),
);
}
if (_errorMessage != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, color: Colors.red, size: 48),
const SizedBox(height: 16),
Text(
_errorMessage!,
style: const TextStyle(fontSize: 16, color: Colors.red),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _loadPendingActions,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFD4AF37),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text('重试'),
),
],
),
),
);
}
if (_pendingActions.isEmpty) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.check_circle_outline, color: Color(0xFF4CAF50), size: 64),
SizedBox(height: 16),
Text(
'所有待办事项已完成',
style: TextStyle(
fontSize: 18,
color: Color(0xFF4CAF50),
fontWeight: FontWeight.w600,
),
),
],
),
);
}
if (_currentIndex >= _pendingActions.length) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.check_circle_outline, color: Color(0xFF4CAF50), size: 64),
SizedBox(height: 16),
Text(
'恭喜!所有任务已完成',
style: TextStyle(
fontSize: 18,
color: Color(0xFF4CAF50),
fontWeight: FontWeight.w600,
),
),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _pendingActions.length,
itemBuilder: (context, index) => _buildActionCard(index),
);
}
Widget _buildActionCard(int index) {
final action = _pendingActions[index];
final isCompleted = index < _currentIndex;
final isCurrent = index == _currentIndex;
return Container(
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
border: isCurrent
? Border.all(color: const Color(0xFFD4AF37), width: 2)
: isCompleted
? Border.all(color: const Color(0xFF4CAF50), width: 1)
: null,
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
//
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: isCompleted
? const Color(0xFF4CAF50)
: isCurrent
? const Color(0xFFD4AF37)
: Colors.grey[300],
shape: BoxShape.circle,
),
child: Icon(
isCompleted
? Icons.check
: _getActionIcon(action.actionCode),
color: Colors.white,
size: 24,
),
),
const SizedBox(width: 16),
//
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
action.displayLabel,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: isCompleted
? Colors.grey
: const Color(0xFF5D4037),
decoration: isCompleted
? TextDecoration.lineThrough
: null,
),
),
const SizedBox(height: 4),
Text(
isCompleted
? '已完成'
: isCurrent
? '当前任务'
: '待处理',
style: TextStyle(
fontSize: 12,
color: isCompleted
? const Color(0xFF4CAF50)
: isCurrent
? const Color(0xFFD4AF37)
: Colors.grey,
),
),
],
),
),
//
if (isCurrent)
ElevatedButton(
onPressed: _isExecuting ? null : _executeCurrentAction,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFD4AF37),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: _isExecuting
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text('开始'),
),
],
),
),
);
}
IconData _getActionIcon(String actionCode) {
switch (actionCode) {
case 'ADOPTION_WIZARD':
return Icons.nature;
case 'SETTLE_REWARDS':
return Icons.monetization_on;
case 'BIND_PHONE':
return Icons.phone_android;
case 'FORCE_KYC':
return Icons.person_outline;
case 'SIGN_CONTRACT':
return Icons.description;
case 'UPDATE_PROFILE':
return Icons.edit;
default:
return Icons.assignment;
}
}
}

View File

@ -41,6 +41,7 @@ import '../features/kyc/presentation/pages/kyc_id_card_page.dart';
import '../features/kyc/presentation/pages/change_phone_page.dart';
import '../features/contract_signing/presentation/pages/contract_signing_page.dart';
import '../features/contract_signing/presentation/pages/pending_contracts_page.dart';
import '../features/pending_actions/presentation/pages/pending_actions_page.dart';
import 'route_paths.dart';
import 'route_names.dart';
@ -435,6 +436,13 @@ final appRouterProvider = Provider<GoRouter>((ref) {
},
),
// Pending Actions Page ()
GoRoute(
path: RoutePaths.pendingActions,
name: RouteNames.pendingActions,
builder: (context, state) => const PendingActionsPage(),
),
// Main Shell with Bottom Navigation
ShellRoute(
navigatorKey: _shellNavigatorKey,

View File

@ -56,4 +56,7 @@ class RouteNames {
// Contract Signing ()
static const contractSigning = 'contract-signing';
static const pendingContracts = 'pending-contracts';
// Pending Actions ()
static const pendingActions = 'pending-actions';
}

View File

@ -56,4 +56,7 @@ class RoutePaths {
// Contract Signing ()
static const contractSigning = '/contract-signing';
static const pendingContracts = '/contract-signing/pending';
// Pending Actions ()
static const pendingActions = '/pending-actions';
}