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:
hailin 2026-01-03 07:04:46 -08:00
parent a609600cd8
commit dfdd8ed65a
10 changed files with 907 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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: '⚠️';
}
}
}

View File

@ -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; // 创建人
}
/**
*
*/

View File

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

View File

@ -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(),
);
}
}
///

View File

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

View File

@ -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,
),
),
],
),
);
}
}