/// 相册表单草稿/校验/载荷构建(P3 计划 Task 4)。 /// /// 镜像 admin Web `albumValidation.ts` 的客户端预检(服务端 /// VALIDATION_FAILED 仍是权威): /// - 默认语言条目必须存在且 title 非空(契约 translations minItems 1 + /// defaultLocale 覆盖); /// - 「某语言填了描述/场地/地址但 title 空」视为漏填; /// - endAt 必须严格晚于 startAt(null=未定放行); /// - 时区必须是非空合法 IANA 名。 /// /// PATCH 载荷最小化:[toUpdatePatch] 仅提交与原相册不同的字段——四个 /// 开关的「独立性」由此保证(未拨动的开关不出现在请求体),translations /// 全量替换(有任一语言内容变更即整组提交)。 library; import 'album_format.dart'; import 'models.dart'; /// 表单错误码(展示层映射 l10n 文案)。 enum AlbumFormError { /// 默认语言条目缺失或标题为空。 defaultLocaleMissing, /// 某语言填了其他字段但没填标题。 titleRequiredWithFields, /// 未选择开始时间。 startAtRequired, /// 结束时间不晚于开始时间。 endAtNotAfterStartAt, /// 时区为空或不是合法 IANA 名。 timezoneInvalid, } /// 单语言表单草稿(zh-CN / en 两个 tab 各一份;空串 = 未填)。 class AlbumTranslationDraft { const AlbumTranslationDraft({ required this.locale, this.title = '', this.description = '', this.venue = '', this.address = '', }); /// 从服务端条目预填(null 可选字段归一为空串)。 factory AlbumTranslationDraft.fromTranslation(AlbumTranslation t) => AlbumTranslationDraft( locale: t.locale, title: t.title, description: t.description ?? '', venue: t.venue ?? '', address: t.address ?? '', ); /// 语言(zh-CN | en)。 final String locale; /// 标题(默认语言必填)。 final String title; /// 描述;空串 = 未填。 final String description; /// 场地名称;空串 = 未填。 final String venue; /// 场地地址;空串 = 未填。 final String address; /// 是否填了除标题以外的任一字段。 bool get hasOtherFields => description.trim().isNotEmpty || venue.trim().isNotEmpty || address.trim().isNotEmpty; } /// 相册表单草稿(创建/编辑共用;UI 层持可变控件,提交前汇成本类走纯逻辑)。 class AlbumFormDraft { const AlbumFormDraft({ required this.zh, required this.en, required this.defaultLocale, required this.timezone, this.startAt, this.endAt, this.uploadEnabled = true, this.publicEnabled = true, this.downloadEnabled = true, this.originalDownloadEnabled = false, }); /// 从既有相册预填(编辑模式)。 factory AlbumFormDraft.fromAlbum(Album album) => AlbumFormDraft( zh: _draftFor(album, 'zh-CN'), en: _draftFor(album, 'en'), defaultLocale: album.defaultLocale, timezone: album.timezone, startAt: album.startAt, endAt: album.endAt, uploadEnabled: album.uploadEnabled, publicEnabled: album.publicEnabled, downloadEnabled: album.downloadEnabled, originalDownloadEnabled: album.originalDownloadEnabled, ); static AlbumTranslationDraft _draftFor(Album album, String locale) { for (final t in album.translations) { if (t.locale == locale) return AlbumTranslationDraft.fromTranslation(t); } return AlbumTranslationDraft(locale: locale); } /// 中文条目草稿。 final AlbumTranslationDraft zh; /// 英文条目草稿。 final AlbumTranslationDraft en; /// 默认语言(zh-CN | en)。 final String defaultLocale; /// IANA 时区(表单缺省 Asia/Shanghai)。 final String timezone; /// 开始时间(UTC 瞬间);null = 未选。 final DateTime? startAt; /// 结束时间(UTC 瞬间);null = 未定。 final DateTime? endAt; /// 是否允许新上传任务(仅编辑可改)。 final bool uploadEnabled; /// 是否公开可见(仅编辑可改)。 final bool publicEnabled; /// 允许下载浏览版本。 final bool downloadEnabled; /// 允许下载原图。 final bool originalDownloadEnabled; /// 两个语言的草稿(zh-CN 在前,与 tab 顺序一致)。 List get drafts => [zh, en]; /// 客户端预检;返回按序去重的错误码(空列表 = 通过)。 List validate() { final errors = {}; final defaultDraft = drafts .where((d) => d.locale == defaultLocale) .toList(); if (defaultDraft.isEmpty || defaultDraft.first.title.trim().isEmpty) { errors.add(AlbumFormError.defaultLocaleMissing); } if (startAt == null) { errors.add(AlbumFormError.startAtRequired); } for (final d in drafts) { if (d.title.trim().isEmpty && d.hasOtherFields) { errors.add(AlbumFormError.titleRequiredWithFields); } } if (endAt != null && startAt != null && !endAt!.isAfter(startAt!)) { errors.add(AlbumFormError.endAtNotAfterStartAt); } if (!isValidTimezone(timezone)) { errors.add(AlbumFormError.timezoneInvalid); } return errors.toList(); } /// 提炼 translations 载荷:只保留标题非空的语言条目,空白字段归一 /// null(镜像 admin toTranslationPayload,避免空串占位覆盖 null 语义)。 List toTranslationPayload() => drafts .where((d) => d.title.trim().isNotEmpty) .map( (d) => AlbumTranslation( locale: d.locale, title: d.title.trim(), description: _orNull(d.description), venue: _orNull(d.venue), address: _orNull(d.address), ), ) .toList(); /// 创建请求体(调用前须已通过 [validate],startAt 非空)。 /// /// 契约缺省 uploadEnabled/publicEnabled=true 不可在创建时覆盖, /// 请求体不含这两个开关([AlbumCreateRequest] 亦无对应字段)。 AlbumCreateRequest toCreateRequest() => AlbumCreateRequest( translations: toTranslationPayload(), defaultLocale: defaultLocale, startAt: startAt!, endAt: endAt, timezone: timezone.trim(), downloadEnabled: downloadEnabled, originalDownloadEnabled: originalDownloadEnabled, ); /// 编辑载荷(最小 PATCH):仅提交与 [original] 不同的字段。 /// /// 无任何变更时返回 null(调用方据此免提交/禁用保存)。 AlbumUpdateRequest? toUpdatePatch(Album original) { final tzName = timezone.trim(); final translations = toTranslationPayload(); final translationsChanged = !_sameTranslations(translations, original.translations); final localeChanged = defaultLocale != original.defaultLocale; final startChanged = startAt != null && !_sameInstant(startAt!, original.startAt); final endCleared = endAt == null && original.endAt != null; final endChanged = endAt != null && (original.endAt == null || !_sameInstant(endAt!, original.endAt!)); final tzChanged = tzName != original.timezone; final uploadChanged = uploadEnabled != original.uploadEnabled; final publicChanged = publicEnabled != original.publicEnabled; final downloadChanged = downloadEnabled != original.downloadEnabled; final originalChanged = originalDownloadEnabled != original.originalDownloadEnabled; final touched = translationsChanged || localeChanged || startChanged || endCleared || endChanged || tzChanged || uploadChanged || publicChanged || downloadChanged || originalChanged; if (!touched) return null; return AlbumUpdateRequest( translations: translationsChanged ? translations : null, defaultLocale: localeChanged ? defaultLocale : null, startAt: startChanged ? startAt : null, endAt: endChanged ? endAt : null, clearEndAt: endCleared, timezone: tzChanged ? tzName : null, uploadEnabled: uploadChanged ? uploadEnabled : null, publicEnabled: publicChanged ? publicEnabled : null, downloadEnabled: downloadChanged ? downloadEnabled : null, originalDownloadEnabled: originalChanged ? originalDownloadEnabled : null, ); } static String? _orNull(String value) => value.trim().isNotEmpty ? value.trim() : null; /// 瞬间相等(DateTime.== 额外比较 isUtc 标记,此处只比时间轴)。 static bool _sameInstant(DateTime a, DateTime b) => a.microsecondsSinceEpoch == b.microsecondsSinceEpoch; /// translations 组是否等价(按 locale 对齐,字段 trim 后比较; /// null 与空串视为相等)。 static bool _sameTranslations( List a, List b, ) { if (a.length != b.length) return false; final byLocale = { for (final t in b) t.locale: t, }; for (final t in a) { final o = byLocale[t.locale]; if (o == null) return false; if (!_eq(t.title, o.title) || !_eq(t.description, o.description) || !_eq(t.venue, o.venue) || !_eq(t.address, o.address)) { return false; } } return true; } static bool _eq(String? a, String? b) => a?.trim() == b?.trim(); }