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 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-19 22:21:39 -08:00
parent 99c1ff1fb7
commit 13f1b687ee
8 changed files with 289 additions and 17 deletions

View File

@ -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')

View File

@ -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);
// 获取原始快照数据

View File

@ -58,7 +58,8 @@ abstract class TradingRemoteDataSource {
Future<AssetDisplayModel> getAccountAsset(String accountSequence, {String? dailyAllocation});
/// K线数据
Future<List<KlineModel>> getKlines({String period = '1h', int limit = 100});
/// [before] K线数据
Future<List<KlineModel>> getKlines({String period = '1h', int limit = 100, DateTime? before});
/// P2P转账 -
Future<P2pTransferModel> p2pTransfer({
@ -307,11 +308,22 @@ class TradingRemoteDataSourceImpl implements TradingRemoteDataSource {
}
@override
Future<List<KlineModel>> getKlines({String period = '1h', int limit = 100}) async {
Future<List<KlineModel>> getKlines({
String period = '1h',
int limit = 100,
DateTime? before,
}) async {
try {
final queryParams = <String, dynamic>{
'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<dynamic> data = response.data;
return data.map((json) => KlineModel.fromJson(json)).toList();

View File

@ -188,9 +188,9 @@ class TradingRepositoryImpl implements TradingRepository {
}
@override
Future<Either<Failure, List<Kline>>> getKlines({String period = '1h', int limit = 100}) async {
Future<Either<Failure, List<Kline>>> 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));

View File

@ -55,5 +55,6 @@ abstract class TradingRepository {
Future<Either<Failure, AssetDisplay>> getAccountAsset(String accountSequence, {String? dailyAllocation});
/// K线数据
Future<Either<Failure, List<Kline>>> getKlines({String period = '1h', int limit = 100});
/// [before] K线数据
Future<Either<Failure, List<Kline>>> getKlines({String period = '1h', int limit = 100, DateTime? before});
}

View File

@ -45,19 +45,28 @@ class _TradingPageState extends ConsumerState<TradingPage> {
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<TradingPage> {
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<TradingPage> {
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<TradingPage> {
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<TradingPage> {
);
}
Widget _buildChartSection(AsyncValue<PriceInfo?> priceAsync, AsyncValue<List<Kline>> klinesAsync) {
Widget _buildChartSection(AsyncValue<PriceInfo?> 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<TradingPage> {
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<TradingPage> {
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(),
),
);
}

View File

@ -48,7 +48,189 @@ String _periodToApiParam(String period) {
return periodMap[period] ?? '1h';
}
// K线数据 Provider ()
/// K线数据状态
class KlinesState {
final List<Kline> 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<Kline>? 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<KlinesState> {
final TradingRepository repository;
Timer? _refreshTimer;
KlinesNotifier({required this.repository}) : super(const KlinesState());
/// K线数据
Future<void> 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<void> 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<void> 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<KlinesNotifier, KlinesState>((ref) {
final repository = ref.watch(tradingRepositoryProvider);
return KlinesNotifier(repository: repository);
});
// Provider
final klinesProvider = FutureProvider<List<Kline>>((ref) async {
final repository = ref.watch(tradingRepositoryProvider);
final selectedPeriod = ref.watch(selectedKlinePeriodProvider);

View File

@ -18,6 +18,12 @@ class KlineChartWidget extends StatefulWidget {
final List<String> 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<KlineChartWidget> {
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<KlineChartWidget> {
void _onScaleEnd(ScaleEndDetails details) {
_startFocalPoint = null;
_isScaling = false;
//
// 0
if (_scrollX <= 50 && widget.hasMoreHistory && !widget.isLoadingMore) {
widget.onLoadMoreHistory?.call();
}
}
void _onLongPressStart(LongPressStartDetails details) {