From 187b82e9acf0c885142ca77d0940096e2e53ee34 Mon Sep 17 00:00:00 2001 From: hailin Date: Fri, 30 Jan 2026 11:36:57 -0800 Subject: [PATCH] =?UTF-8?q?feat(c2c):=20=E4=BF=AE=E5=A4=8D6=E9=A1=B9C2C?= =?UTF-8?q?=E4=BA=A4=E6=98=93=E9=97=AE=E9=A2=98=20(#11-#16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #11: 价格固定1:1,最小交易数量为1 - #12: BUY买单不再冻结买方积分值(绿积分通过外部1.0系统支付) - #13: 支持部分成交,taker可指定数量,剩余自动拆分为新PENDING订单 - #14: 发布页双向输入,积分股数量与积分值金额1:1联动 - #15: 接BUY单时弹出收款信息输入(收款方式、账号、姓名) - #16: BUY单创建时验证积分值余额但不冻结 后端: cancelOrder/expireOrder/executeTransfer 均按BUY/SELL分别处理 前端: 发布页价格只读、市场页接单对话框增加数量和收款输入 Co-Authored-By: Claude Opus 4.5 --- .../src/application/services/c2c.service.ts | 277 +++++++++---- .../repositories/c2c-order.repository.ts | 3 + .../pages/c2c/c2c_market_page.dart | 387 ++++++++++++++++-- .../pages/c2c/c2c_publish_page.dart | 184 +++++---- 4 files changed, 657 insertions(+), 194 deletions(-) diff --git a/backend/services/trading-service/src/application/services/c2c.service.ts b/backend/services/trading-service/src/application/services/c2c.service.ts index 7418541e..7712e90c 100644 --- a/backend/services/trading-service/src/application/services/c2c.service.ts +++ b/backend/services/trading-service/src/application/services/c2c.service.ts @@ -109,6 +109,13 @@ export class C2cService { if (quantityDecimal.lte(0)) { throw new BadRequestException('数量必须大于0'); } + // #11: 价格必须为1(1:1),数量最小为1 + if (!priceDecimal.equals(1)) { + throw new BadRequestException('C2C交易价格固定为1:1'); + } + if (quantityDecimal.lt(1)) { + throw new BadRequestException('最小交易数量为1'); + } // 卖单:自动获取卖家 Kava 地址(用于绿积分转账) let sellerKavaAddress: string | null = null; @@ -130,13 +137,12 @@ export class C2cService { // 检查余额并冻结资产 if (type === C2C_ORDER_TYPE.BUY) { - // 买入订单:需要冻结积分值(现金) + // #16: 买入订单:验证积分值余额(买方通过外部系统支付绿积分) const totalAmountMoney = new Money(totalAmount); if (account.availableCash.isLessThan(totalAmountMoney)) { throw new BadRequestException(`积分值余额不足,需要 ${totalAmount.toString()},可用 ${account.availableCash.toString()}`); } - // 冻结积分值 - await this.tradingAccountRepository.freezeCash(accountSequence, totalAmount); + // #12: 不冻结(买方通过外部1.0系统支付绿积分,系统无法冻结外部资产) } else { // 卖出订单:需要冻结积分股 const quantityMoney = new Money(quantityDecimal); @@ -234,15 +240,60 @@ export class C2cService { throw new NotFoundException('交易账户不存在'); } - const quantityDecimal = new Decimal(order.quantity); - const totalAmountDecimal = new Decimal(order.totalAmount); + // #13: 支持部分成交,taker可指定数量 + const originalQuantity = new Decimal(order.quantity); + const priceDecimal = new Decimal(order.price); + + let quantityDecimal: Decimal; + let totalAmountDecimal: Decimal; + + if (options?.quantity) { + quantityDecimal = new Decimal(options.quantity); + if (quantityDecimal.gt(originalQuantity)) { + throw new BadRequestException('接单数量不能超过订单数量'); + } + if (quantityDecimal.lt(1)) { + throw new BadRequestException('最小交易数量为1'); + } + totalAmountDecimal = priceDecimal.mul(quantityDecimal); + } else { + quantityDecimal = originalQuantity; + totalAmountDecimal = new Decimal(order.totalAmount); + } + + // #13: 如果部分成交,创建剩余部分的新PENDING订单 + if (quantityDecimal.lt(originalQuantity)) { + const remainingQuantity = originalQuantity.minus(quantityDecimal); + const remainingTotal = priceDecimal.mul(remainingQuantity); + + const remainOrderNo = this.generateOrderNo(); + await this.c2cOrderRepository.create({ + orderNo: remainOrderNo, + type: order.type as any, + makerAccountSequence: order.makerAccountSequence, + makerUserId: order.makerUserId ?? undefined, + makerPhone: order.makerPhone ?? undefined, + makerNickname: order.makerNickname ?? undefined, + price: order.price, + quantity: remainingQuantity.toString(), + totalAmount: remainingTotal.toString(), + paymentMethod: order.paymentMethod ?? undefined, + paymentAccount: order.paymentAccount ?? undefined, + paymentQrCode: order.paymentQrCode ?? undefined, + paymentRealName: order.paymentRealName ?? undefined, + sellerKavaAddress: order.sellerKavaAddress, + remark: order.remark ?? undefined, + }); + + this.logger.log(`C2C部分成交: 原订单 ${orderNo} 拆分,剩余 ${remainingQuantity} 创建新订单 ${remainOrderNo}`); + } // 接单方需要冻结对应资产 if (order.type === C2C_ORDER_TYPE.BUY) { // 挂单方要买入积分股,接单方需要有积分股来卖出 const quantityMoney = new Money(quantityDecimal); if (takerAccount.availableShares.isLessThan(quantityMoney)) { - throw new BadRequestException(`积分股余额不足,需要 ${order.quantity},可用 ${takerAccount.availableShares.toString()}`); + throw new BadRequestException(`积分股余额不足,需要 ${quantityDecimal.toString()},可用 ${takerAccount.availableShares.toString()}`); } // 冻结接单方的积分股 await this.tradingAccountRepository.freezeShares(takerAccountSequence, quantityDecimal); @@ -250,7 +301,7 @@ export class C2cService { // 挂单方要卖出积分股,接单方需要有积分值来买入 const totalAmountMoney = new Money(totalAmountDecimal); if (takerAccount.availableCash.isLessThan(totalAmountMoney)) { - throw new BadRequestException(`积分值余额不足,需要 ${order.totalAmount},可用 ${takerAccount.availableCash.toString()}`); + throw new BadRequestException(`积分值余额不足,需要 ${totalAmountDecimal.toString()},可用 ${takerAccount.availableCash.toString()}`); } // 冻结接单方的积分值 await this.tradingAccountRepository.freezeCash(takerAccountSequence, totalAmountDecimal); @@ -266,6 +317,8 @@ export class C2cService { takerUserId: options?.userId, takerPhone: options?.phone, takerNickname: options?.nickname, + quantity: quantityDecimal.toString(), // #13: 更新为实际成交量 + totalAmount: totalAmountDecimal.toString(), // #13: 更新为实际成交额 matchedAt: now, paymentDeadline, }; @@ -280,7 +333,7 @@ export class C2cService { const updatedOrder = await this.c2cOrderRepository.updateStatus(orderNo, C2C_ORDER_STATUS.MATCHED as any, updateData); - this.logger.log(`C2C订单接单成功: ${orderNo}, 接单方: ${takerAccountSequence}`); + this.logger.log(`C2C订单接单成功: ${orderNo}, 接单方: ${takerAccountSequence}, 成交数量: ${quantityDecimal.toString()}`); return updatedOrder!; } finally { await this.redis.releaseLock(lockKey, lockValue); @@ -314,7 +367,7 @@ export class C2cService { // 解冻挂单方的资产 if (order.type === C2C_ORDER_TYPE.BUY) { - await this.tradingAccountRepository.unfreezeCash(accountSequence, totalAmountDecimal); + // BUY 订单未冻结买方资产(买方通过外部支付绿积分),无需解冻 } else { await this.tradingAccountRepository.unfreezeShares(accountSequence, quantityDecimal); } @@ -443,81 +496,144 @@ export class C2cService { // 使用事务执行转账 await this.prisma.$transaction(async (tx) => { - // 1. 解冻买方的积分值并扣除 - await tx.tradingAccount.update({ - where: { accountSequence: buyerAccountSequence }, - data: { - frozenCash: { decrement: totalAmountDecimal.toNumber() }, - cashBalance: { decrement: totalAmountDecimal.toNumber() }, - }, - }); + if (order.type === C2C_ORDER_TYPE.BUY) { + // BUY订单:买方通过外部支付绿积分,系统只转移卖方积分股给买方(单向) + // 1. 解冻卖方积分股并扣除 + await tx.tradingAccount.update({ + where: { accountSequence: sellerAccountSequence }, + data: { + frozenShares: { decrement: quantityDecimal.toNumber() }, + shareBalance: { decrement: quantityDecimal.toNumber() }, + totalSold: { increment: quantityDecimal.toNumber() }, + }, + }); - // 2. 解冻卖方的积分股并扣除 - await tx.tradingAccount.update({ - where: { accountSequence: sellerAccountSequence }, - data: { - frozenShares: { decrement: quantityDecimal.toNumber() }, - shareBalance: { decrement: quantityDecimal.toNumber() }, - totalSold: { increment: quantityDecimal.toNumber() }, - }, - }); + // 2. 买方获得积分股 + await tx.tradingAccount.update({ + where: { accountSequence: buyerAccountSequence }, + data: { + shareBalance: { increment: quantityDecimal.toNumber() }, + totalBought: { increment: quantityDecimal.toNumber() }, + }, + }); - // 3. 买方获得积分股 - await tx.tradingAccount.update({ - where: { accountSequence: buyerAccountSequence }, - data: { - shareBalance: { increment: quantityDecimal.toNumber() }, - totalBought: { increment: quantityDecimal.toNumber() }, - }, - }); + // 3. 记录交易流水(买方 — 积分股入账) + const buyerAccount = await tx.tradingAccount.findUnique({ + where: { accountSequence: buyerAccountSequence }, + }); + await tx.tradingTransaction.create({ + data: { + accountSequence: buyerAccountSequence, + type: 'C2C_BUY', + assetType: 'SHARE', + amount: quantityDecimal.toNumber(), + balanceBefore: new Decimal(buyerAccount!.shareBalance).minus(quantityDecimal).toNumber(), + balanceAfter: buyerAccount!.shareBalance, + referenceId: order.orderNo, + referenceType: 'C2C_ORDER', + counterpartyType: 'USER', + counterpartyAccountSeq: sellerAccountSequence, + memo: `C2C买入 ${order.quantity} 积分股(绿积分支付)`, + }, + }); - // 4. 卖方获得积分值 - await tx.tradingAccount.update({ - where: { accountSequence: sellerAccountSequence }, - data: { - cashBalance: { increment: totalAmountDecimal.toNumber() }, - }, - }); + // 4. 记录交易流水(卖方 — 积分股扣除) + const sellerAccount = await tx.tradingAccount.findUnique({ + where: { accountSequence: sellerAccountSequence }, + }); + await tx.tradingTransaction.create({ + data: { + accountSequence: sellerAccountSequence, + type: 'C2C_SELL', + assetType: 'SHARE', + amount: quantityDecimal.toNumber(), + balanceBefore: new Decimal(sellerAccount!.shareBalance).plus(quantityDecimal).toNumber(), + balanceAfter: sellerAccount!.shareBalance, + referenceId: order.orderNo, + referenceType: 'C2C_ORDER', + counterpartyType: 'USER', + counterpartyAccountSeq: buyerAccountSequence, + memo: `C2C卖出 ${order.quantity} 积分股(收到绿积分)`, + }, + }); + } else { + // SELL订单:双向交换(积分股 ↔ 积分值),保持现有逻辑 + // 1. 解冻买方的积分值并扣除 + await tx.tradingAccount.update({ + where: { accountSequence: buyerAccountSequence }, + data: { + frozenCash: { decrement: totalAmountDecimal.toNumber() }, + cashBalance: { decrement: totalAmountDecimal.toNumber() }, + }, + }); - // 5. 记录交易流水(买方) - const buyerAccount = await tx.tradingAccount.findUnique({ - where: { accountSequence: buyerAccountSequence }, - }); - await tx.tradingTransaction.create({ - data: { - accountSequence: buyerAccountSequence, - type: 'C2C_BUY', - assetType: 'SHARE', - amount: quantityDecimal.toNumber(), - balanceBefore: new Decimal(buyerAccount!.shareBalance).minus(quantityDecimal).toNumber(), - balanceAfter: buyerAccount!.shareBalance, - referenceId: order.orderNo, - referenceType: 'C2C_ORDER', - counterpartyType: 'USER', - counterpartyAccountSeq: sellerAccountSequence, - memo: `C2C买入 ${order.quantity} 积分股,单价 ${order.price}`, - }, - }); + // 2. 解冻卖方的积分股并扣除 + await tx.tradingAccount.update({ + where: { accountSequence: sellerAccountSequence }, + data: { + frozenShares: { decrement: quantityDecimal.toNumber() }, + shareBalance: { decrement: quantityDecimal.toNumber() }, + totalSold: { increment: quantityDecimal.toNumber() }, + }, + }); - // 6. 记录交易流水(卖方) - const sellerAccount = await tx.tradingAccount.findUnique({ - where: { accountSequence: sellerAccountSequence }, - }); - await tx.tradingTransaction.create({ - data: { - accountSequence: sellerAccountSequence, - type: 'C2C_SELL', - assetType: 'CASH', - amount: totalAmountDecimal.toNumber(), - balanceBefore: new Decimal(sellerAccount!.cashBalance).minus(totalAmountDecimal).toNumber(), - balanceAfter: sellerAccount!.cashBalance, - referenceId: order.orderNo, - referenceType: 'C2C_ORDER', - counterpartyType: 'USER', - counterpartyAccountSeq: buyerAccountSequence, - memo: `C2C卖出 ${order.quantity} 积分股,单价 ${order.price}`, - }, - }); + // 3. 买方获得积分股 + await tx.tradingAccount.update({ + where: { accountSequence: buyerAccountSequence }, + data: { + shareBalance: { increment: quantityDecimal.toNumber() }, + totalBought: { increment: quantityDecimal.toNumber() }, + }, + }); + + // 4. 卖方获得积分值 + await tx.tradingAccount.update({ + where: { accountSequence: sellerAccountSequence }, + data: { + cashBalance: { increment: totalAmountDecimal.toNumber() }, + }, + }); + + // 5. 记录交易流水(买方) + const buyerAccount = await tx.tradingAccount.findUnique({ + where: { accountSequence: buyerAccountSequence }, + }); + await tx.tradingTransaction.create({ + data: { + accountSequence: buyerAccountSequence, + type: 'C2C_BUY', + assetType: 'SHARE', + amount: quantityDecimal.toNumber(), + balanceBefore: new Decimal(buyerAccount!.shareBalance).minus(quantityDecimal).toNumber(), + balanceAfter: buyerAccount!.shareBalance, + referenceId: order.orderNo, + referenceType: 'C2C_ORDER', + counterpartyType: 'USER', + counterpartyAccountSeq: sellerAccountSequence, + memo: `C2C买入 ${order.quantity} 积分股,单价 ${order.price}`, + }, + }); + + // 6. 记录交易流水(卖方) + const sellerAccount = await tx.tradingAccount.findUnique({ + where: { accountSequence: sellerAccountSequence }, + }); + await tx.tradingTransaction.create({ + data: { + accountSequence: sellerAccountSequence, + type: 'C2C_SELL', + assetType: 'CASH', + amount: totalAmountDecimal.toNumber(), + balanceBefore: new Decimal(sellerAccount!.cashBalance).minus(totalAmountDecimal).toNumber(), + balanceAfter: sellerAccount!.cashBalance, + referenceId: order.orderNo, + referenceType: 'C2C_ORDER', + counterpartyType: 'USER', + counterpartyAccountSeq: buyerAccountSequence, + memo: `C2C卖出 ${order.quantity} 积分股,单价 ${order.price}`, + }, + }); + } }); this.logger.log(`C2C交易转账完成: ${order.orderNo}, 买方: ${buyerAccountSequence}, 卖方: ${sellerAccountSequence}`); @@ -626,8 +742,7 @@ export class C2cService { // 解冻双方资产 if (freshOrder.type === C2C_ORDER_TYPE.BUY) { - // BUY订单:maker冻结了积分值,taker冻结了积分股 - await this.tradingAccountRepository.unfreezeCash(freshOrder.makerAccountSequence, totalAmountDecimal); + // BUY订单:maker未冻结(买方通过外部支付绿积分),只解冻taker的积分股 if (freshOrder.takerAccountSequence) { await this.tradingAccountRepository.unfreezeShares(freshOrder.takerAccountSequence, quantityDecimal); } diff --git a/backend/services/trading-service/src/infrastructure/persistence/repositories/c2c-order.repository.ts b/backend/services/trading-service/src/infrastructure/persistence/repositories/c2c-order.repository.ts index 04ba0f8f..5725b76e 100644 --- a/backend/services/trading-service/src/infrastructure/persistence/repositories/c2c-order.repository.ts +++ b/backend/services/trading-service/src/infrastructure/persistence/repositories/c2c-order.repository.ts @@ -150,6 +150,9 @@ export class C2cOrderRepository { paymentAccount: string; paymentQrCode: string; paymentRealName: string; + // #13: 部分成交时更新数量和金额 + quantity: string; + totalAmount: string; // 超时时间 paymentDeadline: Date; confirmDeadline: Date; diff --git a/frontend/mining-app/lib/presentation/pages/c2c/c2c_market_page.dart b/frontend/mining-app/lib/presentation/pages/c2c/c2c_market_page.dart index 3d7d7f2f..fae9bfa4 100644 --- a/frontend/mining-app/lib/presentation/pages/c2c/c2c_market_page.dart +++ b/frontend/mining-app/lib/presentation/pages/c2c/c2c_market_page.dart @@ -585,65 +585,364 @@ class _C2cMarketPageState extends ConsumerState return '${dateTime.month}/${dateTime.day} ${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}'; } + // #13 + #15: 接单对话框(支持部分成交数量输入 + BUY单收款信息输入) void _showTakeOrderDialog(C2cOrderModel order, bool isBuyAction) { - showDialog( + final quantityController = TextEditingController(text: order.quantity); + final paymentAccountController = TextEditingController(); + final paymentRealNameController = TextEditingController(); + final isTakingBuyOrder = order.isBuy; // 接BUY单 = taker是卖方 + Set selectedPaymentMethods = {'GREEN_POINTS'}; + + showModalBottomSheet( context: context, - builder: (dialogContext) => AlertDialog( - backgroundColor: AppColors.cardOf(dialogContext), - title: Text( - isBuyAction ? '确认购买' : '确认出售', - style: TextStyle(color: AppColors.textPrimaryOf(dialogContext)), + isScrollControlled: true, + backgroundColor: AppColors.cardOf(context), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (sheetContext) { + return StatefulBuilder( + builder: (context, setSheetState) { + return Padding( + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 20, + bottom: MediaQuery.of(context).viewInsets.bottom + 20, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 标题 + Center( + child: Text( + isBuyAction ? '确认购买' : '确认出售', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.textPrimaryOf(context), + ), + ), + ), + const SizedBox(height: 20), + + // 订单信息 + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: _bgGray, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('单价', style: TextStyle(fontSize: 13, color: AppColors.textSecondaryOf(context))), + Text('${formatPrice(order.price)} 积分值', style: TextStyle(fontSize: 13, color: AppColors.textPrimaryOf(context))), + ], + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('可用数量', style: TextStyle(fontSize: 13, color: AppColors.textSecondaryOf(context))), + Text('${formatAmount(order.quantity)} 积分股', style: TextStyle(fontSize: 13, color: AppColors.textPrimaryOf(context))), + ], + ), + ], + ), + ), + const SizedBox(height: 16), + + // #13: 数量输入框(支持部分成交) + Text( + '交易数量', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.textPrimaryOf(context), + ), + ), + const SizedBox(height: 8), + TextField( + controller: quantityController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: InputDecoration( + hintText: '输入数量 (最大 ${order.quantity})', + hintStyle: TextStyle(color: AppColors.textSecondaryOf(context)), + filled: true, + fillColor: _bgGray, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: _orange, width: 2), + ), + suffixText: '积分股', + suffixStyle: TextStyle(color: AppColors.textSecondaryOf(context), fontSize: 14), + ), + ), + const SizedBox(height: 4), + Text( + '最小数量: 1,可输入小于订单总量的数量进行部分成交', + style: TextStyle(fontSize: 11, color: AppColors.textMutedOf(context)), + ), + + // #15: 收款信息(仅当接BUY单时显示 — taker是卖方,需要提供收款信息) + if (isTakingBuyOrder) ...[ + const SizedBox(height: 20), + const Divider(), + const SizedBox(height: 12), + Text( + '收款信息', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.textPrimaryOf(context), + ), + ), + const SizedBox(height: 4), + Text( + '买方将通过以下方式向您支付绿积分', + style: TextStyle(fontSize: 12, color: AppColors.textSecondaryOf(context)), + ), + const SizedBox(height: 12), + + // 收款方式选择 + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _buildPaymentChip('GREEN_POINTS', '绿积分', Icons.eco, + isSelected: selectedPaymentMethods.contains('GREEN_POINTS'), + isLocked: true, + onTap: null, + ), + _buildPaymentChip('ALIPAY', '支付宝', Icons.account_balance_wallet, + isSelected: selectedPaymentMethods.contains('ALIPAY'), + onTap: () => setSheetState(() { + if (selectedPaymentMethods.contains('ALIPAY')) { + selectedPaymentMethods.remove('ALIPAY'); + } else { + selectedPaymentMethods.add('ALIPAY'); + } + }), + ), + _buildPaymentChip('WECHAT', '微信', Icons.chat_bubble, + isSelected: selectedPaymentMethods.contains('WECHAT'), + onTap: () => setSheetState(() { + if (selectedPaymentMethods.contains('WECHAT')) { + selectedPaymentMethods.remove('WECHAT'); + } else { + selectedPaymentMethods.add('WECHAT'); + } + }), + ), + _buildPaymentChip('BANK', '银行卡', Icons.credit_card, + isSelected: selectedPaymentMethods.contains('BANK'), + onTap: () => setSheetState(() { + if (selectedPaymentMethods.contains('BANK')) { + selectedPaymentMethods.remove('BANK'); + } else { + selectedPaymentMethods.add('BANK'); + } + }), + ), + ], + ), + + // 账户ID (自动填充) + const SizedBox(height: 12), + Text( + '您的账户ID(买方将向此ID转账绿积分)', + style: TextStyle(fontSize: 12, color: AppColors.textSecondaryOf(context)), + ), + const SizedBox(height: 4), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: _bgGray, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + ref.read(userNotifierProvider).accountSequence ?? '', + style: TextStyle(fontSize: 14, color: AppColors.textPrimaryOf(context)), + ), + ), + + // 其他支付方式的收款账号 + if (selectedPaymentMethods.any((m) => m != 'GREEN_POINTS')) ...[ + const SizedBox(height: 12), + TextField( + controller: paymentAccountController, + decoration: InputDecoration( + labelText: '收款账号(其他支付方式)', + hintText: '请输入收款账号', + hintStyle: TextStyle(color: AppColors.textSecondaryOf(context)), + filled: true, + fillColor: _bgGray, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: _orange, width: 2), + ), + ), + ), + const SizedBox(height: 12), + TextField( + controller: paymentRealNameController, + decoration: InputDecoration( + labelText: '收款人姓名', + hintText: '请输入收款人真实姓名', + hintStyle: TextStyle(color: AppColors.textSecondaryOf(context)), + filled: true, + fillColor: _bgGray, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: _orange, width: 2), + ), + ), + ), + ], + ], + + const SizedBox(height: 24), + + // 提示信息 + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: _orange.withOpacity(0.05), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + isBuyAction + ? '您将使用积分值购买对方的积分股' + : '您将出售积分股,买方将向您支付绿积分', + style: TextStyle(fontSize: 12, color: AppColors.textSecondaryOf(context)), + ), + ), + + const SizedBox(height: 16), + + // 按钮 + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => Navigator.pop(context), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: const Text('取消'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton( + onPressed: () { + Navigator.pop(context); + _takeOrder( + order, + quantity: quantityController.text.trim(), + paymentMethod: isTakingBuyOrder ? selectedPaymentMethods.join(',') : null, + paymentAccount: isTakingBuyOrder ? paymentAccountController.text.trim() : null, + paymentRealName: isTakingBuyOrder ? paymentRealNameController.text.trim() : null, + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: _orange, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: const Text('确认接单', style: TextStyle(fontWeight: FontWeight.bold)), + ), + ), + ], + ), + ], + ), + ), + ); + }, + ); + }, + ); + } + + Widget _buildPaymentChip(String value, String label, IconData icon, { + required bool isSelected, + bool isLocked = false, + VoidCallback? onTap, + }) { + return GestureDetector( + onTap: isLocked ? null : onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: isSelected ? (value == 'GREEN_POINTS' ? _green : _orange) : _bgGray, + borderRadius: BorderRadius.circular(8), + border: isSelected ? null : Border.all(color: Colors.grey.shade300), ), - content: Column( + child: Row( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - '数量: ${formatAmount(order.quantity)} 积分股', - style: TextStyle(color: AppColors.textPrimaryOf(dialogContext)), + Icon( + isSelected ? Icons.check_circle : Icons.circle_outlined, + size: 16, + color: isSelected ? Colors.white : _grayText, ), - const SizedBox(height: 8), + const SizedBox(width: 4), + Icon(icon, size: 14, color: isSelected ? Colors.white : _grayText), + const SizedBox(width: 4), Text( - '单价: ${formatPrice(order.price)} 积分值', - style: TextStyle(color: AppColors.textPrimaryOf(dialogContext)), - ), - const SizedBox(height: 8), - Text( - '总金额: ${formatAmount(order.totalAmount)} 积分值', - style: TextStyle(fontWeight: FontWeight.bold, color: AppColors.textPrimaryOf(dialogContext)), - ), - const SizedBox(height: 16), - Text( - isBuyAction - ? '您将使用积分值购买对方的积分股' - : '您将出售积分股换取对方的积分值', - style: TextStyle(fontSize: 12, color: AppColors.textSecondaryOf(dialogContext)), + label, + style: TextStyle( + fontSize: 12, + color: isSelected ? Colors.white : _darkText, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + ), ), + if (isLocked) ...[ + const SizedBox(width: 2), + Icon(Icons.lock, size: 10, color: isSelected ? Colors.white70 : _grayText), + ], ], ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext), - child: Text('取消', style: TextStyle(color: AppColors.textSecondaryOf(dialogContext))), - ), - TextButton( - onPressed: () async { - Navigator.pop(dialogContext); - await _takeOrder(order); - }, - child: Text( - '确认', - style: TextStyle(color: _orange), - ), - ), - ], ), ); } - Future _takeOrder(C2cOrderModel order) async { + Future _takeOrder( + C2cOrderModel order, { + String? quantity, + String? paymentMethod, + String? paymentAccount, + String? paymentRealName, + }) async { final notifier = ref.read(c2cTradingNotifierProvider.notifier); - final success = await notifier.takeOrder(order.orderNo); + final success = await notifier.takeOrder( + order.orderNo, + quantity: quantity, + paymentMethod: paymentMethod, + paymentAccount: paymentAccount, + paymentRealName: paymentRealName, + ); if (success && mounted) { // 刷新列表 diff --git a/frontend/mining-app/lib/presentation/pages/c2c/c2c_publish_page.dart b/frontend/mining-app/lib/presentation/pages/c2c/c2c_publish_page.dart index 9ae6fd33..007c0855 100644 --- a/frontend/mining-app/lib/presentation/pages/c2c/c2c_publish_page.dart +++ b/frontend/mining-app/lib/presentation/pages/c2c/c2c_publish_page.dart @@ -24,9 +24,11 @@ class _C2cPublishPageState extends ConsumerState { static const Color _bgGray = Color(0xFFF3F4F6); int _selectedType = 1; // 0: 买入, 1: 卖出 - final _priceController = TextEditingController(); + final _priceController = TextEditingController(text: '1'); // #11: 价格固定1:1 final _quantityController = TextEditingController(); + final _amountController = TextEditingController(); // #14: 双向输入 final _remarkController = TextEditingController(); + bool _isSyncingInput = false; // 防止双向输入递归 // 收款信息(卖单必填) Set _selectedPaymentMethods = {'GREEN_POINTS'}; // 绿积分默认选中且不可取消 @@ -37,6 +39,7 @@ class _C2cPublishPageState extends ConsumerState { void dispose() { _priceController.dispose(); _quantityController.dispose(); + _amountController.dispose(); _remarkController.dispose(); _paymentAccountController.dispose(); _paymentRealNameController.dispose(); @@ -56,11 +59,6 @@ class _C2cPublishPageState extends ConsumerState { final availableShares = asset?.availableShares ?? '0'; final availableCash = asset?.availableCash ?? '0'; - // 设置默认价格 - if (_priceController.text.isEmpty && currentPrice != '0') { - _priceController.text = currentPrice; - } - return Scaffold( backgroundColor: _bgGray, appBar: AppBar( @@ -194,8 +192,12 @@ class _C2cPublishPageState extends ConsumerState { } Widget _buildBalanceCard(String availableShares, String availableCash) { - // C2C交易的是积分值,买入广告需要买家有绿积分(积分值)来支付 - // 卖出广告需要卖家有积分值来出售 + final isBuy = _selectedType == 0; + // BUY: 显示可用积分值(用于验证 #16) + // SELL: 显示可用积分股(卖方需要有足够积分股) + final label = isBuy ? '可用积分值' : '可用积分股'; + final balance = isBuy ? availableCash : availableShares; + return Container( margin: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.all(16), @@ -206,12 +208,12 @@ class _C2cPublishPageState extends ConsumerState { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Text( - '可用积分值', - style: TextStyle(fontSize: 14, color: _grayText), + Text( + label, + style: const TextStyle(fontSize: 14, color: _grayText), ), Text( - formatAmount(availableCash), + formatAmount(balance), style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, @@ -223,6 +225,7 @@ class _C2cPublishPageState extends ConsumerState { ); } + // #11: 价格固定为1:1,不可修改 Widget _buildPriceInput(String currentPrice) { return Container( margin: const EdgeInsets.symmetric(horizontal: 16), @@ -234,59 +237,51 @@ class _C2cPublishPageState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - '单价', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: _darkText, - ), - ), - Text( - '当前价: ${formatPrice(currentPrice)}', - style: const TextStyle(fontSize: 12, color: _grayText), - ), - ], + const Text( + '单价', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: _darkText, + ), ), const SizedBox(height: 12), - TextField( - controller: _priceController, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - inputFormatters: [ - FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,8}')), - ], - decoration: InputDecoration( - hintText: '请输入单价', - hintStyle: const TextStyle(color: _grayText), - filled: true, - fillColor: _bgGray, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: _orange, width: 2), - ), - suffixText: '元/积分值', - suffixStyle: const TextStyle(color: _grayText, fontSize: 14), + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: _bgGray, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: const [ + Text( + '1', + style: TextStyle(fontSize: 16, color: _darkText), + ), + Text( + '元/积分值 (固定)', + style: TextStyle(color: _grayText, fontSize: 14), + ), + ], ), - onChanged: (_) => setState(() {}), ), ], ), ); } + // #14: 双向输入 — 积分股数量 ↔ 积分值金额(1:1固定价格,两者相等) Widget _buildQuantityInput( String availableShares, String availableCash, String currentPrice, ) { - // C2C交易的是积分值 + final isBuy = _selectedType == 0; + // BUY: 买入积分股,用积分值支付 → 上限是可用积分值 + // SELL: 卖出积分股 → 上限是可用积分股 + final maxBalance = isBuy ? availableCash : availableShares; + return Container( margin: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.all(20), @@ -298,14 +293,21 @@ class _C2cPublishPageState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( - '数量', + '交易数量', style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, color: _darkText, ), ), + const SizedBox(height: 4), + Text( + '价格1:1,积分股数量 = 积分值金额', + style: const TextStyle(fontSize: 12, color: _grayText), + ), const SizedBox(height: 12), + + // 输入框1: 积分股数量 TextField( controller: _quantityController, keyboardType: const TextInputType.numberWithOptions(decimal: true), @@ -313,7 +315,7 @@ class _C2cPublishPageState extends ConsumerState { FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,4}')), ], decoration: InputDecoration( - hintText: '请输入积分值数量', + hintText: '请输入积分股数量', hintStyle: const TextStyle(color: _grayText), filled: true, fillColor: _bgGray, @@ -327,8 +329,10 @@ class _C2cPublishPageState extends ConsumerState { ), suffixIcon: TextButton( onPressed: () { - // 填入全部可用积分值 - _quantityController.text = availableCash; + _isSyncingInput = true; + _quantityController.text = maxBalance; + _amountController.text = maxBalance; + _isSyncingInput = false; setState(() {}); }, child: const Text( @@ -339,10 +343,50 @@ class _C2cPublishPageState extends ConsumerState { ), ), ), + suffixText: '积分股', + suffixStyle: const TextStyle(color: _grayText, fontSize: 14), + ), + onChanged: (val) { + if (_isSyncingInput) return; + _isSyncingInput = true; + _amountController.text = val; // 1:1同步 + _isSyncingInput = false; + setState(() {}); + }, + ), + + const SizedBox(height: 12), + + // 输入框2: 积分值金额 + TextField( + controller: _amountController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,4}')), + ], + decoration: InputDecoration( + hintText: '请输入积分值金额', + hintStyle: const TextStyle(color: _grayText), + filled: true, + fillColor: _bgGray, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: _orange, width: 2), + ), suffixText: '积分值', suffixStyle: const TextStyle(color: _grayText, fontSize: 14), ), - onChanged: (_) => setState(() {}), + onChanged: (val) { + if (_isSyncingInput) return; + _isSyncingInput = true; + _quantityController.text = val; // 1:1同步 + _isSyncingInput = false; + setState(() {}); + }, ), ], ), @@ -635,9 +679,9 @@ class _C2cPublishPageState extends ConsumerState { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Text('积分值数量', style: TextStyle(fontSize: 14, color: _grayText)), + const Text('积分股数量', style: TextStyle(fontSize: 14, color: _grayText)), Text( - '${formatAmount(quantity.toString())} 积分值', + '${formatAmount(quantity.toString())} 积分股', style: const TextStyle( fontSize: 14, fontWeight: FontWeight.bold, @@ -661,12 +705,11 @@ class _C2cPublishPageState extends ConsumerState { } Widget _buildPublishButton(C2cTradingState c2cState) { - final price = double.tryParse(_priceController.text) ?? 0; final quantity = double.tryParse(_quantityController.text) ?? 0; final isSell = _selectedType == 1; - // 验证条件 - bool isValid = price > 0 && quantity > 0; + // #11: 价格固定为1,数量最小为1 + bool isValid = quantity >= 1; if (isSell) { // 如果选择了其他支付方式,还需要填写收款账号和姓名 if (_selectedPaymentMethods.any((m) => m != 'GREEN_POINTS')) { @@ -740,11 +783,12 @@ class _C2cPublishPageState extends ConsumerState { ), const SizedBox(height: 8), const Text( - '1. 发布广告后,您的积分值将被冻结直到交易完成或取消\n' - '2. 其他用户接单后,需在规定时间内完成交易\n' - '3. 买方需在1.0系统中向卖方转账绿积分\n' - '4. 卖方确认收到绿积分后,积分值自动划转给买方\n' - '5. 如遇问题,请联系客服处理', + '1. 发布卖出广告后,您的积分股将被冻结直到交易完成或取消\n' + '2. 发布买入广告不会冻结您的资产\n' + '3. 其他用户接单后,需在规定时间内完成交易\n' + '4. 买方需在1.0系统中向卖方转账绿积分\n' + '5. 卖方确认收到绿积分后,积分股自动划转给买方\n' + '6. 如遇问题,请联系客服处理', style: TextStyle( fontSize: 12, color: _grayText, @@ -825,9 +869,11 @@ class _C2cPublishPageState extends ConsumerState { ], ], const SizedBox(height: 16), - const Text( - '发布后,您的积分值将被冻结', - style: TextStyle(fontSize: 12, color: _grayText), + Text( + _selectedType == 1 + ? '发布后,您的积分股将被冻结' + : '发布后不会冻结您的资产', + style: const TextStyle(fontSize: 12, color: _grayText), ), ], ),