feat(pending-actions): add special deduction feature for admin-created user actions
实现特殊扣减功能,允许管理员为用户创建扣减待办操作,由用户在移动端确认执行。 ## 后端 (wallet-service) ### 领域层 - 新增 `SPECIAL_DEDUCTION` 到 LedgerEntryType 枚举 用于记录特殊扣减的账本流水类型 ### 应用层 - 新增 `executeSpecialDeduction` 方法 - 验证用户钱包存在性 - 检查余额是否充足 - 乐观锁控制并发 - 扣减余额并记录账本流水 - 返回操作结果和新余额 ### API层 - 新增内部API: POST /api/v1/wallets/special-deduction/execute 供移动端调用执行特殊扣减操作 ## 前端 (admin-web) ### 类型定义 - 新增 `SPECIAL_DEDUCTION` 到 ACTION_CODES - 新增 `SpecialDeductionParams` 接口定义扣减参数 - amount: 扣减金额 - reason: 扣减原因 ### 页面 - 更新待办操作管理页面 - 当选择 SPECIAL_DEDUCTION 时显示扣减金额和原因输入框 - 验证扣减金额必须大于0 - 验证扣减原因不能为空 ### 样式 - 新增特殊扣减表单区域样式 ## 前端 (mobile-app) ### 服务层 - 新增 `executeSpecialDeduction` 方法到 WalletService - 新增 `SpecialDeductionResult` 结果类 - 新增 `specialDeduction` 到 PendingActionCode 枚举 ### 页面 - 新增 `SpecialDeductionPage` 特殊扣减确认页面 - 显示扣减金额和管理员备注 - 显示当前余额和扣减后余额 - 余额不足时禁用确认按钮 - 温馨提示说明操作性质 - 更新 `PendingActionsPage` - 处理 SPECIAL_DEDUCTION 类型的待办操作 - 从 actionParams 解析 amount 和 reason - 导航到特殊扣减确认页面 ## 工作流程 1. 管理员在 admin-web 创建 SPECIAL_DEDUCTION 待办操作 - 选择目标用户 - 输入扣减金额 - 输入扣减原因 2. 用户在 mobile-app 待办操作列表看到该操作 3. 用户点击后进入特殊扣减确认页面 - 查看扣减详情 - 确认余额充足 - 点击确认执行扣减 4. 后端执行扣减并记录账本流水 🤖 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
a609600cd8
commit
dfdd8ed65a
|
|
@ -273,4 +273,32 @@ export class InternalWalletController {
|
|||
remark: dto.remark,
|
||||
});
|
||||
}
|
||||
|
||||
// =============== 特殊扣减 API ===============
|
||||
|
||||
@Post('special-deduction/execute')
|
||||
@Public()
|
||||
@ApiOperation({ summary: '执行特殊扣减(内部API) - 用户确认扣减操作' })
|
||||
@ApiResponse({ status: 200, description: '扣减结果' })
|
||||
async executeSpecialDeduction(
|
||||
@Body() dto: {
|
||||
accountSequence: string;
|
||||
pendingActionId: string;
|
||||
amount: number;
|
||||
reason: string;
|
||||
},
|
||||
) {
|
||||
this.logger.log(`========== special-deduction/execute 请求 ==========`);
|
||||
this.logger.log(`请求参数: ${JSON.stringify(dto)}`);
|
||||
|
||||
const result = await this.walletService.executeSpecialDeduction({
|
||||
accountSequence: dto.accountSequence,
|
||||
amount: dto.amount,
|
||||
memo: dto.reason,
|
||||
operatorId: `pending-action:${dto.pendingActionId}`,
|
||||
});
|
||||
|
||||
this.logger.log(`特殊扣减结果: ${JSON.stringify(result)}`);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2331,6 +2331,8 @@ export class WalletApplicationService {
|
|||
TRANSFER_TO_POOL: '转入矿池',
|
||||
SWAP_EXECUTED: '兑换执行',
|
||||
WITHDRAWAL: '提现',
|
||||
FIAT_WITHDRAWAL: '法币提现',
|
||||
SPECIAL_DEDUCTION: '特殊扣减',
|
||||
TRANSFER_IN: '转入',
|
||||
TRANSFER_OUT: '转出',
|
||||
FREEZE: '冻结',
|
||||
|
|
@ -2339,4 +2341,151 @@ export class WalletApplicationService {
|
|||
};
|
||||
return nameMap[entryType] || entryType;
|
||||
}
|
||||
|
||||
// =============== 特殊扣减 (管理员操作) ===============
|
||||
|
||||
/**
|
||||
* 特殊扣减命令
|
||||
*/
|
||||
async executeSpecialDeduction(command: {
|
||||
accountSequence: string;
|
||||
amount: number;
|
||||
memo: string;
|
||||
operatorId: string;
|
||||
}): Promise<{ success: boolean; orderNo: string; newBalance: number }> {
|
||||
const MAX_RETRIES = 3;
|
||||
let retries = 0;
|
||||
|
||||
while (retries < MAX_RETRIES) {
|
||||
try {
|
||||
return await this.doExecuteSpecialDeduction(command);
|
||||
} catch (error) {
|
||||
if (this.isOptimisticLockError(error)) {
|
||||
retries++;
|
||||
this.logger.warn(`[executeSpecialDeduction] Optimistic lock conflict for ${command.accountSequence}, retry ${retries}/${MAX_RETRIES}`);
|
||||
if (retries >= MAX_RETRIES) {
|
||||
this.logger.error(`[executeSpecialDeduction] Max retries exceeded for ${command.accountSequence}`);
|
||||
throw error;
|
||||
}
|
||||
await this.sleep(50 * retries);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Unexpected: exited retry loop without result');
|
||||
}
|
||||
|
||||
private async doExecuteSpecialDeduction(command: {
|
||||
accountSequence: string;
|
||||
amount: number;
|
||||
memo: string;
|
||||
operatorId: string;
|
||||
}): Promise<{ success: boolean; orderNo: string; newBalance: number }> {
|
||||
this.logger.log(`[executeSpecialDeduction] 执行特殊扣减: accountSequence=${command.accountSequence}, amount=${command.amount}`);
|
||||
|
||||
// 验证金额
|
||||
if (command.amount <= 0) {
|
||||
throw new BadRequestException('扣减金额必须大于 0');
|
||||
}
|
||||
|
||||
// 查找钱包
|
||||
const wallet = await this.walletRepo.findByAccountSequence(command.accountSequence);
|
||||
if (!wallet) {
|
||||
throw new WalletNotFoundError(`accountSequence: ${command.accountSequence}`);
|
||||
}
|
||||
|
||||
// 验证余额是否足够
|
||||
const deductAmount = new (require('decimal.js').default)(command.amount);
|
||||
if (wallet.balances.usdt.available.value < command.amount) {
|
||||
throw new BadRequestException(
|
||||
`余额不足: 需要 ${command.amount} 绿积分, 当前可用 ${wallet.balances.usdt.available.value} 绿积分`,
|
||||
);
|
||||
}
|
||||
|
||||
// 生成订单号
|
||||
const orderNo = this.generateSpecialDeductionOrderNo();
|
||||
|
||||
// 使用事务处理
|
||||
let newBalance = 0;
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const walletRecord = await tx.walletAccount.findUnique({
|
||||
where: { accountSequence: command.accountSequence },
|
||||
});
|
||||
|
||||
if (!walletRecord) {
|
||||
throw new Error(`Wallet not found: ${command.accountSequence}`);
|
||||
}
|
||||
|
||||
const currentAvailable = new (require('decimal.js').default)(walletRecord.usdtAvailable.toString());
|
||||
const currentVersion = walletRecord.version;
|
||||
|
||||
if (currentAvailable.lessThan(deductAmount)) {
|
||||
throw new BadRequestException(`余额不足: 需要 ${command.amount} 绿积分, 当前可用 ${currentAvailable} 绿积分`);
|
||||
}
|
||||
|
||||
const newAvailable = currentAvailable.minus(deductAmount);
|
||||
newBalance = newAvailable.toNumber();
|
||||
|
||||
// 乐观锁更新
|
||||
const updateResult = await tx.walletAccount.updateMany({
|
||||
where: {
|
||||
id: walletRecord.id,
|
||||
version: currentVersion,
|
||||
},
|
||||
data: {
|
||||
usdtAvailable: newAvailable,
|
||||
version: currentVersion + 1,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
if (updateResult.count === 0) {
|
||||
throw new OptimisticLockError(`Optimistic lock conflict for wallet ${walletRecord.id}`);
|
||||
}
|
||||
|
||||
// 记录账本流水
|
||||
await tx.ledgerEntry.create({
|
||||
data: {
|
||||
accountSequence: command.accountSequence,
|
||||
userId: walletRecord.userId,
|
||||
entryType: LedgerEntryType.SPECIAL_DEDUCTION,
|
||||
amount: deductAmount.negated(),
|
||||
assetType: 'USDT',
|
||||
balanceAfter: newAvailable,
|
||||
refOrderId: orderNo,
|
||||
memo: `特殊扣减: ${command.memo}`,
|
||||
payloadJson: {
|
||||
operatorId: command.operatorId,
|
||||
originalAmount: command.amount,
|
||||
reason: command.memo,
|
||||
executedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`[executeSpecialDeduction] 成功扣减 ${command.amount} 绿积分, 新余额: ${newBalance}`);
|
||||
});
|
||||
|
||||
// 清除缓存
|
||||
await this.walletCacheService.invalidateWallet(wallet.userId.value);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
orderNo,
|
||||
newBalance,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成特殊扣减订单号
|
||||
*/
|
||||
private generateSpecialDeductionOrderNo(): string {
|
||||
const now = new Date();
|
||||
const dateStr = now.toISOString().slice(0, 10).replace(/-/g, '');
|
||||
const timeStr = now.toISOString().slice(11, 19).replace(/:/g, '');
|
||||
const random = Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||
return `SD${dateStr}${timeStr}${random}`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export enum LedgerEntryType {
|
|||
SWAP_EXECUTED = 'SWAP_EXECUTED',
|
||||
WITHDRAWAL = 'WITHDRAWAL', // 区块链划转
|
||||
FIAT_WITHDRAWAL = 'FIAT_WITHDRAWAL', // 法币提现
|
||||
SPECIAL_DEDUCTION = 'SPECIAL_DEDUCTION', // 特殊扣减(管理员操作)
|
||||
TRANSFER_IN = 'TRANSFER_IN',
|
||||
TRANSFER_OUT = 'TRANSFER_OUT',
|
||||
FREEZE = 'FREEZE',
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import {
|
|||
PendingAction,
|
||||
PendingActionStatus,
|
||||
ACTION_CODE_OPTIONS,
|
||||
ACTION_CODES,
|
||||
STATUS_OPTIONS,
|
||||
getStatusInfo,
|
||||
getActionCodeLabel,
|
||||
|
|
@ -118,6 +119,12 @@ export default function PendingActionsPage() {
|
|||
expiresAt: '',
|
||||
});
|
||||
|
||||
// 特殊扣减表单状态
|
||||
const [specialDeductionForm, setSpecialDeductionForm] = useState({
|
||||
amount: '',
|
||||
reason: '',
|
||||
});
|
||||
|
||||
// 单个操作编辑时使用的表单
|
||||
const [editFormData, setEditFormData] = useState({
|
||||
actionParams: '',
|
||||
|
|
@ -167,6 +174,7 @@ export default function PendingActionsPage() {
|
|||
actionParamsMap: {},
|
||||
expiresAt: '',
|
||||
});
|
||||
setSpecialDeductionForm({ amount: '', reason: '' });
|
||||
setShowCreateModal(true);
|
||||
};
|
||||
|
||||
|
|
@ -283,6 +291,25 @@ export default function PendingActionsPage() {
|
|||
// 解析每个操作的参数
|
||||
const actionParamsMap: Record<string, Record<string, unknown> | undefined> = {};
|
||||
for (const actionCode of formData.selectedActions) {
|
||||
// 特殊扣减有专用表单
|
||||
if (actionCode === ACTION_CODES.SPECIAL_DEDUCTION) {
|
||||
const amount = parseFloat(specialDeductionForm.amount);
|
||||
if (isNaN(amount) || amount <= 0) {
|
||||
toast.error('特殊扣减金额必须大于 0');
|
||||
return;
|
||||
}
|
||||
if (!specialDeductionForm.reason.trim()) {
|
||||
toast.error('特殊扣减必须填写原因/备注');
|
||||
return;
|
||||
}
|
||||
actionParamsMap[actionCode] = {
|
||||
amount,
|
||||
reason: specialDeductionForm.reason.trim(),
|
||||
createdBy: 'admin', // TODO: 从登录状态获取
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
const paramsStr = formData.actionParamsMap[actionCode];
|
||||
if (paramsStr?.trim()) {
|
||||
try {
|
||||
|
|
@ -718,6 +745,40 @@ export default function PendingActionsPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 特殊扣减专用表单 */}
|
||||
{formData.selectedActions.includes(ACTION_CODES.SPECIAL_DEDUCTION) && (
|
||||
<div className={styles.pendingActions__specialDeduction}>
|
||||
<div className={styles.pendingActions__specialDeductionTitle}>
|
||||
特殊扣减设置
|
||||
</div>
|
||||
<div className={styles.pendingActions__formRow}>
|
||||
<div className={styles.pendingActions__formGroup}>
|
||||
<label>扣减金额 (绿积分) *</label>
|
||||
<input
|
||||
type="number"
|
||||
value={specialDeductionForm.amount}
|
||||
onChange={(e) => setSpecialDeductionForm({ ...specialDeductionForm, amount: e.target.value })}
|
||||
placeholder="请输入扣减金额"
|
||||
min={0}
|
||||
step={0.01}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.pendingActions__formGroup}>
|
||||
<label>扣减原因/备注 *</label>
|
||||
<textarea
|
||||
value={specialDeductionForm.reason}
|
||||
onChange={(e) => setSpecialDeductionForm({ ...specialDeductionForm, reason: e.target.value })}
|
||||
placeholder="请详细说明扣减原因,用户会在确认页面看到此信息"
|
||||
rows={3}
|
||||
/>
|
||||
<span className={styles.pendingActions__formHint}>
|
||||
此信息将显示给用户,请确保描述清晰准确
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.pendingActions__formGroup}>
|
||||
<label>过期时间 (可选,所有操作共用)</label>
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -422,4 +422,27 @@
|
|||
color: $error-color;
|
||||
}
|
||||
}
|
||||
|
||||
// 特殊扣减样式
|
||||
&__specialDeduction {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
background: #fff7e6;
|
||||
border: 1px solid #ffd591;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
&__specialDeductionTitle {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #d46b08;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
&::before {
|
||||
content: '⚠️';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export const ACTION_CODES = {
|
|||
FORCE_KYC: 'FORCE_KYC', // 强制 KYC
|
||||
SIGN_CONTRACT: 'SIGN_CONTRACT', // 签订合同
|
||||
CUSTOM_NOTICE: 'CUSTOM_NOTICE', // 自定义通知
|
||||
SPECIAL_DEDUCTION: 'SPECIAL_DEDUCTION', // 特殊扣减
|
||||
} as const;
|
||||
|
||||
export type ActionCode = (typeof ACTION_CODES)[keyof typeof ACTION_CODES] | string;
|
||||
|
|
@ -108,8 +109,18 @@ export const ACTION_CODE_OPTIONS = [
|
|||
{ value: ACTION_CODES.FORCE_KYC, label: '强制 KYC' },
|
||||
{ value: ACTION_CODES.SIGN_CONTRACT, label: '签订合同' },
|
||||
{ value: ACTION_CODES.CUSTOM_NOTICE, label: '自定义通知' },
|
||||
{ value: ACTION_CODES.SPECIAL_DEDUCTION, label: '特殊扣减' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 特殊扣减操作参数
|
||||
*/
|
||||
export interface SpecialDeductionParams {
|
||||
amount: number; // 扣减金额
|
||||
reason: string; // 扣减原因/备注
|
||||
createdBy: string; // 创建人
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态选项(用于筛选和显示)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ enum PendingActionCode {
|
|||
bindPhone('BIND_PHONE', '绑定手机'),
|
||||
forceKyc('FORCE_KYC', '强制实名'),
|
||||
signContract('SIGN_CONTRACT', '签署合同'),
|
||||
updateProfile('UPDATE_PROFILE', '更新资料');
|
||||
updateProfile('UPDATE_PROFILE', '更新资料'),
|
||||
specialDeduction('SPECIAL_DEDUCTION', '特殊扣减');
|
||||
|
||||
final String code;
|
||||
final String label;
|
||||
|
|
|
|||
|
|
@ -824,6 +824,75 @@ class WalletService {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行特殊扣减
|
||||
///
|
||||
/// 调用 POST /api/v1/wallets/special-deduction/execute (wallet-service 内部 API)
|
||||
/// 此方法由待办操作页面调用,用户确认后执行扣减
|
||||
Future<SpecialDeductionResult> executeSpecialDeduction({
|
||||
required String accountSequence,
|
||||
required String pendingActionId,
|
||||
required double amount,
|
||||
required String reason,
|
||||
}) async {
|
||||
try {
|
||||
debugPrint('[WalletService] ========== 执行特殊扣减 ==========');
|
||||
debugPrint('[WalletService] accountSequence: $accountSequence');
|
||||
debugPrint('[WalletService] pendingActionId: $pendingActionId');
|
||||
debugPrint('[WalletService] amount: $amount');
|
||||
debugPrint('[WalletService] reason: $reason');
|
||||
|
||||
final response = await _apiClient.post(
|
||||
'/api/v1/wallets/special-deduction/execute',
|
||||
data: {
|
||||
'accountSequence': accountSequence,
|
||||
'pendingActionId': pendingActionId,
|
||||
'amount': amount,
|
||||
'reason': reason,
|
||||
},
|
||||
);
|
||||
|
||||
debugPrint('[WalletService] 响应状态码: ${response.statusCode}');
|
||||
debugPrint('[WalletService] 响应数据: ${response.data}');
|
||||
|
||||
if (response.statusCode == 200 || response.statusCode == 201) {
|
||||
final responseData = response.data as Map<String, dynamic>;
|
||||
final data = responseData['data'] as Map<String, dynamic>? ?? responseData;
|
||||
final result = SpecialDeductionResult.fromJson(data);
|
||||
debugPrint('[WalletService] 特殊扣减成功: orderNo=${result.orderNo}, newBalance=${result.newBalance}');
|
||||
debugPrint('[WalletService] ================================');
|
||||
return result;
|
||||
}
|
||||
|
||||
throw Exception('特殊扣减失败: ${response.statusCode}');
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('[WalletService] !!!!!!!!!! 特殊扣减异常 !!!!!!!!!!');
|
||||
debugPrint('[WalletService] 错误: $e');
|
||||
debugPrint('[WalletService] 堆栈: $stackTrace');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 特殊扣减结果
|
||||
class SpecialDeductionResult {
|
||||
final bool success;
|
||||
final String orderNo;
|
||||
final double newBalance;
|
||||
|
||||
SpecialDeductionResult({
|
||||
required this.success,
|
||||
required this.orderNo,
|
||||
required this.newBalance,
|
||||
});
|
||||
|
||||
factory SpecialDeductionResult.fromJson(Map<String, dynamic> json) {
|
||||
return SpecialDeductionResult(
|
||||
success: json['success'] as bool? ?? false,
|
||||
orderNo: json['orderNo'] as String? ?? '',
|
||||
newBalance: (json['newBalance'] ?? 0).toDouble(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 提取响应
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import '../../../../core/services/contract_signing_service.dart';
|
|||
import '../../../../core/services/reward_service.dart';
|
||||
import '../../../../routes/route_paths.dart';
|
||||
import '../../../kyc/data/kyc_service.dart';
|
||||
import 'special_deduction_page.dart';
|
||||
|
||||
/// 待办操作页面
|
||||
/// 强制用户按后端配置执行待办任务,不可跳过
|
||||
|
|
@ -166,6 +167,29 @@ class _PendingActionsPageState extends ConsumerState<PendingActionsPage> {
|
|||
final result = await context.push<bool>(RoutePaths.editProfile);
|
||||
return result == true;
|
||||
|
||||
case 'SPECIAL_DEDUCTION':
|
||||
// 特殊扣减操作
|
||||
final amount = (action.actionParams?['amount'] ?? 0).toDouble();
|
||||
final reason = action.actionParams?['reason'] as String? ?? '管理员扣减';
|
||||
|
||||
if (amount <= 0) {
|
||||
debugPrint('[PendingActionsPage] 特殊扣减金额无效: $amount');
|
||||
return true; // 金额无效,跳过
|
||||
}
|
||||
|
||||
final result = await Navigator.of(context).push<bool>(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => SpecialDeductionPage(
|
||||
params: SpecialDeductionParams(
|
||||
pendingActionId: action.id.toString(),
|
||||
amount: amount,
|
||||
reason: reason,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return result == true;
|
||||
|
||||
default:
|
||||
// 未知操作类型,显示提示并标记为完成
|
||||
debugPrint('[PendingActionsPage] 未知操作类型: ${action.actionCode}');
|
||||
|
|
@ -588,6 +612,8 @@ class _PendingActionsPageState extends ConsumerState<PendingActionsPage> {
|
|||
return Icons.description;
|
||||
case 'UPDATE_PROFILE':
|
||||
return Icons.edit;
|
||||
case 'SPECIAL_DEDUCTION':
|
||||
return Icons.remove_circle_outline;
|
||||
default:
|
||||
return Icons.assignment;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,537 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../../core/di/injection_container.dart';
|
||||
import '../../../../core/services/wallet_service.dart';
|
||||
|
||||
/// 特殊扣减参数
|
||||
class SpecialDeductionParams {
|
||||
final String pendingActionId;
|
||||
final double amount;
|
||||
final String reason;
|
||||
|
||||
SpecialDeductionParams({
|
||||
required this.pendingActionId,
|
||||
required this.amount,
|
||||
required this.reason,
|
||||
});
|
||||
}
|
||||
|
||||
/// 特殊扣减确认页面
|
||||
class SpecialDeductionPage extends ConsumerStatefulWidget {
|
||||
final SpecialDeductionParams params;
|
||||
|
||||
const SpecialDeductionPage({
|
||||
super.key,
|
||||
required this.params,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<SpecialDeductionPage> createState() =>
|
||||
_SpecialDeductionPageState();
|
||||
}
|
||||
|
||||
class _SpecialDeductionPageState extends ConsumerState<SpecialDeductionPage> {
|
||||
bool _isLoading = false;
|
||||
bool _isExecuting = false;
|
||||
String? _errorMessage;
|
||||
WalletResponse? _walletInfo;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadWalletInfo();
|
||||
}
|
||||
|
||||
Future<void> _loadWalletInfo() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final walletService = ref.read(walletServiceProvider);
|
||||
final wallet = await walletService.getMyWallet();
|
||||
setState(() {
|
||||
_walletInfo = wallet;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_errorMessage = '加载钱包信息失败: $e';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _executeDeduction() async {
|
||||
if (_walletInfo == null) return;
|
||||
|
||||
// 检查余额是否足够
|
||||
if (_walletInfo!.balances.usdt.available < widget.params.amount) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('余额不足,无法执行扣减'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isExecuting = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final accountService = ref.read(accountServiceProvider);
|
||||
final accountSequence = await accountService.getUserSerialNum();
|
||||
|
||||
if (accountSequence == null || accountSequence.isEmpty) {
|
||||
throw Exception('未登录');
|
||||
}
|
||||
|
||||
final walletService = ref.read(walletServiceProvider);
|
||||
final result = await walletService.executeSpecialDeduction(
|
||||
accountSequence: accountSequence,
|
||||
pendingActionId: widget.params.pendingActionId,
|
||||
amount: widget.params.amount,
|
||||
reason: widget.params.reason,
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('扣减成功,新余额: ${result.newBalance.toStringAsFixed(2)} 绿积分'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
} else {
|
||||
throw Exception('扣减失败');
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isExecuting = false;
|
||||
_errorMessage = '执行扣减失败: $e';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return 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.warning_amber_rounded,
|
||||
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],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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: _loadWalletInfo,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFD4AF37),
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
// 扣减信息卡片
|
||||
_buildDeductionInfoCard(),
|
||||
const SizedBox(height: 16),
|
||||
// 钱包余额卡片
|
||||
_buildBalanceCard(),
|
||||
const SizedBox(height: 24),
|
||||
// 确认按钮
|
||||
_buildConfirmButton(),
|
||||
const SizedBox(height: 16),
|
||||
// 提示信息
|
||||
_buildNotice(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDeductionInfoCard() {
|
||||
return Container(
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF7E6),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.remove_circle_outline,
|
||||
color: Color(0xFFD4AF37),
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
'扣减金额',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: Text(
|
||||
'${widget.params.amount.toStringAsFixed(2)} 绿积分',
|
||||
style: const TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFFE53935),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Divider(),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'扣减原因',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF7E6),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: const Color(0xFFFFD591)),
|
||||
),
|
||||
child: Text(
|
||||
widget.params.reason,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF5D4037),
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBalanceCard() {
|
||||
final available = _walletInfo?.balances.usdt.available ?? 0;
|
||||
final afterDeduction = available - widget.params.amount;
|
||||
final isInsufficient = afterDeduction < 0;
|
||||
|
||||
return Container(
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'当前余额',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF666666),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${available.toStringAsFixed(2)} 绿积分',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'扣减金额',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF666666),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'-${widget.params.amount.toStringAsFixed(2)} 绿积分',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFFE53935),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12),
|
||||
child: Divider(),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'扣减后余额',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${afterDeduction.toStringAsFixed(2)} 绿积分',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isInsufficient ? Colors.red : const Color(0xFF4CAF50),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (isInsufficient) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFEBEE),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: Colors.red, size: 20),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'余额不足,无法执行此扣减操作',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildConfirmButton() {
|
||||
final available = _walletInfo?.balances.usdt.available ?? 0;
|
||||
final isInsufficient = available < widget.params.amount;
|
||||
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _isExecuting || isInsufficient ? null : _executeDeduction,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: isInsufficient ? Colors.grey : const Color(0xFFE53935),
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
disabledBackgroundColor: Colors.grey[300],
|
||||
),
|
||||
child: _isExecuting
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
isInsufficient ? '余额不足' : '确认扣减',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNotice() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF7E6),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: const Color(0xFFFFD591)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
color: Color(0xFFD46B08),
|
||||
size: 20,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'温馨提示',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFFD46B08),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'• 此操作由系统管理员发起\n'
|
||||
'• 扣减将从您的绿积分余额中直接扣除\n'
|
||||
'• 扣减完成后无法撤销\n'
|
||||
'• 如有疑问,请联系客服',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.grey[700],
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue