1167 lines
39 KiB
Dart
1167 lines
39 KiB
Dart
/// ============================================================
|
||
/// 闲言APP — 首页Feed数据拉取Mixin
|
||
/// 创建时间: 2026-05-12
|
||
/// 更新时间: 2026-06-27
|
||
/// 作用: 频道/每日推荐/列表/降级/缓存的拉取逻辑
|
||
/// 上次更新: v6.144.0 — 新增 _mergeLocalInteractionState 合并本地DB互动状态,
|
||
/// 解决已收藏/已点赞句子重复出现未显示状态;循环加载时保留已收藏句子去重
|
||
/// ============================================================
|
||
|
||
import 'dart:convert';
|
||
|
||
import 'package:drift/drift.dart' show OrderingTerm, Value;
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
|
||
import '../../../core/network/api_interceptor.dart';
|
||
import '../../../core/storage/kv_storage.dart';
|
||
import '../../../core/storage/database/app_database.dart';
|
||
import '../../../core/utils/data/bounded_collection_manager.dart';
|
||
import '../../../core/utils/logger.dart';
|
||
import '../../../core/services/data/home_widget_service.dart';
|
||
import '../../../editor/services/core/hitokoto_service.dart';
|
||
import '../../../shared/widgets/feedback/app_toast.dart';
|
||
import '../../../features/source/services/field_mapper_service.dart';
|
||
import '../../../features/source/models/custom_channel.dart';
|
||
import '../../../features/source/services/url_analyzer_service.dart';
|
||
import '../feed_model.dart';
|
||
import '../services/feed_service.dart';
|
||
import 'home_sentence_model.dart';
|
||
import 'home_state.dart';
|
||
|
||
mixin HomeFeedMixin on Notifier<HomeState> {
|
||
AppDatabase get feedDb;
|
||
List<FeedChannel> get allChannels;
|
||
set allChannels(List<FeedChannel> value);
|
||
|
||
Map<String, List<HomeSentence>> get categoryCache;
|
||
BoundedCollectionManager<String> get allSeenIds;
|
||
BoundedCollectionManager<String> get allSeenTexts;
|
||
bool get deduplicateContent;
|
||
int get maxCategoryCacheSize;
|
||
|
||
int get currentPage;
|
||
set currentPage(int value);
|
||
int? get lastFeedId;
|
||
set lastFeedId(int? value);
|
||
bool get isLoadingMore;
|
||
set isLoadingMore(bool value);
|
||
bool get isRefreshingDaily;
|
||
set isRefreshingDaily(bool value);
|
||
|
||
String get cacheKey;
|
||
List<HomeSentence> deduplicateList(List<HomeSentence> list);
|
||
|
||
/// 传给 API 的 seen_ids 最大数量,避免 URL 过长触发 414 URI Too Long
|
||
/// 每个 ID 约 10-15 字符,300 个约 4000 字符,加上 %2C 分隔符约 5000 字符,安全可控
|
||
static const int _maxSeenIdsForApi = 300;
|
||
|
||
List<String> _buildSeenIdList() {
|
||
final all = allSeenIds.toList();
|
||
if (all.length <= _maxSeenIdsForApi) return all;
|
||
// 保留最近添加的 ID(LinkedHashSet 保持插入顺序,取末尾部分)
|
||
return all.sublist(all.length - _maxSeenIdsForApi);
|
||
}
|
||
|
||
/// 传给 API 的 seen_hashes 最大数量,避免 URL 过长触发 414 URI Too Long
|
||
static const int _maxSeenHashesForApi = 300;
|
||
|
||
List<String> _buildSeenHashList() {
|
||
if (!deduplicateContent) return [];
|
||
final all = allSeenTexts.toList()
|
||
.where((t) => t.isNotEmpty)
|
||
.map((t) => t.length >= 8 ? t.substring(0, 8) : t)
|
||
.toList();
|
||
if (all.length <= _maxSeenHashesForApi) return all;
|
||
return all.sublist(all.length - _maxSeenHashesForApi);
|
||
}
|
||
|
||
/// 合并本地 DB 的互动状态(isFavorite/isLiked)
|
||
///
|
||
/// v6.144.0: 解决"已收藏/已点赞句子重复出现但未显示状态"的问题。
|
||
/// 合并策略:local true wins(OR 合并)
|
||
/// - 服务端 true → true(已登录用户的服务端权威状态)
|
||
/// - 服务端 false 但本地 true → true(未登录或未同步的本地操作)
|
||
/// - 两者都 false → false
|
||
///
|
||
/// 未登录场景:服务端始终返回 false,本地 DB 是唯一状态来源。
|
||
/// 登录场景:服务端返回权威状态,本地 DB 兜底未同步的离线操作。
|
||
Future<List<HomeSentence>> _mergeLocalInteractionState(
|
||
List<HomeSentence> sentences,
|
||
) async {
|
||
if (sentences.isEmpty) return sentences;
|
||
try {
|
||
final ids = sentences.map((s) => s.id).toList();
|
||
final localMap = await feedDb.getSentencesByIds(ids);
|
||
if (localMap.isEmpty) return sentences;
|
||
bool changed = false;
|
||
final merged = sentences.map((s) {
|
||
final local = localMap[s.id];
|
||
if (local == null) return s;
|
||
final mergedLiked = s.isLiked || local.isLiked;
|
||
final mergedFavorited = s.isFavorited || local.isFavorite;
|
||
if (mergedLiked == s.isLiked && mergedFavorited == s.isFavorited) {
|
||
return s;
|
||
}
|
||
changed = true;
|
||
return s.copyWith(
|
||
isLiked: mergedLiked,
|
||
isFavorited: mergedFavorited,
|
||
);
|
||
}).toList();
|
||
if (changed) {
|
||
Log.i('_mergeLocalInteractionState: 合并 ${sentences.length} 条, '
|
||
'命中本地 ${localMap.length} 条');
|
||
}
|
||
return merged;
|
||
} catch (e) {
|
||
Log.w('_mergeLocalInteractionState: 合并失败, 保留服务端状态: $e');
|
||
return sentences;
|
||
}
|
||
}
|
||
|
||
Future<void> fetchRefreshSentences() async {
|
||
try {
|
||
final seenIds = _buildSeenIdList();
|
||
final seenHashes = _buildSeenHashList();
|
||
final isAllChannel = state.selectedType == null;
|
||
final limit = isAllChannel ? 30 : 20;
|
||
|
||
Log.i(
|
||
'fetchRefreshSentences: channel=${state.selectedType ?? "all"}, '
|
||
'seenIds=${seenIds.length}, seenHashes=${seenHashes.length}',
|
||
);
|
||
|
||
final result = await FeedService.fetchRefreshContent(
|
||
channel: state.selectedType ?? 'all',
|
||
sort: state.currentSort,
|
||
limit: limit,
|
||
seenIds: seenIds.isNotEmpty ? seenIds : null,
|
||
seenHashes: seenHashes.isNotEmpty ? seenHashes : null,
|
||
platform: ApiInterceptor.currentPlatform,
|
||
);
|
||
|
||
if (result.list.isEmpty) {
|
||
Log.w('fetchRefreshSentences: 服务端返回空, 降级fetchNewSentences');
|
||
await fetchNewSentences(replace: true);
|
||
return;
|
||
}
|
||
|
||
final enabledChannelKeys = state.channels.map((c) => c.key).toSet();
|
||
// 当选择了具体分类时,只保留该分类的数据
|
||
final selectedType = state.selectedType;
|
||
|
||
var newSentences = result.list
|
||
.map(HomeSentence.fromFeedItem)
|
||
.where((s) => s.text.isNotEmpty)
|
||
.where((s) {
|
||
// 选择了具体分类时,严格过滤只保留该分类
|
||
if (selectedType != null) {
|
||
return s.feedType == selectedType;
|
||
}
|
||
// "推荐"模式下,只显示启用的分类
|
||
return enabledChannelKeys.isEmpty ||
|
||
enabledChannelKeys.contains(s.feedType);
|
||
})
|
||
.toList();
|
||
|
||
// v6.144.0: 合并本地 DB 互动状态,确保已收藏/已点赞句子显示正确状态
|
||
newSentences = await _mergeLocalInteractionState(newSentences);
|
||
|
||
final unique = newSentences
|
||
.where((s) => !allSeenIds.contains(s.id))
|
||
.toList();
|
||
|
||
if (unique.isEmpty) {
|
||
Log.w('fetchRefreshSentences: 去重后为空, 使用原始列表');
|
||
await saveToDb(newSentences);
|
||
allSeenIds.addAll(newSentences.map((s) => s.id));
|
||
if (deduplicateContent) {
|
||
for (final s in newSentences) {
|
||
if (s.text.isNotEmpty) {
|
||
allSeenTexts.add(s.text.trim());
|
||
}
|
||
}
|
||
}
|
||
state = state.copyWith(
|
||
sentences: newSentences,
|
||
isLoading: false,
|
||
isForceLoading: false,
|
||
hasMore: true,
|
||
isOffline: false,
|
||
isCycling: false,
|
||
isCategorySwitching: false,
|
||
cycleRound: 0,
|
||
lastCycleIds: [],
|
||
);
|
||
return;
|
||
}
|
||
|
||
await saveToDb(unique);
|
||
allSeenIds.addAll(unique.map((s) => s.id));
|
||
if (deduplicateContent) {
|
||
for (final s in unique) {
|
||
if (s.text.isNotEmpty) {
|
||
allSeenTexts.add(s.text.trim());
|
||
}
|
||
}
|
||
}
|
||
|
||
state = state.copyWith(
|
||
sentences: unique,
|
||
isLoading: false,
|
||
isForceLoading: false,
|
||
hasMore: true,
|
||
isOffline: false,
|
||
isCycling: false,
|
||
isCategorySwitching: false,
|
||
cycleRound: 0,
|
||
lastCycleIds: [],
|
||
);
|
||
|
||
if (unique.isNotEmpty) {
|
||
if (categoryCache.length >= maxCategoryCacheSize &&
|
||
!categoryCache.containsKey(cacheKey)) {
|
||
categoryCache.remove(categoryCache.keys.first);
|
||
}
|
||
categoryCache[cacheKey] = List.from(unique);
|
||
}
|
||
|
||
Log.i('fetchRefreshSentences: ${unique.length}条新内容');
|
||
} catch (e) {
|
||
Log.e('fetchRefreshSentences失败, 降级fetchNewSentences', e);
|
||
await fetchNewSentences(replace: true);
|
||
}
|
||
}
|
||
|
||
Future<void> fetchChannels() async {
|
||
loadCachedChannels();
|
||
|
||
try {
|
||
final channels = await FeedService.fetchChannels(
|
||
platform: ApiInterceptor.currentPlatform,
|
||
);
|
||
if (channels.isNotEmpty) {
|
||
allChannels = channels;
|
||
final disabledKeys = loadDisabledKeys();
|
||
var filtered = disabledKeys.isNotEmpty
|
||
? channels.where((c) => !disabledKeys.contains(c.key)).toList()
|
||
: channels;
|
||
filtered = _applyChannelOrder(filtered);
|
||
state = state.copyWith(channels: filtered);
|
||
saveChannelsCache(channels);
|
||
}
|
||
} catch (e) {
|
||
Log.e('频道列表拉取失败', e);
|
||
}
|
||
}
|
||
|
||
Future<void> refreshChannels({Set<String>? disabledKeys}) async {
|
||
var source = allChannels.isNotEmpty ? allChannels : state.channels;
|
||
if (source.isEmpty) {
|
||
try {
|
||
source = await FeedService.fetchChannels(
|
||
platform: ApiInterceptor.currentPlatform,
|
||
);
|
||
if (source.isNotEmpty) {
|
||
allChannels = source;
|
||
}
|
||
} catch (e) {
|
||
Log.e('refreshChannels: 频道拉取失败', e);
|
||
return;
|
||
}
|
||
}
|
||
final keys = disabledKeys ?? loadDisabledKeys();
|
||
var filtered = keys.isNotEmpty
|
||
? source.where((c) => !keys.contains(c.key)).toList()
|
||
: source;
|
||
filtered = _applyChannelOrder(filtered);
|
||
|
||
Log.i(
|
||
'refreshChannels: 源频道${source.length}个, '
|
||
'禁用${keys.length}个, '
|
||
'过滤后${filtered.length}个',
|
||
);
|
||
|
||
final selectedWasDisabled =
|
||
state.selectedType != null && keys.contains(state.selectedType);
|
||
|
||
categoryCache.clear();
|
||
for (final s in state.sentences) {
|
||
allSeenIds.add(s.id);
|
||
if (deduplicateContent && s.text.isNotEmpty) {
|
||
allSeenTexts.add(s.text.trim());
|
||
}
|
||
}
|
||
|
||
state = state.copyWith(
|
||
channels: filtered,
|
||
selectedType: selectedWasDisabled ? null : state.selectedType,
|
||
clearType: selectedWasDisabled,
|
||
);
|
||
|
||
Log.i(
|
||
'refreshChannels: 频道更新, 启用${filtered.length}个, 禁用${keys.length}个, 选中=${state.selectedType}',
|
||
);
|
||
|
||
if (selectedWasDisabled) {
|
||
Log.i('refreshChannels: 当前分类被禁用, 重置为全部');
|
||
}
|
||
|
||
state = state.copyWith(
|
||
sentences: [],
|
||
isLoading: true,
|
||
isForceLoading: true,
|
||
isCycling: false,
|
||
cycleRound: 0,
|
||
lastCycleIds: [],
|
||
);
|
||
currentPage = 1;
|
||
lastFeedId = null;
|
||
isLoadingMore = false;
|
||
await fetchNewSentences();
|
||
}
|
||
|
||
void loadCachedChannels() {
|
||
try {
|
||
final raw = KvStorage.getString('feed_channels_cache');
|
||
if (raw == null || raw.isEmpty) return;
|
||
final decoded = jsonDecode(raw) as List<dynamic>;
|
||
final cached = decoded
|
||
.map((e) => FeedChannel.fromJson(e as Map<String, dynamic>))
|
||
.toList();
|
||
if (cached.isNotEmpty && state.channels.isEmpty) {
|
||
allChannels = cached;
|
||
final disabledKeys = loadDisabledKeys();
|
||
var filtered = disabledKeys.isNotEmpty
|
||
? cached.where((c) => !disabledKeys.contains(c.key)).toList()
|
||
: cached;
|
||
filtered = _applyChannelOrder(filtered);
|
||
state = state.copyWith(channels: filtered);
|
||
}
|
||
} catch (e) {
|
||
Log.e('频道缓存读取失败', e);
|
||
}
|
||
}
|
||
|
||
Set<String> loadDisabledKeys() {
|
||
try {
|
||
final raw = KvStorage.getString('source_disabled_channels');
|
||
if (raw != null && raw.isNotEmpty) {
|
||
final list = (jsonDecode(raw) as List<dynamic>)
|
||
.map((e) => e.toString())
|
||
.toList();
|
||
return list.toSet();
|
||
}
|
||
} catch (e) {
|
||
Log.e('禁用频道读取失败', e);
|
||
}
|
||
return {};
|
||
}
|
||
|
||
// ── 应用用户自定义频道排序 ──
|
||
|
||
List<FeedChannel> _applyChannelOrder(List<FeedChannel> channels) {
|
||
try {
|
||
final savedOrder = KvStorage.getStringList(StorageKeys.channelOrder);
|
||
if (savedOrder == null || savedOrder.isEmpty) return channels;
|
||
final orderMap = <String, int>{
|
||
for (var i = 0; i < savedOrder.length; i++) savedOrder[i]: i,
|
||
};
|
||
final sorted = List<FeedChannel>.from(channels);
|
||
sorted.sort((a, b) {
|
||
final aIdx = orderMap[a.key] ?? 999;
|
||
final bIdx = orderMap[b.key] ?? 999;
|
||
return aIdx.compareTo(bIdx);
|
||
});
|
||
return sorted;
|
||
} catch (e) {
|
||
Log.e('频道排序应用失败', e);
|
||
return channels;
|
||
}
|
||
}
|
||
|
||
Future<void> saveChannelsCache(List<FeedChannel> channels) async {
|
||
try {
|
||
final encoded = jsonEncode(channels.map((c) => c.toJson()).toList());
|
||
await KvStorage.setString('feed_channels_cache', encoded);
|
||
} catch (e) {
|
||
Log.e('频道缓存写入失败', e);
|
||
}
|
||
}
|
||
|
||
Future<void> fetchDailySentence() async {
|
||
try {
|
||
FeedMixConfig mixConfig;
|
||
try {
|
||
final raw = KvStorage.getString('home_card_mix_config');
|
||
if (raw != null && raw.isNotEmpty) {
|
||
mixConfig = FeedMixConfig.fromJson(
|
||
jsonDecode(raw) as Map<String, dynamic>,
|
||
).copyWith(platform: ApiInterceptor.currentPlatform);
|
||
} else {
|
||
mixConfig = FeedMixConfig(
|
||
limit: 5,
|
||
platform: ApiInterceptor.currentPlatform,
|
||
);
|
||
}
|
||
} catch (_) {
|
||
mixConfig = FeedMixConfig(
|
||
limit: 5,
|
||
platform: ApiInterceptor.currentPlatform,
|
||
);
|
||
}
|
||
|
||
if (mixConfig.mode == 'specific') {
|
||
final disabledKeys = loadDisabledKeys();
|
||
final allCh = allChannels.isNotEmpty
|
||
? allChannels
|
||
: (state.channels.isNotEmpty
|
||
? state.channels
|
||
: await FeedService.fetchChannels());
|
||
final enabledKeys = allCh
|
||
.where((c) => !disabledKeys.contains(c.key))
|
||
.map((c) => c.key)
|
||
.toList();
|
||
mixConfig = mixConfig.copyWith(channels: enabledKeys);
|
||
}
|
||
|
||
Log.i(
|
||
'fetchDailySentence: mode=${mixConfig.mode}, limit=${mixConfig.limit}, sort=${mixConfig.sort}',
|
||
);
|
||
|
||
final result = await FeedService.fetchMix(mixConfig).timeout(
|
||
const Duration(seconds: 8),
|
||
onTimeout: () {
|
||
Log.w('fetchDailySentence: fetchMix超时');
|
||
return FeedMixResult.empty();
|
||
},
|
||
);
|
||
|
||
if (result.list.isNotEmpty) {
|
||
final dailyList = result.list.map(HomeSentence.fromFeedItem).toList();
|
||
final ids = dailyList.map((s) => s.id).toSet();
|
||
Log.i('fetchDailySentence: ${dailyList.length}条, ids=$ids');
|
||
state = state.copyWith(
|
||
dailySentence: dailyList.first,
|
||
dailySentences: dailyList,
|
||
);
|
||
_syncDailyWithCharacterWidget(dailyList.first);
|
||
return;
|
||
}
|
||
|
||
Log.w('fetchDailySentence: fetchMix返回空, 降级fetchRecommend');
|
||
} catch (e) {
|
||
Log.e('混合接口拉取失败,降级Feed推荐', e);
|
||
}
|
||
|
||
await fetchDailySentenceFallback();
|
||
}
|
||
|
||
Future<void> refreshDailySentences() async {
|
||
if (isRefreshingDaily) {
|
||
Log.w('refreshDailySentences: 防重入,跳过本次调用');
|
||
return;
|
||
}
|
||
isRefreshingDaily = true;
|
||
|
||
try {
|
||
FeedMixConfig mixConfig;
|
||
try {
|
||
final raw = KvStorage.getString('home_card_mix_config');
|
||
if (raw != null && raw.isNotEmpty) {
|
||
mixConfig = FeedMixConfig.fromJson(
|
||
jsonDecode(raw) as Map<String, dynamic>,
|
||
).copyWith(platform: ApiInterceptor.currentPlatform);
|
||
} else {
|
||
mixConfig = FeedMixConfig(
|
||
limit: 5,
|
||
platform: ApiInterceptor.currentPlatform,
|
||
);
|
||
}
|
||
} catch (_) {
|
||
mixConfig = FeedMixConfig(
|
||
limit: 5,
|
||
platform: ApiInterceptor.currentPlatform,
|
||
);
|
||
}
|
||
|
||
if (mixConfig.mode == 'specific') {
|
||
final disabledKeys = loadDisabledKeys();
|
||
final allCh = allChannels.isNotEmpty
|
||
? allChannels
|
||
: (state.channels.isNotEmpty
|
||
? state.channels
|
||
: await FeedService.fetchChannels());
|
||
final enabledKeys = allCh
|
||
.where((c) => !disabledKeys.contains(c.key))
|
||
.map((c) => c.key)
|
||
.toList();
|
||
mixConfig = mixConfig.copyWith(channels: enabledKeys);
|
||
}
|
||
|
||
final oldIds = state.dailySentences.map((s) => s.id).toSet();
|
||
Log.i('refreshDailySentences: 旧数据=${oldIds.length}条, config=$mixConfig');
|
||
|
||
final result = await FeedService.fetchMix(mixConfig).timeout(
|
||
const Duration(seconds: 8),
|
||
onTimeout: () {
|
||
Log.w('refreshDailySentences: fetchMix超时');
|
||
return FeedMixResult.empty();
|
||
},
|
||
);
|
||
|
||
if (result.list.isNotEmpty) {
|
||
var dailyList = result.list.map(HomeSentence.fromFeedItem).toList();
|
||
var unique = dailyList.where((s) => !oldIds.contains(s.id)).toList();
|
||
|
||
Log.i(
|
||
'refreshDailySentences: 返回${dailyList.length}条, 去重后${unique.length}条',
|
||
);
|
||
|
||
if (unique.isEmpty && oldIds.isNotEmpty) {
|
||
final retryConfig = mixConfig.copyWith(
|
||
limit: (mixConfig.limit * 3).clamp(15, 50),
|
||
);
|
||
Log.i('refreshDailySentences: 去重为空, 重试limit=${retryConfig.limit}');
|
||
final retryResult = await FeedService.fetchMix(retryConfig).timeout(
|
||
const Duration(seconds: 8),
|
||
onTimeout: () => FeedMixResult.empty(),
|
||
);
|
||
if (retryResult.list.isNotEmpty) {
|
||
final retryList = retryResult.list
|
||
.map(HomeSentence.fromFeedItem)
|
||
.toList();
|
||
unique = retryList.where((s) => !oldIds.contains(s.id)).toList();
|
||
Log.i(
|
||
'refreshDailySentences: 重试返回${retryList.length}条, 去重后${unique.length}条',
|
||
);
|
||
if (unique.isNotEmpty) dailyList = unique;
|
||
}
|
||
}
|
||
|
||
final finalList = unique.isNotEmpty ? unique : dailyList;
|
||
state = state.copyWith(
|
||
dailySentence: finalList.first,
|
||
dailySentences: finalList,
|
||
);
|
||
_syncDailyWithCharacterWidget(finalList.first);
|
||
} else {
|
||
Log.w('refreshDailySentences: fetchMix返回空, 降级fetchRecommend');
|
||
await fetchDailySentenceFallback();
|
||
}
|
||
} catch (e) {
|
||
Log.e('刷新每日推荐失败', e);
|
||
await fetchDailySentenceFallback();
|
||
} finally {
|
||
isRefreshingDaily = false;
|
||
}
|
||
}
|
||
|
||
Future<void> fetchDailySentenceFallback() async {
|
||
try {
|
||
final result = await FeedService.fetchRecommend(limit: 5).timeout(
|
||
const Duration(seconds: 8),
|
||
onTimeout: () =>
|
||
const FeedRecommendResult(list: [], personalized: false),
|
||
);
|
||
if (result.list.isNotEmpty) {
|
||
final dailyList = result.list.map(HomeSentence.fromFeedItem).toList();
|
||
final ids = dailyList.map((s) => s.id).toSet();
|
||
Log.i('fetchDailySentenceFallback: ${dailyList.length}条, ids=$ids');
|
||
state = state.copyWith(
|
||
dailySentence: dailyList.first,
|
||
dailySentences: dailyList,
|
||
);
|
||
_syncDailyWithCharacterWidget(dailyList.first);
|
||
return;
|
||
}
|
||
} catch (e) {
|
||
Log.e('Feed推荐拉取失败,降级Hitokoto', e);
|
||
}
|
||
|
||
try {
|
||
final quote = await HitokotoService.fetch();
|
||
if (quote != null) {
|
||
Log.i('fetchDailySentenceFallback: Hitokoto降级成功');
|
||
final sentence = HomeSentence.fromHitokoto(quote);
|
||
state = state.copyWith(dailySentence: sentence);
|
||
_syncDailyWithCharacterWidget(sentence);
|
||
}
|
||
} catch (e) {
|
||
Log.e('Hitokoto降级也失败', e);
|
||
state = state.copyWith(isOffline: true);
|
||
}
|
||
}
|
||
|
||
Future<void> fetchNewSentences({bool replace = false}) async {
|
||
try {
|
||
// 自定义频道 — 从本地数据库加载
|
||
if (state.selectedType != null && state.selectedType!.startsWith('custom_')) {
|
||
await _fetchCustomChannelSentences();
|
||
return;
|
||
}
|
||
|
||
final isAllChannel = state.selectedType == null;
|
||
final enabledCount = state.channels.length;
|
||
final limit = isAllChannel && enabledCount > 0 && enabledCount < 10
|
||
? 40
|
||
: 20;
|
||
final params = FeedListParams(
|
||
channel: state.selectedType ?? 'all',
|
||
sort: state.currentSort,
|
||
page: currentPage,
|
||
lastId: lastFeedId,
|
||
lite: true,
|
||
limit: limit,
|
||
seenIds: allSeenIds.isNotEmpty ? _buildSeenIdList() : null,
|
||
platform: ApiInterceptor.currentPlatform,
|
||
);
|
||
Log.i(
|
||
'fetchNewSentences: channel=${params.channel}, sort=${params.sort}, page=${params.page}, limit=$limit',
|
||
);
|
||
final result = await FeedService.fetchList(params);
|
||
if (result.list.isEmpty) {
|
||
if (!state.isOffline) {
|
||
if (state.cycleRound >= 3) {
|
||
Log.w('fetchNewSentences: 循环3轮仍无数据, 停止');
|
||
state = state.copyWith(
|
||
isLoading: false,
|
||
isForceLoading: false,
|
||
hasMore: false,
|
||
isCycling: false,
|
||
cycleRound: 0,
|
||
lastCycleIds: [],
|
||
);
|
||
return;
|
||
}
|
||
final currentIds = state.sentences.map((s) => s.id).toList();
|
||
state = state.copyWith(
|
||
isCycling: true,
|
||
cycleRound: state.cycleRound + 1,
|
||
lastCycleIds: currentIds,
|
||
);
|
||
currentPage = 1;
|
||
lastFeedId = null;
|
||
await fetchNewSentences();
|
||
return;
|
||
}
|
||
state = state.copyWith(
|
||
isLoading: false,
|
||
isForceLoading: false,
|
||
hasMore: false,
|
||
);
|
||
return;
|
||
}
|
||
|
||
final enabledChannelKeys = state.channels.map((c) => c.key).toSet();
|
||
// 当选择了具体分类时,只保留该分类的数据
|
||
final selectedType = state.selectedType;
|
||
|
||
final newSentences = result.list
|
||
.map(HomeSentence.fromFeedItem)
|
||
.where((s) => s.text.isNotEmpty)
|
||
.where((s) {
|
||
// 选择了具体分类时,严格过滤只保留该分类
|
||
if (selectedType != null) {
|
||
return s.feedType == selectedType;
|
||
}
|
||
// "推荐"模式下,只显示启用的分类
|
||
return enabledChannelKeys.isEmpty ||
|
||
enabledChannelKeys.contains(s.feedType);
|
||
})
|
||
.toList();
|
||
|
||
// v6.144.0: 合并本地 DB 互动状态,确保已收藏/已点赞句子显示正确状态
|
||
// 注意:此处不使用 var 重赋值,直接生成 mergedSentences 供后续使用
|
||
final mergedSentences = await _mergeLocalInteractionState(newSentences);
|
||
|
||
final selfDeduped = <HomeSentence>[];
|
||
final selfSeen = <String>{};
|
||
final selfSeenTexts = <String>{};
|
||
for (final s in mergedSentences) {
|
||
if (!selfSeen.contains(s.id)) {
|
||
selfSeen.add(s.id);
|
||
if (deduplicateContent && s.text.isNotEmpty) {
|
||
final trimmed = s.text.trim();
|
||
if (selfSeenTexts.contains(trimmed)) continue;
|
||
selfSeenTexts.add(trimmed);
|
||
}
|
||
selfDeduped.add(s);
|
||
}
|
||
}
|
||
|
||
List<HomeSentence> unique;
|
||
if (replace) {
|
||
unique = selfDeduped.where((s) => !allSeenIds.contains(s.id)).toList();
|
||
if (deduplicateContent) {
|
||
unique = unique.where((s) {
|
||
if (s.text.isEmpty) return true;
|
||
return !allSeenTexts.contains(s.text.trim());
|
||
}).toList();
|
||
}
|
||
if (unique.isEmpty) unique = selfDeduped;
|
||
} else {
|
||
unique = selfDeduped.where((s) => !allSeenIds.contains(s.id)).toList();
|
||
if (deduplicateContent) {
|
||
unique = unique.where((s) {
|
||
if (s.text.isEmpty) return true;
|
||
return !allSeenTexts.contains(s.text.trim());
|
||
}).toList();
|
||
}
|
||
}
|
||
|
||
if (state.isCycling && unique.isNotEmpty) {
|
||
unique = unique
|
||
.where((s) => !state.lastCycleIds.contains(s.id))
|
||
.toList();
|
||
}
|
||
|
||
if (unique.isEmpty) {
|
||
// 任务6修复:去重后无新数据时,重置已见集合实现循环加载
|
||
// 避免骨架屏卡死 — 重置后用本次返回的数据继续填充列表
|
||
if (state.cycleRound >= 3) {
|
||
Log.w('fetchNewSentences: 去重3轮后仍无新数据, 重置已见集合进入循环加载');
|
||
// v6.144.0: 保留已收藏句子的 ID 和文本,避免循环加载时已收藏内容重复出现
|
||
// 用户已收藏的句子在"我的收藏"页面可见,无需在广场再次展示
|
||
final favoritedIds = state.sentences
|
||
.where((s) => s.isFavorited)
|
||
.map((s) => s.id)
|
||
.toSet();
|
||
final favoritedTexts = deduplicateContent
|
||
? state.sentences
|
||
.where((s) => s.isFavorited && s.text.isNotEmpty)
|
||
.map((s) => s.text.trim())
|
||
.toSet()
|
||
: <String>{};
|
||
// 重置已见集合,允许相同内容再次出现(循环加载)
|
||
allSeenIds.clear();
|
||
if (deduplicateContent) {
|
||
allSeenTexts.clear();
|
||
}
|
||
// 恢复已收藏句子的去重记录,确保循环加载时跳过已收藏内容
|
||
allSeenIds.addAll(favoritedIds);
|
||
if (deduplicateContent) {
|
||
allSeenTexts.addAll(favoritedTexts);
|
||
}
|
||
// 保留最近一批id避免立即重复 + v6.144.0: 已收藏句子 ID 合并排除
|
||
final recentIds = <String>{
|
||
if (state.sentences.length > 20)
|
||
...state.sentences
|
||
.sublist(state.sentences.length - 20)
|
||
.map((s) => s.id),
|
||
...favoritedIds,
|
||
};
|
||
final recentTexts = <String>{
|
||
if (deduplicateContent && state.sentences.length > 20)
|
||
...state.sentences
|
||
.sublist(state.sentences.length - 20)
|
||
.where((s) => s.text.isNotEmpty)
|
||
.map((s) => s.text.trim()),
|
||
...favoritedTexts,
|
||
};
|
||
|
||
// 重新处理本次返回的数据,仅排除最近20条 + 已收藏内容
|
||
final cycledSentences = mergedSentences.where((s) {
|
||
if (recentIds.contains(s.id)) return false;
|
||
if (deduplicateContent && s.text.isNotEmpty &&
|
||
recentTexts.contains(s.text.trim())) return false;
|
||
return true;
|
||
}).toList();
|
||
|
||
// 如果排除后仍为空,直接用全部数据(兜底防止空白)
|
||
final finalBatch = cycledSentences.isNotEmpty
|
||
? cycledSentences
|
||
: mergedSentences;
|
||
|
||
if (finalBatch.isNotEmpty) {
|
||
await saveToDb(finalBatch);
|
||
allSeenIds.addAll(finalBatch.map((s) => s.id));
|
||
if (deduplicateContent) {
|
||
for (final s in finalBatch) {
|
||
if (s.text.isNotEmpty) {
|
||
allSeenTexts.add(s.text.trim());
|
||
}
|
||
}
|
||
}
|
||
final updated = replace
|
||
? finalBatch
|
||
: [...state.sentences, ...finalBatch];
|
||
// 循环加载时不使用全局去重,允许列表持续增长
|
||
if (result.list.isNotEmpty) {
|
||
lastFeedId = result.list.last.id;
|
||
}
|
||
state = state.copyWith(
|
||
sentences: updated,
|
||
isLoading: false,
|
||
isForceLoading: false,
|
||
hasMore: true,
|
||
isOffline: false,
|
||
isCycling: false,
|
||
isCategorySwitching: false,
|
||
cycleRound: 0,
|
||
lastCycleIds: [],
|
||
);
|
||
AppToast.showInfo('已浏览全部内容,为你重新推荐');
|
||
} else {
|
||
// API返回数据但过滤后为空(分类不匹配等),停止加载
|
||
state = state.copyWith(
|
||
isLoading: false,
|
||
isForceLoading: false,
|
||
hasMore: false,
|
||
isCycling: false,
|
||
cycleRound: 0,
|
||
lastCycleIds: [],
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
state = state.copyWith(
|
||
isCycling: true,
|
||
cycleRound: state.cycleRound + 1,
|
||
lastCycleIds: state.sentences.map((s) => s.id).toList(),
|
||
);
|
||
currentPage = 1;
|
||
lastFeedId = null;
|
||
await fetchNewSentences();
|
||
return;
|
||
}
|
||
|
||
await saveToDb(unique);
|
||
|
||
allSeenIds.addAll(unique.map((s) => s.id));
|
||
if (deduplicateContent) {
|
||
for (final s in unique) {
|
||
if (s.text.isNotEmpty) {
|
||
allSeenTexts.add(s.text.trim());
|
||
}
|
||
}
|
||
}
|
||
|
||
final updated = replace ? unique : [...state.sentences, ...unique];
|
||
|
||
final finalList = deduplicateList(updated);
|
||
|
||
if (result.list.isNotEmpty) {
|
||
lastFeedId = result.list.last.id;
|
||
}
|
||
|
||
state = state.copyWith(
|
||
sentences: finalList,
|
||
isLoading: false,
|
||
isForceLoading: false,
|
||
hasMore: true,
|
||
isOffline: false,
|
||
isCycling: false,
|
||
isCategorySwitching: false,
|
||
cycleRound: 0,
|
||
lastCycleIds: [],
|
||
);
|
||
if (finalList.isNotEmpty) {
|
||
if (categoryCache.length >= maxCategoryCacheSize &&
|
||
!categoryCache.containsKey(cacheKey)) {
|
||
categoryCache.remove(categoryCache.keys.first);
|
||
}
|
||
categoryCache[cacheKey] = List.from(finalList);
|
||
}
|
||
} catch (e) {
|
||
Log.e('Feed列表拉取失败,降级Hitokoto', e);
|
||
AppToast.showWarning('网络不稳定,已切换离线模式');
|
||
state = state.copyWith(
|
||
isLoading: false,
|
||
isForceLoading: false,
|
||
hasMore: false,
|
||
isCycling: false,
|
||
cycleRound: 0,
|
||
lastCycleIds: [],
|
||
);
|
||
await fetchNewSentencesFallback();
|
||
}
|
||
}
|
||
|
||
Future<void> fetchNewSentencesFallback() async {
|
||
try {
|
||
final type = state.selectedType != null
|
||
? HitokotoType.values.firstWhere(
|
||
(t) => t.code == state.selectedType,
|
||
orElse: () => HitokotoType.literature,
|
||
)
|
||
: null;
|
||
final quotes = await HitokotoService.fetchBatch(count: 10, type: type);
|
||
if (quotes.isEmpty) {
|
||
state = state.copyWith(isLoading: false, hasMore: false);
|
||
return;
|
||
}
|
||
|
||
final newSentences = quotes.map(HomeSentence.fromHitokoto).toList();
|
||
final existingIds = state.sentences.map((s) => s.id).toSet();
|
||
final unique = newSentences
|
||
.where((s) => !existingIds.contains(s.id))
|
||
.toList();
|
||
|
||
await saveToDb(unique);
|
||
|
||
state = state.copyWith(
|
||
sentences: [...state.sentences, ...unique],
|
||
isLoading: false,
|
||
hasMore: unique.length >= 5,
|
||
isOffline: false,
|
||
);
|
||
} catch (e) {
|
||
Log.e('Hitokoto降级也失败', e);
|
||
state = state.copyWith(isLoading: false, isOffline: true);
|
||
}
|
||
}
|
||
|
||
/// 任务6修复:重置已见集合并重新加载 — 用于"没有更多内容"时的重试
|
||
///
|
||
/// 清空 allSeenIds/allSeenTexts,重置分页游标,从第1页重新拉取
|
||
Future<void> resetAndReload() async {
|
||
Log.i('resetAndReload: 重置已见集合,重新加载');
|
||
allSeenIds.clear();
|
||
if (deduplicateContent) {
|
||
allSeenTexts.clear();
|
||
}
|
||
currentPage = 1;
|
||
lastFeedId = null;
|
||
state = state.copyWith(
|
||
hasMore: true,
|
||
isCycling: false,
|
||
cycleRound: 0,
|
||
lastCycleIds: [],
|
||
isOffline: false,
|
||
isLoading: true,
|
||
);
|
||
await fetchNewSentences(replace: true);
|
||
}
|
||
|
||
Future<void> loadCachedSentences() async {
|
||
try {
|
||
final cached = await feedDb.getAllSentences();
|
||
if (cached.isNotEmpty) {
|
||
final sentences = cached.map(HomeSentence.fromDb).toList();
|
||
for (final s in sentences) {
|
||
allSeenIds.add(s.id);
|
||
if (deduplicateContent && s.text.isNotEmpty) {
|
||
allSeenTexts.add(s.text.trim());
|
||
}
|
||
}
|
||
state = state.copyWith(sentences: sentences, isLoading: false);
|
||
}
|
||
} catch (e) {
|
||
Log.e('缓存加载失败', e);
|
||
}
|
||
}
|
||
|
||
/// 从本地数据库加载自定义频道句子
|
||
/// 支持URL来源频道自动刷新拉取新数据
|
||
Future<void> _fetchCustomChannelSentences() async {
|
||
try {
|
||
final channelId = state.selectedType!.replaceFirst('custom_', '');
|
||
|
||
// 如果是首次加载(offset=0),尝试从URL源刷新数据
|
||
final offset = state.sentences.length;
|
||
if (offset == 0) {
|
||
await _refreshCustomChannelFromUrl(channelId);
|
||
}
|
||
|
||
final db = feedDb;
|
||
final rows = await (db.select(db.importedSentences)
|
||
..where((t) => t.channelId.equals(channelId))
|
||
..orderBy([(t) => OrderingTerm.desc(t.createdAt)])
|
||
..limit(20, offset: offset))
|
||
.get();
|
||
|
||
if (rows.isEmpty) {
|
||
state = state.copyWith(isLoading: false, hasMore: false);
|
||
return;
|
||
}
|
||
|
||
final sentences = rows.map(HomeSentence.fromImported).toList();
|
||
for (final s in sentences) {
|
||
allSeenIds.add(s.id);
|
||
}
|
||
|
||
state = state.copyWith(
|
||
sentences: [...state.sentences, ...sentences],
|
||
isLoading: false,
|
||
isForceLoading: false,
|
||
hasMore: rows.length >= 20,
|
||
isOffline: false,
|
||
);
|
||
} catch (e) {
|
||
Log.e('自定义频道数据加载失败', e);
|
||
state = state.copyWith(isLoading: false, isOffline: true);
|
||
}
|
||
}
|
||
|
||
/// 从URL来源刷新自定义频道数据
|
||
Future<void> _refreshCustomChannelFromUrl(String channelId) async {
|
||
try {
|
||
final db = feedDb;
|
||
// 查询频道来源
|
||
final sourceRows = await (db.select(db.channelSources)
|
||
..where((t) => t.channelId.equals(channelId)))
|
||
.get();
|
||
|
||
for (final source in sourceRows) {
|
||
if (source.type != 'url' || source.url.isEmpty) continue;
|
||
if (!source.autoRefresh) continue;
|
||
|
||
// 检查是否需要刷新(根据缓存TTL)
|
||
if (source.lastFetchAt != null) {
|
||
final elapsed = DateTime.now().difference(source.lastFetchAt!).inSeconds;
|
||
if (elapsed < source.cacheTtl) continue;
|
||
}
|
||
|
||
// 使用UrlAnalyzerService拉取更多数据
|
||
final items = await UrlAnalyzerService().fetchMore(source.url);
|
||
if (items.isEmpty) continue;
|
||
|
||
// 解析字段映射
|
||
final fieldMap = _parseFieldMapFromJson(source.fieldMap);
|
||
final format = _parseDataFormatStr(source.format);
|
||
|
||
// 导入句子
|
||
final fieldMapper = FieldMapperService();
|
||
int imported = 0;
|
||
for (final raw in items) {
|
||
final sentence = fieldMapper.applyMap(
|
||
rawData: raw,
|
||
channelId: channelId,
|
||
sourceId: source.id,
|
||
format: format,
|
||
fieldMap: fieldMap.isNotEmpty ? fieldMap : null,
|
||
);
|
||
if (sentence.title.isEmpty && sentence.content.isEmpty) continue;
|
||
|
||
// 去重检查
|
||
final exists = await (db.select(db.importedSentences)
|
||
..where((t) => t.hash.equals(sentence.hash)))
|
||
.getSingleOrNull();
|
||
if (exists != null) continue;
|
||
|
||
await db.into(db.importedSentences).insert(
|
||
ImportedSentencesCompanion.insert(
|
||
channelId: sentence.channelId,
|
||
sourceId: sentence.sourceId,
|
||
title: sentence.title,
|
||
category: Value(sentence.category),
|
||
content: sentence.content,
|
||
detail: Value(sentence.detail),
|
||
author: Value(sentence.author),
|
||
sourceTime: Value(sentence.sourceTime),
|
||
hash: sentence.hash,
|
||
createdAt: sentence.createdAt ?? DateTime.now(),
|
||
),
|
||
);
|
||
imported++;
|
||
}
|
||
|
||
// 更新频道总数和最后拉取时间
|
||
final count = await (db.select(db.importedSentences)
|
||
..where((t) => t.channelId.equals(channelId)))
|
||
.get()
|
||
.then((list) => list.length);
|
||
final channelRow = await (db.select(db.customChannels)
|
||
..where((t) => t.id.equals(channelId)))
|
||
.getSingleOrNull();
|
||
if (channelRow != null) {
|
||
await db.update(db.customChannels).replace(
|
||
channelRow.copyWith(
|
||
totalCount: count,
|
||
updatedAt: DateTime.now(),
|
||
),
|
||
);
|
||
}
|
||
|
||
// 更新来源的最后拉取时间
|
||
await db.update(db.channelSources)
|
||
.replace(source.copyWith(lastFetchAt: Value(DateTime.now())));
|
||
|
||
Log.i('自定义频道URL刷新: channelId=$channelId, 新导入$imported条');
|
||
}
|
||
} catch (e) {
|
||
Log.e('自定义频道URL刷新失败(非致命)', e);
|
||
}
|
||
}
|
||
|
||
/// 解析字段映射JSON
|
||
Map<String, String> _parseFieldMapFromJson(String json) {
|
||
try {
|
||
final map = const JsonDecoder().convert(json) as Map<String, dynamic>;
|
||
return map.map((k, v) => MapEntry(k, v.toString()));
|
||
} catch (_) {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
/// 解析数据格式字符串
|
||
DataFormat _parseDataFormatStr(String s) {
|
||
switch (s) {
|
||
case 'xianyanV1':
|
||
return DataFormat.xianyanV1;
|
||
case 'custom':
|
||
return DataFormat.custom;
|
||
default:
|
||
return DataFormat.hitokoto;
|
||
}
|
||
}
|
||
|
||
Future<void> saveToDb(List<HomeSentence> items) async {
|
||
try {
|
||
final now = DateTime.now();
|
||
final rows = items
|
||
.map(
|
||
(s) => Sentence(
|
||
id: s.id,
|
||
content: s.text,
|
||
author: s.author ?? '',
|
||
source: s.source ?? '',
|
||
tags: s.type ?? '',
|
||
feedType: s.feedType ?? '',
|
||
feedName: s.feedName ?? '',
|
||
feedIcon: s.feedIcon ?? '',
|
||
views: s.views,
|
||
imageUrl: '',
|
||
isFavorite: s.isFavorited,
|
||
isLiked: s.isLiked,
|
||
isRead: false,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
),
|
||
)
|
||
.toList();
|
||
await feedDb.insertOrUpdateBatch(rows);
|
||
} catch (e) {
|
||
Log.e('句子缓存写入失败', e);
|
||
}
|
||
}
|
||
|
||
void _syncDailyWithCharacterWidget(HomeSentence sentence) {
|
||
try {
|
||
final moodValue = KvStorage.getDouble('character_mood_value') ?? 0.5;
|
||
String moodName = 'neutral';
|
||
if (moodValue >= 0.8)
|
||
moodName = 'excited';
|
||
else if (moodValue >= 0.6)
|
||
moodName = 'happy';
|
||
else if (moodValue >= 0.4)
|
||
moodName = 'neutral';
|
||
else if (moodValue >= 0.2)
|
||
moodName = 'bored';
|
||
else
|
||
moodName = 'bored';
|
||
ref
|
||
.read(homeWidgetServiceProvider)
|
||
.updateDailyWithCharacter(
|
||
content: sentence.text,
|
||
author: sentence.author,
|
||
sentenceId: sentence.id,
|
||
mood: moodName,
|
||
);
|
||
} catch (e) {
|
||
Log.e('同步拾光角色小组件失败', e);
|
||
}
|
||
}
|
||
}
|