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:
parent
04a8c56ad6
commit
28e0396a65
|
|
@ -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");
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: '操作已删除',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
export * from './user-profile.dto';
|
||||
export * from './device.dto';
|
||||
export * from './pending-action.dto';
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
@ -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],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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] 首次打开 → 跳转到向导页');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue