// ============================================================ // 闲言APP — Feed信息流数据模型 // 创建时间: 2026-04-28 // 更新时间: 2026-05-14 // 作用: Feed API + SearchAll API 数据模型,覆盖信息流/频道/互动/搜索 // 上次更新: 修复_extractTitleFromExtra字段映射,对齐API实际返回字段名 // ============================================================ import 'dart:convert'; import 'package:flutter/cupertino.dart'; import '../../../core/utils/data/extensions.dart'; /// Feed 类型主题色映射 /// /// 44种数据源对应不同色调,增强视觉差异化。 class FeedTypeColor { FeedTypeColor._(); static const Map _colorMap = { 'poetry': Color(0xFF4A90D9), 'wisdom': Color(0xFFD4A843), 'story': Color(0xFF4CAF50), 'hitokoto': Color(0xFF7C8DB5), 'riddle': Color(0xFF9C27B0), 'efs': Color(0xFFFF7043), 'brainteaser': Color(0xFF26A69A), 'saying': Color(0xFF8D6E63), 'lyric': Color(0xFFE91E63), 'why': Color(0xFF42A5F5), 'composition': Color(0xFFAB47BC), 'couplet': Color(0xFFEF5350), 'cs': Color(0xFF66BB6A), 'drug': Color(0xFF5C6BC0), 'herbal': Color(0xFF66BB6A), 'food': Color(0xFFFF9800), 'wine': Color(0xFFAD1457), 'article': Color(0xFF5C6BC0), 'chengyu': Color(0xFF7E57C2), 'hanzi': Color(0xFF29B6F6), 'cidian': Color(0xFF5C6BC0), 'prescription': Color(0xFF26A69A), 'tisana': Color(0xFF8D6E63), 'joke': Color(0xFFFFCA28), 'zgjm': Color(0xFF5C6BC0), 'illness': Color(0xFFEF5350), 'word': Color(0xFF29B6F6), 'abbr': Color(0xFF78909C), 'surname': Color(0xFF8D6E63), 'jieqi': Color(0xFF66BB6A), 'nation': Color(0xFF42A5F5), 'jiufang': Color(0xFFAD1457), 'lunyu': Color(0xFFD4A843), 'hdnj': Color(0xFF26A69A), 'jgj': Color(0xFF8D6E63), 'mz': Color(0xFF5C6BC0), 'zz': Color(0xFF7E57C2), 'zuozhuan': Color(0xFF4A90D9), 'sj': Color(0xFFD4A843), 'sgz': Color(0xFFEF5350), 'sbbf': Color(0xFF78909C), 'warring': Color(0xFFFF7043), 'wlyh': Color(0xFF8D6E63), 'bot': Color(0xFF66BB6A), }; static const Color _defaultColor = Color(0xFF6C63FF); static Color getColor(String? feedType) { if (feedType == null) return _defaultColor; return _colorMap[feedType] ?? _defaultColor; } static Color getColorWithAlpha(String? feedType, {double alpha = 0.15}) { return getColor(feedType).withValues(alpha: alpha); } /// Feed类型 → Interaction API targetType 映射 /// Interaction API允许的targetType: article, tool, poetry, cy, story, /// wisdom, hanzi, chengyu, cidian, saying, joke, feed, page, user static const _interactionTypeMap = { 'hitokoto': 'saying', 'sentence': 'saying', 'lyric': 'saying', 'riddle': 'feed', 'brainteaser': 'feed', 'efs': 'feed', 'article': 'article', 'poetry': 'poetry', 'cy': 'cy', 'story': 'story', 'wisdom': 'wisdom', 'hanzi': 'hanzi', 'chengyu': 'chengyu', 'cidian': 'cidian', 'saying': 'saying', 'joke': 'joke', 'tool': 'tool', 'page': 'page', 'user': 'user', 'feed': 'feed', }; /// 将Feed类型转换为Interaction API可接受的targetType static String toInteractionType(String? feedType) { if (feedType == null) return 'feed'; return _interactionTypeMap[feedType] ?? 'feed'; } } class FeedTypeIcon { FeedTypeIcon._(); static final Map _iconMap = { 'poetry': CupertinoIcons.book_fill, 'wisdom': CupertinoIcons.lightbulb_fill, 'story': CupertinoIcons.book, 'hitokoto': CupertinoIcons.chat_bubble_2_fill, 'riddle': CupertinoIcons.question_circle_fill, 'efs': CupertinoIcons.smiley_fill, 'brainteaser': CupertinoIcons.lightbulb_fill, 'saying': CupertinoIcons.chat_bubble_fill, 'lyric': CupertinoIcons.music_note_2, 'why': CupertinoIcons.question_circle, 'composition': CupertinoIcons.pencil, 'couplet': CupertinoIcons.textformat, 'cs': CupertinoIcons.gear, 'drug': CupertinoIcons.heart_fill, 'herbal': CupertinoIcons.globe, 'food': CupertinoIcons.cart_fill, 'wine': CupertinoIcons.drop_fill, 'article': CupertinoIcons.doc_text_fill, 'chengyu': CupertinoIcons.star_fill, 'hanzi': CupertinoIcons.textformat, 'cidian': CupertinoIcons.textformat, 'prescription': CupertinoIcons.doc_text, 'tisana': CupertinoIcons.globe, 'joke': CupertinoIcons.smiley, 'zgjm': CupertinoIcons.moon_fill, 'illness': CupertinoIcons.heart_fill, 'word': CupertinoIcons.textformat, 'abbr': CupertinoIcons.textformat, 'surname': CupertinoIcons.person_2_fill, 'jieqi': CupertinoIcons.calendar, 'nation': CupertinoIcons.flag_fill, 'jiufang': CupertinoIcons.drop, 'lunyu': CupertinoIcons.book_fill, 'hdnj': CupertinoIcons.calendar, 'jgj': CupertinoIcons.book, 'mz': CupertinoIcons.flag, 'zz': CupertinoIcons.doc_text, 'zuozhuan': CupertinoIcons.doc_text, 'sj': CupertinoIcons.book_fill, 'sgz': CupertinoIcons.star_fill, 'sbbf': CupertinoIcons.trash_fill, 'warring': CupertinoIcons.shield_fill, 'wlyh': CupertinoIcons.globe, 'bot': CupertinoIcons.gear, }; static const IconData _defaultIcon = CupertinoIcons.doc_text_fill; static IconData getIcon(String? feedType) { if (feedType == null) return _defaultIcon; return _iconMap[feedType] ?? _defaultIcon; } } int _parseInt(dynamic value) { if (value is int) return value; if (value is String) return int.tryParse(value) ?? 0; return 0; } int? _parseIntOrNull(dynamic value) { if (value is int) return value; if (value is String) return int.tryParse(value); return null; } /// Feed信息流条目模型 class FeedItem { const FeedItem({ required this.feedType, required this.feedName, required this.feedIcon, required this.id, required this.title, required this.author, required this.content, required this.summary, required this.views, this.likeCount = 0, this.favoriteCount = 0, this.commentCount = 0, this.shareCount = 0, this.isLiked = false, this.isFavorited = false, this.imageUrl = '', this.source = '', this.extra, this.myActions = const [], this.createtime, this.updatetime, this.rawData, }); final String feedType; final String feedName; final String feedIcon; final int id; final String title; final String author; final String content; final String summary; final int views; /// 互动计数 final int likeCount; final int favoriteCount; final int commentCount; final int shareCount; /// 交互状态(服务端返回) final bool isLiked; final bool isFavorited; final String imageUrl; final String source; final Map? extra; final List myActions; /// 时间戳 final int? createtime; final int? updatetime; /// 全量详情原始数据(fullDetail接口) final Map? rawData; bool get isReadLater => myActions.contains('readlater'); FeedItem copyWith({ List? myActions, int? views, int? likeCount, int? favoriteCount, int? commentCount, int? shareCount, bool? isLiked, bool? isFavorited, }) { return FeedItem( feedType: feedType, feedName: feedName, feedIcon: feedIcon, id: id, title: title, author: author, content: content, summary: summary, views: views ?? this.views, likeCount: likeCount ?? this.likeCount, favoriteCount: favoriteCount ?? this.favoriteCount, commentCount: commentCount ?? this.commentCount, shareCount: shareCount ?? this.shareCount, isLiked: isLiked ?? this.isLiked, isFavorited: isFavorited ?? this.isFavorited, imageUrl: imageUrl, source: source, extra: extra, myActions: myActions ?? this.myActions, createtime: createtime, updatetime: updatetime, rawData: rawData, ); } factory FeedItem.fromJson(Map json) { final extra = json['extra'] as Map?; final rawTitle = (json['title'] as String? ?? '').cleanHtml; final rawContent = (json['content'] as String? ?? '').cleanHtml; final rawSummary = (json['summary'] as String? ?? '').cleanHtml; String effectiveTitle = rawTitle; if (effectiveTitle.isEmpty && extra != null) { final feedType = json['feed_type'] as String? ?? ''; effectiveTitle = _extractTitleFromExtra(extra, feedType).cleanHtml; } String effectiveSummary = rawSummary; if (effectiveSummary.isEmpty && rawContent.isNotEmpty) { effectiveSummary = rawContent.length > 100 ? '${rawContent.substring(0, 100)}...' : rawContent; } return FeedItem( feedType: json['feed_type'] as String? ?? '', feedName: json['feed_name'] as String? ?? '', feedIcon: json['feed_icon'] as String? ?? '', id: _parseInt(json['id']), title: effectiveTitle, author: (json['author'] as String? ?? '').cleanHtml, content: rawContent, summary: effectiveSummary, views: _parseInt(json['views']), likeCount: _parseInt(json['like_count']), favoriteCount: _parseInt(json['favorite_count']), commentCount: _parseInt(json['comment_count']), shareCount: _parseInt(json['share_count']), isLiked: json['is_liked'] as bool? ?? false, isFavorited: json['is_favorited'] as bool? ?? false, imageUrl: extra?['image_url'] as String? ?? '', source: json['source'] as String? ?? '', extra: extra, myActions: (json['my_actions'] as List?) ?.map((e) => e.toString()) .toList() ?? [], createtime: _parseIntOrNull(json['createtime']), updatetime: _parseIntOrNull(json['updatetime']), rawData: json['raw_data'] as Map?, ); } } /// Feed频道模型 String _extractTitleFromExtra(Map extra, String feedType) { const typeFieldMap = >{ 'chengyu': ['cy', 'cyjs', 'cypy', 'cycc'], 'hanzi': ['pinyin', 'bushou'], 'cidian': ['zc', 'zcjs', 'zcpy'], 'illness': ['jb', 'zz'], 'drug': ['name', 'syz', 'goods_name', 'gg', 'cf'], 'herbal': ['name', 'effect', 'name_alias', 'spell'], 'food': ['sw', 'yh'], 'wine': ['name', 'ingredients', 'usage', 'source'], 'jiufang': [ 'name', 'ingredients', 'usage', 'source', 'method', 'categories', ], 'prescription': ['title', 'content'], 'tisana': ['name', 'effect', 'recipe', 'source'], 'couplet': ['hp', 'sl', 'xl', 'yy'], 'composition': ['zw'], 'riddle': ['riddle', 'miidii'], 'brainteaser': ['topic', 'answer'], 'saying': ['cy'], 'why': ['wt'], 'efs': ['facet', 'undertone'], 'joke': ['xh'], 'story': ['gs'], 'lyric': ['gc'], 'poetry': ['ss'], 'wisdom': ['my'], 'hitokoto': ['hitokoto', 'type_name', 'from_source', 'from_who'], 'zgjm': ['jm'], 'word': ['dc'], 'abbr': ['js'], 'surname': ['xs'], 'jieqi': ['jq'], 'nation': ['mz'], }; final fields = typeFieldMap[feedType] ?? []; for (final field in fields) { final val = extra[field]; if (val is String && val.isNotEmpty) return val; } for (final entry in extra.entries) { final val = entry.value; if (val is String && val.isNotEmpty && val.length < 100) return val; } return ''; } class FeedChannel { const FeedChannel({ required this.key, required this.name, required this.icon, required this.count, this.isEnabled = true, }); final String key; final String name; final String icon; final int count; /// 后台启用状态(来自推荐权重管理配置) final bool isEnabled; factory FeedChannel.fromJson(Map json) { return FeedChannel( key: json['key'] as String? ?? '', name: json['name'] as String? ?? '', icon: json['icon'] as String? ?? '', count: _parseInt(json['count']), isEnabled: json['is_enabled'] as bool? ?? true, ); } Map toJson() { return { 'key': key, 'name': name, 'icon': icon, 'count': count, 'is_enabled': isEnabled, }; } } /// Feed互动操作类型 enum FeedAction { like('like', '👍 点赞'), unlike('unlike', '取消点赞'), favorite('favorite', '⭐ 收藏'), unfavorite('unfavorite', '取消收藏'), share('share', '📤 分享'), dislike('dislike', '👎 不感兴趣'), readlater('readlater', '📖 稍后读'), unreadlater('unreadlater', '取消稍后读'), rating('rating', '⭐ 评分'), block('block', '🚫 屏蔽'), unblock('unblock', '取消屏蔽'), report('report', '🚨 举报'), readtime('readtime', '⏱️ 阅读时长'), comment('comment', '💬 评论'), commentLike('comment_like', '👍 评论点赞'), commentUnlike('comment_unlike', '取消评论点赞'); const FeedAction(this.code, this.label); final String code; final String label; } /// Feed评论模型 class FeedComment { const FeedComment({ required this.id, required this.userId, required this.username, required this.content, this.avatar = '', this.likeCount = 0, this.createtime, }); final int id; final int userId; final String username; final String content; final String avatar; final int likeCount; final int? createtime; factory FeedComment.fromJson(Map json) { return FeedComment( id: _parseInt(json['id']), userId: _parseInt(json['user_id']), username: json['username'] as String? ?? '', content: json['content'] as String? ?? '', avatar: json['avatar'] as String? ?? '', likeCount: _parseInt(json['like_count']), createtime: _parseIntOrNull(json['createtime']), ); } } /// Feed评论列表结果 class FeedCommentResult { const FeedCommentResult({ required this.list, required this.total, required this.feedType, required this.feedId, }); final List list; final int total; final String feedType; final int feedId; static FeedCommentResult empty() => const FeedCommentResult(list: [], total: 0, feedType: '', feedId: 0); } /// Feed统计 — 单频道统计 class FeedChannelStat { const FeedChannelStat({ required this.key, required this.name, required this.icon, required this.count, this.views = 0, }); final String key; final String name; final String icon; final int count; final int views; factory FeedChannelStat.fromJson(Map json) { return FeedChannelStat( key: json['key'] as String? ?? '', name: json['name'] as String? ?? '', icon: json['icon'] as String? ?? '', count: _parseInt(json['count']), views: _parseInt(json['views']), ); } } /// Feed统计 — 互动统计条目 class FeedInteractionStat { const FeedInteractionStat({required this.action, required this.count}); final String action; final int count; factory FeedInteractionStat.fromJson(Map json) { return FeedInteractionStat( action: json['action'] as String? ?? '', count: _parseInt(json['cnt']), ); } } /// Feed统计结果 class FeedStatsResult { const FeedStatsResult({ this.totalContent = 0, this.totalViews = 0, this.channelCount = 0, this.channels = const [], this.interactions = const [], }); final int totalContent; final int totalViews; final int channelCount; final List channels; final List interactions; static FeedStatsResult empty() => const FeedStatsResult(); } /// 搜索建议条目 class SearchSuggestion { const SearchSuggestion({required this.text, this.type = '', this.label = ''}); final String text; final String type; final String label; factory SearchSuggestion.fromJson(Map json) { return SearchSuggestion( text: json['text'] as String? ?? '', type: json['type'] as String? ?? '', label: json['label'] as String? ?? '', ); } } /// 搜索结果分类统计 class SearchTypeStat { const SearchTypeStat({ required this.type, required this.name, required this.icon, required this.count, }); final String type; final String name; final String icon; final int count; factory SearchTypeStat.fromJson(Map json) { return SearchTypeStat( type: json['type'] as String? ?? '', name: json['name'] as String? ?? '', icon: json['icon'] as String? ?? '', count: _parseInt(json['count']), ); } } /// 搜索高亮结果 class SearchHighlightItem { const SearchHighlightItem({ required this.item, this.titleHighlight = '', this.contentHighlight = '', this.authorHighlight = '', }); final FeedItem item; final String titleHighlight; final String contentHighlight; final String authorHighlight; } /// Feed列表查询参数 class FeedListParams { const FeedListParams({ this.channel = 'all', this.sort = 'newest', this.page = 1, this.limit = 20, this.lastId, this.lite = false, this.seenIds, this.seenHashes, }); final String channel; final String sort; final int page; final int limit; final int? lastId; final bool lite; final List? seenIds; final List? seenHashes; Map toQueryParameters() { final params = { 'channel': channel, 'sort': sort, 'page': page, 'limit': limit, }; if (lastId != null) params['last_id'] = lastId; if (lite) params['lite'] = 1; if (seenIds != null && seenIds!.isNotEmpty) { params['seen_ids'] = seenIds!.join(','); } if (seenHashes != null && seenHashes!.isNotEmpty) { params['seen_hashes'] = seenHashes!.join(','); } return params; } } /// Feed列表结果 class FeedListResult { const FeedListResult({ required this.list, required this.total, required this.page, required this.hasMore, }); final List list; final int total; final int page; final bool hasMore; static FeedListResult empty() => const FeedListResult(list: [], total: 0, page: 1, hasMore: false); } /// Feed推荐结果 class FeedRecommendResult { const FeedRecommendResult({required this.list, required this.personalized}); final List list; final bool personalized; static FeedRecommendResult empty() => const FeedRecommendResult(list: [], personalized: false); } /// Feed混合信息流结果 class FeedMixResult { const FeedMixResult({ required this.list, required this.total, required this.mode, required this.channels, required this.limit, }); final List list; final int total; final String mode; final List channels; final int limit; static FeedMixResult empty() => const FeedMixResult( list: [], total: 0, mode: 'random', channels: [], limit: 0, ); } /// Feed混合规则配置 class FeedMixConfig { const FeedMixConfig({ this.mode = 'random', this.channels = const [], this.ratios = const {}, this.groupSize = 3, this.limit = 20, this.sort = 'newest', }); final String mode; final List channels; final Map ratios; final int groupSize; final int limit; final String sort; Map toQueryParameters() { final params = { 'mode': mode, 'limit': limit, 'sort': sort, }; if (channels.isNotEmpty) { params['channels'] = channels.join(','); } if (mode == 'ratio' && ratios.isNotEmpty) { params['ratios'] = jsonEncode(ratios); } if (mode == 'group') { params['group_size'] = groupSize; } return params; } FeedMixConfig copyWith({ String? mode, List? channels, Map? ratios, int? groupSize, int? limit, String? sort, }) { return FeedMixConfig( mode: mode ?? this.mode, channels: channels ?? this.channels, ratios: ratios ?? this.ratios, groupSize: groupSize ?? this.groupSize, limit: limit ?? this.limit, sort: sort ?? this.sort, ); } Map toJson() => { 'mode': mode, 'channels': channels, 'ratios': ratios, 'groupSize': groupSize, 'limit': limit, 'sort': sort, }; factory FeedMixConfig.fromJson(Map json) => FeedMixConfig( mode: json['mode'] as String? ?? 'random', channels: (json['channels'] as List? ?? []) .map((e) => e.toString()) .toList(), ratios: Map.from(json['ratios'] as Map? ?? {}), groupSize: _parseInt(json['groupSize']), limit: _parseInt(json['limit']), sort: json['sort'] as String? ?? 'newest', ); } /// Feed刷新检查结果 class FeedRefreshResult { const FeedRefreshResult({ required this.hasNew, required this.newCount, required this.latestId, }); final bool hasNew; final int newCount; final int latestId; } /// Feed刷新内容结果 class FeedRefreshContentResult { const FeedRefreshContentResult({ required this.list, required this.total, required this.channel, required this.sort, required this.limit, }); final List list; final int total; final String channel; final String sort; final int limit; static FeedRefreshContentResult empty() => const FeedRefreshContentResult( list: [], total: 0, channel: 'all', sort: 'newest', limit: 20, ); } /// Feed偏好设置 class FeedPreferences { const FeedPreferences({ this.disabledChannels = const [], this.mixMode = 'random', this.mixChannels = const [], this.mixRatios = const {}, this.groupSize = 3, this.deduplicate = true, this.sort = 'newest', this.homeCardMode = 'random', this.homeCardChannels = const [], this.perPage = 20, }); final List disabledChannels; final String mixMode; final List mixChannels; final Map mixRatios; final int groupSize; final bool deduplicate; final String sort; final String homeCardMode; final List homeCardChannels; final int perPage; factory FeedPreferences.fromJson(Map json) => FeedPreferences( disabledChannels: (json['disabled_channels'] as List? ?? []) .map((e) => e.toString()) .toList(), mixMode: json['mix_mode'] as String? ?? 'random', mixChannels: (json['mix_channels'] as List? ?? []) .map((e) => e.toString()) .toList(), mixRatios: Map.from(json['mix_ratios'] as Map? ?? {}), groupSize: _parseInt(json['group_size']), deduplicate: json['deduplicate'] as bool? ?? true, sort: json['sort'] as String? ?? 'newest', homeCardMode: json['home_card_mode'] as String? ?? 'random', homeCardChannels: (json['home_card_channels'] as List? ?? []) .map((e) => e.toString()) .toList(), perPage: _parseInt(json['per_page']), ); Map toJson() => { 'disabled_channels': disabledChannels, 'mix_mode': mixMode, 'mix_channels': mixChannels, 'mix_ratios': mixRatios, 'group_size': groupSize, 'deduplicate': deduplicate, 'sort': sort, 'home_card_mode': homeCardMode, 'home_card_channels': homeCardChannels, 'per_page': perPage, }; } /// SearchAll 聚合搜索结果 class SearchAllResult { const SearchAllResult({ required this.list, required this.total, required this.typeStats, this.keyword = '', this.mode = 'fuzzy', this.page = 1, this.hasMore = false, }); final List list; final int total; final List typeStats; final String keyword; final String mode; final int page; final bool hasMore; static SearchAllResult empty() => const SearchAllResult(list: [], total: 0, typeStats: []); } /// 数据源分类模型 /// /// 对应 /api/searchall/sources 返回的每个数据源。 class SourceCategory { const SourceCategory({ required this.type, required this.name, required this.icon, required this.searchFields, this.count = 0, }); final String type; final String name; final String icon; final List searchFields; final int count; factory SourceCategory.fromJson(Map json) { return SourceCategory( type: json['key'] as String? ?? json['type'] as String? ?? '', name: json['name'] as String? ?? '', icon: json['icon'] as String? ?? '', searchFields: (json['search_fields'] as List? ?? []) .map((e) => e.toString()) .toList(), count: _parseInt(json['count']), ); } } /// 热搜关键词(带计数) /// /// 对应 /api/searchall/hot 返回的 hot_list 条目。 class HotKeyword { const HotKeyword({required this.keyword, this.count = 0}); final String keyword; final int count; }