feat(mobile): album management pages with qr share
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
/// 相册表单草稿/校验/载荷构建(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<AlbumTranslationDraft> get drafts => <AlbumTranslationDraft>[zh, en];
|
||||
|
||||
/// 客户端预检;返回按序去重的错误码(空列表 = 通过)。
|
||||
List<AlbumFormError> validate() {
|
||||
final errors = <AlbumFormError>{};
|
||||
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<AlbumTranslation> 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<AlbumTranslation> a,
|
||||
List<AlbumTranslation> b,
|
||||
) {
|
||||
if (a.length != b.length) return false;
|
||||
final byLocale = <String, AlbumTranslation>{
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/// 相册时区感知的时间展示(P3 计划 Task 4)。
|
||||
///
|
||||
/// 契约:startAt/endAt 按 UTC 存储,「展示按 timezone」。Dart 核心库无
|
||||
/// IANA 时区数据库,引入 `timezone` 包(内嵌 tzdata)做墙钟换算,字符串
|
||||
/// 格式化仍走 intl [DateFormat](App 语言)。tzdata 初始化为幂等懒加载
|
||||
/// ([ensureTzInitialized]),生产与测试共用同一路径。
|
||||
library;
|
||||
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:timezone/data/latest.dart' as tzdata;
|
||||
import 'package:timezone/timezone.dart' as tz;
|
||||
|
||||
bool _tzInitialized = false;
|
||||
|
||||
/// 初始化内嵌 tzdata(幂等;首次调用后为空操作)。
|
||||
void ensureTzInitialized() {
|
||||
if (!_tzInitialized) {
|
||||
tzdata.initializeTimeZones();
|
||||
_tzInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析 IANA 时区名;无效返回 null。
|
||||
///
|
||||
/// 内嵌 tzdata(10 年窗口)不含 `UTC` 顶名(仅 `Etc/UTC`),此处将
|
||||
/// `UTC` 归一化到 `Etc/UTC`,避免最常用的时区名被误判非法。
|
||||
tz.Location? _resolveLocation(String timeZone) {
|
||||
ensureTzInitialized();
|
||||
final name = timeZone.trim();
|
||||
try {
|
||||
return tz.getLocation(name == 'UTC' ? 'Etc/UTC' : name);
|
||||
} on Object {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// UTC 瞬间 → 相册时区墙钟;IANA 名无效返回 null。
|
||||
tz.TZDateTime? tryToAlbumZone(DateTime instant, String timeZone) {
|
||||
final location = _resolveLocation(timeZone);
|
||||
if (location == null) return null;
|
||||
return tz.TZDateTime.from(instant.toUtc(), location);
|
||||
}
|
||||
|
||||
/// 时区名是否为合法 IANA 标识(表单校验用)。
|
||||
bool isValidTimezone(String timeZone) =>
|
||||
timeZone.trim().isNotEmpty &&
|
||||
tryToAlbumZone(DateTime.utc(2026, 1, 1), timeZone) != null;
|
||||
|
||||
/// 按相册时区格式化时间(`y/M/d HH:mm` 模式,App locale)。
|
||||
///
|
||||
/// 时区无效时回退 UTC 展示(表单校验另行拦截无效时区,此处仅兜底)。
|
||||
String formatAlbumDateTime(
|
||||
DateTime utcInstant,
|
||||
String timeZone,
|
||||
String locale,
|
||||
) {
|
||||
final zoned = tryToAlbumZone(utcInstant, timeZone) ?? utcInstant.toUtc();
|
||||
return DateFormat('y/M/d HH:mm', locale).format(zoned);
|
||||
}
|
||||
|
||||
/// 相册墙钟(表单里选择的日期时间,设备本地时区标记的 naive 值)→
|
||||
/// UTC 瞬间(提交与回显往返一致)。
|
||||
///
|
||||
/// 时区无效时按字面值视为 UTC(校验先行拦截,避免设备时区偏移污染)。
|
||||
DateTime albumWallClockToUtc(DateTime wallClock, String timeZone) {
|
||||
final location = _resolveLocation(timeZone);
|
||||
if (location == null) {
|
||||
return DateTime.utc(
|
||||
wallClock.year,
|
||||
wallClock.month,
|
||||
wallClock.day,
|
||||
wallClock.hour,
|
||||
wallClock.minute,
|
||||
);
|
||||
}
|
||||
return tz
|
||||
.TZDateTime(
|
||||
location,
|
||||
wallClock.year,
|
||||
wallClock.month,
|
||||
wallClock.day,
|
||||
wallClock.hour,
|
||||
wallClock.minute,
|
||||
)
|
||||
.toUtc();
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/// 相册列表 Riverpod 状态(P3 计划 Task 4)。
|
||||
///
|
||||
/// 首帧后自动拉首页(build 返回加载态,microtask 里触发首次加载,
|
||||
/// 不在 build 内改 state);游标加载更多(失败保留已加载条目并提供
|
||||
/// 重试标记);下拉刷新重置到第一页。创建/编辑/删除成功后由调用方
|
||||
/// 调 [AlbumListNotifier.refresh] 同步。
|
||||
library;
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/api_client.dart';
|
||||
import '../../core/app_exception.dart';
|
||||
import '../../core/error_codes.dart';
|
||||
import 'models.dart';
|
||||
|
||||
/// 相册列表状态。
|
||||
class AlbumListState {
|
||||
const AlbumListState({
|
||||
this.items = const <AlbumSummary>[],
|
||||
this.nextCursor,
|
||||
this.hasMore = false,
|
||||
this.loading = false,
|
||||
this.loadingMore = false,
|
||||
this.loadMoreFailed = false,
|
||||
this.errorCode,
|
||||
});
|
||||
|
||||
/// 已加载条目(跨页累积)。
|
||||
final List<AlbumSummary> items;
|
||||
|
||||
/// 下一页游标;null = 没有更多。
|
||||
final String? nextCursor;
|
||||
|
||||
/// 是否还有下一页(与 [nextCursor] 成对)。
|
||||
final bool hasMore;
|
||||
|
||||
/// 首次加载/刷新进行中。
|
||||
final bool loading;
|
||||
|
||||
/// 加载更多进行中。
|
||||
final bool loadingMore;
|
||||
|
||||
/// 最近一次加载更多失败(保留已加载条目,页脚显示重试)。
|
||||
final bool loadMoreFailed;
|
||||
|
||||
/// 首屏/刷新失败的错误码([AppException.code]);null = 无错误。
|
||||
final String? errorCode;
|
||||
}
|
||||
|
||||
/// 相册列表 Notifier(游标分页状态机)。
|
||||
class AlbumListNotifier extends Notifier<AlbumListState> {
|
||||
/// 每页条数(契约列表端点通用上限内取 20)。
|
||||
static const int pageSize = 20;
|
||||
|
||||
@override
|
||||
AlbumListState build() {
|
||||
// microtask:确保不在 build 执行期内改 state。
|
||||
Future<void>.microtask(refresh);
|
||||
return const AlbumListState(loading: true);
|
||||
}
|
||||
|
||||
/// 重置到第一页(下拉刷新/数据变更后同步)。
|
||||
Future<void> refresh() async {
|
||||
state = const AlbumListState(loading: true);
|
||||
try {
|
||||
final page = await ref
|
||||
.read(apiClientProvider)
|
||||
.listAlbums(limit: pageSize);
|
||||
state = AlbumListState(
|
||||
items: page.items,
|
||||
nextCursor: page.nextCursor,
|
||||
hasMore: page.hasMore,
|
||||
);
|
||||
} on AppException catch (e) {
|
||||
state = AlbumListState(errorCode: e.code);
|
||||
} on Object {
|
||||
state = const AlbumListState(errorCode: ErrorCodes.internalError);
|
||||
}
|
||||
}
|
||||
|
||||
/// 游标加载下一页(加载中/无更多时为空操作)。
|
||||
Future<void> loadMore() async {
|
||||
if (state.loading || state.loadingMore || !state.hasMore) return;
|
||||
state = AlbumListState(
|
||||
items: state.items,
|
||||
nextCursor: state.nextCursor,
|
||||
hasMore: true,
|
||||
loadingMore: true,
|
||||
);
|
||||
try {
|
||||
final page = await ref.read(apiClientProvider).listAlbums(
|
||||
cursor: state.nextCursor,
|
||||
limit: pageSize,
|
||||
);
|
||||
state = AlbumListState(
|
||||
items: <AlbumSummary>[...state.items, ...page.items],
|
||||
nextCursor: page.nextCursor,
|
||||
hasMore: page.hasMore,
|
||||
);
|
||||
} on AppException catch (e) {
|
||||
// 保留已加载条目:页脚给重试入口,错误码用于一次性提示语义。
|
||||
state = AlbumListState(
|
||||
items: state.items,
|
||||
nextCursor: state.nextCursor,
|
||||
hasMore: true,
|
||||
loadMoreFailed: true,
|
||||
errorCode: e.code,
|
||||
);
|
||||
} on Object {
|
||||
state = AlbumListState(
|
||||
items: state.items,
|
||||
nextCursor: state.nextCursor,
|
||||
hasMore: true,
|
||||
loadMoreFailed: true,
|
||||
errorCode: ErrorCodes.internalError,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 相册列表 Provider(首页相册 tab 消费)。
|
||||
final albumListProvider =
|
||||
NotifierProvider<AlbumListNotifier, AlbumListState>(
|
||||
AlbumListNotifier.new,
|
||||
);
|
||||
Reference in New Issue
Block a user