feat(app): 客户端展示多语言并支持筛选分页
This commit is contained in:
1 parent
8481611372
commit
8b50b5bd06
68 files changed
+2742
-1120
No files matched your search
@@ -1,17 +1,30 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'locale_text.dart';
|
||||
|
||||
/// 服务端内容分类。id 按契约保持字符串,避免雪花 ID 精度丢失。
|
||||
class ContentCategory {
|
||||
const ContentCategory({required this.id, required this.name});
|
||||
const ContentCategory({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.locales = const {},
|
||||
});
|
||||
|
||||
final String id;
|
||||
|
||||
/// 中文名称,与 locales 里的 zh 相同。
|
||||
final String name;
|
||||
final Map<String, String> locales;
|
||||
|
||||
/// 解析 `GET /app/categories` 的单项。
|
||||
factory ContentCategory.fromJson(Map<String, dynamic> json) {
|
||||
return ContentCategory(
|
||||
id: (json['id'] ?? '').toString(),
|
||||
name: json['name'] as String? ?? '',
|
||||
);
|
||||
final locales = localeMap(json['locales']);
|
||||
final name = json['name'] as String? ?? locales['zh'] ?? '';
|
||||
return ContentCategory(id: (json['id'] ?? '').toString(), name: name, locales: locales);
|
||||
}
|
||||
|
||||
/// 按界面语言取名称。缺对应语言时回落到中文。
|
||||
String localizedName(String languageCode) {
|
||||
return localizedText(languageCode, name, locales);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:ui';
|
||||
|
||||
import 'json_numbers.dart';
|
||||
import 'locale_text.dart';
|
||||
|
||||
/// 短剧与剧集,字段与后端 `/app/**` 的下发结构对应。
|
||||
///
|
||||
@@ -76,7 +79,14 @@ class Drama {
|
||||
required this.synopsis,
|
||||
required this.posterUrl,
|
||||
this.episodes = const [],
|
||||
this.titleLocales = const {},
|
||||
this.synopsisLocales = const {},
|
||||
this.tag = '',
|
||||
this.tagLocales = const {},
|
||||
this.tagColor = '',
|
||||
this.category = '',
|
||||
this.categoryLocales = const {},
|
||||
this.types = const [],
|
||||
this.genre = '',
|
||||
this.totalEpisodes = 0,
|
||||
this.freeEpisodes = 0,
|
||||
@@ -94,9 +104,16 @@ class Drama {
|
||||
final String id;
|
||||
final String title;
|
||||
final String synopsis;
|
||||
final Map<String, String> titleLocales;
|
||||
final Map<String, String> synopsisLocales;
|
||||
final String tag;
|
||||
final Map<String, String> tagLocales;
|
||||
final String tagColor;
|
||||
final String posterUrl;
|
||||
final List<Episode> episodes;
|
||||
final String category;
|
||||
final Map<String, String> categoryLocales;
|
||||
final List<Map<String, String>> types;
|
||||
final String genre;
|
||||
final int totalEpisodes;
|
||||
final int freeEpisodes;
|
||||
@@ -127,19 +144,26 @@ class Drama {
|
||||
/// 列表卡片:带一集可播预览(首页直接起播),完整剧集进详情再取。
|
||||
factory Drama.fromCardJson(Map<String, dynamic> json) {
|
||||
final preview = json['preview'];
|
||||
final text = _DramaText.fromJson(json);
|
||||
return Drama(
|
||||
id: (json['id'] ?? '').toString(),
|
||||
title: json['title'] as String? ?? '',
|
||||
synopsis: json['synopsis'] as String? ?? '',
|
||||
title: text.title,
|
||||
synopsis: text.synopsis,
|
||||
titleLocales: text.titleLocales,
|
||||
synopsisLocales: text.synopsisLocales,
|
||||
tag: text.tag,
|
||||
tagLocales: text.tagLocales,
|
||||
tagColor: json['tagColor'] as String? ?? '',
|
||||
posterUrl: json['cover'] as String? ?? '',
|
||||
episodes: preview is Map<String, dynamic>
|
||||
? [Episode.fromJson(preview)]
|
||||
: const [],
|
||||
category: json['category'] as String? ?? '',
|
||||
genre: json['genre'] as String? ?? '',
|
||||
category: text.category,
|
||||
categoryLocales: text.categoryLocales,
|
||||
types: text.types,
|
||||
genre: text.genre,
|
||||
totalEpisodes: (json['totalEpisodes'] as num?)?.toInt() ?? 0,
|
||||
isHot: json['isHot'] == true,
|
||||
// viewCount 后端是 Long,按字符串下发(见 json_numbers.dart)
|
||||
viewCount: jsonToInt(json['viewCount']),
|
||||
likeCount: _optionalInt(json['likeCount']),
|
||||
favoriteCount: _optionalInt(json['favoriteCount']),
|
||||
@@ -152,13 +176,21 @@ class Drama {
|
||||
|
||||
/// 详情:带剧集列表。
|
||||
factory Drama.fromDetailJson(Map<String, dynamic> json) {
|
||||
final text = _DramaText.fromJson(json);
|
||||
return Drama(
|
||||
id: (json['id'] ?? '').toString(),
|
||||
title: json['title'] as String? ?? '',
|
||||
synopsis: json['synopsis'] as String? ?? '',
|
||||
title: text.title,
|
||||
synopsis: text.synopsis,
|
||||
titleLocales: text.titleLocales,
|
||||
synopsisLocales: text.synopsisLocales,
|
||||
tag: text.tag,
|
||||
tagLocales: text.tagLocales,
|
||||
tagColor: json['tagColor'] as String? ?? '',
|
||||
posterUrl: json['cover'] as String? ?? '',
|
||||
category: json['category'] as String? ?? '',
|
||||
genre: json['genre'] as String? ?? '',
|
||||
category: text.category,
|
||||
categoryLocales: text.categoryLocales,
|
||||
types: text.types,
|
||||
genre: text.genre,
|
||||
totalEpisodes: (json['totalEpisodes'] as num?)?.toInt() ?? 0,
|
||||
freeEpisodes: (json['freeEpisodes'] as num?)?.toInt() ?? 0,
|
||||
isHot: json['isHot'] == true,
|
||||
@@ -176,6 +208,37 @@ class Drama {
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
String localizedTitle(String languageCode) {
|
||||
return localizedText(languageCode, title, titleLocales);
|
||||
}
|
||||
|
||||
String localizedSynopsis(String languageCode) {
|
||||
return localizedText(languageCode, synopsis, synopsisLocales);
|
||||
}
|
||||
|
||||
String localizedTag(String languageCode) {
|
||||
return localizedText(languageCode, tag, tagLocales);
|
||||
}
|
||||
|
||||
/// 标签角标背景色。接口没给合法色值时用 fallback。
|
||||
Color tagRibbonColor(Color fallback) {
|
||||
final parsed = _colorFromHex(tagColor);
|
||||
return parsed ?? fallback;
|
||||
}
|
||||
|
||||
/// 按当前语言取类型名称。接口没带语言表时返回空,调用方继续用中文 genre。
|
||||
List<String> localizedTypeLabels(String languageCode) {
|
||||
final labels = <String>[];
|
||||
for (final item in types) {
|
||||
final label = localizedText(languageCode, item['zh'] ?? '', item);
|
||||
if (label.isNotEmpty && !labels.contains(label)) labels.add(label);
|
||||
}
|
||||
if (labels.isNotEmpty) return labels;
|
||||
final primary = localizedText(languageCode, '', categoryLocales);
|
||||
if (primary.isNotEmpty) return [primary];
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
int? _optionalInt(dynamic value) => value == null ? null : jsonToInt(value);
|
||||
@@ -184,3 +247,81 @@ String? _optionalString(dynamic value) {
|
||||
final text = value is String ? value.trim() : '';
|
||||
return text.isEmpty ? null : text;
|
||||
}
|
||||
|
||||
Color? _colorFromHex(String raw) {
|
||||
final text = raw.trim();
|
||||
if (text.length != 7 || !text.startsWith('#')) return null;
|
||||
final value = int.tryParse(text.substring(1), radix: 16);
|
||||
if (value == null) return null;
|
||||
return Color(0xFF000000 | value);
|
||||
}
|
||||
|
||||
List<Map<String, String>> _typeLocales(Object? raw) {
|
||||
if (raw is! List) return const [];
|
||||
return raw.whereType<Map>().map(localeMap).toList(growable: false);
|
||||
}
|
||||
|
||||
/// 从语言表还原中文展示字段。列表接口不再单独下发 title / synopsis / tag。
|
||||
class _DramaText {
|
||||
const _DramaText({
|
||||
required this.title,
|
||||
required this.synopsis,
|
||||
required this.titleLocales,
|
||||
required this.synopsisLocales,
|
||||
required this.tag,
|
||||
required this.tagLocales,
|
||||
required this.category,
|
||||
required this.categoryLocales,
|
||||
required this.types,
|
||||
required this.genre,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String synopsis;
|
||||
final Map<String, String> titleLocales;
|
||||
final Map<String, String> synopsisLocales;
|
||||
final String tag;
|
||||
final Map<String, String> tagLocales;
|
||||
final String category;
|
||||
final Map<String, String> categoryLocales;
|
||||
final List<Map<String, String>> types;
|
||||
final String genre;
|
||||
|
||||
factory _DramaText.fromJson(Map<String, dynamic> json) {
|
||||
final titleLocales = localeMap(json['titleLocales']);
|
||||
final synopsisLocales = localeMap(json['synopsisLocales']);
|
||||
final tagLocales = localeMap(json['tagLocales']);
|
||||
final categoryLocales = localeMap(json['categoryLocales']);
|
||||
final types = _typeLocales(json['types']);
|
||||
final category = _firstText(categoryLocales, json['category'], types);
|
||||
return _DramaText(
|
||||
title: _firstText(titleLocales, json['title']),
|
||||
synopsis: _firstText(synopsisLocales, json['synopsis'] ?? json['description']),
|
||||
titleLocales: titleLocales,
|
||||
synopsisLocales: synopsisLocales,
|
||||
tag: _firstText(tagLocales, json['tag']),
|
||||
tagLocales: tagLocales,
|
||||
category: category,
|
||||
categoryLocales: categoryLocales,
|
||||
types: types,
|
||||
genre: _genreText(types, json['genre']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _firstText(Map<String, String> locales, Object? fallback, [List<Map<String, String>>? types]) {
|
||||
final chinese = locales['zh'] ?? '';
|
||||
if (chinese.isNotEmpty) return chinese;
|
||||
if (types != null && types.isNotEmpty) {
|
||||
final label = types.first['zh'] ?? '';
|
||||
if (label.isNotEmpty) return label;
|
||||
}
|
||||
return fallback is String ? fallback : '';
|
||||
}
|
||||
|
||||
String _genreText(List<Map<String, String>> types, Object? fallback) {
|
||||
final names = types.map((item) => item['zh'] ?? '').where((value) => value.isNotEmpty);
|
||||
final joined = names.join(' / ');
|
||||
if (joined.isNotEmpty) return joined;
|
||||
return fallback is String ? fallback : '';
|
||||
}
|
||||
@@ -48,7 +48,7 @@ class FeedSection {
|
||||
factory FeedSection.fromJson(Map<String, dynamic> json) {
|
||||
return FeedSection(
|
||||
code: json['code'] as String? ?? '',
|
||||
title: json['title'] as String? ?? '',
|
||||
title: _sectionTitle(json),
|
||||
dramas: (json['dramas'] as List? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(Drama.fromCardJson)
|
||||
@@ -84,3 +84,12 @@ class FeedBanner {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _sectionTitle(Map<String, dynamic> json) {
|
||||
final locales = json['titleLocales'];
|
||||
if (locales is Map) {
|
||||
final chinese = locales['zh']?.toString() ?? '';
|
||||
if (chinese.isNotEmpty) return chinese;
|
||||
}
|
||||
return json['title'] as String? ?? '';
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
/// 把接口里的语言表解析成字符串映射。
|
||||
Map<String, String> localeMap(Object? raw) {
|
||||
if (raw is! Map) return const {};
|
||||
return raw.map((key, value) => MapEntry('$key', value?.toString() ?? ''));
|
||||
}
|
||||
|
||||
/// 按当前语言取文案。没有对应语言时用中文,中文也没有时用 fallback。
|
||||
String localizedText(String languageCode, String fallback, Map<String, String> locales) {
|
||||
final selected = locales[languageCode] ?? '';
|
||||
if (selected.isNotEmpty) return selected;
|
||||
final chinese = locales['zh'] ?? '';
|
||||
if (chinese.isNotEmpty) return chinese;
|
||||
return fallback;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'drama.dart';
|
||||
import 'locale_text.dart';
|
||||
|
||||
/// C 端剧单:`GET /app/playlists` 与 `GET /app/playlists/{id}` 的同一结构。
|
||||
class PlaylistDetail {
|
||||
const PlaylistDetail({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.description,
|
||||
this.locales = const {},
|
||||
this.descriptionLocales = const {},
|
||||
required this.dramaCount,
|
||||
required this.dramas,
|
||||
this.pageIndex = 1,
|
||||
this.pageSize = 0,
|
||||
});
|
||||
|
||||
final String id;
|
||||
|
||||
/// 中文名称,与 locales 里的 zh 相同。
|
||||
final String name;
|
||||
final Map<String, String> locales;
|
||||
|
||||
/// 中文描述,与 descriptionLocales 里的 zh 相同。
|
||||
final String description;
|
||||
final Map<String, String> descriptionLocales;
|
||||
final int dramaCount;
|
||||
final int pageIndex;
|
||||
final int pageSize;
|
||||
final List<Drama> dramas;
|
||||
|
||||
factory PlaylistDetail.fromJson(Map<String, dynamic> json) {
|
||||
final dramas = _dramasOf(json['list']);
|
||||
final locales = localeMap(json['locales']);
|
||||
final descriptions = localeMap(json['descriptionLocales']);
|
||||
return PlaylistDetail(
|
||||
id: (json['id'] ?? '').toString(),
|
||||
name: json['name'] as String? ?? locales['zh'] ?? '',
|
||||
locales: locales,
|
||||
description: json['description'] as String? ?? descriptions['zh'] ?? '',
|
||||
descriptionLocales: descriptions,
|
||||
dramaCount: (json['dramaCount'] as num?)?.toInt() ?? dramas.length,
|
||||
pageIndex: (json['pageIndex'] as num?)?.toInt() ?? 1,
|
||||
pageSize: (json['pageSize'] as num?)?.toInt() ?? dramas.length,
|
||||
dramas: dramas,
|
||||
);
|
||||
}
|
||||
|
||||
/// 按界面语言取名称。缺对应语言时回落到中文。
|
||||
String localizedName(String languageCode) {
|
||||
return localizedText(languageCode, name, locales);
|
||||
}
|
||||
|
||||
/// 按界面语言取描述。缺对应语言时回落到中文。
|
||||
String localizedDescription(String languageCode) {
|
||||
return localizedText(languageCode, description, descriptionLocales);
|
||||
}
|
||||
}
|
||||
|
||||
List<Drama> _dramasOf(Object? rows) {
|
||||
if (rows is! List) return const [];
|
||||
return rows
|
||||
.whereType<Map>()
|
||||
.map((item) => Drama.fromCardJson(Map<String, dynamic>.from(item)))
|
||||
.toList(growable: false);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'json_numbers.dart';
|
||||
import 'locale_text.dart';
|
||||
|
||||
/// 服务端续看记录:`GET /app/watch/progress?dramaId=` 与 `/app/watch/recent` 的下发结构。
|
||||
///
|
||||
@@ -13,6 +14,7 @@ class WatchProgress {
|
||||
required this.position,
|
||||
required this.duration,
|
||||
this.dramaTitle,
|
||||
this.titleLocales = const {},
|
||||
this.dramaCover,
|
||||
this.episodeNumber,
|
||||
this.totalEpisodes,
|
||||
@@ -25,6 +27,7 @@ class WatchProgress {
|
||||
final String dramaId;
|
||||
final String episodeId;
|
||||
final String? dramaTitle;
|
||||
final Map<String, String> titleLocales;
|
||||
final String? dramaCover;
|
||||
final int? episodeNumber;
|
||||
final int? totalEpisodes;
|
||||
@@ -45,11 +48,17 @@ class WatchProgress {
|
||||
return (position.inMilliseconds / duration.inMilliseconds).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
/// 按当前语言取剧名。没有对应语言时回退中文,再回退 dramaTitle。
|
||||
String localizedTitle(String languageCode) {
|
||||
return localizedText(languageCode, dramaTitle ?? '', titleLocales);
|
||||
}
|
||||
|
||||
factory WatchProgress.fromJson(Map<String, dynamic> json) {
|
||||
return WatchProgress(
|
||||
dramaId: (json['dramaId'] ?? '').toString(),
|
||||
episodeId: (json['episodeId'] ?? '').toString(),
|
||||
dramaTitle: json['dramaTitle']?.toString(),
|
||||
titleLocales: localeMap(json['titleLocales']),
|
||||
dramaCover: json['dramaCover']?.toString(),
|
||||
episodeNumber: json['episodeNumber'] == null
|
||||
? null
|
||||
@@ -72,6 +81,7 @@ class WatchProgress {
|
||||
'dramaId': dramaId,
|
||||
'episodeId': episodeId,
|
||||
if (dramaTitle != null) 'dramaTitle': dramaTitle,
|
||||
if (titleLocales.isNotEmpty) 'titleLocales': titleLocales,
|
||||
if (dramaCover != null) 'dramaCover': dramaCover,
|
||||
if (episodeNumber != null) 'episodeNumber': episodeNumber,
|
||||
if (totalEpisodes != null) 'totalEpisodes': totalEpisodes,
|
||||
|
||||
@@ -31,9 +31,13 @@ class WatchRecord {
|
||||
}
|
||||
|
||||
/// 服务端分页标题优先,详情标题作为回退。
|
||||
String get displayTitle {
|
||||
final pageTitle = progress.dramaTitle?.trim() ?? '';
|
||||
return pageTitle.isNotEmpty ? pageTitle : drama?.title.trim() ?? '';
|
||||
String get displayTitle => localizedDisplayTitle('zh');
|
||||
|
||||
/// 按当前语言取标题。分页语言表优先,详情语言表作为回退。
|
||||
String localizedDisplayTitle(String languageCode) {
|
||||
final pageTitle = progress.localizedTitle(languageCode).trim();
|
||||
if (pageTitle.isNotEmpty) return pageTitle;
|
||||
return drama?.localizedTitle(languageCode).trim() ?? '';
|
||||
}
|
||||
|
||||
/// 服务端分页封面优先,详情封面作为回退。
|
||||
|
||||
Reference in new issue
Block a user