feat(c2c): 订单详情增强、付款水单上传、买单逻辑修复及schema对齐

- 订单详情页显示完整手机号和收款账号信息
- 新增付款水单上传功能(前后端全链路)
- 修复时间显示为本地时区格式
- 移除买单发布时的积分值余额验证(买方通过外部绿积分支付)
- 前端买单发布页隐藏余额卡片和"全部"按钮
- 补齐 Prisma migration 与 schema 差异(payment_proof_url、
  bot_purchased索引、market_maker_deposits/withdraws表)
- docker-compose 新增 trading-uploads 卷用于水单持久化

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-31 08:54:05 -08:00
parent 4df23b02b8
commit ce173451f5
15 changed files with 503 additions and 39 deletions

View File

@ -121,6 +121,8 @@ services:
FUSDT_MARKET_MAKER_ADDRESS: ${FUSDT_MARKET_MAKER_ADDRESS:-}
EUSDT_MARKET_MAKER_USERNAME: ${EUSDT_MARKET_MAKER_USERNAME:-}
EUSDT_MARKET_MAKER_ADDRESS: ${EUSDT_MARKET_MAKER_ADDRESS:-}
volumes:
- trading-uploads:/app/uploads
ports:
- "3022:3022"
healthcheck:
@ -349,6 +351,8 @@ services:
volumes:
mining-admin-uploads:
driver: local
trading-uploads:
driver: local
networks:
rwa-network:

View File

