Files
photo-live/apps/mobile/lib/features/albums/models.dart
T
2026-09-21 15:55:44 +08:00

650 lines
20 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 相册域模型(P3 计划 Task 2):相册、分类、成员、账号查询与请求体。
///
/// 契约来源:`packages/contracts/openapi.yaml` tag `albums`/`categories`/
/// `members`/`admin`。
library;
/// 相册多语言条目(创建/更新请求与响应共用)。
///
/// 契约 `components.schemas.AlbumTranslation`:未填的可选字段存储为 null
/// (回读时 null 而非缺省键)。
class AlbumTranslation {
const AlbumTranslation({
required this.locale,
required this.title,
this.description,
this.venue,
this.address,
});
factory AlbumTranslation.fromJson(Map<String, dynamic> json) =>
AlbumTranslation(
locale: json['locale'] as String,
title: json['title'] as String,
description: json['description'] as String?,
venue: json['venue'] as String?,
address: json['address'] as String?,
);
/// 语言(契约 Locale enum:zh-CN | en;Dart 标识符不兼容连字符,
/// 按 String 透传)。
final String locale;
/// 相册标题。
final String title;
/// 相册描述;未填为 null。
final String? description;
/// 场地名称;未填为 null。
final String? venue;
/// 场地地址;未填为 null。
final String? address;
Map<String, dynamic> toJson() => {
'locale': locale,
'title': title,
'description': description,
'venue': venue,
'address': address,
};
}
/// 相册状态。
///
/// 契约 `AlbumSummary.status` enum:active=正常;offline=下线
/// (停止公开访问与新授权签发)。
enum AlbumStatus {
active('active'),
offline('offline');
const AlbumStatus(this.wire);
/// 线上(JSON)取值。
final String wire;
static AlbumStatus fromJson(String value) => values.firstWhere(
(v) => v.wire == value,
orElse: () => throw FormatException('未知 AlbumStatus: $value'),
);
}
/// 私有对象的短期签名 URL。
///
/// 契约 `components.schemas.SignedUrl`(UI-01 起相册封面/照片预览共用):
/// 不得写入持久分享链接,过期后经原入口(列表/详情重取)刷新。
class SignedUrl {
const SignedUrl({required this.url, required this.expiresAt});
factory SignedUrl.fromJson(Map<String, dynamic> json) => SignedUrl(
url: json['url'] as String,
expiresAt: DateTime.parse(json['expiresAt'] as String),
);
/// 签名访问 URL。
final String url;
/// URL 失效时间。
final DateTime expiresAt;
Map<String, dynamic> toJson() => {
'url': url,
'expiresAt': expiresAt.toUtc().toIso8601String(),
};
}
/// 相册列表条目(完整开关与多语言标题,管理列表无需二次请求详情)。
///
/// 契约 `components.schemas.AlbumSummary`(GET /albums,
/// operationId `listAlbums` 的条目)。cover(UI-01 可选扩展):当前生效
/// 封面的短期签名 URL;旧响应缺键或未配置封面为 null。
class AlbumSummary {
const AlbumSummary({
required this.id,
required this.publicToken,
required this.ownerId,
required this.status,
required this.defaultLocale,
required this.timezone,
required this.startAt,
required this.endAt,
required this.uploadEnabled,
required this.publicEnabled,
required this.downloadEnabled,
required this.originalDownloadEnabled,
required this.watermarkVersion,
required this.translations,
required this.createdAt,
required this.updatedAt,
required this.statsSyncedAt,
this.cover,
});
factory AlbumSummary.fromJson(Map<String, dynamic> json) => AlbumSummary(
id: json['id'] as String,
publicToken: json['publicToken'] as String,
ownerId: json['ownerId'] as String,
status: AlbumStatus.fromJson(json['status'] as String),
defaultLocale: json['defaultLocale'] as String,
timezone: json['timezone'] as String,
startAt: DateTime.parse(json['startAt'] as String),
endAt: json['endAt'] == null
? null
: DateTime.parse(json['endAt'] as String),
uploadEnabled: json['uploadEnabled'] as bool,
publicEnabled: json['publicEnabled'] as bool,
downloadEnabled: json['downloadEnabled'] as bool,
originalDownloadEnabled: json['originalDownloadEnabled'] as bool,
watermarkVersion: json['watermarkVersion'] as int,
translations: (json['translations'] as List<dynamic>)
.map(
(dynamic e) => AlbumTranslation.fromJson(
e as Map<String, dynamic>,
),
)
.toList(),
createdAt: DateTime.parse(json['createdAt'] as String),
updatedAt: DateTime.parse(json['updatedAt'] as String),
statsSyncedAt: json['statsSyncedAt'] == null
? null
: DateTime.parse(json['statsSyncedAt'] as String),
cover: json['cover'] == null
? null
: SignedUrl.fromJson(json['cover'] as Map<String, dynamic>),
);
/// 相册 ID。
final String id;
/// 公开访问令牌(不可枚举,pattern ^[0-9a-z]{20,32}$)。
final String publicToken;
/// 创建者用户 ID。
final String ownerId;
/// 相册状态。
final AlbumStatus status;
/// 缺省语言(契约 Locale enum:zh-CN | en)。
final String defaultLocale;
/// 活动 IANA 时区。
final String timezone;
/// 活动开始时间(UTC 存储,展示按 timezone)。
final DateTime startAt;
/// 活动结束时间;null 表示未定。
final DateTime? endAt;
/// 是否允许新上传任务。
final bool uploadEnabled;
/// 是否公开可见。
final bool publicEnabled;
/// 允许下载浏览版本。
final bool downloadEnabled;
/// 允许下载原图。
final bool originalDownloadEnabled;
/// 当前水印配置版本。
final int watermarkVersion;
/// 各语言内容。
final List<AlbumTranslation> translations;
/// 创建时间。
final DateTime createdAt;
/// 最近更新时间。
final DateTime updatedAt;
/// 统计副本最近同步时间(P2 A27);null 表示尚无回传应用。
final DateTime? statsSyncedAt;
/// 相册封面签名 URL(UI-01);未配置封面或旧响应缺键为 null。
final SignedUrl? cover;
Map<String, dynamic> toJson() => {
'id': id,
'publicToken': publicToken,
'ownerId': ownerId,
'status': status.wire,
'defaultLocale': defaultLocale,
'timezone': timezone,
'startAt': startAt.toUtc().toIso8601String(),
'endAt': endAt?.toUtc().toIso8601String(),
'uploadEnabled': uploadEnabled,
'publicEnabled': publicEnabled,
'downloadEnabled': downloadEnabled,
'originalDownloadEnabled': originalDownloadEnabled,
'watermarkVersion': watermarkVersion,
'translations': translations
.map((AlbumTranslation t) => t.toJson())
.toList(),
'createdAt': createdAt.toUtc().toIso8601String(),
'updatedAt': updatedAt.toUtc().toIso8601String(),
'statsSyncedAt': statsSyncedAt?.toUtc().toIso8601String(),
'cover': cover?.toJson(),
};
}
/// 相册详情。
///
/// 契约 `components.schemas.Album`(= AlbumSummary + categories,按
/// sortOrder 升序);POST /albums、GET/PATCH /albums/{albumId}
/// (operationId `createAlbum` / `getAlbum` / `updateAlbum`)的响应。
class Album extends AlbumSummary {
const Album({
required this.categories,
required super.id,
required super.publicToken,
required super.ownerId,
required super.status,
required super.defaultLocale,
required super.timezone,
required super.startAt,
required super.endAt,
required super.uploadEnabled,
required super.publicEnabled,
required super.downloadEnabled,
required super.originalDownloadEnabled,
required super.watermarkVersion,
required super.translations,
required super.createdAt,
required super.updatedAt,
required super.statsSyncedAt,
super.cover,
});
factory Album.fromJson(Map<String, dynamic> json) => Album(
categories: (json['categories'] as List<dynamic>)
.map(
(dynamic e) => Category.fromJson(e as Map<String, dynamic>),
)
.toList(),
cover: json['cover'] == null
? null
: SignedUrl.fromJson(json['cover'] as Map<String, dynamic>),
id: json['id'] as String,
publicToken: json['publicToken'] as String,
ownerId: json['ownerId'] as String,
status: AlbumStatus.fromJson(json['status'] as String),
defaultLocale: json['defaultLocale'] as String,
timezone: json['timezone'] as String,
startAt: DateTime.parse(json['startAt'] as String),
endAt: json['endAt'] == null
? null
: DateTime.parse(json['endAt'] as String),
uploadEnabled: json['uploadEnabled'] as bool,
publicEnabled: json['publicEnabled'] as bool,
downloadEnabled: json['downloadEnabled'] as bool,
originalDownloadEnabled: json['originalDownloadEnabled'] as bool,
watermarkVersion: json['watermarkVersion'] as int,
translations: (json['translations'] as List<dynamic>)
.map(
(dynamic e) => AlbumTranslation.fromJson(
e as Map<String, dynamic>,
),
)
.toList(),
createdAt: DateTime.parse(json['createdAt'] as String),
updatedAt: DateTime.parse(json['updatedAt'] as String),
statsSyncedAt: json['statsSyncedAt'] == null
? null
: DateTime.parse(json['statsSyncedAt'] as String),
);
/// 相册分类(按 sortOrder 升序;新相册为空数组)。
final List<Category> categories;
@override
Map<String, dynamic> toJson() =>
{...super.toJson(), 'categories': categories.map((c) => c.toJson()).toList()};
}
/// 分类多语言名称条目。
///
/// 契约 `components.schemas.CategoryTranslation`。
class CategoryTranslation {
const CategoryTranslation({required this.locale, required this.name});
factory CategoryTranslation.fromJson(Map<String, dynamic> json) =>
CategoryTranslation(
locale: json['locale'] as String,
name: json['name'] as String,
);
/// 语言(契约 Locale enum:zh-CN | en)。
final String locale;
/// 分类名称。
final String name;
Map<String, dynamic> toJson() => {'locale': locale, 'name': name};
}
/// 相册分类。
///
/// 契约 `components.schemas.Category`;POST /albums/{albumId}/categories、
/// PATCH /categories/{categoryId}(operationId `createCategory` /
/// `updateCategory`)的响应,亦内嵌于 [Album.categories]。
class Category {
const Category({
required this.id,
required this.albumId,
required this.sortOrder,
required this.translations,
});
factory Category.fromJson(Map<String, dynamic> json) => Category(
id: json['id'] as String,
albumId: json['albumId'] as String,
sortOrder: json['sortOrder'] as int,
translations: (json['translations'] as List<dynamic>)
.map(
(dynamic e) =>
CategoryTranslation.fromJson(e as Map<String, dynamic>),
)
.toList(),
);
/// 分类 ID。
final String id;
/// 所属相册 ID。
final String albumId;
/// 排序序号(升序)。
final int sortOrder;
/// 各语言名称。
final List<CategoryTranslation> translations;
Map<String, dynamic> toJson() => {
'id': id,
'albumId': albumId,
'sortOrder': sortOrder,
'translations':
translations.map((CategoryTranslation t) => t.toJson()).toList(),
};
}
/// 成员角色。
///
/// 契约 `Member.role` enum:owner 由创建流程产生,P1a 仅 collaborator 可添加。
enum MemberRole {
owner('owner'),
collaborator('collaborator');
const MemberRole(this.wire);
/// 线上(JSON)取值。
final String wire;
static MemberRole fromJson(String value) => values.firstWhere(
(v) => v.wire == value,
orElse: () => throw FormatException('未知 MemberRole: $value'),
);
}
/// 成员状态。
///
/// 契约 `Member.status` enum:P1a 仅 active,预留扩展。
enum MemberStatus {
active('active');
const MemberStatus(this.wire);
/// 线上(JSON)取值。
final String wire;
static MemberStatus fromJson(String value) => values.firstWhere(
(v) => v.wire == value,
orElse: () => throw FormatException('未知 MemberStatus: $value'),
);
}
/// 相册成员。
///
/// 契约 `components.schemas.Member`;POST /albums/{albumId}/members
/// (operationId `addAlbumMember`)的响应、GET 同路径
/// (`listAlbumMembers`)的条目。
class Member {
const Member({
required this.userId,
required this.account,
required this.displayName,
required this.role,
required this.status,
});
factory Member.fromJson(Map<String, dynamic> json) => Member(
userId: json['userId'] as String,
account: json['account'] as String,
displayName: json['displayName'] as String,
role: MemberRole.fromJson(json['role'] as String),
status: MemberStatus.fromJson(json['status'] as String),
);
/// 用户 ID。
final String userId;
/// 登录账号。
final String account;
/// 展示姓名(来自用户资料同步)。
final String displayName;
/// 相册角色。
final MemberRole role;
/// 成员状态。
final MemberStatus status;
Map<String, dynamic> toJson() => {
'userId': userId,
'account': account,
'displayName': displayName,
'role': role.wire,
'status': status.wire,
};
}
/// 账号前缀查询条目(脱敏)。
///
/// 契约 `components.schemas.AdminUserLookupItem`;GET /admin/users
/// (operationId `lookupUsersByAccount`)的条目,用于邀请协作者的
/// 前置搜索。无游标(前缀搜索 + 小上限即取即用)。
class AdminUserLookupItem {
const AdminUserLookupItem({
required this.id,
required this.account,
required this.displayName,
});
factory AdminUserLookupItem.fromJson(Map<String, dynamic> json) =>
AdminUserLookupItem(
id: json['id'] as String,
account: json['account'] as String,
displayName: json['displayName'] as String,
);
/// 用户 ID(用于 POST /albums/{albumId}/members 的 userId)。
final String id;
/// 登录账号。
final String account;
/// 展示姓名。
final String displayName;
Map<String, dynamic> toJson() => {
'id': id,
'account': account,
'displayName': displayName,
};
}
/// 创建相册请求体。
///
/// 契约 `components.schemas.AlbumCreateRequest`(operationId
/// `createAlbum`)。初始默认值(服务端):uploadEnabled/publicEnabled=
/// downloadEnabled=true、originalDownloadEnabled=false、status=active、
/// watermarkVersion=0——故请求体不含 uploadEnabled/publicEnabled/status。
class AlbumCreateRequest {
const AlbumCreateRequest({
required this.translations,
required this.defaultLocale,
required this.startAt,
this.endAt,
this.timezone = 'Asia/Shanghai',
this.downloadEnabled = true,
this.originalDownloadEnabled = false,
});
/// 多语言内容;必须包含 defaultLocale 对应条目(minItems 1)。
final List<AlbumTranslation> translations;
/// 缺省语言(契约 Locale enum:zh-CN | en)。
final String defaultLocale;
/// 活动开始时间。
final DateTime startAt;
/// 活动结束时间;null/缺省表示未定。
final DateTime? endAt;
/// 活动 IANA 时区(契约缺省 Asia/Shanghai)。
final String timezone;
/// 允许下载浏览版本(契约缺省 true)。
final bool downloadEnabled;
/// 允许下载原图(契约缺省 false)。
final bool originalDownloadEnabled;
Map<String, dynamic> toJson() => {
'translations':
translations.map((AlbumTranslation t) => t.toJson()).toList(),
'defaultLocale': defaultLocale,
'startAt': startAt.toUtc().toIso8601String(),
'endAt': endAt?.toUtc().toIso8601String(),
'timezone': timezone,
'downloadEnabled': downloadEnabled,
'originalDownloadEnabled': originalDownloadEnabled,
};
}
/// 更新相册请求体(部分更新:仅提交的字段被修改)。
///
/// 契约 `components.schemas.AlbumUpdateRequest`(operationId
/// `updateAlbum`,minProperties 1)。translations 为全量替换(须包含
/// defaultLocale 对应条目);[endAt] 传 null 清除——「未提供」与
/// 「显式清除」经 [clearEndAt] 区分(Dart 可空字段无法自带该语义)。
class AlbumUpdateRequest {
const AlbumUpdateRequest({
this.translations,
this.defaultLocale,
this.startAt,
this.endAt,
this.clearEndAt = false,
this.timezone,
this.uploadEnabled,
this.publicEnabled,
this.downloadEnabled,
this.originalDownloadEnabled,
});
/// 全量替换多语言内容(须包含 defaultLocale 对应条目)。
final List<AlbumTranslation>? translations;
/// 缺省语言。
final String? defaultLocale;
/// 活动开始时间。
final DateTime? startAt;
/// 活动结束时间;与 [clearEndAt] 互斥使用。
final DateTime? endAt;
/// 显式清除 endAt(序列化为 `"endAt": null`)。
final bool clearEndAt;
/// 活动 IANA 时区。
final String? timezone;
/// 是否允许新上传任务。
final bool? uploadEnabled;
/// 是否公开可见。
final bool? publicEnabled;
/// 允许下载浏览版本。
final bool? downloadEnabled;
/// 允许下载原图。
final bool? originalDownloadEnabled;
Map<String, dynamic> toJson() => {
if (translations != null)
'translations':
translations!.map((AlbumTranslation t) => t.toJson()).toList(),
if (defaultLocale != null) 'defaultLocale': defaultLocale,
if (startAt != null) 'startAt': startAt!.toUtc().toIso8601String(),
if (endAt != null) 'endAt': endAt!.toUtc().toIso8601String(),
if (clearEndAt) 'endAt': null,
if (timezone != null) 'timezone': timezone,
if (uploadEnabled != null) 'uploadEnabled': uploadEnabled,
if (publicEnabled != null) 'publicEnabled': publicEnabled,
if (downloadEnabled != null) 'downloadEnabled': downloadEnabled,
if (originalDownloadEnabled != null)
'originalDownloadEnabled': originalDownloadEnabled,
};
}
/// 创建分类请求体。
///
/// 契约 `components.schemas.CategoryCreateRequest`(operationId
/// `createCategory`)。sortOrder 契约缺省 0——null 表示不提交(走服务端
/// 缺省)。
class CategoryCreateRequest {
const CategoryCreateRequest({this.sortOrder, required this.translations});
/// 排序序号(升序);null 表示不提交(服务端缺省 0)。
final int? sortOrder;
/// 分类多语言名称(minItems 1)。
final List<CategoryTranslation> translations;
Map<String, dynamic> toJson() => {
if (sortOrder != null) 'sortOrder': sortOrder,
'translations':
translations.map((CategoryTranslation t) => t.toJson()).toList(),
};
}
/// 更新分类请求体(部分更新;translations 为全量替换)。
///
/// 契约 `components.schemas.CategoryUpdateRequest`(operationId
/// `updateCategory`,minProperties 1)。
class CategoryUpdateRequest {
const CategoryUpdateRequest({this.sortOrder, this.translations});
/// 排序序号(升序)。
final int? sortOrder;
/// 全量替换多语言名称。
final List<CategoryTranslation>? translations;
Map<String, dynamic> toJson() => {
if (sortOrder != null) 'sortOrder': sortOrder,
if (translations != null)
'translations':
translations!.map((CategoryTranslation t) => t.toJson()).toList(),
};
}