futures_history_screen.dart 55 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter_riverpod/flutter_riverpod.dart';
  3. import 'package:go_router/go_router.dart';
  4. import '../../../core/l10n/app_localizations.dart';
  5. import '../../../core/network/dio_client.dart';
  6. import '../../../core/theme/app_colors.dart';
  7. import '../../../data/services/futures_service.dart';
  8. import '../../widgets/common/app_refresh_indicator.dart';
  9. import '../../widgets/common/app_shimmer.dart';
  10. import '../../widgets/common/app_tab_bar.dart';
  11. // ── 分页状态 ────────────────────────────────────────────────
  12. class _PageState {
  13. final List<Map<String, dynamic>> items;
  14. final bool isLoading;
  15. final bool hasMore;
  16. final int page;
  17. final String? error;
  18. const _PageState({
  19. this.items = const [],
  20. this.isLoading = false,
  21. this.hasMore = true,
  22. this.page = 0,
  23. this.error,
  24. });
  25. _PageState copyWith({
  26. List<Map<String, dynamic>>? items,
  27. bool? isLoading,
  28. bool? hasMore,
  29. int? page,
  30. String? error,
  31. }) =>
  32. _PageState(
  33. items: items ?? this.items,
  34. isLoading: isLoading ?? this.isLoading,
  35. hasMore: hasMore ?? this.hasMore,
  36. page: page ?? this.page,
  37. error: error,
  38. );
  39. }
  40. // ── 历史持仓分页 Notifier ───────────────────────────────────
  41. class _PositionHistoryNotifier
  42. extends AutoDisposeNotifier<_PageState> {
  43. static const _pageSize = 10;
  44. @override
  45. _PageState build() {
  46. Future.microtask(loadMore);
  47. return const _PageState();
  48. }
  49. Future<void> loadMore() async {
  50. final s = state;
  51. if (s.isLoading || !s.hasMore) return;
  52. state = s.copyWith(isLoading: true);
  53. try {
  54. final svc = FuturesService(ref.read(dioClientProvider));
  55. final result = await svc.getPositionHistory(
  56. pageNo: s.page + 1,
  57. pageSize: _pageSize,
  58. );
  59. state = state.copyWith(
  60. items: [...s.items, ...result.items],
  61. hasMore: result.hasMore,
  62. page: s.page + 1,
  63. isLoading: false,
  64. );
  65. } catch (e) {
  66. state = state.copyWith(isLoading: false, error: e.toString());
  67. }
  68. }
  69. Future<void> refresh() async {
  70. state = const _PageState();
  71. await loadMore();
  72. }
  73. }
  74. final _positionHistoryProvider =
  75. NotifierProvider.autoDispose<_PositionHistoryNotifier, _PageState>(
  76. _PositionHistoryNotifier.new,
  77. );
  78. // ── 历史委托分页 Notifier ───────────────────────────────────
  79. // 使用 history-all 接口,一次返回所有历史委托(ContractOrderEntrust)
  80. // 包含:开仓委托(含撤销/失败)+ 平仓委托(成功)
  81. class _OrderHistoryNotifier
  82. extends AutoDisposeNotifier<_PageState> {
  83. static const _pageSize = 10;
  84. @override
  85. _PageState build() {
  86. Future.microtask(loadMore);
  87. return const _PageState();
  88. }
  89. Future<void> loadMore() async {
  90. final s = state;
  91. if (s.isLoading || !s.hasMore) return;
  92. state = s.copyWith(isLoading: true);
  93. try {
  94. final svc = FuturesService(ref.read(dioClientProvider));
  95. final nextPage = s.page + 1;
  96. final result = await svc.getOrderHistoryAll(
  97. pageNo: nextPage,
  98. pageSize: _pageSize,
  99. );
  100. state = state.copyWith(
  101. items: [...s.items, ...result.items],
  102. hasMore: result.hasMore,
  103. page: nextPage,
  104. isLoading: false,
  105. );
  106. } catch (e) {
  107. state = state.copyWith(isLoading: false, error: e.toString());
  108. }
  109. }
  110. Future<void> refresh() async {
  111. state = const _PageState();
  112. await loadMore();
  113. }
  114. }
  115. final _orderHistoryProvider =
  116. NotifierProvider.autoDispose<_OrderHistoryNotifier, _PageState>(
  117. _OrderHistoryNotifier.new,
  118. );
  119. // ── Screen ─────────────────────────────────────────────────
  120. class FuturesHistoryScreen extends ConsumerStatefulWidget {
  121. const FuturesHistoryScreen({super.key});
  122. @override
  123. ConsumerState<FuturesHistoryScreen> createState() =>
  124. _FuturesHistoryScreenState();
  125. }
  126. class _FuturesHistoryScreenState extends ConsumerState<FuturesHistoryScreen>
  127. with SingleTickerProviderStateMixin {
  128. late final TabController _tabCtrl;
  129. late final PageController _pageCtrl;
  130. @override
  131. void initState() {
  132. super.initState();
  133. _tabCtrl = TabController(length: 2, vsync: this);
  134. _pageCtrl = PageController();
  135. // Tab 点击 → 驱动 PageView 动画
  136. _tabCtrl.addListener(() {
  137. if (!_tabCtrl.indexIsChanging) return;
  138. _pageCtrl.animateToPage(
  139. _tabCtrl.index,
  140. duration: const Duration(milliseconds: 280),
  141. curve: Curves.easeOut,
  142. );
  143. });
  144. // 拖动 PageView → 实时更新指示器 offset(平滑插值)
  145. _pageCtrl.addListener(() {
  146. if (!_pageCtrl.hasClients) return;
  147. if (_tabCtrl.indexIsChanging) return;
  148. final page = _pageCtrl.page!;
  149. final offset = page - _tabCtrl.index;
  150. if (offset.abs() <= 1.0) {
  151. _tabCtrl.offset = offset.clamp(-1.0, 1.0);
  152. }
  153. });
  154. }
  155. @override
  156. void dispose() {
  157. _tabCtrl.dispose();
  158. _pageCtrl.dispose();
  159. super.dispose();
  160. }
  161. @override
  162. Widget build(BuildContext context) {
  163. final cs = Theme.of(context).colorScheme;
  164. final isDark = Theme.of(context).brightness == Brightness.dark;
  165. return Scaffold(
  166. backgroundColor:
  167. isDark ? AppColors.darkBg : AppColors.lightBgSecondary,
  168. appBar: AppBar(
  169. backgroundColor: isDark ? AppColors.darkBgSecondary : Colors.white,
  170. elevation: 0,
  171. leading: IconButton(
  172. icon: Icon(Icons.chevron_left, color: cs.onSurface, size: 28),
  173. onPressed: () => Navigator.pop(context),
  174. ),
  175. title: TabBar(
  176. controller: _tabCtrl,
  177. tabs: [
  178. Tab(text: AppLocalizations.of(context)!.historicalPositions),
  179. Tab(text: AppLocalizations.of(context)!.historicalOrders),
  180. ],
  181. indicator: StretchTabIndicator(
  182. controller: _tabCtrl,
  183. color: AppColors.brand,
  184. ),
  185. indicatorSize: TabBarIndicatorSize.label,
  186. dividerColor: Colors.transparent,
  187. ),
  188. titleSpacing: 0,
  189. bottom: PreferredSize(
  190. preferredSize: const Size.fromHeight(1),
  191. child: Divider(
  192. height: 1,
  193. color: isDark ? AppColors.darkDivider : AppColors.lightDivider,
  194. ),
  195. ),
  196. ),
  197. body: PageView(
  198. controller: _pageCtrl,
  199. physics: const BouncingScrollPhysics(
  200. parent: AlwaysScrollableScrollPhysics(),
  201. ),
  202. onPageChanged: (index) {
  203. if (_tabCtrl.indexIsChanging) return;
  204. _tabCtrl.index = index;
  205. },
  206. children: const [
  207. _PositionHistoryTab(),
  208. _OrderHistoryTab(),
  209. ],
  210. ),
  211. );
  212. }
  213. }
  214. // ── 历史持仓 Tab ──────────────────────────────────────────
  215. class _PositionHistoryTab extends ConsumerStatefulWidget {
  216. const _PositionHistoryTab();
  217. @override
  218. ConsumerState<_PositionHistoryTab> createState() =>
  219. _PositionHistoryTabState();
  220. }
  221. class _PositionHistoryTabState extends ConsumerState<_PositionHistoryTab> {
  222. final _scroll = ScrollController();
  223. @override
  224. void initState() {
  225. super.initState();
  226. _scroll.addListener(_onScroll);
  227. }
  228. @override
  229. void dispose() {
  230. _scroll.removeListener(_onScroll);
  231. _scroll.dispose();
  232. super.dispose();
  233. }
  234. void _onScroll() {
  235. if (_scroll.position.pixels >=
  236. _scroll.position.maxScrollExtent - 200) {
  237. ref.read(_positionHistoryProvider.notifier).loadMore();
  238. }
  239. }
  240. @override
  241. Widget build(BuildContext context) {
  242. final s = ref.watch(_positionHistoryProvider);
  243. if (s.items.isEmpty && s.isLoading) {
  244. return const _HistoryListShimmer(isPosition: true);
  245. }
  246. if (s.items.isEmpty && !s.isLoading) {
  247. return _EmptyHint(message: s.error != null ? AppLocalizations.of(context)!.loadFailedRetry : null);
  248. }
  249. return AppRefreshIndicator(
  250. onRefresh: () =>
  251. ref.read(_positionHistoryProvider.notifier).refresh(),
  252. child: ListView.separated(
  253. controller: _scroll,
  254. padding: const EdgeInsets.fromLTRB(12, 12, 12, 24),
  255. itemCount: s.items.length + 1,
  256. separatorBuilder: (_, __) => const SizedBox(height: 12),
  257. itemBuilder: (ctx, i) {
  258. if (i == s.items.length) {
  259. return _LoadMoreFooter(isLoading: s.isLoading, hasMore: s.hasMore);
  260. }
  261. return _PositionHistoryCard(data: s.items[i]);
  262. },
  263. ),
  264. );
  265. }
  266. }
  267. // ── 历史委托 Tab ──────────────────────────────────────────
  268. class _OrderHistoryTab extends ConsumerStatefulWidget {
  269. const _OrderHistoryTab();
  270. @override
  271. ConsumerState<_OrderHistoryTab> createState() => _OrderHistoryTabState();
  272. }
  273. class _OrderHistoryTabState extends ConsumerState<_OrderHistoryTab> {
  274. final _scroll = ScrollController();
  275. @override
  276. void initState() {
  277. super.initState();
  278. _scroll.addListener(_onScroll);
  279. }
  280. @override
  281. void dispose() {
  282. _scroll.removeListener(_onScroll);
  283. _scroll.dispose();
  284. super.dispose();
  285. }
  286. void _onScroll() {
  287. if (_scroll.position.pixels >=
  288. _scroll.position.maxScrollExtent - 200) {
  289. ref.read(_orderHistoryProvider.notifier).loadMore();
  290. }
  291. }
  292. @override
  293. Widget build(BuildContext context) {
  294. final s = ref.watch(_orderHistoryProvider);
  295. if (s.items.isEmpty && s.isLoading) {
  296. return const _HistoryListShimmer(isPosition: false);
  297. }
  298. if (s.items.isEmpty && !s.isLoading) {
  299. return _EmptyHint(message: s.error != null ? AppLocalizations.of(context)!.loadFailedRetry : null);
  300. }
  301. return AppRefreshIndicator(
  302. onRefresh: () =>
  303. ref.read(_orderHistoryProvider.notifier).refresh(),
  304. child: ListView.separated(
  305. controller: _scroll,
  306. padding: const EdgeInsets.fromLTRB(12, 12, 12, 24),
  307. itemCount: s.items.length + 1,
  308. separatorBuilder: (_, __) => const SizedBox(height: 12),
  309. itemBuilder: (ctx, i) {
  310. if (i == s.items.length) {
  311. return _LoadMoreFooter(isLoading: s.isLoading, hasMore: s.hasMore);
  312. }
  313. return _OrderHistoryCard(data: s.items[i]);
  314. },
  315. ),
  316. );
  317. }
  318. }
  319. // ── 持仓历史卡片 ──────────────────────────────────────────
  320. class _PositionHistoryCard extends StatelessWidget {
  321. const _PositionHistoryCard({required this.data});
  322. final Map<String, dynamic> data;
  323. @override
  324. Widget build(BuildContext context) {
  325. final cs = Theme.of(context).colorScheme;
  326. final isDark = Theme.of(context).brightness == Brightness.dark;
  327. final l10n = AppLocalizations.of(context)!;
  328. // symbol 优先读嵌套 coin 对象(持仓历史结构),兼容委托历史平铺字段
  329. final coinObj = data['coin'] as Map<String, dynamic>?;
  330. final symbol = _str(coinObj?['symbol'] ?? data['symbol'] ?? data['coinSymbol'] ?? '');
  331. final dirInfo = _directionInfo(data['direction'], null, l10n);
  332. final dirColor = dirInfo.isGreen ? AppColors.rise : AppColors.fall;
  333. final openTypeLabel = _mapOrderType(data['openType'], l10n);
  334. final closeTypeLabel = _mapCloseType(data['closeType'], l10n);
  335. final leverage = (_toDouble(data['leverage'] ?? 0)).toInt();
  336. final positionTypeLabel = _mapPositionType(
  337. data['type'] ?? data['positionType'], data['patterns'], l10n);
  338. final positionTypeTagColor = _positionTypeColor(
  339. data['type'] ?? data['positionType'], data['patterns']);
  340. final status = _str(data['status'] ?? '');
  341. final statusLabel = _mapStatus(status, l10n, isPosition: true);
  342. final statusColor = _statusColor(status);
  343. final coinLabel = symbol.contains('/')
  344. ? symbol.split('/').first
  345. : symbol.replaceAll(RegExp(r'USDT$|BUSD$|BTC$|ETH$'), '').isNotEmpty
  346. ? symbol.replaceAll(RegExp(r'USDT$|BUSD$'), '')
  347. : coinObj?['coinSymbol']?.toString() ?? 'BTC';
  348. final totalVolume = _toDouble(data['totalPosition'] ?? data['volume'] ?? 0);
  349. final dealVolume = _toDouble(data['closedPosition'] ?? data['tradedVolume'] ?? data['dealVolume'] ?? 0);
  350. final openAvgPrice = _toDouble(data['openPrice'] ?? data['usdtOpenPrice'] ?? 0);
  351. final closeAvgPrice = _toDouble(data['closePrice'] ?? data['tradedPrice'] ?? 0);
  352. final profitLoss = _toDouble(data['usdtProfit'] ?? data['profitAndLoss'] ?? 0);
  353. final totalPrincipal = _toDouble(data['totalPrincipalAmount'] ?? data['principalAmount'] ?? 0);
  354. final profitRate =
  355. totalPrincipal > 0 ? (profitLoss / totalPrincipal * 100) : 0.0;
  356. final openTime = _formatTime(data['openTime'] ?? data['usdtOpenTime'] ?? data['createTime']);
  357. final closeTime = _formatTime(data['closeTime'] ?? data['dealTime']);
  358. final cardBg = isDark ? AppColors.darkBgSecondary : Colors.white;
  359. final dividerColor =
  360. isDark ? AppColors.darkDivider : AppColors.lightDivider;
  361. return GestureDetector(
  362. onTap: () {
  363. final uri = GoRouterState.of(context).uri.toString();
  364. context.push('$uri/position-detail', extra: data);
  365. },
  366. child: Container(
  367. decoration: BoxDecoration(
  368. color: cardBg,
  369. borderRadius: BorderRadius.circular(12),
  370. boxShadow: [
  371. BoxShadow(
  372. color: Colors.black.withAlpha(isDark ? 40 : 15),
  373. blurRadius: 6,
  374. offset: const Offset(0, 1),
  375. ),
  376. ],
  377. ),
  378. child: Column(
  379. crossAxisAlignment: CrossAxisAlignment.start,
  380. children: [
  381. // ── 头部:币对名 + 状态 ─────────────────────────
  382. Padding(
  383. padding: const EdgeInsets.fromLTRB(14, 14, 14, 10),
  384. child: Row(
  385. children: [
  386. Expanded(
  387. child: Row(
  388. children: [
  389. Text(
  390. symbol.isNotEmpty ? '$symbol ${AppLocalizations.of(context)!.perpetual}' : '--',
  391. style: TextStyle(
  392. color: cs.onSurface,
  393. fontSize: 15,
  394. fontWeight: FontWeight.w700,
  395. ),
  396. ),
  397. const SizedBox(width: 2),
  398. Icon(Icons.chevron_right,
  399. color: cs.onSurface.withAlpha(100), size: 16),
  400. ],
  401. ),
  402. ),
  403. Text(
  404. statusLabel,
  405. style: TextStyle(
  406. color: statusColor,
  407. fontSize: 13,
  408. fontWeight: FontWeight.w600,
  409. ),
  410. ),
  411. ],
  412. ),
  413. ),
  414. // ── 标签行 ─────────────────────────────────────
  415. Padding(
  416. padding: const EdgeInsets.fromLTRB(14, 0, 14, 12),
  417. child: Wrap(
  418. spacing: 6,
  419. runSpacing: 6,
  420. children: [
  421. _Tag(label: dirInfo.label, tagStyle: _TagStyle.direction, color: dirColor),
  422. if (openTypeLabel.isNotEmpty)
  423. _Tag(label: openTypeLabel, tagStyle: _TagStyle.orderType),
  424. if (closeTypeLabel.isNotEmpty)
  425. _Tag(label: closeTypeLabel, tagStyle: _TagStyle.closeType),
  426. _Tag(label: positionTypeLabel, tagStyle: _TagStyle.positionType, color: positionTypeTagColor),
  427. if (leverage > 0)
  428. _Tag(label: '${leverage}x', tagStyle: _TagStyle.gray),
  429. ],
  430. ),
  431. ),
  432. Divider(height: 1, color: dividerColor),
  433. // ── 数据行 1:委托总量 | 开仓均价 | 收益 ──────────
  434. Padding(
  435. padding: const EdgeInsets.fromLTRB(14, 10, 14, 6),
  436. child: Row(
  437. children: [
  438. _DataField(
  439. label: '${l10n.entrustTotal}($coinLabel)',
  440. value: _rawNum(totalVolume),
  441. align: CrossAxisAlignment.start,
  442. ),
  443. _DataField(
  444. label: '${l10n.openAvgPrice}(USDT)',
  445. value: openAvgPrice > 0 ? _rawNum(openAvgPrice) : '--',
  446. align: CrossAxisAlignment.center,
  447. ),
  448. _DataField(
  449. label: '${l10n.profitLabel}(USDT)',
  450. value: profitLoss != 0
  451. ? '${profitLoss >= 0 ? '+' : ''}${_rawNum(profitLoss)}'
  452. : '--',
  453. valueColor: profitLoss != 0
  454. ? AppColors.changeColor(profitLoss)
  455. : null,
  456. align: CrossAxisAlignment.end,
  457. ),
  458. ],
  459. ),
  460. ),
  461. // ── 数据行 2:已成交量 | 平仓均价 | 收益率 ─────────
  462. Padding(
  463. padding: const EdgeInsets.fromLTRB(14, 0, 14, 12),
  464. child: Row(
  465. children: [
  466. _DataField(
  467. label: '${l10n.filledVolume}($coinLabel)',
  468. value: _rawNum(dealVolume),
  469. align: CrossAxisAlignment.start,
  470. ),
  471. _DataField(
  472. label: '${l10n.closeAvgPrice}(USDT)',
  473. value: closeAvgPrice > 0 ? _rawNum(closeAvgPrice) : '--',
  474. align: CrossAxisAlignment.center,
  475. ),
  476. _DataField(
  477. label: l10n.profitRateLabel,
  478. value: profitRate != 0
  479. ? '${profitRate >= 0 ? '+' : ''}${profitRate.toStringAsFixed(2)}%'
  480. : '--',
  481. valueColor: profitRate != 0
  482. ? AppColors.changeColor(profitRate)
  483. : null,
  484. align: CrossAxisAlignment.end,
  485. ),
  486. ],
  487. ),
  488. ),
  489. Divider(height: 1, color: dividerColor),
  490. // ── 时间行 ─────────────────────────────────────
  491. Padding(
  492. padding: const EdgeInsets.fromLTRB(14, 10, 14, 12),
  493. child: Row(
  494. children: [
  495. Expanded(
  496. child: _TimeBlock(
  497. label: l10n.openTime,
  498. time: openTime,
  499. align: CrossAxisAlignment.start,
  500. ),
  501. ),
  502. _TimeBlock(
  503. label: l10n.closeTime,
  504. time: closeTime,
  505. align: CrossAxisAlignment.end,
  506. ),
  507. ],
  508. ),
  509. ),
  510. ],
  511. ),
  512. ),
  513. );
  514. }
  515. }
  516. // ── 委托历史卡片 ──────────────────────────────────────────
  517. class _OrderHistoryCard extends StatelessWidget {
  518. const _OrderHistoryCard({required this.data});
  519. final Map<String, dynamic> data;
  520. @override
  521. Widget build(BuildContext context) {
  522. final cs = Theme.of(context).colorScheme;
  523. final isDark = Theme.of(context).brightness == Brightness.dark;
  524. final l10n = AppLocalizations.of(context)!;
  525. // history-all 返回 ContractOrderEntrust,symbol 直接在顶层
  526. final symbol = _str(data['symbol'] ?? data['coinSymbol'] ?? '');
  527. final dirInfo = _directionInfo(data['direction'], data['entrustType'], l10n);
  528. final dirColor = dirInfo.isGreen ? AppColors.rise : AppColors.fall;
  529. final openTypeLabel = _mapOrderType(data['type'], l10n);
  530. final leverage = (_toDouble(data['leverage'] ?? 0)).toInt();
  531. final positionTypeLabel = _mapPositionType(
  532. data['positionType'], data['patterns'], l10n);
  533. final positionTypeTagColor = _positionTypeColor(
  534. data['positionType'], data['patterns']);
  535. final status = _str(data['status'] ?? '');
  536. final statusLabel = _mapStatus(status, l10n);
  537. final statusColor = _statusColor(status);
  538. final coinLabel = symbol.contains('/')
  539. ? symbol.split('/').first
  540. : symbol.replaceAll(RegExp(r'USDT$|BUSD$'), '').isNotEmpty
  541. ? symbol.replaceAll(RegExp(r'USDT$|BUSD$'), '')
  542. : _str(data['coinSymbol'] ?? 'BTC');
  543. final volume = _toDouble(data['volume'] ?? 0);
  544. final dealVolume = _toDouble(data['tradedVolume'] ?? 0);
  545. final entrustPrice = _toDouble(data['entrustPrice'] ?? 0);
  546. // 开仓均价:开仓单用 tradedPrice(成交均价);平仓单用 usdtOpenPrice(原仓位开仓均价)
  547. final entrustTypeRaw = (data['entrustType']?.toString() ?? '').toUpperCase();
  548. final isCloseOrd = entrustTypeRaw == '1' || entrustTypeRaw == 'CLOSE';
  549. final openAvgPrice = isCloseOrd
  550. ? _toDouble(data['usdtOpenPrice'] ?? data['openPrice'] ?? 0)
  551. : _toDouble(data['tradedPrice'] ?? data['usdtOpenPrice'] ?? 0);
  552. final createTime = _formatTime(data['createTime']);
  553. final dealTime = _formatTime(data['dealTime']);
  554. // 委托价格:市价/计划市价→"市价",限价/计划限价→显示原始委托价
  555. final typeRaw = data['type'];
  556. final entrustPriceStr = _showMarketPrice(typeRaw, entrustPrice)
  557. ? l10n.marketOrderType
  558. : (entrustPrice > 0 ? _rawNum(entrustPrice) : '--');
  559. final cardBg = isDark ? AppColors.darkBgSecondary : Colors.white;
  560. final dividerColor =
  561. isDark ? AppColors.darkDivider : AppColors.lightDivider;
  562. return GestureDetector(
  563. onTap: () {
  564. final uri = GoRouterState.of(context).uri.toString();
  565. context.push('$uri/order-detail', extra: data);
  566. },
  567. child: Container(
  568. decoration: BoxDecoration(
  569. color: cardBg,
  570. borderRadius: BorderRadius.circular(12),
  571. boxShadow: [
  572. BoxShadow(
  573. color: Colors.black.withAlpha(isDark ? 40 : 15),
  574. blurRadius: 6,
  575. offset: const Offset(0, 1),
  576. ),
  577. ],
  578. ),
  579. child: Column(
  580. crossAxisAlignment: CrossAxisAlignment.start,
  581. children: [
  582. // ── 头部:币对名 + 状态 ─────────────────────────
  583. Padding(
  584. padding: const EdgeInsets.fromLTRB(14, 14, 14, 10),
  585. child: Row(
  586. children: [
  587. Expanded(
  588. child: Row(
  589. children: [
  590. Text(
  591. symbol.isNotEmpty ? '$symbol ${AppLocalizations.of(context)!.perpetual}' : '--',
  592. style: TextStyle(
  593. color: cs.onSurface,
  594. fontSize: 15,
  595. fontWeight: FontWeight.w700,
  596. ),
  597. ),
  598. const SizedBox(width: 2),
  599. Icon(Icons.chevron_right,
  600. color: cs.onSurface.withAlpha(100), size: 16),
  601. ],
  602. ),
  603. ),
  604. Text(
  605. statusLabel,
  606. style: TextStyle(
  607. color: statusColor,
  608. fontSize: 13,
  609. fontWeight: FontWeight.w600,
  610. ),
  611. ),
  612. ],
  613. ),
  614. ),
  615. // ── 标签行 ─────────────────────────────────────
  616. Padding(
  617. padding: const EdgeInsets.fromLTRB(14, 0, 14, 12),
  618. child: Wrap(
  619. spacing: 6,
  620. runSpacing: 6,
  621. children: [
  622. _Tag(label: dirInfo.label, tagStyle: _TagStyle.direction, color: dirColor),
  623. if (openTypeLabel.isNotEmpty)
  624. _Tag(label: openTypeLabel, tagStyle: _TagStyle.orderType),
  625. _Tag(label: positionTypeLabel, tagStyle: _TagStyle.positionType, color: positionTypeTagColor),
  626. if (leverage > 0)
  627. _Tag(label: '${leverage}x', tagStyle: _TagStyle.gray),
  628. ],
  629. ),
  630. ),
  631. Divider(height: 1, color: dividerColor),
  632. // ── 数据行 1:委托总量 | 开仓均价 ──────────────────
  633. Padding(
  634. padding: const EdgeInsets.fromLTRB(14, 10, 14, 6),
  635. child: Row(
  636. children: [
  637. _DataField(
  638. label: '${l10n.entrustTotal}($coinLabel)',
  639. value: _rawNum(volume),
  640. align: CrossAxisAlignment.start,
  641. ),
  642. _DataField(
  643. label: '${l10n.openAvgPrice}(USDT)',
  644. value: openAvgPrice > 0 ? _rawNum(openAvgPrice) : '--',
  645. align: CrossAxisAlignment.end,
  646. ),
  647. ],
  648. ),
  649. ),
  650. // ── 数据行 2:已成交量 | 委托价格 ──────────────────
  651. Padding(
  652. padding: const EdgeInsets.fromLTRB(14, 0, 14, 12),
  653. child: Row(
  654. children: [
  655. _DataField(
  656. label: '${l10n.filledVolume}($coinLabel)',
  657. value: _rawNum(dealVolume),
  658. align: CrossAxisAlignment.start,
  659. ),
  660. _DataField(
  661. label: '${l10n.orderPriceLabel}(USDT)',
  662. value: entrustPriceStr,
  663. align: CrossAxisAlignment.end,
  664. ),
  665. ],
  666. ),
  667. ),
  668. Divider(height: 1, color: dividerColor),
  669. // ── 时间行 ─────────────────────────────────────
  670. Padding(
  671. padding: const EdgeInsets.fromLTRB(14, 10, 14, 12),
  672. child: Row(
  673. children: [
  674. Expanded(
  675. child: _TimeBlock(
  676. label: l10n.orderTime,
  677. time: createTime,
  678. align: CrossAxisAlignment.start,
  679. ),
  680. ),
  681. _TimeBlock(
  682. label: l10n.closeTime,
  683. time: dealTime,
  684. align: CrossAxisAlignment.end,
  685. ),
  686. ],
  687. ),
  688. ),
  689. ],
  690. ),
  691. ),
  692. );
  693. }
  694. }
  695. // ── 加载更多底部组件 ───────────────────────────────────────
  696. class _LoadMoreFooter extends StatelessWidget {
  697. const _LoadMoreFooter({required this.isLoading, required this.hasMore});
  698. final bool isLoading;
  699. final bool hasMore;
  700. @override
  701. Widget build(BuildContext context) {
  702. final cs = Theme.of(context).colorScheme;
  703. if (isLoading) {
  704. return const Padding(
  705. padding: EdgeInsets.symmetric(vertical: 16),
  706. child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
  707. );
  708. }
  709. if (!hasMore) {
  710. return Padding(
  711. padding: const EdgeInsets.symmetric(vertical: 16),
  712. child: Center(
  713. child: Text(AppLocalizations.of(context)!.allLoaded,
  714. style: TextStyle(
  715. color: cs.onSurface.withAlpha(100), fontSize: 12)),
  716. ),
  717. );
  718. }
  719. return const SizedBox(height: 16);
  720. }
  721. }
  722. // ── Tag 样式枚举 ───────────────────────────────────────────
  723. enum _TagStyle { direction, orderType, closeType, gray, positionType }
  724. // ── 通用辅助 Widgets ──────────────────────────────────────
  725. class _Tag extends StatelessWidget {
  726. const _Tag({
  727. required this.label,
  728. required this.tagStyle,
  729. this.color,
  730. });
  731. final String label;
  732. final _TagStyle tagStyle;
  733. final Color? color; // 仅 direction 使用
  734. static const _blue = AppColors.tagBlue;
  735. @override
  736. Widget build(BuildContext context) {
  737. final isDark = Theme.of(context).brightness == Brightness.dark;
  738. Color bgColor;
  739. Color textColor;
  740. switch (tagStyle) {
  741. case _TagStyle.direction:
  742. final c = color ?? AppColors.rise;
  743. bgColor = c.withAlpha(30);
  744. textColor = c;
  745. case _TagStyle.orderType:
  746. bgColor = _blue.withAlpha(isDark ? 50 : 30);
  747. textColor = _blue;
  748. case _TagStyle.closeType:
  749. bgColor = isDark ? AppColors.darkBgTertiary : AppColors.darkBgMid;
  750. textColor = isDark ? AppColors.darkTextSecondary : Colors.white;
  751. case _TagStyle.gray:
  752. bgColor = isDark
  753. ? AppColors.darkBgTertiary
  754. : AppColors.lightBgTertiary;
  755. textColor = isDark
  756. ? AppColors.darkTextSecondary
  757. : AppColors.lightTextSecondary;
  758. case _TagStyle.positionType:
  759. final c = color ?? AppColors.tagBlue;
  760. bgColor = c.withAlpha(isDark ? 45 : 25);
  761. textColor = c;
  762. }
  763. return Container(
  764. padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
  765. decoration: BoxDecoration(
  766. color: bgColor,
  767. borderRadius: BorderRadius.circular(4),
  768. ),
  769. child: Text(
  770. label,
  771. style: TextStyle(
  772. color: textColor,
  773. fontSize: 11,
  774. fontWeight: FontWeight.w500,
  775. ),
  776. ),
  777. );
  778. }
  779. }
  780. /// 数据字段(标签 + 值),支持左/中/右对齐
  781. class _DataField extends StatelessWidget {
  782. const _DataField({
  783. required this.label,
  784. required this.value,
  785. this.valueColor,
  786. this.align = CrossAxisAlignment.start,
  787. });
  788. final String label;
  789. final String value;
  790. final Color? valueColor;
  791. final CrossAxisAlignment align;
  792. @override
  793. Widget build(BuildContext context) {
  794. final cs = Theme.of(context).colorScheme;
  795. final isDark = Theme.of(context).brightness == Brightness.dark;
  796. final secondaryText =
  797. isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary;
  798. TextAlign textAlign;
  799. if (align == CrossAxisAlignment.center) {
  800. textAlign = TextAlign.center;
  801. } else if (align == CrossAxisAlignment.end) {
  802. textAlign = TextAlign.right;
  803. } else {
  804. textAlign = TextAlign.left;
  805. }
  806. return Expanded(
  807. child: Column(
  808. crossAxisAlignment: align,
  809. children: [
  810. Text(
  811. label,
  812. style: TextStyle(color: secondaryText, fontSize: 11),
  813. textAlign: textAlign,
  814. ),
  815. const SizedBox(height: 3),
  816. Text(
  817. value,
  818. style: TextStyle(
  819. color: valueColor ?? cs.onSurface,
  820. fontSize: 13,
  821. fontWeight: FontWeight.w600,
  822. ),
  823. textAlign: textAlign,
  824. ),
  825. ],
  826. ),
  827. );
  828. }
  829. }
  830. /// 时间块(标签 + 时间值加粗)
  831. class _TimeBlock extends StatelessWidget {
  832. const _TimeBlock({
  833. required this.label,
  834. required this.time,
  835. this.align = CrossAxisAlignment.start,
  836. });
  837. final String label;
  838. final String time;
  839. final CrossAxisAlignment align;
  840. @override
  841. Widget build(BuildContext context) {
  842. final cs = Theme.of(context).colorScheme;
  843. final isDark = Theme.of(context).brightness == Brightness.dark;
  844. final secondaryText =
  845. isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary;
  846. final textAlign = align == CrossAxisAlignment.end
  847. ? TextAlign.right
  848. : TextAlign.left;
  849. return Column(
  850. crossAxisAlignment: align,
  851. children: [
  852. Text(
  853. label,
  854. style: TextStyle(color: secondaryText, fontSize: 11),
  855. textAlign: textAlign,
  856. ),
  857. const SizedBox(height: 3),
  858. Text(
  859. time,
  860. style: TextStyle(
  861. color: cs.onSurface,
  862. fontSize: 12,
  863. fontWeight: FontWeight.w700,
  864. ),
  865. textAlign: textAlign,
  866. ),
  867. ],
  868. );
  869. }
  870. }
  871. class _EmptyHint extends StatelessWidget {
  872. const _EmptyHint({this.message});
  873. final String? message;
  874. @override
  875. Widget build(BuildContext context) {
  876. final cs = Theme.of(context).colorScheme;
  877. return Center(
  878. child: Column(
  879. mainAxisSize: MainAxisSize.min,
  880. children: [
  881. Icon(Icons.history_outlined,
  882. color: cs.onSurface.withAlpha(80), size: 48),
  883. const SizedBox(height: 8),
  884. Text(message ?? AppLocalizations.of(context)!.noRecord,
  885. style: TextStyle(
  886. color: cs.onSurface.withAlpha(120), fontSize: 13)),
  887. ],
  888. ),
  889. );
  890. }
  891. }
  892. // ── 工具函数 ───────────────────────────────────────────────
  893. String _str(dynamic v) => v?.toString() ?? '';
  894. double _toDouble(dynamic v) {
  895. if (v == null) return 0.0;
  896. if (v is num) return v.toDouble();
  897. return double.tryParse(v.toString()) ?? 0.0;
  898. }
  899. /// 去除多余的尾零,整数不带小数点
  900. String _rawNum(double v) {
  901. if (v == v.truncateToDouble()) return v.toInt().toString();
  902. return v.toString();
  903. }
  904. ({String label, bool isGreen}) _directionInfo(
  905. dynamic direction, dynamic entrustType, AppLocalizations l10n) {
  906. // direction: 0/"BUY" = 买;1/"SELL" = 卖
  907. final dirRaw = direction?.toString() ?? '';
  908. final isBuy = dirRaw == '0' || dirRaw.toUpperCase() == 'BUY';
  909. // entrustType: null/0/"OPEN" = 开仓;1/"CLOSE" = 平仓
  910. final etRaw = entrustType?.toString() ?? '';
  911. final isClose = etRaw == '1' || etRaw.toUpperCase() == 'CLOSE';
  912. if (!isClose) {
  913. return isBuy
  914. ? (label: l10n.longBull, isGreen: true)
  915. : (label: l10n.shortBear, isGreen: false);
  916. } else {
  917. // 平仓委托方向与仓位方向相反:BUY+CLOSE=平空(绿),SELL+CLOSE=平多(红)
  918. return isBuy
  919. ? (label: l10n.closeBull, isGreen: true)
  920. : (label: l10n.closeBear, isGreen: false);
  921. }
  922. }
  923. String _mapOrderType(dynamic raw, AppLocalizations l10n) {
  924. if (raw == null) return '';
  925. final s = raw.toString();
  926. switch (s) {
  927. case '0':
  928. case 'MARKET_PRICE':
  929. case 'MARKET':
  930. return l10n.marketOrderLabel;
  931. case '1':
  932. case 'LIMIT_PRICE':
  933. case 'LIMIT':
  934. return l10n.limitOrderLabel;
  935. case '2':
  936. case 'STOP':
  937. case 'PLAN':
  938. case 'SPOT_LIMIT':
  939. return l10n.planOrderLabel;
  940. case '3':
  941. return l10n.mergeOrderLabel;
  942. default:
  943. return s.isNotEmpty ? s : '';
  944. }
  945. }
  946. String _mapCloseType(dynamic raw, AppLocalizations l10n) {
  947. if (raw == null) return '';
  948. final s = raw.toString();
  949. switch (s) {
  950. case '0':
  951. case 'CLOSE': return l10n.closePosition;
  952. case '1':
  953. case 'MARKET_CLOSE': return l10n.closePositionMarket;
  954. case '2':
  955. case 'ONE_KEY_CLOSE': return l10n.closeAll;
  956. case '3':
  957. case 'REVERSE_OPEN': return l10n.reverseOpen;
  958. case '4':
  959. case 'CUT': return l10n.stopProfitLoss;
  960. case '5':
  961. case 'BLAST': return l10n.liquidationLabel;
  962. case '6':
  963. case 'ADMIN_CLOSE': return l10n.adminForceClose;
  964. default: return s.isNotEmpty ? s : '';
  965. }
  966. }
  967. String _mapPositionType(
  968. dynamic positionType, dynamic patterns, AppLocalizations l10n) {
  969. final pt = (positionType?.toString() ?? '').toUpperCase();
  970. // 0/INTEGRAL=全仓,1/SEPARATE=分仓
  971. if (pt == '0' || pt == 'INTEGRAL') return l10n.crossMargin;
  972. if (pt == '1' || pt == 'SEPARATE') return l10n.splitMargin;
  973. final p = (patterns?.toString() ?? '').toUpperCase();
  974. if (p == 'CROSSED') return l10n.crossMargin;
  975. if (p == 'ISOLATED' || p == 'SEPARATE') return l10n.splitMargin;
  976. return l10n.crossMargin;
  977. }
  978. /// 根据原始 positionType / patterns 返回标签颜色
  979. Color _positionTypeColor(dynamic positionType, dynamic patterns) {
  980. final pt = (positionType?.toString() ?? '').toUpperCase();
  981. if (pt == '1' || pt == 'SEPARATE') return AppColors.rankPurple; // 分仓
  982. final p = (patterns?.toString() ?? '').toUpperCase();
  983. if (p == 'ISOLATED' || p == 'SEPARATE') return AppColors.rankPurple; // 分仓
  984. return AppColors.tagBlue; // 全仓
  985. }
  986. String _mapStatus(String status, AppLocalizations l10n, {bool isPosition = false}) {
  987. switch (status.toUpperCase()) {
  988. case 'ENTRUST_ING':
  989. case 'OPEN':
  990. case 'STARTED':
  991. return l10n.orderPending;
  992. case 'ENTRUST_SUCCESS':
  993. case 'FILLED':
  994. case 'DONE': // MemberContractPosition: 全部平仓
  995. case 'PARTLYDONE': // MemberContractPosition: 部分平仓
  996. return isPosition ? l10n.tradingSuccess : l10n.orderFilled;
  997. case 'ENTRUST_CANCEL':
  998. case 'CANCELLED':
  999. return l10n.orderCancelledLabel;
  1000. case 'ENTRUST_FAILURE':
  1001. case 'FAILED':
  1002. return l10n.orderFailedLabel;
  1003. default:
  1004. return status.isNotEmpty
  1005. ? status
  1006. : (isPosition ? l10n.tradingSuccess : l10n.orderFilled);
  1007. }
  1008. }
  1009. Color _statusColor(String status) {
  1010. switch (status.toUpperCase()) {
  1011. // 成功/进行中 → 绿色
  1012. case 'ENTRUST_SUCCESS':
  1013. case 'FILLED':
  1014. case 'DONE':
  1015. case 'PARTLYDONE':
  1016. case 'ENTRUST_ING':
  1017. case 'OPEN':
  1018. case 'STARTED':
  1019. return AppColors.rise;
  1020. // 撤销/失败 → 红色
  1021. case 'ENTRUST_CANCEL':
  1022. case 'CANCELLED':
  1023. case 'ENTRUST_FAILURE':
  1024. case 'FAILED':
  1025. return AppColors.fall;
  1026. default:
  1027. return AppColors.fall;
  1028. }
  1029. }
  1030. /// 判断是否为市价单(type=0/MARKET)
  1031. bool _isMarketType(dynamic raw) {
  1032. if (raw == null) return false;
  1033. final s = raw.toString();
  1034. return s == '0' || s == 'MARKET_PRICE' || s == 'MARKET';
  1035. }
  1036. /// 是否展示"市价":普通市价,或计划委托 entrustPrice=0(计划市价)
  1037. bool _showMarketPrice(dynamic type, double entrustPrice) {
  1038. if (_isMarketType(type)) return true;
  1039. final s = (type?.toString() ?? '').toUpperCase();
  1040. final isPlan = s == '2' || s == 'SPOT_LIMIT' || s == 'PLAN';
  1041. return isPlan && entrustPrice == 0;
  1042. }
  1043. String _formatTime(dynamic raw) {
  1044. if (raw == null) return '--';
  1045. final ts = raw is num ? raw.toInt() : int.tryParse(raw.toString());
  1046. if (ts == null || ts == 0) return '--';
  1047. final dt = DateTime.fromMillisecondsSinceEpoch(ts);
  1048. final mo = dt.month.toString().padLeft(2, '0');
  1049. final d = dt.day.toString().padLeft(2, '0');
  1050. final h = dt.hour.toString().padLeft(2, '0');
  1051. final mi = dt.minute.toString().padLeft(2, '0');
  1052. final se = dt.second.toString().padLeft(2, '0');
  1053. return '${dt.year}-$mo-$d $h:$mi:$se';
  1054. }
  1055. // ═══════════════════════════════════════════════════════════
  1056. // 历史持仓详情页
  1057. // ═══════════════════════════════════════════════════════════
  1058. class PositionHistoryDetailScreen extends StatelessWidget {
  1059. const PositionHistoryDetailScreen({super.key, required this.data});
  1060. final Map<String, dynamic> data;
  1061. @override
  1062. Widget build(BuildContext context) {
  1063. final cs = Theme.of(context).colorScheme;
  1064. final l10n = AppLocalizations.of(context)!;
  1065. final coinObj = data['coin'] as Map<String, dynamic>?;
  1066. final rawSym = _str(coinObj?['symbol'] ?? data['symbol'] ?? data['coinSymbol'] ?? '');
  1067. final coinLabel = rawSym.contains('/')
  1068. ? rawSym.split('/').first
  1069. : rawSym.replaceAll(RegExp(r'USDT$|BUSD$'), '').isNotEmpty
  1070. ? rawSym.replaceAll(RegExp(r'USDT$|BUSD$'), '')
  1071. : coinObj?['coinSymbol']?.toString() ?? 'BTC';
  1072. final posType = _mapPositionType(
  1073. data['type'] ?? data['positionType'], data['patterns'], l10n);
  1074. // 持仓详情无 entrustType,按仓位方向显示
  1075. final dirInfo = _directionInfo(data['direction'], null, l10n);
  1076. final dirColor = dirInfo.isGreen ? AppColors.rise : AppColors.fall;
  1077. final orderType = _mapOrderType(data['openType'] ?? data['type'], l10n);
  1078. final leverage = (_toDouble(data['leverage'])).toInt();
  1079. final openTime = _formatTime(data['openTime'] ?? data['usdtOpenTime'] ?? data['createTime']);
  1080. final closeTime = _formatTime(data['closeTime'] ?? data['dealTime']);
  1081. final triggerPrice = _toDouble(data['triggerPrice']);
  1082. final entrustPrice = _toDouble(data['entrustPrice']);
  1083. final openAvgPrice = _toDouble(data['openPrice'] ?? data['usdtOpenPrice']);
  1084. final closeAvgPrice = _toDouble(data['closePrice'] ?? data['tradedPrice']);
  1085. final profitPrice = _toDouble(data['profitPrice']);
  1086. final lossPrice = _toDouble(data['lossPrice']);
  1087. final margin = _toDouble(data['totalPrincipalAmount'] ?? data['principalAmount']);
  1088. final volume = _toDouble(data['totalPosition'] ?? data['volume'] ?? data['tradedVolume']);
  1089. final profitLoss = _toDouble(data['usdtProfit'] ?? data['profitAndLoss'] ?? data['profit'] ?? 0);
  1090. // 手续费 = 开仓手续费 + 平仓手续费(后端字段 openFee / closeFee)
  1091. final fee = _toDouble(data['openFee']) + _toDouble(data['closeFee']) + _toDouble(data['commissionFee']);
  1092. final profitRate = margin > 0 ? profitLoss / margin * 100 : 0.0;
  1093. final profitColor = AppColors.changeColor(profitLoss);
  1094. // 委托价格:市价/计划市价显示"市价",限价/计划限价显示原始价格
  1095. final entrustPriceStr = _showMarketPrice(data['type'], entrustPrice)
  1096. ? l10n.marketOrderType
  1097. : (entrustPrice > 0 ? _rawNum(entrustPrice) : '--');
  1098. final status = _str(data['status'] ?? '');
  1099. final statusLabel = _mapStatus(status, l10n, isPosition: true);
  1100. return Scaffold(
  1101. backgroundColor: cs.surface,
  1102. appBar: AppBar(
  1103. elevation: 0,
  1104. centerTitle: true,
  1105. backgroundColor: cs.surface,
  1106. title: Text(
  1107. l10n.positionDetail,
  1108. style: TextStyle(
  1109. color: cs.onSurface, fontSize: 16, fontWeight: FontWeight.w700),
  1110. ),
  1111. ),
  1112. body: SingleChildScrollView(
  1113. child: Column(
  1114. children: [
  1115. _HistoryDetailStatus(label: statusLabel),
  1116. _HistorySection(rows: [
  1117. _HistoryRow(l10n.crossIsolatedLabel, posType),
  1118. _HistoryRow(l10n.direction, dirInfo.label, valueColor: dirColor),
  1119. _HistoryRow(l10n.orderType, orderType),
  1120. _HistoryRow(l10n.leverage, '${leverage}x'),
  1121. _HistoryRow(l10n.openTime, openTime),
  1122. _HistoryRow(l10n.closeTime, closeTime, isLast: true),
  1123. ]),
  1124. const _HistorySectionGap(),
  1125. _HistorySection(rows: [
  1126. _HistoryRow('${l10n.triggerPrice}(USDT)', triggerPrice > 0 ? _rawNum(triggerPrice) : '--'),
  1127. _HistoryRow('${l10n.orderPriceLabel}(USDT)', entrustPriceStr),
  1128. _HistoryRow('${l10n.openAvgPrice}(USDT)', openAvgPrice > 0 ? _rawNum(openAvgPrice) : '--'),
  1129. _HistoryRow('${l10n.closeAvgPrice}(USDT)', closeAvgPrice > 0 ? _rawNum(closeAvgPrice) : '--'),
  1130. _HistoryRow('${l10n.takeProfitTriggerPrice}(USDT)', profitPrice > 0 ? _rawNum(profitPrice) : '--'),
  1131. _HistoryRow('${l10n.stopLossTriggerPrice}(USDT)', lossPrice > 0 ? _rawNum(lossPrice) : '--'),
  1132. _HistoryRow('${l10n.marginLabel}(USDT)', margin > 0 ? margin.toStringAsFixed(2) : '--', isLast: true),
  1133. ]),
  1134. const _HistorySectionGap(),
  1135. _HistorySection(rows: [
  1136. _HistoryRow('${l10n.entrustAmount}($coinLabel)', _rawNum(volume)),
  1137. _HistoryRow('${l10n.profitLabel}(USDT)', profitLoss != 0 ? profitLoss.toStringAsFixed(4) : '--', valueColor: profitLoss != 0 ? profitColor : null),
  1138. _HistoryRow(l10n.profitRateLabel, profitRate != 0 ? '${profitRate.toStringAsFixed(2)}%' : '--', valueColor: profitRate != 0 ? profitColor : null),
  1139. _HistoryRow('${l10n.fee}(USDT)', fee > 0 ? fee.toStringAsFixed(4) : '--', isLast: true),
  1140. ]),
  1141. const SizedBox(height: 24),
  1142. ],
  1143. ),
  1144. ),
  1145. );
  1146. }
  1147. }
  1148. // ═══════════════════════════════════════════════════════════
  1149. // 历史委托详情页
  1150. // ═══════════════════════════════════════════════════════════
  1151. class OrderHistoryDetailScreen extends StatelessWidget {
  1152. const OrderHistoryDetailScreen({super.key, required this.data});
  1153. final Map<String, dynamic> data;
  1154. @override
  1155. Widget build(BuildContext context) {
  1156. final cs = Theme.of(context).colorScheme;
  1157. final l10n = AppLocalizations.of(context)!;
  1158. final rawSym = _str(data['symbol'] ?? data['coinSymbol'] ?? '');
  1159. final coinLabel = rawSym.contains('/')
  1160. ? rawSym.split('/').first
  1161. : rawSym.replaceAll(RegExp(r'USDT$|BUSD$'), '').isNotEmpty
  1162. ? rawSym.replaceAll(RegExp(r'USDT$|BUSD$'), '')
  1163. : 'BTC';
  1164. final posType = _mapPositionType(
  1165. data['positionType'], data['patterns'], l10n);
  1166. final dirInfo = _directionInfo(data['direction'], data['entrustType'], l10n);
  1167. final dirColor = dirInfo.isGreen ? AppColors.rise : AppColors.fall;
  1168. final orderType = _mapOrderType(data['type'], l10n);
  1169. final leverage = (_toDouble(data['leverage'])).toInt();
  1170. final openTime = _formatTime(data['createTime'] ?? data['usdtOpenTime']);
  1171. final closeTime = _formatTime(data['dealTime']);
  1172. final triggerPrice = _toDouble(data['triggerPrice']);
  1173. final entrustPrice = _toDouble(data['entrustPrice']);
  1174. // 判断是否为平仓单(entrustType=1/CLOSE)
  1175. final entrustTypeRaw = (data['entrustType']?.toString() ?? '').toUpperCase();
  1176. final isCloseOrder = entrustTypeRaw == '1' || entrustTypeRaw == 'CLOSE';
  1177. // 开仓单:tradedPrice = 开仓成交均价,无平仓均价
  1178. // 平仓单:usdtOpenPrice/openPrice = 原仓位开仓均价,tradedPrice = 平仓成交均价
  1179. final openAvgPrice = isCloseOrder
  1180. ? _toDouble(data['usdtOpenPrice'] ?? data['openPrice'] ?? 0)
  1181. : _toDouble(data['tradedPrice']);
  1182. final closeAvgPrice = isCloseOrder ? _toDouble(data['tradedPrice']) : 0.0;
  1183. final profitPrice = _toDouble(data['profitPrice'] ?? data['stopProfitPrice']);
  1184. final lossPrice = _toDouble(data['lossPrice'] ?? data['stopLossPrice']);
  1185. final margin = _toDouble(data['principalAmount']);
  1186. final volume = _toDouble(data['volume'] ?? data['size']);
  1187. // 手续费 = 开仓手续费 + 平仓手续费(后端字段 openFee / closeFee)
  1188. final fee = _toDouble(data['openFee']) + _toDouble(data['closeFee']);
  1189. // 委托价格:市价/计划市价显示"市价",限价/计划限价显示原始价格
  1190. final entrustPriceStr = _showMarketPrice(data['type'], entrustPrice)
  1191. ? l10n.marketOrderType
  1192. : (entrustPrice > 0 ? _rawNum(entrustPrice) : '--');
  1193. final status = _str(data['status'] ?? '');
  1194. final statusLabel = _mapStatus(status, l10n);
  1195. return Scaffold(
  1196. backgroundColor: cs.surface,
  1197. appBar: AppBar(
  1198. elevation: 0,
  1199. centerTitle: true,
  1200. backgroundColor: cs.surface,
  1201. title: Text(
  1202. l10n.orderDetail,
  1203. style: TextStyle(
  1204. color: cs.onSurface, fontSize: 16, fontWeight: FontWeight.w700),
  1205. ),
  1206. ),
  1207. body: SingleChildScrollView(
  1208. child: Column(
  1209. children: [
  1210. _HistoryDetailStatus(label: statusLabel),
  1211. _HistorySection(rows: [
  1212. _HistoryRow(l10n.crossIsolatedLabel, posType),
  1213. _HistoryRow(l10n.direction, dirInfo.label, valueColor: dirColor),
  1214. _HistoryRow(l10n.orderType, orderType),
  1215. _HistoryRow(l10n.leverage, '${leverage}x'),
  1216. _HistoryRow(l10n.openTime, openTime),
  1217. _HistoryRow(l10n.closeTime, closeTime, isLast: true),
  1218. ]),
  1219. const _HistorySectionGap(),
  1220. _HistorySection(rows: [
  1221. _HistoryRow('${l10n.triggerPrice}(USDT)', triggerPrice > 0 ? _rawNum(triggerPrice) : '--'),
  1222. _HistoryRow('${l10n.orderPriceLabel}(USDT)', entrustPriceStr),
  1223. _HistoryRow('${l10n.openAvgPrice}(USDT)', openAvgPrice > 0 ? _rawNum(openAvgPrice) : '--'),
  1224. _HistoryRow('${l10n.closeAvgPrice}(USDT)', closeAvgPrice > 0 ? _rawNum(closeAvgPrice) : '--'),
  1225. _HistoryRow('${l10n.takeProfitTriggerPrice}(USDT)', profitPrice > 0 ? _rawNum(profitPrice) : '--'),
  1226. _HistoryRow('${l10n.stopLossTriggerPrice}(USDT)', lossPrice > 0 ? _rawNum(lossPrice) : '--'),
  1227. _HistoryRow('${l10n.marginLabel}(USDT)', margin > 0 ? margin.toStringAsFixed(2) : '--'),
  1228. _HistoryRow('${l10n.entrustAmount}($coinLabel)', _rawNum(volume)),
  1229. _HistoryRow('${l10n.fee}(USDT)', fee > 0 ? fee.toStringAsFixed(4) : '--', isLast: true),
  1230. ]),
  1231. const SizedBox(height: 24),
  1232. ],
  1233. ),
  1234. ),
  1235. );
  1236. }
  1237. }
  1238. // ─── 共用辅助 widgets ──────────────────────────────────────
  1239. class _HistoryDetailStatus extends StatelessWidget {
  1240. const _HistoryDetailStatus({required this.label});
  1241. final String label;
  1242. @override
  1243. Widget build(BuildContext context) {
  1244. final cs = Theme.of(context).colorScheme;
  1245. return Padding(
  1246. padding: const EdgeInsets.fromLTRB(0, 32, 0, 24),
  1247. child: Column(
  1248. children: [
  1249. Stack(
  1250. clipBehavior: Clip.none,
  1251. children: [
  1252. Container(
  1253. width: 56,
  1254. height: 56,
  1255. decoration: BoxDecoration(
  1256. color: const Color(0xFF3B82F6).withAlpha(25),
  1257. borderRadius: BorderRadius.circular(14),
  1258. ),
  1259. child: const Icon(Icons.receipt_long,
  1260. color: Color(0xFF3B82F6), size: 28),
  1261. ),
  1262. Positioned(
  1263. right: -4,
  1264. bottom: -4,
  1265. child: Container(
  1266. width: 20,
  1267. height: 20,
  1268. decoration: const BoxDecoration(
  1269. color: AppColors.rise,
  1270. shape: BoxShape.circle,
  1271. ),
  1272. child: const Icon(Icons.check,
  1273. color: Colors.white, size: 13),
  1274. ),
  1275. ),
  1276. ],
  1277. ),
  1278. const SizedBox(height: 14),
  1279. Text(
  1280. label,
  1281. style: TextStyle(
  1282. color: cs.onSurface,
  1283. fontSize: 16,
  1284. fontWeight: FontWeight.w600),
  1285. ),
  1286. ],
  1287. ),
  1288. );
  1289. }
  1290. }
  1291. class _HistoryRow {
  1292. const _HistoryRow(this.label, this.value,
  1293. {this.valueColor, this.isLast = false});
  1294. final String label;
  1295. final String value;
  1296. final Color? valueColor;
  1297. final bool isLast;
  1298. }
  1299. class _HistorySection extends StatelessWidget {
  1300. const _HistorySection({required this.rows});
  1301. final List<_HistoryRow> rows;
  1302. @override
  1303. Widget build(BuildContext context) {
  1304. final cs = Theme.of(context).colorScheme;
  1305. return Column(
  1306. children: rows.map((r) {
  1307. return Column(
  1308. children: [
  1309. Padding(
  1310. padding:
  1311. const EdgeInsets.symmetric(horizontal: 16, vertical: 13),
  1312. child: Row(
  1313. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  1314. children: [
  1315. Text(r.label,
  1316. style: TextStyle(
  1317. color: cs.onSurface.withAlpha(153), fontSize: 13)),
  1318. Text(r.value,
  1319. style: TextStyle(
  1320. color: r.valueColor ?? cs.onSurface,
  1321. fontSize: 13,
  1322. fontWeight: FontWeight.w500)),
  1323. ],
  1324. ),
  1325. ),
  1326. if (!r.isLast)
  1327. Divider(
  1328. height: 1,
  1329. thickness: 0.5,
  1330. indent: 16,
  1331. endIndent: 16,
  1332. color: cs.outline.withAlpha(60)),
  1333. ],
  1334. );
  1335. }).toList(),
  1336. );
  1337. }
  1338. }
  1339. class _HistorySectionGap extends StatelessWidget {
  1340. const _HistorySectionGap();
  1341. @override
  1342. Widget build(BuildContext context) {
  1343. final isDark = Theme.of(context).brightness == Brightness.dark;
  1344. return Container(height: 8, color: isDark ? AppColors.darkBg : AppColors.lightBgSecondary);
  1345. }
  1346. }
  1347. // ── 骨架屏 ────────────────────────────────────────────────────
  1348. /// 历史列表骨架:模拟 3 张卡片占位
  1349. class _HistoryListShimmer extends StatelessWidget {
  1350. const _HistoryListShimmer({required this.isPosition});
  1351. final bool isPosition; // 持仓卡片 vs 委托卡片(行数略有差异)
  1352. @override
  1353. Widget build(BuildContext context) {
  1354. final isDark = Theme.of(context).brightness == Brightness.dark;
  1355. final cardBg = isDark ? AppColors.darkBgSecondary : AppColors.lightBg;
  1356. Widget card() {
  1357. return Container(
  1358. decoration: BoxDecoration(
  1359. color: cardBg,
  1360. borderRadius: BorderRadius.circular(12),
  1361. ),
  1362. padding: const EdgeInsets.all(14),
  1363. child: Column(
  1364. crossAxisAlignment: CrossAxisAlignment.start,
  1365. children: [
  1366. // 标题行:币对名 + 状态
  1367. Row(
  1368. children: [
  1369. shimmerBox(90, 15),
  1370. const Spacer(),
  1371. shimmerBox(52, 13),
  1372. ],
  1373. ),
  1374. const SizedBox(height: 10),
  1375. // 标签行
  1376. Row(children: [
  1377. shimmerBox(52, 20, radius: 4),
  1378. const SizedBox(width: 6),
  1379. shimmerBox(44, 20, radius: 4),
  1380. const SizedBox(width: 6),
  1381. shimmerBox(32, 20, radius: 4),
  1382. const SizedBox(width: 6),
  1383. shimmerBox(28, 20, radius: 4),
  1384. ]),
  1385. const SizedBox(height: 12),
  1386. // 分割线
  1387. shimmerBox(double.infinity, 0.5),
  1388. const SizedBox(height: 10),
  1389. // 数据行 1
  1390. Row(children: [
  1391. _ShimmerField(),
  1392. _ShimmerField(),
  1393. if (isPosition) _ShimmerField(),
  1394. ]),
  1395. const SizedBox(height: 8),
  1396. // 数据行 2
  1397. Row(children: [
  1398. _ShimmerField(),
  1399. _ShimmerField(),
  1400. if (isPosition) _ShimmerField(),
  1401. ]),
  1402. const SizedBox(height: 10),
  1403. shimmerBox(double.infinity, 0.5),
  1404. const SizedBox(height: 10),
  1405. // 时间行
  1406. Row(
  1407. children: [
  1408. Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
  1409. shimmerBox(44, 10),
  1410. const SizedBox(height: 4),
  1411. shimmerBox(110, 12),
  1412. ]),
  1413. const Spacer(),
  1414. Column(crossAxisAlignment: CrossAxisAlignment.end, children: [
  1415. shimmerBox(44, 10),
  1416. const SizedBox(height: 4),
  1417. shimmerBox(110, 12),
  1418. ]),
  1419. ],
  1420. ),
  1421. ],
  1422. ),
  1423. );
  1424. }
  1425. return AppShimmer(
  1426. child: ListView.separated(
  1427. physics: const NeverScrollableScrollPhysics(),
  1428. padding: const EdgeInsets.fromLTRB(12, 12, 12, 24),
  1429. itemCount: 4,
  1430. separatorBuilder: (_, __) => const SizedBox(height: 12),
  1431. itemBuilder: (_, __) => card(),
  1432. ),
  1433. );
  1434. }
  1435. }
  1436. /// 数据字段占位(标签 + 值,竖向排列)
  1437. class _ShimmerField extends StatelessWidget {
  1438. @override
  1439. Widget build(BuildContext context) {
  1440. return Expanded(
  1441. child: Column(
  1442. crossAxisAlignment: CrossAxisAlignment.start,
  1443. children: [
  1444. shimmerBox(40, 10),
  1445. const SizedBox(height: 5),
  1446. shimmerBox(60, 13),
  1447. ],
  1448. ),
  1449. );
  1450. }
  1451. }