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)) { if (quantityDecimal.lte(0)) {
throw new BadRequestException('数量必须大于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 地址(用于绿积分转账) // 卖单:自动获取卖家 Kava 地址(用于绿积分转账)
let sellerKavaAddress: string | null = null; let sellerKavaAddress: string | null = null;
@ -130,13 +137,12 @@ export class C2cService {
// 检查余额并冻结资产 // 检查余额并冻结资产
if (type === C2C_ORDER_TYPE.BUY) { if (type === C2C_ORDER_TYPE.BUY) {
// 买入订单:需要冻结积分值(现金 // #16: 买入订单:验证积分值余额(买方通过外部系统支付绿积分
const totalAmountMoney = new Money(totalAmount); const totalAmountMoney = new Money(totalAmount);
if (account.availableCash.isLessThan(totalAmountMoney)) { if (account.availableCash.isLessThan(totalAmountMoney)) {
throw new BadRequestException(`积分值余额不足,需要 ${totalAmount.toString()},可用 ${account.availableCash.toString()}`); throw new BadRequestException(`积分值余额不足,需要 ${totalAmount.toString()},可用 ${account.availableCash.toString()}`);
} }
// 冻结积分值 // #12: 不冻结买方通过外部1.0系统支付绿积分,系统无法冻结外部资产)
await this.tradingAccountRepository.freezeCash(accountSequence, totalAmount);
} else { } else {
// 卖出订单:需要冻结积分股 // 卖出订单:需要冻结积分股
const quantityMoney = new Money(quantityDecimal); const quantityMoney = new Money(quantityDecimal);
@ -234,15 +240,60 @@ export class C2cService {
throw new NotFoundException('交易账户不存在'); throw new NotFoundException('交易账户不存在');
} }
const quantityDecimal = new Decimal(order.quantity); // #13: 支持部分成交taker可指定数量
const totalAmountDecimal = new Decimal(order.totalAmount); 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) { if (order.type === C2C_ORDER_TYPE.BUY) {
// 挂单方要买入积分股,接单方需要有积分股来卖出 // 挂单方要买入积分股,接单方需要有积分股来卖出
const quantityMoney = new Money(quantityDecimal); const quantityMoney = new Money(quantityDecimal);
if (takerAccount.availableShares.isLessThan(quantityMoney)) { 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); await this.tradingAccountRepository.freezeShares(takerAccountSequence, quantityDecimal);
@ -250,7 +301,7 @@ export class C2cService {
// 挂单方要卖出积分股,接单方需要有积分值来买入 // 挂单方要卖出积分股,接单方需要有积分值来买入
const totalAmountMoney = new Money(totalAmountDecimal); const totalAmountMoney = new Money(totalAmountDecimal);
if (takerAccount.availableCash.isLessThan(totalAmountMoney)) { 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); await this.tradingAccountRepository.freezeCash(takerAccountSequence, totalAmountDecimal);
@ -266,6 +317,8 @@ export class C2cService {
takerUserId: options?.userId, takerUserId: options?.userId,
takerPhone: options?.phone, takerPhone: options?.phone,
takerNickname: options?.nickname, takerNickname: options?.nickname,
quantity: quantityDecimal.toString(), // #13: 更新为实际成交量
totalAmount: totalAmountDecimal.toString(), // #13: 更新为实际成交额
matchedAt: now, matchedAt: now,
paymentDeadline, paymentDeadline,
}; };
@ -280,7 +333,7 @@ export class C2cService {
const updatedOrder = await this.c2cOrderRepository.updateStatus(orderNo, C2C_ORDER_STATUS.MATCHED as any, updateData); 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!; return updatedOrder!;
} finally { } finally {
await this.redis.releaseLock(lockKey, lockValue); await this.redis.releaseLock(lockKey, lockValue);
@ -314,7 +367,7 @@ export class C2cService {
// 解冻挂单方的资产 // 解冻挂单方的资产
if (order.type === C2C_ORDER_TYPE.BUY) { if (order.type === C2C_ORDER_TYPE.BUY) {
await this.tradingAccountRepository.unfreezeCash(accountSequence, totalAmountDecimal); // BUY 订单未冻结买方资产(买方通过外部支付绿积分),无需解冻
} else { } else {
await this.tradingAccountRepository.unfreezeShares(accountSequence, quantityDecimal); await this.tradingAccountRepository.unfreezeShares(accountSequence, quantityDecimal);
} }
@ -443,81 +496,144 @@ export class C2cService {
// 使用事务执行转账 // 使用事务执行转账
await this.prisma.$transaction(async (tx) => { await this.prisma.$transaction(async (tx) => {
// 1. 解冻买方的积分值并扣除 if (order.type === C2C_ORDER_TYPE.BUY) {
await tx.tradingAccount.update({ // BUY订单买方通过外部支付绿积分系统只转移卖方积分股给买方单向
where: { accountSequence: buyerAccountSequence }, // 1. 解冻卖方积分股并扣除
data: { await tx.tradingAccount.update({
frozenCash: { decrement: totalAmountDecimal.toNumber() }, where: { accountSequence: sellerAccountSequence },
cashBalance: { decrement: totalAmountDecimal.toNumber() }, data: {
}, frozenShares: { decrement: quantityDecimal.toNumber() },
}); shareBalance: { decrement: quantityDecimal.toNumber() },
totalSold: { increment: quantityDecimal.toNumber() },
},
});
// 2. 解冻卖方的积分股并扣除 // 2. 买方获得积分股
await tx.tradingAccount.update({ await tx.tradingAccount.update({
where: { accountSequence: sellerAccountSequence }, where: { accountSequence: buyerAccountSequence },
data: { data: {
frozenShares: { decrement: quantityDecimal.toNumber() }, shareBalance: { increment: quantityDecimal.toNumber() },
shareBalance: { decrement: quantityDecimal.toNumber() }, totalBought: { increment: quantityDecimal.toNumber() },
totalSold: { increment: quantityDecimal.toNumber() }, },
}, });
});
// 3. 买方获得积分股 // 3. 记录交易流水(买方 — 积分股入账)
await tx.tradingAccount.update({ const buyerAccount = await tx.tradingAccount.findUnique({
where: { accountSequence: buyerAccountSequence }, where: { accountSequence: buyerAccountSequence },
data: { });
shareBalance: { increment: quantityDecimal.toNumber() }, await tx.tradingTransaction.create({
totalBought: { increment: quantityDecimal.toNumber() }, 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. 卖方获得积分值 // 4. 记录交易流水(卖方 — 积分股扣除)
await tx.tradingAccount.update({ const sellerAccount = await tx.tradingAccount.findUnique({
where: { accountSequence: sellerAccountSequence }, where: { accountSequence: sellerAccountSequence },
data: { });
cashBalance: { increment: totalAmountDecimal.toNumber() }, 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. 记录交易流水(买方) // 2. 解冻卖方的积分股并扣除
const buyerAccount = await tx.tradingAccount.findUnique({ await tx.tradingAccount.update({
where: { accountSequence: buyerAccountSequence }, where: { accountSequence: sellerAccountSequence },
}); data: {
await tx.tradingTransaction.create({ frozenShares: { decrement: quantityDecimal.toNumber() },
data: { shareBalance: { decrement: quantityDecimal.toNumber() },
accountSequence: buyerAccountSequence, totalSold: { increment: quantityDecimal.toNumber() },
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. 记录交易流水(卖方) // 3. 买方获得积分股
const sellerAccount = await tx.tradingAccount.findUnique({ await tx.tradingAccount.update({
where: { accountSequence: sellerAccountSequence }, where: { accountSequence: buyerAccountSequence },
}); data: {
await tx.tradingTransaction.create({ shareBalance: { increment: quantityDecimal.toNumber() },
data: { totalBought: { increment: quantityDecimal.toNumber() },
accountSequence: sellerAccountSequence, },
type: 'C2C_SELL', });
assetType: 'CASH',
amount: totalAmountDecimal.toNumber(), // 4. 卖方获得积分值
balanceBefore: new Decimal(sellerAccount!.cashBalance).minus(totalAmountDecimal).toNumber(), await tx.tradingAccount.update({
balanceAfter: sellerAccount!.cashBalance, where: { accountSequence: sellerAccountSequence },
referenceId: order.orderNo, data: {
referenceType: 'C2C_ORDER', cashBalance: { increment: totalAmountDecimal.toNumber() },
counterpartyType: 'USER', },
counterpartyAccountSeq: buyerAccountSequence, });
memo: `C2C卖出 ${order.quantity} 积分股,单价 ${order.price}`,
}, // 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}`); this.logger.log(`C2C交易转账完成: ${order.orderNo}, 买方: ${buyerAccountSequence}, 卖方: ${sellerAccountSequence}`);
@ -626,8 +742,7 @@ export class C2cService {
// 解冻双方资产 // 解冻双方资产
if (freshOrder.type === C2C_ORDER_TYPE.BUY) { if (freshOrder.type === C2C_ORDER_TYPE.BUY) {
// BUY订单maker冻结了积分值taker冻结了积分股 // BUY订单maker未冻结买方通过外部支付绿积分只解冻taker的积分股
await this.tradingAccountRepository.unfreezeCash(freshOrder.makerAccountSequence, totalAmountDecimal);
if (freshOrder.takerAccountSequence) { if (freshOrder.takerAccountSequence) {
await this.tradingAccountRepository.unfreezeShares(freshOrder.takerAccountSequence, quantityDecimal); await this.tradingAccountRepository.unfreezeShares(freshOrder.takerAccountSequence, quantityDecimal);
} }

View File

@ -150,6 +150,9 @@ export class C2cOrderRepository {
paymentAccount: string; paymentAccount: string;
paymentQrCode: string; paymentQrCode: string;
paymentRealName: string; paymentRealName: string;
// #13: 部分成交时更新数量和金额
quantity: string;
totalAmount: string;
// 超时时间 // 超时时间
paymentDeadline: Date; paymentDeadline: Date;
confirmDeadline: 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')}'; 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) { 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, context: context,
builder: (dialogContext) => AlertDialog( isScrollControlled: true,
backgroundColor: AppColors.cardOf(dialogContext), backgroundColor: AppColors.cardOf(context),
title: Text( shape: const RoundedRectangleBorder(
isBuyAction ? '确认购买' : '确认出售', borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
style: TextStyle(color: AppColors.textPrimaryOf(dialogContext)), ),
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, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Icon(
'数量: ${formatAmount(order.quantity)} 积分股', isSelected ? Icons.check_circle : Icons.circle_outlined,
style: TextStyle(color: AppColors.textPrimaryOf(dialogContext)), 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( Text(
'单价: ${formatPrice(order.price)} 积分值', label,
style: TextStyle(color: AppColors.textPrimaryOf(dialogContext)), style: TextStyle(
), fontSize: 12,
const SizedBox(height: 8), color: isSelected ? Colors.white : _darkText,
Text( fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
'总金额: ${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)),
), ),
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 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) { if (success && mounted) {
// //

View File

@ -24,9 +24,11 @@ class _C2cPublishPageState extends ConsumerState<C2cPublishPage> {
static const Color _bgGray = Color(0xFFF3F4F6); static const Color _bgGray = Color(0xFFF3F4F6);
int _selectedType = 1; // 0: , 1: int _selectedType = 1; // 0: , 1:
final _priceController = TextEditingController(); final _priceController = TextEditingController(text: '1'); // #11: 1:1
final _quantityController = TextEditingController(); final _quantityController = TextEditingController();
final _amountController = TextEditingController(); // #14:
final _remarkController = TextEditingController(); final _remarkController = TextEditingController();
bool _isSyncingInput = false; //
// //
Set<String> _selectedPaymentMethods = {'GREEN_POINTS'}; // 绿 Set<String> _selectedPaymentMethods = {'GREEN_POINTS'}; // 绿
@ -37,6 +39,7 @@ class _C2cPublishPageState extends ConsumerState<C2cPublishPage> {
void dispose() { void dispose() {
_priceController.dispose(); _priceController.dispose();
_quantityController.dispose(); _quantityController.dispose();
_amountController.dispose();
_remarkController.dispose(); _remarkController.dispose();
_paymentAccountController.dispose(); _paymentAccountController.dispose();
_paymentRealNameController.dispose(); _paymentRealNameController.dispose();
@ -56,11 +59,6 @@ class _C2cPublishPageState extends ConsumerState<C2cPublishPage> {
final availableShares = asset?.availableShares ?? '0'; final availableShares = asset?.availableShares ?? '0';
final availableCash = asset?.availableCash ?? '0'; final availableCash = asset?.availableCash ?? '0';
//
if (_priceController.text.isEmpty && currentPrice != '0') {
_priceController.text = currentPrice;
}
return Scaffold( return Scaffold(
backgroundColor: _bgGray, backgroundColor: _bgGray,
appBar: AppBar( appBar: AppBar(
@ -194,8 +192,12 @@ class _C2cPublishPageState extends ConsumerState<C2cPublishPage> {
} }
Widget _buildBalanceCard(String availableShares, String availableCash) { Widget _buildBalanceCard(String availableShares, String availableCash) {
// C2C交易的是积分值广绿 final isBuy = _selectedType == 0;
// 广 // BUY: #16
// SELL:
final label = isBuy ? '可用积分值' : '可用积分股';
final balance = isBuy ? availableCash : availableShares;
return Container( return Container(
margin: const EdgeInsets.symmetric(horizontal: 16), margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@ -206,12 +208,12 @@ class _C2cPublishPageState extends ConsumerState<C2cPublishPage> {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
const Text( Text(
'可用积分值', label,
style: TextStyle(fontSize: 14, color: _grayText), style: const TextStyle(fontSize: 14, color: _grayText),
), ),
Text( Text(
formatAmount(availableCash), formatAmount(balance),
style: const TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@ -223,6 +225,7 @@ class _C2cPublishPageState extends ConsumerState<C2cPublishPage> {
); );
} }
// #11: 1:1
Widget _buildPriceInput(String currentPrice) { Widget _buildPriceInput(String currentPrice) {
return Container( return Container(
margin: const EdgeInsets.symmetric(horizontal: 16), margin: const EdgeInsets.symmetric(horizontal: 16),
@ -234,59 +237,51 @@ class _C2cPublishPageState extends ConsumerState<C2cPublishPage> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( const Text(
mainAxisAlignment: MainAxisAlignment.spaceBetween, '单价',
children: [ style: TextStyle(
const Text( fontSize: 16,
'单价', fontWeight: FontWeight.bold,
style: TextStyle( color: _darkText,
fontSize: 16, ),
fontWeight: FontWeight.bold,
color: _darkText,
),
),
Text(
'当前价: ${formatPrice(currentPrice)}',
style: const TextStyle(fontSize: 12, color: _grayText),
),
],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
TextField( Container(
controller: _priceController, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
keyboardType: const TextInputType.numberWithOptions(decimal: true), decoration: BoxDecoration(
inputFormatters: [ color: _bgGray,
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,8}')), borderRadius: BorderRadius.circular(12),
], ),
decoration: InputDecoration( child: Row(
hintText: '请输入单价', mainAxisAlignment: MainAxisAlignment.spaceBetween,
hintStyle: const TextStyle(color: _grayText), children: const [
filled: true, Text(
fillColor: _bgGray, '1',
border: OutlineInputBorder( style: TextStyle(fontSize: 16, color: _darkText),
borderRadius: BorderRadius.circular(12), ),
borderSide: BorderSide.none, Text(
), '元/积分值 (固定)',
focusedBorder: OutlineInputBorder( style: TextStyle(color: _grayText, fontSize: 14),
borderRadius: BorderRadius.circular(12), ),
borderSide: const BorderSide(color: _orange, width: 2), ],
),
suffixText: '元/积分值',
suffixStyle: const TextStyle(color: _grayText, fontSize: 14),
), ),
onChanged: (_) => setState(() {}),
), ),
], ],
), ),
); );
} }
// #14: 1:1
Widget _buildQuantityInput( Widget _buildQuantityInput(
String availableShares, String availableShares,
String availableCash, String availableCash,
String currentPrice, String currentPrice,
) { ) {
// C2C交易的是积分值 final isBuy = _selectedType == 0;
// BUY:
// SELL:
final maxBalance = isBuy ? availableCash : availableShares;
return Container( return Container(
margin: const EdgeInsets.symmetric(horizontal: 16), margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
@ -298,14 +293,21 @@ class _C2cPublishPageState extends ConsumerState<C2cPublishPage> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text( const Text(
'数量', '交易数量',
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: _darkText, color: _darkText,
), ),
), ),
const SizedBox(height: 4),
Text(
'价格1:1积分股数量 = 积分值金额',
style: const TextStyle(fontSize: 12, color: _grayText),
),
const SizedBox(height: 12), const SizedBox(height: 12),
// 1:
TextField( TextField(
controller: _quantityController, controller: _quantityController,
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(decimal: true),
@ -313,7 +315,7 @@ class _C2cPublishPageState extends ConsumerState<C2cPublishPage> {
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,4}')), FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,4}')),
], ],
decoration: InputDecoration( decoration: InputDecoration(
hintText: '请输入积分数量', hintText: '请输入积分数量',
hintStyle: const TextStyle(color: _grayText), hintStyle: const TextStyle(color: _grayText),
filled: true, filled: true,
fillColor: _bgGray, fillColor: _bgGray,
@ -327,8 +329,10 @@ class _C2cPublishPageState extends ConsumerState<C2cPublishPage> {
), ),
suffixIcon: TextButton( suffixIcon: TextButton(
onPressed: () { onPressed: () {
// _isSyncingInput = true;
_quantityController.text = availableCash; _quantityController.text = maxBalance;
_amountController.text = maxBalance;
_isSyncingInput = false;
setState(() {}); setState(() {});
}, },
child: const Text( 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: '积分值', suffixText: '积分值',
suffixStyle: const TextStyle(color: _grayText, fontSize: 14), 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( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
const Text('积分数量', style: TextStyle(fontSize: 14, color: _grayText)), const Text('积分数量', style: TextStyle(fontSize: 14, color: _grayText)),
Text( Text(
'${formatAmount(quantity.toString())} 积分', '${formatAmount(quantity.toString())} 积分',
style: const TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@ -661,12 +705,11 @@ class _C2cPublishPageState extends ConsumerState<C2cPublishPage> {
} }
Widget _buildPublishButton(C2cTradingState c2cState) { Widget _buildPublishButton(C2cTradingState c2cState) {
final price = double.tryParse(_priceController.text) ?? 0;
final quantity = double.tryParse(_quantityController.text) ?? 0; final quantity = double.tryParse(_quantityController.text) ?? 0;
final isSell = _selectedType == 1; final isSell = _selectedType == 1;
// // #11: 11
bool isValid = price > 0 && quantity > 0; bool isValid = quantity >= 1;
if (isSell) { if (isSell) {
// //
if (_selectedPaymentMethods.any((m) => m != 'GREEN_POINTS')) { if (_selectedPaymentMethods.any((m) => m != 'GREEN_POINTS')) {
@ -740,11 +783,12 @@ class _C2cPublishPageState extends ConsumerState<C2cPublishPage> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
const Text( const Text(
'1. 发布广告后,您的积分值将被冻结直到交易完成或取消\n' '1. 发布卖出广告后,您的积分股将被冻结直到交易完成或取消\n'
'2. 其他用户接单后,需在规定时间内完成交易\n' '2. 发布买入广告不会冻结您的资产\n'
'3. 买方需在1.0系统中向卖方转账绿积分\n' '3. 其他用户接单后,需在规定时间内完成交易\n'
'4. 卖方确认收到绿积分后,积分值自动划转给买方\n' '4. 买方需在1.0系统中向卖方转账绿积分\n'
'5. 如遇问题,请联系客服处理', '5. 卖方确认收到绿积分后,积分股自动划转给买方\n'
'6. 如遇问题,请联系客服处理',
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: _grayText, color: _grayText,
@ -825,9 +869,11 @@ class _C2cPublishPageState extends ConsumerState<C2cPublishPage> {
], ],
], ],
const SizedBox(height: 16), const SizedBox(height: 16),
const Text( Text(
'发布后,您的积分值将被冻结', _selectedType == 1
style: TextStyle(fontSize: 12, color: _grayText), ? '发布后,您的积分股将被冻结'
: '发布后不会冻结您的资产',
style: const TextStyle(fontSize: 12, color: _grayText),
), ),
], ],
), ),