diff --git a/frontend/admin-web/package-lock.json b/frontend/admin-web/package-lock.json
index 62b9990a..d33132ba 100644
--- a/frontend/admin-web/package-lock.json
+++ b/frontend/admin-web/package-lock.json
@@ -8,6 +8,9 @@
"name": "rwadurian-admin-web",
"version": "1.0.0",
"dependencies": {
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"@reduxjs/toolkit": "^2.3.0",
"@tanstack/react-query": "^5.62.16",
"axios": "^1.7.9",
@@ -42,6 +45,60 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@dnd-kit/accessibility": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
+ "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/core": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
+ "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@dnd-kit/accessibility": "^3.1.1",
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/sortable": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
+ "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
+ "license": "MIT",
+ "dependencies": {
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "@dnd-kit/core": "^6.3.0",
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/utilities": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
+ "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
"node_modules/@emnapi/core": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz",
diff --git a/frontend/admin-web/package.json b/frontend/admin-web/package.json
index f3804074..0de95462 100644
--- a/frontend/admin-web/package.json
+++ b/frontend/admin-web/package.json
@@ -14,6 +14,9 @@
"type-check": "tsc --noEmit"
},
"dependencies": {
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"@reduxjs/toolkit": "^2.3.0",
"@tanstack/react-query": "^5.62.16",
"axios": "^1.7.9",
diff --git a/frontend/admin-web/src/app/(dashboard)/pending-actions/page.tsx b/frontend/admin-web/src/app/(dashboard)/pending-actions/page.tsx
index 2cb6ac33..b4f245c2 100644
--- a/frontend/admin-web/src/app/(dashboard)/pending-actions/page.tsx
+++ b/frontend/admin-web/src/app/(dashboard)/pending-actions/page.tsx
@@ -1,6 +1,23 @@
'use client';
import { useState, useCallback } from 'react';
+import {
+ DndContext,
+ closestCenter,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ DragEndEvent,
+} from '@dnd-kit/core';
+import {
+ arrayMove,
+ SortableContext,
+ sortableKeyboardCoordinates,
+ useSortable,
+ verticalListSortingStrategy,
+} from '@dnd-kit/sortable';
+import { CSS } from '@dnd-kit/utilities';
import { Modal, toast, Button } from '@/components/common';
import { PageContainer } from '@/components/layout';
import { cn } from '@/utils/helpers';
@@ -23,6 +40,56 @@ import {
} from '@/types/pending-action.types';
import styles from './pending-actions.module.scss';
+/** 可排序的操作项组件 */
+interface SortableActionItemProps {
+ id: string;
+ label: string;
+ index: number;
+ onRemove: () => void;
+}
+
+function SortableActionItem({ id, label, index, onRemove }: SortableActionItemProps) {
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({ id });
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ opacity: isDragging ? 0.5 : 1,
+ };
+
+ return (
+
+
+ ☰
+
+ {index + 1}
+ {label}
+
+
+ );
+}
+
/**
* 用户待办操作管理页面
*/
@@ -43,15 +110,29 @@ export default function PendingActionsPage() {
const [deleteConfirm, setDeleteConfirm] = useState(null);
const [viewingAction, setViewingAction] = useState(null);
- // 表单状态
+ // 表单状态 - 支持多选操作
const [formData, setFormData] = useState({
userId: '',
- actionCode: 'ADOPTION_WIZARD',
+ selectedActions: [] as string[], // 按顺序存储已选择的 actionCode
+ actionParamsMap: {} as Record, // 每个 actionCode 对应的参数
+ expiresAt: '',
+ });
+
+ // 单个操作编辑时使用的表单
+ const [editFormData, setEditFormData] = useState({
actionParams: '',
priority: 0,
expiresAt: '',
});
+ // 拖拽排序 sensors
+ const sensors = useSensors(
+ useSensor(PointerSensor),
+ useSensor(KeyboardSensor, {
+ coordinateGetter: sortableKeyboardCoordinates,
+ })
+ );
+
// 批量创建表单
const [batchFormData, setBatchFormData] = useState({
userIds: '',
@@ -82,9 +163,8 @@ export default function PendingActionsPage() {
setEditingAction(null);
setFormData({
userId: '',
- actionCode: 'ADOPTION_WIZARD',
- actionParams: '',
- priority: 0,
+ selectedActions: [],
+ actionParamsMap: {},
expiresAt: '',
});
setShowCreateModal(true);
@@ -93,9 +173,7 @@ export default function PendingActionsPage() {
// 打开编辑弹窗
const handleEdit = (action: PendingAction) => {
setEditingAction(action);
- setFormData({
- userId: action.userId,
- actionCode: action.actionCode,
+ setEditFormData({
actionParams: action.actionParams ? JSON.stringify(action.actionParams, null, 2) : '',
priority: action.priority,
expiresAt: action.expiresAt ? action.expiresAt.slice(0, 16) : '',
@@ -103,6 +181,51 @@ export default function PendingActionsPage() {
setShowCreateModal(true);
};
+ // 切换选择操作
+ const handleToggleAction = (actionCode: string) => {
+ setFormData((prev) => {
+ const isSelected = prev.selectedActions.includes(actionCode);
+ if (isSelected) {
+ // 取消选择
+ const newSelected = prev.selectedActions.filter((code) => code !== actionCode);
+ const newParamsMap = { ...prev.actionParamsMap };
+ delete newParamsMap[actionCode];
+ return { ...prev, selectedActions: newSelected, actionParamsMap: newParamsMap };
+ } else {
+ // 添加选择(追加到末尾)
+ return {
+ ...prev,
+ selectedActions: [...prev.selectedActions, actionCode],
+ };
+ }
+ });
+ };
+
+ // 拖拽排序结束
+ const handleDragEnd = (event: DragEndEvent) => {
+ const { active, over } = event;
+ if (over && active.id !== over.id) {
+ setFormData((prev) => {
+ const oldIndex = prev.selectedActions.indexOf(active.id as string);
+ const newIndex = prev.selectedActions.indexOf(over.id as string);
+ return {
+ ...prev,
+ selectedActions: arrayMove(prev.selectedActions, oldIndex, newIndex),
+ };
+ });
+ }
+ };
+
+ // 移除已选择的操作
+ const handleRemoveAction = (actionCode: string) => {
+ setFormData((prev) => {
+ const newSelected = prev.selectedActions.filter((code) => code !== actionCode);
+ const newParamsMap = { ...prev.actionParamsMap };
+ delete newParamsMap[actionCode];
+ return { ...prev, selectedActions: newSelected, actionParamsMap: newParamsMap };
+ });
+ };
+
// 打开批量创建弹窗
const handleBatchCreate = () => {
setBatchFormData({
@@ -117,45 +240,83 @@ export default function PendingActionsPage() {
// 提交创建/编辑表单
const handleSubmit = async () => {
- if (!editingAction && !formData.userId.trim()) {
- toast.error('请输入用户ID');
- return;
- }
-
- let actionParams: Record | undefined;
- if (formData.actionParams.trim()) {
- try {
- actionParams = JSON.parse(formData.actionParams);
- } catch {
- toast.error('操作参数格式错误,请输入有效的 JSON');
- return;
+ // 编辑模式
+ if (editingAction) {
+ let actionParams: Record | undefined;
+ if (editFormData.actionParams.trim()) {
+ try {
+ actionParams = JSON.parse(editFormData.actionParams);
+ } catch {
+ toast.error('操作参数格式错误,请输入有效的 JSON');
+ return;
+ }
}
- }
- try {
- if (editingAction) {
+ try {
await updateMutation.mutateAsync({
id: editingAction.id,
data: {
actionParams,
- priority: formData.priority,
- expiresAt: formData.expiresAt ? new Date(formData.expiresAt).toISOString() : undefined,
+ priority: editFormData.priority,
+ expiresAt: editFormData.expiresAt ? new Date(editFormData.expiresAt).toISOString() : undefined,
},
});
toast.success('操作已更新');
- } else {
+ setShowCreateModal(false);
+ } catch (err) {
+ toast.error((err as Error).message || '更新失败');
+ }
+ return;
+ }
+
+ // 创建模式 - 支持多选
+ if (!formData.userId.trim()) {
+ toast.error('请输入用户ID');
+ return;
+ }
+
+ if (formData.selectedActions.length === 0) {
+ toast.error('请至少选择一个操作类型');
+ return;
+ }
+
+ // 解析每个操作的参数
+ const actionParamsMap: Record | undefined> = {};
+ for (const actionCode of formData.selectedActions) {
+ const paramsStr = formData.actionParamsMap[actionCode];
+ if (paramsStr?.trim()) {
+ try {
+ actionParamsMap[actionCode] = JSON.parse(paramsStr);
+ } catch {
+ toast.error(`${getActionCodeLabel(actionCode)} 的参数格式错误,请输入有效的 JSON`);
+ return;
+ }
+ }
+ }
+
+ try {
+ // 按顺序创建多个操作,优先级递减(第一个最高)
+ const basePriority = formData.selectedActions.length * 10; // 例如 3 个操作: 30, 20, 10
+ let successCount = 0;
+
+ for (let i = 0; i < formData.selectedActions.length; i++) {
+ const actionCode = formData.selectedActions[i];
+ const priority = basePriority - i * 10;
+
await createMutation.mutateAsync({
userId: formData.userId.trim(),
- actionCode: formData.actionCode,
- actionParams,
- priority: formData.priority,
+ actionCode,
+ actionParams: actionParamsMap[actionCode],
+ priority,
expiresAt: formData.expiresAt ? new Date(formData.expiresAt).toISOString() : undefined,
});
- toast.success('操作已创建');
+ successCount++;
}
+
+ toast.success(`成功创建 ${successCount} 个待办操作`);
setShowCreateModal(false);
} catch (err) {
- toast.error((err as Error).message || '操作失败');
+ toast.error((err as Error).message || '创建失败');
}
};
@@ -417,7 +578,7 @@ export default function PendingActionsPage() {
{/* 创建/编辑弹窗 */}
setShowCreateModal(false)}
footer={
@@ -429,14 +590,59 @@ export default function PendingActionsPage() {
onClick={handleSubmit}
loading={createMutation.isPending || updateMutation.isPending}
>
- {editingAction ? '保存' : '创建'}
+ {editingAction ? '保存' : `创建${formData.selectedActions.length > 0 ? ` (${formData.selectedActions.length})` : ''}`}
}
- width={600}
+ width={700}
>
-
- {!editingAction && (
+ {editingAction ? (
+ /* 编辑模式 - 单个操作 */
+
+
+
+
+
+
+
+
+
+
+
+
+ ) : (
+ /* 创建模式 - 多选操作 */
+
- )}
-
-
-
-
-
-
-
-
-
-
-
-
setFormData({ ...formData, priority: parseInt(e.target.value) || 0 })}
- min={0}
- />
-
数值越大优先级越高
+
+
+ {ACTION_CODE_OPTIONS.map((opt) => {
+ const isSelected = formData.selectedActions.includes(opt.value);
+ return (
+
+ );
+ })}
+
+ {formData.selectedActions.length > 0 && (
+
+
+
+
+
+ {formData.selectedActions.map((actionCode, index) => (
+ handleRemoveAction(actionCode)}
+ />
+ ))}
+
+
+
+
+ 将创建 {formData.selectedActions.length} 个待办操作,用户登录后按此顺序依次执行
+
+
+ )}
+
-
+
留空表示永不过期
-
+ )}
{/* 批量创建弹窗 */}
diff --git a/frontend/admin-web/src/app/(dashboard)/pending-actions/pending-actions.module.scss b/frontend/admin-web/src/app/(dashboard)/pending-actions/pending-actions.module.scss
index 6e8877f8..cf7f9d79 100644
--- a/frontend/admin-web/src/app/(dashboard)/pending-actions/pending-actions.module.scss
+++ b/frontend/admin-web/src/app/(dashboard)/pending-actions/pending-actions.module.scss
@@ -291,4 +291,124 @@
margin: 0;
flex: 1;
}
+
+ &__disabledInput {
+ background: #f5f5f5 !important;
+ cursor: not-allowed;
+ }
+
+ // 多选复选框网格
+ &__checkboxGrid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 8px;
+ }
+
+ &__checkboxItem {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 12px;
+ border: 1px solid $border-color;
+ border-radius: 8px;
+ cursor: pointer;
+ transition: all 0.2s;
+
+ &:hover {
+ border-color: $primary-color;
+ background: #f0f5ff;
+ }
+
+ input[type='checkbox'] {
+ width: 16px;
+ height: 16px;
+ cursor: pointer;
+ }
+
+ span {
+ font-size: 14px;
+ color: $text-primary;
+ }
+
+ &--selected {
+ border-color: $primary-color;
+ background: #e6f4ff;
+
+ span {
+ color: $primary-color;
+ font-weight: 500;
+ }
+ }
+ }
+
+ // 可排序列表
+ &__sortableList {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ background: #fafafa;
+ padding: 12px;
+ border-radius: 8px;
+ min-height: 50px;
+ }
+
+ &__sortableItem {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 12px;
+ background: white;
+ border: 1px solid $border-color;
+ border-radius: 8px;
+ transition: box-shadow 0.2s;
+
+ &:hover {
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+ }
+ }
+
+ &__dragHandle {
+ cursor: grab;
+ color: $text-disabled;
+ font-size: 16px;
+ padding: 4px;
+
+ &:active {
+ cursor: grabbing;
+ }
+ }
+
+ &__sortableIndex {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ background: $primary-color;
+ color: white;
+ border-radius: 50%;
+ font-size: 12px;
+ font-weight: 600;
+ }
+
+ &__sortableLabel {
+ flex: 1;
+ font-size: 14px;
+ color: $text-primary;
+ }
+
+ &__sortableRemove {
+ background: transparent;
+ border: none;
+ color: $text-disabled;
+ font-size: 18px;
+ cursor: pointer;
+ padding: 4px 8px;
+ line-height: 1;
+ transition: color 0.2s;
+
+ &:hover {
+ color: $error-color;
+ }
+ }
}
diff --git a/frontend/mobile-app/lib/features/pending_actions/presentation/pages/pending_actions_page.dart b/frontend/mobile-app/lib/features/pending_actions/presentation/pages/pending_actions_page.dart
index 2d21f203..fb3236b8 100644
--- a/frontend/mobile-app/lib/features/pending_actions/presentation/pages/pending_actions_page.dart
+++ b/frontend/mobile-app/lib/features/pending_actions/presentation/pages/pending_actions_page.dart
@@ -4,6 +4,7 @@ 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';
+import '../../../kyc/data/kyc_service.dart';
/// 待办操作页面
/// 强制用户按后端配置执行待办任务,不可跳过
@@ -97,7 +98,15 @@ class _PendingActionsPageState extends ConsumerState
{
}
/// 执行具体的操作,返回 true 表示成功
+ /// 会先检查操作是否已经完成,如果已完成则直接返回 true
Future _executeAction(PendingAction action) async {
+ // 预检查:检查操作是否已经完成
+ final alreadyCompleted = await _checkIfAlreadyCompleted(action);
+ if (alreadyCompleted) {
+ debugPrint('[PendingActionsPage] 操作 ${action.actionCode} 已完成,自动标记');
+ return true;
+ }
+
switch (action.actionCode) {
case 'ADOPTION_WIZARD':
// 跳转到认种向导
@@ -149,6 +158,42 @@ class _PendingActionsPageState extends ConsumerState {
}
}
+ /// 检查操作是否已经完成
+ /// 例如:KYC 已经验证过了,手机号已经绑定了
+ Future _checkIfAlreadyCompleted(PendingAction action) async {
+ try {
+ switch (action.actionCode) {
+ case 'FORCE_KYC':
+ // 检查 KYC 是否已完成
+ final kycService = ref.read(kycServiceProvider);
+ final kycStatus = await kycService.getKycStatus();
+ if (kycStatus.isCompleted) {
+ debugPrint('[PendingActionsPage] KYC 已完成,跳过此操作');
+ return true;
+ }
+ return false;
+
+ case 'BIND_PHONE':
+ // 检查手机号是否已绑定
+ final kycService = ref.read(kycServiceProvider);
+ final kycStatus = await kycService.getKycStatus();
+ if (kycStatus.phoneVerified) {
+ debugPrint('[PendingActionsPage] 手机号已绑定,跳过此操作');
+ return true;
+ }
+ return false;
+
+ // 其他操作类型目前不做预检查
+ default:
+ return false;
+ }
+ } catch (e) {
+ debugPrint('[PendingActionsPage] 预检查失败: $e');
+ // 检查失败时不跳过,让用户手动完成
+ return false;
+ }
+ }
+
/// 所有操作完成,跳转到主页
void _completeAllActions() {
if (mounted) {