Files
xianyan/lib/features/home/providers/home_feed_mixin.dart
2026-06-27 04:57:00 +08:00

1167 lines
39 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// ============================================================
/// 闲言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;
// 保留最近添加的 IDLinkedHashSet 保持插入顺序,取末尾部分)
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 winsOR 合并)
/// - 服务端 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);
}
}
}