@ -0,0 +1,100 @@
-- ============================================================================
-- 补齐 schema 与 migration 的差异
-- 新增: payment_proof_url 字段, bot_purchased 索引,
-- 做市商充提记录表 (market_maker_deposits, market_maker_withdraws)
-- ============================================================================
-- 1. c2c_orders: 补充 payment_proof_url 字段
ALTER TABLE "c2c_orders" ADD COLUMN "payment_proof_url" TEXT;
-- 2. c2c_orders: 补充 bot_purchased 索引
CREATE INDEX "c2c_orders_bot_purchased_idx" ON "c2c_orders"("bot_purchased");
-- 3. 创建 MarketMakerAssetType 枚举
CREATE TYPE "MarketMakerAssetType" AS ENUM ('EUSDT', 'FUSDT');
-- 4. 创建 MarketMakerDepositStatus 枚举
CREATE TYPE "MarketMakerDepositStatus" AS ENUM ('DETECTED', 'CONFIRMING', 'CONFIRMED', 'CREDITED', 'FAILED');
-- 5. 创建 MarketMakerWithdrawStatus 枚举
CREATE TYPE "MarketMakerWithdrawStatus" AS ENUM ('PENDING', 'SIGNING', 'SIGNED', 'BROADCAST', 'CONFIRMING', 'COMPLETED', 'FAILED', 'CANCELLED');
-- 6. 创建 market_maker_deposits 表
CREATE TABLE "market_maker_deposits" (
"id" TEXT NOT NULL,
"market_maker_id" TEXT NOT NULL,
"chain_type" VARCHAR(20) NOT NULL,
"tx_hash" VARCHAR(66) NOT NULL,
"block_number" BIGINT NOT NULL,
"block_timestamp" TIMESTAMP(3) NOT NULL,
"asset_type" "MarketMakerAssetType" NOT NULL,
"token_contract" VARCHAR(42) NOT NULL,
"from_address" VARCHAR(42) NOT NULL,
"to_address" VARCHAR(42) NOT NULL,
"amount" DECIMAL(36,8) NOT NULL,
"amount_raw" DECIMAL(78,0) NOT NULL,
"confirmations" INTEGER NOT NULL DEFAULT 0,
"required_confirms" INTEGER NOT NULL DEFAULT 12,
"status" "MarketMakerDepositStatus" NOT NULL DEFAULT 'DETECTED',
"credited_at" TIMESTAMP(3),
"credited_amount" DECIMAL(36,8),
"ledger_id" TEXT,
"error_message" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "market_maker_deposits_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "market_maker_deposits_tx_hash_key" ON "market_maker_deposits"("tx_hash");
CREATE INDEX "market_maker_deposits_market_maker_id_idx" ON "market_maker_deposits"("market_maker_id");
CREATE INDEX "market_maker_deposits_status_idx" ON "market_maker_deposits"("status");
CREATE INDEX "market_maker_deposits_chain_type_status_idx" ON "market_maker_deposits"("chain_type", "status");
CREATE INDEX "market_maker_deposits_asset_type_idx" ON "market_maker_deposits"("asset_type");
CREATE INDEX "market_maker_deposits_block_number_idx" ON "market_maker_deposits"("block_number");
CREATE INDEX "market_maker_deposits_created_at_idx" ON "market_maker_deposits"("created_at" DESC);
-- AddForeignKey
ALTER TABLE "market_maker_deposits" ADD CONSTRAINT "market_maker_deposits_market_maker_id_fkey" FOREIGN KEY ("market_maker_id") REFERENCES "market_maker_configs"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- 7. 创建 market_maker_withdraws 表
CREATE TABLE "market_maker_withdraws" (
"id" TEXT NOT NULL,
"withdraw_no" TEXT NOT NULL,
"market_maker_id" TEXT NOT NULL,
"asset_type" "MarketMakerAssetType" NOT NULL,
"amount" DECIMAL(36,8) NOT NULL,
"chain_type" VARCHAR(20) NOT NULL,
"token_contract" VARCHAR(42) NOT NULL,
"from_address" VARCHAR(42) NOT NULL,
"to_address" VARCHAR(42) NOT NULL,
"tx_hash" VARCHAR(66),
"block_number" BIGINT,
"confirmations" INTEGER NOT NULL DEFAULT 0,
"gas_used" DECIMAL(36,0),
"gas_price" DECIMAL(36,0),
"status" "MarketMakerWithdrawStatus" NOT NULL DEFAULT 'PENDING',
"ledger_id" TEXT,
"error_message" TEXT,
"retry_count" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"signed_at" TIMESTAMP(3),
"broadcast_at" TIMESTAMP(3),
"completed_at" TIMESTAMP(3),
"cancelled_at" TIMESTAMP(3),
CONSTRAINT "market_maker_withdraws_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "market_maker_withdraws_withdraw_no_key" ON "market_maker_withdraws"("withdraw_no");
CREATE INDEX "market_maker_withdraws_market_maker_id_idx" ON "market_maker_withdraws"("market_maker_id");
CREATE INDEX "market_maker_withdraws_status_idx" ON "market_maker_withdraws"("status");
CREATE INDEX "market_maker_withdraws_asset_type_idx" ON "market_maker_withdraws"("asset_type");
CREATE INDEX "market_maker_withdraws_tx_hash_idx" ON "market_maker_withdraws"("tx_hash");
CREATE INDEX "market_maker_withdraws_created_at_idx" ON "market_maker_withdraws"("created_at" DESC);
-- AddForeignKey
ALTER TABLE "market_maker_withdraws" ADD CONSTRAINT "market_maker_withdraws_market_maker_id_fkey" FOREIGN KEY ("market_maker_id") REFERENCES "market_maker_configs"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -679,6 +679,9 @@ model C2cOrder {
paymentDeadline DateTime? @map("payment_deadline") // 付款截止时间
confirmDeadline DateTime? @map("confirm_deadline") // 确认收款截止时间
// 付款水单(买方上传的付款凭证图片)
paymentProofUrl String? @map("payment_proof_url")
// ============ Bot 自动购买相关 ============
// 卖家 Kava 地址(从 SyncedUser 表获取,用于接收 dUSDT
sellerKavaAddress String? @map("seller_kava_address")

View File

@ -6,16 +6,25 @@ import {
Query,
Body,
Req,
Res,
HttpCode,
HttpStatus,
UseInterceptors,
UploadedFile,
BadRequestException,
NotFoundException,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiParam,
ApiBearerAuth,
ApiConsumes,
} from '@nestjs/swagger';
import * as path from 'path';
import * as fs from 'fs';
import { C2cService } from '../../application/services/c2c.service';
import {
CreateC2cOrderDto,
@ -57,6 +66,8 @@ export class C2cController {
paymentAccount: order.paymentAccount || undefined,
paymentQrCode: order.paymentQrCode || undefined,
paymentRealName: order.paymentRealName || undefined,
// 付款水单
paymentProofUrl: order.paymentProofUrl || undefined,
// 卖家 Kava 地址
sellerKavaAddress: order.sellerKavaAddress || undefined,
// 超时信息
@ -250,4 +261,53 @@ export class C2cController {
const order = await this.c2cService.confirmReceived(orderNo, accountSequence);
return this.toResponseDto(order);
}
@Post('orders/:orderNo/upload-proof')
@HttpCode(HttpStatus.OK)
@UseInterceptors(FileInterceptor('file', {
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
fileFilter: (_req: any, file: any, cb: any) => {
if (!file.mimetype.match(/^image\/(jpeg|png|gif|webp)$/)) {
cb(new BadRequestException('仅支持 JPEG/PNG/GIF/WebP 图片'), false);
} else {
cb(null, true);
}
},
}))
@ApiOperation({ summary: '上传付款水单(买方操作)' })
@ApiConsumes('multipart/form-data')
@ApiParam({ name: 'orderNo', description: '订单号' })
@ApiResponse({ status: 200, description: '上传成功' })
async uploadPaymentProof(
@Param('orderNo') orderNo: string,
@UploadedFile() file: Express.Multer.File,
@Req() req: any,
): Promise<C2cOrderResponseDto> {
const accountSequence = req.user?.accountSequence;
if (!accountSequence) {
throw new Error('Unauthorized');
}
if (!file) {
throw new BadRequestException('请上传付款水单图片');
}
const order = await this.c2cService.uploadPaymentProof(orderNo, accountSequence, file);
return this.toResponseDto(order);
}
@Get('proofs/:filename')
@ApiOperation({ summary: '获取付款水单图片' })
@ApiParam({ name: 'filename', description: '文件名' })
async getProofImage(
@Param('filename') filename: string,
@Res() res: any,
): Promise<void> {
// 防止路径遍历攻击
const safeName = path.basename(filename);
const filePath = path.resolve('./uploads/c2c-proofs', safeName);
if (!fs.existsSync(filePath)) {
throw new NotFoundException('文件不存在');
}
res.sendFile(filePath);
}
}

View File

@ -169,6 +169,8 @@ export class C2cOrderResponseDto {
paymentAccount?: string;
paymentQrCode?: string;
paymentRealName?: string;
// 付款水单
paymentProofUrl?: string;
// 卖家 Kava 地址(绿积分转账地址)
sellerKavaAddress?: string;
// 超时信息

View File

@ -1,4 +1,6 @@
import { Injectable, Logger, BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
import * as fs from 'fs';
import * as path from 'path';
import { C2cOrderRepository, C2cOrderEntity } from '../../infrastructure/persistence/repositories/c2c-order.repository';
import { TradingAccountRepository } from '../../infrastructure/persistence/repositories/trading-account.repository';
import { RedisService } from '../../infrastructure/redis/redis.service';
@ -138,12 +140,7 @@ 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()}`);
}
// #12: 不冻结买方通过外部1.0系统支付绿积分,系统无法冻结外部资产)
// 买入订单:买方通过外部系统支付绿积分,不需要验证积分值余额也不冻结
} else {
// 卖出订单需要冻结积分值C2C交易的是积分值
const quantityMoney = new Money(quantityDecimal);
@ -618,6 +615,52 @@ export class C2cService {
return order;
}
/**
*
*/
async uploadPaymentProof(
orderNo: string,
accountSequence: string,
file: { buffer: Buffer; originalname: string; mimetype: string },
): Promise<C2cOrderEntity> {
const order = await this.c2cOrderRepository.findByOrderNo(orderNo);
if (!order) {
throw new NotFoundException('订单不存在');
}
// 只有 MATCHED 或 PAID 状态可以上传水单
if (order.status !== C2C_ORDER_STATUS.MATCHED && order.status !== C2C_ORDER_STATUS.PAID) {
throw new BadRequestException('当前订单状态不允许上传水单');
}
// 确定买方身份
const buyerAccountSequence = order.type === C2C_ORDER_TYPE.BUY
? order.makerAccountSequence
: order.takerAccountSequence;
if (accountSequence !== buyerAccountSequence) {
throw new ForbiddenException('只有买方可以上传付款水单');
}
// 保存文件到本地
const uploadDir = path.resolve('./uploads/c2c-proofs');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
const ext = path.extname(file.originalname) || '.jpg';
const filename = `${orderNo}-${Date.now()}${ext}`;
const filePath = path.join(uploadDir, filename);
fs.writeFileSync(filePath, file.buffer);
// 生成访问URL通过 API 网关访问)
const proofUrl = `/api/v2/trading/c2c/proofs/${filename}`;
const updated = await this.c2cOrderRepository.updatePaymentProof(orderNo, proofUrl);
this.logger.log(`C2C订单上传付款水单: ${orderNo}, file: ${filename}`);
return updated!;
}
/**
*
*/

View File

@ -48,6 +48,8 @@ export interface C2cOrderEntity {
confirmTimeoutMinutes: number;
paymentDeadline?: Date | null;
confirmDeadline?: Date | null;
// 付款水单
paymentProofUrl?: string | null;
// Bot 自动购买相关
sellerKavaAddress?: string | null;
botPurchased: boolean;
@ -153,6 +155,8 @@ export class C2cOrderRepository {
// #13: 部分成交时更新数量和金额
quantity: string;
totalAmount: string;
// 付款水单
paymentProofUrl: string;
// 超时时间
paymentDeadline: Date;
confirmDeadline: Date;
@ -323,6 +327,17 @@ export class C2cOrderRepository {
return this.toEntity(record);
}
/**
* URL
*/
async updatePaymentProof(orderNo: string, paymentProofUrl: string): Promise<C2cOrderEntity | null> {
const record = await this.prisma.c2cOrder.update({
where: { orderNo },
data: { paymentProofUrl },
});
return this.toEntity(record);
}
/**
* Prisma记录转为实体
*/
@ -355,6 +370,8 @@ export class C2cOrderRepository {
confirmTimeoutMinutes: record.confirmTimeoutMinutes,
paymentDeadline: record.paymentDeadline,
confirmDeadline: record.confirmDeadline,
// 付款水单
paymentProofUrl: record.paymentProofUrl,
// Bot 自动购买相关
sellerKavaAddress: record.sellerKavaAddress,
botPurchased: record.botPurchased,

View File

@ -71,6 +71,7 @@ class ApiEndpoints {
static String c2cCancelOrder(String orderNo) => '/api/v2/trading/c2c/orders/$orderNo/cancel';
static String c2cConfirmPayment(String orderNo) => '/api/v2/trading/c2c/orders/$orderNo/confirm-payment';
static String c2cConfirmReceived(String orderNo) => '/api/v2/trading/c2c/orders/$orderNo/confirm-received';
static String c2cUploadProof(String orderNo) => '/api/v2/trading/c2c/orders/$orderNo/upload-proof';
// Contribution Service 2.0 (Kong路由: /api/v2/contribution -> /api/v1/contributions)
static const String contributionStats = '/api/v2/contribution/stats';

View File

@ -2,17 +2,17 @@ import 'package:intl/intl.dart';
String formatDateTime(DateTime? date) {
if (date == null) return '-';
return DateFormat('yyyy-MM-dd HH:mm:ss').format(date);
return DateFormat('yyyy-MM-dd HH:mm:ss').format(date.toLocal());
}
String formatDate(DateTime? date) {
if (date == null) return '-';
return DateFormat('yyyy-MM-dd').format(date);
return DateFormat('yyyy-MM-dd').format(date.toLocal());
}
String formatTime(DateTime? date) {
if (date == null) return '-';
return DateFormat('HH:mm:ss').format(date);
return DateFormat('HH:mm:ss').format(date.toLocal());
}
String formatRelative(DateTime? date) {

View File

@ -6,6 +6,7 @@ import '../../models/asset_display_model.dart';
import '../../models/kline_model.dart';
import '../../models/p2p_transfer_model.dart';
import '../../models/p2p_transfer_fee_config_model.dart';
import 'package:dio/dio.dart';
import '../../models/c2c_order_model.dart';
import '../../../core/network/api_client.dart';
import '../../../core/network/api_endpoints.dart';
@ -137,6 +138,9 @@ abstract class TradingRemoteDataSource {
///
Future<C2cOrderModel> confirmC2cReceived(String orderNo);
///
Future<C2cOrderModel> uploadC2cPaymentProof(String orderNo, String filePath);
}
class TradingRemoteDataSourceImpl implements TradingRemoteDataSource {
@ -581,4 +585,20 @@ class TradingRemoteDataSourceImpl implements TradingRemoteDataSource {
throw ServerException(e.toString());
}
}
@override
Future<C2cOrderModel> uploadC2cPaymentProof(String orderNo, String filePath) async {
try {
final formData = FormData.fromMap({
'file': await MultipartFile.fromFile(filePath),
});
final response = await client.post(
ApiEndpoints.c2cUploadProof(orderNo),
data: formData,
);
return C2cOrderModel.fromJson(response.data);
} catch (e) {
throw ServerException(e.toString());
}
}
}

View File

@ -47,6 +47,8 @@ class C2cOrderModel {
final int confirmTimeoutMinutes; //
final DateTime? paymentDeadline; //
final DateTime? confirmDeadline; //
//
final String? paymentProofUrl; // URL
// Bot
final bool botPurchased; // Bot
final String? sellerKavaAddress; // Kava
@ -84,6 +86,8 @@ class C2cOrderModel {
this.confirmTimeoutMinutes = 60,
this.paymentDeadline,
this.confirmDeadline,
//
this.paymentProofUrl,
// Bot
this.botPurchased = false,
this.sellerKavaAddress,
@ -127,6 +131,8 @@ class C2cOrderModel {
confirmDeadline: json['confirmDeadline'] != null
? DateTime.parse(json['confirmDeadline'])
: null,
//
paymentProofUrl: json['paymentProofUrl'],
// Bot
botPurchased: json['botPurchased'] ?? false,
sellerKavaAddress: json['sellerKavaAddress'],

View File

@ -602,7 +602,8 @@ class _C2cMarketPageState extends ConsumerState<C2cMarketPage>
}
String _formatDateTime(DateTime dateTime) {
return '${dateTime.month}/${dateTime.day} ${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}';
final local = dateTime.toLocal();
return '${local.month}/${local.day} ${local.hour.toString().padLeft(2, '0')}:${local.minute.toString().padLeft(2, '0')}';
}
// #13 + #15: + BUY单收款信息输入

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import '../../../core/utils/format_utils.dart';
import '../../../data/models/c2c_order_model.dart';
import '../../providers/c2c_providers.dart';
@ -133,6 +134,13 @@ class _C2cOrderDetailPageState extends ConsumerState<C2cOrderDetailPage> {
//
_buildParticipantsCard(order, isMaker, isTaker),
//
if ((order.isMatched || order.isPaid || order.isCompleted) && !order.isBotPurchased)
...[
const SizedBox(height: 16),
_buildPaymentProofCard(order, isBuyer),
],
const SizedBox(height: 16),
//
@ -756,7 +764,8 @@ class _C2cOrderDetailPageState extends ConsumerState<C2cOrderDetailPage> {
label: order.isBuy ? '买方 (发布者)' : '卖方 (发布者)',
name: order.makerNickname?.isNotEmpty == true
? order.makerNickname!
: (order.makerPhone?.isNotEmpty == true ? _maskPhone(order.makerPhone!) : '未知用户'),
: (order.makerPhone?.isNotEmpty == true ? order.makerPhone! : '未知用户'),
phone: order.makerPhone,
isMe: isMaker,
),
const Divider(height: 24),
@ -766,7 +775,8 @@ class _C2cOrderDetailPageState extends ConsumerState<C2cOrderDetailPage> {
label: order.isBuy ? '卖方 (接单者)' : '买方 (接单者)',
name: order.takerNickname?.isNotEmpty == true
? order.takerNickname!
: (order.takerPhone?.isNotEmpty == true ? _maskPhone(order.takerPhone!) : '未知用户'),
: (order.takerPhone?.isNotEmpty == true ? order.takerPhone! : '未知用户'),
phone: order.takerPhone,
isMe: isTaker,
)
else
@ -774,6 +784,13 @@ class _C2cOrderDetailPageState extends ConsumerState<C2cOrderDetailPage> {
'等待接单...',
style: TextStyle(fontSize: 14, color: _grayText),
),
//
if (order.paymentAccount != null && order.takerAccountSequence != null) ...[
const Divider(height: 24),
_buildInfoRow('收款账号', order.paymentAccount!, canCopy: true),
if (order.paymentRealName != null)
_buildInfoRow('收款人', order.paymentRealName!),
],
],
),
);
@ -783,6 +800,7 @@ class _C2cOrderDetailPageState extends ConsumerState<C2cOrderDetailPage> {
required String label,
required String name,
required bool isMe,
String? phone,
}) {
return Row(
children: [
@ -844,6 +862,13 @@ class _C2cOrderDetailPageState extends ConsumerState<C2cOrderDetailPage> {
],
],
),
if (phone != null && phone.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
phone,
style: const TextStyle(fontSize: 12, color: _grayText),
),
],
],
),
),
@ -851,6 +876,169 @@ class _C2cOrderDetailPageState extends ConsumerState<C2cOrderDetailPage> {
);
}
Widget _buildPaymentProofCard(C2cOrderModel order, bool isBuyer) {
final hasProof = order.paymentProofUrl != null && order.paymentProofUrl!.isNotEmpty;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.receipt_long, size: 20, color: _orange),
const SizedBox(width: 8),
const Text(
'付款水单',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: _darkText,
),
),
const Spacer(),
if (isBuyer && (order.isMatched || order.isPaid))
GestureDetector(
onTap: () => _handleUploadProof(order),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: _orange.withOpacity(0.1),
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: const [
Icon(Icons.upload, size: 16, color: _orange),
SizedBox(width: 4),
Text(
'上传水单',
style: TextStyle(fontSize: 12, color: _orange, fontWeight: FontWeight.w500),
),
],
),
),
),
],
),
const SizedBox(height: 16),
if (hasProof)
GestureDetector(
onTap: () => _showProofFullScreen(order.paymentProofUrl!),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
order.paymentProofUrl!,
height: 200,
width: double.infinity,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
height: 100,
color: _bgGray,
child: const Center(
child: Text('图片加载失败', style: TextStyle(color: _grayText)),
),
),
),
),
)
else
Container(
height: 80,
width: double.infinity,
decoration: BoxDecoration(
color: _bgGray,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300, style: BorderStyle.solid),
),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.image_not_supported_outlined, size: 28, color: Colors.grey.shade400),
const SizedBox(height: 4),
Text(
isBuyer ? '请上传付款凭证' : '买方暂未上传水单',
style: TextStyle(fontSize: 12, color: Colors.grey.shade400),
),
],
),
),
),
],
),
);
}
Future<void> _handleUploadProof(C2cOrderModel order) async {
final picker = ImagePicker();
final image = await picker.pickImage(
source: ImageSource.gallery,
maxWidth: 1920,
maxHeight: 1920,
imageQuality: 85,
);
if (image == null) return;
final notifier = ref.read(c2cTradingNotifierProvider.notifier);
final success = await notifier.uploadPaymentProof(order.orderNo, image.path);
if (success && mounted) {
ref.invalidate(c2cOrderDetailProvider(order.orderNo));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('付款水单上传成功'),
backgroundColor: _green,
),
);
} else if (mounted) {
final error = ref.read(c2cTradingNotifierProvider).error;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('上传失败: ${error ?? '未知错误'}'),
backgroundColor: _red,
),
);
}
}
void _showProofFullScreen(String url) {
showDialog(
context: context,
builder: (context) => Dialog(
backgroundColor: Colors.black,
insetPadding: const EdgeInsets.all(0),
child: Stack(
fit: StackFit.expand,
children: [
InteractiveViewer(
child: Image.network(
url,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => const Center(
child: Text('图片加载失败', style: TextStyle(color: Colors.white)),
),
),
),
Positioned(
top: 40,
right: 16,
child: IconButton(
icon: const Icon(Icons.close, color: Colors.white, size: 28),
onPressed: () => Navigator.pop(context),
),
),
],
),
),
);
}
Widget _buildInstructionsCard(
C2cOrderModel order,
bool isMaker,
@ -1108,8 +1296,9 @@ class _C2cOrderDetailPageState extends ConsumerState<C2cOrderDetailPage> {
}
String _formatDateTime(DateTime dateTime) {
return '${dateTime.year}-${dateTime.month.toString().padLeft(2, '0')}-${dateTime.day.toString().padLeft(2, '0')} '
'${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}';
final local = dateTime.toLocal();
return '${local.year}-${local.month.toString().padLeft(2, '0')}-${local.day.toString().padLeft(2, '0')} '
'${local.hour.toString().padLeft(2, '0')}:${local.minute.toString().padLeft(2, '0')}';
}
Future<void> _handleCancel(C2cOrderModel order) async {

View File

@ -192,9 +192,11 @@ class _C2cPublishPageState extends ConsumerState<C2cPublishPage> {
}
Widget _buildBalanceCard(String availableShares, String availableCash) {
// C2C交易对象是积分值BUY和SELL都显示可用积分值
const label = '可用积分值';
final balance = availableCash;
final isSell = _selectedType == 1;
//
// 绿
if (!isSell) return const SizedBox.shrink();
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
@ -206,12 +208,12 @@ class _C2cPublishPageState extends ConsumerState<C2cPublishPage> {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: const TextStyle(fontSize: 14, color: _grayText),
const Text(
'可用积分值',
style: TextStyle(fontSize: 14, color: _grayText),
),
Text(
formatAmount(balance),
formatAmount(availableCash),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
@ -275,8 +277,9 @@ class _C2cPublishPageState extends ConsumerState<C2cPublishPage> {
String availableCash,
String currentPrice,
) {
// C2C交易的是积分值BUY和SELL上限都是可用积分值
final maxBalance = availableCash;
final isSell = _selectedType == 1;
// 绿
final maxBalance = isSell ? availableCash : null;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
@ -323,22 +326,24 @@ class _C2cPublishPageState extends ConsumerState<C2cPublishPage> {
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: _orange, width: 2),
),
suffixIcon: TextButton(
onPressed: () {
_isSyncingInput = true;
_quantityController.text = maxBalance;
_amountController.text = maxBalance;
_isSyncingInput = false;
setState(() {});
},
child: const Text(
'全部',
style: TextStyle(
color: _orange,
fontWeight: FontWeight.w500,
),
),
),
suffixIcon: maxBalance != null
? TextButton(
onPressed: () {
_isSyncingInput = true;
_quantityController.text = maxBalance;
_amountController.text = maxBalance;
_isSyncingInput = false;
setState(() {});
},
child: const Text(
'全部',
style: TextStyle(
color: _orange,
fontWeight: FontWeight.w500,
),
),
)
: null,
suffixText: '积分值',
suffixStyle: const TextStyle(color: _grayText, fontSize: 14),
),

View File

@ -178,6 +178,19 @@ class C2cTradingNotifier extends StateNotifier<C2cTradingState> {
}
}
///
Future<bool> uploadPaymentProof(String orderNo, String filePath) async {
state = state.copyWith(isLoading: true, clearError: true);
try {
final order = await _dataSource.uploadC2cPaymentProof(orderNo, filePath);
state = state.copyWith(isLoading: false, lastOrder: order);
return true;
} catch (e) {
state = state.copyWith(isLoading: false, error: e.toString());
return false;
}
}
void clearError() {
state = state.copyWith(clearError: true);
}