diff --git a/flutter_monisuo/build/99111e0c5b6228829e100ef67db14ea2.cache.dill.track.dill b/flutter_monisuo/build/99111e0c5b6228829e100ef67db14ea2.cache.dill.track.dill index bbd52a9..f59f155 100644 Binary files a/flutter_monisuo/build/99111e0c5b6228829e100ef67db14ea2.cache.dill.track.dill and b/flutter_monisuo/build/99111e0c5b6228829e100ef67db14ea2.cache.dill.track.dill differ diff --git a/flutter_monisuo/lib/ui/pages/trade/trade_page.dart b/flutter_monisuo/lib/ui/pages/trade/trade_page.dart index dcc28fb..274b5ff 100644 --- a/flutter_monisuo/lib/ui/pages/trade/trade_page.dart +++ b/flutter_monisuo/lib/ui/pages/trade/trade_page.dart @@ -7,11 +7,11 @@ import '../../../core/theme/app_spacing.dart'; import '../../../data/models/coin.dart'; import '../../../providers/market_provider.dart'; import '../../../providers/asset_provider.dart'; -import '../../shared/ui_constants.dart'; +import '../../../data/services/trade_service.dart'; import '../../components/glass_panel.dart'; import '../../components/neon_glow.dart'; -/// 交易页面 - Material Design 3 风格 +/// 交易页面 class TradePage extends StatefulWidget { final String? initialCoinCode; @@ -21,12 +21,12 @@ class TradePage extends StatefulWidget { State createState() => _TradePageState(); } -class _TradePageState extends State with AutomaticKeepAliveClientMixin { +class _TradePageState extends State + with AutomaticKeepAliveClientMixin { int _tradeType = 0; // 0=买入, 1=卖出 Coin? _selectedCoin; - final _formKey = GlobalKey(); - final _priceController = TextEditingController(); - final _quantityController = TextEditingController(); + final _amountController = TextEditingController(); + bool _isSubmitting = false; @override bool get wantKeepAlive => true; @@ -40,30 +40,67 @@ class _TradePageState extends State with AutomaticKeepAliveClientMixi void _loadData() { final marketProvider = context.read(); marketProvider.loadCoins().then((_) { - // 如果有初始币种代码,自动选中 if (widget.initialCoinCode != null && _selectedCoin == null) { final coins = marketProvider.allCoins; final coin = coins.firstWhere( - (c) => c.code.toUpperCase() == widget.initialCoinCode!.toUpperCase(), - orElse: () => coins.isNotEmpty ? coins.first : throw Exception('No coins available'), + (c) => + c.code.toUpperCase() == widget.initialCoinCode!.toUpperCase(), + orElse: () => + coins.isNotEmpty ? coins.first : throw Exception('No coins'), ); - if (mounted) { - setState(() { - _selectedCoin = coin; - _priceController.text = coin.formattedPrice; - }); - } + if (mounted) setState(() => _selectedCoin = coin); } }); + context.read().refreshAll(force: true); } @override void dispose() { - _priceController.dispose(); - _quantityController.dispose(); + _amountController.dispose(); super.dispose(); } + /// 获取交易账户中 USDT 可用余额 + String get _availableUsdt { + final holdings = context.read().holdings; + final usdt = holdings.where((h) => h.coinCode == 'USDT').firstOrNull; + return usdt?.quantity ?? '0'; + } + + /// 获取交易账户中当前币种的持仓数量 + String get _availableCoinQty { + if (_selectedCoin == null) return '0'; + final holdings = context.read().holdings; + final pos = holdings + .where((h) => h.coinCode == _selectedCoin!.code) + .firstOrNull; + return pos?.quantity ?? '0'; + } + + /// 计算可买入/卖出的最大 USDT 金额 + String get _maxAmount { + if (_selectedCoin == null) return '0'; + final price = _selectedCoin!.price; + if (price <= 0) return '0'; + + if (_tradeType == 0) { + // 买入:最大 = USDT 余额 + return _availableUsdt; + } else { + // 卖出:最大 = 持有数量 × 当前价格 + final qty = double.tryParse(_availableCoinQty) ?? 0; + return (qty * price).toStringAsFixed(2); + } + } + + /// 计算数量 + String get _calculatedQuantity { + final amount = double.tryParse(_amountController.text) ?? 0; + final price = _selectedCoin?.price ?? 0; + if (price <= 0 || amount <= 0) return '0'; + return (amount / price).toStringAsFixed(6); + } + @override Widget build(BuildContext context) { super.build(context); @@ -75,43 +112,81 @@ class _TradePageState extends State with AutomaticKeepAliveClientMixi builder: (context, market, asset, _) { return SingleChildScrollView( padding: AppSpacing.pagePadding, - child: ShadForm( - key: _formKey, - child: Column( - children: [ - _CoinSelector( - selectedCoin: _selectedCoin, - coins: market.allCoins, - onCoinSelected: (coin) { - setState(() { - _selectedCoin = coin; - _priceController.text = coin.formattedPrice; - }); - }, - ), - SizedBox(height: AppSpacing.md), - if (_selectedCoin != null) _PriceCard(coin: _selectedCoin!), - SizedBox(height: AppSpacing.md), - _TradeForm( - tradeType: _tradeType, - selectedCoin: _selectedCoin, - priceController: _priceController, - quantityController: _quantityController, - tradeBalance: asset.overview?.tradeBalance, - onTradeTypeChanged: (type) => setState(() => _tradeType = type), - ), - SizedBox(height: AppSpacing.lg), - _TradeButton( - isBuy: _tradeType == 0, - coinCode: _selectedCoin?.code, - onPressed: () { - if (_formKey.currentState!.saveAndValidate()) { - _executeTrade(); - } - }, - ), - ], - ), + child: Column( + children: [ + // 币种选择器 + _CoinSelector( + selectedCoin: _selectedCoin, + coins: market.allCoins + .where((c) => c.code != 'USDT') + .toList(), + onCoinSelected: (coin) { + setState(() { + _selectedCoin = coin; + _amountController.clear(); + }); + }, + ), + SizedBox(height: AppSpacing.md), + + // 价格卡片 + if (_selectedCoin != null) + _PriceCard(coin: _selectedCoin!) + else + _PlaceholderCard(message: '请先选择交易币种'), + + SizedBox(height: AppSpacing.md), + + // 交易表单 + _TradeFormCard( + tradeType: _tradeType, + selectedCoin: _selectedCoin, + amountController: _amountController, + availableUsdt: _availableUsdt, + availableCoinQty: _availableCoinQty, + calculatedQuantity: _calculatedQuantity, + maxAmount: _maxAmount, + onTradeTypeChanged: (type) => setState(() { + _tradeType = type; + _amountController.clear(); + }), + onAmountChanged: () => setState(() {}), + onFillPercent: (pct) => _fillPercent(pct), + ), + + SizedBox(height: AppSpacing.lg), + + // 买入 + 卖出双按钮 + Row( + children: [ + Expanded( + child: _TradeButton( + isBuy: true, + coinCode: _selectedCoin?.code, + enabled: _canTrade() && !_isSubmitting, + isLoading: _isSubmitting && _tradeType == 0, + onPressed: () { + _tradeType = 0; + _executeTrade(); + }, + ), + ), + SizedBox(width: AppSpacing.md), + Expanded( + child: _TradeButton( + isBuy: false, + coinCode: _selectedCoin?.code, + enabled: _canTrade() && !_isSubmitting, + isLoading: _isSubmitting && _tradeType == 1, + onPressed: () { + _tradeType = 1; + _executeTrade(); + }, + ), + ), + ], + ), + ], ), ); }, @@ -119,57 +194,89 @@ class _TradePageState extends State with AutomaticKeepAliveClientMixi ); } - void _executeTrade() { - final colorScheme = Theme.of(context).colorScheme; - final price = _priceController.text; - final quantity = _quantityController.text; - final isBuy = _tradeType == 0; - - showShadDialog( - context: context, - builder: (ctx) => ShadDialog.alert( - title: Text(isBuy ? '确认买入' : '确认卖出'), - description: Text('${isBuy ? '买入' : '卖出'} $quantity ${_selectedCoin?.code ?? ''} @ $price USDT'), - actions: [ - ShadButton.outline(child: const Text('取消'), onPressed: () => Navigator.of(ctx).pop()), - ShadButton( - child: const Text('确认'), - onPressed: () { - Navigator.of(ctx).pop(); - _showTradeResult(); - }, - ), - ], - ), - ); + bool _canTrade() { + if (_selectedCoin == null) return false; + final amount = double.tryParse(_amountController.text) ?? 0; + return amount > 0; } - void _showTradeResult() { - final colorScheme = Theme.of(context).colorScheme; - final isBuy = _tradeType == 0; + void _fillPercent(double pct) { + final max = double.tryParse(_maxAmount) ?? 0; + _amountController.text = (max * pct).toStringAsFixed(2); + setState(() {}); + } + void _executeTrade() async { + final isBuy = _tradeType == 0; + final amount = _amountController.text; + final quantity = _calculatedQuantity; + final price = _selectedCoin!.price.toStringAsFixed(2); + final coinCode = _selectedCoin!.code; + + final confirmed = await showDialog( + context: context, + builder: (ctx) => _ConfirmDialog( + isBuy: isBuy, + coinCode: coinCode, + price: price, + quantity: quantity, + amount: amount, + ), + ); + + if (confirmed != true) return; + + setState(() => _isSubmitting = true); + + try { + final tradeService = context.read(); + final response = isBuy + ? await tradeService.buy( + coinCode: coinCode, price: price, quantity: quantity) + : await tradeService.sell( + coinCode: coinCode, price: price, quantity: quantity); + + if (!mounted) return; + + if (response.success) { + _amountController.clear(); + // 刷新资产数据 + context.read().refreshAll(force: true); + _showResultDialog(true, '${isBuy ? '买入' : '卖出'}成功', + '$quantity $coinCode @ $price USDT'); + } else { + _showResultDialog(false, '交易失败', response.message ?? '请稍后重试'); + } + } catch (e) { + if (mounted) { + _showResultDialog(false, '交易失败', e.toString()); + } + } finally { + if (mounted) setState(() => _isSubmitting = false); + } + } + + void _showResultDialog(bool success, String title, String message) { + final colorScheme = Theme.of(context).colorScheme; showShadDialog( context: context, builder: (ctx) => ShadDialog.alert( title: Row( children: [ NeonIcon( - icon: Icons.check_circle, - color: colorScheme.primary, + icon: success ? Icons.check_circle : Icons.error, + color: success ? AppColorScheme.up : colorScheme.error, size: 24, ), SizedBox(width: AppSpacing.sm), - const Text('交易成功'), + Text(title), ], ), - description: Text('已${isBuy ? '买入' : '卖出'} ${_quantityController.text} ${_selectedCoin?.code ?? ''}'), + description: Text(message), actions: [ ShadButton( child: const Text('确定'), - onPressed: () { - Navigator.of(ctx).pop(); - _quantityController.clear(); - }, + onPressed: () => Navigator.of(ctx).pop(), ), ], ), @@ -177,7 +284,105 @@ class _TradePageState extends State with AutomaticKeepAliveClientMixi } } -/// 币种选择器 - Glass Panel 风格 +/// 确认对话框 +class _ConfirmDialog extends StatelessWidget { + final bool isBuy; + final String coinCode; + final String price; + final String quantity; + final String amount; + + const _ConfirmDialog({ + required this.isBuy, + required this.coinCode, + required this.price, + required this.quantity, + required this.amount, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final actionColor = isBuy ? AppColorScheme.up : AppColorScheme.down; + + return Dialog( + backgroundColor: Colors.transparent, + child: GlassPanel( + borderRadius: BorderRadius.circular(AppRadius.xxl), + padding: EdgeInsets.all(AppSpacing.lg), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Text( + '确认${isBuy ? '买入' : '卖出'}', + style: GoogleFonts.spaceGrotesk( + fontSize: 20, + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ), + SizedBox(height: AppSpacing.lg), + _dialogRow('交易对', '$coinCode/USDT', colorScheme), + SizedBox(height: AppSpacing.sm), + _dialogRow('委托价格', '$price USDT', colorScheme), + SizedBox(height: AppSpacing.sm), + _dialogRow('交易金额', '$amount USDT', colorScheme, + valueColor: actionColor), + SizedBox(height: AppSpacing.sm), + _dialogRow('交易数量', '$quantity $coinCode', colorScheme), + SizedBox(height: AppSpacing.lg), + Row( + children: [ + Expanded( + child: NeonButton( + text: '取消', + type: NeonButtonType.outline, + onPressed: () => Navigator.of(context).pop(false), + height: 44, + showGlow: false, + ), + ), + SizedBox(width: AppSpacing.sm), + Expanded( + child: NeonButton( + text: '确认${isBuy ? '买入' : '卖出'}', + type: isBuy ? NeonButtonType.tertiary : NeonButtonType.error, + onPressed: () => Navigator.of(context).pop(true), + height: 44, + showGlow: true, + ), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _dialogRow(String label, String value, ColorScheme colorScheme, + {Color? valueColor}) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, + style: TextStyle( + fontSize: 14, color: colorScheme.onSurfaceVariant)), + Text(value, + style: GoogleFonts.spaceGrotesk( + fontSize: 14, + fontWeight: FontWeight.w600, + color: valueColor ?? colorScheme.onSurface, + )), + ], + ); + } +} + +/// 币种选择器 class _CoinSelector extends StatelessWidget { final Coin? selectedCoin; final List coins; @@ -195,8 +400,8 @@ class _CoinSelector extends StatelessWidget { return GestureDetector( onTap: () => _showCoinPicker(context), - child: GlassCard( - showNeonGlow: false, + child: GlassPanel( + padding: EdgeInsets.all(AppSpacing.md), child: Row( children: [ _CoinAvatar(icon: selectedCoin?.displayIcon), @@ -206,7 +411,9 @@ class _CoinSelector extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - selectedCoin != null ? '${selectedCoin!.code}/USDT' : '选择币种', + selectedCoin != null + ? '${selectedCoin!.code}/USDT' + : '选择币种', style: GoogleFonts.spaceGrotesk( fontSize: 18, fontWeight: FontWeight.bold, @@ -224,10 +431,7 @@ class _CoinSelector extends StatelessWidget { ], ), ), - Icon( - LucideIcons.chevronDown, - color: colorScheme.onSurfaceVariant, - ), + Icon(LucideIcons.chevronDown, color: colorScheme.onSurfaceVariant), ], ), ), @@ -243,14 +447,16 @@ class _CoinSelector extends StatelessWidget { backgroundColor: Colors.transparent, isScrollControlled: true, builder: (ctx) => Container( - height: MediaQuery.of(ctx).size.height * 0.7, + height: MediaQuery.of(ctx).size.height * 0.65, decoration: BoxDecoration( - color: isDark ? colorScheme.surface : colorScheme.surfaceContainerLowest, - borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xxl)), + color: isDark + ? colorScheme.surface + : colorScheme.surfaceContainerLowest, + borderRadius: + BorderRadius.vertical(top: Radius.circular(AppRadius.xxl)), ), child: Column( children: [ - // 拖动条 Container( margin: EdgeInsets.only(top: AppSpacing.sm), width: 40, @@ -260,37 +466,32 @@ class _CoinSelector extends StatelessWidget { borderRadius: BorderRadius.circular(2), ), ), - // 标题 Padding( padding: EdgeInsets.all(AppSpacing.lg), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - '选择币种', - style: GoogleFonts.spaceGrotesk( - fontSize: 20, - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), - ), + Text('选择币种', + style: GoogleFonts.spaceGrotesk( + fontSize: 20, + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + )), GestureDetector( onTap: () => Navigator.of(ctx).pop(), - child: Icon( - LucideIcons.x, - color: colorScheme.onSurfaceVariant, - ), + child: Icon(LucideIcons.x, + color: colorScheme.onSurfaceVariant), ), ], ), ), Divider(height: 1, color: colorScheme.outlineVariant.withOpacity(0.2)), - // 币种列表 Expanded( child: ListView.builder( padding: EdgeInsets.symmetric(vertical: AppSpacing.sm), itemCount: coins.length, - itemBuilder: (ctx, index) => _buildCoinItem(coins[index], context, ctx), + itemBuilder: (listCtx, index) => + _buildCoinItem(coins[index], context, listCtx), ), ), ], @@ -299,10 +500,10 @@ class _CoinSelector extends StatelessWidget { ); } - Widget _buildCoinItem(Coin coin, BuildContext context, BuildContext sheetContext) { + Widget _buildCoinItem( + Coin coin, BuildContext context, BuildContext sheetContext) { final colorScheme = Theme.of(context).colorScheme; final isSelected = selectedCoin?.code == coin.code; - final isDark = Theme.of(context).brightness == Brightness.dark; final changeColor = coin.isUp ? AppColorScheme.up : AppColorScheme.down; return GestureDetector( @@ -311,11 +512,10 @@ class _CoinSelector extends StatelessWidget { onCoinSelected(coin); }, child: Container( - padding: EdgeInsets.symmetric( - horizontal: AppSpacing.lg, - vertical: AppSpacing.md, - ), - color: isSelected ? colorScheme.primary.withOpacity(0.1) : Colors.transparent, + padding: + EdgeInsets.symmetric(horizontal: AppSpacing.lg, vertical: AppSpacing.md), + color: + isSelected ? colorScheme.primary.withOpacity(0.1) : Colors.transparent, child: Row( children: [ _CoinAvatar(icon: coin.displayIcon), @@ -326,64 +526,48 @@ class _CoinSelector extends StatelessWidget { children: [ Row( children: [ - Text( - coin.code, - style: GoogleFonts.spaceGrotesk( - fontSize: 16, - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), - ), + Text(coin.code, + style: GoogleFonts.spaceGrotesk( + fontSize: 16, + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + )), SizedBox(width: AppSpacing.xs), - Text( - '/USDT', - style: TextStyle( - fontSize: 12, - color: colorScheme.onSurfaceVariant, - ), - ), + Text('/USDT', + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant, + )), ], ), - SizedBox(height: AppSpacing.xs / 2), - Text( - coin.name, - style: TextStyle( - fontSize: 12, - color: colorScheme.onSurfaceVariant, - ), - ), + Text(coin.name, + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant, + )), ], ), ), Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ - Text( - '\$${coin.formattedPrice}', - style: GoogleFonts.spaceGrotesk( - fontSize: 14, - fontWeight: FontWeight.w600, - color: colorScheme.onSurface, - ), - ), - SizedBox(height: AppSpacing.xs / 2), - Text( - coin.formattedChange, - style: TextStyle( - fontSize: 12, - color: changeColor, - fontWeight: FontWeight.w600, - ), - ), + Text('\$${coin.formattedPrice}', + style: GoogleFonts.spaceGrotesk( + fontSize: 14, + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + )), + Text(coin.formattedChange, + style: TextStyle( + fontSize: 12, + color: changeColor, + fontWeight: FontWeight.w600, + )), ], ), if (isSelected) ...[ SizedBox(width: AppSpacing.sm), - Icon( - LucideIcons.check, - size: 18, - color: colorScheme.primary, - ), + Icon(LucideIcons.check, size: 18, color: colorScheme.primary), ], ], ), @@ -392,69 +576,62 @@ class _CoinSelector extends StatelessWidget { } } -/// 币种头像 - 带霓虹光效 +/// 币种头像 class _CoinAvatar extends StatelessWidget { final String? icon; - const _CoinAvatar({this.icon}); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - return Container( width: 44, height: 44, decoration: BoxDecoration( color: colorScheme.primary.withOpacity(0.1), borderRadius: BorderRadius.circular(AppRadius.md), - border: Border.all( - color: colorScheme.primary.withOpacity(0.2), - ), + border: Border.all(color: colorScheme.primary.withOpacity(0.2)), ), child: Center( - child: Text( - icon ?? '?', - style: TextStyle( - fontSize: 20, - color: colorScheme.primary, - fontWeight: FontWeight.bold, - ), - ), + child: Text(icon ?? '?', + style: TextStyle( + fontSize: 20, + color: colorScheme.primary, + fontWeight: FontWeight.bold, + )), ), ); } } -/// 价格卡片 - Glass Panel 风格 +/// 价格卡片 class _PriceCard extends StatelessWidget { final Coin coin; - const _PriceCard({required this.coin}); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final color = coin.isUp ? AppColorScheme.up : AppColorScheme.down; + final isDark = Theme.of(context).brightness == Brightness.dark; + final color = + coin.isUp ? AppColorScheme.getUpColor(isDark) : AppColorScheme.down; final bgColor = coin.isUp - ? AppColorScheme.up.withOpacity(0.1) + ? AppColorScheme.getUpBackgroundColor(isDark) : colorScheme.error.withOpacity(0.1); - return GlassCard( - showNeonGlow: false, + return GlassPanel( + padding: EdgeInsets.all(AppSpacing.lg), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - '最新价', - style: TextStyle( - fontSize: 12, - color: colorScheme.onSurfaceVariant, - ), - ), + Text('最新价', + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant, + )), SizedBox(height: AppSpacing.xs), Text( '\$${coin.formattedPrice}', @@ -468,23 +645,16 @@ class _PriceCard extends StatelessWidget { ), Container( padding: EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.sm, - ), + horizontal: AppSpacing.md, vertical: AppSpacing.sm), decoration: BoxDecoration( color: bgColor, borderRadius: BorderRadius.circular(AppRadius.md), - border: Border.all( - color: color.withOpacity(0.2), - ), + border: Border.all(color: color.withOpacity(0.2)), ), child: Text( coin.formattedChange, style: TextStyle( - fontSize: 16, - color: color, - fontWeight: FontWeight.w700, - ), + fontSize: 16, color: color, fontWeight: FontWeight.w700), ), ), ], @@ -493,211 +663,211 @@ class _PriceCard extends StatelessWidget { } } -/// 交易表单 - Glass Panel 风格 -class _TradeForm extends StatelessWidget { +/// 占位卡片 +class _PlaceholderCard extends StatelessWidget { + final String message; + const _PlaceholderCard({required this.message}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return GlassPanel( + padding: EdgeInsets.all(AppSpacing.xl), + child: Center( + child: Text(message, + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: 14, + )), + ), + ); + } +} + +/// 交易表单卡片 +class _TradeFormCard extends StatelessWidget { final int tradeType; final Coin? selectedCoin; - final TextEditingController priceController; - final TextEditingController quantityController; - final String? tradeBalance; + final TextEditingController amountController; + final String availableUsdt; + final String availableCoinQty; + final String calculatedQuantity; + final String maxAmount; final ValueChanged onTradeTypeChanged; + final VoidCallback onAmountChanged; + final ValueChanged onFillPercent; - const _TradeForm({ + const _TradeFormCard({ required this.tradeType, required this.selectedCoin, - required this.priceController, - required this.quantityController, - required this.tradeBalance, + required this.amountController, + required this.availableUsdt, + required this.availableCoinQty, + required this.calculatedQuantity, + required this.maxAmount, required this.onTradeTypeChanged, + required this.onAmountChanged, + required this.onFillPercent, }); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + final isBuy = tradeType == 0; + final actionColor = isBuy ? AppColorScheme.up : AppColorScheme.down; return GlassPanel( padding: EdgeInsets.all(AppSpacing.lg), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ // 买入/卖出切换 - _TradeTypeSelector( - tradeType: tradeType, - onChanged: onTradeTypeChanged, + Container( + padding: EdgeInsets.all(AppSpacing.xs), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerLowest, + borderRadius: BorderRadius.circular(AppRadius.xl), + ), + child: Row( + children: [ + Expanded( + child: _typeButton('买入', isBuy, AppColorScheme.up, () => onTradeTypeChanged(0)), + ), + SizedBox(width: AppSpacing.sm), + Expanded( + child: _typeButton('卖出', !isBuy, AppColorScheme.down, () => onTradeTypeChanged(1)), + ), + ], + ), ), SizedBox(height: AppSpacing.lg), - // 价格输入 - _buildInputField( - label: '价格(USDT)', - controller: priceController, - placeholder: '输入价格', - suffix: 'USDT', - colorScheme: colorScheme, - ), - SizedBox(height: AppSpacing.md), - // 数量输入 - _buildInputField( - label: '数量', - controller: quantityController, - placeholder: '输入数量', - suffix: selectedCoin?.code ?? '', - colorScheme: colorScheme, - ), - SizedBox(height: AppSpacing.lg), - // 信息行 - _InfoRow(label: '交易金额', value: '${_calculateAmount()} USDT', colorScheme: colorScheme), - SizedBox(height: AppSpacing.sm), - _InfoRow(label: '可用', value: '${tradeBalance ?? '0.00'} USDT', colorScheme: colorScheme), - ], - ), - ); - } - Widget _buildInputField({ - required String label, - required TextEditingController controller, - required String placeholder, - required String suffix, - required ColorScheme colorScheme, - }) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w700, - letterSpacing: 0.2, - color: colorScheme.onSurfaceVariant, - ), - ), - SizedBox(height: AppSpacing.xs), - Container( - decoration: BoxDecoration( - color: colorScheme.surfaceContainerLowest, - borderRadius: BorderRadius.circular(AppRadius.xl), - border: Border.all( - color: colorScheme.outlineVariant.withOpacity(0.3), + // 交易金额输入 + Text('交易金额 (USDT)', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 0.2, + color: colorScheme.onSurfaceVariant, + )), + SizedBox(height: AppSpacing.xs), + Container( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerLowest, + borderRadius: BorderRadius.circular(AppRadius.xl), + border: Border.all(color: colorScheme.outlineVariant.withOpacity(0.3)), + ), + child: TextField( + controller: amountController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + onChanged: (_) => onAmountChanged(), + style: GoogleFonts.spaceGrotesk( + fontSize: 20, + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + decoration: InputDecoration( + hintText: '输入金额', + hintStyle: TextStyle( + color: colorScheme.outlineVariant.withOpacity(0.5)), + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.md, + ), + suffixIcon: Padding( + padding: EdgeInsets.only(right: AppSpacing.sm), + child: Text('USDT', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: colorScheme.onSurfaceVariant, + )), + ), + suffixIconConstraints: const BoxConstraints(minWidth: 50), + ), ), ), - child: TextField( - controller: controller, - enabled: false, // 价格不允许修改,使用当前币种的市场价格 - keyboardType: const TextInputType.numberWithOptions(decimal: true), - style: GoogleFonts.spaceGrotesk( - fontSize: 20, - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), - decoration: InputDecoration( - hintText: placeholder, - hintStyle: TextStyle( - color: colorScheme.outlineVariant.withOpacity(0.5), - ), - border: InputBorder.none, - contentPadding: EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.md, - ), - suffixIcon: Padding( - padding: EdgeInsets.only(right: AppSpacing.sm), - child: Text( - suffix, + SizedBox(height: AppSpacing.sm), + + // 快捷比例按钮 + Row( + children: [ + _pctButton('25%', 0.25, colorScheme), + SizedBox(width: AppSpacing.xs), + _pctButton('50%', 0.5, colorScheme), + SizedBox(width: AppSpacing.xs), + _pctButton('75%', 0.75, colorScheme), + SizedBox(width: AppSpacing.xs), + _pctButton('全部', 1.0, colorScheme), + ], + ), + SizedBox(height: AppSpacing.lg), + + // 预计数量 + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('预计数量', style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, + fontSize: 13, color: colorScheme.onSurfaceVariant, - ), + )), + Text( + '$calculatedQuantity ${selectedCoin?.code ?? ''}', + style: GoogleFonts.spaceGrotesk( + fontSize: 14, + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, ), ), - suffixIconConstraints: const BoxConstraints(minWidth: 50), - ), + ], ), - ), - ], - ); - } + SizedBox(height: AppSpacing.md), - String _calculateAmount() { - final price = double.tryParse(priceController.text) ?? 0; - final quantity = double.tryParse(quantityController.text) ?? 0; - return (price * quantity).toStringAsFixed(2); - } -} - -/// 交易类型选择器 - Material Design 3 风格 -class _TradeTypeSelector extends StatelessWidget { - final int tradeType; - final ValueChanged onChanged; - - const _TradeTypeSelector({required this.tradeType, required this.onChanged}); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - return Container( - padding: EdgeInsets.all(AppSpacing.xs), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerLowest, - borderRadius: BorderRadius.circular(AppRadius.xl), - ), - child: Row( - children: [ - Expanded( - child: _TypeButton( - label: 'Buy', - isSelected: tradeType == 0, - color: AppColorScheme.up, - onTap: () => onChanged(0), - ), - ), - SizedBox(width: AppSpacing.sm), - Expanded( - child: _TypeButton( - label: 'Sell', - isSelected: tradeType == 1, - color: AppColorScheme.down, - onTap: () => onChanged(1), - ), + // 可用余额 + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(isBuy ? '可用 USDT' : '可用 ${selectedCoin?.code ?? ""}', + style: TextStyle( + fontSize: 13, + color: colorScheme.onSurfaceVariant, + )), + Text( + isBuy + ? '$availableUsdt USDT' + : '$availableCoinQty ${selectedCoin?.code ?? ""}', + style: GoogleFonts.spaceGrotesk( + fontSize: 14, + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), + ], ), ], ), ); } -} -/// 类型按钮 -class _TypeButton extends StatelessWidget { - final String label; - final bool isSelected; - final Color color; - final VoidCallback onTap; - - const _TypeButton({ - required this.label, - required this.isSelected, - required this.color, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { + Widget _typeButton( + String label, bool isActive, Color color, VoidCallback onTap) { return GestureDetector( onTap: onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 200), padding: EdgeInsets.symmetric(vertical: AppSpacing.sm + AppSpacing.xs), decoration: BoxDecoration( - color: isSelected ? color.withOpacity(0.15) : Colors.transparent, + color: isActive ? color.withOpacity(0.15) : Colors.transparent, borderRadius: BorderRadius.circular(AppRadius.md), - border: isSelected ? null : Border.all(color: color.withOpacity(0.3)), + border: isActive ? null : Border.all(color: color.withOpacity(0.3)), ), child: Center( child: Text( label, style: TextStyle( - color: isSelected ? color : color.withOpacity(0.7), + color: isActive ? color : color.withOpacity(0.7), fontWeight: FontWeight.w700, fontSize: 14, letterSpacing: 0.5, @@ -707,62 +877,86 @@ class _TypeButton extends StatelessWidget { ), ); } -} -/// 信息行 -class _InfoRow extends StatelessWidget { - final String label; - final String value; - final ColorScheme colorScheme; - - const _InfoRow({required this.label, required this.value, required this.colorScheme}); - - @override - Widget build(BuildContext context) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - label, - style: TextStyle( - fontSize: 14, - color: colorScheme.onSurfaceVariant, + Widget _pctButton(String label, double pct, ColorScheme colorScheme) { + return Expanded( + child: GestureDetector( + onTap: () => onFillPercent(pct), + child: Container( + padding: EdgeInsets.symmetric(vertical: AppSpacing.xs + 2), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + child: Center( + child: Text(label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: colorScheme.onSurfaceVariant, + )), ), ), - Text( - value, - style: GoogleFonts.spaceGrotesk( - fontSize: 14, - fontWeight: FontWeight.w600, - color: colorScheme.onSurface, - ), - ), - ], + ), ); } } -/// 交易按钮 - 带霓虹光效 +/// 交易按钮 class _TradeButton extends StatelessWidget { final bool isBuy; final String? coinCode; + final bool enabled; + final bool isLoading; final VoidCallback onPressed; const _TradeButton({ required this.isBuy, required this.coinCode, + required this.enabled, + required this.isLoading, required this.onPressed, }); @override Widget build(BuildContext context) { - return NeonButton( - text: '${isBuy ? '买入' : '卖出'} ${coinCode ?? ''}', - type: isBuy ? NeonButtonType.tertiary : NeonButtonType.error, - icon: isBuy ? Icons.arrow_downward : Icons.arrow_upward, - onPressed: onPressed, + final colorScheme = Theme.of(context).colorScheme; + + return SizedBox( width: double.infinity, - showGlow: true, + height: 52, + child: ElevatedButton( + onPressed: enabled ? onPressed : null, + style: ElevatedButton.styleFrom( + backgroundColor: + isBuy ? AppColorScheme.up : AppColorScheme.down, + disabledBackgroundColor: colorScheme.onSurface.withOpacity(0.12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.xl), + ), + elevation: isBuy && enabled ? 4 : 0, + shadowColor: + isBuy ? AppColorScheme.up.withOpacity(0.3) : Colors.transparent, + ), + child: isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: isBuy ? Colors.black87 : Colors.white, + ), + ) + : Text( + '${isBuy ? '买入' : '卖出'} ${coinCode ?? ""}', + style: GoogleFonts.spaceGrotesk( + fontSize: 16, + fontWeight: FontWeight.w700, + color: isBuy ? Colors.black87 : Colors.white, + letterSpacing: 1, + ), + ), + ), ); } } diff --git a/src/main/java/com/it/rattan/monisuo/service/AssetService.java b/src/main/java/com/it/rattan/monisuo/service/AssetService.java index 13e34fe..35b1d10 100644 --- a/src/main/java/com/it/rattan/monisuo/service/AssetService.java +++ b/src/main/java/com/it/rattan/monisuo/service/AssetService.java @@ -237,7 +237,8 @@ public class AssetService { trade.setCoinCode(coinCode.toUpperCase()); trade.setQuantity(BigDecimal.ZERO); trade.setFrozen(BigDecimal.ZERO); - trade.setAvgPrice(BigDecimal.ZERO); + // USDT作为基准货币,均价固定为1 + trade.setAvgPrice("USDT".equals(coinCode.toUpperCase()) ? BigDecimal.ONE : BigDecimal.ZERO); trade.setTotalBuy(BigDecimal.ZERO); trade.setTotalSell(BigDecimal.ZERO); trade.setCreateTime(LocalDateTime.now()); diff --git a/src/main/java/com/it/rattan/monisuo/service/TradeService.java b/src/main/java/com/it/rattan/monisuo/service/TradeService.java index fba79f9..7efec83 100644 --- a/src/main/java/com/it/rattan/monisuo/service/TradeService.java +++ b/src/main/java/com/it/rattan/monisuo/service/TradeService.java @@ -4,8 +4,10 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.it.rattan.monisuo.entity.*; +import com.it.rattan.monisuo.mapper.AccountTradeMapper; import com.it.rattan.monisuo.mapper.OrderTradeMapper; import com.it.rattan.monisuo.util.OrderNoUtil; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -23,6 +25,9 @@ public class TradeService { @Autowired private OrderTradeMapper orderTradeMapper; + @Autowired + private AccountTradeMapper accountTradeMapper; + @Autowired private AssetService assetService; @@ -55,6 +60,11 @@ public class TradeService { // 扣减USDT usdtAccount.setQuantity(usdtAccount.getQuantity().subtract(amount)); usdtAccount.setUpdateTime(LocalDateTime.now()); + // 持久化USDT扣减 + accountTradeMapper.update(null, new LambdaUpdateWrapper() + .eq(AccountTrade::getId, usdtAccount.getId()) + .set(AccountTrade::getQuantity, usdtAccount.getQuantity()) + .set(AccountTrade::getUpdateTime, usdtAccount.getUpdateTime())); // 增加持仓 AccountTrade coinAccount = assetService.getOrCreateTradeAccount(userId, coinCode); @@ -68,6 +78,13 @@ public class TradeService { } coinAccount.setTotalBuy(coinAccount.getTotalBuy().add(quantity)); coinAccount.setUpdateTime(LocalDateTime.now()); + // 持久化币种持仓更新 + accountTradeMapper.update(null, new LambdaUpdateWrapper() + .eq(AccountTrade::getId, coinAccount.getId()) + .set(AccountTrade::getQuantity, coinAccount.getQuantity()) + .set(AccountTrade::getAvgPrice, coinAccount.getAvgPrice()) + .set(AccountTrade::getTotalBuy, coinAccount.getTotalBuy()) + .set(AccountTrade::getUpdateTime, coinAccount.getUpdateTime())); // 创建订单 OrderTrade order = new OrderTrade(); @@ -124,11 +141,22 @@ public class TradeService { coinAccount.setQuantity(coinAccount.getQuantity().subtract(quantity)); coinAccount.setTotalSell(coinAccount.getTotalSell().add(quantity)); coinAccount.setUpdateTime(LocalDateTime.now()); + // 持久化币种持仓扣减 + accountTradeMapper.update(null, new LambdaUpdateWrapper() + .eq(AccountTrade::getId, coinAccount.getId()) + .set(AccountTrade::getQuantity, coinAccount.getQuantity()) + .set(AccountTrade::getTotalSell, coinAccount.getTotalSell()) + .set(AccountTrade::getUpdateTime, coinAccount.getUpdateTime())); // 增加USDT AccountTrade usdtAccount = assetService.getOrCreateTradeAccount(userId, "USDT"); usdtAccount.setQuantity(usdtAccount.getQuantity().add(amount)); usdtAccount.setUpdateTime(LocalDateTime.now()); + // 持久化USDT增加 + accountTradeMapper.update(null, new LambdaUpdateWrapper() + .eq(AccountTrade::getId, usdtAccount.getId()) + .set(AccountTrade::getQuantity, usdtAccount.getQuantity()) + .set(AccountTrade::getUpdateTime, usdtAccount.getUpdateTime())); // 创建订单 OrderTrade order = new OrderTrade();