313 lines
13 KiB
Dart
313 lines
13 KiB
Dart
import 'dart:convert';
|
||
|
||
/// `skin_config.json` 中 `extConfig.keys` / `extConfig.itemKeys` 的描述:
|
||
/// **逻辑字段** ↔ 远端/common_info 下发的 **JSON 键名列表**(按顺序首个存在的键生效,便于兼容多版本键名)。
|
||
///
|
||
/// 可选的 [itemKeysHome] / [itemKeysTask]:同一逻辑名可在**首页展示**与**创建任务/生图**两侧映射到不同线网键(值结构与 [itemKeys] 相同:逻辑名 → 键名列表)。
|
||
/// 未配置时回退到 [itemKeys] 中对应项(即与旧版单表行为一致)。
|
||
///
|
||
/// [taskItemMapping]:`extConfig.items` 每一项在展示/生图前要**对齐** [GET /v1/image/img2video/tasks] 经 [FieldMapping.mapResponse] 后的单项结构([TaskItem.fromJson] 的输入)。
|
||
/// **键**为任务项上的**目标路径**(点号嵌套,如 `previewVideo.url`、`resolution720p.credits`),**值**为在源对象上按顺序尝试的**源路径**列表(点号表示嵌套)。
|
||
/// 若未配置或为空,则仍按旧逻辑(先 mapResponse,再猜测 Task 形态或走 [ExtConfigItem.fromJson])。
|
||
///
|
||
/// [defaultItemTitleWhenEmpty]:`extConfig.items` 解析后 [ExtConfigItem.title] 仍为空(仅空白)时填入的占位文案(`extConfig.defaultItemTitle`)。
|
||
///
|
||
/// 「固定字段」指除兜底图 [imageFixKeys] 外的首页策略与列表项语义字段;`image_fix` 仅走 [itemImageFixKeys],默认 `["image_fix"]`,可在 JSON 中改写。
|
||
class ExtConfigKeySchema {
|
||
const ExtConfigKeySchema({
|
||
required this.showVideoMenuKeys,
|
||
required this.forbidScreenshotKeys,
|
||
required this.blockScreenshotKeys,
|
||
required this.allowThirdPartyPaymentKeys,
|
||
required this.privacyUrlKeys,
|
||
required this.agreementUrlKeys,
|
||
required this.itemsKeys,
|
||
required this.itemImageKeys,
|
||
required this.itemImageFixKeys,
|
||
required this.itemImgNeedKeys,
|
||
required this.itemCostKeys,
|
||
required this.itemCost480pKeys,
|
||
required this.itemCost720pKeys,
|
||
required this.itemTitleKeys,
|
||
required this.itemTemplateNameKeys,
|
||
required this.itemTaskTypeKeys,
|
||
required this.itemParamsKeys,
|
||
required this.itemDetailKeys,
|
||
required this.itemVideoUrlKeys,
|
||
this.itemKeysHome,
|
||
this.itemKeysTask,
|
||
this.taskItemMapping,
|
||
this.itemsApplyFieldMappingBeforeTaskMapping = true,
|
||
this.defaultItemTitleWhenEmpty = '-',
|
||
});
|
||
|
||
/// 是否展示顶部 Video/分类 Tab,并把 items 固定为最后一格 Tab。
|
||
final List<String> showVideoMenuKeys;
|
||
|
||
/// 线网布尔为 `true` 时表示**禁止**截屏的键(默认 `screen`)。
|
||
/// 与 [blockScreenshotKeys] 二选一:[forbidScreenshotKeys] 优先(首个存在的键即生效)。
|
||
final List<String> forbidScreenshotKeys;
|
||
|
||
/// 备选用键:`true` 同样表示禁止截屏(默认 `safe_area`),仅当 [forbidScreenshotKeys] 未命中任何键时使用。
|
||
final List<String> blockScreenshotKeys;
|
||
|
||
/// 是否允许第三方支付。
|
||
final List<String> allowThirdPartyPaymentKeys;
|
||
|
||
final List<String> privacyUrlKeys;
|
||
final List<String> agreementUrlKeys;
|
||
|
||
/// items 数组所在键名。
|
||
final List<String> itemsKeys;
|
||
|
||
final List<String> itemImageKeys;
|
||
final List<String> itemImageFixKeys;
|
||
final List<String> itemImgNeedKeys;
|
||
final List<String> itemCostKeys;
|
||
|
||
/// 生图分辨率 480p 对应积分(可选;缺省由业务侧按 [itemCostKeys] 推导)。
|
||
final List<String> itemCost480pKeys;
|
||
|
||
/// 生图分辨率 720p 对应积分(可选;缺省用 [itemCostKeys] / `cost`)。
|
||
final List<String> itemCost720pKeys;
|
||
|
||
final List<String> itemTitleKeys;
|
||
|
||
/// 创建任务用的模板标识(`POST /v1/image/create-task` 的 `templateName` / 换皮 `itinerary`);与展示用 [itemTitleKeys] 分离。
|
||
final List<String> itemTemplateNameKeys;
|
||
|
||
/// 创建任务时的任务类型(`POST /v1/image/create-task` 的 `cipher` / 列表项换皮 `liaison` ↔ 逻辑 `taskType`)。
|
||
final List<String> itemTaskTypeKeys;
|
||
|
||
/// 新业务扩展串(如 animal_expression),与 [itemDetailKeys] 并列用于 [ExtConfigItem.taskExt]。
|
||
final List<String> itemParamsKeys;
|
||
final List<String> itemDetailKeys;
|
||
|
||
/// 预览/背景视频地址;非空时 [ExtConfigItem.isVideoItem] 为 true(与 [itemImageKeys] 并列)。
|
||
final List<String> itemVideoUrlKeys;
|
||
|
||
/// 首页/列表展示侧:封面图、标题、预览视频等线网键覆盖(逻辑名与 [itemKeys] 一致)。
|
||
final Map<String, List<String>>? itemKeysHome;
|
||
|
||
/// 创建任务/生图侧:模板名、任务类型、params/detail、张数、计费等线网键覆盖。
|
||
final Map<String, List<String>>? itemKeysTask;
|
||
|
||
/// 将 `extConfig.items` 单项**组装**成与 [TaskItem.fromJson] 一致的结构(与 `/v1/image/img2video/tasks` 单项在 mapResponse 后同形)。
|
||
/// 非空时**优先**于 [itemKeys] 扁平解析。
|
||
final Map<String, List<String>>? taskItemMapping;
|
||
|
||
/// 为 `true`(默认)时:先对单项做 [FieldMapping.mapResponse],再按 [taskItemMapping] 从**逻辑/已换皮**键上取值;为 `false` 时从**原始**单项上按源路径取值(源路径需为线网键名)。
|
||
final bool itemsApplyFieldMappingBeforeTaskMapping;
|
||
|
||
/// `extConfig.items` 解析出 [ExtConfigItem] 后,若 [ExtConfigItem.title] 仅空白,则替换为该字符串(见 `extConfig.defaultItemTitle`)。
|
||
final String defaultItemTitleWhenEmpty;
|
||
|
||
/// 首页展示用线网键:[itemKeysHome] 中该逻辑名非空则用之,否则 [fallbackFromItemKeys](通常为 [itemKeys] 解析结果)。
|
||
List<String> homeWireKeysFor(
|
||
String logical,
|
||
List<String> fallbackFromItemKeys,
|
||
) {
|
||
final o = itemKeysHome?[logical];
|
||
if (o != null && o.isNotEmpty) return o;
|
||
return fallbackFromItemKeys;
|
||
}
|
||
|
||
/// 生图/创建任务用线网键:[itemKeysTask] 优先,否则 [fallbackFromItemKeys]。
|
||
List<String> taskWireKeysFor(
|
||
String logical,
|
||
List<String> fallbackFromItemKeys,
|
||
) {
|
||
final o = itemKeysTask?[logical];
|
||
if (o != null && o.isNotEmpty) return o;
|
||
return fallbackFromItemKeys;
|
||
}
|
||
|
||
/// 与当前框架内置默认一致(go_run / need_wait、screen、safe_area、san_fang、lucky 等)。
|
||
factory ExtConfigKeySchema.defaults() {
|
||
return const ExtConfigKeySchema(
|
||
showVideoMenuKeys: ['go_run', 'need_wait'],
|
||
forbidScreenshotKeys: ['screen'],
|
||
blockScreenshotKeys: ['safe_area'],
|
||
allowThirdPartyPaymentKeys: ['san_fang', 'lucky'],
|
||
privacyUrlKeys: ['privacy'],
|
||
agreementUrlKeys: ['agreement'],
|
||
itemsKeys: ['items'],
|
||
itemImageKeys: ['image'],
|
||
itemImageFixKeys: ['image_fix'],
|
||
itemImgNeedKeys: ['img_need'],
|
||
itemCostKeys: ['cost'],
|
||
itemCost480pKeys: ['cost_480p', 'cost480p', 'cost_480'],
|
||
itemCost720pKeys: ['cost_720p', 'cost720p', 'cost_720'],
|
||
itemTitleKeys: ['title'],
|
||
itemTemplateNameKeys: ['templateName', 'template_name'],
|
||
itemTaskTypeKeys: ['taskType'],
|
||
itemParamsKeys: ['params'],
|
||
itemDetailKeys: ['detail'],
|
||
itemVideoUrlKeys: ['video', 'video_url', 'videoUrl', 'preview_video'],
|
||
itemKeysHome: null,
|
||
itemKeysTask: null,
|
||
taskItemMapping: null,
|
||
itemsApplyFieldMappingBeforeTaskMapping: true,
|
||
defaultItemTitleWhenEmpty: '-',
|
||
);
|
||
}
|
||
|
||
/// 从 `skin_config` 的 `extConfig` 对象解析;缺少的子块用 [defaults] 补全。
|
||
factory ExtConfigKeySchema.fromSkinExtConfigJson(Map<String, dynamic>? ext) {
|
||
if (ext == null) return ExtConfigKeySchema.defaults();
|
||
final d = ExtConfigKeySchema.defaults();
|
||
final keys = ext['keys'];
|
||
final itemKeys = ext['itemKeys'] as Map<String, dynamic>?;
|
||
|
||
List<String> rootList(Map<String, dynamic>? m, String k, List<String> fallback) {
|
||
if (m == null) return fallback;
|
||
final raw = m[k];
|
||
final parsed = _stringList(raw);
|
||
return parsed.isEmpty ? fallback : parsed;
|
||
}
|
||
|
||
Map<String, dynamic>? keyMap;
|
||
if (keys is Map<String, dynamic>) {
|
||
keyMap = keys;
|
||
}
|
||
|
||
List<String> itemField(
|
||
String k,
|
||
List<String> fallback,
|
||
) {
|
||
if (itemKeys == null) return fallback;
|
||
return rootList(itemKeys, k, fallback);
|
||
}
|
||
|
||
/// `skin_config.extConfig.keys`:优先 [primary],为空则回退 [legacy](兼容旧键名),再不行用 [fallback]。
|
||
List<String> mergedRootList(
|
||
String primary,
|
||
String legacy,
|
||
List<String> fallback,
|
||
) {
|
||
final p = rootList(keyMap, primary, const <String>[]);
|
||
if (p.isNotEmpty) return p;
|
||
final l = rootList(keyMap, legacy, const <String>[]);
|
||
if (l.isNotEmpty) return l;
|
||
return fallback;
|
||
}
|
||
|
||
return ExtConfigKeySchema(
|
||
showVideoMenuKeys: rootList(keyMap, 'showVideoMenu', d.showVideoMenuKeys),
|
||
forbidScreenshotKeys: mergedRootList(
|
||
'forbidScreenshot',
|
||
'allowScreenshot',
|
||
d.forbidScreenshotKeys,
|
||
),
|
||
blockScreenshotKeys:
|
||
rootList(keyMap, 'blockScreenshot', d.blockScreenshotKeys),
|
||
allowThirdPartyPaymentKeys: rootList(
|
||
keyMap,
|
||
'allowThirdPartyPayment',
|
||
d.allowThirdPartyPaymentKeys,
|
||
),
|
||
privacyUrlKeys: rootList(keyMap, 'privacyUrl', d.privacyUrlKeys),
|
||
agreementUrlKeys: rootList(keyMap, 'agreementUrl', d.agreementUrlKeys),
|
||
itemsKeys: rootList(keyMap, 'items', d.itemsKeys),
|
||
itemImageKeys: itemField('image', d.itemImageKeys),
|
||
itemImageFixKeys: itemField('imageFix', d.itemImageFixKeys),
|
||
itemImgNeedKeys: itemField('imgNeed', d.itemImgNeedKeys),
|
||
itemCostKeys: itemField('cost', d.itemCostKeys),
|
||
itemCost480pKeys: itemField('cost480p', d.itemCost480pKeys),
|
||
itemCost720pKeys: itemField('cost720p', d.itemCost720pKeys),
|
||
itemTitleKeys: itemField('title', d.itemTitleKeys),
|
||
itemTemplateNameKeys:
|
||
itemField('templateName', d.itemTemplateNameKeys),
|
||
itemTaskTypeKeys: itemField('taskType', d.itemTaskTypeKeys),
|
||
itemParamsKeys: itemField('params', d.itemParamsKeys),
|
||
itemDetailKeys: itemField('detail', d.itemDetailKeys),
|
||
itemVideoUrlKeys: itemField('videoUrl', d.itemVideoUrlKeys),
|
||
itemKeysHome: _optionalLogicalWireMap(ext['itemKeysHome']),
|
||
itemKeysTask: _optionalLogicalWireMap(ext['itemKeysTask']),
|
||
taskItemMapping: _optionalLogicalWireMap(ext['taskItemMapping']),
|
||
itemsApplyFieldMappingBeforeTaskMapping:
|
||
ext['itemsApplyFieldMappingBeforeTaskMapping'] is bool
|
||
? ext['itemsApplyFieldMappingBeforeTaskMapping'] as bool
|
||
: true,
|
||
defaultItemTitleWhenEmpty: _parseDefaultItemTitle(
|
||
ext['defaultItemTitle'],
|
||
d.defaultItemTitleWhenEmpty,
|
||
),
|
||
);
|
||
}
|
||
|
||
static String _parseDefaultItemTitle(dynamic raw, String fallback) {
|
||
if (raw is String && raw.trim().isNotEmpty) return raw.trim();
|
||
return fallback;
|
||
}
|
||
|
||
/// 非空 Map;若解析结果为空则返回 null(与「未配置」一致)。
|
||
static Map<String, List<String>>? _optionalLogicalWireMap(dynamic raw) {
|
||
if (raw is! Map) return null;
|
||
final out = <String, List<String>>{};
|
||
for (final e in raw.entries) {
|
||
final k = e.key.toString();
|
||
final list = _stringList(e.value);
|
||
if (list.isNotEmpty) out[k] = list;
|
||
}
|
||
return out.isEmpty ? null : out;
|
||
}
|
||
|
||
static List<String> _stringList(dynamic raw) {
|
||
if (raw == null) return [];
|
||
if (raw is String) return raw.isEmpty ? [] : [raw];
|
||
if (raw is List) {
|
||
return raw.map((e) => e.toString()).where((s) => s.isNotEmpty).toList();
|
||
}
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/// `skin_config` 根下 `extConfig` 节:键模式 + 可选默认 JSON(wire 键名,与 common_info 一致)。
|
||
class SkinExtConfigSection {
|
||
SkinExtConfigSection({
|
||
required this.keySchema,
|
||
this.defaults,
|
||
});
|
||
|
||
final ExtConfigKeySchema keySchema;
|
||
|
||
/// 本地默认 extConfig 对象(与线上下发同一套 **键名**);`common_info` 成功后与服务器对象 **浅合并**,服务器键覆盖同名键。
|
||
final Map<String, dynamic>? defaults;
|
||
|
||
static SkinExtConfigSection? fromRootJson(Map<String, dynamic>? ext) {
|
||
if (ext == null) return null;
|
||
final schema = ExtConfigKeySchema.fromSkinExtConfigJson(ext);
|
||
Map<String, dynamic>? def;
|
||
final raw = ext['defaults'];
|
||
if (raw is Map<String, dynamic>) {
|
||
def = Map<String, dynamic>.from(raw);
|
||
} else if (raw is String && raw.trim().isNotEmpty) {
|
||
try {
|
||
final dec = json.decode(raw);
|
||
if (dec is Map<String, dynamic>) def = dec;
|
||
} catch (_) {}
|
||
}
|
||
return SkinExtConfigSection(keySchema: schema, defaults: def);
|
||
}
|
||
|
||
/// 浅合并:[server] 中非 null 的顶层键覆盖 [base]。
|
||
static Map<String, dynamic>? mergeDefaults({
|
||
Map<String, dynamic>? base,
|
||
Map<String, dynamic>? server,
|
||
}) {
|
||
if (base == null || base.isEmpty) {
|
||
if (server == null || server.isEmpty) return null;
|
||
return Map<String, dynamic>.from(server);
|
||
}
|
||
if (server == null || server.isEmpty) {
|
||
return Map<String, dynamic>.from(base);
|
||
}
|
||
final out = Map<String, dynamic>.from(base);
|
||
server.forEach((k, v) {
|
||
out[k] = v;
|
||
});
|
||
return out;
|
||
}
|
||
}
|