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:
parent
99c1ff1fb7
commit
13f1b687ee
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
// 获取原始快照数据
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue