feat(pending-actions): enhance multi-select creation and add pre-check

Admin Web:
- Redesign create modal to support multi-select action types
- Add drag-and-drop ordering for execution sequence
- Auto-calculate priority based on order (first = highest)
- Add @dnd-kit dependencies for sortable functionality

Flutter Mobile App:
- Add pre-check logic before executing pending actions
- Auto-complete FORCE_KYC if KYC already verified
- Auto-complete BIND_PHONE if phone already bound
- Skip unnecessary user interactions for completed tasks

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-02 20:23:15 -08:00
parent 06d3489b49
commit 47a7e4a4da
5 changed files with 522 additions and 76 deletions

View File

@ -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",

View File

@ -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",

View File

@ -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 (
<div
ref={setNodeRef}
style={style}
className={styles.pendingActions__sortableItem}
>
<span
className={styles.pendingActions__dragHandle}
{...attributes}
{...listeners}
>
&#x2630;
</span>
<span className={styles.pendingActions__sortableIndex}>{index + 1}</span>
<span className={styles.pendingActions__sortableLabel}>{label}</span>
<button
type="button"
className={styles.pendingActions__sortableRemove}
onClick={onRemove}
>
&times;
</button>
</div>
);
}
/**
*
*/
@ -43,15 +110,29 @@ export default function PendingActionsPage() {
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const [viewingAction, setViewingAction] = useState<PendingAction | null>(null);
// 表单状态
// 表单状态 - 支持多选操作
const [formData, setFormData] = useState({
userId: '',
actionCode: 'ADOPTION_WIZARD',
selectedActions: [] as string[], // 按顺序存储已选择的 actionCode
actionParamsMap: {} as Record<string, string>, // 每个 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<string, unknown> | undefined;
if (formData.actionParams.trim()) {
try {
actionParams = JSON.parse(formData.actionParams);
} catch {
toast.error('操作参数格式错误,请输入有效的 JSON');
return;
// 编辑模式
if (editingAction) {
let actionParams: Record<string, unknown> | 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<string, Record<string, unknown> | 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() {
{/* 创建/编辑弹窗 */}
<Modal
visible={showCreateModal}
title={editingAction ? '编辑操作' : '新建操作'}
title={editingAction ? '编辑操作' : '新建待办操作'}
onClose={() => setShowCreateModal(false)}
footer={
<div className={styles.pendingActions__modalFooter}>
@ -429,14 +590,59 @@ export default function PendingActionsPage() {
onClick={handleSubmit}
loading={createMutation.isPending || updateMutation.isPending}
>
{editingAction ? '保存' : '创建'}
{editingAction ? '保存' : `创建${formData.selectedActions.length > 0 ? ` (${formData.selectedActions.length})` : ''}`}
</Button>
</div>
}
width={600}
width={700}
>
<div className={styles.pendingActions__form}>
{!editingAction && (
{editingAction ? (
/* 编辑模式 - 单个操作 */
<div className={styles.pendingActions__form}>
<div className={styles.pendingActions__formGroup}>
<label></label>
<input
type="text"
value={getActionCodeLabel(editingAction.actionCode)}
disabled
className={styles.pendingActions__disabledInput}
/>
</div>
<div className={styles.pendingActions__formGroup}>
<label> (JSON格式)</label>
<textarea
value={editFormData.actionParams}
onChange={(e) => setEditFormData({ ...editFormData, actionParams: e.target.value })}
placeholder='{"key": "value"}'
rows={4}
/>
</div>
<div className={styles.pendingActions__formRow}>
<div className={styles.pendingActions__formGroup}>
<label></label>
<input
type="number"
value={editFormData.priority}
onChange={(e) => setEditFormData({ ...editFormData, priority: parseInt(e.target.value) || 0 })}
min={0}
/>
</div>
<div className={styles.pendingActions__formGroup}>
<label></label>
<input
type="datetime-local"
value={editFormData.expiresAt}
onChange={(e) => setEditFormData({ ...editFormData, expiresAt: e.target.value })}
/>
</div>
</div>
</div>
) : (
/* 创建模式 - 多选操作 */
<div className={styles.pendingActions__form}>
<div className={styles.pendingActions__formGroup}>
<label>ID *</label>
<input
@ -446,50 +652,65 @@ export default function PendingActionsPage() {
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>
<label> ()</label>
<div className={styles.pendingActions__checkboxGrid}>
{ACTION_CODE_OPTIONS.map((opt) => {
const isSelected = formData.selectedActions.includes(opt.value);
return (
<label
key={opt.value}
className={cn(
styles.pendingActions__checkboxItem,
isSelected && styles['pendingActions__checkboxItem--selected']
)}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => handleToggleAction(opt.value)}
/>
<span>{opt.label}</span>
</label>
);
})}
</div>
</div>
{formData.selectedActions.length > 0 && (
<div className={styles.pendingActions__formGroup}>
<label> ()</label>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={formData.selectedActions}
strategy={verticalListSortingStrategy}
>
<div className={styles.pendingActions__sortableList}>
{formData.selectedActions.map((actionCode, index) => (
<SortableActionItem
key={actionCode}
id={actionCode}
label={getActionCodeLabel(actionCode)}
index={index}
onRemove={() => handleRemoveAction(actionCode)}
/>
))}
</div>
</SortableContext>
</DndContext>
<span className={styles.pendingActions__formHint}>
{formData.selectedActions.length}
</span>
</div>
)}
<div className={styles.pendingActions__formGroup}>
<label></label>
<label> ()</label>
<input
type="datetime-local"
value={formData.expiresAt}
@ -498,7 +719,7 @@ export default function PendingActionsPage() {
<span className={styles.pendingActions__formHint}></span>
</div>
</div>
</div>
)}
</Modal>
{/* 批量创建弹窗 */}

View File

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

View File

@ -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<PendingActionsPage> {
}
/// true
/// true
Future<bool> _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<PendingActionsPage> {
}
}
///
/// KYC
Future<bool> _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) {