From 13f1b687eeef49f60952580b551ada46c8231f53 Mon Sep 17 00:00:00 2001 From: hailin Date: Mon, 19 Jan 2026 22:21:39 -0800 Subject: [PATCH] feat(kline): add dynamic history loading on pan Add support for loading more K-line history data when user pans to the left edge. Backend API now accepts 'before' parameter for pagination. Frontend uses KlinesNotifier to manage accumulated data with proper deduplication. Co-Authored-By: Claude Opus 4.5 --- .../src/api/controllers/price.controller.ts | 10 +- .../src/application/services/price.service.ts | 5 +- .../remote/trading_remote_datasource.dart | 18 +- .../repositories/trading_repository_impl.dart | 4 +- .../repositories/trading_repository.dart | 3 +- .../pages/trading/trading_page.dart | 33 +++- .../providers/trading_providers.dart | 184 +++++++++++++++++- .../kline_chart/kline_chart_widget.dart | 49 +++++ 8 files changed, 289 insertions(+), 17 deletions(-) diff --git a/backend/services/trading-service/src/api/controllers/price.controller.ts b/backend/services/trading-service/src/api/controllers/price.controller.ts index bf2bfe8b..eebe6ffd 100644 --- a/backend/services/trading-service/src/api/controllers/price.controller.ts +++ b/backend/services/trading-service/src/api/controllers/price.controller.ts @@ -61,11 +61,19 @@ export class PriceController { example: '1h', }) @ApiQuery({ name: 'limit', required: false, type: Number, description: '返回数量,默认100' }) + @ApiQuery({ + name: 'before', + required: false, + type: String, + description: '获取此时间之前的K线数据(ISO datetime),用于加载更多历史数据', + }) async getKlines( @Query('period') period: string = '1h', @Query('limit') limit: number = 100, + @Query('before') before?: string, ) { - return this.priceService.getKlines(period, Math.min(limit, 500)); + const beforeTime = before ? new Date(before) : undefined; + return this.priceService.getKlines(period, Math.min(limit, 500), beforeTime); } @Get('depth') diff --git a/backend/services/trading-service/src/application/services/price.service.ts b/backend/services/trading-service/src/application/services/price.service.ts index 449e0f64..395bc749 100644 --- a/backend/services/trading-service/src/application/services/price.service.ts +++ b/backend/services/trading-service/src/application/services/price.service.ts @@ -233,10 +233,12 @@ export class PriceService { * * @param period K线周期: 1m, 5m, 15m, 30m, 1h, 4h, 1d * @param limit 返回数量 + * @param before 获取此时间之前的K线数据(用于加载更多历史) */ async getKlines( period: string = '1h', limit: number = 100, + before?: Date, ): Promise< Array<{ time: string; @@ -251,7 +253,8 @@ export class PriceService { const periodMinutes = this.parsePeriodToMinutes(period); // 计算时间范围 - const endTime = new Date(); + // 如果指定了before,则获取before之前的数据;否则获取到当前时间的数据 + const endTime = before ? before : new Date(); const startTime = new Date(endTime.getTime() - periodMinutes * limit * 60 * 1000); // 获取原始快照数据 diff --git a/frontend/mining-app/lib/data/datasources/remote/trading_remote_datasource.dart b/frontend/mining-app/lib/data/datasources/remote/trading_remote_datasource.dart index 66d24af4..f56b9ec9 100644 --- a/frontend/mining-app/lib/data/datasources/remote/trading_remote_datasource.dart +++ b/frontend/mining-app/lib/data/datasources/remote/trading_remote_datasource.dart @@ -58,7 +58,8 @@ abstract class TradingRemoteDataSource { Future getAccountAsset(String accountSequence, {String? dailyAllocation}); /// 获取K线数据 - Future> getKlines({String period = '1h', int limit = 100}); + /// [before] 获取此时间之前的K线数据(用于加载更多历史) + Future> getKlines({String period = '1h', int limit = 100, DateTime? before}); /// P2P转账 - 发送积分股给其他用户 Future p2pTransfer({ @@ -307,11 +308,22 @@ class TradingRemoteDataSourceImpl implements TradingRemoteDataSource { } @override - Future> getKlines({String period = '1h', int limit = 100}) async { + Future> getKlines({ + String period = '1h', + int limit = 100, + DateTime? before, + }) async { try { + final queryParams = { + 'period': period, + 'limit': limit, + }; + if (before != null) { + queryParams['before'] = before.toUtc().toIso8601String(); + } final response = await client.get( ApiEndpoints.priceKlines, - queryParameters: {'period': period, 'limit': limit}, + queryParameters: queryParams, ); final List data = response.data; return data.map((json) => KlineModel.fromJson(json)).toList(); diff --git a/frontend/mining-app/lib/data/repositories/trading_repository_impl.dart b/frontend/mining-app/lib/data/repositories/trading_repository_impl.dart index 121ad49c..2669cebb 100644 --- a/frontend/mining-app/lib/data/repositories/trading_repository_impl.dart +++ b/frontend/mining-app/lib/data/repositories/trading_repository_impl.dart @@ -188,9 +188,9 @@ class TradingRepositoryImpl implements TradingRepository { } @override - Future>> getKlines({String period = '1h', int limit = 100}) async { + Future>> getKlines({String period = '1h', int limit = 100, DateTime? before}) async { try { - final result = await remoteDataSource.getKlines(period: period, limit: limit); + final result = await remoteDataSource.getKlines(period: period, limit: limit, before: before); return Right(result); } on ServerException catch (e) { return Left(ServerFailure(e.message)); diff --git a/frontend/mining-app/lib/domain/repositories/trading_repository.dart b/frontend/mining-app/lib/domain/repositories/trading_repository.dart index faf464bd..c4352ca8 100644 --- a/frontend/mining-app/lib/domain/repositories/trading_repository.dart +++ b/frontend/mining-app/lib/domain/repositories/trading_repository.dart @@ -55,5 +55,6 @@ abstract class TradingRepository { Future> getAccountAsset(String accountSequence, {String? dailyAllocation}); /// 获取K线数据 - Future>> getKlines({String period = '1h', int limit = 100}); + /// [before] 获取此时间之前的K线数据(用于加载更多历史) + Future>> getKlines({String period = '1h', int limit = 100, DateTime? before}); } diff --git a/frontend/mining-app/lib/presentation/pages/trading/trading_page.dart b/frontend/mining-app/lib/presentation/pages/trading/trading_page.dart index ccac0db7..5c0f7e46 100644 --- a/frontend/mining-app/lib/presentation/pages/trading/trading_page.dart +++ b/frontend/mining-app/lib/presentation/pages/trading/trading_page.dart @@ -45,19 +45,28 @@ class _TradingPageState extends ConsumerState { super.dispose(); } + @override + void initState() { + super.initState(); + // 初始化时加载K线数据 + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(klinesNotifierProvider.notifier).loadKlines(_timeRanges[_selectedTimeRange]); + }); + } + @override Widget build(BuildContext context) { final priceAsync = ref.watch(currentPriceProvider); final marketAsync = ref.watch(marketOverviewProvider); final ordersAsync = ref.watch(ordersProvider); - final klinesAsync = ref.watch(klinesProvider); + final klinesState = ref.watch(klinesNotifierProvider); final user = ref.watch(userNotifierProvider); final accountSequence = user.accountSequence ?? ''; // 全屏K线图模式 if (_isFullScreen) { return KlineChartWidget( - klines: klinesAsync.valueOrNull ?? [], + klines: klinesState.klines, currentPrice: priceAsync.valueOrNull?.price ?? '0', isFullScreen: true, onFullScreenToggle: () => setState(() => _isFullScreen = false), @@ -66,7 +75,11 @@ class _TradingPageState extends ConsumerState { onTimeRangeChanged: (index) { setState(() => _selectedTimeRange = index); ref.read(selectedKlinePeriodProvider.notifier).state = _timeRanges[index]; + ref.read(klinesNotifierProvider.notifier).loadKlines(_timeRanges[index]); }, + isLoadingMore: klinesState.isLoadingMore, + hasMoreHistory: klinesState.hasMoreHistory, + onLoadMoreHistory: () => ref.read(klinesNotifierProvider.notifier).loadMoreHistory(), ); } @@ -79,7 +92,8 @@ class _TradingPageState extends ConsumerState { ref.invalidate(currentPriceProvider); ref.invalidate(marketOverviewProvider); ref.invalidate(ordersProvider); - ref.invalidate(klinesProvider); + // 重新加载K线数据 + await ref.read(klinesNotifierProvider.notifier).loadKlines(_timeRanges[_selectedTimeRange]); }, child: Column( children: [ @@ -89,7 +103,7 @@ class _TradingPageState extends ConsumerState { child: Column( children: [ _buildPriceCard(priceAsync), - _buildChartSection(priceAsync, klinesAsync), + _buildChartSection(priceAsync, klinesState), _buildMarketDataCard(marketAsync), _buildTradingPanel(priceAsync), _buildMyOrdersCard(ordersAsync), @@ -197,10 +211,9 @@ class _TradingPageState extends ConsumerState { ); } - Widget _buildChartSection(AsyncValue priceAsync, AsyncValue> klinesAsync) { + Widget _buildChartSection(AsyncValue priceAsync, KlinesState klinesState) { final priceInfo = priceAsync.valueOrNull; final currentPrice = priceInfo?.price ?? '0.000000'; - final klines = klinesAsync.valueOrNull ?? []; return Container( margin: const EdgeInsets.symmetric(horizontal: 16), @@ -208,13 +221,13 @@ class _TradingPageState extends ConsumerState { color: AppColors.cardOf(context), borderRadius: BorderRadius.circular(16), ), - child: klinesAsync.isLoading + child: klinesState.isLoading && klinesState.klines.isEmpty ? const SizedBox( height: 280, child: Center(child: CircularProgressIndicator(strokeWidth: 2)), ) : KlineChartWidget( - klines: klines, + klines: klinesState.klines, currentPrice: currentPrice, isFullScreen: false, onFullScreenToggle: () => setState(() => _isFullScreen = true), @@ -223,7 +236,11 @@ class _TradingPageState extends ConsumerState { onTimeRangeChanged: (index) { setState(() => _selectedTimeRange = index); ref.read(selectedKlinePeriodProvider.notifier).state = _timeRanges[index]; + ref.read(klinesNotifierProvider.notifier).loadKlines(_timeRanges[index]); }, + isLoadingMore: klinesState.isLoadingMore, + hasMoreHistory: klinesState.hasMoreHistory, + onLoadMoreHistory: () => ref.read(klinesNotifierProvider.notifier).loadMoreHistory(), ), ); } diff --git a/frontend/mining-app/lib/presentation/providers/trading_providers.dart b/frontend/mining-app/lib/presentation/providers/trading_providers.dart index a486300b..a06cd7aa 100644 --- a/frontend/mining-app/lib/presentation/providers/trading_providers.dart +++ b/frontend/mining-app/lib/presentation/providers/trading_providers.dart @@ -48,7 +48,189 @@ String _periodToApiParam(String period) { return periodMap[period] ?? '1h'; } -// K线数据 Provider (根据选中周期获取) +/// K线数据状态 +class KlinesState { + final List klines; + final bool isLoading; + final bool isLoadingMore; + final bool hasMoreHistory; + final String? error; + final String period; + + const KlinesState({ + this.klines = const [], + this.isLoading = false, + this.isLoadingMore = false, + this.hasMoreHistory = true, + this.error, + this.period = '1h', + }); + + KlinesState copyWith({ + List? klines, + bool? isLoading, + bool? isLoadingMore, + bool? hasMoreHistory, + String? error, + String? period, + }) { + return KlinesState( + klines: klines ?? this.klines, + isLoading: isLoading ?? this.isLoading, + isLoadingMore: isLoadingMore ?? this.isLoadingMore, + hasMoreHistory: hasMoreHistory ?? this.hasMoreHistory, + error: error, + period: period ?? this.period, + ); + } +} + +/// K线数据管理器 - 支持累积数据和加载更多历史 +class KlinesNotifier extends StateNotifier { + final TradingRepository repository; + Timer? _refreshTimer; + + KlinesNotifier({required this.repository}) : super(const KlinesState()); + + /// 初始加载K线数据 + Future loadKlines(String period) async { + final apiPeriod = _periodToApiParam(period); + + // 如果周期变化,重置数据 + if (state.period != apiPeriod) { + state = KlinesState(isLoading: true, period: apiPeriod); + } else { + state = state.copyWith(isLoading: true, error: null); + } + + final result = await repository.getKlines(period: apiPeriod, limit: 100); + + result.fold( + (failure) { + state = state.copyWith(isLoading: false, error: failure.message); + }, + (klines) { + state = state.copyWith( + klines: klines, + isLoading: false, + hasMoreHistory: klines.length >= 100, // 如果返回100条,可能还有更多 + ); + _startAutoRefresh(apiPeriod); + }, + ); + } + + /// 加载更多历史数据(向左滑动到边界时触发) + Future loadMoreHistory() async { + if (state.isLoadingMore || !state.hasMoreHistory || state.klines.isEmpty) { + return; + } + + state = state.copyWith(isLoadingMore: true); + + // 获取当前最早K线的时间作为before参数 + final oldestKline = state.klines.first; + final beforeTime = oldestKline.time; + + final result = await repository.getKlines( + period: state.period, + limit: 100, + before: beforeTime, + ); + + result.fold( + (failure) { + state = state.copyWith(isLoadingMore: false, error: failure.message); + }, + (newKlines) { + if (newKlines.isEmpty) { + // 没有更多历史数据了 + state = state.copyWith(isLoadingMore: false, hasMoreHistory: false); + } else { + // 将新数据插入到现有数据前面(按时间顺序) + // 需要去重,因为可能有重叠 + final existingTimes = state.klines.map((k) => k.time.millisecondsSinceEpoch).toSet(); + final uniqueNewKlines = newKlines.where( + (k) => !existingTimes.contains(k.time.millisecondsSinceEpoch) + ).toList(); + + final combinedKlines = [...uniqueNewKlines, ...state.klines]; + // 按时间排序 + combinedKlines.sort((a, b) => a.time.compareTo(b.time)); + + state = state.copyWith( + klines: combinedKlines, + isLoadingMore: false, + hasMoreHistory: newKlines.length >= 100, + ); + } + }, + ); + } + + /// 刷新最新数据(不清除历史) + Future refreshLatest() async { + if (state.klines.isEmpty) { + return loadKlines(state.period); + } + + final result = await repository.getKlines(period: state.period, limit: 100); + + result.fold( + (failure) { + // 刷新失败不影响现有数据 + }, + (newKlines) { + if (newKlines.isEmpty) return; + + // 合并新数据到现有数据末尾 + final existingTimes = state.klines.map((k) => k.time.millisecondsSinceEpoch).toSet(); + final uniqueNewKlines = newKlines.where( + (k) => !existingTimes.contains(k.time.millisecondsSinceEpoch) + ).toList(); + + if (uniqueNewKlines.isNotEmpty) { + final combinedKlines = [...state.klines, ...uniqueNewKlines]; + combinedKlines.sort((a, b) => a.time.compareTo(b.time)); + state = state.copyWith(klines: combinedKlines); + } + + // 更新最新K线的收盘价(实时更新) + if (newKlines.isNotEmpty && state.klines.isNotEmpty) { + final latestNew = newKlines.last; + final latestExisting = state.klines.last; + if (latestNew.time.millisecondsSinceEpoch == latestExisting.time.millisecondsSinceEpoch) { + // 更新最新K线 + final updatedKlines = [...state.klines]; + updatedKlines[updatedKlines.length - 1] = latestNew; + state = state.copyWith(klines: updatedKlines); + } + } + }, + ); + } + + void _startAutoRefresh(String period) { + _refreshTimer?.cancel(); + _refreshTimer = Timer.periodic(const Duration(minutes: 1), (_) { + refreshLatest(); + }); + } + + @override + void dispose() { + _refreshTimer?.cancel(); + super.dispose(); + } +} + +/// K线数据 Provider(支持累积和加载更多) +final klinesNotifierProvider = StateNotifierProvider((ref) { + final repository = ref.watch(tradingRepositoryProvider); + return KlinesNotifier(repository: repository); +}); + +// 保留原有的简单 Provider 以保持向后兼容 final klinesProvider = FutureProvider>((ref) async { final repository = ref.watch(tradingRepositoryProvider); final selectedPeriod = ref.watch(selectedKlinePeriodProvider); diff --git a/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_chart_widget.dart b/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_chart_widget.dart index 79567da1..48d4b92f 100644 --- a/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_chart_widget.dart +++ b/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_chart_widget.dart @@ -18,6 +18,12 @@ class KlineChartWidget extends StatefulWidget { final List timeRanges; final int selectedTimeIndex; final Function(int)? onTimeRangeChanged; + /// 是否正在加载更多历史数据 + final bool isLoadingMore; + /// 是否还有更多历史数据可加载 + final bool hasMoreHistory; + /// 加载更多历史数据的回调 + final VoidCallback? onLoadMoreHistory; const KlineChartWidget({ super.key, @@ -29,6 +35,9 @@ class KlineChartWidget extends StatefulWidget { this.timeRanges = const ['1分', '5分', '15分', '30分', '1时', '4时', '日'], this.selectedTimeIndex = 4, this.onTimeRangeChanged, + this.isLoadingMore = false, + this.hasMoreHistory = true, + this.onLoadMoreHistory, }); @override @@ -399,6 +408,40 @@ class _KlineChartWidgetState extends State { scrollOffset: _scrollX, ), ), + // 加载更多历史数据指示器 + if (widget.isLoadingMore) + Positioned( + left: 8, + top: mainChartHeight / 2 - 12, + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.cardOf(context).withOpacity(0.9), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: _orange, + ), + ), + const SizedBox(width: 8), + Text( + '加载中...', + style: TextStyle( + fontSize: 11, + color: AppColors.textSecondaryOf(context), + ), + ), + ], + ), + ), + ), // 十字线信息 if (_showCrossLine && _crossLineIndex >= 0 && _crossLineIndex < widget.klines.length) _buildCrossLineInfo(), @@ -545,6 +588,12 @@ class _KlineChartWidgetState extends State { void _onScaleEnd(ScaleEndDetails details) { _startFocalPoint = null; _isScaling = false; + + // 检查是否滚动到了左边界(历史数据边界) + // 如果滚动接近0且还有更多历史数据,触发加载 + if (_scrollX <= 50 && widget.hasMoreHistory && !widget.isLoadingMore) { + widget.onLoadMoreHistory?.call(); + } } void _onLongPressStart(LongPressStartDetails details) {