Przeglądaj źródła

✨ feat(asset): 对齐 Web 新增锁仓账户 Tab

资产页增加第 6 个锁仓 Tab,展示释放总量、总锁仓余额、充提划转与锁仓订单列表,总览页锁仓行可跳转至该 Tab。

Co-authored-by: Cursor <cursoragent@cursor.com>
maotouying_cc 1 miesiąc temu
rodzic
commit
c9eee2cfa6

+ 10 - 0
lib/core/l10n/app_en.arb

@@ -712,6 +712,16 @@
   "transferFreeHint": "Free transfer, instant arrival",
   "spotTradingAccount": "Spot Trading Account",
   "assetsLockedStakingAccount": "Locked Account",
+  "assetsReleaseTotal": "Total Released",
+  "assetsTotalLockedBalance": "Total Locked Balance (iBit)",
+  "assetsMyStakingOrders": "My Locked Orders",
+  "assetsStakingOrderNo": "Order No.",
+  "assetsStakingTotal": "Locked Amount",
+  "assetsReleased": "Released",
+  "assetsPendingRelease": "Pending Release",
+  "assetsSubscribing": "Subscribing",
+  "assetsReleasing": "Releasing",
+  "assetsIbitUnit": "iBit",
   "transferAvailableCoin": "Available: {available} {coin}",
   "@transferAvailableCoin": {
     "placeholders": {

+ 60 - 0
lib/core/l10n/app_localizations.dart

@@ -3941,6 +3941,66 @@ abstract class AppLocalizations {
   /// **'锁仓账户'**
   String get assetsLockedStakingAccount;
 
+  /// No description provided for @assetsReleaseTotal.
+  ///
+  /// In zh, this message translates to:
+  /// **'释放总量'**
+  String get assetsReleaseTotal;
+
+  /// No description provided for @assetsTotalLockedBalance.
+  ///
+  /// In zh, this message translates to:
+  /// **'总锁仓余额(iBit)'**
+  String get assetsTotalLockedBalance;
+
+  /// No description provided for @assetsMyStakingOrders.
+  ///
+  /// In zh, this message translates to:
+  /// **'我的锁仓订单'**
+  String get assetsMyStakingOrders;
+
+  /// No description provided for @assetsStakingOrderNo.
+  ///
+  /// In zh, this message translates to:
+  /// **'订单编号'**
+  String get assetsStakingOrderNo;
+
+  /// No description provided for @assetsStakingTotal.
+  ///
+  /// In zh, this message translates to:
+  /// **'锁仓总量'**
+  String get assetsStakingTotal;
+
+  /// No description provided for @assetsReleased.
+  ///
+  /// In zh, this message translates to:
+  /// **'已释放'**
+  String get assetsReleased;
+
+  /// No description provided for @assetsPendingRelease.
+  ///
+  /// In zh, this message translates to:
+  /// **'待释放'**
+  String get assetsPendingRelease;
+
+  /// No description provided for @assetsSubscribing.
+  ///
+  /// In zh, this message translates to:
+  /// **'申购中'**
+  String get assetsSubscribing;
+
+  /// No description provided for @assetsReleasing.
+  ///
+  /// In zh, this message translates to:
+  /// **'释放中'**
+  String get assetsReleasing;
+
+  /// No description provided for @assetsIbitUnit.
+  ///
+  /// In zh, this message translates to:
+  /// **'iBit'**
+  String get assetsIbitUnit;
+
   /// No description provided for @transferAvailableCoin.
   ///
   /// In zh, this message translates to:

+ 30 - 0
lib/core/l10n/app_localizations_en.dart

@@ -1978,6 +1978,36 @@ class AppLocalizationsEn extends AppLocalizations {
   @override
   String get assetsLockedStakingAccount => 'Locked Account';
 
+  @override
+  String get assetsReleaseTotal => 'Total Released';
+
+  @override
+  String get assetsTotalLockedBalance => 'Total Locked Balance (iBit)';
+
+  @override
+  String get assetsMyStakingOrders => 'My Locked Orders';
+
+  @override
+  String get assetsStakingOrderNo => 'Order No.';
+
+  @override
+  String get assetsStakingTotal => 'Locked Amount';
+
+  @override
+  String get assetsReleased => 'Released';
+
+  @override
+  String get assetsPendingRelease => 'Pending Release';
+
+  @override
+  String get assetsSubscribing => 'Subscribing';
+
+  @override
+  String get assetsReleasing => 'Releasing';
+
+  @override
+  String get assetsIbitUnit => 'iBit';
+
   @override
   String transferAvailableCoin(Object available, Object coin) {
     return 'Available: $available $coin';

+ 30 - 0
lib/core/l10n/app_localizations_hi.dart

@@ -1974,6 +1974,36 @@ class AppLocalizationsHi extends AppLocalizations {
   @override
   String get assetsLockedStakingAccount => '锁仓账户';
 
+  @override
+  String get assetsReleaseTotal => '释放总量';
+
+  @override
+  String get assetsTotalLockedBalance => '总锁仓余额(iBit)';
+
+  @override
+  String get assetsMyStakingOrders => '我的锁仓订单';
+
+  @override
+  String get assetsStakingOrderNo => '订单编号';
+
+  @override
+  String get assetsStakingTotal => '锁仓总量';
+
+  @override
+  String get assetsReleased => '已释放';
+
+  @override
+  String get assetsPendingRelease => '待释放';
+
+  @override
+  String get assetsSubscribing => '申购中';
+
+  @override
+  String get assetsReleasing => '释放中';
+
+  @override
+  String get assetsIbitUnit => 'iBit';
+
   @override
   String transferAvailableCoin(Object available, Object coin) {
     return '可用: $available $coin';

+ 30 - 0
lib/core/l10n/app_localizations_id.dart

@@ -1978,6 +1978,36 @@ class AppLocalizationsId extends AppLocalizations {
   @override
   String get assetsLockedStakingAccount => '锁仓账户';
 
+  @override
+  String get assetsReleaseTotal => '释放总量';
+
+  @override
+  String get assetsTotalLockedBalance => '总锁仓余额(iBit)';
+
+  @override
+  String get assetsMyStakingOrders => '我的锁仓订单';
+
+  @override
+  String get assetsStakingOrderNo => '订单编号';
+
+  @override
+  String get assetsStakingTotal => '锁仓总量';
+
+  @override
+  String get assetsReleased => '已释放';
+
+  @override
+  String get assetsPendingRelease => '待释放';
+
+  @override
+  String get assetsSubscribing => '申购中';
+
+  @override
+  String get assetsReleasing => '释放中';
+
+  @override
+  String get assetsIbitUnit => 'iBit';
+
   @override
   String transferAvailableCoin(Object available, Object coin) {
     return '可用: $available $coin';

+ 30 - 0
lib/core/l10n/app_localizations_ja.dart

@@ -1961,6 +1961,36 @@ class AppLocalizationsJa extends AppLocalizations {
   @override
   String get assetsLockedStakingAccount => '锁仓账户';
 
+  @override
+  String get assetsReleaseTotal => '释放总量';
+
+  @override
+  String get assetsTotalLockedBalance => '总锁仓余额(iBit)';
+
+  @override
+  String get assetsMyStakingOrders => '我的锁仓订单';
+
+  @override
+  String get assetsStakingOrderNo => '订单编号';
+
+  @override
+  String get assetsStakingTotal => '锁仓总量';
+
+  @override
+  String get assetsReleased => '已释放';
+
+  @override
+  String get assetsPendingRelease => '待释放';
+
+  @override
+  String get assetsSubscribing => '申购中';
+
+  @override
+  String get assetsReleasing => '释放中';
+
+  @override
+  String get assetsIbitUnit => 'iBit';
+
   @override
   String transferAvailableCoin(Object available, Object coin) {
     return '可用: $available $coin';

+ 30 - 0
lib/core/l10n/app_localizations_ko.dart

@@ -1962,6 +1962,36 @@ class AppLocalizationsKo extends AppLocalizations {
   @override
   String get assetsLockedStakingAccount => '锁仓账户';
 
+  @override
+  String get assetsReleaseTotal => '释放总量';
+
+  @override
+  String get assetsTotalLockedBalance => '总锁仓余额(iBit)';
+
+  @override
+  String get assetsMyStakingOrders => '我的锁仓订单';
+
+  @override
+  String get assetsStakingOrderNo => '订单编号';
+
+  @override
+  String get assetsStakingTotal => '锁仓总量';
+
+  @override
+  String get assetsReleased => '已释放';
+
+  @override
+  String get assetsPendingRelease => '待释放';
+
+  @override
+  String get assetsSubscribing => '申购中';
+
+  @override
+  String get assetsReleasing => '释放中';
+
+  @override
+  String get assetsIbitUnit => 'iBit';
+
   @override
   String transferAvailableCoin(Object available, Object coin) {
     return '可用: $available $coin';

+ 30 - 0
lib/core/l10n/app_localizations_zh.dart

@@ -1956,6 +1956,36 @@ class AppLocalizationsZh extends AppLocalizations {
   @override
   String get assetsLockedStakingAccount => '锁仓账户';
 
+  @override
+  String get assetsReleaseTotal => '释放总量';
+
+  @override
+  String get assetsTotalLockedBalance => '总锁仓余额(iBit)';
+
+  @override
+  String get assetsMyStakingOrders => '我的锁仓订单';
+
+  @override
+  String get assetsStakingOrderNo => '订单编号';
+
+  @override
+  String get assetsStakingTotal => '锁仓总量';
+
+  @override
+  String get assetsReleased => '已释放';
+
+  @override
+  String get assetsPendingRelease => '待释放';
+
+  @override
+  String get assetsSubscribing => '申购中';
+
+  @override
+  String get assetsReleasing => '释放中';
+
+  @override
+  String get assetsIbitUnit => 'iBit';
+
   @override
   String transferAvailableCoin(Object available, Object coin) {
     return '可用: $available $coin';

+ 10 - 0
lib/core/l10n/app_zh.arb

@@ -712,6 +712,16 @@
   "transferFreeHint": "划转免手续费,实时到账",
   "spotTradingAccount": "现货交易账户",
   "assetsLockedStakingAccount": "锁仓账户",
+  "assetsReleaseTotal": "释放总量",
+  "assetsTotalLockedBalance": "总锁仓余额(iBit)",
+  "assetsMyStakingOrders": "我的锁仓订单",
+  "assetsStakingOrderNo": "订单编号",
+  "assetsStakingTotal": "锁仓总量",
+  "assetsReleased": "已释放",
+  "assetsPendingRelease": "待释放",
+  "assetsSubscribing": "申购中",
+  "assetsReleasing": "释放中",
+  "assetsIbitUnit": "iBit",
   "transferAvailableCoin": "可用: {available} {coin}",
   "@transferAvailableCoin": {
     "placeholders": {

+ 33 - 0
lib/core/utils/spot_transfer_asset.dart

@@ -1,4 +1,5 @@
 import '../../data/models/asset/today_pnl.dart';
+import '../../data/models/finance/staking_wallet_balance.dart';
 
 /// 解析现货各币种可用余额(对齐 Web `parseSpotAvailableMap`)。
 Map<String, String> parseSpotAvailableMap(Map<String, dynamic> spotData) {
@@ -94,3 +95,35 @@ String stakingOverviewLockedFromAccounts(List<AccountInfo> list) {
   }
   return row.frozenBalance.toString();
 }
+
+String stakingOverviewAvailableFromAccounts(List<AccountInfo> list) {
+  final row = pickStakingAccount(list);
+  if (row == null) {
+    return '0';
+  }
+  return row.balance.toString();
+}
+
+/// 总览锁仓余额合并 wallet-new 与 staking/wallet(对齐 Web applyStakingOverviewBalances)
+({String available, String locked}) mergeStakingOverviewBalances({
+  required String walletAvailable,
+  required String walletLocked,
+  StakingWalletBalance? apiWallet,
+}) {
+  var available = walletAvailable;
+  var locked = walletLocked;
+  if (apiWallet == null) {
+    return (available: available, locked: locked);
+  }
+  final apiAvailable = apiWallet.availableBalance;
+  final apiLocked = apiWallet.lockedBalance;
+  if ((double.tryParse(available) ?? 0) <= 0 &&
+      (double.tryParse(apiAvailable) ?? 0) > 0) {
+    available = apiAvailable;
+  }
+  if ((double.tryParse(locked) ?? 0) <= 0 &&
+      (double.tryParse(apiLocked) ?? 0) > 0) {
+    locked = apiLocked;
+  }
+  return (available: available, locked: locked);
+}

+ 85 - 0
lib/data/models/finance/staking_order_item.dart

@@ -0,0 +1,85 @@
+class StakingOrderItem {
+  final int id;
+  final String coinUnit;
+  final String amount;
+  final String releasedAmount;
+  final int status;
+  final String? stakingTime;
+  final String? unlockTime;
+  final String? lastReleaseTime;
+  final String? createTime;
+
+  const StakingOrderItem({
+    required this.id,
+    required this.coinUnit,
+    required this.amount,
+    required this.releasedAmount,
+    required this.status,
+    this.stakingTime,
+    this.unlockTime,
+    this.lastReleaseTime,
+    this.createTime,
+  });
+
+  factory StakingOrderItem.fromJson(Map<String, dynamic> json) {
+    return StakingOrderItem(
+      id: int.tryParse('${json['id'] ?? 0}') ?? 0,
+      coinUnit: '${json['coinUnit'] ?? ''}'.toUpperCase(),
+      amount: '${json['amount'] ?? '0'}',
+      releasedAmount: '${json['releasedAmount'] ?? '0'}',
+      status: int.tryParse('${json['status'] ?? 0}') ?? 0,
+      stakingTime: json['stakingTime']?.toString(),
+      unlockTime: json['unlockTime']?.toString(),
+      lastReleaseTime: json['lastReleaseTime']?.toString(),
+      createTime: json['createTime']?.toString(),
+    );
+  }
+
+  /// 待释放 = 锁仓总量 - 已释放
+  double get pendingAmount {
+    final total = double.tryParse(amount) ?? 0;
+    final released = double.tryParse(releasedAmount) ?? 0;
+    final pending = total - released;
+    if (!pending.isFinite || pending <= 0) {
+      return 0;
+    }
+    return pending;
+  }
+
+  String get displayStartTime {
+    final raw = stakingTime ?? createTime;
+    if (raw == null || raw.isEmpty) {
+      return '-';
+    }
+    return raw.replaceFirst('T', ' ').substring(0, raw.length >= 19 ? 19 : raw.length);
+  }
+}
+
+class StakingOrderPage {
+  final List<StakingOrderItem> content;
+  final int totalPages;
+  final int pageNo;
+
+  const StakingOrderPage({
+    required this.content,
+    required this.totalPages,
+    required this.pageNo,
+  });
+}
+
+/// 锁仓订单状态筛选(对齐 Web StakingOrderStatusFilter)
+enum StakingOrderStatusFilter {
+  all,
+  subscribing,
+  releasing,
+  completed,
+}
+
+extension StakingOrderStatusFilterX on StakingOrderStatusFilter {
+  String? get apiStatus => switch (this) {
+        StakingOrderStatusFilter.all => null,
+        StakingOrderStatusFilter.subscribing => '0',
+        StakingOrderStatusFilter.releasing => '1',
+        StakingOrderStatusFilter.completed => '2',
+      };
+}

+ 41 - 0
lib/data/services/staking_service.dart

@@ -5,6 +5,7 @@ import '../../core/utils/spot_transfer_asset.dart';
 import '../models/finance/airdrop_eligibility.dart';
 import '../models/finance/airdrop_record_item.dart';
 import '../models/finance/staking_config.dart';
+import '../models/finance/staking_order_item.dart';
 import '../models/finance/staking_wallet_balance.dart';
 
 class StakingService {
@@ -55,6 +56,46 @@ class StakingService {
     );
   }
 
+  /// 分页查询我的质押订单(对齐 Web fetchStakingOrders)
+  Future<StakingOrderPage> getStakingOrders({
+    required int pageNo,
+    int pageSize = 5,
+    String? status,
+  }) async {
+    final data = <String, dynamic>{
+      'pageNo': pageNo,
+      'pageSize': pageSize,
+    };
+    if (status != null && status.isNotEmpty) {
+      data['status'] = status;
+    }
+    final resp = await _dio.post<Map<String, dynamic>>(
+      'uc/staking/orders',
+      data: data,
+      options: Options(contentType: Headers.formUrlEncodedContentType),
+    );
+    final raw = resp.data?['data'];
+    if (raw is! Map<String, dynamic>) {
+      return const StakingOrderPage(content: [], totalPages: 0, pageNo: 1);
+    }
+    final contentRaw = raw['content'];
+    final content = contentRaw is List
+        ? contentRaw
+            .whereType<Map<String, dynamic>>()
+            .map(StakingOrderItem.fromJson)
+            .toList()
+        : <StakingOrderItem>[];
+    final totalPages = int.tryParse('${raw['totalPages'] ?? 0}') ?? 0;
+    final rawPageNo = int.tryParse('${raw['pageNo'] ?? ''}');
+    final rawNumber = int.tryParse('${raw['number'] ?? ''}');
+    final parsedPageNo = rawPageNo ?? ((rawNumber ?? (pageNo - 1)) + 1);
+    return StakingOrderPage(
+      content: content,
+      totalPages: totalPages,
+      pageNo: parsedPageNo,
+    );
+  }
+
   Future<StakingWalletBalance> getStakingWalletBalance(String coinUnit) async {
     final resp = await _dio.post<Map<String, dynamic>>(
       'uc/staking/wallet',

+ 20 - 4
lib/presentation/screens/asset/asset_overview_tab.dart

@@ -12,10 +12,15 @@ import 'asset_screen.dart' show AssetAccountRow;
 
 /// 总览 Tab
 class AssetOverviewTab extends ConsumerWidget {
-  const AssetOverviewTab(
-      {super.key, required this.state, required this.notifier});
+  const AssetOverviewTab({
+    super.key,
+    required this.state,
+    required this.notifier,
+    this.onNavigateToStaking,
+  });
   final AssetState state;
   final AssetNotifier notifier;
+  final VoidCallback? onNavigateToStaking;
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
@@ -134,7 +139,10 @@ class AssetOverviewTab extends ConsumerWidget {
             ),
           ),
           const SizedBox(height: 24),
-          _AccountSection(state: state),
+          _AccountSection(
+            state: state,
+            onStakingTap: onNavigateToStaking,
+          ),
           const SizedBox(height: 32),
         ],
       ),
@@ -181,8 +189,9 @@ class _ActionChip extends StatelessWidget {
 }
 
 class _AccountSection extends StatelessWidget {
-  const _AccountSection({required this.state});
+  const _AccountSection({required this.state, this.onStakingTap});
   final AssetState state;
+  final VoidCallback? onStakingTap;
 
   String _fmtUsdt(double bal, bool obscure) =>
       obscure ? '******' : '${formatPrice(bal, decimalPlaces: 2)} USDT';
@@ -215,36 +224,42 @@ class _AccountSection extends StatelessWidget {
       String label,
       String amount,
       String usdAmount,
+      VoidCallback? onTap,
     })>[
       (
         icon: Icons.account_balance_wallet_outlined,
         label: l10n.fundAccount,
         amount: _fmtUsdt(fund, obscure),
         usdAmount: _fmtUsd(fund, obscure),
+        onTap: null,
       ),
       (
         icon: Icons.currency_exchange_outlined,
         label: l10n.spotTradingAccount,
         amount: _fmtUsdt(spotTrading, obscure),
         usdAmount: _fmtUsd(spotTrading, obscure),
+        onTap: null,
       ),
       (
         icon: Icons.show_chart,
         label: l10n.futuresAccount,
         amount: _fmtUsdt(swap, obscure),
         usdAmount: _fmtUsd(swap, obscure),
+        onTap: null,
       ),
       (
         icon: Icons.people_alt_outlined,
         label: l10n.copyAccount,
         amount: _fmtUsdt(follow, obscure),
         usdAmount: _fmtUsd(follow, obscure),
+        onTap: null,
       ),
       (
         icon: Icons.lock_outline,
         label: l10n.assetsLockedStakingAccount,
         amount: _fmtIbit(stakingLocked, obscure),
         usdAmount: '',
+        onTap: onStakingTap,
       ),
     ];
 
@@ -265,6 +280,7 @@ class _AccountSection extends StatelessWidget {
               label: row.label,
               amount: row.amount,
               usdAmount: row.usdAmount,
+              onTap: row.onTap,
             ),
             const SizedBox(height: 8),
           ],

+ 29 - 4
lib/presentation/screens/asset/asset_screen.dart

@@ -14,6 +14,7 @@ import 'asset_futures_tab.dart';
 import 'asset_copy_trading_tab.dart';
 import 'asset_spot_tab.dart';
 import 'asset_spot_trading_tab.dart';
+import 'asset_staking_tab.dart';
 
 class AssetScreen extends ConsumerStatefulWidget {
   const AssetScreen({super.key});
@@ -35,13 +36,14 @@ class _AssetScreenState extends ConsumerState<AssetScreen>
       l10n.spotTab,
       l10n.futures,
       l10n.copyTrading,
+      l10n.assetsLockedStakingAccount,
     ];
   }
 
   @override
   void initState() {
     super.initState();
-    _tabController = TabController(length: 5, vsync: this);
+    _tabController = TabController(length: 6, vsync: this);
     _pageController = PageController();
     // 拖动 PageView → 实时更新指示器 offset(平滑插值)
     _pageController.addListener(() {
@@ -95,9 +97,20 @@ class _AssetScreenState extends ConsumerState<AssetScreen>
     if (tabIndex == 4) {
       // 跟单 tab 额外刷新跟单仓位
       ref.read(myCopyTradingProvider.notifier).silentRefresh();
+    } else if (tabIndex == 5) {
+      n.loadStakingOrders();
     }
   }
 
+  void _navigateToTab(int index) {
+    _tabController.animateTo(index);
+    _pageController.animateToPage(
+      index,
+      duration: const Duration(milliseconds: 280),
+      curve: Curves.easeOut,
+    );
+  }
+
   @override
   void dispose() {
     ref.read(assetProvider.notifier).onSpotTradingTabHidden();
@@ -114,6 +127,7 @@ class _AssetScreenState extends ConsumerState<AssetScreen>
       'asset_tab_spot_trading',
       'asset_tab_futures',
       'asset_tab_copy_trading',
+      'asset_tab_staking',
     ];
     return labels[index];
   }
@@ -145,7 +159,7 @@ class _AssetScreenState extends ConsumerState<AssetScreen>
                 labelPadding: const EdgeInsets.symmetric(horizontal: 14),
                 padding: const EdgeInsets.fromLTRB(4, 8, 16, 0),
                 tabs: <Widget>[
-                  for (int i = 0; i < 5; i++)
+                  for (int i = 0; i < 6; i++)
                     Semantics(
                       label: _getTabLabel(i),
                       button: true,
@@ -179,11 +193,16 @@ class _AssetScreenState extends ConsumerState<AssetScreen>
         _tabController.index = index;
       },
       children: [
-        AssetOverviewTab(state: state, notifier: notifier),
+        AssetOverviewTab(
+          state: state,
+          notifier: notifier,
+          onNavigateToStaking: () => _navigateToTab(5),
+        ),
         AssetSpotTab(state: state, notifier: notifier),
         AssetSpotTradingTab(state: state, notifier: notifier),
         AssetFuturesTab(state: state, notifier: notifier),
         AssetCopyTradingTab(state: state, notifier: notifier),
+        AssetStakingTab(state: state, notifier: notifier),
       ],
     );
   }
@@ -460,18 +479,23 @@ class AssetAccountRow extends StatelessWidget {
     required this.amount,
     required this.usdAmount,
     this.onTransfer,
+    this.onTap,
   });
   final IconData icon;
   final String label;
   final String amount;
   final String usdAmount;
   final VoidCallback? onTransfer;
+  final VoidCallback? onTap;
 
   @override
   Widget build(BuildContext context) {
     final cs = Theme.of(context).colorScheme;
     final isDark = Theme.of(context).brightness == Brightness.dark;
-    return Container(
+    return GestureDetector(
+      onTap: onTap,
+      behavior: HitTestBehavior.opaque,
+      child: Container(
       padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
       decoration: BoxDecoration(
         color: isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary,
@@ -522,6 +546,7 @@ class AssetAccountRow extends StatelessWidget {
           ),
         ],
       ),
+    ),
     );
   }
 }

+ 425 - 0
lib/presentation/screens/asset/asset_staking_tab.dart

@@ -0,0 +1,425 @@
+import 'package:flutter/material.dart';
+import 'package:go_router/go_router.dart';
+
+import '../../../core/l10n/app_localizations.dart';
+import '../../../core/theme/app_colors.dart';
+import '../../../core/utils/number_format.dart';
+import '../../../data/models/finance/staking_order_item.dart';
+import '../../../providers/asset_provider.dart';
+import '../../widgets/common/app_refresh_indicator.dart';
+
+/// 锁仓账户 Tab — 对齐 Web AssetsView STAKING Tab
+class AssetStakingTab extends StatelessWidget {
+  const AssetStakingTab({super.key, required this.state, required this.notifier});
+  final AssetState state;
+  final AssetNotifier notifier;
+
+  @override
+  Widget build(BuildContext context) {
+    final cs = Theme.of(context).colorScheme;
+    final l10n = AppLocalizations.of(context)!;
+    final obscure = state.obscureBalance;
+    final locked = double.tryParse(state.stakingOverviewLocked) ?? 0;
+    final available = double.tryParse(state.stakingOverviewAvailable) ?? 0;
+    final totalLocked = locked + available;
+    final lockedDisplay =
+        obscure ? '******' : formatPrice(locked, decimalPlaces: 2);
+    final totalDisplay =
+        obscure ? '******' : formatPrice(totalLocked, decimalPlaces: 2);
+
+    return AppRefreshIndicator(
+      onRefresh: notifier.refreshStakingTab,
+      child: ListView(
+        children: [
+          Padding(
+            padding: const EdgeInsets.fromLTRB(16, 20, 16, 0),
+            child: Container(
+              padding: const EdgeInsets.all(16),
+              decoration: BoxDecoration(
+                color: Theme.of(context).brightness == Brightness.dark
+                    ? AppColors.darkBgSecondary
+                    : AppColors.lightBgSecondary,
+                borderRadius: BorderRadius.circular(12),
+                border: Theme.of(context).brightness == Brightness.dark
+                    ? null
+                    : Border.all(color: AppColors.lightBorder, width: 0.5),
+              ),
+              child: Column(
+                crossAxisAlignment: CrossAxisAlignment.start,
+                children: [
+                  Row(
+                    children: [
+                      Text(
+                        l10n.assetsReleaseTotal,
+                        style: TextStyle(
+                          color: cs.onSurface.withAlpha(153),
+                          fontSize: 13,
+                        ),
+                      ),
+                      const SizedBox(width: 6),
+                      GestureDetector(
+                        onTap: notifier.toggleObscure,
+                        child: Icon(
+                          obscure
+                              ? Icons.visibility_off_outlined
+                              : Icons.visibility_outlined,
+                          size: 16,
+                          color: cs.onSurface.withAlpha(153),
+                        ),
+                      ),
+                    ],
+                  ),
+                  const SizedBox(height: 8),
+                  Row(
+                    crossAxisAlignment: CrossAxisAlignment.end,
+                    children: [
+                      Text(
+                        lockedDisplay,
+                        style: TextStyle(
+                          color: cs.onSurface,
+                          fontSize: 32,
+                          fontWeight: FontWeight.w700,
+                          letterSpacing: -0.5,
+                        ),
+                      ),
+                      const SizedBox(width: 6),
+                      Padding(
+                        padding: const EdgeInsets.only(bottom: 5),
+                        child: Text(
+                          l10n.assetsIbitUnit,
+                          style: TextStyle(
+                            color: cs.onSurface.withAlpha(153),
+                            fontSize: 14,
+                          ),
+                        ),
+                      ),
+                    ],
+                  ),
+                  const SizedBox(height: 8),
+                  Row(
+                    mainAxisAlignment: MainAxisAlignment.spaceBetween,
+                    children: [
+                      Text(
+                        l10n.assetsTotalLockedBalance,
+                        style: TextStyle(
+                          color: cs.onSurface.withAlpha(153),
+                          fontSize: 13,
+                        ),
+                      ),
+                      Text(
+                        totalDisplay,
+                        style: TextStyle(
+                          color: cs.onSurface,
+                          fontSize: 13,
+                          fontWeight: FontWeight.w500,
+                        ),
+                      ),
+                    ],
+                  ),
+                  const SizedBox(height: 16),
+                  Row(
+                    children: [
+                      Expanded(
+                        child: _StakingActionButton(
+                          label: l10n.depositCoin,
+                          primary: true,
+                          onTap: () => context.push('/asset/deposit'),
+                        ),
+                      ),
+                      const SizedBox(width: 8),
+                      Expanded(
+                        child: _StakingActionButton(
+                          label: l10n.withdrawCoin,
+                          onTap: () => context.push('/asset/withdraw'),
+                        ),
+                      ),
+                      const SizedBox(width: 8),
+                      Expanded(
+                        child: _StakingActionButton(
+                          label: l10n.transfer,
+                          onTap: () =>
+                              context.push('/asset/transfer?from=SPOT&to=SWAP'),
+                        ),
+                      ),
+                    ],
+                  ),
+                ],
+              ),
+            ),
+          ),
+          Padding(
+            padding: const EdgeInsets.fromLTRB(16, 20, 16, 8),
+            child: Row(
+              children: [
+                Expanded(
+                  child: Text(
+                    l10n.assetsMyStakingOrders,
+                    style: TextStyle(
+                      color: cs.onSurface,
+                      fontSize: 15,
+                      fontWeight: FontWeight.w600,
+                    ),
+                  ),
+                ),
+                _StakingStatusFilter(
+                  value: state.stakingOrderStatusFilter,
+                  loading: state.stakingOrdersLoading,
+                  onChanged: notifier.setStakingOrderStatusFilter,
+                ),
+              ],
+            ),
+          ),
+          if (state.stakingOrdersLoading && state.stakingOrders.isEmpty)
+            Padding(
+              padding: const EdgeInsets.symmetric(vertical: 32),
+              child: Center(
+                child: SizedBox(
+                  width: 24,
+                  height: 24,
+                  child: CircularProgressIndicator(
+                    strokeWidth: 2,
+                    color: cs.onSurface.withAlpha(120),
+                  ),
+                ),
+              ),
+            )
+          else if (state.stakingOrders.isEmpty)
+            Padding(
+              padding: const EdgeInsets.symmetric(vertical: 32),
+              child: Center(
+                child: Text(
+                  l10n.noData,
+                  style: TextStyle(
+                    color: cs.onSurface.withAlpha(120),
+                    fontSize: 14,
+                  ),
+                ),
+              ),
+            )
+          else
+            ...state.stakingOrders.map(
+              (order) => _StakingOrderCard(order: order, obscure: obscure),
+            ),
+          const SizedBox(height: 32),
+        ],
+      ),
+    );
+  }
+}
+
+class _StakingActionButton extends StatelessWidget {
+  const _StakingActionButton({
+    required this.label,
+    required this.onTap,
+    this.primary = false,
+  });
+  final String label;
+  final VoidCallback onTap;
+  final bool primary;
+
+  @override
+  Widget build(BuildContext context) {
+    final cs = Theme.of(context).colorScheme;
+    return GestureDetector(
+      onTap: onTap,
+      child: Container(
+        height: 40,
+        alignment: Alignment.center,
+        decoration: BoxDecoration(
+          color: primary ? AppColors.brand : cs.onSurface.withAlpha(20),
+          borderRadius: BorderRadius.circular(20),
+        ),
+        child: Text(
+          label,
+          style: TextStyle(
+            color: primary ? Colors.white : cs.onSurface,
+            fontSize: 13,
+            fontWeight: FontWeight.w600,
+          ),
+        ),
+      ),
+    );
+  }
+}
+
+class _StakingStatusFilter extends StatelessWidget {
+  const _StakingStatusFilter({
+    required this.value,
+    required this.loading,
+    required this.onChanged,
+  });
+  final StakingOrderStatusFilter value;
+  final bool loading;
+  final ValueChanged<StakingOrderStatusFilter> onChanged;
+
+  @override
+  Widget build(BuildContext context) {
+    final l10n = AppLocalizations.of(context)!;
+    final cs = Theme.of(context).colorScheme;
+    return Container(
+      padding: const EdgeInsets.symmetric(horizontal: 10),
+      decoration: BoxDecoration(
+        border: Border.all(color: cs.outline.withAlpha(80)),
+        borderRadius: BorderRadius.circular(8),
+      ),
+      child: DropdownButtonHideUnderline(
+        child: DropdownButton<StakingOrderStatusFilter>(
+          value: value,
+          isDense: true,
+          icon: Icon(Icons.expand_more, size: 18, color: cs.onSurface.withAlpha(153)),
+          items: [
+            DropdownMenuItem(
+              value: StakingOrderStatusFilter.all,
+              child: Text(l10n.all, style: const TextStyle(fontSize: 13)),
+            ),
+            DropdownMenuItem(
+              value: StakingOrderStatusFilter.subscribing,
+              child: Text(l10n.assetsSubscribing, style: const TextStyle(fontSize: 13)),
+            ),
+            DropdownMenuItem(
+              value: StakingOrderStatusFilter.releasing,
+              child: Text(l10n.assetsReleasing, style: const TextStyle(fontSize: 13)),
+            ),
+            DropdownMenuItem(
+              value: StakingOrderStatusFilter.completed,
+              child: Text(l10n.completed, style: const TextStyle(fontSize: 13)),
+            ),
+          ],
+          onChanged: loading
+              ? null
+              : (next) {
+                  if (next != null) {
+                    onChanged(next);
+                  }
+                },
+        ),
+      ),
+    );
+  }
+}
+
+class _StakingOrderCard extends StatelessWidget {
+  const _StakingOrderCard({required this.order, required this.obscure});
+  final StakingOrderItem order;
+  final bool obscure;
+
+  String _fmtAmount(double value) {
+    if (obscure) {
+      return '******';
+    }
+    return formatPrice(value, decimalPlaces: 2);
+  }
+
+  String _statusLabel(AppLocalizations l10n) {
+    if (order.status == 0) {
+      return l10n.assetsSubscribing;
+    }
+    if (order.status == 2) {
+      return l10n.completed;
+    }
+    return l10n.assetsReleasing;
+  }
+
+  Color _statusColor() {
+    if (order.status == 0) {
+      return AppColors.brand;
+    }
+    if (order.status == 2) {
+      return Colors.grey;
+    }
+    return Colors.orange;
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final cs = Theme.of(context).colorScheme;
+    final l10n = AppLocalizations.of(context)!;
+    final isDark = Theme.of(context).brightness == Brightness.dark;
+    final coin = order.coinUnit.isNotEmpty ? order.coinUnit : l10n.assetsIbitUnit.toUpperCase();
+    final amount = double.tryParse(order.amount) ?? 0;
+    final released = double.tryParse(order.releasedAmount) ?? 0;
+
+    return Container(
+      margin: const EdgeInsets.fromLTRB(16, 0, 16, 10),
+      padding: const EdgeInsets.all(16),
+      decoration: BoxDecoration(
+        color: isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary,
+        borderRadius: BorderRadius.circular(12),
+        border: isDark ? null : Border.all(color: AppColors.lightBorder, width: 0.5),
+      ),
+      child: Column(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: [
+          Text(
+            '${l10n.assetsStakingOrderNo}: ORD${order.id}',
+            style: TextStyle(
+              color: cs.onSurface.withAlpha(153),
+              fontSize: 12,
+            ),
+          ),
+          const SizedBox(height: 12),
+          _StakingOrderRow(
+            label: l10n.assetsStakingTotal,
+            value: '${_fmtAmount(amount)} $coin',
+          ),
+          _StakingOrderRow(
+            label: l10n.assetsReleased,
+            value: '${_fmtAmount(released)} $coin',
+          ),
+          _StakingOrderRow(
+            label: l10n.assetsPendingRelease,
+            value: '${_fmtAmount(order.pendingAmount)} $coin',
+          ),
+          _StakingOrderRow(
+            label: l10n.startTime,
+            value: order.displayStartTime,
+          ),
+          _StakingOrderRow(
+            label: l10n.statusLabel,
+            value: _statusLabel(l10n),
+            valueColor: _statusColor(),
+          ),
+        ],
+      ),
+    );
+  }
+}
+
+class _StakingOrderRow extends StatelessWidget {
+  const _StakingOrderRow({
+    required this.label,
+    required this.value,
+    this.valueColor,
+  });
+  final String label;
+  final String value;
+  final Color? valueColor;
+
+  @override
+  Widget build(BuildContext context) {
+    final cs = Theme.of(context).colorScheme;
+    return Padding(
+      padding: const EdgeInsets.only(bottom: 8),
+      child: Row(
+        mainAxisAlignment: MainAxisAlignment.spaceBetween,
+        children: [
+          Text(
+            label,
+            style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 13),
+          ),
+          Flexible(
+            child: Text(
+              value,
+              textAlign: TextAlign.right,
+              style: TextStyle(
+                color: valueColor ?? cs.onSurface,
+                fontSize: 13,
+                fontWeight: FontWeight.w500,
+              ),
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+}

+ 2 - 0
lib/presentation/widgets/common/bottom_nav_shell.dart

@@ -62,6 +62,8 @@ class BottomNavShell extends ConsumerWidget {
               ref.read(assetProvider.notifier).silentRefresh();
               if (subTab == 4) {
                 ref.read(myCopyTradingProvider.notifier).silentRefresh();
+              } else if (subTab == 5) {
+                ref.read(assetProvider.notifier).loadStakingOrders();
               }
             }
           });

+ 89 - 9
lib/providers/asset_provider.dart

@@ -9,6 +9,7 @@ import '../data/models/asset/today_pnl.dart';
 import '../data/services/asset_service.dart';
 import '../data/services/futures_service.dart';
 import '../data/services/spot_service.dart';
+import '../data/models/finance/staking_order_item.dart';
 import '../data/models/finance/staking_wallet_balance.dart';
 import '../data/services/staking_service.dart';
 import '../core/utils/spot_transfer_asset.dart';
@@ -33,6 +34,14 @@ class AssetState {
 
   /// 锁仓账户 iBit 冻结量(总览展示,对齐 Web STAKING_LOCKED)
   final String stakingOverviewLocked;
+
+  /// 锁仓账户 iBit 可用量(总锁仓余额计算用)
+  final String stakingOverviewAvailable;
+
+  /// 锁仓订单列表(锁仓 Tab 展示)
+  final List<StakingOrderItem> stakingOrders;
+  final bool stakingOrdersLoading;
+  final StakingOrderStatusFilter stakingOrderStatusFilter;
   final bool hideZeroBalanceInFundTab;
   final bool hideZeroBalanceInSpotTab;
 
@@ -49,6 +58,10 @@ class AssetState {
     this.spotWallets = const [],
     this.fundWallets = const [],
     this.stakingOverviewLocked = '0',
+    this.stakingOverviewAvailable = '0',
+    this.stakingOrders = const [],
+    this.stakingOrdersLoading = false,
+    this.stakingOrderStatusFilter = StakingOrderStatusFilter.all,
     this.hideZeroBalanceInFundTab = false,
     this.hideZeroBalanceInSpotTab = false,
     this.obscureBalance = false,
@@ -122,6 +135,10 @@ class AssetState {
     List<SpotWalletAsset>? spotWallets,
     List<SpotWalletAsset>? fundWallets,
     String? stakingOverviewLocked,
+    String? stakingOverviewAvailable,
+    List<StakingOrderItem>? stakingOrders,
+    bool? stakingOrdersLoading,
+    StakingOrderStatusFilter? stakingOrderStatusFilter,
     bool? hideZeroBalanceInFundTab,
     bool? hideZeroBalanceInSpotTab,
     bool? obscureBalance,
@@ -138,6 +155,13 @@ class AssetState {
         fundWallets: fundWallets ?? this.fundWallets,
         stakingOverviewLocked:
             stakingOverviewLocked ?? this.stakingOverviewLocked,
+        stakingOverviewAvailable:
+            stakingOverviewAvailable ?? this.stakingOverviewAvailable,
+        stakingOrders: stakingOrders ?? this.stakingOrders,
+        stakingOrdersLoading:
+            stakingOrdersLoading ?? this.stakingOrdersLoading,
+        stakingOrderStatusFilter:
+            stakingOrderStatusFilter ?? this.stakingOrderStatusFilter,
         hideZeroBalanceInFundTab:
             hideZeroBalanceInFundTab ?? this.hideZeroBalanceInFundTab,
         hideZeroBalanceInSpotTab:
@@ -151,6 +175,9 @@ class AssetState {
 class AssetNotifier extends AutoDisposeNotifier<AssetState> {
   Timer? _pollTimer;
   StreamSubscription<Map<String, dynamic>>? _spotAssetWsSub;
+  int _stakingOrdersRequestSeq = 0;
+
+  static const int _stakingOrderPreviewSize = 5;
 
   bool _spotTradingTabHttpBootstrapped = false;
   bool _spotAssetChannelRetained = false;
@@ -219,6 +246,9 @@ class AssetNotifier extends AutoDisposeNotifier<AssetState> {
       } else if (prev == 2) {
         onSpotTradingTabHidden();
       }
+      if (subTab == 5 && ref.read(isLoggedInProvider)) {
+        Future.microtask(() => loadStakingOrders());
+      }
     });
 
     ref.listen<int>(activeBottomTabProvider, (prev, tabIndex) {
@@ -399,13 +429,14 @@ class AssetNotifier extends AutoDisposeNotifier<AssetState> {
       final fundWallets = _parseSpotWallets(fundData);
 
       final stakingWallet = results[4] as StakingWalletBalance;
-      var stakingLocked =
-          stakingOverviewLockedFromAccounts(todayPnl.accountInfoList);
-      final apiLocked = stakingWallet.lockedBalance;
-      if ((double.tryParse(stakingLocked) ?? 0) <= 0 &&
-          (double.tryParse(apiLocked) ?? 0) > 0) {
-        stakingLocked = apiLocked;
-      }
+      final accounts = todayPnl.accountInfoList;
+      final walletAvailable = stakingOverviewAvailableFromAccounts(accounts);
+      final walletLocked = stakingOverviewLockedFromAccounts(accounts);
+      final merged = mergeStakingOverviewBalances(
+        walletAvailable: walletAvailable,
+        walletLocked: walletLocked,
+        apiWallet: stakingWallet,
+      );
 
       state = state.copyWith(
         todayPnl: todayPnl,
@@ -415,7 +446,8 @@ class AssetNotifier extends AutoDisposeNotifier<AssetState> {
         spotTodayPnlRate: spotPnlRate,
         spotWallets: spotWallets,
         fundWallets: fundWallets,
-        stakingOverviewLocked: stakingLocked,
+        stakingOverviewLocked: merged.locked,
+        stakingOverviewAvailable: merged.available,
         isLoading: false,
       );
     } catch (e) {
@@ -435,6 +467,54 @@ class AssetNotifier extends AutoDisposeNotifier<AssetState> {
 
   Future<void> silentRefresh() => loadAssets(silent: true);
 
+  /// 锁仓 Tab 刷新:资产 + 订单
+  Future<void> refreshStakingTab() async {
+    await Future.wait([
+      loadAssets(silent: true),
+      loadStakingOrders(),
+    ]);
+  }
+
+  Future<void> loadStakingOrders({bool clear = false}) async {
+    if (!ref.read(isLoggedInProvider)) {
+      return;
+    }
+    final requestSeq = ++_stakingOrdersRequestSeq;
+    if (clear) {
+      state = state.copyWith(stakingOrders: const [], stakingOrdersLoading: true);
+    } else {
+      state = state.copyWith(stakingOrdersLoading: true);
+    }
+    try {
+      final dio = ref.read(dioClientProvider);
+      final page = await StakingService(dio).getStakingOrders(
+        pageNo: 1,
+        pageSize: _stakingOrderPreviewSize,
+        status: state.stakingOrderStatusFilter.apiStatus,
+      );
+      if (requestSeq != _stakingOrdersRequestSeq) {
+        return;
+      }
+      state = state.copyWith(
+        stakingOrders: page.content,
+        stakingOrdersLoading: false,
+      );
+    } catch (_) {
+      if (requestSeq != _stakingOrdersRequestSeq) {
+        return;
+      }
+      state = state.copyWith(stakingOrders: const [], stakingOrdersLoading: false);
+    }
+  }
+
+  void setStakingOrderStatusFilter(StakingOrderStatusFilter filter) {
+    if (state.stakingOrderStatusFilter == filter) {
+      return;
+    }
+    state = state.copyWith(stakingOrderStatusFilter: filter);
+    Future.microtask(() => loadStakingOrders(clear: true));
+  }
+
   void toggleObscure() =>
       state = state.copyWith(obscureBalance: !state.obscureBalance);
 
@@ -468,5 +548,5 @@ final assetProvider = AutoDisposeNotifierProvider<AssetNotifier, AssetState>(
   AssetNotifier.new,
 );
 
-/// 资产页子 tab:0 总览 1 现货 2 合约…
+/// 资产页子 tab:0 总览 1 资金 2 现货交易 3 合约 4 跟单 5 锁仓
 final currentAssetSubTabProvider = StateProvider<int>((ref) => 0);