feat(c2c): 修复6项C2C交易问题 (#11-#16)

- #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 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-30 11:36:57 -08:00
parent c28ccb6206
commit 187b82e9ac
4 changed files with 657 additions and 194 deletions

View File

@ -109,6 +109,13 @@ export class C2cService {
if (quantityDecimal.lte(0)) {
throw new BadRequestException('数量必须大于0');
}
// #11: 价格必须为11: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);
}

View File

@ -150,6 +150,9 @@ export class C2cOrderRepository {
paymentAccount: string;
paymentQrCode: string;
paymentRealName: string;
// #13: 部分成交时更新数量和金额
quantity: string;
totalAmount: string;
// 超时时间
paymentDeadline: Date;
confirmDeadline: Date;

View File

@ -585,65 +585,364 @@ class _C2cMarketPageState extends ConsumerState<C2cMarketPage>
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<String> 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<void> _takeOrder(C2cOrderModel order) async {
Future<void> _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) {
//

View File

@ -24,9 +24,11 @@ class _C2cPublishPageState extends ConsumerState<C2cPublishPage> {
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<String> _selectedPaymentMethods = {'GREEN_POINTS'}; // 绿
@ -37,6 +39,7 @@ class _C2cPublishPageState extends ConsumerState<C2cPublishPage> {
void dispose() {
_priceController.dispose();
_quantityController.dispose();
_amountController.dispose();
_remarkController.dispose();
_paymentAccountController.dispose();
_paymentRealNameController.dispose();
@ -56,11 +59,6 @@ class _C2cPublishPageState extends ConsumerState<C2cPublishPage> {
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<C2cPublishPage> {
}
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<C2cPublishPage> {
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<C2cPublishPage> {
);
}
// #11: 1:1
Widget _buildPriceInput(String currentPrice) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
@ -234,59 +237,51 @@ class _C2cPublishPageState extends ConsumerState<C2cPublishPage> {
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<C2cPublishPage> {
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<C2cPublishPage> {
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<C2cPublishPage> {
),
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<C2cPublishPage> {
),
),
),
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<C2cPublishPage> {
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<C2cPublishPage> {
}
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: 11
bool isValid = quantity >= 1;
if (isSell) {
//
if (_selectedPaymentMethods.any((m) => m != 'GREEN_POINTS')) {
@ -740,11 +783,12 @@ class _C2cPublishPageState extends ConsumerState<C2cPublishPage> {
),
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<C2cPublishPage> {
],
],
const SizedBox(height: 16),
const Text(
'发布后,您的积分值将被冻结',
style: TextStyle(fontSize: 12, color: _grayText),
Text(
_selectedType == 1
? '发布后,您的积分股将被冻结'
: '发布后不会冻结您的资产',
style: const TextStyle(fontSize: 12, color: _grayText),
),
],
),